服务 Backbone 以支持机器人和传统浏览器

我喜欢单页应用程序模型和 Backbone.js,因为我理解它。作为一名前 Java 开发人员,我习惯了面向对象的编码和事件消息传递。在我们的 HTML5 咨询公司 SC5 中,Backbone 几乎成为了单页应用程序的代名词,在不同的项目之间切换也很容易,因为每个人都理解相同的开发模型。

我们讨厌需要针对机器人进行服务器端变通方案的事实。从商业角度来说,使应用程序可爬取非常合理,但它不适合 SPA 模型。数据驱动的单页应用程序通常只提供一个 HTML 页面骨架,所有视觉元素的实际构建都在浏览器中完成。任何其他方法都容易导致双重代码路径(一个在浏览器中,一个在服务器上)。有些人甚至担心放弃 SPA 模型,将逻辑和表示重新移回服务器。

但是,我们不应该让尾巴摇晃狗。为什么要为了 0.1% 的用户而牺牲 99.9% 的用户体验?相反,对于这种低流量,更合适的解决方案是创建服务器端变通方案。

使用应用程序代理解决爬取问题

这个问题的显而易见的解决方案是在两端运行相同的应用程序代码。就像在数字电视转型中,机顶盒通过将数字信号压缩成模拟形式,填补了传统电视的空白。相应地,代理会在服务器端运行应用程序,并将生成的 HTML 返回给爬虫。智能浏览器将获得所有交互式内容,而爬虫和传统浏览器将只获得预处理的 HTML 文档。

Proxy pattern explained through a TV set metaphor

得益于 node.js,JavaScript 开发人员已经能够在两端使用他们喜欢的语言,代理式解决方案也成为了一种可行的选择。

在服务器上实现 DOM 和浏览器 API

单页应用程序通常严重依赖 DOM 操作。典型的服务器应用程序通过串联将多个视图模板组合成一个页面,而 Backbone 应用程序将视图作为新元素追加到 DOM 中。开发人员要么需要在服务器端模拟 DOM,要么构建一个抽象层,允许在浏览器中使用 DOM,而在服务器上使用模板串联。DOM 可以序列化成一个 HTML 文档,反之亦然,但这些技术不能轻松地混合运行时使用。

典型的 Backbone 应用程序通过几个不同的层与浏览器 API 交互——要么使用 Backbone 或 jQuery API,要么直接访问 API。Backbone 本身对底层层的依赖性很小——jQuery 用于 DOM 操作和 AJAX 请求,而应用程序状态处理则使用 pushState 完成。

Sample Backbone layers

Node.js 为每个抽象层提供了现成的模块:JSDOM 在服务器端提供完整的 DOM 实现,而 Cheerio 在一个假 DOM 之上提供 jQuery API,性能更高。一些其他服务器端 Backbone 实现,例如 Airbnb 的 RendrBackbone.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 as a proxy

编写在 backbone-serverside 上工作的应用程序

目前 backbone-serverside 核心是一组最小的适配器,使 Backbone 可以在 node.js 上运行。将您的应用程序移植到服务器上可能需要进一步的修改。

如果应用程序还没有使用模块加载器,例如 RequireJSBrowserify,您需要弄清楚如何在服务器上加载相同的模块。在我们下面的示例中,我们使用 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 对象。

Activity diagram of a Backbone app event flow

超越实验性黑客

当前版本仍然是实验性工作,但它证明了 Backbone 应用程序可以愉快地驻留在服务器上,而不会破坏 Backbone API 或引入太多新的约定。目前在 SC5,我们有一些项目正在开始,它们可以使用这个实现,所以我们将
继续努力。

我们相信 web 堆栈社区会从这项工作中获益,因此我们在 GitHub 上发布了这项工作。它远未完成,我们感谢社区以想法和代码的形式提供的所有贡献。分享您的爱、批评以及介于两者之间的一切:@sc5io #backboneserverside

特别地,我们计划改变,并希望为以下内容获得贡献

  • 当前示例可能会在并发请求时出现故障。它为所有正在进行的请求共享一个 DOM 表示,这很容易导致它们相互干扰。
  • 状态机实现只是关于何时将DOM序列化回客户端的一种想法。对于大多数用例,它可能可以大幅简化,并且很有可能找到更好的通用解决方案。
  • 服务器端路由处理很天真。为了强调只有爬虫和遗留浏览器可能需要服务器端渲染,示例可以使用像express-device这样的项目来检测我们是否正在为遗留浏览器或服务器提供服务。
  • 示例应用程序是一个非常基本的“主-细节”视图应用程序,不太可能引起任何惊叹的效果。它需要一些爱。

我们鼓励您fork仓库,并从修改示例以满足您的需求开始。快乐黑客!

关于Lauri Svan

Lauri (@laurisvan) 是 SC5 的架构师。

更多Lauri Svan的文章...

关于 Robert Nyman [荣誉编辑]

Mozilla Hacks的技术布道师和编辑。分享关于HTML5、JavaScript和开放网络的演讲和博客。Robert是HTML5和开放网络的坚定支持者,从1999年开始在瑞典和纽约市从事网络前端开发。他经常在 http://robertnyman.com 上发表博客,并且喜欢旅行和结识新朋友。

更多Robert Nyman [荣誉编辑]的文章...


5条评论

  1. Lauri Svan

    对于参加旧金山HTML5DevConf的人,我将在下午5点讲解这个主题。我也很乐意与大家面对面讨论这个话题!

    2013年4月2日 下午09:20

  2. Amy Varga

    我总是开发网站/webapp的遗留版本。这个版本不使用任何CSS或JavaScript,完全在服务器端构建。它确保网站/webapp的一个版本可供遗留浏览器使用,并形成了经过测试的代码基础,我可以从中进行优化。它构建起来不需要太长时间,完全可爬取和搜索。我建议总体而言,这是一种更好的网站/webapp设计方法。

    2013年4月3日 上午04:11

  3. Andrew

    您好!请问您可以告诉我本文中使用的绘图应用程序是什么吗?

    2013年4月9日 上午03:43

  4. Lauri Svan

    我使用了OmniGraffle。有趣的是你问了,我就在30分钟前讨论过,目前还没有合适的FOSS工具可以用来绘制概念级设计图。

    2013年4月9日 上午07:20

  5. Paul Grill

    很有趣!我一直想做个将SPA移植到node.js的POC。在我看来,这个问题也是关于你是否可以要求用户开启JavaScript或升级浏览器。在企业环境中,这并不总是可取的。

    谢谢!
    ——Paul

    2013年4月9日 上午09:56

本文章的评论已关闭。