文:萝卜(沪江金融前端开发工程师)javascript
本文原创,转载请注明做者及出处html
若是你使用 React ,你能够在各个工程里面看到 Dan Abramov 的身影。他于 2015 年加入 facebook,是 React Hot Loader 、React Transform、redux-thunk、redux-devtools 等等的开发者。一样也是 React、Redux、Create-React-App 的联合开发者。从他的签名 Building tools for humans. 或许代表了他想打造高效的开发环境以及调试过程。前端
做为 Dan 的小迷妹,如他说 is curious where the magic comes from。这篇文章会带大家去了解 React Hot Loader 的由来,它实现的原理,以及在实现中遇到的问题对应的解决方法。也许你认为这篇文章太过于底层,对平常的业务并无帮助,但但愿你和我同样能经过了解一个实现获得乐趣,以及收获一些思路。java
Dan 在本身的文章里面说到。React Hot Loader 起源一个来自 stackoverflow 上的一个问题 —— what exactly is hot module replacement in webpack,这个问题解释了 webpack 的 hot module replacement(下面简称 HMR)究竟是什么,以及咱们能够利用它作什么,Dan 当时想到也 React 能够和 webpack hot module 以一种有趣的方式结合在一块儿。node
因而他在 Twitter 上录制了一个简单的视频(请看下面),事实上视频中的实现依赖于它在 React 源代码里面插入了不少本身的全局变量。他本没期望到这个视频能带来多大的关注,但结果是他收到了不少点赞,而且粉丝狂增,他意识到必须以一个真正的工程去实现。react
大图请戳webpack
HMR 是属于 webpack 范畴内的实现,你能够在 webpack 的官方文档 看到如何开启它以及它提供的接口。若是你有印象,你会记得使用它须要
在 webpack config 或者 webpack-dev-server cli 里面指定开启 hot reloading 模式,而且在你的代码里写上 module.hot.accept(xxx)
。但 HMR 究竟是什么?咱们能够用一句话总结:当一个 import 进来的模块发生了变化,HMR 提供了一个接口让咱们使用 callback 回调去作一些事情。git
一个使用 HMR 实现自动刷新的 React App 像下面这样:github
// index.js var App = require('./App') var React = require('react') var ReactDOM = require('react-dom') // 像一般同样 render Root Element var rootEl = document.getElementById('root') ReactDOM.render(<App />, rootEl) // 咱们是否是在 dev 环境 ? if (module.hot) { // 当 App.js 更新了 module.hot.accept('./App', function () { // require 进来更新的 App.js 从新render var NextApp = require('./App') ReactDOM.render(<NextApp />, rootEl) }) }
请注意,这个实现没有使用 React Hot Loader 或者 React Transform 或者任何其余的,这仅仅是 webpack 的HMR 的 api。而这里的 callback 回调函数固然是 re-render 咱们的 app。web
得益于 HMR API 的设计,在嵌套的组件也能实现更新。若是一个模块没有指明如何去更新本身,那么引入这个模块的另外一个模块也会被包含在热更新的 bundle 里,这些更新会”冒泡“,直到某个 import 它们的模块 "接收" 更新。若是有些模块最终没有被"接受",那么热更新失败,控制台会打印出警告。为了“接受”更新,你只须要调用 module.hot.accept('./name', callback)
。
由于咱们在 index.js 里的接受了 App.js 的更新 ,这使得咱们隐性的接受了全部从 App.js 引入的全部模块(component)的更新。打个比方,假如我编辑了 Button.js 组件,而它被 UserProfile.js 以及 Navbar.js import, 而这两个模块都被 App.js import 引入了。由于 index.js import 了 App.js,而且它包含了 module.hot.accept('./App', callback)
,Webpack 会自动产生一个包含以上全部文件的 “updated bundle”, 而且运行咱们提供的 callback。
你觉得 hot reloading 就到此为止了吗,固然远远不够 😉 。
当咱们的 App.js 更新,其实是有个新的 App.js 用 script 标签注入到 html, 而且从新执行了一次。此时新生成的 component 和以前的是一个组件的不一样版本,它们是不一样版本的同一个组件,可是 NextApp !== App。
若是你了解 React ,你会知道当下一个 component 的 type 和以前的不同,它会 unmount 以前那个。这就是为何 state 和 DOM 会被销毁。
在解决 state 保留的问题上,有人认为若是工程依赖一个单一的 state 树,那没有必要费大精力去保留组件自身的 state。由于在这种类型的 app 里面咱们关注的更多的是全局的这个 state 树,而去保存这个全局的 state 树是很容易作到的,好比你能够把它保存到 localstorage里面,当 store 初始化的时候你去读取它,这样的话连刷新都不会丢失状态。
Dan 接受了这个意见,而且在本身的文章里面总结,若是你使用 redux ,而且主要的状态保存在 redux 的 store 上,这时也许你不须要使用 React-Hot-Loader。
但他并无由于仅仅 有些人 可能不须要用到而放弃了 React-Hot-Loader。这才有了下文 😉 。
当你从上面了解了为何 DOM 和 state 会丢失,也许你就会 和 Dan 同样想到了两种方法。
找到一种方式把 React 的实例和 Dom nodes 以及 state 分离,建立一个新组件的新实例,而后用一种方式把它递归地和现有的 Dom 和 state 结合在一块儿。
另一种,代理 component 的 type,这样能让 React 认为 type 没有变。事实上每次 hot update 实现引用的是新的 component type。
第一种方式看上去好一点,可是 React 暂时没有提供能够分离(聚合)state 以及不销毁 DOM、不运行生命周期去替换一个实例。即便深刻到使用 React 的私有 API 达到这个目的,采用第一个方案任然面临着一些细微的问题。
好比,React components 常常 在 componentDidmount 时候订阅 Flux stores 或者其余数据源。即便咱们作到不销毁 Dom 以及 state, 偷偷地用一个新的实例替换旧的实例,旧的实例仍然会继续保持订阅,而新的实例将不会订阅。
结论是,若是 React 的 state 的订阅是申明式,而且独立于生命周期以外,或者 React 没有那么依赖 class 和 instance, 第一个方法才可行。这些也许会出如今之后的 React 版本里,可是如今并无。
因而 Dan 采用了第二种,这也是以后的 React Hot Loader 和 React Transform 所使用的到技巧。
为此,Dan 创建了一个独立的工程(react-proxy)去作 proxy,你能够在这里 看到它。create-proxy 只是一个底层的工程,它不依赖 wepback 也不依赖 babel。React Hot Loader 和 React Transform 依赖它,它把 React Component 包装到一个个 proxy 里面,这些 “proxy” 只是些 class, 它们表现的就像你本身的class,可是提供你一些钩子让你能对 class 注入新的实现方法,这样至关于让一个已经存在的实例表现的像新的 class,从而不会销毁 state 和 DOM。
Dan 首先所作的是在 wepback 的 loader 里面 proxy。
补充,不少人认为 React Hot Loader 不是一个 “loader”,由于它只是实现 hot reloading 的。这是一个广泛的误解😀。
之因此叫 “loader” 是由于 webpack 是这么称呼它,而其余 bundlers(打包器)称呼为 “transform”。打个比方,json-loader 把JSON 文件 “transform” 成 javascript modules,style-loader 把 CSS 文件 “transform” 成 js code 而后把它们以 stylesheets 的形式注入。
而关于 React Hot Loader 你能够在这里 看到,在编译的时候它经过 export 找到 component,而且“静默” 的包裹它,而后 export 一个代理的 component 取而代之原来的。
经过 module.exports 去寻找 components 开始听上去是合理的。开发者们常常把每一个组件单独储存在一个文件,天然而然组件将会被exported。然而,随着时间变化,React 社区发生了一些变化,采起了一些新的写法或者思想,这致使了一些问题。
随着高阶组件变得流行,你们开始 export 出来的是一个高阶组件,而不是实际上本身写的组件。 结果致使, React Hot Loader 没有“发现” module.export 里面包裹的组件,因此没有给它们建立 proxy。它们的 DOM 以及 local state 将会被在这些文件每次修改后销毁。这尤为影响像 React JSS 同样利用高阶组件实现样式。
React 0.14 引进了函数式组件,而且鼓励在一个文件里面最小化拆分组件。即便React Hot Loader 能检测到导出的组件,它也“看”不到那些未被导出的本地的component。因此这些component 将不会包裹在proxy里面,因此会致使在它以及它下面的子树丢失 DOM 以及 state。
这显然是使得从 module.exports
去找组件是不可靠的。
除了上面提到的从 module.exports
不可靠以外,初版的 React-Hot-Loader 还存在一些其余的问题。好比 webpack 的依赖问题,Dan 想作的是一个通用的工具,而不只限于 webpack,而如今的工具只是一个 webpack 的 loader。
虽然目前为止只有 webpack 实现了HMR, 可是一旦有其余的编译工具也实现了 HMR,那现有的 loader
如何集成到新的编译工具里面 ?
基于这些问题 Dan 曾经写过一篇 React-Hot-Loader 之死的文章,文章中提到虽然 React-Hot-Loader 获得了巨大的关注,而且有不少工程也采起了他的思想,他仍然认为这不是他所想要的。
此时 Babel
如浪潮通常忽然占领了整个 javascript 世界。Dan 意识到能够采用静态分析的方法去找到这些 component,而 babel 正好很适合作这些。不只如此,Dan 一样想作一个错误处理的方式,由于当 render() 方法报错的时候,此时组件会处于一种无效状态,而此时 hot reload 是没办法工做的,Dan 想一块儿 fix 掉这个问题。
把 component 包裹在一个 proxy 里或者把 component render() 包裹在一个 try/catch 里,听上去都像 “一个函数接受一个component class 而且在它身上作些修改"。
那为何不创造一个 Babel plugin 在你的基准代码里去定位 React Component 而且包裹它们,这样就能够进行随意的 transform。
若是你在 github 去搜 React Transform ,你能够搜到 gearaon ( dan 在github上的名字,也是惟一一个不使用真名的帐号哦~) 几个工程。 这是由于在开始设定 Transform 实现的时候不肯定哪些想法最终会有实质做用,因此他拆分了 React Transform 为如下 5 个子工程:
这种模块化带了好处,同时也带来了弊端,弊端就是使用者在不清楚原理的状况下,不知道这些工程到底如何关联起来使用。而且这里有太多的概念暴露给了使用者, “proxies”, “HMR”, “hot middleware”, “error catcher”, 这使得用户感到很迷惑。
当你解决了这些问题,尽可能避免引入由解决它们带来的新的问题。
还记得当年 React-Hot-Loader 在高阶组件上面一筹莫展吗,它没办法经过 module.export
导出的,包裹在高阶组件里面的组件。而 React Transform 经过静态检查这些组件的生命去“fix”这个问题,寻找继承自
React.Component 或者使用 React.createClass() 申明的 class。
// React Hot Loader 找不到它 // React Transform 找获得它 class Counter extends Component { constructor(props) { super(props) this.state = { counter: 0 } this.handleClick = this.handleClick.bind(this) } handleClick() { this.setState({ counter: this.state.counter + 1 }) } render() { return ( <div className={this.props.sheet.container} onClick={this.handleClick}> {this.state.counter} </div> ) } } const styles = { container: { backgroundColor: 'yellow' } } // React Hot Loader 找到到它 // React Transform 找不到它 export default useSheet(styles)(Counter)
猜猜这里咱们遗漏了什么?被导出的 components! 在这个例子中,React Transform 会保留 Counter 的 state , hot reload 会改变
render() 和 handleClick() 这些方法,可是任何对 styles 的改变不会体现,由于它不知道 useSheet(styles)(Counter) 正好 return 一个 React component, 这个组件也须要被 proxy。
不少人发现了这个问题,当他们注意到他们在 redux 里面 selectors 以及 action creators 再也不会 hot reload。这是由于 React Transform 没有发现 connect() 返回一个组件,而后并无一个简单的方法去识别。
找到经过继承自 React.Component 或者使 React.createClass() 建立的class 不是很难 。然而,它可能出错,你也不想 带来误判。
随着React 0.14的发布,这个任务变得更加艰难。任何 functions,若是
return 出来的是一个有效的 ReactElement 那就多是一个组件。因为你不能确定,因此你不得不采用探索法。好比说,你可在判断在顶级做用域的 function,若是是以驼峰命名,使用JSX, 而且接受不超过两个以上(props 和 context)参数,那它多是个React component。这样会误判吗?是,可能会。
更糟糕的是,你必须让全部的 “transform” 去处理 classes 和 functions。若是React 在v16版本里面引进另一种 一种方式去声明组件呢,咱们将要重写全部的transform吗?
最后得出结论,用静态方法 包裹 组件至关复杂。你将要对 functions 和 classes 可能的 export 方式取使用各类方法去处理,包括 default 和 named 的 exports,function声明,箭头函数,class声明,class表达式,createClass() 形式调用,以及等等。每种状况你都须要用一种方法针对相同的变量或者表达式去绑定不一样的值。
想办法支持 functional components 是最多的提议, 我如今不会考虑在 React Transform 支持它,由于实现的复杂程度会给工程以及它的维护者带来巨大困难,而且可能因为一些边缘状况致使完全的破坏。
以上总结是出自 Dan 的一篇在medium上的文章,他称呼 React Hot Loader 是一个 Accidental Complexity,其中还提到它对 compile-to-js 语言 (其余经过编译转成JS的语言)的考虑,以及中途遇到的 babel 的问题等。文章中 Dan 代表他会在几个月内中止 React Transform 而使用一个新的工程代替,新的工程会解决大多数残留的问题,末尾给了一些提示在新工程里面须要作到的。在这篇文章的一个月后,React-Hot-Loader 3 release了,让咱们大体的过一下 3 的到底作了些什么。
在源码中找到而且包裹React components是很是难作到的,而且有多是破坏性的。这真的会破坏你的代码,但标记它们相对来讲是比较安全。好比咱们能够经过 babel-plugin 检查一个文件,针对顶层 class、function 以及 被 export 出来的模块在文件末尾作个标记:
class Counter extends Component { constructor(props) { super(props) this.state = { counter: 0 } this.handleClick = this.handleClick.bind(this) } handleClick() { this.setState({ counter: this.state.counter + 1 }) } render() { return ( <div className={this.props.sheet.container} onClick={this.handleClick}> {this.state.counter} </div> ) } } const styles = { container: { backgroundColor: 'yellow' } } const __exports_default = useSheet(styles)(Counter) export default __exports_default // 咱们 generate 的标记代码: // 在 *远端* 标记任何看上去像 React Component 的东西 register('Counter.js#Counter', Counter) register('Counter.js#exports#default', __exports_default) // every export too
register() 至少会判断传进来的值是否是一个函数,若是是,建立一个 React Proxy 包裹它。它不会替换你的 class 或者 function,这个proxy将会待在全局的map里面,等待着,直到你使用React.createElement()。
仅仅真正的组件才会经历 React.createElement,这就是咱们为何 monkeyPatch React.createElement()。
import createProxy from 'react-proxy' let proxies = {} const UNIQUE_ID_KEY = '__uniqueId' export function register(uniqueId, type) { Object.defineProperty(type, UNIQUE_ID_KEY, { value: uniqueId, enumerable: false, configurable: false }) let proxy = proxies[uniqueId] if (proxy) { proxy.update(type) } else { proxy = proxies[id] = createProxy(type) } } // Resolve 发生在 element 被建立的时候,而不是声明的时候 const realCreateElement = React.createElement React.createElement = function createElement(type, ...args) { if (type[UNIQUE_ID_KEY]) { type = proxies[type[UNIQUE_ID_KEY]].get() } return realCreateElement(type, ...args) }
在调用端包裹组件解决了不少问题,好比 functional component 不会误判,包裹的逻辑只要考虑 function 和 class,由于咱们把生成的代码移到底部这样不会污染代码。
Dan 提供了相似于 React-Hot-Loader 1 的 webpack loader, 即 react-hot-loader/webpack
。在不使用 babel 作静态分析的状况下,你能够经过它找到 module.export
出来的 component,而且 register 到全局,而后在调用端实现真正的代理。因此这种方式只能针对实际 export 出来的组件作保留 state 以及 DOM 的 hot reloading。
什么状况下会使用这种方式,那就是针对其余 compile-to-js 的语言好比 Figwheel 和 Elm Reactor。在这些语言里面有本身的类的实现等,因此 Babel 没有针对源码办法去作静态检查,因此必须在编译以后去处理。
还记得 React Transform 里面的React Transform Catch Error 吗。React-Hot-Loader 把处理 render 出错的逻辑放到 AppContainer 。由于 React V16 增长了 error boundaries,相信在将来的版本 React-Hot-Loader 也会作相应调整。
这就是对 React-Hot-Loader 的实现的一个追溯,若是你真的理解了,那么你在配置 React-Hot-Loader 到你的应用代码里面的每一个步骤会有一个从新的认识。我不肯定你们是否读懂了,或者存在还存在什么疑问,欢迎来沟通讨论。截止写文如今 React-Hot-Loader 4 已经在进行中,我比较偏向于 4 会和 React 迭代保持更亲密的同步( 从以前 error boundaries 和 official instrumentation API 来看),到时候拭目以待吧。