【vite插件】动手实现一个动态publicPath插件,类似__webpack_public_path__

4,135 阅读3分钟

image.png

publicPath是什么?动态publicPath又是什么

publicPath

publicPath是作为一共公共基础路径配置项在使用,它决定了资源文件的基础路径,如下

module.exports = {
  //...
  output: {
    // One of the below
    publicPath: 'auto', // It automatically determines the public path from either `import.meta.url`, `document.currentScript`, `<script />` or `self.location`.
    publicPath: 'https://cdn.example.com/assets/', // CDN(总是 HTTPS 协议)
    publicPath: '//cdn.example.com/assets/', // CDN(协议相同)
    publicPath: '/assets/', // 相对于服务(server-relative)
    publicPath: 'assets/', // 相对于 HTML 页面
    publicPath: '../assets/', // 相对于 HTML 页面
    publicPath: '', // 相对于 HTML 页面(目录相同)
  },
};

由于该配置项只支持string,在编译时候会和assetsDir及资源文件名拼接成一个字符串地址

动态publicPath

动态publicPath那就是打包后还能通过赋值方式改变的publicPath,webpack内置了__webpack_public_path__这个动态变量用于处理动态资源加载的问题(vue-cli可以通过webpack-dynamic-public-path插件)

为什么要做动态publicPath这么一个vite插件

vite目前暂是没有支持类似__webpack_public_path__配置项,社区插件库中的插件仅处理了preload相关部分的功能,类似图片等资源未处理,无法满足生产使用,所以就做一个。

怎么做一个vite插件

在vite中,用base配置项作为公共基础路径

找问题点

首先准备一个demo,主要路由动态加载(css/js)以及类似img这种src加载的资源文件。编译后的index.hash.js中与资源文件加载相关代码如下

// 图片资源
var b = '/assets/logo.03d6d6da.png'
// 创建虚拟dom
const N = t('img', { alt: 'Vue logo', src: b }, null, -1)

// 路由
const U = [
    {
      path: '/',
      component: () => _(() => import('./Home.3eb23e3e.js'), ['assets/Home.3eb23e3e.js', 'assets/vendor.49a43a24.js'])
    },
    {
      path: '/about',
      component: () =>
        _(
          () => import('./About.e7930246.js'),
          ['assets/About.e7930246.js', 'assets/About.cf1872ae.css', 'assets/vendor.49a43a24.js']
        )
    },
    { path: '/basic', component: Q }
  ]
  
// preload函数
const w = 'modulepreload',
  y = {},
  x = '/', // base
  u = function (s, a) {
    return !a || a.length === 0
      ? s()
      : Promise.all(
          a.map(o => {
            if (((o = `${x}${o}`), o in y)) return
            y[o] = !0
            const e = o.endsWith('.css'),
              n = e ? '[rel="stylesheet"]' : ''
            if (document.querySelector(`link[href="${o}"]${n}`)) return
            const l = document.createElement('link')
            if (
              ((l.rel = e ? 'stylesheet' : w),
              e || ((l.as = 'script'), (l.crossOrigin = '')),
              (l.href = o),
              document.head.appendChild(l),
              e)
            )
              return new Promise((k, V) => {
                l.addEventListener('load', k), l.addEventListener('error', V)
              })
          })
        ).then(() => s())
  }

先看下编译完成后的代码,编译后的地址全都是相对路径,看下这些代码都做了什么

图片资源

赋值后是直接被使用,渲染出来的时候还是相对路径

preload函数

s:imports

a: preloads(预加载资源)

判断是否有预加载资源,没有这直接执行imports;如果有则加载所有预加载资源,加载完成后执行imports,通过代码发现预加载的时候o = ${x}${o}得到的也是一个相对路径。

都是相对路径,如果做这个动态publicPath,在前面加个变量就行了

确定方案

// 图片资源
var b = '/assets/logo.03d6d6da.png'
// change 
var b = __dyanmic_base__ + '/assets/logo.03d6d6da.png'

// preload函数
u = function (s, a) {
    return !a || a.length === 0
      ? s()
      : Promise.all(
          a.map(o => {
            if (((o = `${x}${o}`), o in y)) return
            y[o] = !0
            const e = o.endsWith('.css'),
              n = e ? '[rel="stylesheet"]' : ''
            if (document.querySelector(`link[href="${o}"]${n}`)) return
            const l = document.createElement('link')
            if (
              ((l.rel = e ? 'stylesheet' : w),
              e || ((l.as = 'script'), (l.crossOrigin = '')),
              (l.href = o),
              document.head.appendChild(l),
              e)
            )
              return new Promise((k, V) => {
                l.addEventListener('load', k), l.addEventListener('error', V)
              })
          })
        ).then(() => s())
  }
// change 
u = function (s, a) {
    return !a || a.length === 0
      ? s()
      : Promise.all(
          a.map(o => {
            if (((o = `${__dyanmic_base__}${x}${o}`), o in y)) return
            y[o] = !0
            const e = o.endsWith('.css'),
              n = e ? '[rel="stylesheet"]' : ''
            if (document.querySelector(`link[href="${o}"]${n}`)) return
            const l = document.createElement('link')
            if (
              ((l.rel = e ? 'stylesheet' : w),
              e || ((l.as = 'script'), (l.crossOrigin = '')),
              (l.href = o),
              document.head.appendChild(l),
              e)
            )
              return new Promise((k, V) => {
                l.addEventListener('load', k), l.addEventListener('error', V)
              })
          })
        ).then(() => s())
  }

那要怎么实现上面的方案?

vite源码中的preload插件相关代码

先找到preload函数

function preload(baseModule: () => Promise<{}>, deps?: string[]) {
  // @ts-ignore
  if (!__VITE_IS_MODERN__ || !deps || deps.length === 0) {
    return baseModule()
  }

  return Promise.all(
    deps.map((dep) => {
      // @ts-ignore
      dep = `${base}${dep}`
      // @ts-ignore
      if (dep in seen) return
      // @ts-ignore
      seen[dep] = true
      const isCss = dep.endsWith('.css')
      const cssSelector = isCss ? '[rel="stylesheet"]' : ''
      // @ts-ignore check if the file is already preloaded by SSR markup
      if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
        return
      }
      // @ts-ignore
      const link = document.createElement('link')
      // @ts-ignore
      link.rel = isCss ? 'stylesheet' : scriptRel
      if (!isCss) {
        link.as = 'script'
        link.crossOrigin = ''
      }
      link.href = dep
      // @ts-ignore
      document.head.appendChild(link)
      if (isCss) {
        return new Promise((res, rej) => {
          link.addEventListener('load', res)
          link.addEventListener('error', rej)
        })
      }
    })
  ).then(() => baseModule())
}

再看应用的地方

// 截图不太清晰用复制了片段出来
const preloadHelperId = 'vite/preload-helper'
...
export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin {
  const ssr = !!config.build.ssr
  const insertPreload = !(ssr || !!config.build.lib)

  const scriptRel = config.build.polyfillModulePreload
    ? `'modulepreload'`
    : `(${detectScriptRel.toString()})()`
  const preloadCode = `const scriptRel = ${scriptRel};const seen = {};const base = '${preloadBaseMarker}';export const ${preloadMethod} = ${preload.toString()}`

  return {
    name: 'vite:build-import-analysis',

    resolveId(id) {
      if (id === preloadHelperId) {
        return id
      }
    },

    load(id) {
      if (id === preloadHelperId) {
        return preloadCode.replace(preloadBaseMarker, config.base)
      }
    },
    ...
}

可以看到preload函数在buildImportAnalysisPlugin初始化时候进行了处理,针对preloadCode的操作都是在id === preloadHelperId时候才执行的

vite文档--插件API说明 以及rollup.js--plugin 找到我们需要用到的三个钩子,configResolved、transform、generatebundle

这三个钩子作用如下:

configResolved: 负责从编译配置中获取base和assetsDir

transform:负责转换preload

generatebundle:负责处理图片资源文件这种

综上实现如下

import type { Plugin } from 'vite'
import type { Options } from '../index'

export function dynamicBase(options?: Options): Plugin {
  const { publicPath = 'window.__dynamic_base__' } = options || {}
  const preloadHelperId = 'vite/preload-helper'
  let assetsDir = 'assets'
  let base = '/'

  return {
    name: 'vite-plugin-dynamic-base',
    enforce: 'post', // 滞后执行
    apply: 'build', // 仅编译时候执行
    configResolved(resolvedConfig) { // 获取配置,用与后面配置assetsMaker使用
      assetsDir = resolvedConfig.build.assetsDir
      base = resolvedConfig.base
    },
    transform(code, id) { // 替换preload中的base
      if (id === preloadHelperId) {
        code = code.replace(/(\${base})/g, `\${${publicPath}}$1`)
        return {
          code
        }
      }
    },
    generateBundle({ format }, bundle) { // 处理图片等资源
      if (format !== 'es') {
        return
      }
      const assetsMarker = `${base}${assetsDir}/`
      const assetsMarkerRE = new RegExp(`("${assetsMarker}*.*.*")`, 'g')
      for (const file in bundle) {
        const chunk = bundle[file]
        if (chunk.type === 'chunk' && chunk.code.indexOf(assetsMarker) > -1) {
          chunk.code = chunk.code.replace(assetsMarkerRE, `${publicPath}+$1`)
        }
      }
    }
  }
}

测试

vite.config.ts

import { dynamicBase } from 'vite-plugin-dynamic-base'

export default defineConfig({
  plugins: [
    dynamicBase({ /* options */ }),
  ],
})

编译结果

// 图片资源
var b = window.__dynamic_base__ + '/assets/logo.03d6d6da.png'
  
// preload函数
const S = 'modulepreload',
  y = {},
  x = '/',
  u = function (s, a) {
    return !a || a.length === 0
      ? s()
      : Promise.all(
          a.map(o => {
            if (((o = `${window.__dynamic_base__}${x}${o}`), o in y)) return
            y[o] = !0
            const e = o.endsWith('.css'),
              n = e ? '[rel="stylesheet"]' : ''
            if (document.querySelector(`link[href="${o}"]${n}`)) return
            const l = document.createElement('link')
            if (
              ((l.rel = e ? 'stylesheet' : S),
              e || ((l.as = 'script'), (l.crossOrigin = '')),
              (l.href = o),
              document.head.appendChild(l),
              e)
            )
              return new Promise((k, V) => {
                l.addEventListener('load', k), l.addEventListener('error', V)
              })
          })
        ).then(() => s())
  }

插件和demo

vite-plugin-dynamic-base

目前已添加至 awesome-vite 社区插件列表