背景

  • 为了给TA提供可靠的,获取 Element 的标识,打算在项目中给特定 Element 上打上语意化的 data-test-id.
  • 对于HTML Element,直接设置即可;难点在于如何设置依赖的组件库内的 Element.
  • Vue 版本是 2.7.16

第一反应

最简单的做法就是提供一个自定义指令,在运行时进行处理。由于自定义指令可以拿到 Dom 节点,通过 querySelector 找到目标元素,指定 data-test-id 是非常简单的。

问题:由于是为了TA,可以预见会大量使用,在 Dom 树内寻找元素可以预见会对性能造成影响,该方案被弃用。

发展:有同事提出,也许可以在编译时进行处理

编译时处理的探索

vue-loader 提供了API,可以对 AST 节点进行改造。这些 API 是:

  • preTransformNode
  • transformNode
  • postTransformNode

我们可以在这个阶段,将 data-test-id 直接注入 attrs.

为了避免外部影响,使用 vue-cli 创建项目,进行测试。

Demo 结果

核心改动(伪码):

// vue.config.js module.exports = defineConfig({ transpileDependencies: true, chainWebpack: (config) => { config.module.rule('vue').use('vue-loader') .tap(options => { options.compilerOptions = { ...(options.compilerOptions || {}), modules: [ { transformNode(node) { if (needAddTestId(node)) { let value = node.attrsMap['test-id'] const { className, testId } = parseValue(value) setTestId(node, className, testId) deleteUselessAttr() } return node function setTestId(node, className, testId) { node.children.forEach(n => { if (getClassList(node).find(c => c === className)) { n.attrs.push({name: 'data-test-id', value: `"${testId}"`}) // 这里必须加上 "",否则会作为表达式运行,导致报错 } else { node.children.forEach(c => setTestId(c, option)) } }) } function needAddTestId(node) { // ... } function deleteUselessAttr(node) { //... } function getClassList(node) { // ... } } } ] } return options }) } })

测试代码:

// vue.app <template> <div id="app" test-id='{"className":"hello", "testId":"test-id"}'> <img alt="Vue logo" src="./assets/logo.png"> <HelloWorld msg="Welcome to Your Vue.js App"/> </div> </template>

经过测试,这是可行的。于是,理所当然进行下一步的工作,也就是在项目种集成。

这里需要注意,使用的 hook 是 transformNode. 如果使用 preTransformNode, AST 树还没有生成,children 树为空。

这里依然有一个遍历树节点的过程。然而,这只会影响编译时速度,是可以接受的。

// AST Node(删除部分 start 和 end) { type: 1, tag: 'div', attrsList: [ { name: 'id', value: 'app', start: 5, end: 13 }, { name: 'test-id', value: '{"className":"hello", "testId":"test-id"}', } ], attrsMap: { id: 'app', 'test-id': '{"className":"hello", "testId":"test-id"}', class: 'test-1 test-2' }, rawAttrsMap: { id: { name: 'id', value: 'app', start: 5, end: 13 }, 'test-id': { name: 'test-id', value: '{"className":"hello", "testId":"test-id"}', }, class: { name: 'class', value: 'test-1 test-2', start: 66, end: 87 } }, parent: undefined, children: [ { type: 1, tag: 'img', attrsList: [Array], attrsMap: [Object], rawAttrsMap: [Object], parent: [Circular *1], children: [], plain: false, attrs: [Array] }, { type: 1, tag: 'HelloWorld', attrsList: [Array], attrsMap: [Object], rawAttrsMap: [Object], parent: [Circular *1], children: [], plain: false, attrs: [Array] } ], plain: false, staticClass: '"test-1 test-2"' }

在项目中使用遇到的困境

修改 transformNode 非常简单,没有遇到任何困难。然而,在组件库上进行测试,发现不生效。

打印 node,发现 children 为空数组。

实际项目中,打印出来的 Node:

{ type: 1, tag: 'date-picker', attrsList: [ { name: 'v-model', value: 'timer', start: 1318, end: 1341 }, { name: 'test-id', value: '{"className":"picker-panel__link-btn", "testId":"test-id"}', }, { name: 'popper-class', value: 'datetime_popper', }, { name: 'type', value: 'datetimerange', start: 1520, end: 1548 }, { name: 'range-separator', value: '-', start: 1582, end: 1609 }, { name: 'align', value: 'left', start: 1862, end: 1882 }, { name: '@change', value: 'fetchList(1)', start: 1883, end: 1913 } ], attrsMap: { 'v-model': 'timer', 'test-id': '{"className":"picker-panel__link-btn", "testId":"test-id"}', 'popper-class': 'datetime_popper', 'range-separator': '-', align: 'left', '@change': 'fetchList(1)' }, rawAttrsMap: { // ... }, parent: { type: 1, tag: 'div', attrsList: [ [Object], [Object] ], attrsMap: { ... }, rawAttrsMap: { ... }, parent: { // ... }, children: [ [Object], [Object] ], }, children: [], // <- Why? plain: false, staticClass: '"search-datetime"' }

在这里,我开始思考一个问题:通过编译时注入 test-data-id 是可行的吗?

关于在编译时处理的可行性的思考

首先,需要厘清,Vue 在编译时做了什么?

编译阶段,我们所做的事情,实际上是编译 template 字符串。在这个过程中我们确实会生成 AST 树,但是,最终结果实际上是把一个个 component 的 template 生成 render 函数。实际上,我们完全可以手写 render 函数。换句话说,能够在通过编译阶段处理的事情,我们通过手写 render 应该也可以处理。

相对的,从入口处理 Vue component,生成 VNode tree,实际上是运行时做的事情。最直观的:v-if 和 v-for 是在运行时处理的。

现在来思考一个问题:如果 test-data-id 加在了一个有 v-for 的值上,我们应该怎么处理?答案是无法处理。在编译阶段不可能知道某个组件是否应该渲染,需要渲染多少。

再换个一个问题,假设我们要给每一个 el-button 添加 data-test-id,这是否可以在编译时处理?我的理解是不行。证明步骤如下:

  1. el-button 的 template 只会编译一次。最终结果应该形如: h('div', {staticClass:"el-button"}, ...)
  2. 相对的,每个使用到 el-button 的地方,都会被编译为:h('el-button, …)
  3. 在编译时处理,只有两种可能:加在调用方或者加在组件本身
  4. 加在组件本身的情况下,由于只存在一个编译函数,所以无法实现
  5. 加在调用方的情况下,无法干涉到子组件。同时,由于子组件的 template 只会编译出一个 render 函数,所以根据不同的场景,给对应的 Element 加上 data-test-id 是不可行的。
  6. 综上,这个问题无法在编译时解决。

deepseek 关于该问题的论述:

组件的AST在父组件的编译阶段并不包含子组件的内部结构。例如,父组件使用<MyComponent>,而MyComponent的模板是独立的,编译父组件时,MyComponent的子元素可能只是作为一个组件节点,没有展开其内部DOM结构。因此,在父组件的AST转换中无法直接访问子组件的内部DOM节点。

因此,正确的做法应该是在每个组件的编译过程中,检查其自身的模板,看是否有元素匹配需要添加属性的选择器,或者是否有特定的指令需要处理。也就是说,转换应该应用于每个组件自身的模板,而不是在父组件中处理子组件的内部结构。这可能意味着,用户的自定义指令需要在定义该指令的组件内部生效,而不是在父组件中应用到子组件上。

或者,用户可能希望在使用组件的地方(父组件)添加一个指令,指定在该子组件内部的某个选择器添加属性。此时,问题转化为:在父组件编译时,如何修改子组件的模板,这在编译时是不可能的,因为子组件的模板已经独立编译。

关于在运行时处理的思考

由于在编译时处理已经被证明不可行,进一步考虑是否可以在运行时处理。运行时方案被否定的主要原因是,遍历节点树会导致性能消耗。如果我们不遍历节点树,而是在 Vue 生成 VNode 树的时候进行处理,那么性能开销可能是可以接受的。

根据 DeepSeek 的回复:

Vue3引入了自定义渲染器API,允许更灵活地修改渲染过程,而Vue2的渲染器是封闭的,没有官方支持的扩展方式。

在 Vue2 当中,进行类似的处理似乎需要修改原型中的 _c 方法。虽然修改的逻辑比较简单,但是对于项目的风险恐怕难以接受。

// powered by deepseek // patch-vue2-render.js import Vue from 'vue' import { VNodeSelector } from './selector-helper' const originalVNode = Vue.prototype._c Vue.prototype._c = function () { const vnode = originalVNode.apply(this, arguments) if (vnode && vnode.tag) { const matchedRules = VNodeSelector.match( vnode.tag, vnode.data || {} ) matchedRules.forEach(rule => { vnode.data = vnode.data || {} vnode.data.attrs = { ...(vnode.data.attrs || {}), [rule.attr]: '' } }) } return vnode }

最终结论

这个问题的开端是,为了避免遍历Element,希望在编译时写入 data-test-id,并且已经通过 vue-loader 的 API 做出了一些尝试。 然而,一旦意识到到每个 template 只会编译一次,vnode tree 是在运行时生成的,就会发现这个问题无法在编译时解决。如果尝试运行时解决,则因为风险和开销等因素难以接受。最终,还是需要项目和TA双方一起解决才能最低成本解决问题。

同时,可以发现这个结论是可以通过思考得出的,却浪费了很多时间。诚然,我现在这么说有马后炮之嫌,我会没有看出这里的问题,是因为自己对 vue 编译时和运行时的理解不深。但是,从中依然可以吸取一些教训。我个人是半天讨论不如实际编码的类型,但是在有些时候,思考比直接编码来得有用。在大方向问题上尤为如此。然而,这一结论建立在思考者本身水平足够的基础上。如果水平不足,思考100年,最终也只得到一个错误的结论,尚不如直接开干。知道不可行亦是一种收货。