next.js + qiankun 项目实践

打算在 next.js 项目中集成 qiankun 这个微前端框架了。╮( ̄▽ ̄)╭

qiankun

按照惯例,先说说啥是 qiankun

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

qiankun 的特性

  • 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 技术栈无关,任意技术栈的应用均可 使用/接入
  • HTML Entry 接入方式
  • 样式隔离,确保微应用之间样式互相不干扰。
  • JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • 资源预加载
  • 提供 umi 插件

更多的信息就出门右转 qiankun官网

为什么用qiankun

其实 next.js 本身就具备一定微前端的能力,在 next.config.js 中提供了 rewrites 配置,可以将前端路由请求代理到另外一台 next.js 服务器,同时保持前端路由状态。

那为什么还要用qiankun?最主要是因为 nextjs 微服务架构对子应用的技术栈有限制,要实现微前端,所有的子应用都应该使用 nextjs,所以不适合“能够引入不同技术栈的前端项目需求。

更多 next.js 微前端相关可看 nextjs微服务原理

实现功能

  1. 不影响原项目的情况下,能够引入不同技术栈的前端项目

  2. 数据共享

  3. 通用方法、工具类等共享

    ……待补充……

项目架构

其他很多 qiankun 项目实践中“一个基座应用,若干个微应用”的项目结构,但我因为是要在原有的 next.js 项目基础上引入qiankun,使其具有兼容其他前端项目的能力,但是不影响项目原有的功能,所以于我的项目而言,“基座”和原 next.js 的项目可能更像是“主从”关系?可以说“基座”是原项目提供出来的一种能力。

大致的架构图如下:

项目架构.jpg

基座配置

在主应用中安装 qiankun

$ yarn add qiankun # 或者 npm i qiankun -S

把基座封装成一个组件,当成组件来使用。需要注意的是因为有服务端渲染,所以会报错 window undefined 的问题,所以 qiankun 的引用需要在useEffect中才可以。

在主应用中注册微应用,大概就是如下:

import React, { useEffect } from 'react';
import OSBasePage from 'components/pages/BasePage/OSBasePage';

const Micro = () => {
  useEffect(() => {
    if (typeof window !== 'undefined') {
      const {
        registerMicroApps,
        start,
      } = require('qiankun');

      registerMicroApps(
        [
          {
            name: 'react',
            entry: '//localhost:3001',
            container: '#Micro',
            activeRule: '/micro/react',
          },
        ],
      );

      start();
    }
  }, []);

  return (
    <OSBasePage>
      <div id="Micro" />
    </OSBasePage>
  );
};

export default Micro;

对于微应用的注册可以抽取出来,后面通过请求来实现微应用的动态注册。

微应用配置

微应用不需要额外安装任何其他依赖即可接入 qiankun 主应用。

微应用分为有 webpack 构建和无 webpack 构建项目,有 webpack 的微应用(主要是指 Vue、React、Angular)需要做的事情有:

  1. 新增 public-path.js 文件,用于修改运行时的 publicPath什么是运行时的 publicPath ?

注意:运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代。

  1. 微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。
  2. 在入口文件最顶部引入 public-path.js,修改并导出三个生命周期函数。
  3. 修改 webpack 打包,允许开发环境跨域和 umd 打包。

webpack 构建的微应用直接将 lifecycles 挂载到 window 上即可。

react 子应用

create react app 生成的 react 17 项目为例,搭配 react-router-dom 5.x。

  1. src 目录新增 public-path.js
if (window.__POWERED_BY_QIANKUN__) {
	// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 设置 history 模式路由的 base
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
  1. 入口文件 index.js 修改,为了避免根 id #root 与其他的 DOM 冲突,需要限制查找范围。
import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';


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


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


export async function bootstrap() {
console.log('[react17] react app bootstraped');
}


export async function mount(props) {
console.log('[react17] props from main framework', props);
render(props);
}


export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}

如果用的是 react typescript 的模板,则修改index.tsx文件为

// public-path必须在最上面,不然图片路径会报错
import './public-path.js'
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

declare global {
    interface Window {
        __POWERED_BY_QIANKUN__: string
    }
}

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

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

export async function bootstrap() {
    console.log('[react17] react app bootstraped');
}

export async function mount(props: any) {
    console.log('[react17] props from main framework', props);
    render(props);
}

export async function unmount(props: any) {
    const {container} = props;
    ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}

reportWebVitals();
  1. 修改 webpack 配置

安装插件 @rescripts/cli,当然也可以选择其他的插件,例如 react-app-rewired

yarn add -D @rescripts/cli

根目录新增 .rescriptsrc.js

const { name } = require('./package');

module.exports = {
  webpack: (config) => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';

    return config;
  },

  devServer: (_) => {
    const config = _;

    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    config.historyApiFallback = true;
    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;

    return config;
  },
};

修改 package.json

-   "start": "react-scripts start",
+   "start": "rescripts start",
-   "build": "react-scripts build",
+   "build": "rescripts build",
-   "test": "react-scripts test",
+   "test": "rescripts test",
-   "eject": "react-scripts eject"

如果用的是react-app-rewired,则

yarn add -D react-app-rewired

根目录新增 config-overrides.js

const { name } = require('./package.json');

module.exports = {
  webpack: function override(config) {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';

    return config;
  },

  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.open = false;
      config.hot = false;
      config.headers = {
        'Access-Control-Allow-Origin': '*',
      };
      return config;
    };
  },
};

修改 package.json

  "scripts": {
-   "start": "react-scripts start",
+   "start": "react-app-rewired start",
-   "build": "react-scripts build",
+   "build": "react-app-rewired build",
-   "test": "react-scripts test",
+   "test": "react-app-rewired test",
    "eject": "react-scripts eject"
}

如果用的是run eject弹出webpack配置的方式,那么在 config/webpackDevServer.config.js 中配置:

module.exports = function(proxy, allowedHost) {
  return {
    hot: false,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
    watchContentBase: false,
    liveReload: false
  }
}

config/webpack.config.js中配置:

const packageName = require('./package.json').name;

module.exports = function(webpackEnv) {
  return {
    output: {
      library: `${packageName}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${packageName}`,
    }
  }
}

到这里,react 的配置就算完成了。

vue 子应用

vue-cli 3+ 生成的 vue 2.x 项目为例。

  1. src 目录新增 public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 入口文件 main.js 修改,为了避免根 id #app 与其他的 DOM 冲突,需要限制查找范围。
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;

let router = null;
let instance = null;
function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
  1. 在根目录创建vue.config.js文件,用来修改打包配置:
const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

vue-cli 2生成的vue 2.x项目为例

  1. src 目录新增 public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. 入口文件 main.js 修改,为了避免根 id #app 与其他的 DOM 冲突,需要限制查找范围。
import './public-path.js'
import Vue from 'vue'
import App from './App'
import router from './router'

Vue.config.productionTip = false

let instance = null
function render (props = {}) {
  const { container } = props

  instance = new Vue({
    router,
    store,
    render: (h) => h(App)
  }).$mount(container ? container.querySelector('#app') : '#app')
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap () {
  console.log('[vue] vue app bootstraped')
}
export async function mount (props) {
  console.log('[vue] props from main framework', props)
  render(props)
}
export async function unmount () {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
}

修改router文件

import '../public-path.js'
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'

Vue.use(Router)

export default new Router({
  base: window.__POWERED_BY_QIANKUN__ ? '/micro/app-vue/' : '/',
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld
    }
  ]
})
  1. 修改webpack配置,当vue.config.js没有生效的时候,选择进行如下修改:

修改build/webpack.dev.conf.js

  devServer: {
    ......
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },

修改build/webpack.base.conf.js

const { name } = require('./package');
module.exports = {
  output: {
    library: `${name}-[name]`,
    libraryTarget: 'umd', // 把微应用打包成 umd 库格式
    jsonpFunction: `webpackJsonp_${name}`,
    ......
  },
}

基础版本的vue子应用配置好了,如果routervuex不需用到,可以选择性去掉。

数据共享

通用方法、工具类等共享

有些方法或工具类是所有子应用都需要用到的,解决资源共用的问题,可以提高项目的维护性,不然多个系统共用的组件或者工具维护起来很费力,所以抽取公共代码到一处是必要的一步。要让子应用能够使用公共代码,需要让这些公共方法,工具类发布成一个npm私有包。

发布为一个npm私包,npm私包有以下几种组织形式:

  • npm指向本地file地址:npm install file:../common。直接在根目录新建一个common目录,然后npm直接依赖文件路径。
  • npm指向私有git仓库: npm install git+ssh://xxx-common.git
  • 发布到npm私服。

还有一种方式是用git库引入, 在package.json的依赖中加入”qiankun-common”:”git+https://git@github.com:czero1995/qiankun-common.git”

遇到的问题

TypeError: application ‘react’ died in status LOADING SOURCE CODE: Failed to fetch

这个问题可能是网络不通或者是跨域被阻止了,如果是开发环境,检查一下微应用是否已启动,是否做了跨域设置。

webpack 跨越设置,在 devServer 中设置头部 headers"Access-Control-Allow-Origin": "*"

module.exports = {
  devServer: (_) => {
    const config = _;

    // 重点
    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    
    config.historyApiFallback = true;
    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;

    return config;
  },
};

或者是微应用引入了第三方静态资源时跨域导致

解决参考:

Uncaught TypeError: application ‘sub-app’ died in status LOADING_SOURCE_CODE: Failed to fetch #1331

微应用静态资源一定要支持跨域吗?

Error: application ‘react’ died in status LOADING_SOURCE_CODE: [qiankun]: You need to export lifecycle functions in react entry

qiankun 抛出这个错误是因为无法从微应用的 entry js 中识别出其导出的生命周期钩子。

检查微应用是否已经导出相应的生命周期钩子。检查微应用的 webpack 是否增加了指定的配置。其他一些解决方法见 qiankun 官网文档。

解决参考:Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry

还有一种情况也会报这个错,微应用为react,修改微应用之后,主应用报错,需要关掉热重载,如果使用的是react-scripts-rewired修改webpack配置,则改:

const { name } = require('./package.json');

module.exports = {
  webpack: function override(config, env) {
    // 重点
    config.entry = config.entry.filter(
      (e) => !e.includes('webpackHotDevClient')
    );
    
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';
    
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.open = false;
      config.hot = false;
      config.headers = {
        'Access-Control-Allow-Origin': '*',
      };
      return config;
    };
  },
};

解决参考:子项目为create-react-app,修改任意后,主项目报错

主应用中的微应用图片或者静态资源404

原因是 webpack 加载资源时未使用正确的 publicPath。

我自己的原因是entry文件引用public-path.js的时候没有置于最前。参考上面react子应用的配置。

或者自己添加环境变量修改 publicPath。

还有一些其他原因导致的图片加载不出来,整理了下链接,放下面👇

解决参考:为什么微应用加载的资源会 404?

添加自定义环境变量

子应用的图片资源加载不出来 #36

子应用为老项目,相对路径的图片加载问题 #619

一些相关链接

qiankun的react子项目引入less支持

qiankun的react子项目引入antd

参考资料

qiankun

qiankun项目实践和优化(React+Vue)

微前端qiankun从搭建到部署的实践

nextjs微服务原理