大家好,这里是【FE情报局】
作为前端来说,工作三年以上,如果你还是不会或者不了解如何手写一些最基础的框架,对于当前的形势来说是不太友好的,了解框架原理,手写框架已经成为前端开发者最基础的知识技能了,学习框架设计思维,结合业务体系,能够更好的做开发和优化
react作为前端热门框架之一,学习了解手写其中的原理也是前端们需要掌握的技能之一,了解如何一步一步实现一个简易的react,能够更深刻的了解react原理,hook的原理和机制,使用起来才能够更加得心应手
我会参照build-your-own-react这个项目,一步一步实现一个mini react
当然这不会涉及到react中一些非必要的功能,比如一些优化,但是会遵循react的设计理念
首先我们可以了解一些react的基本概念和内容,使用一个react很简单,只需要三行代码,我们详细来讲述一下这三行代码
const element = <span class='text'>FE情报局</span>
const root = document.getElementById('root')
ReactDOM.render(element, root)
ele定义了一个dom节点,root是html中body下面的根元素,然后使用 ReactDOM.render将ele的dom插入到root节点下,熟悉react的同学对这个内容都不陌生,这是react项目入口写法
将其替换成原始的javascript代码应该怎么实现呢?
首先第一行,这是一段jsx代码,在原生的javascript中是不会被识别的,将其变成原生React代码应该是这样的
// const element = <span class='text'>FE情报局</span>
const element = React.createElement(
'span',
{
class: 'text'
},
'FE情报局'
)
对于jsx代码的转换通常是通过babel工具,将jsx的代码转换成js认识的代码
createElement第一个参数是元素类型,第二个是元素属性,其中有一个特殊的节点children,之后会讲到,之后所有的参数都是子节点内容,可以是一个字符串,也可以是另一个节点
转换过程通常也比较简单,了解babel的人对AST过程比较熟悉,将jsx代码通过parse生成AST语法树,然后通过transform再将其进行转换,变成最终的AST语法树,最后再generate将AST语法树转换成最终的结果,transform阶段其实就是将jsx代码转换成对createElement的调用,将标签,属性,以及子元素当参数进行传递
但是createElement并不会直接创建一个dom元素,而是创建一个object,这个object就是我们常说的虚拟dom,本质就是一个js对象,这个对象包含以下内容,当然最主要的内容是这几个,更多的虚拟dom属性可以自己了解
const element = {
type: 'span',
props: {
class: 'text',
children: 'FE情报局'
}
}
这个虚拟dom就可以完全表示一个真实dom,其中type是DOM节点类型,当然也可以是一个函数,后续会做说明
props是一个对象,具有jsx属性中所有的健值对,还有一个特殊的属性children,当前这个情况children是一个字符串,它也可以是嵌套的其它内容,比如可以再嵌套一个数组,数组内容可以是element或者字符串,这也就说明为什么虚拟dom是个树形结构
最后一行代码ReactDOM.render,就是用来生成真实dom,目的是为了将element插入到root节点当中,用原生js替换一下
// ReactDOM.render(element, root)
const node = document.createElement(element.type)
node['class'] = ele.props.class
const text = document.createTextNode('')
text['nodeValue'] = ele.props.children
首先我们创建了一个span的节点,然后将class赋值给节点,node表示真实dom节点,然后创建子节点元素,子元素就是一段text,所以创建一个text节点,这里不用innerText主要是这种方式不可以处理多种格式的children,使用createNode的形式可以直接处理props,比如这里我们可以将porps改成props: {nodeValue: "FE情报局"}
最后,我们将textNode插入到span标签中,将node插入到root元素下
node.appendChild(text)
root.appendChild(node)
最终,我们将react代码转换成了js原生代码,有了这些认知,我们将开始正经写一个mini react内容
在前沿里面,我们使用了React官方提供的一些方法,虽然使用原生js也实现了一些基本的功能,但是只是按逻辑实现,并没有做一些较好的封装,所以我们要自己写一个mini React,提供一些通用方法,达到原生React功能的目的
首先先编写createElement函数,来一个稍微复杂一点的例子
const ele = (
<div class='box'>
<a>FE情报局</a>
<br />
</div>
)
const root = document.getElementById('root')
ReactDOM.render(ele, root)
还记得React.createElement的调用方式么?通过一些传参,它返回一个虚拟dom,也就是一个js对象
function createElement(type, props, ...children){
return {
type,
props: {
...props,
children
}
}
}
const element = createElement(
'div',
{
class: 'box',
},
createElement('a', null, 'FE情报局'),
createElement('br')
)
这里讲一个小技巧,我们使用的扩展运算符,会导致如果你不传子元素,默认children是一个数组,比如我们调用createElement('div')
传了后续的内容,自然也是一个数组,保证了格式的统一
当然children的数据类型较多,比如它可以是数字,字符串,也可以是一个虚拟dom,如果不是一个对象,则它就是自己的一个内容,那我们可以为其创造一个类型标识:TEXT_ELEMENT
完善一下createElement的逻辑,判断传入的children类型
function createElement(type, props, ...children){
return {
type,
props: {
...props,
children: children.map(child => {
if(typeof child === 'object'){
return child
}
return createTextElement(child)
})
}
}
}
// 编写createTextElement,同样返回虚拟dom,也就是一个js对象
function createTextElement(text)
{
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: []
}
}
}
然后我们取名MiniReact,可以通过MiniReact.createElement方式进行调用
const MiniReact = {
createElement
}
现在我们要实现render方法,将虚拟dom转成真实dom,并挂在到对应的节点,第一个参数是ele虚拟dom,第二个是要挂在的dom元素,当前我们先完成在挂在的dom元素后追加
function render(ele, container){
// 先判断ele.type是否为text然后执行对应的逻辑
const dom = ele.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(ele.type)
// props中属性追加到dom元素中,但是要注意去除children属性,所以要先做一层过滤
const isProperty = key => key !== 'children'
Object.keys(ele.props).filter(isProperty).forEach(name => {
dom[name] = ele.props[name]
})
// 将children遍历调用render
ele.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
最后将render方法挂在到MiniReact
在进行功能追加的时候,我们需要重构一下之前的内容
那我们之前的内容有什么问题呢?细心的同学就会发现,如果我们在人render的时候传入的虚拟dom树过于庞大,而render方法中总是不断的去递归虚拟dom中的children,那就会存在在执行render的时候,整个js线程被阻塞,并且停不下来,导致用户输入或者页面渲染卡顿,必须等render函数执行完成才有响应
所以这个就涉及到react中的时间切片的概念了,我们需要将大量的执行任务分割成小的单元,这个小的单元会在屏幕更新的间隙完成,每次完成之后看是否有其它事情做,如果有则中断render,执行完成之后,再将render继续执行
那如何知道浏览器是否空闲呢?window.requestIdleCallback就可以知道,这个方法插入一个函数,这个函数就会在浏览器空闲的时间去调用
当然react肯定不是使用window.requestIdleCallback,它们有自己的调度器,由于我们是MiniReact,所以我们使用window.requestIdleCallback就可以了,本质是一样的
我们先简单实现一下,然后再来具体介绍一下
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while(nextUnitOfWork && !shouldYield){
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadLine.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
requestIdleCallback这个函数接收到要执行的函数之后,会给这个函数传递一个参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态
比如上述的deadline,就是回调函数的参数,deadLine.timeRemaining()表示浏览器需要执行其它逻辑的时候我们还有多少时间
这里表示如果这个时间小于1,那么这个while循环就被停止,执行被暂停,将控制权交给浏览器
然后当浏览器空闲的时候,继续执行workLoop,查看是否有nextUnitOfWork,也就是要开始执行逻辑,同时我们必须要有一个performUnitOfWork函数,用来执行nextUnitOfWork并且要返回下一个nextUnitOfWork
function performUnitOfWork(nextUnitOfWork){
// TODO
}
为了实现我们上述的performUnitOfWork这个函数,我们需要组织一下我们对应的工作单元,也就是需要支持我们的虚拟dom能够渐进式渲染,对整个大的虚拟dom或者任务进行分片,分片完成之后要能够支持分片任务的挂起、恢复、终止等操作,并且任务都有优先级,这个时候就要提到我们的Fiber
在这个架构中,引入了一个新的数据结构,Fiber节点,这个节点根据虚拟dom生成,然后通过Fiber节点生成真实dom
为了尽可能细化我们每个单元的操作,需要每个元素都应该有一个fiber,每一个fiber都是一个工作单元,确保执行的速度,举个例子,假设我们有这样一个dom结构,将其挂在到root节点
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>
拿到这个内容,babel会将其转换成虚拟dom,然后通过虚拟dom数据,转换成fiber的数据结构
我们首先需要创建一个root fiber,将其赋值给nextUnitOfWork,赋值之后performUnitOfWork就开始执行,这个函数需要处理三件事
为什么是这种数据结构,这种结构的主要目的就是便于查找下一个工作单元,所以这里列出当前节点的父节点、子节点、同级节点
当完成一个fiber的工作的时候,如果它有子节点,则进行子节点的工作单元
所以root之后,下一个是div fiber,再下一个是h1 fiber
当没有子节点,则查看是否有兄弟节点,所以从p到a
那既没有子节点,也没有兄弟节点怎么办?那就去找父级元素,然后父母的兄弟,没有兄弟继续向上,直到根元素root,如果到达root,则说明我们已经完成了渲染的所有工作
接下来我们用代码实现一下
之前我们使用了render函数来进行渲染,需要改造一下,通过createDom这个方式来执行对应的逻辑,而createDom中,函数参数应该是每一个fiber节点
function createDom(fiber){
const dom = fiber.type === 'TEXT_ELEMENT' ? document.createTextNode('') : document.createElement(fiber.type)
const isProperty = key => key !== 'children'
Object.keys(ele.props).filter(isProperty).forEach(name => {
dom[name] = ele.props[name]
})
return dom
}
在render函数中,我们需要设置nextUnitOfWork为根节点
function render(element, container){
nextUnitOfWork = {
dom: container,
props: {
children: [element]
}
}
}
然后当浏览器有空闲时间的时候,便开始执行workLoop,执行performUnitOfWork方法,然后从根节点root开始,按照上述逻辑渲染每一个节点
performUnitOfWork这个方法当中需要做什么操作呢?上面我们已经提过了
我们来实现一下
function performUnitOfWork(fiber){
// 查看当前fiber是否已经生成dom
if(fiber.dom){
fiber.dom = createDom(fiber)
}
// 存在父节点则挂载当前节点
if(fiber.parent){
fiber.parent.dom.appendChild(fiber.dom)
}
// 创建子节点fiber
const elements = fiber.props.children
let index = 0
let prevSibling = null
// 然后我们将其添加到fiber树当中,将其设置为子节点或者兄弟节点
while(index < elements.length){
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null
}
if(index === 0){
fiber.child = newFiber
}else{
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
// 搜索逻辑
if(fiber.child){
return fiber.child
}
let nextFiber = fiber
while(nextFiber){
if(nextFiber.sibling){
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
这就是我们整个performUnitOfWork函数
到这里,我们还有一个问题,工作单元目前是一个一个小的元素节点,也就是我们的真实dom需要一个一个添加到页面中。在这个过程中,浏览器可以随时打断我们执行的渲染,这个时候很有可能就会发生用户看到的是某一个小片段,这显然是有问题的
所以在这个performUnitOfWork函数中,我们需要修改这块代码,因为这里直接就会将dom插入到对应的节点中
if(fiber.parent){
fiber.parent.dom.appendChild(fiber.dom)
}
我们需要定义一个wipRoot的变量,表示work in progress root,然后将它的引用地址复制给nextUnitOfWork
let wipRoot = null
let currentRoot = null
function render(element, container){
wipRoot = {
dom: container,
props: {
children: [element]
},
alernate: currentRoot
}
nextUnitOfWork = wipRoot
}
这样一旦我们完成整个工作单元,完成的条件就是没有根节点,我们就commit整个fiber tree到真实dom
// workLoop函数中,需要增加commitRoot时机
function workLoop(deadline) {
let shouldYield = false
while(nextUnitOfWork && !shouldYield){
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadLine.timeRemaining() < 1
}
if(!nextUnitOfWork $$ wipRoot){
commitRoot()
}
requestIdleCallback(workLoop)
}
function commitRoot(){
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
function commitWork(fiber){
if(!fiber) return
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
到现在我们只是向dom添加了对应的内容,如果更新或着删除节点该怎么做呢?
我们需要将我们在渲染函数上接收到的元素,与提交给dom的最后一个fiber树进行比较
因此,我们需要在完成提交后保存对我们提交给dom的最后一个fiber树的引用
我们将performUnitOfWork中的这段代码进行替换
// if(fiber.parent){
// fiber.parent.dom.appendChild(fiber.dom)
// }
const elements = fiber.props.children
reconcileChildren(fiber, elements)
这里我们将协调旧的fiber
function reconcileChildren(wipFiber, elements) {
let index = 0
// 旧节点,上次渲染的内容
let oldFiber =
wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
// element是我们要渲染的内容
elements.forEach((element, index) => {
let newFiber = null
const sameType = oldFiber && element && oldFiber.type === element.type
// 如果旧的 fiber 和新的元素有相同的类型,我们可以保留 DOM 节点并用新的 props 更新它
if(sameType){
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE'
}
}
// 如果类型不同并且有一个新元素,则意味着我们需要创建一个新的 DOM 节点
if(!sameType && element){
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
// 如果类型不同并且有旧fiber,我们需要删除旧节点
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
})
}
之后就是需要根据这个逻辑去改造对应的逻辑处理部分,这里不详细的说明了,之后我会将源码放出来,感兴趣可以去看,这块要细讲的话内容过多
这就是整个节点更新操作删除的简单diff
上面就是整个MiniReact的源码,参考自 https://pomb.us/build-your-own-react/
当然原文要比我这个好看的多,但是还是想拿出来分享一下
如果您在真实的 React 应用程序中的一个函数组件中添加一个断点,调用堆栈应该会显示:
我们没有包含很多 React 功能和优化,因为这是一些细致内容
我们在渲染阶段遍历整棵树,实际 React 会遵循一些提示和启发式方法来跳过没有任何变化的整个子树
我们还在提交阶段遍历整棵树。但 React 保留一个链表,其中只包含有影响的fiber,并且只访问这些fiber
每次我们构建一个新的正在进行的工作树时,我们都会为每个fiber创建新对象。 React 从以前的树中回收fiber
在渲染阶段接收到新的更新时,它会丢弃正在进行的工作树并从根部重新开始。 React 使用到期时间戳标记每个更新,并使用它来决定哪个更新具有更高的优先级
等等很多不一样的地方,但是主要的思想就是这些,如果你有什么问题或者想法,欢迎评论