2

微前端系列之:
一、记一次微前端技术选型
二、清晰简单易懂的qiankun主流程分析
三、记一次qiankun落地遇到的问题

本文是系列之三。

项目背景

  1. app下架需要把所有页面都迁移到企业微信h5,作为主应用。
  2. 本来内嵌到app webview的h5,以微应用的方式接入到主应用。
  3. 主应用技术选型:vite + vue3 + qiankun
  4. 子应用,有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。但会遇到以下问题:

  1. 分包无法加载,排查之后,发现是publicPath丢失了。
  2. 在尝试修改配置时,搞不清楚主应用注册子应用入口、webpack配置的output.publicPath、__webpack_public_path__、window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__、router.base的关系。

经过查资料梳理出了以下关系:

  1. 主应用注册微信应用入口,是入口html的地址。在这个微应用中入口如下:

  2. webpack.output.publicPath,决定了输出静态资源请求url前缀,如代码写了'/static/1.js',配置了output.publicPath = '/marketapp/',那么打包出来的结果是 '/marketapp/static/1.js'
  3. __webpack_public_path__是运行时的webpack.output.publicPath,可以动态修改其值,会覆盖webpack.output.publicPath的值
  4. 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;
    }
}

注意,如果微应用用到了 url-loader 来加载图片资源,也需要配置对应的 publicPath 值,否则会导致使用主应用的域名来请求,导致404。

样式隔离相关

主应用的UI库是vant3.x
微应用marketapp的UI库是vant2.x
微应用priceluck的UI库是antd-mobile

发现问题:

  1. 微应用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,是不生效的。要处理这样问题,我想到两种思路:

  1. 主应用通过props提供主应用的toast、popup等方法给微应用调用。可以一定程度低处理这种情况,且样式统一;但是子应用修改起来很麻烦,而且有可能是dialog里面再套一些antd-mobile的组件,这种情况就很难兼容。
  2. 主应用在加载子应用时,进行样式隔离,针对UI组件的样式不要做隔离。直接在全局生效。一方面,UI组件库的命名方式都是BEM风格,antd-mobile是以 .am-开头的;vant是以 .van- 开头的;另一方面,我们这个项目是单例模式,且后续也不考虑多例模式。

所以就采用方案二。需要在主应用,通过vite插件来实现改 qiankun 源码,实现自定义样式隔离逻辑。

但是,又遇到了以下问题:

  1. 微应用priceluck有一些全局组件,如dialog,是自己实现的,需要挂在到body下。qiankun的样式隔离方案,也会导致样式丢失。

解决方法:协商一个样式前缀,如 .wh5,如果是这个前缀,则不进行scoped。

接下来又遇到了问题:

  1. 主应用和微应用marketapp都是用vant的,版本不一样,但是样式名是一样的,会导致样冲突。主应用和微应用样式都会受到影响。

解决方案:主应用对vant做scoped,微应用如果也是用vant的,也要做scoped。但是业务样式呢?因为在vue的SFC中style使用了scoped,所以是天然的隔离。于是干脆直接把qiankun的样式隔离方案给关了。

接下来又遇到了问题:

  1. 去掉qiankun的样式隔离之后,微应用priceluck的reset样式,污染到了全局的 htmlbody 样式

解决方案:qiankun的scoped样式隔离方案,除了会对样式名加scoped,还会让微应用的html、body的样式名,直接改为微前端容器对应的样式名,从而做到了隔离,不会污染全局的样式。所以,我们开启qiankun的scoped样式隔离方案之后,需要阉割掉scoped功能就可以了。保留隔离html、body样式的方案。

最终的样式隔离方案:

  1. 主应用业务样式,通过vue的SFC中的scoped实现隔离
  2. 主应用第三方UI库,做scoped处理
  3. 子应用第三方UI库如果跟主应用的相同,则也需要对子应用第三方UI库做scoped
  4. 主应用写插件,阉割qiankun的scoped样式隔离方案,保留对微应用的html和body的样式隔离

样式隔离方案实施:

  1. 主应用UI库样式隔离,可以参考vue的scoped的原理,其实就是对元素添加一个自定义属性如data-v-mainapp;css的样式名,后面加一个 [data-v-mainapp] 属性后缀,来作为作用域。
  2. qiankun的scoped样式隔离方案阉割

写成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的scoped样式隔离方案阉割
      if (id.includes('node_modules/qiankun/es/sandbox/patchers/css.js')) {
              code = code.replace(`return "".concat(p).concat(prefix, " ").concat(s.replace(/^ */, ''));`, `return item;`);
            }
    // 主应用 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脚本来替换了。此处就不展示了。

  1. 子应用样式隔离
    主应用是用 vue2.x 的,所以 h 方法还是跟 vue3.xcreateVNode 有点不一样,所以思路是重写 h 方法,对第二个参数,做重写,统一加上参数 attrs:{"data-v-subapp": ""}。写成 webpack-loader 是这样的:

    const loaderUtils = require('loader-utils');
    module.exports = function (code) {
     const options = loaderUtils.getOptions(this);
     if (options.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-subapp]$2`
         );
         // https://regex101.com/r/ZRM6D4/1
         code = code.replace(/([a-zA-Z])(\.van)/gm, `$1[data-v-subapp]$2`);
     }
     if (options.js) {
         code = code.replace(
             /(function.*\(h,.*$)/gm,
             `$1
             var rawH = h;
             h = (a,b,c) => {
                 if (!b) {
                     b = {}
                 }
                 if (b.attrs) {
                     b.attrs["data-v-subapp"] = "";
                 } else {
                  b.attrs = {"data-v-subapp": ""};
                 }
                 return rawH(a,b,c);
             }
             `
         );
         code = code.replace(
             /(render\(h\).*$)/gm,
             `$1
             var rawH = h;
             h = (a,b,c) => {
                 if (!b) {
                     b = {}
                 }
                 if (b.attrs) {
                     b.attrs["data-v-subapp"] = "";
                 } else {
                  b.attrs = {"data-v-subapp": ""};
                 }
                 return rawH(a,b,c);
             }
             `
         );
         code = code.replace(
             /(var h = arguments.*$)/gm,
             `$1
             var rawH = h;
             h = (a,b,c) => {
                 if (!b) {
                     b = {}
                 }
                 if (b.attrs) {
                     b.attrs["data-v-subapp"] = "";
                 } else {
                  b.attrs = {"data-v-subapp": ""};
                 }
                 return rawH(a,b,c);
             }
             `
         );
         code = code.replace(
             /(var h = this\.\$createElement;.*$)/gm,
             `$1
             var rawH = h;
             h = (a,b,c) => {
                 if (!b) {
                     b = {}
                 }
                 if (b.attrs) {
                     b.attrs["data-v-subapp"] = "";
                 } else {
                  b.attrs = {"data-v-subapp": ""};
                 }
                 return rawH(a,b,c);
             }
             `
         );
     }
     return code;
    }

    rule配置是这样的:

    {
     rule: [
         {
             test: /vant.*\.js$/,
             loader: path.resolve(__dirname, '../build/vanthash.js'),
             options: {
                 js: true
             }
         },
         {
         test: /\.css$/,
         use: [
             'vue-style-loader',
             { loader: 'css-loader', options: { importLoaders: 1 } },
             {
                 loader: path.resolve(__dirname, '../build/vanthash.js'),
                 options: {
                     css: true
                 }
             },
             // 把vant.css进行px2rem转换
             'postcss-loader'
         ]
     },
     ]
    }

主应用注册子应用相关

我们这次是分批次接入微前端的,因为有些业务部门需要排期先做他们自己的业务。每接入一个微应用,主应用就要添加一个配置,跟着发布。主应用很被动。所以就让后端出一个接口,把微应用配置放到数据库上,主应用每次都拉一次配置就可以了。

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,才可以成功跳转到路由。

后记

  1. 样式隔离,如果改 qiankun 源码,修改自定义scoped样式隔离方案,针对 .am-.van- 前缀,就不进行scoped。尽管这个方案可以解决全局组件样式丢失的问题,也会碰到问题:qiankun 会自动把微应用写在根部的样式(如 reset.cssnormalize.css)自动放到微前端容器下。因为开启了 qiankunscoped 样式隔离方案,所以写在根部的样式,本来是 .test{color: red;} 变成了 div#微应用名字 .test{color:red;},导致到权限变高,而第三方UI库样式没有scoped,所以权限变低,导致第三方UI库样式出问题。尽管可以考虑去掉 reset.cssnormalize.css,但是第三方UI库如 vant 也有写在根部的样式,且不带 .-van 前缀,也是一种样式重置的效果。这种就防不胜防了。所以基本无解。

RockerLau
363 声望11 粉丝

Rocker Lau


引用和评论

0 条评论