zhangsanshi / issue-blog

It's a blog rather than issue
0 stars 0 forks source link

网易云控制台微前端探索实践 #59

Open zhangsanshi opened 5 years ago

zhangsanshi commented 5 years ago

网易云控制台微前端探索实践

1. 前言

微前端,并不是一个新的技术名词,在 2016 年就被提出来了,具体可以参见链接。看过文章会发现,它是由后端微服务启发而提出来的。有人对它下了一个定义,使用不同 JavaScript 框架为多个团队构建现代 Web 应用程序的技术,策略和方法。根据这个定义,一方面可以看出它不是一个新技术,仅仅是一个新理念,另一方面我们会发现,在平时的工程实践中,可能已经做过相关的方案。本文主要讲的是,网易云控制台(简称:控制台)是如何实践微前端的。

2. 微前端的理解

优点:

  1. 有利于技术迭代升级,比如说,老系统使用的是 AngularJS,相关技术人员难以招聘,毕竟现在框架多使用 vue、 angular、react,那么就可以采用微前端架构,新需求采用新的框架开发,老的页面仅仅做维护,可以享受新技术带来的效率提升。
  2. 一个系统由多个子模块整合,一般会由一个大团队维护前端相关。这种协作模式在一定规模后会有缺陷,一方面,所有团队都依赖这个大前端团队,效率的天花板十分明显。另一方面,同时增加了沟通成本。而交由对应的业务方进行维护,会在一定程度上避免此类问题。
  3. 如果需要在已有的页面中加入另一个团队实现的业务,对于非微前端方案,最简单的做法可能是页面跳转,但是采用微前端架构,我们可能会有更好的选择。
  4. 无需同时升级整个项目

缺点:

  1. 假使模块间有依赖,那么管理依赖会有一定的问题
  2. 对相关团队前端技能有一定的要求
  3. 功能实现冲突,需要有合理的约定

3. 背景

控制台前端由 30+ 模块组成,一共有 700+ 页面,由前端团队维护。最近组织架构调整,希望由后端业务团队维护自身的模块,并且提出了一些要求,整理如下:

  1. 业务之间可以独立部署,互不影响
  2. 前端不再参与具体模块业务开发,由相关模块后端独立负责

4. 方案

4.1. 方案选择

微前端的实现方案有多种多样,其实方案之间也没有优劣之分,合适的才是最好的(方案前面文章有讲)。这里控制台使用的方案,是运行时通过 javascript 集成(在页面中引用所有模块的 script url)。下面简单讲一下,选择这种方案的原因。

  1. 控制台模块之间,依赖是很复杂的。比如:云服务器,依赖 弹性 IP、云硬盘、VPC 等等模块。VPC 被绝大多数业务模块依赖。这里如果采用 iframe 的方案,没有办法共享模块间的配置,而此类信息不适合全局模块收集,必须由相关模块暴露。
  2. 模块要求独立部署,那么就不能在服务端拼合(改一次更新一次,频率过高)、不能在构建时集成(独立部署)。

4.2. 方案解析

  1. 上面讲到模块间的依赖很复杂,那么对于控制台来说,模块间真正的依赖是什么。组件实现?接口信息?配置信息?路由?这里给出的答案是:全局模块需要模块的路由和配置信息用于初始化整个项目。而模块间的组件实现、接口信息可以有一定的冗余。所以,模块间的解耦集中在向全局提供其路由和配置信息,同时将对全局文件的引入改成访问全局变量的形式,至于模块间的,暂时可以不处理。

  2. 全局模块是否可以再次拆分依赖?如果考虑到复用,肯定需要二次拆分依赖。所以这里又将全局依赖划分为两部分,一部分被称为微前端解决方案,另一部分是控制台全局模块(业务相关)。

  3. 微前端解决方案包含了:vuecloud-ui(组件库)、vue-routernpm 包 以及页面启动引导器(这里暴露的全局变量为 mv),不含任何业务代码,访问相关属性均通过全局变量的方式暴露。其中为了防止用户修改 mv 的内容,使用了一个小窍门。另一方面为了大家使用时不需要看说明文档,就利用 typescript 写了相关的声明文件,通过 vscode 之类的编辑器,就可以做到自动提示。相关代码如下所示:

    // 使用 mv 下面的方法,在业务模块使用
    mv.util('cookie') // 获取 util 这个命名空间下的 cookie 方法集
    /** 获取全局定义 util */
    function util(service: string): object;
    function util(service: 'cookie'): cookie; // 重载
    
    interface cookie {
        /**
         * 设置 cookie
         * @param name cookie 名
         * @param value cookie 值
         * @param days 过期时间,天
         */
        setCookie(name:string, value:string, days:number): void;
        /**
         * 根据 cookie 名,获取 cookie
         * @param name cookie 名
         * @return 不存在,返回 null,否则返回 cookie。
         */
        getCookie(name:string): null|string;
    }
  4. 控制台全局模块,包含了一些全局组件,方法等等,同时负责在合适的时机初始化路由、模块间的配置,最终由它真正启动整个项目。同时也会有一些方法,也需要和上述 cookie 类似的暴露方式,以及相关文档的编写。

  5. 模块的 entry 文件,它是很浅的一层,不含有任何业务代码(业务代码通过二次异步加载)。

    // vpc/index.js
    mv.module('vpc', {
        routes,
        config,
    });
  6. 写了一个脚本,用于模块内替换全局引入,如下:

    import cookie from '@/utils/cookie';
    // 会被脚本替换成
    // const cookie = mv.util('cookie');
  7. 伪全局组件、方法降级,这里纯粹的人工活,简单进行分辨即可

  8. webpack 里,定义多个 entry, 同时将 commonChunk 的配置删去,因为独立部署的原因,要求代码互不影响,所以必须删去 commonChunk

    {
        entry: {
            vpc: 'src/module/vpc/index.js',
            main: 'src/module/main/index.js',
            bootstrap: 'src/module/bootstrap/index.js',
        },
    }
  9. 打包策略调整,需要在打包时指定打哪些 entry,比如说 vpc 团队,只需要打包 vpc 这个 entry 即可。原前端团队需要打包 bootstrapmain 这俩 entry

  10. 页面引入脚本方式调整,这里就不展开了。

  11. 发布工具链整合,全自动化。

4.3. 前后端分离部署

通过上述的拆分,页面也是可以跑起来的,但这里需要考虑另一个问题。在前后端不分离部署的前提下,更新一个模块,后端就重启一次。在极端情况下,页面可能会挂掉数个小时,所以前后端必须部署分离。问题就转换成如何分离部署,一般的方案可能有如下两种:

  1. 全局的后端提供相关接口,同时注入相关变量到模板中。假如 vpc 模块有更新,就调用相关接口通知全局的模块,当用户刷新页面的时候,即可看到新模块。

  2. 在某个地方存放版本信息,如 https://xxx.com/vpc.js。同时在页面引入 <script src="https://xxx.com/vpc.js?t=timestamp"></script>

// vpc.js
loadStatic({
    js: [`hash.js`],
    css: [`hash.css`],
});

控制台这里一开始选择的是第一种方案,但是这种方案有一个隐藏的坑,有些线上环境对网络的安全性有要求,外网无法访问相关环境的接口,幸好打包方案有考虑此种情况,最终降级上线。第二种方案需要加载的 script 太多了,性能堪忧。所以结合两种方案的优点,设计了第三种方案,后面成功上线所有环境。

存在一个中介服务,它会生成一个名字叫 staticInfo.js 的文件放在 nos 上,同时页面引入<script src="https://nos.com/staticInfo.js?t=timestamp"></script>。每当模块有更新,中介服务就会更新 staticInfo.js 中相关模块的信息。当用户刷新页面,该模块的代码就是最新的了。这样就做到了模块更新和代码引入相互解耦,同时和公共网络的第三方进行交流,就不存在网络不通的问题,同时将加载的脚本数量降为 1。

// staticInfo.js
loadStatic({
    vpc: {
        js: [`hash.js`],
        css: [`hash.css`],
    },
    main: {
        js: [`hash.js`],
        css: [`hash.css`],
    },
});

4.4. 整体流程梳理

  1. 用户进入到页面,首先加载 staticInfo.js,找到 bootstrap 模块的信息,进行加载
  2. bootstrap 加载所有模块的 [entry].hash.js
  3. 待所有模块的 [entry].hash.js 加载完毕,再加载 main 模块
  4. main 加载完毕后启动整个项目,根据路由的不同,再加载 chunk.[entry].hash.js

可以看到加载是分成四个层级,在第三层级用户就可以看到页面。但还是会对页面性能有一定的影响,后续可以对相关代码进行优化调整

4.5. 方案说明

  1. 全局模块的稳定性非常重要,模块之间如何写问题并不大,影响的范围有限,但全局模块的修改,会影响所有模块,所以后续,全局模块可以发布成 npm 包的方式引入
  2. 从上面代码描述可以看到,并没有对仓库进行拆分,原因是耦合严重,需要花人力慢慢来,而且拆分不一定有特别大的好处,需要对开发流程进行二次改造
  3. 微前端解决方案现阶段并不优秀,需要更加合理的抽象
  4. 加载性能优化
  5. 初期一定要引入 code review 工作模式,后续可以将工作交由模块业务方进行。
  6. 模块间的依赖解耦需要在合适的时间点交由模块方处理掉
  7. 需要设计合理的机制,处理业务方的全局模块,由业务方维护,同时又在全局。
  8. 脚本请求失败时的容错处理机制还未设计,但可以参考腾讯云的实现(很有意思)

5. 参考文档

  1. https://martinfowler.com/articles/micro-frontends.html
  2. https://micro-frontends.org/
  3. https://github.com/phodal/microfrontends