Wierd Parts


Motavition

一直以来JavaScript是我认为设计的比较奇怪的一门语言,一个原因是JavaScript中一切皆为Object,导致很多概念之间是模糊的,比如函数,对象,Prototype,Interface等等。而且它和同时期主流的编程语言设计有着非常大的不同,一个最重要的差别是,JavaScript采用基于Prototype的继承方式,而不是类继承,但是它的语法又借鉴了其它面向对象语言(比如引入了关键字new,this等,后面我们会看到这实际上是一个很糟糕的设计),这使得很多C++或者Java的程序员误以为JavaScript的面向对象和C++或Java是相同的。实际上,JavaScript完全可以采用一套自己独有的语言设计风格,这种基于Prototype + 弱类型设计,可以使其足够的灵活,也足以衍生出许多比C++或Java更富表现力的设计。

Scope

JavaScript中的作用域设计的似乎有一些反常识,比如下面代码:

var a = 1;
//create a scope
{
    var a = 2;
}
console.log(a); //2
//function scope
function func(){
    var a = 3;
}
console.log(a); //2

上述代码中,第一个{}中的a改变了全局变量a的值,说明JS中单独的{}并不产生作用域的效果,但是函数body的{}却可以。

在随后的ES6中似乎修正了这个问题,let关键字可以保证变量严格受scope约束

为了进一步了解作用域的问题,下面我们分析一下var a = 2的执行过程:

  1. 当编译器遇到这行代码时,首先查看a所在的作用域中是否有该符号,如果有则忽略该语句,继续向下执行,否则会向作用域注册一个a符号并赋值undefined。上面的例子中,由于{}不产生作用域,var a = 2中的a{}外层的a共享作用域,因此这条语句会被编译器忽略。

  2. 接下来,编译器在编译完上述代码后,会生成可执行代码和运行时上下文,当执行到var a = 2时,JS的引擎会先从运行时上下文中查找a是否存在,如果存在,则将其指向的内容变为2,不存在则报错。

按照上面的过程,我们就不难解释JavaScript中所谓的”Hoisting”:

console.log(a) //undefined
var a = 10

当执行console.log(a)时,编译器已经将a注册到运行时的上下文中了,并赋予了初值undefined,因此console.log(a)输出undefined。接下来引擎会

Function

函数可以说是JavaScript中最出彩的设计,如果放到在当时的年代,这种设计确实很大胆也很领先。在JS中的函数是First-Class Object,它即有函数的特性,也同时具有Object的特性,这使得在JS中函数的定义和使用非常灵活,

function func(name){
    console.log(name)
}

函数名func也是函数对象的名称,可以像使用任何Object一样使用func,比如可以给它增加成员函数,成员变量,做参数传递,做返回值返回等等。

func.index = 1;
func.log = function(text){
    console.log(text)
}
func.log("some text") //some text

和Object相同的是,它也有自己的所谓的”基类”,即Function.prototype,而Function.prototype也是一个Object,它的”基类“是Object.prototype

func.__proto__ === Function.prototype; //true
Function.prototype.__proto__ === Object.prototype //true

上述代码可看出,FunctionObject均为大写开头,这种规则“暗示”其类型为一个”构造函数“。”构造函数”也是一种函数对象,而函数对象和普通对象不同的是,它还有一个prototype属性:

func.prototype; //{constructor: ƒ,__proto__:Object}

func.prototype的类型也是Object,它只有一个成员叫做constructor,而construtor的值又指向func

这是一个看起来特别让人困惑的设计,为什么要给函数对象增加这么一个对象? 实际上引入这个prototype是为了模拟”继承“或者Interface,实现代码的重用。由于JavaScript中没有提供一种专门的”构造函数“,因此每个函数都会得到一个prototype对象。而constructor属性目前看来还没有什么用。

在后面讨论JavaScript面向对象的时候还会继续深入讨论prototype机制。

最后说一下函数的传参问题,在JS中,没有所谓的函数签名,因此函数的声明和函数的调用也不需要match,我们可以不给函数声明任何参数,却可以在调用它的时候传递若干个参数。

function func(){
    console.log(arguments[0]) //1
}
func(1,2,3)

this

this在JS中是另一个古怪的存在,由于这门语言没有面向对象设计,因此this应该无从谈起,但实际上this却到处都有,有global的this,函数中有this,Object中也有this

Global Object

全局this指向global object

console.log(this);
//Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}
this.var = 100;
console.log(windown.var); //100

全局函数中的this同样指向window obejct

function a(){
    consloe.log(this); //points to window obejct
    this.var = "hello"; //add var to window object
}
var b = function b(){
    consloe.log(this); //window obejct
}

this in Obejct

如果this位于某个Object的内部,则this指向该Obejct

var person = {
	firstName: "Elie",
    log:function(){
        console.log(this) //points to person obejct
    }
}

但是如果你认为person内部定义的function其this都指向person的话,那么你就错了,如果在log函数中定义另一个function,那么这个function中的this指向的是global object

var person = {
	firstName: "Elie",
    log:function(){
        console.log(this) //person
        var setname = function(name){
            this.firstName = name; //this points to window object
        }
        setname("Tom");//not working 
    }
}

正确的做法是显式的定义一个this的引用

log:function(){
    var self = this;
    var setname = function(name){
        self.firstName = name; 
    }
    setname("Tom"); 
}

bind,apply,call

var logName = function(str1, str2){
    console.log(this.getFullName()) //window object
}

上述代码中,logName是一个全局函数,由前面的讨论可知,此时this指向global object,也就是window,由于windown中没有getFullName这个方法,因此上述代码执行会出错。针对上面的情况,在JavaScript中,this的值是可以改变的,我们可以让this指向一个有getFullName方法的对象:

var person = {
    firstname: 'John',
    lastname: 'Doe',
    getFullName: function(){
        var fullname = this.firstname + ' ' + this.lastname;
        return fullname;
    }
}

var logName = (function(str1,str2){
    console.log(this.getFullName()) //wrong
}).bind(person)

logName() //John Doe
logName.call(person,"str1","str2") //John Doe
logName.apply(person,["str1,str2"])//John Doe

上述代码中令logName函数中的this指向了person

bind,call,apply这三种方式均可以改变this的指向,不同的是bind并不执行函数,只是改变this的值,call,apply会直接执行函数,callapply的区别仅在传参的写法上。

凡是有this的函数均可以用这几种方式去改变this,比如

var person2 = {
    firstname="Jane",
    lastname="Doe"
}
person.getFullName.apply(person2)

bind还以用来改变函数的行为

function map(arr, fn){
    var ret = []
    for(let item of arr){
        ret.push(fn(item))
    }
    return ret; 
}

const arr = map([1,2,3],function(limit,item){
    return item > limit;
}.bind(this,1)) //limit的值为1

上面代码中,map接受两个参数,一个是数组,另一个是fn函数。fn函数按照上面定义,接受一个数组元素作为参数。接下来当我们在调用map的时候,却给fn传了两个参数,一个是this用作占位(对于fn来说,这个this没有实际意义)另一个是数字1,此时对于fn来说,传入的1会被自动绑定到limit上,而fn此时也等价于下面的函数:

function(item){
    limit = 1;
    return item > 1;
}

IIFEs

第一次看到IIFEs这种形式的JS代码,完全不知道它是干嘛的,这种写法实际上隐含了JavaScript解释器的一些规则,看下面代码

var greeting = function(name){
    return 'Hello' + name;
}('John')
console.log(greeting);

这时greeting的类型是什么呢?string还是function? 显然上述写法中,greeting变成了string。不难理解上述代码的执行顺序为:

  1. 匿名函数求值
  2. 将求值结果保存在greeting中。

为什么会这么解释,在任何编程语言中,statement和expression是两个概念,statement是普通的语句,可以是条件语句,或者定义一个函数等等,而expression则表示一个表达式,而表达式是要立刻求值的。上面代码中,当编译器看到var greeting = 时,知道后面是一个表达式,进而对后面的匿名函数进行求值。而我们如果让编译器先看到function,结果会是什么样呢?

function(name){ //wrong
    return 'Hello ' + name;
}

错误原因是上面的语句以function开头,编译器会认为它是一个函数定义的statement,而statement一定要有”主语”,即函数名,这样编译器才能将该符号注册到context中,由于上面语句没有函数名,因此报错。但是如果使用()将其括起来,则语义就会发生变化:

(function(name){
    return 'Hello ' + name;
})

在JS中()包裹的statement为一个表达式expression,因此上述代码会被当做expression进行求值,显然求值的结果是得到了一个匿名函数(值得注意的是,匿名函数并没有求值)。因此该表达式的返回值是一个匿名函数,相当于

var func = (function(name){
    return 'Hello ' + name;
})

如果想要执行这个匿名函数,则需要显式调用它

//#1
(function(name){
    return 'Hello ' + name;
})("John")

//#2
(function(name){
    return 'Hello ' + name;
}("John"))

这样,上述代码就变成了两个表达式。如果是第一种写法,则第一个()表达式返回了一个匿名函数,第二表达式是匿名函数调用得到一个string;如果是第二种写法,则第一个表达式为函数求值,返回一个string,第二个表达式为()什么也没做,直接返回该string。

这两种写法的结果一样,但是表达式的执行顺序却不同。

这种方式对于隔离全局变量很有帮助,由于JS没有命名空间,函数外定义的的变量都是全局的,如果我们想要执行一段代码又不污染全局变量,使用IIFE是一种好的方式

Closures

function greet(str1){
    return function(str2){
        console.log(str1+' '+str2)
    }
}
var func2 = greet("Hi")
func2("Tony") //Hi Tony

上面代码中,按照常理理解,在执行func2之前,greet函数已经执行完了,str1应该已经被释放了,为什么在执行func2的时候还能访问到str1呢?如果熟悉其它编程语言的Closure,比如C++的Lambda表达式,Objective-C的Block等,那么这个问题就不难理解。从现象看,是str1str2所在的匿名函数给capture了,至于是怎么capture的,无外乎两种策略,拷贝或者引用,按照前面小节的推断,如果str1是Primary Type,那么应该是拷贝,如果是Object类型,那么应该是传引用。但是对于Closures,无论任何数据类型,均是传引用,下面是一个经典的例子:

function buildFunctions(){
    var arr = [];
    for(var i =0; i<3;i++){
        arr.push(function(){
            console.log(i);
        })
    }
    return arr;
}

var fs = buildFunctions();
fs[0](); //3
fs[1](); //3
fs[2](); //3

通过这个例子可以看出,即使是i作为int型的Primary Type,对于Closure而言,保存的仍是它的引用。Closure这个特性可用来做异步任务,异步任务的回调函数可以capture在执行任务前的变量,比如

function timer_func(){
    var greeting = "Hi!";
    setTimeout(function(){
        console.log(greeting);
    },3000);
}
timer_func()

上述代码中,在执行3s后输出Hi!,其原因是setTimer的回调函数capture了greeting。JavaScript的这个特性对很多Framework的设计起到了非常关键的作用。

Call By Value / Call by Reference

这个问题是理解每一门编程语言都要绕不过去的问题,本质上是内存分配问题,无论是Python,C++,Java等等,比如C++中函数传参可以有传值和传引用两种方式,所谓传值就是拷贝,传引用就是传地址。理解这个问题是正确处理Side Effect的基础。

在JS中对Primary Type类型的对象传值,对Object类型的对象传引用。即是不是传参的情况,JS对象之间的赋值也是引用的传递。

//pass by value
function change(b) { b = 2; }
var a = 1;
change(a);
console.log(a) // still 1

//pass object by reference
function changeObj(d){
    d.prop1 = "string"
}
var c =  {}
c.prop1 = {};
changeObj(c);
consloe.log(c.prop1); //string

null & undefined

JavaScript中的null表示显式的指明某变量的值为空或者0,undefined表示”无”的原始值,转为数值时为NaN。

var x;
console.log(x); //undefined
var y = null;
console.log(y); //null
Number(null); //0
5 + null //5
5 + undefined //NaN

在应用上二者没有本质的区别,在条件判断中都是false

if (!undefined) 
    console.log('undefined is false');// undefined is false
if (!null) 
    console.log('null is false');// null is false
undefined == null // true

对于undefined,可以用在

  1. 变量被声明了,默认值为undefined。
  2. 调用函数时,缺省参数默认为undefined。
  3. 对象的属性没有赋值,该属性的值为undefined。
  4. 不指定函数返回值,默认返回undefined。

对于null,一般用来表示

  1. 显式指定函数参数为null
  2. 显式指定原型链的终点为null
Object.getPrototypeOf(Object.prototype) //null

在设计上,二者的类型确不同,对于null的类型为Object,这个已经被人吐槽好多次了,这里就不再解释了。

typeof undifined //undefined
typeof null //object

Exceptions

JavaScript使用throw抛异常,当函数抛出异常后会立即终止运行

var add = function (a,b){
    if(typeof a != 'number' || typeof b != 'number'){
        throw{
            name: 'TypeError',
            message: 'add needs numbers'
        };
    }
    return (a+b); //won't run if there is an error
}

var  try_it = function(x,y){
    try{
        console.log(add(x,y));
    }catch(e){
        console.log(e.name, e.message)
    }
}
try_it(1,"10");

Resources