我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。 --- React.
UI = f(data).
制约快速响应因素:
1)CPU 瓶颈
- 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
function App() {
const len = 3000;
return (
<ul>
{Array(len).fill(0).map((_, i) => <li>{i}</li>)}
</ul>
);
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);
主流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。
我们知道,JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。
在每16.6ms时间内,需要完成如下工作:
当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
在Demo中,由于组件数量繁多(3000个),JS脚本执行时间过长,页面掉帧,造成卡顿。
可以从打印的执行堆栈图看到,JS执行时间为73.65ms,远远多于一帧的时间。
如何解决这个问题呢?
答案是:在浏览器每一帧的时间中,预留一些时间给 JS 线程,React利用这部分时间更新组件(可以看到,在 源码 (opens new window)中,预留的初始时间是5ms)。
当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)
接下来我们开启 Concurrent Mode(开启后会启用时间切片):
// 通过使用ReactDOM.unstable_createRoot开启Concurrent Mode
// ReactDOM.render(<App/>, rootEl);
ReactDOM.unstable_createRoot(rootEl).render(<App/>);
所以,解决CPU瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新。
2)IO 瓶颈
网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知?
React给出的答案是将人机交互研究的结果整合到真实的 UI 中 (opens new window)。
这里我们以业界人机交互最顶尖的苹果举例,在IOS系统中:
点击“设置”面板中的“通用”,进入“通用”界面:
作为对比,再点击“设置”面板中的“Siri与搜索”,进入“Siri与搜索”界面:
你能感受到两者体验上的区别么?
事实上,点击“通用”后的交互是同步的,直接显示后续界面。而点击“Siri与搜索”后的交互是异步的,需要等待请求返回后再显示后续界面。但从用户感知来看,这两者的区别微乎其微。
这里的窍门在于:点击“Siri与搜索”后,先在当前页面停留了一小段时间,这一小段时间被用来请求数据。
当“这一小段时间”足够短时,用户是无感知的。如果请求时间超过一个范围,再显示loading的效果。
试想如果我们一点击“Siri与搜索”就显示loading效果,即使数据请求时间很短,loading效果一闪而过。用户也是可以感知到的。
为此,React实现了Suspense (opens new window)功能及配套的hook——useDeferredValue (opens new window)。
而在源码内部,为了支持这些特性,同样需要将同步的更新变为可中断的异步更新。
Build Your Own React.
基础用法:
const element = <h1 title="foo">Hello</h1>;
const container = document.getElementById('root');
ReactDOM.render(element, container);
- JSX
JSX 通过 Babel 等构建工具转化为 JS。用 createElement 替换标签内的代码,将 tag、props 和children作为参数传递。例如:
React.createElement(
"h1",
{ title: "foo" },
"Hello"
);
// element 被转化为
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
- render
根据 element 创建元素,将元素挂载到根节点。
const node = document.createElement(element.type)
node["title"] = element.props.title
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)
Step 1: CreateElement
JSX to JS对象。
// 声明自定义 React Obj.
const YReact = {
createElement,
render,
};
const createElement = (type, props, ...children) => {
return {
type,
props: {
...props,
children: children?.map((child) =>typeof children === 'object' ? child : createTextNode(child)
),
},
};
};
const createTextNode = (text) => {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
};
};
createElement("div") returns:
{
"type": "div",
"props": { "children": [] }
}
createElement("div", null, a) returns:
{
"type": "div",
"props": { "children": [a] }
}
createElement("div", null, a, b) returns:
{
"type": "div",
"props": { "children": [a, b] }
}
Step2: render
挂载 ReactDOM 树到真实 DOM节点
const render = (element, container) => {
const dom =
element.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type);
element.props.children.forEach((child) => {
render(child, dom);
});
// why children in props?
const isPropriety = (key) => key !== 'children';
Object.keys(element.props)
.filter(isPropriety)
.forEach((name) => (dom[name] = element.props[name]));
container.appendChild(dom);
};
到这步,React 核心流程已经完成。但有一个硬伤,这个递归调用有问题。
一旦开始渲染,将不会停止,直到我们渲染完完整的元素树。 如果元素树很大,它可能会阻塞主线程太久。 如果浏览器需要做高优先级的事情,比如处理用户输入或保持动画流畅,它必须等到渲染完成。
const createElement = (type, props, ...children) => {
return {
type,
props: {
...props,
children: children.map((child) =>typeof child === 'object' ? child : createTextNode(child)
),
},
};
};
const createTextNode = (text) => {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
};
};
const render = (element, container) => {
const dom =
element.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type);
element.props.children.forEach((child) => {
render(child, dom);
});
// why children in props?const isPropriety = (key) => key !== 'children';
Object.keys(element.props)
.filter(isPropriety)
.forEach((name) => (dom[name] = element.props[name]));
container.appendChild(dom);
};
const YReact = {
createElement,
render,
};
/** @jsx YReact.createElement */const element = (
<div style="background: salmon"><h1>Hello World</h1><h2 style="text-align:right">from YReact</h2></div>
);
const container = document.getElementById('root');
YReact.render(element, container);
Step3: ConcurrentMode
React 把工作分解成小的单元,当完成每个单元后,如果还有其他需要做的事情,会让浏览器中断渲染。
requestIdleCallback 可以视为 setTimeout,但不是我们告诉它何时运行,浏览器将在主线程空闲时运行回调。
React 不再使用 requestIdleCallback,而是基于此开发了 schedule。
const nextUnitOfWork = null;
const workLoop = (deadline) => {
let shouldStop = false;
while (nextUnitToWork && !shouldStop) {
nextUnitToWork = performerUnitOfWork(nextUnitOfWork);
// 归还浏览器控制
shouldStop = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
};
requestIdleCallback(workLoop);
// 执行当前单元 job,返回下个执行单元
const performerUnitOfWork = () => {
// todo.
};
Step4: fiber
为了组织工作单元,React 定义一个数据结构:fiber tree。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。每个元素都是一个 fiber 节点,每个 fiber 将成为一个工作单元。
const performerUnitOfWork = (fiber) => {
// add dom noeif (!fiber.dom) {
fiber.dom = createDom(fiber);
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// create new fiber.const elements = fiber.props.child;
let idx = 0;
const prevSibling = null;
while (idx < element.length) {
const el = elements[idx];
const newFiber = {
type: el.type,
props: el.props,
parent: fiber,
dom: null,
};
if (idx === 0) {
fiber.children = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
idx++;
}
// 先遍历子节点,再遍历兄弟节点。if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = fiber.parent;
}
};
Step5: render & commit
每次我们处理一个元素时,我们都会向 DOM 添加一个新节点。在我们完成渲染整棵 DOM 树之前,浏览器可能会中断我们的工作。 在这种情况下,用户将看到一个不完整的 UI。 我们不希望那样。
const render = (element, container) => {
// 维护根节点const wipRoot = nextUnitOfWork({
dom: container,
props: {
children: [element],
},
});
nextUnitOfWork = wipRoot;
};
let wipRoot = null
// 渲染完 DOM Tree,挂载到真实节点。
const commitRoot = () => {
commitWork(wipRoot.child);
wipRoot = null;
};
const commitWork = (fiber) => {
if (!fiber) {
return;
}
const parent = fiber.parent.dom;
parent.appendChildren(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
};
const performerUnitOfWork = (fiber) => {
...
// 没有下个执行单元,进行 DOM 挂载if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
...
}
Step6: Reconciler
我们构建了一个新的 DOM 树,但是更新或删除节点呢?
React 需要将在渲染函数上接收到的元素与我们提交给 DOM 的最后一个 fiber 树进行比较。
React 在完成 commit 会保存对最后一个 fiberTree 的引用,称之为 current。
并且还为每个 fiber 添加了 alternate 。 此属性是指向旧 fiber 。
const render = (element, container) => {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
deletions = [];
...
};
let deletions = null;
//比较新旧 fiber 的 props,移除消失的 props,并设置新的或更改的 props。
const updateDom = (dom, prevProps, nextProps) => {
// 移除旧的propsObject.keys(prevProps)
.filter(isPropriety)
.filter(isGone(prevProps, nextProps))
.forEach((name) => (dom[name] = ''));
// 移除/更改 eventListenerObject.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]);
});
// 新增 addEventListenerObject.keys(prevProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, prevProps[name]);
});
// 更新 /新增Object.keys(prevProps)
.filter(isPropriety)
.filter(isNew(prevProps, nextProps))
.forEach((name) => (dom[name] = nextProps[name]));
};
// 渲染完 DOM Tree,挂载到真实节点。
const commitRoot = () => {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
// 保留上个 fiber tree
currentRoot = wipRoot;
wipRoot = null;
};
const commitWork = (fiber) => {
if (!fiber) {
return;
}
const parent = fiber.parent.dom;
// 处理 effectTagif (fiber.effectTag === 'PLACEMENT') {
parent.appendChildren(fiber);
} else if (fiber.effectTag === 'DELETION') {
parent.removeChildren(fiber);
} else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
};
const performerUnitOfWork = (fiber) => {
...
// 协调新旧元素
reconcileChildren(fiber, elements);
...
};
const reconcileChildren = (wipFiber, elements) => {
let idx = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
const prevSibling = null;
while (idx < elements.length || oldFiber !== null) {
const el = elements[idx];
const newFiber = null;
// compare fiber.const sameType = oldFiber && element && element.type == oldFiber.type;
if (sameType) {
// TODO update the node
newFiber = {
type: oldFiber.type,
props: el.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE',
};
}
if (el && !sameType) {
// TODO add this node
newFiber = {
type: el.type,
props: el.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT',
};
}
if (oldFiber && !sameType) {
// TODO delete the oldFiber's node
oldFiber.effectTag = 'DELETION';
deletions.push(oldFiber);
}
if (idx === 0) {
wipFiber.children = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
idx++;
}
};
我们同时遍历旧fiber (wipFiber.alternate) 的子节点和我们想要协调的元素数组。
忽略React里其他条件,那么 while 中最重要的东西:oldFiber 和 element。 element 是我们要渲染到 DOM 的东西,oldFiber 是我们上次渲染的东西。
React 使用 type 比较新旧 fiber 查看是否需要对 DOM 应用任何更改。
- 如果旧的 fiber 和新的元素有相同的类型,我们可以保留 DOM 节点并用新的 props 更新它
- 如果类型不同并且有一个新元素,则意味着我们需要创建一个新的 DOM 节点
- 如果类型不同并且有旧 fiber,我们需要删除旧节点
这里 React 也使用键,这样可以更好地协调。 例如,它检测孩子何时更改元素数组中的位置。
我们需要更新的一种特殊道具是事件侦听器,因此如果道具名称以“on”前缀开头,我们将以不同方式处理它们。
step7: Function Component
函数式组件在两个方面有所不同:
- 没有 DOM 节点
- Children 只有执行完函数才能获得
const performerUnitOfWork = (fiber) => {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
...
}
const updateFunctionComponent = (fiber) => {
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
};
const updateHostComponent = (fiber) => {
// add dom noeif (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
};
const commitWork = (fiber) => {
...
// 找到有dom 的父亲节点let domParentFiber = fiber.parent;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent;
}
const domParent = domParentFiber.dom;
...
else if (fiber.effectTag === 'DELETION') {
commitDeletion(fiber, domParent);
}
...
};
const commitDeletion = (fiber, domParent) => {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
};
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />