用400行代码构建 React
本文将用大约 400 行代码构建一个支持异步更新和可中断的 React 版本——这是 React 的一个核心特性,许多高级 API 都依赖于它。
JSX 和 createElement
在深入研究mini-react.ts的原理之前,了解 JSX 代表什么非常重要。我们可以使用 JSX 来描述 DOM,并轻松应用 JavaScript 逻辑。然而,浏览器本身并不理解JSX,所以我们编写的JSX被编译成浏览器可以理解的JavaScript。我在这里使用了 babel,但是当然你可以使用其他构建工具,它们生成的内容将是类似的。因此可以看到它调用了React.createElement,它提供了以下选项:
- type:当前节点的类型,例如div。
- config:表示当前元素节点的属性,例如{id: "test"}。
- children:子元素,可以是多个元素、简单文本或由 React.createElement 创建的多个节点。
如果你是一位经验丰富的 React 用户,你可能会记得在 React 18 之前,你需要import React from 'react'
;
从 React 18 开始,这不再是必需的,增强了开发人员的体验,但React.createElement仍在底层调用。
对于我们简化的 React 实现,我们需要配置Vite以react({ jsxRuntime: 'classic' })
将 JSX 直接编译到React.createElement
实现中。然后我们可以实现我们自己的:
// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
type: 'TEXT',
props: {
nodeValue: text,
},
});
// Create custom JavaScript data structures.
const createElement = (
type: VirtualElementType,
props: Record<string, unknown> = {},
...child: (unknown | VirtualElement)[]
): VirtualElement => {
const children = child.map((c) =>
isVirtualElement(c) ? c : createTextElement(String(c)),
);
return {
type,
props: {
...props,
children,
},
};
};
Render
接下来,我们根据前面创建的数据结构实现一个简化版本的 render 函数,用于将 JSX 渲染到真实的 DOM 上。
// Update DOM properties.
// For simplicity, we remove all the previous properties and add next properties.
const updateDOM = (DOM, prevProps, nextProps) => {
const defaultPropKeys = 'children';
for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
if (removePropKey.startsWith('on')) {
DOM.removeEventListener(
removePropKey.substr(2).toLowerCase(),
removePropValue
);
} else if (removePropKey !== defaultPropKeys) {
DOM[removePropKey] = '';
}
}
for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
if (addPropKey.startsWith('on')) {
DOM.addEventListener(addPropKey.substr(2).toLowerCase(), addPropValue);
} else if (addPropKey !== defaultPropKeys) {
DOM[addPropKey] = addPropValue;
}
}
};
// Create DOM based on node type.
const createDOM = (fiberNode) => {
const { type, props } = fiberNode;
let DOM = null;
if (type === 'TEXT') {
DOM = document.createTextNode('');
} else if (typeof type === 'string') {
DOM = document.createElement(type);
}
// Update properties based on props after creation.
if (DOM !== null) {
updateDOM(DOM, {}, props);
}
return DOM;
};
const render = (element, container) => {
const DOM = createDOM(element);
if (Array.isArray(element.props.children)) {
for (const child of element.props.children) {
render(child, DOM);
}
}
container.appendChild(DOM);
};
Fiber架构和并发模式
Fiber 架构和并发模式主要是为了解决元素树一旦递归完成就无法中断,从而可能长时间阻塞主线程的问题而开发的,高优先级任务(例如用户输入或动画)可能无法及时处理。在其源代码中,工作被分解为小单元。每当浏览器空闲时,它就会处理这些小工作单元,放弃对主线程的控制,以便浏览器能够及时响应高优先级任务。一旦作业的所有小单元完成,结果就会映射到真实的 DOM。
React 大会 2024而在真实的 React 中,我们可以使用其提供的 API 比如useTransition或useDeferredValue来明确降低更新的优先级。所以,总而言之,这里的两个关键点是如何放弃主线程以及如何将工作分解为可管理的单元。
requestIdleCallback
requestIdleCallback是一个实验性的 API,在浏览器空闲时执行回调。它尚未得到所有浏览器的支持。在 React 中,它用于scheduler 包中,该包具有比 requestIdleCallback 更复杂的调度逻辑,包括更新任务优先级。但这里我们只考虑异步可中断性,因此这是模仿 React 的基本实现:
// Enhanced requestIdleCallback.
((global: Window) => {
const id = 1;
const fps = 1e3 / 60;
let frameDeadline: number;
let pendingCallback: IdleRequestCallback;
const channel = new MessageChannel();
const timeRemaining = () => frameDeadline - window.performance.now();
const deadline = {
didTimeout: false,
timeRemaining,
};
channel.port2.onmessage = () => {
if (typeof pendingCallback === 'function') {
pendingCallback(deadline);
}
};
global.requestIdleCallback = (callback: IdleRequestCallback) => {
global.requestAnimationFrame((frameTime) => {
frameDeadline = frameTime + fps;
pendingCallback = callback;
channel.port1.postMessage(null);
});
return id;
};
})(window);
以下是一些关键点的简要说明:为什么要使用MessageChannel?它主要使用宏任务来处理每轮单元任务。但为什么是宏观任务呢?这是因为我们需要使用宏任务来放弃对主线程的控制,让浏览器在此空闲期间更新 DOM 或接收事件。由于浏览器将更新 DOM 作为单独的任务,因此此时不会执行 JavaScript。主线程一次只能运行一个任务——执行 JavaScript 或处理 DOM 计算、样式计算、输入事件等。Promise.then然而,微任务(例如)不会放弃对主线程的控制。为什么不使用 setTimeout?这是因为现代浏览器认为嵌套 setTimeout 调用超过五次就会阻塞,并将其最小延迟设置为 4ms,因此不够精确。
算法
请注意,React 正在不断发展,我描述的算法可能不是最新的,但足以理解其基本原理。下面是显示工作单元之间连接的图表:在 React 中,每个工作单元称为 Fiber 节点。它们使用类似链表的结构链接在一起:
- child:从父节点指向第一个子元素的指针。
- return/parent:所有子元素都有一个指向父元素的指针。
- sibling:从第一个子元素指向下一个同级元素。
有了这个数据结构,我们来看看具体的实现。我们只是扩展_渲染_逻辑,将调用序列重组为workLoop-> performUnitOfWork-> reconcileChildren-> commitRoot。
- workLoop:通过不断调用来获取空闲时间requestIdleCallback,如果当前空闲,且有单元任务需要执行,则执行每个单元任务。
- PerformUnitOfWork:执行的特定单元任务。这就是链表思想的体现。具体来说,一次只处理一个Fiber节点,并返回下一个要处理的节点。
- reconcileChildren:对当前的 fiber 节点进行 reconcile,其实就是对虚拟 DOM 的比较,并记录需要做的修改。可以看到我们直接在每个 fiber 节点上进行修改和保存,因为现在只是对 JavaScript 对象的修改,并没有触碰真实的 DOM。
- commitRoot:如果当前需要更新(根据wipRoot),并且没有下一个单元任务需要处理(根据!nextUnitOfWork),则意味着需要将虚拟更改映射到真实 DOM。 就是commitRoot根据 fiber 节点的变化来修改真实 DOM。
有了这些,我们就可以真正使用 Fiber 架构来进行可中断的 DOM 更新,但我们仍然缺乏触发器。
触发更新
在React中,常见的触发器是useState最基本的更新机制。让我们实现它来启动我们的 Fiber 引擎。下面是具体实现:
// Associate the hook with the fiber node.
function useState<S>(initState: S): [S, (value: S) => void] {
const fiberNode: FiberNode<S> = wipFiber;
const hook: {
state: S;
queue: S[];
} = fiberNode?.alternate?.hooks
? fiberNode.alternate.hooks[hookIndex]
: {
state: initState,
queue: [],
};
while (hook.queue.length) {
let newState = hook.queue.shift();
if (isPlainObject(hook.state) && isPlainObject(newState)) {
newState = { ...hook.state, ...newState };
}
if (isDef(newState)) {
hook.state = newState;
}
}
if (typeof fiberNode.hooks === 'undefined') {
fiberNode.hooks = [];
}
fiberNode.hooks.push(hook);
hookIndex += 1;
const setState = (value: S) => {
hook.queue.push(value);
if (currentRoot) {
wipRoot = {
type: currentRoot.type,
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
currentRoot = null;
}
};
return [hook.state, setState];
}
它巧妙地将钩子的状态保留在 Fiber 节点上,并通过队列修改状态。从这里,你也可以明白为什么React hook调用的顺序一定不能改变。
结论
我们实现了一个支持异步和可中断更新的 React 最小模型,没有依赖,不包括注释和类型,代码可能不到 400 行。希望对你有帮助。
转载自:https://juejin.cn/post/7397288963600416820