├── .gitignore
├── README.md
├── babel.config.js
├── package.json
├── public
└── favicon.ico
├── src
├── assets
│ └── .gitkeep
├── components
│ └── .gitkeep
└── pages
│ ├── index
│ ├── App.vue
│ ├── index.html
│ └── main.js
│ ├── page2
│ ├── App.vue
│ ├── index.html
│ └── main.js
│ └── test
│ └── page
│ ├── App.vue
│ ├── index.html
│ └── main.js
└── vue.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | package-lock.json
4 | /dist
5 |
6 | # local env files
7 | .env.local
8 | .env.*.local
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 前言
2 |
3 | 如果你是找一个多入口的脚手架,很抱歉并不能给你提供帮助,请直接使用[Nuxt](https://github.com/nuxt/nuxt.js)开启你的旅程。
4 |
5 | 本文主要是总结使用vue-cli配置多入口的一些经验,用于解决当你需要在单页应用中挂载不同的入口遇到的问题。
6 |
7 | 需要注意的是,在多页应用中子路由不支持history路由的形式,可以使用hash。
8 |
9 | ## 特性
10 | - 🎉 Vue 3 & @vue/cli 4,保持最新的 Vue 依赖
11 | - ✅ 自动生成入口,所有入口以目录的形式存放于src/pages下,并在第一层目录需包含index.html文件
12 | - ✅ 支持嵌套路由,如在pages目录下存在/test/page1/index.html,访问路径为:/test/page1
13 | - ✅ 根目录挂载,目录名为index会挂载到根目录下,而不是通过/index访问
14 |
15 | ## 什么是多页应用
16 |
17 | 单页应用(SPA)往往只含有包含一个主入口文件与index.html,页面间切换通过局部刷新资源来完成。而在多页应用中,我们会为每个HTML文档文件都指定好一个JS入口,这样一来当页面跳转时用户会获得一个新的HTML文档,整个页面会重新加载。
18 |
19 | 单页应用、多页应用的优劣势在此就不进行分析了,总而言之,多页架构模式暂时是无法取代的,如果尝试把几十个不关联的页面做成一个,那么开发成本会非常大的,**Not every app has to be an SPA**。
20 |
21 | ## 初始化项目
22 |
23 | 首先我们安装好vue-cli脚手架,并初始化一个默认工程:
24 |
25 | ```
26 | $ npm i -g @vue-cli@next
27 | $ vue create --default multi-page-demo
28 | ```
29 |
30 | 此时的目录结构为:
31 |
32 | ```
33 | ...
34 | ├── public
35 | │ ├── favicon.ico
36 | │ └── index.html
37 | ├── src
38 | │ ├── App.vue
39 | │ ├── assets
40 | │ │ └── logo.png
41 | │ ├── components
42 | │ │ └── HelloWorld.vue
43 | │ └── main.js
44 | ```
45 |
46 | 我们首先需要重构工程目录,可以基于以下或自身的需求进行考虑:
47 |
48 | 1. 通过一个pages目录来存放不同的入口,每个入口包含:index.html、main.js,pages,其中文件夹名即路由名
49 | 2. src目录下可以存放项目的通用组件和静态资源,每个入口单独管理自己独有的资源
50 |
51 | 通过以上考虑,决定将工程结果重构为:
52 |
53 | ```
54 | ...
55 | ├── public
56 | │ └── favicon.ico
57 | ├── src
58 | │ ├── assets
59 | │ ├── components
60 | │ └── pages
61 | │ └── page1
62 | │ ├── App.vue
63 | │ ├── assets
64 | │ │ └── logo.png
65 | │ ├── components
66 | │ │ └── HelloWorld.vue
67 | │ ├── index.html
68 | │ └── main.js
69 | │ └── page2
70 | │ ├── ...
71 | ```
72 |
73 | 更改完结构后,我们再通过vue.config.js对入口进行一下调整,保证项目正常运行:
74 |
75 | ```
76 | // vue.config.js
77 | const path = require("path");
78 |
79 | module.exports = {
80 | chainWebpack: config => {
81 | config.plugin("html").tap(args => {
82 | args[0].template = path.join(__dirname, "./src/pages/page1/index.html");
83 | return args;
84 | });
85 | },
86 | configureWebpack: {
87 | entry: {
88 | app: path.join(__dirname, "./src/pages/page1/main.js")
89 | }
90 | }
91 | };
92 |
93 | ```
94 |
95 | vue.config.js是一个可选文件,用户需要自行创建,它会被`@vue/cli-service`读取。当正确添加配置后,重启一下项目,测试一下项目在改变目录结构后能否正常运行。试想一下,若照着这个思路进行配置多入口,那么首先需要删除或修改掉原有webpack配置项,然后还需添加多入口的一些插件,虽然通过脚手架对外提供的API可以实现,可是这种修改方式还不是直接修改原生构建配置更快,那么还有其他解决方法吗?
96 |
97 | ## Multi-Page模式
98 |
99 | Vue CLI 支持多入口模式,只需要在vue.config.js中提供pages选项即可开启[多入口模式](https://cli.vuejs.org/zh/config/#pages),我们现在将使用pages字段来重构vue.config.js:
100 |
101 | ```
102 | module.exports = {
103 | pages: {
104 | index: {
105 | // page 的入口
106 | entry: "src/pages/page1/main.js",
107 | // 模板来源
108 | template: "src/pages/page1/index.html",
109 | // 在 dist/index.html 的输出
110 | filename: "index.html",
111 | // 当使用 title 选项时,
112 | // template 中的 title 标签需要是
<%= htmlWebpackPlugin.options.title %>
113 | title: "Index Page",
114 | // 在这个页面中包含的块,默认情况下会包含
115 | // 提取出来的通用 chunk 和 vendor chunk。
116 | chunks: ["chunk-vendors", "chunk-common", "index"]
117 | }
118 | }
119 | };
120 | ```
121 |
122 | 配置完成后,服务依然正确访问,至此已经完成了添加多入口的基本准备工作,借助于脚手架多入口构建模式,因此需要做的工作仅剩下一个:**通过目录结构生成对应的入口配置**,也就是说,我们需要实现一个函数,函数的具体功能为:
123 |
124 | 
125 |
126 | 以下是具体配置:
127 |
128 | ```
129 | // vue.config.js
130 | const path = require("path");
131 | const glob = require("glob");
132 | const fs = require("fs");
133 |
134 | const config = {
135 | entry: "main.js",
136 | html: "index.html",
137 | pattern: ["src/pages/*"]
138 | };
139 |
140 | const genPages = () => {
141 | const pages = {};
142 | const pageEntries = config.pattern.map(e => {
143 | const matches = glob.sync(path.resolve(__dirname, e));
144 | return matches.filter(
145 | match =>
146 | fs.existsSync(`${match}/${config.entry}`) &&
147 | fs.existsSync(`${match}/${config.html}`)
148 | );
149 | });
150 | Array.prototype.concat.apply([], pageEntries).forEach(dir => {
151 | const filename = dir.split(path.sep).pop();
152 | pages[filename] = {
153 | entry: `${dir}/${config.entry}`,
154 | template: `${dir}/${config.html}`,
155 | filename: filename === 'index' ? config.html : `${filename}/${config.html}`
156 | };
157 | });
158 | return pages;
159 | };
160 |
161 | module.exports = {
162 | pages: genPages()
163 | };
164 |
165 | ```
166 |
167 | 通过以上配置后,如果是page1的入口,则会最终在dist目录生成一个:`page1/index.html`,因此还需要设置vue.config.js中的devServer.historyApiFallback,确保任意的 404 响应都可能需要被替代为 index.html正常返回:
168 |
169 | ```
170 | ...
171 | devServer: {
172 | historyApiFallback: true,
173 | },
174 | ...
175 | ```
176 |
177 | 至此,我们配置工作就结束了。复制一份page1文件夹为page2,通过将App.vue内的img标签去除,然后重新启动项目,在浏览器中测试一下访问,若以上步骤没问题,那么访问/page2时,应该会出现不带logo的页面,运行`yarn build输出结构如下:
178 |
179 | ```
180 | .
181 | ├── css
182 | │ ├── page1.5f9ed80b.css
183 | │ └── page2.80dc2e75.css
184 | ├── favicon.ico
185 | ├── img
186 | │ └── logo.82b9c7a5.png
187 | ├── js
188 | │ ├── chunk-vendors.f061f10e.js
189 | │ ├── chunk-vendors.f061f10e.js.map
190 | │ ├── page1.973fb73f.js
191 | │ ├── page1.973fb73f.js.map
192 | │ ├── page2.5e495ede.js
193 | │ └── page2.5e495ede.js.map
194 | ├── page1
195 | │ └── index.html
196 | └── page2
197 | └── index.html
198 | ```
199 |
200 | ## 源码部分
201 |
202 | @vue/cli-service通过判断是否传入pages参数来生成对应Webpack配置文件,让我们先来看看没有传入时的处理函数:
203 |
204 | ```
205 | if (!multiPageConfig) {
206 | // default, single page setup.
207 | htmlOptions.template = fs.existsSync(htmlPath)
208 | ? htmlPath
209 | : defaultHtmlPath
210 |
211 | webpackConfig
212 | .plugin('html')
213 | .use(HTMLPlugin, [htmlOptions])
214 |
215 | if (!isLegacyBundle) {
216 | // inject preload/prefetch to HTML
217 | ...
218 | }
219 | }
220 | ```
221 |
222 | 由源码可知,pages参数可用于生成三个插件:preload-plugin、prefetch-plugin、html-plugin,若不传html文件则会使用一个只空的默认html文件,而在多入口模式下,代码的逻辑也很简单,在此就不贴源码了,它会执行以下步骤:
223 |
224 | 1. 清除原有entry
225 | 2. 对pages字段的每个key做循环,解析每个入口对象的参数entry(必填)、title、template、filename、chunks
226 | 3. 通过entry字段生成webpack的entry入口
227 | 4. 通过其余参数生成对应的html-webpack-plugin,若不为传统模式,也会生成对应入口的preload插件与prefetch插件
228 |
229 | ## 局部优化
230 |
231 | #### 移除prefetch
232 |
233 | 由于本人并不喜欢为将来做打算,因此并不希望预加载一些可能会用到的asyncChunk,因为会浪费掉一些带宽,而且在多页面中并不见得预加载其他入口的文件是一件好事情,于是我们通过chainWebpack进行删除:
234 |
235 | ```
236 | modules.exports = {
237 | // ...
238 | chainWebpack: config => {
239 | Object.keys(pages).forEach(entryName => {
240 | config.plugins.delete(`prefetch-${entryName}`);
241 | });
242 | }
243 | }
244 | ```
245 |
246 | #### 关闭SourceMap
247 |
248 | 关闭之后不仅能加快生产环境的打包速度,也能避免源码暴露在浏览器端:
249 |
250 | ```
251 | modules.exports = {
252 | // ...
253 | productionSourceMap: false,
254 | }
255 | ```
256 |
257 | #### 打包分类(强迫症患者福音)
258 |
259 | 首先回顾一下dist中的部分文件夹:
260 |
261 | ```
262 | .
263 | ├── css
264 | │ ├── page1.5f9ed80b.css
265 | │ └── page2.80dc2e75.css
266 | ├── js
267 | │ ├── chunk-vendors.f061f10e.js
268 | │ ├── chunk-vendors.f061f10e.js.map
269 | │ ├── page1.973fb73f.js
270 | │ ├── page1.973fb73f.js.map
271 | │ ├── page2.5e495ede.js
272 | │ └── page2.5e495ede.js.map
273 | ├── page1
274 | │ └── index.html
275 | └── page2
276 | └── index.html
277 | ```
278 |
279 | 其实我们更希望的是不同入口的css与js文件放入不同入口中,而不是统一放在一个js和css文件夹,为了做到这一点,js打包路径我们可已通过修改webpack的output配置来完成,而css打包路径,脚手架是通过MiniCssExtractPlugin插件来完成的,因此可以使用chainWebpack的tap来修改其配置,以上只需要在生产环境修改即可:
280 |
281 | ```
282 | modules.exports = {
283 | // ...
284 | chainWebpack: config => {
285 | // ...
286 | if (process.env.NODE_ENV === "production") {
287 | config.plugin("extract-css").tap(() => (() => [
288 | {
289 | filename: "[name]/css/[name].[contenthash:8].css",
290 | chunkFilename: "[name]/css/[name].[contenthash:8].css"
291 | }
292 | ]));
293 | }
294 | },
295 | configureWebpack: config => {
296 | if (process.env.NODE_ENV === "production") {
297 | config.output = {
298 | path: path.join(__dirname, "./dist"),
299 | filename: "[name]/js/[name].[contenthash:8].js",
300 | publicPath: "/",
301 | chunkFilename: "[name]/js/[name].[contenthash:8].js"
302 | };
303 | }
304 | }
305 | }
306 | ```
307 |
308 | 此时打包后的dist文件夹为:
309 |
310 | ```
311 | ├── page1
312 | │ ├── css
313 | │ │ └── page1.42195c95.css
314 | │ ├── index.html
315 | │ └── js
316 | │ └── page1.569bf4e5.js
317 | └── page2
318 | ├── css
319 | │ └── page2.4e7ad924.css
320 | ├── index.html
321 | └── js
322 | └── page2.05e51252.js
323 | ```
324 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-cli-multipage",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint"
9 | },
10 | "dependencies": {
11 | "core-js": "^3.6.5",
12 | "vue": "^3.0.0"
13 | },
14 | "devDependencies": {
15 | "@vue/cli-plugin-babel": "~4.5.0",
16 | "@vue/cli-plugin-eslint": "~4.5.0",
17 | "@vue/cli-service": "~4.5.0",
18 | "@vue/compiler-sfc": "^3.0.0",
19 | "babel-eslint": "^10.1.0",
20 | "eslint": "^6.7.2",
21 | "eslint-plugin-vue": "^7.0.0-0"
22 | },
23 | "eslintConfig": {
24 | "root": true,
25 | "env": {
26 | "node": true
27 | },
28 | "extends": [
29 | "plugin:vue/vue3-essential",
30 | "eslint:recommended"
31 | ],
32 | "parserOptions": {
33 | "parser": "babel-eslint"
34 | },
35 | "rules": {}
36 | },
37 | "browserslist": [
38 | "> 1%",
39 | "last 2 versions",
40 | "not dead"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vv13/vue-cli-multipage/cc38821876072d402b717b2be694999837cddf71/public/favicon.ico
--------------------------------------------------------------------------------
/src/assets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vv13/vue-cli-multipage/cc38821876072d402b717b2be694999837cddf71/src/assets/.gitkeep
--------------------------------------------------------------------------------
/src/components/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vv13/vue-cli-multipage/cc38821876072d402b717b2be694999837cddf71/src/components/.gitkeep
--------------------------------------------------------------------------------
/src/pages/index/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Page from Index
4 |
5 |
6 |
7 |
13 |
14 |
24 |
--------------------------------------------------------------------------------
/src/pages/index/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | vue-cli-multipage
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/pages/index/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 |
4 | createApp(App).mount('#app')
5 |
--------------------------------------------------------------------------------
/src/pages/page2/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Page from page2
4 |
5 |
6 |
7 |
13 |
14 |
24 |
--------------------------------------------------------------------------------
/src/pages/page2/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | vue-cli-multipage
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/pages/page2/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 |
4 | createApp(App).mount('#app')
5 |
--------------------------------------------------------------------------------
/src/pages/test/page/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Page from test/page
4 |
5 |
6 |
7 |
13 |
14 |
24 |
--------------------------------------------------------------------------------
/src/pages/test/page/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | vue-cli-multipage
9 |
10 |
11 |
12 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/pages/test/page/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 |
4 | createApp(App).mount('#app')
5 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const fs = require('fs');
3 |
4 | const config = {
5 | entry: 'main.js',
6 | html: 'index.html',
7 | pagesRoot: path.resolve(__dirname, 'src/pages')
8 | };
9 |
10 | const genRoutes = () => {
11 | const allRoutes = [];
12 |
13 | const findAllRoutes = (source, routes) => {
14 | const files = fs.readdirSync(source);
15 | files.forEach(filename => {
16 | const fullname = path.join(source, filename);
17 | const stats = fs.statSync(fullname);
18 | if (!stats.isDirectory()) return;
19 | if (fs.existsSync(`${fullname}/${config.html}`)) {
20 | routes.push(fullname);
21 | } else {
22 | findAllRoutes(fullname, routes);
23 | }
24 | });
25 | };
26 | findAllRoutes(config.pagesRoot, allRoutes);
27 | return allRoutes;
28 | };
29 |
30 | const genPages = () => {
31 | const pages = {};
32 | genRoutes().forEach(route => {
33 | const filename = route.slice(config.pagesRoot.length + 1);
34 | pages[filename] = {
35 | entry: `${route}/${config.entry}`,
36 | template: `${route}/${config.html}`,
37 | filename:
38 | filename === 'index' ? config.html : `${filename}/${config.html}`
39 | };
40 | });
41 | return pages;
42 | };
43 |
44 | const pages = genPages();
45 |
46 | module.exports = {
47 | productionSourceMap: false,
48 | pages,
49 | chainWebpack: config => {
50 | // remove prefetch, use import(/* webpackPrefetch: true */ './someAsyncComponent.vue')
51 | Object.keys(pages).forEach(entryName => {
52 | config.plugins.delete(`prefetch-${entryName}`);
53 | });
54 | if (process.env.NODE_ENV === 'production') {
55 | config.plugin('extract-css').tap(() => [
56 | {
57 | filename: '[name]/css/[name].[contenthash:8].css',
58 | chunkFilename: '[name]/css/[name].[contenthash:8].css'
59 | }
60 | ]);
61 | }
62 | },
63 | configureWebpack: config => {
64 | if (process.env.NODE_ENV === 'production') {
65 | config.output = {
66 | path: path.join(__dirname, './dist'),
67 | filename: '[name]/js/[name].[contenthash:8].js',
68 | publicPath: '/',
69 | chunkFilename: '[name]/js/[name].[contenthash:8].js'
70 | };
71 | }
72 | }
73 | };
74 |
--------------------------------------------------------------------------------