如今的前端,框架横行,出去面试问到框架是常有的事。
我比较常用React, 这里就写了一篇 React 基础原理的内容, 面试基本上也就问这些, 分享给大家。
React 是什么
React是一个专注于构建用户界面的 Javascript Library.
React做了什么?
- Virtual Dom模型
- 生命周期管理
- setState机制
- Diff算法
- React patch、事件系统
- React的 Virtual Dom模型
virtual dom 实际上是对实际Dom的一个抽象,是一个js对象。react所有的表层操作实际上是在操作Virtual dom。
经过 Diff 算法会计算出 Virtual DOM 的差异,然后将这些差异进行实际的DOM操作更新页面。
React 总体架构
几点要了解的知识
- JSX 如何生成Element
- Element 如何生成DOM
1
JSX 如何生成Element
先看一个例子, Counter :
App.js 就做了一件事情,就是把 Counter 组件挂在 #root 上.
Counter 组件里面定义了自己的 state, 这是个默认的 property ,还有一个 handleclick 事件和 一个 render 函数。
看到 render 这个函数里,竟然在 JS 里面写了 html !
这是一种 JSX 语法。React 为了方便 View 层组件化,承载了构建 html 结构化页面的职责。
这里也简单的举个例子:
将 html 语法直接加入到 javascript 代码中,再通过翻译器转换到纯 javascript 后由浏览器执行。
这里调用了 React 和 createElement 方法,这个方法就是用于创建虚拟元素 Virtual Dom 的。
React 把真实的 DOM 树转换成 Javascript 对象树,也就是 Virtual Dom。
每次数据更新后,重新计算 Virtual Dom ,并和上一次生成的 virtual dom 做对比,对发生变化的部分做批量更新。
而 React 是通过创建与更新虚拟元素 Virtual Element 来管理整个Virtual Dom 的。
虚拟元素可以理解为真实元素的对应,它的构建与更新都是在内存中完成的,并不会真正渲染到 dom 中去。
回到我们的计数器 counter 组件:
注意下 a 标签 createElement 的返回结果, 这里 CreateElement 只是做了简单的参数修正,返回一个 ReactElemet 实例对象。
Virtual element 彼此嵌套和混合,就得到了一颗 virtual dom 的树:
2
Element 如何生成DOM
现在我们有了由 ReactElement 组成的 Virtual Dom 树,接下来我们要怎么我们构建好的 Virtual dom tree 渲染到真正的 DOM 里面呢?
这时可以利用 ReactDOM.render 方法,传入一个 reactElement 和一个 作为容器的 DOM 节点。
看进去 ReactDOM.render 的源码,里面有两个比较关键的步骤:
第一步是 instantiateReactComponent。
这个函数创建一个 ReactComponent 的实例并返回,也可以看到 ReactDOM.render 最后返回的也是这个实例。
instantiateReactComponent 方法是初始化组件的入口函数,它通过判断 node 的类型来区分不同组件的入口。
- 当 node 为空的时候,初始化空组件。
- 当 node 为对象,类型 type 字段标记为是字符串,初始化 DOM 标签。否则初始化自定义组件。
- 当 node 为字符串或者数字时,初始化文本组件。
虽然 Component 有多种类型,但是它们具有基本的数据结构:ReactComponent 类。
注意到这里的 setState, 这也是重点之一。
创建了 Component 实例后,调用 component 的 mountComponent 方法,注意到这里是会被批量 mount 的,这样组件就开始进入渲染到 DOM 的流程了。
React生命周期
React 组件基本由三个部分组成,
- 属性 props
- 状态 state
- 生命周期方法
React 组件可以接受参数props, 也有自身状态 state。
一旦接受到的参数 props 或自身状态 state 有所改变,React 组件就会执行相应的生命周期方法。
React 生命周期的全局图
首次挂载组件时,按顺序执行
- componentWillMount、
- render
- componentDidMount
卸载组件时,执行 componentDidUnmount
当组件接收到更新状态,重新渲染组件时,执行
- componentWillReceiveProps
- shouldComponentUpdate
- componentWillUpdate
- render
- componentDidUpdate
更新策略
通过 updateComponent 更新组件,首先判读上下文是否改变,前后元素是否一致,如果不一致且组件的 componentWillReceiveProps 存在,则执行。然后进行 state 的合并。
调用 shouldComponentUpdate 判断是否需要进行组件更新,如果存在 componentWillUpdate 则执行。
后面的流程跟 mountComponent 相似,这里就不赘述了。
setState机制
为避免篇幅过长,这部分可移步我的另一篇文章:
[第10期] 深入了解 React setState 运行机制
Diff算法
Diff算法用于计算出两个virtual dom的差异,是React中开销最大的地方。
传统diff算法通过循环递归对比差异,算法复杂度为 O(n3)。
React diff算法制定了三条策略,将算法复杂度从 O(n3)降低到O(n)。
- 1. UI中的DOM节点跨节点的操作特别少,可以忽略不计。
- 2. 拥有相同类的组件会拥有相似的DOM结构。拥有不同类的组件会生成不同的DOM结构。
- 3. 同一层级的子节点,可以根据唯一的
ID
来区分。
1. Tree Diff
对于策略一,React 对树进行了分层比较,两棵树只会对同一层次的节点进行比较。
只会对相同层级的 DOM 节点进行比较,当发现节点已经不存在时,则该节点及其子节点会被完全删除,不会用于进一步的比较。
如果出现了 DOM 节点跨层级的移动操作。
如上图这样,A节点就会被直接销毁了。
Diif 的执行情况是:create A -> create C -> create D -> delete A
2. Element Diff
- 当节点处于同一层级时,diff 提供了 3 种节点操作:插入、移动和删除。
- 对于同一层的同组子节点添加唯一 key 进行区分。
通过 diff 对比后,发现新旧集合的节点都是相同的节点,因此无需进行节点删除和创建,只需要将旧集合中节点的位置更新为新集合中节点的位置.
原理解析
几个概念
- 对新集合中的节点进行循环遍历,新旧集合中是否存在相同节点
- nextIndex: 新集合中当前节点的位置
- lastIndex: 访问过的节点在旧集合中最右的位置(最大位置)
- If (child._mountIndex < lastIndex)
对新集合中的节点进行循环遍历,通过 key 值判断,新旧集合中是否存在相同节点,如果存在,则进行移动操作。
在移动操作的过程中,有两个指针需要注意,
一个是 nextIndex,表示新集合中当前节点的位置,也就是遍历新集合时当前节点的坐标。
另一个是 lastIndex,表示访问过的节点在旧集合中最右的位置,
更新流程:
1
( 如果新集合中当前访问的节点比 lastIndex 大,证明当前访问节点在旧集合中比上一个节点的位置靠后,则该节点不会影响其他节点的位置,即不进行移动操作。只有当前访问节点比 lastIndex 小的时候,才需要进行移动操作。)
首先,我们开遍历新集合中的节点, 当前 lastIndex = 0, nextIndex = 0,拿到了 B.
此时在旧集合中也发现了 B,B 在旧集合中的 mountIndex 为 1 , 比当前 lastIndex 0 要大,不满足 child._mountIndex < lastIndex,对 B 不进行移动操作,更新 lastIndex = 1, 访问过的节点在旧集合中最右的位置,也就是 B 在旧集合中的位置,nextIndex++ 进入下一步。
2
当前 lastIndex = 1, nextIndex = 1,拿到了 A,在旧集合中也发现了 A,A 在旧集合中的 mountIndex 为 0 , 比当前 lastIndex 1 要小,满足 child._mountIndex < lastIndex,对 A 进行移动操作,此时 lastIndex 依然 = 1, A 的 _mountIndex 更新为 nextIndex = 1, nextIndex++, 进入下一步.
3
这里,A 变成了蓝色,表示对 A 进行了移动操作。
当前 lastIndex = 1, nextIndex = 2,拿到了 D,在旧集合中也发现了 D,D 在旧集合中的 mountIndex 为 3 , 比当前 lastIndex 1 要大,不满足 child._mountIndex < lastIndex,不进行移动操作,此时 lastIndex = 3, D 的 _mountIndex 更新为 nextIndex = 2, nextIndex++, 进入下一步.
4
当前 lastIndex = 3, nextIndex = 3,拿到了 C,在旧集合中也发现了 C,C 在旧集合中的 mountIndex 为 2 , 比当前 lastIndex 3 要小,满足 child._mountIndex < lastIndex,要进行移动,此时 lastIndex不变,为 3, C 的 _mountIndex 更新为 nextIndex = 3.
5
由于 C 已经是最后一个节点,因此 diff 操作完成.
这样最后,要进行移动操作的只有 A C。
另一种情况
刚刚说的例子是新旧集合中都是相同节点但是位置不同。
那如果新集合中有新加入的节点且旧集合存在需要删除的节点,
那 diff 又是怎么进行的呢?比如:
1
首先,依旧,我们开遍历新集合中的节点, 当前 lastIndex = 0, nextIndex = 0,拿到了 B,此时在旧集合中也发现了 B,B 在旧集合中的 mountIndex 为 1 , 比当前 lastIndex 0 要大,不满足 child._mountIndex < lastIndex,对 B 不进行移动操作,更新 lastIndex = 1, 访问过的节点在旧集合中最右的位置,也就是 B 在旧集合中的位置,nextIndex++ 进入下一步。
2
当前 lastIndex = 1, nextIndex = 1,拿到了 E,发现旧集合中并不存在 E,此时创建新节点 E,nextIndex++,进入下一步
3
当前 lastIndex = 1, nextIndex = 2,拿到了 C,在旧集合中也发现了 C,C 在旧集合中的 mountIndex 为 2 , 比当前 lastIndex 1 要大,不满足 child._mountIndex < lastIndex,不进行移动,此时 lastIndex 更新为 2, nextIndex++ ,进入下一步
4
当前 lastIndex = 2, nextIndex = 3,拿到了 A,在旧集合中也发现了 A,A 在旧集合中的 mountIndex 为 0 , 比当前 lastIndex 2 要小,不满足 child._mountIndex < lastIndex,进行移动,此时 lastIndex 不变, nextIndex++ ,进入下一步
5
当完成新集合中所有节点的差异化对比后,还需要对旧集合进行循环遍历,判断是否勋在新集合中没有但旧集合中存在的节点。
此时发现了 D 满足这样的情况,因此删除 D。
Diff 操作完成。
整个过程还是很繁琐的, 明白过程即可。
二、性能优化
由于react中性能主要耗费在于update阶段的diff算法,因此性能优化也主要针对diff算法。
1
减少diff算法触发次数
减少diff算法触发次数实际上就是减少update流程的次数。
正常进入update流程有三种方式:
1.setState
setState机制在正常运行时,由于批更新策略,已经降低了update过程的触发次数。
因此,setState优化主要在于非批更新阶段中(timeout/Promise回调),减少setState的触发次数。
常见的业务场景即处理接口回调时,无论数据处理多么复杂,保证最后只调用一次setState。
2.父组件render
父组件的render必然会触发子组件进入update阶段(无论props是否更新)。此时最常用的优化方案即为shouldComponentUpdate方法。
最常见的方式为进行this.props和this.state的浅比较来判断组件是否需要更新。或者直接使用PureComponent,原理一致。
需要注意的是,父组件的render函数如果写的不规范,将会导致上述的策略失效。
3. forceUpdate
forceUpdate方法调用后将会直接进入componentWillUpdate阶段,无法拦截,因此在实际项目中应该弃用。
其他优化策略
1. shouldComponentUpdate
使用shouldComponentUpdate钩子,根据具体的业务状态,减少不必要的props变化导致的渲染。如一个不用于渲染的props导致的update。
另外, 也要尽量避免在shouldComponentUpdate 中做一些比较复杂的操作, 比如超大数据的pick操作等。
2. 合理设计state,不需要渲染的state,尽量使用实例成员变量。
不需要渲染的 props,合理使用 context机制,或公共模块(比如一个单例服务)变量来替换。
2
正确使用 diff算法
- 不使用跨层级移动节点的操作。
- 对于条件渲染多个节点时,尽量采用隐藏等方式切换节点,而不是替换节点。
- 尽量避免将后面的子节点移动到前面的操作,当节点数量较多时,会产生一定的性能问题。
看个具体的例子
这时一个 List 组件,里面有标题,包含 ListItem 子组件的members列表,和一个按钮,绑定了一个 onclick 事件.
然后我加了一个插件,可以显示出各个组件的渲染情况。
现在我们来点击改变标题, 看看会发生些什么。
奇怪的事情发生了,为什么我只改了标题, 为什么不相关的 ListItem 组件也会重新渲染呢?
我们可以回到组件生命周期看看为什么。
还记得这个组件更新的生命周期流程图嘛,这里的重点在于这个 shouldComponentUpdate。
只有这个方法返回 true 的时候,才会进行更新组件的操作。我们进步一来看看源码。
可以看到这里,原来如果组件没有定义 shouldComponentUpdate 方法,也是默认认为需要更新的。
当然,我们的 ListItem 组件是没有定义这个 shouldComponentUpdate 方法的。
然后我们使用PureComponent :
其原理为重新实现了 shouldComponentUpdate 生命周期方法,让当前传入的 props 和 state 之前做浅比较,如果返回 false ,那么组件就不会更新了。
这里也放上一张官网的例图:
根据渲染流程,首先会判断shouldComponentUpdate(SCU)是否需要更新。
如果需要更新,则调用组件的render生成新的虚拟DOM,然后再与旧的虚拟DOM对比(vDOMEq)。
如果对比一致就不更新,如果对比不同,则根据最小粒度改变去更新DOM;
如果SCU不需要更新,则直接保持不变,同时其子元素也保持不变。
相似的APi还有React.memo:
回到组件
再次回到我们的组件中, 这次点击按钮, 把第二条数据换掉:
奇怪的事情发生了,为什么我只改了第二个 listItem, 还是全部 10 个都重新渲染了呢?
原因在于 shallow compare , 浅比较。
前面说到,我们不能直接修改 this.state 的值,所以我们把
this.state.members 拷贝出来再修改第二个人的信息。
很明显,因为对象的比较是引用地址,显然是不相等的。
因此 shoudComponentUpdate 方法都返回了 false, 组件就进行了更新。
那么我们怎么能避免这种情况的发生呢?
其中一个方法是做深比较,但是如果对象或数组层级比较深和复制,那么这个代价就太昂贵了。
我们就可以用到 Immutable.js 来解决这个问题,进一步提高组件的渲染性能。
Immutable Data 就是一旦被创建,就是不能再更改的数据。
首先,我们定义了一个 Immutable 的 List 对象,List 对应于原生 JS 的 Array,对 Immutable 对象进行修改、添加或删除操作,都会返回一个新的 Immutable 对象,所以这里 bbb 不等于 aaa。
但是同时为了避免深拷贝吧所有节点都复制一遍带来的性能消耗,Immutable 使用了结构共享,即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其他节点则进行共享。
结果也是我们预期的那样。
性能分析
用好火焰图, 该优化的时候再优化。