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

大家好,这里是【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 应用到期工夫戳标记每个更新,并应用它来决定哪个更新具备更高的优先级

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

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理