likes
comments
collection
share

「手写系列」从 0 到 1 实现 Micro React

作者站长头像
站长
· 阅读数 40

前言

本文基于React 16.8实现一个micro版本的React,利于快速理解 React 的源码设计思想以及熟悉React的工作流程,对阅读React源码有一定帮助,预计阅读用时约30分钟。

在开始实现 micro react 之前,先来看一段代码:

const element = <h1 title='world'>Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container);

相信作为React的使用者,你已经接触过JSX。如果你还不了解他,可以看下官网对于JSX的解释

通过 BABEL 编译 JSX Demo, 可以看到BABEL会把JSX转译成一个名为 React.createElement() 函数调用。

// 转换前
const element = <h1 title='world'>Hello</h1>

// 转换后
"use strict";

const element = /*#__PURE__*/React.createElement("h1", {
  title: "world"
}, "Hello");

打印 element 可以在控制台看到一个包含 propstype 等属性的对象。

「手写系列」从 0 到 1 实现 Micro React

我们尝试不用 ReactDom.render() 去将 element 挂载到idroot的容器中。

const container = document.getElementById("root");
const node = document.createElement(element.type);
node.title = element.props.title;
const text = document.createTextNode("");
text.nodeValue = element.props.children;
node.append(text);
container.append(node);

浏览器查看发现 element 成功挂载,在浏览器中正常显示。

「手写系列」从 0 到 1 实现 Micro React

到此可以得知,createElement 会创建一个描述 DOM节点 的对象即虚拟DOMrender 会将生成的虚拟DOM挂载到指定 DOM节点 中进行渲染。

本文将从createElement && render入手逐步实现一个micro react

代码地址:lazylwz/micro-react

初始化项目

yarn create vite // 项目模板选择 原生js 或者 react 都行
yarn
yarn dev

在根目录下新增 React 文件夹,将无用文件进行清理,最终项目结构如下

「手写系列」从 0 到 1 实现 Micro React

createElement && render

新建createElement.js文件,编写createElement函数,入参分别为节点类型(type)节点属性(props)子节点(children)

在构造虚拟DOM树的过程中,若children的值的类型为基本类型如String Number等,直接将该值渲染即可,将此类节点类型赋值为TEXT_ELEMENT用来区分createElement创建的节点,在render遍历虚拟DOM树的过程中可直接渲染,无需递归遍历该类型节点。

const createElement = (type, props, ...children) => {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) =>
        typeof child === "object" ? child : createTextElement(child)
      ),
    },
  };
};

const createTextElement = (text) => {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [], // 无子节点
    },
  };
};

export default createElement;
// 转换前
const element = createElement(
  <h1 title="world">
    Hello<div>!</div>
  </h1>
);
// 转换后
const element = createElement("h1", {
  title: "world"
}, "Hello", createElement("div", null, "!"));

打印 element 可以在控制台看到DOM树基本构建完成,接下来就是渲染至浏览器页面。

「手写系列」从 0 到 1 实现 Micro React

新建 render.js 文件,编写render函数,入参分别为elementcontainer

  • element:经过 createElement 包装过的虚拟DOM节点
  • container:真实的DOM节点

将虚拟DOM渲染成真实DOM节点并挂载需要以下几步:

  1. 根据节点类型创建对应如元素节点或文本节点;
  2. 将属性添加到对应节点;
  3. 递归处理虚拟DOM中的节点;
  4. 将处理完的节点挂载到真实的DOM节点。

代码描述如下:

const render = (element, container) => {
  const dom =
    element.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);
  Object.keys(element.props)
    .filter((key) => key !== "children") // 子节点无需追加
    .forEach((name) => (dom[name] = element.props[name]));
  element.props.children.forEach((child) => render(child, dom));
  container.appendChild(dom);
};
z
export default render;

来看下 render 函数写的是否能够完成渲染

import { createElement, render } from "./react";

const element = createElement(
  "h1",
  {
    title: "world",
  },
  "Hello",
  createElement("div", null, '!')
);
const container = document.getElementById("root");
render(element, container);

「手写系列」从 0 到 1 实现 Micro React 显示效果达成预期,至此简单实现了 createElement render

Concurrent Mode && Fiber

render.js 中,采用递归的方式处理节点渲染,递归过程是不能中断的。如果虚拟DOM树的层级很深,递归会占用线程很多时间,造成页面卡顿,所以需要将同步递归的渲染重构为异步可中断的渲染

为此借助浏览器 API requestIdleCallback 在浏览器空闲时期被调用,并且在其回调中利用 IdleDeadline可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态这些特性,实现异步渲染,再将虚拟DOM树处理成Fiber树就可完成异步可中断的渲染

并发模式处理

处理虚拟DOM树只在浏览器空闲时进行,不影响浏览器主线程中其他事件如用户事件、动画渲染等事件的执行,需要利用requestIdleCallback处理。简单描述如下:

// 需要处理的工作单元
let nextUnitOfWork = null;

// 处理当前工作单元,返回下一个需要执行的工作单元
const performUnitOfWork = (work) => {
  // TODO:
  return work;
};

const workLoop = (deadline) => {
  let shouldYield = false; // 不应该交出控制权或不应该停止
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行工作单元
    shouldYield = deadline.timeRemaining() < 1; // 检测浏览器是否还有空余时间
  }
  // 没有空余时间,将工作单元放到浏览器下一次空闲时执行
  requestIdleCallback(workLoop);
};

// 初始化,第一次请求
requestIdleCallback(workLoop);

目前浏览器大多是 60Hz(60帧/s),每一帧耗时约为 16.6ms

现在来假设一个场景,nextUnitOfWork虚拟DOM树的渲染时间为100ms,远远超过16.6ms,按照并发模式处理页面还是会卡顿。

如果将100ms这个大的工作单元拆分为10个的小工作单元,每个工作单元花费为10ms,那么就可以将每个小的工作单元放在requestIdleCallback中执行,这些工作单元会在浏览器每一帧的空闲时间去执行,空余时间有就继续执行工作单元,没有就处理浏览器自身事件,不会造成渲染阻塞,也就达到了异步渲染的效果。

但是目前还存在问题,拆分后的工作单元如小的虚拟DOM节点如何知道自身应插入位置呢?如何建立彼此之间的联系呢?

为解决这个问题,React官方引入了Fiber

Fiber

Fiber通过链表的形式来记录节点之间的关系,它与传统的虚拟DOM最大的区别是多加了几个属性:

  • parent:指向父节点fiber
  • child:指向子节点的第一个fiber
  • sibling:指向下一个兄弟节点的fiber

下面是一个虚拟DOM,每一个DOM节点对应一个Fiber对象,DOM树对应的Fiber结构如下:

<h1>
  <div>
    hello
    <span>
      world !
    </span>
  </div>
</h1>

「手写系列」从 0 到 1 实现 Micro React

可以看出每个fiber 都记录了上一个节点的信息和下一个节点的信息,利用fiber就可以将一棵完整的虚拟DOM树转化为基于链表的由fiber构成的Fiber tree

现在将刚构成的 Fiber tree 用刚写的并发模式试运行下,具体过程如下:

  1. 首先给requestIdleCallback传入第一个根fiber h1先进行渲染工作, 当该 fiber 渲染完成时利用deadline.timeRemaining()检测浏览器是否还有空余时间,如果有空闲时间,那么就从该fiber中取出child对应的fiber继续进行渲染,如果没有空余时间,那么就等到浏览器下一次的空闲时间再继续对该fiber中的child进行渲染,该过程一直循环执行,直到没有需要处理的fiber为止。
  2. h1 div hello都处理完时,此时hellochild没有指向fiber,也就是没有child时,取该fibersibling 中对应的 fiber进行渲染。
  3. h1 div hello span world!都处理完时,此时world!child sibling均没有指向的fiber,此时开始向上寻找parent对应的sibling指向的fiberspan div h1均没有sibling指向的fiber,此时结束所有的渲染工作。

总结:在 Fiber tree 的遍历过程中,先找child指向的fiber,当所有的child均处理完时,开始从向上找parent对应的sibling指向的fiber

有点类似与二叉树的先序遍历,即'根左右',把左子树节点当作 child ,右子树当作 sibling 的集合,从根节点开始,先遍历左子树,再遍历右子树的第一个节点,当右子树的节点数量大于1时,把右子树的第二个节点开始当作根节点依次处理。

「手写系列」从 0 到 1 实现 Micro Reactrender.js中,因为渲染DOM的逻辑要频繁使用,所以先将其移出,接着初始化rootFiber,相关代码如下。

const createDom = (fiber) => {
  const dom =
    fiber.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type);
  Object.keys(fiber.props)
    .filter((key) => key !== "children")
    .forEach((name) => (dom[name] = fiber.props[name]));
  return dom;
};

// 初始化第一个工作单元 rootFiber
const render = (element, container) => {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],// 对于 container 来说,element 均为子节点
    },
    sibling: null,
    child: null,
    parent: null,
  };
};

接着开始写虚拟DOM Tree构造Fiber Tree渲染的逻辑。

首先从根节点开始遍历所有的子节点数组,构造fiber,遍历的过程中,第一个子节点作为 child,其余子节点均为 sibling,当前节点fiber渲染完成后返回下一个工作单元需要的fiber即当前fiberchild,若所有的child均渲染完成,开始向上处理parentsibling对应的fiber,若存在sibling对应的fiber 则把该fiber当作下一个工作单元返回,当所有的sibling都渲染结束时,虚拟DOM Tree利用Fiber Tree异步渲染的过程就完成了。

const performUnitOfWork = (fiber) => {
  // 创建 DOM 元素
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  // 追加到父节点
  if (fiber.parent) {
    fiber.parent.dom.append(fiber.dom);
  }
  // 给children添加fiber
  const elements = fiber.props.children;
  let prevSibling = null;
  // 构建Fiber tree
  for (let i = 0; i < elements.length; i++) {
    const newFiber = {
      type: elements[i].type,
      props: elements[i].props,
      parent: fiber,
      dom: null,
      child: null,
      sibling: null,
    };
    // children 数组中第一个节点当作 child ,其余均为 sibling 节点
    if (i === 0) {
      fiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
  }
  // 返回下一个fiber
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
};

每一个 fiber 均可以找到parent child sibling的信息,浏览器在异步渲染时不会丢失节点信息。

「手写系列」从 0 到 1 实现 Micro React

Render and Commit Phases

目前为止,虚拟DOM的渲染已经被我们改为Fiber Tree结构进行异步渲染,但是如果此时浏览器动画渲染或处理用户事件时间较长,导致整颗虚拟DOM Tree未完成全部渲染,浏览器将会渲染不完整的DOM结构,用户将会看到不完整的UI,如此例中的helloworld!只渲染了hello,对于用户来说是不友好的,我们应该让用户看到完整的UI,为此需要对根fiber进行追踪,当整颗Fiber Tree中的fiber均处理完时再进行渲染。

改动如下:

  1. 定义 workInProgressRootwipRoot 对 根fiber进行存储追踪
  2. 将原先一个fiber处理完就提交至浏览器进行渲染修改为所有的fiberFiber Tree处理完成再交给浏览器渲染
 let nextUnitOfWork = null;
+  let wipRoot = null; // 工作中的 Fiber tree

// 处理完成的 fiber tree 提交至浏览器进行渲染
+ const commitRoot = () => {
+   commitWork(wipRoot.child);
+   wipRoot = null; // 渲染完成进行清空
+ };

+ // 递归处理每个小的 fiber
+ const commitWork = (fiber) => {
+   if (!fiber) return;
+     const domParent = fiber.parent.dom;
+     domParent.appendChild(fiber.dom);
+       if (fiber.child) commitWork(fiber.child);
+       if (fiber.sibling) commitWork(fiber.sibling);
+ };
 
 const workLoop = (deadline) => {
   let shouldYield = false; // 不应该交出控制权或不应该停止
   while (nextUnitOfWork && !shouldYield) {
     nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行工作单元
     shouldYield = deadline.timeRemaining() < 1; // 检测浏览器是否还有空余时间
   }
+  // 如果没有下一个需要处理的 fiber 并且有全部处理完成的需要提交的 wipRoot,就提交至浏览器进行渲染
+  if (!nextUnitOfWork && wipRoot) {
+    commitRoot();
+  }
   
   // 没有空余时间,将工作单元放到浏览器下一次空闲时执行
   requestIdleCallback(workLoop);
 };
 
 const performUnitOfWork = (fiber) => {
   // 创建 DOM 元素
   if (!fiber.dom) {
     fiber.dom = createDom(fiber);
   }
-  // 追加到父节点
-  if (fiber.parent) {
-    fiber.parent.dom.append(fiber.dom);
-  }
   // 给children添加fiber
   const elements = fiber.props.children;
   let prevSibling = null;
 };

「手写系列」从 0 到 1 实现 Micro React

Reconciliation

Reconciliation具体细节可以看官方对于这一节的阐述 协调 – React (reactjs.org)

React 采取Diff最优解策略的限制条件是

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用;
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点;
  3. 开发者指定key区分子节点遍历时是否复用。

我们这里简单限制为:

  1. 元素类型相同:复用节点进行更新
  2. 元素类型不同:创建新的DOM节点,旧节点存在进行删除

具体比较过程如下 「手写系列」从 0 到 1 实现 Micro React

对于fiber的调度我们需要添加一些属性和全局变量来完成

  • currentRoot:全局存储上一个 fiber
  • deletions:需要删除的 fiber
  • alternate:上一个 fiber 的备份
  • effectFlag:标识fiber节点的操作类型,添加(PLACEMENT)、更新(UPDATE)或删除(DELETION)
+ let currentRoot = null;
+ let deletions = null;

 const commitRoot = () => {
+   deletions.forEach(commitWork); // 删除不需要的 fiber
   commitWork(wipRoot.child);
+   currentRoot = wipRoot; // 上一个 fiber
   wipRoot = null;
 };
 
// 初始化第一个工作单元 rootFiber
const render = (element, container) => {
    wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    sibling: null,
    child: null,
    parent: null,
+    alternate: currentRoot, // 上一次fiber
  };
+  deletions = [];

   nextUnitOfWork = wipRoot;

};  

将原先构建Fiber Tree的代码提取出单独用新的调度函数reconcileChildren处理,添加diff后的逻辑如下:

const performUnitOfWork = (fiber) => {
  // 创建 DOM 元素
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  // 追加到父节点
  if (fiber.parent) {
    fiber.parent.dom.append(fiber.dom);
  }
  // 给children添加fiber
  const elements = fiber.props.children;
  let prevSibling = null;
- // 构建Fiber tree
- for (let i = 0; i < elements.length; i++) {
-   const newFiber = {
-     type: elements[i].type,
-     props: elements[i].props,
-     parent: fiber,
-     dom: null,
-     child: null,
-     sibling: null,
-   };
-   // children 数组中第一个节点当作 child ,其余均为 sibling 节点
-   if (i === 0) {
-     fiber.child = newFiber;
-   } else {
-     prevSibling.sibling = newFiber;
-   }
-   prevSibling = newFiber;
- }
+ // 新建newFiber
+ reconcileChildren(fiber, elements);
  // 返回下一个fiber
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
};
const reconcileChildren = (wipFiber, elements) => {
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
    let prevSibling = null;
    let newFiber = null;
    for (let i = 0; i < elements.length; i++) {
      const sameType =
        oldFiber && elements[i] && elements[i].type === oldFiber.type;
      /* 
        类型相同:
          复用节点进行更新
        类型不同:
          创建新的DOM节点
          旧节点存在进行删除
      */
      if (sameType) {
        newFiber = {
          type: oldFiber.type,
          props: elements[i].props,
          dom: oldFiber.dom,
          parent: wipFiber,
          alternate: oldFiber,
          effectFlag: "UPDATE",
        };
      }
      if (!sameType && elements[i]) {
        newFiber = {
          type: elements[i].type,
          props: elements[i].props,
          dom: null,
          parent: wipFiber,
          alternate: null,
          effectFlag: "PLACEMENT",
        };
      }
      if (!sameType && oldFiber) {
        oldFiber.effectFlag = "DELETION";
        deletions.push = [oldFiber];
      }
      if (oldFiber) oldFiber = oldFiber.sibling;
      if (i === 0) {
        wipFiber.child = newFiber;
      } else {
        prevSibling.sibling = newFiber;
      }
      prevSibling = newFiber;
    }
  };

经过diff后的fiber后会存在被删除或更新,之前只写了添加的情况处理,需补充剩余情况,对于更新操作,需考虑对应事件、属性的处理,具体如下:

  const commitWork = (fiber) => {
    if (!fiber) return;
    const domParent = fiber.parent.dom;
-   domParent.appendChild(fiber.dom);
+   if (fiber.effectFlag === "PLACEMENT" && fiber.dom !== null) {
+     domParent.appendChild(fiber.dom);
+   } else if (fiber.effectFlag === "DElETION") {
+     domParent.removeChild(fiber.dom);
+   } else if (fiber.effectFlag === "UPDATE" && fiber.dom !== null) {
+     updateDom(fiber.dom, fiber.alternate.props, fiber.props);
+   }
    if (fiber.child) commitWork(fiber.child);
    if (fiber.sibling) commitWork(fiber.sibling);
  };
const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (next) => (key) => !(key in next);

const updateDom = (dom, prevProps, nextProps) => {
  // 删除已经不存在的 props
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(nextProps))
    .forEach((name) => (dom[name] = ""));
  // 添加新的或者改变的 props
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => (dom[name] = nextProps[name]));
  // 删除没有的或者改变的 events
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(isGone(nextProps) || isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });
  // 添加新 events
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
};

Function Components

先来看一段代码,试着用micro react运行,看能否正常渲染?

import { createElement, render } from "../react";

const funComponent = () => createElement("我是函数式组件", null, null)

const element = createElement(
  "h1",
  null,
  createElement(
    "div",
    null,
    "hello",
    createElement("span", null, "world !"),
    createElement(funComponent, null, null)
  )
);
const container = document.getElementById("root");

render(element, container);

「手写系列」从 0 到 1 实现 Micro React

答案是否定的,这是因为函数组件没有自身DOM结构,而在我们编写的createElement时需要有DOM结构的入参,后续需要使用其DOM进行渲染,所以不能正常渲染。

再使用 React 开发时,函数式组件经常是第一选择,所以为了支持函数式组件开发,需要处理这个问题。

「手写系列」从 0 到 1 实现 Micro ReactcreateElement中打印type分析可以得出,函数组件funComponent对应的type为函数,而我们需要的是该函数的返回结果值,所以需将函数组件与原生组件区分处理。

 const performUnitOfWork = (fiber) => {
-  // 创建 DOM 元素
-  if (!fiber.dom) {
-    fiber.dom = createDom(fiber);
-  }
-
-  // 给children添加fiber
-  const elements = fiber.props.children;
-  // 新建newFiber
-  reconcileChildren(fiber, elements);
+  //区分函数组件,渲染 DOM 特殊处理 
+  const isFunctionComponent = fiber.type instanceof Function;
+  isFunctionComponent
+    ? updateFunctionComponent(fiber)
+    : updateHostComponent(fiber);
 
   // 返回下一个fiber
   if (fiber.child) {
     return fiber.child;
   }
   let nextFiber = fiber;
   while (nextFiber) {
     if (nextFiber.sibling) {
       return nextFiber.sibling;
     }
     nextFiber = nextFiber.parent;
   }
 };
const updateHostComponent = (fiber) => {
    // 创建 DOM 元素
    if (!fiber.dom) {
      fiber.dom = createDom(fiber);
    }
    // 新建newFiber
    reconcileChildren(fiber, fiber.props.children);
  };
  
const updateFunctionComponent = (fiber) => {
   const children = [fiber.type(fiber.props)]; // 将函数运行取其对应 DOM
   reconcileChildren(fiber, children);
};

  • 添加时,要找到DOM节点的父节点,需要沿着当前fiberparent向上查找,直到找到存在DOMfiber节点
  • 删除时由于函数组件没有自身DOM结构,所以向下寻找最近的child节点DOM进行删除
 const commitWork = (fiber) => {
   if (!fiber) return;
-  const domParent = fiber.parent.dom;
   let domParentFiber = fiber.parent;
+  while (!domParentFiber.dom) {
+    domParentFiber = domParentFiber.parent; // 向上寻找存在 DOM 的 fiber 节点
+  }
+  const domParent = domParentFiber.dom;
   if (fiber.effectFlag === "PLACEMENT" && fiber.dom !== null) {
     domParent.appendChild(fiber.dom);
   } else if (fiber.effectFlag === "DElETION") {
-    domParent.removeChild(fiber.dom);
+    commitDeletion(fiber, domParent);
   } else if (fiber.effectFlag === "UPDATE" && fiber.dom !== null) {
     updateDom(fiber.dom, fiber.alternate.props, fiber.props);
   }
   if (fiber.child) commitWork(fiber.child);
   if (fiber.sibling) commitWork(fiber.sibling);
 };
  // 函数组件(没有自己的DOM)特殊处理:向下寻找最近的 child 节点dom进行删除
  const commitDeletion = (fiber, domParent) => {
    if (fiber.dom) {
      domParent.removeChild(fiber.dom);
    } else {
      commitDeletion(fiber.child, domParent);
    }
  };

继续运行本节开头的例子,发现正常渲染,支持函数式组件功能基本实现。

「手写系列」从 0 到 1 实现 Micro React

Hooks

函数式组件离不开hooks,所以这里以useState 为例实现一个简单的hooks

// 使用时
const [state, setState] = useState(0)

// 转换时推测应该返回这样的一个数组,只需关心中间的逻辑处理即可
export const useState = (init) => {
   // ...逻辑处理
   return [state, setState];
};

考虑到使用setState时需要对之前的state进行比较以及使用多个useState,所以定义一些全局变量方便使用:

  • wipFiber:存储记录上一个fiber
  • hookIndex:存储记录 每一个hook对应的位置
+ let wipFiber = null;
+ let hookIndex = null;
 
  const updateFunctionComponent = (fiber) => {
+   wipFiber = fiber; //默认当前 fiber 为 wipFiber
+   hookIndex = 0;
+   wipFiber.hooks = []; // 初始化 hooks 数组,用于存储 hook
    const children = [fiber.type(fiber.props)];
    reconcileChildren(fiber, children);
  };

接着开始处理 useState 的逻辑:

  1. 将旧的hook从上一个fiberwipFiber.alternate中取出;
  2. 定义 hook 对象,包含状态值state和存储改变其状态的函数数组queue,其中queue记录每一次调用的setState函数;
  3. queue 中的函数遍历取出,将当前hookstate作为参数传入,循环调用,使state的值变为最新;
  4. 最后在当前fiber中存储该hookhookIndex加1方便对该hook追踪记录。
export const useState = (init) => {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];
  const hook = {
    state: oldHook ? oldHook.state : init,
    queue: [],
  };
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => (hook.state = action(hook.state)));// 执行传入的函数
  
  const setState = (action) => {
    hook.queue.push(action); // 记录调用的函数
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot; // 将执行完毕的 wipRoot 提交至浏览器渲染
    deletions = [];
  };
  // 记录该 hook
  wipFiber.hooks.push(hook); 
  hookIndex++;
  return [hook.state, setState];
};

来尝试下使用useState

const funcComponent = () => {
    const [state, setState] = useState(0);
    console.log('state:', state);
    return createElement(
      "h1",
      { onclick: () => setState((state) => state + 1) },
      state
    );
}

const element = createElement(
  "h1",
  null,
  createElement(
    "div",
    null,
    "hello",
    createElement("span", null, "world !"),
    createElement(funcComponent, null, null)
  )
);
const container = document.getElementById("root");

浏览器正常显示,控制台输出结果一致,效果实现。

「手写系列」从 0 到 1 实现 Micro React

Dom style props && Flat children arrays

目前 micro react 的功能基本完成,有些优化可以放在这里补充下。

dom style props

在添加属性时遍历props对象,但是DOM中的样式对应着一个style对象,所以在遍历style属性时需当作对象处理。


 const createDom = (fiber) => {
  const dom =
    fiber.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type);
      console.log(fiber)
  Object.keys(fiber.props)
    .filter((key) => key !== "children")
    .forEach((name) => (dom[name] = fiber.props[name]));
    console.log(  Object.keys(fiber.props)
      .filter((key) => key === "style"))
+ // 设置默认样式属性
+ const defaultStyle = fiber.props.style || {};
+ Object.keys(defaultStyle).forEach((name) => (dom.style[name] = defaultStyle[name]));
  return dom;
};

  const isEvent = (key) => key.startsWith("on");
- const isProperty = (key) => key !== "children" && !isEvent(key);
+ const isStyle = (key) => key === "style";
+ const isProperty = (key) => key !== "children" && !isEvent(key) && !isStyle(key);
  const isNew = (prev, next) => (key) => prev[key] !== next[key];
  const isGone = (next) => (key) => !(key in next);
  
  const updateDom = (dom, prevProps, nextProps) => {
    const prevStyle = prevProps.style || {};
    const nextStyle = nextProps.style || {};
    // 删除已经不存在的 props
    Object.keys(prevProps)
      .filter(isProperty)
      .filter(isGone(prevProps, nextProps))
      .filter(isGone(nextProps))
      .forEach((name) => (dom[name] = ""));
    // 添加新的或者改变的 props
    Object.keys(nextProps)
      .filter(isProperty)
      .filter(isNew(prevProps, nextProps))
      .forEach((name) => (dom[name] = nextProps[name]));
    // 删除没有的或者改变的 events
    Object.keys(prevProps)
      .filter(isEvent)
      .filter((key) => !(key in nextProps) || isNew(prevProps, nextProps))
      .filter(isGone(nextProps) || isNew(prevProps, nextProps))
      .forEach((name) => {
        const eventType = name.toLowerCase().substring(2);
        dom.removeEventListener(eventType, prevProps[name]);
      });
    // 添加新的 events
    Object.keys(nextProps)
      .filter(isEvent)
      .filter(isNew(prevProps, nextProps))
      .forEach((name) => {
        const eventType = name.toLowerCase().substring(2);
        dom.addEventListener(eventType, nextProps[name]);
      });
+   // 删除已经不存在的 style
+   Object.keys(prevStyle)
+     .filter(isGone(nextStyle))
+     .forEach((name) => (dom.style[name] = ""));
+   // 添加新的 style
+   Object.keys(nextStyle).forEach((name) => (dom.style[name] = nextStyle[name]));
  };

「手写系列」从 0 到 1 实现 Micro React

flat children arrays

对于多节点元素需单独处理,如列表ul ol

 const createElement = (type, props, ...children) => {
   return {
     type,
     props: {
       ...props,
-       children: children.map((child) =>
+       children: children.flat().map((child) =>
         typeof child === "object" ? child : createTextElement(child)
       ),
     },
   };
 };
const nodes = ["节点1", "节点2", "节点3"];
const list = createElement(
  "ul",
  null,
  nodes.map((node) => createElement("li", null, node))
);

const renderer = () => {
  const container = document.querySelector("#root");
  const element = createElement("h1", null, list);
  render(element, container);
};
renderer();

浏览器正常显示,控制台输出结果一致,效果实现。

「手写系列」从 0 到 1 实现 Micro React

总结

目前为止一个基础版的micro react就基本实现了,相信React的工作流程及思想有了更深的认识,对于类组件以及其他hooks等react特性功能感兴趣的朋友可以自行实现。

本文源码放在下方,需要可以自取,与本文章节对应。

github.com/lazylwz/mic…

参考资料

[1] Build your own React:pomb.us/build-your-…

[2] React 官网:reactjs.org

[3] React 技术解密:kasong.gitee.io/just-react