5个例子带你入门 Webpack5 模块联邦 Module Federation(下篇)

236 阅读7分钟

前言

上篇文章 5个例子带你入门 Webpack5 模块联邦 Module Federation(上篇),通过两个简单例子介绍Module Federation入门用法,本文继续介绍剩下3个使用场景及例子。

源码:github.com/markz-demo/…

例子3:动态添加导航路由

需求是:客户想主应用上线后,不动主应用,只部署子应用就可以在主应用上添加新导航以及对应页面,实现新功能。

思路是:

  1. 首先现在子应用里定义俩数组,导航和路由,并且export出来;
  2. 然后在主应用路由文件中,动态import进来,并且渲染出来。
  3. 另外也可以子应用中定义一级路由,然后再在路由组件中定义更多二级路由和导航。
// sub1/src/nav.jsx
function Page1() { ... }
function Page2() { ... }

// 子应用导航数组
export const navs = [{
    title: 'Sub1 nav1', // 对应导航名称
    url: '/sub1/page1', // url
}, {
    title: 'Sub1 nav2', // 对应导航名称
    url: '/sub1/page2', // url
}]

// 子应用路由数组
export const routers = [{
    exact: true,
    path: '/sub1/page1',// 路由path
    element: <Page1 />, // 路由组件
}, {
    exact: true,
    path: '/sub1/page2',// 路由path
    element: <Page2 />, // 路由组件
}]
// sub1/webpack.config.js
new ModuleFederationPlugin({
    ...
    exposes: {
        ...
        './nav': './src/nav.jsx',
// main/src/router.jsx
export default function RootRouter() {
    const [navs, setNavs] = useState([])
    const [routers, setRouters] = useState([])

    useEffect(() => {
        // 动态异步加载子应用navs和routers列表
        import('sub1/nav').then(({ navs, routers }) => {
            setNavs(navs)
            setRouters(routers)
        })
    }, [])

    return (
        <BrowserRouter>
            <div className='layout'>
                <div className='nav'>
                    <NavLink to='/' className='nav-link'>Home</NavLink>
                    <NavLink to='/page1' className='nav-link'>Page1</NavLink>
                    <NavLink to='/page2' className='nav-link'>Page2</NavLink>
                    {/* 遍历渲染导航 */}
                    {navs.map((item, i) => <NavLink key={i} to={item.url} className='nav-link'>{item.title}</NavLink>)}
                </div>
                <div className='layout-body'>
                    <Routes>
                        <Route exact path='/' element={<Home />} />
                        <Route exact path='/page1' element={<Page1 />} />
                        <Route exact path='/page2' element={<Page2 />} />
                            {/* 遍历渲染路由 */}
                        {routers.map((item, i) => <Route key={i} {...item} />)}
                    </Routes>
                </div>
            </div>
        </BrowserRouter>
    )
}

效果图:

image.png

例子4:动态替换导航

需求是:客户想主应用上线后,不动主应用,只部署子应用就可以重写主应用里的部分页面逻辑,也就是替换页面。

思路是:在子应用里配置需要替换的路由path及组件,然后在主应用里取到这个替换列表,再在渲染每个路由之前判断下当前path是否在替换列表里,如果在就直接渲染子应用对应的路由组件,如果不在就还是渲染主应用的路由组件。

// sub1/src/replace.jsx
const Sub1Page1 = () => <div>Sub1 Replace Page1</div>

// 替换主应用里page1路由
export default [{
    exact: true,
    path: '/page1',
    element: <Sub1Page1 />,
}]
// main/src/router.jsx
import replaces from 'sub1/replace';

<Routes>
    {/* 外层套个自定义Wrapper,重写路由加载逻辑 */}
    <Route exact path='/' element={<Wrapper replaces={replaces} />} >
        <Route exact path='/' element={<Home />} />
        <Route exact path='/page1' element={<Page1 />} />
        <Route exact path='/page2' element={<Page2 />} />
    </Route>
</Routes>

function Wrapper({ replaces }) {
    const { pathname } = useLocation()
    const replace = replaces.find(item => matchPath(item, pathname)) // 判断当前path是否在子应用的replace路由列表里
    if (replace) {
        return replace.element // 如果有,则渲染子应用里配置的路由组件
    }
    return <Outlet />
}

动态import

这个例子有个限制,由于主应用里不知道子应用里到底配置了哪些替换路由,所以只能在渲染路由之前把子应用配置加载出来,从上一篇文章知道,子应用得用动态import,才能避免子应用如果挂掉了影响主应用。但是动态import是异步的,所以得处理下渲染顺序。

// main/src/router.jsx
// import replaces from 'sub1/replace';
useEffect(() => {
    // 动态异步加载子应用replace路由列表
    import('sub1/replace').then(result => {
        setReplaces(result.default)
    }).catch(e => { setReplaces([]) })
}, [])

{/* 子应用replace路由列表加载完成才能渲染路由 */}
{replaces && <Routes>...

例子5:共享Common控件

这里的Common控件是指项目业务用到的公共控件或utils、常量等等,并不是指第三方库。

这种控件定义在主应用里,并以路径方式引用,然后也需要用在子应用里,有几种方案:

  1. Module Federation expose 暴露共享代码。
  2. 用 Webpack output library umd 方式把common代码单独打包,并且引用在html script里。

Module Federation expose 暴露共享代码

还是利用 Module Federation 特性,反过来,把主应用里的组件export出来,在子应用里引用:

// main/src/common/SearchButton.jsx 主应用里定义一个SearchButton控件
export default function SearchButton({ children, ...others }) {
    return <Button icon={<SearchOutlined />} {...others}>{children}</Button>
}
// main/src/common/index.js 公共控件入口export
export { default as SearchButton } from './SearchButton';
// main/webpack.config.js
new ModuleFederationPlugin({
    name: 'main_app', // 主应用名字
    filename: 'main.js', // 编译生成的文件名,给子应用引用
    exposes: {
        './common': './src/common/index.js', // 主应用common模块,给子应用引用
    },
})
// sub1/replace.jsx 在上例子应用replace的组件里引用公共控件
import { SearchButton } from 'main/common';

<SearchButton>Sub1 Search Button</SearchButton>
// sub1/webpack.config.js
new ModuleFederationPlugin({
    remotes: {
        'main': 'main_app@http://localhost:3000/main.js', // <import时的别名>: <主应用名字@主应用common入口路径>
    },
})

效果如下图,发现common控件入口文件被构建成一个独立文件。

image.png

与微前端比较

关于微前端概念:关于微前端的几大特性技术选型分析

  • 微前端:比较适合一个主应用架子,没有实质性的功能内容,然后通过多个子应用以微服务形式渲染到主应用里。
    • 大多基于路由,一个路由对应一个子应用;
    • 大部分功能是在子应用里实现的,并且多个,得考虑子应用之间的影响,比如样式隔离、JS隔离等机制。
    • 各个子应用之间独立性比较强。
    • 支持(适合)多框架。
  • Module Federation:比较适合想在一个功能性比较全面的主应用中,实现一些类似自定制的页面功能改动,或者功能扩展。
    • 可以基于组件,也可以基于路由,但是得主应用配合改动支持子应用;
    • 大部分功能是在主应用里实现的,子应用只负责部分功能的替换、重写、或者在原有功能上添加功能;
    • 主应用可以独立运行,不依赖子应用;子应用依赖主应用;
    • 由于主子应用依赖性比较强,如果是相同框架会有比较好的表现。

通过上面分析可以看出来:如果是在一个已有项目里支持自定制的话,Module Federation比较适合。如果是比较小、或者新开发的微前端,Module Federation也可能是一个好的选择。

待优化

子应用入口js文件hash\version缓存问题

image.png

从上篇文章里的network分析里,可以看到 http://localhost:3001/sub1.js 这个js是没有hash的,因为这个文件是在子应用里编译生成的,然后在主应用里动态加载,因为主应用不知道子应用编译出来的文件是啥名,所以不能给文件加hash。

那加version呢?也就是版本号,但是主子应用的部署是相互独立的,相互不知道对方是啥时候部署的,所以version不能写死,能起到部分作用,但依然存在缓存问题。

所以当前方案是:

  1. 方案1:对此文件不做缓存,或者主应用里每次服务器渲染html的时候都加上随机version,这个文件只是一个子应用的webpack模块实例化文件,用来给主应用请求加载远程依赖用的,并不会把子应用业务实现代码构建进去的,所以可以size是比较固定的,也很小,所以不用担心load效率或流量等问题。
  2. 方案2:无论是主应用还是子应用,如果子应用里有代码改动,每次部署时都得手动改动下主应用里加载这个js的version。

渲染效率优化

都知道Module Federation的加载远程模块是异步的,体现在两方面:

  1. shared的第三方包是异步的,所以主应用入口也得做成异步的,势必会影响页面渲染速度。
  2. 有些需求得在异步加载子应用远程模块之后,再实例化页面甚至路由,会导致页面局部渲染慢,甚至白页。
  3. 如果卸载子应用,只运行主应用也会多少有些影响,肯定是不如在加子应用之前的运行状态的。

以上都会导致页面渲染效率,导致白页,影响用户体验、SEO等等。这些都是必须要考虑的问题。

总结

综合上篇 5个例子带你入门 Webpack5 模块联邦 Module Federation(上篇),5个例子就介绍完了。也列举了一些 故障排除简单分析,以及 待优化的问题。有些方案可能不是最优方案,希望提供的思路能帮助或启发到大家。最后列下这5个例子:

  • 例子1:动态添加Tab
  • 例子2:动态import
  • 例子3:动态添加导航路由
  • 例子4:动态替换导航
  • 例子5:共享Common控件

源码:github.com/markz-demo/…