带你从 0 到 1 实现前端「插件市场架构」
lite-module-federation: github.com/ericlee33/l…
demo代码仓库: github.com/ericlee33/r…
如果有帮助到的话,欢迎点个 star ⭐️ & follow github.com/ericlee33
前言
我们使用过很多携带插件市场功能的应用,例如 Chrome 插件市场,VSCode 插件市场,你有没有想过自己实现一个插件市场架构呢?
背景
业务开发中遇到的问题
笔者现在在中台部门工作,平日业务开发时,经常遇到一个场景,业务方会提需求过来,需要我们去帮助开发定制的业务插件,以此来补齐业务方需要的功能。
作为前端开发,如果不加以思考,很容易就会陷入到业务之中,实际上是业务方自己的需求,但是却需要我们来帮助开发。
在这种时候,我们需要思考一种方案,来把自己从需求黑洞中抽离出来。为什么不让业务方自己来作为插件开发者,自己开发呢?
有的同学可能会问,直接让需求方在中台项目仓库中去开发不就好了?
可不可以业务方自行开发呢?
听起来好像是一种办法,但是当项目结构很庞大,比如monorepo
项目,插件开发者就会遇到如下问题:
- 很难快速理清宿主项目的代码逻辑
- 开发前,需要先安装项目依赖、本地启动项目,尤其是在项目庞大的情况下,本地首次编译项目,可能需要20分钟甚至更多,对业务方同学来说,简直是痛苦
- 宿主项目会有代码规范,插件开发者一上来也不一定能直接满足宿主项目设立的代码规范,
MR
的时候需要宿主侧前端同学帮忙做Code Review
,也会消耗宿主方的精力 - 插件开发者的代码逻辑如果有问题,可能会导致宿主项目线上白屏
目标
整体流程
业务方自行开发过程中,实际上会存在一个很大的痛点,插件开发者不得不深入到我们的项目中,去了解内部实现细节。并且需要很长时间编译项目才能启动。
注意:插件场景和 qiankun
等微前端看起来应用场景类似。但是,对于插件来说,不同的一点是希望同宿主应用使用一套上下文,包括(宿主的 context
、宿主项目的公用方法)等等。
⭐️ 如果,有一种方式,能提供给插件开发者一个插件项目模板,并且能直接用宿主应用的线上环境去远程加载业务方同学本地启动的
Module
,让业务方更方便的开发插件,这样是不是就省去了让业务方理解我方平台代码的成本?
下面是一个父应用引入多插件的示例。
我们希望在一个独立的仓库中,去开发插件组件。我先给出一个最终效果,之后我们来分析一下如何实现这种效果
预期宿主应用最终效果
在引入插件后,宿主应用预期页面展示如下。
注意看:这里的 plugin-1
和 plugin-2
组件,是加载的远程 Module
(单独在插件项目打包存放到 CDN
上的) 进行渲染的。
插件侧代码
在插件侧,我们暴露出一个 config
出去,这里类似于 Module Federation 的 exposes
属性,可以导出多个组件。这里在打包时的区别是,我们会在 webpack
中externals
配置 react
。也就是不将 React
进行打包。
import React from 'react';
import './test.css';
const PluginOne: React.FC<{}> = () => {
return <div className="sub-app-box">plugin-1</div>;
};
const PluginTwo: React.FC<{}> = () => {
return <div className="sub-app-box">plugin-2</div>;
};
export const config = {
componentOne: PluginOne,
componentTwo: PluginTwo,
};
宿主侧使用插件
宿主侧,我们使用 lite-module-federation
包,进行远程 Module
解析
import ReactDom from 'react-dom';
import React, { useEffect, useState } from 'react';
import { memorizedFetchBundle } from 'lite-module-federation';
import './app.css';
const App: React.FC<{}> = () => {
const [config, setConfig] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
setTimeout(async () => {
const { config } = await memorizedFetchBundle(
'http://localhost:7001/cdn/remoteEntry.js'
);
setConfig(config);
setLoading(false);
}, 1000);
}, []);
if (loading) {
return <div>Loading sub-app.....</div>;
}
const PluginOne = config.componentOne;
const PluginTwo = config.componentTwo;
return (
<div className="main-box">
<div className="main-app">Main App</div>
<div className="sub-app-wrapper">
<PluginOne />
<PluginTwo />
</div>
</div>
);
};
ReactDom.render(<App />, document.getElementById('app'));
对流程进行拆解
让我们来梳理一下,如果需要实现上面这个流程,我们需要的能力。
在宿主项目中,调用远程 Module
目前有一个比较常见的方案是 Module federation
,但是我们不使用这种方式,他太重
线上环境调试本地 Module 代码
我们如果能做到通过某种方式,劫持线上 cdn 接口返回内容,替换为本地启动服务的插件打包组件产物bundle
,就可以实现线上环境调试本地调试代码的过程。
插件模板生成
这里为了让开发者体验更好,我们可以开发一个脚手架,根据开发者的需求,去生成不同的模板
插件产物打包上传
这里思路是有以下两种:
- 实现一个插件开发者后台,进行插件统一管理、版本控制、上传操作
- 实现 VSCode 插件,通过扫码方式,增加用户登录态,让开发者在
VSCode
中,将全流程闭环
技术方案
引入打包好的远程 Module
在宿主项目中,需要远程加载打包好的 Module
。
可能大家对 Webpack 5 的 Module Federation 技术有所了解,这个场景也契合使用场景。
但是他太重了,以及存在一些问题,我会在后续写一篇文章介绍一下存在的问题。
这里我开源了 lite-module-federation
这个npm
包来解决这个问题。他是一个更轻量的 Module Federation,仅为 4kB
。更轻量,更易使用。
Github地址:github.com/ericlee33/l…
远程组件使用方法
import { memorizedFetchBundle } from 'lite-module-federation';
const { config } = await memorizedFetchBundle(
'http://localhost:7001/cdn/remoteEntry.js'
);
const PluginOne = config.componentOne;
ReactDom.render(<PluginOne />, document.getElementById('app'));
线上环境调试本地调试代码
为了达到这个目的,我们需要实现以下两点:
- 搭建代理服务器
- 劫持浏览器的请求,让浏览器的请求全部打到代理服务器上
搭建代理服务器
代理服务器我们使用Anyproxy
包
使用文档
浏览器代理插件
我们使用SwitchOmega
,它可以对浏览器进行proxy
代理
插件地址: chrome.google.com/webstore/se…
插件模板脚手架
我们可以使用commander
/inquirer
/chalk
等npm
包,设计开发脚手架工具
插件产物打包上传
插件开发者后台
我们需要在页面中提供以下功能:
- 新增插件
- 删除插件
- 插件产物上传
- 插件发布
- 插件版本回退
VSCode插件
VSCode
插件开发实现的功能类似于后台,但是可以免去在后台登录,以及本地打包上传的过程,直接在插件中进行集成。
插件开发API
文档: code.visualstudio.com/api
插件开发者流程

具体实现
项目结构
这里我们用pnpm
先搭一个最简骨架,包含这个 demo
所需的各个子项目
.
├── README.md
├── package.json
├── packages
│ ├── anyproxy-server // 代理服务器,端口11111,劫持浏览器请求
│ ├── cdn-server // 后端服务,这里是为 demo 提供一个模拟的 cdn 地址
│ ├── platform // 宿主平台项目
│ └── plugin-components // 插件项目
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── tsconfig.json
模拟一个 CDN 服务(仅供 demo 展示使用)
为了展示 demo 效果,我们启动一个node
的后台,提供给平台一个/cdn/remoteEntry.js
接口,用于模拟获取上传到 CDN
的 remoteEntry.js
。
const http = require('http');
const Url = require('url');
const fs = require('fs');
const path = require('path');
const httpServer = http.createServer((req, res) => {
const url = Url.parse(req.url);
console.log(`Request pathname: ${url.pathname}`);
res.setHeader('Access-Control-Allow-Origin', '*');
if (url.pathname === '/cdn/remoteEntry.js') {
const bundle = fs.readFileSync(path.resolve(__dirname, './cdnFile.js'));
res.write(bundle);
} else {
res.write('not found');
}
res.end();
});
httpServer.listen(7001);
console.log('Server running at 7001\n');
提供一个代理服务器
这里我直接提供代理服务器核心代码,我们设置代理服务器运行在本地11111
端口,这里我们去代理后台提供的/cdn/remoteEntry.js
路由,替换 response
为本地启动的插件项目的 bundle
。
const AnyProxy = require('anyproxy');
const rule = require('./rule/index');
const DEFAULT_PORT = 11111;
const proxyServer = new AnyProxy.ProxyServer({
rule: {
beforeSendResponse: (requestDetail, responseDetail) => {
if (requestDetail.url.includes('/cdn/remoteEntry.js')) {
responseDetail.response.body = await execSync(
`curl -s http://localhost:9001/remoteEntry.js`
);
return responseDetail;
} else {
return responseDetail;
}
},
},
port: DEFAULT_PORT,
throttle: 10000,
forceProxyHttps: true,
wsIntercept: false, // 不开启websocket代理
silent: false,
});
proxyServer.start();
配置浏览器代理插件
安装好浏览器代理插件之后,我们启动插件,点击新增new profile
,我们随便起个名字,就叫test_local
,设置代理到本地代理服务器的端口,这里我们使用上面设置的11111
端口。进行完这一步之后,我们就能将浏览器的请求打到我们本地代理服务器的上了
注意,要在
Bypass List
中配置<-loopback>
,不然代理不到本地接口
到这里,前置工作就准备好了,下面我们开始开发平台项目,和插件页面
FAQ
- 如果代理网站时,网站不被信任,需要信任
anyproxy
的证书,不同系统证书位置不同,例如MacOS
在如下目录./.anyproxy/certificates/rootCA.crt
开发插件
写一个 demo 插件
我们首先写一个最简插件模板,这里我们直接写死,实际可以用脚手架生成一个插件项目模板出来
import React from 'react';
import './test.css';
const PluginOne: React.FC<{}> = () => {
return <div className="sub-app-box">plugin-1</div>;
};
const PluginTwo: React.FC<{}> = () => {
return <div className="sub-app-box">plugin-2</div>;
};
export const config = {
componentOne: PluginOne,
componentTwo: PluginTwo,
};
插件侧 webpack 配置
这里我抽离几行核心配置,主要要注意libraryTarget
需为commonjs
格式,以及我们需要配置externals
,避免打包react
到插件产物中。
为了demo能跑起来,我们给devServer
配置跨域头为*
,并且运行到9001
端口,这样本地插件产物js文件的地址就会是http://localhost:9001/remoteEntry.js
另外需要注意,只能打包出一个 bundle
,这里我们通过 LimitChunkCountPlugin
插件进行设置。
const webpack = require('webpack');
module.exports = {
mode: 'development',
entry: {
remoteEntry: './src/plugin.tsx',
},
output: {
filename: '[name].js',
libraryTarget: 'commonjs',
},
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
],
externals: {
react: 'react',
},
devServer: {
hot: true,
port: 9001,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
};
在平台项目中,配置远程组件
注入远程组件所需的依赖
由于远程组件我们设置了externals
属性,避免打包react
,我们在宿主平台项目中,需要提供react
依赖给插件项目,这样插件项目才可以用到react
包的依赖。
这里我们在宿主应用根目录做配置,供 lite-module-federation
包做依赖注入使用。
// lite-module-federation.config.js
module.exports = {
shared: {
react: require("react")
}
};
修改宿主应用 webpack 配置
这里我粘贴部分代码,主要核心点是我们需要在webpack.config.js
中添加一个别名,这样lite-module-federation
包,就可以加载到这个lite-module-federation.config.js
文件。
module.exports = {
resolve: {
alias: {
"lite-module-federation.config.js": path.resolve(__dirname, "lite-module-federation.config.js")
}
}
};
具体实现可以参考项目源码,我已经将源码贴在文章最下方
启动浏览器代理插件
我们选择我们刚刚配置好的test_local
配置项
启动我们monorepo
内的4个子项目
进行平台项目目录,启动平台项目
cd packages/platform
pnpm dev
进行插件项目目录,启动插件项目
cd packages/plugin-components
pnpm dev
进行代理服务器项目目录,启动代理服务
cd packages/anyproxy-server
pnpm dev
进行后端项目目录,启动后端项目
cd packages/cdn-server
pnpm dev
查看效果
访问宿主平台项目首页,即可看到效果 http://localhost:9000
可以看到,插件是以远程方式进行加载的。
具体实现可以参考源码仓库,感谢你看到这里,后续我会讲解 lite-module-federation
这个包的实现原理。
lite-module-federation: github.com/ericlee33/l…
demo代码仓库: github.com/ericlee33/r…
如果有帮助到的话,欢迎点个 Star ⭐️ & follow github.com/ericlee33
转载自:https://juejin.cn/post/7170613119755452446