开发一个Vite插件,给所有DOM节点插入自定义属性

背景

公司测试的小伙伴需要进行自动化测试,发现我们的页面上没有类似于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-pluginvite-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 的值可以是prepost。解析后的插件将按照以下顺序排列:

  • 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)
    }
  })
}

不足之处

  1. 开启后会对性能有所影响,所以在插件内部指定只会在build模式下运行。
  2. 类似于ElementUI第三方组件内部还没没法标记的
  3. 文档DOM结构变动了,所生成的ID也会发生变动。

个人水平有限,如果有更好的想法,欢迎指导交流~

参考文档

  1. Vite官方文档:插件API
  2. Rollup中文文档--插件
相关推荐
ziyue757514 分钟前
vue修改element-ui的默认的class
前端·vue.js·ui
程序定小飞2 小时前
基于springboot的在线商城系统设计与开发
java·数据库·vue.js·spring boot·后端
BumBle2 小时前
uniapp 用css实现圆形进度条组件
前端·vue.js·uni-app
Komorebi_99993 小时前
Vue3 + TypeScript provide/inject 小白学习笔记
前端·javascript·vue.js
二十雨辰4 小时前
vite性能优化
前端·vue.js
明月与玄武4 小时前
浅谈 富文本编辑器
前端·javascript·vue.js
FuckPatience5 小时前
Vue 与.Net Core WebApi交互时路由初探
前端·javascript·vue.js
aklry5 小时前
elpis之学习总结
前端·vue.js
FuckPatience7 小时前
Vue ASP.Net Core WebApi 前后端传参
前端·javascript·vue.js
Komorebi_99997 小时前
Vue3 provide/inject 详细组件关系说明
前端·javascript·vue.js