React Native 热加载(Hot Reload)原理简介

@author ASCE1885的 Github 简书 微博 CSDN 知乎

未标题-1.png-1123.9kB
未标题-1.png-1123.9kB

广而告之时间:我的新书《Android 高级进阶》(https://item.jd.com/10821975932.html在京东开始预售了,欢迎订购!

TB2MnqlXH1J.eBjSszcXXbFzVXa_!!1020536390.png-39kB
TB2MnqlXH1J.eBjSszcXXbFzVXa_!!1020536390.png-39kB

最近发现 React Native 官方博客上面这篇介绍 Hot Reload 原理的文章,仔细阅读了一下,顺便翻译为中文,以飨读者。本文不少内容加入了译者的理解,并没有严格字对字翻译,英文水平不错的同学可以直接阅读原文[1]

React Native 的设计目标是为开发者提供最好的开发体验,其中很重要的一点就是尽量缩短文件修改后到看到修改所产生的变化之间所需的时间。我们的目标是将这个循环所需的时间降到 1 秒以下,即使你应用的功能和体积在不断的膨胀。

通过下面三个主要特性我们离目标越来越近:

  • 基于 Javascript 进行开发,避免了长时间的代码编译过程
  • 实现了一个名为 Packager 的工具,用来将 es6/flow/jsx 文件转换成虚拟机可以理解的普通 JavaScript 语言。Packager 被设计为一个服务器,从而能够在内存中保存当下的状态,实现快速的增量更新,同时可以使用系统的多核 CPU 提高性能。
  • 新增了一个名为实时加载(Live Reload)的特性,实现保存代码修改后自动重新加载 APP

到这一步,对开发者而言瓶颈已然不是重新加载 APP 所需花费的时间,而是如何保持 APP 的状态。一个典型的场景是如果当前页面是一个三级页面,那么每次重新加载后,我们都要通过重复之前的多次点击才能再次进入这个三级页面,而这将花费好几秒的时间。

热加载

热加载的思想是运行时动态注入修改后的文件内容,同时不中断 APP 的正常运行。这样,我们就不会丢失 APP 的任何状态信息,尤其是 UI 页面栈相关的。

关于实时加载(Live Reload)和热加载(Hot Reload)的区别,可以参见YouTube上面这个视频[2],其中关键的区别在于实时加载应用更新时需要刷新当前页面,可以看到明显的全局刷新效果,而热加载基本上看不出刷新的效果,类似于局部刷新。

热加载在 React Native 0.22 中开始引入,摇动手机打开 RN 的开发者菜单,点击 Enable Hot Reloading 即可开启。

核心实现原理

热加载的基础是模块热替换(HMR,Hot Module Replacement[3]),HMR 最开始是由 Webpack 引入的,我们在 React Native Packager 中也实现了这个功能。HMR 使得 Packager 可以监控文件的改动并发送 HMR 更新消息(HMR update)给包含在 APP 中的一层很薄的 HMR 运行时(HMR runtime)。

简而言之,HMR 更新消息包含 JS 模块中发生变化的代码,当 HMR 运行时接收到这个消息,就使用新的代码替换旧的代码,流程如下图所示:

HMR 更新消息中除了包含发生改动的代码之外,还需要包含其他一些信息,因为如果只有发生改动的代码,HMR 运行时不足以实现代码替换。原因在于模块系统可能已经缓存了我们想要替换的模块的 exports,比如你的应用有如下两个模块,其中 log 模块的功能是打印 time 模块提供的日期信息,代码如下所示:

// 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;

当应用打包(bundled)后,React Native 会使用 __d 函数将所有的模块注册到模块系统中。在我们这个例子 APP 中,可以看到下面所示 log 模块的 __d 定义:

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

这个函数调用将每个模块的代码包裹进一个匿名函数中,我们通常称之为工厂函数。模块系统运行时会跟踪每个模块的工厂函数,看它是否已经被执行,以及执行的结果(exports)。当一个模块被 required 之后,模块系统会判断当前模块的工厂函数是否已经执行过,如果是则返回缓存的 exports,否则调用工厂函数并保存结果到缓存中。

因此,当你启动应用并 require log 模块时,这时由于 logtime 这两个模块的工厂函数都还没有执行过,因此不存在 exports 缓存。接着用户修改 time 模块添加返回 MM/DD 形式的日期,代码如下:

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

module.exports = bar;
  • 步骤一:Packager 会将 time 模块的新代码发送给 HMR 运行时
  • 步骤二:当 log 模块最终被 required 且 exported 函数被执行到时,它会随着 time 模块的变化而变化

整个过程如下图所示:

让我们假设 log 模块以最顶层的方式 require time 模块:

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

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

module.exports = log;
  • 步骤一:当 logrequired 时,HMR 运行时会缓存它和 time 的 exports
  • 步骤二:接着当 time 被修改后,HMR 进程不能简单的替换完 time 的代码后就结束运行,否则当 log 被执行时,它会使用到 time 的缓存,也就是旧代码
  • 步骤三:为了实现 log 可以得到 time 的最新修改,我们需要清空缓存的 exports,因为 log 所依赖的模块有至少一个发生了改变
  • 步骤四:最后,当 log 被再次 required,它的工厂函数会被执行并 require time 模块从而得到最新的代码。

整个过程如下图所示:

HMR API

React Native 中的 HMR 通过引入 hot 对象实现对模块系统的继承,这个 API 基于 Webpack 的基础上。hot 对象对外暴露了一个名为 accept 的函数,它使得开发者可以定义一个回调函数,当模块需要热交换(hot swapped)时会执行到。例如,我们如下所示修改 time 的代码,每次我们保存 time 模块时,可以在控制台看到 time changed 这句日志:

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

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

module.exports = time;

需要注意的是,只有在很少数情况下你才需要手动调用这个 API,热加载在大多数情况下已经帮我们实现了。

HMR Runtime

如前所见,有时仅仅 accept HMR 更新是不够的,因为模块 A 如果依赖一个经过热交换的模块 B,且此时模块 A 可能已经执行过且缓存了所有的 imports。例如,假设一个 movies 应用的依赖树有一个最顶层的 MovieRouter 模块,它依赖于 MovieSearchMovieScreen 两个页面,而这两个页面又依赖于前面介绍过的 logtime 模块:

当用户访问了 MovieSearch 页面而还没有访问 MovieScreen 页面,此时除了 MovieScreen 模块之外,其他模块的 exports 都被缓存了。这时 time 模块代码发生了改动,HMR 运行时将会清空 log 模块的 exports 缓存,并加载 time 的改动。接着运行时会向上递归直到所有的父模块被 accepted。也就是运行时会获取所有依赖于 log 的模块并尝试 accept 它们。当尝试 accept MovieScreen 模块时会失败,因为这个模块还没有被 required;当尝试 accept MovieSearch 模块时,运行时将会清空它缓存的 exports 并继续递归执行它的父模块,最后执行到最顶层的 MovieRouter 模块时结束。

为了遍历上面的依赖树,运行时在 HMR 更新时从 Packager 获取反转后的依赖树信息,在上面这个例子中,获取到的反转依赖树如下,是一个 JSON 对象:

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

React Components

想要实现 React Components 的热加载并不是一件容易的事情,因为我们不能简单的使用新的 Component 替换旧的,这样会丢失它的状态。对于 React 的 Web 应用,Dan Abramov[4] 实现了一个名为 React Hot Loader[5] 的 babel 转换器,它使用 Webpack 的 HMR API 来解决这个问题。简而言之,他的解决方案是在转换阶段为每个 React Component 创建一个代理,这些代理保存了 Component 的状态,并将生命周期函数委托给实际的 Components,也就是我们执行热加载的 Components。

除了创建代理 Component,React Hot Loader 转换器还通过一段代码定义了 accept 函数,强制 React 重新渲染这个 Component。这样,我们实现了热加载渲染的代码且不丢失应用的状态。

React Native 默认使用的转换器[6]使用 babel-preset-react-native,它跟前面介绍的 React Web 应用一样的方式使用[7] react-transform

Redux Stores

想要在 Redux[8] stores 中开启热加载,只需像前面介绍的基于 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,客户端会接收到 accept 这个 reducer 的代码,这时,客户端将会发现 reducer 不知道如何 accept 自身。因此它将会查询依赖它的所有模块并尝试 accept 他们。最终,数据会流向单一的 store:configureStore,由它来 accept HMR 的更新。

总结

如果你对于改善热加载感兴趣的话,我建议你阅读 Dan Abramov 关于热加载的未来[9]这篇文章并作出自己的贡献。例如,Johny Days 正在尝试使热加载支持多个 HMR 客户端[10],我们有赖于你来维护和改进这个特性。

React Native 让我们有机会重新思考在构建 APP 时如何提供更好的开发体验,热加载只是冰山一角,我们还有哪些其他的 hacks 可以更进一步提高开发体验呢?有待你来发掘和贡献!

欢迎关注我的微信公众号,专注与原创或者分享 Android,iOS,ReactNative,Web 前端移动开发领域高质量文章,主要包括业界最新动态,前沿技术趋势,开源函数库与工具等。


  1. http://facebook.github.io/react-native/blog/

  2. https://youtu.be/2uQzVi-KFuc

  3. https://webpack.github.io/docs/hot-module-replacement-with-webpack.html

  4. https://twitter.com/dan_abramov

  5. http://gaearon.github.io/react-hot-loader/

  6. https://github.com/facebook/react-native/blob/master/packager/transformer.js#L92-L95

  7. https://github.com/facebook/react-native/blob/master/babel-preset/configs/hmr.js#L24-L31

  8. http://redux.js.org/

  9. https://medium.com/@dan_abramov/hot-reloading-in-react-1140438583bf#.jmivpvmz4

  10. https://github.com/facebook/react-native/pull/6179

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容