前一阵舍友去面试,被问到JavaScript中的事件处理机制。暗自思忖,发现自己也没有深入的了解过。顺带连同常用的HTML元素大小和实际中用到的HTML5中的媒体元素简单整理在下面,方便之后回顾。

事件

JavaScript和HTML的交互是通过事件实现的。可以通过监听器订阅文档或窗口中的事件,在事件发生时执行特定的代码。这种属于设计模式中的观察者模式。

事件相关的API最早出现在IE4和NetScape Nivagator4(后面简称为网景)中。两种浏览器提供了相似却不同的API。在之后的DOM2级标准中对DOM事件进行了标准化。

事件流

事件流描述的是页面中接受时间的顺序。在这点上IE和网景采用了完全相反的两种处理思路。IE采用的是事件冒泡流,网景采用的是事件捕获流

事件冒泡(event bubbling)指从事件开始的最具体的元素接收,再逐步向上传递到最外层的节点,直到document。如下图(来自红宝书)展示的过程,在下面的文档中:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>Event bubbling</title>
</head>
<body>
<div>Click me</div>
</body>
</html>

如果div标签被点击,click事件会这样依次传递:<div> -> <body> -> <html> -> document。(不同浏览器实现细节上会有不同)

事件捕获(event capturing)则认为应该从父节点开始捕获事件直到事件目标。因此,同样的上面的例子,顺序将是:document -> <html> -> <body> -> <div>

目前很少有人使用事件捕获这种方式作为事件流。

DOM 事件流

“DOM2级标准”中规定事件流包括三个阶段,事件捕获处于目标时间冒泡。在实际的DOM事件流中,实际目标不会接受到事件。因此如下图展示的那样,捕获阶段停止在父目标<body>上,之后事件发生在目标上,并作为事件冒泡的一部分。然后,冒泡阶段发生,事件传回到文档。

事件处理程序

事件处理程序指用户指定响应事件的某种动作。它们都以’on’开头。HTML元素本身都可以使用与之同名的HTML特性。

DOM0级事件处理程序

DOM0级事件处理程序就是将一个函数直接赋值给一个事件处理程序属性。使用这种方法指定的事件处理程序被认为是元素的一种方法,从而其作用域为元素本身,即this指向引用元素。可以通过直接为事件处理程序属性赋值为null删除。

1
2
3
4
5
6
var btn = document.getElementById('button');
btn.onclick = function () {
alert(this.id); // "button"
}
//删除
btn.onclick = null;

所有浏览器都支持DOM0级事件处理程序。这么做的好处是可以保证浏览器兼容性,缺点是使得HTML和JavaScript紧密耦合,不利用后期维护。

DOM2级事件处理程序

伴随DOM2级标准提出,“DOM2级事件”提出了两种方法,用于绑定和解除事件处理程序:addEventListener()removeEventListener()。它接受3个参数:事件名事件处理程序对应的函数表示捕获阶段的布尔值

1
2
3
4
5
6
7
var btn = document.getElementById('button');
btn.addEventListener("click", function () {
alert(this.id);
}, false);
btn.addEventListener("click", function () {
alert(this.id + " again.");
}, false);

使用DOM2级方法绑定事件处理程序的一个优点是,可以添加多个程序到同一个标签上。使用DOM0级方法时则会覆盖上一次的事件处理程序。IE9及以上版本都支持DOM2级事件处理程序。

由于IE事件处理程序在IE8之前,是通过类似的attachEvent()detachEvent()方法。它的第一个参数是事件名(需要带上on),第二个参数是事件处理程序。通过这种方法绑定的处理程序都添加在冒泡阶段,且需要注意的是其中的this等于window对象。支持这种方式有IE和Opera。

因此,一个跨浏览器兼容的事件绑定和解绑应该是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var EventUtil = {
addHandler: function(element, type, handler) {
if (element.addEventListener) {
element.addEventListener(type, handler, false);
} else if (element.attachEvent) {
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
},
removeHandler: function(element, type, handler) {
if (element.removeEventListener) {
element.removeEventListener(type, handler, false);
} else if (element.detachEvent) {
element.detachEvent("on" + type, handler);
} else {
element["on" + type] = null;
}
},
}

===

元素大小与位置

这些属性方法并不属于“DOM2级样式”,但是却经常得到使用。目前所有主流浏览器都支持这些属性。它们大多都是只读的。

偏移量

偏移量描述元素在屏幕中占用的可用空间,由其宽高决定,包括内边距、滚动条和边框(不包括外边距)。有下面4个属性:

  • offsetHeight 元素垂直方向上的占用空间
  • offsetWidth 元素水平方向上的占用空间
  • offsetLeft 元素左边框距offsetParent元素左内边框的像素距离
  • offsetTop 元素上边框距offsetParent元素上内边框的像素距离

可以利用元素的offsetLeftoffsetLeft与其offsetParent对应属性相加直到根元素,获取到元素相对于页面的左偏移值或上偏移值。

客户区大小

客户区大小指元素内容和内边距占据的空间大小,不包括滚动条。clientWidth是元素内容宽度加左右内边距的宽度,clientHeight是元素内容高度加上下内边距的高度。

可以通过对body元素取值来获取当前浏览器视口的大小。

滚动大小

滚动大小包含滚动内容的元素大小。它有下面4个相关属性:

  • scrollHeight 没有滚动条时,元素内容的高度
  • scrollWidth 没有滚动条时,元素内容的宽度
  • scrollLeft 被隐藏在内容区域左侧的像素数,可以设置从而改变元素滚动位置
  • scrollTop 被隐藏在内容区域上侧的像素数,可以设置从而改变元素滚动位置

scrollHeight/scrollWidthclientHeight/clientWidth在不同浏览器下的表现行为并不相同,有的表示视口大小,有的表示元素内容区域大小。使用时可以取较大值。而另外两个属性scrollLeftscrollTop则通常用在document中,获取和滚动相关的属性。

确定元素大小

大多数主流浏览器为元素提供了getBoundingClientRect()方法,返回一个对象,包含leftrighttopbottom四个属性。给出了元素相对于视口的位置。

对不支持这个方法的浏览器,可以通过偏移量的相关属性获取。

===

媒体元素

HTML5出现前,提供富媒体内容的网站多采用Flash的方式保证浏览器兼容性。HTML5新增了两个标签<audio><video>。用于方便地嵌入音频和视频内容。同时,这两个标签也提供了实现常用功能的JavaScript API。允许为媒体创建自定义控件。

1
2
<video src="demo.mpg" id="foo">Video player is not available.</video>
<audio src="song.mp3" id="bar">Audio player is not available.</audio>

其中元素的src属性指定了加载的媒体文件,还可以通过widthheight属性指定播放器大小。controls属性意味浏览器应该显示UI控件用于操作媒体。标签中的内容用于在不支持时显示后备内容。

因为不同浏览器支持的媒体格式集并不完全相同,可以在标签下指定一或多个<source>元素,通过srctype属性指定来源和格式,视频标签下<source>type中甚至可以指定codecs表示解码器。目前现代浏览器(IE9+,对IE说的就是你)都支持这两个标签。

1
2
3
4
5
6
7
8
<video id="myVideo">
<source src="foo.mpg">
<source src="foo.webm" type="video/webm; codecs=vp8, vorbis">
</video>
<audio>
<source src="song.ogg" type="audio/ogg">
<source src="song.mp3" type="audio/mpeg">
</audio>

属性

<video><audio>提供了完善的JavaScript接口,下面是一些可能会用到的它们的属性。其中很多可以直接在标签元素上设置。

  • autoplay 取消或设置当前autoplay标识
  • controls 取消或设置当前controls标识,用于显示和隐藏浏览器内置控件
  • currentTime 获取已经播放的秒数
  • duration 获取媒体的总长度(秒数)
  • ended 获取媒体是否播放完成
  • loop 取消或设置媒体文件是否循环播放
  • muted 取消或设置媒体文件是否静音
  • paused 标识播放器是否暂停
  • playbackRate 取消或设置当前播放速度
  • readyState 标识媒体是否就绪,有0,1,2,3四种情况,表示不可用、可以播放当前帧、可以播放、加载完毕
  • src 媒体文件来源,可重写
  • volume 取消或设置当前音量,值为0.0到1.0

事件

这两个媒体元素还有许多事件,有的是媒体播放的结果,有的是用户操作的结果。

  • abort 下载中断
  • canplay 对应着readyState为2
  • canplaythrough 对应着readyState为3
  • ended 媒体播放完毕
  • error 下载过程网络错误
  • pause 播放暂停
  • play 媒体收到播放指令
  • playing 媒体开始播放
  • ratechange 播放速度改变
  • seeked 移动到新位置
  • seeking 正在移动进度条
  • volumnchange volumnmuted属性值改变
  • waiting 播放因下载未完成暂停

在如此丰富的属性和事件的帮助下,结合play()pause()方法,我们可以很容易构建一个自定义的媒体播放器。

1
2
3
4
5
6
7
8
9
10
11
<div class="player">
<div class="player__content">
<video id="video" src="movie.mov" poster="movie.jpg" width="400" height="200">
Video is not supported.
</video>
</div>
<div class="player__control">
<input type="button" value="Play!" id="video-play">
<span id="curtime">0</span>/<span id="duration">0</span>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var player = document.getElementById("video"),
btn = document.getElementById("video-play"),
curtime = document.getElementById("curtime"),
duration = document.getElementById("duration");

//更新播放时间
duration.innerHTML = player.duration;

//为按钮添加处理事件
EventUtil.addHandler(btn, "click", function (e) {
if (player.paused) {
player.play();
btn.value = "Pause!";
} else {
player.pause();
btn.value = "Play!";
}
});

//定时更新时间
setInterval(function () {
curtime.innerHTML = player.currentTime;
}, 250);

最后,不是所有浏览器都支持这两个标签的所有解码器,因此有一个API来检测浏览器是否支持某种解码器。通过canPlayType()方法,该方法接收格式/编解码器(如”audio/wav“)字符串,返回”probably”, “maybe”或是空字符串””。像下面这样:

1
2
3
if (audio.canPlayType("audio/mpeg")){
//进一步处理
}

CSS是一种“奇怪”的编程语言,用来描述网页的样式。使用起来很简单,却由于自身的缺陷(只有全局作用域、没有模块化)使得它很难像真正的编程语言那样,有软件工程的办法适用。

BEM

BEM是一个方法论,是一套使用CSS的惯例和约定,用于写出更具有维护性和重用性的CSS代码。BEM由Yandex公司提出,目前已被广泛采用。它和其他的CSS的规范如OOCSSSMACSS并不冲突。它们都用来提高CSS文件的可维护性。

规范

BEM的三个字母分别代表块(block)、元素(element)、修饰符(modifier)。根据官网的介绍:

  • Block代表一个独立的抽象的组件
  • Element代表依附于Block的后代,用来形成一个完整的block
  • Modifier代表Block或是Element的不同状态或版本,用来改变默认样式

另外:

  • 不要使用文档的层级结构
  • 在标签嵌套时,只使用一层嵌套,通过class名标注标签

其中Element命名时在Block后添加两个短横线--ModifierBlock后添加两个下划线__所有的CSS均绑定到标签的class上,确保样式的重用性。

1
2
3
.block{}
.block__element{}
.block--modifier{}

之所以采用两个短划线和下划线,是为了让用户自定义的块命名中可以含有单个短划线和下划线。

下面是官网的样例:

1
2
3
4
5
6
<form class="form form--theme-xmas form--simple">
<input class="form__input" type="text" />
<input
class="form__submit form__submit--disabled"
type="submit" />
</form>
1
2
3
4
5
6
.form { }
.form--theme-xmas { }
.form--simple { }
.form__input { }
.form__submit { }
.form__submit--disabled { }

怎么用

BEM正如上面介绍的那样,只是一套规范。在使用的时候会感觉类名有些冗长和奇怪。不过它带来的好处是很有价值的。

另外,没有必要在每个地方都使用BEM规范。对于独立的一条CSS样式,写成BEM格式的写法并没有必要。对于考虑使用BEM的人来讲,可能最重要的是从哪里到哪里使用BEM。

OOCSS

写CSS代码很简单,但是写出可维护的CSS代码比其他语言就要更难了。因此,大牛们提出了OOCSS、SMACSS这样的设计模式来让事情更容易。OOCSS(Object Oriented CSS)即面向对象的CSS,它的关键在于创建在页面中创建模块化可重用的对象(HTML和CSS的结合体)。

根据OOCSS之父Nicole Sullivan的说法,OOCSS重点在于:

  1. 独立文档结构与样式
  2. 独立文档容器和内容

使用容易理解的话来说,就是从HTML结构上解脱出来,增加CSS class的重复利用。

1
2
3
4
5
6
7
<nav class="nav--main">
<ul>
<li><a>.........</a></li>
<li><a>.........</a></li>
<li><a>.........</a></li>
</ul>
</nav>

上面的例子里,业务代码经常会将CSS选择器写成nav ul li a这样的写法。这么做过渡依赖原有的HTML文档结构。原有的文档结构改变时,CSS就必须跟着重构。因此,建议直接给a标签绑定class,或写成nav--main a的写法。

第二,减少使用id作为CSS的选择器。尽量使用class,类似OOP中的概念,抽出重复的部分,定义在一个class中。像下面这样,定义基本的类button,并通过button-defaultbutton-primary来拓展基本类。

1
2
<div class="button button-default">
<div class="button button-primary">

总结一下,OOCSS的优势在于它可以减少CSS的代码减少加载时间(当然的),语义化的类名增强逻辑性和SEO,CSS样式可以轻松拓展,

缺点在于它适合大型网站的开发,在小型项目中似乎用不到这种40米的长刀,同时没有巧妙地使用,创建的组件会适得其反增加,增加维护难度。

SMACSS

SMACSS(读作”smacks”)全称为Scalable and Modular Architecture for CSS。它也是CSS的框架规范之一,目标是让”keep CSS more organized and more structured, leading to code that is easier to build and easier to maintain(作者Jonathan Snook语)

SMACSS使用了一套5个类别来划分CSS,这种组织和结构规范了CSS写法,提高了CSS使用效率。

  • Base rules 类似与reset.cssnormalize.css的效果,为文档的标签设置默认样式,应该只包含单独的标签选择器
  • Layout rules 将文档分成诸如header,article,footer这样的各个部分,为布局中的每个部分设置样式
  • Module rules 页面中可重用部分的样式,在layout中出现多次,使用时避免出现标签选择器
  • State rules 用于描述element的不同状态,和基本规则组合使用。
  • Theme rules 类似与“皮肤”的概念,更改整个网站的主题。

其他

CSS Modules和上面的思路要来的不大一样。它着眼于解决作用域和模块依赖的问题,采取的做法是重写class名。在React,Vue中每个组件中的CSS样式就做了这样的处理,保证的模块间的CSS文件不相冲突。

在通过JavaScript绑定到特定class的标签上时,也造成了CSS维护的不变。必要的时候可以为HTML标签赋予专为JavaScript使用的类名。如:

1
<li class="nav--main__item js-nav--main__item"><a>whatever</a></li>

总结

CSS是一门看起来很简单的语言,但是它的简单性也提升了工程中的使用难度。为了增强它的可用性。许多名为”xxxCSS”的方法论和机制等被发明,类似Sass,SCSS,Compass,Less,stylus,BEM,SMACSS,OOCSS,ACSS,CCSS等。在使用CSS时,可以尝试使用上面的规范,遵守一些法则,以写出更pure的代码。

扩展阅读

对象

对象:无序属性的集合,其属性可以包含基本值、对象或者函数

ECMA-262

JavaScript中的对象和其他OO(Object-Oriented,面向对象)语言不大相同。它没有类的概念。所以根据ECMAScript的定义,对象无非就是一组键值对,类似于散列(Hash)表的概念,其中的值可以是基本类型也可以是对象或函数。

一个常见的对象像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var Stu = new Object();
Stu.name = 'shenlvmeng';
Stu.age = 23;
Stu.school = 'BUPT';
Stu.sayHi = function () {
alert('Hi');
};

//或者直接使用字面量
var Stu = {
name: 'shenlvmeng',
age: 23,
school: 'BUPT',
sayHi: function () {
alert('Hi');
}
}

对象属性

对象属性(property)是实现JavaScript引擎用的,由两对方括号包裹,表示是内部值,如[[Enumerable]]。ECMAScript中目前分两种属性:数据属性访问器属性

数据属性

  • [[Configurable]]: 表示能否通过delete删除属性,能否修改属性。默认为true。
  • [[Enumerable]]: 能否通过for-in语句循环返回属性。默认为true。
  • [[Writable]]: 如同字面意思,能否修改属性的值。
  • [[Value]]: 属性的数据值。

数据属性可以直接通过字面量来定义。可以通过ECMAScript 5中Object.defineProperty()方法修改对象默认属性。方法接受三个参数:属性所在对象,属性名,和一个描述符对象。其中描述符对象的属性必须是上述4个属性的子集。值得注意的是,修改configurable为false后,将无法将其变为true。此时,只能修改writablevalue

在使用Object.defineProperty()创建新属性时,若不指定,前三项的属性默认均为false

访问器属性

访问器属性不包含数据值(即value),取而代之的是getter和setter两个函数。不过它们也不是必须的。

  • [[Configurable]]: 表示能否通过delete删除属性,能否修改属性。默认为true。
  • [[Enumerable]]: 能否通过for-in语句循环返回属性。默认为true。
  • [[Get]]: 读取属性时调用的函数,默认为undefined
  • [[Set]]: 写入属性时调用的函数,默认为undefined

访问器属性不能直接定义。必须使用Object.defineProperty()方法定义。未指定getter或setter时,意味着属性不可读或不可写。强制读写时,在严格模式下会抛出错误,非严格模式下会返回undefined。

1
2
3
4
5
6
7
8
9
10
11
12
13
var person = {
_name: '', //`_`表示只能通过方法访问
nickname: '酱'
};
Object.defineProperty(person, 'name', function () {
get: function () {
return this._name;
},
set: function (newValue) {
this.nickname = newValue + '酱';
this._name = newValue;
}
});

访问器属性实际上使得数据劫持得以实现,即在存取属性值时执行预定义的操作。Vue的数据绑定就是这么来实现的(具体的实现方式见另外的博文)。不过支持Object.defineProperty()方法的浏览器需要IE9+,Firefox4+,Safari5+,Opera12+,Chrome。在这个方法前,通常使用两个非规范的方法__defineGetter__()__defineSetter__(),它们是对象的prototype中的的方法。

IE8其实也实现了`Object.defineProperty()`方法,不过存在诸多限制,只能对DOM对象使用。

ECMAScript 5还定义了一个Object.defineProperties()方法,用于为对象定义多个属性。用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var person = {};
Object.defineProperties(person, {
_name: {
writable: true,
value: 'shenlvmeng'
},
nickname: {
writable: true,
value: 'shenlvmeng酱'
},
name: {
get: function () { return this._name; },
set: function (newName) { this._name = newName; this.nickname = newName + '酱'; }
}
})

读取属性的特性

Object.getOwnPropertyDescriptor()方法可以读取指定对象属性的描述符。如果属性是数据属性,则返回对象包含configurable, enumerable, writable, value;如果属性是访问器属性,则返回对象包括configurable, enumerable, get, set。支持这个方法的浏览器包括IE9+,Firefox4+,Safari5+,Opera12+,Chrome。

创建对象

最简单的创建方法是通过Object构造函数和字面量的形式,如第一段代码里展示的那样。不过很显然,这么做有点蠢。在创建大量对象的时候,会产生成吨的重复代码。于是就产生了下面这些工程手段。

工厂模式

既然会产生重复代码,那么将这些重复代码封装成函数不就行了么。工厂模式就这么出现了。

1
2
3
4
5
6
7
8
9
10
11
12
function createPerson (name, age, school) {
var o = new Object(); //用字面量的形式亦可
o.name = name;
o.age = age;
o.school = school;
o.sayHi = function () {
alert(this.name + ' say hi.');
}
return o;
}
var p1 = createPerson('shenlvmeng', 23, 'BUPT');
p1.sayHi();

这么做解决了创建相似对象的问题,不过并没解决对象识别的问题。而且可以复用的函数创建了很多次。

构造函数模式

ECMAScript中的构造函数可用来创建特定类型的对象,从而解决了对象识别的问题。上面的例子用构造函数模式重写如下:

1
2
3
4
5
6
7
8
9
10
function Person (name, age, school) {
this.name = name;
this.age = age;
this.school = school;
this.sayHi = function () {
alert(this.name + ' say hi.');
}
}
var p1 = new Person('shenlvmeng', 23, 'BUPT');
p1.sayHi();

通过构造函数创建实例时,需要使用new操作符。创建的步骤如下:

  1. 创建一个空对象
  2. 将构造函数的作用域(this)赋给空对象
  3. 执行函数代码
  4. 返回这个对象
1
2
3
4
p1.constructor == Person //true
p1 instanceof Person //true
p2 = new Person('weii', 23, 'BUPT');
p1.constructor == p2.constructor //true

返回对象会自带constructor属性,指向构造函数本身。可以用来区分对象类型。不过使用instanceof操作符要更可靠些。因为构造函数本身也是函数,只是用来构造对象而已。为了和其他函数区分开,通常命名首字母使用大写字母。这么做是为了避免一个问题:当不使用new操作符调用构造函数时,函数作用域并不会指向新创建的函数,因此this实际上是进入函数时的全局作用域,从而会污染全局作用域。

可以看到一个问题,使用构造函数模式并未解决函数复用的目标,同样的函数创建了许多次,通过p1.sayHi == p2.sayHi //false即可发现。为了复用函数,可以把函数放在构造函数外。

1
2
3
4
5
6
7
8
9
function Person (name, age, school) {
this.name = name;
this.age = age;
this.school = school;
this.sayHi = sayHi;
}
function sayHi () {
alert(this.name + ' say hi.');
}

这么做却又带来了副作用,全局作用域中定义的函数,实际上只能被函数调用。且不同对象的方法作为全局函数混杂在一起,封装性很差劲。

原型模式

原型(prototype)的设计解决了这个大问题。每个函数都有一个prototype属性。这个属性是一个指针,指向一个对象,包含由该函数构造对象共享的属性和方法。从而,不必在构造函数中定义所有实例共有的属性和方法。

1
2
3
4
5
6
7
function Person () {};
Person.prototype.name = 'shenlvmeng';
Person.prototype.age = 23;
Person.prototype.school = 'BUPT';
Person.prototype.sayHi = function () {
alert(this.name + ' say hi.');
}

原型对象

无论何时,只要创建了一个新函数,就会相应的为该函数创建一个prototype属性,指向该函数的原型对象。默认情况下,所有原型对象都会有一个constructor属性指向prototype属性所在函数。

创建自定义的构造函数时,原型对象默认只会有constructor属性,其他的方法都继承自Object。在调用构造函数创建对象实例后,实例的内部都有一个指针[[prototype]]指向构造函数的原型对象。这个指针是内部的,但在FF,Safari,Chrome中,有__proto__属性可以访问。

是不是听起来有点晕,下面的图(来自红宝书)形象地说明了上面这些关系。

虽然无法访问到[[prototype]]属性,但是可以通过prototype的isPrototypeOf()方法确认对象和原型的对应关系,或ES5中的Object.prototype()得到[[prototype]]的值。

代码在尝试读取对象属性的时候,会先从实例本身属性开始,若找到同名属性,则返回值;如果没有找到,则继续搜索指针指向的原型对象。例如,因为原型对象中包含constructor属性,所以实际上对象实例也都可以访问到constructor这个属性。在为对象实例添加属性时,这个属性会屏蔽(不是覆盖)原型对象中的同名属性。通过对象的hasOwnProperty()方法,可以检测属性来自实例还是原型对象。

in 操作符

in操作有两种使用方法,单独使用和配合for-in循环使用。前者在对象可以访问给定属性时返回true

1
2
3
4
5
alert(p1.hasOwnProperty('name')); //false
alert('name' in p1); //true
delete p1.name;
alert(p1.hasOwnProperty('name')) //false
alert('name' in p1); //true name属性来自原型

for-in循环可以访问所有对象可以访问的、可枚举(enumerable)的属性。既包含实例自身属性,也包含原型中的属性。下面是个简单的例子。

1
2
3
4
var body = document.body.attributes;
for (var prop in body) {
console.log(prop + ': ' + body[prop]);
}

使用最新的Object.keys()可以获取所有键名,使用Object.getOwnPropertyNames()方法可以返回所有实例属性,而不论是否可枚举。

重写prototype

上面原型模式里一个一个属性为prototype赋值的方法略显重复,可以直接通过对象字面量的形式重写整个原型对象。但是需要注意的是,重写后的原型对象中的constructor属性继承自Object。此时只能通过instanceof操作符确定对象类型了。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person () {};
Person.prototype = {
name: 'shenlvmeng',
age: 23,
school: 'BUPT',
sayHi: function () {
alert(this.name + ' says hi.');
}
}
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
});

因为在原型中查找值是一次搜索的过程,我们对原型对象所做的修改都会立即在对象中体现出来。

问题

原型对象中的所有属性和方法都在实例中共享,这在某些场景下可能并不是我们想要的。比如,不同的Person间应该总有些自己的属性。这些应该在构造函数中体现出来。

组合使用构造函数和原型模式

如上面所说,将实例的属性放在构造函数中,将共有属性和方法放在原型对象中,可以最大程度减少无谓的内存占用。因此,这也是目前使用最多的创建自定义类型的方法。

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

Person.prototype = {
constructor: Person,
sayHi: function () {
alert(this.name);
}
}
p1 = new Person('shenlvmeng', 23, 'BUPT');
p2 = new Person('weii', 29, 'BUPT');
alert(p1.sayHi == p2.sayHi);

其他

寄生构造函数和稳妥构造函数在有些时候也用来构造对象。前者仅仅将创建对象的代码封装起来,通过new操作符调用,内部不使用this。这种情况下,对象和构造函数实际上没有关系,因此不能使用instanceof操作符确定类型。

稳妥构造函数有Douglas Crockford提出,利用了闭包的特点,保证了内部数据的安全性和封装性。函数内部没有公共属性,也不引用this对象。

1
2
3
4
5
6
7
8
9
function Person (name, age, school) {
var o = new Object();
o.sayHi = function () {
alert(name + ' says hi.');
}
return o;
}
var p1 = Person('shenlvmeng', 23, 'BUPT');
p1.sayHi();

继承

传统面向对象语言支持继承接口和继承实现。前者只继承签名,实现接口;后者继承实现的方法。ECMAScript只支持后者(ECMAScript中没有函数签名)。其实现主要利用原型链。

原型链

ECMAScript中最基本的继承方式,它的思想在于通过原型让一个自定义类型用于另一个类型的属性和方法。具体实现上,只用将一个对象实例作为另一个对象的原型对象即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//父类
function SuperType (name) {
var name = name;
}
SuperType.prototype.sayName = function () {
alert(this.name);
}
//子类
function Subtype (age) {
this.age = age;
}
//实现继承
SubType.prototype = new SuperType('shenlvmeng');
SubType.prototype.sayAge = function () {
alert(this.age);
}

var o = new Subtype(23);
o.sayAge();

上面的关键一步就是将父类的实例赋给子类的prototype,从而子类的所有实例可以共享父类的所有属性和方法。在上面的步骤中,SubType默认的原型被替换为SuperType的实例,所以实际上,SubTypeconstructor属性成为了SuperType。这是因为,再找不到属性或方法时,搜索过程会一步一步向原型链末端前进,直到Object。

下面是SuperType和SubType构造函数以及原型对象间的关系。

可以发现,使用原型链的一个问题是,父类的实例属性变成了子类的原型属性,分享在子类所有实例间。很显然是不合理的。第二,创建子类型时,为超类构造函数传递的参数将作为原型对象影响整个子类对象实例。

借用构造函数

这种方法的思路是,在子类构造函数调用父类的构造函数,并将执行环境绑定在子类环境中。这样可以方便地向父类构造函数中添加自己的参数而不影响其他的子类实例。

1
2
3
4
5
6
7
8
9
10
function SuperType (newFriend) {
this.friends = ['Alice', 'Bob', 'Caley'].push(newFriend);
}
function SubType (newFriend) {
SuperType.call(this, newFriend);
this.newFriend = newFriend;
}
var o = new SubType('Dude');
alert(o.friends); //[Alice, Bob, Caley, Dude]
alert(o.newFriend); //Dude

这么做的缺点也很明显,函数的定义都需要在构造函数中重新写一遍。因此,这种技术很少单独使用。

组合继承

组合继承发挥了上面两者的长处,通过原型链继承了原型属性和方法,通过构造函数实现对父类实例属性的继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SuperType (newFriend) {
this.friends = ['Alice', 'Bob', 'Caley'].push(newFriend);
}
function SubType (newFriend) {
//继承属性
SuperType.call(this, newFriend);
this.newFriend = newFriend;
}
//继承方法
SubType.prototype = new SuperType();
//修改构造函数,新添新方法
SubType.prototype.constructor = SubType;
SubType.prototype.sayHi = function () {
alert(this.newFriend + ' says hi.');
}

组合继承是JavaScript中最常用的集成模式。不过它实际上调用了两次父类的构造函数,因此后面介绍的寄生组合式继承方法又对此进行了优化。

原型式继承

Douglas CrockFord在2006年提出可以通过原型基于已有对象创建新对象,还不必创建自定义类型。函数大概像下面这样

1
2
3
4
5
function object(o) {
function F() {}
F.prototype = o;
return new F();
}

可以看到,本质上,object函数只是对传入的对象o进行了一层浅复制。从而其中的引用类型将会在返回的对象间共享。ECMAScript 5对这种通过对象创建对象的原型式继承方式进行了规范,新增了Object.create()方法。方法接受两个参数,一个作为新对象原型,一个作为新对象新增的额外属性。

寄生式继承模式和原型式继承很类似。它将创建一个仅用来封装继承过程的函数,在函数内部处理增强对象的过程。功能和Object.create()类似。但是这么做不能做到函数复用,从而效率会降低,用在简单的场景下。

寄生组合式继承

这种模式解决了组合式继承的弊端——调用两次父类构造函数。其中SubType构造函数中的调用作为实例的属性将覆盖原型中的同名属性。寄生组合式继承的关键在于:不必为了指定子类型的原型而调用父类的构造函数,我们不过是要一个父类原型的副本而已。因此,可以得到下面这样的基本模式:

1
2
3
4
5
function inheritPrototype (subtype, supertype) {
var prototype = object(superType.prototype) //或Object.create(superType.prototype)
prototype.constructor = subtype;
subType.prototype = prototype;
}

上面的三步分别是创建对象,添加constructor属性,替换子类原型。从而在继承的过程中只调用了1次SuperType函数。同时原型链保持了不变。因此insanceof和isPrototypeOf()可以正常使用。寄生组合式继承是最理想的继承模式。就像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SuperType (newFriend) {
this.friends = ['Alice', 'Bob', 'Caley'].push(newFriend);
}
function SubType (newFriend) {
//继承属性
SuperType.call(this, newFriend);
this.newFriend = newFriend;
}
//继承方法
inheritPrototype(SubType, SuperType);
//新增新方法
SubType.prototype.sayHi = function () {
alert(this.newFriend + ' says hi.');
}

总结

总结一下,ECMAScript支持面向对象编程,但没有类和接口的概念。对象和原型的定义和关系比较松散。

在创建对象上,有工厂模式原型对象构造函数利用闭包几种方式可选,它们也可以组合使用。

在实现继承上,可以借助原型链、构造函数和寄生组合式的模式实现比较严格的继承,原型式和寄生式模式用于不那么严格的对象间继承。

函数

函数是JavaScript中最有特色同时又容易让人困扰的特性。定义函数的方式有两种:函数声明和函数表达式。

1
2
3
4
5
6
7
8
9
//函数声明
function foo (arg0, arg1) {
//函数体
}

//函数表达式
var bar = function () {
//函数体
}

在非IE浏览器中,function都有非标准的name属性,属性值为function后的标识符或表达式的接收变量名。在函数声明里有一个重要特征——函数声明提升(function declaration hoisting)。这意味着函数声明可以放在调用它的语句后。

1
2
3
4
sayHi();
function sayHi () {
alert("Hi!"); // "Hi!"
}

而函数表达式则不能这样使用,因为变量声明提升会将函数名提升,下面的代码将导致错误。

1
2
3
4
sayHi();
var sayHi = function () {
alert("Hi!"); // Error!
}

正确理解函数声明提升将会避免很多潜在的错误,或者干脆养成好习惯——定义在前,调用在后

递归

递归函数是一个函数通过调用自身得到的。如

1
2
3
4
5
6
7
function factorial (num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num-1);
}
}

这是一个经典的递归阶乘函数。不过当我们不知道函数名或者函数是匿名函数时,可以通过arguments.callee来调用自身。
argument.callee是一个指向正在执行的函数的指针。

在ES5的严格模式下,`arguments.callee`特性是禁止使用的。它将影响解释器的优化和运行效率。

闭包

闭包几乎是前端面试必考的一个知识点。它的存在是JavaScript中作用域链带来的特性。闭包是指有权访问另一个函数
作用域中变量的函数。创建闭包最常用的方式就是在函数内部创建另一个函数。就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
function fatherFunction (propertyName, value) {
var sum = value;
return function (object1, object2) {
var a = object1[propertyName],
b = object2[propertyName];
if (a + b > value) {
return 1
} else {
return 0;
}
}
}

可以看到,在返回的函数中引用了外部函数的变量propertyNamesum。即使这个函数已经返回,只要内部函数还存在,那么这两个变量就仍然可以访问。这就是闭包的直观体现。

解释闭包就要先理解JS中的作用域链。执行环境是JS中的一个关键概念。它定义了变量或函数可以访问的数据。全局执行环境是最外层的执行环境。根据ECMAScript实现宿主的不同,全局执行环境也各不相同。某个执行环境中的代码执行完毕后,环境被销毁,包括其中的所有变量和函数定义。JS中的每个函数都有自己的执行环境。执行流进入一个函数时,函数环境就被推入到环境栈中,待执行完毕后出栈。在执行环境中的代码时,会创建变量对象的作用域链,由当前的活动对象到外部环境变量对象一直到全局执行环境中的变量对象。内部环境可以通过作用域链访问所有外部环境,但是外部环境不能访问内部环境中的变量和函数。

现在回过头看闭包。在函数执行过程中,为了读取和写入变量值,需要保存整个作用域链。因此,在fatherFunction中创建的匿名函数的作用域链实际上包含了fantherFunction()的活动对象(输入参数和变量)以及全局变量对象。在fatherFunction()返回时,匿名函数的作用域链仍然引用着它的活动对象,使其并不会被销毁,直到匿名函数被销毁。

1
2
3
4
5
6
// 创建函数
var compare = fatherFunction("value", 0);
// 调用函数
var res = compare({value: 2}, {value: -1});
// 解除匿名函数的引用,释放内存
compare = null;
由于闭包会携带包含它的函数作用域,过度使用闭包会导致内存占用过多。忘记解除匿名函数引用还会导致内存泄漏。

闭包与变量

闭包可以取得父级函数的变量的最终值,因此配合for循环这样的结构就容易发生意外,就像下面的函数:

1
2
3
4
5
6
7
8
9
10
function bindClickFunctions () {
var buttons = document.getElementsByTagName('button');
// 让我们假设length是20
for (var i = 0, len = buttons.length; i < len; i++) {
buttons[i].onclick = function () {
alert(i);
}
}
return;
}

上面的函数会为所有的按钮绑定点击事件,不过效果却并不像预想中那样,不同的button弹出不同的值。结果是所有的button在点击后弹出的都是20。这是因为所有的匿名函数都使用着同一个外部函数的活动对象。可以通过在创建一层闭包来达到预期的目的。

1
2
3
4
5
6
7
8
9
10
11
function bindClickFunctions () {
var buttons = document.getElementsByTagName('button');
for (var i = 0, len = buttons.length; i < len; i++) {
buttons[i].onclick = function (i) {
return function () {
alert(i);
}
}(i);
}
return;
}

我们在每层循环中创建了一个匿名函数,匿名函数包含一个输入参数i,再分别保存在内部函数的作用域链中,就可以使闭包间引用的i互不干扰了。

块级作用域

JavaScript中是没有块级作用域的。不过可以利用匿名函数的作用域模拟一个块级作用域出来。在其中定义的私有变量也不必担心与其他作用域的变量名相冲突。这种用法很常用于最外层的封装,用于隐藏代码中的变量,在一定程度上保证安全。

1
2
3
4
(function(){
var foo = "You can see me, but you cannot touch me."
alert(foo);
})()

私有变量

同样的,JavaScript中是没有私有成员的概念的。但是,利用闭包可以制造出私有变量。原理是,利用函数作用域隐藏其中的变量甚至输入参数,通过返回的闭包操作这些“私有”变量。如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
function Stu (name) {
this.getName = function () {
return name;
};
this.setName = function (value) {
name = value;
}
}
var stu = new Stu("Shenlvmeng");
alert(stu.getName());
stu.setName("Weii");
alert(stu.getName());

这里只是一个很简单的展示,红宝书中还介绍了模块模式和增强模块模式,利用闭包的特点实现了单例的构造和特权方法。下面对上面的Stu函数进行改造,可以使得所有通过Stu()构造的对象都有相同的公有变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function(){
var name = '';

Stu = function (value) {
name = value;
};

Stu.prototype.getName = function () {
return name;
};

Stu.prototype.setName = function (value) {
name = value;
};
})();

var stu1 = new Stu('shenlvmeng');
alert(stu1.getName());
var stu2 = new Stu('weii');
alert(stu2.getName()); // "weii"
alert(stu1.getName()); // "weii"

DOM

DOM(文档对象模型)是针对HTML和XML文档的一个API,描绘了层次化的节点树。开发者可以借助DOM对页面的某一部分进行添加、移除、修改。DOM来源于网景和微软提出的DHTML。1998年10月,DOM 1级规范称为W3C的标准,为文档查询和改动提供了接口。各大主流浏览器都完善地实现了DOM。


DOM把HTML和XML文档描述成一个多层节点构成的结构。每个节点有都有自己的特点、数据和方法。

文档节点是每个文档的根节点。HTML中<html>元素是文档节点唯一的子节点,又称为文档元素。每一个标签都通过树中的一个节点表示,HTML元素表示为元素节点,特性通过特性节点表示,文档表示为文档节点,如此这样,总共有12中节点类型。

Node

DOM 1级中定义了Node类型。JavaScript中的所有节点类型都继承于Node类型。每个节点都有一个nodeType属性。常用的取值如下:

  • Node.ELEMENT_NODE(1)
  • Node.ATTRIBUTE_NODE(2)
  • Node.TEXT_NODE(3)
  • Node.COMMENT_NODE(8)
  • Node.DOCUMENT_NODE(9)
  • Node.DOCUMENT_TYPE_NODE(10)
  • Node.DOCUMENT_FRAGMENT_NODE(11)

为了确保浏览器兼容性,建议还是将nodeType属性和数字值进行比较,减少使用常量。因为IE没有公开Node类型的构造函数。

1
2
3
if (someNode.nodeType == 1) { // 元素节点
alert("This is an element node.");
}

除了nodeType,节点类型还有nodeNamenodeValue两个属性。它们的取值取决于节点的类型。后面的介绍中也会提到。

节点关系

在父子关系上,每个节点有一个childNodes属性,保存着NodeList对象。这个对象有length属性,也可以通过方括号访问其中的值,也可以通过item()方法访问,但它并不是Array的实例。且DOM结构的变化会实时地反映到这个NodeList对象中。减少使用NodeList可以避免它带来的时延

使用firstChildlastChild属性可以分别访问到列表中第一个和最后一个节点。同时,每个节点都有一个parentNode属性,指向文档树的父节点。

在兄弟关系上,使用nextSiblingpreviousSibling分别可以访问到下一个和上一个兄弟节点。hasChildNodes()在节点包含多个子节点时返回true。

最后,所有节点都有ownerDocument属性,指向整个文档的文档节点。

节点操作

appendChildinsertBefore分别用于在childNodes末尾和某位前插入节点。并返回插入的DOM节点。需要注意的是,如果插入的节点来自于DOM中,则节点会从原来的位置删除

1
2
3
var returnedNode = someNode.appendChild(newNode);
var anotherNode = someNode.insertBefore(newNode, null); // 插入到最后一位
anotherNode = someNode.insertBefore(newNode, someNode.firstChild); // 插入到第二位

replaceChild用于替换节点,cloneChild用于复制节点,cloneChild方法接受一个布尔值参数,表示是否执行深复制。在参数为true时执行深复制,否则执行浅复制。

需要留意的是,`cloneChild`不会复制DOM节点中的JavaScript属性,如事件处理程序。IE则会复制事件处理程序。为了保证一致性,建议在复制前移除事件处理程序。

最后,节点的normalize方法可以删除节点后代中的空文本节点,合并相邻的文本节点。

Document

JavaScript中用Document类型表示文档,浏览器中的document表示整个页面。它是window对象的一个属性。因此可以作为全局对象来访问。

它的nodeType为9,nodeName为"#document",子节点可以是一个DocumentType或Element类型的节点。在HTML中可以通过documentElement属性便捷地得到子节点<html>。所有浏览器都支持这个属性。document.body属性也都被支持。

除了<html>,另一个可能的子节点是DocumentType,即<!DOCTYPE>标签。浏览器对它的支持差异很大。同样的还有<html>元素外的注释。

document还有下面4个独特的HTML属性:

  • title包含网页的标题,属性的修改直接对应<title>元素
  • URL表示页面的URL,只读
  • domain表示页面的域名,可修改
  • referer保存链接到当前页面的URL,只读

其中修改domain可以用来访问同一父级域名下的iframe中的资源。

为了保证安全,域名修改只能由“紧绷”变“松散”,而不能反过来。

查找元素

DOM 1级标准只有getElementById()getElementsByTagName()getElementsByName()三种方法。具体功能不必再提。它们返回的是一个HTMLCollection对象。这个对象和NodeList很类似。同样可以通过方括号访问其中的子元素。不过HTMLCollection额外提供namedItem()方法,通过name属性值访问其中的子元素。

除此以外,document对象还有一个特殊的集合,它们也都是HTMLCollection对象。如document.anchorsdocument.formsdocument.imgsdocument.links

其他

document.implementation属性用来检测浏览器对DOM实现的程度,它有一个hasFeature()

write()writeln()open()close()方法可以将输出流写入到网页中。

Element

除了document外,Element类型应该是HTML中最常用的类型了。它的nodeType为1,nodeName为元素的标签名,tagName属性也可以用来访问元素标签名。

所有HTML元素都由HTMLElement的子类型表示。所有的HTML元素都具有下面的一些标准特性:

  • id:元素的唯一标识符
  • title:有关元素的附加说明信息
  • lang:元素的语言代码
  • dir:语言的方向
  • className:与元素的class对应

这些都可以通过访问元素的属性得到。如:

1
<div id="myDiv" class="foo" title="text" lang="en" dir="ltr"></div>

元素的信息可以通过下面的方式得到,同样,这些属性可以直接赋予新值来修改。

1
2
3
4
5
6
var div = document.getElementById("myDiv");
console.log(div.id);
console.log(div.class);
console.log(div.title);
console.log(div.lang);
console.log(div.dir);

未完待续

0%