Skip to content

v-on に対応する

リファクタリング

実装を進めていく前に少しリファクタリングを行います。
現在、codegen で生成するコード中では、shared や runtime-core から export された helper 関数を多数 import (なり、分割代入で読み込んだり)しています。
そして、codegen(や transform)の実装の方ではその関数名をハードコードしてしまっています。これはあまりスマートではありません。

今回は、これらを runtime-helper として、symbol で一元管理し、さらに、必要なものだけを読み込むような実装に変更してみようと思います。

とりあえず、それぞれの helper を表す symbol を compiler-core/runtimeHelpers.ts に実装してみます。
今まで VNode の生成に関しては h 関数を使っていたのですが、これを機に本家の実装にならい createVNode を使うように変更しょう。
runtime-core/vnode から crateVNode を export して、genVNodeCall では createVNode を呼び出すコードに変更します。

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 として、symbol も使えるようにします。

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

TransformContext に、helper を登録する領域と登録するための関数を実装します。

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
}

あとは、ハードコードしてしまっている部分をこの helper 関数に置き換えて、Preamble では登録された helper を使用するように書き換えます。

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

createVNodeCall にも context を渡すようにして、中で 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をcallするように
  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 に helpersを持たせる
}
ts
// 本家の実装に合わせてエイリアスとして `_` を prefix としてつける
const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`

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

  // astに登録された helper を元に helperの宣言を生成
  const helpers = Array.from(ast.helpers)
  push(
    `const { ${helpers.map(aliasHelper).join(', ')} } = ${runtimeGlobalName}\n`,
  )
  newline()
}
ts
// genCallExpression で symbol をハンドリングして helperの呼び出しに変換します。

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

  // symbol の場合は helper から取得
  const callee = isString(node.callee) ? node.callee : helper(node.callee)

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

これで今回行うリファクタは終わりです。ハードコードしていた部分綺麗にできました!

コンパイル結果

※ 注意

  • input は前回の playground のものを使っています
  • 実際には 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')

やりたいこと

実は Parser の実装としては、前チャプターのもので十分で、問題は Transformer の実装です。
主に arg 有無と、exp の形式の種類によって変換する内容が変わってきます。 そして、arg がない場合に関して、やることはほとんど v-bind と同じです。

つまり考えるべきは、arg がある場合の exp として取りうる形式の種類と、それらに必要な AST Node の変換です。

  • 課題 1
    関数を割り当てられる
    こちらは最もシンプルなケースです。

    html
    <button v-on:click="increment">increment</button>
  • 課題 2
    その場で関数式を書ける この場合には、第 1 引数としてイベントを受け取ることができます。

    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' // --------------- これ

// special case for v-bind and v-on with no argument
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: custom directive.
}

TO_HANDLERS と言う helper 関数に関しては今回新たに実装します。

v-on="{ click: increment }" のような形式で渡ってきたオブジェクトを、{ onClick: increment } のような形式に変換する関数です。
特に難しいところはないかと思います。

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

/**
 * For prefixing keys in v-on="obj" with "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 の exp には様々な形式があります。

ts
increment

state.increment

count++

;() => count++

increment($event)

e => increment(e)

まず、これらの形式は大きく二つに分類できます。「関数」と「文」です。
Vue では、単体の Identifier か、単体の MemberExpression, 関数式 の場合には関数として扱われます。
それ以外が文です。ソースコード上は inlineStatement という名前で通じているようです。

ts
// function (※ 便宜上セミコロンがついてしまっていますが、これらは関数式だと思ってください。)
increment
state.increment
;() => count++
e => increment(e)

// inlineStatement
count++
increment($event)

つまり、今回実装する流れ的には、

  1. まずは関数かどうかを判定する (単体の Identifier or 単体の MemberExpression or 関数式)

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 は結構泥臭く、長々と実装してあります。ちょっと長いので、ここでは省略します。(ぜひコードを読んでみてください。)
MemberExpression というと、parent.propのような形式を推察しますが、どうやらこの関数では ident のようなルートレベルのものも true として判定しているようです。

ここまで判定できたら、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) {
  // wrap inline statement in a function expression
  exp = createCompoundExpression([
    `$event => ${hasMultipleStatements ? `{` : `(`}`,
    exp,
    hasMultipleStatements ? `}` : `)`,
  ])
}

問題点

実は上記の対応では少し問題点があります。

というのも、dir.exp 中では setup からバインドされた値を扱うので processExpression を噛ませないといけないのですが、問題は $event です。
AST 上は $event も Identifier 扱いなので、このままでは _ctx. prefix がついてしまいます。

そこで少し工夫をしてみます。 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 で transform する際に $event を登録してあげます。

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

if (isInlineStatement) {
  // wrap inline statement in a function expression
  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

Released under the MIT License.