震撼!微前端在TMD的落地实践与突破

1,513 阅读10分钟

引言

严格来说,这篇文章应该在 2020年 年底发表出来,迟迟没有写原因很简单 - "懒"

纸上谈兵

微前端时间线

image.png

什么是微前端

微前端是一个设计理念,不是一个库或者框架

微前端的概念由 thoughtworks 在 2016 年提出。其核心思路是借鉴后端微服务架构思想,将一个庞大的巨石前端应用拆分为多个简单独立的前端工程。每个前端工程可以独立开发、测试、部署。最终再由一个主应用应用将所有子应用合并到一起,以一个完整的网站形式展现给用户。简单来说,微前端 【一拆一合】,拆的是复杂度、合的是视图和子应用之间的通用能力!

  • 独立开发、降低代码耦合: 每个子应用由独立的前端团队开发,一般是独立仓库,这使得在代码维护、代码编译方面更加友好
  • 独立部署: 团队可以自行控制负责子应用的研发、编译、测试、部署等,上线后自动更新内容且不影响其他模块。
  • 技术栈无关、增量升级: 对于一个古老的项目来说这是很具有吸引力的,可以对项目进行增量技术迭代
  • 独立运行时: 每个微应用之间状态隔离,运行时状态不共享

你可能不需要微前端

满足这部分的同学,可以控制台执行window.close()了

  • 系统由单个小团队开发,拥有技术的绝对话语权
  • 对于老系统,你们有足够精力是做技术改造 或 不去做技术改造
  • 系统强耦合,拆分应用会带来更高的人力成本

为什么不是iframe

可以容忍下面几条的的同学,也可以控制台执行window.close()了

  • url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  • UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  • 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  • 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

(引用自 www.yuque.com/kuitos/gky7…

qiankun

源起

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。在我看来和single-spa的主要区别在于html-enty、隔离,后面会讲到。

核心

  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 🛡​ 样式隔离,确保微应用之间样式互相不干扰。
  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

说下js沙箱

  • SnapshotSandbox:基于快照,适用于不支持 Proxy 的浏览器,有性能和污染全局 window 问题。
  • LegacySandbox:基于 Proxy 的单例模式,在激活和失活时进行环境恢复。
  • ProxySandbox:基于 Proxy 的单例和多例模式,通过对 fakewindow 代理实现隔离。

说下css沙箱

qiankun 的 css 沙箱的原理是重写 HTMLHeadElement.prototype.appendChild 事件,记录子项目运行时新增的 style/link 标签,卸载子项目时移除这些标签。

子应用之间样式隔离

子应用卸载会去掉dom(包含样式表),所以子应用之间不存在样式污染

父子应用之间样式隔离
  • 父子应用qiankun也提供了解决方案,但是如果使用样式库会出现各种奇奇怪怪的问题。
  • 需要借助样式库的命名空间,比如element-plus的 ElConfigProvider
  • 如果组件库不支持定制命名空间,那你需要一款样式定制的插件了,比如post-css-namespace-plugin

主应用

1. 安装 qiankun
 yarn add qiankun # 或者 npm i qiankun -S
2. 在主应用中注册微应用
import { registerMicroApps, start } from 'qiankun';


registerMicroApps([
  {
    name: 'react app', // app name registered
    entry: '//localhost:7100',
    container: '#yourContainer',
    activeRule: '/yourActiveRule',
  },
  {
    name: 'vue app',
    entry: { scripts: ['//localhost:7100/main.js'] },
    container: '#yourContainer2',
    activeRule: '/yourActiveRule2',
  },
]);


start();

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子。

如果微应用不是直接跟路由关联的时候,你也可以选择手动加载微应用的方式:

import { loadMicroApp } from 'qiankun';


loadMicroApp({
  name: 'app',
  entry: '//localhost:7100',
  container: '#yourContainer',
});

子应用

微应用不需要额外安装任何其他依赖即可接入 qiankun 主应用。

1. 导出相应的生命周期钩子

微应用需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出 bootstrapmountunmount 三个生命周期钩子,以供主应用在适当的时机调用。

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('react app bootstraped');
}


/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}


/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount(props) {
  ReactDOM.unmountComponentAtNode(
    props.container ? props.container.querySelector('#root') : document.getElementById('root'),
  );
}


/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('update props', props);
}

qiankun 基于 single-spa,所以你可以在这里找到更多关于微应用生命周期相关的文档说明。

无 webpack 等构建工具的应用接入方式请见这里

2. 配置微应用的打包工具

除了代码中暴露出相应的生命周期钩子之外,为了让主应用能正确识别微应用暴露出来的一些信息,微应用的打包工具需要增加如下配置:

webpack:

webpack v5:

const packageName = require('./package.json').name;


module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    chunkLoadingGlobal: `webpackJsonp_${packageName}`,
  },
};

webpack v4:

const packageName = require('./package.json').name;


module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

踩坑

请移步qiankun官网,很良心的文档!👍🏻

微前端在互联网大厂S级项目实战 - 2020年

背景

  • 坐标:某互联网一线大厂
  • 职责:主导微前端架构落地
  • 项目背景
    • 该项目是一个可预见的硕大工程/巨石应用
    • 业务方面由三个团队共同建设,每个团队大致会出3-4人参与业务研发
    • 类似历史项目存在 能力实现冗余、技术升级困难、团队对接成本高、构建时间长、发布风险高、系统体验差等问题 (这几个问题作为前端应该都能感同身受吧)
      • 相同的能力实现N次,实现方式不一,升级困难
      • 单次build 8分钟
      • 构建出现OOM
      • 热更新6秒+
      • 单次发布上百页面

选型

image.png

架构设计-初步方案

  • 责任划分:按照团队和业务将巨石应用拆分为一个主应用和三个子应用,主应用负责基础框架、子应用逻辑控制、系统UI结构
  • 部署环境:完整的系统展示给用户
  • 开发环境:只启动子应用,通过将header和sideBar抽象为npm组件使得本地开发可以有完整的UI视图(也仅仅是这样了)

image.png

image.png

方案并不完美

  • 基本符合要求:按照常规方案来看,该方案已经可以落地了,咨询了我厂其他部门、外部大厂一些同学,基本上也都是这么落地,但我觉得这个方案有些鸡肋了...或者说只是用了,并没有用好
  • 存在的问题:能力实现冗余、技术升级困难、团队对接成本高这三个问题并没有解决。简单来说,因为本地开发只启动子应用,不启动主应用,所以子应用间有很多通用能力不能很好的做出抽象和复用,带来了能力冗余、维护成本高等问题。
  • 是否有一种完美方案,能在上述基础上,抽象子应用通用能力统一实现,并能和子应用友好的"交互"

思考

如果本地开发和部署环境运行时代码高度一致,我们抽象通用能力在主应用中实现,统一下发到子应用,那么问题将迎刃而解。

本地开发不需要启动主应用,但是却能使用主应用的能力,听起来匪夷所思,但、却是可行的!

方案升级-带壳开发

方案核心 - 本地开发时,使用部署环境的主应用加载子应用本地的devServer服务;部署环境加载远端子应用html

本地开发时页面url由http://localhost:8084改为使用http://域名.com?LOCAL_PORT=8084

image.png

最终方案

image.png

最终方案实践

最终方案更多的是一种架构建设思路,旨在解决项目实际痛点,当时实践过程中遇到一些问题,篇幅问题直接写问题和解法,感兴趣可以私聊

踩坑1:子应用本地开发不支持HMR image.png 原因:webpack devserver默认读取的socketHost是/,修改为localhost即可 解决方案:

// Webpack配置举例
devServer: {
    sockHost: 'localhost'
}

踩坑2:部署环境无法加载本地静态资源

image.png 原因:webpack基于安全考虑禁止远端加载本地静态资源

解决方案:

// Webpack配置举例
devServer: {
    allowHosts: ['.abc.com', '可信任域名'] // 支持通配符
}

踩坑3:部署环境加载静态资源地址不对

image.png 原因:webpack devserver 默认加载静态资源根目录是/

解决方案:

// Webpack配置举例
output: {
    publickPath: `/localhost:${port}`
}

收益

  • 成功落地S级项目
  • 抽象10+项基础能力,节省30+PD(9个子应用),且后续技术升级均平滑无阻塞
  • 成功解耦【技术架构】和 【业务研发】,让业务同学专注于业务建设
  • 三个团队共建巨石应用,共计9个子应用,上线三年无低效协同事宜
  • 单次构建时间平均1min,HMR平均0.5s内
  • 个人获得S绩效和个人之星奖

小结

没有最好的技术,只有更适合的技术,突破思想束缚,打造更适合项目的技术架构

最后一公里

支持带壳开发的webpack5脚手架