一、框架简介
目前为前端框架可谓是处于百花齐放的状态,市面上有着许许多多通过不同加载方式实现的微前端框架。当然,这其中让大家最为熟悉和广泛使用的便是阿里开源的Qiankun。但是今天,我们一起来看一个后起之秀Piral微前端框架,根据npmtrends的统计数据,在2022年的中旬,Piral已经超过Qiankun成为下载量最高的微前端框架。因此,了解它究竟是怎样帮助开发者实现微前端架构,它背后的实现思想、原理及其侧重点对于我们进一步理解微前端思想,实现自己非微前端项目是大有裨益的。
上图是Piral官网给出的典型Piral微前端项目结构。在Piral项目的整个开发部署中,我们主要需要理解以下几个部分。
1.1 Piral-CLI
Piral为开发者提供的简便命令行工具,安装后可以通过简单指令完成创建/运行/打包/上传Piral Instance以及Pilets的工作。
1.2 Piral Instance(也叫App Shell)
完全可以将其视为微前端模型里项目基座的部分。在整个Piral项目开发过程中我们需要最先将其创建并打包,便于之后用其打包产物创建相关Pilets(这样便于相关Pilets能够与基座配合,并且方便其在本地运行)。
import * as React from 'react';
import { renderInstance } from 'piral';
....
// change to your feed URL here (either using feed.piral.cloud or your own service)
const feedUrl = 'https://feed.piral.cloud/api/v1/pilet/my-tutorial-feed-mino';
renderInstance({
layout: {
ErrorInfo: props => (
<div>
<h1>Error</h1>
<SwitchErrorInfo {...props} />
</div>
),
DashboardContainer: ({ children }) => (
<div>
<h1>Hello, world!</h1>
<p>Welcome to your new micro frontend app shell, built with:</p>
<div className="tiles">
{defaultTiles}
{children}
</div>
</div>
),
DashboardTile: ({ children }) => <div>{children}</div>,
},
requestPilets() {
return fetch(feedUrl)
.then(res => res.json())
.then(res => res.items);
},
})
以上是一个基础的Piral Instance入口文件示例。可以看到在Piral Instance中,我们最主要的任务包括构建项目整体布局以及请求项目注册信息(后边Feed Service会提到)。当然,这里还可以做很多其它事情,例如传入全局共享变量、方法等等。
1.3 Pilets
对应微前端项目里子应用的部分。开发者通过Piral-CLI指令将Piral Instance打包后的文件地址作为参数传入后可以一键创建出其基础骨架。
import * as React from 'react';
import { PiletApi } from 'xxxPiralInstance';
import { Link } from 'react-router-dom';
import './index.css'
export function setup(app: PiletApi) {
app.showNotification('Hello from Piral!', {
autoClose: 2000,
});
app.registerMenu(() =>
<>
<Link to="/test">Test</Link>
<a href="https://docs.piral.io" target="_blank">Documentation</a>
</>
);
app.registerTile(() => <div>Welcome to Piral111ss!</div>, {
initialColumns: 2,
initialRows: 1,
});
app.registerPage('/test', () => (
<div>
<h1>My Example Page</h1>
<p>This is a test page</p>
</div>
));
}
以上是一个基础的Pilet入口文件示例。在Pilet中,我们的主要任务就是在传统的网站基础之上导出一个setup函数。该函数将接收之前打包好的Piral Instance中由createInstance生成的Piral实例之上的一个变量,这里将其命名为app。通过app变量上所携带的方法,我们可以轻松的完成注册子应用页面(registerPage)、改变子应用菜单(registerMenu)、改变子应用Title(registerTile)等等工作,甚至可以通过app.registerExtension来注册并在所有子应用中共享组件。
可以看到app上携带了多种实用方法,其详细信息可以参考官网文档,此外如果官方提供的方法仍然不能满足你的需求,那么你完全可以通过Piral提供的Plugin模式创建并注册使用自己的插件来拓展自定义方法(目前Piral社区也提供了多种开源Plugin可供选择)。
1.4 Feed Service
可以将其类比为后端微服务架构中的注册中心,提供了对所有Pilets地址及版本统一管理的能力。个人认为这是Piral所提供的最大亮点,以往传统的微前端框架往往只专注于解决如何将传统项目更简单方便的微前端化,但是并没有提供一个对于所有微前端项目统一管理的地方,这大大削弱了微前端的威力,让微前端无法像微服务一样能够真正的成为一个跨团队共享的资源。但是Feed Service解决了这一痛点,开发者无需了解后端知识并自己搭建一个注册中心,取而代之可以直接将所有子应用发布并注册在Piral已经搭建好的云上Feed Service之中,并且在任何希望使用的地方通过一个简单的URL获取到所需子应用。此外,如果你对出于安全或公司规定不能使用公共Feed Service,Piral也提供了详细简便的搭建私有Feed Service的教程和方式。
配合Piral-CLI使用,我们可以通过一条简单的指令将Pilets发布注册至Feed Service之中。之后将Feed Service中生成的URL放入Piral Instance之中就可以部署自己的微前端项目了。现在我们返回Piral Instance中的requestPilets方法,稍微对其进行修改,启动项目并查看一下这个Http请求究竟从注册中心拿到了什么东西。
......
requestPilets() {
return fetch(feedUrl)
.then(res => res.json())
.then(res => {
console.log(res)
return res.items
});
}
......
打开console查看相应输出如下
可以看到,这个请求获取到的信息主要是一个包含所有已注册Pilets的列表,列表中的每个Pilet都包含了其入口地址Link,子应用版本Version,所用Piral框架版本spec等信息以便于Piral Instance基座通过相关信息加载所有对应的子应用并在未来需要展示时将其渲染在页面上。
1.5 总结
可以看到,相比于其他微前端框架而言,Piral有着以下优势:
- 上手简单,无任何复杂配置。Piral在一种微前端框架中可以说是入门门槛最低的了,不需要了解太多微前端概念和实现原理就可以直接跟随教程快速搭建属于自己的微前端项目,享受微前端架构带来的好处。
- 自带注册中心的部分,开发者无需自行管理微前端地址、版本等繁琐的细节信息。
同样,它也存在着自己的缺陷:
-
定制化较难。Piral默认开发者使用React进行开发,使用其它框架的开发者需要额外的转换工作。另外Piral打包默认使用webpack或parcel并且几乎不暴露任何底层配置信息,如果想要使用其他打包工具则需要开发者自己研究(没有相关文档,较为困难)。
-
目前其相关文章较少,切基本只有英文社区信息,很多使用都要靠自己踩坑。
二、子应用加载
我们先来看一下Piral是如何做到在切换URL时加载不同子应用的。
首先,我们从第一节中提到的Piral Instance可以看到,整个Piral微前端的入口中存在一个requestPilets方法。在该方法中我们向Feed Service中所提供的接口发出请求并拿到所有Pilets的注册信息。在所拿到的Pilets信息中可以看到(详细请求结果见第一节中的图),每个Pilet都存在一个Link属性指向其已经打包好的子应用入口文件地址。接下来,Piral会将这些入口文件依次请求并加载运行。让我们打开一个Link来看看这里面都有些什么。
//@pilet v:2(wp4Chunkpr_mypilet2,{})
System.register(["react","react-router-dom"],(function(e){var t,n;return{setters:[function(e){t=e},function(e){n=e}],execute:function(){e(function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="./",n(n.s=2)}([function(e,n){e.exports=t},function(e,t){e.exports=n},function(e,t,n){n(3),n(4),e.exports=n(5)},function(e,t,n){const __bundleUrl__=function(){try{throw new Error}catch(e){const t=(""+e.stack).match(/(https?|file|ftp|chrome-extension|moz-extension):\/\/[^)\n]+/g);if(t)return t[0].replace(/^((?:https?|file|ftp|chrome-extension|moz-extension):\/\/.+)\/[^\/]+$/,"$1")+"/"}return"/"}();n.p=__bundleUrl__},function(e,t,n){var r=document,o=n.p+"main.css",i=r.createElement("link");i.setAttribute("data-origin","my-pilet2"),i.type="text/css",i.rel="stylesheet",i.href=o,r.head.appendChild(i)},function(e,t,n){"use strict";n.r(t),n.d(t,"setup",(function(){return i}));var r=n(0),o=n(1);function i(e){e.showNotification("Hello from Piral!",{autoClose:2e3}),e.registerMenu((function(){return r.createElement(r.Fragment,null,r.createElement(o.Link,{to:"/test"},"Test"),r.createElement("a",{href:"https://docs.piral.io",target:"_blank"},"Documentation"))})),e.registerTile((function(){return r.createElement("div",null,"Welcome to Piral111ss!")}),{initialColumns:2,initialRows:1}),e.registerPage("/test",(function(){return r.createElement("div",null,r.createElement("h1",null,"My Example Page"),r.createElement("p",null,'Below we use some extension called "test-extension".'),r.createElement(e.Extension,{name:"test-extension",params:{num:5},empty:function(){return r.createElement("div",null,"null")}}))}))}}]))}}}));
//# sourceMappingURL=index.js.map
该文件是经过打包压缩后的Pilet入口文件所以比较难以阅读(文件地址assets.piral.cloud/pilets/my-t…
-
文件最开头使用System.register函数来加载模块,该函数是由SystemJS动态模块加载器所实现。简单来说就是因为ESM虽然已经成为模块规范,但是:a .很多旧版本的依赖包并没有更对于ESM的支持;b.很多旧版本浏览器也没有提供对于ESM在浏览器端Import Map特性的支持。所以为了兼容不同的模块加载规范,SystemJS这个通用模块加载器被创造并引入,在浏览器端实现对 CommonJS、AMD、UMD 等各种模块的加载。
-
Pilet入口文件中的registerpage函数对子应用相关页面进行了注册,因此Piral可以遍历探测到所有Piltes子应用中所注册的路径及组件,之后Piral借用了react-router的能力,加载生成router path config然后传入router中,从而将一个含有多个子应用的微前端项目组装成为一个SPA。(这里值得注意的是Piral支持我们使用自定义的方式替换默认的Router及Switch组件,此外Piral自带使用的Router版本也支持在v5与v6版本之间切换)
以下是Piral生成的React入口组件源码,可以看到在该组件中通过PiralContext(Piral生成的Context)将Piral Instance中生成的实例传入并全局共享,通过RegisteredRouter(默认为React-Router V6的Router组件)来控制具体子应用的显示及卸载。
import * as React from 'react'; import { createInstance } from './createInstance'; import { PiralView, RegisteredRouter } from './components'; import { PiralContext } from './PiralContext'; import { publicPath } from '../app.codegen'; import type { PiralProps } from './types'; /** * Represents the Piral app shell frame. Use this component together * with an existing instance to render the app shell. * Includes layout and routing handling. Connects the Piral context * and the React router to the generated views. * * @example ```jsx const app = ( <Piral instance={yourPiralInstance}> <Define name="Layout" component={MyLayout} /> </Piral> ); ``` */ export const Piral: React.FC<PiralProps> = ({ instance = createInstance(), breakpoints, children }) => ( <PiralContext instance={instance}> <RegisteredRouter publicPath={publicPath}> <PiralView breakpoints={breakpoints}>{children}</PiralView> </RegisteredRouter> </PiralContext> ); Piral.displayName = 'Piral'; -
对于子应用CSS文件的加载就是将其转换成为一个Link标签进行加载,为了在之后子应用切换时对CSS进行卸载,在插入Link时给其使用setAttribute方法注册了一个名为data-origin,值为子应用名称的属性(子应用名称由用户自定义并且唯一)。
卸载时Piral会调用一个名为runCleanup的如下方法(在Piral cleanup.ts源码里)。
...... /** * Cleans up the pilet by destroying the referenced stylesheets and * running the cleanup steps incl. deletion of referenced global * resources. * @param app The pilet to be cleaned up. * @param api The api for the pilet to be used. * @param hooks The hooks to use in the cleanup process. */ export function runCleanup(app: SinglePilet, api: PiletApi, hooks: PiletLifecycleHooks) { const css = document.querySelector(`link[data-origin=${JSON.stringify(app.name)}]`); const url = app.basePath; css?.remove(); ......
通过对Piral子应用加载的分析可以看到,Piral尽可能的复用了React已经提供的能力,并没有选择自己造轮子的做法。这样的好处很明显,那就是Piral可以轻松的跟随React及其社区的更新。但是与此同时,该做法也使得Piral对于Vue\Angular框架的支持变得比较复杂。
三、应用间隔离
对于微前端项目而言,其中比较重要的一个方面是我们需要让子应用单独的不互相影响地运行在各自的沙箱之中。因此应用间隔离就显得尤为重要,但是Piral在这方面并没有做出太多的努力。
3.1 JS隔离
Piral的JS隔离完全依靠JS的模块隔离机制,并没有任何额外的工作。这导致如果你a子应用中给window上绑定了一个属性,那么你不但可以在b子应用中访问到,甚至可以自行修改它。对于这一点,Piral甚至将其作为一种应用间共享变量的通信机制。这样的灵活性导致你需要非常谨慎地使用和更改全局变量。
3.2 CSS隔离
与JS隔离一样,Piral也没有对于CSS实施任何方式的隔离。为了避免不同子应用间CSS的相互影响,Piral仅在加载子应用时引入该子应用css的Link标签,并且在子应用被卸载时及时找到并删除这个标签。但是这样力度的隔离显然是不够的,因此在其文档中也给出了自己推荐的做法。
可以看到,其推荐了两种隔离CSS的方式,第一是使用Tailwind给所有CSS类添加前缀,第二是充分应用CSS作用域的规则。但是这两种方式都需要开发者自行手动保证子应用间的CSS隔离
四、应用间通信
另一个对于微前端框架来说比较重要的功能就是支持子应用与基座之间,不同的子应用之间相互通信。在这个方面,Piral可以说是做的非常不错,提供了和多种不同的方式让开发者自行灵活地选择适合的通信方式。
4.1 子应用与基座之间通信
-
共享依赖包
首先Piral为我们提供了一个在全局共享常用依赖包的方式,开发者只需要Piral Instance中修改package.json文件如下就可以将常用的包放入全局作用域之中。之后所有使用该包的Pilets都可以共享这些资源。
{ // dependecies中记得写上依赖 "dependencies": { "reactstrap": "latest", // ... }, // 添加希望共享的依赖包 "pilets": { "externals": [ "reactstrap" ], } } -
自定义Plugin插件
Piral推荐使用这样的方式来共享一些全局方法,其详细使用方式如下。这种方式不仅方便了开发者自行定义一些通用方法,也方便社区添加上传一些通用插件。
// Piral Instance中 function createCustomApi() { // return a constructor using the global context return context => { // return a constructor for each local API using the pilet's metadata return (api, meta) => ({ foo: 'bar', // this is the API to return; just a "static" foo - but you could have functions, etc. }); }; } const instance = createInstance({ plugins: [createCustomApi()], }); // Pilets中 export function setup(app: PiletApi) { const result = app.foo(); // returns 'bar' } -
全局State
Piral在其实例上提供了添加和读取全局变量的方法,让开发者能够轻松的设置和读取全局变量。
// Piral Instance中 const instance = createInstance({ ... }); instance.root.setData('foo', 'bar'); // Pilets中 export function setup(app: PiletApi) { const result = app.getData('foo'); // returns 'bar' }
4.2不同子应用之间通信
-
全局State
与子应用与基座之间的通信使用方式基本一致。
// Pilet A 之中 export function setup(app: PiletApi) { app.setData('foo', 'bar'); } // Pilet B 之中 export function setup(app: PiletApi) { const result = app.getData('foo'); // returns 'bar' } -
事件机制
为了更加方便的监听或触发变量的改变,我们自然会想到使用事件机制来对相关变量进行发布订阅。Piral为我们提供了内置的事件机制,方便开发者直接使用。
// Pilet A 之中 export function setup(app: PiletApi) { app.emit('foo', 'bar'); } // Pilet B 之中 export function setup(app: PiletApi) { app.on('foo', result => { // returns 'bar' // ... }); } -
全局变量共享
正如第三节中提到的,Piral并没有为JS隔离多做什么工作。因此所有绑定在window上的全局变量可以直接在Pilets之间共享。
// Pilet A 之中 export function setup(app: PiletApi) { window.myFunction = () => { console.log('Hello World!'); }; } // Pilet B 之中 export function setup(app: PiletApi) { // 记得判断一下(因为这种方式并不可靠,全局变量很可能被删除更改或还没有被添加) if (typeof window.myFunction === 'function') { window.myFunction(); } } -
注册Extension
这应该是Piral所提供的通信方式之中最独特,也是功能最强大的一个方式了。因为通过注册Extension的方式,你能共享的不单单是简单的变量和函数,而是一个完整的React组件!
// Pilet A 之中 export function setup(app: PiletApi) { app.registerExtension('sample-ext-name', ({ params }) => { if (typeof params.value !== 'number') { console.warn('You need to provide a param "value" with a <number>.'); return null; } return <div>All good!</div>; }); } // Pilet B 之中 export function setup(app: PiletApi) { app.registerPage('/my-page', () => ( <div> <h1>My Example Page</h1> <p>Below we use some extension called "sample-ext-name".</p> <app.Extension name="sample-ext-name" params={{ value: 5 }} /> </div> )); }
4.3 通信原理浅析
分析上述所有的方法,我们可以大多整个通信过程都是通过Piral Instacne上createInstance创建出来实例上的方法来实现的(在Pilet中会将这个实例上的部分属性传入setup函数,作为第一个参数app: PiletApi)。因此,我们需要做的就是将这个Piral实例能在基座和不同的子应用之间共享。
为了实现该实例的共享,与子应用加载的思想相同,Piral并没有选择自己写代码实现,取而代之Piral直接借用了React提供的Context/Provider机制。让我们继续回到第二节之中已经提到过的Piral生成的React入口组件源码。
// ......
export const Piral: React.FC<PiralProps> = ({ instance = createInstance(), breakpoints, children }) => (
<PiralContext instance={instance}>
<RegisteredRouter publicPath={publicPath}>
<PiralView breakpoints={breakpoints}>{children}</PiralView>
</RegisteredRouter>
</PiralContext>
);
// ......
可以看到,在整个应用的最最外层,Piral提供了PiralContext组件并且将createInstance中生成的实例作为参数传入了。这里PiralContext使用的就是React提供的React.createContext方法生成的Provider组件。
// stateContext文件(https://github.com/smapiot/piral/blob/6e7b1a13a5178d9123a80e44bc59d52a7310e41f/src/framework/piral-core/src/state/stateContext.tsx)
import * as React from 'react';
import { GlobalStateContext } from '../types';
export const StateContext = React.createContext<GlobalStateContext>(undefined);
export default StateContext;
// PiralContext文件(https://github.com/smapiot/piral/blob/6e7b1a13a5178d9123a80e44bc59d52a7310e41f/src/framework/piral-core/src/PiralContext.tsx)
// ......
export const PiralContext: React.FC<PiralContextProps> = ({ instance = createInstance(), children }) => (
<StateContext.Provider value={instance.context}>
<Mediator options={instance.options} key={instance.id} />
<RootListener />
<PiralProvider>{children}</PiralProvider>
</StateContext.Provider>
);
// ......
靠着React Context功能,Piral轻松的将实例传递给了各个子应用。之后在子应用之中使用useContext钩子便可以获取这个实例。
// 文件地址(https://github.com/smapiot/piral/blob/6e7b1a13a5178d9123a80e44bc59d52a7310e41f/src/framework/piral-core/src/components/PortalRenderer.tsx)
import * as React from 'react';
import { useGlobalState } from '../hooks';
import { defaultRender, none } from '../utils';
export interface PortalRendererProps {
id: string;
}
export const PortalRenderer: React.FC<PortalRendererProps> = ({ id }) => {
// 这里useGlobalState就是包装后的React useContext(这里传入的state使用了zustand)
const children = useGlobalState((m) => m.portals[id]) || none;
// 这里defaultRender可以理解为我们Pilets入口中的setup函数
return defaultRender(children);
};
可以看到,在整个Piral的设计过程中,其中心思想便是尽可能的使用React原生提供的能力,避免自己花费时间建造和维护轮子。
五、写在最后
整个Piral的实现原理还是比较清晰简单的。与Qiankun等兼容多种技术栈,面向公司级项目开发者的微前端框架不同,Piral更专注于React中型开发团队的微前端实现。Piral致力于让开发者能够快速上手并享受到微前端带来的好处的同时,努力降低社区成本,尽可能的复用社区已有的技术。
此外,它所实现的Feed Sevice以及跨子应用的组件共享是两个非常突出的亮点,在我们自己开发为前端项目的过程中也可以思考对其如何进行借鉴,以便我们更大程度的发挥微前端架构的优势。