module federation +SSR会是前端框架的未来么?

    文章引用:

    今天博客不谈论文,谈一个困扰前端研发的一个很久的问题:模块化共享
    **
    这个问题不是仅仅作用于前端,移动端和后端(后端反而容易解决些)都同样面临相同的问题。这篇文章仅仅分享了一个思路。随着多端同构架构的引入,模块federation化是否在其中解决了关键行的问题。当然以我的角度这也是社区的一个大的方向,大家都烦透了碎片化开发带来的成本问题。

    先看webpack module federation的特性:
    有一个主机生成和一个远程生成。主机生成希望使用远程生成中的某些公开模块。

    在主机生成代码中:

    • 你想要一些在启动时执行的启动代码
    • 您想使用import(“/”)(另外还有:import“/”)。

    在主机生成配置中:

    • 要为定义名称
    • 您想为定义一个远程URL(可选:这是在运行时用一些特殊指令设置的)
    • 您希望定义一些模块,这些模块必须由远程生成运行时而不是它们自己的模块使用(这可能是由请求字符串或全局唯一名称造成的,在大多数情况下可能是npm名称)
    • (可选)您可能需要指定这些模块中使用的导出列表(因为在生成时不知道远程生成使用哪些导出)

    在远程生成代码中:
    您需要一些在正常启动时使用的启动代码,但如果主机生成使用远程生成,则不需要。

    在远程生成配置中:

    • 您希望通过公开主机生成的某些模块,将此生成定义为可由主机生成使用。
    • 您需要定义主机构建中的哪个指向哪个模块
    • 您需要定义哪些模块可以被宿主构建重写(这是在运行时将这些模块保持为模块所必需的,并防止某些优化,例如范围提升或会移除模块的副作用)

    交付:

    • 对于目标:“web”:远程构建可以跨源加载。(例如,通过脚本标签和类似于块加载工作原理的JSONP)
    • 对于目标:“node”:远程构建应该加载require()
    • 对于目标:“异步节点”:远程构建应该加载fs+vm

    递归:

    • 主机生成可以定义多个和远程生成
    • 远程生成可能再次使用相同的功能。它可能是另一个远程生成的主机生成。这是可能的循环方式。在最小的情况下,A从B加载模块,B从A加载模块。
    • 可重写模块被继承并递归地应用,例如对于A->B->C:这里A也可以重写C中的模块。

    优化:

    • 可重写模块仍应删除其未使用的导出。
    • 远程和主机生成运行时尝试仅下载应用程序所需的模块。
    • 主机生成:

    不应下载用于重写远程生成中未被远程生成使用的模块。

    • 远程生成

    不应下载由宿主生成重写的可重写模块。

    • 当下载非常小时可能会出现异常,这会导致在某些情况下发出的请求数量减少(类似于splitChunks.minSize)

    主机生成和远程生成可以独立更新。以下信息在频带外共享:

    • 远程生成的公开模块
    • 主机构建可以在代码中将这些模块用作
    • 共享列表可能不完整或随时间变化
    • 使用非公开的将导致运行时错误或承诺拒绝。
    • 可重写模块
    • 主机构建可以在配置中使用这些模块来覆盖它们
    • 尝试重写不可重写的模块将被忽略
    • 这些模块中使用的导出列表(可选)
    • 远程URL。

    更新远程生成时,URL不得更改。
    应该提示用户正确缓存远程URL。
    再验证,etag
    备选方案:重定向到哈希URL
    可能是短时间的缓存,应该考虑部署的延迟效应。

    额外考虑
    白名单指令,使开发人员能够告诉Webpack使用依赖项的“宿主构建”版本。像React这样的情况,我不能在页面上有两个——我想提供一个选项,让一个区块使用已经安装的版本,即使它的补丁版本不同。
    Hashing bloats builds:我正在寻找一种替代Hashing的解决方案,以提供社区选项,然而,在我看来,代码共享的节省远远超过任何开销。4mb到1.2mb就说明了这一点。
    区块清单在区块内-这可能在一个文件中生成一个单一清单,但是,我喜欢将区块需求分解为区块本身的想法。这将使实际的清单文件非常小,因为它只查找到URL路径的块名称。由于清单要么是用Date.now缓存崩溃,要么是304缓存控制-我希望在开发人员不想处理缓存头的情况下保持它的精简,而只是在每次页面加载时缓存崩溃2kb文件。这将引入一个额外的加载波,因为一旦块被下载,它将指定它需要什么,触发Webpack,然后在一个调用中下载任何其他文件。下载区块->下载包含Webpack主机生成无法提供的模块的任何区块。嵌套的块不会创建更多的加载波,因为块元数据包含了它需要的全部内容的完整映射

    SSR
    更新:因为这不仅仅是一个浏览器实现——您可以通过公共js和共享目录或s3或其他存储系统上的卷来联合模块。或者,可以在CI上安装依赖项的节点版本,处理联邦代码(它是捆绑的,因此不需要嵌套的npm安装)以在部署时安装最新副本,从而呈现最新的部署,但在运行时,最新的版本是可用的。
    最终一致-如果您对节点构建使用模块联合,并且要有一个lambda可以读取的共享磁盘,那么您将能够要求以与在前端完全相同的方式部署其他代码。

    具体实现:
    模块federation允许JavaScript应用程序在客户端和服务器上动态运行来自另一个bundle/build的代码
    #这是JavaScript绑定器相当于Apollo server对GraphQL所实现的。
    在独立应用程序之间共享代码的可伸缩解决方案从来都不方便,而且几乎不可能大规模实现。我们最接近的是externals或DLLPlugin,强制集中依赖于外部文件。共享代码很麻烦,独立的应用程序并不是真正独立的,而且通常共享的依赖项数量有限。此外,在单独捆绑的应用程序之间共享实际的功能代码或组件是不可行的、缺乏效率的和没有价值的。

    我们需要一个可扩展的解决方案来共享节点模块和功能/应用程序代码。它需要在运行时发生,以便具有自适应性和动态性。外部的工作没有效率或灵活性。导入地图不能解决缩放问题。我不想单独下载代码和共享依赖项,我需要一个编排层,它在运行时动态地共享模块,并带有回退。

    模块联合是我发明并原型化的一种JavaScript架构。然后在我的共同创建者和Webpack - 的创始人的帮助下,它变成了Webpack 5核心中最令人兴奋的功能之一(其中有一些很酷的东西,新的API非常强大和干净)。

    模块联邦
    模块联邦允许JavaScript应用程序从另一个应用程序动态加载代码,并在过程中共享依赖项。如果使用联邦模块的应用程序没有联邦代码所需的依赖项,则 Webpack将从该联邦生成源下载缺少的依赖项。
    代码可以共享,但在每种情况下都存在回退。联邦代码总是可以加载其依赖项,但在下载更多负载之前会尝试使用使用者的依赖项。这意味着更少的代码复制和依赖共享,就像一个单一的网页包构建。虽然我可能已经发明了这个最初的系统,但它是由我自己(扎克杰克逊)和Marais Rossouw在Tobias Koppers的指导、配对编程和帮助下共同编入Webpack 5的。这些工程师在Webpack5核心中重写和稳定模块联盟方面发挥了关键作用。

    术语
    模块联邦:与Apollo GraphQL federation - 相同的想法,但适用于JavaScript模块。在浏览器和node.js中。通用模块联邦
    主机:在页面加载期间(当onLoad事件被触发时)首先初始化的Webpack构建
    远程:另一个Webpack构建,其中一部分被“主机”使用
    双向主机:当捆绑包或Web包生成可以作为主机或远程主机工作时。在运行时使用其他应用程序或被其他应用程序使用

    需要注意的是,该系统的设计使每个完全独立的构建/应用程序都可以在自己的存储库中,独立部署,并作为自己的独立SPA运行。
    这些应用程序都是双向主机。任何先加载的应用程序都将成为主机。 当您更改路由并在应用程序中移动时,它以实现动态导入的方式加载联邦模块。但是,如果要刷新页面,则无论哪个应用程序首先在该负载上启动,都将成为主机。

    假设网站的每个页面都是独立部署和编译的。我想要这种微型前端风格的架构,但不希望页面重新加载时,改变路线。我还想在他们之间动态地共享代码和供应商,这样它就像一个大型的网页包构建一样高效,可以进行代码拆分。
    登陆主页应用程序将使“主页”成为“主机”。如果您浏览到“关于”页面,主机(主页spa)实际上是从另一个独立应用程序(关于页面spa)动态导入模块。它不会加载主入口点和另一个完整的应用程序:只有几千字节的代码。如果我在“关于”页面并刷新浏览器。“about”页面变为“host”,再次浏览回主页将是about页面“host”从“remote” - 主页获取运行时片段的情况。所有应用程序都是系统中任何其他联邦模块的远程和主机、可消费和使用者。

    阅读有关GitHub技术方面的更多信息:https://GitHub.com/webpack/webpack/issues/10352

    构建联合应用程序#
    让我们从三个独立的应用程序开始。

    应用程序一
    配置:

    我将使用app One中的app container。它将被其他应用程序使用。为此,我将其应用程序公开为AppContainer。
    App One还将使用其他两个联合应用程序的组件。为此,我指定remotes作用域:

    1. const HtmlWebpackPlugin = require("html-webpack-plugin");
    2. const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
    3. module.exports = {
    4. // other webpack configs...
    5. plugins: [
    6. new ModuleFederationPlugin({
    7. name: "app_one_remote",
    8. remotes: {
    9. app_two: "app_two_remote",
    10. app_three: "app_three_remote"
    11. },
    12. exposes: {
    13. 'AppContainer':'./src/App'
    14. },
    15. shared: ["react", "react-dom","react-router-dom"]
    16. }),
    17. new HtmlWebpackPlugin({
    18. template: "./public/index.html",
    19. chunks: ["main"]
    20. })
    21. ]
    22. }

    设置生成业务流程:

    在应用程序的头部,我加载app_one_remote.js。这会将您连接到其他Webpack运行时,并在运行时提供业务流程层。这是一个特别设计的网页包运行时和入口点。它不是一个普通的应用程序入口点,只有几KB。
    需要注意的是,这些是特殊的入口点,它们的大小只有几KB。包含可以与主机接口的特殊Webpack运行时,它不是标准入口点

    1. <head>
    2. <script src="http://localhost:3002/app_one_remote.js"></script>
    3. <script src="http://localhost:3003/app_two_remote.js"></script>
    4. </head>
    5. <body>
    6. <div id="root"></div>
    7. </body>

    从远程使用代码

    App One有一个页面,该页面使用App 2中的对话组件。

    1. const Dialog = React.lazy(() => import("app_two_remote/Dialog"));
    2. const Page1 = () => {
    3. return (
    4. <div>
    5. <h1>Page 1</h1>
    6. <React.Suspense fallback="Loading Material UI Dialog...">
    7. <Dialog />
    8. </React.Suspense>
    9. </div>
    10. );
    11. }
    12. export default Page1;

    路由代码看起来很标准:

    1. import { Route, Switch } from "react-router-dom";
    2. import Page1 from "./pages/page1";
    3. import Page2 from "./pages/page2";
    4. import React from "react";
    5. const Routes = () => (
    6. <Switch>
    7. <Route path="/page1">
    8. <Page1 />
    9. </Route>
    10. <Route path="/page2">
    11. <Page2 />
    12. </Route>
    13. </Switch>
    14. );
    15. export default Routes;

    应用二
    配置:

    App 2将公开对话框,使App 1能够使用它。App Two还将使用App One的 - 因此我们将App One指定为远程显示双向主机:

    1. const HtmlWebpackPlugin = require("html-webpack-plugin");
    2. const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
    3. module.exports = {
    4. plugins: [
    5. new ModuleFederationPlugin({
    6. name: "app_two_remote",
    7. filename: "remoteEntry.js",
    8. exposes: {
    9. Dialog: "./src/Dialog"
    10. },
    11. remotes: {
    12. app_one: "app_one_remote",
    13. },
    14. shared: ["react", "react-dom","react-router-dom"]
    15. }),
    16. new HtmlWebpackPlugin({
    17. template: "./public/index.html",
    18. chunks: ["main"]
    19. })
    20. ]
    21. };

    消费:
    下面是应用程序如何实现的:

    1. import React from "react";
    2. import Routes from './Routes'
    3. const AppContainer = React.lazy(() => import("app_one_remote/AppContainer"));
    4. const App = () => {
    5. return (
    6. <div>
    7. <React.Suspense fallback="Loading App Container from Host">
    8. <AppContainer routes={Routes}/>
    9. </React.Suspense>
    10. </div>
    11. );
    12. }
    13. export default App;

    下面是使用Dialog的默认页面:

    1. import React from 'react'
    2. import {ThemeProvider} from "@material-ui/core";
    3. import {theme} from "./theme";
    4. import Dialog from "./Dialog";
    5. function MainPage() {
    6. return (
    7. <ThemeProvider theme={theme}>
    8. <div>
    9. <h1>Material UI App</h1>
    10. <Dialog />
    11. </div>
    12. </ThemeProvider>
    13. );
    14. }
    15. export default MainPage

    应用程序3
    正如所料,app3看起来很相似。但是,它不使用App One中的作为一个独立的、自运行的组件(没有导航或侧边栏)。因此,它没有指定任何远程主机:

    1. new ModuleFederationPlugin({
    2. name: "app_three_remote",
    3. library: { type: "var", name: "app_three_remote" },
    4. filename: "remoteEntry.js",
    5. exposes: {
    6. Button: "./src/Button"
    7. },
    8. shared: ["react", "react-dom"]
    9. }),

    代码重复
    几乎没有依赖项重复。通过共享选项 - 远程将依赖于主机依赖项,如果主机没有依赖项,远程将下载自己的。没有代码复制,但内置冗余。

    手动将Provider或其他模块添加到共享中在规模上并不理想。这可以很容易地自动化与一个自定义编写的功能,或与一个补充的网页包插件。我们确实计划发布AutomaticModuleFederationPlugin并在Webpack核心之外维护它。既然我们已经在Webpack中构建了一流的代码联合支持,扩展它的功能就变得微不足道了。

    现在来问一个大问题-这和SSR有什么关系吗??

    SSR
    我们设计的这个是通用的。模块联合可以在任何环境中工作。服务器端呈现联邦代码是完全可能的。只是让服务器构建使用commonjs库目标。实现联邦SSR有多种方法:S3流、ESI、自动发布npm以使用服务器变体。我计划使用一个常用的共享文件卷或异步S3流来跨文件系统传输文件。使服务器像在浏览器中一样需要联邦代码,并使用fs而不是http加载联邦代码。

    1. module.exports = {
    2. plugins: [
    3. new ModuleFederationPlugin({
    4. name: "container",
    5. library: { type: "commonjs-module" },
    6. filename: "container.js",
    7. remotes: {
    8. containerB: "../1-container-full/container.js"
    9. },
    10. shared: ["react"]
    11. })
    12. ]
    13. };

    模块联合也适用于目标:“节点”。这里使用指向其他微前端的文件路径,而不是指向其他微前端的URL。这样,您就可以使用相同的代码库和不同的webpack配置来为node.js构建SSR。node.js中的模块联合保持相同的属性:例如,单独的构建,单独的部署“ - Tobias Koppers
    Webpack 5上的federed Next.js
    联盟需要Webpack5 - ,而Next默认不支持Webpack5 。然而,我确实设法分叉和升级Next.js与Webpack 5一起工作!这仍在进行中。一些开发模式的中间件需要完成一些工作。生产模式正在运行,一些附加装载机仍需重新测试。

    Next.js支持webpack5 module federation的测试代码

    注意事项

    • 这实际上是前端的微服务。所以一定会有版本。”为什么你要引入一个突破性的改变。。。为此,我建议使用契约api快照jest测试
    • 如果您使用的是中继,则不能在包装潜在联合模块的查询上传播片段。因为碎片可能已经改变了。为此,我建议使用QueryRenderer组件。
    • 依赖于的模块称为react上下文,其中提供程序从未公开。那些东西。
    • 在这个阶段,在正确的初始远程块中加载是相当乏味的。它需要提前知道块文件名并手动注入这些文件名。但我们有一些想法。
    • 本地开发运行时。不过,为了找到一个干净的方法,不必一次运行所有的应用程序,但目前我个人一直在使用webpack别名,将这些应用程序引用指向mono repo中的文件夹。
    • … 就这样,在我所有的试验中,这个解决方案还没有出现任何初始问题。