前幾天咱們稍微嘗試了一下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
這種新事物怎麼看呢,歡迎來跟我交流~