【JS】React 整体感知
当我们由浅入深地认知一样新事物的时候,往往需要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 What 和 How 之后,往往能够更加具象地回答理论层面的 Why,因此,在进入 Why 的探索之前,我们先整体感知一下 What 和 How 两个过程。
What
打开 React 官网,第一眼便能看到官方给出的回答。
不知道你有没有想过,构建用户界面的方式有千百种,为什么 React 会突出?同样,我们可以从 React 哲学里得到回应。
可见,关键是实现了 快速响应 ,那么制约 快速响应 的因素有哪些呢?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,
}
}
}
CreateElement("h1", {title:"el_title"}, 'hello', 'HuaMu')// 返回的 JS 对象
{
"type": "h1",
"props": {
"title": "el_title" // key-value
"children": ["hello", "HuaMu"] // 数组类型
}
}
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);
}
到目前为止,我们已经实现了一个简易的用于构建用户界面的 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) {
// 为元素创建节点并添加到 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
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 = nulllet 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 的实践。
以上是 【JS】React 整体感知 的全部内容, 来源链接: utcz.com/a/94124.html