引入热重载
React Native 的目标是为您提供最佳的开发者体验。其中很重要的一部分是您保存文件到看到更改所需的时间。我们的目标是使这个反馈循环在应用程序增长时也能保持在1秒以内。
我们通过以下三个主要功能接近这个理想状态:
- 使用 JavaScript 作为语言,因为它没有漫长的编译周期。
- 实现一个名为 Packager 的工具,将 ES6/Flow/JSX 文件转换为虚拟机可理解的普通 JavaScript。它被设计为一个服务器,在内存中保存中间状态以实现快速增量更改,并使用多核。
- 构建一个名为“实时重载”的功能,在保存时重新加载应用程序。
此时,开发人员的瓶颈不再是重新加载应用所需的时间,而是丢失应用的状态。一个常见的情况是开发一个距离启动屏幕有多个屏幕之远的功能。每次重新加载时,您都必须一遍又一遍地点击相同的路径才能返回到您的功能,这使得这个周期持续数秒。
热重载
热重载的理念是保持应用程序运行,并在运行时注入您编辑的文件的最新版本。这样,您不会丢失任何状态,这在调整 UI 时尤其有用。
一图胜千言。看看实时重载(当前)和热重载(新)之间的区别。
如果您仔细观察,您会发现可以从红框中恢复,并且还可以开始导入以前不存在的模块,而无需完全重新加载。
警告:由于 JavaScript 是一种非常注重状态的语言,热重载无法完美实现。实际上,我们发现当前的设置对于大量常见用例都运行良好,并且在出现问题时始终可以进行完全重载。
热重载在 0.22 版本中可用,您可以启用它:
- 打开开发者菜单
- 点击“启用热重载”
实现概述
现在我们已经了解了为什么需要它以及如何使用它,有趣的部分开始了:它实际是如何工作的。
热重载是基于一项功能——热模块替换(或 HMR)构建的。它最初由 webpack 引入,我们将其实现到了 React Native 打包器中。HMR 使打包器监听文件更改并将 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`,它被配置为以与您在使用 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 不知道如何接受自身,因此它会查找所有引用它的模块并尝试接受它们。最终,流程会到达单个 store,即 `configureStore` 模块,它将接受 HMR 更新。
结论
如果您有兴趣帮助改进热重载,我鼓励您阅读Dan Abramov 关于热重载未来的文章并贡献力量。例如,Johny Days 将使其支持多个已连接的客户端。我们依赖大家来维护和改进此功能。
借助 React Native,我们有机会重新思考构建应用程序的方式,以创造出色的开发者体验。热重载只是其中一部分,我们还能做些什么疯狂的“黑科技”来做得更好呢?