Vue+微前端(QianKun)落地优化篇(三)

3,753 阅读12分钟

简介

公司主研的前端项目在qiankunVue2.x自研组件库的加持下,集成的微应用有十来个左右了,基本行云流水了,但缺乏对性能、资源加载、异常上报等的一些加持,本文讲介绍一种优化方案、简易异常上报、自动化一些方案。

本文不再介绍如何从 0 搭建配合qiankun实现的微前端架构,如果对此感兴趣可以参考我前两篇文章。

🔌CDN 资源化

之前项目刚成型的时候,微应用集成大概 8 至 10 个左右,(局域网下)浏览器每次进入都至少等 10 至 20 秒的白屏,甚至内存占用非常高(F12 - Menory - Select JavaScript VM instance中可以看到)有800M~1.xG,页面卡顿很明显,多点几次菜单页面就内存崩溃了,可以说体验非常不错(🐶)。

占用内存高页面崩溃的问题我已经找不到截图了,但如果有类似的情况可以参考改造。

十多秒的资源加载

先看看改造完成后的加载速度和内存占用,取某个微应用中的一个加载资源较大的页面,有地图、charts、表格、表格、弹框等组件进行测试,从下面两张图可得知页面加载时间在6 秒左右,内存占用只有100M+,页面基本很流畅。

资源较大的页面

再取某个微应用中的一个只有表格、表单筛选条件的页面进行测试,加载时间在3.38 秒左右,内存占用只有100M+,如下图

常规页面

以上两个页面都是基于访问先加载主应用再间接加载某个微应用清空缓存情况下进行访问的,如果第二次访问页面配合强缓存协商缓存会快个 1~2 秒。

说到“先加载主应用再间接加载某个微应用”为啥呢,是因为这是Qiankun是先加载主应用,等主应用加载渲染完成后,识别是否Qiankun模式,根据路由匹配再加载对应的微应用和渲染,最终才完成,实际这里加载完成等于加载了两个单页面的时间,这种加载模式也决定了时间和内存会比普通的单页面多一些。

qiankun加载机制.jpg

再了解一下HTTP的加载资源机制,它会对加载过的资源进行缓存,第二次加载同样的资源直接取缓存的,而没有发出去请求,也就是说当主、微应用存在同样的资源时,只会请求一次,第二次起包括第二次都直接读缓存,当然这只是请求阶段,在内存中,还是会存在两份不同的内存,为了保证页面访问速度和流畅,这种重复的资源能不用则不用。

image.png

再来了解一下资源加载决策,分主、微两个层次,主应用一般是公共资源,比如我这边项目架构是Vue技术栈,则有Vuevue-routervuex这些资源是固定,可以充当公共资源;而微应用则是定制化的,也就是只有我这个 A 应用存在这个资源使用或者像上图中的组件库,组件库因为经常会发布,每个微应用的所引用的组件库版 本不一致,也就是相当于自己本身的独立资源。

image.png 资源加载决策.jpg

上图中提到的 A 应用内存中存在两份组件库,分别是主应用和它本身的,从Qiankun的沙盒机制中可以知道,微应用的window对象只是从沙盒拷贝一个出来而已,往这里进行添加、删除并不会影响全局的window,那么当微应用的window没有想要的属性时,则会从全局的window取,可以参考Qiankun 原理详解 JS 沙箱是如何做隔离

改造步骤

本例来看一个git提交记录有263次的微应用改造结果对比,先来看看改造前后打包大小。

image.png

改造前的

改造前的

改造后的

after.png

由此可见速度提升是有多快,gz: 2487.20kiB -> 127.75kiB。

接下来开始改造

主、微应用都可以参考以下改造,区别就是资源不同而已。

  1. 在根目录创建 cdn.js 文件

    // cdn.js
    const isProduction = process.env.NODE_ENV === 'production'
    // const ver = require('./package.json').dependencies['组件库'].replace(/(\^|\*)/g, '')
    const cdn = {
      css: [],
      js: []
    }
    
    const externals = []
    
    // 默认是打包后才引入cdn,本地运行使用node_modules
    if (isProduction) {
      cdn.js.push(
        ...[
          `/cdn/vue/2.6.14/vue.min.js`,
          `/cdn/vue-router/3.5.4/vue-router.min.js`,
          `/cdn/vuex/3.6.2/vuex.min.js`,
          `/cdn/echarts/5.4.0/echarts.min.js`,
          `/cdn/element-ui/lib/index.js`,
          // `/cdn/组件库/${ver}/lib/index/index.js`
        ]
      )
      cdn.css.push(`/cdn/组件库/${ver}/lib/style/index.css`)
      externals.push(
        {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          vuex: 'Vuex',
          echarts: 'echarts',
          'element-ui': 'ELEMENT'
        }
      Object.assign(externals)
    }
    
    if (!isProduction) {
      cdn.css.push(...[`/icon/iconfont.css`])
      cdn.js.push(
        ...[
          '...一些本地运行的cdn资源'
        ]
      )
    }
    
    module.exports = {
      cdn,
      externals
    }
    
  2. 修改 vue.config.js 配置 cdnexternals

    // vue.config.js
    ...
    const { cdn, externals } = require('./cdn.js')
    
    export default {
      chainWebpack: {
        ...,
    
        // 注入cdn地址
        config.plugin('html').tap((args) => {
          args[0].cdn = cdn
          return args
        })
    
        // 设置不参与打包
        config.externals(externals)
      }
    }
    
  3. 修改入口文件 main.js

    在配置externals时,只会匹配到的完全一样的路径才会生效,比如上面的'element-ui': 'ELEMENT',只会匹配element-ui的字符,所以像下面这种要自己处理一下或者设置正则规则。

    原来的

    // src/main.js
    import 'element-ui/lib/theme-chalk/index.css'
    

    改为

    // src/main.js
    
    // 只有本地运行才手动引入element样式
    if (process.env.NODE_ENV !== 'production') {
      require('element-ui/lib/theme-chalk/index.css')
    }
    
  4. 修改public/index.html脚本资源。

    <!DOCTYPE html>
    <html lang="en" theme="default">
      <head>
        <meta charset="utf-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width,initial-scale=1.0" />
        <meta name="renderer" content="webkit|ie-comp|ie-stand" />
        <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
        <title>!!!原应用这里不做修改</title>
    
        <!-- 循环引入css资源 -->
        <% for (var i in htmlWebpackPlugin.options.cdn&&htmlWebpackPlugin.options.cdn.css) { %>
        <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="preload" as="style" />
        <link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="stylesheet" />
        <% } %>
      </head>
      <body>
        <noscript>
          <strong>
            We're sorry but su-template doesn't work properly without JavaScript enabled. Please
            enable it to continue.
          </strong>
        </noscript>
        <div id="app"></div>
        <!-- built files will be auto injected -->
    
        <!-- 循环引入js资源 -->
        <% for (var i in htmlWebpackPlugin.options.cdn&&htmlWebpackPlugin.options.cdn.js) { %>
        <script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
        <% } %>
      </body>
    </html>
    

简易异常上报工具

在市面上的来源监控工具很多,比如SentryBugsnagRaygunTrackJsRollbarfundebug等一些来源工具都可以实现,我这边后端使用ELK做日志工具,前端则不用重复去部署一些额外的工具,只需让后端暴露一个接口和前端实现异常上报逻辑即可。

微前端场景的异常捕获跟普通单页面有些不一样,每个微应用都会经历多次渲染、销毁阶段,就算销毁某个微应用,可页面还没关,而普通单页面的销毁就是关闭页面了。所以微前端场景下需要有开启、销毁异常上报功能。

Qiankun中有提供addGlobalUncaughtErrorHandler用于捕获全局未捕获的错误,作者在使用的时候发现第一次错误生效,后面都不生效,果断放弃了。

可以参考一文搞定前端错误捕获和上报查看具体异常知识。

上报效果

接下来看一下具体的实现方式

// errUpload.js
import browserTool from 'browser-tool'
import dayjs from 'dayjs'

/**
 * 返回检测类型
 * @param {*} value - 要被检测类型的数据
 * @param {String} type - 匹配的类型 'String|Number|Boolean|Null|Undefined...'
 * @returns {String}
 */
export function toType(value, type) {
  if (type) return Object.prototype.toString.call(value).slice(8, -1) === type
  return Object.prototype.toString.call(value).slice(8, -1)
}

/**
 * 获取用户信息
 */
let getUserInfo

let getEnvProd

/**
 * 获取通用信息
 * @returns
 */
function getCommonInfo(userData) {
  const userInfo = userData || getUserInfo?.() || {}
  const { system, systemVersion, browser, device, version } = browserTool()
  return {
    // 客户端信息
    system: `${system}-${systemVersion}`,
    browser: `${browser} ${version}`,
    device,
    // 时间
    time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
    // 应用名称,如果匹配不到,则识别为主应用
    app: location.pathname.split('/')[2]?.replace(new RegExp('micro-', 'g'), '') || 'main',
    // 页面url
    url: location.href,
    // 用户信息
    userId: userInfo.userId,
    userName: userInfo.employeeName
  }
}

/**
 * 上报错误信息
 */
export function captureError(err, { userData, envProd = false } = {}) {
  // 非线上环境不会触发上报错误
  if (!(envProd || getEnvProd?.() || false)) return
  const report = () => {
    let apiParams
    try {
      apiParams =
        err.apiParams && toType(err.apiParams, 'String') ? JSON.parse(err.apiParams) : err.apiParams
    } catch (error) {
      apiParams = err.apiParams
    }
    const params = {
      ...getCommonInfo(userData),
      // 错误类型 1: 前端异常、2: 接口异常、3: PV/UV
      errorType: err.errorType ?? 1,
      errorMessage: err.errorMessage || '',
      scriptURI: err.scriptURI || '',
      lineNo: err.lineNo ?? null,
      columnNo: err.columnNo ?? null,
      errorStack: err.errorStack || '',

      apiUrl: err.apiUrl || '',
      apiMethod: err.apiMethod || '',
      apiStatus: err.apiStatus ?? null,
      apiTime: err.apiTime ?? null,
      apiParams: apiParams || null,

      // 停留时间
      stopTime: err.stopTime || null,
      ...err
    }
    const url = `${location.origin}接口地址`
    navigator.sendBeacon(url, JSON.stringify(params))
  }

  // 降级上报等级,避免跟主线程冲突
  if ('requestIdleCallback' in window) {
    requestIdleCallback(report)
  } else {
    Promise.resolve().then(report)
  }
}

function handleError(errorMessage, scriptURI, lineNo, columnNo, errorStack) {
  captureError({
    errorMessage,
    scriptURI,
    lineNo,
    columnNo,
    errorStack
  })
}

export default {
  install(Vue, instance) {
    getUserInfo = () => {
      try {
        return instance.$store.getters['common/userInfo']
      } catch (error) {
        return {}
      }
    }

    getEnvProd = () => {
      try {
        return instance.$store.getters['common/env'] === 'production'
      } catch (error) {
        return false
      }
    }

    Vue.config.errorHandler = (err) => {
      const {
        message, // 异常信息
        // name, // 异常名称
        script, // 异常脚本url
        line, // 异常行号
        column, // 异常列号
        stack // 异常堆栈信息
      } = err
      // 错误上报到收集报错的平台
      captureError({
        // name,
        errorMessage: message,
        scriptURI: script,
        lineNo: line,
        columnNo: column,
        errorStack: stack
      })
    }

    /**
     * @param { string } message 错误信息
     * @param { string } source 发生错误的脚本URL
     * @param { number } lineno 发生错误的行号
     * @param { number } colno 发生错误的列号
     * @param { object } error Error对象
     */
    window.onerror = handleError

    window.addEventListener('error', handleError)

    window.addEventListener('unhandledrejection', handleError)
  }
}

/**
 * 销毁上报
 */
export const destroyErrUpload = () => {
  getUserInfo = null
  getEnvProd = null
  window.onerror = null
  window.removeEventListener('error', handleError)
  window.removeEventListener('unhandledrejection', handleError)
}
// main.js
import Vue from 'vue'
import store from '@store'
import errUpload, { destroyErrUpload } from './errUpload'

const instance = new Vue({
  router,
  store,
  render: (h) => h(App)
}).$mount('#app')

// 模拟微应用销毁前解绑上报功能
instance.$once('hook:beforeDestroy', destroyErrUpload)

Vue.use(errUpload, instance)

提效增值

在工作中可以利用jenkinsshellnodejs等一些工具,把一些繁琐、重复、耗时间的流程提取到自动化中,可以节省很多时间。

1. git 提交/合并到master分支自动构建测试发布测试环境。

在每次修复/新增代码时,如果手动打包、连接 ssh、上传文件,有点费时间,特别是在微前端架构下,很多个仓库,更费时间,针对此问题,网上其实有很多解决方案 🔨,我这边采用jenkinsgitlab的 Webhooks 实现自动构建发布测试环境。

  1. jenkins新增一个项目。

  2. 选择自由风格。

    image.png

  3. “源码管理”选择Git并配置gitlab项目地址和登录账户以及拉取分支。

    image.png

  4. “构建触发器”选择"Build when a change is pubshed to GitLab. GitLab CI Service URL:",并复制后面的地址。

    image.png

  5. “构建”填写打包和上传到服务器的命令。

    我这边使用sshpass工具进行上传服务器和执行远程命令,有条件的话可以选择jenkinsPublish Over SSH进行连接。

    rm -rf ./node_module
    npm cache clean -f
    npm install
    npm run build
    tar -zcvf dist.gz ./dist/
    sshpass ...上传远服务器
    sshpass ...解压
    # 结束
    

    image.png

  6. gitlab的 Webhooks 配置界面中新增一个,把步骤 4 复制的地址,粘贴到“网址”中,勾选推送事件输入master,代表只有 master 分支生效,最后点击保存。

    image.png

以上可能说得不全,可以参考jenkins + gitlab + nginx 部署前端应用

2. 一键构建升级所有应用的组件库版本。

在前端微应用越来越多的情况下,如果发布新的组件库版本,那么所有微应用都要经历拉取代码创建分支手动更新提交分支合并分支,假设手动操作一个应用需要 10 分钟,那么 9 个应用就是 1.5 小时,针对这个场景可以直接交给jenkins流水线实现即可。

既然已经有了需求,升级还要版本号输入,那么就要有版本号文本框输入;我这边只实现了半自动化,只有拉去所有仓库代码创建分支根据输入版本更新组件库提交分支,最后的合并代码考虑到这一步太过危险,不能交给机器去搞,要手动点一下。

一键更新组件库配置.png

一键更新组件库结果.png

实现也不难大概是如下这样子

  1. jenkins新建一个流水线的工程。

  2. job Notifications勾选“参数化构建过程”,并配置如下。

    image.png

  3. Pipeline输入大概以下内容,如果有需要请自行调整。

    pipeline{
        agent {label "node5"}
        parameters {
                    choice(name: 'branch',choices: 'develop\nmaster', description: "分支")
                    string(name: 'version',description: '例如:0.1.78', defaultValue: '')
        }
        options {
            //保留构建任务最大数量
                    buildDiscarder(logRotator(numToKeepStr: '10'))
                    disableConcurrentBuilds()
            //如果构建时长超过20分钟,可调大
                    timeout(time: 60, unit: 'MINUTES')
            }
            stages {
            stage('准备') {
                steps {
                    sh 'node -v'
                    sh 'rm -rf ./*'
                }
            }
    
            stage('main') {
                steps {
                    script {
                          catchError(buildResult: 'ALWAYS', stageResult: 'ALWAYS')
                            {
                            dir('main')  {
                                git branch: "$branch", credentialsId: 'gitlan账户id', url: '仓库地址/main.git'
                                echo '拉取成功---main'
                            }
                            sh '''
                            cd main
                            git config user.email "邮箱"
                            git config user.name "账户名"
                            git branch fix/update$version
                            git checkout fix/update$version
                            npm i 组件库@$version -S
                            git add .
                            git commit -m "fix: 更新组件库版本$version"
                            git push -f -u http://gitlab账号:"gitlab密码"@仓库地址main.git fix/update$version
                            '''
                            }
                    }
                }
            }
    
            stage('micro1') {
                steps {
                     script {
                          catchError(buildResult: 'ALWAYS', stageResult: 'ALWAYS')
                            {
                            dir('micro1')  {
                                git branch: "$branch", credentialsId: 'gitlan账户id', url: '仓库地址/micro1.git'
                                echo '拉取成功---micro1'
                            }
    
                            sh '''
                            cd micro1
                            git config user.email "邮箱"
                            git config user.name "账户名"
                            git branch fix/update$version
                            git checkout fix/update$version
                            npm i 组件库@$version -S
                            git add .
                            git commit -m "fix: 更新组件库版本$version"
                            git push -f -u http://gitlab账号:"gitlab密码"@仓库地址config.git fix/update$version
                            '''
                            }
                    }
                }
            }
    
            stage('micro2') {
                steps {
                    script {
                          catchError(buildResult: 'ALWAYS', stageResult: 'ALWAYS')
                            {
                            dir('micro2')  {
                                git branch: "$branch", credentialsId: 'gitlan账户id', url: '仓库地址/micro2.git'
                                echo '拉取成功---micro2'
                            }
    
                            sh '''
                            cd micro2
                            git config user.email "邮箱"
                            git config user.name "账户名"
                            git branch fix/update$version
                            git checkout fix/update$version
                            npm i 组件库@$version -S
                            git add .
                            git commit -m "fix: 更新组件库版本$version"
                            git push -f -u http://gitlab账号:"gitlab密码"@仓库地址intelligentPatrol.git fix/update$version
                            '''
                            }
                    }
                }
            }
    
            stage('micro3') {
                steps {
                    script {
                          catchError(buildResult: 'ALWAYS', stageResult: 'ALWAYS')
                            {
                            dir('micro3')  {
                                git branch: "$branch", credentialsId: 'gitlan账户id', url: '仓库地址/micro3.git'
                                echo '拉取成功---micro3'
                            }
    
                            sh '''
                            cd micro3
                            git config user.email "邮箱"
                            git config user.name "账户名"
                            git branch fix/update$version
                            git checkout fix/update$version
                            npm i 组件库@$version -S
                            git add .
                            git commit -m "fix: 更新组件库版本$version"
                            git push -f -u http://gitlab账号:"gitlab密码"@仓库地址cockpit.git fix/update$version
                            '''
                            }
                    }
                }
            }
    
            stage('micro4') {
                steps {
                    script {
                          catchError(buildResult: 'ALWAYS', stageResult: 'ALWAYS')
                            {
                            dir('micro4')  {
                                git branch: "$branch", credentialsId: 'gitlan账户id', url: '仓库地址/micro4.git'
                                echo '拉取成功---micro4'
                            }
    
                            sh '''
                            cd micro4
                            git config user.email "邮箱"
                            git config user.name "账户名"
                            git branch fix/update$version
                            git checkout fix/update$version
                            npm i 组件库@$version -S
                            git add .
                            git commit -m "fix: 更新组件库版本$version"
                            git push -f -u http://gitlab账号:"gitlab密码"@仓库地址alarmWindow.git fix/update$version
                            '''
                            }
                    }
                }
            }
    
            stage('micro5') {
                steps {
                    script {
                          catchError(buildResult: 'ALWAYS', stageResult: 'ALWAYS')
                            {
                            dir('micro5')  {
                                git branch: "$branch", credentialsId: 'gitlan账户id', url: '仓库地址/micro5.git'
                                echo '拉取成功---micro5'
                            }
    
                            sh '''
                            cd micro5
                            git config user.email "邮箱"
                            git config user.name "账户名"
                            git branch fix/update$version
                            git checkout fix/update$version
                            npm i 组件库@$version -S
                            git add .
                            git commit -m "fix: 更新组件库版本$version"
                            git push -f -u http://gitlab账号:"gitlab密码"@仓库地址system.git fix/update$version
                            '''
                            }
                    }
                }
            }
            }
    }
    
    

再使用半手动方式的puppeteer实现自动创建 MR合并 MR

// mr.js
const puppeteer = require('puppeteer')

async function executeMr(page, task, branch) {
  console.log(`================== 开始处理 ${task} ==================`)
  try {
    const frontEndUrl = (task) => `http://gitlab/${task}/-/merge_requests`
    await page.goto(frontEndUrl(task))

    // 开始创建合并请求
    await Promise.all([
      page.waitForNavigation(),
      page.click('#content-body > div.top-area > div > a.gl-button.btn.btn-confirm')
    ])

    // 选择源分支
    const selectSource = await page.$(
      '#new_merge_request > div > div:nth-child(1) > div > div.clearfix > div:nth-child(2) > button'
    )
    await selectSource.click()

    await page.waitForTimeout(1500)

    const selectSourceList = await page.$(
      `#new_merge_request > div > div:nth-child(1) > div > div.clearfix > div.merge-request-select.dropdown.show > div > div.dropdown-content > ul > li > a[data-ref="${branch}"]`
    )
    await selectSourceList.click()

    // 选择目标分支
    const selectTarget = await page.$(
      '#new_merge_request > div > div:nth-child(2) > div > div.clearfix > div:nth-child(2) > button'
    )
    await selectTarget.click()

    await page.waitForTimeout(1500)

    const selectTargetList = await page.$(
      `#new_merge_request > div > div:nth-child(2) > div > div.clearfix > div.merge-request-select.dropdown.show > div > div.dropdown-content > ul > li > a[data-ref='develop']`
    )
    await selectTargetList.click()

    // 提交创建合并请求 1
    await Promise.all([page.waitForNavigation(), page.click('#new_merge_request > input')])

    await page.waitForTimeout(1500)

    // 提交创建合并请求 2
    await Promise.all([
      page.waitForNavigation(),
      page.click('#new_merge_request > div.gl-mt-5.middle-block > input')
    ])

    await page.waitForTimeout(2000)

    // 合并MR
    const submit = await page.$('button[data-testid="merge-button"]')
    await submit.click()
    console.log(`================== 处理 ${task} 成功 ==================`)
  } catch (error) {
    console.log(error)
    console.log(`================== 处理 ${task} 失败 ==================`)
  }
}

/*
创建一个Browser浏览器实例,并设置浏览器实例相关参数
headless: 是否在无头模式下运行浏览器,默认是true
defaultViewport:设置页面视口大小,默认800*600,如果为null的话就禁用视图口
args:浏览器实例的其他参数
defaultViewport: null, args: ['--start-maximized']:最大化视图窗口展示
ignoreDefaultArgs: ['--enable-automation']:
 禁止展示chrome左上角有个Chrome正受自动软件控制,避免puppeteer被前端JS检测到
 */
puppeteer
  .launch({
    headless: true,
    defaultViewport: null,
    args: ['--start-maximized'],
    ignoreDefaultArgs: ['--enable-automation']
  })
  .then(async (browser) => {
    const user = 'gitlab账户'
    const pass = 'gitlab密码'
    const url = 'http://gitlab/users/sign_in'
    // const branch = "fix/调整滚动条";
    const branch = process.argv[2]
    if (!branch) {
      console.log('请输入分支名')
      await browser.close()
      return
    }
    // 微应用项目
    const tasks = ['su-main', 'su-micro1', 'su-micro2', 'su-micro3', 'su-micro4', 'su-micro5']

    const page = await browser.newPage()
    await page.goto(url)
    console.log('---打开---')

    //定位输入框元素
    const username = await page.$('#username')
    await username.type(user)

    const password = await page.$('#password')
    await password.type(pass)

    await Promise.all([
      page.waitForNavigation(),
      page.click('#new_ldap_user > div.submit-container.move-submit-down.gl-px-5 > input')
    ])

    console.log('--登录成功--')

    for (const iterator of tasks) {
      await executeMr(page, iterator, branch)
    }

    console.log('--完成--')

    //关闭Chromium
    await browser.close()
  })

运行指令

node mr.js [分支名]
node mr.js fix/update0.1.78-xxx

image.png

3. 一键构建所有应用编译包。

在部署其他环境或者上线发布,人工操作要经历克隆应用的代码安装依赖打包,微应用越多,用的时间越久,针对这种场景,也可以直接交给jenkins流水线实现。

实现效果如下:

构建所有应用配置

构建所有应用结果

  1. jenkins新建一个流水线的工程。

  2. job Notifications勾选“参数化构建过程”,并配置如下。

    image.png

  3. Pipeline输入大概以下内容,如果有需要请自行调整。

    pipeline{
        agent {label "node5"}
        parameters {
                    choice(name: 'branch',choices: 'master\ndevelop', description: "分支")
                    choice(name: 'env',choices: '\nD302\nprod', description: "构建环境")
        }
        options {
            //保留构建任务最大数量
                    buildDiscarder(logRotator(numToKeepStr: '10'))
                    disableConcurrentBuilds()
            //如果构建时长超过20分钟,可调大
                    timeout(time: 60, unit: 'MINUTES')
            }
            stages {
            stage('拉取所有仓库') {
                steps {
                    sh 'node -v'
                    sh 'rm -rf ./*'
    
                    dir('main')  {
                        git branch: "$branch", credentialsId: 'gitlab账号Id', url: 'http://仓库地址/-main.git'
                        echo '拉取成功---main'
                    }
    
                    dir('micro1')  {
                        git branch: "$branch", credentialsId: 'gitlab账号Id', url: 'http://仓库地址/-micro1.git'
                        echo '拉取成功---micro1'
                    }
    
                    dir('micro2')  {
                        git branch: "$branch", credentialsId: 'gitlab账号Id', url: 'http://仓库地址/-micro2.git'
                        echo '拉取成功---micro2'
                    }
    
                    dir('micro3')  {
                        git branch: "$branch", credentialsId: 'gitlab账号Id', url: 'http://仓库地址/-micro3.git'
                        echo '拉取成功---micro3'
                    }
    
                    ...更多
    
                }
            }
    
            stage('依赖打包') {
                steps {
                    sh 'pwd'
                    sh 'ls'
    
                    sh 'cd main && npm i --legacy-peer-deps && npm run build:$env || npm run build'
                    sh 'cd micro1 && npm i --legacy-peer-deps && npm run build:$env || npm run build'
                    sh 'cd micro2 && npm i --legacy-peer-deps && npm run build:$env || npm run build'
                    sh 'cd micro3 && npm i --legacy-peer-deps && npm run build:$env || npm run build'
                    ...更多
                }
            }
    
            stage('拷贝') {
                steps {
                    sh 'pwd'
                    sh 'ls'
    
                    sh 'mkdir ./web'
                    sh 'mkdir ./web/main && cp -R ./main/dist/ ./web/main/'
                    sh 'mkdir ./web/micro1 && cp -R ./micro1/dist/ ./web/micro1/'
                    sh 'mkdir ./web/micro2 && cp -R ./micro2/dist/ ./web/micro2/'
                    sh 'mkdir ./web/micro3 && cp -R ./micro3/dist/ ./web/micro3/'
    
                    zip dir: './web', glob: '', zipFile: 'web.zip'
    
                    sh 'ls'
                }
            }
    
            stage('发送服务器') {
                steps {
                    script {
                        // ...
                    }
                }
            }
        }
    }
    

4.建立微应用模板和说明文档

在越来越多人参与开发情况下,如果微应用每次都要手动搭建给别人使用和重新讲一遍架构的事情,会显得很傻。这个时候,微应用模板说明文档的好处就突出来了。

我这边是统一对外建提供一个微应用 git 仓库,提供vue2.6vue2.7vue2.7+ts三个可选择。

image.png

说明文档除了模板根目录的 MD 文档,还有组件库文档统一对外说明。

5.动态代理(转发?)

基于后端微服务统一网关的架构下,所有代理都是走根目录的/api-xxx规则,其中/api-是固定的,后面的xxx是后端微服务前缀,也就是/api-{后端微服务前缀}

代理架构.jpg

通常一个前端微应用会连接多个后端微服务,比如登录、退出、获取应用等接口会划分到system,那么规则就是/api-system,那么在前端工程就需要配置多个代理,但不免会带来有人忘记配置排查问题大半天、或者配置太多显得非常臃肿、又或者切换个环境要改很多个地址。

// proxy.js
/**
 * 转发配置列表
 * baseUrl => 请求头
 * target => 转发地址
 * secure => 如果是https接口,需要配置这个参数
 * changeOrigin => 如果接口跨域,需要进行这个参数配置
 * pathRewrite => 地址改写
 */
module.exports = [
  {
    // 系统管理服务
    baseUrl: '/api-system',
    target: 'http://172.16.17.1:3003/api-system/',
    secure: false,
    changeOrigin: true,
    pathRewrite: ''
  },
  {
    // serve1
    baseUrl: '/api-serve1',
    target: 'http://172.16.17.1:3001/api-serve1/',
    secure: false,
    changeOrigin: true,
    pathRewrite: ''
  },
  {
    // serve2
    baseUrl: '/api-serve2',
    target: 'http://172.16.17.1:3001/api-serve2/',
    secure: false,
    changeOrigin: true,
    pathRewrite: ''
  },
  {
    // serve3
    baseUrl: '/api-serve3',
    target: 'http://172.16.17.1:3001/api-serve3/',
    secure: false,
    changeOrigin: true,
    pathRewrite: ''
  },
  {
    // serve4
    baseUrl: '/api-serve4',
    target: 'http://172.16.17.1:3001/api-serve4/',
    secure: false,
    changeOrigin: true,
    pathRewrite: ''
  },
  {
    // ...更多
    baseUrl: '/api-...',
    target: 'http://172.16.17.1:3001/api-.../',
    secure: false,
    changeOrigin: true,
    pathRewrite: ''
  }
]
// vue.config.js
const proxy = require('./proxy')

export default {
  devServer: {
    ...,
    proxy: {
      ...proxy.reduce((prev, { baseUrl, target, secure, changeOrigin, pathRewrite, ...other }) => {
        prev[baseUrl] = {
          target,
          secure,
          changeOrigin,
          ...other,
          pathRewrite: {
            [`^${baseUrl}`]: pathRewrite
          }
        }
        return prev
      }, {})
    }
  }
}

可以看到target地址是协议、域名、端口一样,/后面不一样,还有制定的api-规则命名,可以通过router实现动态代理方式去统一进行代理,就不需要配置很多个,但也需要兼顾有时候需要某个请求头连接某个服务进行调试接口或者不走网关。

proxy.png

// vue.config.js
const proxy = require('./proxy')

export default {
  devServer: {
    ...,
    proxy: {
      // 其他人代理,可以配置在这里 proxy.js
      ...proxy.reduce((prev, { baseUrl, target, secure, changeOrigin, pathRewrite, ...other }) => {
        prev[baseUrl] = {
          target,
          secure,
          changeOrigin,
          ...other,
          pathRewrite: {
            [`^${baseUrl}`]: pathRewrite
          }
        }
        return prev
      }, {}),
      // 默认按动态代理走测试环境
      '/api-': {
        target: 'http://172.16.17.1:3001/',
        ws: true,
        router: ({ url }) =>
          // 配置 api-* 规则
          /^api-[a-zA-z0-9]+/.test(url) ? `http://172.16.17.1:3001/${url}` : undefined
      }
    }
  }
}
server {
    ...
    
    # 单独设置某个服务不走网关, ^~ 比 ~ 优先级高
    location ^~ /api-serve5/ {
      proxy_redirect off;
      proxy_read_timeout 60s;
      proxy_pass http://10.66.77.1/;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
    }

    # 通用后端服务
    location ~ /api-(.*)$ {
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_set_header X-NginX-Proxy true;
      // $1 等于上面的 (.*)
      proxy_pass http://172.16.17.1:3001/$1$is_args$args;
      proxy_redirect off;
      client_max_body_size 1000m;

      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
    }
}

最后把实际代理地址配置环境变量中,根据启动模式去连接对应的环境。

// .env
VUE_APP_BASE_API_SYSTEM = '/api-system'
VUE_APP_BASE_API_XXX = '/api-xxx'
// .env.dev
VUE_APP_PROXY = http://172.16.17.1:3001/
// .env.dev.test
VUE_APP_PROXY = http://10.1.1.1:3001/
// vue.config.js
const proxy = require('./proxy')

export default {
  devServer: {
    ...,
    proxy: {
      // 默认按动态代理走测试环境
      '/api-': {
+        target: process.env.VUE_APP_PROXY,
        ws: true,
        router: ({ url }) =>
          // 配置 api-* 规则
+          /^api-[a-zA-z0-9]+/.test(url) ? `${process.env.VUE_APP_PROXY}${url}` : undefined
      }
    }
  }
}

我这边使用的是axios进行 http 请求,不同接口设置不同的请求头如下

import axios from 'axios'

axios({
  method: 'post',
  url: '/user/12345',
  data: { firstName: 'Fred', lastName: 'Flintstone' },
  baseURL: process.env.VUE_APP_BASE_API_SYSTEM
})

axios.get('/user/123', {
  baseURL: process.env.VUE_APP_BASE_API_XXX
})

部署生产环境一些事情

1. 拦截 IE 访问

项目上决定不支持 IE 访问,为了防止用户使用 IE 访问,在NGINX设置 IE 用户引导页。当用户访问时,Nginx检测$http_user_agent包含MSIE或者Trident则重定向到内置好的 IE 专用引导页。

server {
    # ie 判断
    set $jump "";
    if ($request_uri !~ "/ie") {
      set $jump "yes-ie";
    }
    if ($http_user_agent ~ (MSIE|Trident)){
      set $jump "${jump}-jump";
    }
    if ($jump = "yes-ie-jump") {
      ## 跳转到IE界面
      rewrite ^(/.*)$  http://$http_Host/ie/$is_args$query_string last;
    }

    # ie
    location /ie {
      alias /usr/share/nginx/ie;
      index index.html;

      add_header Cache-Control no-cache;
    }
}

2. 最佳微前端部署架构设计

无论主应用还是微应用都可以统一平级化部署,清晰且能回滚。

先定一个加载微应用的上下文,比如我的是/module/,再来了解一下Qiankun中两个参数

image.png

  1. entry: 官方原意是“微应用的入口”,也就是微应用的真实加载地址,当然还要配合Nginx,假设地址正常的情况下,通过curl或者Postman进行调用时可以正确访问所在页面。
  2. activeRule: 官方原意是“微应用的激活规则”,当配置跟entry不一样时通过curl或者Postman进行调用时,访问 404,这是因为activeRuleQiankun中带自带的路由匹配规则,只有在Qiankun中才会生效。

看着两个是不是有点相似,可以配置为一样也可以配置不一样,配置为一样时,在浏览器直接访问会导致直接加微应用,而不是先主再微,所以建议是配置不一样的规则,比如我的是

;[
  { entry: '/module/micro1/', activeRule: '/module/micro-micro1/' },
  { entry: '/module/micro2/', activeRule: '/module/micro-micro2/' },
  { entry: '/module/micro3/', activeRule: '/module/micro-micro3/' },
  { entry: '/module/micro4/', activeRule: '/module/micro-micro4/' }
]

可以看到activeRule只是在entry前面多了micro-字符,这样子的好处就是无论怎么访问,都不会直接访问到微应用,浏览器初始化访问规则如下

微前端url加载规则.jpg

http://127.0.0.1/login  # 主应用,登录页
http://127.0.0.1/module/ # 主应用,加载微应用准备页
http://127.0.0.1/module/micro-micro1/ # 加载微应用1
http://127.0.0.1/module/micro-micro2/ # 加载微应用2
http://127.0.0.1/module/micro-micro3/ # 加载微应用3
http://127.0.0.1/module/micro-.../ # 加载更多微应用

目录设计

# /home/front/
main
  index.html
  statis
    js
    css
micro1
  index.html
  statis
    js
    css
micro2
micro3
micro4

Nginx 设计

server {
    # 主应用
    location / {
      root /home/front/main;
      index index.html;
      try_files $uri $uri/ /index.html;

      expires -1;
      add_header Cache-Control no-cache;
    }

    # 所有微应用
    location /module {
      # 当匹配到 /module /module/ /module? /module/? /module/main 则重定向到 /
      if ($request_uri ~ "(^\/module$|^\/module\?|^\/module\/$|^\/module\/\?|^\/module\/main)") {
        return http://$http_Host/$is_args$query_string;
      }

      index index.html;
      try_files $uri $uri/ /index.html;
      alias /home/front;

      # 静态资源设置强缓存
      if ($request_filename ~* .*\.(js|css|woff|woff2|png|jpg|jpeg)$) {
        expires 100d;
        add_header header-custom "static";
      }

      # html文件设置不缓存
      if ($request_filename ~* .*\.(?:htm|html)$) {
        expires -1;
        add_header Cache-Control "no-cache";
        add_header header-custom "html";
      }
    }
}

3. Nginx 动态代理 URl

适用于灵活访问第三方服务,当有很多个第三方,无需每个设置代理,只需按照如下配置即可进行访问,但也有一些限制,比如 url 参数。

server {
    # 动态代理
    location ^~ /api-proxy {
      proxy_set_header Host $host:$server_port;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_set_header X-NginX-Proxy true;
      proxy_redirect off;
      client_max_body_size 1000m;

      proxy_pass $arg_url;
      proxy_http_version 1.1;
      add_header arg-url $arg_url;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
    }
}

使用时如下

curl http://127.0.0.1/?url=http://10.16.22.33/

参考链接

总结

Vue2.x+QianKun实现的微前端基本、落地、优化都七七八八了,还差一个404路由自动匹配问题。最后祝正在研究微前端架构的同学快速入门,如果有什么问题我们在评论区见!