微前端技术在游戏平台后台系统的实践
一、背景
哔哩哔哩游戏业务作为公司主要业务之一,一直保持了较快的发展速度。而作为游戏平台方,为公司的独代发行、联运和自研业务提供了广泛的技术支持。
作为核心业务配置系统的 B 端,承载了包括游戏上架,游戏内服务,CP 管理等多条业务核心流程。随着业务的快速发展,对业务系统的稳定性,运营配置效率等方面都有了更高的要求。
因此,我们引入了微前端架构,来帮助我们更好地对游戏平台内的后台系统进行整体升级改造。
微前端,同它的名字一样,正是受微服务的启发,也是用以治理臃肿膨胀的工程项目为目的的一种技术方案。
使用微前端架构的具体表现为,一个前端应用根据功能或页面拆分为多个小型的前端应用,它们独立运行,独立开发,独立部署,但又通过技术手段聚为一体。
而在游戏业务场景下,我们在 B 端系统的开发及使用中实际面临着以下几个痛点:
业务线多
伴随着游戏平台的业务增长,可能每新增一项业务能力,就需要配备对应的后台配置能力。
团队较独立
但游戏平台各业务线较为独立,往往都是各自独立开发了许多运营管理后台,没有统一的收口管理。
运营效率问题
在众多配置管理系统的环境下,运营可能面临着多平台间的切换,以及多开发团队的技术对接等问题,影响运营配置效率。
技术栈不统一
部分后台技术陈旧,而前端技术日新月异,历史久远的平台系统扩展维护困难,新功能需求持续不断地产生,有时为了开发效率的考虑,不得不摈弃历史系统,这也是持续不断增加新系统的原因之一。
服务能力
虽然 B 端业务在业务中担当着类似幕后工作的角色,但同样也需要稳定可靠的优质服务,各个平台之间都希望不因为无关的改动影响到其原本的能力。
而微前端技术很好的解决了这些痛点。
采用微前端的优点
为什么要使用微前端架构?因为其具备众多优点,且足以满足我们的业务需要:
多技术栈融合能力:无关技术栈实现,可以通过技术手段将应用聚合在一起。
中心化路由: 主应用统一注册子应用,统一管理各个子应用的路由,提供菜单切换能力。
渐进式升级:融合历史项目较为友好,不需要重写业务逻辑,只需要在应用入口处做一些细微的调整就可以完成接入。
多开发团队支持:按业务领域拆分成不同的应用进行维护,各个业务团队只需要关心各自负责的业务。
独立部署:每一个微前端应用都具备独立的开发和工程化流水线,不同应用间互相独立,且互不影响 。
二、选型
实现微前端有多种方式,我们调研了业界常见的一些做法和技术框架,总结对比如下。
综合业务场景接入成本以及稳定兼容性等因素,我们最终采用了 qiankun 框架。
qiankun 框架相比传统的路由分发式和 iframe 方案,有更好的用户体验。
qiankun 是基于 single-spa 框架的二次封装,在当前最流行的稳定的微前端实现方案的能力基础上,优化了接入方式,降低了学习成本,完善了 js 沙箱,应用通信等在业务开发中非常必要的功能。
当然也有一些新兴技术框架如 micro-app 也非常值得学习参考,但相对而言 qiankun 有着更久远的历史,更稳定的技术支持,更大量的实践验证。
三、qiankun 工作原理
“工欲善其事,必先利其器“,通过了解 qiankun 的基本工作原理才能更好地接入以及应对可能产生的问题。
实现一个微前端框架,必须包括以下两个要素和两个核心功能。
要素:
一个主应用:也称作基座应用,是所有子应用的容器,用来加载,渲染不同的子应用。
若干个子应用:按照功能/业务维度拆分,独立开发部署。
核心功能:
应用的加载与切换:包括路由的注册,应用加载卸载等。
应用的隔离与通信:js 隔离,样式隔离,父子应用间通信。
我们可以从以上两个核心功能点切入,来学习理解 qiankun 内部原理。
应用加载
主要基于 import-html-entry 插件实现,每个子应用都需要在主应用中注册其 html 的入口地址和路由。主应用根据注册路由,识别到目标子应用,完成加载。
匹配到注册路由的子应用完整的加载流程为:
1、通过 fetch 请求获取到子应用的 html 字符串(要求同域或跨域支持)并解析拿到 html 模板及其对应的 js、css。
2、通过 fetch 请求获取 css,以内联 css 的方式嵌入到 html 模板中。
子应用独立运行,通过外联加载的 css:
通过 qiankun,作为子应用内嵌入主应用加载后可以看到原本外联的 css 文件被解析成了内联 css 代码嵌入:
3、沙箱执行 js。同样通过 fetch 请求加载 js 外联资源(异步执行的 js 会使用 requestIdleCallback 封装并延迟获取)后,会用一个匿名执行函数包裹,用 eval 函数执行。
应用沙箱隔离
子应用的运行过程中难免会产生一些副作用,比如全局变量的产生,因此需要保证子应用都是独立运行在各自的空间里不会互相影响,即沙箱隔离。
qiankun 的沙箱隔离的实现思路简单说就是在应用挂载和卸载时记录快照,在应用切换时根据快照还原。在 es6 中可以借助 proxy 代理来实现,核心代码如下:
不过需要注意的是沙箱只隔离了一层的劫持,原型链上的改动例如 Date.prototype.xxx = function () { ... } 是不会被还原的。还有一些会产生副作用的程序(如 addEventListener,setInterval 等),也是无法完全覆盖并自动解除影响的,需要手动在子应用卸载阶段手动清除影响。
因此,qiankun 官方不建议共享依赖,不然很容易产生原型链污染等问题。但是否共享一些大型依赖(如 React,Vue)等尚没有定论,往往需要根据业务情况来决定。如业务中需要,推荐使用类似 webpack 的 externals 配置项功能来剥离项目中的依赖库来完成共享。
四、项目实践
游戏平台在开发提供用户服务相关的运营配置系统的过程中,尝试落地了微前端架构,并扩展接入了其他来自不同业务场景的运营系统。下文主要按照开发实践中的先后顺序来分享一些实践方法和经验总结。
页面结构
页面整体结构虽然看起来同普通的 SPA 应用并无差异,但其中却包括了多个应用的融合展示。
主应用主要包括公共导航栏,以及控制路由切换的菜单栏。菜单栏接入了统一权限管理平台,根据用户角色按需展示导航和菜单。
子应用则只负责各个独立的功能页面。
路由注册
我们根据业务和功能进行了子应用的拆分。拆分后的子应用还需要通过 registerMicroApps API 来完成注册。
注册信息主要包括子应用的名称,入口地址,子应用容器节点和子应用的激活规则。
激活规则比较灵活,同时支持 hash 模式和 history 模式。但在项目实践中,如子应用内部也有路由导航,建议采用的模式应同主应用保持一致。特别是主应用项目采用 hash 模式路由的情况下,如果子应用采用 history 模式,每次切换会改变 pathname,这时候很难通过激活规则去匹配到子应用,会造成子应用 unmount。
这里以 hash 模式为例,注册信息如下:
子应用内路由也要做相应的调整,需要采用统一的 base 路径(也就是在主应用中注册的入口路由),在原路由基础上再多嵌套一层:
那么在访问地址 /subapp1/page1/list 时是怎么匹配到目标页面的呢?
首先会先加载主应用,主应用识别到是 /subapp1 打头的路由就会加载子应用 subapp1,子应用 subapp1 加载后就接管了路由,再匹配指向目标列表页面。
完整的路由注册结构如下:
子应用接入
虽然子应用与技术栈无关,但还是需要按照协议接入,即在约定生命周期内完成应用的装载和卸载,并把这些生命周期函数直接暴露对外。
主要的生命周期钩子函数及其具体的作用:
bootstrap:第一次挂载之前的初始化阶段。
mount:挂载阶段,一般也建议在这个钩子函数里执行 render 函数,把应用挂载渲染到真实的 DOM 节点上。
unmount:应用卸载阶段,执行数据清理等操作。
每一个子应用都需要在 mount 阶段完成子应用的渲染挂载,以及在 unmount 阶段完成子应用的卸载。子应用的挂载渲染逻辑与常规应用不同,不可直接在入口文件中立即执行,需要用单独的 render 函数包裹(逻辑复用),根据不同的情况(即判断是作为子应用挂载还是独立运行)选择在合适的时机执行。而且需要在卸载阶段做好清理工作,避免内存泄漏。
react 项目接入核心代码:
vue3 项目接入核心代码:
数据共享
理论上,各个子应用是可独立运行的,但部分情况下还是存在一些数据共享的场景。
我们在项目实践中遇到的场景主要有权限 id 列表,通用列表数据,环境变量,用户信息等。
我们根据数据量大小以及作用场景,采用了三种不同的数据共享方式。部分是利用 qiankun 框架提供的方法,部分则是采用浏览器原生能力。
1、生命周期钩子函数传递
在前文中有提到,需要在主应用中对子应用进行注册。除了必要的入口信息外,还可以在注册的时候传递数据。
比如我们主要用来传递环境变量,在主应用中注册时:
子应用接收环境变量,并将其缓存在全局变量(或者使用状态管理工具存储到数据流)中:
这种场景主要适用于传递一些常量,或者在应用加载之初就能获取的数据。
2、使用 qiankun 内置通信方法传递
qiankun 内部提供了 initGlobalState 方法,支持定义全局状态,并实时监听。
主应用或任意子应用中出发状态设置。
主应用或子应用中设置监听,并使用数据。
当然数据传输只是 globalStateChange 事件的应用场景之一,它使用起来非常地灵活,且无关应用角色。
推荐使用场景:涉及子应用之间的数据传输,需要传递一些不太能及时获取到的数据,以及需要动态变化的数据。
3、使用浏览器 local storage 传递
使用浏览器自带的客户端存储技术,如 local storage,在此不再赘述。
我们的项目主要用来存储以及在应用之间共享权限 ID 列表的数据。
其他还推荐在一些大数据量共享的场景中使用。
应用管理及部署
1、仓库管理
在开发过程中,常用的仓库管理方式可以总结为:multirepo 和 monorepo 方式。
微前端架构最核心的价值优势之一即支持主应用和子应用的独立开发和部署。因此,各应用使用独立的代码仓库,即 multirepo 方式可以做到最大化的独立开发和代码管理。该方式可以承接完全不同风格的代码和工具,甚至可以使用完全不同的工作模式。
然而,如果仅根据业务线来拆分子应用,部分子应用很可能还会包含多达数十个功能配置页面,再次形成庞大臃肿的巨石应用。因此,我们在业务线基础上进一步根据功能进行了拆分。
针对这种来自同一个业务开发团队维护的较小颗粒度的子应用,则推荐使用 monorepo 方式。不仅可以继续落实团队内的开发规范,统一管理,还可以快速归类定位到目标应用进行开发。
根据功能模块划分为子应用等,对应工作目录如下:
每个项目下应用的启动和构建均采用相同的启动命令。
借助 lerna 工具,支持并行启动开发所有应用或独立启动开发单个应用(scope)。
2、打包构建
除了常规的项目构建配置外,子应用需要做一些特殊的配置:
为了让 qiankun 通过 eval 执行子应用的 js 逻辑时可以拿到暴露的生命周期函数,需将子应用的打包输出类型设置为 umd 模式。
3、独立部署
qiankun 通过 html entry 的方式来加载子应用,因此对子应用和主应用的部署方式是没有任何限制的。
即使是接入来自不同开发团队的子应用,也无需关心他们采用的部署方式。如果部署在相同域名下,直接可以完成接入;如在不同的域名路径下,也只要目标所引用的资源做好跨域支持即可。
主应用和各个子应用均使用独立的应用节点,接入公司内统一持续交付平台,分别独立部署。子应用改动后也只需要单独对它的目标服务节点进行构建发布,其他应用不受影响。
游戏平台 B 端系统均采用静态部署的方式,对应的目录和 SLB 配置大致如下:
五、一点避坑建议
css 污染
我们在项目实践中主要采用了两种方式来避免 css 污染。
1、常规开发中使用 css scope
2、子应用使用公共样式库需要带上全局前缀
监听子应用路由
子应用装载后,会自动接手对路由的控制,特别是在跨技术栈的一些场景下,子应用内的路由变化可能无法直接触达到主应用。
但前端路由无非都是基于 history API 的二次封装。大部分场景下可以利用对原生事件来监听子应用中触发的路由改变。
资源引用路径
游戏平台 B 端系统一般采用静态站部署的方式,页面内包含的 js 和 css 资源和 html 页面一同打包上传至静态服务站, js 和 css 资源也直接采用相对路径的方式在页面引入。
此时作为子应用引入时,主应用通过 fetch 请求获取子应用资源时,是使用主应用的域名和路径和子应用内资源的相对路径完成子应用资源路径的自动补全查找的,这时候往往会发生错误。
因此,建议在子应用构建打包流程,利用工具自动完成 js 和 css 资源路径的补全,输出为绝对路径。
六、总结
以上就是哔哩哔哩游戏平台在微前端架构实践中的经验总结。
我们从常规业务需求开发切入,抓住了业务核心痛点,主动从技术上推进了业务能力的升级,落地了微前端架构,快速建设了一个扩展性高、聚合众多业务线的运营管理后台。
微前端架构落地半年内,就已有三个子应用完成了接入,其中包括一个跨业务开发团队的子应用。
在这个跨团队子应用的接入中,充分利用了微前端带来的优势,我们作为主应用的收口方,无需关心子应用的开发部署方式和业务逻辑,一天就完成了子应用的接入,且接入的子应用之间互不感知,互不影响。在子应用开发接入的积累中,我们还总结输出了子应用脚手架工具,以及开发了自动适配接入工具。减少子应用接入的学习成本,完善了统一运营管理后台的技术支持能力,足以承担今后来自更多业务线融合的挑战。
除此之外,我们还得以将巨型应用体根据功能拆解成细颗粒度的子应用,减少了构建打包过程中的等待时间(每个应用控制在 1 分钟左右),提高了开发体验和上线速度。
文 / 游戏发行事业部 技术部 大前端团队
版权声明: 本文为 InfoQ 作者【bilibili游戏技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/56a9596352fbaf52f8cef2711】。未经作者许可,禁止转载。
评论