欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  web前端

细品javascript 寻址,闭包,对象模型和相关问题_javascript技巧

程序员文章站 2022-03-30 10:44:44
...
正是因为JS是动态语言,所以JS的寻址是现场寻址,而非像C一样,编译后确定。此外,JS引入了this指针,这是一个很麻烦的东西,因为它“隐式”作为一个参数传到函数里面。我们先看“作用域链”话题中的例子:
var testvar = 'window属性';
var o1 = {testvar:'1', fun:function(){alert('o1: '+this.testvar);}};
var o2 = {testvar:'2', fun:function(){alert('o2: '+this.testvar);}};
o1.fun(); // '1'
o2.fun(); // '2'
o1.fun.call(o2); //'2'三次alert结果并不相同,很有趣不是么?其实,所有的有趣、诡异的概念最后都可以归结到一个问题上,那就是寻址。
简单变量的寻址
JS是静态还是动态作用域?
告诉你一个很不幸的消息,JS是静态作用域的,或者说,变量寻址比perl之类的动态作用域语言要复杂得多。下面的代码是程序设计语言原理上面的例子:
01| function big(){
02| var x = 1;
03| eval('f1 = function(){echo(x)}');
04| function f2(){var x = 2;f1()};
05| f2();
06| };
07| big();
输出的是1,和pascal、ada如出一辙,虽然f1是用eval动态定义的。另外一个例子同样来自程序设计语言原理:
function big2(){
var x = 1;
function f2(){echo(x)}; //用x的值产生一个输出
function f3(){var x = 3;f4(f2)};
function f4(f){var x = 4;f()};
f3();
}
big2();//输出1:深绑定;输出4:浅绑定;输出3:特别绑定
输出的还是1,说明JS不仅是静态作用域,还是深绑定,这下事情出大了……
ARI的概念
为了解释函数(尤其是允许函数嵌套的语言中,比如Ada)运行时复杂的寻址问题,《程序设计语言原理》一书中定义了“ARI”:它是堆栈上一些记录,包括:
函数地址
局部变量
返回地址
动态链接
静态链接
这里,动态链接永远指向某个函数的调用者(如b执行时调用a,则a的ARI中,动态链接指向b);静态链接则描述了a定义时的父元素,因为函数的组织是有根树,所以所有的静态链接汇总后一定会指向宿主(如window),我们可以看例子(注释后为输出):
var x = 'x in host';
function a(){echo(x)};
function b(){var x = 'x inside b';echo(x)};
function c(){var x = 'x inside c';a()};
function d(){
var x = 'x inside d,a closure-made function';
return function(){echo(x)}};
a();// x in host
b();// x inside b
c();// x in host
d()();// x inside d,a closure-made function在第一句调用时,我们可以视作“堆栈”上有下面的内容(左边为栈顶):
[a的ARI] → [宿主]A的静态链直直的戳向宿主,因为a中没有定义x,解释器寻找x的时候,就沿着静态链在宿主中找到了x;对b的调用,因为b的局部变量里记录了x,所以最后echo的是b里面的x:'x inside b';
现在,c的状况有趣多了,调用c时,可以这样写出堆栈信息:
动态链:[a]→[c]→[宿主]
静态链:[c]→[宿主];[a]→[宿主]
因为对x的寻址在调用a后才进行,所以,静态链接还是直直的戳向宿主,自然x还是'x in host'咯!
d的状况就更加有趣了,d创建了一个函数作为返回值,而它紧接着就被调用了~因为d的返回值是在d的生命周期内创建的,所以d返回值的静态链接戳向d,所以调用的时候,输出d中的x:'x inside d,a closure-made function'。
静态链接的创建时机
月影和amingoo说过,“闭包”是函数的“调用时引用”,《程序设计语言原理》上面干脆直接叫ARI,不过有些不同的是,《程序设计语言原理》里面的ARI保存在堆栈中,而且函数的生命周期一旦结束,ARI就跟着销毁;而JS的闭包却不是这样,闭包被销毁,当且仅当没有指向它和它的成员的引用(或者说,任何代码都无法找到它)。我们可以简单地认为函数ARI就是一个对象,只不过披上了函数的“衣服”而已。
《程序设计语言原理》描述的静态链是调用时创建的,不过,静态链的关系却是在代码编译的时候就确定了。比如,下面的代码:
PROCEDURE a;
PROCEDURE b;
END
PEOCEDURE c;
END
END
中,b和c的静态链戳向a。如果调用b,而b中某个变量又不在b的局部变量中时,编译器就生成一段代码,它希望沿着静态链向上搜堆栈,直到搜到变量或者RTE。
和ada之类的编译型语言不同的是,JS是全解释性语言,而且函数可以动态创建,这就出现了“静态链维护”的难题。好在,JS的函数不能直接修改,它就像erl里面的符号一样,更改等于重定义。所以,静态链也就只需要在每次定义的时候更新一下。无论定义的方式是function(){}还是eval赋值,函数创建后,静态链就固定了。
我们回到big的例子,当解释器运行到“function big(){......}”时,它在内存中创建了一个函数实例,并连接静态链接到宿主。但是,在最后一行调用的时候,解释器在内存中画出一块区域,作为ARI。我们不妨成为ARI[big]。执行指针移动到第2行。
执行到第3行时,解释器创建了“f1”实例,保存在ARI[big]中,连接静态链到ARI[big]。下一行。解释器创建“f2”实例,连接静态链。接着,到了第5行,调用f2,创建ARI[f1];f2调用f1,创建ARI[f1];f1要输出x,就需要对x寻址。
简单变量的寻址
我们继续,现在要对x寻址,但x并不出现在f1的局部变量中,于是,解释器必须要沿着堆栈向上搜索去找x,从输出看,解释器并不是沿着“堆栈”一层一层找,而是有跳跃的,因为此时“堆栈”为:
|f1 | ←线程指针
|f2 | x = 2
|big | x = 1
|HOST|
如果解释器真的沿着堆栈一层一层找的话,输出的就是2了。这就触及到Js变量寻址的本质:沿着静态链上搜。
继续上面的问题,执行指针沿着f1的静态链上搜,找到big,恰好big里面有x=1,于是输出1,万事大吉。
那么,静态链是否会接成环,造成寻址“死循环”呢?大可不用担心,因为还记得函数是相互嵌套的么?换言之,函数组成的是有根树,所有的静态链指针最后一定能汇总到宿主,因此,担心“指针成环”是很荒谬的。(反而动态作用域语言寻址容易造成死循环。)
现在,我们可以总结一下简单变量寻址的方法:解释器现在当前函数的局部变量中寻找变量名,如果没有找到,就沿着静态链上溯,直到找到或者上溯到宿主仍然没有找到变量为止。
ARI的生命
现在来正视一下ARI,ARI记录了函数执行时的局部变量(包括参数)、this指针、动态链和最重要的——函数实例的地址。我们可以假想一下,ARI有下面的结构:
ARI :: {
variables :: *variableTable, //变量表
dynamicLink :: *ARI, //动态链接
instance :: *funtioninst //函数实例
}
variables包括所有局部变量、参数和this指针;dynamicLink指向ARI被它的调用者;instance指向函数实例。在函数实例中,有:
functioninst :: {
source :: *jsOperations, //函数指令
staticLink :: *ARI, //静态链接
......
}
当函数被调用时,实际上执行了如下的“形式代码”:
*ARI p;
p = new ARI();
p->dynamicLink = thread.currentARI;
p->instance = 被调用的函数
p->variables.insert(参数表,this引用)
thread.transfer(p->instance->operations[0])
看见了么?创建ARI,向变量表压入参数和this,之后转移线程指针到函数实例的第一个指令。
函数创建的时候呢?在函数指令赋值之后,还要:
newFunction->staticLink = thread.currentARI;
现在问题清楚了,我们在函数定义时创建了静态链接,它直接戳向线程的当前ARI。这样就可以解释几乎所有的简单变量寻址问题了。比如,下面的代码:
function test(){
for(i=0;i(function(t){ //这个匿名函数姑且叫做f
setTimeout(function(){echo(''+t)},1000) //这里的匿名函数叫做g
})(i)
}
}
test()
这段代码的效果是延迟1秒后按照0 1 2 3 4的顺序输出。我们着重看setTimeout作用的那个函数,在它创建时,静态链接指向匿名函数f,f的(某个ARI的)变量表中含有i(参数视作局部变量),所以,setTimeout到时时,匿名函数g搜索变量t,它在匿名函数f的ARI里面找到了。于是,按照创建时的顺序逐个输出0 1 2 3 4。
公用匿名函数f的函数实例的ARI一共有5个(还记得函数每调用一次,ARI创建一次么?),相应的,g也“创建”了5次。在第一个setTimeout到时之前,堆栈中相当于有下面的记录(我把g分开写成5个):
+test的ARI [循环结束时i=5]
| f的ARI;t=0 ←——————g0的静态链接
| f的aRI ;t=1 ←——————g1的静态链接
| f的aRI ;t=2 ←——————g2的静态链接
| f的aRI ;t=3 ←——————g3的静态链接
| f的aRI ;t=4 ←——————g4的静态链接
\------
而,g0调用的时候,“堆栈”是下面的样子:
+test的ARI [循环结束时i=5]
| f的ARI ;t=0 ←——————g0的静态链接
| f的ARI ;t=1 ←——————g1的静态链接
| f的ARI ;t=2 ←——————g2的静态链接
| f的ARI ;t=3 ←——————g3的静态链接
| f的ARI ;t=4 ←——————g4的静态链接
\------
+g0的ARI
| 这里要对t寻址,于是……t=0
\------
g0的ARI可能并不在f系列的ARI中,可以视作直接放在宿主里面;但寻址所关心的静态链接却仍然戳向各个f的ARI,自然不会出错咯~因为setTimeout是顺序压入等待队列的,所以最后按照0 1 2 3 4的顺序依次输出。
函数重定义时会修改静态链接吗?
现在看下一个问题:函数定义的时候会建立静态链接,那么,函数重定义的时候会建立另一个静态链接么?先看例子:
var x = "x in host";
f = function(){echo(x)};
f();
function big(){
var x = 'x in big';
f();
f = function(){echo (x)};
f()
}
big()
输出:
x in host
x in host
x in big
这个例子也许还比较好理解,big运行的时候重定义了宿主中的f,“新”f的静态链接指向big,所以最后一行输出'x in big'。
但是,下面的例子就有趣多了:
var x = "x in host";
f = function(){echo(x)};
f();
function big(){
var x = 'x in big';
f();
var f1 = f;
f1();
f = f;
f()
}
big()
输出:
x in host
x in host
x in host
x in host
不是说重定义就会修改静态链接么?但是,这里两个赋值只是赋值,只修改了f1和f的指针(还记得JS的函数是引用类型了么?),f真正的实例中,静态链接没有改变!。所以,四个输出实际上都是宿主中的x。
结构(对象)中的成分(属性)寻址问题
请基督教(java)派和摩门教(csh)派的人原谅我用这个奇怪的称呼,不过JS的对象太像Hash表了,我们考虑这个寻址问题:
a.b编译型语言会生成找到a后向后偏移一段距离找b的代码,但,JS是全动态语言,对象的成员可以随意增减,还有原型的问题,让JS对象成员的寻址显得十分有趣。
对象就是哈希表
除开几个特殊的方法(和原型成员)之外,对象简直和哈希表没有区别,因为方法和属性都可以存储在“哈希表”的“格子”里面。月版在他的《JS王者归来》里面就实现了一个HashTable类。
对象本身的属性寻址
“本身的”属性说的是hasOwnProperty为真的那些属性。从实现的角度看,就是对象自己的“哈希表”里面拥有的成员。比如:
function Point(x,y){
this.x = x;
this.y = y;
}
var a = new Point(1,2);
echo("a.x:"+a.x)
Point构造器创建了“Point”对象a,并且设置了x和y属性;于是,a的成员表里面,就有:
| x | ---> 1
| y | ---> 2
搜索a.x时,解释器先找到a,然后在a的成员表里面搜索x,得到1。
从构造器给对象设置方法不是好策略,因为它会造成两个同类的对象方法不等:
function Point(x,y){
this.x = x;
this.y = y;
this.abs = function(){return Math.sqrt(this.x*this.x+this.y*this.y)}
}
var a = new Point(1,2);
var b = new Point(1,2);
echo("a.abs == b.abs ? "+(a.abs==b.abs));
echo("a.abs === b.abs ? "+(a.abs===b.abs));
两个输出都是false,因为第四行中,对象的abs成员(方法)每次都创建了一个,于是,a.abs和b.abs实际上指向两个完全不同的函数实例。因此,两个看来相等的方法实际上不等。
扯上原型的寻址问题
原型是函数(类)的属性,它指向某个对象(不是类)。“原型”思想可以类比“照猫画虎”:类“虎”和类“猫”没有那个继承那个的关系,只有“虎”像“猫”的关系。原型着眼于相似性,在js中,代码估计可以写作:
Tiger.prototype = new Cat()函数的原型也可以只是空白对象:
SomeClass.prototype = {}我们回到寻址上来,假设用.来获取某个属性,它偏偏是原型里面的属性怎么办?现象是:它的确取到了,但是,这是怎么取到的?如果对象本身的属性和原型属性重名怎么办?还好,对象本身的属性优先。
把方法定义在原型里面是很好的设计策略。假如我们改一下上面的例子:
function Point(x,y){
this.x = x;
this.y = y;
}
Point.prototype.abs = function(){return Math.sqrt(this.x*this.x+this.y*this,y)}
var a = new Point(1,2);
var b = new Point(1,2);
echo("a.abs == b.abs ? "+(a.abs==b.abs));
echo("a.abs === b.abs ? "+(a.abs===b.abs));
这下,输出终于相等了,究其原因,因为a.abs和b.abs指向的是Point类原型的成员abs,所以输出相等。不过,我们不能直接访问Point.prototype.abs,测试的时候直接出错。更正:经过重新测试,“Point.prototype.abs不能访问”的问题是我采用的JSCOnsole的问题。回复是对的,感谢您的指正!
原型链可以很长很长,甚至可以绕成环。考虑下面的代码:
A = function(x){this.x = x};
B = function(x){this.y = x};
A.prototype = new B(1);
B.prototype = new A(1);
var a = new A(2);
echo(a.x+' , '+a.y);
var b = new B(2);
echo(b.x+' , '+b.y);
这描述的关系大概就是“我就像你,你也像我”。原型指针对指造成了下面的输出:
2 , 1
1 , 2
搜索a.y的时候,沿着原型链找到了“a.prototype”,输出1;b.x也是一样的原理。现在,我们要输出“a.z”这个没有注册的属性:
echo(tyoeof a.z)我们很诧异,这里并没有死循环,看来解释器有一个机制来处理原型链成环的问题。同时,原型要么结成树,要么就成单环,不会有多环结构,这是很简单的图论。
this:函数中的潜规则
方法(函数)调用中最令人烦恼的潜规则就是this问题。从道理上讲,this是一个指针,戳向调用者(某个对象)。但假如this永远指向调用者的话,世界就太美好了。但这个可恶的指针时不时的“踢你的狗”。可能修改的情况包括call、apply、异步调用和“window.eval”。
我更愿意把this当做一个参数,就像lua里面的self一样。lua的self可以显式传递,也可以用冒号来调用:
a:f(x,y,z) === a.f(a,x,y,z)JS中“素”的方法调用也是这个样子:
a.f(x,y,z) === a.f.call(a,x,y,z)f.call才是真正“干净”的调用形式,这就如同lua中干净的调用一般。很多人都说lua是js的清晰版,lua简化了js的很多东西,曝光了js许多的潜规则,着实不假。
修正“this”的原理
《王者归来》上面提到的“用闭包修正this”,先看代码:
button1.onclick = (
function(e){return function(){button_click.apply(e,arguments)}}
)(button1)别小看了这一行代码,其实它创建了一个ARI,将button1绑定于此,然后返回一个函数,函数强制以e为调用者(主语)调用button_click,所以,传到button_click里的this就是e,也就是button1咯!事件绑定结束后,环境大概是下面的样子:
button1.onclick = _F_; //给返回的匿名函数设置一个名字
_F_.staticLink = _ARI_; //创建之后就调用的匿名函数的ARI
_ARI_[e] = button1 //匿名ARI参数表里面的e,同时也是_F_寻找的那个e
于是,我们单击button,就会调用_F_,_F_发起了一个调用者是e的button_click函数,根据我们前面的分析,e等于button1,所以我们得到了一个保险的“指定调用者”方法。或许我们还可以继续发挥这个思路,做成通用接口:
bindFunction = function(f,e){ //我们是好人,不改原型,不改……
return function(){
f.apply(e,arguments)
}
}