Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
crazylxr opened this issue Jun 3, 2020 · 0 comments
Open
Labels
react react 相关

Comments

@crazylxr
Copy link
Owner

crazylxr commented Jun 3, 2020

从 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)
  • 第一个参数是 DOM 节点的标签名,值类似 div,h1,span 这样的。
  • 第二个参数是一个对象,包含了标签里的所有属性,比如 className,id 等。
  • 从第三个参数开始,都是这个元素的子节点

实现 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

参考资料

珠峰架构公开课

@crazylxr crazylxr added the react react 相关 label Jun 3, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 相关
Projects
None yet
Development

No branches or pull requests

1 participant