开发一个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中文文档--插件
相关推荐
GIS程序媛—椰子5 分钟前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
毕业设计制作和分享1 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
程序媛小果1 小时前
基于java+SpringBoot+Vue的旅游管理系统设计与实现
java·vue.js·spring boot
从兄2 小时前
vue 使用docx-preview 预览替换文档内的特定变量
javascript·vue.js·ecmascript
凉辰2 小时前
设计模式 策略模式 场景Vue (技术提升)
vue.js·设计模式·策略模式
薛一半4 小时前
PC端查看历史消息,鼠标向上滚动加载数据时页面停留在上次查看的位置
前端·javascript·vue.js
MarcoPage4 小时前
第十九课 Vue组件中的方法
前端·javascript·vue.js
工业互联网专业4 小时前
Python毕业设计选题:基于Hadoop的租房数据分析系统的设计与实现
vue.js·hadoop·python·flask·毕业设计·源码·课程设计
你好龙卷风!!!5 小时前
vue3 怎么判断数据列是否包某一列名
前端·javascript·vue.js
Ljw...5 小时前
Vue.js组件开发
vue.js