Products
GG网络技术分享 2025-03-18 16:14 0
在中台业务中,交易流转、业务运营和商户赋能等功能,主要集中在两个系统中(暂且命名为 inner/outer )。两个系统基座(功能框架)类似,以 inner 系统为例,如图:
inner系统基座
至今,inner/outer 均有以下特点:
初次接触上述问题时,闪现在脑海里的是:用 iframe 呀。确实,刚开始也是这样做的。
系统在一个长时间跨度的运行下,随着维护人员的变迁、使用人群的增多,更多的问题也接踵而至:
由于没有统一规范,每个功能模块在不同的开发者键盘下设想的结构不同,输出的风格也不统一,使整个系统看起来略显杂乱。
首先,iframe 页面没有自己的历史记录,使用的是基座(父页面)的浏览历史。所以,当iframe 页在内部进行跳转时,浏览器地址栏无变化,基座中加载的 src 资源也无变化,当浏览器刷新时,无法停留在iframe内部跳转后的页面上,需要用户重新走一遍操作,体验上会大打折扣。
iframe 页产生的弹窗,一般只能遮罩 iframe 区域。
与基座非同源下,iframe 无法直接获取基座 url 的参数,消息传递需要周转一下,如使用postmessage来实现;而动态创建的 iframe 页,或许还需要借助本地存储等。
iframe 资源变更上线后,打开系统会发现 iframe 页依旧是老资源。需要用时间戳方案或强制刷新。
与基座非同源下,onerror 事件无法使用。使用 try catch 解决此问题,尝试获取 contentDocument 时将抛出异常
以上问题,从业务价值看,对用户的使用体验会有损失;从工程价值看,希望能通过技术提升业务体验的同时,也提高系统的维护性。
大多数工程师,包括我,一边儿嘴里说着:学不动啦!一边儿想尝试一些新方式来优化系统。
结合问题分类,有思考一些尝试方向,如:
另外,大互联网时代,从工程角度看,社区对类似系统的探索有很多,除了 iframe 外,也有不少相对成熟的替代方案:
1. single-spa
2. qiankun
提起这两个,就要提一下微前端理念,目前社区有很多关于微前端架构的介绍,这里简单提一下:
大致是说,微前端有以下特点:
基于此,不难想到:iframe 也是符合微前端理念的。那其他方案又是如何做的呢?
社区里 single-spa 介绍也不少。根据 demo 比葫芦画瓢,可以知道它的架构分布:
single-spa架构
启动服务的配置主要是在single-spa-config 文件中,包含项目名称、 项目地址、路由配置等:
//single-spa-config.js
import{registerApplication,start}from'single-spa';
//子应用唯一ID
constmicroAppName='react';
//子应用入口
constloadingFunction=()=>import('./react/app.js');
//url前缀校验
constactivityFunction=location=>location.pathname.startsWith('/react');
//注册
registerApplication(
microAppName,
loadingFunction,
activityFunction
);
//singleSpa启动
start();
single-spa 让基座和子应用共用一个 document,那就需要对子应用进行改造:把子项目的容器和生成的 js 插入到基座项目中。
<divid='micro-react'></div>
<scriptsrc=/js/chunk-vendors.js></script>
<scriptsrc=/js/app.js></script>
不过这种方式需要对现有项目的打包方式和配置项进行改造,成本很大。所以,对于已有的工程项目,我选择了放弃使用。
qiankun 也是社区提到比较多的一个开源框架,是基于single-spa 实现了开箱即用。可以采用html entry 方式接入子应用,且子应用只需暴露一些生命周期,改动较少。【少】这个点,真是让我跃跃欲试。
目前我司业务场景是单实例模式(一个运行时只有一个子应用被激活),我们可以根据一张图来看看单实例下以html entry方式 qiankun 实现流程:
qiankun原理
如上图所示,一个子应用的全过程有:
具体实现细节,大家可以参考qiankun源码。
从规范化开发角度,我司的中后台系统是基于 umi 开发(详细可参考我们之前的文章 umi 中后台项目实践)。在构建主应用使用了配套的 qiankun 插件:@umijs/plugin-qiankun。
1. 初始化配置项,注册子应用
插件安装之后,我们可以在入口文件里配置:
此处主要以运行时为例
//app.js
exportconstqiankun=Promise.resolve().then(()=>({
//运行时注册子应用信息
apps:[
{
//结算单管理
name:'settlement',//唯一id,与子应用的library保持一致
entry:'//xxx',//htmlentry
history:'hash',//子应用的history配置,默认为当前主应用history配置
container:'#root-content',//子应用存放节点
mountElementId:'root-content'//子应用存放节点
},{
//公告消息
name:'news',//唯一id,与子应用的library保持一致
entry:'//xxx',//htmlentry
history:'hash',//子应用的history配置,默认为当前主应用history配置
container:'#root-content',//子应用存放节点
mountElementId:'root-content'//子应用存放节点
}
],
jsSandbox:{strictStyleIsolation:true},//是否启用js沙箱,默认为false
prefetch:true,//是否启用prefetch特性,默认为true
lifeCycles:{
//seehttps://github.com/umijs/qiankun#registermicroapps
beforeLoad:(props)=>{
returnPromise.resolve(props).then(()=>loading())
},
afterMount:(props)=>{
console.log('afterMount',props)
},
afterUnmount:(props)=>{
console.log('afterUnmount',props)
}
}
}))
2. 装载子应用,在路由配置中使用microApp来获取相应的子应用名称:
//router.config.js
exportdefault[
{
path:'/',
component:'../layouts/BasicLayout',
routes:[
...
{
path:'/settlement/list',
name:'结算单管理',
icon:'RedEnvelopeOutlined',
microApp:'settlement',//子应用唯一id
},
{
path:'/settlement/detail/:id',
name:'结算单管理',
icon:'RedEnvelopeOutlined',
microApp:'settlement',//子应用唯一id
hideInMenu:true,
},
...
...
{
component:'./404',
},
],
},
{
component:'./404',
},
]
以上就是基座的改动点,看起来代码侵入性很少。
在子应用中,需要做如下的配置
1. 入口文件设置 baseName,及暴露钩子函数
//设置主应用下的子应用路由命名空间
constBASE_NAME=window.__POWERED_BY_QIANKUN__?"/settlement":"";
//独立运行时,直接挂载应用
if(!window.__POWERED_BY_QIANKUN__){
effectRender();
}
//在子应用初始化的时候调用一次
exportasyncfunctionbootstrap(){
console.log("ReactMicroAppbootstraped");
}
exportasyncfunctionmount(props){
console.log("ReactMicroAppmount",props);
effectRender(props);
}
//卸载子应用的应用实例
exportasyncfunctionunmount(props){
const{container}=props||{};
ReactDOM.unmountComponentAtNode(document.getElementById('root-content')
);
}
2. webpack 配置中,需要设置输出为 umd 格式:
//设置别名
merge:{
plugins:[newwebpack.ProvidePlugin({
React:'react',
PropTypes:'prop-types'
})],
output:{
library:`[name]`,//子应用的包名,这里与主应用中注册子应用名称一致
libraryTarget:"umd",//所有的模块定义下都可运行的方式
jsonpFunction:`webpackJsonp_ReactMicroApp`,//按需加载
}
}//自定义webpack配置
OK,配置完成!
理论上,启动项目,部署等都应该没有问题了。咦,打开地址,页面一直在 loading,控制台一堆报错,看起来要踩一踩坑了。
1. 版本一致性
如果主应用和子应用都是基于 umi 框架,在使用 @umijs/umi-plugin-qiankun 插件时,要使用同一个版本,否则子应用报错。
2. 跨域
qiankun 是通过 fetch 去获取子应用资源的,所以必须支持跨域
constmountDOM=appWrapperGetter();
const{fetch}=frameworkConfiguration;
constreferenceNode=mountDOM.contains(refChild)?refChild:null;
if(src){
execScripts(null,[src],proxy,{
fetch,
strictGlobal:!singular,
beforeExec:()=>{
Object.defineProperty(document,'currentScript',{
get():any{
returnelement;
},
configurable:true,
})
};
})
}
比如:基座地址为 b.zhuanzhuan.com, 子应用为 d.zhuanzhuan.com 。当基座去加载子应用时,会出现跨域错误。
曾经有采用通过 Node 服务做一层中转,跳过跨域问题:
....
maxDays:3,//保留最大天数日志文件
}
//代理
config.httpProxy={
'/cors':{
target:'https://d.zhuanzhuan.com',
pathRewrite:{'^/cors':''}
}
};
returnconfig
但考虑应用的访问量,以及线上线下环境维护成本,觉得必要性不是很大,最终选择通过 nginx 解决跨域。
3. 子应用内部跳转
子应用内部跳转,需要在基座路由上提前注册好,否则在跳转后,页面识别不到。
{
path:'/settlement/detail/:id',
name:'结算单管理',
icon:'RedEnvelopeOutlined',
microApp:'settlement',
hideInMenu:true,
},
4. css 污染
qiankun 只能解决子应用之间的样式相互污染,不能解决子应用样式污染基座的样式。比如:当切换到某个子应用时,左侧菜单栏突然往右移了。
系统右移
查看控制台,不难发现,子应用的相同模块覆盖了基座:
样式覆盖
这个问题,可以通过改变基座的前缀来解决,搞一个postcss 插件给不同的组件添加不同的前缀。
这里补充一个 css 隔离常用的方式如:css前缀、CSS Module、动态加载/卸载样式表。
qiankun 中 css沙箱机制 采用的是 动态加载/卸载样式表。
//Justoverwriteitwhileithavenotbeenoverwrite
if(
HTMLHeadElement.prototype.appendChild===rawHeadAppendChild&&
HTMLBodyElement.prototype.appendChild===rawBodyAppendChild&&
HTMLHeadElement.prototype.insertBefore===rawHeadInsertBefore
){
HTMLHeadElement.prototype.appendChild=getOverwrittenAppendChildOrInsertBefore({
rawDOMAppendOrInsertBefore:rawHeadAppendChild,
appName,
appWrapperGetter,
proxy,
singular,
dynamicStyleSheetElements,
scopedCSS,
excludeAssetFilter,
})astypeofrawHeadAppendChild;
....
//Justoverwriteitwhileithavenotbeenoverwrite
if(
HTMLHeadElement.prototype.removeChild===rawHeadRemoveChild&&
HTMLBodyElement.prototype.removeChild===rawBodyRemoveChild
){
HTMLHeadElement.prototype.removeChild=getNewRemoveChild({
appWrapperGetter,
headOrBodyRemoveChild:rawHeadRemoveChild,
});
HTMLBodyElement.prototype.removeChild=getNewRemoveChild({
appWrapperGetter,
headOrBodyRemoveChild:rawBodyRemoveChild,
});
}
看起来很完美,但有时候会出现,基座样式丢失的问题。这个跟子应用卸载的时机有关系:当切换子应用时,当前子应用沙箱环境还未被卸载,但基座 css 已被插入,当卸载时会连带基座 css 一起被清除。
5. 错误捕获,降级处理
若子应用加载失败,需要给相应的提示或动态插入iframe页:
//iframe.js
exportdefault({sourceUrl})=>
<iframe
src={sourceUrl}
title="xxxx"
width="100%"
height="100%"
border="0"
frameBorder="0"
/>
import{render}from'react-dom';
//全局未捕获异常处理器
addGlobalUncaughtErrorHandler((event)=>{
console.error(event);
const{message,location:{hash}}=event;
//加载失败时提示
if(message&&message.includes("diedinstatusLOADING_SOURCE_CODE")){
Modal.Confirm({
content:"子应用加载失败,请检查应用是否可运行"
onOk:()=>import('./Inframe.js')
});
}
});
6. 路由懒加载样式丢失
子应用中存在按需加载的路由,在加载时页面样式丢失,这是官方库产生的问题,issue 里已有大佬提 PR 啦,可参考 https://github.com/umijs/qiankun/issues/857
以上,就是不完全踩坑。
此次接入 qiankun,也只是处于表面应用。后续我们更要思考接入它之后更深的工程价值,如:
- 自动接入 qiankun
结合我司已有的脚手架和 umi 模板,额外添加一个命令,自动注册子应用,做到自动化。
- 子应用间组件共享
基座和子应用大概率都用到了 react/dva 等,是否可以在基座加载完之后,子应用直接复用?当然,浅显思考应该少不了 webpack 的 externals。
在这里就不讲微前端的各种优缺点,直接假设你在负责一个中后台管理系统的开发,所有的业务模块全部都在一个项目中打包,随着业务量的不断增长,编译越来越慢,你期望可以从老的项目中将新的业务进行独立开发、独立部署,以微应用的形式嵌入到老项目中。
本篇文章的受众是那些希望在新老的项目中,在不需要你对老项目进行改动老项目的前提下,嵌入微应用,如果本篇文章对你有帮助,请点个:+1:!
核心要素
构建生产环境代码,输出远程组件所需的 JSON
通过 ajax 请求,拿到这个 JSON 的数据,传给 远程组件
新项目的搭建
对于项目的搭建,在这里就不再仔细的展开讲了,大家直接看Genesis项目快速开始的文档吧
修改编译代码
import path from \'path\'; import fs from \'fs\'; import { Build } from \'@fmfe/genesis-compiler\'; import { ssr } from \'./genesis\'; const start = async () => { /** * 创建一个编译实例 */ const build = new Build(ssr); /** * 开始执行编译程序,构建生产环境应用包 */ await build.start(); /** * 编译完成后,创建一个渲染器,输出远程组件所需的内容 */ const renderer = ssr.createRenderer(); /** * CSR 渲染输出 JSON */ const result = await renderer.render({ mode: \'csr-json\' }); /** * 将 JSON 保存到客户端目录 */ fs.writeFileSync( path.resolve(ssr.outputDirInClient, \'app.json\'), JSON.stringify(result.data, null, 4), \'utf8\' ); }; start(); |
如果你是根据官方文档来走的话,你只需要自己修改一下genesis.build.ts文件即可。如果你自定义了文件名,只要在await build.start()方法执行完成后,创建一个渲染器,将 CSR 的渲染结果保存到一个 JSON 文件即可。
编译完成后,你得到的大概是这样的一个文件
你只需要将client
目录的静态文件部署到你的服务器即可。
老项目远程组件加载
假设你的静态资源的基本路径是/ssr-genesis/,那么你就可以通过/ssr-genesis/app.json访问到这个文件。
在你的老项目上安装远程组件
npm install @fmfe/genesis-remote axios |
在你对应的路由上,使用远程组件,加载此组件即可。
<template> <div class=\"app\"> <RemoteView :fetch=\"fetch\" /> </div> </template> <script lang=\"ts\"> import Vue from \'vue\'; import axios from \'axios\'; import { RemoteView } from \'@fmfe/genesis-remote\'; export default Vue.extend({ components: { RemoteView }, methods: { async fetch() { const res = await axios.get(\'/ssr-genesis/app.json\'); if (res.status === 200) { return res.data; } return null; } } }); </script> |
多历史模式的支持
如果你老项目使用历史模式,在子应用也使用历史模式,会导致router.push(\'/xxx\')产生多条历史记录,为了解决这个问题,你可以在子应用使用abstract模式,或者使用 @fmfe/genesis-app 帮你处理多个 Router 实例使用历史模式产生的BUG,注意,你需要在新老项目一起使用 @fmfe/genesis-app 包装后的 Router
拓展
/** * CSR 渲染输出 HTML */ const result = await renderer.render({ mode: \'csr-html\' }); /** * 将 html 保存到客户端目录 */ fs.writeFileSync( path.resolve(ssr.outputDirInClient, \'index.html\'), result.data \'utf8\' ); |
如果你仅仅只是想做 CSR 的项目,只需要渲染出 html 即可,和平常的 CSR 项目一样,将client目录部署到服务器。
总结
Genesis只是一个 Vue SSR 的渲染库,它不是框架,它只是给你提供了最基础的 SSR 渲染能力。
到此这篇关于基于Vue CSR的微前端实现方案实践的文章就介绍到这了,希望大家以后多多支持!
Demand feedback