问题引入
最近在公司遇到了一个需求,别的团队的同事想将他们用 React
编写的工程作为子系统集成到我们已有的系统中,React
工程是基于 umi
框架编写的,我们的主系统是基于 jquery
框架实现的。其实他们本来是已经实现了 React
作为子系统集成到我们的主系统中的,但是他们是借助于 iframe
实现页面嵌入的,后来因为用户体验不佳、存在安全性问题等因素而不得不放弃这种方式的集成了。
分析了一下他们的需求,其实就是一个微前端的需求,即将业务拆分成多个子系统,每个子系统可以独立开发,开发完毕后会作为一个个子模块被集成到主系统中。
思考及实现
关于微前端的解决方案有很多,比方说使用 iframe
隔离运行时、Single-SPA
等,但是因为安全性和时间性的要求,能否提供一种快速便捷的方案解决子系统的集成问题呢?
考虑到子系统是基于 umi
编写的,就想到了能否借助于 umi
提供的强大的插件机制,通过编写 umi
插件来扩展项目的编译时和运行时的能力?
我们知道,umi dev
的时候,会生成 src/pages/.umi
临时目录,里面包含 umi.js
和 router.js
等临时文件,其中的 .umi.js
文件就是编译之后生成的入口文件。看下这个文件:
...
let clientRender = async () => {
window.g_isBrowser = true;
let props = {};
// Both support SSR and CSR
if (window.g_useSSR) {
// 如果开启服务端渲染则客户端组件初始化 props 使用服务端注入的数据
props = window.g_initialData;
} else {
const pathname = location.pathname;
const activeRoute = findRoute(require('@@/router').routes, pathname);
// 在客户端渲染前,执行 getInitialProps 方法
// 拿到初始数据
if (
activeRoute &&
activeRoute.component &&
activeRoute.component.getInitialProps
) {
const initialProps = plugins.apply('modifyInitialProps', {
initialValue: {},
});
props = activeRoute.component.getInitialProps
? await activeRoute.component.getInitialProps({
route: activeRoute,
isServer: false,
location,
...initialProps,
})
: {};
}
}
const rootContainer = plugins.apply('rootContainer', {
initialValue: React.createElement(require('./router').default, props),
});
ReactDOM[window.g_useSSR ? 'hydrate' : 'render'](
rootContainer,
document.getElementById('root'),
);
};
const render = plugins.compose(
'render',
{ initialValue: clientRender },
);
...
// client render
if (__IS_BROWSER) {
Promise.all(moduleBeforeRendererPromises)
.then(() => {
render();
})
.catch(err => {
window.console && window.console.error(err);
});
}
...
可以看到,编译结束后,会去调用render方法,最终通过:
ReactDOM[window.g_useSSR ? 'hydrate' : 'render'](
rootContainer,
document.getElementById('root'),
);
将虚拟Dom渲染到指定的id容器上。受此启发,那么我们能不能将此render方法挂载到window对象上呢,在主系统中通过调用此方法,将子系统的虚拟Dom渲染到主系统中指定的Dom容器中呢?这样,只要在主系统中引入编译后的子系统的js和css资源文件,就可以直接通过window上挂载的指定方法来实现子系统集成到主系统中。
于是,现在问题就转化为了通过umi的插件,来修改render方法,将render方法提供出来,供主系统调用。
Ok,既然有思路了,就赶紧查看了下 umi
的插件开发文档。
umi
的所有插件接口都是通过初始化插件时候的 api 来提供的。分为如下几类:
- 环境变量,插件中可以使用的一些环境变量
- 系统级变量,一些插件系统暴露出来的变量或者常量
- 工具类 API,常用的一些工具类方法
- 系统级 API,一些插件系统暴露的核心方法
- 事件类 API,一些插件系统提供的关键的事件点
- 应用类 API,用于实现插件功能需求的 API,有直接调用和函数回调两种方法
系统级 API 中提供了一个 modifyEntryRender 方法,可以实现对entryRender方法的修改。
通过create-umi命令,生成一个umi插件的模版,然后就可以开发插件了。
src/index.js
import { writeFileSync } from 'fs-extra';
import { join } from 'path';
const writeFile = (text, outputPath) => {
writeFileSync(outputPath, text, { encoding: 'utf8' })
}
const generateManifestCode = (manifest) => {
return `
(function (window, factory) {
if (typeof exports === 'object') {
module.exports = factory();
} else if (typeof define === 'function' && define.amd) { // eslint-disable-line
define(factory); // eslint-disable-line
} else {
window.assetManifest = factory(); // eslint-disable-line
}
})(this, function () {
return [${manifest.map(item => `'${item}'`).join(',')}]
});
`
}
export default function (api, options) {
const { integrateName, fileList = [] } = options;
const { paths } = api;
const { absOutputPath } = paths;
api.addEntryCode(`
window['${integrateName}'] = {};
window['${integrateName}'].render = function(selector) {
if (__IS_BROWSER) {
Promise.all(moduleBeforeRendererPromises)
.then(() => {
render().then(result => { result(selector)});
})
.catch(err => {
window.console && window.console.error(err);
});
}
}
`);
api.modifyEntryRender(() => {
return `
window.g_isBrowser = true;
let props = {};
// Both support SSR and CSR
if (window.g_useSSR) {
// 如果开启服务端渲染则客户端组件初始化 props 使用服务端注入的数据
props = window.g_initialData;
} else {
const pathname = location.pathname;
const activeRoute = findRoute(require('@@/router').routes, pathname);
// 在客户端渲染前,执行 getInitialProps 方法
// 拿到初始数据
if (
activeRoute &&
activeRoute.component &&
activeRoute.component.getInitialProps
) {
const initialProps = plugins.apply('modifyInitialProps', {
initialValue: {},
});
props = activeRoute.component.getInitialProps
? await activeRoute.component.getInitialProps({
route: activeRoute,
isServer: false,
location,
...initialProps,
})
: {};
}
}
const rootContainer = plugins.apply('rootContainer', {
initialValue: React.createElement(require('./router').default, props),
});
return function(selector){
ReactDOM.render(
rootContainer,
document.getElementById(selector),
);
}
`
});
api.onBuildSuccess(() => {
let outputFileList = [...fileList];
const manifestText = generateManifestCode(outputFileList);
writeFile(manifestText, join(absOutputPath, 'asset-manifest.js'));
});
}
该插件做了以下几件事:
-
addEntryCode
方法里面将render方法挂载到了window
对象的integrateName对象上,integrateName是由插件的参数传入的,需要和主系统约定好。 -
modifyEntryRender
方法重写了clientRender
方法, 最后返回一个function
:return function(selector) { ReactDOM.render(rootContainer, document.getElementById(selector)); };
这么写的目的有两个,一个是防止原来的
render
方法去调用clientRender
的时候直接将虚拟 Dom 渲染了出来;第二个是目的是返回一个函数,方便集成的时候调用传参。 - 最后在
onBuildSuccess
方法里面会根据插件的fileList
参数将编译之后的资源文件传入,在dist目录下生成一个asset-manifest.js
文件,这样在主系统中可以直接通过加载asset-manifest.js
文件就可以加载到所有静态资源了。
在umi子工程的 .umirc.js
中配置好插件,并安装 umi-integrate-plugin
包:
plugins: [
// ref: https://umijs.org/plugin/umi-plugin-react.html
['umi-plugin-react', {
antd: true,
dva: true,
dynamicImport: { webpackChunkName: true },
title: 'umi-app',
dll: true,
locale: {
enable: true,
default: 'en-US',
},
routes: {
exclude: [
/models\//,
/services\//,
/model\.(t|j)sx?$/,
/service\.(t|j)sx?$/,
/components\//,
],
},
}],
['umi-integrate-plugin', {
integrateName: 'gcc',
fileList: [
'/umi.js',
'/umi.css',
]
}]
],
同时需要在 .umirc.js
文件中将路由切换为 memory
路由:
export default {
history: 'memory',
}
开启缓存路由的目的是为了防止子工程集成进主工程之后,子工程路由的切换会影响主工程的路由。
接着执行$ npm run build
命令,cd
到 dist
目录下通过 http-server
启动一个静态服务,打开浏览器访问静态服务地址,在控制台输入 window.gcc.render('root')
就可以看到子工程被渲染出来了。
主工程中我们可以借助于 loaderjs
来加载 asset-manifest.js
文件,获取到子工程的 js 和 css 文件。
const loadjs = require('loadjs');
const cdnUrl = 'http://localhost:8080';
loadjs(`${cdnUrl}/asset-manifest.js`, () => {
const assetManifest = (window as any).assetManifest;
const jsReg = /\.(.js)$/;
const cssReg = /\.(.css)$/;
const jsFileList = [];
const cssFileList:any = [];
assetManifest.forEach(item => {
if (jsReg.test(item)) {
jsFileList.push(`${cdnUrl}${item}`);
}
if (cssReg.test(item)) {
cssFileList.push(`${cdnUrl}${item}`);
}
});
loadjs([...jsFileList, ...cssFileList], {
success: () => {
(window as any).gcc.render('root');
},
async: false,
});
});
存在的问题
当然这种集成方式还是会存在很多不足的地方,比方说:
集成多个 umi
工程的时候,每个工程都需要打包一次,多个工程有很多第三方的包其实是相同的,但是每个工程都需要将这些包打包引入,造成很多冗余。
其次,如果多个子 umi
工程都使用来dva,集成之后 dva
的 store
是共享的,容易造成多个子工程的 store
数据互相污染,这就需要在开发的时候进行约定好,确保 namespace
不能重复。
源码大家可以参考这里
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。