基于Backbone的复杂SPA架构

5 年前

过去的几个月中,我们一直在开发SOOMLA工作台的第二版。有很多前端技术可用,我们选择了Backbone.js,这颗皇冠上的珠宝!网络上已经有很多介绍Backbone的文章了,不过我发现大部分文章都是关于如何使用Model、View和Event的。在本文中,我会讲讲我们工作台的Backbone架构,扩展开来,讲讲使用Backbone创建复杂单页应用(下面用SPA来指代)的基本的见解。申明下:本文的关键不是代码,而是架构。因此,示例代码并不追求细枝末节,主要是为了表达观点。好,走起!

前端技术栈

我们使用的技术:

  • Require.js:模块化代码,依赖管理;

  • Backbone.js: 应用架构,分层设计;

  • Marionette.js:复杂应用架构,减少重复代码;

  • underscore.js:通用的Object、Array和Function工具方法;

  • undescore.string: 用来扩展字符串处理函数;

  • Handlebars.js: - 客户端模板引擎;

  • jQuery:DOM操作、Ajax、Promise等等;

  • jQuery插件:Qtip、SlimScroll、Isotope、jQuery UI sortable和jQuery validation;

  • imagesLoaded:捕获image的load事件;

  • Less:预处理CSS;

  • -grunt.js:自动化构建工具(预编译、压缩和打包代码)。

架构

分而治之

SPA,所有东西都放在一个页面上,让人望而生畏,太复杂了。我们的方案可以追溯到大学时期的算法课——一个问题如果太大无法解决,将其分割开,各个击破。在Backbone单页应用中,就是为每个sub-app创建单独的模块,由root-app来实例化。我们可以使用AMD模块、CommonJS模块或IIEF(立即执行的函数来创建作用域毕包)来实现模块间的独立。这种实现方式不但要求sub-app不知道彼此的存在,而且鼓励sub-app间通过事件通信。例如,在我们的SPA中,每个`sub-app在启动后都会触发一个started事件,在关闭后,触发closed事件。用户任何导致子app切换的操作,都会触发switchApp事件,根app会监听这个事件,然后停止刚刚活动的子app,打开新的子app。后面还会更加详细的讲解事件。

RootApp.on("switchApp", function (appName, args) {
    // Assuming the Root app has already instantiated all sub-apps
    // and cached the instances by their names, fetch the app instance
    var currentApp = RootApp.getSubappInstance(appName);

    // If the app we're switching to is the currently active one, do nothing
    if (RootApp.currentApp === currentApp) return;

    // Stop the previously running app.  Safe-guard with `if` in case
    // this is the first time this function is called and no previous app was started
    if (RootApp.currentApp) RootApp.currentApp.stop();

    // Keep a reference to the new app instance and start it.
    RootApp.currentApp = currentApp;
    currentApp.start(args);

});

上面并没没子app停止的代码,它负责释放内容。例如,解绑定DOM或者应用级的事件,释放对view的引用,避免产生Zombie views

另一个分而治之的体现就是把页面分成了不同的区域。例如,导航、边栏和工作区等等。这些DOM元素本身被设计用来包含不同的内容,而内容则取决于目前活动的视图。在SOOMLA的工作台商,边栏会在"My games"和"Storefront Editor"间切换,前者可以让用户选择不同的游戏,而后者则是让用户在商店的多个可编辑的地方做出选择。

路由作为状态机

URL表示资源。而用户看到的资源(包括嵌套的资源)也是代表了当前app的状态。http://bookworm.com/authors/10/books不单单是一个地址,同时也是告诉应用开启authors子app,并显示ID为10的作者的全部书籍。当用户操作跳到了另外一个作家那里,比如说ID为20的作家,他\她就是告诉应用“嘿,改变状态吧,从显示作家10改成显示作家20”。于是子app的工作就是切换到新状态对应的资源上,即作家20,而无需关闭。基于资源URL设计的SPA的优势是,这些资源可以被保存被引用;而劣势就是你必须小心处理子app的路由,明白每个路由都包含(也可能没有)一系列的初始化。例如,路由"/authors/10/books",在渲染数据之前,需要发出一个获取作家10全部书籍的请求。

在一个由多个sub app拼起来的Backbone SPA中,每个sub app都可以注册自己的路由,这个路由只和自己有关。有个关于Backbone Router的秘密,在Backbone的文档中并没有说明,就是你可以实例化多个路由实例,而且这些注册的路由都会聚合到底层的路由map中。你只需确保;

  • 不将两个完全一样的路由放到不同的Router实例中;

  • -在调用Backbone.histroy.start()实例化所有的Router的对象。

    // Define a router for the games sub-application var GamesRouter = Backbone.Router.extend({

    routes: {
      "": "root",
      "games": "index",
      "games/:id": "showGame"
    },
    root: function () { /*...*/ },
    index: function () { /*...*/ },
    showGame: function (id) { /*...*/ }
    

    })

    // Define a router for the storefronts sub-application var StorefrontsRouter = Backbone.Router.extend({

    routes: {
      "games/:gameId/storefronts": "showStorefronts"
    },
    showStorefronts: function (gameId, storefrontId) { /*...*/ },
    

    })

    // Instantiated an instace of each router // You can choose to keep references to your routers if necessary new GamesRouter(); new StorefrontsRouter();

    // Finally start Backbone's global history object

    Backbone.history.start();

Events = 关注分离

在SPA中所有东西,对,我指的是所有的东西,都应该使用事件驱动!!!进一步,DOM事件应该映射到合适的事件上,将用户与页面的交互和应用的响应分离。这对于模块化来说是极为重要的;这还能将jQuery小效果和复杂的操作分开,比如说表单提交、文件上传等。下面举两个例子:

表单提交:

  • 糟糕的方式:用Backbone的view监听表单的"submit"事件,收集"First Name"和"Last Name"两个字段的值,然后发起一个post请求来完成提交;

  • 好的方式:使用Backbone的view监听表单提交事件,使用通用的方法从DOM中收集数据,出发事件,发送这些数据,通知该view的父组件来处理。

我得到的是一个模块化的表单组件,可以在任何地方重用,无需关心表单的内容,也无需关心表单如何提交到服务器端。

文件上传:

  • 糟糕的方式:用Backbone的view监听文件的"drop"事件,通过一个指向特定URL地址的AJAX请求上传这个文件;

  • 好的方式:view监听文件的"drop"事件,收集所有与文件有关的数据,然后出发事件,将数据传递给监听器。

我们得到的是一个可重用的拖放view,它唯一的功能就是处理拖放文件,讲文件传递给它的父组件。父组件可以监听事件,做它喜欢做的事情:上传文件,插入图片,文本处理等等。

在SOOMLA的工作台应用中,View的作用就是捕获DOM行为。在某些特别的例子中,View可能需要承担更多的责任,比如说有与其他View沟通的能力,或者向服务器发起请求。这主要要是一种模块化和约定。但是决定某个View到底要承担多大的责任,关键就是问自己“这个View干的事情就只是它做么?”,如果是,那就保持不变,如果不是,就应该把View中特殊的地方抽出来,这个View就只是作为用户操作的监听器。在特定情况下,就比方说一个集合View,你甚至需要将事件冒泡到超过一个层级,因为它就是多个View的另外一种形式,并且我们尽力让View只关心自己的事情,就是渲染HTML和监听用户操作。下面这张图说明了事件是如何从叶子节点冒泡到sub app的。

Backbone为我们提供了一个通用的事件总线,用来添加事件监听器,触发事件,而且所有的组件都可以使用。可以翻一下Backbone的源码,所有的组件,Model,View,Collection和Router都mix了Event。

申明、实例化和垃圾回收

如果要写复杂的SPA,有一个最佳实践。就是将对象原型定义的模块和实例化的模块分开。这种拆分使得,作为定义的模块是无状态的,多次调用它们都不会有什么副作用。而那些用于实例化的模块则负责握着这些对象,管理它们的状态。以我们的工作台为例,suo'y的Backbone View都以views.js模块定义,而.js则负责实例化它们。

Backbone最棘手的问题之一就是垃圾回收。通常来讲,开发者需要负责析构自己创建的Backbone对象,尤其需要回收Backbone的View对象。我特意使用析构这个词,就算会让我想起C++,不寒而栗,就是因为大多数JavaScript开发者根本没有这种意识。是的,JavaScript是一个有垃圾回收功能的语言,它会帮你回收内存垃圾,不过,这并不意味着JavaScript知道自己要回收什么。如果一个Backbone的View在为其提供的model上绑定了一个事件,就算是model被删除或者所有直接引用都删除了,它都不会被gc回收,因为View还在监听model。实际上,监听某个事件就意味着保持着一个对被监听元素的引用。因此,在SPA中,当在不同的状态切换时,你不但需要为新的sub app创建新的View,还要关闭上一个sub-app的View。Marionette.js框架中已经提供了这种关闭的功能,当然你也可以单独自己实现一个。

升级到一个更高层次的框架

在用了Backbone一段时间之后,你很可能发现你反复编写一些重复的代码。你的View包含了大量的事件处理器,你写了很多函数,这些函数解决问题的方案就是触发事件。编写大量的Collection View来渲染Item View,让你作呕。据我的经验,这通常是一种征兆,意味着需要开始使用一些更高级的框架,这些框架已Backbone为核心,提供了一种清晰的方式来管理View,减少重复代码。我们选择了Marionette.js。Marionette很好用,我们的代码更加模块化,更加简洁。还有很多基于Backbone的框架,ChaplinLayout ManagerThorax等等,但是我们发现Marionette是最好的,文档齐全,社区也很活跃。尽管学习曲线有点陡峭,不过只要你掌握了它,你就不会再回去写纯的Backbone代码了。

扩展Backbone

总会有这样的时候,高大全的框架或者Backbone插件都不能解决你的需求。我们就发生了这样的情况,在我们处理mixin和多个实例关系时。举个例子,有时,你会发现一系列的View或者Model都包含了同样一组方法,这些方法在这些View或者Model中都重复出现了。我们无法它们抽取出来放到一个通用的原型上,因为这些类扩展自不同的祖先,这会破坏原型链。还有一种,多个View的某个属性需要扩展,不能被覆盖。例如,ListView和ItemView原型有不同的事件对象,但都需要扩展一个通用的事件,{"click a": "doSomething"}。我们的解决方式就是,给Backbone.View添加了一个静态方法,可传入一个对象,用来扩展这个View的原型,而有选择的处理一些特别的属性,例如事件和initialize方法。推荐阅读Kim Joar Bekkelund这篇非常棒的文章,Gtihub:https://github.com/kjbekkelund/writings/blob/master/published/backbone-mixins.md

var BackboneViewExtensions = {

  mixin: function (from) {
    var to = this.prototype;

    // we add those methods which exists on `from` but not on `to` to the latter
    _.defaults(to, from);

    // ...and we do the same for events and triggers
    _.defaults(to.events, from.events);
    _.defaults(to.triggers, from.triggers);

    // we then extend `to`'s `initialize`
    BackboneExtensions.extendMethod(to, from, "initialize");

    // … and its `render`
    Utils.extendMethod(to, from, "render");
  },

  // Helper method to extend an already existing method
  extendMethod: function (to, from, methodName) {

    // if the method is defined on from ...
    if (!_.isUndefined(from[methodName])) {
      var old = to[methodName];

      // ... we create a new function on to
      to[methodName] = function () {

        // wherein we first call the method which exists on `to`
        var oldReturn = old.apply(this, arguments);

        // and then call the method on `from`
        from[methodName].apply(this, arguments);

        // and then return the expected result,
        // i.e. what the method on `to` returns
        return oldReturn;
      };
    }
  }
};

_.extend(Backbone.View.prototype, BackboneViewExtensions);

结语

我就是想给大家分享一些架构上的经验,这些经验是我在最近的这几个月里学到的。构建SPA的方式有多种多样,而Backbone的优雅就在这里,给了你一个简单的架构,而你可以自由发挥。你手里有的是建筑材料,而整个建筑架构完全取决于你自己。如果你有兴趣,还想深入的了解,我强烈推荐你去阅读 Derick Bailey的博客,他是Marionette.js的作者。

原文:Complex Single Page Application Architecture with Backbone ~ The SOOMLA Blog

0
推荐阅读