React :setState、虚拟dom、diff算法

809 阅读10分钟

一、setState

1. state 和 props 的区别

  • props 是一个从外部传给组件的参数,用于父组件向子组件传递数据,不能被修改
  • state 是组件内部私有的,可以通过setState修改。

为什么不能直接修改 state?

react的 immutable 的理念,在监听数据的变化是通过比较对象引用的方式,直接修改 state 会让 react 无法监听到变化。而 setState 会重新生成新对象。

2. setState 介绍

setState有 2 个参数:

setState(updater [, callback])
  • 第一个参数可以是一个对象或函数
    • 对象形式:会将传入的对象 浅合并state中。这种形式的setState是异步的、批处理的。
    • 函数形式:是带有形参的更新函数(state, props) => stateChange。你不应该直接修改参数,而应该基于stateprops来构建表示变化的新对象。更新函数的返回值会与state 浅合并
this.setState({quantity: 2}); // 传入对象

this.setState((state, prop) => { // 传入函数
    return {counter: state.counter + props.step};
});

setState第一个参数是对象或函数的区别是?

如果后续state的计算依赖于当前state,那么应该传递函数,因为在函数内部可以拿到最新的state。(但state的渲染更新过程仍是异步的。)

1. 参数是对象,当React重新渲染该组件时,this.state.count 会变为 1
add() {
    this.setState({count: this.state.count + 1});
}
handleAdd() { 
    // 假设 this.state.count 从 0 开始
    this.add();
    this.add();
}

2. 参数是函数,当React重新渲染该组件时,this.state.count 会变为 2
add() {
    this.setState((state) => {
        return {count: state.count + 1})
    });
}
  • 第二个参数是可选的回调函数,它将在setState完成合并而且重新渲染组件后执行,因此可以获取更新后的 state。通常,建议用componentDidUpdate()代替。

3. 异步 or 同步?如何获取更新后的 state?

注意,这里的同步是指 state 能否立即更新。

  1. React18会对所有更新自动批处理,所以setState表现为异步。
  2. React18 之前:
    • 同步更新:原生事件clicksetTimeout等异步。
    • 异步更新:合成事件onclick、生命周期函数、(hooks)等。

常见问题:

  • 如何获取更新后的 state?

    componentDidUpdatesetState的回调中获取。在应用更新后,这两种方式都会被触发。

  • 为什么 setTimeout 或原生事件里是同步的?

    • 原生事件:因为它们不会触发 react 的批处理机制。
    • 异步函数:js 的事件循环机制会将异步代码放到任务队列中,等同步代码执行完后再执行。也就是说 react 的批处理完成后再执行异步函数,所以函数里拿到的是最新 state,表现为同步。
  • 为什么要合并更新?

    如果不合并,那么每次调用 setState 都会执行一次更新的生命周期,进行diff比较、更新dom树,比较浪费时间。而将多个setState合并更新,只会产生一次 re-render 、避免了不必要的渲染开销,能够提升性能。

4. setState 和生命周期

  • constructor不能调用 setState,是无意义的,应该用this.state初始化。
  • componentDidMount中调用 setState 就相当于在初始化阶段执行了两次render(),也就是挂载阶段和更新阶段,会影响性能。
  • 更新阶段的生命周期都禁止调用 setState,无限次的触发更新、陷入死循环,导致内存崩溃
  • setState 会触发更新阶段的 5 个生命周期。
是否可以执行setState生命周期
尽量避免componentDidMount
禁止凡是更新阶段的生命周期都禁止:shouldComponentUpdate、render、componentDidUpdate
无意义constructor、componentWillUnmount

5. v18 的setState 批量更新机制:了解

  1. 调用 setState 之后内部会执行一个函数,用来将参数对象推入到队列中。

  2. 在函数执行前会判断队列是否为空,如果为空就会设置一个微任务flush用于清空队列。

  3. 然后继续执行所有同步代码(其中可能包括多个 setState,需要循环第 1、2 步)。

  4. 当同步代码都执行完成后,会执行flush微任务。flush函数会合并队列中所有的 state,然后更新对应的组件。

二、虚拟dom

1. 虚拟dom介绍

虚拟 dom 是一个描述真实 DOM 结构和属性的 JavaScript 对象,通过比较虚拟 dom 来最小化地更新真实 DOM,有利于性能提升。

和真实 DOM 有什么区别?

  • 当真实 DOM 频繁被修改时会重排重绘整棵DOM树,并且执行多次,性能消耗很高。
  • 而虚拟 DOM 频繁被修改时,通过 diff 算法和批量更新机制最后只需要对局部的真实DOM节点重排重绘即可,并且只需要执行一次重排重绘,性能消耗很低。

2. 虚拟dom的优缺点

  • 优点

    • 减少重排重绘以提升性能:见上。
    • 跨平台兼容:虚拟 dom 就是 js 对象,不像 DOM 与平台是强相关的,虚拟 dom 可以更方便地跨平台操作(微信小程序、原生应用、浏览器、Node)。
    • 跨浏览器兼容:React 基于虚拟 dom 实现了一套自己的事件机制,抹平了浏览器的事件兼容性差异
  • 缺点

    • 首次渲染时,react 需要更多的计算,所以速度比 js 原生操作 DOM 慢。
    • 针对一些性能要求比较高的应用,虚拟 dom 无法做到性能的极致优化

3. 虚拟dom转为真实dom:render

jsx -> 真实 dom:

  • jsx 会被 babel 转换成React.createElement进而生成虚拟 dom。
  • 然后虚拟 dom 通过 render 函数调用document.createElement生成真实 dom 节点,最后挂载到目标 dom 上。

render 的第一个参数实际上是React.createElement函数的返回对象;第二个参数是要挂载的目标 DOM。

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('root')  
);
1. babel转换后
ReactDOM.render(
  React.createElement( 'h1', null, 'Hello, world!' ),
  document.getElementById('root')
);
2. render函数内部的过程
function render(vnode, container) {
   1. 如果是vnode是文本节点,直接挂载即可。
   const textNode = document.createTextNode(vnode);
   return container.appendChild(textNode);
   2. 根据vnode的标签名,创建dom节点
   const dom = document.createElement(vnode.tag)
   3. 将vnode.attrs遍历添加到dom节点上,然后递归render它的子节点
   4. 最后将完整的dom节点挂载到真正的目标dom上
    return container.appendChild(dom); 
}

4. diff 算法:深度优先遍历

构建 WIP 树:diff 算法通过对比最新的 vdom 和之前的 current fiber,然后根据比较的结果生成 WIP fiber,进而生成 WIP 树。

Diff 算法是比较最新的 vdom 和之前的 current fiber,然后决定怎么产生新的 fiber,对可复用的节点打上更新的 tag,剩余的旧节点打上删除 tag,新节点打上新增 tag。通过 Diff 来最小化 DOM 的更新,有利于性能优化。

具体操作其实就是reconciliation的阶段:React18 的 Diff

  1. 第一次遍历时会一一对比 vDom 和老的 Fiber,如果可以复用就处理下一个节点,否则就结束遍历。(对比是会比较类型和key值是否都相同)
  2. 如果所有新的 vDom 都处理完了,那就把剩下的老 Fiber 节点删掉。如果还有新的 vDom 没处理,那就进行第二次遍历。
  3. 第二次遍历时将剩下的老 Fiber 放到 map 中(key就是节点的key),然后遍历剩余的新的 vDom,看看 map 中是否有可复用的节点,有就移动过来、打上更新的 tag。
  4. 第二次遍历结束后,将剩余的老的 Fiber 删除,剩余的新的 vDom 新增。

常见问题:

  • key 的作用?

    key 可以帮助识别该节点是否存在于旧集合,尽可能帮我们重用同一层级的节点,执行移动操作。不需要删除、新建节点,减少了不必要的渲染

  • 节点复用的条件?

    只有该节点的类型和 key 都相同,才能复用节点。

  • index 能作为 key 吗?

    不能,如果删除某节点那么后面的元素都会前移,顺序会变化,导致dom节点的不必要的重建,降低性能。

  • diff 还能改进吗?

    在 element 层级中会根据 key 在新旧集合中的大小比较进行移动,假设旧集合a b c d,新集合d a b c,那么 diff 算法的移动规则是会移动a b c,实际可以只移动 d。

    let num1= “react diff 操作节点的频率”,let num2=“相反的移动规则所需的操作节点的频率”,取 Min 移动

三、数据驱动视图原理

React 数据驱动视图是通过setState实现的。

假设我们触发了onClick事件,然后事件处理函数会调用setState进行处理:

  1. v18 中会对 setState 自动批处理,将多个 state 的更新合并为一次。
  2. 当组件开始更新时,会执行更新阶段的五个生命周期,其中执行到render时,会
    • 生成虚拟DOM树
    • 执行 Diff 算法找出变化的组件
    • 然后将变化的组件渲染到页面上

(了解) 虚拟DOM实现原理:jsx 转为虚拟dom,借助createElement

function x() {
  return (
      <div className="box">
          <span>
              <em>title</em>
          </span>
          <div>1111</div>
    </div>  
  )
}
// babel转译
"use strict";
function x() {
    return React.createElement(
        "div", 
        { className: "box"}, 
        React.createElement(
            "span", 
            null,
            React.createElement("em", null, "title”)
        ), 
        React.createElement("div", null, "1111")
    );
}
  1. jsx 语法会被 babel 转换成React.createElement(tag, attrs, ...children)
  2. React.createElement 方法接收 元素类型、属性、子节点 这三个参数,返回一个ReactElement(type, key, ...ReactCurrentOwner.current),其中ReactCurrentOwner.currentFiber节点
  3. ReactElement 函数只在原参数的基础上多包装了一个$$type: REACT_ELEMENT_TYPE属性,用来识别这个元素是 React 的元素。

虚拟 dom 就是一个 dom 树结构的 js 对象,它的转换过程是:react 中的 jsx 语法 {只转换dom节点<div>} 会被 babel 转换成React.createElement(tag, attrs, ...children)代码。

  • tag:dom 节点的标签名。
  • attrs:是一个对象,里面包含了所有的属性。
  • 第 3 个参数往后,就是它的子节点。
const t = <h1 color="blue" className="title">Click Me</h1>;
1. babel编译为
React.createElement(
  h1, 
  {color:'blue', className:'title'},
  'Click Me'
)
2. createElement函数的内部过程:返回一个对象来保存它的信息,这个对象就是虚拟dom!!!
function createElement(tag, attrs, ...children) {
  2.1 初始化一个props,用作返回值
  props={}; key=null;ref=null;self=null;source=null;
  2.2 将attrs的属性、子节点都填充到props中(判断组件属性上是否有特殊属性`ref属性或key属性`,无则为null)
     (1if(hasValidKey(attrs)) ref=attrs.ref; key=''+attrs.key(用于diff的key)
     (2)遍历attrs,向props中添加属性
  2.3 返回一个对象
  return {
      // 只是多了一个typeof属性,唯一识别这个元素是ReactElement的标识
      $$typeof:REACT_ELEMENT_TYPE,  
      tag: tag,
      attrs: attrs:
      children:children,
      ref: ref
      key: key
   };  
}

babel: es6 -> es5,jsx -> js。

v17开始,jsx 转换时会引入 jsx-runtime 处理。

import { jsx as _jsx } from "react/jsx-runtime";
import { jsxs as _jsxs } from "react/jsx-runtime";
function x() {
  return _jsxs("div", {
    className: "box",
    children: [_jsx("span", {
      children: _jsx("em", {
        children: "title"
      })
    }), _jsx("div", {
      children: "1111"
    })]
  });
}

帮助理解 Diff

react 分别对 tree、component、element 三个层级的 diff 进行优化,时间复杂度为O(n),只需一次遍历就能完成整个 DOM 树的比较,优化策略为:

  1. tree 层级:对树进行分层比较,会忽略跨层级的操作。如果发现节点已经不存在,则会删除该节点及其子节点(只有创建、删除操作)。

  2. component 层级 (react 假设相同类型生成相似树形结构的策略)

    • 如果是同一类型的组件(div、p等)则继续比较。
    • 如果不是,则就删除该组件和它所有子节点,重新创建。
  3. element 层级 :针对同一层级的节点,通过 key 来识别哪些元素是移动的。

    • 插入、删除:如果该节点只在旧集合存在或只在新集合中存在。
    • 移动:当新旧集合中存在相同节点时,比较节点在新旧集合中的下标,当新下标 > 旧下标就会移动,否则不移动

过时内容:setState 更新机制

该部分针对v16,和v18会有出入。

1. 初始渲染

  1. 假如我们触发了click事件去setState,首先在 dispatchEvent方法中调用batchedUpdates批量更新方法;
  2. 批量更新方法会将批量更新标识设置为true,然后执行更新事务的perform方法;
return transaction.perform(callback, null, a, b, c, d, e);
1. callback 就是事务中的目标方法,也就是click的监听函数
2. a/b/c...就是
  1. 更新事务中有两个wrapper方法:
 // wrapper1:
 FLUSH_BATCHED_UPDATES = {
  // initialize:空函数,什么都不做
  initialize: emptyFunction,
  // close:调用flushBatchedUpdates,该函数作用是批量更新组件
  close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates),
}
// wrapper2:
RESET_BATCHED_UPDATES = {
  // initialize:空函数,什么都不做
  initialize: emptyFunction,
  // close:将 全局 React批量更新标识(ReactDefaultBatchingStrategy.isBatchingUpdates)重置为false
  close: function () {  ReactDefaultBatchingStrategy.isBatchingUpdates = false; },
}
  • 第 1 个是FLUSH_BATCHED_UPDATES,负责批量更新组件,即虚拟dom真实dom的过程。
  • 第 2 个是RESET_BATCHED_UPDATES,负责将React的批量更新标识isBatchingUpdate重置为false
  1. 更新事务的执行流程为: image.png
transaction.perform(callback, null, a, b, c, d, e)
  • 事务initialize:啥也不干;
  • 事务perform:执行setState传入的callback方法;
  • 事务close:执行两个wrapperclose方法,
    • 首先组件批量更新;
    • 然后将批量更新标识isBatchingUpdate设置为false

2. 再次渲染

if (alreadyBatchingUpdates) {
  return callback(a, b, c, d, e);
} else {
  return transaction.perform(callback, null, a, b, c, d, e);
}

(参考《数据驱动视图》)

判断当前是否处理批量更新状态,

  • 是,就将待更新的组件放到dirtyComponents中;
  • 否,开启批量更新事务,批量更新组件,然后将批量更新标识重置为false

参考

  1. How V-DOM and diffing works in React
  2. setState机制
  3. 协调中的diff思想