【微前端入门】: webpack 5 模块联邦实践

背景

前端应用、微服务的发展,使得模块化的概念越来越重要。 这也不可避免的会产生再不同的项目会有很多功能相似,甚至完全相同。所以跨应用的代码共享尤为重要,之前我们处理这种问题往往采用,

1、功能相似的页面直接赋值方便,不用费脑力直接复用。但这样就会导致项目中代码的复用性低,代码冗余多等问题出现。

2、微服务的出现,很多业务一般使用npm 发布的形式管理公共包。我们EDSP前端项目也是用了npm插件形式;但在使用下来我们发现npm 比较适合对业务逻辑耦合小,完全工具类的包。而对于业务逻辑比较繁重,更新频繁的模块,npm 包使用就会存在迭代需要更新版本的问题,。 并且 npm 包对于业务代码的拆分有工作量,维护成本相对较大,代码有一定质量要求,否则就会导致模块过大的问题。

3、Module Federation 解决了跨应用代码共享

什么是 Module Federation

Module federation (模块联邦)使 JavaScript 应用得以从另一个 JavaScript 应用中动态地加载代码 —— 同时共享依赖。

通过细化功能模块、组件复用、共享第三方库、runtime dependencies线上加载npm包等,可以更好的服务于多页应用、微前端等开发模式。

如何使用 ModuleFederationPlugin

Module Federation 整体是通过ModuleFederationPlugin这个插件串联起来的。

提供方配置示例

 new ModuleFederationPlugin({
    
    
      // 应用名称,调用方使用
      name: 'vueAppOne',
      // 调用方引用的文件名称
      filename: 'app1.js',
      library: {
    
     type: "var", name: "vueAppOne" },
      exposes: {
    
    
        //模块名称
        './untils': './src/untils/count.js',
      }
    })

配置属性:

name,必须,唯一 ID,作为输出的模块名,使用的时通过 name/{
    
    name}/name/{
    
    expose} 的方式使用;
library,必须,其中这里的 name 为作为 umd 的 name;
remotes,可选,作为引用方最关键的配置项,用于声明需要引用的远程资源包的名称与模块名称,作为 Host 时,去消费哪些 Remote;
exposes,可选,表示作为 Remote 时,export 哪些属性被消费;
shared,可选

若是配置了这个属性。webpack在加载的时候会先判断本地应用是否存在对应的包,若是不存在,则加载远程应用的依赖包。
以app1来讲,由于它是一个远程应用,配置了[“vue”] ,而它被app1所消费,因此webpack会先查找app1是否存在这个包,若是不存在就使用app2自带包。 app2(app2 之后会以案例的形式展现)里面一样申明了这两个参数,由于app2是本地应用,因此会直接用app2的依赖 在引用远程资源的项目中使用时,需要先远程资源入口文件引入,可以异步加载,也可以使用script标签引入。

消费方配置示例:

//-----webpack配置:-----
new ModuleFederationPlugin({
    
    
      // 导入模块
      remotes: {
    
    
        // '导入别名':'远程应用名称/远程应用地址/导入文件的名称'
        'mfpVueAppOne': 'vueAppOne@http://localhost:3001/app1.js'
      }
    })
//-----vue项目中代码-----
//调用app1的模块
import('mfpVueAppOne/untils').then(res => {
    
    
  res.default(31, 2)
})

Vue项目应用案例:

1.创建两个vue项目

我们使用webpack单独部署两个Vue项目,目录结构如下:

在这里插入图片描述

Vue-app1的webpacl.config.js:

/*
 * @Descripttion:
 * @version:
 * @Author: Jason chen
 * @Date: 2021-08-18 14:09:16
 * @LastEditors: sueRimn
 * @LastEditTime: 2021-08-19 18:16:41
 */
const {
    
     resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const FriendlyErrorsPlugin = require("friendly-errors-webpack-plugin");
const address = require("address");
const port = 3001;
// 模块联邦的插件
const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin
const result = {
    
    
  mode: 'development',
  entry: './src/index.js',
  output: {
    
    
    path: resolve(__dirname, 'output'),
    filename: 'bundle.js'
  },
  module: {
    
    
    rules: [
      {
    
    
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      {
    
    
        test: /\.less$/i,
        use: [
          // compiles Less to CSS
          'style-loader',
          'css-loader',
          'less-loader',
        ],
      },
      {
    
    
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
    
    
          preserveWhitepace: true,
          extractCSS: true,
          cssModules: {
    
    },
          //hotReload: false,//根据环境变量生成
        }
      },
    ],
  },
  devServer: {
    
    
    contentBase: resolve(__dirname, 'output'),
    port,
    quiet: true, // necessary for FriendlyErrorsPlugin
  },
  plugins: [
    new ProgressBarPlugin(),
    new FriendlyErrorsPlugin({
    
    
      compilationSuccessInfo: {
    
    
        messages: [
          `  App running at:`,
          `  - Local:    http://localhost:${
      
      port}`,
          `  - Network:  http://${
      
      address.ip()}:${
      
      port}`
        ]
      },
    }),
    new HtmlWebpackPlugin({
    
    
      template: './public/index.html'
    }),
    new VueLoaderPlugin(),
    new ModuleFederationPlugin({
    
    
      // 应用名称,调用方使用
      name: 'vueAppOne',
      //调用放引用的文件名称
      filename: 'app1.js',
      exposes: {
    
    
        //模块名称
        './untils': './src/untils/count.js',
        './appOneChildren': './src/App.vue'
      },
    })
  ],


}

module.exports = result;

Vue-app2的webpacl.config.js:

/*
 * @Descripttion:
 * @version:
 * @Author: Jason chen
 * @Date: 2021-08-18 14:22:19
 * @LastEditors: Jason chen
 * @LastEditTime: 2021-08-23 11:40:23
 */
const {
    
     resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const FriendlyErrorsPlugin = require("friendly-errors-webpack-plugin");
const CopyWebpackPlugin = require('copy-webpack-plugin');
const address = require("address");
// 模块联邦的插件
const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin
const port = 3002;
const result = {
    
    
  mode: 'development',
  entry: './src/index.js',
  output: {
    
    
    path: resolve(__dirname, 'deploy'),
    filename: '[name].js'
  },
  module: {
    
    
    rules: [
      {
    
    
        test: /\.css$/i,
        use: ['style-loader', 'css-loader']
      },
      {
    
    
        test: /\.less$/i,
        use: ['style-loader', 'css-loader', 'less-loader']
      },
      {
    
    
        test: /\.vue$/i,
        loader: 'vue-loader',
        options: {
    
    
          preserveWhitepace: true,
          extractCSS: true,
          cssModules: {
    
    },
          //hotReload: false,//根据环境变量生成
        }
      },
    ]
  },
  plugins: [
    new CopyWebpackPlugin({
    
    
      patterns: [
        {
    
    
          from: resolve(__dirname, './public'),
          to: '',
        },
      ]
    }),
    new ProgressBarPlugin(),
    new FriendlyErrorsPlugin({
    
    
      compilationSuccessInfo: {
    
    
        messages: [
          `  App running at:`,
          `  - Local:    http://localhost:${
      
      port}`,
          `  - Network:  http://${
      
      address.ip()}:${
      
      port}`
        ]
      },
    }),
    new HtmlWebpackPlugin({
    
    
      template: './/public/index.ejs'
    }),
    new VueLoaderPlugin(),
    new ModuleFederationPlugin({
    
    
      // 应用名称,调用方使用
      name: 'vueAppTwo',
      //调用放引用的文件名称
      filename: 'app2.js',
      // 导入模块
      remotes: {
    
    
        // '导入别名':'远程应用名称/远程应用地址/导入文件的名称'
        'mfpVueAppOne': 'vueAppOne@http://localhost:3001/app1.js'
        // 'mfpVueAppOne': 'vueAppOne@http://localhost:3002/output/app1.js'
      },
    })
  ],
  devServer: {
    
    
    quiet: true, // necessary for FriendlyErrorsPlugin
    contentBase: resolve(__dirname, 'deploy'),
    port,
  }
}


module.exports = result;

package.json(app1与app2一致)

{
    
    
  "name": "vue-app2",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    
    
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "cross-env NODE_ENV=development webpack serve",
    "build": "cross-env NODE_ENV=production webpack "
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    
    
    "address": "^1.1.2",
    "cross-env": "^7.0.3",
    "css-loader": "^6.2.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "html-webpack-plugin": "^5.3.2",
    "less": "^4.1.1",
    "less-loader": "^10.0.1",
    "progress-bar-webpack-plugin": "^2.1.0",
    "style-loader": "^3.2.1",
    "url-loader": "^4.1.1",
    "vue": "^2.6.14",
    "vue-loader": "^15.9.8",
    "vue-template-compiler": "^2.6.14",
    "webpack": "^5.50.0",
    "webpack-cli": "^4.8.0",
    "webpack-dev-server": "^3.11.2"
  },
  "dependencies": {
    
    
    "copy-webpack-plugin": "^9.0.1",
    "vue-i18n": "^8.25.0"
  }
}

我们在app2中引入app1共享的appOneChildren组件:
在这里插入图片描述

分别在app1和app2的终端中执行:

yarn dev

app2运行效果:

在这里插入图片描述

这样我们就成功从app2成功引用了app1共享出来的代码,并成功运用到app2中了

其他的使用方式:

如上述所说我们通过webpack-dev-serve启动了两个前端项目,并且通过模块联邦共享出来了他们各自的代码模块,但是我们能否实现在前端项目打包后,实现模块联邦呢?

操作步骤:

一、app1打包,app2在线,app2访问app1资源

1.我们先将app1进行打包,我们在app1目录中执行

webpack

2.我们先将app1打包生成的output目录复制到app2中的public目录中(让app2通过webpack-dev-serve跑起来时可以拿到app1打包后的资源)
在这里插入图片描述

在这里插入图片描述

3.1 我们先单独将app2前端用webpack-dev-serve跑起来,看看是否能拿得到public中的app1资源:

前置工作:因为我们在app2中是要获取app1共享出来的代码的,而现在app1打包后的代码保存在app2的public目录中,所以app2的webpack中消费app1的地址需要修改为app2它自己的地址

‘mfpVueAppOne’: vueAppOne@http://localhost:${port}/output/app1.js

在这里插入图片描述

运行app2:

在这里插入图片描述

由图可见,此方案是可以行的,我们只需要可以访问app1的资源即可使用app1通过模块联邦分享过来的代码了

二、app1打包,app2打包,app2访问app1资源

1.我们app2项目在线时可以拿到app1的资源了;现在我们尝试打包app2

前置工作:因为我们在app2中是要获取app1共享出来的代码的,而现在app1打包后的代码保存在app2的public目录中,所以app2的webpack中消费app1的地址需要修改为app2它自己的地址

‘mfpVueAppOne’: vueAppOne@./output/app1.js

在这里插入图片描述

2.在app2根目录执行

webpack

3.生成项目打包文件
在这里插入图片描述

4.我们在app2中跑一个本地的node服务访问deploy中的index.html看看是否正常:

web-serve/index.js文件:

/*
 * @Descripttion: 
 * @version: 
 * @Author: Jason chen
 * @Date: 2021-08-20 15:31:00
 * @LastEditors: Jason chen
 * @LastEditTime: 2021-08-23 13:52:39
 */
const express = require('express');


const expressStaticGzip = require('express-static-gzip');
const app = express();

app.use(
  expressStaticGzip('../', {
    
    
    maxAge: '3d',
    setHeaders: setCustomCacheControl,
  })
);

function setCustomCacheControl (res, currentFilePath, stat) {
    
    
  if (currentFilePath.match(/\index\.html$/)) {
    
    
    // Custom Cache-Control for HTML files
    res.setHeader('Cache-Control', 'no-cache');
  }
}

app.use(express.static('../deploy'));


app.listen(9901, (req, res) => {
    
    
  console.log(req, res);
  console.log('启动成功,请通过localhost:9901访问');
});



5.在web-serve目录中执行

node index.js
  1. 运行效果

在这里插入图片描述

由图可见,此方案是可以行的,及时app2项目已经被打包成静态资源了,app2中只需要可以访问app1的资源即可使用app1通过模块联邦分享过来的代码了

三、国际化实践

1.我们在app1中使用vue-i18n,app2中不开启vue-i18n,查看效果
在这里插入图片描述

app1中国际化使用正常

在这里插入图片描述

2.app2中暂不使用vue-i18n并在app2中引入app1通过模块联邦分享的代码

在这里插入图片描述

3.我们发现app2中报错了,$t未定义,所以app2在app1中运行时,所使用的插件或其他依赖是需要消费方提供的,

3.1 我们在app2中安装vue-i18n并注册

3.2 并且app1它也把自己用到的国际化文件也导出并合到app2的vue-i18n的语言库里去

在这里插入图片描述

在这里插入图片描述

app1和app2重新运行项目效果:
在这里插入图片描述

四、配置:shared

除了前面提到的模块引入和模块暴露相关的配置外,还有个 shared 配置,主要是用来避免项目出现多个公共依赖。

例如,我们当前的app1,已经引入了一个 vue-i18n和Vue,而app2暴露的组件也依赖了 vue-i18n和Vue。如果不解决这个问题,app1就会加载两个 vue-i18n和Vue库。影响页面性能。

所以,我们在使用 Module Federation 的时候一定要记得,将公共依赖配置到 shared 中。

4.1 接下来,我们在浏览器打开app1,在 Chrome 的 network 面板中,可以看到app1 直接使用了项目 B 的 vue-i18n。

在这里插入图片描述

4.2 我们在app1处配置shared:

在这里插入图片描述

4.3重新运行app1项目,在app2页面中查看netWork:

我们可以看到如果提供方应用配置了shared之后,消费方就不会去下载vue-i18n和Vue这两个库了

在这里插入图片描述

五、动态加载远程联邦模块

第一步:我们在webpack的官网文档中可以看到,我们模块联邦可以动态的加载远端的代码资源:

在这里插入图片描述

关键代码:

new Promise(resolve => {
    
    
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // 这一部分取决于您计划如何托管和版本化您的联邦模块
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
    
    
        //注入的脚本已加载,可在Windows上使用
        const proxy = {
    
    
          get: (request) => window.app1.get(request),
          init: (arg) => {
    
    
            try {
    
    
              return window.app1.init(arg)
            } catch(e) {
    
    
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // 将 script设置为 versioned 注入此脚本 remoteEntry.js
      document.head.appendChild(script);
    })

这一步使我们通过script标签成功注入远端联邦模块的入口 remoteEntry.js ,

第二步:我们需要加载远端联邦模块的组件(容器):

在这里插入图片描述

关键代码:

function loadComponent(scope, module) {
    
    
  return async () => {
    
    
    // 初始化共享作用域(shared scope)用提供的已知此构建和所有远程的模块填充它
    await __webpack_init_sharing__('default');
    const container = window[scope]; // 或从其他地方获取容器
    // 初始化容器 它可能提供共享模块
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

第一步和第二步汇总:

export async function loadRemoteComponent(config) {
    
    
    return loadScript(config).then(() => loadComponentByWebpack(config))
}

function loadScript(config) {
    
    
    return new Promise((resolve, reject) => {
    
    
        const script = document.createElement('script');
        script.src = config.url
        script.type = 'text/javascript'
        script.async = true
        script.onload = () => {
    
    
            console.log(`Dynamic Script Loaded: ${
      
      config.url}`)
            document.head.removeChild(script);
            resolve();
        }
        script.onerror = () => {
    
    
            console.error(`Dynamic Script Error: ${
      
      config.url}`)
            document.head.removeChild(script)
            reject()
        }
        document.head.appendChild(script)
    })
}

async function loadComponent({
     
      scope, module }) {
    
    
    // 初始化共享作用域,这将使用此构建和所有远程提供的已知模块填充它
    await __webpack_init_sharing__('default')
    const container = window[scope] // 获取容器
    // 初始化容器,它可以提供共享模块
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    return factory();
}

我们先观察到这个loadRemoteComponent方法,它内部return了获取到的远程组件的数据,往下看这个loadScript方法,它返回了一个Promise,内部的代码我们稍微瞄一眼就知道这里是在使用js动态创建script标签的方式来加载一个远程js文件,当加载完毕时,将这个标签从页面中移除,然后结束。

当js文件加载完毕之后,页面中就拿到了远程项目暴露的组件信息,这个时候,我们就能使用loadComponent来加载指定的组件了,这个函数中主要就是初始化远程组件所需的环境,并根据我们传入的module,从相关作用域中查到到对应的模块进行返回;

在vue2中使用:

<template>
  <div class="vue-app1">
   <h1>这里是Vue-App2.vue</h1>
    <children ref="children"></children><br />
    <appOneChildren></appOneChildren>
  </div>
</template>
<script>
import children from './component/children.vue'
import {
      
       loadRemoteComponent } from './untils/index.js'

export default {
      
      
  components: {
      
      
    children,
    appOneChildren: (async () => {
      
      
      const app1 = await loadRemoteComponent({
      
      
        url: 'http://localhost:3001/app1.js',
        scope: 'vueAppOne',
        module: './appOneChildren'
      })
      return app1
    })
  },
}
</script>


效果:

在这里插入图片描述

简单理解:

url:就是我们模块联邦提供方的入口文件的地址;

scope:就是我们提供方模块联邦中name的值;

module:就是提供方在模块联邦中exposes中的key;

如下是app1(提供方)中的模块联邦的配置:

image2021-8-26_14-39-28.png

项目地址:

https://github.com/Jason-chen-coder/webpack-mfp

猜你喜欢

转载自blog.csdn.net/weixin_39085822/article/details/119936668