写点什么

怎样徒手写一个 React

  • 2022-12-19
    浙江
  • 本文字数:7698 字

    阅读完需:约 25 分钟

本文主要通过手写一个简单的 React,旨在了解 Facebook 团队使用两年多时间重构的 Fiber 架构到底做了些什么?从而对 React 基本原理有一个直观的认识。尬不多说,搭建开始~

青铜 – React、JSX、DOM elements 如何工作的?

本文主要基本 React 16.8 版本进行实现。


下面先实现一个最简单的页面渲染,快速了解 JSX、React、DOM 元素的联系。


import React from "react";import ReactDOM from "react-dom";
const element = ( <div id="foo"> <a>bar</a> <b /> </div>);const container = document.getElementById("root");ReactDOM.render(element, container);
复制代码


实现一个最简单的 React 应用,只需要上面的三行代码就够了 👆,下面我们也将拆分三步进行分析,


  1. 创建 React 元素(React Element)

  2. 获取根节点 root

  3. 将 React 元素渲染到页面上

1. JSX 是如何被解析的 - Babel

const element = (  <div id="foo">    <a>bar</a>    <b />  </div>);
复制代码


用 JSX 创建了一个 react 元素,它不是有效的 JS,其实它是被 babel 解析为如下代码:


"use strict";const element = /*#__PURE__*/ React.createElement(  "div",  {    id: "foo",  },  /*#__PURE__*/ React.createElement("a", null, "bar"),  /*#__PURE__*/ React.createElement("b", null));
复制代码


可以看到 Babel 会将 JSX 转换成 React.createElement() 方法,其中 createElement() 方法接收三个参数,分别是元素类型 type、元素属性 props、和子元素 children,后面我们会实现这个方法。

2. React 虚拟 DOM 对象的设计

React 的核心思想是在内存中维护一颗虚拟 DOM 树,当数据变化时更新虚拟 DOM,得到一颗新树,然后 Diff 新老虚拟 DOM 树,找到有变化的部分,得到一个 Change(Patch),将这个 Patch 加入队列,最终批量更新这些 Patch 到 DOM 中。


首先来看下基本的虚拟 DOM 结构:


const element = {  type: "div",  props: {    id: "foo",    children: [      {        type: "a",        props: {          children: ["bar"],        },      },      {        type: "b",        props: {          children: [],        },      },    ],  },};
复制代码


可以看出 React.createElement() 方法其实就是返回了一个虚拟 DOM 对象。下面我们来实现 createElement() 这个方法,


function createElement(type, props, ...children) {  return {    type,    props: {      ...props,      children: children.map((child) =>        // 这里我们区分下基本类型和引用类型,用 createTextElement 来创建文本节点类型        typeof child === "object" ? child : createTextElement(child)      ),    },  };}
function createTextElement(text) { return { type: "TEXT_ELEMENT", props: { nodeValue: text, children: [], }, };}
复制代码


可以看到通过 Babel 编译后的 element 对象,其实是对 React.createElement()的递归调用所返回的数据结构 - 一个嵌套的虚拟 DOM 结构。

3. 实现 render() 方法

有了虚拟 DOM 结构,接下来需要根据它来生成真实节点并渲染到页面上,也就是 render() 方法的工作。基本分为以下四步:


  • 创建不同类型节点

  • 添加属性 props

  • 遍历 children,递归调用 render

  • 将生成的节点 append 到 root 根节点上


function render(element, container) {  // 1. 创建不同类型的DOM节点  const dom =    element.type == "TEXT_ELEMENT"      ? document.createTextNode("")      : document.createElement(element.type);
// 2.为 DOM 节点添加属性props (排查children属性) const isProperty = (key) => key !== "children"; Object.keys(element.props) .filter(isProperty) .forEach((name) => { dom[name] = element.props[name]; });
// 3. 遍历children,递归调用 render element.props.children.forEach((child) => render(child, dom));
// 4. 将 DOM 节点添加至 root 根节点 container.appendChild(dom);}
复制代码


此时还有一个问题,在使用 JSX 语法时,Babel 默认寻找 React.createElement 方法进行编译(这也是一般项目中 app.tsx 入口文件中虽然没有显式用到 react,但必须 import react 的原因),那么如何告诉 Babel 使用自己定义的 createElement 方法来编译呢?JSX 支持使用以下注释的方式来告诉 Babel,使用指定的方法来进行编译:


参考 前端进阶面试题详细解答


const MyReact = {  createElement,  render,};/** @jsx MyReact.createElement */const element = (  <div id="foo">    <a>bar</a>    <b />  </div>);function createElement() {  //...}
复制代码


这样我们就通过实现一个简单的页面渲染,快速了解了 JSX、React、DOM 元素的联系。至此,我们就有了一个简单的 React,实现了将 JSX 渲染到页面上了。

白银 – Fiber 架构

但是第一部分的这种递归调用的方式还是存在问题的。一旦我们开始渲染 render,直到我们渲染完整个完整的 DOM 树之前,我们是没法中止的,这会造成什么问题呢?


在浏览器中,页面是一帧一帧绘制出来的,一般情况下设备的屏幕刷新率为 1s 60 次,每帧绘制大概需要 16ms。在这一帧中浏览器要完成很多事情!如果在某个阶段执行任务特别长,时间已经明显超过了 16ms,那么就会阻塞页面的渲染,从而出现卡顿现象,也就是常说的掉帧!


React 16 之前就是采用这种递归调用的遍历方式,执行栈会越来越深,而且不能中断,中断后就不能恢复了。如果递归花了 100ms,则这 100ms 浏览器是无法响应的,代码执行时间越长卡顿越明显。


为了解决以上的痛点问题,React 希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 架构。React Fiber 架构是怎么做的呢?


  1. 通过将工作任务拆分成一个个小的工作单元 units 分别来执行 -> Fiber

  2. 让 React 渲染的过程可以被中断,可以将控制权交回浏览器,让浏览器及时地响应用户的交互 -> 异步可中断

window.requestIdleCallback()

我们先来解决第二个问题,如何让 React 渲染的过程可以被中断,可以将控制权交回浏览器,让浏览器及时地响应用户的交互,等浏览器空闲后再恢复渲染?


其实浏览器提供了相关实现的 API:requestIdleCallback(callback, {timeout: 1000}), 从字面意思可以理解成 “让浏览器在‘有空’的时候就执行我们的回调”


我们来简单看一个关于 requestIdleCallback 例子 ~


// 定义一个任务队列let taskQueue = [  () => {    console.log("task1 start");    console.log("task1 end");  },  () => {    console.log("task2 start");    console.log("task2 end");  },  () => {    console.log("task3 start");    console.log("task3 end");  },];
// 执行工作单元。每次取出队列中的第一个任务,并执行let performUnitOfWork = () => { taskQueue.shift()();};
/** * callback 接收默认参数 deadline,timeRamining 属性表示当前帧还剩多少时间 */let workloop = (deadline) => { console.log(`此帧的剩余时间 --> ${deadline.timeRemaining()} ms`); // 此帧剩余时间大于0 while (deadline.timeRemaining() > 0 && taskQueue.length > 0) { performUnitOfWork(); console.log(`还剩时间: ${deadline.timeRemaining()} ms`); } // 否则应该放弃执行任务控制权,把执行权交还给浏览器。 if (taskQueue.length > 0) { // 申请下一个时间片 requestIdleCallback(workloop); }};
// 注册任务,告诉浏览器如果每一帧存在空闲时间,就可以执行注册的这个任务requestIdleCallback(workloop);
复制代码


可以看到在当前帧还剩 15ms 时,浏览器依次完成了完成了三个任务,当前帧时间还比较充裕。下面增加一个 sleep 时间 20ms,也就是说每个任务都超过一帧的时间 16ms, 也就是执行完每一个任务后当前帧是没有时间了的,需要把控制权交给浏览器


// 每个任务都超过了16ms的时间let taskQueue = [  () => {    console.log("task1 start");    sleep(20);    console.log("task1 end");  },  () => {    console.log("task2 start");    sleep(20);    console.log("task2 end");  },  () => {    console.log("task3 start");    sleep(20);    console.log("task3 end");  },];
let sleep = (delay) => { for (let start = Date.now(); Date.now() - start <= delay; ) {}};// 其他逻辑不变let performUnitOfWork = () => { taskQueue.shift()();};// ...
复制代码


可以看到浏览器每次执行一个任务,由于剩余时间为 0ms,都会把控制权交给浏览器,等待下一帧有时间时再次执行 workloop 方法。


但目前 requestIdleCallback 只有部分浏览器支持,所以 React 自己实现了一个 requestIdleCallback。它模拟将回调延迟到‘绘制操作’之后执行。下面是它的主要实现过程,并且后面我们也会延续这个思想进行 Fiber 的实现。


// 1. 定义下一次执行的工作单元let nextUnitOfWork = null// 2. 定义回调函数function workLoop(deadline) {  // 标示位  let shouldYield = false;
while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork( nextUnitOfWork ) shouldYield = deadline.timeRemaining() < 1 }
// 提前申请下一个时间片 requestIdleCallback(workLoop)}
// 3. 向浏览器注册回调事件,申请时间片requestIdleCallback(workLoop)// 4. 执行工作单元,并返回下一个工作单元function performUnitOfWork(nextUnitOfWork) { // ...}
复制代码


了解了 React 对 requestIdleCallback() 的实现,下面我们来看看 React 对工作单元是如何进行拆分的?

初识 Fiber

Fiber 是对 React 核心算法的重构,Facebook 团队使用两年多的时间去重构 React 的核心算法,并在 React16 以上的版本中引入了 Fiber 架构。

Fiber 既是一种数据结构,又是一个工作单元
  1. Fiebr 作为数据结构


React Fiber 机制的实现,就是依赖于下面的这种数据结构 - 链表实现的。其中每个节点都是一个 fiber。一个 fiber 包括了 child(第一个子节点)、sibling(兄弟节点)、parent(父节点)等属性。Fiber 节点中其实还会保存节点的类型、节点的信息(比如 state,props)、节点对应的值等。


<div>  <h1>    <p />    <a />  </h1>  <h2 /></div>
复制代码


  1. Fiber 作为工作单元


将它视作一个执行单元,每次执行完一个 “执行单元” (下面的 nextUnitOfWork), React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去。


while (nextUnitOfWork) {  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);  // 判断当前帧是否还有剩余时间}
复制代码


要想实现 Fiber 结构需要分为两步,一是如何创建 Fiber 树,二是如何遍历 Fiber 树。


React 的思想就是设法将组件的递归更新,改成链表的依次执行。所以接下来我们先将之前的虚拟 DOM 树,转换成 Fiber 树。

具体 Fiber 实现

因为是 render 方法中的递归调用不可中断的方式造成的性能问题,接下来我们来优化 render 方法


// 针对前文的 render 方法,只保留创建节点部分的逻辑,并重命名为createDom。function createDom(fiber) {  // 1. 创建不同类型的DOM节点  const dom =    fiber.type == "TEXT_ELEMENT"      ? document.createTextNode("")      : document.createElement(fiber.type);
// 2.为 DOM 节点添加属性props (没有children属性) const isProperty = (key) => key !== "children"; Object.keys(fiber.props) .filter(isProperty) .forEach((name) => { dom[name] = fiber.props[name]; });
return dom;}
复制代码


接下来定义工作单元,并在 render 方法中进行初始化。


let nextUnitOfWork = null;
function render(element, container) { // 定义初始工作单元(定义初始Fiber根节点) nextUnitOfWork = { dom: container, // root props: { children: [element], // DOM }, }; console.log("1. 初始 Fiber", nextUnitOfWork);}
复制代码


打印一下此时的 fiber 结构,可以看下初始 fiber 结构对应的就是 fiber 树的根节点。dom 属性中保存中 root 根节点、props.children 中保存着初始的虚拟 DOM 结构(后面对 fiber 树中的每个 fiber 节点的依次创建,依据的就是完整的虚拟 DOM 结构。)


根据前面对 requestIdleCallback 的理解,下面我们定义一个事件循环,并在 requestIdleCallback()方法中进行回调事件注册。


function workLoop(deadline) {  let shouldYield = false;  while (nextUnitOfWork && !shouldYield) {    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);    shouldYield = deadline.timeRemaining() < 1;  }  if (nextUnitOfWork) {    requestIdleCallback(workLoop);  }}
requestIdleCallback(workLoop);
复制代码


那么这个 performUnitOfWork() 方法,都做了哪些工作呢?


  1. 把元素添加到 dom 中

  2. 当从根 Fiber 向下创建 Fiber 时,我们始终是为子节点创建 Fiber(逐步创建 fiber 链表的过程)

  3. 遍历 fiber 树,找到下一个工作单元(遍历 fiber 树的过程)


/** * 执行工作单元都做了什么❓ */function performUnitOfWork(fiber) {  //  1. 把元素添加到 dom 中  if (!fiber.dom) {    fiber.dom = createDom(fiber);  }  if (fiber.parent) {    fiber.parent.dom.appendChild(fiber.dom);  }
// 2. 当从根 Fiber 向下创建 Fiber 时,我们始终是为子节点创建Fiber const elements = fiber.props.children; // 之前的vDOM结构 let index = 0; let prevSibling = null; while (index < elements.length) { const element = elements[index]; const newFiber = { type: element.type, props: element.props, dom: null, parent: fiber, }; // 第一个子元素 作为 child,其余的 子元素 作为 sibling if (index === 0) { fiber.child = newFiber; } else { prevSibling.sibling = newFiber; } prevSibling = newFiber; index++; } console.log("2. 每次执行工作单元后的Fiber树", fiber);
// 步骤2实现了创建 fiber树的过程 👆👆👆 // 下面的步骤3实现遍历 fiber的过程 👇👇👇
// 3. 遍历fiber树,找到下一个工作单元 if (fiber.child) { return fiber.child; } while (fiber) { if (fiber.sibling) { return fiber.sibling; } fiber = fiber.parent; }}
复制代码


可以看到每一次执行工作单元,都逐渐完善了 fiber 结构,结构中包含了当前处理节点的 parent、child 以及 sibling 的指向。


最后获取页面根节点,并渲染到页面上。


const container = document.getElementById("root");MyReact.render(element, container);
复制代码

黄金 – Commit 提交

我们在上面的 performUnitOfWork 里 ,每次都把元素直接添加到 DOM 上。这里会有一个问题,就是浏览器随时都有可能中断我们的操作,这样呈现给用户的就是一个不完整的 UI,所以我们需要做出些改动,就是让所有工作单元执行完后,我们再一并进行所有 DOM 的添加。也就是说在 react 不同阶段的机制不同,


  • Render 阶段,是可中断的

  • Commit 阶段,是不可中断的


下面我们标注出需要改动的部分


function performUnitOfWork(fiber) {  // 把元素添加到 dom 中  if (!fiber.dom) {    fiber.dom = createDom(fiber);  }  // step1. 去掉提交DOM节点的部分,后面进行统一提交  // if (fiber.parent) {  //   fiber.parent.dom.appendChild(fiber.dom);  // }
// 为元素的子元素都创建一个 fiber 结构(没有子元素跳过) const elements = fiber.props.children; let index = 0; let prevSibling = null; while (index < elements.length) { const element = elements[index]; const newFiber = { type: element.type, props: element.props, dom: null, parent: fiber, }; if (index === 0) { fiber.child = newFiber; } else { prevSibling.sibling = newFiber; } prevSibling = newFiber; index++; }
// 找到下一个工作单元(遍历fiber树) if (fiber.child) { return fiber.child; } while (fiber) { if (fiber.sibling) { return fiber.sibling; } fiber = fiber.parent; }}
// step2. 保存一个工作中的 fiber 树 wipRoot (work in progress root)并在render中初始化,便于后续 commit 整颗 fiber 树// 后续执行 performUnitOfWork时每次还是操作nextUnitOfWorklet wipRoot = null;function render(element, container) { // 初始化需要跟踪fiber的根节点,并赋值给 nextUnitOfWork wipRoot = { dom: container, props: { children: [element], }, }; nextUnitOfWork = wipRoot;}
// step3 workLoop中判断所有工作单元都执行完后,一并进行“提交”操作function workLoop(deadline) { let shouldYield = false; while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); shouldYield = deadline.timeRemaining() < 1; } // 进行“提交”操作 if (!nextUnitOfWork && wipRoot) { commitRoot(); } requestIdleCallback(workLoop);}
// 4. 创建commit函数,将所有元素往 dom 树上添加function commitRoot() { commitWork(wipRoot.child); wipRoot = null;}
// 进行递归提交function commitWork(fiber) { if (!fiber) { return; } const domParent = fiber.parent.dom; domParent.appendChild(fiber.dom); commitWork(fiber.child); commitWork(fiber.sibling);}
复制代码


至此,我们已经完成了一个简版 React 的实现,包括了 React 如何将 JSX 元素转换成我们熟知的虚拟 DOM 结构;Fiber 架构是如何实现优化拆分工作单元、实现异步可中断机制的;以及如何将一个 Fiber 树进行进行遍历、提交至页面进行渲染的。


当然,react 非常出名的 Reconciliation 协调算法本文还没有提到,它是 react 进行更新调度的核心机制,极大的提高的 react 的性能,后续有机会会继续进行探讨。


最后放两张大神 Lin Clark presentation in ReactConf 2017 演讲的示意图(Lin Clark 这个演讲太经典了)。就好比一个小人儿在潜水,如果他一直潜水并且越潜越深,那么它是无法感知岸上情况的(执行栈会越来越深、而且不能中断);第二张图就好像每次潜水一段时间就回到岸上看一下是否有新的任务要做(异步可中断,每次判断是否有优先级更高的任务),变得更加灵活了。


当然,引用尤雨溪大神说的话:React Fiber 本质上是为了解决 React 更新低效率的问题,不要期望 Fiber 能给你现有应用带来质的提升, 如果性能问题是自己造成的,自己的锅还是得自己背。


用户头像

还未添加个人签名 2022-07-31 加入

还未添加个人简介

评论

发布
暂无评论
怎样徒手写一个React_JavaScript_helloworld1024fd_InfoQ写作社区