React理解
1、React Element
React Element是React的virtual DOM,本质上就是一个普通的对象,相较于浏览器的DOM更加轻量,它是Component的组成部分,是构建React应用的最小单元。
React Element通常由render函数返回的JSX创建,但其本质上只是React.createElement(component, props, …children)的语法糖。
React Element有类型之分,比如JSX的标签名就决定了React Element的类型,不同的JSX标签,就是不同类型的React Element。
React Element有内容(children)和属性(attribute),但是一旦React Element被创建之后,是无法改变其内容或属性的。即,React Element都是immutable不可变的。
更新界面的唯一办法是创建一个新的React Element,会由React DOM对比(diff)新旧React Element之后,只把改变了的部分更新到浏览器DOM上。
2、React Components
React的主要特征就是由Components组成。Components可以将UI切分成一些的独立的、可复用的部件,这样你就只需专注于构建每一个单独的部件。
所有的React组件必须像纯函数那样使用它们的props,就是不能修改props,只能使用props。
满足以下两点称为纯函数:
- 返回的值仅依赖于传入的参数,而不依赖于内部或外部的其它状态
- 纯函数运行没有副作用(例如本地静态变量或非局部变量的变异,或执行I / O操作)
2.1 Functional && Class Component
Functional Component 函数定义的组件需要是一个函数,接收单一的props对象作为参数,然后返回一个React Element。
Class Component 使用ES6语法定义的组件,必须继承自React.Component(或PureComponent),实现render函数并返回React Element。Class Component可以有自己的state,用来实现局部状态(或封装)。
2.2 PureComponent
PureComponent改变了生命周期方法shouldComponentUpdate,并且它会自动检查组件是否需要重新渲染。这时,只有PureComponent检测到state或者props发生变化时,PureComponent才会调用render方法。
PureComponent对state和props的变化都是浅比较,浅比较的意思是对state或props中每个属性之前和之后的值使用Object.is来进行比较。可以看出Object.is可以对基本数据类型:null,undefined,number,string,boolean做出非常精确的比较,但是对于引用数据类型是没办法直接比较的。
所以PureComponent的使用场景是:
- props和state的对象中的属性值都是简单类型
- 确定深层数据结构改变时使用forceUpdate
- 使用immutable对象
React.PureComponent 的 shouldComponentUpdate() 会跳过整个组件子树的 prop 更新,也就是如果父PureComponent不更新,子组件也不可能更新;父PureComponent更新,子组件才更新。因此使用时请确保所有子组件同样是“纯”的。
3、 组件生命周期
- 首次装载组件时,按顺序执行:getDefaultProps、getInitialState、componentWillMount、render和componentDidMount;
- 在重新装载组件时,此时按顺序执行 getInitialState、componentWillMount、render 和 componentDidMount,但并不执行 getDefaultProps;
- 当再次渲染组件时,组件接受到更新状态,此时按顺序执行 componentWillReceiveProps、shouldComponentUpdate、componentWillUpdate、render 和 componentDidUpdate。
- 当卸载组件时,执行 componentWillUnmount;
如下图所示:
React 通过三种状态:MOUNTING、RECEIVE_PROPS、UNMOUNTING,管理整个生命周期的执行顺序。这三个状态对应三种方法,分别为:mountComponent、updateComponent、unmountComponent。
状态一:MOUNTING
mountComponent负责管理生命周期中的getInitialState、componentWillMount、render和componentDidMount。
由于getDefaultProps是通过Constructor进行管理,因此也是整个生命周期中最先开始执行,而mountComponent只能望洋兴叹,无法调用到getDefaultProps。这就解释了为何getDefaultProps只执行1次的原因。
由于通过ReactCompositeComponentBase返回的是一个虚拟节点,因此需要利用instantiateReactComponent去得到实例,再使用mountComponent拿到结果作为当前自定义元素的结果。关于这部分将虚拟节点转换成DOM元素的过程还是有必要再深挖的。
首先通过mountComponent装载组件,此时,将状态设置为MOUNTING,利用getInitialState获取初始化state,初始化更新队列。
若存在componentWillMount,则执行;如果此时在componentWillMount中调用setState,是不会触发reRender,而是进行state合并。
到此时,已经完成MOUNTING的工作,更新状态为NULL,同时state也将执行更新操作,此刻在render中可以获取更新后的this.state数据。
其实,mountComponent本质上是通过递归渲染内容的,由于递归的特性,父组件的componentWillMount一定在其子组件的componentWillMount之前调用,而父组件的componentDidMount肯定在其子组件的componentDidMount之后调用。
当渲染完成之后,若存在componentDidMount则触发。这就解释了componentWillMount-render-componentDidMount三者之间的执行顺序。
如下图所示:
状态二:RECEIVE_PROPS
updateComponent负责管理生命周期中的componentWillReceiveProps、shouldComponentUpdate、componentWillUpdate、render和componentDidUpdate。
首先通过updateComponent更新组件,如果前后元素不一致说明需要进行组件更新,此时将状态设置为RECEIVING_PROPS。
若存在componentWillReceiveProps,则执行;如果此时在componentWillReceiveProps中调用setState,是不会触发reRender,而是进行state合并。
到此时,已经完成RECEIVING_PROPS工作,更新状态为NULL,同时state也将执行更新操作,此刻this.state可以获取到更新后的数据。
调用shouldComponentUpdate判断是否需要进行组件更新,如果存在componentWillUpdate,则执行。
updateComponent本质上也是通过递归渲染内容的,由于递归的特性,父组件的componentWillUpdate一定在其子组件的componentWillUpdate之前调用,而父组件的componentDidUpdate肯定在其子组件componentDidUpdate之后调用。
当渲染完成之后,若存在componentDidUpdate,则触发,这就解释了componentWillReceiveProps-componentWillUpdate-render-componentDidUpdate它们之间的执行顺序。
如下图所示:
状态三:UNMOUNTING
unmountComponent负责管理生命周期中的componentWillUnmount。
首先将状态设置为UNMOUNTING,若存在componentWillUnmount,则执行;如果此时在componentWillUnmount中调用setState,是不会触发reRender。更新状态为NULL,完成组件卸载操作。
2 setState的更新机制
setState在React合成事件和react的生命周期函数中执行是异步的,而在一些异步(setTimeout、ajax请求)和原生DOM事件中是同步的。在React17中可能会全部处理为异步。
在setState之后进行了如下的流程:
调用enqueueSetState方法,这个方法获取当前组件的internalInstance,将setState中需要更新的state参数push进internalInstance._pendingStateQueue中,然后将internalInstance交给enqueueUpdate处理。
调用enqueueUpdate这个方法,在这个方法中有一个isBatchingUpdates的状态,这个状态标识是否处于创建、更新阶段。
- 如果不处于创建、更新阶段(isBatchingUpdates为false),则执行batchedUpdates开启批量更新,在batchedUpdates中做了两件事:
- 将isBatchingUpdates设为true
- 用事务transaction.perform执行更新事务
这两件事后就处于创建、更新阶段。
- 如果处于创建、更新阶段,就不会立刻去更新组件,而是先把当前的组件放在dirtyComponent里。所以不是每一次的setState都会更新组件,这就解释了我们常常听说的:setState是一个异步的过程,它会集齐一批需要更新的组件然后一起更新。
3 批量更新
第一种情况, React 在首次渲染组件的时候会调用batchedUpdates, 然后开始渲染组件。那么为什么要在这个时候启动一次batch呢? 不是因为要批量插入, 因为插入过程是递归的, 而是因为组件在渲染的过程中, 会依顺序调用各种生命周期函数, 开发者很可能在生命周期函数中(如componentWillMount或者componentDidMount)调用setState. 因此, 开启一次batch就是要存储更新(放入dirtyComponents), 然后在事务结束时批量更新. 这样以来, 在初始渲染流程中, 任何setState都会生效, 用户看到的始终是最新的状态。
第二种情况, 如果你在HTML元素上或者组件上绑定了事件, 那么你有可能在事件的监听函数中调用setState, 因此, 同样为了存储更新(放入dirtyComponents), 需要启动批量更新策略. 在回调函数被调用之前, React事件系统中的dispatchEvent函数负责事件的分发, 在dispatchEvent中启动了事务, 开启了一次batch, 随后调用了回调函数. 这样一来, 在事件的监听函数中调用的setState就会生效.也就是说, 任何可能调用 setState 的地方, 在调用之前, React 都会启动批量更新策略以提前应对可能的setState。
1 | promise.then(() => { |
2、React虚拟节点和diff算法
在不使用现代框架的早期阶段,当页面上数据状态变更后,需要操作对应的DOM元素,页面上监听的事件越多,回调中的DOM操作也越多。手动进行DOM的操作,有两个缺陷:
1、操作DOM的复杂性,使代码结构不清晰
2、人为操作DOM,可能性能不回达到最佳
既然状态改变了要操作相应的DOM元素,为什么不做一个东西可以让视图和状态进行绑定,状态变更了视图自动变更,就不用手动更新页面了。这就是后来人们想出了MVVM模式,只要在模版中声明视图组件是和什么状态进行绑定的,双向绑定引擎就会在状态更新的时候自动更新视图。
MVVM可以很好的降低我们维护状态->视图的复杂程度(大大减少代码中的视图更新逻辑)。
然而对于react的VirtualDOM而言,即使一个小小的状态变更都要重新构造整棵DOM。只是在react的实现中,加了一些特别的步骤来避免整棵DOM树变更。
一个DOM元素的属性非常多,处理DOM不可能会比JS对象处理起来快。VirtualDOM使用JS对象来表示一个节点
1 | var element = { |
上面对应的HTML写法是:
1 | <ul id='list'> |
所以上面所说的,状态变更->重新渲染整个视图的方式可以稍微修改一下:用 JavaScript 对象表示 DOM 信息和结构,当状态变更的时候,重新渲染这个 JavaScript 的对象结构。当然这样做其实没什么卵用,因为真正的页面其实没有改变。
但是可以用新渲染的对象树去和旧的树进行对比,记录这两棵树差异。记录下来的不同就是我们需要对页面真正的 DOM 操作,然后把它们应用在真正的 DOM 树上,页面就变更了。这样就可以做到:视图的结构确实是整个全新渲染了,但是最后操作DOM的时候确实只变更有不同的地方,而非重新渲染整个页面,从而保证了每次操作更新后页面的高效渲染。
diff算法返回的是最优更新DOM的方式。
然而,使用Virtual DOM不一定在性能上强于直接操作DOM:
因为使用diff算法,需要先比较两棵Virtual DOM树,得出需要变化的部分,最后再去修改DOM。这个过程可能不比直接操作DOM要快。
但是使用virtual dom最大的好处是:
- 抽象了视图层的操作方法,使用用户可以不用直接操作DOM;
- 同时在大量操作DOM时,一定比大量操作Virtual DOM效率低。
传统的diff算法,算法复杂度高达 O(n^3),改进后的算法复杂度为O(n)
diff算法优化基于三点前提: - Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
- 两个不同类型的元素将产生不同的树。
- 通过渲染器附带key属性,开发者可以示意哪些子元素可能是稳定的。
基于以上三个前提假设,React 分别对 tree diff、component diff 以及 element diff 进行算法优化
tree diff
根据前提1,React diff算法将树进行分层,两棵树只会对同一层次的节点进行比较。
既然 DOM 节点跨层级的移动操作少到可以忽略不计,针对这一现象,React 通过 updateDepth 对 Virtual DOM 树进行层级控制,只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。
当存在跨层级的移动操作呢?此时也是按层比较,不会进行元素层级间的移动,而会新增或是删除节点。这是一种影响 React 性能的操作,因此 React 官方建议不要进行 DOM 节点跨层级的操作。component diff
针对不同类型的元素
React会销毁该元素及其所有的子元素,并重新构建新的元素及其所有的子元素;
DOM元素:直接销毁并重建;
Component元素:销毁前,该Component实例会收到componentWillUnmount();重建时,新Component实例会收到componentWillMount() 和 componentDidMount(),这会导致该Component的state丢失。针对相同类型的元素
DOM元素:例如div、h1等,React会比较两者的属性,仅更新变化的属性,并递归其子元素;
Component元素:会保留该Component的实例,并在该实例上依次调用componentWillReceiveProps() 和 componentWillUpdate() 方法,该Component的state会保留。在组件元素的render方法被调用的时候,diff算法会继续以该Component为根元素进行递归处理;
- element diff
当节点处于同一层级时,React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。
当同一层上节点一样,但是节点顺序不同,更新时对每一个节点都需要进行插入新节点和删除旧节点的操作,导致操作繁琐冗余。因为这些都是相同的节点,但由于位置发生变化,导致需要进行繁杂低效的删除、创建操作,其实只要对这些节点进行位置移动即可。
针对这一现象,React 提出优化策略:允许开发者对同一层级的同组子节点,添加唯一 key 进行区分,虽然只是小小的改动,性能上却发生了翻天覆地的变化!
有key:为了提高效率并保证稳定性,可以给所有的children加“key”。该key值需要在同一兄弟元素之间应该是独一无二的,这样就能快速地通过该key值做对比,本质上是一种hash的思想。
3. React 数据是如何驱动视图的
MVVM的viewmodel层将数据与视图进行绑定,操作数据等同于操作视图,数据修改后视图自动更新。
当state或者是props发生变化后,将该组件标记为dirty,然后生成一个新的VDOM 树,对比新旧VDOM树,使用diff算法进行更新操作。
4. 废弃的react生命周期原因及新生命周期如何适配旧的生命周期
componentWillMount、componentWillReceiveProps、componentWillUpdate是即将在17版本中被标记为unsafe的生命周期函数。这里的“不安全”不是指安全性,而是传达使用这些生命周期的代码更有可能在React的未来版本中出现错误,特别是在启用异步渲染时。
- componentWillMount
使用constructor和componentDidMount来覆盖
- 如果要在componentWillMount中放入setState改变状态,可以将这部分代码放入constructor中。
- 如果要在componentWillMount中进行异步操作,可以将这部分代码写在componentDidMount中。
- 如果想在componentWillMount中订阅事件,可能会发生内存泄漏,因为如果server rendering或异步rendering出错,componentWillUnmount将不会触发,因此就无法在componentWillUnmount中进行取消订阅,导致内存泄漏。应该将componentWillMount中的订阅事件放入componentDidMount中,这样只有在server rendering或异步rendering顺利执行后才会调用componentDidMount,就不会发生内存泄漏。
- componentWillReceiveProps
总结来说当有关根据props更新state就将这部分逻辑存放在getDerivedStateFromProps中,如果和异步操作有关(如数据更新)就使用componentDidUpdate。
因为componentWillReceiveProps和componentWillUpdate可能会在正式更新之前调用好几次,所以要避免将有副作用的操作放在这个生命周期函数中执行,而应该将这些放入componentDidUpdate方法中,因为componentDidUpdate确保只执行一次。
componentWillReceiveProps方法用于根据props更新state,但它经常被错误地用于确实存在问题的方式。 因此该方法将被弃用。
响应props以更新state的推荐方法是使用新的静态getDerivedStateFromProps生命周期。getDerivedStateFromProps使用在实例化组件之后以及re-render组件之前,它可以返回一个更新state的对象,或者返回null以指示新的props不需要任何state更新。 - componentWillUpdate
使用getSnapshotBeforeUpdate和componentDidUpdate来覆盖。
有时候componentWillUpdate需要用来比如重新渲染期间手动保留滚动位置时,但是因为异步渲染,所以“渲染”阶段生命周期(如componentWillUpdate和render)和“commit”阶段生命周期(如componentDidUpdate)之间可能存在延迟。 如果用户在此期间执行类似调整窗口大小的操作,则从componentWillUpdate读取的scrollHeight值将过时。此问题的解决方案是使用新的“提交”阶段生命周期getSnapshotBeforeUpdate。 在进行突变之前(例如在更新DOM之前)立即调用该方法。 它可以返回一个React的值作为参数传递给componentDidUpdate,它在突变后立即被调用。
很多时候人们会误用componentWillUpdate是担心componentDidUpdate触发时,更新其他组件的状态“为时已晚”。但react确保任何setState在componentDidMount和componentDidUpdate调用时,会在用户看到UI之前立刻刷新。如果在componentWillUpdate中对state变化进行事件绑定,componentWillUpdate可能会发生多次,导致进行多次事件绑定,因此可以将这部分内容放在componentDidUpdate中进行。
5. react和vue
在vue中createElement方法用来创建一个虚拟节点。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。
- JSX vs Template
对于一些开发者而言,模板更容易理解;并且相比JSX,模板可以更好的把功能和布局分割开来;并且比起模板,渲染函数更易于调试和测试。 - 数据层
React组件内部通过state来维护组件状态的变化,这也是state唯一的作用。
React里的state只能用setState方法改变。使用setState可以合并需要修改的state,避免多次触发reRender。
Vue中的数据是可变的(mutated) - 如何实现批量更新
react使用setState,vue使用nextTick
Vue会把一轮事件循环(即一次task)中所有触发的watcher去重后添加到一个队列里,然后将这个队列交由Vue.nextTick(),即将这个队列添加到microtask中,这样在本次task结束后,按照规则就会取出所有的microtask执行它们,实现DOM的更新。
就是说如果方法是通过React调用的比如生命周期函数,React的事件处理等,那么会进行批量更新,自己调用的方法,比如setTimeout,xhr等则是连续更新。当批量更新时,react将组件需要更新的状态放入dirtyComponents队列中。在react中有事务(transaction)的概念,事务就是在真正执行method之前加一些预处理和之后加一些尾处理。react将mounting放入method中,然后在mounting结束后的尾处理中,进行批量更新。
看到批量更新vue和react的区别:
vue:
依赖浏览器Api与事件处理队列
不可控(我们无法通过编码改变它)
react:
纯JS实现,不依赖浏览器Api
可控性强,可手动调用(因为可编码)
是否异步需要看具体场景,易出错(需要对源码有了解) - 生命周期
生命周期不一样 - 组件间的数据通信
vue:
子=>父通信:通过父组件给子组件传递的回调函数,和子组件的自定义事件通信
react:
子=>父通信:通过父组件给子组件传递的回调函数
共同:
父=>子通信:props
兄弟组件之间的通信:寻找其共同的父组件,使用数据和相关方法“提升”到父组件内部,并向下传给两个子组件。其中一个子组件取得数据,另一个子组件取得了改变数据的方法。
redux:这样就出现了一个模式:数据总是单向从顶层向下分发的,但是只有子组件回调在概念上可以回到state顶层影响数据。这样state一定程度上是响应式的。为了面临所有可能的扩展问题,最容易想到的办法就是把所有state集中放到所有组件顶层,然后分发给所有组件。为了有更好的state管理,就需要一个库来作为更专业的顶层state分发给所有React应用,这就是Redux。
全局通信:可以定义一个全局的eventEmitter,一个地方发送消息,另一个地方监听并接收消息,很容易想到的就是发布订阅模式了。 - 事件
vue中的事件分为两种一种DOM绑定事件还有一种是自定义事件,自定义事件用于子组件向父组件传递数据,子组件使用$emit触发一个自定义事件,父组件使用v-on监听这个自定义事件。
react中的事件是合成事件。在DOM中事件处理函数是一个字符串,而在react的JSX语法中是一个函数。在react中不能使用return false表明阻止默认行为,而必须明确使用preventDefault,在React中定义了合成事件,不需要考虑浏览器的兼容性。使用es6的class语法来定义一个组件时,事件处理器会成为类的一个方法。
DOM上绑定了过多的事件处理函数,整个页面响应以及内存占用可能都会受到影响。React为了避免这类DOM事件滥用,同时屏蔽底层不同浏览器之间的事件系统差异,实现了一个中间层——SyntheticEvent。
合成事件有几个特性:
1、合成事件对象是共享的,只有一个,这是出于性能因素考虑。在当前事件回调完成之后,会初始化事件对象属性的内容,以便下一次重用。
2、合成事件是基于事件委托实现的。直接在DOM树的document上监听原生事件,然后合成对应的事件,根据target分发到对应的React Element上去。同时还实现了event poll,就是事件池,这样可以复用合成的事件对象。
6. 新特性和改进
New render return types: fragments and strings; (支持返回数组组件)
Better error handling (更好的错误处理)
Portals (新特性)
Better server-side rendering (更好的服务端渲染)
Support for custom DOM attributes (支持自定义 DOM 属性)
Reduced file size (体积更小)
New core architecture (新的 Fiber 架构)
7. react的常用性能优化策略
- 使用Production Build
- shouldComponentUpdate
使用shouldComponentUpdate避免不必要组件的re-render。在大多数情况下可以直接继承React.PureComponent - 使用不可变数据,使用Immutable数据结构
immutable.js提供了不可变的数据结构。不可变性使跟踪变化变得便宜。更改将始终生成新对象,因此我们只需要检查对象的引用是否已更改。使用shouldComponentUpdate时,方便比较 - 使用chrome的performance来改进性能
参考文章:
https://github.com/livoras/blog/issues/13
https://zhuanlan.zhihu.com/p/20312691
https://stackoverflow.com/questions/48563650/does-react-keep-the-order-for-state-updates/48610973#48610973
https://github.com/facebook/react/issues/11527#issuecomment-360199710
https://zhuanlan.zhihu.com/p/20328570
https://github.com/Bogdan-Lyashenko/Under-the-hood-ReactJS/blob/master/stack/book/Part-1.md