微前端qiankun手把手实践

3,263 阅读12分钟

遇到公司的巨石项目时不要慌,微前端或许是一个很好的很提案,很多小伙伴的公司或许都有自己的微前端的解决方案,如果没有,希望这篇文章能帮到你,下次再做公司巨石项目开发时,你可以尝试一下,让你公司的项目代码不再像个庞然大物,维护起来也可以心情舒畅,不至于打开项目代码瑟瑟发抖,如果你刚好还没了解微前端,那这篇文章你一定值得你看完

这里我们主要是分享由蚂蚁团队开源的qiankun,一个目前比较成熟的微前端的解决方案。笔者保姆心,多出来的废话请谅解

1. 什么是微前端?为什么要用微前端?

微前端的概念值的是将一个大型项目拆分成多个子项目,每个子项目各自独立,互不影响,又互相关联。我们举一个例子,假设说你们公司需要做一个医疗相关的后台系统,假设这个后台系统中需要实现 科、教、研三个模块。首页好比如下图这样

image.png

点击各自的模块进入系统后的功能都已尽相同,这种情况下不管你用的是 vue 还是 react,这个项目最终的代码量都是会比较庞大的,庞大的项目体积给我们带来的困扰一个是运行速度慢(说ssr的同学同统一当做杠精处理),打包速度慢,打开项目看着代码头皮发麻(我不相信只有我会这么觉得...),那么,试想一下,如果我们能把 科、教、研,模块,各写成一个项目,再像个办法把这三个项目都整合成一个项目,那么开始起来岂不是快乐 ✖️3?好了你理解微前端概念主要是干什么的了

那么微前端架构的核心价值是什么呢:

  • 技术栈无关
    主框架不限制接入应用的技术栈,微应用具备完全自主权

  • 独立开发、独立部署
    微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 增量升级
    在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时
    每个微应用之间状态隔离,运行时状态不共享

如果能做到这几点,嘿嘿嘿,想想就开心不是吗

2. 如何将vue微应用集成到主应用中

前面说到,将多个大模块分割成独立的应用,那么如何将多个子应用再整合起来,这里我们就用qiankun来实践一波

动手之前,我们先假设我们有一个 base 项目,作为主应用,一个 m-react 作为子应用,另一个 m-vue作为另一个子应用。\

于是,我们去创建一个项目结构

image.png

这里的 base 我用的脚手架 npx create-react-app base 创建的一个react默认18版本的项目作为要整合资源的主项目,这里 17, 18 无所谓

create-react-app m-react 创建的react17版本做为子应用(注意react的版本,这里使用最新的18版本会有个别问题,我还在努力看react18的文档,暴风哭泣...)

vue create m-vue 创建的vue3的项目最为子应用,你要是用习惯了vue2也没关系,自己知道哪里可以改webpack的配置就行

好了,一主二子三个项目创建好了,接下来就是如何实现将 m-reactm-vue整合到 base中。(前面提到微前端跟技术栈是没有关系的哈)

第一步 来到 base 下,先安装一个路由和乾坤,

npm i react-router-dom qiankun

再到根组件下写上这样一段代码

// base > src > App.js

import { BrowserRouter as Router, Link } from 'react-router-dom'

function App() {
  return (
    <div className="App">
      <Router>
        <Link to='/vue'>vue应用</Link>
        <Link to='/react'>react应用</Link>
      </Router>

      {/* 切换导航将微应用渲染到container容器中 */}
      <div id='container'></div>
      
    </div>
  );
}



export default App;

运行 base 效果:

image.png

现在我们希望当点击 vue应用 时,能访问 m-vue应用,点击 react应用 时能访问 m-react应用

这时就需要将这两个子应用都注册到 base 中,在 base > src 中创建 registerApps.js 文件

// 使用qiankun来注册应用

import { registerMicroApps, start } from 'qiankun' 

const loader = (loading) => {
  console.log(loading);
}

registerMicroApps([
  {
    name: 'm-vue',                 // 子应用的名称
    entry: '//localhost:8080',     // 子应用的访问地址
    container: '#container',       // 子应用应该挂载的位置(后面会解释)
    activeRule: '/vue',            // 在路径为xxx的时候让子应用渲染
    loader                         // loader是乾坤提供的一个类似于加载中的函数
  },
  {
    name: 'm-react',
    entry: '//localhost:3001',
    container: '#container',
    activeRule: '/react',
    loader
  }
], { // 乾坤为我们提供了一系列的生命周期函数,会在子应用加载前后生效
  beforeLoad: () => {
    console.log('子应用加载前');
  },
  beforeMount: () => {
    console.log('子应用挂载前');
  },
  afterMount: () => {
    console.log('子应用挂载后');
  },
  beforeUnmount: () => {
    console.log('子应用销毁前');
  },
  afterUnmount: () => {
    console.log('子应用销毁后');
  }
})

// 调用start用于启动子应用
start()

因为代码中有注释,就不进行一一讲解含义了,相信你能明白的

注册好了这两个子应用之后,我们将 registerApps.js 文件引入到 base > src > index.js 中

// base > src > index.js 删减了部分代码为了看着舒服

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './registerApps.js'       // +++++++++++

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <App />
);

再次启动 base 项目,点击 vue应用 效果:

image.png

路由来到了 http://localhost:3000/vue 但是现在在报错,因为我们的 m-vue 应用还没有启动,所以现在在 base 中注册 m-vue 是不生效的,那么我们先来 m-vue 应用中开发点东西

3. 子应用的开发

因为我们在 base 中注册的vue应用端口号是 8080,而vue脚手架默认启动的端口号就是8080,所以刚刚刚好,但是如果你想修改端口号的话,就要改掉 上文中entry: '//localhost:8080', // 子应用的访问地址处的端口,并且修改 m-vue应用 中的配置,在 m-vue项目根目录下创建 vue.config.js并写下

// m-vue > vue.config.js

module.exports = {
  publicPath: 'http://localhost:8080', // 保证子应用静态资源都是向8080端口发送的,此处的端口号和上文中注册的端口号保持一致
  devServer: {
    port: 8080, // 启动的端口号也保持一致
    headers: {
      'Access-Control-Allow-Origin': '*'  // 因为乾坤会用fetch请求的方式在3000端口的base应用中访问8080的m-vue,所以存在跨域,这里操作允许跨域
    }
  },
  configureWebpack: { // 需要获取我打包的内容
    output: {
      libraryTarget: 'umd',  // 打包格式
      library: 'm-vue'  // 打包成umd格式会挂载到window上,名为m-vue
    }
  }
}

m-vue的配置项注释里面都有解释了,那么接下,qiankun需要我们要做的就是在m-vue这个子应用中导出相应的生命周期钩子

// m-vue > src > main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')


// +++++需要暴露接入协议
export async function bootstrap() {
  console.log('vue3 app bootstraped');
}
export async function mount(props) {
  console.log('vue3 app mount');
}
export async function unmount() {
  console.log('vue3 app unmount');
}

这里我们已经提前去装好vue-router并且配好了两个简单的页面(没装的自己装一下),关于路由的配置我们不去做过多的阐述了,相信你自己,这里直接放上路由的代码吧(页面自行创建哈)

// m-vue > src > router > index.js

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/About.vue')
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

再在App.vue中加上

// m-vue > src > App.vue

<template>
  <div>
    <router-link to="/">Home</router-link>|
    <router-link to="/about">About</router-link>

    <router-view></router-view>
  </div>
</template>

启动 m-vue应用后:

image.png

好的,子应用本身的运行时没有问题的,再来到 base应用的启动界面看看

image.png

俨然已经可以看到在 base主应用中,控制告诉了我们,当前这个m-vue的子应用已经挂载了。

这里需要解释一下:
子应用中的三个生命周期 bootstrap, mount, unmount都是qiankun内定的,我们只需要暴露出来这三个这样名称的函数,它们便会和主应用中的生命周期整合在一起生效

好了,还没完,我们需要挂载vue应用到 base中,在我们不断的点击页面切换子应用的过程中就涉及到了子应用的挂载和卸载,那么我们需要在qiankun的生命周期 mount 中,来实现子应用自己的挂载,接下来修改main.js

// m-vue > src > main.js

import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router' // 将路由的创建拿出来,每次子应用被切换的时候路由也会重新加载
import App from './App.vue'
import routes from './router'


let history;
let router;
let app;
function render (props = {}) {
  history = createWebHistory('/vue')
  router = createRouter({
    history,
    routes
  }),
  app = createApp(App)
  let { container } = props
  app.use(router).mount(container ? container.querySelector('#app') : '#app') // 子应用在被注册到主应用中的时候,我们希望将子应用最后打包的代码挂载到主应用的DOM结构中,当其自己独立运行成项目时挂载到自己的'#app'中
}

// 乾坤在渲染前给我提供了一个变量 window.__POWERED_BY_QIANKUN__
if (!window.__POWERED_BY_QIANKUN__) { // 该应用没有加入到父应用中,独立运行
  render()
}


// 需要暴露接入协议
export async function bootstrap() {
  console.log('vue3 app bootstraped');
}
export async function mount(props) { // 参数props包含了主应用中的注册信息
  console.log('vue3 app mount', props);
  render(props)
}
export async function unmount() {
  console.log('vue3 app unmount');
  history = null // 当子应用被卸载后我们将路由等全部清空
  app = null
  router = null
}

并且修改路由配置文件,删除多余部分

import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/About.vue')
  }
]

export default routes

修改后我们单独启动m-vue子应用

image.png

ok!没有问题,那么再来运行 base主应用看看

image.png

一样ok!此时可以轻叹一声 奶思!!

这样我们就已经成功的将 m-vue子应用成功的整合到了 base主应用中了,那么接下来我们继续将另一个m-react子应用也整合到 base 中

3. 如何将react微应用集成到主应用中

来到m-react项目中,同样的第一步,我们需要先修改 m-react子应用(或者称微应用)的配置文件,修改react配置文件的方式有很多,要么执行 npm run eject暴露出webpack的配置,但是这样暴露出来的配置文件太多,我们选择一个轻便点的,安装插件 npm i @rescripts/cli -D,并在m-react根目录创建 .rescriptsrc.js 配置文件

// m-ract > .rescriptsrc.js

module.exports = {
  webpack: (config) => {
    config.output.library = 'm-react'
    config.output.libraryTarget = 'umd'
    config.output.publicPath = 'http://localhost:3001/'
    return config
  },
  devServer: (config) => {
    config.headers = {
      'Access-Control-Allow-Origin': '*'
    }
    return config
  }
}

这里的配置和vue中的配置相同不一样的知识语法而已,语法是插件决定的,我们不做过多阐述

另外react项目,我们希望它能运行在 3001 端口,在react中要修改项目运行的端口号和vue差别较大,我们需要在 m-react根目录下创建 .env 文件

m-react > .env

PORT=3001
WDS_SOCKET_PORT=3001

最后,因为我们使用插件 rescripts重写了项目配置,所以我们需要将 package.json 中的项目启动命令修改成这样

// m-react > package.json 
// 省略部分代码...

"scripts": {
    "start": "rescripts start",
    "build": "rescripts build",
    "test": "rescripts test",
    "eject": "rescripts eject"
  },

完成这些工作后,启动 m-react应用你可以看到:

image.png

m-react应用单独运行没有问题,那么我们再来看看 base主应用中的效果

image.png

我猜你已经知道了问题在哪里了,我们还没有导出子应用中的 三个生命周期呢!搞定它

// m-react > src > index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';


function render(props = {}) {
  let { container } = props
  ReactDOM.render(<App />, container ? container.querySelector('#root') : document.getElementById('root'));
}

if (!window.__POWERED_BY_QIANKUN__) {
  render()
}


export async function bootstrap() {

}
export async function mount(props) {
  render(props)
}
export async function unmount(props) {
  let { container } = props
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.getElementById('root'))
}

这里的执行逻辑和vue中的一样,我们直接看主应用中的效果吧:

这是个题外话,问个问题
ReactDOM.unmountComponentAtNode() 在react17中存在这样一个卸载函数,更新到18版本后这里这个卸载函数没了,所以文章开始就强调了注意版本,有知道18版本中怎么写的小伙伴欢迎指点一下

image.png

它也成功出现了,你现在分别去点击 vue应用 和 react应用试试,两个子应用的切换没有问题,这就大功告成了!!

4. 样式隔离问题

到这里真的完成了吗?还没有

image.png

现在页面上这两个nav导航的样式居然会随着应用的切换而变化位置,这个问题也是微前端设计中最常见的问题,主应用和子应用样式冲突

那么这里qiankun当中目前采取的解决方案是一个css-module式的通过加前缀的方式来隔离样式

我们只需要在 base主应用中的registerApps.js文件中修改

// base > registerApps.js

start({
  sandbox: {
    experimentalStyleIsolation: true
  }
})

目前这个方案还是试用型的,将来可能会被修改掉,好了,我们再看页面效果,刚刚的nav导航的样式就没有问题了,你也可以在控制台看到,它是通过

image.png 给页面标签添加属性再样式中靠这个属性充当前缀的方式来实现样式的隔离

到这里,我们就完成了在一个react项目中集成两个子应用的效果了,你是不是也觉得微前端很有意思 >_<

写在最后

文章参考

qiankun官网

珠峰架构