开发一个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中文文档--插件
相关推荐
JIngJaneIL2 小时前
基于Java非遗传承文化管理系统(源码+数据库+文档)
java·开发语言·数据库·vue.js·spring boot
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue心理健康管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
老华带你飞6 小时前
旅游|基于Java旅游信息系统(源码+数据库+文档)
java·开发语言·数据库·vue.js·spring boot·旅游
韭菜炒大葱6 小时前
别等了!用 Vue 3 让 AI 边想边说,字字蹦到你脸上
前端·vue.js·aigc
关关长语7 小时前
Vue本地部署包快速构建为Docker镜像
前端·vue.js·docker
一 乐8 小时前
高校评教|基于SpringBoot+vue高校学生评教系统 (源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·学习
爱分享的鱼鱼8 小时前
Vue生命周期钩子详解与实战应用
前端·vue.js
sosojie8 小时前
and+design的table前端本地分页处理
前端·vue.js
apollo_qwe8 小时前
Vue3 核心设计模式实战:5 种模式 + 可复用代码,覆盖 80% 开发场景
vue.js
前端老宋Running8 小时前
一种名为“Webpack 配置工程师”的已故职业—— Vite 与“零配置”的快乐
前端·vite·前端工程化