一、前言
随着项目的发展,前端SPA应用的规模不断加大、业务代码耦合、编译慢,导致日常的维护难度日益增加。同时前端技术的发展迅猛,导致功能扩展吃力,重构成本高,稳定性低。因此前端微服务应运而生。
前端微服务优势
1.复杂度可控: 业务模块解耦,避免代码过大,保持较低的复杂度,便于维护与开发效率。
2.独立部署: 模块部署,减少模块影响范围,单个模块发生错误,不影响全局,提升项目稳定性。
3.技术选型灵活: 在同一项目下可以使用市面上所有前端技术栈,也包括未来的前端技术栈。
4.扩展性,提升业务动态扩展的可能,避免资源浪费
微前端服务结构
技术对比和选型:
| 选型 | 静态资源预加载 | 子应用保活 | iframe | js沙箱 | css沙箱 | 接入成本 | 地址 |
|---|---|---|---|---|---|---|---|
| EMP | √ | √ | × | × | × | 低 | https://github.com/efoxTeam/emp |
| Qiankun | √ | × | × | √ | √ | 中低 | https://qiankun.umijs.org/zh/ |
| 无界 | √ | √ | √ | √ | √ | 中低 | https://wujie-micro.github.io/doc/ |
| micro-app | √ | × | × | √ | √ | 中低 | https://zeroing.jd.com/micro-app/ |
通过对比多种技术对项目的支持情况和项目接入的成本,我们最终选型无界。
二、wujie简单用法(以主应用使用vue框架为例)
主应用是vue框架可直接使用wujie-vue,react框架可直接使用wujie-react,先安装对应的插件哦
主应用改造:
1// 引入无界,根据框架不同版本不同,引入不同的版本 2import { setupApp, bus, preloadApp, startApp } from 'wujie-vue2' 3 4// 设置子应用默认参数 5setupApp({ 6 name: '子应用id(唯一值)', 7 url: "子应用地址", 8 exec: true, 9 el: "容器", 10 sync: true 11}) 12 13// 预加载 14preloadApp({ name: "唯一id"}); 15 16// 启动子应用 17startApp({ name: "唯一id"});
子应用改造:
1、跨域
子应用如果支持跨域,则不用修改
原因:存在请求子应用资源跨域
方案:因前端应用基本是前后端分离,使用proxy代理。只需配置在子应用配置允许跨域即可
1// 本地配置 2server: { 3 host: '127.0.0.1', // 本地启动如果主子应用没处在同一个ip下,也存在跨域的问题,需要配置 4 headers: { 5 'Access-Control-Allow-Credentials': true, 6 'Access-Control-Allow-Origin': '*', // 如资源没有携带 cookie,需设置此属性 7 'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type', 8 'Access-Control-Allow-Methods': '*' 9 } 10} 11 12// nginx 配置 13add_header Access-Control-Allow-Credentials true; 14add_header Access-Control-Allow-Origin "*"; 15add_header Access-Control-Allow-Headers 'X-Requested-With,Content-Type'; 16add_header Access-Control-Allow-Methods "*";
2、运行模式选择
无界有三种运行模式:单例模式、保活模式、重建模式
(1)、保活模式(长存页面)
释义:类似于vue的keep-alive性质(子应用实例和webcomponent不销毁,状态、路由都不丢失,只做热webcomponent的热插拔),子应用不想做生命周期改造,子应用切换又不想有白屏时间,可以采用保活模式。主应用上有多个入口跳转到子应用的不同页面,不能采用保活模式,因为无法改变子应用路由。
配置:只需要在主应用加载子应用的时候,配置参数添加alive:true
效果:预加载+保活模式=页面数据请求和渲染提前完成,实现瞬间打开效果
(2)、单例模式
释义:子应用页面切走,会调用window.__WUJIE_UNMOUNT销毁子应用当前实例。子应用页面如果切换回来,会调用window.__WUJIE_MOUNT渲染子应用新的子应用实例。过程相当于:销毁当前应用实例 => 同步新路由 => 创建新应用实例
配置:只需要在主应用加载子应用的时候,配置参数添加alive:false
改造生命周期
1// window.__POWERED_BY_WUJIE__用来判断子应用是否在无界的环境中 2if (window.__POWERED_BY_WUJIE__) { 3 let instance; 4 // 将子应用的实例和路由进线创建和挂载 5 window.__WUJIE_MOUNT = () => { 6 const router = new VueRouter({ routes }); 7 instance = new Vue({ router, render: (h) => h(App) }).$mount("#app"); 8 }; 9 // 实例销毁 10 window.__WUJIE_UNMOUNT = () => { 11 instance.$destroy(); 12 }; 13} else { 14 // 子应用单独启动 15 new Vue({ router: new VueRouter({ routes }), render: (h) => h(App) }).$mount("#app"); 16} 17
(3)、重建模式
释义:每次页面切换销毁子应用webcomponent+js的iframe。
配置:只需要在主应用加载子应用的时候,配置参数添加alive:false
无生命周期改造
备注:非webpack打包的老项目,子应用切换可能出现白屏,应尽可能使用保活模式降低白屏时间
三、加载模块(主应用配置)
子应用基础信息管理
<!---->1// subList.js 数据可在配置页面动态配置 2const subList = [ 3 { 4 "name":"subVueApp1", 5 "exec":true,// false只会预加载子应用的资源,true时预执行子应用代码 6 "alive": true, 7 "show":true,// 是否引入 8 "url":{ 9 "pre":"http://xxx1-pre.com", 10 "gray":"http://xxx1-gray.com", 11 "prod":"http://xxx1.com" 12 } 13 }, 14 { 15 "name":"subVueApp2", 16 "exec":false,// false只会预加载子应用的资源,true时预执行子应用代码 17 "alive": false, 18 "show":true,// 是否引入 19 "url":{ 20 "pre":"http://xxx2-pre.com", 21 "gray":"http://xxx2-gray.com", 22 "prod":"http://xxx2.com" 23 } 24 } 25] 26export default subList;
1// hostMap.js 2import subList from './subList' 3 4 const env = process.env.mode || 'pre' 5 6// 子应用map结构 7const subMap = {} 8const subArr = [] 9 10// 转换子应用 11export const hostMap = () => { 12 subList.forEach(v => { 13 const {url, ...other} = v 14 const info = { 15 ...other, 16 url: url[env] 17 } 18 subMap[v.name] = info 19 subArr.push(info) 20 }) 21 return subArr 22} 23 24// 获取子应用配置信息 25export const getSubMap = name => { 26 return subMap[name].show ? subMap[name] : {} 27}
子应用注册预加载和启动
1// setupApp.js 2import WujieVue from 'wujie-vue2'; 3import {hostMap} from './hostMap'; 4 5const { setupApp, preloadApp } = WujieVue 6 7const setUpApp = Vue => { 8 Vue.use(WujieVue) 9 hostMap().forEach(v => { 10 setupApp(v) 11 preloadApp(v.name) 12 }) 13} 14export default setUpApp; 15 16 17// main.js 18import Vue from 'vue' 19import setUpApp from'@/microConfig/setupApp' 20setUpApp(Vue)
配置公共函数
全子应用共享的生命周期函数,可用于执行多个子应用间相同的逻辑操作函数共同处理
1// lifecycle.js 2const lifecycles = { 3 beforeLoad: (appWindow) => console.log(`${appWindow.__WUJIE.id} beforeLoad 生命周期`), 4 beforeMount: (appWindow) => console.log(`${appWindow.__WUJIE.id} beforeMount 生命周期`), 5 afterMount: (appWindow) => console.log(`${appWindow.__WUJIE.id} afterMount 生命周期`), 6 beforeUnmount: (appWindow) => console.log(`${appWindow.__WUJIE.id} beforeUnmount 生命周期`), 7 afterUnmount: (appWindow) => console.log(`${appWindow.__WUJIE.id} afterUnmount 生命周期`), 8 activated: (appWindow) => console.log(`${appWindow.__WUJIE.id} activated 生命周期`), 9 deactivated: (appWindow) => console.log(`${appWindow.__WUJIE.id} deactivated 生命周期`), 10 loadError: (url, e) => console.log(`${url} 加载失败`, e), 11}; 12 13export default lifecycles; 14 15 16// subCommon.js 17// 跳转到主应用指定页面 18const toJumpMasterApp = (location, query) => { 19 20 this.$router.replace(location, query); 21 const url = new URL(window.location.href); 22 url.search = query 23 // 手动的挂载url查询参数 24 window.history.replaceState(null, '', url.href); 25} 26// 跳转到子应用的页面 27const toJumpSubApp = (appName, query) => { 28 this.$router.push({path: appName}, query) 29} 30export default { 31 toJumpMasterApp, 32 toJumpSubApp 33} 34 35 36// setupApp.js 37import lifecycles from './lifecycles'; 38import subCommon from './subCommon'; 39const setUpApp = Vue => { 40 .... 41 hostMap().forEach(v => { 42 setupApp({ 43 ...v, 44 ...lifecycles, 45 props: subCommon 46 }) 47 preloadApp(v.name) 48 }) 49}
主应用加载子应用页面
1// 子应用页面加载 2// app1.vue 3<template> 4 <WujieVue 5 :key="update" 6 width="100%" 7 height="100%" 8 :name="name" 9 :url="appUrl" 10 :sync="subVueApp1Info.sync" 11 :alive="subVueApp1Info.alive" 12 :props="{ data: dataProps ,method:{propsMethod}}" 13 ></WujieVue> 14</template> 15 16<script> 17import wujieVue from "wujie-vue2"; 18import {getSubMap} from '../../hostMap'; 19const name = 'subVueApp1' 20export default { 21 data() { 22 return { 23 dataProps: [], 24 subVueApp1Info: getSubMap(name) 25 } 26 }, 27 computed: { 28 appUrl() { 29 // return getSubMap('subVueApp1').url 30 return this.subVueApp1Info.url + this.$route.params.path 31 } 32 }, 33 watch: { 34 // 如果子应用是保活模式,可以采用通信的方式告知路由变化 35 "$route.params.path": { 36 handler: function () { 37 wujieVue.bus.$emit("vue-router-change", `/${this.$route.params.path}`); 38 }, 39 immediate: true, 40 }, 41 }, 42 methods: { 43 propsMethod() {} 44 } 45} 46</script>
四、子应用配置
无界的插件体系主要是方便用户在运行时去修改子应用代码从而避免去改动仓库代码
<!---->1// plugins.js 2const plugins = { 3 'subVueApp1': [{ 4 htmlLoader:code => { 5 return code; 6 }, 7 cssAfterLoaders: [ 8 // 在加载html所有样式之后添加一个外联样式 9 { src:'https://xxx/xxx.css' }, 10 // 在加载html所有样式之后添加一个内联样式 11 { content:'img{height: 300px}' } 12 ], 13 jsAfterLoaders: [ 14 { src:'http://xxx/xxx.js' }, 15 // 插入一个内联脚本本 16 { content:` 17 window.$wujie.bus.$on('routeChange', path => { 18 console.log(path, window, self, global, location) 19 })` 20 }, 21 // 执行一个回调 22 { 23 callback(appWindow) { 24 console.log(appWindow.__WUJIE.id); 25 } 26 } 27 ] 28 }], 29 'subVueApp2': [{ 30 htmlLoader: code=> { 31 return code; 32 } 33 }] 34}; 35export default plugins;
1// setupApp.js 2import plugins from './plugins'; 3const setUpApp = Vue => { 4 ...... 5 hostMap().forEach(v => { 6 setupApp({ 7 ...v, 8 plugins: plugins[element.name] 9 }) 10 ...... 11 }) 12}
五、数据传输和消息通信
数据交互方式
1,通过props进行传
2、通过window进线传达
3,通过事件bus进行传达
props
主应用通过data传参给子应用, 子应用通过methods方法传参给主应用
1// 主应用 2<WujieVue name="xxx" url="xxx" :props="{ data: xxx, methods: xxx }"></WujieVue> 3 4// 子应用 5const props = window.$wujie?.props; // {data: xxx, methods: xxx}
window
利用子应用运行在主应用的iframe
类似iframe的传参和调用
1// 主应用获取子应用的全局变量数据 2window.document.querySelector("iframe[name=子应用id]").contentWindow.xxx; 3 4//子应用获取主应用的全局变量数据 5window.parent.xxx;
eventBus
去中心化的通信方案,方便。类似于组件间的通信
主应用
1// 使用 wujie-vue 2import WujieVue from"wujie-vue"; 3const{ bus }= WujieVue; 4 5// 主应用监听事件 6bus.$on("事件名字",function(arg1,arg2, ...){}); 7// 主应用发送事件 8bus.$emit("事件名字", arg1, arg2,...); 9// 主应用取消事件监听 10bus.$off("事件名字",function(arg1,arg2, ...){});
子应用
1// 子应用监听事件 2window.$wujie?.bus.$on("事件名字",function(arg1,arg2, ...){}); 3// 子应用发送事件 4window.$wujie?.bus.$emit("事件名字", arg1, arg2,...); 5// 子应用取消事件监听 6window.$wujie?.bus.$off("事件名字",function(arg1,arg2, ...){});
规范主子应用传递规则
规则:子应用名+事件名
主应用向子应用传参
1// 主应用传参 2bus.$emit('matser', options) // 主应用向所有子应用传参 3bus.$emit('vite:getOptions', options) // 主应用向指定子应用传参 4 5//子应用监听主应用事件 6window?.$wujie?.bus.$on("master", (options) => { 7 console.log(options) 8}); 9//子应用监听主应用特定通知子应用事件 10window?.$wujie?.bus.$on("vite:getOptions", (options) => { 11 console.log(options) 12});
六、路由
以 vue 主应用为例,子应用 A 的 name 为 A, 主应用 A 页面的路径为/pathA,子应用 B 的 name 为 B,主应用 B 页面的路径为/pathB为例
主应用统一props传入跳转函数
1jump (location) { 2 this.$router.push(location); 3}
1、主应用history路由
子应用 B 为非保活应用
1、子应用A 只能跳转到子应用 B 的主应用的默认路由
1function handleJump(){ 2 window.$wujie?.props.jump({ path:"/pathB"}); 3}
2、子应用A 只能跳转到子应用B 应用的指定路由(非默认路由)
1// 子应用A点击跳转处理函数, 子应用B需开启路由同步 2function handleJump(){ 3 window.$wujie?.props.jump({ path:"/pathB", query:{ B:"/test"}}); 4}
子应用 B 为保活应用
子应用A 只能跳转到子应用 B 的主应用的路由
可写入主应用的插件中,主应用插件根据不同的应用,引入不同方法
1// 子应用 A 点击跳转处理函数 2function handleJump() { 3 window.$wujie?.bus.$emit("routeChange", "/test"); 4} 5 6// 子应用 B 监听并跳转 7window.$wujie?.bus.$on("routeChange", (path) => this.$router.push({ path }));
2、主应用hash路由
子应用 B 为非保活应用
1、子应用A 只能跳转到子应用 B 的主应用的默认路由
同子应用B为非保活应用,子应用A跳转到子应用 B 的主应用的默认路由
2、子应用A 只能跳转到子应用B 应用的指定路由(非默认路由)
1主应用 2jump(location,query){ 3 // 跳转到主应用B页面 4 this.$router.push(location); 5 const url=new URL(window.location.href); 6 url.search=query 7 // 手动的挂载url查询参数 8 window.history.replaceState(null,"",url.href); 9} 10 11// 子应用 B 开启路由同步能力 12 13 14// 子应用A 15function handleJump() { 16 window.$wujie?.props.jump({ path: "/pathB" } , `?B=${window.encodeURIComponent("/test")}`}); 17}
子应用 B 为保活应用
同子应用B为保活应用,子应用A跳转到子应用 B 路由
1// bus.js 2// 在 xxx-sub 路由下子应用将激活路由同步给主应用,主应用跳转对应路由高亮菜单栏 3 bus.$on('sub-route-change', (name, path) => { 4 const mainName = `${name}-sub`; 5 const mainPath = `/${name}-sub${path}`; 6 const currentName = router.currentRoute.name; 7 const currentPath = router.currentRoute.path; 8 if (mainName === currentName && mainPath !== currentPath) { 9 router.push({ path: mainPath }); 10 } 11 });
七、部署
前端单页面的部署,不管怎么自动化,工具怎么变. 都是把打包好的静态文件,放到服务器的正确位置下。所以支持项目的独立部署和混合部署。
作者:京东物流 张燕燕 刘海鼎
内容来源:京东云开发者社区
