周其仁:停止改革,我们将面临三大麻烦

抛开立场观点不谈,且看周小平写一句话能犯多少语病

罗马尼亚的声明:小事件隐藏着大趋势——黑暗中的风:坚持做对的事相信未来的结果

布林肯突访乌克兰,为何选择去吃麦当劳?

中国不再是美国第一大进口国,贸易战殃及纺织业? 美国进一步延长352项中国商品的关税豁免期

生成图片,分享到微信朋友圈

自由微信安卓APP发布,立即下载! | 提交文章网址
查看原文

React与HMR | 冰岩分享

🐇 冰岩作坊 2022-06-09

React + Webpack-HMR 简介

本文主要介绍了开源项目React Hot Loader作者 Dan Abramov 在项目之初所做的一些尝试,希望能借此管中窥豹。如想先了解HMR技术请移步 原理解析。本文是在原作者发布的文章基础上加工形成的,其中混杂了不少笔者的个人看法与理解,如果想要阅读原汁原味的教学请移步原文链接(可能由于网络环境而导致无法正常访问)。

注意:React Hot Loader3之后的版本已不再采用本文介绍的处理方式

前言

        Webpack对模块进行打包时,以import语句为分界线进行模块划分。若开发过程中启用了dev-server,那么Webpack将会探知到开发者“本次构建”与“上次构建”期间的文件变动。如果开发者进一步开启了模块热替换(HMR)并在配置中引入了相关插件,那么对于每一次的文件变动,dev-server都将告知浏览器“某文件发生了变动”。 因此,为了能够使用HMR带来的便利,开发者需要依据自己的实际情况在合适的地方调用函数处理dev-server发来的信息。 举个例子:

// index.jsimport './index.css';import tool from './tool'; // tool是函数,返回一个DIVlet toolEle = tool('工具箱');document.body.append(toolEle);/** * HMR处理 */if (module.hot) { module.hot.accept('./tool', () => { document.body.removeChild(toolEle); toolEle = tool('工具箱'); document.body.appendChild(toolEle); })}

       我们想要进行热替换的模块对应的文件名字叫做“tool.js”,它导出一个函数,“index.js”中引用了这个函数。我们想要在每次“热更新”时探知到“tool.js”的变动,就得调用module.hot.accept来注册一个“事件侦听器”。在侦听器的回调函数内部,必须有一段和实际代码功能相匹配的处理代码。在我们的这个例子中,每次监听到文件变化都重新调用tool生成一个新的DIV,替换掉原来的。 这个简单的示例暴露了Webpack HMR的两个问题:
        1.  在本例中tool.js导出一个函数,那么在修改的过程中必须保证tool模块导出始终为一函数 
        2.  “侦听器回调”里面的代码与上下文关系密切。对比本例,假如另外一个模块导出一个对象,那么回调函数内的处理逻辑就完全不同了。
        因此,应用HMR的项目最好有统一的文件结构、统一的导出格式,方便我们做一些统一化、规范化的处理。

一个直观的想法:整体重载

tip: module.hot.accept用来监听指定文件的变动。这种变动包括了“它所引用的文件的变动”,概念上与事件冒泡类似

        在React中应用HMR,显然规避了上面的两个问题:React自己能够处理体系内的任何模块,HMR只需及时替换掉旧模块即可。那么能否像之前例子中的tool函数一样,把React App整体注册为监听对象呢?

import App from './App';import React from 'react';import ReactDOM from 'react-dom';const rootEl = document.getElementById('root')ReactDOM.render(<App />, rootEl)
if (module.hot) { module.hot.accept('./App', function () { ReactDOM.render(<App />, rootEl) })}

        直观上,这样写是能够达成目的的:任何文件的改动最终都会被侦听到,并重新渲染整个应用。但“重新渲染”的过程不会保留组件内部的状态信息。这个过程是从零到一的render而不是从一到二的update,因此这种处理办法只适用于将React作为纯粹UI渲染工具的项目。这类项目往往有类似Redux的数据驱动,即使从零开始重新渲染也不会丢失数据。 虽然简单,但这种处理方式最为直观:我们甚至不用引入任何多余的loader和其他库,就能手写几行代码实现HMR,清清爽爽。

更进一步:抽离state

        要兼容那些用了state的组件,就必须想个办法。问题的关键在于,用现有的手段能否把组件和状态分离,保存状态的备份,更新时拿新的组件与老状态组合。然而截至目前,react组件中的状态(如果使用了)仍是不可分离的——没有相关的API来帮助我们完成组件实例与组件状态的解耦。但也绝非无路可通:我们可以在父组件中获取子组件的状态,用emit之类的方式告知新的子组件实例。这两个步骤的实现手段很多,也相当复杂,读者如果有兴趣,可以做些尝试。
       这样看来似乎用粗暴一点的手段就可以解决问题,其实不然。考虑以下两个因素 :
        1.  
state常常与组件的各种方法或生命周期回调有直接关系,比较典型的例子是,开发者常常在componentDidMount中加入一些处理state的代码           
        2.  实例化的组件总是不独立的。这种不独立性往往体现在
componentDidMount中:组件可能订阅了某些事件或做了其他有副作用的操作
       那么问题来了:对于一个新的组件实例,它的一切都是纯净的。假设我们真的拿到了旧的状态,为了让组件正常展示,需要把旧的状态合并到新组件上;为了保持组件间的联系,需要重新执行
componentDidMount。对state或者其他被生命周期影响的部分而言,componentDidMount实际上执行了两次,甚至更多——用了HMR当然会不停的改改改T_T——这么干对应用产生的影响无法提前预估,尤其是当我们还引用了其他库的代码时。
       结论是:这样的处理方式适合那些生命周期回调无副作用的组件。(这个限制算是很苛刻了)

可行的办法:代理组件

        虽然上面的尝试并不成功,但Dan Abramov脑洞大开,想到了另外一个相反的解决方案:保留旧的组件实例,在此基础上把所有的方法(包括生命周期、render、事件处理等)替换成新的,这样就能让旧组件“看起来”像是新的一样,同时还保留了旧组件与应用程序其他部分的联系。 为了落地这一设想,Dan Abramov用“组件代理”的手段(Abramov本来的想法是用高阶组件来实现代理,实际上他采用的是ES6的Proxy),拦截每个React文件的导出对象,包上一层代理再导出。这种工作当然要在模块引入的时候做——所以是个loader。 借助react-proxy很容易做出成效,来看一段示例:

// 代码来自 https://zhuanlan.zhihu.com/p/34193549 @孙正斌import React from 'react'import { render } from 'react-dom'import createProxy from 'react-proxy'import deepForceUpdate from 'react-deep-force-update'
import App from './App'
class App1 extends App { handleIncrease() { this.setState({ counter: this.state.counter + 2 }) }}
const proxy = createProxy(App)const ProxyApp = proxy.get() // 拿到的ProxyApp就是经过包装的组件
const Instance = render(<ProxyApp />, document.getElementById('root'))
const proxyUpdate = () => { proxy.update(App1) // update会读取App1中所有方法或其他非state属性移植到App deepForceUpdate(Instance)}
proxyUpdate()

更多的问题...

         组件代理遇到的最大的问题就是:首先得有办法发现项目中的组件们。按照export来寻找是最简单的方法,但对于高阶组件、无状态的内部组件,仅仅通过export是没办法发现的。笔者浏览了Abramov提出的几种设想,比较赞同的一种办法是(实际上Abramov也确实是这么做的):loader在处理代码的时候,标记出所有的function/class/export,实际运行项目时,为所有这些标记项全都创建Proxy,但并不替代他们。只在react.createElement时才真的用Proxy来替换。这样就可以避免某些深层组件不被发现的问题(如下):

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
// loader之后代码加上下面的内容register('Counter.js#Counter', Counter)register('Counter.js#exports#default', __exports_default)

要使用这种方式,少不了对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) }}const realCreateElement = React.createElementReact.createElement = function createElement(type, ...args) { if (type[UNIQUE_ID_KEY]) { type = proxies[type[UNIQUE_ID_KEY]].get() // 狸猫换太子 O(∩_∩)O  } return realCreateElement(type, ...args)}

        对于Abramov来说,第二个问题就是过分依赖Webpack的HMR。读者可以移步原文观摩,也可以到原作者Git地址做更多探索,本文不再详细介绍。而对于开发者而言,知道了以上这些,我们就有了对Wepack中ReactHMR最基本的认识,“减少未知是消除恐慌的最好办法”,读者朋友们若想来一次大扫盲,请戳下面的原文链接,有惊喜哦O(∩_∩)O



文章有问题?点此查看未经处理的缓存