敲一下React-mini版代码

敲一下React-mini版代码

最近学习一篇文章关于React原理基础实现,收获良多,想在此写一下个人收获与总结。

0、jsx

const element = (
    <input value="todo" />
)

写一个最简单的jsx,我们知道它将会转换成一个object对象,如下面

const element = {
    type: 'input',
    props: {
        value: 'todo',
        children: [],
    }
}

1、createElement

那具体是怎样将jsx转换成一个object对象呢?那么我需要写一个命名为createElement的函数进行处理

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children
    }
  }
}

const element = createElement(
  'input',
  {
    value: 'todo'
  }
)

但是如果有嵌套子元素的jsx呢,例如

const element = (
    <div id="foo">
      <b />
      <b />
    </div>
)

那么将会变成下面

/* @return
{
  type: 'div',
    props: {
    id: 'foo',
      children: [
        {
          type: 'b',
          props: {
            children: []
          }
        },
        {
          type: 'b',
          props: {
            children: []
          }
        },
      ]
  }
}
* /
const element = createElement(
  'div',
  {
    id: 'foo'
  },
  createElement('b'),
  createElement('b'),
)

当然还有一种特殊情况,那就是子元素含有文本

const element = (
  <div id="foo">
    <b />
    bar
  </div>
)

那么调用的时候为

/*
 @return
 {
    type: 'div',
    props: {
      id: 'foo',
      children: [
        {  
          type: 'b', 
          props: { 
            children: [] 
          } 
        },
        'bar'
      ]
    }
 }
*/
const element = createElement(
  'div',
  {
    id: 'foo'
  },
  createElement('b'),
  'bar'
)

可以看到divchildren数组包含了一个对象和一个字符串'bar',为了后续方便处理,我们需要把文本元素也需要转换成一种我们约定好的对象,如下,那么createElement也对应改一下

function createTextElement(text) {
  return {
    type: 'TEXT_ELEMENT',
    props: {
      nodeValue: text,
      children: [],
    }
  }
}
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === 'object'
          ? child
          : createTextElement(child)
      )
    }
  }
}

最终转换为

{
  "type": "div",
    "props": {
    "id": "foo",
      "children": [
        {
          "type": "b",
          "props": { "children": [] }
        },
        {
          "type": "TEXT_ELEMENT",
          "props": { "nodeValue": "bar", "children": [] }
        }
      ]
  }
}

2、render

我们已经有了jsx对象了,那么我们要怎么渲染到页面容器上去呢,我们会去调用ReactDOM.render

const container = document.getElementById('root')
ReactDOM.render(element, container)

render方法基础实现,如下

function render(element, container) {
  const dom =
    element.type == 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(element.type)

  // element.props上的属性除了children,都属于dom节点属性
  const isProperty = key => key !== 'children'
  
  // 给dom添加属性
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  // 递归遍历dom里面的子元素
  element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}

const ReactDOM = {
  render
}

3、concurrent mode

上面截止,我们可以编写jsx,然后交给createElemnt转换为js对象,最终交给render渲染。

上面render使用递归遍历的,这可能会有些问题。一旦render开始执行,直到dom树渲染完成之后,render函数才会结束。万一dom树非常大,可能会阻塞主线程太长时间。如果浏览器需要处理用户输入或平滑动画等高优先级工作,则必须等到渲染完成才会去处理。

为了解决上述问题,React约定一种叫fiber模型的虚拟树, React是利用空闲时间(不会影响延迟关键事件,如动画和输入响应)进行虚拟dom树的构建,最终整个虚拟dom树构建完成后,才进行渲染工作。那究竟React是怎么让空闲时间时进行虚拟dom构建呢?

虽然React已经弃用了requestIdleCallback方法,因为这个api存在一些问题(执行频率不够实现流畅的UI渲染,兼容性等等)。

https://github.com/facebook/react/issues/11171#issuecomment-417349573

https://github.com/hushicai/hushicai.github.io/issues/48

但我们这里仍然可以使用这个api达到实现简易版react的concurrent mode功能。如下,我们不停地调用requestIdleCallback,一旦发现每一帧中有足够的空闲时间,并且还有未完成的任务,就会去执行任务。

let nextUnitOfWork = null

function workLoop(deadline) {
  // 是否停止
  let shouldYield = false

  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)

    // 空闲时间小于1ms则停止
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)

function performUnitOfWork() {
  // ...
}

4、fiber模型

接着来介绍一下fiber模型,假如有一段jsx

const element = (
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>
)

之前使用createElement方法会将jsx中每个元素转换成含有type、children等属性的对象。基于此,我们要对这些对象丰富一下,设计成fiber模型。规则就是

  • 每个元素都会有指向其父元素的属性(根元素除外)
  • 每个元素都会有指向其第一个子元素的属性(若有儿子)
  • 每个元素都会有指向与其相邻的弟弟元素的属性(若有弟弟)

fiber.png

第2节的render方法是从根节点一次性递归实现渲染。现在我们必须进行优化一下,将每一个元素的添加属性及添加子元素的操作都分成一个个单独的任务,利用空闲时间执行任务,当没有空闲时间时,记录下当前任务并且停止执行,到下一次空闲时间时继续执行,直到所有任务完成,那么即可进行渲染任务。同时上一节中有个关键的函数performUnitOfWork需要实现一下

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    // 创建fiber对应的dom片段
    fiber.dom = createDom(fiber)
  }

  const elements = fiber.props.children
  let index = 0
  let prevSibling = null

  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
  }
}

function createDom(fiber) {
  const dom = 
    fiber.type === 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(fiber.type)
    
  const isProperty = key => key !== 'children'
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = fiber.props[name]
    })

  return dom
}

那么render函数更改为

let nextUnitOfWork = null
function workLoop(deadline) {
  // 是否停止
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)

function render(element, container) {
  // 开始render,nextUnitOfWork从根节点开始
  nextUnitOfWork = {
    dom: container,
    props: {
      children: [element],
    }
  }
}

const ReactDOM = {
  createElement,
  render
}

5、render and commit phases

上一节说到利用空闲时间执行每一个单元任务,为的是创建fiber模型,那最终整个fiber虚拟树构建完成,就应该进行真正的dom渲染了。

那问题来了,我们要怎么才能知道虚拟树构建完成,怎么才能知道要进行渲染工作了呢?

首先回顾fiber模型和performUnitOfWork函数,可以了解到,构建虚拟树的过程,类似DFS(深度优先遍历)。从根节点开始深度往下遍历,再回流遍历,直至重新回到根节点。由于根节点没有弟弟元素和父元素,即代表构建完成(performUnitOfWork()返回值为undefined)。

此时我们又要更改一下render函数,声明一个变量wipRoot来存储构建中的虚拟树的根节点

let nextUnitOfWork = null
// 记录根节点
let wipRoot = null

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
  }
  nextUnitOfWork = wipRoot
}

在第3节concurrent mode中,我们知道react-mini是会不停地调用requestIdleCallback来实现分片任务,那么要在这里判断构建树是否完成,并且决定开始渲染工作。

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }

  requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)

commitRoot函数很简单

function commitRoot() {
  commitWork(wipRoot.child)
  // 重置根节点
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }

  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

6、Reconciliation

到此为止,我们实现了初始化渲染工作。那要是再次更新渲染呢,我们需要考虑更新dom和删除dom的操作。

首先,再次渲染我们并不需要完整构建新的dom树,我们可以把当前的虚拟树即将渲染的虚拟树进行对比,有更改或删除的元素我们才需要进行操作,尽量减少不必要的渲染。

因此我们需要个变量来记录当前虚拟树,并且和即将渲染的虚拟树建立起联系

let nextUnitOfWork = null
// 当前虚拟树根节点
let currentRoot = null
// 进行中虚拟树的根节点
let wipRoot = null

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    // 建立关系
    alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}

function commitRoot() {
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

那么对比的过程就在performUnitOfWork,我们需要对其更改一下

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  const elements = fiber.props.children
  reconcileChildren(fiber, elements)

  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

这里抽出来一个函数reconcileChildren,它主要的功能就是进行fiber对比。

// oldFiber:   div  -->   div -->   div  --> div
// elements:   div  -->   div -->   p  
// 操作        update     update    add      delete

这里是将老fiber,和新的jsx按位置顺序一一对比,

  • 相同位置的且元素类型相同,可以认为是update更新;
  • 相同位置但元素类型不同的,认为是add新增;
  • 相同位置老fiber有元素而新的jsx没有元素了,则是delete删除。

例如上面的例子。其实这里没有考虑到key值比较(暂时省略),只是按位置顺序一一对比,从这可以理解到React需要给遍历元素加key值的目的。https://react.docschina.org/docs/lists-and-keys.html#keys

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (
    index < elements.length ||
    oldFiber != null
  ) {
    const element = elements[index]
    let newFiber = null

    // 判断旧元素与新元素的类型是否相同
    const sameType = oldFiber && element && element.type == oldFiber.type

    if (sameType) {
      // update
    }
    if (element && !sameType) {
      // add
    }
    if (oldFiber && !sameType) {
      // delete
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
}

那具体怎么设定新的newFiber呢,我们增加多一个字段effectTag,表示这个newFiber是增删改的哪一种,方便后续渲染任务时判断。

if (sameType) {
  // update
  newFiber = {
    type: oldFiber.type,
    props: element.props,
    dom: oldFiber.dom,
    parent: wipFiber,
    alternate: oldFiber,
    effectTag: 'UPDATE',
  }
}

if (element && !sameType) {
  // add
  newFiber = {
    type: element.type,
    props: element.props,
    dom: null,
    parent: wipFiber,
    alternate: null,
    effectTag: 'PLACEMENT',
  }
}

if (oldFiber && !sameType) {
  // delete
  oldFiber.effectTag = 'DELETION'
  deletions.push(oldFiber)
}

由于需要删除的旧fiber不需要放回虚拟树上,所以单独用deletions数组变量存起来,后续渲染时遍历数组卸载对应的dom。

deletions还需要放到其他函数中去。

let nextUnitOfWork = null
// 当前虚拟树根节点
let currentRoot = null
// 进行中虚拟树的根节点
let wipRoot = null
// 需要删除的节点
let deletions = null

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  // 重置需要删除的节点
  deletions = []
  nextUnitOfWork = wipRoot
}
function commitRoot() {
  // 遍历卸载节点
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

那么到此fiber对比结束,又到了渲染任务了。我们需要对commitWork函数进行修改,对增删改不同effectTag的fiber进行对应的操作。

function commitWork(fiber) {
  if (!fiber) {
    return
  }

  const domParent = fiber.parent.dom
  if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  } else if (fiber.effectTag === 'DELETION') {
    domParent.removeChild(fiber.dom)
  }

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

这里又提到了一个新的函数updateDom,它的功能就是对dom上的属性进行增删改。

const isEvent = key => key.startsWith('on')
const isProperty = key => key !== 'children' && !isEvent(key)
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
function updateDom(dom, prevProps, nextProps) {
  // 删除旧的或者更改的事件监听
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      key => !(key in nextProps) || isNew(prevProps, nextProps)(key)
    )
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2)
      dom.removeEventListener(
        eventType,
        prevProps[name]
      )
    })

  // 删除旧属性
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ''
    })

  // 设置新的或更改的属性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })

  // 添加事件监听
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name.toLowerCase().substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}

因为之前的createDom函数没有考虑到添加事件监听,也对其更改一下

function createDom(fiber) {
  const dom =
    fiber.type == 'TEXT_ELEMENT'
      ? document.createTextNode('')
      : document.createElement(fiber.type)

  updateDom(dom, {}, fiber.props)

  return dom
}

至此React-mini主要功能算是完成了。后面的算是补充。

7、Function Components

我们先回顾之前createElement的章节

// 1、编写jsx
const element = (
  <input value="todo" />
)

// 2、转换jsx
const element = createElement(
  'input',
  {
    value: 'todo'
  }
)

// 3、转换jsx得到对象
const element = {
  type: 'input',
  props: {
    value: 'todo',
    children: [],
  }
}

那如果编写的jsx是函数组件呢

function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />

按照之前的规则,它同样会被转换

function App(props) {
  return Didact.createElement(
    'h1',
    null,
    'Hi ',
    props.name
  )
}
const element = Didact.createElement(App, {
  name: 'foo',
})
const element = {
  type: App,
  props: {
    name: 'foo',
    children: [
      {
        type: 'h1',
        props: {
          children: [
            {
              type: 'TEXT_ELEMENT',
              props: { 'nodeValue': 'Hi ', 'children': [] }
            },
            {
              type: 'TEXT_ELEMENT',
              props: { 'nodeValue': 'foo', 'children': [] }
            }
          ]
        }
      }
    ]
  }
}

这里比较特殊的地方在于element的属性type不再是标签类型的字符串,而是函数。因此之前的代码又需要改动了。

首先是performUnitOfWork函数

function performUnitOfWork(fiber) {
  // if (!fiber.dom) {
  //   fiber.dom = createDom(fiber)
  // }
  // const elements = fiber.props.children
  // reconcileChildren(fiber, elements)
  
  const isFunctionComponent = fiber.type instanceof Function

  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }

  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }

}

这里根据type属性不同区分处理

function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  const elements = fiber.props.children
  reconcileChildren(fiber, elements)
}

接着再来思考下函数组件有什么特点,如下面

function App() {
  return (
    <span>foo</span>
  )
}
const element = (
  <div id="root">
    <App />
  </div>
)

// fiber模型
div --> App --> span

// 最终渲染
<div>
  <span>foo</span>
</div>

其实App这一层的fiber节点是没有对应的dom的,span标签应该跨过App节点,作为div标签的子元素进行渲染。因此回顾下commitWork

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  // 这里就不能直接找parent.dom了,有可能是parent.dom.dom,或者更上一层
  // const domParent = fiber.parent.dom
  
  // 应该改为如下,也就是找有dom的父节点
  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom
  
  // ...
}

完整的commitWork如下

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom
  
  if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) {
    updateDom(
      fiber.dom,
      fiber.alternate.props,
      fiber.props
    )
  } else if (fiber.effectTag === 'DELETION') {
    commitDeletion(fiber, domParent)
  }

  if (fiber.effectTag !== 'DELETION') {
    commitWork(fiber.child)
    commitWork(fiber.sibling)
  }
}  

删除节点的时候也同理

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}

8、 Hooks

自从hooks面世之后,有人欢喜有人愁。欢喜的人觉得使用hooks很简便,例如下方简单的例子

function Counter() {
  const [state, setState] = useState(1)
  return (
    <button onClick={() => setState(c => c + 1)}>
      Count: {state}
    </button>
  )
}

但具体hooks是怎么运作的?我们需要从创建函数组件的地方着手,也就是之前的updateFunctionComponent函数

// 构建中的fiber
let wipFiber = null
// 记录hook执行位置
let hookIndex = null

function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

可以看到,我们声明了两个变量wipFiberhookIndex,分别存储构建中的fiber和记录hook位置。

下面是useState的实现

function useState(inital) {
  const oldHook = 
    wipFiber.alternate && 
    wipFiber.alternate.hooks && 
    wipFiber.alternate.hooks[hookIndex]
  
  const hook = {
    state: oldHook ? oldHook.state : inital,
    queue: [],
  }

  // fiber节点上存储hook状态
  wipFiber.hooks.push(hook)

  // hook位置向前一步
  hookIndex++
  return [hook.state]
}

这里hook使用数组来存储状态,每使用一次hook,数组就前进一步。因此需要旧hook和新hook位置顺序一一对应得上,新hook才能够准确依赖旧hook的状态。因此也就很好理解了React为何需要约定使用hook的规则 https://react.docschina.org/docs/hooks-rules.html

事实上,React是使用链表来存储hook状态,这里为了方便使用了数组。

目前使用useState只返回了state,还需要补充一下setState

function useState(inital) {
  const oldHook = 
    wipFiber.alternate && 
    wipFiber.alternate.hooks && 
    wipFiber.alternate.hooks[hookIndex]
  
  const hook = {
    state: oldHook ? oldHook.state : inital,
    queue: [],
  }

  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    const isFunction = action instanceof Function
    // 更新state
    hook.state = isFunction ? action(hook.state) : action
  })

  const setState = action => {
    hook.queue.push(action)
    // 像render函数一样,触发虚拟树构建并渲染
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    nextUnitOfWork = wipRoot
    deletions = []
  }

  // fiber节点上存储hook状态
  wipFiber.hooks.push(hook)

  // hook位置向前一步
  hookIndex++
  return [hook.state, setState]
}

至此,React的mini版就完成啦!

完整代码https://github.com/Zeng-J/react-mini-learn

总结

通过这次学习,对React基本理念有了一些了解。但其实React的min版还有很多细节未完善,例如没有考虑key对比、没有考虑回收复用旧的fiber等等。不管怎样,有了基础理念的了解,后续肝源码的时候就稍微没那么吃力了。

学习文章

https://pomb.us/build-your-own-react/

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值