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

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

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

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

Reconciler(协调器); 负责找出变化的组件
Renderer(渲染器); 负责将变化的组件渲染到页面上
Reconciler
在React中可以通过setState、forceUpdate、ReactDOM.render来触发更新。每当有更新发生时,Reconciler会做如下工作:

调用组件的render方法,将返回的JSX转化为虚拟DOM
将虚拟DOM和上次更新时的虚拟DOM对比
通过对比找出本次更新中变化的虚拟DOM
通知Renderer将变化的虚拟DOM渲染到页面上
Renderer
在对某个更新节点执行玩Reconciler之后,会通知Renderer根据不同的"宿主环境"进行相应的节点渲染/更新。

React 15的缺陷
React 15的diff过程是 递归执行更新 的。由于是递归,一旦开始就"无法中断" 。当层级太深或者diff逻辑(钩子函数里的逻辑)太复杂,导致递归更新的时间过长,Js线程一直卡主,那么用户交互和渲染就会产生卡顿。看个例子: count-demo

click

  • 1
  • ->
  • 2
  • 2
  • ->
  • 4
  • 3
  • ->
  • 6
  • 当点击button后,列表从左边的1、2、3变为右边的2、4、6。每个节点的更新过程对用户来说基本是同步,但实际上他们是顺序遍历的。具体步骤如下:
  • 点击button,触发更新
    Reconciler检测到需要变更为,则立刻通知Renderer更新DOM。列表变成2、2、3
    Reconciler检测到需要变更为,通知Renderer更新DOM。列表变成2、4、3
    Reconciler检测到需要变更为,则立刻通知Renderer更新DOM。列表变成2、4、6
    从此可见 Reconciler和Renderer是交替工作 的,当第一个节点在页面上已经变化后,第二个节点再进入Reconciler。由于整个过程都是同步的,所以在用户看来所有节点是同时更新的。如果中断更新,则会在页面上看见更新不完全的新的节点树!

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

    click

  • 1
  • ->
  • 2
  • 2
  • ->
  • 2
  • 3
  • ->
  • 3
  • 这种情况是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架构中整个更新流程为:

    setState产生一个更新,更新内容为:state.count从1变为2
    更新被交给Scheduler,Scheduler发现没有其他更高优先任务,就将该任务交给Reconciler
    Reconciler接到任务,开始遍历Virtual DOM,判断哪些Virtual DOM需要更新,为需要更新的Virtual DOM打上标记
    Reconciler遍历完所有Virtual DOM,通知Renderer
    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里,唯一的坏处可能就是方法地址索引起来不是很方便,如果不是对源码比较熟悉的话,某个功能点可能需要通过关键字全局查询然后去一个个排查。开始之前,可以先阅读下官方的这份阅读指南
    因为源码实在是太多太复杂了,所有我这里尽可能的最大到小,从面到点的一个个分析。大致的流程如下:

    首先得知道通过JSX或者createElement编码的代码到底会转成啥
    然后分析应用的入口ReactDOM.render
    接着进一步分析setState更新的流程
    最后再具体分析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


    }
    }

    // babel
    var App = /#PURE/function (_ReactKaTeX parse error: Expected '}', got 'EOF' at end of input: …its(App, _ReactComponent);

    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攻击(你思品)。
    https://www.bilibili.com/video/BV1dt4y1H7jt
    https://www.bilibili.com/video/BV13Y4y147zM
    https://www.bilibili.com/video/BV1534y1j7f2
    https://www.bilibili.com/video/BV1zt4y1H7TQ
    https://www.bilibili.com/video/BV1Fv4y1G7Rm
    https://www.bilibili.com/video/BV1zS4y1e7VX

    但是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等;

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值