Vite5.0 自定义插件实战

vite插件是基于rollup插件上面拓展而来的,如不了解可以看看如下两篇文章:

rollup插件机制文章的配图是旧版本的,与此时rollup官网上面配图有偏差,但不影响我们理解对应钩子功能。

下面来写一个vite插件,其功能如下:

  • 统计项目中用到文件类型(不包括node_modules)以及其数量
  • 统计项目中代码的行数

项目版本信息:

  • vue: ^3.3.4
  • vite: ^4.0.0
  • @vitejs/plugin-vue: ^4.0.0

大体结构

// root/src/summary.ts
// 一个可以往控制台输出添加颜色的库
import pc from 'picocolors'
const { green, blue, cyan } = pc

export function vitePluginSummary() {
    return {
    	name: 'vite-plugin-summary'
    }
}

// root/vite.config.ts
import { vitePluginSummary } from './src/summary.ts'
export default defineConfig({
  plugins: [
    vitePluginSummary()
  ]
})

具体钩子函数

写vite插件就是写对应的钩子函数,针对于本次插件使用的场景,这边使用如下几个钩子函数:

  • buildStart
  • load
  • transform
  • closeBundle

buildStart

rollup中打包开始时触发的钩子函数,这里我们在这个钩子函数中初始化变量。

export function vitePluginSummary() {
  // Map存储文件后缀名与之对应的文件名称集合Set
  const extnameMap = new Map<string, Set<string>>()

  // 统计代码行数
  let totalCodeLine = 0

	return {
    // ...省略
    buildStart() {
      extnameMap.clear()
      totalCodeLine = 0
    },
  }
}

load

rollup中读取具体文件触发的钩子函数,这里面我们可以得到文件路径,通过文件路径获取到文件拓展名,并收集计数。

这个钩子类型在rollup里面叫做First,在钩子容器中所有的load钩子挨个执行,如果某个钩子返回了不为null的的就停止,否则就一直执行。所以我们这边自定义的load中要返回一个null,不能影响其他load钩子函数的执行。

First类型的钩子执行原理简要实现:

// 生成钩子函数
function generateHook(timeout, val) {
  return () => new Promise(res => {
    setTimeout(() => {
      console.log(val, 'val')
      res(val)
    }, timeout)
  })
}

const hook1 = generateHook(1000, null)
const hook2 = generateHook(1000, 'not null')
const hook3 = generateHook(1000, 'hook2中返回值不为null,所以该钩子不会执行')

// first类型钩子 
async function triggerFirstHook(hookArr) { 
    for (let i = 0; i < hookArr.length; i++) { 
        let res = await hookArr[i]()
        // 返回结果不为null 就停止
        if (res !== null) {
            break
        }
    }
}

triggerFirstHook([hook1, hook2, hook3])

/** 
 * ==> 输出
 * null val
 * not null val
*/

load中代码如下:

// 使用 extname 获取字符串中的文件拓展名
import { extname } from 'path'

export function vitePluginSummary() {
  // ... 省略部分代码
  /** 解释下参数 importee
   * 例如:import xx from './src/moduleA'
   * importee 就是 './src/moduleA'
   */
  load(importee: string) {
    // 去除node_modules中的内容
    // x00这个好像是vite内置添加的内容 都不用考虑
    if (/node_modules|\x00/.test(importee)) return null

    /**
     * 文件名称中可能有请求参数,例如:/root/src/index.vue?type=script&setup=true
     * 去除文件名称中的参数得到:/root/src/index.vue
     * */
    const importeeRemoveQuery = importee.replace(/?.*$/, '')

    /**
     * 经过extname处理过的文件后缀名可能会有请求参数,例如:.vue?type=script&xxxx
     * 将  .vue?type=script&xxxx => .vue
     */
    const currentExtname = extname(importeeRemoveQuery).replace(/?.*$/, '')

    // 根据后缀名称存储对应的文件名称
    extnameMap.set(
      currentExtname,
      extnameMap.has(currentExtname)
        ? extnameMap.get(currentExtname)!.add(importeeRemoveQuery)
        : new Set<string>().add(importeeRemoveQuery)
    )
  
    return null
  }
}

来解释下上面的一行代码:

/**
 * 文件名称中可能有请求参数,例如:/root/src/index.vue?type=script&setup=true
 * 去除文件名称中的参数得到:/root/src/index.vue
 * */
const importeeRemoveQuery = importee.replace(/?.*$/, '')

这行代码中去除了importee中后面的请求信息,我们通过一个案例看看这样做的原因。

一个文件如root/src/App.vue,它的源码如下:

<script setup lang="ts">
const personObj = ref({
  name: 'yellres'
})  
</script>

<template>
  <div>{{ personObj.name }}</div>
</template>

<style scoped></style>

这个文件打包时候,会先触发load钩子,然后触发transform钩子,由于这是个vue文件,会触发@vitejs/plugin-vue中的transform钩子,@vitejs/plugin-vue会将App.vue转换为如下内容:

/* unplugin-vue-components disabled */
import _sfc_main from "root/src/App.vue?vue&type=script&setup=true&lang.ts";      
export * from "root/src/App.vue?vue&type=script&setup=true&lang.ts";
export default _sfc_main;

得到模块内容后会去解析模块内容,此时触发了moduleParsed钩子,这其中moduleParse又会去解析import路径,在没有命中缓存情况依次触发resolveIdloadtransform钩子。

如上面代码所示,rollup此时会去读取root/src/App.vue?vue&type=script&setup=true&lang.ts中的内容,此时的load钩子又被触发了。

root/src/App.vue?vue&type=script&setup=true&lang.tsroot/src/App.vue是同一个文件,它们的后缀名只要被统计一次即可。所以在load钩子触发的时候,默认去掉了文件地址的参数。

transform

rollup执行完load钩子后,下一步就会把load钩子中获取到的内容给transform钩子处理。

该钩子类型是Sequential,所有钩子挨个执行,上一个钩子的返回值作为参数传入下个钩子中。

Sequential类型的钩子执行原理简要实现:

async function triggerSequentialHook(hookArr) { 
  let result = null
  for (let i = 0; i < hookArr.length; i++) { 
      if (i === 0) {
          result = await hookArr[i]()
      } else { 
          result = await hookArr[i](result)
      }
  }
}

这个钩子中可以取得文件的源代码,我们统计项目的代码行数就可以在这边获取。

transform代码如下:

export function vitePluginSummary() {
  return {
    // ... 省略
    transform(code: string, id: string) {
      const cachedExtArr = [...extnameMap.values()].reduce((pre, cur) => {
        pre.push(...cur)
        return pre
      }, [])
  
      if (cachedExtArr.includes(id)) {
        totalCodeLine += (code.split('\n') || []).length
      }
  
      return code
    },
   enforce: 'pre'
  }
}

最后要给加 enforce: 'pre', 我们的transform钩子要在其他transform函数钩子执行前执行,防止内容被其他tranform钩子修改。

closeBundle

rollup中结束打包后触发的钩子,这边我们来输出最终的结果。

import { extname } from 'path'
import pc from 'picocolors'

const { green, blue, cyan } = pc

export function vitePluginSummary() {
  // ... 省略
  closeBundle() {
    for (const [key, extSet] of extnameMap.entries()) {
      console.log(`${green(key)}'s num is ${blue(extSet.size)}`)
    }
    console.log(`the total code line is ${cyan(totalCodeLine)}`)
  }
}

最终代码如下:

import { extname } from 'path'
import pc from 'picocolors'

const { green, blue, cyan } = pc

export function vitePluginSummary() {
  // Map存储文件后缀名与之对应的文件名称集合Set
  const extnameMap = new Map<string, Set<string>>()

  // 统计代码行数
  let totalCodeLine = 0

  return {
    name: 'vite-plugin-summary',
    buildStart() {
      extnameMap.clear()
      totalCodeLine = 0
    },

    load(importee: string) {
      if (/node_modules|\x00/.test(importee)) return null

      /**
       * 文件名称中可能有请求参数,例如:/root/src/index.vue?type=script&setup=true
       * 去除文件名称中的参数得到:/root/src/index.vue
       * */
      const importeeRemoveQuery = importee.replace(/?.*$/, '')
      // const importeeRemoveQuery = importee
      /**
       * 经过extname处理过的文件后缀名可能会有请求参数,例如:.vue?type=script&xxxx
       * 将  .vue?type=script&xxxx => .vue
       */
      const currentExtname = extname(importeeRemoveQuery).replace(/?.*$/, '')

      extnameMap.set(
        currentExtname,
        extnameMap.has(currentExtname)
          ? extnameMap.get(currentExtname)!.add(importeeRemoveQuery)
          : new Set<string>().add(importeeRemoveQuery)
      )

      return null
    },

    transform(code: string, id: string) {
      const cachedExtArr = [...extnameMap.values()].reduce((pre, cur) => {
        pre.push(...cur)
        return pre
      }, [])

      if (cachedExtArr.includes(id)) {
        totalCodeLine += (code.split('\n') || []).length
      }

      return code
    },

    closeBundle() {
      for (const [key, extSet] of extnameMap.entries()) {
        console.log(`${green(key)}'s num is ${blue(extSet.size)}`)
      }
      console.log(`the total code line is ${cyan(totalCodeLine)}`)
    },

    // transform 必须要设置pre
    enforce: 'pre'
  }
}

执行结果如下:

Vite5.0 自定义插件实战

本文只是提供想写个vite插件加深下对vite插件的理解,其中统计代码行数,以及文件数目只是适用于个人项目,由于不同项目复杂度不同,不是一个完备的解决方案。

本人才疏学浅,上面内容如果有错误,欢迎指出~~☺️

原文链接:https://juejin.cn/post/7325269225430204431 作者:Yellres

(0)
上一篇 2024年1月19日 上午10:47
下一篇 2024年1月19日 上午10:58

相关推荐

发表回复

登录后才能评论