定制一个小程序版vconsole (下)-- network面板等功能开发

833 阅读5分钟

在上篇文章定制一个小程序版vconsole (上)-- 在小程序各页面自动插入组件中,我们解决了在小程序各页面统一注入组件的问题,本章来讲解一下具体的各个功能如何开发。

2023-07-24 17.28.15.gif
功能演示

源码与使用文档

本插件已开源:

在入口处执行初始化

首先需要在小程序的入口插入一段脚本,执行一些初始化操作:

  1. 拦截各平台下的request api
  2. 记录http请求与响应
  3. 创建全局store存储公共数据
import Vue from 'vue'
import Recorder from './Recorder' // http请求记录器
import Store from './Store.js' // 插件共享数据
import { rewriteRequest } from './request' // 重写各平台request api

Vue.prototype.$devToolStore = new Store() // 存取共享数据
const recorder = Vue.prototype.$recorder = new Recorder() // 读取http记录

rewriteRequest(recorder)
Recorder.clearStatic() // 清空历史记录

console.log('初始化成功')

拦截各平台request api

要实现埋点network面板功能,我们需要将小程序中所有的http请求的api进行拦截(类似重写js中的ajaxfetch), 另外,因为uni是跨平台的,虽然提供了统一里的uni.request,但不同平台的api依然可以单独使用,因此为适应多平台,需要分开重写。

export function rewriteRequest (recorder) {
  // mp-weixin mp-alipay 获取当前平台
  const platform = process.env.VUE_APP_PLATFORM;
  let request;
  // 重写各平台下的http请求api
  switch(platform){
    case 'mp-weixin':
      request= wx.request
      Object.defineProperty(wx, 'request', { writable: true })
      wx.request = customRequest
      break;
    case 'mp-alipay':   
      request= my.request
      Object.defineProperty(my, 'request', { writable: true })
      my.request = customRequest
      break;
    // ... 更多平台
    default: 
      request= uni.request
      Object.defineProperty(uni, 'request', { writable: true })
      uni.request = customRequest
      break;
  }
  function customRequest (options) {
    // 拦截http请求
    // ...
    // 记录请求信息
    const id = recorder.addRecord(options)
    
    // 拦截响应信息
    const { complete } = options
    function _complete (...args) {
      typeof complete === 'function' && complete.apply(_this, args)
      // 记录响应信息
      recorder.addResponse(id, ...args)
    }
    
    return request.call(_this, { ...options, complete: _complete })
  }
}


记录http请求

http请求进行记录,在network面板进行展示。另外可以针对具体业务场景,进行格式化或过滤操作,如本插件的埋点查看功能,本质上就是对http请求的过滤和响应信息的格式化。

  1. 首先对各平台的request请求的api进行拦截
  2. 创建Recorder实例辅助存取http记录
  3. 业务内发起http请求后,拦截到请求信息,通过recorder存储记录,返回记录id
  4. 从请求的options中取出响应回调complete,进行包装拦截
  5. 调用当前平台真正requestapi发起http请求
  6. 拿到响应信息,通过recorder和记录id,更新记录的响应信息及耗时
  7. 调用业务逻辑的回调函数complete,返回响应信息

未命名文件.jpg

使用发布订阅管理视图更新

另外,记录信息最终要展示到页面上,当有记录被更新时,我们需要手动来管理视图的更新,因此Recorder使用发布订阅(vue event bus)来通知视图记录的更新。

import Vue from 'vue'
import { REQUESTS_STORAGE_KEY, RESPONSES_STORAGE_KEY } from './constant'
const UPDATE = 'update'

export default class Recorder {
  constructor () {
    this.checkStorageSize() // 检查并清理storage
    this.bus = new Vue()
  }

  // 订阅更新
  onUpdate(handler) {
    this.bus.$on(UPDATE, handler)
  }
  
  // 取消订阅
  offUpdate(handler) {
    this.bus.$off(UPDATE, handler)
  }
  
  // 发布更新
  emitUpdate(records) {
    this.bus.$emit(UPDATE, records)
  }

  addRecord (options) {
    try {
      const records = this.getAll()
      const record = this.formatRequest(options)
      records.unshift(record)
      uni.setStorageSync(REQUESTS_STORAGE_KEY, records)
      this.emitUpdate(records)
      return record.id
    } catch (error) {
      console.error(error)
    }
  }

  updateRecord (id, options) {
    const now = +new Date()
    try {
      const records = this.getAll()
      const record = records.find(record => record.id === id)
      if (record) {
        Object.assign(record, options, {
          time: now - record.startTime
        })
        uni.setStorageSync(REQUESTS_STORAGE_KEY, records)
        this.emitUpdate(records)
      }
    } catch (error) {
      console.error(error)
    }
  }

  getAll () {
    let records = []
    try {
      records = uni.getStorageSync(REQUESTS_STORAGE_KEY) || []
      if (typeof records === 'string') {
        // 兼容遗留问题
        records = JSON.parse(records)
      }
    } catch (error) {
      console.error(error)
    }
    return records
  }

  formatRequest (options) {
    let { url, data, header, method, dataType } = options
    const id = Date.now()
    const reqDataType = typeof data
    if (data && reqDataType !== 'string' && reqDataType !== 'object') {
      data = '非文本或json'
    }

    return {
      url,
      data,
      header,
      method,
      dataType,
      id,
      startTime: +new Date()
    }
  }

  clear () {
    this.emitUpdate([])
    Recorder.clearStatic()
  }

  static clearStatic () {
    uni.removeStorageSync(REQUESTS_STORAGE_KEY)
    uni.removeStorageSync(RESPONSES_STORAGE_KEY)
  }

  checkStorageSize () {
    try {
      const { currentSize, limitSize } = uni.getStorageInfoSync()
      if (currentSize > limitSize * 0.5) {
        const records = this.getAll()
        // 删除一半数据
        const deletedRecords = records.splice(records.length / 2)
        const allResponse = uni.getStorageSync(RESPONSES_STORAGE_KEY) || {}
        deletedRecords.forEach(record => {
          delete allResponse[record.id]
        })
        uni.setStorageSync(REQUESTS_STORAGE_KEY, records)
        uni.setStorageSync(RESPONSES_STORAGE_KEY, allResponse)
        this.emitUpdate(records)
      }
    } catch (error) {
      console.log('error', error)
    }
  }

  addResponse (id, res) {
    // 对request的成功回调进行切片,记录响应值
    try {
      const allResponse = uni.getStorageSync(RESPONSES_STORAGE_KEY) || {}
      const response = JSON.parse(JSON.stringify(res))
      this.updateRecord(id, {
        status: response.statusCode
      })
      allResponse[id] = response
      uni.setStorageSync(RESPONSES_STORAGE_KEY, allResponse)
      this.checkStorageSize()
    } catch (error) {
      console.error('格式化响应失败', error)
    }
  }

  getResponse (id) {
    try {
      const allResponse = uni.getStorageSync(RESPONSES_STORAGE_KEY) || {}
      return allResponse[id]
    } catch (error) {
      console.error('读取相应失败', error)
      return {}
    }
  }
}

创建store存储公共数据

根据上文所说,因为小程序是多页应用,devtool组件会在每个页面内注入,即每个页面的devtool组件都是新的实例,而我们需要在不同页面中保持devtool组件的状态相同,如可拖拽的悬浮图标的位置、当前打开的面板tab...

因此需要在入口处初始化一个Store实例来存取这些公共数据

未命名文件 (3).jpg

import { STORE_STORAGE_KEY } from './constant'

export default class Store {
  constructor () {
    this.instances = [] // 组件实例列表
    this.data = uni.getStorageSync(STORE_STORAGE_KEY) || {} // 所有需同步数据
  }

  /**
   * 注册(缓存)组件实例
   * @param {Object} instance Vue组件实例
   */
  register (instance) {
    this.instances.push(instance)
  }

  /**
   * 销毁缓存的组件实例
   * @param {Object} instance 待销毁组件实例
   */
  unregister (instance) {
    const index = this.instances.findIndex(item => item === instance)
    index > -1 && this.instances.splice(index, 1)
  }

  get (key) {
    return this.data[key]
  }

  /**
   * 设置值
   * @param {String} key
   * @param {any} value
   * @param {Object} instance 可选,传入当前实例避免重复设置当前实例值(watch可能造成错误)
   */
  set (key, value, instance) {
    this.data[key] = value
    this.instances.forEach(ins => {
      if (ins !== instance && ins[key] !== value) {
        ins[key] = value
      }
    })
    uni.setStorageSync(STORE_STORAGE_KEY, this.data)
  }
}

同步各页面devtool组件信息

以组件内的隔离网关功能为例,初始时自定义隔离网关为空

image.png

此时从a页面进入b页面,会在a、b页面各创建一个devtool组件,若在b页面修改了网关信息,返回a页面,需要保证a页面的数据被更新。

使用发布订阅模式,对需要保持数据同步的组件进行订阅,当数据更新时,发布更新通知,通知组件视图更新。

封装组件的订阅和取消订阅为mixin

export default {
  created () {
    this.$devToolStore.register(this)
  },
  destroyed () {
    this.$devToolStore.unregister(this)
  }
}

需要订阅的组件,注册mixin

import storeMixin from '../../mixins/storeMixin'

export default {
    {
      // 注册
      mixins: [storeMixin],
      data () {
        return {
          // 获取初始数据
          gatewayTag: this.$devToolStore.get('gatewayTag') || ''
        }
      },
      methods: {
        handleTagChange () {
          // 更新store中的数据,自动同步其他组件
          this.$devToolStore.set('gatewayTag', this.gatewayTag)
        }
      },
    }
}

devtool组件功能开发

至此,前置工作都已经做好:

  1. 将devtool组件自动注入每个页面(page)
  2. 自动在入口插入初始化脚本
  3. 初始化插件,拦截并记录http请求、创建全局store

下面进入devtool组件的开发:悬浮窗、各功能面板。

悬浮窗

2023-07-25 10.45.18.gif

悬浮窗的逻辑主要在于拖拽操作,需要实现下列功能:

  • 拖拽移动
  • 自动吸附到最近边界
  • 由于各页面的悬浮窗组件并不是同一个实例,拖拽后需同步各页面悬浮窗位置

使用ElDrag来实现上述功能,过程其实比较清晰:

  1. 获取当前窗口大小
  2. 获取开始拖拽时的位置
  3. 手指移动时改变悬浮窗的位置
  4. 停止拖拽时根据窗口大小和悬浮窗的当前位置计算出悬浮窗应该吸附的位置
  5. 移动悬浮窗到边界

特殊的需要需要的是,初始化时将拖拽实例进行缓存,每个拖拽实例的位置发生变化时,遍历拖拽实例,同步悬浮窗位置。

/**
 * 拖拽元素
 * :style="{'transform':`translate(${drag.x}px, ${drag.y}px)`}"
    @touchstart="e => drag.start(e)"
    @touchmove.stop.prevent="e => drag.move(e)"
    @touchend="e => drag.end(e)"
 */

const STORAGE_POSITION_KEY = 'wy_mp_devtool_icon_position'

const iDrags = [] // 缓存ElDrag实例, 在一个实例更新位置后,通知其他实例更新

export class ElDrag {
  constructor (menuRef) {
    iDrags.push(this)

    // this.menuRef = menuRef 会报错
    Object.defineProperty(this, 'menuRef', {
      value: menuRef,
      writable: false
    })

    this.getBasicInfo()
    this.x = 0
    this.y = 0

    // 当前坐标
    this.curPoint = {
      x: 0,
      y: 0
    }
    // 拖动原点(相对位置,相对自身)
    this.startPoint = {}
  }

  async getBasicInfo () {
    await Promise.all([
      this.getRefInfo().then(data => {
        const { width, height, left, top } = data
        this.width = width
        this.height = height
        this.left = left
        this.top = top
      }),
      this.getSystemInfo().then(info => {
        this.windowWidth = info.windowWidth
        this.windowHeight = info.windowHeight
      })
    ])
    this.sideDistance = await this.getSideDistance()
  }

  async start (ev) {
    // 记录一开始手指按下的坐标
    const touch = ev.changedTouches[0]
    this.startPoint.x = touch.pageX
    this.startPoint.y = touch.pageY

    this.curPoint.x = this.x
    this.curPoint.y = this.y
  }

  async move (ev) {
    /**
     * 防止页面高度很大,出现滚动条,不能移动-默认拖动滚动条事件
     * https://uniapp.dcloud.io/vue-basics?id=%e4%ba%8b%e4%bb%b6%e4%bf%ae%e9%a5%b0%e7%ac%a6
     * 使用修饰符处理(出现滚动条,用下面方方式依然可滚动)
     */
    // ev.preventDefault()
    // ev.stopPropagation()

    const touch = ev.changedTouches[0]
    const diffPoint = {} // 存放差值
    diffPoint.x = touch.pageX - this.startPoint.x
    diffPoint.y = touch.pageY - this.startPoint.y
    // 移动的距离 = 差值 + 当前坐标点
    const x = diffPoint.x + this.curPoint.x
    const y = diffPoint.y + this.curPoint.y

    const { left, right, top, bottom } = this.sideDistance
    if (x >= left && x <= right) {
      this.x = x
    }
    if (y >= top && y <= bottom) {
      this.y = y
    }
  }

  end (ev) {
    this.moveToSide()
  }

  /**
   * 获取当前拖拽元素的信息
   * @returns {Promise<Object>}
   */
  getRefInfo () {
    return new Promise(resolve => {
      this.menuRef
        .boundingClientRect(data => {
          resolve(data)
        })
        .exec()
    })
  }

  getSystemInfo () {
    return new Promise(resolve => {
      uni.getSystemInfo({
        success: info => {
          resolve(info)
        }
      })
    })
  }

  /**
   * 移动到边界
   */
  async moveToSide () {
    const { x, y } = await this.getSidePosition()

    this.x = x
    this.y = y
    uni.setStorageSync(STORAGE_POSITION_KEY, { x, y })

    iDrags.forEach(iDrag => {
      if (iDrag !== this) {
        iDrag.x = x
        iDrag.y = y
      }
    })
  }

  /**
   * 获取移动到边界时的坐标
   */
  async getSidePosition () {
    const refInfo = await this.getRefInfo()
    const { width, height, left, top } = refInfo
    const { windowWidth, windowHeight } = this

    let x = this.x
    let y = this.y

    if (left + width / 2 < windowWidth / 2) {
      // 移动到左边界
      x = this.sideDistance.left
    } else {
      // 移动到右边界
      x = this.sideDistance.right
    }

    if (top < 0) {
      // 移动到上边界
      y = this.sideDistance.top
    } else if (top + height > windowHeight) {
      // 移动到下边界
      y = this.sideDistance.bottom
    }

    return { x, y }
  }

  async getSideDistance () {
    const sideSpace = 5 // 边距

    const refInfo = await this.getRefInfo()
    const { width, height, left, top } = refInfo
    const { windowWidth, windowHeight } = this

    const position = {
      left: 0,
      right: 0,
      top: 0,
      bottom: 0
    }

    position.left = -left + sideSpace
    position.right = windowWidth - left - width - sideSpace
    position.top = -top + sideSpace
    position.bottom = windowHeight - top - height - sideSpace
    return position
  }

  /**
   * 移除缓存的实例,防止内存泄漏
   */
  destroy () {
    const i = iDrags.indexOf(this)
    iDrags.splice(i, 1)
  }
}

在devtool组件创建时初始化拖拽实例,组件销毁时销毁拖拽实例

<template>
    <div class="container">
        <view
            id="menu"
            :style="{transform: `translate(${drag.x || 0}px, ${drag.y || 0}px)`}"
            @touchstart="(e) => drag.start(e)"
            @touchmove.stop.prevent="(e) => drag.move(e)"
            @touchend="(e) => drag.end(e)"
            @click="showPopup = !showPopup"
        >
        悬浮窗
        </view>
        
        <wy-devtool-popup v-model="showPopup" >
        弹出功能面板
        </wy-devtool-popup>
    </div>
</template>
<script>
import { ElDrag } from './util/index.js'

export default {
    data () {
        return {
          menus: menus,
          showPopup: false,
          curMenu: menus[0] || {},
          drag: {}
        }
    },
    methods: {
        handleMenuClick (menu = {}) {
          this.curMenu = menu
        }
    },
    created () {
        const query = uni.createSelectorQuery().in(this)
        const menuRef = query.select('#menu')
        this.drag = new ElDrag(menuRef)
    },
    destroyed () {
        this.drag.destroy()
    }
}
</script>

埋点和network面板

image.pngimage.png

展示

在上面我们已经记录了每个http请求的network信息,并且在Vue原型上挂载了$recorder来读取记录,因此在组件中只要通过$recorder来获取记录并展示即可,埋点只是对记录的过滤和特殊格式化

更新

Recorder中创建了发布订阅系统,因此我们在需要显示和更新记录的组件内进行订阅,同时要记得组件销毁时取消订阅:

created () {
    const onUpdate = (records) => this.records = records
    this.$recorder.onUpdate(onUpdate)
    // 取消订阅
    this.$once('hook:beforeDestroy', () => this.$recorder.offUpdate(onUpdate))
}

打开H5

有时要在小程序中查看H5页面的显示效果,但是在小程序中并没有入口,这种场景测试起来就比较麻烦,于是提供了扫码/输入url打开H5页面的功能

2023-07-25 15.18.53.gif

添加用于打开H5页面的webview路由

要打开H5页面需要一个页面能用来显示webview,我们不可能要求使用插件的用户提供这个页面,因此需要的插件在初始化时添加一个路由。

在上文中,讲解过组件插入的逻辑,在这个过程中我们会拿到路由配置文件pages.json,同理我们可以在拿到pages.json后,添加一个路由,用来展示webview。

首先需要一个H5Webview.vue文件,逻辑很简单,从url参数获取要展示的h5页面地址,在webview中展示

<template>
  <web-view :src="url" />
</template>

<script>
export default {
  data() {
    return {
      url: ''
    }
  },
  mounted() {
    this.getUrl()
  },
  methods: {
    getUrl() {
      /* eslint-disable-next-line */
      const routes = getCurrentPages()
      const page = routes?.[routes.length - 1] || {}
      const url = decodeURIComponent(page.options.url)
      this.url = url
    }
  }
}
</script>

在插件的入口处,添加路由

const fs = require('fs')

const addH5Webview = function (source, resourcePath, config) {
  const { h5WebviewPath, pagesJsonPath } = config
  // 判断当前编译的文件是否是路由文件(pages.json)
  if (pagesJsonPath.toLowerCase() === resourcePath.toLowerCase()) {
    const json = JSON.parse(fs.readFileSync(pagesJsonPath, 'utf-8'))
    // 添加H5Webview路由
    json.pages.push({
      path: h5WebviewPath,
      style: {
        navigationBarTitleText: '加载中',
      },
    })
    source = JSON.stringify(json)
  }
  return source
}

打开H5页面

打开H5的逻辑就很简单了,扫码或从输入框获取到要打开的url,将其拼接到刚才创建的路由路径后面进行跳转。

goH5(url) {
  const path = defaultConfig.h5WebviewPath
  uni.navigateTo({
    url: `/${path}?url=${encodeURIComponent(url.trim())}`
  })
}

页面信息

image.png

在真机测试时,为方便测试提bug,可展示当前页面的路由、参数等信息,虽然简单也是比较实用的功能。

切换隔离网关

我的公司内不同测试环境使用网关进行隔离,隔离信息放在请求的header中,写死在前端的配置文件中。若后端切换了网关,前端需要修改配置文件,重新发布。

这里我们已经拦截了http请求,那么就可以提供一个配置网关界面,修改隔离网格,在拦截到http请求后,使用修改的网格进行覆盖。

此部分逻辑也并不复杂,主要需要注意通过Store来同步各页面devtool组件的tag信息,这在上文同步各页面devtool组件信息章节已进行说明。

import storeMixin from '../../mixins/storeMixin'

export default {
  name: 'mp-dev-tool-gateway-tag',
  mixins: [storeMixin],
  data () {
    return {
      gatewayTag: this.$devToolStore.get('gatewayTag') || ''
    }
  },
  methods: {
    handleTagChange () {
      this.$devToolStore.set('gatewayTag', this.gatewayTag)
      uni.showToast({
        title: '设置成功',
        duration: 1000,
        icon: 'none'
      })
    }
  }
}

更多功能

查看埋点信息、隔离网格,都是针对公司内具体的场景开发的功能,基于插件的基础能力,可以针对自己公司内的具体场景,发掘更多的功能。