[实践向] 从小白视角实现React的Fiber架构

写在前边

  • 创做本篇博客的初衷是,在浏览社区时发现了pomb.us/build-your-…这篇宝藏文章,该博主基于react16以后的fiber架构实现了一套react的简易版本,很是有助于理解react工做原理。可是苦于只有英文版本,且偏向理论。html

  • 本着提高自我、贡献社区的理念。在此记录下学习历程,并尽本身微薄之力对重点部分(结合本身理解)进行翻译整理。但愿对你们有所帮助。node

零、准备工做

  1. 建立项目(本身命名),下载文件包react

    $ mkdir xxx
    $ cd xxx
    $ yarn init -y / npm init -y
    $ yarn add react react-dom
    复制代码
  2. 创建以下目录结构npm

    - src/
     - myReact/
      - index.js
     - index.html
     - main.jsx
    复制代码
  3. 初始化文件内容json

    //index.html
    <!DOCTYPE html>
    <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>React App</title> </head> <body> <div id="root"></div> <script src="main.jsx"></script> </body> </html>
    
    
    // main.jsx
    import React from "react";
    import ReactDom from "react-dom";
    const App = () => {
        return <div title="oliver">Hello</div>;
    };
    ReactDom.render(<App />, document.getElementById("root"));
    
    // myReact/index.js
    export default {}
    复制代码
  4. 安装 parcel 用于打包和热更新数组

    $ yarn add parcel-bundler
    复制代码

1、createElement的功能

功不可没的babel

// main.jsx
const element = (
  <div id="foo"> <a>Hello</a> <span /> </div>
)
复制代码

通过babel转译后的效果(使用plugin-transform-react-jsx插件,www.babeljs.cn/docs/babel-…):浏览器

const element = React.createElement(
  "div",	//type
  { id: "foo" },	//config
  React.createElement("a", null, "bar"),	//...children
  React.createElement("span")
)
复制代码
  • babel的 plugin-transform-react-jsx 作的事情很简单: 使用 React.createElement 函数来从处理.jsx文件中的jsx语法。
  • 这也就是为何在.jsx文件中必须 import React from "react" 的缘由啦,不然插件会找不到React对象的!

配置babel

tips:笔者原本也打算使用 plugin-transform-react-jsx 插件,可是在调试中遇到了问题。查找后才知道最新版本的插件已经再也不是由 <h1>Hello World</h1>React.createElement('h1', null, 'Hello world') 的简单转换了(具体见zh-hans.reactjs.org/blog/2020/0…),故退而求其次选择了功能相似的 transform-jsxbash

$ touch .babelrc
$ yarn add babel@transform-jsx
复制代码
// .babelrc
{
    "presets": ["es2015"],
     "plugins": [
    [
      "transform-jsx",
      {
        "function": "React.createElement",
        "useVariables": true
      }
    ]
  ]
}
复制代码
$ parcel src/index.html
复制代码

此时页面中能够看到Hello字样,说明咱们配置成功了!babel

动手实现createElement

transform-jsx 插件会将参数封装在一个对象中,传入createElement。markdown

// myReact/index.js
export function createElement(args) {
  const { elementName, attributes, children } = args;
  return {
    type:elementName,
    props: {
      ...attributes,
      children
    }
  };
}
复制代码

考虑到children中还可能包含基本类型如string,number。为了简化操做咱们将这样的children统一使用 TEXT_ELEMENT 包裹。

// myReact/index.js
export function createElement(type, config, ...children) {
  return {
    type,
    props: {
      ...attributes,
      children: children.map((child) =>
                typeof child === "object" ? child : createTextElement(child)
            ),
    }
  };
}
function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}
export default { createElement }
复制代码

React并不会像此处这样处理基本类型节点,但咱们这里这样作:由于这样能够简化咱们的代码。毕竟这是一篇以功能而非细节为主的文章。

看看效果

首先为咱们本身的库起个名字吧!

// .babelrc
{
    "presets": ["es2015"],
     "plugins": [
    [
      "transform-jsx",
      {
        "function": "OllyReact.createElement",
        "useVariables": true
      }
    ]
  ]
}
复制代码

引入时就使用本身写的名字吧!

// main.jsx
import OllyReact from "./myReact/index";
import ReactDom from "react-dom"
const element = (
    <div style="background: salmon"> <h1>Hello World</h1> <h2 style="text-align:right">—Oliver</h2> </div>
);
ReactDom.render(element, document.getElementById("root"));
复制代码

此时页面上已经出现了Hello , 这证实咱们的React.createElement已经基本实现了React的功能。

2、Render功能

接下来编写render函数。

目前咱们只关注向DOM中添加内容。修改和删除功能将在后续添加。

// React/index.js
export function render(element, container) {}
export default {
  //...省略
  render
};
复制代码

细节实现

注意:

本小节每一步内容主要参考思路便可,详细的逻辑顺序会在底部汇总。

  • 首先使用对应的元素类型建立新DOM节点,并把该DOM节点加入股container中

    const dom = document.createElement(element.type)
    container.appendChild(dom)
    复制代码
  • 而后递归地为每一个child JSX元素执行相同的操做

    element.props.children.forEach(child =>
        render(child, dom)
      )
    复制代码
  • 考虑到TEXT节点须要特殊处理

    const dom =
        element.type == "TEXT_ELEMENT"
          ? document.createTextNode("")
          : document.createElement(element.type)
    复制代码
  • 最后将元素的props分配给真实DOM节点

    Object.keys(element.props)
            .filter(key => key !== "children")	// children属性要除去。
            .forEach(name => {
              dom[name] = element.props[name];
            });
    复制代码

汇总:

export function 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);
}
复制代码

看看效果

// main.jsx
import OllyReact from "./myReact/index";
const element = (
    <div style="background: salmon"> <h1>Hello World</h1> <h2 style="text-align:right">—Oliver</h2> </div>
);
OllyReact.render(element, document.getElementById("root"));
复制代码

此时看到咱们的render函数也能够正常工做了!

小结

就是这样!如今,咱们有了一个能够将JSX呈现到DOM的库(虽然它只支持原生DOM标签且不支持更新 QAQ)。

3、concurrent mode 并发模式

实际上,以上的递归调用是存在问题的。

  1. 这样的调用方式,一旦开始渲染,就不会中止,直到咱们渲染了完整的元素树。若是元素树很大,则可能会阻塞主线程太长时间。
  2. 即便浏览器须要执行诸如处理用户输入等高优先级的工做,也必须等待渲染完成。

所以React16的concurrent模式实现了一种异步可中断的工做方式。它将把工做分解成几个小单元,完成每一个单元后,若是须要执行其余任何操做,则让浏览器中断渲染。

workLoop

let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // todo
}
复制代码
  • 咱们用 requestIdleCallback 来作一个循环。能够将其requestIdleCallback视为一种异步任务,浏览器将在主线程空闲时运行回调,而不是告诉咱们什么时候运行。
  • requestIdleCallback还为咱们提供了截止日期参数。咱们可使用它来检查浏览器须要再次控制以前有多少时间。
  • 要开始使用循环,咱们须要设置第一个工做单元,而后编写一个performUnitOfWork 函数。要求它不只执行当前工做单元,而且要返回下一个工做单元。

4、Fiber

为了组织工做单元的结构,咱们须要一棵 Fiber 树。

Fiber的功能

  1. 静态数据结构(虚拟dom)
  2. 做为架构:链接父、子、兄弟节点
  3. 做为工做单元

Fiber Tree组织形式

  • 在render中建立一个 rootFiber 节点,并将它做为第一个 nextUnitOfWork(a instance of Fiber) 传入
  • performUnitOfWork 接受 nextUnitOfWork 做为参数并作三件事:
    1. 将对应的fiber节点添加到DOM
    2. 建立该fiber节点的子fiber节点
    3. 选中下个工做单元

这样的数据结构的目的就在于更方便地找到下个工做单元:

  1. 当前Fiber的工做执行完毕后,若是 fiber.child!==null ,则 fiber.child 节点将是下一个工做单元。
  2. 当前Fiber没有子节点,则 fiber.sibling!==null 的状况下, fiber.sibling 节点将是下一个工做单元。
  3. 当前Fiber节点 fiber.child===null && fiber.sibiling===null的状况下,fiber.parent 节点的 sibling 节点将是下一个工做单元。
  4. 回到rootFiber证实完成了render工做。

重构代码

// 将render方法中建立DOM元素的逻辑抽离出来
function createDom(fiber) {
  const dom =
    fiber.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(fiber.type)
  const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })
  return dom
}

// 在render节点中初始化rootFiber根节点
export function render(element, container) {
    nextUnitOfWork = {  //rootFiber
    dom: container,
    props: {
      children: [element]
    },
  }
}

function workLoop() {...}
function performUnitOfWork(){
    //todo
}
requestIdleCallback(workLoop)
复制代码

改造完成后而后,当浏览器准备就绪时,它将调用咱们workLoop,咱们将开始在根目录上工做。

performUnitOfWork

功能1
function performUnitOfWork() {
  //******** 功能1:建立dom ********
  if (!fiber.dom) {  //为fiber节点绑定dom
    fiber.dom = createDom(fiber);
  }
  if (fiber.parent) {   //若存在父节点,则挂载到父节点下
    fiber.parent.dom.appendChild(fiber.dom);
  }
}
复制代码
功能2
function performUnitOfWork() {
  ...
  //******** 功能2:为jsx元素的children建立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,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {  //第一个子fiber为children
      fiber.child = newFiber;
    } else {  //其余子fiber依次用sibling做链接
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}
复制代码
功能3
function performUnitOfWork() {
  ...
  //******** 功能3:返回下一个工做单元 ********
  if (fiber.child) return fiber.child;  //子节点存在,则返回子节点
  let nextFiber = fiber;
  while (nextFiber) {   //子节点不存在则查找兄弟节点 or 父节点的兄弟节点
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}
复制代码

5、render阶段 & commit阶段

这里咱们还有一个问题。

因为每次在处理fiber时,都会建立DOM并插入一个新节点。而且fiber架构下的渲染是可打断的。这就形成了用户有可能看到不完整的UI。这不是咱们想要的。

所以咱们须要删除插入dom的操做。

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  // if (fiber.parent) {
  // fiber.parent.dom.appendChild(fiber.dom)
  // }
  const elements = fiber.props.children
}
复制代码

相反地,咱们追踪 Fiber Tree 的根节点,称之为wipRoot

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  }
  nextUnitOfWork = wipRoot
}
复制代码

workLoop 完成后(不存在 nextUnitOfWork ),则使用 commitRootrenderer 提交整棵 Fiber 树。

function workLoop() {
    ...
    if (!nextUnitOfWork && wipRoot) {
    	commitRoot()
  	}
    ...
}
复制代码

使用commitWork来处理每个工做单元

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)
}
复制代码

6、Reconcilation 协调

到如今为止咱们只实现了添加DOM,那么如何更新或删除呢?

这就是咱们如今要作的:对比在render函数中接收的Fiber树与上一次提交的Fiber树的差别。

currentRoot

因此咱们须要一个指针,指向上一次的Fiber树,不如称之为 currentRoot

let currentRoot = null
function commitRoot() {
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}
复制代码

alternate

在每一个fiber节点数上,增长一个alternate属性,指向旧的fiber节点。

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}
复制代码

reconcileChildren

从performUnitOfWork中提取建立 Fiber 节点的代码,抽离成 reconcileChildren 方法。

在此方法中,咱们将新jsx元素与旧Fiber节点进行 diff

function reconcileChildren(fiber, elements) {
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    };
    if (index === 0) {  //第一个子fiber为children
      fiber.child = newFiber;
    } else {  //其余子fiber依次用sibling做链接
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}
复制代码

接下来是diff的详细过程,这里再也不赘述。

7、函数组件支持

目标:

import OllyReact from "./myReact/index";

const App = () => {
  const element = (
    <div style="background: salmon"> <h1>Hello World</h1> <h2 style="text-align:right">—Oliver</h2> </div>
  );
  return element;
};
OllyReact.render(<App/>, document.getElementById("root"));
复制代码

函数组件与原生组件的主要区别:

  1. Fiber 节点上 Fiber.dom 为null
  2. children 须要执行函数组件才能获得,而不是直接从props里获取

函数组件的特殊处理

function performUnitOfWork() {
      const isFunctionComponent =
      fiber.type instanceof Function
      if (isFunctionComponent) {
        updateFunctionComponent(fiber)
      } else {
        updateHostComponent(fiber)
      }
      ...
}
    
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)];	// 经过执行函数组件,得到jsx元素
  reconcileChildren(fiber, children);
}
    
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  reconcileChildren(fiber, fiber.props.children);
}
    
function commitWork() {
    ...
    let domParentFiber = fiber.parent;  //向上遍历,直到找到带有fiber.dom的父Fiber
    while (!domParentFiber.dom) {
      domParentFiber = domParentFiber.parent;
    }
    const domParent = domParentFiber.dom
}
    
function commitDeletion(fiber, domParent) { //在删除节点时,咱们还须要继续操做,直到找到带有DOM节点的子节点为止。
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}
复制代码

8、Hooks

经典的计数器

function Counter() {
  const [state, setState] = Didact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}> Count: {state} </h1>
  )
}
const element = <Counter />
复制代码

为Hook增长一些辅助变量吧

let wipFiber = null		//当前workInProgress Fiber节点
let hookIndex = null	//hooks下标

function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []	//为每一个fiber节点单独维护一个hooks数组
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}
复制代码

编写useState

function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
  
  const hook = {
    state: oldHook ? oldHook.state : initial,	//存在旧值则使用旧值,不然使用初始值。
    queue: []
  }
  
  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {	//遍历旧hooks.queue中的每一个action,依次执行
    hook.state = action(hook.state)
  })
  
  const setState = action => {
    hook.queue.push(action)
    wipRoot = {	// 切换fiber tree
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot	//从新设定nextUnitOfWork,触发更新。
    deletions = []
  }
  wipFiber.hooks.push(hook)	//向hooks中push进当前的useState调用
  hookIndex++	// hooks数组下标 +1 , 指针后移
  return [hook.state, setState]
}
复制代码

从本小节,咱们能够获得一些关于hooks的启发。

  1. 为何hooks不能写在 if 中?

    • 在本例中:由于每个hook都按照调用顺序被维护在fiber节点上的hooks数组中。若某个hooks在 if 语句中,则可能会打乱数组应有的顺序。这样会致使hook的对应出错。

    • 在react中:使用next指针将hook串联起来,这种状况下一样是不能容忍顺序的打乱的。

      type Hooks = { 
          memoizedState: any, // 指向当前渲染节点 Fiber 
          baseState: any, // 初始化 initialState, 已经每次 dispatch 以后 newState 
          baseUpdate: Update<any> | null,// 当前须要更新的 Update ,每次更新完以后,会赋值上一个 update,方便 react 在渲染错误的边缘,数据回溯 
          queue: UpdateQueue<any> | null,// UpdateQueue 经过 
          next: Hook | null, // link 到下一个 hooks,经过 next 串联每一 hooks 
      }
      复制代码
  2. capture Value特性

    • capture Value没什么特别的。它只是个闭包。

    • 每一次触发rerender,都是去从新执行了函数组件。则上次执行过的函数组件的词法环境应当被回收。可是因为useEffect等hooks中保存了该词法环境中的引用,造成了闭包,因此词法环境仍然会存在一段时间。

相关文章
相关标签/搜索