前言
阅读React源码是一件很有意思的事情,它可以让你了解到React的设计思想,以及它是如何实现的。但是如果对React基本原理不了解,直接阅读源码会感到很吃力。所以在阅读源码之前,我们需要先了解React的基本原理。本文通过实现一个mini版React来帮助了解React的基本原理,这可以让你更好的理解React源码。React的核心功能并不难实现,本文围绕下面几个核心功能来实现一个mini版React:
- 函数式组件
- 并发模式
- Fiber架构
- Reconcliation算法
- Hooks
开发环境
这里我们使用webpack-cli来初始化一个项目。首先我们需要安装webpack-cli:
1 2 3
| npm init -y // 初始化package.json npm install webpack webpack-cli -D npx webpack-cli init
|
根据提示生成一个webpack配置文件,为了能打包JSX我们直接使用@babel/preset-react预设:
1
| npm install @babel/preset-react -D
|
然后修改.babelrc文件:
1 2 3 4 5 6 7 8 9 10 11
| { "presets": [ [ "@babel/preset-env", { "modules": false } ], "@babel/preset-react" ] }
|
此时我们的项目打包环境就已经准备好了,可以开始实现mini版React了。首先创建src/index.js文件:
1 2 3 4 5 6 7
|
import React from "./lib/react-slim";
const element = <h1>Hello world!</h1>;
React.render(element, document.getElementById("root"));
|
然后创建src/lib/react-slim.js文件,初始代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
function createElement(type, props, ...children) { }
function render(element, container) { }
const React = { createElement, render };
export default React;
|
然后需要修改index.html文件,创建容器元素:
1 2 3 4 5 6 7 8 9 10 11 12
| // index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Webpack App</title> </head> <body> <div id="root"></div> </body> </html>
|
接下来就需要实现createElement和render方法了。实现之前我们先看下index.js文件中的代码:
1
| const element = <h1>Hello world!</h1>;
|
这段代码是JSX语法,它会被转换成下面的代码:
1
| const element = React.createElement("h1", null, "Hello world!");
|
所以我们首先实现createElement方法,它接收三个参数,返回一个虚拟Dom对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function createElement(type, props, ...children) { return { type, props: { ...props, children: children.map(child => typeof child === "object" ? child : createTextElement(child) ) } }; }
function createTextElement(text) { return { type: "TEXT_ELEMENT", props: { nodeValue: text, children: [] } }; }
|
然后实现render方法,它接收两个参数,第一个是虚拟Dom对象,第二个是容器元素,通过递归的形式生成DOM树,挂在到容器元素中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function render(element, container) { const dom = element.type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(element.type);
const isProperty = key => key !== "children"; Object.keys(element.props) .filter(isProperty) .forEach(name => { dom[name] = element.props[name]; });
element.props.children.forEach(child => render(child, dom));
container.appendChild(dom); }
|
由于是递归调用,所以函数一旦开始执行就无法暂停,这样主线程被占用,浏览器无法处理其他更高优先级的任务,如响应用户输入和动画。当有大量元素需要渲染时,用户就会感觉到明显的卡顿。所以我们需要一种能力,将渲染任务拆分成小任务,每次只渲染一小部分,然后让出主线程,这样浏览器就可以处理其他任务。其他任务完成后再继续渲染,这就是React的并发模式。
并发模式
我们可以通过requestIdleCallback来实现并发模式,requestIdleCallback会在浏览器空闲时执行回调函数,我们可以在回调函数中执行渲染任务。在reac-slim.js文件中添加requestIdleCallback的调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
let nextUnitOfWork = null; function workLoop(deadline) { let shouldYield = false; while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); shouldYield = deadline.timeRemaining() < 1; } requestIdleCallback(workLoop); } requestIdleCallback(workLoop);
function performUnitOfWork(nextUnitOfWork) {}
|
以上代码使用requestIdleCallback启动一个workLoop函数,浏览器空闲时会自动调用该函数。并且会给函数传入一个deadline参数,可以调用deadline.timeRemaining()
获取浏览器某一帧的剩余空闲时间。
初始时,nextUnitOfWork为null,所以workLoop函数会一直空转,我们需要在render函数中初始化nextUnitOfWork,让worLoop函数真正工作起来,修改render函数:
1 2 3 4 5 6 7 8
| function render(element, container) { nextUnitOfWork = { dom: container, props: { children: [element] } }; }
|
这样render执行后,workLoop函数就会执行performUnitOfWork函数,该函数接收一个工作单元,返回下一个工作单元,在实现这个函数之前需要来了解React另外一个重要的概念:Fiber。
由于requestIdleCallback的兼容性问题,React没有使用requestIdleCallback,而是使用了自己实现的调度器scheduler,这里我们简化实现,使用requestIdleCallback来实现并发模式。
Fiber架构
Fiber是一种链表数据结构,主要解决两方面的问题:一是能够快速找到下一个工作单元,以实现可中断的并发渲染。二是实现Reconciliation算法,使React能够在O(n)的时间复杂度内完成新旧Fiber树的Diff计算。在React中,每个元素都对应一个Fiber节点,每个Fiber节点都是一个工作单元。
Fiber树的结构如下图:
Fiber树有以下几个特点:
- 每个Fiber节点都有一个指向父Fiber节点的指针parent或者return。
- 父Fiber节点有一个指向第一个子Fiber节点的指针child。
- Fiber节点通过sibling指针指向下一个兄弟Fiber节点。
- 每个Fiber节点都有一个指向上一个Fiber节点的指针alternate,用于保存上一次的Fiber节点。
performUnitOfWork函数遍历Fiber树,返回下一个工作单元的过程如下:
- 从根节点开始,如果当前节点有子节点,则返回子节点为下一个工作单元。
- 如果当前节点没有子节点,则需要判断是否有兄弟节点,如果有,则返回兄弟节点为下一个工作单元。
- 如果没有兄弟节点,则说明他是最后一个字节点,则需要通过parent/return属性向上查找父节点的兄弟节点,作为下一个工作单元。
- 重复步骤2和3,当Fiber树遍历完成时会回到根节点,此时整个Fiber树也构建完成。
Fiber的理论知识介绍完之后,我们就可以来实现performUnitOfWork函数了。首先创建一个createDom函数,他根据Fiber节点创建DOM元素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function createDom(fiber) { const dom = fiber.type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(fiber.type);
const isProperty = key => key !== "children"; Object.keys(fiber.props) .filter(isProperty) .forEach(name => { dom[name] = fiber.props[name]; });
return dom; }
|
然后开始实现performUnitOfWork函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| function performUnitOfWork(fiber) { if (!fiber.dom) { fiber.dom = createDom(fiber); }
if (fiber.parent) { fiber.parent.dom.appendChild(fiber.dom); }
const elements = fiber.props.children; let index = 0; let prevSibling = null; while (index < elements.length) { const element = elements[index]; const newFiber = { type: element.type, props: element.props, parent: fiber, dom: null };
if (index === 0) { fiber.child = newFiber; } else { prevSibling.sibling = newFiber; }
prevSibling = newFiber; index++; }
if (fiber.child) { return fiber.child; }
let nextFiber = fiber; while (nextFiber) { if (nextFiber.sibling) { return nextFiber.sibling; } nextFiber = nextFiber.parent; } }
|
以上就是perfromUnitOfWork函数的实现。但是该函数有一个问题,他会一边构建Fiber树,一边频繁的操作DOM:fiber.parent.dom.appendChild(fiber.dom)
,更高效的方式是先构建DOM树,最后再将整个树挂载到容器元素上。而且由于performUnitOfWork函数是在requestIdleCallback中执行的,这意味着他只会在浏览器空闲的时候执行,当有有用户操作时构建DOM的过程会被中断,这样会导致用户看到不完整的DOM树。所以我们需要将构建DOM树和挂载DOM树过程分开。这就是Render阶段和Commit阶段。
首先删除performUnitOfWork函数中的DOM操作代码:
修改其他代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
|
function commitRoot() { }
function render(element, container) { wipRoot = { dom: container, props: { children: [element] } }; nextUnitOfWork = wipRoot; }
let nextUnitOfWork = null; let wipRoot = null;
function workLoop(deadline) { let shouldYield = false; while (nextUnitOfWork && !shouldYield) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork); shouldYield = deadline.timeRemaining() < 1; }
if (!nextUnitOfWork && wipRoot) { commitRoot(); }
requestIdleCallback(workLoop); } requestIdleCallback(workLoop);
|
接下来实现commitRoot函数,它会连接Fiber对象上的dom节点,然后挂载到容器元素上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function commitRoot() { commitWork(wipRoot.child); wipRoot = null; }
function commitWork(fiber) { if (!fiber) { return; }
const domParent = fiber.parent.dom; domParent.appendChild(fiber.dom); commitWork(fiber.child); commitWork(fiber.sibling); }
|
当前我们已经实现了一个最小可运行版本的React库了,接下来我们看一下运行效果,修改入口文件index.js,然后执行npm run serve启动项目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import React from "./lib/react-slim";
const element = <div> <h1>Hello, React</h1> <h2>Hi, Fiber</h2> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> </div>;
React.render(element, document.getElementById("root"));
|
项目启动后,可以在浏览器中看到渲染的结果。
Reconciliation算法
目前为止,我们已经实现了一个简单的React,但是只能新增元素,当组件状态更新时,我们还需要更新或删除元素。这就是Reconciliation算法的作用,它是一个深度优先遍历算法,会比较新旧Fiber树,标记需要更新或删除的节点,然后在commit阶段真正更新或删除他们。
那要如何实现呢?首先组件更新时我们会在render函数中收到新的element元素,其次在commit阶段我们还需要一个变量currentRoot保存最新Fiber树的根节点,最后需要为每一个Fiber节点添加一个alternate属性,用来保存上一次的Fiber节点。修改代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
function commitRoot() { commitWork(wipRoot.child); currentRoot = wipRoot; wipRoot = null; }
function render(element, container) { wipRoot = { dom: container, props: { children: [element] }, alternate: currentRoot }; nextUnitOfWork = wipRoot; }
let currentRoot = null;
|
我们的performUnitOfWork函数只能创建节点,也需要修改一下,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
|
function performUnitOfWork(fiber) { if (!fiber.dom) { fiber.dom = createDom(fiber); }
const elements = fiber.props.children; reconcileChildren(fiber, elements); if (fiber.child) { return fiber.child; }
let nextFiber = fiber; while (nextFiber) { if (nextFiber.sibling) { return nextFiber.sibling; } nextFiber = nextFiber.parent; } }
function reconcileChildren(wipFiber, elements) { let index = 0; let oldFiber = wipFiber.alternate && wipFiber.alternate.child; let prevSibling = null;
while (index < elements.length || oldFiber != null) { const element = elements[index]; let newFiber = null;
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) { 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: null, parent: wipFiber, alternate: null, effectTag: "PLACEMENT" }; }
if (oldFiber && !sameType) { oldFiber.effectTag = "DELETION"; deletions.push(oldFiber); }
if (oldFiber) { oldFiber = oldFiber.sibling; }
if (index === 0) { wipFiber.child = newFiber; } else if (element) { prevSibling.sibling = newFiber; }
prevSibling = newFiber; index++; } }
let deletions = null
function render(element, container) { wipRoot = { dom: container, props: { children: [element], }, alternate: currentRoot, } deletions = [] nextUnitOfWork = wipRoot }
|
接下来总结一下Reconciliation算法的实现:
- 首先判断旧的Fiber节点和新的element元素类型是否相同,如果相同则只需要更新Fiber节点的属性props
- 如果类型不同,直接根据element元素创建新的Fiber节点
- 如果类型不同,element元素不存在,说明新的Fiber树不需要该节点了,此时将旧的Fiber节点标记为删除
以上就是React Fiber架构能够在O(n)事件复杂度内完成Fiber树遍历的大致实现。需要声明的是,我们实现的Reconciliation算法只是一个简化版本,React的Reconciliation算法还有很多优化,如Diff算法、双缓存以及key的使用等等。
再下一步,修改commit阶段的代码,根据不同的effectTag属性,执行不同的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
function updateDom() { }
function commitRoot() { deletions.forEach(commitWork); commitWork(wipRoot.child); currentRoot = wipRoot; wipRoot = null; }
function commitWork(fiber) { if (!fiber) { return; }
const domParent = fiber.parent.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) { domParent.appendChild(fiber.dom); } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) { updateDom(fiber.dom, fiber.alternate.props, fiber.props); } else if (fiber.effectTag === "DELETION") { domParent.removeChild(fiber.dom); }
commitWork(fiber.child); commitWork(fiber.sibling); }
|
最后我们实现最复杂的updateDom函数,用于更新节点属性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
|
function createDom(fiber) { const dom = fiber.type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom; }
const isEvent = (key) => key.startsWith('on');
const isProperty = (key) => key !== 'children' && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (prev, next) => (key) => !(key in next); function updateDom(dom, prevProps, nextProps) { 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]); }); }
|
此时我们可以在浏览器中看到渲染的结果,当组件状态更新时,会触发Reconciliation算法更新或删除节点。我们修改index.js文件测试组件状态更新的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import React from "./lib/react-slim";
let count = 0;
function handleClick() { count++; render(); }
function render() { const element = <div> <h1 onClick={handleClick}>Hello, {count}</h1> <h2>{ count % 2 === 0 ? 'Even' : 'Odd' }</h2> <ul> <li>1</li> <li>2</li> { count % 2 === 0 ? <li>3</li> : null } </ul> </div>; React.render(element, document.getElementById("root")); }
render();
|
点击h1元素,可以看到count会递增,同时h2元素的文本会更新,ul元素会根据count的奇偶性显示或隐藏第三个li元素。
函数式组件
上面在演示demo时,我们创建了一个element元素,但是实际开发中,我们会创建一个函数式组件,然后在render函数中调用该组件。基本形式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function App({count}) { return <div> <h1 onClick={handleClick}>Hello, {count}</h1> <h2>{ count % 2 === 0 ? 'Even' : 'Odd' }</h2> <ul> <li>1</li> <li>2</li> { count % 2 === 0 ? <li>3</li> : null } </ul> </div>; }
React.render(<App count={1} />, document.getElementById("root"));
|
所以我们要实现函数式组件,首先修改performUnitOfWork函数,判断当前节点是否是函数式组件。
- 如果是函数式组件,则调用该函数将返回的element元素添加到fiber节点的children属性中
- 如果是普通元素,则继续执行原来的逻辑
- 其余逻辑保持不变
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| function performUnitOfWork(fiber) { const isFunctionComponent = fiber.type instanceof 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; } }
function updateFunctionComponent(fiber) { const children = [fiber.type(fiber.props)]; reconcileChildren(fiber, children); }
function updateHostComponent(fiber) { if (!fiber.dom) { fiber.dom = createDom(fiber); }
reconcileChildren(fiber, fiber.props.children); }
|
接下来修改commit阶段的代码,因为函数式组件的Fiber节点没有dom属性,所以修改commitWork函数中更新和删除节点的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| function commitWork(fiber) { if (!fiber) { return; }
let domParentFiber = fiber.parent; while (!domParentFiber.dom) { domParentFiber = domParentFiber.parent; } const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) { domParent.appendChild(fiber.dom); } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) { updateDom(fiber.dom, fiber.alternate.props, fiber.props); } else if (fiber.effectTag === "DELETION") { commitDeletion(fiber, domParent); }
commitWork(fiber.child); commitWork(fiber.sibling); }
function commitDeletion(fiber, domParent) { if (fiber.dom) { domParent.removeChild(fiber.dom); } else { commitDeletion(fiber.child, domParent); } }
|
再次修改index.js测试函数式组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import React from "./lib/react-slim";
function App({count}) { return <div> <h1>Hello, {count}</h1> <h2>{ count % 2 === 0 ? 'Even' : 'Odd' }</h2> <ul> <li>1</li> <li>2</li> { count % 2 === 0 ? <li>3</li> : null } </ul> </div>; }
React.render(<App count={1} />, document.getElementById("root"));
|
经过上面的修改,函数式组件的功能就完成了,但是目前组件还只能接受props,不能使用state,接下来我们实现useState函数。
Hooks
新增函数useState,用于实现函数式组件的状态管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| let wipFiber = null;
let hookIndex = null; function updateFunctionComponent(fiber) { wipFiber = fiber; hookIndex = 0; wipFiber.hooks = []; const children = [fiber.type(fiber.props)]; reconcileChildren(fiber, children); }
function useState(initial) { const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]; const hook = { state: oldHook ? oldHook.state : initial, queue: [] };
const actions = oldHook ? oldHook.queue : []; actions.forEach(action => { const isFunction = action instanceof Function; hook.state = isFunction ? action(hook.state) : action; });
const setState = action => { 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]; }
|
接下来修改index.js文件,测试useState函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import React from "./lib/react-slim";
function App() { const [count, setCount] = React.useState(0);
const handleClick = () => { setCount(count + 1); };
return <div> <h1 onClick={handleClick}>Hello, {count}</h1> <h2>{ count % 2 === 0 ? 'Even' : 'Odd' }</h2> <ul> <li>1</li> <li>2</li> { count % 2 === 0 ? <li>3</li> : null } </ul> </div>; }
React.render(<App count={1} />, document.getElementById("root"));
|
总结
通过以上代码我们就完成了mini版的React(完整代码点击这里),实现了React的核心功能,包括函数式组件、并发模式、Fiber架构、Reconciliation算法、Hooks。通过实现mini版React,我们可以更好的理解React的基本原理,为阅读React源码打下基础。当然,mini版React还有很多功能没有实现,如Context、useEffect、合成事件等,这些功能可以通过阅读React源码来实现。
参考链接
- https://pomb.us/build-your-own-react/
- https://webdeveloper.beehiiv.com/p/build-react-400-lines-code