React 整体感知
source link: https://my.oschina.net/u/4430337/blog/4877581
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
React 整体感知 - 涂鸦智能技术团队的个人空间 - OSCHINA - 中文开源技术交流社区
当我们由浅入深地认知一样新事物的时候,往往需要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 What 和 How 之后,往往能够更加具象地回答理论层面的 Why,因此,在进入 Why 的探索之前,我们先整体感知一下 What 和 How 两个过程。
打开 React 官网,第一眼便能看到官方给出的回答。
React 是用于构建用户界面的 JavaScript 库。
不知道你有没有想过,构建用户界面的方式有千百种,为什么 React 会突出?同样,我们可以从 React 哲学里得到回应。
我们认为, React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
可见,关键是实现了 快速响应 ,那么制约 快速响应 的因素有哪些呢?React 是如何解决的呢?
让我们带着上面的两个问题,在遵循真实的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/
转载请注明出处
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK