petite-vue原始碼剖析-從靜態檢視開始

肥仔John發表於2022-03-04

程式碼庫結構介紹

  • examples 各種使用示例
  • scripts 打包釋出指令碼
  • tests 測試用例
  • src

    • directives v-if等內建指令的實現
    • app.ts createApp函式
    • block.ts 塊物件
    • context.ts 上下文物件
    • eval.ts 提供v-if="count === 1"等表示式運算功能
    • scheduler.ts 排程器
    • utils.ts 工具函式
    • walk.ts 模板解析

若想構建自己的版本只需在控制檯執行npm run build即可。

深入理解靜態檢視的渲染過程

靜態檢視是指首次渲染後,不會因UI狀態變化引發重新渲染。其中檢視不包含任何UI狀態,和根據UI狀態首次渲染後狀態不再更新兩種情況,本篇將針對前者進行講解。

示例:

<div v-scope="App"></div>

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'

  createApp({
    App: {
      $template: `
      <span> OFFLINE </span>
      <span> UNKOWN </span>
      <span> ONLINE </span>
      `
    }
  }).mount('[v-scope]')
</script>

首先進入的就是createApp方法,它的作用就是建立根上下文物件(root context)全域性作用域物件(root scope)並返回mount,unmountdirective方法。然後通過mount方法尋找附帶[v-scope]屬性的孩子節點(排除匹配[v-scope] [v-scope]的子孫節點),併為它們建立根塊物件
原始碼如下(基於這個例子,我對原始碼進行部分刪減以便更容易閱讀):

// 檔案 ./src/app.ts

export const createApp = (initialData: any) => {
  // 建立根上下文物件
  const ctx = createContext()
  // 全域性作用域物件,作用域物件其實就是一個響應式物件
  ctx.scope = reactive(initialData)
  /* 將scope的函式成員的this均繫結為scope。
   * 若採用箭頭函式賦值給函式成員,則上述操作對該函式成員無效。
   */
  bindContextMethods(ctx.scope)
  
  /* 根塊物件集合
   * petite-vue支援多個根塊物件,但這裡我們可以簡化為僅支援一個根塊物件。
   */
  let rootBlocks: Block[]

  return {
    // 簡化為必定掛載到某個帶`[v-scope]`的元素下
    mount(el: Element) {
      let roots = el.hasAttribute('v-scope') ? [el] : []
      // 建立根塊物件
      rootBlocks = roots.map(el => new Block(el, ctx, true))
      return this
    },
    unmount() {
      // 當節點解除安裝時(removeChild)執行塊物件的清理工作。注意:重新整理介面時不會觸發該操作。
      rootBlocks.forEach(block => block.teardown())
    }
  }
}

程式碼雖然很短,但引出了3個核心物件:上下文物件(context)作用域(scope)塊物件(block)。他們三的關係是:

  • 上下文物件(context)作用域(scope) 是 1 對 1 關係;
  • 上下文物件(context)塊物件(block) 是 多 對 多 關係,其中塊物件(block)通過ctx指向當前上下文物件(context),並通過parentCtx指向父上下文物件(context)
  • 作用域(scope)塊物件(block) 是 1 對 多 關係。

具體結論是:

  • 根上下文物件(context) 可被多個根塊物件通過ctx引用;
  • 塊物件(block)建立時會基於當前的上下文物件(context)建立新的上下文物件(context),並通過parentCtx指向原來的上下文物件(context)
  • 解析過程中v-scope就會基於當前作用域物件構建新的作用域物件,並複製當前上下文物件(context)組成一個新的上下文物件(context)用於子節點的解析和渲染,但不會影響當前塊物件指向的上下文。

下面我們逐一理解。

作用域(scope)

這裡的作用域和我們編寫JavaScript時說的作用域是一致的,作用是限定函式和變數的可用範圍,減少命名衝突。
具有如下特點:

  1. 作用域之間存在父子關係和兄弟關係,整體構成一顆作用域樹;
  2. 子作用域的變數或屬性可覆蓋祖先作用域同名變數或屬性的訪問性;
  3. 若對僅祖先作用域存在的變數或屬性賦值,將賦值給祖先作用域的變數或屬性。
// 全域性作用域
var globalVariable = 'hello'
var message1 = 'there'
var message2 = 'bye'

(() => {
  // 區域性作用域A
  let message1 = '區域性作用域A'
  message2 = 'see you'
  console.log(globalVariable, message1, message2)
})()
// 回顯:hello 區域性作用域A see you

(() => {
  // 區域性作用域B
  console.log(globalVariable, message1, message2)
})()
// 回顯:hello there see you

而且作用域是依附上下文存在的,所以作用域的建立和銷燬自然而然都位於上下文的實現中(./src/context.ts)。
另外,petite-vue中的作用域並不是一個普通的JavaScript物件,而是一個經過@vue/reactivity處理的響應式物件,目的是一旦作用域成員被修改,則觸發相關副作用函式執行,從而重新渲染介面。

塊物件(block)

作用域(scope)是用於管理JavaScript的變數和函式可用範圍,而塊物件(block)則用於管理DOM物件。

// 檔案 ./src/block.ts

// 基於示例,我對程式碼進行了刪減
export class Block {
  template: Element | DocumentFragment // 不是指向$template,而是當前解析的模板元素
  ctx: Context // 有塊物件建立的上下文物件
  parentCtx?: Context // 當前塊物件所屬的上下文物件,根塊物件沒有歸屬的上下文物件

  // 基於上述例子沒有采用<template>元素,並且靜態檢視不包含任何UI狀態,因此我對程式碼進行了簡化
  construct(template: Element, parentCtx: Context, isRoot = false) {
    if (isRoot) {
      // 對於根塊物件直接以掛載點元素作為模板元素
      this.template = template
    }
    if (isRoot) {
      this.ctx = parentCtx
    }

    // 採用深度優先策略解析元素(解析過程會向非同步任務佇列壓入渲染任務)
    walk(this.template, this.ctx)
  }
}
// 檔案 ./src/walk.ts

// 基於上述例子為靜態檢視不包含任何UI狀態,因此我對程式碼進行了簡化
export const walk = (node: Node, ctx: Context): ChildNode | null | void => {
  const type= node.nodeType
  if (type === 1) {
    // node為Element型別
    const el = node as Element

    let exp: string | null
    if ((exp = checkAttr(el, 'v-scope')) || exp === '') {
      // 元素帶`v-scope`則計算出最新的作用物件。若`v-scope`的值為空,則最新的作用域物件為空物件
      const scope = exp ? evaluate(ctx.scope, exp) : {}
      // 更新當前上下文的作用域
      ctx = createScopedContext(ctx, scope)
      // 若當前作用域存在`$template`渲染到DOM樹上作為線上模板,後續會遞迴解析處理
      // 注意:這裡不會讀取父作用域的`$template`屬性,必須是當前作用域的
      if (scope.$template) {
        resolveTemplate(el, scope.$template)
      }
    }

    walkChildren(el, ctx)
  }
}

// 首先解析第一個孩子節點,若沒有孩子則解析兄弟節點
const walkChildren = (node: Element | DocumentFragment, ctx: Context) => {
  let child = node.firstChild
  while (child) {
    child = walk(child, ctx) || child.nextSibling
  }
}

// 基於上述例子我對程式碼進行了簡化
const resolveTemplate = (el: Element, template: string) => {
  // 得益於Vue採用的模板完全符合HTML規範,所以這麼直接簡單地渲染為HTML元素後,`@click`和`:value`等屬性名稱依然不會丟失
  el.innerHTML = template
}

為了更容易閱讀我又對錶達式運算的程式碼進行了簡化(移除開發階段的提示和快取機制)

// 檔案 ./src/eval.ts

export const evaluate = (scope: any, exp: string, el? Node) => execute(scope, exp, el)

const execute = (scope: any, exp: string, el? Node) => {
  const fn = toFunction(exp)
  return fn(scope, el)
}

const toFunction = (exp: string): Function => {
  try {
    return new Function('$data', '$el', `with($data){return(${exp})}`)
  }
  catch(e) {
    return () => {}
  }
}

上下文物件(context)

上面我們瞭解到作用域(scope)是用於管理JavaScript的變數和函式可用範圍,而塊物件(block)則用於管理DOM物件,那麼上下文物件(context)則是連線作用域(scope)塊物件(block)的載體,也是將多個塊物件組成樹狀結構的連線點([根塊物件.ctx] -> [根上下文物件, 根上下文物件.blocks] -> [子塊物件] -> [子上下文物件])。

// 檔案 ./src/context.ts

export interface Context {
  scope: Record<string, any> // 當前上下文對應的作用域物件
  cleanups: (()=>void)[] // 當前上下文指令的清理函式
  blocks: Block[] // 歸屬於當前上下文的塊物件
  effect: typeof rawEffect // 類似於@vue/reactivity的effect方法,但可根據條件選擇排程方式
  effects: ReativeEffectRunner[] // 當前上下文持有副作用方法,用於上下文銷燬時回收副作用方法釋放資源
}

/**
 * 由Block建構函式呼叫建立新上下文物件,特性如下:
 * 1. 新上下文物件作用域與父上下文物件一致
 * 2. 新上下文物件擁有全新的effects、blocks和cleanups成員
 * 結論:由Block建構函式發起的上下文物件建立,不影響作用域物件,但該上下文物件會獨立管理旗下的副作用方法、塊物件和指令
 */
export const createContext = (parent? Context): Context => {
  const ctx: Context = {
    ...parent,
    scope: parent ? parent.scope : reactive({}), // 指向父上下文作用域物件
    effects: [],
    blocks: [],
    cleanups: [],
    effect: fn => {
      // 當解析遇到`v-once`屬性,`inOnce`即被設定為`true`,而副作用函式`fn`即直接壓入非同步任務佇列執行一次,即使其依賴的狀態發生變化副作用函式也不會被觸發。
      if (inOnce) {
        queueJob(fn)
        return fn as any
      }
      // 生成狀態發生變化時自動觸發的副作用函式
      const e: ReactiveEffectRunner = rawEffect(fn, {
        scheduler: () => queueJob(e)
      })
      ctx.effects.push(e)
      return e
    }
  }
  return ctx
}

/**
 * 當解析時遇到`v-scope`屬性並存在有效值時,便會呼叫該方法基於當前作用域建立新的作用域物件,並複製當前上下文屬性構建新的上下文物件用於子節點的解析和渲染。
 */
export const createScopedContext = (ctx: Context, data = {}): Context => {
  const parentScope = ctx.scope
  /* 構造作用域物件原型鏈 
   * 此時若當設定的屬性不存在於當前作用域,則會在當前作用域建立該屬性並賦值。
   */
  cosnt mergeScope = Object.create(parentScope)
  Object.defineProperties(mergeScope, Object.getOwnPropertyDescriptors(data))
  // 構造ref物件原型鏈
  mergeScope.$ref = Object.create(parentScope.$refs)
  // 構造作用域鏈
  const reactiveProxy = reactive(
    new Proxy(mergeScope, {
      set(target, key, val, receiver) {
        // 若當設定的屬性不存在於當前作用域則將值設定到父作用域上,由於父作用域以同樣方式建立,因此遞迴找到擁有該屬性的祖先作用域並賦值
        if (receiver === reactiveProxy && !target.hasOwnProperty(key)) {
          return Reflect.set(parentScope, key, val)
        }
        return Reflect.set(target, key, val, receiver)
      }
    })
  )

  /* 將scope的函式成員的this均繫結為scope。
   * 若採用箭頭函式賦值給函式成員,則上述操作對該函式成員無效。
   */
  bindContextMethods(reactiveProxy)
  return {
    ...ctx,
    scope: reactiveProxy
  }
}

人肉單步除錯

  1. 呼叫createApp根據入參生成全域性作用域rootScope,建立根上下文rootCtx
  2. 呼叫mount<div v-scope="App"></div>構建根塊物件rootBlock,並將其作為模板執行解析處理;
  3. 解析時識別到v-scope屬性,以全域性作用域rootScope為基礎運算得到區域性作用域scope,並以根上下文rootCtx為藍本一同構建新的上下文ctx,用於子節點的解析和渲染;
  4. 獲取$template屬性值並生成HTML元素;
  5. 深度優先遍歷解析子節點。

待續

通過簡單的例子我們對petite-vue的解析、排程和渲染過程有了一定程度的瞭解,下一篇我們將再次通過靜態檢視看看v-ifv-for是如何根據狀態改變DOM樹結構的。
另外,可能有朋友會有如下疑問

  1. Proxy的receiver是什麼?
  2. new Functioneval的區別?

這些後續會在專門的文章介紹,敬請期待:)

相關文章