简易mini-react

先不去考虑React原理中那些个复杂的概念,就从React使用的API倒推实现一个最简易的mini-react,我们需要做的有:

  • 调用API创建虚拟DOM
  • 将虚拟DOM渲染为真实DOM

常见的 main.js 的内容如下

import ReactDOM from 'react-dom'
import App from './App.js'

ReactDOM.createRoot(document.querySelector('#root')).render(App)

// App.js (不支持JSX语法之前先使用API形式)
React.createElement('div', {id: 'app'}, 'hello mini-react')

我们根据使用方法返回一下结果

import React from './React.js'

const ReactDOM = {
  createRoot(container) {
    return {
      render(App) {
        React.render(App, container)
      }
    }
  }
}

export default ReactDOM;

然后重点实现一下 React 的 render。

DOM元素中有两类元素,标签元素和文本节点,我们先实现一下创建两种元素的方法。

/**
 * 创建文本内容
 * @param {string} text 文本内容
 */
function createTextNode(text) {
  return {
    type: 'TEXT_ELEMENT',
    props: {
      nodeValue: text,
      children: []
    }
  }
}

/**
  * 创建元素
  * @param {string} type 创建元素类型
  * @param {object} props 创建元素时添加的属性
  * @param  {...any} children 子元素
  */
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child => {
        return typeof child === 'string'
          ? createTextNode(child)
          : child
      }),
    },
  };
}

上面返回的对象就是虚拟 DOM,来看下调用结果

image-20240115220448036

然后根据返回的虚拟 DOM 对象进行递归渲染

/**
 * 渲染vdom
 * @param {object} el vdom
 * @param {HTMLElement} container 容器
 */
function render(el, container) {
  // 根据类型创建元素
  const dom = el.type === 'TEXT_ELEMENT'
    ? document.createTextNode('')
    : document.createElement(el.type);

  // 挂载props
  Object.keys(el.props).forEach(key => {
    if (key !== 'children') {
      dom[key] = el.props[key];
    }
  });

  // 子节点渲染
  const children = el.props.children;
  children.forEach(child => {
    React.render(child, dom);
  });

  container.appendChild(dom);
}

此时打开浏览器可以看到内容正常渲染

image-20240115220845237

JSX

JSX 是一种 JavaScript 的语法扩展,最开始用于在 React 应用中编写 UI 组件(现在其他框架也支持了jsx语法,比如Vue)。

JSX的特点如下:

  1. 类似 HTML:JSX 使用类似 HTML 的标签和属性来描述组件的结构,使得代码更易读、理解和维护。

  2. 强大的表达能力:通过 JSX,可以直接在 JavaScript 中嵌入表达式,并且支持条件判断、循环等复杂逻辑。

  3. 组件化开发:JSX 支持将 UI 拆分为多个可重用的组件,并通过 props 属性进行数据传递。

  4. 静态类型检查:使用静态类型检查工具(如 TypeScript)可以对 JSX 进行类型检查,提高代码质量和可维护性。

  5. 编译优化:React 在运行时会将 JSX 转换为纯 JavaScript 代码执行,这样可以进行一些编译优化以提高性能。

要提供jsx语法支持,需要借助babel等编译工具,这里我们不去考虑其深层原理,借助 Vite(Webpack,Rollup之类的都有相关生态支持)来解析 JSX 语法。

使用命令创建一个Vite项目

$ pnpm create vite

创建模板选择React即可,注意在实现mini-react过程中需要把默认vite配置文件中的react插件关闭

然后把我们之前用 React.createElement 创建的组件替换为 JSX 语法即可。

打包工具会自动将.jsx文件转为 React.createElement API 的 js 文件,并且会在 jsx 文件内部寻找引入的React对象,这就是为什么我们之前写 React 时即便没有用到也必须要引入React对象的原因(新版本中已经不需要了)。

将之前的 App.jsx 改写为以下内容

import React from '/core/React.js'

const App = <div id="app">
  mini-react
  <br />
  abc
</div>;

export default App

可以看到正常渲染

image-20240114221100459

fiber

浏览器中的每一个网页都是单线程运行的,如果一次 JS 脚本执行的时间太长会阻塞渲染进程,造成页面卡顿(浏览器的渲染原理不在这里展开)。

React 中虚拟 DOM 和真实 DOM 的处理都是非常耗时的任务,React 非常聪明地在执行 JS 进程的过程中,根据浏览器的空闲时间将任务进行分片处理,即使有大批量的数据需要处理,也不会阻塞渲染进程的执行。

image-20240114222752818

但是对象结构无法实现任务中断,一旦中断就得从 root 节点开始遍历,这是对象结构决定的,但是链表可以,只要在任务中断的时候保存当前节点的指针,下次任务执行的时候就可以从当前节点继续执行。

image-20240115203958854

通过指针关系,将各个元素添加通过链表形式串联:

  • 第一个子元素标记为 child;
  • 其余的子元素通过 child 的 sibling 标记;
  • 所有的父元素通过 return 标记(为了方便理解,本文使用 parent)。

递归处理 fiber 的过程大致有:创建 DOM、处理 Props、转换链表、下次要处理的节点。

创建 DOM 的过程我们之前已经实现了,可以单独抽离一个方法出来

function createDOM(type) {
  return type === 'TEXT_ELEMENT'
    ? document.createTextNode('')
    : document.createElement(type)
}

Props 我们之前也处理过,也单独拿出来

function updateProps(dom, props) {
  Object.keys(props).forEach(key => {
    if (key !== 'children') {
      dom[key] = props[key];
    }
  });
}

转换链表的过程参考上图的所示,对虚拟DOM进行转换,建立起节点之间的关系

function initChildren(work) {
  const children = work.props.children;
  let prevChild = null;
  children.forEach((child, index) => {
    const fiber = {
      type: child.type,
      props: child.props,
      child: null,
      parent: work,
      sibling: null,
      dom: null
    }
    if (index === 0) {
      work.child = fiber;
    } else {
      prevChild.sibling = fiber;
    }
    prevChild = fiber;
  })
}

至此,一个Fiber对象就构建完成,我们需要对整个虚拟 DOM 树进行处理

function performWorkOfUnit(fiber) {
  if (!fiber.dom) {
    // 创建DOM
    const dom = (fiber.dom = createDOM(fiber.type));
    fiber.parent.dom.append(dom)

    // 处理props
    updateProps(dom, fiber.props);
  }

  // 转换链表
  initChildren(fiber)

  // 返回下一个处理的节点
  if (fiber.child) {
    return fiber.child
  }
  if (fiber.sibling) {
    return fiber.sibling
  }
  return fiber.parent?.sibling
}

有了 fiber,就可以进行任务中断了,通过浏览器的 requestIdleCallback API来进行任务的中断和重启。

调用 window.requestIdleCallback() 方法,并传入一个回调函数作为参数。浏览器会在主线程空闲时调用该回调函数,同时传递一个 IdleDeadline 对象作为参数。

在回调函数中可以根据 IdleDeadline 对象判断当前是否还有足够的时间来执行任务。如果没有足够时间,则可以选择继续等待下次空闲;如果有足够时间,则可以开始执行任务。执行完毕后,可以选择再次请求下一轮空闲。

当当前一次执行剩余的时间不足时停止执行,并将任务添加到下一次空闲时会掉

let nextWorkOfUnit = null;
function workloop(deadline) {
  let shouldYield = false;
  while (!shouldYield && nextWorkOfUnit) {
    nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
    shouldYield = deadline.timeRemaining() < 1;
  }
  requestIdleCallback(workloop);
}

此时虽然已经完成了 fiber 大致的内容,但还是有一点,fiber 节点会在没有完全处理完之后进行渲染,所以我们要进行限制,只有在所有的节点全部处理完成之后再提交渲染。

对上面的调度函数进行重构

let nextWorkOfUnit = null;
let root = null;
function workloop(deadline) {
  let shouldYield = false;
  while (!shouldYield && nextWorkOfUnit) {
    nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
    shouldYield = deadline.timeRemaining() < 1;
  }
  if (!nextWorkOfUnit && root) {
    commitRoot();
  }
  requestIdleCallback(workloop);
}

function commitRoot() {
  commitWork(root.child)
  root = null;
}

function commitWork(fiber) {
  if (!fiber) return;

  fiber.parent.dom.append(fiber.dom);
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

function render(el, container) {
  nextWorkOfUnit = {
    dom: container,
    props: {
      children: [el]
    }
  }
  root = nextWorkOfUnit
  requestIdleCallback(workloop)
}

函数组件

函数组件是现在React中常用的一个语法,JSX 在解析函数组件的时候,会把 fiber.type 标记为一个函数,可以打印下看看。

image-20240115225849693

我们可以根据这个来判断是否为函数组件,然后给出对应的处理逻辑。

回想一下函数组件的返回值,是一个DOM结构,所以函数组件的渲染就是执行一下这个函数,拿到返回值进行fiber的处理,并将props对象作为参数传入

const children = [fiber.type(fiber.props)]

结合之前普通元素的处理,可以将处理函数重新改写为以下内容

// 处理函数组件
function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  initChildren(fiber, children)
}

// 处理普通元素
function updateHostComponent(fiber) {
  if (!fiber.dom) {
    // 创建DOM
    const dom = (fiber.dom = createDOM(fiber.type));

    // 处理props
    updateProps(dom, fiber.props);
  }
  // 转换链表
  const children = fiber.props.children;
  initChildren(fiber, children)
}

function performWorkOfUnit(fiber) {
  // 判断是否为函数组件
  const isFunctionComponent = typeof fiber.type === 'function';
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }

  // 返回下一个处理的节点
  if (fiber.child) {
    return fiber.child
  }

  let nextFiber = fiber;
  // 嵌套结构处理
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent;
  }
}

首先根据 fiber.type进行对应的处理(实际React中有非常多的情况),当发现时函数组件时,执行函数,将函数的返回值作为children保存,如果是普通元素则进行创建 dom 元素的操作,处理完当前 fiber 节点的关系之后返回下一个要处理的节点。

因为元素会出现多层嵌套关系,使用while循环查找下一个要处理的 sibling 节点。

此时可以测试一下效果

import React from '/core/React.js'

function Container() {
  return <div>
    <Count num={10}></Count>
    <Count num={20}></Count>
  </div>
}

function Count({ num }) {
  return <div>count: {num}</div>
}

const App = () => <div id="app">
  mini-react
  <br />
  <Container></Container>

</div>;

export default App

image-20240116222633887

前面几个部分只支持了原生 DOM 的 attribute 绑定,这里我们绑定 一下事件(React 的事件并不是使用 DOM 原生的事件进行绑定,而是自己实现了一套事件系统)。

在处理props的时候,如果检测为事件则向dom添加事件。

function updateProps(dom, props) {
  Object.keys(props).forEach(key => {
    if (key !== 'children') {
      if (key.startsWith('on')) {
        const eventName = key.slice(2).toLowerCase();
        dom.addEventListener(eventName, props[key]);
      } else {
        dom[key] = props[key];
      }
    }
  });
}

更新

更新 props

更新props的过程就是对比状态改变前后的 fiber.props 是否发生改变(新增、删除、修改),只需要比较节点的前后props即可,重点在于要如何获取节点的上一个状态,这里我们可以在更新时将旧的fiber记录到新的fiber中。

在更新fiber对象时添加新的属性

function initChildren(work, children) {
  let oldFiber = work.alternate?.child;
  let prevChild = null;
  children.forEach((child, index) => {
    // type 相同即位更新
    const isSameType = oldFiber?.type === child.type;
    let fiber;

    if (isSameType) {
      fiber = {
        type: child.type,
        props: child.props,
        child: null,
        parent: work,
        sibling: null,
        dom: oldFiber.dom,
        effectTag: 'update',
        alternate: oldFiber
      }
    } else {
      fiber = {
        type: child.type,
        props: child.props,
        child: null,
        parent: work,
        sibling: null,
        dom: null,
        effectTag: 'placement',
      }
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
    if (index === 0) {
      work.child = fiber;
    } else {
      prevChild.sibling = fiber;
    }
    prevChild = fiber;
  })
}

然后在处理节点的时候根据effectTag进行对应的处理即可,新增保持原有逻辑,更新时执行更新操作

function commitWork(fiber) {
  if (!fiber) return;

  let fiberParent = fiber.parent;
  // 函数组件嵌套
  while (!fiberParent.dom) {
    fiberParent = fiberParent.parent;
  }

  if (fiber.effectTag === 'update') {
    updateProps(fiber.dom, fiber.props, fiber.alternate?.props)
  } else if (fiber.effectTag === 'placement') {
    if (fiber.dom) {
      fiberParent.dom.append(fiber.dom);
    }
  }

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

这里的更新操作需要添加一个oldProps的参数,用于比对新旧props

function updateProps(dom, nextProps, prevProps) {
  // 删除
  Object.keys(prevProps).forEach(key => {
    if (key !== 'children') {
      if (!(key in nextProps)) {
        dom.removeAttribute(key);
      }
    }
  })
  // 添加/更新
  Object.keys(nextProps).forEach(key => {
    if (key !== 'children') {
      if (nextProps[key] !== prevProps[key]) {
        if (key.startsWith('on')) {
          const eventName = key.slice(2).toLowerCase();
          dom.addEventListener(eventName, nextProps[key]);
        } else {
          dom[key] = nextProps[key];
        }
      }
    }
  });
}

更新 props 的过程需要有动作出发,React 中由setState来触发更新,但是我们现在并没有实现useState,所以我们需要手动进行一下更新。

function update() {
    root = {
    dom: currentRoot.dom,
    props: currentRoot.props,
    alternate: currentRoot,
  }
  nextWorkOfUnit = root
}

在组件中进行调用

import React from '/core/React.js'

let count = 10
function Count({ num }) {
  function handle() {
    console.log('click')
    count++
    React.update()
  }
  return <div>count: {count} <button onClick={handle}>+</button></div>
}

const App = () => <div id="app">
  mini-react
  <br />
  <Count></Count>

</div>;

export default App

更新children

DOM 的变化无非三种情况:新增、删除、更新,在实现更新之前,我们已经完成了添加的功能,关于更新可以理解为删除旧的添加新的,所以更新DOM的关键在于实现删除。

判断fiber是否变化可以和旧DOM的type进行比较,不同则为更新,可以在处理fiber的时候,将需要删除的fiber节点添加到一个数组中,然后统一删除DOM。

function reconcileChildren(work, children) {
  let oldFiber = work.alternate?.child;
  let prevChild = null;
  // JSX 代码中会有{bool && <Count />}这样的代码,过滤掉这些布尔值
  children.filter(child => child).forEach((child, index) => {
    const isSameType = oldFiber && oldFiber.type === child.type;
    let fiber;

    if (isSameType) {
      // 更新
      fiber = {
        type: child.type,
        props: child.props,
        child: null,
        parent: work,
        sibling: null,
        dom: oldFiber.dom,
        effectTag: 'update',
        alternate: oldFiber
      }
    } else {
      fiber = {
        type: child.type,
        props: child.props,
        child: null,
        parent: work,
        sibling: null,
        dom: null,
        effectTag: 'placement',
      }

      if (oldFiber) {
        deletions.push(oldFiber)
      }
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
    if (index === 0) {
      work.child = fiber;
    } else {
      prevChild.sibling = fiber;
    }

    prevChild = fiber;
  })
  
  // 删除的核心操作,如果此时还有oldFiber,说明在新的fiber中没有这些DOM
  while (oldFiber) {
    deletions.push(oldFiber)
    oldFiber = oldFiber.sibling
  }
}

删除DOM的过程,遍历处理要删除的fiber集合,要注意的点在于函数组件,他没有实际的DOM,所以在移除DOM的时候需要向上寻找带有dom的父级节点。

function commitDeletion(fiber) {
  if (fiber.dom) {
    let fiberParent = fiber.parent;
    // 函数组件嵌套
    while (!fiberParent.dom) {
      fiberParent = fiberParent.parent;
    }
    fiberParent.dom.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child);
  }
}

useState

useState 是 React 函数组件的核心,是函数组件从无状态组件迈向有状态组件的基石。

useState 接受一个参数作为初始值,并将state状态保存到fiber中,组件中可以有多个useState, 所以需要使用一个数组来保存所有的state状态,这就是为什么函数组件的中 hooks 不可以放在if语句中,因为这会导致hooks取值异常。

let stateHooks = [];
let stateIndex = 0;
export function useState(initial) {
  let currentFiber = wipFiber;
  let oldHook = currentFiber.alternate?.stateHooks[stateIndex++]
  const stateHook = {
    state: oldHook ? oldHook.state : initial,
    queue: oldHook ? oldHook.queue : []
  }

  stateHook.queue.forEach(action => {
    stateHook.state = action(stateHook.state)
  })
  stateHook.queue = []

  stateHooks.push(stateHook)
  currentFiber.stateHooks = stateHooks

  function setState(action) {
    const eagerState = typeof action === 'function' ? action(stateHook.state) : action
    if (eagerState === stateHook.state) return
    // stateHook.state = action(stateHook.state)
    stateHook.queue.push(typeof action !== 'function' ? () => action : action)
    wipRoot = {
      ...currentFiber,
      alternate: currentFiber
    }
    nextWorkOfUnit = wipRoot
  }

  return [stateHook.state, setState]
}

如果是第一次调用,则直接使用初始值,否则使用fiber中保存的状态进行处理。setState 可以接受一个普通参数或者一个函数,如果是一个函数则执行并传入当前状态,如果是普通参数则包装为一个函数,目的是保持行为一致,方便处理。

setState 执行时将nextWorkUnit 进行赋值,来触发更新操作。

另外,在开始处理函数组件的时候重置一下状态。

function updateFunctionComponent(fiber) {
  stateHooks = []
  stateIndex = 0

  wipFiber = fiber;
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

useEffect

同 useState 类似,也是将hook的信息保存到fiber上,当组件更新时进行处理。useEffect接受一个回调函数和一个依赖数组,会在初始化和依赖数组发生改变时执行callback。

let effectHooks = [];
export function useEffect(callback, deps) {
  const effectHook = {
    callback,
    deps
  }
  effectHooks.push(effectHook)
  wipFiber.effectHooks = effectHooks
}

然后就是处理effect,初始化时直接执行,更新时取出旧的deps和当前的deps进行比较,如果发生改变则执行回调函数,执行时将返回值保存到fiber,用于一下次执行时cleanup;如果deps是空数组,则只有初始化时执行callback。

function commitEffectHooks() {
  function run(fiber) {
    if (!fiber) return;
    if (!fiber.alternate) {
      // init
      fiber.effectHooks?.forEach(hook => {
        hook.cleanup = hook.callback()
      })
    } else {
      // update
      fiber.effectHooks?.forEach((newHook, index) => {
        if (newHook.deps.length > 0) {
          const oldEffectHook = fiber.alternate?.effectHooks[index]
          const needUpdate = oldEffectHook?.deps.some((oldDep, i) => {
            return oldDep !== newHook.deps[i]
          })
          needUpdate && (newHook.cleanup = newHook.callback())
        }
      })
    }
    run(fiber.child)
    run(fiber.sibling)
  }

  function runCleanup(fiber) {
    if (!fiber) return

    fiber.alternate?.effectHooks?.forEach(hook => {
      if (hook.deps.length > 0) {
        hook.cleanup?.()
      }
    })

    runCleanup(fiber.child)
    runCleanup(fiber.sibling)
  }

  runCleanup(wipRoot)
  run(wipRoot)
}

前端小白