qiankun(乾坤)搭建微前端项目

1,783 阅读9分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

概述

微前端的适用场景:

  1. 存在很多的业务,业务之间没有太多关联,但是呢放一个项目里面,项目太大了,开发维护都不是很方便。
  2. 存在老项目,使用的是不同的语言,像react vue angular 这些。 然后想加入新的业务模块,但是又不太想去使用旧的代码去写。因为前端更新太快, 像vue都到vue3了, react也更新了一些新的东西,现在react18都出来了。还有例如antd 之前版本是v3版本的,想更新到v4版本的。
  3. 还有就是可能存在太多的可复用的模块,例如一些公共的服务,权限的。

我目前想到的就这些场景了,微前端并不适用于所有的场景,也不能盲目的去跟风,要搞个新的东西出来。只有最合适当下的才是做好。

选择方案

  • single-spa: 需要处理的东西挺多,比如父应用和子应用之间的通信,css样式的问题,还有就是全局的变量的问题。所以就不推荐了
  • qiankun(乾坤): 阿里基于single-spa 开发出来的,我实现的方式就是这个啦。

qiankun的聚合有两种集成的方式,一种是根据路由集成,一种是将子应用当成模块去集成。

贴上链接有空的话,可以自己去瞅瞅:
single-spa
qiankun(乾坤)

原理概述

仅是我理解的:首先分为父应用和子应用。
父应用:主要负责集成所有的子应用,提供一个入口能够访问你所需要的子应用的展示。
子应用:对应的业务的项目或者是对应的业务的模块。子应用的打包需要输出成模块的形式(umd)父应用通过这个来加载模块。

创建子应用

子应用的创建我就使用之前创建的项目了, 在上面进行改造。 webpack5 手动搭建前端项目(react+antd + ts)

当前项目地址: Github

创建子应用项目

我实现不同的项目之间的切换,所以创建了两个子项目

目录结构如下:

image.png

首先对子项目进行处理,子项目的话,按照qiankun文档的话是需要暴露出三个函数,对应子应用的生命周期bootstrapmountunmount, 还有一个可选的函数update

首先修改入口文件在入口文件中添加暴露这几个函数,update 暂时用不到,我们就不添加了,注意函数是异步的函数

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export const bootstrap = async (props:any) => {
  console.log('react app bootstraped')
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export const mount = async (props:any) => {
  ReactDom.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'))
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export const unmount = async (props:any) => {
  ReactDom.unmountComponentAtNode(
    props.container ? props.container.querySelector('#root') : document.getElementById('root')
  )
}

子应用环境的区分

为了能够区分子应用的应用环境的在聚合中还是单独的访问,我们新增一个静态的配置文件public-path.js

文件存放位置: subpage/subject1/statics/public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}

没有静态文件进行大包配置的需要使用 copy-webpack-plugin 进行文件的复制

注意: copy-webpack-plugin 最新的版本需要使用nodejs的版本在14.15.0或者以上的版本,当前的node版本低的话,就升级或者将copy-webpack-plugin 版本降低。也可以用nvm来管理nodejs的版本切换进行编译。

image.png

修改项目中的打包配置文件,配置文件中的plugins 新增插件

import CopyWebpackPlugin from 'copy-webpack-plugin'
//...其他的代码
plugins: [
    new CopyWebpackPlugin({
      patterns: [{ from: 'statics', to: 'statics' }]
    }),
  ],

修改入口文件,根据不同的环境进行不同的加载

import React from 'react';
import { createRoot, Root } from 'react-dom/client'
import App from './app'
import '../statics/public-path'

/** 不是qiankun 聚合的时候进行的加载 */
if (!(window as any).__POWERED_BY_QIANKUN__) {
  render({})
}

let root:Root

/** 根据参数判断从哪儿获取值 */
function render (props:any) {
  const { container } = props
  const dom = container ? container.querySelector('#root') : document.getElementById('root')
  root = createRoot(dom)
  root.render(<App/>)
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap(props:any) {
  console.log('react app bootstraped')
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount (props:any) {
  render(props)
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount (props:any) {
  root.unmount()
}

子应用打包修改

修改子应用中的打包配置,将构建的包修改成umd的。修改output的配置,新增参数library 和libraryTarget,因为使用的是webpack5,因为使用的是webpack5中去除了jsonpFunction的配置,所以这就不写了,使用webpack4的时候需要加上jsonpFunction

output: {
    library: 'subject1-[name]',
    libraryTarget: 'umd'
    // jsonpFunction: `webpackJsonp_subject1` // 因为使用的webpack5所以就将此配置删除,webpack4 中需要此配置 https://webpack.js.org/blog/2020-10-10-webpack-5-release/#changes-to-the-structure
  },

我们在项目中的 webpack.dev.ts 和 webpack.pro.ts 配置都加上该配置

//  webpack.dev.ts
import webpack, { Configuration } from 'webpack'
import WebpackDevServer from 'webpack-dev-server'
import { ConfigInit } from './web/webpack.web'
import { merge } from 'webpack-merge'

const openBrowser = require('./util/openBrowser')

// 开发环境的配置文件
const config:Configuration = merge(ConfigInit('development'), {
  output: {
    library: 'subject1-[name]',
    libraryTarget: 'umd'
  }
})

const host:string = '127.0.0.1'
const port:string = '10087'

const devserver = new WebpackDevServer({
  headers: { 'Access-Control-Allow-Origin': '*' },
  hot: true, // 热更新
  host: host, // 地址
  port: port, // 端口
  // open: true, // 关闭
  setupExitSignals: true,
  compress: true
}, webpack(config))

devserver.start().then(() => {
  // 启动界面
  openBrowser(`http://${host}:${port}`)
})
// webpack.pro.ts
import webpack, { Configuration, BannerPlugin, LoaderOptionsPlugin } from 'webpack'
import { CleanWebpackPlugin } from 'clean-webpack-plugin'
import CompressionWebpackPlugin from 'compression-webpack-plugin'
import TerserPlugin from 'terser-webpack-plugin'
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
import CopyWebpackPlugin from 'copy-webpack-plugin'
import { merge } from 'webpack-merge'
import { ConfigInit } from './web/webpack.web'

const config:Configuration = merge(ConfigInit('production'), {
  plugins: [
    new CleanWebpackPlugin(),
    new CompressionWebpackPlugin(),
    new LoaderOptionsPlugin({
      minimize: true
    }),
    new CopyWebpackPlugin({
      patterns: [{ from: 'statics', to: 'statics' }]
    }),
    new BannerPlugin('版权所有,翻版必究')
  ],
  output: {
    library: 'subject1-[name]',
    libraryTarget: 'umd'
  },
  optimization: {
    splitChunks: {
      chunks: 'all'
    },
    runtimeChunk: {
      name: 'mainifels'
    },
    minimize: true,
    minimizer: [
      new TerserPlugin(),
      new CssMinimizerPlugin()
    ]
  },
  performance: {
    hints: false,
    maxAssetSize: 4000000, // 整数类型(以字节为单位)
    maxEntrypointSize: 5000000 // 整数类型(以字节为单位)
  }
})

webpack(config, (err:any, state:any) => {
  if (err) {
    console.log(err.stack || err)
  } else if (state.hasErrors()) {
    let err = ''
    state.toString({
      chunks: false,
      colors: true
    }).split(/\r?\n/).forEach((line:any) => {
      err += `    ${line}\n`
    })
    console.warn(err)
  } else {
    console.log(state.toString({
      chunks: false,
      colors: true
    }))
  }
})

ps: 注释或者删除掉 DllPlugin和DllReferencePlugin的配置。原因:因为DllPlugin和DllReferencePlugin将第三包抽离出来了,但是抽离的是动态的库,然后生成了一个全局的变量, 在基座项目中引用的时候,如果父项目的加载的时候不存在这个变量会被加载失败,所以去除。

image.png

修改子项目的启动端口, 项目1(subject1)为 10087,项目2(subject2)为10088.

基座项目(父应用)

安装qiankun的依赖包

npm install qiankun

创建page文件夹存放界面, 新增界面,一个主页展示,一个作为qiankun的容器界面

image.png

// home.tsx
import React, { useEffect } from 'react'
import { Row, Col, Layout } from 'antd'

const { Content, Sider, Header } = Layout

export default () => {
  useEffect(() => {
  }, [])

  return <Layout style={{ height: '100%' }}>
    <Header style={{ color: '#fff' }}>顶部的</Header>
    <Layout>
      <Sider theme='light'>
        <Row>
          <Col span={24}>
            项目1
          </Col>
          <Col span={24}>
           项目2
          </Col>
        </Row>
      </Sider>
    </Layout>
  </Layout>
}

// qiankun.tsx
import React from 'react'

export default () => {
  return <div id='sub-container'>子项目的容器</div>
}

更改入口中的app.tsx的界面, 创建react路由对界面进行加载

// app.tsx
import React, { useEffect } from 'react'
import { HashRouter, Routes, Route } from 'react-router-dom'
import HomePage from './page/home'
import QiankunPage from './page/qiankun'

export default () => {
  return <HashRouter>
    <Routes>
      <Route path="/" element={<HomePage />}>
        <Route path='app1' element={<QiankunPage />} />
        <Route path='app2' element={<QiankunPage />} />
      </Route>
    </Routes>
  </HashRouter>
}

路由接入子项目

创建子应用的配置

const qiankunProject = [
  {
    name: 'react app1', // app name registered
    entry: 'http://localhost:10087', // 子项目对应的地址, 打包之后存放对应的网址
    container: '#sub-container', // 容器的ID(qiankun.tsx界面中生命的)
    activeRule: hashPrefix(`/app1`) // 判断是否加载
  },
  {
    name: 'react app2', // app name registered
    entry: 'http://localhost:10088',
    container: '#sub-container',
    activeRule: hashPrefix(`/app2`)
  }
]

hashPrefix函数: 对路由进行判断的

export function hashPrefix (prefix:string) {
  if (!prefix || prefix === '') {
    return () => true
  }
  return function (location:any) {
    // 使用的hash的形式对路由进行加载,前面会存在#所以替换之后,对路径进行判断,是相应的路径就返回ture
    const path:string = location.hash.replace('#', '')
    return prefix === path || path.includes(prefix + '/')
  }
}

在app.tsx界面中注册并且启用

import React, { useEffect } from 'react'
import { registerMicroApps, start } from 'qiankun'
import { HashRouter, Routes, Route } from 'react-router-dom'
import HomePage from './page/home'
import QiankunPage from './page/qiankun'

export function hashPrefix (prefix:string) {
  if (!prefix || prefix === '') {
    return () => true
  }
  return function (location:any) {
    // 使用的hash的形式对路由进行加载,前面会存在#所以替换之后,对路径进行判断,是相应的路径就返回ture
    const path:string = location.hash.replace('#', '')
    return prefix === path || path.includes(prefix + '/')
  }
}

const qiankunProject = [
  {
    name: 'react app1', // app name registered
    entry: 'http://localhost:10087', // 子项目对应的地址, 打包之后存放对应的网址
    container: '#sub-container', // 容器的ID(qiankun.tsx界面中生命的)
    activeRule: hashPrefix(`/app1`) // 判断是否加载
  },
  {
    name: 'react app2', // app name registered
    entry: 'http://localhost:10088',
    container: '#sub-container',
    activeRule: hashPrefix(`/app2`)
  }
]

export default () => {
  useEffect(() => {
    registerMicroApps(qiankunProject, {
      beforeLoad: async app => console.info(`[${app.name}]:before load`),
      beforeMount: [async app => console.info(`[${app.name}]:before mount`)],
      afterMount: async app => console.info(`[${app.name}]:after mount`),
      beforeUnmount: async app => console.info(`[${app.name}]:before unmount`),
      afterUnmount: async app => console.info(`[${app.name}]:after unmount`)
    })
    start()
  }, [])


  return <HashRouter>
    <Routes>
      <Route path="/" element={<HomePage />}>
        <Route path='app1' element={<QiankunPage />} />
        <Route path='app2' element={<QiankunPage />} />
      </Route>
    </Routes>
  </HashRouter>
}

因为使用的是react-router的v6版本所以写法有点不一样

 import { HashRouter, Routes, Route } from 'react-router-dom'
 
 <HashRouter>
    <Routes>
      <Route path="/" element={<HomePage />}>
        <Route path='app1' element={<QiankunPage />} />
        <Route path='app2' element={<QiankunPage />} />
      </Route>
    </Routes>
  </HashRouter>

修改home界面的显示,需要增加Outlet的一个插槽,来实现侧边栏点击的效果

// home.tsx
import React, { useEffect } from 'react'
import { Row, Col, Layout } from 'antd'
import { Link, Outlet } from 'react-router-dom'

const { Content, Sider, Header } = Layout

export default () => {
  useEffect(() => {
  }, [])

  return <Layout style={{ height: '100%' }}>
    <Header style={{ color: '#fff' }}>顶部的</Header>
    <Layout>
      <Sider theme='light'>
        <Row>
          <Col span={24}>
            <Link to='app1'>项目1</Link>
          </Col>
          <Col span={24}>
            <Link to='app2'>项目2</Link>
          </Col>
        </Row>
      </Sider>
      <Content>
        <Outlet />
      </Content>
    </Layout>
  </Layout>
}

启动结果

然后启动项目,结果如下 image.png

点击菜单栏进行切换子应用

image.png

手动加载一个微应用

手动加载一个微应用加载微应用的需要子应用多导出一个生命周期的函数update

修改子项目的入口文件,增加函数

// index.tsx
// 增加 update 钩子以便主应用手动更新微应用
export async function update(props:any) {
}

修改主项目,新增一个界面home2.tsx用来做手动加载的

注意:同时加载多个子应用的话,需要设置不同的容器,如果你只想要一个容器的话,那么需要将之前加载的子应用给卸载掉

// home2.tsx
import React, { useState } from 'react'
import { Row, Col, Layout, Button } from 'antd'
import { loadMicroApp, MicroApp } from 'qiankun'
const { Content, Sider, Header } = Layout

const qiankunProject = [
  {
    name: 'react app1', // app name registered
    entry: 'http://localhost:10087', // 子项目对应的地址, 打包之后存放对应的网址
    container: '#sub-container' // 容器的ID(qiankun.tsx界面中生命的)
  },
  {
    name: 'react app2', // app name registered
    entry: 'http://localhost:10088',
    container: '#sub-container'
  }
]

export default () => {
  /** 存放对应的子项目的对象 */
  const [microAppAry, setMicroAppAry] = useState<Array<MicroApp>>([])
  /** 存放当前的子项目的下标 */
  const [index, setIndex] = useState<number>(0)

  const onChangeProject = (num:number) => {
    if (microAppAry[index]) { // 注销上一次加载的项目
      microAppAry[index].unmount()
    }
    microAppAry[num] = loadMicroApp(qiankunProject[num])
    setMicroAppAry(microAppAry)
    setIndex(num) // 设置当前的项目的下标
  }

  return <Layout style={{ height: '100%' }}>
    <Header style={{ color: '#fff' }}>顶部的</Header>
    <Layout>
      <Sider theme='light'>
        <Row>
          <Col span={24}>
            <Button onClick={() => onChangeProject(0)}>项目1</Button>
          </Col>
          <Col span={24}>
          <Button onClick={() => onChangeProject(1)}>项目2</Button>
          </Col>
        </Row>
      </Sider>
      <Content>
        <div id='sub-container'>子项目的容器</div>
      </Content>
    </Layout>
  </Layout>
}

修改app.tsx中的路由,增加一个home的路由

import React, { useEffect } from 'react'
import { registerMicroApps, start } from 'qiankun'
import { HashRouter, Routes, Route } from 'react-router-dom'
import HomePage from './page/home'
import Home2Page from './page/home2'
import QiankunPage from './page/qiankun'

export function hashPrefix (prefix:string) {
  if (!prefix || prefix === '') {
    return () => true
  }
  return function (location:any) {
    // 使用的hash的形式对路由进行加载,前面会存在#所以替换之后,对路径进行判断,是相应的路径就返回ture
    const path:string = location.hash.replace('#', '')
    return prefix === path || path.includes(prefix + '/')
  }
}

const qiankunProject = [
  {
    name: 'react app1', // app name registered
    entry: 'http://localhost:10087', // 子项目对应的地址, 打包之后存放对应的网址
    container: '#sub-container', // 容器的ID(qiankun.tsx界面中生命的)
    activeRule: hashPrefix(`/app1`) // 判断是否加载
  },
  {
    name: 'react app2', // app name registered
    entry: 'http://localhost:10088',
    container: '#sub-container',
    activeRule: hashPrefix(`/app2`)
  }
]

export default () => {
  useEffect(() => {
    registerMicroApps(qiankunProject, {
      beforeLoad: async app => console.info(`[${app.name}]:before load`),
      beforeMount: [async app => console.info(`[${app.name}]:before mount`)],
      afterMount: async app => console.info(`[${app.name}]:after mount`),
      beforeUnmount: async app => console.info(`[${app.name}]:before unmount`),
      afterUnmount: async app => console.info(`[${app.name}]:after unmount`)
    })
    start()
  }, [])


  return <HashRouter>
    <Routes>
      <Route path="/" element={<HomePage />}>
        <Route path='app1' element={<QiankunPage />} />
        <Route path='app2' element={<QiankunPage />} />
      </Route>
      <Route path="/home" element={<Home2Page />}/>
    </Routes>
  </HashRouter>
}

预加载资源

路由加载的预加载配置在start函数中配置

启动时增加配置prefetch即可,默认加载方式为ture: 在第一个微应用 mount 完成后开始预加载其他微应用的静态资源

image.png

手动加载需要调用prefetchApps函数

import { prefetchApps } from 'qiankun';

prefetchApps([
  { name: 'app1', entry: '//localhost:7001' },
  { name: 'app2', entry: '//localhost:7002' },
]);

父应用和子应用之间的通信

主应用中创建状态,并进行初始化

import { initGlobalState, MicroAppStateActions } from 'qiankun'

const state = {
  userId: 10008611
}

// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state)
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log('变更前:', state, '变更后:', prev)
})
actions.setGlobalState(state)
// 关闭状态的接口
// actions.offGlobalStateChange()

修改app.tsx文件

// app.tsx
import React, { useEffect } from 'react'
import { registerMicroApps, start, initGlobalState, MicroAppStateActions } from 'qiankun'
import { HashRouter, Routes, Route } from 'react-router-dom'
import HomePage from './page/home'
import Home2Page from './page/home2'
import QiankunPage from './page/qiankun'

export function hashPrefix (prefix:string) {
  if (!prefix || prefix === '') {
    return () => true
  }
  return function (location:any) {
    // 使用的hash的形式对路由进行加载,前面会存在#所以替换之后,对路径进行判断,是相应的路径就返回ture
    const path:string = location.hash.replace('#', '')
    return prefix === path || path.includes(prefix + '/')
  }
}

const qiankunProject = [
  {
    name: 'react app1', // app name registered
    entry: 'http://localhost:10087', // 子项目对应的地址, 打包之后存放对应的网址
    container: '#sub-container', // 容器的ID(qiankun.tsx界面中生命的)
    activeRule: hashPrefix(`/app1`) // 判断是否加载
  },
  {
    name: 'react app2', // app name registered
    entry: 'http://localhost:10088',
    container: '#sub-container',
    activeRule: hashPrefix(`/app2`)
  }
]

const state = {
  userId: 10008611
}

export default () => {
  useEffect(() => {
    // 初始化 state
    const actions: MicroAppStateActions = initGlobalState(state)
    actions.onGlobalStateChange((state, prev) => {
      // state: 变更后的状态; prev 变更前的状态
      console.log('变更前:', state, '变更后:', prev)
    })
    actions.setGlobalState(state)
    // 关闭状态的接口
    // actions.offGlobalStateChange()

    registerMicroApps(qiankunProject, {
      beforeLoad: async app => console.info(`[${app.name}]:before load`),
      beforeMount: [async app => console.info(`[${app.name}]:before mount`)],
      afterMount: async app => console.info(`[${app.name}]:after mount`),
      beforeUnmount: async app => console.info(`[${app.name}]:before unmount`),
      afterUnmount: async app => console.info(`[${app.name}]:after unmount`)
    })
    start()
  }, [])


  return <HashRouter>
    <Routes>
      <Route path="/" element={<HomePage />}>
        <Route path='app1' element={<QiankunPage />} />
        <Route path='app2' element={<QiankunPage />} />
      </Route>
      <Route path="/home" element={<Home2Page />}/>
    </Routes>
  </HashRouter>
}

子应用中接收状态

当加载子应用之后我们可以在mount函数的子应用中获取到onGlobalStateChangesetGlobalState的函数

image.png

// index.tsx
export async function mount (props:any) {
  console.log('mount', props)
  props.onGlobalStateChange((state:any, prev:any) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev)
  })
  props.setGlobalState(state)
  render(props)
}

为了便于子项目的状态进行一个操作获取管理,可以使用redux、mobx等状态的管理的工具进行管理。父应用和子应用中的状态需要进行抽离出来,进行管理便于其他组件或这界面中去掉用。

结束

虽然只是写了一个大概,可能也是有点懒了,父应用和子应用之间通信的更多的细节就没有写了。说下思路和需要的场景吧。

我在实际项目中的实现的话,是把initGlobalState 生成的actions 单独抽离存放在一个文件中,导出了一个设置状态的函数, 同时在接收到又子应用状态更新的话,进行判断处理,是否值有更新等,这个可以用来处理子应用登录失效等,或者需要子应用与父应用的交互的时候进行使用。在子应用中,我用的浏览器的缓存进行存储的数据,子应用需要去交互的时候,写了一个订阅发布模式的函数在mount进行中监听,子应用的页面或组件中发起了订阅的事件,根据传递的参数去调用 props.setGlobalState(state); 设置值,去通知父应用。