读源码或者工作当中读别人的项目代码是非常花费时间的事情,有什么方法能够稍微轻松地去了解一个项目呢?
基本问题:如何追踪一个变量在整个应用生命周期中的轨迹
- 变量什么时候被创建?
- 变量什么时候被引用?
- 变量什么时候被修改?
问题2,3 最先想到的是使用 proxy,这样我就能知道变量被引用和修改的时机
export function traceObject(obj: object) {
return new Proxy(obj, {
get(...args) {
// 这里写触发引用逻辑
return Reflect.get(...args);
},
set(...args) {
// 这里写触发修改逻辑
return Reflect.set(...args);
}
})
}
其他的方法都有些问题
- 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
})()
因为这个代码是在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);
}
})
}
现在我只要能搜集这些信息,就能知道这个变量在整个应用周期里运行的轨迹
问题:但是这种方法只能监视对象里面的变量,变量本身和不是对象的变量无法检测到
如:
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
可以看到代码被转换成了嵌套的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
})
好,现在我们需要递归遍历所有节点,通过判断类型来搜集所有生命的变量
这里可以使用 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
}
好,现在我们只要把这个变量用我们自定义的函数包装一下就行,方法也很简单,模仿 AST 改一下node 结构就行
改成
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
})
}
⚠️ 这里注意我们把变量名当作第一个参数传了进去
结果和想要的一样
现在我们可以在全局定义一个自定义函数叫做 __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...