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 语言层面的模块体系定义,其设计思想是尽量静态化,使得编译时就能确定模块的依赖关系、以及输入和输出的变量。