一个学习 React 源码的例子
- JSX
- createElement
- Render
- fiber
- functional component
- hooks
通过 Create React App 创建初始的 React
项目
然后在浏览器打开 http://localhost:3000 查看
JSX 写起来像 html, 其实是 babel 转义成 React.createElement
来执行的,用来构建虚拟 dom,
<h1 title="app">Yolk test</h1>
解析成:
React.createElement(
"h1",
{ title: "app"},
"Yolk test"
)
这也是为什么用了 JSX 的文件,必须要 import React from 'react'
的原因所在
我们知道通过 createElement 构建虚拟 dom 这种形式是组件化的最佳实践,那为什么要用 JSX 呢?这就需要从虚拟 dom 的概念说起,简单来说,虚拟 dom 就是用 JS 对象来描述真实的 dom。
const element = {
type: "h1",
props: {
title: "app",
children: "Yolk, test",
}
}
我们用 JS 对象,来完整地描述 dom, 后续有任何的修改需求,只需要频繁的操作这个 dom,尽可能少的操作真实 dom, 这也就是为什么虚拟 dom 性能良好的原因所在: 在两次操作之间进行 diff,只做最少的修改次数。
我们通过改写 index.js
文件,实现一个原始的 JSX 项目:
// index.js
import React from 'react'
import ReactDOM from 'react-dom'
const App = (
<div>
<h1>Yolkjs</h1>
<p>react dom</p>
<a href="https://jd.com">shop</a>
</div>
)
ReactDOM.render(App, document.getElementById('root'))
由于可以预知我们需要要构建虚拟 dom,上述 JSX 应该会解析成这样:
React.createElement(
"div",
{id: "app"},
React.createElement(
"h1",
null,
"Yolkjs"
),
React.createElement(
"p",
null,
"react dom"
),
React.createElement(
"a",
{ href: "https://jd.com"},
"shop"
)
)
期待返回的对象如下:
const element = {
type: "div",
props: {
id: "app",
children: [
{ type: "h1", props: { value: "Yolkjs"}},
{ type: "p", props: { value: "react dom"}},
{ type: "a", props: { href: "https://jd.com", value: "shop"}},
]
}
}
所以我们 yolkjs
的源码中的 createElement()
方法也可以想到该如何实现了, 其实就是在 createElement
的时候创建一个合适的 JSON 结构数据。让我们先在 src
目录下创建一个 yolkjs
文件夹,然后再创建一个 index.js
文件,实现我们的源码
// /src/yolkjs/index.js
function createElement(type, props, ...children) {
// 删除掉开发调试的 source,避免干扰
delete props.__source
return {
type,
props: {
...props,
children,
}
}
}
function render(vdom, container) {
// 我们先在页面上渲染处 dom 的 json 结构
container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
}
export default {
createElement,
render,
}
最后我们再改造下 /src/index.js
的代码:
// /src/index.js
// import React from 'react'
// import ReactDOM from 'react-dom'
import React from './yolkjs'
const ReactDOM = React
// ...
运行 yarn start
,然后访问 http://localhost:3000 应该可以得到如下界面:
这个 json 基本符合我们一开始的预期效果,不过为了方便后面操作 dom 我们需要简单改造一下,把 children
中的文本也改造成一个节点,所以我们需要实现一个 createTextElement()
的方法,并且遍历 children 的属性根据类型来判断是否返回文本节点:
// /src/yolkjs/index.js
function createElement(type, props, ...children) {
delete props.__source
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === 'object' ? child : createTextElement(child)
)
}
}
}
function createTextElement(text) {
return {
type: 'TEXT',
props: {
nodeValue: text,
children: [],
}
}
}
// ...
再访问下 http://localhost:3000 应该可以得到结果:
现在的 render()
只是简单地渲染一个虚拟对象结构,我们需要转换成真实的 dom 渲染,这一步其实没啥特别的,就是挨个遍历,创建dom,然后添加到父视图中。
// /src/yolkjs/index.js
// ...
function render(vdom, container) {
// container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
const dom = vdom.type === "TEXT" ? document.createTextNode("") : document.createElement(vdom.type)
// 设置属性
Object.keys(vdom.props).forEach(name => {
if (name !== 'children') {
// @todo: 属性判断,事件处理
dom[name] = vdom.props[name]
}
})
// 递归渲染的子元素
vdom.props.children.forEach(child => render(child, dom))
container.appendChild(dom)
}
// ...
细心的同学已经发现,上面的 render()
函数,一旦开始就会不断地递归,无法停止,本身在这里是没啥问题,但是如果应用变大以后就会有卡顿。后面状态修改后的 diff 过程也是一样的道理,整个 vdom 变大以后,diff 的过程也会递归过多而导致卡顿。
那如何解决这个问题呢?
浏览器提供了一个 api reauestIdleCallback
可以利用浏览器的业余时间,我们可以把任务拆解成一个一个的小任务,然后利用浏览器空闲的时间来做 diff, 如果当前任务来了,比如用户的点击或者动画,就会先执行,等待空闲后再回去把 requestIdleCallback
没完成的任务完成
// /src/yolkjs/index.js
// ...
function render(vdom, container) {
//...
}
// 下一个单元任务
// render 函数会初始化第一个任务
let nextUnitOfWork = null
// 调度我们的 diff 或者渲染任务
function workLoop(deadline) {
// 有下一个任务,且当前帧还没有结束
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
//
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
requestIdleCallback(workLoop)
}
function performUnitOfWork(fiber) {
// 获取下一个任务
// 根据当前任务获取下一个任务
}
// 启动空闲时间渲染
requestIdleCallback(workLoop)
// ...
PS: react 已经重写了 requestIdleCallback, 不用系统的,但是整个过程是一致的。
我们有了调度逻辑,之前的 vdom 结构是一个树形结构,diff 过程是没法中断的,为了我们 vdom 树之间的关系,我们需要把树形结构的内部关系改造成链表(方便中止),之前 children 作为一个数组递归遍历,现在 父 => 子, 子 => 父, 子 => 兄弟, 都有关系。
整个任务从 render 开始, 每次只遍历一个小单元,一旦被打断,就去执行优先级高的任务(用户交互,动画),回来后,由于回来的元素知道父,子,兄弟元素,很容易恢复遍历的状态。
首先我们需要把创建 dom 的函数提取出来,新建一个 createDom()
方法
// /src/yolkjs/index.js
/**
* 通过虚拟 dom 新建 dom
* @param {*} vdom
*/
// ...
function createDom(vdom) {
// 创建 dom
const dom = vdom.type === "TEXT"
? document.createTextNode("")
: document.createElement(vdom.type)
// 设置属性
Object.keys(vdom.props).forEach(name => {
if (name !== 'children') {
// @todo: 属性判断,事件处理
dom[name] = vdom.props[name]
}
})
return dom
}
function render(vdom, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [vdom]
}
}
// container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
// 递归渲染的子元素
// vdom.props.children.forEach(child => render(child, dom))
// container.appendChild(dom)
}
// 下一个单元任务
// render 函数会初始化第一个任务
let nextUnitOfWork = null
// 调度我们的 diff 或者渲染任务
function workLoop(deadline) {
// 有下一个任务,且当前帧还没有结束
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
//
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
requestIdleCallback(workLoop)
}
function performUnitOfWork(fiber) {
// 获取下一个任务
// 根据当前任务获取下一个任务
if (!fiber.dom) {
// 不是入口
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
const elements = fiber.props.children
// 构建成 fiber
let index = 0
let preSibling = null
while (index < elements.length) {
let element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
if (index === 0) {
// 第一个元素,是父 fiber 的 child 属性
fiber.child = newFiber
} else {
// 其他元素是兄弟元素
preSibling.sibling = newFiber
}
preSibling = fiber
index++
// fiber 基本结构构建完毕
}
// 找下一个任务
// 先找子元素
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
// 没有子元素,就找兄弟元素
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
// 没有兄弟元素,找父元素
nextFiber = nextFiber.parent
}
}
// ...
给 dom 添加节点的时候,如果渲染过程中被打断,ui 渲染会变得很奇怪,所以我们需要把 dom 操作独立出来。用一个全局变量来存储正在工作的根节点。
// /src/yolkjs/index.js
// ...
function render(vdom, container) {
wipRoot = {
dom: container,
props: {
children: [vdom],
}
}
nextUnitOfWork = wipRoot
// container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
// 递归渲染的子元素
// vdom.props.children.forEach(child => render(child, dom))
// container.appendChild(dom)
}
function commitRoot() {
commitWorker(wipRoot.child)
wipRoot = null
}
function commitWorker(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWorker(fiber.child)
commitWorker(fiber.sibling)
}
// 下一个单元任务
// render 函数会初始化第一个任务
let nextUnitOfWork = null
let wipRoot = null
// 调度我们的 diff 或者渲染任务
function workLoop(deadline) {
// 有下一个任务,且当前帧还没有结束
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
//
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
if (!nextUnitOfWork && wipRoot) {
// 没有任务了,并且根节点还在
commitRoot()
}
requestIdleCallback(workLoop)
}
function performUnitOfWork(fiber) {
// 获取下一个任务
// 根据当前任务获取下一个任务
if (!fiber.dom) {
// 不是入口
fiber.dom = createDom(fiber)
}
// // 真实的 dom 操作
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom)
// }
// ...
}
// ...
现在已经能够渲染了,但是如何更新和删除节点呢?这里我们需要保存一个被中断前工作的 fiber 节点 currentRoot
// /src/yolkjs/index.js
let currentRoot = null
let deletions = null
每个 fiber 都有一个字段,存储这上一个状态的 fiber 并且针对子元素,设计一个 reconcileChildren 函数,并且我们需要对 fiber 增加一个 base 字段进行对比。同时还要根据节点类型进行比较,如果类型相同,dom 可以复用,更新节点即可,如果类型不同,就直接替换,否则就直接删除。
function reconcileChildren(wipFiber, elements) {
// 构建成 fiber
let index = 0
let oldFiber = wipFiber.base && wipFiber.base.child
let preSibling = null
// console.log('oldFiber:', oldFiber)
while (index < elements.length) {
// while (index < elements.length) {
let element = elements[index]
let newFiber = null
// 对比 oldfiber 的状态和当前 element
// 先比较类型,
const sameType = oldFiber && element && oldFiber.type === element.type
if (sameType) {
// 复用节点,更新
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
base: oldFiber,
effectTag: "UPDATE",
}
}
if (!sameType && element) {
// 新增节点
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
base: null,
effectTag: "PLACEMENT",
}
}
if (!sameType && oldFiber) {
// 删除
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
// 第一个元素,是父 fiber 的 child 属性
wipFiber.child = newFiber
} else {
// 其他元素是兄弟元素
preSibling.sibling = newFiber
}
preSibling = newFiber
index++
// fiber 基本结构构建完毕
}
}
我们还要新增一个方法 updateDom()
,来完成事件的监听:
// /src/yolkjs/index.js
// ...
/**
* 通过虚拟 dom 新建 dom 元素
* @param {*} vdom 虚拟 dom
*/
function createDom(vdom) {
const dom = vdom.type === "TEXT"
? document.createTextNode("")
: document.createElement(vdom.type)
updateDom(dom, {}, vdom.props)
// Object.keys(vdom.props).forEach(name => {
// if (name !== "children") {
// // @todo: 属性判断,事件处理
// dom[name] = vdom.props[name]
// }
// })
return dom
}
function updateDom(dom, preProps, nextProps) {
/**
* 1. 规避 children 属性
* 2. 老得存在,取消
* 3. 新的存在,新增,
*/
// @todo 兼容性问题
Object.keys(preProps)
.filter(name => name !== 'children')
.filter(name => !(name in nextProps))
.forEach(name => {
if (name.slice(0, 2) === 'on') {
// onclick => chick
dom.removeEventListener(name.slice(2).toLowerCase(), preProps[name], false)
} else {
dom[name] = ''
}
})
Object.keys(nextProps)
.filter(name => name !== 'children')
.forEach(name => {
if (name.slice(0, 2) === 'on') {
// onclick => chick
dom.addEventListener(name.slice(2).toLowerCase(), preProps[name], false)
} else {
dom[name] = nextProps[name]
}
})
}
最后修改下 render 和 commit 相关的一些代码,我们即可实现 fiber 的架构
// /src/yolkjs/index.js
// ...
function render(vdom, container) {
wipRoot = {
dom: container,
props: {
children: [vdom],
},
base: currentRoot, // 分身
}
deletions = []
nextUnitOfWork = wipRoot
// container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
// 递归渲染的子元素
// vdom.props.children.forEach(child => render(child, dom))
// container.appendChild(dom)
}
function commitRoot() {
deletions.forEach(commitWorker)
commitWorker(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
function commitWorker(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
// domParent.appendChild(fiber.dom)
if (fiber.effectTag === 'PLACEMENT' && fiber.dom !== null) {
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === 'DELETION') {
domParent.removeChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
// 更新dom
updateDom(fiber.dom, fiber.base.props, fiber.props)
}
commitWorker(fiber.child)
commitWorker(fiber.sibling)
}
// ...
为了让我们方便测试函数式组件的实现,我们先把页面代码改造成函数式组件的写法:
// /src/index.js
// ...
function App(props) {
return (
<div id="app">
<h1>hello, {props.title}</h1>
<p>react dom</p>
<a href="https://jd.com">shop</a>
</div>
)
}
let element = <App title="yolkjs" />
ReactDOM.render(element, document.getElementById('root'))
其实函数也是一样的渲染,只不过 type 是函数,而不是字符串,我们需要在处理 vdom 的时候识别其和普通 dom 的区别
- 根据type 执行不同的函数来初始化 fiber,
// /src/yolkjs/index.js
// ...
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function
console.log('iisFunctionComponents', isFunctionComponent, fiber.type, fiber)
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
// dom
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)
}
// // 真实的 dom 操作
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom)
// }
const elements = fiber.props.children
reconcileChildren(fiber, elements)
}
// ...
- 函数组件没有 dom 属性(没有 dom 属性,查找 dom 需要向上循环查找)
// /src/yolkjs/index.js
// ...
function commitWorker(fiber) {
if (!fiber) {
return
}
// const domParent = fiber.parent.dom
// domParent.appendChild(fiber.dom)
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 === 'DELETION') {
commitDeletion(fiber, domParent)
// domParent.removeChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
// 更新dom
updateDom(fiber.dom, fiber.base.props, fiber.props)
}
commitWorker(fiber.child)
commitWorker(fiber.sibling)
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
// ...
实际上 hooks 是通过链表来查找具体的 state, 这里我们通过数组来简单模拟一下,把 userState 存储的 hooks, 存储在 fiber 中