本文所用示例的仓库地址: gayhubjavascript
改了名后想到可能会引发误会,故提醒下注意把和路由懒加载和模块懒加载区分开来,前者针对的是组件,然后者模块的含义趋于子系统。css
经过上一节的优化,咱们已经有了从零构建中小型单页项目的能力,但若是项目模块足够多,进一步优化将变得困难重重。因此即使 VUE 是一个单页框架,你也能够在网上搜索到大量多页架构配置(固然这其中部分缘由是业务要求),它在原理上和各个电商网站采用的模块独立部署方式(购物车 cart.taobao.com
、订单 buyertrade.taobao.com
)相似,不一样点在于开发时有同一套基础环境以及部署时一般不会部署到单独的子域名(判断 url 转发请求)。但咱们前面就讲到,多页架构没法使用路由的 history 模式以及在开发时会遇到严重性能缺陷:html
项目自己不能去踩一些没法优化的坑,已知两坑:超多页( html-Webpack-plugin 热更新时更新全部页面)和动态加载未指明明确路径(打包目录下全部页面)—— Webpack 4 构建大型项目实践 / 优化前端
这一节咱们将一块儿来了解一种全(网)新的方案,把项目拆分为一个基础模块和 N 个业务模块,基础模块做为业务模块插槽(约定好模块对接接口),业务模块则独立开发和更新,而且业务模块使用时为懒加载,在性能和上文提到的模块独立部署方式相近,且有两个优点:vue
用户体验和单页项目一致(自己就是单页)。java
负责不一样模块的小组技术栈甚至代码风格是一致的,能更好应对紧急状况下的人员调动。node
虽然可能由不一样小组负责不一样业务模块,但技术选型、代码风格和打包都依赖于基础模块,因此规范方面都是能够在基础模块严格控制的。webpack
本节例子在《 Webpack 4 构建大型项目实践 / 优化》例子基础上进行修改nginx
假设 D 项目为一个面向我的用户的商城项目,功能复杂且性能要求很高,我的中心功能还须要提供完整源码给隔壁项目组。 而后团队内展开讨(Y)论(Y):git
xcoder-a: 该项目功能需求不是不少,即便作成单页也能彻底能知足性能需求。
xcoder-b: 这个“我的中心功能还须要提供完整源码”这个是嘛意思。
xleader : 他们但愿能直接把咱们的我的中心系统嵌入到他们的单页应用中,这点已经和他们讨论过,url 跳转的方式不符合他们预期,因此让是咱们把我的中心的源码提供给他们,他们在本身项目中部署我的中心,再把请求转发到咱们后台。另外由于如今只是第一期,因此看到的需求不是不少,但后续确定还会增长各类各样的功能留住用户以及刺激购物,好比积分兑换商品、消费等级铭牌什么的,因此扩展性仍是要考虑到。
xxxxx-PM: 咱们但愿作成“小淘宝”,个人意思是不必定要有淘宝的全部功能,但咱们要把精髓的部分吸纳到项目中。
xcoder-a: ...
xcoder-c: 他们不想经过 url 跳转的方式,那就是说他们结构也想是单页,两个单页项目之间想共用业务模块,我以为不可能。
xcoder-b: 很玄幻!
xleader : 其实最初他们的提议我也拒绝了,但后面咱们研究发现只要有合理的结构,共用业务模块也是能实现的。不过不是给他们源码,由于给源码涉及到依赖整理、代码更新等问题,因此咱们是把打包后的完整模块给他们。
xcoder-a: 明白了,是指基础工程和业务模块有统一的接口,就像乐高积木同样,业务模块能够嵌入到基础工程也能够取出来。
xleader : 对的,业务模块和基础工程只要约定了接口,就能够彻底独立开发,业务模块的嵌入或者拔出不会对项目产生任何影响。
咱们想要实现的其实就是在程序运行初始状态下只加载基础模块,用户使用某个功能时才动态把功能对于模块下载到浏览器,且为了模块在多个项目中共用,这些项目应该保留有一致的模块接口。玩过沙盒类游戏的朋友可能更容易理解,当咱们想玩某个非官方地图时,咱们就须要去额外装该地图的 Mod ,这个 Mod 就是这里讲的模块( module )。原理并不复杂,但咱们能够发现普通的单页项目的打包结构( vue-cli )有如下两点没法实现:
<1> 是打包上须要解决的问题,<2> 是代码逻辑须要解决的问题(包括统一接口和处理加载逻辑)
问题 1 须要依据代码结构新增一个打包命令,且配置 libraryTarget
属性把文件打包称一个库(具体值为 umd
amd
仍是 commonjs
由你的模块记载方式决定),用于打包特定的模块以及模块依赖。打包后生成的库文件须要一个 xx.js
做为入口,也是加载模块时须要加载的文件。
问题 2 则须要保证模块加载方式不被 Webpack 识别,由于一旦 Webpack 识别就会把代码打包到基础工程,咱们将采用 script 引入 requirejs 的方式来解决。这个问题其实困扰过咱们一段时间,由于 Webpack 支持 ES6 、 AMD 和 CommonJS 模块标准,咱们彷佛没办法让模块避免被打包,直到想通了在标准支持以前,还须要经过语法分析识别出这属于什么标准。举个例子, requirejs 实现的是 AMD 标准,但 Webpack 只认识 require
函数,若是咱们使用 requirejs
函数来加载模块,Webpack 只会把它看成寻常函数处理。
代码调整主要分为两步:业务模块独立打包、基础模块和业务模块对接,分别对应解决上文讲的两个问题。
在 /build
新增 Webpack.mod.conf.js
const Webpack = require('Webpack')
const {
CleanWebpackPlugin
} = require('clean-Webpack-plugin')
const TerserJSPlugin = require("terser-Webpack-plugin")
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-Webpack-plugin")
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const config = require('./config')
const {
resolve
} = require('./utils')
const generateModConfig = mod => {
const WebpackConfig = {
mode: 'production',
devtool: config.production.sourceMap ?
'cheap-module-source-map' : 'none',
entry: resolve(`src/modules/${mod}/index.js`),
output: {
path: resolve(`modules/${mod}`),
publicPath: `modules/${mod}`,
filename: `${mod}.js`,
chunkFilename: '[name].[contentHash:5].chunk.js',
library: `_${mod}`,
// 导出 umd 模块 ,以便容许 AMD 和 CommonJS 模块库使用,本文用到的 requirejs 就是实现 AMD 标准的一个库
libraryTarget: 'umd'
},
resolve: {
alias: {
'@': resolve('src'),
'@mod-a': resolve('src/modules/mod-a'),
'@mod-b': resolve('src/modules/mod-b')
}
},
optimization: {
minimizer: [
new TerserJSPlugin({
parallel: true // 开启多线程压缩
}),
new OptimizeCSSAssetsPlugin({})
],
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '/',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
},
plugins: [
new CleanWebpackPlugin(),
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:5].css',
chunkFilename: 'css/[name].[contenthash:5].css'
}),
new Webpack.BannerPlugin({
banner: `@auther 莫得盐\n@version ${ require('../package.json').version }\n@info hash:[hash], chunkhash:[chunkhash], name:[name], filebase:[filebase], query:[query], file:[file]`
})
]
}
if (config.production.bundleAnalyzer) {
const BundleAnalyzerPlugin = require('Webpack-bundle-analyzer')
.BundleAnalyzerPlugin
WebpackConfig.plugins.push(new BundleAnalyzerPlugin())
}
return WebpackConfig
}
module.exports = generateModConfig
复制代码
修改 /build/build.js
,加入 mod 模式
const Webpack = require('Webpack')
const chalk = require('chalk')
const Spinner = require('cli-spinner').Spinner
const {
generateWebpackConfig,
WebpackStatsPrint
} = require('./utils')
// 环境传参
const env = process.argv[2]
// 生产环境
const production = env === 'production'
// 模块环境
const mod = env === 'mod'
if (production) {
let config = generateWebpackConfig('production')
let spinner = new Spinner('building: ')
spinner.start()
Webpack(config, (err, stats) => {
if (err || stats.hasErrors()) {
WebpackStatsPrint(stats)
console.log(chalk.red('× Build failed with errors.\n'))
process.exit()
}
WebpackStatsPrint(stats)
spinner.stop()
console.log('\n')
console.log(chalk.cyan('√ Build complete.\n'))
console.log(
chalk.yellow(
' Built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
)
)
})
} else if (mod) {
const mods = process.argv.splice(3)
mods.forEach(modName => {
let config = generateWebpackConfig('mod', modName)
let spinner = new Spinner(`${modName} building: `)
spinner.start()
Webpack(config, (err, stats) => {
if (err || stats.hasErrors()) {
WebpackStatsPrint(stats)
console.log(chalk.red(`× ${modName} build failed with errors.\n`))
process.exit()
}
WebpackStatsPrint(stats)
spinner.stop()
console.log('\n')
console.log(chalk.cyan(`√ ${modName} build complete.\n`))
console.log(
chalk.yellow(
' Module should be loaded by base project.\n'
)
)
})
})
} else {
module.exports = generateWebpackConfig('development')
}
复制代码
修改 /build/uitils.js
中的 generateWebpackConfig
函数
/** * @description 根据不一样环境生成不一样 Webpack 配置文件 * @param {String} env 环境 * @param {String} modName mod 名, mod 环境下特有属性 */
const generateWebpackConfig = (env, modName = '') => {
process.env.NODE_ENV = env
console.log('modName:', modName)
if (env === 'production') {
return merge(require('./Webpack.base.conf'), require('./Webpack.prod.conf'))
} else if (env === 'mod') {
return merge(require('./Webpack.base.conf'), require('./Webpack.mod.conf')(modName))
} else {
return merge(require('./Webpack.base.conf'), require('./Webpack.dev.conf'))
}
}
复制代码
/package.json
中添加命令方便平常使用
{
"scripts": {
"mod": "node build/build.js mod",
}
}
复制代码
经过 yarn mod {modNameA} {modNameB} {...}
调用命令, modNameA
、 modNameB
为须要打包的模块名
统一 API 模块只导出 router 、 store 、 国际化等模块,在基础模块使用它们时,基础模块经过相应的热加载方式把他们加入到当前项目中。这里只展现模块标准导出文件(也就是打包入口)代码,其他代码可到 github 中查看。
/src/modules/mod-a/index.js
import router from './router/index.js'
import store from './store/index.js'
export default {
router,
store,
}
复制代码
而后咱们执行 yarn mod mod-a
就能够在 /modules/mod-a
文件夹下找到模块 A 的打包产物,它有这样的结构:
modules
├─ mod-a # 模块 A
├─ mod-a.js # 模块 A 标准出/入口
├─ function-a # 功能 A
├─ page-a.js # 功能 A 关联页面 A
├─ page-b.js # 功能 A 关联页面 B
├─ function-b # 功能 B
├─ ...
├─ mod-b # 模块 B
├─ ...
复制代码
要使用打包好的模块,有两个核心点:
addRoutes
函数动态添加路由,经过 vuex 的 registerModule
函数动态注册 store 模块,若是某些模块中导出内容对于的插件未提供动态注册方法,则须要本身 hack ,固然若是本身时间充足最好是给插件提 PR 。假设咱们已经约定了路由规则,即若是匹配到 /mod/xxx
则这个路由属于 xxx
模块,若是模块是初次加载则下载 xxx
模块,而后经过接口和模块内容动态注册 router 和 store ,下面是处理约定路由逻辑的代码。 src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import store from '@/store'
import {
splitModName, // 正则匹配分离模块名
getModResources, // 调用接口获取模块名对应的拥有权限的路由
generateRoutes // 经过接口获取的路由和加载模块中路由与组件的映射,生成 vue-router 须路由结构
} from '../utils/module'
Vue.use(Router)
const router = new Router({
mode: 'history',
routes: [{
path: '/',
name: 'index',
component: () =>
import( /* WebpackChunkName: "views/index" */ '@/views/index/main.vue')
}]
})
// 记录注册过的模块
let registeredRouterRecord = []
/** * @description 检查模块是否注册 * @param {String} modName 模块名 */
const isModRegistered = modName => {
return registeredRouterRecord.includes(modName)
}
/** * @description 注册模块 * @param {String} modName 模块名 */
const regeisterMod = modName => {
getModResources(modName).then(res => {
console.log('res:', res)
// generate routes
generateRoutes(modName, res.router).then(appendRoutes => {
console.log('appendRoutes:', appendRoutes)
// register router
router.addRoutes(appendRoutes)
})
// register store
store.registerModule(modName, res.store)
registeredRouterRecord.push(modName)
})
}
router.beforeEach((to, from, next) => {
console.log(to, from)
let modName = splitModName(to.path)
// 非基础模块 + 模块未注册 = 须要注册模块
if (modName && !isModRegistered(modName)) {
regeisterMod(modName)
}
next()
})
export default router
复制代码
src/utils/module/index.js
/** * @description 模块加载相关函数 * @author luwuer */
import {
getRoutes
} from '@/utils/api/base'
/** * @description 分离模块名 * @param {String} path 路由路径 */
const splitModName = path => {
// 本例中路由规定为 /mod/{modName} ,如 /mod/a/xxx 对应模块名为 mod-a
if (/\/mod\/(\w+)/.test(path)) {
return 'mod-' + RegExp.$1
}
return ''
}
/** * @description 取得模块有权限的路由 + 模块路由和组件映射关系 = 须要动态添加的路由 * @param {String} modName 模块名 */
const generateRoutes = (modName, routerMap) => {
return getRoutes(modName).then(data => {
return data.map(route => {
route.component = routerMap[route.name]
route.name = `${modName}-${route.name}`
return route
})
})
}
/** * @description 获取模块打包后的标准入口 JS 文件 * @param {String} modName */
const getModResources = modName => {
if (process.env.NODE_ENV === 'development') {
// 开发环境用 es6 模块加载方式,方便调试
return import(`@/modules/${modName}/index.js`).then(res => {
return res
})
} else {
return new Promise((resolve, reject) => {
requirejs(['/modules/' + modName + '/' + modName + '.js'], mod => {
resolve(mod)
})
})
}
}
export {
splitModName,
generateRoutes,
getModResources
}
复制代码
非核心点的代码调整在文章中并未说起,文章只是阐述一种架构思想,若是你有兴趣建议去 github 查看完整示例
该结构下,工程的完整打包流程为以下所示,其中 yarn dll
只有第一次打包时须要、 yarn mod xxx
在 xxx
业务模块改变后才须要、 yarn base
在基础模块改变后才须要。
yarn dll
yarn mod {modName1} {modName2} {...}
yarn base
复制代码
用 nginx 在本地 80 端口部署这个测试项目,而后查看项目在切换模块时的表现。