petite-vue源碼剖析-屬性綁定`v-bind`的工作原理

關於指令(directive)

屬性綁定、事件綁定和v-modal底層都是通過指令(directive)實現的,那麼什麼是指令呢?我們一起看看Directive的定義吧。

//文件 ./src/directives/index.ts

export interface Directive<T = Element> {
  (ctx: DirectiveContext<T>): (() => void) | void
}

指令(directive)其實就是一個接受參數類型爲DirectiveContext並且返回cleanup
函數或啥都不返回的函數。那麼DirectiveContext有是如何的呢?

//文件 ./src/directives/index.ts

export interface DirectiveContext<T = Element> {
  el: T
  get: (exp?: string) => any // 獲取表達式字符串運算後的結果
  effect: typeof rawEffect // 用於添加副作用函數
  exp: string // 表達式字符串
  arg?: string // v-bind:value或:value中的value, v-on:click或@click中的click
  modifiers?: Record<string, true> // @click.prevent中的prevent
  ctx: Context
}

深入v-bind的工作原理

walk方法在解析模板時會遍歷元素的特性集合el.attributes,當屬性名稱name匹配v-bind:時,則調用processDirective(el, 'v-bind', value, ctx)對屬性名稱進行處理並轉發到對應的指令函數並執行。

//文件 ./src/walk.ts

// 爲便於閱讀,我將與v-bind無關的代碼都刪除了
const processDirective = (
  el: Element,
  raw, string, // 屬性名稱
  exp: string, // 屬性值:表達式字符串
  ctx: Context
) => {
  let dir: Directive
  let arg: string | undefined
  let modifiers: Record<string, true> | undefined // v-bind有且僅有一個modifier,那就是camel

  if (raw[0] == ':') {
    dir = bind
    arg = raw.slice(1)
  }
  else {
    const argIndex = raw.indexOf(':')
    // 由於指令必須以`v-`開頭,因此dirName則是從第3個字符開始截取
    const dirName = argIndex > 0 ? raw.slice(2, argIndex) : raw.slice(2)
    // 優先獲取內置指令,若查找失敗則查找當前上下文的指令
    dir = builtInDirectives[dirName] || ctx.dirs[dirName]
    arg = argIndex > 0 ? raw.slice(argIndex) : undefined
  }

  if (dir) {
    // 由於ref不是用於設置元素的屬性,因此需要特殊處理
    if (dir === bind && arg === 'ref') dir = ref
    applyDirective(el, dir, exp, ctx, arg, modifiers)
  }
}

processDirective根據屬性名稱匹配相應的指令和抽取入參後,就會調用applyDirective來通過對應的指令執行操作。

//文件 ./src/walk.ts

const applyDirective = (
  el: Node,
  dir: Directive<any>,
  exp: string,
  ctx: Context,
  arg?: string
  modifiers?: Record<string, true>
) => {
  const get = (e = exp) => evaluate(ctx.scope, e, el)
  // 指令執行後可能會返回cleanup函數用於執行資源釋放操作,或什麼都不返回
  const cleanup = dir({
    el,
    get,
    effect: ctx.effect,
    ctx,
    exp,
    arg,
    modifiers
  })

  if (cleanup) {
    // 將cleanup函數添加到當前上下文,當上下文銷燬時會執行指令的清理工作
    ctx.cleanups.push(cleanup)
  }
}

現在我們終於走到指令bind執行階段了

//文件 ./src/directives/bind.ts

// 只能通過特性的方式賦值的屬性
const forceAttrRE = /^(spellcheck|draggable|form|list|type)$/

export const bind: Directive<Element & { _class?: string }> => ({
  el,
  get,
  effect,
  arg,
  modifiers
}) => {
  let prevValue: any
  if (arg === 'class') {
    el._class = el.className
  }

  effect(() => {
    let value = get()
    if (arg) {
      // 用於處理v-bind:style="{color:'#fff'}" 的情況

      if (modifiers?.camel) {
        arg = camelize(arg)
      }
      setProp(el, arg, value, prevValue)
    }
    else {
      // 用於處理v-bind="{style:{color:'#fff'}, fontSize: '10px'}" 的情況

      for (const key in value) {
        setProp(el, key, value[key], prevValue && prevValue[key])
      }
      // 刪除原視圖存在,而當前渲染的新視圖不存在的屬性
      for (const key in prevValue) {
        if (!value || !(key in value)) {
          setProp(el, key, null)
        }
      }
    }
    prevValue = value
  })
}

const setProp = (
  el: Element & {_class?: string},
  key: string,
  value: any,
  prevValue?: any
) => {
  if (key === 'class') {
    el.setAttribute(
      'class',
      normalizeClass(el._class ? [el._class, value] : value) || ''
    )
  }
  else if (key === 'style') {
    value = normalizeStyle(value)
    const { style } = el as HTMLElement
    if (!value) {
      // 若`:style=""`則移除屬性style
      el.removeAttribute('style')
    }
    else if (isString(value)) {
      if (value !== prevValue) style.cssText = value
    }
    else {
      // value爲對象的場景
      for (const key in value) {
        setStyle(style, key, value[key])
      }
      // 刪除原視圖存在,而當前渲染的新視圖不存在的樣式屬性
      if (prevValue && !isString(prevValue)) {
        for (const key in prevValue) {
          if (value[key] == null) {
            setStyle(style, key, '')
          }
        } 
      }
    }
  }
  else if (
    !(el instanceof SVGElement) &&
    key in el &&
    !forceAttrRE.test(key)) {
      // 設置DOM屬性(屬性類型可以是對象)
      el[key] = value
      // 留給`v-modal`使用的
      if (key === 'value') {
        el._value = value
      }
  } else {
    // 設置DOM特性(特性值僅能爲字符串類型)

    /* 由於`<input v-modal type="checkbox">`元素的屬性`value`僅能存儲字符串,
     * 通過`:true-value`和`:false-value`設置選中和未選中時對應的非字符串類型的值。
     */
    if (key === 'true-value') {
      ;(el as any)._trueValue = value
    }
    else if (key === 'false-value') {
      ;(el as any)._falseValue = value
    }
    else if (value != null) {
      el.setAttribute(key, value)
    }
    else {
      el.removeAttribute(key)
    }
  }
}

const importantRE = /\s*!important/

const setStyle = (
  style: CSSStyleDeclaration,
  name: string,
  val: string | string[]
) => {
  if (isArray(val)) {
    val.forEach(v => setStyle(style, name, v))
  } 
  else {
    if (name.startsWith('--')) {
      // 自定義屬性
      style.setProperty(name, val)
    }
    else {
      if (importantRE.test(val)) {
        // 帶`!important`的屬性
        style.setProperty(
          hyphenate(name),
          val.replace(importantRE, ''),
          'important'
        )
      }
      else {
        // 普通屬性
        style[name as any] = val
      }
    }
  }
}

總結

通過本文我們以後不單可以使用v-bind:style綁定單一屬性,還用通過v-bind一次過綁定多個屬性,雖然好像不太建議這樣做>_<

後續我們會深入理解v-on事件綁定的工作原理,敬請期待。

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