跳到主要内容

阅读前提

微前端的概念和应用已经是被广泛传播和使用了,各种微前端框架也层出不穷,比较基础的就是 single-spa,它也算得上微前端框架的“始祖”。很多文章都是在说如何去创建一个微前端应用,但是内部的工作原理却很少提示,在读完这些教程以后,如果想要更深入的了解一番,那本篇就是一个不错的选择。

本文从 single-spa 的实现角度来分析,通过路由劫持的方式是如何实现子应用聚合到一起形成一个完整的应用。

image.png

提出问题

想要了解 single-spa 是如何运行起来的,首先我们需要弄懂下面几个问题:

  1. single-spa 中注册子应用的 app 属性是做什么的,有什么作用?
  2. 子应用中需要对外暴露生命周期,它的作用是什么?
  3. single-spa 中除了注册的 API,还暴露了 start 的 API,这个又起到什么作用?
  4. 子应用怎么独立运行?
  5. single-spa 是如何劫持路由,切换对应的子应用的?
  6. single-spa 的局限性是什么?
  7. 应用沙箱是什么?JS Entry 是什么?HTML entry 是什么?

对于初次接触微前端的同学而言,理解了以上几个问题,就能对 single-spa 这种微前端有了一个基础的了解,再去学习像是 qiankun 这样基于 single-spa 实现的微前端框架更加得心应手,同时也可以去和其他实现方式的微前端,Web Components模块联邦IFRAME 等做一个横向对比。

开始分析

1.注册微前端应用

针对上面提出的问题,按照流程,首先我们得基于 single-spa 来搭建一个微前端的应用。

前面也提到了,关于 single-spa 搭建教程,网上也有非常多的学习资源,所以这里不再去强调如何去使用,而是重点分析使用过程中的意图

这里做了一个简单的流程图,展示了如何去改造像 React、Vue 的脚手架创建出来的项目

image.png

可以看到改造其实很简单,这也是 single-spa 的一个迁移优势,不会对原有项目造成太大的入侵。整个改造只有两个部分,一个是作为基座的应用的改造,另外一个就是子应用的改造了。

首先,基座应用的改造就是添加子应用的相关配置信息,像是子应用的名称、加载方法、路由激活方法以及一些共享的属性

const microApps = [
{
name: "app1", // 名称必须唯一
app: loadApp("http://localhost:5001", "app1"), // 加载子应用的脚本
activeWhen: location => location.pathname.startsWith("/app1"), 子应用激活的路由
customProps: {}, // 共享的属性
},
{
name: "app2",
app: loadApp("http://localhost:5002", "app2"),
activeWhen: location => location.pathname.startsWith("/app2"),
customProps: {},
},
];

microApps.forEach((app) => {
// single-spa提供的方法,注册子应用
registerApplication(app);
});

上面的代码中也对每项配置的作用进行了简单的说明,创建了两个子应用,分别是 app1 和 app2,其中需要我们重点关注的一项配置就是这个 app 选项,它也是我们加载子应用的一个关键。

来看一下 loadApp 方法做了什么:

function createScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}

function loadApp(url, appName) {
return async () => {
await createScript(`${url}/js/chunk-vendors.js`);
await createScript(`${url}/js/app.js`);
return window[appName];
};
}

loadApp 方法返回了一个 Promise,并且 Promise 内部调用了 createScript 的方法。先来看 createScript 方法,同样也是返回了一个 Promise 实例,其执行器就是创建了一个 script 脚本并且添加到 DOM 中。那么对于 loadApp 而言就不难理解,这个返回的 Promise,内部加载了两个脚本,并且返回的 Promise 中最后也返回了 window 上面的属性,这个 appName 就是在上面的配置中出现的子应用的名称,这个名称必须是唯一的,为什么呢?后面会说到。

对 webpack 打包这块熟悉的同学看到这里,应该就会知道加载的脚本地址,实际就是项目打包后的项目地址,而 url 的前缀,正式在配置项中子应用的地址。看到这里就应该有种恍然大悟的感觉,噢~,原来基座应用这里的加载子应用,实际就是把子应用打包好的文件,一起加载到基座应用中,这就实现了在基座应用中,可以读取到子应用的前提条件,因为仅仅加载子应用打包好的文件还是不够的。

2.子应用的生命周期

再来看看子应用的改造,上面说到更改 webpack 的打包名称和打包类型是这样的:

module.exports = defineConfig({
configureWebpack: {
output: {
library: packageName, // 打包以后对外暴露的名称
libraryTarget: "umd", // 打包的类型
},
},
});

这里是截取的使用 vite 创建的 vue 应用的配置文件。改动的地方只有两个属性,一个 packageName,导入的是 package.json 文件中的 name 属性值,这个名称需要和上面的配置项中的 name 名称保持一致。另外一个 libraryTarget 就是把应用给打包成 umd 模块。结合这两者,可以得出,子应用打包以后,暴露了上面配置的应用名称到 window 对象上

噢~好像又懂了啊,所以上面 loadApp 返回的 Promise 中的 return window[appName] 其实作用就是拿到子应用对外暴露的内容,所以这个 appName 必须是唯一的,不然会覆盖同名的子应用,相当于子应用对外暴露的一个变量名称。

那么我们在控制台上面打印看看这个 window[app1] 看看暴露了什么内容?

image.png

对应了上面设置的 umd 模块外,其他什么也没有了。这就走到了配置子应用的第二部,需要在入口文件处暴露对应的生命周期。它的作用就是给 single-spa 去调用对应子应用状态的方法,这样才能在页面上显示对应的子应用的 DOM 元素。最基本的生命周期周期包括bootstrapmountunmount这三个方法,分别表示初始化、挂载以及卸载。

// 生命周期的帮助方法,当前是使用了"single-spa-react",vue的话则是"single-spa-vue"
// 帮助方法主要省去自己去编写初始化、挂载以及卸载的相关方法
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
renderType: "createRoot", // 表明react18 render使用的类型是最新的createRoot
domElementGetter: () => document.getElementById("subApp"),
});

// 这里的props实际上就是基座应用配置中的customProps中设置的属性
export const bootstrap = (props) => {
console.log("app1 bootstrap!");
return reactLifecycles.bootstrap(props);
};

export const mount = (props) => {
console.log("app1 mounted");
return reactLifecycles.mount(props);
};

export const unmount = (props) => {
console.log("app1 unmount");
return reactLifecycles.unmount(props);
};

这些生命周期方法除了可以是函数,也可以是函数数组,single-spa 会依次去调用数组中的每个方法。除了以上三个必备的生命周期外,还有一个 unload 表示移除应用的方法。如果需要在子应用切换的时候有一些过渡效果,则可以在生命周期中做处理。

3.启动微应用

那么以上的生命周期方法什么时候调用呢?

一般来说,在基座应用中注册好子应用的相关信息以后,子应用只会进行下载,但是并不会进行初始化,所以我们在注册好子应用以后,需要调用 single-spa 暴露出来的另一个 API:start方法。

// 这里使用了react 18的渲染根节点的方法
// start在App组件加载以后调用
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App callback={() => start()} />
</React.StrictMode>
);

为什么要这么设计呢?不能注册外子应用以后就自动启动子应用吗?这里我猜测可能就是启动一个手动初始化子应用的方法,让用户可以在恰当的时机去启动子应用,并且子应用的下载本身就需要一定的时间,如果下载完成以后就立刻启动,可能会阻塞基座应用的一些操作,所以设计成手动启动,做到更好的渲染体验。

4.独立运行

single-spa 本身就是一个状态机,用来控制子应用的各个状态,从下载到卸载,不同的状态,对应了不同的处理逻辑。下面画了一个子应用的状态图,可以作为参考:

image.png

说完了如何启动子应用,接着来说说,子应用是如何独立运行的?

single-spa 在 window 上面挂载了一个方法 singleSpaNavigate,这个方法类似于 a 标签的功能,用来跳转到对应的 url。可以利用这个方法来区分当前应用是否独立运行,因为独立运行自然不需要 single-spa 了。

if (!window.singleSpaNavigate) {
ReactDOM.createRoot(document.querySelector("#root")).render(<App />);
}

如果 window 上不存在这个方法,那么就正常渲染应用即可。那么考虑一个问题,微前端用于中后台系统时,登录界面通常被放在了基座应用中,而子应用则需要登录以后,才能访问对应的页面,此时的子应用又该如何在不启动基座应用的时候独立运行呢?

5.路由监听

那 single-spa 又是如何知道什么时候该去展示什么子应用呢?这就用到了在配置项中为子应用设置的路由属性activeWhen。这个属性是一个方法,返回值是一个 boolean,意思就是返回 true 的时候就激活了当前的子应用。

具体的过程对照上面的状态管理把 single-spa 当作一个状态机,用来处理各个子应用的状态改变,状态改变会调用对应的处理方法。当调用 registerApplication 的时候,子应用只是进行了下载,并没有初始化,在 start 以后,子应用会进行初始化,初始化这里是触发了一些自定义的事件监听,改变子应用的状态为 NOT_MOUNT。其次就是 single-spa 对popstatehashchange事件做了监听,当发现路由改变的时候,则会去找NOT_mount状态的子应用,去调用它们暴露出来的 mount 方法进行挂载,同时,会把之前显示的子应用进行卸载,也就是那些状态已经是MOUNTED的子应用,去调用 unmount 方法进行卸载。

image.png

以上就是一个简单的状态转移的示意图,值得一提的是,最后 unmount 状态并不会变成 NOT_LOADED,表示子应用永远是不会被移除的,只有调用了 single-spa 提供的 unloadApplication 方法才会对子应用进行移除。

至此对于整个 single-spa 的运行,心里已经有了一个大概的印象了。微前端的优势相信的大家已经有所了解,但也并不是全是优势。对于 single-spa 来说,它也存在一些难以解决的不足之处。

6.局限性

首先就上面提到的当子应用加载的时候,之前展示的子应用就会被卸载,也就是说,同一时间,只能存在一个子应用,原因自然就是因为浏览器的 URL 只能有一个,这就造成了子应用也只能显示一个问题。

其次就是应用隔离的问题,比如 JS 全局变量冲突,CSS 样式冲突的问题,这在 single-spa 中都没有处理,需要我们自己去做一个代码规范的限制,比如全局变量的命名,CSS 使用 BEM 的方法命名等方法来解决。

另外则是应用无法进行代码分割,对首页加载性能有一定的影响。在上面我们加载脚本的时候,可以看到,具体的地址是写死了的,无法根据实际的情况对代码块进行分割,这就没法解决了。

所以说,自己的项目是否适合 single-spa,或者需要 single-spa,就需要做一些取舍。

7.一些概念

说完了 single-spa 的局限性,对于此出现了一些概念尝试去解决这些问题,诸如沙箱、JS Entry 以及 HTML Entry 的概念,这又是什么呢?

沙箱其实就是为了解决上面的应用隔离问题,沙箱的概念,我也是在学习浏览器的时候听到的概念,浏览器的渲染进程实际就是一个沙箱,用来隔绝一些恶意的脚本或者 DOM 元素。就字面意思而言,沙箱就是一个箱子,隔绝外面的密闭空间,这就意味着所有沙箱内部的操作不会影响到外部。

对于子应用而言,如果存在修改 window 上的属性,或者添加属性,都会影响到其他的子应用,就会产生一些出人意料的结果,这就不是我们想要看到的。而沙箱是如何做到隔离应用呢?当前主流的实现沙箱的方式,一个就是通过快照的方法,对初始化之前的对象进行存储,当退出子应用的时候,根据之前的快照,再把对象进行还原。另外则是 proxy 代理的方式实现的沙箱隔离。

再说 JS Entry 和 HTML Entry,是为了解决子应用无法进行代码分割、按需加载等功能而出现的。JS Entry 就是把 JS 文件作为子应用的入口,从 JS 文件中获取暴露的生命周期。HTML Entry 则是以 HTML 文件作为入口,从 HTML 文件中分析出要加载的脚本,样式文件等等,这样就不必限制死具体的脚本文件了,可以正常使用代码分割等优化功能。

其实以上这些解决办法,熟悉的同学肯定已经知道,qiankun 这款微前端框架已经全部集成了这些解决方法,成为了微前端应用的更好的选择,但它也是基于 single-spa 做了一层封装,所以想要彻底掌握好微前端、single-spa 必然也是不可或缺的前提条件。

最后了

对于开头提出的问题,以上都做了一个简单的解释了解。如果读完后能对微前端、single-spa 有了一个全新的认识,那就不枉费了阅读的几分钟。微前端也是给前端的架构发展带来了一个全新的思路,也是值得大家去学习了解的。好了,就到这里了,溜了。

信息

作者:qiugu 链接:https://juejin.cn/post/7123861138164432926 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。