简易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,来看下调用结果
然后根据返回的虚拟 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);
}
此时打开浏览器可以看到内容正常渲染
JSX
JSX 是一种 JavaScript 的语法扩展,最开始用于在 React 应用中编写 UI 组件(现在其他框架也支持了jsx语法,比如Vue)。
JSX的特点如下:
类似 HTML:JSX 使用类似 HTML 的标签和属性来描述组件的结构,使得代码更易读、理解和维护。
强大的表达能力:通过 JSX,可以直接在 JavaScript 中嵌入表达式,并且支持条件判断、循环等复杂逻辑。
组件化开发:JSX 支持将 UI 拆分为多个可重用的组件,并通过 props 属性进行数据传递。
静态类型检查:使用静态类型检查工具(如 TypeScript)可以对 JSX 进行类型检查,提高代码质量和可维护性。
编译优化: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
可以看到正常渲染
fiber
浏览器中的每一个网页都是单线程运行的,如果一次 JS 脚本执行的时间太长会阻塞渲染进程,造成页面卡顿(浏览器的渲染原理不在这里展开)。
React 中虚拟 DOM 和真实 DOM 的处理都是非常耗时的任务,React 非常聪明地在执行 JS 进程的过程中,根据浏览器的空闲时间将任务进行分片处理,即使有大批量的数据需要处理,也不会阻塞渲染进程的执行。
但是对象结构无法实现任务中断,一旦中断就得从 root 节点开始遍历,这是对象结构决定的,但是链表可以,只要在任务中断的时候保存当前节点的指针,下次任务执行的时候就可以从当前节点继续执行。
通过指针关系,将各个元素添加通过链表形式串联:
- 第一个子元素标记为 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 标记为一个函数,可以打印下看看。
我们可以根据这个来判断是否为函数组件,然后给出对应的处理逻辑。
回想一下函数组件的返回值,是一个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
前面几个部分只支持了原生 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)
}