React 整体感知
当我们由浅入深地认知一样新事物的时候,往往需要遵循 Why > What > How 这样一个认知过程。它们是相辅相成、缺一不可的。而了解了具体的 What 和 How 之后,往往能够更加具象地回答理论层面的 Why,因此,在进入 Why 的探索之前,我们先整体感知一下 What 和 How 两个过程。
What
打开一眼便能看到官方给出的回答。
React 是用于构建用户界面的 JavaScript 库。
不知道你有没有想过,构建用户界面的方式有千百种,为什么 React 会突出?站长交易同样,我们可以从 里得到回应。
我们认为, React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
可见,关键是实现了 快速响应 ,那么制约 快速响应 的因素有哪些呢?React 是如何解决的呢?
How
让我们带着上面的两个问题,在遵循真实的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 */ 添加到代码中
并发模式
在继续向下探索之前,我们先思考一下上面的代码中,有哪些代码制约 快速响应 了呢?
是的,在Render函数中递归每个孩子节点,即这句代码el.props.children.forEach(child => Render(child, dom))存在问题。一旦开始渲染,便不会停止,直到渲染了整棵元素树,我们知道,GUI渲染线程与JS线程是互斥的,JS脚本执行和浏览器布局、绘制不能同时执行。如果元素树很大,JS脚本执行时间过长,可能会阻塞主线程,导致页面掉帧,造成卡顿,且妨碍浏览器执行高优作业。
那如何解决呢?
通过时间切片的方式,即将任务分解为多个工作单元,每完成一个工作单元,判断是否有高优作业,若有,则让浏览器中断渲染。下面通过requestIdleCallback模拟实现:
简单说明一下:
window.requestIdleCallback(cb[, options]) :浏览器将在主线程空闲时运行回调。函数会接收到一个IdleDeadline的参数,这个参数可以获取当前空闲时间(timeRemaining)以及回调是否在超时前已经执行的状态(didTimeout)。
React 已不再使用requestIdleCallback,目前使用 但在概念上是相同的。
依据上面的分析,代码结构如下:
// 当浏览器准备就绪时,它将调用 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>
)
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。
结语
到目前为止,我们从 What > How 梳理了大概的 React 知识链路,后面的章节我们对文中所提及的知识点进行 Why 的探索,相信会反哺到 What 的理解和 How 的实践。