开篇概述
ajax技术在gmail中的成功应用,和高性能的v8引擎的推出,使得编写web应用变得流行起来,使用前端技术也可以编写具有复杂交互的应用。相对于原生应用,web应用具有如下优点:
跨平台,开发和维护成本低;
升级和发布方便,没有版本的概念,随时随地发布,用户没有感知,不需要安装;
响应式设计(responsive design)使得web应用可以跨平台,同一份代码自适应各种 屏幕大小
即使最终不采用web应用方案,也很适合开发原型
当然,web应用也不是没有缺点。由于不同平台和厂商的浏览器并不完全一样,跨平台也有一些兼容成本。另外,web应用的性能不如native应用,交互有时候不是很流畅, 再加上html5的api上的限制,使得有些功能采用web应用不太合适。由于这些原因,结合两者优点的混合方案变得流行起来(比如微信、手机qq和手机qq浏览器中会嵌入一 些web页面)。
根据笔者的开发经验,下面总结一些web应用开发过程中的要面临的几个问题。
模块化编程
模块化编程是编写大规模应用必不可少的一个特性,与其它主流的编程语言相比 javascript没有对模块提供直接的支持,更不用说维护模块之间的依赖关系,这使得维 护javascript代码变得异常困难,在3f1c4e4b6b16bbbd69b2ee476dc4f83a标签中包含代码的顺序需要人工维护。
要支持模块化编程必须解决两个问题:
支持编写模块并为模块命名,防止名字冲突和全局变量的使用;
支持显示指定模块之间的依赖关系,并在程序执行时自动加载依赖的模块。
douglas crockford在”javascript: the good parts”一书中提出的module pattern利用javascript的闭包技术来模拟模块的概念,防止名字冲突和全局变量的使用。这解决了第一个问题。
var modulename = function () {
// define private variables and functions
var private = ...
// return public interface.
return {
foo: ...
};
}();
为了解决第二个问题commonjs组织定义了 amd规范方便 开发者显示指定模块之间的依赖关系,并在需要时加载依赖的模块。 requirejs是amd规范的一个比较流行的实现。
首先我们在a.js中定义模块a.
define(function () {
return {
color: "black",
size: 10
};
});
然后定义模块b依赖模块a.
define(["a"], function (a) {
// ...
});
当模块b执行时requirejs保证模块a已被加载。具体细节可参考requirejs官方文档。
脚本加载
最简单的脚本加载方式是放在<head>加载。
<head>
<script src="base.js" type="text/javascript"></script>
<script src="app.js" type="text/javascript"></script>
</head>
其缺点是:
加载和解析是顺序是同步执行的,先下载base.js然后解析和执行,然后再下载 app.js;
加载脚本时还会阻塞对<script>之后的dom元素的渲染。
为了缓解这些问题,现在的普遍做法是将<script>放在<body>的底部。
<script src="base.js" type="text/javascript"></script>
<script src="app.js" type="text/javascript"></script>
</body>
但并不是所有的脚本都可以放在<body>的底部,比如有些逻辑要在页面渲染时执行, 不过大多数脚本没有这样的要求。
将脚本放在<body>底部仍然没有解决顺序下载的问题,一些浏览器厂商也意识到了 这个问题并开始支持异步下载。html5也提供了标准的解决方案:
<script src="base.js" type="text/javascript" async></script>
<script src="app.js" type="text/javascript" async></script>
标上async属性的脚本表明你没有在里面使用document.write之类的代码。浏览器 将异步下载和执行这些脚本,并且不会组织dom树的渲染。但是这会导致另一个问题: 由于是异步执行,app.js可能在base.js之前执行,如果它们之间有依赖关系这将导致错误。
讲到这里从开发者角度来看我们其实需要的是这些特性:
异步下载,不要阻塞dom的渲染;
按照模块的依赖关系解析和执行脚本。
所以脚本的加载其实需要与模块化编程问题结合起来解决。requirejs不仅记录了模 块之间的依赖关系,并且提供了根据依赖关系的按需加载和执行(详情请参考 requirejs官方文档)。
关于脚本加载的更多方案请看 这里.
静态资源文件的部署
这里的静态资源文件是指css、javascript和css需要的一些图片文件。它们的部署需 要考虑两个问题:
下载速度
版本管理
静态资源文件的一个特点变化不频繁,且与用户身份无关(即与cookie无关),因此很适合缓存。另一方面,一旦静态资源文件变化时,浏览器必须从web服务器下载最新 的版本。当发布新版本的web应用时,并不是所有用户马上就用上新版本,老版本和新版本将会共存,这就涉及到版本匹配问题。老版本的应用需要下载老版本的css和 javascript,新版本的应用需要下载新版本的静态资源。
为了防止版本不一致,每当发布新版本的应用时静态资源文件都需要改名,让旧的 html引用旧的静态文件,新的html引用新的静态文件。一个常见办法就是在文件名 中加时间戳;
为了防止悬挂引用,资源文件应该比html先发布。
上述方案可以解决版本问题,这样每个静态文件的缓存时间可以设置得任意大,防止重复下载,同时在新版本发布时浏览器将及时更新。
为解决下载速度问题,可以考虑以下几个方案:
合并静态文件以免文件数量过多,过多的文件需要更多的连接来下载,浏览器通常 对同一个域名的连接数量有限制;
压缩静态文件;为了可读性,css和javascript通常有很多空行、缩进和注释,这 些在发布时都可以去掉;
静态文件通常与cookie没有关系,所以为了减小传输大小和增加缓存命中率(缓存的key需要考虑cookie),静态文件最好托管在没有cookie的域名上;
最后也是最重要的,要使上述过程自动化。
mvc编程模型
web应用采用的是事件驱动编程模型,与native应用是一样的,区别仅在于基础设施提供的api不一样。ui编程通常采用mvc设计模式,以流行的 backbone.js为例包括如下部分:
model
数据的唯一来源
负责获取和存储数据
可提供缓存机制
数据变化时通过事件通知其它对象
view
负责渲染
监听ui事件和model事件并重绘ui
渲染结果取决于两类数据:model和ui交互状态
ui的交互状态通常存在view对象中,有时候为了方便也存在dom树节点中
为了降低渲染成本,尽量减少需要渲染的区域,每次当数据变化时只渲染受影响 的区域
router
负责监听url的变化,并通知相应的view对象渲染页面
为了有效地使用mvc,有几个问题需要注意。
model应与view完全隔离
model仅提供数据的访问,不应该依赖view,因此model不应该知道view的存在。所以 model不能持有对任何view对象的引用。model的数据发生变化时只能通过事件通知 view.
view在初始化时采用委派方式监听ui事件
这里有两个关键点:
在初始化时监听事件var view = backbone.view.extend({ initialize: function () { this.$el.on(‘click’, ‘#id’, function () { // … }); } });
除了一些特殊情况外(请看下文),所有ui事件都应该在view初始化时初始化,防止同 一个事件被绑定多次。即使有些事件是动态监听的(有时候需要监听,有时候有不需要 监听,比如有些按钮有时候是有效的,有时候又无效),也需要在初始化时监听,然后 在事件回调函数里判断是否需要处理。这样逻辑更简单,更容易维护。
采用委派方式监听ui事件
关于委派方式监听请参考jquery文档.
上面已强调要在初始化时监听事件,但是初始化时需要监听的dom节点可能还不存在, 所以没法直接绑定事件,只能采用委派方式。不过采用委派方式要求事件可以冒泡。
对于那些没法冒泡的事件(比如<img>的load事件)只能在保证其存在的情况下直 接绑定,而不一定要在初始化时绑定。
复杂的view组织成树形层次结构
函数太大了需要拆分成几个子函数。同样,view的逻辑如果过于复杂也应根据页面结 构拆成几个子view:
父view通过引用访问子view,但是子view不应该持有父view的引用;
子view只负责自己区域的渲染,其它区域由父view负责渲染;
父view通过函数调用访问子view的功能,子view通过事件与父view通信;
子view之间不能直接通信。
其它技巧可查看 backbone技巧与模式.
离线应用缓存
为使web应用体验更加流畅,可考虑使用html5离线应用缓存,不过有以下几点需要注 意:
不要将离线应用缓存与http缓存机制搞混淆,前者是html5引入的新特性,与http缓 存机制是相互独立并存的;
cache manifest文件不应被http缓存太久(通过http头cache-control控制缓存 时间),否则发布新版后浏览器不会及时监测到变化并下载新文件;
在cache manifest文件的network节放一个*,否则没有列在这个文件的资源不 会被请求;
不适合缓存的请求最好都放在network节;
如果之前使用过离线应用缓存现在不想再使用了,从<html>删除manifest属性, 并发送404响应给manifest文件请求。仅仅删除manifest属性是没有效的。
线上错误报告
javascript是一个动态语言,许多检查都是在运行时执行的,所以大多数错误只有执行到的时候才能检查到,只能在发布前通过大量测试来发现。即使这样仍可能有少数 没有执行到的路径有错误,这只能通过线上错误报告来发现了。
window.onerror = function (errormsg, fileloc, linenumber) {
var s = 'url: ' + document.url + '\nfile: ' + fileloc
+ '\nline number: ' + linenumber
+ '\nmessage: ' + errormsg;
log.error(s); // 发给服务器统计监控
console.log(s);
};
通常线上的javascript都是经过了合并和压缩的,上报的文件名和行号基本上没法对 应到源代码,对查错帮助不是很大。不过最新版的chrome支持在onerror的回调函数 中获取出错时的栈轨迹:window.event.error.stack.
