当咱们由浅入深地认知同样新事物的时候,每每须要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 What 和 How 以后,每每可以更加具象地回答理论层面的 Why,所以,在进入 Why 的探索以前,咱们先总体感知一下 What 和 How 两个过程。html
What
打开 React 官网,第一眼便能看到官方给出的回答。前端
React 是用于构建用户界面的 JavaScript 库。node
不知道你有没有想过,构建用户界面的方式有千百种,为何 React 会突出?一样,咱们能够从 React 哲学里获得回应。react
咱们认为, React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。git
可见,关键是实现了 快速响应 ,那么制约 快速响应 的因素有哪些呢?React 是如何解决的呢?github
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/
转载请注明出处