React Actions Recorder 的模块热替换(HMR)

652 查看

看到微博上 vue-loader 开始支持代码热替换的消息
真让人坐不住, 赶紧翻代码看下, 结果看不懂
现实的压力还是在的, react-hot-loader 已经不推荐使用了
作者搞了 React Transform, 而且针对 Babel 优化, 整套新的东西
而简聊用 actions-recorderreact-hot-loader 多少有些风险
actions-recorder 更好调试是很有必要的

关于 Webpack HMR

然而问题的重点是, Webpack 是跟很强大但文档挺难懂的项目
周末我大致翻了一些文档, 晚上做了写尝试, 大致达成了基本的功能
https://github.com/webpack/docs/wiki/hot-module-replacement
http://jlongster.com/Backend-Apps-with-Webpack--Part-III
实际上一个 API 就能解决的事情, 太简单了.. 只是门槛真高啊

细节强烈建议读上边两篇文档, 到底还是花了很长篇幅解释的
简单的似乎, 大致上就是 Webpack 当中模块实际上是 module tree
这个和 DOM tree 很类似, update 在其中就是一个个事件
update 事件会冒泡, 可以通过代码捕捉和停止冒泡
如果冒泡到根节点, 那么说明代码没有处理 update 事件, 于是刷新页面

  • module.hot.accept

Wiki 上的这个服务端例子, 我觉得最能说清 Webpack 是怎么做的
不过这个例子怎么运行我就完全不知道了... 文档没写清楚
注意其中的 module.hotmodule.hot.accept 两个 API
前者只是状态, 无视, 而后者就是用来捕捉 update 事件进行处理的全部

var requestHandler = require("./handler.js");
var server = require("http").createServer();
server.on("request", requestHandler);
server.listen(8080);

// check if HMR is enabled
if (module.hot) {
    // accept update of dependency
    module.hot.accept("./handler.js", function() {
        // replace request handler of server
        server.removeListener("request", requestHandler);
        requestHandler = require("./handler.js");
        server.on("request", requestHandler);
    });
}

可以看到代码里指明了 ./handle.js 模块的更新需要被接受
然后, update 发生时, 原有的旧代码 requestHandler 先移除掉
然后重新调用 require 加载新的代码, 然后再重新绑定一次
内部的操作, Webpack 自动完成的, 我按照这个写法也基本没遇到问题
总之这样就完成了模块热替换的实现

  • module.hot.dispose

这个 API 说实话我还不会用, 现在只知道大概的意思
单单上边的方案只适合处理纯函数的代码, 也就是没有内部状态
如果一个模块, 比如说会往 DOM 上挂载节点的,
那么模块移除的时候它就应该自动把挂上去的节点带走销毁
.dispose() 方法大致上就是处理这样一个场景用的, 看代码

// addStyleTag(css: string) => HTMLStyleElement
var addStyleTag = require("./addStyleTag");

var element = addStyleTag(".rule { attr: name }");
module.exports = null;

// check if HMR is enabled
if(module.hot) {

    // accept itself
    module.hot.accept();

    // removeStyleTag(element: HTMLStyleElement) => void
    var removeStyleTag = require("./removeStyleTag");

    // dispose handler
    module.hot.dispose(function() {
        // revoke the side effect
        removeStyleTag(element);
    });
}

当然, 实际尝试处理 dispose 我估计还会遇到各种坑, 不像折腾了
还好这个 API 尤雨溪大神也不用, 我就先不要趟浑水了
还好 React 里的东西主要都是一些纯函数的编码风格主导的, 私有状态不多

此外在 module.hot 还绑定了一些能访问内部状态和操作的 API
具体情况比较复杂, 我现在完全不知道如何下手
如果很有兴趣或者很无聊, 可以继续看看两个著名的 loader 怎么搞的:
https://github.com/webpack/style-loader/blob/master/index.js
https://github.com/gaearon/react-hot-loader/blob/master/index.js

Loader 的一些处理

上边的代码会有个不足, 就是 module.hot 相关的代码直接在源码里了
其实不属于应用的代码, 只是专门处理模块热替换的, 有点难看
前面这些 loader 考虑到了这一点, 于是会自动生成代码进去
也就是比如一个 React 组件的 module.exports 是个组件,
那么这个组件前面后面就爆包裹一段生成的代码, 用来调用 accept 方法
确实是个很有效果的方法

不用这样的办法, 我在 Amok 的文档上见到一种
因为 Amok 是通过 Remote Debugging API 动态替换 JavaScript 的
就是说任何状态很大程度上能够保留的, 只是更新函数代码
所以它可以在代码替换完成后, 直接顶层调用一次 render 搞定
http://amokjs.com/getting_started.html

window.addEventListener('patch', function(event) {
  React.render(app);
});

我在现有的 actions-recorder 方案中模拟了一下
隐隐会有一些问题, 就是组件内部状态我未必能确保
这个更多取决于 React 内部机制怎样处理 render 过程具体的更新
反过来看 react-hot-loader 的方案会是更有保障的
就是每个组件在更新时类方法都单独更新了一遍, 同时手动维护好 state
只是代码量真心不小, 也没有讲解, 我并不知道具体细节如何

代码

actions-recorder 中为了支持热替换, 我增加了一个方法
hotSetup, 和 setup 类似传入对象配置 schemaupdater
代码我觉直接复制文件了, 应该能看懂... 也就是代码多复制了一遍嘛:

React = require 'react'
recorder = require 'actions-recorder'
ReactDOM = require 'react-dom'
Immutable = require 'immutable'
require('volubile-ui/ui/index.less')

# 正常引用 schema 和 updater
schema = require './schema'
updater = require './updater'
Page = React.createFactory require './app/page'

# 第一次初始化
recorder.setup
  initial: schema.store
  updater: updater

# 第一次生成 render 函数, 以及做绑定
render = (store, core) ->
  ReactDOM.render Page({store, core}), document.querySelector('.demo')
recorder.request render
recorder.subscribe render

# 开始处理模块热替换, 特征代码, 很容易认出来吧
if module.hot
  # 声明两个入口文件
  module.hot.accept ['./updater', './schema'], ->
    # 重新调用新的模块
    schema = require './schema'
    updater = require './updater'
    # 这次用 hotSetup 传入参数, hotSetup 会强制创建新的 store
    recorder.hotSetup
      initial: schema.store
      updater: updater
  # 组件也要处理一下, 跟上边逻辑有点区别
  module.hot.accept ['./app/page'], ->
    # 重新调用新的组件
    Page = React.createFactory require './app/page'
    # 先解绑旧的 render 函数, 同时生成新的 render 函数
    # ... 考虑到变量是可以修改的, 下面引用的 Page 是新的也许不用写那么多
    recorder.unsubscribe render
    render = (store, core) ->
      ReactDOM.render Page({store, core}), document.querySelector('.demo')
    # 新的 render 函数先调用一次更新下界面, 然后重新监听
    recorder.request render
    recorder.subscribe render

这样的代码, 实现的效果就是, updater schema Page 代码可以热替换
updater 代码的更新在热替换后可以马上从回调的 store 中体现
schema 更新会更新 actions-recorder 初始状态, 也是更新 store
Page 更新会引起整个 DOM tree 重新渲染, 就是 DOM 的局部更新
初步的 Demo 已经运行正常, 复杂场景还需要多加试验

然后 actions 可能涉及到不少的代码, 我发现也可以试着改一下
首先 todo.coffee 文件里写把 actions 需要的方法写好
然后用这个文件... 其实就是写两遍啦, 都不需要解释了
由于 JavaScript 数据可变, 后面的 exports 就都能访问到新的代码了

todo = require './todo'

exports.create = todo.create
exports.update = todo.update
exports.toggle = todo.toggle
exports.archive = todo.archive

if module.hot
  module.hot.accept ['./todo'], ->
    todo = require './todo'

    exports.create = todo.create
    exports.update = todo.update
    exports.toggle = todo.toggle
    exports.archive = todo.archive

那么用视频演示下:

http://www.tudou.com/programs/view/3zCKPo6a1ZU/?phd=99

大致就这样吧, 后面的开发当中我再想法子深入一下
哪位有资源希望也能共享, Webpack 这个文档看着太累了

感想

目前代码热替换比较惊人的, 除了 Webpack 还有 Amok 和 Elm, Figwheel
Amok 是用 Chrome DevTools 内部 API, 对现成的大项目项目支持度存疑
后边两个是函数式社区超前而且高大上的玩意, 我只能看看视频了解下
另外 Redux 作者搞的那一整套东西, 我也就望而生畏一下了

首先热替换带来的界面开发的效率提高是有目共睹的, 回想下改 CSS
另一方面, 函数式的理念让代码热替换变得可行甚至实用
我想这也会极大的改变大家理解程序, 理解图形应用的方式
对于程序的抽象, 可以远远超出二进制指令用 subroutine 整合的抽象
而是程序执行的 tree 可以分为函数, 状态, 还有 IO 几个变和不变的部分,
某一时刻代码修改了, 这棵 tree 其实可以快速更新为等效的新的 tree
就是 React 更新 DOM 一样, 某些场景理解对了, 更新就非常巧妙

开发效率不是件小事, 后续事情还很多, 我文章先结尾了
照例加个公司招聘邮箱 <hr@teambition.com>