• 为了 Vue 组件测试,你需要为每个事件绑定的方法加上括号吗?


    本文由华为云体验技术团队松塔同学分享

    先说结论,当然不是!Vue 组件测试,尤其是组件触发事件的测试,有成熟的示例。我们同样要关注测试的原则,例如将组件当成黑盒,不关心其内部实现,而只关心与其交互。本文是借由一次 Vue 组件测试,引发对 Vue 源码和 Spy 函数的延伸探讨。

    假设你写了一个 Vue 组件,它大概长这样:

    <MyComponent
      :disabled="!valid"
      :data="someTestData"
      @confirm="handleConfirm"
    />
    
    • 1
    • 2
    • 3
    • 4
    • 5

    它定义了datadisabled作为 props,前者作为组件的数据输入,后者用来定义组件的功能开关。组件被点击时,会抛出confirm事件,不过当disabledtrue时,confirm事件不会被触发。

    当你想为这个组件写一些单元测试时,可能会这样写:

    describe('MyComponent on the page', () => {
      // ...
      it('confirm event', async () => {
        const instance = wrapper.findComponent({ name: 'MyComponent' })
        const spy = vi
          .spyOn(wrapper.vm, 'handleConfirm')
          .mockImplementation(() => null)
        await instance.trigger('click')
        expect(spy).not.toHaveBeenCalled()
        // ... change valid
        await instance.trigger('click')
        expect(spy).toHaveBeenCalledTimes(1)
      })
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    valid初始化时为false,即MyComponent一开始不会抛出confirm事件,当valid被改变后,点击MyComponentconfirm事件才被抛出。

    这段单元测试会在最后一句报错,显示spy实际被触发 0 次。实际上,spy永远不会被触发,即使valid初始化时为true也是如此。

    然而,将模板里的方法调用调整一下,加上括号,单元测试就按照预期通过了:

    <MyComponent
      :disabled="!valid"
      :data="someTestData"
      @confirm="handleConfirm()"
    />
    
    • 1
    • 2
    • 3
    • 4
    • 5

    为什么加不加括号会引起单元测试的逻辑变化?

    模板语法

    首先我们需要看一看模板在编译时,处理@confirm="handleConfirm()"@confirm="handleConfirm"有什么不同。

    @vue/compiler-sfccompileTemplate方法开始一路往下分析,会发现模板编译的核心方法是@vue/compiler-core这个包中的baseCompile方法。这个方法主要干三件事:

    export function baseCompile(
      template: string | RootNode,
      options: CompilerOptions = {}
    ): CodegenResult {
      // ...
        
      // 1. 生成基础ast
      const ast = isString(template) ? baseParse(template, options) : template
      
      // ...
      
      // 2. 对ast做转换
      transform(
        ast,
        extend({}, options, {
          prefixIdentifiers,
          nodeTransforms: [
            ...nodeTransforms,
            ...(options.nodeTransforms || []) // user transforms
          ],
          directiveTransforms: extend(
            {},
            directiveTransforms,
            options.directiveTransforms || {} // user transforms
          )
        })
      )
      // 3.生成渲染函数
      return generate(
        ast,
        extend({}, options, {
          prefixIdentifiers
        })
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    1. 调用baseParse方法解析 HTML,生成基础的 AST。由于 Vue 在 HTML 上增加了许多语法特性(v-if、v-for、v-bind 等等),需要做对应解析。
     生成的 AST
     生成的 AST
    11.18图1.png11.18图2.png

    查看生成的 AST 结构后可以发现,加不加括号对结构并不会产生影响。二者都生成了 v-on 的 prop,exp中的 content 未对原始内容做出改动。

    1. 进一步对 AST 做解析和转换。这一步引入了nodeTransformsdirectiveTransforms对象,其实是在./transforms目录下的一系列函数:
    export function getBaseTransformPreset(
        prefixIdentifiers?: boolean
    ): TransformPreset {
        return [
            [
                transformOnce,
                transformIf,
                transformMemo,
                transformFor,
                ...(__COMPAT__ ? [transformFilter] : []),
                ...(!__BROWSER__ && prefixIdentifiers
                    ? [
                        // order is important
                        trackVForSlotScopes,
                        transformExpression
                    ]
                    : __BROWSER__ && __DEV__
                        ? [transformExpression]
                        : []),
                transformSlotOutlet,
                transformElement,
                trackSlotScopes,
                transformText
            ],
            {
                on: transformOn,
                bind: transformBind,
                model: transformModel
            }
        ]
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    光从名字就可以看出来,依旧是对 Vue 的语法特性做的一些工作,最终在 AST 的每个节点上增加codegenNode,这个属性将会被用在第三步生成渲染函数过程中。经过 transform 这一步后,生成的codegenNode如下:

     的 codegenNode
     的 codegenNode
    11.18图3.png11.18图4.png

    二者 prop 中的 value 值有所差异,type 是 typescript 定义的 enum,编译后变成了数字,还原后前者的类型从SIMPLE_EXPRESSION变成了COMPOUND_EXPRESSION,后者仍保持之前的SIMPLE_EXPRESSION

    造成二者差异的原因,需要深入transformOn这个对 v-on 语法转换的方法。它根据 AST 节点的exparg,生成codegenNodeprops下的属性。简化一下有关exp的逻辑,核心代码如下:

    const isMemberExp = isMemberExpression(exp.content, context)
    const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content))
    const hasMultipleStatements = exp.content.includes(`;`)
    if (isInlineStatement || (shouldCache && isMemberExp)) {
        // wrap inline statement in a function expression
        exp = createCompoundExpression([
            `${isInlineStatement
                ? !__BROWSER__ && context.isTS
                    ? `($event: any)`
                    : `$event`
                : `${!__BROWSER__ && context.isTS ? `\n//@ts-ignore\n` : ``}(...args)`
            } => ${hasMultipleStatements ? `{` : `(`}`,
            exp,
            hasMultipleStatements ? `}` : `)`,
        ]);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    首先对exp做判断,是否是 member expression、是否是 inline statement,是否有多个 statement。然后出现了exp的改写,根据判断生成了 compound expression,实际就是转换成了函数表达。看来isMemberExpisInlineStatement这两个判断影响了最终codegenNode的生成。

    Member Expression

    这是个来源于 AST 定义的概念,JavaScript 中经常有对象属性的指向,例如:

    const a = { x: 0 }
    const b = a.x
    
    • 1
    • 2

    这里a.x就是 member expression,transformOn中调用isMemberExpression来做判断,实际就是调用 babel parser 的能力分析,简化来说:

    try {
        let ret: Expression = parseExpression(path, {
            plugins: context.expressionPlugins,
        });
        if (ret.type === 'TSAsExpression' || ret.type === 'TSTypeAssertion') {
            ret = ret.expression;
        }
        return (
            ret.type === 'MemberExpression' ||
            ret.type === 'OptionalMemberExpression' ||
            ret.type === 'Identifier'
        );
    } catch (e) {
        return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这里 MemberExpression、OptionalMemberExpression、Identifier 都被认定成了 member expression。OptionalMemberExpression 即带有 optional chaining (?.) 的表达式。Identifier 也被包括的原因是,在模板中一般会省略主对象,如 this、或者 setup 中返回的对象。

    handleConfirm就是 Identifier,它指向的就是我们在 script 中定义的函数。

    isInlineStatement的判断中还出现了一个条件fnExpRE.test(exp.content),这是函数表达式的正则判断:

    11.18图5.png

    虽然直接在模板里声明函数很罕见,但是 Vue 并没有限制这种做法。

    exp如果既不是 member expression,也不是函数表达式,transformOn就把它当作 inline statement。实际上这是我们在日常使用时比较常见的作法,例如只是简单对变量赋值,那就无需在