JavaScript 进阶知识、技巧

对象

Js 共有number、string、boolean、null、undefined、object六种主要类型,除了object的其它五中类型都属于基本类型,它们本身并不是对象。但是null有时会被当做对象处理,其原因在于不同的对象在底层都表示为二进制,在 js 中二进制前三位都为 0 的话就会被判定为object类型,而null的二进制表示全是 0, 所以使用typeof操作符会返回object,而后续的 Js 版本为了兼容前面埋下的坑,也就没有修复这个 bug。

"I'm a string"本身是一个字面量,并且是一个不可变的值,如果要在这个字面量上执行一些操作,比如获取长度、访问某个字符等,那就需要将其转换为String类型,在必要的时候 js 会自动帮我们完成这种转换,也就是说我们并不需要用new String('I'm a string')来显示的创建一个对象。类似的像使用42.359.toFixed(2)时,引擎也会自动把数字转换为Number对象。

nullundefined没有对应的构造形式,它们只有文字形式。相反,Date只有构造,没有文字形式。对于Object、Array、FunctionRegExp(正则表达式)来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。

Array 类型

数组类型有一套更加结构化的值存储机制,但是要记住的是,数组也是对象,所以有趣的是你也可以给数组添加属性。

var myArray = ["foo", 42, "bar"];
myArray.baz = "baz";
myArray.length; // 3
myArray.baz; // "baz"

数组类型的length属性是比较有特点的,它的特点在于不是只读的,也就是说你可以修改它的值。因此可以通过设置这个属性从数组末尾删除或添加新的项。

var colors = ["red", "blue", "green"];
colors.length = 2;
console.info(colors[2]); // undefined
colors.length = 4;
console.info(colors[4]); // undefined
// 向后面追加元素
colors[colors.length] = "black";

数组还有一些很方便的迭代方法,比如every()、filter()、forEach()、map()、some(),这些方法都不会修改数组中包含的值,传入这些方法的函数会接收三个参数:数组项的值、该项在数组中的位置、和数组对象本身。

Function 类型

在 ECMAScript 中,每个函数都是Function类的实例,而且都与其它引用类型一样具有属性和方法。由于函数时对象,因此函数名实际上也是一个指向函数对象的指针,不会与某个函数绑定。

在函数的内部有两个特殊的对象,thisargumentsarguments对象有calleecaller属性。caller用来指向调用它的function对象,若直接在全局环境下调用,则会返回nullcallee用来指向当前执行函数,所以我们可以通过下面的方式来实现阶乘函数。

function factorial(num) {
    if (num <= 1) {
        return 1;
    } else {
        return num * arguments.callee(num-1);
    }
}

每个函数都包含两个非继承而来的方法,apply()call(),这两个方法都是在特定作用域中调用函数,实际上等于设置函数体内this对象的值。首先,apply()方法接收两个参数,一个是在其中运行函数的作用域,另一个是参数数组,其中第二个参数可以是Array的实例,也可以是arguments对象。call()方法与apply()方法的作用相同,它们的区别仅仅在于接收参数的方式不同,在使用call()方法时必须逐个列举出来。

window.color = "red";
var o = {color: "blue"};
function sayColor() {
    console.info(this.color);
}
sayColor(); // red
sayColor.call(this); // red
sayColor.call(window); // red
sayColor.call(o); // blue
sayColor.apply(o); // blue

需要注意的是,在严格模式下未指定环境对象而调用函数,则this值不会转型为window,除非明确把函数添加到某个对象或者调用apply()call()

安全的类型检查

Js 内置的类型检查机制并不是完全可靠的,比如在 Safari(第5版前),对正则表达式应用typeof操作符会返回function;像instanceof在存在多个全局作用域(包含 frame)的情况下,也会返回不可靠的结果;前文提到的 Js 一开始埋下的坑也会导致类型检查出错。

我们可以使用toString()方法来达到安全类型检查的目的,在任何值上调用Object原生的toString()方法都会返回一个[object NativeConstructorName]格式的字符串,下面以检查数组为例。

Object.prototype.toString.call([]); // "[object Array]"
function isArray(val) {
    return Object.prototype.toString.call(val) == "[object Array]";
}

作用域安全的构造函数

构造函数其实就是一个使用new操作符调用的函数,当使用new操作符调用时,构造函数内用到的this对象会指向新创建的对象实例,比如我们有下面的构造函数。

function Person(name, age) {
    this.name = name;
    this.age = age;
}

现在的问题在于,要是我们不使用new操作符呢?会发生什么!

let person = Person('name', 23);
console.info(window.name); // name
console.info(window.age); // 23

很明显,这里污染了全局作用域,原因就在于没有使用new操作符调用构造函数,此时它就会被当作一个普通的函数被调用,this就被解析成了window对象。我们需要将构造函数修改为先确认this是否是正确类型的实例,如果不是则创建新的实例并返回。

function Person(name, age) {
    if (this instanceof Person) {
        this.name = name;
        this.age = age;
    } else {
        return new Person(name, age);
    }
}

高级定时器

大部分人都知道使用setTimeout()setInterval()可以方便的创建定时任务,看起来好像 Js 也是多线程的一样,实际上定时器仅仅是计划代码在未来的某个时间执行,但是执行时机是不能保证的。因为在页面的生命周期中,不同时间可能有其它代码控制着 JavaScript 进程。

这里需要注意一下setInterval()函数,仅当没有该定时器的任何其他代码实例时,Js 引起才会将定时器代码添加到队列中。这样可以避免定时器代码可能在代码再次被添加到队列之前还没有完成执行,进而导致定时器代码连续运行好几次的问题。但是这也导致了另外的问题:(1)某些间隔会被跳过;(2)多个定时器的代码执行之间的间隔可能会比预期小。

假设某个click事件处理程序使用setInterval()设置了一个 200ms 间隔的重复定时器。如果这个事件处理程序花了 300ms 多的时间完成,同时定时器代码也花了差不多了的时间,就会同时出现跳过间隔切连续运行定时器代码的情况。

为了避免setInterval()的重复定时器的这两个缺点,我们可以使用如下模式的链式setTimeout(),代码一看就懂什么意思了。

setTimeout(function() {
    // 处理中
    setTimeout(arguements.callee, interval);
}, interval)

消息队列与事件循环

如下图所示,左边的栈存储的是同步任务,就是那些能立即执行、不耗时的任务,如变量和函数的初始化、事件的绑定等等那些不需要回调函数的操作都可归为这一类。

image.png

右边的堆用来存储声明的变量、对象。下面的队列就是消息队列,一旦某个异步任务有了响应就会被推入队列中。如用户的点击事件、浏览器收到服务的响应和setTimeout中待执行的事件,每个异步任务都和回调函数相关联。 JS引擎线程用来执行栈中的同步任务,当所有同步任务执行完毕后,栈被清空,然后读取消息队列中的一个待处理任务,并把相关回调函数压入栈中,单线程开始执行新的同步任务。

来看个例子:执行下面这段代码,执行后,在 5s 内点击两下,过一段时间(> 5s)后,再点击两下,整个过程的输出结果是什么?

setTimeout(function(){
    for(var i = 0; i < 100000000; i++){}
    console.log('timer a');
}, 0)
for(var j = 0; j < 5; j++){
    console.log(j);
}
setTimeout(function(){
    console.log('timer b');
}, 0)
function waitFiveSeconds(){
    var now = (new Date()).getTime();
    while(((new Date()).getTime() - now) < 5000){}
    console.log('finished waiting');
}
document.addEventListener('click', function(){
    console.log('click');
})
console.log('click begin');
waitFiveSeconds();

首先,先执行同步任务。其中waitFiveSeconds是耗时操作,持续执行长达 5s。然后,在 Js 引擎线程执行的时候,'timer a'对应的定时器产生的回调、'timer b'对应的定时器产生的回调和两次 click 对应的回调被先后放入消息队列。由于 Js 引擎线程空闲后,会先查看是否有事件可执行,接着再处理其他异步任务,最后,5s 后的两次 click 事件被放入消息队列,由于此时 Js 引擎线程空闲,便被立即执行了。因此会产生下面的输出顺序。

0
1
2
3
4
click begin
finished waiting
click
click
timer a
timer b
click
click
前端JavaScript