前几天咱们稍微尝试了一下Webpack
提供的新能力Module Federation
,它为咱们代码共享跟团队协做提供了新的可能性。以前如果咱们项目A跟项目B有一些共同的逻辑,那咱们可能会选择把它抽成一个npm包,而后在两个项目间引入。可是这有个缺点是只要npm包更新,咱们的项目就须要从新打包来引入公共逻辑的更新,哪怕项目里一行代码没改。javascript
而经过ModuleFederation
,咱们指定exposes
跟shared
,就能够配置要导出的模块跟它依赖的一些库,就能够成功地把这个模块分享出去。经过配置remotes
,就能够指定一些依赖的远程模块。咱们的应用会在运行时去请求依赖的远程模块,不须要从新打包(前提是远程模块没有breaking change
)。这个时候项目A就能够在它的项目里实现这部分逻辑而后把这部分逻辑分享出去,项目B再引入,两个项目各自独立部署运行同时又在公共逻辑这边保持相同的行为。html
这带来的好处毫不只是减小体力劳动这么简单,今天咱们就来进一步探讨一下其它方向的可能性。前端
先建立多个项目:java
咱们先实现一些组件,先在咱们的header
项目里实现Header
组件:node
const Header = ({count,reset}) => {
return (
<header>
<h1>计数器Header</h1>
<span>{`当前数量是:${count}`}</span>
<button onClick={reset}>重置</button>
</header>
)
}
复制代码
它接受一个属性count
来展现当前数量以及提供了一个按钮来重置数字。react
而后把这个Header
导出:webpack
const commonConfig = merge([
parts.basis({mode}),
parts.loadJavaScript(),
parts.page({title: 'Header'}),
parts.federateModule({
name: 'header',
filename: 'headerComp.js',
remotes: {
header: 'header@http://127.0.0.1:8001/headerComp.js',
},
shared: sharedDependencies,
exposes: {'./Header': './src/Header'},
}),
])
复制代码
我用函数封装的方式,将Webpack
各个单一功能的配置对象管理起来(基础配置、页面配置、js配置、ModuleFederation配置等等),最后把各个不一样功能的函数返回的配置对象merge
成Webpack
熟悉的形式,感兴趣的能够看看以前这篇文章,如今咱们直接拿来复用。ios
content
项目里的的Content
组件内容大致相似:git
const Content = ({count,add}) => {
return (
<main>
<span>计数器Content</span>
<div>
<span>{count}</span>
<button onClick={add}>加</button>
</div>
</main>
);
}
复制代码
它接受一个属性count
来展现数字以及提供了一个按钮来增长数字。github
footer
项目里的Footer
组件展现固定的UI:
const Footer = () => {
return <span>计数器Footer</span>
}
复制代码
别忘了也要在Webpack
配置中分别把这两个组件导出,咱们app项目才能正常使用它们,具体操做跟Header
相似,这边就再也不赘述。
而后咱们就能够在app
里引入并使用他们啦!
const commonConfig = merge([
parts.basis({mode}),
parts.loadJavaScript(),
parts.page({title: 'App'}),
parts.federateModule({
name: 'app',
remotes: {
header: 'header@http://127.0.0.1:8001/headerComp.js',
content: 'content@http://127.0.0.1:8002/contentComp.js',
footer: 'footer@http://127.0.0.1:8003/footerComp.js',
},
shared: sharedDependencies,
}),
])
复制代码
加载组件并渲染:
const Header = lazy(() => import('header/Header'))
const Content = lazy(() => import('content/Content'))
const Footer = lazy(() => import('footer/Footer'))
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
<Suspense
fallback={<FallbackContent text={'正在加载Header'}/>}
>
<Header count={count} reset={() => setCount(0)}/>
</Suspense>
<Suspense
fallback={<FallbackContent text={'正在加载Content'}/>}
>
<Content count={count} add={() => setCount(count + 1)}/>
</Suspense>
<Suspense
fallback={<FallbackContent text={'正在加载Footer'}/>}
>
<Footer/>
</Suspense>
</div>
)
}
复制代码
如今咱们把各个项目都跑起来,
来看看效果:
能够看到这些远程导入的组件,只用起来跟本地项目里的组件并无什么区别,咱们能够正常地传递数据给它们。
这边细心的同窗可能已经注意到了,没错,header
,content
,footer
这几个项目都是能够独立运行的,它们只是跟app
共享了部分逻辑,不是要彻底做为app
的一部分。在这共享的逻辑以外,它们能够有所做为,自成一体。这种扩展性可让多个团队快速迭代,独立测试,听起来是否是有点像亚马逊的那种micro site
的开发方式?
好多同窗可能会疑惑了,这种很是规的开发方式,还涉及到“能够各自独立部署运行”,跟以前咱们开发单页应用时有点不同,那咱们以前的那些状态管理方案还管用吗?
我和大家同样疑惑😉,实践出真知,咱们来尝试引入recoil
来作状态管理看看。
添加recoil
的依赖,而后在app
下新建一个atoms.js
:
export const counter = atom({
key: 'counter',
default: 0,
})
复制代码
而后把RecoilRoot
做为咱们App组件
的根目录,以后把atoms.js
导出:
parts.federateModule({
name: 'app',
filename: 'state.js',
...
exposes: {
'./state': './src/atoms',
},
}),
复制代码
在header
跟content
项目里引入这个模块,这样作是没问题的,这几个项目既然没有固有的主次关系,均可以独立运行的,我能分享给你天然你也能分享给我,任何能以js模块导出的东西均可以经过ModuleFederation
分享,这是仅能分享UI代码的微前端框架作不到的。(可是它们能够经过支持ModuleFederation
来解决,手动滑稽下😆)
...
remotes: {
...
state: "app@http://127.0.0.1:8000/state.js" },
}
...
复制代码
而后调整一下咱们的组件,经过hook
来使用这个atom
:
const Content = () => {
const [count, setCount] = useRecoilState(counter)
return (
<main>
<span>计数器Content</span>
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>加</button>
</div>
</main>
);
}
复制代码
const Header = () => {
const [count, setCount] = useRecoilState(counter)
return (
<header>
<h1>计数器Header</h1>
<span>{`当前数量是:${count}`}</span>
<button onClick={() => setCount(0)}>重置</button>
</header>
)
}
复制代码
useRecoilState
几乎能够跟useState
无缝切换,并且能够避免没必要要的重复渲染,这点很棒~
接着从新把这几个项目跑起来,打开http://127.0.0.1:8000/
,咱们能够看到它表现得跟以前用属性注入的方式实现的效果如出一辙,状态管理在这种开发模式下仍是能够正常发挥做用的。这方面又跟咱们的单页应用很像了。这边不是限制只有recoil
才能够,经我实测redux
,mobx
均可以正常使用。
你们确定都很好奇,Webpack
到底是怎样作到这一切的?咱们既能够把每一个部分都当成一个独立的应用来开发,相似于micro site
,又能够把它们组合成一个完整的应用,相似于spa
。这也太黑科技了吧!!
咱们来仔细看看Webpack
为咱们作了什么,直接打开咱们的footer
项目,运行yarn start
,能够看到以下输出:
[0] footer
[0] | ⬡ webpack: assets by chunk 972 KiB (id hint: vendors)
[0] | asset vendors-node_modules_react-dom_index_js.js 909 KiB [emitted] (id hint: vendors)
[0] | asset vendors-node_modules_react_index_js.js 62.8 KiB [emitted] (id hint: vendors)
[0] | asset main.js 94.2 KiB [emitted] (name: main)
[0] | asset footerComp.js 61.1 KiB [emitted] (name: footer)
[0] | asset node_modules_object-assign_index_js-node_modules_prop-types_checkPropTypes_js.js 8.19 KiB [emitted]
[0] | asset src_index_js.js 2.14 KiB [emitted]
[0] | asset src_Footer_js.js 1.65 KiB [emitted]
[0] | asset index.html 229 bytes [emitted]
复制代码
咱们能够在dist
目录找到这些文件。
main.js
这里是这个应用的入口代码index.html
这个生成的HTMl文件引入了上面main.js
src_Footer_js.js
这是咱们Footer
组件编译后产生的js文件footerComp.js
默认给的名字是remoteEntry.js
,咱们这边为了突出导出的是个Footer
组件改为了footerComp.js
,这是一个特殊的清单js文件,同时也包含咱们经过ModuleFederationPlugin
的exposes
配置项导出去的模块以及运行时环境,venders-node_modules_*.js
这些都是一些共享的依赖,也就是咱们经过ModuleFederationPlugin
的shared
选项配置的依赖包为了搞清楚整个加载流程,咱们打开app
的main.js
,由于它做为宿主加载了不少远程模块,其中有段代码被注释为remotes的加载过程
,咱们一块儿来看看:
/* webpack/runtime/remotes loading */
/******/
(() => {
var chunkMapping = {
/******/ "webpack_container_remote_header_Header": [
/******/ "webpack/container/remote/header/Header"
/******/],
/******/ "webpack_container_remote_content_Content": [
/******/ "webpack/container/remote/content/Content"
/******/],
/******/ "webpack_container_remote_footer_Footer": [
/******/ "webpack/container/remote/footer/Footer"
/******/]
/******/
};
/******/
var idToExternalAndNameMapping = {
/******/ "webpack/container/remote/header/Header": [
/******/ "default",
/******/ "./Header",
/******/ "webpack/container/reference/header"
/******/],
/******/ "webpack/container/remote/content/Content": [
/******/ "default",
/******/ "./Content",
/******/ "webpack/container/reference/content"
/******/],
/******/ "webpack/container/remote/footer/Footer": [
/******/ "default",
/******/ "./Footer",
/******/ "webpack/container/reference/footer"
/******/]
/******/
};
/******/
__webpack_require__.f.remotes = (chunkId, promises) => {
/******/
if (__webpack_require__.o(chunkMapping, chunkId)) {
/******/
chunkMapping[chunkId].forEach((id) => {
/******/
var getScope = __webpack_require__.R;
/******/
if (!getScope) getScope = [];
/******/
var data = idToExternalAndNameMapping[id];
/******/
if (getScope.indexOf(data) >= 0) return;
/******/
getScope.push(data);
/******/
if (data.p) return promises.push(data.p);
/******/
var onError = (error) => {
/******/
if (!error) error = new Error("Container missing");
/******/
if (typeof error.message === "string")
/******/ error.message += '\nwhile loading "' + data[1] + '" from ' + data[2];
/******/
__webpack_modules__[id] = () => {
/******/
throw error;
/******/
}
/******/
data.p = 0;
/******/
};
/******/
var handleFunction = (fn, arg1, arg2, d, next, first) => {
/******/
try {
/******/
var promise = fn(arg1, arg2);
/******/
if (promise && promise.then) {
/******/
var p = promise.then((result) => (next(result, d)), onError);
/******/
if (first) promises.push(data.p = p); else return p;
/******/
} else {
/******/
return next(promise, d, first);
/******/
}
/******/
} catch (error) {
/******/
onError(error);
/******/
}
/******/
}
/******/
var onExternal = (external, _, first) => (external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError());
/******/
var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
/******/
var onFactory = (factory) => {
/******/
data.p = 1;
/******/
__webpack_modules__[id] = (module) => {
/******/
module.exports = factory();
/******/
}
/******/
};
console.log(data[2], data[0], data[1])
/******/
handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
/******/
});
/******/
}
/******/
}
/******/
})();
复制代码
这段代码不是写给人看的,读起来真难受,不过咱们只要照着这些变量看一下最后执行的那个handleFunction
函数就行了,好歹寻到了一些蛛丝马迹。
第一次执行handleFunction
传入了data[2]
,那对于footer
来讲,就是传入了webpack/container/reference/footer
,那咱们去搜索一下这个字符串。
以webpack/container/reference/footer
为key就这段代码了:
/***/ "webpack/container/reference/footer":
/*!*************************************************************!*\ !*** external "footer@http://127.0.0.1:8003/footerComp.js" ***! \*************************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
"use strict";
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
if (typeof footer !== "undefined") return resolve();
__webpack_require__.l("http://127.0.0.1:8003/footerComp.js", (event) => {
if (typeof footer !== "undefined") return resolve();
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
__webpack_error__.message = 'Loading script failed.\n(' + errorType + ': ' + realSrc + ')';
__webpack_error__.name = 'ScriptExternalLoadError';
__webpack_error__.type = errorType;
__webpack_error__.request = realSrc;
reject(__webpack_error__);
}, "footer");
}).then(() => (footer));
/***/
})
复制代码
这边去请求了footerComp.js
了。咱们来看一下__webpack_require__.l
的定义:
(() => {
/******/
var inProgress = {};
/******/
var dataWebpackPrefix = "app:";
/******/ // loadScript function to load a script via script tag
/******/
__webpack_require__.l = (url, done, key, chunkId) => {
/******/
if (inProgress[url]) {
inProgress[url].push(done);
return;
}
/******/
var script, needAttach;
/******/
if (key !== undefined) {
/******/
var scripts = document.getElementsByTagName("script");
/******/
for (var i = 0; i < scripts.length; i++) {
/******/
var s = scripts[i];
/******/
if (s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) {
script = s;
break;
}
/******/
}
/******/
}
/******/
if (!script) {
/******/
needAttach = true;
/******/
script = document.createElement('script');
/******/
/******/
script.charset = 'utf-8';
/******/
script.timeout = 120;
/******/
if (__webpack_require__.nc) {
/******/
script.setAttribute("nonce", __webpack_require__.nc);
/******/
}
/******/
script.setAttribute("data-webpack", dataWebpackPrefix + key);
/******/
script.src = url;
/******/
}
/******/
inProgress[url] = [done];
/******/
var onScriptComplete = (prev, event) => {
/******/ // avoid mem leaks in IE.
/******/
script.onerror = script.onload = null;
/******/
clearTimeout(timeout);
/******/
var doneFns = inProgress[url];
/******/
delete inProgress[url];
/******/
script.parentNode && script.parentNode.removeChild(script);
/******/
doneFns && doneFns.forEach((fn) => (fn(event)));
/******/
if (prev) return prev(event);
/******/
}
/******/;
/******/
var timeout = setTimeout(onScriptComplete.bind(null, undefined, {type: 'timeout', target: script}), 120000);
/******/
script.onerror = onScriptComplete.bind(null, script.onerror);
/******/
script.onload = onScriptComplete.bind(null, script.onload);
/******/
needAttach && document.head.appendChild(script);
/******/
};
/******/
})();
复制代码
它会建立一个script
标签而后监听加载状态,那咱们再去看footerComp.js
。
在footerComp.js
最开始定义了一个全局变量footer
,而后它去请求一些被导出来的文件,即咱们的Footer
组件:
var footer;
...
var __webpack_modules__ = ({
/***/ "webpack/container/entry/footer":
/*!***********************!*\ !*** container entry ***! \***********************/
/***/ ((__unused_webpack_module, exports, __webpack_require__) => {
eval("var moduleMap = {\n\t\"./Footer\": () => {\n\t\t" +
"return Promise.all([__webpack_require__.e(\"webpack_sharing_consume_default_react_react-_1a68\"), " +
"__webpack_require__.e(\"src_Footer_js\")]).then(() => " +
"(() => ((__webpack_require__(/*! ./src/Footer */ \"./src/Footer.js\")))));\n\t}\n};\n" +
"var get = (module, getScope) => {\n\t" +
"__webpack_require__.R = getScope;\n\t" +
"getScope = (\n\t\t" +
"__webpack_require__.o(moduleMap, module)\n\t\t\t" +
"? moduleMap[module]()\n\t\t\t: Promise.resolve().then(() => {\n\t\t\t\t" +
"throw new Error('Module \"' + module + '\" does not exist in container.');\n\t\t\t})\n\t);\n\t" +
"__webpack_require__.R = undefined;\n\treturn getScope;\n};\n" +
"var init = (shareScope, initScope) => {\n\tif (!__webpack_require__.S) return;\n\t" +
"var oldScope = __webpack_require__.S[\"default\"];\n\t" +
"var name = \"default\"\n\tif(oldScope && oldScope !== shareScope) " +
"throw new Error(\"Container initialization failed as it has already been initialized with a different share scope\");\n\t" +
"__webpack_require__.S[name] = shareScope;\n\t" +
"return __webpack_require__.I(name, initScope);\n};\n\n// This exports getters to disallow modifications\n" +
"__webpack_require__.d(exports, {\n\tget: () => (get),\n\tinit: () => (init)\n});\n\n//# sourceURL=webpack://footer/container_entry?");
/***/
})
/******/
});
...
复制代码
而后在footerComp.js
的最后:
...
var __webpack_exports__ = __webpack_require__("webpack/container/entry/footer");
/******/
footer = __webpack_exports__;
复制代码
当回到app
的main.js
的时候,又会执行这两个方法:
var onExternal = (external, _, first) => (external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError());
/******/
var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
复制代码
这下大体的逻辑就有了,当remoteEntry.js
被浏览器加载后,它会用咱们在ModuleFederationPlugin
里面指定的name
注册一个全局变量。这个变量有一个get
方法来返回remote模块
以及一个init
函数,这个函数用来管理全部共享的依赖的。
就拿咱们上面的footer
项目来讲,当它的footerComp.js
文件(注意没有设置filename
时叫remoteEntry.js
),被浏览器加载后,会建立一个名为footer
(咱们经过name
选项指定的)的全局变量,咱们能够用控制台来看看它的组成: window.footer
经过这个get
函数,咱们能够拿到暴露出来的Footer
组件:
window.footer.get('./Footer')
复制代码
这会返回一个promise
,当resolve的时候会给咱们一个factory
,咱们来尝试调用它:
window.footer.get('./Footer').then(factory=>console.log(factory()))
复制代码
咱们把这个模块打印到控制台上了。
这边咱们的Footer
是默认导出,因此咱们看到这个返回的Module
对象有个key
名为default
,若是这个模块包含其余的命名导出,也会被添加到这个对象中。
须要注意的是,咱们调用这个factory
会去加载这个远程模块须要的共享依赖,Webpack
在这方面作得还比较智能,像咱们header
,content
模块都依赖了recoil
,那这两个远程模块谁先被加载谁就去加载recoil
,若是这个recoil
版本知足剩下的那个的要求,剩下的那个远程模块就会直接使用这个已经加载好的recoil
。并且循环引入跟嵌套的remotes
都是支持的,好比咱们这里,app
暴露了state
,header
引入了state
,header
暴露了Header
,app
引入了Header
,Webpack
会正确处理这一流程。
那咱们这个时候就恍然大悟了,原来,这边就跟react hook
同样,经过全局变量来实现它的功能。一个能够随处访问的全局变量,咱们只须要保证它先被加载进来就行了。
既然知道Webpack
是怎么实现远程模块的加载的了,逻辑都很常规,那其实咱们就能够手动模拟这一过程,没必要把咱们须要的远程模块都写在Webpack
配置里。
首先是请求远程模块,把它添加在全局做用域内,咱们先写一个hook
来处理从url
加载模块,这边须要的是咱们清单文件也就是remoteEntry.js
的地址:
const useScript = (args) => {
const [ready, setReady] = useState(false)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!args.url) {
return
}
const element = document.createElement('script')
element.src = args.url
element.type = 'text/javascript'
element.async = true setReady(false)
setFailed(false)
element.onload = () => {
console.log(`远程依赖已加载: ${args.url}`)
setReady(true)
}
element.onerror = () => {
console.error(`远程依赖加载失败: ${args.url}`)
setReady(false)
setFailed(true)
}
document.head.appendChild(element)
return () => {
console.log(`移除远程依赖: ${args.url}`)
document.head.removeChild(element)
}
}, [args.url])
return {
ready,
failed,
}
}
复制代码
这个是咱们这个方案的灵魂,咱们动态地添加一个script
标签,而后监听加载的过程,经过useState的变量把导入远程依赖的状态动态地传递出去。
而后光把这样还不行,毕竟咱们才引入了清单js文件,咱们须要把背后真正的模块设置到到全局:
const loadComponent = (scope, module) => {
return () =>
window[scope].get(module).then((factory) => {
return factory()
})
}
复制代码
最后咱们须要在这些前置工做都完成的时候,把指定的内容加载出来:
const LoaderContainer = ({ url, scope, module }) => {
const { ready, failed } = useScript({
url: url,
})
if (!url) {
return <h2>没有指定远程依赖</h2>
}
if (!ready) {
return <h2>正在加载远程依赖: {url}</h2>
}
if (failed) {
return <h2>加载远程依赖失败: {url}</h2>
}
const Component = lazy(loadComponent(scope, module))
return (
<Suspense fallback={<FallbackContent text={'加载远程依赖'} />}>
<Component />
</Suspense>
)
}
复制代码
这边由于咱们知道远程那边导出的是一个React组件,因此直接实现了加载组件的逻辑,实际上还有不少其余类型的模块也能够分享,严谨一些这边要分状况处理。
而后精彩的地方来了:
<LoaderContainer
module={'./Footer'}
scope={'footer'}
url={'http://127.0.0.1:8003/footerComp.js'}
/>
复制代码
注意,因为咱们LoaderContainer
里面作了一些错误处理,在远程依赖被加载成功前会return别的UI元素,咱们想要导入的远程模块的组件就不能使用hook
了,不然会由于违反hook
的规则报错。
如今咱们从新运行一下项目,应该不会发现有什么变化。咱们这边的例子虽然简单,看起来作了不必作的事,可是这为咱们提供了新世界的大门,由于咱们不须要把咱们项目依赖的远程模块写死在Webpack
配置里了,也就是说,只要咱们脑洞够大,模块配置能够以任何形式出现,咱们甚至能够对用户作到“千人千面”,在运行时动态地拼装新的页面,而不须要借助各类flag,是否是颇有意思呢?
这么一通操做下来,我以为ModuleFederation
的可玩性仍是很高的,咱们能够看到它并不仅是让咱们少维护了几个代码仓库、少打了几回包这么简单,在各个体验上也一样出色。它既能给咱们提供相似micro site
同样的开发体验,又能带来spa
提供的测试与使用体验,这是二者单独都很难作到的。将来可期,后面社区愈来愈多人拥抱它以后,必定还会开发出其它更有意思的使用方法。就目前来看,把基础依赖彻底经过运行时动态请求可能不是很好的选择,好比基础组件库,在这种场景下咱们能够同时构建npm包跟远程模块,而后优先使用远程模块,在远程模块没法使用时再转而使用应用打包时依赖的npm包做为备用方案(至于新的代码逻辑咱们能够下次打包时再更新到它的最新npm版本),这样虽然可能没用上最新的代码,不过至少能够保证项目稳定运行。另一些通用的代码,想要分享给更多人而不只仅是内部业务使用的代码,好比React
啊,axios
啊,这种框架跟工具包等等,npm包仍是最好的选择。
你们对ModuleFederation
这种新事物怎么看呢,欢迎来跟我交流~