[淺析] vue3.0中的自定義渲染器

小平果118發表於2020-10-19

Vue3.0中支援 自定義渲染器 (Renderer):這個 API 可以用來自定義渲染邏輯。它可以將 Virtual DOM 渲染為 Web 平臺的真實 DOM。(在以往像weex和mpvue,需要通過fork原始碼的方式進行擴充套件)。

1.自定義渲染器的原理

渲染器是圍繞 Virtual DOM 而存在的,在 Web 平臺下它能夠把 Virtual DOM 渲染為瀏覽器中的真實 DOM 物件,通過前面幾章的講解,相信你已經能夠認識到渲染器的實現原理,為了能夠將 Virtual DOM 渲染為真實 DOM,渲染器內部需要呼叫瀏覽器提供的 DOM 程式設計介面,下面羅列了在出上一章中我們曾經使用到的那些瀏覽器為我們提供的 DOM 程式設計介面:

  • document.createElement / createElementNS:建立標籤元素。
  • document.createTextNode:建立文字元素。
  • el.nodeValue:修改文字元素的內容。
  • el.removeChild:移除 DOM 元素。
  • el.insertBefore:插入 DOM 元素。
  • el.appendChild:追加 DOM 元素。
  • el.parentNode:獲取父元素。
  • el.nextSibling:獲取下一個兄弟元素。
  • document.querySelector:掛載 Portal 型別的 VNode 時,用它查詢掛載點。

這些 DOM 程式設計介面完成了 Web 平臺(或者說瀏覽器)下對 DOM 的增加、刪除、查詢的工作,它是 Web 平臺獨有的,所以如果渲染器自身強依賴於這些方法(函式),那麼這個渲染器也只能夠執行在瀏覽器中,它不具備跨平臺的能力。換句話說,如果想要實現一個平臺無關的渲染器,那麼渲染器自身必須不能強依賴於任何一個平臺下特有的介面,而是應該提供一個抽象層,將 “DOM” 的增加、刪除、查詢等操作使用抽象介面實現,具體到某個平臺下時,由開發者決定如何使用該平臺下的介面實現這個抽象層,這就是自定義渲染器的本質。

渲染器除了負責對元素的增加、刪除、查詢之外,它還負責修改某個特定元素自身的屬性/特性,例如 Web 平臺中元素具有 idhref 等屬性/特性。在上一章中,我們使用 patchData 函式來完成元素自身屬性/特性的更新,如下程式碼用於修改一個元素的類名列表(class):

// patchData.js
case 'class':
  el.className = nextValue
  break

這段程式碼同樣也只能執行在瀏覽器中,為了渲染器能夠跨平臺,那麼修改一個元素自身的屬性/特性的工作也應該作為可自定義的一部分才行,因此,一個跨平臺的渲染器應該至少包含兩個可自定義的部分:可自定義元素的增加、刪除、查詢等操作可自定義元素自身屬性/特性的修改操作。這樣對於任何一個元素來說,它的增刪改查都已經變成了可自定義的部分,我們只需要“告知”渲染器在對元素進行增刪改查時應該做哪些具體的操作即可。

接下來我們就著手將一個普通渲染器修改為擁有自定義能力的渲染器,在之前的講解中,我們將渲染器的程式碼存放在了 render.js 檔案中,如下是整個 render.js 檔案的核心程式碼:

// 匯出渲染器
export default function render(vnode, container) { /* ... */ }

// ========== 掛載 ==========

function mount(vnode, container, isSVG, refNode) { /* ... */ }

function mountElement(vnode, container, isSVG, refNode) { /* ... */ }

function mountText(vnode, container) { /* ... */ }

function mountFragment(vnode, container, isSVG) { /* ... */ }

function mountPortal(vnode, container) { /* ... */ }

function mountComponent(vnode, container, isSVG) { /* ... */ }

function mountStatefulComponent(vnode, container, isSVG) { /* ... */ }

function mountFunctionalComponent(vnode, container, isSVG) { /* ... */ }

// ========== patch ==========

function patch(prevVNode, nextVNode, container) { /* ... */ }

function replaceVNode(prevVNode, nextVNode, container) { /* ... */ }

function patchElement(prevVNode, nextVNode, container) { /* ... */ }

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) { /* ... */ }

function patchText(prevVNode, nextVNode) { /* ... */ }

function patchFragment(prevVNode, nextVNode, container) { /* ... */ }

function patchPortal(prevVNode, nextVNode) { /* ... */ }

function patchComponent(prevVNode, nextVNode, container) { /* ... */ }

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function lis(arr) { /* ... */ }

觀察如上程式碼結構,可以發現一個渲染器由兩部分組成:mountpatch。在 mountpatch 中都會呼叫瀏覽器提供的 DOM 程式設計介面來完成真正的渲染工作。為了將瀏覽器提供的 DOM 程式設計介面與渲染器的程式碼分離,我們可以將如上程式碼封裝到一個叫做 createRenderer 的函式中,如下程式碼所示:

export default function createRenderer(options) {
  function render(vnode, container) { /* ... */ }

  // ========== 掛載 ==========

  function mount(vnode, container, isSVG, refNode) { /* ... */ }

  function mountElement(vnode, container, isSVG, refNode) { /* ... */ }

  function mountText(vnode, container) { /* ... */ }

  function mountFragment(vnode, container, isSVG) { /* ... */ }

  function mountPortal(vnode, container) { /* ... */ }

  function mountComponent(vnode, container, isSVG) { /* ... */ }

  function mountStatefulComponent(vnode, container, isSVG) { /* ... */ }

  function mountFunctionalComponent(vnode, container, isSVG) { /* ... */ }

  // ========== patch ==========

  function patch(prevVNode, nextVNode, container) { /* ... */ }

  function replaceVNode(prevVNode, nextVNode, container) { /* ... */ }

  function patchElement(prevVNode, nextVNode, container) { /* ... */ }

  function patchChildren(
    prevChildFlags,
    nextChildFlags,
    prevChildren,
    nextChildren,
    container
  ) { /* ... */ }

  function patchText(prevVNode, nextVNode) { /* ... */ }

  function patchFragment(prevVNode, nextVNode, container) { /* ... */ }

  function patchPortal(prevVNode, nextVNode) { /* ... */ }

  function patchComponent(prevVNode, nextVNode, container) { /* ... */ }

  return { render }
}

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function lis(arr) { /* ... */ }

createRenderer 函式的返回值就是之前的 render 函式,也就是說呼叫 createRenderer 函式可以建立一個渲染器。createRenderer 函式接收一個引數 options,該引數的作用是為了允許外界有能力將操作元素的具體實現以選項的方式傳遞進來。

那麼 options 引數中應該包含哪些選項呢?其實前面我們已經分析過了,只要是需要自定義的部分就應該作為選項傳遞進來,所以引數 options 中至少要包含兩部分:一部分是元素的增加、刪除、查詢;另外一部分是元素的修改,即 patchData 函式。如下程式碼所示:

const { render } = createRenderer({
  // nodeOps 是一個物件,該物件包含了所有用於操作節點的方法
  nodeOps: {
    createElement() { /* ... */ },
    createText() { /* ... */ }
    // more...
  },
  patchData
})

基於此,在 createRenderer 函式內部我們就可以通過解構的方式從 options 引數中得到具體的方法:

export default function createRenderer(options) {
  // options.nodeOps 選項中包含了本章開頭羅列的所有操作 DOM 的方法
  // options.patchData 選項就是 patchData 函式
  const {
    nodeOps: {
      createElement: platformCreateElement,
      createText: platformCreateText,
      setText: platformSetText, // 等價於 Web 平臺的 el.nodeValue
      appendChild: platformAppendChild,
      insertBefore: platformInsertBefore,
      removeChild: platformRemoveChild,
      parentNode: platformParentNode,
      nextSibling: platformNextSibling,
      querySelector: platformQuerySelector
    },
    patchData: platformPatchData
  } = options

  function render(vnode, container) { /* ... */ }

  // ========== 掛載 ==========
  // 省略...

  // ========== patch ==========
  // 省略...

  return { render }
}

如上程式碼所示,options.nodeOps 選項是一個物件,它包含了所有用於對元素進行增、刪、查的操作,options.patchData 選項是一個函式,用於處理某個特定元素上的屬性/特定,這些內容都是在建立渲染器時由外界來決定的。

接下來我們要做的就是將渲染器中原本使用了 Web 平臺進行 DOM 操作的地方修改成使用通過解構得到的函式進行替代,例如在建立 DOM 元素時,原來的實現如下:

function mountElement(vnode, container, isSVG, refNode) {
  isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG
  const el = isSVG
    ? document.createElementNS('http://www.w3.org/2000/svg', vnode.tag)
    : document.createElement(vnode.tag)
  // 省略...
}

現在我們應該使用 platformCreateElement 函式替代 document.createElement(NS)

function mountElement(vnode, container, isSVG, refNode) {
  isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG
  const el = platformCreateElement(vnode.tag, isSVG)
  // 省略...
}

類似的,其他所有涉及 DOM 操作的地方都應該使用這些通過解構得到的抽象介面替代。當這部分工作完成之後,接下來要做的就是對這些用於操作節點的抽象方法進行實現,如下程式碼所示,我們實現了 Web 平臺下建立 DOM 節點的方法:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    }
  }
})

再舉一個例子,下面這條語句是我們之前實現的渲染器中用於移除舊 children 中節點的程式碼:

container.removeChild(prevChildren.el)

現在我們將之替換為 platformRemoveChild 函式:

platformRemoveChild(container, prevVNode.el)

為了讓這段程式碼在 Web 平臺正常工作,我們需要在建立渲染器時實現 nodeOps.removeChild 函式:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    },
    removeChild(parent, child) {
      parent.removeChild(child)
    }
  }
})

也許你已經想到了,當我們實現了所有 nodeOps 下的規定的抽象介面之後,實際上就完成了一個面向 Web 平臺的渲染器,如下程式碼所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    },
    removeChild(parent, child) {
      parent.removeChild(child)
    },
    createText(text) {
      return document.createTextNode(text)
    },
    setText(node, text) {
      node.nodeValue = text
    },
    appendChild(parent, child) {
      parent.appendChild(child)
    },
    insertBefore(parent, child, ref) {
      parent.insertBefore(child, ref)
    },
    parentNode(node) {
      return node.parentNode
    },
    nextSibling(node) {
      return node.nextSibling
    },
    querySelector(selector) {
      return document.querySelector(selector)
    }
  }
})

當然了,如上程式碼所建立的渲染器只能夠完成 Web 平臺中對 DOM 的增加、刪除和查詢的功能,為了能夠修改 DOM 元素自身的屬性和特性,我們還需要在建立渲染器時將 patchData 函式作為選項傳遞過去,好在我們之前已經封裝了 patchData 函式,現在直接拿過來用即可:

import { patchData } from './patchData'
const { render } = createRenderer({
  nodeOps: {
    // 省略...
  },
  patchData
})

完整程式碼&線上體驗地址:https://codesandbox.io/s/web-render-6m73k

以上我們就完成了對渲染器的抽象,使它成為一個平臺無關的工具。並基於此實現了一個 Web 平臺的渲染器,專門用於瀏覽器環境。

2.自定義渲染器的應用

Vue3 提供了一個叫做 @vue/runtime-test 的包,其作用是方便開發者在無 DOM 環境時有能力對元件的渲染內容進行測試,這實際上就是對自定義渲染器的應用。本節我們嘗試來實現與 @vue/runtime-test 具有相同功能的渲染器。

原理其實很簡單,如下程式碼所示,這是用於 Web 平臺下建立真實 DOM 元素的程式碼:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag, isSVG) {
      return isSVG
        ? document.createElementNS('http://www.w3.org/2000/svg', tag)
        : document.createElement(tag)
    }
  }
})

其中 nodeOps.createElement 函式會返回一個真實的 DOM 物件,在其內部呼叫的是瀏覽器為我們提供的 document.createElement/NS 函式。實際上 nodeOps.createElement 函式的真正意圖是:建立一個元素,然而並沒有規定這個元素應該由誰來建立,或這個元素應該具有什麼樣的特徵,這就是自定義的核心所在。因此,我們完全使 nodeOps.createElement 函式返回一個普通物件來代指一個元素,後續的所有操作都是基於我們所規定的元素而進行,如下程式碼所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {
        type: 'ELEMENT',
        tag
      }
      return customElement
    }
  }
})

在這段程式碼中,我們自行規定了 nodeOps.createElement 函式所返回的元素的格式,即 customElement 物件,它包含兩個屬性,分別是 用來代表元素型別的 type 屬性以及用來代表元素名稱的 tag 屬性。雖然看上去很奇怪,但這確實是一個完全符合要求的實現。這麼做的結果就是:nodeOps.createElement 函式所建立的元素不來自於瀏覽器的 DOM 程式設計介面,更不來自於任何其他平臺的 API,因此,如上程式碼所建立的渲染器也將是一個平臺無關的渲染器。這就是為什麼 @vue/runtime-test 可以執行在 NodeJs 中的原因。

當然了,如上程式碼中 customElement 只有兩個屬性,實際上這並不能滿足需求,即使元素的格式由我們自行定義,但還是要有一定的限制,例如元素會有子節點,子節點也需要儲存對父節點的引用,元素自身也會有屬性/特性等等。一個最小且完整的元素定義應該包含以下屬性:

const customElement = {
  type, // 元素的型別:ELEMENT ---> 標籤元素;TEXT ---> 文字
  tag, // 當 type === 'ELEMENT' 時,tag 屬性為標籤名字
  parentNode, // 對父節點的引用
  children, // 子節點
  props,  // 當 type === 'ELEMENT' 時,props 中儲存著元素的屬性/特性
  eventListeners,  // 當 type === 'ELEMENT' 時,eventListeners 中儲存著元素的事件資訊
  text  // 當 type === 'TEXT' 時,text 儲存著文字內容
}

現在 customElement 就是一個能完全代替真實 DOM 物件的模擬實現了,我們用它修改之前的程式碼:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {
        type: 'ELEMENT',
        tag,
        parentNode: null,
        children: [],
        props: {},
        eventListeners: {},
        text: null
      }
      return customElement
    }
  }
})

如上程式碼所示,由於 nodeOps.createElement 函式用於建立元素節點,因此 type 屬性的值為 'ELEMENT';剛剛建立的元素還不能確定其父節點,因此 parentNodenull;用於儲存子節點的 children 屬性被初始化為一個陣列,props 屬性和 eventListeners 被初始化為空物件;最後的 textnull,因為它不是一個文字節點。

現在建立元素節點的功能已經實現,那麼建立文字節點呢?如下:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {/* 省略... */}
      return customElement
    },
    createText(text) {
      const customElement = {
        type: 'TEXT',
        parentNode: null,
        text: text
      }
      return customElement
    }
  }
})

文字元素的 type 型別值為 'TEXT'parentNode 同樣被初始化為 unlltext 屬性儲存著文字節點的內容。由於文字元素沒有子節點、屬性/特性、事件等資訊,因此不需要其他描述資訊。

文字節點與元素節點的建立都已經實現,接下來我們看看當元素被追加時應該如何處理,即 nodeOps.appendChild 函式的實現:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {/* 省略... */}
      return customElement
    },
    createText(text) {
      const customElement = {/* 省略... */}
      return customElement
    },
    appendChild(parent, child) {
      // 簡歷父子關係
      child.parentNode = parent
      parent.children.push(child)
    }
  }
})

如上高亮程式碼所示,追加節點時我們要做的就是建立節點間正確的父子關係,在 Web 平臺下,當我們呼叫 el.appendChild 函式時,父子關係是由瀏覽器負責建立的,但在模擬實現中,這個關係需要我們自己來維護。不過好在這很簡單,讓子元素的 parentNode 指向父元素,同時將子元素新增到父元素的 children 陣列中即可。

類似的,如下是 nodeOps.removeChild 函式的實現:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {/* 省略... */},
    createText(text) {/* 省略... */},
    appendChild(parent, child) {
      // 簡歷父子關係
      child.parentNode = parent
      parent.children.push(child)
    },
    removeChild(parent, child) {
      // 找到將要移除的元素 child 在父元素的 children 中的位置
      const i = parent.children.indexOf(child)
      if (i > -1) {
        // 如果找到了,則將其刪除
        parent.children.splice(i, 1)
      } else {
        // 沒找到,說明渲染器出了問題,例如沒有在 nodeOps.appendChild 函式中維護正確的父子關係等
        // 這時需要列印錯誤資訊,以提示開發者
        console.error('target: ', child)
        console.error('parent: ', parent)
        throw Error('target 不是 parent 的子節點')
      }
      // 清空父子鏈
      child.parentNode = null
    }
  }
})

如上高亮程式碼所示,在移除節點時,思路也很簡單,首先需要在父節點的 children 屬性中查詢即將要被移除的節點的位置索引,如果找到了,那麼就直接將其從父節點的 children 陣列中移除即可。如果沒有找到則說明渲染器出問題了,例如在你實現自定義渲染器時沒有在 nodeOps.appendChild 函式或 nodeOps.insertBefore 函式中維護正確的父子關係,這時我們需要列印錯誤資訊以提示開發者。最後不要忘記清空父子鏈。

通過如上的講解,你可能已經領會到了,我們所做的其實就是在模擬 Web 平臺在操作元素時的行為,並且這個模擬的思路也及其簡單。實際上,當我們實現了所有 nodeOps 下的抽象函式之後,那麼這個類似於 @vue/runtime-test 的自定義渲染器就基本完成了。當然,不要忘記的是我們還需要實現 patchData 函式,這可能比你想象的要簡單的多,如下高亮程式碼所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {/* 省略... */},
    createText(text) {/* 省略... */},
    appendChild(parent, child) {/* 省略... */},
    removeChild(parent, child) {/* 省略... */}
    // 其他 nodeOps 函式的實現
  },
  patchData(
    el,
    key,
    prevValue,
    nextValue
  ) {
    // 將屬性新增到元素的 props 物件下
    el.props[key] = nextValue
    // 我們將屬性名字中前兩個字元是 'o' 和 'n' 的屬性認為是事件繫結
    if (key[0] === 'o' && key[1] === 'n') {
      // 如果是事件,則將事件新增到元素的 eventListeners 物件下
      const event = key.slice(2).toLowerCase()
      ;(el.eventListeners || (el.eventListeners = {}))[event] = nextValue
    }
  }
})

在建立渲染器時我們需要實現 patchData 函式的功能,它的功能是用來更新元素自身的屬性/特性的,在之前的講解中我們實現了 Web 平臺中 patchData 函式,然而在這個模擬實現中,我們要做的事情就少了很多。只需要把元素的屬性新增到元素的 props 物件中即可,同時如果是事件的話,我們也只需要將其新增到元素的 eventListeners 物件中就可以了。

實際上,本節我們所實現的自定義渲染器,就能夠滿足我們對元件測試的需求,我們可以利用它來測試元件所渲染內容的正確性。如果你想要進一步提升該自定義渲染器的能力,例如希望該渲染器有能力在控制檯中列印出操作元素的資訊,也很簡單,我們以建立元素為例,如下程式碼所示:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {
      const customElement = {
        type: 'ELEMENT',
        tag,
        parentNode: null,
        children: [],
        props: {},
        eventListeners: {},
        text: null
      }

      console.table({
        type: 'CREATE ELEMENT',
        targetNode: customElement
      })

      return customElement
    }
  }
})

只需要在 nodeOps.createElement 函式中呼叫 console.table 進行列印你想要的資訊即可,例如我們列印了一個物件,該物件包含 type 屬性用於指示當前操作元素的型別,所以對於建立元素來說,我們為 type 屬性賦值了字串 'CREATE ELEMENT',同時將目標節點也列印了出來(即 targetNode)。類似的,追加節點可以列印如下資訊:

const { render } = createRenderer({
  nodeOps: {
    createElement(tag) {/* 省略... */},
    appendChild(parent, child) {
      // 簡歷父子關係
      child.parentNode = parent
      parent.children.push(child)

      console.table({
        type: 'APPEND',
        targetNode: child,
        parentNode: parent
      })
    }
  }
})

怎麼樣,是不是很簡單。當然了這只是自定義渲染器的應用之一,對於自定義渲染器來說,它可發揮的空間還是非常大的,舉幾個例子:

  • 渲染到 PDF,我們可以實現一個自定義渲染器如 vue-pdf-renderer,它能夠將 Vue 元件渲染為 PDF 檔案。
  • 渲染到檔案系統,我們可以實現一個 vue-file-renderer,它可以根據 VNode 的結構在本地渲染與該結構相同的檔案目錄。
  • canvas 渲染器,我們可以實現一個 vue-canvas-renderer,它可以從渲染器的層面渲染 canvas,而非元件層面。

以上僅僅是簡單的列了幾個小想法,實際上由於自定義渲染器本身就是平臺無關的,很多事情需要看特定平臺的能力,渲染器為你提供的就是在元件層面的抽象能力以及虛擬 DOM 的更新演算法,剩下的就靠社群的想象力和實現能力了。

完整程式碼&線上體驗地址:https://codesandbox.io/s/vue3-custom-render-tbv1e

3.自定義canvas渲染

這裡我們來自定義一個canvas渲染器,可以渲染常見的餅圖,點選後增加一個其他。
[淺析] vue3.0中的自定義渲染器

首先建立一個元件描述要渲染的資料,我們想要渲染一個叫做piechart的元件,我們不需要單獨宣告該元件,因為我們只是想把它攜帶的資料繪製到canvas上。建立CanvasApp.vue

<template>
 <piechart @click="handleClick" :data="state.data" :x="200" :y="200" :r="200"></piechart>
</template>
<script>
import { reactive, ref } from "vue";
export default {
 setup() {
   const state = reactive({
     data: [
      { name: "大專", count: 200, color: "brown" },
      { name: "本科", count: 300, color: "yellow" },
      { name: "碩士", count: 100, color: "pink" },
      { name: "博士", count: 50, color: "skyblue" }
    ]
  });
   function handleClick() {
     state.data.push({ name: "其他", count: 30, color: "orange" });
  }
   return {
     state,
     handleClick
  };
}
};
</script>

下面我們建立自定義渲染器,main.js.藉助Vue響應式的特性,實現圖形渲染。

import { createRenderer } from '@vue/runtime-dom';
let renderer = createRenderer(nodeOps);
let ctx;
let canvas;
function createApp(App) {
    const app = renderer.createApp(App);
    return {
        mount(selector) {
            canvas = document.createElement('canvas');
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            document.querySelector(selector).appendChild(canvas);
            ctx = canvas.getContext('2d');
            app.mount(canvas);
        }
    }
}
createApp(App).mount('#app')

重寫mount方法,生成cavans並進行掛載操作,這裡的nodeOps是需要提供的api ,Vue在渲染時會呼叫使用者提供的方法,從而達到自定義渲染器的目的!

3.1 自定義渲染邏輯

const nodeOps = {
    insert: (child, parent, anchor) => {
        child.parent = parent;
        if (!parent.childs) { // 格式化父子關係
            parent.childs = [child]
        } else {
            parent.childs.push(child);
        }
        if (parent.nodeType == 1) {
            draw(child); // 開始繪圖
            if (child.onClick) {
                ctx.canvas.addEventListener('click', () => {
                    child.onClick();
                    setTimeout(() => {
                        draw(child)
                    }, 0);
                }, false)
            }
        }
    },
    remove: child => {},
    createElement: (tag, isSVG, is) => {
        return {tag}
    },
    createText: text => {},
    createComment: text => {},
    setText: (node, text) => {},
    setElementText: (el, text) => {},
    parentNode: node => {},
    nextSibling: node => {},
    querySelector: selector => {},
    setScopeId(el, id) {},
    cloneNode(el) {},
    insertStaticContent(content, parent, anchor, isSVG) {},
    patchProp(el, key, prevValue, nextValue) {
        el[key] = nextValue;
    },
};

這裡我們改寫 patchProinsertcreateElement方法。

  • patchProp 每次更新屬性會呼叫此方法
  • createElement 建立元素會呼叫此方法
  • insert 元素插入到頁面中會呼叫此方法

3.2 提供繪製邏輯

這裡就是普通的canvas操作~

const draw = (el,noClear) => {
 if (!noClear) {
   ctx.clearRect(0, 0, canvas.width, canvas.height)
}
 if (el.tag == 'piechart') {
   let { data, r, x, y } = el;
   let total = data.reduce((memo, current) => memo + current.count, 0);
   let start = 0,
       end = 0;
   data.forEach(item => {
     end += item.count / total * 360;
     drawPieChart(start, end, item.color, x, y, r);
     drawPieChartText(item.name, (start + end) / 2, x, y, r);
     start = end;
  });
}
 el.childs && el.childs.forEach(child => draw(child,true));
}

const d2a = (n) => {
 return n * Math.PI / 180;
}
const drawPieChart = (start, end, color, cx, cy, r) => {
 let x = cx + Math.cos(d2a(start)) * r;
 let y = cy + Math.sin(d2a(start)) * r;
 ctx.beginPath();
 ctx.moveTo(cx, cy);
 ctx.lineTo(x, y);
 ctx.arc(cx, cy, r, d2a(start), d2a(end), false);
 ctx.fillStyle = color;
 ctx.fill();
 ctx.stroke();
 ctx.closePath();
}
const drawPieChartText = (val, position, cx, cy, r) => {
 ctx.beginPath();
 let x = cx + Math.cos(d2a(position)) * r/1.25 - 20;
 let y = cy + Math.sin(d2a(position)) * r/1.25;
 ctx.fillStyle = '#000';
 ctx.font = '20px 微軟雅黑';
 ctx.fillText(val,x,y);
 ctx.closePath();
}

相關文章