vite插件定制化业务之请求上下文切换控制中心

286 阅读11分钟

前言

先给大家讲一下我为什么要编写这个插件。

我们公司后端有多个微服务,对于前端开发而言,数据的来源有多重渠道,而后端的微服务又分多个环境,比如测试环境预发环境正式环境等。相应的,我们前端也有测试环境,预发环境,正式环境。

因此,为了对接这些微服务,我们团队封装了一个负责请求的后端服务的SDK,我们就把它称之为Request对象,后文我们也一律这样称谓。

每个Request对象有自己负责的对应的微服务名称,在同一个项目中,我们需要请求多个微服务的话,就根据需求初始化相应微服务的Request对象即可,然后根据业务逻辑调用这些实例完成相应的数据访问。

表面看目前是没有什么问题的,Request对象根据自己所处的运行时环境,比如当前自己运行在测试环境,那就请求测试环境的微服务。

但是随着业务开发的进行,一个比较囧的场景就出现了,业务线A的后端服务已经上预发环境了,而我们前端当前还在测试环境(读数据接口,只有在预发或者线上才有数据,并且不会造成数据混乱)。 于是我们就有了能够动态切换Request请求服务地址的需求。

思考

上述的能够动态切换Request请求服务地址的需求其实仅仅处于非线上环境(测试,预发环境以及其他环境),线上环境肯定是不需要这样的能力的,因此,我们对我们的请求SDK进行改造,将所有的微服务的正式地址都直接硬编码到SDK内部,然后非线上环境读取localStorage存储的微服务请求地址,如果读取不到,仍然回退到走请求线上环境的逻辑。

在这之前,我已经跟后端团队协商,所有非线上环境的微服务均支持跨域访问

那么,问题就来了,对于PC来说,我们还可以使用浏览器的工具编辑localStorage,但是对于移动端的项目的话是非常不方便的,因此,我们可以参考vConsole这样的插件做一个可以编辑这份存储内容的能力,然后当开发人员点击确定的时候,刷新页面,业务逻辑重新加载,然后读取到配置的存储域,这样的话,就可以很方便的切换请求上下文了。

因为我们的移动端项目迭代周期比较快,每个项目比较小,所以,如果每个项目都去编写一个这样的逻辑是非常不划算的,代码非常冗余。

于是,我想了一下,我们可以编写一个vite插件,首先将编辑localStorage改变请求环境的插件UI(后文统称插件UI)打成一个独立的包,然后vite插件根据生命周期进行拦截,在html中插入一个特征的DOM节点,一会儿vite插件在别的生命周期钩子里面,导入插件UI,当插件UI包执行的时候,将其渲染在之前插入的那个DOM节点的位置上即可。

编码实现

总体上分为3个步骤:

  • 第一步是编写插件UI,用于控制localStorage;
  • 第二步是编写Vite插件,用于将插件UI初始化到业务项目中去;
  • 第三步就是改造SDK,使得它可以根据环境读取请求微服务Host的配置。

插件UI实现

关于插件UI部分,其实它就是一个简单的前端项目,你使用什么实现无所谓,使用框架也无所谓,反正最终都是JS代码执行。

我根据自己的需求,使用的是Vue3编写的,由于我们的业务项目是使用Vue2进行开发的,因为两个版本的Vue有差异,所以我这个插件UI最终在打包的时候,必须将Vue3打包进去,这样在一起配合工作的时候,对方就把插件UI当成一个黑盒即可,不会对现有的环境造成破坏

有的同学觉得可能把Vue3整体打包进去会增加代码的体积,抱歉,这其实没有任何关系的,因为这个代码最终不会在线上环境运行,非线上环境首屏渲染慢一些其实也没关系的

还有2个关键点就是,首先,插件渲染的时候,为了不影响正常的业务项目的逻辑,它最小化时,应该只是一个浮动在页面中的一个小控件,并且开发人员可以拖拽它的位置,当操作它的时候,打开自己的UI界面,然后开发人员在UI界面进行操作,达成修改配置数据的目标。

因此,我对插件UI采用的是固定定位的样式。

以下是最小化控件的代码逻辑(之所以贴出来这个代码逻辑,可能某些同学对拖拽操作的实现有困难)

<template>
  <div
    ref="circle"
    class="circle"
    :style="circleStyle"
    @touchstart="startDrag"
    @touchmove="onDrag"
    @touchend="endDrag"
    @touchcancel="endDrag"
  >
    <!-- 圆圈内部的内容 -->
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const circle = ref(null)

const circleStyle = ref({
  position: 'fixed',
  width: '50px',
  height: '50px',
  touchAction: 'none', // 禁用默认的触摸滚动
})

const PositionKey = '__FUNNY_CONTROL_CENTER__POSITION__'

// 从 localStorage 获取拖拽位置
const getCachedPosition = () => {
  try {
    const position = JSON.parse(localStorage.getItem(PositionKey) || '')
    return position
  } catch (exp) {
    return { left: 100, top: 100 } // 默认位置
  }
}

let isDragging = false
let offsetX = 0
let offsetY = 0
let lastTouchTime = 0 // 上次触摸的时间
const touchTimeout = null // 用于控制双击时间检测

const doubleClickThreshold = 300 // 双击间隔阈值,单位毫秒

const emits = defineEmits(['dblclick'])

// 在组件加载时恢复位置
onMounted(() => {
  const cachedPosition = getCachedPosition()
  circleStyle.value.left = `${cachedPosition.left}px`
  circleStyle.value.top = `${cachedPosition.top}px`
})

const startDrag = (e) => {
  // 记录触摸开始时的位置
  const touch = e.touches[0]
  const rect = circle.value.getBoundingClientRect()
  offsetX = touch.clientX - rect.left
  offsetY = touch.clientY - rect.top
  isDragging = true

  // 禁用默认的滚动行为
  e.preventDefault()

  // 判断是否为双击
  const currentTime = Date.now()
  if (currentTime - lastTouchTime < doubleClickThreshold) {
    emits('dblclick')
    clearTimeout(touchTimeout) // 清除之前的延时操作
  }

  // 更新最后的点击时间
  lastTouchTime = currentTime
}

const onDrag = (e) => {
  if (!isDragging) return

  const touch = e.touches[0]

  // 获取窗口宽高
  const windowWidth = window.innerWidth
  const windowHeight = window.innerHeight

  // 更新圆圈位置
  let left = touch.clientX - offsetX
  let top = touch.clientY - offsetY

  // 限制圆圈位置,确保不会超出屏幕
  left = Math.max(0, Math.min(left, windowWidth - 50)) // 50是圆圈的宽度
  top = Math.max(0, Math.min(top, windowHeight - 50)) // 50是圆圈的高度

  // 更新样式,移动圆圈
  circleStyle.value = {
    ...circleStyle.value,
    left: `${left}px`,
    top: `${top}px`,
  }

  // 禁用默认的滚动行为
  e.preventDefault()
}

const endDrag = () => {
  isDragging = false
  const { left, top } = circleStyle.value
  const position = JSON.stringify({
    left: Number.parseInt(left, 10),
    top: Number.parseInt(top, 10),
  })
  localStorage.setItem(PositionKey, position)
}
</script>

<style scoped>
.circle {
  user-select: none;
  width: 50px;
  height: 50px;
  background-image: url('./assets/logo.png');
  background-size: contain;
  background-position: center;
  z-index: 1000000;
}
</style>

最小化控件采用fixed布局,并且支持记录上一次拖拽停留的位置,以免形成对业务项目的遮挡。

插件主逻辑:

<template>
  <teleport to="body">
    <Circle @dblclick="handleOpen" />
    <ControlCenter v-if="showControlCenter" @confirm="handleConfirm" @cancel="handleCancel" />
  </teleport>
</template>

<script setup>
import { ref } from 'vue'
import Circle from './Circle.vue'
import ControlCenter from './ControlCenter.vue'

const showControlCenter = ref(false)

function handleOpen() {
  showControlCenter.value = true
}

function handleConfirm() {
  window.location.reload()
}

function handleCancel() {
  showControlCenter.value = false
}
</script>

插件对localStorage的处理我就不向大家展示了,纯业务逻辑代码,仅仅给大家展示一个截图即可,如果有需求,可以私信联系我获取。

image.png

最后是配置我们的package.json和vite.config.ts。

原则上来说,我们的这个插件UI是不带任何依赖的,在前文就已经提到过了,我们将会把Vue3整体打包进去,所以,插件UI的所有依赖,全部都写到devDependencies里面去,关于dependencies和devDependencies的区别,有疑问的同学请自行查阅资料。

{
  "name": "@funny/control-center",
  "version": "0.0.0",
  "module": "dist/funny-control-center-plugin.es.js",
  "main": "dist/funny-control-center-plugin.umd.js",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {},
  "devDependencies": {
    "amfe-flexible": "^2.2.1",
    "vant": "^4.9.9",
    "vue": "^3.5.13",
    "@vant/auto-import-resolver": "^1.2.1",
    "@vitejs/plugin-vue": "^5.2.1",
    "ress": "^5.0.2",
    "typescript": "~5.6.2",
    "unplugin-auto-import": "^0.18.5",
    "unplugin-vue-components": "^0.27.5",
    "vite": "^6.0.1",
    "vite-plugin-css-injected-by-js": "^3.5.2",
    "vue-tsc": "^2.1.10"
  }
}

最后,是将插件UI打包成一个npm包,这一点可能有些同学比较陌生,因为我们平时都是在开发项目,项目最终直接就部署了(所以这也是平时在业务项目开发中为什么我们感受不到devDependencies和dependencies的差异的原因)。而项目内容是一大堆牛鬼蛇神的集合,我们把这些内容渲染到HTML的某个节点上即可,至于这些牛鬼蛇神是你自己本地的还是来源于NPM包,其实并没有太大的关系,所以大家对这个事儿不用觉得很神奇。

我们需要将vite配置成打包成lib包的模式,并且,为了简化导入,采用插件将项目的内容全部都打进JS中,这样别人在导入的时候仅仅只需要写一个import语句就可以了。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { VantResolver } from '@vant/auto-import-resolver'
// 此插件可以将css一同打包进JS
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/main.ts',
      name: 'FunnyControlCenter',
      fileName: (format) => `funny-control-center-plugin.${format}.js`,
      formats: ['umd', 'es'],
    },
  },
  plugins: [
    vue(),
    cssInjectedByJsPlugin(),
    AutoImport({
      resolvers: [VantResolver()],
    }),
    Components({
      resolvers: [VantResolver()],
    }),
  ],
})

最后给大家看一下我的TS主入口文件:

import 'ress'
import 'amfe-flexible'
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

const app = createApp(App)

app.mount('#funny-control-center')

插件UI需要一个id为xxx的元素,这是以后我们的业务项目在运行时需要为其准备的,贴出来主要是向大家阐述其中的道理。

至此,我们对于插件UI的部分编码就已经完成了。

Vite插件的实现

在前面插件部分我们已经提过了,插件UI需要一个id为xxx的元素节点用来渲染它自身的逻辑。所以,我们需要控制Vite的行为,要在html加入一个id为xxx的元素。

这个插件的执行顺序应该是最前面的,所以我们应该指定插件执行方式为enforce: pre

vitejs.cn/vite5-cn/gu…

在这个时候,我们需要利用Vite独有的生命周期tansformIndexHtml,我们需要在插件中插入一个id为xxx的元素。

关于这个生命周期,还有其它的操作Html的方式,有兴趣的同学自行查阅Vite的文档。

然后,我们需要在合适的时机导入之前我们编写的插件UI,正常我们就在Vite项目的入口文件main.js中导入。

这个时候,我们又需要利用一个生命周期,transform,这个是Rollup的生命周期。

rollupjs.org/plugin-deve…

在这个生命周期中,它给我们的是源代码和这个文件的ID,如果我们返回null的话,代表当前插件不对这个内容进行处理,如果我们要进行处理的话,可以返回一个{ code: '', map: '' }这样的对象,完成对源代码的编译,这个map代表的就是编译之后的sourceMap,如果没有的话,map字段为null即可。

因为用户的入口文件不一定是main.js,所以我们的插件需要接受一个参数,参数为用户指定的Vite项目入口文件的路径。

好了,通过以上逻辑的分析,以下就是我们的插件的具体实现了:

interface FunnyControlCenterPluginOptions {
  entry?: string | string[]
}

export function funnyControlCenterPlugin(options: FunnyControlCenterPluginOptions) {
  return {
    name: 'vite-plugin-funny-control-center',
    // 指定插件的配置为最先执行
    enforce: 'pre',
    transformIndexHtml(html: string) {
      // 生产环境不做任何转换
      if (process.env.NODE_ENV === 'production') {
        return html
      }
      // 非生产环境插入funny control center的渲染DOM节点
      const customDom = `<div id="funny-control-center"></div>`
      return html.replace('<body>', `<body>${customDom}`)
    },
    transform(sourceCode: string, id: string) {
      // 生产环境
      if (process.env.NODE_ENV === 'production') {
        return null
      }
      const { entry = 'src/main.js' } = options
      // Compatible to solve the windows path problem
      let entryPath = Array.isArray(entry) ? entry : [entry]
      if (process.platform === 'win32') {
        entryPath = entryPath.map((item) => item.replace(/\\/g, '/'))
      }
      if (
        entryPath.some((v) => {
          return id.indexOf(v) >= 0
        })
      ) {
        const insertCode = `
        // eslint-disable-next-line
        import '@funny/control-center';`
        // 将插入的代码放到文件的最顶部,同时注意不要给业务项目造成ESlint的错误,若业务项目有配置ESLint
        return {
          code: `${insertCode}\n${sourceCode}`,
          map: null,
        }
      }
      return null
    },
  }
}

SDK代码逻辑的改造

以下的代码已经经过我的脱敏处理,代码已经不能正常运行,但是却不妨碍我们的正常的业务流程的阐述。

/**
 * Service服务名称,默认是DEFAULT,不做任何处理
 */
export type ServiceName = 'A' | 'B' | 'C' | 'D'

export type RuntimeEnv = 'testing' | 'production' | 'preview' | 'development'

interface HostDefine {
  A: string
  B: string
  C: string
  D: string
}

class Request {
  /**
   * 服务名称
   */
  private serviceName: ServiceName = 'D'

  // 创建axios实例
  private instance!: AxiosInstance

  constructor(serviceName: ServiceName) {
    this.serviceName = serviceName
    this.createInstance()
  }

  /**
   * 默认都在线上的配置
   */
  private normalHostConfig = {
    A: 'https://xxx.com',
    B: 'https://xxx.com',
    C: 'https://xxx.com',
    D: '',
  }

  private hostConfig(serviceName: ServiceName) {
    // NOTE: 为了TreeShaking
    if (process.env.NODE_ENV === 'production') {
      return this.normalHostConfig[serviceName]
    }
    try {
      // NOTE: 这个Key不要随意乱改,这个是和@funny/control-center插件对应的
      const userDevHostSelectionConfig = localStorage.getItem('__FUNNY_CONTROL_CENTER_HOST_SELECTION__') || ''
      // 读取到的用户Host配置
      const userDevHostSelection = JSON.parse(userDevHostSelectionConfig) as HostDefine
      const customConfig = {
        ...this.normalHostConfig,
      }
      Object.entries(userDevHostSelection).forEach(([prop, value]) => {
        // 有值才覆盖,没有就用默认的
        if (value) {
          ;(customConfig as any)[prop] = value
        }
      })
      return customConfig[serviceName]
    } catch (exp) {
      console.log('没有解析到用户的HOST环境配置')
      // 没有解析到配置,直接使用线上的配置
      return this.normalHostConfig[serviceName]
    }
  }

  private createInstance() {
    let baseURL = this.hostConfig(this.serviceName)
    this.instance = axios.create({
      timeout: 30000,
      baseURL,
      headers: defaultHeaders,
      transformRequest: [
        function (data, headers) {
          // 对于FormData不进行任何转换
          if (data instanceof FormData) {
            return data
          }
          // 指定以JSON的形式传递给后端
          if (data?.[symbolJson] === true) {
            headers['Content-Type'] = 'application/json; charset=utf-8'
            // 删除这个临时字段,不发送给后端
            delete data?.[symbolJson]
            return JSON.stringify(data)
          }
          headers['Content-Type'] = 'application/x-www-form-urlencoded'
          // 以www-urlencoded的形式传递给后端
          return qs.stringify(data)
        },
      ],
    })
  }
}

可以看到,在创建axios实例的时候,我们读取了当前需要的Host,在里面进行了一个先决判断,当项目构建的时候,process.env.NODE_ENV === 'production'这行代码会被替换为true,这样,if分支后续的代码都是deadcode,这样构建工具就将这一大串代码都消掉了,从而减少了我们的构建体积。

插件运行效果

引入插件:

import { funnyControlCenterPlugin } from '@funny/vite-plugin-funny-control-center'

export default defineConfig({
    // 其它配置已省略
    plugins: [
        funnyControlCenterPlugin({
          entry: 'src/main.ts',
        }),
    ]
})

我选了一个可爱的哆啦梦作为最小化控件的背景图。 image.png 当双击最小化控件的时候,打开插件的配置界面: image.png 我们可以看到,此刻请求的微服务对应的是预发环境的地址。 image.png 再将其切换回线上,可以看到,请求的是线上环境的地址。 image.png image.png

总结

本文以公司的项目例子向大家阐述了一个修改环境变量的Vite插件的设计和开发过程。我们之所以能使用这种方式离不开几个重要的先决条件:

  • 多个微服务、多环境需要混合使用。
  • 微服务均支持跨域调用。
  • 团队统一使用封装的请求SDK,接口请求业务逻辑收敛到了一处。

本文在设计过程中,广泛利用到了前端工程化的知识,即增加了对非线上环境的友好访问支持,又不对线上环境引入新的副作用,大家可以好好体会一些这些设计思路。

关于Vite插件,本质上其实并不是神秘的东西,它只不过是利用了Vite暴露出来的API进行一些构建工具行为的定制。

对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。