关于javascript:怎样徒手写一个React

2次阅读

共计 10011 个字符,预计需要花费 26 分钟才能阅读完成。

本文次要通过手写一个简略的 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 时每次还是操作 nextUnitOfWork
let 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 能给你现有利用带来质的晋升, 如果性能问题是本人造成的,本人的锅还是得本人背。

正文完
 0