跳到主要内容

引入热重载

·阅读时长 9 分钟
Martín Bigio
Instagram 软件工程师

React Native 的目标是为您提供最佳的开发者体验。其中很重要的一部分是您保存文件到看到更改所需的时间。我们的目标是使这个反馈循环在应用程序增长时也能保持在1秒以内。

我们通过以下三个主要功能接近这个理想状态:

  • 使用 JavaScript 作为语言,因为它没有漫长的编译周期。
  • 实现一个名为 Packager 的工具,将 ES6/Flow/JSX 文件转换为虚拟机可理解的普通 JavaScript。它被设计为一个服务器,在内存中保存中间状态以实现快速增量更改,并使用多核。
  • 构建一个名为“实时重载”的功能,在保存时重新加载应用程序。

此时,开发人员的瓶颈不再是重新加载应用所需的时间,而是丢失了应用的状态。一个常见场景是处理一个离启动屏幕有多个屏幕距离的功能。每次重新加载时,你都必须一遍又一遍地点击相同的路径才能回到你的功能,这使得周期长达数秒。

热重载

热重载的理念是保持应用程序运行,并在运行时注入您编辑的文件的最新版本。这样,您不会丢失任何状态,这在调整 UI 时尤其有用。

一图胜千言。看看实时重载(当前)和热重载(新)之间的区别。

如果您仔细观察,您会发现可以从红框中恢复,并且还可以开始导入以前不存在的模块,而无需完全重新加载。

警告:由于 JavaScript 是一种非常注重状态的语言,热重载无法完美实现。在实践中,我们发现当前的设置对于大量常见用例都运行良好,并且在出现问题时始终可以使用完全重载。

热重载在 0.22 版本中可用,您可以启用它:

  • 打开开发者菜单
  • 点击“启用热重载”

实现简介

现在我们已经了解了为什么需要它以及如何使用它,有趣的部分开始了:它实际是如何工作的。

热重载建立在热模块替换 (HMR) 功能之上。它最初由 webpack 引入,我们将其实现到了 React Native Packager 中。HMR 使 Packager 监视文件更改并将 HMR 更新发送到应用程序中包含的一个轻量级 HMR 运行时。

简而言之,HMR 更新包含已更改的 JS 模块的新代码。当运行时收到它们时,它会将旧模块的代码替换为新代码。

HMR 更新不仅仅包含我们想要更改的模块代码,因为仅仅替换它不足以让运行时识别更改。问题是模块系统可能已经缓存了我们想要更新的模块的导出。例如,假设你有一个由这两个模块组成的应用程序

// log.js
function log(message) {
const time = require('./time');
console.log(`[${time()}] ${message}`);
}

module.exports = log;
// time.js
function time() {
return new Date().getTime();
}

module.exports = time;

模块 `log` 打印提供的消息,包括模块 `time` 提供的当前日期。

当应用程序打包时,React Native 使用 `__d` 函数在模块系统上注册每个模块。对于这个应用程序,在许多 `__d` 定义中,会有一个 `log` 的定义。

__d('log', function() {
... // module's code
});

此调用将每个模块的代码封装在一个匿名函数中,我们通常称之为工厂函数。模块系统运行时会跟踪每个模块的工厂函数,它是否已执行,以及执行结果(导出)。当需要一个模块时,模块系统要么提供已缓存的导出,要么首次执行该模块的工厂函数并保存结果。

所以假设你启动了你的应用程序并需要 `log`。此时,`log` 和 `time` 的工厂函数都还没有执行,所以没有导出被缓存。然后,用户修改 `time` 以便返回 `MM/DD` 格式的日期

// time.js
function bar() {
const date = new Date();
return `${date.getMonth() + 1}/${date.getDate()}`;
}

module.exports = bar;

Packager 会将 time 的新代码发送到运行时(步骤 1),当 `log` 最终被 require 时,导出的函数会与 `time` 的更改一起执行(步骤 2)。

现在,假设 `log` 的代码将 `time` 作为顶层 `require`。

const time = require('./time'); // top level require

// log.js
function log(message) {
console.log(`[${time()}] ${message}`);
}

module.exports = log;

当需要`log`时,运行时将缓存其导出和`time`的导出(步骤1)。然后,当`time`被修改时,HMR进程不能简单地在替换`time`的代码后结束。如果这样做,当`log`执行时,它将使用`time`的缓存副本(旧代码)来执行。

为了让 `log` 接收 `time` 的更改,我们需要清除其缓存的导出,因为其依赖的模块之一已被热交换(步骤 3)。最后,当 `log` 再次被请求时,其工厂函数将被执行,请求 `time` 并获取其新代码。

HMR API

React Native 中的 HMR 通过引入 `hot` 对象来扩展模块系统。此 API 基于 webpack 的 API。`hot` 对象公开了一个名为 `accept` 的函数,允许你定义一个回调,当模块需要热交换时将执行该回调。例如,如果我们按如下方式更改 `time` 的代码,每次保存 `time` 时,我们都会在控制台中看到“time changed”

// time.js
function time() {
... // new code
}

module.hot.accept(() => {
console.log('time changed');
});

module.exports = time;

请注意,您很少需要手动使用此 API。对于大多数常见用例,热重载应该开箱即用。

HMR 运行时

正如我们之前看到的,有时仅接受 HMR 更新是不够的,因为使用正在热交换的模块的模块可能已经执行并且其导入已缓存。例如,假设电影应用程序示例的依赖树有一个顶层 `MovieRouter`,它依赖于 `MovieSearch` 和 `MovieScreen` 视图,而这些视图又依赖于前面示例中的 `log` 和 `time` 模块

如果用户访问电影搜索视图而不是另一个视图,除了 `MovieScreen` 之外的所有模块都将缓存导出。如果对模块 `time` 进行了更改,运行时将不得不清除 `log` 的导出,以便它能接收 `time` 的更改。这个过程不会就此结束:运行时将递归地重复这个过程,直到所有父级都被接受。因此,它将获取依赖于 `log` 的模块并尝试接受它们。对于 `MovieScreen`,它可以跳过,因为它还没有被请求。对于 `MovieSearch`,它将不得不清除其导出并递归处理其父级。最后,它将对 `MovieRouter` 执行相同的操作并在那里结束,因为没有模块依赖于它。

为了遍历依赖树,运行时从 Packager 接收 HMR 更新中的反向依赖树。对于这个例子,运行时将接收一个像这样的 JSON 对象:

{
modules: [
{
name: 'time',
code: /* time's new code */
}
],
inverseDependencies: {
MovieRouter: [],
MovieScreen: ['MovieRouter'],
MovieSearch: ['MovieRouter'],
log: ['MovieScreen', 'MovieSearch'],
time: ['log'],
}
}

React 组件

React 组件在热重载下工作起来有点困难。问题在于我们不能简单地用新代码替换旧代码,因为我们会丢失组件的状态。对于 React Web 应用程序,Dan Abramov 实现了一个 babel 转换,它使用 webpack 的 HMR API 来解决这个问题。简而言之,他的解决方案通过在转换时为每个 React 组件创建一个代理来实现。这些代理保存组件的状态并将生命周期方法委托给实际的组件,而这些组件正是我们热重载的组件

除了创建代理组件外,转换还定义了 `accept` 函数,其中包含一段强制 React 重新渲染组件的代码。通过这种方式,我们可以在不丢失任何应用程序状态的情况下热重载渲染代码。

React Native 附带的默认转换器使用 `babel-preset-react-native`,它被配置为以与在 React web 项目中使用 webpack 相同的方式使用 `react-transform`。

Redux Stores

要启用 Redux 存储的热重载,您只需像在使用 webpack 的 Web 项目中一样使用 HMR API:

// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../reducers';

export default function configureStore(initialState) {
const store = createStore(
reducer,
initialState,
applyMiddleware(thunk),
);

if (module.hot) {
module.hot.accept(() => {
const nextRootReducer = require('../reducers/index').default;
store.replaceReducer(nextRootReducer);
});
}

return store;
};

当你更改一个 reducer 时,接受该 reducer 的代码将被发送到客户端。然后客户端会意识到 reducer 不知道如何接受自己,所以它会查找所有引用它的模块并尝试接受它们。最终,流程会到达单个 store,即 `configureStore` 模块,它将接受 HMR 更新。

结论

如果您有兴趣帮助改进热重载,我鼓励您阅读Dan Abramov 关于热重载未来的文章并做出贡献。例如,Johny Days 将使其与多个连接的客户端协同工作。我们依赖大家来维护和改进此功能。

借助 React Native,我们有机会重新思考构建应用程序的方式,以创造出色的开发者体验。热重载只是其中一部分,我们还能做些什么疯狂的“黑科技”来做得更好呢?