抖音技术能力大揭密!钜惠大礼、深度体验,尽在火山引擎增长沙龙,就等你来! 立即报名>> 了解详情
写点什么

用微前端的方式搭建类单页应用

2020 年 2 月 25 日

用微前端的方式搭建类单页应用

前言

微前端由 ThoughtWorks 2016 年提出,将后端微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。


美团已经是一家拥有几万人规模的大型互联网公司,提升整体效率至关重要,这需要很多内部和外部的管理系统来支撑。由于这些系统之间存在大量的连通和交互诉求,因此我们希望能够按照用户和使用场景将这些系统汇总成一个或者几个综合的系统。


我们把这种由多个微前端聚合出来的单页应用叫做“类单页应用”,美团 HR 系统就是基于这种设计实现的。美团 HR 系统是由 30 多个微前端应用聚合而成,包含 1000 多个页面,300 多个导航菜单项。对用户来说,HR 系统是一个单页应用,整个交互过程非常顺畅;对开发者同学来说,各个应用均可独立开发、独立测试、独立发布,大大提高了开发效率。


接下来,本文将为大家介绍“微前端构建类单页应用”在美团 HR 系统中的一些实践。同时也分享一些我们的思考和经验,希望能够对大家有所启发。


HR 系统的微前端设计

因为美团的 HR 系统所涉及项目比较多,目前由三个团队来负责。其中:OA 团队负责考勤、合同、流程等功能,HR 团队负责入职、转正、调岗、离职等功能,上海团队负责绩效、招聘等功能。这种团队和功能的划分模式,使得每个系统都是相对独立的,拥有独立的域名、独立的 UI 设计、独立的技术栈。但是,这样会带来开发团队之间职责划分不清、用户体验效果差等问题,所以就迫切需要把 HR 系统转变成只有一个域名和一套展示风格的系统。


为了满足公司业务发展的要求,我们做了一个 HR 的门户页面,把各个子系统的入口做了链接归拢。然而我们发现 HR 门户的意义非常小,用户跳转两次之后,又完全不知道跳到哪里去了。因此我们通过将 HR 系统整合为一个应用的方式,来解决以上问题。


一般而言,“类单页应用”的实现方式主要有两种:


  1. iframe 嵌入

  2. 微前端合并类单页应用


其中,iframe 嵌入方式是比较容易实现的,但在实践的过程中带来了如下问题:


  • 子项目需要改造,需要提供一组不带导航的功能

  • iframe 嵌入的显示区大小不容易控制,存在一定局限性

  • URL 的记录完全无效,页面刷新不能够被记忆,刷新会返回首页

  • iframe 功能之间的跳转是无效的

  • iframe 的样式显示、兼容性等都具有局限性


考虑到这些问题,iframe 嵌入并不能满足我们的业务诉求,所以我们开始用微前端的方式来搭建 HR 系统。


在这个微前端的方案里,有几个我们必须要解决的问题:


  1. 一个前端需要对应多个后端

  2. 提供一套应用注册机制,完成应用的无缝整合

  3. 构建时集成应用和应用独立发布部署


只有解决了以上问题,我们的集成才是有效且真正可落地的,接下来详细讲解一下这几个问题的实现思路。


一个前端对应多个后端

HR 系统最终线上运行的是一个单页应用,而项目开发中要求应用独立,因此我们新建了一个入口项目,用于整合各个应用。在我们的实践中,把这个项目叫做“Portal 项目”或“主项目”,业务应用叫做“子项目”,整个项目结构图如下所示:



项目结构图


“Portal 项目”是比较特殊的,在开发阶段是一个容器,不包含任何业务,除了提供“子项目”注册、合并功能外,还可以提供一些系统级公共支持,例如:


  • 用户登录机制

  • 菜单权限获取

  • 全局异常处理

  • 全局数据打点


“子项目”对外输出不需要入口 HTML 页面,只需要输出的资源文件即可,资源文件包括 js、css、fonts 和 imgs 等。


HR 系统在线上运行了一个前端服务(Node Server),这个 Server 用于响应用户登录、鉴权、资源的请求。HR 系统的数据请求并没有经过前端服务做透传,而是被 Nginx 转发到后端 Server 上,具体交互如下图所示:



前后端分离图


转发规则上限制数据请求格式必须是 系统名+Api做前缀 这样保障了各个系统之间的请求可以完全隔离。其中,Nginx 的配置示例如下:


server {  listen     80;  server_name   xxx.xx.com;
location /project/api/ { set $upstream_name "server.project"; proxy_pass http://$upstream_name; } ...
location / { set $upstream_name "web.portal"; proxy_pass http://$upstream_name; }}
复制代码


我们将用户的统一登录和认证问题交给了 SSO,所有的项目的后端 Server 都要接入 SSO 校验登录状态,从而保障业务系统间用户安全认证的一致性。


在项目结构确定以后,应用如何进行合并呢?因此,我们开始制定了一套应用注册机制。


应用注册机制

“Portal 项目”提供注册的接口,“子项目”进行注册,最终聚合成一个单页应用。在整套机制中,比较核心的部分是路由注册机制,“子项目”的路由应该由自己控制,而整个系统的导航是“Portal 项目”提供的。


路由注册

路由的控制由三部分组成:权限菜单树、导航和路由树,“Portal 项目”中封装一个组件 App,根据菜单树和路由树生成整个页面。路由挂载到 DOM 树上的代码如下:


let Router = <Router      fetchMenu = {fetchMenuHandle}      routes = {routes}      app = {App}      history = {history}      >ReactDOM.render(Router,document.querySelector("#app"));
复制代码


Router 是在 react-router 的基础上做了一层封装,通过 menu 和 routes 最后生成一个如下所示的路由树:


 <Router>  <Route path="/" component={App}>   <Route path="/namespace/xx" component={About} />   <Route path="inbox" component={Inbox}>    <Route path="messages/:id" component={Message} />   </Route>  </Route> </Router>
复制代码


具体注册使用了全局的window.app.routes,“Portal 项目”从window.app.routes获取路由,“子项目”把自己需要注册的路由添加到window.app.routes中,子项目的注册如下:


let app = window.app = window.app || {}; app.routes = (app.routes || []).concat([{ code:'attendance-record', path: '/attendance-record', component: wrapper(() => async(require('./nodes/attendance-record'), 'kaoqin')),}]);
复制代码


路由合并的同时也把具体的功能做了引用关联,再到构建时就可以把所有的功能与路由管理起来。项目的作用域要怎么控制呢?我们要求“子项目”间是彼此隔离,要避免样式污染,要做独立的数据流管理,我们用项目作用域的方式来解决这些问题。


项目作用域控制

在路由控制的时候我们提到了 window.app,我们也是通过这个全局 App 来做项目作用域的控制。window.app包含了如下几部分:


let app = window.app || {};app = {  require:function(request){...},  define:function(name,context,index){...},  routes:[...],  init:function(namespace,reducers){...}};
复制代码


window.app 主要功能:


  • define 定义项目的公共库,主要用来解决 JS 公共库的管理问题

  • require 引用自己的定义的基础库,配合 define 来使用

  • routes 用于存放全局的路由,子项目路由添加到 window.app.routes,用于完成路由的注册

  • init 注册入口,为子项目添加上 namesapce 标识,注册上子项目管理数据流的 reducers


子项目完整的注册,如下所示:


import reducers from './redux/kaoqin-reducer';let app = window.app = window.app || {}; app.routes = (app.routes || []).concat([{ code:'attendance-record', path: '/attendance-record', component: wrapper(() => async(require('./nodes/attendance-record'), 'kaoqin')), // ... 其他路由}]);
function wrapper(loadComponent) { let React = null; let Component = null; let Wrapped = props => ( <div className="namespace-kaoqin"> <Component {...props} /> ); return async () => { await window.app.init('namespace-kaoqin',reducers); React = require('react'); Component = await loadComponent(); return Wrapped; };}
复制代码


其中做了这几件事情:


  1. 把路由添加到 window.app 中

  2. 业务第一次功能被调用的时候执行 window.app.init(namespace,reducers),注册项目作用域和数据流的 reducers

  3. 对业务功能的挂载节点包装一个根节点:Component挂载在classNamenamespace-kaoqindiv下面


这样就完成了“子项目”的注册,“子项目”的对外输出是一个入口文件和一系列的资源文件,这些文件由 webpack 构建生成。


CSS 作用域方面,使用 webpack 在构建阶段为业务的所有 CSS 都加上自己的作用域,构建配置如下:


//webpack打包部分,在postcss插件中 添加namespace的控制config.postcss.push(postcss.plugin('namespace', () => css => css.walkRules(rule => {  if (rule.parent && rule.parent.type === 'atrule' && rule.parent.name !== 'media') return;  rule.selectors = rule.selectors.map(s => `.namespace-kaoqin ${s === 'body' ? '' : s}`); })));
复制代码


CSS 处理用到 postcss-loader,postcss-loader 用到 postcss,我们添加 postcss 的处理插件,为每一个 CSS 选择器都添加名为.namespace-kaoqin的根选择器,最后打包出来的 CSS,如下所示:


.namespace-kaoqin .attendance-record {  height: 100%;  position: relative}
.namespace-kaoqin .attendance-record .attendance-record-content { font-size: 14px; height: 100%; overflow: auto; padding: 0 20px}...
复制代码


CSS 样式问题解决之后,接下来看一下,Portal 提供的 init 做了哪些工作。


let inited = false;let ModalContainer = null;app.init = async function (namespace,reducers) { if (!inited) {  inited = true;  let block = await new Promise(resolve => {   require.ensure([], function (require) {    app.define('block', require.context('block', true, /^\.\/(?!dev)([^\/]|\/(?!demo))+\.jsx?$/));    resolve(require('block'));   }, 'common');  });  ModalContainer = document.createElement('div');  document.body.appendChild(mtfv3ModalContainer);  let { Modal} = block;  Modal.getContainer = () => ModalContainer; } ModalContainer.setAttribute('class', `${namespace}`); mountReducers(namepace,reducers)};
复制代码


init 方法主要做了两件事情:


  1. 挂载“子项目”的 reducers,把“子项目”的数据流挂载了 redux 上

  2. “子项目”的弹出窗全部挂载在一个全局的 div 上,并为这个 div 添加对应的项目作用域,配合“子项目”构建的 CSS,确保弹出框样式正确


上述代码中还看到了app.define的用法,它主要是用来处理 JS 公共库的控制,例如我们用到的组件库 Block,期望每个“子项目”的版本都是统一的。因此我们需要解决 JS 公共库版本统一的问题。


JS 公共库版本统一

为了不侵入“子项目”,我们采用构建过程中替换的方式来做,“Portal 项目”把公共库引入进来,重新定义,然后通过window.app.require的方式引用,在编译“子项目”的时候,把引用公共库的代码从require('react')全部替换为window.app.require('react'),这样就可以将 JS 公共库的版本都交给“Portal 项目”来控制了。


define 的代码和示例如下:


/*** 重新定义包* @param name 引用的包名,例如 react* @param context 资源引用器 实际上是 webpackContext(是一个方法,来引用资源文件)* @param index 定义的包的入口文件*/app.define = function (name, context, index) { let keys = context.keys(); for (let key of keys) {  let parts = (name + key.slice(1)).split('/');  let dir = this.modules;  for (let i = 0; i < parts.length - 1; i++) {   let part = parts[i];   if (!dir.hasOwnProperty(part)) {    dir[part] = {};   }   dir = dir[part];  }  dir[parts[parts.length - 1]] = context.bind(context, key); } if (index != null) {  this.modules[name]['index.js'] = this.modules[name][index]; }};//定义app的react //定义一个react资源库:把原来react根目录和lib目录下的.js全部获取到,绑定到新定义的react中,并指定react.js作为入口文件app.define('react', require.context('react', true, /^.\/(lib\/)?[^\/]+\.js$/), 'react.js');app.define('react-dom', require.context('react-dom', true, /^.\/index\.js$/));
复制代码


“子项目”的构建,使用 webpack 的 externals(外部扩展)来对引用进行替换:


/** * 对一些公共包的引用做处理 通过webpack的externals(外部扩展)来解决 */const libs = ['react', 'react-dom', "block"];
module.exports = function (context, request, callback) { if (libs.indexOf(request.split('/', 1)[0]) !== -1) { //如果文件的require路径中包含libs中的 替换为 window.app.require('${request}'); //var在这儿是声明的意思 callback(null, `var window.app.require('${request}')`); } else { callback(); }};
复制代码


这样项目的注册就完成了,还有一些需要“子项目”自己改造的地方,例如本地启动需要把“Portal 项目”的导航加载进来,需要做 mock 数据等等。


项目的注册完成了,我们如何发布部署呢?


构建后集成和独立部署

在 HR 系统的整合过程中,开发阶段对“子项目”是“零侵入”,而在发布阶段,我们也希望如此。


我们的部署过程,大概如下:



第一步:在发布机上,获取代码、安装依赖、执行构建;


第二步:把构建的结果上传到服务器;


第三步:在服务器执行 node index.js 把服务启动起来。


“Portal 项目”构建之后的文件结构如下:



“子项目”构建后的文件结构如下:



线上运行的文件结构如下:



把“子项目”的构建文件上传到服务器对应的“子项目”文件目录下,然后对“子项目”的资源文件进行集成合并,生成.dist 目录中的文件,提供给用户线上访问使用。


每次发布,我们主要做以下三件事情:


  1. 发布最新的静态资源文件

  2. 重新生成 entry-xx.js 和 index.html(更新入口引用)

  3. 重启前端服务


如果是纯静态服务,完全可以做到热部署,动态更新一下引用关系即可,不需要重启服务。因为我们在 Node 服务层做了一些公共服务,所以选择了重启服务,我们使用了公司的基础服务和 PM2 来实现热启动。


对于历史文件,我们需要做版本控制,以保障之前的访问能够正常运行。此外,为了保证服务的高可用性,我们上线了 4 台机器,分别在两个机房进行部署,最终来提高 HR 系统的容错性。


总结

以上就是我们使用 React 技术栈和微前端方式搭建的“类单页应用”HR 业务系统,回顾一下这个技术方案,整个框架流程如下图所示:



在产品层面上,“微前端类单页应用”打破了独立项目的概念,我们可以根据用户的需求自由组装我们的页面应用,例如:我们可以在 HR 门户上把考勤、请假、OA 审批、财务报销等高频功能放在一起。甚至可以让用户自己定制功能,让用户真的感受到我们是一个系统。


“微前端构建类单页应用”方案是基于 React 技术栈开发,如果把路由管理机制和注册机制抽离出来作为一个公共的库,就可以在 webpack 的基础上封装成一个业务无关性的通用方案,而且使用起来非常的友好。


截止目前,HR 系统已经稳定运行了 1 年多的时间,我们总结了以下三个优点:


  1. 单页应用的体验比较好,按需加载,交互流畅

  2. 项目微前端化,业务解耦,稳定性有保障,项目的粒度易控制

  3. 项目的健壮性比较好,项目注册仅仅增加了入口文件的大小,30 多个项目目前只有 12K


作者简介

  • 贾召,2014 年加入美团,先后主导了 OA、HR、财务等企业项目的前端搭建,自主研发 React 组件库 Block,在 Block 的基础上统一了整个企业平台的前端技术栈,致力于提高研发团队的工作效率。


2020 年 2 月 25 日 20:32290

评论

发布
暂无评论
发现更多内容

云小课 | 玩转HiLens Studio之快速订购HiLens Studio版本

华为云开发者社区

modelarts 华为HiLens HiLens Studio EI智能体 AI开发应用平台

如何使用 Distroless 让你的容器更加安全

K8sCat

flask Docker Kubernetes Google Distroless

java特点了解及JDK初谈(程序员必看!)

Java 白

Java java程序员 java面试

查漏补缺!复盘B站面试坑我最深的Java并发:JDK源码剖析

云流

Java 编程 程序员 架构 面试

智慧社区平台解决方案,平安小区建设解决方案

13823153121

花168大洋买来的【阿里P8Java成长笔记】,看完才知道我就是菜鸡

Java架构师迁哥

Linux运维职业困惑?给你史上最全互联网Linux工作规划!

学神来啦

基于K8S构建Zeppelin大数据可视化分析工具

张浩_house

spark 数据分析 Sparksql 数据可视化 Zeppelin

今天,「浪潮云说」直播间开讲啦!

浪潮云

阿里专家把SpringBoot:入门+基础+进阶+项目全部整理出来了

云流

Java 编程 架构 面试 微服务

万物互联时代,如何玩转鸿蒙系统的用户体验?

博睿数据

鸿蒙 用户体验 博睿数据

未来,让我们一起想象 — “Imagine” 阿里云视频云全景创新峰会

阿里云视频云

阿里云 计算机视觉 视频 英特尔 音视频开发

备战金九银十:4000道Java面试真题合集,助你搞定面试官

Java 白

【视频】51CTO专访博睿数据COO吴静涛,解读IT运维“新范式”

博睿数据

千古无同局?围棋在线教育还有这样的打开方式!

亚马逊云科技 (Amazon Web Services)

eKuiper 与百度智能边缘框架 BIE 集成方案

EMQ映云科技

边缘计算 边缘技术 边缘流式数据 #百度# 智能IoT边缘服务

架构之:微服务架构漫谈

程序那些事

架构 微服务 程序那些事

密码合规测评新服务:“微咨询”正式发布

腾讯安全云鼎实验室

密码合规 微咨询

Nginx的进程管理与重载原理

Linux服务器开发

nginx 中间件 后端开发 Linux服务器开发 进程管理

面试碰壁?奉劝想跳槽大厂的Java岗程序员三刷以下面试题

Crud的程序员

Java 程序员 架构 面试 编程语言

干货!8年Java开发经验,分享各专题面试文档及架构资料

Crud的程序员

Java spring 架构

权限与认证:HTTP状态码返回

程序员架构进阶

Java HTTP 28天写作 6 月日更

您的出门“最后三公里”问题解决啦!

亚马逊云科技 (Amazon Web Services)

【案例】消除隐患,基于电力大数据的群租房智能分析

星环科技

一妹子揭露美团面试中一些不愉快的事情(Java岗)

Java架构师迁哥

TcaplusDB小知识之查看TcaplusDB集群状态

数据人er

数据库 nosql tencentdb TcaplusDB

图解 Redis丨这就是 RDB 快照,能记录实际数据的

华为云开发者社区

redis 数据 日志 aof RDB 快照

昆明智慧安防小区建设方案,平安社区建设

13823153121

秋招冲刺:网络安全工程师入围成功之旅!!

网络安全学海

面试 运维 网络安全 信息安全 渗透

全网72万浏览量!阿里重磅开放 “SpringCloudAlibaba学习笔记”(附下载)!

云流

Java 编程 架构 面试 微服务

2021年大厂Java面试总结:Java+并发+spring+数据库+Netty

程序员改bug

Java spring 程序员 架构

Study Go: From Zero to Hero

Study Go: From Zero to Hero

用微前端的方式搭建类单页应用-InfoQ