JavaScript OOP
1. JavaScript 数据类型
JavaScript 是一种弱类型的动态语言:
var foo = 42; // foo is a Number
foo = "bar"; // foo is a String
foo = true; // foo is a Boolean
JavaScript 的基本类型有 boolean, null, undefined, number, string,基本类型是不可变的,除此之外都是对象类型。其中 boolean, number, string 有对应的包装对象,可以通过内置的构造函数 Number()
、String()
、Boolean()
来生成包装对象。
包装对象带有一些常用的属性和方法,比如数字对象就带有 toFixed()
和 toExponential()
等方法,字符串对象带有 substring()
、charAt()
和 toLowerCase()
等方法及 length 属性。通过 typeof
运算符可以查询变量类型。
JavaScript 对象
JavaScript 对象可以看作属性的集合,每个属性是一组键值映射,其中键是字符串,值可以为任意类型(等价很多语言中的关联数组、哈希表、字典类型)。通过 {}
可以声明对象直接量,另外两种创建对象的方法是使用构造函数及 ES5 定义的 Object.create
方法。
从 ES5 开始,有三种原生方法用于列举对象的属性:
for...in
循环:依次访问对象及其原型链中所有可枚举的属性Object.keys(o)
:返回对象 o 自身包含(不包括原型中)的所有属性的名称的数组Object.getOwnPropertyNames(o)
:返回一个数组,包含对象 o 所有拥有的属性(不论是否可枚举)的名称
Chrome/Firefox 控制台中使用 console.dir
打印对象全部属性的键值。
通过 delete 操作符可以删除变量及属性,设置属性为 undefined 或 null 并不能真正删除属性、而仅仅移除了属性和值的关联:
var obj = {bar: 1, foo: 2, baz: 3};
obj.bar = undefined;
obj.foo = null;
delete obj.baz;
for (var i in obj) {
if (obj.hasOwnProperty(i)) {
console.log(i, '' + obj[i]);
}
}
// bar undefined
// foo null
JavaScript 中对象是引用类型,因此将两个属性相同的对象对比会返回 false. 可以通过一些第三方库提供的 API 实现对象深度对比,Node 中的 assert 模块提供了 assert.deepEqual(actual, expected, [message])
方法。
var a = {name: 'a'};
var b = {name: 'a'};
a == b // false
a === b // false
var c = a;
c === a // true
JavaScript 函数
JavaScript 函数是一个附带可被调用功能的常规对象。
// TODO
2. JavaScript prototype
// TODO
3. 基于原型的类定义
类是对象的模板,定义了同一组对象共用的属性 ( property ) 和方法 ( method ), JavaScript 中没有类的原生定义,实例对象是从原型对象 ( prototypical object ) 调用构造函数 new 生成(ES5 提供了 Object.create
方法,同样用于创建一个拥有指定原型和若干指定属性的对象)
// Constructor
var Animal = function(name) {
// property
this.name = name;
};
Animal.prototype.home = 'Earth';
Animal.prototype.breath = function() {
console.log('breath');
};
创建一个实例对象
var cat = new Animal('susan');
console.log(cat.name); // 'susan'
console.log(cat.home); // 'Earth'
cat.breath(); // 'breath'
// ES5 Syntax
Object.create(proto, [ propertiesObject ])
两种对象创建方法的使用选择,参考:http://stackoverflow.com/questions/2709612/using-object-create-instead-of-new
判断一个原型对象和实例之间的关系
Animal.prototype.isPrototypeOf(cat)
cat.constructor === Animal
判断一个属性是本地属性还是继承自原型对象
cat.hasOwnProperty('name') // true
cat.hasOwnProperty('home') // false
in
运算符判断一个对象是否具有某属性(不区分本地属性 / 原型对象属性)
'name' in cat // true
'home' in cat // true
在基于类的对象系统中,往往无法在运行时动态添加属性,而 JavaScript 允许动态地向单个对象或整个对象集中添加或移除属性。
var data = {
'prop1': 1, 'prop2': 2
};
data['prop3'] = 3;
data.prop4 = 4;
Object.defineProperty(data, 'prop5', {
value: 6,
writeable: true,
enumerable : true,
configurable : true
});
3. 基于原型的类继承
JavaScript 中任何对象可以作为另一个对象的原型对象,以此来共享属性。当读取一个对象的属性时,首先在本地对象中查找,进而沿着原型继承链向上查找,直到 Object.prototype
给子类定义一个构造函数,将 prototype 对象指向父类的实例:
var Dog = function() {};
Dog.prototype = new Animal();
Dog.prototype.wag = function() {
console.log('wag tail');
};
通过 Object.create
实现类继承:
// Shape - superclass
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 - subclass
function Rectangle() {
Shape.call(this); // call super constructor
}
Rectangle.prototype = Object.create(Shape.prototype); // inherit
// Multiple inheritance
function RectA() {
Shape.call(this);
Rectangle.call(this);
}
RectA.prototype = Object.create(Shape.prototype); // inherit
mixin(Shape.prototype, Rectangle.prototype); // mixin
mixin
的行为是将超类原型上的函数拷贝到子类原型上,类似 jQuery.extend
5. 函数调用(apply, call, bind)
使用 apply()
和 call()
允许在一个对象上调用另一个对象上的方法。两者区别在于传入函数的参数形式 ( 助记:“A for array and C for comma.” )
Syntax:
fun.apply(thisArg, [argsArray])
fun.call(thisArg[, arg1[, arg2[, ...]]])
使用 call()
调用父构造函数
function Product(name, price) {
this.name = name;
this.price = price;
}
function Food(name, price) {
Product.call(this, name, price);
this.category = 'food';
}
使用 apply()
寻找一个数组中最大 / 最小值
var numbers = [5, 6, 2, 3, 7];
var max = Math.max.apply(null, numbers);
var min = Math.min.apply(null, numbers);
另一个常用场景是,将一个函数调用委托 ( proxy ) 给另一个调用,以及修改传入参数。常见于一些框架实现:
var logger = {
log: function () {
var args = Array.prototype.slice.call(arguments);
args.unshift('[' + new Date().toLocaleString() + ']');
console.log.apply(console, args);
}
};
这里的 arguments
变量是当前函数作用域内的参数数组,注意 arguments
对象不是一个真正的 Array, 没有数组特有的属性和方法(除了 length),例如它没有 pop()
方法;通过 Array.prototype.slice.call(arguments)
将其转换为数组。
ES5 中加入了 bind()
函数以控制调用的作用域,bind()
会创建一个绑定函数,与被调函数有相同的函数体。确保函数是在指定 this 所在的上下文中调用
var user = {
data: [
{ name: "Alex", age: 27 },
{ name: "Bob", age: 23 }
],
clickHandler: function(event) {
var idx = Math.floor(Math.random() * 2); // random between 0, 1
$("input").val(this.data[idx].name + " " + this.data[idx].age);
}
}
$("button").click(user.clickHandler); // Error
$("button").click(user.clickHandler.bind(user));
bind 配合 setTimeout: 使用 window.setTimeout()
时,this 关键字会指向 window(或全局)对象。当使用类的方法时,需要 this 引用类的实例,你可能需要显式地把 this 绑定到回调函数以便继续使用实例
function LateBloomer() {
this.petalCount = Math.ceil(Math.random() * 12) + 1;
}
// Declare bloom after a delay of 1 second
LateBloomer.prototype.bloom = function() {
window.setTimeout(this.declare.bind(this), 1000);
};
LateBloomer.prototype.declare = function() {
console.log('I am a beautiful flower with ' +
this.petalCount + ' petals!');
};
var flower = new LateBloomer();
flower.bloom();
6. 定义类的私有函数
将类的属性包装到一个匿名函数,并创建局部变量,并定义为类的属性
var Person = function() {};
(function() {
var findByName = function(name) {
console.log('Anonymous ' + name);
}
Person.find = function(name) {
if (typeof name === 'string') {
return findByName(name);
}
}
})();
JavaScript 变量作用域
JavaScript 中变量作用域是函数级的,没有块级作用域。例如:
function foo() {
for (var i = 0; i < 10; i++) {
var value = "hello world";
}
console.log(i); // 10
console.log(value); // hello world
}
因此要避免就近变量声明,更多放在函数体顶部。
事件和监听
1. 事件类型 / 事件对象
如果一个节点和它的父节点都绑定相同类型的回调,事件触发时按照执行顺序可以分类:
- 事件捕捉(capturing):从顶级父节点开始触发事件,从外向内传播
- 事件冒泡(bubbling):从最内层逐级冒泡直至顶层节点,从内向外传播
绑定事件监听器:
// 第三个参数 useCapture 指定事件类型,默认 false 即冒泡
button.addEventListener("click", function() { /* ... */ }, false);
button.addEventListener("click", function(e) {
// 终止事件冒泡,父节点的事件回调都不会触发
e.stopPropagation();
});
阻止事件默认行为,通过调用 event
对象的 preventDefault()
函数,或在回调中返回 false.
2. 委托事件(Delegation)
给父节点绑定事件监听,用于检测子元素内的事件;可以用来减少应用中事件监听器的数量
list.addEventListener("click", function(e) {
if (e.currentTarget.tagName === "li") {
/* ... */
return false;
}
}, false);
jQuery 的 delegate()
方法传入子元素选择器、事件类型和回调,即可实现父节点绑定:
$("ul").delegate("li", "click", /* ... */);
使用事件委托的另一个好处是,动态添加的子元素也都具有事件监听;上例中页面载入完成后添加的 li 节点同样可以触发事件的回调。
3. 自定义事件
jQuery 提供了 trigger()
函数来触发自定义事件,可以通过命名空间的形式来管理事件名称:
// 绑定自定义事件
$(".class").bind("refresh.widget", function() {});
// 触发自定义事件
$(".class").trigger("refresh.widget");
通过传入一个额外参数来传递数据,数据会以附加参数的形式带入回调:
$(".class").bind("frob.widget", function(e, dataNumber) {
console.log(dataNumber):
});
$(".class").trigger("frob.widget", 5);
和内置事件一样,自定义事件同样会沿着 DOM 树做冒泡。
4. DOM 无关事件
基于事件的编程非常强大,因为它能让应用架构充分解耦,让功能变得更加内聚且具有更好可维护性。事件本质是 DOM 无关的,因此可以很容易开发出一个事件驱动的库。这种模式称为发布/订阅(Pub/Sub):发布者向某个信道(channel)发布一条消息,订阅者绑定这个信道,当有消息发布时会接受到一个通知。发布者和订阅者是完全解耦的,两者仅共享一个信道名称。
var PubSub = {
subscribe: function(ev, callback) {
var calls = this._callbacks || (this._callbacks = {});
(this._callbacks[ev] || (this._callbacks[ev] = [])).push(callback);
return this;
},
publish: function() {
var args = Array.prototype.slice.call(arguments, 0);
var ev = args.shift();
var list, calls;
if (!this._callbacks || !this._callbacks[ev]) {
return this;
} else {
calls = this._callbacks;
list = this._callbacks[ev];
}
for (var i = 0, l = list.length; i < l; i++) {
list[i].apply(this, args);
}
return this;
}
};
PubSub.subscribe('Test', function() {
console.log('Test');
});
PubSub.publish('Test');
模型和数据
1. 命名空间
MVC 模式中,数据管理由模型负责,与数据操作和行为相关的逻辑都应放入模型中,通过命名空间管理。
在 JavaScript 中,通过给对象添加属性和方法以实现命名空间:
var User = {
records: [ /* ... */ ],
fetchRemote: function() { /* ... */ }
};
也可以通过定义一个类,实例化相关的模型对象:
var User = function(atts) {
this.attributes = atts || {};
};
User.prototype.destroy = function() {
/* ... */
};
User.fetchRemote = function() {
/* ... */
};
2. ORM
对象关系映射(ORM)是一种常用的技术,可以将模型和远程服务 / 页面 DOM 元素等绑定在一起。
本质上,ORM 是一个包装了数据的对象层;以往 ORM 常用于抽象数据库,这里 ORM 只用于抽象 JavaScript 数据类型。额外的层带来的好处是:可以通过给它添加自定义的函数和属性、来增强基础数据的功能,如添加数据合法性校验、持久化、服务器端的回调处理等,以增加代码的重用率。
大部分 JavaScript ORM 是和前端 MVC 框架结合使用的。
3. 数据加载
数据可以直接嵌入初始页面(后端模版渲染),但不利于缓存。也可以通过 Ajax 或 JSONP 方式动态加载数据。
控制器和状态
状态可以使用 session cookie 保存,但对于单页应用(SPA)而言,状态保存在客户端。当使用了 MVC 框架之后,状态使用控制器(Controller)进行管理。
1. 状态机
有限状态机(Finite State Machines, FSM)是编写 UI 程序的利器,使用状态机可以轻松管理很多控制器,按需显示和隐藏视图。
状态机本质上有两部分组成:状态和转换器,只有一个活动状态(active state),有很多非活动状态(passive state),当状态切换时会调用状态转换器。
首先,使用 jQuery 的事件 API 创建一个 Events 对象,给它添加绑定和触发状态机的事件的能力:
var Events = {
bind: function() {
if (!this.o) {
this.o = $({});
}
this.o.bind.apply(this.o, arguments);
},
trigger: function() {
if (!this.o) {
this.o = $({});
}
this.o.trigger.apply(this.o, arguments);
},
};
创建 StateMachine 类
var StateMachine = function() {};
StateMachine.fn = StateMachine.prototype;
// 添加 bind 和 trigger
$.extend(StateMachine.fn, Events);
StateMachine.fn.add = function(controller) {
this.bind("change", function(e, current) {
if (controller == current) {
controller.activate();
} else {
controller.deactivate();
}
});
controller.active = $.proxy(function() {
this.trigger("change", controller);
}, this);
};
这个状态机的 add()
函数将传入的 controller 加入到状态列表、并创建一个 active()
函数。当调用 active()
时,控制器的状态就会转换为 active 状态;对于 active 状态的控制器,状态机将基于它调用 activate()
、对于其他控制器调用 deactivate()
var controller1 = {
activate: function() {
$("#con1").addClass("active");
},
deactivate: function() {
$("#con1").removeClass("active");
},
};
var controller2 = {
activate: function() {
$("#con2").addClass("active");
},
deactivate: function() {
$("#con1").removeClass("active");
},
};
var sm = new StateMachine;
sm.add(controller1);
sm.add(controller2);
controller1.active();
2. 路由控制
通过 URL Hash 或 HTML5 History API.
视图和模版
将应用的重心从服务器端迁往客户端的过程:
- 将视图所需的数据迁往客户端,通过 Ajax 请求、并由应用的模型载入数据
- 不必在服务端对视图进行预渲染、而是全部放在客户端
- 不使用 JavaScript 动态创建 DOM 的形式(如果需渲染的视图元素不多、则建议使用)、而是使用模版生成视图
JavaScript 模版的核心概念是,将包含模版变量的 HTML 片段和 JavaScript 对象做合并,将模版变量替换为对象中属性。模版的功能不仅限于单纯的插值替换,还有一些条件流(conditional flow)和迭代。
1. 绑定
绑定会将视图元素和 JavaScript 对象(通常是模型)关联在一起,当 JavaScript 对象变化时、视图会根据新修改后的对象做适时更新。
即一旦将视图和模型绑定,当应用的模型更新时、视图也会自动渲染,而控制器不必处理视图的更新(MVVM)
依赖管理
前端模块系统的工程意义:复用、分治,解决模块的定义 / 依赖 / 导出,前端开发特征:
- 基于多语言、多层次的编码和组织工作
- 前端产品的交付是基于浏览器、且通过增量加载的方式执行在客户端浏览器
对于模块的加载和传输,两种极端方式:
- 每个模块单独请求(会导致交互行为迟滞)
- 所有模块打包后请求一次(会导致流量浪费、首次加载慢)
普遍使用按需懒加载,即分块传输。CSS 模块实现,由 less / sass / stylus 等预处理器的 import
/ mixin
特性支持。对于 JavaScript 模块系统的演进:
1. <script>
标签
最原始的 JavaScript 文件加载方式,把每个文件看成一个模块,其接口通常暴露在全局作用域下(window
对象中)。弊端有:
- 文件只能按
<script>
顺序加载 - 容易变量冲突;可以采用命名空间的概念组织各模块的接口,以规避该问题
2. CommonJS
模块通过 require
方法同步加载依赖的其他模块,通过 exports
导出要暴露的接口。弊端有:
- 不适合浏览器环境(异步加载),同步意味着阻塞加载
3. AMD
AMD 规范只有一个主要接口
define(id?: String, dependencies?: String[], factory: Function|Object)
它在声明模块时指定所有的依赖,并且当作形参传到 factory. 依赖前置,即对于依赖的模块提前执行。
define("module", ["dep1", "dep2"], function(d1, d2) {
return someExportedValue;
});
require(["module", "../file"], function(mod, fs) {
/* ... */
});
适合在浏览器中异步加载模块,可以并行加载多个模块,弊端在于开发成本高。
4. ES6 Module
ES6 标准增加了 JavaScript 语言层面的模块体系定义,其设计思想是尽量静态化,使得编译时就能确定模块的依赖关系、以及输入和输出的变量。