资讯专栏INFORMATION COLUMN

当我们谈论Virtual DOM时,我们在说什么——etch源码解读

RayKr / 2892人阅读

摘要:接下来我们深入函数,看看它干了什么。在我们写的代码里,我们会手动将元素挂载到树上。到这里,我们已经完成了元素挂载的全过程,接下来我们看一看更新的时候会发生什么。这部分应该是负责的,我们要在组件的方法中调用。

etch简介

首先我们有必要介绍一下etch。

etch是atom团队下的开源项目,是一套非常简洁然而功能十分完善的virtualDOM机制。我在偶然的情况下接触到了这个开源项目,在读README时为它简洁的设计而惊叹,而在阅读源码的过程中也为它巧妙的实现而赞叹。

个人觉得etch针对是一个非常好的学习内容,实际代码才七百来行,逻辑极度清晰,很适合作为想了解vdom的人的入门项目。
etch项目地址

源码解读

我将个人对etch源码的实践和理解写成了一个项目,地址为源码解读地址

个人建议是直接去我这个项目看,我在项目中整理的整体的流程,也对具体的代码添加的笔记,应该很好懂,不过,如果你只是想简单了解一下,那么可以继续看这篇文章。

首先我们看一下项目的文件结构

正常来说我们应该从index.js开始看,但是index.js只是负责将函数汇总了一下,所以我们从真正的开始——component-helpers文件的initialize函数开始。

这个函数负责以一个component实例为参数(具体表现形式为在一个component的constructor中调用,参数为this。
举个栗子

/** @jsx etch.dom */

const etch = require("etch")

class MyComponent {
  // Required: Define an ordinary constructor to initialize your component.
  constructor (props, children) {
    // perform custom initialization here...
    // then call `etch.initialize`:
    etch.initialize(this)
  }

  // Required: The `render` method returns a virtual DOM tree representing the
  // current state of the component. Etch will call `render` to build and update
  // the component"s associated DOM element. Babel is instructed to call the
  // `etch.dom` helper in compiled JSX expressions by the `@jsx` pragma above.
  render () {
    return 
} // Required: Update the component with new properties and children. update (props, children) { // perform custom update logic here... // then call `etch.update`, which is async and returns a promise return etch.update(this) } // Optional: Destroy the component. Async/await syntax is pretty but optional. async destroy () { // call etch.destroy to remove the element and destroy child components await etch.destroy(this) // then perform custom teardown logic here... } }

上面就是一个非常标准的etch组件,在constructor中使用etch.initialize就保证了当一个组件被实例化的时候必然会调用initialize然后完成必要的初始化)。接下来我们深入initialize函数,看看它干了什么。

function initialize(component) {
  if (typeof component.update !== "function") {
    throw new Error("Etch components must implement `update(props, children)`.")
  }

  let virtualNode = component.render()
  if (!isValidVirtualNode(virtualNode)) {
    let namePart = component.constructor && component.constructor.name ? " in " + component.constructor.name : ""
    throw new Error("invalid falsy value " + virtualNode + " returned from render()" + namePart)
  }

  applyContext(component, virtualNode)

  component.refs = {}
  component.virtualNode = virtualNode
  component.element = render(component.virtualNode, {
    refs: component.refs, listenerContext: component
  })
}

我们可以清楚的看到initialize干的非常简单——调用component实例的render函数返回jsx转成的virtualNode,然后调用render将virtualNode转化为DOM元素,最后将virtualNode和DOM元素都挂载在component上。在我们写的代码里,我们会手动将DOM元素挂载到dom树上。

接下来我们分两条线看,一条是jsx如何如何变成virtualNode。很简单,babel转码器,react就是用的这个。然而transform-react-jsx插件的默认入口是React.createElement,这里需要我们配置一下,将其改成etch.dom。(入口的意思是jsx转码后的东西应该传到哪里)。

以下是.babelrc配置文件内容
{
    "presets": ["env"],
    "plugins": [
        ["transform-react-jsx", {
          "pragma": "etch.dom" // default pragma is React.createElement
        }],"transform-object-rest-spread","transform-regenerator"
    ]
}

dom文件下的dom函数所做的就是将传入的参数进行处理,然后返回一个货真价实的virtualNode,具体实现如下

function dom (tag, props, ...children) {
  let ambiguous = []

  //这里其实就是我之前在bl写的flatternChildren,作用就是对children进行一些处理,将数组或者是字符串转化为真正的vnode
  for (let i = 0; i < children.length;) {
    const child = children[i]
    switch (typeof child) {
      case "string":
      case "number":
        children[i] = {text: child}
        i++
        break;

      case "object":
        if (Array.isArray(child)) {
          children.splice(i, 1, ...child)
        } else if (!child) {
          children.splice(i, 1)
        } else {
          if (!child.context) {
            ambiguous.push(child)
            if (child.ambiguous && child.ambiguous.length) {
              ambiguous = ambiguous.concat(child.ambiguous)
            }
          }
          i++
        }
        break;

      default:
        throw new Error(`Invalid child node: ${child}`)
    }
  }

  //对于props进行处理,props包括所有在jsx上的属性
  if (props) {
    for (const propName in props) {
      const eventName = EVENT_LISTENER_PROPS[propName]
      //处理事件挂载
      if (eventName) {
        if (!props.on) props.on = {}
        props.on[eventName] = props[propName]
      }
    }
    //处理css类挂载
    if (props.class) {
      props.className = props.class
    }
  }

  return {tag, props, children, ambiguous}
}

到此,我们应该明白了,当我们碰到一个jsx时候,我们实际收到的是一个经过dom函数处理过的virtualNode(没错,我说的就是每个component的render返回的东西,另外所谓virtualNode说到底就是一个拥有特定属性的对象)。

接下来我们看另一条线,那就是render如何将virtualNode转化为一个真正的DOM元素。

unction render (virtualNode, options) {
  let domNode
  if (virtualNode.text != null) {
    domNode = document.createTextNode(virtualNode.text)
  } else {
    const {tag, children} = virtualNode
    let {props, context} = virtualNode

    if (context) {
      options = {refs: context.refs, listenerContext: context}
    }

    if (typeof tag === "function") {
      let ref
      if (props && props.ref) {
        ref = props.ref
      }
      const component = new tag(props || {}, children)
      virtualNode.component = component
      domNode = component.element
     // console.log(domNode,"!!!",virtualNode)
      if (typeof ref === "function") {
        ref(component)
      } else if (options && options.refs && ref) {
        options.refs[ref] = component
      }
    } else if (SVG_TAGS.has(tag)) {
      domNode = document.createElementNS("http://www.w3.org/2000/svg", tag);
      if (children) addChildren(domNode, children, options)
      if (props) updateProps(domNode, null, virtualNode, options)
    } else {
      domNode = document.createElement(tag)
      if (children) addChildren(domNode, children, options)
      if (props) updateProps(domNode, null, virtualNode, options)
    }
  }
  virtualNode.domNode = domNode
  return domNode
}

其实很简单,通过对virtualNode的tag进行判断,我们可以轻易的判断virtualNode是什么类型的(比如组件,比如基本元素,比如字符元素),然后针对不同的类型进行处理(基本的好说),组件的话,要再走一遍组件的创建和挂载流程。若为基础元素,则我们可以将对应的属性放到DOM元素上,最后返回创建好的DOM元素(其实virtualNode上的所有元素基本最后都是要反映到基础DOM元素上的,可能是属性,可能是子元素)。

到这里,我们已经完成了DOM元素挂载的全过程,接下来我们看一看更新的时候会发生什么。

更新的话,我们会在自己写的update函数中调用component-helpers的update函数(后面我们叫它etch.update),而etch.update和initialize一样会以component实例作为参数,具体来说就是组件class中的this。然后在etch.update中会以异步的形式来进行更新,这样可以保证避免更新冗余,极大的提升性能

function update (component, replaceNode=true) {
  if (syncUpdatesInProgressCounter > 0) {
    updateSync(component, replaceNode)
    return Promise.resolve()
  }
  //这是一个可以完成异步的机制
  let scheduler = getScheduler()
 //通过这个判断保证了再一次DOM实质性更新完成之前不会再次触发
  if (!componentsWithPendingUpdates.has(component)) {
    componentsWithPendingUpdates.add(component)
    scheduler.updateDocument(function () {
      componentsWithPendingUpdates.delete(component)
      //而根据这个我们可以很清楚的发现真正的更新还是靠同步版update
      updateSync(component, replaceNode)
    })
  }

  return scheduler.getNextUpdatePromise()
}

。但是etch.update真正进行更新的部分却是在etch.updateSync。看函数名我们就知道这是这是一个更新的同步版。这个函数会让component实时更新,而etch.update实际上是以异步的形式调用的这个同步版。

接下来我们深入etch.updateSync来看看它到底是怎么做的。

function updateSync (component, replaceNode=true) {
  if (!isValidVirtualNode(component.virtualNode)) {
    throw new Error(`${component.constructor ? component.constructor.name + " instance" : component} is not associated with a valid virtualNode. Perhaps this component was never initialized?`)
  }

  if (component.element == null) {
    throw new Error(`${component.constructor ? component.constructor.name + " instance" : component} is not associated with a DOM element. Perhaps this component was never initialized?`)
  }

  let newVirtualNode = component.render()
  if (!isValidVirtualNode(newVirtualNode)) {
    const namePart = component.constructor && component.constructor.name ? " in " + component.constructor.name : ""
    throw new Error("invalid falsy value " + newVirtualNode + " returned from render()" + namePart)
  }

  applyContext(component, newVirtualNode)

  syncUpdatesInProgressCounter++
  let oldVirtualNode = component.virtualNode
  let oldDomNode = component.element
  let newDomNode = patch(oldVirtualNode, newVirtualNode, {
    refs: component.refs,
    listenerContext: component
  })
  component.virtualNode = newVirtualNode
  if (newDomNode !== oldDomNode && !replaceNode) {
    throw new Error("The root node type changed on update, but the update was performed with the replaceNode option set to false")
  } else {
    component.element = newDomNode
  }

  // We can safely perform additional writes after a DOM update synchronously,
  // but any reads need to be deferred until all writes are completed to avoid
  // DOM thrashing. Requested reads occur at the end of the the current frame
  // if this method was invoked via the scheduler. Otherwise, if `updateSync`
  // was invoked outside of the scheduler, the default scheduler will defer
  // reads until the next animation frame.
  if (typeof component.writeAfterUpdate === "function") {
    component.writeAfterUpdate()
  }
  if (typeof component.readAfterUpdate === "function") {
    getScheduler().readDocument(function () {
      component.readAfterUpdate()
    })
  }

  syncUpdatesInProgressCounter--
}

事实上由于scheduler的骚操作,在调用updateSync之前实质性的更新已经全部调用,然后我们要做的就是调用component.render获取新的virtualNode,然后通过patch函数根据新旧virtualNode判断哪些部分需要更新,然后对DOM进行更新,最后处理生命周期函数,完美。

那么scheduler的骚操作到底是什么呢?其实就是靠requestAnimationFrame保证所有的更新都在同一帧内解决。另外通过weakSet机制,可以保证一个组件在它完成自己的实质性更新之前绝不会再重绘(这里是说数据会更新,但不会反映到实际的DOM元素上,这就很完美的做到了避免冗余的更新)

最后我们看一看组件的卸载和销毁部分。这部分应该是destroy负责的,我们要在组件的destory方法中调用etch.destory。要说一下,etch.destory和etch.update一样是异步函数.然后我们可以根据update很轻松的猜出一定含有一个同步版的destroySync。没错,就是这样,真正的卸载是在destroySync中完成的。逻辑也很简单,组件上的destory会被调用,它的子组件上具有destory的也会被调用,这样一直递归。最后从DOM树上删除掉component对应的DOM元素。

unction destroySync (component, removeNode=true) {
  syncDestructionsInProgressCounter++
  destroyChildComponents(component.virtualNode)
  if (syncDestructionsInProgressCounter === 1 && removeNode) component.element.remove()
  syncDestructionsInProgressCounter--
}

/**
 * 若为组件直接摧毁,否则摧毁子元素中为组件的部分
 * @param {*} virtualNode 
 */
function destroyChildComponents(virtualNode) {
  if (virtualNode.component && typeof virtualNode.component.destroy === "function") {
    virtualNode.component.destroy()
  } else if (virtualNode.children) {
    virtualNode.children.forEach(destroyChildComponents)
  }
}

到这里我们就走完全部流程了。这就是一套etch virtualNode,很简单,很有趣,很巧妙。

整篇文章絮絮叨叨的,而且还是源码这种冷门的东西,估计没什么人愿意看。不过我还是想发上来,作为自己的笔记,也希望能对他人有用。这篇文章是我在segmentfault上发的第一篇技术文章,生涩的很,我会努力进步。另外,我真的建议直接去我那个项目看笔记,应该比这篇文章清晰的多。

2018.4.11于学校

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/94132.html

相关文章

  • 漫谈前端性能 突破 React 应用瓶颈

    摘要:表示调用栈在下一将要执行的任务。两方性能解药我们一般有两种方案突破上文提到的瓶颈将耗时高成本高易阻塞的长任务切片,分成子任务,并异步执行这样一来,这些子任务会在不同的周期执行,进而主线程就可以在子任务间隙当中执行更新操作。 showImg(https://segmentfault.com/img/remote/1460000016008111); 性能一直以来是前端开发中非常重要的话题...

    whlong 评论0 收藏0
  • 解读React源码(一):初探React源码

    摘要:前言的基本概念组件的构建方法以及高级用法这背后的一切如何运转深入内部的实现机制和原理初探源码代码组织结构包含一系列的工具方法插件包含一系列同构方法包含一些公用或常用方法如等包含一些测试方法等包含一些边界错误的测试用例是代码的核心部分它包含了 前言 React的基本概念,API,组件的构建方法以及高级用法,这背后的一切如何运转,深入Virtual DOM内部的实现机制和原理. 初探Rea...

    Eminjannn 评论0 收藏0
  • 解读React源码(二):Virtual DOM模型

    摘要:模型模型负责底层框架的构建工作它拥有一整套的标签并负责虚拟节点及其属性的构建更新删除等工作其实构建一套简易模型并不复杂它只需要具备一个标签所需的基本元素即可标签名属性样式子节点唯一标识中的节点称为它分为种类型其中又分为和创建元素输入输出通过 Virtual DOM模型 1.Virtual DOM模型负责Virtual DOM底层框架的构建工作,它拥有一整套的Virtual DOM标签,...

    kuangcaibao 评论0 收藏0
  • 一起理解 Virtual DOM

    摘要:具体而言,就是每次数据发生变化,就重新执行一次整体渲染。而给出了解决方案,就是。由于只关注,通过阅读两个库的源码,对于的定位有了更深一步的理解。第二个而且,技术本身不是目的,能够更好地解决问题才是王道嘛。 前言 React 好像已经火了很久很久,以致于我们对于 Virtual DOM 这个词都已经很熟悉了,网上也有非常多的介绍 React、Virtual DOM 的文章。但是直到前不久...

    Tangpj 评论0 收藏0

发表评论

0条评论

RayKr

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<