Qida's Blog

纸上得来终觉浅,绝知此事要躬行。

本文目的:

  • 能够理解事件绑定和事件委托两种机制的区别
  • 能够使用原生 API 和 jQuery API 两种方式进行事件委托

Native API

项目开发时遇到一个需求:修改页面中所有 A 链接的默认行为。

事件绑定

最开始想到了用“事件绑定”机制进行实现:

1
2
3
4
5
6
7
8
9
10
11
var showMessage = function(event) {
// 阻止 A 链接的默认行为(不进行跳转)
event.preventDefault();
// 仅弹窗显示链接的 href 属性
alert(event.currentTarget.href);
};

var links = document.getElementsByTagName('a');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', showMessage, false);
}

这种做法的问题是:如果页面中绑定了大量的事件处理程序,将直接影响页面的整体运行性能,因为:

  1. 函数即对象,对象越多,越占用内存,性能就越差。
  2. 事件绑定前,必须先找到指定的 DOM 元素。而 DOM 元素查找次数越多,页面的交互就绪时间就越长。

更麻烦的是,如果页面加载完后再次插入新元素,需要再次绑定事件处理程序,灵活性差:

1
2
3
4
5
var newLink = document.createElement("a");
newLink.innerHTML = 'Click Me';
newLink.href = 'http://localhost';
newLink.addEventListener('click', showMessage, false);
document.body.appendChild(newLink);

事件委托

利用事件委托机制可以同时解决上述两个问题。只需在 DOM 树中尽量最高的层次上添加一个事件处理程序,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var showMessage = function(event, target) {
// 阻止 A 链接的默认行为(不进行跳转)
event.preventDefault();
// 仅弹窗显示链接的 href 属性
alert(target.href);
},
// 递归查询指定父元素
findTarget = function(target, tagName) {
while (target.tagName && target.tagName !== tagName.toUpperCase()) {
target = target.parentNode;
}
return (target.tagName && target.tagName === tagName.toUpperCase()) ? target : null;
};

document.body.addEventListener('click', function(event) {
// 间接判断 A 链接是否被点击
var target = findTarget(event.target, 'a');
if (target) {
showMessage(event, target);
}
}, false);

由于所有 A 链接都是 body 元素的子节点,并且它们的事件都会冒泡,因此点击事件最终会被 body 上添加的事件处理程序所处理。代码重构后在以下方面提升了页面性能:

  • 由于 document 对象很快就可以访问,而且可以在页面生命周期的任何时点上为它添加事件处理程序(无需等待 DOMContentLoadedload 事件),因此只要可点击的元素呈现在页面上,就可以立即具备适当的功能。
  • 由于只添加一个事件处理程序,因此所需的 DOM 引用更少,整个页面占用的内存空间也更少。

此外,事件会关联到当前以及以后添加的子元素上面,可以避免反复为新元素绑定事件处理程序,可谓一劳永逸。

jQuery API

理解了两种机制的区别后,看看如何使用 jQuery 进行最快的实现:

on()

jQuery 1.7+ 推出了 on() 方法,其目的有两个:

  1. 统一接口

  2. 提高性能

用法如下:

  • 事件绑定:on(events,[data],fn) ,用于替换 bind()
  • 事件委托:on(events,[selector],[data],fn) ,用于替换 live()delegate() 。这里的 [selector] 参数很关键,起到了一个过滤器的效果,只有被选中元素的 子元素 才会触发事件。

on() 方法重构后的代码如下:

1
2
3
4
$('body').on('click', 'a', function(event) {
event.preventDefault();
alert(event.currentTarget.href);
});

可见,代码重构后非常简洁,推荐使用。

参考

本文目的:

  • 理解并能按需使用各种事件绑定 API
  • 理解事件对象
  • 理解事件流

事件绑定

事件是用户或浏览器自身执行的某种动作,例如 onclickonload ,都是事件的名字。而响应某个事件的函数就叫做 事件处理程序(Event Handlers)。为事件绑定处理程序的方式有以下几种:

HTML

做法:在 HTML 元素中直接编写事件处理程序:

1
2
3
4
5
6
7
8
<!-- 输出“Clicked” —— 事件处理程序中,可以直接编写 JavaScript 代码 -->
<input type="button" value="Click Me" onclick="alert('Clicked')" />

<!-- 输出“click” —— 事件处理程序中,可以直接访问事件对象 event -->
<input type="button" value="Click Me" onclick="alert('event.type')" />

<!-- 输出“Click Me” —— 事件处理程序中,this 指向事件的目标元素 -->
<input type="button" value="Click Me" onclick="alert('this.value')" />

其中除了可以编写 JavaScript 代码,还可以调用外部脚本:

1
2
3
4
5
6
7
8
<!-- 事件处理程序中的代码在执行时,有权访问全局作用域中的任何代码 -->
<input type="button" value="Click Me" onclick="showMessage()" />

<script type="text/javascript">
function showMessage() {
alert("Hello world!");
}
</script>

特点:上述 onclick 事件将自动产生一个事件处理程序(函数),例如:

1
2
3
function onclick(event) {
alert('Clicked')
}

优点:简单、粗暴,浏览器兼容性好。

缺点:

  • 存在时差问题。用户可能会在 HTML 元素一出现在页面上时,就触发相应事件,但当时的事件处理程序有可能还未具备执行条件(例如事件处理程序所在的外部脚本文件还未加载或解析完毕),此时会引发 undefined 错误。
  • HTML 与 JavaScript 代码紧密耦合。如果要重命名事件处理程序,就要改动两个地方,容易改漏、改错。

DOM Level 0

做法:首先获取目标 HTML 元素的引用,然后将一个事件处理程序赋值给其指定的事件属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<input type="button" id='btn' value="Click Me" />

<script type="text/javascript">
var btn = document.getElementById('btn');

// 绑定事件处理程序
btn.onclick = function() {
alert('Clicked');
alert(this.id); // 输出“myDiv” —— this 指向事件的目标元素
}

// 删除事件处理程序
btn.onclick = null;
</script>

特点:本质上,DOM 0级事件处理程序 等于 HTML 事件处理程序,例如:

1
2
3
4
5
6
7
8
9
<input type="button" id='btn' value="Click Me" onclick="alert('Clicked')" />

<script type="text/javascript">
setTimeout(function() {
var btn = document.getElementById('btn');
alert(typeof btn.onclick); // 通过 HTML 的事件属性,访问其 HTML 事件处理程序,并输出其类型“function”
btn.onclick = null; // 几秒后,将会删除该按钮的事件处理程序
}, 3000);
</script>

优点:

  • 传统、常用、浏览器兼容性好。
  • 解决了 HTML 事件处理程序的两个缺点。

缺点:一个事件只能绑定唯一一个事件处理程序。

DOM Level 2

做法:目前最主流的写法,可以支持事件冒泡或捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<input type="button" id='btn' value="Click Me" />

<script type="text/javascript">
var btn = document.getElementById('btn'),
showMessage = function() {
alert('Clicked');
alert(this.id); // 输出“myDiv” —— this 指向事件的目标元素
};

// 绑定事件处理程序。false 表示在“冒泡阶段”和“目标阶段”触发
btn.addEventListener('click', showMessage, false);

// 删除事件处理程序。注意,匿名函数无法移除
btn.removeEventListener('click', showMessage, false);
</script>

优点:一个事件可以绑定多个事件处理程序,以绑定的顺序执行。

缺点:浏览器兼容性差,IE8 及以下版本不支持。

API:element.addEventListener(event, function, useCapture) 。其中 useCapture 可选,布尔值,指定事件是否在捕获或冒泡阶段执行:

  • true 捕获阶段执行
  • false 冒泡阶段执行(默认值)

IE

IE 实现了与 DOM 2 级类似的两个方法,只支持事件冒泡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<input type="button" id='btn' value="Click Me" />

<script type="text/javascript">
var btn = document.getElementById('btn'),
showMessage = function() {
alert('Clicked');
alert(this === window); // 输出“true” —— 注意 this 指向 window
};

// 绑定事件处理程序。仅在“冒泡阶段”和“目标阶段”触发
btn.attachEvent('onclick', showMessage);

// 删除事件处理程序。注意,匿名函数无法移除
btn.detachEvent('onclick', showMessage);
</script>

优点:一个事件可以绑定多个事件处理程序,以绑定的顺序 逆序 执行。

缺点:浏览器兼容性差,仅支持 IE 及 Opera。

Cross-Browser

鉴于上述几种方式的各有优劣,为了以跨浏览器的方式处理事件,可以定义自己的 EventUtil

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
}
}

};

事件对象

在触发 DOM 上的某个事件时,会产生一个事件对象 event ,这个对象中包含着所有与事件有关的信息。尽管触发的事件类型不同,可用属性和方法也会不同,但是所有事件都会包含下列常用成员:

DOM Level 2 Type IE Type Description
type String type String 被触发的事件类型
eventPhase Integer - - 调用事件处理程序的所处阶段:1 表示捕获阶段,2 表示“处于目标”,3 表示冒泡阶段
target Element srcElement Element 事件的目标元素
currentTarget Element - - 当前正在处理事件的元素。如果事件处于目标元素,则 this === currentTarget === target
stopPropagation() Function cancelBubble Boolean 取消事件的进一步捕获或冒泡
preventDefault() Function returnValue Boolean 取消事件的默认行为。该方法将通知 Web 浏览器不要执行与事件关联的默认动作(如果存在这样的动作)。例如,如果 type 属性是 “submit”,可以阻止提交表单。

事件流

最后总结下与事件处理程序息息相关的“事件流”。事件流是指从页面中接收事件的顺序。但有意思的是,历史上 IE 和 Netscape 开发团队居然提出了 完全相反 的事件流概念 —— IE 使用“事件冒泡(Event Bubbling)”、Netscape 使用“事件捕获(Event Capturing)”。下图演示了这两种事件流的区别:

事件流(Event Flow)

下表列出了四种事件绑定所使用的事件流模型:

事件冒泡 or 事件捕获?
HTML 取决于 IE or Netscape
DOM Level 0 取决于 IE or Netscape
DOM Level 2 事件冒泡 + 事件捕获
IE 事件冒泡

下面重点讲解 DOM Level 2 事件处理程序所规定的事件流,其共包含三个阶段(其运行效果如上图从 1 到 10):

  1. 事件捕获阶段,可用于事件截获
  2. 处于目标阶段
  3. 事件冒泡阶段,可用于事件委托(Event Delegation)

下面这段代码演示了 DOM Level 2 的整个事件流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
<body>
<input type="button" id='btn' value="Click Me" />

<script type="text/javascript">

// 仅在“事件捕获阶段”和“处于目标阶段”触发
document.body.addEventListener('click', function(event){
alert(event.eventPhase + ' body');
}, true);

// 仅在“事件冒泡阶段”和“处于目标阶段”触发
document.getElementById('btn').addEventListener('click', function(event){
alert(event.eventPhase + ' input');
}, false);

// 仅在“事件冒泡阶段”和“处于目标阶段”触发
document.addEventListener('click', function(event){
alert(event.eventPhase + ' document');
}, false);

</script>
</body>
</html>

点击 input 按钮,将依次输出:

1
2
3
1 body
2 input
3 document

可见,DOM Level 2 是同时支持事件冒泡 + 事件捕获的。

参考

  • 《JavaScript 高级程序设计》

一年前,为了优化这个博客的访问速度,我将 Pages 服务迁移 到了 GitCafe,没想到一年后 GitCafe 竟被 codeing.net 收购了,其服务将在五月底全面停止,真是令人叹息。

幸好 Coding Pages 支持免费绑定自定义域名,其配置也非常简单。在完成配置之后,只需要到 DNSPod 切换下 cname ,等待 DNS 解析生效即可。整个过程对网站用户透明。

最后,这里 列举了不少知名的 Pages 服务可供选择。不过对于国内用户来说,还是使用国内服务最快、最稳定。

本文演示如何动态加载脚本。即脚本在页面加载时不存在,但将来的某一时刻通过修改 DOM 动态添加脚本,从而实现按需加载脚本。

加载脚本文件

1
2
3
4
5
6
7
8
function loadScriptFile(url) {
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = url;

// 在执行到这行代码将 <script> 元素添加到页面之前,不会下载指定外部文件
document.body.appendChild(script);
}

内联脚本代码

1
2
3
4
5
6
7
8
function loadScriptString(code) {
var script = document.createElement('script');
script.type = 'text/javascript';
script.text = code;

// 在执行到这行代码将 <script> 元素添加到页面之前,不会下载指定外部文件
document.body.appendChild(script);
}

以这种方式加载的代码会在全局作用域中执行,而且当脚本执行后将立即可用。实际上,这样执行代码与在全局作用域中把相同的字符串传递给 eval() 是一样的。

有了一套成熟的分支模型以及配套的权限控制之后,接下来我们以一个例子来演示如何实践这套流程。

分支模型实践

创建版本分支

首先,项目管理员(Master)从 master 分支中创建出版本分支 release-* 进行新版本的开发,* 为发布日期:

1
2
3
4
5
$ git checkout -b release-20190101

do something and commit...

$ git push origin release-20190101

版本分支 release-* 一般是锁起来的,不允许随便提交代码。

创建特性分支

然后,开发人员(Developer)从版本分支中创建出特性分支,并在其上进行特性开发:

1
2
3
4
5
$ git checkout -b feature-test

do something and commit...

$ git push origin feature-test

由于特性分支可能会跨版本开发,因此需要定期维护:主要的工作就是定期将 master 分支或版本分支合并进来,保持同步,代码够新。使用命令:rebase

Merge Request

开发完毕后,开发人员(Developer)需要整理特性分支——例如从中挑选出能够发版的提交,剔除掉不能发版的提交。如果想要筛选出将要被合并的提交有哪些,可以参考这里

整理完毕后,给项目管理员(Master)发起一个 MR,请求合并到版本分支。

标记新版本

当版本分支发布完毕,Master 打 Tag 标记该新版本,以便后续回顾:

1
2
$ git tag tag-20190101 -m "XX 项目 v1.0 版本"
$ git push origin tag-20190101

注意,在默认情况下,git push 并不会把标签(tag)推送到远端仓库上,只有通过显式命令才能分享标签到远端仓库。其命令格式如同推送分支,运行 git push origin [tagname] 即可。如果要一次推送所有本地新增的标签上去,可以使用 --tags 选项。

清理分支

最后是一些清理工作,Master 需要删除已完成开发的版本分支、特性分支,避免分支越来越多导致不好管理。例如:

1
2
$ git branch -d release-20190101
$ git push --delete origin release-20190101

最后,列出所有远程和本地分支确认下:

1
$ git branch -a

总结

代码提交指南

  • 请不要在更新中提交多余的白字符(whitespace)。Git 有种检查此类问题的方法,在提交之前,先运行 git diff --check ,会把可能的多余白字符修正列出来。
  • 请将每次提交限定于完成一次逻辑功能。并且可能的话,适当地分解为多次小更新,以便每次小型提交都更易于理解。
  • 最后需要谨记的是提交说明的撰写。可以理解为第一行的简要描述将用作邮件标题,其余部分作为邮件正文。

分支管理指南

  • 主分支 master 一般不提交代码,只合并代码。
  • 各特性分支要定期将 master 分支合并进来,避免后续处理合并请求时产生冲突,以减轻项目管理员的工作负担。
  • 发版之后,项目管理员要记得打 tag 。

技巧

跟踪分支

查看本地分支和远程分支的跟踪关系:

1
2
3
4
5
$ git branch -vv

* feature-modify-remit-recon f898c3c [origin/feature-modify-remit-recon: ahead 2, behind 6] chore:xxx
feature-recon-history cfbf905 [origin/feature-recon-history] Merge branch master into feature-recon-history
master e1f5e67 [origin/master] chore:xxx

设置本地分支 master 跟踪远程分支 origin/<branch>

1
$ git branch --set-upstream-to=origin/<branch> master

删除本地远程分支

1
2
3
-r
--remotes
List or delete (if used with -d) the remote-tracking branches.

删除本地远程分支

1
$ git branch -r -d origin/branch-name

参考

除了 Git 命令,权限控制也是 Git 中极为重要的组成部分,本文主要介绍 GitLab 系统提供的最常用的权限控制功能。

分配成员角色

首先来了解下,Git 中的五种角色:

角色 描述
Owner Git 系统管理员
Master Git 项目管理员
Developer Git 项目开发人员
Reporter Git 项目测试人员
Guest 访客

每一种角色所拥有的权限都不同,如下图:

Git 权限控制

我们需要做的是,为项目成员分配恰当的角色,以限制其权限。

Protected Branches

在对 Git 不熟悉的时候,时常苦恼于各个分支不受约束,任何开发人员都可以向任何分支直接推送任何提交,各种未经审查的代码、花样百出的 Bug 就这样流窜在预发布分支上。

其实我们可以通过 GitLab 的受保护分支(Protected Branches)功能解决该问题,该功能可用于:

Keep stable branches secure and force developers to use merge requests.

By default, protected branches are designed to:

  • prevent their creation, if not already created, from everybody except Masters
  • prevent pushes from everybody except Masters
  • prevent anyone from force pushing to the branch
  • prevent anyone from deleting the branch

接下来我们就使用这项功能,锁定我们的受保护分支——主分支 master 和预发布分支 release-*,以阻止 Developer 直接向这两类分支中推送代码:

Git 受保护分支

锁定后,Developer 推送代码将会报错:

1
2
3
4
5
6
7
8
9
10
11
$ git push origin master
Counting objects: 4, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 283 bytes | 0 bytes/s, done.
Total 3 (delta 1), reused 1 (delta 0)
remote: GitLab: You are not allowed to access master!
remote: error: hook declined to update refs/heads/master
To git@website:project.git
! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@website:project.git'

Merge Requests

锁定受保护分支后,要么 Master 需要时刻、主动关注各特性分支的进度,要么 Developer 需要线下、口头向 Master 汇报其特性分支的进度,这两种做法都非常不便于 Master 管理每个预发布分支的合并,尤其在团队大、分支多的情况。

我们可以通过 GitLab 的发起合并请求(Merge Request)功能解决该问题,这样既可以让 Developer 更自如的掌控自己分支进度,在必要的时候才主动发起合并请求;又可以减轻 Master 的合并工作量和沟通成本,可谓一举两得。

新建 MR

第一步,按表单要求填写合并请求。注意,对于 Developer 而言:

  • Source branch 是你的特性分支 feature-*
  • Tagget branch 只可能是预发布分支 release-*
  • TitleDescription 要填写恰当的分支描述;
  • Assignee 是该项目的 Master。

新建合并请求

审查 MR

第二步,Master 收到合并请求后,进行代码审查。逐一查看 CommitsChanges 一栏提交的内容即可,对于需要改进的代码,可以直接在该行添加注释,非常方便。

如果对整个请求还有疑问的地方,还可以通过 Discussion 功能进行线上讨论。

处理 MR

第三步,针对审查结果进行相应处理:

关闭

对于完全不合格的垃圾代码、或者废弃的特性分支的合并请求,Master 点击右上角的 Close 按钮即可。合并请求将被关闭,相当于扔进回收站。

改进

对于分支内需改进的代码,Developer 直接修正并推送即可,合并请求将会自动包含最新的推送提交。

接受

Master 审查无误后,可以接受该次合并请求。点击 Accept Merge Request 按钮将自动合并分支,勾选 Remove source-branch 将同时删除该特性分支。

整个自动合并过程如果以命令形式手工执行的话,步骤如下:

1
2
3
4
5
6
7
8
#Step 1. Update the repo and checkout the branch we are going to merge 
git fetch origin
git checkout -b test origin/feature-test

#Step 2. Merge the branch and push the changes to GitLab
git checkout release-2016.4.7
git merge --no-ff feature-test
git push origin release-2016.4.7

非快进式合并完成后,祖先图谱(graph)的展现结果如下:

1
2
3
4
5
*   be512fa (HEAD, origin/release-2016.4.7, release-2016.4.7)  Merge branch 'test' into 'release-2016.4.7'
|\
| * 1f52adf 测试
|/
* a4febbb (tag: 1.0.0, origin/master) 格式化货币保留两位小数

最后需要注意的是,只有 Assignee 才能够接受合并请求,其它人只会被通知:

You don’t have permission to merge this MR

总结

GitLab 提供的上述功能非常实用,为项目的源码管理提供了有力的支持。

项目总归要协作开发,在此总结我在团队中推广使用的分支模型。

A successful Git branching model

分支模型

主分支(Main branches)

企业的项目开发不像开源的项目开发,通常只会有一个远程仓库。这种情况下,通常会有两个常驻分支:

Branch Name Is locked? Description
master YES 主干分支,仅用于发布新版本,平时不能在上面干活,只做代码合并、以及打标记(git tag)。
理论上,每当对 master 分支有一个合并提交操作,我们就可以使用 Git 钩子脚本来自动构建并且发布软件到生产服务器。
dev NO 开发分支,平时干活的地方。每当发版时,需要被合并到 master

对于简单的项目而言,这样的分支模型已经够用了。

辅助性分支(Supporting branches)

除了常驻分支,通常大的特性开发或生产缺陷修复还建议创建相应的临时分支。因为:

  1. 在分支上开发可以让你随意尝试,进退自如,比如碰上无法正常工作的特性或补丁,可以先搁在那边,直到有时间仔细核查修复为止。
  2. 团队中如果有代码审查流程,独立的分支还可以留给审查者抽空审查的时间和改进代码的余地,并将是否合并、是否发布的权利留给审查者,为代码质量设一道门槛。

每一类分支都有一个特定目的,如何命名每一类分支?建议用相关的主题关键字进行命名,并且建议将分支名称分置于不同命名空间(前缀)下,例如:

Branch Name May branch off from Must merge back into Is locked? Description
feature-* dev dev NO 特性分支,为了开发某种特定功能而建。开发完成并测试通过后,需发送 Merge Request 到 release-* 进行代码审查及合版。
release-* dev dev
master
YES 预发布分支,为了新版本的发布做准备,一般命名为 release-<版本号>。这是一个稳定分支,只接受审核通过的 Merge Request。
hotfix-* master dev
master
NO 补丁分支,为了修复生产缺陷而建,一般命名为 hotfix-<issue 编号>

与主分支不同,这些辅助性分支总是有一个有限的生命期,因为他们在被合并到主分支之后,就会被移除掉。

参考

对比两个分支中,所有文件的详细差异,常用于合并操作之后确认有没有遗漏文件:

1
$ git diff branch1 branch2

对比两个分支中,指定文件的详细差异:

1
$ git diff branch1 branch2 文件名(带路径)

对比两个分支中,差异的文件列表:

1
$ git diff branch1 branch2 --stat

前言

本文总结出一些广受认可的编程最佳实践,用于解决特定领域的问题。

编程最佳实践

避免使用全局变量

在 JavaScript 所有的糟糕特性之中,最为糟糕的一个就是它对全局变量的依赖。JS 大神 Douglas Crockford 甚至称之为“毒瘤”。想象一下,一个全局变量可以被程序的任何部分在任意时间修改,将使得程序的行为变得极度复杂。可怕的全局变量还带来了以下问题:

  1. 命名冲突
  2. 代码的脆弱性
  3. 难以测试

共有三种方式定义全局变量,这些方式都是我们要避免的:

1
2
3
var foo = value;       // 1、在任何函数之外放置一个 var 语句
window.foo = value; // 2、直接给全局对象添加属性
foo = value; // 3、直接使用未经声明的变量,即隐式的全局变量。一般都是开发者忘记声明,这将导致查找 bug 非常困难

下面是一些解决办法:

零全局变量

如果你编写的是一段不会被其它脚本访问到的完全独立的脚本,可以使用一个立即执行的匿名函数来创建私有作用域

单全局变量

最小化使用全局变量的方法之一是为你的应用创建唯一一个全局变量,并将你所有的功能代码都挂载到这个全局对象上。这种做法既降低了模块之间发生冲突的可能,又能保证模块之间的正常通信。可以参考 JavaScript 模块模式

目前这种单全局变量模式已经在各种流行的库中广泛使用了:

  • jQuery 定义了两个全局对象,$jQuery。只有在 $ 被其它库使用了的情况下,为了避免冲突,才使用 jQuery
  • YUI 定义了唯一一个 YUI 全局对象。
  • Dojo 定义了唯一一个 dojo 全局对象。
  • ……

模块化

最后一种、也是最为推崇的做法是使用“模块化”方式组织代码:

不是你的对象不要动

JavaScript 独一无二之处在于任何东西都不是神圣不可侵犯的。默认情况下,你可以修改任何你可以触及的对象。解析器根本就不在乎这些对象是开发者定义的还是默认执行环境的一部分——只要是能访问到的对象都可以修改。如果你的代码没有创建这些对象,禁止修改它们,包括:

  • 原生对象(ObjectArray 等等);
  • 文档对象模型(DOM)(document 等等);
  • 浏览器对象模型(BOM)(window 等等);
  • 类库的对象($jQuery 等等)。

原则

不覆盖方法

覆盖方法将会导致所有依赖该方法的代码失效:

1
2
3
4
// 不好的写法 - 覆盖了 DOM 方法
document.getElementById = function() {
// 任意代码
};

不新增方法

新增方法将会导致未来潜在的命名冲突,因为一个对象此刻没有某个方法不代表它未来没有。更糟糕的是如果将来原生的方法和你新增的方法行为不一致,将会陷入一场代码维护的噩梦:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 不好的写法,在 DOM 对象上增加了方法
document.getElementsByClassName = function(classes) {
// 非原生实现。
// 该新增方法在 HTML 5 中被官方实现了,这将会导致所有依赖该方法的代码报错。
};
// 不好的写法,在原生对象上增加了方法
Array.prototype.reverseSort = function() {
return this.sort().reverse();
};
// 不好的写法,在库对象上增加了方法
$.doSomeThing = function() {
// 任意代码
};

不删除方法

删除方法将会导致所有依赖该方法的代码运行时错误。对于已发布的库来说,无用的方法应该被标识位“废弃”而不是直接删掉:

1
2
// 不好的写法 - 删除了 DOM 方法
document.getElementById = null;

解决办法

下面介绍一些解决方法:

继承

如果一种类型的对象已经做到了你想要的大多数工作,那么继承它然后再新增一些功能是最好的做法。JavaScript 中有两种基本的继承形式:

  • 基于对象的继承
  • 基于类型的继承

例如:

1
2
3
4
5
var MyError = function(message) {
this.message = message;
};

MyError.prototype = new Error(); // 基于类型的继承,继承自原生的 Error 类

门面模式

JavaScript 的继承有一些很大的限制,就是无法继承自 DOM 或 BOM 对象。解决办法是利用门面模式为这些已存在的对象创建一个新的接口,达到二次封装的效果。jQuery 和 YUI 的 DOM 接口都使用了门面模式。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 自定义一个 DOM 对象包装器
var DOMWrapper = function(element) {
this.element = element;
};

DOMWrapper.prototype = {
constructor: DOMWrapper,
addClass: function(className) {
this.element.className += ' ' + className;
},
remove: function() {
this.element.parentNode.removeChild(this.element);
}
};

// 用法
var wrapper = new DOMWrapper(document.getElementById("my-div"));
// 添加一个 className
wrapper.addClass("selected");
// 删除元素
wrapper.remove();

事件处理

解耦事件处理

事件处理常见的问题是将事件处理程序和业务逻辑紧紧耦合在一起,降低了代码的可维护性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不好的写法
var handleClick = function(event) {
// DOM Level 2
event.preventDefault();
event.stopPropagation();

// 耦合业务逻辑
var popup = document.getElementById("popup");
popup.style.left = event.clientX + "px";
popup.style.top = event.clientY + "px";
popup.className = "reveal";
}

document.getElementById('btn-action')
.addEventListener("click", handleClick, false); // DOM Level 2

正确的做法应该是解耦事件处理程序和业务逻辑,提高代码的可维护性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 好的写法

// 事件处理程序,唯一能接触 event 对象的函数
var handleClick = function(event) {
// DOM Level 2
event.preventDefault();
event.stopPropagation();

showPopup(event.clientX, event.clientY);
},
// 抽取业务逻辑,与事件隔离,便于重用与测试
showPopup = function(x, y) {
var popup = document.getElementById("popup");
popup.style.left = x + "px";
popup.style.top = y + "px";
popup.className = "reveal";
}

document.getElementById('btn-action')
.addEventListener("click", handleClick, false); // DOM Level 2

可见,业务逻辑不应该依赖于 event 对象来完成功能,原因如下:

  • 好的 API 一定是对于期望和依赖都是透明的,因此方法接口应该表明哪些数据是必要的。将 event 对象作为参数并不能告诉你 event 的哪些属性是有用的,用来干什么?
  • 如果想测试这个方法,你必须构建一个 event 对象并作为参数传入。这迫使你关注方法内部实现,以确切地知道这个方法使用了哪些信息,这样才能正确地写出测试代码。

使用事件委托

关于“事件绑定(Event Binding)”和“事件委托(Event Delegation)”两种机制的区别在 本文 有详细的描述。简而言之,从“内存消耗”、“处理速度”、“新增元素的处理”三方面考虑,都更建议使用“事件委托”。下例演示了如何使用 jQuery 语法进行“事件委托”:

1
2
3
$('#list').on('click', 'li', function() {
//function code here.
});

#list 内任一 li 子元素被点击时,click 事件将冒泡到其父元素 #list 并触发 #list 的事件处理程序,即子元素的事件都委托给父元素进行处理。这种做法有利于提升性能,推荐使用。

UI 层保持松耦合

保持 Web UI 层的松耦合,以便在以下场景中调试代码,定位问题:

  • 当发生了文本或结构相关的问题,通过查找 HTML 即可定位;
  • 当发生了样式相关的问题,通过查找 CSS 即可定位;
  • 当发生了行为和交互相关的问题,通过查找 JavaScript 即可定位。

这种快速定位问题的能力是 Web 界面可维护性的核心关键。

将 JavaScript 从 CSS 中抽离

禁止使用 CSS 表达式(CSS Expression)。

1
2
3
4
// 不好的写法
.box {
width: expression(document.body.offsetWidth + "px");
}

CSS 表达式是 IE8 及更早版本中的一个特性,它允许你将 JavaScript 直接插入到 CSS 中,这样可以在 CSS 代码中直接执行运算或其它操作。但 CSS 表达式会带来两个问题:

  • 性能问题
  • 代码可维护性问题

将 CSS 从 JavaScript 中抽离

禁止在 JavaScript 脚本中直接操作 CSS 样式:

1
2
3
4
// 不好的写法
element.style.color = 'red';
element.style.left = '10px';
element.style.cssText = 'color: red; left: 10px';

当需要通过 JavaScript 来操作元素样式的时候,最佳方法是操作 CSS 的 className

1
2
element.className = 'className';    // 原生方法
$(element).addClass('className'); // jQuery

CSS 的 className 应该成为 CSS 和 JavaScript 之间通信的桥梁。JavaScript 不应当直接操作 CSS 样式,以便保持和 CSS 的松耦合。

将 JavaScript 从 HTML 中抽离

禁止在 HTML 标签中嵌入 JavaScript 脚本:

1
2
<!-- 不好的写法,不该直接为 HTML 标签的 on 属性挂载事件处理程序 -->
<button onclick="doSomeThing()" id="btn-action">Click Me</button>

这样会导致 HTML 页面和 JavaScript 脚本紧紧耦合。正确的做法应当是在外部脚本文件中添加事件处理程序:

1
2
3
4
5
var doSomeThing() {  }

document.getElementById('btn-action')
.addEventListener("click", doSomeThing, false); // DOM Level 2
$('#btn-action').click(doSomeThing); // jQuery

这种做法的优势在于,函数 doSomeThing() 的定义和事件处理程序的绑定都是在同一个文件中完成的。如果函数名称需要修改,则只需修改一个文件即可;如果点击发生时想额外做一些动作,也只需在一处做修改。

此外,不到迫不得已,不建议在 HTML 页面中嵌入 JavaScript 脚本:

1
2
3
4
<!--  不好的做法 -->
<script>
doSomeThing();
</script>

将 HTML 从 JavaScript 中抽离

不建议在 JavaScript 脚本文件中嵌入 HTML 操作:

1
2
3
// 不好的做法
var div = document.getElementById('my-div');
div.innerHTML = "<h3>Error</h3><p>Invalid e-mail address.</p>";

这样会导致 JavaScript 脚本和 HTML 标签紧紧耦合,从而降低了代码的可维护性,增加了跟踪文本和结构性问题的复杂度。正常来说,调试上述这段标签的典型方法,应当是先去浏览器调试工具中的 DOM 树中查找,然后打开页面的 HTML 源码对比其不同。一旦 JavaScript 脚本文件中做了除简单 DOM 操作之外的事情,如渲染标签,追踪 Bug 就变得很麻烦。因为脚本和标签都耦合成一坨了,让人望而却步。

HTML 文本和标签应该只存放于一个地方:可以控制你 HTML 代码的地方。最为推崇的做法是利用 JavaScript 模板引擎 解决这个问题。

项目中我引入了模板引擎 artTemplate 进行 HTML 渲染,并通过修改源码内置了两个常用的格式化工具:

详见 DEMO:finance-marketres-mobi\js\utility\util-demo.html

参考

  • 《编写可维护的 JavaScript》
  • 《JavaScript 高级程序设计》
  • 《JavaScript 权威指南》
  • 《JavaScript 语言精粹》

前言

在团队开发中,所有的代码看起来风格一致是极其重要的,原因有以下几点:

  • 任何开发者都不会在乎某个文件的作者是谁,因为所有代码排版格式看起来应当是非常一致,不该花费额外精力去理解代码逻辑并重新排版。
  • 风格一致能够让人很容易识别出问题代码并发现错误。如果所有代码看起来很像,当你看到一段与众不同的代码时,很可能错误就产生在这段代码中。

当项目变得庞大时,统一的编程风格能够节省的大量时间成本。

基本编程风格

本节编程风格(Style Guideline)是用于规范单文件中的代码,使团队编程风格保持一致。

缩进层级

每一行的层级由 四个空格 组成,避免使用制表符(Tab)进行缩进,以便在所有的系统和编辑器中,文件的展现格式不会有任何差异。建议在文本编辑器中配置敲击 Tab 键时插入四个空格。

1
2
3
4
// 好的写法
if (true) {
doSomething();
}

行的长度

每行长度不应该超过 80 个字符。如果一行多于 80 个字符,应当在一个运算符(逗号、加号等)后换行。下一行应当增加两级缩进(8 个字符)

1
2
3
4
5
6
7
8
9
10
11
// 好的写法
doSomething(arg1, arg2, arg3, arg4,
arg5);

// 不好的写法:第二行只有 4 个空格的缩进
doSomething(arg1, arg2, arg3, arg4,
arg5);

// 不好的写法:在运算符之前换行
doSomething(arg1, arg2, arg3, arg4
, arg5);

语句格式

  • 始终使用分号 ; 结束一个语句。禁止省略分号,因为:
    • 后续使用构建工具时,可以通过自动删除多余的空格和换行来压缩代码行(代码行结尾处没有分号会导致压缩错误)。
    • 在某些情况下增进代码的性能,因为这样解析器就不必再花时间推测应该在哪里插入分号了。
    • 避免解析器错误的插入分号,导致程序报错。
  • 始终使用花括号 {} 包住块语句,可以让编程意图更清晰,降低修改代码时出错的几率。

这里展示了一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 不好的写法,缺少花括号
if (condition)
doSomething();

// 不好的写法,左花括号应当放在块语句中第一句代码的末尾
if (condition)
{
doSomething();
}

// 不好的写法,缺少空格间隔
if(condition){
doSomething();
}

// 不好的写法,缺少适当的换行
if (condition) { doSomething(); }

// 不好的写法,缺少分号结尾
if (condition) {
doSomething()
}

// 好的写法
if (condition) {
doSomething();
}

操作符间隔

二元操作符(如赋值、逻辑运算)前后必须使用一个空格来保持表达式的整洁。

1
2
3
4
5
// 好的写法
var found = (value[i] === item);

// 不好的写法:丢失了空格
var found=(value[i]===item);

注释声明

注释有时候可以用于给一段代码声明额外的信息。这些声明的格式如下:

注释声明 描述
TODO 说明代码还未完成。此时应当描述下一步要做的事情。
HACK 说明代码实现走了一个捷径。此时应当描述为何使用 hack 的原因。这也可能表明该问题可能会有更好的解决方法。
FIXME 说明代码是有问题的需要尽快修复。此时应当描述问题出在哪里,或者提供解决方案。
REVIEW 说明代码任何可能的改动都需要评审。

注释声明可以用于单行或多行注释,例如:

1
2
3
4
5
6
7
8
9
10
// TODO: 我希望找到一种效率更快的实现方式
doSomething();

/*
* HACK: 不得不针对 IE 做的特殊处理。我计划后续有时间时
* 重写这部分。这些代码可能需要在 v1.2 版本之后替换掉。
*/
if (document.all) {
doSomething();
}

变量命名

  • 变量命名使用小驼峰式(Camel Case)命名法,即以小写字母开头,后续每个单词首字母都大写。
  • 常量命名使用大写字母和下划线。
  • 私有属性、方法使用下划线前缀:_

常量

所有字母大写,不同单词之间用单个下划线 _ 分隔。

构造函数

构造函数使用大驼峰式(Pascal Case)命名法,即以大写字母开头,后续每个单词首字母都大写。

函数变量

函数变量使用前缀:fn

DOM 变量

  • class:使用全小写字母 + 中划线的形式命名。如果该类是用于在 JS 中引用的,还需要添加前缀 js-。注意用于 JS 的类严禁用于样式文件中引用。
  • id:使用小驼峰命名,并添加前缀如下:
前缀 描述
ipt input 输入框
btn 按钮
lbl Label
chk CheckBox
lnk A链接
img 图片

禁止使用的

包装类型

JavaScript 中有三种基本包装类型BooleanNumberString,每种类型都代表全局作用域中的一个构造函数,并分别表示各自对应的原始值的对象。基本包装类型的主要作用是让原始值具有对象般的行为。

禁止使用这些基本包装类型声明变量,应该直接使用对应的字面量:

类型 描述 注意项
布尔值 统一使用字面量 truefalse 而不是构造函数 new Boolean()
数字值 统一使用字面量,而不是构造函数 new Number() 避免使用八进制字面量
字符串 统一使用单引号 '',而不是构造函数 new String() 避免在字符串中使用斜杠 \ 另起一行
对象 统一使用字面量 {} 而不是构造函数 new Object()
数组 统一使用字面量 [] 而不是构造函数 new Array()

等号操作符

由于相等(==)和不相等(!=)操作符存在 自动类型转换 的问题,因此禁止使用。为了保持代码中数据类型的完整性,要求使用全等(===)和不全等(!==)操作符。

代码执行

setTimeout()setInterval() 函数中的回调代码禁止使用字符串格式。

eval() 函数禁止使用。

空链接跳转

常用的三种空链接跳转:

1
2
3
#
javascript:void(0);
javascript:; // 推荐这种

进阶编程风格

变量声明

在具有块级作用域的语言中,在狭小的作用域内让变量声明和使用变量的代码尽可能彼此靠近,通常是个好的编程习惯。因此在编写 JavaScript 时常常会出现类似的惯性思维:

1
2
3
4
for(var i = 0; i < 3; i++) {
console.log('for 语句内,i=' + i);
}
console.log('for 语句外,i=' + i); // 注意这里,JavaScript 没有块级作用域,因此 for 语句外仍然可以读取变量 i

输出如下:

1
2
3
4
for 语句内,i=0
for 语句内,i=1
for 语句内,i=2
for 语句外,i=3 // 注意这里

但由于 JavaScript 中并没有块级作用域(block scope),只有函数作用域(function scope),因此函数内声明的所有变量在函数体内始终是可见的。这个特性被非正式地称为 声明提前(hoisting),即 JavaScript 函数内声明的所有变量(但不涉及赋值)都被“提前”至函数顶部。这步操作是在代码开始运行之前、JavaScript 引擎的“预编译”阶段进行的。上述代码编译如下:

1
2
3
4
5
var i;    // 变量声明提前
for(i = 0; i < 3; i++) {
console.log('for 语句内,i=' + i);
}
console.log('for 语句外,i=' + i);

变量声明提前意味着:在函数内部任意地方声明变量和在函数顶部声明变量是完全一样的。为了让源代码能够非常清晰地反映出真实的变量作用域,避免潜藏错误,规范要求始终在函数顶部使用单 var 语句统一声明所有变量,例如:

1
2
3
4
5
// 每个变量声明都独占一行,同时注意每行的缩进
var iptUsername = $('input[name="username"]'),
iptPwd = $('input[name="pwd"]'),
btnLogin = $('#js-btn-login'),
fnLogin = function() {};

函数声明

和上述变量声明提前一样,函数声明也会被 JavaScript 引擎提前(function declaration hoisting)。因此,在代码中函数的调用可以出现在函数声明之前:

1
2
3
4
5
6
// 不好的写法
doSomeThing();

function doSomeThing() {
console.log('Hello world!');
}

这段代码是可以正常运行的,因为 JavaScript 引擎将这段代码解析为:

1
2
3
4
5
6
// 函数声明提前
function doSomeThing() {
console.log('Hello world!');
}

doSomeThing();

由于 JavaScript 的这种行为会放宽函数必须 先声明后使用 的要求,因此会导致代码混乱。

规范要求函数始终 先声明后使用

函数表达式

更好的办法是使用 函数表达式 代替函数声明:

1
2
3
4
5
6
// 好的写法
var doSomeThing = function() {
console.log('Hello world!');
};

doSomeThing();

这种形式看起来像是常规的变量赋值语句,即创建一个函数并将它赋值给变量 doSomeThing。这种情况下创建的函数叫做 匿名函数(anonymous function)(也称为 拉姆达函数),因为 function 关键字后面没有标识符,其 name 属性为空。

与使用函数声明的区别在于,如果执行顺序颠倒,函数调用 doSomeThing() 将会报错。因为函数表达式必须等到解析器执行到它所在的代码行,才会真正被解释执行:

1
2
3
4
5
typeof doSomeThing === 'undefined';    // true

var doSomeThing = function() {
console.log('Hello world!');
};

除此之外,函数声明与函数表达式的语法其实是等价的。尽管如此,规范仍然要求优先使用函数表达式,原因有二:

  • 强制开发者 先声明后使用 函数,避免函数声明提升带来的混乱;
  • 函数表达式更能明确表示一个包含函数的变量。要学好这门语言,理解 函数就是对象 是很重要的。因为函数是对象,所以它们可以像任何其它的值一样被使用。例如:
    • 函数可以保存在变量、对象和数组中;
    • 函数可以被当做 参数 传递给其它函数,也可以被作为函数的 返回值
    • 函数可以拥有方法。

立即执行的函数

使用函数表达式可以声明匿名函数,并将匿名函数赋值给变量或者属性:

1
2
3
4
5
var doSomeThing = function() {
return 'doSomeThing';
};

typeof doSomeThing === 'function'; // true

这种匿名函数可以通过在最后加上一对圆括号 ()立即执行并返回 一个值给变量:

1
2
3
4
5
6
// 不好的写法
var doSomeThing = function() {
return 'doSomeThing';
}();

typeof doSomeThing === 'string'; // true

这种写法的问题在于,会让人误以为将一个匿名函数赋值给了这个变量。除非读完整段代码并看到最后一行的那对圆括号 (),否则你不会知道是将函数赋值给变量还是将函数的执行结果赋值给变量。这种困惑会影响代码的可读性。

为了让立即执行的函数能够被一眼看出来,可以用一对圆括号 () 将函数包起来。这样做并不会影响代码的执行结果,却能让人一眼就看出这是个立即执行的函数:

1
2
3
4
5
6
// 好的写法
var doSomeThing = (function() {
return 'doSomeThing';
})();

typeof doSomeThing === 'string'; // true

创建私有作用域

此外,还可以使用立即执行的匿名函数(immediately executed anonymous function)来创建私有作用域,从而解决全局变量污染的问题。这种函数一般是没有返回值的:

1
2
3
(function() {
var hidden_variable = 'Hello world!'; // hidden_variable 只是一个局部变量
})()

要注意的是在这种场景下,函数表达式外的那对圆括号 () 绝不能省略,因为官方的语法假定以单词 function 开头的语句是一个函数声明语句,而函数声明语句是无法匿名的,否则会报错。

参考

  • 《编写可维护的 JavaScript》
  • 《JavaScript 高级程序设计》
  • 《JavaScript 权威指南》
  • 《JavaScript 语言精粹》