定制一个小程序版vconsole (下)-- network面板等功能开发
功能演示
源码与使用文档
本插件已开源:
在入口处执行初始化
首先需要在小程序的入口插入一段脚本,执行一些初始化操作:
- 拦截各平台下的request api
- 记录http请求与响应
- 创建全局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中的ajax
和fetch
), 另外,因为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请求的过滤和响应信息的格式化。
- 首先对各平台的
request
请求的api
进行拦截 - 创建
Recorder
实例辅助存取http记录
- 业务内发起http请求后,拦截到请求信息,通过
recorder
存储记录,返回记录id
- 从请求的
options
中取出响应回调complete
,进行包装拦截 - 调用当前平台真正
request
的api
发起http
请求 - 拿到响应信息,通过
recorder
和记录id
,更新记录的响应信息及耗时 - 调用业务逻辑的回调函数
complete
,返回响应信息
使用发布订阅管理视图更新
另外,记录信息最终要展示到页面上,当有记录被更新时,我们需要手动来管理视图的更新,因此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
实例来存取这些公共数据
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组件信息
以组件内的隔离网关功能为例,初始时自定义隔离网关为空
此时从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组件功能开发
至此,前置工作都已经做好:
- 将devtool组件自动注入每个页面(
page
) - 自动在入口插入初始化脚本
- 初始化插件,拦截并记录http请求、创建全局store
下面进入devtool组件的开发:悬浮窗、各功能面板。
悬浮窗
悬浮窗的逻辑主要在于拖拽操作,需要实现下列功能:
- 拖拽移动
- 自动吸附到最近边界
- 由于各页面的悬浮窗组件并不是同一个实例,拖拽后需同步各页面悬浮窗位置
使用ElDrag
来实现上述功能,过程其实比较清晰:
- 获取当前窗口大小
- 获取开始拖拽时的位置
- 手指移动时改变悬浮窗的位置
- 停止拖拽时根据窗口大小和悬浮窗的当前位置计算出悬浮窗应该吸附的位置
- 移动悬浮窗到边界
特殊的需要需要的是,初始化时将拖拽实例进行缓存,每个拖拽实例的位置发生变化时,遍历拖拽实例,同步悬浮窗位置。
/**
* 拖拽元素
* :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面板
展示
在上面我们已经记录了每个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页面的功能
添加用于打开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())}`
})
}
页面信息
在真机测试时,为方便测试提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'
})
}
}
}
更多功能
查看埋点信息、隔离网格,都是针对公司内具体的场景开发的功能,基于插件的基础能力,可以针对自己公司内的具体场景,发掘更多的功能。
转载自:https://juejin.cn/post/7259621864917844027