qiankun 子项目不能热更新 ?

59 阅读2分钟

背景

由于 qiankun 官方方案不支持子项目热更新,导致在多项目联调的时候,十分的不方便。

  • 比如我启动了3000,3001,3002 三个端口的服务,其中3000作为主应用,3001,3002 作为微应用的时候。
  • 如果我需要在主应用看效果的时候,那么两个微应用就不能支持热更新,只能通过子项目 reload 的方式将资源加载进来,导致 react 状态丢失。
  • 如果单独启动子项目,配置成热更新模式,qiankun 主应用又不能及时更新 ui,如果要切换到主应用查看,那么还需要关闭当前服务,通过命令行传参数来实现热更新的启停,真的是一根筋两头堵,开发体验十分的不佳。

qiankun 官方配置

image.png

我的方案

qiankun 只有主应用支持热更新,这是事实,那么只能优化一些开发体验,针对微应用,我的想法是在页面注入一个按钮,通过点击按钮来控制热更新的启停,这样不用离开浏览器,进入 vscode,切换 cli 启停热更新。

rsbuild 自定义 plugin

import path from 'path'
import fs from 'fs'
import { RsbuildPlugin } from '@rsbuild/core'

export const alterHrmPlugin = (props: { hmr: boolean, single: boolean }): RsbuildPlugin => ({
  name: 'alter-hmr-plugin',
  apply: (congig, context) => {
    return context.action === 'dev' && !props.single
  },
  setup(api) {
    api.modifyRsbuildConfig((config, { mergeRsbuildConfig }) => {
      const hmr = props.hmr
      return mergeRsbuildConfig(config, {
        dev: {
          hmr,
        },
      })
    })

    api.onBeforeStartDevServer(({ server }) => {
      // 拦截页面请求 
      server.middlewares.use((req, res, next) => {
        if (req.originalUrl?.startsWith('/alter/hmr')) {
          // 通过改变配置文件,从而更新配置,达到重启服务的目的
          const url = new URLSearchParams(req.originalUrl.split('?')[1])
          fs.writeFileSync(
            path.resolve(__dirname, '.env.local'),
            ['hmr', url.get('state')].join(' = ')
          )
          res.writeHead(200, {
            'Content-Type': 'application/json',
          })
          res.end(JSON.stringify({ done: true }), 'utf-8')
        } else {
          next()
        }
      })
    })
    
    // 注入 button 到页面中
    api.modifyHTMLTags(({ headTags, bodyTags }) => {
      const config = api.getRsbuildConfig()
      bodyTags.push({
        tag: 'button',
        attrs: {
          type: 'button',
          'data-hmr-href': `/alter/hmr`,
          title: '开启关闭热更新',
          onclick: `{
            const { hmrStatus, hmrHref } = this.dataset
            const newHmrStatus = hmrStatus === 'on' ? 'off' : 'on';
            this.dataset.hmrStatus = newHmrStatus;
            let count = 0;
            setInterval(() => {
              this.innerText = newHmrStatus + '.'.repeat(count++ % 4)
            }, 200)
            this.style.background = newHmrStatus === 'on' ? 'forestgreen' : 'grey'
            this.disabled = true
            fetch(this.dataset.hmrHref + '?state=' + newHmrStatus).then(res => res.json()).then(res => {
              console.warn('热更新模式切换成功')
            }).catch(err => {
              console.error('热更新模式切换失败')
            })
          }`,
          'data-hmr-status': config.dev?.hmr === false ? 'off' : 'on',
          style: `position: fixed; width: 100px; line-height: 48px; right: -32px; bottom: -8px; transform: rotate(-45deg); background: ${
            config.dev?.hmr ? 'forestgreen' : 'grey'
          }; color: #fff`,
        },
        children: config.dev?.hmr === false ? 'off' : 'on',
      })
      return { headTags, bodyTags }
    })
  },
})

使用方法

import path from 'path'
import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { pluginSass } from '@rsbuild/plugin-sass'
import { dropWhile, get } from 'lodash-es'
import { pluginSvgr } from '@rsbuild/plugin-svgr'
import { name } from './package.json'
import { externals } from '@dnt/config/cdn'
import { PUBLIC_URL } from '@dnt/config/webpack.config'
import CompressionPlugin from "compression-webpack-plugin"
import { pluginEslint } from '@rsbuild/plugin-eslint'
import { alterHrmPlugin } from './alter-hmr-plugin'

const { publicVars, rawPublicVars, parsed } = loadEnv({ prefixes: ['PUBLIC_'] })

const argv = require('minimist')(
  dropWhile(process.argv, (v) => v !== '--').slice(1)
)

/**
 * 
 * 单体运行 single  true\
 * 父级单体运行 single true\
 * 父级 qiankun 运行 undefined false\
 */
const single = get<boolean>(argv, 'single', false)

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginSass(),
    // 可将 svg 直接导入到 react 组件中使用
    pluginSvgr({ mixedImport: true }),
    // TODO: 有空放开修改不合理的地方
    // pluginEslint(),
    alterHrmPlugin({
      hmr: parsed.hmr === 'on' ? true : false,
      single
    }),
  ],
  html: {
    template: './public/index.html',
  },
  source: {
    define: {
      ...publicVars,
      'process.env': JSON.stringify(rawPublicVars),
    },
  },
  output: {
    // 兼容 ie 11
    // polyfill: 'usage',
    assetPrefix: [PUBLIC_URL, 'child', name, ''].join('/'),
    distPath: {
      root: 'build',
    },
  },
  resolve: {
    alias: {
      '&': path.resolve(__dirname, 'src/'),
    },
  },
  server: {
    strictPort: true,
    open: single === true,
    port: parseInt(parsed.PORT),
    proxy: [
      {
        context: ['/rhino/v1', '/auth/v1'],
        target: parsed.HTTPS_PROXY_TARGET,
        secure: false,
        changeOrigin: true,
      },
    ],
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
    historyApiFallback: true,
  },
  dev: {
    lazyCompilation: single, // 注意这个配置在主应用的时候必须关闭,否则会报错
    client: {
      overlay: false,
      port: parseInt(parsed.PORT),
    },
    hmr: single === true,
  },
  tools: {
    rspack(config, { env }) {
      config.output.library = {
        name: `${name}-[name]`,
        type: 'umd',
      }
      config.output.chunkLoadingGlobal = `webpackJsonp_${name}`
      config.output.globalObject = 'window'

      if (env === 'production') {
        // config.externals = externals

        config.plugins.push(
          new CompressionPlugin({
            algorithm: 'gzip',
          })
        )
      } else {
        config.output.publicPath = 'auto'
      }

      return config
    },
  },
  performance: {
    removeConsole: true,
    chunkSplit: {
      strategy: 'split-by-module'
    }
  }
})


效果展示

右下角生成一个 button

image.png

点击之后

image.png

image.png

总结

核心就是利用了 rsbuild 会 watch env 文件更新,从而重启服务,这样我通过命令行启动 3 个服务以后,如果想在主应用看效果,那可以关闭热更新,如果想在微应用开发,就可以开放热更新。