CanJS 是一个前端库的集合,使得构建复杂的、可维护的 Web 应用程序更容易维护。它分解成几十个独立的包,所以你可以选择你想要的模块,而不是巨大的 100kb+ 依赖。
CanJS 使用 MVVM(模型-视图-视图模型),具有以下主要包模块:
- can-component 自定义元素
- can-connect 用于与 API 通信
- can-define 对于观测值
- can-stache 类 Handlebars 模板
译者注:更多内容请访问,https://www.oschina.net/p/canjs
在该教程中,我们将会制作一个使用 GitHub 仓库的问题列表作为源的待办事宜列表应用. 多亏了 GitHub’s Webhook API 与 jQuery UI的可排序交互,我们的应用将会实时更新并且能够重排问题顺序.
你可以在 GitHub 找到该应用的最终源代码. 下面是我们最终应用的样子:
如果你对进一步提升你的 JavaScript 技能感兴趣, 注册 SitePoint Premium 并查看我们最新的书籍, 现代 JavaScript
CanJS 中的 MVVM
在我们开始本教程的工程之前,让我们深入了解一下 CanJS 应用中的 MVVM 意味着什么.
数据模型
MVVM 中的 “Model” 用于你的数据模型: 你应用中的数据表示. 我们的应用处理单个问题与问题列表,所以这是我们的数据模型中所应有的数据类型.
在 CanJS 中, 我们使用 can-define/list/list 与 can-define/map/map 来分别表示数组与对象. 这些是数据的观察类型,当它们发生变化时,会自动更新 View 或 ViewModel (位于MVVM中) .
例如,我们的应用会有一个类似如下的 Issue
类型:
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
, 与 body
. 当设置一个值时, can-define/map/map
会将该值转换为上面特定的类型,除非该值为 null
或 未定义. 例如,将 id 设置为字符串 "1"
将会为 id
属性指定数值 1,然而将其设置为 null
实际上会将其变为 null
.
我们将会为问题列表定义一个如下的类型:
import DefineList from 'can-define/list/list';
Issue.List = DefineList.extend('IssueList', {
'#': Issue
});
can-define/list/list 中的
# 属性会将列表中的任意项转换为指定的类型,所以 Issue.List
中的每一项将会是一个 Issue
实例.
视图模板
web 应用中的 “视图” 是用户与其交互的 HTML 用户界面. CanJS 可以使用多个不同的模板语法渲染 HTML , 包括 can-stache, 其类似于 Mustache and Handlebars.
下面是一个 can-stache
模板的简单示例:
<ol>
{{#each issues}}
<li>
{{title}}
</li>
{{/each}}
</ol>
在上面的示例中,我们使用 {{#each}} 来迭代问题列表, 然后使用 {{title}}
显示每一个问题的标题. 对问题列表或是问题标题的任何修改都会使得 DOM 被更新 (例如,如果一个新问题被添加到列表中,则会向 DOM 中添加一个 li
).
视图模型
MVVM 中的视图模型是模型与视图之间的胶水代码 . 任何不能组合到模型中但对于视图则是必须的逻辑由视图模型来提供.
在 CanJS 中, can-stache
模板由视图模型来渲染. 下面是一个简单的示例:
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”
组件
组件的概念是将所有这些内容连接在一起 (或自定义元素). 组件对于将功能组合在一起并使其在我们的整个应用中重用非常有用.
在 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 world” (没有感叹号).
所有这四个概念是你构建一个 CanJS 应用需要了解的内容! 我们的示例应用将会使用这四个概念来构建一个完全成熟的 MVVM .
教程的先决条件
在开始之前,安装最新版本的 Node.js. 我们将会使用 npm 来安装将处理与 GitHub 的 API 通信的后端服务器.
另外, 如果你还没有 GitHub 帐户, 注册 一个.
设置本地工程
让我们首先为我们的工程创建一个新目录并切换到该新目录:
mkdir canjs-github
cd canjs-github
现在让我们创建我们工程所需要的文件:
touch app.css app.js index.html
我们会将 app.css
用于我们的风格, app.js
用于我们的 JavaScript, 以及 index.html
用于我们的用户界面 (UI).
CanJS Hello World
让我们编码吧! 首先,我们会将下面代码添加到我们的 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>
这有一系列不同的组成部分,让我们将其分解:
- 头部的两个
link
元素是我们工程的样式表. 我们使用 Bootstrap 作为基本风格并且我们还会在app.css 中的一些自定义风格
- 第一个
script
元素 (id="app-template"
) 包含我们应用的根模板 - 第二
script
元素 (id="github-issues-template"
) 将包含我们稍后在本教程中创建的github-issues
组件的模板 - 页面底部的
script
元素载入我们的依赖: jQuery, jQuery UI, CanJS, Socket.io, 以及我们应用的代码
在我们的应用中, 我们将使用 jQuery UI (依赖于 jQuery) 通过拖放来排序问题. 我们已经包含了can.all.js
从而我们可以访问 所有 CanJS module; 通常, 你希望使用 模块装载器 例如 StealJS 或 webpack, 但是这超出了本文的范围. 我们将使用 Socket.io 由 GitHub 接收事件来实时更新我们的应用.
接下来,让我们向我们的 our 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
是所有AppViewModel
实例的属性,默认为GitHub Issues 的值
appVM
是我们应用的视图模型的新实例can.stache.from
将script
标记的内容转换为渲染模板的函数appFragment
是使用appVM
数据所渲染模板的文档片段document.body.appendChild
获取 DOM 节点并将其添加到 HTML 体中
注意:我们页面中的 can.all.js
脚本提供了一个全局变量,我们可以用其来访问任意的 CanJS 模块. 例如, can-stache
模块可以通过 can.stache
来使用.
如果你在浏览器中打开 index.html
, 你将会看到类似下面的页面:
在控制台中有一个错误,这是因为我们还没有设置实时 Socket.io 服务器. 让我们马上来设置。
设置我们的服务器
GitHub 的 Webhooks API 会在仓库内发生变化时发送服务器通知. 无需花费时间编写服务器代码, 我已经实现了 github-issue-server npm 模块 ,该模块将会:
- 设置 ngrok 服务器接收 GitHub Webhook 事件
- 当我们在 UI 中创建问题时向 GitHub API 发送授权请求
- 使用 Socket.io 与我们的 UI 进行实时通信
- 在工程目录内提供服务文件
- 向每个问题添加
sort_position
属性 - 将我们的问题列表及其
sort_position
保存到本地的issues.json
文件
为了使得服务器能够与 GitHub 通过授权请求进行通信, 我们需要 创建一个个人访问令牌:
- 访问 github.com/settings/tokens/new
- 输入 令牌 描述 (我将其称之为 “CanJS GitHub Issue To-do List”)
- 选择
public_repo
域 - 点击 生成令牌
- 在下一页, 点击令牌旁边的复制令牌剪贴板图标
现在我们可以安装服务器. 我们将使用 npm 来 创建 package.json 并安装 github-issue-server
:
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
或是 rok.io
地址时, 我们将会看到与之前相同的主页, 所不同的是这次在我们的控制台中不会有任何错误:
创建 GitHub 问题组件
在 CanJS 中, 组件是具有视图 (stache 模板) 与视图模型 (将你的数据模型连接到视图) 的自定义元素. 组件对于将功能组合在一起并使其在整个应用中重用非常有用.
让我们创建一个将会用于列出我们所有 Github 问题并添加新问题的 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被定义作为我们的组件的视图模型。组件的每个实例都有它自己的页面标题属性,它将在html视图中呈现。
其次,让我们为github-issues元素定义模板:
<script type="text/stache" id="github-issues-template">
<h1 class="page-header text-center">
{{pageTitle}}
</h1>
</script>
注意这里使用{{pageTitle}}
语法,它在我们的视图模型中用模版渲染页面标题。
最后,让我们替换HTML中的头:
<h1 class="page-header text-center">
{{pageTitle}}
</h1>
...以我们新的自定义元素:
<github-issues {page-title}="pageTitle" />
在以上代码中,我们从我们的视图模型中传递了页面标题属性到github-issues
组件。{page-title}
语法是从父模板到子组件的单向绑定,这意味着父模板中的任何更改都将传递给子组件,但是子组件中的任何更改都不会影响父模板。CanJS支持单向和双向数据绑定。稍后我们将讨论双向数据绑定的示例。
我们的页面看起来与之前的完全相同,所不同的是它现在具有这样的 HTML 结构:
设置 GitHub 仓库
我们的应用将会为 Github 仓库内的问题创建一个待办事宜列表, 所以我们需要为我们的应用配置 GitHub 仓库.
如果你已经拥有一个你希望使用的仓库, 太好了! 否则, 现在创建一个.
现在我们已经有了仓库, 进入其 设置 页面, 点击 Webhooks, 然后点击 添加 webhook. 在授权之后, 你可以填充表单:
- 由你的本地服务器拷贝
ngrok
地址到 Payload URL 文本框 (地址类似于https://829s1522.ngrok.io/api/webhook
) - 选择
application/json
作为 内容类型 - 点击 让我选择单独事件 并选择 Issues 复选框
- .....
- 点击 添加 webhook 按钮完成处理
现在,当你的仓库中的问题列表发生变化时, 你的本地服务器将会接收 到这些 Webhook 事件. 让我们测试一下!
通过 Github 中的问题标签在你的 Github 仓库中创建一个问题. 如果你创建一个名为 “Test issue” 的问题, 你将会在你的命令行界面看到如下的消息:
由 Github 接收到 “Test issue” 问题的 “opened” 动作
列出 GitHub 问题
现在我们在 Github 仓库已经有一些问题了, 让我们在 UI 中显示这些问题!
首先, 我们将创建一个可观察的 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 问题具有多个其他属性而不仅仅是我们在这里建模的这些属性, 我们将 seal 设置为 false
从而在通过 Github API 获取到其他属性时不会抛出错误.
第二, 让我们为问题数组创建一个 can.DefineList
类型:
Issue.List = can.DefineList.extend('IssueList', {
'#': Issue
});
第三, 我们将配置一个 can-set.Algebra 从而 can-connect
会知道两个特殊的属性: id
是每个问题的唯一标识符而我们将使用 sort
配合 Issue.getList
以特定顺序获取问题.
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, CRUD (创建, 读取, 更新, 与删除) 方法被添加到我们的 Issue
对象. 包含在这些方法之中的是 getList, 该方法可以被调用来获取该类型所有实例的列表.
在我们的应用中, 我们将使用 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 返回的
Promise ; 我们指定 sort_position
作为 sort
属性从而列表按该属性进行排序. issues
属性将会是解析后 Promise 的值.
现在让我们修改 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}} 用作条件, 从而我们有三个主要块用于处理我们问题列表的 Promise 是否处于 isPending, isRejected, 或 isResolved 状态. 在 isResolved
情况下, 我们将通过 {{#each}} 遍历问题数组, 否则我们将会显示一个没有问题的消息.
现在当你重新载入页面, 你可以看到相同的问题列表!
创建 GitHub 问题
让我们添加一个带有标题与描述的表单用于创建新问题. 然后我们将通过 GitHub API 创建新问题.
首先, 让我们在 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)
是一个DOM事件监听器它会在提交表单时在我们视图中调用send()
函数{($value)}="title"
and{($value)}="body"
都是双向绑定值: 当输入值发生变化时,视图模型将更新,反之亦然。
其次,让我们来更新app.js中GitHubIssuesVM的三个新属性:
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));
}
});
对于新问题,除了 body
与 title
属性外, 我们添加了创建新问题的 send()
方法. 它接受 issues
列表从而可以为新问题计算 sort_position
: 我们希望其位于第一个问题之前. 一旦我们拥有了新问题的所有值, 我们调用 new Issue()
来创建新问题, 调用 .save()
将其发送给我们的服务器,然后等待要解析的 Promise ; 如果成功, 我们重置 title
与 body
,从而表单被清空!
最后, 让我们更新 app.js
中的 github-issues
组件使其拥有新的事件对象:
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 属性被用于监听被触发的表单的 submit event . 我们并不希望在用户提交表单时重新载入页面, 所以我们调用 preventDefault() 来关闭默认的表单提交行为.
现在我们可以添加一个新问题并且看到它显示在 GitHub UI 中! 除此之外, 问题出现在我们问题列表的底部, 由于集合代数而显得非常神奇!
添加实时更新
我们的应用可以向 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);
});
我们的本地服务器会在问题被创建,删除或更新时发送三个不同的事件. 然后我们的事件监听器会调用 createInstance, destroyInstance, 或 updateInstance 来修改 Issue
数据模型. 因为每个 Issue
实例都是可观察的且Issue.List
是可观察的, CanJS 将会自动更新我们应用中引用 Issue
模型的任何部分!
当我们重新载入页面并通过 GitHub 的 UI 进行修改时, 我们将会在我们的 UI 中看到相同的修改!
重新排序问题
现在让我们添加一些拖拽功能来组织我们的问题! 我们的本地服务器被配置为当我们问题列表的顺序发生变化时将 issues.json
文件保存到工程目录, 所以我们需要做的就是更新我们的应用进行重新排序控制以及为为其赋值一个新 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
属性被添加到一个元素时被调用. - 我们使用 jQuery UI 的可排序交互 来处理 DOM 元素的拖放. 我们已使用 containment, handle, 与 revert 选项对其进行配置.
- 当一个问题开始被用户拖拽时, start 函数将会被触发, 该函数将会向 DOM 元素添加一个类.
- 当一个问题被用户放下时, stop 函数将会被触发, 该函数将会移除我们在
start 中添加的类
. - update 将会在排序已完全停止而已经被更新时被调用. 我们的函数获取我们所拖拽问题以及目标位置前后问题的
Issue
模型数据, 从而它可以重新计算两个问题之间的sort_position
. 在我们赋值sort_position
属性后, 我们调用 save() 将更新的问题数据发送到我们的局部服务器.
现在让我们更新 index.html 中的问题
<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 元素从而我们可以在sortable-issues
回调中获得该数据.如果列表中有多个问题,{{^is issues.length 1}}
部分将会添加一个握柄来移动问题.
现在当我们重新载入页面,我们可以看到每个问题上的握柄,我们将可以将其拾起对问题进行重新排序!
进一步阅读
我们已成功使用 CanJS 为 GitHub 构建了一个实时的待办事宜列表! 如果我已经引起你学习更多 CanJS 知识的兴趣, 查看 CanJS.com 上的下列指南:
感谢你花时间完成本教程. 如果你需要帮助, 请不要害怕在 Gitter, CanJS 论坛, tweet 上提问或是在下面留下你的评论!
本文是由 Camilo Reyes 完成同行评审的. 感谢所有使得 SitePoint 的内容达到最好而做的 SitePoint 的同行评审!

- 原文:How to Build a Real-Time GitHub Issue To-Do List with CanJS / 如何使用 CanJS 基于 Github Issue 构建一个待办事宜列表
- 作者:Chasen Le Hara
- 频道:计算机
- 发布:CY2 (2017-06-20)
- 标签: Github
- 版权:本文仅用于学习、研究和交流目的,非商业转载请注明出处、译者和可译网完整链接。
文章评论