当咱们由浅入深地认知一样新事物的时候,往往须要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而理解了具体的 What 和 How 之后,往往可能更加具象地答复实践层面的 Why,因而,在进入 Why 的摸索之前,咱们先整体感知一下 What 和 How 两个过程。
What
关上 React 官网,第一眼便能看到官网给出的答复。
React 是用于构建用户界面的 JavaScript 库。
不晓得你有没有想过,构建用户界面的形式有千百种,为什么 React 会突出?同样,咱们能够从 React 哲学里失去回应。
咱们认为,React 是用 JavaScript 构建疾速响应的大型 Web 应用程序的首选形式。它在 Facebook 和 Instagram 上体现优良。
可见,要害是实现了 疾速响应 ,那么制约 疾速响应 的因素有哪些呢?React 是如何解决的呢?
How
让咱们带着下面的两个问题,在遵循实在的 React 代码架构的前提下,实现一个蕴含工夫切片、fiber
、Hooks
的繁难 React,并舍弃局部优化代码和非必要的性能,将其命名为 HuaMu
。
留神:为了和源码有点辨别,函数名首字母大写,源码是小写。
CreateElement
函数
在开始之前,咱们先简略的理解一下 JSX
,如果你感兴趣,能够关注下一篇《JSX
背地的故事》。
JSX
会被工具链 Babel
编译为 React.createElement()
, 接着React.createElement()
返回一个叫作 React.Element
的JS
对象。
这么说有些形象,通过上面 demo
看下转换前后的代码:
// JSX 转换前
const el = <h1 title="el_title">HuaMu<h1>;
// 转换后的 JS 对象
const el = {
type:"h1",
props:{
title:"el_title",
children:"HuaMu",
}
}
可见,元素是具备 type
和 props
属性的对象,而 CreateElement
函数的次要工作就是创立该对象。
/**
* @param {string} type HTML 标签类型
* @param {object} props 具备 JSX 属性中的所有键和值
* @param {string | array} children 元素树
*/
function CreateElement(type, props, ...children) {
return {
type,
props:{
...props,
children,
}
}
}
阐明:咱们将残余参数赋予
children
,扩大运算符用于结构字面量对象props
,对象表达式将依照key-value
的形式开展,从而保障props.children
始终是一个数组。接下来,咱们一起看下demo
:
CreateElement("h1", {title:"el_title"}, 'hello', 'HuaMu')
// 返回的 JS 对象
{
"type": "h1",
"props": {
"title": "el_title" // key-value
"children": ["hello", "HuaMu"] // 数组类型
}
}
留神:当
...children
为空或为原始值时,React 不会创立props.children
,但为了简化代码,暂不思考性能,咱们为原始值创立非凡的类型TEXT_EL
。
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_EL",
props: {
nodeValue: text,
children: []}
}
}
Render
函数
CreateElement
函数将标签转化为对象输入,接着 React 进行一系列解决,Render
函数将解决好的节点依据标记进行增加、更新或删除内容,最初附加到容器中。上面简略的实现 Render
函数是如何实现增加内容的:
- 首先创立对应的 DOM 节点,而后将新节点附加到容器中,并递归每个孩子节点做同样的操作。
-
将元素的
props
属性调配给节点。function Render(el,container) { // 创立节点 const dom = el.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(el.type); el.props.children.forEach(child => Render(child, dom)) // 为节点调配 props 属性 const isProperty = key => key !== 'children'; const setProperty = name => dom[name] = el.props[name]; Object.keys(el.props).filter(isProperty).forEach(setProperty) container.appendChild(dom); }
留神 :文本节点应用
textNode
而不是innerText
,是为了保障以雷同的形式看待所有的元素。
到目前为止,咱们曾经实现了一个繁难的用于构建用户界面的 JavaScript
库。当初,让 Babel
应用自定义的 HuaMu
代替 React,将 /** @jsx HuaMu.CreateElement */
增加到代码中,关上 codesandbox
看看成果吧。
并发模式
在持续向下摸索之前,咱们先思考一下下面的代码中,有哪些代码制约 疾速响应 了呢?
是的,在 Render
函数中递归每个孩子节点,即这句代码 el.props.children.forEach(child => Render(child, dom))
存在问题。一旦开始渲染,便不会进行,直到渲染了整棵元素树,咱们晓得,GUI
渲染线程与 JS
线程是互斥的,JS 脚本执行和浏览器布局、绘制不能同时执行。如果元素树很大,JS 脚本执行工夫过长,可能会阻塞主线程,导致页面掉帧,造成卡顿,且障碍浏览器执行高优作业。
那如何解决呢?
通过工夫切片的形式,行将工作合成为多个工作单元,每实现一个工作单元,判断是否有高优作业,若有,则让浏览器中断渲染。上面通过 requestIdleCallback
模仿实现:
简略阐明一下:
window.requestIdleCallback(cb[, options])
:浏览器将在主线程闲暇时运行回调。函数会接管到一个IdleDeadline
的参数,这个参数能够获取以后闲暇工夫(timeRemaining
)以及回调是否在超时前曾经执行的状态(didTimeout
)。- React 已不再应用
requestIdleCallback
,目前应用 scheduler package。但在概念上是雷同的。
根据下面的剖析,代码构造如下:
// 当浏览器准备就绪时,它将调用 WorkLoop
requestIdleCallback(WorkLoop)
let nextUnitOfWork = null;
function PerformUnitOfWork(nextUnitOfWork) {// TODO}
function WorkLoop(deadline) {
// 以后线程的闲置工夫是否能够在完结前执行更多的工作
let shouldYield = false;
while(nextUnitOfWork && !shouldYield) {nextUnitOfWork = PerformUnitOfWork(nextUnitOfWork) // 赋值下一个工作单元
shouldYield = deadline.timeRemaining() < 1; // 如果 idle period 曾经完结,则它的值是 0}
requestIdleCallback(WorkLoop)
}
咱们在 PerformUnitOfWork
函数里实现当前工作的执行并返回下一个执行的工作单元,可下一个工作单元如何疾速查找呢?让咱们初步理解 Fibers
吧。
Fibers
为了组织工作单元,即不便查找下一个工作单元,需引入 fiber tree
的数据结构。即每个元素都有一个 fiber
,链接到其第一个子节点,下一个兄弟姐妹节点和父节点,且每个fiber
都将成为一个工作单元。
// 假如咱们要渲染的元素树如下
const el = (
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>
)
其对应的 fiber tree
如下:
若将上图转化到咱们的代码里,咱们第一件事得找到 root fiber
,即在Render
中,设置 nextUnitOfWork
初始值为root fiber
,并将创立节点局部独立进去。
function Render(el,container) {
// 设置 nextUnitOfWork 初始值为 root fiber
nextUnitOfWork = {
dom: container,
props:{children:[el],
}
}
}
// 将创立节点局部独立进去
function CreateDom(fiber) {const dom = fiber.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(fiber.type);
// 为节点调配 props 属性
const isProperty = key => key !== 'children';
const setProperty = name => dom[name] = fiber.props[name];
Object.keys(fiber.props).filter(isProperty).forEach(setProperty)
return dom
}
残余的 fiber
将在 performUnitOfWork
函数上执行以下三件事:
- 为元素创立节点并增加到
dom
- 为元素的子代创立
fiber
-
抉择下一个执行工作单元
function PerformUnitOfWork(fiber) { // 为元素创立节点并增加到 dom if(!fiber.dom) {fiber.dom = CreateDom(fiber) } // 若元素存在父节点,则挂载 if(fiber.parent) {fiber.parent.dom.appendChild(fiber.dom) } // 为元素的子代创立 fiber const els = fiber.props.children; let index = 0; // 作为一个容器,存储兄弟节点 let prevSibling = null; while(index < els.length) {const el = els[index]; const newFiber = { type: el.type, props: el.props, parent: fiber, dom: null } // 子代在 fiber 树中的地位是 child 还是 sibling,取决于它是否第一个 if(index === 0){fiber.child = newFiber;} else {prevSibling.sibling = newFiber;} prevSibling = newFiber; index++; } // 抉择下一个执行工作单元,优先级是 child -> sibling -> parent if(fiber.child){return fiber.child;} let nextFiber = fiber; while(nextFiber) {if(nextFiber.sibling) {return nextFiber.sibling;} nextFiber = nextFiber.parent; } }
Render
和 Commit
阶段
在下面的代码中,咱们退出了工夫切片,但它还存在一些问题,上面咱们来看看:
-
在
performUnitOfWork
函数里,每次为元素创立节点之后,都向dom
增加一个新节点,即if(fiber.parent) {fiber.parent.dom.appendChild(fiber.dom) }
- 咱们都晓得,支流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms 浏览器刷新一次。当 JS 执行工夫过长,超出了 16.6ms,这次刷新就没有工夫执行款式布局和款式绘制了。也就是在渲染完整棵树之前,浏览器可能会中断,导致用户看不到残缺的 UI。
那该如何解决呢?
- 首先将创立一个节点就向
dom
进行增加解决的形式更改为跟踪fiber root
,也被称为progress root
或者wipRoot
-
一旦实现所有的工作,即没有下一个工作单元时,才将
fiber
提交给dom
// 跟踪根节点 let wipRoot = null; function Render(el,container) { wipRoot = { dom: container, props:{children:[el], } } nextUnitOfWork = wipRoot; } // 一旦实现所有的工作,将整个 fiber 提交给 dom function WorkLoop(deadline) { ... if(!nextUnitOfWork && wipRoot) {CommitRoot() } requestIdleCallback(WorkLoop) } // 将残缺的 fiber 提交给 dom function CommitRoot() {CommitWork(wipRoot.child) wipRoot = null } // 递归将每个节点增加进去 function CommitWork(fiber) {if(!fiber) return; const parentDom = fiber.parent.dom; parentDom.appendChild(fiber.dom); CommitWork(fiber.child); CommitWork(fiber.sibling); }
Reconciliation
到目前为止,咱们优化了下面自定义的 HuaMu
库,但下面只实现了增加内容,当初,咱们把更新和删除内容也加上。而要实现更新、删除性能,须要将 render
函数中收到的元素与提交给 dom
的最初的 fiber tree
进行比拟。因而,须要保留最初一次提交给 fiber tree
的援用currentRoot
。同时,为每个fiber
增加 alternate
属性,记录上一阶段提交的old fiber
let currentRoot = null;
function Render(el,container) {
wipRoot = {
...
alternate: currentRoot
}
...
}
function CommitRoot() {
...
currentRoot = wipRoot;
wipRoot = null
}
- 为元素的子代创立
fiber
的同时,将old fiber
与new fiber
进行reconcile
-
通过以下三个维度进行比拟
- 如果
old fiber
与new fiber
具备雷同的type
,保留dom
节点并更新其props
,并设置标签effectTag
为UPDATE
-
type
不同,且为new fiber
,意味着要创立新的dom
节点,设置标签effectTag
为PLACEMENT
;若为old fiber
,则须要删除节点,设置标签effectTag
为DELETION
留神:为了更好的
Reconciliation
,React 还应用了key
,比方更疾速的检测到子元素何时更改了在元素数组中的地位,这里为了简洁,暂不思考。
let deletions = null; function PerformUnitOfWork(fiber) { ... const els = fiber.props.children; // 提取 为元素的子代创立 fiber 的代码 ReconcileChildren(fiber, els); } function ReconcileChildren(wipFiber, els) { let index = 0; let oldFiber = wipFiber.alternate && wipFiber.alternate.child; let prevSibling = null; // 为元素的子代创立 fiber 的同时 遍历旧的 fiber 的子级 // undefined != null; // false // undefined !== null; // true while(index < els.length || oldFiber != null) {const el = els[index]; const sameType = oldFiber && el && el.type === oldFiber.type; let newFiber = null; // 更新节点 if(sameType) { newFiber = { type: el.type, props: el.props, parent: wipFiber, dom: oldFiber.dom, // 应用 oldFiber alternate: oldFiber, effectTag: "UPDATE", } } // 新增节点 if(!sameType && el){ newFiber = { type: el.type, props: el.props, parent: wipFiber, dom: null, // dom 设置为 null alternate: null, effectTag: "PLACEMENT", } } // 删除节点 if(!sameType && oldFiber) {// 删除节点没有新的 fiber,因而将标签设置在旧的 fiber 上,并退出删除队列 [commit 阶段提交时,执行 deletions 队列,render 阶段执行完清空 deletions 队列] oldFiber.effectTag = "DELETION"; deletions.push(oldFiber) } if(oldFiber) {oldFiber = oldFiber.sibling;} if(index === 0) {wipFiber.child = newFiber;} else if(el) {prevSibling.sibling = newFiber;} prevSibling = newFiber; index++; } }
- 如果
-
在
CommitWork
函数里,依据effectTags
进行节点解决- PLACEMENT – 跟之前一样,将 dom 节点增加进父节点
- DELETION – 删除节点
- UPDATE – 更新 dom 节点的 props
function CommitWork(fiber) {if (!fiber) return; const parentDom = fiber.parent.dom; if (fiber.effectTags === 'PLACEMENT' && fiber.dom !== null){parentDom.appendChild(fiber.dom); } else if (fiber.effectTags === 'DELETION') {parentDom.removeChild(fiber.dom) } else if(fiber.effectTags === 'UPDATE' && fiber.dom !== null) { UpdateDom( fiber.dom, fiber.alternate.props, fiber.props ) } CommitWork(fiber.child); CommitWork(fiber.sibling); }
重点剖析一下 UpdateDom
函数:
-
一般属性
- 删除旧的属性
- 设置新的或更改的属性
-
非凡解决以
on
为前缀的事件属性- 删除旧的或更改的事件属性
-
增加新的事件属性
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); /** * 更新 dom 节点的 props * @param {object} dom * @param {object} prevProps 之前的属性 * @param {object} nextProps 以后的属性 */ function UpdateDom(dom, prevProps, nextProps) { // 删除旧的属性 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(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(nextProps) .filter(isEvent) .filter(isNew(prevProps, nextProps)) .forEach(name => {const eventType = name.toLowerCase().substring(2) dom.addEventListener( eventType, nextProps[name] ) }) }
当初,咱们曾经实现了一个蕴含工夫切片、fiber
的繁难 React。关上 codesandbox
看看成果吧。
Function Components
组件化对于前端的同学应该不生疏,而实现组件化的根底就是函数组件,绝对与下面的标签类型,函数组件有哪些不一样呢?让咱们来啾啾
function App(props) {return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
若由下面实现的 Huamu
库进行转换,应该等价于:
function App(props) {return Huamu.CreateElement("h1",null,"Hi",props.name)
}
const element = Huamu.CreateElement(App, {name:"foo"})
由此,可见 Function Components
的fiber
是没有 dom
节点的,而且其 children
是来自于函数的运行而不是props
。基于这两个不同点,咱们将其划分为UpdateFunctionComponent
和 UpdateHostComponent
进行解决
function PerformUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if(isFunctionComponent) {UpdateFunctionComponent(fiber)
} else {UpdateHostComponent(fiber)
}
// 抉择下一个执行工作单元,优先级是 child -> sibling -> parent
...
}
function UpdateFunctionComponent(fiber) {// TODO}
function UpdateHostComponent(fiber) {if (!fiber.dom) = fiber.dom = CreateDom(fiber);
const els = fiber.props.children;
ReconcileChildren(fiber, els);
}
-
children
来自于函数的运行而不是props
,即运行函数获取children
function UpdateFunctionComponent(fiber) {const children = [fiber.type(fiber.props)]; ReconcileChildren(fiber,children); }
-
没有
dom
节点的fiber
- 在增加节点时,得沿着
fiber
树向上挪动,直到找到带有dom
节点的父级fiber
-
在删除节点时,得持续向下挪动,直到找到带有
dom
节点的子级fiber
function CommitWork(fiber) {if (!fiber) return; // 优化:const domParent = fiber.parent.dom; let domParentFiber = fiber.parent; while(!domParentFiber.dom) {domParentFiber = domParentFiber.parent;} const domParent = domParentFiber.dom; if (fiber.effectTags === 'PLACEMENT' && fiber.dom!=null){domParent.appendChild(fiber.dom); } else if (fiber.effectTags === 'DELETION') {// 优化:domParent.removeChild(fiber.dom) CommitDeletion(fiber, domParent) } else if(fiber.effectTags === 'UPDATE' && fiber.dom!=null) { UpdateDom( fiber.dom, fiber.alternate.props, fiber.props ) } CommitWork(fiber.child); CommitWork(fiber.sibling); } function CommitDeletion(fiber,domParent){if(fiber.dom){domParent.removeChild(fiber.dom) } else {CommitDeletion(fiber.child, domParent) } }
- 在增加节点时,得沿着
最初,咱们为 Function Components
增加状态。
Hooks
向 fiber
增加一个 hooks
数组,以反对 useState
在同一组件中屡次调用,且跟踪以后的 hooks
索引。
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 Components
组件调用UseState
时,通过alternate
属性检测fiber
是否有old hook
。 - 若有
old hook
,将状态从old hook
复制到new hook
,否则,初始化状态。 -
将
new hook
增加fiber
,hook index
递增,返回状态。function UseState(initial) { const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex] const hook = {state: oldHook ? oldHook.state : initial,} wipFiber.hooks.push(hook) hookIndex++ return [hook.state] }
UseState
还需返回一个可更新状态的函数,因而,须要定义一个接管action
的setState
函数。- 将
action
增加到队列中,再将队列增加到fiber
。 - 在下一次渲染时,获取
old hook
的action
队列,并代入new state
逐个执行,以保障返回的状态是已更新的。 -
在
setState
函数中,执行跟Render
函数相似的操作,将currentRoot
设置为下一个工作单元,以便开始新的渲染。function UseState(initial) { ... const hook = { state: oldHook ? oldHook.state : initial, queue: [],} const actions = oldHook ? oldHook.queue : [] actions.forEach(action => {hook.state = action(hook.state) }) 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] }
当初,咱们曾经实现一个蕴含工夫切片、fiber
、Hooks
的繁难 React。关上 codesandbox
看看成果吧。
结语
到目前为止,咱们从 What > How 梳理了大略的 React 常识链路,前面的章节咱们对文中所提及的知识点进行 Why 的摸索,置信会反哺到 What 的了解和 How 的实际。
本文原创公布于涂鸦智能技术博客
https://tech.tuya.com/react-zheng-ti-gan-zhi/
转载请注明出处