Hot Reloading in React
本文是Dan Abramov发表于Medium文章的部分译文,只截取了其中部分内容,仅供学习交流,主要作为本人其他博客的素材使用,禁止转载
React Hto Loader是我第一个比较知名的开源项目,据我所知,这是第一个可以实现让使用者无需重新挂载组件组件或是丢失组件state的工具,在刚开始的时候我只是做出了一个Demo用于在台上进行演示,不过在之后我发现了大家对这个工具的巨大热情,所以我花费了几周的时间开发出了这个工具。
第一次尝试: 直接使用HMR
在创建这个特别的项目之前,我曾想到直接使用webpack提供的HMR来替换项目中的根节点并且重新渲染我们的整个React树。
但是需要注意的是HMR根本不是为React定制的。James Long在这片文章中说的很不错。简单来说他只是一个「当新版本的模块可以使用过后,调用设置好的回调函数,让我们便于对新模块做一些处理」的作用。这件事情会发生在你每次保存修改好的文件的时候。
一个纯粹的HMR来实现热加载就像是这样的:
var App = require('./App')
var React = require('react')
var ReactDOM = require('react-dom')
// Render the root component normally
var rootEl = document.getElementById('root')
ReactDOM.render(<App />, rootEl)
// Are we in development mode?
if (module.hot) {
// Whenever a new version of App.js is available
module.hot.accept('./App', function () {
// Require the new version and render it instead
var NextApp = require('./App')
ReactDOM.render(<NextApp />, rootEl)
})
}
只要你按照类似上面的配置处理好了你的react引用,那么只要出现了文件的修改,你的应用状态就可以直接刷新而不用刷新浏览器界面啦。
这样的实现是最纯粹的,没有用到React-Hot-Loader,React-Transform,没有对你的模块做任何有关语义化的改变,只是做到了监听变化,适时地插入新的<script/>
标签用于引入模块,并且调用一个回调函数而已
内部的模块改变也会被外界的hot.accept捕捉到,如果只是在子组件中发生了变化而其上没有设置accept来捕捉这次的变化的话,它将会往上冒泡直到被捕捉进行回调的处理(就像上文一样),否则会给用户抛出一个警告。
因为我们是在App中进行了热模块的处理,也就是说我们在App中使用的子孙组件都会被重新挂载处理。
举个栗子,我们有一个Button组件被UserProfile和NavBar使用,并且这两个组件都被App引用。
因为index是唯一一个引入了App的模块,并且他其中还配置了module.hot.accept('./App', callback)
的处理操作,webpack会生成一个包含了我们所有被影响的module的「update bundle」给他处理(就是我们的XXX.hot-update.js文件)
当发现一个升级的App的时候,直接就能重新渲染React啦,
// Whenever a new version of App.js is available
module.hot.accept('./App', function () {
// Require the new version and render it instead
var NextApp = require('./App')
ReactDOM.render(<NextApp />, rootEl)
})
不过这是我们想要的结果吗?当然,不完全是
DOM和组件的旧State都不见了
新版本的module其实就是放在script中重新执行的模块罢了!由于App的重新运行,他的Class定义和之前的class不是同一个了,只是从我们的理解上来说觉得他们是一个class的不同版本而已
对于React来说,你所做的只是在渲染一个全新的组件,所以他会帮你把旧的组件给卸载掉,因为他没有办法帮你把这个已经存在的组件实例的class替换一下!官方的接口实际上都是创建新节点的create相关接口
所以啦~其实React必须这样做才能够应用上新的组件
或许可行的方式:提取出state
James Kyle近期指出我们应用可以使用维护单一状态树,就像Redux一样。在这样的应用里,我们重要的数据状态往往是存在Redux中的,所以并不需要去想着维护在每个组件中的state(可能是一些展示类的需求)。
受到他的启发,我准备了这个PR来移除了Redux应用中对state的维护,转而使用纯净的HMR。James同样建议使用isolated-core来应对这种情况,我还没有查看过,但是如果你对这个实现感兴趣的话我建议你去看看这个demo。
通过这种方式,你甚至可以把状态存储到localStorage里面,就连刷新页面也不能阻止你保留以前的状态,岂不是美滋滋。
总之,如果你是使用Redux来存储几乎所有有价值的信息的话,那么我强烈建议你就使用HMR就好了,这样已经完全够用并且很简洁。但是我们还有些用户需要在组件自身中存储比较复杂的状态,那我们还是得继续研究下去了
保留DOM和本地State
现在你知道这中间会出现哪些问题了,我想出来了两个法子或许能解决这个问题
- 想个法子把我们的React实例和DOM与State的联系解开,利用新获得的class创建新实例的时候再把他们结合起来,这样达到我们保留状态的目的
- 或者是,通过代理的方式在每个组件外面加上一层代理组件,让React从她的视角看到我们的组件始终是保持一致的都是外面那层代理,但是实际上每次进行升级替换的时候我们都是更新的内部的实例
惜败的尝试:重新结合树结构
这个方法可能更适合React的长期发展,但是目前官方还没有提供合适的接口来完成相关工作(合并state到React组件上,替换实例而不卸载DOM和执行生命周期函数)即使我们通过内部的API解决了上面的问题,我们仍然还有其他问题需要解决
再举个例子,我们的组件常常订阅了Flux/Reudx的Store或者是在componentDidMount中会有其他带来副作用的操作。就算我们可以悄悄的把旧的实例换成新的(不破坏DOM和state),还会出现事件绑定无法转移的问题
为了为我们的实例添加上订阅,在替换过程中,我们需要将生命周期函数重新执行一下子。但是这样就会出现componentDidMount执行两次的情况,鬼知道会做出什么动作,是自己写的代码还能尽量避免,但是使用三方的库时就没那么好说了
最终来说,如果状态的订阅能够脱离生命周期存在或者说React不再那么依赖class和实例,这个方法也可能有实现的方式;当然上面所说的其实也是我认为的React的一些改进方向吧
成功的尝试:代理组件Class
这个方法是我在React Hot Loader和React Transform中使用的方法,这是一个相当有入侵性的方法,他会改变我们代码结构。不过实际上他现在还是工作的非常好在当前版本和以后的一段时间看来。
主要是路就是把每个组件放到一个Proxy里面,在这里说的Proxy并不是指的ES2015中的那个,我想用的是HOC就能够实现这个功能(口嫌体正直,现在的版本就是直接用的Proxy)
这样的代理可以正常的工作,把我们的实际用到的组件放到里面包起来,进行了更改只需要HOC把新的组件更新一个并把State放过去
出现的问题:到哪儿去做这层代理
一个大家经常误会的问题:React Hot Loader其实并不是一个webpack中使用的loader(错的)
她是一个loader,因为loader做的就是对我们的代码进行transform的操作,把我们的代码进行格式化变形,像json-loader把json处理为js文件,style-loader也处理成js文件这样子。
相似的,React Hot Loader也是一个编译时转换(compile time transform),并且所做的也不是简单的加上一段代码这么简单,他会找到所有module.exports导出的Component把他们放入想对应的代理中,并把外面这层代理给返回出去。
通过这样的方法,当App在渲染NavBar的时候,他实际上渲染的是<NavBarProxy/>
,render会通过调用NavBar#render()的方法进行实际的渲染,保证内部的模块时刻是最新的
代理的对象通过拼凑出来的uid(文件名+组件名)存储在某个全局的对象中,当一次更新进行时,与之匹配的Proxy就会吸收新版本的class,并且进行重新的渲染
从module.exports中寻找组件开始看起来非常合理,这是符合我们平时一般的开发习惯的,但是随着前端的不断发展,慢慢的这么做开始不能覆盖另一些出现较多的情况了
- 由于高阶组件的盛行,人们开始export高阶组件而不是原生的组件,从结果来说,React Hot Loader就没办法发现这些组件了,当然也就无法代理。他们的DOMinate和状态无法保留,每次都会重新挂载。尤其是哪些提供样式的比如React JSS
- React 0.14介绍了无状态的组件,鼓励我们在单个文件中进行微组件化处理(即把组件拆分为最小单位,然后进行组合)。即使我们检查输出函数的toString方法,寻找createElement方法的调用,并假设他们都是React组件,还是没办法找到那些本地组件(未export)。这些组件也没办法保留状态,脆弱的不堪一击
不过,就算只是让开发者把所有的组件都export出来都还是不够的,他们在文件内部的相互依赖,我们没有办法通过简单的改变module.exports进行改变。
听到这里肯定有很多人会说了:随便啦,就用一个全局存储就好了嘛,管那么多组件内的状态干嘛,肯定不会有多大用处。。。。或许我真该信了你们的邪
问题:对于webpack系统的依赖
这可是个大问题,我们现在的系统非常依赖webpack的打包和HMR机制,如果不是用的webpack,我们要支持rollupheFIS一类的工具该咋办呢?(译者:巧了吗这不是,你只针对webpack我不知道又得改多少东西)所以还是尽量脱离这个系统,便于别人移植的时候不用做那么多的修改
React Transform
差不多就是那段时间我写下了这篇博客 The Death of React Hot Loader,我在寻找方法来解决上述的一些问题
Babel似风暴一样影响了整个前端的生态圈,我正好也需要某种静态分析的工具来定位组件(甚至没有被export出去)并给他们外面包上一层代理。babel看起来正合适
除此之外,我刚好还在想个法子实现错误处理。我们的组件如果在render中发生错误每次都会直接导致渲染失败进入一个错误的state状态并变得无法更新,我想通过babel更加优雅的处理这个问题
所以我想到:咦,为什么不写一个babel插件来定位组件的位置,并把他们直接的按照我们定义好的模式来进行变换呢?如果别人在这个基础上也来开发其他的卡法工具不会也很酷吗?比如说在页面上对应组件显示性能热力图什么的
这就是React Transform要做的事情了
实现1:过度模块化
我不确定哪些功能是以后还需要进行使用和维护的,所以我在React Transform下面创建了很多独立的模块:
- React Proxy,实现了底层的Component代理
- React Transform HMR,为找到的Component做一个代理并把代理的列表(Map)放到全局中,当发现子组件需要更新的时候,proxy也会应用上新的组件
- React Transform Catch Errors,把
render()
函数通过try/catch包起来并展示一个可自定义的组件而不是任其发展 - Babel Plugin for React Transform,尽最大努力找到你代码中所有的React组件,在编译阶段提取出他们的信息并对他们使用你需要的Transform进行处理(如:React Transform HMR)
- React Transform Boilerplate,展示了如何结合使用上述这些东西
问题:过多可活动部分?
上述的实现是一个双刃剑,好的方面可以让我们更加利于实验开发,但坏处又是大大的增加了普通开发者的使用成本,有些东西其实没必要向他们暴露出来,比如:「proxies」,「HMR」,「hot middleware」,「error catcher」等等
我还想着Babel6能够尽快出来,然后做一个presets,那就可以把我们的默认配置直接封装好放到上面了。然而Babel6比我预期的发布时间晚太多了(不过毕竟工程量那么大,能做出来我也很感激了)
React Transform比我想象中还快的流行开来,并且现在在boilerplate中的完整配置需要尽快处理掉。不然会给用户带来一些误解。事实上我们在Redux中的使用让情况变得更糟糕了
解决方案:合理的默认值
在Babel6升级并发布了官方的preset之后,模块化就不再是一个问题了,事实上还变成了一件好事,我们针对不同的环境可以只取到需要的部分(比如React Naive)。这也是给我上了一课,在将某个东西进行模块化的时候,你必须提供一个好的默认值,如果有人想要搞明白其中的原理他们必定会自己去看其中的东西
问题:高阶组件又搞事情
有时候问题总是对立的,你没办法同时兼顾两面
React-Hot-Loader可以找到你在文件中导出的组件,但是看不到文件中的本地组件,以下图为例,能够对useSheet的高阶组件做一层代理处理,但是没办法对Counter进行处理,文件一刷新Counter中的状态就会丢失
// React Hot Loader doesn’t see it
// React Transform sees it
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 sees it
// React Transform doesn’t see it
export default useSheet(styles)(Counter)
React Transform 通过了静态分析,在文件中寻找继承自React.Component的组件或者是通过React.createClass创建的组件的操作,"修复"了这一问题
猜猜我们忘了什么什么东西,输出去的高阶组件啊!在这个例子里面,React Transform会保存Counter组件的状态,并且热替换他的render和handleClick方法,但是任何styles的改变都不会得到回应因为他不知道useSheet也会返回一个需要代理的组件
通常人们发现这个问题是在使用Redux的时候,这里就是因为没有把connect处理后的组件做代理,导致了selector和action creator都不会被替换了
问题:直接的包裹组件是入侵性的
通过寻找继承或者是createClass创建的对象并不困难。然而这有一个潜在的问题,我相信你一定不想看到这个确实出现的错误。
在React 0.14之后,这个问题变得愈发难以处理。任何返回ReactElement的函数都有可能是一个组件。但是你没办法保证,所以只能启发式的搜寻。比如说,你可以说顶底作用域中使用Pascal命名的,使用JSX的并且接受两个参数以下的或许能够被称为组件?这样能避免问题吗?或许是不能的
这样甚至比以前更糟糕了,你还得告诉React Transform那些是组件。如果React推出了新的组件定义方法怎么办?我们再把Transform重写一遍吗?
最后,其实我们觉得通过静态分析吧他们包起来已经足够了。你必须得处理无尽的导出函数和class,包括:default的啊,有名字的啊,函数声明啦,createClass调用啦等等等等。每种情况你都得想个法子进行处理避免遗漏。
对于函数式组件的提供支持是目前呼声最高的一个特性,但是我现在不能这么搞,毕竟这个东西的工作量太大了,会给我自己的和其他维护者带来巨大的压力,并且还存在着一些边缘情况有潜在的风险
那。。是否应该选择放弃呢?
长路漫漫
我仍然觉得React Hot Loader和React Transform是很成功的项目,尽管他们内部有些缺点而且有些局限。我仍然相信热加载总有一天能够实现,我们不应该停止继续尝试。讲真,这是我在这几个月以来第一次对热加载感觉到乐观。
React Naive在React Transform上面封装了一个hot reloading的实现。现在已经足够稳定了,但是我也相信我们以后会有更好更简单的解决方案
以下开始介绍目前实现的解决方案
方案:尽量使用Vanilla HMR
这是最直接的一个方案。正如James Kyle建议,如果你把状态存在Redux这样的东西里,其实不需要考虑在Reload的时候保留DOM,考虑一下就那么用或者试试isolated-core这样的,这会让你的项目简单很多!
方案:放弃配制Transform
当React Native在使用fork的一份React封装的时候,配置这些东西是很有用处的,但是现在直接使用的是react这个package了,他其中也实现了HMR的一部分需要的功能,所以配置也不那么有意义
Browserify也有一个HMR Plugin,但是还是有bug,但我还是觉得项目中能够简单开启HMR配制已经很好了。我觉着要求其他环境像React Native一样提供polyfill是相当不公的
React计划封装一套官方的工具API来让我们更方便使用,像是观察profile或者是更方便在用户端实现无需通过某种手段来包裹就能监测组件。事实上,通过DevTools提供的API来做这些事情会可靠的多,毕竟这些代码在实际运行的时候肯定会被剥离出去。
这就是我不乐意作为三方进行深度定制库的原因了,只要相应的系统发生一些改变就可能推倒重来,所以我们还是继续研究热加载吧
方案:使用错误边界控制
React15封装了一个内部接口用于控制错误的状态(这个接口在目前已经在16中有了对应的生命周期函数),实际上最有效的使用方法就是把她,作为一个类似于React Native那样的错误显示屏功能了,我们以后可能会将这个和hot reload结合起来(确实是的)
方案:代理所有出现调用的地方
我觉着吧,这是我在写React Transform的时候犯的最大的一个错误。之前十月份的时候Sebastian Markbåge就给我说过她不是很看好我这种Babel-Plugin的写法,但是我没有完全理解她的建议直到我这几天重新在思考的时候才发现,其实距离更好的写法只有一尺之隔
找到组件并把他们包起来确实有很难做,并伴有很大的风险。很有可能会破坏你的代码结构。但是从另一方面想,标记他们却是相对安全的,设想我们没有直接在组件中进行操作,而是将他们标记起来,在文件的最下方进行远程处理,大大减弱了入侵性。
比方说,我们可以为顶层中的function和class还有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
// generated:
// register anything that *remotely* looks like a React component
register('Counter.js#Counter', Counter)
register('Counter.js#exports#default', __exports_default) // every export too
register具体是要干啥?我的想法是检查一下至少先检查一下是不是一个function,如果是的话那就利用React Proxy对她做一层代理操作。但是!她!不会立即替换你的class或者是function!这才是关键点,这个代理只是会乖乖的把东西存好,然后一直等着你用React.createElement的时候再说(懒)
如果调用React.createElement,那么你传入的就肯定是一个Class没得跑了,不管之前声明了什么乱七八糟的,最后一定是传过来的一个组件。
现在看来,只要我们的React Proxy是支持所有类型的组件的,那我们的热加载就是OK的。这也是为虾米我们需要为React.createElement做一些monkeypatch(简单的说就是单纯修饰一下这个方法)让这个过程可以触发操作,看起来就像是这样:
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 when elements are created, not during type definition!
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)
}
有了React Proxy对我们的组件做了一些里里外外的代理(递归),我们可以做到和现在的React Transform有大致相同的表现啦,并且同时还能解决下面这样一些问题
- 这里没有非组件会被代理,因为我们的createElement相当于已经做了一次筛选了
- 我们能「找到」function,class,createClass的调用,以及每一个export,并且我们也不用担心这些组件的嵌套递归逻辑,一切都被安排的井井有条
- 改变无状态组件不能把DOM重置,也不会让子组件的state丢失
- 我们可以通过发布React Hot Loader 2.0,使用相同的技术但是不使用静态分析。仅为export的组件进行处理,这样立即可以为使用js编译工具的系统所使用,)其实说白了就是相当于一个fallback,用不了Transform的时候使用
- 处理完成的代码将会更容易使用理解,因为我们可以把生成的放到文件底部而不是像React Transform一样污染了整段代码
总得而言,谢谢大家的资磁,我们定将继往开来,一往无前继续开发(懒得翻译了,我编得)
另外新版本也可以看看这里 Say hi to React Hot Loader 3
原文来自:Medium - Dan Abramov - Hot Reloading in React
为了图简单,文章中省略了很多的链接,有人看的话评论下我会补充上去,没有就算了哈哈
github首发链接:https://github.com/879479119/879479119.github.io/issues/4