咱们把公司前端慢慢架构了

引言

项目初态

这家独角兽公司主要是面向企业的业务,对于前端的需求来讲,项目是一个很是大的管理平台,当时前端架构也很是很是古老,先后端并无分离,总体架构大致是这样的。css

image-20201119205537278

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

能够看到,这种模式对于如今的咱们来讲,很是有年代感,经典的MVC模式,缺点很明显。前端

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

因为项目相对来讲已经比较成熟了,全部内容推倒重作是不可能的,并且当时前端在公司的地位很是的低,没有影响力,老板是不会容许前端乱搞的,因此,只能一步一步的想办法,改变现状。react

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

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

为了解决开发体验的问题,咱们想到了个一箭双鵰的办法,既能够更新技术栈,又能够提高开发体验。那就是对于老的、已完成的模块页面,先放着无论,后续有时间在重构,而对于新的需求页面,使用React进行工程化编写。git

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

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

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

<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/preluer-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

结束

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