bt365体育投注.主頁欢迎您!!

    <acronym id="zvmrr"></acronym>
    <td id="zvmrr"></td>
  • <tr id="zvmrr"><label id="zvmrr"></label></tr>
  • <acronym id="zvmrr"></acronym>
  • Shenfq

    Shenfq 查看完整档案

    广州编辑  |  填写毕业院校前端  |  工具人 编辑 shenfq.com 编辑
    编辑

    欢迎关注公众号「更了不起的前端」

    个人动态

    Shenfq 发布了文章 · 12月1日

    Vue3 Teleport 组件的实践及原理

    Vue3 的组合式 API 以及基于 Proxy 响应式原理已经有很多文章介绍过了,除了这些比较亮眼的更新,Vue3 还新增了一个内置组件:Teleport。这个组件的作用主要用来将模板内的 DOM 元素移动到其他位置。

    使用场景

    业务开发的过程中,我们经常会封装一些常用的组件,例如 Modal 组件。相信大家在使用 Modal 组件的过程中,经常会遇到一个问题,那就是 Modal 的定位问题。

    话不多说,我们先写一个简单的 Modal 组件。

    <!-- Modal.vue -->
    <style lang="scss">
    .modal {
      &__mask {
        position: fixed;
        top: 0;
        left: 0;
        width: 100vw;
        height: 100vh;
        background: rgba(0, 0, 0, 0.5);
      }
      &__main {
        margin: 0 auto;
        margin-bottom: 5%;
        margin-top: 20%;
        width: 500px;
        background: #fff;
        border-radius: 8px;
      }
      /* 省略部分样式 */
    }
    </style>
    <template>
      <div class="modal__mask">
        <div class="modal__main">
          <div class="modal__header">
            <h3 class="modal__title">弹窗标题</h3>
            <span class="modal__close">x</span>
          </div>
          <div class="modal__content">
            弹窗文本内容
          </div>
          <div class="modal__footer">
            <button>取消</button>
            <button>确认</button>
          </div>
        </div>
      </div>
    </template>
    
    <script>
    export default {
      setup() {
        return {};
      },
    };
    </script>

    然后我们在页面中引入 Modal 组件。

    <!-- App.vue -->
    <style lang="scss">
    .container {
      height: 80vh;
      margin: 50px;
      overflow: hidden;
    }
    </style>
    <template>
      <div class="container">
        <Modal />
      </div>
    </template>
    
    <script>
    export default {
      components: {
        Modal,
      },
      setup() {
        return {};
      }
    };
    </script>

    Modal

    如上图所示, div.container 下弹窗组件正常展示。使用 fixed 进行布局的元素,在一般情况下会相对于屏幕视窗来进行定位,但是如果父元素的 transform, perspectivefilter 属性不为 none 时,fixed 元素就会相对于父元素来进行定位。

    我们只需要把 .container 类的 transform 稍作修改,弹窗组件的定位就会错乱。

    <style lang="scss">
    .container {
      height: 80vh;
      margin: 50px;
      overflow: hidden;
      transform: translateZ(0);
    }
    </style>

    Modal

    这个时候,使用 Teleport 组件就能解决这个问题了。

    Teleport 提供了一种干净的方法,允许我们控制在 DOM 中哪个父节点下呈现 HTML,而不必求助于全局状态或将其拆分为两个组件。 -- Vue 官方文档

    我们只需要将弹窗内容放入 Teleport 内,并设置 to 属性为 body,表示弹窗组件每次渲染都会做为 body 的子级,这样之前的问题就能得到解决。

    <template>
      <teleport to="body">
        <div class="modal__mask">
          <div class="modal__main">
            ...
          </div>
        </div>
      </teleport>
    </template>

    可以在 https://codesandbox.io/embed/vue-modal-h5g8y 查看代码。

    使用 Teleport 的 Modal

    源码解析

    我们可以先写一个简单的模板,然后看看 Teleport 组件经过模板编译后,生成的代码。

    Vue.createApp({
      template: `
        <Teleport to="body">
          <div> teleport to body </div>  
        </Teleport>
      `
    })

    模板编译后的代码

    简化后代码:

    function render(_ctx, _cache) {
      with (_ctx) {
        const { createVNode, openBlock, createBlock, Teleport } = Vue
        return (openBlock(), createBlock(Teleport, { to: "body" }, [
          createVNode("div", null, " teleport to body ", -1 /* HOISTED */)
        ]))
      }
    }

    可以看到 Teleport 组件通过 createBlock 进行创建。

    // packages/runtime-core/src/renderer.ts
    export function createBlock(
        type, props, children, patchFlag
    ) {
      const vnode = createVNode(
        type,
        props,
        children,
        patchFlag
      )
      // ... 省略部分逻辑
      return vnode
    }
    
    export function createVNode(
      type, props, children, patchFlag
    ) {
      // class & style normalization.
      if (props) {
        // ...
      }
    
      // encode the vnode type information into a bitmap
      const shapeFlag = isString(type)
        ? ShapeFlags.ELEMENT
        : __FEATURE_SUSPENSE__ && isSuspense(type)
          ? ShapeFlags.SUSPENSE
          : isTeleport(type)
            ? ShapeFlags.TELEPORT
            : isObject(type)
              ? ShapeFlags.STATEFUL_COMPONENT
              : isFunction(type)
                ? ShapeFlags.FUNCTIONAL_COMPONENT
                : 0
    
      const vnode: VNode = {
        type,
        props,
        shapeFlag,
        patchFlag,
        key: props && normalizeKey(props),
        ref: props && normalizeRef(props),
      }
    
      return vnode
    }
    
    // packages/runtime-core/src/components/Teleport.ts
    export const isTeleport = type => type.__isTeleport
    export const Teleport = {
      __isTeleport: true,
      process() {}
    }

    传入 createBlock 的第一个参数为 Teleport,最后得到的 vnode 中会有一个 shapeFlag 属性,该属性用来表示 vnode 的类型。isTeleport(type) 得到的结果为 true,所以 shapeFlag 属性最后的值为 ShapeFlags.TELEPORT1 << 6)。

    // packages/shared/src/shapeFlags.ts
    export const enum ShapeFlags {
      ELEMENT = 1,
      FUNCTIONAL_COMPONENT = 1 << 1,
      STATEFUL_COMPONENT = 1 << 2,
      TEXT_CHILDREN = 1 << 3,
      ARRAY_CHILDREN = 1 << 4,
      SLOTS_CHILDREN = 1 << 5,
      TELEPORT = 1 << 6,
      SUSPENSE = 1 << 7,
      COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
      COMPONENT_KEPT_ALIVE = 1 << 9
    }

    在组件的 render 节点,会依据 typeshapeFlag 走不同的逻辑。

    // packages/runtime-core/src/renderer.ts
    const render = (vnode, container) => {
      if (vnode == null) {
        // 当前组件为空,则将组件销毁
        if (container._vnode) {
          unmount(container._vnode, null, null, true)
        }
      } else {
        // 新建或者更新组件
        // container._vnode 是之前已创建组件的缓存
        patch(container._vnode || null, vnode, container)
      }
      container._vnode = vnode
    }
    
    // patch 是表示补丁,用于 vnode 的创建、更新、销毁
    const patch = (n1, n2, container) => {
      // 如果新旧节点的类型不一致,则将旧节点销毁
      if (n1 && !isSameVNodeType(n1, n2)) {
        unmount(n1)
      }
      const { type, ref, shapeFlag } = n2
      switch (type) {
        case Text:
          // 处理文本
          break
        case Comment:
          // 处理注释
          break
        // case ...
        default:
          if (shapeFlag & ShapeFlags.ELEMENT) {
            // 处理 DOM 元素
          } else if (shapeFlag & ShapeFlags.COMPONENT) {
            // 处理自定义组件
          } else if (shapeFlag & ShapeFlags.TELEPORT) {
            // 处理 Teleport 组件
            // 调用 Teleport.process 方法
            type.process(n1, n2, container...);
          } // else if ...
      }
    }

    可以看到,在处理 Teleport 时,最后会调用 Teleport.process 方法,Vue3 中很多地方都是通过 process 的方式来处理 vnode 相关逻辑的,下面我们重点看看 Teleport.process 方法做了些什么。

    // packages/runtime-core/src/components/Teleport.ts
    const isTeleportDisabled = props => props.disabled
    export const Teleport = {
      __isTeleport: true,
      process(n1, n2, container) {
        const disabled = isTeleportDisabled(n2.props)
        const { shapeFlag, children } = n2
        if (n1 == null) {
          const target = (n2.target = querySelector(n2.prop.to))      
          const mount = (container) => {
            // compiler and vnode children normalization.
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
              mountChildren(children, container)
            }
          }
          if (disabled) {
            // 开关关闭,挂载到原来的位置
            mount(container)
          } else if (target) {
            // 将子节点,挂载到属性 `to` 对应的节点上
            mount(target)
          }
        }
        else {
          // n1不存在,更新节点即可
        }
      }
    }

    其实原理很简单,就是将 Teleportchildren 挂载到属性 to 对应的 DOM 元素中。为了方便理解,这里只是展示了源码的九牛一毛,省略了很多其他的操作。

    总结

    希望在阅读文章的过程中,大家能够掌握 Teleport 组件的用法,并使用到业务场景中。尽管原理十分简单,但是我们有了 Teleport 组件,就能轻松解决弹窗元素定位不准确的问题。

    image

    查看原文

    赞 0 收藏 0 评论 1

    Shenfq 发布了文章 · 11月18日

    CommonJS 是如何导致打包后体积增大的?

    今天的文章,将介绍什么是 CommonJS,以及它为什么会导致我们打包后的文件体积增大。

    本文概要:为了确保打包工具(webpack之类的)能够对你的项目代码进行优化,请避免在项目中使用 CommonJS 模块,并且整个项目都应该使用 ESM(ECMAScript Module) 的模块语法。

    什么是 CommonJS?

    CommonJS 是 2009 年发布的 JavaScript模块化的一项标准,最初它只打算在浏览器之外的场景使用,主要用于服务器端的应用程序。

    你可以使用 CommonJS 来定义模块,并从中导出部分模块。例如,下面的代码定义了一个模块,该模块导出了五个函数:addsubtractmultiplydividemax:

    // utils.js
    const { maxBy } = require('lodash-es');
    const fns = {
      add: (a, b) => a + b,
      subtract: (a, b) => a - b,
      multiply: (a, b) => a * b,
      divide: (a, b) => a / b,
      max: arr => maxBy(arr)
    };
    
    Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

    其他模块可以导入这个模块的部分函数。

    // index.js
    const { add } = require(‘./utils');
    console.log(add(1, 2));

    通过 node 运行 index.js ,会在控制台输出数字 3

    在 2010 年,由于浏览器缺乏标准化的模块化能力,CommonJS 成了当时 JavaScript 客户端较为流行的模块化标准。

    CommonJS 如何影响包体?

    服务端的 JavaScript 程序对代码体积并不像浏览器中那么敏感,这就是为什么在设计 CommonJS 的时候,并没有考虑减少生产包大小的原因。同时,研表究明 JavaScript 代码的体积依然是影响页面加载速度的一个重要因素。

    JavaScript 的打包工具(webpackterser)会进行许多优化以减小最后生成的包体大小。他们在构建时,会分析你的代码,尽可能的删除不会使用的部分。例如,上面的代码中,最终生成的包应该只包含 add 函数,因为这是 index.js 唯一从 utils.js 中导入的部分。

    下面我们使用如下 webpack 配置对应用进行打包:

    const path = require('path');
    module.exports = {
      entry: 'index.js',
      output: {
        filename: 'out.js',
        path: path.resolve(__dirname, 'dist'),
      },
      mode: 'production',
    };

    我们需要将 webpackmode 指定为 production,并且将 index.js 做为入口。运行 webpack 后,会输出一个文件:dist/out.js,可以通过如下方式统计它的大小:

    $ cd dist && ls -lah
    625K Apr 13 13:04 out.js

    打包后的文件高达 625 KB。如果看下 out.js 文件,会发现 utils.js 导入 lodash 的所有模块都打包到了输出的文件中,尽管我们在 index.js 并没有使用到 lodash 的任何方法,但是这给我们的包体带来了巨大的影响。

    现在我们将代码的模块化方案改为 ESMutils.js 部分的代码如下:

    export const add = (a, b) => a + b;
    export const subtract = (a, b) => a - b;
    export const multiply = (a, b) => a * b;
    export const divide = (a, b) => a / b;
    
    import { maxBy } from 'lodash-es';
    
    export const max = arr => maxBy(arr);

    index.js 也改为 ESM 的方式从 utils.js 导入模块:

    import { add } from './utils';
    
    console.log(add(1, 2));

    使用相同的 webpack 配置,构建完毕之后,我们打开 out.js仅有 40 字节,输出如下:

    (()=>{"use strict";console.log(1+2)})();

    值得注意的是,最终的输出并没有包含 utils.js 的任何代码,而且 lodash 也消失了。而且 terserwebpack 使用的压缩工具)直接将 add 函数内联到了 console.log 内部。

    有的小朋友可能就会问了(此处采用了李永乐语法),为什么使用 CommonJS 会导致输出的文件大了 16,000 倍?当然,这只是用来展示 CommonJS 与 ESM 差异的案例,实际上并不会出现这么大的差异,但是使用 CommonJS 肯定会导致打包后的体积更大。

    一般情况下,CommonJS 模块的体积更加难优化,因为它比 ES 模块更加的动态化。为了确保构建工具以及压缩工具能成功优化代码,请避免使用 CommonJS 模块。

    当然,如果你只在 utils.js 采用了 ESM 的模块化方案,而 index.js 还是维持 CommonJS,则包体依旧会受到影响。

    为什么 CommonJS 会使包体更大?

    要回答这个问题,我们需要研究 webpackModuleConcatenationPlugin 的行为,并且看看它是如何进行静态分析的。该插件将所有的模块都放入一个闭包内,这会让你的代码在浏览器中更快的执行。我们来看看下面的代码:

    // utils.js
    export const add = (a, b) => a + b;
    export const subtract = (a, b) => a - b;
    // index.js
    import { add } from ‘./utils';
    const subtract = (a, b) => a - b;
    
    console.log(add(1, 2));

    我们有一个新的 ESM 模块(utils.js),将其导入 index.js 中,我们还重新定义一个 subtract 函数。接下来使用之前的 webpack 配置来构建项目,但是这次,我把禁用压缩配置。

    const path = require('path');
    
    module.exports = {
      entry: 'index.js',
      output: {
        filename: 'out.js',
        path: path.resolve(__dirname, 'dist'),
      },
    + optimization: {
    +   minimize: false
    + },
      mode: 'production',
    };

    输出的 out.js 如下:

    /******/ (() => { // webpackBootstrap
    /******/     "use strict";
    
    // CONCATENATED MODULE: ./utils.js**
    const add = (a, b) => a + b;
    const subtract = (a, b) => a - b;
    
    // CONCATENATED MODULE: ./index.js**
    const index_subtract = (a, b) => a - b;
    console.log(add(1, 2));
    
    /******/ })();

    输出的代码中,所有的函数都在一个命名空间里,为了防止冲突,webpackindex.js 中的 subtract 函数重新命名为了 index_subtract 函数。

    如果开启压缩配置,它会进行如下操作:

    1. 删除没有使用的 subtract 函数和 index_subtract 函数;
    2. 删除所有的注释和空格;
    3. console.log 中直接内联 add 函数;

    一些开发人员会把这种删除未使用代码的行为称为“tree-shaking(树摇)”。webpack 能够通过导出、导入符号静态的分析 utils.js(在构建的过程中),这使得 tree-shaking 有了可行性。当使用 ESM 时,这种行为是默认开启的,因为相比于 CommonJS,它更加易于静态分析。

    让我们看看另外的示例,这一次将 utils.js 改为 CommonJS 模块,而不是 ESM 模块。

    // utils.js
    const { maxBy } = require('lodash-es');
    
    const fns = {
      add: (a, b) => a + b,
      subtract: (a, b) => a - b,
      multiply: (a, b) => a * b,
      divide: (a, b) => a / b,
      max: arr => maxBy(arr)
    };
    
    Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

    这个小小的改动,明显影响了输出的代码。由于输出的文本太大,我们只展示其中的一小部分。

    ...
    (() => {
    
    "use strict";
    /* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
    const subtract = (a, b) => a - b;
    console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));
    
    })();

    可以看到,最终生成的代码包含一些 webpackruntime 代码,这部分代码负责模块的导入导出的能力。这次并没有将 utils.jsindex.js 所有的变量放到了同一命名空间下,动态引入的模块都是通过 __webpack_require__ 进行导入。

    使用 CommonJS 的时候,我们可以通过任意的表达式构造导出名称,例如下面的代码也是能正常运行的:

    module.exports[(Math.random()] = () => { … };

    这导致构建工具在构建时,没有办法知道导出的变量名,因为这个名称只有在用户浏览器运行时才能够真正确定。压缩工具无法准确的知道 index.js 使用了模块的哪部分内容,因此无法正确的进行 tree-shaking。如果我们从 node_modules 导入了 CommonJS 模块,你的构建工具将无法正确的优化它。

    对 CommonJS 使用 Tree-shaking

    由于 CommonJS 的模块化方案是动态的,想要分析他们是特别困难的。与通过表达式导入模块的 CommonJS 相比,ESM 模块的导入始终使用的是静态的字符串文本。

    在某些情况下,如果你使用的库遵循 CommonJS 的相关的一些约定,你可以使用第三方的 webpack 插件:webpack-common-shake,在构建的过程中,删除未使用的模块。尽管该插件增加了 CommonJS 对 tree-shaking 的支持,但并没有涵盖所有的 CommonJS 依赖,这意味着你不能获得 ESM 相同的效果。

    此外,这并非是 webpack 默认行为,它会对你的构建耗时增加额外的成本。

    总结

    为了确保构建工具对你的代码尽可能的进行优化,请避免使用 CommonJS 模块,并在整个项目中使用 ESM 语法。

    下面是一些检验你的项目是否是最佳实践的方法:

    • 使用 Rollup.js 提供的 node-resolve 插件,并开启 modulesOnly 选项,表示你的项目只会使用 ESM。
    • 使用 is-esm 来验证 npm 安装的模块是否使用 ESM。
    • 如果您使用的是Angular,默认情况下,如果你依赖了不能进行 tree-shaking 的模块,则会收到警告。

    image

    查看原文

    赞 0 收藏 0 评论 0

    Shenfq 发布了文章 · 11月11日

    Vue3 模板编译优化

    Vue3 正式发布已经有一段时间了,前段时间写了一篇文章(《Vue 模板编译原理》)分析 Vue 的模板编译原理。今天的文章打算学习下 Vue3 下的模板编译与 Vue2 下的差异,以及 VDOM 下 Diff 算法的优化。

    编译入口

    了解过 Vue3 的同学肯定知道 Vue3 引入了新的组合 Api,在组件 mount 阶段会调用 setup 方法,之后会判断 render 方法是否存在,如果不存在会调用 compile 方法将 template 转化为 render

    // packages/runtime-core/src/renderer.ts
    const mountComponent = (initialVNode, container) => {
      const instance = (
        initialVNode.component = createComponentInstance(
          // ...params
        )
      )
      // 调用 setup
      setupComponent(instance)
    }
    
    // packages/runtime-core/src/component.ts
    let compile
    export function registerRuntimeCompiler(_compile) {
      compile = _compile
    }
    export function setupComponent(instance) {
      const Component = instance.type
      const { setup } = Component
      if (setup) {
        // ...调用 setup
      }
      if (compile && Component.template && !Component.render) {
          // 如果没有 render 方法
        // 调用 compile 将 template 转为 render 方法
        Component.render = compile(Component.template, {...})
      }
    }

    这部分都是 runtime-core 中的代码,之前的文章有讲过 Vue 分为完整版和 runtime 版本。如果使用 vue-loader 处理 .vue 文件,一般都会将 .vue 文件中的 template 直接处理成 render 方法。

    //  需要编译器
    Vue.createApp({
      template: '<div>{{ hi }}</div>'
    })
    
    // 不需要
    Vue.createApp({
      render() {
        return Vue.h('div', {}, this.hi)
      }
    })

    完整版与 runtime 版的差异就是,完整版会引入 compile 方法,如果是 vue-cli 生成的项目就会抹去这部分代码,将 compile 过程都放到打包的阶段,以此优化性能。runtime-dom 中提供了 registerRuntimeCompiler 方法用于注入 compile 方法。

    主流程

    在完整版的 index.js 中,调用了 registerRuntimeCompilercompile 进行注入,接下来我们看看注入的 compile 方法主要做了什么。

    // packages/vue/src/index.ts
    import { compile } from '@vue/compiler-dom'
    
    // 编译缓存
    const compileCache = Object.create(null)
    
    // 注入 compile 方法
    function compileToFunction(
        // 模板
      template: string | HTMLElement,
      // 编译配置
      options?: CompilerOptions
    ): RenderFunction {
      if (!isString(template)) {
        // 如果 template 不是字符串
        // 则认为是一个 DOM 节点,获取 innerHTML
        if (template.nodeType) {
          template = template.innerHTML
        } else {
          return NOOP
        }
      }
    
      // 如果缓存中存在,直接从缓存中获取
      const key = template
      const cached = compileCache[key]
      if (cached) {
        return cached
      }
    
      // 如果是 ID 选择器,这获取 DOM 元素后,取 innerHTML
      if (template[0] === '#') {
        const el = document.querySelector(template)
        template = el ? el.innerHTML : ''
      }
    
      // 调用 compile 获取 render code
      const { code } = compile(
        template,
        options
      )
    
      // 将 render code 转化为 function
      const render = new Function(code)();
    
        // 返回 render 方法的同时,将其放入缓存
      return (compileCache[key] = render)
    }
    
    // 注入 compile
    registerRuntimeCompiler(compileToFunction)

    在讲 Vue2 模板编译的时候已经讲过,compile 方法主要分为三步,Vue3 的逻辑类似:

    1. 模板编译,将模板代码转化为 AST;
    2. 优化 AST,方便后续虚拟 DOM 更新;
    3. 生成代码,将 AST 转化为可执行的代码;
    // packages/compiler-dom/src/index.ts
    import { baseCompile, baseParse } from '@vue/compiler-core'
    export function compile(template, options) {
      return baseCompile(template, options)
    }
    
    // packages/compiler-core/src/compile.ts
    import { baseParse } from './parse'
    import { transform } from './transform'
    
    import { transformIf } from './transforms/vIf'
    import { transformFor } from './transforms/vFor'
    import { transformText } from './transforms/transformText'
    import { transformElement } from './transforms/transformElement'
    
    import { transformOn } from './transforms/vOn'
    import { transformBind } from './transforms/vBind'
    import { transformModel } from './transforms/vModel'
    
    export function baseCompile(template, options) {
      // 解析 html,转化为 ast
      const ast = baseParse(template, options)
      // 优化 ast,标记静态节点
      transform(ast, {
        ...options,
        nodeTransforms: [
          transformIf,
          transformFor,
          transformText,
          transformElement,
          // ... 省略了部分 transform
        ],
        directiveTransforms: {
          on: transformOn,
          bind: transformBind,
          model: transformModel
        }
      })
      // 将 ast 转化为可执行代码
      return generate(ast, options)
    }

    计算 PatchFlag

    这里大致的逻辑与之前的并没有多大的差异,主要是 optimize 方法变成了 transform 方法,而且默认会对一些模板语法进行 transform。这些 transform 就是后续虚拟 DOM 优化的关键,我们先看看 transform 的代码 。

    // packages/compiler-core/src/transform.ts
    export function transform(root, options) {
      const context = createTransformContext(root, options)
      traverseNode(root, context)
    }
    export function traverseNode(node, context) {
      context.currentNode = node
      const { nodeTransforms } = context
      const exitFns = []
      for (let i = 0; i < nodeTransforms.length; i++) {
        // Transform 会返回一个退出函数,在处理完所有的子节点后再执行
        const onExit = nodeTransforms[i](node, context)
        if (onExit) {
          if (isArray(onExit)) {
            exitFns.push(...onExit)
          } else {
            exitFns.push(onExit)
          }
        }
      }
      traverseChildren(node, context)
      context.currentNode = node
      // 执行所以 Transform 的退出函数
      let i = exitFns.length
      while (i--) {
        exitFns[i]()
      }
    }

    我们重点看一下 transformElement 的逻辑:

    // packages/compiler-core/src/transforms/transformElement.ts
    export const transformElement: NodeTransform = (node, context) => {
      // transformElement 没有执行任何逻辑,而是直接返回了一个退出函数
      // 说明 transformElement 需要等所有的子节点处理完后才执行
      return function postTransformElement() {
        const { tag, props } = node
    
        let vnodeProps
        let vnodePatchFlag
        const vnodeTag = node.tagType === ElementTypes.COMPONENT
          ? resolveComponentType(node, context)
          : `"${tag}"`
        
        let patchFlag = 0
        // 检测节点属性
        if (props.length > 0) {
          // 检测节点属性的动态部分
          const propsBuildResult = buildProps(node, context)
          vnodeProps = propsBuildResult.props
          patchFlag = propsBuildResult.patchFlag
        }
    
        // 检测子节点
        if (node.children.length > 0) {
          if (node.children.length === 1) {
            const child = node.children[0]
            // 检测子节点是否为动态文本
            if (!getStaticType(child)) {
              patchFlag |= PatchFlags.TEXT
            }
          }
        }
    
        // 格式化 patchFlag
        if (patchFlag !== 0) {
            vnodePatchFlag = String(patchFlag)
        }
    
        node.codegenNode = createVNodeCall(
          context,
          vnodeTag,
          vnodeProps,
          vnodeChildren,
          vnodePatchFlag
        )
      }
    }

    buildProps 会对节点的属性进行一次遍历,由于内部源码涉及很多其他的细节,这里的代码是经过简化之后的,只保留了 patchFlag 相关的逻辑。

    export function buildProps(
      node: ElementNode,
      context: TransformContext,
      props: ElementNode['props'] = node.props
    ) {
      let patchFlag = 0
      for (let i = 0; i < props.length; i++) {
        const prop = props[i]
        const [key, name] = prop.name.split(':')
        if (key === 'v-bind' || key === '') {
          if (name === 'class') {
              // 如果包含 :class 属性,patchFlag | CLASS
            patchFlag |= PatchFlags.CLASS
          } else if (name === 'style') {
              // 如果包含 :style 属性,patchFlag | STYLE
            patchFlag |= PatchFlags.STYLE
          }
        }
      }
    
      return {
        patchFlag
      }
    }

    上面的代码只展示了三种 patchFlag 的类型:

    • 节点只有一个文本子节点,且该文本包含动态的数据TEXT = 1
    <p>name: {{name}}</p>
    • 节点包含可变的 class 属性CLASS = 1 << 1
    <div :class="{ active: isActive }"></div>
    • 节点包含可变的 style 属性STYLE = 1 << 2
    <div :style="{ color: color }"></div>

    可以看到 PatchFlags 都是数字 1 经过 左移操作符 计算得到的。

    export const enum PatchFlags {
      TEXT = 1,             // 1, 二进制 0000 0001
      CLASS = 1 << 1,       // 2, 二进制 0000 0010
      STYLE = 1 << 2,       // 4, 二进制 0000 0100
      PROPS = 1 << 3,       // 8, 二进制 0000 1000
      ...
    }

    从上面的代码能看出来,patchFlag 的初始值为 0,每次对 patchFlag 都是执行 | (或)操作。如果当前节点是一个只有动态文本子节点且同时具有动态 style 属性,最后得到的 patchFlag 为 5(二进制:0000 0101)。

    <p :style="{ color: color }">name: {{name}}</p>
    patchFlag = 0
    patchFlag |= PatchFlags.STYLE
    patchFlag |= PatchFlags.TEXT
    // 或运算:两个对应的二进制位中只要一个是1,结果对应位就是1。
    // 0000 0001
    // 0000 0100
    // ------------
    // 0000 0101  =>  十进制 5

    patchFlag

    我们将上面的代码放到 Vue3 中运行:

    const app = Vue.createApp({
      data() {
        return {
          color: 'red',
          name: 'shenfq'
        }
      },
      template: `<div>
          <p :style="{ color: color }">name: {{name}}</p>
      </div>`
    })
    
    app.mount('#app')

    最后生成的 render 方法如下,和我们之前的描述基本一致。

    function render() {}

    render 优化

    Vue3 在虚拟 DOM Diff 时,会取出 patchFlag 和需要进行的 diff 类型进行 &(与)操作,如果结果为 true 才进入对应的 diff。

    patchFlag 判断

    还是拿之前的模板举例:

    <p :style="{ color: color }">name: {{name}}</p>

    如果此时的 name 发生了修改,p 节点进入了 diff 阶段,此时会将判断 patchFlag & PatchFlags.TEXT ,这个时候结果为真,表明 p 节点存在文本修改的情况。

    patchFlag

    patchFlag = 5
    patchFlag & PatchFlags.TEXT
    // 或运算:只有对应的两个二进位都为1时,结果位才为1。
    // 0000 0101
    // 0000 0001
    // ------------
    // 0000 0001  =>  十进制 1
    if (patchFlag & PatchFlags.TEXT) {
      if (oldNode.children !== newNode.children) {
        // 修改文本
        hostSetElementText(el, newNode.children)
      }
    }

    但是进行 patchFlag & PatchFlags.CLASS 判断时,由于节点并没有动态 Class,返回值为 0,所以就不会对该节点的 class 属性进行 diff,以此来优化性能。

    patchFlag

    patchFlag = 5
    patchFlag & PatchFlags.CLASS
    // 或运算:只有对应的两个二进位都为1时,结果位才为1。
    // 0000 0101
    // 0000 0010
    // ------------
    // 0000 0000  =>  十进制 0

    总结

    其实 Vue3 相关的性能优化有很多,这里只单独将 patchFlag 的十分之一的内容拿出来讲了,Vue3 还没正式发布的时候就有看到说 Diff 过程会通过 patchFlag 来进行性能优化,所以打算看看他的优化逻辑,总的来说还是有所收获。
    image

    查看原文

    赞 1 收藏 0 评论 1

    Shenfq 发布了文章 · 11月2日

    小程序依赖分析实践

    用过 webpack 的同学肯定知道 webpack-bundle-analyzer ,可以用来分析当前项目 js 文件的依赖关系。

    webpack-bundle-analyzer

    因为最近一直在做小程序业务,而且小程序对包体大小特别敏感,所以就想着能不能做一个类似的工具,用来查看当前小程序各个主包与分包之间的依赖关系。经过几天的折腾终于做出来了,效果如下:

    小程序依赖关系

    今天的文章就带大家来实现这个工具。

    小程序入口

    小程序的页面通过 app.jsonpages 参数定义,用于指定小程序由哪些页面组成,每一项都对应一个页面的路径(含文件名) 信息。 pages 内的每个页面,小程序都会去寻找对应的 json, js, wxml, wxss 四个文件进行处理。

    如开发目录为:

    ├── app.js
    ├── app.json
    ├── app.wxss
    ├── pages
    │   │── index
    │   │   ├── index.wxml
    │   │   ├── index.js
    │   │   ├── index.json
    │   │   └── index.wxss
    │   └── logs
    │       ├── logs.wxml
    │       └── logs.js
    └── utils

    则需要在 app.json 中写:

    {
      "pages": ["pages/index/index", "pages/logs/logs"]
    }

    为了方便演示,我们先 fork 一份小程序的官方demo,然后新建一个文件 depend.js,依赖分析相关的工作就在这个文件里面实现。

    $ git clone git@github.com:wechat-miniprogram/miniprogram-demo.git
    $ cd miniprogram-demo
    $ touch depend.js

    其大致的目录结构如下:

    目录结构

    app.json 为入口,我们可以获取所有主包下的页面。

    const fs = require('fs-extra')
    const path = require('path')
    
    const root = process.cwd()
    
    class Depend {
      constructor() {
        this.context = path.join(root, 'miniprogram')
      }
      // 获取绝对地址
      getAbsolute(file) {
        return path.join(this.context, file)
      }
      run() {
        const appPath = this.getAbsolute('app.json')
        const appJson = fs.readJsonSync(appPath)
        const { pages } = appJson // 主包的所有页面
      }
    }

    每个页面会对应 json, js, wxml, wxss 四个文件:

    const Extends = ['.js', '.json', '.wxml', '.wxss']
    class Depend {
      constructor() {
        // 存储文件
        this.files = new Set()
        this.context = path.join(root, 'miniprogram')
      }
      // 修改文件后缀
      replaceExt(filePath, ext = '') {
        const dirName = path.dirname(filePath)
        const extName = path.extname(filePath)
        const fileName = path.basename(filePath, extName)
        return path.join(dirName, fileName + ext)
      }
      run() {
        // 省略获取 pages 过程
        pages.forEach(page => {
          // 获取绝对地址
          const absPath = this.getAbsolute(page)
          Extends.forEach(ext => {
            // 每个页面都需要判断 js、json、wxml、wxss 是否存在
            const filePath = this.replaceExt(absPath, ext)
            if (fs.existsSync(filePath)) {
              this.files.add(filePath)
            }
          })
        })
      }
    }

    现在 pages 内页面相关的文件都放到 files 字段存起来了。

    构造树形结构

    拿到文件后,我们需要依据各个文件构造一个树形结构的文件树,用于后续展示依赖关系。

    假设我们有一个 pages 目录,pages 目录下有两个页面:detailindex ,这两个 页面文件夹下有四个对应的文件。

    pages
    ├── detail
    │   ├── detail.js
    │   ├── detail.json
    │   ├── detail.wxml
    │   └── detail.wxss
    └── index
        ├── index.js
        ├── index.json
        ├── index.wxml
        └── index.wxss

    依据上面的目录结构,我们构造一个如下的文件树结构,size 用于表示当前文件或文件夹的大小,children 存放文件夹下的文件,如果是文件则没有 children 属性。

    pages = {
      "size": 8,
      "children": {
        "detail": {
          "size": 4,
          "children": {
            "detail.js": { "size": 1 },
            "detail.json": { "size": 1 },
            "detail.wxml": { "size": 1 },
            "detail.wxss": { "size": 1 }
          }
        },
        "index": {
          "size": 4,
          "children": {
            "index.js": { "size": 1 },
            "index.json": { "size": 1 },
            "index.wxml": { "size": 1 },
            "index.wxss": { "size": 1 }
          }
        }
      }
    }

    我们先在构造函数构造一个 tree 字段用来存储文件树的数据,然后我们将每个文件都传入 addToTree 方法,将文件添加到树中 。

    class Depend {
      constructor() {
        this.tree = {
          size: 0,
          children: {}
        }
        this.files = new Set()
        this.context = path.join(root, 'miniprogram')
      }
      
      run() {
        // 省略获取 pages 过程
        pages.forEach(page => {
          const absPath = this.getAbsolute(page)
          Extends.forEach(ext => {
            const filePath = this.replaceExt(absPath, ext)
            if (fs.existsSync(filePath)) {
              // 调用 addToTree
              this.addToTree(filePath)
            }
          })
        })
      }
    }

    接下来实现 addToTree 方法:

    class Depend {
      // 省略之前的部分代码
    
      // 获取相对地址
      getRelative(file) {
        return path.relative(this.context, file)
      }
      // 获取文件大小,单位 KB
      getSize(file) {
        const stats = fs.statSync(file)
        return stats.size / 1024
      }
    
      // 将文件添加到树中
      addToTree(filePath) {
        if (this.files.has(filePath)) {
          // 如果该文件已经添加过,则不再添加到文件树中
          return
        }
        const size = this.getSize(filePath)
        const relPath = this.getRelative(filePath)
        // 将文件路径转化成数组
        // 'pages/index/index.js' =>
        // ['pages', 'index', 'index.js']
        const names = relPath.split(path.sep)
        const lastIdx = names.length - 1
    
        this.tree.size += size
        let point = this.tree.children
        names.forEach((name, idx) => {
          if (idx === lastIdx) {
            point[name] = { size }
            return
          }
          if (!point[name]) {
            point[name] = {
              size, children: {}
            }
          } else {
            point[name].size += size
          }
          point = point[name].children
        })
        // 将文件添加的 files
        this.files.add(filePath)
      }
    }

    我们可以在运行之后,将文件输出到 tree.json 看看。

     run() {
       // ...
       pages.forEach(page => {
         //...
       })
       fs.writeJSONSync('tree.json', this.tree, { spaces: 2 })
     }

    tree.json

    获取依赖关系

    上面的步骤看起来没什么问题,但是我们缺少了重要的一环,那就是我们在构造文件树之前,还需要得到每个文件的依赖项,这样输出的才是小程序完整的文件树。文件的依赖关系需要分成四部分来讲,分别是 js, json, wxml, wxss 这四种类型文件获取依赖的方式。

    获取 .js 文件依赖

    小程序支持 CommonJS 的方式进行模块化,如果开启了 es6,也能支持 ESM 进行模块化。我们如果要获得一个 js 文件的依赖,首先要明确,js 文件导入模块的三种写法,针对下面三种语法,我们可以引入 Babel 来获取依赖。

    import a from './a.js'
    export b from './b.js'
    const c = require('./c.js')

    通过 @babel/parser 将代码转化为 AST,然后通过 @babel/traverse 遍历 AST 节点,获取上面三种导入方式的值,放到数组。

    const { parse } = require('@babel/parser')
    const { default: traverse } = require('@babel/traverse')
    
    class Depend {
      // ...
        jsDeps(file) {
        const deps = []
        const dirName = path.dirname(file)
        // 读取 js 文件内容
        const content = fs.readFileSync(file, 'utf-8')
        // 将代码转化为 AST
        const ast = parse(content, {
          sourceType: 'module',
          plugins: ['exportDefaultFrom']
        })
        // 遍历 AST
        traverse(ast, {
          ImportDeclaration: ({ node }) => {
            // 获取 import from 地址
            const { value } = node.source
            const jsFile = this.transformScript(dirName, value)
            if (jsFile) {
              deps.push(jsFile)
            }
          },
          ExportNamedDeclaration: ({ node }) => {
            // 获取 export from 地址
            const { value } = node.source
            const jsFile = this.transformScript(dirName, value)
            if (jsFile) {
              deps.push(jsFile)
            }
          },
          CallExpression: ({ node }) => {
            if (
              (node.callee.name && node.callee.name === 'require') &&
              node.arguments.length >= 1
            ) {
              // 获取 require 地址
              const [{ value }] = node.arguments
              const jsFile = this.transformScript(dirName, value)
              if (jsFile) {
                deps.push(jsFile)
              }
            }
          }
        })
        return deps
      }
    }

    在获取依赖模块的路径后,还不能立即将路径添加到依赖数组内,因为根据模块语法 js 后缀是可以省略的,另外 require 的路径是一个文件夹的时候,默认会导入该文件夹下的 index.js

    class Depend {
      // 获取某个路径的脚本文件
      transformScript(url) {
        const ext = path.extname(url)
        // 如果存在后缀,表示当前已经是一个文件
        if (ext === '.js' && fs.existsSync(url)) {
          return url
        }
        // a/b/c => a/b/c.js
        const jsFile = url + '.js'
        if (fs.existsSync(jsFile)) {
          return jsFile
        }
        // a/b/c => a/b/c/index.js
        const jsIndexFile = path.join(url, 'index.js')
        if (fs.existsSync(jsIndexFile)) {
          return jsIndexFile
        }
        return null
      }
        jsDeps(file) {...}
    }

    我们可以创建一个 js,看看输出的 deps 是否正确:

    // 文件路径:/Users/shenfq/Code/fork/miniprogram-demo/
    import a from './a.js'
    export b from '../b.js'
    const c = require('../../c.js')

    image-20201101134549678

    获取 .json 文件依赖

    json 文件本身是不支持模块化的,但是小程序可以通过 json 文件导入自定义组件,只需要在页面的 json 文件通过 usingComponents 进行引用声明。usingComponents 为一个对象,键为自定义组件的标签名,值为自定义组件文件路径:

    {
      "usingComponents": {
        "component-tag-name": "path/to/the/custom/component"
      }
    }

    自定义组件与小程序页面一样,也会对应四个文件,所以我们需要获取 jsonusingComponents 内的所有依赖项,并判断每个组件对应的那四个文件是否存在,然后添加到依赖项内。

    class Depend {
      // ...
      jsonDeps(file) {
        const deps = []
        const dirName = path.dirname(file)
        const { usingComponents } = fs.readJsonSync(file)
        if (usingComponents && typeof usingComponents === 'object') {
          Object.values(usingComponents).forEach((component) => {
            component = path.resolve(dirName, component)
            // 每个组件都需要判断 js/json/wxml/wxss 文件是否存在
            Extends.forEach((ext) => {
              const file = this.replaceExt(component, ext)
              if (fs.existsSync(file)) {
                deps.push(file)
              }
            })
          })
        }
        return deps
      }
    }

    获取 .wxml 文件依赖

    wxml 提供两种文件引用方式 importinclude

    <import data-original="a.wxml"/>
    <include data-original="b.wxml"/>

    wxml 文件本质上还是一个 html 文件,所以可以通过 html parser 对 wxml 文件进行解析,关于 html parser 相关的原理可以看我之前写过的文章 《Vue 模板编译原理》

    const htmlparser2 = require('htmlparser2')
    
    class Depend {
      // ...
        wxmlDeps(file) {
        const deps = []
        const dirName = path.dirname(file)
        const content = fs.readFileSync(file, 'utf-8')
        const htmlParser = new htmlparser2.Parser({
          onopentag(name, attribs = {}) {
            if (name !== 'import' && name !== 'require') {
              return
            }
            const { src } = attribs
            if (src) {
              return
            }
              const wxmlFile = path.resolve(dirName, src)
            if (fs.existsSync(wxmlFile)) {
                deps.push(wxmlFile)
            }
          }
        })
        htmlParser.write(content)
        htmlParser.end()
        return deps
      }
    }

    获取 .wxss 文件依赖

    最后 wxss 文件导入样式和 css 语法一致,使用 @import 语句可以导入外联样式表。

    @import "common.wxss";

    可以通过 postcss 解析 wxss 文件,然后获取导入文件的地址,但是这里我们偷个懒,直接通过简单的正则匹配来做。

    class Depend {
      // ...
      wxssDeps(file) {
        const deps = []
        const dirName = path.dirname(file)
        const content = fs.readFileSync(file, 'utf-8')
        const importRegExp = /@import\s*['"](.+)['"];*/g
        let matched
        while ((matched = importRegExp.exec(content)) !== null) {
          if (!matched[1]) {
            continue
          }
          const wxssFile = path.resolve(dirName, matched[1])
          if (fs.existsSync(wxmlFile)) {
            deps.push(wxssFile)
          }
        }
        return deps
      }
    }

    将依赖添加到树结构中

    现在我们需要修改 addToTree 方法。

    class Depend {
      addToTree(filePath) {
        // 如果该文件已经添加过,则不再添加到文件树中
        if (this.files.has(filePath)) {
          return
        }
    
        const relPath = this.getRelative(filePath)
        const names = relPath.split(path.sep)
        names.forEach((name, idx) => {
          // ... 添加到树中
        })
        this.files.add(filePath)
    
        // ===== 获取文件依赖,并添加到树中 =====
        const deps = this.getDeps(filePath)
        deps.forEach(dep => {
          this.addToTree(dep)      
        })
      }
    }

    image-20201101205623259

    获取分包依赖

    熟悉小程序的同学肯定知道,小程序提供了分包机制。使用分包后,分包内的文件会被打包成一个单独的包,在用到的时候才会加载,而其他的文件则会放在主包,小程序打开的时候就会加载。subpackages 中,每个分包的配置有以下几项:

    字段类型说明
    rootString分包根目录
    nameString分包别名,分包预下载时可以使用
    pagesStringArray分包页面路径,相对与分包根目录
    independentBoolean分包是否是独立分包

    所以我们在运行的时候,除了要拿到 pages 下的所有页面,还需拿到 subpackages 中所有的页面。由于之前只关心主包的内容,this.tree 下面只有一颗文件树,现在我们需要在 this.tree 下挂载多颗文件树,我们需要先为主包创建一个单独的文件树,然后为每个分包创建一个文件树。

    class Depend {
      constructor() {
        this.tree = {}
        this.files = new Set()
        this.context = path.join(root, 'miniprogram')
      }
      createTree(pkg) {
        this.tree[pkg] = {
          size: 0,
          children: {}
        }
      }
      addPage(page, pkg) {
        const absPath = this.getAbsolute(page)
        Extends.forEach(ext => {
          const filePath = this.replaceExt(absPath, ext)
          if (fs.existsSync(filePath)) {
            this.addToTree(filePath, pkg)
          }
        })
      }
      run() {
        const appPath = this.getAbsolute('app.json')
        const appJson = fs.readJsonSync(appPath)
        const { pages, subPackages, subpackages } = appJson
        
        this.createTree('main') // 为主包创建文件树
        pages.forEach(page => {
          this.addPage(page, 'main')
        })
        // 由于 app.json 中 subPackages、subpackages 都能生效
        // 所以我们两个属性都获取,哪个存在就用哪个
        const subPkgs = subPackages || subpackages
        // 分包存在的时候才进行遍历
        subPkgs && subPkgs.forEach(({ root, pages }) => {
          root = root.split('/').join(path.sep)
          this.createTree(root) // 为分包创建文件树
          pages.forEach(page => {
            this.addPage(`${root}${path.sep}${page}`, pkg)
          })
        })
        // 输出文件树
        fs.writeJSONSync('tree.json', this.tree, { spaces: 2 })
      }
    }

    addToTree 方法也需要进行修改,根据传入的 pkg 来判断将当前文件添加到哪个树。

    class Depend {
      addToTree(filePath, pkg = 'main') {
        if (this.files.has(filePath)) {
          // 如果该文件已经添加过,则不再添加到文件树中
          return
        }
        let relPath = this.getRelative(filePath)
        if (pkg !== 'main' && relPath.indexOf(pkg) !== 0) {
          // 如果该文件不是以分包名开头,证明该文件不在分包内,
          // 需要将文件添加到主包的文件树内
          pkg = 'main'
        }
    
        const tree = this.tree[pkg] // 依据 pkg 取到对应的树
        const size = this.getSize(filePath)
        const names = relPath.split(path.sep)
        const lastIdx = names.length - 1
    
        tree.size += size
        let point = tree.children
        names.forEach((name, idx) => {
          // ... 添加到树中
        })
        this.files.add(filePath)
    
        // ===== 获取文件依赖,并添加到树中 =====
        const deps = this.getDeps(filePath)
        deps.forEach(dep => {
          this.addToTree(dep)      
        })
      }
    }

    这里有一点需要注意,如果 package/a 分包下的文件依赖的文件不在 package/a 文件夹下,则该文件需要放入主包的文件树内。

    通过 EChart 画图

    经过上面的流程后,最终我们可以得到如下的一个 json 文件:

    tree.json

    接下来,我们利用 ECharts 的画图能力,将这个 json 数据以图表的形式展现出来。我们可以在 ECharts 提供的实例中看到一个 Disk Usage 的案例,很符合我们的预期。

    ECharts

    ECharts 的配置这里就不再赘述,按照官网的 demo 即可,我们需要把 tree. json 的数据转化为 ECharts 需要的格式就行了,完整的代码放到 codesandbod 了,去下面的线上地址就能看到效果了。

    线上地址:https://codesandbox.io/s/cold...

    最后效果

    总结

    这篇文章比较偏实践,所以贴了很多的代码,另外本文对各个文件的依赖获取提供了一个思路,虽然这里只是用文件树构造了一个这样的依赖图。

    在业务开发中,小程序 IDE 每次启动都需要进行全量的编译,开发版预览的时候会等待较长的时间,我们现在有文件依赖关系后,就可以只选取目前正在开发的页面进行打包,这样就能大大提高我们的开发效率。如果有对这部分内容感兴趣的,可以另外写一篇文章介绍下如何实现。

    image

    查看原文

    赞 4 收藏 2 评论 0

    Shenfq 发布了文章 · 10月27日

    React 架构的演变 - Hooks 的实现

    这是这个系列的最后一篇文章了,终于收尾了?? 。

    React Hooks 可以说完全颠覆了之前 Class Component 的写法,进一步增强了状态复用的能力,让 Function Component 也具有了内部状态,对于我个人来说,更加喜欢 Hooks 的写法。当然如果你是一个使用 Class Component 的老手,初期上手时会觉得很苦恼,毕竟之前沉淀的很多 HOC、Render Props 组件基本没法用。而且之前的 Function Component 是无副作用的无状态组件,现在又能通过 Hooks 引入状态,看起来真的很让人疑惑。Function Component 的另一个优势就是可以完全告别 this ,在 Class Component 里面 this 真的是一个让人讨厌的东西??。

    Hook 如何与组件关联

    在之前的文章中多次提到,Fiber 架构下的 updateQueueeffectList 都是链表的数据结构,然后挂载的 Fiber 节点上。而一个函数组件内所有的 Hooks 也是通过链表的形式存储的,最后挂载到 fiber.memoizedState 上。

    function App() {
      const [num, updateNum] = useState(0)
    
      return <div
        onClick={() => updateNum(num => num + 1)}
      >{ num }</div>
    }
    
    export default App

    我们先简单看下,调用 useState 时,构造链表的过程:

    var workInProgressHook = null
    var HooksDispatcherOnMount = {
      useState: function (initialState) {
        return mountState(initialState)
      }
    }
    
    function function mountState(initialState) {
      // 新的 Hook 节点
      var hook = mountWorkInProgressHook()
      // 缓存初始值
      hook.memoizedState = initialState
      // 构造更新队列,类似于 fiber.updateQueue
      var queue = hook.queue = {
        pending: null,
        dispatch: null,
        lastRenderedState: initialState
      }
      // 用于派发更新
      var dispatch = queue.dispatch = dispatchAction.bind(
        null, workInProgress, queue
      )
      // [num, updateNum] = useState(0)
      return [hook.memoizedState, dispatch]
    }
    
    function mountWorkInProgressHook() {
      var hook = {
        memoizedState: null,
        baseState: null,
        baseQueue: null,
        queue: null,
        next: null
      }
    
      if (workInProgressHook === null) {
        // 构造链表头节点
        workInProgress.memoizedState = workInProgressHook = hook
      } else {
        // 如果链表已经存在,在挂载到 next
        workInProgressHook = workInProgressHook.next = hook
      }
    
      return workInProgressHook
    }

    Hook

    如果此时有两个 Hook,第二个 Hook 就会挂载到第一个 Hook 的 next 属性上。

    function App() {
      const [num, updateNum] = useState(0)
      const [str, updateStr] = useState('value: ')
    
      return <div
        onClick={() => updateNum(num => num + 1)}
      >{ str } { num }</div>
    }
    
    export default App

    Hook

    Hook 的更新队列

    Hook 通过 .next 彼此相连,而每个 Hook 对象下,还有个 queue 字段,该字段和 Fiber 节点上的 updateQueue 一样,是一个更新队列在,上篇文章 《React 架构的演变-更新机制》中有讲到,React Fiber 架构中,更新队列通过链表结构进行存储。

    class App extends React.Component {
      state = { val: 0 }
      click () {
        for (let i = 0; i < 3; i++) {
          this.setState({ val: this.state.val + 1 })
        }
      }
      render() {
        return <div onClick={() => {
          this.click()
        }}>val: { this.state.val }</div>
      }
    }

    点击 div 之后,产生的 3 次 setState 通过链表的形式挂载到 fiber.updateQueue 上,待到 MessageChannel 收到通知后,真正执行更新操作时,取出更新队列,将计算结果更新到 fiber.memoizedState

    setState

    hook.queue 的逻辑和 fiber.updateQueue 的逻辑也是完全一致的。

    function App() {
      const [num, updateNum] = useState(0)
    
      return <div
        onClick={() => {
          // 连续更新 3 次
          updateNum(num => num + 1)
          updateNum(num => num + 1)
          updateNum(num => num + 1)
        }}
      >
        { num }
      </div>
    }
    
    export default App;
    var dispatch = queue.dispatch = dispatchAction.bind(
      null, workInProgress, queue
    )
    // [num, updateNum] = useState(0)
    return [hook.memoizedState, dispatch]

    调用 useState 的时候,返回的数组第二个参数为 dispatch,而 dispatchdispatchAction bind 后得到。

    function dispatchAction(fiber, queue, action) {
      var update = {
        next: null,
        action: action,
        // 省略调度相关的参数...
      };
    
      var pending = queue.pending
      if (pending === null) {
        update.next = update
      } else {
        update.next = pending.next
        pending.next = update
      }
      queue.pending = update
    
      // 执行更新
      scheduleUpdateOnFiber()
    }

    可以看到这里构造链表的方式与 fiber.updateQueue 如出一辙。之前我们通过 updateNumnum 连续更新了 3 次,最后形成的更新队列如下:

    更新队列

    函数组件的更新

    前面的文章分享过,Fiber 架构下的更新流程分为递(beginWork)、归(completeWork)两个步骤,在 beginWork 中,会依据组件类型进行 render 操作构造子组件。

    function beginWork(current, workInProgress) {
      switch (workInProgress.tag) {
        // 其他类型组件代码省略...
        case FunctionComponent: {
          // 这里的 type 就是函数组件的函数
          // 例如,前面的 App 组件,type 就是 function App() {}
          var Component = workInProgress.type
          var resolvedProps = workInProgress.pendingProps
          // 组件更新
          return updateFunctionComponent(
            current, workInProgress, Component, resolvedProps
          )
        }
      }
    }
    
    function updateFunctionComponent(
        current, workInProgress, Component, nextProps
    ) {
      // 构造子组件
      var nextChildren = renderWithHooks(
        current, workInProgress, Component, nextProps
      )
      reconcileChildren(current, workInProgress, nextChildren)
      return workInProgress.child
    }
    

    看名字就能看出来,renderWithHooks 方法就是构造带 Hooks 的子组件。

    function renderWithHooks(
        current, workInProgress, Component, props
    ) {
      if (current !== null && current.memoizedState !== null) {
        ReactCurrentDispatcher.current = HooksDispatcherOnUpdate
      } else {
        ReactCurrentDispatcher.current = HooksDispatcherOnMount
      }
      var children = Component(props)
      return children
    }

    从上面的代码可以看出,函数组件更新或者首次渲染时,本质就是将函数取出执行了一遍。不同的地方在于给 ReactCurrentDispatcher 进行了不同的赋值,而 ReactCurrentDispatcher 的值最终会影响 useState 调用不同的方法。

    根据之前文章讲过的双缓存机制,current 存在的时候表示是更新操作,不存在的时候表示首次渲染。

    function useState(initialState) {
      // 首次渲染时指向 HooksDispatcherOnMount
      // 更新操作时指向 HooksDispatcherOnUpdate
      var dispatcher = ReactCurrentDispatcher.current
      return dispatcher.useState(initialState)
    }

    HooksDispatcherOnMount.useState 的代码前面已经介绍过,这里不再着重介绍。

    // HooksDispatcherOnMount 的代码前面已经介绍过
    var HooksDispatcherOnMount = {
      useState: function (initialState) {
        return mountState(initialState)
      }
    }

    我们重点看看 HooksDispatcherOnMount.useState 的逻辑。

    var HooksDispatcherOnUpdateInDEV = {
      useState: function (initialState) {
        return updateState()
      }
    }
    
    function updateState() {
      // 取出当前 hook
      workInProgressHook = nextWorkInProgressHook
      nextWorkInProgressHook = workInProgressHook.next
    
      var hook = nextWorkInProgressHook
      var queue = hook.queue
      var pendingQueue = queue.pending
    
      // 处理更新
      var first = pendingQueue.next
      var state = hook.memoizedState
      var update = first
    
      do {
        var action = update.action
        state = typeof action === 'function' ? action(state) : action
    
        update = update.next;
      } while (update !== null && update !== first)
    
    
      hook.memoizedState = state
    
      var dispatch = queue.dispatch
      return [hook.memoizedState, dispatch]
    }

    如果有看之前的 setState 的代码,这里的逻辑其实是一样的。将更新对象的 action 取出,如果是函数就执行,如果不是函数就直接对 state 进行替换操作。

    总结

    React 系列的文章终于写完了,这一篇文章应该是最简单的一篇,如果想抛开 React 源码,单独看 Hooks 实现可以看这篇文章:《React Hooks 原理》。Fiber 架构为了能够实现循环的方式更新,将所有涉及到数据的地方结构都改成了链表,这样的优势就是可以随时中断,为异步模式让路,Fiber 树就像一颗圣诞树,上面挂满了各种彩灯(alternateEffectListupdateQueueHooks)。

    推荐大家可以将这个系列从头到尾看一遍,相信会特别有收获的。

    image

    查看原文

    赞 12 收藏 8 评论 2

    Shenfq 发布了文章 · 10月20日

    Vue 3 的组合 API 如何请求数据?

    前言

    之前在学习 React Hooks 的过程中,看到一篇外网文章,通过 Hooks 来请求数据,并将这段逻辑抽象成一个新的 Hooks 给其他组件复用,我也在我的博客里翻译了一下:《在 React Hooks 中如何请求数据?》,感兴趣可以看看。虽然是去年的文章,在阅读之后一下子就掌握了 Hooks 的使用方式,而且数据请求是在业务代码中很常用的逻辑。

    Vue 3 已经发布一段时间了,其组合 API 多少有点 React Hooks 的影子在里面,今天我也打算通过这种方式来学习下组合 API。

    项目初始化

    为了快速启动一个 Vue 3 项目,我们直接使用当下最热门的工具 Vite 来初始化项目。整个过程一气呵成,行云流水。

    npm init vite-app vue3-app
    # 打开生成的项目文件夹
    cd vue3-app
    # 安装依赖
    npm install
    # 启动项目
    npm run dev

    我们打开 App.vue 将生成的代码先删掉。

    组合 API 的入口

    接下来我们将通过 Hacker News API 来获取一些热门文章,Hacker News API返回的数据结构如下:

    {
      "hits": [
        {
          "objectID": "24518295",
          "title": "Vue.js 3",
          "url": "https://github.com/vuejs/vue-next/releases/tag/v3.0.0",
        },
        {...},
        {...},
      ]
    }

    我们通过 ui > li 将新闻列表展示到界面上,新闻数据从 hits 遍历中获取。

    <template>
      <ul>
        <li
          v-for="item of hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>
    
    <script>
    import { reactive } from 'vue'
    
    export default {
      setup() {
        const state = reactive({
          hits: []
        })
        return state
      }
    }
    </script>

    在讲解数据请求前,我看先看看 setup() 方法,组合 API 需要通过 setup() 方法来启动,setup() 返回的数据可以在模板内使用,可以简单理解为 Vue 2 里面 data() 方法返回的数据,不同的是,返回的数据需要先经过 reactive() 方法进行包裹,将数据变成响应式。

    组合 API 中请求数据

    在 Vue 2 中,我们请求数据时,通常需要将发起请求的代码放到某个生命周期中(createdmounted)。在 setup() 方法内,我们可以使用 Vue 3 提供的生命周期钩子将请求放到特定生命周期内,关于生命周期钩子方法与之前生命周期的对比如下:

    生命周期

    可以看到,基本上就是在之前的方法名前加上了一个 on,且并没有提供 onCreated 的钩子,因为在 setup() 内执行就相当于在 created 阶段执行。下面我们在 mounted 阶段来请求数据:

    import { reactive, onMounted } from 'vue'
    
    export default {
      setup() {
        const state = reactive({
          hits: []
        })
        onMounted(async () => {
          const data = await fetch(
            'https://hn.algolia.com/api/v1/search?query=vue'
          ).then(rsp => rsp.json())
          state.hits = data.hits
        })
        return state
      }
    }

    最后效果如下:

    Demo

    监听数据变动

    Hacker News 的查询接口有一个 query 参数,前面的案例中,我们将这个参数固定了,现在我们通过响应式的数据来定义这个变量。

    <template>
      <input type="text" v-model="query" />
      <ul>
        <li
          v-for="item of hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>
    
    <script>
    import { reactive, onMounted } from 'vue'
    
    export default {
      setup() {
        const state = reactive({
          query: 'vue',
          hits: []
        })
        onMounted((async () => {
          const data = await fetch(
            `https://hn.algolia.com/api/v1/search?query=${state.query}`
          ).then(rsp => rsp.json())
          state.hits = data.hits
        })
        return state
      }
    }
    </script>

    现在我们在输入框修改,就能触发 state.query 同步更新,但是并不会触发 fetch 重新调用,所以我们需要通过 watchEffect() 来监听响应数据的变化。

    import { reactive, onMounted, watchEffect } from 'vue'
    
    export default {
      setup() {
        const state = reactive({
          query: 'vue',
          hits: []
        })
        const fetchData = async (query) => {
          const data = await fetch(
            `https://hn.algolia.com/api/v1/search?query=${query}`
          ).then(rsp => rsp.json())
          state.hits = data.hits
        }
        onMounted(() => {
          fetchData(state.query)
          watchEffect(() => {
            fetchData(state.query)
          })
        })
        return state
      }
    }

    由于 watchEffect() 首次调用的时候,其回调就会执行一次,造成初始化时会请求两次接口,所以我们需要把 onMounted 中的 fetchData 删掉。

    onMounted(() => {
    - fetchData(state.query)
      watchEffect(() => {
        fetchData(state.query)
      })
    })

    Demo

    watchEffect() 会监听传入函数内所有的响应式数据,一旦其中的某个数据发生变化,函数就会重新执行。如果要取消监听,可以调用 watchEffect() 的返回值,它的返回值为一个函数。下面举个例子:

    const stop = watchEffect(() => {
      if (state.query === 'vue3') {
        // 当 query 为 vue3 时,停止监听
        stop()
      }
      fetchData(state.query)
    })

    当我们在输入框输入 "vue3" 后,就不会再发起请求了。

    Demo

    返回事件方法

    现在有个问题就是 input 内的值每次修改都会触发一次请求,我们可以增加一个按钮,点击按钮后再触发 state.query 的更新。

    <template>
      <input type="text" v-model="input" />
      <button @click="setQuery">搜索</button>
      <ul>
        <li
          v-for="item of hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>
    
    <script>
    import { reactive, onMounted, watchEffect } from 'vue'
    
    export default {
      setup() {
        const state = reactive({
          input: 'vue',
          query: 'vue',
          hits: []
        })
        const fetchData = async (query) => {
          const data = await fetch(
            `https://hn.algolia.com/api/v1/search?query=${query}`
          ).then(rsp => rsp.json())
          state.hits = data.hits
        }
        onMounted(() => {
          watchEffect(() => {
            fetchData(state.query)
          })
        })
        
        const setQuery = () => {
          state.query = state.input
        }
        return { setQuery, state }
      }
    }
    </script>

    可以注意到 button 绑定的 click 事件的方法,也是通过 setup() 方法返回的,我们可以将 setup() 方法返回值理解为 Vue2 中 data() 方法和 methods 对象的合并。

    原先的返回值 state 变成了现在返回值的一个属性,所以我们在模板层取数据的时候,需要进行一些修改,在前面加上 state.

    <template>
      <input type="text" v-model="state.input" />
      <button @click="setQuery">搜索</button>
      <ul>
        <li
          v-for="item of state.hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>

    Demo

    返回数据修改

    作为强迫症患者,在模板层通过 state.xxx 的方式获取数据实在是难受,那我们是不是可以通过对象解构的方式将 state 的数据返回呢?

    <template>
      <input type="text" v-model="input" />
      <button class="search-btn" @click="setQuery">搜索</button>
      <ul class="results">
        <li
          v-for="item of hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>
    
    <script>
    import { reactive, onMounted, watchEffect } from 'vue'
    
    export default {
      setup(props, ctx) {
        const state = reactive({
          input: 'vue',
          query: 'vue',
          hits: []
        })
        // 省略部分代码...
        return {
          ...state,
          setQuery,
        }
      }
    }
    </script>

    答案是『不可以』。修改代码后,可以看到页面虽然发起了请求,但是页面并没有展示数据。

    state 在解构后,数据就变成了静态数据,不能再被跟踪,返回值类似于:

    export default {
      setup(props, ctx) {
        // 省略部分代码...
        return {
          input: 'vue',
          query: 'vue',
          hits: [],
          setQuery,
        }
      }
    }

    Demo

    为了跟踪基础类型的数据(即非对象数据),Vue3 也提出了解决方案:ref()

    import { ref } from 'vue'
    
    const count = ref(0)
    console.log(count.value) // 0
    
    count.value++
    console.log(count.value) // 1

    上面为 Vue 3 的官方案例,ref() 方法返回的是一个对象,无论是修改还是获取,都需要取返回对象的 value 属性。

    我们将 state 从响应对象改为一个普通对象,然后所有属性都使用 ref 包裹,这样修改后,后续的解构才做才能生效。这样的弊端就是,state 的每个属性在修改时,都必须取其 value 属性。但是在模板中不需要追加 .value,Vue 3 内部有对其进行处理。

    import { ref, onMounted, watchEffect } from 'vue'
    export default {
      setup() {
        const state = {
          input: ref('vue'),
          query: ref('vue'),
          hits: ref([])
        }
        const fetchData = async (query) => {
          const data = await fetch(
            `https://hn.algolia.com/api/v1/search?query=${query}`
          ).then(rsp => rsp.json())
          state.hits.value = data.hits
        }
        onMounted(() => {
          watchEffect(() => {
            fetchData(state.query.value)
          })
        })
        const setQuery = () => {
          state.query.value = state.input.value
        }
        return {
          ...state,
          setQuery,
        }
      }
    }

    有没有办法保持 state 为响应对象,同时又支持其对象解构的呢?当然是有的,Vue 3 也提供了解决方案:toRefs()toRefs() 方法可以将一个响应对象变为普通对象,并且给每个属性加上 ref()

    import { toRefs, reactive, onMounted, watchEffect } from 'vue'
    
    export default {
      setup() {
        const state = reactive({
          input: 'vue',
          query: 'vue',
          hits: []
        })
        const fetchData = async (query) => {
          const data = await fetch(
            `https://hn.algolia.com/api/v1/search?query=${query}`
          ).then(rsp => rsp.json())
          state.hits = data.hits
        }
        onMounted(() => {
          watchEffect(() => {
            fetchData(state.query)
          })
        })
        const setQuery = () => {
          state.query = state.input
        }
        return {
          ...toRefs(state),
          setQuery,
        }
      }
    }

    Loading 与 Error 状态

    通常,我们发起请求的时候,需要为请求添加 Loading 和 Error 状态,我们只需要在 state 中添加两个变量来控制这两种状态即可。

    export default {
      setup() {
        const state = reactive({
          input: 'vue',
          query: 'vue',
          hits: [],
          error: false,
          loading: false,
        })
        const fetchData = async (query) => {
          state.error = false
          state.loading = true
          try {
            const data = await fetch(
              `https://hn.algolia.com/api/v1/search?query=${query}`
            ).then(rsp => rsp.json())
            state.hits = data.hits
          } catch {
            state.error = true
          }
          state.loading = false
        }
        onMounted(() => {
          watchEffect(() => {
            fetchData(state.query)
          })
        })
        const setQuery = () => {
          state.query = state.input
        }
        return {
          ...toRefs(state),
          setQuery,
        }
      }
    }

    同时在模板使用这两个变量:

    <template>
      <input type="text" v-model="input" />
      <button @click="setQuery">搜索</button>
      <div v-if="loading">Loading ...</div>
      <div v-else-if="error">Something went wrong ...</div>
      <ul v-else>
        <li
          v-for="item of hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>

    展示 Loading、Error 状态:

    Demo

    将数据请求逻辑抽象

    用过 umi 的同学肯定知道 umi 提供了一个叫做 useRequest 的 Hooks,用于请求数据非常的方便,那么我们通过 Vue 的组合 API 也可以抽象出一个类似于 useRequest 的公共方法。

    接下来我们新建一个文件 useRequest.js

    import {
      toRefs,
      reactive,
    } from 'vue'
    
    export default (options) => {
      const { url } = options
      const state = reactive({
        data: {},
        error: false,
        loading: false,
      })
    
      const run = async () => {
        state.error = false
        state.loading = true
        try {
          const result = await fetch(url).then(res => res.json())
          state.data = result
        } catch(e) {
          state.error = true
        }
        state.loading = false
      }
    
      return {
        run,
        ...toRefs(state)
      }
    }

    然后在 App.vue 中引入:

    <template>
      <input type="text" v-model="query" />
      <button @click="search">搜索</button>
      <div v-if="loading">Loading ...</div>
      <div v-else-if="error">Something went wrong ...</div>
      <ul v-else>
        <li
          v-for="item of data.hits"
          :key="item.objectID"
        >
          <a :href="item.url">{{item.title}}</a>
        </li>
      </ul>
    </template>
    
    <script>
    import { ref, onMounted } from 'vue'
    import useRequest from './useRequest'
    
    export default {
      setup() {
        const query = ref('vue')
        const { data, loading, error, run } = useRequest({
          url: 'https://hn.algolia.com/api/v1/search'
        })
        onMounted(() => {
          run()
        })
        return {
          data,
          query,
          error,
          loading,
          search: run,
        }
      }
    }
    </script>

    当前的 useRequest 还有两个缺陷:

    1. 传入的 url 是固定的,query 修改后,不能及时的反应到 url 上;
    2. 不能自动请求,需要手动调用一下 run 方法;
    import {
      isRef,
      toRefs,
      reactive,
      onMounted,
    } from 'vue'
    
    export default (options) => {
      const { url, manual = false, params = {} } = options
    
      const state = reactive({
        data: {},
        error: false,
        loading: false,
      })
    
      const run = async () => {
        // 拼接查询参数
        let query = ''
        Object.keys(params).forEach(key => {
          const val = params[key]
          // 如果去 ref 对象,需要取 .value 属性
          const value = isRef(val) ? val.value : val
          query += `${key}=${value}&`
        })
        state.error = false
        state.loading = true
        try {
          const result = await fetch(`${url}?${query}`)
              .then(res => res.json())
          state.data = result
        } catch(e) {
          state.error = true
        }
        state.loading = false
      }
    
      onMounted(() => {
        // 第一次是否需要手动调用
        !manual && run()
      })
    
      return {
        run,
        ...toRefs(state)
      }
    }

    经过修改后,我们的逻辑就变得异常简单了。

    import useRequest from './useRequest'
    
    export default {
      setup() {
        const query = ref('vue')
        const { data, loading, error, run } = useRequest(
          {
            url: 'https://hn.algolia.com/api/v1/search',
            params: {
              query
            }
          }
        )
        return {
          data,
          query,
          error,
          loading,
          search: run,
        }
      }
    }

    当然,这个 useRequest 还有很多可以完善的地方,例如:不支持 http 方法修改、不支持节流防抖、不支持超时时间等等。最后,希望大家看完文章后能有所收获。

    image

    查看原文

    赞 10 收藏 7 评论 0

    Shenfq 发布了文章 · 10月12日

    React 架构的演变 - 更新机制

    前面的文章分析了 Concurrent 模式下异步更新的逻辑,以及 Fiber 架构是如何进行时间分片的,更新过程中的很多内容都省略了,评论区也收到了一些同学对更新过程的疑惑,今天的文章就来讲解下 React Fiber 架构的更新机制。

    Fiber 数据结构

    我们先回顾一下 Fiber 节点的数据结构(之前文章省略了一部分属性,所以和之前文章略有不同):

    function FiberNode (tag, key) {
      // 节点 key,主要用于了优化列表 diff
      this.key = key
      // 节点类型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ...
      this.tag = tag
    
        // 子节点
      this.child = null
      // 父节点
      this.return = null 
      // 兄弟节点
      this.sibling = null
      
      // 更新队列,用于暂存 setState 的值
      this.updateQueue = null
      // 新传入的 props
      this.pendingProps = pendingProps;
      // 之前的 props
      this.memoizedProps = null;
      // 之前的 state
      this.memoizedState = null;
    
      // 节点更新过期时间,用于时间分片
      // react 17 改为:lanes、childLanes
      this.expirationTime = NoLanes
      this.childExpirationTime = NoLanes
    
      // 对应到页面的真实 DOM 节点
      this.stateNode = null
      // Fiber 节点的副本,可以理解为备胎,主要用于提升更新的性能
      this.alternate = null
    
      // 副作用相关,用于标记节点是否需要更新
      // 以及更新的类型:替换成新节点、更新属性、更新文本、删除……
      this.effectTag = NoEffect
      // 指向下一个需要更新的节点
      this.nextEffect = null
      this.firstEffect = null
      this.lastEffect = null
    }

    缓存机制

    可以注意到 Fiber 节点有个 alternate 属性,该属性在节点初始化的时候默认为空(this.alternate = null)。这个节点的作用就是用来缓存之前的 Fiber 节点,更新的时候会判断 fiber.alternate 是否为空来确定当前是首次渲染还是更新。下面我们上代码:

    import React from 'react';
    import ReactDOM from 'react-dom';
    
    class App extends React.Component {
      state = { val: 0 }
      render() {
        return <div>val: { this.state.val }</div>
      }
    }
    
    ReactDOM.unstable_createRoot(
      document.getElementById('root')
    ).render(<App />)

    在调用 createRoot 的时候,会先生成一个FiberRootNode,在 FiberRootNode 下会有个 current 属性,current 指向 RootFiber 可以理解为一个空 Fiber。后续调用的 render 方法,就是将传入的组件挂载到 FiberRootNode.current(即 RootFiber) 的空 Fiber 节点上。

    // 实验版本对外暴露的 createRoot 需要加上 `unstable_` 前缀
    exports.unstable_createRoot = createRoot
    
    function createRoot(container) {
      return new ReactDOMRoot(container)
    }
    function ReactDOMRoot(container) {
      var root = new FiberRootNode()
      // createRootFiber => createFiber => return new FiberNode(tag);
      root.current = createRootFiber() // 挂载一个空的 fiber 节点
      this._internalRoot = root
    }
    ReactDOMRoot.prototype.render = function render(children) {
      var root = this._internalRoot
      var update = createUpdate()
      update.payload = { element: children }
      const rootFiber = root.current
      // update对象放到 rootFiber 的 updateQueue 中
      enqueueUpdate(rootFiber, update)
      // 开始更新流程
      scheduleUpdateOnFiber(rootFiber)
    }

    render 最后调用 scheduleUpdateOnFiber 进入更新任务,该方法之前有说明,最后会通过 scheduleCallback 走 MessageChannel 消息进入下个任务队列,最后调用 performConcurrentWorkOnRoot 方法。

    // scheduleUpdateOnFiber
    // => ensureRootIsScheduled
    // => scheduleCallback(performConcurrentWorkOnRoot)
    function performConcurrentWorkOnRoot(root) {
      renderRootConcurrent(root)
    }
    function renderRootConcurrent(root) {
      // workInProgressRoot 为空,则创建 workInProgress
      if (workInProgressRoot !== root) {
        createWorkInProgress()
      }
    }
    function createWorkInProgress() {
      workInProgressRoot = root
      var current = root.current
      var workInProgress = current.alternate;
      if (workInProgress === null) {
        // 第一次构建,需要创建副本
        workInProgress = createFiber(current.tag)
        workInProgress.alternate = current
        current.alternate = workInProgress
      } else {
        // 更新过程可以复用
        workInProgress.nextEffect = null
        workInProgress.firstEffect = null
        workInProgress.lastEffect = null
      }
    }

    开始更新时,如果 workInProgress 为空会指向一个新的空 Fiber 节点,表示正在进行工作的 Fiber 节点。

    workInProgress.alternate = current
    current.alternate = workInProgress

    fiber tree

    构造好 workInProgress 之后,就会开始在新的 RootFiber 下生成新的子 Fiber 节点了。

    function renderRootConcurrent(root) {
      // 构造 workInProgress...
      // workInProgress.alternate = current
        // current.alternate = workInProgress
    
      // 进入遍历 fiber 树的流程
      workLoopConcurrent()
    }
    
    function workLoopConcurrent() {
      while (workInProgress !== null && !shouldYield()) {
        performUnitOfWork()
      }
    }
    
    function performUnitOfWork() {
      var current = workInProgress.alternate
      // 返回当前 Fiber 的 child
      const next = beginWork(current, workInProgress)
      // 省略后续代码...
    }

    按照我们前面的案例, workLoopConcurrent 调用完成后,最后得到的 fiber 树如下:

    class App extends React.Component {
      state = { val: 0 }
      render() {
        return <div>val: { this.state.val }</div>
      }
    }

    fiber tree

    最后进入 Commit 阶段的时候,会切换 FiberRootNode 的 current 属性:

    function performConcurrentWorkOnRoot() {
      renderRootConcurrent() // 结束遍历流程,fiber tree 已经构造完毕
    
      var finishedWork = root.current.alternate
      root.finishedWork = finishedWork
      commitRoot(root)
    }
    function commitRoot() {
      var finishedWork = root.finishedWork
      root.finishedWork = null
      root.current = finishedWork // 切换到新的 fiber 树
    }

    fiber tree

    上面的流程为第一次渲染,通过 setState({ val: 1 }) 更新时,workInProgress 会切换到 root.current.alternate

    function createWorkInProgress() {
      workInProgressRoot = root
      var current = root.current
      var workInProgress = current.alternate;
      if (workInProgress === null) {
        // 第一次构建,需要创建副本
        workInProgress = createFiber(current.tag)
        workInProgress.alternate = current
        current.alternate = workInProgress
      } else {
        // 更新过程可以复用
        workInProgress.nextEffect = null
        workInProgress.firstEffect = null
        workInProgress.lastEffect = null
      }
    }

    fiber tree

    在后续的遍历过程中(workLoopConcurrent()),会在旧的 RootFiber 下构建一个新的 fiber tree,并且每个 fiber 节点的 alternate 都会指向 current fiber tree 下的节点。

    fiber tree

    这样 FiberRootNode 的 current 属性就会轮流在两棵 fiber tree 不停的切换,即达到了缓存的目的,也不会过分的占用内存。

    更新队列

    在 React 15 里,多次 setState 会被放到一个队列中,等待一次更新。

    // setState 方法挂载到原型链上
    ReactComponent.prototype.setState = function (partialState, callback) {
      // 调用 setState 后,会调用内部的 updater.enqueueSetState
      this.updater.enqueueSetState(this, partialState)
    };
    
    var ReactUpdateQueue = {
      enqueueSetState(component, partialState) {
        // 在组件的 _pendingStateQueue 上暂存新的 state
        if (!component._pendingStateQueue) {
          component._pendingStateQueue = []
        }
        // 将 setState 的值放入队列中
        var queue = component._pendingStateQueue
        queue.push(partialState)
        enqueueUpdate(component)
      }
    }

    同样在 Fiber 架构中,也会有一个队列用来存放 setState 的值。每个 Fiber 节点都有一个 updateQueue 属性,这个属性就是用来缓存 setState 值的,只是结构从 React 15 的数组变成了链表结构。

    无论是首次 Render 的 Mount 阶段,还是 setState 的 Update 阶段,内部都会调用 enqueueUpdate 方法。

    // --- Render 阶段 ---
    function initializeUpdateQueue(fiber) {
      var queue = {
        baseState: fiber.memoizedState,
        firstBaseUpdate: null,
        lastBaseUpdate: null,
        shared: {
          pending: null
        },
        effects: null
      }
      fiber.updateQueue = queue
    }
    ReactDOMRoot.prototype.render = function render(children) {
      var root = this._internalRoot
      var update = createUpdate()
      update.payload = { element: children }
      const rootFiber = root.current
      // 初始化 rootFiber 的 updateQueue
      initializeUpdateQueue(rootFiber)
      // update 对象放到 rootFiber 的 updateQueue 中
      enqueueUpdate(rootFiber, update)
      // 开始更新流程
      scheduleUpdateOnFiber(rootFiber)
    }
    
    // --- Update 阶段 ---
    Component.prototype.setState = function (partialState, callback) {
      this.updater.enqueueSetState(this, partialState)
    }
    var classComponentUpdater = {
      enqueueSetState: function (inst, payload) {
        // 获取实例对应的fiber
        var fiber = get(inst)
        var update = createUpdate()
        update.payload = payload
    
        // update 对象放到 rootFiber 的 updateQueue 中
        enqueueUpdate(fiber, update)
        scheduleUpdateOnFiber(fiber)
      }
    }

    enqueueUpdate 方法的主要作用就是将 setState 的值挂载到 Fiber 节点上。

    function enqueueUpdate(fiber, update) {
      var updateQueue = fiber.updateQueue;
    
      if (updateQueue === null) {
        // updateQueue 为空则跳过
        return;
      }
      var sharedQueue = updateQueue.shared;
      var pending = sharedQueue.pending;
    
      if (pending === null) {
        update.next = update;
      } else {
        update.next = pending.next;
        pending.next = update;
      }
    
      sharedQueue.pending = update;
    }

    多次 setState 会在 sharedQueue.pending 上形成一个单向循环链表,具体例子更形象的展示下这个链表结构。

    class App extends React.Component {
      state = { val: 0 }
      click () {
        for (let i = 0; i < 3; i++) {
          this.setState({ val: this.state.val + 1 })
        }
      }
      render() {
        return <div onClick={() => {
          this.click()
        }}>val: { this.state.val }</div>
      }
    }

    点击 div 之后,会连续进行三次 setState,每次 setState 都会更新 updateQueue。

    第一次 setState

    第二次 setState

    第三次 setState

    更新过程中,我们遍历下 updateQueue 链表,可以看到结果与预期的一致。

    let $pending = sharedQueue.pending
    // 遍历链表,在控制台输出 payload
    while($pending) {
      console.log('update.payload', $pending.payload)
      $pending = $pending.next
    }

    链表数据

    递归 Fiber 节点

    Fiber 架构下每个节点都会经历递(beginWork)归(completeWork)两个过程:

    • beginWork:生成新的 state,调用 render 创建子节点,连接当前节点与子节点;
    • completeWork:依据 EffectTag 收集 Effect,构造 Effect List;

    先回顾下这个流程:

    function workLoopConcurrent() {
      while (workInProgress !== null && !shouldYield()) {
        performUnitOfWork()
      }
    }
    
    function performUnitOfWork() {
      var current = workInProgress.alternate
      // 返回当前 Fiber 的 child
      const next = beginWork(current, workInProgress)
      if (next === null) { // child 不存在
        completeUnitOfWork()
      } else { // child 存在
        // 重置 workInProgress 为 child
        workInProgress = next
      }
    }
    function completeUnitOfWork() {
      // 向上回溯节点
      let completedWork = workInProgress
      while (completedWork !== null) {
        // 收集副作用,主要是用于标记节点是否需要操作 DOM
        var current = completedWork.alternate
        completeWork(current, completedWork)
    
        // 省略构造 Effect List 过程
    
        // 获取 Fiber.sibling
        let siblingFiber = workInProgress.sibling
        if (siblingFiber) {
          // sibling 存在,则跳出 complete 流程,继续 beginWork
          workInProgress = siblingFiber
          return
        }
    
        completedWork = completedWork.return
        workInProgress = completedWork
      }
    }

    递(beginWork)

    先看看 beginWork 进行了哪些操作:

    function beginWork(current, workInProgress) {
      if (current !== null) { // current 不为空,表示需要进行 update
        var oldProps = current.memoizedProps // 原先传入的 props
        var newProps = workInProgress.pendingProps // 更新过程中新的 props
        // 组件的 props 发生变化,或者 type 发生变化
        if (oldProps !== newProps || workInProgress.type !== current.type) {
          // 设置更新标志位为 true
          didReceiveUpdate = true
        }
      } else { // current 为空表示首次加载,需要进行 mount
        didReceiveUpdate = false
      }
      
      // tag 表示组件类型,不用类型的组件调用不同方法获取 child
      switch(workInProgress.tag) {
        // 函数组件
        case FunctionComponent:
          return updateFunctionComponent(current, workInProgress, newProps)
        // Class组件
        case ClassComponent:
          return updateClassComponent(current, workInProgress, newProps)
        // DOM 原生组件(div、span、button……)
        case HostComponent:
          return updateHostComponent(current, workInProgress)
        // DOM 文本组件
        case HostText:
          return updateHostText(current, workInProgress)
      }
    }

    首先判断 current(即:workInProgress.alternate) 是否存在,如果存在表示需要更新,不存在就是首次加载,didReceiveUpdate 变量设置为 false,didReceiveUpdate 变量用于标记是否需要调用 render 新建 fiber.child,如果为 false 就会重新构建fiber.child,否则复用之前的 fiber.child

    然后会依据 workInProgress.tag 调用不同的方法构建 fiber.child。关于 workInProgress.tag 的含义可以参考 react/packages/shared/ReactWorkTags.js,主要是用来区分每个节点各自的类型,下面是常用的几个:

    var FunctionComponent = 0; // 函数组件
    var ClassComponent = 1; // Class组件
    var HostComponent = 5; // 原生组件
    var HostText = 6; // 文本组件

    调用的方法不一一展开讲解,我们只看看 updateClassComponent

    // 更新 class 组件
    function updateClassComponent(current, workInProgress, newProps) {
      // 更新 state,省略了一万行代码,只保留了核心逻辑,看看就好
      var oldState = workInProgress.memoizedState
      var newState = oldState
    
      var queue = workInProgress.updateQueue
      var pendingQueue = queue.shared.pending
      var firstUpdate = pendingQueue
      var update = pendingQueue
    
      do {
        // 合并 state
        var partialState = update.payload
        newState = Object.assign({}, newState, partialState)
    
        // 链表遍历完毕
        update = update.next
        if (update === firstUpdate) {
            // 链表遍历完毕
          queue.shared.pending = null
          break
        }
      } while (true)
    
        workInProgress.memoizedState = newState // state 更新完毕
      
      // 检测 oldState 和 newState 是否一致,如果一致,跳过更新
      // 调用 componentWillUpdate 判断是否需要更新
      
    
      var instance = workInProgress.stateNode
      instance.props = newProps
      instance.state = newState
    
      // 调用 Component 实例的 render
      var nextChildren = instance.render()
      reconcileChildren(current, workInProgress, nextChildren)
      return workInProgress.child
    }

    首先遍历了之前提到的 updateQueue 更新 state,然后就是判断 state 是否更新,以此来推到组件是否需要更新(这部分代码省略了),最后调用的组件 render 方法生成子组件的虚拟 DOM。最后的 reconcileChildren 就是依据 render 的返回值来生成 fiber 节点并挂载到 workInProgress.child 上。

    // 构造子节点
    function reconcileChildren(current, workInProgress, nextChildren) {
      if (current === null) {
        workInProgress.child = mountChildFibers(
          workInProgress, null, nextChildren
        )
      } else {
        workInProgress.child = reconcileChildFibers(
          workInProgress, current.child, nextChildren
        )
      }
    }
    
    // 两个方法本质上一样,只是一个需要生成新的 fiber,一个复用之前的
    var reconcileChildFibers = ChildReconciler(true)
    var mountChildFibers = ChildReconciler(false)
    
    function ChildReconciler(shouldTrackSideEffects) {
      return function (returnFiber, currentChild, nextChildren) {
        // 不同类型进行不同的处理
        // 返回对象
        if (typeof newChild === 'object' && newChild !== null) {
                return placeSingleChild(
            reconcileSingleElement(
              returnFiber, currentChild, newChild
            )
          )
        }
        // 返回数组
        if (Array.isArray(newChild)) {
          // ...
        }
        // 返回字符串或数字,表明是文本节点
        if (
          typeof newChild === 'string' ||
          typeof newChild === 'number'
        ) {
          // ...
        }
        // 返回 null,直接删除节点
        return deleteRemainingChildren(returnFiber, currentChild)
      }
    }

    篇幅有限,看看 render 返回值为对象的情况(通常情况下,render 方法 return 的如果是 jsx 都会被转化为虚拟 DOM,而虚拟 DOM 必定是对象或数组):

    if (typeof newChild === 'object' && newChild !== null) {
      return placeSingleChild(
        // 构造 fiber,或者是复用 fiber
        reconcileSingleElement(
          returnFiber, currentChild, newChild
        )
      )
    }
    
    function placeSingleChild(newFiber) {
      // 更新操作,需要设置 effectTag
      if (shouldTrackSideEffects && newFiber.alternate === null) {
        newFiber.effectTag = Placement
      }
      return newFiber
    }

    归(completeWork)

    fiber.child 为空时,就会进入 completeWork 流程。而 completeWork 主要就是收集 beginWork 阶段设置的 effectTag,如果有设置 effectTag 就表明该节点发生了变更, effectTag 的主要类型如下(默认为 NoEffect ,表示节点无需进行操作,完整的定义可以参考 react/packages/shared/ReactSideEffectTags.js):

    export const NoEffect = /*                     */ 0b000000000000000;
    export const PerformedWork = /*                */ 0b000000000000001;
    
    // You can change the rest (and add more).
    export const Placement = /*                    */ 0b000000000000010;
    export const Update = /*                       */ 0b000000000000100;
    export const PlacementAndUpdate = /*           */ 0b000000000000110;
    export const Deletion = /*                     */ 0b000000000001000;
    export const ContentReset = /*                 */ 0b000000000010000;
    export const Callback = /*                     */ 0b000000000100000;
    export const DidCapture = /*                   */ 0b000000001000000;

    我们看看 completeWork 过程中,具体进行了哪些操作:

    function completeWork(current, workInProgress) {
      switch (workInProgress.tag) {
        // 这些组件没有反应到 DOM 的 effect,跳过处理
        case Fragment:
        case MemoComponent:
        case LazyComponent:
        case ContextConsumer:
        case FunctionComponent:
          return null
        // class 组件
        case ClassComponent: {
          // 处理 context
          var Component = workInProgress.type
          if (isContextProvider(Component)) {
            popContext(workInProgress)
          }
          return null
        }
        case HostComponent: {
          // 这里 Fiber 的 props 对应的就是 DOM 节点的 props
          // 例如: id、src、className ……
              var newProps = workInProgress.pendingProps // props
          if (
            current !== null &&
            workInProgress.stateNode != null
          ) { // current 不为空,表示是更新操作
            var type = workInProgress.type
            updateHostComponent(current, workInProgress, type, newProps)
          } else { // current 为空,表示需要渲染 DOM 节点
            // 实例化 DOM,挂载到 fiber.stateNode
            var instance = createInstance(type, newProps)
            appendAllChildren(instance, workInProgress, false, false);
            workInProgress.stateNode = instance
          }
          return null
        }
        case HostText: {
          var newText = workInProgress.pendingProps // props
          if (current && workInProgress.stateNode != null) {
            var oldText = current.memoizedProps
            // 更新文本节点
            updateHostText(current, workInProgress, oldText, newText)
          } else {
            // 实例文本节点
            workInProgress.stateNode = createTextInstance(newText)
          }
          return null
        }
      }
    }

    beginWork 一样,completeWork 过程中也会依据 workInProgress.tag 来进行不同的处理,其他类型的组件基本可以略过,只用关注下 HostComponentHostText,这两种类型的节点会反应到真实 DOM 中,所以会有所处理。

    updateHostComponent = function (
        current, workInProgress, type, newProps
    ) {
      var oldProps = current.memoizedProps
    
      if (oldProps === newProps) {
        // 新旧 props 无变化
        return
      }
    
      var instance = workInProgress.stateNode // DOM 实例
      // 对比新旧 props
        var updatePayload = diffProperties(instance, type, oldProps, newProps)
      // 将发生变化的属性放入 updateQueue
      // 注意这里的 updateQueue 不同于 Class 组件对应的 fiber.updateQueue
      workInProgress.updateQueue = updatePayload
    };

    updateHostComponent 方法最后会通过 diffProperties 方法获取一个更新队列,挂载到 fiber.updateQueue 上,这里的 updateQueue 不同于 Class 组件对应的 fiber.updateQueue,不是一个链表结构,而是一个数组结构,用于更新真实 DOM。

    下面举一个例子,修改 App 组件的 state 后,下面的 span 标签对应的 data-valstylechildren 都会相应的发生修改,同时,在控制台打印出 updatePayload 的结果。

    import React from 'react'
    
    class App extends React.Component {
      state = { val: 1 }
      clickBtn = () => {
        this.setState({ val: this.state.val + 1 })
      }
      render() {
        return (<div>
          <button onClick={this.clickBtn}>add</button>
          <span
            data-val={this.state.val}
            style={{ fontSize: this.state.val * 15 }}
          >
            { this.state.val }
          </span>
        </div>)
      }
    }
    
    export default App

    console

    副作用链表

    在最后的更新阶段,为了不用遍历所有的节点,在 completeWork 过程结束后,会构造一个 effectList 连接所有 effectTag 不为 NoEffect 的节点,在 commit 阶段能够更高效的遍历节点。

    function completeUnitOfWork() {
      let completedWork = workInProgress
      while (completedWork !== null) {
        // 调用 completeWork()...
    
        // 构造 Effect List 过程
        var returnFiber = completedWork.return
        if (returnFiber !== null) {
          if (returnFiber.firstEffect === null) {
            returnFiber.firstEffect = completedWork.firstEffect;
          }
          if (completedWork.lastEffect !== null) {
            if (returnFiber.lastEffect !== null) {
              returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
            }
            returnFiber.lastEffect = completedWork.lastEffect;
          }
    
          if (completedWork.effectTag > PerformedWork) {
            if (returnFiber.lastEffect !== null) {
              returnFiber.lastEffect.nextEffect = completedWork
            } else {
              returnFiber.firstEffect = completedWork
            }
            returnFiber.lastEffect = completedWork
          }
        }
    
        // 判断 completedWork.sibling 是否存在...
      }
    }

    上面的代码就是构造 effectList 的过程,光看代码还是比较难理解的,我们还是通过实际的代码来解释一下。

    import React from 'react'
    
    export default class App extends React.Component {
      state = { val: 0 }
      click = () => {
        this.setState({ val: this.state.val + 1 })
      }
      render() {
        const { val } = this.state
        const array = Array(2).fill()
        const rows = array.map(
          (_, row) => <tr key={row}>
            {array.map(
              (_, col) => <td key={col}>{val}</td>
            )}
          </tr>
        )
        return <table onClick={() => this.click()}>
          {rows}
        </table>
      }
    }

    App

    我们构造一个 2 * 2 的 Table,每次点击组件,td 的 children 都会发生修改,下面看看这个过程中的 effectList 是如何变化的。

    第一个 td 完成 completeWork 后,EffectList 结果如下:

    1

    第二个 td 完成 completeWork 后,EffectList 结果如下:

    2

    两个 td 结束了 completeWork 流程,会回溯到 tr 进行 completeWork ,tr 结束流程后 ,table 会直接复用 tr 的 firstEffect 和 lastEffect,EffectList 结果如下:

    3

    后面两个 td 结束 completeWork 流程后,EffectList 结果如下:

    4

    回溯到第二个 tr 进行 completeWork ,由于 table 已经存在 firstEffect 和 lastEffect,这里会直接修改 table 的 firstEffect 的 nextEffect,以及重新指定 lastEffect,EffectList 结果如下:

    5

    最后回溯到 App 组件时,就会直接复用 table 的 firstEffect 和 lastEffect,最后 的EffectList 结果如下:

    6

    提交更新

    这一阶段的主要作用就是遍历 effectList 里面的节点,将更新反应到真实 DOM 中,当然还涉及一些生命周期钩子的调用,我们这里只展示最简单的逻辑。

    function commitRoot(root) {
      var finishedWork = root.finishedWork
      var firstEffect = finishedWork
      var nextEffect = firstEffect
      // 遍历effectList
      while (nextEffect !== null) {
        const effectTag = nextEffect.effectTag
        // 根据 effectTag 进行不同的处理
        switch (effectTag) {
          // 插入 DOM 节点
          case Placement: {
            commitPlacement(nextEffect)
            nextEffect.effectTag &= ~Placement
            break
          }
          // 更新 DOM 节点
          case Update: {
            const current = nextEffect.alternate
            commitWork(current, nextEffect)
            break
          }
          // 删除 DOM 节点
          case Deletion: {
            commitDeletion(root, nextEffect)
            break
          }
        }
        nextEffect = nextEffect.nextEffect
      }
    }

    这里不再展开讲解每个 effect 下具体的操作,在遍历完 effectList 之后,就是将当前的 fiber 树进行切换。

    function commitRoot() {
      var finishedWork = root.finishedWork
    
      // 遍历 effectList ……
    
      root.finishedWork = null
      root.current = finishedWork // 切换到新的 fiber 树
    }

    总结

    到这里整个更新流程就结束了,可以看到 Fiber 架构下,所有数据结构都是链表形式,链表的遍历都是通过循环的方式来实现的,看代码的过程中经常会被突然出现的 return、break 扰乱思路,所以要完全理解这个流程还是很不容易的。

    最后,希望大家在阅读文章的过程中能有收获,下一篇文章会开始写 Hooks 相关的内容。
    image

    查看原文

    赞 3 收藏 3 评论 0

    Shenfq 发布了文章 · 9月30日

    React 架构的演变 - 从递归到循环

    这篇文章是 React 架构演变的第二篇,上一篇主要介绍了更新机制从同步修改为异步,这一篇重点介绍 Fiber 架构下通过循环遍历更新的过程,之所以要使用循环遍历的方式,是因为递归更新过程一旦开始就不能暂停,只能不断向下,直到递归结束或者出现异常。

    递归更新的实现

    React 15 的递归更新逻辑是先将需要更新的组件放入脏组件队列(这里在上篇文章已经介绍过,没看过的可以先看看《React 架构的演变 - 从同步到异步》),然后取出组件进行一次递归,不停向下寻找子节点来查找是否需要更新。

    下面使用一段代码来简单描述一下这个过程:

    updateComponent (prevElement, nextElement) {
      if (
        // 如果组件的 type 和 key 都没有发生变化,进行更新
        prevElement.type === nextElement.type &&
        prevElement.key === nextElement.key
      ) {
        // 文本节点更新
        if (prevElement.type === 'text') {
            if (prevElement.value !== nextElement.value) {
                this.replaceText(nextElement.value)
            }
        }
        // DOM 节点的更新
        else {
          // 先更新 DOM 属性
          this.updateProps(prevElement, nextElement)
          // 再更新 children
          this.updateChildren(prevElement, nextElement)
        }
      }
      // 如果组件的 type 和 key 发生变化,直接重新渲染组件
      else {
        // 触发 unmount 生命周期
        ReactReconciler.unmountComponent(prevElement)
        // 渲染新的组件
        this._instantiateReactComponent(nextElement)
      }
    },
    updateChildren (prevElement, nextElement) {
      var prevChildren = prevElement.children
      var nextChildren = nextElement.children
      // 省略通过 key 重新排序的 diff 过程
      if (prevChildren === null) { } // 渲染新的子节点
      if (nextChildren === null) { } // 清空所有子节点
      // 子节点对比
      prevChildren.forEach((prevChild, index) => {
        const nextChild = nextChildren[index]
        // 递归过程
        this.updateComponent(prevChild, nextChild)
      })
    }

    为了更清晰的看到这个过程,我们还是写一个简单的Demo,构造一个 3 * 3 的 Table 组件。

    Table

    // https://codesandbox.io/embed/react-sync-demo-nlijf
    class Col extends React.Component {
      render() {
        // 渲染之前暂停 8ms,给 render 制造一点点压力
        const start = performance.now()
        while (performance.now() - start < 8)
        return <td>{this.props.children}</td>
      }
    }
    
    export default class Demo extends React.Component {
      state = {
        val: 0
      }
      render() {
        const { val } = this.state
        const array = Array(3).fill()
        // 构造一个 3 * 3 表格
        const rows = array.map(
          (_, row) => <tr key={row}>
            {array.map(
              (_, col) => <Col key={col}>{val}</Col>
            )}
          </tr>
        )
        return (
          <table className="table">
            <tbody>{rows}</tbody>
          </table>
        )
      }
    }

    然后每秒对 Table 里面的值更新一次,让 val 每次 + 1,从 0 ~ 9 不停循环。

    Table Loop

    // https://codesandbox.io/embed/react-sync-demo-nlijf
    export default class Demo extends React.Component {
        tick = () => {
        setTimeout(() => {
          this.setState({ val: next < 10 ? next : 0 })
          this.tick()
        }, 1000)
      }
      componentDidMount() {
        this.tick()
      }
    }

    完整代码的线上地址: https://codesandbox.io/embed/react-sync-demo-nlijf。Demo 组件每次调用 setState,React 会先判断该组件的类型有没有发生修改,如果有就整个组件进行重新渲染,如果没有会更新 state,然后向下判断 table 组件,table 组件继续向下判断 tr 组件,tr 组件再向下判断 td 组件,最后发现 td 组件下的文本节点发生了修改,通过 DOM API 更新。

    Update

    通过 Performance 的函数调用堆栈也能清晰的看到这个过程,updateComponent 之后 的 updateChildren 会继续调用子组件的 updateComponent,直到递归完所有组件,表示更新完成。

    调用堆栈

    递归的缺点很明显,不能暂停更新,一旦开始必须从头到尾,这与 React 16 拆分时间片,给浏览器喘口气的理念明显不符,所以 React 必须要切换架构,将虚拟 DOM 从树形结构修改为链表结构。

    可循环的 Fiber

    这里说的链表结构就是 Fiber 了,链表结构最大的优势就是可以通过循环的方式来遍历,只要记住当前遍历的位置,即使中断后也能快速还原,重新开始遍历。

    我们先看看一个 Fiber 节点的数据结构:

    function FiberNode (tag, key) {
      // 节点 key,主要用于了优化列表 diff
      this.key = key
      // 节点类型;FunctionComponent: 0, ClassComponent: 1, HostRoot: 3 ...
      this.tag = tag
    
        // 子节点
      this.child = null
      // 父节点
      this.return = null 
      // 兄弟节点
      this.sibling = null
      
      // 更新队列,用于暂存 setState 的值
      this.updateQueue = null
      
      // 节点更新过期时间,用于时间分片
      // react 17 改为:lanes、childLanes
      this.expirationTime = NoLanes
      this.childExpirationTime = NoLanes
    
      // 对应到页面的真实 DOM 节点
      this.stateNode = null
      // Fiber 节点的副本,可以理解为备胎,主要用于提升更新的性能
      this.alternate = null
    }

    下面举个例子,我们这里有一段普通的 HTML 文本:

    <table class="table">
      <tr>
        <td>1</td>
        <td>1</td>
      </tr>
      <tr>
        <td>1</td>
      </tr>
    </table>

    在之前的 React 版本中,jsx 会转化为 createElement 方法,创建树形结构的虚拟 DOM。

    const VDOMRoot = {
      type: 'table',
      props: { className: 'table' },
      children: [
        {
          type: 'tr',
          props: { },
          children: [
            {
              type: 'td',
              props: { },
              children: [{type: 'text', value: '1'}]
            },
            {
              type: 'td',
              props: { },
              children: [{type: 'text', value: '1'}]
            }
          ]
        },
        {
          type: 'tr',
          props: { },
          children: [
            {
              type: 'td',
              props: { },
              children: [{type: 'text', value: '1'}]
            }
          ]
        }
      ]
    }

    Fiber 架构下,结构如下:

    // 有所简化,并非与 React 真实的 Fiber 结构一致
    const FiberRoot = {
      type: 'table',
      return: null,
      sibling: null,
      child: {
        type: 'tr',
        return: FiberNode, // table 的 FiberNode
        sibling: {
          type: 'tr',
          return: FiberNode, // table 的 FiberNode
          sibling: null,
          child: {
            type: 'td',
            return: FiberNode, // tr 的 FiberNode
            sibling: {
              type: 'td',
              return: FiberNode, // tr 的 FiberNode
              sibling: null,
              child: null,
              text: '1' // 子节点仅有文本节点
            },
            child: null,
            text: '1' // 子节点仅有文本节点
          }
        },
        child: {
          type: 'td',
          return: FiberNode, // tr 的 FiberNode
          sibling: null,
          child: null,
          text: '1' // 子节点仅有文本节点
        }
      }
    }

    Fiber

    循环更新的实现

    那么,在 setState 的时候,React 是如何进行一次 Fiber 的遍历的呢?

    let workInProgress = FiberRoot
    
    // 遍历 Fiber 节点,如果时间片时间用完就停止遍历
    function workLoopConcurrent() {
      while (
        workInProgress !== null &&
        !shouldYield() // 用于判断当前时间片是否到期
      ) {
        performUnitOfWork(workInProgress)
      }
    }
    
    function performUnitOfWork() {
      const next = beginWork(workInProgress) // 返回当前 Fiber 的 child
      if (next) { // child 存在
        // 重置 workInProgress 为 child
        workInProgress = next
      } else { // child 不存在
        // 向上回溯节点
        let completedWork = workInProgress
        while (completedWork !== null) {
          // 收集副作用,主要是用于标记节点是否需要操作 DOM
          completeWork(completedWork)
    
          // 获取 Fiber.sibling
          let siblingFiber = workInProgress.sibling
          if (siblingFiber) {
            // sibling 存在,则跳出 complete 流程,继续 beginWork
            workInProgress = siblingFiber
            return;
          }
    
          completedWork = completedWork.return
          workInProgress = completedWork
        }
      }
    }
    
    function beginWork(workInProgress) {
      // 调用 render 方法,创建子 Fiber,进行 diff
      // 操作完毕后,返回当前 Fiber 的 child
      return workInProgress.child
    }
    function completeWork(workInProgress) {
      // 收集节点副作用
    }

    Fiber 的遍历本质上就是一个循环,全局有一个 workInProgress 变量,用来存储当前正在 diff 的节点,先通过 beginWork 方法对当前节点然后进行 diff 操作(diff 之前会调用 render,重新计算 state、prop),并返回当前节点的第一个子节点( fiber.child)作为新的工作节点,直到不存在子节点。然后,对当前节点调用 completedWork 方法,存储 beginWork 过程中产生的副作用,如果当前节点存在兄弟节点( fiber.sibling),则将工作节点修改为兄弟节点,重新进入 beginWork 流程。直到 completedWork 重新返回到根节点,执行 commitRoot 将所有的副作用反应到真实 DOM 中。

    Fiber work loop

    在一次遍历过程中,每个节点都会经历 beginWorkcompleteWork ,直到返回到根节点,最后通过 commitRoot 将所有的更新提交,关于这部分的内容可以看:《React 技术揭秘》

    时间分片的秘密

    前面说过,Fiber 结构的遍历是支持中断恢复,为了观察这个过程,我们将之前的 3 * 3 的 Table 组件改成 Concurrent 模式,线上地址:https://codesandbox.io/embed/react-async-demo-h1lbz。由于每次调用 Col 组件的 render 部分需要耗时 8ms,会超出了一个时间片,所以每个 td 部分都会暂停一次。

    class Col extends React.Component {
      render() {
        // 渲染之前暂停 8ms,给 render 制造一点点压力
        const start = performance.now();
        while (performance.now() - start < 8);
        return <td>{this.props.children}</td>
      }
    }

    在这个 3 * 3 组件里,一共有 9 个 Col 组件,所以会有 9 次耗时任务,分散在 9 个时间片进行,通过 Performance 的调用栈可以看到具体情况:

    异步模式的调用栈

    在非 Concurrent 模式下,Fiber 节点的遍历是一次性进行的,并不会切分多个时间片,差别就是在遍历的时候调用了 workLoopSync 方法,该方法并不会判断时间片是否用完。

    // 遍历 Fiber 节点
    function workLoopSync() {
      while (workInProgress !== null) {
        performUnitOfWork(workInProgress)
      }
    }

    同步模式的调用栈

    通过上面的分析可以看出, shouldYield 方法决定了当前时间片是否已经用完,这也是决定 React 是同步渲染还是异步渲染的关键。如果去除任务优先级的概念,shouldYield 方法可以说很简单,就是判断了当前的时间,是否已经超过了预设的 deadline

    function getCurrentTime() {
      return performance.now()
    }
    function shouldYield() {
      // 获取当前时间
      var currentTime = getCurrentTime()
      return currentTime >= deadline
    }

    deadline 又是如何得的呢?可以回顾上一篇文章(《React 架构的演变 - 从同步到异步》)提到的 ChannelMessage,更新开始的时候会通过 requestHostCallback(即:port2.send)发送异步消息,在 performWorkUntilDeadline (即:port1.onmessage)中接收消息。performWorkUntilDeadline 每次接收到消息时,表示已经进入了下一个任务队列,这个时候就会更新 deadline

    异步调用栈

    var channel = new MessageChannel()
    var port = channel.port2
    channel.port1.onmessage = function performWorkUntilDeadline() {
      if (scheduledHostCallback !== null) {
        var currentTime = getCurrentTime()
        // 重置超时时间 
        deadline = currentTime + yieldInterval
        
        var hasTimeRemaining = true
        var hasMoreWork = scheduledHostCallback()
    
        if (!hasMoreWork) {
          // 已经没有任务了,修改状态 
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // 还有任务,放到下个任务队列执行,给浏览器喘息的机会 
          port.postMessage (null);
        }
      } else {
        isMessageLoopRunning = false;
      }
    }
    
    requestHostCallback = function (callback) {
      //callback 挂载到 scheduledHostCallback
      scheduledHostCallback = callback
      if (!isMessageLoopRunning) {
        isMessageLoopRunning = true
        // 推送消息,下个队列队列调用 callback
        port.postMessage (null)
      }
    }

    超时时间的设置就是在当前时间的基础上加上了一个 yieldInterval, 这个 yieldInterval 的值,默认是 5ms。

    deadline = currentTime + yieldInterval

    同时 React 也提供了修改 yieldInterval 的手段,通过手动指定 fps,来确定一帧的具体时间(单位:ms),fps 越高,一个时间分片的时间就越短,对设备的性能要求就越高。

    forceFrameRate = function (fps) {
      if (fps < 0 || fps > 125) {
        // 帧率仅支持 0~125
        return
      }
    
      if (fps > 0) {
        // 一般 60 fps 的设备
        // 一个时间分片的时间为 Math.floor(1000/60) = 16
        yieldInterval = Math.floor(1000 / fps)
      } else {
        // reset the framerate
        yieldInterval = 5
      }
    }

    总结

    下面我们将异步逻辑、循环更新、时间分片串联起来。先回顾一下之前的文章讲过,Concurrent 模式下,setState 后的调用顺序:

    Component.setState()
      => enqueueSetState()
      => scheduleUpdate()
      => scheduleCallback(performConcurrentWorkOnRoot)
      => requestHostCallback()
      => postMessage()
      => performWorkUntilDeadline()

    scheduleCallback 方法会将传入的回调(performConcurrentWorkOnRoot)组装成一个任务放入 taskQueue 中,然后调用 requestHostCallback 发送一个消息,进入异步任务。performWorkUntilDeadline 接收到异步消息,从 taskQueue 取出任务开始执行,这里的任务就是之前传入的 performConcurrentWorkOnRoot 方法,这个方法最后会调用workLoopConcurrentworkLoopConcurrent 前面已经介绍过了,这个不再重复)。如果 workLoopConcurrent 是由于超时中断的,hasMoreWork 返回为 true,通过 postMessage 发送消息,将操作延迟到下一个任务队列。

    流程图

    到这里整个流程已经结束,希望大家看完文章能有所收获,下一篇文章会介绍 Fiber 架构下 Hooks 的实现。

    image

    查看原文

    赞 7 收藏 4 评论 2

    Shenfq 赞了文章 · 9月29日

    探索 React 的内在 —— postMessage & Scheduler

    postMessage & Scheduler

    写在前面

    • 本文包含了一定量的源码讲解,其中笔者写入了一些内容来替代官方注释(就是写了差不多等于没写那种),若读者更青睐于原始的代码,

    可移步官方仓库,结合起来阅读。也正因为这个原因,横屏或 PC 的阅读体验也许会更佳(代码可能需要左右滑动)

    • 本文没有显式的涉及 React Fiber Reconciler 和 Algebraic Effects(代数效应)的内容,但其实它们是息息相关的,可以理解为本文的内容就是实现前两者的基石。

    有兴趣的读者可移步《Fiber & Algebraic Effects》做一些前置阅读。

    开始

    在去年 2019 年 9 月 27 日的 release 中,React 在 Scheduler 中开启了新的调度任务方案试验:

    • 旧方案:通过 requestAnimationFrame(以下统称 cAF,相关的 requestIdleCallback?则简称 rIC)使任务调度与帧对齐
    • 新方案:通过高频(短间隔)的调用 postMessage?来调度任务

    Emm x1... 突然有了好多问题
    那么本文就来探索一下,在这次“小小的” release 中都发生了什么

    契机

    通过对这次 release 的 commit-message?的查看,我们总结出以下几点:

    1. 由于 rAF 仰仗显示器的刷新频率,因此使用 rAF 需要看 vsync cycle(指硬件设备的频率)的脸色
    2. 那么为了在每帧执行尽可能多的任务,采用了?5ms?间隔的消息事件?来发起调度,也就是 postMessage 的方式
    3. 这个方案的主要风险是:更加频繁的调度任务会加剧主线程与其他浏览器任务的资源争夺
    4. 相较于 rAF 和 setTimeout,浏览器在后台标签下对消息事件进行了什么程度的节流还需要进一步确定,该试验是假设它与定时器有相同的优先级

    简单来说,就是放弃了由 rAF 和 rIC 两个 API 构成的帧对齐策略,转而人为的控制调度频率,提升任务处理速度,优化 React 运行时的性能

    postMessage


    那么,postMessage 又是什么呢?是指 iframe 通信机制中的 postMessage 吗?

    不对,也对

    Emm x2... 好吧,有点谜语了,那解谜吧

    不对

    说不对呢,是因为 postMessage 本身是使用的 MessageChannel?这个接口创建的对象发起的

    Channel Message API 的 MessageChannel?接口允许我们创建一个新的消息通道,并通过该通道的两个 MessagePort?进行通信

    这个通道同样适用于 Web Worker —— 所以,它挺有用的...
    我们看看它到底是怎样通信的:

    const ch = new MessageChannel()
    
    ch.port1.onmessage = function(msgEvent) {
      console.log('port1 got ' + msgEvent.data)
      ch.port1.postMessage('Ok, r.i.p Floyd')
    }
    
    ch.port2.onmessage = function(msgEvent) {
      console.log(msgEvent.data)
    }
    
    ch.port2.postMessage('port2!')
    
    // 输出:
    // port1 got port2!
    // Ok, r.i.p Floyd.

    很简单,没什么特别的...
    Emm x3...
    啊... 平常很少直接用它,它的兼容性怎么样呢?
    image.png


    唔!尽管是 10,但 IE 竟然也可以全绿!

    也对

    害,兼容性这么好,其实就是因为现代浏览器中 iframe 与父文档之间的通信,就是使用的这个消息通道,你甚至可以:

    // 假设 <iframe id="childFrame" data-original="XXX" />
    
    const ch = new MessageChannel()
    const childFrame = document.querySelector('#childFrame')
    
    ch.port2.onmessage = function(msgEvent) {
      console.log(msgEvent.data)
      console.log('There\'s no father exists ever')
    }
    
    childFrame.contentWindow.postMessage('Father I can\'t breathe!', '*', [ch.port2])
    
    // 输出:
    // Father I can't breathe
    // There's no father exists ever

    好了,我们已经知道这个 postMessage 是个什么东西了,那接着看看它是怎么运作的吧

    做事

    在谈到 postMessage 的运作方式之前,先提一下 Scheduler

    Scheduler

    Scheduler 是 React 团队开发的一个用于事务调度的包,内置于 React 项目中。其团队的愿景是孵化完成后,使这个包独立于 React,成为一个能有更广泛使用的工具
    我们接下来要探索的相关内容,都是在这个包的范畴之内

    找到 MessageChannel

    在 Scheduler 的源码中,通过搜索 postMessage?字眼,我们很容易的就将目光定位到了 SchedulerHostConfig.default.js 文件,我们截取部分内容:

    在完整源码中,有一个 if-else 分支来实现了两套不同的 API。对于非 DOM 或是没有 MessageChannel 的 JavaScript 环境(如 JavaScriptCore),以下内容是采用 setTimeout 实现的,有兴趣的同学可以去看一下,相当简单的一段 Hack,本文不作赘述,仅专注于 else 分支下的源码。
    以上也是为什么这个文件会叫 xxxConfig 的原因,它确实是带有配置性的逻辑的
    const performWorkUntilDeadline = () => {
        if (scheduledHostCallback !== null) {
          const currentTime = getCurrentTime();
          // Yield after `yieldInterval` ms, regardless of where we are in the vsync
          // cycle. This means there's always time remaining at the beginning of
          // the message event.
          deadline = currentTime + yieldInterval;
          const hasTimeRemaining = true;
          try {
            const hasMoreWork = scheduledHostCallback(
              hasTimeRemaining,
              currentTime,
            );
            if (!hasMoreWork) {
              isMessageLoopRunning = false;
              scheduledHostCallback = null;
            } else {
              // If there's more work, schedule the next message event at the end
              // of the preceding one.
              port.postMessage(null);
            }
          } catch (error) {
            // If a scheduler task throws, exit the current browser task so the
            // error can be observed.
            port.postMessage(null);
            throw error;
          }
        } else {
          isMessageLoopRunning = false;
        }
        // Yielding to the browser will give it a chance to paint, so we can
        // reset this.
        needsPaint = false;
      };
    
      const channel = new MessageChannel();
      const port = channel.port2;
      channel.port1.onmessage = performWorkUntilDeadline;
    
      requestHostCallback = function(callback) {
        scheduledHostCallback = callback;
        if (!isMessageLoopRunning) {
          isMessageLoopRunning = true;
          port.postMessage(null);
        }
      };

    这行代码的逻辑其实很简单:

    1. 定义一个名为 channel 的 MessageChannel,并定义一个 port 变量指向其 port2 端口
    2. 将预先定义好的 performWorkUntilDeadline 方法作为 channel 的 port1 端口的消息事件处理函数
    3. 在 requestHostCallback 中调用前面定义的 port 变量 —— 也就是 channel 的 port2 端口 —— 上的 postMessage 方法发送消息
    4. performWorkUntilDeadline 方法开始运作

    好了,我们现在就来剖析一下这一小段代码中的各个元素

    requestHostCallback(以下简称 rHC)

    还记得 rAF 和 rIC 吗?他们前任调度机制的核心 API,那么既然 rHC 和他们长这么像,一定就是现在值班那位咯
    确实,我们直接进入代码身体内部尝尝:

    requestHostCallback = function(callback) {
        // 将传入的 callback 赋值给 scheduledHostCallback
        // 类比 `requestAnimationFrame(() => { /* doSomething */ })` 这样的使用方法,
        // 我们可以推断 scheduledHostCallback 就是当前要执行的任务(scheduled嘛)
        scheduledHostCallback = callback;
      
          // isMessageLoopRunning 标志当前消息循环是否开启
        // 消息循环干嘛用的呢?就是不断的检查有没有新的消息——即新的任务——嘛
        if (!isMessageLoopRunning) {
          // 如果当前消息循环是关闭的,则 rHC 有权力打开它
          isMessageLoopRunning = true;
          // 打开以后,channel 的 port2 端口将受到消息,也就是开始 performWorkUntilDeadline 了
          port.postMessage(null);
        } // else 会发生什么?
      };

    好了,我们现在知道,rHC 的作用就是:

    • 准备好当前要执行的任务(scheduledHostCallback)
    • 开启消息循环调度
    • 调用 performWorkUntilDeadline

    performWorkUntilDeadline

    现在看来,rHC 是搞事的,performWorkUntilDealine 就是做事的咯
    确实,我们又直接进入代码身体内部尝尝:

    const performWorkUntilDeadline = () => {
          // [A]:先检查当前的 scheduledHostCallback 是否存在
        // 换句话说就是当前有没有事需要做
        if (scheduledHostCallback !== null) {
          const currentTime = getCurrentTime();
          // 啊,截止时间!
          // 看来就是截止到 yieldInterval 之后,是多少呢?
          // 按前文的内容,应该是 5ms 吧,我们之后再验证
          deadline = currentTime + yieldInterval;
          // 唔,新鲜的截止时间,换句话说就是还有多少时间呗
          // 有了显示的剩余时间定义,无论我们处于 vsync cycle 的什么节点,在收到消息(任务)的时候都有时间了
          const hasTimeRemaining = true; // timeRemaining 这个字眼让人想起了 rIC
          try {
            // 嗯,看来这个 scheduledHostCallback 中不简单,稍后研究它
            const hasMoreWork = scheduledHostCallback(
              hasTimeRemaining,
              currentTime,
            );
            if (!hasMoreWork) {
                // 如果完成了最后一个任务,就关闭消息循环,并清洗掉 scheduledHostCallback 的引用
              isMessageLoopRunning = false;
              scheduledHostCallback = null;
            } else {
              // [C]:如果还有任务要做,就用 port 继续向 channel 的 port2 端口发消息
              // 显然,这是一个类似于递归的操作
              // 那么,如果没有任务了,显然不会走到这儿,为什么还要判断 scheduledHostCallback 呢?往后看
              port.postMessage(null);
            }
          } catch (error) {
            // 如果当前的任务执行除了故障,则进入下一个任务,并抛出错误
            port.postMessage(null);
            throw error;
          }
        } else {
          // [B]:没事儿做了,那么就不用循环的检查消息了呗
          isMessageLoopRunning = false;
        }
        // Yielding to the browser will give it a chance to paint, so we can
        // reset this.
        needsPaint = false;
      };

    现在就明朗许多了,我们用一个示意图进行表示:
    how_postMessage_work.png
    两个虚线箭头表示引用关系,那么根据代码中的分析现在可以知道,所有的任务调度,都是由 port?—— 也就是 channel 的 port2 端口 —— 通过调用 postMessage 方法发起的,而这个任务是否要被执行,似乎与 yieldInterval 和 hasTimeRemaning 有关,来看看它们:

    • yieldInterval: 在完整源码中,有这两么两处:
    // 直接定义为 5ms,根本没商量的
    const yieldInterval = 5
    
    // 但是
    // 这个方法其实是 Scheduler 包提供给开发者的公共 API,
    // 允许开发者根据不同的设备刷新率设置调度间隔
    // 其实就是因地制宜的考虑
    
    forceFrameRate = function(fps) {
          // 最高到 125 fps
        // 我的(假装有)144hz 电竞屏有被冒犯到
        if (fps < 0 || fps > 125) {
          // Using console['error'] to evade Babel and ESLint
          console['error'](
            'forceFrameRate takes a positive int between 0 and 125, ' +
              'forcing framerates higher than 125 fps is not unsupported',
          );
          return;
        }
        if (fps > 0) {
          yieldInterval = Math.floor(1000 / fps);
        } else {
          // 显然,如果没传或者传了个负的,就重置为 5ms,提升了一些鲁棒性
          // reset the framerate
          yieldInterval = 5;
        }
      };
    • hasTimeRemaning:参考 rIC 通常的使用方式:
    function doWorks() {
      // todo
    }
    
    function doMoreWorks() {
         // todo more 
    }
    
    function todo() {
          requestIdleCallback(() => {
          // 做事嘛,最重要的就是还有没有时间
               if (e.timeRemaining()) {
            doMoreWorks()
          }
       })
       doWorks()
    }

    Emm x4... 上图中还有两处标红的疑问:

    • what happened?:?其实这个地方呢,就是为 performWorkUntilDeadline 提供新的 scheduledHostCallback。这样一来,performWorkUntilDeadline 就“一直有事做”,直到不再有任务通过 rHC 注册进来
    • But How?:?接下来,我们就来解答这个问题的答案,一切都要从 Scheduler 说起

    Scheduler

    啊哈,这次我们给 Scheduler 了一个更大的标题来表明它的主角身份 ??...
    我们这次直接从入口开始,一步一步地回归到 But How??这个问题上去

    又写在前面

    • 根据 Scheduler 的 README 文件可知,其当前的 API 尚非最终方案,因此其入口文件 Scheduler.js 所暴露出来的接口都带上了 unstable_?前缀,为使篇幅简单,以下对接口名称的描述都省去该前缀
    • 源码中还包含了一些 profiling 相关的逻辑,它们主要是用于辅助调试和审计,与运作方式没有太大的关系,因此下文会忽略这些内容,专注于核心逻辑的阐释

    scheduleCallback —— 把任务交给 Scheduler

    我们旅程的起点就从这个接口开始,它是开启 Scheduler 魔法的钥匙??~
    该接口用于将一个回调函数——也就是我们要执行的任务——按给定的优先级额外设置注册进 Scheduler 的任务队列中,并启动任务调度:

    function unstable_scheduleCallback(priorityLevel, callback, options) {
      var currentTime = getCurrentTime(); // [A]:getCurrentTime 是怎样获取当前时间的?
    
      var startTime; // 给定回调函数一个开始时间,并根据 options 中定义的 delay 来延迟
      // 给定回调函数一个定时器,并根据 options 中的 timeout 定义来确定是直接使用自定义的还是用 timeoutForPriorityLevel 方法来产出定时时间
      // [B]:那么 timeoutForPriorityLevel 是怎么做的呢?
      var timeout;
      if (typeof options === 'object' && options !== null) {
        var delay = options.delay;
        if (typeof delay === 'number' && delay > 0) {
          startTime = currentTime + delay;
        } else {
          startTime = currentTime;
        }
        timeout =
          typeof options.timeout === 'number'
            ? options.timeout
            : timeoutForPriorityLevel(priorityLevel); // [C] 这个 priorityLevel 哪来的?
      } else {
        timeout = timeoutForPriorityLevel(priorityLevel);
        startTime = currentTime;
      }
      
      // 定义一个过期时间,之后还会遇到它
      var expirationTime = startTime + timeout;
    
      // 啊,从这里我们可以看到,在 Scheduler 中一个 task 到底长什么样了
      var newTask = {
        id: taskIdCounter++, // Scheduler.js 中全局定义了一个 taskIdCounter 作为 taskId 的生产器
        callback,
        priorityLevel,
        startTime,
        expirationTime,
        sortIndex: -1,  // [D]:前面的都见过了,这个 sortIndex 是排序用的吗?
      };
      if (enableProfiling) {
        newTask.isQueued = false;
      }
    
      if (startTime > currentTime) {
        // 还记得 options 中的 delay 属性吗,这就给予了该任务开始时间大于当前时间的可能
        // 唔,前面定义 sortIndex 又出现了,在这种情况下被赋值为了 startTime,
        newTask.sortIndex = startTime;
        // [E]:这里出现了一个定时器队列(timerQueue)
        // 如果开始时间大于当前时间,就将它 push 进这个定时器队列
        // 显然,对于要将来执行的任务,势必得将它放在一个“待激活”的队列中
        push(timerQueue, newTask);
        // 这里的逻辑稍后讨论,先进入 else 分支
        if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
          // All tasks are delayed, and this is the task with the earliest delay.
          if (isHostTimeoutScheduled) {
            // Cancel an existing timeout.
            cancelHostTimeout();
          } else {
            isHostTimeoutScheduled = true;
          }
          // Schedule a timeout.
          requestHostTimeout(handleTimeout, startTime - currentTime);
        }
      } else {
        // expirationTime 作为了 sortIndex 的值,从逻辑上基本可以确认 sortIndex 就是用于排序了
        newTask.sortIndex = expirationTime;
        // [F]: 这里又出现了 push 方法,这次是将任务 push 进任务队列(taskQueue),看来定时器队列和任务队列是同构的咯?
        push(taskQueue, newTask);
        if (enableProfiling) {
          markTaskStart(newTask, currentTime);
          newTask.isQueued = true;
        }
        // 从逻辑上看,这里就是判断当前是否正处于流程,即 performWorkUntilDeadline 是否正处于一个递归的执行状态中中,如果不在的话,就开启这个调度
        // [G]:Emm x5... 那这个 flushWork 是干什么的呢?
        if (!isHostCallbackScheduled && !isPerformingWork) {
          isHostCallbackScheduled = true;
          requestHostCallback(flushWork);
        }
      }
    
      return newTask;
    }

    ok,我们现在来分解一下上述注释中标记了 [X]?的几个问题,使函数作用更加立体一点:

    • A:?getCurrentTime 是如何获取当前时间的呢?

      • 解:在之前提到的 schedulerHostConfig.default.js 文件中,根据 performance 对象及 performance.now 方法是否存在,区分了是用 Date.now 还是用 performance.now 来获取当前时间,原因是后者比前者更加精确切绝对,详情可参考这里
    • B C:?我们直接来看看 Scheduler.js 中 timeoutForPriorityLevel 方法的相关内容便知:
    // ...other code
    var maxSigned31BitInt = 1073741823;
    
    /**
     * 以下几个变量是全局定义的,相当于系统常量(环境变量)
     */
    // 立即执行
    // 显然,如果不定义 deley,根据 [B] 注释处紧接的逻辑,expirationTime 就等于 currentTime - 1 了
    var IMMEDIATE_PRIORITY_TIMEOUT = -1;
    // 再往后就一定会进入 else 分支,并 push 到任务队列立即进入 performWorkUntilDealine
    var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
    var NORMAL_PRIORITY_TIMEOUT = 5000;
    var LOW_PRIORITY_TIMEOUT = 10000;
    // 最低的优先级看起来是永远不会被 timeout 到的,稍后看看它会在什么时候执行
    var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;
    
    // ...other code
    
    // 可以看到,priorityLevel 显然也是被系统常量化了的
    function timeoutForPriorityLevel(priorityLevel) {
      switch (priorityLevel) {
        case ImmediatePriority:
          return IMMEDIATE_PRIORITY_TIMEOUT;
        case UserBlockingPriority:
          return USER_BLOCKING_PRIORITY_TIMEOUT;
        case IdlePriority:
          return IDLE_PRIORITY_TIMEOUT;
        case LowPriority:
          return LOW_PRIORITY_TIMEOUT;
        case NormalPriority:
        default:
          return NORMAL_PRIORITY_TIMEOUT;
      }
    }
    
    // ...other code

    其中 priorityLevel 定义在 schedulerPriorities.js 中,非常直观:

    export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;
    
    // 啊哈,将来可能用 symbols 来实现,
    // 那样的话,大小的对比是不是又得抽象一个规则出来呢?
    // TODO: Use symbols?
    export const NoPriority = 0;
    export const ImmediatePriority = 1;
    export const UserBlockingPriority = 2;
    export const NormalPriority = 3;
    export const LowPriority = 4;
    export const IdlePriority = 5;

    看来,任务执行的时机就是由 当前时间(currentTime)+延时(delay)+优先级定时(XXX_PRIORITY_TIMEOUT)?来决定,而定时时长的增量则由 shedulerPriorities.js 中的各个值来决定

    • C D E:?这三个点是非常相关的,因此直接放在一起

      • sortIndex:?即排序索引,根据前面的内容和 [B]?的阐释,我们可以知道,该属性的值要么是 startTime,要么是 expirationTime,显然都是越小越早嘛——因此,用这个值来排序,势必也就将任务的优先级排出来了
      • timerQueue 和 taskQueue:害,sortIndex 肯定是用于在这两个同构队列中排序了嘛。_看到这里,熟悉数据结构的同学应该已经猜到,这两个队列的数据结构可能就是处理优先级事务的标准方案——最小优先队列。_

    果然,我们溯源到 push 方法是在一个叫 schedulerMinHeap.js 的文件中,而最小优先队列就是基于最小堆(min-heap)来实现的。我们待会儿看看 push 到底对这个队列做了什么。

    • F:?flushWork!听这个名字就很通畅对不对这个名字已经很好的告诉了我们,它就是要将当前所有的任务一一处理掉!它是怎么做的呢?留个悬念,先跳出 scheduleCallback

    最小堆

    最小堆本质上是一棵完全二叉树,经排序后,其所有非终端节点的元素值都不大于其左节点和右节点,即如下:
    min-heap.png

    原理

    Sheduler 采用了数组对这个最小堆进行实现,现在我们简单的来解析一下它的工作原理

    PUSH

    我们向上面这个最小堆中 push 进一个值为 5 的元素,其工作流程如下所示:
    min-heap-push.png
    可以看到,在 push 的过程中,调用 siftUp 方法将值为 5 的元素排到了我们想要的位置,成了右边这棵树。相关代码如下:

    type Heap = Array<Node>;
    type Node = {|
      id: number,
      sortIndex: number,
    |};
    
    export function push(heap: Heap, node: Node): void {
      const index = heap.length;
      heap.push(node);
      siftUp(heap, node, index);
    }
    
    function siftUp(heap, node, i) {
      let index = i;
      while (true) {
        const parentIndex = (index - 1) >>> 1;
        const parent = heap[parentIndex];
        if (parent !== undefined && compare(parent, node) > 0) {
          // The parent is larger. Swap positions.
          heap[parentIndex] = node;
          heap[index] = parent;
          index = parentIndex;
        } else {
          // The parent is smaller. Exit.
          return;
        }
      }
    }
    
    function compare(a, b) {
      // Compare sort index first, then task id.
      const diff = a.sortIndex - b.sortIndex;
      return diff !== 0 ? diff : a.id - b.id;
    }

    可以看到,siftUp 中对于父节点位置的计算还使用了移位操作符>>>1?等价于除以 2 再去尾)进行优化,以提升计算效率

    POP

    那么,我们要从其中取出一个元素来用(在 Scheduler 中即调度一个任务出来执行),工作流程如下所示:
    min-heap-pop.png
    当我们取出第一个元素——即值最小,优先级最高——后,树失去了顶端,势必需要重新组织其枝叶结构,而 siftDown 方法就是用于重新梳理剩余的元素,使其仍然保持为一个最小堆,相关代码如下:

    export function pop(heap: Heap): Node | null {
      const first = heap[0];
      if (first !== undefined) {
        const last = heap.pop();
        if (last !== first) {
          heap[0] = last;
          siftDown(heap, last, 0);
        }
        return first;
      } else {
        return null;
      }
    }
    
    function siftDown(heap, node, i) {
      let index = i;
      const length = heap.length;
      while (index < length) {
        const leftIndex = (index + 1) * 2 - 1;
        const left = heap[leftIndex];
        const rightIndex = leftIndex + 1;
        const right = heap[rightIndex];
    
        // If the left or right node is smaller, swap with the smaller of those.
        if (left !== undefined && compare(left, node) < 0) {
          if (right !== undefined && compare(right, left) < 0) {
            heap[index] = right;
            heap[rightIndex] = node;
            index = rightIndex;
          } else {
            heap[index] = left;
            heap[leftIndex] = node;
            index = leftIndex;
          }
        } else if (right !== undefined && compare(right, node) < 0) {
          heap[index] = right;
          heap[rightIndex] = node;
          index = rightIndex;
        } else {
          // Neither child is smaller. Exit.
          return;
        }
      }
    }

    Emm x5... 和 PUSH 部分的代码合并一下,就是一个最小堆的标准实现了
    剩下地,SchedulerMinHeap.js 源码中还提供了一个 peek(看一下) 方法,用于查看顶端元素:

    export function peek(heap: Heap): Node | null {
      const first = heap[0];
      return first === undefined ? null : first;
    }

    其作用显然就是取第一个元素出来 peek peek 咯~ 我们马上就会遇到它

    flushWork

    现在,我们来看看 Scheduler 是如何将任务都 flush 掉的:

    function flushWork(hasTimeRemaining, initialTime) {
      if (enableProfiling) {
        markSchedulerUnsuspended(initialTime);
      }
    
      // [A]:为什么要重置这些状态呢?
      isHostCallbackScheduled = false;
      if (isHostTimeoutScheduled) {
        // We scheduled a timeout but it's no longer needed. Cancel it.
        isHostTimeoutScheduled = false;
        cancelHostTimeout();
      }
    
      // [B]:从逻辑上看,在任务本身没有抛出错误的情况下,flushWork 就是返回 workLoop 的结果,那么 workLoop 做了些什么呢?
      isPerformingWork = true;
      const previousPriorityLevel = currentPriorityLevel;
      try {
        if (enableProfiling) {
          try {
            return workLoop(hasTimeRemaining, initialTime);
          } catch (error) {
            if (currentTask !== null) {
              const currentTime = getCurrentTime();
              markTaskErrored(currentTask, currentTime);
              currentTask.isQueued = false;
            }
            throw error;
          }
        } else {
          // 特意留下了这条官方注释,它告诉我们在生产环境下,flushWork 不会去 catch workLoop 中抛出的错误的,
               // 因为在开发模式下或调试过程中,这种错误一般会造成白页并给予开发者一个提示,显然这个功能不能影响到用户
          // No catch in prod codepath.
          return workLoop(hasTimeRemaining, initialTime);
        }
      } finally {
        // 如果任务执行出错,则终结当前的调度工作
        currentTask = null;
        currentPriorityLevel = previousPriorityLevel;
        isPerformingWork = false;
        if (enableProfiling) {
          const currentTime = getCurrentTime();
          markSchedulerSuspended(currentTime);
        }
      }
    }

    现在来分析一下这段代码中的 ABC~

    • A:?为什么要重置这些状态呢?

    由于 rHC 并不一定立即执行传入的回调函数,所以?isHostCallbackScheduled 状态可能会维持一段时间;等到 flushWork 开始处理任务时,则需要释放该状态以支持其他的任务被 schedule 进来;isHostTimeoutScheduled 也是同样的道理,关于这是个什么 timeout,我们很快就会遇到

    • B:?workLoop,Emm x6... 快要到这段旅程的终点了。就像连载小说的填坑一样,这个方法将会解答很多问题

    workLoop

    顾名思义,该方法一定会包含一个用于处理任务的循环,那么这个循环里都发生了什么呢?

    function workLoop(hasTimeRemaining, initialTime) {
      let currentTime = initialTime;
      // [A]:这个方法是干嘛的?
      advanceTimers(currentTime);
      // 将任务队列最顶端的任务 peek 一下
      currentTask = peek(taskQueue);
      // 只要 currentTask 存在,这个 loop 就会继续下去
      while (
        currentTask !== null &&
        !(enableSchedulerDebugging && isSchedulerPaused)
      ) {
        if (
          currentTask.expirationTime > currentTime &&
          (!hasTimeRemaining || shouldYieldToHost())
        ) {
          // dealine 到了,但是当前任务尚未过期,因此让它在下次调度周期内再执行
          // [B]:shouldYieldToHost 是怎么做判断的呢?
          break;
        }
        const callback = currentTask.callback;
        if (callback !== null) {
          // callback 不为 null,则说明当前任务是可用的
          currentTask.callback = null;
          currentPriorityLevel = currentTask.priorityLevel;
          // 判断当前任务是否过期
          const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
          markTaskRun(currentTask, currentTime);
          // [C]:continuationCallback?这是什么意思?让任务继续执行?
          const continuationCallback = callback(didUserCallbackTimeout);
          currentTime = getCurrentTime();
          if (typeof continuationCallback === 'function') {
              // 看来,如果 continuationCallback 成立,则用它来取代当前的 callback
            currentTask.callback = continuationCallback;
            markTaskYield(currentTask, currentTime);
          } else {
            if (enableProfiling) {
              markTaskCompleted(currentTask, currentTime);
              currentTask.isQueued = false;
            }
            // 如果 continuationCallback 不成立,就会 pop 掉当前任务,
            // 逻辑上则应该是判定当前任务已经完成
            // Emm x7... 那么 schedule 进来的任务,实际上应该是要遵循这个规则的
            // [D]:我们待会儿再强调一下这个问题
            if (currentTask === peek(taskQueue)) {
              pop(taskQueue);
            }
          }
          // advanceTimers 又来了...
          advanceTimers(currentTime);
        } else {
          // 如果当前的任务已经不可用,则将它 pop 掉
          pop(taskQueue);
        }
        // 再次从 taskQueue 中 peek 一个任务出来
        // 注意,如果前面的 continuationCallback 成立,taskQueue 则不会发生 pop 行为,
        // 因此 peek 出的任务依然是当前的任务,只是 callback 已经是 continuationCallback 了
        currentTask = peek(taskQueue);
      }
      // Bingo!这不就是检查还有没有更多的任务吗?
      // 终于回归到 performWorkUntilDealine 中的 hasMoreWork 逻辑上了!
      if (currentTask !== null) {
        return true;
      } else {
        // [E]:诶,这儿好像不太单纯,干了点儿啥呢?
        const firstTimer = peek(timerQueue);
        if (firstTimer !== null) {
          requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
        }
        return false;
      }
    }

    我们终于解答了前面的 But How?问题
    现在,我们解析一下上述代码中的 ABC,看看这个循环是怎么运作起来的

    • A:上述代码两次出现了 advanceTimers,它究竟是用来干嘛的呢?上代码一看便知:
    function advanceTimers(currentTime) {
      // 其实下面的官方注解已经很明确了,就是把 timerQueue 中排队的任务根据需要转移到 taskQueue 中去
      // Check for tasks that are no longer delayed and add them to the queue.
      let timer = peek(timerQueue);
      while (timer !== null) {
        if (timer.callback === null) {
          // Timer was cancelled.
          pop(timerQueue);
        } else if (timer.startTime <= currentTime) {
          // Timer fired. Transfer to the task queue.
          pop(timerQueue);
          timer.sortIndex = timer.expirationTime;
          push(taskQueue, timer);
          if (enableProfiling) {
            markTaskStart(timer, currentTime);
            timer.isQueued = true;
          }
        } else {
          // Remaining timers are pending.
          return;
        }
        timer = peek(timerQueue);
      }
    }

    其实这段代码相当的简单,就是根据 startTimecurrentTime 来判断某个 timer 是否到了该执行的时间,然后将它转移到 taskQueue 中,大致可以总结为以下示意:
    advanceTimers.png
    因此,workLoop 中第一次调用它的作用就是将当前需要执行的任务重新梳理一下;
    那么第二次调用则是由于 while 语句中的任务执行完后,已经消耗掉一定时间,再次进入 while 的时候当然也需要重新梳理 taskQueue 了

    • B:shouldYieldToHosthasTimeRemaning 一起判定了是否还有时间来执行任务,如果没有的话,break 出 while 循环,由此 保持了一个以 5ms 为周期的循环调度?——啊,又解决一个疑问;其中 shouldYieldToHost 的源码有点儿料的,可以看看:
    if (
        enableIsInputPending &&
        navigator !== undefined &&
        navigator.scheduling !== undefined &&
        navigator.scheduling.isInputPending !== undefined
      ) {
        const scheduling = navigator.scheduling;
        shouldYieldToHost = function() {
          const currentTime = getCurrentTime();
          if (currentTime >= deadline) {
            // There's no time left. We may want to yield control of the main
            // thread, so the browser can perform high priority tasks. The main ones
            // are painting and user input. If there's a pending paint or a pending
            // input, then we should yield. But if there's neither, then we can
            // yield less often while remaining responsive. We'll eventually yield
            // regardless, since there could be a pending paint that wasn't
            // accompanied by a call to `requestPaint`, or other main thread tasks
            // like network events.
            // 译:没空了。我们可能需要将主线程的控制权暂时交出去,因此浏览器能够执行高优先级的任务。
            // 所谓的高优先级的任务主要是”绘制“及”用户输入”。如果当前有执行中的绘制或者输入,那么
            // 我们就应该让出资源来让它们优先的执行;如果没有,我们则可以让出更少的资源来保持响应。
            // 但是,毕竟存在非 `requestPaint` 发起的绘制状态更新,及其他的主线程任务——如网络请求等事件,
            // 我们最终也会在某个临界点一定地让出资源来
            if (needsPaint || scheduling.isInputPending()) {
              // There is either a pending paint or a pending input.
              return true;
            }
            // There's no pending input. Only yield if we've reached the max
            // yield interval.
            return currentTime >= maxYieldInterval;
          } else {
            // There's still time left in the frame.
            return false;
          }
        };
    
        requestPaint = function() {
          needsPaint = true;
        };
      } else {
        // `isInputPending` is not available. Since we have no way of knowing if
        // there's pending input, always yield at the end of the frame.
        shouldYieldToHost = function() {
          return getCurrentTime() >= deadline;
        };
    
        // Since we yield every frame regardless, `requestPaint` has no effect.
        requestPaint = function() {};
      }

    可以看到,对于支持 navigator.scheduling?属性的环境,React 有更进一步的考虑,也就是 浏览器绘制?和 用户输入?要优先进行,这其实就是 React 设计理念中的 Scheduling 部分所阐释的内涵
    当然了,由于这个属性并非普遍支持,因此也 else 分支里的定义则是单纯的判断是否超过了 deadline
    考虑到 API 的健壮性,requestPaint?也根据情况有了不同的定义

    • C:?我们仔细看看 continuationCallback 的赋值—— continuationCallback = callback(didUserCallbackTimeout)?,它将任务是否已经过期的状态传给了任务本身,如果该任务支持根据过期状态有不同的行为——例如在过期状态下,将当前的执行结果缓存起来,等到下次调度未过期的时候再复用缓存的结果继续执行后面的逻辑,那么则返回新的处理方式并赋值到 continuationCallback 上。这就是 React 中的 Fiber Reconciler 实现联系最紧密的地方了;而 callback 本身若并没有对过期状态进行处理,则返回的东西从逻辑上来讲,需要控制为非函数类型的值,也就是使得 typeof continuationCallback === 'function'?判断为假。也正因为 callback 不一定会对过期状态有特别待遇,所以它的执行时间可能会大大超出预料,就更需要在之后再执行一次 advanceTimers 了。
    • D:?前面说到了,我们传入的 callback 一定要遵循与 continuationCallback 相关逻辑一致的规则。由于 Scheduler 现在尚未正式的独立于 React 做推广,所以也没有相关文档来显式的做讲解,因此我们在直接使用 Scheduler 的时候一定要注意这点
    • E:?其实这里就是将 timer 中剩下的任务再进行一次梳理,我们看看 requestHostTimeouthandleTimeout 都做了什么就知道了:

    现在,看 requestHostTimeout 个名字就知道他一定来自于 SchedulerHostConfig.default.js 这个文件??:

    // 很简单,就是在下一轮浏览器 eventloop 的定时器阶段执行回调,如果传入了具体时间则另说  
    requestHostTimeout = function(callback, ms) {
        taskTimeoutID = setTimeout(() => {
          callback(getCurrentTime());
        }, ms);
      };
    
    // 相关的 cancel 方法则是直接 clear 掉定时器并重置 taskTimoutID
    cancelHostTimeout = function() {
      clearTimeout(taskTimeoutID);
      taskTimeoutID = -1;
    };

    再看 handleTimeout,它的定义就在 Scheduler.js 中:

    function handleTimeout(currentTime) {
      isHostTimeoutScheduled = false;
      // 这里再次重新梳理了 task
      advanceTimers(currentTime);
    
      // 如果这时候 isHostCallbackScheduled 再次被设为 true
      // 说明有新的任务注册了进来
      // 从逻辑上来看,这些任务将再次被滞后
      if (!isHostCallbackScheduled) {
        // flush 新进入 taskQueue 的任务
        if (peek(taskQueue) !== null) {
          // 如果本方法中的 advanceTimer 有对 taskQueue push 进任务
          // 则直接开始 flush 它们
          isHostCallbackScheduled = true;
          requestHostCallback(flushWork);
        } else {
          // 如果 taskQueue 仍然为空,就开始递归的调用该方法
          // 直到清理掉 timerQueue 中所有的任务
          // (我想,对于交互频繁的应用,这个递归应该不太会有停止的机会)
          const firstTimer = peek(timerQueue);
          if (firstTimer !== null) {
            // startTime - currentTime,不就是 XXX_PRIORITY_TIMEOUT 的值嘛!
            requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
          }
        }
      }
    }

    可以概括为是 workLoop 的善后工作...
    现在,我们可以总结出一个大致的 workLoop 示意图了:
    workLoop.png
    Emm x7... 拉得挺长,其实也没多少内容
    至此,Scheduler 的核心运作方式就剖开了
    而源码中还有一些其他的方法,有些是用于 cancel 掉当前的调度循环(即递归过程),有些是提供给开发者使用的工具接口,有兴趣的同学可以戳这里进行进一步地了解

    总结

    由于贴入了大量的源码,因此本文篇幅也比较长,但其实总得来说就是解释了两个问题

    postMessage 如何运作?

    主要就是通过 performWorkUntilDeadline 这个方法来实现一个递归的消息?发送-接收-处理?流程,来实现任务的处理

    任务如何被处理?

    一切都围绕着两个最小优先队列进行:

    • taskQueue
    • timerQueue

    任务被按照一定的优先级规则进行预设,而这些预设的主要目的就是确认执行时机(timeoutForPriorityLevel)。
    没当开始处理一系列任务的时候(flushWork),会产生一个 while 循环(workLoop)来不断地对队列中的内容进行处理,这期间还会逐步的将被递延任务从 timerQueue 中梳理(advanceTimers)到 taskQueue 中,使得任务能按预设的优先级有序的执行。甚至,对于更高阶的任务回调实现,还可以将任务“分段进行”(continuationCallback)。
    而穿插在这整个过程中的一个原则是所有的任务都尽量不占用与用户感知最密切的浏览器任务(needsPainiting & isInputPending),当然,这一点能做得多极致也与浏览器的实现(navigator.scheduling)有关

    总览

    现在,我们将前面的示意图都整合起来,并加上两个队列的示意,可以得到一张大大的运作原理总览:
    scheduler.png
    啊,真的很大... 其实主要是空白多...
    总的来说,相比旧的实现(rIC 和 rAF),postMessage 的方式更加独立,对设备本身的运作流程有了更少的依赖,这不仅提升了任务处理的效率,也减少了因不可控因素导致应用出错的风险,是相当不错的尝试。尽管它没有显式地对各个 React 应用产生影响,甚至也无须开发者对它有深刻的理解,但也许我们知道了它的运作原理,也就增添了代码优化及排错查误的思路。
    然而,前面也提到了,这个实现的一些东西目前也正处于试验阶段,因此我们如果要直接使用 Scheduler 来实现一些东西,也是需要慎重考虑的。
    Emm x8... 是不是可以用它来做一些弹幕应用的渲染管理呢——毕竟飞机礼物的通知比纯文字的吹水优先级要高吧,贵的礼物要比……哎,有点讨打了,拜托忘记破折号后的内容。
    有兴趣的同学可以实践一下,也是帮助 Scheduler 的试验了~


    最后,如果有什么本文理解有误的地方,还望指出??

    查看原文

    赞 18 收藏 3 评论 2

    认证与成就

    • 获得 808 次点赞
    • 获得 10 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 10 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    (??? )
    暂时没有

    注册于 2016-05-11
    个人主页被 4.4k 人浏览

    bt365体育投注