我喜欢单页应用程序模型和 Backbone.js,因为我理解它。作为一名前 Java 开发人员,我习惯了面向对象的编码和事件消息传递。在我们的 HTML5 咨询公司 SC5 中,Backbone 几乎成为了单页应用程序的代名词,在不同的项目之间切换也很容易,因为每个人都理解相同的开发模型。
我们讨厌需要针对机器人进行服务器端变通方案的事实。从商业角度来说,使应用程序可爬取非常合理,但它不适合 SPA 模型。数据驱动的单页应用程序通常只提供一个 HTML 页面骨架,所有视觉元素的实际构建都在浏览器中完成。任何其他方法都容易导致双重代码路径(一个在浏览器中,一个在服务器上)。有些人甚至担心放弃 SPA 模型,将逻辑和表示重新移回服务器。
但是,我们不应该让尾巴摇晃狗。为什么要为了 0.1% 的用户而牺牲 99.9% 的用户体验?相反,对于这种低流量,更合适的解决方案是创建服务器端变通方案。
使用应用程序代理解决爬取问题
这个问题的显而易见的解决方案是在两端运行相同的应用程序代码。就像在数字电视转型中,机顶盒通过将数字信号压缩成模拟形式,填补了传统电视的空白。相应地,代理会在服务器端运行应用程序,并将生成的 HTML 返回给爬虫。智能浏览器将获得所有交互式内容,而爬虫和传统浏览器将只获得预处理的 HTML 文档。
得益于 node.js,JavaScript 开发人员已经能够在两端使用他们喜欢的语言,代理式解决方案也成为了一种可行的选择。
在服务器上实现 DOM 和浏览器 API
单页应用程序通常严重依赖 DOM 操作。典型的服务器应用程序通过串联将多个视图模板组合成一个页面,而 Backbone 应用程序将视图作为新元素追加到 DOM 中。开发人员要么需要在服务器端模拟 DOM,要么构建一个抽象层,允许在浏览器中使用 DOM,而在服务器上使用模板串联。DOM 可以序列化成一个 HTML 文档,反之亦然,但这些技术不能轻松地混合运行时使用。
典型的 Backbone 应用程序通过几个不同的层与浏览器 API 交互——要么使用 Backbone 或 jQuery API,要么直接访问 API。Backbone 本身对底层层的依赖性很小——jQuery 用于 DOM 操作和 AJAX 请求,而应用程序状态处理则使用 pushState 完成。
Node.js 为每个抽象层提供了现成的模块:JSDOM 在服务器端提供完整的 DOM 实现,而 Cheerio 在一个假 DOM 之上提供 jQuery API,性能更高。一些其他服务器端 Backbone 实现,例如 Airbnb 的 Rendr 和 Backbone.LayoutManager,将抽象层设置为 Backbone API 的级别,并将实际 DOM 操作隐藏在一些约定之下。实际上,Backbone.LayoutManager 确实通过 Cheerio 提供 jQuery API,但该库本身的主要目的是简化 Backbone 布局之间的切换,从而提升抽象级别。
介绍 backbone-serverside
尽管如此,我们还是选择了自己的解决方案。我们的团队是一群老狗,不容易学习新技巧。我们认为,没有一种简单的方法可以完全抽象出 DOM,而不会改变 Backbone 应用程序的本质。我们喜欢我们的 Backbone 应用程序没有额外的层,而且 jQuery 一直以来都是我们抵御浏览器在 DOM 操作方面差异的良好兼容性层。与 Backbone.LayoutManager 一样,我们选择 Cheerio 作为我们的 jQuery 抽象。我们通过用与 API 兼容的替代项覆盖 Backbone.history 和 Backbone.ajax,解决了 Backbone 浏览器 API 的依赖问题。实际上,在第一个草稿版本中,这些实现仍然是最低限度的存根。
我们对正在开发的解决方案感到非常满意。如果您研究 backbone-serverside 示例,它看起来非常接近典型的 Backbone 应用程序。我们不会强制在任何特定的抽象级别工作;您可以使用 Backbone API 或 jQuery 提供的 API 子集。如果您想深入了解,没有什么可以阻止您实现浏览器 API 的服务器端版本。在这种情况下,实际的服务器端实现可能是一个存根。例如,需要在服务器上处理触摸事件?
当前解决方案假设使用 node.js 服务器,但这并不一定意味着对现有服务器堆栈进行重大更改。用于 API 和静态资产的现有服务器可以保持原样,但应该有一个代理将哑客户端的请求转发到我们的服务器。示例应用程序从同一个服务器提供静态文件、API 和代理,但它们都可以通过一些小的修改进行解耦。
编写在 backbone-serverside 上工作的应用程序
目前 backbone-serverside 核心是一组最小的适配器,使 Backbone 可以在 node.js 上运行。将您的应用程序移植到服务器上可能需要进一步的修改。
如果应用程序还没有使用模块加载器,例如 RequireJS 或 Browserify,您需要弄清楚如何在服务器上加载相同的模块。在我们下面的示例中,我们使用 RequireJS,需要一些 JavaScript 代码才能在服务器上使用 Cheerio 而不是普通的 jQuery。否则,我们基本上能够使用我们通常使用的堆栈(jQuery、Underscore/Lo-Dash、Backbone 和 Handlebars。在选择模块时,您可能需要限制那些不直接与浏览器 API 交互的模块,或者准备好自己编写一些存根。
// Compose RequireJS configuration run-time by determining the execution
// context first. We may pass different values to browser and server.
var isBrowser = typeof(window) !== 'undefined';
// Execute this for RequireJS (client or server-side, no matter which)
requirejs.config({
paths: {
text: 'components/requirejs-text/text',
underscore: 'components/lodash/dist/lodash.underscore',
backbone: 'components/backbone/backbone',
handlebars: 'components/handlebars/handlebars',
jquery: isBrowser ? 'components/jquery/jquery' : 'emptyHack'
},
shim: {
'jquery': {
deps: ['module'],
exports: 'jQuery',
init: function (module) {
// Fetch the jQuery adapter parameters for server case
if (module && module.config) {
return module.config().jquery;
}
// Fallback to browser specific thingy
return this.jQuery.noConflict();
}
},
'underscore': {
exports: '_',
init: function () {
return this._.noConflict();
}
},
'backbone': {
deps: ['underscore', 'jquery'],
exports: 'Backbone',
init: function (_, $) {
// Inject adapters when in server
if (!isBrowser) {
var adapters = require('../..');
// Add the adapters we're going to be using
_.extend(this.Backbone.history,
adapters.backbone.history);
this.Backbone.ajax = adapters.backbone.ajax;
Backbone.$ = $;
}
return this.Backbone.noConflict();
}
},
'handlebars': {
exports: 'Handlebars',
init: function() {
return this.Handlebars;
}
}
},
config: {
// The API endpoints can be passed via URLs
'collections/items': {
// TODO Use full path due to our XHR adapter limitations
url: 'http://localhost:8080/api/items'
}
}
});
配置正常工作后,应用程序就可以正常引导。在示例中,我们使用 Node.js express 服务器堆栈,并将特定的请求路径传递给 Backbone Router 实现进行处理。完成后,我们将 DOM 序列化成文本并发送到客户端。需要添加一些额外的代码来处理 Backbone 异步事件模型。我们将在下面更详细地讨论这一点。
// URL Endpoint for the 'web pages'
server.get(//(items/d+)?$/, function(req, res) {
// Remove preceeding '/'
var path = req.path.substr(1, req.path.length);
console.log('Routing to '%s'', path);
// Initialize a blank document and a handle to its content
//app.router.initialize();
// If we're already on the current path, just serve the 'cached' HTML
if (path === Backbone.history.path) {
console.log('Serving response from cache');
res.send($html.html());
}
// Listen to state change once - then send the response
app.router.once('done', function(router, status) {
// Just a simple workaround in case we timeouted or such
if (res.headersSent) {
console.warn('Could not respond to request in time.');
}
if (status === 'error') {
res.send(500, 'Our framework blew it. Sorry.');
}
if (status === 'ready') {
// Set the bootstrapped attribute to communicate we're done
var $root = $html('#main');
$root.attr('data-bootstrapped', true);
// Send the changed DOM to the client
console.log('Serving response');
res.send($html.html());
}
});
// Then do the trick that would cause the state change
Backbone.history.navigate(path, { trigger: true });
});
处理应用程序事件和状态
Backbone 使用一个异步、事件驱动的模型在模型视图和其他对象之间进行通信。对于面向对象的开发人员来说,这种模型很好,但在 node.js 上会导致一些头痛。毕竟,Backbone 应用程序是数据驱动的;从远程 API 端点拉取数据可能需要几秒钟,一旦数据最终到达,模型将通知视图重新绘制自己。没有简单的方法可以知道应用程序 DOM 操作何时完成,所以我们需要发明自己的机制。
在我们的示例中,我们使用简单的状态机来解决这个问题。由于简化的示例没有单独的应用程序单例类,我们使用路由器对象作为控制的单点。路由器侦听每个视图状态的变化,只有在所有视图都准备就绪后才会通知 express 服务器渲染就绪。在请求开始时,路由器将视图状态重置为待处理状态,并在知道所有视图都完成之前不会通知浏览器或服务器。相应地,视图在知道自己已从相应的模型/集合中获取到有效数据之前不会声称自己已完成。状态机很简单,可以一致地应用于不同的 Backbone 对象。
超越实验性黑客
当前版本仍然是实验性工作,但它证明了 Backbone 应用程序可以愉快地驻留在服务器上,而不会破坏 Backbone API 或引入太多新的约定。目前在 SC5,我们有一些项目正在开始,它们可以使用这个实现,所以我们将
继续努力。
我们相信 web 堆栈社区会从这项工作中获益,因此我们在 GitHub 上发布了这项工作。它远未完成,我们感谢社区以想法和代码的形式提供的所有贡献。分享您的爱、批评以及介于两者之间的一切:@sc5io #backboneserverside。
特别地,我们计划改变,并希望为以下内容获得贡献
- 当前示例可能会在并发请求时出现故障。它为所有正在进行的请求共享一个 DOM 表示,这很容易导致它们相互干扰。
- 状态机实现只是关于何时将DOM序列化回客户端的一种想法。对于大多数用例,它可能可以大幅简化,并且很有可能找到更好的通用解决方案。
- 服务器端路由处理很天真。为了强调只有爬虫和遗留浏览器可能需要服务器端渲染,示例可以使用像express-device这样的项目来检测我们是否正在为遗留浏览器或服务器提供服务。
- 示例应用程序是一个非常基本的“主-细节”视图应用程序,不太可能引起任何惊叹的效果。它需要一些爱。
我们鼓励您fork仓库,并从修改示例以满足您的需求开始。快乐黑客!
关于Lauri Svan
Lauri (@laurisvan) 是 SC5 的架构师。
关于 Robert Nyman [荣誉编辑]
Mozilla Hacks的技术布道师和编辑。分享关于HTML5、JavaScript和开放网络的演讲和博客。Robert是HTML5和开放网络的坚定支持者,从1999年开始在瑞典和纽约市从事网络前端开发。他经常在 http://robertnyman.com 上发表博客,并且喜欢旅行和结识新朋友。
5条评论