下篇:定制一个小程序版vconsole (下)-- network面板等功能开发
在h5开发时,我们经常会引入vconsole
或者eruda
,基本功能也够用了,在微信小程序的开发环境也内置了小程序版的vconsole,但是没有network面板
,真机测试时就必须连电脑抓包,非常麻烦。
另外,我们的埋点是用gif图的url参数上报,验证埋点数据时直接看url上的一坨,也很难识别,于是便想自己开发一个调试工具,能查看network,可以格式化出埋点数据。
最终开发中结合实际需求,实现了以下功能:
- network查看
- 埋点数据格式化
- 扫码打开h5页面
- 查看页面基本信息
- 切换隔离网关
- 支持全局注入自定义组件
源码与使用文档
本插件已开源:
ps.项目的小程序基于uni-app开发的,因此此插件也是基于uni-app的框架开发,理论上也支持uni-app的多平台特性。使用taro或者原生开发话,原理是一样的,可以自行实现。
功能演示
直接使用
如果你不想定制自己的功能,只使用我已经实现的基础功能,或者想先体验一下,可以直接安装 npm地址使用。
其中埋点数据格式化和切换网关隔离的功能,是公司内的具体业务,这里进行了移除,有需要的话可以自行实现,埋点查看其实就是对network的数据进行过滤和格式化展示;修改网格隔离是在发送请求时修改header。在拦截了network后,这两个功能都很好实现。
调试工具的基本工作原理
对于传统的调试工具(vconsole或eruda),把功能封装为一个组件,在应用初始化时将组件插入到根节点
下就可以了。对于单页应用,在应用入口(一般是main.js
)将组件插入到html
节点下即可。
但是小程序不提供根节点
,无法用上面常规手段使用js让组件在每个页面可用。因此本上篇先解决如何在小程序中全局注入组件
这个前置的问题。
小程序内如何全局注入组件
手动在页面引入组件
首先假设功能封装好的组件是my-devtool
,要在某个页面内使用,最常规的方法是:
- 在页面中引入组件并声明
- 在template中调用组件
<template>
<my-devtool />
this is home page
...
</template>
<script>
import MyDevtool from 'my-devtool'
export default {
name: "Home",
components: {
MyDevtool
}
}
</script>
在home页面内引入MyDevtool组件
在每个页面自动引入组件
作为一个调试
工具,我们不可能手动在每个页面都这么做,应该实现以下目标:
- 自动在每个页面内引用并调用组件
- 不能入侵源码,编译后的产物才包含插件的引用
- 可以通过配置控制引入时机,如配置只在开发环境下生效
对于上面几点,如果你使用webpack开发,是不是很容易就想到,这不就是loader的功能么?
事实上,uni-app
的框架基于vue-cli
,而vue-cli
基于webpack
,那么开发一个webpack的loader来实现上述的功能就可以了。
当然,对于原生小程序,通过自行配置webpack、gulp、rollup,写对应的插件实现也是类似的。
webpack loader
webpack的loader不是本篇讲解的重点,仅做一点简单的介绍吧。
webpack本身只能编译标准js
语法的文件,但是现在有很多自定义的语法,比如vue的模板语法,react的jsx,webpack自己是无法处理的。
编译的过程中loader的处理可以看做一个管道,在webpack的配置文件中,可以配置每种文件都要经过哪些loader进行处理。
webpack -> a.vue -> loader1 -> loader2 -> loader3 -> ...[标准js文件] -> webpack
当webpack编译到a.vue时,首先根据配置文件判断需要哪些loader进行预处理,按照顺序把文件内容一直往下传递,下一个节点得到的内容是上一个节点返回的内容(字符串),最后再给webpack处理时,就是标准的js语法内容了。 因此在这个过程中通过loader,可以做很多事情:
- 字符串替换
- 语法转换、
- 移除console
- ...
插入代码
插入代码,就是我们需要做的事情了。
对于一个最基本的loader,形式非常简单,就是一个函数,接收字符串,处理后返回新的字符串:
module.exports = function (source) {
// this上挂载了一些对象,如resourcePath当前文件路径,用户配置项等,因此不能用箭头函数
const { resourcePath } = this
return source.replace('a', 'b')
}
上面的loader将文件的字符a替换为b,传递给下一个loader
分析loader
uni-devtool
入口
如上面loader介绍,一个loader就是一个函数。
const { injectComponents, injectJs, getOptions } = require("./utils")
module.exports = function (source) {
// 默认情况下如果待处理文件未发生变化, 会使用缓存的loader处理结果, 调试loader时可禁用缓存
// this.cacheable(false);
const options = getOptions(this) // 获取用户的loader配置
const { resourcePath } = this // 当前处理的文件路径
// 在入口执行初始化脚本,注册全局组件、拦截http请求等
source = injectJs(source, resourcePath, options)
// 在页面中注入组件调用代码
source = injectComponents(source, resourcePath, options)
return source
}
获取用户配置
在webpack配置loader时,可以进行一些配置,如是否要开启调试
const { getOptions: _getOptions } = require("loader-utils")
const { validate } = require("schema-utils")
const { defaultConfig, schema } = require("../default.config")
/**
* 获取loader选项
* @param {object} ctx
* @returns {object}
*/
function getOptions(ctx) {
const userOptions = _getOptions(ctx) || {}
// 校验用户的配置是否符合要求(schema)
validate(schema, userOptions, { name: "@weiyi/mp-devtool-loader", baseDataPath: "options" })
let options = {}
if (userOptions.devtool) {
// 是否开启devtool
// ...
}
return options
}
module.exports = {
getOptions
}
在页面入口注入初始化脚本
通常我们要在应用的入口处做一些初始化的操作,比如要做查看network功能,就要在入口处对http请求的方法进行拦截重写,记录每一次请求的入参和出参;比如注册全局组件...
/**
* 拷贝待插入js文件到node_modules下,并在入口中引用
* @param {string} source 入口文件源码
* @param {string} resourcePath 当前解析的文件的路径
* @param {object} config loader配置
* @param {string} config.injectJsEntry 要插入脚本的入口文件路径,默认main.js
* @param {string[]} scripts 要在入口注入的js文件路径
* @param {object[]} components 插入组件配置
* @param {string} components[].name 组件名称
*/
function injectJs(source, resourcePath, config) {
const { injectJsEntry, scripts, components } = config
// 判断当前处理的文件路径(resourcePath)是否是入口文件
if (injectJsEntry.toLowerCase() === resourcePath.toLowerCase()) {
/* 获取要插入的代码块,如:
code = `
import 'node_modules/uni-devtool/DevTool/scripts/init.js'
import DevTool from 'node_modules/uni-devtool/index.vue'
Vue.component('dev-tool', DevTool)
`
*/
const code = getInjectCode(scripts, components)
// 将代码块内容插入带文件内容里
source = injectCodeAfterLastImportDeclaration(source, code)
}
return source
}
使用AST将代码块插入
入口文件里都会引入Vue
,注册全局组件需要在Vue
上进行操作,为保证运行正常,需要将要插入的代码块插入到最后一个import语句
的下面。 这就需要用到AST
了。
AST这里也不做详细介绍了,可以简单理解为AST是对代码的分析,将代码按照语法生成一棵树,再利用AST的遍历工具,可以很方便的拿到最后一个import语句
,对其进行修改后,重新生成新的代码块。
const acorn = require("acorn") // 将代码分析为AST的工具
const estraverse = require("estraverse") // AST遍历工具
const escodegen = require("escodegen") // 将AST重新生成为代码块工具
/**
* 在代码的最后一个import后插入一段代码
* @param {String} source 要插入代码的源码
* @param {String} code 要插入的代码
* @returns {String} 插入后的新代码
*/
function injectCodeAfterLastImportDeclaration(source, code) {
// 将入口文件main.js的内容转成AST
const sourceAst = acorn.parse(source, { sourceType: "module", ecmaVersion: 2020 })
let lastNode = null
// 遍历AST
estraverse.traverse(sourceAst, {
leave: function (node, parent) {
if (node.type == "ImportDeclaration") {
// 找到最后一个import语句
lastNode = node
}
},
})
const lastImportDeclarationIndex = sourceAst.body.indexOf(lastNode) // 最后一个ImportDeclaration的索引
// 将要插入的代码块也转为AST
const codeAst = acorn.parse(code, { sourceType: "module" })
// 将插入代码块的AST插入到最后一个import语句的后面
sourceAst.body.splice(lastImportDeclarationIndex + 1, 0, codeAst)
// 生成插入后的代码
const newSource = escodegen
.generate(sourceAst, {
indent: " ",
}) + '\n'
return newSource
}
至此,脚本在入口文件的插入功能就实现了,编译时,下一个loader拿到的main.js内容就是上面插入了代码块的内容。
在每个页面中插入组件的调用
/**
* 在vue文件的template中第一个非template节点下插入组件的调用
* @param {string} source vue文件(SFC)源码
* @param {string} resourcePath 当前解析的文件的路径
* @param {object} config loader配置
* @param {string} config.injectComponentRule 需要自动插入组件的vue文件路径正则
* @param {string} config.pagesJsonPath page.json文件地址
* @param {string} config.componentName 在template中自动插入的组件名
*/
function injectComponents(source, resourcePath, config) {
const { components, injectComponentRule, pagesJsonPath } = config
// 插入组件调用
// 判断是否是vue文件
const isSFC = path.extname(resourcePath).includes("vue")
// 判断是否是要插入调试工具的页面文件
const pathMatch = pathRulesTest(resourcePath, { regRules: injectComponentRule, pagesJsonPath })
if (isSFC && pathMatch) {
// 插入组件调用代码
source = inject(source, components)
}
return source
}
判断是否要插入组件调用代码
首先需要判断是否是vue文件,然后工具只需要在页面文件
进行调用即可,而对于uni-app来说,所有的页面都要在src/pages.json
中声明,因此只需要拿到宿主项目的pages.json
文件,判断当前文件是否在pages.json中声明即可。
插入组件调用代码
这里使用vue官方包@vue/compiler-sfc
来对vue代码进行解析,拿到template
的代码
/**
* 在vue文件的template中第一个非template节点下插入组件的调用
* @param {string} source vue文件(SFC)源码
* @param {object[]} components 插入组件配置
* @param {string} components[].name 组件名称
*/
function inject(source, components) {
/**
* 将所有调用组件的代码拼接为字符串
injectTags = `
<UniDevtool />
<MyButton />
`
*/
let injectTags = components.reduce((tags, curr) => {
tags += `<${curr.name} />`
return tags
}, "")
const { descriptor } = compiler.parse(source)
// TODO 暂未考虑没有只有一个自闭和标签的情况
// 如: <template><img/></template>
const matchTags = source.match(/<[^\/>]+>/g)
// 查找template中第一个标签
const tag =
matchTags &&
matchTags.find(
(item) =>
!item.includes("<template") &&
!item.includes("<script") &&
!item.includes("<style") &&
!item.includes("<!") // 注释也是标签
)
// 在第一个标签后插入组件的调用
if (tag) {
source = source.replace(tag, `$&${injectTags}`)
}
return source
}
这样,就基本完成了在每个页面文件
内插入了调试工具组件的功能。
对于调试工具的具体功能,在下一篇文章再继续介绍。
本地调试
关于本地调试可以看 开发文档
需要注意的是,在本地调试时,若组件是软链到调试小程序中(npm/yarn link
),全局注册组件是有些问题(uni-app的问题),需要在vue.config.js
中添加如下配置:
configureWebpack: {
resolve: {
symlinks: false
}
}