Skip to content

marklin2012/yolkjs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

yolkjs

一个学习 React 源码的例子

学习目标

  1. JSX
  2. createElement
  3. Render
  4. fiber
  5. functional component
  6. hooks

初始环境

通过 Create React App 创建初始的 React 项目

然后在浏览器打开 http://localhost:3000 查看

JSX

JSX 写起来像 html, 其实是 babel 转义成 React.createElement 来执行的,用来构建虚拟 dom,

<h1 title="app">Yolk test</h1>

解析成:

React.createElement(
  "h1",
  { title: "app"},
  "Yolk test"
)

这也是为什么用了 JSX 的文件,必须要 import React from 'react' 的原因所在

我们知道通过 createElement 构建虚拟 dom 这种形式是组件化的最佳实践,那为什么要用 JSX 呢?这就需要从虚拟 dom 的概念说起,简单来说,虚拟 dom 就是用 JS 对象来描述真实的 dom。

const element = {
  type: "h1",
  props: {
    title: "app",
    children: "Yolk, test",
  }
}

我们用 JS 对象,来完整地描述 dom, 后续有任何的修改需求,只需要频繁的操作这个 dom,尽可能少的操作真实 dom, 这也就是为什么虚拟 dom 性能良好的原因所在: 在两次操作之间进行 diff,只做最少的修改次数。

createElement

我们通过改写 index.js 文件,实现一个原始的 JSX 项目:

// index.js
import React from 'react'
import ReactDOM from 'react-dom'

const App = (
  <div>
    <h1>Yolkjs</h1>
    <p>react dom</p>
    <a href="https://jd.com">shop</a>
  </div>
)

ReactDOM.render(App, document.getElementById('root'))

由于可以预知我们需要要构建虚拟 dom,上述 JSX 应该会解析成这样:

React.createElement(
  "div",
  {id: "app"},
  React.createElement(
    "h1",
    null,
    "Yolkjs"
  ),
  React.createElement(
    "p",
    null,
    "react dom"
  ),
  React.createElement(
    "a",
    { href: "https://jd.com"},
    "shop"
  )
)

期待返回的对象如下:

const element = {
  type: "div",
  props: {
    id: "app",
    children: [
      { type: "h1", props: { value: "Yolkjs"}},
      { type: "p", props: { value: "react dom"}},
      { type: "a", props: { href: "https://jd.com", value: "shop"}},
    ]
  }
}

所以我们 yolkjs 的源码中的 createElement() 方法也可以想到该如何实现了, 其实就是在 createElement 的时候创建一个合适的 JSON 结构数据。让我们先在 src 目录下创建一个 yolkjs 文件夹,然后再创建一个 index.js 文件,实现我们的源码

// /src/yolkjs/index.js
function createElement(type, props, ...children) {
  // 删除掉开发调试的 source,避免干扰
  delete props.__source
  return {
    type,
    props: {
      ...props,
      children,
    }
  }
}

function render(vdom, container) {
  // 我们先在页面上渲染处 dom 的 json 结构
  container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
}

export default {
  createElement,
  render,
}

最后我们再改造下 /src/index.js 的代码:

// /src/index.js
// import React from 'react'
// import ReactDOM from 'react-dom'

import React from './yolkjs'
const ReactDOM = React

// ...

运行 yarn start,然后访问 http://localhost:3000 应该可以得到如下界面:

初始版 yolkjs json

这个 json 基本符合我们一开始的预期效果,不过为了方便后面操作 dom 我们需要简单改造一下,把 children 中的文本也改造成一个节点,所以我们需要实现一个 createTextElement() 的方法,并且遍历 children 的属性根据类型来判断是否返回文本节点:

// /src/yolkjs/index.js

function createElement(type, props, ...children) {
  delete props.__source
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === 'object' ? child : createTextElement(child)
      )
    }
  }
}

function createTextElement(text) {
  return {
    type: 'TEXT',
    props: {
      nodeValue: text,
      children: [],
    }
  }
}

// ...

再访问下 http://localhost:3000 应该可以得到结果:

初始版 yolkjs 修正文本节点

render

现在的 render() 只是简单地渲染一个虚拟对象结构,我们需要转换成真实的 dom 渲染,这一步其实没啥特别的,就是挨个遍历,创建dom,然后添加到父视图中。

// /src/yolkjs/index.js
// ...
function render(vdom, container) {
  // container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
  const dom = vdom.type === "TEXT" ? document.createTextNode("") : document.createElement(vdom.type)

  // 设置属性
  Object.keys(vdom.props).forEach(name => {
    if (name !== 'children') {
      // @todo: 属性判断,事件处理
      dom[name] = vdom.props[name]
    }
  })

  // 递归渲染的子元素
  vdom.props.children.forEach(child => render(child, dom))

  container.appendChild(dom)
}
// ...

concurrent

细心的同学已经发现,上面的 render() 函数,一旦开始就会不断地递归,无法停止,本身在这里是没啥问题,但是如果应用变大以后就会有卡顿。后面状态修改后的 diff 过程也是一样的道理,整个 vdom 变大以后,diff 的过程也会递归过多而导致卡顿。

那如何解决这个问题呢?

浏览器提供了一个 api reauestIdleCallback 可以利用浏览器的业余时间,我们可以把任务拆解成一个一个的小任务,然后利用浏览器空闲的时间来做 diff, 如果当前任务来了,比如用户的点击或者动画,就会先执行,等待空闲后再回去把 requestIdleCallback 没完成的任务完成

// /src/yolkjs/index.js
// ...
function render(vdom, container) {
  //... 
}

// 下一个单元任务
// render 函数会初始化第一个任务
let nextUnitOfWork = null

//  调度我们的 diff 或者渲染任务
function workLoop(deadline) {
  // 有下一个任务,且当前帧还没有结束
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }
  requestIdleCallback(workLoop)
}

function performUnitOfWork(fiber) {
  // 获取下一个任务
  // 根据当前任务获取下一个任务
}

//  启动空闲时间渲染
requestIdleCallback(workLoop)
// ...

PS: react 已经重写了 requestIdleCallback, 不用系统的,但是整个过程是一致的。

fibers

我们有了调度逻辑,之前的 vdom 结构是一个树形结构,diff 过程是没法中断的,为了我们 vdom 树之间的关系,我们需要把树形结构的内部关系改造成链表(方便中止),之前 children 作为一个数组递归遍历,现在 父 => 子, 子 => 父, 子 => 兄弟, 都有关系。

fiber tree

整个任务从 render 开始, 每次只遍历一个小单元,一旦被打断,就去执行优先级高的任务(用户交互,动画),回来后,由于回来的元素知道父,子,兄弟元素,很容易恢复遍历的状态。

首先我们需要把创建 dom 的函数提取出来,新建一个 createDom() 方法

// /src/yolkjs/index.js
/**
 *  通过虚拟 dom 新建 dom
 * @param {*} vdom 
 */

// ...
function createDom(vdom) {
  // 创建 dom
  const dom = vdom.type === "TEXT"
    ? document.createTextNode("")
    : document.createElement(vdom.type)
  // 设置属性
  Object.keys(vdom.props).forEach(name => {
    if (name !== 'children') {
      // @todo: 属性判断,事件处理
      dom[name] = vdom.props[name]
    }
  })
  return dom
}

function render(vdom, container) {

  nextUnitOfWork = {
    dom: container,
    props: {
      children: [vdom]
    }
  }
  // container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
  // 递归渲染的子元素
  // vdom.props.children.forEach(child => render(child, dom))

  // container.appendChild(dom)
}

// 下一个单元任务
// render 函数会初始化第一个任务
let nextUnitOfWork = null

//  调度我们的 diff 或者渲染任务
function workLoop(deadline) {
  // 有下一个任务,且当前帧还没有结束
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }
  requestIdleCallback(workLoop)
}

function performUnitOfWork(fiber) {
  // 获取下一个任务
  // 根据当前任务获取下一个任务

  if (!fiber.dom) {
    // 不是入口
    fiber.dom = createDom(fiber)
  }

  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
  const elements = fiber.props.children
  // 构建成 fiber
  let index = 0
  let preSibling = null
  while (index < elements.length) {
    let element = elements[index]
    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }
    if (index === 0) {
      // 第一个元素,是父 fiber 的 child 属性
      fiber.child = newFiber
    } else {
      // 其他元素是兄弟元素
      preSibling.sibling = newFiber
    }
    preSibling = fiber
    index++
    // fiber 基本结构构建完毕
  }

  // 找下一个任务
  // 先找子元素
  if (fiber.child) {
    return fiber.child
  }

  let nextFiber = fiber
  // 没有子元素,就找兄弟元素
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    // 没有兄弟元素,找父元素
    nextFiber = nextFiber.parent
  }
}

// ...

commit

给 dom 添加节点的时候,如果渲染过程中被打断,ui 渲染会变得很奇怪,所以我们需要把 dom 操作独立出来。用一个全局变量来存储正在工作的根节点。

// /src/yolkjs/index.js
// ...
function render(vdom, container) {

  wipRoot = {
    dom: container,
    props: {
      children: [vdom],
    }
  }

  nextUnitOfWork = wipRoot
  // container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
  // 递归渲染的子元素
  // vdom.props.children.forEach(child => render(child, dom))

  // container.appendChild(dom)
}

function commitRoot() {
  commitWorker(wipRoot.child)
  wipRoot = null
}

function commitWorker(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWorker(fiber.child)
  commitWorker(fiber.sibling)
}

// 下一个单元任务
// render 函数会初始化第一个任务
let nextUnitOfWork = null
let wipRoot = null

//  调度我们的 diff 或者渲染任务
function workLoop(deadline) {
  // 有下一个任务,且当前帧还没有结束
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    // 
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }
  if (!nextUnitOfWork && wipRoot) {
    // 没有任务了,并且根节点还在
    commitRoot()
  }
  requestIdleCallback(workLoop)
}

function performUnitOfWork(fiber) {
  // 获取下一个任务
  // 根据当前任务获取下一个任务

  if (!fiber.dom) {
    // 不是入口
    fiber.dom = createDom(fiber)
  }

  // // 真实的 dom 操作
  // if (fiber.parent) {
  //   fiber.parent.dom.appendChild(fiber.dom)
  // }
  // ...
}
// ...

Recon

现在已经能够渲染了,但是如何更新和删除节点呢?这里我们需要保存一个被中断前工作的 fiber 节点 currentRoot

// /src/yolkjs/index.js
let currentRoot = null
let deletions = null

每个 fiber 都有一个字段,存储这上一个状态的 fiber 并且针对子元素,设计一个 reconcileChildren 函数,并且我们需要对 fiber 增加一个 base 字段进行对比。同时还要根据节点类型进行比较,如果类型相同,dom 可以复用,更新节点即可,如果类型不同,就直接替换,否则就直接删除。

function reconcileChildren(wipFiber, elements) {
  // 构建成 fiber
  let index = 0
  let oldFiber = wipFiber.base && wipFiber.base.child
  let preSibling = null
  // console.log('oldFiber:', oldFiber)
  while (index < elements.length) {
    // while (index < elements.length) {
    let element = elements[index]
    let newFiber = null
    // 对比 oldfiber 的状态和当前 element
    // 先比较类型,
    const sameType = oldFiber && element && oldFiber.type === element.type
    if (sameType) {
      // 复用节点,更新
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        base: oldFiber,
        effectTag: "UPDATE",
      }
    }

    if (!sameType && element) {
      // 新增节点
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        base: null,
        effectTag: "PLACEMENT",
      }
    }
    if (!sameType && oldFiber) {
      // 删除
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }
    if (index === 0) {
      // 第一个元素,是父 fiber 的 child 属性
      wipFiber.child = newFiber
    } else {
      // 其他元素是兄弟元素
      preSibling.sibling = newFiber
    }
    preSibling = newFiber
    index++
    // fiber 基本结构构建完毕
  }
}

我们还要新增一个方法 updateDom() ,来完成事件的监听:

// /src/yolkjs/index.js
// ...
/**
 * 通过虚拟 dom 新建 dom 元素
 * @param {*} vdom 虚拟 dom
 */
function createDom(vdom) {
  const dom = vdom.type === "TEXT"
    ? document.createTextNode("")
    : document.createElement(vdom.type)
  updateDom(dom, {}, vdom.props)
  // Object.keys(vdom.props).forEach(name => {
  //   if (name !== "children") {
  //     // @todo: 属性判断,事件处理
  //     dom[name] = vdom.props[name]
  //   }
  // })
  return dom
}

function updateDom(dom, preProps, nextProps) {
  /**
   * 1. 规避 children 属性
   * 2. 老得存在,取消
   * 3. 新的存在,新增, 
   */

  //  @todo 兼容性问题  

  Object.keys(preProps)
    .filter(name => name !== 'children')
    .filter(name => !(name in nextProps))
    .forEach(name => {
      if (name.slice(0, 2) === 'on') {
        // onclick => chick
        dom.removeEventListener(name.slice(2).toLowerCase(), preProps[name], false)
      } else {
        dom[name] = ''
      }
    })

  Object.keys(nextProps)
    .filter(name => name !== 'children')
    .forEach(name => {
      if (name.slice(0, 2) === 'on') {
        // onclick => chick
        dom.addEventListener(name.slice(2).toLowerCase(), preProps[name], false)
      } else {
        dom[name] = nextProps[name]
      }
    })
}

最后修改下 render 和 commit 相关的一些代码,我们即可实现 fiber 的架构

// /src/yolkjs/index.js
// ...
function render(vdom, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [vdom],
    },
    base: currentRoot, // 分身
  }
  deletions = []
  nextUnitOfWork = wipRoot
  // container.innerHTML = `<pre>${JSON.stringify(vdom, null, 2)}</pre>`
  // 递归渲染的子元素
  // vdom.props.children.forEach(child => render(child, dom))

  // container.appendChild(dom)
}

function commitRoot() {
  deletions.forEach(commitWorker)
  commitWorker(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

function commitWorker(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  // domParent.appendChild(fiber.dom)
  if (fiber.effectTag === 'PLACEMENT' && fiber.dom !== null) {
    domParent.appendChild(fiber.dom)
  } else if (fiber.effectTag === 'DELETION') {
    domParent.removeChild(fiber.dom)
  } else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
    // 更新dom
    updateDom(fiber.dom, fiber.base.props, fiber.props)
  }
  commitWorker(fiber.child)
  commitWorker(fiber.sibling)
}
// ...

函数式组件

为了让我们方便测试函数式组件的实现,我们先把页面代码改造成函数式组件的写法:

// /src/index.js
// ...
function App(props) {
  return (
    <div id="app">
      <h1>hello, {props.title}</h1>
      <p>react dom</p>
      <a href="https://jd.com">shop</a>
    </div>
  )
}

let element = <App title="yolkjs" />

ReactDOM.render(element, document.getElementById('root'))

其实函数也是一样的渲染,只不过 type 是函数,而不是字符串,我们需要在处理 vdom 的时候识别其和普通 dom 的区别

  1. 根据type 执行不同的函数来初始化 fiber,
// /src/yolkjs/index.js
// ...
function performUnitOfWork(fiber) {
  const isFunctionComponent = fiber.type instanceof Function
  console.log('iisFunctionComponents', isFunctionComponent, fiber.type, fiber)
  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    // dom
    updateHostComponent(fiber)
  }
  // 找下一个任务
  // 先找子元素
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  // 没有子元素,就找兄弟元素
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    // 没有兄弟元素,找父元素
    nextFiber = nextFiber.parent
  }
}

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

function updateHostComponent(fiber) {
  // 获取下一个任务
  // 根据当前任务获取下一个任务
  if (!fiber.dom) {
    // 不是入口
    fiber.dom = createDom(fiber)
  }

  // // 真实的 dom 操作
  // if (fiber.parent) {
  //   fiber.parent.dom.appendChild(fiber.dom)
  // }

  const elements = fiber.props.children

  reconcileChildren(fiber, elements)

}
// ...
  1. 函数组件没有 dom 属性(没有 dom 属性,查找 dom 需要向上循环查找)
// /src/yolkjs/index.js
// ...
function commitWorker(fiber) {
  if (!fiber) {
    return
  }
  // const domParent = fiber.parent.dom
  // domParent.appendChild(fiber.dom)
  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 === 'DELETION') {
    commitDeletion(fiber, domParent)
    // domParent.removeChild(fiber.dom)
  } else if (fiber.effectTag === 'UPDATE' && fiber.dom !== null) {
    // 更新dom
    updateDom(fiber.dom, fiber.base.props, fiber.props)
  }
  commitWorker(fiber.child)
  commitWorker(fiber.sibling)
}

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

Hooks

实际上 hooks 是通过链表来查找具体的 state, 这里我们通过数组来简单模拟一下,把 userState 存储的 hooks, 存储在 fiber 中

About

a demo for learning react

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published