【译】React之旅:搭建一个自己的React
原文链接:pomb.us/build-your-…
我们打算从头开始重写React
,一步步地按照React实际代码结构进行,但除去了所有的优化和非必要的特性。
如果你已经阅读了我的任意一篇"build your own React"帖子,与此不同的是这篇基于React 16.8
,因此我们现在可以使用hooks
并且放弃所有与类相关的代码。
你可以在Didact repo中找到旧博客文章和代码的历史记录,也有一些相似内容的演讲,但这是一篇独立的文章。 从头开始,这些是我们将逐步添加到自己的React版本中的全部内容:
- 步骤一: 创建元素函数
- 步骤二: 渲染函数
- 步骤三: 并发模式
- 步骤四: Fibers
- 步骤五: 渲染和提交阶段
- 步骤六: 协调
- 步骤七: 函数式组件
- 步骤八: 钩子函数
步骤零:回顾
首先我们回顾一些基本概念,如果你已经对React
、JSX
和DOM
元素如何工作有一些好的理解可以跳过这个步骤。
我们仅仅需要三行代码使用React
应用,第一行定义一个React
元素,下一行从DOM
中获取节点,最后将元素渲染到容器中。
让我们移除所有React代码,使用原生JS开始吧!
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
第一行我们使用jsx
创建了元素,它在JavaScript
中是不合法的,为了用原生JS代替它,首先我们使用合法的JS
去替换。
JSX
通过类似Babel
的工具转化为JS
,转化通常是简单的:将标签中的代码替换成createElement
的调用。将标签名、属性和子元素作为参数传递过去。
React.createElement
根据参数创建一个对象,除去一些校验,这就是它做的全部。因此我们可以安全地使用它的输出代替函数的调用。
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
一个Element
是什么?
一个带有两个属性的对象:type
和props
(也许有更多,但我们只关心这两个)。
Type 是我们创建的一个区分DOM
节点类型的字符串,它是当你创建一个HTML
元素时,通过CREATE ElEMENT
创建的标签名,它也是一个方法,但我们将在第五步说。
Props 是另一个对象,它有JSX
属性中所有的索引和值,它也包括一个特别的属性:children
,
children
这种情况下是一个字符串,但它通常是一个有更多元素的数组,那也是为什么元素是树状的。
我们需要替换的另一部分代码块是ReactDOM.render
,render
是DOM
中React
改变的地方,因此我们自己来更新它。
const node = document.createElement(element.type);
node["title"] = element.props.title;
首先我们使用h1
创建一个node
;
然后我们将所有属性赋予node
中,这里仅有title
。
为避免冲突,我使用“element” 代指React
元素,“node” 代指DOM
元素,然后我们为这个节点创造子节点,我们创建一个文本节点时我们仅仅用一个字符串作为子节点。
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)
使用textNode
而不是设置innerText
将允许我们以后以相同的方式处理所有元素。注意我们如何设置nodeValue
,就像我们在h1
标题中所做的那样,几乎就像字符串有props: {nodeValue: "hello"}
一样。
最后,我们将textNode
追加到h1
元素,然后将h1
追加到容器中。
现在,我们拥有了与之前相同的应用程序,但不再使用React
。
步骤一:createElement函数
让我们从另一个应用开始,这次我们将使用我们自己版本的React
代替React
代码。
我们将从写自己的createElement
开始。
我们把JSX
转化为JS
,以便于我们能够看到createElement
的调用,正如我们在当前步骤看到的这样,一个元素是一个带有type
和props
属性的对象,我们的函数唯一需要做的事创建这个对象。
element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
我们使用扩展运算符处理props
,并使用剩余参数rest
语法处理children
,通过这个方式,子元素永远是个数组。
例如,createElement("div")
返回
{
"type": "div",
"props": { "children": [] }
}
`createElement("div", null, a)`返回:
{
"type": "div",
"props": { "children": [a] }
}
`createElement("div", null, a, b)` 返回:
{
"type": "div",
"props": { "children": [a, b] }
}
子数组也可以包含原始数据类型的值像string
或number
,因此我们将除了对象以外的所有元素都装进自己的元素中并为它们创建一个特殊的类型TEXT_ELEMENT
。
React
没有子节点时不装载基本数据类型和创建空数组,但是我们这样做能简化我们的代码,且对于我们的库来说,我们更喜欢简单的代码而不是高性能的代码。
我们仍然使用React
的createElement
函数。
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
为了替代它,我们为我们的仓库命名,我们给我们仓库起一个名,我们需要这个名字听起俩像React但也暗示了它明确的目的。
我们叫它Diadact
.
我们这里仍然想使用JSX
,我们如何告诉babel
使用Didact
的createELement
代替React
的呢?
如果我使用这样的注释,当babel
转译JSX
时,将使用我们定义的方法。
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
步骤二:渲染函数
ReactDOM.render(element, container)
接下来,我们要编写自己版本的ReactDOM.render
函数了,目前为止,我们只关心DOM
的增加,后边再处理更新和删除。
我们从使用组件类型创建DOM
节点开始,之后像容器中追加新节点。
我们对子节点递归执行相同操作。
我们也需要处理文本元素,如果元素类型为TEXT_ELEMENT
,我们创建文本节点代替常规节点。
最后我们需要为节点赋予属性,这样,我们现在拥有了一个可以将JSX
渲染到DOM
中的库。
// 传入两个参数,第一个是react元素,第二个是dom元素
const render = (elem, container) => {
// 创建dom元素
const dom = elem.type === 'TEXT_ELEMENT' ? document.createTextNode(''): document.createElement(elem.type);
//为dom元素赋予属性
Object.keys(elem.props)
.filter(key => key !== 'children')
.forEach(prop => dom[prop] = elem.props[prop]);
// 递归渲染子元素,递归停止:子元素没有子元素了
elem.props.children.forEach(item => render(item, dom));
// 将dom元素追加到父节点中
container.append(dom);
}
export default render;
步骤三:并发模式
一旦我们开始渲染,直到我们渲染完整的树形结构才能停止,如果元素树是庞大的,它会阻塞主线程太长时间,并且如果浏览器需要做像处理用户输入框或平滑地渲染动画这种高优先级的元素更新,需要等到渲染完成。因此我们打算将工作分成一些小的单元,每完成一个单元后,如果有一些其他事情需要做我们将中断浏览器的渲染。
element.props.children.forEach(child =>
render(child, dom)
)
我们使用requestIdleCallback
做循环,你可以把requestIdleCallback
当作setTimeOut,但不需要我们通知它什么时间启动,主线程空闲时浏览器将运行回调函数。
React不再使用requestIdleCallback
,现在它使用scheduler package。但在这个使用示例中,他们的概念是相同的。
requestIdleCallback
也提供给我们deadline
参数,我们可以通过这个参数检查浏览器还有多久空余时间。
自从2019年11月起,并发模式已经在React
中稳定了,稳定版本中的循环看起来像这样的。
while (nextUnitOfWork) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
}
开始使用循环,我们需要设置第一个工作单元,然后写一个`performUnitOfWork` 函数 不仅能渲染当前工作也会返回下一个工作单元。
// 渲染任务调度逻辑--Fiber升级
// 传入两个参数,第一个是react元素,第二个是dom元素
const render = (elem, container) => {
// 创建dom元素
const dom = elem.type === 'TEXT\_ELEMENT' ? document.createTextNode(''): document.createElement(elem.type);
//为dom元素赋予属性
Object.keys(elem.props)
.filter(key => key !== 'children')
.forEach(prop => dom\[prop] = elem.props\[prop]);
// 删除递归,递归严重影响性能,且无脑无优先级更新
// 将dom元素追加到父节点中
container.append(dom);
}
let nextUnitOfWork = null;
const workLoop = (deadLine) => {
// 是否应该终止
let shouldYield = false;
while(nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadLine.timeRmatining() < 1;
}
// 判断是否有足够的时间执行当前任务,若没有,则等系统空闲后执行workhook
requestIdleCallback(workLoop);
}
// 第一次发起请求
requestIdleCallback(workLoop);
const performUnitOfWork = () => {
}
export default render;
步骤四:FIbers
为了组织工作单元,我们需要一个数据结构:a fiber Tree
;
每个元素都有一个fiber
,并且每个fiber
都是一个工作单元,
举个例子:假设我们想渲染一个下面这样的元素树
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
在渲染过程中,我们将创建root fiber
,并且将它设为第一个工作单元,其余的工作将发生在performUnitOfWork
函数中,在这里,我们将为每个fiber
做三件事情:
- 添加
DOM
元素; - 为元素子元素创建;
- 选择下一个工作单元。
这个数据结构的目标之一是让找到下一个工作单元更简单,这就是为什么每一个fiber
都有指向其第一个子节点,下一个兄弟节点和父节点的链接。
当我们结束一个fiber
的渲染工作,如果他有子元素,那么fiber
将是下一个工作单元。
从我们的例子中可以看出,当我们结束div
这个工作单元,下一个工作单元将是h1 fiber
,如果fiber c
没有子节点,我们使用兄弟节点作为下一个工作单元
,例如p
, fiber
没有子节点,我们在它工作完成后移动到a fiber
。如果fiber
没有子节点或兄弟节点,我们走到“叔叔”-兄弟节点的父节点,像例子中的a
和h2 fiber
。
并且,如果父节点没有兄弟节点,我们继续向上遍历父节点,直到找到有兄弟元素的父节点或到达了根节点,如果我们到达了根节点,意味着所有渲染工作已经完成。
现在让我们将其转化为代码,首先,我们从render中删除这个代码,保留在自己的函数中创建DOM节点的部分,我们一会儿继续使用它。
render
函数中,我们将设置nextUnitOfWork
为fiber tree
的根节点。
然后,当浏览器准备好了,它将调用我们的workLoop
我们将开始运行根节点。
首先,我们创建一个新节点并且把它加入到DOM
中。我们在fiber.dom
中持续追踪DOM
节点属性;
然后为每一个子元素创建一个新的fiber
,我们将其添加到fiber tree
中,作为一个子元素或兄弟元素,取决于它是否有第一个子节点。
最后我们搜寻下一个工作单元,我们首先尝试子节点,然后是兄弟节点,然后是叔叔节点,以此类推。
这就是我们的performUnitOfWork
。
const performUnitOfWork = (fiber) => {
// 初次渲染时,不存在dom,因此将fiber转化为dom渲染到页面中
console.log('fiber', fiber);
if(!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 追加到父节点
if(fiber.parent) {
fiber.parent.dom.append(fiber.dom);
}
// 给children 新建fiber
const elements = fiber.props.children;
// preSibling用于记录非亲儿子情况下的前一个兄弟组件
let preSibling = null;
for(let i = 0; i < elements.length; i++) {
const newFiber = {
type: elements[i].type,
props: elements[i].props,
parent: fiber,
dom: null,
child: null,
sibiling: null
}
//为fiber之间构建关系,构建Fiber tree
// 如果是children的第一个,就是唯一儿子,同时将preSibling指向当前newFiber
if(i === 0) {
fiber.child = newFiber;
} else {
preSibling.sibiling = newFiber;
}
preSibling = newFiber;
}
// 返回下一个fiber
if(fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while(nextFiber) {
if(nextFiber.sibiling) {
return nextFiber.sibiling;
}
nextFiber = nextFiber.parent;
}
}
步骤五:渲染和提交阶段
我们有另一个问题,每次元素执行时我们增加一个新节点到DOM
中,并且浏览器可以打断我们、结束渲染整颗树的工作,用户将看到一个不完整的UI
。
我们不想要这样,因此我们需要从这里删掉修改DOM
的部分。
if(fiber.parent) {
fiber.parent.dom.append(fiber.dom);
}
因此,我们保持追踪这个fiber树的根节点,我们称之为 work in progress root
或者 wipRoot
。一旦我们完成所有工作(我们知道这一点是因为没有下一个工作单元),我们就会将整个 fiber
树提交到 DOM
中。
// commit 阶段
if(!nextUnitOfWork && wipRoot) {
commitRoot();
}
我们在commitRoot
函数中做这些,这里我们递归地向DOM中添加节点。
// 提交
const commitRoot = () => {
// 入口是child,、需要将其插入父节点的dom中
commitWork(wipRoot.child);
wipRoot = null;
}
// 递归提交所有fiber
const commitWork = (fiber) => {
if(!fiber) {
return;
}
const parentDom = fiber.parent.dom;
parentDom.append(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}
步骤六:重建
目前为止,我们仅仅向DOM
中添加了一些内容,但关于更新和删除节点呢?
我们现在打算做这个,我们需要比较
render
函数中接收到的元素和最后提交到DOM
中的fiber tree
,因此,我们需要在完成提交后保存最后提交到DOM
中的fiber tree
的引用。我们叫它currentRoot
。 我们也向每个fiber
增加alternate
属性,这个属性是旧的fiber
和我们现在的提交阶段要提交到DOM
中的fiber
之间的一个关联。
现在我们将performUnitTiWork
中创建新节点的代码提取出来,放到一个新的reconcilenChildren
函数中,在这里,我们将对旧的 fiber
与新元素进行协调(reconcile)。我们同时迭代旧的fiber
(wipFiber.alternate
)和我们想要重建的元素数组。
如果我们同时忽略了迭代数组和链表中所有的脚手架代码,我们最关注的是while
循环内最重要的部分:oldFiber
和 element
。element
是我们想要渲染到DOM
上的东西,oldFIber
是我们最后一次渲染的内容,我们需要比较它们看是否有需要提交到DOM
中的修改。
我们使用type
比较它们:
- 如果旧的
fiber
和新元素有相同类型,我们可以保留DOM
节点只需更新新的属性 - 如果类型不同且有一个新元素,意味着我们需要新建一个元素;
- 如果类型不同且有一个旧的
fiber
,我们需要删除旧fiber
。
React
也使用了keys
,能够更好进行重建,例如,它会检测到当子元素在元素数组中改变位置的情况,当旧的fiber
和元素有相同类型,我们创建一个新的fiber
,保留旧的fiber
的DOM
节点挤属性。
我们也为fiber
增加了一个新的属性effectTag
,我们将使用这个属性在提交阶段。
然后对于需要新的节点的情况,我们使用PLACEMENT
标记新的fiber
,对于需要删掉节点的情况,我们没有新的fiber
,因此我们对旧的fiber
进行标记。
当我们提交fiber tree
到DOM
时 我们在正在渲染的根中处理它,不需要旧的fibers。
因此我们需要一个数组标记我们想删除的节点,然后当我们向DOM
中提交改变时,我们使用数组中的fibers
。
现在,我们改变commitWork
函数去处理新的effectTags
。
如果fiber
有PLACEMENT
标签,我们和之前做的一样,想父元素fiber添加DOM节点;
如它是DELETION
,我们做相反的操作,移除子节点;如果是UPDATE
,我们需要用改变的属性更新已经存在的DOM
节点;我们实现更新在updateDom
函数中。我们对比新旧fiber
的属性,移除掉已经消失的属性,设置新的或改变了的属性。
if(sameType) {
// 更新
newFiber = {
type: oldFiber.type,
props: elements\[index].props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE'
}
}
if(element && !sameType) {
// 新增
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT'
}
}
if(oldFiber && !sameType) {
// 删除
oldFiber.effectTag = 'DELETION';
deletions.push(oldFiber);
}
我们需要更新一个新的属性类型是事件监听,如果属性名称是以on
前缀,我们用不同的方式处理它。
如果事件处理改变,我们从nodes
中移除它然后添加新的处理。
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name
.toLowerCase()
.substring(2)
dom.addEventListener(
eventType,
nextProps[name]
)
})
}
步骤七:函数式组件
接下来我们需要让其支持函数式组件,首先,让我们改变这个例子,我们将使用单独的函数式组件返回一个h1
元素,
请注意,如果我们将 JSX
转换为 JavaScript
,它将变为:
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = Didact.createElement(App, {
name: "foo",
})
函数式组件有两个方面的不同:
- fiber来源于函数组件而不是一个DOM节点
- 子元素来源于运行后的函数而不是直接从props获取
我们检查fiber
类型是一个function
,根据这个条件我们进入不同的更新函数,在updteHostComponent
我们和之前做的一样,在updateFunctionComponent
我们执行函数获取子元素。
const updateFunctionComponent = (fiber) => {
// 运行函数式组件,同时将props作为参数传入
const children = [fiber.type(fiber.props)];
// 新建newFiber,构建fiber树
reconcileChildren(fiber, children);
}
对于我们的例子,fiber.type
是App
函数,当我们执行它时,返回的是h1
元素。
然后,一旦有孩子,重构以相同方式工作,我们不希望有任何改动。
我们需要在commitWork
函数中改变一些,现在我们有了没有DOM
节点的fiber
,我们需要改变两个事情。
首先,找到DOM
节点的父节点,我们需要向上查找直到找到有DOM
节点的fiber
,然后删除节点时,我们需要一直进行,直到找到有子节点的DOM
节点为止。
// 删除函数式组件commit时dom中的子组件
const commitDeletion = (fiber, domParent) => {
if(fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
步骤八:钩子函数
最后一步,现在我们有函数式组件,我们也来添加状态。
我们用经典的计数器改变组件的例子,每当我们点击,它的状态就加一。
请注意,我们使用Didact.useState
去获取和更新计数器的值。
这是我们例子中调用counter
函数的地方,在函数内部我们叫useState
。
我们需要在调用函数式组件之前初始化一些全局变量,以使我们可以使用useState函数内部使用它们。
首先我们设置work in progress fiber
;
我们在fiber
中添加了一个hooks
数组支持多次在相同的组件中调用useState
,同时我们追踪当前hook
的索引。
当函数式组件调用useState
时,我们检查是否有旧的hook
,我们使用 hook
索引在 fiber
的 alternate
中进行检查。如果存在旧的hook
,将旧的hook
复制到新的hook
,如果不存在的话,初始化这个state
。然后向fiber
中添加新的hook
,按属性递增增加,并且返回这个state
。
unction 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
也应该返回一个更新state
的函数,因此我们定义一个setState
方法接受一个action
(对于计数器例子,这个方法时递增state
的方法)。
const setState = action => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
我们推动这个action
到我们新增的hook
中,然后我们做和render
函数相似的工作,设置a new work in progress
作为下一个工作单元以便于这个工作循环可以开始一个新渲染阶段。
但是我们还没有运行这个action
。
我们渲染这个组件在下一个时间片中,我们从旧的hook queue
中获取到所有actions
,然后逐个将它们应用到新的hook
中,我们返回的state
是更新后的state
了,这就是所有,我们搭建了自己版本的React。
结语
除了帮助你理解React
是如何工作的之外,这篇文章的目的之一是让你更容易更深入地理解React
代码库,这也是为什么我们全局使用相同的变量和函数名。
例如,如果你在一个真实的react
应用中,在任意一个函数式组件中增加一个断点,调用堆栈应该会展示:
workLoop
performUnitOfWork
updateFunctionComponent
我们没有包含大部分React
特性和优化,例如,有一些和React做的不同的:
- 在
Didact
,我们在渲染阶段遍历整颗树,如果子树没有改变的话,React
会通过一个启示或方法跳过这些部分。 - 我们在提交阶段也遍历整颗树,
React
为有相互作用和只访问的fibers
维护了一个链表。 - 每当我们为进行中的树创建了
work in progress
树,我们为每个fiber
创建新的对象,React
回收之前的fiber
放到当前树上。 - 当Didact在渲染阶段接收到一些新的更新时,他会丢掉正在进行中的工作从根节点重新开始,
React
还会为每个更新添加一个过期时间戳,并使用它来决定哪个更新具有更高的优先级。 - 还有很多...
你还可以轻松地添加一些功能:
- 使用对象作为样式属性
- 拍平子数组
useEffect
钩子- 通过`key进行协调
转载自:https://juejin.cn/post/7241465246463918140