使用示例

// vite.config.jsimport createHtmlPlugin from './vite-plugin-html.js'export default async ()=>{  // 前置处理  const pages=[    {      // 默认的filename是template的文件名,即此处为index.html      template: 'templates/index.html',      injectOptions: {        data: {          // 替换模板的内容        }      }    },    {      // filename会用于路径匹配      // path模式下正则表达式为:      // `^\\/${filename}(\\?\w.*|\\/[^\\.]*)?$`      // 与之相对的是query模式,见下方pageKey      // 允许不带.html后缀,导出时会自动补上,放在dist目录下      // 不带.html后缀部分会作为build.rollupOptions.input的键名,即output的[name]      filename: 'station',      // entry是相对于filename的,插件中只处理了分隔符,即"/"符号      // 此处可以理解为在项目根路径下有一个虚拟的station.html      // html文档里有一个script[type=module]标签,其src即为entry值      entry: 'station/MP/main.js',      template: 'templates/MP.html',       injectOptions: {        data: {          // 替换模板的内容        }      }    },  ]  return defineConfig({    // 其他配置    build:{      rollupOptions:{        // 如前文所述,pages中的页面不需要填入input,插件内部会处理        input:{},        output:{          entryFileNames:'/[name].js',          chunkFileNames:'/[name].js',          assetFileNames:'[name].[ext]'        },      }    },    plugins:[      createHtmlPlugin({        minify:false,        pages,        // 开发环境中,如果希望通过query参数区分页面,则需要传入pageKey        // 如下启用query模式后,插件会根据query中的app的值查找页面:        // `^\\/[^#\\\?]*?[\\?&]app=${filename}(&.+|#\\\/.*)?`        pageKey:'app'       })    ]  })}

插件源码

import { normalizePath,loadEnv,createFilter } from 'vite';// 下面四个模块需要安装一下,相比原版删了几个不必要的// 最搞笑的明明开发的是一个vite插件,不知道为什么原作者重复安装了一个vite已经提供的方法createFilterimport { render } from 'ejs';import { parse } from 'node-html-parser';import { minify } from 'html-minifier-terser';import path from 'pathe';const fs=require('fs');function createPlugin({entry,template='./index.html',pages=[],verbose=false,inject={},pageKey=''}) {  const env=loadEnv(process.env.NODE_ENV,process.cwd()),  rewrites=[];  let viteConfig;  return {    name: "vite:html",    enforce: "pre",    // 组建重写映射,并将filename添加到vite的input中    config(conf) {      const filename=path.basename(template);      // 如果没有配置pages,则把根路径重定向到默认模板      // 允许input有多个入口,只要不和filename重名就行      // 这些html都在transform钩子中使用公共配置      if(!pages?.length){        const to = path.resolve(conf.root, template);        rewrites.push({from:new RegExp('^\\/$'),to});        return {          build: {            rollupOptions: {              input:{                [filename.replace(/\.html/,'')]: to              }            }          }        };      }      let getRegStr,getInputStr,indexPage=null;      if(pageKey){        getRegStr=page=>`^\\/[^#\\\?]*?[\\?&]${pageKey}=${page}(&.+|#\\\/.*)?`;        getInputStr=page=>'/?'+pageKey+'='+page;      }else{        getRegStr=page=>`^\\/${page}(\\?\w.*|\\/[^\\.]*)?$`;        getInputStr=page=>'/'+page;      }      const input = {};      pages.forEach(page=>{        const to={...page};        if(!to.template) to.template=template;        if(!to.filename) to.filename=path.basename(filename);        if (to.filename !== 'index.html'&&to.filename !== 'index') {          rewrites.push({from:new RegExp(getRegStr(to.filename.replaceAll('.','\\.'))),to});          input[to.filename.replace(/\.html/,'')]=getInputStr(to.filename);        } else {          indexPage = to;        }        if(!to.filename.endsWith('.html')) to.filename+='.html';      });      if(indexPage){        rewrites.push({from:new RegExp('^\\/(index\\.html)?$'),to:indexPage});        input.index='/index.html';      }      return {        build: {          rollupOptions: {            input          }        }      };    },    configResolved(resolvedConfig) {      viteConfig = resolvedConfig;    },    configureServer(server) {      const baseUrl=viteConfig.base??'/',      proxyKeys=viteConfig.server?.proxy?Object.keys(viteConfig.server.proxy):[];      server.middlewares.use((rqst, resp, next)=>{        if(!['GET','HEAD'].includes(rqst.method)||!rqst.headers) return next();        const headers = rqst.headers;        if(typeof headers.accept!=='string'||!["text/html","application/xhtml+xml"].some(accept=>headers.accept.includes(accept))) return next();        const parsedUrl = rqst._parsedUrl,        rewrite=rewrites.find(r=>parsedUrl.path.match(r.from));        if (!rewrite) {          if(parsedUrl.pathname.lastIndexOf('.')<=parsedUrl.pathname.lastIndexOf('/')) rqst.url='/index.html';          return next();        }        if(typeof rewrite.to==='string'){          rqst.url=rewrite.to;          return next();        }        // 遗留内容,貌似没什么用        if(proxyKeys.some(k=>parsedUrl.pathname.startsWith(path.resolve(baseUrl,k)))){          rqst.url=parsedUrl.pathname.replace(baseUrl,'/');          return next();        }        // 调用resp的end或write方法会直接把数据发给浏览器        // 因此不会再触发transformIndexHtml钩子,需要手动调用        server.transformIndexHtml(          path.resolve(baseUrl,rewrite.to.filename),          fs.readFileSync(path.resolve(viteConfig.root,rewrite.to.template)).toString()        ).then(html=>{resp.end(html)});      });    },    // rollup钩子,获取文件地址    resolveId(source,importer){      const rewrite=rewrites.find(r=>source.match(r.from));      if(!rewrite) return null;      if(typeof rewrite.to==='string') return rewrite.to;      return path.resolve(viteConfig.root,rewrite.to.filename);    },    // rollup钩子,根据文件地址读取文件内容    load(id){      if(typeof id!=='string') return null;      const rewrite=rewrites.filter(r=>typeof r.to!=='string')      .find(r=>path.resolve(viteConfig.root,r.to.filename)===id);      return rewrite?fs.readFileSync(path.resolve(viteConfig.root,rewrite.to.template)).toString():null;    },    // vite特有钩子,填充html文件插槽    transformIndexHtml:{      enforce:'pre',      async transform(html, ctx) {        let injectOptions,pageEntry;        const rewrite=rewrites.filter(r=>typeof r.to!=='string')        .find(r=>path.resolve(viteConfig.root,r.to.filename)===ctx.filename);        if(rewrite){          injectOptions=rewrite.to.injectOptions||{};          pageEntry=rewrite.to.entry||entry;        }else{          injectOptions=inject;          pageEntry=entry;        }        html=await render(          html,          {            ...viteConfig?.env ?? {},            ...viteConfig?.define ?? {},            ...env || {},            ...injectOptions.data          },          injectOptions.ejsOptions        );        if(pageEntry){          const root=parse(html),          scriptNodes=root.querySelectorAll('script[type=module]');          if(scriptNodes?.length){            const removedNode=scriptNodes.map(item => {              item.parentNode.removeChild(item);              return item.toString();            });            if(verbose) console.warn(`vite-plugin-html: Since you have already configured entry, ${removedNode.toString()} is deleted. You may also delete it from the index.html.`);          }          html=root.toString().replace(//,`<script type="module" src="${normalizePath(`${pageEntry}`)}">\n`);        }        return { html, tags:injectOptions.tags||[] };      }    },  };}const htmlFilter = createFilter(["**/*.html"]);function getOptions(minify) {  return {    collapseWhitespace: minify,    keepClosingSlash: minify,    removeComments: minify,    removeRedundantAttributes: minify,    removeScriptTypeAttributes: minify,    removeStyleLinkTypeAttributes: minify,    useShortDoctype: minify,    minifyCSS: minify  };}async function minifyHtml(html, minify$1) {  if (typeof minify$1 === "boolean" && !minify$1) {    return html;  }  let minifyOptions = minify$1;  if (typeof minify$1 === "boolean" && minify$1) {    minifyOptions = getOptions(minify$1);  }  return await minify(html, minifyOptions);}function createMinifyHtmlPlugin({minify = true} = {}) {  return {    name: "vite:minify-html",    enforce: "post",    async generateBundle(_, outBundle) {      if (minify) {        for (const bundle of Object.values(outBundle)) {          if (bundle.type === "asset" && htmlFilter(bundle.fileName) && typeof bundle.source === "string") {            bundle.source = await minifyHtml(bundle.source, minify);          }        }      }    }  };}export default (userOptions = {}) => {  return [createPlugin(userOptions), createMinifyHtmlPlugin(userOptions)];}// 本插件基于[https://github.com/vbenjs/vite-plugin-html]二次开发

逻辑概要

写在最后

这是我第一次写vite插件,但不是第一次改源码。作为非科班出身,缺乏基础功底始终是个硬伤,因此几乎每次遇到难题都是逆向解剖的方式层层抽剥,很艰难,很费时,但是每次都能有很多“精准的”收获。如果你也不是科班出身,并且时常会有一些缺少技能支撑的想法,别焦虑,请相信,眼前的问题最终都会一个一个解决的。

我是个人开发者,全栈程序员。后端语言PHP,喜欢用Codeigniter框架、swoole-cli。前端vue框架、微信小程序,欢迎交流!