手写一个简化版的React

115 阅读8分钟

先来看看一个简单的React页面渲染

import React from "react";
import ReactDOM from "react-dom";

const element = (
  <div id="contentWrap">
    <a> a simple React Application </a>
    <p> React version -- 16.8 </p>
  </div>
);
const container = document.getElementById("root");
ReactDOM.render(element, container);

一个React应用简单概括可以分为三步:

  1. 创建JSX元素
  2. 获取应用挂载的根节点
  3. 将元素渲染到节点上--(ReactDom.render)

所以实现一个简易版本的React,看起来主要要实现如何将JSX转译ReactDOM.render这两个方法

React元素是被babel用React.createElement()方法进行一层转译,会被解析为如下代码:

"use strict";
const element = /*#__PURE__*/ React.createElement(
  "div",
  {
    id: "contentWrap",
  },
  /*#__PURE__*/ React.createElement("a", null, "a simple React Application"),
  /*#__PURE__*/ React.createElement("p", null, " React version -- 16.8 ")
);

React.createElement()会返回一个虚拟Dom结构,大致如下

const element = {
    type: 'div',
    props: {
        id: 'contentWrap',
        children: [
            {
                type: 'a',
                props: {
                    children: ['a simple React Application']
                }
            },
            {
                type: 'p',
                props: {
                    children: [' React version -- 16.8 ']
                }
            },
        ]
    }
}

实现自己的React.createElement()版本

type MyReactElement = {
  type: string | Function;
  props: {
    children: MyReactElement[];
    [props: string]: any
  }
}

/**
 * 
 * @param type 元素类型
 * @param props 元素属性
 * @param children 子级元素
 * @returns MyReactElement
 */
function createElement(
  type: string | Function,
  props: Record<string, any>,
  ...children: MyReactElement[]
): MyReactElement {
  return {
    type,
    props: {
      ...props,
      children: children.map(child => typeof child === 'object' ? child : createTextElement(child))
    }
  }
};

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

实现一个render

render函数就是根据虚拟dom来生成真实的Dom,并将其挂载在根节点上。

function render(element: MyReactElement, container: HTMLElement ) {
  // 根据类型创建对应的DOM
  const dom =
    element.type === "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement((element.type as string));
  // 追加属性
  Object.keys(element.props)
  .filter(key => key !== "children")
  .forEach(name => {
    (dom as HTMLElement)[name] = element.props[name]
  })

  // 递归,将每一个孩子节点渲染出来
  element.props.children.forEach(child =>
    render(child, dom as HTMLElement)
  );

  // 挂载
  container.append(dom)
};

查看效果

import MyReact from "./MyReact2";

/** @jsxRuntime classic */
/** @jsx MyReact.createElement */       
const element = (
    <div>
    <h1 title="this is a simple react">
      MyReact
      <div>It is important for me</div>
    </h1>
    </div>
)

const root = document.getElementById('root');

MyReact.render(element, root);

运行之后就能看到

企业微信截图_16751509143625.png

至此 一个最简单的React版本实现了(也就仅仅实现了JSX->DOM的转换),但是React16之后的核心Fiber架构却完全没涉及。

并且这个最简单版本的React一旦开始render,直到渲染完成整个DOM之前,是没有办法暂停的,而浏览器中是每16ms进行一次绘制,如果render函数执行时间过长,超过16ms就会造成卡顿掉帧的现象。(此处用window.requestIdleCallback()模拟解决)

React16之后的Fiber架构就是为了解决主线占用时间过长的问题

而Fiber架构的处理办法

  • 将整个虚拟DOM树分成一个个节点即工作单元来执行
  • 渲染的过程可以中断,可以将控制权交回浏览器,让浏览器及时的响应用户

关于Fiber的一些信息

Fiber应该是React16版本之后的核心了,它既是一种数据结构,也是一个工作单元

Fiber作为数据结构

企业微信截图_16751527983545.png

Fiber结构是一个树形结构,树中的每一个节点都是Fiber,每个节点的child指针只指向自己的第一个孩子,sibling指向自己的第一个兄弟节点,parent指向自己的父节点

对此, Fiber架构的组成如下

  • Scheduler(调度器)-- 找出高优先级的任务,高优先级先进入Reconciler
  • Reconciler(协调器)-- 负责找出变化的组件,可以被中断
  • Renderer -- 负责将变化的组件渲染到页面上,不被中断

初步改造--单元化render

将原先的递归式render拆分成单个Fiber单元式的render,这样每个Fiber执行完之后,如果主线程有其他高优先级操作需要执行时,可以中断,那么这就需要一个变量记住当前的Fiber,以便中断后继续渲染

type Fiber = {
  type?: string | Function;
  props: {
    children: MyReactElement[];
    [props: string]: any
  }
}

type Deadline = {
  timeRemaining: () => number; // 当前剩余的可用时间,即该帧剩余时间
  didTimeout: boolean; // 是否超时
}

let nextUnitOfWork: Fiber | null = null;

function workLoop(deadline: Deadline) {
  let shouldYield = false;
  // 每次执行完一个工作单元,则判断是否需要中断
  // 当没有工作单元或到了中断时间 则跳出循环
  while(nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1;
  }

  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop);

function performUnitOfWork() {
  // code  
}

在render中只执行构建根节点的Fiber,然后将它赋值给nextUnitOfWork,剩余的操作将在workLoop函数中执行(该函数在初始化的时候就被requestIdleCallback函数注册执行了), workLoop函数从最初的根节点开始,以工作单元的方式构建整个Dom树。

performUnitOfWork函数主要是将element转换成Fiber结构,并返回下一个Fiber

主要做三件事

  • 创建node并添加到DOM中
  • 为props中的children,建立Fiber
  • 选择下一个Fiber

确定Fiber的顺序

  1. 若有children,则返回第一个children
  2. 若无children,则返回sibling(即第一个兄弟节点)
  3. children和sibiling都无的情况下,则返回父节点的sibling,若父节点也无sibling,则继续往上寻找,直到祖先节点的sibling或根节点(全部转换完毕)
type Fiber = {
  type?: string | Function;
  props: {
    children: MyReactElement[];
    [props: string]: any
  },
  dom: HTMLElement | Text | null;
  parent?: Fiber;
  child?: Fiber;
  sibling?: Fiber;
}

function performUnitOfWork(fiber: Fiber) {
  //1. 创建node
  if (!fiber.dom) fiber.dom = createDom(fiber)  
  // 
  if (fiber.parent) fiber.parent.dom?.appendChild(fiber.dom)
  // 为children创建fiber
  const elements = fiber.props.children
  let index = 0,
      prevSibling: Fiber | null = null;
  
  while( index < elements.length) {
    const element = elements[index]
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }

    // 构建child指针
    if (index === 0) {
      fiber.child = newFiber
    } else {
      // 构建sibling指针 上一个children Fiber 的sibling指向当前children Fiber
      prevSibling && ((prevSibling as Fiber).sibling = newFiber)
    }

    prevSibling = newFiber;
    index++;

  }
  // 按照 child -> sibling -> uncle的顺序 寻找下一个进入工作单元的fiber
  if (fiber.child) return fiber.child
  
  let nextFiber: Fiber | undefined = fiber
  while(nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling
    // 往上找父辈的兄弟节点
    nextFiber = nextFiber.parent
  }

}


function createDom(fiber: Fiber) {
  const dom = fiber.type === 'TEXT_ELEMENT'
    ? document.createTextNode('')
    : document.createElement((fiber.type) as string);
  const isProperty = (key: string ) => key !== 'children';

  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })

  return  dom;
}

截止目前的版本,在workLoop中每个工作单元结束后,浏览器都主线程都可以打断渲染,这样很可能让用户看到不完整的ui。

为了解决这个问题,需要一个全局变量wipRoot记录当前构造的Fiber树的根节点。 所以不能生成一个node,就将其添加到dom中,要当整个workLoop中已经没有新的工作单元了,才将生成的节点添加到dom中,因此要对performUnitOfWork和render函数进行改造

// 用wipRoot存储当前树的Root, 当workLoop中探测到整棵树都渲染完成(没有下一个work unit)则从root fiber开始便历,添加每个fiber内的dom到DOM树中
let wipRoot: Fiber | undefined = undefined;

function workLoop(deadline: Deadline) {
  let shouldYield = false;
  // 每次执行完一个工作单元,则判断是否需要中断
  // 当没有工作单元或到了中断时间 则跳出循环
  while(nextUnitOfWork && !shouldYield) {
    // 构建Fiber
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    shouldYield = deadline.timeRemaining() < 1;
  }
  // 所有树都渲染完 则commit 进入Render阶段
  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

function commitRoot() {
  const childs =  (wipRoot as Fiber).child
  childs && commitWork(childs)
  wipRoot = undefined;
}

function commitWork(fiber: Fiber | undefined) {
  if (!fiber) return
  const domParent = fiber.parent?.dom
  domParent?.appendChild((fiber.dom) as HTMLElement | Text)
  commitWork(fiber.child);
  commitWork(fiber.sibling)
}

function render(element: MyReactElement, container: HTMLElement ) {
  // 保持构建的root节点
  wipRoot = {
    dom: container,
    props: {
      children: [element]
    }
  }
  nextUnitOfWork = wipRoot
 };

function performUnitOfWork(fiber: Fiber) {
  //1. 创建node
  if (!fiber.dom) fiber.dom = createDom(fiber)  
  // 不需要在构建Fiber的时候 添加dom
  // if (fiber.parent) fiber.parent.dom?.appendChild(fiber.dom)

  // 为children创建fiber
  const elements = fiber.props.children
  let index = 0,
      prevSibling: Fiber | null = null;
  
  while( index < elements.length) {
    const element = elements[index]
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }

    // 构建child指针
    if (index === 0) {
      fiber.child = newFiber
    } else {
      // 构建sibling指针 上一个children Fiber 的sibling指向当前children Fiber
      prevSibling && ((prevSibling as Fiber).sibling = newFiber)
    }

    prevSibling = newFiber;
    index++;

  }
  // 按照 child -> sibling -> uncle的顺序 寻找下一个进入工作单元的fiber
  if (fiber.child) return fiber.child
  
  let nextFiber: Fiber | undefined = fiber
  while(nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling
    // 往上找父辈的兄弟节点
    nextFiber = nextFiber.parent
  }

}

至此实现了dom的新增,但是没实现dom的删除和更新

删除和更新节点

这两个操作需要将当前commit的Fiber树和上一次渲染的Fiber树进行对比,所以需要两颗Fiber树,一颗是Current Fiber(对应当前渲染的Fiber tree),一颗是WorkInProgress Fiber(对应构建中的Fiber tree),两棵树中的对应节点通过Fiber上的alternate指针连接,以便比较每一个节点的变化(是新增还是删除还是修改),这样也提高了渲染效率

新旧节点的对比规则如下: 1.如果旧的 fiber 元素 和 新元素具有相同的类型,那么属于更新,复用旧fiber的dom,只更新属性 2.如果类型不同,且有一个新元素,则需要创建dom 3.类型不同,且有一个旧fiber,则移除旧的节点

type Fiber = {
  type?: string | Function;
  props: {
    children: MyReactElement[];
    [props: string]: any
  },
  dom: HTMLElement | Text | undefined;
  parent?: Fiber;
  child?: Fiber;
  sibling?: Fiber;
  alternate: Fiber | undefined;
  effectTag?: 'UPDATE' | 'PLACEMENT' | 'DELETION'
}

function performUnitOfWork(fiber: Fiber) {
  //1. 创建node
  if (!fiber.dom) fiber.dom = createDom(fiber)  
  // 不需要在构建Fiber的时候 添加dom
  // if (fiber.parent) fiber.parent.dom?.appendChild(fiber.dom)

  // 为当前fiber的children elements 对比 old fiber执行新增、删除、更新的操作
  const elements = fiber.props.children

  reconcileChildren(fiber, elements)
  
  // 按照 child -> sibling -> uncle的顺序 寻找下一个进入工作单元的fiber
  if (fiber.child) return fiber.child
  
  let nextFiber: Fiber | undefined = fiber
  while(nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling
    // 往上找父辈的兄弟节点
    nextFiber = nextFiber.parent
  }

}

function reconcileChildren(wipFiber: Fiber, elements: MyReactElement[]) {
  // elements是当前想要渲染到dom中的, oldFiber是上一次渲染到dom中的 
  let index = 0,
      oldFiber: Fiber | undefined = wipFiber.alternate && wipFiber.alternate.child,
      prevSibling: Fiber | undefined = undefined;

  while(index < elements.length || oldFiber !== undefined) {
    const element = elements[index],
          sameType = oldFiber && element && element.type === oldFiber.type;
    let newFiber: Fiber | undefined = undefined;
    
    if (sameType) {
      // 相同类型 则复用dom,只更新props
      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: undefined,
        parent: wipFiber,
        alternate: undefined,
        effectTag: 'PLACEMENT'
      }
    }
    // 删除后新的树中就不能有该Fiber节点,所以只能在老树中的Fiber节点存储标记
    if (oldFiber && !sameType) {
      oldFiber.effectTag = 'DELETION';
      deletions?.push(oldFiber);
    }

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

    prevSibling = newFiber;
    if (oldFiber) oldFiber = oldFiber.sibling;

    index++
  } 
}

function commitRoot() {
  // 每次提交前删除不需要的Fiber
  deletions?.forEach(commitWork)
  const childs =  (wipRoot as Fiber).child
  childs && commitWork(childs)
  currentRoot = wipRoot; // 记录上一次commit的Fiber树
  wipRoot = undefined;
}

// 根据不同的effectTag进行不同的操作
function commitWork(fiber: Fiber | undefined) {
  if (!fiber) return
  const domParent = fiber.parent?.dom,
        effectTag = fiber.effectTag;
  
  if (effectTag === 'PLACEMENT' && fiber.dom !== undefined) {
    domParent?.appendChild(fiber.dom)
  } else if (effectTag === 'UPDATE' && fiber.dom !== undefined) {
    updateDom(
      fiber.dom,
      fiber.alternate?.props,
      fiber.props
    )
  } else if (effectTag === 'DELETION') {
    domParent?.removeChild((fiber.dom as HTMLElement | Text))
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling)
}

更新节点的操作则需要一些判断,如果新props中没有一些属性则删除,若有则更新,并且React中的事件是以on开头

const isEvent = (key: string) => key.startsWith("on");
const isProperty = (key: string) => key !== "children" && !isEvent(key);
const isNew = (prev: Record<string, any>, next: Record<string, any>) => (key: string) => prev[key] !== next[key];
const isGone = (prev: Record<string, any>, next: Record<string, any>) => (key: string) => !(key in next);


function updateDom(dom: HTMLElement, prevProps: Record<string, any>, nextProps: Record<string, any>) {
  // 删除或改变事件
  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]);
  });

  // 删除旧的属性
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = "";
    });

  // 设置新属性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name];
    });

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

函数式组件

函数式组件有两个特点

  • 生成的Fiber没有dom
  • 函数式组件的childern是通过运行函数得到的

所以需要在performUnitOfWork函数中判断是否是函数式组件,因为函数式组件没有dom,所以commitWork中也需要进行对应的改变,需要在fiber树上找到一个含有dom的节点,然后将函数式组件中children的dom同样增添加进去。

// 根据不同的effectTag进行不同的操作
function commitWork(fiber: Fiber | undefined) {
  if (!fiber) return

  let domParentFiber = fiber.parent;
  // 函数组件没有dom(即 函数式组件 在从jsx -> element这一步构建的时候,其最外层结构中对应的dom为null,type是该函数本身),所以需要沿fiber tree向上寻找一个有dom的fiber,将函数式组件children的dom添加进去。
  while(!domParentFiber?.dom) {
    domParentFiber = domParentFiber?.parent
  }

  const domParent = domParentFiber.dom,
        effectTag = fiber.effectTag;
  
  if (effectTag === 'PLACEMENT' && fiber.dom !== undefined) {
    domParent?.appendChild(fiber.dom)
  } else if (effectTag === 'UPDATE' && fiber.dom !== undefined) {
    updateDom(
      (fiber.dom as HTMLElement),
      (fiber.alternate?.props as Record<string, any>),
      fiber.props
    )
  } else if (effectTag === 'DELETION') {
    commitDeletion(fiber, (domParent as HTMLElement | Text
      ))
  }
  commitWork(fiber.child);
  commitWork(fiber.sibling)
}

hooks

需要添加一个全局hooks array实现一个component中多次使用useState 在react在每次setState会重新渲染,这是因为会设置一个新的wipRoot作为下一次的工作单元

function useState<T>(initial: T) {
  const oldHook = 
    wipFiber?.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];
  
  const hook: selfHook = {
    state: oldHook ? oldHook.state : initial,
    queue: []
  }

  const actions = oldHook ? oldHook.queue : []
  actions.forEach((action: any) => {
    hook.state = action(hook.state)
  })
  // 执行此函数后,会触发重新构建Fiber树
  const setState = (action: (value: T) => T) => {
    // 上一步中执行的就是action方法,此处会将其推入queue
    hook.queue.push(action);
    // 执行更新,做的工作和render方法相似,因此setState是update的入口
    currentRoot && 
    (wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    })
    nextUnitOfWork = wipRoot;
    deletions = [];
  };
  // 执行后将该hook推入新树的hooks数组
  wipFiber?.hooks?.push(hook);
  // 为处理下一个hook作准备
  hookIndex++;
  // 注意此时返回了一个函数setState
  // setState用到了函数中的局部变量hook,因此形成了一个闭包
  return [hook.state, setState];
}

总结

至此也算基本完成了对Own React代码的学习,虽然这个版本的React是一个简化版,但也好比自己啥都不清楚就一脑子钻进去看React源码,算是一个个浅浅的入门。

参考文献