当咱们由浅入深地认知一样新事物的时候,往往须要遵循 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。但在概念上是雷同的。
根据下面的剖析,代码构造如下:
// 当浏览器准备就绪时,它将调用 WorkLooprequestIdleCallback(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) {// 为元素创立节点并增加到 domif(!fiber.dom) { fiber.dom = CreateDom(fiber)}// 若元素存在父节点,则挂载if(fiber.parent) { fiber.parent.dom.appendChild(fiber.dom)}// 为元素的子代创立 fiberconst 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 -> parentif(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提交给domfunction WorkLoop(deadline) {...if(!nextUnitOfWork && wipRoot) { CommitRoot()}requestIdleCallback(WorkLoop)}// 将残缺的fiber提交给domfunction 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; // truewhile(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 = nulllet hookIndex = nullfunction 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/
转载请注明出处