关于前端:新的一年从手写mini-react开始

7次阅读

共计 9512 个字符,预计需要花费 24 分钟才能阅读完成。

大家好,这里是【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 内容

createElement 函数

在前沿外面,咱们应用了 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 办法

当初咱们要实现 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}

Fibers

为了实现咱们上述的 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 就开始执行,这个函数须要解决三件事

  1. 增加这个节点到实在 dom
  2. 创立以后节点的下一个 fiber
  3. 赋值下一个工作单元

为什么是这种数据结构,这种构造的次要目标就是便于查找下一个工作单元,所以这里列出以后节点的父节点、子节点、同级节点

当实现一个 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 这个办法当中须要做什么操作呢?下面咱们曾经提过了

  1. 参数 fiber 传入,将以后 fiber 节点转换成实在 dom
  2. 创立一个新的 fiber 节点
  3. 返回下一个工作单元

咱们来实现一下

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 函数

render 和 commit 阶段

到这里,咱们还有一个问题,工作单元目前是一个一个小的元素节点,也就是咱们的实在 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)
}

Reconciliation

到当初咱们只是向 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-ow…

当然原文要比我这个难看的多,然而还是想拿进去分享一下

如果您在实在的 React 应用程序中的一个函数组件中增加一个断点,调用堆栈应该会显示:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

咱们没有蕴含很多 React 性能和优化,因为这是一些粗疏内容

咱们在渲染阶段遍历整棵树,理论 React 会遵循一些提醒和启发式办法来跳过没有任何变动的整个子树

咱们还在提交阶段遍历整棵树。但 React 保留一个链表,其中只蕴含有影响的 fiber,并且只拜访这些 fiber

每次咱们构建一个新的正在进行的工作树时,咱们都会为每个 fiber 创立新对象。React 从以前的树中回收 fiber

在渲染阶段接管到新的更新时,它会抛弃正在进行的工作树并从根部从新开始。React 应用到期工夫戳标记每个更新,并应用它来决定哪个更新具备更高的优先级

等等很多不一样的中央,然而次要的思维就是这些,如果你有什么问题或者想法,欢送评论

正文完
 0