前言
日常开发中,随着H5的需求的不断迭代,前端包含的功能模块也会越来越多,比如活动、用户帮助协议模块等等。我们肯定不希望某一个模块的代码变动就去全量构建前端代码,然后全量发布到线上。这种全量发布的模式如果不是全量测试的话容易导致其他模块代码出现意料之外的问题。所以将功能不同的模块分开发布就很有必要了。
分模块开发的方案
一、vite的多页面应用模式
假设你有下面这样的项目文件结构
├── package.json
├── vite.config.js
├── index.html
├── main.js
└── nested
├── index.html
└── nested.js
在开发过程中,简单地导航或链接到 /nested/ - 将会按预期工作,与正常的静态文件服务器表现一致。
在构建过程中,你只需指定多个 .html 文件作为入口点即可:
import { resolve } from 'path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
nested: resolve(__dirname, 'nested/index.html'),
},
},
},
})
这个方案有三个瑕疵。
- 在开发模式下,如果nested模块也引入了路由,比如nested下面有一个
/nested2的路由,直接在地址栏输入/nested/nested2是不会正确的导航的,只能导航到/nested/,然后再通过路由router.push('/page2')路由到/nested/nested2,这种方式对开发阶段很不友好。 - 这种打包方案会将项目的所有模块一并打包,并将不同模块的共同依赖合并到同一个文件中。然而,这些合并后的文件可能包含某个模块未使用的依赖,导致这个模块包体积增大,进而影响网络加载性能。遗憾的是,官方文档并未提供相关配置来控制多页面打包的结果。
- 主模块位于 src 目录下,而其他模块则需定义在 src 目录之外。这种目录结构不符合企业级项目的开发规范,导致项目组织不够清晰,维护成本增加。
二、monorepo方式
借鉴现在开源项目都在使用的代码管理方式,项目的目录大概是下面这个样子
├── package.json
├── pnpm-workspace.yaml
├── script
└── build.ts 构建脚本
└── packages
└── main 主功能模块
└── hd 活动模块
└── help 隐私协等帮助模块
└── components 公共组件
└── utils 公共工具函数
将所有的模块都放到一个仓库中去维护,每个模块都能独立开发和维护,同时也能做到共享一些工具函数和组件等。但是这种方案有一个非常大的坑点,开发阶段每个模块运行在不同的端口,导致模块之间无法共用localstorage这类本地存储,非常影响开发体验
三、利用connect-history-api-fallback
利用connect-history-api-fallback把不同的请求代理到对应模块入口html,可以把项目的目录建成下面的样子
├── package.json
├── vite.config.ts
├── public
├── script
└── build.ts 构建脚本
├── vite-plugin
└── muti-page.ts 开发阶段多模块请求代理插件
└── src
└── modules 功能模块
└── main 主功能模块
├── router 路由
├── views 页面
├── index.html 入口html
├── App.vue
├── main.ts
└── vite.config.ts 构建配置
└── hd 活动模块
└── hd1 活动1
├── router 路由
├── views 页面
├── index.html 入口html
├── App.vue
├── main.ts
└── vite.config.ts 构建配置
└── hd1 活动2
├── router 路由
├── views 页面
├── index.html 入口html
├── App.vue
├── main.ts
└── vite.config.ts 构建配置
└── communal
└── help 隐私协议等
├── router 路由
├── views 页面
├── index.html 入口html
├── App.vue
├── main.ts
└── vite.config.ts 构建配置
└── public 公共模块
├── assets 静态资源
├── components 组件
└── utils 工具函数
页面中所有的页面模块都在src/modules文件夹下,开发过程中的首要任务是把我们对应的http请求代理到对应的html入口,可以利用connect-history-api-fallback结合vite的开发服务器的中间件完成。代码如下
import { type Plugin } from 'vite'
import path from 'path'
import { glob } from 'glob'
// @ts-ignore ignore
import historyApiFallback from 'connect-history-api-fallback'
// 模块所在的目录
const sourceSrc = 'src/modules'
// 主模块
const mainModule = 'main'
// 获得所有的模块
function getModules() {
const modules: string[] = []
glob.sync(path.resolve(`${sourceSrc}`, '**', 'main.ts')).forEach((file) => {
const pageName = path.dirname(path.relative(`${sourceSrc}`, file)).replace(/\\/g, '/')
modules.push(pageName)
})
return modules.filter((module) => module !== mainModule)
}
export default function (): Plugin {
const pages = getModules()
return {
name: 'muti-page',
apply: 'serve',
enforce: 'pre',
configureServer(server) {
server.middlewares.use((req, res, next) => {
// 将请求代理到对应的模块,将根请求代理到主模块下
return historyApiFallback({
rewrites: pages
.map((module) => ({
from: new RegExp(`^/${module}/?[^.]*$`),
to: `/${sourceSrc}/${module}/index.html`,
}))
.concat({
from: new RegExp(`^/($|(?!(${pages.join('|')}))).*$`),
to: `/${sourceSrc}/${mainModule}/index.html`,
}),
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
})(req, res, next)
})
},
}
}
将上述插件引入到项目中,再启动项目,就可以同一个端口访问多个模块了。
解决了开发服务器的问题,还需要编写构建脚本将每个模块单独打包。
首先要为每个模块单独建一个vite.config.ts文件
main模块的示例
import baseConfig from '../../../vite.config'
import { mergeConfig } from 'vite'
import { resolve } from 'path'
import { renameSync, rmSync } from 'fs'
export default mergeConfig(baseConfig, {
base: '/',
plugins: [
{
name: 'rename-html-plugin',
closeBundle() {
// 这个插件是需要的,因为最终构建生成的html的文件位置有问题,需要修改到正确的位置
const oldPath = resolve(process.cwd(), 'dist/src/modules/main/index.html')
const newPath = resolve(process.cwd(), 'dist/index.html')
renameSync(oldPath, newPath)
// 删除多余目录
rmSync(resolve(process.cwd(), 'dist/src'), { recursive: true })
},
},
],
build: {
rollupOptions: {
input: {
// 指定入口
main: resolve(__dirname, 'index.html'),
},
output: {
dir: '/dist'
}
},
},
})
help模块的示例
import baseConfig from '../../../vite.config'
import { mergeConfig } from 'vite'
import { resolve } from 'path'
import { renameSync, rmSync } from 'fs'
export default mergeConfig(baseConfig, {
base: '/help',
plugins: [
{
name: 'rename-html-plugin',
closeBundle() {
// 这个插件是需要的,因为最终构建生成的html的文件位置有问题,需要修改到正确的位置
const oldPath = resolve(process.cwd(), 'dist/help/src/modules/help/index.html')
const newPath = resolve(process.cwd(), 'dist/help/index.html')
renameSync(oldPath, newPath)
// 删除多余目录
rmSync(resolve(process.cwd(), 'dist/help/src'), { recursive: true })
},
},
],
build: {
rollupOptions: {
input: {
// 指定入口
main: resolve(__dirname, 'index.html'),
},
output: {
dir: 'dist/help'
}
},
},
})
模块太多的情况下可以考虑把rename-html-plugin插件提取成公共的插件。
有了模块的配置文件,接下来写一个脚本运行这个构建脚本使用这些配置文件
import { join } from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
import { spawn } from 'child_process'
const args = process.argv.slice(2)
const __filename = fileURLToPath(import.meta.url)
function build() {
if (!args.length) {
// eslint-disable-next-line no-console
console.log('请传入需要构建的模块')
} else {
const moduleName = args[0]
const configPath = join(__filename, `../../src/modules/${moduleName}/vite.config.ts`)
if (!fs.existsSync(configPath)) {
// eslint-disable-next-line no-console
console.log(`${moduleName}模块不存在`)
} else {
// eslint-disable-next-line no-console
console.log(`Build start for ${configPath}`)
const viteProcess = spawn('vite', ['build', '--config', configPath], {
shell: process.platform === 'win32', // 会启动一个新的shell执行这个命令 解决window上找不到这个vite可执行文件的问题
stdio: 'inherit', // 继承主进程的 stdio,方便查看日志
})
viteProcess.on('close', (code) => {
if (code === 0) {
// eslint-disable-next-line no-console
console.log(`Build succeeded for ${configPath}`)
}
})
}
}
}
build()
可以用tsx去运行这个脚本,
# 安装tsx
npm i tsx -D
# 在package.json里面注册构建命令
tsx ./script/build.ts
# 运行构建命令
# 构建main模块
npm run build main
# 构建help
npm run build help
# 构建活动
npm run build hd/hd1
最后还有一个公共资源路径的细节点需要处理,正常来说CSS 中的 url() 引用以及 .html 文件中引用的资源在构建过程中都会根据base配置的值自动调整,但是有一种特殊情况不会调整,就是通过js脚本指定的资源路径不会被调整。比如在help模块有这样一段代码
<template>
<img :src="imgSrc"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const imgSrc = ref('/logo.svg')
</script>
这里图片引用的地址在打包后就不会基于base被处理为/help/logo.svg,所以线上最终请求的时候还是会去访问根目录下的logo.svg文件,导致加载不到图片,针对这种情况只需要引入vite-plugin-copy插件,不管什么模块打包都把public文件夹下面的文件拷贝一份到dist目录中
import { copy } from 'vite-plugin-copy';
plugins: [
vue(),
copy([{src: 'public/*', dest: 'dist'}])
],
好啦,终于一切搞定,可以快乐的开发多页应用了!
总结
本文一共讨论了三种开发多页的方式,第二种有无法解决的开发环境无法使用同一个端口的问题,第一种官方方案其实也可以结合connect-history-api-fallback解决问题,但是本人很不喜欢方案一的开发目录结构。综合考虑比较推荐第三种写法。