观测项目:如何追踪变量

256 阅读6分钟

读源码或者工作当中读别人的项目代码是非常花费时间的事情,有什么方法能够稍微轻松地去了解一个项目呢?

基本问题:如何追踪一个变量在整个应用生命周期中的轨迹

  1. 变量什么时候被创建?
  2. 变量什么时候被引用?
  3. 变量什么时候被修改?

问题2,3 最先想到的是使用 proxy,这样我就能知道变量被引用和修改的时机

export function traceObject(obj: object) {
  return new Proxy(obj, {
    get(...args) {
      // 这里写触发引用逻辑
      return Reflect.get(...args);
    },
    set(...args) {
      // 这里写触发修改逻辑
      return Reflect.set(...args);
    }
  })
}

其他的方法都有些问题

参考stackoverflow

  • Object.watch and Object.observe are both deprecated and should not be used.
  • onPropertyChange is a DOM element event handler that only works in some versions of IE.
  • Object.defineProperty allows you to make an object property immutable, which would allow you to detect attempted changes, but it would also block any changes.
  • Defining setters and getters works, but it requires a lot of setup code and it does not work well when you need to delete or create new properties.

现在我能在想要的时机执行我想要的动作,那么我怎么知道当前触发的环境在代码的哪里呢,或者哪个函数里使用或者修改了这个变量呢?

问题:如何确定当前变量运行的函数名

可以使用 arguments.callee, 但是在严格模式下会报错

var x = function x() {
    console.log( arguments.callee.name );
}
x();

下一个可选方案是手动抛出异常来获取当前的栈信息

  const error = new Error();
  console.log(error.stack.split('\n'))

error.stack 里包含了当前运行的栈信息

如果应用到之前的Proxy里

export function traceObject(obj: object, context?: any) {
  return new Proxy(obj, {
    get(...args) {
      const [obj, prop, value] = args
      const errors = new Error().stack?.split("\n").slice(1)
      console.log(errors)

      return Reflect.get(...args);
    },
    set(...args) {
      const [obj, prop, value] = args
      const errors = new Error().stack?.split("\n").slice(1)
      console.log(errors)
  
      return Reflect.set(...args);
    }
  })
}

测试一下

(function testFn() {
  const obj = traceObject({ x: 1 })

  const x = obj.x + 1

  obj.x = 1
})()

image.png

因为这个代码是在vue3环境里执行的,可以先忽略后面的栈信息,我们关注前2个 能看到每次访问和修改变量 Obj.x 的时候都能读取出当前调用的函数栈

我们把打印信息稍微过滤一下

export function traceObject(obj: object, context?: any) {
  return new Proxy(obj, {
    get(...args) {
      const [obj, prop, value] = args
      const errors = new Error().stack?.split("\n").slice(1)
      const stack = errors?.map(s => {
        const _stack = s.trim().split(" ")
        return {
          funcName: _stack[1],
          pathName: _stack[2]
        }
      }) || []

      console.log('get --------------------------------------------------')
      console.log(`${obj}.${String(prop)} - 被引用: "${stack[0]?.funcName}"`);
      console.log(`=>`)
      console.log(`${obj}.${String(prop)} - 被引用: "${stack[1]?.funcName}"`);
      console.log('get --------------------------------------------------')
      return Reflect.get(...args);
    },
    set(...args) {
      const [obj, prop, value] = args
      const errors = new Error().stack?.split("\n").slice(1)
      const stack = errors?.map(s => {
        const _stack = s.trim().split(" ")
        return {
          funcName: _stack[1],
          pathName: _stack[2]
        }
      }) || []

      console.log('set --------------------------------------------------')
      console.log(`${obj}.${String(prop)} - 被引用: "${stack[0]?.funcName}"`);
      console.log(`=>`)
      console.log(`${obj}.${String(prop)} - 被引用: "${stack[1]?.funcName}"`);
      console.log('set --------------------------------------------------')

      return Reflect.set(...args);
    }
  })
}

image.png

现在我只要能搜集这些信息,就能知道这个变量在整个应用周期里运行的轨迹

问题:但是这种方法只能监视对象里面的变量,变量本身和不是对象的变量无法检测到

如:

let obj = traceObject({x: 1})
const a = 2
obj = {y: 1}

这里 obj 和 a 都是无法监视的

常量如何追踪呢?

这里抄一下.. 不,借鉴一下 vue3 的思路,把常量的值放在新的对象 value 里

class Ref { 
    value: 值,
    
    get value() {
        // 这里触发引用逻辑
        this.value
    }
    
    set value(value) {
        // 这里触发修改逻辑
        this.value = value
    }
 }

这样的话定义常量的时候不是给基础类型的值,而是给我们定义的特定对象

let a = 1; // 原来的
let a = new Ref(1);

那么新的问题来了

问题:如何识别所有的变量的声明,并且把它的值用指定的方法包裹

比如:

const fn4 = () => {
  console.log('fn4')
  var a = 1
  var b = (p: any) => { return p + 1; }
  var c = { x: 1 }
  function c(a) {}
  
  c.x = 2
  
  b(a)

  setTimeout(() => {
    a = 2
  }, 3000)

  a = 3
}

上面的代码转变成:

() => {
    console.log('fn4');
    var a = _my_trace('a', 1);
    var b = _my_trace('b', p => {
        return p + 1;
    });
    var c = _my_trace('c', { x: 1 });
    function c(a2) {
    }
    c.x = 2;
    b(a.value);
    setTimeout(() => {
        a.value = 2;
    }, 3000);
    a.value = 3;
};

在搜索了一阵后,我决定采用把代码转成 AST 树然后在分析的方法

关于代码转 AST 可以在这个网站在线实验一下

首先装一个浏览器里能执行的 转 AST 包

我选了 acorn

image.png

可以看到代码被转换成了嵌套的JSON 数据,其中每个节点都带有 type 字段表示节点的意义,具体的可以看这篇文章

import * as acorn from 'acorn'

function traceFunc(fn: Function): Function {
  const code = fn.toString()
  const nodeTree = acorn.parse(code, { ecmaVersion: 'latest' })
}

我们可以把一段代码用匿名函数包装一下然后传给 traceFunc

traceFunc(() => {
  var a = 1
  var b = (p: any) => { return p + 1; }

  a = 3
})

image.png

好,现在我们需要递归遍历所有节点,通过判断类型来搜集所有生命的变量

这里可以使用 acorn-walk, acorn-walk 有很多方法,我用了比较灵活的 full

import * as acornWalk from 'acorn-walk'

 acornWalk.fullAncestor(nodeTree, (node, state, ancestors, type) => {
    // 如果类型为声明类型
    if(node.type === 'VariableDeclarator') {
      state.declar.push(node.id.name) // 记录变量名
    }
    
    console.log("所有声明的变量", state)
    
    return state
  }, undefined, {
    declar: [] // 把声明的变量放这里
  })

  return nodeTree
}

image.png

好,现在我们只要把这个变量用我们自定义的函数包装一下就行,方法也很简单,模仿 AST 改一下node 结构就行

image.png

改成

image.png

const wrapWithFunName = (node: acorn.Node) => {
  // 保存之前的初始化
  const oloNode = JSON.parse(JSON.stringify(node))

  // 使用函数名包装
  node = Object.assign(node, {
    type: 'CallExpression',
    callee: {
      type: 'Identifier',
      name: funcName
    },
    arguments: [{
        type: 'Literal',
        value: a,
        raw: `'${a}'`
    }],
    optional: false
  })
}

⚠️ 这里注意我们把变量名当作第一个参数传了进去

结果和想要的一样

image.png

现在我们可以在全局定义一个自定义函数叫做 __my_trace, 然后根据不同的类型去设置响应规则

if(!window['_tianji_trace']) {
    window['_tianji_trace'] = (varName: string, target: any) => {
      if(typeof target === 'object' && target !== null) { // 对象,数组
        return traceObject(varName, target)
      } else if(typeof target === 'function') { // 函数
        return traceFunc(target)
      } else { // 其他
        return traceBaseType(varName, target)
      }
    }
  }

这里重点关注 traceBaseType

我们先定一个简单的类

class RefImpl<T> {
 // ...
  constructor(variableName: string, value: T) {
    this._value = value
    this._rawValue = value
    this._name = variableName
  }

  // getter value拦截器
  get value() {
    // 引用触发逻辑 ...

    return this._value
  }

  // setter value拦截器
  set value(newVal) {
    if (newVal !== this._rawValue) {
      // 存储新的 raw
      this._rawValue = newVal
      this._value = newVal
      
      // 修改触发逻辑...
    }
  }
}

然后返回这个新创建的对象

function traceBaseType (varName: string, variable: any) {
  return new RefImpl(varName, variable)
}

我们把基础类型改成了对象类型,那么所有引用的地方也要改成 myObj.value 的形式

问题:如何给所有引用 RefImpl 的地方把变量名改掉

let a = 1
call(a)

变成

let a = _my_trace(1)
call(a.value)

还是使用遍历方法,找到所有引用这个变量名的方法,然后把 a 改成 a.value

所有代码比较多,只显示关键代码

acornWalk.fullAncestor(nodeTree, (node, state, ancestors, type) => {
    if(node.type === 'VariableDeclarator') {
      // 变量声明逻辑
      // ...
    } else if(node.type === 'Identifier') {
        // 判断是否为已经声明并且是基础类型的变量
        if(
            // 为已经声明的变量
            // 不是对象 | 函数名
            // 不是重新声明的对象
            // 不为 {} 的 key
            // 不为已经对象的key
        ) {
          // 把变量 x 改成 x.value
          const oldNode = JSON.parse(JSON.stringify(node))
          node = Object.assign(node, {
            type: "MemberExpression",
            object: oldNode,
            property: {
              type: "Identifier",
              name: "value"
            },
            computed: false,
            optional: false
          }) 
        }
      }
      
    }
    
    return state
  }, undefined, {
    declar: []
  })

问题:如何把转换好的 AST 再转回 JS 代码

我这里选择了 escodegen 这个库,任何其他可以解析 esTree 标准格式的库都是可以的

import escodegen from 'escodegen'
// 编译成 AST
const nodeTree = acorn.parse(code, { ecmaVersion: 'latest' })
// 遍历节点
const newTree = traverse(nodeTree)
// AST 转 JS
const newCode = escodegen.generate(newTree)

好,到这里我们做到最基本的变量追踪功能,我们搜集下 get, set 里 Error 抛出的 Stack 信息,就能大概的列出每个变量的运行轨迹

但是这个精度不是很精准,而且使用 error.stack 能提供的信息比较有限, 有什么更好的方法呢?

问题: 如何更精确地描述一段代码的运行逻辑

To be continue...