1. Parcel 安装与使用 Parcel 是 Web 应用打包工具,适用于经验不同的开发者。它利用多核处理提供了极快的速度,并且不需要任何配置。
文档:https://www.parceljs.cn/getting_started.html
本地安装:
1 yarn add parcel-bundler --dev
安装 babel ,将 jsx 语法转化成 js 对象(虚拟DDM):
1 yarn add @babel/core @babel/plugin-transform-react-jsx @babel/preset-env --dev
配置 .babelrc :
1 2 3 4 5 6 7 8 9 10 11 12 13 { "presets" : [ "evn" ] , "plugins" : [ [ "transform-react-jsx" , { "prama" : "React.createElement" } ] ] }
2. JSX 的渲染 2.1 Babel 转义 先举个例子,当我们编写一个正常的 jsx 文件时,其结构是这样的:
1 2 3 4 5 6 7 8 9 10 import React from "./lib/react" ;import ReactDOM from "./lib/react-dom" ;const ele = ( <div className ="active" title ="123" > hello,<span style ={{ color: "red " }}> React!</span > </div > ); ReactDOM .render (ele, document .querySelector ("#app" ));
其中, babel 会对 jsx 部分进行转义,调用 react 的 createElement
方法去创建虚拟 DOM 树:
1 2 3 4 5 6 7 8 9 function Test ( ){ const flag = true const name = "ZhangSan" return <div className ="active" title ="123" > hello<span style ={{ color: "red " }}> React!</span > my name is {flag === true ? name : "LiSi"} </div > }
Babel 转义后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 "use strict" ;function Test ( ) { const flag = true ; const name = "ZhangSan" ; return React .createElement ("div" , { className : "active" , title : "123" }, "hello" , React .createElement ("span" , { style : { color : "red" } }, "React!" ), "my name is " , flag === true ? name : "LiSi" ); }
在 Babel 进行对 jsx 语法的转义过程中,也会对模板语法直接进行转义,调用其中使用的变量
同样的,我们可以不编写 JSX,直接调用 React.createElement()
方法来生成虚拟 DOM 树,然后再调用 ReactDOM.render()
来渲染虚拟 DOM 树:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import React from "./lib/react" ;import ReactDOM from "./lib/react-dom" ;const ele = React .createElement ( "div" , { className : "active" , title : "123" , }, "hello," , React .createElement ( "span" , { style : { color : "red" , }, }, "React!" ) ); ReactDOM .render (ele, document .querySelector ("#app" ));
在 Babel 转义后由于会转义为 React.createElement
因此必须把 React
引入到当前代码中,这也就是为什么我们即使在代码中并没有用到 React
对象,却仍要引用它的原因。
2.2 React.createElement React.createElement
方法会生成一个对象,这个对象包含了将来生成节点的类型、属性、内容(包含子节点),其是一个嵌套的结构,这就形成了一个树形结构,我们便将其称之为 虚拟DOM树 。
我们先来看下其 API 设计:
1 React .createElement (tagName, attribute, ...children)
tag 表示虚拟节点的类型,attribute 表示虚拟节点的属性,children 表示虚拟节点的子节点。这里要注意的是,如果子节点是文本节点,那么会直接传入一个字符串,如:
会被 Babel 转化为:
1 React .createElement ("h1" , null , "标题" )
实现这个方法其实也很简单,我们只需要返回一个对象就可以了:
1 2 3 4 5 6 7 8 9 10 11 12 13 const React = { createElement, }; function createElement (tag, attrs, ...children ) { return { tag, attrs, children, }; } export default React ;
借用最初的例子:
1 2 3 4 5 const dom = ( <div className ="active" title ="123" > hello,<span style ={{ color: "red " }}> React!</span > </div > )
经 Babel 转义并使用调用 React.createElement() 后,打印出 dom:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "tag" : "div" , "attrs" : { "className" : "active" , "title" : "123" }, "children" : [ "hello," , { "tag" : "span" , "attrs" : { "style" : { "color" : "red" } }, "children" : [ "React!" ] } ] }
2.3 ReactDOM.render React 生成了虚拟 DOM 树,那么 ReactDOM 就需要将虚拟 DOM 树渲染为 html 节点,其核心就是调用 ReactDOM.render
函数。
我们先来看一下 ReactDOM.render
函数的 API:
1 ReactDOM .render (vnode, container);
其中,vnode 就是虚拟 DOM 树,container 就是由虚拟 DOM 树生成真实 html 节点后,节点挂载的目标父节点,其是一个 HTMLElement。
其实现也并不复杂,只需分如下几步:
判断 vnode 类型,如果是字符串,就创建文本节点,并将文本节点挂载到目标父节点中;
如果不是字符串,那就根据 tag 名称,调用 document.createElement
生成真实节点;
为真实节点添加属性;
使用递归,遍历子节点,将当前生成的真实节点作为子节点的目标父节点,调用 render 函数渲染子节点;
调用 appendChild
方法将生成的节点挂载到目标父节点中。
代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 const ReactDOM = { render };function render (vnode, container ) { if (vnode === undefined ) { return ; } if (typeof vnode === "string" ) { const textNode = document .createTextNode (vnode); return container.appendChild (textNode); } const { tag, attrs, children } = vnode; const dom = document .createElement (tag); if (attrs) { for (let key in attrs) { const value = attrs[key]; setAttribute (dom, key, value); } } if (children && children instanceof Array ) { children.forEach ((child ) => render (child, dom)); } return container.appendChild (dom); } function setAttribute (dom, key, value ) { if (key === "className" ) { key = "class" ; } if (/on\w+/ .test (key)) { key = key.toLowerCase (); dom[key] = value || "" ; } else if (key === "style" ) { if (!value || typeof value === "string" ) { dom.style .cssText = value || "" ; } else if (value && typeof value === "object" ) { for (let key in value) { if (typeof value === "number" ) { dom.style [key] = value[key] + "px" ; } else { dom.style [key] = value[key]; } } } } else { if (key in dom) { dom[key] = value || "" ; } if (value) { dom.setAttribute (key, value); } else { dom.removeAttribute (key); } } } export default ReactDOM ;
3. 组件的实现 在上一节中,我们实现了 render 函数,render 函数的第一个参数可以传入一个虚拟节点。但是,在实际的 React 中,第一个参数还可以传入一个函数组件,因此我们以此为切入点,探讨一下 React 中组件的渲染原理。
3.1 让 render 函数支持传入组件 我们先来看一下经过 babel 转义的组件 jsx 长什么样子:
1 2 3 4 5 6 7 8 9 10 11 function Home ( ) { return ( <div className ="active" title ="123" > hello, <span > react</span > </div > ); } const title = "active" ;console .log (<Home name ={title} /> );
输出结果:
我们可以发现,函数组件被处理为虚拟节点对象后,tag 中包含了改组件的渲染函数,因此我们可以通过 render 函数来判断 tag 属性来判断渲染对象到底是 HTMLElement 还是 React 组件,同时我们将 render 函数进行一下简单的拆分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 function render (vnode, container ) { return container.appendChild (_render (vnode)); } function _render (vnode ) { if (vnode === undefined || vnode === null || typeof vnode === "boolean" ) { return ; } if (typeof vnode.tag === "function" ) { const comp = createComponent (vnode.tag , vnode.attrs ); setComponentProps (com, vnode.attrs ); return comp.base ; } if (typeof vnode === "string" ) { } return dom; }
3.2 createComponent 在 _render()
函数中,如果传入的是一个函数或 class 组件,首先要实现一个 createComponent
方法,来将组件进行 实例化 ,最终的实例化对象上会有一个 render()
方法来生成具体的虚拟 DOM 对象。
以下是 createComponent
方法的具体实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 function createComponent (comp, props ) { let inst; if (comp.prototype && comp.prototype .render ) { inst = new comp (props); } else { inst = new Component (props); inst.constructor = comp; inst.render = function ( ) { return this .constructor (props ); }; } return inst; }
如果我们传入的是一个 class 组件,那么直接将其进行实例化,注意此时组件就会执行构造函数的 constructor
部分,如进行 state 的初始化 ,最终实例化后的对象上会挂载一个 render
方法(1);
但如果我们传入的是一个函数组件,我们要将其构造为一个 class 组件,在构造为一个 class 组件之前,我们需要首先声明 Component
类:
1 2 3 4 5 6 7 8 9 class Component { constructor (props = {} ) { this .props = props; this .state = {}; } } export default Component ;
首先我们实例化一个 Component
对象,作为我们即将改造的“初始对象”,此时要注意将组件属性 props
传入,这样在组件对象上才能取到传入的 props
(2);之后我们将函数组件的函数体挂载到生成的 Component 对象的 constructor
上,我们这一步是改写了生成的 Component 对象的构造方法(3),目前来看意义不大;之后,我们将生成的 Component 对象的 render()
方法改写为函数组件的函数体(4),这样就将一个函数组件改写为了 class 组件。
3.3 setComponentProps setComponentProps
方法负责对组件的 props 进行更新,并触发组件的渲染:
1 2 3 4 export function setComponentProps (comp, props ) { comp.props = props; renderComponent (comp); }
其实,在目前组件执行初次渲染时 comp.props = props
的执行是没有意义的,因为在执行 createComponent
组件实例化时,就已经完成了对 props 的挂载。我们在这里再重新挂载一次是因为该方法不仅在组件初始化时调用,也会在组件更新时调用。当组件更新时,直接调用该方法就可以直接完成 props 的更新以及组件的重新渲染。
3.4 renderComponent 在调用 renderComponent
之前,我们已经完成了对函数组件、class 组件的实例化,并且将外部传入的组件属性挂载到了实例化对象的 props
属性上,同时实例化好的组件对象上有用 render()
方法,执行后可以返回一个虚拟节点对象。
因此,在 renderComponent
方法中,我们主要是调用组件的 render()
函数(1),然后再将生成的虚拟节点对象传入到 _render()
函数中,渲染为真实的 DOM 对象,并将 DOM 对象挂载到组件实例的 base 属性上(2):
1 2 3 4 function renderComponent (comp ) { const renderer = comp.render (); comp.base = _render (renderer); }
4. 生命周期 在上一节中,我们已经完善了组件的渲染过程,那么在本章节中,我们将还原 React 组件生命周期函数 的调用过程。我们将着重探讨 componentWillMount
componentDidMount
componentWillUpdate
componentDidUpdate
这四个生命周期函数。
4.1 componentWillMount 与 componentDidMount
UNSAFE_componentWillMount() 在挂载之前被调用。它在 render() 之前调用,因此在此方法中同步调用 setState() 不会触发额外渲染。
根据以上的特性我们很容易判断出 componentWillMount
的执行位置,让其在 renderComponent()
之前执行并且仅执行一次即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function _render (vnode ) { if (typeof vnode.tag === "function" ) { const comp = createComponent (vnode.tag , vnode.attrs ); if (!comp.base ) { comp?.componentWillMount (); } renderComponent (comp); return comp.base ; } }
componentDidMount() 会在组件挂载后(插入 DOM 树中)立即调用。依赖于 DOM 节点的初始化应该放在这里。如需通过网络请求获取数据,此处是实例化请求的好地方。
componentDidMount
要在组件完成挂载时执行,且只执行一次,那么在 renderComponent
过程中,我们可以将其放置在组件渲染完成并且是初次挂载时执行。
!!! 此处教程有误,componentDidMount 应该在组件 DOM 挂载到页面上后再执行,按照下面的写法显然实在 DOM 被挂载之前执行 !!!
1 2 3 4 5 6 7 8 9 10 11 export function renderComponent (comp ) { const renderer = comp.render (); let base = _render (renderer); if (!comp.base ) { comp?.componentDidMount (); } comp.base = base; }
4.2 componentWillUpdate 与 componentDidUpdate 要执行这两个方法,我们首先要实现组件内部 state 的更新以及重新渲染,我们先编写一个如下的 demo 组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class Home extends React.Component { constructor (props ) { super (props); this .state = { num : 0 , }; } handleClick ( ) { this .setState ({ num : this .state .num + 1 , }); } render ( ) { return ( <div id ="home" > <h1 > {this.props.title}</h1 > <span > hello, react!</span > <button onClick ={this.handleClick.bind(this)} > Click me! ({this.state.num}) </button > </div > ); } }
当用户点击按钮后,state 中存放的 num 变量就会被加一,然后页面会触发重新渲染,来展示整个页面。
要实现 state 的变更以及重新渲染,我们首先要扩展一下 Component 组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 + import { renderComponent } from "../react-dom/index"; class Component { constructor(props = {}) { this.props = props; this.state = {}; } + setState(stateChange) { + Object.assign(this.state, stateChange); // (1) + renderComponent(this); // (2) + } } export default Component;
我们为 Component
添加 setState
方法,此时我们为了方便编写 demo,只是简单的将 state 进行了浅拷贝并覆盖值(1),实际的 setState 操作是异步的。进行了赋值操作之后,我们重新调用 renderComponent
方法对组件进行重新渲染,并将当前组件作为渲染对象传入(2)。
此时我们点击按钮后,会发现页面上的 DOM 结构并不会改变,但是在 renderComponent
方法中打印出当前的组件对象,其 base 上挂载的 HTMLElement 的确是已经发生了更新,这就说明我们并没有将更新后的 HTMLElement 挂载到页面上。
更新节点的方法其实也很简单,我们只需要获取到当前组件的父节点,然后使用 replaceChild()
方法,替换父节点的内容为最新的组件节点就可以了(1)。
1 2 3 4 5 6 7 8 9 10 11 12 export function renderComponent(comp) { // 对组件进行渲染,获取虚拟节点对象 const renderer = comp.render(); let base = _render(renderer); + // 节点替换 + if (comp?.base?.parentNode) { + comp.base.parentNode.replaceChild(base, comp.base); // (1) + } comp.base = base; }
这时,我们根据 componentWillUpdate
与 componentDidUpdate
的定义,就很容易得知其执行的位置:
当组件收到新的 props 或 state 时,会在渲染之前调用 UNSAFE_componentWillUpdate()。使用此作为在更新发生之前执行准备更新的机会。初始渲染不会调用此方法。
componentDidUpdate() 会在更新后会被立即调用。首次渲染不会执行此方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 export function renderComponent(comp) { const renderer = comp.render(); let base = _render(renderer); + if (comp.base) { + comp?.componentWillUpdate(comp.props, comp.state); + } else { + comp.base = base; // 组件必须在挂载后再触发 componentDidMount + comp?.componentDidMount(); + } if (comp?.base?.parentNode) { comp.base.parentNode.replaceChild(base, comp.base); + comp.base = base; + comp?.componentDidUpdate(); } - comp.base = base; }
5. Diff 算法的实现 截至目前,我们已经刨析了 jsx 的渲染以及组件 state 的更新,那么接下来我们会进一步对渲染流程进行优化。
在前面的写法中,每当 state 改变触发组件重新渲染时,都会从头开始进行渲染,这样对性能的损耗是很大的。为了优化 DOM 结构的更新性能,react 引入了 diff 算法,这个算法会对比每个同级节点的变更 ,如果当前节点与之前相较发生了变更,就会更新当前节点与其子节点,这比重新渲染整个 DOM 结构要高效的多。
总而言之,我们的diff算法有两个原则:
对比当前真实的DOM和虚拟DOM,在对比过程中直接更新真实DOM
只对比同一层级的变化
5.1 起步 先修改render函数,将_render方法渲染的方式改为我们即将写的diff算法方式
/react-dom/index.js
1 2 3 4 5 6 7 8 9 import diff from './diff' ;const ReactDOM = { render } function render (vnode, container ) { return diff (dom,vnode,container); }
由上面的diff()
可以看出,传入了 真实DOM对象,虚拟DOM对象,根元素
5.2 实现 实现一个diff算法,它的作用是对比真实的DOM和虚拟DOM,最后返回更新后的DOM
/react-dom/diff.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export function diff (dom,vnode,container ) { let ret = diffNode (dom,vnode); return ret; } function diffNode (dom,vnode ){ }
接下来实现这个方法
在这之前先来回忆一下我们虚拟DOM的结构:
虚拟DOM的结构可以分为三种,分别表示文本,原生DOM节点以及组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 { tag : 'div' , attrs : { className : 'container' }, children : [] } "hello,world" { tag : ComponentConstrucotr , attrs : { className : 'container' }, children : [] }
5.3 对比文本节点 首先考虑最简单的文本节点,如果当前的DOM就是文本节点,则直接更新内容,否则就新建一个文本节点,并移除原来的DOM
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function diffNode (dom,vnode ) { let out = dom; if (typeof vnode === 'string' ){ if (dom && dom.nodeType === 3 ){ if (dom.textContent !== vnode) dom.textContent = vnode; } else { out = document .createTextNode (vnode); if (dom && dom.parentNode ){ dom.parentNode .replaceChild (out,dom); } } return out; } return out; }
文本节点十分简单,它没有属性,也没有子元素
5.4 对比组件 之前也说过,react组件分为函数组件和类组件,我们定制一个方法diffComponent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import setComponentProps from "./index.js" function diffNode (dom,vnode ){ if (typeof vnode.tag === 'function' ) { return diffComponent (dom, vnode); } } function diffComponent (dom, vnode ) { let comp = dom; if (comp && comp.constructor === vnode.tag ) { setComponentProp (comp, vnode.attrs ); dom = comp.base ; } else { if (comp) { unmountComponent (comp); comp = null ; } comp = createComponent (vnode.tag , vnode.attrs ); setComponentProp (comp, vnode.attrs ); dom = comp.base ; } return dom; }
5.5 对比非文本DOM节点 如果vnode表示的是一个非文本DOM节点,分两种情况分析:
情况一: 如果真实DOM不存在,表示此节点是新增的
1 2 3 if (!dom){ updateDOM = document .createElement (vnode.tag ); }
情况二:如果真实DOM存在,需要对比属性
和对比子节点
5.5.1 对比属性 找出来节点的属性以及事件监听的变化 单独起一个diffAttributes
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import { setAttribute } from './index' ;function diffNode (dom,vnode ){ let out = dom; if (!dom){ out = document .createElement (vnode.tag ); } diffAttributes (out,vnode); return out; } function diffAttributes (dom, vnode ) { const oldAttrs = {}; const newAttrs = vnode.attrs ; const domAttrs = dom.attributes ; [...domAttrs].forEach (item => { oldAttrs[item.name ] = item.value ; }) for (let key in oldAttrs) { if (!(key in newAttrs)) { setAttribute (dom, key, undefined ); } } for (let key in newAttrs) { if (oldAttrs[key] !== newAttrs[key]) { console .log (dom, newAttrs[key], key); setAttribute (dom, key, newAttrs[key]); } } }
5.5.2 对比子节点 节点对比完成之后,接下来对比它的子节点
这个时候会有一个问题,前面我们实现的不同的diff算法,都是明确知道哪一个是真实DOM和虚拟DOM对比,但是子节点childrens是一个数组,他们可能改变顺序,或者数量有所变化,我们很难确定是和虚拟DOM对比的是哪一个?
思路:给节点设置一个key值,重新渲染时对比key值相同的节点,这样我们就能找到真实DOM和哪个虚拟DOM进行对比了
对比子节点的方法有点复杂,在这里理解一下原理
1 2 3 4 5 function diffNode (dom,vnode ){ if (vnode.childrens && vnode.childrens .length > 0 || (out.childNodes && out.childNodes .length > 0 )) { diffChildren (out, vnode.childrens ); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 function diffChildren (dom, vchildren ) { const domChildren = dom.childNodes ; const children = []; const keyed = {}; if (domChildren.length > 0 ) { [...domChildren].forEach ((item ) => { const key = item.key ; if (key) { keyed[key] = item; } else { children.push (item);`` } }); } if (vchildren && vchildren.length > 0 ) { let min = 0 ; let childrenLen = children.length ; [...vchildren].forEach ((vchild, i ) => { const key = vchild.key ; let child; if (key) { if (keyed[key]) { child = keyed[key]; keyed[key] = undefined ; } } else if (childrenLen > min) { for (let j = min; j < childrenLen; j++) { let c = children[j]; if (c) { child = c; children[j] = undefined ; if (j === childrenLen - 1 ) { childrenLen--; } if (j === min) { min++; } break ; } } } child = diffNode (child, vchild); const f = domChildren[i]; if (child && child !== dom && child !== f) { if (!f) { dom.appendChild (child); } else if (child === f.nextSibling ) { dom.removeChild (f); } else { dom.insertBefore (child, f); } } }); } }
整个流程如下:
最后再修改renderComponent
方法的两个地方
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 export function renderComponent(comp) { + let base // 对组件进行渲染,获取虚拟节点对象 const renderer = comp.render(); - let base = _render(renderer); if (comp.base) { // 组件更新时引发重新渲染 comp?.componentWillUpdate(comp.props, comp.state); + base = diffNode(comp.base,renderer); comp.base = base; + comp?.componentDidUpdate(); } else { // 初次渲染 + base = _render(renderer); comp.base = base; comp?.componentDidMount(); } - // 组件 state 发生变化后,重新渲染节点,需要进行节点替换 - if (comp?.base?.parentNode) { - comp.base.parentNode.replaceChild(base, comp.base); - comp?.componentDidUpdate(); - } }
6. 异步 setState 在 React 中,为了优化性能 setState
的操作是异步的,当我们在一个 for 循环中直行 setState,会出现以下情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 constructor (props ) { super (props); this .state = { num : 0 , }; } componentDidMount ( ) { for (let i = 0 ; i < 5 ; i++) { console .log (this .state .num ); this .setState ({ num : this .state .num + 1 , }); } }
输出结果始终为初始化的 state 值:
同时 setState
也支持传入一个函数,在该函数中,可以获取到上一次更新 state 后的状态(prevState):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 constructor (props ) { super (props); this .state = { num : 0 , }; } componentDidMount ( ) { for (let i = 0 ; i < 5 ; i++) { this .setState ((prevState )=> { console .log (prevState.num ); return { num : prevState.num + 1 } }) } }
输出结果可以获取到每次 state 改变前的值:
之所以会这样,是因为 setState
始终是一个异步的操作,此时在循环中取到的 state 都是在循环执行时获取到的组件 state。但是 setState
中如果传入一个函数,那么在函数中可以获取到上次组件更新的 prevState,也就是在传入函数执行时,组件最新的 state,这的确很神奇。
那么知道的上述的具体表现后,我们再来探讨一下为什么 setState
要是一个异步操作,其是怎么优化的,其主要分如下几步:
当 react 进行 setState 操作时,会重新渲染组件,渲染组件会消耗大量的性能,为了减少性能损耗,react 会将 当前同步任务队列 中的所有 setState 操作都暂存在一个 执行队列 中,我们将其定为 setStateQueue
,但并不立即执行。同时创建一个 渲染队列 ,将要改变 state 的组件全部存放在渲染队列中,同时 合并渲染队列中重复的组件 ;
等待一段时间过后,react 会将直行队列 setStateQueue
中的 setState 操作,但此时只会改组件的 state ,组件并没有直行实质性的渲染;
等 setStateQueue
队列执行完毕之后,开始对渲染队列 renderQueue
的组件直行渲染操作,这样就可以实现改变多个 state 但只渲染一次,从而优化性能。
6.1 创建 setStateQueue 与 renderQueue setStateQueue
负责存储该轮更新时执行的 setState 操作,renderQueue
负责存储在该轮更新后应该渲染的组件。我们要创建这两个队列,并创建加入队列的方法 enqueueSetState(stateChange, component)
,并在组件更新 state 时调用该方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const setStateQueue = [];const renderQueue = [];export function enqueueSetState (stateChange, component ) { setStateQueue.push ({ stateChange, component, }); let r = renderQueue.some ((item ) => { return item === component; }); if (!r) { renderQueue.push (component); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { enqueueSetState } from "./set_state_queue" ;class Component { constructor (props = {} ) { this .props = props; this .state = {}; } setState (stateChange ) { enqueueSetState (stateChange, this ); } } export default Component ;
6.2 创建清空队列的方法 假设我们已经等待了 一定的时间 ,到达了某一时刻,那么我们就要清空 setStateQueue 与 renderQueue 了。因此我们要创建一个清空队列的方法,将其命名为 flush:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function flush ( ) { let item; while ((item = setStateQueue.shift ())) { const { stateChange, component } = item; if (!component.prevState ) { component.prevState = Object .assign ({}, component.state ); } if (typeof stateChange === "function" ) { Object .assign ( component.state , stateChange (component.prevState , component.props ) ); } else { Object .assign (component.state , stateChange); } component.prevState = component.state ; } let component; while ((component = renderQueue.shift ())) { renderComponent (component); } }
该清空队列的方法实际上就是实现了我们前面原理分析的第 2 与第 3 步操作:
6.3 寻找合适的更新时机 前面我们一直在强调 setState 是一个异步操作,其原因就在这儿,我们虽然将所有的 setState 行为已经组件都存放在队列里了,但最重要是寻找到一个时间点去清空队列。这个时间点既不能太快(要后置于当前 JS 的同步任务),又不能太慢(保证让用户无感知)。
最简单的我们就使用一个定时器来实现:
1 2 3 4 5 6 7 8 9 10 export function enqueueSetState (stateChange, component ) { if (setStateQueue.length === 0 ) { setTimeout (() => { flush (); }, 0 ); } }
当更新队列为空时,我们开启定时器,由于定时器是一个异步任务,后面更新 setStateQueue 与 renderQueue 的操作会继续执行。同时需要添加一个判断队列是否为空时候才开启定时器,保证开启定时器的操作只在“一轮更新”的最开始直行,只要是在该轮直行的 enqueueSetState 操作都不会在开启定时器,保证了定时器的唯一性。
当定时器到达时间阈值时,标志着该轮更新“到点了”,开始清空队列并执行渲染操作,后续再有更新 state 操作的话就会被延迟到“下一轮”更新中。
当然,使用定时器的方法并不是最优雅的,某些浏览器 setTimeout
的最小触发时间为 4ms,如果我们只想同步任务直行完成之后就直行组件更新,连 4ms 都不想等,或者说总是想要优先于主任务队列中的所有 setTimeout
行为的话,该怎么办呢?
这时候就要利用到任务轮询中的 微任务 了,这也是微任务的应用点之一。我们都知道微任务属于异步任务,会延后于同步任务队列,但是又会优先于宏任务队列,而 Promise.resolve().then()
是一个最典型的微任务,那么我们只需要将 setTimeout
改成微任务就行了:
1 2 3 4 5 6 7 8 export function enqueueSetState (stateChange, component ) { if (setStateQueue.length === 0 ) { return Promise .resolve ().then (flush); } }
这样的话,我们就不会跟其他的 setTimeout 行为发生冲突了,也可以利用这点来让某些行为在组件更新后发生,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class Home extends React.Component { constructor (props ) { super (props); this .state = { num : 0 , }; } handleClick ( ) { this .setState ({ num : this .state .num + 1 , }); setTimeout (() => { const btn = document .querySelector ("#btn" ); console .log ("btn: " , btn.innerHTML ); }, 0 ); } render ( ) { return ( <button id ="btn" onClick ={this.handleClick.bind(this)} > Click me! ({this.state.num}) </button > ); } }
7. 参考 本文主要参考如下视频与文章,以及 React 官网以及具体源码,结合一些个人理解而编写:
配套源码:https://github.com/EsunR/Study-Book/tree/master/React/React%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90