Backbone.jsと比較したReact.js

  • 81
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

普段はBackbone.jsでアプリを作成しているのですが、React.jsの評判が高まってきているので、簡易BlogをBackbone.jsとReact.jsの両方で実装して、比較してみました。
Demoではjs以外は同じサーバのAPIを使っているのでどちらにも反映されます。

Backbone.jsをつかったバージョン
Source
https://github.com/takeshy/backbone_socket_io
Demo
https://radiant-dawn-8878.herokuapp.com/backbone

React.jsをつかったバージョン
Source
https://github.com/takeshy/react_socket_io
Demo
https://radiant-dawn-8878.herokuapp.com/react

HTML部分

Backbone.js

index.ejs
<!DOCTYPE html>
<html>
  <head>
    <link rel='stylesheet' href='/stylesheets/style.css' />
    <script type="text/javascript" src="/assets/bundle.js"></script>
    <script type="text/javascript">
      window.onload = function(){
        window.router = new Blog.Routers.PostsRouter(<%- initialData %>);
      }
    </script>
  </head>
  <body>
    <div id="posts"></div>
  </body>
</html>

React.js

index.ejs
<!DOCTYPE html>
<html>
  <head>
    <link rel='stylesheet' href='/stylesheets/style.css' />
    </script>
  </head>
  <body>
    <div id="posts"></div>
    <script type="text/javascript" src="socket.io.min.js"></script>
    <script type="text/javascript" src="bundle.js"></script>
    <script>
      reactInitial(<%- initialData %>);
    </script>
  </body>
</html>

ほぼ同じです。jsの読み込みと初期呼び出しを行っているのみです。
初期表示を速くするため初期データにjsonを渡しています。
React.jsは、Browserifyを使っているのですが、socket.ioがglobalにioが定義される必要があり、うまく動作しないため、別呼び出しにしています。

Router

Backbone.js

posts_router.js.coffee
class Blog.Routers.PostsRouter extends Backbone.Router
  initialize: (options) ->
    @posts = new Blog.Collections.PostsCollection(options.posts)
    @posts.listen("http://localhost:3000")
    Backbone.history.start()

  draw: (view)->
    @view.remove() if @view
    elem = document.getElementById("posts")
    elem.innerHTML = ""
    elem.appendChild(view.render().el)
    @view = view

  routes:
    "new"      : "newPost"
    "index"    : "index"
    ":id/edit" : "edit"
    ":id"      : "show"
    ".*"        : "index"

  index: ->
    view = new Blog.Views.Posts.IndexView(collection: @posts)
    @draw(view)

  newPost: ->
    view = new Blog.Views.Posts.NewView(collection: @posts)
    @draw(view)

  show: (id) ->
    view = new Blog.Views.Posts.ShowView(model: @posts.get(id))
    @draw(view)

  edit: (id) ->
    view = new Blog.Views.Posts.EditView(model: @posts.get(id))
    @draw(view)

React.js

index.js
import React from 'react';
import {} from 'whatwg-fetch';
import Blog from './Blog';
import NewPost from './NewPost';
import Edit from './Edit';
import Show from './Show';
import { Router, Route } from 'react-router'
import BlogsActionCreators from '../actions/BlogsActionCreators';

function initial(data){
  BlogsActionCreators.initial(data.posts);
  React.render((
    <Router>
      <Route path="/" component={Blog}/>
      <Route component={NewPost} path="/new"/>
      <Route component={Edit} path="/:blogId/edit"/>
      <Route component={Show} path="/:blogId/show"/>
    </Router>
  ), document.getElementById('posts'));
}
window.reactInitial = initial;

よく使われる組み合わせにするため、BackboneはCoffeeScript、React.jsではES6を使いました。
React.jsにはRouter機能がないので、一番使われているであろうReact.Routerを使ってみました。
ES6にするとimport文がたくさんで大変。IDEがあると便利なのでしょうが、vimだと辛いです。
ただ、保守性を重視しているReact.jsなので、はっきりモジュール化がされるES6のほうが人気があるというのはわかる気がします。
Backbone.jsのほうはすっきりしていて見やすいですが、基本routerは1つしか使わないので、沢山のページが必要になった場合にソースが長くなります。
共通処理は実行しやすいです。
React.jsでもReact.routerを使わず、Backbone.jsのRouterを使う人もいるのでここの比較はあまり意味をなさないかもですが、一般性で比較してみました。

Model VS Dispatcher Store ActionCreator

Backbone.js

post.js.coffee
class Blog.Models.Post extends Backbone.Model
  paramRoot: 'post'

  defaults:
    title: null
    content: null

class Blog.Collections.PostsCollection extends Backbone.Collection
  model: Blog.Models.Post
  url: '/posts'
  socket: null
  listen:(nodeUrl)->
    @socket = io.connect(nodeUrl + "/posts")
    @socket.on('connect', ()=>
      @addListener()
    )

  addListener:()->
    @socket.on("post", (msg)=>
      obj = JSON.parse(msg)
      _.each(obj,(postData)=>
        if postData.deleted
          @get(postData["id"])?.destroy()
        else
          @add(postData,{merge: true})
      )
    )

React.js

AppDispacher.js
//Dispacher
import {Dispatcher} from 'flux';

const AppDispatcher = new Dispatcher();

export default AppDispatcher;
Blogs.js
//Store
import {EventEmitter} from 'events';
import assign from 'object-assign';
import AppDispatcher from '../dispatcher/AppDispatcher';
import BlogConstants from '../constants/BlogConstants';

const ActionTypes = BlogConstants.ActionTypes;
const CHANGE_EVENT = 'change';
let blogs = [];

const Blogs = assign({}, EventEmitter.prototype, {
  emitChange() {
    this.emit(CHANGE_EVENT);
  },
  addChangeListener(callback) {
    this.on(CHANGE_EVENT, callback);
  },
  removeChangeListener(callback) {
    this.removeListener(CHANGE_EVENT, callback);
  },
  all(){
    return blogs.concat([])
  },
  find(id){
    for(let i=0;i<blogs.length;i++){
      let blog = blogs[i];
      if(blog.id == id){
        return blog;
      }
    }
    return null;
  }
});

Blogs.dispatchToken = AppDispatcher.register(action => {

  switch (action.type) {
    case ActionTypes.RECEIVE_BLOG:
      blogs = blogs.concat(action.posts);
      Blogs.emitChange();
      break;
    case ActionTypes.UPDATE_BLOG:
      action.posts.forEach((post)=>{
        if(post.deleted){
          let nBlog = [];
          blogs.forEach((blog)=>{
            if(blog.id != post.id){
              nBlog.push(blog);
            }
          })
          blogs = nBlog;
        }else{
          let idx = blogs.map(blog=> blog.id).indexOf(post.id);
          if(idx != -1){
            let nBlog = [];
            blogs.forEach((blog)=>{
              if(blog.id != post.id){
                nBlog.push(blog);
              }else{
                nBlog.push(post);
              }
            });
            blogs = nBlog;
          }else{
            blogs = blogs.concat([post]);
          }
        }
      });
      Blogs.emitChange();
      break;
    case ActionTypes.DESTROY_BLOG:
      let nBlog = [];
      blogs.forEach((blog)=>{
        if(blog.id != action.post.id){
          nBlog.push(blog);
        }
      });
      blogs = nBlog;
      Blogs.emitChange();
      break;
  }
});

export default Blogs;
BlogsActionCreators.js
//ActionCreator
import AppDispatcher from '../dispatcher/AppDispatcher';
import BlogConstants from '../constants/BlogConstants';
import request from 'superagent';

const ActionTypes = BlogConstants.ActionTypes;

const Action = {
  createBlog(post,fn){
    request.post("/posts").send(post).set('Accept', 'application/json').end((err,res)=>{
      if(err){
        alert(err.message);
      }
      AppDispatcher.dispatch({
        type: ActionTypes.UPDATE_BLOG,
        posts: [res.body]
      });
      fn();
    })
  },
  updateBlog(post,fn){
    post["_method"] = "put"
    request.post("/posts/" + post.id).send(post).set('Accept', 'application/json').end((err,res)=>{
      if(err){
        alert(err.message);
      }
      AppDispatcher.dispatch({
        type: ActionTypes.UPDATE_BLOG,
        posts: [res.body]
      });
      fn();
    })
  },
  destroyBlog(post){
    request.post("/posts/" + post.id).send({_method: "DELETE"}).set('Accept', 'application/json').end((err,res)=>{
      AppDispatcher.dispatch({
        type: ActionTypes.DESTROY_BLOG,
        post: post
      });
    })
  },
  initial(posts){
    AppDispatcher.dispatch({
      type: ActionTypes.RECEIVE_BLOG,
      posts: posts
    });
    var socket = io.connect("http://localhost:3000/posts")
    socket.on('connect', ()=>{
      socket.on("post", (msg)=>{
        AppDispatcher.dispatch({
          type: ActionTypes.UPDATE_BLOG,
          posts: JSON.parse(msg)
        });
      })
    });
  }
};

export default Action;

Backbone.jsのほうは、初期処理時にlistenがrouterから呼ばれるので、その際にsocket.ioにイベントリスナーを登録して、Socket.IOサーバからイベントがあるごとにBackbone.jsより提供されている更新メソッドを使うことで更新と共に自動的に更新内容に応じたイベントの発行が行われます。

React.jsはFluxと呼ばれるアーキテクチャで作成されます。
Dispatcherと呼ばれるSingletonのインスタンスに対してStore(Modelっぽいなにか)が、イベントハンドラを登録します。
StoreはDispatcherからイベントハンドラを呼ばれた時のみ、自身のデータを更新します。
Storeの元となるデータの取得はActionCreatorと呼ばれるモジュールが担当します。
ActionCreatorはWebサーバ、SocktIOサーバ、LocalStorage etc.からデータを取得し、Dispacherのdispachメソッドにデータを渡します。
dispachメソッドに渡されるActionTypeはアプリでユニークである必要があり、定数で定義されます。
Dispacherはdispachメソッドが呼ばれると、登録されているすべてのStoreのイベントハンドラを呼ぶので、Storeは自分が関心のあるActionTypeの場合のみ処理を実行します。

これによりデータの更新はActionCreator->Dispacher->Storeの一方通行であることが保証されるため、デバッグもStoreのイベントハンドラに注目すればいいだけになります。
またビジネスロジックであるStoreは、データの取得方法を意識しなくてすむため、テストが簡単に書けます。

React.jsのほうは長いですが、Boilerplateが提供されるようになるとかなり短くなるはずです。

View VS Component

Backbone.js

posts_index_view.js.coffee
Blog.Views.Posts ||= {}
class Blog.Views.Posts.IndexView extends Backbone.View
  template: JST["templates/posts/index"]

  initialize: () ->
    @childViews = []
    @listenTo(@collection,'add', @addOne)

  addAll: () =>
    @collection.each(@addOne)

  addOne: (post) =>
    view = new Blog.Views.Posts.PostView({model : post})
    @el.getElementsByTagName("tbody")[0].appendChild(view.render().el)
    @childViews.push(view)

  remove: ()->
    _.each(@childViews,(view)->
      view.remove()
    )
    super()

  render: =>
    @el.innerHTML = @template()
    @addAll()
    @
index.jst.ejs
<h1>Listing posts</h1>

<table id="posts-table">
  <thead>
    <tr>
      <th>Title</th>
      <th>Content</th>
  </thead>
  <tbody>
  </tbody>
</table>
<br>
<a href="#/new">New Post</a>
post_view.js.coffee

Blog.Views.Posts ||= {}

class Blog.Views.Posts.PostView extends Backbone.View
  template: JST["templates/posts/post"]

  events:
    "click .destroy" : "destroy"

  tagName: "tr"

  initialize: ()->
    @listenTo(@model,'destroy', @remove)
    @listenTo(@model,'change', @render)

  destroy: (e) ->
    e.preventDefault()
    e.stopPropagation()
    @model.destroy()
    window.router.navigate("/index",{trigger: true})

  render: ->
    @el.innerHTML = @template(@model.toJSON())
    return this
post.jst.ejs
<td><%= title %></td>
<td><%= content %></td>
<td><a href="#/<%= id %>">Show</a></td>
<td><a href="#/<%= id %>/edit">Edit</a></td>
<td><a href="#/<%= id %>/destroy" class="destroy">Destroy</a></td>

React.js

Blog.js
import React from 'react';
import BlogsStore from '../stores/Blogs';
import BlogHeader from './BlogHeader';
import { Link } from 'react-router'
class Blog extends React.Component {
  constructor(props) {
    super(props);
    this.onChangeFunc = this._onChange.bind(this)
    this.state = {
      blogs: BlogsStore.all()
    }
  }
  componentDidMount() {
    BlogsStore.addChangeListener(this.onChangeFunc);
  }
  componentWillUnmount() {
    BlogsStore.removeChangeListener(this.onChangeFunc);
  }
  _onChange() {
    this.setState({ blogs: BlogsStore.all()});
  }
  render() {
    return (
      <div>
      <h1>Listing posts</h1>
      <table id="posts-table">
        <thead>
          <tr>
            <th>Title</th>
            <th>Content</th>
          </tr>
        </thead>
        <tbody>
        {this.state.blogs.map(blog => <BlogHeader key={blog.id} blog={blog} />)}
        </tbody>
      </table>
      <Link to='/new'>New Post</Link>
      </div>
    );
  }
}
export default Blog;

BlogHeader.js
import React from 'react';
import BlogsActionCreators from '../actions/BlogsActionCreators';
import Router from 'react-router'; 
var Link = Router.Link;
class BlogHeader extends React.Component {
  clickDestroy(){
    BlogsActionCreators.destroyBlog(this.props.blog);
  }
  render() {
    return (
      <tr>
        <td>{this.props.blog.title}</td>
        <td>{this.props.blog.content}</td>
        <td><Link to="show" params={{blogId: this.props.blog.id}}>Show</Link></td>
        <td><Link to="edit" params={{blogId: this.props.blog.id}}>Edit</Link></td>
        <td><a onClick={this.clickDestroy.bind(this)} className="destroy">Destroy</a></td>
      </tr>
    )
  }
}
export default BlogHeader;

Backbone.jsの場合は、JSTとよばれる仕組みを使うことで、ViewとTemplateを切り離すことができ、かつCompile時にTemplateをjsのメソッドに変換してくれるため、描画速度も速いです。
またTempleteをejs等の素のHTMLに近くすることで、デザイナーの方がTemplateに対して直接修正してデザインを反映していただくことが可能です。
各viewではCollectionもしくはModelのイベントに対してListenToというメソッドを使ってイベントハンドラを登録しています。
これにより、Viewに対してremoveを呼びだした際に自動的にイベントハンドラを解除してもらえます。

React.jsの場合は、Backbone.jsで言うViewとTemplateが一緒になったものをComponentと呼びます。
親のComponentにおいて、Storeのイベントハンドラを登録し、state(変更可能な変数)に値をセットしています。
子のComponentの場合は、親Componentからprops(変更不可な変数)として渡されたデータを使って描画することに集中するだけでよくなるため、シンプルで使い回しがしやすくなります。
画面内でイベントが起きた場合も、ActionCreatorに対して適切なメソッドを呼び出すだけとなります。
Storeが更新されると親Componentからまるっと描画処理がされますが、VirtualDOMの恩恵により、実DOMに対して変更分のみ更新が走るので、まるっと描画処理をしても問題がないようになっています。

その他

アプリ size 初期load時間 2回目以降load時間 ファイル数 行数
Backbone.js 119K 565ms 288ms 12 240
React.js 314K 714ms 382ms 9 335

Backbone.jsはVer1.2以降を使って、jQueryの依存をなくしました。
各jsには、socket.io.js(サイズ 64K)が含まれます。

React.jsでいいと思うところ

  1. モジュラビリティが高く、役割ごとに記述する場所がはっきりしているので、Debugがしやすかったり、プログラマごとによるバラツキを減らせ、保守がしやすい。

  2. Virtual DOMやRootComponent以外はデータのイベントを気にする必要がないという仕組みにより、Browserだけでなく、サーバ側と実装を共通化できたり(Isomorphic)、アプリとして動作させる実装(React Native)がある。

  3. Root Component以外はModelのイベントを意識せず、immutableな変数を使うので、Componentの流用性が高い。

React.jsの懸念

  1. jsのサイズやレンダリング速度がモバイルだとどうか?

  2. Canvasを使って描画をしているアプリには使えないのでは?

  3. 変更があった箇所に対してAnimationさせるといったことが難しいのでは?

  4. touch系の細かいイベント制御が大変そう。

  5. jsの遅延ロードが難しそう。

  6. デザイナーとの連携が面倒そう。

まとめ

React.js
制限がある代わりにシンプル。
Fluxの仕組みによりシンプルに実装を構築できたり、便利なComponentが流用されるようになり、Client側でも堅牢なアプリを作れる。
Backbone.js
複雑になりえるけどほぼ制限がない。
Canvasを活用したり、touchイベントを駆使したり、JSを遅延ロードさせるなど快適なレスポンスを提供するようチューニングができる。