Hatena::ブログ(Diary)

mizchi log

@mizchiの雑記帳

2012-11-27

大規模JSでのBackbone.js/CoffeeScript について考えてみた


これ読んでたらr7kamura君にJSのMVCどうするの的な話きかれてたのを思い出したので、自分がBackboneを使う時のパターンをr7kamura君の記事をベースに書きなおしてみた。
> サバクラ両方で動く JavaScript の大規模開発を行うために ― Gist https://gist.github.com/1362110

> client-side javascript - ✘╹◡╹✘ http://r7kamura.hatenablog.com/entry/2012/10/18/023629


以下の様なコードを書いた。かなり冗長だが、複雑なアプリだとこれぐらいの冗長性は必要になる。
(なお概念を伝えるための解説用コードなのでそのままじゃ動かない)

Backbone.Model


# 名前空間の初期化
App = {}
App.View = {}
App.Model = {}

# Backboneを継承して実装する
class App.Model.TabSelecter extends Backbone.Model
  defaults:
    index: 0

  next: ->
    index = @get 'index'
    @set index: index + 1

  prev: ->
    index = @get 'index'
    if index > 0
      @set index: index - 1

ViewModelを意識

モデルのデータが変化するロジックは全てモデルに書く。この場合はビューに関するデータをもっているので、実質ビューモデルといっていい。

Backbone継承パターン

Backbone.Modelは複雑な振る舞いを持つことが多い。これらはコントローラ側で制御せず、モデル自身が厚く振る舞いを持てるようにする。そうでないとコントローラが肥大化して手に負えなくなる。

外部からの get, setは控えめに

どこでsetするかを明示的にするために、可能な限り外部からsetすることは避ける。むしろgetも禁止して取り出したい項目はgetter作らないと取得できない、ぐらいの拘束でもいい。
理由は、外部から複雑なデータの受け渡しを行った場合、どこでデータが加工されたかわからなくなるケースが多々ある。

Backbone.View

Viewって名前だけど実際はコントローラだよ!

class App.View.Tabs extends Backbone.View
  el: '.tabs'
  events:
    'click .button': 'select_tab'
    'click #next': 'next'
    'click #prev': 'prev'

  constructor: ->
    super
    @tab_selecter = new App.Model.TabSelecter
    @tab_selecter.on 'change:index', 'render'

    @tab_views =
      for el, index in $('.tab')
        # 冗長だが実際には個別のテンプレートだと思われるので
        # indexでテンプレートを分岐する
        new App.View.TabItem el: el, template: do ->
          switch index
            when 0 then $("#a_template")
            when 1 then $("#b_template")
            when 2 then $("#c_template")
  next: ->
    @tab_selecter.trigger 'next'

  prev: ->
    @tab_selecter.trigger 'prev'

  current_tab: ->
    @tab_views[@tab_selecter.get('index')]

  select_tab: (event, el) => # click eventを受け取る
    # DOMイベントから必要な値を取り出す
    tab_index = $(el).index $('.tab')
    @set index: tab_index

  render: => # => でthisをbind
    @current_tab().render_to $('.section')

「モデルによって一意に決まる状態」を意識する。

タブを選択する、という場合、indexがすべての状態を一意に決めることができるので、index状態の変化を監視してrenderを呼ぶ。ビューはDOMを書き換えず、モデルを監視した結果副作用(#render)を起こす。副作用を起こすメソッドと監視するメソッドは明確に分離する。

イベントハンドラとして振る舞う関数はHTMLからデータを読み取ってモデルのメソッドを叩く。
render系のメソッドはDOMへの副作用を隠蔽する。

bindされたeventはDOMのパラメータを取り出す以上の事はしない

clickされたDOMからdata属性を取り出したり、現在のDOMの状態をみたりはするが、それ以上のロジックは自分自身に持たず、モデル側に記述する。ただし、モデルはDOMに関する情報(id情報やDOM)を持たない。
操作の結果、モデルが書き換わり、上記のデータによって一意に決まる状態が更新される。そのために、モデルが一意に決まる状態を持たねばならない。

可能な限りeventsを使ってイベントを定義する。

eents経由(正確には#delegateEvents)で定義したものは、Backbone.View#undelegateEvents() で解放できる。ビューを作っては破棄していると、イベントのバインド漏れで複数イベントが飛んだり、メモリリークを起こす可能性が高くなる。

HTMLをラップするView


#.tabをラップするクラス
class App.View.TabItem extends Backbone.View
  constructor: ->
    super
    @$el.html Mustache.render @template, {}

  hide: ->
   @$el.hide()

  show: ->
   @$el.show()

  render_to: ($section) ->
    $section.html @$el

this.$elをラップしてjQueryのアクションに名前をつける

HTMLと強く紐付くクラスは、@$elをラップして振る舞いを記述する。jQueryでこねくり回す場合でも、名前をつけて意味的な名前をつけると、意味を見失いがちなjQueryの一連の振る舞いをわかりやすくすることができる。

this.$elを使いまわす

$elはイベントを画面から取り除かれてもイベントを保持し続ける。大きなDOMを再生成するコストを省ける。

「本当の」View

実体はHTMLテンプレート

 
<script id='a_template' type='text/template'>
{{a}}
</script>

<script id='b_template' type='text/template'>
{{b}}
</script>

<script id='c_template' type='text/template'>
{{c}}
</script>

scriptタグのtype='text/template'でHTMLテンプレート書く

Mustacheで書いて$('#hoge_template').html()で取り出して使う
scriptタグの中にHTMLを書くのは、必要になるまでDOMのインスタンスを作らせないようにするため。

このパターンが実際にどう開発に影響を与えるか?


モデル

テストが書きやすくなり、充実する。
自分のプロジェクトではモデルがDOMに一切依存しないため、nodeで動かしている。

ビュー

Backbone.Viewのテストをする場合、各テストケースのセットアップでグローバルなDOMを初期化しないといけない。このモックHTMLを書くのが非常に手間で、さらにいえば、現代のHTMLは(残念ながら)デザインと不可分であるため、デザイン変更によってセレクタがずれたり、親子関係が入れ替わったりすることがある。よって、どんなにテストを書いても無駄になることはある。可能な限りidやセレクタを振って、親子関係に依存しないマークアップが求められる。

副作用をもつメソッド群をスタブして、発火してることを確かめるのは有意義。逆に言えばそれ以上のテストはあまり有意義でない。

最終的に何が言いたいかというと


jQuery黒魔術つらい

2012-01-29

非同期メソッドを書きやすく拡張されたIcedCoffeeScript が登場


IcedCoffeeScript

IcedCoffeeScriptなんてのができていた。(フォーク元は最新のv1.2.0)
非同期関連を書きやすくしたもの。その意味ではtame.jsなどとコンセプトは同じ。
生成されるコードはIcedCoffeeScript -> CoffeeScript

使い方

公式サンプルより

search = (keyword, cb) ->
  host = "http://search.twitter.com/"
  url = "#{host}/search.json?q=#{keyword}&callback=?"
  await $.getJSON url, defer json
  cb json.results

await の非同期関数の返り値を、defer 受け取ってそのクロージャに吐き出す。直列に記述されているようだが、実際にはawait以下はコールバックの中になる。(その操作を隠蔽している)
非常にシンプル。

await for ~も使えるらしい。

out = []
await 
  for k,i in keywords
    search k, defer out[i]


雑感

生成されるコードを覗くと、resultとかlaunchとかの名前空間を平気で汚していて、せめてアンダーバープレフィックスを付けてほしい気持ちはある(CoffeeScriptは_refs1みたいな感じ)

自分はちょっとまだ信頼できないので使わないかなといった印象
これがメジャーになるのは考えにくいとは思うが、本家でも議論されている非同期メソッドAPI提案として実装されたのはGJだと思う。本家で採用されたら使ってもいい、ぐらい。

そういえば自分もこんなことできるやつを書いてみた、けど使ってない。

fs = require 'fs'
main = ->
  err,txt1 <- fs.readFile 'hello1.txt', next
  err,txt2 <- fs.readFile 'hello2.txt', next
  err,txt3 <- fs.readFile 'hello3.txt', next
  console.log i.toString() for i in [txt1,txt2,txt3]
main()

CoffeeScriptの文法拡張して非同期でネストが深くならないようにしてみた - mizchi log


みんなが納得できるシンタックスが提案されて採用されるといいですね

2011-11-07

不思議なダンジョン風マップ自動生成


参考にしたのはここ http://racanhack.sourceforge.jp/rhdoc/index.html

方針

上記の説明だけ読んでコード参考にしなかったので全然違う実装になった。

大きな空部屋を作って、部屋のクラスごとに担当する領域を持っておく。再帰構造で次の部屋の参照をもっとく。隣の部屋へのルートを書けば全部の部屋が繋がる。

コード


部屋と部屋をつなぐ部分を手抜きでやったので他の部屋跨いだりする。

{pow,sqrt,abs,random,max,min} = Math
class Room
  constructor:(@map,@depth, @ax,@ay)->
    @max_size = 4
    @next = null
    if @depth > 0
      @next = @split()

    if @ax[1]-@ax[0] < 13
      @rx = @ax
    else
      cx = ~~((@ax[0]+@ax[1])/2)
      @rx = [cx-6, cx+6]
    if @ay[1]-@ay[0] < 13
      @ry = @ay
    else
      cy = ~~((@ay[0]+@ay[1])/2)
      @ry = [cy-6, cy+6]

    @center = [
      ~~((@rx[1]+@rx[0])/2)
      ~~((@ry[1]+@ry[0])/2)
    ]

    @draw_area()

  _v : ->
    [sx , ex] = @ax
    cx = ~~( (ex-sx)*(max(0.2,1-random()/@depth) )+sx  )
    @ax = [cx,ex]
    new Room @map,--@depth, [sx,cx ],@ay

  _s : ->
    [sy , ey] = @ay
    cy = ~~( (ey-sy)*(1-random()/@depth)+sy  )
    @ay = [cy,ey]
    new Room @map,--@depth, @ax , [sy,cy]

  split:->
    if Math.random() > 0.5
      @_s()
    else
      @_v()

  draw_area : ->
    [sx,ex] = @rx
    [sy,ey] = @ry
    for i in [sx ... ex]
      for j in [sy ... ey]
        if @center[0] is i and @center[1] is j
          @map[i][j] = 2
          console.log i,j
        if (i == sx or i == (ex-1) ) or (j == sy or j == (ey-1))
          @map[i][j] = 1
        else
          @map[i][j] = 0

  draw_path : ->
    if @next
      [cx,cy] = @center
      [nx,ny] = @next.center
      while abs(cx-nx)+abs(cy-ny) > 0
        if cx>nx then cx--
        else if cx<nx then cx++
        else if cy>ny then cy--
        else if cy<ny then cy++
        @map[cx][cy] = 0
      @next.draw_path()

blank = (x,y)->
  map = []
  for i in [0 ... x]
    map[i] = []
    for j in [0 ... y]
      map[i][j] = 1
  return map

create_map = ->
  x = 30
  y = 30
  root = new Room(blank(x,y),13 ,[1,x-1],[1,y-1])
  root.draw_path()
  root.map

main = ->
  map = create_map()
  for i in map
    console.log i
      .join('')
      .split('0').join('  ')
      .split('1').join('//')
      .split('2').join('..')

main()


実行したらこんな感じ。

////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////                    ////////////////////////////////////
////                    ////////////////////////////////////
////                    ////////////////////////////////////
//////////////  ////////////////////////////////////////////
//////////////  //////////////    ////    //////////////////
////                    //////    ////    //////////////////
////                    //////    ////    //////////////////
////                    //////    ////    //////////////////
////                    //////    ////    //////////////////
////                    ////                          //////
////                    ////      ////    //////////////////
////                    ////                              //
////                    ////      ////    //////////////  //
////                    ////      ////    //////////////  //
//////////////  ////////////      //////////////////////  //
//////////////  ////////////      //////////////////////  //
////                    ////      //////////////////////  //
//////////////  ////////////      //////////////////////  //
//////////////                    //////////////////////  //
////////////////////////////    ////////////////////////  //
////////////////////////////    ////////////////////////  //
////////////////////////////    ////////////////////////  //
//////////////////                                        //
//////////////////////////////  ////////////////////////////
//////////////////////////////  ////////////////////////////
//////////////////////////////  ////////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////