为什么认为 Backbone 是现代化前端框架的基石

伴随着 W3C 协议规范的不断更新,以及现代化浏览器技术的快速进步,Web 前端技术整体取得了日新月异发展,交互体验更加丰富与多样化,与此同时业务逻辑也在极速的膨胀。开发人员函需从大量 DOM 底层处理的劳动中解放出来,更加从容的面对纷繁复杂的各式交互需求。本文开头先简单的梳理一下历史,然后基于现代化前端框架的主要特性,逐一与 Backbone 进行比较和剖析。

从前端技术发展趋势的角度而言,目前层出不穷的现代化前端框架的诞生,都可以认为是 Angular 和 Backbone 等古典前端框架设计思想走向融合之后的产物。虽然截至到本文执笔的时间点,Backbone 已经略微old school,但之所以依然单独对 Backbone 着重笔墨,主要是在组件化作用域控制等方面,Backbone 更加接近于现代化前端框架的设计理念,而这两点又正好是同一时期的 Angular 并没有解决好的问题。

起步于 2005 年的jQuery仅仅对 DOM 操作进行了基础性的封装,提供了可链式调用的写法、更加友好的 Ajax 函数、屏蔽了浏览器兼容性的各类选择器,但是并没有解决前端开发中选择器滥用、作用域相互污染、代码复用度低冗余度高、数据和事件绑定烦琐等痛点。

为此,2009 年横空出世的Angular提供了一揽子解决方案,对浏览器原生事件机制进行深度封装的同时,提供了路由、双向绑定、指令等现代化前端框架的特性,但是也正是由于其封装的抽象程度太深,学习曲线相对陡峭,而对于controller$scope的过度倚重,以及照搬 Java 的 MVC 分层思想试图通过service来完成页面逻辑的复用,并未彻底解决前端开发过程中的上述痛点。

诞生于 2010 的Backbone则另辟蹊径,通过与UndersocreRequireHandlebar的整合,为那个年代的开发人员提供了 Angular 之外,一个更加轻量和友好的前端开发解决方案,其诸多设计思想对于后续的现代化前端框架发展起到了举足轻重的作用。

视图组件化

视图组件化是 Vue、React、Angular2 等现代化前端框架的基本思想,其主要目的是将复杂的 DOM 结构切割为更小粒度的 HTML 代码片段。Backbone 通过Backbone.View.extend()继承函数来新建一个视图对象(即组件),该视图对象即可以使用el属性挂载到现有 DOM,也可以通过template属性建立全新的视图对象。对 Vue2 比较熟悉的同学,应该会感觉到这样的写法与Vue 组件对象非常相似。事实上 Backbone 视图对象不旦与 Vue2,也与 Angular2 和 React 当中的组件对象作用极其类似,具体请参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* Backbone视图对象 */
Backbone.View.extend({
id: "app",

template: "...",

events: {
"click .icon": "open",
"click .button.edit": "openEditDialog",
"click .button.delete": "destroy",
},

initialize: function () {
this.listenTo(this.model, "change", this.render);
},

render: function () {
this.$el.html(this.template());
return this;
},
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Vue组件对象 */
import Vue from "vue";

new Vue({
template: "<div>模板字符串<div>",

data: {
// 组件绑定的数据
},

methods: {
myEvent() {
// 组件自定义事件
},
},
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* React组件对象 */
import React from 'react';
import ReactDOM from 'react-dom';

class MyComponent extends React.Component {
constructor(props) {
// 组件构造函数
}

myEvent(event) {
event.preventDefault();
}

render() {
return (
// JSX
);
}
};
1
2
3
4
5
6
7
8
9
10
11
/* Angular2组件对象 */
import { Component, Input } from "@angular/core";
import { Demo } from "./demo";

@Component({
selector: "demo-detail",
template: ` <div>模板字符串</div> `,
})
export class DemoDetailComponent {
@Input() demo: Demo;
}

作用域控制

通过上面代码的比较,大家应该能够了解,Backbone 视图对象的核心任务在于DOM 选择器、数据事件绑定的作用域控制。Web 前端组件化的过程,实质是可以认为是一个切割 DOM 的过程,切割 DOM 必然意味同时需要分离事件数据绑定,并且控制视图对象上选择器的作用范围。

首先,Backbone 的事件绑定机制源于 JQuery 的事件委托方法on(),Backbone 仅仅将其封装成为一个简单明了的糖衣语法对象events,集中注册当前视图对象上涉及的 DOM 事件,以及事件触发的选择器和事件类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var View = Backbone.View.extend({
id: "login",
template: Handlebars.compile(Html),
initialize: function () {},
events: {
input: "checked",
"click .btn": "login",
},
render: function () {
this.$el.html(this.template());
this.$(".rember-me").iCheck({
checkboxClass: "icheckbox_square-blue",
radioClass: "iradio_square-blue",
});
return this;
},
login: function () {
var login = new Model();
login.auth();
},
});

return View;

其次,从 DOM 选择器的纬度,上面例子中 Backbone 对象内的全部 DOM 操作,都被封装至this.$el()或者this.$()函数上进行,核心目的就是为了控制 JQuery 选择器的作用域,防止选择器的互相污染,并且也一定程度上提升了选择器的效率。

数据绑定

Backbone 原生的数据绑定需要依赖于 underscore 当中的<%=...%>表达式,但鉴于 underscore 模板表达式在书写循环语句时语法过于繁杂,因此在实际生产环境下,笔者采用了 Handlebars 模板引擎进行数据绑定,通过执行template: Handlebars.compile(Html)编译字符串模板,提供与 Angular 以及 Vue 当中 Mustache 表达式类似的开发体验。因为字符串模板编译后通过this.$el.html(this.template())插入当前视图对象,实质上就是通过this指针的运用,完成了上面内容所提到的数据绑定的作用域控制

1
2
3
4
{{#each comments}}
<h1>{{title}}</h1>
<p>{{content}}</p>
{{/each}}

MVVM 与双向绑定

MVVMModel-View-ViewModel的缩写形式,相比传统MVC模式的Model-View-Controller,最主要的区别在于将模型 Model 与视图 View 的绑定工作从控制器Controller,前置到视图模型对象ViewModel当中。MVVM这一概念最先由 Angular1.x 在 Web 前端开发当中提出,但是事实上 Angular1.x 仍然保留了 Controller 的存在,并严重依赖于其间接绑定$scope可以理解为 Angular 中的 ViewModel),这也正是笔者认为 Angular1.x 设计上的一个缺陷所在,一方面 Controller 的存在会让组件化工作进行得极其困难,另一方面为了抽象复用的业务逻辑,Angular 不得不专门抽象出对应于 Controller 的 Service 服务层,而 Web 前端实际开发过程当中,大量的业务复用是基于 DOM 结构存在的,横向抽象出的 Service 层作用显得比较鸡肋,这也正是为什么虽然 Angular 提供了比 Backbone 更加完整的单页面应用开发体验,但笔者依然并未将其视为现代化前端开发当中组件化思想来源的原因所在。

视图模型对象ViewModel存在的意义,主要是为了更加清晰的进行View->Model->View数据绑定,Angular1.x 默认对 Mustache 表达式执行双向绑定(View 和 Model 的数据双向映射,无需事先声明),Vue2 采用了单向绑定(数据必须先在 ViewModel 中进行声明)响应式数据更新(View 和 Model 都基于 ViewModel 中事先声明的数据进行映射)。而 Backbone 和 Handlebars 默认是单向进行绑定,如果需要实现ViewModel的双向数据映射,必须通过手动监听Backbone.Model对象上的change事件,并且在事件触发后立刻执行该视图对象上的render()渲染函数。

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
28
29
30
31
32
33
34
35
var Model = Backbone.Model.extend({
default: {
cases: {},
},
initialize: function () {
this.getCaseList();
},
getCaseList: function () {
var self = this;
Http.fetch({
url: "/legal/verdict",
method: "GET",
}).then(function (data) {
if (Http.verify(data, 200)) {
self.set(data);
}
});
},
});

var View = Backbone.View.extend({
id: "demo",
model: new Model(),
initialize: function () {
this.listenTo(this.model, "change", this.render);
},
template: Handlebars.compile(Html),
events: {},
render: function () {
this.$el.html(this.template(this.model.attributes));
return this;
},
});

return View;

上面代码中,首先设置视图对象的model属性,通过new Model()实例化当前代码内所继承的Backbone.Model对象。然后在当前视图对象的初始化函数initialize当中,通过 Backbone 视图对象上内置的listenTo(this.model, 'change', this.render)方法完成对模型的监听,并设置相应的回调渲染函数。从 API 使用的角度而言,Backbone 缺乏一个真实的 ViewModel 概念,但是实际生产环境下,可以考虑将该视图对象所涉及的多个数据对象集中放置在一个Model内部处理,从而最大程度上模拟 ViewModel 作为视图和模型之间数据绑定介质的作用,虽然这样的灵活处理方式显得不尽优雅。

前端路由

Web 应用程序通常需要提供可链接的、可书签化的、可任意进行分享的 URL 地址,从而去标识应用程序的各个具体状态。现代化前端框架的 Router 实现(例如:vue-routerreact-router),通常会提供#Hash或者HTML5两种前端路由方式,Backbone 的路由机制Backbone.Router是基于路径 Hash 进行实现。Backbone.Router当中,大量封装了window.historywindow.location中提供的大量 API,将浏览器地址栏当中的 URL 属性,与 Backbone 路由事件相绑定,当访问这些 URL 属性时,相应的路由事件就会被触发。

路由事件当中,通常会初始化 Backbone 视图对象上的render()函数,然后调用其$el属性将渲染后的 Backbone 视图对象转换为 JQuery 对象,并通过$.html()将其插入到应用程序的 DOM 挂载点,从而将 URL 状态的变化绑定至 Web 页面进行局部刷新。

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
var Router = Backbone.Router.extend({
initialize: function () {
this.app = $("#app");
},
routes: {
"": "login", // default
login: "login", // #login
dashboard: "dashboard", // #dashboard
},
login: function () {
var loginView = new Login();
this.app.html(loginView.render().$el);
},
layout: function () {
this.layoutView = new Layout();
return this.app.html(this.layoutView.$el);
},
// dashboard是嵌套视图
dashboard: function () {
var dashboardView = new Dashboard();
this.layout().find("#main").html(dashboardView.render().$el);
},
});

return Router;

对比上面的代码,大家应该能够发现vue-routerreact-router这两款现代化前端框架的路由实现,与 Angular1.x 上的ui-router最大的区别在于:前者的路由目标是组件 Components,而后者的路由则是绑定在控制器 Controller。而 Backbone 路由机制的设计,虽然配置和编写方式略显老派,但是其URL->路由事件->视图对象->局部HTML片断的渲染思想,明显相比 Angular 粒度更小,也更接近现代化前端框架的组件化路由机制

2017 年以后的 Angular1.6.x 版本增加了ngComponentRouter模块,已经原生提供了 Component Router 组件路由的支持。

模型与集合分离的缺陷

Backbone 当中的Collection用于存放Model,这样的设计主要是出于 2 个角度的考虑,第 1 是方便 Backbone 扩展 Underscore 提供的集合操作方法,第 2 是方便通过 Backbone 封装的fetch()抓取服务器端的数组类型数据。

创建Collection需要首先创建Model,然后将该Model赋值给Backbone.Collection继承对象的model属性,最后在实例化Collection时通过构造函数传入每个具体的Model。因此,Backbone 当中CollectionModel的关系实质类似于数组对象的关系,Backbone 只是将这两种引用数据类型分开进行处理,便于使用 Underscore 上提供的辅助函数处理各自的数据类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var user = Backbone.Model.extend({
defaults: {
name: "",
age: 0,
},
});

var group = Backbone.Collection.extend({
model: user,
});

var myGroup = new group([
{
name: "hank",
age: 32,
},
{
name: "uinika",
age: 23,
},
]);

CollectionModel分离的设计方式,在服务器后端接口需要变化或者调整的时候,总是需要去更改相应的数据类型及关联操作,这样对于前端开发人员敏捷的响应需求变化是非常不利的因素。这样的设计方式,在 Java 开源 MVC 框架大行其道的年代,多少是受到服务器后端对象-关系映射理念的影响,笔者认为这也是 Backbone 另外一处处理得比较欠妥的地方;虽然站在MVVMViewModel的角度有其合理性,但是实现起来还是相对冗杂了一些。因此,笔者在 Backbone 的使用实践当中,最终摒弃了Collection的使用,而完全通过 Model 以及 Model 上的数组属性来接收服务器端响应,并且在项目中通过下划线_引用全局的 underscore 操作各类数据,避免与 Backbone 数据对象的类型发生耦合。

僵尸视图问题

总体而言,Backbone 是一款事件驱动的前端框架,在 Backbone 应用程序中会大量使用到事件机制进行各类交互,常见的用途主要体现在如下 3 个场景。

  1. 通过Backbone.Viewevents属性绑定事件到视图的 DOM 元素。
  2. CollectionModel绑定change事件,然后在事件触发时调用render()进行页面重绘。
  3. 应用程序的各块业务逻辑都通过Backbone.Events提供的事件机制进行通信。

在开发单页面应用程序的场景下,当视图对象伴随 URL 路由地址不断进行局部刷新的时候,由于大量事件并未伴随视图对象的移除而同时解除绑定,造成大量事件对象堆积在浏览器内存当中,逐渐让视图对象成为僵尸视图,最终引发内存溢出的惨剧(更加详细的讨论可以参见Zombies! RUN! (Managing Page Transitions In Backbone Apps)一文)。

早期的 Backbone 版本并没有提供僵尸视图的解决办法,直到 Backbone1.2.x 版本之后,开始在Backbone.View视图对象上新增加一个remove()函数支持,可以在移除视图对象 DOM 元素的同时,自动调用stopListening移除之前通过listenTo绑定到视图上的 Backbone 自定义事件,但是remove()并没有同时移除视图上绑定的 JQuery DOM 事件,所以还需要再手动进行清理。加上Backbone.Router的 API 设计过于简单,也没有提供相应的路由切换回调函数去自动调取remove()卸载事件,因此截止到目前最新的 Backbone1.3.3 版本,依然未能彻底在官方实现上解决僵尸视图的问题。

解决僵尸视图问题的关键,是需要在恰当的位置提供一种通用的事件卸载机制,而 Backbone 视图的切换多与路由 URL 的状态变化相关,因此路由事件成为解决 Backbone 僵尸视图问题的关键点所在。

构建单页面应用

Backbone 出现的年代,Web 单页面应用开发方式还未能普及,基于 JSP 或 PHP 等服务器标签的前后端耦合式开发还是主流,因此 Backbone 对构建单页面应用的支持还较为薄弱,也造成嵌套视图僵尸视图两大问题长期困扰着继往开来的 Backbone 开发人员们。伴随移动互联网的快速崛起,对单页面应用交互的需求量越来越大,许多开发人员在实际开发实践过程中,逐步对Backbone.Router进行增强,其间诞生了backbone.routefilterbackbone.subroute两款优秀的第 3 方 Backbone 路由插件,基本解决了僵尸视图卸载的痛点。但是伴随 Web 前端交互逻辑愈加复杂,嵌套视图的问题又开始逐步凸显,而嵌套视图依然与路由机制密切相关。因此,MarionetteThorax两款基于 Backbone 的单页面前端框架应运而生。

Thorax 对 Backbone 和 Handlebars 进行了深度的整合,提供了一栈式的体验,相对 Marionette 更加轻量也更加容易上手,可惜目前该项目作者已经停止更新和维护。而 Marionette 则是一款相当完善的 Backbone 重型单页面应用框架,完美解决了嵌套视图僵尸视图的问题,但是同时也引入了更多的概念和 API,学习曲线较为陡峭。其开源团队在配合 Backbone1.3.3 版本发布 Marionette3.5.1 之后更新周期明显放慢,好在团队依然在接收并处理 Github 上的 Issues,应该算是当前 Backbone 技术栈开发单页面应用为数不多的选择(¬_¬)

基于 RequireJS 模块化

在开发人员还不能使用 ES6 的importexport语句愉快的进行模块化的年代,RequireJS 几乎成为前端模块化的必然选择,通过设置相应的依赖与回调函数,实现 JavaScript 代码的模块化,随后诞生的 Angular1.x 通过angular.module提供了类似的模块化特性,但缺点在于只能异步的加载 HTML 模板,并不能异步加载 JavaScript 脚本,使用上略有局限,虽然也有开发人员提出整合 Angular1.x 和 RequireJS 来弥补该局限,但是两种模块化机制混用又会为项目带来新的复杂度。

RequireJS 遵循了 AMD 规范,API 设计非常简洁明了,提供require()方法加载依赖然后执行相应回调函数,以及使用define()方法定义 AMD 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
require([
/*----- core -----*/
"backbone",
"admin",
"router",
"backbone.marionette",
/*----- general -----*/
"http",
"util",
/*----- plugin -----*/
"bootstrap",
"jquery.slimScroll",
"jquery.webcam",
], function (Backbone, Admin, Router) {
var router = new Router();
Backbone.history.start();
// backbone debugger
if (window.__backboneAgent) {
window.__backboneAgent.handleBackbone(Backbone);
}
});

结合require.text插件,异步加载远程的 HTML 模板,避免类似 Angular1.x 虽然能定义模块,但却无法异步进行加载的尴尬。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
define([
"backbone",
"handlebars",
"admin",
"text!snippets/layout/view.html",
], function (Backbone, Handlebars, Admin, Html) {
return Backbone.View.extend({
id: "layout",
initialize: function () {
this.render();
},
template: Handlebars.compile(Html),
events: {},
render: function () {
this.$el.html(this.template());
return this;
},
});
});

虽然 RequireJS 本身可以异步按需加载各种依赖,但是受限于Backbone.Router实例化时会一次性加载所有视图对象,导致整个应用程序会在启动时一次性加载所有依赖,产品层面并没有体现出前端模块化之后的优势,仅仅有利于项目源代码的管理。但是通过Backbone+RequireJS的组合来实现.css.js.html的完全异步加载,确实为后续现代化前端框架的发展提供了比较良好的示范。

Webpack2.x.x 已经原生支持 ES6 的import语句,且增加了import()代码切割(code split)函数,应该是迄今为止最为方便好用的前端模块化暨打包工具。

完整 Demo

廉颇老矣,尚能饭否?在 Web 前端技术日新月展的年代,Backbone 或许真的已经老了。但并不能忽略其在 JavaScript 前端框架演进历史当中,所曾经扮演过的重要角色。包括 underscore 及后续发展出来的 lodash,Jeremy Ashkenasbackbone 和 underscore 的共同作者)为开源社区做出的杰出贡献有目共睹。

笔者花去周末 2 天时间撰写本文,一方面是对过去使用 Backbone 的经验和体会做一些总结;另一方面也是让大家在 Webpack 横行、各类高度封装的单页面前端框架层出不穷的年代,能够静下心来思考原生 JavaScript 究竟在浏览器里发生了哪些有趣的故事;与此同时,笔者将过去使用的 Backbone 前端架构抽象成为一个小小的 Demo,在 Github 上开源出来,希望读者在阅读本文的过程中,结合 Demo 当中的源代码,去深入体会现代化前端框架发展的历史沿革。

开源 Demo 项目命名为sparrow,仍然基于 NodeJS 和 Gulp 工作流构建,编译时会对每个模块进行代码混淆或压缩,有兴趣的同学可以去我的 Github进行克隆(项目基于笔者技术团队日常的开发实践,提供了一个比较通用和完善的 Backbone 项目结构)。

general目录下是通用的 JavaScript 工具方法以及 Less 样式,libraries目录下是项目依赖的各种库文件,assets目录下放置图片、字体、多媒体内容,snippets目录存放项目所有的样式、模板、脚本文件,router.js用于配置路由,而下面的index.html代码则是所有 Web 前端故事开始的地方。

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
28
29
30
31
32
33
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>Sparrow</title>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
name="viewport"
/>
<link rel="icon" href="assets/favicon.ico" type="image/png" />
<link
href="libraries/theme/bootstrap/css/bootstrap.min.css"
rel="stylesheet"
/>
<link href="libraries/theme/admin/css/AdminLTE.css" rel="stylesheet" />
<link
href="libraries/theme/admin/css/skins/skin-red.css"
rel="stylesheet"
/>
<link href="libraries/theme/animate.css" rel="stylesheet" />
<link
href="libraries/theme/awesome/css/font-awesome.css"
rel="stylesheet"
/>
<link href="bundle.css" rel="stylesheet" />
</head>

<body class="fixed sidebar-mini skin-red">
<div id="app"></div>
<script data-main="app" src="libraries/core/require.js"></script>
</body>
</html>

最后展示的是app.js的代码,即整个单页面应用的全局启动入口点,主要用于加载各种依赖、实例化路由对象、启动 debug 模式。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
require.config({
baseUrl: "/",
paths: {
/*----- core -----*/
text: "libraries/core/require.text",
domReady: "libraries/core/require.domReady",
admin: "libraries/theme/admin/js/app",
jquery: "libraries/core/jquery",
underscore: "libraries/core/underscore",
backbone: "libraries/core/backbone",
"backbone.marionette": "libraries/core/backbone.marionette",
"backbone.radio": "libraries/core/backbone.radio",
handlebars: "libraries/core/handlebars",
bootstrap: "libraries/theme/bootstrap/js/bootstrap",
/*----- general -----*/
router: "snippets/router",
http: "general/http",
util: "general/util",
/*----- widget -----*/
"jquery.iCheck": "libraries/theme/widget/iCheck/icheck",
"jquery.slimScroll": "libraries/theme/widget/slimScroll/jquery.slimscroll",
/*----- plugin -----*/
"jquery.webcam": "libraries/plugin/webcam/jquery.webcam",
},
map: {
"*": {
css: "libraries/core/require.css",
},
},
shim: {
/*----- core -----*/
underscore: {
exports: "_",
},
backbone: {
deps: ["underscore", "jquery"],
exports: "Backbone",
},
"backbone.radio": ["backbone"],
"backbone.marionette": ["backbone.radio"],
bootstrap: ["jquery"],
admin: ["jquery", "bootstrap"],
/*----- general -----*/
http: ["jquery"],
util: ["jquery"],
/*----- plugin -----*/
"jquery.iCheck": [
"jquery",
"css!libraries/theme/widget/iCheck/square/blue.css",
],
"jquery.slimScroll": ["jquery"],
"jquery.webcam": ["jquery"],
},
waitSeconds: 0,
});

require([
/*----- core -----*/
"backbone",
"admin",
"router",
"backbone.marionette",
/*----- general -----*/
"http",
"util",
/*----- plugin -----*/
"bootstrap",
"jquery.slimScroll",
"jquery.webcam",
], function (Backbone, Admin, Router) {
var router = new Router();
Backbone.history.start();
// backbone debugger
if (window.__backboneAgent) {
window.__backboneAgent.handleBackbone(Backbone);
}
});