CanJS是一个前端库的集合,这些前端库使构建可长期维护的复杂且创新的Web应用程序变得更加容易。 它分为数十个单独的程序包,因此您可以在应用程序中选择所需的内容,而不会被100kb +的巨大依赖所困扰。
CanJS通过以下关键软件包来提升MVVM(Model-View-ViewModel)体系结构:
在本教程中,我们将制作一个使用GitHub存储库的问题列表作为源的待办事项列表应用程序。 借助GitHub的Webhook API ,我们的应用将实时更新,并且由于jQuery UI的可排序交互 ,我们将能够对问题进行重新排序 。
您可以在GitHub上找到此应用程序的完成源代码。 最终的应用程序如下所示:
如果您有兴趣将自己的JavaScript技能提高到一个新的水平,请注册SitePoint Premium,并查看我们的最新书籍《 Modern JavaScript》
CanJS中的MVVM
在开始本教程的项目之前,让我们深入了解MVVM在CanJS应用程序中的含义。
数据模型
MVVM中的“模型”适用于您的数据模型:应用程序中数据的表示。 我们的应用程序处理单个问题和问题列表,因此这些是我们模型中的数据类型。
在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
。 设置值后,除非该值为null
或undefined
,否则can-define/map/map
会将其转换为上面指定的类型。 例如,将id
设置为字符串"1"
将为id
属性赋予数字值1
,而将其设置为null
则实际上将其设置为null
。
我们将为一系列问题定义一种类型:
import DefineList from 'can-define/list/list';
Issue.List = DefineList.extend('IssueList', {
'#': Issue
});
can-define/list/list
上的#属性会将can-define/list/list
任何项目转换为指定的类型,因此Issue.List
任何项目Issue.List
将是Issue
实例。
查看模板
Web应用程序中的“视图”是与用户交互的HTML用户界面。 CanJS可以使用几种不同的模板语法来呈现HTML,包括can-stache ,它类似于Mustache和Handlebars 。
这是can-stache
模板的简单示例:
<ol>
{{#each issues}}
<li>
{{title}}
</li>
{{/each}}
</ol>
在上面的例子中,我们使用{{#each}}通过列表迭代issues
,然后显示title
每个问题与{{title}}
。 对issues
列表或问题标题的任何更改都将导致DOM更新(例如,如果将新问题添加到列表中,则将li
添加到DOM中)。
查看模型
MVVM中的ViewModel是模型和视图之间的粘合代码。 ViewModel提供了模型中无法包含但视图必需的任何逻辑。
在CanJS中, can-stache
使用ViewModel渲染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组件由一个视图( 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>
它有很多不同的部分,所以让我们分解一下:
-
head
中的两个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模块 ; 通常,您可能希望使用StealJS或webpack之 类的模块加载器 ,但这超出了本文的范围。 我们将使用Socket.io从GitHub接收事件以实时更新我们的应用程序。
接下来,让我们向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
脚本会生成一个can
全局变量,可用于访问任何CanJS模块。 例如,can-stache
模块可用于我们的脚本,如can.stache
。
如果在浏览器中打开index.html
,您将看到类似以下内容:
控制台中有一个错误,因为我们尚未设置实时Socket.io服务器。 接下来,让我们开始。
设置我们的服务器
只要存储库中发生任何更改, GitHub的Webhooks API即可发送服务器通知。 我没有花时间编写服务器代码,而是制作了github-issue-server npm模块 ,该模块将:
- 设置一个ngrok服务器以接收GitHub Webhook事件
- 在用户界面中创建问题时,向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
或ngrok.io
地址,我们将看到与以前相同的主页,除了这次控制台中不会出现任何错误:
创建一个GitHub Issues组件
在CanJS中, 组件是具有视图 (stache模板)和视图模型 (将数据模型连接到视图)的自定义元素。 组件对于将功能分组在一起并使其在整个应用程序中可重用非常有用。
让我们创建一个github-issues
组件,该组件将用于列出所有GitHub问题并添加新问题!
首先,我们将其添加到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" />
在上面的代码中,我们将pageTitle
属性从应用程序的视图模型传递到github-issues
组件。 {page-title}
语法是从父级模板到子级组件的单向绑定 ,这意味着父级中的任何更改都将传播到子级,但子级中的任何更改都不会影响父级。 CanJS支持单向和双向数据绑定。 稍后我们将查看双向数据绑定的示例。
我们的页面应该与以前完全一样,只是现在它具有以下HTML结构:
设置GitHub存储库
我们的应用程序将从GitHub存储库(repo)中的问题中列出待办事项,因此我们需要为我们的应用程序配置GitHub repo。
如果您已经有要使用的仓库,那就太好了! 否则, 现在创建一个 。
现在我们有了一个仓库 ,转到其“设置”页面,单击“ Webhooks” ,然后单击“ 添加webhook” 。 验证后,您可以填写以下表格:
- 将
ngrok
服务器地址从本地服务器复制到有效负载URL字段(该地址类似于https://829s1522.ngrok.io/api/webhook
) - 选择
application/json
作为内容类型 - 单击“ 让我选择单个事件”,然后选择“ 问题”复选框
- gfgf
- 单击添加webhook按钮以完成该过程
现在,只要您的回购中的问题列表发生更改,本地服务器就会收到这些Webhook事件。 让我们测试一下!
转到GitHub中的Issues标签,在GitHub存储库中创建问题。 如果您创建一个称为“测试问题”的问题,您将在命令行界面中看到以下消息:
从GitHub收到了针对“测试问题”的“未解决”行动
列出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
; 我们将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}}用作条件,因此对于问题清单的承诺是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"
和{($value)}="body"
都是双向绑定的值 :input
的value
更改时,视图模型将更新,反之亦然
其次,让我们将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
组件,使其具有一个新的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属性用于侦听要触发的表单的Submit事件 。 我们不希望用户提交表单时重新加载页面,因此我们调用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();
}
});
});
ew! 让我们分解一下:
- can.view.callbacks用于在将新属性或元素添加到DOM时注册回调。 在我们的代码中,只要将
sortable-issues
属性添加到元素,就会调用我们的函数。 - 我们正在使用jQuery UI的可排序交互来处理DOM元素的拖放。 我们已经使用containment , handle和revert选项对其进行了配置。
- 每当用户开始拖动问题时,就会触发启动函数,这将向DOM元素添加一个类。
- 每当用户删除问题时,将触发stop函数,这将删除我们在
start
添加的类。 - 排序完全停止并且DOM已更新后,将调用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>
我们添加了一些新内容:
- 该列表位于DOM中后,
sortable-issues
属性将导致我们在app.js
定义的回调被调用。 -
{{data('issue', this)}}
会将问题数据附加到DOM元素,因此我们可以在我们的sortable-issues
回调中获取它。 - 如果列表中有多个问题,则
{{^is issues.length 1}}
部分将添加一个抓取手柄以移动问题。
现在,当我们重新加载页面时,我们将看到每个问题的抓取手柄,我们可以捡起它们来重新排序问题!
进一步阅读
我们已经使用CanJS成功为GitHub问题建立了一个实时待办事项清单! 如果我想了解更多有关CanJS的信息,请在CanJS.com上查看以下一些指南:
感谢您抽出宝贵时间阅读本教程。 如果您需要任何帮助,请不要害怕在Gitter上 ,在CanJS论坛上发问 , 向我发送推文或在下面发表评论!
本文由Camilo Reyes进行同行评审。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!
From: https://www.sitepoint.com/real-time-github-issue-list-canjs/