定制一个小程序版vconsole (上)-- 在小程序各页面自动插入组件

2,039 阅读9分钟

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

在h5开发时,我们经常会引入vconsole或者eruda,基本功能也够用了,在微信小程序的开发环境也内置了小程序版的vconsole,但是没有network面板,真机测试时就必须连电脑抓包,非常麻烦。 另外,我们的埋点是用gif图的url参数上报,验证埋点数据时直接看url上的一坨,也很难识别,于是便想自己开发一个调试工具,能查看network,可以格式化出埋点数据。

最终开发中结合实际需求,实现了以下功能:

  • network查看
  • 埋点数据格式化
  • 扫码打开h5页面
  • 查看页面基本信息
  • 切换隔离网关
  • 支持全局注入自定义组件

源码与使用文档

本插件已开源:

ps.项目的小程序基于uni-app开发的,因此此插件也是基于uni-app的框架开发,理论上也支持uni-app的多平台特性。使用taro或者原生开发话,原理是一样的,可以自行实现。

WX20211210-173545@2x.png

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

直接使用

如果你不想定制自己的功能,只使用我已经实现的基础功能,或者想先体验一下,可以直接安装 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  
    }  
}