实现 mini React

1,040 阅读5分钟

回顾

要想创建一个React app,我们需要以下三行代码

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

在第一行中我们使用了用JSX语法定义了一个元素,JSX通过Babel等构建工具转换为JS。转换通常很简单,将标签名、属性和子元素作为参数传递给createElement函数。

(1)

const element = <h1 title="foo">Hello</h1>

等价于

const element = React.createElement(
  "h1", //标签名
  { title: "foo" }, //元素的属性值对象
  "Hello" //元素的子节点
)

(2)

const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)

等价于

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)

1.createElement函数

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

createElement("div")将返回

{
    "type" : "div",
    "props":{"children" : []}
}

createElement("div",null,a,b)将返回

{
    "type" : "div",
    "props":{"children" : [a,b]}
}

子数组也可以包含字符串或数字等。所以我们将把所有不是对象的东西包装在它自己的元素中,并为它们创建一个特殊的类型:TEXT_ELEMENT。

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

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

在React中,不会在没有子元素的情况下包装原始值或创建空数组,但我们这样做是为了简化代码。

const MyReact = {
  createElement,
}
/** @jsx MyReact.createElement */ (当babel翻译JSX时,它将使用MyReact而不是React的。)
const element = MyReact.createElement(
  "div",
  { id: "foo" },
  MyReact.createElement("a", null, "bar"),
  MyReact.createElement("b")
)

2.Render函数

function render(element, container) {
  // 1.创建dom
  const dom =
    element.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);

  // 2.给dom赋值
  Object.keys(element.props)
    .filter((item) => item !== "children")
    .forEach((name) => {
      dom[name] = element.props[name];
    });
  // 3.递归子节点

  element.props.children.forEach((child) => render(child, dom));
  // 4.插入dom
  container.appendChild(dom);
}

上面render使用递归遍历的,这可能会有些问题。一旦render开始执行,直到dom树渲染完成之后,render函数才会结束。万一dom树非常大,可能会阻塞主线程太长时间。如果浏览器需要处理用户输入或平滑动画等高优先级工作,则必须等到渲染完成才会去处理。

3.并行模式

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

这里我们用 requestIdleCallback 这个浏览器 API 来实现。这个 API 有点类似 setTimeout,不过不是我们告诉浏览器什么时候执行回调函数,而是浏览器在线程空闲(idle)的时侯主动执行回调函数。

React 目前已经不用这个API了,因为requestIdleCallback存在一些问题(执行频率不够实现流畅的UI渲染,兼容性等等)。React现在使用调度器/scheduler 这个包,自己实现调度算法。但它们核心思路是类似的,为了实现方便本demo使用requestIdleCallback 足矣。

let nextUnitOfWork = null

function workLoop(deadline) {
  // 是否交出控制权
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    // 浏览器空闲时间小于1毫秒就停止,等待浏览器再次空闲
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

// 任务循环入口
requestIdleCallback(workLoop)

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

要开始这个循环,我们需要设置第一个工作单元,然后编写一个能执行工作并返回下一个工作单元的函数。performUnitOfWork

4.Fibers

为了分配工作单元,我们需要一个数据结构:FIber树。每一个元素都有一个fiber,每个fiber都对应一个工作单元。

  • 每个元素都会有指向其父元素的属性(根元素除外)
  • 每个元素都会有指向其第一个子元素的属性(若有儿子)
  • 每个元素都会有指向与其相邻的兄弟元素的属性(若有兄弟)

此数据结构的目标之一是快速找到下一个工作单元。每个fiber都链接到它的第一个子节点、它的下一个兄弟节点和它的父节点。

当一个fiber完成工作时,如果它有一个子节点,子节点将成为下一个工作单元。

如果一个fiber没有子节点也没有兄弟节点,则返回它的父节点。直到找到根节点,这意味着我们已经完成了执行此渲染的所有工作

 <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>

image.png 在渲染中,我们将创建root fiber并将其设置为第一个工作。其余的工作将在performUnitOfWork函数上进行,在每个fiber上,我们将做三件事:

  1. 将元素添加到DOM
  2. 为子元素创建fiber
  3. 进入下一个工作单元

重构之前代码

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
}

function render(element, container) {
  // TODO set next unit of work
}

let nextUnitOfWork = null

render函数中,我们将nextUnitOfWork设置为fiber树的根节点。

function render(element, container) {
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    },
  }
}

然后,当浏览器准备就绪时,会调用我们的workLoop函数,在根节点上开始工作。

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

requestIdleCallback(workLoop)

// 打开代码编辑器看
function performUnitOfWork(fiber) {
  // TODO add dom node
  // TODO create new fibers
  // TODO return next unit of work
}

5.Render和Commit阶段

(1)上一节说到利用空闲时间执行每一个单元任务,为的是创建fiber模型,那最终整个fiber虚拟树构建完成,就应该进行真正的dom渲染了。

那问题来了,我们要怎么才能知道虚拟树构建完成,怎么才能知道要进行渲染工作了呢?

首先回顾fiber模型和performUnitOfWork函数,可以了解到,构建虚拟树的过程,类似DFS(深度优先遍历)。从根节点开始深度往下遍历,再回流遍历,直至重新回到根节点。由于根节点没有兄弟元素和父元素,即代表构建完成(performUnitOfWork()返回值为undefined)。

此时我们又要更改一下render函数,声明一个变量wipRoot来存储构建中的虚拟树的根节点。

(2)浏览器可能在我们完成整棵树上的工作前就将我们正在进行的工作打断。在这种情况下,用户会看见不完整的UI。 所以,我们需要删除对DOM进行变更的部分。

//删除performUnitOfWork中的以下部分
if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

相对的,我们需要保存fiber树的根结点,我们称它为progress root 或者 wipRoot。

let nextUnitOfWork = null
let wipRoot = null

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

当我们结束工作时(当没有下一个工作单元时就意味着结束工作),再把整棵fiber树一起提交到DOM上。

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

我们在commitRoot函数中实现这个功能,递归地把节点添加到DOM中。

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.Reconciliation

到此为止,我们实现了初始化渲染工作。那要是再次更新渲染呢,我们需要考虑更新dom和删除dom的操作。

首先,再次渲染我们并不需要完整构建新的dom树,我们可以把当前的虚拟树即将渲染的虚拟树进行对比,有更改或删除的元素我们才需要进行操作,尽量减少不必要的渲染。

因此我们需要变量来记录当前虚拟树,并且和即将渲染的虚拟树建立起联系

let nextUnitOfWork = null 
// 当前虚拟树根节点 
let currentRoot = null 
let wipRoot = null

function commitRoot() {
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

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

let currentRoot = null

从 performUnitOfWork中提取代码来创建新的fiber,提取到新的 reconcileChildren函数中

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  const elements = fiber.props.children
  // 新添加的方法
  reconcileChildren(fiber, elements)

  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

这里是将老fiber,和新的按位置顺序一一对比,

  • 相同位置的且元素类型相同,可以认为是update更新;
  • 相同位置但元素类型不同的,认为是add新增;
  • 相同位置老fiber有元素而新的没有这个元素,则是delete删除。
function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let oldFiber =wipFiber?.alternate?.child;
  let prevSibling = null;

  while (index < elements.length || oldFiber !== null) {
    const element = elements[index];
    let newFiber = null;

    const sameType = oldFiber && element && element.type === oldFiber.type;

    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}

由于需要删除的旧fiber不需要放回虚拟树上,所以单独用deletions数组变量存起来,后续渲染时遍历数组卸载对应的dom。

deletions还需要放到其他函数中去。

// 需要删除的节点 
let deletions = null
function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  // 重置需要删除的节点
  deletions = []
  nextUnitOfWork = wipRoot
}

然后,当我们提交到DOM时,也将用到这个数组中的fiber。

function commitRoot() {
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

现在,让我们修改 commitWork 函数,让它能够处理新的 effectTag 属性

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  //domParent.appendChild(fiber.dom)
  //修改部分
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

当fiber的 effectTag 为 PLACEMENT时,我们执行和以前一样的操作,将DOM节点添加到父节点DOM下。

  if (
    fiber.effectTag === "PLACEMENT" &&
    fiber.dom != null
  ) {
    domParent.appendChild(fiber.dom)
  }

如果是 DELETION, 我们做相反的操作,从父节点DOM下删除这个DOM节点。

  else if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom)
  }

当为 UPDATE 时,我们需要使用新的props来更新现有的DOM节点。

  else if (
    fiber.effectTag === "UPDATE" &&
    fiber.dom != null
  ) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  }

我们将在 updateDom 函数中实现具体的操作。

function updateDom(dom, prevProps, nextProps) {
  // TODO
}

我们将旧fiber和新fiber的props进行比对,删除不再使用的props,并设置新的或者发生改变的props。 props有两种类型,一种是on开头的事件,另一种是普通的属性。

const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (prev, next) => (key) => !(key in next);
function updateDom(dom, prevProps, nextProps) {
  //Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });

  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = "";
    });

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name];
    });

  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

7.Function Components

我们先回顾之前createElement的章节

// 1、编写jsx
const element = (
  <input value="todo" />
)

// 2、转换jsx
const element = createElement(
  'input',
  {
    value: 'todo'
  }
)

// 3、转换jsx得到对象
const element = {
  type: 'input',
  props: {
    value: 'todo',
    children: [],
  }
}

那如果编写的jsx是函数组件呢

function App(props) { return <h1>Hi {props.name}</h1> } 
const element = <App name="foo" />

按照之前的规则,它同样会被转换

function App(props) {
  return Didact.createElement(
    'h1',
    null,
    'Hi ',
    props.name
  )
}
const element = Didact.createElement(App, {
  name: 'foo',
})
const element = {
  type: App,
  props: {
    name: 'foo',
    children: [
      {
        type: 'h1',
        props: {
          children: [
            {
              type: 'TEXT_ELEMENT',
              props: { 'nodeValue': 'Hi ', 'children': [] }
            },
            {
              type: 'TEXT_ELEMENT',
              props: { 'nodeValue': 'foo', 'children': [] }
            }
          ]
        }
      }
    ]
  }
}

这里比较特殊的地方在于element的属性type不再是标签类型的字符串,而是函数。因此之前的代码又需要改动了。

function App() {
  return (
    <span>foo</span>
  )
}
const element = (
  <div id="root">
    <App />
  </div>
)

// fiber模型
div --> App --> span

// 最终渲染
<div>
  <span>foo</span>
</div>

基于这两点,需要对performUnitOfWork函数中部分代码重构。

function performUnitOfWork(fiber) {
 if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements);

  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}
function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function;
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
 ...
}
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}

目前,在commitWork中,是通过fiber.parent来获取DOM,但是函数式组件中的fiber没有DOM。

(1)首先,我们需要在沿着fiber树向上找,直到找到带有DOM节点的fiber

(2)其次,在删除节点时,需要找到一个具有DOM节点的子节点。

function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  // (1)修改
  const domParent = fiber.parent.dom;
  
  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);
  } else if (fiber.effectTag === "DELETION") {
   // (2)修改
    domParent.removeChild(fiber.dom);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

(1)

  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.dom;

(2)

 commitDeletion(fiber, domParent);
 function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}

8.Hooks

function useState(initial) {
// TODO
}

我们声明了两个变量wipFiberhookIndex,分别存储构建中的fiber和记录hook位置。

let wipFiber = null
let hookIndex = null

function updateFunctionComponent(fiber) {
    wipFiber = fiber
    hookIndex = 0
    wipFiber.hooks = []
    const children = [fiber.type(fiber.props)]
    reconcileChildren(fiber, children)
}
function useState(initial) {
    const oldHook = wipFiber?.alternate?.hooks[hookIndex]
    const hook = {
    state: oldHook ? oldHook.state : initial,
    }
    // fiber节点上存储hook状态
    wipFiber.hooks.push(hook)
    hookIndex++
    return [hook.state]
}

这里hook使用数组来存储状态,每使用一次hook,数组就前进一步。因此需要旧hook和新hook位置顺序一一对应得上,新hook才能够准确依赖旧hook的状态。因此也就很好理解了React为何需要约定使用hook的规则。

目前使用useState只返回了state,还需要补充一下setState

function useState(inital) {
  const oldHook = 
    wipFiber.alternate && 
    wipFiber.alternate.hooks && 
    wipFiber.alternate.hooks[hookIndex]
  
  const hook = {
    state: oldHook ? oldHook.state : inital,
    queue: [],
  }

  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    const isFunction = action instanceof Function
    // 更新state
    hook.state = isFunction ? action(hook.state) : action
  })

  const setState = action => {
    hook.queue.push(action)
    // 像render函数一样,触发虚拟树构建并渲染
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }

  // fiber节点上存储hook状态
  wipFiber.hooks.push(hook)

  // hook位置向前一步
  hookIndex++
  return [hook.state, setState]
}