#はじめに
普段は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
<!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
<!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
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
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
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
//Dispacher
import {Dispatcher} from 'flux';
const AppDispatcher = new Dispatcher();
export default AppDispatcher;
//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;
//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
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()
@
<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>
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
<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
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;
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でいいと思うところ
-
モジュラビリティが高く、役割ごとに記述する場所がはっきりしているので、Debugがしやすかったり、プログラマごとによるバラツキを減らせ、保守がしやすい。
-
Virtual DOMやRootComponent以外はデータのイベントを気にする必要がないという仕組みにより、Browserだけでなく、サーバ側と実装を共通化できたり(Isomorphic)、アプリとして動作させる実装(React Native)がある。
-
Root Component以外はModelのイベントを意識せず、immutableな変数を使うので、Componentの流用性が高い。
React.jsの懸念
-
jsのサイズやレンダリング速度がモバイルだとどうか?
-
Canvasを使って描画をしているアプリには使えないのでは?
-
変更があった箇所に対してAnimationさせるといったことが難しいのでは?
-
touch系の細かいイベント制御が大変そう。
-
jsの遅延ロードが難しそう。
-
デザイナーとの連携が面倒そう。
#まとめ
React.js
制限がある代わりにシンプル。
Fluxの仕組みによりシンプルに実装を構築できたり、便利なComponentが流用されるようになり、Client側でも堅牢なアプリを作れる。
Backbone.js
複雑になりえるけどほぼ制限がない。
Canvasを活用したり、touchイベントを駆使したり、JSを遅延ロードさせるなど快適なレスポンスを提供するようチューニングができる。