前端架构演进
前言
前段时间一直在想能培训什么呢,讲代码吧可能各位也听不进去,讲产品吧,可能测试组更在行,想了很久就讲讲前端架构的演进吧,前端架构的改变是根据现场长期反馈的信息, 也包括我们自己在开发中遇到的一些痛点,一步步发展而来的, 在讲解的过程当中呢, 我尽量少的去讲代码,用一些大白话举一些咱们相中的例子在推我们为什么这么做。
常见问题
- 每个用户登录界面,免登录的定制化
- 现场的bug 有一个bug 都需要打全量包,导致错误加倍发生
- baymax 产品嵌套其他产品, 或者被其他产品嵌套
- 最常见的问题 更换 logo 更换产品名称 需要重新打包的问题
- 最重要的一环, 产品线的增加, 怎么能快速构建
就先列举这几个问题吧,以上的问题都会面临同样一个问题,都是一些小的改动,而要打全量包这样就引出下一个话题 微前端, 我们先看一下微前端是什么一个概念
什么是微前端
微前端是一种类似于微服务的架构,是一种由独立交付的多个前端应用组成整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。
站在整个公司研发的角度考虑,最好的产品形态就是将所有的研发系统都放置同一个产品内,用户是无法感知他在使用不同的产品,对于用户而言就是单个产品不存割裂感。(比如 baymax 和 dsp-ui 作为两个产品, 有两个 html 入口,而且需要部署两套环境, baymax 跳转到 dsp 时 画面出现空白, 项目越多体验越不好。)
代码构建半小时以上 无法局部灰度局部升级 项目遇到问题时回滚影响其他模块
SPA 应用带来的优势,SPA (单页面程序) 应用跳转页面时无需刷新整个页面,
不用让用户产生在 MPA(多页面应用) 应用切换时整个页面刷新带来的抖动感而降低体验,
遇到的问题,代码在一个仓库怎么维护
传统 Web 应用的利与弊
这里简单分析一下传统 Web 应用在开发大规模应用时遇到的一些困境,以上面案例中的「baymax」举例,对于咱们产品 dsp、标准、质量、数仓...都是平台能力的一部分,而不是独立之间的孤岛。若以传统的前端研发模式进行开发,那么此时有两种项目设计策略
- 将平台内多个系统放置同一个代码仓库维护 ,采用 SPA(Single-page Application) 单页应用模式
- 将系统分为多个仓库维护,在首页聚合所有平台的入口,采用 MPA(Multi-page Application)多页应用模式(咱们之前采用的方法)
多个系统放置同一个项目内维护
优势:
- 统一的权限管控、统一的开发能力
- 更好的代码复用,基础库复用
- 统一的运营管理能力
- 不同系统可以很好的通信
- 具备局部更新,无缝的用户体验
劣势:
- 代码权限管控问题(如果多条产品线,多个团队权限没办法控制)
- 项目构建时间长(同时启动多个项目)
- 代码 commit 混乱、分支混乱
- 技术体系要求统一
- 无法同时去掉多条产品功能(不能进行插拔, 比如发布baymax产品, dsp也会在项目中,本来是要发布baynax 产品,导致 dsp 的代码也会存在)
- 代码回滚相互影响(几个项目在同一个git仓库)
采用这种方法劣势明显,代码构建太慢,无法局部升级,项目遇到问题回滚影响其他业务(baymax 曾经遇到这个问题很麻烦, 需要手动拷贝合并代码, 经常会遗漏问题导致新的 bug 出现)
但是确实对平台用户而言体验确实提升的,这是因为几个项目只有一个入口(SPA)跳转页面不需要刷新整个页面,路由变化是仅更新局部,不让用户在 MPA 应用切换时整个页面刷新带来的抖动、空白、而降低体验,并且页面不刷新很大程度服用页面间的资源,进而降低性能损耗,用户也不会感知到在使用的不同的平台
采用拆分成多个仓库维护(怎们之前使用的方法)
优势:
- 可以以项目维度拆分代码,解决权限管控问题(一个项目使用一个仓库)
- 仅构建本项目代码,构建速度快
- 可以使用不同的技术体系(各项目使用不同的框架没有影响)
- 不存在同一个仓库维护时的 commit 混乱和分支混乱等问题
- 功能互不影响
劣势:
- 用户在使用时体验割裂,会在不同平台间跳转,无法达到 SPA 应用带来的用户体验
- 只能以页面维度拆分,无法拆分至区块部分,只能以业务为维度划分
- 多系统同灰度策略困难
- 公共包基础库重复加载(baymax dsp 不能使用公共的基础组件)
- 不同系统间不可以直接通信 (之前是 dsp 嵌套在 baynax 之中, 两个系统不能通信)
- 公共部分只能每个系统独立实现
- 产品权限无法进行统一管控
- 搭建多个项目脚手架(项目依赖的环境)
这种方案提升了开发体验, 降低了用户体验,项目之间跳转体验交差,各个产品之间是相互独立的, 这就是传统意义的 MPA 跳转需要加载整个页面的资源, 性能不如 SPA, 系统不能通信, 用户使用产品产生的割裂感。
其实两种模式是相互互补的,在第一种方式种的优势变成了 第二种的劣势, 两种优劣完全掉了一下 , 那有没有一种方案能把这个两个的优点都结合起来呢,那我们看一下今天的主角。
微前端解决方案
在涉及人员广和项目复杂度高的场景下带来的劣势,那么期望能有一种新的架构能同时具备 SPA 和 MPA 两种架构优势 在理想的情况下,期望能达到,将一个复杂的单体应用以功能或业务需求垂直的切分成更小的子系统,并且能够达到以下能力。
- 子系统间的开发、发布从空间上完成隔离
- 子系统可以使用不同的技术体系 (baymax 可以使用 vue, dsp 使用 react 不局限于使用什么框架)
- 子系统间可以完成基础库的代码复用 (项目可以使用基础的公共组件)
- 子系统间可以快速完成通信
- 子系统间需求迭代互不阻塞
- 子应用可以增量升级(比如 baymax 中 项目目录有问题,只需要更新项目目录就可以)
- 用户使用体验多个项目是一个单一的产品
我们看到这些会想 iframe也可以实现啊 其实从浏览器原生的方案来说,iframe 不从体验角度上来看几乎是最可靠的微前端方案了,主应用通过iframe 来加载子应用,iframe 自带的样式、环境隔离机制使得它具备天然的沙盒机制,但也是由于它的隔离性导致其并不适合作为加载子应用的加载器,iframe 的特性不仅会导致用户体验的下降,也会在研发在日常工作中造成较多困扰,以下总结了 iframe 作为子应用的一些劣势:(其实诺基亚的产品有很多使用的 iframe 进行整合各个项目, 我们有很长的一段时间也是使用这种方案)
- 跳转路径无法与上层文档同步,刷新丢失路由状态
- Iframe 登录态无法共享,子应用需要重新登录
- Iframe 在禁用三方 cookie 时,iframe 平台服务不可用
- Iframe 应用加载失败,内容发生错误主应用无法感知
- 无法共享基础库进一步减少包体积
- 事件通信繁琐且限制多
微前端架构 ---独立运行、独立部署、独立开发
首先微前端只是一种概念,不是一种技术。 我们项目中使用的是 qiankun 我们来理解组成的4个部分
加载器(Loader)可以理解为电源
- 负责注册平台侧提供的应用列表
- 负责加载和解析子应用入口资源
沙箱隔离(Sandbox)它相当于电源适配器
- 提供代码执行能力,收集执行代码时存在的副作用
- 提供销毁收集作用的能力
- 支持沙箱多实例,收集不同实例
主程序 它相当于电器底座
- 解决不同应用间的路由
- 提供路由劫持能力,在主应用上管控子应用路由
子应用 它相当于你要使用的电器
- 建立通信桥梁
- 提供共享机制
qiankun 的使用
说了这么久的概念, 我们来看一下咱们项目中是如何使用的, 首先看一下咱们的项目结构
├── /config/ # webpack、项目自动化配置
├── /packages/ # 项目目录
│ ├── /master/ # 主程序
│ ├── /baymax/ # baymax项目
│ │ ├── /flowManagement/ # flow 管理
│ │ ├── /resourceDirectory/ # 资源目录
│ ├── /dsp/ # dsp 项目
│ │ ├── /administrator/ # 管理员
│ │ ├── /consumer/ # 消费者
主程序 master 和 baymax dsp 都在 packages 作为平级目录存在 packages 中, master 作为主程序, 提供基础组件和对项目路由分发的角色,
我们看一下 master 作为主程序是怎么进行分发的
// dirve.json 提供注册那些服务
{
"baymax_ui": {
"flowManagement": {
"port": 9010,
"enabled": false
},
"consumer": {
"port": 9016,
"isBase": "dsp-ui/consumer",
"enabled": true
},
"schedule": {
"port": 9022,
"isBase": "compass/schedule",
"enabled": false
},
}
}
// 上边的列子中 baymax 涉及了 三个项目的入口, 分别是 baymax consumer 和 dsp 以consumer 举例是在dsp项目中,但是想部署在baymax中 需要 isBase 属性提供目录路径, enabled 属性是是否启动服务
接下来我们看一下是怎么主程序是怎么利用 dirve.json 提供的服务进行分发
// 在 master 主程序中 app.vue 文件作为 整个项目的主入口
// 导入 文件
import entry from '../../../config/drive.json'
import {
registerMicroApps, // 注册服务
runAfterFirstMounted, // 第一个应用程序 加载成功
setDefaultMountApp, // 默认跳转在那个程序 通过配置完成
start, // 启动
addGlobalUncaughtErrorHandler // 程序报的回调
} from 'qiankun'
methods: {
render ({ appContent, loading }) {
// 这个是子程序的 就是子程序的 html
this.content = appContent
this.loading = loading
},
initQiankun () {
// 第一个参数服务列表 , 第二个参数是一个对象 服务的钩子函数
registerMicroApps([
// 这个数组的服务列表就是根据 drive.json 来的 enabled属性是 true 才会到数组中, 这里就做到了 服务的插拔, 而且这个属性 在 webpack 打包的时候 需不需打包也是根据这个而来的,这样包中没有多余的代码
// "isBase": "dsp-ui/consumer", 在启动baymax 属性的时候默认是在 baymax 文件中找项目, 如果有 isBase 属性就去 这个路径去找, 这样就实现了 项目之间 服务的插拔
{
name: 'flowManagement',
// 上线入口
entry: '',
// href: 'http://192.168.1.82:8515/sub/flowManagement/index.html',
// 开发入口
href: 'localhost:9010',
props: {}, // 公共的方法 子程序能接收到
render: this.render
}
], {
// 路由变化会执行这些钩子函数
beforeLoad: [ app => {} ],
// 是否需要展示菜单
beforeMount: [ app => { this.getIsShowNav() } ],
afterUnmount: [ app => { } ]
})
}
// 第一个加载的程序是根据配置文件来的, 这次就先不讲配置文件的内容了, 默认加载 login 程序
setDefaultMountApp(this.__getItem('serveConfig').defaultHref)
runAfterFirstMounted(() => {
// 第一APP 加载成功
})
start()
addGlobalUncaughtErrorHandler(event => {
console.error(event)
})
}
讲到这里其实主程序已经讲完了, 其实没有什么复杂的, 主要是注册程序通过有 路由的改变加载不同的服务(应用)
接下来看看 子程序是如何交互的,每个程序下都有一个 main.js 作为入口, 打包成 umd js 的文件, 这里先不讲是怎么打包的,首先让主程序能接收到, 子程序也需要几个钩子函数 这属于固定套路
export async function bootstrap ({ components, utils, pager, plugins }) {
// 这里 有一个问题 因为 vue 是单实例框架, 注册到Vue 的公共方法 我觉得在 master 主程序完成注册就可以, 现在每个子程序都执行了一个遍, 没有问题的原因是只是属性的覆盖, 将来有一个问题是,如果主程序更新了, 需要每个子程序都需要修改一下,这里有时间修改一下试试,理论上是可以的,不太确定。
Vue.use(plugins.components, plugins.locale)
// 注册主应用下发的组件
Vue.use(components)
// 把工具 函数 挂载 到原型上
Object.keys(utils).forEach(i => {
Vue.prototype[i] = utils[i]
})
Vue.prototype.$pager = pager
}
export async function mount (props) {
Vue.prototype.$https = http
render(props)
}
export async function unmount () {
// 我觉得不全局给Vue 添加公共方法, 这里不能销毁
instance.$destroy()
instance = null
}
只有导出这三个属性 在主程序加载的时候能注册成 子程序, 讲到这里呢 我们的程序做成了插拔式,项目之间也可以做成插拔式的,可以解决业务的需求了, 但是在开发的时候也遇到了一个问题, 一个项目下依赖太大导致占用磁盘空间比较大(其实这个倒不是很重要原因),主要是安装相同的依赖开发中比较繁琐。
依赖重复问题
依赖重复问题 lerna
之前安装比较慢的原因是重复安装依赖, 其实每个子程序都是一个独立的项目, 而且依赖90% 也都一样, 但是需要重复安装一遍, 比如安装 flowManagement 的依赖 需要 cd packages/baymax/flowManagement 进行安装依赖, 但是我们已经拆分出来 几十个 子程序, 如果都需要安装一遍依赖 这件事太繁琐,耽误太多的时间。
其实 lerna 和 yarn 都能解决现在的这个问题, 我们是使用 lerna + yarn 的方式实现的 他们两个都提供了 workspaces 工作空间的概念, 在根目录 package.json 下 有一个 workspaces 的属性,这里存放的是 服务的路径,只要是在这里显示的 lerna 就能一次 进行 安装依赖 卸载依赖, 启动服务, 也就是说我们动态修改 workspaces 里边的值 就能做到 进行服务的插拔。
这里可以提一下使用 learn 安装依赖的好处是 把所有项目的公共依赖都安装到了 根目录,子程序使用 link 的方式进行依赖的引用,所以内存空间 和 安装速度也提升了
不管是 新建项目, 新建项目中的模块, 还是启动服务, 服务进行插拔, 我们写了很多的自动化脚本,执行脚本就能实现的功能,(以上涉及都是根据 drive.json 来的, 但是涉及的文件有10几个所以利用脚本的形式去修改这些文件, 避免会遗漏修改的地方) 因为我们讲的是一个 架构演进的一个过程,代码就尽量的少涉及。
说道这里还有一个问题没有解决,就是我们所有的 baymax dsp ...的项目都在 一个仓库下,没有进行权限的管理, 而且一个仓库也过于庞大
git submodule
我们遇到在一个Git仓库 中添加 其他 Git 仓库的场景,某些公共的模块是需要单独维护的,使用单独的仓库比较方便,用 Git 的 git submodule 命令为一个 git 项目 添加 子git项目。
添加子仓库 在 packages/ 目录下执行此命令 git submodule add git@github.com:inforefiner/dsp-ui.git
拉取子仓库
在 packages/ 目录下执行此命令
git submodule init
git submodule update
执行完之后 就会拉取子仓库代码
删除子仓库
进入到 .git\modules\packages 下删除文件夹
然后进入到 .gitmodules 下删除相对应的模块
总结
最后在看一下最早的时候我们的问题, 是怎么解决的
每个用户登录界面,免登录的定制化
使用 qiankun (微前端)方案,有一个文件专门负责, 比如 dsp baymax 免登录有不同的路由, 不同的路由对应不同的页面, 更新的时候只需要更新一个文件就可以, transfer 文件为例
现场的bug 有一个bug 都需要打全量包,导致错误加倍发生
比如 baymax 的免登录 我们是在 master版本 开发的, 但是这个功能在 1.2.3 需要这种情况下 我们只需要 打包master的这个模块 替换 1.2.3 sub 下的 这个模块, (项目的插拔)
最常见的问题 更换 logo 更换产品名称 需要重新打包的问题
有配置文件配置 logo 名称 产品名称,包括导航是横版还是竖版都是可以配置
最重要的一环, 产品线的增加, 怎么能快速构建
dsp baymax ... 如果作为单独项目,就需要webpack 配置不同的开发环境,公共的组件没办法复用,有一段时间公共组件都是复制来复制去的使用,这样会出现遗漏的现象,现在的话使用总共配置,公共组件。
其实这些所有的问题都是在解决开发过程中,和现场反馈的一些问题慢慢总结而来的,我们我们平常开发过程中要有一双发现问题的眼睛,至于用什么方式解决只是一个工具,其实在修改的过程也是一个痛苦的过程,不断的推倒从来的一个过程,只要我们知道这么做能解决什么样的问题,解决这个问题能带来什么好处那这件事就是值得我们去做的, 而且也是一个学习新知识的一个过程,好吧,接下来我们一起加油, 这期分享就先到这里了,感谢大家的收听,我们下期再会