背景随着项目的成长,单页spa逐渐包含了许多业务线 当项目页面超过一定数量(150+)之后,会产生一系列的问题
项目编译的时间(启动server,修改代码)越来越长,而每次调试关注的可能只是其中1、2个页面
所有的需求都定位到当前git,需求过多导致测试环境经常排队
基于以上问题有了对git进行拆分的技术需求。具体如下 目标由于改善的是开发环境,当然不希望拆分项目影响用户体验。如果完全将业务线拆分成2个独立页面,那么用户在业务线之间跳转时将不再流畅,因为所有框架以及静态资源都会在页面切换的时候重载。因此要求跳转业务线的时候依然停留在spa内部,不刷新页面,共用同一个页面入口;
因为大部分业务线需要用到的框架(vue, vuex...), 公共组件(dialog,toast)都已经在spa入口加载过了,不希望业务线重复加载这些资源。业务线项目中应该只包含自己独有的资源,并能使用公共资源;
业务线之间应该能用router互相跳转,能访问其他业务线包括全局的store
需求如上,下面介绍的实现方式 技术框架- vue: 2.4.2
- vue-router: 2.7.0
- vuex: 2.5.0
- webpack: 4.7.0
实现假设要从主项目拆分一个业务线 hello 出来
- 主项目:包含系统核心页面 + 各种必须框架(vue, vuex...)
- hello项目:包含hello自己内部的业务代码
跳转hello页面流程- 用户访问业务线页面 路由 #/hello/index;
- 主项目router未匹配,走公共*处理;
- 公共router判定当前路由为业务线hello路由,请求hello的入口bundle js;
- hello入口js执行过程中,将自身的router与store注册到主项目;
- 注册完毕,标记当前业务线hello为已注册;
- 之后路由调用next。会自动继续请求 #/hello/index对应的页面chunk(js,css)页面跳转成功;
- 此时hello已经与主项目完成融合,hello可以自由使用全部的store,使用router可以自由跳转任何页面。done
需要的功能就是这些,下面分步骤看看具体实现 请求业务线路由(步骤1)第一次请求#/hello/index时,此时router中所有路由无法匹配,会走公共*处理
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 | /** 主项目 **/
const router = new VueRouter({
routes: [
...
// 不同路由默认跳转链接不同
{
path: '*',
async beforeEnter(to, from, next) {
// 业务线拦截
let isService = await service.handle(to, from, next);
// 非业务线页面,走默认处理
if(!isService) {
next('/error');
}
}
}
]
});
|
业务线初始化(步骤2、步骤3)首先需要一个全局的业务线配置,存放各个业务线的入口js文件
1 2 3 4 5 6 7 8 | const config = {
"hello": {
"src": [
"http://local.aaa.com:7000/dist/dev/js/hellobundle.js"
]
},
"其他业务线": {...}
}
|
此时需要利用业务线配置,判断当前路由是否属于业务线,是的话就请求业务线,不是返回false
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | /** 主项目 **/
// 业务线接入处理
export const handle = async (to, from, next) => {
let path = to.path || "";
let paths = path.split('/');
let serviceName = paths[1];
let cfg = config[serviceName];
// 非业务线路由
if(!cfg) {
return false;
}
// 该业务线已经加载
if(cfg.loaded) {
next();
return true;
}
for(var i=0; i<cfg.src.length; i++) {
await loadScript(cfg.src[i]);
}
cfg.loaded = true;
next(to); // 继续请求页面
return true;
}
|
有几点需要注意
- 一般业务线配置存放在后端,此处为了说明直接列出
- 业务线只加载1次,loaded为判定条件。加载过的话直接进行next
- 当第1次业务线加载成功,此时主项目已经包含了 #/hello/index 的路由,此时next可以正常跳转。原因见下一节
hello的入口entry.js做的工作(步骤4)为了节省资源,hello业务线不再重复打包vue,vuex等主项目已经加载的框架。 那么为了hello能正常工作,需要主项目将以上框架传递给hello,方法为直接将相关变量挂在到window:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | /** 主项目 **/
import Vue from 'vue';
import { default as globalRouter } from 'app/router.js'; 2个需要动态赋值
import { default as globalStore } from 'app/vuex/index.js';
import Vuex from 'vuex'
// 挂载业务线数据
function registerApp(appName, {
store,
router
}) {
if(router) {
globalRouter.addRoutes(router);
}
if(store) {
globalStore.registerModule(appName, Object.assign(store, {
namespaced: true
}));
}
}
window.bapp = Object.assign(window.bapp || {}, {
Vue,
Vuex,
router: globalRouter,
store: globalStore,
util: {
registerApp
}
});
|
注意registerApp这个方法,此方法为hello与主项目融合的挂载方法,由业务线调用。 上一步已经正常运行了hello的entry.js,那我们看看hello在entry中干了什么:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | /** hello **/
import App from 'app/pages/Hello.vue'; // 路由器根实例
import {APP_NAME} from 'app/utils/global';
import store from 'app/vuex/index';
let router = [{
path: `/${APP_NAME}`,
name: 'hello',
meta: {
title: '页面测试',
needLogin: true
},
component: App,
children: [
{
path: 'index',
name: 'hello-index',
meta: {
title: '商品列表'
},
component: resolve => require.ensure([], () => resolve(require('app/pages/goods/Goods.vue').default), 'hello-goods')
},
{
path: 'newreq',
name: 'hello-newreq',
meta: {
title: '新品页面'
},
component: resolve => require.ensure([], () => resolve(require('app/pages/newreq/List.vue').default), 'hello-newreq')
},
]
}]
window.bapp && bapp.util.registerApp(APP_NAME, {router, store});
|
注意几点
- APP_NAME是业务线的唯一标识,也就是hello
- 业务线有自己内部的router和store
- 业务线主动调用registerApp,将自己的router和store与主项目融合
- store融合的时候需要添加namespace: true,因为此时整个hello业务线store成为了globalStore的一个module
- addRoutes和registerModule是router与store的动态注册方法
- 路由的name需要和主项目保持唯一
业务线配置更新业务线配置需要在hello每次编译完成后更新,更新分为本地调试更新和线上更新。
- 本地调试更新只需要更新一个本地配置文件service-line-config.json,然后在请求业务线config时由主项目读取该文件返回给js。
- 线上更新更为简单,每次发布编译后,将当前入口js+md5的完整url更新到后端
以上,看到使用webpack-plugin比较适合当前场景,实现如下
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | class ServiceUpdatePlugin {
constructor(options) {
this.options = options;
this.runCount = 0;
}
// 更新本地配置文件
updateLocalConfig({srcs}) {
....
}
// 更新线上配置文件
uploadOnlineConfig({files}) {
....
}
apply(compiler) {
// 调试环境:编译完毕,修改本地文件
if(process.env.NODE_ENV === 'dev') {
// 本地调试没有md5值,不需要每次刷新
compiler.hooks.done.tap('ServiceUpdatePlugin', (stats) => {
if(this.runCount > 0) {
return;
}
let assets = stats.compilation.assets;
let publicPath = stats.compilation.options.output.publicPath;
let js = Object.keys(assets).filter(item => {
// 过滤入口文件
return item.startsWith('js/');
}).map(path => `${publicPath}${path}`);
this.updateLocalConfig({srcs: js});
this.runCount++;
});
}
// 发布环境:上传完毕,请求后端修改
else {
compiler.hooks.uploaded.tap('ServiceUpdatePlugin', (upFiles) => {
let entries = upFiles.filter(file => {
return file &&
file.endsWith('js') &&
file.includes('js/');
});
this.uploadOnlineConfig({files: entries});
return;
})
}
}
}
|
注意,uploaded事件由我们项目组的静态资源上传plugin发出,会传递当前所有上传文件完整路径。需要等文件上传cdn完毕才可更新业务线 之后在webpack中使用即可
01 02 03 04 05 06 07 08 09 10 11 12 | /** hello **/
{
...
plugins: [
// 业务线js md5更新
new McServiceUpdatePlugin({
app_name,
configFile: path.resolve(process.cwd(), '../mainProject/app/service-line-config.json')
})
],
...
}
|
注意本地调试时业务线config是主项目才会用到的,因此直接更新主项目目录下的配置文件 调试发布基于上面的plugin,有以下效果 调试过程如下:- 启动主项目server(端口7777);
- 启动hello业务线server(端口7000),此时启动成功会同时更新本地文件service-line-config.json;
- 访问hello页面,加载本地配置后,加载7000端口提供的静态资源(如http://local.aaa.com:7000/dist/dev/js/hellobundle.js)
发布test过程如下:- 执行 npm run test
- 执行过程中会上传文件并更新test环境业务线配置
- 此时访问test环境页面已经更新
可以看到hello发布是比主项目更加轻量的,这是因为业务线只更新接口,但是主项目要发布还需要更新html的web服务 小结至此已经完成了一开始的主体需求,访问业务线页面后,业务线页面会和主项目页面合并成为1个新的spa,spa内部store和router完全共享。 可以看到主要利用了vue家族的动态注册方法。下面是一些过程中遇到的问题和解决思路 遇到的问题与解决hello业务线的wepback打包- 业务线需要独立的打包命名空间
- 为了能与主项目区分,会给hello业务线的bundle重命名,增加了业务线名称前缀
- 入口文件越少越好,因此删除了一些打包配置
- 删除了vendor: 主要第三方库由主项目加载
- 删除了dll: dll资源由主项目加载
- 删除了runtime(manifest)配置: 各业务线将各自处理依赖加载
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | /** hello **/
{
...
entry: {
[app_name + 'bundle']: path.resolve(SRC, `entry.js`)
},
output: {
publicPath: `http://local.aaa.com:${PORT}${devDefine.publicPath}`,
library: app_name // 业务线命名空间
},
...
optimization: {
runtimeChunk: false, // 依赖处理与bundle合并
splitChunks: {
cacheGroups: false // 业务线不分包
}
},
...
}
|
注意library的设置隔离了各个业务线入口文件
 依赖
  router拆分问题最开始使用/:name来做公共处理。 但是发现router的优先级按照数组的插入顺序,那么后插入的hello路由优先级将一直低于/:name路由。 之后使用*做公共处理,将一直处于兜底,问题解决。 store拆分hello的store做为globalStore的一个module注册,需要标注 namespaced: true,否则拿不到数据; store使用基本和主项目一致:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 | /** hello **/
let { Vuex } = bapp;
// 全局store获取
let { mapState: gmapState, mapActions: gmapActions, createNamespacedHelpers } = Vuex;
// 本业务线store获取
const { mapState, mapActions } = createNamespacedHelpers(`${APP_NAME}/feedback`)
export default {
...
computed: {
...gmapState('userInfo', {
userName: state => state.userName
}),
...gmapState('hello/feedback', {
helloName2: state => state.helloName
}),
...mapState({
helloName: state => state.helloName
})
},
}
|
接口拆分虽然前端工程拆分了,但是后端接口依然是走相同的域名,因此可以给hello暴露一个生成接口参数的公共方法,然后由hello自己组织。 公共利用可以直接使用全局组件,mixins,directives,可以直接使用font。局部的相关内容需要拷贝到hello或者暴露给hello才可用。图片完全无法复用 本地server工具主项目由于需要对request有比较精细的操作,因此是我们自己实现的express来本地调试。 但是hello工程的唯一作用是提供本地当前的js与css,因此使用官方devServer就够了。
文章转载自:https://juejin.cn/post/6844903742534336520
|
|