检测Javascript中变量的修改

问题描述

目前在做的小项目中,我们在浏览器中通过Hook浏览器的API,截获目标程序对浏览器API的调用。比如对于Webgl的网页程序,我们就能截获所有的gl指令,这其中主要是截获参数列表。把这些gl指令翻译为Opengl ES标准的话便可以编译为一个其他设备上的原生应用了。

图形的绘制过程中经常会有大量的数组需要传递入GPU,这就造成了我们截获了大量的数组。如果想直接把这些数据保存在浏览器内存中,压力很大,实时流到其他服务器上的话也带来了stringfy瓶颈和传输瓶颈。虽然对于Javascript中的Typed Array,我们现在直接二进制传输到服务器上,是否能只传输变化的数组,或者是只传输数组的delta,这个是一个潜在的优化。当然,如果发现大部分要传输的数组都是改变过的话,或者是检测改变的开销大于直接传输的开销(毕竟是CPU时间对抗IO时间,而且由于检测比如会使目标程序运行变慢),完全便可以不考虑进行数组改变的检测了。

对于给定Javascript的TypedArray,我们的目标便是当截获到的gl指令需要使用它的时候,我们需要能知道它是否内容发生了改动,更进一步能记录出上次截获到这次截获间的delta。
花了一些时间进行探索,然而最后还是无解,并也没有方法能占用资源尽量少地做到标记修改,记录如下。

暴力检测

这个方法不需要多说,不过检测的时间消耗太多,由于消耗了Webgl每帧的CPU时间,很容易造成CPU性能瓶颈,导致Fps下降。

Object.observe() / Array.observe()

最先查询到的便是两个内建的API,直接来看MDN上的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var obj = {
foo: 0,
bar: 1
};

Object.observe(obj, function(changes) {
console.log(changes);
});

obj.baz = 2;
// [{name: 'baz', object: <obj>, type: 'add'}]

obj.foo = 'hello';
// [{name: 'foo', object: <obj>, type: 'update', oldValue: 0}]

delete obj.baz;
// [{name: 'baz', object: <obj>, type: 'delete', oldValue: 2}]
```

一开始看到的时候感觉非常符合我的需求,并且还区分了改动的类型,比如updateadddelete类型。
应该现在很多前端的工具就使用Observe便可以完全数据到显示的单向同步了。
于是尝试了一下在Typed Array上的操作:

1
2
3
4
5
6
7
8
9
10
var obj = new Int16Array([1,2,3]);
Object.observe(obj, function(changes) {
console.log(changes);
});

obj[0] = 9; // changed
obj[0] = 9; // unchanged
obj.sort(); // changed

```

不过后来看到这么一个解释:

The Object.observe() method is used for asynchronously observing the changes
to an object. It provides a stream of changes in the order in which they
occur.

说明变更事件是异步通知的,比如可以这样实验一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = new Int16Array([1,2,3]);
a.isDirty = false;
Object.observe(a, function(changes) {
a.isDirty = true;
console.log('c');
});
a[0]=9;
console.log(a.isDirty);
setTimeout('console.log(a.isDirty);',100);

output:
false
c
true
```

这就带来了问题,由于Javascript执行是单线程的,可能在webgl程序刚修改完后便被我截获了这个数组,但修改通知没到,我就已经错误判断数组未改变了,由于我相当于是对每个gl命令的参数进行快照,无法在未来进行记录,如果停止我当前线程的,会带来严重的性能问题。

Object.defineProperty

这个方法可以为对象定义属性,并能对属性的访问进行详细的控制,最简单的话我们给定义get和set函数,举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
var arr = {};
var shadow = [1,2,3];
Object.defineProperty(arr, '0', {
get: function() {
console.log('get');
return shadow['0'];
},
set: function(v) {
console.log('set');
shadow['0'] = v;
}
});
```

通常对外暴露的get,set中要访问一个内部的变量,以防止递归访问属性的get或是set。
这个方法其实是多加入了一个proxy的对象。并且TypedArray并不能重写数字属性的get/set,只能通过一个proxy对象了。

一个简陋的实现,直接替换浏览器中提供的API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
var _Float32Array = Float32Array;
Float32Array = function () {
// 这里需要罗列出内部的属性和函数名
// 因为数组类型只能for遍历数字属性
var list = [
"BYTES_PER_ELEMENT",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"constructor",
"buffer",
"byteLength",
"byteOffset",
"entries",
"hasOwnProperty",
"isPrototypeOf",
"keys",
"length",
"propertyIsEnumerable",
"set",
"subarray",
"toLocaleString",
"toString",
"valueOf",
"values",
]

// 初始化的函数可能会有三个参数,还未处理
var a = new _Float32Array(arguments[0]);

this.a = a;
var b = this;
this.isDirty = false;

list.forEach(function (key) {
if (typeof(a[key]) == 'function') {
b[key] = function () {
b.isDirty = true;
console.log('invoke', key);
return a[key].apply(a, arguments);
}
}else {
Object.defineProperty(b, key, {
configurable: true,
enumerable: true,
get: function(i) {
return function () {
console.log('get member',i);
return a[i];
}
}(key),
set: function(i) {
return function (v) {
b.isDirty = true;
console.log('set member',i);
a[i] = v;
}
}(key)
});
}
})

this.constructor = a.constructor;

for (var idx in a) {
Object.defineProperty(b, idx, {
configurable: true,
enumerable: true,
get: function(i) {
return function () {
console.log('get index',i);
return a[i];
}
}(idx),
set: function(i) {
return function (v) {
console.log('set index',i);
b.isDirty = true;
a[i] = v;
}
}(idx)
});
}
}

// 测试
>>a = new Float32Array([1,2,3])
<<Float32Array {a: Float32Array[3], isDirty: false}
>>a.toString()
<<t.js:64 invoke toString
<<"1,2,3"
>>a[0]=1
<<t.js:100 set index 0
<<1
>>a[2]
<<t.js:94 get index 2
<<3
>>a.constructor
<<Float32Array() { [native code] }
```

本做法应该还有以下局限:

性能

大规模下创建一个1000000的数组内存占用飙升,因为创建了太多的get/set函数。

1
2
3
4
5
6
7
8
9
10
11
ori = new _Float32Array(1000000)
Float32Array[1000000]
mod = new Float32Array(1000000)
Float32Array {a: Float32Array[1000000], isDirty: false}

console.time('a');for(i=0;i<1000000;i++){var t = mod[i];t++;mod[i]=t;};console.timeEnd('a');
VM741:2 a: 5313.409ms

console.time('a');for(i=0;i<1000000;i++){var t=ori[i];t++;ori[i]=t;};console.timeEnd('a');
VM743:2 a: 3884.074ms
```

内部创建并返回的TypedArray无法Hook

比如类似subarray()函数,返回值便是原生的TypedArray了,需要在Proxy对象中特殊处理一下。

在某些场景下不能完全替代原有的TypedArray

可以尽量模拟原生TypedArray的对外Api,不过还是有些场景可能照顾不到。

ES6 Proxy & Reflect

Chrome中还没有实现,使用Firefox一试。对某对象加一层通用的代理,非常容易实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

var ori = new Float32Array(1000000);
var obj = new Proxy(aa, {
get: function (target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
return Reflect.set(target, key, value, receiver);
}
});


console.time('a');for(i=0;i<1000000;i++){var t = aa[i];t++;aa[i]=t;};console.timeEnd('a');
a: 计时器开始
a: 1738.62ms
console.time('a');for(i=0;i<1000000;i++){var t = obj[i];t++;obj[i]=t;};console.timeEnd('a');
a: 计时器开始
a: 3226.16ms

```

如果还有更好的方法,欢迎讨论。