Skip to content

v-on 的支援

重構

在繼續實現之前,讓我們進行一些重構.
目前,在 codegen 生成的程式碼中,我們從 sharedruntime-core 匯入(或解構)了許多輔助函式.
而且在 codegen(和 transform)的實現中,我們硬編碼了函式名稱.這不是很明智.

這次,讓我們將它們重構為 runtime-helper 並用符號集中管理,進一步地,更改實現以僅匯入必要的內容.

首先,讓我們在 compiler-core/runtimeHelpers.ts 中實現表示每個輔助函式的符號.
到目前為止,我們一直使用 h 函式來生成 VNode,但這次,讓我們按照原始實現更改為使用 createVNode
runtime-core/vnode 匯出 createVNode,並在 genVNodeCall 中,更改程式碼以呼叫 createVNode 而不是 genVNodeCall

ts
export const CREATE_VNODE = Symbol()
export const MERGE_PROPS = Symbol()
export const NORMALIZE_CLASS = Symbol()
export const NORMALIZE_STYLE = Symbol()
export const NORMALIZE_PROPS = Symbol()

export const helperNameMap: Record<symbol, string> = {
  [CREATE_VNODE]: 'createVNode',
  [MERGE_PROPS]: 'mergeProps',
  [NORMALIZE_CLASS]: 'normalizeClass',
  [NORMALIZE_STYLE]: 'normalizeStyle',
  [NORMALIZE_PROPS]: 'normalizeProps',
}

使符號在 CallExpression 中可用作 callee

ts
export interface CallExpression extends Node {
  type: NodeTypes.JS_CALL_EXPRESSION
  callee: string | symbol
}

TransformContext 中實現一個區域來註冊輔助函式和註冊它們的函式.

ts
export interface TransformContext extends Required<TransformOptions> {
  currentNode: RootNode | TemplateChildNode | null
  parent: ParentNode | null
  childIndex: number
  helpers: Map<symbol, number> // 這個
  helper<T extends symbol>(name: T): T // 這個
}

export function createTransformContext(
  root: RootNode,
  { nodeTransforms = [], directiveTransforms = {} }: TransformOptions,
): TransformContext {
  const context: TransformContext = {
    // .
    // .
    // .
    helpers: new Map(),
    helper(name) {
      const count = context.helpers.get(name) || 0
      context.helpers.set(name, count + 1)
      return name
    },
  }

  return context
}

用這個輔助函式替換硬編碼的部分,並修改 Preamble 以使用註冊的輔助函式.

ts
// 示例)
propsExpression = createCallExpression('mergeProps', mergeArgs, elementLoc)
// ↓
propsExpression = createCallExpression(
  context.helper(MERGE_PROPS),
  mergeArgs,
  elementLoc,
)

context 傳遞給 createVNodeCall 並在其中註冊 CREATE_VNODE

ts
export function createVNodeCall(
  context: TransformContext | null, // 這個
  tag: VNodeCall['tag'],
  props?: VNodeCall['props'],
  children?: VNodeCall['children'],
  loc: SourceLocation = locStub,
): VNodeCall {
  // 這裡 ------------------------
  if (context) {
    context.helper(CREATE_VNODE)
  }
  // ------------------------

  return {
    type: NodeTypes.VNODE_CALL,
    tag,
    props,
    children,
    loc,
  }
}
ts
function genVNodeCall(
  node: VNodeCall,
  context: CodegenContext,
  option: Required<CompilerOptions>,
) {
  const { push, helper } = context
  const { tag, props, children } = node

  push(helper(CREATE_VNODE) + `(`, node) // 呼叫 createVNode
  genNodeList(genNullableArgs([tag, props, children]), context, option)
  push(`)`)
}
ts
export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  root.helpers = new Set([...context.helpers.keys()]) // 將輔助函式添加到 root
}
ts
// 根據原始實現添加 `_` 作為前綴來為其設置別名
const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`

function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
  const { push, newline, runtimeGlobalName } = context

  // 基於在 ast 中註冊的輔助函式生成輔助函式宣告
  const helpers = Array.from(ast.helpers)
  push(
    `const { ${helpers.map(aliasHelper).join(', ')} } = ${runtimeGlobalName}\n`,
  )
  newline()
}
ts
// 在 genCallExpression 中處理符號並將它們轉換為輔助函式呼叫。

export interface CodegenContext {
  // .
  // .
  // .
  helper(key: symbol): string
}

function createCodegenContext(ast: RootNode): CodegenContext {
  const context: CodegenContext = {
    // .
    // .
    // .
    helper(key) {
      return `_${helperNameMap[key]}`
    },
  }
  // .
  // .
  // .
  return context
}

// .
// .
// .

function genCallExpression(
  node: CallExpression,
  context: CodegenContext,
  option: Required<CompilerOptions>,
) {
  const { push, helper } = context

  // 如果是符號,從輔助函式中獲取它
  const callee = isString(node.callee) ? node.callee : helper(node.callee)

  push(callee + `(`, node)
  genNodeList(node.arguments, context, option)
  push(`)`)
}

透過這樣,我們這次進行的重構就完成了.我們能夠清理硬編碼的部分!

編譯結果

※ 注意

  • 輸入使用的是前一個遊樂場的輸入
  • 實際上在 function 前面有一個 return
  • 生成的程式碼用 prettier 格式化

當你這樣看時,有太多不必要的換行和空格...

好吧,讓我們在其他地方改進這個.

ts
function render(_ctx) {
  with (_ctx) {
    const {
      normalizeProps: _normalizeProps,
      createVNode: _createVNode,
      normalizeClass: _normalizeClass,
    } = ChibiVue

    return _createVNode('div', null, [
      '\n  ',
      _createVNode('p', _normalizeProps({ id: count }), ' v-bind:id="count" '),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ id: count * 2 }),
        ' :id="count * 2" ',
      ),
      '\n\n  ',
      _createVNode(
        'p',
        _normalizeProps({ ['style' || '']: bind.style }),
        ' v-bind:["style"]="bind.style" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ ['style' || '']: bind.style }),
        ' :["style"]="bind.style" ',
      ),
      '\n\n  ',
      _createVNode('p', _normalizeProps(bind), ' v-bind="bind" '),
      '\n\n  ',
      _createVNode(
        'p',
        _normalizeProps({ style: { 'font-weight': 'bold' } }),
        ' :style="{ font-weight: \'bold\' }" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ style: 'font-weight: bold;' }),
        ' :style="\'font-weight: bold;\'" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({
          class: _normalizeClass('my-class my-class2'),
        }),
        ' :class="\'my-class my-class2\'" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ class: _normalizeClass(['my-class']) }),
        ' :class="[\'my-class\']" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({
          class: _normalizeClass({ 'my-class': true }),
        }),
        ' :class="{ \'my-class\': true }" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({
          class: _normalizeClass({ 'my-class': false }),
        }),
        ' :class="{ \'my-class\': false }" ',
      ),
      '\n',
    ])
  }
}

v-on

這次要實現的開發者介面

現在讓我們繼續實現 v-on.

v-on 也有各種開發者介面. https://vuejs.org/guide/essentials/event-handling.html

這是我們這次的目標.

ts
import { createApp, defineComponent, ref } from 'chibivue'

const App = defineComponent({
  setup() {
    const count = ref(0)
    const increment = (e: Event) => {
      console.log(e)
      count.value++
    }
    return { count, increment, state: { increment }, eventName: 'click' }
  },

  template: `<div>
    <p>count: {{ count }}</p>

    <button v-on:click="increment">v-on:click="increment"</button>
    <button v-on:[eventName]="increment">v-on:click="increment"</button>
    <button @click="increment">@click="increment"</button>
    <button v-on="{ click: increment }">v-on="{ click: increment }"</button>

    <button @click="state.increment">v-on:click="increment"</button>
    <button @click="count++">@click="count++"</button>
    <button @click="() => count++">@click="() => count++"</button>
    <button @click="increment($event)">@click="increment($event)"</button>
    <button @click="e => increment(e)">@click="e => increment(e)"</button>
</div>`,
})

const app = createApp(App)

app.mount('#app')

我想要做的事情

實際上,關於解析器的實現,前一章的實現就足夠了,問題在於轉換器的實現. 轉換的內容主要根據 arg 的存在與否和 exp 的類型而變化. 當沒有 arg 時,需要做的事情幾乎與 v-bind 相同.

換句話說,需要考慮的是可以作為 arg 的 exp 類型以及為它們轉換必要的 AST Node.

  • 任務 1 分配一個函式. 這是最簡單的情況.

    html
    <button v-on:click="increment">increment</button>
  • 任務 2 在現場編寫函式表達式. 在這種情況下,你可以接收事件作為第一個參數.

    html
    <button v-on:click="(e) => increment(e)">increment</button>
  • 任務 3 編寫除函式以外的語句.

    html
    <button @click="count = 0">reset</button>

    看起來這個表達式需要轉換為以下函式.

    ts
    ;() => {
      count = 0
    }
  • 任務 4 在像任務 3 這樣的情況下,你可以使用識別符 $event. 這是處理事件物件的情況.

    ts
    const App = defineComponent({
      setup() {
        const count = ref(0)
        const increment = (e: Event) => {
          console.log(e)
          count.value++
        }
        return { count, increment, object }
      },
    
      template: `
        <div class="container">
          <button @click="increment($event)">increment($event)</button>
          <p> {{ count }} </p>
        </div>
        `,
    })
    // 不能像 @click="() => increment($event)" 這樣使用。

    看起來它需要轉換為以下函式.

    ts
    $event => {
      increment($event)
    }

實現

當沒有 arg 時

暫時,讓我們實現沒有 arg 的情況,因為它與 v-bind 相同. 這是我在前一章中留下 TODO 註釋的部分.它在 transformElement 附近.

ts
const isVBind = name === 'bind'
const isVOn = name === 'on' // --------------- 這裡

// v-bind 和 v-on 沒有參數的特殊情況
if (!arg && (isVBind || isVOn)) {
  if (exp) {
    if (isVBind) {
      pushMergeArg()
      mergeArgs.push(exp)
    } else {
      // -------------------------------------- 這裡
      // v-on="obj" -> toHandlers(obj)
      pushMergeArg({
        type: NodeTypes.JS_CALL_EXPRESSION,
        loc,
        callee: context.helper(TO_HANDLERS),
        arguments: [exp],
      })
    }
  }
  continue
}

const directiveTransform = context.directiveTransforms[name]
if (directiveTransform) {
  const { props } = directiveTransform(prop, node, context)
  if (isVOn && arg && !isStaticExp(arg)) {
    pushMergeArg(createObjectExpression(props, elementLoc))
  } else {
    properties.push(...props)
  }
} else {
  // TODO: 自訂指令。
}

我將這次實現名為 TO_HANDLERS 的輔助函式.

這個函式將以 v-on="{ click: increment }" 形式傳遞的物件轉換為 { onClick: increment } 的形式. 沒有什麼特別困難的.

ts
import { toHandlerKey } from '../../shared'

/**
 * 用於在 v-on="obj" 中為鍵添加 "on" 前綴
 */
export function toHandlers(obj: Record<string, any>): Record<string, any> {
  const ret: Record<string, any> = {}
  for (const key in obj) {
    ret[toHandlerKey(key)] = obj[key]
  }
  return ret
}

這完成了沒有 arg 時的實現. 讓我們繼續實現有 arg 時的情況.

transformVOn

現在,讓我們繼續這次的主題,即 v-on.v-on 的 exp 有各種格式.

ts
increment

state.increment

count++

;() => count++

increment($event)

e => increment(e)

首先,這些格式可以大致分為兩類:"函式"和"語句".在 Vue 中,如果是單個 Identifier,單個 MemberExpression 或函式表達式,則將其視為函式.否則,它是一個語句.在原始碼中,它似乎被稱為 inlineStatement.

ts
// 函式(※ 為了方便,請將這些視為函式表達式。)
increment
state.increment
;() => count++
e => increment(e)

// inlineStatement
count++
increment($event)

換句話說,這次的實現流程如下:

  1. 首先,確定它是否是函式(單個 Identifier 或單個 MemberExpression 或函式表達式).

2-1. 如果是函式,生成 eventName: exp 形式的 ObjectProperty,不進行任何轉換.

2-2. 如果不是函式(如果是 inlineStatement),將其轉換為 $event => { ${exp} } 的形式並生成 ObjectProperty.

這就是基本思路.

確定是函式表達式還是語句

讓我們從實現確定開始.是否是函式表達式是使用正規表達式完成的.

ts
const fnExpRE =
  /^\s*([\w$_]+|(async\s*)?\([^)]*?\))\s*(:[^=]+)?=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/

const isFn = fnExpRE.test(exp.content)

是否是單個 Identifier 或單個 MemberExpression 是用名為 isMemberExpression 的函式實現的.

ts
const isMemberExp = isMemberExpression(exp.content)

這個 isMemberExpression 函式相當複雜,實現很長.有點長,所以我在這裡省略它.(如果你感興趣,請查看程式碼.)

一旦我們確定了這一點,它是 inlineStatement 的條件就是除了這些之外的任何東西.

ts
const isMemberExp = isMemberExpression(exp.content)
const isFnExp = fnExpRE.test(exp.content)
const isInlineStatement = !(isMemberExp || isFnExp)

現在我們已經確定了這一點,讓我們基於這個結果實現轉換過程.

ts
const isMemberExp = isMemberExpression(exp.content)
const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content))
const hasMultipleStatements = exp.content.includes(`;`)

if (isInlineStatement) {
  // 將內聯語句包裝在函式表達式中
  exp = createCompoundExpression([
    `$event => ${hasMultipleStatements ? `{` : `(`}`,
    exp,
    hasMultipleStatements ? `}` : `)`,
  ])
}

問題

實際上,上述實現有一個小問題.

問題在於 $event,因為在 dir.exp 中,我們需要使用 processExpression 處理從 setup 綁定的值,但問題在於 $event. 在 AST 上,$event 也被視為 Identifier,所以如果我們保持原樣,它將被加上 _ctx. 前綴.

所以讓我們做一點改進.讓我們在 transformContext 中註冊一個局部變數.在 walkIdentifiers 中,如果有局部變數,我們不會執行 onIdentifier

ts
const context: TransformContext = {
  // .
  // .
  // .
  identifiers: Object.create(null),
  // .
  // .
  addIdentifiers(exp) {
    if (!isBrowser) {
      addId(exp)
    }
  },
  removeIdentifiers(exp) {
    if (!isBrowser) {
      removeId(exp)
    }
  },
}

function addId(id: string) {
  const { identifiers } = context
  if (identifiers[id] === undefined) {
    identifiers[id] = 0
  }
  identifiers[id]!++
}

function removeId(id: string) {
  context.identifiers[id]!--
}
ts
export function walkIdentifiers(
  root: Node,
  onIdentifier: (node: Identifier) => void,
  knownIds: Record<string, number> = Object.create(null), 
) {
  ;(walk as any)(root, {
    enter(node: Node) {
      if (node.type === 'Identifier') {
        const isLocal = !!knownIds[node.name] 
        // prettier-ignore
        if (!isLocal) { 
          onIdentifier(node);
        } 
      }
    },
  })
}

然後,當在 processExpression 中使用 walkIdentifiers 時,我們將從 context 中拉取 identifiers

ts
const ids: QualifiedId[] = []
const knownIds: Record<string, number> = Object.create(ctx.identifiers) 

walkIdentifiers(
  ast,
  node => {
    node.name = rewriteIdentifier(node.name)
    ids.push(node as QualifiedId)
  },
  knownIds, 
)

最後,當在 transformOn 中轉換時,讓我們註冊 $event

ts
// prettier-ignore
if (!context.isBrowser) { 
  isInlineStatement && context.addIdentifiers(`$event`); 
  exp = dir.exp = processExpression(exp, context); 
  isInlineStatement && context.removeIdentifiers(`$event`); 
} 

if (isInlineStatement) {
  // 將內聯語句包裝在函式表達式中
  exp = createCompoundExpression([
    `$event => ${hasMultipleStatements ? `{` : `(`}`,
    exp,
    hasMultipleStatements ? `}` : `)`,
  ])
}

由於 v-on 需要一些特殊處理,並且由於它在 transformOn 中單獨處理,我們將在 transformExpression 中跳過它.

ts
export const transformExpression: NodeTransform = (node, ctx) => {
  // .
  // .
  // .
  if (
    exp &&
    exp.type === NodeTypes.SIMPLE_EXPRESSION &&
    !(dir.name === 'on' && arg) 
  ) {
    dir.exp = processExpression(exp, ctx)
  }
}

現在,我們已經完成了這次的關鍵部分.讓我們實現剩餘的必要部分並完成 v-on!!

到此為止的原始碼:GitHub

基於 MIT 許可證發布。