当我们由浅入深地认知一样新事物的时候,往往需要遵循 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看下转换前后的代码:
1// JSX 转换前 2const el = <h1 title="el_title">HuaMu<h1>; 3 4// 转换后的 JS 对象 5const el = { 6 type:"h1", 7 props:{ 8 title:"el_title", 9 children:"HuaMu", 10 } 11}
可见,元素是具有 type 和 props 属性的对象,而 CreateElement 函数的主要任务就是创建该对象。
1/** 2 * @param {string} type HTML标签类型 3 * @param {object} props 具有JSX属性中的所有键和值 4 * @param {string | array} children 元素树 5 */ 6function CreateElement(type, props, ...children) { 7 return { 8 type, 9 props:{ 10 ...props, 11 children, 12 } 13 } 14}
说明:我们将剩余参数赋予
children,扩展运算符用于构造字面量对象props,对象表达式将按照key-value的方式展开,从而保证props.children始终是一个数组。接下来,我们一起看下demo:
1CreateElement("h1", {title:"el_title"}, 'hello', 'HuaMu') 2 3// 返回的 JS 对象 4{ 5 "type": "h1", 6 "props": { 7 "title": "el_title" // key-value 8 "children": ["hello", "HuaMu"] // 数组类型 9 } 10}
注意:当
...children为空或为原始值时,React 不会创建props.children,但为了简化代码,暂不考虑性能,我们为原始值创建特殊的类型TEXT_EL。
1function CreateElement(type, props, ...children) { 2 return { 3 type, 4 props:{ 5 ...props, 6 children: children.map(child => typeof child === "object" ? child : CreateTextElement(child)) 7 } 8 } 9} 10 11function CreateTextElement(text) { 12 return { 13 type: "TEXT_EL", 14 props: { 15 nodeValue: text, 16 children: [] 17 } 18 } 19}
Render 函数
CreateElement 函数将标签转化为对象输出,接着 React 进行一系列处理,Render 函数将处理好的节点根据标记进行添加、更新或删除内容,最后附加到容器中。下面简单的实现 Render 函数是如何实现添加内容的:
-
首先创建对应的DOM节点,然后将新节点附加到容器中,并递归每个孩子节点做同样的操作。
-
将元素的
props属性分配给节点。1 function Render(el,container) { 2 // 创建节点 3 const dom = el.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(el.type); 4 el.props.children.forEach(child => Render(child, dom)) 5 // 为节点分配 props 属性 6 const isProperty = key => key !== 'children'; 7 const setProperty = name => dom[name] = el.props[name]; 8 Object.keys(el.props).filter(isProperty).forEach(setProperty) 9 container.appendChild(dom); 10 }注意:文本节点使用
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。但在概念上是相同的。
依据上面的分析,代码结构如下:
1// 当浏览器准备就绪时,它将调用 WorkLoop 2requestIdleCallback(WorkLoop) 3 4let nextUnitOfWork = null; 5 6function PerformUnitOfWork(nextUnitOfWork) { 7 // TODO 8} 9 10function WorkLoop(deadline) { 11 // 当前线程的闲置时间是否可以在结束前执行更多的任务 12 let shouldYield = false; 13 while(nextUnitOfWork && !shouldYield) { 14 nextUnitOfWork = PerformUnitOfWork(nextUnitOfWork) // 赋值下一个工作单元 15 shouldYield = deadline.timeRemaining() < 1; // 如果 idle period 已经结束,则它的值是 0 16 } 17 requestIdleCallback(WorkLoop) 18}
我们在 PerformUnitOfWork 函数里实现当前工作的执行并返回下一个执行的工作单元,可下一个工作单元如何快速查找呢?让我们初步了解 Fibers 吧。
Fibers
为了组织工作单元,即方便查找下一个工作单元,需引入fiber tree的数据结构。即每个元素都有一个fiber,链接到其第一个子节点,下一个兄弟姐妹节点和父节点,且每个fiber都将成为一个工作单元。
1// 假设我们要渲染的元素树如下 2const el = ( 3 <div> 4 <h1> 5 <p /> 6 <a /> 7 </h1> 8 <h2 /> 9 </div> 10)
其对应的 fiber tree 如下:
若将上图转化到我们的代码里,我们第一件事得找到root fiber,即在Render中,设置nextUnitOfWork初始值为root fiber,并将创建节点部分独立出来。
1function Render(el,container) { 2 // 设置 nextUnitOfWork 初始值为 root fiber 3 nextUnitOfWork = { 4 dom: container, 5 props:{ 6 children:[el], 7 } 8 } 9} 10 11// 将创建节点部分独立出来 12function CreateDom(fiber) { 13 const dom = fiber.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(fiber.type); 14 // 为节点分配props属性 15 const isProperty = key => key !== 'children'; 16 const setProperty = name => dom[name] = fiber.props[name]; 17 Object.keys(fiber.props).filter(isProperty).forEach(setProperty) 18 return dom 19}
剩余的 fiber 将在 performUnitOfWork 函数上执行以下三件事:
-
为元素创建节点并添加到
dom -
为元素的子代创建
fiber -
选择下一个执行工作单元
1 function PerformUnitOfWork(fiber) { 2 // 为元素创建节点并添加到 dom 3 if(!fiber.dom) { 4 fiber.dom = CreateDom(fiber) 5 } 6 // 若元素存在父节点,则挂载 7 if(fiber.parent) { 8 fiber.parent.dom.appendChild(fiber.dom) 9 } 10 11 // 为元素的子代创建 fiber 12 const els = fiber.props.children; 13 let index = 0; 14 // 作为一个容器,存储兄弟节点 15 let prevSibling = null; 16 while(index < els.length) { 17 const el = els[index]; 18 const newFiber = { 19 type: el.type, 20 props: el.props, 21 parent: fiber, 22 dom: null 23 } 24 25 // 子代在fiber树中的位置是child还是sibling,取决于它是否第一个 26 if(index === 0){ 27 fiber.child = newFiber; 28 } else { 29 prevSibling.sibling = newFiber; 30 } 31 prevSibling = newFiber; 32 index++; 33 } 34 35 // 选择下一个执行工作单元,优先级是 child -> sibling -> parent 36 if(fiber.child){ 37 return fiber.child; 38 } 39 let nextFiber = fiber; 40 while(nextFiber) { 41 if(nextFiber.sibling) { 42 return nextFiber.sibling; 43 } 44 nextFiber = nextFiber.parent; 45 } 46 }
Render 和 Commit 阶段
在上面的代码中,我们加入了时间切片,但它还存在一些问题,下面我们来看看:
-
在
performUnitOfWork函数里,每次为元素创建节点之后,都向dom添加一个新节点,即1if(fiber.parent) { 2 fiber.parent.dom.appendChild(fiber.dom) 3} -
我们都知道,主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。也就是在渲染完整棵树之前,浏览器可能会中断,导致用户看不到完整的UI。
那该如何解决呢?
-
首先将创建一个节点就向
dom进行添加处理的方式更改为跟踪fiber root,也被称为progress root或者wipRoot -
一旦完成所有的工作,即没有下一个工作单元时,才将
fiber提交给dom1 // 跟踪根节点 2 let wipRoot = null; 3 function Render(el,container) { 4 wipRoot = { 5 dom: container, 6 props:{ 7 children:[el], 8 } 9 } 10 nextUnitOfWork = wipRoot; 11 } 12 13 // 一旦完成所有的工作,将整个fiber提交给dom 14 function WorkLoop(deadline) { 15 ... 16 if(!nextUnitOfWork && wipRoot) { 17 CommitRoot() 18 } 19 requestIdleCallback(WorkLoop) 20 } 21 22 // 将完整的fiber提交给dom 23 function CommitRoot() { 24 CommitWork(wipRoot.child) 25 wipRoot = null 26 } 27 28 // 递归将每个节点添加进去 29 function CommitWork(fiber) { 30 if(!fiber) return; 31 const parentDom = fiber.parent.dom; 32 parentDom.appendChild(fiber.dom); 33 CommitWork(fiber.child); 34 CommitWork(fiber.sibling); 35 }
Reconciliation
到目前为止,我们优化了上面自定义的HuaMu库,但上面只实现了添加内容,现在,我们把更新和删除内容也加上。而要实现更新、删除功能,需要将render函数中收到的元素与提交给dom的最后的fiber tree进行比较。因此,需要保存最后一次提交给fiber tree 的引用currentRoot。同时,为每个fiber添加alternate属性,记录上一阶段提交的old fiber
1let currentRoot = null; 2function Render(el,container) { 3 wipRoot = { 4 ... 5 alternate: currentRoot 6 } 7 ... 8} 9 10function CommitRoot() { 11 ... 12 currentRoot = wipRoot; 13 wipRoot = null 14}
-
为元素的子代创建
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,比如更快速的检测到子元素何时更改了在元素数组中的位置,这里为了简洁,暂不考虑。1let deletions = null; 2function PerformUnitOfWork(fiber) { 3... 4const els = fiber.props.children; 5// 提取 为元素的子代创建fiber 的代码 6ReconcileChildren(fiber, els); 7} 8 9function ReconcileChildren(wipFiber, els) { 10let index = 0; 11let oldFiber = wipFiber.alternate && wipFiber.alternate.child; 12let prevSibling = null; 13// 为元素的子代创建fiber 的同时 遍历旧的fiber的子级 14// undefined != null; // false 15// undefined !== null; // true 16while(index < els.length || oldFiber != null) { 17 const el = els[index]; 18 const sameType = oldFiber && el && el.type === oldFiber.type; 19 let newFiber = null; 20 21 // 更新节点 22 if(sameType) { 23 newFiber = { 24 type: el.type, 25 props: el.props, 26 parent: wipFiber, 27 dom: oldFiber.dom, // 使用 oldFiber 28 alternate: oldFiber, 29 effectTag: "UPDATE", 30 } 31 } 32 33 // 新增节点 34 if(!sameType && el){ 35 newFiber = { 36 type: el.type, 37 props: el.props, 38 parent: wipFiber, 39 dom: null, // dom 设置为null 40 alternate: null, 41 effectTag: "PLACEMENT", 42 } 43 } 44 // 删除节点 45 if(!sameType && oldFiber) { 46 // 删除节点没有新的fiber,因此将标签设置在旧的fiber上,并加入删除队列 [commit阶段提交时,执行deletions队列,render阶段执行完清空deletions队列] 47 oldFiber.effectTag = "DELETION"; 48 deletions.push(oldFiber) 49 } 50 51 if(oldFiber) { 52 oldFiber = oldFiber.sibling; 53 } 54 55 if(index === 0) { 56 wipFiber.child = newFiber; 57 } else if(el) { 58 prevSibling.sibling = newFiber; 59 } 60 prevSibling = newFiber; 61 index++; 62} 63}
-
-
在
CommitWork函数里,根据effectTags进行节点处理-
PLACEMENT - 跟之前一样,将dom节点添加进父节点
-
DELETION - 删除节点
-
UPDATE - 更新dom节点的props
1function CommitWork(fiber) { 2if (!fiber) return; 3const parentDom = fiber.parent.dom; 4if (fiber.effectTags === 'PLACEMENT' && fiber.dom !== null){ 5 parentDom.appendChild(fiber.dom); 6} else if (fiber.effectTags === 'DELETION') { 7 parentDom.removeChild(fiber.dom) 8} else if(fiber.effectTags === 'UPDATE' && fiber.dom !== null) { 9 UpdateDom( 10 fiber.dom, 11 fiber.alternate.props, 12 fiber.props 13 ) 14} 15 16CommitWork(fiber.child); 17CommitWork(fiber.sibling); 18}
-
重点分析一下UpdateDom函数:
-
普通属性
- 删除旧的属性
- 设置新的或更改的属性
-
特殊处理以
on为前缀的事件属性-
删除旧的或更改的事件属性
-
添加新的事件属性
1const isEvent = key => key.startsWith("on"); 2const isProperty = key => key !== 'children' && !isEvent(key); 3const isNew = (prev, next) => key => prev[key] !== next[key]; 4const isGone = (prev, next) => key => !(key in next); 5 6/** 7* 更新dom节点的props 8* @param {object} dom 9* @param {object} prevProps 之前的属性 10* @param {object} nextProps 当前的属性 11*/ 12function UpdateDom(dom, prevProps, nextProps) { 13// 删除旧的属性 14Object.keys(prevProps) 15 .filter(isProperty) 16 .filter(isGone(prevProps, nextProps)) 17 .forEach(name => { 18 dom[name] = "" 19 }) 20 21// 设置新的或更改的属性 22Object.keys(nextProps) 23 .filter(isProperty) 24 .filter(isNew(prevProps, nextProps)) 25 .forEach(name => { 26 dom[name] = nextProps[name] 27 }) 28 29// 删除旧的或更改的事件属性 30Object.keys(prevProps) 31 .filter(isEvent) 32 .filter(key => (!(key in nextProps) || isNew(prevProps, nextProps)(key))) 33 .forEach(name => { 34 const eventType = name.toLowerCase().substring(2) 35 dom.removeEventListener( 36 eventType, 37 prevProps[name] 38 ) 39 }) 40 41// 添加新的事件属性 42Object.keys(nextProps) 43 .filter(isEvent) 44 .filter(isNew(prevProps, nextProps)) 45 .forEach(name => { 46 const eventType = name.toLowerCase().substring(2) 47 dom.addEventListener( 48 eventType, 49 nextProps[name] 50 ) 51 }) 52}
-
现在,我们已经实现了一个包含时间切片、fiber的简易 React。打开 codesandbox看看效果吧。
Function Components
组件化对于前端的同学应该不陌生,而实现组件化的基础就是函数组件,相对与上面的标签类型,函数组件有哪些不一样呢?让我们来啾啾
1function App(props) { 2 return <h1>Hi {props.name}</h1> 3} 4const element = <App name="foo" />
若由上面实现的Huamu库进行转换,应该等价于:
1function App(props) { 2 return Huamu.CreateElement("h1",null,"Hi ",props.name) 3} 4const element = Huamu.CreateElement(App, {name:"foo"})
由此,可见Function Components的fiber是没有dom节点的,而且其children是来自于函数的运行而不是props。基于这两个不同点,我们将其划分为UpdateFunctionComponent 和 UpdateHostComponent 进行处理
1function PerformUnitOfWork(fiber) { 2 const isFunctionComponent = fiber.type instanceof Function; 3 if(isFunctionComponent) { 4 UpdateFunctionComponent(fiber) 5 } else { 6 UpdateHostComponent(fiber) 7 } 8 // 选择下一个执行工作单元,优先级是 child -> sibling -> parent 9 ... 10} 11 12function UpdateFunctionComponent(fiber) { 13 // TODO 14} 15 16function UpdateHostComponent(fiber) { 17 if (!fiber.dom) = fiber.dom = CreateDom(fiber); 18 const els = fiber.props.children; 19 ReconcileChildren(fiber, els); 20}
-
children来自于函数的运行而不是props,即运行函数获取children1 function UpdateFunctionComponent(fiber) { 2 const children = [fiber.type(fiber.props)]; 3 ReconcileChildren(fiber,children); 4 } -
没有
dom节点的fiber-
在添加节点时,得沿着
fiber树向上移动,直到找到带有dom节点的父级fiber -
在删除节点时,得继续向下移动,直到找到带有
dom节点的子级fiber1function CommitWork(fiber) { 2if (!fiber) return; 3// 优化:const domParent = fiber.parent.dom; 4let domParentFiber = fiber.parent; 5while(!domParentFiber.dom) { 6 domParentFiber = domParentFiber.parent; 7} 8const domParent = domParentFiber.dom; 9if (fiber.effectTags === 'PLACEMENT' && fiber.dom!=null){ 10 domParent.appendChild(fiber.dom); 11} else if (fiber.effectTags === 'DELETION') { 12 // 优化: domParent.removeChild(fiber.dom) 13 CommitDeletion(fiber, domParent) 14} else if(fiber.effectTags === 'UPDATE' && fiber.dom!=null) { 15 UpdateDom( 16 fiber.dom, 17 fiber.alternate.props, 18 fiber.props 19 ) 20} 21 22CommitWork(fiber.child); 23CommitWork(fiber.sibling); 24} 25 26function CommitDeletion(fiber,domParent){ 27if(fiber.dom){ 28 domParent.removeChild(fiber.dom) 29} else { 30 CommitDeletion(fiber.child, domParent) 31} 32}
-
最后,我们为Function Components添加状态。
Hooks
向fiber添加一个hooks数组,以支持useState在同一组件中多次调用,且跟踪当前的hooks索引。
1let wipFiber = null 2let hookIndex = null 3 4function UpdateFunctionComponent(fiber) { 5 wipFiber = fiber; 6 hookIndex = 0 7 wipFiber.hooks = [] 8 const children = [fiber.type(fiber.props)] 9 ReconcileChildren(fiber, children) 10}
-
当
Function Components组件调用UseState时,通过alternate属性检测fiber是否有old hook。 -
若有
old hook,将状态从old hook复制到new hook,否则,初始化状态。 -
将
new hook添加fiber,hook index递增,返回状态。1 function UseState(initial) { 2 const oldHook = 3 wipFiber.alternate && 4 wipFiber.alternate.hooks && 5 wipFiber.alternate.hooks[hookIndex] 6 const hook = { 7 state: oldHook ? oldHook.state : initial, 8 } 9 10 wipFiber.hooks.push(hook) 11 hookIndex++ 12 return [hook.state] 13 } -
UseState还需返回一个可更新状态的函数,因此,需要定义一个接收action的setState函数。 -
将
action添加到队列中,再将队列添加到fiber。 -
在下一次渲染时,获取
old hook的action队列,并代入new state逐一执行,以保证返回的状态是已更新的。 -
在
setState函数中,执行跟Render函数类似的操作,将currentRoot设置为下一个工作单元,以便开始新的渲染。1 function UseState(initial) { 2 ... 3 const hook = { 4 state: oldHook ? oldHook.state : initial, 5 queue: [], 6 } 7 const actions = oldHook ? oldHook.queue : [] 8 actions.forEach(action => { 9 hook.state = action(hook.state) 10 }) 11 const setState = action => { 12 hook.queue.push(action) 13 wipRoot = { 14 dom: currentRoot.dom, 15 props: currentRoot.props, 16 alternate: currentRoot, 17 } 18 nextUnitOfWork = wipRoot 19 deletions = [] 20 } 21 22 wipFiber.hooks.push(hook) 23 hookIndex++ 24 return [hook.state, setState] 25 }
现在,我们已经实现一个包含时间切片、fiber、Hooks 的简易 React。打开codesandbox看看效果吧。
结语
到目前为止,我们从 What > How 梳理了大概的 React 知识链路,后面的章节我们对文中所提及的知识点进行 Why 的探索,相信会反哺到 What 的理解和 How 的实践。
本文原创发布于涂鸦智能技术博客
https://tech.tuya.com/react-zheng-ti-gan-zhi/
转载请注明出处