《编写可维护的javascript》推荐的编码规范之——编程实践
二、编程实践
编程实践是另一类编程规范。代码风格规范只关心代码的呈现,而编程实践则关心编码的结果,指引开发者以某种方式编写代码。设计模式是编程实践的组成部分。
web UI 分层:
CSS | JavaScript |
HTML |
5 UI层的松耦合
5.1 松耦合
5.2 将javascript从css中抽离
杜绝使用css表达式。
5.3 将CSS从javascript中抽离
不要使用脚本修改元素对象的style属性,而应该使用className。例外是,当你需要给页面中的元素作定位时,使其相对于某个基准重新定位。这种计算是无法在css中完成的,因此这时使用style.top、style.left等对元素作正确定位是可以接受的。
5.4 将javascript从HTML中抽离
// 不好的做法 <p onclick=”doSomething()” id=”para”> 一段文本</p>
应该在外部脚本文件中:
var para = document.getElementById(‘para’); para.addEventListener(‘click’, doSomething);
5.5 将HTML从javascript中分离
content.innerHTML = "<h3>Error</h3><p>出错了!</p>";
5.5.1 从服务器加载
适用于大段的HTML。这种方法(从服务器获取模板)很容易造成xss漏洞,需要服务器对模板文件做适当的转义处理,比如 < 和 > 以及双引号等,当然前端也应当给出与之匹配的渲染规则,总之这种方法需要一揽子前后端的转码和解码策略来尽可能地封堵XSS漏洞。
这种方法能对 单页面应用(Single Page Applications) 带来便捷。
5.5.2 客户端模板
将模板嵌入到HTML页面中。一种方法是将模板置于HTML注释中,使用时再提取出来,然后将它格式化并插入DOM;另一种方法是将模板置于拥有自定义的浏览器无法识别的type属性的script标签内,通过script标签对象的text属性获取,使用javascript模板引擎解析。
6 避免使用全局变量
在全局作用域中声明的变量和函数都是window对象的属性。
6.2 意外的全局变量:
function doSomething() { var count = result = 10; // 创建了全局变量 result title = "这是标题文字"; // 创建了全局变量title }
6.3 单全局变量方式
单全局变量模式已经在各种流行的javascript类库中广泛使用了。
- YUI定义了一个YUI全局对象。
- jQuery定义了两个全局对象,$ 和jQuery。
- Dojo定义了一个dojo全局对象。
- Closure类库定义了一个goog全局对象。
“单全局变量”的意思是所创建的这个唯一全局对象名是独一无二的,并将你所有的功能代码都挂载到这个全局对象上。
6.3.1 命名空间
大多数使用单全局变量模式的项目同样包含“命名空间”的概念。命名空间是简单的通过全局对象的单一属性表示的功能性分组。如,YUI就是依照命名空间的思路来管理其代码的。Y.DOM下的所有方法都是和DOM操作相关的,Y.Event下的所有方法都是和事件相关的,以此类推。
同样有另外一些场景,每个文件都需要给一个命名空间挂载东西。在这种情况下,你需要首先保证这个命名空间是已经存在的。这时全局对象非破坏性的处理命名空间的方式变得非常有用。完成这项操作的基本模式是像下面这样。
var yourGlobal = { namespace: function( ns ) { var parts = ns.split('.'), object = this, i, len; for (i=0, len=parts.length; i<len; i++) { if ( !object[ parts[i] ] ) { object[ parts[i] ] = {}; } object = object[ parts[i] ]; } return object; } };
我们给这个方法传入一个表示命名空间对象的字符串,它会非破坏性的创建这个命名空间。基本的用法如下:
// 同时创建yourGlobal.Language和yourGlobal.Language.JavaScript yourGlobal.namespace( "Language.JavaScript" ); // 现在你可以使用这个命名空间了 yourGlobal.Language.JavaScript.author = "Brendan Eich";
基于你的单全局对象使用 namespace() 来声明开发者将要使用的命名空间,这样做不会对已有的命名空间造成任何破坏。这个方法可以让开发者解放出来。在使用命名空间之前不必再去判断它是否存在。
6.3.2 模块
另外一种基于单全局变量的扩充方法是使用模块。模块是一种通用的功能片段,它并没有创建新的全局变量或命名空间。相反,所有的这些代码都存放于一个表示执行一个任务或发布一个借口的单函数中。可以用一个名称来表示这个模块,同样这个模块可以依赖其他模块。两种最流行的类型是“YUI模块”模型和“异步模块定义”(Asynchronous Module Definition,简称AMD)模式。
(和AMD规范类似的还有CommonJS规范,它更适用于服务器端或纯脚本编程,而在浏览器端则不大适用。)
6.4 零全局变量
(function(window, undefined) { // 这里是一段完全独立的脚步 }(this));
7 事件处理
7.1 典型用法
function handleClick(e) { var popup = document.getElementById('popup'); popup.style.left = e.clientX + 'px'; popup.style.top = e.clientY + 'px'; }
addListener(elm, 'click', handleClick);
上段实例代码的第一个问题是事件处理程序包含了应用逻辑(application logic)。
7.2 规则1:隔离应用逻辑
将应用逻辑从所有事件处理程序中抽离出来的做法是一种最佳实践。这样可以实现代码的复用,还可以方便测试。
var MyApplication = { handleClick: function(e) { this.showPopup(e); }, showPopup: function(e) { var popup = document.getElementById('popup'); popup.style.left = e.clientX + 'px'; popup.style.top = e.clientY + 'px'; popup.className += ' reveal'; } } addListener(elm, 'click', function(e){ MyApplication.handleClick(e); });
7.3 规则2:不要分发事件对象
上段代码还存在一个问题,即event对象被无节制的分发。Event对象包含很多与事件相关的信息,这段代码只用到了其中的两个而已。
应用逻辑不应该依赖于event对象来正确完成功能,原因如下:
- 方法接口并没有表明哪些数据是必要的。好的API一定是对于期望和依赖都是透明的(注)。
- 如果你想测试这个方法,就需要重建一个event对象,还需要知道这个方法是有了哪些信息,这样才能正确写出代码。
接口格式不清晰和自行构造event对象来用于测试在大型web应用中都是不可取的。
(注:作者的意思是说好的API要更明确清楚,但这个观点我们需要辩证地来对待,如果明确的知道回调传值的用处以及需要传哪些值,当然更好,但更多的时候我们并不知道应用逻辑做何种事情,因此需要为应用逻辑提供尽可能多的信息,如何利用这些信息,效率如何统统交由应用逻辑负责,已达到某种层次(数据层和控制层么?)的解耦。译者认为这个原因是次要的,主要原因是作者提到的第二点。)
当处理事件时,最好让事件处理程序成为接触到event对象的唯一的函数。事件处理程序应当在进入应用逻辑之前针对event对象执行任何必要的操作。这样事件处理程序和应用逻辑能更清楚的分工。
var MyApplication = { handleClick: function(e) { this.showPopup(e.clientX, e.clientY); }, showPopup: function(x, y) { var popup = document.getElementById('popup'); popup.style.left = x + 'px'; popup.style.top = y + 'px'; popup.className += ' reveal'; } } addListener(elm, 'click', function(e){ MyApplication.handleClick(e); });
8.1 检测原始值
Javascript有5种原始值:字符串、数字、布尔值、null和undefined。除了null之外,使用typeof来检测它们的类型是非常可靠的。typeof null 会返回“object”。所以检测一个值是否是null,就需要将该值与null进行恒等比较: value === null。
8.2 检测引用值
引用值也称作对象。在javascript中除了原始值之外的值都是引用值。有这样几种内置的引用类型:Object、Array、Date和Error。使用typeof判断它们都是返回“object”,囧。
在javascript中检测自定义类型时,最好的做法就是使用instanceof运算符,这也是唯一的方法。同样对于内置javascript类型也是如此。但它有一个严重的限制,那就是在跨frame使用时。这个限制不仅出现在自定义类型上,还出现在两个内置类型上:函数和数组。
(注:instanceof会检测原型,因此每个对象的value instanceof Object都会返回true。)
8.2.1 检测函数
在js中,函数也是引用类型,检测函数最好的方法是使用typeof。
但使用typeof来检测函数有一个限制。在IE8-中,使用typeof来检测DOM节点(如:document.getElementById)中的方法都是返回“object”而不是“function”。之所以会出现这种怪异的现象是因为IE并没有将DOM实现为内置的javascript方法。因此,开发者往往通过in运算符来检测DOM的方法,如:
if ("querySelectAll" in document) { images = document.querySelectorAll(“img”); }
尽管不是最理想的方法,如果想要兼容IE,这是最安全的做法。
8.2.2 检测数组
function isArray(value) { if (typeof Array.isArray === "function") { return Array.isArray(value); } else { return Object.prototype.toString.call(value) === "[object Array]"; } }
8.3 检测属性
检测一个属性是否在对象中存在最好的方法是使用in 运算符。它同样会检测原型。
如果只想检测实例属性,则使用hasOwnProperty()方法。所有继承自Object的javascript对象都有这个方法。需要注意的是,在IE8-中,DOM对象并非继承自Object,因此它们不包含这个方法。
// 如果你不确定是否为DOM对象,则这样来写 if ( 'hasOwnProperty' in obj && obj.hasOwnProperty('prop') ) { }
9 分离配置数据
将配置数据抽离出来,让数据和逻辑分层。
var config = { domian: “baidu.com”, key1: val1, key2: val2, callback: function() {} }
10 抛出自定义错误
10.1 why
抛出错误有助于调试。Javascript错误消息以信息稀少、隐晦含糊而臭名昭著。
10.2 抛出错误
throw new Error("Something bad happened.");
内置的Error类型在所有javascript实现中都是有效的,它的构造器只接受一个参数,指代错误消息。
// 不好的写法 throw “message”
这样做确实也能够抛出一个错误,但不是所有浏览器做出的响应都会按照你的预期。
10.3 抛出错误的好处
我推荐总是在错误消息中包含函数名称,以及函数调用失败的原因。
function getDivs(elm) { if (elm && elm.getElementsByTagName) { return elm.getElementsByTagName('div'); } else { throw new Error('getDivs(): Argument must be a DOM elment.'); } }
10.4 何时抛出错误
辨识代码中哪些部分在特定的情况下最有可能导致失败,并只在那些地方抛出错误才是关键所在。
如果一个函数只会被已知的实体调用,错误检查很可能没有必要(比如私有函数)。
抛出错误最佳的地方是在工具函数中。如,addClass,因为它会在很多地方使用。
函数调用栈应该在进入库代码接口时就终止,不应该更深了。
抛出错误的目的不是为了防止错误,而是在错误发生时能更加容易地调试。
11 不是你的对象不要动
11.1 什么是你的
你负责的对象,是你拥有的对象。不是你的,不要修改:
- 原生对象。
- DOM
- BOM
- 类库的对象
11.2 原则
你应该把已经存在的Javascript对象如一个使用的工具函数一样对待。
- 不覆盖方法。
- 不新增方法。
- 不删除方法。
11.2.1 不覆盖方法
糟糕的做法。如:
覆写:
document.getElementById = function() { return null; };
函数劫持(注):
document.getId = document.getElementById; document.getElementById = function(id) { return id===window? window : document.getId(id); }
(注:先保存原函数实现,然后替换为我们自己的函数实现,添加我们自己的处理逻辑后最终再调用原来的函数实现)
11.2.2不新增方法
不要扩展内置对象,prototype类库就是个反面教材。
11.2.3 不删除方法
document.getElementById = null;
无需多言,删除一个已存在对象的方法是多么糟糕的实践。
11.3 更好的途径
继承,js中有两种基本的形式:基于对象的继承和基于类的继承。在js中,继承仍然有一些很大的限制。首先,不能从DOM或者BOM对象继承。其次,由于数组索引和length属性之间错综复杂的关系,继承自Array是不能正常工作的。
11.3.1 基于对象的继承
var jim = Object.create(man, { name: 'Jim', age: 18 });
第一个参数是一个对象,第二个参数对象中的属性和方法将添加到新的对象中。
11.3.2 基于类型的继承。
在开发者定义了构造函数的情况下,基于类型的继承是最合适的。
function Person(name) { this.name; } function Author(name) { Person.call(this, name); // 继承构造器 } Author.prototype = new Person();
11.3.3 门面模式
门面有时也叫包装器。jQuery和YUI的DOM接口都使用了门面。你无法从DOM对象上继承。所以唯一的能够安全地为其新增功能的选择就是创建一个门面。下面是一个DOM对象包装器的示例:
function DOMWrapper(element) { this.element = element; } DOMWrapper.prototype.addClass = function () { this.element.className += ' ' + className; };
11.4 关于Polyfill
es5 shim 和 HTML5 shim 的讨论。有人用,有人不用,作者建议不用。
11.5 阻止修改
ES5引入了几个方法来防止对象被修改。有三种锁定修改的级别:防止扩展、密封和冻结。
12 浏览器嗅探
12.1 User-Agent检测
if (navigator.userAgent.indexOf('MSIE') > -1) { // 是IE } else { // 不是IE }
由于浏览器为了确保兼容性,都会复制另外一个浏览器的用户代理字符串。所以用户代理检测并不是最佳实践。
12.2 特性检测
推荐使用特性检测:
var addHandler = document.body.addEventListener ? function ( target, eventType, handler ) { target.addEventListener ( eventType, handler, false ); } : function ( target, eventType, handler ) { target.attachEvent( 'on' + eventType, handler ); };
12.3 避免特性推断
根据一个特性的存在推断另一些特性是否存在。如:
function getId(id) { var elm = null; if(window.ActiveXObject) { elm = document.all[id]; } }
这个推断基本上断定window.ActiveXObject仅仅存在于IE,且document.all也仅存在IE。实际上Opera的一些版本也支持document.all。
12.4 避免浏览器推断
通过特性检测从而推断出是某个浏览器同样是很糟糕的做法。如:
var isIE = !!document.all;
12.5 如何取舍
特性推断和浏览器推断都是糟糕的做法。纯粹的特性检测是一种很好的方法。通常,你仅需在使用前检测特性是否可用。不要试图推断特性间的关系,否则最终得到的结果也是不可靠的。
用户代理检测还是有合理的使用场景的,但应该不会很多。如果你试图使用它,记住:这么做唯一安全的方式是针对旧的或特定版本的浏览器。而绝不应该针对最新版本或未来的浏览器。