读书笔记:《JavaScript Patterns》 - Stoyan Stefanov 著 / 拔赤、goddyzhao、TooBug 译

第七章讲了很多设计模式值得一看。

出版时间:2010 年 09 月 21 日

  • JavaScript 中有五种原始类型:数字、字符串、布尔值、nullundefined(ES6 引入了一种新的原始数据类型 Symbol,表示独一无二的值)。除了 nullundefined 之外,其他三种都有对应的“包装对象”(primitive wrapper object)。可以通过内置构造函数 Number()String()Boolean() 来生成包装对象。
  • 原始值毕竟不是对象,不能直接对其进行扩充。
  • 那么,到底什么是对象?对象能做这么多事情,那它们一定非常特别。实际上,对象是极其简单的。对象只是很多属性的集合,一个名值对的列表(在其他语言中可能被称作关联数组),这些属性也可以是函数(函数对象),这种函数我们称为“方法”。
  • JavaScirpt 中具有“隐式全局对象”的概念,也就是说任何不通过 var 声明的变量都会成为全局对象的一个属性(可以把它们当作全局变量)。隐式全局变量并不算是真正的变量,但它们却是全局对象的属性。属性是可以通过 delete 运算符删除的,而变量不可以被删除。
  • 代码处理经过了两个阶段:第一阶段是创建变量、函数和形参,也就是预编译的过程,它会扫描整段代码的上下文;第二阶段是在代码的运行时(runtime),这一阶段将创建函数表达式和一些非法的标识符(未声明的变量)。(译注:这两个阶段并没有包含代码的执行,是在执行前的处理过程。)从实用性角度来讲,我们更愿意将这两个阶段归成一个概念“变量提前”,尽管这个概念并没有在 ECMAScript 标准中定义,但我们常常用它来解释预编译的行为过程。
  • 每次遍历都会访问数组的 length 属性,这会降低代码运行效率,特别是当 myarray 不是一个数组而是一个 HTMLCollection 对象的时候。这些对象的问题在于,它们都会实时查询文档(HTML 页面)中的对象。也就是说每次通过它们访问集合的 length 属性时,总是都会去查询 DOM,而 DOM 操作则是很耗资源的。
  • 这里介绍一种格式上的变种,这种写法在 for 循环所在的行加入了 if 判断条件,他的好处是能让循环语句读起来更完整和通顺(“如果元素包含属性 X,则对 X 做点什么”):
var i,
    hasOwn = Object.prototype.hasOwnProperty;
for (i in man) if (hasOwn.call(man, i)) { // 过滤
    console.log(i, ":", man[i]);
}
  • new Function() 的用法和 eval() 非常类似,应当特别注意。这种构造函数的方式很强大,但经常会被误用。如果你不得不使用 eval(),你可以尝试用 new Function() 来代替。这有一个潜在的好处,在 new Function() 中运行的代码会在一个局部函数作用域内执行,因此源码中所有用 var 定义的变量不会自动变成全局变量。还有一种方法可以避免 eval() 中定义的变量被转换为全局变量,即是将 eval() 包装在一个即时函数内。
  • 首字母大写可以提示你这是一个构造函数,而首字母小写的函数一般只认为它是普通的函数,不应该通过 new 来调用它。
  • 最早利用注释生成 API 文档的工具诞生自 Java 业界,这个工具名叫“javadoc”,和 Java SDK(软件开发工具包)一起提供,但这个创意迅速被其他语言借鉴。JavaScript 领域有两个非常优秀的开源工具,它们是 JSDoc Toolkit(http://code.google.com/p/jsdoc-toolkit/)和 YUIDoc(http://yuilibrary.com/projects/yuidoc)。
  • 避免“声明提前”给程序逻辑带来的影响 for 循环、for-in 循环、switch 语句、“避免使用 eval()”、不要扩充内置原型。
  • 在 JavaScript 中根本不存在真正的空对象,理解这一点至关重要。即使最简单的 {} 对象也会包含从 Object.prototype 继承来的属性和方法。我们提到的“空(empty)对象”只是说这个对象没有自有属性(own properties),不考虑它是否有继承来的属性。
  • 我们知道,构造函数和普通的函数本质一样,只是通过 new 调用而已。那么如果调用构造函数时忘记 new 会发生什么呢?漏掉 new 不会产生语法错误也不会有运行时错误,但可能会造成逻辑错误,导致执行结果不符合预期。这是因为如果不写 new 的话,函数内的 this 会指向全局对象(在浏览器端 this 指向 window)。
  • 有些人用 Array() 构造器来做一些有意思的事情,比如用来生成重复字符串。下面这行代码返回的字符串包含 255 个空格。
var white = new Array(256).join(' ');
  • ECMAScript5 定义了一个新的方法 Array.isArray(),如果参数是数组的话就返回 true。如果你的开发环境不支持 ECMAScript5,可以通过 Object.prototype.toString() 方法来代替。如调用 toStringcall() 方法并传入数组上下文,将返回字符串 [object Array]。如果传入对象上下文,则返回字符串 [object Object]。因此可以这样做:
if (typeof Array.isArray === "undefined") {
    Array.isArray = function (arg) {
        return Object.prototype.toString.call(arg) === "[object Array]";
    };
}
  • throw 可以抛出任何对象,并不限于“错误对象”,因此你可以根据需要抛出自定义的对象。这些对象包含属性 namemessage 或其他你希望传递给异常处理逻辑的信息,异常处理逻辑由 catch 语句指定。你可以灵活运用抛出的错误对象,将程序从错误状态恢复至正常状态。
  • JavaScript 的函数具有两个主要特性,正是这两个特性让它们与众不同。第一个特性是,函数是一等对象(first-class object),第二个是函数提供作用域支持。
  • 在 JavaScript 中没有块级作用域(译注:在JavaScript1.7中提供了块级作用域部分特性的支持,可以通过 let 来声明块级作用域内的“局部变量”),也就是说不能通过花括号来创建作用域,JavaScript 中只有函数作用域(译注:这里只针对函数而言,此外 JavaScript 还有全局作用域)。在函数内所有通过 var 声明的变量都是局部变量,在函数外部是不可见的。刚才所说的花括号无法提供作用域支持的意思是说,如果if 条件句、forwhile 循环体内用 var 定义了变量,这个变量并不是属于 if 语句或 forwhile)循环的局部变量,而是属于它所在的函数。如果不在任何函数内部,它会成为全局变量。
  • apply() 接受两个参数:第一个是在函数内部绑定到 this 上的对象,第二个是一个参数数组,参数数组会在函数内部变成一个类似数组的 arguments 对象。如果第一个参数为 null,那么 this 将指向全局对象,这正是当你调用一个函数(且这个函数不是某个对象的方法)时发生的事情。
  • 使用链式调用模式可以让你在一对个象上连续调用多个方法,不需要将前一个方法的返回值赋给变量,也不需要将多个方法调用分散在多行。当你创建了一个没有有意义的返回值的方法时,你可以让它返回 this,也就是这些方法所属的对象。这使得对象的使用者可以将下一个方法的调用和前一次调用链起来。
  • 使用构造函数就像 Java 中使用类一样。它也允许你在构造函数体的 this 中添加实例属性。但是在 this 中添加方法却是不高效的,因为最终这些方法会在每个实例中被重新创建一次,这样会花费更多的内存。这也是为什么可重用的方法应该被放到构造函数的 prototype 属性(原型)中的原因。但对很多开发者来说,prototype 可能跟个外星人一样陌生,所以你可以通过一个方法将它隐藏起来。
  • constructor 属性很少被用到,但是在运行时检查对象很方便。你可以重新将它指向期望的构造函数而不影响功能,因为这个属性更多是“信息性”的。(译注:即它更多的时候是在提供信息而不是参与到函数功能中。)
function inherit(C, P) {
    var F = function () {};
    F.prototype = P.prototype;
    C.prototype = new F();
    C.uber = P.prototype; // MAZEY! uber 是德语“向上”,直接指向父对象(P),保证了继承的完备性,纯属备用。
    C.prototype.constructor = C;
}
  • 有时候会有这样的情况:你希望使用某个已存在的对象的一两个方法,你希望能复用它们,但是又真的不希望和那个对象产生继承关系,因为你只希望使用你需要的那一两个方法,而不继承那些你永远用不到的方法。得益于函数的 call()apply() 方法,可以通过借用方法模式实现它。你传一个对象和任意的参数,这个被借用的方法会将 this 绑定到你传递的对象上。简单地说,你的对象会临时假装成另一个对象以使用它的方法。这就像实际上获得了继承但又免除了“继承税”(译注:指不需要的属性和方法)。
  • ECMAScript5 在 Function.prototype 中添加了一个方法叫 bind(),使用时和 apply()/call() 一样简单。
// 语法
let boundFunc = func.bind(thisArg[, arg1[, arg2[, ...argN]]])

const fn = function (m, n) { return this.x + m + n }.bind({x: 3}, 2, 1)
fn() // 6
  • 外观模式也适用于一些浏览器脚本的场景,即将浏览器的差异隐藏在一个外观方法下面。添加一些处理 IE 中事件 API 的代码:
var myevent =  {
    // ……
    stop: function (e) {
        // 其它浏览器
        if (typeof e.preventDefault === "function") {
            e.preventDefault();
        }
        if (typeof e.stopPropagation === "function") {
            e.stopPropagation();
        }
        // IE
        if (typeof e.returnValue === "boolean") {
            e.returnValue = false;
        }
        if (typeof e.cancelBubble === "boolean") {
            e.cancelBubble = true;
        }
    }
    // ……
};
  • 观察者模式被广泛地应用于 JavaScript 客户端编程中。所有的浏览器事件(mouseoverkeypress 等)都是使用观察者模式的例子。这种模式的另一个名字叫“自定义事件”,意思是这些事件是被编写出来的,和浏览器触发的事件相对。它还有另外一个名字叫“订阅者/发布者”模式(Pub/Sub)。使用这个模式的最主要目的就是促进代码解耦。在观察者模式中,一个对象订阅另一个对象的指定活动并得到通知,而不是调用另一个对象的方法。订阅者也被叫作观察者,被观察的对象叫作发布者或者被观察者。当一个特定的事件发生的时候,发布者会通知(调用)所有的订阅者,同时还可能以事件对象的形式传递一些消息。
  • 浏览器中有非常多不一致的宿主对象和 DOM 实现。很明显,任何能够减轻客户端脚本编程的痛楚的最佳实践都是大有益处的。
  • DOM 访问的次数应该被减少到最低,这意味者:1.避免在循环中访问 DOM。2.将 DOM 引用赋给本地变量,然后操作本地变量。3.当可能的时候使用 selectors API。4.遍历 HTML collections 时缓存 length
  • 除了访问 DOM 元素之外,你可能经常需要改变它们、删除其中的一些或者是添加新的元素。更新 DOM 会导致浏览器重绘(repaint)屏幕,也经常导致重排(reflow,重新计算元素的位置),这些操作代价是很高的。还是那句话,原则是尽量少地更新 DOM,这意味着我们可以将变化集中到一起,然后在“活动的”(live)文档树之外去执行这些变化。当你需要添加一棵相对较大的子树的时候,你应该在完成这棵树的构建之后再放到文档树中。为了达到这个目的,你可以使用文档碎片(document fragment)来包含你的节点。
  • 创建一个文档碎片,然后“离线地”(译注:即不在文档树中)更新它,当它准备好之后再将它加入文档树中。当你将文档碎片添加到 DOM 树中时,碎片的内容将会被添加进去,而不是碎片本身。这个特性非常好用。所以当有好几个没有被包裹在同一个父元素的节点时,文档碎片是一个很好的包裹方式。
var p, t, frag;
frag = document.createDocumentFragment();
p = document.createElement('p');
t = document.createTextNode('first paragraph');
p.appendChild(t);
frag.appendChild(p);
p = document.createElement('p');
t = document.createTextNode('second paragraph');
p.appendChild(t);
frag.appendChild(p);
document.body.appendChild(frag);
  • setTimeout() 1 毫秒(甚至 0 毫秒)的延迟执行命令在实际运行的时候会延迟更多,这取决于浏览器和操作系统。设定 0 毫秒的延迟并不意味着马上执行,而是指“尽快执行”。比如,在 IE 中,最短的延迟是 15 毫秒。
  • script 元素会阻塞页面的下载。浏览器会同时下载好几个组件(文件),但遇到一个外部脚本的时候,会停止其它的下载,直到脚本文件被下载、解析、执行完毕。这会严重影响页面的加载时间,尤其是当页面加载时发生多次阻塞的时候。为了尽量减小阻塞带来的影响,你可以将 script 元素放到页面的尾部,在 </body> 之前,这样就没有可以被脚本阻塞的元素了。此时,页面中的其它组件(文件)已经被下载完毕并呈现给用户了。
  • 通常脚本是插入到文档的 <head> 中的,但其实你可以插入任何元素中,包括 <body>(像 JSONP 那样)。在前面的例子中,我们使用 documentElement 来插到 <head> 中,因为 documentElement 就是 <html>,它的第一个子元素是 <head>。当你能控制结构的时候,这样做没有问题,但是如果你在写挂件(widget)或者是广告时,你并不知道使用它的是一个什么样的页面。甚至可能页面上连 <head><body> 都没有,尽管 document.body 在绝大多数没有 <body> 标签的时候也可以工作。可以肯定页面上一定存在的一个标签是你正在运行的脚本所处的位置 —— script 标签。(对内联或者外部文件来说)如果没有 script 标签,那么代码就不会运行。可以利用这一事实,在页面的第一个 script 标签上使用 insertBefore()
var first_script = document.getElementsByTagName('script')[0];
first_script.parentNode.insertBefore(script, first_script);

发表评论

您的电子邮箱地址不会被公开。