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

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

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

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

js 复制代码
<MyComponent
  :disabled="!valid"
  :data="someTestData"
  @confirm="handleConfirm"
/>

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

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

js 复制代码
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)
  })
})

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

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

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

js 复制代码
<MyComponent
  :disabled="!valid"
  :data="someTestData"
  @confirm="handleConfirm()"
/>

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

模板语法

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

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

js 复制代码
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. 调用baseParse方法解析 HTML,生成基础的 AST。由于 Vue 在 HTML 上增加了许多语法特性(v-if、v-for、v-bind 等等),需要做对应解析。
<div @click="handleConfirm()" /> 生成的 AST <div @click="handleConfirm" /> 生成的 AST

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

  1. 进一步对 AST 做解析和转换。这一步引入了nodeTransformsdirectiveTransforms对象,其实是在./transforms目录下的一系列函数:
js 复制代码
    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
        }
      ]
    }

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

<div @click="handleConfirm()" />codegenNode <div @click="handleConfirm" />codegenNode

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

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

js 复制代码
    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 ? `}` : `)`,
      ]);
    }

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

Member Expression

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

css 复制代码
const a = { x: 0 }
const b = a.x

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

js 复制代码
    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;
    }

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

<div @click="handleConfirm" />handleConfirm就是 Identifier,它指向的就是我们在 script 中定义的函数。

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

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

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

js 复制代码
    <MyComponent
      :disabled="!valid"
      :data="someTestData"
      @confirm="hasConfirmed = $event"
    />

而让这段代码生效的原因,就在于transformOn编译时将exp包裹了一层函数声明。它调用createCompoundExpression,将$event 作为函数入参,从而使函数内能获取到:

ini 复制代码
($event) => (hasConfirmed = $event)
  1. 由上一步生成的codegenNode,转换成最终的渲染函数。重点看一下带括号的表达式生成的渲染函数:
js 复制代码
        const _Vue = Vue

        return function render(_ctx, _cache) {
          with (_ctx) {
            const { openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue

            return (_openBlock(), _createElementBlock("div", {
              onClick: $event => (handleConfirm())
            }, null, 8 /* PROPS */, ["onClick"]))
          }
        }
csharp 复制代码
with statement 是在模板中可以省略 this 的原因。

对比

将以上分析做一个总结,我们可以将编译后结果简化一下,那么带括号的函数表达:

js 复制代码
    const ctx = { handleConfirm: () => null }
    const prop = { onClick: ($event) => { ctx.handleConfirm() } }

不带括号的函数表达:

js 复制代码
    const ctx = { handleConfirm: () => null }
    const prop = { onClick: ctx.handleConfirm }

Mock Function

我们已经搞清楚在编译阶段,带不带括号的函数表达有什么区别。接下来就要研究这个区别对于 Mock 行为产生了什么影响。Vitest 内部利用 tinyspy 来实现 mock 功能,本文并不会深入 tinyspy 的具体实现,因为 JavaScript spy 库大同小异,而背后的 JavaScript 语言特性才是本文真正想分享的。spy 函数的基本功能就是提供对目标函数的监视,例如执行次数,出入参等。一个函数在声明后,JavaScript 无法让我们二次修改它的内容,因此通常来说 spy 库会将原本函数的引用指向新的实现。一个简单的 spy 函数可以是这样:

js 复制代码
    function spyOn(obj, method) {
      let spy = {
        args: [],
      };

      let original = obj[method];
      obj[method] = function () {
        let args = [].slice.apply(arguments);
        spy.count++;
        spy.args.push(args);
        return original.call(obj, args);
      };

      return Object.freeze(spy);
    }

它将object[method]指向了新的函数,首先更新函数执行的次数、记录每次执行的入参,然后用call执行原始函数。

对应到本文的例子中,当我们声明const spy = vi.spyOn(wrapper.vm, 'handleConfirm')后,wrapper.vm.handleConfirm就被指向了 spy 生成的新函数,这个改动针对的是 Vue 实例对象,而我们由模板编译生成的渲染函数仍保持不变。因此const prop = { onClick: ctx.handleConfirm }onClick仍指向原始函数的引用,无论 handleConfirm 之后怎么改变,其在渲染函数生成后就从始至终不变了。而const prop = { onClick: ($event) => { ctx.handleConfirm() } }ctx.handleConfirm()会在点击回调触发后解析,此时就会指向spyOn定义的新函数了。

总结

当搞清楚模板语法生成事件回调的逻辑后,我们就会发现这其实是一个经典的对象引用指向的问题。受限于 JavaScript 语言特性,mock 行为实际上创建了一个新的函数,而上下文若仍保持着对原函数的引用,那 mock 行为不会按照预期运行也就可以理解了。

当你想要测试组件是否正确地 emit,也许应该尝试@vue/test-utils中的emitted()方法。或者将视角拉得更高,从最终页面呈现的内容来判断。

关于 OpenTiny

OpenTiny 是一套企业级 Web 前端开发解决方案,提供跨端、跨框架、跨版本的 TinyVue 组件库,包含基于 Angular+TypeScript 的 TinyNG 组件库,拥有灵活扩展的低代码引擎 TinyEngine,具备主题配置系统TinyTheme / 中后台模板TinyPro/ TinyCLI命令行等丰富的效率提升工具,可帮助开发者高效开发 Web 应用。


欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~更多视频内容也可关注B站、抖音、小红书、视频号

OpenTiny 也在持续招募贡献者,欢迎一起共建

OpenTiny 官网opentiny.design/

OpenTiny 代码仓库github.com/opentiny/

TinyVue 源码github.com/opentiny/ti...

TinyEngine 源码github.com/opentiny/ti...

欢迎进入代码仓库 Star🌟TinyEngineTinyVueTinyNGTinyCLI~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60615 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角6 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒9 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端