敲一下React-mini版代码
最近学习一篇文章关于React
原理基础实现,收获良多,想在此写一下个人收获与总结。
0、jsx
const element = (
<input value="todo" />
)
写一个最简单的jsx,我们知道它将会转换成一个object对象,如下面
const element = {
type: 'input',
props: {
value: 'todo',
children: [],
}
}
1、createElement
那具体是怎样将jsx转换成一个object对象呢?那么我需要写一个命名为createElement
的函数进行处理
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children
}
}
}
const element = createElement(
'input',
{
value: 'todo'
}
)
但是如果有嵌套子元素的jsx呢,例如
const element = (
<div id="foo">
<b />
<b />
</div>
)
那么将会变成下面
/* @return
{
type: 'div',
props: {
id: 'foo',
children: [
{
type: 'b',
props: {
children: []
}
},
{
type: 'b',
props: {
children: []
}
},
]
}
}
* /
const element = createElement(
'div',
{
id: 'foo'
},
createElement('b'),
createElement('b'),
)
当然还有一种特殊情况,那就是子元素含有文本
const element = (
<div id="foo">
<b />
bar
</div>
)
那么调用的时候为
/*
@return
{
type: 'div',
props: {
id: 'foo',
children: [
{
type: 'b',
props: {
children: []
}
},
'bar'
]
}
}
*/
const element = createElement(
'div',
{
id: 'foo'
},
createElement('b'),
'bar'
)
可以看到div
中children
数组包含了一个对象和一个字符串'bar'
,为了后续方便处理,我们需要把文本元素也需要转换成一种我们约定好的对象,如下,那么createElement
也对应改一下
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
}
}
}
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === 'object'
? child
: createTextElement(child)
)
}
}
}
最终转换为
{
"type": "div",
"props": {
"id": "foo",
"children": [
{
"type": "b",
"props": { "children": [] }
},
{
"type": "TEXT_ELEMENT",
"props": { "nodeValue": "bar", "children": [] }
}
]
}
}
2、render
我们已经有了jsx对象了,那么我们要怎么渲染到页面容器上去呢,我们会去调用ReactDOM.render
const container = document.getElementById('root')
ReactDOM.render(element, container)
render方法基础实现,如下
function render(element, container) {
const dom =
element.type == 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type)
// element.props上的属性除了children,都属于dom节点属性
const isProperty = key => key !== 'children'
// 给dom添加属性
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
// 递归遍历dom里面的子元素
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
const ReactDOM = {
render
}
3、concurrent mode
上面截止,我们可以编写jsx
,然后交给createElemnt
转换为js对象,最终交给render
渲染。
上面render
使用递归遍历的,这可能会有些问题。一旦render
开始执行,直到dom树渲染完成之后,render
函数才会结束。万一dom树非常大,可能会阻塞主线程太长时间。如果浏览器需要处理用户输入或平滑动画等高优先级工作,则必须等到渲染完成才会去处理。
为了解决上述问题,React
约定一种叫fiber
模型的虚拟树, React
是利用空闲时间(不会影响延迟关键事件,如动画和输入响应)进行虚拟dom树的构建,最终整个虚拟dom树构建完成后,才进行渲染工作。那究竟React
是怎么让空闲时间时进行虚拟dom构建呢?
虽然React
已经弃用了requestIdleCallback方法,因为这个api存在一些问题(执行频率不够实现流畅的UI渲染,兼容性等等)。
https://github.com/facebook/react/issues/11171#issuecomment-417349573
https://github.com/hushicai/hushicai.github.io/issues/48
但我们这里仍然可以使用这个api达到实现简易版react的concurrent mode
功能。如下,我们不停地调用requestIdleCallback
,一旦发现每一帧中有足够的空闲时间,并且还有未完成的任务,就会去执行任务。
let nextUnitOfWork = null
function workLoop(deadline) {
// 是否停止
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
// 空闲时间小于1ms则停止
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork() {
// ...
}
4、fiber模型
接着来介绍一下fiber模型,假如有一段jsx
const element = (
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>
)
之前使用createElement
方法会将jsx中每个元素转换成含有type、children等属性的对象。基于此,我们要对这些对象丰富一下,设计成fiber模型。规则就是
- 每个元素都会有指向其父元素的属性(根元素除外)
- 每个元素都会有指向其第一个子元素的属性(若有儿子)
- 每个元素都会有指向与其相邻的弟弟元素的属性(若有弟弟)
第2节的render方法是从根节点一次性递归实现渲染。现在我们必须进行优化一下,将每一个元素的添加属性及添加子元素的操作都分成一个个单独的任务,利用空闲时间执行任务,当没有空闲时间时,记录下当前任务并且停止执行,到下一次空闲时间时继续执行,直到所有任务完成,那么即可进行渲染任务。同时上一节中有个关键的函数performUnitOfWork
需要实现一下
function performUnitOfWork(fiber) {
if (!fiber.dom) {
// 创建fiber对应的dom片段
fiber.dom = createDom(fiber)
}
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
}
}
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
}
那么render函数更改为
let nextUnitOfWork = null
function workLoop(deadline) {
// 是否停止
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function render(element, container) {
// 开始render,nextUnitOfWork从根节点开始
nextUnitOfWork = {
dom: container,
props: {
children: [element],
}
}
}
const ReactDOM = {
createElement,
render
}
5、render and commit phases
上一节说到利用空闲时间执行每一个单元任务,为的是创建fiber
模型,那最终整个fiber
虚拟树构建完成,就应该进行真正的dom渲染了。
那问题来了,我们要怎么才能知道虚拟树构建完成,怎么才能知道要进行渲染工作了呢?
首先回顾fiber
模型和performUnitOfWork
函数,可以了解到,构建虚拟树的过程,类似DFS(深度优先遍历)。从根节点开始深度往下遍历,再回流遍历,直至重新回到根节点。由于根节点没有弟弟元素和父元素,即代表构建完成(performUnitOfWork()
返回值为undefined
)。
此时我们又要更改一下render
函数,声明一个变量wipRoot
来存储构建中的虚拟树的根节点。
let nextUnitOfWork = null
// 记录根节点
let wipRoot = null
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
在第3节concurrent mode
中,我们知道react-mini
是会不停地调用requestIdleCallback
来实现分片任务,那么要在这里判断构建树是否完成,并且决定开始渲染工作。
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
函数很简单
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)
}
6、Reconciliation
到此为止,我们实现了初始化渲染工作。那要是再次更新渲染呢,我们需要考虑更新dom和删除dom的操作。
首先,再次渲染我们并不需要完整构建新的dom树,我们可以把当前的虚拟树和即将渲染的虚拟树进行对比,有更改或删除的元素我们才需要进行操作,尽量减少不必要的渲染。
因此我们需要个变量来记录当前虚拟树,并且和即将渲染的虚拟树建立起联系
let nextUnitOfWork = null
// 当前虚拟树根节点
let currentRoot = null
// 进行中虚拟树的根节点
let wipRoot = null
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
// 建立关系
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
function commitRoot() {
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
那么对比的过程就在performUnitOfWork
,我们需要对其更改一下
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
}
}
这里抽出来一个函数reconcileChildren
,它主要的功能就是进行fiber
对比。
// oldFiber: div --> div --> div --> div
// elements: div --> div --> p
// 操作 update update add delete
这里是将老fiber,和新的jsx按位置顺序一一对比,
- 相同位置的且元素类型相同,可以认为是update更新;
- 相同位置但元素类型不同的,认为是add新增;
- 相同位置老fiber有元素而新的jsx没有元素了,则是delete删除。
例如上面的例子。其实这里没有考虑到key值比较(暂时省略),只是按位置顺序一一对比,从这可以理解到React
需要给遍历元素加key值的目的。https://react.docschina.org/docs/lists-and-keys.html#keys
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) {
// update
}
if (element && !sameType) {
// add
}
if (oldFiber && !sameType) {
// delete
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
wipFiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
那具体怎么设定新的newFiber
呢,我们增加多一个字段effectTag
,表示这个newFiber
是增删改的哪一种,方便后续渲染任务时判断。
if (sameType) {
// update
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE',
}
}
if (element && !sameType) {
// add
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT',
}
}
if (oldFiber && !sameType) {
// delete
oldFiber.effectTag = 'DELETION'
deletions.push(oldFiber)
}
由于需要删除的旧fiber
不需要放回虚拟树上,所以单独用deletions
数组变量存起来,后续渲染时遍历数组卸载对应的dom。
deletions
还需要放到其他函数中去。
let nextUnitOfWork = null
// 当前虚拟树根节点
let currentRoot = null
// 进行中虚拟树的根节点
let wipRoot = null
// 需要删除的节点
let deletions = null
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
// 重置需要删除的节点
deletions = []
nextUnitOfWork = wipRoot
}
function commitRoot() {
// 遍历卸载节点
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
那么到此fiber对比结束,又到了渲染任务了。我们需要对commitWork
函数进行修改,对增删改不同effectTag
的fiber进行对应的操作。
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
,它的功能就是对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]
)
})
}
因为之前的createDom
函数没有考虑到添加事件监听,也对其更改一下
function createDom(fiber) {
const dom =
fiber.type == 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(fiber.type)
updateDom(dom, {}, fiber.props)
return dom
}
至此React-mini
主要功能算是完成了。后面的算是补充。
7、Function Components
我们先回顾之前createElement
的章节
// 1、编写jsx
const element = (
<input value="todo" />
)
// 2、转换jsx
const element = createElement(
'input',
{
value: 'todo'
}
)
// 3、转换jsx得到对象
const element = {
type: 'input',
props: {
value: 'todo',
children: [],
}
}
那如果编写的jsx是函数组件呢
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
按照之前的规则,它同样会被转换
function App(props) {
return Didact.createElement(
'h1',
null,
'Hi ',
props.name
)
}
const element = Didact.createElement(App, {
name: 'foo',
})
const element = {
type: App,
props: {
name: 'foo',
children: [
{
type: 'h1',
props: {
children: [
{
type: 'TEXT_ELEMENT',
props: { 'nodeValue': 'Hi ', 'children': [] }
},
{
type: 'TEXT_ELEMENT',
props: { 'nodeValue': 'foo', 'children': [] }
}
]
}
}
]
}
}
这里比较特殊的地方在于element的属性type不再是标签类型的字符串,而是函数。因此之前的代码又需要改动了。
首先是performUnitOfWork
函数
function performUnitOfWork(fiber) {
// if (!fiber.dom) {
// fiber.dom = createDom(fiber)
// }
// const elements = fiber.props.children
// reconcileChildren(fiber, elements)
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
}
}
这里根据type属性不同区分处理
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
reconcileChildren(fiber, elements)
}
接着再来思考下函数组件有什么特点,如下面
function App() {
return (
<span>foo</span>
)
}
const element = (
<div id="root">
<App />
</div>
)
// fiber模型
div --> App --> span
// 最终渲染
<div>
<span>foo</span>
</div>
其实App这一层的fiber节点是没有对应的dom的,span标签应该跨过App节点,作为div标签的子元素进行渲染。因此回顾下commitWork
function commitWork(fiber) {
if (!fiber) {
return
}
// 这里就不能直接找parent.dom了,有可能是parent.dom.dom,或者更上一层
// const domParent = fiber.parent.dom
// 应该改为如下,也就是找有dom的父节点
let domParentFiber = fiber.parent
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
// ...
}
完整的commitWork
如下
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)
}
if (fiber.effectTag !== 'DELETION') {
commitWork(fiber.child)
commitWork(fiber.sibling)
}
}
删除节点的时候也同理
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
8、 Hooks
自从hooks面世之后,有人欢喜有人愁。欢喜的人觉得使用hooks很简便,例如下方简单的例子
function Counter() {
const [state, setState] = useState(1)
return (
<button onClick={() => setState(c => c + 1)}>
Count: {state}
</button>
)
}
但具体hooks是怎么运作的?我们需要从创建函数组件的地方着手,也就是之前的updateFunctionComponent
函数
// 构建中的fiber
let wipFiber = null
// 记录hook执行位置
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
可以看到,我们声明了两个变量wipFiber
和hookIndex
,分别存储构建中的fiber和记录hook位置。
下面是useState
的实现
function useState(inital) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : inital,
queue: [],
}
// fiber节点上存储hook状态
wipFiber.hooks.push(hook)
// hook位置向前一步
hookIndex++
return [hook.state]
}
这里hook使用数组来存储状态,每使用一次hook,数组就前进一步。因此需要旧hook和新hook位置顺序一一对应得上,新hook才能够准确依赖旧hook的状态。因此也就很好理解了React
为何需要约定使用hook的规则 https://react.docschina.org/docs/hooks-rules.html
事实上,React
是使用链表来存储hook状态,这里为了方便使用了数组。
目前使用useState
只返回了state
,还需要补充一下setState
function useState(inital) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : inital,
queue: [],
}
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
const isFunction = action instanceof Function
// 更新state
hook.state = isFunction ? action(hook.state) : action
})
const setState = action => {
hook.queue.push(action)
// 像render函数一样,触发虚拟树构建并渲染
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
// fiber节点上存储hook状态
wipFiber.hooks.push(hook)
// hook位置向前一步
hookIndex++
return [hook.state, setState]
}
至此,React
的mini版就完成啦!
完整代码https://github.com/Zeng-J/react-mini-learn
总结
通过这次学习,对React
基本理念有了一些了解。但其实React
的min版还有很多细节未完善,例如没有考虑key对比、没有考虑回收复用旧的fiber等等。不管怎样,有了基础理念的了解,后续肝源码的时候就稍微没那么吃力了。