20

闲庭信步聊前端 - 见微知著微前端

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzU0OTExNzYwNg%3D%3D&%3Bmid=2247488286&%3Bidx=1&%3Bsn=b940b63ccdb807c56196558b609e78f0
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

笔者初次接触微前端在2020年7月,是从同事的口中听说的。虽然不算是一个早期接触者,但是也确实的推动和跟进了内部某大型项目的开发和落地。也希望能把一些走过的坑和一些思考分享给大家。文内所指应用均为PC网页应用。此文不进行移动端M页讨论。

当时网上信息也不是很多,百度搜索微前端的结果也寥寥无几,乾坤,飞冰占据了大部分篇幅,还有的就是美团的内部技术沙龙。谁想半年后微前端一词越炒越烈,各种解决方案和技术实现如雨后春笋,也逐渐形成了百家争鸣的态势。

什么是微前端

网上介绍微前端的文章不胜枚举,大多源引自 Micro Frontends这篇文章。文内详细的介绍了微前端的思想。也推广了一种微前端的实现方案( Web Components ),随着时间的推移和笔者对微前端的实践,对微前端这一概念也有了一些自己的认知,希望可以帮助大家更好的理解微前端。

何为微前端,分而治之然同台而坐也。微者,散也。详细来说,即微前端是一种架构,是将整个巨石应用拆分成多个可独立开发、部署、上线,运行的小型应用(子应用),对外暴露一个控制台应用(父应用)来统一管理各个子应用的运行状态,多个子应用间在用户无感情况下往复切换。

微前端一词第一次出现是在工作思维(ThoughtWorks)2016年的技术雷达( Technology Radar )中。但是笔者认为微前端的思想在更早就已经被频繁的应用于巨石应用上了。回望前后端还未分离的时代,还在用jsp混写java和HTML时,当时一个页面就是一个html文档,以index.html文件为入口,通过锚点标签( <a/> )将页面连接起来,页面以功能为指标,分别隶属于不同的项目中,项目间互不影响,可分布独立上线。笔者认为这就是最早期的微前端。随着SPA应用的兴起,这一早期的微前端思想也慢慢的被埋没。

为什么要用微前端

现在,SPA应用已经无法满足现代网页应用的逻辑复杂度和功能的多样性了。试想一个几M的入口文件,在首开时请求无数张图片和css文件,这期间的网络耗时和浏览器渲染时间都早已远远达不到用户预期了。

虽然也出现了诸如懒加载,精灵图,文件压缩,CDN等数不胜数的解决方案,但本质还是隶属于同一个巨石应用,这也导致对应用的任何改动都要重新打包整个项目,即使开启了happypack多线程打包,其工程效率也是不敢恭维。相信有过大型项目维护经验的读者也能体会到那种心在飞而身不动的那种无力感。

另一方面,今年前端技术栈爆炸式增长,早已不是手握jQuery,走遍天下都不怕的时代了。vue已经到了3.0,react的版本更是到了17这个恐怖数字。angular国内虽然用的不多,但是也是三大框架之一,其版本也是早早到了11。即使在各个版本向下兼容的情况下,试问有几个人敢保证项目在基础框架不被时代抛弃的前提下。长期稳定运行。这些人中又有几个人经历过angular2的重写升级和react的hooks重构(虽然react不建议这样)。

所以长远来看,将一个巨石应用拆分成多个小型应用。以达到项目可稳定升级迭代还是很具有可行性的。基本原理的流程如下:

bMjMfmA.png!mobile

现代应用痛点:

  • 项目中的组件和功能模块会越来越多,导致整个项目的打包速度变慢;

  • 因为文件夹的数量会随着功能模块的增多而增多,查找代码会变得越来越慢;

  • 如果只改动其中一个模块的情况,需要把整个项目重新打包上线;

  • 目录层级和模块层级过深而且文件又多,定位文件会越来越慢;

  • 所有的项目都只能使用同一技术框架如:react、vue等;

微前端优势:

  • 技术栈无关:主框架不限制接入应用的技术栈,微应用完全具备自主权;

  • 独立开发、独立部署:微应用的 git 仓库独立,微应用部署完成后父应用打开的页面同步更新;

  • 增量升级:在面对各种复杂的场景时,我们通常很难对一个已存在的系统做全量的技术栈升级或者重构,而微前端可以让我们可以做到很好的渐进式重构;

  • 独立运行:每个项目都可以作为一个完整的单独项目去运行,它可能只是一个大后台项目中的某个功能模块,然后所有微应用组合到一起就是 PM 想要的完整功能;

微前端技术方案

路由分发式微前端

顾名思义就是通过路由(如 nginx 配置)将不同的业务分发到不同的应用上,这也是采用较多而且做简单的“微前端”方法,但是这种方式其实更像是将多个应用聚合起来了,将不同的前端应用拼凑到一起让他们看起来像是一个整体。

iFrame

iframe 是浏览器提供的一个 html 标签,iframe 元素会创建包含另一个页面的内联框架(即行内框架)。这个标签可以有效的把完成“微前端”,但是如果使用就要考虑两件事情:

  1. 应用的加载问题:父应用何时去加载和卸载微应用,在页面切换时使用怎么的效果或者动画让整个页面看起来更加自然更容易接受;

  2. 应用的通讯问题:通过 HTMLIFrameElement.contentWindow 去获取 iFrame 元素的 window 对象是一种更简化的方法,但是这就需要定义一套通讯规范:事件的key采用什么形式怎么起名,从什么时候开始监听时间等等问题;

Web Components

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。它主要有三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  • Custom elements:一组JavaScript API,允许您定义custom elements及其行为,然后可以在您的用户界面中按照需要使用它们。

  • Shadow DOM:一组JavaScript API,用于将封装的“影子”DOM树附加到元素(与主文档DOM分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。

  • HTML templates: <template><slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

微前端框架选择

Mooa

Mooa 是一个为 Angular 服务的微前端框架,它是一个基于 single-spa 的微前端解决方案,但是他的项目服务于 Angular,就直接pass这种框架了,有兴趣的同学可以自行去研究一下。

飞冰

飞冰是一个面向大型系统的微前端解决方案,它可以保证一个系统的操作体验基础上,实现各个微应用的独立开发和发版,通过 icestark 管理微应用的注册和渲染,正整个系统彻底解耦。(这是我们的候选之一)

飞冰的官网地址:https://ice.work/docs/icestark/about

qiankun

qiankun 是一个 single-spa 的微前端实现库,qiankun 的设计理念一个是简单,另一个就是解耦/技术栈无关:

  • 简单:由于主应用微应用都能做到技术栈无关,qiankun 对于用户而言只是一个类似 jQuery 的库,你需要调用几个 qiankun 的 API 即可完成应用的微前端改造。同时由于 qiankun 的 HTML entry 及沙箱的设计,使得微应用的接入像使用 iframe 一样简单;

  • 解耦/技术栈无关:微前端的核心目标是将巨石应用拆解成若干可以自治的松耦合微应用,而 qiankun 的诸多设计均是秉持这一原则,如 HTML entry、沙箱、应用间通信等。这样才能确保微应用真正具备 独立开发、独立运行 的能力;

因为他的介绍和理念就很突出两个字,简单所以他就成为了我们的主角,但是呢还是要经过一番对比的。

qiankun的官网地址:https://qiankun.umijs.org/zh

qiankun与飞冰的对比

下面就列出以下对他们的大概对比,截止至2020-12-03

飞冰

  • git:星星数 943;

  • js 隔离:沙箱;

  • 样式隔离:使用 CSS Modules 方案管理样式;(正常尝试使用 Shadow Dom 的方案)

  • 打包相关:强制依赖 ice.js,及应用只能使用 react,打包也要使用ice.js 框架打包;

  • 更新周期:1周 ~ 一个月;

qiankun

  • git:星星数 7.8k;

  • js 隔离:沙箱;

  • 样式隔离:Shadow Dom;

  • 打包相关:依赖于 qiankun,官方推荐使用 parcel 打包,但是可以使用 webpack;

  • 更新周期:1天 ~ 一周;

经过上述的整理和查看后,我们内部决定使用qiankun,原因有很多,因为我们的项目对打包结果有要求,使用 parcel 满足不了我们的要求,所以要用 webpack 来打包自己想要的结果,样式隔离相比较 飞冰 更成熟,星星数高等等。

qiankun实际使用的介绍和问题记录

框架选择完毕后我们就开始搭建项目,搭建了两种不同的父应用(也可称之为基应用),一种是 react 的一种 vue 的。之后用了 demo 提供的微应用做了尝试,然后打算统一下代码最后决定后台项目都是用 react 来写,所以最后切换为 react 项目。

在研究之中得到一个消息,咱们的上线打包结果有限制的,必须要以xxx什么形式的才可以上线,然后还有一堆配置,因为时间紧迫所以只能被迫开始使用公司的搭建的 zz-react-cli 来生成 react 项目,所以最后父应用和微应用都是使用的 zz-react-cli 生成的。(这就是我们内部问题了,就不多介绍了),下面开始给大家介绍使用和遇到的问题以及怎么解决的。

基本项目结构是这样的,包含主子应用,如下图: MRNz2ub.png!mobile

qiankun基本使用

项目框架

父应用和微应用都是用 react 官方脚手架生成,然后下面再讲对其修改和配置。

父应用的改造

父应用要安装两个依赖包,一个 history 一个 qiankun ,然后就是对 index.js 的修改:

import { registerMicroApps, start } from 'qiankun';

ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

registerMicroApps(
[
{
name: 'app1',
entry: '//localhost:3002/',
container: '#container',
activeRule: '/app1',
props: {
name: 'kuitos',
}
}
],
{
beforeLoad: app => console.log('before load', app.name),
beforeMount: [
app => console.log('before mount', app.name),
],
},
);

start();

reportWebVitals();

然后就是对 app.jsx 的修改:

import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
function App() {
// 子项的点击
const onMenuClick = (path) => {
history.push(path);
};
return (
<div className="layout">
<header className="layout-header">
<img src={logo} className="layout-logo" alt="logo" />
<div className="layout-link" onClick={() => { onMenuClick('/app1'); }}>点击加载app1微应用页面</div>
</header>
<div className="layout-main" id="container">
app1微应用展示区域
</div>
</div>
);
}

微应用的改造

微应用什么额外的 npm 包都不需要安装,下面是 index.js 的修改:

const initAPP = container => {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
container?container.querySelector('#root'):document.querySelector('#root')
)
}
//全局变量来判断环境,独立运行时
if(!window.__POWERED_BY_QIANKUN__){
initAPP()
}
/**
* bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
* 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
*/
export async function bootstrap() {
console.log('react app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
const {container}=props
initAPP(container)
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
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);
}
reportWebVitals();

这样就是基本配置,但是这样配置并没有完全ok,中间你会碰到各种各样的问题,然后碰到问题请不要着急啦,请看下面的问题记录找到相应的解决办法。

到了这里,也许你会问,网上类似资料不是有很多么,随便搭一个demo应该很容易跑出来。不过,下面说的就是基于这套架构,我们遇到的问题了,真正的干货,快仔细看!

qiankun 问题记录

无法识别生命周期钩子问题

qiankun 抛出一个 Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry 的错误,这个问题是自己的项目或者官方项目引入 qiankun 是会抛出的错误,微应用的 webpack 打包部分就要加上以下代码:

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

output: {
// 这里改成跟主应用中注册的一致
library: 'brokenSubApp',
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${packageName}`,
},

具体原因是 qiankun 抛出这个错误是因为无法从微应用的 entry js 中识别出其导出的生命周期钩子。(官方有介绍)

请求资源跨问题

项目开始启动的初始阶段因为没有一个固定域名,但是开发还要允许跨域,这时候要怎么解决呢:

devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
}
}

在微应用的 webpack 的 devServer 中加上 'Access-Control-Allow-Origin': '*' 即可。父应用不需要加,因为是父应用去加载微应用的资源。

还有一种方法就是如果你有代理工具,比如说我们使用的 whistle。

127.0.0.1:8085 a.zhuanzhuan.com/manager 
127.0.0.1:8084 a.zhuanzhuan.com

把父应用和微应用的资源代理到同一个域名下也可以。

微应用刷新404问题

将父应用与微应用的路由模式切换为同样的路由模式,微应用以 browserHistory 为例:

<!-- config 设置 -->
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();

<Provider store={store}>
<ConnectedRouter history={history}>
<div className="rooter-wrap">
<Route routeList={routeList}/>
</div>
</ConnectedRouter>
</Provider>

<!-- 另一个页面的配置 -->
<BrowserRouter
basename = {window.__POWERED_BY_QIANKUN__ ? '/qc_xxx' : '/hunter_qc_xxx'}
forceRefresh = {!window.__POWERED_BY_QIANKUN__}
>

每个人或者公司的项目都不一样,这个具体要看各自的项目代码去更新一些东西。(如果有使用的话自己要研究下这里,否则就会造成刷新之后 404 的问题)

父应用和微应用的菜单问题

因为微应用可以单独运行,所以微应用有一套自己的菜单导航逻辑,然后再父应用时要把所有的菜单做整合。那么这就要延伸出一个问题了,怎么样去对所有的微应用的菜单做整合呢,我们想了三种思路:

  1. 在配置中心配置一套菜单导航,然后父应用请求配置中心拿到导航再渲染。微应用内部自己维护路由:

  • 优点:本地开发方便;

  • 缺点:维护两套路由,配置中心一套和本地一套;

  1. 父应用和微应用都在配置中心,这样子应用单独开发时也依赖于外部资源,可能会影响开发效率:

  • 优点:维护一套路由,有现在的配置中心接口,不需要单独开发;

  • 缺点:微应用单独开发时不方便,还要维护不同环境的不同路由。微应用和父应用都要拉取整套路由配置,然后子应用要过滤并筛选出自己的路由;

  1. 微应用打包时,把导航打包出一份单独的 .json 文件,然后通过 nginx 配置到某个地址,通过地址去访问这个文件内容,父应用请求该文件然后做整合,如下图:
  • 优点:本地开发方便,不需要过多的配置;

  • 缺点:需要单独开发这个功能;父应用要拉去多个子应用但是会影响首屏渲染速度;

vI7JVbV.png!mobile

结合上面三种方案,我们内部讨论后,决定用第三方方式,经过开发和实际上线运行发现其完全可行

下面介绍下怎么打包:

/**
* 需要一个 npm 包:generate-json-webpack-plugin
* webpack中写配置
*/


const GenerateJsonPlugin = require('generate-json-webpack-plugin');
const menuList = require('../src/router/menuData.js'); // 路由表
// 需要满足封装请求的格式
const menuConfig = {
code: 0,
msg: '',
data: menuList
};

/**
* 省略中间代码...
*/


/**
* 在 webpack 的 build 中增加 plugins:
* 生成指定的json文件
*/

plugins: [
// 生成指定的json文件
new GenerateJsonPlugin('webserver/config/menu.json', menuConfig),
],

// 之后在通过 nginx 配置一个单独的配置去指向这个文件

这样就解决了父应用和微应用的菜单问题。

子应用间同步数据

在真正的开发场景中,总会遇到复杂且超出预期的需求。子应用间同步数据就是其中之一。理论上讲,如果子应用之间足够独立,业务逻辑足够分离。是不会存在子应用间共用数据的情况的。所有数据由父应用分发即可,这也是qiankun的理念。详见issue412。但是在实践过程中,确实的存在这类需求。但是同时笔者也建议尽量减少这类需求,因为可能会导致数据流的混乱造成难以解决的bug。下面介绍详细的解决方案

父应用使用proxy创建全局store,完整代码如下。

const GlobalStore = (function () {
if (window.__hunterMfeBase__) {
return window.__hunterMfeBase__;
}

// 初始化store
const store = Object.assign(Object.create(null), {
/**
* 赋值变量
* @param prop 属性名
* @param value 属性值
* @param options
* {
* readOnly: Boolean 只读
* }
*/

setValue(prop, value, options = {}) {
const { readOnly } = options;

// 非只读属性-直接赋值
if (!(readOnly === true)) {
this[prop] = value;
return;
}

// 只读属性-将值代理到__value__上
this[prop] = {
value,
readOnly
};
},

/**
* 初始化全局变量 (用于在父应用上初始化全局变量)
* @param global
*/

init(global = {}) {
Object.keys(global)
.forEach(item => (this[item] = global[item]));
}
});

// 初始化拦截器
const handler = {
// 拦截取值操作
get(target, prop) {
// const { activeApp = 'base' } = store;
// console.log(`[${activeApp}] get`, { activeApp, target, prop });

const primitive = target[prop]; // 获取原始值

// 只读属性-返回代理的 __value__
if ({}.toString.call(primitive) === '[object Object]' && primitive.readOnly === true) {
return primitive.__value__;
}

// 非只读属性-直接返回原始值
return primitive;
},

// 拦截赋值操作
set(target, prop, value) {
// const { activeApp = 'base' } = store;
// console.log(`[${activeApp}] set`, { activeApp, target, prop, value });

const oldVal = target[prop]; // 读取原始值
// 存在旧值且为只读属性-不可进行更改值操作
if ({}.toString.call(oldVal) === '[object Object]' && oldVal.readOnly === true) {
console.error(`GlobalStore.${prop}是只读属性,无法重复赋值`);
return true;
}

// 新值存在只读属性-数据代理到__value__
if ({}.toString.call(value) === '[object Object]' && value.readOnly === true) {
delete value.readOnly;
const proxyVal = {
__value__: value.value,
readOnly: true,
};

target[prop] = proxyVal;
return true;
}

// 非只读属性-直接赋值
target[prop] = value;
return true;
}
};

window.__hunterMfeBase__ = new Proxy(store, handler);
return window.__hunterMfeBase__;
}());

export default GlobalStore;

可在父应用初始化时为全局store赋值

import GlobalStore from '@store';

GlobalStore.init({
// 常规属性
userInfo:{},
// 只读属性
permissionInfo:{
value: {},
readOnly: true
},
});

// 手动添加属性
GlobalStore.other = '';
// 手动添加只读属性
GlobalStore.setValue({
value: '',
readOnly: true
})

子应用中引用全局store

const GlobalStore = (function () {
return window.__hunterMfeBase__ || {};
}());

export default GlobalStore;

子应用读取全局store中数据

import GlobalStore from '@/store/globalStore';
console.log(GlobalStore.userInfo);

子应用中设置全局store中的数据

import GlobalStore from '@/store/globalStore';

//...res为接口请求结果
const { userInfo = {}, permissionInfo = {} } = res;
GlobalStore.userInfo = userInfo;

此时所有子应用中获取的 userInfo 都是更新过的。

至此本期微前端的所有内容已经分享完了,大家有什么问题可以再文下留言哦。我们会酌情考虑继续分享的。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK