crazylxr / blog

技术博客记录
http://www.taoweng.site
24 stars 1 forks source link

从 0 开始实现一个 fiber 架构的 React(一)--初次渲染 #16

Open crazylxr opened 4 years ago

crazylxr commented 4 years ago

从 0 写一个 React(一)

在阅读这篇文章之前,我希望你已经了解过 React 的 Fiber 架构,如果还不熟悉,请阅读我的这篇:Deep In React 之浅谈 React Fiber 架构(一)

准备工作

在环境搭建上我选择了 Parcel,因为它使用起来非常的简洁,配置少,使用起来方便。
首先通过 npm 安装 Parcel:

npm install -g parcel-bundler

创建一个项目目录并且初始化 package.json 文件:

mkdir react-like && cd react-like && npm init -y

接下来创建 index.html 和 index.js,在 index.html 里引入 index.js

了解 jsx 并实现虚拟 DOM

jsx 的本质

const title = <h1 className="title"><h2>fetaoyuan</h2><h2>taoweng</h2></h1>;

这样的一段 jsx 代码其实对于浏览器来说是一段不合法的 js 代码,本质上,jsx 是 js 的语法糖,比如上面的这段代码会被 babel 转成如下代码:

var title = React.createElement("h1", {
  className: "title"
}, 
React.createElement("h2", null, "fetaoyuan"), 
React.createElement("h2", null, "taoweng"));

你可以在这里进行在线转换查看转换后的代码

可以看出来转化的逻辑大概是这样:

React.createElement(type, props, child1, child2, child3)

实现 React.createElement

清楚了 babel 的转化逻辑,接下来就来实现以下吧。

babel 配置

首先配置一下 .babelrc:

{
    "presets": ["@babel/env"],
    "plugins": [
        ["@babel/transform-react-jsx", {
            "pragma": "React.createElement"
        }]
    ]
}

接下来在 index.js 里写一行代码看是否成功。

document.write('前端桃园')

然后让项目跑起来:

parcel index.html

parcel 是一个非常智能的工具,不需要你去安装 babel 相关的包,会根据你的配置,自动的去安装相关的包,在平时的玩具里面用,还是非常方便的。

然后访问 localhost:1234 就可以看到屏幕输出了前端桃园了。

createElement

我们知道在 React 里,children 是作为 props 里面的一个属性,这根 jsx 转化出来的不一样。知道了 babel 转化 jsx 的规则,我们要实现 createElement 就非常的简单了,只需要利用 ES6 的 rest 参数,就可以非常容易的拿到所有的 children。

function createElement(type, config, ...children) {
    return {
    type,
    props: {
        ...config,
      children
    }
  }
}

接下来在进行调试一下:

// index.js
const React = {
    createElement
}

function createElement(type, config, ...children) {
    return {
    type,
    props: {
        ...config,
        children
    }
  }
}

const title = <h1 className="title"><h2>fetaoyuan</h2><h2>taoweng</h2></h1>;

console.log(title)

输出的结果如下,是符合我们的期望的。
image.png
实际上这个输出出来的,通过 createElement 方法返回的对象记录了这个 DOM 节点我们需要的信息,这个对象就被称为虚拟DOM。

初次渲染

在了解 fiber 架构之后,你就应该知道 fiber 是如何工作的,在初次渲染的时候:

  1. 生成虚拟 DOM
  2. 根据虚拟 DOM 生成 Fiber(这里需要用到并发模式)
  3. 生成 EffectList
  4. 根据 EffectList 更新 DOM(commit阶段)

第一步生成虚拟 DOM 上面已经完成了,接下来了解如何通过并发模式来生成 Fiber。

并发模式

理想情况下,我们应该把 render 拆成更细分的单元,每完成一个单元的工作,允许浏览器打断渲染响应更高优先级的工作,这个过程称为"并发模式(Concurrent Mode)"。

这里用 requestIdleCallback 这个浏览器 API 来实现,这个 API 可以在线程空闲的时候去执行回调函数(执行我们的工作单元)。

由于兼容性的问题,React 目前没有使用这个 API,而是为了这个效果,自己实现了一套方案,但核心思路是类似的。

大致的代码如下:

let nextUnitOfWork; // 下一个执行单元

function workLoop(deadline) {
    while(nextUnitOfWork) {
        nextUnitOfWork = performUnitWork(nextUnitOfWork)
    }
}

function performUnitWork(currentFiber) {
    // TODO, 执行单元
}

requestIdleCallback(workLoop)

全局遍历 nextUnitOfWork 为下一个执行单元,是一个 Fiber 结构。
我们要知道架构改为 fiber 的一个大的特征就是将结构改为了链表,链表的遍历就是一个一个的, performUnitWork 函数就是执行当前的 Fiber,然后返回下一个 Fiber,这样遍历整棵树。
但是目前的代码是有问题的,因为没有被打断的逻辑,那咱们再加上被打断的逻辑。

let nextUnitOfWork; // 下一个执行单元

// deadline 是还有多少的空闲时间
function workLoop(deadline) {
    let shouldYield = false;

    while(nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitWork(nextUnitOfWork)
        // 回调函数入参 deadline 可以告诉我们在这个渲染周期还剩多少时间可用
        // 剩余时间小于1毫秒就被打断,等待浏览器再次空闲
        shouldYield = deadline.timeRemaining() < 1;
    }

    requestIdleCallback(workLoop);
}

function performUnitWork(currentFiber) {
    // TODO, 执行单元
}

requestIdleCallback(workLoop)

打断的逻辑就在 shouldYield = deadline.timeRemaining() < 1 这行代码里,如果时间片小于 1 毫秒,就被打断,等待浏览器下次空闲的时候再执行。

有没有忽然觉得如此高大上的概念(并发模式),其实原理很简单。

合理拆分文件

为了便于理解,现在将文件进行拆分一下,将 React.xxx 的 API 放到 react.js 里。

另外我们都知道 react 要进行渲染需要有个 render 函数,这个是在 ReactDOM 下面的 API,所以再建一个 react-dom.js 用来放 render 函数。

对于刚才我们所写的并发模式相关的代码,放到 schedule.js 里。

另外再增加一个 constants.js 的常量文件,用来存放一些特殊常量。

所以现在就有 6 个文件, index.html 、 index.js 、 react.js 、 react-dom.js 、 schedule.js 、constants.js

index.html 里需要添加一个 react 挂载的节点。

<body>
    <div id="root"></div>
    <script src="./index.js"></script>
</body>

index.js 需要导入 React 和 ReactDOM ,然后调用 render 函数进行渲染。

// index.js
import React from "./react.js";
import ReactDOM from "./react-dom";

const title = (
  <h1 className="title">
    <h2>fetaoyuan</h2>
    <h2>taoweng</h2>
  </h1>
);

ReactDOM.render(title, document.getElementById("root"));

createElement 放到 react.js 里,进行简单的改造,并且创建 constants.js 。

import { ELEMENT_TEXT } from "./constants";

const React = {
  createElement,
};

function createElement(type, config, ...children) {
  return {
    type,
    props: {
      ...config,
      children: children.map((child) => {
        if (typeof child === "object") {
          return child;
        } else {
          return {
            type: ELEMENT_TEXT,
            props: {
              text: child,
              children: [],
            },
          };
        }
      }),
    },
  };
}

export default React;

改造的点主要是针对文本节点,如果是文本节点的时候返回一个跟正常的虚拟 DOM 节点一样的结构,而不是直接返回文本,这样做的目的是为了后面方便统一处理。

tip:react 里并没有做这一步,而是直接返回的文本。

constants.js 里存放着节点的一些类型。

// constants.js

// 虚拟DOM 节点类型
export const ELEMENT_TEXT = Symbol.for('ELEMENT_TEXT');
// Fiber 的类型
export const TAG_ROOT = Symbol.for('TAG_ROOT'); // 根节点
export const TAG_HOST = Symbol.for('TAG_HOST'); // host 节点
export const TAG_TEXT = Symbol.for('TAG_TEXT'); // 文本节点

// effect 类型
export const PLACEMENT = Symbol.for('PLACEMENT'); // 增加元素

react-dom.js 的 render 函数写成这样:

// react-dom.js
import { TAG_ROOT } from './constants'
import { scheduleRoot } from "./schedule";

function render(element, container) {
    let rootFiber = {
        tag: TAG_ROOT,
        stateNode: container,
        props: { children: [element] }
    }

    scheduleRoot(rootFiber)

    return rootFiber
}

export default { render }

新建一个 rootFiber 的 fiber,然后通过 scheduleRoot 进行去调度。
schedule.js 目前就是这样:

let nextUnitOfWork; // 下一个执行单元

export function scheduleRoot(rootFiber) {
    nextUnitOfWork = rootFiber
}

// deadline 是还有多少的空闲时间
function workLoop(deadline) {
    let shouldYield = false;

    while(nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitWork(nextUnitOfWork)
        // 回调函数入参 deadline 可以告诉我们在这个渲染周期还剩多少时间可用
        // 剩余时间小于1毫秒就被打断,等待浏览器再次空闲
        shouldYield = deadline.timeRemaining() < 1;
    }

    requestIdleCallback(workLoop);
}

function performUnitWork(currentFiber) {
    // TODO, 执行单元
}

requestIdleCallback(workLoop)

scheduleRoot 所要做的事情就是将 nextUnitOfWork 赋值为 rootFiber ,这样 requestIdleCallback 调用的时候 workLoop 里才有值。

构建 fiber list

遍历整棵树

**performUnitOfWork** 是如何去遍历整棵树的逻辑的函数,同时也会返回下一个要完成的 fiber。
Fiber 架构遍历是采用的深度优先遍历,会先遍历子节点,如果子节点没有,再遍历兄弟节点,如果没有兄弟节点,就返回到父节点。

TODO:这里应该把 react 如何遍历一棵树的原理讲出来。

所以 performUnitOfWork 的代码如下:

// schedule.js
function performUnitWork(currentFiber) {
    // 把子元素变成子 fiber
    beginWork(currentFiber)

    // 如果有子节点就返回以第一个子节点
    if(currentFiber.child) {
        return currentFiber.child
    }

    while (currentFiber) {
      // 没有子节点就代表当前节点已经完成了调和工作,
      // 就可以结束 fiber 的调和,进入收集副作用的步骤(completeUnitOfWork)
      completeUnitOfWork(currentFiber);
      if (currentFiber.sibling) {
        return currentFiber.sibling;
      }

      currentFiber = currentFiber.return;
    }
}

// complete的工作就是收集副作用
function completeUnitOfWork(currentFiber) {}

Fiber 的结构

type Fiber = {
  //标记不同的组件类型
    tag: WorkTag,
  // ReactElement.type,也就是我们调用`createElement`的第一个参数
  elementType: any, 
  // 跟当前Fiber相关本地状态(比如浏览器环境就是DOM节点)
  stateNode: any,
  // 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
  return: Fiber | null,
  // 新的变动带来的新的props
  pendingProps: any, 
  // 上一次渲染完成之后的props
  memoizedProps: any,

  // 单链表树结构
  // 指向自己的第一个子节点
  child: Fiber | null,
  // 指向自己的兄弟结构
  // 兄弟节点的return指向同一个父节点
  sibling: Fiber | null,

  // Effect
  // 用来记录Side Effect
  effectTag: SideEffectTag,
  // 单链表用来快速查找下一个side effect
  nextEffect: Fiber | null,

  // 子树中第一个side effect
  firstEffect: Fiber | null,
  // 子树中最后一个side effect
  lastEffect: Fiber | null,
}

如果你是了解 fiber 架构的,那么对于 Fiber 是这么一个结构应该不陌生。其中 tag  和 effectTag  放在 constans.js 里,具体的常量的值我这里保持跟 React 里一样,首次更新的也不多,所以 constans.js 增加的常量有:

// WorkTag
export const HostRoot = 3; // 根节点
export const HostComponent = 5; // 一般的 host 节点
export const HostText = 6; // 文本节点

// SideEffectTag
export const Placement = 0b00000000010;

将子元素变为子 Fiber

将子元素变为 fiber,首先需要判断当前 fiber 的 tag 类型,不同的类型有不同的策略。

function beginWork(currentFiber) {
    if (currentFiber.tag === HostRoot) {
      updateHostRoot(currentFiber);
    } else if (currentFiber.tag === HostText) {
        updateHostText(currentFiber)
    } else if(currentFiber.tag === HostComponent) {
        updateHostComponent(currentFiber);
    }
}

function updateHostRoot(currentFiber) {}

function updateHostText(currentFiber) {}

function updateHostComponent(currentFiber) {}

 接下来就是重点了,要实现一个 reconcileChildren 的函数,这个函数理论上就是 diff 的过程,但是由于首次渲染,没有 diff 的过程,就直接创建 fiber 了。

咱们先写根节点的时候的更新方法(updateHostRoot)吧。

function updateHostRoot(currentFiber) {
    // 拿到当前 fiber 的所有子节点,然后将所有子节点变为 fiber
    const children = currentFiber.props.children
    reconcileChildren(currentFiber, children)
}

接下来实现以下 reconcileChildren 这个函数。

function reconcileChildren(currentFiber, newChildren) {
    let newChildIndex = 0; // 新虚拟 DOM 数组索引
    let prevSibling; // 上一个兄弟节点

    // 循环虚拟DOM数组
    while(newChildIndex < newChildren.length) {
        let newChild = newChildren[newChildIndex]

        // 要根据不同的虚拟 DOM 类型,给到不同的 WorkTag
        let tag
        if(newChild.type === ELEMENT_TEXT) {
            tag = HostText
        } else if(typeof newChild.type === 'string') {
            tag = HostComponent
        }

        let newFiber = {
            tag,
            elementType: newChild.type,
            stateNode: null,
            return: currentFiber,
            pendingProps: newChild.props,
            effectTag: Placement, // 首次渲染,一定是增加,所以是 Placement
        }

        if (newFiber) {
          // 第一个会被当做父 fiber 的 child,其他的作为 child 的 sibling
          if (newChildIndex === 0) {
            currentFiber.child = newFiber;
          } else {
            prevSibling.sibling = newFiber;
          }
        }

        prevSibling = newFiber;
        newChildIndex++
    }
}

执行完 reconcileChildren 之后,所有的子节点都转化为了 fiber,不过还有一些属性没有添加上去,比如 stateNode 和 nextEffect 。

接下来继续完成 updateHostText 和 updateHostComponent 。
这两步需要进行 dom 的操作,所以先创建一个 dom.js 用来存放 dom 相关的操作。

// dom.js
// 文本节点直接创建 textNode,host 节点创建 element 之后再进行属性的赋值。
export function createDOM(currentFiber) {
    if(currentFiber.elementType === ELEMENT_TEXT) {
        return document.createTextNode(currentFiber.pendingProps.text)
    }

    const stateNode = document.createElement(currentFiber.elementType)
    setProps(stateNode, {}, currentFiber.pendingProps)
    return stateNode
}

// 除了 children 属性,其他的都作为 dom 的 Attribute
export function setProps(elem, oldProps, newProps) {
  for (let key in oldProps) {
    if (key !== "children") {
      if (newProps.hasOwnProperty(key)) {
        setProp(elem, key, newProps[key]);
      } else {
        elem.removeAttribute(key);
      }
    }
  }
  for (let key in newProps) {
    if (key !== "children") {
      setProp(elem, key, newProps[key]);
    }
  }
}

function setProp(dom, key, value) {
  if (/^on/.test(key)) {
    dom[key.toLowerCase()] = value;
  } else if (key === "style") {
    if (value) {
      for (let styleName in value) {
        if (value.hasOwnProperty(styleName)) {
          dom.style[styleName] = value[styleName];
        }
      }
    }
  } else {
    dom.setAttribute(key, value);
  }
  return dom;
}

关于 dom 操作就不多说了,这应该是基础,不算是 react 的核心。
updateHostText 和 updateHostComponent 的代码也不复杂,如下:

// schedule.js
function updateHostText(currentFiber) {
    if (!currentFiber.stateNode) {
        currentFiber.stateNode = createDOM(currentFiber);//先创建真实的DOM节点
    }
}

function updateHostComponent(currentFiber) {
    // 由于 fiber 里面是有 elementType 的,
    // 所以是可以根据elementType 来创建 dom 节点的,
    // 那么 stateNode 就可以先创建 
    if(!currentFiber.stateNode) {
        currentFiber.stateNode = createDOM(currentFiber)
    }

    const children = currentFiber.pendingProps.children
    reconcileChildren(currentFiber, children)
}

到这个时候,fiber list 基本构建完毕,如果在 updateHostRoot 的最后一行打印一下 currentFiber 应该就可以看到整个构建的 fiber 链表。
接下来就是完成 effectList 的构建。

构建 effect list

effect list 是在 completeUnitOfWork 函数里完成的,具体代码如下:

function completeUnitOfWork(currentFiber) {
  const returnFiber = currentFiber.return;
  if (returnFiber) {
    if (!returnFiber.firstEffect) {
      returnFiber.firstEffect = currentFiber.firstEffect;
    }
    if (!!currentFiber.lastEffect) {
      if (!!returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber.firstEffect;
      }
      returnFiber.lastEffect = currentFiber.lastEffect;
    }

    const effectTag = currentFiber.effectTag;
    if (effectTag) {
      if (!!returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber;
      } else {
        returnFiber.firstEffect = currentFiber;
      }
      returnFiber.lastEffect = currentFiber;
    }
  }
}

commit effect list

构建完 effect list 了就可以开始 commit 了,构建完 effect list 的时机就是没有 nextUnitOfWork 了,就代表已经调和完毕了,到了下一个阶段:commit。

那么在 workLoop 就会有一个判断是否存在下一个执行单元,如果没有就进行提交阶段。

function workLoop(deadline) {
    let shouldYield = false;
    while (nextUnitOfWork && !shouldYield) {
        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);//执行一个任务并返回下一个任务
        shouldYield = deadline.timeRemaining() < 1;//如果剩余时间小于1毫秒就说明没有时间了,需要把控制权让给浏览器
    }
    //如果没有下一个执行单元了,并且当前渲染树存在,则进行提交阶段
    if (!nextUnitOfWork && workInProgressRoot) {
        commitRoot();
    }
    requestIdleCallback(workLoop);
}

我们在提交的时候就要拿到整颗 fiber 链表的头结点,但是之前的 nextUnitOfWork  已经为空了,所以还需要一个变量来存储当前正在渲染的根 fiber,这个 fiber 就是之前学到的 WorkInProgress Tree 。

所以就需要一个变量: workInProgressRoot 的遍历用来存储当前渲染的 fiber 树,并且在 scheduleRoot 的时候把根 fiber 赋值给它

let nextUnitOfWork; // 下一个执行单元
let workInProgressRoot; // 当前正在工作的树

export function scheduleRoot(rootFiber) {
    nextUnitOfWork = rootFiber
    workInProgressRoot = rootFiber
}

所以 commitRoot 就应该是这样:

function commitRoot() {
    let currentFiber = workInProgressRoot.firstEffect
    while(currentFiber) {
        commitWork(currentFiber)
        currentFiber = currentFiber.nextEffect
    }

    workInProgressRoot = null
}

function commitWork(currentFiber) {
    if(!currentFiber) {
        return;
    }

    let returnFiber = currentFiber.return;
    const domReturn = returnFiber.stateNode;

    if(currentFiber.effectTag === Placement && currentFiber.stateNode != null) {
        domReturn.append(currentFiber.stateNode)
    }

    currentFiber.effectTag = null
}

到此,就已经可以渲染出这样的效果了:
image.png
撒花,结束,接下来将实现元素的更新以及函数式组件,还有 hooks。

demo 代码在这里:https://github.com/crazylxr/luffy/tree/chapter1

参考资料

珠峰架构公开课