简介:面向Vue.js开发者的多页面应用模板,基于vue2.0实现,主要用于解决传统单页应用难以同时维护多个业务模块的问题,帮助团队在统一工程化体系下快速搭建并持续扩展多页面项目。模板内置了开发与生产环境配置、多入口生成机制、通用构建脚本等,并对目录结构做了清晰切分,便于开发者按模块组织业务代码。资源共三十二个文件,包含十五个JS脚本、六个Vue组件、两个HTML页面,以及eslint、babel、editorconfig等辅助配置,压缩包仅三十一KB,轻量而完整。已有四千六百余人学习下载,尤其适合中初级前端开发者学习多页面工程的构建与配置思路;可直接替换业务代码作为项目基底,也可参考其webpack配置拆分和公共模块抽取方式,降低多业务线维护复杂度,整体实用性很强。
1. vue20多页面应用模板:它到底解决什么问题
vue20多页面应用模板听起来像是个过时的名词,但在中后台项目里,它比单页面应用(SPA)更务实。单页面把十几个业务模块塞进一个 bundle,首屏卡、导航互踩、样式冲突,每次改一个页面都要重新全量构建。而多页面模板把每个业务页面当作独立入口,页面之间天然隔离,路由由浏览器接管,部署时还能按页面增量发布。如果你手上是一个登录页、控制台、报表页、配置页各自独立,但又共享同一套组件库和请求封装的项目,这个模板就是为你准备的。本文从目录结构、webpack 多入口、页面级路由到构建优化和常见坑,手把手把这个模板搭起来,并讲清楚哪些配置项值得保留、哪些纯粹是玄学。
2. 看懂多页面模板的目录与构建思路:为什么不是多个单页面
2.1 多页面模板的目录结构:按页面划分而不是按组件划分
很多人把"多页面模板"理解成"复制多份单页面项目",这是第一个翻车点。正确的目录结构是"一个页面一个文件夹",每个页面拥有自己的入口 JS、HTML 模板、路由和样式,共享的部分放在外层公共区域内。
我常用的目录模板长这样:
src/ pages/ login/ main.js index.html router.js store.js dashboard/ main.js index.html router.js store.js system/ main.js index.html router.js common/ api/ components/ styles/ utils/每个pages/xxx下的main.js是独立入口,index.html是页面级模板,router.js只负责当前页面的路由。common里放跨页面复用的东西,比如 API 封装、公共组件、全局样式。
这个结构的核心逻辑是:入口文件决定一个页面,页面之间不共享运行时状态,但可以共享代码。如果你的需求只是"点开不同的菜单 URL 加载不同的应用",那这种方式比单页面里做动态路由更直接,也不容易被路由守卫和权限逻辑搞晕。
实际项目中我在common/api下会封装 axios 实例,每个页面通过它发请求。而页面内的业务请求,我会放在pages/xxx/api.js里,这样每个页面自带请求清单,改起来不担心碰到别的页面。
2.2 webpack 多入口与 HtmlWebpackPlugin 的关系:入口数决定页面数
多页面模板的构建核心不是 Vue 本身,而是 webpack 的entry配置。每个入口对应一个页面,同时需要一个HtmlWebpackPlugin实例来生成该页面的 HTML 文件。两者必须一一对应,否则打包出来要么缺少页面,要么所有页面共用同一份 HTML。
来看一个最基础的多入口配置:
const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const glob = require('glob'); // 获取所有 pages 下的入口 const entries = {}; const pages = []; const files = glob.sync('./src/pages/**/main.js'); files.forEach((file) => { const match = file.match(/src\/pages\/(.*)\/main\.js$/); const name = match[1]; entries[name] = file; pages.push({ name, file, template: path.resolve(__dirname, `src/pages/${name}/index.html`), }); }); module.exports = { entry: entries, plugins: [ ...pages.map((page) => { return new HtmlWebpackPlugin({ filename: `${page.name}.html`, template: page.template, chunks: ['vendor', 'common', page.name], }); }), ], };glob扫描pages目录下所有main.js,每个目录自动成为一个入口。HtmlWebpackPlugin数组里每个实例对应一个页面,chunks数组指定该页面要加载哪些公共包和自身依赖。
这里的filename决定了浏览器访问路径,比如login.html对应http://localhost:8080/login.html。而template指向该页面自己的 HTML 模板,里面可以放不同的 title、meta 和根节点。需要注意:chunks顺序很关键,必须把vendor和common放在前面,页面入口放在最后,否则会报"Cannot access before initialization"之类的运行时错误,这个坑后面细说。
3. 从零搭建 vue20 多页面模板:核心配置与可抄作业的命令
3.1 初始化项目与安装依赖:用 vue-cli 还是手动 webpack
搭建这个模板有两种路径:基于 vue-cli 然后改造,或者手动组装 webpack 配置。vue-cli 默认是 SPA 结构,要改成多页面需要覆盖configureWebpack,而手动组装则一开始就掌控全部,但成本略高。
我建议已经有 vue-cli 经验的人尝试后者,因为多页面模板最需要理解的就是入口与插件的关系,手动搭一次以后任何 webpack 版本变化都难不倒你。先初始化一个空项目并安装依赖:
npm init -y npm install vue@2 vue-router@3 vuex@3 npm install webpack@4 webpack-cli@3 webpack-dev-server@3 npm install vue-loader@15 vue-template-compiler html-webpack-plugin@4 npm install clean-webpack-plugin mini-css-extract-plugin这里特意把版本锁在常见组合:Vue 2.6、webpack 4、vue-loader 15。如果你要升级到 Vue 2.7,注意vue-loader和vue-template-compiler需要对应换版本,否则模板编译会玄学报错。
依赖装完后,package.json里至少要有两条 script:
{ "scripts": { "dev": "webpack-dev-server --config build/webpack.dev.js", "build": "webpack --config build/webpack.prod.js" } }我用build目录放 dev 和 prod 两套配置,公共部分抽到webpack.base.js,避免两个配置文件里重复一堆 loader。
3.2 webpack 多入口配置:entry 动态生成与 chunk 拆分
继续完善webpack.base.js,除了entry和HtmlWebpackPlugin,还需要配置 Vue 的解析规则和公共样式处理。
// build/webpack.base.js module.exports = { entry: entries, output: { path: path.resolve(__dirname, '../dist'), filename: 'js/[name].[hash].js', publicPath: '/', // 部署在子路径时改成 './' 或具体路径 }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', }, { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/, }, { test: /\.css$/, use: [ process.env.NODE_ENV === 'production' ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader', ], }, ], }, resolve: { alias: { vue$: 'vue/dist/vue.esm.js', '@': path.resolve(__dirname, '../src'), }, }, optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', priority: 10, }, common: { name: 'common', minSize: 0, minChunks: 2, priority: 5, }, }, }, }, };这段配置里最值得解释的是splitChunks.cacheGroups。多页面模板如果不拆公共包,每个页面入口都会把 Vue、Vue Router、axios 打包进去,整个 dist 体积膨胀得像在堆棉花。我把node_modules中重复依赖抽成vendor,把业务代码中出现过 2 次以上的模块抽成common。
minChunks: 2表示模块至少被 2 个页面引用才会进入common包。如果你有十来个页面都引用了一个权限工具函数,它就该抽出来。如果一个页面独有的组件只有它自己用,最好留在页内 chunk 里,避免公共包里塞满低频代码。
3.3 多页面 dev server 与热更新:一次启动多个页面
开发调试时,webpack-dev-server天然支持多入口。但有几个配置必须设置,否则页面路由回退会失效。
// build/webpack.dev.js const devServer = { contentBase: path.resolve(__dirname, '../src'), hot: true, historyApiFallback: { rewrites: Object.keys(entries).map((name) => ({ from: new RegExp(`^/${name}`), to: `/${name}.html`, })), }, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, };historyApiFallback.rewrites是多页面模式下最关键的一处。开发时访问/login时,webpack-dev-server 默认会去找login.html,但浏览器地址栏没有.html后缀时,必须让它重写到login.html。如果不加这段,刷新/login会直接 404,看起来像是路由配置有问题,其实是 dev server 不知道怎么映射。
热更新方面,vue-hot-reload-api会随 vue-loader 自动工作。但注意:不同页面入口之间的 HMR 是独立的,改 A 页面不会重置 B 页面的状态,这种隔离感在调试多开页面时特别舒服。代价是内存占用会随页面数上涨,页面超过 20 个时我会把不常用的页面暂时从glob里排除掉,等需要时再加回来。
4. 各页面独立路由与公共业务抽离:模板能不能直接用
4.1 页面级路由组织:每个页面自己一套 router
多页面模板里,路由是"每个页面各自管理自己的路由栈"。登录页不需要关心控制台内部有哪些子路由,控制台的侧边栏也跟报表页完全无关。因此每个pages/xxx下的router.js只定义该页面的路由表。
以控制台页面为例:
// src/pages/dashboard/router.js import Vue from 'vue'; import Router from 'vue-router'; import Layout from './views/Layout.vue'; import Overview from './views/Overview.vue'; import Reports from './views/Reports.vue'; Vue.use(Router); export default new Router({ mode: 'history', base: '/dashboard', routes: [ { path: '/', component: Layout, children: [ { path: '', redirect: 'overview' }, { path: 'overview', component: Overview }, { path: 'reports', component: Reports }, ], }, ], });注意base: '/dashboard'。这是多页面路由最容易忘的配置。如果控制台页面的 URL 是/dashboard/overview,而base没设置,路由会自动用当前文件的目录作为基础路径,结果匹配不上报错。
每个页面的main.js里安装路由:
// src/pages/dashboard/main.js import Vue from 'vue'; import App from './App.vue'; import router from './router.js'; import store from './store.js'; new Vue({ router, store, render: (h) => h(App), }).$mount('#app');这样dashboard页面就只能处理/dashboard/*路径,login页面只看/login。浏览器访问哪个 HTML,就加载哪个页面代码,完全不会串。
4.2 公共组件、工具与请求层的复用:避免复制粘贴
多页面最大的风险是:每个页面为了省事,复制了一份公共代码,导致后续改一处要同步十个地方。所以模板里必须把共享逻辑放到src/common,并且保证这些模块是纯函数或无状态组件,页面间通过 props 和事件交互。
请求层是我一定会抽出来的。比如:
// src/common/api/request.js import axios from 'axios'; const service = axios.create({ baseURL: process.env.BASE_API, timeout: 10000, }); service.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } return config; }); service.interceptors.response.use( (response) => { if (response.data.code !== 0) { // 统一错误提示 return Promise.reject(response.data); } return response.data; }, (error) => { return Promise.reject(error); } ); export default service;公共组件方面,比如BaseTable、ImageUploader这类,我通常会放进src/common/components,并在src/common/index.js里统一导出。页面里按需引入:
import { BaseTable, ImageUploader } from '@/common';不建议用全局 Vue.use 自动注册,因为每多注册一个组件,所有页面入口的 bundle 都会把它打进去。即便你只在一个页面用了ImageUploader,全局注册也会让其他页面无辜变重。在多页面模式下,"按需局部注册"是性能底线。
5. 多页面模板的构建优化与部署检查
5.1 生产构建的公共代码分包:runtime、vendor、common
前面splitChunks已经抽了vendor和common,但生产构建还需要考虑runtime和 CSS 的隔离。runtime是 webpack 的模块启动代码,每个入口都会引用,如果不单独抽出来,浏览器加载时会重复执行。
在webpack.prod.js中加入:
optimization: { runtimeChunk: { name: 'runtime', }, minimize: true, minimizer: [ new TerserPlugin({ parallel: true, cache: true, }), ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash].css', }), new CleanWebpackPlugin(), ]runtimeChunk会把 webpack 的启动逻辑抽到runtime.js,这样页面更新业务代码时,只要 hash 变化,浏览器可以继续命中缓存的vendor和runtime,不需要全部重新下载。
同时,CSS 也要按页面拆分。MiniCssExtractPlugin会根据入口名生成login.css、dashboard.css这样的文件。注意chunks里要把common和vendor对应的 CSS 也引入到每个页面,否则公共样式会丢。
5.2 页面级懒加载与按需引入:别让首页背所有页面的债
多页面已经做了页面级拆分,但页面内部还有优化空间。每个页面里的业务组件和路由组件,可以用动态 import 做懒加载:
const Overview = () => import(/* webpackChunkName: "overview" */ './views/Overview.vue'); const Reports = () => import(/* webpackChunkName: "reports" */ './views/Reports.vue');这样dashboard页面只先加载Layout和当前路由的组件,其他子页面等到用户点进去才拉取。如果你把这个模式漏掉,那么一个页面里的所有子模块全都会被塞进一个 bundle,首屏依然会慢。
另外,第三方 UI 组件库也要按需引入。以某 UI 组件库为例:
import { Button, Table, Dialog } from '某UI';配合 babel-plugin-import 或者手写局部注册,只打包用到的组件。千万不要import Vue from 'vue'; Vue.use(某UI),这是多页面模板里最常见的体积膨胀来源,一个 UI 库全部引入可能让每个页面的 bundle 增加几百 KB。
部署环节有一个检查点:output.publicPath必须和服务器路径匹配。如果项目部署在https://example.com/admin/,那么publicPath要设为./或者/admin/,否则加载 JS、CSS 时路径全部 404。这个检查我一般在 build 完成后直接打开 dist 目录里的 HTML 文件,看 script 标签的 src 是否是相对路径,这一步能省掉很多线上排查时间。
6. 多页面模板避坑指南:这些坑我把能踩的都踩过了
6.1 坑一:HtmlWebpackPlugin 模板路径导致打包后白屏
现象:本地 dev 跑得好好的,执行 build 后打开 dist 里的 HTML,页面白屏,控制台报错找不到main.js或 css 文件。
原因:HtmlWebpackPlugin的模板template路径写成了src/pages/xxx/index.html,而生成的 HTML 文件默认在 dist 根目录,但引用资源路径是绝对路径/js/main.js。如果直接 file 协议打开,浏览器把/js/main.js解析成本地磁盘根路径,自然找不到。
解决:把output.publicPath设为'./'(相对路径),并且确认HtmlWebpackPlugin的filename不要包含子目录。如果你确实需要生成dist/pages/login.html这种结构,记得把filename写成pages/login.html,同时publicPath要相应调整到../。我一般统一用 flat 模式:所有 HTML 都在 dist 根目录,这样最简单。
6.2 坑二:css 抽离后顺序错乱,样式互相污染
现象:A 页面和 B 页面都引用了公共按钮样式,但是 B 页面的按钮颜色被 A 页面的私有样式覆盖,改 A 页面 css 导致 B 页面样式变化。
原因:当你把chunks里公共 css 和页面 css 都通过MiniCssExtractPlugin抽出后,HTML 里 link 标签的顺序由chunks数组顺序决定。如果页面私有 css 排在公共 css 之前,后加载的公共 css 会覆盖私有样式。同时,多页面模板里公共样式和私有样式没有做命名空间隔离,一旦顺序错一次,后面全是血泪。
解决:在HtmlWebpackPlugin的chunks中严格固定顺序,把vendor、common放在最前,页面 chunk 最后。如果仍然有覆盖问题,给每个页面根节点加一个专属 class,比如<div id="app" class="dashboard-page">,所有私有样式的选择器都写成.dashboard-page .xxx。这个方法看起来笨,但在多页面场景里是防止样式串扰最可靠的手段。
6.3 坑三:开发时页面间跳转用 hash 路由,刷新 404 的误区
现象:开发环境登录页跳转到控制台页,URL 写的是#/dashboard或者直接改 pathname 到/dashboard,刷新后 404。
原因:多页面模式下,不同页面是不同的 HTML,浏览器刷新/dashboard时会请求服务器上的dashboard文件。如果 dev server 没有配置historyApiFallback.rewrites,服务器只会提供静态文件,没有dashboard这个路径,自然 404。
解决:要么页面间跳转时直接用<a href="/dashboard.html">加上.html后缀,要么在 dev server 里配置前面讲的rewrites。生产环境则必须让 nginx 将/dashboard重写到/dashboard.html,规则类似:
location /dashboard { try_files $uri $uri/ /dashboard.html; }这个坑最常见的原因是开发者用习惯了 SPA 的 history 回退,以为多页面也天然支持,忽略了每个页面独立文件的事实。
6.4 坑四:全局组件注册导致每个页面都加载,首屏变慢
现象:在common/index.js里用Vue.component注册了几个常用的业务组件,结果每个页面的 bundle 体积都涨了,甚至登录页也加载了报表页才用到的组件。
原因:全局注册的组件会进入Vue的构造函数,不管哪个页面入口,只要执行了Vue.use或Vue.component,该组件代码就会被打包进去。多页面模板的公共代码抽取是按模块引用次数划分的,全局注册绕过了引用链,强制所有页面依赖。
解决:去掉全局注册,改为各页面局部引入。比如common里只导出组件,不主动注册。如果实在嫌麻烦,至少要做一个"按页面白名单注册"的机制,例如common/index.js中检测当前页面的name字段,只注册该页面需要的组件。但我的习惯是:除了router和axios,其他一律局部注册。宁可多写一行 import,也不愿每个页面背一个肥胖的公共包。
6.5 坑五:多页面时环境变量失效,因为定义在单入口配置里
现象:在项目的webpack.dev.js里通过DefinePlugin定义了process.env.BASE_API,登录页能读到,但控制台页读不到,页面请求全部打到 undefined。
原因:很可能你只在某个入口的配置块里写了DefinePlugin,而不是放在公共的webpack.base.js里。多页面模板的所有入口共用同一个 webpack 配置,任何环境变量必须定义在配置的plugins顶层,不能嵌套在某个入口内部。
解决:把DefinePlugin放到webpack.base.js:
const webpack = require('webpack'); module.exports = { plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 'process.env.BASE_API': JSON.stringify(process.env.BASE_API), }), ], };另外,如果使用dotenv读取.env文件,记得放在配置加载的最顶端执行,并且确认.env文件在项目根目录而不是某个页面目录下。环境变量失效的问题,大多数人都以为是 secret 泄漏或者语法错,实际就是"定义在了单入口内部"这个智障位置。
7. 把这个模板真正用起来:一个验证过的落地技巧
最后一个技巧,也是我看这个模板是否可落地的最重要的检查点:用函数动态生成页面配置,而不是手工维护列表。
当项目只有三五个页面时,手写 entry 和 HtmlWebpackPlugin 还可以忍受。一旦页面数超过十个,新增一个页面需要同步改入口、改插件、改 dev server 重写规则、改构建脚本,漏掉一个就是线上事故。我的解决办法是规定页面目录必须包含main.js和index.html,然后通过一个scripts/gen-pages.js脚本统一生成配置:
// scripts/gen-pages.js const fs = require('fs'); const path = require('path'); const pagesDir = path.resolve(__dirname, '../src/pages'); const pageNames = fs .readdirSync(pagesDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name); module.exports = pageNames.map((name) => ({ name, entry: path.join(pagesDir, name, 'main.js'), template: path.join(pagesDir, name, 'index.html'), }));然后在webpack.base.js里引用它:
const pages = require('../scripts/gen-pages'); const entries = {}; pages.forEach((page) => { entries[page.name] = page.entry; });这样以后新增页面只需要创建一个文件夹,放好main.js和index.html,重启 dev server 会自动出现新页面,build 也会自动生成对应的 HTML。我当初就是被手工维护 entry 列表搞崩过一次:加了一个活动页,忘记 dev server 重写规则,整个下午都在和 404 搏斗。后来改成自动生成,类似问题再没出现过。
如果你想进一步减少页面间公共代码的重复,可以把login、register这类低关联页面都放到一个auth目录下,让它们共享同一个 entry 和路由,但生成多个 HTML 文件名。这种"一个入口多个模板"的做法,适合登录/注册/找回密码这种状态不共享、视觉结构类似的场景。具体可以在HtmlWebpackPlugin里配置多个实例指向同一个entry,但chunks要小心处理。
这个模板我后来在不同团队里用过多次,最可靠的评价标准是:新成员接手后能不能在半天内独立新增一个页面并且不破坏现有页面。如果你做到这一步,模板本身已经成功了。以上这些都是我用血泪换来的习惯,希望能帮到你少走弯路。
本文还有配套的精品资源,点击获取