Table of Contents generated with DocToc
ember cli, ember命令行,都为ember项目提供了良好的项目结构,以及很多开发工具和附加的插件,因此使得使用ember的开发者们可以更加专心致力于App的开发而不是搭建项目的结构。
# 新建一个名为super-rentals的项目,我们将在应用里浏览并查找租房信息
$ ember new super-rentals
# 基本的项目结构
|--app # models, components, routes, templates, styles...
|--bower_components
|--config # containers environment.js
|--dist # output files
|--node_modules
|--public # assets such as images & fonts
|--tests
|--tmp # ember cli temporary files
|--vendor # third-party dependencies
bower.json
ember-cli-build.js
package.json
README.md
testem.js
值得一提的是,ember cli默认使用es6语法
$ ember server
# $ ember s
然后访问ember tutorial
首先我们思考一下App的功能:
- 列出可用的租房信息
- 要有能够到达公司介绍页面的链接
- 要有能够到达联系我们页面的链接
- 根据城市来筛选租房信息的列表
它的最终形态应该这样:
我们把这些目标叫作ember验收测试
,它用来保障我们的App在完工之后能够正常工作。
首先,新建一个测试文件:
$ ember g acceptance-test list-rentals
# output
# installing acceptance-test
# create tests/acceptance/list-rentals-test.js
之后我们修改/tests/acceptance/list-rentals-test.js
文件:
import { test } from 'qunit';
import moduleForAcceptance from 'super-rentals/tests/helpers/module-for-acceptance';
moduleForAcceptance('Acceptance | list-rentals');
test('should list available rentals.', function (assert) {
});
test('should link to information about the company.', function (assert) {
});
test('should link to contact information.', function (assert) {
});
test('should filter the list of rentals by city.', function (assert) {
});
测试毫无疑问会爆掉。ember为我们提供了测试方法来模拟访问页面、表单填充、等待页面渲染等行为,因此我们应该更详细的写下测试:
// 测试页面访问
test('should list available rentals.', function (assert) {
visit('/');
// andThen 方法会等到之前的执行完毕之后再执行
andThen(function () {
assert.equal(find('.listing').length, 3, 'should see 3 listings');
});
});
test('should link to information about the company.', function (assert) {
visit('/');
click('a:contains("About")');
andThen(function () {
assert.equal(currentURL(), '/about', 'should navigate to about');
});
});
test('should link to contact information', function (assert) {
visit('/');
// 运用click模拟点击操作
click('a:contains("Contact")');
andThen(function () {
assert.equal(currentURL(), '/contact', 'should navigate to contact');
});
});
// 最后来测试列表筛选功能
test('should filter the list of rentals by city.', function (assert) {
visit('/');
// fillIn模拟input的填充
// keyEvent模拟键盘事件
fillIn('.list-filter input', 'seattle');
keyEvent('.list-filter input', 'keyup', 69);
andThen(function () {
assert.equal(find('.listing').length, 1, 'should show 1 listing');
assert.equal(find('.listing .location:contains("Seattle")').length, 1, 'should contain 1 listing with location Seattle');
});
});
$ ember generate route about
# $ ember g route about
# 它会新增about路由,以及对应的route handler
# 并创建about模板,和对应的测试
# installing route
# create app/routes/about.js
# create app/templates/about.hbs
# updating router
# add route about
# installing route-test
# create tests/unit/routes/about-test.js
// app/router.js
import Ember from 'ember';
import config from './config/environment';
const Router = Ember.Router.extend({
location: config.locationType,
rootURL: config.rootURL
});
Router.map(function() {
// 新增about
this.route('about');
});
export default Router;
在默认情况下,about路由指向app/routes/about.js
route handler,并加载app/templates/about.hbs
模板。来给模板里填充点东西:
<div class="jumbo">
<div class="right tomster"></div>
<h2>About Super Rentals</h2>
<p>
The Super Rentals website is a delightful project created to explore Ember.
By building a property rental site, we can simultaneously imagine traveling
AND building Ember applications.
</p>
</div>
$ ember g route contact
生成的文件结构和about一样。接着编写hbs
<!-- app/templates/contact.hbs -->
<div class="jumbo">
<div class="right tomster"></div>
<h2>Contact Us</h2>
<p>Super Rentals Representatives would love to help you<br>choose a destination or answer
any questions you may have.</p>
<p>
Super Rentals HQ
<address>
1212 Test Address Avenue<br>
Testington, OR 97233
</address>
<a href="tel:503.555.1212">+1 (503) 555-1212</a><br>
<a href="mailto:superrentalsrep@emberjs.com">superrentalsrep@emberjs.com</a>
</p>
</div>
ember拥有{{link-to}}
这个内置的方法来构建不同路由的导航。
<!-- app/templates/about.hbs -->
<div class="jumbo">
<div class="right tomster"></div>
<h2>About Super Rentals</h2>
<p>
The Super Rentals website is a delightful project created to explore Ember.
By building a property rental site, we can simultaneously imagine traveling
AND building Ember applications.
</p>
{{#link-to 'contact' class="button"}}
Get Started!
{{/link-to}}
</div>
同样的,我们给contact页面也加上路由:
<!-- app/templates/contact.hbs -->
<div class="jumbo">
<div class="right tomster"></div>
<h2>Contact Us</h2>
<p>Super Rentals Representatives would love to help you<br>choose a destination or answer
any questions you may have.</p>
<p>
Super Rentals HQ
<address>
1212 Test Address Avenue<br>
Testington, OR 97233
</address>
<a href="tel:503.555.1212">+1 (503) 555-1212</a><br>
<a href="mailto:superrentalsrep@emberjs.com">superrentalsrep@emberjs.com</a>
</p>
{{#link-to 'about' class="button"}}
About
{{/link-to}}
</div>
$ ember g route index
# installing route
# create app/routes/index.js
# create app/templates/index.hbs
# installing route-test
# create tests/unit/routes/index-test.js
<!-- app/templates/index.hbs -->
<div class="jumbo">
<div class="right tomster"></div>
<h2>Welcome!</h2>
<p>
We hope you find exactly what you're looking for in a place to stay.
<br>Browse our listings, or use the search box above to narrow your search.
</p>
{{#link-to 'about' class="button"}}
About Us
{{/link-to}}
</div>
为了能在不同的页面中渲染出一样的结构和样式,我们需要创建一个application
模板
$ ember g template application
# installing template
# create app/templates/application.hbs
当application.hbs
创建好之后,每个页面都会渲染它:
<!-- app/templates/application.hbs -->
<div class="container">
<div class="menu">
{{#link-to 'index'}}
<h1 class="left">
<em>SuperRentals</em>
</h1>
{{/link-to}}
<div class="left links">
{{#link-to 'about'}}
About
{{/link-to}}
{{#link-to 'contact'}}
Contact
{{/link-to}}
</div>
</div>
<div class="body">
{{outlet}}
</div>
</div>
需要注意的是,
{{outlet}}
是各页面将对应route handler渲染的template组合进application
的位置。
接下来,我们要在首页增加一个列表,为此,需要一个租房信息的model来储存数据。但一步一步来嘛,我们先hard-code写一个JavaScript Object进去:
// app/routes/index.js
// 在index的route handler中写入加数据
import Ember from 'ember';
let rentals = [{
id: 1,
title: 'Grand Old Mansion',
owner: 'Veruca Salt',
city: 'San Francisco',
type: 'Estate',
bedrooms: 15,
image: 'https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg'
}, {
id: 2,
title: 'Urban Living',
owner: 'Mike TV',
city: 'Seattle',
type: 'Condo',
bedrooms: 1,
image: 'https://upload.wikimedia.org/wikipedia/commons/0/0e/Alfonso_13_Highrise_Tegucigalpa.jpg'
}, {
id: 3,
title: 'Downtown Charm',
owner: 'Violet Beauregarde',
city: 'Portland',
type: 'Apartment',
bedrooms: 3,
image: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Wheeldon_Apartment_Building_-_Portland_Oregon.jpg'
}];
export default Ember.Route.extend({
// model()等效于model: function()
model() {
return rentals;
}
});
之后当用户进入index
页面的时候就会调用model钩子,并返回我们虚构的数据。紧接着,就会把数据渲染在模板上:
<!-- app/templates/index.hbs -->
<!-- 忽略 -->
{{#each model as |rental|}}
<article class="listing">
<h3>{{rental.title}}</h3>
<div class="detail owner">
<span>Owner:</span> {{rental.owner}}
</div>
<div class="detail type">
<span>Type:</span> {{rental.type}}
</div>
<div class="detail location">
<span>Location:</span> {{rental.city}}
</div>
<div class="detail bedrooms">
<span>Number of bedrooms:</span> {{rental.bedrooms}}
</div>
</article>
{{/each}}
在模板里,我们通过{{#each}} {{\each}}
进行循环遍历。最终的页面应该长这样:
ember提供了非常多的附加插件,你可以在Ember Observer查阅它们。
在这个项目里我们会使用两个插件:ember-cli-tutorial-style和ember-cli-mirage
为了避免你写繁琐的CSS代码,继续跟着这个ember教程走,就要安装ember-cli-tutorial-style
来为这个项目提供CSS样式。
$ ember install ember-cli-tutorial-style
# 安装完成之后重启ember server
这个插件安装完后生成vendor/ember-tutorial.css
文件。当ember cli运行时,它会被打包进vendor.css
文件里,最终被index.html
页面所引用。
效果如下:
Mirage是一个静态HTTP桩库,常用于ember的验收测试。我们在这里将它作为模拟的数据源。
$ ember install ember-cli-mirage
# 安装完成之后需要重启ember server
// 更新/mirage/config.js
export default function() {
this.get('/rentals', function() {
return {
data: [{
type: 'rentals',
id: 1,
attributes: {
title: 'Grand Old Mansion',
owner: 'Veruca Salt',
city: 'San Francisco',
type: 'Estate',
bedrooms: 15,
image: 'https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg'
}
}, {
type: 'rentals',
id: 2,
attributes: {
title: 'Urban Living',
owner: 'Mike Teavee',
city: 'Seattle',
type: 'Condo',
bedrooms: 1,
image: 'https://upload.wikimedia.org/wikipedia/commons/0/0e/Alfonso_13_Highrise_Tegucigalpa.jpg'
}
}, {
type: 'rentals',
id: 3,
attributes: {
title: 'Downtown Charm',
owner: 'Violet Beauregarde',
city: 'Portland',
type: 'Apartment',
bedrooms: 3,
image: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Wheeldon_Apartment_Building_-_Portland_Oregon.jpg'
}
}]
};
});
}
这样就模拟了当发送GET请求给/rentals
时,返回的JSON数据。
到目前为止,这个APP里的数据都是我们hard-code出来的。当应用不断增大时,我们可能会有新的需求,例如增加/更新/删除数据等等。ember提供了一个数据管理库(Ember Data)来处理这个问题。
# 创建名为rental的ember data model
$ ember g model rental
# installing model
# create app/models/rental.js
# installing model-test
# create tests/unit/models/rental-test.js
打开app/models/rental.js
,发现里面长这样:
// app/models/rental.js
import DS from 'ember-data';
export default DS.Model.extend({
});
把title, owner, city, type, image, and bedrooms作为支持的属性写进去:
// app/models/rental.js
import DS from 'ember-data';
export default DS.Model.extend({
title: DS.attr(),
owner: DS.attr(),
city: DS.attr(),
type: DS.attr(),
image: DS.attr(),
bedrooms: DS.attr()
});
此时已经没必要保留hard-code进index route-handler里的数据了:
// app/routes/index.js
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.get('store').findAll('rental');
}
});
当我们调用this.get('store').findAll('rental')
的时候,Ember Data会发送一个GET请求到/rentals
。
当我们在开发环境下使用Mirage
的时候,获得的数据都是我们自己提供给Mirage
的。而到了生产环境下,则需要为Ember Data提供后端数据接口。
当用户访问我们的列表时,可能想要一些可交互的操作来帮助他们更好的浏览和决策。为了高效的达成这个目的,应该建立一个组件把我们的列表独立出去。
# 新建名为rental-listing的组件
$ ember g component rental-listing
# installing component
# create app/components/rental-listing.js
# create app/templates/components/rental-listing.hbs
# installing component-test
# create tests/integration/components/rental-listing-test.js
要注意的是。
-
在组件名里必不可缺,它用来方式组件名和HTML元素名发生冲突。因此rental-listing
这个名字ok而rental
不行。
下一步,测试先行:
我们期望在组件render的时候没有wide
这个class名,而点击.image
的时候则可以切换wide
class。
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import Ember from 'ember';
moduleForComponent('rental-listing', 'Integration | Component | rental listing', {
integration: true
});
test('should toggle wide class on click', function(assert) {
assert.expect(3);
let stubRental = Ember.Object.create({
image: 'fake.png',
title: 'test-title',
owner: 'test-owner',
type: 'test-type',
city: 'test-city',
bedrooms: 3
});
this.set('rentalObj', stubRental);
this.render(hbs`{{rental-listing rental=rentalObj}}`);
assert.equal(this.$('.image.wide').length, 0, 'initially rendered small');
this.$('.image').click();
assert.equal(this.$('.image.wide').length, 1, 'rendered wide after click');
this.$('.image').click();
assert.equal(this.$('.image.wide').length, 0, 'rendered small after second click');
});
值得注意的是,在测试中使用了jQuery选择器来获取目标元素。
组件由两部分组成(除去组件对应的测试文件):
- 模板文件来决定组件的样式(
app/templates/components/rental-listing.hbs
) - js文件来决定组件的行为(
app/components/rental-listing.js
)
我们把之前app/templates/index.hbs
里的列表元素转移到app/templates/components/rental-listing.hbs
里,并新增img
标签:
<!-- app/templates/components/rental-listing.hbs -->
<article class="listing">
<img src="{{rental.image}}" alt="">
<h3>{{rental.title}}</h3>
<div class="detail owner">
<span>Owner:</span> {{rental.owner}}
</div>
<div class="detail type">
<span>Type:</span> {{rental.type}}
</div>
<div class="detail location">
<span>Location:</span> {{rental.city}}
</div>
<div class="detail bedrooms">
<span>Number of bedrooms:</span> {{rental.bedrooms}}
</div>
</article>
而在app/templates/index.hbs
文件中,使用一个循环来替代之前的列表:
<!-- app/templates/index.hbs -->
<!-- 忽略 -->
{{#each model as |rentalUnit|}}
{{rental-listing rental=rentalUnit}}
{{/each}}
在index.hbs
里,通过rental-listing
这个名词来引用对应的组件,并将数据遍历中的每组数据命名为rentalUnit
传递给rental-listing.hbs
组件。
现在我们来处理用户对图片的点击事件。
首先,需要使用{{#if}}
这个辅助方法来确定是否要把wide
class名赋给对应的标签:
<!-- app/templates/components/rental-listing.hbs -->
<!-- 忽略 -->
<a class="image {{if isWide "wide"}}">
<img src="{{rental.image}}" alt="">
<small>View Larger</small>
</a>
<!-- 忽略 -->
其中,isWide
这个值来源自组件的js文件。鉴于我们期望组件在初次渲染时只是小图,可以在js里把isWide
赋值为false
:
// app/components/rental-listing.js
import Ember from 'ember';
export default Ember.Component.extend({
isWide: false
});
接下来就是事件的绑定。为了让用户可以切换isWide
的值,我们需要在标签上绑定点击事件:
<!-- app/templates/components/rental-listing.hbs -->
<!-- 忽略 -->
<a {{action 'toggleImageSize'}} class="image {{if isWide "wide"}}">
<img src="{{rental.image}}" alt="">
<small>View Larger</small>
</a>
<!-- 忽略 -->
// app/components/rental-listing.js
import Ember from 'ember';
export default Ember.Component.extend({
isWide: false,
actions: {
toggleImageSize() {
this.toggleProperty('isWide');
}
}
});
当点击a标签的时候,会给组件的js文件发送这个action,组件接受这个action,触发对应的事件(toggleImageSize
),最终调用toggleProperty
方法切换isWide
属性。
最终当我们点击图片的时候,效果如图:
目前为止,App渲染的数据都是从Ember Data里直接取出的。如果我们的应用增长的很大有很多数据,想要在渲染时改变一些数据的话,就要借助Handlebars Helper
,在hbs文件中对数据进行修饰。
现在来建立一个Handlebars Helper,帮助用户快速的分辨列表中的各条目是"Standalone"还是"Community"。
$ ember g helper rental-property-type
# installing helper
# create app/helpers/rental-property-type.js
# installing helper-test
# create tests/unit/helpers/rental-property-type-test.js
初始化的helper,从hbs中引用并接受参数,然后返回这个参数。
需要注意的是,rentalPropertyType
的参数params
是个Array
// app/helpers/rental-property-type.js
import Ember from 'ember';
export function rentalPropertyType(params/*, hash*/) {
return params;
}
export default Ember.Helper.helper(rentalPropertyType);
更新rental-listing.hbs
文件:
<!-- app/templates/components/rental-listing.hbs -->
<!-- 忽略 -->
<!-- 替换了 {{rental.type}} -->
<div class="detail type">
<span>Type:</span> {{rental-property-type rental.type}} - {{rental.type}}
</div>
<!-- 忽略 -->
rental-property-type
后可跟多个参数,因此实际传入的参数会被转换为一个Array
然后更新rental-property-type.js
文件,让它对rental.type
进行判断,返回不同的值:
// app/helpers/rental-property-type.js
import Ember from 'ember';
const communityPropertyTypes = [
'Condo',
'Townhouse',
'Apartment'
];
export function rentalPropertyType([type]/*, hash*/) {
if (communityPropertyTypes.contains(type)) {
return 'Community';
}
return 'Standalone';
}
export default Ember.Helper.helper(rentalPropertyType);
因为Handlebars Helper
会把参数转为Array,所以我们在rentalPropertyType
中通过[type]
来获取第一个参数,也就是我们传进去的rental.type
。
刷新页面,可以看见列表的前两个是Standalone,其他的是Community
基础功能完成了,我们接下来给App增加筛选功能,为此需要新建一个名为list-filter
的组件。
$ ember g component list-filter
# installing component
# create app/components/list-filter.js
# create app/templates/components/list-filter.hbs
# installing component-test
# create tests/integration/components/list-filter-test.js
像之前那样,测试先行,以便帮助我们更好的思考自己要做的事情:
- filter component应该能够渲染出一个筛选后的列表
- 当没有筛选的时候它应该渲染全部的列表
- 有的话则渲染根据城市筛选出的列表
在初步测试里,我们只要简单的验证所有的城市都被渲染并可得就可以了。因为使用了Ember Data作为数据储存,我们需要异步的拿取数据,并使用wait
方法等待所有的异步完成。(异步返回promise):
// tests/integration/components/list-filter-test.js
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import wait from 'ember-test-helpers/wait';
import RSVP from 'rsvp';
moduleForComponent('list-filter', 'Integration | Component | filter listing', {
integration: true
});
const ITEMS = [{city: 'San Francisco'}, {city: 'Portland'}, {city: 'Seattle'}];
const FILTERED_ITEMS = [{city: 'San Francisco'}];
test('should initially load all listings', function (assert) {
// action异步获取数据并返回promise
this.on('filterByCity', (val) => {
if (val === '') {
return RSVP.resolve(ITEMS);
} else {
return RSVP.resolve(FILTERED_ITEMS);
}
});
// 你可以像hbs里那样使用你的组件,来模拟组件的渲染
this.render(hbs`
{{#list-filter filter=(action 'filterByCity') as |results|}}
<ul>
{{#each results as |item|}}
<li class="city">
{{item.city}}
</li>
{{/each}}
</ul>
{{/list-filter}}
`);
// wait function会等待所有的promise和xhr请求结束,并返回一个promise
return wait().then(() => {
assert.equal(this.$('.city').length, 3);
assert.equal(this.$('.city').first().text().trim(), 'San Francisco');
});
});
下一步,我们要在输入框中输入一些文字,监听键盘keyup事件并调用filter action,而且期待列表被筛选后只有一个条目被render:
// tests/integration/components/list-filter-test.js
// 忽略
test('should update with matching listings', function (assert) {
this.on('filterByCity', (val) => {
if (val === '') {
return RSVP.resolve(ITEMS);
} else {
return RSVP.resolve(FILTERED_ITEMS);
}
});
this.render(hbs`
{{#list-filter filter=(action 'filterByCity') as |results|}}
<ul>
{{#each results as |item|}}
<li class="city">
{{item.city}}
</li>
{{/each}}
</ul>
{{/list-filter}}
`);
// keyup 事件应该触发列表被筛选的事件
this.$('.list-filter input').val('San').keyup();
return wait().then(() => {
assert.equal(this.$('.city').length, 1);
assert.equal(this.$('.city').text().trim(), 'San Francisco');
});
});
之后处理模板。我们在app/templates/index.hbs
中要像测试里写的那样调用组价:
<!-- app/templates/index.hbs -->
<!-- 上面的忽略 -->
{{#list-filter
filter=(action 'filterByCity')
as |rentals|}}
<ul class="results">
{{#each rentals as |rentalUnit|}}
<li>{{rental-listing rental=rentalUnit}}</li>
{{/each}}
</ul>
{{/list-filter}}
并且在list-filter.hbs
中增加input:
<!-- app/templates/components/list-filter.hbs -->
{{input value=value key-up=(action 'handleFilterEntry') class="light" placeholder="Filter By City"}}
{{yield results}}
在这里我们使用了{{input}}
helper来render一个input,而input的value
和key-up
事件都由这个component对应的js提供:
// app/components/list-filter.js
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['list-filter'],
value: '',
init() {
this._super(...arguments);
// 初始化
this.get('filter')('').then((results) => this.set('results', results));
},
actions: {
handleFilterEntry() {
let filterInputValue = this.get('value'); // input的value
let filterAction = this.get('filter'); // 在index.hbs中传入,为filterByCity
// 筛选过后把结果赋值给result
// filter=(action 'filterByCity') as |rentals| 即 result as |rentals|
filterAction(filterInputValue).then((filterResults) => this.set('results', filterResults));
}
}
});
现在我们需要一个controller,来运行筛选的方法:
$ ember g controller index
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
actions: {
filterByCity(param) {
if (param !== '') {
return this.get('store').query('rental', { city: param });
} else {
return this.get('store').findAll('rental');
}
}
}
});
当用户在搜索框敲入字符的时候,就会调用filterByCity方法。该方法根据传入参数的不同调用不同的查询语句来达到搜索的效果。所以我们还需要修改之前的那个mirage/config.js
文件:
// mirage/config.js
export default function() {
this.get('/rentals', function(db, request) {
let rentals = [{
type: 'rentals',
id: 1,
attributes: {
title: 'Grand Old Mansion',
owner: 'Veruca Salt',
city: 'San Francisco',
type: 'Estate',
bedrooms: 15,
image: 'https://upload.wikimedia.org/wikipedia/commons/c/cb/Crane_estate_(5).jpg'
}
}, {
type: 'rentals',
id: 2,
attributes: {
title: 'Urban Living',
owner: 'Mike Teavee',
city: 'Seattle',
type: 'Condo',
bedrooms: 1,
image: 'https://upload.wikimedia.org/wikipedia/commons/0/0e/Alfonso_13_Highrise_Tegucigalpa.jpg'
}
}, {
type: 'rentals',
id: 3,
attributes: {
title: 'Downtown Charm',
owner: 'Violet Beauregarde',
city: 'Portland',
type: 'Apartment',
bedrooms: 3,
image: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Wheeldon_Apartment_Building_-_Portland_Oregon.jpg'
}
}];
if(request.queryParams.city !== undefined) {
let filteredRentals = rentals.filter(function(i) {
return i.attributes.city.toLowerCase().indexOf(request.queryParams.city.toLowerCase()) !== -1;
});
return { data: filteredRentals };
} else {
return { data: rentals };
}
});
}
完成以后如下图所示:
打包代码:
$ ember build
# 建议以生产环境运行:
$ ember build --environment=development
会将生成好的代码全部打包在dist/
文件夹下,之后怎么用就是自己的事喽