基于Webpack5实现微前端架构

前言

最近这段时间微前端这个概念愈来愈被说起,它采用了微服务的相关理念,咱们能够把一个应用拆分红多个能够互不依赖能够独立开发并单独部署的模块,而后在运行时把它们组合成一个完整的App。html

经过这样的手段,咱们可使用不一样的技术去开发应用的各个部分,好比这个模块已经用React开发好了咱们能够继续用React,那个新模块团队更偏向于用Vue来实现咱们就能够用Vue去实现。咱们能够有专门的团队去维护各个独立的模块,维护起来也会更加方便。这样咱们团队协做的方式也就跟着改变了。前端

Webpack5开始,已经内置了对微前端开发的支持,它们提供了一个新的功能叫Module Federation(我也不知道该怎么翻译这个术语会比较恰当),提供了足够的能力来让咱们实现微前端开发。react

话很少说,咱们仍是经过一个简单的例子来感觉下总体的一个概念跟流程。咱们会实现一个简单的App,而后把它经过webpack改形成微前端的形式。webpack

咱们开始吧!

此次全部配置都由咱们来手动完成。首先咱们新建一个空白目录,而后在项目里面执行:git

npm init -y
复制代码

而后为了使用webpack,github

npm add webpack webpack-nano -D
复制代码

接下来咱们就能够经过在根目录新建一个webpack.config.js文件来配置整个打包过程啦!web

咱们在开发时跟运行时配置是有差异的,通常你们可能会编写webpack.production.jswebpack.development.js两个文件,来配置不一样的环境。但这样可能会让咱们的配置对象变得很大很臃肿不容易维护,咱们须要在一大堆配置中找到咱们想要的配置去修改,并且各个环境的配置也不是彻底不一样,那咱们得封装啊,咱们得抽象啊,咱们要想办法复用啊!npm

那咱们该怎么办呢?

咱们能不能把这个大的配置对象拆解成一个个具备特定功能的配置对象来单独维护呢?json

好比咱们这个项目会经过mini-html-webpack-plugin来生成最终的index.html文件,那咱们就能够写一个单独的函数来导出配置这个页面的相关配置bootstrap

exports.page = ({title}) => ({
    plugins: [new MiniHtmlWebpackPlugin({
        context: {title}
    })]
})
复制代码

这样后续咱们要改变页面相关的配置时就咱们就会知道来修改这个page函数,咱们甚至能够替换成新的插件,而须要这个配置的地方只须要调用这个函数就能拿到配置,不须要关心细节,它们对咱们的变更是无感知的,天然也不会受到影响。咱们的配置也就能以函数的形式在各个环境中复用。

那么问题来了,毕竟webpack最终仍是只认它认识的那个配置形式,因此咱们还须要把这些函数返回的小配置对象合并成一个大的完整的配置对象。注意像Object.assign这种处理方式对数组不太友好,会丢失数据,你们能够本身实现相关逻辑,或者使用webpack-merge这个包来处理。

为了更好地管理webpack配置,不让复杂的配置花了眼,咱们能够再新建一个webpack.parts.js文件,在这里定义一个个小函数来返回配置特定功能的配置对象。

而后在webpack.config.js里面,咱们能够导入这些函数,而且咱们能够经过运行时传过来的mode来判断须要给什么环境打包,动态生成最后的配置:

const {mode} = require('webpack-nano/argv')
const parts = require('./webpack.parts')
const {merge} = require('webpack-merge')

const commonConfig = merge([
    {mode},
    {entry: ["./App"]},
    parts.page({title: 'React Micro-Frontend'}),
    parts.loadJavaScript()
])

const productionConfig = merge([parts.eliminateUnusedCss()])

const developmentConfig = merge([{entry: ['webpack-plugin-serve/client']}, parts.devServer()])

const getConfig = (mode) => {
    process.env.NODE_ENV = mode
    switch (mode) {
        case 'production':
            return merge([commonConfig, productionConfig])
        case 'development':
            return merge([commonConfig, developmentConfig])
        default:
            throw new Error(`Trying to use an unknown mode, ${mode}`);
    }
}

module.exports = getConfig(mode)
复制代码

这最大限度地避免了咱们配置文件的臃肿。

冲冲冲~

而后咱们还须要配置咱们的开发环境,咱们固然不想在开发时每次都手动去刷新页面,这边用到了一个插件webpack-plugin-serve来作实时更新:

exports.devServer = () => ({
    watch: true,
    plugins: [
        new WebpackPluginServe(
            {
                port: Process.env.PORT || 8000,
                host: '127.0.0.1',
                static: './dist',
                liveReload: true,
                waitForBuild: true
  })
    ]
})
复制代码

而后咱们这边使用了React做为前端框架:

npm add react react-dom
复制代码

为了让编译器可以正确理解咱们的React组件,咱们要使用babel

npm add babel-loader @babel/core @babel/preset-env @babel/preset-react -D
复制代码

配置一下babel-loader

exports.loadJavaScript = () => ({
    module: {
        rules: [
            { test: /\.js$/, include: APP_SOURCE, use: "babel-loader" },
        ],
    },
});
复制代码

别忘了还要增长一个.babelrc文件

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
  }
    ],
    [
      "@babel/preset-react"
  ]
  ]
}
复制代码

如今咱们的React组件能被正确处理了,咱们能够开始写咱们的组件了。

愉快的业务代码环节~

首先是咱们的Header组件:

import React from "react";

const Header = () => {
    return <header>
        <h1>Micro-Frontend With React</h1>
    </header>
}

export default Header;
复制代码

而后是咱们的Main组件:

import React from "react";
import Header from "./Header";

const Main = () => {
    return (
        <main>
            <Header/>
            <span>a Demo for Micro-Frontend using Webpack5</span>
        </main>
    );
}

export default Main
复制代码

最后是入口文件:

import ReactDOM from "react-dom";
import React from "react";
import Main from "./Main";

const container = document.createElement("div");
document.body.appendChild(container);
ReactDOM.render(<Main/>, container);
复制代码

打开package.json文件配置以下脚本:

"scripts": {
  "build": "wp --mode production",
  "start": "wp --mode development"
  }
复制代码

如今咱们能够经过在终端执行npm run start来预览咱们的App了。

运行中的App

MF它来了!

接下来咱们来把它改形成微前端的形式,把Header作成单独的模块,而后其它的作成另一个模块,这时候就要用到ModuleFederationPlugin了。

首先咱们要配置这个插件:

const {ModuleFederationPlugin} = require("webpack").container;

exports.federateModule = ({
                              name,
                              filename,
                              exposes,
                              remotes,
                              shared,
                          }) => ({
    plugins: [
        new ModuleFederationPlugin({
            name,
            filename,
            exposes,
            remotes,
            shared,
        }),
    ],
});
复制代码

其中name是惟一ID,用于标记当前服务,filename是提供给其余服务加载的文件,exposes则是须要暴露的模块,remotes指定要使用的其它服务,shared则是配置公共模块(好比lodash这种)

  • 提供了 exposes 选项的表示当前应用是一个 Remoteexposes 内的模块能够被其余的 Host 引用,引用方式为import(${name}/${expose})
  • 提供了 remotes 选项的表示当前应用是一个 Host,能够引用 remoteexpose 的模块。

咱们要在webpack.config.js里面配置这两个模块:

const componentConfig = {
    App: merge(
        {
            entry: [path.join(__dirname, "src", "bootstrap.js")],
        },
        parts.page({title: 'React Micro-Frontend'}),
        parts.federateModule({
            name: "app",
            remotes: {mf: "mf@/mf.js"},
            shared: sharedDependencies,
        })
    ),
    Header: merge(
        {
            entry: [path.join(__dirname, "src", "Header.js")],
        },
        parts.federateModule({
            name: "mf",
            filename: "mf.js",
            exposes: {"./Header": "./src/Header"},
            shared: sharedDependencies,
        })
    ),
};
复制代码

由于咱们为了简化代码把全部代码都写在一个项目里了,更常见的状况是每一个模块均可以有属于本身的代码仓库,并且可使用不一样的技术来实现。,这种状况咱们处理的方式基本不变,引用远程依赖时记得按照相似[name]@[protocol]://[domain]:[port][filename]的形式去指定remotes就好。

那为了模拟多个项目独立编译,咱们也是用了组件名来设置不一样的配置,这边对于Header咱们并不想直接在浏览器中运行,而对于App咱们想要在浏览器中看到完整的页面,因此咱们把对页面相关的配置移到对App的配置中,这webpack.config.js在动态生成配置对象时也须要接受一个组件名做为参数了。

const {mode, component} = require('webpack-nano/argv')
  ...
const getConfig = (mode, component) => {
    switch (mode) {
        case 'production':
            return merge([commonConfig, productionConfig, componentConfig[component]])
        case 'development':
            return merge([commonConfig, developmentConfig, componentConfig[component]])
        default:
            throw new Error(`Trying to use an unknown mode, ${mode}`);
    }
}
复制代码

而后咱们要在Main里修改引入Header的路径

import Header from "mf/Header";
复制代码

最后是要经过一个引导文件bootstrap.js来加载这一切

import("./App");
复制代码

这是由于remote暴露的js文件须要优先加载,若是App.js不是异步的,在import Header的时候,会依赖mf.js,直接运行可能致使mf.js还没有加载完毕,因此会有问题。

js加载顺序

经过network面板也能够看出,mf.js 是先于 App.js 加载的,因此咱们的 App.js 必须是个异步逻辑。

经过npm run build -- --component Header咱们先完成对Header的编译,而后再经过npm run start -- --component App完成项目的运行,打开浏览器,应该能够看到跟以前同样的界面。

写在最后

总的来讲,这为团队协做代码共享提供了新的方式,同时有一些侵入性,并且咱们的项目就得都依赖于webpack了。我我的以为没啥问题,毕竟如今大部分项目都会用到webpack,比较介意这一点的同窗能够关注下vitevite利用浏览器原生的模块化能力来提供代码共享的解决方案。今天咱们仅仅用Module Federation实现了一个小demo,关于微前端webpack的管理都不是一篇文章就可以说得清楚的,还有不少事情能够聊,我们后面再分别单独展开讲讲,Happy coding~

完整Demo代码