我们把公司前端架构了!

31,186 阅读13分钟

引言

笔者本科是在成都的一所双非大学念的,四年前大四的找工作时候,由于没什么好公司愿意到我们学校招人,于是我每天到隔壁985电子科技大学蹲点混宣讲会,经过了一个多月的不要脸的摸爬滚打,终于收到杭州的一家独角兽公司的offer,月薪8K,贼开心,不久之后,我就到了这家公司上班。

原文地址 欢迎star

项目初态

这家独角兽公司主要是toB的业务,对于前端的需求来说,项目是一个非常大的管理平台,当时前端架构也非常非常古老,前后端并没有分离,整体架构大体是这样的。

image-20201119205537278

一个非常大的Java项目,用的阿里开源的JavaWeb框架Webx,然后用了类似JSP之类的东西,也就是velocity模板来渲染页面,而前端需要编写jQuery脚本和CSS脚本来完成功能,这些脚本放在Webx的静态资源目录,在velocity模板中引入对应的脚本。

可以看到,这种模式对于现在的我们来说,非常有年代感,经典的MVC模式,缺点很明显。

  • 前端代码在velocity模板中与部分后端逻辑混杂在一起,前端依赖后端渲染HTML,使得前后端代码有着很强的耦合性。
  • 前端需要学习Java的模板引擎velocity语法,增加了前端学习的成本。
  • 每次前端出现问题需要修改发布,即使后端没有任何更新,都必须伴随一次后端服务的发布,增加了出问题的可能。

由于项目相对来说已经比较成熟了,所有内容推倒重做是不可能的,而且当时前端在公司的地位非常的低,没有影响力,老板是不会允许前端乱搞的,所以,只能一步一步的想办法,改变现状。

前端第一次改造:技术栈更新

对当时的我们来说,最大的痛点是前端在开发过程中,必须启动一个后端项目,而随着后端项目的越来越庞大,每次启动都至少需要四五分钟,开发体验极差。为什么必须要启动后端项目?一个是项目开发依赖于velocity渲染的html结构,第二是因为项目请求的数据接口依赖于后端项目。

为了解决开发体验的问题,我们想到了个两全其美的办法,既可以更新技术栈,又可以提升开发体验。那就是对于老的、已完成的模块页面,先放着不管,后续有时间在重构,而对于新的需求页面,使用React进行工程化编写。

对于velocity渲染的html依赖问题,我们只需要约定好在velocity中渲染对应ID的DOM节点和初始化数据,然后在React项目中ReactDOM.render对应ID的节点并将初始化数据传递进去,这样就可以解决渲染后端项目的html渲染依赖问题。

而接口依赖问题很容易解决,可以通过mock接口解决,也可以在webpack中配置代理,将请求代理到后端的测试机器,这样就可以解决后端项目启动的问题。

而在项目发布时,将React项目的webpack的output目录指定到Webx的静态资源目录,然后在velocity中引入对应的编译结果就可以。比如现在有一个新的模块A,那么velocity中的模板是这样的:

<link rel="stylesheet" href="/static/xxx_module/moduleA/main.css?v=hash" />
<script src="/static/xxx_module/moduleA/main.js?v=hash"></script>
<script>
  var _velocity_init_data_ = {
    // 渲染velocity数据
  };
</script>
<div id="pageA"></div>

而React的项目是这样的

import React from 'react';
import React from 'react-dom';
import App from './App';

// 在开发时,声明一个带ID为pageA的空页面就可以
const container = document.getElementById('pageA');
const initData = window._velocity_init_data_;

ReactDom.render(<App initData={initData}/>, container);

webpack项目配置:

const path = require('path');

module.exports = {
  entry: './src/main.js',
  output: {
    path: path.resolve('后端项目路径', 'static', 'moduleA') // 对应的模块目录
  },
  // ...其他配置
}

此时,项目的架构如下:

image-20201119205628118

可以看到,将页面使用React工程化编写以后,前端代码与后端代码的耦合性大大降低了,后端只需要为前端提供初始化数据,前端可使用初始化数据完成相应的页面渲染。

前端第二次改造:微前端

随着公司业务的发展,整个后端项目越来越庞大,项目的单次更新部署至少都需要二三十分钟,而且由于业务场景要求,后端项目必须提升其高可用性和稳定性,这使得后端不得不将项目拆分,将各个模块各自单独开发,并且根据其访问情况,单独部署不同的机器、容器数量。这样的模块拆分,可以理解为后端项目在想微服务架构演进,各个模块有各自的路由,它们之间内部会通过http、rpc或者kafka进行通信。而当时前端在公司的影响力也并不大,以至于当时错过在后端项目拆分过程中的可以接过路由让前端管理的机会。

在后端向微服务架构演进的过程中,前端也迫不得已变成了一个微前端架构,因为公司当时没有专门做前端架构的人,所以由当时开发这部分的前端拍脑袋定了一个iframe的方案。

页面情况大体是这样,平台有一个主入口路由,这个路由由原本的Webx项目控制,这个路由渲染页面左侧的菜单栏和右侧的内容区域,所有的页面的权限控制、路由分发由原本的Webx项目完成,右侧内容区域渲染一个iframe节点,iframe根据左侧的菜单栏的选中项来加载不同模块的页面。

后端模块拆分后,大部分项目框架用的Spring Boot,而模板引擎,也从velocity切换到了freemarker,完成后架构如下:

image-20201120100214453

由于刚开始没有经过详细的考虑,iframe式的微前端架构缺点也慢慢暴露出来,和社区里讲的一样:

  • 页面加载性能:页面之间切换必须重新加载一次页面,且主页面的onload事件受到iframe的影响。
  • 用户体验不佳:iframe必须给指定高度,不然就会塌陷,这可能使得主页面和子页面都会出现滚动条。另外iframe内的fixed节点样式受到限制,比如Antd的message组件和Modal组件,会被定为到iframe的中心位置,而不是浏览器窗口的中心问题。

由于项目为toB项目,更注重项目的可用性,而不是性能,所以我们当时忽略了页面加载性能问题。对于用户体验问题,我们通过在iframe内实时计算高度,并用postMessage发送到主页面,主页面动态设置iframe的高度,从而解决了高度塌陷问题。而iframe内的fixed节点样式受限问题,只能见招拆招,比如前面提到的Antd的message组件和Modal组件,在设置了主页面和子页面的域解决了跨域问题后,通过设置组件的getContainer方法,将fixed节点渲染到主页面去,然后在主页面中添加对应的css样式。

前端第三次改造:前端独立发布系统

iframe式微前端完成后,为了提高前端影响力,我的导师当时率先提出了前端独立发布的想法,将需要发布的前端的静态资源从后端服务中抽离出来你,部署到公司的CDN中(印象里我的导师好像是华为云的前员工,据说这个前端独立发布的想法是他在华为云提出并实践过)。

针对当时前端的情况,我们的难点很明显,路由是由后端项目来分发的,HTML的渲染也是由后端控制的,假如前端资源抽离单独发布,那么在只发布前端的时,必须保证在HTML不变的情况下(HTML决定了加载哪些CSS、JS),更新需要加载的前端资源,也就是更新需要加载的JS和CSS。

为了解决这个难点,我们实现了一个前端资源独立发布系统,项目代号prelude。每个项目的模块需要prelude在定义应用app,模块bundle,在资源发布时,应用以模块为粒度,将对应版本(版本号必须遵循Semantic Versioning规范)的静态资源发布到对应的CDN文件夹。比如,应用appA的模块bundleA,发布的V1.1.0版本,那么资源请求的路径应该是:

https://static.xxx.com/appA/bundleA/1.1.0/

在prelude控制台中,需要配置该模块bundleA初始化需要加载的资源,也可以配置的前置依赖模块,比如初始化配置了需要加载vender-chunk.js、main.js、vender-chunk.css和main.css,那么如果需要使用这个模块,则需要加载以下资源:

<link rel="stylesheet" href="https://static.xxx.com/appA/bundleA/1.1.0/vender-chunk.css" />
<link rel="stylesheet" href="https://static.xxx.com/appA/bundleA/1.1.0/main.css" />

<script src="https://static.xxx.com/appA/bundleA/1.1.0/vender-chunk.js"></script>
<script src="https://static.xxx.com/appA/bundleA/1.1.0/main.js"></script>

知道了模块需要加载的资源后,prelude向外暴露了一个loader接口,这个接口接收app、bundle、version三个参数,然后渲染一段js脚本,用来向页面中注入对应app/bundle/version配置好的需要加载的所有资源,例如前面的例子,只需要在velocity或者freemarker中引入一下脚本:

<script src="https://prelude.xxx.com/prelude-loader?app=appA&bundle=bundleA&version=V1.1.0"></script>

loader接口渲染的脚本大体如下:

var assets = {
  css: [
    'https://static.xxx.com/appA/bundleA/1.1.0/vender-chunk.css',
    'https://static.xxx.com/appA/bundleA/1.1.0/main.css'
  ],
  js: [
    'https://static.xxx.com/appA/bundleA/1.1.0/vender-chunk.js',
    'https://static.xxx.com/appA/bundleA/1.1.0/main.js'
  ]
};
// 加载CSS
assets.css.forEach(href => {
  var link = document.createElement('link');
  // 其他逻辑
  link.rel = 'stylesheet';
  link.href = hrefs;
  document.head.appendChild(link);
});
// 加载JS
assets.css.forEach(src => {
  var script = document.createElement('script');
  // 其他逻辑
  script.async = false; // 顺序执行
  script.src = src;
  document.body.appendChild(script);
});

对此还不够,因为接口的version参数是写死的V1.1.0,如果前端发布更新了版本,那么还需要后端应用去发布更新velocity或者freemarker中的script标签的version参数,这不符合需求。于是我们将version参数进行了升级,可以使用规范的通配符,比如传入version=*,代表永远取该模块的最新版本,那么velocity或者freemarker引入的脚本就变成了下面这样:

<script src="https://prelude.xxx.com/preluer-loader?app=appA&bundle=bundleA&version=*"></script>

于是,流程差不多通了,在技术方案评审过程中,收到了来自经理的疑问:假如前后端同时发布的情况下,如何保证前后端发布的同步?

  • 前端先发布,还未发布后端渲染的页面就会直接加载到发布后的前端资源,如果新的版本需要请求新的接口,而后端还未更新,页面就会报错。
  • 后端先发布,为发布的前端资源就会请求到发布更新后的后端接口,如果接口存在不兼容情况,页面就会报错。

那既然是经理的疑问,该解决还是要解决,不然方案评审不给过怎么办?针对在这个问题,我们可以前后端做好约定,约定version参数只允许有第三位版本号的使用通配符,例如只能使用V1.1.*,这种情况,loader只会加载V1.1.*的最新版本,然后在做好发布的版本更新约定。

  • 只发布前端:前端发布版本只更新第三位版本号,则页面自动会拉取到最新版本。
  • 只发布后端:不需要更新任何内容。
  • 前后端同时发布:前端先发布版本更新第一或第二位版本号,前端发布完成后,后端发布更新script标签的version参数的对应的第一第二版本号。

例如,当前后端同时发布时,前端先发布更新版本到V1.2.0,后端没发布时,一直用的是V1.1.*的版本,当后端发布后,更新version参数为V1.2.*,上线后就自动加载为V1.2.0的版本了。

整个项目由有三个人完成,我主要负责平台的所有配置和配置Mysql入库,我的导师负责loader接口的开发和对应redis的读写、项目基建等,另外一个同事负责CDN的对接操作,历时大概一个月左右就完成了。

项目完成并实施后,架构已经将前端慢慢的解耦出来了。

image-20201120131044267

当prelude项目完成后,我们利用prelude的优势,通过新的方式弥补将iframe式的微前端架构的缺点,比如在Webx项目路由分发时,用渲染prelude-loader标签的形式代替iframe标签,制造一个伪iframe微前端的架构。

前端第四次改造:持续集成CI/CD

项目完成后,由于我的导师功劳巨大(期间还不断组件公司组件库的建设之类的),他直接被掉到了公司的基础架构组做前端架构。2019年12月,我从这家独角兽离职,后续偶尔有和我的导师聊两句,他说prelude的发展挺好的,得到了公司的认可,目前也和持续集成平台打通了,虽然我没有问细节,但是大体我可以想象到现在prelude的样子。

我离职的时候,prelude的状态是,模块production编译发布是在个人电脑上进行的,编译后通过手动或者webpack插件的形式上传到prelude平台,由prelude代发到CDN中,操作极其繁琐。

如今打通了持续集成后,可以做非常多的事情,例如静态类型检查、编码风格检测等,当然这都不是重点,重点是怎么利用持续集成,将前端代码的交付规范起来,而不是原本的在个人电脑上完成。

首先,前端项目方在gitlab中,我们需要规定每个项目有一个编译产出的出口目录,然后流水线按照约定好的出口目录获取产出,并发布到prelude。

image-20201120141102717

有了持续集成之后,所有production环境的代码我们不需要在自己的电脑进行build,只需要在项目根目录新建build.sh脚本,这个脚本内包含了所有编译构建的命令,让持续集成平台去执行,编译后在根目录产出output.tar.gz,然后将产出包包含app/bundle/version参数描述文件或者在产出发布时候直接传参给prelude,让prelude代发到CDN,这样就可以完成版本发布。

到了这一步,整个前端架构基本已经完全解耦出来。

image-20201120142134478

结束

整个架构的发展大概经历了两到三年的时间,中间也遇到了很多坎坎坷坷的问题,虽然我们没有全局最优的方案,但是基本都用了局部最优的方案来解决问题,这两三年时间,我也从一个前端菜鸟变成了一个还可以的前端精神小伙,还是非常感谢当时的导师。