petite-vue源碼剖析-從靜態視圖開始

代碼庫結構介紹

  • 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的區別?

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章