引言:
提升H5商城整体用户体验,规范跨团队的协同开发,提供一套架构方案来明确各个系统之间的依赖和边界,适应当前国际应用场景的架构设计;此方案已经应用到了印尼M端购物车、结算页、个人中心、订单等核心模块;本文将会从背景描述、问题分析、方案设计、应用接入、总结五个方面介绍,为读者全面解析微前端在商城C端的使用;
对比app的用户体验,一般都会认为H5的体验会差些,其一在依赖资源加载上,app除了首次需要下载资源,以后每次打开,依赖资源不需要重新下载,只需要动态更新数据,而前端页面访问都是在每个页面加载的时候去拉取的页面需要加载的资源,其二在应用访问模式上,app是一个单应用,页面之间的跳转,不需要重新打开APP,页面都在共享一个应用底座,页面以组件的纬度加载,而对于大部分H5应用而言,页面之间没有共享,每次打开下一个页面都是从头开始加载,这也说明单应用访问模式比多页应用模式在体验上更好;从浏览器对页面的加载机制上去解析,我们都知道从浏览器的地址栏输入一个页面的地址到最终页面的呈现,浏览器做了很多事情,页面的整个加载流程,可以分为以下几步:
(1)DNS的域名解析;
(2)浏览器与web服务建立TCP的三次握手;
(3)浏览器发送Request请求;
(4)web服务返回Response响应;
(5)浏览器解析html、CSS;
(6)构建对象模型,生成DOM树和CSSOM树,最终生成渲染树;
(7)浏览器进行 布局渲染;
(8)对DOM的操作,会进行的重排、重绘,会继续重复以上的第7步;
以上我们可以大致分为两个阶段:1、2、3、4点为页面加载白屏阶段,5、6、7、8点为页面的渲染阶段;而多页应用的访问,每个页面的加载都会经历这两个阶段;单页应用除了初始化访问的时候需要完成完整的两个阶段,而下级页面的跳转访问,只有少量的资源需要完成加载阶段,所有的子级页面,共享渲染树,通过JS动态更新DOM,通过重排、重绘来渲染下级页面;从整个加载流程上来说,单页应用的加载会比多页应用加载少了很多的步骤,缩短页面的加载路径,并且页面之间能共享通用资源,减少了资源的重复加载,完成局部刷新,提高了整体的用户体验;因此对于一个站点而言有必要使用单页应用模式访问,能极大提高用户体验;如下图:
图1:页面的加载流程图
目前行业有很多实现方式,其底层核心有两种方式实现,一直是依赖地址的hash变化,通过浏览器提供的API监听地址上的hash值变化,实现页面的局部刷新;第二种是的通过html5提供的history对象,history的值更新,监听history值的变化实现页面的局部刷新;vue和react这些成熟的框架都有很好的支持,无论使用哪种框架,传统的方式在一个开发工程中进行实现所有的路由管理,关联不同的业务模块,以此来实现页面的切换,这种方式要求所有的业务模块都在一个工程代码中,技术栈统一;
图2:单应用路由模式
随着电商业务的快速发展,业务类型增多,导致系统复杂度变高,一个站点支撑的业务模块有很多,一个业务模块要支持多种业务类型,很可能会分在不同的团队来进行维护;也可能因为历史原因,技术栈也存在不统一,即使框架统一,框架的版本也不同;基于以上的这些情况,如果采用传统的方案,把所有的页面模块都改造成统一的技术栈,放在一个工程代码中进行维护,将是一种灾难,也会带来很多问题;
(1)代码的安全性很难保障,牵一发而动全身,特别是多团队协同,如果维护在一个工程中,需要大家有很强的规范意识;
(2)技术架构的扩展性差,对于一些新的技术引入,或者组件升级都会带来很大的系统负担;
(3)没有时间对历史项目做技术栈统一改造,这将是一个庞大的工程,没有一个老板会允许这样不计成本的做,无法做到系统渐进式升级;
因此采用统一的工程来实现单应用模式已经不太适用,只能适用在简单的工程中;微前端的引入可以解决以上这些问题;
图3:一个站点的应用情况
区别于商家端的使用,在商城C端中使用微前端要求更高,总结有以下几个方面的诉求:
所有的单页应用模式都存在首屏渲染的问题,在微前端架构中同样存在,商城端的业务比如首页、商品、类目等这些页面对SEO的首屏渲染是强诉求,通过地址访问就需要首屏内容的渲染出来;
在C端很多页面都是动态下发A标签,直接通过A标签的跳转,会重新加载下级页面,整个分流加载的单应用模式就会被破坏,这种情况在C端比较常见,比如首页中配置的活动、商品、店铺等;
目前微前端通常采用的方案是动态访问子应用页面资源,识别子页面中的静态资源再进行动态加载,挂载到基座应用提供的子应用容器中;基于这种加载模式,优势是能够完全的实现子应用的内治和隔离,应用之间的依赖性非常低,接入成本低;但对于C端的应用还是带来一些资源加载的冗余,首先子应用的加载不一定需要通过子应用的页面,只需要知道子应用需要依赖哪些静态资源就可以;其次如果直接访问子应用的路径,不一定先加载基座应用,再加载子应用页面;这些对子应用的加载性能都是有损的;
微前端体现了一种高内聚、松耦合的架构思想,是把一个复杂的前端应用系统拆解成一个个小的子应用模块,明确他们之间的关系,这些子应用可以实现独立维护、独立部署,也可以独立访问,通过基座应用来管理各个子应用的有序加载,实现子应用的切换;代码库更小,更加内聚,可维护性更高;对系统进行合理的拆解、结合BFF层实现首屏数据的加载、去中心化的设计模式保障了SEO的诉求,也解决了首次访问的性能问题,优化子应用的加载机制提高了子应用的访问效率;
将一个站点拆分成多个应用在维护,这符合团队的发展,也符合业务的发展,对于用户而言,只关心整体性的体验;微前端把整个站点分为基座应用和子应用两种类型,比如商品详情、购物车、订单等这些模块可以算成是子应用,维护在不同团队,通过基座应用建立起联系,保障子应用之间的跳转仍然是单应用的访问模式;
图4:微前端的路由模式
基于node的BFF层实现,BFF的设计主要是确保每个子应用的直接访问,渲染出来的页面都是子页面需要的内容,在node端实现首屏数据渲染,解决页面SEO不友好的问题;同时以当前子应用为中心,加载通用资源,比如外层容器、资源清单、共享数据、公共依赖库等;
图5:前端服务BFF层的实现
区别目前行业内微前端加载模式(一个基座应用,动态加载多个子应用),去中心化的模式是模糊掉了基座应用的独立性,每个子模块都可以是基座应用,只要用户通过地址直接访问,那么该子应用升级为基座应用,再往其他模块进行分流;这样的设计解决了两个问题:(1)子应用的首屏渲染问题。(2)初次访问的性能问题;这种设计架构是依托与BFF服务的实现,需要在BFF层中实现两个统一:前后端路径的统一和每个子应用渲染模板结构统一;去中心化架构模式实现了用户访问目标页面有两种方式,一种是经过一级一级的往下分流,这种采用的是前端页面路由实现,页面数据是异步加载的;另一种是直接访问地址,使用服务端路由,触达页面,页面数据可以通过服务端首屏渲染;
图6:页面访问流转图
为了保持对外提供统一的地址,并且确保页面不管是从上级页面分流过来,还是从浏览器直接刷新加载,都能加载同样的内容,因此前端页面之间的跳转的路由和koa中实现的服务端路由保持一致;
图7:前后端路由映射图
确保每个子应用初始化加载,都成为基座应用,那么页面结构的一致性是非常必要的,封装了统一的view模板,每个子应用会在动态更新模板中中头部信息、子应用容器内容、子应用静态资源,保持所有子应用公共部分的有公共资源库、基座应用静态资源、通用资源库SDK等,动态组合渲染出子应用页面,所以不管哪个子应用页面初始化加载,都会成为基座应用,实现去中心化访问;
图8:子页面渲染内容
基于BFF服务的实现,在子应用中下发静态资源清单(所有接入的子应用所依赖的静态资源),在前端进行分流加载子应用的时候,会根据子应用的路由去静态资源清单中匹配模块所依赖的静态资源,按需加载,中间不需要依赖子应用服务,这样提高了子应用切换的效率,如下图:
图9:子应用加载流程图
静态资源清单作为共享数据,会下发每个接入的子应用的静态资源和版本,数据格式如下图:
"appname": {
"version": "v2022051101",
"css": [
"https://i.3.cn/appname/[version]/css/app.css"
],
"js": [
"https://i.3.cn/appname/[version]/js/manifest.js",
"https://i.3.cn/appname/[version]/js/vendor.js",
"https://i.3.cn/appname/[version]/js/app.js"
]
}
在C端页面之间的跳转有两种方式,事件驱动跳转和A标签跳转,第一种事件驱动的方式跳转下级页面比较简单,直接触发基座组件中提供的方法MicroJump,再调用基座路由跳到下级页面,这种是主动型的;第二种通过A标签的跳转的,这种方式的实现是被动型的,基座组件在加载子应用的时候,就会通过委托代理方式的监听子应用中所有的A标签事件,在点击A标签的时候,会先触发基座组件的事件,基座组件会从配置数据中识别下级页面是否已经完成微前端的接入,如果已经完成,那么就会通过基座路由跳转到下级页面,如果没有配置,就会通过location直接跳转下级页面,这样保障了非微前端子应用的A标签跳转仍然正常跳转;如下图:
图10:页面切换流程图
1)在基座中注册页面组件,在基座应用的pages文件夹下面,注册子应用组件,注意appname,要保持与配置中心动态配置的模块key统一,并且不能重复,用于后面的静态资源匹配,在注册的过程中可以通过生命周期的钩子函数执行相关的操作;
/**
* name 子应用name 必填
* htmlSkeleton 骨架屏
* showLoading 是否线上loading圈,默认 true
* caches 是否缓存子应用的静态资源,默认为 true
* loadingTimes loading消失的兜底时间,如果想再子应用中控制,就越大越好
* title 用在title动态修改
* discription 用在discription动态修改
* hasListener 是否需要对a标签跳转拦截
* errorHandler 异常钩子,可以做异常告警
* completeHandler //渲染完成
* beforeHandler //子应用渲染之前
* unmountHandler //子应用卸载
*/
return (
<MicroFrontend
beforeHandler={beforeHandler}
completeHandler={completeHandler}
errorHandler={errorHandler}
hasListener
history={history}
htmlSkeleton={htmlSkeleton()}
name="appname"
showLoading={false}
title={translate('cartTitle')}
unmountHandler={unmountHandler}
/>
);
2)子应用注册,在基座应用增加路由,注意与node的服务端路由一致;
class Routers extends React.Component {
render() {
return (
<BrowserRouter>
<div id="router-container">
<Switch>
<Route component={demo} exact path="/app/demo" />
<Route component={appname} exact path="/app/appname" />
</Switch>
</div>
</BrowserRouter>
);
}
3)基座实现两种方式提供子应用之间的跳转
主动性的跳转机制,这种需要提供给子应用的事件中进行使用,比如通过click事件执行之后,跳转到下一个页面,如果是采用传统的location的方式,那么就会重新加载页面,需要使用这个API实现子应用之间的跳转,基座应用组件实现如下:
/** 对外提供跳转的api */
window.microAppJump = url => {
const pathRoute = url ? url.replace('https://xxx.jd.com', '') : '/';
history.push(pathRoute);
};
A标签的跳转机制,子应用接入微前端体系,并且在动态配置增加了“linkListeners”的子应用,这种跳转在C端比较常见,比如首页的楼层数据都是动态下发,有跳到活动页的,有跳到商品详情的等等,像这种跳转都是直接通过A标签的href跳转下一级页面,如果不做任何处理,那么跳到下一级页面就会完全刷新,因此需要通过基座实现对A标签跳转的拦截,确定下一级页面是已经接入单应用体系的子应用,就会实现子应用的挂载刷新,基座应用实现如下:
/** 监听a标签 */
const listenerRouter = () => {
const container = document.getElementById(`${name}-container`);
if (!container) {
return;
}
container.addEventListener(
'click',
function(e) {
eventDelegate(e, 'a', function() {
if (this && this.nodeName.toLowerCase() === 'a') {
const linkUrl = this.getAttribute('href');
if (linkUrl) {
const linkRoute = getLinkRoute(linkUrl);
/** 检查路径 */
if (
staticData &&
staticData.linkListeners &&
linkRoute &&
staticData.linkListeners[linkRoute]
) {
e.preventDefault();
history.push(
linkUrl.replace('http:', '').replace('https:', '').replace('//xxx.jd.com', '')
);
}
}
}
});
},
false
);
};
1)入口文件增加mount、unmount方法,不管是那种框架的入口的文件,挂载的容器id一定是跟子应用匹配,并且是按照在基座应用中注册的 appname+“container”;
基于React框架的入口改造
const mount = function(e, h) {
ReactDOM.render(
<Provider store={store}>
<IntlProvider defaultLocale={locale}
key={locale}
locale={locale}
messages={getMessages(locale)}
>
<App />
</IntlProvider>
</Provider>,
document.getElementById(e)
);
if (window.__MICRO_APP_ENVIRONMENT__) {
window['micro-app-appname'].completeHandler();
}
};
const unmount = function(e, h) {
ReactDOM.unmountComponentAtNode(document.getElementById(e));
};
if (window.__MICRO_APP_ENVIRONMENT__) {
window['micro-app-appname'] = { mount, unmount };
} else {
mount('appname-container');
}
基于Vue的入口改造
let app = null;
/** 微前端 */
const mount = function(e, h) {
app = new Vue({
el: '#appname-container',
store,
components: {
App
},
mounted() {
// 页面滚动事件
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset;
stateStore.setItem('scrollTop', scrollTop);
});
},
template: '<App/>'
});
console.log('cart mount...');
};
const unmount = function(e, h) {
app.$destroy();
app.$el.innerHTML = '';
app = null;
console.log('cart unmout...');
};
if (window.__MICRO_APP_ENVIRONMENT__) {
window['micro-app-appname'] = { mount, unmount };
} else {
mount('appcart-appname');
}
2)优化入口启动
if (window.__MICRO_APP_ENVIRONMENT__) {
window['micro-app-appname'] = { mount, unmount };
} else {
mount('orderlist-appname');
}
window.__MICRO_APP_ENVIRONMENT__ 作为类微前端应用的环境判断
3)子应用之间的跳转
1)采用a连接的方式进行跳转,这种正常处理就可以,不用添加其他额外的操作;
2)通过绑定事件进行window.location.href跳转的需要调用microapp基座应用提供的方法进行跳转,如下实现:
if (window.__MICRO_APP_ENVIRONMENT__) {
window.microAppJump('/app/name');
}else{
window.location.href='https://xxx.jd.com/app/name';
}
1)容器适配,View模板中的容器id,必须为基座应用中注册的appname+“container”,同时修改入口文件的挂载id,如下实现:
<!DOCTYPE html>
<html lang="en" style="font-size: 50px;">
<head>
<!-- 通用头部 -->
<%- include('/common.title.ejs');%>
<!-- 子应用样式 -->
<link id="micro-app-css-appname-0" rel="stylesheet" href="https://i.3.cn/app/orderList/<%=appnameVersion %>/css/vendors.css?v=<%=commonVersion %>" >
<link id="micro-app-css-appname-1" rel="stylesheet" href="https://i.3.cn/app/orderList/<%=appnameVersion %>/css/main.css?v=<%=commonVersion %>" >
<title><%=title%></title>
</head>
<!-- 通用适配 -->
<%- include('/common.screen.ejs');%>
<script>
<!-- 静态资源清单 -->
window.STATIC_LIST_DATA = <%- static_list_data %>;
</script>
<script>
// 接口数据配置
window.configModel = null;
try{
configModel = <%- configModel %>;
}catch{
console.log('接口数据配置异常');
}
</script>
<body>
<div id="app">
<div id="orderList-container">
<!-- 子应用骨架 -->
</div>
</div>
<!-- 通用js引入 -->
<%- include('/common.script.ejs');%>
<!-- 基座应用 -->
<script type="text/javascript" src="https://i.3.cn/app/microapp/<%=microappVersion%>/js/main.js?v=<%=commonVersion %>"></script>
<!-- 子应用引入js -->
<script id="" type="text/javascript" src="https://i.3.cn/app/appname/<%=appnameVersion%>/js/vendors.js?v=<%=commonVersion %>"></script>
<script id="" type="text/javascript" src="https://i.3.cn/app/appname/<%=appnameVersion%>/js/main.js?v=<%=commonVersion %>"></script>
</body>
</html>
在配置数据中增加配置对象static_list_data,linkListeners的配置非常重要,只有在这里进行配置的子应用,才可以进行子应用之间的跳转,比如在首页跳转到商详,那么就需要在这里配置一下,key为子应用在基座中注册的app name,lib是通用的一些静态资源,目前没有用上,作为预载的;modules中就是子应用要加载的静态资源,每次更新版本号即可,基座应用中会根据提供的子应用模块提供的版本号动态加载子应用,数据如下:
{
"linkListeners": { //已经接入的子应用,需要在这里完成配置
"appname": true
},
"lib": {
"js": [
"https://i.3.cn/npm/vue@2.5.21/dist/vue.min.js",
]
},
"modules": {
"appcart": {
"version": "v2022051101",
"css": [
"https://i.3.cn/app/appname/[version]/css/app.css"
],
"js": [
"https://i.3.cn/appname/[version]/js/manifest.js",
"https://i.3.cn/appname/[version]/js/vendor.js",
"https://i.3.cn/appname/[version]/js/app.js"
]
}
}
}
前端系统发展到一定规模之后,需要一种能够分解复杂业务的架构模式,微前端思想由此产生,其目的实现系统架构的扩展性,明确系统之间的依赖和边界;
使用微前端更具意义的,实现了多技术栈的包容并存,允许我们在做系统升级中,可以低成本、低风险的进行技术栈创新,并引入新的技术栈,实现渐进式的架构升级;当前,微前端在C端商城中的应用我们已经跨出了一步,回顾整个过程,我们在使用微前端的时候也遇到很多问题,对比seller端的系统的使用,C端对首屏渲染有要求,需要对SEO友好,页面之间的跳转,A标签的跳转等问题,这些都一一得到解决;接下来可以结合PWA的缓存机制,把静态资源的清单提前做好缓存,在性能上往类app的体验上更进一步;
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8