写在前面:本文为JavaScript新手之作,大牛轻喷
要说当下编程社区里最火的语言是什么,如果JavaScript称第二,怕是没其他语言敢称第一。不过这几年JS实在发展得太快,而在ES5之后,好像除了统一的模块机制,我其实并没有觉得JS的发展给前端加上了什么非用不可的语言特性——嗯,是的我觉得前端不太用得上 async 和 await(什么?class?我没听过诶)。
不过JS仍然是一个很好玩的语言,和Lua一样,有着Scheme一样的风骨。所以窃以为,要理解一些JS的设计思路和编码习惯,还是要搞点函数式才行。网上有一套开源的书叫 You Don't Know JS,我之前小翻了前几本,觉得写得很不错。但是仔细想想它解释一些概念的说法,还是可能对命令式编程背景的读者不是那么友好。前两天组里有一个同事问我能不能对JS里的 this
给一个相对浅显的理解,我第一反应就是把 YDKJS 里让我印象深刻的说法丢出来:
this
相当于JS静态作用域里的一个动态变量
——呃,这个定义是挺准确的,但是真的好理解吗?
好吧,展开来写一篇我自己是怎么一步一步地理解JS里的 this
这个磨人的小妖精
静态作用域和动态作用域
变量作用域其实和所有编程语言都相关,但是函数式编程语言为了闭包的支持,对这个概念尤其看重。来一个简单的栗子——我们有如下两个函数一个变量和一次调用:
var v = 1;
function f1() {
console.log(v);
}
function f2() {
var v = 2;
f1();
}
f2();
我们知道JS是一个静态作用域的语言,如果把上面代码贴到浏览器的 console 里执行一下,会输出 1
。这说明 v
这个变量的绑定是在我们写 f1
的定义时就确定的,也可以说 v
的作用域是静态的,所以这样的设计叫静态作用域(也有人叫词法作用域)。但是如果JS是一个动态作用域的语言,v
的绑定就是在 f1
运行的时候确定的,在 f2
里这个值指向了上面那句 var v = 2
,最终输出的结果就会是 2
。这个问题实在太重要了,因为正是作用域在函数间的隔离,给了函数足够的抽象能力来实现各种对逻辑和数据的封装。
Now this
is the main dish
好了,现在我们理解了静态和动态作用域以后,就可以贴点复杂一些但是更能说明问题的例子了——首先,假设我们的App有这么一个工具集:
var utils = {
base: 0,
accumulate: function(list, acc) {
var result = this.base;
for (var i of list) result = acc(result, i);
return result;
},
};
系不系很简单?accumulate
函数就是拿一列数字和一个 acc
函数,然后把这个函数对这列数字层层套上(如果脑海里飞过left fold/reduce这样的概念,请给自己一朵小红花)。现在假设我们的App就是一个用来把1、2、3、4加起来的简单计算器——
var app = {
list: [1, 2, 3, 4],
simpleSum: function() {
var result = utils.accumulate(this.list, function(x, y) { return x + y });
console.log(result);
},
};
app.simpleSum();
是不是很明显会log一个10?现在我们来让逻辑复杂一些,求这几个数的平方和:
app.square = function(x) { return x * x };
app.sqrSum = function() {
console.log(utils.accumulate(
this.list,
function(x, y) { return x + this.square(y) } // <-- watch this
));
};
app.sqrSum(); // Hmm...
发现了吗,传递给 app.sqrSum
的函数里,this
指向的并不是 app
。问题出在哪里呢?问题就在于,this
在函数里的作用域不是跟着定义走,而是在运行的时候被动态分发的(敲黑板!划重点!)。是不是看起来和动态作用域简直一毛一样?
所以我们怎么绕开这个坑呢?这就是在遥远的ES5时代及以前,大家经常用的一个办法:把 this
存成另一个变量,再把这个变量传进子函数里:
app.sqrSum = function() {
var self = this; // some may prefer using `that`
console.log(utils.accumulate(
this.list,
function(x, y) { return x + self.square(y) } // now self is referenced correctly
));
};
把 this
赋值给另一个变量再往里传是不是其实有点蛋疼?确实有点。JS社区里大家也这么觉得,所以才为此在ES6里搞了个箭头函数,在箭头函数定义里的 this
就是跟着词法作用域走的。这个特性在网上已经有太多讨论,一搜一大把,这里就不展开了。
更好的JS
不管是箭头函数还是重新赋值,其实都不是我心中最好的解决方式——我其实更偏向于直接用函数,而不是用对象来封装数据和逻辑。学JS的朋友大多都读过或者听过 JavaScript the Good Parts 这本书的大名吧,作者 Douglas Crockford 在14年曾经谈过他觉得JavaScript里怎样做到更好的设计模式,这个Talk名字就叫 JavaScript the Better Parts。其中有一点我特别特别赞同:完全不要用this,因为……根本不需要!JS里的函数是比对象更全能的抽象方法。
再复用刚才的那个例子,我就不需要再特别解释,直接把用函数做抽象的代码贴上来一看就懂了:
var utils = (function utils() {
var base = 0;
function accumulate(list, func) {
var result = base;
for (var i of list) result = func(result, i);
return result;
}
return { base: base, accumulate: accumulate };
})();
var app = (function app() {
var list = [1, 2, 3, 4], square = function(x) { return x * x };
return {
simpleSum: function() {
console.log(utils.accumulate(
list,
function(x, y) { return x + y }
));
},
sqrSum: function() {
console.log(utils.accumulate(
list,
function(x, y) { return x + square(y) }
));
},
};
})();
是不是没有了 this
以后整个逻辑清晰不少?再也不用纠结指的是哪一层对象的字段了。而且用函数来做封装还有另外一个好处,不放在返回对象字段里的变量对外不可见,相当于一个私有变量。如果不习惯IIFE的写法,网上的资料也挺多的,搜一下不难,这里就略过不表咯。至于那些对象实例的 call
和 bind
这些用法细节,在把 this
拿掉之后,也一样没有必要去深究了——是不是好棒棒?
但是残酷的现实是写一个网页App一般都会用个框架,框架里一般都会用上 this
,实在是……(啊不,像我这样没有能力写一个框架的小菜鸡,是不会抱怨的)
P.S. 如果有大牛想吐槽我的API里犯了跟Lodash和Underscore一样的错——那就吐吧反正这种科普贴只是我分享自己的入门心得,又不是给大牛看的吼吼……