微前端系列之:
一、记一次微前端技术选型
二、清晰简单易懂的qiankun主流程分析
三、记一次qiankun落地遇到的问题
本文是系列之三。
项目背景
- app下架需要把所有页面都迁移到企业微信h5,作为主应用。
- 本来内嵌到app webview的h5,以微应用的方式接入到主应用。
- 主应用技术选型:vite + vue3 + qiankun
- 子应用,有vue、react
__webpack_public_path__相关
微应用market-app-h5背景知识,publicPath配置如下:
本地开发 /marketapp
测试 /marketapp
预生产 /
生产:https://some.cdn.com/marketapp
微应用接入时,需要写这段代码,这是官方提供的demo代码。
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
尽管可以成功加载入口html,以及写在html文档的远程script和远程style。但会遇到以下问题:
- 分包无法加载,排查之后,发现是publicPath丢失了。
- 在尝试修改配置时,搞不清楚主应用注册子应用入口、webpack配置的output.publicPath、__webpack_public_path__、window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__、router.base的关系。
经过查资料梳理出了以下关系:
主应用注册微信应用入口,是入口html的地址。在这个微应用中入口如下:
- webpack.output.publicPath,决定了输出静态资源请求url前缀,如代码写了'/static/1.js',配置了output.publicPath = '/marketapp/',那么打包出来的结果是 '/marketapp/static/1.js'
- __webpack_public_path__是运行时的webpack.output.publicPath,可以动态修改其值,会覆盖webpack.output.publicPath的值
- window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__,是
qiankun
提供的。根据源码分析,它会拿到微应用html入口url之后,将pathname的最后一项去掉,再组装起来。譬如,子应用入口配置:http://local.soame.domain/mar...,那么经过处理后,会变成http://local.soame.domain/。所以在加载分包的时候,丢失了前缀,然后导致404。
所以,针对这种问题,还要区分publicPath是否cdn地址,所以最后代码改成这样,就可以了
let STATIC_URL = '';
switch (process.env.NODE_ENV) {
case 'development':
STATIC_URL = '/marketapp/';
break;
case 'test':
STATIC_URL = '/marketapp/';
break;
case 'preprod':
STATIC_URL = '/';
break;
case 'production':
STATIC_URL = '//some.cdn.com/marketapp/';
break;
}
if (window.__POWERED_BY_QIANKUN__) {
// 如果当前环境下webpack配置的out.publicPath的值是cdn地址,那么直接使用
if (/(https?:)?\/\/.*$/.test(STATIC_URL)) {
__webpack_public_path__ = STATIC_URL;
} else {
// 如果当前环境下webpack配置的out.publicPath的值不是cdn地址,如 / /marketapp /a/b/c
// 需要对window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__进行重新处理
__webpack_public_path__ = new URL(window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__).origin + STATIC_URL;
}
}
样式隔离相关
主应用的UI库是vant3.x
微应用marketapp的UI库是vant2.x
微应用priceluck的UI库是antd-mobile
发现问题:微应用priceluck,在独立运行时,是可以使用toast组件的,但qiankun环境下运行,toast组件样式丢失了,直接append在body底部。
通过排查,是 qiankun
的样式隔离导致的,无论使用shadow dom方案,还是scoped方案,都无法处理这种情况。以scoped方案为例,本来是 .test{width: 100%;}
,scoped之后变成div[data-qiankun=priceluck] .test{width: 100%:}
。这种方案,好处是可以把业务样式限制在微应用容器中。但是通用组件样式,如挂载到body下的组件,如toast、popup,是不生效的。要处理这样问题,我想到两种思路:
- 主应用通过props提供主应用的toast、popup等方法给微应用调用。可以一定程度低处理这种情况,且样式统一;但是子应用修改起来很麻烦,而且有可能是dialog里面再套一些antd-mobile的组件,这种情况就很难兼容。
主应用在加载子应用时,进行样式隔离,针对UI组件的样式不要做隔离。直接在全局生效。一方面,UI组件库的命名方式都是以BEM风格明明,antd-mobile是以
.am-
开头的;vant是以.van-
开头的;另一方面,我们这个项目是单例模式,且后续也不考虑多例模式。但是,主应用和微应用marketapp都是用vant的,样式会冲突。主需要对主应用的样式也做scoped处理就可以了。所以就采用方案二。需要通过vite插件来实现改
qiankun
源码、主应用vant
源码。
经过断点得知 qiankun
是在 src/sandbox/patchers/css.ts
进行scoped样式隔离处理。
private ruleStyle(rule: CSSStyleRule, prefix: string) {
const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
const rootCombinationRE = /(html[^\w{[]+)/gm;
const selector = rule.selectorText.trim();
let { cssText } = rule;
// handle html { ... }
// handle body { ... }
// handle :root { ... }
if (selector === 'html' || selector === 'body' || selector === ':root') {
return cssText.replace(rootSelectorRE, prefix);
}
// handle html body { ... }
// handle html > body { ... }
if (rootCombinationRE.test(rule.selectorText)) {
const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;
// since html + body is a non-standard rule for html
// transformer will ignore it
if (!siblingSelectorRE.test(rule.selectorText)) {
cssText = cssText.replace(rootCombinationRE, '');
}
}
// handle grouping selector, a,span,p,div { ... }
cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
// handle div,body,span { ... }
if (rootSelectorRE.test(item)) {
return item.replace(rootSelectorRE, (m) => {
// do not discard valid previous character, such as body,html or *:not(:root)
const whitePrevChars = [',', '('];
if (m && whitePrevChars.includes(m[0])) {
return `${m[0]}${prefix}`;
}
// replace root selector with prefix
return prefix;
});
}
return `${p}${prefix} ${s.replace(/^ */, '')}`;
}),
);
return cssText;
}
重点在
return `${p}${prefix} ${s.replace(/^ */, '')}`;
在这里做样式名判断,如果是有 .van-
或者 .am-
,则不加作用域前缀。否则就按照原来的逻辑加前缀。所以要改成:
let matchUILib = s.includes('.am-') || s.includes('.van-');
return matchUILib ? s.replace(/^ */, '') : "".concat(p).concat(prefix, " ").concat(s.replace(/^ */, ''));
而主应用UI库样式隔离,可以参考vue的scoped的原理,其实就是对元素添加一个自定义属性如data-v-mainapp
;css的样式名,加一个 [data-v-mainapp]
属性后缀,来作为作用域。
写成vite插件,是这样的:
import fs from 'fs';
import path from 'path';
export default function microAppsStyleHack() {
return {
name: 'vite-plugin-micro-apps-style-hack',
transform(code, id) {
// qiankun 样式隔离特殊处理
if (id.includes('node_modules/qiankun/es/sandbox/patchers/css.js')) {
code = code.replace(
`return "".concat(p).concat(prefix, " ").concat(s.replace(/^ */, ''));`,
`let matchUILib = s.includes('.am-') || s.includes('.van-'); return matchUILib ? s.replace(/^ */, '') : "".concat(p).concat(prefix, " ").concat(s.replace(/^ */, ''));`);
}
// 主应用 vant 样式 scoped
if (id.includes('vant')) {
// js文件,用vue的createVNode方法创建虚拟节点的,因为每个ui组件都会有class属性,这里取巧,直接在class属性前面加一个自定义的属性
if (id.includes('.js')) {
code = code.replace(
/((?:'|")?\bclass\b(?:'|")?:)/gm,
`"data-v-mainapp":"",$1`
);
}
// css文件,直接对样式名做处理,可以点击regex101那两个url,看看考虑了哪几种情况
if (id.includes('.css')) {
// https://regex101.com/r/SAm0zC/1
// https://www.w3school.com.cn/cssref/css_selectors.asp
code = code.replace(
/(\.van.*?)(\s|,|{|>|\+|~|:)/gm,
`$1[data-v-mainapp]$2`
);
// https://regex101.com/r/ZRM6D4/1
code = code.replace(/([a-zA-Z])(\.van)/gm, `$1[data-v-mainapp]$2`);
}
}
return {
code,
map: null
};
}
};
}
这里还有一个小插曲,vite
在开发环境没有走插件的逻辑,查资料得知,是 vite
的 依赖预构建
导致的,这个功能的好处是可以秒开。如果想走 transform的逻辑,那么需要在vite配置中需要配置 optimizeDeps: { exclude: ['vant'] }
。这里如果配置了 qiankun
会报关于 loadash
的错,所以我直接写了个node脚本来替换了。此处就不展示了。
主应用注册子应用相关
我们这次是分批次接入微前端的,因为有些业务部门需要排期先做他们自己的业务。每接入一个微应用,主应用就要添加一个配置,跟着发布。主应用很被动。所以就让后端出一个接口,把微应用配置放到数据库上,主应用每次都拉一次配置就可以了。
vconsole以及fastclick
微应用本来接入了vconsole、fastclick,在qiankun下会报错,说window对象不是原生的。这个很容易猜到就是因为他们拿到的全局对象是代理的对象。
且vconsole主应用也有接入。在微应用中判断如果是开发环境且不在qiankun环境下,才开启;
fastclick直接去掉,官方已经在chrome32的时候,以及ios9.x的时候已经解决300ms延迟问题。(历史的洪流,啊~~)
history路由模式相关
之前比较少配置history路由模式相关的配置。在这里记录一下。
关于devServer的配置
// 跨域设置
headers: {
'Access-Control-Allow-Origin': '*'
},
// 通过前缀来访问
historyApiFallback: {
rewrites: [
{
from: /^\/marketapp.*/,
to: path.posix.join(config.dev.assetsPublicPath, 'app.html')
}
]
},
// 通过host配置的域名访问
disableHostCheck: true
关于nginx的配置
location ^~ /xxx {
alias /data/XXX;
index index.html;
try_files $uri $uri/ @xxxredirect;
}
# 解决history模式刷新404问题
location @xxxredirect {
rewrite ^.*$ /xxx/index.html last;
}
以及注意下router.base的关系。new VueRouter({base: '/marketapp'}),这里的base的作用就是路由路径前缀,譬如,路由路径是/path/to/view,配置了base之后,需要/marketapp/path/to/view,才可以成功跳转到路由。
后记
先记这么多,后续再出问题会继续补充到这里。