當我們談論Virtual DOM時,我們在說什麼——etch原始碼解讀

滄溟發表於2019-02-21

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

  // 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,很簡單,很有趣,很巧妙。


寫在最後

整篇文章絮絮叨叨的,而且還是原始碼這種冷門的東西,估計沒什麼人願意看。不過我還是想發上來,作為自己的筆記,也希望能對他人有用。這篇文章是我在掘金上發的第一篇技術文章,生澀的很,我會努力進步。另外,我真的建議直接去我那個專案看筆記,應該比這篇文章清晰的多。
2018.4.11於學校

相關文章