SoundCloud是世界领先的基于声音分享的社交平台,每个人可以录制并上传自己的声音,同时分享给社区的好友。SoundCloud前端技术团队,不断通过技术的创新来提升用户体验,打造下一代单页面应用,并分享了技术实现的心得体会。
构建单页面应用之JavaScript选型
下一代SoundCloud应用最重要的一个特性是在不打断用户通过导航寻找其他声音的前提下,可以回放之前播放的track(声音片段),这相当于,界面右上方总会悬浮一个迷你播放面板,每当用户想回放上一个track,一次不刷新页面的点击就可以解决问题。这势必会鼓励用户根据当前的页面导航,不断寻求新的内容,此类行为会通过点击完成,每次点击应该保证又快又平滑。在系统层面保证又快又平滑是将下一代应用定位为单页面应用的重要原因(数据通过统一的API获取,前端的展现和用户点击行为,通过前端技术处理以获取更好的体验)。
图1:悬浮按钮
在前端JavaScript技术框架的选型上,SoundCloud推崇Backbone.js,原因除了在手机站点的实践经验外,Backbone.js会对前端进行分层:Views(视图),Data
Model(数据)以及Collection(集合)等。剩下的业务逻辑以及组件的具体实现,会留给应用端自己处理,这就意味着应用端有非常大的灵活性。
以生成视图Rendering Views为例,SoundCloud选择Handlebars作为页面模板库,Handlebars与其他模版库相比有以下优势:
- 模版内部没有具体的逻辑,便于解耦
- 模版可以通过预编译,获取在浏览器更快的渲染性能(运行时库只有3.3kb大小)
- 支持自定义custom helpers
代码的模块化
模块化代码技术非常受用:将独立的功能,编写到独立的模块中去,并在外部显式声明模块之间的依赖关系。
SoundCloud前端会按照CommonJS-style modules规范来编写代码,在浏览器执行的时候转换为AMD
modules书写的方式。为什么这样做,通过代码解释:
// CommonJS module ////////////////
var View = require('lib/view'),
Sound = require('models/sound'),
MyView;
MyView = module.exports = View.extend({
// ...
});
// Equivalent AMD module //////////
define(['require', 'exports', 'module', 'lib/view', 'models/sound'],
function () {
var View = require('lib/view'),
Sound = require('models/sound'),
MyView;
MyView = module.exports = View.extend({
// ...
});
}
); |
- 按照AMD modules规范,define的书写很烦琐
- 模块之间的依赖关系会重复声明,也很容易犯错
- CommonJS-style modules到AMD modules的转换,很容易自动化
本地开发的时候,为了提高开发效率,使用RequireJS分别对模块进行加载。但线上就不方便使用RequireJS作为模块加载器了(这会导致创建上百个HTTP请求),这时候更轻量级模块加载器
AlmondJS就会派上用场(它会根据需要合并模块并打包)。
将CSS和Templates同样视为模块依赖
既然已经应用模块化的设计思想,将CSS以及Templates视为模块依赖也不足为奇了。将模版定义为模块依赖非常好理解,因为模版可以通过Handlebars预编译为JavaScript函数功能组件。而CSS是一个截然不同的范型:
视图会指定关联的CSS做显示,而且每当这个视图需要显示的时候,才会关联相应的CSS(当然,全局CSS除外)。CSS以纯vanilla
CSS的方式书写,为了使用RequireJS/AlmondJS作为模块加载器加载CSS,CSS会被转换为功能独立的模块。这步操作需要一个构建过程:将CSS文本包装起来,转换为一个独立的功能,该功能返回的结果是一个Dom元素。
以下是将一段CSS代码转换为AMD modules的示例:
Input is plain CSS
.myView {
padding: 5px;
color: #f0f;
}
.myView__foo {
border: 1px solid #0f0;
}
Result is an AMD module
define("views/myView.css", [...], function (...) {
var style = module.exports = document.createElement('style');
style.appendChild(
document.createTextNode('.myView { padding: 5px; color ... }');
);
}) |
视图也是逻辑组件
下一代SoundCloud应用一个很核心的理念是:将视图看做是独立的、可重用的组件。逻辑上,每个视图组件可以引用其它视图,同样被引用的视图也可以引用其它视图,以此类推。那么,整个页面就是由不同的视图组成,视图的粒度可大可小,可以小到一个按钮或者标签。
保持每个视图的独立性是很重要的,每个视图有自己的设置、数据、事件等属性,但不能改变子视图的行为和显示,甚至不能决定自己是如何被其他视图引用的。这样每个视图就是功能独立的,可以热插拔的组件。
以下一代播放按钮视图作为例子,如果想在页面某个位置放这样一个视图组件,第一步是创一个该视图的实例,并告诉它需要播放的声音ID,至于如果播放我们无需操心。
至于创建子视图,会通过custom Handlebars helper在父视图中进行,示例代码如下:
<div class="listenNetwork__creator">
{{view "views/user/user-badge" resource_id=user.id}}
</div> |
添加子视图非常简单,只需要指定模块名称,并将参数传递过去,接下来就是模版引擎解析上述模版片段:
首先模版引擎会将模版的属性、传递的参数以及对应的视图类,保存到一个临时对象中去(theTemporaryObject),并以一个唯一的、自增长ID做key,接着上述模版会被替换为一段格式化的字符串:
<div class="foo">
<view data-id="123"></view>
</div> |
<view>标签是占位符,data-id对应访问临时对象theTemporaryObject的key
最后模版引擎找到占位符,替换为对应子视图的内容:
parentView.$('view').each(function () {
var id = this.getAttribute('data-id'),
attrs = theTemporaryObject[id],
SubView = attrs.ViewClass,
subView = new SubView(attrs);
subView.render(); // repeat the process again
$(this).replaceWith(subView.el);
}); |
视图之间共享数据
单个页面会有许多视图,其中有许多视图会基于相同的数据,举个具体的例子listen页面:
图2:声音播放面板
很明显,播放按钮、音频标题、波形图都算是页面视图,但它们都基于相同的数据——声音模型,所有人都不希望各个视图分别创建声音模型数据,必须找到一种方式在视图之间共享数据。
需要谨记的一件事是每个视图初始化的时候,很可能只有对应数据模型的ID,而数据模型还未加载。为了解决这个问题,SoundCloud使用一种叫实例存储的方式,具体来讲实例存储是一个JavaScript对象,每次通过构造函数创建爱该对象,如果构造函数参数相同,那么会返回初始的实例:
var s1 = new Sound({id: 123}),
s2 = new Sound({id: 123});
s1 === s2; // true, these are the exact same object. |
看看构造函数究竟做了什么:
var store = {};
function Sound(attributes) {
var id = attributes.id;
// check if this model has already been created
if (store[id]) {
// if yes, return that
return store[id];
}
// otherwise, store this instance
store[id] = this;
} |
此类方法并不是什么新花样,仅仅是工厂方法设计模式在构造函数中的应用,它完全可以写成Sound.create({id:
123})的形式,但JavaScript给了我们这种便利,并在语义上说的通。
实例存储的方式使得视图之间共享数据变得简单,甚至视图之间不需要感知对方的存在,实例存储对象相当于一个局部的事件总线(Event
Bus),负责协调/同步视图之间的共享状态。说的更正式一点,实例存储可以抽象为发布者/订阅者模式的体现,每个视图监听数据的变化,每当数据变化获得通知,并体现在页面的变化上。
而且,实例存储的方式解决了数据模型未初始化即被使用的情形,刚开始不同视图仅共享数据模型的ID,还未加载数据。当生成第一个视图,发现数据模型还未加载数据,即刻通过API获取数据。数据模型会负责持续跟踪请求,因而每当其他视图请求加载数据,数据模型不会发送重复的请求。当数据从API获取完毕,数据模型会被更新,触发了数据变更事件,每个视图作为订阅者会被通知到。
充分使用数据
许多API 设计的特点是,每当请求一个特定的资源,返回结果总会包含其他相关属性。就SoundCloud来讲,当请求一个声音的信息,返回结果总会附带创建该声音的用户:
/* api.soundcloud.com/tracks/49931 */
{
"id": 49931,
"title": "Hobnotropic",
...
"user": {
"id": 1433,
"permalink": "matas",
"username": "matas",
"avatar_url": "http://i1.soundc..."
}
} |
与其每个视图对请求结果的附带属性进行解析,不如将此类附带属性放入实例存储中,这样会带来很多好处,减少了对API的访问量,并加速了视图的渲染速度。
就上述示例代码,声音模型需要具备感知其附带属性(用户信息)的能力,每次视图获取API数据将会创建两个数据模型:声音模型和用户模型:
var sound = new Sound({id: 49931 });
sound
.fetch() // get the data
.done(function () { // and when it's done
var user = new User({id: sound.user.id });
user.get('username'); // 'matas' -- we already have the model data
});
|
一个很重要的概念是,不论是声音模型还是用户模型,只存在一个单一实例。并且模型本身是支持数据更新以及一致性约束的:
var sound = new Sound({id: 49931 }),
user = new User({id: 1433 });
user.get('username'); // undefined -- we haven't fetched anything yet.
sound
.fetch()
.done(function () {
user.get('username'); // 'matas' -- the previous instance is updated
}
|
数据模型的资源释放
一直保持数据模型的实例是不合理的,尤其对下一代SoundCloud应用,用户很可能几个小时没有触发一次页面加载或者页面点击。这就意味着,浏览器端的内存资源得不到释放,所以定期清理数据模型的资源是必须的。释放资源的策略是,实例存储维护一个实例引用计数器,每当一个实例被请求一次,对应的计数器会加一。当一个实例不在被引用的时候,视图就可以释放该数据模型实例资源了。
系统会有一个定时器,每隔一段固定时间来检查实例存储中,哪些数据模型的引用计数器为零,将为零的计数器清除,同时强制浏览器垃圾回收器释放资源。引用计数器的实现如下:
var store = {},
counts = {};
function Sound(attributes) {
var id = attributes.id;
if (store[id]) {
counts[id]++;
return store[id];
}
store[id] = this;
counts[id] = 1;
}
Sound.prototype.release = function () {
counts[this.id]--;
}
|
定期进行垃圾回收清理工作,而不是在引用计数器会为零即刻触发的原因是,即刻触发可能导致资源浪费。比如从一个页面导航到另一个页面,有一个时间差(清理当前页面视图资源到加载新页面视图资源),实例资源的引用计数器为零,而新页面可能包含其中一个或多个实例资源,所以即刻触发释放资源的做法太浪费。
|