手寫 Vue2 系列 之 編譯器

李永寧發表於2022-03-16

前言

接下來就要正式進入手寫 Vue2 系列了。這裡不會從零開始,會基於 lyn-vue 直接進行升級,所以如果你沒有閱讀過 手寫 Vue 系列 之 Vue1.x,請先從這篇文章開始,按照順序進行學習。

都知道,Vue1 存在的問題就是在大型應用中 Watcher 太多,如果不清楚其原理請檢視 手寫 Vue 系列 之 Vue1.x

所以在 Vue2 中通過引入了 VNode 和 diff 演算法來解決該問題。通過降低 Watcher 的粒度,一個元件對應一個 Watcher(渲染 Watcher),這樣就不會出現大型頁面 Watcher 太多導致效能下降的問題。

在 Vue1 中,Watcher 和 頁面中的響應式資料一一對應,當響應式資料發生改變,Dep 通知 Watcher 完成對應的 DOM 更新。但是在 Vue2 中一個元件對應一個 Watcher,當響應式資料發生改變時,Watcher 並不知道這個響應式資料在元件中的什麼位置,那又該如何完成更新呢?

閱讀過之前的 原始碼系列,大家肯定都知道,Vue2 引入了 VNode 和 diff 演算法,將元件 編譯 成 VNode,每次響應式資料發生變化時,會生成新的 VNode,通過 diff 演算法對比新舊 VNode,找出其中發生改變的地方,然後執行對應的 DOM 操作完成更新。

所以,到這裡大家也能明白,Vue1 和 Vue2 在核心的資料響應式部分其實沒什麼變化,主要的變動在編譯器部分。

目標

完成 Vue2 編譯器的一個簡版實現,從字串模版解析開始,到最終得到 render 函式。

編譯器

在手寫 Vue1 時,編譯器時通過 DOM API 來遍歷模版的 DOM 結構來完成的,在 Vue2 中不再使用這種方式,而是和官方一樣,直接編譯元件的模版字串,生成 AST,然後從 AST 生成渲染函式。

首先將 Vue1 的 compiler 目錄備份,然後新建一個 compiler 目錄,作為 Vue2 的編譯器目錄

mv compiler compiler-vue1 && mkdir compiler

mount

/src/compiler/index.js

/**
 * 編譯器
 */
export default function mount(vm) {
  if (!vm.$options.render) { // 沒有提供 render 選項,則編譯生成 render 函式
    // 獲取模版
    let template = ''

    if (vm.$options.template) {
      // 模版存在
      template = vm.$options.template
    } else if (vm.$options.el) {
      // 存在掛載點
      template = document.querySelector(vm.$options.el).outerHTML
      // 在例項上記錄掛載點,this._update 中會用到
      vm.$el = document.querySelector(vm.$options.el)
    }

    // 生成渲染函式
    const render = compileToFunction(template)
    // 將渲染函式掛載到 $options 上
    vm.$options.render = render
  }
}

compileToFunction

/src/compiler/compileToFunction.js

/**
 * 解析模版字串,得到 AST 語法樹
 * 將 AST 語法樹生成渲染函式
 * @param { String } template 模版字串
 * @returns 渲染函式
 */
export default function compileToFunction(template) {
  // 解析模版,生成 ast
  const ast = parse(template)
  // 將 ast 生成渲染函式
  const render = generate(ast)
  return render
}

parse

/src/compiler/parse.js

/**
 * 解析模版字串,生成 AST 語法樹
 * @param {*} template 模版字串
 * @returns {AST} root ast 語法樹
 */
export default function parse(template) {
  // 存放所有的未配對的開始標籤的 AST 物件
  const stack = []
  // 最終的 AST 語法樹
  let root = null

  let html = template
  while (html.trim()) {
    // 過濾註釋標籤
    if (html.indexOf('<!--') === 0) {
      // 說明開始位置是一個註釋標籤,忽略掉
      html = html.slice(html.indexOf('-->') + 3)
      continue
    }
    // 匹配開始標籤
    const startIdx = html.indexOf('<')
    if (startIdx === 0) {
      if (html.indexOf('</') === 0) {
        // 說明是閉合標籤
        parseEnd()
      } else {
        // 處理開始標籤
        parseStartTag()
      }
    } else if (startIdx > 0) {
      // 說明在開始標籤之間有一段文字內容,在 html 中找到下一個標籤的開始位置
      const nextStartIdx = html.indexOf('<')
      // 如果棧為空,則說明這段文字不屬於任何一個元素,直接丟掉,不做處理
      if (stack.length) {
        // 走到這裡說說明棧不為空,則處理這段文字,並將其放到棧頂元素的肚子裡
        processChars(html.slice(0, nextStartIdx))
      }
      html = html.slice(nextStartIdx)
    } else {
      // 說明沒有匹配到開始標籤,整個 html 就是一段文字
    }
  }
  return root
  
  // parseStartTag 函式的宣告
  // ...
  // processElement 函式的宣告
}

// processVModel 函式的宣告
// ...
// processVOn 函式的宣告

parseStartTag

/src/compiler/parse.js

/**
 * 解析開始標籤
 * 比如: <div id="app">...</div>
 */
function parseStartTag() {
  // 先找到開始標籤的結束位置 >
  const end = html.indexOf('>')
  // 解析開始標籤裡的內容 <內容>,標籤名 + 屬性,比如: div id="app"
  const content = html.slice(1, end)
  // 截斷 html,將上面解析的內容從 html 字串中刪除
  html = html.slice(end + 1)
  // 找到 第一個空格位置
  const firstSpaceIdx = content.indexOf(' ')
  // 標籤名和屬性字串
  let tagName = '', attrsStr = ''
  if (firstSpaceIdx === -1) {
    // 沒有空格,則認為 content 就是標籤名,比如 <h3></h3> 這種情況,content = h3
    tagName = content
    // 沒有屬性
    attrsStr = ''
  } else {
    tagName = content.slice(0, firstSpaceIdx)
    // content 的剩下的內容就都是屬性了,比如 id="app" xx=xx
    attrsStr = content.slice(firstSpaceIdx + 1)
  }
  // 得到屬性陣列,[id="app", xx=xx]
  const attrs = attrsStr ? attrsStr.split(' ') : []
  // 進一步解析屬性陣列,得到一個 Map 物件
  const attrMap = parseAttrs(attrs)
  // 生成 AST 物件
  const elementAst = generateAST(tagName, attrMap)
  // 如果根節點不存在,說明當前節點為整個模版的第一個節點
  if (!root) {
    root = elementAst
  }
  // 將 ast 物件 push 到棧中,當遇到結束標籤的時候就將棧頂的 ast 物件 pop 出來,它兩就是一對兒
  stack.push(elementAst)

  // 自閉合標籤,則直接呼叫 end 方法,進入閉合標籤的處理截斷,就不入棧了
  if (isUnaryTag(tagName)) {
    processElement()
  }
}

parseEnd

/src/compiler/parse.js

/**
 * 處理結束標籤,比如: <div id="app">...</div>
 */
function parseEnd() {
  // 將結束標籤從 html 字串中截掉
  html = html.slice(html.indexOf('>') + 1)
  // 處理棧頂元素
  processElement()
}

parseAttrs

/src/compiler/parse.js

/**
 * 解析屬性陣列,得到一個屬性 和 值組成的 Map 物件
 * @param {*} attrs 屬性陣列,[id="app", xx="xx"]
 */
function parseAttrs(attrs) {
  const attrMap = {}
  for (let i = 0, len = attrs.length; i < len; i++) {
    const attr = attrs[i]
    const [attrName, attrValue] = attr.split('=')
    attrMap[attrName] = attrValue.replace(/"/g, '')
  }
  return attrMap
}

generateAST

/src/compiler/parse.js

/**
 * 生成 AST 物件
 * @param {*} tagName 標籤名
 * @param {*} attrMap 標籤組成的屬性 map 物件
 */
function generateAST(tagName, attrMap) {
  return {
    // 元素節點
    type: 1,
    // 標籤
    tag: tagName,
    // 原始屬性 map 物件,後續還需要進一步處理
    rawAttr: attrMap,
    // 子節點
    children: [],
  }
}

processChars

/src/compiler/parse.js

/**
 * 處理文字
 * @param {string} text 
 */
function processChars(text) {
  // 去除空字元或者換行符的情況
  if (!text.trim()) return

  // 構造文字節點的 AST 物件
  const textAst = {
    type: 3,
    text,
  }
  if (text.match(/{{(.*)}}/)) {
    // 說明是表示式
    textAst.expression = RegExp.$1.trim()
  }
  // 將 ast 放到棧頂元素的肚子裡
  stack[stack.length - 1].children.push(textAst)
}

processElement

/src/compiler/parse.js

/**
 * 處理元素的閉合標籤時會呼叫該方法
 * 進一步處理元素上的各個屬性,將處理結果放到 attr 屬性上
 */
function processElement() {
  // 彈出棧頂元素,進一步處理該元素
  const curEle = stack.pop()
  const stackLen = stack.length
  // 進一步處理 AST 物件中的 rawAttr 物件 { attrName: attrValue, ... }
  const { tag, rawAttr } = curEle
  // 處理結果都放到 attr 物件上,並刪掉 rawAttr 物件中相應的屬性
  curEle.attr = {}
  // 屬性物件的 key 組成的陣列
  const propertyArr = Object.keys(rawAttr)

  if (propertyArr.includes('v-model')) {
    // 處理 v-model 指令
    processVModel(curEle)
  } else if (propertyArr.find(item => item.match(/^v-bind:(.*)/))) {
    // 處理 v-bind 指令,比如 <span v-bind:test="xx" />
    processVBind(curEle, RegExp.$1, rawAttr[`v-bind:${RegExp.$1}`])
  } else if (propertyArr.find(item => item.match(/^v-on:(.*)/))) {
    // 處理 v-on 指令,比如 <button v-on:click="add"> add </button>
    processVOn(curEle, RegExp.$1, rawAttr[`v-on:${RegExp.$1}`])
  }

  // 節點處理完以後讓其和父節點產生關係
  if (stackLen) {
    stack[stackLen - 1].children.push(curEle)
    curEle.parent = stack[stackLen - 1]
  }
}

processVModel

/src/compiler/parse.js

/**
 * 處理 v-model 指令,將處理結果直接放到 curEle 物件身上
 * @param {*} curEle 
 */
function processVModel(curEle) {
  const { tag, rawAttr, attr } = curEle
  const { type, 'v-model': vModelVal } = rawAttr

  if (tag === 'input') {
    if (/text/.test(type)) {
      // <input type="text" v-model="inputVal" />
      attr.vModel = { tag, type: 'text', value: vModelVal }
    } else if (/checkbox/.test(type)) {
      // <input type="checkbox" v-model="isChecked" />
      attr.vModel = { tag, type: 'checkbox', value: vModelVal }
    }
  } else if (tag === 'textarea') {
    // <textarea v-model="test" />
    attr.vModel = { tag, value: vModelVal }
  } else if (tag === 'select') {
    // <select v-model="selectedValue">...</select>
    attr.vModel = { tag, value: vModelVal }
  }
}

processVBind

/src/compiler/parse.js

/**
 * 處理 v-bind 指令
 * @param {*} curEle 當前正在處理的 AST 物件
 * @param {*} bindKey v-bind:key 中的 key
 * @param {*} bindVal v-bind:key = val 中的 val
 */
function processVBind(curEle, bindKey, bindVal) {
  curEle.attr.vBind = { [bindKey]: bindVal }
}

processVOn

/src/compiler/parse.js

/**
 * 處理 v-on 指令
 * @param {*} curEle 當前被處理的 AST 物件
 * @param {*} vOnKey v-on:key 中的 key
 * @param {*} vOnVal v-on:key="val" 中的 val
 */
function processVOn(curEle, vOnKey, vOnVal) {
  curEle.attr.vOn = { [vOnKey]: vOnVal }
}

isUnaryTag

/src/utils.js

/**
 * 是否為自閉合標籤,內建一些自閉合標籤,為了處理簡單
 */
export function isUnaryTag(tagName) {
  const unaryTag = ['input']
  return unaryTag.includes(tagName)
}

generate

/src/compiler/generate.js

/**
 * 從 ast 生成渲染函式
 * @param {*} ast ast 語法樹
 * @returns 渲染函式
 */
export default function generate(ast) {
  // 渲染函式字串形式
  const renderStr = genElement(ast)
  // 通過 new Function 將字串形式的函式變成可執行函式,並用 with 為渲染函式擴充套件作用域鏈
  return new Function(`with(this) { return ${renderStr} }`)
}

genElement

/src/compiler/generate.js

/**
 * 解析 ast 生成 渲染函式
 * @param {*} ast 語法樹 
 * @returns {string} 渲染函式的字串形式
 */
function genElement(ast) {
  const { tag, rawAttr, attr } = ast

  // 生成屬性 Map 物件,靜態屬性 + 動態屬性
  const attrs = { ...rawAttr, ...attr }

  // 處理子節點,得到一個所有子節點渲染函式組成的陣列
  const children = genChildren(ast)

  // 生成 VNode 的可執行方法
  return `_c('${tag}', ${JSON.stringify(attrs)}, [${children}])`
}

genChildren

/src/compiler/generate.js

/**
 * 處理 ast 節點的子節點,將子節點變成渲染函式
 * @param {*} ast 節點的 ast 物件 
 * @returns [childNodeRender1, ....]
 */
function genChildren(ast) {
  const ret = [], { children } = ast
  // 遍歷所有的子節點
  for (let i = 0, len = children.length; i < len; i++) {
    const child = children[i]
    if (child.type === 3) {
      // 文字節點
      ret.push(`_v(${JSON.stringify(child)})`)
    } else if (child.type === 1) {
      // 元素節點
      ret.push(genElement(child))
    }
  }
  return ret
}

結果

mount 方法中加一句 console.log(vm.$options.render),開啟控制檯,重新整理頁面,看到如下內容,說明編譯器就完成了

image.png

接下來就會進入正式的掛載階段,完成頁面的初始渲染。

連結

感謝各位的:關注點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識。 感謝各位的 關注點贊收藏評論

新視訊和文章會第一時間在微信公眾號傳送,歡迎關注:李永寧lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。

相關文章