JavaScript-underscore

分類 编程语言, JavaScript

underscore

前面我们已经讲过了,JavaScript是函数式编程语言,支持高阶函数和闭包。函数式编程非常强大,可以写出非常简洁的代码。例如Arraymap()filter()方法:

1
2
3
let a1 = [1, 4, 9, 16];
let a2 = a1.map(Math.sqrt); // [1, 2, 3, 4]
let a3 = a2.filter((x) => { return x % 2 === 0; }); // [2, 4]

现在问题来了,Arraymap()filter()方法,可是Object没有这些方法。此外,低版本的浏览器例如IE6~8也没有这些方法,怎么办?

方法一,自己把这些方法添加到Array.prototype中,然后给Object.prototype也加上mapObject()等类似的方法。

方法二,直接找一个成熟可靠的第三方开源库,使用统一的函数来实现map()filter()这些操作。

我们采用方法二,选择的第三方库就是underscore。

正如jQuery统一了不同浏览器之间的DOM操作的差异,让我们可以简单地对DOM进行操作,underscore则提供了一套完善的函数式编程的接口,让我们更方便地在JavaScript中实现函数式编程。

jQuery在加载时,会把自身绑定到唯一的全局变量$上,underscore与其类似,会把自身绑定到唯一的全局变量_上,这也是为啥它的名字叫underscore的原因。

用underscore实现map()操作如下:

1
2
let r = _.map([1, 2, 3], (x) => x * x);
console.log(r); // [1, 4, 9]

咋一看比直接用Array.map()要麻烦一点,可是underscore的map()还可以作用于Object:

1
_.map({ a: 1, b: 2, c: 3 }, (v, k) => k + '=' + v); // ['a=1', 'b=2', 'c=3']

后面我们会详细介绍underscore提供了一系列函数式接口。



underscore为集合类对象提供了一致的接口。集合类是指Array和Object,暂不支持Map和Set。

map/filter

Arraymap()filter()类似,但是underscore的map()filter()可以作用于Object。当作用于Object时,传入的函数为function (value, key),第一个参数接收value,第二个参数接收key:

1
2
3
4
5
6
7
8
9
10
11
let obj = {
name: 'bob',
school: 'No.1 middle school',
address: 'xueyuan road'
};

let upper = _.map(obj, function (value, key) {
return ???;
});

console.log(JSON.stringify(upper));

你也许会想,为啥对Object作map()操作的返回结果是Array?应该是Object才合理啊!把_.map换成_.mapObject再试试。

every / some

当集合的所有元素都满足条件时,_.every()函数返回true,当集合的至少一个元素满足条件时,_.some()函数返回true

1
2
3
4
// 所有元素都大于0?
_.every([1, 4, 7, -3, -9], (x) => x > 0); // false
// 至少一个元素大于0?
_.some([1, 4, 7, -3, -9], (x) => x > 0); // true

当集合是Object时,我们可以同时获得value和key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = {
name: 'bob',
school: 'No.1 middle school',
address: 'xueyuan road'
};

// 判断key和value是否全部是小写:
let r1 = _.every(obj, function (value, key) {
return ???;
});
let r2 = _.some(obj, function (value, key) {
return ???;
});

console.log('every key-value are lowercase: ' + r1 + '\nsome key-value are lowercase: ' + r2);

max / min

这两个函数直接返回集合中最大和最小的数:

1
2
3
4
5
6
7
8
9
let arr = [3, 5, 7, 9];
_.max(arr); // 9
_.min(arr); // 3

// 空集合会返回-Infinity和Infinity,所以要先判断集合不为空:
_.max([])
-Infinity
_.min([])
Infinity

注意,如果集合是Object,max()min()只作用于value,忽略掉key:

1
_.max({ a: 1, b: 2, c: 3 }); // 3

groupBy

groupBy()把集合的元素按照key归类,key由传入的函数返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let scores = [20, 81, 75, 40, 91, 59, 77, 66, 72, 88, 99];
let groups = _.groupBy(scores, function (x) {
if (x < 60) {
return 'C';
} else if (x < 80) {
return 'B';
} else {
return 'A';
}
});
// 结果:
// {
// A: [81, 91, 88, 99],
// B: [75, 77, 66, 72],
// C: [20, 40, 59]
// }

可见groupBy()用来分组是非常方便的。

shuffle / sample

shuffle()用洗牌算法随机打乱一个集合:

1
2
// 注意每次结果都不一样:
_.shuffle([1, 2, 3, 4, 5, 6]); // [3, 5, 4, 6, 2, 1]

sample()则是随机选择一个或多个元素:

1
2
3
4
5
// 注意每次结果都不一样:
// 随机选1个:
_.sample([1, 2, 3, 4, 5, 6]); // 2
// 随机选3个:
_.sample([1, 2, 3, 4, 5, 6], 3); // [6, 1, 4]

更多完整的函数请参考underscore的文档:https://underscorejs.org/#collections

underscore为Array提供了许多工具类方法,可以更方便快捷地操作Array

first / last

顾名思义,这两个函数分别取第一个和最后一个元素:

1
2
3
let arr = [2, 4, 6, 8];
_.first(arr); // 2
_.last(arr); // 8

flatten

flatten()接收一个Array,无论这个Array里面嵌套了多少个Arrayflatten()最后都把它们变成一个一维数组:

1
_.flatten([1, [2], [3, [[4], [5]]]]); // [1, 2, 3, 4, 5]

zip / unzip

zip()把两个或多个数组的所有元素按索引对齐,然后按索引合并成新数组。例如,你有一个Array保存了名字,另一个Array保存了分数,现在,要把名字和分数给对上,用zip()轻松实现:

1
2
3
4
let names = ['Adam', 'Lisa', 'Bart'];
let scores = [85, 92, 59];
_.zip(names, scores);
// [['Adam', 85], ['Lisa', 92], ['Bart', 59]]

unzip()则是反过来:

1
2
3
let namesAndScores = [['Adam', 85], ['Lisa', 92], ['Bart', 59]];
_.unzip(namesAndScores);
// [['Adam', 'Lisa', 'Bart'], [85, 92, 59]]

object

有时候你会想,与其用zip(),为啥不把名字和分数直接对应成Object呢?别急,object()函数就是干这个的:

1
2
3
4
let names = ['Adam', 'Lisa', 'Bart'];
let scores = [85, 92, 59];
_.object(names, scores);
// {Adam: 85, Lisa: 92, Bart: 59}

注意_.object()是一个函数,不是JavaScript的Object对象。

range

range()让你快速生成一个序列,不再需要用for循环实现了:

1
2
3
4
5
6
7
8
9
10
11
// 从0开始小于10:
_.range(10); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

// 从1开始小于11:
_.range(1, 11); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 从0开始小于30,步长5:
_.range(0, 30, 5); // [0, 5, 10, 15, 20, 25]

// 从0开始大于-10,步长-1:
_.range(0, -10, -1); // [0, -1, -2, -3, -4, -5, -6, -7, -8, -9]

更多完整的函数请参考underscore的文档:https://underscorejs.org/#arrays

练习

请根据underscore官方文档,使用_.uniq对数组元素进行不区分大小写去重:

1
2
3
4
5
6
7
8
9
let arr = ['Apple', 'orange', 'banana', 'ORANGE', 'apple', 'PEAR'];
let result = ???

// 测试
if (result.toString() === ["Apple", "orange", "banana", "PEAR"].toString()) {
console.log('测试成功!');
} else {
console.log('测试失败!');
}

因为underscore本来就是为了充分发挥JavaScript的函数式编程特性,所以也提供了大量JavaScript本身没有的高阶函数。

bind

bind()有什么用?我们先看一个常见的错误用法:

1
2
3
4
5
6
7
let s = ' Hello  ';
s.trim();
// 输出'Hello'

let fn = s.trim;
fn();
// Uncaught TypeError: String.prototype.trim called on null or undefined

如果你想用fn()取代s.trim(),按照上面的做法是不行的,因为直接调用fn()传入的this指针是undefined,必须这么用:

1
2
3
4
5
let s = ' Hello  ';
let fn = s.trim;
// 调用call并传入s对象作为this:
fn.call(s)
// 输出Hello

这样搞多麻烦!还不如直接用s.trim()。但是,bind()可以帮我们把s对象直接绑定在fn()this指针上,以后调用fn()就可以直接正常调用了:

1
2
3
4
let s = ' Hello  ';
let fn = _.bind(s.trim, s);
fn();
// 输出Hello

结论:当用一个变量fn指向一个对象的方法时,直接调用fn()是不行的,因为丢失了this对象的引用。用bind可以修复这个问题。

partial

partial()就是为一个函数创建偏函数。偏函数是什么东东?看例子:

假设我们要计算xy,这时只需要调用Math.pow(x, y)就可以了。

假设我们经常计算2y,每次都写Math.pow(2, y)就比较麻烦,如果创建一个新的函数能直接这样写pow2N(y)就好了,这个新函数pow2N(y)就是根据Math.pow(x, y)创建出来的偏函数,它固定住了原函数的第一个参数(始终为2):

1
2
3
4
let pow2N = _.partial(Math.pow, 2);
pow2N(3); // 8
pow2N(5); // 32
pow2N(10); // 1024

如果我们不想固定第一个参数,想固定第二个参数怎么办?比如,希望创建一个偏函数cube(x),计算x3,可以用_作占位符,固定住第二个参数:

1
2
3
4
let cube = _.partial(Math.pow, _, 3);
cube(3); // 27
cube(5); // 125
cube(10); // 1000

可见,创建偏函数的目的是将原函数的某些参数固定住,可以降低新函数调用的难度。

memoize

如果一个函数调用开销很大,我们就可能希望能把结果缓存下来,以便后续调用时直接获得结果。举个例子,计算阶乘就比较耗时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function factorial(n) {
console.log('start calculate ' + n + '!...');
let s = 1, i = n;
while (i > 1) {
s = s * i;
i --;
}
console.log(n + '! = ' + s);
return s;
}

factorial(10); // 3628800
// 注意控制台输出:
// start calculate 10!...
// 10! = 3628800

memoize()就可以自动缓存函数计算的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let factorial = _.memoize(function(n) {
console.log('start calculate ' + n + '!...');
let s = 1, i = n;
while (i > 1) {
s = s * i;
i --;
}
console.log(n + '! = ' + s);
return s;
});

// 第一次调用:
factorial(10); // 3628800
// 注意控制台输出:
// start calculate 10!...
// 10! = 3628800

// 第二次调用:
factorial(10); // 3628800
// 控制台没有输出

对于相同的调用,比如连续两次调用factorial(10),第二次调用并没有计算,而是直接返回上次计算后缓存的结果。不过,当你计算factorial(9)的时候,仍然会重新计算。

可以对factorial()进行改进,让其递归调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let factorial = _.memoize(function(n) {
console.log('start calculate ' + n + '!...');
if (n < 2) {
return 1;
}
return n * factorial(n - 1);
});

factorial(10); // 3628800
// 输出结果说明factorial(1)~factorial(10)都已经缓存了:
// start calculate 10!...
// start calculate 9!...
// start calculate 8!...
// start calculate 7!...
// start calculate 6!...
// start calculate 5!...
// start calculate 4!...
// start calculate 3!...
// start calculate 2!...
// start calculate 1!...

factorial(9); // 362880
// console无输出

once

顾名思义,once()保证某个函数执行且仅执行一次。如果你有一个方法叫register(),用户在页面上点两个按钮的任何一个都可以执行的话,就可以用once()保证函数仅调用一次,无论用户点击多少次:

1
2
3
4
5
6
7
8
let register = _.once(function () {
console.log('Register ok!');
});

// 测试效果:
register();
register();
register();

delay

delay()可以让一个函数延迟执行,效果和setTimeout()是一样的,但是代码明显简单了:

1
2
// 2秒后调用alert():
_.delay(alert, 2000);

如果要延迟调用的函数有参数,把参数也传进去:

1
2
3
let log = _.bind(console.log, console);
_.delay(log, 2000, 'Hello,', 'world!');
// 2秒后打印'Hello, world!':

更多完整的函数请参考underscore的文档:https://underscorejs.org/#functions

Array类似,underscore也提供了大量针对Object的函数。

keys / allKeys

keys()可以非常方便地返回一个object自身所有的key,但不包含从原型链继承下来的:

1
2
3
4
5
6
7
function Student(name, age) {
this.name = name;
this.age = age;
}

let xiaoming = new Student('小明', 20);
_.keys(xiaoming); // ['name', 'age']

allKeys()除了object自身的key,还包含从原型链继承下来的:

1
2
3
4
5
6
7
function Student(name, age) {
this.name = name;
this.age = age;
}
Student.prototype.school = 'No.1 Middle School';
let xiaoming = new Student('小明', 20);
_.allKeys(xiaoming); // ['name', 'age', 'school']

values

keys()类似,values()返回object自身但不包含原型链继承的所有值:

1
2
3
4
5
6
let obj = {
name: '小明',
age: 20
};

_.values(obj); // ['小明', 20]

注意,没有allValues(),原因我也不知道。

mapObject

mapObject()就是针对object的map版本:

1
2
3
let obj = { a: 1, b: 2, c: 3 };
// 注意传入的函数签名,value在前,key在后:
_.mapObject(obj, (v, k) => 100 + v); // { a: 101, b: 102, c: 103 }

invert

invert()把object的每个key-value来个交换,key变成value,value变成key:

1
2
3
4
5
6
let obj = {
Adam: 90,
Lisa: 85,
Bart: 59
};
_.invert(obj); // { '59': 'Bart', '85': 'Lisa', '90': 'Adam' }

extend / extendOwn

extend()把多个object的key-value合并到第一个object并返回:

1
2
3
4
let a = {name: 'Bob', age: 20};
_.extend(a, {age: 15}, {age: 88, city: 'Beijing'}); // {name: 'Bob', age: 88, city: 'Beijing'}
// 变量a的内容也改变了:
a; // {name: 'Bob', age: 88, city: 'Beijing'}

注意:如果有相同的key,后面的object的value将覆盖前面的object的value。

extendOwn()extend()类似,但获取属性时忽略从原型链继承下来的属性。

clone

如果我们要复制一个object对象,就可以用clone()方法,它会把原有对象的所有属性都复制到新的对象中:

1
2
3
4
5
6
7
8
let source = {
name: '小明',
age: 20,
skills: ['JavaScript', 'CSS', 'HTML']
};

let copied = _.clone(source);
console.log(JSON.stringify(copied, null, ' '));

注意,clone()是“浅复制”。所谓“浅复制”就是说,两个对象相同的key所引用的value其实是同一对象:

1
source.skills === copied.skills; // true

也就是说,修改source.skills会影响copied.skills

isEqual

isEqual()对两个object进行深度比较,如果内容完全相同,则返回true

1
2
3
4
5
let o1 = { name: 'Bob', skills: { Java: 90, JavaScript: 99 }};
let o2 = { name: 'Bob', skills: { JavaScript: 99, Java: 90 }};

o1 === o2; // false
_.isEqual(o1, o2); // true

isEqual()其实对Array也可以比较:

1
2
3
4
5
let o1 = ['Bob', { skills: ['Java', 'JavaScript'] }];
let o2 = ['Bob', { skills: ['Java', 'JavaScript'] }];

o1 === o2; // false
_.isEqual(o1, o2); // true

更多完整的函数请参考underscore的文档:https://underscorejs.org/#objects

Chaining

还记得jQuery支持链式调用吗?

1
2
3
$('a').attr('target', '_blank')
.append(' <i class="uk-icon-external-link"></i>')
.click(function () {});

如果我们有一组操作,用underscore提供的函数,写出来像这样:

1
2
_.filter(_.map([1, 4, 9, 16, 25], Math.sqrt), x => x % 2 === 1);
// [1, 3, 5]

能不能写成链式调用?

能!

underscore提供了把对象包装成能进行链式调用的方法,就是chain()函数:

1
2
3
4
5
let r = _.chain([1, 4, 9, 16, 25])
.map(Math.sqrt)
.filter(x => x % 2 === 1)
.value();
console.log(r); // [1, 3, 5]

因为每一步返回的都是包装对象,所以最后一步的结果需要调用value()获得最终结果。

小结

通过学习underscore,是不是对JavaScript的函数式编程又有了进一步的认识?



留言與分享

JavaScript-jQuery

分類 编程语言, JavaScript

jQuery

你可能听说过jQuery,它名字起得很土,但却是JavaScript世界中使用最广泛的一个库。

江湖传言,全世界大约有80~90%的网站直接或间接地使用了jQuery。鉴于它如此流行,又如此好用,所以每一个入门JavaScript的前端工程师都应该了解和学习它。

jQuery这么流行,肯定是因为它解决了一些很重要的问题。实际上,jQuery能帮我们干这些事情:

  • 消除浏览器差异:你不需要自己写冗长的代码来针对不同的浏览器来绑定事件,编写AJAX等代码;
  • 简洁的操作DOM的方法:写$('#test')肯定比document.getElementById('test')来得简洁;
  • 轻松实现动画、修改CSS等各种操作。

jQuery的理念“Write Less, Do More“,让你写更少的代码,完成更多的工作!

jQuery版本

jQuery官网可以下载最新版本。jQuery只是一个jquery-xxx.js文件,但你会看到有compressed(已压缩)和uncompressed(未压缩)两种版本,使用时完全一样,但如果你想深入研究jQuery源码,那就用uncompressed版本。

使用jQuery

使用jQuery只需要在页面的<head>引入jQuery文件即可:

1
2
3
4
5
6
7
8
9
<html>
<head>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
...
</head>
<body>
...
</body>
</html>

好消息是,当你在学习这个教程时,已经引用了jQuery,所以你可以直接使用:

1
console.log('jQuery版本:' + $.fn.jquery);

$符号

$是著名的jQuery符号。实际上,jQuery把所有功能全部封装在一个全局变量jQuery中,而$也是一个合法的变量名,它是变量jQuery的别名:

1
2
3
4
window.jQuery; // jQuery(selector, context)
window.$; // jQuery(selector, context)
$ === jQuery; // true
typeof($); // 'function'

$本质上就是一个函数,但是函数也是对象,于是$除了可以直接调用外,也可以有很多其他属性。

注意,你看到的$函数名可能不是jQuery(selector, context),因为很多JavaScript压缩工具可以对函数名和参数改名,所以压缩过的jQuery源码$函数可能变成a(b,c)

绝大多数时候,我们都直接用$(因为写起来更简单嘛)。但是,如果$这个变量不幸地被占用了,而且还不能改,那我们就只能让jQuery$变量交出来,然后就只能使用jQuery这个变量:

1
2
3
4
$; // jQuery(selector, context)
jQuery.noConflict();
$; // undefined
jQuery; // jQuery(selector, context)

这种黑魔法的原理是jQuery在占用$之前,先在内部保存了原来的$,调用jQuery.noConflict()时会把原来保存的变量还原。



选择器是jQuery的核心。一个选择器写出来类似$('#dom-id')

为什么jQuery要发明选择器?回顾一下DOM操作中我们经常使用的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 按ID查找:
let a = document.getElementById('dom-id');

// 按tag查找:
let divs = document.getElementsByTagName('div');

// 查找<p class="red">:
let ps = document.getElementsByTagName('p');
// 过滤出class="red":
// TODO:

// 查找<table class="green">里面的所有<tr>:
let table = ...
for (let i=0; i<table.children; i++) {
// TODO: 过滤出<tr>
}

这些代码实在太繁琐了,并且,在层级关系中,例如,查找<table class="green">里面的所有<tr>,一层循环实际上是错的,因为<table>的标准写法是:

1
2
3
4
5
6
<table>
<tbody>
<tr>...</tr>
<tr>...</tr>
</tbody>
</table>

很多时候,需要递归查找所有子节点。

jQuery的选择器就是帮助我们快速定位到一个或多个DOM节点。

按ID查找

如果某个DOM节点有id属性,利用jQuery查找如下:

1
2
// 查找<div id="abc">:
let div = $('#abc');

注意#abc#开头。返回的对象是jQuery对象。

什么是jQuery对象?jQuery对象类似数组,它的每个元素都是一个引用了DOM节点的对象。

以上面的查找为例,如果idabc<div>存在,返回的jQuery对象如下:

1
[<div id="abc">...</div>]

如果idabc<div>不存在,返回的jQuery对象如下:

1
[]

总之jQuery的选择器不会返回undefined或者null,这样的好处是你不必在下一行判断if (div === undefined)

jQuery对象和DOM对象之间可以互相转化:

1
2
3
let div = $('#abc'); // jQuery对象
let divDom = div.get(0); // 假设存在div,获取第1个DOM元素
let another = $(divDom); // 重新把DOM包装为jQuery对象

通常情况下你不需要获取DOM对象,直接使用jQuery对象更加方便。如果你拿到了一个DOM对象,那可以简单地调用$(aDomObject)把它变成jQuery对象,这样就可以方便地使用jQuery的API了。

按tag查找

按tag查找只需要写上tag名称就可以了:

1
2
let ps = $('p'); // 返回所有<p>节点
ps.length; // 数一数页面有多少个<p>节点

按class查找

按class查找注意在class名称前加一个.

1
2
3
4
let a = $('.red'); // 所有节点包含`class="red"`都将返回
// 例如:
// <div class="red">...</div>
// <p class="green red">...</p>

通常很多节点有多个class,我们可以查找同时包含redgreen的节点:

1
2
3
4
let a = $('.red.green'); // 注意没有空格!
// 符合条件的节点:
// <div class="red green">...</div>
// <div class="blue green red">...</div>

按属性查找

一个DOM节点除了idclass外还可以有很多属性,很多时候按属性查找会非常方便,比如在一个表单中按属性来查找:

1
2
3
let email = $('[name=email]'); // 找出<??? name="email">
let passwordInput = $('[type=password]'); // 找出<??? type="password">
let a = $('[items="A B"]'); // 找出<??? items="A B">

当属性的值包含空格等特殊字符时,需要用双引号括起来。

按属性查找还可以使用前缀查找或者后缀查找:

1
2
3
4
let icons = $('[name^=icon]'); // 找出所有name属性值以icon开头的DOM
// 例如: name="icon-1", name="icon-2"
let names = $('[name$=with]'); // 找出所有name属性值以with结尾的DOM
// 例如: name="startswith", name="endswith"

这个方法尤其适合通过class属性查找,且不受class包含多个名称的影响:

1
2
let icons = $('[class^="icon-"]'); // 找出所有class包含至少一个以`icon-`开头的DOM
// 例如: class="icon-clock", class="abc icon-home"

组合查找

组合查找就是把上述简单选择器组合起来使用。如果我们查找$('[name=email]'),很可能把表单外的<div name="email">也找出来,但我们只希望查找<input>,就可以这么写:

1
let emailInput = $('input[name=email]'); // 不会找出<div name="email">

同样的,根据tag和class来组合查找也很常见:

1
let tr = $('tr.red'); // 找出<tr class="red ...">...</tr>

多项选择器

多项选择器就是把多个选择器用,组合起来一块选:

1
2
$('p,div'); // 把<p>和<div>都选出来
$('p.red,p.green'); // 把<p class="red">和<p class="green">都选出来

要注意的是,选出来的元素是按照它们在HTML中出现的顺序排列的,而且不会有重复元素。例如,<p class="red green">不会被上面的$('p.red,p.green')选择两次。

练习

使用jQuery选择器分别选出指定元素:

  • 仅选择JavaScript
  • 仅选择Erlang
  • 选择JavaScript和Erlang
  • 选择所有编程语言
  • 选择名字input
  • 选择邮件和名字input
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML结构 -->
<div id="test-jquery">
<p id="para-1" class="color-red">JavaScript</p>
<p id="para-2" class="color-green">Haskell</p>
<p class="color-red color-green">Erlang</p>
<p name="name" class="color-black">Python</p>
<form class="test-form" target="_blank" action="#0" onsubmit="return false;">
<legend>注册新用户</legend>
<fieldset>
<p><label>名字: <input name="name"></label></p>
<p><label>邮件: <input name="email"></label></p>
<p><label>口令: <input name="password" type="password"></label></p>
<p><button type="submit">注册</button></p>
</fieldset>
</form>
</div>

JavaScript

Haskell

Erlang

Python

运行查看结果:

1
2
3
4
5
6
7
8
9
10
let selected = null;

selected = ???;

// 高亮结果:
if (!(selected instanceof jQuery)) {
return console.log('不是有效的jQuery对象!');
}
$('#test-jquery').find('*').css('background-color', '');
selected.css('background-color', '#ff8000');

除了基本的选择器外,jQuery的层级选择器更加灵活,也更强大。

因为DOM的结构就是层级结构,所以我们经常要根据层级关系进行选择。

层级选择器(Descendant Selector)

如果两个DOM元素具有层级关系,就可以用$('ancestor descendant')来选择,层级之间用空格隔开。例如:

1
2
3
4
5
6
7
8
<!-- HTML结构 -->
<div class="testing">
<ul class="lang">
<li class="lang-javascript">JavaScript</li>
<li class="lang-python">Python</li>
<li class="lang-lua">Lua</li>
</ul>
</div>

要选出JavaScript,可以用层级选择器:

1
2
$('ul.lang li.lang-javascript'); // [<li class="lang-javascript">JavaScript</li>]
$('div.testing li.lang-javascript'); // [<li class="lang-javascript">JavaScript</li>]

因为<div><ul>都是<li>的祖先节点,所以上面两种方式都可以选出相应的<li>节点。

要选择所有的<li>节点,用:

1
$('ul.lang li');

这种层级选择器相比单个的选择器好处在于,它缩小了选择范围,因为首先要定位父节点,才能选择相应的子节点,这样避免了页面其他不相关的元素。

例如:

1
$('form[name=upload] input');

就把选择范围限定在name属性为upload的表单里。如果页面有很多表单,其他表单的<input>不会被选择。

多层选择也是允许的:

1
$('form.test p input'); // 在form表单选择被<p>包含的<input>

子选择器(Child Selector)

子选择器$('parent>child')类似层级选择器,但是限定了层级关系必须是父子关系,就是<child>节点必须是<parent>节点的直属子节点。还是以上面的例子:

1
2
$('ul.lang>li.lang-javascript'); // 可以选出[<li class="lang-javascript">JavaScript</li>]
$('div.testing>li.lang-javascript'); // [], 无法选出,因为<div>和<li>不构成父子关系

过滤器(Filter)

过滤器一般不单独使用,它通常附加在选择器上,帮助我们更精确地定位元素。观察过滤器的效果:

1
2
3
4
5
6
$('ul.lang li'); // 选出JavaScript、Python和Lua 3个节点
$('ul.lang li:first-child'); // 仅选出JavaScript
$('ul.lang li:last-child'); // 仅选出Lua
$('ul.lang li:nth-child(2)'); // 选出第N个元素,N从1开始
$('ul.lang li:nth-child(even)'); // 选出序号为偶数的元素
$('ul.lang li:nth-child(odd)'); // 选出序号为奇数的元素

表单相关

针对表单元素,jQuery还有一组特殊的选择器:

  • :input:可以选择<input><textarea><select><button>
  • :file:可以选择<input type="file">,和input[type=file]一样;
  • :checkbox:可以选择复选框,和input[type=checkbox]一样;
  • :radio:可以选择单选框,和input[type=radio]一样;
  • :focus:可以选择当前输入焦点的元素,例如把光标放到一个<input>上,用$('input:focus')就可以选出;
  • :checked:选择当前勾上的单选框和复选框,用这个选择器可以立刻获得用户选择的项目,如$('input[type=radio]:checked')
  • :enabled:可以选择可以正常输入的<input><select> 等,也就是没有灰掉的输入;
  • :disabled:和:enabled正好相反,选择那些不能输入的。

此外,jQuery还有很多有用的选择器,例如,选出可见的或隐藏的元素:

1
2
$('div:visible'); // 所有可见的div
$('div:hidden'); // 所有隐藏的div

练习

针对如下HTML结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- HTML结构 -->
<div class="test-selector">
<ul class="test-lang">
<li class="lang-javascript">JavaScript</li>
<li class="lang-python">Python</li>
<li class="lang-lua">Lua</li>
</ul>
<ol class="test-lang">
<li class="lang-swift">Swift</li>
<li class="lang-java">Java</li>
<li class="lang-c">C</li>
</ol>
</div>
  • JavaScript
  • Python
  • Lua
  1. Swift
  2. Java
  3. C

选出相应的内容并观察效果:

1
2
3
4
5
6
7
8
9
// 分别选择所有语言,所有动态语言,所有静态语言,JavaScript,Lua,C等:
let selected = ???

// 高亮结果:
if (!(selected instanceof jQuery)) {
return console.log('不是有效的jQuery对象!');
}
$('#test-jquery').find('*').css('background-color', '');
selected.css('background-color', '#ff8000');

通常情况下选择器可以直接定位到我们想要的元素,但是,当我们拿到一个jQuery对象后,还可以以这个对象为基准,进行查找和过滤。

最常见的查找是在某个节点的所有子节点中查找,使用find()方法,它本身又接收一个任意的选择器。例如如下的HTML结构:

1
2
3
4
5
6
7
8
<!-- HTML结构 -->
<ul class="lang">
<li class="js dy">JavaScript</li>
<li class="dy">Python</li>
<li id="swift">Swift</li>
<li class="dy">Scheme</li>
<li name="haskell">Haskell</li>
</ul>
  • JavaScript
  • Python
  • Swift
  • Scheme
  • Haskell

find()查找:

1
2
3
4
let ul = $('ul.lang'); // 获得<ul>
let dy = ul.find('.dy'); // 获得JavaScript, Python, Scheme
let swf = ul.find('#swift'); // 获得Swift
let hsk = ul.find('[name=haskell]'); // 获得Haskell

如果要从当前节点开始向上查找,使用parent()方法:

1
2
3
let swf = $('#swift'); // 获得Swift
let parent = swf.parent(); // 获得Swift的上层节点<ul>
let a = swf.parent('.red'); // 获得Swift的上层节点<ul>,同时传入过滤条件。如果ul不符合条件,返回空jQuery对象

对于位于同一层级的节点,可以通过next()prev()方法,例如:

当我们已经拿到Swift节点后:

1
2
3
4
5
6
7
let swift = $('#swift');

swift.next(); // Scheme
swift.next('[name=haskell]'); // 空的jQuery对象,因为Swift的下一个元素Scheme不符合条件[name=haskell]

swift.prev(); // Python
swift.prev('.dy'); // Python,因为Python同时符合过滤器条件.dy

过滤

和函数式编程的map、filter类似,jQuery对象也有类似的方法。

filter()方法可以过滤掉不符合选择器条件的节点:

1
2
let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell
let a = langs.filter('.dy'); // 拿到JavaScript, Python, Scheme

或者传入一个函数,要特别注意函数内部的this被绑定为DOM对象,不是jQuery对象:

1
2
3
4
let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell
langs.filter(function () {
return this.innerHTML.indexOf('S') === 0; // 返回S开头的节点
}); // 拿到Swift, Scheme

map()方法把一个jQuery对象包含的若干DOM节点转化为其他对象:

1
2
3
4
let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell
let arr = langs.map(function () {
return this.innerHTML;
}).get(); // 用get()拿到包含string的Array:['JavaScript', 'Python', 'Swift', 'Scheme', 'Haskell']

此外,一个jQuery对象如果包含了不止一个DOM节点,first()last()slice()方法可以返回一个新的jQuery对象,把不需要的DOM节点去掉:

1
2
3
4
let langs = $('ul.lang li'); // 拿到JavaScript, Python, Swift, Scheme和Haskell
let js = langs.first(); // JavaScript,相当于$('ul.lang li:first-child')
let haskell = langs.last(); // Haskell, 相当于$('ul.lang li:last-child')
let sub = langs.slice(2, 4); // Swift, Scheme, 参数和数组的slice()方法一致

练习

对于下面的表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
<form id="test-form" action="#0" onsubmit="return false;">
<p><label>Name: <input name="name"></label></p>
<p><label>Email: <input name="email"></label></p>
<p><label>Password: <input name="password" type="password"></label></p>
<p>Gender: <label><input name="gender" type="radio" value="m" checked> Male</label> <label><input name="gender" type="radio" value="f"> Female</label></p>
<p><label>City: <select name="city">
<option value="BJ" selected>Beijing</option>
<option value="SH">Shanghai</option>
<option value="CD">Chengdu</option>
<option value="XM">Xiamen</option>
</select></label></p>
<p><button type="submit">Submit</button></p>
</form>

输入值后,用jQuery获取表单的JSON字符串,key和value分别对应每个输入的name和相应的value,例如:{"name":"Michael","email":...}

1
2
3
4
5
6
7
8
9
let json = ???;

// 显示结果:
if (typeof(json) === 'string') {
console.log(json);
}
else {
console.log('json变量不是string!');
}

jQuery的选择器很强大,用起来又简单又灵活,但是搞了这么久,我拿到了jQuery对象,到底要干什么?

答案当然是操作对应的DOM节点啦!

回顾一下修改DOM的CSS、文本、设置HTML有多么麻烦,而且有的浏览器只有innerHTML,有的浏览器支持innerText,有了jQuery对象,不需要考虑浏览器差异了,全部统一操作!

修改Text和HTML

jQuery对象的text()html()方法分别获取节点的文本和原始HTML文本,例如,如下的HTML结构:

1
2
3
4
5
<!-- HTML结构 -->
<ul id="test-ul">
<li class="js">JavaScript</li>
<li name="book">Java &amp; JavaScript</li>
</ul>

分别获取文本和HTML:

1
2
$('#test-ul li[name=book]').text(); // 'Java & JavaScript'
$('#test-ul li[name=book]').html(); // 'Java &amp; JavaScript'

如何设置文本或HTML?jQuery的API设计非常巧妙:无参数调用text()是获取文本,传入参数就变成设置文本,HTML也是类似操作,自己动手试试:

  • JavaScript
  • Java & JavaScript
1
2
3
4
5
let j1 = $('#test-ul li.js');
let j2 = $('#test-ul li[name=book]');

j1.html('<span style="color: #c00">JavaScript</span>');
j2.text('JavaScript & ECMAScript');

一个jQuery对象可以包含0个或任意个DOM对象,它的方法实际上会作用在对应的每个DOM节点上。在上面的例子中试试:

1
$('#test-ul li').text('JS'); // 是不是两个节点都变成了JS?

所以jQuery对象的另一个好处是我们可以执行一个操作,作用在对应的一组DOM节点上。即使选择器没有返回任何DOM节点,调用jQuery对象的方法仍然不会报错:

1
2
// 如果不存在id为not-exist的节点:
$('#not-exist').text('Hello'); // 代码不报错,没有节点被设置为'Hello'

这意味着jQuery帮你免去了许多if语句。

修改CSS

jQuery对象有“批量操作”的特点,这用于修改CSS实在是太方便了。考虑下面的HTML结构:

1
2
3
4
5
6
7
8
<!-- HTML结构 -->
<ul id="test-css">
<li class="lang dy"><span>JavaScript</span></li>
<li class="lang"><span>Java</span></li>
<li class="lang dy"><span>Python</span></li>
<li class="lang"><span>Swift</span></li>
<li class="lang dy"><span>Scheme</span></li>
</ul>

要高亮显示动态语言,调用jQuery对象的css('name', 'value')方法,我们用一行语句实现:

  • JavaScript
  • Java
  • Python
  • Swift
  • Scheme
1
$('#test-css li.dy>span').css('background-color', '#ff0').css('color', '#c00');

注意,jQuery对象的所有方法都返回一个jQuery对象(可能是新的也可能是自身),这样我们可以进行链式调用,非常方便。

jQuery对象的css()方法可以这么用:

1
2
3
4
let div = $('#test-div');
div.css('color'); // '#000033', 获取CSS属性
div.css('color', '#336699'); // 设置CSS属性
div.css('color', ''); // 清除CSS属性

为了和JavaScript保持一致,CSS属性可以用'background-color''backgroundColor'两种格式。

css()方法将作用于DOM节点的style属性,具有最高优先级。如果要修改class属性,可以用jQuery提供的下列方法:

1
2
3
4
let div = $('#test-div');
div.hasClass('highlight'); // false, class是否包含highlight
div.addClass('highlight'); // 添加highlight这个class
div.removeClass('highlight'); // 删除highlight这个class

练习:分别用css()方法和addClass()方法高亮显示JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML结构 -->
<style>
.highlight {
color: #c00;
background-color: #ff0;
}
</style>

<div id="test-highlight-css">
<ul>
<li class="py"><span>Python</span></li>
<li class="js"><span>JavaScript</span></li>
<li class="sw"><span>Swift</span></li>
<li class="hk"><span>Haskell</span></li>
</ul>
</div>
  • Python
  • JavaScript
  • Swift
  • Haskell
1
2
let div = $('#test-highlight-css');
// TODO:

显示和隐藏DOM

要隐藏一个DOM,我们可以设置CSS的display属性为none,利用css()方法就可以实现。不过,要显示这个DOM就需要恢复原有的display属性,这就得先记下来原有的display属性到底是block还是inline还是别的值。

考虑到显示和隐藏DOM元素使用非常普遍,jQuery直接提供show()hide()方法,我们不用关心它是如何修改display属性的,总之它能正常工作:

1
2
3
let a = $('a[target=_blank]');
a.hide(); // 隐藏
a.show(); // 显示

注意,隐藏DOM节点并未改变DOM树的结构,它只影响DOM节点的显示。这和删除DOM节点是不同的。

获取DOM信息

利用jQuery对象的若干方法,我们直接可以获取DOM的高宽等信息,而无需针对不同浏览器编写特定代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 浏览器可视窗口大小:
$(window).width(); // 800
$(window).height(); // 600

// HTML文档大小:
$(document).width(); // 800
$(document).height(); // 3500

// 某个div的大小:
let div = $('#test-div');
div.width(); // 600
div.height(); // 300
div.width(400); // 设置CSS属性 width: 400px,是否生效要看CSS是否有效
div.height('200px'); // 设置CSS属性 height: 200px,是否生效要看CSS是否有效

attr()removeAttr()方法用于操作DOM节点的属性:

1
2
3
4
5
6
7
// <div id="test-div" name="Test" start="1">...</div>
let div = $('#test-div');
div.attr('data'); // undefined, 属性不存在
div.attr('name'); // 'Test'
div.attr('name', 'Hello'); // div的name属性变为'Hello'
div.removeAttr('name'); // 删除name属性
div.attr('name'); // undefined

prop()方法和attr()类似,但是HTML5规定有一种属性在DOM节点中可以没有值,只有出现与不出现两种,例如:

1
<input id="test-radio" type="radio" name="test" checked value="1">

等价于:

1
<input id="test-radio" type="radio" name="test" checked="checked" value="1">

attr()prop()对于属性checked处理有所不同:

1
2
3
let radio = $('#test-radio');
radio.attr('checked'); // 'checked'
radio.prop('checked'); // true

prop()返回值更合理一些。不过,用is()方法判断更好:

1
2
let radio = $('#test-radio');
radio.is(':checked'); // true

类似的属性还有selected,处理时最好用is(':selected')

操作表单

对于表单元素,jQuery对象统一提供val()方法获取和设置对应的value属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
<input id="test-input" name="email" value="test">
<select id="test-select" name="city">
<option value="BJ" selected>Beijing</option>
<option value="SH">Shanghai</option>
<option value="SZ">Shenzhen</option>
</select>
<textarea id="test-textarea">Hello</textarea>
*/
let
input = $('#test-input'),
select = $('#test-select'),
textarea = $('#test-textarea');

input.val(); // 'test'
input.val('abc@example.com'); // 文本框的内容已变为abc@example.com

select.val(); // 'BJ'
select.val('SH'); // 选择框已变为Shanghai

textarea.val(); // 'Hello'
textarea.val('Hi'); // 文本区域已更新为'Hi'

可见,一个val()就统一了各种输入框的取值和赋值的问题。

直接使用浏览器提供的API对DOM结构进行修改,不但代码复杂,而且要针对浏览器写不同的代码。

有了jQuery,我们就专注于操作jQuery对象本身,底层的DOM操作由jQuery完成就可以了,这样一来,修改DOM也大大简化了。

添加DOM

要添加新的DOM节点,除了通过jQuery的html()这种暴力方法外,还可以用append()方法,例如:

1
2
3
4
5
6
7
<div id="test-div">
<ul>
<li><span>JavaScript</span></li>
<li><span>Python</span></li>
<li><span>Swift</span></li>
</ul>
</div>

如何向列表新增一个语言?首先要拿到<ul>节点:

1
let ul = $('#test-div>ul');

然后,调用append()传入HTML片段:

1
ul.append('<li><span>Haskell</span></li>');

除了接受字符串,append()还可以传入原始的DOM对象,jQuery对象和函数对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建DOM对象:
let ps = document.createElement('li');
ps.innerHTML = '<span>Pascal</span>';
// 添加DOM对象:
ul.append(ps);

// 添加jQuery对象:
ul.append($('#scheme'));

// 添加函数对象:
ul.append(function (index, html) {
return '<li><span>Language - ' + index + '</span></li>';
});

传入函数时,要求返回一个字符串、DOM对象或者jQuery对象。因为jQuery的append()可能作用于一组DOM节点,只有传入函数才能针对每个DOM生成不同的子节点。

append()把DOM添加到最后,prepend()则把DOM添加到最前。

另外注意,如果要添加的DOM节点已经存在于HTML文档中,它会首先从文档移除,然后再添加,也就是说,用append(),你可以移动一个DOM节点。

如果要把新节点插入到指定位置,例如,JavaScript和Python之间,那么,可以先定位到JavaScript,然后用after()方法:

1
2
let js = $('#test-div>ul>li:first-child');
js.after('<li><span>Lua</span></li>');

也就是说,同级节点可以用after()或者before()方法。

删除节点

要删除DOM节点,拿到jQuery对象后直接调用remove()方法就可以了。如果jQuery对象包含若干DOM节点,实际上可以一次删除多个DOM节点:

1
2
let li = $('#test-div>ul>li');
li.remove(); // 所有<li>全被删除

练习

除了列出的3种语言外,请再添加Pascal、Lua和Ruby,然后按字母顺序排序节点:

1
2
3
4
5
6
7
8
<!-- HTML结构 -->
<div id="test-div">
<ul>
<li><span>JavaScript</span></li>
<li><span>Python</span></li>
<li><span>Swift</span></li>
</ul>
</div>
  • JavaScript
  • Python
  • Swift
1
2
3
4
5
6
7
8
9
10
11
12
13
// TODO:

// 测试:
(function () {
let s = $('#test-div>ul>li').map(function () {
return $(this).text();
}).get().join(',');
if (s === 'JavaScript,Lua,Pascal,Python,Ruby,Swift') {
console.log('测试通过!');
} else {
console.log('测试失败: ' + s);
}
})();

因为JavaScript在浏览器中以单线程模式运行,页面加载后,一旦页面上所有的JavaScript代码被执行完后,就只能依赖触发事件来执行JavaScript代码。

浏览器在接收到用户的鼠标或键盘输入后,会自动在对应的DOM节点上触发相应的事件。如果该节点已经绑定了对应的JavaScript处理函数,该函数就会自动调用。

由于不同的浏览器绑定事件的代码都不太一样,所以用jQuery来写代码,就屏蔽了不同浏览器的差异,我们总是编写相同的代码。

举个例子,假设要在用户点击了超链接时弹出提示框,我们用jQuery这样绑定一个click事件:

1
2
3
4
5
6
7
8
9
10
/* HTML:
*
* <a id="test-link" href="#0">点我试试</a>
*
*/
// 获取超链接的jQuery对象:
let a = $('#test-link');
a.on('click', function () {
alert('Hello!');
});

实测:点我试试

on方法用来绑定一个事件,我们需要传入事件名称和对应的处理函数。

另一种更简化的写法是直接调用click()方法:

1
2
3
a.click(function () {
alert('Hello!');
});

两者完全等价。我们通常用后面的写法。

jQuery能够绑定的事件主要包括:

鼠标事件

  • click: 鼠标单击时触发;
  • dblclick:鼠标双击时触发;
  • mouseenter:鼠标进入时触发;
  • mouseleave:鼠标移出时触发;
  • mousemove:鼠标在DOM内部移动时触发;
  • hover:鼠标进入和退出时触发两个函数,相当于mouseenter加上mouseleave。

键盘事件

键盘事件仅作用在当前焦点的DOM上,通常是<input><textarea>

  • keydown:键盘按下时触发;
  • keyup:键盘松开时触发;
  • keypress:按一次键后触发。

其他事件

  • focus:当DOM获得焦点时触发;
  • blur:当DOM失去焦点时触发;
  • change:当<input><select><textarea>的内容改变时触发;
  • submit:当<form>提交时触发;
  • ready:当页面被载入并且DOM树完成初始化后触发。

其中,ready仅作用于document对象。由于ready事件在DOM完成初始化后触发,且只触发一次,所以非常适合用来写其他的初始化代码。假设我们想给一个<form>表单绑定submit事件,下面的代码没有预期的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
<script>
// 代码有误:
$('#testForm').on('submit', function () {
alert('submit!');
});
</script>
</head>
<body>
<form id="testForm">
...
</form>
</body>

因为JavaScript在此执行的时候,<form>尚未载入浏览器,所以$('#testForm)返回[],并没有绑定事件到任何DOM上。

所以我们自己的初始化代码必须放到document对象的ready事件中,保证DOM已完成初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<html>
<head>
<script>
$(document).on('ready', function () {
$('#testForm).on('submit', function () {
alert('submit!');
});
});
</script>
</head>
<body>
<form id="testForm">
...
</form>
</body>

这样写就没有问题了。因为相关代码会在DOM树初始化后再执行。

由于ready事件使用非常普遍,所以可以这样简化:

1
2
3
4
5
6
$(document).ready(function () {
// on('submit', function)也可以简化:
$('#testForm).submit(function () {
alert('submit!');
});
});

甚至还可以再简化为:

1
2
3
$(function () {
// init...
});

上面的这种写法最为常见。如果你遇到$(function () {...})的形式,牢记这是document对象的ready事件处理函数。

完全可以反复绑定事件处理函数,它们会依次执行:

1
2
3
4
5
6
7
8
9
$(function () {
console.log('init A...');
});
$(function () {
console.log('init B...');
});
$(function () {
console.log('init C...');
});

事件参数

有些事件,如mousemovekeypress,我们需要获取鼠标位置和按键的值,否则监听这些事件就没什么意义了。所有事件都会传入Event对象作为参数,可以从Event对象上获取到更多的信息:

1
2
3
4
5
$(function () {
$('#testMouseMoveDiv').mousemove(function (e) {
$('#testMouseMoveSpan').text('pageX = ' + e.pageX + ', pageY = ' + e.pageY);
});
});

取消绑定

一个已被绑定的事件可以解除绑定,通过off('click', function)实现:

1
2
3
4
5
6
7
8
9
10
function hello() {
alert('hello!');
}

a.click(hello); // 绑定事件

// 10秒钟后解除绑定:
setTimeout(function () {
a.off('click', hello);
}, 10000);

需要特别注意的是,下面这种写法是无效的:

1
2
3
4
5
6
7
8
9
// 绑定事件:
a.click(function () {
alert('hello!');
});

// 解除绑定:
a.off('click', function () {
alert('hello!');
});

这是因为两个匿名函数虽然长得一模一样,但是它们是两个不同的函数对象,off('click', function () {...})无法移除已绑定的第一个匿名函数。

为了实现移除效果,可以使用off('click')一次性移除已绑定的click事件的所有处理函数。

同理,无参数调用off()一次性移除已绑定的所有类型的事件处理函数。

事件触发条件

一个需要注意的问题是,事件的触发总是由用户操作引发的。例如,我们监控文本框的内容改动:

1
2
3
4
let input = $('#test-input');
input.change(function () {
console.log('changed...');
});

当用户在文本框中输入时,就会触发change事件。但是,如果用JavaScript代码去改动文本框的值,将不会触发change事件:

1
2
let input = $('#test-input');
input.val('change it!'); // 无法触发change事件

有些时候,我们希望用代码触发change事件,可以直接调用无参数的change()方法来触发该事件:

1
2
3
let input = $('#test-input');
input.val('change it!');
input.change(); // 触发change事件

input.change()相当于input.trigger('change'),它是trigger()方法的简写。

为什么我们希望手动触发一个事件呢?如果不这么做,很多时候,我们就得写两份一模一样的代码。

练习

对如下的Form表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- HTML结构 -->
<form id="test-form" action="test">
<legend>请选择想要学习的编程语言:</legend>
<fieldset>
<p><label class="selectAll"><input type="checkbox"> <span class="selectAll">全选</span><span class="deselectAll">全不选</span></label> <a href="#0" class="invertSelect">反选</a></p>
<p><label><input type="checkbox" name="lang" value="javascript"> JavaScript</label></p>
<p><label><input type="checkbox" name="lang" value="python"> Python</label></p>
<p><label><input type="checkbox" name="lang" value="ruby"> Ruby</label></p>
<p><label><input type="checkbox" name="lang" value="haskell"> Haskell</label></p>
<p><label><input type="checkbox" name="lang" value="scheme"> Scheme</label></p>
<p><button type="submit">Submit</button></p>
</fieldset>
</form>

绑定合适的事件处理函数,实现以下逻辑:

当用户勾上“全选”时,自动选中所有语言,并把“全选”变成“全不选”;

当用户去掉“全不选”时,自动不选中所有语言;

当用户点击“反选”时,自动把所有语言状态反转(选中的变为未选,未选的变为选中);

当用户把所有语言都手动勾上时,“全选”被自动勾上,并变为“全不选”;

当用户手动去掉选中至少一种语言时,“全不选”自动被去掉选中,并变为“全选”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let
form = $('#test-form'),
langs = form.find('[name=lang]'),
selectAll = form.find('label.selectAll :checkbox'),
selectAllLabel = form.find('label.selectAll span.selectAll'),
deselectAllLabel = form.find('label.selectAll span.deselectAll'),
invertSelect = form.find('a.invertSelect');

// 重置初始化状态:
form.find('*').show().off();
form.find(':checkbox').prop('checked', false).off();
deselectAllLabel.hide();
// 拦截form提交事件:
form.off().submit(function (e) {
e.preventDefault();
alert(form.serialize());
});
// TODO:绑定事件

// 测试:
console.log('请测试功能是否正常。');

用JavaScript实现动画,原理非常简单:我们只需要以固定的时间间隔(例如,0.1秒),每次把DOM元素的CSS样式修改一点(例如,高宽各增加10%),看起来就像动画了。

但是要用JavaScript手动实现动画效果,需要编写非常复杂的代码。如果想要把动画效果用函数封装起来便于复用,那考虑的事情就更多了。

使用jQuery实现动画,代码已经简单得不能再简化了:只需要一行代码!

让我们先来看看jQuery内置的几种动画样式:

show / hide

直接以无参数形式调用show()hide(),会显示和隐藏DOM元素。但是,只要传递一个时间参数进去,就变成了动画:

1
2
let div = $('#test-show-hide');
div.hide(3000); // 在3秒钟内逐渐消失

时间以毫秒为单位,但也可以是'slow''fast'这些字符串:

1
2
let div = $('#test-show-hide');
div.show('slow'); // 在0.6秒钟内逐渐显示

toggle()方法则根据当前状态决定是show()还是hide()

效果实测:

slideUp / slideDown

你可能已经看出来了,show()hide()是从左上角逐渐展开或收缩的,而slideUp()slideDown()则是在垂直方向逐渐展开或收缩的。

slideUp()把一个可见的DOM元素收起来,效果跟拉上窗帘似的,slideDown()相反,而slideToggle()则根据元素是否可见来决定下一步动作:

1
2
let div = $('#test-slide');
div.slideUp(3000); // 在3秒钟内逐渐向上消失

效果实测:

fadeIn / fadeOut

fadeIn()fadeOut()的动画效果是淡入淡出,也就是通过不断设置DOM元素的opacity属性来实现,而fadeToggle()则根据元素是否可见来决定下一步动作:

1
2
let div = $('#test-fade');
div.fadeOut('slow'); // 在0.6秒内淡出

自定义动画

如果上述动画效果还不能满足你的要求,那就祭出最后大招:animate(),它可以实现任意动画效果,我们需要传入的参数就是DOM元素最终的CSS状态和时间,jQuery在时间段内不断调整CSS直到达到我们设定的值:

1
2
3
4
5
6
let div = $('#test-animate');
div.animate({
opacity: 0.25,
width: '256px',
height: '256px'
}, 3000); // 在3秒钟内CSS过渡到设定值

animate()还可以再传入一个函数,当动画结束时,该函数将被调用:

1
2
3
4
5
6
7
8
9
10
let div = $('#test-animate');
div.animate({
opacity: 0.25,
width: '256px',
height: '256px'
}, 3000, function () {
console.log('动画已结束');
// 恢复至初始状态:
$(this).css('opacity', '1.0').css('width', '128px').css('height', '128px');
});

实际上这个回调函数参数对于基本动画也是适用的。

有了animate(),你就可以实现各种自定义动画效果了:

串行动画

jQuery的动画效果还可以串行执行,通过delay()方法还可以实现暂停,这样,我们可以实现更复杂的动画效果,而代码却相当简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let div = $('#test-animates');
// 动画效果:slideDown - 暂停 - 放大 - 暂停 - 缩小
div.slideDown(2000)
.delay(1000)
.animate({
width: '256px',
height: '256px'
}, 2000)
.delay(1000)
.animate({
width: '128px',
height: '128px'
}, 2000);
}
</script>

因为动画需要执行一段时间,所以jQuery必须不断返回新的Promise对象才能后续执行操作。简单地把动画封装在函数中是不够的。

效果实测:

为什么有的动画没有效果

你可能会遇到,有的动画如slideUp()根本没有效果。这是因为jQuery动画的原理是逐渐改变CSS的值,如height100px逐渐变为0。但是很多不是block性质的DOM元素,对它们设置height根本就不起作用,所以动画也就没有效果。

此外,jQuery也没有实现对background-color的动画效果,用animate()设置background-color也没有效果。这种情况下可以使用CSS3的transition实现动画效果。

练习

在执行删除操作时,给用户显示一个动画比直接调用remove()要更好。请在表格删除一行的时候添加一个淡出的动画效果:

Name Email Address Status
Bart Simpson bart.s@primary.school Springfield Active
Michael Scofield m.scofield@escape.org Fox River Locked
Optimus Prime prime@cybertron.org Cybertron Active
Peter Parker spider@movie.org New York Active
Thor Odinson thor@asgard.org Asgard Active
1
2
3
4
5
6
7
function deleteFirstTR() {
let tr = $('#test-table>tbody>tr:visible').first();
// TODO:
tr.remove();
}

deleteFirstTR();

留言與分享

JavaScript-错误处理

分類 编程语言, JavaScript

在执行JavaScript代码的时候,有些情况下会发生错误。

错误分两种,一种是程序写的逻辑不对,导致代码执行异常。例如:

1
2
let s = null;
let len = s.length; // TypeError:null变量没有length属性

对于这种错误,要修复程序。

一种是执行过程中,程序可能遇到无法预测的异常情况而报错,例如,网络连接中断,读取不存在的文件,没有操作权限等。

对于这种错误,我们需要处理它,并可能需要给用户反馈。

错误处理是程序设计时必须要考虑的问题。对于C这样贴近系统底层的语言,错误是通过错误码返回的:

1
2
3
4
5
6
int fd = open("/path/to/file", O_RDONLY);
if (fd == -1) {
printf("Error when open file!");
} else {
// TODO
}

通过错误码返回错误,就需要约定什么是正确的返回值,什么是错误的返回值。上面的open()函数约定返回-1表示错误。

显然,这种用错误码表示错误在编写程序时十分不便。

因此,高级语言通常都提供了更抽象的错误处理逻辑try … catch … finally,JavaScript也不例外。

try … catch … finally

使用try … catch … finally处理错误时,我们编写的代码如下:

1
2
3
4
5
6
7
8
9
10
11
let r1, r2, s = null;
try {
r1 = s.length; // 此处应产生错误
r2 = 100; // 该语句不会执行
} catch (e) {
console.log('出错了:' + e);
} finally {
console.log('finally');
}
console.log('r1 = ' + r1); // r1应为undefined
console.log('r2 = ' + r2); // r2应为undefined

运行后可以发现,输出提示类似“出错了:TypeError: Cannot read property ‘length’ of null”。

我们来分析一下使用try … catch … finally的执行流程。

当代码块被try { ... }包裹的时候,就表示这部分代码执行过程中可能会发生错误,一旦发生错误,就不再继续执行后续代码,转而跳到catch块。catch (e) { ... }包裹的代码就是错误处理代码,变量e表示捕获到的错误。最后,无论有没有错误,finally一定会被执行。

所以,有错误发生时,执行流程像这样:

  1. 先执行try { ... }的代码;
  2. 执行到出错的语句时,后续语句不再继续执行,转而执行catch (e) { ... }代码;
  3. 最后执行finally { ... }代码。

而没有错误发生时,执行流程像这样:

  1. 先执行try { ... }的代码;
  2. 因为没有出错,catch (e) { ... }代码不会被执行;
  3. 最后执行finally { ... }代码。

最后请注意,catchfinally可以不必都出现。也就是说,try语句一共有三种形式:

完整的try … catch … finally:

1
2
3
4
5
6
7
try {
...
} catch (e) {
...
} finally {
...
}

只有try … catch,没有finally:

1
2
3
4
5
try {
...
} catch (e) {
...
}

只有try … finally,没有catch:

1
2
3
4
5
try {
...
} finally {
...
}

错误类型

JavaScript有一个标准的Error对象表示错误,还有从Error派生的TypeErrorReferenceError等错误对象。我们在处理错误时,可以通过catch(e)捕获的变量e访问错误对象:

1
2
3
4
5
6
7
8
9
10
11
try {
...
} catch (e) {
if (e instanceof TypeError) {
alert('Type error!');
} else if (e instanceof Error) {
alert(e.message);
} else {
alert('Error: ' + e);
}
}

使用变量e是一个习惯用法,也可以以其他变量名命名,如catch(ex)

抛出错误

程序也可以主动抛出一个错误,让执行流程直接跳转到catch块。抛出错误使用throw语句。

例如,下面的代码让用户输入一个数字,程序接收到的实际上是一个字符串,然后用parseInt()转换为整数。当用户输入不合法的时候,我们就抛出错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
let r, n, s;
try {
s = prompt('请输入一个数字');
n = parseInt(s);
if (isNaN(n)) {
throw new Error('输入错误');
}
// 计算平方:
r = n * n;
console.log(n + ' * ' + n + ' = ' + r);
} catch (e) {
console.log('出错了:' + e);
}

实际上,JavaScript允许抛出任意对象,包括数字、字符串。但是,最好还是抛出一个Error对象。

最后,当我们用catch捕获错误时,一定要编写错误处理语句:

1
2
3
4
5
6
7
let n = 0, s;
try {
n = s.length;
} catch (e) {
console.log(e);
}
console.log(n);

哪怕仅仅把错误打印出来,也不要什么也不干:

1
2
3
4
5
6
let n = 0, s;
try {
n = s.length;
} catch (e) {
}
console.log(n);

因为catch到错误却什么都不执行,就不知道程序执行过程中到底有没有发生错误。

处理错误时,请不要简单粗暴地用alert()把错误显示给用户。教程的代码使用alert()是为了便于演示。

错误传播

如果代码发生了错误,又没有被try … catch捕获,那么,程序执行流程会跳转到哪呢?

1
2
3
4
5
6
7
8
9
10
function getLength(s) {
return s.length;
}

function printLength() {
console.log(getLength('abc')); // 3
console.log(getLength(null)); // Error!
}

printLength();

如果在一个函数内部发生了错误,它自身没有捕获,错误就会被抛到外层调用函数,如果外层函数也没有捕获,该错误会一直沿着函数调用链向上抛出,直到被JavaScript引擎捕获,代码终止执行。

所以,我们不必在每一个函数内部捕获错误,只需要在合适的地方来个统一捕获,一网打尽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function main(s) {
console.log('BEGIN main()');
try {
foo(s);
} catch (e) {
console.log('出错了:' + e);
}
console.log('END main()');
}

function foo(s) {
console.log('BEGIN foo()');
bar(s);
console.log('END foo()');
}

function bar(s) {
console.log('BEGIN bar()');
console.log('length = ' + s.length);
console.log('END bar()');
}

main(null);

bar()函数传入参数null时,代码会报错,错误会向上抛给调用方foo()函数,foo()函数没有try … catch语句,所以错误继续向上抛给调用方main()函数,main()函数有try … catch语句,所以错误最终在main()函数被处理了。

至于在哪些地方捕获错误比较合适,需要视情况而定。



异步错误处理

编写JavaScript代码时,我们要时刻牢记,JavaScript引擎是一个事件驱动的执行引擎,代码总是以单线程执行,而回调函数的执行需要等到下一个满足条件的事件出现后,才会被执行。

例如,setTimeout()函数可以传入回调函数,并在指定若干毫秒后执行:

1
2
3
4
5
6
function printTime() {
console.log('It is time!');
}

setTimeout(printTime, 1000);
console.log('done');

上面的代码会先打印done,1秒后才会打印It is time!

如果printTime()函数内部发生了错误,我们试图用try包裹setTimeout()是无效的:

1
2
3
4
5
6
7
8
9
10
function printTime() {
throw new Error();
}

try {
setTimeout(printTime, 1000);
console.log('done');
} catch (e) {
console.log('error');
}

原因就在于调用setTimeout()函数时,传入的printTime函数并未立刻执行!紧接着,JavaScript引擎会继续执行console.log('done');语句,而此时并没有错误发生。直到1秒钟后,执行printTime函数时才发生错误,但此时除了在printTime函数内部捕获错误外,外层代码并无法捕获。

所以,涉及到异步代码,无法在调用时捕获,原因就是在捕获的当时,回调函数并未执行。

类似的,当我们处理一个事件时,在绑定事件的代码处,无法捕获事件处理函数的错误。

例如,针对以下的表单:

1
2
3
4
<form>
<input id="x"> + <input id="y">
<button id="calc" type="button">计算</button>
</form>

我们用下面的代码给button绑定click事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let btn = document.querySelector('#calc');

// 取消已绑定的事件:
btn.onclick = null;

try {
btn.onclick = function () {
let
x = parseFloat(document.querySelector('#x').value),
y = parseFloat(document.querySelector('#y').value),
r;
if (isNaN(x) || isNaN(y)) {
throw new Error('输入有误');
}
r = x + y;
alert('计算结果:' + r);
};
} catch (e) {
alert('输入有误!');
}

但是,用户输入错误时,处理函数并未捕获到错误。请修复错误处理代码。



留言與分享

JavaScript-浏览器

分類 编程语言, JavaScript

JavaScript可以获取浏览器提供的很多对象,并进行操作。

window

window对象不但充当全局作用域,而且表示浏览器窗口。

window对象有innerWidthinnerHeight属性,可以获取浏览器窗口的内部宽度和高度。内部宽高是指除去菜单栏、工具栏、边框等占位元素后,用于显示网页的净宽高。

兼容性:IE<=8不支持。

1
2
// 可以调整浏览器窗口大小试试:
console.log('window inner size: ' + window.innerWidth + ' x ' + window.innerHeight);

对应的,还有一个outerWidthouterHeight属性,可以获取浏览器窗口的整个宽高。

navigator对象表示浏览器的信息,最常用的属性包括:

  • navigator.appName:浏览器名称;
  • navigator.appVersion:浏览器版本;
  • navigator.language:浏览器设置的语言;
  • navigator.platform:操作系统类型;
  • navigator.userAgent:浏览器设定的User-Agent字符串。
1
2
3
4
5
console.log('appName = ' + navigator.appName);
console.log('appVersion = ' + navigator.appVersion);
console.log('language = ' + navigator.language);
console.log('platform = ' + navigator.platform);
console.log('userAgent = ' + navigator.userAgent);

请注意navigator的信息可以很容易地被用户修改,所以JavaScript读取的值不一定是正确的。很多初学者为了针对不同浏览器编写不同的代码,喜欢用if判断浏览器版本,例如:

1
2
3
4
5
6
let width;
if (getIEVersion(navigator.userAgent) < 9) {
width = document.body.clientWidth;
} else {
width = window.innerWidth;
}

但这样既可能判断不准确,也很难维护代码。正确的方法是充分利用JavaScript对不存在属性返回undefined的特性,直接用短路运算符||计算:

1
let width = window.innerWidth || document.body.clientWidth;

screen

screen对象表示屏幕的信息,常用的属性有:

  • screen.width:屏幕宽度,以像素为单位;
  • screen.height:屏幕高度,以像素为单位;
  • screen.colorDepth:返回颜色位数,如8、16、24。
1
console.log('Screen size = ' + screen.width + ' x ' + screen.height);

location

location对象表示当前页面的URL信息。例如,一个完整的URL:

1
http://www.example.com:8080/path/index.html?a=1&b=2#TOP

可以用location.href获取。要获得URL各个部分的值,可以这么写:

1
2
3
4
5
6
location.protocol; // 'http'
location.host; // 'www.example.com'
location.port; // '8080'
location.pathname; // '/path/index.html'
location.search; // '?a=1&b=2'
location.hash; // 'TOP'

要加载一个新页面,可以调用location.assign()。如果要重新加载当前页面,调用location.reload()方法非常方便。

1
2
3
4
5
if (confirm('重新加载当前页' + location.href + '?')) {
location.reload();
} else {
location.assign('/'); // 设置一个新的URL地址
}

document

document对象表示当前页面。由于HTML在浏览器中以DOM形式表示为树形结构,document对象就是整个DOM树的根节点。

documenttitle属性是从HTML文档中的<title>xxx</title>读取的,但是可以动态改变:

1
document.title = '努力学习JavaScript!';

请观察浏览器窗口标题的变化。

要查找DOM树的某个节点,需要从document对象开始查找。最常用的查找是根据ID和Tag Name。

我们先准备HTML数据:

1
2
3
4
5
6
7
8
<dl id="drink-menu" style="border:solid 1px #ccc;padding:6px;">
<dt>摩卡</dt>
<dd>热摩卡咖啡</dd>
<dt>酸奶</dt>
<dd>北京老酸奶</dd>
<dt>果汁</dt>
<dd>鲜榨苹果汁</dd>
</dl>

摩卡

热摩卡咖啡

酸奶

北京老酸奶

果汁

鲜榨苹果汁

document对象提供的getElementById()getElementsByTagName()可以按ID获得一个DOM节点和按Tag名称获得一组DOM节点:

1
2
3
4
5
6
7
8
let menu = document.getElementById('drink-menu');
let drinks = document.getElementsByTagName('dt');
let s = '提供的饮料有:';

for (let i=0; i<drinks.length; i++) {
s = s + drinks[i].innerHTML + ',';
}
console.log(s);

document对象还有一个cookie属性,可以获取当前页面的Cookie。

Cookie是由服务器发送的key-value标示符。因为HTTP协议是无状态的,但是服务器要区分到底是哪个用户发过来的请求,就可以用Cookie来区分。当一个用户成功登录后,服务器发送一个Cookie给浏览器,例如user=ABC123XYZ(加密的字符串)...,此后,浏览器访问该网站时,会在请求头附上这个Cookie,服务器根据Cookie即可区分出用户。

Cookie还可以存储网站的一些设置,例如,页面显示的语言等等。

JavaScript可以通过document.cookie读取到当前页面的Cookie:

1
document.cookie; // 'v=123; remember=true; prefer=zh'

由于JavaScript能读取到页面的Cookie,而用户的登录信息通常也存在Cookie中,这就造成了巨大的安全隐患,这是因为在HTML页面中引入第三方的JavaScript代码是允许的:

1
2
3
4
5
6
7
<!-- 当前页面在wwwexample.com -->
<html>
<head>
<script src="http://www.foo.com/jquery.js"></script>
</head>
...
</html>

如果引入的第三方的JavaScript中存在恶意代码,则www.foo.com网站将直接获取到www.example.com网站的用户登录信息。

为了解决这个问题,服务器在设置Cookie时可以使用httpOnly,设定了httpOnly的Cookie将不能被JavaScript读取。这个行为由浏览器实现,主流浏览器均支持httpOnly选项,IE从IE6 SP1开始支持。

为了确保安全,服务器端在设置Cookie时,应该始终坚持使用httpOnly

history

history对象保存了浏览器的历史记录,JavaScript可以调用history对象的back()forward (),相当于用户点击了浏览器的“后退”或“前进”按钮。

这个对象属于历史遗留对象,对于现代Web页面来说,由于大量使用AJAX和页面交互,简单粗暴地调用history.back()可能会让用户感到非常愤怒。

新手开始设计Web页面时喜欢在登录页登录成功时调用history.back(),试图回到登录前的页面。这是一种错误的方法。

对使用AJAX动态加载的页面,如果希望页面更新时同时更新history对象,应当使用history.pushState()方法:

1
2
3
4
// when AJAX is done:
let state = 'any-data';
let newUrl = '/ajax.html#signin';
history.pushState(state, '', newUrl);

当用户点击“后退”时,浏览器并不会刷新页面,而是触发popstate事件,可由JavaScript捕获并更新相应的部分页面内容。

参考

常用浏览器对象参考:

操作DOM

由于HTML文档被浏览器解析后就是一棵DOM树,要改变HTML的结构,就需要通过JavaScript来操作DOM。

始终记住DOM是一个树形结构。操作一个DOM节点实际上就是这么几个操作:

  • 更新:更新该DOM节点的内容,相当于更新了该DOM节点表示的HTML的内容;
  • 遍历:遍历该DOM节点下的子节点,以便进行进一步操作;
  • 添加:在该DOM节点下新增一个子节点,相当于动态增加了一个HTML节点;
  • 删除:将该节点从HTML中删除,相当于删掉了该DOM节点的内容以及它包含的所有子节点。

在操作一个DOM节点前,我们需要通过各种方式先拿到这个DOM节点。最常用的方法是document.getElementById()document.getElementsByTagName(),以及CSS选择器document.getElementsByClassName()

由于ID在HTML文档中是唯一的,所以document.getElementById()可以直接定位唯一的一个DOM节点。document.getElementsByTagName()document.getElementsByClassName()总是返回一组DOM节点。要精确地选择DOM,可以先定位父节点,再从父节点开始选择,以缩小范围。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 返回ID为'test'的节点:
let test = document.getElementById('test');

// 先定位ID为'test-table'的节点,再返回其内部所有tr节点:
let trs = document.getElementById('test-table').getElementsByTagName('tr');

// 先定位ID为'test-div'的节点,再返回其内部所有class包含red的节点:
let reds = document.getElementById('test-div').getElementsByClassName('red');

// 获取节点test下的所有直属子节点:
let cs = test.children;

// 获取节点test下第一个、最后一个子节点:
let first = test.firstElementChild;
let last = test.lastElementChild;

第二种方法是使用querySelector()querySelectorAll(),需要了解selector语法,然后使用条件来获取节点,更加方便:

1
2
3
4
5
// 通过querySelector获取ID为q1的节点:
let q1 = document.querySelector('#q1');

// 通过querySelectorAll获取q1节点内的符合条件的所有节点:
let ps = q1.querySelectorAll('div.highlighted > p');

注意:低版本的IE<8不支持querySelectorquerySelectorAll。IE8仅有限支持。

严格地讲,我们这里的DOM节点是指Element,但是DOM节点实际上是Node,在HTML中,Node包括ElementCommentCDATA_SECTION等很多种,以及根节点Document类型,但是,绝大多数时候我们只关心Element,也就是实际控制页面结构的Node,其他类型的Node忽略即可。根节点Document已经自动绑定为全局变量document

练习

如下的HTML结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML结构 -->
<div id="test-div">
<div class="c-red">
<p id="test-p">JavaScript</p>
<p>Java</p>
</div>
<div class="c-red c-green">
<p>Python</p>
<p>Ruby</p>
<p>Swift</p>
</div>
<div class="c-green">
<p>Scheme</p>
<p>Haskell</p>
</div>
</div>

请选择出指定条件的节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 选择<p>JavaScript</p>:
let js = ???;

// 选择<p>Python</p>,<p>Ruby</p>,<p>Swift</p>:
let arr = ???;

// 选择<p>Haskell</p>:
let haskell = ???;

// 测试:
if (!js || js.innerText !== 'JavaScript') {
alert('选择JavaScript失败!');
} else if (!arr || arr.length !== 3 || !arr[0] || !arr[1] || !arr[2] || arr[0].innerText !== 'Python' || arr[1].innerText !== 'Ruby' || arr[2].innerText !== 'Swift') {
console.log('选择Python,Ruby,Swift失败!');
} else if (!haskell || haskell.innerText !== 'Haskell') {
console.log('选择Haskell失败!');
} else {
console.log('测试通过!');
}


更新DOM

拿到一个DOM节点后,我们可以对它进行更新。

可以直接修改节点的文本,方法有两种:

一种是修改innerHTML属性,这个方式非常强大,不但可以修改一个DOM节点的文本内容,还可以直接通过HTML片段修改DOM节点内部的子树:

1
2
3
4
5
6
7
// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置文本为abc:
p.innerHTML = 'ABC'; // <p id="p-id">ABC</p>
// 设置HTML:
p.innerHTML = 'ABC <span style="color:red">RED</span> XYZ';
// <p>...</p>的内部结构已修改

innerHTML时要注意,是否需要写入HTML。如果写入的字符串是通过网络拿到的,要注意对字符编码来避免XSS攻击。

第二种是修改innerTexttextContent属性,这样可以自动对字符串进行HTML编码,保证无法设置任何HTML标签:

1
2
3
4
5
6
// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置文本:
p.innerText = '<script>alert("Hi")</script>';
// HTML被自动编码,无法设置一个<script>节点:
// <p id="p-id">&lt;script&gt;alert("Hi")&lt;/script&gt;</p>

两者的区别在于读取属性时,innerText不返回隐藏元素的文本,而textContent返回所有文本。另外注意IE<9不支持textContent

修改CSS也是经常需要的操作。DOM节点的style属性对应所有的CSS,可以直接获取或设置。因为CSS允许font-size这样的名称,但它并非JavaScript有效的属性名,所以需要在JavaScript中改写为驼峰式命名fontSize

1
2
3
4
5
6
// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置CSS:
p.style.color = '#ff0000';
p.style.fontSize = '20px';
p.style.paddingTop = '2em';

练习

有如下的HTML结构:

1
2
3
4
5
<!-- HTML结构 -->
<div id="test-div">
<p id="test-js">javascript</p>
<p>Java</p>
</div>

请尝试获取指定节点并修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 获取<p>javascript</p>节点:
let js = ???;

// 修改文本为JavaScript:
// TODO:

// 修改CSS为: color: #ff0000, font-weight: bold
// TODO:

// 测试:
if (js && js.parentNode && js.parentNode.id === 'test-div' && js.id === 'test-js') {
if (js.innerText === 'JavaScript') {
if (js.style && js.style.fontWeight === 'bold' && (js.style.color === 'red' || js.style.color === '#ff0000' || js.style.color === '#f00' || js.style.color === 'rgb(255, 0, 0)')) {
console.log('测试通过!');
} else {
console.log('CSS样式测试失败!');
}
} else {
console.log('文本测试失败!');
}
} else {
console.log('节点测试失败!');
}


当我们获得了某个DOM节点,想在这个DOM节点内插入新的DOM,应该如何做?

如果这个DOM节点是空的,例如,<div></div>,那么,直接使用innerHTML = '<span>child</span>'就可以修改DOM节点的内容,相当于“插入”了新的DOM节点。

如果这个DOM节点不是空的,那就不能这么做,因为innerHTML会直接替换掉原来的所有子节点。

有两个办法可以插入新的节点。一个是使用appendChild,把一个子节点添加到父节点的最后一个子节点。例如:

1
2
3
4
5
6
7
<!-- HTML结构 -->
<p id="js">JavaScript</p>
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
</div>

<p id="js">JavaScript</p>添加到<div id="list">的最后一项:

1
2
3
4
let
js = document.getElementById('js'),
list = document.getElementById('list');
list.appendChild(js);

现在,HTML结构变成了这样:

1
2
3
4
5
6
7
<!-- HTML结构 -->
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
<p id="js">JavaScript</p>
</div>

因为我们插入的js节点已经存在于当前的文档树,因此这个节点首先会从原先的位置删除,再插入到新的位置。

更多的时候我们会从零创建一个新的节点,然后插入到指定位置:

1
2
3
4
5
6
let
list = document.getElementById('list'),
haskell = document.createElement('p');
haskell.id = 'haskell';
haskell.innerText = 'Haskell';
list.appendChild(haskell);

这样我们就动态添加了一个新的节点:

1
2
3
4
5
6
7
<!-- HTML结构 -->
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
<p id="haskell">Haskell</p>
</div>

动态创建一个节点然后添加到DOM树中,可以实现很多功能。举个例子,下面的代码动态创建了一个<style>节点,然后把它添加到<head>节点的末尾,这样就动态地给文档添加了新的CSS定义:

1
2
3
4
let d = document.createElement('style');
d.setAttribute('type', 'text/css');
d.innerHTML = 'p { color: red }';
document.getElementsByTagName('head')[0].appendChild(d);

可以在Chrome的控制台执行上述代码,观察页面样式的变化。

insertBefore

如果我们要把子节点插入到指定的位置怎么办?可以使用parentElement.insertBefore(newElement, referenceElement);,子节点会插入到referenceElement之前。

还是以上面的HTML为例,假定我们要把Haskell插入到Python之前:

1
2
3
4
5
6
<!-- HTML结构 -->
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
</div>

可以这么写:

1
2
3
4
5
6
7
let
list = document.getElementById('list'),
ref = document.getElementById('python'),
haskell = document.createElement('p');
haskell.id = 'haskell';
haskell.innerText = 'Haskell';
list.insertBefore(haskell, ref);

新的HTML结构如下:

1
2
3
4
5
6
7
<!-- HTML结构 -->
<div id="list">
<p id="java">Java</p>
<p id="haskell">Haskell</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
</div>

可见,使用insertBefore重点是要拿到一个“参考子节点”的引用。很多时候,需要循环一个父节点的所有子节点,可以通过迭代children属性实现:

1
2
3
4
5
6
let
i, c,
list = document.getElementById('list');
for (i = 0; i < list.children.length; i++) {
c = list.children[i]; // 拿到第i个子节点
}

练习

对于一个已有的HTML结构:

1
2
3
4
5
6
7
8
<!-- HTML结构 -->
<ol id="test-list">
<li class="lang">Scheme</li>
<li class="lang">JavaScript</li>
<li class="lang">Python</li>
<li class="lang">Ruby</li>
<li class="lang">Haskell</li>
</ol>
  1. Scheme
  2. JavaScript
  3. Python
  4. Ruby
  5. Haskell

按字符串顺序重新排序DOM节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// sort list:
TODO

// 测试:
(function () {
let
arr, i,
t = document.getElementById('test-list');
if (t && t.children && t.children.length === 5) {
arr = [];
for (i=0; i<t.children.length; i++) {
arr.push(t.children[i].innerText);
}
if (arr.toString() === ['Haskell', 'JavaScript', 'Python', 'Ruby', 'Scheme'].toString()) {
console.log('测试通过!');
}
else {
console.log('测试失败: ' + arr.toString());
}
}
else {
console.log('测试失败!');
}
})();

删除DOM

删除一个DOM节点就比插入要容易得多。

要删除一个节点,首先要获得该节点本身以及它的父节点,然后,调用父节点的removeChild把自己删掉:

1
2
3
4
5
6
7
// 拿到待删除节点:
let self = document.getElementById('to-be-removed');
// 拿到父节点:
let parent = self.parentElement;
// 删除:
let removed = parent.removeChild(self);
removed === self; // true

注意到删除后的节点虽然不在文档树中了,但其实它还在内存中,可以随时再次被添加到别的位置。

当你遍历一个父节点的子节点并进行删除操作时,要注意,children属性是一个只读属性,并且它在子节点变化时会实时更新。

例如,对于如下HTML结构:

1
2
3
4
<div id="parent">
<p>First</p>
<p>Second</p>
</div>

当我们用如下代码删除子节点时:

1
2
3
let parent = document.getElementById('parent');
parent.removeChild(parent.children[0]);
parent.removeChild(parent.children[1]); // <-- 浏览器报错

浏览器报错:parent.children[1]不是一个有效的节点。原因就在于,当<p>First</p>节点被删除后,parent.children的节点数量已经从2变为了1,索引[1]已经不存在了。

因此,删除多个节点时,要注意children属性时刻都在变化。

练习

1
2
3
4
5
6
7
8
9
<!-- HTML结构 -->
<ul id="test-list">
<li>JavaScript</li>
<li>Swift</li>
<li>HTML</li>
<li>ANSI C</li>
<li>CSS</li>
<li>DirectX</li>
</ul>
  • JavaScript
  • Swift
  • HTML
  • ANSI C
  • CSS
  • DirectX

把与Web开发技术不相关的节点删掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// TODO

// 测试:
(function () {
let
arr, i,
t = document.getElementById('test-list');
if (t && t.children && t.children.length === 3) {
arr = [];
for (i = 0; i < t.children.length; i ++) {
arr.push(t.children[i].innerText);
}
if (arr.toString() === ['JavaScript', 'HTML', 'CSS'].toString()) {
console.log('测试通过!');
}
else {
console.log('测试失败: ' + arr.toString());
}
}
else {
console.log('测试失败!');
}
})();


用JavaScript操作表单和操作DOM是类似的,因为表单本身也是DOM树。

不过表单的输入框、下拉框等可以接收用户输入,所以用JavaScript来操作表单,可以获得用户输入的内容,或者对一个输入框设置新的内容。

HTML表单的输入控件主要有以下几种:

  • 文本框,对应的<input type="text">,用于输入文本;
  • 口令框,对应的<input type="password">,用于输入口令;
  • 单选框,对应的<input type="radio">,用于选择一项;
  • 复选框,对应的<input type="checkbox">,用于选择多项;
  • 下拉框,对应的<select>,用于选择一项;
  • 隐藏文本,对应的<input type="hidden">,用户不可见,但表单提交时会把隐藏文本发送到服务器。

获取值

如果我们获得了一个<input>节点的引用,就可以直接调用value获得对应的用户输入值:

1
2
3
// <input type="text" id="email">
let input = document.getElementById('email');
input.value; // '用户输入的值'

这种方式可以应用于textpasswordhidden以及select。但是,对于单选框和复选框,value属性返回的永远是HTML预设的值,而我们需要获得的实际是用户是否“勾上了”选项,所以应该用checked判断:

1
2
3
4
5
6
7
8
// <label><input type="radio" name="weekday" id="monday" value="1"> Monday</label>
// <label><input type="radio" name="weekday" id="tuesday" value="2"> Tuesday</label>
let mon = document.getElementById('monday');
let tue = document.getElementById('tuesday');
mon.value; // '1'
tue.value; // '2'
mon.checked; // true或者false
tue.checked; // true或者false

设置值

设置值和获取值类似,对于textpasswordhidden以及select,直接设置value就可以:

1
2
3
// <input type="text" id="email">
let input = document.getElementById('email');
input.value = 'test@example.com'; // 文本框的内容已更新

对于单选框和复选框,设置checkedtruefalse即可。

HTML5控件

HTML5新增了大量标准控件,常用的包括datedatetimedatetime-localcolor等,它们都使用<input>标签:

1
<input type="date" value="2021-12-02">
1
<input type="datetime-local" value="2021-12-02T20:21:12">
1
<input type="color" value="#ff0000">

不支持HTML5的浏览器无法识别新的控件,会把它们当做type="text"来显示。支持HTML5的浏览器将获得格式化的字符串。例如,type="date"类型的inputvalue将保证是一个有效的YYYY-MM-DD格式的日期,或者空字符串。

提交表单

最后,JavaScript可以以两种方式来处理表单的提交(AJAX方式在后面章节介绍)。

方式一是通过<form>元素的submit()方法提交一个表单,例如,响应一个<button>click事件,在JavaScript代码中提交表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- HTML -->
<form id="test-form">
<input type="text" name="test">
<button type="button" onclick="doSubmitForm()">Submit</button>
</form>

<script>
function doSubmitForm() {
let form = document.getElementById('test-form');
// 可以在此修改form的input...
// 提交form:
form.submit();
}
</script>

这种方式的缺点是扰乱了浏览器对form的正常提交。浏览器默认点击<button type="submit">时提交表单,或者用户在最后一个输入框按回车键。因此,第二种方式是响应<form>本身的onsubmit事件,在提交form时作修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- HTML -->
<form id="test-form" onsubmit="return checkForm()">
<input type="text" name="test">
<button type="submit">Submit</button>
</form>

<script>
function checkForm() {
let form = document.getElementById('test-form');
// 可以在此修改form的input...
// 继续下一步:
return true;
}
</script>

注意要return true来告诉浏览器继续提交,如果return false,浏览器将不会继续提交form,这种情况通常对应用户输入有误,提示用户错误信息后终止提交form。

在检查和修改<input>时,要充分利用<input type="hidden">来传递数据。

例如,很多登录表单希望用户输入用户名和口令,但是,安全考虑,提交表单时不传输明文口令,而是口令的MD5。普通JavaScript开发人员会直接修改<input>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML -->
<form id="login-form" method="post" onsubmit="return checkForm()">
<input type="text" id="username" name="username">
<input type="password" id="password" name="password">
<button type="submit">Submit</button>
</form>

<script>
function checkForm() {
let pwd = document.getElementById('password');
// 把用户输入的明文变为MD5:
pwd.value = toMD5(pwd.value);
// 继续下一步:
return true;
}
</script>

这个做法看上去没啥问题,但用户输入了口令提交时,口令框的显示会突然从几个*变成32个*(因为MD5有32个字符)。

要想不改变用户的输入,可以利用<input type="hidden">实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- HTML -->
<form id="login-form" method="post" onsubmit="return checkForm()">
<input type="text" id="username" name="username">
<input type="password" id="input-password">
<input type="hidden" id="md5-password" name="password">
<button type="submit">Submit</button>
</form>

<script>
function checkForm() {
let input_pwd = document.getElementById('input-password');
let md5_pwd = document.getElementById('md5-password');
// 把用户输入的明文变为MD5:
md5_pwd.value = toMD5(input_pwd.value);
// 继续下一步:
return true;
}
</script>

注意到idmd5-password<input>标记了name="password",而用户输入的idinput-password<input>没有name属性。没有name属性的<input>的数据不会被提交。

练习

利用JavaScript检查用户注册信息是否正确,在以下情况不满足时报错并阻止提交表单:

  • 用户名必须是3-10位英文字母或数字;
  • 口令必须是6-20位;
  • 两次输入口令必须一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML结构 -->
<form id="test-register" action="#" target="_blank" onsubmit="return checkRegisterForm()">
<p id="test-error" style="color:red"></p>
<p>
用户名: <input type="text" id="username" name="username">
</p>
<p>
口令: <input type="password" id="password" name="password">
</p>
<p>
重复口令: <input type="password" id="password-2">
</p>
<p>
<button type="submit">提交</button> <button type="reset">重置</button>
</p>
</form>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
window.checkRegisterForm = function () {
// TODO:
return false;
};

// 测试:
(function () {
window.testFormHandler = window.checkRegisterForm;
let form = document.getElementById('test-register');
if (form.dispatchEvent) {
let event = new Event('submit', {
bubbles: true,
cancelable: true
});
form.dispatchEvent(event);
} else {
form.fireEvent('onsubmit');
}
})();

在HTML表单中,可以上传文件的唯一控件就是<input type="file">

注意:当一个表单包含<input type="file">时,表单的enctype必须指定为multipart/form-datamethod必须指定为post,浏览器才能正确编码并以multipart/form-data格式发送表单的数据。

出于安全考虑,浏览器只允许用户点击<input type="file">来选择本地文件,用JavaScript对<input type="file">value赋值是没有任何效果的。当用户选择了上传某个文件后,JavaScript也无法获得该文件的真实路径:

通常,上传的文件都由后台服务器处理,JavaScript可以在提交表单时对文件扩展名做检查,以便防止用户上传无效格式的文件:

1
2
3
4
5
6
let f = document.getElementById('test-file-upload');
let filename = f.value; // 'C:\fakepath\test.png'
if (!filename || !(filename.endsWith('.jpg') || filename.endsWith('.png') || filename.endsWith('.gif'))) {
alert('Can only upload image file.');
return false;
}

File API

由于JavaScript对用户上传的文件操作非常有限,尤其是无法读取文件内容,使得很多需要操作文件的网页不得不用Flash这样的第三方插件来实现。

随着HTML5的普及,新增的File API允许JavaScript读取文件内容,获得更多的文件信息。

HTML5的File API提供了FileFileReader两个主要对象,可以获得文件信息并读取文件。

下面的例子演示了如何读取用户选取的图片文件,并在一个<div>中预览图像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let
fileInput = document.getElementById('test-image-file'),
info = document.getElementById('test-file-info'),
preview = document.getElementById('test-image-preview');
// 监听change事件:
fileInput.addEventListener('change', function () {
// 清除背景图片:
preview.style.backgroundImage = '';
// 检查文件是否选择:
if (!fileInput.value) {
info.innerHTML = '没有选择文件';
return;
}
// 获取File引用:
let file = fileInput.files[0];
// 获取File信息:
info.innerHTML = '文件: ' + file.name + '<br>' +
'大小: ' + file.size + '<br>' +
'修改: ' + file.lastModified;
if (file.type !== 'image/jpeg' && file.type !== 'image/png' && file.type !== 'image/gif') {
alert('不是有效的图片文件!');
return;
}
// 读取文件:
let reader = new FileReader();
reader.onload = function(e) {
let
data = e.target.result; // 'data:image/jpeg;base64,/9j/4AAQSk...(base64编码)...'
preview.style.backgroundImage = 'url(' + data + ')';
};
// 以DataURL的形式读取文件:
reader.readAsDataURL(file);
});

上面的代码演示了如何通过HTML5的File API读取文件内容。以DataURL的形式读取到的文件是一个字符串,类似于data:image/jpeg;base64,/9j/4AAQSk...(base64编码)...,常用于设置图像。如果需要服务器端处理,把字符串base64,后面的字符发送给服务器并用Base64解码就可以得到原始文件的二进制内容。

回调

上面的代码还演示了JavaScript的一个重要的特性就是单线程执行模式。在JavaScript中,浏览器的JavaScript执行引擎在执行JavaScript代码时,总是以单线程模式执行,也就是说,任何时候,JavaScript代码都不可能同时有多于1个线程在执行。

你可能会问,单线程模式执行的JavaScript,如何处理多任务?

在JavaScript中,执行多任务实际上都是异步调用,比如上面的代码:

1
reader.readAsDataURL(file);

就会发起一个异步操作来读取文件内容。因为是异步操作,所以我们在JavaScript代码中就不知道什么时候操作结束,因此需要先设置一个回调函数:

1
2
3
reader.onload = function(e) {
// 当文件读取完成后,自动调用此函数:
};

当文件读取完成后,JavaScript引擎将自动调用我们设置的回调函数。执行回调函数时,文件已经读取完毕,所以我们可以在回调函数内部安全地获得文件内容。

AJAX不是JavaScript的规范,它只是一个哥们“发明”的缩写:Asynchronous JavaScript and XML,意思就是用JavaScript执行异步网络请求。

如果仔细观察一个Form的提交,你就会发现,一旦用户点击“Submit”按钮,表单开始提交,浏览器就会刷新页面,然后在新页面里告诉你操作是成功了还是失败了。如果不幸由于网络太慢或者其他原因,就会得到一个404页面。

这就是Web的运作原理:一次HTTP请求对应一个页面。

如果要让用户留在当前页面中,同时发出新的HTTP请求,就必须用JavaScript发送这个新请求,接收到数据后,再用JavaScript更新页面,这样一来,用户就感觉自己仍然停留在当前页面,但是数据却可以不断地更新。

最早大规模使用AJAX的就是Gmail,Gmail的页面在首次加载后,剩下的所有数据都依赖于AJAX来更新。

用JavaScript写一个完整的AJAX代码并不复杂,但是需要注意:AJAX请求是异步执行的,也就是说,要通过回调函数获得响应。

在现代浏览器上写AJAX主要依靠XMLHttpRequest对象,如果不考虑早期浏览器的兼容性问题,现代浏览器还提供了原生支持的Fetch API,以Promise方式提供。使用Fetch API发送HTTP请求代码如下:

1
2
3
4
5
6
7
8
9
10
11
async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}

// 发送异步请求:
get('./content.html').then(data => {
let textarea = document.getElementById('fetch-response-text');
textarea.value = data;
});

使用Fetch API配合async写法,代码更加简单。

Fetch API的详细用法可以参考MDN文档

安全限制

上面代码的URL使用的是相对路径。如果你把它改为'https://www.sina.com.cn/',再运行,肯定报错。在Chrome的控制台里,还可以看到错误信息。

这是因为浏览器的同源策略导致的。默认情况下,JavaScript在发送AJAX请求时,URL的域名必须和当前页面完全一致。

完全一致的意思是,域名要相同(www.example.comexample.com不同),协议要相同(httphttps不同),端口号要相同(http默认是:80端口,它和:8080就不同)。有的浏览器口子松一点,允许端口不同,大多数浏览器都会严格遵守这个限制。

那是不是用JavaScript无法请求外域(就是其他网站)的URL了呢?方法还是有的,大概有这么几种:

一是通过Flash插件发送HTTP请求,这种方式可以绕过浏览器的安全限制,但必须安装Flash,并且跟Flash交互。不过Flash用起来麻烦,而且现在用得也越来越少了。

二是通过在同源域名下架设一个代理服务器来转发,JavaScript负责把请求发送到代理服务器:

1
'/proxy?url=https://www.sina.com.cn'

代理服务器再把结果返回,这样就遵守了浏览器的同源策略。这种方式麻烦之处在于需要服务器端额外做开发。

第三种方式称为JSONP,它有个限制,只能用GET请求,并且要求返回JavaScript。这种方式跨域实际上是利用了浏览器允许跨域引用JavaScript资源:

1
2
3
4
5
6
7
8
9
<html>
<head>
<script src="http://example.com/abc.js"></script>
...
</head>
<body>
...
</body>
</html>

JSONP通常以函数调用的形式返回,例如,返回JavaScript内容如下:

1
foo('data');

这样一来,我们如果在页面中先准备好foo()函数,然后给页面动态加一个<script>节点,相当于动态读取外域的JavaScript资源,最后就等着接收回调了。

CORS

如果浏览器支持HTML5,那么就可以一劳永逸地使用新的跨域策略:CORS了。

CORS全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。

了解CORS前,我们先搞明白概念:

Origin表示本域,也就是浏览器当前页面的域。当JavaScript向外域(如sina.com)发起请求后,浏览器收到响应后,首先检查Access-Control-Allow-Origin是否包含本域,如果是,则此次跨域请求成功,如果不是,则请求失败,JavaScript将无法获取到响应的任何数据。

用一个图来表示就是:

1
2
3
4
5
6
7
8
9
10
         GET /res/abc.data
Host: sina.com
┌──────┐ Origin: http://my.com ┌────────┐
│my.com│───────────────────────────────────────────▶│sina.com│
│ │◀───────────────────────────────────────────│ │
└──────┘ HTTP/1.1 200 OK └────────┘
Access-Control-Allow-Origin: http://my.com
Content-Type: text/xml

<xml data...>

假设本域是my.com,外域是sina.com,只要响应头Access-Control-Allow-Originhttp://my.com,或者是*,本次请求就可以成功。

可见,跨域能否成功,取决于对方服务器是否愿意给你设置一个正确的Access-Control-Allow-Origin,决定权始终在对方手中。

上面这种跨域请求,称之为“简单请求”。简单请求包括GET、HEAD和POST(POST的Content-Type类型 仅限application/x-www-form-urlencodedmultipart/form-datatext/plain),并且不能出现任何自定义头(例如,X-Custom: 12345),通常能满足90%的需求。

无论你是否需要用JavaScript通过CORS跨域请求资源,你都要了解CORS的原理。最新的浏览器全面支持HTML5。在引用外域资源时,除了JavaScript和CSS外,都要验证CORS。例如,当你引用了某个第三方CDN上的字体文件时:

1
2
3
4
5
/* CSS */
@font-face {
font-family: 'FontAwesome';
src: url('http://cdn.com/fonts/fontawesome.ttf') format('truetype');
}

如果该CDN服务商未正确设置Access-Control-Allow-Origin,那么浏览器无法加载字体资源。

对于PUT、DELETE以及其他类型如application/json的POST请求,在发送AJAX请求之前,浏览器会先发送一个OPTIONS请求(称为preflighted请求)到这个URL上,询问目标服务器是否接受:

1
2
3
4
OPTIONS /path/to/resource HTTP/1.1
Host: bar.com
Origin: http://my.com
Access-Control-Request-Method: POST

服务器必须响应并明确指出允许的Method:

1
2
3
4
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://my.com
Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS
Access-Control-Max-Age: 86400

浏览器确认服务器响应的Access-Control-Allow-Methods头确实包含将要发送的AJAX请求的Method,才会继续发送AJAX,否则,抛出一个错误。

由于以POSTPUT方式传送JSON格式的数据在REST中很常见,所以要跨域正确处理POSTPUT请求,服务器端必须正确响应OPTIONS请求。

需要深入了解CORS的童鞋请移步MDN文档W3C文档

在JavaScript的世界中,所有代码都是单线程执行的。

由于这个“缺陷”,导致JavaScript的所有网络操作,浏览器事件,都必须是异步执行。异步执行可以用回调函数实现:

1
2
3
4
5
6
function callback() {
console.log('Done');
}
console.log('before setTimeout()');
setTimeout(callback, 1000); // 1秒钟后调用callback函数
console.log('after setTimeout()');

观察上述代码执行,在Chrome的控制台输出可以看到:

1
2
3
4
before setTimeout()
after setTimeout()
(等待1秒后)
Done

可见,异步操作会在将来的某个时间点触发一个函数调用。

AJAX就是典型的异步操作:

1
2
3
4
5
6
7
8
9
request.onreadystatechange = function () {
if (request.readyState === 4) {
if (request.status === 200) {
return success(request.responseText);
} else {
return fail(request.status);
}
}
}

把回调函数success(request.responseText)fail(request.status)写到一个AJAX操作里很正常,但是不好看,而且不利于代码复用。

有没有更好的写法?比如写成这样:

1
2
3
let ajax = ajaxGet('http://...');
ajax.ifSuccess(success)
.ifFail(fail);

这种链式写法的好处在于,先统一执行AJAX逻辑,不关心如何处理结果,然后,根据结果是成功还是失败,在将来的某个时候调用success函数或fail函数。

古人云:“君子一诺千金”,这种“承诺将来会执行”的对象在JavaScript中称为Promise对象。

Promise有各种开源实现,在ES6中被统一规范,由浏览器直接支持。先测试一下你的浏览器是否支持Promise:

1
2
3
4
new Promise(function () {});

// 直接运行测试:
console.log('支持Promise!');

我们先看一个最简单的Promise例子:生成一个0-2之间的随机数,如果小于1,则等待一段时间后返回成功,否则返回失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function test(resolve, reject) {
let timeOut = Math.random() * 2;
log('set timeout to: ' + timeOut + ' seconds.');
setTimeout(function () {
if (timeOut < 1) {
log('call resolve()...');
resolve('200 OK');
}
else {
log('call reject()...');
reject('timeout in ' + timeOut + ' seconds.');
}
}, timeOut * 1000);
}

这个test()函数有两个参数,这两个参数都是函数,如果执行成功,我们将调用resolve('200 OK'),如果执行失败,我们将调用reject('timeout in ' + timeOut + ' seconds.')。可以看出,test()函数只关心自身的逻辑,并不关心具体的resolvereject将如何处理结果。

有了执行函数,我们就可以用一个Promise对象来执行它,并在将来某个时刻获得成功或失败的结果:

1
2
3
4
5
6
7
let p1 = new Promise(test);
let p2 = p1.then(function (result) {
console.log('成功:' + result);
});
let p3 = p2.catch(function (reason) {
console.log('失败:' + reason);
});

变量p1是一个Promise对象,它负责执行test函数。由于test函数在内部是异步执行的,当test函数执行成功时,我们告诉Promise对象:

1
2
3
4
// 如果成功,执行这个函数:
p1.then(function (result) {
console.log('成功:' + result);
});

test函数执行失败时,我们告诉Promise对象:

1
2
3
p2.catch(function (reason) {
console.log('失败:' + reason);
});

Promise对象可以串联起来,所以上述代码可以简化为:

1
2
3
4
5
new Promise(test).then(function (result) {
console.log('成功:' + result);
}).catch(function (reason) {
console.log('失败:' + reason);
});

实际测试一下,看看Promise是如何异步执行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 清除log:
let logging = document.getElementById('test-promise-log');
while (logging.children.length > 1) {
logging.removeChild(logging.children[logging.children.length - 1]);
}

// 输出log到页面:
function log(s) {
let p = document.createElement('p');
p.innerHTML = s;
logging.appendChild(p);
}

new Promise(function (resolve, reject) {
log('start new Promise...');
let timeOut = Math.random() * 2;
log('set timeout to: ' + timeOut + ' seconds.');
setTimeout(function () {
if (timeOut < 1) {
log('call resolve()...');
resolve('200 OK');
}
else {
log('call reject()...');
reject('timeout in ' + timeOut + ' seconds.');
}
}, timeOut * 1000);
}).then(function (r) {
log('Done: ' + r);
}).catch(function (reason) {
log('Failed: ' + reason);
});

Log:

可见Promise最大的好处是在异步执行的流程中,把执行代码和处理结果的代码清晰地分离了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                                   ┌──────────────────┐
then │on_resolve(data) {│
┌──────────▶│ // TODO │
│ │} │
┌────────────────────────┐ └──────────────────┘
│ new Promise(async) │
└────────────────────────┘ ┌──────────────────┐
│ │ │on_reject(data) { │
│ └──────────▶│ // TODO │
promise│ catch │} │
│ └──────────────────┘

┌────────────────────────┐
│async(resolve, reject) {│
│ // TODO │
│} │
└────────────────────────┘

Promise还可以做更多的事情,比如,有若干个异步任务,需要先做任务1,如果成功后再做任务2,任何任务失败则不再继续并执行错误处理函数。

要串行执行这样的异步任务,不用Promise需要写一层一层的嵌套代码。有了Promise,我们只需要简单地写:

1
job1.then(job2).then(job3).catch(handleError);

其中,job1job2job3都是Promise对象。

下面的例子演示了如何串行执行一系列需要异步计算获得结果的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
let logging = document.getElementById('test-promise2-log');
while (logging.children.length > 1) {
logging.removeChild(logging.children[logging.children.length - 1]);
}

function log(s) {
let p = document.createElement('p');
p.innerHTML = s;
logging.appendChild(p);
}

// 0.5秒后返回input*input的计算结果:
function multiply(input) {
return new Promise(function (resolve, reject) {
log('calculating ' + input + ' x ' + input + '...');
setTimeout(resolve, 500, input * input);
});
}

// 0.5秒后返回input+input的计算结果:
function add(input) {
return new Promise(function (resolve, reject) {
log('calculating ' + input + ' + ' + input + '...');
setTimeout(resolve, 500, input + input);
});
}

let p = new Promise(function (resolve, reject) {
log('start new Promise...');
resolve(123);
});

p.then(multiply)
.then(add)
.then(multiply)
.then(add)
.then(function (result) {
log('Got value: ' + result);
});

Log:

setTimeout可以看成一个模拟网络等异步执行的函数。

除了串行执行若干异步任务外,Promise还可以并行执行异步任务。

试想一个页面聊天系统,我们需要从两个不同的URL分别获得用户的个人信息和好友列表,这两个任务是可以并行执行的,用Promise.all()实现如下:

1
2
3
4
5
6
7
8
9
10
let p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
let p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
// 同时执行p1和p2,并在它们都完成后执行then:
Promise.all([p1, p2]).then(function (results) {
console.log(results); // 获得一个Array: ['P1', 'P2']
});

有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()实现:

1
2
3
4
5
6
7
8
9
let p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
let p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
console.log(result); // 'P1'
});

由于p1执行较快,Promise的then()将获得结果'P1'p2仍在继续执行,但执行结果将被丢弃。

如果我们组合使用Promise,就可以把很多异步任务以并行和串行的方式组合起来执行。

我们说JavaScript异步操作需要通过Promise实现,一个Promise对象在操作网络时是异步的,等到返回后再调用回调函数,执行正确就调用then(),执行错误就调用catch(),虽然异步实现了,不会让用户感觉到页面“卡住”了,但是一堆then()catch()写起来麻烦看起来也乱。

有没有更简单的写法?

可以用关键字async配合await调用Promise,实现异步操作,但代码却和同步写法类似:

1
2
3
4
5
async function get(url) {
let resp = await fetch(url);
let result = await resp.json();
return result;
}

使用async function可以定义一个异步函数,异步函数和Promise可以看作是等价的,在async function内部,用await调用另一个异步函数,写起来和同步代码没啥区别,但执行起来是异步的。

也就是说:

1
let resp = await fetch(url);

自动实现了异步调用,它和下面的Promise代码等价:

1
2
3
4
let promise = fetch(url);
promise.then((resp) => {
// 拿到resp
});

如果我们要实现catch()怎么办?用Promise的写法如下:

1
2
3
4
5
6
let promise = fetch(url);
promise.then((resp) => {
// 拿到resp
}).catch(e => {
// 出错了
});

await调用时,直接用传统的try { ... } catch

1
2
3
4
5
6
7
8
9
async function get(url) {
try {
let resp = await fetch(url);
let result = await resp.json();
return result;
} catch (e) {
// 出错了
}
}

用async定义异步函数,用await调用异步函数,写起来和同步代码差不多,但可读性大大提高。

需要特别注意的是,await调用必须在async function中,不能在传统的同步代码中调用。那么问题来了,一个同步function怎么调用async function呢?

首先,普通function直接用await调用异步函数将报错:

1
2
3
4
5
6
7
8
9
10
11
async function get(url) {
let resp = await fetch(url);
return resp.json();
}

function doGet() {
let data = await get('/api/categories');
console.log(data);
}

doGet();

如果把await去掉,调用实际上发生了,但我们拿不到结果,因为我们拿到的并不是异步结果,而是一个Promise对象:

1
2
3
4
5
6
7
8
9
10
11
12
async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}

function doGet() {
let promise = get('./content.html');
console.log(promise);
}

doGet();

因此,在普通function中调用async function,不能使用await,但可以直接调用async function拿到Promise对象,后面加上then()catch()就可以拿到结果或错误了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}

function doGet() {
let promise = get('./content.html');
promise.then(data => {
// 拿到data
document.getElementById('test-response-text').value = JSON.stringify(data);
});
}

doGet();

因此,定义异步任务时,使用async function比Promise简单,调用异步任务时,使用await比Promise简单,捕获错误时,按传统的try...catch写法,也比Promise简单。只要浏览器支持,完全可以用async简洁地实现异步操作。

Canvas是HTML5新增的组件,它就像一块幕布,可以用JavaScript在上面绘制各种图表、动画等。

没有Canvas的年代,绘图只能借助Flash插件实现,页面不得不用JavaScript和Flash进行交互。有了Canvas,我们就再也不需要Flash了,直接使用JavaScript完成绘制。

一个Canvas定义了一个指定尺寸的矩形框,在这个范围内我们可以随意绘制:

1
<canvas id="test-canvas" width="300" height="200"></canvas>

由于浏览器对HTML5标准支持不一致,所以,通常在<canvas>内部添加一些说明性HTML代码,如果浏览器支持Canvas,它将忽略<canvas>内部的HTML,如果浏览器不支持Canvas,它将显示<canvas>内部的HTML。在使用Canvas前,用canvas.getContext来测试浏览器是否支持Canvas:

1
2
3
4
<!-- HTML代码 -->
<canvas id="test-canvas" width="200" heigth="100">
<p>你的浏览器不支持Canvas</p>
</canvas>

你的浏览器不支持Canvas

1
2
3
4
5
6
let canvas = document.getElementById('test-canvas');
if (canvas.getContext) {
console.log('你的浏览器支持Canvas!');
} else {
console.log('你的浏览器不支持Canvas!');
}

getContext('2d')方法让我们拿到一个CanvasRenderingContext2D对象,所有的绘图操作都需要通过这个对象完成。

1
let ctx = canvas.getContext('2d');

如果需要绘制3D怎么办?HTML5还有一个WebGL规范,允许在Canvas中绘制3D图形:

1
gl = canvas.getContext("webgl");

本节我们只专注于绘制2D图形。

绘制形状

我们可以在Canvas上绘制各种形状。在绘制前,我们需要先了解一下Canvas的坐标系统:

1
2
3
4
5
6
7
8
9
 0,0                20
┌───────────────────────▶ x
│ │

│ │
10│─ ─ ─ ─ ─ ─ ─ ─ ─●
│ 20,10

y

Canvas的坐标以左上角为原点,水平向右为X轴,垂直向下为Y轴,以像素为单位,所以每个点都是非负整数。

CanvasRenderingContext2D对象有若干方法来绘制图形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let
canvas = document.getElementById('test-shape-canvas'),
ctx = canvas.getContext('2d');

ctx.clearRect(0, 0, 200, 200); // 擦除(0,0)位置大小为200x200的矩形,擦除的意思是把该区域变为透明
ctx.fillStyle = '#dddddd'; // 设置颜色
ctx.fillRect(10, 10, 130, 130); // 把(10,10)位置大小为130x130的矩形涂色
// 利用Path绘制复杂路径:
let path=new Path2D();
path.arc(75, 75, 50, 0, Math.PI*2, true);
path.moveTo(110,75);
path.arc(75, 75, 35, 0, Math.PI, false);
path.moveTo(65, 65);
path.arc(60, 65, 5, 0, Math.PI*2, true);
path.moveTo(95, 65);
path.arc(90, 65, 5, 0, Math.PI*2, true);
ctx.strokeStyle = '#0000ff';
ctx.stroke(path);

绘制文本

绘制文本就是在指定的位置输出文本,可以设置文本的字体、样式、阴影等,与CSS完全一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
let
canvas = document.getElementById('test-text-canvas'),
ctx = canvas.getContext('2d');

ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 300, 100);
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.shadowBlur = 2;
ctx.shadowColor = '#ccc';
ctx.font = '28px Arial';
ctx.fillStyle = '#999';
ctx.fillText('带阴影的文字', 20, 40);

Canvas除了能绘制基本的形状和文本,还可以实现动画、缩放、各种滤镜和像素转换等高级操作。如果要实现非常复杂的操作,考虑以下优化方案:

  • 通过创建一个不可见的Canvas来绘图,然后将最终绘制结果复制到页面的可见Canvas中;
  • 尽量使用整数坐标而不是浮点数;
  • 可以创建多个重叠的Canvas绘制不同的层,而不是在一个Canvas中绘制非常复杂的图;
  • 背景图片如果不变可以直接用<img>标签并放到最底层。

练习

请根据天气API返回的JSON数据在Canvas上绘制未来天气预报,如下图所示:

weather

下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let data = [
{ high: 35, low: 22 },
{ high: 37, low: 24 },
{ high: 37, low: 25 },
{ high: 34, low: 24 },
{ high: 33, low: 23 }
];

let canvas = document.getElementById('weather-canvas');
// TODO: 绘图
// 400x200

// 下载:
let download = document.getElementById('weather-download');
download.href = canvas.toDataURL();

留言與分享

JavaScript的所有数据都可以看成对象,那是不是我们已经在使用面向对象编程了呢?

当然不是。如果我们只使用NumberArraystring以及基本的{...}定义的对象,还无法发挥出面向对象编程的威力。

JavaScript的面向对象编程和大多数其他语言如Java、C#的面向对象编程都不太一样。如果你熟悉Java或C#,很好,你一定明白面向对象的两个基本概念:

  1. 类:类是对象的类型模板,例如,定义Student类来表示学生,类本身是一种类型,Student表示学生类型,但不表示任何具体的某个学生;
  2. 实例:实例是根据类创建的对象,例如,根据Student类可以创建出xiaomingxiaohongxiaojun等多个实例,每个实例表示一个具体的学生,他们全都属于Student类型。

所以,类和实例是大多数面向对象编程语言的基本概念。

不过,在JavaScript中,这个概念需要改一改。JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。

原型是指当我们想要创建xiaoming这个具体的学生时,我们并没有一个Student类型可用。那怎么办?恰好有这么一个现成的对象:

1
2
3
4
5
6
7
let robot = {
name: 'Robot',
height: 1.6,
run: function () {
console.log(this.name + ' is running...');
}
};

我们看这个robot对象有名字,有身高,还会跑,有点像小明,干脆就根据它来“创建”小明得了!

于是我们把它改名为Student,然后创建出xiaoming

1
2
3
4
5
6
7
8
9
10
11
12
13
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};

let xiaoming = {
name: '小明'
};

xiaoming.__proto__ = Student;

注意最后一行代码把xiaoming的原型指向了对象Student,看上去xiaoming仿佛是从Student继承下来的:

1
2
xiaoming.name; // '小明'
xiaoming.run(); // 小明 is running...

xiaoming有自己的name属性,但并没有定义run()方法。不过,由于小明是从Student继承而来,只要Studentrun()方法,xiaoming也可以调用:

xiaoming-prototype

JavaScript的原型链和Java的Class区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。

如果你把xiaoming的原型指向其他对象:

1
2
3
4
5
6
7
let Bird = {
fly: function () {
console.log(this.name + ' is flying...');
}
};

xiaoming.__proto__ = Bird;

现在xiaoming已经无法run()了,他已经变成了一只鸟:

1
xiaoming.fly(); // 小明 is flying...

在JavaScrip代码运行时期,你可以把xiaomingStudent变成Bird,或者变成任何对象。

请注意,上述代码仅用于演示目的。在编写JavaScript代码时,不要直接用obj.__proto__去改变一个对象的原型,并且,低版本的IE也无法使用__proto__Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有,因此,我们可以编写一个函数来创建xiaoming

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 原型对象:
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};

function createStudent(name) {
// 基于Student原型创建一个新对象:
let s = Object.create(Student);
// 初始化新对象:
s.name = name;
return s;
}

let xiaoming = createStudent('小明');
xiaoming.run(); // 小明 is running...
xiaoming.__proto__ === Student; // true

这是创建原型继承的一种方法,JavaScript还有其他方法来创建对象,我们在后面会一一讲到。

JavaScript对每个创建的对象都会设置一个原型,指向它的原型对象。

当我们用obj.xxx访问一个对象的属性时,JavaScript引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到Object.prototype对象,最后,如果还没有找到,就只能返回undefined

例如,创建一个Array对象:

1
let arr = [1, 2, 3];

其原型链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
       null


┌─────────────────┐
│Object.prototype │
└─────────────────┘


┌─────────────────┐
│ Array.prototype │
└─────────────────┘


┌─────────────────┐
│ arr │
└─────────────────┘

Array.prototype定义了indexOf()shift()等方法,因此你可以在所有的Array对象上直接调用这些方法。

当我们创建一个函数时:

1
2
3
function foo() {
return 0;
}

函数也是一个对象,它的原型链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
        null


┌───────────────────┐
│ Object.prototype │
└───────────────────┘


┌───────────────────┐
│Function.prototype │
└───────────────────┘


┌───────────────────┐
│ foo │
└───────────────────┘

由于Function.prototype定义了apply()等方法,因此,所有函数都可以调用apply()方法。

很容易想到,如果原型链很长,那么访问一个对象的属性就会因为花更多的时间查找而变得更慢,因此要注意不要把原型链搞得太长。

构造函数

除了直接用{ ... }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:

1
2
3
4
5
6
function Student(name) {
this.name = name;
this.hello = function () {
alert('Hello, ' + this.name + '!');
}
}

你会问,咦,这不是一个普通函数吗?

这确实是一个普通函数,但是在JavaScript中,可以用关键字new来调用这个函数,并返回一个对象:

1
2
3
let xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!

注意,如果不写new,这就是一个普通函数,它返回undefined。但是,如果写了new,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this,也就是说,不需要在最后写return this;

新创建的xiaoming的原型链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
       null


┌─────────────────┐
│Object.prototype │
└─────────────────┘


┌─────────────────┐
│Student.prototype│
└─────────────────┘


┌─────────────────┐
│ xiaoming │
└─────────────────┘

也就是说,xiaoming的原型指向函数Student的原型。如果你又创建了xiaohongxiaojun,那么这些对象的原型与xiaoming是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
              null


┌─────────────────┐
│Object.prototype │
└─────────────────┘


┌─────────────────┐
│Student.prototype│
└─────────────────┘
▲ ▲ ▲
│ │ │
┌─────────┐┌─────────┐┌─────────┐
│xiaoming ││xiaohong ││ xiaojun │
└─────────┘└─────────┘└─────────┘

new Student()创建的对象还从原型上获得了一个constructor属性,它指向函数Student本身:

1
2
3
4
5
6
xiaoming.constructor === Student.prototype.constructor; // true
Student.prototype.constructor === Student; // true

Object.getPrototypeOf(xiaoming) === Student.prototype; // true

xiaoming instanceof Student; // true

看晕了吧?用一张图来表示这些乱七八糟的关系就是:

prototypes

红色箭头是原型链。注意,Student.prototype指向的对象就是xiaomingxiaohong的原型对象,这个原型对象自己还有个属性constructor,指向Student函数本身。

另外,函数Student恰好有个属性prototype指向xiaomingxiaohong的原型对象,但是xiaomingxiaohong这些对象可没有prototype这个属性,不过可以用__proto__这个非标准用法来查看。

现在我们就认为xiaomingxiaohong这些对象“继承”自Student

不过还有一个小问题,注意观察:

1
2
3
4
5
xiaoming.name; // '小明'
xiaohong.name; // '小红'
xiaoming.hello; // function: Student.hello()
xiaohong.hello; // function: Student.hello()
xiaoming.hello === xiaohong.hello; // false

xiaomingxiaohong各自的name不同,这是对的,否则我们无法区分谁是谁了。

xiaomingxiaohong各自的hello是一个函数,但它们是两个不同的函数,虽然函数名称和代码都是相同的!

如果我们通过new Student()创建了很多对象,这些对象的hello函数实际上只需要共享同一个函数就可以了,这样可以节省很多内存。

要让创建的对象共享一个hello函数,根据对象的属性查找原则,我们只要把hello函数移动到xiaomingxiaohong这些对象共同的原型上就可以了,也就是Student.prototype

protos2

修改代码如下:

1
2
3
4
5
6
7
function Student(name) {
this.name = name;
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};

new创建基于原型的JavaScript的对象就是这么简单!

忘记写new怎么办

如果一个函数被定义为用于创建对象的构造函数,但是调用时忘记了写new怎么办?

在strict模式下,this.name = name将报错,因为this绑定为undefined,在非strict模式下,this.name = name不报错,因为this绑定为window,于是无意间创建了全局变量name,并且返回undefined,这个结果更糟糕。

所以,调用构造函数千万不要忘记写new。为了区分普通函数和构造函数,按照约定,构造函数首字母应当大写,而普通函数首字母应当小写,这样,一些语法检查工具如jslint将可以帮你检测到漏写的new

最后,我们还可以编写一个createStudent()函数,在内部封装所有的new操作。一个常用的编程模式像这样:

1
2
3
4
5
6
7
8
9
10
11
12
function Student(props) {
this.name = props.name || '匿名'; // 默认值为'匿名'
this.grade = props.grade || 1; // 默认值为1
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
};

function createStudent(props) {
return new Student(props || {})
}

这个createStudent()函数有几个巨大的优点:一是不需要new来调用,二是参数非常灵活,可以不传,也可以这么传:

1
2
3
4
5
let xiaoming = createStudent({
name: '小明'
});

xiaoming.grade; // 1

如果创建的对象有很多属性,我们只需要传递需要的某些属性,剩下的属性可以用默认值。由于参数是一个Object,我们无需记忆参数的顺序。如果恰好从JSON拿到了一个对象,就可以直接创建出xiaoming

练习

请利用构造函数定义Cat,并让所有的Cat对象有一个name属性,并共享一个方法say(),返回字符串'Hello, xxx!'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Cat(name) {
// TODO:
}

// 测试:
let kitty = new Cat('Kitty');
let doraemon = new Cat('哆啦A梦');
if (kitty && kitty.name === 'Kitty'
&& kitty.say
&& typeof kitty.say === 'function'
&& kitty.say() === 'Hello, Kitty!'
&& kitty.say === doraemon.say
) {
console.log('测试通过!');
} else {
console.log('测试失败!');
}

在传统的基于Class的语言如Java、C++中,继承的本质是扩展一个已有的Class,并生成新的Subclass。

由于这类语言严格区分类和实例,继承实际上是类型的扩展。但是,JavaScript由于采用原型继承,我们无法直接扩展一个Class,因为根本不存在Class这种类型。

但是办法还是有的。我们先回顾Student构造函数:

1
2
3
4
5
6
7
function Student(props) {
this.name = props.name || 'Unnamed';
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

以及Student的原型链:

js-proto

现在,我们要基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent

1
2
3
4
5
function PrimaryStudent(props) {
// 调用Student构造函数,绑定this变量:
Student.call(this, props);
this.grade = props.grade || 1;
}

但是,调用了Student构造函数不等于继承了StudentPrimaryStudent创建的对象的原型是:

1
new PrimaryStudent() --> PrimaryStudent.prototype --> Object.prototype --> null

必须想办法把原型链修改为:

1
new PrimaryStudent() --> PrimaryStudent.prototype --> Student.prototype --> Object.prototype --> null

这样,原型链对了,继承关系就对了。新的基于PrimaryStudent创建的对象不但能调用PrimaryStudent.prototype定义的方法,也可以调用Student.prototype定义的方法。

如果你想用最简单粗暴的方法这么干:

1
PrimaryStudent.prototype = Student.prototype;

是不行的!如果这样的话,PrimaryStudentStudent共享一个原型对象,那还要定义PrimaryStudent干啥?

我们必须借助一个中间对象来实现正确的原型链,这个中间对象的原型要指向Student.prototype。为了实现这一点,参考道爷(就是发明JSON的那个道格拉斯)的代码,中间对象可以用一个空函数F来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// PrimaryStudent构造函数:
function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}

// 空函数F:
function F() {
}

// 把F的原型指向Student.prototype:
F.prototype = Student.prototype;

// 把PrimaryStudent的原型指向一个新的F对象,F对象的原型正好指向Student.prototype:
PrimaryStudent.prototype = new F();

// 把PrimaryStudent原型的构造函数修复为PrimaryStudent:
PrimaryStudent.prototype.constructor = PrimaryStudent;

// 继续在PrimaryStudent原型(就是new F()对象)上定义方法:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};

// 创建xiaoming:
let xiaoming = new PrimaryStudent({
name: '小明',
grade: 2
});
xiaoming.name; // '小明'
xiaoming.grade; // 2

// 验证原型:
xiaoming.__proto__ === PrimaryStudent.prototype; // true
xiaoming.__proto__.__proto__ === Student.prototype; // true

// 验证继承关系:
xiaoming instanceof PrimaryStudent; // true
xiaoming instanceof Student; // true

用一张图来表示新的原型链:

js-proto-inherit

注意,函数F仅用于桥接,我们仅创建了一个new F()实例,而且,没有改变原有的Student定义的原型链。

如果把继承这个动作用一个inherits()函数封装起来,还可以隐藏F的定义,并简化代码:

1
2
3
4
5
6
function inherits(Child, Parent) {
let F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}

这个inherits()函数可以复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Student(props) {
this.name = props.name || 'Unnamed';
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}

// 实现原型继承链:
inherits(PrimaryStudent, Student);

// 绑定其他方法到PrimaryStudent原型:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};

小结

JavaScript的原型继承实现方式就是:

  1. 定义新的构造函数,并在内部用call()调用希望“继承”的构造函数,并绑定this
  2. 借助中间函数F实现原型链继承,最好通过封装的inherits函数完成;
  3. 继续在新的构造函数的原型上定义新方法。

class继承

在上面的章节中我们看到了JavaScript的对象模型是基于原型实现的,特点是简单,缺点是理解起来比传统的类-实例模型要困难,最大的缺点是继承的实现需要编写大量代码,并且需要正确实现原型链。

有没有更简单的写法?有!

新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。

我们先回顾用函数实现Student的方法:

1
2
3
4
5
6
7
function Student(name) {
this.name = name;
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

如果用新的class关键字来编写Student,可以这样写:

1
2
3
4
5
6
7
8
9
class Student {
constructor(name) {
this.name = name;
}

hello() {
alert('Hello, ' + this.name + '!');
}
}

比较一下就可以发现,class的定义包含了构造函数constructor和定义在原型对象上的函数hello()(注意没有function关键字),这样就避免了Student.prototype.hello = function () {...}这样分散的代码。

最后,创建一个Student对象代码和前面章节完全一样:

1
2
let xiaoming = new Student('小明');
xiaoming.hello();

class继承

class定义对象的另一个巨大的好处是继承更方便了。想一想我们从Student派生一个PrimaryStudent需要编写的代码量。现在,原型继承的中间对象,原型对象的构造函数等等都不需要考虑了,直接通过extends来实现:

1
2
3
4
5
6
7
8
9
10
class PrimaryStudent extends Student {
constructor(name, grade) {
super(name); // 记得用super调用父类的构造方法!
this.grade = grade;
}

myGrade() {
alert('I am at grade ' + this.grade);
}
}

注意PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。子类的构造函数可能会与父类不太相同,例如,PrimaryStudent需要namegrade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。

PrimaryStudent已经自动获得了父类Studenthello方法,我们又在子类中定义了新的myGrade方法。

ES6引入的class和原有的JavaScript原型继承有什么区别呢?实际上它们没有任何区别,class的作用就是让JavaScript引擎去实现原来需要我们自己编写的原型链代码。简而言之,用class的好处就是极大地简化了原型链代码。

练习

请利用class重新定义Cat,并让它从已有的Animal继承,然后新增一个方法say(),返回字符串'Hello, xxx!'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Animal {
constructor(name) {
this.name = name;
}
}

// FIXME:
class Cat ???

// 测试:
let kitty = new Cat('Kitty');
let doraemon = new Cat('哆啦A梦');
if ((new Cat('x') instanceof Animal)
&& kitty
&& kitty.name === 'Kitty'
&& kitty.say
&& typeof kitty.say === 'function'
&& kitty.say() === 'Hello, Kitty!'
&& kitty.say === doraemon.say)
{
console.log('测试通过!');
} else {
console.log('测试失败!');
}

这个练习需要浏览器支持ES6的class,如果遇到SyntaxError,则说明浏览器不支持class语法,请换一个最新的浏览器试试。



留言與分享

JavaScript-标准对象

分類 编程语言, JavaScript

标准对象

在JavaScript的世界里,一切都是对象。

但是某些对象还是和其他对象不太一样。为了区分对象的类型,我们用typeof操作符获取对象的类型,它总是返回一个字符串:

1
2
3
4
5
6
7
8
9
10
typeof 123; // 'number'
typeof 123n; // 'bigint'
typeof NaN; // 'number'
typeof 'str'; // 'string'
typeof true; // 'boolean'
typeof undefined; // 'undefined'
typeof Math.abs; // 'function'
typeof null; // 'object'
typeof []; // 'object'
typeof {}; // 'object'

可见,numberbigintstringbooleanfunctionundefined有别于其他类型。特别注意null的类型是objectArray的类型也是object,如果我们用typeof将无法区分出nullArray和通常意义上的object——{}

包装对象

除了这些类型外,JavaScript还提供了包装对象,熟悉Java的小伙伴肯定很清楚intInteger这种暧昧关系。

numberbooleanstring都有包装对象。没错,在JavaScript中,字符串也区分string类型和它的包装类型。包装对象用new创建:

1
2
3
let n = new Number(123); // 123,生成了新的包装类型
let b = new Boolean(true); // true,生成了新的包装类型
let s = new String('str'); // 'str',生成了新的包装类型

虽然包装对象看上去和原来的值一模一样,显示出来也是一模一样,但他们的类型已经变为object了!所以,包装对象和原始值用===比较会返回false

1
2
3
4
5
6
7
8
typeof new Number(123); // 'object'
new Number(123) === 123; // false

typeof new Boolean(true); // 'object'
new Boolean(true) === true; // false

typeof new String('str'); // 'object'
new String('str') === 'str'; // false

所以闲的蛋疼也不要使用包装对象!尤其是针对string类型!!!

如果我们在使用NumberBooleanString时,没有写new会发生什么情况?

此时,Number()BooleanString()被当做普通函数,把任何类型的数据转换为numberbooleanstring类型(注意不是其包装类型):

1
2
3
4
5
6
7
8
9
10
11
let n = Number('123'); // 123,相当于parseInt()或parseFloat()
typeof n; // 'number'

let b = Boolean('true'); // true
typeof b; // 'boolean'

let b2 = Boolean('false'); // true! 'false'字符串转换结果为true!因为它是非空字符串!
let b3 = Boolean(''); // false

let s = String(123.45); // '123.45'
typeof s; // 'string'

是不是感觉头大了?这就是JavaScript特有的催眠魅力!

总结一下,有这么几条规则需要遵守:

  • 不要使用new Number()new Boolean()new String()创建包装对象;
  • parseInt()parseFloat()来转换任意类型到number
  • String()来转换任意类型到string,或者直接调用某个对象的toString()方法;
  • 通常不必把任意类型转换为boolean再判断,因为可以直接写if (myVar) {...}
  • typeof操作符可以判断出numberbooleanstringfunctionundefined
  • 判断Array要使用Array.isArray(arr)
  • 判断null请使用myVar === null
  • 判断某个全局变量是否存在用typeof window.myVar === 'undefined'
  • 函数内部判断某个变量是否存在用typeof myVar === 'undefined'

最后有细心的同学指出,任何对象都有toString()方法吗?nullundefined就没有!确实如此,这两个特殊值要除外,虽然null还伪装成了object类型。

更细心的同学指出,number对象调用toString()报SyntaxError:

1
123.toString(); // SyntaxError

遇到这种情况,要特殊处理一下:

1
2
123..toString(); // '123', 注意是两个点!
(123).toString(); // '123'

不要问为什么,这就是JavaScript代码的乐趣!



在JavaScript中,Date对象用来表示日期和时间。

要获取系统当前时间,用:

1
2
3
4
5
6
7
8
9
10
11
let now = new Date();
now; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
now.getFullYear(); // 2015, 年份
now.getMonth(); // 5, 月份,注意月份范围是0~11,5表示六月
now.getDate(); // 24, 表示24号
now.getDay(); // 3, 表示星期三
now.getHours(); // 19, 24小时制
now.getMinutes(); // 49, 分钟
now.getSeconds(); // 22, 秒
now.getMilliseconds(); // 875, 毫秒数
now.getTime(); // 1435146562875, 以number形式表示的时间戳

注意,当前时间是浏览器从本机操作系统获取的时间,所以不一定准确,因为用户可以把当前时间设定为任何值。

如果要创建一个指定日期和时间的Date对象,可以用:

1
2
let d = new Date(2015, 5, 19, 20, 15, 30, 123);
console.log(d); // Fri Jun 19 2015 20:15:30 GMT+0800 (CST)

你可能观察到了一个非常非常坑爹的地方,就是JavaScript的月份范围用整数表示是0~11,0表示一月,1表示二月……,所以要表示6月,我们传入的是5!这绝对是JavaScript的设计者当时脑抽了一下,但是现在要修复已经不可能了。

特别注意

JavaScript的Date对象月份值从0开始,牢记0=1月,1=2月,2=3月,……,11=12月。

第二种创建一个指定日期和时间的方法是解析一个符合ISO 8601格式的字符串:

1
2
let d = Date.parse('2015-06-24T19:49:22.875+08:00');
console.log(d); // 1435146562875

但它返回的不是Date对象,而是一个时间戳。不过有时间戳就可以很容易地把它转换为一个Date

1
2
3
let d = new Date(1435146562875);
d; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
d.getMonth(); // 5

注意

使用Date.parse()时传入的字符串使用实际月份01~12,转换为Date对象后getMonth()获取的月份值为0~11。

时区

Date对象表示的时间总是按浏览器所在时区显示的,不过我们既可以显示本地时间,也可以显示调整后的UTC时间:

1
2
3
let d = new Date(1435146562875);
d.toLocaleString(); // '2015/6/24 下午7:49:22',本地时间(北京时区+8:00),显示的字符串与操作系统设定的格式有关
d.toUTCString(); // 'Wed, 24 Jun 2015 11:49:22 GMT',UTC时间,与本地时间相差8小时

那么在JavaScript中如何进行时区转换呢?实际上,只要我们传递的是一个number类型的时间戳,我们就不用关心时区转换。任何浏览器都可以把一个时间戳正确转换为本地时间。

时间戳是个什么东西?时间戳是一个自增的整数,它表示从1970年1月1日零时整的GMT时区开始的那一刻,到现在的毫秒数。假设浏览器所在电脑的时间是准确的,那么世界上无论哪个时区的电脑,它们此刻产生的时间戳数字都是一样的,所以,时间戳可以精确地表示一个时刻,并且与时区无关。

所以,我们只需要传递时间戳,或者把时间戳从数据库里读出来,再让JavaScript自动转换为当地时间就可以了。

要获取当前时间戳,可以用:

1
2
3
4
5
// 方法1:
console.log(Date.now());

// 方法2:
console.log(new Date().getTime());

练习

小明为了和女友庆祝情人节,特意制作了网页,并提前预定了法式餐厅。小明打算用JavaScript给女友一个惊喜留言:

1
2
3
4
let today = new Date();
if (today.getMonth() === 2 && today.getDate() === 14) {
alert('亲爱的,我预定了晚餐,晚上6点在餐厅见!');
}

结果女友并未出现。小明非常郁闷,请你帮忙分析他的JavaScript代码有何问题。

注孤生

字符串是编程时涉及到的最多的一种数据结构,对字符串进行操作的需求几乎无处不在。比如判断一个字符串是否是合法的Email地址,虽然可以编程提取@前后的子串,再分别判断是否是单词和域名,但这样做不但麻烦,而且代码难以复用。

正则表达式是一种用来匹配字符串的强有力的武器。它的设计思想是用一种描述性的语言来给字符串定义一个规则,凡是符合规则的字符串,我们就认为它“匹配”了,否则,该字符串就是不合法的。

所以我们判断一个字符串是否是合法的Email的方法是:

  1. 创建一个匹配Email的正则表达式;
  2. 用该正则表达式去匹配用户的输入来判断是否合法。

因为正则表达式也是用字符串表示的,所以,我们要首先了解如何用字符来描述字符。

在正则表达式中,如果直接给出字符,就是精确匹配。用\d可以匹配一个数字,\w可以匹配一个字母或数字,所以:

  • '00\d'可以匹配'007',但无法匹配'00A'
  • '\d\d\d'可以匹配'010'
  • '\w\w'可以匹配'js'

.可以匹配任意字符,所以:

  • 'js.'可以匹配'jsp''jss''js!'等等。

要匹配变长的字符,在正则表达式中,用*表示任意个字符(包括0个),用+表示至少一个字符,用?表示0个或1个字符,用{n}表示n个字符,用{n,m}表示n-m个字符:

来看一个复杂的例子:\d{3}\s+\d{3,8}

我们来从左到右解读一下:

  1. \d{3}表示匹配3个数字,例如'010'
  2. \s可以匹配一个空格(也包括Tab等空白符),所以\s+表示至少有一个空格,例如匹配' ''\t\t'等;
  3. \d{3,8}表示3-8个数字,例如'1234567'

综合起来,上面的正则表达式可以匹配以任意个空格隔开的带区号的电话号码。

如果要匹配'010-12345'这样的号码呢?由于'-'是特殊字符,在正则表达式中,要用'\'转义,所以,上面的正则是\d{3}\-\d{3,8}

但是,仍然无法匹配'010 - 12345',因为带有空格。所以我们需要更复杂的匹配方式。

进阶

要做更精确地匹配,可以用[]表示范围,比如:

  • [0-9a-zA-Z\_]可以匹配一个数字、字母或者下划线;
  • [0-9a-zA-Z\_]+可以匹配至少由一个数字、字母或者下划线组成的字符串,比如'a100''0_Z''js2015'等等;
  • [a-zA-Z\_\$][0-9a-zA-Z\_\$]*可以匹配由字母或下划线、开头,后接任意个由一个数字、字母或者下划线、开头,后接任意个由一个数字、字母或者下划线、组成的字符串,也就是JavaScript允许的变量名;
  • [a-zA-Z\_\$][0-9a-zA-Z\_\$]{0, 19}更精确地限制了变量的长度是1-20个字符(前面1个字符+后面最多19个字符)。

A|B可以匹配A或B,所以(J|j)ava(S|s)cript可以匹配'JavaScript''Javascript''javaScript'或者'javascript'

^表示行的开头,^\d表示必须以数字开头。

$表示行的结束,\d$表示必须以数字结束。

你可能注意到了,js也可以匹配'jsp',但是加上^js$就变成了整行匹配,就只能匹配'js'了。

RegExp

有了准备知识,我们就可以在JavaScript中使用正则表达式了。

JavaScript有两种方式创建一个正则表达式:

第一种方式是直接通过/正则表达式/写出来,第二种方式是通过new RegExp('正则表达式')创建一个RegExp对象。

两种写法是一样的:

1
2
3
4
5
let re1 = /ABC\-001/;
let re2 = new RegExp('ABC\\-001');

re1; // /ABC\-001/
re2; // /ABC\-001/

注意,如果使用第二种写法,因为字符串的转义问题,字符串的两个\\实际上是一个\

先看看如何判断正则表达式是否匹配:

1
2
3
4
let re = /^\d{3}\-\d{3,8}$/;
re.test('010-12345'); // true
re.test('010-1234x'); // false
re.test('010 12345'); // false

RegExp对象的test()方法用于测试给定的字符串是否符合条件。

切分字符串

用正则表达式切分字符串比用固定的字符更灵活,请看正常的切分代码:

1
'a b   c'.split(' '); // ['a', 'b', '', '', 'c']

嗯,无法识别连续的空格,用正则表达式试试:

1
'a b   c'.split(/\s+/); // ['a', 'b', 'c']

无论多少个空格都可以正常分割。加入,试试:

1
'a,b, c  d'.split(/[\s\,]+/); // ['a', 'b', 'c', 'd']

再加入;试试:

1
'a,b;; c  d'.split(/[\s\,\;]+/); // ['a', 'b', 'c', 'd']

如果用户输入了一组标签,下次记得用正则表达式来把不规范的输入转化成正确的数组。

分组

除了简单地判断是否匹配之外,正则表达式还有提取子串的强大功能。用()表示的就是要提取的分组(Group)。比如:

^(\d{3})-(\d{3,8})$分别定义了两个组,可以直接从匹配的字符串中提取出区号和本地号码:

1
2
3
let re = /^(\d{3})-(\d{3,8})$/;
re.exec('010-12345'); // ['010-12345', '010', '12345']
re.exec('010 12345'); // null

如果正则表达式中定义了组,就可以在RegExp对象上用exec()方法提取出子串来。

exec()方法在匹配成功后,会返回一个Array,第一个元素是正则表达式匹配到的整个字符串,后面的字符串表示匹配成功的子串。

exec()方法在匹配失败时返回null

提取子串非常有用。来看一个更凶残的例子:

1
2
let re = /^(0[0-9]|1[0-9]|2[0-3]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])$/;
re.exec('19:05:30'); // ['19:05:30', '19', '05', '30']

这个正则表达式可以直接识别合法的时间。但是有些时候,用正则表达式也无法做到完全验证,比如识别日期:

1
let re = /^(0[1-9]|1[0-2]|[0-9])-(0[1-9]|1[0-9]|2[0-9]|3[0-1]|[0-9])$/;

对于'2-30''4-31'这样的非法日期,用正则还是识别不了,或者说写出来非常困难,这时就需要程序配合识别了。

贪婪匹配

需要特别指出的是,正则匹配默认是贪婪匹配,也就是匹配尽可能多的字符。举例如下,匹配出数字后面的0

1
2
let re = /^(\d+)(0*)$/;
re.exec('102300'); // ['102300', '102300', '']

由于\d+采用贪婪匹配,直接把后面的0全部匹配了,结果0*只能匹配空字符串了。

必须让\d+采用非贪婪匹配(也就是尽可能少匹配),才能把后面的0匹配出来,加个?就可以让\d+采用非贪婪匹配:

1
2
let re = /^(\d+?)(0*)$/;
re.exec('102300'); // ['102300', '1023', '00']

全局搜索

JavaScript的正则表达式还有几个特殊的标志,最常用的是g,表示全局匹配:

1
2
3
let r1 = /test/g;
// 等价于:
let r2 = new RegExp('test', 'g');

全局匹配可以多次执行exec()方法来搜索一个匹配的字符串。当我们指定g标志后,每次运行exec(),正则表达式本身会更新lastIndex属性,表示上次匹配到的最后索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let s = 'JavaScript, VBScript, JScript and ECMAScript';
let re=/[a-zA-Z]+Script/g;

// 使用全局匹配:
re.exec(s); // ['JavaScript']
re.lastIndex; // 10

re.exec(s); // ['VBScript']
re.lastIndex; // 20

re.exec(s); // ['JScript']
re.lastIndex; // 29

re.exec(s); // ['ECMAScript']
re.lastIndex; // 44

re.exec(s); // null,直到结束仍没有匹配到

全局匹配类似搜索,因此不能使用/^...$/,那样只会最多匹配一次。

正则表达式还可以指定i标志,表示忽略大小写,m标志,表示执行多行匹配。

小结

正则表达式非常强大,要在短短的一节里讲完是不可能的。要讲清楚正则的所有内容,可以写一本厚厚的书了。如果你经常遇到正则表达式的问题,你可能需要一本正则表达式的参考书。

练习

请尝试写一个验证Email地址的正则表达式。版本一应该可以验证出类似的Email:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let re = /^$/;

// 测试:
let
i,
success = true,
should_pass = ['someone@gmail.com', 'bill.gates@microsoft.com', 'tom@voyager.org', 'bob2015@163.com'],
should_fail = ['test#gmail.com', 'bill@microsoft', 'bill%gates@ms.com', '@voyager.org'];
for (i = 0; i < should_pass.length; i++) {
if (!re.test(should_pass[i])) {
console.log('测试失败: ' + should_pass[i]);
success = false;
break;
}
}
for (i = 0; i < should_fail.length; i++) {
if (re.test(should_fail[i])) {
console.log('测试失败: ' + should_fail[i]);
success = false;
break;
}
}
if (success) {
console.log('测试通过!');
}

版本二可以验证并提取出带名字的Email地址:

1
2
3
4
5
6
7
8
9
10
let re = /^$/;

// 测试:
let r = re.exec('<Tom Paris> tom@voyager.org');
if (r === null || r.toString() !== ['<Tom Paris> tom@voyager.org', 'Tom Paris', 'tom@voyager.org'].toString()) {
console.log('测试失败!');
}
else {
console.log('测试成功!');
}

JSON是JavaScript Object Notation的缩写,它是一种数据交换格式。

在JSON出现之前,大家一直用XML来传递数据。因为XML是一种纯文本格式,所以它适合在网络上交换数据。XML本身不算复杂,但是,加上DTD、XSD、XPath、XSLT等一大堆复杂的规范以后,任何正常的软件开发人员碰到XML都会感觉头大了,最后大家发现,即使你努力钻研几个月,也未必搞得清楚XML的规范。

终于,在2002年的一天,道格拉斯·克罗克福特(Douglas Crockford)同学为了拯救深陷水深火热同时又被某几个巨型软件企业长期愚弄的软件工程师,发明了JSON这种超轻量级的数据交换格式。

道格拉斯同学长期担任雅虎的高级架构师,自然钟情于JavaScript。他设计的JSON实际上是JavaScript的一个子集。在JSON中,一共就这么几种数据类型:

  • number:和JavaScript的number完全一致;
  • boolean:就是JavaScript的truefalse
  • string:就是JavaScript的string
  • null:就是JavaScript的null
  • array:就是JavaScript的Array表示方式——[]
  • object:就是JavaScript的{ ... }表示方式。

以及上面的任意组合。

并且,JSON还定死了字符集必须是UTF-8,表示多语言就没有问题了。为了统一解析,JSON的字符串规定必须用双引号"",Object的键也必须用双引号""

由于JSON非常简单,很快就风靡Web世界,并且成为ECMA标准。几乎所有编程语言都有解析JSON的库,而在JavaScript中,我们可以直接使用JSON,因为JavaScript内置了JSON的解析。

把任何JavaScript对象变成JSON,就是把这个对象序列化成一个JSON格式的字符串,这样才能够通过网络传递给其他计算机。

如果我们收到一个JSON格式的字符串,只需要把它反序列化成一个JavaScript对象,就可以在JavaScript中直接使用这个对象了。

序列化

让我们先把小明这个对象序列化成JSON格式的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
let xiaoming = {
name: '小明',
age: 14,
gender: true,
height: 1.65,
grade: null,
'middle-school': '\"W3C\" Middle School',
skills: ['JavaScript', 'Java', 'Python', 'Lisp']
};

let s = JSON.stringify(xiaoming);
console.log(s);

要输出得好看一些,可以加上参数,按缩进输出:

1
JSON.stringify(xiaoming, null, '  ');

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "小明",
"age": 14,
"gender": true,
"height": 1.65,
"grade": null,
"middle-school": "\"W3C\" Middle School",
"skills": [
"JavaScript",
"Java",
"Python",
"Lisp"
]
}

第二个参数用于控制如何筛选对象的键值,如果我们只想输出指定的属性,可以传入Array

1
JSON.stringify(xiaoming, ['name', 'skills'], '  ');

结果:

1
2
3
4
5
6
7
8
9
{
"name": "小明",
"skills": [
"JavaScript",
"Java",
"Python",
"Lisp"
]
}

还可以传入一个函数,这样对象的每个键值对都会被函数先处理:

1
2
3
4
5
6
7
8
function convert(key, value) {
if (typeof value === 'string') {
return value.toUpperCase();
}
return value;
}

JSON.stringify(xiaoming, convert, ' ');

上面的代码把所有属性值都变成大写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "小明",
"age": 14,
"gender": true,
"height": 1.65,
"grade": null,
"middle-school": "\"W3C\" MIDDLE SCHOOL",
"skills": [
"JAVASCRIPT",
"JAVA",
"PYTHON",
"LISP"
]
}

如果我们还想要精确控制如何序列化小明,可以给xiaoming定义一个toJSON()的方法,直接返回JSON应该序列化的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let xiaoming = {
name: '小明',
age: 14,
gender: true,
height: 1.65,
grade: null,
'middle-school': '\"W3C\" Middle School',
skills: ['JavaScript', 'Java', 'Python', 'Lisp'],
toJSON: function () {
return { // 只输出name和age,并且改变了key:
'Name': this.name,
'Age': this.age
};
}
};

JSON.stringify(xiaoming); // '{"Name":"小明","Age":14}'

反序列化

拿到一个JSON格式的字符串,我们直接用JSON.parse()把它变成一个JavaScript对象:

1
2
3
4
JSON.parse('[1,2,3,true]'); // [1, 2, 3, true]
JSON.parse('{"name":"小明","age":14}'); // Object {name: '小明', age: 14}
JSON.parse('true'); // true
JSON.parse('123.45'); // 123.45

JSON.parse()还可以接收一个函数,用来转换解析出的属性:

1
2
3
4
5
6
7
let obj = JSON.parse('{"name":"小明","age":14}', function (key, value) {
if (key === 'name') {
return value + '同学';
}
return value;
});
console.log(JSON.stringify(obj)); // {name: '小明同学', age: 14}

在JavaScript中使用JSON,就是这么简单!

练习

用浏览器访问OpenWeatherMap的天气API,查看返回的JSON数据,然后返回城市、天气预报等信息:

1
2
3
4
5
6
7
8
9
10
11
let url = 'https://api.openweathermap.org/data/2.5/forecast?q=Beijing,cn&appid=800f49846586c3ba6e7052cfc89af16c';
fetch(url).then(resp => {
resp.json().then(data => {
let info = {
city: data.city.name,
weather: data.list[0].weather[0].main,
time: data.list[0].dt_txt
};
alert(JSON.stringify(info, null, ' '));
});
});

留言與分享

JavaScript-函数

分類 编程语言, JavaScript

函数

我们知道圆的面积计算公式为:

当我们知道半径r的值时,就可以根据公式计算出面积。假设我们需要计算3个不同大小的圆的面积:

1
2
3
4
5
6
let r1 = 12.34;
let r2 = 9.08;
let r3 = 73.1;
let s1 = 3.14 * r1 * r1;
let s2 = 3.14 * r2 * r2;
let s3 = 3.14 * r3 * r3;

当代码出现有规律的重复的时候,你就需要当心了,每次写3.14 * x * x不仅很麻烦,而且,如果要把3.14改成3.1416的时候,得全部替换。

有了函数,我们就不再每次写s = 3.14 * x * x,而是写成更有意义的函数调用s = area_of_circle(x),而函数area_of_circle本身只需要写一次,就可以多次调用。

基本上所有的高级语言都支持函数,JavaScript也不例外。JavaScript的函数不但是“头等公民”,而且可以像变量一样使用,具有非常强大的抽象能力。

抽象

抽象是数学中非常常见的概念。举个例子:

计算数列的和,比如:1 + 2 + 3 + ... + 100,写起来十分不方便,于是数学家发明了求和符号∑,可以把1 + 2 + 3 + ... + 100记作:

这种抽象记法非常强大,因为我们看到 ∑ 就可以理解成求和,而不是还原成低级的加法运算。

而且,这种抽象记法是可扩展的,比如:

还原成加法运算就变成了:

可见,借助抽象,我们才能不关心底层的具体计算过程,而直接在更高的层次上思考问题。

写计算机程序也是一样,函数就是最基本的一种代码抽象的方式。



定义函数

在JavaScript中,定义函数的方式如下:

1
2
3
4
5
6
7
function abs(x) {
if (x >= 0) {
return x;
} else {
return -x;
}
}

上述abs()函数的定义如下:

  • function指出这是一个函数定义;
  • abs是函数的名称;
  • (x)括号内列出函数的参数,多个参数以,分隔;
  • { ... }之间的代码是函数体,可以包含若干语句,甚至可以没有任何语句。

请注意,函数体内部的语句在执行时,一旦执行到return时,函数就执行完毕,并将结果返回。因此,函数内部通过条件判断和循环可以实现非常复杂的逻辑。

如果没有return语句,函数执行完毕后也会返回结果,只是结果为undefined

由于JavaScript的函数也是一个对象,上述定义的abs()函数实际上是一个函数对象,而函数名abs可以视为指向该函数的变量。

因此,第二种定义函数的方式如下:

1
2
3
4
5
6
7
let abs = function (x) {
if (x >= 0) {
return x;
} else {
return -x;
}
};

在这种方式下,function (x) { ... }是一个匿名函数,它没有函数名。但是,这个匿名函数赋值给了变量abs,所以,通过变量abs就可以调用该函数。

上述两种定义完全等价,注意第二种方式按照完整语法需要在函数体末尾加一个;,表示赋值语句结束。

调用函数

调用函数时,按顺序传入参数即可:

1
2
abs(10); // 返回10
abs(-9); // 返回9

由于JavaScript允许传入任意个参数而不影响调用,因此传入的参数比定义的参数多也没有问题,虽然函数内部并不需要这些参数:

1
2
abs(10, 'blablabla'); // 返回10
abs(-9, 'haha', 'hehe', null); // 返回9

传入的参数比定义的少也没有问题:

1
abs(); // 返回NaN

此时abs(x)函数的参数x将收到undefined,计算结果为NaN

要避免收到undefined,可以对参数进行检查:

1
2
3
4
5
6
7
8
9
10
function abs(x) {
if (typeof x !== 'number') {
throw 'Not a number';
}
if (x >= 0) {
return x;
} else {
return -x;
}
}

arguments

JavaScript还有一个免费赠送的关键字arguments,它只在函数内部起作用,并且永远指向当前函数的调用者传入的所有参数。arguments类似Array但它不是一个Array

1
2
3
4
5
6
7
function foo(x) {
console.log('x = ' + x); // 10
for (let i=0; i<arguments.length; i++) {
console.log('arg ' + i + ' = ' + arguments[i]); // 10, 20, 30
}
}
foo(10, 20, 30);

利用arguments,你可以获得调用者传入的所有参数。也就是说,即使函数不定义任何参数,还是可以拿到参数的值:

1
2
3
4
5
6
7
8
9
10
11
function abs() {
if (arguments.length === 0) {
return 0;
}
let x = arguments[0];
return x >= 0 ? x : -x;
}

abs(); // 0
abs(10); // 10
abs(-9); // 9

实际上arguments最常用于判断传入参数的个数。你可能会看到这样的写法:

1
2
3
4
5
6
7
8
9
10
// foo(a[, b], c)
// 接收2~3个参数,b是可选参数,如果只传2个参数,b默认为null:
function foo(a, b, c) {
if (arguments.length === 2) {
// 实际拿到的参数是a和b,c为undefined
c = b; // 把b赋给c
b = null; // b变为默认值
}
// ...
}

要把中间的参数b变为“可选”参数,就只能通过arguments判断,然后重新调整参数并赋值。

rest参数

由于JavaScript函数允许接收任意个参数,于是我们就不得不用arguments来获取所有参数:

1
2
3
4
5
6
7
8
9
10
11
function foo(a, b) {
let i, rest = [];
if (arguments.length > 2) {
for (i = 2; i<arguments.length; i++) {
rest.push(arguments[i]);
}
}
console.log('a = ' + a);
console.log('b = ' + b);
console.log(rest);
}

为了获取除了已定义参数ab之外的参数,我们不得不用arguments,并且循环要从索引2开始以便排除前两个参数,这种写法很别扭,只是为了获得额外的rest参数,有没有更好的方法?

ES6标准引入了rest参数,上面的函数可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function foo(a, b, ...rest) {
console.log('a = ' + a);
console.log('b = ' + b);
console.log(rest);
}

foo(1, 2, 3, 4, 5);
// 结果:
// a = 1
// b = 2
// Array [ 3, 4, 5 ]

foo(1);
// 结果:
// a = 1
// b = undefined
// Array []

rest参数只能写在最后,前面用...标识,从运行结果可知,传入的参数先绑定ab,多余的参数以数组形式交给变量rest,所以,不再需要arguments我们就获取了全部参数。

如果传入的参数连正常定义的参数都没填满,也不要紧,rest参数会接收一个空数组(注意不是undefined)。

因为rest参数是ES6新标准,所以你需要测试一下浏览器是否支持。请用rest参数编写一个sum()函数,接收任意个参数并返回它们的和:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function sum(...rest) {
???
}

// 测试:
let i, args = [];
for (i=1; i<=100; i++) {
args.push(i);
}
if (sum() !== 0) {
console.log('测试失败: sum() = ' + sum());
} else if (sum(1) !== 1) {
console.log('测试失败: sum(1) = ' + sum(1));
} else if (sum(2, 3) !== 5) {
console.log('测试失败: sum(2, 3) = ' + sum(2, 3));
} else if (sum.apply(null, args) !== 5050) {
console.log('测试失败: sum(1, 2, 3, ..., 100) = ' + sum.apply(null, args));
} else {
console.log('测试通过!');
}

小心你的return语句

前面我们讲到了JavaScript引擎有一个在行末自动添加分号的机制,这可能让你栽到return语句的一个大坑:

1
2
3
4
5
function foo() {
return { name: 'foo' };
}

foo(); // { name: 'foo' }

如果把return语句拆成两行:

1
2
3
4
5
6
function foo() {
return
{ name: 'foo' };
}

foo(); // undefined

要小心了,由于JavaScript引擎在行末自动添加分号的机制,上面的代码实际上变成了:

1
2
3
4
function foo() {
return; // 自动添加了分号,相当于return undefined;
{ name: 'foo' }; // 这行语句已经没法执行到了
}

所以正确的多行写法是:

1
2
3
4
5
function foo() {
return { // 这里不会自动加分号,因为{表示语句尚未结束
name: 'foo'
};
}

练习

定义一个计算圆面积的函数area_of_circle(),它有两个参数:

  • r: 表示圆的半径;
  • pi: 表示π的值,如果不传,则默认3.14
1
2
3
4
5
6
7
8
9
10
function area_of_circle(r, pi) {
// FIXME:
return 0;
}
// 测试:
if (area_of_circle(2) === 12.56 && area_of_circle(2, 3.1416) === 12.5664) {
console.log('测试通过');
} else {
console.log('测试失败');
}

小明是一个JavaScript新手,他写了一个max()函数,返回两个数中较大的那个:

1
2
3
4
5
6
7
8
9
10
function max(a, b) {
if (a > b) {
return
a;
} else {
return
b;
}
}
console.log(max(15, 20));

但是小明抱怨他的浏览器出问题了,无论传入什么数,max()函数总是返回undefined。请帮他指出问题并修复。

在JavaScript中,用var申明的变量实际上是有作用域的。

如果一个变量在函数体内部申明,则该变量的作用域为整个函数体,在函数体外不可引用该变量:

1
2
3
4
5
6
function foo() {
var x = 1;
x = x + 1;
}

x = x + 2; // ReferenceError! 无法在函数体外引用变量x

如果两个不同的函数各自申明了同一个变量,那么该变量只在各自的函数体内起作用。换句话说,不同函数内部的同名变量互相独立,互不影响:

1
2
3
4
5
6
7
8
9
function foo() {
var x = 1;
x = x + 1;
}

function bar() {
var x = 'A';
x = x + 'B';
}

由于JavaScript的函数可以嵌套,此时,内部函数可以访问外部函数定义的变量,反过来则不行:

1
2
3
4
5
6
7
function foo() {
var x = 1;
function bar() {
var y = x + 1; // bar可以访问foo的变量x!
}
var z = y + 1; // ReferenceError! foo不可以访问bar的变量y!
}

如果内部函数和外部函数的变量名重名怎么办?来测试一下:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var x = 1;
function bar() {
var x = 'A';
console.log('x in bar() = ' + x); // 'A'
}
console.log('x in foo() = ' + x); // 1
bar();
}

foo();

这说明JavaScript的函数在查找变量时从自身函数定义开始,从“内”向“外”查找。如果内部函数定义了与外部函数重名的变量,则内部函数的变量将“屏蔽”外部函数的变量。

变量提升

JavaScript的函数定义有个特点,它会先扫描整个函数体的语句,把所有用var申明的变量“提升”到函数顶部:

1
2
3
4
5
6
7
function foo() {
var x = 'Hello, ' + y;
console.log(x);
var y = 'Bob';
}

foo();

虽然是strict模式,但语句var x = 'Hello, ' + y;并不报错,原因是变量y在稍后申明了。但是console.log显示Hello, undefined,说明变量y的值为undefined。这正是因为JavaScript引擎自动提升了变量y的声明,但不会提升变量y的赋值。

对于上述foo()函数,JavaScript引擎看到的代码相当于:

1
2
3
4
5
6
function foo() {
var y; // 提升变量y的申明,此时y为undefined
var x = 'Hello, ' + y;
console.log(x);
y = 'Bob';
}

由于JavaScript的这一怪异的“特性”,我们在函数内部定义变量时,请严格遵守“在函数内部首先申明所有变量”这一规则。最常见的做法是用一个var申明函数内部用到的所有变量:

1
2
3
4
5
6
7
8
9
10
function foo() {
var
x = 1, // x初始化为1
y = x + 1, // y初始化为2
z, i; // z和i为undefined
// 其他语句:
for (i=0; i<100; i++) {
...
}
}

如果不需要兼容低版本浏览器,完全可以用let代替var来申明变量。

注意

建议使用let申明变量,避免var申明变量时带来的隐患。

全局作用域

不在任何函数内定义的变量就具有全局作用域。实际上,JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性:

1
2
3
var course = 'Learn JavaScript';
console.log(course); // 'Learn JavaScript'
console.log(window.course); // 'Learn JavaScript'

因此,直接访问全局变量course和访问window.course是完全一样的。

你可能猜到了,由于函数定义有两种方式,以变量方式var foo = function () {}定义的函数实际上也是一个全局变量,因此,顶层函数的定义也被视为一个全局变量,并绑定到window对象:

1
2
3
4
5
6
function foo() {
alert('foo');
}

foo(); // 直接调用foo()
window.foo(); // 通过window.foo()调用

进一步大胆地猜测,我们每次直接调用的alert()函数其实也是window的一个变量:

1
2
3
4
5
6
7
8
9
10
11
window.alert('调用window.alert()');
// 把alert保存到另一个变量:
let old_alert = window.alert;
// 给alert赋一个新函数:
window.alert = function () {}

alert('无法用alert()显示了!');

// 恢复alert:
window.alert = old_alert;
alert('又可以用alert()了!');

这说明JavaScript实际上只有一个全局作用域。任何变量(函数也视为变量),如果没有在当前函数作用域中找到,就会继续往上查找,最后如果在全局作用域中也没有找到,则报ReferenceError错误。

名字空间

全局变量会绑定到window上,不同的JavaScript文件如果使用了相同的全局变量,或者定义了相同名字的顶层函数,都会造成命名冲突,并且很难被发现。

减少冲突的一个方法是把自己的所有变量和函数全部绑定到一个全局变量中。例如:

1
2
3
4
5
6
7
8
9
10
11
// 唯一的全局变量MYAPP:
let MYAPP = {};

// 其他变量:
MYAPP.name = 'myapp';
MYAPP.version = 1.0;

// 其他函数:
MYAPP.foo = function () {
return 'foo';
};

把自己的代码全部放入唯一的名字空间MYAPP中,会大大减少全局变量冲突的可能。

许多著名的JavaScript库都是这么干的:jQuery,YUI,underscore等等。

局部作用域

由于JavaScript的变量作用域实际上是函数内部,我们在for循环等语句块中是无法定义具有局部作用域的变量的:

1
2
3
4
5
6
function foo() {
for (var i=0; i<100; i++) {
//
}
i += 100; // 仍然可以引用变量i
}

为了解决块级作用域,ES6引入了新的关键字let,用let替代var可以申明一个块级作用域的变量:

1
2
3
4
5
6
7
8
function foo() {
let sum = 0;
for (let i=0; i<100; i++) {
sum += i;
}
// SyntaxError:
i += 1;
}

常量

由于varlet申明的是变量,如果要申明一个常量,在ES6之前是不行的,我们通常用全部大写的变量来表示“这是一个常量,不要修改它的值”:

1
let PI = 3.14;

ES6标准引入了新的关键字const来定义常量,constlet都具有块级作用域:

1
2
3
const PI = 3.14;
PI = 3; // 某些浏览器不报错,但是无效果!
PI; // 3.14

解构赋值

从ES6开始,JavaScript引入了解构赋值,可以同时对一组变量进行赋值。

什么是解构赋值?我们先看看传统的做法,如何把一个数组的元素分别赋值给几个变量:

1
2
3
4
let array = ['hello', 'JavaScript', 'ES6'];
let x = array[0];
let y = array[1];
let z = array[2];

现在,在ES6中,可以使用解构赋值,直接对多个变量同时赋值:

1
2
3
4
5
// 如果浏览器支持解构赋值就不会报错:
let [x, y, z] = ['hello', 'JavaScript', 'ES6'];

// x, y, z分别被赋值为数组对应元素:
console.log(`x = ${x}, y = ${y}, z = ${z}`);

注意,对数组元素进行解构赋值时,多个变量要用[...]括起来。

如果数组本身还有嵌套,也可以通过下面的形式进行解构赋值,注意嵌套层次和位置要保持一致:

1
2
3
4
let [x, [y, z]] = ['hello', ['JavaScript', 'ES6']];
x; // 'hello'
y; // 'JavaScript'
z; // 'ES6'

解构赋值还可以忽略某些元素:

1
2
let [, , z] = ['hello', 'JavaScript', 'ES6']; // 忽略前两个元素,只对z赋值第三个元素
z; // 'ES6'

如果需要从一个对象中取出若干属性,也可以使用解构赋值,便于快速获取对象的指定属性:

1
2
3
4
5
6
7
8
9
10
11
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678',
school: 'No.4 middle school'
};
let {name, age, passport} = person;

// name, age, passport分别被赋值为对应属性:
console.log(`name = ${name}, age = ${age}, passport = ${passport}`);

对一个对象进行解构赋值时,同样可以直接对嵌套的对象属性进行赋值,只要保证对应的层次是一致的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678',
school: 'No.4 middle school',
address: {
city: 'Beijing',
street: 'No.1 Road',
zipcode: '100001'
}
};
let {name, address: {city, zip}} = person;
name; // '小明'
city; // 'Beijing'
zip; // undefined, 因为属性名是zipcode而不是zip
// 注意: address不是变量,而是为了让city和zip获得嵌套的address对象的属性:
address; // Uncaught ReferenceError: address is not defined

使用解构赋值对对象属性进行赋值时,如果对应的属性不存在,变量将被赋值为undefined,这和引用一个不存在的属性获得undefined是一致的。如果要使用的变量名和属性名不一致,可以用下面的语法获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678',
school: 'No.4 middle school'
};

// 把passport属性赋值给变量id:
let {name, passport:id} = person;
name; // '小明'
id; // 'G-12345678'
// 注意: passport不是变量,而是为了让变量id获得passport属性:
passport; // Uncaught ReferenceError: passport is not defined

解构赋值还可以使用默认值,这样就避免了不存在的属性返回undefined的问题:

1
2
3
4
5
6
7
8
9
10
11
let person = {
name: '小明',
age: 20,
gender: 'male',
passport: 'G-12345678'
};

// 如果person对象没有single属性,默认赋值为true:
let {name, single=true} = person;
name; // '小明'
single; // true

有些时候,如果变量已经被声明了,再次赋值的时候,正确的写法也会报语法错误:

1
2
3
4
5
// 声明变量:
let x, y;
// 解构赋值:
{x, y} = { name: '小明', x: 100, y: 200};
// 语法错误: Uncaught SyntaxError: Unexpected token =

这是因为JavaScript引擎把{开头的语句当作了块处理,于是=不再合法。解决方法是用小括号括起来:

1
({x, y} = { name: '小明', x: 100, y: 200});

使用场景

解构赋值在很多时候可以大大简化代码。例如,交换两个变量xy的值,可以这么写,不再需要临时变量:

1
2
let x=1, y=2;
[x, y] = [y, x]

快速获取当前页面的域名和路径:

1
let {hostname:domain, pathname:path} = location;

如果一个函数接收一个对象作为参数,那么,可以使用解构直接把对象的属性绑定到变量中。例如,下面的函数可以快速创建一个Date对象:

1
2
3
function buildDate({year, month, day, hour=0, minute=0, second=0}) {
return new Date(`${year}-${month}-${day} ${hour}:${minute}:${second}`);
}

它的方便之处在于传入的对象只需要yearmonthday这三个属性:

1
2
buildDate({ year: 2017, month: 1, day: 1 });
// Sun Jan 01 2017 00:00:00 GMT+0800 (CST)

也可以传入hourminutesecond属性:

1
2
buildDate({ year: 2017, month: 1, day: 1, hour: 20, minute: 15 });
// Sun Jan 01 2017 20:15:00 GMT+0800 (CST)

使用解构赋值可以减少代码量,但是,需要在支持ES6解构赋值特性的现代浏览器中才能正常运行。目前支持解构赋值的浏览器包括Chrome,Firefox,Edge等。

在一个对象中绑定函数,称为这个对象的方法。

在JavaScript中,对象的定义是这样的:

1
2
3
4
let xiaoming = {
name: '小明',
birth: 1990
};

但是,如果我们给xiaoming绑定一个函数,就可以做更多的事情。比如,写个age()方法,返回xiaoming的年龄:

1
2
3
4
5
6
7
8
9
10
11
let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
let y = new Date().getFullYear();
return y - this.birth;
}
};

xiaoming.age; // function xiaoming.age()
xiaoming.age(); // 今年调用是25,明年调用就变成26了

绑定到对象上的函数称为方法,和普通函数也没啥区别,但是它在内部使用了一个this关键字,这个东东是什么?

在一个方法内部,this是一个特殊变量,它始终指向当前对象,也就是xiaoming这个变量。所以,this.birth可以拿到xiaomingbirth属性。

让我们拆开写:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getAge() {
let y = new Date().getFullYear();
return y - this.birth;
}

let xiaoming = {
name: '小明',
birth: 1990,
age: getAge
};

xiaoming.age(); // 25, 正常结果
getAge(); // NaN

单独调用函数getAge()怎么返回了NaN请注意,我们已经进入到了JavaScript的一个大坑里。

JavaScript的函数内部如果调用了this,那么这个this到底指向谁?

答案是,视情况而定!

如果以对象的方法形式调用,比如xiaoming.age(),该函数的this指向被调用的对象,也就是xiaoming,这是符合我们预期的。

如果单独调用函数,比如getAge(),此时,该函数的this指向全局对象,也就是window

坑爹啊!

更坑爹的是,如果这么写:

1
2
let fn = xiaoming.age; // 先拿到xiaoming的age函数
fn(); // NaN

也是不行的!要保证this指向正确,必须用obj.xxx()的形式调用!

由于这是一个巨大的设计错误,要想纠正可没那么简单。ECMA决定,在strict模式下让函数的this指向undefined,因此,在strict模式下,你会得到一个错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
'use strict';

let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
let y = new Date().getFullYear();
return y - this.birth;
}
};

let fn = xiaoming.age;
fn(); // Uncaught TypeError: Cannot read property 'birth' of undefined

这个决定只是让错误及时暴露出来,并没有解决this应该指向的正确位置。

有些时候,喜欢重构的你把方法重构了一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';

let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
function getAgeFromBirth() {
let y = new Date().getFullYear();
return y - this.birth;
}
return getAgeFromBirth();
}
};

xiaoming.age(); // Uncaught TypeError: Cannot read property 'birth' of undefined

结果又报错了!原因是this指针只在age方法的函数内指向xiaoming,在函数内部定义的函数,this又指向undefined了!(在非strict模式下,它重新指向全局对象window!)

修复的办法也不是没有,我们用一个that变量首先捕获this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'use strict';

let xiaoming = {
name: '小明',
birth: 1990,
age: function () {
let that = this; // 在方法内部一开始就捕获this
function getAgeFromBirth() {
let y = new Date().getFullYear();
return y - that.birth; // 用that而不是this
}
return getAgeFromBirth();
}
};

xiaoming.age(); // 25

let that = this;,你就可以放心地在方法内部定义其他函数,而不是把所有语句都堆到一个方法中。

apply

虽然在一个独立的函数调用中,根据是否是strict模式,this指向undefinedwindow,不过,我们还是可以控制this的指向的!

要指定函数的this指向哪个对象,可以用函数本身的apply方法,它接收两个参数,第一个参数就是需要绑定的this变量,第二个参数是Array,表示函数本身的参数。

apply修复getAge()调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getAge() {
let y = new Date().getFullYear();
return y - this.birth;
}

let xiaoming = {
name: '小明',
birth: 1990,
age: getAge
};

xiaoming.age(); // 25
getAge.apply(xiaoming, []); // 25, this指向xiaoming, 参数为空

另一个与apply()类似的方法是call(),唯一区别是:

  • apply()把参数打包成Array再传入;
  • call()把参数按顺序传入。

比如调用Math.max(3, 5, 4),分别用apply()call()实现如下:

1
2
Math.max.apply(null, [3, 5, 4]); // 5
Math.max.call(null, 3, 5, 4); // 5

对普通函数调用,我们通常把this绑定为null

装饰器

利用apply(),我们还可以动态改变函数的行为。

JavaScript的所有对象都是动态的,即使内置的函数,我们也可以重新指向新的函数。

现在假定我们想统计一下代码一共调用了多少次parseInt(),可以把所有的调用都找出来,然后手动加上count += 1,不过这样做太傻了。最佳方案是用我们自己的函数替换掉默认的parseInt()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict';

let count = 0;
let oldParseInt = parseInt; // 保存原函数

window.parseInt = function () {
count += 1;
return oldParseInt.apply(null, arguments); // 调用原函数
};

// 测试:
parseInt('10');
parseInt('20');
parseInt('30');
console.log('count = ' + count); // 3

高阶函数英文叫Higher-order function。那么什么是高阶函数?

JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。

一个最简单的高阶函数:

1
2
3
function add(x, y, f) {
return f(x) + f(y);
}

当我们调用add(-5, 6, Math.abs)时,参数xyf分别接收-56和函数Math.abs,根据函数定义,我们可以推导计算过程为:

1
2
3
4
5
x = -5;
y = 6;
f = Math.abs;
f(x) + f(y) ==> Math.abs(-5) + Math.abs(6) ==> 11;
return 11;

用代码验证一下:

1
2
3
4
5
6
function add(x, y, f) {
return f(x) + f(y);
}

let x = add(-5, 6, Math.abs);
console.log(x); // 11

编写高阶函数,就是让函数的参数能够接收别的函数。

如果你读过Google的那篇大名鼎鼎的论文“MapReduce: Simplified Data Processing on Large Clusters”,你就能大概明白map/reduce的概念。

map

举例说明,比如我们有一个函数f(x)=x2,要把这个函数作用在一个数组[1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
            f(x) = x * x



┌───┬───┬───┬───┼───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 2 3 4 5 6 7 8 9 ]

│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 4 9 16 25 36 49 64 81 ]

由于map()方法定义在JavaScript的Array中,我们调用Arraymap()方法,传入我们自己的函数,就得到了一个新的Array作为结果:

1
2
3
4
5
6
7
function pow(x) {
return x * x;
}

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let results = arr.map(pow); // [1, 4, 9, 16, 25, 36, 49, 64, 81]
console.log(results);

注意:map()传入的参数是pow,即函数对象本身。

你可能会想,不需要map(),写一个循环,也可以计算出结果:

1
2
3
4
5
6
7
8
9
let f = function (x) {
return x * x;
};

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let result = [];
for (let i=0; i<arr.length; i++) {
result.push(f(arr[i]));
}

的确可以,但是,从上面的循环代码,我们无法一眼看明白“把f(x)作用在Array的每一个元素并把结果生成一个新的Array”。

所以,map()作为高阶函数,事实上它把运算规则抽象了,因此,我们不但可以计算简单的f(x)=x2,还可以计算任意复杂的函数,比如,把Array的所有数字转为字符串:

1
2
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
arr.map(String); // ['1', '2', '3', '4', '5', '6', '7', '8', '9']

只需要一行代码。

reduce

再看reduce的用法。Array的reduce()把一个函数作用在这个Array[x1, x2, x3...]上,这个函数必须接收两个参数,reduce()把结果继续和序列的下一个元素做累积计算,其效果就是:

1
[x1, x2, x3, x4].reduce(f) = f(f(f(x1, x2), x3), x4)

比方说对一个Array求和,就可以用reduce实现:

1
2
3
4
let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
return x + y;
}); // 25

如果数组元素只有1个,那么还需要提供一个额外的初始参数以便至少凑够两:

1
2
3
4
let arr = [123];
arr.reduce(function (x, y) {
return x + y;
}, 0); // 123

练习:利用reduce()求积:

1
2
3
4
5
6
7
8
9
10
11
12
function product(arr) {
// FIXME:
return 0;
}

// 测试:
if (product([1, 2, 3, 4]) === 24 && product([0, 1, 2]) === 0 && product([99, 88, 77, 66]) === 44274384) {
console.log('测试通过!');
}
else {
console.log('测试失败!');
}

要把[1, 3, 5, 7, 9]变换成整数13579,reduce()也能派上用场:

1
2
3
4
let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
return x * 10 + y;
}); // 13579

如果我们继续改进这个例子,想办法把一个字符串13579先变成Array——[1, 3, 5, 7, 9],再利用reduce()就可以写出一个把字符串转换为Number的函数。

练习:不要使用JavaScript内置的parseInt()函数,利用map和reduce操作实现一个string2int()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function string2int(s) {
// FIXME:
return 0;
}

// 测试:
if (string2int('0') === 0 && string2int('12345') === 12345 && string2int('12300') === 12300) {
if (string2int.toString().indexOf('parseInt') !== -1) {
console.log('请勿使用parseInt()!');
} else if (string2int.toString().indexOf('Number') !== -1) {
console.log('请勿使用Number()!');
} else {
console.log('测试通过!');
}
}
else {
console.log('测试失败!');
}

练习

请把用户输入的不规范的英文名字,变为首字母大写,其他小写的规范名字。输入:['adam', 'LISA', 'barT'],输出:['Adam', 'Lisa', 'Bart']

1
2
3
4
5
6
7
8
9
10
11
12
function normalize(arr) {
// FIXME:
return [];
}

// 测试:
if (normalize(['adam', 'LISA', 'barT']).toString() === ['Adam', 'Lisa', 'Bart'].toString()) {
console.log('测试通过!');
}
else {
console.log('测试失败!');
}

小明希望利用map()把字符串变成整数,他写的代码很简洁:

1
2
3
4
5
let arr = ['1', '2', '3'];
let r;

r = arr.map(parseInt);
console.log(r);

结果竟然是1, NaN, NaN,小明百思不得其解,请帮他找到原因并修正代码。

提示:参考Array.prototype.map()的文档

原因分析:

由于map()接收的回调函数可以有3个参数:callback(currentValue, index, array),通常我们仅需要第一个参数,而忽略了传入的后面两个参数。不幸的是,parseInt(string, radix)没有忽略第二个参数,导致实际执行的函数分别是:

parseInt(‘1’, 0); // 1, 按十进制转换
parseInt(‘2’, 1); // NaN, 没有一进制
parseInt(‘3’, 2); // NaN, 按二进制转换不允许出现3
可以改为r = arr.map(Number);,因为Number(value)函数仅接收一个参数。

filter也是一个常用的操作,它用于把Array的某些元素过滤掉,然后返回剩下的元素。

map()类似,Arrayfilter()也接收一个函数。和map()不同的是,filter()把传入的函数依次作用于每个元素,然后根据返回值是true还是false决定保留还是丢弃该元素。

例如,在一个Array中,删掉偶数,只保留奇数,可以这么写:

1
2
3
4
5
let arr = [1, 2, 4, 5, 6, 9, 10, 15];
let r = arr.filter(function (x) {
return x % 2 !== 0;
});
r; // [1, 5, 9, 15]

把一个Array中的空字符串删掉,可以这么写:

1
2
3
4
5
let arr = ['A', '', 'B', null, undefined, 'C', '  '];
let r = arr.filter(function (s) {
return s && s.trim(); // 注意:IE9以下的版本没有trim()方法
});
r; // ['A', 'B', 'C']

可见用filter()这个高阶函数,关键在于正确实现一个“筛选”函数。

回调函数

filter()接收的回调函数,其实可以有多个参数。通常我们仅使用第一个参数,表示Array的某个元素。回调函数还可以接收另外两个参数,表示元素的位置和数组本身:

1
2
3
4
5
6
7
let arr = ['A', 'B', 'C'];
let r = arr.filter(function (element, index, self) {
console.log(element); // 依次打印'A', 'B', 'C'
console.log(index); // 依次打印0, 1, 2
console.log(self); // self就是变量arr
return true;
});

利用filter,可以巧妙地去除Array的重复元素:

1
2
3
4
5
6
7
8
9
let
r,
arr = ['apple', 'strawberry', 'banana', 'pear', 'apple', 'orange', 'orange', 'strawberry'];

r = arr.filter(function (element, index, self) {
return self.indexOf(element) === index;
});

console.log(r);

去除重复元素依靠的是indexOf总是返回第一个元素的位置,后续的重复元素位置与indexOf返回的位置不相等,因此被filter滤掉了。

练习

请尝试用filter()筛选出素数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function get_primes(arr) {
// FIXME:
return [];
}

// 测试:
let
x,
r,
arr = [];
for (x = 1; x < 100; x++) {
arr.push(x);
}
r = get_primes(arr);
if (r.toString() === [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97].toString()) {
console.log('测试通过!');
} else {
console.log('测试失败: ' + r.toString());
}

排序算法

排序也是在程序中经常用到的算法。无论使用冒泡排序还是快速排序,排序的核心是比较两个元素的大小。如果是数字,我们可以直接比较,但如果是字符串或者两个对象呢?直接比较数学上的大小是没有意义的,因此,比较的过程必须通过函数抽象出来。通常规定,对于两个元素xy,如果认为x < y,则返回-1,如果认为x == y,则返回0,如果认为x > y,则返回1,这样,排序算法就不用关心具体的比较过程,而是根据比较结果直接排序。

JavaScript的Arraysort()方法就是用于排序的,但是排序结果可能让你大吃一惊:

1
2
3
4
5
6
7
8
// 看上去正常的结果:
['Google', 'Apple', 'Microsoft'].sort(); // ['Apple', 'Google', 'Microsoft'];

// apple排在了最后:
['Google', 'apple', 'Microsoft'].sort(); // ['Google', 'Microsoft", 'apple']

// 无法理解的结果:
[10, 20, 1, 2].sort(); // [1, 10, 2, 20]

第二个排序把apple排在了最后,是因为字符串根据ASCII码进行排序,而小写字母a的ASCII码在大写字母之后。

第三个排序结果是什么鬼?简单的数字排序都能错?

这是因为Arraysort()方法默认把所有元素先转换为String再排序,结果'10'排在了'2'的前面,因为字符'1'比字符'2'的ASCII码小。

douwo

如果不知道sort()方法的默认排序规则,直接对数字排序,绝对栽进坑里!

幸运的是,sort()方法也是一个高阶函数,它还可以接收一个比较函数来实现自定义的排序。

要按数字大小排序,我们可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
let arr = [10, 20, 1, 2];

arr.sort(function (x, y) {
if (x < y) {
return -1;
}
if (x > y) {
return 1;
}
return 0;
});

console.log(arr); // [1, 2, 10, 20]

如果要倒序排序,我们可以把大的数放前面:

1
2
3
4
let arr = [10, 20, 1, 2];
arr.sort(function (x, y) {
return y - x;
}); // [20, 10, 2, 1]

sort()传入的比较函数接受xy两个参数,如果x<y,应返回负数,如果x>y,应返回正数,如果x=y,应返回0。

默认情况下,对字符串排序,是按照ASCII的大小比较的,现在,我们提出排序应该忽略大小写,按照字母序排序。要实现这个算法,不必对现有代码大加改动,只要我们能定义出忽略大小写的比较算法就可以:

1
2
3
4
5
6
7
8
9
10
11
12
let arr = ['Google', 'apple', 'Microsoft'];
arr.sort(function (s1, s2) {
x1 = s1.toUpperCase();
x2 = s2.toUpperCase();
if (x1 < x2) {
return -1;
}
if (x1 > x2) {
return 1;
}
return 0;
}); // ['apple', 'Google', 'Microsoft']

忽略大小写来比较两个字符串,实际上就是先把字符串都变成大写(或者都变成小写),再比较。

从上述例子可以看出,高阶函数的抽象能力是非常强大的,而且,核心代码可以保持得非常简洁。

最后友情提示,sort()方法会直接对Array进行修改,它返回的结果仍是当前Array

1
2
3
4
5
let a1 = ['B', 'A', 'C'];
let a2 = a1.sort();
a1; // ['A', 'B', 'C']
a2; // ['A', 'B', 'C']
a1 === a2; // true, a1和a2是同一对象

Array

对于数组,除了map()reducefilter()sort()这些方法可以传入一个函数外,Array对象还提供了很多非常实用的高阶函数。

every

every()方法可以判断数组的所有元素是否满足测试条件。

例如,给定一个包含若干字符串的数组,判断所有字符串是否满足指定的测试条件:

1
2
3
4
5
6
7
8
let arr = ['Apple', 'pear', 'orange'];
console.log(arr.every(function (s) {
return s.length > 0;
})); // true, 因为每个元素都满足s.length>0

console.log(arr.every(function (s) {
return s.toLowerCase() === s;
})); // false, 因为不是每个元素都全部是小写

find

find()方法用于查找符合条件的第一个元素,如果找到了,返回这个元素,否则,返回undefined

1
2
3
4
5
6
7
8
let arr = ['Apple', 'pear', 'orange'];
console.log(arr.find(function (s) {
return s.toLowerCase() === s;
})); // 'pear', 因为pear全部是小写

console.log(arr.find(function (s) {
return s.toUpperCase() === s;
})); // undefined, 因为没有全部是大写的元素

findIndex

findIndex()find()类似,也是查找符合条件的第一个元素,不同之处在于findIndex()会返回这个元素的索引,如果没有找到,返回-1

1
2
3
4
5
6
7
8
let arr = ['Apple', 'pear', 'orange'];
console.log(arr.findIndex(function (s) {
return s.toLowerCase() === s;
})); // 1, 因为'pear'的索引是1

console.log(arr.findIndex(function (s) {
return s.toUpperCase() === s;
})); // -1

forEach

forEach()map()类似,它也把每个元素依次作用于传入的函数,但不会返回新的数组。forEach()常用于遍历数组,因此,传入的函数不需要返回值:

1
2
let arr = ['Apple', 'pear', 'orange'];
arr.forEach(x=>console.log(x)); // 依次打印每个元素


函数作为返回值

高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回。

我们来实现一个对Array的求和。通常情况下,求和的函数是这样定义的:

1
2
3
4
5
6
7
function sum(arr) {
return arr.reduce(function (x, y) {
return x + y;
});
}

sum([1, 2, 3, 4, 5]); // 15

但是,如果不需要立刻求和,而是在后面的代码中,根据需要再计算怎么办?可以不返回求和的结果,而是返回求和的函数!

1
2
3
4
5
6
7
8
function lazy_sum(arr) {
let sum = function () {
return arr.reduce(function (x, y) {
return x + y;
});
}
return sum;
}

当我们调用lazy_sum()时,返回的并不是求和结果,而是求和函数:

1
let f = lazy_sum([1, 2, 3, 4, 5]); // function sum()

调用函数f时,才真正计算求和的结果:

1
f(); // 15

在这个例子中,我们在函数lazy_sum中又定义了函数sum,并且,内部函数sum可以引用外部函数lazy_sum的参数和局部变量,当lazy_sum返回函数sum时,相关参数和变量都保存在返回的函数中,这种称为“闭包(Closure)”的程序结构拥有极大的威力。

请再注意一点,当我们调用lazy_sum()时,每次调用都会返回一个新的函数,即使传入相同的参数:

1
2
3
let f1 = lazy_sum([1, 2, 3, 4, 5]);
let f2 = lazy_sum([1, 2, 3, 4, 5]);
f1 === f2; // false

f1()f2()的调用结果互不影响。

闭包

注意到返回的函数在其定义内部引用了局部变量arr,所以,当一个函数返回了一个函数后,其内部的局部变量还被新函数引用,所以,闭包用起来简单,实现起来可不容易。

另一个需要注意的问题是,返回的函数并没有立刻执行,而是直到调用了f()才执行。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
function count() {
let arr = [];
for (var i=1; i<=3; i++) {
arr.push(function () {
return i * i;
});
}
return arr;
}

let results = count();
let [f1, f2, f3] = results;

在上面的例子中,每次循环,都创建了一个新的函数,然后,把创建的3个函数都添加到一个Array中返回了。

你可能认为调用f1()f2()f3()结果应该是149,但实际结果是:

1
2
3
f1(); // 16
f2(); // 16
f3(); // 16

全部都是16!原因就在于返回的函数引用了用var定义的变量i,但它并非立刻执行。等到3个函数都返回时,它们所引用的变量i已经变成了4,因此最终结果为16

返回闭包时牢记的一点就是:返回函数不要引用任何循环变量,或者后续会发生变化的变量。

如果一定要引用循环变量怎么办?方法是再创建一个函数,用该函数的参数绑定循环变量当前的值,无论该循环变量后续如何更改,已绑定到函数参数的值不变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function count() {
let arr = [];
for (var i=1; i<=3; i++) {
arr.push((function (n) {
return function () {
return n * n;
}
})(i));
}
return arr;
}

let [f1, f2, f3] = count();

f1(); // 1
f2(); // 4
f3(); // 9

注意这里用了一个“创建一个匿名函数并立刻执行”的语法:

1
2
3
(function (x) {
return x * x;
})(3); // 9

理论上讲,创建一个匿名函数并立刻执行可以这么写:

1
function (x) { return x * x } (3);

但是由于JavaScript语法解析的问题,会报SyntaxError错误,因此需要用括号把整个函数定义括起来:

1
(function (x) { return x * x }) (3);

通常,一个立即执行的匿名函数可以把函数体拆开,一般这么写:

1
2
3
(function (x) {
return x * x;
})(3);

另一个方法是把循环变量ilet定义在for循环体中,let作用域决定了在每次循环时都会绑定新的i

1
2
3
4
5
6
7
8
9
function count() {
let arr = [];
for (let i=1; i<=3; i++) {
arr.push(function () {
return i * i;
});
}
return arr;
}

但如果i定义在for循环外面,则仍然是错误的:

1
2
3
4
5
6
7
8
9
10
function count() {
let arr = [];
let i;
for (i=1; i<=3; i++) {
arr.push(function () {
return i * i;
});
}
return arr;
}

因此,最好的办法还是返回函数不要引用任何循环变量。

说了这么多,难道闭包就是为了返回一个函数然后延迟执行吗?

当然不是!闭包有非常强大的功能。举个栗子:

在面向对象的程序设计语言里,比如Java和C++,要在对象内部封装一个私有变量,可以用private修饰一个成员变量。

在没有class机制,只有函数的语言里,借助闭包,同样可以封装一个私有变量。我们用JavaScript创建一个计数器:

1
2
3
4
5
6
7
8
9
function create_counter(initial) {
let x = initial || 0;
return {
inc: function () {
x += 1;
return x;
}
}
}

它用起来像这样:

1
2
3
4
5
6
7
8
9
let c1 = create_counter();
c1.inc(); // 1
c1.inc(); // 2
c1.inc(); // 3

let c2 = create_counter(10);
c2.inc(); // 11
c2.inc(); // 12
c2.inc(); // 13

在返回的对象中,实现了一个闭包,该闭包携带了局部变量x,并且,从外部代码根本无法访问到变量x。换句话说,闭包就是携带状态的函数,并且它的状态可以完全对外隐藏起来。

闭包还可以把多参数的函数变成单参数的函数。例如,要计算xy可以用Math.pow(x, y)函数,不过考虑到经常计算x2或x3,我们可以利用闭包创建新的函数pow2pow3

1
2
3
4
5
6
7
8
9
10
11
12
function make_pow(n) {
return function (x) {
return Math.pow(x, n);
}
}

// 创建两个新函数:
let pow2 = make_pow(2);
let pow3 = make_pow(3);

console.log(pow2(5)); // 25
console.log(pow3(7)); // 343

廖雪峰
资深软件开发工程师,业余马拉松选手。

ES6标准新增了一种新的函数:箭头函数(Arrow Function)。

为什么叫箭头函数?因为它的定义用的就是一个箭头:

1
x => x * x

上面的箭头函数相当于:

1
2
3
function (x) {
return x * x;
}

在继续学习箭头函数之前,请测试你的浏览器是否支持ES6的Arrow Function:

1
2
let fn = x => x * x;
console.log('你的浏览器支持ES6的Arrow Function!');

箭头函数相当于匿名函数,并且简化了函数定义。箭头函数有两种格式,一种像上面的,只包含一个表达式,连{ ... }return都省略掉了。还有一种可以包含多条语句,这时候就不能省略{ ... }return

1
2
3
4
5
6
7
8
x => {
if (x > 0) {
return x * x;
}
else {
return - x * x;
}
}

如果参数不是一个,就需要用括号()括起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 两个参数:
(x, y) => x * x + y * y

// 无参数:
() => 3.14

// 可变参数:
(x, y, ...rest) => {
let i, sum = x + y;
for (i=0; i<rest.length; i++) {
sum += rest[i];
}
return sum;
}

如果要返回一个对象,就要注意,如果是单表达式,这么写的话会报错:

1
2
// SyntaxError:
x => { foo: x }

因为和函数体的{ ... }有语法冲突,所以要改为:

1
2
// ok:
x => ({ foo: x })

this

箭头函数看上去是匿名函数的一种简写,但实际上,箭头函数和匿名函数有个明显的区别:箭头函数内部的this是词法作用域,由上下文确定。

回顾前面的例子,由于JavaScript函数对this绑定的错误处理,下面的例子无法得到预期结果:

1
2
3
4
5
6
7
8
9
10
let obj = {
birth: 1990,
getAge: function () {
let b = this.birth; // 1990
let fn = function () {
return new Date().getFullYear() - this.birth; // this指向window或undefined
};
return fn();
}
};

现在,箭头函数完全修复了this的指向,this总是指向词法作用域,也就是外层调用者obj

1
2
3
4
5
6
7
8
9
let obj = {
birth: 1990,
getAge: function () {
let b = this.birth; // 1990
let fn = () => new Date().getFullYear() - this.birth; // this指向obj对象
return fn();
}
};
obj.getAge(); // 25

如果使用箭头函数,以前的那种hack写法:

1
let that = this;

就不再需要了。

由于this在箭头函数中已经按照词法作用域绑定了,所以,用call()或者apply()调用箭头函数时,无法对this进行绑定,即传入的第一个参数被忽略:

1
2
3
4
5
6
7
8
9
let obj = {
birth: 1990,
getAge: function (year) {
let b = this.birth; // 1990
let fn = (y) => y - this.birth; // this.birth仍是1990
return fn.call({birth:2000}, year);
}
};
obj.getAge(2015); // 25

练习

请使用箭头函数简化排序时传入的函数:

1
2
3
4
5
let arr = [10, 20, 1, 2];
arr.sort((x, y) => {
???
});
console.log(arr); // [1, 2, 10, 20]

标签函数

廖雪峰
资深软件开发工程师,业余马拉松选手。

前面我们介绍了模板字符串,它可以非常方便地引用变量,并合并出最终的字符串:

1
2
3
4
let name = '小明';
let age = 20;
let s = `你好, ${name}, 你今年${age}岁了!`;
console.log(s);

对于模板字符串,除了方便引用变量构造字符串外,还有一种更强大的功能,即可以使用标签函数(Tag Function)。

什么是标签函数?让我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const email = "test@example.com";
const password = 'hello123';

function sql(strings, ...exps) {
console.log(`SQL: ${strings.join('?')}`);
console.log(`SQL parameters: ${JSON.stringify(exps)}`);
return {
name: '小明',
age: 20
};
}

const result = sql`SELECT * FROM users WHERE email=${email} AND password=${password}`;

console.log(JSON.stringify(result));

这里出现了一个奇怪的语法:

1
sql`SELECT * FROM users WHERE email=${email} AND password=${password}`

模板字符串前面以sql开头,实际上这是一个标签函数,上述语法会自动转换为对sql()函数的调用。我们关注的是,传入sql()函数的参数是什么。

sql()函数实际上接收两个参数:

第一个参数strings是一个字符串数组,它是["SELECT * FROM users WHERE email=", " AND password=", ""],即除去${xxx}剩下的字符组成的数组;

第二个参数...exps是一个可变参数,它接收的也是一个数组,但数组的内容是由模板字符串里所有的${xxx}的实际值组成,即["test@example.com", "hello123"],因为解析${email}得到"test@example.com",解析${password}得到"hello123"

标签函数sql()实际上是一个普通函数,我们在内部把strings拼接成一个SQL字符串,把...exps作为参数,就可以实现一个安全的SQL查询,并返回查询结果。此处并没有真正的数据库连接,因此返回一个固定的Object。

标签函数和普通函数的定义区别仅仅在于参数,如果我们想对数据库进行修改,完全可以定义一个标签函数如下:

1
2
3
4
5
function update(strings, ...exps) {
let sql = strings.join('?');
// 执行数据库更新
// TODO:
}

函数调用可以简化为带标签的模板字符串:

1
2
3
4
5
let id = 123;
let age = 21;
let score = 'A';

update`UPDATE users SET age=${age}, score=${score} WHERE id=${id}`;

是不是非常简洁?



廖雪峰
资深软件开发工程师,业余马拉松选手。

生成器(generator)是ES6标准引入的新的数据类型。一个生成器看上去像一个函数,但可以返回多次。

ES6定义生成器标准的哥们借鉴了Python的generator的概念和语法,如果你对Python的generator很熟悉,那么ES6的generator就是小菜一碟了。如果你对Python还不熟,赶快恶补Python教程!。

我们先复习函数的概念。一个函数是一段完整的代码,调用一个函数就是传入参数,然后返回结果:

1
2
3
4
5
function foo(x) {
return x + x;
}

let r = foo(1); // 调用foo函数

函数在执行过程中,如果没有遇到return语句(函数末尾如果没有return,就是隐含的return undefined;),控制权无法交回被调用的代码。

generator跟函数很像,定义如下:

1
2
3
4
5
function* foo(x) {
yield x + 1;
yield x + 2;
return x + 3;
}

generator和函数不同的是,generator由function*定义(注意多出的*号),并且,除了return语句,还可以用yield返回多次。

大多数同学立刻就晕了,generator就是能够返回多次的“函数”?返回多次有啥用?

还是举个栗子吧。

我们以一个著名的斐波那契数列为例,它由01开头:

1
0 1 1 2 3 5 8 13 21 34 ...

要编写一个产生斐波那契数列的函数,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fib(max) {
let
t,
a = 0,
b = 1,
arr = [0, 1];
while (arr.length < max) {
[a, b] = [b, a + b];
arr.push(b);
}
return arr;
}

// 测试:
fib(5); // [0, 1, 1, 2, 3]
fib(10); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

函数只能返回一次,所以必须返回一个Array。但是,如果换成generator,就可以一次返回一个数,不断返回多次。用generator改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function* fib(max) {
let
t,
a = 0,
b = 1,
n = 0;
while (n < max) {
yield a;
[a, b] = [b, a + b];
n ++;
}
return;
}

直接调用试试:

1
fib(5); // fib {[[GeneratorStatus]]: "suspended", [[GeneratorReceiver]]: Window}

直接调用一个generator和调用函数不一样,fib(5)仅仅是创建了一个generator对象,还没有去执行它。

调用generator对象有两个方法,一是不断地调用generator对象的next()方法:

1
2
3
4
5
6
7
let f = fib(5);
f.next(); // {value: 0, done: false}
f.next(); // {value: 1, done: false}
f.next(); // {value: 1, done: false}
f.next(); // {value: 2, done: false}
f.next(); // {value: 3, done: false}
f.next(); // {value: undefined, done: true}

next()方法会执行generator的代码,然后,每次遇到yield x;就返回一个对象{value: x, done: true/false},然后“暂停”。返回的value就是yield的返回值,done表示这个generator是否已经执行结束了。如果donetrue,则value就是return的返回值。

当执行到donetrue时,这个generator对象就已经全部执行完毕,不要再继续调用next()了。

第二个方法是直接用for ... of循环迭代generator对象,这种方式不需要我们自己判断done

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* fib(max) {
let
a = 0,
b = 1,
n = 0;
while (n < max) {
yield a;
[a, b] = [b, a + b];
n ++;
}
return;
}

for (let x of fib(10)) {
console.log(x); // 依次输出0, 1, 1, 2, 3, ...
}

generator和普通函数相比,有什么用?

因为generator可以在执行过程中多次返回,所以它看上去就像一个可以记住执行状态的函数,利用这一点,写一个generator就可以实现需要用面向对象才能实现的功能。例如,用一个对象来保存状态,得这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let fib = {
a: 0,
b: 1,
n: 0,
max: 5,
next: function () {
let
r = this.a,
t = this.a + this.b;
this.a = this.b;
this.b = t;
if (this.n < this.max) {
this.n ++;
return r;
} else {
return undefined;
}
}
};

用对象的属性来保存状态,相当繁琐。

generator还有另一个巨大的好处,就是把异步回调代码变成“同步”代码。这个好处要等到后面学了AJAX以后才能体会到。

没有generator之前的黑暗时代,用AJAX时需要这么写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ajax('http://url-1', data1, function (err, result) {
if (err) {
return handle(err);
}
ajax('http://url-2', data2, function (err, result) {
if (err) {
return handle(err);
}
ajax('http://url-3', data3, function (err, result) {
if (err) {
return handle(err);
}
return success(result);
});
});
});

回调越多,代码越难看。

有了generator的美好时代,用AJAX时可以这么写:

1
2
3
4
5
6
7
8
9
try {
r1 = yield ajax('http://url-1', data1);
r2 = yield ajax('http://url-2', data2);
r3 = yield ajax('http://url-3', data3);
success(r3);
}
catch (err) {
handle(err);
}

看上去是同步的代码,实际执行是异步的。

练习

要生成一个自增的ID,可以编写一个next_id()函数:

1
2
3
4
5
6
let current_id = 0;

function next_id() {
current_id ++;
return current_id;
}

由于函数无法保存状态,故需要一个全局变量current_id来保存数字。

不用闭包,试用generator改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function* next_id() {
???
}

// 测试:
let
x,
pass = true,
g = next_id();
for (x = 1; x < 100; x ++) {
if (g.next().value !== x) {
pass = false;
console.log('测试失败!');
break;
}
}
if (pass) {
console.log('测试通过!');
}

留言與分享

JavaScript-快速入门

分類 编程语言, JavaScript

JavaScript代码可以直接嵌在网页的任何地方,不过通常我们都把JavaScript代码放到<head>中:

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<script>
alert('Hello, world');
</script>
</head>
<body>
...
</body>
</html>

<script>...</script>包含的代码就是JavaScript代码,它将直接被浏览器执行。

第二种方法是把JavaScript代码放到一个单独的.js文件,然后在HTML中通过<script src="..."></script>引入这个文件:

1
2
3
4
5
6
7
8
<html>
<head>
<script src="/static/js/abc.js"></script>
</head>
<body>
...
</body>
</html>

这样,/static/js/abc.js就会被浏览器执行。

把JavaScript代码放入一个单独的.js文件中更利于维护代码,并且多个页面可以各自引用同一份.js文件。

可以在同一个页面中引入多个.js文件,还可以在页面中多次编写<script> js代码... </script>,浏览器按照顺序依次执行。

有些时候你会看到<script>标签还设置了一个type属性:

1
2
3
<script type="text/javascript">
...
</script>

但这是没有必要的,因为默认的type就是JavaScript,所以不必显式地把type指定为JavaScript。

如何编写JavaScript

可以用任何文本编辑器来编写JavaScript代码。这里我们推荐以下几种文本编辑器:

Visual Studio Code

微软出的Visual Studio Code,可以看做迷你版Visual Studio,免费,跨平台,内置JavaScript支持,强烈推荐使用!

Sublime Text

Sublime Text是一个好用的文本编辑器,免费,但不注册会不定时弹出提示框。

注意:不可以用Word或写字板来编写JavaScript或HTML,因为带格式的文本保存后不是纯文本文件,无法被浏览器正常读取。也尽量不要用记事本编写,它会自作聪明地在保存UTF-8格式文本时添加BOM头。

如何运行JavaScript

要让浏览器运行JavaScript,必须先有一个HTML页面,在HTML页面中引入JavaScript,然后,让浏览器加载该HTML页面,就可以执行JavaScript代码。

你也许会想,直接在我的硬盘上创建好HTML和JavaScript文件,然后用浏览器打开,不就可以看到效果了吗?

这种方式运行部分JavaScript代码没有问题,但由于浏览器的安全限制,以file://开头的地址无法执行如联网等JavaScript代码,最终,你还是需要架设一个Web服务器,然后以http://开头的地址来正常执行所有JavaScript代码。

不过,开始学习阶段,你无须关心如何搭建开发环境的问题,我们提供在页面输入JavaScript代码并直接运行的功能,让你专注于JavaScript的学习。

试试直接点击“Run”按钮执行下面的JavaScript代码:

1
2
3
4
5
// 以双斜杠开头直到行末的是注释,注释是给人看的,会被浏览器忽略
/* 在这中间的也是注释,将被浏览器忽略 */
// 第一个JavaScript代码:

alert('Hello, world'); // 观察执行效果

浏览器将弹出一个对话框,显示“Hello, world”。你也可以修改两个单引号中间的内容,再试着运行。

调试

俗话说得好,“工欲善其事,必先利其器。”,写JavaScript的时候,如果期望显示ABC,结果却显示XYZ,到底代码哪里出了问题?不要抓狂,也不要泄气,作为小白,要坚信:JavaScript本身没有问题,浏览器执行也没有问题,有问题的一定是我的代码。

如何找出问题代码?这就需要调试。

怎么在浏览器中调试JavaScript代码呢?

首先,你需要安装Google Chrome浏览器,Chrome浏览器对开发者非常友好,可以让你方便地调试JavaScript代码。从这里下载Chrome浏览器。打开网页出问题的童鞋请移步国内镜像

安装后,随便打开一个网页,然后点击菜单“查看(View)”-“开发者(Developer)”-“开发者工具(Developer Tools)”,浏览器窗口就会一分为二,下方就是开发者工具:

chrome dev tools

先点击“控制台(Console)“,在这个面板里可以直接输入JavaScript代码,按回车后执行。

要查看一个变量的内容,在Console中输入console.log(a);,回车后显示的值就是变量的内容。

关闭Console请点击右上角的“×”按钮。请熟练掌握Console的使用方法,在编写JavaScript代码时,经常需要在Console运行测试代码。

如果你对自己还有更高的要求,可以研究开发者工具的“源码(Sources)”,掌握断点、单步执行等高级调试技巧。

练习

打开新浪首页,然后查看页面源代码,找一找引入的JavaScript文件和直接编写在页面中的JavaScript代码。然后在Chrome中打开开发者工具,在控制台输入console.log('Hello');,回车查看JavaScript代码执行结果。

基本语法

JavaScript的语法和Java语言类似,每个语句以;结束,语句块用{...}。但是,JavaScript并不强制要求在每个语句的结尾加;,浏览器中负责执行JavaScript代码的引擎会自动在每个语句的结尾补上;

提示

让JavaScript引擎自动加分号在某些情况下会改变程序的语义,导致运行结果与期望不一致。在本教程中,我们不会省略;,所有语句都会添加;。

例如,下面的一行代码就是一个完整的赋值语句:

1
var x = 1;

下面的一行代码是一个字符串,但仍然可以视为一个完整的语句:

1
'Hello, world';

下面的一行代码包含两个语句,每个语句用;表示语句结束:

1
var x = 1; var y = 2; // 不建议一行写多个语句!

语句块是一组语句的集合,例如,下面的代码先做了一个判断,如果判断成立,将执行{...}中的所有语句:

1
2
3
4
5
if (2 > 1) {
x = 1;
y = 2;
z = 3;
}

注意花括号{...}内的语句具有缩进,通常是4个空格。缩进不是JavaScript语法要求必须的,但缩进有助于我们理解代码的层次,所以编写代码时要遵守缩进规则。很多文本编辑器具有“自动缩进”的功能,可以帮助整理代码。

{...}还可以嵌套,形成层级结构:

1
2
3
4
5
6
7
8
9
10
11
if (2 > 1) {
x = 1;
y = 2;
z = 3;
if (x < y) {
z = 4;
}
if (x > y) {
z = 5;
}
}

JavaScript本身对嵌套的层级没有限制,但是过多的嵌套无疑会大大增加看懂代码的难度。遇到这种情况,需要把部分代码抽出来,作为函数来调用,这样可以减少代码的复杂度。

注释

//开头直到行末的字符被视为行注释,注释是给开发人员看到,JavaScript引擎会自动忽略:

1
2
// 这是一行注释
alert('hello'); // 这也是注释

另一种块注释是用/*...*/把多行字符包裹起来,把一大“块”视为一个注释:

1
2
3
4
/* 从这里开始是块注释
仍然是注释
仍然是注释
注释结束 */

练习

分别利用行注释和块注释把下面的语句注释掉,使它不再执行:

1
2
3
// 请注释掉下面的语句:
alert('我不想执行');
alert('我也不想执行');

大小写

请注意,JavaScript严格区分大小写,如果弄错了大小写,程序将报错或者运行不正常。



数据类型

计算机顾名思义就是可以做数学计算的机器,因此,计算机程序理所当然地可以处理各种数值。但是,计算机能处理的远不止数值,还可以处理文本、图形、音频、视频、网页等各种各样的数据,不同的数据,需要定义不同的数据类型。在JavaScript中定义了以下几种数据类型:

Number

JavaScript不区分整数和浮点数,统一用Number表示,以下都是合法的Number类型:

1
2
3
4
5
6
123; // 整数123
0.456; // 浮点数0.456
1.2345e3; // 科学计数法表示1.2345x1000,等同于1234.5
-99; // 负数
NaN; // NaN表示Not a Number,当无法计算结果时用NaN表示
Infinity; // Infinity表示无限大,当数值超过了JavaScript的Number所能表示的最大值时,就表示为Infinity

计算机由于使用二进制,所以,有时候用十六进制表示整数比较方便,十六进制用0x前缀和0-9,a-f表示,例如:0xff000xa5b4c3d2,等等,它们和十进制表示的数值完全一样。

Number可以直接做四则运算,规则和数学一致:

1
2
3
4
5
6
1 + 2; // 3
(1 + 2) * 5 / 2; // 7.5
2 / 0; // Infinity
0 / 0; // NaN
10 % 3; // 1
10.5 % 3; // 1.5

注意%是求余运算。

要注意,JavaScript的Number不区分整数和浮点数,也就是说,12.00 === 12。(在大多数其他语言中,整数和浮点数不能直接比较)并且,JavaScript的整数最大范围不是±263,而是±253,因此,超过253的整数就可能无法精确表示:

1
2
3
4
5
6
7
// 计算圆面积:
var r = 123.456;
var s = 3.14 * r * r;
console.log(s); // 47857.94555904001

// 打印Number能表示的最大整数:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991

字符串

字符串是以单引号’或双引号"括起来的任意文本,比如'abc'"xyz"等等。请注意,''""本身只是一种表示方式,不是字符串的一部分,因此,字符串'abc'只有abc这3个字符。

布尔值

布尔值和布尔代数的表示完全一致,一个布尔值只有truefalse两种值,要么是true,要么是false,可以直接用truefalse表示布尔值,也可以通过布尔运算计算出来:

1
2
3
4
true; // 这是一个true值
false; // 这是一个false值
2 > 1; // 这是一个true值
2 >= 3; // 这是一个false值

&&运算是与运算,只有所有都为true&&运算结果才是true

1
2
3
true && true; // 这个&&语句计算结果为true
true && false; // 这个&&语句计算结果为false
false && true && false; // 这个&&语句计算结果为false

||运算是或运算,只要其中有一个为true||运算结果就是true

1
2
3
false || false; // 这个||语句计算结果为false
true || false; // 这个||语句计算结果为true
false || true || false; // 这个||语句计算结果为true

!运算是非运算,它是一个单目运算符,把true变成falsefalse变成true

1
2
3
! true; // 结果为false
! false; // 结果为true
! (2 > 5); // 结果为true

布尔值经常用在条件判断中,比如:

1
2
3
4
5
6
var age = 15;
if (age >= 18) {
alert('adult');
} else {
alert('teenager');
}

比较运算符

当我们对Number做比较时,可以通过比较运算符得到一个布尔值:

1
2
3
2 > 5; // false
5 >= 2; // true
7 == 7; // true

实际上,JavaScript允许对任意数据类型做比较:

1
2
false == 0; // true
false === 0; // false

要特别注意相等运算符==。JavaScript在设计时,有两种比较运算符:

第一种是==比较,它会自动转换数据类型再比较,很多时候,会得到非常诡异的结果;

第二种是===比较,它不会自动转换数据类型,如果数据类型不一致,返回false,如果一致,再比较。

由于JavaScript这个设计缺陷,不要使用==比较,始终坚持使用===比较。

另一个例外是NaN这个特殊的Number与所有其他值都不相等,包括它自己:

1
NaN === NaN; // false

唯一能判断NaN的方法是通过isNaN()函数:

1
isNaN(NaN); // true

最后要注意浮点数的相等比较:

1
1 / 3 === (1 - 2 / 3); // false

这不是JavaScript的设计缺陷。浮点数在运算过程中会产生误差,因为计算机无法精确表示无限循环小数。要比较两个浮点数是否相等,只能计算它们之差的绝对值,看是否小于某个阈值:

1
Math.abs(1 / 3 - (1 - 2 / 3)) < 0.0000001; // true

BigInt

要精确表示比253还大的整数,可以使用内置的BigInt类型,它的表示方法是在整数后加一个n,例如9223372036854775808n,也可以使用BigInt()把Number和字符串转换成BigInt:

1
2
3
4
5
6
7
// 使用BigInt:
var bi1 = 9223372036854775807n;
var bi2 = BigInt(12345);
var bi3 = BigInt("0x7fffffffffffffff");
console.log(bi1 === bi2); // false
console.log(bi1 === bi3); // true
console.log(bi1 + bi2);

使用BigInt可以正常进行加减乘除等运算,结果仍然是一个BigInt,但不能把一个BigInt和一个Number放在一起运算:

1
2
3
4
5
// 使用BigInt:
console.log(1234567n + 3456789n); // OK
console.log(1234567n / 789n); // 1564, 除法运算结果仍然是BigInt
console.log(1234567n % 789n); // 571, 求余
console.log(1234567n + 3456789); // Uncaught TypeError: Cannot mix BigInt and other types

null和undefined

null表示一个“空”的值,它和0以及空字符串''不同,0是一个数值,''表示长度为0的字符串,而null表示“空”。

在其他语言中,也有类似JavaScript的null的表示,例如Java也用null,Swift用nil,Python用None表示。但是,在JavaScript中,还有一个和null类似的undefined,它表示“未定义”。

JavaScript的设计者希望用null表示一个空的值,而undefined表示值未定义。事实证明,这并没有什么卵用,区分两者的意义不大。大多数情况下,我们都应该用nullundefined仅仅在判断函数参数是否传递的情况下有用。

数组

数组是一组按顺序排列的集合,集合的每个值称为元素。JavaScript的数组可以包括任意数据类型。例如:

1
[1, 2, 3.14, 'Hello', null, true];

上述数组包含6个元素。数组用[]表示,元素之间用,分隔。

另一种创建数组的方法是通过Array()函数实现:

1
new Array(1, 2, 3); // 创建了数组[1, 2, 3]

然而,出于代码的可读性考虑,强烈建议直接使用[]

数组的元素可以通过索引来访问。请注意,索引的起始值为0

1
2
3
4
5
var arr = [1, 2, 3.14, 'Hello', null, true];
arr[0]; // 返回索引为0的元素,即1
arr[5]; // 返回索引为5的元素,即true
arr[6]; // 索引超出了范围,返回undefined
console.log(arr[0], arr[5], arr[6]);

对象

JavaScript的对象是一组由键-值组成的无序集合,例如:

1
2
3
4
5
6
7
8
var person = {
name: 'Bob',
age: 20,
tags: ['js', 'web', 'mobile'],
city: 'Beijing',
hasCar: true,
zipcode: null
};

JavaScript对象的键都是字符串类型,值可以是任意数据类型。上述person对象一共定义了6个键值对,其中每个键又称为对象的属性,例如,personname属性为'Bob'zipcode属性为null

要获取一个对象的属性,我们用对象变量.属性名的方式:

1
2
person.name; // 'Bob'
person.zipcode; // null

变量

变量的概念基本上和初中代数的方程变量是一致的,只是在计算机程序中,变量不仅可以是数字,还可以是任意数据类型。

变量在JavaScript中就是用一个变量名表示,变量名是大小写英文、数字、$_的组合,且不能用数字开头。变量名也不能是JavaScript的关键字,如ifwhile等。申明一个变量用var语句,比如:

1
2
3
4
5
var a; // 申明了变量a,此时a的值为undefined
var $b = 1; // 申明了变量$b,同时给$b赋值,此时$b的值为1
var s_007 = '007'; // s_007是一个字符串
var Answer = true; // Answer是一个布尔值true
var t = null; // t的值是null

变量名也可以用中文,但是,请不要给自己找麻烦。

在JavaScript中,使用等号=对变量进行赋值。可以把任意数据类型赋值给变量,同一个变量可以反复赋值,而且可以是不同类型的变量,但是要注意只能用var申明一次,例如:

1
2
var a = 123; // a的值是整数123
a = 'ABC'; // a变为字符串

这种变量本身类型不固定的语言称之为动态语言,与之对应的是静态语言。静态语言在定义变量时必须指定变量类型,如果赋值的时候类型不匹配,就会报错。例如Java是静态语言,赋值语句如下:

1
2
int a = 123; // a是整数类型变量,类型用int申明
a = "ABC"; // 错误:不能把字符串赋给整型变量

和静态语言相比,动态语言更灵活,就是这个原因。

请不要把赋值语句的等号等同于数学的等号。比如下面的代码:

1
2
var x = 10;
x = x + 2;

如果从数学上理解x = x + 2那无论如何是不成立的,在程序中,赋值语句先计算右侧的表达式x + 2,得到结果12,再赋给变量x。由于x之前的值是10,重新赋值后,x的值变成12

要显示变量的内容,可以用console.log(x),打开Chrome的控制台就可以看到结果。

1
2
3
// 打印变量x
var x = 100;
console.log(x);

使用console.log()代替alert()的好处是可以避免弹出烦人的对话框。

strict模式

JavaScript在设计之初,为了方便初学者学习,并不强制要求用var申明变量。这个设计错误带来了严重的后果:如果一个变量没有通过var申明就被使用,那么该变量就自动被申明为全局变量:

1
i = 10; // i现在是全局变量

在同一个页面的不同的JavaScript文件中,如果都不用var申明,恰好都使用了变量i,将造成变量i互相影响,产生难以调试的错误结果。

使用var申明的变量则不是全局变量,它的范围被限制在该变量被申明的函数体内(函数的概念将稍后讲解),同名变量在不同的函数体内互不冲突。

为了修补JavaScript这一严重设计缺陷,ECMA在后续规范中推出了strict模式,在strict模式下运行的JavaScript代码,强制通过var申明变量,未使用var申明变量就使用的,将导致运行错误。

启用strict模式的方法是在JavaScript代码的第一行写上:

1
'use strict';

这是一个字符串,不支持strict模式的浏览器会把它当做一个字符串语句执行,支持strict模式的浏览器将开启strict模式运行JavaScript。

来测试一下你的浏览器是否能支持strict模式:

1
2
3
4
5
6
7
8
function hello() {
'use strict';
// 如果浏览器支持strict模式,
// 下面的代码将报ReferenceError错误:
helloStr = 'hello';
console.log(helloStr);
}
hello();

运行代码,如果浏览器报错,请修复后再运行。如果浏览器不报错,说明你的浏览器太古老了,需要尽快升级。

不用var申明的变量会被视为全局变量,为了避免这一缺陷,所有的JavaScript代码都应该使用strict模式。我们在后面编写的JavaScript代码将全部采用strict模式。

另一种申明变量的方式是let,这也是现代JavaScript推荐的方式:

1
2
3
// 用let申明变量:
let s = 'hello';
console.log(s);

后续我们还会详细讨论varlet的区别。

JavaScript的字符串就是用''""括起来的字符表示。

如果'本身也是一个字符,那就可以用""括起来,比如"I'm OK"包含的字符是I'm,空格,OK这6个字符。

如果字符串内部既包含'又包含"怎么办?可以用转义字符\来标识,比如:

1
'I\'m \"OK\"!'; // I'm "OK"!

表示的字符串内容是:I'm "OK"!

转义字符\可以转义很多字符,比如\n表示换行,\t表示制表符,字符\本身也要转义,所以\\表示的字符就是\

ASCII字符可以以\x##形式的十六进制表示,例如:

1
'\x41'; // 完全等同于 'A'

还可以用\u####表示一个Unicode字符:

1
'\u4e2d\u6587'; // 完全等同于 '中文'

多行字符串

由于多行字符串用\n写起来比较费事,所以最新的ES6标准新增了一种多行字符串的表示方法,用反引号`…`表示:

1
2
3
`这是一个
多行
字符串`;

注意:反引号在键盘的ESC下方,数字键1的左边:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────┐ ┌─────┬─────┬─────┬─────┐
│ ESC │ │ F1 │ F2 │ F3 │ F4 │
└─────┘ └─────┴─────┴─────┴─────┘
┌─────┬─────┬─────┬─────┬─────┐
│ ~ │ ! │ @ │ # │ $ │
│ ` │ 1 │ 2 │ 3 │ 4 │
├─────┴──┬──┴──┬──┴──┬──┴──┬──┘
│ │ │ │ │
│ tab │ Q │ W │ E │
├────────┴──┬──┴──┬──┴──┬──┘
│ │ │ │
│ caps lock │ A │ S │
└───────────┴─────┴─────┘

练习:测试你的浏览器是否支持ES6标准,如果不支持,请把多行字符串用\n重新表示出来:

1
2
3
4
// 如果浏览器不支持ES6,将报SyntaxError错误:
console.log(`多行
字符串
测试`);

模板字符串

要把多个字符串连接起来,可以用+号连接:

1
2
3
4
let name = '小明';
let age = 20;
let message = '你好, ' + name + ', 你今年' + age + '岁了!';
alert(message);

如果有很多变量需要连接,用+号就比较麻烦。ES6新增了一种模板字符串,表示方法和上面的多行字符串一样,但是它会自动替换字符串中的变量:

1
2
3
4
let name = '小明';
let age = 20;
let message = `你好, ${name}, 你今年${age}岁了!`;
alert(message);

练习:测试你的浏览器是否支持ES6模板字符串,如果不支持,请把模板字符串改为+连接的普通字符串:

1
2
3
4
// 如果浏览器支持模板字符串,将会替换字符串内部的变量:
let name = '小明';
let age = 20;
console.log(`你好, ${name}, 你今年${age}岁了!`);

操作字符串

字符串常见的操作如下:

获取字符串长度:

1
2
let s = 'Hello, world!';
s.length; // 13

要获取字符串某个指定位置的字符,使用类似Array的下标操作,索引号从0开始:

1
2
3
4
5
6
7
let s = 'Hello, world!';

s[0]; // 'H'
s[6]; // ' '
s[7]; // 'w'
s[12]; // '!'
s[13]; // undefined 超出范围的索引不会报错,但一律返回undefined

需要特别注意的是,字符串是不可变的,如果对字符串的某个索引赋值,不会有任何错误,但是,也没有任何效果:

1
2
3
let s = 'Test';
s[0] = 'X';
console.log(s); // s仍然为'Test'

JavaScript为字符串提供了一些常用方法,注意,调用这些方法本身不会改变原有字符串的内容,而是返回一个新字符串:

toUpperCase

toUpperCase()把一个字符串全部变为大写:

1
2
let s = 'Hello';
s.toUpperCase(); // 返回'HELLO'

toLowerCase

toLowerCase()把一个字符串全部变为小写:

1
2
3
let s = 'Hello';
let lower = s.toLowerCase(); // 返回'hello'并赋值给变量lower
lower; // 'hello'

indexOf

indexOf()会搜索指定字符串出现的位置:

1
2
3
let s = 'hello, world';
s.indexOf('world'); // 返回7
s.indexOf('World'); // 没有找到指定的子串,返回-1

substring

substring()返回指定索引区间的子串:

1
2
3
let s = 'hello, world'
s.substring(0, 5); // 从索引0开始到5(不包括5),返回'hello'
s.substring(7); // 从索引7开始到结束,返回'world'

JavaScript的Array可以包含任意数据类型,并通过索引来访问每个元素。

要取得Array的长度,直接访问length属性:

1
2
3
// Array.length:
let arr = [1, 2, 3.14, 'Hello', null, true];
console.log(arr.length); // 6

请注意,直接给Arraylength赋一个新的值会导致Array大小的变化:

1
2
3
4
5
6
7
8
let arr = ['A', 'B', 'C'];
console.log(arr.length); // 3
// 调整数组大小:
arr.length = 6;
console.log(arr); // arr变为['A', 'B', 'C', undefined, undefined, undefined]
// 调整数组大小:
arr.length = 2;
console.log(arr); // arr变为['A', 'B']

Array可以通过索引把对应的元素修改为新的值,因此,对Array的索引进行赋值会直接修改这个Array

1
2
3
4
5
// Array index:
let arr = ['A', 'B', 'C'];
arr[1] = 99;
console.log(arr); // arr现在变为['A', 99, 'C']
console.log(arr[1]); // 99

请注意,如果通过索引赋值时,索引超过了范围,同样会引起Array大小的变化:

1
2
3
4
// 索引超出范围会导致数组大小自动调整:
let arr = ['A', 'B', 'C'];
arr[5] = 'x';
console.log(arr); // arr变为['A', 'B', 'C', undefined, undefined, 'x']

大多数其他编程语言不允许直接改变数组的大小,越界访问索引会报错。然而,JavaScript的Array却不会有任何错误。在编写代码时,不建议直接修改Array的大小,访问索引时要确保索引不会越界。

indexOf

与String类似,Array也可以通过indexOf()来搜索一个指定的元素的位置:

1
2
3
4
5
let arr = [10, 20, '30', 'xyz'];
arr.indexOf(10); // 元素10的索引为0
arr.indexOf(20); // 元素20的索引为1
arr.indexOf(30); // 元素30没有找到,返回-1
arr.indexOf('30'); // 元素'30'的索引为2

注意了,数字30和字符串'30'是不同的元素。

slice

slice()就是对应String的substring()版本,它截取Array的部分元素,然后返回一个新的Array

1
2
3
let arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
arr.slice(0, 3); // 从索引0开始,到索引3结束,但不包括索引3: ['A', 'B', 'C']
arr.slice(3); // 从索引3开始到结束: ['D', 'E', 'F', 'G']

注意到slice()的起止参数包括开始索引,不包括结束索引。

如果不给slice()传递任何参数,它就会从头到尾截取所有元素。利用这一点,我们可以很容易地复制一个Array

1
2
3
4
let arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
let aCopy = arr.slice();
aCopy; // ['A', 'B', 'C', 'D', 'E', 'F', 'G']
aCopy === arr; // false

push和pop

push()Array的末尾添加若干元素,pop()则把Array的最后一个元素删除掉:

1
2
3
4
5
6
7
8
9
let arr = [1, 2];
arr.push('A', 'B'); // 返回Array新的长度: 4
arr; // [1, 2, 'A', 'B']
arr.pop(); // pop()返回'B'
arr; // [1, 2, 'A']
arr.pop(); arr.pop(); arr.pop(); // 连续pop 3次
arr; // []
arr.pop(); // 空数组继续pop不会报错,而是返回undefined
arr; // []

unshift和shift

如果要往Array的头部添加若干元素,使用unshift()方法,shift()方法则把Array的第一个元素删掉:

1
2
3
4
5
6
7
8
9
let arr = [1, 2];
arr.unshift('A', 'B'); // 返回Array新的长度: 4
arr; // ['A', 'B', 1, 2]
arr.shift(); // 'A'
arr; // ['B', 1, 2]
arr.shift(); arr.shift(); arr.shift(); // 连续shift 3次
arr; // []
arr.shift(); // 空数组继续shift不会报错,而是返回undefined
arr; // []

sort

sort()可以对当前Array进行排序,它会直接修改当前Array的元素位置,直接调用时,按照默认顺序排序:

1
2
3
let arr = ['B', 'C', 'A'];
arr.sort();
arr; // ['A', 'B', 'C']

能否按照我们自己指定的顺序排序呢?完全可以,我们将在后面的函数中讲到。

reverse

reverse()把整个Array的元素给调个个,也就是反转:

1
2
3
let arr = ['one', 'two', 'three'];
arr.reverse();
arr; // ['three', 'two', 'one']

splice

splice()方法是修改Array的“万能方法”,它可以从指定的索引开始删除若干元素,然后再从该位置添加若干元素:

1
2
3
4
5
6
7
8
9
10
let arr = ['Microsoft', 'Apple', 'Yahoo', 'AOL', 'Excite', 'Oracle'];
// 从索引2开始删除3个元素,然后再添加两个元素:
arr.splice(2, 3, 'Google', 'Facebook'); // 返回删除的元素 ['Yahoo', 'AOL', 'Excite']
arr; // ['Microsoft', 'Apple', 'Google', 'Facebook', 'Oracle']
// 只删除,不添加:
arr.splice(2, 2); // ['Google', 'Facebook']
arr; // ['Microsoft', 'Apple', 'Oracle']
// 只添加,不删除:
arr.splice(2, 0, 'Google', 'Facebook'); // 返回[],因为没有删除任何元素
arr; // ['Microsoft', 'Apple', 'Google', 'Facebook', 'Oracle']

concat

concat()方法把当前的Array和另一个Array连接起来,并返回一个新的Array

1
2
3
4
let arr = ['A', 'B', 'C'];
let added = arr.concat([1, 2, 3]);
added; // ['A', 'B', 'C', 1, 2, 3]
arr; // ['A', 'B', 'C']

请注意concat()方法并没有修改当前Array,而是返回了一个新的Array

实际上,concat()方法可以接收任意个元素和Array,并且自动把Array拆开,然后全部添加到新的Array里:

1
2
let arr = ['A', 'B', 'C'];
arr.concat(1, 2, [3, 4]); // ['A', 'B', 'C', 1, 2, 3, 4]

join

join()方法是一个非常实用的方法,它把当前Array的每个元素都用指定的字符串连接起来,然后返回连接后的字符串:

1
2
let arr = ['A', 'B', 'C', 1, 2, 3];
arr.join('-'); // 'A-B-C-1-2-3'

练习:在新生欢迎会上,你已经拿到了新同学的名单,请排序后显示:欢迎XXX,XXX,XXX和XXX同学!:

1
2
3
let arr = ['小明', '小红', '大军', '阿黄'];

console.log('???');

如果Array的元素不是字符串,将自动转换为字符串后再连接。

多维数组

如果数组的某个元素又是一个Array,则可以形成多维数组,例如:

1
let arr = [[1, 2, 3], [400, 500, 600], '-'];

上述Array包含3个元素,其中头两个元素本身也是Array

练习:如何通过索引取到500这个值:

1
2
3
4
let arr = [[1, 2, 3], [400, 500, 600], '-'];

let x = ??;
console.log(x); // x应该为500

小结

Array提供了一种顺序存储一组元素的功能,并可以按索引来读写。

JavaScript的对象是一种无序的集合数据类型,它由若干键值对组成。

JavaScript的对象用于描述现实世界中的某个对象。例如,为了描述“小明”这个淘气的小朋友,我们可以用若干键值对来描述他:

1
2
3
4
5
6
7
8
let xiaoming = {
name: '小明',
birth: 1990,
school: 'No.1 Middle School',
height: 1.70,
weight: 65,
score: null
};

JavaScript用一个{...}表示一个对象,键值对以xxx: xxx形式申明,用,隔开。注意,最后一个键值对不需要在末尾加,,如果加了,有的浏览器(如低版本的IE)将报错。

上述对象申明了一个name属性,值是'小明'birth属性,值是1990,以及其他一些属性。最后,把这个对象赋值给变量xiaoming后,就可以通过变量xiaoming来获取小明的属性了:

1
2
xiaoming.name; // '小明'
xiaoming.birth; // 1990

访问属性是通过.操作符完成的,但这要求属性名必须是一个有效的变量名。如果属性名包含特殊字符,就必须用''括起来:

1
2
3
4
let xiaohong = {
name: '小红',
'middle-school': 'No.1 Middle School'
};

xiaohong的属性名middle-school不是一个有效的变量,就需要用''括起来。访问这个属性也无法使用.操作符,必须用['xxx']来访问:

1
2
3
xiaohong['middle-school']; // 'No.1 Middle School'
xiaohong['name']; // '小红'
xiaohong.name; // '小红'

也可以用xiaohong['name']来访问xiaohongname属性,不过xiaohong.name的写法更简洁。我们在编写JavaScript代码的时候,属性名尽量使用标准的变量名,这样就可以直接通过object.prop的形式访问一个属性了。

实际上JavaScript对象的所有属性都是字符串,不过属性对应的值可以是任意数据类型。

如果访问一个不存在的属性会返回什么呢?JavaScript规定,访问不存在的属性不报错,而是返回undefined

1
2
3
4
5
6
7
8
'use strict';

let xiaoming = {
name: '小明'
};

console.log(xiaoming.name);
console.log(xiaoming.age); // undefined

由于JavaScript的对象是动态类型,你可以自由地给一个对象添加或删除属性:

1
2
3
4
5
6
7
8
9
10
11
let xiaoming = {
name: '小明'
};
xiaoming.age; // undefined
xiaoming.age = 18; // 新增一个age属性
xiaoming.age; // 18
delete xiaoming.age; // 删除age属性
xiaoming.age; // undefined
delete xiaoming['name']; // 删除name属性
xiaoming.name; // undefined
delete xiaoming.school; // 删除一个不存在的school属性也不会报错

如果我们要检测xiaoming是否拥有某一属性,可以用in操作符:

1
2
3
4
5
6
7
8
9
10
let xiaoming = {
name: '小明',
birth: 1990,
school: 'No.1 Middle School',
height: 1.70,
weight: 65,
score: null
};
'name' in xiaoming; // true
'grade' in xiaoming; // false

不过要小心,如果in判断一个属性存在,这个属性不一定是xiaoming的,它可能是xiaoming继承得到的:

1
'toString' in xiaoming; // true

因为toString定义在object对象中,而所有对象最终都会在原型链上指向object,所以xiaoming也拥有toString属性。

要判断一个属性是否是xiaoming自身拥有的,而不是继承得到的,可以用hasOwnProperty()方法:

1
2
3
4
5
let xiaoming = {
name: '小明'
};
xiaoming.hasOwnProperty('name'); // true
xiaoming.hasOwnProperty('toString'); // false

条件判断

JavaScript使用if () { ... } else { ... }来进行条件判断。例如,根据年龄显示不同内容,可以用if语句实现如下:

1
2
3
4
5
6
let age = 20;
if (age >= 18) { // 如果age >= 18为true,则执行if语句块
console.log('adult');
} else { // 否则执行else语句块
console.log('teenager');
}

其中else语句是可选的。如果语句块只包含一条语句,那么可以省略{}

1
2
3
4
5
let age = 20;
if (age >= 18)
console.log('adult');
else
console.log('teenager');

省略{}的危险之处在于,如果后来想添加一些语句,却忘了写{},就改变了if...else...的语义,例如:

1
2
3
4
5
6
let age = 20;
if (age >= 18)
console.log('adult');
else
console.log('age < 18'); // 添加一行日志
console.log('teenager'); // <- 这行语句已经不在else的控制范围了

上述代码的else子句实际上只负责执行console.log('age < 18');,原有的console.log('teenager');已经不属于if...else...的控制范围了,它每次都会执行。

相反地,有{}的语句就不会出错:

1
2
3
4
5
6
7
let age = 20;
if (age >= 18) {
console.log('adult');
} else {
console.log('age < 18');
console.log('teenager');
}

这就是为什么我们建议永远都要写上{}

多行条件判断

如果还要更细致地判断条件,可以使用多个if...else...的组合:

1
2
3
4
5
6
7
8
let age = 3;
if (age >= 18) {
console.log('adult');
} else if (age >= 6) {
console.log('teenager');
} else {
console.log('kid');
}

上述多个if...else...的组合实际上相当于两层if...else...

1
2
3
4
5
6
7
8
9
10
let age = 3;
if (age >= 18) {
console.log('adult');
} else {
if (age >= 6) {
console.log('teenager');
} else {
console.log('kid');
}
}

但是我们通常把else if连写在一起,来增加可读性。这里的else略掉了{}是没有问题的,因为它只包含一个if语句。注意最后一个单独的else不要略掉{}

请注意if...else...语句的执行特点是二选一,在多个if...else...语句中,如果某个条件成立,则后续就不再继续判断了。

试解释为什么下面的代码显示的是teenager

1
2
3
4
5
6
7
8
9
10
'use strict';
let age = 20;

if (age >= 6) {
console.log('teenager');
} else if (age >= 18) {
console.log('adult');
} else {
console.log('kid');
}

由于age的值为20,它实际上同时满足条件age >= 6age >= 18,这说明条件判断的顺序非常重要。请修复后让其显示adult

如果if的条件判断语句结果不是truefalse怎么办?例如:

1
2
3
4
let s = '123';
if (s.length) { // 条件计算结果为3
//
}

JavaScript把nullundefined0NaN和空字符串''视为false,其他值一概视为true,因此上述代码条件判断的结果是true

练习

小明身高1.75,体重80.5kg。请根据BMI公式(体重除以身高的平方)帮小明计算他的BMI指数,并根据BMI指数:

  • 低于18.5:过轻
  • 18.5-25:正常
  • 25-28:过重
  • 28-32:肥胖
  • 高于32:严重肥胖

if...else...判断并显示结果:

1
2
3
4
5
6
let height = parseFloat(prompt('请输入身高(m):'));
let weight = parseFloat(prompt('请输入体重(kg):'));

// TODO:
let bmi = ???;
if ...


要计算1+2+3,我们可以直接写表达式:

1
1 + 2 + 3; // 6

要计算1+2+3+…+10,勉强也能写出来。

但是,要计算1+2+3+…+10000,直接写表达式就不可能了。

为了让计算机能计算成千上万次的重复运算,我们就需要循环语句。

JavaScript的循环有两种,一种是for循环,通过初始条件、结束条件和递增条件来循环执行语句块:

1
2
3
4
5
6
let x = 0;
let i;
for (i=1; i<=10000; i++) {
x = x + i;
}
x; // 50005000

让我们来分析一下for循环的控制条件:

  • i=1 这是初始条件,将变量i置为1;
  • i<=10000 这是判断条件,满足时就继续循环,不满足就退出循环;
  • i++ 这是每次循环后的递增条件,由于每次循环后变量i都会加1,因此它终将在若干次循环后不满足判断条件i<=10000而退出循环。

练习:利用for循环计算1 * 2 * 3 * ... * 10的结果:

1
2
3
4
5
6
7
8
9
10
let x = ?;
let i;
for ...

if (x === 3628800) {
console.log('1 x 2 x 3 x ... x 10 = ' + x);
}
else {
console.log('计算错误');
}

for循环最常用的地方是利用索引来遍历数组:

1
2
3
4
5
6
let arr = ['Apple', 'Google', 'Microsoft'];
let i, x;
for (i=0; i<arr.length; i++) {
x = arr[i];
console.log(x);
}

for循环的3个条件都是可以省略的,如果没有退出循环的判断条件,就必须使用break语句退出循环,否则就是死循环:

1
2
3
4
5
6
7
let x = 0;
for (;;) { // 将无限循环下去
if (x > 100) {
break; // 通过if判断来退出循环
}
x ++;
}

for … in

for循环的一个变体是for ... in循环,它可以把一个对象的所有属性依次循环出来:

1
2
3
4
5
6
7
8
let o = {
name: 'Jack',
age: 20,
city: 'Beijing'
};
for (let key in o) {
console.log(key); // 'name', 'age', 'city'
}

要过滤掉对象继承的属性,用hasOwnProperty()来实现:

1
2
3
4
5
6
7
8
9
10
let o = {
name: 'Jack',
age: 20,
city: 'Beijing'
};
for (let key in o) {
if (o.hasOwnProperty(key)) {
console.log(key); // 'name', 'age', 'city'
}
}

由于Array也是对象,而它的每个元素的索引被视为对象的属性,因此,for ... in循环可以直接循环出Array的索引:

1
2
3
4
5
let a = ['A', 'B', 'C'];
for (let i in a) {
console.log(i); // '0', '1', '2'
console.log(a[i]); // 'A', 'B', 'C'
}

请注意for ... inArray的循环得到的是String而不是Number

while

for循环在已知循环的初始和结束条件时非常有用。而上述忽略了条件的for循环容易让人看不清循环的逻辑,此时用while循环更佳。

while循环只有一个判断条件,条件满足,就不断循环,条件不满足时则退出循环。比如我们要计算100以内所有奇数之和,可以用while循环实现:

1
2
3
4
5
6
7
let x = 0;
let n = 99;
while (n > 0) {
x = x + n;
n = n - 2;
}
x; // 2500

在循环内部变量n不断自减,直到变为-1时,不再满足while条件,循环退出。

do … while

最后一种循环是do { ... } while()循环,它和while循环的唯一区别在于,不是在每次循环开始的时候判断条件,而是在每次循环完成的时候判断条件:

1
2
3
4
5
let n = 0;
do {
n = n + 1;
} while (n < 100);
n; // 100

do { ... } while()循环要小心,循环体会至少执行1次,而forwhile循环则可能一次都不执行。

练习

请利用循环遍历数组中的每个名字,并显示Hello, xxx!

1
2
3
let arr = ['Bart', 'Lisa', 'Adam'];

for ...

请尝试for循环和while循环,并以正序、倒序两种方式遍历。

小结

循环是让计算机做重复任务的有效的方法,有些时候,如果代码写得有问题,会让程序陷入“死循环”,也就是永远循环下去。JavaScript的死循环会让浏览器无法正常显示或执行当前页面的逻辑,有的浏览器会直接挂掉,有的浏览器会在一段时间后提示你强行终止JavaScript的执行,因此,要特别注意死循环的问题。

在编写循环代码时,务必小心编写初始条件和判断条件,尤其是边界值。特别注意i < 100i <= 100是不同的判断逻辑。

JavaScript的默认对象表示方式{}可以视为其他语言中的MapDictionary的数据结构,即一组键值对。

但是JavaScript的对象有个小问题,就是键必须是字符串。但实际上Number或者其他数据类型作为键也是非常合理的。

为了解决这个问题,最新的ES6规范引入了新的数据类型Map。要测试你的浏览器是否支持ES6规范,请执行以下代码,如果浏览器报ReferenceError错误,那么你需要换一个支持ES6的浏览器:

1
2
3
4
let m = new Map();
let s = new Set();
console.log('你的浏览器支持Map和Set!');
// 直接运行测试

Map

Map是一组键值对的结构,具有极快的查找速度。

举个例子,假设要根据同学的名字查找对应的成绩,如果用Array实现,需要两个Array

1
2
let names = ['Michael', 'Bob', 'Tracy'];
let scores = [95, 75, 85];

给定一个名字,要查找对应的成绩,就先要在names中找到对应的位置,再从scores取出对应的成绩,Array越长,耗时越长。

如果用Map实现,只需要一个“名字”-“成绩”的对照表,直接根据名字查找成绩,无论这个表有多大,查找速度都不会变慢。用JavaScript写一个Map如下:

1
2
let m = new Map([['Michael', 95], ['Bob', 75], ['Tracy', 85]]);
m.get('Michael'); // 95

初始化Map需要一个二维数组,或者直接初始化一个空MapMap具有以下方法:

1
2
3
4
5
6
7
let m = new Map(); // 空Map
m.set('Adam', 67); // 添加新的key-value
m.set('Bob', 59);
m.has('Adam'); // 是否存在key 'Adam': true
m.get('Adam'); // 67
m.delete('Adam'); // 删除key 'Adam'
m.get('Adam'); // undefined

由于一个key只能对应一个value,所以,多次对一个key放入value,后面的值会把前面的值冲掉:

1
2
3
4
let m = new Map();
m.set('Adam', 67);
m.set('Adam', 88);
m.get('Adam'); // 88

Set

SetMap类似,也是一组key的集合,但不存储value。由于key不能重复,所以,在Set中,没有重复的key。

要创建一个Set,需要提供一个Array作为输入,或者直接创建一个空Set

1
2
let s1 = new Set(); // 空Set
let s2 = new Set([1, 2, 3]); // 含1, 2, 3

重复元素在Set中自动被过滤:

1
2
let s = new Set([1, 2, 3, 3, '3']);
s; // Set {1, 2, 3, "3"}

注意数字3和字符串'3'是不同的元素。

通过add(key)方法可以添加元素到Set中,可以重复添加,但不会有效果:

1
2
3
4
s.add(4);
s; // Set {1, 2, 3, 4}
s.add(4);
s; // 仍然是 Set {1, 2, 3, 4}

通过delete(key)方法可以删除元素:

1
2
3
4
let s = new Set([1, 2, 3]);
s; // Set {1, 2, 3}
s.delete(3);
s; // Set {1, 2}

小结

MapSet是ES6标准新增的数据类型,请根据浏览器的支持情况决定是否要使用。

遍历Array可以采用下标循环,遍历MapSet就无法使用下标。为了统一集合类型,ES6标准引入了新的iterable类型,ArrayMapSet都属于iterable类型。

具有iterable类型的集合可以通过新的for ... of循环来遍历。

for ... of循环是ES6引入的新的语法,请测试你的浏览器是否支持:

1
2
3
4
5
let a = [1, 2, 3];
for (let x of a) {
}
console.log('你的浏览器支持for ... of');
// 请直接运行测试

for ... of循环遍历集合,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
let a = ['A', 'B', 'C'];
let s = new Set(['A', 'B', 'C']);
let m = new Map([[1, 'x'], [2, 'y'], [3, 'z']]);
for (let x of a) { // 遍历Array
console.log(x);
}
for (let x of s) { // 遍历Set
console.log(x);
}
for (let x of m) { // 遍历Map
console.log(x[0] + '=' + x[1]);
}

你可能会有疑问,for ... of循环和for ... in循环有何区别?

for ... in循环由于历史遗留问题,它遍历的实际上是对象的属性名称。一个Array数组实际上也是一个对象,它的每个元素的索引被视为一个属性。

当我们手动给Array对象添加了额外的属性后,for ... in循环将带来意想不到的意外效果:

1
2
3
4
5
let a = ['A', 'B', 'C'];
a.name = 'Hello';
for (let x in a) {
console.log(x); // '0', '1', '2', 'name'
}

for ... in循环将把name包括在内,但Arraylength属性却不包括在内。

for ... of循环则完全修复了这些问题,它只循环集合本身的元素:

1
2
3
4
5
let a = ['A', 'B', 'C'];
a.name = 'Hello';
for (let x of a) {
console.log(x); // 'A', 'B', 'C'
}

这就是为什么要引入新的for ... of循环。

然而,更好的方式是直接使用iterable内置的forEach方法,它接收一个函数,每次迭代就自动回调该函数。以Array为例:

1
2
3
4
5
6
7
let a = ['A', 'B', 'C'];
a.forEach(function (element, index, array) {
// element: 指向当前元素的值
// index: 指向当前索引
// array: 指向Array对象本身
console.log(`${element}, index = ${index}`);
});

注意forEach()方法是ES5.1标准引入的,你需要测试浏览器是否支持。

SetArray类似,但Set没有索引,因此回调函数的前两个参数都是元素本身:

1
2
3
4
let s = new Set(['A', 'B', 'C']);
s.forEach(function (element, sameElement, set) {
console.log(element);
});

Map的回调函数参数依次为valuekeymap本身:

1
2
3
4
let m = new Map([[1, 'x'], [2, 'y'], [3, 'z']]);
m.forEach(function (value, key, map) {
console.log(value);
});

如果对某些参数不感兴趣,由于JavaScript的函数调用不要求参数必须一致,因此可以忽略它们。例如,只需要获得Arrayelement

1
2
3
4
let a = ['A', 'B', 'C'];
a.forEach(function (element) {
console.log(element);
});

留言與分享

SwiftUI 是 Apple 推出的声明式框架,用于构建用户界面(UI)。 自2019年首次发布以来,SwiftUI 已成为 iOS、macOS、watchOS 和 tvOS 应用开发的重要工具。 本文将深入介绍 SwiftUI 的基本概念,包括视图(View)、修饰符(Modifiers)、内置修饰符、如何自定义视图、 组件化开发以及 MVVM 架构在 SwiftUI 中的应用。无论你是刚开始学习 SwiftUI,还是希望巩固基础,这篇指南都将为你提供有价值的知识。

什么是 SwiftUI

SwiftUI 是 Apple 推出的用于构建用户界面的框架,采用声明式语法,使开发者能够更简洁、高效地创建复杂的界面。与传统的 UIKit 不同,SwiftUI 允许开发者通过声明视图的状态和布局来自动管理 UI 更新,从而减少了大量的样板代码。

SwiftUI 的优势

  • 声明式语法:通过描述界面的状态,SwiftUI 自动处理界面的更新。
  • 跨平台支持:适用于 iOS、macOS、watchOS 和 tvOS。
  • 实时预览:Xcode 提供的 Canvas 允许开发者实时预览和调试 UI。
  • 高效开发:减少样板代码,提高开发效率。
  • 响应式编程:SwiftUI 内置支持响应式编程范式,简化数据与UI的绑定。

什么是视图(View)

在 SwiftUI 中,视图(View) 是构建用户界面的基本单元。每一个视图都代表 UI 的一个部分,例如文本、按钮、图像等。视图可以嵌套和组合,以创建复杂的界面。

常见的 SwiftUI 视图

  • Text:显示文本内容。
  • Image:显示图片。
  • Button:交互式按钮。
  • VStackHStack:垂直和水平堆叠视图。
  • List:显示可滚动的列表。
  • ScrollView:实现可滚动的内容视图。
  • Spacer:在堆叠视图中添加弹性空间。
  • NavigationView:实现导航功能。

示例:创建一个简单的文本视图

1
import SwiftUIstruct ContentView: View {    var body: some View {        Text("Hello, SwiftUI!")    }}

在上述示例中,Text 视图用于显示一段文本。

什么是修饰符(Modifiers)

修饰符(Modifiers) 是 SwiftUI 中用于修改视图属性的方法。通过链式调用修饰符,开发者可以轻松地调整视图的外观和行为,例如颜色、字体、边距等。

使用修饰符的示例

1
Text("Hello, SwiftUI!")    .font(.title)    .foregroundColor(.blue)    .padding()

在这个例子中,Text 视图被应用了三个修饰符:font 修改字体大小,foregroundColor 修改文本颜色,padding 添加内边距。

什么是内置修饰符

SwiftUI 提供了丰富的内置修饰符,开发者可以直接使用这些修饰符来调整视图的各种属性。以下是一些常用的内置修饰符:

  • font(_:):设置字体和字体大小。
  • foregroundColor(_:):设置前景色(如文本颜色)。
  • background(_:):设置视图的背景颜色或视图。
  • padding(_:):设置视图的内边距。
  • frame(width:height:):设置视图的宽度和高度。
  • cornerRadius(_:):设置视图的圆角半径。
  • shadow(color:radius:x:y:):添加阴影效果。
  • opacity(_:):设置视图的不透明度。
  • rotationEffect(_:):旋转视图。
  • scaleEffect(_:):缩放视图。

示例:使用多个内置修饰符

1
Text("Welcome to SwiftUI")    .font(.largeTitle)    .foregroundColor(.white)    .padding()    .background(Color.blue)    .cornerRadius(10)    .shadow(color: .gray, radius: 5, x: 0, y: 5)

在这个例子中,Text 视图应用了多个内置修饰符,改变了字体、颜色、背景、圆角和阴影效果。

如何自定义视图

除了使用内置视图和修饰符,SwiftUI 允许开发者创建自定义视图。通过组合多个视图和修饰符,可以创建可重用且模块化的 UI 组件。

创建自定义视图的步骤

  1. 定义新的视图结构:创建一个遵循 View 协议的结构体。
  2. 实现 body 属性:在 body 中定义视图的内容和布局。
  3. 组合视图和修饰符:使用内置视图和修饰符来设计自定义视图。

示例:创建一个自定义按钮视图

1
import SwiftUIstruct CustomButton: View {    var title: String    var backgroundColor: Color    var body: some View {        Text(title)            .font(.headline)            .foregroundColor(.white)            .padding()            .background(backgroundColor)            .cornerRadius(8)            .shadow(radius: 5)    }}struct ContentView: View {    var body: some View {        CustomButton(title: "Click Me", backgroundColor: .green)    }}

在这个示例中,CustomButton 是一个自定义视图,通过组合 Text 视图和多个修饰符,创建了一个带有背景色、圆角和阴影效果的按钮。

视图与组件化开发

组件化开发 是现代软件开发中的一种最佳实践,通过将 UI 分解为可重用的组件,提升代码的可维护性和可扩展性。在 SwiftUI 中,视图本身就是组件的基本形式,开发者可以通过创建自定义视图来实现组件化开发。

组件化的优势

  • 可重用性:相同的组件可以在不同的地方重复使用,减少代码冗余。
  • 可维护性:组件独立,便于管理和更新。
  • 可测试性:独立的组件更容易进行单元测试。

示例:创建多个自定义组件

1
struct HeaderView: View {    var title: String    var body: some View {        Text(title)            .font(.largeTitle)            .padding()            .background(Color.orange)            .foregroundColor(.white)    }}struct FooterView: View {    var body: some View {        Text("© 2025 Your Company")            .font(.footnote)            .padding()            .background(Color.gray.opacity(0.2))    }}struct ContentView: View {    var body: some View {        VStack {            HeaderView(title: "Welcome to My App")            Spacer()            FooterView()        }    }}

在这个示例中,HeaderViewFooterView 是两个独立的自定义组件,通过组合它们构建了一个完整的界面。

MVVM 架构简介

MVVM(Model-View-ViewModel) 是一种软件架构模式,旨在将应用程序的业务逻辑与用户界面分离。MVVM 提供了一种清晰的方式来组织代码,使其更易于维护、测试和扩展。

MVVM 的组成部分

  • Model(模型):表示应用程序的数据和业务逻辑。通常与数据存储和网络请求相关。
  • View(视图):负责显示数据和处理用户交互。SwiftUI 中的视图如 TextImage 等。
  • ViewModel(视图模型):充当 Model 和 View 之间的中介。它处理业务逻辑,将 Model 中的数据转换为 View 可以直接使用的格式,并响应用户交互。

MVVM 的优势

  • 分离关注点:将 UI 与业务逻辑分离,提升代码的可维护性。
  • 可测试性:ViewModel 可以独立于 View 进行单元测试。
  • 可重用性:ViewModel 可以在多个 View 中复用。

SwiftUI 中的 MVVM 实现

在 SwiftUI 中,MVVM 架构通过结合 SwiftUI 的声明式语法和数据绑定机制,实现了 Model、View 和 ViewModel 的分离与协作。

关键概念

  • @State:用于在 View 中存储和管理局部状态。
  • @ObservableObject:用于声明一个可观察的对象,通常在 ViewModel 中使用。
  • @Published:用于在 ObservableObject 中标记可以被观察的属性。
  • @StateObject@ObservedObject:用于在 View 中引用 ViewModel。

示例:使用 MVVM 架构的简单计数器应用

Model

1
struct CounterModel {    var count: Int = 0}

ViewModel

1
import Combineclass CounterViewModel: ObservableObject {    @Published var counter: CounterModel    init(counter: CounterModel = CounterModel()) {        self.counter = counter    }    func increment() {        counter.count += 1    }    func decrement() {        counter.count -= 1    }}

View

1
import SwiftUIstruct CounterView: View {    @StateObject private var viewModel = CounterViewModel()    var body: some View {        VStack(spacing: 20) {            Text("Count: \(viewModel.counter.count)")                .font(.largeTitle)            HStack(spacing: 40) {                Button(action: {                    viewModel.decrement()                }) {                    Text("-")                        .font(.title)                        .frame(width: 60, height: 60)                        .background(Color.red)                        .foregroundColor(.white)                        .clipShape(Circle())                }                Button(action: {                    viewModel.increment()                }) {                    Text("+")                        .font(.title)                        .frame(width: 60, height: 60)                        .background(Color.green)                        .foregroundColor(.white)                        .clipShape(Circle())                }            }        }        .padding()    }}

在这个示例中:

  • ModelCounterModel 包含一个 count 属性,表示当前计数值。
  • ViewModelCounterViewModel 作为 ObservableObject,管理 CounterModel 的实例,并提供 incrementdecrement 方法来修改计数值。
  • ViewCounterView 使用 @StateObject 引用 CounterViewModel,并通过数据绑定 (viewModel.counter.count) 显示计数值,同时通过按钮调用 ViewModel 的方法来修改计数。

MVVM 架构关系图

以下图示展示了 MVVM 架构在 SwiftUI 中的关系:

1
graph LR    Model --> ViewModel    ViewModel --> View    View --> ViewModel
  • Model:提供数据和业务逻辑。
  • ViewModel:持有 Model 的实例,处理业务逻辑,并通过 @Published 属性将数据暴露给 View。
  • View:通过 @StateObject@ObservedObject 引用 ViewModel,使用数据绑定显示数据,并通过用户交互调用 ViewModel 的方法。

基于SwiftUI的项目如何设置MVVM

在 SwiftUI 项目中应用 MVVM 架构,可以按照以下步骤进行设置:

1. 创建 Model

首先,定义应用程序的数据结构和业务逻辑。例如,一个简单的用户模型:

1
struct User: Identifiable {    let id: UUID    let name: String    let email: String}

2. 创建 ViewModel

创建一个 ObservableObject 类来管理 Model 的数据和业务逻辑:

1
import Combineclass UserViewModel: ObservableObject {    @Published var users: [User] = []    func fetchUsers() {        // 模拟网络请求或数据获取        users = [            User(id: UUID(), name: "Alice", email: "alice@example.com"),            User(id: UUID(), name: "Bob", email: "bob@example.com")        ]    }    func addUser(name: String, email: String) {        let newUser = User(id: UUID(), name: name, email: email)        users.append(newUser)    }    func removeUser(at offsets: IndexSet) {        users.remove(atOffsets: offsets)    }}

3. 创建 View

在 View 中引用 ViewModel 并使用数据绑定展示和操作数据:

1
import SwiftUIstruct UserListView: View {    @StateObject private var viewModel = UserViewModel()    var body: some View {        NavigationView {            List {                ForEach(viewModel.users) { user in                    VStack(alignment: .leading) {                        Text(user.name)                            .font(.headline)                        Text(user.email)                            .font(.subheadline)                            .foregroundColor(.gray)                    }                }                .onDelete(perform: viewModel.removeUser)            }            .navigationTitle("用户列表")            .navigationBarItems(trailing: Button(action: {                viewModel.addUser(name: "新用户", email: "newuser@example.com")            }) {                Image(systemName: "plus")            })            .onAppear {                viewModel.fetchUsers()            }        }    }}

4. 组织项目结构

为了更好地组织代码,建议按照 MVVM 的结构将项目分为不同的文件夹:

1
YourProject/├── Models/│   └── User.swift├── ViewModels/│   └── UserViewModel.swift├── Views/│   └── UserListView.swift└── YourProjectApp.swift

5. 使用依赖注入(可选)

为了提高代码的可测试性和可扩展性,可以使用依赖注入将 ViewModel 注入到 View 中。例如:

1
struct UserListView: View {    @ObservedObject var viewModel: UserViewModel    var body: some View {        // 与之前相同    }}// 在入口处注入 ViewModelstruct YourProjectApp: App {    var body: some Scene {        WindowGroup {            UserListView(viewModel: UserViewModel())        }    }}

6. 添加更多功能

随着项目的发展,可以在 ViewModel 中添加更多的方法和属性,处理更复杂的业务逻辑,同时保持 View 的简洁和专注于展示。

SwiftUI 术语汇总

为了更好地理解 SwiftUI,以下是一些常见的术语及其解释:

  • 声明式编程(Declarative Programming):一种编程范式,开发者描述 UI 应该是什么样子,系统负责管理其状态和更新。
  • 视图(View):SwiftUI 中的基本构建块,用于构建用户界面。
  • 修饰符(Modifier):用于修改视图属性的方法,采用链式调用方式。
  • 布局容器(Layout Containers):用于组织和布局视图的容器,如 VStackHStackZStack
  • 状态(State):视图的数据源,影响视图的显示和行为。
  • 绑定(Binding):一种双向数据流机制,使视图与其数据源保持同步。
  • 环境(Environment):提供全局数据和配置,视图可以从环境中读取或写入数据。
  • 预览(Preview):Xcode 提供的实时预览功能,允许开发者在编写代码时即时查看 UI 的变化。
  • ObservableObject:一种可以被多个视图观察的对象,当其中的 @Published 属性发生变化时,视图会自动更新。
  • @State:用于在视图内部存储和管理局部状态。
  • @ObservedObject:用于在视图中观察外部的 ObservableObject,视图会在对象变化时自动更新。
  • @EnvironmentObject:用于在多个视图中共享一个 ObservableObject,无需手动传递。

总结

SwiftUI 以其声明式语法和强大的功能,正在迅速改变 iOS 和其他 Apple 平台的应用开发方式。理解 SwiftUI 的核心概念,如视图(View)、修饰符(Modifiers)、内置修饰符、如何自定义视图和组件化开发,是掌握这一框架的关键。通过引入 MVVM 架构,开发者可以进一步提升代码的可维护性和可测试性,构建出高效、可扩展的应用。

本文介绍了 SwiftUI 的基本概念和术语,展示了如何创建和使用自定义视图,并深入探讨了 MVVM 架构在 SwiftUI 中的实现方式。掌握这些知识后,你将能够在 SwiftUI 项目中应用最佳实践,打造出优雅且高效的用户界面。

继续深入学习 SwiftUI 的高级功能,如动画、数据绑定和自定义控件,将进一步提升你的开发技能,帮助你打造出更具吸引力和互动性的应用。

留言與分享

SwiftUI文档

分類 编程语言, SwiftUI

SwiftUI简介

SwiftUI是一种更为现代的编码方式,他可以为苹果任意平台声明用户界面。以更为快捷迅速的方式创建漂亮的动态应用程序!

只需描述你的布局

为视图的任何状态声明内容和布局。SwiftUI知道该状态何时更改,并更新视图,以匹配其呈现的状态。

1
2
3
4
5
6
7
8
9
10
11
12
List(landmarks) { landmark in
HStack {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}

构建可重用组件

将小的、单一职责的视图组合成更大、更复杂的界面。在为苹果任意平台设计的应用程序之间共享您的自定义视图。

1
2
3
4
5
6
7
8
9
10
struct FeatureCard: View {
var landmark: Landmark

var body: some View {
landmark.featureImage
.resizable()
.aspectRatio(3/2, contentMode: .fit)
.overlay(TextOverlay(landmark))
}
}

简化动画

创建平滑动画就像添加一个方法调用一样简单。SwiftUI会在需要时自动计算并设置过渡动画。

1
2
3
4
5
6
7
8
VStack {
Badge()
.frame(width: 300, height: 300)
.animation(.easeInOut())
Text(name)
.font(.title)
.animation(.easeInOut())
}

在Xcode中实时预览

在不运行应用程序的情况下设计、生成和测试应用程序的界面。使用交互式预览测试控件和布局。

应用程序设计和布局

组合复杂界面

地标的首页显示一个滚动的分类列表,在每个分类中水平滚动地标。在构建此主导航时,您将探索组合视图如何适应不同的设备大小和方向。

学习时间:20分钟

下载地址:ComposingComplexInterfaces.zip

第一节 添加首页

既然你已经拥有了地标应用程序所需的所有视图,现在是时候给它们一个首页了——一个统一视图页面。主视图不仅包含所有其他视图,还提供导航和显示地标的方法。

步骤1

在名为Home.swift的新文件中创建名为CategoryHome的自定义视图。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct CategoryHome: View {
var body: some View {
Text("Landmarks Content")
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤2

修改scene delegate,使其显示新的CategoryHome视图而不是地标列表。

主视图是地标应用程序的根,因此它需要一种方式来呈现所有其他视图。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(
rootView: CategoryHome()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
}
}

步骤3

添加NavigationView以在地标中承载不同的视图。

您可以使用NavigationView以及NavigationLink实例和相关修饰符在应用程序中构建导航层级结构。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct CategoryHome: View {
var body: some View {
NavigationView {
Text("Landmarks Content")
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤4

将导航栏的标题设置为“Featured”。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct CategoryHome: View {
var body: some View {
NavigationView {
Text("Landmarks Content")
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第二节 创建分类列表

Landmarks应用程序将所有分类显示在垂直列中的单独行中,以便于浏览。通过组合垂直和水平堆栈,并向列表中添加滚动条,就可以完成此操作。

步骤1

使用Dictionary结构的init(grouping:by:)方法将地标分组到类别中,输入地标的category属性。

初始化项目文件给每个示例地标预设了类别。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
Text("Landmarks Content")
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤2

使用列表在地标中显示分类。

Landmark.Category会匹配列表中每一项name,这些项在其他类别中必须是唯一的,因为它是枚举。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
List {
ForEach(categories.keys.sorted(), id: \.self) { key in
Text(key)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第三节 添加地标行

地标在水平滚动的行中显示每个类别。添加一个新的视图类型来表示行,然后在新视图中显示该类别的所有地标。

步骤1

定义用于保存行内容的新自定义视图。

这个视图需要存储地标的类别信息,以及地标本身。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
Text(self.categoryName)
.font(.headline)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(3))
)
}
}

步骤2

更新CategoryHome的主体以将类别信息传递给新的行类型。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var body: some View {
NavigationView {
List {
ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

HStack中显示该类别的地标。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
Text(landmark.name)
}
}
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(3))
)
}
}

步骤4

通过指定一个frame(width:height:) 并在scrollView中包装stack,为行提供空间。

使用长数据样本更新视图预览,以便确保可以滚动。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
Text(landmark.name)
}
}
}
.frame(height: 185)
}
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

第四节 组成主页

地标应用程序的主页需要显示地标的简单信息,然后用户点击其中一个更多进入详情视图。

重新使用在Creating and Combining Views中的视图来创建更简单的视图预览,以显示地标的分类和特征。

步骤1

CategoryRow旁边创建一个名为CategoryItem的新自定义视图,并用新视图替换Text视图。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
CategoryItem(landmark: landmark)
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

步骤2

Home.swift中,添加一个名为FeaturedLandmarks的简单视图,该视图仅显示标记为isFeatured的地标。

在后面的教程中,您将把此视图转换为可交互的轮播视图。目前,它显示了一个裁剪和缩放后的预览图像。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

在两种地标预览中将edge insets设置为零,以便内容可以延伸到屏幕边缘。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

第五节 在分区之间添加导航

现在,在主页中可以看到所有不同分类的地标,用户需要一种方法来访问应用程序中的每个部分。使用navigationpresentation Apis可以从主页导航到地标详情页,收藏夹和用户主页。

步骤1

CategoryRow.swift中,用NavigationLink包装现有的CategoryItem

类别项本身是按钮的label,其目的地是显示地标详情视图。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

注意

Xcode11 beta 6中,如果在列表中嵌套一个ScrollView,并且该ScrollView包含一个NavigationLink,那么当用户点击NavigationLink时,这些NavigationLink不会导航到目标视图。

步骤2

通过应用renderingMode(:)foregroundColor(:)修饰符更改导航外观。

作为NavigationLinklabel传递的文本使用环境的强调色进行渲染,而图像可能被作为template images进行渲染。您可以修改任一行为以最适合您的设计。

CategoryRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import SwiftUI

struct CategoryRow: View {
var categoryName: String
var items: [Landmark]

var body: some View {
VStack(alignment: .leading) {
Text(self.categoryName)
.font(.headline)
.padding(.leading, 15)
.padding(.top, 5)

ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 0) {
ForEach(self.items) { landmark in
NavigationLink(
destination: LandmarkDetail(
landmark: landmark
)
) {
CategoryItem(landmark: landmark)
}
}
}
}
.frame(height: 185)
}
}
}

struct CategoryItem: View {
var landmark: Landmark
var body: some View {
VStack(alignment: .leading) {
landmark.image
.renderingMode(.original)
.resizable()
.frame(width: 155, height: 155)
.cornerRadius(5)
Text(landmark.name)
.foregroundColor(.primary)
.font(.caption)
}
.padding(.leading, 15)
}
}

struct CategoryRow_Previews: PreviewProvider {
static var previews: some View {
CategoryRow(
categoryName: landmarkData[0].category.rawValue,
items: Array(landmarkData.prefix(4))
)
}
}

步骤3

Home.swift中,点击选项卡栏中的简介图标后,添加一个模态视图以显示用户简介页面。

showProfile状态变量设置为true时,SwiftUI显示用户简介占位符,当用户关闭模态时,将showProfile设置回false

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤4

在导航栏中添加一个按钮,在点击时将showProfilefalse切换为true

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
.init(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image(forSize: 250).resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤5

通过添加导航链接完成主页,该链接指向可过滤所有地标的列表。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList()) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
Text("User Profile")
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤6

LandmarkList.swift中,删除包装地标列表的NavigationView,并将其添加到预览中。

在应用程序的环境中,LandmarkList将始终显示在Home.swift中声明的导航视图中。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {

List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))

}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LandmarkList()
.environmentObject(UserData())
}
}
}

SwiftUI纲要

创建和组织视图

本教程将指导您构建一个iOS应用程序——Landmarks(地标),用于发现和分享您喜欢的地方。首先,您将构建显示Landmark的详情页面。

为了布局视图,"地标"使用stacks来对image、text等组件进行组织和分层。要将地图添加到视图中,需要包含一个标准的MapKit组件。当您优化视图的设计时,Xcode提供实时反馈,这样当您修改代码时,就可以看到视图状态的改变。

下载项目文件开始构建此项目,并按照以下步骤操作:

学习时长:40分钟

下载示例:CreatingAndCombiningViews.zip

第一节 创建新项目并浏览画布

创建一个使用SwiftUI的新Xcode项目。浏览画布、预览和SwiftUI模板代码。

要在Xcode中预览画布上的视图并与之交互,请确保Mac运行的是macOS Catalina 10.15。

步骤一

打开Xcode并在Xcode的启动窗口中单击Create a new Xcode项目,或者选择File>new>project

步骤二

在模板选择器中,选择iOS平台,选择单视图应用程序模板,然后单击下一步。

步骤三

输入“Landmarks”作为产品名称,选中Use SwiftUI复选框,然后单击Next。选择一个路径来保存你的项目。

步骤四

在项目导航器中,单击以选择ContentView.swift。

默认情况下,SwiftUI视图文件声明两个结构。第一个结构实现了视图的必选协议,并描述了视图的内容和布局。第二个结构声明了该视图的预览。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

//
struct ContentView: View {
var body: some View {
Text("Hello World")
}
}
//

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

在画布中,单击Resume以显示预览。

Tips:如果画布不可见,请选择Editor > Editor and Canvas以显示它。

步骤六

body属性中,将“Hello World”更改为自己的问候语。

当您在视图的body属性中更改代码时,预览将更新以反映您的更改。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Hello SwiftUI!")
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第二节 自定义文本视图

您可以通过更改代码,或使用检查器查看可用的内容,并帮助您编写代码,来自定义视图的显示。

构建Lanmarks应用程序时,可以使用任意编辑器组合:源码编辑、画布或检查器。无论使用哪种工具,代码都会保持更新。

接下来,您将使用检查器自定义文本视图。

步骤一

在预览中,按住command并单击问候语以打开结构化编辑弹出窗口,然后选择Inspect

弹出窗口显示可自定义的不同属性,具体取决于所检查的视图类型。

步骤二

使用检查器将文本更改为“Turtle Rock”,即您将在应用程序中显示的第一个地标的名称。

步骤三

font更改为Title

这会将系统字体应用于该文本,以便它正确适应用户系统偏好的字体大小和设置。

要自定义SwiftUI视图,可以调用名为修饰符(modifier)的方法。修饰符会包装视图以更改其显示或其他属性。每个修饰符都返回一个新视图,因此通常采用垂直的链式调用多个修饰符。

步骤四

手动编辑代码添加foregroundColor(.green)修饰符;这会将文本的颜色更改为绿色。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Turtle Rock")
.font(.title)
.foregroundColor(.green)
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

您的代码始终真实反应到视图上。因此当使用检查器更改或移除修饰符时,Xcode会立即更新视图以匹配代码。

步骤五

然后我们在代码编辑区,按住command并单击Text来打开检查器,然后从弹出窗口中选择Inspect。单击Color弹出菜单,然后选择Inherited , 将文本颜色再次更改为黑色。

步骤六

请注意,Xcode会自动更新代码以反映您的更改,并删除foregroundColor(.green)修饰符。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct ContentView: View {
var body: some View {
Text("Turtle Rock")
.font(.title)

}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第三节

使用堆栈组合视图

除了在上一节中创建的标题视图之外,还将添加文本视图,以包含有关地标的详细信息,例如公园的名称和所在的州。

创建SwiftUI视图时,可以在视图的body属性中描述其内容、布局和行为;但是,body属性只返回单个视图。您可以将多个视图组合并嵌入到Stack中,从而将视图水平、垂直或前后组合在一起。

在本节中,您将使用VStack(垂直堆栈)组合标题信息,使用HStack(水平堆栈)组合公园详情信息。

你可以使用Xcode的结构化编辑功能在容器视图中嵌入视图,也可以打开Inspector 或使用help进行其他更多有用的更改。

步骤一

按住command并单击Text视图,会看到初始值设定项的显示结构化编辑弹出窗口,然后选择Embed in VStack

接下来,通过从组件库中拖动Text视图,将Text视图添加到Stack中。

通过单击Xcode窗口右上角的加号按钮(+)打开组件库,然后将Text视图拖到Turtle Rock文本视图下面的位置。

步骤三

Text视图的默认文本替换为“Joshua Tree National Park”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

自定义位置文本的样式,以满足布局需求。

步骤四

将位置的font设置为subheadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

编辑VStack初始值设定项,以leading方式对齐视图。

默认情况下,VStack会将它们的内容按轴中心对齐,并提供上下文适当的间距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
Text("Joshua Tree National Park")
.font(.subheadline)
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

接下来,您将在位置文本的右侧添加另一个文本视图,该视图用于展示公园所在的州。

步骤六

在画布中,按住command并单击Joshua Tree National Park,然后选择Embed In HStack

步骤七

在地点文本后添加新的文本视图,将默认文本更改为公园所在的州,然后将其font设置为subheadline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Text("California")
.font(.subheadline)
}
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤八

若想使布局自动撑开并充满设备的宽,在HStack中添加Spacer来分隔Joshua Tree National ParkCalifornia两个文本视图。

Spacer将撑开Stack,以使其包含的视图充满其父视图的所有空间,而不是仅由其内容定义其大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤九

最后,使用padding()修饰符方法给整个地标信息区域加一个边距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

第四节

创建自定义图像视图

在名称和地点视图都设置好的情况下,接下来要做的是为地标添加一个图像。

对自定义的图像使用遮罩、边框和阴影,和原来的方法相比,将使用很少的代码。

首先将图片添加到项目的Assets中。

步骤一

在项目Resources文件夹的中找到turtlerock.png,将其拖到Assets中。Xcode为图像创建一个新的图像集。

接下来,您将为您的自定义图像视图创建一个新的SwiftUI视图。

步骤二

选择File > New > File再次打开模板选择器。在User Interface中,单击选择SwiftUI View,然后单击Next。将文件命名为CircleImage.swift,然后单击Create

现在您已准备好图像,以满足设计所需。

步骤三

使用Image(:)Text视图替换为Turtle Rock的图像视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤四

添加Image.clipShape(Circle())的修饰符,将图像剪裁为圆形。

Circle()是一个可以用作遮罩的形状,或通过给Circle()设置strokefill来绘制视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤五

创建另一个带有灰色strokeCircle(),使用.overlay()修饰符,将其覆盖添加到图像的边框中。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.gray, lineWidth: 4))
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤六

接下来,添加半径为10point的阴影。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.gray, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤七

将边框颜色修改为白色。

到此,我们就完成了图像视图。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct CircleImage: View {
var body: some View {
Image("turtlerock")
.clipShape(Circle())
.overlay(
Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

第五节

UIKit和SwiftUI配合使用

现在可以创建地图视图了,您可以使用MapKit中的MKMappView类来绘制地图。

要在SwiftUI中使用UIView子类,可以将另一个视图包装在遵守UIViewRepresentable协议的SwiftUI视图中。SwiftUI包含了WatchKit和AppKit视图类似的协议。

现在开始,您将创建一个新的自定义视图,该视图可以显示MKMapView

选择File > New > File,选择iOS平台,选择SwiftUI View模板,然后单击Next。将新文件命名为MapView.swift,然后单击“Create”。

步骤二

添加import MapKit,并让MappView结构遵守UIViewRepresentable协议。

不要担心Xcode显示的警告;您将在接下来的几个步骤中修复它。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var body: some View {
Text("Hello World")
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

UIViewRepresentable协议有两个需要实现的方法:使用UIView(context:)方法创建MKMapView,使用updateUIView(u:context:)方法来配置视图并响应视图的任意变化。

步骤三

makeUIView(context:)方法替换body属性。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

步骤四

创建一个updateUIView(u:context:)方法,将地图视图的区域设置为正确的坐标,以便将地图居中放置在Turtle Rock上。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {
let coordinate = CLLocationCoordinate2D(
latitude: 34.011286, longitude: -116.166868)
let span = MKCoordinateSpan(latitudeDelta: 2.0, longitudeDelta: 2.0)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

当预览处于静态模式时,它们仅完全呈现SwiftUI视图。因为MKMapView是一个UIView子类,所以您需要切换到实时预览来查看地图。

步骤五

单击Live Preview按钮将预览切换到实时预览模式。您可能需要单击预览上方的Try AgainResume按钮。

再过一会儿,你会看到一张Joshua Tree National Park的地图,那里是Turtle Rock的故乡。

第六节

组成详情视图

现在您已经拥有了所需的所有组件——名称和位置、圆形图像以及地图。

现在使用目前的工具,组合自定义视图,创建地标详情视图以达到最终设计吧。

步骤一

在项目导航器中,选择ContentView.swift文件。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤二

将刚才的三个文本的VStack嵌入到另一个VStack中。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤三

将自定义MapView添加到Stack顶部。使用frame(width:height:)设置MapView的大小。

仅指定height(高度)参数时,视图会自动调整其内容的宽度。在这种情况下,MapView将展开以填充可用空间。

步骤四

单击Live Preview按钮,以在预览中查看渲染的地图。

您可以在显示实时预览时继续编辑视图。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤五

CircleImage视图添加到Stack中。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤六

若要将CircleImage显示在MapView视图的上方,请使图像垂直偏移-130个点,并从视图底部填充-130个点。

这些调整通过向上移动图像为文本腾出空间。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤七

VStack中底部添加一个Spacer(),将内容推到屏幕的顶部。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

步骤八

最后,要允许地图内容扩展到屏幕的上边缘,请将edgesIgnoringSafeArea(.top)修饰符添加到地图视图中。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct ContentView: View {
var body: some View {
VStack {
MapView()
.edgesIgnoringSafeArea(.top)
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)
HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct ContentView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}

绘图和动画

绘制路径和形状

用户每次访问列表中的地标时都会收到徽章。当然,要让用户收到徽章,您需要创建一个徽章。本教程将引导您通过组合路径和形状来创建徽章,然后将其与表示位置的另一个形状重叠。

如果要为不同类型的地标创建多个徽章,请尝试使用覆盖的符号、更改重复次数或更改不同角度和比例。

学习时间:25分钟

下载地址:DrawingPathsAndShapes.zip

第一节 创建徽章视图

要创建徽章,首先要创建一个徽章视图,该视图使用SwiftUI中的矢量绘图api

步骤1

选择File > New > File,然后从“iOS模板”工作表中选择SwiftUI View。单击Next继续,然后在“Save as”字段中输入Badge并单击“Create”。

步骤2

调整Badge视图以显示文本“Badge”,接下来我们开始定义徽章形状。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct Badge: View {
var body: some View {
Text("Badge")
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

第二节 绘制徽章背景

使用SwiftUI中的图形api绘制自定义徽章形状。

步骤1

查看HexagonParameters.swift文件中的代码。

六边形参数结构定义了绘制徽章六边形的详细信息。您不会修改此数据;相反,您将使用它指定用于绘制徽章的线条和曲线的控制点。

HexagonParameters.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct HexagonParameters {
struct Segment {
let useWidth: (CGFloat, CGFloat, CGFloat)
let xFactors: (CGFloat, CGFloat, CGFloat)
let useHeight: (CGFloat, CGFloat, CGFloat)
let yFactors: (CGFloat, CGFloat, CGFloat)
}

static let adjustment: CGFloat = 0.085

static let points = [
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.60, 0.40, 0.50),
useHeight: (1.00, 1.00, 0.00),
yFactors: (0.05, 0.05, 0.00)
),
Segment(
useWidth: (1.00, 1.00, 0.00),
xFactors: (0.05, 0.00, 0.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.20 + adjustment, 0.30 + adjustment, 0.25 + adjustment)
),
Segment(
useWidth: (1.00, 1.00, 0.00),
xFactors: (0.00, 0.05, 0.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.70 - adjustment, 0.80 - adjustment, 0.75 - adjustment)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.40, 0.60, 0.50),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.95, 0.95, 1.00)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (0.95, 1.00, 1.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.80 - adjustment, 0.70 - adjustment, 0.75 - adjustment)
),
Segment(
useWidth: (1.00, 1.00, 1.00),
xFactors: (1.00, 0.95, 1.00),
useHeight: (1.00, 1.00, 1.00),
yFactors: (0.30 + adjustment, 0.20 + adjustment, 0.25 + adjustment)
)
]
}

步骤2

Badge.swift中,向Badge添加一个Path shape,并应用fill()修饰符将该形状转换为视图。

您可以使用paths来组合线条、曲线和其他绘图,以形成更复杂的形状,如徽章的六边形背景。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in

}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤3

path添加起点。

move(to:)方法在形状的边界内移动绘图光标,就像一支虚构的笔悬停在该区域上,等待开始绘图一样。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤4

为形状数据的每个点绘制线,以创建一个大致的六边形。

addLine(to:)方法接受一个点并绘制它。对addLine(to:)连续调用,会从上一点开始到新点画一条线。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(to: CGPoint(x: width * 0.95, y: height * 0.20))

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)
}
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

如果你的六边形看起来有点不寻常,不要担心;这符合预期。在接下来的几个步骤中,您将努力使六边形看起来更像本节开头显示的徽章形状。

步骤5

使用addQuadCurve(to:control:)方法为徽章的角绘制Bézier曲线。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import SwiftUI

struct Badge: View {
var body: some View {
Path { path in
var width: CGFloat = 100.0
let height = width
path.move(
to: CGPoint(
x: width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤6

将徽章path包装在GeometryReader中,以便徽章可以使用其包含视图的大小,该视图定义大小,而不是硬编码值(100)。

当包含的视图不是正方形时,使用几何体的两个维度中的最小值以保证徽章的宽高比。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
path.move(
to: CGPoint(
x: width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤7

使用xScalexOffset调整变量使徽章在其几何体中居中。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(Color.black)
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤8

将徽章的纯黑背景替换为与设计匹配的渐变色。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤9

对渐变填充应用aspectRatio(u:contentMode:)修饰符。

即使其父视图不是正方形的,可以通过它可以使视图保持1:1的宽高比,并保持其在视图中心的位置。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import SwiftUI

struct Badge: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
.aspectRatio(1, contentMode: .fit)
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

第三节 画徽章符号

地标徽章的中心有一个自定义徽章,该徽章基于地标应用程序图标中显示的山。

山体符号由两个形状组成:一个表示山顶的积雪,另一个表示沿引道的植被。您将使用两个由一个小间隙隔开的部分三角形来绘制它们。

步骤1

在名为BadgeBackground.swift的新文件中,将徽章视图的主体放入新的徽章背景视图中,以便为其他视图准备徽章视图。

BadgeBackground.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import SwiftUI

struct BadgeBackground: View {
var body: some View {
GeometryReader { geometry in
Path { path in
var width: CGFloat = min(geometry.size.width, geometry.size.height)
let height = width
let xScale: CGFloat = 0.832
let xOffset = (width * (1.0 - xScale)) / 2.0
width *= xScale
path.move(
to: CGPoint(
x: xOffset + width * 0.95,
y: height * (0.20 + HexagonParameters.adjustment)
)
)

HexagonParameters.points.forEach {
path.addLine(
to: .init(
x: xOffset + width * $0.useWidth.0 * $0.xFactors.0,
y: height * $0.useHeight.0 * $0.yFactors.0
)
)

path.addQuadCurve(
to: .init(
x: xOffset + width * $0.useWidth.1 * $0.xFactors.1,
y: height * $0.useHeight.1 * $0.yFactors.1
),
control: .init(
x: xOffset + width * $0.useWidth.2 * $0.xFactors.2,
y: height * $0.useHeight.2 * $0.yFactors.2
)
)
}
}
.fill(LinearGradient(
gradient: .init(colors: [Self.gradientStart, Self.gradientEnd]),
startPoint: .init(x: 0.5, y: 0),
endPoint: .init(x: 0.5, y: 0.6)
))
.aspectRatio(1, contentMode: .fit)
}
}
static let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
static let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
}

struct BadgeBackground_Previews: PreviewProvider {
static var previews: some View {
BadgeBackground()
}
}

步骤2

将徽章背景放在徽章主体中以恢复徽章。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct Badge: View {
var body: some View {
BadgeBackground()
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤3

为山形创建一个名为BadgeSymbol的新自定义视图,该山形在徽章设计中以旋转方式绘制。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
Text("Badge Symbol")
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤4

使用Paths api绘制符号的顶部。

实验

尝试调整与spacing、topWidth和topHeight常量关联的系数,以查看它们如何影响整体形状。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])
}
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤5

绘制符号的底部。

使用move(to:) 修饰符在同一路径中的多个形状之间插入间隙。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct BadgeSymbol: View {
var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])

path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
path.addLines([
CGPoint(x: middle - topWidth, y: topHeight + spacing),
CGPoint(x: spacing, y: height - spacing),
CGPoint(x: width - spacing, y: height - spacing),
CGPoint(x: middle + topWidth, y: topHeight + spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
])
}
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

步骤6

用设计中的紫色填充符号。

BadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct BadgeSymbol: View {
static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)

var body: some View {
GeometryReader { geometry in
Path { path in
let width = min(geometry.size.width, geometry.size.height)
let height = width * 0.75
let spacing = width * 0.030
let middle = width / 2
let topWidth = 0.226 * width
let topHeight = 0.488 * height

path.addLines([
CGPoint(x: middle, y: spacing),
CGPoint(x: middle - topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing),
CGPoint(x: middle + topWidth, y: topHeight - spacing),
CGPoint(x: middle, y: spacing)
])

path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
path.addLines([
CGPoint(x: middle - topWidth, y: topHeight + spacing),
CGPoint(x: spacing, y: height - spacing),
CGPoint(x: width - spacing, y: height - spacing),
CGPoint(x: middle + topWidth, y: topHeight + spacing),
CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
])
}
.fill(Self.symbolColor)
}
}
}

struct BadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
BadgeSymbol()
}
}

第四节 结合徽章前景和背景

徽章设计要求在徽章背景上旋转和重复多次山形。

定义一种新的rotation类型,并利用ForEach视图对山形的多个副本使用相同的调整。

步骤1

创建新的RotatedBadgeSymbol视图以封装旋转符号。

实验

调整预览中的角度以测试旋转的效果。

RotatedBadgeSymbol.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct RotatedBadgeSymbol: View {
let angle: Angle

var body: some View {
BadgeSymbol()
.padding(-60)
.rotationEffect(angle, anchor: .bottom)
}
}

struct RotatedBadgeSymbol_Previews: PreviewProvider {
static var previews: some View {
RotatedBadgeSymbol(angle: .init(degrees: 5))
}
}

步骤2

Badge.swift中,将徽章的符号放置在ZStack中,将其放置在徽章背景上。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct Badge: View {
var badgeSymbols: some View {
RotatedBadgeSymbol(angle: .init(degrees: 0))
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

self.badgeSymbols
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

现在看来,徽章符号与预期的设计和背景的相对大小相比太大了。

步骤3

通过读取周围的几何图形并缩放符号,更正徽章符号的大小。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct Badge: View {
var badgeSymbols: some View {
RotatedBadgeSymbol(angle: .init(degrees: 0))
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

GeometryReader { geometry in
self.badgeSymbols
.scaleEffect(1.0 / 4.0, anchor: .top)
.position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
}
}
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

步骤4

使用ForEach创建旋转显示多个徽章符号的副本。

一个完整的360°旋转分成八个部分,通过重复山脉符号创建一个类似太阳的图案。

Badge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import SwiftUI

struct Badge: View {
static let rotationCount = 8

var badgeSymbols: some View {
ForEach(0..<Badge.rotationCount) { i in
RotatedBadgeSymbol(
angle: .degrees(Double(i) / Double(Badge.rotationCount)) * 360.0
)
}
.opacity(0.5)
}

var body: some View {
ZStack {
BadgeBackground()

GeometryReader { geometry in
self.badgeSymbols
.scaleEffect(1.0 / 4.0, anchor: .top)
.position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height)
}
}
.scaledToFit()
}
}

struct Badge_Previews: PreviewProvider {
static var previews: some View {
Badge()
}
}

框架集成

与UIKit协作

SwiftUI与所有苹果平台上现有的UI框架无缝协作。例如,可以将UIKit视图和视图控制器放置在SwiftUI视图中,反之亦然。

本教程向您展示如何将主页的特色地标转换为包装UIPageViewController和UIPageControl实例。您将使用UIPageViewController来显示SwiftUI视图的轮播,并使用状态变量和绑定来协调整个用户界面中的数据更新。

学习时间:25分钟

下载地址:InterfacingWithUIKit.zip

第一节

创建显示UIPageViewController的视图

要在SwiftUI中显示UIKit视图和视图控制器,可以创建遵守UIViewRepresentableUIViewControllerRepresentable协议的类型,您的自定义类型创建并配置它们所表示的UIKit类型,而SwiftUI管理它们的生命周期,并在需要时更新它们。

步骤1

创建一个名为PageViewController.swift的新SwiftUI视图文件,并声明PageViewController类型遵守UIViewControllerRepresentable

页面视图控制器存储UIViewController实例的数组。这些是在地标之间滚动的页面。

PageViewController.swift

1
2
3
4
5
6
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
}

接下来,添加UIViewControllerRepresentable协议的两个方法。

步骤2

添加makeUIViewController(context:)方法,该方法创建满足需求的UIPageViewController

SwiftUI在准备好显示视图时调用此方法一次,然后管理视图控制器的生命周期。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}
}

步骤3

添加一个updateUIViewController(_:context:)方法,该方法调用setViewControllers(_:direction:animated:)以显示数组中的第一个视图控制器。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}
}

创建另一个SwiftUI视图以显示UIViewControllerRepresentable视图。

步骤4

创建一个名为PageView.swift的新SwiftUI视图文件,并更新PageView类型以将PageViewController声明为子视图。

注意,泛型初始值设定项接受一个视图数组,并将每个视图嵌套在UIHostingController中。UIHostingControllerUIViewController子类,表示UIKit上下文中的SwiftUI视图。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView()
}
}

步骤5

更新PageView_Preview以传递所需的视图数组,此时预览开始工作。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤6

在继续下一步之前,将页面视图预览固定到画布,所有操作变化都会发生在此视图上。

第二节 创建视图控制器的数据源

在几个简单的步骤中,您已经完成了很多工作—PageViewController使用一个UIPageViewController在SwiftUI视图中显示内容。现在是时候让视图轮播滚动了。

代表UIKit视图控制器的SwiftUI视图可以定义一个``类型,SwiftUI将其作为可表示视图上下文的一部分进行提供和管理。

步骤1

PageViewController中声明嵌套的Coordinator类。

SwiftUI管理UIViewControllerRepresentable类型的Coordinator,并在调用上面创建的方法时将其作为上下文的一部分提供。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
}

步骤2

PageViewController添加另一个方法以生成Coordinator

SwiftUI在makeUIViewController(context:)之前调用此makeCoordinator()方法,以便在配置视图控制器时可以访问coordinator对象。

Tips 您可以使用这个Coordinator来实现常见的Cocoa模式,例如委托、数据源和通过target-action响应用户事件。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}
}
}

步骤3

Coordinator类型添加UIPageViewControllerDataSource一致性,并实现两个必需的方法。

这两个方法建立视图控制器之间的关系,以便您可以在它们之间来回滑动。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤4

coordinator添加为UIPageViewController的数据源。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[0]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤5

打开实时预览并测试滑动交互。

第三节 在SwiftUI视图状态下跟踪页面

要准备添加自定义UIPageControl,需要一种方法从PageView中跟踪当前页。

为此,您将在PageView中声明@State属性,并将对该属性的绑定传递到PageViewController视图。PageViewController更新绑定以匹配当前可见页。

步骤1

首先添加一个currentPage绑定作为PageViewController的属性。

除了声明@Binding属性外,还要更新对setViewControllers(u:direction:animated:)的调用,传递currentPage绑定的值。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}
}
}

步骤2

PageView中声明@State变量,并在创建子PageViewController时向属性传递绑定。

重要

请记住使用$语法创建用状态来存储值的绑定。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤3

通过更改PageViewController的初始值,测试该值是否通过绑定流向PageViewController

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 1

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

实验:向PageView添加一个按钮,使PageViewController跳转到第二个视图。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 1

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
.aspectRatio(3/2, contentMode: .fit)
}
}

步骤4

添加一个带有currentPage属性的文本视图,这样您就可以监视@State属性的值。

请注意,当您从一页刷到另一页时,值不会改变。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
VStack {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
Text("Current Page: \(currentPage)")
}
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
}
}

步骤5

PageViewController.swift中,将协调器设置为UIPageViewControllerDelegate,并添加pageViewController(_:didFinishAnimating:previousViewControllers:transitionCompleted completed: Bool) 方法。

由于SwiftUI在页面切换动画完成时调用此方法,因此可以找到当前视图控制器的索引并更新绑定。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}

步骤6

除了dataSource之外,还将coordinator指定为UIPageViewController的委托。

当在两个方向上连接绑定后,文本视图会在每次刷新后更新以显示正确的页码。

PageViewController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import SwiftUI
import UIKit

struct PageViewController: UIViewControllerRepresentable {
var controllers: [UIViewController]
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIViewController(context: Context) -> UIPageViewController {
let pageViewController = UIPageViewController(
transitionStyle: .scroll,
navigationOrientation: .horizontal)
pageViewController.dataSource = context.coordinator
pageViewController.delegate = context.coordinator

return pageViewController
}

func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
pageViewController.setViewControllers(
[controllers[currentPage]], direction: .forward, animated: true)
}

class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
var parent: PageViewController

init(_ pageViewController: PageViewController) {
self.parent = pageViewController
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index == 0 {
return parent.controllers.last
}
return parent.controllers[index - 1]
}

func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController) -> UIViewController?
{
guard let index = parent.controllers.firstIndex(of: viewController) else {
return nil
}
if index + 1 == parent.controllers.count {
return parent.controllers.first
}
return parent.controllers[index + 1]
}

func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed,
let visibleViewController = pageViewController.viewControllers?.first,
let index = parent.controllers.firstIndex(of: visibleViewController)
{
parent.currentPage = index
}
}
}
}

第四节 添加自定义分页控件

您已经准备好向视图中添加自定义UIPageControl,该控件包装在SwiftUIUIViewRepresentable视图中。

步骤1

创建一个新的SwiftUI视图文件,名为PageControl.swift。更新PageControl类型以遵守UIViewRepresentable协议。

UIViewRepresentableUIViewControllerRepresentable类型具有相同的生命周期,其方法与其基础的UIKit类型相对应。

PageControl.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}
}

步骤2

将文本框替换为PageControl,从VStack切换到ZStack进行布局。

因为我们正在讲页数和绑定传递到当前页,所以PageControl已显示正确的值。

PageView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct PageView<Page: View>: View {
var viewControllers: [UIHostingController<Page>]
@State var currentPage = 0

init(_ views: [Page]) {
self.viewControllers = views.map { UIHostingController(rootView: $0) }
}

var body: some View {
ZStack(alignment: .bottomTrailing) {
PageViewController(controllers: viewControllers, currentPage: $currentPage)
PageControl(numberOfPages: viewControllers.count, currentPage: $currentPage)
.padding(.trailing)
}
}
}

struct PageView_Preview: PreviewProvider {
static var previews: some View {
PageView(features.map { FeatureCard(landmark: $0) })
}
}

下一步,让PageControl交互,这样用户可以点击一边或另一边时,页面可以随之滚动。

步骤3

PageControl中创建嵌套的Coordinator类型,并添加makeCoordinator()方法以创建并返回新的协调器。

由于UIPageControlUIControl子类,故使用target-action模式而不是代理,所以此Coordinator实现@objc方法来更新 current page的绑定。

PageControl.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}

class Coordinator: NSObject {
var control: PageControl

init(_ control: PageControl) {
self.control = control
}

@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}

步骤4

Coordinator添加为valueChanged事件的目标,指定updateCurrentPage(sender:)方法作为要执行的操作。

PageControl.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI
import UIKit

struct PageControl: UIViewRepresentable {
var numberOfPages: Int
@Binding var currentPage: Int

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = numberOfPages
control.addTarget(
context.coordinator,
action: #selector(Coordinator.updateCurrentPage(sender:)),
for: .valueChanged)

return control
}

func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
}

class Coordinator: NSObject {
var control: PageControl

init(_ control: PageControl) {
self.control = control
}

@objc func updateCurrentPage(sender: UIPageControl) {
control.currentPage = sender.currentPage
}
}
}

步骤5

现在尝试所有不同的交互,PageView展示了UIKit和SwiftUI视图以及控制器是如何协同工作的。

绘图和动画

视图动画和转场

使用SwiftUI时,无论效果在哪里,都可以对视图或视图状态的更改单独设置动画。SwiftUI负责处理这些组合、重叠和可中断动画的所有复杂性。

在本教程中,您将设置一个视图的动画,该视图包含一个图形,用于跟踪用户在使用Landmarks应用程序时的行为数据。使用animation(_:)修改器,您将看到设置视图动画是多么容易。

学习时间:25分钟

下载地址:AnimatingViewsAndTransitions.zip

第一节 向各个视图添加动画

在视图上使用animation(_:)修改器时,SwiftUI可以改变视图的任何animatable(可动画)属性。视图的颜色color、不透明度opacity、旋转rotation、大小size和其他属性都是可设置动画的。

步骤1

HikeView.swift中,打开实时预览并尝试显示和隐藏图形。

请确保在本教程中使用实时预览,以便您可以尝试每个步骤的结果。

步骤2

通过添加animation(.easeInOut())为按钮启用旋转动画。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.padding()
.animation(.easeInOut())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤3

当图形可见时,添加一个让按钮变大的动画。

animation(_:)修饰符将应用于其包装的视图中所有可动画的更改。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.easeInOut())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤4

将动画类型从easeInOut()更改为spring()

SwiftUI包括预定义或自定义宽松的基本动画,以及弹簧和流体动画。可以调整动画的速度、在动画开始之前设置延迟或指定动画重复。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤5

尝试通过在scaleEffect修改器的正上方添加另一个动画修改器来关闭旋转的动画。

实验

围绕SwiftUI尝试组合不同的动画效果,看看有什么不同的效果。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤6

在转到下一节之前,请删除两个动画(:)修改器。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))

.scaleEffect(showDetail ? 1.5 : 1)
.padding()

}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

第二节 使状态的改变产生动画效果

既然您已经学习了如何将动画应用于各个视图,现在是时候在更改状态值的位置添加动画了。

在这里,您将对用户点击按钮并切换showDetail属性时发生的所有更改添加动画。

步骤1

withAnimation函数包装showDetail.toggle()的调用。

showDetail属性影响的两个视图(disclosure按钮和HikeDetail视图)现在都具有动画过渡。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

减慢动画的速度,以查看SwiftUI动画是如何中断的。

步骤2

将4秒长的基本动画传递给withAnimation函数。

可以传递相同类型的动画给给animation(:)修饰符的withAnimation函数。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation(.easeInOut(duration: 4)) {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

步骤3

尝试在动画中打开和关闭图形视图。

步骤4

在继续下一节之前,请从withAnimation函数调用中删除慢速动画。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
}
}
}
}

第三节 自定义视图转场

默认情况下,视图通过淡入淡出在屏幕内外切换。您可以使用transition(:)修饰符自定义此转场。

步骤1

向条件可见的HikeView添加一个transition(_:)修饰符。

现在,图形通过滑动进入和退出视线而出现和消失。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import SwiftUI

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.slide)
}
}
}
}

步骤2

将转场提取为AnyTransition的静态属性。

这将在展开自定义转场时保持代码的干净。对于自定义转换,您可以使用与SwiftUI相同的.语法。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
AnyTransition.slide
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

步骤3

切换到使用move(edge:) 转场,以便图形从同一侧滑入和滑出。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
AnyTransition.move(edge: .trailing)
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

步骤4

使用asymmetric(insertion:removal:)(不对称)修饰符在视图出现和消失时提供不同的过渡。

HikeView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import SwiftUI

extension AnyTransition {
static var moveAndFade: AnyTransition {
let insertion = AnyTransition.move(edge: .trailing)
.combined(with: .opacity)
let removal = AnyTransition.scale
.combined(with: .opacity)
return .asymmetric(insertion: insertion, removal: removal)
}
}

struct HikeView: View {
var hike: Hike
@State private var showDetail = false

var body: some View {
VStack {
HStack {
HikeGraph(data: hike.observations, path: \.elevation)
.frame(width: 50, height: 30)

VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}

Spacer()

Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}

if showDetail {
HikeDetail(hike: hike)
.transition(.moveAndFade)
}
}
}
}

第四节 为复杂效果撰写动画

单击栏下方的按钮时,图形将在三组不同的数据之间切换。在本节中,将使用组合动画为构成图形的胶囊提供动态、波动的转场动画。

步骤1

showDetail的默认值更改为true,并将HikeView预览锁定到画布。

这使您在另一个文件中处理动画时依然可以在上下文中看到图表。

步骤2

GraphCapsule.swift中,添加新的计算动画属性,并将其应用于胶囊形状。

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.default
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤3

将动画切换为弹簧动画spring,减少阻尼部分dampingFraction以使条形可以跳跃。

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤4

稍微加快动画的速度,以缩短每个条形移动到新位置所需的时间。

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤5

根据胶囊在图形上的位置为每个动画添加延迟delay

GraphCapsule.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct GraphCapsule: View {
var index: Int
var height: CGFloat
var range: Range<Double>
var overallRange: Range<Double>

var heightRatio: CGFloat {
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}

var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}

var animation: Animation {
Animation.spring(dampingFraction: 0.5)
.speed(2)
.delay(0.03 * Double(index))
}

var body: some View {
Capsule()
.fill(Color.gray)
.frame(height: height * heightRatio, alignment: .bottom)
.offset(x: 0, y: height * -offsetRatio)
.animation(animation)
)
}
}

步骤6

观察自定义动画在图形之间转换时如何提供波动效果。

构建列表和导航

设置了基本地标详情视图后,需要为用户提供查看地标所有列表和查看每个地标详情的方法。

您将创建显示所有地标信息的视图,并动态生成一个滚动列表,用户可以点击该列表查看地标的详情视图。要微调UI,您将使用Xcode的画布以不同的设备大小呈现多个预览。

下载项目文件以开始构建此项目,并执行以下步骤。

学习时间:35分钟

下载示例:BuildingListsAndNavigation.zip

第一节 了解示例数据。

在第一个教程中,我们将数据硬编码到所有自定义视图中。在这里,您将学习如何将数据传递到自定义视图中以动态显示。

首先下载示例项目并熟悉示例数据。

步骤1

在项目导航器中,选择Models>Landmark.swift

Landmark.swift声明了一个Landmark结构,该结构存储应用程序需要显示的所有Landmark信息,并从landmarkData.json导入一组Landmark数据。

Landmark.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category

var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}

enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}

extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤2

在项目导航器中,选择Resources>landmarkData.json

您将在本教程的其余部分以及随后的所有内容中使用此示例数据。

landmarkData.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[
{
"name": "Turtle Rock",
"category": "Featured",
"city": "Twentynine Palms",
"state": "California",
"id": 1001,
"park": "Joshua Tree National Park",
"coordinates": {
"longitude": -116.166868,
"latitude": 34.011286
},
"imageName": "turtlerock"
},
{
"name": "Silver Salmon Creek",
"category": "Lakes",
"city": "Port Alsworth",
"state": "Alaska",
"id": 1002,
"park": "Lake Clark National Park and Preserve",
"coordinates": {
"longitude": -152.665167,
"latitude": 59.980167
},
"imageName": "silversalmoncreek"
},
...
]

步骤3

请注意,我们将ContentView类型重命名为LandmarkDetail

在本教程和以下每个教程中,您将创建多个视图类型。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct LandmarkDetail: View {
var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

第二节 创建行视图

本节中构建的第一个视图是行视图,用于显示每个地标的详细信息。此行视图声明了一个属性即landmark ,其用于存储地标信息,以便行视图可以显示任何地标。稍后,您将把多个行视图合并成一个地标列表。

步骤1

创建一个新的SwiftUI视图,名为LandmarkRow.swift

步骤2

如果预览尚未显示,请通过选择Editor > Editor and Canvas,来显示画布,然后单击“Resume”。

步骤3

添加landmark作为LandmarkRow的存储属性。

添加landmark属性时,预览将停止工作,因为LandmarkRow类型在初始化期间需要landmark实例。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
Text("Hello World")
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow()
}
}

想要修复预览,需要修改PreviewProvider

步骤4

LandmarkRow_Previewsstatic previews属性中,将landmarkData数组的第一个元素作为LandmarkRow参数添加到LandmarkRow初始值设定项中。

预览可以正常显示文本Hello World了。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
Text("Hello World")
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

修复后,可以为行视图生成布局。

步骤5

将文本视图嵌入到HStack中。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
Text("Hello World")
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤6

修改文本视图以使用landmarkname属性。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

步骤7

Text视图之前添加Image视图来完成行视图。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}

第三节 自定义行预览

Xcode的画布自动识别并显示当前编辑器中遵守PreviewProvider协议的任何类型。PreviewProvider返回一个或多个视图,并提供配置大小和设备的选项。

您可以自定义PreviewProvider返回的内容,以准确呈现对您最有帮助的预览。

步骤1

LandmarkRow_Previews中,将landmark参数更新为landmarkData数组中的第二个元素。

预览立即显示第二个示例地标,而不是第一个。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
}
}

步骤2

使用previewLayout(_:) 设置一个与列表中的行近似的大小。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}

可以使用GroupPreviewProvider返回多个视图预览。

步骤3

将返回的行包装在一个Group中,然后再次添加第一行。

Group是用于对视图内容进行分组的容器。Xcode将组的子视图呈现为画布中的单独预览。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
.previewLayout(.fixed(width: 300, height: 70))
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width: 300, height: 70))
}
}
}

步骤4

要简化代码,请将previewLayout(:)调用移到Group的外部。

视图的子级视图继承视图的上下文设置,如预览配置。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

Tips:在PreviewProvider中编写的代码只会改变Xcode在画布中显示的内容。

第四节 创建地标列表

使用SwiftUI的List类型时,可以显示特定于平台的视图列表。列表的元素可以是静态的,比如您目前创建的堆栈的子视图,也可以是动态生成的。您甚至可以混合静态和动态生成的视图。

步骤1

创建一个新的SwiftUI View,名为LandmarkList.swift

步骤2

将默认Text视图替换为List,并提供前两个Landmark作为列表子级的LandmarkRow实例。

预览显示了两个地标,它们以适合iOS的列表样式呈现。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SwiftUI

struct LandmarkList: View {
var body: some View {
List {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第五节 动态化列表

您可以直接从集合中生成行,而不是单独的指定列表中的元素。

通过传递数据集合并为集合中的每个元素提供视图的闭包,可以创建显示集合元素的列表。列表使用提供的闭包将集合中的每个元素转换为子视图。

步骤1

删除两个静态LandmarkRow,并将landmarkData 传递给Lisst初始值设定项。

List使用identifiable数据。您可以通过以下两种方式之一生产identifiable数据:1、使用key path属性标识每个元素;2、使数据遵守Identifiable协议。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in

}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

通过从闭包返回LandmarkRow来动态生成列表。

这将为landmarkData数组中的每个元素创建一个LandmarkRow

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

接下来,您将通过使Landmark遵守Identifiable来简化代码。

步骤3

切换到Landmark.swift并声明Identifiable协议。

由于Landmark类型已经具有Identifiable所需的id属性,因此没有其他工作要做。

Landmark.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI
import CoreLocation

struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var state: String
var park: String
var category: Category

var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}

enum Category: String, CaseIterable, Codable, Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}

extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}

struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}

步骤4

切换回LandmarkList.swift并删除id参数。

从现在起,您将能够直接使用Landmark的集合。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct LandmarkList: View {
var body: some View {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第六节 在列表和详情之间设置导航

列表呈现正确,但您还不能轻触每个LandmarkRow来查看该地标的详情页面。

通过把List嵌入到NavigationView中使其具有导航功能,然后在NavigationLink中嵌入每一行LandmarkRow,并设置要跳转的目标视图。

步骤1

NavigationView中嵌入动态生成的LandmarkList

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

在显示列表时,调用navigationBarTitle(:)修饰符方法设置导航栏的标题。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3

List的闭包中,将返回的Row包装在NavigationLink 中,指定LandmarkDetail视图作为跳转目标。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail()) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤4

通过切换到实时预览模式,您可以在预览中直接尝试导航功能。单击Live Preview按钮并点击地标行访问详情页。

第7节 将数据传递到子视图

LandmarkDetail视图仍然使用硬编码方式来显示地标。与LandmarkRow一样,LandmarkDetail类型及其包含的视图需要使用landmark属性作为其数据源。

从子视图开始,您将转换CircleImageMapViewLandmarkDetail以显示传入的数据,而不是对每行进行硬编码。

步骤1

CircleImage.swift中,将存储属性image添加到CircleImage

这是使用SwiftUI构建视图时的常见模式。您的自定义视图通常会包装一系列修饰符。

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct CircleImage: View {
var image: Image

var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage()
}
}

步骤2

更新CircleImage_Preview 以传递名为Turtle RockImage

CircleImage.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct CircleImage: View {
var image: Image

var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 10)
}
}

struct CircleImage_Preview: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("turtlerock"))
}
}

步骤3

MapView.swift中,向MapView添加一个coordinate属性,并将代码转换为使用该属性,而不是硬编码纬度和经度。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D

func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {

let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView()
}
}

步骤4

更新MapView_Preview以传递数据数组中第一个地标元素的坐标。

MapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D

func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}

func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}

struct MapView_Preview: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}

步骤5

LandmarkDetail.swift中,将Landmark属性添加到LandmarkDetail类中。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail()
}
}

步骤6

更新LandmarkDetail_Preview以使用landmarkData数组中的第一个地标元素。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView()
.frame(height: 300)

CircleImage()
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text("Turtle Rock")
.font(.title)

HStack(alignment: .top) {
Text("Joshua Tree National Park")
.font(.subheadline)
Spacer()
Text("California")
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤7

将所需的数据下传给自定义类型。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)

HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()

Spacer()
}
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤8

最后,调用navigationBarTitle(_:displayMode:)修饰符,在显示详情视图时为导航栏提供标题。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import SwiftUI

struct LandmarkDetail: View {
var landmark: Landmark

var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)

HStack(alignment: .top) {
Text(landmark.park)
.font(.subheadline)
Spacer()
Text(landmark.state)
.font(.subheadline)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
}
}

步骤9

SceneDelegate.swift中,将应用程序的根视图切换为LandmarkList

当在模拟器中独立运行(不是预览模式)时,您的应用程序将从SceneDelegate中定义的根视图开始。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: LandmarkList())
self.window = window
window.makeKeyAndVisible()
}
}

// ...
}

步骤10

LandmarkList.swift中,将当前Landmark传递到目标详情页。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤11

切换到实时预览以查看从列表导航到详情视图时,是否显示正确的地标。

第8节 动态生成预览

接下来,将向LandmarkList_Previews添加代码,以呈现不同设备大小的列表视图预览。默认情况下,预览以激活状态设备的大小呈现。可以通过调用previewDevice(:)修饰符方法来更改预览设备。

步骤1

首先,将当前列表预览更改为iPhone SE大小的渲染器。

您可以提供Xcodescheme菜单中显示的任何设备的名称。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.previewDevice(PreviewDevice(rawValue: "iPhone SE"))
}
}

步骤2

LandmarkList_Previews中,使用设备名称数组作为数据,将LandmarkList嵌入ForEach实例中。

ForEach对集合的操作方式与list相同,这意味着您可以在任何可以使用子视图的地方使用它,例如在stackslistgroup等中。当数据元素是简单的值类型(如您在这里使用的字符串)时,可以使用.self作为identifierkey path

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
}
}
}

步骤3

使用previewDisplayName(_:)修饰符将设备名称添加为预览的标签。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
}
}

步骤4

您可以尝试使用不同的设备来比较视图的渲染,所有这些都来自画布。

框架集成

创建watchOS应用程序

本教程为您提供了一个机会,让您可以应用您已经了解的有关SwiftUI的大部分内容,并且只需很少的努力将Landmarks应用程序迁移到watchOS

在复制为iOS应用程序创建的共享数据和视图之前,您将首先向项目中添加watchOS target。所有资源就绪后,您将自定义SwiftUI视图,以便在watchOS上显示详情视图和列表视图。

学习时间:25分钟

下载地址:CreatingAwatchOSApp.zip

第一节 添加watchOS Target

要创建watchOS应用程序,请首先将watchOS Target添加到项目中。Xcode将watchOS应用程序的文件夹和文件,以及构建和运行该应用程序所需的方案添加到项目中。

步骤1

选择File > New > Target。当工作模板表出现时,选择watchOS选项卡,选择Watch App for iOS应用程序模板,然后单击Next

此模板将新的watchOS应用程序添加到您的项目中,并与iOS应用程序配对。

步骤2

在表单中,输入WatchLandmarks作为产品名称。将语言设置为Swift,将用户界面设置为SwiftUI。选中Include Notification Scene复选框,然后单击“Finish”。

步骤3

如果Xcode提示,请单击“Activate”。

这将选择WatchLandmarks方案,以便您可以构建和运行watchOS应用程序。

步骤4

WatchLandmarks扩展的“General”选项卡中,选中Supports Running Without iOS App Installation(支持在不安装iOS应用程序的情况下运行)复选框。

尽可能创建一个独立的watchOS应用程序。独立的watchOS应用不需要iOS配套应用。

第二节 在目标之间共享文件

设置了watchOS目标后,需要共享iOS目标中的一些资源。您将重用Landmark应用程序的数据模型、一些资源文件以及两个平台都可以显示而无需修改的任何视图。

步骤1

在项目导航器中,单击命令以选择以下文件:LandmarkRow.swift、Landmark.swift、UserData.swift、Data.swift、Profile.swift、Hike.swift和CircleImage.swift。

Landmark.swift、UserData.swift、Data.swift、Profile.swift和Hike.swift定义了应用程序的数据模型。您不会使用模型的所有方面,但需要所有文件才能成功编译应用程序。LandmarkRow.swift和CircleImage.swift都是应用程序可以在watchOS上显示的视图,无需任何更改。

步骤2

在“文件检查器”中,选中“Target Membership”部分中的“WatchLandmarks Extension”复选框。

这使您在上一步中选择的文件可用于watchOS应用程序。

步骤3

在项目导航器中,选择Landmark组中的Assets.xclassets文件,并将其添加到File检查器的Target Membership部分中的WatchLandmarks Target中。

这与您在上一步中选择的Target不同。WatchLandmarks Extension Target包含你的应用程序的代码,而WatchLandmarks Target管理脚本、图标和相关资源。

步骤4

在项目导航器中,选择Resources文件夹中的所有文件,然后在File检查器的Target Membership中将它们添加到WatchLandmarks Extension target中。

第三节 创建详情视图

现在iOS目标资源已经准备好用于watch应用程序,您需要创建一个watch的视图来显示地标详情。为了测试详情视图,您将为最大和最小的手表尺寸创建自定义预览,并对圆形视图进行一些更改,以便所有内容都适合手表界面。

步骤1

在项目导航器中,单击WatchLandmarks Extension文件夹旁边的三角形以显示其内容,然后添加名为WatchLandmarkDetail的新SwiftUI视图。

步骤2

userData、landmark、landmarkIndex属性添加到WatchLandmarkDetail结构中。

这些属性与您在Handling User Input时添加到LandmarkDetail结构中的属性相同。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
Text("Hello World!")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
WatchLandmarkDetail()
}
}

在上一步中添加属性后,Xcode中将出现参数丢失错误。要修复错误,您需要执行以下两项操作之一:为属性提供默认值,或传递参数设置视图的属性。

步骤3

在预览中,创建用户数据的实例,并使用它将landmark对象传递给WatchLandmarkView结构的初始值。还需要将用户数据设置为视图的环境对象。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
Text("Hello World!")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return WatchLandmarkDetail(landmark: userData.landmarks[0])
.environmentObject(userData)
}
}

步骤4

WatchLandmarkDetail.swift中,从body()方法返回CircleImage视图。

在这里,您可以重用iOS项目中的CircleImage视图。因为您创建了一个可调整大小的图像,所以调用.scaledToFill()将调整圆的大小,使其自动适配显示。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return WatchLandmarkDetail(landmark: userData.landmarks[0])
.environmentObject(userData)
}
}

步骤5

为最大(44毫米)和最小(38毫米)的表盘创建预览。

通过对最大和最小的手表表面进行测试,你可以看到你的应用程序在屏幕上的缩放效果。一如既往,您应该在所有支持的设备大小上测试用户界面。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

圆形图像将调整大小以适配显示的高度。不幸的是,这也限制了圆的宽度。要解决裁减问题,您需要将图像嵌入VStack中,并进行一些额外的布局更改,以便圆形图像适合任何手表的宽度。

步骤6

将圆图像嵌入到VStack中。在图像下方显示地标名称及其信息。

如您所见,该信息不太适合在表盘屏幕上显示,但您可以通过将VStack放在滚动视图中来解决这个问题。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤7

在滚动视图中包装VStack

这会打开视图滚动,但会产生另一个问题:圆形图像现在会扩展到原大小,并且会调整其他UI元素的大小以匹配图像大小。您需要调整圆图像的大小,以便屏幕上只显示圆和地标名称。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFill()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤8

scaleToFill()更改为scaleToFit()

这将缩放圆图像以匹配显示器的宽度。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤9

添加padding,使地标名称在圆图像下方可见。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
.padding(16)
}
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

步骤10

后退按钮上添加标题。

这会将后退按钮的文本设置为“地标”。但是,在添加“地标列表”视图之前,您不会看到“后退”按钮。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)
}
.padding(16)
}
.navigationBarTitle("Landmarks")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

第四节 添加watchOS地图视图

现在您已经创建了基本详情视图,现在是时候添加一个地图来显示地标的位置了。与CircleImage不同,你不能重用iOS应用程序的MapView。相反,您需要创建一个WKInterfaceObjectRepresentable结构来包装WatchKit的地图视图。

步骤1

WatchKit extension添加名为WatchMapView的自定义视图。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct WatchMapView: View {
var body: some View {
Text("Hello World!")
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView()
}
}

步骤2

WatchMapView结构中,将View更改为WKInterfaceObjectRepresentable

要查看区别,请在步骤1和2所示的代码之间来回滚动。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var body: some View {
Text("Hello World!")
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView()
}
}

Xcode显示编译错误,因为WatchMapView尚未实现WKInterfaceObjectRepresentable协议属性。

步骤3

删除body()方法并将其替换为landmark属性。

无论何时创建地图视图,都需要传递此属性的值。例如,可以将landmark的实例传递给预览。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤4

实现WKInterfaceObjectRepresentablemakeWKInterfaceObject(context:) 方法。

此方法用来创建WatchMapView显示的WatchKit地图。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark

func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
return WKInterfaceMap()
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤5

通过实现WKInterfaceObjectRepresentableupdateWKInterfaceObject(_:,context:)方法,根据地标的坐标设置地图的区域。

现在,编译成功了。

WatchMapView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI

struct WatchMapView: WKInterfaceObjectRepresentable {
var landmark: Landmark

func makeWKInterfaceObject(context: WKInterfaceObjectRepresentableContext<WatchMapView>) -> WKInterfaceMap {
return WKInterfaceMap()
}

func updateWKInterfaceObject(_ map: WKInterfaceMap, context: WKInterfaceObjectRepresentableContext<WatchMapView>) {

let span = MKCoordinateSpan(latitudeDelta: 0.02,
longitudeDelta: 0.02)

let region = MKCoordinateRegion(
center: landmark.locationCoordinate,
span: span)

map.setRegion(region)
}
}

struct WatchMapView_Previews: PreviewProvider {
static var previews: some View {
WatchMapView(landmark: UserData().landmarks[0])
}
}

步骤6

选择WatchLandmarkView.swift文件并将地图视图添加到VStack的底部。

代码添加了一个分隔符,后跟地图视图。.scaledToFit().padding()修饰符将地图以合适的大小适配屏幕。

WatchLandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import SwiftUI

struct WatchLandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
ScrollView {
VStack {
CircleImage(image: self.landmark.image.resizable())
.scaledToFit()

Text(self.landmark.name)
.font(.headline)
.lineLimit(0)

Toggle(isOn:
$userData.landmarks[self.landmarkIndex].isFavorite) {
Text("Favorite")
}

Divider()

Text(self.landmark.park)
.font(.caption)
.bold()
.lineLimit(0)

Text(self.landmark.state)
.font(.caption)

Divider()

WatchMapView(landmark: self.landmark)
.scaledToFit()
.padding()
}
.padding(16)
}
.navigationBarTitle("Landmarks")
}
}

struct WatchLandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
let userData = UserData()
return Group {
WatchLandmarkDetail(landmark: userData.landmarks[0]).environmentObject(userData)
.previewDevice("Apple Watch Series 4 - 44mm")

WatchLandmarkDetail(landmark: userData.landmarks[1]).environmentObject(userData)
.previewDevice("Apple Watch Series 2 - 38mm")
}
}
}

第五节 创建跨平台列表视图

对于地标列表,您可以重用iOS应用程序中的行Row,但每个平台都需要呈现自己的详情视图。为了支持这一点,您将把LandmarkList视图转换为泛型列表类型,在这里实例化代码定义了详情视图。

步骤1 在工具栏中,选择Landmarks scheme

Xcode现在编译并运行应用程序的iOS版本。在将列表移动到watchOS应用程序之前,您要确保对LandmarkList视图的任何修改在iOS应用程序中仍然有效。

步骤2

选择LandmarkList.swift并更改类型声明,使其成为泛型类型。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

在创建LandmarkList结构的实例时,添加泛型声明会导致Generic parameter could not be inferred(无法推断泛型参数)错误。以下步骤会修复这些错误。

步骤3

为创建局部视图的闭包添加属性。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: LandmarkDetail(landmark: landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

步骤4

使用detailViewProducer属性为地标创建详情视图。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

struct LandmarksList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE", "iPhone XS Max"].identified(by: \.self)) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue: deviceName))
.previewDisplayName(deviceName)
}
.environmentObject(UserData())
}
}

创建LandmarkList实例时,还需要提供一个闭包,用于创建landmark的详情视图。

步骤5

选择Home.swift。在CategoryHome结构的body()方法中,添加闭包以创建LandmarkDetail视图。

Xcode根据闭包的返回值推断LandmarkList结构的泛型类型。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: CGFloat(200))
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList { LandmarkDetail(landmark: $0) }) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
ProfileHost()
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

// swiftlint:disable type_name
struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
.environmentObject(UserData())
}
}

步骤6

在LandmarkList.swift中,向预览添加类似的代码。

在这里,您需要使用条件判断,根据Xcode编译的当前scheme定义详情视图。现在地标应用程序可以按照预期在iOS上构建和运行了。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { PreviewDetailView(landmark: $0) }
.environmentObject(UserData())
}
}

第六节 添加地标列表

现在您已经更新了LandmarksList视图,以便它在两个平台上都能工作,您可以将其添加到watchOS应用程序中。

步骤1

在文件检查器中,将LandmarksList.swift添加到WatchLandmarks ExtensionTarget。

现在可以在watchOS应用程序的代码中使用LandmarkList视图。

步骤2

在工具栏中,将scheme更改为WatchLandmarks

步骤3

打开LandmarkList.swift,继续预览。

预览现在显示watchOS列表视图。

LandmarksList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import SwiftUI

struct LandmarkList<DetailView: View>: View {
@EnvironmentObject private var userData: UserData

let detailViewProducer: (Landmark) -> DetailView

var body: some View {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(
destination: self.detailViewProducer(landmark).environmentObject(self.userData)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}

#if os(watchOS)
typealias PreviewDetailView = WatchLandmarkDetail
#else
typealias PreviewDetailView = LandmarkDetail
#endif

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { PreviewDetailView(landmark: $0) }
.environmentObject(UserData())
}
}

watchOS应用程序的根视图是ContentView,它显示默认文本Hello World!

步骤4

修改ContentView,使其显示列表视图。

ContentView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import SwiftUI

struct ContentView: View {
var body: some View {
LandmarkList { WatchLandmarkDetail(landmark: $0) }
.environmentObject(UserData())
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
LandmarkList { WatchLandmarkDetail(landmark: $0) }
.environmentObject(UserData())
}
}

步骤5

在模拟器中编译运行watchOS应用程序。

通过滚动列表中的地标来测试watchOS应用程序的行为,点击以查看地标的详细信息,并将其标记为收藏夹。单击back按钮返回列表,然后打开Favorite开关,这样您只能看到收藏的地标。

第7节 创建自定义通知界面

你的watchOS版地标应用程序几乎完成了。在这最后一部分中,您将创建一个通知界面,每当您离最喜欢的某一位置很近时,会收到地标信息的通知信息。

注意:本节仅介绍如何在收到通知后显示通知。它不描述如何设置或发送通知。

步骤1

打开NotificationView.swift并创建一个显示有关地标、标题和消息的信息的视图。

因为任何通知值都可以为nil,所以预览将显示通知视图的两个版本。第一个仅在未提供数据时显示默认值,第二个显示您提供的标题、消息和位置。

NotificationView.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import SwiftUI

struct NotificationView: View {

let title: String?
let message: String?
let landmark: Landmark?

init(title: String? = nil,
message: String? = nil,
landmark: Landmark? = nil) {
self.title = title
self.message = message
self.landmark = landmark
}

var body: some View {
VStack {

if landmark != nil {
CircleImage(image: landmark!.image.resizable())
.scaledToFit()
}

Text(title ?? "Unknown Landmark")
.font(.headline)
.lineLimit(0)

Divider()

Text(message ?? "You are within 5 miles of one of your favorite landmarks.")
.font(.caption)
.lineLimit(0)
}
}
}

struct NotificationView_Previews: PreviewProvider {

static var previews: some View {
Group {
NotificationView()

NotificationView(title: "Turtle Rock",
message: "You are within 5 miles of Turtle Rock.",
landmark: UserData().landmarks[0])
}
.previewLayout(.sizeThatFits)
}
}

步骤2

打开NotificationController并添加landmarktitlemessage属性。

这些数据存储了有关通知传入信息。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

override var body: NotificationView {
NotificationView()
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤3

更新body()方法以使用这些属性。

此方法实例化您先前创建的通知视图。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤4

定义LandmarkIndexKey

使用此键可从通知中提取地标索引。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

let landmarkIndexKey = "landmarkIndex"

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
// This method is called when a notification needs to be presented.
// Implement it if you use a dynamic notification interface.
// Populate your dynamic notification interface as quickly as possible.
}
}

步骤5

更新didReceive(:)方法以解析通知中的数据。

此方法更新控制器的属性。调用此方法后,系统将使控制器的body属性无效,该属性将更新导航视图。然后系统在Apple Watch上显示通知。

NotificationController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import WatchKit
import SwiftUI
import UserNotifications

class NotificationController: WKUserNotificationHostingController<NotificationView> {
var landmark: Landmark?
var title: String?
var message: String?

let landmarkIndexKey = "landmarkIndex"

override var body: NotificationView {
NotificationView(title: title,
message: message,
landmark: landmark)
}

override func willActivate() {
// This method is called when watch view controller is about to be visible to user
super.willActivate()
}

override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}

override func didReceive(_ notification: UNNotification) {
let userData = UserData()

let notificationData =
notification.request.content.userInfo as? [String: Any]

let aps = notificationData?["aps"] as? [String: Any]
let alert = aps?["alert"] as? [String: Any]

title = alert?["title"] as? String
message = alert?["body"] as? String

if let index = notificationData?[landmarkIndexKey] as? Int {
landmark = userData.landmarks[index]
}
}
}

当Apple Watch收到通知时,它会创建与通知类别关联的通知控制器。若要设置通知控制器的类别,必须打开并编辑应用程序的情节提要。

步骤6

在项目导航栏中,选择“Watch Landmarks”文件夹并打开界面storyboard。选择指向静态通知界面控制器的箭头。

步骤7

在属性检查器中,将Notification Category的名称设置为LandmarkNear

配置测试负载来使用LandmarkNear类别,并传递通知控制器期望的数据。

步骤8

选择PushNotificationPayload.apns文件,并更新title、body、category和landmarkIndex属性。请务必将类别设置为LandmarkNear。您还可以删除本教程中未使用的任何键,如subtitleWatchKit Simulator ActionscustomKey

PushNotificationPayload.apns

1
2
3
4
5
6
7
8
9
10
11
12
{
"aps": {
"alert": {
"body": "You are within 5 miles of Silver Salmon Creek."
"title": "Silver Salmon Creek",
},
"category": "LandmarkNear",
"thread-id": "5280"
},

"landmarkIndex": 1
}

负载文件模拟远程通知中从服务器发送的数据。

步骤9

选择“Landmarks-Watch (Notification)” scheme,并编译和运行您的应用程序。

第一次运行通知Scheme时,系统将请求发送通知的权限。选择Allow(允许)。模拟器随后显示一个可滚动的通知,其中包括:一个用于将地标应用程序标记为发送者的框、通知视图和通知操作的按钮。

应用程序设计和布局

使用UI控件

在地标应用程序中,用户可以创建一个个人资料页来表达他们的个性。为了让用户能够更改他们的个人简介,您将添加一个编辑模式,并设计一个偏好设置页面。

您将使用各种用于数据输入的通用用户界面控件,并在用户保存更改时更新地标数据模型。

学习时间:25分钟

下载地址:WorkingWithUIControls.zip

第一节 显示用户简介

Landmarks应用程序在本地存储一些详情配置和偏好设置。在用户编辑其详情之前,它们将显示在没有任何编辑控件的摘要视图中。

步骤1

要开始,请在Landmarks目录下创建一个名为Profile的新目录,然后将名为ProfileHost的视图添加到该目录中。

ProfileHost视图将同时承载用户信息的静态摘要视图和编辑模式。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import SwiftUI

struct ProfileHost: View {
@State var draftProfile = Profile.default
var body: some View {
Text("Profile for: \(draftProfile.username)")
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

Home.swift中的静态文本替换为上一步中创建的ProfileHost

现在,主屏幕上的profile按钮将以模态方式展现用户简介。

Home.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import SwiftUI

struct CategoryHome: View {
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarkData,
by: { $0.category.rawValue }
)
}

var featured: [Landmark] {
landmarkData.filter { $0.isFeatured }
}

@State var showingProfile = false
@EnvironmentObject var userData: UserData

var profileButton: some View {
Button(action: { self.showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.imageScale(.large)
.accessibility(label: Text("User Profile"))
.padding()
}
}

var body: some View {
NavigationView {
List {
FeaturedLandmarks(landmarks: featured)
.scaledToFill()
.frame(height: 200)
.clipped()
.listRowInsets(EdgeInsets())

ForEach(categories.keys.sorted(), id: \.self) { key in
CategoryRow(categoryName: key, items: self.categories[key]!)
}
.listRowInsets(EdgeInsets())

NavigationLink(destination: LandmarkList()) {
Text("See All")
}
}
.navigationBarTitle(Text("Featured"))
.navigationBarItems(trailing: profileButton)
.sheet(isPresented: $showingProfile) {
ProfileHost()
.environmentObject(self.userData)
}
}
}
}

struct FeaturedLandmarks: View {
var landmarks: [Landmark]
var body: some View {
landmarks[0].image.resizable()
}
}

struct CategoryHome_Previews: PreviewProvider {
static var previews: some View {
CategoryHome()
}
}

步骤3

创建一个名为ProfileSummary的新视图,该视图接受一个Profile实例并显示一些基本的用户信息。

ProfileSummary持有一个Profile,比个人简介持有它好,因为父视图ProfileHost管理此视图的State

ProfileSummary.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

步骤4

更新ProfileHost以显示新的摘要视图。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct ProfileHost: View {
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤5

创建一个名为HikeBadge 的新视图,该视图由绘制路径和形状中制作的徽章以及徒步旅行的一些数据文本组成。

徽章只是一个图形,因此HikeBadge中的文本和accessibility(label:) 修饰符使徽章的含义对其他用户更清晰。

注意:

两次调用frame(width:height:)修饰符,使徽章以其设计时的尺寸300×300点进行缩放渲染。

HikeBadge.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct HikeBadge: View {
var name: String
var body: some View {
VStack(alignment: .center) {
Badge()
.frame(width: 300, height: 300)
.scaleEffect(1.0 / 3.0)
.frame(width: 100, height: 100)
Text(name)
.font(.caption)
.accessibility(label: Text("Badge for \(name)."))
}
}
}

struct HikeBadge_Previews: PreviewProvider {
static var previews: some View {
HikeBadge(name: "Preview Testing")
}
}

步骤6

更新ProfileSummary以添加不同颜色的徽章以及获得徽章的原因文字。

ProfileSummary.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")

VStack(alignment: .leading) {
Text("Completed Badges")
.font(.headline)
ScrollView {
HStack {
HikeBadge(name: "First Hike")

HikeBadge(name: "Earth Day")
.hueRotation(Angle(degrees: 90))


HikeBadge(name: "Tenth Hike")
.grayscale(0.5)
.hueRotation(Angle(degrees: 45))
}
}
.frame(height: 140)
}
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

步骤7

通过引入视图动画与转场HikeView来完成ProfileSummary

ProfileSummary.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import SwiftUI

struct ProfileSummary: View {
var profile: Profile

static let goalFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()

var body: some View {
List {
Text(profile.username)
.bold()
.font(.title)

Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")

Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")

Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")

VStack(alignment: .leading) {
Text("Completed Badges")
.font(.headline)
ScrollView {
HStack {
HikeBadge(name: "First Hike")

HikeBadge(name: "Earth Day")
.hueRotation(Angle(degrees: 90))


HikeBadge(name: "Tenth Hike")
.grayscale(0.5)
.hueRotation(Angle(degrees: 45))
}
}
.frame(height: 140)
}

VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)

HikeView(hike: hikeData[0])
}
}
}
}

struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}

第二节 添加编辑模式

用户需要在查看或编辑其简介详情之间切换。您将添加一个编辑模式,通过向现有的ProfileHost添加一个EditButton,然后创建一个带有控件的视图,用于编辑单个数据。

步骤1

添加一个Environment属性,并设置\.editMode

可以使用此属性读取和写入当前编辑范围。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

创建一个编辑按钮,用于打开和关闭环境的编辑模式。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@State var draftProfile = Profile.default
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤3

更新UserData类以包含用户简介的实例,该实例在用户关闭简介视图后仍然存在。

UserData.swift

1
2
3
4
5
6
7
8
import Combine
import SwiftUI

final class UserData: ObservableObject {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
@Published var profile = Profile.default
}

步骤4

Environment中读取Profile数据,将数据的控制权传递给ProfileHost

为了避免在确认编辑之前更新全局应用程序状态(例如当用户输入其名称时),编辑视图将对其自身的副本进行操作。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
ProfileSummary(profile: draftProfile)
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤5

添加条件视图,显示静态简介视图或编辑模式视图。

注意

目前,编辑模式只是一个静态文本字段。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
Text("Profile Editor")
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

第三节 定义用户简介编辑器

用户简介编辑器主要由不同的控件组成,这些控件更改用户简介中的各个详情信息。配置文件中的某些项目(如徽章)不可由用户编辑,因此它们不会显示在编辑器中。

为了与信息摘要保持一致,您将在编辑器中按相同的顺序添加概要文件详情信息。

步骤1

创建一个名为ProfileEditor的新视图,并包含对用户简介副本的绑定。

视图中的第一个控件是一个TextField,它控制并更新一个字符串的绑定,是用户选择的显示名称。当创建TextField时,您需要提供标签和字符串的绑定。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤2

更新ProfileHost中的条件内容,使其包含Profile Editor,并传递简介信息的绑定。

现在,单击“Edit”时将显示“简介编辑视图”。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤3

添加是否接收地标相关事件通知的开关。

Toggles是只有打开或关闭的控件,因此它们非常适合布尔值Boolean,如“yes”或“no”的设置。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤4

Picker控件及其标签放置在VStack中,使地标照片具有可选择的季节。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}

VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()

Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
ForEach(Profile.Season.allCases, id: \.self) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.top)
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

步骤5

最后,在季节选择器下面添加一个DatePicker,修改到达地标的日期。

ProfileEditor.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import SwiftUI

struct ProfileEditor: View {
@Binding var profile: Profile

var dateRange: ClosedRange<Date> {
let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)!
let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)!
return min...max
}

var body: some View {
List {
HStack {
Text("Username").bold()
Divider()
TextField("Username", text: $profile.username)
}

Toggle(isOn: $profile.prefersNotifications) {
Text("Enable Notifications")
}

VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()

Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
ForEach(Profile.Season.allCases, id: \.self) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
.padding(.top)

VStack(alignment: .leading, spacing: 20) {
Text("Goal Date").bold()
DatePicker(
"Goal Date",
selection: $profile.goalDate,
in: dateRange,
displayedComponents: .date)
}
.padding(.top)
}
}
}

struct ProfileEditor_Previews: PreviewProvider {
static var previews: some View {
ProfileEditor(profile: .constant(.default))
}
}

第四节 延迟编辑传递

要使其编辑,直到用户退出编辑模式后才生效,在编辑过程中使用其Profile的草稿副本,然后仅当用户确认编辑时才将草稿副本分配给真实副本。

步骤1

ProfileHost添加取消按钮。

EditButton提供的Done按钮不同,Cancel按钮不会将编辑应用于其闭包中的真实Profile数据。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
if self.mode?.wrappedValue == .active {
Button("Cancel") {
self.draftProfile = self.userData.profile
self.mode?.animation().wrappedValue = .inactive
}
}

Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

步骤2

应用onAppear(perform:)onDisappear(perform:)修饰符,将正确的用户简介数据填充给编辑器,并在用户点击Done按钮时更新简介数据。

否则,在下次激活编辑模式时显示旧值。

ProfileHost.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import SwiftUI

struct ProfileHost: View {
@Environment(\.editMode) var mode
@EnvironmentObject var userData: UserData
@State var draftProfile = Profile.default

var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
if self.mode?.wrappedValue == .active {
Button("Cancel") {
self.draftProfile = self.userData.profile
self.mode?.animation().wrappedValue = .inactive
}
}

Spacer()

EditButton()
}
if self.mode?.wrappedValue == .inactive {
ProfileSummary(profile: userData.profile)
} else {
ProfileEditor(profile: $draftProfile)
.onAppear {
self.draftProfile = self.userData.profile
}
.onDisappear {
self.userData.profile = self.draftProfile
}
}
}
.padding()
}
}

struct ProfileHost_Previews: PreviewProvider {
static var previews: some View {
ProfileHost()
}
}

处理用户输入

Landmarks应用程序中,用户可以标记他们最喜欢的位置,并筛选列表来仅仅显示他们最喜欢的位置。要创建此功能,首先要向列表中添加一个开关,以便用户只关注他们的收藏夹,然后添加一个星形按钮,用户点击该按钮可将地标标记到收藏夹。

学习时间:20分钟

下载示例:HandlingUserInput.zip

第一节 标记用户最喜欢的地标

从优化列表开始,让用户一目了然地看到他们的最爱。为每个显示最喜欢的地标行添加一个星。

步骤1

打开Xcode项目,然后在项目导航器中选择LandmarkRow.swift

步骤2

Spacer()之后,在if语句中添加一个星星Image,用来测试当前地标是否被收藏。

SwiftUI语句块中,使用if语句有条件地包含视图。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.imageScale(.medium)
}
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

步骤3

由于系统图像是基于矢量的,因此可以使用foregroundColor(_:)修改器更改其颜色。

当地标的isFavorite属性为true时,星星就出现了。您将在本教程后面看到如何修改该属性。

LandmarkRow.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkRow: View {
var landmark: Landmark

var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.imageScale(.medium)
.foregroundColor(.yellow)
}
}
}
}

struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}

第二节 筛选列表视图

您可以自定义列表视图,使其显示所有地标,或仅显示用户的收藏夹。为此,需要向LandmarkList类型添加@State

@State是一个值或一组值,可以随时间变化,并影响视图的行为、内容或布局。使用带有@State的属性将其添加到视图中。

步骤1

在项目导航器中选择LandmarkList.swift。将名为showFavoritesOnly@State属性添加到LandmarkList,其初始值设置为false

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = false

var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

单击“Resume”按钮刷新画布。

当您对视图的结构进行更改(如添加或修改属性)时,需要手动刷新画布。

步骤3

通过检查showFavoritesOnly属性和每个landmark.isFavorite值筛选地标列表。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = false

var body: some View {
NavigationView {
List(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

第三节 添加控件以切换状态

要让用户控制列表的筛选器,需要添加一个控件,该控件可以单独更改showFavoritesOnly的值。通过绑定toggle控件来完成此操作。

绑定是对可变状态的引用。当用户从关闭切换到打开,然后再次关闭时,控件使用绑定相应地更新视图的状态。

步骤1

将行嵌套到ForEach中。

若要在列表中组合静态视图和动态视图,或组合两个或多个不同的动态视图组,请使用ForEach类型,而不是将数据集合传递给列表。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = true

var body: some View {
NavigationView {
List {
ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤2

添加一个Toggle视图作为列表视图的第一个子视图,给showFavoritesOnly做一个绑定。

您可以使用$来访问状态变量或其绑定的属性。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import SwiftUI

struct LandmarkList: View {
@State var showFavoritesOnly = true

var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}

步骤3

使用实时预览并通过点击切换来尝试此新功能。

第四节 使用Observable Object进行存储

为了让用户控制哪些特定的地标是最喜欢的,您首先要将地标数据存储在一个Observable Object中。

Observable Object的自定义对象,可以从SwiftUI环境中的存储绑定到视图。SwiftUI监视可观察对象的任何可能影响视图的更改,并在更改后显示正确的视图。

步骤1

创建一个名为UserData.Swift的新Swift文件。

UserData.swift

步骤2

从组合框架中声明遵守ObservableObject协议的新模型类型。

SwiftUI订阅您的ObservableObject,并在数据更改时更新任何需要刷新的视图。

UserData.swift

1
2
3
4
5
6
import SwiftUI
import Combine

final class UserData: ObservableObject {

}

步骤3

添加showFavoritesOnly和地标的存储属性及其初始值。

UserData.swift

1
2
3
4
5
6
7
import SwiftUI
import Combine

final class UserData: ObservableObject {
var showFavoritesOnly = false
var landmarks = landmarkData
}

ObservableObject需要发布对其数据的任何更改,以便其订阅者可以获取更改。

步骤4

@Published属性添加到模型中的每个属性

UserData.swift

1
2
3
4
5
6
7
import SwiftUI
import Combine

final class UserData: ObservableObject {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
}

第五节 在视图中采用你的模型对象

现在您已经创建了UserData对象,您需要更新视图以将其作为应用程序的数据存储。

步骤1

LandmarkList.swift中,用@EnvironmentObject属性替换showFavoritesOnly声明,并向预览添加environmentObject(:)修饰符。

只要environmentObject(:)修饰符已应用于父对象,此userData属性就会自动获取其值。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤2

通过访问userData上的相同属性来替换showFavoritesOnly的使用。

@State属性一样,您可以使用$访问到userData对象成员的绑定。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤3

创建ForEach实例时使用userData.landmarks作为数据。

LandmarkList.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import SwiftUI

struct LandmarkList: View {
@EnvironmentObject var userData: UserData

var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

ForEach(userData.landmarks) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}

struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}

步骤4

SceneDelegate.swift中,将environmentObject(:)修饰符添加到LandmarkList

如果您在模拟器或设备上构建并运行地标,而不是使用预览,则此更新将确保地标列表在环境中有一个UserData对象。

SceneDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

// Use a UIHostingController as window root view controller
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(
rootView: LandmarkList()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
}

// ...
}

步骤5

更新LandmarkDetail视图以在环境中使用UserData对象。

在访问或更新地标的收藏状态时,您将使用地标索引,以便始终访问该数据的正确版本。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤6

切换回LandmarkList.swift并打开实时预览,以验证一切正常工作。

第六节 为每个地标创建收藏夹按钮

地标应用程序现在可以在过滤和未经过滤的地标视图之间切换,但最喜欢的地标列表仍然是硬编码的。要允许用户添加和删除收藏,需要将收藏按钮添加到地标详情视图。

步骤1

LandmarkDetail.swift中,将地标的名称嵌入HStack

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
}

HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤2

在地标名称旁边创建一个新按钮。使用if-else条件语句提供不同的图像,以指示地标是否收藏。

在按钮的action闭包中,代码使用带有userData对象的landmarkIndex来更新地标。

LandmarkDetail.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import SwiftUI

struct LandmarkDetail: View {
@EnvironmentObject var userData: UserData
var landmark: Landmark

var landmarkIndex: Int {
userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}

var body: some View {
VStack {
MapView(landmark: landmark)
.frame(height: 300)

CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)

VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)

Button(action: {
self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()
}) {
if self.userData.landmarks[self.landmarkIndex].isFavorite {
Image(systemName: "star.fill")
.foregroundColor(Color.yellow)
} else {
Image(systemName: "star")
.foregroundColor(Color.gray)
}
}
}

HStack(alignment: .top) {
Text(landmark.park)
.font(caption)
Spacer()
Text(landmark.state)
.font(.caption)
}
}
.padding()

Spacer()
}
.navigationBarTitle(Text(landmark.name), displayMode: .inline)
}
}

struct LandmarkDetail_Preview: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: landmarkData[0])
.environmentObject(UserData())
}
}

步骤3

切换回LandmarkList.swift,并打开实时预览。

当您从列表导航到详情信息并点击按钮时,这些更改将在您返回列表时保持不变。因为两个视图都在访问环境中的同一个模型对象,所以两个视图保持一致性。

User Interface

视图和控件

在屏幕上显示内容并处理用户交互。

概述

视图和控件是应用程序用户界面的可视化构建区块。使用它们在屏幕上显示应用程序的内容。视图可以描述文本、图像、形状、自定义绘图以及所有这些内容的组合。控件允许用户使用一致的API与其相应的平台和上下文进行交互。

使用指定其视觉关系和层次结构的容器合并视图。使用名为修饰符modifiers的方法自定义内置视图和为应用程序创建的视图的显示、行为和交互。

将修饰符modifiers应用于视图和控件:

  • 控制视图的大小、位置和外观属性。
  • 响应轻触、手势和其他用户交互。
  • 支持拖拽操作。
  • 自定义动画和转场。
  • 设置样式首选项和其他环境数据。

有关如何使用视图和控件的其他信息,请参见人机界面指南。

话题

摘要

protocol View

视图:用来描述SwiftUI的视图类型。

Creating and Combining Views

创建并组合视图:本教程将指导您构建地标,这是一个iOS应用程序,用于发现和共享您喜欢的地方。您将首先构建显示地标的详情视图。

Working with UI Controls

使用UI控件:在地标应用程序中,用户可以创建个人简介来表达他们的个性。为了让用户能够更改他们的个人简介,您将添加一个编辑模式并设计首选项页面。

文本 Text

struct Text

文本:显示一行或多行只读文本的视图。

struct TextField

文本输入框:显示可编辑文本的控件。

struct SecureField

密文输入框:用户安全输入私密文本的控件。

struct Font

字体:依赖于环境的字体。

图像 Images

struct Image

图像:显示依赖于环境的图像视图。

按钮 Buttons

struct Button

按钮:触控时执行操作的控件。

struct NavigationLink

导航链接:按下时触发导航显示的按钮。

struct MenuButton

菜单按钮:当按下时显示包含选项列表的菜单的按钮。

struct EditButton

编辑按钮:切换当前编辑范围的编辑模式的按钮。

struct PasteButton

粘贴按钮:触发从粘贴板读取数据的系统按钮。

值选择器 Value Selectors

struct Toggle

开关:在打开和关闭状态之间切换的控件。

struct Picker

选择器:从一组互斥值中进行选择的控件。

struct DatePicker

日期选择器:用于选择绝对日期的控件。

struct Slider

滑块:从有界线性值范围中选择值的控件。

struct Stepper

步进器:用于执行递增和递减操作的控件。

支持类型 Supporting Types

struct ViewBuilder

视图构建器:从闭包构造视图的自定义参数属性。

protocol ViewModifier

视图修饰器:应用于视图或其他视图的修饰器,生成原始值的不同版本。

protocol

View

一个表示SwiftUI视图的类型。

代码释义

声明

protocol View

概述

通过声明遵守View protocol的类型来创建自定义视图。实现所需的body计算属性,以提供自定义视图的内容和行为。

主题

1 - Implementing a Custom View - 实现自定义视图

1.1 视图的内容和行为。必需的,默认实现。

demo

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
/// 1 - Implementing a Custom View - 实现自定义视图
/// 视图的内容和行为。
/// 必需的。默认实现。
var body: some View {
Text("Hello, World!")
.background(Color.yellow)
.border(Color.red, width: 2)
}
}

2 - Sizing a Views - 制定视图的大小

2.1 将视图定位在具有指定大小的不可见框架中

1
func frame(width: CGFloat?, height: CGFloat?, alignment: Alignment) -> View

2.2 将视图定位在具有指定宽度和高度的不可见框架中。

1
func frame(minWidth: CGFloat?, idealWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, idealHeight: CGFloat?, maxHeight: CGFloat?, alignment: Alignment) -> View

2.3 将视图修复为理想大小。

2.4 将视图修复为理想大小,可指定垂直或水平方向的修复。

1
func fixedSize(horizontal: Bool, vertical: Bool) -> View

2.5 设置父布局应将空间分配给子布局的优先级,默认为0。

1
func layoutPriority(Double) -> View

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct SizingInViews: View {
/// 2 - Sizing a Views - 制定视图大小
var body: some View {
VStack {
/// 2.1 将视图定位在具有指定大小的不可见框架中。
Text("Hello, World!")
.background(Color.yellow)
.border(Color.red, width: 2)
.frame(width: 200, height: 50, alignment: .topLeading)

/// 2.2 将视图定位在具有指定宽度和高度的不可见框架中。
Text("Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello, World 2Hello")
.background(Color.yellow)
.border(Color.red, width: 2)
.frame(minWidth: 10, idealWidth: 60, maxWidth: 300, minHeight: 10, idealHeight: 60, maxHeight: 300, alignment: .topTrailing)

/// 2.3 将视图固定在其理想大小。
Text("Hello, World3")
.background(Color.yellow)
.border(Color.red, width: 2)
.fixedSize()

/// 2.4 将视图修复为理想大小,可指定垂直或水平方向的修复。
Text("Hello, World4Hello, World4HelloHello, World4Hello, World4HelloHello")
.background(Color.yellow)
.border(Color.red, width: 2)
.fixedSize(horizontal: true, vertical: false)
.frame(width: 200, height: 200)

/// 2.5 指定布局优先级 默认为0
Text("Hello, World5")
.background(Color.yellow)
.border(Color.red, width: 2)
.layoutPriority(1)

}
}
}

3 - Positioning a View - 定位视图

3.1 将视图的中心固定在其父坐标空间的指定点上。

1
func position(CGPoint) -> View

3.2 将视图的中心固定在其父坐标空间中指定的坐标上。

1
func position(x: CGFloat, y: CGFloat) -> View

3.3 通过给定Size中的widthheight偏移视图。

1
func offset(CGSize) -> View

3.4 通过指定的xy值偏移视图。

1
func offset(x: CGFloat, y: CGFloat) -> View

3.5 将视图延展到指定边缘的安全区域之外。

1
func edgesIgnoringSafeArea(Edge.Set) -> View

3.6 将名称分配给此视图的坐标空间,此视图的后代可以将其引用。

1
func coordinateSpace<T>(name: T) -> View

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct PositioningAViews: View {
/// 3 - Positioning a View - 定位视图
var body: some View {
ZStack {
/// 3.1 将视图的中心固定在其父坐标空间的指定点上。
Text("Hello, World!")
.background(Color.red)
.position(CGPoint(x: 100, y: 0))

/// 3.2 将视图的中心固定在其父坐标空间中指定的坐标上。
Text("Hello, World2")
.background(Color.green)
.position(x: 100, y: 30)

/// 3.3 通过给定`Size`中的`width`和`height`偏移视图。
Text("Hello, World3")
.background(Color.red)
.position(x: 100, y: 60)
.offset(CGSize(width: 30, height: 0))

/// 3.4 通过指定的`x`和`y`值偏移视图。
Text("Hello, World4")
.background(Color.green)
.position(x: 100, y: 90)
.offset(x: -30, y: 0)

}
.edgesIgnoringSafeArea(.top)/// 3.5 将视图延展到指定边缘的安全区域之外。
.coordinateSpace(name: "test")/// 3.6 将名称分配给此视图的坐标空间,此视图的后代可以将其引用。
}
}

4 - Aligning Views - 设置视图的对其方式

4.1 设置视图的水平对齐方式。

1
func alignmentGuide(HorizontalAlignment, computeValue: (ViewDimensions) -> CGFloat) -> View

4.2 设置视图的垂直

对齐方式。

1
func alignmentGuide(VerticalAlignment, computeValue: (ViewDimensions) -> CGFloat) -> View

4.3 视图在其自身的坐标空间中的大小和对齐方式的规则。

4.4 用于标识对齐规则的类型。

5 - Adjusting the Padding of a View - 调整视图边距

5.1 将视图沿所有边缘内嵌填充指定的大小。

1
func padding(CGFloat) -> View

5.2 使用指定的EdgeInsets内嵌填充视图。

1
func padding(EdgeInsets) -> View

5.3 使用指定的Edge集合内嵌填充指定大小。

1
func padding(Edge.Set, CGFloat?) -> View

5.4 定义了矩形各边的内嵌距离的结构体。

demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct AdjustingPaddingView: View {
/// 5 - Adjusting the Padding of a View - 调整视图边距
var body: some View {
ZStack {

Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
.background(Color.yellow)
.border(Color.red, width: 2)
.position(x: 100, y: 30)
.padding()

}
.background(Color.gray)
// .padding()/// 默认边距
// .padding(30) /// 5.1 将视图沿所有边缘内嵌填充指定的大小。
// .padding(EdgeInsets(top: 20, leading: 10, bottom: 60, trailing: 50)) /// 5.2 使用指定的`EdgeInsets`内嵌填充视图。
.padding([.leading,.trailing], 20) /// 5.3 使用指定的`Edge`集合内嵌填充指定大小。

}
}

留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau