diff --git a/.fatherrc.js b/.fatherrc.js index f0d53670..6ceb55cb 100644 --- a/.fatherrc.js +++ b/.fatherrc.js @@ -19,6 +19,7 @@ const headPkgs = [ "fes-plugin-jest", "fes-plugin-vuex", "create-fes-app", + "fes-plugin-qiankun" ]; const tailPkgs = []; // const otherPkgs = readdirSync(join(__dirname, 'packages')).filter( diff --git a/packages/fes-plugin-layout/src/index.js b/packages/fes-plugin-layout/src/index.js index 99325149..860ac017 100644 --- a/packages/fes-plugin-layout/src/index.js +++ b/packages/fes-plugin-layout/src/index.js @@ -79,6 +79,7 @@ export default (api) => { api.addRuntimePlugin(() => `@@/${absRuntimeFilePath}`); // 把BaseLayout插入到路由配置中,作为根路由 + // TODO: fes缺少修改路由API api.modifyRoutes(routes => [ { path: '/', diff --git a/packages/fes-plugin-qiankun/LICENSE b/packages/fes-plugin-qiankun/LICENSE new file mode 100644 index 00000000..0978fbf7 --- /dev/null +++ b/packages/fes-plugin-qiankun/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present webank + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/fes-plugin-qiankun/package.json b/packages/fes-plugin-qiankun/package.json new file mode 100644 index 00000000..3b8b27dc --- /dev/null +++ b/packages/fes-plugin-qiankun/package.json @@ -0,0 +1,39 @@ +{ + "name": "@fesjs/plugin-qiankun", + "version": "2.0.0-alpha.0", + "description": "@fesjs/plugin-qiankun", + "main": "lib/index.js", + "files": [ + "lib" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/WeBankFinTech/fes.js.git", + "directory": "packages/fes-plugin-qiankun" + }, + "keywords": [ + "fes" + ], + "author": "michaelxxie", + "license": "MIT", + "bugs": { + "url": "https://github.com/WeBankFinTech/fes.js/issues" + }, + "homepage": "https://github.com/WeBankFinTech/fes.js#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@umijs/utils": "3.3.3", + "address": "^1.1.2", + "path-to-regexp": "^6.2.0", + "qiankun": "2.3.4" + }, + "peerDependencies": { + "@webank/fes": "^2.0.0-alpha.0", + "vue": "^3.0.5" + } +} diff --git a/packages/fes-plugin-qiankun/src/index.js b/packages/fes-plugin-qiankun/src/index.js new file mode 100644 index 00000000..a7d24c4e --- /dev/null +++ b/packages/fes-plugin-qiankun/src/index.js @@ -0,0 +1,26 @@ +import { join } from 'path'; + +const namespace = 'plugin-qiankun'; + +export default (api) => { + api.describe({ + key: 'qiankun', + config: { + schema(joi) { + return joi.object().keys({ + mirco: joi.object(), + main: joi.object() + }); + } + } + }); + + api.registerPlugins([ + require.resolve('./main'), + require.resolve('./mirco') + ]); + + const absRuntimeFilePath = join(namespace, 'runtime.js'); + + api.addRuntimePlugin(() => `@@/${absRuntimeFilePath}`); +}; diff --git a/packages/fes-plugin-qiankun/src/main/index.js b/packages/fes-plugin-qiankun/src/main/index.js new file mode 100644 index 00000000..618a2b6e --- /dev/null +++ b/packages/fes-plugin-qiankun/src/main/index.js @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { + defaultHistoryMode, + defaultMainRootId, + testPathWithPrefix, + toArray +} from '../common'; + +export default function (api, options) { + const { registerRuntimeKeyInIndex = false } = options || {}; + + api.addRuntimePlugin(() => require.resolve('./runtime')); + + if (!registerRuntimeKeyInIndex) { + api.addRuntimePluginKey(() => 'qiankun'); + } + + api.modifyDefaultConfig(config => ({ + ...config, + mountElementId: defaultMainRootId, + disableGlobalVariables: true + })); + + // apps 可能在构建期为空 + const { apps = [] } = options || {}; + if (apps.length) { + // 获取一组路由中以 basePath 为前缀的路由 + const findRouteWithPrefix = (routes, basePath) => { + for (const route of routes) { + if (route.path && testPathWithPrefix(basePath, route.path)) { return route; } + + if (route.routes && route.routes.length) { + return findRouteWithPrefix(route.routes, basePath); + } + } + + return null; + }; + + const modifyAppRoutes = () => { + // TODO: fes缺少修改路由API + api.modifyRoutes((routes) => { + const { + config: { history: mainHistory = defaultHistoryMode } + } = api; + + const newRoutes = routes.map((route) => { + if (route.path === '/' && route.routes && route.routes.length) { + apps.forEach(({ history: slaveHistory = 'history', base }) => { + // 当子应用的 history mode 跟主应用一致时,为避免出现 404 手动为主应用创建一个 path 为 子应用 rule 的空 div 路由组件 + if (slaveHistory === mainHistory) { + const baseConfig = toArray(base); + + baseConfig.forEach((basePath) => { + const routeWithPrefix = findRouteWithPrefix(routes, basePath); + + // 应用没有自己配置过 basePath 相关路由,则自动加入 mock 的路由 + if (!routeWithPrefix) { + route.routes.unshift({ + path: basePath, + exact: false, + component: `() => { + if (process.env.NODE_ENV === 'development') { + console.log('${basePath} 404 mock rendered'); + } + + return React.createElement('div'); + }` + }); + } else { + // 若用户已配置过跟应用 base 重名的路由,则强制将该路由 exact 设置为 false,目的是兼容之前遗留的错误用法的场景 + routeWithPrefix.exact = false; + } + }); + } + }); + } + + return route; + }); + + return newRoutes; + }); + }; + + modifyAppRoutes(); + } + + const rootExportsFile = join(api.paths.absSrcPath, 'rootExports.js'); + + api.addTmpGenerateWatcherPaths(() => rootExportsFile); + + const namespace = 'plugin-qiankun'; + const absCoreFilePath = join(namespace, 'qiankunDefer.js'); + + api.onGenerateFiles(() => { + const { + config: { history = defaultHistoryMode } + } = api; + const rootExports = `window.g_rootExports = ${existsSync(rootExportsFile) ? 'require(\'@/rootExports\')' : '{}'};`.trim(); + + api.writeTmpFile({ + path: `${namespace}/qiankunRootExports.js`, + content: rootExports + }); + + api.writeTmpFile({ + path: `${namespace}/subAppsConfig.json`, + content: JSON.stringify({ + mainHistory: history, + ...options + }) + }); + + api.writeTmpFile({ + path: `${namespace}/qiankunDefer.js`, + content: ` + class Deferred { + constructor() { + this.promise = new Promise(resolve => this.resolve = resolve); + } + } + export const deferred = new Deferred(); + export const qiankunStart = deferred.resolve; + `.trim() + }); + + api.writeTmpFile({ + path: `${namespace}/runtime.js`, + content: readFileSync(join(__dirname, 'runtime.js'), 'utf-8') + }); + }); + + api.addPluginExports(() => [ + { + specifiers: ['qiankunStart'], + source: absCoreFilePath + } + ]); +} diff --git a/packages/fes-plugin-qiankun/src/main/runtime.js b/packages/fes-plugin-qiankun/src/main/runtime.js new file mode 100644 index 00000000..2a8ba764 --- /dev/null +++ b/packages/fes-plugin-qiankun/src/main/runtime.js @@ -0,0 +1,85 @@ +import { deferred } from '@@/plugin-qiankun/qiankunDefer.js'; +import '@@/plugin-qiankun/qiankunRootExports.js'; +import subAppConfig from '@@/plugin-qiankun/subAppsConfig.json'; +import { registerMicroApps, start } from 'qiankun'; +import { createApp, h } from 'vue'; +import { plugin, ApplyPluginsType } from '@@/core/coreExports'; +import { defaultMountContainerId, testPathWithPrefix, toArray } from '../common'; + +async function getMasterRuntime() { + const config = plugin.applyPlugins({ + key: 'qiankun', + type: ApplyPluginsType.modify, + initialValue: {}, + async: true + }); + const { master } = config; + return master || config; +} + +export async function render(oldRender) { + oldRender(); + function isAppActive(location, history, base) { + const baseConfig = toArray(base); + switch (history.type || history) { + case 'hash': + return baseConfig.some(pathPrefix => testPathWithPrefix(`#${pathPrefix}`, location.hash)); + case 'browser': + return baseConfig.some(pathPrefix => testPathWithPrefix(pathPrefix, location.pathname)); + default: + return false; + } + } + const runtimeConfig = await getMasterRuntime(); + const { + apps, jsSandbox = false, prefetch = true, defer = false, lifeCycles, masterHistory, ...otherConfigs + } = { + ...subAppConfig, + ...runtimeConfig + }; + + assert(apps && apps.length, 'sub apps must be config when using fes-plugin-qiankun'); + + registerMicroApps(apps.map(({ + name, entry, base, history = masterHistory, mountElementId = defaultMountContainerId, props + }) => ({ + name, + entry, + activeRule: location => isAppActive(location, history, base), + render: ({ appContent, loading }) => { + if (process.env.NODE_ENV === 'development') { + console.info(`app ${name} loading ${loading}`); + } + if (mountElementId) { + const container = document.getElementById(mountElementId); + if (container) { + const subApp = { + setup() { + + }, + render() { + h('div', { + dangerouslySetInnerHTML: { + __html: appContent + } + }); + } + }; + const app = createApp(); + app.mount(subApp, container); + } + } + }, + props: { + base, + history, + ...props + } + })), lifeCycles); + + if (defer) { + await deferred.promise; + } + + start({ jsSandbox, prefetch, ...otherConfigs }); +} diff --git a/packages/fes-plugin-qiankun/src/mirco/index.js b/packages/fes-plugin-qiankun/src/mirco/index.js new file mode 100644 index 00000000..6c81e1d8 --- /dev/null +++ b/packages/fes-plugin-qiankun/src/mirco/index.js @@ -0,0 +1,103 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { defaultMircoRootId } from '../common'; + +export default function (api, options) { + const { registerRuntimeKeyInIndex = false } = options || {}; + + api.addRuntimePlugin(() => require.resolve('./runtime')); + + if (!registerRuntimeKeyInIndex) { + api.addRuntimePluginKey(() => 'qiankun'); + } + + const lifecyclePath = require.resolve('./lifecycles'); + const { name: pkgName } = require(join(api.cwd, 'package.json')); + + // TODO: fes缺少修改默认配置API + api.modifyDefaultConfig(memo => (Object.assign(Object.assign({}, memo), { + disableGlobalVariables: true, + base: `/${pkgName}`, + mountElementId: defaultMircoRootId, + // 默认开启 runtimePublicPath,避免出现 dynamic import 场景子应用资源地址出问题 + runtimePublicPath: true + }))); + + if (api.service.userConfig.runtimePublicPath !== false) { + // TODO: fes缺少修改 publicPath API + api.modifyPublicPathStr(() => `window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ || "${ + // 开发阶段 publicPath 配置无效,默认为 / + process.env.NODE_ENV !== 'development' + ? api.config.publicPath || '/' + : '/'}"`); + } + + api.chainWebpack((config) => { + assert(api.pkg.name, 'You should have name in package.json'); + config.output + .libraryTarget('umd') + .library(`${api.pkg.name}-[name]`); + }); + + // bundle 添加 entry 标记 + // TODO: fes缺少修改HTML API + api.modifyHTML(($) => { + $('script').each((_, el) => { + const scriptEl = $(el); + const umiEntryJs = /\/?umi(\.\w+)?\.js$/g; + const scriptElSrc = scriptEl.attr('src'); + + if (umiEntryJs.test((scriptElSrc) !== null && scriptElSrc !== 0 ? scriptElSrc : '')) { + scriptEl.attr('entry', ''); + } + }); + return $; + }); + + const namespace = 'plugin-qiankun'; + + api.onGenerateFiles(() => { + api.writeTmpFile({ + path: `${namespace}/qiankunContext.js`, + content: ` + import { createApp, h } from 'vue'; + export const Context = createContext(null); + export function useRootExports() { + return useContext(Context); + }; + `.trim() + }); + + api.writeTmpFile({ + path: `${namespace}/runtime.js`, + content: readFileSync(join(__dirname, 'runtime.js'), 'utf-8') + }); + + api.writeTmpFile({ + path: `${namespace}/lifecycles.js`, + content: readFileSync(join(__dirname, 'lifecycles.js'), 'utf-8') + }); + }); + + api.addPluginExports(() => [ + { + specifiers: ['useRootExports'], + source: `${namespace}/qiankunContext.js` + } + ]); + + api.addEntryImports(() => ({ + source: lifecyclePath, + specifier: '{ genMount as qiankun_genMount, genBootstrap as qiankun_genBootstrap, genUnmount as qiankun_genUnmount }' + })); + + api.addEntryCode(() => ` + export const bootstrap = qiankun_genBootstrap(Promise.resolve(), clientRender); + export const mount = qiankun_genMount(); + export const unmount = qiankun_genUnmount('${api.config.mountElementId}'); + + if (!window.__POWERED_BY_QIANKUN__) { + bootstrap().then(mount); + } + `); +} diff --git a/packages/fes-plugin-qiankun/src/mirco/lifecycles.js b/packages/fes-plugin-qiankun/src/mirco/lifecycles.js new file mode 100644 index 00000000..fd160b73 --- /dev/null +++ b/packages/fes-plugin-qiankun/src/mirco/lifecycles.js @@ -0,0 +1,60 @@ +import { plugin, ApplyPluginsType } from '@@/core/coreExports'; + +const defer = {}; +defer.promise = new Promise((resolve) => { + defer.resolve = resolve; +}); + +let render = () => { }; +let hasMountedAtLeastOnce = false; + +export default () => defer.promise; + +function getSlaveRuntime() { + const config = plugin.applyPlugins({ + key: 'qiankun', + type: ApplyPluginsType.modify, + initialValue: {} + }); + const { slave } = config; + return slave || config; +} + +// 子应用生命周期钩子Bootstrap +export function genBootstrap(promise, oldRender) { + return async (...args) => { + const slaveRuntime = getSlaveRuntime(); + if (slaveRuntime.bootstrap) { await slaveRuntime.bootstrap(...args); } + render = () => promise.then(oldRender).catch((e) => { + if (process.env.NODE_ENV === 'development') { + console.error('Render failed', e); + } + }); + }; +} + +// 子应用生命周期钩子Mount +export function genMount() { + return async (...args) => { + defer.resolve(); + const slaveRuntime = getSlaveRuntime(); + if (slaveRuntime.mount) { await slaveRuntime.mount(...args); } + // 第一次 mount 会自动触发 render,非第一次 mount 则需手动触发 + if (hasMountedAtLeastOnce) { + render(); + } + hasMountedAtLeastOnce = true; + }; +} + +// 子应用生命周期钩子Unmount +export function genUnmount(mountElementId, app) { + return async (...args) => { + const container = document.getElementById(mountElementId); + if (container) { + app.unmount(container); + } + const slaveRuntime = getSlaveRuntime(); + if (slaveRuntime.unmount) { await slaveRuntime.unmount(...args); } + }; +} diff --git a/packages/fes-plugin-qiankun/src/mirco/runtime.js b/packages/fes-plugin-qiankun/src/mirco/runtime.js new file mode 100644 index 00000000..96180844 --- /dev/null +++ b/packages/fes-plugin-qiankun/src/mirco/runtime.js @@ -0,0 +1,12 @@ +import { h } from 'vue'; +import qiankunRender from './lifecycles'; + +export function rootContainer(container) { + const value = window.g_rootExports; + const { Context } = require('@@/plugin-qiankun/qiankunContext'); + return h(Context.Provider, { value }, container); +} + +export const render = oldRender => qiankunRender().then(() => { + oldRender(); +}); diff --git a/packages/fes-preset-built-in/src/plugins/registerMethods.js b/packages/fes-preset-built-in/src/plugins/registerMethods.js index 5b5749c5..4748a62e 100644 --- a/packages/fes-preset-built-in/src/plugins/registerMethods.js +++ b/packages/fes-preset-built-in/src/plugins/registerMethods.js @@ -28,7 +28,9 @@ export default function (api) { 'modifyBabelOpts', 'modifyBabelPresetOpts', 'chainWebpack', - 'addTmpGenerateWatcherPaths' + 'addTmpGenerateWatcherPaths', + 'modifyPublicPathStr', + 'modifyHTML', ].forEach((name) => { api.registerMethod({ name }); }); diff --git a/packages/fes-template/package.json b/packages/fes-template/package.json index 25c5325d..09552223 100644 --- a/packages/fes-template/package.json +++ b/packages/fes-template/package.json @@ -55,6 +55,7 @@ "@fesjs/plugin-jest": "^2.0.0-rc.0", "@fesjs/plugin-vuex": "^2.0.0-rc.0", "@fesjs/plugin-request": "^2.0.0-rc.0", + "@fesjs/plugin-qiankun": "^2.0.0-alpha.0", "ant-design-vue": "2.0.0", "vue": "^3.0.5", "vuex": "^4.0.0" diff --git a/yarn.lock b/yarn.lock index b80580c3..c3fa89d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1587,6 +1587,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.7.2": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" + integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.0.0", "@babel/template@^7.10.4", "@babel/template@^7.12.13", "@babel/template@^7.3.3", "@babel/template@^7.4.0": version "7.12.13" resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" @@ -4644,7 +4651,7 @@ acorn@^8.0.4: resolved "https://registry.npmjs.org/acorn/-/acorn-8.0.5.tgz#a3bfb872a74a6a7f661bc81b9849d9cac12601b7" integrity sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg== -address@1.1.2: +address@1.1.2, address@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== @@ -9545,6 +9552,13 @@ import-from@^3.0.0: dependencies: resolve-from "^5.0.0" +import-html-entry@^1.9.0: + version "1.11.1" + resolved "https://registry.yarnpkg.com/import-html-entry/-/import-html-entry-1.11.1.tgz#3d8c5977926bdd122ab8e658965c102068b4af8d" + integrity sha512-O7mCUTwKdYU49/LH6nq1adWPnUlZQpKeGWIEcDq07KTcqP/v0jBLEIVc0oE0Mtlw3CEe0eeKGMyhl6LwfXCV7A== + dependencies: + "@babel/runtime" "^7.7.2" + import-lazy@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" @@ -12892,6 +12906,11 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38" + integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg== + path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -13706,6 +13725,17 @@ q@^1.1.2, q@^1.5.1: resolved "https://registry.npmjs.org/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qiankun@2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/qiankun/-/qiankun-2.3.4.tgz#a6a6382c1e909a76f9aea1708ff46276432428f2" + integrity sha512-LJ3luGH0eAQ3xd7vH7xUtAS57eGUs4bMiCcFQx1OJ94XJ3VdKIb97jqT5p5ibOj82EPQdLJhVsB5+phm4iEXfw== + dependencies: + "@babel/runtime" "^7.10.5" + import-html-entry "^1.9.0" + lodash "^4.17.11" + single-spa "5.8.1" + tslib "^1.10.0" + qs@6.7.0: version "6.7.0" resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" @@ -14783,6 +14813,11 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +single-spa@5.8.1: + version "5.8.1" + resolved "https://registry.yarnpkg.com/single-spa/-/single-spa-5.8.1.tgz#86c2575e297e31d8f06945944ec97e31851a59ae" + integrity sha512-RlyLZ1IDIPdzI6mQPzCQnlgTt9jmbAXBZODmifoDut840wksPDSPhcSS8jXMpuUlqOidQiX2YuLVQSR9DEgsXw== + sirv@^1.0.7: version "1.0.11" resolved "https://registry.npmjs.org/sirv/-/sirv-1.0.11.tgz#81c19a29202048507d6ec0d8ba8910fda52eb5a4"