0%

JavaScript学习笔记(22)

《JavaScript高级程序设计》第二十二章,高级技巧。


函数

下面都是关于函数的特殊操作,用来解决一些问题,满足一些需求。

判断类型&判断原生

问题

JavaScript里用typeofinstanceof来判断类型的操作都不能完全得到正确的结果。比如Safari在对正则表达式使用typeof时会返回"function";比如在一个页面包含多个框架的情况下,对另一个框架中定义的数组使用instanceof判断是否为Array时会返回false(因为该数组与该Array构造函数不在同个全局作用域内)。

检测某个对象到底是原生对象还是开发人员自定义对象,也是一个需求点。比如之前很多人一直在使用Douglas Crockford的JSON库,里面有个全局JSON对象,后来浏览器开始原生支持JSON对象了,就很难判断到底是哪种情况。

解决

使用ObjecttoString()方法,会返回[object NativeConstructorName]格式的字符串,其中NativeConstructorName就是每个类内部的[[Class]]属性,也就是构造函数名。且构造函数名和全局作用域无关,所以能保证返回一致的值。

那我们就用Object.toString()解决一下上面的问题。

1
2
3
4
5
6
7
8
9
10
11
function isRegExp(value) {
return Object.prototype.toString.call(value) == "[object RegExp]";
}

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

const isNativeJSON =
window.JSON &&
Object.prototype.toString.call(JSON) == "[object JSON]";

作用域安全的构造函数

问题

不正确地使用构造函数时,会导致属性的错误赋予。举例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}

// 正确
let person = new Person("Nicholas", 29, "Software Engineer");

// 错误
let person = Person("Nicholas", 29, "Software Engineer");
alert(window.name); // "Nicholas"
alert(window.age); // 29
alert(window.job); // "Software Engineer"

这里用new的话,构造函数内的this会指向新创建的对象实例;不用new的话,该this对象在运行时绑定,直接调用Person()this会映射到全局对象window上。

解决

那我们就要先确定this的指向是否正确才行。使用下面这种方法,就可以锁定可以调用构造函数的环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person(name, age, job) {
if(this instanceof Person) {
this.name = name;
this.age = age;
this.job = job;
} else {
return new Person(name, age, job);
}
}

let person = new Person("Nicholas", 29, "Software Engineer");
alert(person.name); // "Nicholas"

let person = Person("Shelby", 34, "Ergonomist");
alert(person.name); // "Shelby"
alert(window.name); // ""

当使用构造函数窃取模式的继承且不使用原型链时,继承就很可能被破坏。举例如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Polygon(sides) {
if(this instanceof Polygon) {
this.sides = sides;
this.getArea = function() {
return 0;
};
} else {
return new Polygon(sides);
}
}

function Rectangle(width, height) {
Polygon.call(this, 2);
this.width = width;
this.height = height;
this.getArea = function() {
return this.width * this.height;
};
}

const rect = new Rectangle(5, 10);
alert(rect.sides); // undefined

这里新创建的Rectangle实例本应通过Polygon.call()来继承Polygonsides属性,但由于Polygon构造函数是作用域安全的,this对象并非Polygon实例,所以会创建并返回一个新的Polygon对象。这下,Rectangle构造函数中的this对象并没有增长,Polygon.call()返回的值也没用到,所以就没有sides属性。

如果构造函数窃取结合使用原型链或寄生组合则可以解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
function Polygon(sides) {
...
}

function Rectangle(width, height) {
...
}

Rectangle.prototype = new Polygon();

const rect = new Rectangle(5, 10);
alert(rect.sides); // 2

这里Rectangle实例也是Polygon实例,所以Polygon.call()会按照原意执行,最终为Rectangle实例添加sides属性。

惰性载入函数

问题

有一些函数的分支判断很复杂,且在某些条件下从来不会变,这时反复执行分支很耗时。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function createXHR() {
if(typeof XMLHttpRequest != "undefined") {
return new XMLHttpRequest();
} else if(typeof ActiveXObject != "undefined") {
if(typeof arguments.callee.activeXString != "string") {
const versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"];
for(let i = 0; i < versions.length; i++) {
try {
new ActiveXObject(versions[i]);
arguments.callee.activeXString = versions[i];
break;
} catch(ex) {}
}
}
return new ActiveXObject(arguments.callee.activeXString);
} else {
throw new Error("No XHR object available");
}
}

每次调用这个函数,都要对浏览器所支持的能力仔细检查,即使每次调用时分支的结果都不变(因为如果浏览器支持XHR,那它就一直支持了,这种测试就没必要了)。

解决

就是惰性载入,让if语句不必每次执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 方法一,在函数被调用时再处理函数
// 第一次调用时,该函数会被覆盖为合适的函数,之后的调用都不用执行分支了
function createXHR() {
if(typeof XMLHttpRequest != "undefined") {
createXHR = function() {
...
};
} else if(typeof ActiveXObject != "undefined") {
createXHR = function() {
...
}
} else {
createXHR = function() {
...
}
}
return createXHR();
}

// 方法二,在声明函数时指定适当的函数
// 在首次加载时会执行分支,而每次调用都不用执行分支
const createXHR = (function() {
if(typeof XMLHttpRequest != "undefined") {
return function() {
...
};
} else if(typeof ActiveXObject != "undefined") {
return function() {
...
}
} else {
return function() {
...
}
}
})();

函数绑定

问题

直接用例子吧。

1
2
3
4
5
6
7
8
9
const handler = {
message: "Event handled",
handleClick: function(event) {
alert(this.message);
}
}

const btn = document.getElementById("my-btn");
btn.addEventListener("click", handler.handleClick);

这个例子里,handler.handleClick()被分配为一个DOM按钮的事件处理程序,当按下按钮时,警告框里的字不是Event handled而是undefined。问题在于没有保存handler.handleClick()的「环境」,所以this指向的是DOM按钮而非handler

解决

可以用一个闭包来解决。

1
2
3
btn.addEventListener("click", function(event) {
handler.handleClick(event);
});

还可以将函数绑定到指定环境(可以用手写/浏览器支持的bind())。

1
2
3
4
5
6
7
8
9
10
11
function bind(fn, ctx) {
return function() {
return fn.apply(ctx, arguments);
};
}

// 手写的
btn.addEventListener("click", bind(handler.handleClick, handler));

// 原生的
btn.addEventListener("click", handler.handleClick.bind(handler));

这里,我们在bind()中创建了一个闭包,闭包使用apply()调用传入的函数,并给它传入了ctx对象和(内层函数的而非外层函数bind()的)参数,这样就可以在给定的环境中执行被传入的函数并给出所有参数。

函数柯里化

它用于创建已经设置好了一个或多个参数的函数,基本方法与函数绑定一样,即用闭包返回一个函数,区别在于这里返回的函数还需要设置一些传入的参数。

先来一个基础的版本理解一下。

1
2
3
4
5
6
7
8
9
10
function add(num1, num2) {
return num1 + num2;
}

function curriedAdd(num2) {
return add(5, num2);
}

alert(add(2, 3)); // 5
alert(curriedAdd(3)); // 8

这里的curriedAdd()本质上是在任何情况下第一个参数为5add()版本,并非柯里化的函数,不过很好地展示了其概念。

柯里化函数通常由以下步骤动态创建:调用另一个函数并为它传入要柯里化的函数和必要参数,返回一个函数。

1
2
3
4
5
6
7
8
function curry(fn) {
const args = Array.prototype.slice.call(arguments, 1);
return function() {
const innerArgs = Array.prototype.slice.call(arguments);
const finalArgs = args.concat(innerArgs);
return fn.apply(null, finalArgs);
};
}

解释一下这个curry(),它的主要工作是将被返回函数的参数进行排序。它的第一个参数是要进行柯里化的函数,args就是所有目前暂时可能接收到的参数(柯里化函数暂时存起来的参数,也就是外部的参数),innerArgs用来存放内部函数传入的所有参数。有了存放来自外部函数和内部函数的参数数组后,就可以把它们都传给实际执行的函数了。

1
2
3
4
5
let curriedAdd = curry(add, 5);
alert(curriedAdd(3)); // 8

let curriedAdd = curry(add, 5, 3);
alert(curriedAdd()); // 8

BTW,通过柯里化还可以构造出更复杂的bind()(之前那个手写的函数缺少后面的参数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function bind(fn, ctx) {
const args = Array.prototype.slice.call(arguments, 2);
return function() {
const innerArgs = Array.prototype.slice.call(arguments);
const finalArgs = args.concat(innerArgs);
return fn.apply(ctx, finalArgs);
}
}

const handler = {
message: "Event handled",
handleClick: function(name, event) {
alert(`${this.message} : ${name} : ${event.type}`);
}
}

// 手写的
btn.addEventHandler("click", bind(handler.handleClick, handler, "my-btn"));

// 原生的
btn.addEventHandler("click", handler.handleClick.bind(handler, "my-btn"));

防篡改对象

基础的防篡改方法,可以参考第6章手工设置每个属性来改变属性的行为:[[Configurable]][[Writable]][[Enumerable]][[Value]][[Get]][[Set]]

不可扩展对象

指不能添加属性的对象,可以用Object.preventExtensions()来设置,用Object.isExtensible()来判断对象是否可扩展。虽然不能给对象添加新成员,但已有的成员丝毫不受影响,依然可以修改删除。

1
2
3
4
5
6
7
8
9
10
11
12
// 默认情况下,所有对象都是可扩展的
let person = { name: "Nicholas" };
person.age = 29;
alert(person.age); // 29
alert(Object.isExtensible(person)); // true

// 不可扩展对象
let person = { name: "Nicholas" };
Object.preventExtensions(person);
person.age = 29;
alert(person.age); // undefined
alert(Object.isExtensible(person)); // false

密封对象

在不可扩展的基础上,将[[Configurable]]设置为false,即已有的属性和方法不能删除。可以用Object.seal()来设置,用Object.isSealed()来判断对象是否被密封。

1
2
3
4
5
6
7
8
9
10
11
let person = { name: "Nicholas" };
Object.seal(person);

person.age = 29;
alert(person.age); // undefined

delete person.name;
alert(person.name); // "Nicholas"

alert(Object.isExtensible(person)); // false
alert(Object.isSealed(person)); // true

冻结对象

在密封的基础上,将[[Writable]]设置为false,不过定义了[[Set]]函数的话访问器属性就是可写的。可以用Object.freeze()来设置,用Object.isFrozen()类判断对象是否被冻结。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let person = { name: "Nicholas" };
Object.freeze(person);

person.age = 29;
alert(person.age); // undefined

delete person.name;
alert(person.name); // "Nicholas"

person.name = "Greg";
alert(person.name); // "Greg"

alert(Object.isExtensible(person)); // false
alert(Object.isSealed(person)); // true
alert(Object.isFrozen(person)); // true

定时器

这里的重点是,JavaScript是运行在单线程环境中的,定时器仅仅只是在指定的时间后「将任务放进队列」,而不是在指定的事件后「执行任务」。如果在进程空闲时放入队列,那么任务队列就可以开始按序执行了;如果进程此刻在执行任务,那么任务队列里的所有任务都还要等待执行(然后等待执行也要区分是排在前面还是后面)。

假设如下情景:

1
2
3
4
5
6
const btn = document.getElementById("my-btn");
btn.onclick = function() {
setTimeout(function() {
document.getElementById("message").style.visibility = "visible";
}, 250);
};

这里给按钮设置了一个事件处理程序,它设置了一个250ms后调用的定时器。首先将onclick事件处理程序加入队列,该程序执行后才设置定时器,再过250ms指定的代码才被添加到队列中等待执行。

假设onclick事件处理程序执行了300ms,那么定时器的代码至少要在定时器设置之后的300ms后才会被执行,因为队列中所有代码都要等到JavaScript进程空闲之后才能执行。

上图中,尽管在255ms处添加了定时器,但这个时候还不能执行,因为onclick事件处理程序还在运行。定时器代码最早能执行的时机时300ms处,即onclick事件处理程序结束之后。

重复的定时器

问题

setInterval()只能确保定时器代码被规则地插入队列中,而不能保证规则地执行。

如定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行多次而没有任何停顿。不过JavaScript引擎也有自己的解决策略,那就是:当用setInterval()时,仅当没有该定时器的任何其它代码实例时,才将定时器代码添加到队列中。

这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。但也有其它问题:一是某些间隔会被跳过,二是多个定时器的代码执行之间的间隔可能比预期的小。

第一个定时器在205ms添加到队列,但因为onclick在执行的原因直到在300ms才能够执行;第二个定时器在405ms添加到队列,但因为第一个定时器还在执行,它暂时还得待在任务队列里;第三个定时器本来要在605ms添加到队列的,但因为队列中有定时器了,结果就是这个时间点上的定时器不会被添加到队列中(即缺点一);第一个定时器执行完后还是会紧接着取出任务队列里第二个定时器执行(即缺点二)。

解决

setTimeout()模拟。

1
2
3
4
5
6
setTimeout(function() {

// 处理中

setTimeout(arguments.callee, interval);
}, interval);

这样的链式调用,每次函数执行的时候都会创建一个新的定时器,新的定时器通过arguments.callee来设置相同的函数。这样做的好处时,在前一个定时器代码执行完之前,不会向队列插入新的定时器,不会有缺失的间隔;且可以保证下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续运行。

Yielding Processes

问题

运行在浏览器中的JavaScript都被分配了一个确定数量的资源,有限制长时间运行脚本的制约。如果代码运行超过指定时间/指定语句数量就不让它继续执行,弹出对话框问用户是否继续。

脚本长时间运行就是问题所在。

解决

运行时间长通常是因为:过长、过深嵌套的函数调用;进行大量处理的循环。这里主要解决循环的问题,所以我们先要消除无用的循环,看看什么情况下循环是不必要的。

当处理不需要同步完成,或者不需要按顺序完成,那么我们就可以使用数组分块(array chunking)。基本思路是,为要处理的项目创建一个队列,使用定时器取出下一个要处理的项目进行处理,接着再设置另一个定时器。

1
2
3
4
5
6
7
8
9
10
11
12
function chunk(array, process, context) {
setTimeout(function() {
// 取出下一个条目并处理
let item = array.lift();
process.call(context, item);

// 若还有条目,再设置另一个定时器
if(array.length > 0) {
setTimeout(arguments.callee, 100);
}
}, 100);
}

函数防抖/节流

有一些耗时的复杂操作会连续触发,这种时候可以使用定时器来解决问题,一般有两种解决方案:防抖(debounce)和节流(throttle)。二者区别可以参考这个可视化呈现。

防抖

任务频繁触发的情况下,只有任务触发的间隔超过指定间隔时候,任务才会执行。

比如判断用户名是否已注册,我既要及时出结果告诉用户目前字符串是否已注册,又不想每输入一个字符就判断一下,所以在用户输入一个字符后如果紧接着又输入字符,就暂时不判断。

比如给按钮加防抖来防止多次提交,适合多次事件一次响应的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 通过闭包来保存一个标记来保存setTimeout的返回值,每当用户输入的时候就把前一个seTimeout给clear,然后创建一个新的setTimeout,这样就能保证输入一个字符后,在其后一个间隔内再输入的话就clear掉计时器从而不会执行函数,在其后一个间隔内没输入的话就会执行函数。
const handle = (fn, delay) => {
let timeId
return function() {
if (timeId) clearTimeout(timeId)
timeId = setTimeout(() => {
fn.apply(this, arguments)
}, delay)
}
}

// 第一次立刻执行版本
const handle = (fn, delay, immediate) {
let timeId;
return function() {
if(timeId) clearTimeout(timeId);
if(immediate) {
let callNow = !timeId;
timeId = setTimeout(() => {
timeId = null;
}, delay);
if(callNow) fn.aply(this, arguments);
} else {
timeId = setTimeout(() => {
fn.apply(this, arguments);
}, delay);
}
}
}

节流

指定时间间隔内只能执行一次任务。

比如监听页面滚动的事件时,我又需要监听,但又不想让他时时刻刻都去计算判断是否位移了,所以想隔一段时间去执行事件处理程序。

比如游戏里的刷新率,适合大量事件按时间做平均分配的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 定时器
// 最初flag为true可以进入判断设置定时器,但马上要把flag设为false来使后续时间内不能进入判断,而在seTimeout执行的函数内部要执行函数并把flag设为true,这样才可以在这次执行函数时马上再进入判断设置定时器。也就是间隔内flag是false不能设定时器,间隔点flag是true能设置定时器。
const handle = (fn, interval) => {
let timeId = null;
return function() {
if (!timeId) {
timeId = setTimeout(() => {
fn.apply(this, arguments)
timeId = null
}, interval)
}
}
}

// 时间戳
const handle = (fn, interval) => {
let lastTime = 0
return function () {
let now = Date.now();
if (now - lastTime > interval) {
fn.apply(this, arguments)
lastTime = now
}
}
}

自定义事件

事件是JavaScript与浏览器交互的主要途径,是一种叫做观察者的设计模式。设计者模式分为主体和观察者,主体负责发布事件,观察者通过订阅这些事件来观察主体。涉及到DOM时,DOM元素就是主体,事件处理代码就是观察者。

这里我们自定义事件,创建一个管理事件的对象,其它对象监听那些事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 定义
function EventTarget() {
this.handlers = {};
}

EventTarget.prototype = {

constructor: EventTarget,

addHandler: function(type, handler) {
if(typeof this.handlers[type] == "undefined") {
this.handlers[type] = [];
}
this.handlers[type].push(handler);
},

fire: function(event) {
if(!event.target) {
event.target = this;
}
if(this.handlers[event.type] instanceof Array) {
let handlers = this.handlers[event.type];
handlers.forEach(handler => handler(event));
}
},

removeHandler: function(type, handler) {
if(this.handlers[type] instanceof Array) {
let handlers = this.handlers[type];
for(let i = 0; i < handlers.length; i++) {
if(handlers[i] === handler) {
break;
}
}
handlers.splice(i, 1);
}
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用
function handleMessage(event) {
alert(`Message recieved: ${event.message}`);
}

// 创建一个新对象
const target = new EventTarget();

// 添加事件处理程序
target.addHandler("message", handleMessage);

// 触发事件
target.fire({
type: "message",
message: "Hello world"
});

// 删除事件处理程序
target.removeHandler("message", handlerMessage);

// 再次触发事件,应没有处理程序
target.fire({
type: "message",
message: "Hello world"
});

拖放

拖放的基本概念很简单:创建一个绝对定位的元素,使其跟着鼠标指针移动。要注意的点是,要将指针和被移动元素保持相对静止,也就是维持在原始的相对位置上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 定义
const DragDrop = function() {

// 创建自定义事件
let dragdrop = new EventTarget();
dragging = null,
diffX = 0,
diffY = 0;

function handleEvent(event) {

let target = event.target;

// 确定事件类型
switch(event.type) {
case "mousedown":
if(target.className.indexOf("draggable") > -1) {
dragging = target;
diffX = event.clientX - target.offsetLeft;
diffY = event.clientY - target.offsetTop;
// 触发自定义事件
dragdrop.fire({
type: "dragstart",
target: dragging,
x: event.clientX,
y: event.clientY
});
}
break;

case "mousemove":
if(dragging !== null) {
dragging.style.left = `${event.clientX - diffX}px`;
dragging.style.top = `${event.clientY - diffY}px`;
// 触发自定义事件
dragdrop.fire({
type: "drag",
target: dragging,
x: event.clientX,
y: event.clientY
});
}
break;

case "mouseup":
// 触发自定义事件
dragdrop.fire({
type: "dragend",
target: dragging,
x: event.clientX,
y: event.clientY
});
dragging = null;
break;
}
}

dragdrop.enable = function() {
document.addEventListener("mousedown", handleEvent);
document.addEventListener("mousemove", handleEvent);
document.addEventListener("mouseup", handleEvent);
};

dragdrop.disable = function() {
document.removeEventListener("mousedown", handleEvent);
document.removeEventListener("mousemove", handleEvent);
document.removeEventListener("mouseup", handleEvent);
};

return dragdrop;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 使用
Dragdrop.addHandler("dragstart", function(event) {
const status = document.getElementById("status");
status.innerHTML = `Started dragging ${event.target.id}`;
});

Dragdrop.addHandler("drag", function(event) {
const status = document.getElementById("status");
status.innerHTML += `<br/> Dragged ${event.target.id} to (${event.x}, ${event.y})`;
});

Dragdrop.addHandler("dragend", function(event) {
const status = document.getElementById("status");
status.innerHTML += `<br/> Dropped ${event.target.id} at (${event.x}, ${event.y})`;
});