问题背景

我们的客户开发的系统会销售给多个不同的单位使用,并且是需要私有化部署的。在有的客户那里,直接部署完就结束了。但是另外一些客户,提出了一些特别的要求。他们要求我们的系统只需要提供一个个功能页面,无需提供菜单管理等功能。功能页面的调度、管理、权限等工作,则是由他们内部的大平台来统一来完成。客户的这个需求比较特别,他们的这种需求,意味着我们必须要把后台做成一个MPA(MultiPage Application,多页面应用)应用。但是我们实际上开发的已经是一个SPA应用了,且是无法直接满足客户需求的。同时,我们也不可能的大面积的去改动代码或者框架去适应客户的需求,这样会导致后续的开发越来越慢,越来越艰难。因此我们决定从打包入手,来解决这个问题。

解决问题的思路

通过对打包过程的深入介入,实现同样的一份代码,能够根据不同的打包方法,分别打包出SPA和MPA应用。

涉及到的主要技术

  • Vue3
  • Vite
  • rollup 注:Vite是基于rollup的

简化的代码结构(不包含打包以及工程等文件)

 1index.html
 2└─src
 3App.vue
 4main.js
 5style.css
 6    ├─assets
 7vue.svg
 8    ├─components
 9HelloWorld.vue
10    ├─pages
11    │  ├─report
12    │  │      index.vue
13    │  ├─tdm
14    │  │      index.vue
15    │  └─user
16index.vue
17    └─tools
18            hello.js

打包成SPA

打包成SPA非常简单,默认情况下工程就是打包成SPA的。因此无需做出任何调整,就可以直接将程序打包成SPA。

打包成MPA

1. 打包预期的结果

首先,我们要清楚自己通过修改打包过程,实现怎样的一个目标。显然,我们的目标是非常明确的,我们要将上面的代码,打包到dist目录中。且将pages页面下的每一个index.vue页面都打包出一个相应的index.html来,并且能够将依赖关系正确的处理。打包出来预期的结果应该是这样的:

 1index.html
 2├─assets
 3|   ...
 4└─pages
 5    ├─report
 6index.html
 7    ├─tdm
 8index.html
 9    └─user
10         index.html

2. 认识打包过程

为了实现上述的目标,我对rollup的源码进行了深入的研究,并且熟悉了rollup的插件编写。只有对rollup较为深入的理解,才能解决这个问题。rollup的打包过程其实也并不是特别的复杂,或者说最复杂的那一部分暂时不需要去管。rollup打包过程中就是将入口文件找出来,然后将其中的依赖关系都处理好,并生成一个Graph。最后根据这个Graph将html、js等文件的引用关系重新塑造一遍,生成对应的结果并写入到文件当中去。rollup内置了强大的插件功能,在打包每进行到一个新的阶段的时候,rollup就会调用插件中的相关方法来对一些内容进行修饰,进而实现对打包过程的介入并实现更加强大的打包功能。

3. 打包的第一步——准备虚拟文件

我们在打包的过程中,并不会直接用vue文件去生成index.html,这样不仅奇怪,而且会把问题弄得复杂且容易出错。相反,我们会针对每一个需要被打包成页面的vue文件,生成两个虚拟文件(虚拟文件只在逻辑上存在,不在磁盘上存在),它们分别是index.htmlmain.js。且这两个文件则都会放在index.vue的同级目录中,如下图所示:

 1└─src
 2    │ ...
 3    ├─pages
 4    │  ├─report
 5    │  │      index.vue
 6    |  |      main.js虚拟的动态生成的
 7    |  |      index.html虚拟的动态生成的
 8    │  ├─tdm
 9    │  │      ...
10    │  └─user
11    │         ...

index.html中,我们先引用了main.js。在main.js中,我们又引用了index.vue,通过这种方式,我们就能够实现将一个index.vue的内容放在一个index.html页面中去展示。从而最终实现一个从index.vue到最终index.html文件。生成的main.jsindex.html都是千篇一律的,具体的实现方法如下所示:

 1function dynamicMainJs() {
 2  return `
 3import { createApp } from 'vue'
 4import '/src/style.css'
 5import App from './index.vue'
 6
 7createApp(App).mount('#app');
 8  `
 9}
10
11function dynamicIndexHtml() {
12  return `
13<!doctype html>
14<html lang="en">
15  <head>
16    <meta charset="UTF-8" />
17    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
18    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
19    <title>Vite + Vue</title>
20  </head>
21  <body>
22    <div id="app">123</div>
23    <script type="module" src="./main.js"></script>
24  </body>
25</html>`
26}

4. 打包第二步——将虚拟文件加入打包入口

我们准备好了虚拟文件之后,要把虚拟文件中的index.html加入到打包的input当中去。这个较为简单,只要将build.rollupOptions.input配置好即可。下面的三段代码分别完成在page页面下检索index.vue、生成虚拟的文件、将虚拟的index.html加入到打包参数中这三个任务。

 1
 2function search_index_vue(dir, index_vues) {
 3  let files = fs.readdirSync(dir);
 4  files.forEach(function (filename) {
 5    const file_absolute_path = path.join(dir, filename);
 6    const stats = fs.statSync(file_absolute_path);
 7    const is_file = stats.isFile();
 8    const is_dir = stats.isDirectory();
 9    const basename = path.basename(filename)
10    if (is_file && basename == 'index.vue') {
11      index_vues.push(file_absolute_path);
12    } else if (basename == 'index.vue') {
13      console.log('index.vue是专门指向vue的名词,请不要用作文件夹名称');
14    }
15    if (is_dir) {
16      search_index_vue(file_absolute_path, index_vues);
17    }
18  })
19}
20
21const pages = []
22
23const pages_dir = path.resolve(path.join('src', 'pages'));
24const index_vues = [];
25
26search_index_vue(pages_dir, index_vues);
 1
 2for (let index_vue of index_vues) {
 3  const dirname = path.dirname(index_vue)
 4  const rel_path = path.relative(src_dir, dirname);
 5  const segs = rel_path.split(path.sep);
 6  const visit_path = segs.join('/');
 7  const page = {}
 8  
 9  const page_path = path.relative(process.cwd(), path.join(dirname, 'index.html'));
10  page[visit_path] = '/' + page_path.split(path.sep).join('/');
11  pages.push(page)
12}
 1
 2const idContentMap = new Map();
 3const input_option = { 'index': './index.html' };
 4for (const page of pages) {
 5  for (const kv in page) {
 6    
 7    idContentMap.set(page[kv], dynamicIndexHtml());
 8    idContentMap.set(page[kv].replace('index.html', 'main.js'), dynamicMainJs());
 9    input_option[kv] = page[kv];
10  }
11}
12
13
14export default defineConfig({
15  ...
16  build: {
17    rollupOptions: {
18      input: input_option,
19      ...
20    }
21  }
22})

上图显示的三个文件就通过检索并动态生成的html。

打包第三步——准备好插件,通过介入打包流程将生成的虚拟文件加入到打包过程

我们准备写好一个插件,并将这个插件加入到plugins选项当中去。具体的插件方法有三个load(id),resolveId(),generateBundle(),分别解决从内存中加在文本内容、完成相对路径到绝对路径的映射、生成bundle时修改名称。

 1export default function mpa_pack(rawIdContentMap: Map<String, String>): Plugin {
 2  ...
 3  return {
 4    name: 'MPA:PACK',
 5    enforce: 'post',
 6    
 7    load(id) {
 8      ...;
 9    },
10    
11    resolveId(id, importer) {
12      ...
13    },
14    
15    generateBundle(outputOptions,bundle){
16      ...
17    }
18  }
19}

打包第四步——解决虚拟文件到具体路径的转换

rollup打包过程首先会将input里所传的文件,先做一层转换,转换成具体的文件路径名,然后再继续向下处理。虽然我们准备的虚拟文件不存在于文件系统上的, 但是是有具体路径的,且每一个文件都有具体路径。因此我们需要先解决一下resolveId的方法。当rollup将我们准备的虚拟文件传递给我们的时候,我们能够将这些文件路径正确的处理好并返回给rollup。resolveId方法就是做这个工作的。

 1resolveId(id, importer) {
 2  if(id.startsWith("/src/") && id.endsWith("index.html")){
 3    return relative_path_to_abs(id);
 4  }
 5  if(id.startsWith("./") && importer && importer.startsWith(path.join(process.cwd(), "src")) 
 6    && importer.endsWith("index.html")){
 7    const dir = path.dirname(importer);
 8    const main_js_path = dir + path.sep + id.substring(2);
 9    return main_js_path;
10  }
11  if(id == `./${index_vue}` && importer && importer.startsWith("/src/") && importer.endsWith("main.js") && rawIdContentMap[importer]){
12    const relative_path = process.cwd() + importer.split("/").join(path.sep)
13    return path.dirname(relative_path) + path.sep + index_vue;
14    }
15}

resolveId要解决三个文件从相对路径到具体路径的转换,分别是我们动态生成的index.htmlmain.jsindex.vueindex.html是要打包的文件,它引用了main.js,而main.js又引用了index.vue。因此需要对他们进行转换,由相对路径转换为磁盘上的绝对路径。

打��第五步——让虚拟文件的读取由从磁盘上改为内存中

由于我们的虚拟文件实际上是不存在于磁盘上的,但是又有着具体的路径。因此当rollup拿着我们的文件路径去找对应的文件的时候,注定是找不到的,是会抛出异常的。因此我们需要在打包的过程中,增添一段小的逻辑。如果rollup要加载的文件是我们准备的虚拟文件,那就直接从内存中取,不然就从磁盘上取。这一小段逻辑也很简单,且rollup也预留了钩子来给我们完成这个事。我们只要在我们编写的插件里,完善一下load(id)方法即可。

 1load(id) {
 2  
 3  return absIdContentMap[id];
 4}

rollup加载文件的方式很简单。首先rollup会加载一系列插件,然后找出这些插件中实现了load(id)方法的。对于load(id)方法,rollup会一个一个的执行,直到某一个方法返回了具体的文件内容为止。因此我们可以做一个拦截,当rollup加载了一系列插件(自然也包含新编写的插件)的时候,一个一个执行load(id)方法时,只要确保我们的方法在遇到虚拟文件的时候,返回动态生成的内容即可。这样rollup拿到文件内容,发现文件不为空,就不会再去磁盘上读取了。这样我们就实现了虚拟文件的加载。

打包第六步——修改虚拟文件的生成路径

在通常情况下,我们希望生成的打包路径是这样的:

 1dist/pages/user/index.html
 2dist/pages/tdm/index.html
 3dist/pages/report/index.html
 4dist/index.html

但现实情况是,打包完之后,出现的路径是这样的:

 1dist/src/pages/user/index.html
 2dist/src/pages/tdm/index.html
 3dist/src/pages/report/index.html
 4dist/index.html

多出了一个src层级,对应的是源码存放的src位置,这样既不美观看起来也不够专业,因此我们需要将前面的src在打包之后去除掉。因此我们需要通过generateBundle方法来实现这个目的:

 1  ....
 2  return {
 3    ...
 4    enforce: 'post',
 5    generateBundle(outputOptions,bundle){
 6      for(const key in bundle){
 7        if(key.startsWith("src/") && key.endsWith("index.html")){
 8          const value = bundle[key];
 9          const newKey = key.substring(4);
10          delete bundle[key];
11          bundle[newKey] = value;
12          value.fileName = newKey;
13        }
14      }
15    }
16  }

除了generalBundle方法外,我们还在插件属性中添加了一个enforce属性。这个enforce属性表示我们的插件要后置执行。因为index.html的bundle也是插件生成的,我们要确保我们的插件在生成bundle的插件之后运行。因此需要加上enforce属性。在generateBundle文件中,我们的处理也很简单,看看是不是由我们动态生成的index.html,如果是的话,直接去除掉前面的src/四个字符,这样最后生成的路径就不会包含src了。

打包结果

通过插件完善打包的方法之后,最后的打包效果如下所示:

 1vite v5.3.3 building for production...
 226 modules transformed.
 3dist/pages/user/index.html                          0.54 kBgzip:  0.33 kB
 4dist/pages/tdm/index.html                           0.64 kBgzip:  0.36 kB
 5dist/pages/report/index.html                        0.64 kBgzip:  0.36 kB
 6dist/index.html                                     0.70 kBgzip:  0.36 kB
 7dist/assets/index-BwDQkbRD.css                      0.27 kBgzip:  0.18 kB
 8dist/assets/style-CbQMbAXL.css                      1.00 kBgzip:  0.54 kB
 9dist/assets/_plugin-vue_export-helper-DxmBaCLa.js   0.09 kBgzip:  0.10 kB
10dist/assets/pages/user-C9gmg0Ur.js                  0.16 kBgzip:  0.18 kB
11dist/assets/pages/tdm-CcLLPnF2.js                   0.20 kBgzip:  0.20 kB
12dist/assets/pages/report-BcY1BQzc.js                0.20 kBgzip:  0.21 kB
13dist/assets/index-oLntDkqP.js                       2.11 kBgzip:  1.09 kB
14dist/assets/style-C34ZIGKn.js                      53.25 kBgzip: 21.85 kB
15built in 2.91s

可以看到,打包的结果,达到了我们租出想要的目标。

个人笔记记录 2021 ~ 2025