深入React源码揭开渲染更新流程的面纱

  • Post author:
  • Post category:其他


转前端一年半了,平时接触最多的框架就是

React

。在熟悉了其用法之后,避免不了想深入了解其实现原理,网上相关源码分析的文章挺多的,但是总感觉不如自己阅读理解来得深刻。于是话了几个周末去了解了一下常用的流程。也是通过这篇文章将自己的个人理解分享出来。

在具体的源码流程分析之前,根据个人理解,结合网上比较好的文章,先来分析一些概念性的东西。后续再分析具体的流程逻辑。



React 15



架构分层


React 15

版本(Fiber以前)整个更新渲染流程分为两个部分:


  • Reconciler

    (协调器); 负责找出变化的组件

  • Renderer

    (渲染器); 负责将变化的组件渲染到页面上


Reconciler



React

中可以通过

setState



forceUpdate



ReactDOM.render

来触发更新。每当有更新发生时,

Reconciler

会做如下工作:

  1. 调用组件的

    render

    方法,将返回的

    JSX

    转化为虚拟

    DOM
  2. 将虚拟

    DOM

    和上次更新时的虚拟

    DOM

    对比
  3. 通过对比找出本次更新中变化的虚拟

    DOM
  4. 通知

    Renderer

    将变化的虚拟DOM渲染到页面上


Renderer

在对某个更新节点执行玩

Reconciler

之后,会通知

Renderer

根据不同的”宿主环境”进行相应的节点渲染/更新。



React 15的缺陷


React 15



diff

过程是

递归执行更新

的。由于是递归,

一旦开始就”无法中断”

。当层级太深或者

diff

逻辑(钩子函数里的逻辑)太复杂,导致递归更新的时间过长,

Js

线程一直卡主,那么用户交互和渲染就会产生卡顿。看个例子: count-demo

<button>        click     <button>
<li>1<li>        ->       <li>2<li>
<li>2<li>        ->       <li>4<li>
<li>3<li>        ->       <li>6<li>

当点击

button

后,列表从左边的

1、2、3

变为右边的

2、4、6

。每个节点的更新过程对用户来说基本是同步,但实际上他们是顺序遍历的。具体步骤如下:

  1. 点击

    button

    ,触发更新

  2. Reconciler

    检测到

    <li1>

    需要变更为

    <li2>

    ,则立刻通知

    Renderer

    更新

    DOM

    。列表变成

    2、2、3

  3. Reconciler

    检测到

    <li2>

    需要变更为

    <li4>

    ,通知

    Renderer

    更新

    DOM

    。列表变成

    2、4、3

  4. Reconciler

    检测到

    <li3>

    需要变更为

    <li6>

    ,则立刻通知

    Renderer

    更新

    DOM

    。列表变成

    2、4、6

从此可见


Reconciler



Renderer

是交替工作

的,当第一个节点在页面上已经变化后,第二个节点再进入

Reconciler

。由于整个过程都是同步的,所以在用户看来所有节点是同时更新的。

如果中断更新,则会在页面上看见更新不完全的新的节点树!

假如当进行到第2步的时候,突然因为其他任务而中断当前任务,导致第3、4步无法进行那么用户就会看到:

<button>        click     <button>
<li>1<li>        ->       <li>2<li>
<li>2<li>        ->       <li>2<li>
<li>3<li>        ->       <li>3<li>

这种情况是

React

绝对不希望出现的。但是这种应用场景又是十分必须的。想象一下,用户在某个时间点进行了输入事件,此时应该更新

input

内的内容,但是因为一个不在当前可视区域的列表的更新导致用户的输入更新被滞后,那么给用户的体验就是卡顿的。因此

React

团队需要寻找一个办法,来解决这个缺陷。



React 16



架构分层

React15架构不能支撑异步更新以至于需要重构,于是React16架构改成分为三层结构:

  • Scheduler(调度器);调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器);负责找出变化的组件
  • Renderer(渲染器);负责将变化的组件渲染到页面上


Scheduler


React 15



React 16

提出的需求是Diff更新应为可中断的,那么此时又出现了两个新的两个问题:

中断方式和判断标准

;


React

团队采用的是

合作式调度,即主动中断和控制器出让



判断标准为超时检测

。同时还需要一种机制来告知中断的任务在何时恢复/重新执行。

React

借鉴了浏览器的

requestIdleCallback

接口,当浏览器有

剩余时间时通知执行

由于一些原因

React

放弃使用

rIdc

,而是自己实现了功能更完备的

polyfill

,即

Scheduler

。除了在空闲时触发回调的功能外,

Scheduler

还提供了多种调度优先级供任务设置。



Reconciler



React 15



Reconciler

是递归处理

Virtual DOM

的。而

React16

使用了一种新的数据结构:

Fiber



Virtual DOM

树由之前的从上往下的树形结构,变化为基于多向链表的”图”。

更新流程从递归变成了可以中断的循环过程。每次循环都会调用

shouldYield()

判断当前是否有剩余时间。源码地址。

function workLoopConcurrent() {
   
    // Perform work until Scheduler asks us to yield
    while (workInProgress !== null && !shouldYield()) {
   
        workInProgress = performUnitOfWork(workInProgress);
    }
}

前面有分析到

React 15

中断执行会导致页面更新不完全,原因是因为

Reconciler



Renderer

是交替工作的,因此在

React 16

中,

Reconciler



Renderer

不再是交替工作。当

Scheduler

将任务交给

Reconciler

后,

Reconciler

只是会为变化的

Virtual DOM

打上代表增/删/更新的标记,而不会发生通知

Renderer

去渲染。类似这样:

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;

只有当所有组件都完成

Reconciler

的工作,才会统一交给

Renderer

进行渲染更新。



Renderer(Commit)


Renderer

根据

Reconciler



Virtual DOM

打的标记,同步执行对应的渲染操作。

对于我们在上一节使用过的例子,在

React 16

架构中整个更新流程为:


  1. setState

    产生一个更新,更新内容为:

    state.count



    1

    变为

    2
  2. 更新被交给

    Scheduler



    Scheduler

    发现没有其他更高优先任务,就将该任务交给

    Reconciler

  3. Reconciler

    接到任务,开始遍历

    Virtual DOM

    ,判断哪些

    Virtual DOM

    需要更新,为需要更新的

    Virtual DOM

    打上标记

  4. Reconciler

    遍历完所有

    Virtual DOM

    ,通知

    Renderer

  5. Renderer

    根据

    Virtual DOM

    的标记执行对应节点操作

其中步骤2、3、4随时可能由于如下原因被中断:

  • 有其他更高优先任务需要先更新
  • 当前帧没有剩余时间

由于

Scheduler



Reconciler

的工作都在内存中进行,不会更新页面上的节点,所以用户不会看见更新不完全的页面。



Diff原则

React的

Diff

是有一定的

前提假设

的,主要分为三点:

  • DOM跨层级移动的情况少,对

    Virtual DOM

    树进行分层比较,两棵树只会对同一层次的节点进行比较。
  • 不同类型的组件,树形结构不一样。相同类型的组件树形结构相似
  • 同一层级的一组子节点操作无外乎

    更新、移除、新增

    ,可以通过

    唯一

    ID


    区分节点

无论是

JSX

格式还是

React.createElement

创建的React组件最终都会转化为

Virtual DOM

,最终会根据层级生成相应的

Virtual DOM

树形结构。

React 15

每次更新会成新的

Virtual DOM

,然后通

递归

的方式对比新旧

Virtual DOM

的差异,得到对比后的”更新补丁”,最后映射到真实的

DOM

上。

React 16

的具体流程后续会分析到



源码分析

React源码非常多,而且16以后的源码一直在调整,目前Github上最新源码都是保留

xxx.new.js



xxx.old.js

两份代码。react源码 是采用

Monorepo

结构来进行管理的,不同的功能分在不同的

package

里,唯一的坏处可能就是方法地址索引起来不是很方便,如果不是对源码比较熟悉的话,某个功能点可能需要通过关键字全局查询然后去一个个排查。开始之前,可以先阅读下官方的这份阅读指南

因为源码实在是太多太复杂了,所有我这里尽可能的最大到小,从面到点的一个个分析。大致的流程如下:

  1. 首先得知道通过

    JSX

    或者

    createElement

    编码的代码到底会转成啥
  2. 然后分析应用的入口

    ReactDOM.render
  3. 接着进一步分析

    setState

    更新的流程
  4. 最后再具体分析

    Scheduler



    Reconciler



    Renderer

    的大致流程

触发渲染更新的操作除了

ReactDOM.render



setState

外,还有

forceUpdate

。但是其实是差不多的,最大差异在于

forceUpdate

不会走

shouldComponentUpdate

钩子函数。



数据结构



Fiber

开始正式流程分析之前,希望你对

Fiber

有过一定的了解。如果没有,建议你先看看这则视频。然后,先来熟悉下

ReactFiber

的大概结构。

export type Fiber = {
   
    // 任务类型信息;
    // 比如ClassComponent、FunctionComponent、ContextProvider
    tag: WorkTag,
    key: null | string,
    // reactElement.type的值,用于reconciliation期间的保留标识。
    elementType: any,
    // fiber关联的function/class
    type: any,
    // any类型!! 一般是指Fiber所对应的真实DOM节点或对应组件的实例
    stateNode: any,
    // 父节点/父组件
    return: Fiber | null,
    // 第一个子节点
    child: Fiber | null,
    // 下一个兄弟节点
    sibling: Fiber | null,
    // 变更状态,比如删除,移动
    effectTag: SideEffectTag,
    // 用于链接新树和旧树;旧->新,新->旧
    alternate: Fiber | null,
    // 开发模式
    mode: TypeOfMode,
    // ...
  };



FiberRoot

每一次通过

ReactDom.render

渲染的一棵树或者一个应用都会初始化一个对应的

FiberRoot

对象作为应用的起点。其数据结构如下

ReactFiberRoot

type BaseFiberRootProperties = {
   
  // The type of root (legacy, batched, concurrent, etc.)
  tag: RootTag,
  // root节点,ReactDOM.render()的第二个参数
  containerInfo: any,
  // 持久更新会用到。react-dom是整个应用更新,用不到这个
  pendingChildren: any,
  // 当前应用root节点对应的Fiber对象
  current: Fiber,
  // 当前更新对应的过期时间
  finishedExpirationTime: ExpirationTime,
  // 已经完成任务的FiberRoot对象,在commit(提交)阶段只会处理该值对应的任务
  finishedWork: Fiber | null,
  // 树中存在的最旧的未到期时间
  firstPendingTime: ExpirationTime,
  // 挂起任务中的下一个已知到期时间
  nextKnownPendingLevel: ExpirationTime,
  // 树中存在的最新的未到期时间
  lastPingedTime: ExpirationTime,
  // 最新的过期时间
  lastExpiredTime: ExpirationTime,
  // ...
};

相关参考视频讲解:

进入学习



Fiber 类型

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // 不确定类型;可能是class或function
export const HostRoot = 3; // 树的根
export const HostPortal = 4; // 一颗子树
export const HostComponent = 5; // 原生节点;根据环境而定,浏览器环境就是div等
export const HostText = 6; // 纯文本节点
export const Fragment = 7;



模式



React 16.13.1

版本为止,内置的开发模式有如下几种:

export type TypeOfMode = number;
// 普通模式|Legacy模式,同步渲染,React15-16的生产环境用
export const NoMode = 0b0000;
// 严格模式,用来检测是否存在废弃API(会多次调用渲染阶段生命周期),React16-17开发环境使用
export const StrictMode = 0b0001;
// ConcurrentMode 模式的过渡版本
export const BlockingMode = 0b0010;
// 并发模式,异步渲染,React17的生产环境用
export const ConcurrentMode = 0b0100;
// 性能测试模式,用来检测哪里存在性能问题,React16-17开发环境使用
export const ProfileMode = 0b1000;

本文只分析 ConcurrentMode 模式



JSX与React.createElement

先来看一个最简单的

JSX

格式编码的组件,这里借助

babel

进行代码转换,代码看这

// JSX
class App extends React.Component {
   
    render() {
   
        return <div />
    }
}

// babel
var App = /*#__PURE__*/function (_React$Component) {
   
    _inherits(App, _React$Component);

    var _super = _createSuper(App);

    function App() {
   
        _classCallCheck(this, App);

        return _super.apply(this, arguments);
    }

    _createClass(App, [{
   
        key: "render",
        value: function render() {
   
            return /*#__PURE__*/React.createElement("div", null);
        }
    }]);

    return App;
}(React.Component);

关键点在于

render

方法实际上是调用了

React.createElement

方法。那么接下来我们只需要分析

createElement

做了啥即可。我们先看看

ReactElement

的结构:

let REACT_ELEMENT_TYPE = 0xeac7;
if (typeof Symbol === 'function' && Symbol.for) {
   
    REACT_ELEMENT_TYPE = Symbol.for('react.element');
}

const ReactElement = function (type, key, ref, props) {
   
    const element = {
   
        // 唯一地标识为React Element,防止XSS,JSON里不能存Symbol
        ?typeof: REACT_ELEMENT_TYPE,

        type: type,
        key: key,
        ref: ref,
        props: props,
    }
    return element;
}

很简单的一个数据结构,每个属性的作用都一目了然,就不一一解释了。然后分析

React.createElement

源码。



防XSS攻击

如果你不清楚XSS攻击,建议先读这篇文章如何防止XSS攻击?。

首先我们编码的组件都会转化为

ReactElement

的对象。DOM的操作和产生都是有

Js

脚本产生的。从根本上杜绝了三种

XSS

攻击(你思品)。

但是

React

提供了

dangerouslySetInnerHTML

来作为

innerHTML

的替代方案。假如某种场景下,接口给了我

JSON

格式的数据。我需要展示在一个

div

中。如果被攻击者拦截到了,并将

JSON

替换为一段

ReactElement

格式的结构。那么会发生什么呢?

我这里写了一个demo,当去掉

?typeof

会发现会报错。而

Symbol

无法

JSON

化的,因此外部也是无法利用

dangerouslySetInnerHTML

进行攻击的。具体检测的源码看这里

const hasOwnProperty = Object.prototype.hasOwnProperty;
const RESERVED_PROPS = {
   
    key: true,
    ref: true,
    __self: true,
    __source: true,
};

function createElement(type, config, children) {
   
    let propName;

    // Reserved names are extracted
    const props = {
   };

    let key = null;
    let ref = null;

    if (config !== null) {
   
        if (hasValidRef(config)) {
   
            ref = config.ref;
        }
        if (hasValidKey(config)) {
   
            key = '' + config.key;
        }
    }

    // 过滤React保留的关键字
    for (propName in config) {
   
        if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
   
            props[propName] = config[propName];
        }
    }

    // 遍历children
    const childrenLength = arguments.length - 2;
    if (childrenLength === 1) {
   
        props.children = children;
    } else if (childrenLength > 1) {
   
        const childArray = Array(childrenLength);
        for (let i = 0; i < childrenLength; i++) {
   
            childArray[i] = arguments[i + 2];
        }
        props.children = childArray;
    }

    // 设置默认props
    if (type && type.defaultProps) {
   
        const defaultProps = type.defaultProps;
        for (propName in defaultProps) {
   
            if (props[propName] === undefined) {
   
                props[propName] = defaultProps[propName];
            }
        }
    }

    return ReactElement(type, key, ref, props);
}

注释应该已经够清楚了哈。总结下来就是根据参数来生成一个

ReactElement

对象,并绑定对应的

props



key



ref

等;



render流程


ReactDOM.render

使用参考这里

一般来说,使用

React

编写应用,

ReactDOM.render

是我们触发的第一个函数。那么我们先从

ReactDOM.render

这个入口函数开始分析

render

的整个流程。

源码中会频繁出现针对

hydrate

的逻辑判断和处理。这个是跟

SSR

结合客户端渲染相关,不会做过多分析。源码部分我都会进行省略


ReactDOM.render

实际上对

ReactDOMLegacy

里的

render

方法的引用,精简后的逻辑如下:

export function render(
    // React.creatElement的产物
    element: React$Element<any>,    container: Container,    callback: ?Function,
) {
   
    return legacyRenderSubtreeIntoContainer(
        null,
        element,
        container,
        false,
        callback,
    );
}

实际上调用的是

legacyRenderSubtreeIntoContainer

方法,再来看看这个咯

function legacyRenderSubtreeIntoContainer(
    parentComponent: ?React$Component<any, any>, // 一般为null
    children: ReactNodeList,    container: Container,    forceHydrate: boolean,    callback: ?Function,
) {
   

    let root: RootType = (container._reactRootContainer: any);
    let fiberRoot;
    if (!root) {
   
        // [Q]: 初始化容器。清空容器内的节点,并创建FiberRoot
        root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
            container,
            forceHydrate,
        );
        // FiberRoot; 应用的起点
        fiberRoot = root._internalRoot;
        if (typeof callback === 'function') {
   
            const originalCallback = callback;
            callback = function () {
   
                const instance = getPublicRootInstance(fiberRoot);
                originalCallback.call(instance);
            };
        }
        // [Q]: 初始化不能批量处理,即同步更新
        unbatchedUpdates(() => {
   
            updateContainer(children, fiberRoot, parentComponent, callback);
        });
    } else {
   
        // 省略... 跟上面类似,差别是无需初始化容器和可批处理
        // [Q]:咦? unbatchedUpdates 有啥奥秘呢
        updateContainer(children, fiberRoot, parentComponent, callback);
    }
    return getPublicRootInstance(fiberRoot);
}

根据官网的使用文档可知,在这一步会先清空容器里现有的节点,如果有异步回调

callback

会先保存起来,并绑定对应

FiberRoot

引用关系,以用于后续传递正确的根节点。注释里我标注了两个

[Q]

代表两个问题。我们先来仔细分析这两个问题



初始化

从命名上看,

legacyCreateRootFromDOMContainer

是用来初始化根节点的。



legacyCreateRootFromDOMContainer

的返回结果赋值给

container._reactRootContainer

,而

_reactRootContainer

从代码上看是作为是否已经初始化的依据,也验证了这一点。不信的话,打开你的

React

应用,查看下容器元素的

_reactRootContainer

属性

function legacyCreateRootFromDOMContainer(
  container: Container,  forceHydrate: boolean,
): RootType {
   
  // 省略 hydrate ...
  return createLegacyRoot(container, undefined);
}

export function createLegacyRoot(
  container: Container,  options?: RootOptions,
): RootType {
   
  return new ReactDOMBlockingRoot(container, LegacyRoot, options);
}

function ReactDOMBlockingRoot(
  container: Container,  tag: RootTag,  options: void | RootOptions,
) {
   
  // !!! look here
  this._internalRoot = createRootImpl(container, tag, options);
}

一连串的函数调用,其实就是还回了一个ReactDOMBlockingRoot实例。其中重点在于属性

_internalRoot

是通过

createRootImpl

创建的产物。

function createRootImpl(
  container: Container,  tag: RootTag,  options: void | RootOptions,
) {
   
  // 省略 hydrate ...
  const root = createContainer(container, tag, hydrate, hydrationCallbacks);
  // 省略 hydrate ...
  return root;
}

export function createContainer(
  containerInfo: Container,  tag: RootTag,  hydrate: boolean,  hydrationCallbacks: null | SuspenseHydrationCallbacks,
): OpaqueRoot {
   
  return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks);
}

export function createFiberRoot(
  containerInfo: any,  tag: RootTag,  hydrate: boolean,  hydrationCallbacks: null | SuspenseHydrationCallbacks,
): FiberRoot {
   
  // 生成 FiberRoot
  const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
  if (enableSuspenseCallback) {
   
    root.hydrationCallbacks = hydrationCallbacks;
  }

  // 为Root生成Fiber对象
  const uninitializedFiber = 



版权声明:本文为It_kc原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。