背景
公司测试的小伙伴需要进行自动化测试,发现我们的页面上没有类似于id的一些定位属性,无法精确的定位到对应的dom节点进行相应操作。于是希望我们能够在dom节点上添加一些用作定位的属性。
系统是用Vue3+Vite进行开发的,系统内页面还是很多的,手工一处一处添加无疑是个巨大工作量。于是想到了利用Vite插件在构建的过程中自动处理。
那么明确一下,我们的需求就是,开发一个Vite插件,给所有DOM节点插入一个定位属性data-id
,属性值的话用文件名称拼接上dom节点顺序来完成,保证唯一。
第一步,先来看一下Vite插件长啥样,怎么开发。
Vite插件开发
Vite插件一般结构
Rollup官网上对于插件的定义:
插件是一个对象,具有 属性、构建钩子 和 输出生成钩子 中的一个或多个,并遵循我们的 约定。插件应作为一个导出一个函数的包进行发布,该函数可以使用插件特定的选项进行调用并返回此类对象。
中译中就是:
插件需要导出一个函数,函数内返回一个对象,对象需要遵循约定,有一些特殊的属性和钩子。
钩子就是在Vite在构建过程中会调用的一些方法。有点类似于Vue的生命周期函数。
回想一下,我们使用插件都是把导入的插件当做函数直接调用,类似于下面这样。
js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vitePlugin from 'vite-plugin-feature'
export default defineConfig({
plugins: [
vue(),
vitePlugin(),
],
})
所以,插件的默认导出肯定是一个函数。
一个简单的插件就长下面这样:
js
// 自定义插件,默认导出一个函数
export default function myPlugin() {
// 返回一个对象
return {
// 插件名称
name: 'vite-plugin-my-plugin',
// 版本
version: '1.0',
// 钩子,可以用来转换单个模块
// 这里什么都没做,直接返回
transform(code, id) {
return code
},
}
}
命名约定
如果插件不使用 Vite 特有的钩子,可以作为 兼容 Rollup 的插件 来实现,推荐使用 Rollup 插件名称约定。
- Rollup 插件应该有一个带
rollup-plugin-
前缀、语义清晰的名称。 - 在 package.json 中包含
rollup-plugin
和vite-plugin
关键字。
这样,插件也可以用于纯 Rollup 或基于 WMR 的项目。
对于 Vite 专属的插件:
- Vite 插件应该有一个带
vite-plugin-
前缀、语义清晰的名称。 - 在 package.json 中包含
vite-plugin
关键字。 - 在插件文档增加一部分关于为什么本插件是一个 Vite 专属插件的详细说明(如,本插件使用了 Vite 特有的插件钩子)。
如果你的插件只适用于特定的框架,它的名字应该遵循以下前缀格式:
-
vite-plugin-vue-
前缀作为 Vue 插件 -
vite-plugin-react-
前缀作为 React 插件
插件顺序
一个 Vite 插件可以额外指定一个 enforce
属性(类似于 webpack 加载器)来调整它的应用顺序。enforce
的值可以是pre
或 post
。解析后的插件将按照以下顺序排列:
- Alias
- 带有
enforce: 'pre'
的用户插件 - Vite 核心插件
- 没有 enforce 值的用户插件
- Vite 构建用的插件
- 带有
enforce: 'post'
的用户插件 - Vite 后置构建插件(最小化,manifest,报告)
请注意,这与钩子的排序是分开的,钩子的顺序仍然会受到它们的 order
属性的影响,这一点 和 Rollup 钩子的表现一样。
常用属性&钩子
name
插件的名称,用于在日志和调试信息中标识插件。这通常是第一个属性
enforce
控制定插件的执行时机。可以是 'pre'
、'post'
,或默认。
"pre"
:插件将在内置转换插件之前执行。"post"
:插件将在内置转换插件之后执行。
js
export default function myPlugin() {
return {
name: 'my-plugin',
enforce: 'pre',
// other hooks
};
}
load
用于加载模块的内容,可以用于创建虚拟模块。
js
export default function myPlugin() {
return {
name: 'my-plugin',
load(id) {
if (id === 'virtual-module') {
return 'export default "This is virtual!"';
}
return null; // 处理不了,交给其他插件
}
};
}
transform
用于转换模块的内容。例如,你可以在这里进行代码的替换、编译等操作。
js
export default function myPlugin() {
return {
name: 'my-plugin',
transform(code, id) {
if (id.endsWith('.js')) {
return code.replace(/oldContent/g, 'newContent');
}
return null; // 处理不了,交给其他插件
}
};
}
generateBundle
在生成构建输出时触发,可以用于修改生成的文件或者添加额外的文件。
js
export default function myPlugin() {
return {
name: 'my-plugin',
generateBundle(options, bundle) {
// 修改构建输出
for (const fileName in bundle) {
const chunk = bundle[fileName];
if (chunk.type === 'asset') {
chunk.source = chunk.source.replace(/somePattern/g, 'replacement');
}
}
}
};
}
intro 和 outro
分别在生成的代码文件的开头和结尾添加代码。
js
export default function myPlugin() {
return {
name: 'my-plugin',
intro() {
return 'const intro = "This is intro code";';
},
outro() {
return 'const outro = "This is outro code";';
}
};
}
Vite 本身和Rollup提供了丰富的钩子,具体请查看官方文档(列在了最后参考文档处)。
情景应用
默认情况下插件在开发(dev)和构建(build)模式中都会调用。如果想要插件只在开发或者构建模式下运行可以指定 apply 属性。
js
// 自定义插件,默认导出一个函数
export default function myPlugin() {
// 返回一个对象
return {
// 插件名称
name: 'vite-plugin-my-plugin',
// 版本
version: '1.0',
apply: 'build' // 或 'serve'
// 钩子,可以用来转换单个模块
// 这里什么都没做,直接返回
transform(code, id) {
return code
},
}
}
本地使用
只需要在vite.config.js中引入插件文件,然后在plugins数组中调用即可。
js
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vitePluginMyPlugin from 'vite-plugin-my-plugin'
export default defineConfig({
plugins: [
vue(),
vitePluginMyPlugin(),
],
})
实现自定义属性插件vitePluginVueAddId
了解完Vite插件的一般开发流程之后,要开始实现自己需要的这个插件。
思路
自定义插件要放在插件位置的第一个,拿到原始文档数据。
然后将<template>
的内容从vue
文件中抠出来,解析成AST,在AST的基础上添加自定义属性data-id
,作为标识。
最后将AST又序列化成字符串替换掉原来<template>
的内容,接着走后续编译流程。
使用效果
全部代码
要对文件内容做处理,所以我选择了transform
钩子进行操作。
插件中利用 dom-serializer
库来进行AST的解析和序列化。
js
import * as HTMLParser from 'htmlparser2'
import render from 'dom-serializer'
/**
* 自动为每个元素插入id
*/
export default function vitePluginVueAddId() {
return {
name: 'vite-plugin-vue-addId',
version: '1.0',
// 只在构建模式下运行
apply: 'build',
transform(code, id) {
// 如果是node_modules里面的代码,直接返回
if (id.includes('/node_modules/')) {
return code
}
// 只处理.vue文件
if (id.endsWith('.vue')) {
try {
return formatVueTemplate(code, id)
} catch (e) {
// 如果出错了,打印消息,但不中断构建
console.error('vitePluginVueAddId error:', e.message)
return code
}
}
return code
},
}
}
/**
* 格式化vue模板
* @param code
* @param id 文件路径,形如:E:/cuavcloudweb/cuavcloudcusweb/node_modules/@arcgis/core/renderers/support/pointCloud/PointSizeAlgorithm.js
*
* 仓库地址:https://github.com/fb55/htmlparser2
*/
function formatVueTemplate(code, id) {
// 从template中间拿出html
const html = code.match(/<template>[\s\S]*</template>/)[0]
// 取出文件夹名称和文件名作为前缀
const list = id.split('/')
const idPrefix = list[list.length - 2] + '-' + list[list.length - 1].split('.')[0]
// 将html解析成ast
const root = HTMLParser.parseDocument(html, { lowerCaseTags: false, lowerCaseAttributeNames: false, recognizeSelfClosing: true })
// 为每个元素插入data-id
addAttr(root.children, idPrefix)
// 将ast转成html
const template = render(root, { emptyAttrs: false, encodeEntities: false })
// 替换原来的html
return code.replace(html, template)
}
/**
* 为每个元素插入data-id
* @param nodes
* @param parentId
*/
function addAttr(nodes, parentId) {
if (!Array.isArray(nodes) || !nodes.length) {
return
}
nodes.forEach((node, index) => {
// 元素节点
if (node.type === 'tag') {
// 排除自定义组件,自定义组件一般首字母大写
const firstLetter = node.name.charAt(0)
if (firstLetter !== firstLetter.toUpperCase() && node.name !== 'template' && node.name !== 'router-view') {
node.attribs['data-id'] = parentId + '-' + index
}
// 自定义组件下面slot可能有其它元素,递归处理
addAttr(node.children, parentId + '-' + index)
}
})
}
不足之处
- 开启后会对性能有所影响,所以在插件内部指定只会在build模式下运行。
- 类似于ElementUI第三方组件内部还没没法标记的。
- 文档DOM结构变动了,所生成的ID也会发生变动。
个人水平有限,如果有更好的想法,欢迎指导交流~