yanyue404/blog

React 内部是如何工作的 ?

yanyue404 opened this issue · 0 comments

React 内部是如何工作的 ?

Virtual DOM

1. React 元素

在浏览器环境(宿主环境)中,一个 DOM 节点宿(宿主实例)是最小的构建单元。而在 React 中,最小的构建单元是 React 元素。

React 元素是一个普通的 JavaScript 对象。它用来描述 DOM 节点。

// JSX 是用来描述这些对象的语法糖。
// <button className="blue" />
{
  type: 'button',
  props: { className: 'blue' }
}

2. jsx 生成 tree

中间过程经过 babel 编译, createElement 的参数有三个:

  1. type -> 标签类型
  2. attributes -> 标签属性,没有的话,可以为 null
  3. children -> 标签的子节点
return React.createElement(
  'div',
  { className: 'cn' },
  React.createElement(Header, null, 'Hello, This is React'),
  React.createElement('div', null, 'Start to learn right now!'),
  'Right Reserve',
);

对比 render 函数被调用的时候,会返回的 tree 对象,复杂结构会在 children 中递归生成

{
  type: 'div',
    props: {
      className: 'cn',
        children: [
          {
            type: function Header,
            props: {
                children: 'Hello, This is React'
            }
          },
          {
            type: 'div',
            props: {
                children: 'start to learn right now!'
            }
          },
          'Right Reserve'
      ]
  }
}

我们来观察一下这个对象的 children,现在有三种类型:

  1. string
  2. 原生 DOM 节点
  3. React Component - 自定义组件

除了这三种,还有两种类型:

  1. false ,null, undefined, number
  2. 数组 使用 map 方法的时候

3. 递归形渲染过程

由内到外递归渲染

  • father componentWillMount
  • father render
  • son componentWillMount
  • son render
  • son componentDidMount
  • ... other sons
  • father componentDidMount

它会像这样执行:

  • ReactDOM.render(<App />, domContainer)
  • ReactApp ,你想要渲染什么?
    • App :我要渲染包含 <Content><Layout>
  • React<Layout> ,你要渲染什么?
    • Layout :我要在 <div> 中渲染我的子元素。我的子元素是 <Content> 所以我猜它应该渲染到 <div> 中去。
  • React<Content> ,你要渲染什么?
    • <Content> :我要在 <article> 中渲染一些文本和 <Footer>
  • React<Footer> ,你要渲染什么?
    • <Footer> :我要渲染含有文本的 <footer>
  • React: 好的,让我们开始吧:
// 最终的 DOM 结构
<div>
  <article>
    Some text
    <footer>some more text</footer>
  </article>
</div>

这就是为什么我们说协调是递归式的。当 React 遍历整个元素树时,可能会遇到元素的 type 是一个组件。React 会调用它然后继续沿着返回的 React 元素下行(children)。最终我们会调用完所有的组件,然后 React 就会知道该如何改变 DOM 树。

diff 算法

React 的 render 方法,它能将虚拟 DOM 渲染成真正的 DOM。为了减少 DOM 更新数量,我们需要找渲染前后真正变化的部分,只更新这一部分 DOM。而对比变化,找出需要更新部分的算法我们称之为 diff 算法。React 框架选择直接对比虚拟 DOM 和真实 DOM,这样就不需要额外保存上一次渲染的虚拟 DOM,并且能够一边对比一边更新。

不管是 DOM 还是虚拟 DOM,它们的结构都是一棵树,完全对比两棵树变化的算法时间复杂度是 O(n^3),但是考虑到我们很少会跨层级移动 DOM,所以我们只需要对比同一层级的变化。

传统 diff 算法

React Diff

综上所述, diff 算法有两个原则:

  • 对比当前真实的 DOM 和虚拟 DOM,在对比过程中直接更新真实 DOM
  • 只对比同一层级的变化

实现

diff 方法,它的作用是对比真实 DOM 和虚拟 DOM,最后返回更新后的 DOM

  • tree diff
  • component diff
  • element diff

1. tree diff

tree 是由 众多 component 组件构成 ,React 对树的同一层级进行比较,当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。然后继续对树进行递归遍历,去比较 component。

当 React 节点同一层级根节点不一致(也就是发生跨层级的移动操作),React diff 会只有创建和删除操作,将创建新的节点变化的原节点销毁。

// 第一次渲染
ReactDOM.render(
  <dialog>
    <input />
  </dialog>,
  domContainer,
);

// 下一次渲染
ReactDOM.render(
  <dialog>
    <p>I was just added here!</p>
    <input />
  </dialog>,
  domContainer,
);

在这个例子中,<input> 宿主实例会被重新创建。React 会遍历整个元素树,并将其与先前的版本进行比较:

  • dialog → dialog :能重用宿主实例吗?能 — 因为类型是匹配的
    • input → p :能重用宿主实例吗?不能,类型改变了! 需要删除已有的 input 然后重新创建一个 p 宿主实例。
    • (nothing) → input :需要重新创建一个 input 宿主实例。

因此,React 会像这样执行更新:

let oldInputNode = dialogNode.firstChild;
dialogNode.removeChild(oldInputNode);

let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.appendChild(pNode);

let newInputNode = document.createElement('input');
dialogNode.appendChild(newInputNode);

2.component diff

有以下 3 个比较策略:

  • 相同类的组件,则继续比较组件下的节点树,递归比较直至 element
  • 不同类的组件,则将该组件定位 dirty component,从而将该组件删除,替换为新组件
  • 相同类的组件,有可能其组件下的节点没有任何变化,如果能够知道这点就可以节省大量的 diff 运算时间,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff
function Form({ showMessage }) {
  let message = null; // 占位用
  if (showMessage) {
    message = <p>I was just added here!</p>;
  }
  return (
    <dialog>
      {message}
      <input />
    </dialog>
  );
}

不管 showMessagetrue 还是 false ,在渲染的过程中 <input> 总是在第二个孩子的位置且不会改变。

如果 showMessagefalse 改变为 true ,React 会遍历整个元素树,并与之前的版本进行比较:

  • dialog → dialog :能够重用宿主实例吗?能 — 因为类型匹配
    • (null) → p :需要插入一个新的 p 宿主实例。
    • input → input :能够重用宿主实例吗?能 — 因为类型匹配

之后 React 大致会像这样执行代码:

let inputNode = dialogNode.firstChild;
let pNode = document.createElement('p');
pNode.textContent = 'I was just added here!';
dialogNode.insertBefore(pNode, inputNode);

3. element diff

比较树中同一位置的元素类型对于是否该重用还是重建相应的宿主实例往往已经足够。

但这只适用于当子元素是静止的并且不会重排序的情况。在上面的例子中,即使 message 不存在,我们仍然知道输入框在消息之后,并且再没有其他的子元素。

而当遇到动态列表时,我们不能确定其中的顺序总是一成不变的。

function ShoppingList({ list }) {
  return (
    <form>
      {list.map(item => (
        <p>
          You bought {item.name}
          <br />
          Enter how many do you want: <input />
        </p>
      ))}
    </form>
  );
}

当节点处于同一层级时,React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。

React 允许开发者对于这一层级的同组子节点,添加唯一 key 进行区分,提高 diff 性能,避免卸载后又再次创建的操作出现。

function ShoppingList({ list }) {
  return (
    <form>
      {list.map(item => (
        <p key={item.productId}>
          You bought {item.name}
          <br />
          Enter how many do you want: <input />
        </p>
      ))}
    </form>
  );
}

key 给予 React 判断子元素是否真正相同的能力,即使在渲染前后它在父元素中的位置不是相同的。

key 赋予什么值最好呢?最好的答案就是:什么时候你会说一个元素不会改变即使它在父元素中的顺序被改变? 例如,在我们的商品列表中,商品本身的 ID 是区别于其他商品的唯一标识,那么它就最适合作为 key 。

fiber 架构

React 16 之前的 diff 阶段的比较是不可被打断,React16 由主线程不间断使用 Diff(同步比较 + 同步更新) 变为 自由释放主线程(可打断的比较 + 异步更新)可以被打断的新的 fiber 架构。

参考