<MyComponent :disabled="!valid" :data="someTestData" @confirm="handleConfirm" />它定义了 data 和 disabled 作为 props,前者作为组件的数据输入,后者用来定义组件的功能开关。组件被点击时,会抛出 confirm 事件,不过当 disabled 为 true 时,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) }) })valid 初始化时为 false,即 MyComponent 一开始不会抛出 confirm 事件,当valid 被改变后,点击 MyComponent,confirm 事件才被抛出。这段单元测试会在最后一句报错,显示 spy 实际被触发 0 次。实际上,spy 永远不会被触发,即使 valid 初始化时为 true 也是如此。然而,将模板里的方法调用调整一下,加上括号,单元测试就按照预期通过了:
<MyComponent :disabled="!valid" :data="someTestData" @confirm="handleConfirm()" />为什么加不加括号会引起单元测试的逻辑变化?
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
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
const isMemberExp = isMemberExpression(exp.content, context) const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content)) const hasMultipleStatements = exp.content.includes(`;`) if (isInlineStatement || (shouldCache && isMemberExp)) { // 堆代码 duidaima.com // 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,实际就是转换成了函数表达。看来 isMemberExp、isInlineStatement 这两个判断影响了最终 codegenNode 的生成。
const a = { x: 0 }const b = a.x这里 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; }这里 MemberExpression、OptionalMemberExpression、Identifier 都被认定成了 member expression。OptionalMemberExpression 即带有 optional chaining (?.) 的表达式。Identifier 也被包括的原因是,在模板中一般会省略主对象,如 this、或者 setup 中返回的对象。
<MyComponent :disabled="!valid" :data="someTestData" @confirm="hasConfirmed = $event" />而让这段代码生效的原因,就在于 transformOn 编译时将 exp 包裹了一层函数声明。它调用 createCompoundExpression,将 $event 作为函数入参,从而使函数内能获取到:
($event) => (hasConfirmed = $event)3、由上一步生成的 codegenNode,转换成最终的渲染函数。重点看一下带括号的表达式生成的渲染函数:
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"])) } }with statement 是在模板中可以省略 this 的原因。
const ctx = { handleConfirm: () => null }const prop = { onClick: ($event) => { ctx.handleConfirm() } }不带括号的函数表达:
const ctx = { handleConfirm: () => null }const prop = { onClick: ctx.handleConfirm }
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 执行原始函数。