Fork me on GitHub

JS DOM——ParentNode、ChildNode接口

节点对象除了继承Node接口以外,还会继承其他接口。ParentNode接口表示当前节点是一个父节点,提供一些处理子节点的方法。ChildNode接口表示当前节点是一个子节点,提供一些相关方法。

ParentNode 接口

如果当前节点是父节点,就会继承ParentNode接口。由于只有元素节点(element)、文档节点(document)和文档片段节点(documentFragment)拥有子节点,因此只有这三类节点会继承ParentNode接口。

ParentNode.children

children属性返回一个HTMLCollection实例,成员是当前节点的所有元素子节点。该属性只读。

下面是遍历某个节点的所有元素子节点的示例。

1
2
3
for (var i = 0; i < el.children.length; i++) {
// ...
}

注意,children属性只包括元素子节点,不包括其他类型的子节点(比如文本子节点)。如果没有元素类型的子节点,返回值HTMLCollection实例的length属性为0

另外,HTMLCollection是动态集合,会实时反映 DOM 的任何变化。

ParentNode.firstElementChild

firstElementChild属性返回当前节点的第一个元素子节点。如果没有任何元素子节点,则返回null

1
document.firstElementChild.nodeName // "HTML"

上面代码中,document节点的第一个元素子节点是<HTML>

ParentNode.lastElementChild

lastElementChild属性返回当前节点的最后一个元素子节点,如果不存在任何元素子节点,则返回null

1
document.lastElementChild.nodeName // "HTML"

上面代码中,document节点的最后一个元素子节点是<HTML>(因为document只包含这一个元素子节点)。

ParentNode.childElementCount

childElementCount属性返回一个整数,表示当前节点的所有元素子节点的数目。如果不包含任何元素子节点,则返回0

1
document.body.childElementCount // 13

ParentNode.append(),ParentNode.prepend()

append方法为当前节点追加一个或多个子节点,位置是最后一个元素子节点的后面。

该方法不仅可以添加元素子节点,还可以添加文本子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var parent = document.body;

// 添加元素子节点
var p = document.createElement('p');
parent.append(p);

// 添加文本子节点
parent.append('Hello');

// 添加多个元素子节点
var p1 = document.createElement('p');
var p2 = document.createElement('p');
parent.append(p1, p2);

// 添加元素子节点和文本子节点
var p = document.createElement('p');
parent.append('Hello', p);

注意,该方法没有返回值。

prepend方法为当前节点追加一个或多个子节点,位置是第一个元素子节点的前面。它的用法与append方法完全一致,也是没有返回值。

ChildNode 接口

如果一个节点有父节点,那么该节点就继承了ChildNode接口。

ChildNode.remove()

remove方法用于从父节点移除当前节点。

1
el.remove()

上面代码在 DOM 里面移除了el节点。

ChildNode.before(),ChildNode.after()

before方法用于在当前节点的前面,插入一个或多个同级节点。两者拥有相同的父节点。

注意,该方法不仅可以插入元素节点,还可以插入文本节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var p = document.createElement('p');
var p1 = document.createElement('p');

// 插入元素节点
el.before(p);

// 插入文本节点
el.before('Hello');

// 插入多个元素节点
el.before(p, p1);

// 插入元素节点和文本节点
el.before(p, 'Hello');

after方法用于在当前节点的后面,插入一个或多个同级节点,两者拥有相同的父节点。用法与before方法完全相同。

ChildNode.replaceWith()

replaceWith方法使用参数节点,替换当前节点。参数可以是元素节点,也可以是文本节点。

1
2
var span = document.createElement('span');
el.replaceWith(span);

上面代码中,el节点将被span节点替换。

JS DOM——NodeList、HTMLCollection接口

节点都是单个对象,有时需要一种数据结构,能够容纳多个节点。DOM 提供两种节点集合,用于容纳多个节点:NodeListHTMLCollection

这两种集合都属于接口规范。许多 DOM 属性和方法,返回的结果是NodeList实例或HTMLCollection实例。主要区别是,NodeList可以包含各种类型的节点,HTMLCollection只能包含 HTML 元素节点。

NodeList 接口

概述

NodeList实例是一个类似数组的对象,它的成员是节点对象。通过以下方法可以得到NodeList实例。

  • Node.childNodes
  • document.querySelectorAll()等节点搜索方法
1
document.body.childNodes instanceof NodeList // true

NodeList实例很像数组,可以使用length属性和forEach方法。但是,它不是数组,不能使用poppush之类数组特有的方法。

1
2
3
4
5
6
var children = document.body.childNodes;

Array.isArray(children) // false

children.length // 34
children.forEach(console.log)

如果NodeList实例要使用数组方法,可以将其转为真正的数组。

1
2
var children = document.body.childNodes;
var nodeArr = Array.prototype.slice.call(children);

除了使用forEach方法遍历NodeList实例,还可以使用for循环。

1
2
3
4
5
var children = document.body.childNodes;

for (var i = 0; i < children.length; i++) {
var item = children[i];
}

注意,NodeList实例可能是动态集合,也可能是静态集合。所谓动态集合就是一个活的集合,DOM 删除或新增一个相关节点,都会立刻反映在NodeList实例。目前,只有Node.childNodes返回的是一个动态集合,其他的NodeList都是静态集合。

1
2
3
4
var children = document.body.childNodes;
children.length // 18
document.body.appendChild(document.createElement('p'));
children.length // 19

上面代码中,文档增加一个子节点,NodeList实例childrenlength属性就增加了1。

NodeList.prototype.length

length属性返回NodeList实例包含的节点数量。

1
document.querySelectorAll('xxx').length // 0

上面代码中,document.querySelectorAll返回一个NodeList集合。对于那些不存在的 HTML 标签,length属性返回0

NodeList.prototype.forEach()

forEach方法用于遍历NodeList的所有成员。它接受一个回调函数作为参数,每一轮遍历就执行一次这个回调函数,用法与数组实例的forEach方法完全一致。

1
2
3
4
var children = document.body.childNodes;
children.forEach(function f(item, i, list) {
// ...
}, this);

上面代码中,回调函数f的三个参数依次是当前成员、位置和当前NodeList实例。forEach方法的第二个参数,用于绑定回调函数内部的this,该参数可省略。

NodeList.prototype.item()

item方法接受一个整数值作为参数,表示成员的位置,返回该位置上的成员。

1
document.body.childNodes.item(0)

上面代码中,item(0)返回第一个成员。

如果参数值大于实际长度,或者索引不合法(比如负数),item方法返回null。如果省略参数,item方法会报错。

所有类似数组的对象,都可以使用方括号运算符取出成员。一般情况下,都是使用方括号运算符,而不使用item方法。

1
document.body.childNodes[0]

NodeList.prototype.keys(),NodeList.prototype.values(),NodeList.prototype.entries()

这三个方法都返回一个 ES6 的遍历器对象,可以通过for...of循环遍历获取每一个成员的信息。区别在于,keys()返回键名的遍历器,values()返回键值的遍历器,entries()返回的遍历器同时包含键名和键值的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var children = document.body.childNodes;

for (var key of children.keys()) {
console.log(key);
}
// 0
// 1
// 2
// ...

for (var value of children.values()) {
console.log(value);
}
// #text
// <script>
// ...

for (var entry of children.entries()) {
console.log(entry);
}
// Array [ 0, #text ]
// Array [ 1, <script> ]
// ...

HTMLCollection 接口

概述

HTMLCollection是一个节点对象的集合,只能包含元素节点(element),不能包含其他类型的节点。它的返回值是一个类似数组的对象,但是与NodeList接口不同,HTMLCollection没有forEach方法,只能使用for循环遍历。

返回HTMLCollection实例的,主要是一些Document对象的集合属性,比如document.linksdocument.formsdocument.images等。

1
document.links instanceof HTMLCollection // true

HTMLCollection实例都是动态集合,节点的变化会实时反映在集合中。

如果元素节点有idname属性,那么HTMLCollection实例上面,可以使用id属性或name属性引用该节点元素。如果没有对应的节点,则返回null

1
2
3
4
5
// HTML 代码如下
// <img id="pic" src="http://example.com/foo.jpg">

var pic = document.getElementById('pic');
document.images.pic === pic // true

上面代码中,document.images是一个HTMLCollection实例,可以通过<img>元素的id属性值,从HTMLCollection实例上取到这个元素。

HTMLCollection.prototype.length

length属性返回HTMLCollection实例包含的成员数量。

1
document.links.length // 18

HTMLCollection.prototype.item()

item方法接受一个整数值作为参数,表示成员的位置,返回该位置上的成员。

1
2
var c = document.images;
var img0 = c.item(0);

上面代码中,item(0)表示返回0号位置的成员。由于方括号运算符也具有同样作用,而且使用更方便,所以一般情况下,总是使用方括号运算符。

如果参数值超出成员数量或者不合法(比如小于0),那么item方法返回null

HTMLCollection.prototype.namedItem()

namedItem方法的参数是一个字符串,表示id属性或name属性的值,返回对应的元素节点。如果没有对应的节点,则返回null

1
2
3
4
5
// HTML 代码如下
// <img id="pic" src="http://example.com/foo.jpg">

var pic = document.getElementById('pic');
document.images.namedItem('pic') === pic // true

JS DOM——Node接口

DOM 概述

DOM

DOM 是 JavaScript 操作网页的接口,全称为“文档对象模型”(Document Object Model)。它的作用是将网页转为一个 JavaScript 对象,从而可以用脚本进行各种操作(比如增删内容)。

浏览器会根据 DOM 模型,将结构化文档(比如 HTML 和 XML)解析成一系列的节点,再由这些节点组成一个树状结构(DOM Tree)。所有的节点和最终的树状结构,都有规范的对外接口。

DOM 只是一个接口规范,可以用各种语言实现。所以严格地说,DOM 不是 JavaScript 语法的一部分,但是 DOM 操作是 JavaScript 最常见的任务,离开了 DOM,JavaScript 就无法控制网页。另一方面,JavaScript 也是最常用于 DOM 操作的语言。

节点

DOM 的最小组成单位叫做节点(node)。文档的树形结构(DOM 树),就是由各种不同类型的节点组成。每个节点可以看作是文档树的一片叶子。

节点的类型有七种。

  • Document:整个文档树的顶层节点
  • DocumentTypedoctype标签(比如<!DOCTYPE html>
  • Element:网页的各种HTML标签(比如<body><a>等)
  • Attribute:网页元素的属性(比如class="right"
  • Text:标签之间或标签包含的文本
  • Comment:注释
  • DocumentFragment:文档的片段

浏览器提供一个原生的节点对象Node,上面这七种节点都继承了Node,因此具有一些共同的属性和方法。

节点树

一个文档的所有节点,按照所在的层级,可以抽象成一种树状结构。这种树状结构就是 DOM 树。它有一个顶层节点,下一层都是顶层节点的子节点,然后子节点又有自己的子节点,就这样层层衍生出一个金字塔结构,倒过来就像一棵树。

浏览器原生提供document节点,代表整个文档。
文档的第一层有两个节点,第一个是文档类型节点(<!doctype html>),第二个是 HTML 网页的顶层容器标签<html>。后者构成了树结构的根节点(root node),其他 HTML 标签节点都是它的下级节点。

除了根节点,其他节点都有三种层级关系。

  • 父节点关系(parentNode):直接的那个上级节点
  • 子节点关系(childNodes):直接的下级节点
  • 同级节点关系(sibling):拥有同一个父节点的节点

DOM提供操作接口,用来获取这三种关系的节点。比如,子节点接口包括firstChild(第一个子节点)和lastChild(最后一个子节点)等属性,同级节点接口包括nextSibling(紧邻在后的那个同级节点)和previousSibling(紧邻在前的那个同级节点)属性。

Node 接口

所有DOM节点对象都继承了Node接口,拥有一些共同的属性和方法。这是DOM操作的基础。

属性

Node.prototype.nodeType

nodeType属性返回一个整数值,表示节点的类型。

1
document.nodeType // 9

Node对象定义了几个常量,对应这些类型值。

1
document.nodeType === Node.DOCUMENT_NODE // true

上面代码中,文档节点的nodeType属性等于常量Node.DOCUMENT_NODE

不同节点的nodeType属性值和对应的常量如下。

  • 文档节点(document):9,对应常量Node.DOCUMENT_NODE
  • 元素节点(element):1,对应常量Node.ELEMENT_NODE
  • 属性节点(attr):2,对应常量Node.ATTRIBUTE_NODE
  • 文本节点(text):3,对应常量Node.TEXT_NODE
  • 文档片断节点(DocumentFragment):11,对应常量Node.DOCUMENT_FRAGMENT_NODE
  • 文档类型节点(DocumentType):10,对应常量Node.DOCUMENT_TYPE_NODE
  • 注释节点(Comment):8,对应常量Node.COMMENT_NODE

确定节点类型时,使用nodeType属性是常用方法。

1
2
3
4
var node = document.documentElement.firstChild;
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('该节点是元素节点');
}

Node.prototype.nodeName

nodeName属性返回节点的名称。

1
2
3
// <div id="d1">hello world</div>
var div = document.getElementById('d1');
div.nodeName // "DIV"

上面代码中,元素节点<div>nodeName属性就是大写的标签名DIV

不同节点的nodeName属性值如下。

  • 文档节点(document):#document
  • 元素节点(element):大写的标签名
  • 属性节点(attr):属性的名称
  • 文本节点(text):#text
  • 文档片断节点(DocumentFragment):#document-fragment
  • 文档类型节点(DocumentType):文档的类型
  • 注释节点(Comment):#comment

Node.prototype.nodeValue

nodeValue属性返回一个字符串,表示当前节点本身的文本值,该属性可读写。

只有文本节点(text)、注释节点(comment)和属性节点(attr)有文本值,因此这三类节点的nodeValue可以返回结果,其他类型的节点一律返回null。同样的,也只有这三类节点可以设置nodeValue属性的值,其他类型的节点设置无效。

1
2
3
4
5
// HTML 代码如下
// <div id="d1">hello world</div>
var div = document.getElementById('d1');
div.nodeValue // null
div.firstChild.nodeValue // "hello world"

上面代码中,div是元素节点,nodeValue属性返回nulldiv.firstChild是文本节点,所以可以返回文本值。

Node.prototype.textContent

textContent属性返回当前节点和它的所有后代节点的文本内容。

1
2
3
4
5
// HTML 代码为
// <div id="divA">This is <span>some</span> text</div>

document.getElementById('divA').textContent
// This is some text

textContent属性自动忽略当前节点内部的 HTML 标签,返回所有文本内容。

该属性是可读写的,设置该属性的值,会用一个新的文本节点,替换所有原来的子节点。它还有一个好处,就是自动对 HTML 标签转义。这很适合用于用户提供的内容。

1
document.getElementById('foo').textContent = '<p>GoodBye!</p>';

上面代码在插入文本时,会将<p>标签解释为文本,而不会当作标签处理。

对于文本节点(text)、注释节点(comment)和属性节点(attr),textContent属性的值与nodeValue属性相同。对于其他类型的节点,该属性会将每个子节点(不包括注释节点)的内容连接在一起返回。如果一个节点没有子节点,则返回空字符串。

文档节点(document)和文档类型节点(doctype)的textContent属性为null。如果要读取整个文档的内容,可以使用document.documentElement.textContent

Node.prototype.baseURI

baseURI属性返回一个字符串,表示当前网页的绝对路径。浏览器根据这个属性,计算网页上的相对路径的 URL。该属性为只读。

1
2
3
4
// 当前网页的网址为
// http://www.example.com/index.html
document.baseURI
// "http://www.example.com/index.html"

如果无法读到网页的 URL,baseURI属性返回null

该属性的值一般由当前网址的 URL(即window.location属性)决定,但是可以使用 HTML 的<base>标签,改变该属性的值。

1
<base href="http://www.example.com/page.html">

设置了以后,baseURI属性就返回<base>标签设置的值。

Node.prototype.ownerDocument

Node.ownerDocument属性返回当前节点所在的顶层文档对象,即document对象。

1
2
var d = p.ownerDocument;
d === document // true

document对象本身的ownerDocument属性,返回null

Node.prototype.nextSibling

Node.nextSibling属性返回紧跟在当前节点后面的第一个同级节点。如果当前节点后面没有同级节点,则返回null

1
2
3
4
5
6
// HTML 代码如下
// <div id="d1">hello</div><div id="d2">world</div>
var d1 = document.getElementById('d1');
var d2 = document.getElementById('d2');

d1.nextSibling === d2 // true

注意,该属性还包括文本节点和注释节点。因此如果当前节点后面有空格,该属性会返回一个文本节点,内容为空格。

nextSibling属性可以用来遍历所有子节点。

1
2
3
4
5
6
var el = document.getElementById('div1').firstChild;

while (el !== null) {
console.log(el.nodeName);
el = el.nextSibling;
}

上面代码遍历div1节点的所有子节点。

Node.prototype.previousSibling

previousSibling属性返回当前节点前面的、距离最近的一个同级节点。如果当前节点前面没有同级节点,则返回null

1
2
3
4
5
// <div id="d1">hello</div><div id="d2">world</div>
var d1 = document.getElementById('d1');
var d2 = document.getElementById('d2');

d2.previousSibling === d1 // true

注意,该属性还包括文本节点和注释节点。因此如果当前节点前面有空格,该属性会返回一个文本节点,内容为空格。

Node.prototype.parentNode

parentNode属性返回当前节点的父节点。对于一个节点来说,它的父节点只可能是三种类型:元素节点(element)、文档节点(document)和文档片段节点(documentfragment)。

1
2
3
if (node.parentNode) {
node.parentNode.removeChild(node);
}

上面代码中,通过node.parentNode属性将node节点从文档里面移除。

文档节点(document)和文档片段节点(documentfragment)的父节点都是null。另外,对于那些生成后还没插入 DOM 树的节点,父节点也是null

Node.prototype.parentElement

parentElement属性返回当前节点的父元素节点。如果当前节点没有父节点,或者父节点类型不是元素节点,则返回null

1
2
3
if (node.parentElement) {
node.parentElement.style.color = 'red';
}

由于父节点只可能是三种类型:元素节点、文档节点(document)和文档片段节点(documentfragment)。parentElement属性相当于把后两种父节点都排除了。

Node.prototype.firstChild,Node.prototype.lastChild

firstChild属性返回当前节点的第一个子节点,如果当前节点没有子节点,则返回null

1
2
3
// <p id="p1"><span>First span</span></p>
var p1 = document.getElementById('p1');
p1.firstChild.nodeName // "SPAN"

注意,firstChild返回的除了元素节点,还可能是文本节点或注释节点。

1
2
3
4
5
// <p id="p1">
// <span>First span</span>
// </p>
var p1 = document.getElementById('p1');
p1.firstChild.nodeName // "#text"

上面代码中,p元素与span元素之间有空白字符,这导致firstChild返回的是文本节点。

lastChild属性返回当前节点的最后一个子节点,如果当前节点没有子节点,则返回null。用法与firstChild属性相同。

Node.prototype.childNodes

childNodes属性返回一个类似数组的对象(NodeList集合),成员包括当前节点的所有子节点。

1
var children = document.querySelector('ul').childNodes;

使用该属性,可以遍历某个节点的所有子节点。

1
2
3
4
5
6
var div = document.getElementById('div1');
var children = div.childNodes;

for (var i = 0; i < children.length; i++) {
// ...
}

文档节点(document)就有两个子节点:文档类型节点(docType)和HTML根元素节点。

1
2
3
4
5
6
var children = document.childNodes;
for (var i = 0; i < children.length; i++) {
console.log(children[i].nodeType);
}
// 10
// 1

上面代码中,文档节点的第一个子节点的类型是10(即文档类型节点),第二个子节点的类型是1(即元素节点)。

注意,除了元素节点,childNodes属性的返回值还包括文本节点和注释节点。如果当前节点不包括任何子节点,则返回一个空的NodeList集合。由于NodeList对象是一个动态集合,一旦子节点发生变化,立刻会反映在返回结果之中。

Node.prototype.isConnected

isConnected属性返回一个布尔值,表示当前节点是否在文档之中。

1
2
3
4
5
var test = document.createElement('p');
test.isConnected // false

document.body.appendChild(test);
test.isConnected // true

方法

Node.prototype.appendChild()

appendChild()方法接受一个节点对象作为参数,将其作为最后一个子节点,插入当前节点。该方法的返回值就是插入文档的子节点。

1
2
var p = document.createElement('p');
document.body.appendChild(p);

上面代码新建一个<p>节点,将其插入document.body的尾部。

如果参数节点是 DOM 已经存在的节点,appendChild()方法会将其从原来的位置,移动到新位置。

1
2
var div = document.getElementById('myDiv');
document.body.appendChild(div);

上面代码中,插入的是一个已经存在的节点myDiv,结果就是该节点会从原来的位置,移动到document.body的尾部。

如果appendChild()方法的参数是DocumentFragment节点,那么插入的是DocumentFragment的所有子节点,而不是DocumentFragment节点本身。返回值是一个空的DocumentFragment节点。

Node.prototype.hasChildNodes()

hasChildNodes方法返回一个布尔值,表示当前节点是否有子节点。

1
2
3
4
5
var foo = document.getElementById('foo');

if (foo.hasChildNodes()) {
foo.removeChild(foo.childNodes[0]);
}

注意,子节点包括所有类型的节点,并不仅仅是元素节点。哪怕节点只包含一个空格,hasChildNodes方法也会返回true

判断一个节点有没有子节点,有许多种方法,下面是其中的三种。

  • node.hasChildNodes()
  • node.firstChild !== null
  • node.childNodes && node.childNodes.length > 0

hasChildNodes方法结合firstChild属性和nextSibling属性,可以遍历当前节点的所有后代节点。

1
2
3
4
5
6
7
8
9
10
11
function DOMComb(parent, callback) {
if (parent.hasChildNodes()) {
for (var node = parent.firstChild; node; node = node.nextSibling) {
DOMComb(node, callback);
}
}
callback(parent);
}

// 用法
DOMComb(document.body, console.log)

上面代码中,DOMComb函数的第一个参数是某个指定的节点,第二个参数是回调函数。这个回调函数会依次作用于指定节点,以及指定节点的所有后代节点。

Node.prototype.cloneNode()

cloneNode方法用于克隆一个节点。它接受一个布尔值作为参数,表示是否同时克隆子节点。它的返回值是一个克隆出来的新节点。

1
var cloneUL = document.querySelector('ul').cloneNode(true);

该方法有一些使用注意点。

  1. 克隆一个节点,会拷贝该节点的所有属性,但是会丧失addEventListener方法和on-属性(即node.onclick = fn),添加在这个节点上的事件回调函数。
  2. 该方法返回的节点不在文档之中,即没有任何父节点,必须使用诸如Node.appendChild这样的方法添加到文档之中。
  3. 克隆一个节点之后,DOM 有可能出现两个有相同id属性(即id="xxx")的网页元素,这时应该修改其中一个元素的id属性。如果原节点有name属性,可能也需要修改。

Node.prototype.insertBefore()

insertBefore方法用于将某个节点插入父节点内部的指定位置。

1
var insertedNode = parentNode.insertBefore(newNode, referenceNode);

insertBefore方法接受两个参数,第一个参数是所要插入的节点newNode,第二个参数是父节点parentNode内部的一个子节点referenceNodenewNode将插在referenceNode这个子节点的前面。返回值是插入的新节点newNode

1
2
var p = document.createElement('p');
document.body.insertBefore(p, document.body.firstChild);

如果insertBefore方法的第二个参数为null,则新节点将插在当前节点内部的最后位置,即变成最后一个子节点。

1
2
var p = document.createElement('p');
document.body.insertBefore(p, null);

上面代码中,p将成为document.body的最后一个子节点。这也说明insertBefore的第二个参数不能省略。

注意,如果所要插入的节点是当前 DOM 现有的节点,则该节点将从原有的位置移除,插入新的位置。

由于不存在insertAfter方法,如果新节点要插在父节点的某个子节点后面,可以用insertBefore方法结合nextSibling属性模拟。

1
parent.insertBefore(s1, s2.nextSibling);

上面代码中,parent是父节点,s1是一个全新的节点,s2是可以将s1节点,插在s2节点的后面。如果s2是当前节点的最后一个子节点,则s2.nextSibling返回null,这时s1节点会插在当前节点的最后,变成当前节点的最后一个子节点,等于紧跟在s2的后面。

如果要插入的节点是DocumentFragment类型,那么插入的将是DocumentFragment的所有子节点,而不是DocumentFragment节点本身。返回值将是一个空的DocumentFragment节点。

Node.prototype.removeChild()

removeChild方法接受一个子节点作为参数,用于从当前节点移除该子节点。返回值是移除的子节点。

1
2
var divA = document.getElementById('A');
divA.parentNode.removeChild(divA);

上面代码移除了divA节点。注意,这个方法是在divA的父节点上调用的,不是在divA上调用的。

下面是如何移除当前节点的所有子节点。

1
2
3
4
var element = document.getElementById('top');
while (element.firstChild) {
element.removeChild(element.firstChild);
}

被移除的节点依然存在于内存之中,但不再是 DOM 的一部分。所以,一个节点移除以后,依然可以使用它,比如插入到另一个节点下面。

如果参数节点不是当前节点的子节点,removeChild方法将报错。

Node.prototype.replaceChild()

replaceChild方法用于将一个新的节点,替换当前节点的某一个子节点。

1
var replacedNode = parentNode.replaceChild(newChild, oldChild);

上面代码中,replaceChild方法接受两个参数,第一个参数newChild是用来替换的新节点,第二个参数oldChild是将要替换走的子节点。返回值是替换走的那个节点oldChild

1
2
3
4
var divA = document.getElementById('divA');
var newSpan = document.createElement('span');
newSpan.textContent = 'Hello World!';
divA.parentNode.replaceChild(newSpan, divA);

Node.prototype.contains()

contains方法返回一个布尔值,表示参数节点是否满足以下三个条件之一。

  • 参数节点为当前节点。
  • 参数节点为当前节点的子节点。
  • 参数节点为当前节点的后代节点。
1
document.body.contains(node)

上面代码检查参数节点node,是否包含在当前文档之中。

注意,当前节点传入contains方法,返回true

1
nodeA.contains(nodeA) // true

Node.prototype.compareDocumentPosition()

compareDocumentPosition方法的用法,与contains方法完全一致,返回一个六个比特位的二进制值,表示参数节点与当前节点的关系。

二进制值 十进制值 含义
000000 0 两个节点相同
000001 1 两个节点不在同一个文档(即有一个节点不在当前文档)
000010 2 参数节点在当前节点的前面
000100 4 参数节点在当前节点的后面
001000 8 参数节点包含当前节点
010000 16 当前节点包含参数节点
100000 32 浏览器内部使用
1
2
3
4
5
6
7
8
9
10
// HTML 代码如下
// <div id="mydiv">
// <form><input id="test" /></form>
// </div>

var div = document.getElementById('mydiv');
var input = document.getElementById('test');

div.compareDocumentPosition(input) // 20
input.compareDocumentPosition(div) // 10

上面代码中,节点div包含节点input(二进制010000),而且节点input在节点div的后面(二进制000100),所以第一个compareDocumentPosition方法返回20(二进制010100,即010000 + 000100),第二个compareDocumentPosition方法返回10(二进制001010)。

由于compareDocumentPosition返回值的含义,定义在每一个比特位上,所以如果要检查某一种特定的含义,就需要使用比特位运算符。

1
2
3
4
5
6
7
var head = document.head;
var body = document.body;
if (head.compareDocumentPosition(body) & 4) {
console.log('文档结构正确');
} else {
console.log('<body> 不能在 <head> 前面');
}

上面代码中,compareDocumentPosition的返回值与4(又称掩码)进行与运算(&),得到一个布尔值,表示<head>是否在<body>前面。

Node.prototype.isEqualNode(),Node.prototype.isSameNode()

isEqualNode方法返回一个布尔值,用于检查两个节点是否相等。所谓相等的节点,指的是两个节点的类型相同、属性相同、子节点相同。

1
2
3
4
var p1 = document.createElement('p');
var p2 = document.createElement('p');

p1.isEqualNode(p2) // true

isSameNode方法返回一个布尔值,表示两个节点是否为同一个节点。

1
2
3
4
5
var p1 = document.createElement('p');
var p2 = document.createElement('p');

p1.isSameNode(p2) // false
p1.isSameNode(p1) // true

Node.prototype.normalize()

normalize方法用于清理当前节点内部的所有文本节点(text)。它会去除空的文本节点,并且将毗邻的文本节点合并成一个,也就是说不存在空的文本节点,以及毗邻的文本节点。

1
2
3
4
5
6
7
8
var wrapper = document.createElement('div');

wrapper.appendChild(document.createTextNode('Part 1 '));
wrapper.appendChild(document.createTextNode('Part 2 '));

wrapper.childNodes.length // 2
wrapper.normalize();
wrapper.childNodes.length // 1

上面代码使用normalize方法之前,wrapper节点有两个毗邻的文本子节点。使用normalize方法之后,两个文本子节点被合并成一个。

该方法是Text.splitText的逆方法。

Node.prototype.getRootNode()

getRootNode()方法返回当前节点所在文档的根节点document,与ownerDocument属性的作用相同。

1
2
3
4
document.body.firstChild.getRootNode() === document
// true
document.body.firstChild.getRootNode() === document.body.firstChild.ownerDocument
// true

该方法可用于document节点自身,这一点与document.ownerDocument不同。

1
2
document.getRootNode() // document
document.ownerDocument // null

JS异步操作——定时器

JavaScript 提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()setInterval()这两个函数来完成。它们向任务队列添加定时任务。

setTimeout()

setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。

1
var timerId = setTimeout(func|code, delay);

上面代码中,setTimeout函数接受两个参数,第一个参数func|code是将要推迟执行的函数名或者一段代码,第二个参数delay是推迟执行的毫秒数。

1
2
3
4
5
6
console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
// 1
// 3
// 2

上面代码会先输出1和3,然后等待1000毫秒再输出2。注意,console.log(2)必须以字符串的形式,作为setTimeout的参数。

如果推迟执行的是函数,就直接将函数名,作为setTimeout的参数。

1
2
3
4
5
function f() {
console.log(2);
}

setTimeout(f, 1000);

setTimeout的第二个参数如果省略,则默认为0。

1
2
3
setTimeout(f)
// 等同于
setTimeout(f, 0)

除了前两个参数,setTimeout还允许更多的参数。它们将依次传入推迟执行的函数(回调函数)。

1
2
3
setTimeout(function (a,b) {
console.log(a + b);
}, 1000, 1, 1);

上面代码中,setTimeout共有4个参数。最后那两个参数,将在1000毫秒之后回调函数执行时,作为回调函数的参数。

还有一个需要注意的地方,如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象。

1
2
3
4
5
6
7
8
9
10
var x = 1;

var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};

setTimeout(obj.y, 1000) // 1

上面代码输出的是1,而不是2。因为当obj.y在1000毫秒后运行时,this所指向的已经不是obj了,而是全局环境。

为了防止出现这个问题,一种解决方法是将obj.y放入一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
var x = 1;

var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};

setTimeout(function () {
obj.y();
}, 1000);
// 2

上面代码中,obj.y放在一个匿名函数之中,这使得obj.yobj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。

另一种解决方法是,使用bind方法,将obj.y这个方法绑定在obj上面。

1
2
3
4
5
6
7
8
9
10
var x = 1;

var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};

setTimeout(obj.y.bind(obj), 1000) // 2

setInterval()

setInterval函数的用法与setTimeout完全一致,区别仅仅在于setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。

1
2
3
4
var i = 1
var timer = setInterval(function() {
console.log(2);
}, 1000)

上面代码中,每隔1000毫秒就输出一个2,会无限运行下去,直到关闭当前窗口。

setTimeout一样,除了前两个参数,setInterval方法还可以接受更多的参数,它们会传入回调函数。

下面是一个通过setInterval方法实现网页动画的例子。

1
2
3
4
5
6
7
8
9
10
var div = document.getElementById('someDiv');
var opacity = 1;
var fader = setInterval(function() {
opacity -= 0.1;
if (opacity >= 0) {
div.style.opacity = opacity;
} else {
clearInterval(fader);
}
}, 100);

上面代码每隔100毫秒,设置一次div元素的透明度,直至其完全透明为止。

setInterval的一个常见用途是实现轮询。下面是一个轮询 URL 的 Hash 值是否发生变化的例子。

1
2
3
4
5
6
var hash = window.location.hash;
var hashWatcher = setInterval(function() {
if (window.location.hash != hash) {
updatePage();
}
}, 1000);

setInterval指定的是“开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。比如,setInterval指定每 100ms 执行一次,每次执行需要 5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。

为了确保两次执行之间有固定的间隔,可以不用setInterval,而是每次执行结束后,使用setTimeout指定下一次执行的具体时间。

1
2
3
4
5
var i = 1;
var timer = setTimeout(function f() {
// ...
timer = setTimeout(f, 2000);
}, 2000);

上面代码可以确保,下一次执行总是在本次执行结束之后的2000毫秒开始。

clearTimeout(),clearInterval()

setTimeoutsetInterval函数,都返回一个整数值,表示计数器编号。将该整数传入clearTimeoutclearInterval函数,就可以取消对应的定时器。

1
2
3
4
5
var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);

clearTimeout(id1);
clearInterval(id2);

setTimeoutsetInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。

1
2
3
4
function f() {}
setTimeout(f, 1000) // 10
setTimeout(f, 1000) // 11
setTimeout(f, 1000) // 12

上面代码中,连续调用三次setTimeout,返回值都比上一次大了1。

利用这一点,可以写一个函数,取消当前所有的setTimeout定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function() {
// 每轮事件循环检查一次
var gid = setInterval(clearAllTimeouts, 0);

function clearAllTimeouts() {
var id = setTimeout(function() {}, 0);
while (id > 0) {
if (id !== gid) {
clearTimeout(id);
}
id--;
}
}
})();

上面代码中,先调用setTimeout,得到一个计算器编号,然后把编号比它小的计数器全部取消。

实例:debounce 函数

有时,我们不希望回调函数被频繁调用。比如,用户填入网页输入框的内容,希望通过 Ajax 方法传回服务器,jQuery 的写法如下。

1
$('textarea').on('keydown', ajaxAction);

这样写有一个很大的缺点,就是如果用户连续击键,就会连续触发keydown事件,造成大量的 Ajax 通信。这是不必要的,而且很可能产生性能问题。正确的做法应该是,设置一个门槛值,表示两次 Ajax 通信的最小间隔时间。如果在间隔时间内,发生新的keydown事件,则不触发 Ajax 通信,并且重新开始计时。如果过了指定时间,没有发生新的keydown事件,再将数据发送出去。

这种做法叫做debounce(防抖动)。假定两次 Ajax 通信的间隔不得小于2500毫秒,上面的代码可以改写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
$('textarea').on('keydown', debounce(ajaxAction, 2500));

function debounce(fn, delay){
var timer = null; // 声明计时器
return function() {
var context = this;
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
}

上面代码中,只要在2500毫秒之内,用户再次击键,就会取消上一次的定时器,然后再新建一个定时器。这样就保证了回调函数之间的调用间隔,至少是2500毫秒。

运行机制

setTimeoutsetInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。

这意味着,setTimeoutsetInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeoutsetInterval指定的任务,一定会按照预定时间执行。

1
2
setTimeout(someTask, 100);
veryLongTask();

上面代码的setTimeout,指定100毫秒以后运行一个任务。但是,如果后面的veryLongTask函数(同步任务)运行时间非常长,过了100毫秒还无法结束,那么被推迟运行的someTask就只有等着,等到veryLongTask运行结束,才轮到它执行。

再看一个setInterval的例子。

1
2
3
4
5
6
7
8
9
10
11
setInterval(function () {
console.log(2);
}, 1000);

sleep(3000);

function sleep(ms) {
var start = Date.now();
while ((Date.now() - start) < ms) {
}
}

上面代码中,setInterval要求每隔1000毫秒,就输出一个2。但是,紧接着的sleep语句需要3000毫秒才能完成,那么setInterval就必须推迟到3000毫秒之后才开始生效。注意,生效后setInterval不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。

setTimeout(f, 0)

含义

setTimeout的作用是将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),那么会立刻执行吗?

答案是不会。必须要等到当前脚本的同步任务,全部处理完以后,才会执行setTimeout指定的回调函数f。也就是说,setTimeout(f, 0)会在下一轮事件循环一开始就执行。

1
2
3
4
5
6
setTimeout(function () {
console.log(1);
}, 0);
console.log(2);
// 2
// 1

上面代码先输出2,再输出1。因为2是同步任务,在本轮事件循环执行,而1是下一轮事件循环执行。

总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f

实际上,setTimeout(f, 0)不会真的在0毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到4毫秒之后运行。如果电脑正在使用电池供电,会等到16毫秒之后运行;如果网页不在当前 Tab 页,会推迟到1000毫秒(1秒)之后运行。这样是为了节省系统资源。

应用

setTimeout(f, 0)有几个非常重要的用途。它的一大应用是,可以调整事件的发生顺序。比如,网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,想让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码如下
// <input type="button" id="myButton" value="click">

var input = document.getElementById('myButton');

input.onclick = function A() {
setTimeout(function B() {
input.value +=' input';
}, 0)
};

document.body.onclick = function C() {
input.value += ' body'
};

上面代码在点击按钮后,先触发回调函数A,然后触发函数C。函数A中,setTimeout将函数B推迟到下一轮事件循环执行,这样就起到了,先触发父元素的回调函数C的目的了。

另一个应用是,用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。

1
2
3
4
5
6
// HTML 代码如下
// <input type="text" id="input-box">

document.getElementById('input-box').onkeypress = function (event) {
this.value = this.value.toUpperCase();
}

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转为大写,因为浏览器此时还没接收到新的文本,所以this.value取不到最新输入的那个字符。只有用setTimeout改写,上面的代码才能发挥作用。

1
2
3
4
5
6
document.getElementById('input-box').onkeypress = function() {
var self = this;
setTimeout(function() {
self.value = self.value.toUpperCase();
}, 0);
}

上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。

由于setTimeout(f, 0)实际上意味着,将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到setTimeout(f, 0)里面执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var div = document.getElementsByTagName('div')[0];

// 写法一
for (var i = 0xA00000; i < 0xFFFFFF; i++) {
div.style.backgroundColor = '#' + i.toString(16);
}

// 写法二
var timer;
var i=0x100000;

function func() {
timer = setTimeout(func, 0);
div.style.backgroundColor = '#' + i.toString(16);
if (i++ == 0xFFFFFF) clearTimeout(timer);
}

timer = setTimeout(func, 0);

上面代码有两种写法,都是改变一个网页元素的背景色。写法一会造成浏览器“堵塞”,因为 JavaScript 执行速度远高于 DOM,会造成大量 DOM 操作“堆积”,而写法二就不会,这就是setTimeout(f, 0)的好处。

另一个使用这种技巧的例子是代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成setTimeout(highlightNext, 50)的样子,性能压力就会减轻。

JS异步操作——概述

单线程模型

单线程模型指的是,JavaScript 只在一个线程上运行。也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待。

注意,JavaScript 只在一个线程上运行,不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。

JavaScript 之所以采用单线程,而不是多线程,跟历史有关系。JavaScript 从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。如果 JavaScript 同时有两个线程,一个线程在网页 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?是不是还要有锁机制?所以,为了避免复杂性,JavaScript 一开始就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 JavaScript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。JavaScript 语言本身并不慢,慢的是读写外部数据,比如等待 Ajax 请求返回结果。这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞。

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 操作(输入输出)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript 语言的设计者意识到,这时 CPU 完全可以不管 IO 操作,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是 JavaScript 内部采用的“事件循环”机制(Event Loop)。

单线程模型虽然对 JavaScript 构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果用得好,JavaScript 程序是不会出现堵塞的,这就是为什么 Node 可以用很少的资源,应付大流量访问的原因。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

同步任务和异步任务

程序里面所有的任务,可以分成两类:同步任务(synchronous)和异步任务(asynchronous)。

同步任务是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。

异步任务是那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。

举例来说,Ajax 操作可以当作同步任务处理,也可以当作异步任务处理,由开发者决定。如果是同步任务,主线程就等着 Ajax 操作返回结果,再往下执行;如果是异步任务,主线程在发出 Ajax 请求以后,就直接往下执行,等到 Ajax 操作有了结果,主线程再执行对应的回调函数。

任务队列和事件循环

JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列。)

首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。

异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。

JavaScript 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环(Event Loop)。

异步操作的模式

下面总结一下异步操作的几种模式。

回调函数

回调函数是异步操作最基本的方法。

下面是两个函数f1f2,编程的意图是f2必须等到f1执行完成,才能执行。

1
2
3
4
5
6
7
8
9
function f1() {
// ...
}
function f2() {
// ...
}

f1();
f2();

上面代码的问题在于,如果f1是异步操作,f2会立即执行,不会等到f1结束再执行。

这时,可以考虑改写f1,把f2写成f1的回调函数。

1
2
3
4
5
6
7
8
9
function f1(callback) {
// ...
callback();
}
function f2() {
// ...
}

f1(f2);

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。

事件监听

另一种思路是采用事件驱动模式。异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

还是以f1f2为例。首先,为f1绑定一个事件(这里采用的 jQuery 的写法)。

1
f1.on('done', f2);

上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:

1
2
3
4
5
6
function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}

上面代码中,f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2

这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去耦合”(decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。

发布/订阅

事件完全可以理解成“信号”,如果存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做”发布/订阅模式”(publish-subscribe pattern),又称观察者模式。

这个模式有多种实现,下面采用的是 Ben Alman 的Tiny Pub/Sub,这是 jQuery 的一个插件。

首先,f2向信号中心jQuery订阅done信号。

1
jQuery.subscribe('done', f2);

然后,f1进行如下改写。

1
2
3
4
5
6
function f1() {
setTimeout(function () {
// ...
jQuery.publish('done');
}, 1000);
}

上面代码中,jQuery.publish('done')的意思是,f1执行完成后,向信号中心jQuery发布done信号,从而引发f2的执行。

f2完成执行后,可以取消订阅(unsubscribe)。

1
jQuery.unsubscribe('done', f2);

这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

异步操作的流程控制

如果有多个异步操作,就存在一个流程控制的问题:如何确定异步操作执行的顺序,以及如何保证遵守这种顺序。

1
2
3
4
function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

上面代码的async函数是一个异步任务,非常耗时,每次执行需要1秒才能完成,然后再调用回调函数。

如果有六个这样的异步任务,需要全部完成后,才能执行最后的final函数。请问应该如何安排操作流程?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function final(value) {
console.log('完成: ', value);
}

async(1, function (value) {
async(2, function (value) {
async(3, function (value) {
async(4, function (value) {
async(5, function (value) {
async(6, final);
});
});
});
});
});
// 参数为 1 , 1秒后返回结果
// 参数为 2 , 1秒后返回结果
// 参数为 3 , 1秒后返回结果
// 参数为 4 , 1秒后返回结果
// 参数为 5 , 1秒后返回结果
// 参数为 6 , 1秒后返回结果
// 完成: 12

上面代码中,六个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护。

串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串行执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];

function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
console.log('完成: ', value);
}

function series(item) {
if(item) {
async( item, function(result) {
results.push(result);
return series(items.shift());
});
} else {
return final(results[results.length - 1]);
}
}

series(items.shift());

上面代码中,函数series就是串行函数,它会依次执行异步任务,所有任务都完成后,才会执行final函数。items数组保存每一个异步任务的参数,results数组保存每一个异步任务的运行结果。

注意,上面的写法需要六秒,才能完成整个脚本。

并行执行

流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行final函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];

function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
console.log('完成: ', value);
}

items.forEach(function(item) {
async(item, function(result){
results.push(result);
if(results.length === items.length) {
final(results[results.length - 1]);
}
})
});

上面代码中,forEach方法会同时发起六个异步任务,等到它们全部完成以后,才会执行final函数。

相比而言,上面的写法只要一秒,就能完成整个脚本。这就是说,并行执行的效率较高,比起串行执行一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式。

并行与串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行n个异步任务,这样就避免了过分占用系统资源。

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
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;

function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
console.log('完成: ', value);
}

function launcher() {
while(running < limit && items.length > 0) {
var item = items.shift();
async(item, function(result) {
results.push(result);
running--;
if(items.length > 0) {
launcher();
} else if(running == 0) {
final(results);
}
});
running++;
}
}

launcher();

上面代码中,最多只能同时运行两个异步任务。变量running记录当前正在运行的任务数,只要低于门槛值,就再启动一个新的任务,如果等于0,就表示所有任务都执行完了,这时就执行final函数。

这段代码需要三秒完成整个脚本,处在串行执行和并行执行之间。通过调节limit变量,达到效率和资源的最佳平衡。

JS面向对象—对象的继承

面向对象编程很重要的一个方面,就是对象的继承。A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法。这对于代码的复用是非常有用的。

大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过class,而是通过“原型对象”(prototype)实现。

原型对象概述

构造函数的缺点

JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法,可以定义在构造函数内部。

1
2
3
4
5
6
7
8
9
function Cat (name, color) {
this.name = name;
this.color = color;
}

var cat1 = new Cat('大毛', '白色');

cat1.name // '大毛'
cat1.color // '白色'

上面代码中,Cat函数是一个构造函数,函数内部定义了name属性和color属性,所有实例对象(上例是cat1)都会生成这两个属性,即这两个属性会定义在实例对象上面。

通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。

1
2
3
4
5
6
7
8
9
10
11
12
function Cat(name, color) {
this.name = name;
this.color = color;
this.meow = function () {
console.log('喵喵');
};
}

var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');

cat1.meow === cat2.meow // false

上面代码中,cat1cat2是同一个构造函数的两个实例,它们都具有meow方法。由于meow方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个meow方法。这既没有必要,又浪费系统资源,因为所有meow方法都是同样的行为,完全应该共享。

这个问题的解决方法,就是 JavaScript 的原型对象(prototype)。

prototype 属性的作用

JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。

下面,先看怎么为对象指定原型。JavaScript 规定,每个函数都有一个prototype属性,指向一个对象。

1
2
function f() {}
typeof f.prototype // "object"

上面代码中,函数f默认具有prototype属性,指向一个对象。

对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。

1
2
3
4
5
6
7
8
9
10
function Animal(name) {
this.name = name;
}
Animal.prototype.color = 'white';

var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');

cat1.color // 'white'
cat2.color // 'white'

上面代码中,构造函数Animalprototype属性,就是实例对象cat1cat2的原型对象。原型对象上添加一个color属性,结果,实例对象都共享了该属性。

原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。

1
2
3
4
Animal.prototype.color = 'yellow';

cat1.color // "yellow"
cat2.color // "yellow"

上面代码中,原型对象的color属性的值变为yellow,两个实例对象的color属性立刻跟着变了。这是因为实例对象其实没有color属性,都是读取原型对象的color属性。也就是说,当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。

如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。

1
2
3
4
5
cat1.color = 'black';

cat1.color // 'black'
cat2.color // 'yellow'
Animal.prototype.color // 'yellow';

上面代码中,实例对象cat1color属性改为black,就使得它不再去原型对象读取color属性,后者的值依然为yellow

总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。

1
2
3
Animal.prototype.walk = function () {
console.log(this.name + ' is walking');
};

上面代码中,Animal.prototype对象上面定义了一个walk方法,这个方法将可以在所有Animal实例对象上面调用。

原型链

JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”:对象到原型,再到原型的原型……

如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOftoString方法的原因,因为这是从Object.prototype继承的。

那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是nullnull没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null

1
Object.getPrototypeOf(Object.prototype) // null

上面代码表示,Object.prototype对象的原型是null,由于null没有任何属性,所以原型链到此为止。Object.getPrototypeOf方法返回参数对象的原型。

读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。

注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。

举例来说,如果让构造函数的prototype属性指向一个数组,就意味着实例对象可以调用数组方法。

1
2
3
4
5
6
7
8
9
var MyArray = function () {};

MyArray.prototype = new Array();
MyArray.prototype.constructor = MyArray;

var mine = new MyArray();
mine.push(1, 2, 3);
mine.length // 3
mine instanceof Array // true

上面代码中,mine是构造函数MyArray的实例对象,由于MyArray.prototype指向一个数组实例,使得mine可以调用数组方法(这些方法定义在数组实例的prototype对象上面)。最后那行instanceof表达式,用来比较一个对象是否为某个构造函数的实例,结果就是证明mineArray的实例。

constructor 属性

prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。

1
2
function P() {}
P.prototype.constructor === P // true

由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。

1
2
3
4
5
6
function P() {}
var p = new P();

p.constructor === P // true
p.constructor === P.prototype.constructor // true
p.hasOwnProperty('constructor') // false

上面代码中,p是构造函数P的实例对象,但是p自身没有constructor属性,该属性其实是读取原型链上面的P.prototype.constructor属性。

constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。

1
2
3
4
5
function F() {};
var f = new F();

f.constructor === F // true
f.constructor === RegExp // false

上面代码中,constructor属性确定了实例对象f的构造函数是F,而不是RegExp

另一方面,有了constructor属性,就可以从一个实例对象新建另一个实例。

1
2
3
4
5
function Constr() {}
var x = new Constr();

var y = new x.constructor();
y instanceof Constr // true

上面代码中,x是构造函数Constr的实例,可以从x.constructor间接调用构造函数。这使得在实例方法中,调用自身的构造函数成为可能。

1
2
3
Constr.prototype.createCopy = function () {
return new this.constructor();
};

上面代码中,createCopy方法调用构造函数,新建另一个实例。

constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性,防止引用的时候出错。

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

Person.prototype.constructor === Person // true

Person.prototype = {
method: function () {}
};

Person.prototype.constructor === Person // false
Person.prototype.constructor === Object // true

上面代码中,构造函数Person的原型对象改掉了,但是没有修改constructor属性,导致这个属性不再指向Person。由于Person的新原型是一个普通对象,而普通对象的constructor属性指向Object构造函数,导致Person.prototype.constructor变成了Object

所以,修改原型对象时,一般要同时修改constructor属性的指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 坏的写法
C.prototype = {
method1: function (...) { ... },
// ...
};

// 好的写法
C.prototype = {
constructor: C,
method1: function (...) { ... },
// ...
};

// 更好的写法
C.prototype.method1 = function (...) { ... };

上面代码中,要么将constructor属性重新指向原来的构造函数,要么只在原型对象上添加方法,这样可以保证instanceof运算符不会失真。

如果不能确定constructor属性是什么函数,还有一个办法:通过name属性,从实例得到构造函数的名称。

1
2
3
function Foo() {}
var f = new Foo();
f.constructor.name // "Foo"

instanceof 运算符

instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例。

1
2
var v = new Vehicle();
v instanceof Vehicle // true

上面代码中,对象v是构造函数Vehicle的实例,所以返回true

instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象(prototype),是否在左边对象的原型链上。因此,下面两种写法是等价的。

1
2
3
v instanceof Vehicle
// 等同于
Vehicle.prototype.isPrototypeOf(v)

由于instanceof检查整个原型链,因此同一个实例对象,可能会对多个构造函数都返回true

1
2
3
var d = new Date();
d instanceof Date // true
d instanceof Object // true

上面代码中,d同时是DateObject的实例,因此对这两个构造函数都返回true

由于任意对象(除了null)都是Object的实例,所以instanceof运算符可以判断一个值是否为非null的对象。

1
2
3
4
var obj = { foo: 123 };
obj instanceof Object // true

null instanceof Object // false

上面代码中,除了null,其他对象的instanceOf Object的运算结果都是true

instanceof的原理是检查右边构造函数的prototype属性,是否在左边对象的原型链上。有一种特殊情况,就是左边对象的原型链上,只有null对象。这时,instanceof判断会失真。

1
2
3
var obj = Object.create(null);
typeof obj // "object"
Object.create(null) instanceof Object // false

上面代码中,Object.create(null)返回一个新对象obj,它的原型是null。右边的构造函数Objectprototype属性,不在左边的原型链上,因此instanceof就认为obj不是Object的实例。但是,只要一个对象的原型不是nullinstanceof运算符的判断就不会失真。

instanceof运算符的一个用处,是判断值的类型。

1
2
3
4
var x = [1, 2, 3];
var y = {};
x instanceof Array // true
y instanceof Object // true

注意,instanceof运算符只能用于对象,不适用原始类型的值。

1
2
var s = 'hello';
s instanceof String // false

上面代码中,字符串不是String对象的实例(因为字符串不是对象),所以返回false

此外,对于undefinednullinstanceOf运算符总是返回false

1
2
undefined instanceof Object // false
null instanceof Object // false

利用instanceof运算符,还可以巧妙地解决,调用构造函数时,忘了加new命令的问题。

1
2
3
4
5
6
7
8
function Fubar (foo, bar) {
if (this instanceof Fubar) {
this._foo = foo;
this._bar = bar;
} else {
return new Fubar(foo, bar);
}
}

上面代码使用instanceof运算符,在函数体内部判断this关键字是否为构造函数Fubar的实例。如果不是,就表明忘了加new命令。

构造函数的继承

让一个构造函数继承另一个构造函数,是非常常见的需求。这可以分成两步实现。第一步是在子类的构造函数中,调用父类的构造函数。

1
2
3
4
function Sub(value) {
Super.call(this);
this.prop = value;
}

上面代码中,Sub是子类的构造函数,this是子类的实例。在实例上调用父类的构造函数Super,就会让子类实例具有父类实例的属性。

第二步,是让子类的原型指向父类的原型,这样子类就可以继承父类原型。

1
2
3
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.method = '...';

上面代码中,Sub.prototype是子类的原型,要将它赋值为Object.create(Super.prototype),而不是直接等于Super.prototype。否则后面两行对Sub.prototype的操作,会连父类的原型Super.prototype一起修改掉。

另外一种写法是Sub.prototype等于一个父类实例。

1
Sub.prototype = new Super();

上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。

举例来说,下面是一个Shape构造函数。

1
2
3
4
5
6
7
8
9
10
function Shape() {
this.x = 0;
this.y = 0;
}

Shape.prototype.move = function (x, y) {
this.x += x;
this.y += y;
console.info('Shape moved.');
};

我们需要让Rectangle构造函数继承Shape

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第一步,子类继承父类的实例
function Rectangle() {
Shape.call(this); // 调用父类构造函数
}
// 另一种写法
function Rectangle() {
this.base = Shape;
this.base();
}

// 第二步,子类继承父类的原型
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

采用这样的写法以后,instanceof运算符会对子类和父类的构造函数,都返回true

1
2
3
4
var rect = new Rectangle();

rect instanceof Rectangle // true
rect instanceof Shape // true

上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法。

1
2
3
4
ClassB.prototype.print = function() {
ClassA.prototype.print.call(this);
// some code
}

上面代码中,子类Bprint方法先调用父类Aprint方法,再部署自己的代码。这就等于继承了父类Aprint方法。

多重继承

JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function M1() {
this.hello = 'hello';
}

function M2() {
this.world = 'world';
}

function S() {
M1.call(this);
M2.call(this);
}

// 继承 M1
S.prototype = Object.create(M1.prototype);
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype);

// 指定构造函数
S.prototype.constructor = S;

var s = new S();
s.hello // 'hello'
s.world // 'world'

上面代码中,子类S同时继承了父类M1M2。这种模式又称为 Mixin(混入)。

模块

JavaScript 模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。

但是,JavaScript 不是一种模块化编程语言,ES6 才开始支持“类”和“模块”。下面介绍传统的做法,如何利用对象实现模块的效果。

基本的实现方法

模块是实现特定功能的一组属性和方法的封装。

简单的做法是把模块写成一个对象,所有的模块成员都放到这个对象里面。

1
2
3
4
5
6
7
8
9
var module1 = new Object({
 _count : 0,
 m1 : function (){
  //...
 },
 m2 : function (){
 //...
 }
});

上面的函数m1m2,都封装在module1对象里。使用的时候,就是调用这个对象的属性。

1
module1.m1();

但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。

1
module1._count = 5;

封装私有变量:构造函数的写法

我们可以利用构造函数,封装私有变量。

1
2
3
4
5
6
7
8
9
10
11
12
function StringBuilder() {
var buffer = [];

this.add = function (str) {
buffer.push(str);
};

this.toString = function () {
return buffer.join('');
};

}

上面代码中,buffer是模块的私有变量。一旦生成实例对象,外部是无法直接访问buffer的。但是,这种方法将私有变量封装在构造函数中,导致构造函数与实例对象是一体的,总是存在于内存之中,无法在使用完成后清除。这意味着,构造函数有双重作用,既用来塑造实例对象,又用来保存实例对象的数据,违背了构造函数与实例对象在数据上相分离的原则(即实例对象的数据,不应该保存在实例对象以外)。同时,非常耗费内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
function StringBuilder() {
this._buffer = [];
}

StringBuilder.prototype = {
constructor: StringBuilder,
add: function (str) {
this._buffer.push(str);
},
toString: function () {
return this._buffer.join('');
}
};

这种方法将私有变量放入实例对象中,好处是看上去更自然,但是它的私有变量可以从外部读写,不是很安全。

封装私有变量:立即执行函数的写法

另一种做法是使用“立即执行函数”(Immediately-Invoked Function Expression,IIFE),将相关的属性和方法封装在一个函数作用域里面,可以达到不暴露私有成员的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var module1 = (function () {
 var _count = 0;
 var m1 = function () {
  //...
 };
 var m2 = function () {
  //...
 };
 return {
  m1 : m1,
  m2 : m2
 };
})();

使用上面的写法,外部代码无法读取内部的_count变量。

1
console.info(module1._count); //undefined

上面的module1就是 JavaScript 模块的基本写法。下面,再对这种写法进行加工。

模块的放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(augmentation)。

1
2
3
4
5
6
var module1 = (function (mod){
 mod.m3 = function () {
  //...
 };
 return mod;
})(module1);

上面的代码为module1模块添加了一个新方法m3(),然后返回新的module1模块。

在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上面的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用”宽放大模式”(Loose augmentation)。

1
2
3
4
var module1 = (function (mod) {
 //...
 return mod;
})(window.module1 || {});

与”放大模式”相比,“宽放大模式”就是“立即执行函数”的参数可以是空对象。

输入全局变量

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。

为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

1
2
3
var module1 = (function ($, YAHOO) {
 //...
})(jQuery, YAHOO);

上面的module1模块需要使用 jQuery 库和 YUI 库,就把这两个库(其实是两个模块)当作参数输入module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。

立即执行函数还可以起到命名空间的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function($, window, document) {

function go(num) {}

function handleEvents() {}

function initialize() {}

function dieCarouselDie() {}

//attach to the global scope
window.finalCarousel = {
init : initialize,
destroy : dieCarouselDie
}

})( jQuery, window, document );

上面代码中,finalCarousel对象输出到全局,对外暴露initdestroy接口,内部方法gohandleEventsinitializedieCarouselDie都是外部无法调用的。

JS面向对象—this关键字

涵义

this可以用在构造函数之中,表示实例对象。除此之外,this还可以用在别的场合。但不管是什么场合,this都有一个共同点:它总是返回一个对象。

简单说,this就是属性或方法“当前”所在的对象。

1
this.property

上面代码中,this就代表property属性当前所在的对象。

下面是一个实际的例子。

1
2
3
4
5
6
7
8
var person = {
name: '张三',
describe: function () {
return '姓名:'+ this.name;
}
};

person.describe() // "姓名:张三"

上面代码中,this.name表示name属性所在的那个对象。由于this.name是在describe方法中调用,而describe方法所在的当前对象是person,因此this指向personthis.name就是person.name

由于对象的属性可以赋给另一个对象,所以属性所在的当前对象是可变的,即this的指向是可变的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var A = {
name: '张三',
describe: function () {
return '姓名:'+ this.name;
}
};

var B = {
name: '李四'
};

B.describe = A.describe;
B.describe() // "姓名:李四"

上面代码中,A.describe属性被赋给B,于是B.describe就表示describe方法所在的当前对象是B,所以this.name就指向B.name

稍稍重构这个例子,this的动态指向就能看得更清楚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function f() {
return '姓名:'+ this.name;
}

var A = {
name: '张三',
describe: f
};

var B = {
name: '李四',
describe: f
};

A.describe() // "姓名:张三"
B.describe() // "姓名:李四"

上面代码中,函数f内部使用了this关键字,随着f所在的对象不同,this的指向也不同。

只要函数被赋给另一个变量,this的指向就会变。

1
2
3
4
5
6
7
8
9
10
var A = {
name: '张三',
describe: function () {
return '姓名:'+ this.name;
}
};

var name = '李四';
var f = A.describe;
f() // "姓名:李四"

上面代码中,A.describe被赋值给变量f,内部的this就会指向f运行时所在的对象(本例是顶层对象)。

再看一个网页编程的例子。

1
2
3
4
5
6
7
8
<input type="text" name="age" size=3 onChange="validate(this, 18, 99);">

<script>
function validate(obj, lowval, hival){
if ((obj.value < lowval) || (obj.value > hival))
console.log('Invalid Value!');
}
</script>

上面代码是一个文本输入框,每当用户输入一个值,就会调用onChange回调函数,验证这个值是否在指定范围。浏览器会向回调函数传入当前对象,因此this就代表传入当前对象(即文本框),然后就可以从this.value上面读到用户的输入值。

总结一下,JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是函数运行时所在的对象(环境)。这本来并不会让用户糊涂,但是 JavaScript 支持运行环境动态切换,也就是说,this的指向是动态的,没有办法事先确定到底指向哪个对象。

实质

JavaScript 语言之所以有this的设计,跟内存里面的数据结构有关系。

1
var obj = { foo:  5 };

上面的代码将一个对象赋值给变量obj。JavaScript 引擎会先在内存里面,生成一个对象{ foo: 5 },然后把这个对象的内存地址赋值给变量obj。也就是说,变量obj是一个地址。后面如果要读取obj.foo,引擎先从obj拿到内存地址,然后再从该地址读出原始的对象,返回它的foo属性。

原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的foo属性,实际上是以下面的形式保存的。

1
2
3
4
5
6
7
8
{
foo: {
[[value]]: 5
[[writable]]: true
[[enumerable]]: true
[[configurable]]: true
}
}

注意,foo属性的值保存在属性描述对象的value属性里面。

这样的结构是很清晰的,问题在于属性的值可能是一个函数。

1
var obj = { foo: function () {} };

这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给foo属性的value属性。

1
2
3
4
5
6
{
foo: {
[[value]]: 函数的地址
...
}
}

由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

1
2
3
4
5
6
7
8
var f = function () {};
var obj = { f: f };

// 单独执行
f()

// obj 环境执行
obj.f()

JavaScript 允许在函数体内部,引用当前环境的其他变量。

1
2
3
var f = function () {
console.log(x);
};

上面代码中,函数体里面使用了变量x。该变量由运行环境提供。

现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。所以,this就出现了,它的设计目的就是在函数体内部,指代函数当前的运行环境。

1
2
3
var f = function () {
console.log(this.x);
}

上面代码中,函数体里面的this.x就是指当前运行环境的x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var f = function () {
console.log(this.x);
}

var x = 1;
var obj = {
f: f,
x: 2,
};

// 单独执行
f() // 1

// obj 环境执行
obj.f() // 2

上面代码中,函数f在全局环境执行,this.x指向全局环境的x;在obj环境执行,this.x指向obj.x

使用场合

this主要有以下几个使用场合。
(1)全局环境
全局环境使用this,它指的就是顶层对象window

1
2
3
4
5
this === window // true
function f() {
console.log(this === window);
}
f() // true

上面代码说明,不管是不是在函数内部,只要是在全局环境下运行,this就是指顶层对象window
(2)构造函数
构造函数中的this,指的是实例对象。

1
2
3
var Obj = function (p) {
this.p = p;
};

上面代码定义了一个构造函数Obj。由于this指向实例对象,所以在构造函数内部定义this.p,就相当于定义实例对象有一个p属性。

1
2
var o = new Obj('Hello World!');
o.p // "Hello World!"

(3)对象的方法
如果对象的方法里面包含thisthis的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。

但是,这条规则很不容易把握。请看下面的代码。

1
2
3
4
5
6
7
var obj ={
foo: function () {
console.log(this);
}
};

obj.foo() // obj

上面代码中,obj.foo方法执行时,它内部的this指向obj

但是,下面这几种用法,都会改变this的指向。

1
2
3
4
5
6
// 情况一
(obj.foo = obj.foo)() // window
// 情况二
(false || obj.foo)() // window
// 情况三
(1, obj.foo)() // window

上面代码中,obj.foo就是一个值。这个值真正调用的时候,运行环境已经不是obj了,而是全局环境,所以this不再指向obj

可以这样理解,JavaScript 引擎内部,objobj.foo储存在两个内存地址,称为地址一和地址二。obj.foo()这样调用时,是从地址一调用地址二,因此地址二的运行环境是地址一,this指向obj。但是,上面三种情况,都是直接取出地址二进行调用,这样的话,运行环境就是全局环境,因此this指向全局环境。上面三种情况等同于下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 情况一
(obj.foo = function () {
console.log(this);
})()
// 等同于
(function () {
console.log(this);
})()

// 情况二
(false || function () {
console.log(this);
})()

// 情况三
(1, function () {
console.log(this);
})()

如果this所在的方法不在对象的第一层,这时this只是指向当前一层的对象,而不会继承更上面的层。

1
2
3
4
5
6
7
8
9
10
var a = {
p: 'Hello',
b: {
m: function() {
console.log(this.p);
}
}
};

a.b.m() // undefined

上面代码中,a.b.m方法在a对象的第二层,该方法内部的this不是指向a,而是指向a.b,因为实际执行的是下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
var b = {
m: function() {
console.log(this.p);
}
};

var a = {
p: 'Hello',
b: b
};

(a.b).m() // 等同于 b.m()

如果要达到预期效果,只有写成下面这样。

1
2
3
4
5
6
7
8
var a = {
b: {
m: function() {
console.log(this.p);
},
p: 'Hello'
}
};

如果这时将嵌套对象内部的方法赋值给一个变量,this依然会指向全局对象。

1
2
3
4
5
6
7
8
9
10
11
var a = {
b: {
m: function() {
console.log(this.p);
},
p: 'Hello'
}
};

var hello = a.b.m;
hello() // undefined

上面代码中,m是多层对象内部的一个方法。为求简便,将其赋值给hello变量,结果调用时,this指向了顶层对象。为了避免这个问题,可以只将m所在的对象赋值给hello,这样调用时,this的指向就不会变。

1
2
var hello = a.b;
hello.m() // Hello

使用注意点

避免多层 this

由于this的指向是不确定的,所以切勿在函数中包含多层的this

1
2
3
4
5
6
7
8
9
10
11
12
var o = {
f1: function () {
console.log(this);
var f2 = function () {
console.log(this);
}();
}
}

o.f1()
// Object
// Window

上面代码包含两层this,结果运行后,第一层指向对象o,第二层指向全局对象,因为实际执行的是下面的代码。

1
2
3
4
5
6
7
8
9
10
var temp = function () {
console.log(this);
};

var o = {
f1: function () {
console.log(this);
var f2 = temp();
}
}

一个解决方法是在第二层改用一个指向外层this的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {
f1: function() {
console.log(this);
var that = this;
var f2 = function() {
console.log(that);
}();
}
}

o.f1()
// Object
// Object

上面代码定义了变量that,固定指向外层的this,然后在内层使用that,就不会发生this指向的改变。

事实上,使用一个变量固定this的值,然后内层函数调用这个变量,是非常常见的做法。

JavaScript 提供了严格模式,也可以硬性避免这种问题。严格模式下,如果函数内部的this指向顶层对象,就会报错。

1
2
3
4
5
6
7
8
9
10
var counter = {
count: 0
};
counter.inc = function () {
'use strict';
this.count++
};
var f = counter.inc;
f()
// TypeError: Cannot read property 'count' of undefined

上面代码中,inc方法通过'use strict'声明采用严格模式,这时内部的this一旦指向顶层对象,就会报错。

避免数组处理方法中的 this

数组的mapforeach方法,允许提供一个函数作为参数。这个函数内部不应该使用this

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
this.p.forEach(function (item) {
console.log(this.v + ' ' + item);
});
}
}

o.f()
// undefined a1
// undefined a2

上面代码中,foreach方法的回调函数中的this,其实是指向window对象,因此取不到o.v的值。原因跟上一段的多层this是一样的,就是内层的this不指向外部,而指向顶层对象。

解决这个问题的一种方法,就是前面提到的,使用中间变量固定this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
var that = this;
this.p.forEach(function (item) {
console.log(that.v+' '+item);
});
}
}

o.f()
// hello a1
// hello a2

另一种方法是将this当作foreach方法的第二个参数,固定它的运行环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
this.p.forEach(function (item) {
console.log(this.v + ' ' + item);
}, this);
}
}

o.f()
// hello a1
// hello a2

避免回调函数中的 this

回调函数中的this往往会改变指向,最好避免使用。

1
2
3
4
5
6
7
var o = new Object();
o.f = function () {
console.log(this === o);
}

// jQuery 的写法
$('#button').on('click', o.f);

上面代码中,点击按钮以后,控制台会显示false。原因是此时this不再指向o对象,而是指向按钮的 DOM 对象,因为f方法是在按钮对象的环境中被调用的。这种细微的差别,很容易在编程中忽视,导致难以察觉的错误。

为了解决这个问题,可以采用下面的一些方法对this进行绑定,也就是使得this固定指向某个对象,减少不确定性。

绑定 this 的方法

this的动态切换,固然为 JavaScript 创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把this固定下来,避免出现意想不到的情况。JavaScript 提供了callapplybind这三个方法,来切换/固定this的指向。

Function.prototype.call()

函数实例的call方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。

1
2
3
4
5
6
7
8
var obj = {};

var f = function () {
return this;
};

f() === window // true
f.call(obj) === obj // true

上面代码中,全局环境运行函数f时,this指向全局环境(浏览器为window对象);call方法可以改变this的指向,指定this指向对象obj,然后在对象obj的作用域中运行函数f

call方法的参数,应该是一个对象。如果参数为空、nullundefined,则默认传入全局对象。

1
2
3
4
5
6
7
8
9
10
11
12
var n = 123;
var obj = { n: 456 };

function a() {
console.log(this.n);
}

a.call() // 123
a.call(null) // 123
a.call(undefined) // 123
a.call(window) // 123
a.call(obj) // 456

上面代码中,a函数中的this关键字,如果指向全局对象,返回结果为123。如果使用call方法将this关键字指向obj对象,返回结果为456。可以看到,如果call方法没有参数,或者参数为nullundefined,则等同于指向全局对象。

如果call方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入call方法。

1
2
3
4
5
6
var f = function () {
return this;
};

f.call(5)
// Number {[[PrimitiveValue]]: 5}

上面代码中,call的参数为5,不是对象,会被自动转成包装对象(Number的实例),绑定f内部的this

call方法还可以接受多个参数。

1
func.call(thisValue, arg1, arg2, ...)

call的第一个参数就是this所要指向的那个对象,后面的参数则是函数调用时所需的参数。

1
2
3
4
5
function add(a, b) {
return a + b;
}

add.call(this, 1, 2) // 3

上面代码中,call方法指定函数add内部的this绑定当前环境(对象),并且参数为12,因此函数add运行后得到3

call方法的一个应用是调用对象的原生方法。

1
2
3
4
5
6
7
8
9
10
var obj = {};
obj.hasOwnProperty('toString') // false

// 覆盖掉继承的 hasOwnProperty 方法
obj.hasOwnProperty = function () {
return true;
};
obj.hasOwnProperty('toString') // true

Object.prototype.hasOwnProperty.call(obj, 'toString') // false

上面代码中,hasOwnPropertyobj对象继承的方法,如果这个方法一旦被覆盖,就不会得到正确结果。call方法可以解决这个问题,它将hasOwnProperty方法的原始定义放到obj对象上执行,这样无论obj上有没有同名方法,都不会影响结果。

Function.prototype.apply()

apply方法的作用与call方法类似,也是改变this指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。

1
func.apply(thisValue, [arg1, arg2, ...])

apply方法的第一个参数也是this所要指向的那个对象,如果设为nullundefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在call方法中必须一个个添加,但是在apply方法中,必须以数组形式添加。

1
2
3
4
5
6
function f(x, y){
console.log(x + y);
}

f.call(null, 1, 1) // 2
f.apply(null, [1, 1]) // 2

上面代码中,f函数本来接受两个参数,使用apply方法以后,就变成可以接受一个数组作为参数。

利用这一点,可以做一些有趣的应用。
(1)找出数组最大元素
JavaScript 不提供找出数组最大元素的函数。结合使用apply方法和Math.max方法,就可以返回数组的最大元素。

1
2
var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15

(2)将数组的空元素变为undefined
通过apply方法,利用Array构造函数将数组的空元素变成undefined

1
2
Array.apply(null, ['a', ,'b'])
// [ 'a', undefined, 'b' ]

空元素与undefined的差别在于,数组的forEach方法会跳过空元素,但是不会跳过undefined。因此,遍历内部元素的时候,会得到不同的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = ['a', , 'b'];

function print(i) {
console.log(i);
}

a.forEach(print)
// a
// b

Array.apply(null, a).forEach(print)
// a
// undefined
// b

(3)转换类似数组的对象
另外,利用数组对象的slice方法,可以将一个类似数组的对象(比如arguments对象)转为真正的数组。

1
2
3
4
Array.prototype.slice.apply({0: 1, length: 1}) // [1]
Array.prototype.slice.apply({0: 1}) // []
Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined]
Array.prototype.slice.apply({length: 1}) // [undefined]

上面代码的apply方法的参数都是对象,但是返回结果都是数组,这就起到了将对象转成数组的目的。从上面代码可以看到,这个方法起作用的前提是,被处理的对象必须有length属性,以及相对应的数字键。
(4)绑定回调函数的对象
前面的按钮点击事件的例子,可以改写如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = new Object();

o.f = function () {
console.log(this === o);
}

var f = function (){
o.f.apply(o);
// 或者 o.f.call(o);
};

// jQuery 的写法
$('#button').on('click', f);

上面代码中,点击按钮以后,控制台将会显示true。由于apply方法(或者call方法)不仅绑定函数执行时所在的对象,还会立即执行函数,因此不得不把绑定语句写在一个函数体内。更简洁的写法是采用下面介绍的bind方法。

Function.prototype.bind()

bind方法用于将函数体内的this绑定到某个对象,然后返回一个新函数。

1
2
3
4
5
var d = new Date();
d.getTime() // 1481869925657

var print = d.getTime;
print() // Uncaught TypeError: this is not a Date object.

上面代码中,我们将d.getTime方法赋给变量print,然后调用print就报错了。这是因为getTime方法内部的this,绑定Date对象的实例,赋给变量print以后,内部的this已经不指向Date对象的实例了。

bind方法可以解决这个问题。

1
2
var print = d.getTime.bind(d);
print() // 1481869925657

上面代码中,bind方法将getTime方法内部的this绑定到d对象,这时就可以安全地将这个方法赋值给其他变量了。

bind方法的参数就是所要绑定this的对象,下面是一个更清晰的例子。

1
2
3
4
5
6
7
8
9
10
var counter = {
count: 0,
inc: function () {
this.count++;
}
};

var func = counter.inc.bind(counter);
func();
counter.count // 1

上面代码中,counter.inc方法被赋值给变量func。这时必须用bind方法将inc内部的this,绑定到counter,否则就会出错。

this绑定到其他对象也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var counter = {
count: 0,
inc: function () {
this.count++;
}
};

var obj = {
count: 100
};
var func = counter.inc.bind(obj);
func();
obj.count // 101

上面代码中,bind方法将inc方法内部的this,绑定到obj对象。结果调用func函数以后,递增的就是obj内部的count属性。

bind还可以接受更多的参数,将这些参数绑定原函数的参数。

1
2
3
4
5
6
7
8
9
10
11
var add = function (x, y) {
return x * this.m + y * this.n;
}

var obj = {
m: 2,
n: 2
};

var newAdd = add.bind(obj, 5);
newAdd(5) // 20

上面代码中,bind方法除了绑定this对象,还将add函数的第一个参数x绑定成5,然后返回一个新函数newAdd,这个函数只要再接受一个参数y就能运行了。

如果bind方法的第一个参数是nullundefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(浏览器为window)。

1
2
3
4
5
6
function add(x, y) {
return x + y;
}

var plus5 = add.bind(null, 5);
plus5(10) // 15

上面代码中,函数add内部并没有this,使用bind方法的主要目的是绑定参数x,以后每次运行新函数plus5,就只需要提供另一个参数y就够了。而且因为add内部没有this,所以bind的第一个参数是null,不过这里如果是其他对象,也没有影响。

bind方法有一些使用注意点。
(1)每一次返回一个新函数
bind方法每运行一次,就返回一个新函数,这会产生一些问题。比如,监听事件的时候,不能写成下面这样。

1
element.addEventListener('click', o.m.bind(o));

上面代码中,click事件绑定bind方法生成的一个匿名函数。这样会导致无法取消绑定,所以,下面的代码是无效的。

1
element.removeEventListener('click', o.m.bind(o));

正确的方法是写成下面这样:

1
2
3
4
var listener = o.m.bind(o);
element.addEventListener('click', listener);
// ...
element.removeEventListener('click', listener);

(2)结合回调函数使用
回调函数是 JavaScript 最常用的模式之一,但是一个常见的错误是,将包含this的方法直接当作回调函数。解决方法就是使用bind方法,将counter.inc绑定counter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var counter = {
count: 0,
inc: function () {
'use strict';
this.count++;
}
};

function callIt(callback) {
callback();
}

callIt(counter.inc.bind(counter));
counter.count // 1

上面代码中,callIt方法会调用回调函数。这时如果直接把counter.inc传入,调用时counter.inc内部的this就会指向全局对象。使用bind方法将counter.inc绑定counter以后,就不会有这个问题,this总是指向counter

还有一种情况比较隐蔽,就是某些数组方法可以接受一个函数当作参数。这些函数内部的this指向,很可能也会出错。

1
2
3
4
5
6
7
8
9
10
11
var obj = {
name: '张三',
times: [1, 2, 3],
print: function () {
this.times.forEach(function (n) {
console.log(this.name);
});
}
};

obj.print() // 没有任何输出

上面代码中,obj.print内部this.timesthis是指向obj的,这个没有问题。但是,forEach方法的回调函数内部的this.name却是指向全局对象,导致没有办法取到值。稍微改动一下,就可以看得更清楚。

1
2
3
4
5
6
7
8
9
10
obj.print = function () {
this.times.forEach(function (n) {
console.log(this === window);
});
};

obj.print()
// true
// true
// true

解决这个问题,也是通过bind方法绑定this

1
2
3
4
5
6
7
8
9
10
obj.print = function () {
this.times.forEach(function (n) {
console.log(this.name);
}.bind(this));
};

obj.print()
// 张三
// 张三
// 张三

(3)结合call方法使用
利用bind方法,可以改写一些 JavaScript 原生方法的使用形式,以数组的slice方法为例。

1
2
3
[1, 2, 3].slice(0, 1) // [1]
// 等同于
Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]

上面的代码中,数组的slice方法从[1, 2, 3]里面,按照指定位置和长度切分出另一个数组。这样做的本质是在[1, 2, 3]上面调用Array.prototype.slice方法,因此可以用call方法表达这个过程,得到同样的结果。

call方法实质上是调用Function.prototype.call方法,因此上面的表达式可以用bind方法改写。

1
2
var slice = Function.prototype.call.bind(Array.prototype.slice);
slice([1, 2, 3], 0, 1) // [1]

上面代码的含义就是,将Array.prototype.slice变成Function.prototype.call方法所在的对象,调用时就变成了Array.prototype.slice.call。类似的写法还可以用于其他数组方法。

1
2
3
4
5
6
7
8
9
var push = Function.prototype.call.bind(Array.prototype.push);
var pop = Function.prototype.call.bind(Array.prototype.pop);

var a = [1 ,2 ,3];
push(a, 4)
a // [1, 2, 3, 4]

pop(a)
a // [1, 2, 3]

如果再进一步,将Function.prototype.call方法绑定到Function.prototype.bind对象,就意味着bind的调用形式也可以被改写。

1
2
3
4
5
6
7
function f() {
console.log(this.v);
}

var o = { v: 123 };
var bind = Function.prototype.call.bind(Function.prototype.bind);
bind(f, o)() // 123

上面代码的含义就是,将Function.prototype.bind方法绑定在Function.prototype.call上面,所以bind方法就可以直接使用,不需要在函数实例上使用。

JS面向对象—实例对象与 new 命令

对象是什么

面向对象编程(Object Oriented Programming,缩写为 OOP)是目前主流的编程范式。它将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。

我们从两个层次来理解对象。
(1)对象是单个实物的抽象。
一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程。
(2)对象是一个容器,封装了属性(property)和方法(method)。
属性是对象的状态,方法是对象的行为(完成某种任务)。比如,我们可以把动物抽象为animal对象,使用“属性”记录具体是那一种动物,使用“方法”表示动物的某种行为(奔跑、捕猎、休息等等)。

构造函数

面向对象编程的第一步,就是要生成对象。对象是单个实物的抽象。通常需要一个模板,表示某一类实物的共同特征,然后对象根据这个模板生成。

典型的面向对象编程语言(比如Java),都有“类”(class)这个概念。所谓“类”就是对象的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于构造函数(constructor)和原型链(prototype)。

JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来生成实例对象的函数。它就是对象的模板,描述实例对象的基本结构。一个构造函数,可以生成多个实例对象,这些实例对象都有相同的结构。

构造函数就是一个普通的函数,但是有自己的特征和用法。

1
2
3
var Vehicle = function () {
this.price = 1000;
};

上面代码中,Vehicle就是构造函数。为了与普通函数区别,构造函数名字的第一个字母通常大写。

构造函数的特点有两个。

  • 函数体内部使用了this关键字,代表了所要生成的对象实例。
  • 生成对象的时候,必须使用new命令。

new 命令

基本用法

new命令的作用,就是执行构造函数,返回一个实例对象。

1
2
3
4
5
6
var Vehicle = function () {
this.price = 1000;
};

var v = new Vehicle();
v.price // 1000

上面代码通过new命令,让构造函数Vehicle生成一个实例对象,保存在变量v中。这个新生成的实例对象,从构造函数Vehicle得到了price属性。new命令执行时,构造函数内部的this,就代表了新生成的实例对象,this.price表示实例对象有一个price属性,值是1000。

使用new命令时,根据需要,构造函数也可以接受参数。

1
2
3
4
5
var Vehicle = function (p) {
this.price = p;
};

var v = new Vehicle(500);

new命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。下面两行代码是等价的,但是为了表示这里是函数调用,推荐使用括号。

1
2
3
4
// 推荐的写法
var v = new Vehicle();
// 不推荐的写法
var v = new Vehicle;

一个很自然的问题是,如果忘了使用new命令,直接调用构造函数会发生什么事?

这种情况下,构造函数就变成了普通函数,并不会生成实例对象。this这时代表全局对象,将造成一些意想不到的结果。

1
2
3
4
5
6
7
var Vehicle = function (){
this.price = 1000;
};

var v = Vehicle();
v // undefined
price // 1000

上面代码中,调用Vehicle构造函数时,忘了加上new命令。结果,变量v变成了undefined,而price属性变成了全局变量。因此,应该非常小心,避免不使用new命令、直接调用构造函数。

为了保证构造函数必须与new命令一起使用,一个解决办法是,构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错。

1
2
3
4
5
6
7
8
function Fubar(foo, bar){
'use strict';
this._foo = foo;
this._bar = bar;
}

Fubar()
// TypeError: Cannot set property '_foo' of undefined

上面代码的Fubar为构造函数,use strict命令保证了该函数在严格模式下运行。由于严格模式中,函数内部的this不能指向全局对象,默认等于undefined,导致不加new调用会报错(JavaScript 不允许对undefined添加属性)。

另一个解决办法,构造函数内部判断是否使用new命令,如果发现没有使用,则直接返回一个实例对象。

1
2
3
4
5
6
7
8
9
10
11
function Fubar(foo, bar) {
if (!(this instanceof Fubar)) {
return new Fubar(foo, bar);
}

this._foo = foo;
this._bar = bar;
}

Fubar(1, 2)._foo // 1
(new Fubar(1, 2))._foo // 1

上面代码中的构造函数,不管加不加new命令,都会得到同样的结果。

new 命令的原理

使用new命令时,它后面的函数依次执行下面的步骤。

  1. 创建一个空对象,作为将要返回的对象实例。
  2. 将这个空对象的原型,指向构造函数的prototype属性。
  3. 将这个空对象赋值给函数内部的this关键字。
  4. 开始执行构造函数内部的代码。

也就是说,构造函数内部,this指的是一个新生成的空对象,所有针对this的操作,都会发生在这个空对象上。构造函数之所以叫“构造函数”,就是说这个函数的目的,就是操作一个空对象(即this对象),将其“构造”为需要的样子。

如果构造函数内部有return语句,而且return后面跟着一个对象,new命令会返回return语句指定的对象;否则,就会不管return语句,返回this对象。

1
2
3
4
5
6
var Vehicle = function () {
this.price = 1000;
return 1000;
};

(new Vehicle()) === 1000 // false

上面代码中,构造函数Vehiclereturn语句返回一个数值。这时,new命令就会忽略这个return语句,返回“构造”后的this对象。

但是,如果return语句返回的是一个跟this无关的新对象,new命令会返回这个新对象,而不是this对象。这一点需要特别引起注意。

1
2
3
4
5
6
var Vehicle = function (){
this.price = 1000;
return { price: 2000 };
};

(new Vehicle()).price // 2000

上面代码中,构造函数Vehiclereturn语句,返回的是一个新对象。new命令会返回这个对象,而不是this对象。

另一方面,如果对普通函数(内部没有this关键字的函数)使用new命令,则会返回一个空对象。

1
2
3
4
5
6
7
8
function getMessage() {
return 'this is a message';
}

var msg = new getMessage();

msg // {}
typeof msg // "object"

上面代码中,getMessage是一个普通函数,返回一个字符串。对它使用new命令,会得到一个空对象。这是因为new命令总是返回一个对象,要么是实例对象,要么是return语句指定的对象。本例中,return语句返回的是字符串,所以new命令就忽略了该语句。

new命令简化的内部流程,可以用下面的代码表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ params) {
// 将 arguments 对象转为数组
var args = [].slice.call(arguments);
// 取出构造函数
var constructor = args.shift();
// 创建一个空对象,继承构造函数的 prototype 属性
var context = Object.create(constructor.prototype);
// 执行构造函数
var result = constructor.apply(context, args);
// 如果返回结果是对象,就直接返回,否则返回 context 对象
return (typeof result === 'object' && result != null) ? result : context;
}

// 实例
var actor = _new(Person, '张三', 28);

new.target

函数内部可以使用new.target属性。如果当前函数是new命令调用,new.target指向当前函数,否则为undefined

1
2
3
4
5
6
function f() {
console.log(new.target === f);
}

f() // false
new f() // true

使用这个属性,可以判断函数调用的时候,是否使用new命令。

1
2
3
4
5
6
7
8
function f() {
if (!new.target) {
throw new Error('请使用 new 命令调用!');
}
// ...
}

f() // Uncaught Error: 请使用 new 命令调用!

上面代码中,构造函数f调用时,没有使用new命令,就抛出一个错误。

Object.create() 创建实例对象

构造函数作为模板,可以生成实例对象。但是,有时拿不到构造函数,只能拿到一个现有的对象。我们希望以这个现有的对象作为模板,生成新的实例对象,这时就可以使用Object.create()方法。

1
2
3
4
5
6
7
8
9
10
11
12
var person1 = {
name: '张三',
age: 38,
greeting: function() {
console.log('Hi! I\'m ' + this.name + '.');
}
};

var person2 = Object.create(person1);

person2.name // 张三
person2.greeting() // Hi! I'm 张三.

上面代码中,对象person1person2的模板,后者继承了前者的属性和方法。

JS面向对象—Object对象相关方法

JavaScript 在Object对象上面,提供了很多相关方法,处理面向对象编程的相关操作。

Object.getPrototypeOf()

Object.getPrototypeOf方法返回参数对象的原型。这是获取原型对象的标准方法。

1
2
3
var F = function () {};
var f = new F();
Object.getPrototypeOf(f) === F.prototype // true

上面代码中,实例对象f的原型是F.prototype

下面是几种特殊对象的原型。

1
2
3
4
5
6
7
8
9
// 空对象的原型是 Object.prototype
Object.getPrototypeOf({}) === Object.prototype // true

// Object.prototype 的原型是 null
Object.getPrototypeOf(Object.prototype) === null // true

// 函数的原型是 Function.prototype
function f() {}
Object.getPrototypeOf(f) === Function.prototype // true

Object.setPrototypeOf()

Object.setPrototypeOf方法为参数对象设置原型,返回该参数对象。它接受两个参数,第一个是现有对象,第二个是原型对象。

1
2
3
4
5
6
var a = {};
var b = {x: 1};
Object.setPrototypeOf(a, b);

Object.getPrototypeOf(a) === b // true
a.x // 1

上面代码中,Object.setPrototypeOf方法将对象a的原型,设置为对象b,因此a可以共享b的属性。

new命令可以使用Object.setPrototypeOf方法模拟。

1
2
3
4
5
6
7
8
var F = function () {
this.foo = 'bar';
};

var f = new F();
// 等同于
var f = Object.setPrototypeOf({}, F.prototype);
F.call(f);

上面代码中,new命令新建实例对象,其实可以分成两步。第一步,将一个空对象的原型设为构造函数的prototype属性(上例是F.prototype);第二步,将构造函数内部的this绑定这个空对象,然后执行构造函数,使得定义在this上面的方法和属性(上例是this.foo),都转移到这个空对象上。

Object.create()

生成实例对象的常用方法是,使用new命令让构造函数返回一个实例。但是很多时候,只能拿到一个实例对象,它可能根本不是由构建函数生成的,那么能不能从一个实例对象,生成另一个实例对象呢?

JavaScript 提供了Object.create方法,用来满足这种需求。该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 原型对象
var A = {
print: function () {
console.log('hello');
}
};

// 实例对象
var B = Object.create(A);

Object.getPrototypeOf(B) === A // true
B.print() // hello
B.print === A.print // true

上面代码中,Object.create方法以A对象为原型,生成了B对象。B继承了A的所有属性和方法。

实际上,Object.create方法可以用下面的代码代替。

1
2
3
4
5
6
7
if (typeof Object.create !== 'function') {
Object.create = function (obj) {
function F() {}
F.prototype = obj;
return new F();
};
}

上面代码表明,Object.create方法的实质是新建一个空的构造函数F,然后让F.prototype属性指向参数对象obj,最后返回一个F的实例,从而实现让该实例继承obj的属性。

下面三种方式生成的新对象是等价的。

1
2
3
var obj1 = Object.create({});
var obj2 = Object.create(Object.prototype);
var obj3 = new Object();

如果想要生成一个不继承任何属性(比如没有toStringvalueOf方法)的对象,可以将Object.create的参数设为null

1
2
3
4
var obj = Object.create(null);

obj.valueOf()
// TypeError: Object [object Object] has no method 'valueOf'

上面代码中,对象obj的原型是null,它就不具备一些定义在Object.prototype对象上面的属性,比如valueOf方法。

使用Object.create方法的时候,必须提供对象原型,即参数不能为空,或者不是对象,否则会报错。

1
2
3
4
Object.create()
// TypeError: Object prototype may only be an Object or null
Object.create(123)
// TypeError: Object prototype may only be an Object or null

Object.create方法生成的新对象,动态继承了原型。在原型上添加或修改任何方法,会立刻反映在新对象之上。

1
2
3
4
5
var obj1 = { p: 1 };
var obj2 = Object.create(obj1);

obj1.p = 2;
obj2.p // 2

上面代码中,修改对象原型obj1会影响到实例对象obj2

除了对象的原型,Object.create方法还可以接受第二个参数。该参数是一个属性描述对象,它所描述的对象属性,会添加到实例对象,作为该对象自身的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var obj = Object.create({}, {
p1: {
value: 123,
enumerable: true,
configurable: true,
writable: true,
},
p2: {
value: 'abc',
enumerable: true,
configurable: true,
writable: true,
}
});

// 等同于
var obj = Object.create({});
obj.p1 = 123;
obj.p2 = 'abc';

Object.create方法生成的对象,继承了它的原型对象的构造函数。

1
2
3
4
5
6
function A() {}
var a = new A();
var b = Object.create(a);

b.constructor === A // true
b instanceof A // true

上面代码中,b对象的原型是a对象,因此继承了a对象的构造函数A

Object.prototype.isPrototypeOf()

实例对象的isPrototypeOf方法,用来判断该对象是否为参数对象的原型。

1
2
3
4
5
6
var o1 = {};
var o2 = Object.create(o1);
var o3 = Object.create(o2);

o2.isPrototypeOf(o3) // true
o1.isPrototypeOf(o3) // true

上面代码中,o1o2都是o3的原型。这表明只要实例对象处在参数对象的原型链上,isPrototypeOf方法都返回true

1
2
3
4
Object.prototype.isPrototypeOf({}) // true
Object.prototype.isPrototypeOf([]) // true
Object.prototype.isPrototypeOf(/xyz/) // true
Object.prototype.isPrototypeOf(Object.create(null)) // false

上面代码中,由于Object.prototype处于原型链的最顶端,所以对各种实例都返回true,只有直接继承自null的对象除外。

Object.prototype.__proto__

实例对象的__proto__属性(前后各两个下划线),返回该对象的原型。该属性可读写。

1
2
3
4
5
var obj = {};
var p = {};

obj.__proto__ = p;
Object.getPrototypeOf(obj) === p // true

上面代码通过__proto__属性,将p对象设为obj对象的原型。

根据语言标准,__proto__属性只有浏览器才需要部署,其他环境可以没有这个属性。它前后的两根下划线,表明它本质是一个内部属性,不应该对使用者暴露。因此,应该尽量少用这个属性,而是用Object.getPrototypeOf()Object.setPrototypeOf(),进行原型对象的读写操作。

原型链可以用__proto__很直观地表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var A = {
name: '张三'
};
var B = {
name: '李四'
};

var proto = {
print: function () {
console.log(this.name);
}
};

A.__proto__ = proto;
B.__proto__ = proto;

A.print() // 张三
B.print() // 李四

A.print === B.print // true
A.print === proto.print // true
B.print === proto.print // true

上面代码中,A对象和B对象的原型都是proto对象,它们都共享proto对象的print方法。也就是说,ABprint方法,都是在调用proto对象的print方法。

获取原型对象方法的比较

如前所述,__proto__属性指向当前对象的原型对象,即构造函数的prototype属性。

1
2
3
4
var obj = new Object();

obj.__proto__ === Object.prototype // true
obj.__proto__ === obj.constructor.prototype // true

上面代码首先新建了一个对象obj,它的__proto__属性,指向构造函数(Objectobj.constructor)的prototype属性。

因此,获取实例对象obj的原型对象,有三种方法。

  • obj.__proto__
  • obj.constructor.prototype
  • Object.getPrototypeOf(obj)

上面三种方法之中,前两种都不是很可靠。__proto__属性只有浏览器才需要部署,其他环境可以不部署。而obj.constructor.prototype在手动改变原型对象时,可能会失效。

1
2
3
4
5
6
7
8
var P = function () {};
var p = new P();

var C = function () {};
C.prototype = p;
var c = new C();

c.constructor.prototype === p // false

上面代码中,构造函数C的原型对象被改成了p,但是实例对象的c.constructor.prototype却没有指向p。所以,在改变原型对象时,一般要同时设置constructor属性。

1
2
3
4
5
C.prototype = p;
C.prototype.constructor = C;

var c = new C();
c.constructor.prototype === p // true

因此,推荐使用第三种Object.getPrototypeOf方法,获取原型对象。

Object.getOwnPropertyNames()

Object.getOwnPropertyNames方法返回一个数组,成员是参数对象本身的所有属性的键名,不包含继承的属性键名。

1
2
Object.getOwnPropertyNames(Date)
// ["parse", "arguments", "UTC", "caller", "name", "prototype", "now", "length"]

上面代码中,Object.getOwnPropertyNames方法返回Date所有自身的属性名。

对象本身的属性之中,有的是可以遍历的(enumerable),有的是不可以遍历的。Object.getOwnPropertyNames方法返回所有键名,不管是否可以遍历。只获取那些可以遍历的属性,使用Object.keys方法。

1
Object.keys(Date) // []

上面代码表明,Date对象所有自身的属性,都是不可以遍历的。

Object.prototype.hasOwnProperty()

对象实例的hasOwnProperty方法返回一个布尔值,用于判断某个属性定义在对象自身,还是定义在原型链上。

1
2
Date.hasOwnProperty('length') // true
Date.hasOwnProperty('toString') // false

上面代码表明,Date.length(构造函数Date可以接受多少个参数)是Date自身的属性,Date.toString是继承的属性。

另外,hasOwnProperty方法是 JavaScript 之中唯一一个处理对象属性时,不会遍历原型链的方法。

in 运算符和 for…in 循环

in运算符返回一个布尔值,表示一个对象是否具有某个属性。它不区分该属性是对象自身的属性,还是继承的属性。

1
2
'length' in Date // true
'toString' in Date // true

in运算符常用于检查一个属性是否存在。

获得对象的所有可遍历属性(不管是自身的还是继承的),可以使用for...in循环。

1
2
3
4
5
6
7
8
9
10
11
var o1 = { p1: 123 };

var o2 = Object.create(o1, {
p2: { value: "abc", enumerable: true }
});

for (p in o2) {
console.info(p);
}
// p2
// p1

上面代码中,对象o2p2属性是自身的,p1属性是继承的。这两个属性都会被for...in循环遍历。

为了在for...in循环中获得对象自身的属性,可以采用hasOwnProperty方法判断一下。

1
2
3
4
5
for ( var name in object ) {
if ( object.hasOwnProperty(name) ) {
/* loop code */
}
}

获得对象的所有属性(不管是自身的还是继承的,也不管是否可枚举),可以使用下面的函数。

1
2
3
4
5
6
7
8
9
10
function inheritedPropertyNames(obj) {
var props = {};
while(obj) {
Object.getOwnPropertyNames(obj).forEach(function(p) {
props[p] = true;
});
obj = Object.getPrototypeOf(obj);
}
return Object.getOwnPropertyNames(props);
}

上面代码依次获取obj对象的每一级原型对象“自身”的属性,从而获取obj对象的“所有”属性,不管是否可遍历。

下面是一个例子,列出Date对象的所有属性。

1
2
3
4
5
6
7
8
inheritedPropertyNames(Date)
// [
// "caller",
// "constructor",
// "toString",
// "UTC",
// ...
// ]

对象的拷贝

如果要拷贝一个对象,需要做到下面两件事情。

  • 确保拷贝后的对象,与原对象具有同样的原型。
  • 确保拷贝后的对象,与原对象具有同样的实例属性。

下面就是根据上面两点,实现的对象拷贝函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function copyObject(orig) {
var copy = Object.create(Object.getPrototypeOf(orig));
copyOwnPropertiesFrom(copy, orig);
return copy;
}

function copyOwnPropertiesFrom(target, source) {
Object
.getOwnPropertyNames(source)
.forEach(function (propKey) {
var desc = Object.getOwnPropertyDescriptor(source, propKey);
Object.defineProperty(target, propKey, desc);
});
return target;
}

另一种更简单的写法,是利用Object.getOwnPropertyDescriptors方法。

1
2
3
4
5
6
function copyObject(orig) {
return Object.create(
Object.getPrototypeOf(orig),
Object.getOwnPropertyDescriptors(orig)
);
}

JS Blob对象

Blob对象表示一个二进制文件的数据内容,比如一个图片文件的内容就可以通过Blob对象读写。它通常用来读写文件,它的名字是Binary Large Object(二进制大型对象)的缩写。它与ArrayBuffer的区别在于,它用于操作二进制文件,而ArrayBuffer用于操作内存。

浏览器原生提供Blob()构造函数,用来生成实例对象。

1
new Blob(array [, options])

Blob构造函数接受两个参数。第一个参数是数组,成员是字符串或二进制对象,表示新生成的Blob实例对象的内容;第二个参数是可选的,是一个配置对象,目前只有一个属性type,它的值是一个字符串,表示数据的MIME类型,默认是空字符串。

1
2
var htmlFragment = ['<a id="a"><b id="b">hey!</b></a>'];
var myBlob = new Blob(htmlFragment, {type : 'text/html'});

上面代码中,实例对象myBlob包含的是字符串。生成实例的时候,数据类型指定为text/html

下面是另一个例子,Blob保存 JSON 数据。

1
2
var obj = { hello: 'world' };
var blob = new Blob([ JSON.stringify(obj) ], {type : 'application/json'});

实例属性和实例方法

Blob具有两个实例属性sizetype,分别返回数据的大小和类型。

1
2
3
4
5
var htmlFragment = ['<a id="a"><b id="b">hey!</b></a>'];
var myBlob = new Blob(htmlFragment, {type : 'text/html'});

myBlob.size // 32
myBlob.type // "text/html"

Blob具有一个实例方法slice,用来拷贝原来的数据,返回的也是一个Blob实例。

1
myBlob.slice(start, end, contentType)

slice方法有三个参数,都是可选的。它们依次是起始的字节位置(默认为0)、结束的字节位置(默认为size属性的值,该位置本身将不包含在拷贝的数据之中)、新实例的数据类型(默认为空字符串)。

获取文件信息

文件选择器<input type="file">用来让用户选取文件。出于安全考虑,浏览器不允许脚本自行设置这个控件的value属性,即文件必须是用户手动选取的,不能是脚本指定的。一旦用户选好了文件,脚本就可以读取这个文件。

文件选择器返回一个FileList对象,该对象是一个类似数组的成员,每个成员都是一个File实例对象。File实例对象是一个特殊的Blob实例,增加了namelastModifiedDate属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码如下
// <input type="file" accept="image/*" multiple onchange="fileinfo(this.files)"/>

function fileinfo(files) {
for (var i = 0; i < files.length; i++) {
var f = files[i];
console.log(
f.name, // 文件名,不含路径
f.size, // 文件大小,Blob 实例属性
f.type, // 文件类型,Blob 实例属性
f.lastModifiedDate // 文件的最后修改时间
);
}
}

除了文件选择器,拖放 API 的dataTransfer.files返回的也是一个FileList对象,它的成员因此也是File实例对象。

下载文件

AJAX 请求时,如果指定responseType属性为blob,下载下来的就是一个Blob对象。

1
2
3
4
5
6
7
8
9
function getBlob(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onload = function () {
callback(xhr.response);
}
xhr.send(null);
}

上面代码中,xhr.response拿到的就是一个Blob对象。

生成 URL

浏览器允许使用URL.createObjectURL()方法,针对Blob对象生成一个临时 URL,以便于某些 API 使用。这个 URL 以blob://开头,表明对应一个Blob对象,协议头后面是一个识别符,用来唯一对应内存里面的Blob对象。这一点与data://URL(URL 包含实际数据)和file://URL(本地文件系统里面的文件)都不一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var droptarget = document.getElementById('droptarget');

droptarget.ondrop = function (e) {
var files = e.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
var type = files[i].type;
if (type.substring(0,6) !== 'image/')
continue;
var img = document.createElement('img');
img.src = URL.createObjectURL(files[i]);
img.onload = function () {
this.width = 100;
document.body.appendChild(this);
URL.revokeObjectURL(this.src);
}
}
}

上面代码通过为拖放的图片文件生成一个 URL,产生它们的缩略图,从而使得用户可以预览选择的文件。

浏览器处理 Blob URL 就跟普通的 URL 一样,如果Blob对象不存在,返回404状态码;如果跨域请求,返回403状态码。Blob URL 只对 GET 请求有效,如果请求成功,返回200状态码。由于 Blob URL 就是普通 URL,因此可以下载。

读取文件

取得Blob对象以后,可以通过FileReader对象,读取Blob对象的内容,即文件内容。

FileReader对象提供四个方法,处理Blob对象。Blob对象作为参数传入这些方法,然后以指定的格式返回。

  • FileReader.readAsText():返回文本,需要指定文本编码,默认为 UTF-8。
  • FileReader.readAsArrayBuffer():返回ArrayBuffer对象。
  • FileReader.readAsDataURL():返回 Data URL。
  • FileReader.readAsBinaryString():返回原始的二进制字符串。

下面是FileReader.readAsText()方法的例子,用来读取文本文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// HTML 代码如下
// <input type=’file' onchange='readfile(this.files[0])'></input>
// <pre id='output'></pre>
function readfile(f) {
var reader = new FileReader();
reader.readAsText(f);
reader.onload = function () {
var text = reader.result;
var out = document.getElementById('output');
out.innerHTML = '';
out.appendChild(document.createTextNode(text));
}
reader.onerror = function(e) {
console.log('Error', e);
};
}

上面代码中,通过指定FileReader实例对象的onload监听函数,在实例的result属性上拿到文件内容。

下面是FileReader.readAsArrayBuffer()方法的例子,用于读取二进制文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// HTML 代码如下
// <input type="file" onchange="typefile(this.files[0])"></input>
function typefile(file) {
// 文件开头的四个字节,生成一个 Blob 对象
var slice = file.slice(0, 4);
var reader = new FileReader();
// 读取这四个字节
reader.readAsArrayBuffer(slice);
reader.onload = function (e) {
var buffer = reader.result;
// 将这四个字节的内容,视作一个32位整数
var view = new DataView(buffer);
var magic = view.getUint32(0, false);
// 根据文件的前四个字节,判断它的类型
switch(magic) {
case 0x89504E47: file.verified_type = 'image/png'; break;
case 0x47494638: file.verified_type = 'image/gif'; break;
case 0x25504446: file.verified_type = 'application/pdf'; break;
case 0x504b0304: file.verified_type = 'application/zip'; break;
}
console.log(file.name, file.verified_type);
};
}
  • Copyrights © 2017-2023 WSQ
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信