引入热重载
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`,它可以跳过,因为它还没有被 require。对于 `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`,其配置方式与您在使用 webpack 的 React Web 项目中使用 `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 不知道如何接受自身,因此它会查找所有引用它的模块并尝试接受它们。最终,流程将到达单个存储,即 `configureStore` 模块,它将接受 HMR 更新。
结论
如果您有兴趣帮助改进热重载,我鼓励您阅读Dan Abramov 关于热重载未来的文章并做出贡献。例如,Johny Days 将使其与多个连接的客户端协同工作。我们期待您共同维护和改进此功能。
借助 React Native,我们有机会重新思考构建应用程序的方式,以创造出色的开发者体验。热重载只是其中一部分,我们还能做些什么疯狂的“黑科技”来做得更好呢?