javascript 语言精粹
继承
当一个函数对象被创建时,
Function
构造器产生的函数对象会运行类似这样的一些代码:
1 | this.prototype = {constructor:this}; |
新函数对象被赋予一个prototype
属性,其值是包含在一个constructor
属性并且属性值为该函数的新对象.该prototype
对象是存放继承特征的地方.因为javascript语言没有提供一种方法去确定哪个函数是打算用来做构造器的,所以每个函数都会得到一个prototype
对象(非常重要).
通过伪类的方式实现继承
本质上来说就是子类的原型等于父类的实例,实例如下:
1 | var SuperType = function(){ |
数组
javascript中并没有真正的数组,数组本质上也是对象
请看下面的例子:
1 | var arr = [1,2,3,4,5,6] |
由运行的结果可以看出给数组添加了一个abc属性,尽管字面上的长度有所增加,但是数组的实际长度并没有改变!
1 | typeof [] // 'object' |
所以为了区分数组和对象我们应该可以采用以下的函数:
1 | let isArray = value => !!value && typeof value === 'object' && value.constructor === Array |
正则表达式
分组
观察以下匹配url的正则表达式:
1 |
|
(?:([A-Za-z]+):)?
这个因子匹配一个协议名,但仅当它之后跟随一个冒号(:)的时候才匹配(?:...)
表示一个非捕获型分组(noncapturing group),通常用非捕获分组来代替少量不优美的捕获型分组是很好的方法,因为捕获会有性能上的缺失.后缀?
表示这个分组是可选的.(...)
表示一个捕获型分组(capturing group).一个捕获型分组将复制它所匹配的文本,并将其放入到result数组中.每个捕获型分组都将被指定一个编号,第一个捕获分组的编号是1,所以该分组所匹配的文本拷贝将出现在result[1]中;下一个因子(\/{0,3})
是捕获分组2.
常用正则
匹配数字
1 | pattern_number = /^-?\d+(?:\.\d*)?(?:e[+\-]?\d+)?$/i |
正则表达式转义
\1
指向分组1所捕获到的文本的一个引用,所以它能够再次匹配,例如我们用下面的正则表达式来搜索文本中重复的单词:
1 | var doubled_words = /[A-Za-z\u00c0-\u1fff\u2800-\ufffd\-]+\s+\1/gi |
正则表达式分组
捕获型
一个被包围在圆括号中的正则表达式选择.任何匹配这个分组的字符将被捕获.每个捕获分组都被指定了一个数字,第一个捕获(的是分组1,第二个捕获(的是分组2
非捕获型
有一个(?:
前缀,仅做简单匹配,并不会捕获所匹配文本,会有微弱的性能优势,不会干扰捕获型分组的编号.
关于语言本身的一些探讨
位运算
由于javascript中所有数字都是浮点型,所以使用位运算会降低程序性能,因为需要将数字转化为整型后执行运算然后转化回去.
巧妙使用位运算可以创造很多的奇淫技巧。例如以下的转化为整数的代码:
1 | let parseInt = num => num | 0 |
以上的parseInt
实现可以传入任意的数据类型,如果数据类型不匹配将会被转化为0,避免了原生的parseInt
中的NaN
的问题,但是局限在于只能处理32位的整数。
生成随机字母数字字符串
1 | function generateRandomAlphaNum(len) { |
toString(36)
表示10个阿拉伯数字和26个小写英文字母
随机背景色
1 | '#'+(0x1000000+(Math.random())*0xffffff).toString(16).substr(1,6) |
在js中如何优雅实现sleep
promise
1 | function sleep(delay){ |
generator
1 | function sleep(ms) { |
参见知乎
underscore.js中的_.after方法
该方法可以根据传入的times参数的不同生成需要调用多次才执行的实际函数的函数,这是偏函数的典型应用
1 | var _ = require('underscore') |
其内部实现如下:
1 | _.after = function(times, func) { |
闭包
闭包表示的是一个封闭的内存空间。每个函数被创建的时候,都有一个与之关联的闭包。在了解闭包的使用场景之前,先看下面一个例子:
1 | function f() { |
这段代码非常简单。我们知道一旦 f 执行完毕,那么它本次执行的栈上的数据将会被释放,所以每次调用结束后,栈上的 i 都会被及时的释放。再来看另一个例子:
1 | function f() { |
和第一个例子一样,这段代码同样会打印 0。但是这似乎打破了我们第一个例子的总结,按照第一个例子的说法,f 运行结束后,本次调用的栈上的 i 应该被释放掉了。但是我们随后调用返回的匿名函数,发现并没有报错,这就归功于闭包。
每个函数被创建的时候,都会有一个与之关联的闭包被同时创建。在新创建的函数内部,如果引用了外部作用域中的变量,那么这些变量都会被添加到该函数的闭包中。
注意上面代码的注释,为了方便描述,我们将匿名函数取名为 f1。当 f 被调用的时候,f1 被创建,同时与之关联的闭包也被创建。由于 f1 内部引用了位于其作用域之外的、f 作用域中的变量 i,因此 f 作用域中的 i 被拷贝到了 f1 的闭包中。这就解释了,为什么 f 执行完成之后,调用 f1 依然可以打印 0。
现在来看一下第三个例子:
1 | function f() { |
我们会发现打印 1。好像又与第二个例子的结论有些冲突,f 中的 i 不是被拷贝到了 f1 的闭包中吗?为什么不是打印 0 而是打印 1 呢?
这是因为,我们还没有介绍发生拷贝的时机。如果新创建的函数,引用了外部作用域的变量,并且该变量为活动的,那么并不急于将该变量的内容拷贝到闭包中,而是将该变量所指向的内存单元的地址保存于闭包中。比如我们这里,只是先将 i 所绑定到的内存地址保存于闭包中,等到 i 为非活动状态时,才会进行拷贝。也就是这里,当 f 即将运行结束时,i 的将变为非活动状态,那么需要将其内容拷贝到引用它的闭包中,也就是这里的 f1 的闭包中。一旦内容被拷贝到闭包中,除了与之关联的函数对象之外,再也没有其他方式可以访问到其中的内容。
顺便介绍一下,那么**闭包中占用的内存何时才会被释放呢?答案就是当与它关联的函数对象被释放的时候。**比如我们接着上面的例子运行:
1 | var ff = null |
我们将引用 f1 的变量 ff 赋值为 null,这样就没有任何变量引用 f1 了,所以 f1 成为了垃圾,会在未来的某个时间点(具体要看 GC 的实现以及运行情况),由垃圾回收器进行所占内存回收。
上面的例子,其实就是下面的例子的简化版:
1 | function f() { |
这里新创建的两个函数都会打印 2,想必这个例子大家都很熟悉了,就不再赘述了。只是有一个问题需要注意,既然上面提到了说,新创建的函数引用的外部作用域上的变量内容、最终都会拷贝到该函数的闭包中,那么上面的例子中,i 是不是被拷贝了两次?
再来看一个例子:
1 | function f() { |
这个例子会打印什么?答案是 2 和 3。这是因为闭包的另一个机制,同一个变量被引用它的多个闭包所共享。我们在 for 循环内部创建了两个函数,在循环外部创建了一个函数,这三个函数的都引用了 f 中的 i,因而 i 被这三个函数的闭包所共享,也就是说在 i 离开自己所属的作用域时(f 退出前),将只会发生一次拷贝,并将新创建的三个函数的闭包中的 i 的对应的指针设定为那一份拷贝的内存地址即可。对于这一个共享的拷贝地址,除了这三个闭包之外,没有其他方式可以访问到它。
必须再次强调的是,被引用的变量拷贝到闭包中的时机发生在、被引用的变量离开自己所属的作用域时,即状态为非活动时。考虑下面的例子:
1 | function f() { |
我们知道 ES6 中引入了 let 关键字,由它声明的变量所属块级作用域。在上面的例子中,我们在 for 循环体的初始化部分使用了 let,这样一来 i 的作用域被设定为了该循环的块级作用域内。不过另一个细节是,循环体中的 i ,也就是 ff 中引用的 i,在每次迭代中都会进行重新绑定,换句话说循环体中的 i 的作用域是每一次的迭代。因此在循环体中,当每次迭代的 i 离开作用域时,它的状态变为非活动的,因此它的内容被拷贝到引用它的闭包中。
闭包常常会和 IIFE 一起使用,比如:
1 | var a = []; |
在上面的例子中,让人迷惑的除了闭包的部分之外,就是 i1,i2 和 i3 了。
- i1 是 f1 的形参
- i2 是 f2 中对外层作用域中的变量的引用
- i3 是全局的变量 i,IIFE 执行时 i 对应的值将被作为实参来调用 f1
- 当 f1 被调用时,也就是 IIFE 执行阶段,它内部创建了一个新的函数 f2,同时也创建了 f2 对应的闭包
- 由于 f2 中引用了外层作用域中的 i,即 f1 执行期间的 i,且 i 为活动内容,所以 f2 的闭包中添加一条 Key 为 i,Value 为指向 f1 中活动的 i 绑定到的内存单元的地址
- 当 IIFE 执行完毕,即 f1 要退出的时候,其栈上活动对象 i 就会离开作用域,因此需要将 i 拷贝到引用它的闭包中。
到目前为止,我们看到的例子都引用的直接外层作用域中的变量,那么我们再来看一个例子:
1 | function f(x) { // f1 |
为了方便描述,我们分别标记了 f1,f2,f3。我们在 f3 内部,引用了 x 和 y,并且 x 并不是 f3 的直接外部作用域。那么这个闭包的构建过程时怎样的?
在 JS 中,函数也是以对象的形式存在的,如果将与函数关联的闭包想象成函数对象的一个类型为 Map<string, Value> 的属性也不过份,比如:
1 | const CLOSURE = Symbol('closure'); |
即使在引擎的实现阶段,因为性能或者实现差异不采用这样的设计,但本质上与这个结构含义是一致的。为了能在运行阶段创建函数对象,在编译阶段就需要收集到必要的信息:
- 形参列表
- 函数体
- 引用的外部变量
比如在编译 f3 的阶段,我们发现它内部引用了外部的 x 和 y,由于 x 不是直接存在于父及作用域 f2 中的,为了使得未来使用 f2 创建 f3 的时候,仍能够找到 x 的绑定,我们需要将 x 加入到 f2 的闭包中。所以在编译阶段,我们会在 f2 的信息中标注它内部引用了外部变量 x。这样在创建 f2 的时候,x 就会被拷贝到它的闭包中了,等到使用它再创建 f3 的时候,f3 中的 x 也就有了着落。
最后来一个拓展练习:
1 | function f(x) { |
闭包有什么用
如果被问到「闭包有什么作用?」想必有同学首先会想到「模拟私有变量」,又或者「在回调中有时会用到」,又或者最接近正确答案的「用于捕获变量」等等诸如此类。这些回答显然都是正确的,不过它们并不是「闭包的作用」,而是闭包的一些具体的使用场景。同样的,如果被问到「闭包有什么作用」,那么回答为什么需要闭包,才是答其所问,而不是闭包都能干什么。
作用域
首先我们知道编程语言中有一个「作用域」的概念,那么为什么是作用域呢?作用域就是程序中的「一段范围」在这个范围之内,某些变量是有效的。那么为什么要提出这个概念呢?没有作用域就不能编程吗?当然不是,没有作用域也是可以编程的,我们知道机器语言就没有作用域的概念,我曾经见过有前辈直接使用机器码编写出音乐播放器。
那么既然没有作用域也能编程,为什么要引入这个概念呢?这是因为高级编程语言中要尽量的提供丰富地表达程序的能力,因此提出了变量名的概念,而编程中的词汇是相对匮乏的,毕竟编程是一定程度上的对现实抽象的内容。想象一下,如果程序中对于同一个变量名只能使用一次,那么必定是一个噩梦,比如我们在写循环的时候经常使用 i,这下好了,程序中只能出现一个 i,其余都得是 i1 i2 … iN 这样了。
有了作用域之后,我们在每个作用域中都能使用 i 了,这样就使得大家的词汇量得到了解放,也一定程序上使得程序表达更加简洁清晰,否则因为是程序中第 100 个循环就使用变量 i100 多少会让人感到有点傻。
一段范围
注意我们在介绍作用域中提到的:作用域就是程序中的「一段范围」在这个范围之内,某些变量是有效的。这个一段范围,在现有的编程语言实现中具有两种不同的解释:
- 静态作用域,又称为词法作用域
- 动态作用域
静态作用域
在具有静态作用域的语言中,它们对「一段范围」的解释是,代码中的一段范围。换句话说,变量的作用域是直接体现在代码中的、即静态的;在解析阶段就可以确定的、即词法的。静态作用域的好处就是,通过阅读代码,我们和解析程序就能够确定一个变量的作用域,当然就很方便理解了。
1 |
|
结果打印 10。我们在 g 函数中调用了 f,我们知道 f 中的 x 就是全局作用域下的 x,因此即使在 g 中我们定义了局部变量 x,仍然不会影响到 f 中的 x 的绑定关系。
动态作用域
在那些使用动态作用域的语言中,它们对「一段范围」的解释是,程序执行中的某个时间点。换句话说,变量的作用域是又程序运行阶段的行为确定的,是不可预测的,因此在人肉确定变量的作用域时会花费一些精力。
1 | int x = 10; |
结果打印 20,注意这个程序并不是 C 程序了只是用来显示动态作用域的程序。在动态作用域的语言中,如何确定变量的绑定关系取决于当前调用的作用域。当我们在调用 g 的之后,我们在其作用域下定义了 x,于是在 f 中,其内部的 x 将引用到当前调用的作用域下的 x,也就是 g 中当前的 x。
闭包的概念的缘起
既然 C 语言也是词法作用域的,那么为什么 C 语言中没有闭包的概念呢?这是因为在严格意义上来说,在 C 语言中函数并不是一等公民,也就是说你不能够像创建其他数据类型的实例一样、在运行阶段动态的创建一个函数、并将这个函数在程序中来回传递。当然利用汇编或者非 POSIX 中的 JUMP 类指令或者 API,能够模拟出动态创建函数的功能,但是在语言层面上,是没有直接的支持的。
回到 JS 中,函数是一等公民,我们可以像创建普通类型的变量一样创建一个函数类型的变量,比如:
1 | var f = function () { |
在 C 语言中,我们从语言直接提供的语义层面压根做不到上面的功能,而在 JS 中做到了,随之而生的就需要如何对 x 给出合理的解释。
由谁来向谁解释呢?由对语言负责的组织、即标准委员会来像使用语言的人进行解释。为了延续程序其他部分的词法作用域的特质,动态创建的函数部分也维持这个语义,才能保证语言的连贯性和整体性(当然也有同时提供两种作用域的语言,比如 Perl)。为了达到这个目的,就引入了闭包的概念,之所以是引入,而不是创造,是因为闭包也是前人的研究成果,当然前辈研究出闭包的目的也是为了支持在词法作用域下把函数当成一等公民来使用。
总结
说了这么多,对于问题「闭包的作用」,其答案就是「支持在词法作用域下将函数当做一等公民来使用」,没错,就是它的发明者当初发明它的目的。和「起子的作用就是拧螺丝」是一个道理。当然本文还是从理解问题开始,到作用域、到最终引出闭包的作用这样一个循序渐进的过程来进行讲解。相信会比直接给出答案「支持在词法作用域下将函数当做一等公民来使用」这样有利于大家进行理解吧。