文章介绍

build-your-own-react是一篇操作说明书,指导用户一步步实现一个简易的React,从中了解到React的大体工作流程。这篇文章是我的观后整理和记录,或许对大家会有所帮助。

构建简易React,分为九个阶段:

  1. 介绍createElementrender
  2. 实现createElement
  3. 实现render
  4. 介绍并发模式
  5. 实现 Fibers
  6. render 和 commit 阶段
  7. 实现协调
  8. 支持函数组件
  9. 实现 Hooks

介绍 createElementrender

JSX描述结构,由Babel转译为对createElement的调用;

createElement接收 tagName、props、children,返回 ReactElement 对象;

render接收 ReactElement 对象和挂载节点,产生渲染效果。

实现createElement

createElement做以下几件事:

  • props中包括keyref,需要做一次分离
  • children子项可能是String/Number这类原始类型数据。原始类型数据与文本节点对应,因此将其统一处理为TEXT_ELEMENT类型的对象
  • children附加到props对象上
  • 返回 ReactElement 对象
function createElement (type, config, ...children) {let key = null;let ref = null;let props = {};// 从 props 中分离 key 和 refif (config) {for (const name in config) if (Object.prototype.hasOwnProperty.call(config, name)) {if (name === "key") {key = config[name];} else if (name === "ref") {ref = config[name];} else {props[name] = config[name];}}}}// 处理 children 项,并将 children 附加到 props 上props.children = children.map((child) =>typeof child === "object"? child: {type: "TEXT_ELEMENT",props: {nodeValue: child,children: [],},});return {type,key,ref,props,};
}

实现render

render接收到的 ReactElement 对象,其实可以说是虚拟DOM结构的根,通过props.children连接子 ReactElement 对象

render的目的是产生渲染效果。最直观的方法是从根 ReactElement 开始进行深度优先遍历,生成整棵 DOM 树后挂载到根节点上。

function render(element, container) {const { type, props } = element;// 前序创建节点const dom =type === "TEXT_ELEMENT"? document.createTextNode(""): document.createElement(type);Object.keys(props).forEach((name) => {if (isProperty(name)) {dom[name] = props[name];}});props.children.filter(Boolean).forEach((child) => this.render(child, dom));// 后序挂载节点container.appendChild(dom);
}

这其实类似于React v16之前的 stack reconciler。其特点在于利用调用栈实现遍历。

介绍并发模式

按照目前的方式进行更新时,需要将整颗虚拟DOM树一次性处理完毕。当树层级结构变得复杂,JS计算将长时间占用主线程,会导致卡顿、无法响应的糟糕体验。

能否实现增量渲染。具体来说,能否将虚拟DOM树处理划分为一个个小任务,并在主线程上并发执行呢?

依赖于调用栈,难以将整个过程中断,也就无法实现任务拆分。不如在内存中自行维护一个支持 DFS 的数据结构,代替调用栈的功能。React控制主动权,自主做任务拆分和维护。这个数据结构就是 Fiber 树了。

那么如何在主线程上并发执行,或者说怎么确定任务的执行时机。浏览器的主线程需要处理HTML解析、样式计算、布局、系统级任务、JavaScript执行、垃圾回收等一众任务,由任务队列调度。当主线程处于空闲状态时安排上 Fiber 处理那是最好不过。恰好,浏览器端提供了一个API——requestIdleCallback(callback),当浏览器空闲时会主动执行 callback 函数。但是,可惜的是这个方法目前在各浏览器的支持度和稳定性还无法得到保证。因此 React 团队自行实现了 Scheduler 库来代替requestIdleCallback 实现任务调度。

上面说的两个过程就是任务分片和任务调度了,他们一个由 Fiber 实现,一个由 Scheduler 实现。

Fibers

Fiber和ReactElement的关系

ReactElement 对象已经是虚拟DOM的一种表示方法了,一个 ReactElement 对象对应一个 FiberNode,只需给 FiberNode 加上核心信息 typeprops

FiberNode {type: element.type,props: element.props,child: Fiber,sibling: Fiber,parent: Fiber
}

Fiber如何支持DFS

Fiber 结构的最大特点是child/sibling/parent三个指针,分别指向第一个子节点、紧跟着的兄弟节点、父节点。这三个指针使深度优先遍历成为可能。

root - div - h1 - p - a - h2
  • 沿着 child 指针向下遍历,直到叶子节点。
  • 叶子节点依赖 sibling 指针向右遍历该层兄弟节点。
  • 兄弟节点遍历完毕再沿 parent 指针回到上一层
  • 直到回到根节点停止

Fiber和任务分片

前文说过 Fiber 的作用在任务分片。在虚拟DOM树的处理过程中,最小的处理粒度是一个节点。我们把处理单个FiberNode的任务称为“unitOfWork”,方便起见,下文称之为单位任务。

总结

  • 一个 ReactElement 对象对应 一个 Fiber 节点,一个 Fiber 节点对应一个单位任务。
  • Fiber 节点通过parent/child/sibing三个指针构成 Fiber 树,Fiber 树支撑深度优先遍历。

任务调度

在主线程上,每个空闲的时间片长度不一。我们希望在一个时间片有限的时间内尽量多的执行任务。

因此在处理完一个单位任务之后查询是否还有空闲,再决定是否执行下一个单位任务。这部分代码由workLoop函数实现。

// 依赖requestIdleCallback实现调度
let nextOfUnitWork = null;
function workLoop(deadline) {let shouldYield = false;while (nextOfUnitWork && !shouldYield) {nextOfUnitWork = performUnitOfWork(nextOfUnitWork);shouldYield = deadline.timeRemaining() < 1;}requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);

处理单位任务

处理单位任务的函数是performUnitOfWork,在这个函数里做了三件事:

  1. 创建DOM;
  2. 为当前 Fiber 的所有子元素创建 Fiber,并且构建连接;
  3. 按照深度优先遍历的顺序(child > sibling > parent),确定下一个待处理任务。

是的,“构建Fiber树” 和 “Fiber节点处理” 是自上而下同步进行的。

const isProperty = (prop) => prop !== "children";const SimactDOM = {render(element, container) {nextOfUnitWork = {dom: container,props: {children: [element],},};},
};// workLoop依赖requestIdleCallback实现调度
let nextOfUnitWork = null;
function workLoop(deadline) {let shouldYield = false;while (nextOfUnitWork && !shouldYield) {nextOfUnitWork = performUnitOfWork(nextOfUnitWork);shouldYield = deadline.timeRemaining() < 1;}requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);// 处理 unitOfWork
function performUnitOfWork(fiber) {// 创建DOMif (!fiber.dom) {fiber.dom = createDOM(fiber);}// 挂载DOMif (fiber.parent) {fiber.parent.dom.appendChild(fiber.dom);}const elements = fiber.props.children;let index = 0;let prevSibling = null;// 创建 children fiberswhile (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;}if (prevSibling) {prevSibling.sibling = newFiber;}index++;prevSibling = newFiber;}// 返回 next unitOfWorkif (fiber.child) {return fiber.child;}let nextFiber = fiber;while (nextFiber) {if (nextFiber.sibling) {return nextFiber.sibling;}nextFiber = nextFiber.parent;}
}export { SimactDOM as default };

仔细阅读上面的代码,会发现render调用和任务调度执行,在代码上并没有顺序联系。这和我们常见的代码结构有些许不同。

render 和 commit 阶段

在一个任务中直接进行DOM挂载,同时任务分散在多个时间片内并发执行。这会导致部分视图已更新,部分视图未更新 的现象。

那么如何防止DOM发生突变(mutate),尽量将其模拟成一个不可变对象呢?方法是将 Fiber树处理过程和挂载DOM树过程分离开。就是说分为两个阶段:render 和 commit。

render 阶段增量处理Fiber节点,commit阶段将结果一次性提交到DOM树上。

render 阶段负责:

  • 生成 Fiber 树
  • 为 Fiber 创建对应的 DOM 节点。确保进入 commit 前,每一个 Fiber 上都有节点。但 DOM 节点的更新、插入、删除由 commit 负责。

commit 阶段再次遍历 Fiber 树,将 DOM 节点挂载到文档上。

在内存中维护一颗 Fiber 树(workInProgress)充当处理的目标对象。整棵 Fiber 树处理完毕后,一次性渲染到视图上。

function render(element, container) {// workInProgress Tree 充当目标 wipRoot = {dom: container,props: {children: [element],},}nextUnitOfWork = wipRoot
}let nextUnitOfWork = null
let wipRoot = nullfunction workLoop(deadline) {let shouldYield = falsewhile (nextUnitOfWork && !shouldYield) {nextUnitOfWork = performUnitOfWork(nextUnitOfWork)shouldYield = deadline.timeRemaining() < 1}// 进入commit阶段的判断条件:有一棵树在渲染流程中,并且render阶段已执行完毕if (!nextUnitOfWork && wipRoot) {commitRoot()}requestIdleCallback(workLoop)
}function commitRoot() {// commit阶段递归遍历fiber树,挂载DOM节点commitWork(wipRoot.child)wipRoot = null
}function commitWork(fiber) {if (!fiber) {return}const domParent = fiber.parent.domdomParent.appendChild(fiber.dom)commitWork(fiber.child)commitWork(fiber.sibling)
}

实现协调

我们开始考虑状态更新的情况,上述代码重复执行render将会导致 DOM 节点追加,而非更新。虚拟DOM进行协调简单来说是实现一颗新树,比较和记录新树和老树之间的差异。

workInProgress树负责生成新树。我们需要一颗老树,和新树做对比。这颗老树也是与视图对应的Fiber树,称为current树 。workInProgress树和current树的关系,类似于缓冲区和显示区。缓冲区处理完毕,复制给显示区。

计算两棵树的最小修改策略的 Diffing 算法,由

的时间复杂度降维到
,关键因素在于三点:
  1. 节点很少出现跨层级移动,因此只比较同一层级节点
  2. 两个不同类型的节点往往会产生不同的树。因此当节点类型不同时,不再比较其子树,直接销毁并创建新子树
  3. 同一层级节点可以通过key标识对应关系

我们来实现 Diffing 算法。

  • 依赖alternate确定节点的对应关系
  • render阶段:根据节点类型变化确定更新策略effectTag
  • commit阶段:根据effectTag应用具体DOM操作

如何确定两棵树中节点的对应关系?

Fiber节点上alternate属性记录同一层级对应位置的老Fiber节点。而alternate属性的赋值是在创建子Fiber节点时进行的。

  • 根节点 workInProgressRoot.alternate = currentRoot
  • 创建子Fiber节点时,依赖child指针和sibling指针找到current树中的对应老Fiber节点
  • 通过alternate建立新老子层节点的对应关系,到下一层递归

这一部分代码应该更能直观说明:

let workInProgressRoot = null;
let currentRoot = null;// 根节点建立联系
const SimactDOM = {render(element, container) {deletions = [];workInProgressRoot = {dom: container,props: {children: [element],},alternate: currentRoot,};nextOfUnitWork = workInProgressRoot;},
};function performUnitOfWork () {...let oldFiber = fiber.alternate && fiber.alternate.child;...// 处理一个Fiber节点时,创建其子节点。// 依赖对应老节点的child指针和子节点的sibling指针,确定子节点对应关系// 通过alternate建立新老子层节点的对应关系,到下一层递归let index = 0;while (index < elements.length) {const newFiber = {type: element.type,props: element.props,dom: null,parent: fiber,alternate: oldFiber,};....if (oldFiber) {oldFiber = oldFiber.sibling;}index++;}...
}// 渲染完毕后,更新current树,重置workInProgress树
function commitRoot() {commitWork(workInProgressRoot.child);currentRoot = workInProgressRoot;workInProgressRoot = null;
}

render阶段 :根据节点类型确定更新策略

在 render 阶段记录节点对应的操作标识,由Fiber的effectTag记录;

  • 同类型节点复用DOM元素,只需进行属性更改("UPDATE"
  • 不同类型的节点销毁原有DOM元素("DELETION"),创建新的DOM元素("PLACEMENT"
const deletions = [];
function reconcileChildren(fiber, elements) {// create children fiberslet oldFiber = fiber.alternate && fiber.alternate.child;let index = 0;let prevSibling = null;while (index < elements.length || oldFiber) {let newFiber = null;const element = elements[index];// 判断类型是否相同const isSameType = element && oldFiber && element.type === oldFiber.type;// 同类型,复用dom,并建立alternate联系if (isSameType) {newFiber = {type: oldFiber.type,props: element.props,dom: oldFiber.dom,parent: fiber,alternate: oldFiber,effectTag: "UPDATE",};}// 不同类型,创建新dom,并切断子树比较if (element && !isSameType) {newFiber = {type: element.type,props: element.props,dom: null,parent: fiber,alternate: null,effectTag: "PLACEMENT",};}// 不同类型,销毁旧domif (oldFiber && !isSameType) {deletions.push(oldFiber);oldFiber.effectTag = "DELETION";}if (index === 0) {fiber.child = newFiber;}if (prevSibling) {prevSibling.sibling = newFiber;}index++;prevSibling = newFiber;if (oldFiber) {oldFiber = oldFiber.sibling;}}
}function performUnitOfWork(fiber) {// create domif (!fiber.dom) {fiber.dom = createDOM(fiber);}// create chilren fibersreconcileChildren(fiber, fiber.props.children);// return next unitOfWorkif (fiber.child) {return fiber.child;}let nextFiber = fiber;while (nextFiber) {if (nextFiber.sibling) {return nextFiber.sibling;}nextFiber = nextFiber.parent;}
}

commit阶段 :应用DOM操作

在 commit 阶段根据effectTag应用不同的DOM操作 。

  • "DELETION":移除要删除的DOM节点
  • "PLACEMENT":挂载新创建的DOM节点
  • "UPDATE":更新DOM节点属性
function commitRoot() {deletions.forEach(commitWork);commitWork(workInProgressRoot.child);currentRoot = workInProgressRoot;workInProgressRoot = null;
}function commitWork(fiber) {if (!fiber) {return;}const domParent = fiber.parent.dom;if (fiber.effectTag === "DELETION") {domParent.removeChild(fiber.dom)} else if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {domParent.appendChild(fiber.dom);} else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {updateDOM(fiber.dom, fiber.alternate.props, fiber.props);}commitWork(fiber.child);commitWork(fiber.sibling);
}const isProperty = (prop) => prop !== "children";
const isEvent = (prop) => prop.startsWith("on");
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (_prev, next) => (key) => !key in next;
function updateDOM(dom, prevProps, nextProps) {Object.keys(prevProps).forEach((name) => {if (isEvent(name) && (!(name in prevProps) || isNew(name))) {dom.removeEventListener(name.toLowerCase().substring(2), prevProps[name]);}if (isProperty(name) && isGone(name)) {dom[name] = "";}});Object.keys(nextProps).forEach((name) => {if (isEvent(name) && isNew(name)) {dom.addEventListener(name.toLowerCase().substring(2), nextProps[name]);}if (isProperty(name) && isNew(name)) {dom[name] = nextProps[name];}});
}

支持函数组件

函数组件和原生元素的区别在于:

  1. ReactElement 对象的type值是组件的定义函数,执行定义函数返回子 ReactElement 对象。因此在performUnitOfWork中无需创建 DOM 节点,并且需要调用定义函数获得子代。
  2. 函数组件对应一个 Fiber 节点,但其没有对应的 DOM 节点。因此在 commit 阶段进行DOM操作需要找到真正的父子节点。
function performUnitOfWork(fiber) {if (fiber.type instanceof Function) {updateFunctionComponent(fiber);} else {updateHostComponent(fiber);}
}// 更新函数组件
function updateFunctionComponent(fiber) {// 调用组件定义函数,获取子ReactElement对象const children = [fiber.type(fiber.props)];reconcileChildren(fiber, children);
}// 更新原生元素
function updateHostComponent(fiber) {// create domif (!fiber.dom) {fiber.dom = createDOM(fiber);}reconcileChildren(fiber, fiber.props.children);
}function commitWork(fiber) {if (!fiber) {return;}let parentFiber = fiber.parent;// 插入和更新操作需要找到真正的父dom节点 while (parentFiber.dom === null) {parentFiber = parentFiber.parent;}const domParent = parentFiber.dom;if (fiber.effectTag === "DELETION") {commitDeletion(domParent, fiber);} else if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {domParent.appendChild(fiber.dom);} else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {updateDOM(fiber.dom, fiber.alternate.props, fiber.props);}commitWork(fiber.child);commitWork(fiber.sibling);
}function commitDeletion(domParent, fiber) {// 删除操作需要找到真正的子dom节点if (fiber.dom) {domParent.removeChild(fiber.dom);} else {commitDeletion(domParent, fiber.child);}
}

支持Hooks

全局变量workInProgressFiber存储当前正在处理的 Fiber 节点,以供useState访问。

为了支持在一个组件中多次使用useState,hooks 作为队列在 Fiber 节点中维护。全局变量hookIndex维持useState执行顺序和hook的关系。

Fiber {hooks: [  //  hook按调用顺序存放{state,queue: [action]   // 任务分片执行,在未处理到当前节点前。更改状态将重新执行渲染流程,需要保留未生效的修改 }]
}
let workInProgressFiber = null;
let hookIndex = null;function updateFunctionComponent(fiber) {workInProgressFiber = fiber;hookIndex = 0;workInProgressFiber.hooks = [];const children = [fiber.type(fiber.props)];reconcileChildren(fiber, children);
}function useState(initial) {const oldHook =workInProgressFiber.alternate &&workInProgressFiber.alternate.hooks &&workInProgressFiber.alternate.hooks[hookIndex];// 根据老节点的hook确定初始状态const hook = {state: oldHook ? oldHook.state : initial,queue: [],};// 应用状态更新if (oldHook) {oldHook.queue.forEach((action) => {hook.state = action(hook.state);});}const setState = (action) => {// 加入更新队列,在下一次渲染流程中应用。// 开启渲染流程hook.queue.push(action);deletions = [];workInProgressRoot = {dom: currentRoot.dom,props: currentRoot.props,alternate: currentRoot,};nextOfUnitWork = workInProgressRoot;};workInProgressFiber.hooks.push(hook);hookIndex++;return [hook.state, setState];
}

后记

React的功能和优化并没有完全在上述过程中实现,包括:

  1. 在render阶段,我们遍历了整棵Fiber树。而在React中使用启发式算法跳过未修改的子树
  2. 在commit阶段,我们同样遍历了整棵Fiber树。而在React中则是依赖Effect List存储有修改的Fiber,避免对 Fiber树的再次遍历
  3. 在处理单位任务时,我们会为workInProgress树创建新的Fiber节点 。而在React中会重复使用current树中的老节点
  4. 我们在render阶段接收到新的状态会重新开始渲染流程。而在React中会为每个更新标记一个expiration timestamp,比较更新的优先级。

同时,你也可以自行添加一些功能,比如:

  1. 支持style prop 的对象定义
  2. 支持列表元素
  3. 实现useEffect
  4. 在协调过程中支持key标识

https://pomb.us/build-your-own-react/​pomb.us

跟随原文动手实现一遍,对React的大致工作流程会有更深刻的理解。同时,对React优化的历程和出发点也有一些体会,不仅仅知道它是怎么做的,还有它为什么要这么做。另外,动手实现的乐趣和成就感是无可替代的。

所以,快跟着原文实现一遍吧。

react 遍历对象_探索:跟随《Build your own React》实现一个简易React相关推荐

  1. 使用 React 遍历对象

    今天使用React完成一个小案例,使用react把数据渲染到页面,效果如下 首先,既然要是要使用react遍历对象吗,那我们就得引入react的相关插件引入,并且把我们要渲染到页面的data.js数据 ...

  2. react遍历对象的值_React 原理之实现 createElement 和 render 方法

    前言 在 React 中,我们都知道可以写 jsx 代码会被编译成真正的 DOM 插入到要显示的页面上.这具体是怎么实现的,今天我们就自己动手做一下. 实现 createElement 方法 这个方法 ...

  3. react 遍历对象_React 源码系列 | React Children 详解

    本文基于 React V16.8.6,本文代码地址 测试代码 源码讲解 React 中一个元素可能有 0 个.1 个或者多个直接子元素,React 导出的 Children 中包含 5 个处理子元素的 ...

  4. react 遍历对象_React 和 Vue 之间的相爱相杀

    React 和 Vue 应该是国内当下最火热的前端框架,当然 Angular 也是一个不错的框架,但是这个产品国内使用的人很少再加上我对 Angular 也不怎么熟悉,所以就不在这篇文章中做对比了. ...

  5. react 组件构建_让我们用100行JavaScript构建一个React Chat Room组件

    react 组件构建 by Kevin Hsu 通过徐凯文 让我们用100行JavaScript构建一个React Chat Room组件 (Let's Build a React Chat Room ...

  6. react sql格式化_为SQL Server数据库损坏做准备; 初步React与分析

    react sql格式化 Corruption is a looming thought through every administrator's mind, from sysadmins to d ...

  7. php object 对象不存在。增加对象_《相亲者女》:找一个匹配的对象,但永远不存在...

    剩女恨嫁.父母催婚.真爱难求--这些都是时下被深度评议的社会现象.4月13日,香港青年文学奖得主.作家周婉京携新书<相亲者女>来到上海雍福会,与编剧傅踢踢.设计师朱砂及众读者共同分享了一次 ...

  8. matlab中GUI的属性检查器中的XLimMode是什么_如何在Matlab中使用GUI做一个简易音乐播放器? ---- (二)GUIDE...

    咕咕怪由于昨天有重要的事情所以咕了一天的文章 (感觉写得挺基础的,对各个部分有一定了解的童鞋可以直接跳过了解的部分 用Matlab做一个app有几种办法呢? 同样的,帮助文档告诉了我们答案:三种. 英 ...

  9. matlab figure函数_如何在Matlab中使用GUI做一个简易音乐播放器? ---- (六)控件间的数据传递...

    我纠结了两个星期是否要写这一章-最后决定还是要写一章收尾,来解释其中的控件间的数据传递问题. 在前五篇中,如果有童鞋跟上了我的思路或者做完了这样一个gui,会发现还有一个一直避开的遗留问题,就是将歌曲 ...

最新文章

  1. Java网络编程笔记1
  2. C语言估算数学常量e,c语言常量的正确表示方法有哪些
  3. 前端图片有时候能显示有时候不显示_如何自动搞定全站图片的alt属性?
  4. 深入浅出设计模式——组合模式(Composite Pattern)
  5. Microsoft Virtual Lab Use Guide
  6. leetcode 35. 搜索插入位置(二分法搜索失败的情况)
  7. ISA 发布内网 NLB
  8. 网站优化众说纷纭 往左走还是往右走?
  9. AutoJS4.1.0实战教程 ---番茄免费小说
  10. Win10 如何修改C:\Users\下的用户名
  11. Vue-amap 实现获取定位功能
  12. 人类已经无法阻止苹果了——吐槽PC厂商
  13. yii2安装 报错fxp/composer-asset-plugin
  14. 【Linux】Linux 下socket 编程
  15. CentOS Netcat 用法
  16. 什么是状态机(Finite-state machine)?
  17. 【深度学习】入门之keras
  18. 入职8年程序员被怼!网友:不懂业务,滚犊子!
  19. usb禁止重定向_谈USB重定向的方式
  20. Java之Lists.Partition项目中的使用

热门文章

  1. 小狗扫地机器人与石头_当戴森遇到石头机器人,从容应对 “猫狗拆家”
  2. 单例模式应用场景_【简易设计模式04】单例模式
  3. python做大型网站_Python中的大型Web应用:一个好的架构
  4. HDFS上传文件报错java.lang.InterruptedException
  5. 20个安全可靠的免费数据源,各领域数据任你挑
  6. 腾讯JAVA岗位四面,腾讯Java社招四面面经分享(4年java经验者)
  7. 计算机主机内置的地址码被称为,2016年职称计算机考试WPS_Office单选练习试题1
  8. linux libfcmain.so,BabyLinux制作过程详解
  9. linq php,C#开始使用 LINQ (上)
  10. 深度学习-Tensorflow2.2-模型保存与恢复{9}-保存与恢复-21