vite自定义上传插件

vite官网自定义插件

Vite 插件扩展了设计出色的 Rollup 接口,带有一些 Vite 独有的配置项。因为vite是使用rollup打包的所以最好你能了解Rollup 插件文档

下面的都是按照官网介绍写的,因为毕竟你看到我这了,直接给你个链接再让你跳转到官网去看有点不负责任,如果你已经从官网看过了,并且对自定义插件很熟悉了,那么请直接到最下面看 项目实战部分

约定

如果插件不使用 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-plugin-svelte- 前缀作为 Svelte 插件

插件配置

用户会将插件添加到项目的 devDependencies 中并使用数组形式的 plugins 选项配置它们。

// vite.config.js
import vitePlugin from 'vite-plugin-feature'
import rollupPlugin from 'rollup-plugin-feature'

export default defineConfig({
  plugins: [vitePlugin(), rollupPlugin()]
})

假值的插件将被忽略,可以用来轻松地启用或停用插件。

plugins 也可以接受将多个插件作为单个元素的预设。这对于使用多个插件实现的复杂特性(如框架集成)很有用。该数组将在内部被扁平化(flatten)。

// 框架插件
import frameworkRefresh from 'vite-plugin-framework-refresh'
import frameworkDevtools from 'vite-plugin-framework-devtools'

export default function framework(config) {
  return [frameworkRefresh(config), frameworkDevTools(config)]
}
// vite.config.js
import { defineConfig } from 'vite'
import framework from 'vite-plugin-framework'

export default defineConfig({
  plugins: [framework()]
})

简单示例

TIP
通常的惯例是创建一个 Vite/Rollup 插件作为一个返回实际插件对象的工厂函数。该函数可以接受允许用户自定义插件行为的选项。

转换自定义文件类型

const fileRegex = /\.(my-file-ext)$/

export default function myPlugin() {
  return {
    name: 'transform-file',

    transform(src, id) {
      if (fileRegex.test(id)) {
        return {
          code: compileFileToJS(src),
          map: null // 如果可行将提供 source map
        }
      }
    }
  }
}

通用钩子

在开发中,Vite 开发服务器会创建一个插件容器来调用 Rollup 构建钩子,与 Rollup 如出一辙。

以下钩子在服务器启动时被调用:

  • options

  • buildStart
    以下钩子会在每个传入模块请求时被调用:

  • resolveId

  • load

  • transform
    以下钩子在服务器关闭时被调用:

  • buildEnd

  • closeBundle
    请注意 moduleParsed 钩子在开发中是 不会 被调用的,因为 Vite 为了性能会避免完整的 AST 解析。
    Output Generation Hooks(除了 closeBundle) 在开发中是 不会 被调用的。你可以认为 Vite 的开发服务器只调用了 rollup.rollup() 而没有调用 bundle.generate()。

Vite 独有钩子

Vite 插件也可以提供钩子来服务于特定的 Vite 目标。这些钩子会被 Rollup 忽略。

config

  • 类型: (config: UserConfig, env: { mode: string, command: string }) => UserConfig | null | void

  • 种类: async, sequential

在解析 Vite 配置前调用。钩子接收原始用户配置(命令行选项指定的会与配置文件合并)和一个描述配置环境的变量,包含正在使用的 mode 和 command。它可以返回一个将被深度合并到现有配置中的部分配置对象,或者直接改变配置(如果默认的合并不能达到预期的结果)。

示例:

// 返回部分配置(推荐)
const partialConfigPlugin = () => ({
  name: 'return-partial',
  config: () => ({
    alias: {
      foo: 'bar'
    }
  })
})

// 直接改变配置(应仅在合并不起作用时使用)
const mutateConfigPlugin = () => ({
  name: 'mutate-config',
  config(config, { command }) {
    if (command === 'build') {
      config.root = __dirname
    }
  }
})

注意
用户插件在运行这个钩子之前会被解析,因此在 config 钩子中注入其他插件不会有任何效果。

configResolved

  • 类型: (config: ResolvedConfig) => void | Promise

  • 种类: async, parallel

在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它也很有用。

示例:

const exmaplePlugin = () => {
  let config

  return {
    name: 'read-config',

    configResolved(resolvedConfig) {
      // 存储最终解析的配置
      config = resolvedConfig
    },

    // 在其他钩子中使用存储的配置
    transform(code, id) {
      if (config.command === 'serve') {
        // dev: 由开发服务器调用的插件
      } else {
        // build: 由 Rollup 调用的插件
      }
    }
  }
}

注意,在开发环境下,command 的值为 serve(在 CLI 中,vite 和 vite dev 是 vite serve 的别名)。

configureServer

  • 类型: (server: ViteDevServer) => (() => void) | void | Promise<(() => void) | void>

  • 种类: async, sequential

  • 此外请看 ViteDevServer

是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件:

const myPlugin = () => ({
  name: 'configure-server',
  configureServer(server) {
    server.middlewares.use((req, res, next) => {
      // 自定义请求处理...
    })
  }
})

注入后置中间件

configureServer 钩子将在内部中间件被安装前调用,所以自定义的中间件将会默认会比内部中间件早运行。如果你想注入一个在内部中间件 之后 运行的中间件,你可以从 configureServer 返回一个函数,将会在内部中间件安装后被调用:

const myPlugin = () => ({
  name: 'configure-server',
  configureServer(server) {
    // 返回一个在内部中间件安装后
    // 被调用的后置钩子
    return () => {
      server.middlewares.use((req, res, next) => {
        // 自定义请求处理...
      })
    }
  }
})

存储服务器访问

在某些情况下,其他插件钩子可能需要访问开发服务器实例(例如访问 websocket 服务器、文件系统监视程序或模块图)。这个钩子也可以用来存储服务器实例以供其他钩子访问:

const myPlugin = () => {
  let server
  return {
    name: 'configure-server',
    configureServer(_server) {
      server = _server
    },
    transform(code, id) {
      if (server) {
        // 使用 server...
      }
    }
  }
}

注意 configureServer 在运行生产版本时不会被调用,所以其他钩子需要防范它缺失。

transformIndexHtml

  • 类型: IndexHtmlTransformHook | { enforce?: 'pre' | 'post', transform: IndexHtmlTransformHook }

  • 种类: async, sequential

转换 index.html 的专用钩子。钩子接收当前的 HTML 字符串和转换上下文。上下文在开发期间暴露ViteDevServer实例,在构建期间暴露 Rollup 输出的包。
这个钩子可以是异步的,并且可以返回以下其中之一:

  • 经过转换的 HTML 字符串
  • 注入到现有 HTML 中的标签描述符对象数组({ tag, attrs, children })。每个标签也可以指定它应该被注入到哪里(默认是在 之前)
  • 一个包含 { html, tags } 的对象

基础示例:

const htmlPlugin = () => {
  return {
    name: 'html-transform',
    transformIndexHtml(html) {
      return html.replace(
        /<title>(.*?)<\/title>/,
        `<title>Title replaced!</title>`
      )
    }
  }
}

handleHotUpdate

  • 类型: (ctx: HmrContext) => Array | void | Promise<Array | void>

执行自定义 HMR 更新处理。钩子接收一个带有以下签名的上下文对象:

interface HmrContext {
  file: string
  timestamp: number
  modules: Array<ModuleNode>
  read: () => string | Promise<string>
  server: ViteDevServer
}
  • modules 是受更改文件影响的模块数组。它是一个数组,因为单个文件可能映射到多个服务模块(例如 Vue 单文件组件)。

  • read 这是一个异步读函数,它返回文件的内容。之所以这样做,是因为在某些系统上,文件更改的回调函数可能会在编辑器完成文件更新之前过快地触发,并 fs.readFile 直接会返回空内容。传入的 read 函数规范了这种行为。

钩子可以选择:

  • 过滤和缩小受影响的模块列表,使 HMR 更准确。

  • 返回一个空数组,并通过向客户端发送自定义事件来执行完整的自定义 HMR 处理:

handleHotUpdate({ server }) {
  server.ws.send({
    type: 'custom',
    event: 'special-update',
    data: {}
  })
  return []
}

客户端代码应该使用 HMR API 注册相应的处理器(这应该被相同插件的 transform 钩子注入):

if (import.meta.hot) {
  import.meta.hot.on('special-update', (data) => {
    // 执行自定义更新
  })
}

插件顺序

一个 Vite 插件可以额外指定一个 enforce 属性(类似于 webpack 加载器)来调整它的应用顺序。enforce 的值可以是pre 或 post。解析后的插件将按照以下顺序排列:

  • Alias
  • 带有 enforce: 'pre' 的用户插件
  • Vite 核心插件
  • 没有 enforce 值的用户插件
  • Vite 构建用的插件
  • 带有 enforce: 'post' 的用户插件
  • Vite 后置构建插件(最小化,manifest,报告)

情景应用

默认情况下插件在开发(serve)和构建(build)模式中都会调用。如果插件只需要在预览或构建期间有条件地应用,请使用 apply 属性指明它们仅在 'build' 或 'serve' 模式时调用:

function myPlugin() {
  return {
    name: 'build-only',
    apply: 'build' // 或 'serve'
  }
}

同时,还可以使用函数来进行更精准的控制:

apply(config, { command }) {
  // 非 SSR 情况下的 build
  return command === 'build' && !config.build.ssr
}

Rollup 插件兼容性

相当数量的 Rollup 插件将直接作为 Vite 插件工作(例如:@rollup/plugin-alias 或 @rollup/plugin-json),但并不是所有的,因为有些插件钩子在非构建式的开发服务器上下文中没有意义。

一般来说,只要 Rollup 插件符合以下标准,它就应该像 Vite 插件一样工作:

  • 没有使用 moduleParsed 钩子。
  • 它在打包钩子和输出钩子之间没有很强的耦合。

如果一个 Rollup 插件只在构建阶段有意义,则在 build.rollupOptions.plugins 下指定即可。

你也可以用 Vite 独有的属性来扩展现有的 Rollup 插件:

// vite.config.js
import example from 'rollup-plugin-example'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    {
      ...example(),
      enforce: 'post',
      apply: 'build'
    }
  ]
})

查看 Vite Rollup 插件 获取兼容的官方 Rollup 插件列表及其使用指南。

项目实战

先说下我们要做什么,我们用vite打包完之后需要将打包出来的静态资源上传到cdn上,但现有的插件不够灵活,比如vite-plugin-assets-uploader
这个很好,但是需要将oss的密钥等信息写在前端,不够安全。对此我们就用node写了个接口,前端只需要调用node接口就能够上传到oss。而且将代码上传到cdn之后,我们的base路径也要改成cdn的路径。

自定义上传插件代码如下:

/* eslint-disable no-prototype-builtins */
/* eslint-disable no-self-assign */
const path = require('path')
const fs = require('fs-extra')
const request = require('request')
const glob = require('glob')
import { Ioptions } from '../type'
import 'colors'
const packageInfo: any = require(path.resolve('package.json'))
class FileUploader {
  bucket?: string
  uploadUrl: string
  domain: string
  env: string
  prefix?: string
  constructor({ bucket, prefix, env }: { bucket?: string; prefix?: string; env: string }) {
    this.env = env
    if (!env) {
      new Error('请传入env')
      return
    }
    this.bucket = bucket || '默认bucket'
    this.prefix = prefix || `${packageInfo.name}/${this.env}/${packageInfo.version}/`
    this.uploadUrl = '接口域名'
    this.domain = 'domain'
  }
  // 获取base
  getBase() {
    return `${this.domain}/${this.prefix}`
  }
  // vite上传插件
  UploadPlugin() {
    const options: Ioptions = {
      assetsDir: '',
      mode: '',
      outDir: ''
    }
    const that = this
    return {
      name: 'vite-plugin-upload',
      configResolved: function(config: any) {
        const outDir = config.build.hasOwnProperty('outDir') ? config.build.outDir : 'dist'
        const outDirArr = outDir.split(path.sep)
        options.outDir = outDirArr[outDirArr.length - 1]
        options.assetsDir = `${options.outDir}/${config.build.assetsDir}`
        options.mode = config.mode
      },
      closeBundle() {
        const files = glob.sync(`${options.assetsDir}/*`)
        files.map((item: any) => {
          that.uploadPre(path.relative(options.outDir, path.resolve(item)), options.outDir)
        })
      }
    }
  }
  // init判断是否支持此bucket
  init() {
    return new Promise((resolve, reject) => {
      request.get(
        { url: this.uploadUrl + '/api/bucketList' },
        (err: any, response: { statusCode: any }, body: string) => {
          const { code, data } = typeof body === 'object' ? body : JSON.parse(body)
          if (err) {
            reject(false)
          }
          if (code !== 0) {
            reject(false)
          } else {
            let cdnInfo = new Map([])
            data.forEach((v: any) => {
              cdnInfo.set(v.bucket, v.url)
            })
            if (!cdnInfo.has(this.bucket)) {
              throw new Error(`目前仅支持以下bucket${data.map((v: any) => v.bucket).join(' | ')}`)
            } else {
              this.domain = cdnInfo.get(this.bucket) as string
              resolve(this.getBase())
            }
          }
        }
      )
    })
  }
  // 检查是否存在该文件
  isExistObject(filePath: string) {
    return new Promise(reslove => {
      request.get(
        {
          url: `${this.uploadUrl}/api/isExistObject?name=${this.prefix + filePath}&bucket=${
            this.bucket
          }`
        },
        (err: any, response: any, body: string) => {
          if (err) {
            console.log('err', err)
            process.exit(1)
          }
          if (response.statusCode !== 200) {
            console.log('err', 'http err')
            process.exit(1)
          }
          const bodys = JSON.parse(body)
          if (bodys.code === 0) {
            console.log(this.prefix + filePath, '已存在'.yellow)
            reslove(0)
          } else {
            reslove(1)
          }
        }
      )
    })
  }
  // 上传
  upload(filePath: string, outDir: string) {
    return new Promise(reslove => {
      const formData = {
        key: this.prefix + filePath,
        file: fs.createReadStream(path.resolve(path.join(outDir, filePath))),
        bucket: this.bucket
      }
      request.post(
        {
          url: `${this.uploadUrl}/api/upload`,
          formData
        },
        (err: any, response: any, body: string) => {
          if (err) {
            console.log('err', err)
            process.exit(1)
          }
          if (response.statusCode !== 200) {
            console.log('err', 'http err')
            process.exit(1)
          }
          const bodys = JSON.parse(body)
          if (bodys.code === 0) {
            console.log(bodys.data.fixUrl, 'success~'.green)
            reslove(1)
          }
        }
      )
    })
  }
  // 上传前检测
  uploadPre(filePath: string, outDir: string) {
    this.isExistObject(filePath).then((res: number) => {
      if (res === 1) {
        return this.upload(filePath, outDir)
      }
    })
  }
}
export default FileUploader

我们将它封装到前端组件库中,然后在vite中使用

// vite.config.js
import { FileUploader } from '@msb-next/vite-plugin-upload'
const isProd = process.env.VITE_APP_ENV && process.env.VITE_APP_ENV !== 'dev'
  let plugins = [
    reactRefresh(),
    antdDayjs(),
    vitePluginImp({
      libList: [
        {
          libName: 'lodash',
          libDirectory: '',
          camel2DashComponentName: false,
          style: () => {
            return false
          },
        },
        {
          libName: 'antd',
          style(name) {
            return `antd/es/${name}/style/index.css`
          },
        },
      ],
    }),
  ]
  let base = '/'
  let sourcemap = true
  //////////////////////////// 主要是这里 /////////////////////////////////////
  if (isProd) {
    const uploader = new FileUploader({
      env: process.env.VITE_APP_ENV,
    })
    // 修改base路径
    base = (await uploader.init()) as string
    plugins.push(uploader.UploadPlugin())
    sourcemap = false
  }

到这里,其实思路就很清晰了,大家基本能够完成一个自定义vite插件了,下面就是我node层的代码了,大家应该能看明白:
先自定义一个koa中间件

const OSS = require('ali-oss');
/**
 * oss 连接oss
 * @param ctx
 * @param next
 * @returns {Promise<void>}
 */
const connect = async (ctx, next) => {
  let bucket = '';
  if (ctx.request.method === 'POST') {
    const body = ctx.request.body;
    bucket = body.bucket;
  } else {
    bucket = ctx.query.bucket;
  }
  // 从redis中获取oss密钥等重要信息,看看是否支持当前的bucket,这样新增bucket时比较灵活,当然也可以直接写死。
  const ossAccessRes = await redisClient.get('ossAccess');
  const ossAccess = JSON.parse(ossAccessRes);

  const ossConf = ossAccess.find(v => v.bucket === bucket);
  let client, urlPrefix;
  try {
    client = new OSS(ossConf);
    urlPrefix = ossConf.url;
  } catch (e) {
    ctx.DATA.msg = '暂不支持当前bucket';
    ctx.body = ctx.DATA;
    throw new HttpError(200);
  }
  ctx.OSS = client;
  ctx.urlPrefix = urlPrefix;
  await next();
};

module.exports = {
  connect
};

router.js

const router = require('koa-router')();

// 刚才定义的 oss 中间件
const oss = require('../utils/middleware/oss');

// 控制器
const {index, bucketList} = require('../controllers/index');
const api = require('../controllers/api');

// 添加路由
router.post('/api/upload', oss.connect, api.upload);
router.get('/api/list', oss.connect, api.list);
router.get('/api/url', oss.connect, api.url);
router.post('/api/del', oss.connect, api.del);
router.get('/api/isExistObject', oss.connect, api.isExistObject);
router.get('/api/bucketList', bucketList);

module.exports = router;

controller/api.js

const fs = require('fs');
const {CustomError} = require('../utils/tool/error');
const DEL_SWITCH = false;

/**
 * 上传
 */
const upload = async (ctx) => {
  const {file} = ctx.request.files;
  const {key} = ctx.request.body;
  const stream = fs.createReadStream(file.path);
  const {name, res, url} = await ctx.OSS.putStream(key, stream);
  ctx.DATA.data = {
    ...res,
    name,
    url,
    fixUrl: `${ctx.urlPrefix}/${name}`
  };
  ctx.body = ctx.DATA;
};

/**
 * 获取oss列表
 */
const list = async (ctx, next) => {
  const name = ctx.query.name;
  console.log(name);
  ctx.DATA.data = await ctx.OSS.list({
    prefix: name,
    delimiter: '/'
  });
  if (ctx.DATA.data.res.status !== 200) {
    throw new CustomError(0, '文件查找失败');
  }
  delete ctx.DATA.data.res;
  ctx.body = ctx.DATA;
};

/**
 * 获取文件下载链接,有效期1分钟
 */
const url = async (ctx) => {
  const name = ctx.query.name;
  ctx.DATA.data = ctx.OSS.signatureUrl(name, {expires: 3600});
  ctx.body = ctx.DATA;
};

/**
 * 删除文件、文件夹
 */
const del = async (ctx) => {
  if (DEL_SWITCH) {
    let dat = ctx.request.body;
    let delList = dat[0];
    let directory = dat[1];
    try {
      for (let i = 0; i < directory.length; i++) {
        let retList = await ctx.OSS.list({
          prefix: directory[i]
        });
        retList.objects.reverse();
        retList.objects.map(item => {
          delList.push(item.name);
        });
      }
    } catch (e) {
      throw new CustomError(0, '删除文件:整合失败');
    }
    let result = await ctx.OSS.deleteMulti(delList, {quiet: true});
    if (result.res.status !== 200) {
      throw new CustomError(0, '删除失败');
    }
    ctx.DATA.msg = '删除成功';
    ctx.body = ctx.DATA;
  } else {
    ctx.DATA.msg = '删除文件、文件夹功能未开放!';
    ctx.body = ctx.DATA;
  }
};

/**
 * 判断文件是否存在
 * @param ctx
 * @returns {Promise<void>}
 */
const isExistObject = async (ctx) => {
  // 用于判断受版本控制Bucket中指定versionId的Object是否存在。
  const options = {
    // 填写Object的versionId。
    versionId: ''
  };
  const name = ctx.query.name;
  try {
    const res = await ctx.OSS.head(name, options);
    ctx.DATA.data = res;
    ctx.DATA.msg = '文件存在';
    ctx.body = ctx.DATA;
  }  catch (error) {
    if (error.code === 'NoSuchKey') {
      ctx.DATA.code = 1;
      ctx.DATA.msg = '文件不存在';
      ctx.body = ctx.DATA;
    }
  }
};

module.exports = {
  upload,
  list,
  url,
  del,
  isExistObject,
};