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


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

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

    React 15

    架构分层

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

    • Reconciler(协调器); 负责找出变化的组件
    • Renderer(渲染器); 负责将变化的组件渲染到页面上
    Reconciler

    React中可以通过setStateforceUpdateReactDOM.render来触发更新。每当有更新发生时,Reconciler会做如下工作:

    1. 调用组件的render方法,将返回的JSX转化为虚拟DOM
    2. 将虚拟DOM和上次更新时的虚拟DOM对比
    3. 通过对比找出本次更新中变化的虚拟DOM
    4. 通知Renderer将变化的虚拟DOM渲染到页面上
    Renderer

    在对某个更新节点执行玩Reconciler之后,会通知Renderer根据不同的"宿主环境"进行相应的节点渲染/更新。

    React 15的缺陷

    React 15diff过程是 递归执行更新 的。由于是递归,一旦开始就"无法中断" 。当层级太深或者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>
    
    • 1
    • 2
    • 3
    • 4

    当点击button后,列表从左边的1、2、3变为右边的2、4、6。每个节点的更新过程对用户来说基本是同步,但实际上他们是顺序遍历的。具体步骤如下:

    1. 点击button,触发更新
    2. Reconciler检测到需要变更为,则立刻通知Renderer更新DOM。列表变成2、2、3
    3. Reconciler检测到需要变更为,通知Renderer更新DOM。列表变成2、4、3
    4. Reconciler检测到需要变更为,则立刻通知Renderer更新DOM。列表变成2、4、6

    从此可见 ReconcilerRenderer是交替工作 的,当第一个节点在页面上已经变化后,第二个节点再进入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>
    
    • 1
    • 2
    • 3
    • 4

    这种情况是React绝对不希望出现的。但是这种应用场景又是十分必须的。想象一下,用户在某个时间点进行了输入事件,此时应该更新input内的内容,但是因为一个不在当前可视区域的列表的更新导致用户的输入更新被滞后,那么给用户的体验就是卡顿的。因此React团队需要寻找一个办法,来解决这个缺陷。

    React 16

    架构分层

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

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

    React 15React 16提出的需求是Diff更新应为可中断的,那么此时又出现了两个新的两个问题:中断方式和判断标准;

    React团队采用的是 合作式调度,即主动中断和控制器出让判断标准为超时检测。同时还需要一种机制来告知中断的任务在何时恢复/重新执行。 React 借鉴了浏览器的requestIdleCallback接口,当浏览器有剩余时间时通知执行

    由于一些原因React放弃使用rIdc,而是自己实现了功能更完备的polyfill,即Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。

    Reconciler

    React 15Reconciler是递归处理Virtual DOM的。而React16使用了一种新的数据结构:FiberVirtual DOM树由之前的从上往下的树形结构,变化为基于多向链表的"图"。

    更新流程从递归变成了可以中断的循环过程。每次循环都会调用shouldYield()判断当前是否有剩余时间。源码地址。

    function workLoopConcurrent() {
       
        // Perform work until Scheduler asks us to yield
        while (workInProgress !== null && !shouldYield()) {
       
            workInProgress = performUnitOfWork(workInProgress);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    前面有分析到React 15中断执行会导致页面更新不完全,原因是因为ReconcilerRenderer是交替工作的,因此在React 16中,ReconcilerRenderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler只是会为变化的Virtual DOM打上代表增/删/更新的标记,而不会发生通知Renderer去渲染。类似这样:

    export const Placement = /*             */ 0b0000000000010;
    export const Update = /*                */ 0b0000000000100;
    export const PlacementAndUpdate = /*    */ 0b0000000000110;
    export const Deletion = /*              */ 0b0000000001000;
    
    • 1
    • 2
    • 3
    • 4

    只有当所有组件都完成Reconciler的工作,才会统一交给Renderer进行渲染更新。

    Renderer(Commit)

    Renderer根据ReconcilerVirtual DOM打的标记,同步执行对应的渲染操作。

    对于我们在上一节使用过的例子,在React 16架构中整个更新流程为:

    1. setState产生一个更新,更新内容为:state.count1变为2
    2. 更新被交给SchedulerScheduler发现没有其他更高优先任务,就将该任务交给Reconciler
    3. Reconciler接到任务,开始遍历Virtual DOM,判断哪些Virtual DOM需要更新,为需要更新的Virtual DOM打上标记
    4. Reconciler遍历完所有Virtual DOM,通知Renderer
    5. Renderer根据Virtual DOM的标记执行对应节点操作

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

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

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

    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.jsxxx.old.js两份代码。react源码 是采用Monorepo结构来进行管理的,不同的功能分在不同的package里,唯一的坏处可能就是方法地址索引起来不是很方便,如果不是对源码比较熟悉的话,某个功能点可能需要通过关键字全局查询然后去一个个排查。开始之前,可以先阅读下官方的这份阅读指南

    相关参考视频讲解:进入学习

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

    1. 首先得知道通过JSX或者createElement编码的代码到底会转成啥
    2. 然后分析应用的入口ReactDOM.render
    3. 接着进一步分析setState更新的流程
    4. 最后再具体分析SchedulerReconcilerRenderer的大致流程

    触发渲染更新的操作除了ReactDOM.rendersetState外,还有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,
        // ...
      };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    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,
      // ...
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    模式

    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    本文只分析 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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    关键点在于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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    很简单的一个数据结构,每个属性的作用都一目了然,就不一一解释了。然后分析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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71

    注释应该已经够清楚了哈。总结下来就是根据参数来生成一个ReactElement对象,并绑定对应的propskeyref等;

    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,
        );
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    实际上调用的是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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    根据官网的使用文档可知,在这一步会先清空容器里现有的节点,如果有异步回调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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    一连串的函数调用,其实就是还回了一个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 = createHostRootFiber(tag);
      // 绑定 FiberRoot 与 Fiber 
      root.current = uninitializedFiber;
      uninitializedFiber.stateNode = root;
    
      
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
  • 相关阅读:
    reduceByKey(func, [numTasks]) 案例介绍_大数据培训
    Springboot 实践(21)服务熔断机制
    docker容器日志管理
    Trino Presto 等 SQL 连接客户端工具
    如何在 Spartacus 使用 UserAccountFacade 在语言设置更改后重新读取用户数据
    【每日一题Day334】LC2591将钱分给最多的儿童 | 贪心
    VMware下安装、配置ubuntu虚拟系统
    【Leetcode】 717. 1 比特与 2 比特字符
    WebAssembly核心编程[1]:wasm模块实例化的N种方式
    若依框架数据源切换为pg库
  • 原文地址:https://blog.csdn.net/It_kc/article/details/128186285