react render没更新_Fiber 内部: 深入理解 React 的新 reconciliation 算法

最近在看 React, 发现一篇深度好文, 忍不住就翻译了.

React 是一个用于构建用户界面的库, 它的核心是跟踪组件状态变化并将它们更新到页面上. 在 React 中, 我们称这个过程为 reconciliation. 当调用 setState 方法的时候, react 会检查 state 和 props 是否发生了变化, 并重新渲染组件.

本文中, 我们将会深入理解一些重要的概念和与算法相关的数据结构.

背景知识

下面是一个简单的应用, 页面上有一个按钮, 每次点击都会增加数字并渲染在页面上.

ec5b338c9be5ac88e966bca347074fc0.gif

下面是代码:

class ClickCounter extends React.Component {
  constructor(props) {
    super(props)
    this.state = {count: 0}
    this.handleClick = this.handleClick.bind(this)
  }
  handleClick() {
    this.setState(() => {
      return {count: state.count + 1}
    })
  }
  render() {
    return [
        <button key="1" onClick={this.handleClick}>Update counter</button>,
        <span key="2">{this.state.count}</span>
    ]
  }
}

上面是一个简单的组件, 在 render 方法里返回两个子元素 buttonspan, 点击按钮的时候, 组件的 state 会被内部的 handleClick 方法更新.

React 在执行 reconciliation 的时候会有很多不同的活动, 拿上面的例子来说, 第一次渲染和状态更新的时候会执行以下操作:

  • 更新 ClickCounter 的 state 中的 count 属性
  • 检索和比较 ClickCounter 的子元素和他们的 props
  • 更新 span 元素的props

在 reconciliation 期间还有很多其他的活动, 像调用生命周期函数, 更新 refs. 在 Fiber 的架构中, 这些活动都统一的称为 "work". work 的类型通常根据 React element 的类型决定. 例如, 对于类组件会创建实例, 但却不会对函数组件作同样的事. 在 React 中有非常多种类的元素, class components, function components, host components(DOM nodes), portal 等等. React element 的类型一般在 createElement 函数的第一个参数定义. 这个函数用来创造一个元素,通常在 render 方法中调用.

从 React Element 到 Fiber nodes

React 中的每个组件都代表了一个 UI. 例如下面的 ClickCounter 组件所展示的模板.

<button key="1" onClick={this.onClick}>Update counter</button>
<span key="2">{this.state.count}</span>

React Elements

当模板经过 JSX 编译后, 我们会得到一堆的 React elements, 他们是真正从 render 方法里返回的东西, 不是HTML. 因为我们不要求使用 JSX, 所以 ClickCounter 组件 render 方法里的返回可以被重写为:

 class ClickCounter {
    ...
    render() {
        return [
            React.createElement(
                'button',
                {
                    key: '1',
                    onClick: this.onClick
                },
                'Update counter'
            ),
            React.createElement(
                'span',
                {
                    key: '2'
                },
                this.state.count
            )
        ]
    }
}

在 render 方法里调用的 React.createElement 方法会创建两个像下面的数据结构:

[
    {
        $$typeof: Symbol(react.element),
        type: 'button',
        key: "1",
        props: {
            children: 'Update counter',
            onClick: () => { ... }
        }
    },
    {
        $$typeof: Symbol(react.element),
        type: 'span',
        key: "2",
        props: {
            children: 0
        }
    }
]

在这个对象中 React 添加了一个 $$typeof 属性作为当前 React element 的唯一标识. 而 type, key, props, 则描述了当前 element 的样子, 他们的值则是在调用 React.createElement 时传入的. 要注意当 span 和 button 节点的子元素是文本时 React 是怎样处理的, 以及点击回调如何作为 button 元素的 props 中的一部分.

Fiber nodes

在 reconciliation 期间, 每个从 render 方法中返回的 React element 数据都会被合并进 fiber 节点树, 每个 React element 都会关联到一个 fiber node. fiber node 是一种包含组件状态和 DOM 的可变数据结构, 不会像 React element 一样每次渲染都会重新创建.

前面提到过, 不同类型的 React element 会执行不同的活动. 在 ClickCounter 组件里会调用生命周期函数和 render 方法, 对于 span 元素对应的类型标记为 host component (DOM node) ,会执行 DOM 的修改. 所以, React element 会根据相应类型转化为 fiber 节点用来描述需要完成的工作.

你可以把 fiber当做是代表某些工作要做的数据结构, 一种工作单元. Fiber 架构提供了一种便捷的方式去跟踪, 调度, 停止和中断工作.

当 React element 第一次转换为 fiber node,React 在 createFiberFromTypeAndProps 函数中使用元素的数据去创建一个 fiber. 在随后的更新中, React 会重用这 fiber, 根据相对应的 React element 数据更新必要的属性. React 会根据 key 属性在层级结构中移动会删除这个节点当 render 方法中不再返回 React element 的时候.

源码的 ChildReconciler 函数中包含了对所有已存在的 fiber 节点可以执行的活动和对应函数的列表.

因为 React elements 是树状的, 所以 fiber nodes 也会被构建为相对应的树. 下面是 ClickCounter 的 fiber nodes 树结构.

c7d63ac844a1ce9cf1dbdb3b48cd3f69.png

所有的 fiber node 都会用 child, sibling, return 三个属性构建一个链表连接在一起.

Current & work in progress trees

第一次渲染之后, React 会得到一个 fiber 树, 它映射着程序的状态, 并渲染到界面上. 这个树被称为 current. 当 React 开始更新, 会重新构建一棵树, 称为 workInProgress, 所有的状态更新都会新被应用到这棵树上,完成之后刷新到界面上.

所有的 "work" 都是在 workInProgress 树上进行的. 当 React 开始遍历 current 树, 会对每个 fiber 节点创建一个备份 (alternate) 来构成 workInProgress 树. 当所有的更新和相关的 "work" 完成, 这个备份树就会被刷新到界面上, workInProgress 树就会变为 current 树.

React 的核心原则是一致性. 它总是一次性更新 DOM,不会每步都显示效果. workInProgress 就是一个用户不可见的 "草稿", React 在它上面处理所有组件, 处理完成后将它的更改再刷新到界面上.

在源码里, 你会看见非常多的函数将 current 和 workInProgress 作为参数, 下面就是这样的函数:

function updateHostComponent(current, workInProgress, renderExpirationTime) {...}

每一个 fiber 节点的 alternate 字段都持有其它树相对应它的节点引用. current 的节点会指向 workInrogress 的节点, 反之亦然.

在 React 中我们可以把组件当做是一个根据 state 和 props 来计算 UI 的函数. 每个像修改 DOM, 调用生命周期函数都被认为以 side-effect 或一种简单的影响. effects 在这篇文档里也被提及了.

你之前可能在 React 组件中已经通过数据抓取, 订阅或手动修改 DOM. 我们把这些操作称为 "side effects" (或者简称为 "effects") 因为它们会影响其他的组件而且在渲染阶段无法完成.

大多数的 state 和 props 更新都会导致 side-effects. 应用 effects 就是某种类型的 work, fiber node 是种的方便机制, 可以跟踪除了更新之外的 effects. 每个 fiber node 都有与之相关联的 effects. 它们被编码在 effectTag 字段里. 因此, Fiber 中的 effects 基本上定义了处理更新后需要为实例完成的工作. 对于 host component (DOM) 元素, work 包括 添加, 更新和删除元素. 对于类组件, 则可能需要更新 ref , 调用 componentDidUpdate 和 componentDidMount 生命周期方法, 还有其它类型相对应的它 effects.

Effects list

React 处理更新非常的快, 为了达到更好的性能使用了一些非常有趣的技术. 其中几个是将带有 effects 的 fiber 节点构建为线性表可以快速的迭代. 迭代线性表远远快于树而且不需要在没有 side-effects 的 fiber 节点上浪费时间.

这个链表的目的是标记出具有 DOM 更新或其它与之关联的 effects, 是 finishedWork 树的子集. 在 current 和 workInProgress 树中使用 nextEffect 属性连接在一起.

Dan Abramov 为此提供了一个形象的比喻. 他喜欢将它想象成一棵圣诞树, 通过"圣诞灯"将所有的有效节点绑定在一起. 让我们想象下面的 fiber nodes, 其中橙色的部分表示有工作要做. c2将会被插入到 DOM 中, d2和 c1 将会修改属性, b2 会触发生命周期函数. effect 链表会将它们连接在一起以便 React 可以跳过其他的节点.

c775a57c8fce33e0a944ec9ff78c564c.png

我们可以清楚的看到带有 effects 的节点时如何连接在一起的. 当遍历这些节点时, React 用 firstEffect 指针指出链表从哪里开始, 所以上图可以像下面这样显示:

c5e679bbd078cf4679e52ed16078f920.png

React 按照从子元素到父元素的顺序处理这些效果.

Root of the fiber tree

每个 React 应用都有一个或多个 DOM 元素充当容器, 在我们的例子中, 是带有 id container 的 div元素.

const domContainer = document.querySelector('#container');
ReactDOM.render(React.createElement(ClickCounter), domContainer);

React 为每一个容器都创建了 fiber root 对象, 可以通过 DOM 元素的引用访问到它.

const fiberRoot = query('#container')._reactRootContainer._internalRoot

fiber root 对象是 React 保存 fiber 树引用的地方. 它被储存在 fiber root 对象的 current 属性里.

const hostRootFiberNode = fiberRoot.current

fiber 树中的第一个节点有特殊的类型, 叫做HostRoot(容器元素).它在内部创建并作为顶部组件的父级.在 HostRoot 中可以通过 stateNode 属性访问到 FiberRoot.

fiberRoot.current.stateNode === fiberRoot; // true

你可以通过 HostRoot 探索到整个 fiber 树, 也可以通过组件实例获得单个 fiber 节点

compInstance._reactInternalFiber

Fiber node structure

让我们看下 ClickCounter 组件的 fiber 节点结构

{
  stateNode: new ClickCounter,
  type: ClickCounter,
  alternate: null,
  key: null,
  updateQueue: null,
  memoizedState: {count: 0},
  pendingProps: {},
  memoizedProps: {},
  tag: 1,
  effectTag: 0,
  nextEffect: null
}

span DOM 元素的:

{
  stateNode: new HTMLSpanElement,
  type: 'span',
  alternate: null,
  key:'2',
  updateQueue: null,
  memoizedState: null,
  pendingProps: {children: 0},
  memoizedProps: {children: 0},
  tag: 5,
  effectTag: 0,
  nextEffect: null
}

fiber node 上有很多的字段, alternate, effectTag, nextEffect 前面已经解释过了, 剩下的将在下面阐释.

stateNode

持有对组件类实例的引用, DOM 节点或其他与 fiber 节点相关联的 React element 类型.一般来说, 我们可以说这个属性被用于保存与当前 fiber 相关的本地状态.

type

定义相关联的 fiber 类型, 函数或是类. 对于 class components, 它指向构造函数, 对于 DOM 元素则指定为 HTML 标记. 我经常用这个字段来判断这个 fiber 节点与哪个元素相关.

tag

定义 fiber 的类型. 它在 reconciliation 算法中用来确定需要完成的工作. 像前面提到的, work 的种类取决于 React element 的类型. createFiberFromTypeAndProps 函数将React element 映射到f相关的 fiber 节点类型. 在我们的应用中, ClickComponent 组件的 tag 属性是 1 标明为是个 ClassComponent. span 元素是 5 标明它是 HostComponent.

updateQueue

state 更新, 回调和 DOM 更新的队列.

memoizedState

用于创建输出的 fiber state. 在处理更新的时候, 他映射的是当前界面上呈现的state.(更新时,存储的之前的state)

memoizedProps

上一次渲染过程中用来创建的输出的 fiber props.

pendingProps

已从 React elements 的新数据中更新的 props并且需要应用于子组件和 DOM 元素.

key

在一组子元素中的唯一标识, 帮助 React 指出哪些已被更改, 添加或删除.

***更详细的可以去看源码里的注释文件***

算法概览

React 执行 work 的时候分两个阶段: rendercommit.

在第一个 render 阶段, React 将更新应用于通过 setState 和 React.render 调度的组件, 指出在 UI 上需要更新什么. 第一次初始化渲染, React 会通过 render 方法为每一个元素都创建一个新的 fiber.在随后的更新中, 已存在的 fiber 会被重用和更新. 这个阶段会构建一个被 side-effects 标记的 fiber 节点树. effects 描述了在随后的 commit 阶段需要完成的工作.这个阶段带有 effects 的节点都会被应用到它们的实例上, 然后遍历 effects 链表执行 DOM 更新并在界面上呈现.

重要的一点是, 渲染阶段的 work 可以异步执行.React 根据可用时间处理一个或多个 fiber 节点, 当某些重要的事件发生时, 就停下来处理这些事件, 处理完成后再回来继续. 有时候它会丢弃已经完成的工作, 并从顶部重新开始.因为在此阶段对用户是不可见的, 所以才使得暂停才变成可能. 随后的 commit 阶段是同步的, 它会产生用户可见的变化, 例如 DOM 的修改. 这就是 React 需要一次性完成它们的原因.

调用生命周期函数是 React 执行的工作之一. 一些在 render 阶段调用, 一些在 commit 阶段调用.

在 render 阶段执行的方法:

  • [UNSAFE_]componentWillMount ( 弃用 )
  • [UNSAFE_]componentWillReceiveProps ( 弃用 )
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate ( 弃用 )
  • render

上面有一些 render 阶段的方法在 16.3 中被标记为 UNSAFE, 它们也许会在将来的某个 16.x 中被弃用. 你可以在这里了解到更多.

之所以被标记为 UNSAFE, 是因为开发者经常误用, 在那些方法里执行产生 side-effect 的函数, 可能会导致新的异步渲染方法出现问题.

commit 阶段执行的方法:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

Render 阶段

reconciliation 算法总是使用 renderRoot 方法从最顶端的 HostRoot 节点开始, 跳过已经处理过的节点直到带有未完成 work 的节点. 例如, 当在组件树深处调用 setState 方法的时候, React 会从顶部开始快速的跳过所有父级节点直接到调用 setState 方法的节点.

work loop 的主要步骤

所有的 fiber 节点都会在 workLoop 方法中被处理, 下面是代码的同步实现部分:

function workLoop(isYield){
    if(!isYield){
      while(nextUnitOfWork !== null){
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
      }
  } else {...}
} 

在上面的代码中. nextUnitOfWork 保存了 workInProgress 树中一个有工作要处理的 fiber 节点. 当 React 遍历所有节点时, 这个变量用来判断是否尤其它的带有未完成工作的 fiber 节点. 当前节点处理完成之后, 保存的就是下一个节点或 null. 这种情况下, React 退出 work loop 并准备到 commit 阶段.

下面是四个用来初始化和完成工作的主要方法:

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

下面的动画更直观的展示了处理过程. React 每次都是处理完字 fiber 节点再到 父 fiber 节点.

66e4b8644ab9578039c38ccd5308d6fc.gif
垂直线表示兄弟节点, 弯曲线表示子节点.

这里是视频, 可以暂停仔细看看. 概念上来讲, 可以把 'begin' 理解为走入一个组件, 'complete' 为走出一个组件.

下面是一个简单实现的示例:

function performUnitOfWork(workInProgress) {
    let next = beginWork(workInProgress);
    if (next === null) {
        next = completeUnitOfWork(workInProgress);
    }
    return next;
}


function beginWork(workInProgress) {
    console.log('work performed for ' + workInProgress.name);
    return workInProgress.child;
}

performUnitOfWork 接收一个来自 workInProgress 树的 fiber 节点然后调用 beginWork 开始处理它的 work. 为了方便展示, 只是简单的打印了已完成工作 fiber 节点的 name 属性. beginWork 函数总是返回下一个子节点的指针或者是 null.

如果存在下一个子节点, 它会被分配到 nextUnitOfWork 变量. 如果没有节点, React就会知道已经到了分支的结尾, 就会完成当前节点的工作. 工作完成, React 会执行它兄弟节点的工作再回溯到它的父节点. 下面是 completeUnitOfWork 函数:

function completeUnitOfWork(workInProgress) {
    while (true) {
        let returnFiber = workInProgress.return;
        let siblingFiber = workInProgress.sibling;

        nextUnitOfWork = completeWork(workInProgress);

        if (siblingFiber !== null) {
            // If there is a sibling, return it
            // to perform work for this sibling
            return siblingFiber;
        } else if (returnFiber !== null) {
            // If there's no more work in this returnFiber,
            // continue the loop to complete the parent.
            workInProgress = returnFiber;
            continue;
        } else {
            // We've reached the root.
            return null;
        }
    }
}




function completeWork(workInProgress) {
    console.log('work completed for ' + workInProgress.name);
    return n

只有处理完子节点所有分支之后, 才会回溯到父节点. 完整示例地址

Commit 阶段

这个阶段从 completeRoot 函数开始. 这里是 React 更新 DOM, 调用前后异变生命周期函数的地方.

到了这个阶段, 存在着两个 fiber 树和 effect 链表. 一棵树代表着当前渲染在界面上的状态(current). 另一个代表着将来要映射到界面上的状态(workInProgress). 两个树有着同样的数据结构.

还有 effects 链表 ----- 一个通过 nextEffect 引用的 workInProgress 树的节点子集. effects 链表使运行 render 阶段的结果. 整个渲染就是确定哪些节点需要插入, 更新或者删除, 哪些组件需要调用生命周期函数.

For debugging purposes, the current tree can be accessed through the currentproperty of the fiber root. The finishedWork tree can be accessed through the alternate property of the HostFiber node in the current tree.

commit 阶段主要的函数是 commitRoot, 基本上执行以下操作:

  • 在有 Snapshot 效果标记的节点上调用 getSnapshotBeforeUpdate 生命周期方法
  • 在有 Deletion 效果标记的节点上调用 componentWillUnmount 生命周期方法
  • 执行所有的 DOM 插入, 更新, 删除
  • finishedWork 设置为 current 树
  • 在有 Placement 效果标记的节点上调用 componentDidMount 生命周期方法
  • 在有 Update 效果标记的节点上调用 componentDidUpdate 生命周期方法

调用 getSnapshotBeforeUpdate 方法后, React commit 树内的所有 side-effects. 它分为两步. 第一步执行所有的 DOM 插入,更新,删除和 ref 卸载. 然后 React 将 finishedWork 树赋值给 FiberRoot , workInProgress 树标记为 current 树. 第二步 React 调用其他的生命周期函数和 ref 回调. 这写方法都会单独执行, 因此已经执行了整个树中的所有插入, 更新和删除.

function commitRoot(root, finishedWork) {
    commitBeforeMutationLifecycles()
    commitAllHostEffects();
    root.current = finishedWork;
    commitAllLifeCycles();
}

Pre-mutation lifecycle methods

代码遍历 effects 树, 检查节点上是否有 Snapshot effect 标记

function commitBeforeMutationLifecycles() {
    while (nextEffect !== null) {
        const effectTag = nextEffect.effectTag;
        if (effectTag & Snapshot) {
            const current = nextEffect.alternate;
            commitBeforeMutationLifeCycles(current, nextEffect);
        }
        nextEffect = nextEffect.nextEffect;
    }
}

对于 class 组件来说, 就是调用 getSnapshotBeforeUpdate

DOM updates

commitAllHostEffects 函数是 React 执行 DOM 更新的地方

function commitAllHostEffects() {
    switch (primaryEffectTag) {
        case Placement: {
            commitPlacement(nextEffect);
            ...
        }
        case PlacementAndUpdate: {
            commitPlacement(nextEffect);
            commitWork(current, nextEffect);
            ...
        }
        case Update: {
            commitWork(current, nextEffect);
            ...
        }
        case Deletion: {
            commitDeletion(nextEffect);
            ...
        }
    }
}

React 会在 commitDeletion 中调用 componentWillUnmount 方法

Post-mutation lifecycle methods

commitAllLifecycles 函数会调用剩下的生命周期函数 componentDidUpdatecomponentDidMount.

原文地址: https://medium.com/react-in-depth/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react-e1c04700ef6e
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值