打算在 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微服务原理
实现功能
-
不影响原项目的情况下,能够引入不同技术栈的前端项目
-
数据共享
-
通用方法、工具类等共享
……待补充……
项目架构
其他很多 qiankun 项目实践中“一个基座应用,若干个微应用”的项目结构,但我因为是要在原有的 next.js 项目基础上引入qiankun,使其具有兼容其他前端项目的能力,但是不影响项目原有的功能,所以于我的项目而言,“基座”和原 next.js 的项目可能更像是“主从”关系?可以说“基座”是原项目提供出来的一种能力。
大致的架构图如下:
基座配置
在主应用中安装 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)需要做的事情有:
- 新增
public-path.js
文件,用于修改运行时的publicPath
。什么是运行时的 publicPath ?。
注意:运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代。
- 微应用建议使用
history
模式的路由,需要设置路由base
,值和它的activeRule
是一样的。 - 在入口文件最顶部引入
public-path.js
,修改并导出三个生命周期函数。 - 修改
webpack
打包,允许开发环境跨域和umd
打包。
无 webpack
构建的微应用直接将 lifecycles
挂载到 window
上即可。
react 子应用
以 create react app
生成的 react 17
项目为例,搭配 react-router-dom
5.x。
- 在
src
目录新增public-path.js
:
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 设置
history
模式路由的base
:
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
- 入口文件
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();
- 修改
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
项目为例。
- 在
src
目录新增public-path.js
:
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 入口文件
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;
}
- 在根目录创建
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
项目为例
- 在
src
目录新增public-path.js
:
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
- 入口文件
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
}
]
})
- 修改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子应用配置好了,如果router
和vuex
不需用到,可以选择性去掉。
数据共享
通用方法、工具类等共享
有些方法或工具类是所有子应用都需要用到的,解决资源共用的问题,可以提高项目的维护性,不然多个系统共用的组件或者工具维护起来很费力,所以抽取公共代码到一处是必要的一步。要让子应用能够使用公共代码,需要让这些公共方法,工具类发布成一个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 官网文档。
还有一种情况也会报这个错,微应用为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?