实现一个简单的 react

Step 0: 简介

本文章从 createElement 函数讲起,然后一步一步完成了一个简单的 Fiber 、 Reconclie 、 commitRoot 等 react 的核心部分,最终实现一个 react。

如果你比较熟悉 react,dom,jsx 是如何工作的,可以跳过本节。
我们从最基本的vanilla JavaScript开始。

这是一段 jsx 语法:

const element = <h1 title="foo">Hello</h1>;
复制代码

jsx 会被类似于 Babel 之类的工具转换成为 js,上面的 jsx 会被转化为:

const element = React.createElement("h1", { title: "foo" }, "Hello");
复制代码

而一个 virtual element 应该是一个带有 type 和 props 的对象:

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
};
复制代码

其中的 type 即是document.createElement传入的 tagName,还有一种情况是它也可能是一个function,这个将在后面处理。
props 是另一个含有各种 key 和 value 的对象以及一个特殊的 property:children,children 可以是 string 也可以是一个 element 数组。

最后,还需要一个render函数,把 element 挂载到真实的 dom 上,render 大概是做这些事情,创建 node,加入 props,然后挂载 children:

const container = document.getElementById("root")
​
const node = document.createElement(element.type)
node["title"] = element.props.title
​
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
​
node.appendChild(text)
container.appendChild(node)

复制代码

Step 1: createElement

这里需要注意如果 children 是个 string 的话,应该是创建一个TEXT_ELEMENT

// createElement.ts

const createElement = (
  type: string,
  props: Partial<HTMLElement>,
  ...children: VElement[]
): VElement => {
  return {
    type,
    props: {
      ...props,
      children: children
        // 这里使用flat的原因是如果jsx里面包含map返回的一个element数组,就需要把children给拍平
        .flat()
        .map((c) => (typeof c === "object" ? c : createTextElement(c))),
    },
  };
};

const createTextElement = (text: string): VElement => {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  };
};
复制代码

Step 2: render

目前只考虑一种最基本的情况,即添加元素,后面会加入对 element 的更新和删除
添加的过程很简单,就是把 property 依次添加上,然后递归地 render children。

const render = (element: VElement, container: HTMLElement) => {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)
​
  // 排除 特殊属性 "children"
  const isProperty = key => key !== "children"

  // 将元素属性 一一 写入 dom 节点上
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

​  // 遍历递归 将 子元素 一个一个 都 附到 真实的 dom 节点上
  element.props.children.forEach(child =>
    render(child, dom)
  )
​
  // 最后挂载到 指定的 dom 节点容器上
  container.appendChild(dom)
}

复制代码

Step 3: Concurrent mode

对于上一步实现的 render,实际上是有点问题的,就是在于上面的那个递归,只要开始了 render,那么直到它完成,整个 render 才会结束,如果 element tree 比较庞大,render 过程中可能会阻塞浏览器进程很长时间,就会影响浏览器更高优先级的事务处理(比如用户的输入和 ui 交互等等)

所以我们需要把这个大的任务切割为多个小的工作单元,这样的话,如果浏览器有更高优先级的事务处理,我们就可以中断 react 元素的渲染,这我们引入一个概念,称它为 并发模式

在 Concurrent 模式中,React 可以 同时 更新多个状态 —— 就像分支可以让不同的团队成员独立地工作一样:
对于 CPU-bound 的更新 (例如创建新的 DOM 节点和运行组件中的代码),并发意味着一个更急迫的更新可以“中断”已经开始的渲染。
对于 IO-bound 的更新 (例如从网络加载代码或数据),并发意味着 React 甚至可以在全部数据到达之前就在内存中开始渲染,然后跳过令人不愉快的空白加载状态。

我们可以用requestIdleCallback来实现此功能。React 目前不再使用requestIdleCallback,而是scheduler package,不过从概念上来说这两个是一样的。


let nextUnitOfWork = nullfunction workLoop(deadline) {
 // 是否要暂停
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    // 执行 一个工作单元 并返回下一个工作单元
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    // 判断空闲时间是否足够
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}
​
requestIdleCallback(workLoop)
​
function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

复制代码

Step 4: Fibers

假设 render 的 tree 是这样:

render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
);
复制代码

那么最开始,我们会将 root fiber 设置为上面的nextUnitOfWork,剩下的事情就会在performUnitOfWork中完成,主要是做三件事:

  1. 把 element 添加到 DOM 中
  2. 为 element 的每一个 child 创建 fiber
  3. 指定下一个 nextUnitOfWork

为了让每一个 fiber 能够方便的找到下一个 nextUnitOfWork,它本身应该包含它的第一个 child,下一个相邻的元素以及它的父亲,数据结构为:

interface VElement {
  type?: string | "TEXT_ELEMENT" | Function;
  props: HTMLProps;

  parent?: VElement | null;
  child?: VElement | null;
  sibling?: VElement | null;
}
interface HTMLProps extends Partial<Omit<HTMLElement | Text, "children">> {
  nodeValue?: string | null;
  children: VElement[];
}
复制代码

而遍历的时候采用的是深度优先遍历,即先从 root 到主 tree 的最后一个 first child,然后检查它是否有 sibling,当 siblings 遍历结束时再去遍历 parent 的 siblings…依次再 回到 root

const createDom = (fiber) => {
    const dom = fiber.type === 'TEXT_ELEMENT' ?
        document.createTextNode('') : document.createElement(fiber.type as string)
    return dom
}
const performUnitOfWork = (fiber)=> {
  // 创建一个 dom 元素,挂载到 fiber 的 dom 属性
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
​  // 添加 dom 到父元素上
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
​
  const elements = fiber.props.children
  let index = 0
  // 保存上一个 sibling fiber 结构
  let prevSibling = nullwhile (index < elements.length) {
    const element = elements[index]
​
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
​    // 第一个子元素 作为 child,其余的子元素都作为 sibling
    if (index === 0) {
      fiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }
​
    prevSibling = newFiber
    index++
  }
​  // 如果有 child fiber ,则返回 child
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber

  while (nextFiber) {
    // 如果有 sibling fiber,则返回 sibling
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    // 否则返回它的 parent fiber
    nextFiber = nextFiber.parent
  }
}
复制代码

Step 5: Render and Commit

目前虽然是实现了可中断的 render 了,但是还有个问题就在它们是每次直接把 element 添加到 DOM 上,如果在这个过程中浏览器暂时中断了渲染的过程,那么就会呈现出不完整的 UI,这是非常不好的,在 react 官方文档中对 error boundary的处理不完整的 UI 有这样的评论

我们对这一决定有过一些争论,但根据我们的经验,把一个错误的 UI 留在那比完全移除它要更糟糕。例如,在类似 Messenger 的产品中,把一个异常的 UI 展示给用户可能会导致用户将信息错发给别人。同样,对于支付类应用而言,显示错误的金额也比不呈现任何内容更糟糕。

所以我们需要做出一些些改动,当所有工作单元执行完后,再一并将所有的 dom 的添加

export const workLoop = (deadline: TimeRemaining) => {
  let shouldYeild = false;
  while (!!nextUnitOfWork && !shouldYeild) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYeild = deadline.timeRemaining() < 1;
  }
  // 等到nextUnitOfWork做完之后再一并提交到 DOM
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
  window.requestIdleCallback(workLoop);
};

const commitRoot = () => {
  commitWork(wipRoot?.child!);
  wipRoot = null;
};
const commitWork = (fiber?: Fiber) => {
  if (!fiber) return;
  let domParentFiber = fiber.parent;
  while (!domParentFiber?.dom) {
    domParentFiber = domParentFiber?.parent;
  }
  const domParent = domParentFiber?.dom;
  domParent?.appendChild(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
};
复制代码

Step 6: Reconciliation

之前处理的都是向 DOM 之中添加 elements,其实还有两种操作:updatedelete,所以在 commit 结束之后,需要保存当前的 fiber tree (用currentRoot来保存),等到下次 render 时与 wipRoot(work in progress)的 fiber 树进行比较,同时在 wipRoot 中增加一个 alternate 属性来连接旧的 fiber 树。

同时在每次 commit 的时候执行加入 deletions 列表,然后在重新 render 时重置 deletions

const commitRoot = () => {
  deletions?.forEach((d) => commitWork(d));
  commitWork(wipRoot?.child!);
  // 保存当前的tree
  currentRoot = wipRoot;
  wipRoot = null;
};
export const render = (element: VElement, container: HTMLElement) => {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  };
  // 重置deletions
  deletions = [];
  nextUnitOfWork = wipRoot;
};
复制代码

对于performUnitOfWork,我们把对 fiber 的遍历放到 reconcile 中

const performUnitOfWork = (fiber: VElement) => {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  const elements = fiber.props.children;
  // 在这里操作fiber
  reconcileChildren(fiber, elements);

  if (fiber.child) return fiber.child;

  let nextFiber = fiber;
  // if sibling exists,return;else return parent
  while (nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling;
    nextFiber = nextFiber.parent!;
  }
  return null;
};

const reconcileChildren = (fiber: Fiber, elements: VElement[]) => {
  if (!fiber) return;
  let index = 0;
  // 获取到旧fiber
  let oldFiber = fiber.alternate?.child;
  let preSibling: VElement | null = null;

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

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

    let newFiber: VElement | null = null;
    if (sameType) {
      // update
      newFiber = {
        type: oldFiber!.type,
        props: element.props,
        // 使用之前的dom,只需要更新props
        dom: oldFiber?.dom,
        parent: fiber,
        alternate: oldFiber,
        // effectTag将在commit阶段使用
        effectTag: "UPDATE",
      };
    } else if (!sameType && element) {
      // add
      newFiber = {
        type: element.type,
        props: element.props,
        // 需要创建一个新的dom
        dom: null,
        parent: fiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    } else {
      // remove
      oldFiber!.effectTag = "DELETION";
      // 收集deletions,在commitRoot时统一移除
      deletions!.push(oldFiber!);
    }
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }
    if (index === 0) {
      fiber.child = newFiber;
    } else {
      preSibling!.sibling = newFiber;
    }
    preSibling = newFiber;
    index++;
  }
};
复制代码

然后,在 commit 阶段来处理之前加上的effectTags,其中的 UPDATE 即为更新当前 fiber 的 props,包括简单属性的更新(setProperty)以及以 on 开头的事件的更新(addEventListener)

const commitWork = (fiber?: Fiber) => {
  if (!fiber) return;
  let domParentFiber = fiber.parent;
  while (!domParentFiber?.dom) {
    domParentFiber = domParentFiber?.parent;
  }
  const domParent = domParentFiber?.dom;
  if (fiber.effectTag === "PLACEMENT" && !!fiber.dom) {
    // 如果是添加,就直接添加
    domParent?.appendChild(fiber.dom);
  } else if (fiber.effectTag === "UPDATE" && !!fiber.dom) {
    // update fiber
    updateDom(fiber.dom, fiber.alternate?.props!, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    let child = fiber;
    while (!child?.dom) {
      child = fiber.child!;
    }
    domParent?.removeChild(child.dom!);
  }
  // 继续commit child 和 sibling
  commitWork(fiber.child);
  commitWork(fiber.sibling);
};

const isNew = (prev: HTMLProps | {}, next: HTMLProps) => (key: string) =>
  prev[key] !== next[key];
const isGone = (prev: HTMLProps | {}, next: HTMLProps) => (key: string) =>
  !(key in next);
const isEvent = (key: string) => key.startsWith("on");
const isProperty = (key: string) => key !== "children" && !isEvent(key);

export const updateDom = (
  dom: HTMLElement | Text,
  prevProps: HTMLProps | {},
  nextProps: HTMLProps
) => {
  // 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];
    });
  //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]);
    });
  // 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]);
    });
};
复制代码

Step 7: Function Component & useState

如果我们的例子是这样:

import { createElement } from "./react/createElement";

/** @jsx createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>;
}
const element = <App name="foo" />;
const container = document.getElementById("root");
render(element, container);
复制代码

再转译之后变成 js 将会是这样:

function App(props) {
  return createElement("h1", null, "Hi ", props.name);
}
const element = createElement(App, {
  name: "foo",
});
复制代码

所以在执行 reconcile 之前,应该检查一下 fiber 的 type 是否是 Function,如果的话 fiber 的 children 应该通过调用 fiber.type来获得:

let wipFiber: VElement;
let hookIndex: number = -1;
const isFucComponent = fiber.type instanceof Function;
if (isFucComponent) {
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];
  const children = [(fiber.type as Function)(fiber.props)];
  reconcileChildren(fiber, children);
} else {
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements);
}
复制代码

关于 useState 的实现,我们用一个数组来存 hooks,同时用一个 hookIndex 来追踪状态,每一个 hook 中保存着前一个 state 和一个更新 state 的函数列表,然后调用 setState 时指定 nextUnitOfWork 触发 actions 更新 state。

export const useState = <T>(initial: T): [T, Function] => {
  const oldHook = wipFiber?.alternate?.hooks![hookIndex];
  const hook: { state: T; queue: Function[] } = {
    state: oldHook ? oldHook.state : initial,
    // 存放每次更新状态的队列
    queue: [],
  };
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach(
    (action) =>
      (hook.state = action instanceof Function ? action(hook.state) : action)
  );
  const setState = (action: Function) => {
    hook.queue.push(action);
    wipRoot = {
      dom: currentRoot?.dom,
      props: currentRoot?.props!,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };
  wipFiber.hooks?.push(hook);
  hookIndex++;

  return [hook.state, setState];
};
复制代码

小结

这个就是本文构建的 react,这里是源码在线 demo

为了便于理解 react 是如何工作的,这份代码用了和 react 源码里面一模一样的变量名和函数名,like:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

但是在有些地方和 react 本身的实现会有一些出入:

  • 在 render 和 commit 阶段,我们都会遍历整个 fiber tree,而 react 会选择性地跳过一些并没有发生变化的子树
  • 每一次我们构建一个新的work in progresstree 时,都会为每一个 fiber 创建一个新对象,而 react 会回收之前 tree 里面的一些 fiber
  • 在 render 阶段触发更新时,我们会丢弃掉整个work in progress tree,然后从 root 重新开始,而 react 会给每个更新打一个有过期时间的 tag,并且依赖它来确定哪一个更新具有更高的优先级

总的代码量也就 200 多行,但是对于了解 react 大体上是如何工作的还是有很大帮助的。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享