CanJSは、長期にわたってメンテナブルなWebアプリの作成に役立つ、革新的なフロントエンドライブラリーです。何十個もの独立したパッケージがあり、必要なライブラリーを選択できるため、100kb以上の依存オブジェクトに苦労することもなくなります。
CanJSはMVVM(モデル・ビュー・ビューモデル)構造です。以下の主要パッケージで成り立ちます。
- can-component:カスタム要素作成
- can-connect:APIとの通信
- can-define:観察対象(observable)
- can-stache:Handlebars風のテンプレート
このチュートリアルではGitHubリポジトリのissueリストをソースに、ToDoリストアプリを作ります。GitHubのWebhook APIでリアルタイムに更新され、jQuery UIのSortable(並べ替え)によって順番が変更できます。
完成したアプリはGitHubにあります。
CanJSのMVVM
チュートリアルのプロジェクトを開始する前に、CanJSにおけるMVVMを詳しく解説します。
データモデル
MVVMにおける「モデル」はデータモデルで、アプリ内のデータを指します。今回のアプリで扱う個別のissueとissueのリストがモデルのデータ型に当たります。
CanJSではcan-define/list/listとcan-define/map/mapにより、配列とオブジェクトを定義します。観察対象(observable)のデータで、変更があればMVVMのViewもしくはViewModelを自動で更新します。
一例ですが、今回のアプリは以下の型があります。
import DefineMap from 'can-define/map/map';
const Issue = DefineMap.extend('Issue', {
id: 'number',
title: 'string',
sort_position: 'number',
body: 'string'
});
Issueのインスタンスはid、title、sort_position、body4つのプロパティを持ちます。値を代入したら、値がnullやundefinedでない限り、can-define/map/mapが指定した型に変換します。たとえばidを文字列型の"1"にしても、idプロパティには数値の1が入ります。ただし値がnullならnullのままです。
issueの配列の型を定義します。
import DefineList from 'can-define/list/list';
Issue.List = DefineList.extend('IssueList', {
'#': Issue
});
can-define/list/listの#プロパティは、リスト中の項目すべて指定した型に変換できます。この記述によりIssue.ListのオブジェクトもIssue型のインスタンスになります。
Viewテンプレート
Webアプリの「ビュー」とは、訪問者が閲覧、操作するHTMLのユーザーインターフェイスです。CanJSのHTML描画では、MustacheやHandlebarsによく似た構文のcan-stacheを含む、複数の形式のテンプレートが使えます。
can-stacheテンプレートの例です。
<ol>
{{#each issues}}
<li>
{{title}}
</li>
{{/each}}
</ol>
いくつものissuesの繰り返しで{{#each}}を使い、各issueの題名titleを{{title}}で示しています。issuesリストや題名の変更でも、DOMを更新します(例:新しいissueがリストに追加されたら、DOMにliが追加されます)。
ビューモデル
MVVMのビューモデルは、モデルとビューの橋渡しをするコードです。ビューに必要でモデルには含まれないロジックは、ビューモデルが実現します。
CanJSでは、can-stacheテンプレートはViewModelで描画されます。単純化した例です。
import stache from 'can-stache';
const renderer = stache('{{greeting}} world');
const viewModel = {greeting: 'Hello'};
const fragment = renderer(viewModel);
console.log(fragment.textContent);// Logs “Hello world”
コンポーネント
これらを1つにまとめた概念がコンポーネント(もしくはカスタム要素)です。コンポーネントは、機能をひとまとまりにしたり、アプリ全体にわたって再利用可能にしたりする場合に便利です。
CanJSのcan-componentは、ビュー(can-stacheファイル)、ビューモデル(can-define/map/map)、オプションでJavaScriptのイベントを監視するオブジェクトで構成されています。
import Component from 'can-component';
import DefineMap from 'can-define/map/map';
import stache from 'can-stache';
const HelloWorldViewModel = DefineMap.extend('HelloWorldVM', {
greeting: {value: 'Hello'},
showExclamation: {value: true}
});
Component.extend({
tag: 'hello-world',
view: stache('{{greeting}} world{{#if showExclamation}}!{{/if}}'),
ViewModel: HelloWorldViewModel,
events: {
'{element} click': () => {
this.viewModel.showExclamation = !this.viewModel.showExclamation;
}
}
});
const template = stache('hello-world');
document.body.appendChild(template);
上記のテンプレートでは、カスタム要素をユーザーがクリックしたのか判別して「Hello world!」もしくは「Hello warld(!記号なし)」を表示します。
CanJSアプリの作成は以上4つの概念を押さえればよいのです。サンプルアプリでも、MVVM構造のアプリ制作のため4つの概念を使用します。
本チュートリアルの前提条件
開始する前に、最新のNode.jsをインストールしてください。GitHubのAPIと通信するバックエンドのサーバーの設置にはnpmを使用します。
GitHubアカウントが無ければ登録してください。
ローカルプロジェクトの作成
プロジェクトの新規フォルダーを作り、そのフォルダーに移動します。
mkdir canjs-github
cd canjs-github
プロジェクトに必要なファイルを新規作成します。
touch app.css app.js index.html
スタイル設定にapp.cssを、JavaScriptの記述にapp.jsを、ユーザーインターフェイスにindex.htmlを使います。
CanJSのはじめの一歩
コーディング開始です。以下をindex.htmlファイルに加えます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>CanJS GitHub Issues To-Do List</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<link rel="stylesheet" href="app.css">
</head>
<body>
<script type="text/stache" id="app-template">
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<h1 class="page-header text-center">
{{pageTitle}}
</h1>
</div>
</div>
</div>
</script>
<script type="text/stache" id="github-issues-template">
</script>
<script src="https://unpkg.com/jquery@3/dist/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
</body>
</html>
たくさんのパーツがあるので、1つずつ解説します。
- csshead内の2つのlink要素はプロジェクトのスタイルシートを参照する。基本としてBootstrapを使いつつ、app.css内でいくつか独自のカスタムを加える
- 最初のscript要素(id="app-template")はアプリのルートテンプレートを参照する
- 2番目のscript要素(id="github-issues-template")は、あとで作成するgithub-issuesコンポーネントのテンプレートを参照する
- ページの最後のscript要素は、依存オブジェクトを読み込むためのもの。:jQuery、jQuery UI、CanJS、Socket.io、このアプリのコード
ドラッグ&ドロップでissueを並べ替えるため、jQuery UI(jQueryに依存)を使用します。can.all.jsを読み込んでいるためすべてのCanJSモジュールにアクセスが可能です。通常はStealJSのようなモジュールローダーもしくはwebpackを使用しますが、この記事の範疇ではないので割愛します。ここではGitHubのイベントを受け取ってアプリを逐次更新するためにSocket.ioを使用します。
app.cssファイルにいくつかスタイルを追記します。
form {
margin: 1em 0 2em 0;
}
.list-group .drag-background {
background-color: #dff0d8;
}
.text-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
app.jsファイルに以下のコードを加えます。
var AppViewModel = can.DefineMap.extend('AppVM', {
pageTitle: {
type: "string",
value: "GitHub Issues",
}
});
var appVM = new AppViewModel();
var template = can.stache.from('app-template');
var appFragment = template(appVM);
document.body.appendChild(appFragment);
JavaScriptを分解して解説します。
- can.DefineMap:監視対象オブジェクト型のカスタムに使用
- AppViewModel:本アプリのルートビューモデルになる、観察対象オブジェクト
- pageTitle:すべてのAppViewModelfインスタンスのもつプロパティで、GitHub Issuesの初期値(ページタイトル)
- appVM:このアプリのビューモデルの新規インスタンス
- can.stache.from:scriptタグの中身を、テンプレート描画のための関数と入れ替える
- appFragment:appVMデータで描画されたテンプレートのドキュメント・フラグメント
- document.body.appendChild:DOMのノードを取得しHTMLのbodyに追加する
注:このページのcan.all.jsスクリプトの読み込みにより、どのCanJSモジュールにもアクセスできるcanのグローバル変数が使えます。たとえばcan-stacheモジュールはcan.stacheとして、スクリプト内で使用できます。
ブラウザーでindex.htmlを開きます。
コンソールにエラーが1つ表示されるのは、リアルタイムSocket.ioサーバーを設定していないためです。次節で作業します。
サーバー側の作業
GitHubのWebhooks APIは、リポジトリに変更があった際にサーバーに通知を送信します。サーバー側のコードに時間をかけないため、github-issue-server npmモジュールを用意しました。役割は以下の6つです。
- GitHub Webhookイベントを受け取るためにngrokサーバーをセットアップ
- アプリのUIが新規issueを作成すると、認証済みリクエストをGitHub APIに送信
- アプリUIのリアルタイム通信にはSocket.ioを使用
- プロジェクトのフォルダーにファイルを作成
- 各issueにsort_position(並び順)プロパティを付与
- ローカルのissues.jsonファイルで、issueのリストとsort_position(並び順)を保持
サーバーが認証済みのリクエストでGitHubと通信するためアクセストークンを作成します。
- github.com/settings/tokens/newへ移動
- Token descriptionを入力(私の場合「CanJS GitHub Issue To-Do List」)
- public_repoのスコープ(範囲)を選択
- Generate tokenをクリック
- 次のページで、トークンの次にあるクリップボード型アイコンCopy Tokenをクリック
これでサーバーをインストールできます。ここではpackage.jsonの作成とgithub-issue-serverのインストールのためにnpmを使用します。
npm init -y
npm install github-issue-server
サーバーを起動するため以下のコマンドを実行します。ACCESS_TOKENの部分は先程GitHubからコピーしたアクセストークンを入れて下さい。
node node_modules/github-issue-server/ ACCESS_TOKEN
サーバーが立ち上がり、以下を表示します。
Started up server, available at:
http://localhost:8080/
Started up ngrok server, webhook available at:
https://829s1522.ngrok.io/api/webhook
ngrokサーバーアドレスは上記と異なった一意のサブドメインになります。
localhostもしくはngrok.ioのアドレスをブラウザーで開くと、以前と同じホームページが表示されますが、今回はコンソールにエラーは表示されません。
GitHub Isssuesコンポ―ネントの作成
CanJSのコンポーネントとは、ビュー(can-stacheテンプレート)とビューモデル(データをビューと結びつける役割)を持ったカスタム要素です。コンポーネントは、一連の機能を1つにまとめてアプリ内での再利用が可能になるので重宝します。
すべてのGitHub issueをリスト化したり新規作成したりするためのコンポーネント、github-issuesを作ります。
app.jsファイルの先頭に以下を加えます。
var GitHubIssuesVM = can.DefineMap.extend('GitHubIssuesVM', {
pageTitle: 'string'
});
can.Component.extend({
tag: 'github-issues',
view: can.stache.from('github-issues-template'),
ViewModel: GitHubIssuesVM
});
GitHubIssuesVMはコンポーネントのビューモデルにあたります。コンポーネントの各インスタンスはpageTitle(タイトル)プロパティを持っていてHTMLのビューに反映されます。
github-issues要素のテンプレートを定義します。
<script type="text/stache" id="github-issues-template">
<h1 class="page-header text-center">
{{pageTitle}}
</h1>
</script>
{{pageTitle}}が、テンプレートのビューモデルのpageTitleを反映します。
HTMLのヘッダーを変更します。
<h1 class="page-header text-center">
{{pageTitle}}
</h1>
新しいカスタム要素を使います。
<github-issues {page-title}="pageTitle" />
上記のコードで、アプリのビューモデルからgithub-issuesコンポーネントへ、pageTitleプロパティを渡しました。{page-title}文は、親テンプレートから子テンプレートへの一方向のバインドです。つまり親の変更は子に反映されますが、子の変更は親には影響しません。CanJSでは一方向・双方向の両方のデータバインディングに対応しています。後ほど双方向の例を紹介します。
ページは以前とまったく同じですが、HTMLの構造は変わります。
GitHubリポジトリの作成
GitHubリポジトリ(repo)のissueからTo-Doリストを生成するので、GitHubリポジトリの設定が必要です。
リポジトリが無ければ作成してください。
リポジトリができたら、Settingsを開いてWebhooksをクリックし、Add webhook(Webhookの追加)をクリックします。認証後、フォームを埋めます。
- Payload URLの欄にはローカルサーバーのngrokサーバーアドレスを貼る(例:https://829s1522.ngrok.io/api/webhook)
- Content typeは、application/jsonを選択
- Let me select individual events(個別にイベントを選択)をクリックし、Issuesチェックボックスを選択
- そのほかいろいろ
- Add webhookボタンを押して作成を終了する
リポジトリのissuesに変更があれば、ローカルサーバーはそのWebhookイベントを受け取れます。試してみます。
GitHubリポジトリのIssuesタブから新規issueを作成してください。Test issueという名前のissueを作ったら、コマンドラインに以下のメッセージが表示されます。
Received “opened” action from GitHub for issue “Test issue”
GitHub Issueのリスト化
GitHubリポジトリにいくつかissueを作成しました。続いてアプリUIに表示します。
issueデータのモデルとして、観察対象(observable)のIssue型を定義します。app.jsファイルの先頭に以下を加えます。
var Issue = can.DefineMap.extend('Issue', {
seal: false
}, {
id: 'number',
title: 'string',
sort_position: 'number',
body: 'string'
});
各Issueのインスタンスはid、title、sort_position、bodyプロパティを持っています。GitHub issueはここで設定したプロパティが複数あるので、sealをfalseに設定してGitHub APIからほかのプロパティが来てもエラーが出ないようにします。
issueの配列用にcan.DefineList型を定義します。
Issue.List = can.DefineList.extend('IssueList', {
'#': Issue
});
can-connectが2つの特別なプロパティを認識するためにcan-set.Algebraを設定します。特別なプロパティとは、各issueの一意の識別子であるidと、指定した順序でissueを取得するためIssue.getListと一緒に使うsortです。
Issue.algebra = new can.set.Algebra(
can.set.props.id('id'),
can.set.props.sort('sort')
);
IssueとIssue.Listをサーバーのエンドポイントに接続します。以下のGITHUB_ORG/GITHUB_REPOの部分は、自分のリポジトリの情報に置き換えて記述してください。
Issue.connection = can.connect.superMap({
url: '/api/github/repos/GITHUB_ORG/GITHUB_REPO/issues',
Map: Issue,
List: Issue.List,
name: 'issue',
algebra: Issue.algebra
});
アプリがcan.connect.superMapを呼ぶと、IssueオブジェクトはいくつかのCRUDメソッド(データベースの基本機能である新規作成、読み出し、更新、削除の頭文字)が使えるようになります。さらに、指定した型のすべてのインスタンスのリストを取得するメソッドgetListも使えます。
このアプリはサーバーから全issueを取得するためIssue.getListを使います。ここでGitHubIssuesVMを変更してissuesPromiseプロパティを加えます:
var GitHubIssuesVM = can.DefineMap.extend('GitHubIssuesVM', {
issuesPromise: {
value: function() {
return Issue.getList({
sort: 'sort_position'
});
}
},
issues: {
get: function(lastValue, setValue) {
if (lastValue) {
return lastValue;
}
this.issuesPromise.then(setValue);
}
},
pageTitle: 'string'
});
issuesPromiseプロパティは、Issue.getListから返されるプロミスです。sortプロパティとして指定したsort_positionに従ってリストが整列します。またissuesプロパティは、解決後のプロミスの値になります。
index.htmlファイルのgithub-issues-templateを変更します。
<div class="list-group">
{{#if issuesPromise.isPending}}
<div class="list-group-item list-group-item-info">
<h4>Loading…</h4>
</div>
{{/if}}
{{#if issuesPromise.isRejected}}
<div class="list-group-item list-group-item-danger">
<h4>Error</h4>
<p>{{issuesPromise.reason}}</p>
</div>
{{/if}}
{{#if issuesPromise.isResolved}}
{{#if issues.length}}
<ol class="list-unstyled">
{{#each issues}}
<li class="list-group-item">
<h4 class="list-group-item-heading">
{{title}} <span class="text-muted">#{{number}}</span>
</h4>
<p class="list-group-item-text text-overflow">
{{body}}
</p>
</li>
{{/each}}
</ol>
{{else}}
<div class="list-group-item list-group-item-info">
<h4>No issues</h4>
</div>
{{/if}}
{{/if}}
</div>
can-stacheテンプレートでは条件式として{{#if}}が使えるので、issueのプロミスの状態により3つのブロック、sPending(保留)、isRejected(拒否)、isResolved(解決)に分岐します。isResolvedの場合は{{#each}}でissueの配列を処理します。1つもissueが無ければメッセージを表示します。
これでページを更新しても、同じissueのリストを表示します。
GitHub Issueの作成
題名と説明付きのissueを新規作成するフォームを作ります。そのフォームから、GitHubのAPIを使って新規issueを生成します。
index.htmlファイル内のgithub-issues-templateテンプレートのh1タグの下にフォームを作成します。
<form ($submit)="send()">
<div class="form-group">
<label for="title" class="sr-only">Issue title</label>
<input class="form-control" id="title" placeholder="Issue title" type="text" {($value)}="title" />
</div>
<div class="form-group">
<label for="body" class="sr-only">Issue description</label>
<textarea class="form-control" id="body" placeholder="Issue description" {($value)}="body"></textarea>
</div>
<button class="btn btn-primary" type="submit">Submit issue</button>
</form>
上記コードでは、まだ紹介していないCanJSの機能を使っています。
- ($submit):フォームが送信された際にビューモデルのsend()関数を呼ぶDOMイベントリスナー
- {($value)}="title"と{($value)}="body":どちらも値の双方向バインディング。入力欄の値が変更されるとビューモデルも更新され、その逆でも同じく値が更新される
app.jsファイル内のGitHubIssuesVMを変更して、新しいプロパティを3つ追加します。
var GitHubIssuesVM = can.DefineMap.extend('GitHubIssuesVM', {
issuesPromise: {
value: function() {
return Issue.getList({
sort: 'sort_position'
});
}
},
issues: {
get: function(lastValue, setValue) {
if (lastValue) {
return lastValue;
}
this.issuesPromise.then(setValue);
}
},
pageTitle: 'string',
title: 'string',
body: 'string',
send: function() {
var firstIssue = (this.issues) ? this.issues[0] : null;
var sortPosition = (firstIssue) ? (Number.MIN_SAFE_INTEGER + firstIssue.sort_position) / 2 : 0;
new Issue({
title: this.title,
body: this.body,
sort_position: sortPosition
}).save().then(function() {
this.title = this.body = '';
}.bind(this));
}
});
新しいissueには、body、titleプロパティに加えて新規issue作成メソッドsend()を実装しています。このメソッドはissuesリストを受け取って新規issueのsort_position(位置)を計算します。新規issueは最初のissueの前が望ましいです。新規issueの値がそろったらnew Issue()を実行して新規作成し、.save()メソッドでサーバーに保存し、プロミスが解決されるのを待ちます。それが成功したら、フォームの題名と本文(title、body)を消去して空欄に戻します。
app.jsファイル内のgithub-issuesコンポーネントを更新して新規にeventsオブジェクトを作成します。
can.Component.extend({
tag: 'github-issues',
view: can.stache.from('github-issues-template'),
ViewModel: GitHubIssuesVM,
events: {
'{element} form submit': function(element, event) {
event.preventDefault();
}
}
});
フォームの送信イベントを検知するために、can-componentのeventsプロパティを使用します。ユーザーのフォームを送信でページを更新しないため、preventDefault()でフォーム送信時に通常のふるまいを取り消します。
issueを追加して、GitHub UIにも表示できました。さらにcan-set.algebraの影響でissueはリストの最後に表示されます。
リアルタイム更新機能の追加
新規issueをGitHubに送信できますが、GitHubで加えた変更はアプリに反映されません。そこで、Socket.IOによるリアルタイムの更新機能を追加します。
app.jsファイル内の、Issue.connectionに続いて以下のコードを追記します。
var socket = io();
socket.on('issue created', function(issue) {
Issue.connection.createInstance(issue);
});
socket.on('issue removed', function(issue) {
Issue.connection.destroyInstance(issue);
});
socket.on('issue updated', function(issue) {
Issue.connection.updateInstance(issue);
});
ローカルサーバーは、issueが作成・削除・更新されると3種類のイベントを発生します。イベントを受けてイベントリスナーがcreateInstance(作成)、destroyInstance(削除)、updateInstance(更新)をコールしてIssueデータが変更されます。IssueのインスタンスはもちろんのことIssue.Listも観察対象(observable)であるため、CanJSはIssueモデルを参照するいかなるパーツでも自動更新します。
ページを更新したあと、GitHubのUIを変更すると、アプリのUIも同様に変更します。
Issueの並び替え
issueの並び替えをドラッグ&ドロップでする機能を加えます。ローカルサーバーでは、issueリストの順序を変更したら、プロジェクトフォルダーのissues.jsonファイルに保存するように設定します。必要なのはアプリにissueを並び替える操作機能を追加することと、issueに対し新しい位置(sort_position)を代入するロジックを書くことです。
前章で加筆したSocket.IOのコードに続いて、下記を追記します。
can.view.callbacks.attr('sortable-issues', function(element) {
$(element).sortable({
containment: 'parent',
handle: '.grab-handle',
revert: true,
start: function(event, ui) {
var draggedElement = ui.item;
draggedElement.addClass('drag-background');
},
stop: function(event, ui) {
var draggedElement = ui.item;
draggedElement.removeClass('drag-background');
},
update: function(event, ui) {
var draggedElement = ui.item[0];
var draggedIssue = can.data.get.call(draggedElement, 'issue');
var nextSibling = draggedElement.nextElementSibling;
var previousSibling = draggedElement.previousElementSibling;
var nextIssue = (nextSibling) ? can.data.get.call(nextSibling, 'issue') : {sort_position: Number.MAX_SAFE_INTEGER};
var previousIssue = (previousSibling) ? can.data.get.call(previousSibling, 'issue') : {sort_position: Number.MIN_SAFE_INTEGER};
draggedIssue.sort_position = (nextIssue.sort_position + previousIssue.sort_position) / 2;
draggedIssue.save();
}
});
});
詳しく解説します。
- can.view.callbacksは、DOMに新しく属性や要素を加えた際に呼ばれるコールバックを設定する。このコードの場合、要素にsortable-issues属性を与えたときに関数が呼ばれる
- DOM要素のドラッグ&ドロップ機能の実装に使用したjQuery UIのsortable interactionのオプションcontainment、handle、revertの設定をする
- ユーザーがissueをドラッグしたら、start関数が呼ばれ、DOM要素にクラス(drag-background)を追加する
- ユーザーがissueをドロップ(配置)したら、stop関数が呼ばれ、start関数で加えたクラスを取り除く
- 並び替えが完了し、DOMが更新されたらupdate関数が呼ばれる。ドラッグで移動したIssueモデルおよび前後のissueモデルを取得して、間にあるこのissueの位置(sort_position)が計算できる。sort_positionを代入したのちsave()関数をコールし、更新されたissueデータをローカルサーバーに保存する(PUTメソッド)
index.htmlファイル内で、issueの<ol>(順序付きリストタグ)を変更します。
<ol class="list-unstyled" sortable-issues>
{{#each issues}}
<li class="list-group-item" {{data('issue', this)}}>
{{^is issues.length 1}}
<span class="glyphicon glyphicon-move grab-handle pull-right text-muted" aria-hidden="true"></span>
{{/is}}
<h4 class="list-group-item-heading">
{{title}} <span class="text-muted">#{{number}}</span>
</h4>
<p class="list-group-item-text text-overflow">
{{body}}
</p>
</li>
{{/each}}
</ol>
新しく追加したものは以下の通りです。
- sortable-issues属性はapp.jsファイル内で定義したように、DOM上の属性が加わるとコールバックを呼ぶ
- {{data('issue',this)}}はDOM要素にissueデータを載せる。sortable-issuesコールバックでデータを取得する
- {{^is issues.length 1}}は、リスト上に2つ以上のissueがあるときにissueを動かすためのグラブハンドル(取っ手)を追加する
ページを更新すると、各issueにハンドルが現れます。ハンドルをつかんで並び替えができます。
さらに詳しく知るために
CanJSを使った、GitHub issueのリアルタイムTo-Doリストが完成しました。さらにCanJSを学びたい意欲が湧いたら、CanJS.comで以下の導入記事をチェックしてください。
疑問があればGitterで質問するか、CanJSのフォーラム、私のツイッター、で質問してください。
本記事はCamilo Reyesが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:How to Build a Real-Time GitHub Issue To-Do List with CanJS)
[翻訳:西尾 健史/編集:Livit]