JavaScript-编程精解-Eloquent-第四版-三-
JavaScript 编程精解(Eloquent)第四版(三)
译者:飞龙
第十三章:项目:一种编程语言
构建自己的编程语言出乎意料地简单(只要你不追求太高的目标),而且非常有启发性。
我在这一章中想要展示的主要内容是,构建编程语言并没有什么神秘之处。我常常觉得某些人类发明如此聪明复杂,以至于我永远无法理解它们。但经过一点阅读和实验,它们往往显得相当平凡。
我们将构建一种名为Egg的编程语言。它将是一个小而简单的语言——但足够强大,能够表达你能想到的任何计算。它将允许基于函数的简单抽象。
解析
编程语言最明显的部分是它的语法或符号。解析器是一个读取文本并生成反映该文本中程序结构的数据结构的程序。如果文本未形成有效的程序,解析器应该指出错误。
我们的语言将拥有简单且统一的语法。在Egg中,一切都是表达式。表达式可以是绑定的名称、数字、字符串或应用。应用用于函数调用,也用于如if或while等结构。
为了保持解析器的简单性,Egg中的字符串不支持反斜杠转义之类的功能。字符串只是一个不包含双引号的字符序列,用双引号括起来。数字是一个数字字符的序列。绑定名称可以由任何非空白字符组成,并且在语法中没有特殊含义。
应用的写法与JavaScript相同,在表达式后放置括号,并在这些括号之间放入任意数量的参数,用逗号分隔。
do(define(x, 10),
if(>(x, 5),
print("large"),
print("small")))
Egg语言的统一性意味着JavaScript中的运算符(如>)在该语言中是普通绑定,与其他函数一样被应用。由于语法没有块的概念,我们需要一个do结构来表示顺序执行多个操作。
解析器将用来描述程序的数据结构由表达式对象组成,每个对象都有一个类型属性,指示它是哪种表达式,以及其他属性来描述其内容。
类型为“值”的表达式表示字面字符串或数字。它们的值属性包含它们所表示的字符串或数字值。类型为“单词”的表达式用于标识符(名称)。这样的对象有一个名称属性,作为字符串保存标识符的名称。最后,“应用”表达式代表应用。它们有一个操作符属性,指向被应用的表达式,以及一个args属性,保存一个参数表达式的数组。
前一个程序中的>(x, 5)部分将表示为:
{
type: "apply",
operator: {type: "word", name: ">"},
args: [
{type: "word", name: "x"},
{type: "value", value: 5}
]
}
这样的数据结构被称为语法树。如果你想象这些对象为点,而它们之间的链接为这些点之间的线,如下图所示,结构呈现树状形状。表达式包含其他表达式,而这些表达式又可能包含更多的表达式,这类似于树枝的分叉和再分叉的方式。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0197-01.jpg
将其与我们在第九章中为配置文件格式编写的解析器进行对比,该解析器结构简单:它将输入分割成行,并逐行处理。允许每行的形式仅有几种简单的类型。
在这里,我们必须找到一种不同的方法。表达式不是按行分隔的,而且它们具有递归结构。应用表达式包含其他表达式。
幸运的是,这个问题可以通过编写一个递归的解析函数来很好地解决,反映出语言的递归特性。
我们定义了一个函数parseExpression,它接受一个字符串作为输入。它返回一个包含字符串开头表达式的数据结构的对象,以及解析完该表达式后剩余的字符串部分。在解析子表达式(例如应用的参数)时,可以再次调用该函数,从而得到参数表达式以及剩余的文本。该文本可能包含更多的参数,或者可能是结束参数列表的右括号。
这是解析器的第一部分:
function parseExpression(program) {
program = skipSpace(program);
let match, expr;
if (match = /^"([^"]*)"/.exec(program)) {
expr = {type: "value", value: match[1]};
} else if (match = /^\d+\b/.exec(program)) {
expr = {type: "value", value: Number(match[0])};
} else if (match = /^[^\s(),#"]+/.exec(program)) {
expr = {type: "word", name: match[0]};
} else {
throw new SyntaxError("Unexpected syntax: " + program);
}
return parseApply(expr, program.slice(match[0].length));
}
function skipSpace(string) {
let first = string.search(/\S/);
if (first == -1) return "";
return string.slice(first);
}
因为Egg(像JavaScript一样)允许元素之间有任意数量的空白,我们必须反复从程序字符串的开头去掉空白。skipSpace函数对此提供了帮助。
跳过任何前导空格后,parseExpression使用三个正则表达式来识别Egg支持的三种基本元素:字符串、数字和单词。解析器根据匹配的表达式构造不同类型的数据结构。如果输入不符合这三种形式之一,则不是有效表达式,解析器会抛出错误。我们在这里使用SyntaxError构造函数。这是一个由标准定义的异常类,类似于Error,但更加具体。
然后我们从程序字符串中截去匹配的部分,并将其与表达式对象一起传递给parseApply,后者检查该表达式是否为应用。如果是,它将解析一个括号内的参数列表。
function parseApply(expr, program) {
program = skipSpace(program);
if (program[0] != "(") {
return {expr: expr, rest: program};
}
program = skipSpace(program.slice(1));
expr = {type: "apply", operator: expr, args: []};
while (program[0] != ")") {
let arg = parseExpression(program);
expr.args.push(arg.expr);
program = skipSpace(arg.rest);
if (program[0] == ",") {
program = skipSpace(program.slice(1));
} else if (program[0] != ")") {
throw new SyntaxError("Expected ',' or ')'");
}
}
return parseApply(expr, program.slice(1));
}
如果程序中的下一个字符不是左括号,这就不是一个应用,parseApply返回它所给出的表达式。否则,它会跳过左括号,并为这个应用表达式创建语法树对象。然后,它递归调用parseExpression以解析每个参数,直到找到右括号。递归是间接的,通过parseApply和parseExpression相互调用。
因为应用表达式本身可以被应用(例如在multiplier(2)(1)中),所以parseApply在解析完一个应用后必须再次调用自身,以检查是否跟随另一个括号。
这就是解析Egg所需的一切。我们将其包装在一个方便的解析函数中,该函数在解析表达式后验证是否已经到达输入字符串的末尾(一个Egg程序是一个单一的表达式),这为我们提供了程序的数据结构。
function parse(program) {
let {expr, rest} = parseExpression(program);
if (skipSpace(rest).length > 0) {
throw new SyntaxError("Unexpected text after program");
}
return expr;
}
console.log(parse("+(a, 10)"));
// → {type: "apply",
// operator: {type: "word", name: "+"},
// args: [{type: "word", name: "a"},
// {type: "value", value: 10}]}
它有效!当它失败时并没有提供非常有用的信息,也没有存储每个表达式开始时的行和列,这在稍后报告错误时可能会很有帮助,但对于我们的目的来说已经足够了。
求值器
我们可以用程序的语法树做什么?当然是运行它!这就是求值器所做的。你给它一个语法树和一个将名称与值关联的作用域对象,它将评估树所代表的表达式并返回产生的值。
const specialForms = Object.create(null);
function evaluate(expr, scope) {
if (expr.type == "value") {
return expr.value;
} else if (expr.type == "word") {
if (expr.name in scope) {
return scope[expr.name];
} else {
throw new ReferenceError(
`Undefined binding: ${expr.name}`);
}
} else if (expr.type == "apply") {
let {operator, args} = expr;
if (operator.type == "word" &&
operator.name in specialForms) {
return specialFormsoperator.name;
} else {
let op = evaluate(operator, scope);
if (typeof op == "function") {
return op(...args.map(arg => evaluate(arg, scope)));
} else {
throw new TypeError("Applying a non-function.");
}
}
}
}
求值器为每种表达式类型都有代码。字面值表达式产生其值。(例如,表达式100评估为数字100。)对于绑定,我们必须检查它是否在作用域中实际定义,如果是,则获取绑定的值。
应用程序更复杂。如果它们是特殊形式,例如if,我们不评估任何内容——我们只是将参数表达式与作用域一起传递给处理此形式的函数。如果它是普通调用,我们评估运算符,验证它是一个函数,然后用评估后的参数调用它。
我们使用普通的JavaScript函数值来表示Egg的函数值。当定义特殊形式fun时我们会再回来讨论这一点。
求值的递归结构类似于解析器的结构,两者都反映了语言本身的结构。将解析器和求值器合并为一个函数并在解析过程中进行求值也是可能的,但这样拆分使程序更清晰、更灵活。
这实际上就是解释Egg所需的一切。就是这么简单。但如果不定义一些特殊形式并向环境中添加一些有用的值,你在这个语言中也做不了多少。
特殊形式
specialForms对象用于在Egg中定义特殊语法。它将词与评估这些形式的函数关联。目前它是空的。让我们添加if。
specialForms.if = (args, scope) => {
if (args.length != 3) {
throw new SyntaxError("Wrong number of args to if");
} else if (evaluate(args[0], scope) !== false) {
return evaluate(args[1], scope);
} else {
return evaluate(args[2], scope);
}
};
Egg的if构造期待恰好三个参数。它将评估第一个,如果结果不是值false,则将评估第二个。否则,评估第三个。这个if形式更类似于JavaScript的三元运算符?:而不是JavaScript的if。它是一个表达式,而不是语句,并且产生一个值——即第二或第三个参数的结果。
Egg在处理if的条件值时也与JavaScript不同。它只会将值false视为false,而不是像零或空字符串这样的东西。
我们需要将if表示为特殊形式而不是普通函数的原因是,所有函数的参数在调用函数之前都会被评估,而if应该只评估它的第二个或第三个参数,这取决于第一个参数的值。
while形式类似。
specialForms.while = (args, scope) => {
if (args.length != 2) {
throw new SyntaxError("Wrong number of args to while");
}
while (evaluate(args[0], scope) !== false) {
evaluate(args[1], scope);
}
// Since undefined does not exist in Egg, we return false,
// for lack of a meaningful result
return false;
};
另一个基本构建块是do,它从上到下执行所有参数。它的值是最后一个参数产生的值。
specialForms.do = (args, scope) => {
let value = false;
for (let arg of args) {
value = evaluate(arg, scope);
}
return value;
};
为了能够创建绑定并给它们赋予新值,我们还创建了一个叫做define的形式。它的第一个参数期望一个单词,第二个参数期望一个产生赋值给该单词的表达式。由于define和其他所有内容一样,是一个表达式,因此它必须返回一个值。我们将使它返回被赋予的值(就像JavaScript的=操作符)。
specialForms.define = (args, scope) => {
if (args.length != 2 || args[0].type != "word") {
throw new SyntaxError("Incorrect use of define");
}
let value = evaluate(args[1], scope);
scope[args[0].name] = value;
return value;
};
环境
evaluate接受的作用域是一个对象,其中的属性名对应于绑定名称,而属性值对应于这些绑定所绑定的值。让我们定义一个对象来表示全局作用域。
为了能够使用我们刚定义的if构造,我们必须能够访问布尔值。由于只有两个布尔值,我们不需要为它们提供特殊的语法。我们简单地将两个名称绑定到值true和false,并使用它们。
const topScope = Object.create(null);
topScope.true = true;
topScope.false = false;
现在我们可以评估一个简单的表达式,它对布尔值取反。
let prog = parse(`if(true, false, true)`);
console.log(evaluate(prog, topScope));
// → false
为了提供基本的算术和比较运算符,我们还会将一些函数值添加到作用域中。为了保持代码简洁,我们将使用Function在一个循环中合成一组运算符函数,而不是单独定义它们。
for (let op of ["+", "-", "*", "/", "==", "<", ">"]) {
topScope[op] = Function("a, b", `return a ${op} b;`);
}
也很有用的是有一种输出值的方法,因此我们将console.log包裹在一个函数中并将其命名为print。
topScope.print = value => {
console.log(value);
return value;
};
这给了我们足够的基本工具来编写简单的程序。下面的函数提供了一种方便的方式来解析程序并在新的作用域中运行它:
function run(program) {
return evaluate(parse(program), Object.create(topScope));
}
我们将使用对象原型链来表示嵌套作用域,以便程序可以向其局部作用域添加绑定,而不改变顶层作用域。
run(`
do(define(total, 0),
define(count, 1),
while(<(count, 11),
do(define(total, +(total, count)),
define(count, +(count, 1)))),
print(total))
`);
// → 55
这是我们之前多次看到的程序,它计算从1到10的数字之和,用Egg表达。显然,它比等效的JavaScript程序更丑陋,但对于一个实现少于150行代码的语言来说,这并不算坏。
函数
没有函数的编程语言确实是个糟糕的编程语言。幸运的是,添加一个函数构造并不困难,它将最后一个参数视为函数体,并使用之前的所有参数作为函数参数的名称。
specialForms.fun = (args, scope) => {
if (!args.length) {
throw new SyntaxError("Functions need a body");
}
let body = args[args.length - 1];
let params = args.slice(0, args.length - 1).map(expr => {
if (expr.type != "word") {
throw new SyntaxError("Parameter names must be words");
}
return expr.name;
});
return function(...args) {
if (args.length != params.length) {
throw new TypeError("Wrong number of arguments");
}
let localScope = Object.create(scope);
for (let i = 0; i < args.length; i++) {
localScope[params[i]] = args[i];
}
return evaluate(body, localScope);
};
};
Egg中的函数有自己的局部作用域。由fun形式产生的函数创建这个局部作用域,并将参数绑定添加到其中。然后,它在这个作用域中评估函数体并返回结果。
run(`
do(define(plusOne, fun(a, +(a, 1))),
print(plusOne(10)))
`);
// → 11
run(`
do(define(pow, fun(base, exp,
if(==(exp, 0),
1,
*(base, pow(base, -(exp, 1)))))),
print(pow(2, 10)))
`);
// → 1024
编译
我们构建的是一个解释器。在评估过程中,它直接作用于解析器生成的程序表示。
编译是指在解析和运行程序之间增加另一步的过程,它将程序转换为可以更高效地评估的东西,尽可能多地提前完成工作。例如,在设计良好的语言中,每次使用绑定时,引用的绑定是显而易见的,而无需实际运行程序。这可以用来避免每次访问时通过名称查找绑定,而是直接从某个预定的内存位置获取。
传统上,编译涉及将程序转换为机器代码,这是计算机处理器可以执行的原始格式。但任何将程序转换为不同表示的过程都可以视为编译。
有可能为Egg编写一种替代评估策略,首先将程序转换为JavaScript程序,使用Function来调用JavaScript编译器,并运行结果。做到这一点的话,Egg将运行得非常快,同时实现起来也相当简单。
如果你对这个主题感兴趣并愿意花一些时间去研究,我鼓励你尝试实现一个这样的编译器作为练习。
作弊
当我们定义if和while时,你可能注意到它们更多的是围绕JavaScript自身的if和while的简单封装。类似地,Egg中的值只是常规的JavaScript值。将其桥接到更原始的系统,例如处理器所理解的机器代码,需要更多的努力,但其工作方式与我们在这里所做的相似。
虽然本章中的玩具语言在JavaScript中可以做得更好,但在某些情况下,编写小语言确实有助于完成实际工作。
这种语言不必类似于典型的编程语言。例如,如果JavaScript没有提供正则表达式,你可以为正则表达式编写自己的解析器和评估器。
或者想象你正在构建一个程序,使得通过提供语言的逻辑描述可以快速创建解析器。你可以为此定义一种特定的符号表示法,并编写一个将其编译为解析器程序的编译器。
expr = number | string | name | application
number = digit+
name = letter+
string = '"' (! '"')* '"'
application = expr '(' (expr (',' expr)*)? ')'
这通常被称为领域特定语言,是一种旨在表达狭窄知识领域的语言。这种语言比通用语言更具表现力,因为它专门设计用来准确描述该领域内需要描述的内容,而不是其他任何东西。
练习
数组
通过向顶层作用域添加以下三个函数来为Egg添加数组支持:array(...values)用于构造一个包含参数值的数组,length(array)用于获取数组的长度,以及element(array, n)用于从数组中获取第n个元素。
闭包
我们定义函数的方式允许Egg中的函数引用周围的作用域,使得函数体可以使用在函数定义时可见的局部值,就像JavaScript函数一样。
下面的程序说明了这一点:函数f返回一个函数,该函数将其参数与f的参数相加,这意味着它需要访问f内部的局部作用域,以便能够使用绑定a。
run(`
do(define(f, fun(a, fun(b, +(a, b)))),
print(f(4)(5)))
`);
// → 9
返回到fun形式的定义,解释是什么机制使其起作用。
注释
如果我们能在Egg中写注释,那就太好了。例如,每当我们发现一个井号(#)时,可以将该行的其余部分视为注释并忽略它,类似于JavaScript中的//。
我们不需要对解析器进行任何重大修改来支持这个功能。我们只需将skipSpace更改为像跳过空格一样跳过注释,这样所有调用skipSpace的地方现在也将跳过注释。进行此更改。
修复作用域
目前,赋予绑定一个值的唯一方法是定义。这个构造既可以定义新的绑定,也可以为现有绑定赋予一个新值。
这种模糊性造成了问题。当你尝试给非局部绑定赋予一个新值时,最终会定义一个具有相同名称的局部绑定。一些语言本身就是这样设计的,但我总觉得这种处理作用域的方式很尴尬。
添加一个特殊的形式集,类似于定义,赋予绑定一个新值,如果在内部作用域中不存在该绑定,则更新外部作用域中的绑定。如果绑定根本未定义,则抛出ReferenceError(另一种标准错误类型)。
将作用域表示为简单对象的技术,虽然到目前为止让事情变得方便,但此时会有点妨碍你。你可能想使用Object.getPrototypeOf函数,它返回一个对象的原型。同时记得可以使用Object.hasOwn来判断给定对象是否具有某个属性。
第二部分:浏览器
万维网背后的梦想是创造一个共享信息的共同信息空间,通过信息共享进行沟通。它的普遍性至关重要:超文本链接可以指向任何内容,无论是个人的、本地的还是全球的,草稿还是经过精心打磨的。
—蒂姆·伯纳斯-李,《万维网:一个非常简短的个人历史》
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0208-01.jpg
第十四章:JAVASCRIPT与浏览器
本书的下一章节将讨论网页浏览器。没有浏览器,就没有JavaScript——或者即使有,也不会有人关注它。
网络技术从一开始就是去中心化的,不仅在技术上如此,在其发展方式上也是如此。各种浏览器供应商以临时和有时考虑不周的方式添加了新功能,这些功能有时被其他人采纳,最终形成标准。
这既是祝福也是诅咒。一方面,系统不被中央机构控制,而是通过不同方松散合作(或偶尔公开敌对)来改进,这是令人振奋的。另一方面,网络的发展方式杂乱无章,导致最终的系统并不完全是一个内部一致性的典范。其中一些部分令人困惑且设计糟糕。
网络与互联网
计算机网络自1950年代以来就存在。如果你在两台或多台计算机之间连接电缆,并允许它们通过这些电缆来回发送数据,你就可以做各种精彩的事情。
如果在同一栋大楼中连接两台机器可以让我们做出精彩的事情,那么连接遍布全球的机器应该会更好。实现这一愿景的技术是在1980年代开发的,结果形成的网络被称为互联网。它实现了自己的承诺。
计算机可以使用这个网络向另一台计算机发送比特。要想从这种比特发送中产生有效的通信,双方的计算机必须知道这些比特所表示的内容。任何给定比特序列的含义完全取决于它试图表达的事物类型以及所使用的编码机制。
网络协议描述了网络上的一种通信方式。存在用于发送电子邮件、获取电子邮件、共享文件,甚至控制那些被恶意软件感染的计算机的协议。
超文本传输协议(HTTP)是用于检索命名资源(信息块,如网页或图片)的协议。它规定发出请求的一方应以类似这样的行开始,命名所请求的资源及其试图使用的协议版本:
GET /index.xhtml HTTP/1.1
关于请求者如何在请求中包含更多信息,以及返回资源的另一方如何打包其内容,还有许多更多规则。我们将在第十八章中详细讨论HTTP。
大多数协议都是建立在其他协议之上的。HTTP将网络视为一个流式设备,你可以将比特放入其中,并确保它们以正确的顺序到达正确的目的地。在网络提供的原始数据发送之上提供这些保证已经是一个相当棘手的问题。
传输控制协议(TCP)是解决此问题的协议。所有连接到互联网的设备都“使用”它,大多数互联网通信都是基于此构建的。
TCP连接的工作方式如下:一台计算机必须处于等待状态,或监听,以便其他计算机开始与之通信。为了能够在同一台机器上同时监听不同类型的通信,每个监听器都有一个与之关联的数字(称为端口)。大多数协议指定了默认应使用的端口。例如,当我们希望使用SMTP协议发送电子邮件时,发送电子邮件的机器应该在端口25上进行监听。
另一台计算机可以通过使用正确的端口号连接到目标机器来建立连接。如果目标机器可以访问并在该端口上监听,则连接成功建立。监听的计算机称为服务器,而连接的计算机称为客户端。
这样的连接充当了一个双向管道,数据位可以在其中流动——两端的机器都可以向其中输入数据。一旦数据位成功传输,另一侧的机器就可以再次读取。这是一个方便的模型。可以说,TCP提供了网络的抽象。
网络
万维网(不要与整体互联网混淆)是一组允许我们在浏览器中访问网页的协议和格式。Web一词指的是这些页面可以轻松相互链接,从而连接成一个庞大的网络,用户可以在其中移动。
要成为网络的一部分,你只需将一台机器连接到互联网,并让它在端口80上使用HTTP协议进行监听,以便其他计算机可以请求文档。
网络上的每个文档都有一个统一资源定位符(URL),看起来像这样:
http://eloquentjavascript.net/13_browser.xhtml
| | | |
protocol server path
URL的第一部分告诉我们该URL使用HTTP协议(与加密的HTTP,例如https://相对)。接下来是标识我们请求文档的服务器的部分。最后是一个路径字符串,标识我们感兴趣的文档(或资源)。
连接到互联网的机器会获得一个IP 地址,这是一个可用于向该机器发送消息的数字,看起来像149.210.142.219或2001:4860:4860::8888。由于随意的数字组合很难记住且输入不便,你可以注册一个域名来指向一个地址或一组地址。我注册了eloquent javascript.net,以指向我控制的机器的IP 地址,从而可以使用该域名来提供网页。
如果你在浏览器的地址栏中输入这个URL,浏览器将尝试检索并显示该URL处的文档。首先,你的浏览器必须找出eloquentjavascript.net指的是什么地址。然后,使用HTTP协议,它将与该地址的服务器建立连接,并请求资源/13_browser.xhtml。如果一切顺利,服务器会返回一个文档,浏览器随后在你的屏幕上显示该文档。
HTML
HTML,即超文本标记语言,是用于网页的文档格式。一个 HTML 文档包含文本以及*标签*,这些标签为文本提供结构,描述诸如链接、段落和标题等内容。
一个简短的 HTML 文档可能如下所示:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>My home page</title>
</head>
<body>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.</p>
<p>I also wrote a book! Read it
<a href="http://eloquentjavascript.net">here</a>.</p>
</body>
</html>
在浏览器中,这样的文档可能看起来像这样:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0212-01.jpg
标签用尖括号(<和>,分别表示“小于”和“大于”)包裹,提供有关文档结构的信息。其他文本则只是纯文本。
文档以<!doctype html>开始,这告诉浏览器将页面解释为*现代* HTML,而不是过去使用的过时样式。
HTML 文档有一个头部和一个主体。头部包含有关文档的信息*about*,而主体包含文档本身。在这个例子中,头部声明该文档的标题是“My home page”,并且使用 UTF-8 编码,这是一种将 Unicode 文本编码为二进制数据的方法。文档的主体包含一个标题(<h1>,意为“标题 1”——<h2>到<h6>产生子标题)和两个段落(<p>)。
标签有多种形式。一个元素,例如主体、段落或链接,是由一个*开标签*开始的,例如<p>,并由一个*闭标签*结束,例如</p>。一些开标签,例如链接的标签(<a>),包含以name="value"形式表示的额外信息。这些称为*属性*。在这种情况下,链接的目标通过href="http://eloquentjavascript.net"指示,其中href代表“超文本引用”。
某些类型的标签不包含任何内容,因此不需要闭合。元数据标签<meta charset="utf-8">就是一个例子。
为了能够在文档的文本中包含尖括号,尽管它们在 HTML 中有特殊含义,还需要引入另一种特殊表示法。一个普通的开尖括号写作<(“小于”),而闭括号写作>(“大于”)。在 HTML 中,一个与名称或字符代码和分号(;)相连的&字符称为*实体*,它将被替换为它所编码的字符。
这类似于反斜杠在 JavaScript 字符串中的用法。由于此机制也使得&字符具有特殊含义,因此它们需要被转义为&。在用双引号包裹的属性值中,可以使用"来插入字面上的引号字符。
HTML 以一种非常容错的方式进行解析。当应该存在的标签缺失时,浏览器会自动添加它们。这个过程已经标准化,你可以依赖所有现代浏览器以相同的方式来执行。
以下文档将像之前显示的文档一样处理:
<!doctype html>
<meta charset=utf-8>
<title>My home page</title>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.
<p>I also wrote a book! Read it
<a href=http://eloquentjavascript.net>here</a>.
<html>、<head>和<body>标签完全消失。浏览器知道<meta>和<title>属于头部,而<h1>表示正文已经开始。此外,我不再明确关闭段落,因为打开新段落或结束文档会隐式关闭它们。属性值周围的引号也消失了。
本书通常会在示例中省略<html>、<head>和<body>标签,以保持简洁并避免杂乱。不过,我会关闭标签并在属性周围包含引号。
我通常还会省略doctype和charset声明。不要把这当作鼓励在 HTML 文档中去掉这些的理由。当你忘记这些时,浏览器往往会做出荒谬的事情。即使在示例中没有实际显示,doctype和charset元数据也应被视为隐式存在。
HTML 和 JavaScript
在本书的上下文中,最重要的 HTML 标签是<script>,它允许我们在文档中包含一段 JavaScript。
<h1>Testing alert</h1>
<script>alert("hello!");</script>
这样的脚本将在浏览器读取 HTML 时遇到<script>标签时立即运行。打开此页面时将弹出一个对话框——alert函数类似于prompt,它会弹出一个小窗口,但只显示消息而不要求输入。
直接在 HTML 文档中包含大型程序通常不切实际。<script>标签可以设置一个src属性,从 URL 中获取一个脚本文件(包含 JavaScript 程序的文本文件)。
<h1>Testing alert</h1>
<script src="code/hello.js"></script>
这里包含的code/hello.js文件包含相同的程序——alert("hello!")。当 HTML 页面引用其他 URL 作为其一部分时,例如图像文件或脚本,网络浏览器会立即检索它们并将其包含在页面中。
脚本标签必须始终以</script>关闭,即使它引用的是脚本文件并且不包含任何代码。如果你忘记这一点,页面的其余部分将被解释为脚本的一部分。
你可以通过给<script>标签添加type="module"属性在浏览器中加载 ES 模块(见第十章)。这样的模块可以通过在导入声明中使用相对于自身的 URL 作为模块名称来依赖其他模块。
一些属性也可以包含 JavaScript 程序。<button>标签(显示为按钮)支持onclick属性。每当按钮被点击时,属性的值将被执行。
<button onclick="alert('Boom!');">DO NOT PRESS</button>
注意,我必须在onclick属性的字符串中使用单引号,因为双引号已经用来引用整个属性。我也可以使用"来转义内部引号。
在沙盒中
从互联网上下载的程序潜在地危险。你对大多数你访问的网站背后的人知之甚少,他们并不一定有好的意图。运行恶意行为者的程序就是让你的计算机感染病毒、数据被盗和账户被黑的方式。
然而,网络的吸引力在于你可以浏览它,而不必信任你访问的所有页面。这就是为什么浏览器对JavaScript程序能做的事情限制得非常严格:它不能查看你电脑上的文件,也不能修改与其嵌入的网页无关的任何内容。
以这种方式隔离编程环境称为沙箱,其理念是程序在沙箱中无害地进行操作。但你应该想象这种沙箱是有厚钢栏杆围住的,以便在其中玩耍的程序无法真正逃脱。
沙箱技术的难点在于为程序提供足够的空间以保持其有用性,同时限制其进行任何危险操作。许多有用的功能,比如与其他服务器通信或读取剪贴板的内容,也可能被用于问题性和侵犯隐私的目的。
时不时会有人提出新的方法,绕过浏览器的限制并做一些有害的事情,从泄露小的私人信息到接管运行浏览器的整个机器。浏览器开发者们会通过修补漏洞来回应,事情又恢复正常——直到下一个问题被发现,并希望这次是公开的,而不是被某个政府机构或犯罪组织秘密利用。
兼容性和浏览器战争
在网络的早期阶段,一款名为Mosaic的浏览器主导了市场。几年后,市场的平衡转向了Netscape,随后又被微软的Internet Explorer大部分取代。在单一浏览器占据主导地位的时期,该浏览器的供应商往往觉得有权单方面为网络发明新功能。由于大多数用户使用最受欢迎的浏览器,网站便开始简单地使用这些功能——而不考虑其他浏览器。
这是一段兼容性黑暗时代,通常被称为浏览器战争。网页开发者们面临的不是一个统一的网络,而是两个或三个不兼容的平台。更糟糕的是,2003年左右使用的浏览器都有很多漏洞,当然每个浏览器的漏洞也各不相同。为网页编写代码的人们的生活非常艰难。
Mozilla Firefox是Netscape的一个非营利分支,在2000年代末挑战了Internet Explorer的市场地位。因为微软当时并不特别关注保持竞争力,Firefox从其手中夺走了大量市场份额。与此同时,谷歌推出了Chrome浏览器,苹果的Safari浏览器也开始受到欢迎,导致市场上出现了四大主要玩家,而非只有一个。
新的参与者对标准和工程实践持有更加严肃的态度,从而减少了不兼容性和漏洞。微软看到其市场份额急剧下降,开始采纳这些态度,在其取代Internet Explorer的Edge浏览器中实施。如果你今天开始学习网络开发,可以认为自己很幸运。主要浏览器的最新版本表现得相当一致,且相对较少出现漏洞。
不幸的是,随着Firefox的市场份额越来越小,而Edge在2018年仅仅成为Chrome内核的外壳,这种统一性可能再次变成单一供应商——这次是谷歌——在浏览器市场上拥有足够的控制权,将其对网络的看法强加于世界其他地方。
这一系列历史事件和偶然事故造就了我们今天所拥有的网络平台。在接下来的章节中,我们将为其编写程序。
真可惜!老调重弹!一旦你建好了房子,你会发现自己意外学到了一些你本该在开始之前就知道的东西。
—弗里德里希·尼采,超越善恶
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0216-01.jpg
第十五章:文档对象模型
当你打开一个网页时,浏览器检索页面的 HTML 文本并解析它,就像我们在第十二章中的解析器解析程序一样。浏览器构建文档结构的模型,并使用该模型在屏幕上绘制页面。
文档的这种表示形式是 JavaScript 程序在其沙盒中可用的玩具之一。这是一个你可以读取或修改的数据结构。它作为一个实时数据结构运作:当它被修改时,屏幕上的页面会更新以反映这些更改。
文档结构
你可以将 HTML 文档想象成一组嵌套的框。像<body>和</body>这样的标签包围其他标签,这些标签又包含其他标签或文本。以下是上一章的示例文档:
<!doctype html>
<html>
<head>
<title>My home page</title>
</head>
<body>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.</p>
<p>I also wrote a book! Read it
<a href="http://eloquentjavascript.net">here</a>.</p>
</body>
</html>
本页面具有以下结构:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0218-01.jpg
浏览器用来表示文档的数据结构遵循这种形状。对于每个框,存在一个对象,我们可以与之交互以了解它表示什么 HTML 标签以及它包含哪些框和文本。这种表示被称为文档对象模型,简称DOM。
全局绑定文档使我们可以访问这些对象。其documentElement属性指向表示<html>标签的对象。由于每个 HTML 文档都有头和主体,因此它也具有指向这些元素的head和body属性。
树
回想一下第十二章的语法树。它们的结构与浏览器文档的结构惊人地相似。每个节点可能引用其他节点,即子节点,而这些子节点又可能有自己的子节点。这种形状是嵌套结构的典型特征,其中元素可以包含与自己相似的子元素。
当数据结构具有分支结构,没有循环(一个节点不能直接或间接包含自身)并且有一个单一、明确的根时,我们称其为树。在 DOM 的情况下,document.documentElement充当根节点。
树在计算机科学中经常出现。除了表示递归结构(如 HTML 文档或程序)外,它们还常用于维护排序的数据集,因为在树中查找或插入元素通常比在平面数组中更有效。
一个典型的树具有不同类型的节点。Egg语言的语法树有标识符、值和应用节点。应用节点可以有子节点,而标识符和值则是叶子,或没有子节点的节点。
DOM 也是如此。元素的节点代表 HTML 标签,决定文档的结构。这些节点可以有子节点。一个这样的节点的例子是document.body。这些子节点中有些可以是叶子节点,例如文本片段或注释节点。
每个 DOM 节点对象都有一个nodeType属性,其中包含一个代码(数字),用以标识节点的类型。元素的代码为1,这也被定义为常量属性Node.ELEMENT_NODE。表示文档中一段文本的文本节点的代码为3(Node.TEXT_NODE)。评论的代码为8(Node.COMMENT_NODE)。
另一种可视化文档树的方法如下:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0219-01.jpg
叶子是文本节点,箭头表示节点之间的父子关系。
标准
使用晦涩的数字代码来表示节点类型并不是一个非常 JavaScript 风格的做法。本章后面我们将看到,DOM 接口的其他部分也感觉笨重且陌生。这是因为 DOM 接口并不是专为 JavaScript 设计的。相反,它试图成为一个语言中立的接口,可以在其他系统中使用——不仅仅是 HTML,还包括 XML,后者是一种具有 HTML 类似语法的通用数据格式。
这很不幸。标准通常是有用的。但在这种情况下,优势(跨语言的一致性)并不是特别令人信服。拥有一个与所使用语言良好集成的接口,比在多种语言中拥有一个熟悉的接口能节省更多时间。
作为这种不佳集成的一个例子,考虑 DOM 中元素节点拥有的childNodes属性。该属性持有一个类数组对象,具有一个length属性和用数字标记的属性以访问子节点。但它是NodeList类型的实例,而不是一个真正的数组,因此没有像slice和map这样的函数。
然后还有一些问题是由于设计不佳造成的。例如,没有办法创建一个新节点并立即添加子节点或属性。相反,你必须先创建它,然后逐个添加子节点和属性,使用副作用。与 DOM 密切互动的代码往往变得冗长、重复且难以维护。
但这些缺陷并不是致命的。由于 JavaScript 允许我们创建自己的抽象,因此可以设计改进的方法来表达我们正在执行的操作。许多用于浏览器编程的库都提供了这样的工具。
在树中移动
DOM 节点包含大量链接到其他邻近节点的信息。以下图表对此进行了说明:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0220-01.jpg
尽管图表仅显示了每种类型的一个链接,但每个节点都有一个parentNode属性,指向它所属的节点(如果有的话)。同样,每个元素节点(节点类型1)都有一个childNodes属性,指向一个类数组对象,包含它的子节点。
理论上,您可以仅使用这些父节点和子节点链接在树中任意移动。但是JavaScript还为您提供了许多其他方便的链接。firstChild和lastChild属性指向第一个和最后一个子元素,或者对于没有子元素的节点,其值为null。类似地,previousSibling和nextSibling指向相邻节点,即与节点本身具有相同父级并立即在其前后出现的节点。对于第一个子节点,previousSibling将为null,对于最后一个子节点,nextSibling将为null。
还有一个children属性,类似于childNodes,但仅包含元素(类型 1)子节点,而不包括其他类型的子节点。当您不关心文本节点时,这可能非常有用。
当处理像这样的嵌套数据结构时,递归函数通常很有用。以下函数扫描文档以查找包含给定字符串的文本节点,并在找到时返回true:
function talksAbout(node, string) {
if (node.nodeType == Node.ELEMENT_NODE) {
for (let child of node.childNodes) {
if (talksAbout(child, string)) {
return true;
}
}
return false;
} else if (node.nodeType == Node.TEXT_NODE) {
return node.nodeValue.indexOf(string) > -1;
}
}
console.log(talksAbout(document.body, "book"));
// → true
文本节点的nodeValue属性保存它所表示的文本字符串。
查找元素
在父节点、子节点和兄弟节点之间导航链接通常很有用。但是,如果我们想在文档中找到特定的节点,通过从document.body开始并遵循固定的属性路径来达到这个目的是一个不好的主意。这样做会在我们的程序中固化关于文档精确结构的假设,而这个结构以后可能会更改。另一个复杂因素是,即使是节点之间的空格也会创建文本节点。示例文档的<body>标签不仅有三个子节点(<h1>和两个<p>元素),而是有七个:这三个,加上它们之间的空格及其前后。
如果我们想要获取文档中链接的href属性,我们不希望说像“获取文档主体的第六个子节点的第二个子节点”。最好的方式是说“获取文档中的第一个链接”。我们可以这样做。
let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);
所有元素节点都有一个getElementsByTagName方法,该方法收集该节点的所有后代(直接或间接子节点)中具有给定标签名的元素,并将它们作为类似数组的对象返回。
要找到一个特定的单个节点,可以给它一个id属性,然后使用document.getElementById。
<p>My ostrich Gertrude:</p>
<p><img id="gertrude" src="img/ostrich.png"></p>
<script>
let ostrich = document.getElementById("gertrude");
console.log(ostrich.src);
</script>
第三个类似的方法是getElementsByClassName,它类似于getElementsByTagName,通过元素节点的内容搜索,并检索其类属性中包含给定字符串的所有元素。
更改文档
几乎可以更改DOM数据结构的所有内容。可以通过更改父子关系来修改文档树的形状。节点具有remove方法可以从当前父节点中删除它们。要将子节点添加到元素节点中,可以使用appendChild将其放在子节点列表的末尾,或者使用insertBefore将给定的第一个参数节点插入到给定的第二个参数节点之前。
<p>One</p>
<p>Two</p>
<p>Three</p>
<script>
let paragraphs = document.body.getElementsByTagName("p");
document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>
一个节点只能在文档中存在一个位置。因此,在段落One前插入段落Three将首先从文档末尾移除它,然后插入到前面,结果将是Three/One/Two。所有将节点插入某处的操作都会作为副作用使其从当前位置移除(如果它有位置的话)。
replaceChild方法用于用另一个节点替换子节点。它接受两个节点作为参数:一个新节点和要被替换的节点。被替换的节点必须是调用该方法的元素的子节点。请注意,replaceChild和insertBefore都期望新节点作为它们的第一个参数。
创建节点
假设我们想编写一个脚本,将文档中的所有图像(<img>标签)替换为它们alt属性中包含的文本,alt属性指定图像的替代文本表示。这不仅涉及移除图像,还需要添加一个新的文本节点来替代它们。
<p>The <img src="img/cat.png" alt="Cat"> in the
<img src="img/hat.png" alt="Hat">.</p>
<p><button onclick="replaceImages()">Replace</button></p>
<script>
function replaceImages() {
let images = document.body.getElementsByTagName("img");
for (let i = images.length - 1; i >= 0; i--) {
let image = images[i];
if (image.alt) {
let text = document.createTextNode(image.alt);
image.parentNode.replaceChild(text, image);
}
}
}
</script>
给定一个字符串,createTextNode会给我们一个文本节点,我们可以将其插入到文档中以使其在屏幕上显示。
遍历图像的循环从列表的末尾开始。这是必要的,因为像getElementsByTagName(或像childNodes这样的属性)返回的节点列表是实时的。也就是说,它会随着文档的变化而更新。如果我们从前面开始,移除第一个图像会导致列表失去第一个元素,因此在循环第二次重复时(当i为 1 时),它会停止,因为集合的长度现在也变为 1。
如果你想要一个固态的节点集合,而不是一个实时的集合,你可以通过调用Array.from将集合转换为一个真正的数组。
let arrayish = {0: "one", 1: "two", length: 2};
let array = Array.from(arrayish);
console.log(array.map(s => s.toUpperCase()));
// → ["ONE", "TWO"]
要创建元素节点,可以使用document.createElement方法。该方法接受一个标签名并返回一个给定类型的新空节点。
以下示例定义了一个工具函数elt,该函数创建一个元素节点并将其余参数视为该节点的子节点。然后使用这个函数为引文添加归属。
<blockquote id="quote">
No book can ever be finished. While working on it we learn
just enough to find it immature the moment we turn away
from it.
</blockquote>
<script>
function elt(type, ...children) {
let node = document.createElement(type);
for (let child of children) {
if (typeof child != "string") node.appendChild(child);
else node.appendChild(document.createTextNode(child));
}
return node;
}
document.getElementById("quote").appendChild(
elt("footer", "--",
elt("strong", "Karl Popper"),
", preface to the second edition of ",
elt("em", "The Open Society and Its Enemies"),
", 1950"));
</script>
这就是生成的文档的样子:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0224-01.jpg
属性
一些元素属性,比如链接的href,可以通过元素DOM对象上同名的属性访问。这是大多数常用标准属性的情况。
HTML允许你在节点上设置任何你想要的属性。这很有用,因为它允许你在文档中存储额外的信息。要读取或更改自定义属性(这些属性在常规对象属性中不可用),你必须使用getAttribute和setAttribute方法。
<p data-classified="secret">The launch code is 00000000.</p>
<p data-classified="unclassified">I have two feet.</p>
<script>
let paras = document.body.getElementsByTagName("p");
for (let para of Array.from(paras)) {
if (para.getAttribute("data-classified") == "secret") {
para.remove();
}
}
</script>
建议将这些虚构属性的名称以data-为前缀,以确保它们不会与其他属性冲突。
有一个常用的属性class,这是JavaScript语言中的一个关键字。由于历史原因——一些旧的JavaScript实现无法处理与关键字匹配的属性名——用于访问此属性的属性被称为className。你也可以通过getAttribute和setAttribute方法以其真实名称“class”访问它。
布局
你可能注意到不同类型的元素布局方式不同。一些元素,如段落(<p>)或标题(<h1>),占据文档的整个宽度,并在单独的行上渲染。这些被称为*块*元素。其他元素,如链接(<a>)或<strong>元素,则与其周围文本在同一行上渲染。这些元素被称为*内联*元素。
对于任何给定的文档,浏览器能够计算一个布局,根据元素的类型和内容为每个元素提供大小和位置。然后,这个布局被用于实际绘制文档。
元素的大小和位置可以通过JavaScript访问。offsetWidth和offsetHeight属性告诉你元素在*像素*中占据的空间。像素是浏览器中的基本测量单位。它传统上对应于屏幕能够绘制的最小点,但在现代显示器上,它可以绘制*非常*小的点,这可能不再是事实,并且浏览器像素可能跨越多个显示点。
类似地,clientWidth和clientHeight给你提供*内部*空间的大小,忽略边框宽度。
<p style="border: 3px solid red">
I'm boxed in
</p>
<script>
let para = document.body.getElementsByTagName("p")[0];
console.log("clientHeight:", para.clientHeight);
// → 19
console.log("offsetHeight:", para.offsetHeight);
// → 25
</script>
给段落添加边框会在其周围绘制一个矩形。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0226-01.jpg
找到元素在屏幕上精确位置的最有效方法是getBoundingClientRect方法。它返回一个对象,包含顶部、底部、左侧和右侧属性,指示元素相对于屏幕左上角的边缘的像素位置。如果你想要相对于整个文档的像素位置,你必须加上当前的滚动位置,可以在pageXOffset和pageYOffset绑定中找到。
布局一个文档可能需要相当多的工作。为了提高速度,浏览器引擎不会在每次更改文档时立即重新布局,而是尽可能长时间地等待。在更改文档的JavaScript程序运行结束后,浏览器将不得不计算一个新布局,以将更改后的文档绘制到屏幕上。当程序*请求*通过读取offsetHeight属性或调用getBoundingClientRect来获取某个元素的位置或大小时,提供该信息也需要计算布局。
一个不断在读取DOM布局信息和更改DOM之间交替进行的程序会迫使进行大量布局计算,因此运行会非常缓慢。以下代码就是一个例子。它包含两个不同的程序,构建一行2,000像素宽的*X*字符,并测量每个程序所需的时间。
<p><span id="one"></span></p>
<p><span id="two"></span></p>
<script>
function time(name, action) {
let start = Date.now(); // Current time in milliseconds
action();
console.log(name, "took", Date.now() - start, "ms");
}
time("naive", () => {
let target = document.getElementById("one");
while (target.offsetWidth < 2000) {
target.appendChild(document.createTextNode("X"));
}
});
// → naive took 32 ms
time("clever", function() {
let target = document.getElementById("two");
target.appendChild(document.createTextNode("XXXXX"));
let total = Math.ceil(2000 / (target.offsetWidth / 5));
target.firstChild.nodeValue = "X".repeat(total);
});
// → clever took 1 ms
</script>
样式
我们已经看到不同的HTML元素有不同的绘制方式。有些显示为块,有些则内联。有些添加样式——<strong>会使其内容变为粗体,而<a>会使其变为蓝色并加下划线。
<img>标签如何显示图像或<a>标签如何在点击时跟随链接,与元素类型密切相关。但我们可以更改与元素关联的样式,例如文本颜色或下划线。这里是一个使用样式属性的示例:
<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>
第二个链接将显示为绿色,而不是默认链接颜色。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0227-01.jpg
样式属性可以包含一个或多个*声明*,声明是属性(如颜色)后跟冒号和一个值(如绿色)。当有多个声明时,它们必须用分号分隔,如“color: red; border: none”。
文档的许多方面都可以受到样式的影响。例如,display属性控制一个元素是作为块级元素还是内联元素显示。
This text is displayed <strong>inline</strong>,
<strong style="display: block">as a block</strong>, and
<strong style="display: none">not at all</strong>.
块标签最终会单独占据一行,因为块级元素不会与周围文本内联显示。最后一个标签根本不显示——display: none阻止元素在屏幕上显示。这是一种隐藏元素的方法。通常,这种方式比将它们完全从文档中删除更可取,因为这使得以后再次显示它们变得简单。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0227-02.jpg
JavaScript代码可以通过元素的样式属性直接操作元素的样式。此属性保存一个对象,该对象具有所有可能样式属性的属性。这些属性的值是字符串,我们可以通过写入它们来更改元素样式的特定方面。
<p id="para" style="color: purple">
Nice text
</p>
<script>
let para = document.getElementById("para");
console.log(para.style.color);
para.style.color = "magenta";
</script>
一些样式属性名称包含连字符,例如font-family。由于这样的属性名称在 JavaScript 中处理起来很麻烦(你必须这样写style["font-family"]),因此此类属性在样式对象中的名称去掉了连字符,并将其后面的字母大写(style.fontFamily)。
层叠样式
HTML 的样式系统称为CSS,即层叠样式表。样式表是一组关于如何为文档中的元素添加样式的规则。它可以放在<style>标签内。
<style>
strong {
font-style: italic;
color: gray;
}
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>
名称中的层叠指的是多个此类规则结合以产生元素的最终样式。在这个例子中,<strong>标签的默认样式(使其font-weight: bold)被<style>标签中的规则覆盖,后者添加了font-style和color。
当多个规则为同一属性定义值时,最近读取的规则优先级更高并获胜。例如,如果<style>标签中的规则包含font-weight: normal,与默认的font-weight规则相矛盾,文本将显示为正常的,而不是粗体。直接应用于节点的样式属性具有最高优先级,总是获胜。
在CSS规则中,可以针对除标签名以外的其他东西。规则.abc应用于所有类属性中包含“abc”的元素。规则#xyz应用于具有 id 属性“xyz”的元素(在文档中应该是唯一的)。
.subtle {
color: gray;
font-size: 80%;
}
#header {
background: blue;
color: white;
}
/* p elements with id main and with classes a and b */
p#main.a.b {
margin-bottom: 20px;
}
优先级规则偏向于最近定义的规则,仅在规则具有相同的特异性时适用。规则的特异性是衡量其描述匹配元素的精确程度,取决于它所要求的元素方面的数量和种类(标签、类或 ID)。例如,针对p.a的规则比针对p或仅.a的规则更具特异性,因此将优先于它们。
记号p > a {...}将给定的样式应用于所有直接子元素为<p>标签的<a>标签。类似地,p a {...}将应用于所有位于<p>标签内部的<a>标签,无论它们是直接还是间接子元素。
查询选择器
在本书中我们不会频繁使用样式表。理解样式表在浏览器编程时是有帮助的,但它们足够复杂,值得单独成书。我引入选择器语法(样式表中用于确定一组样式适用哪些元素的记号)的主要原因是,我们可以使用这个相同的迷你语言作为有效的方式来查找 DOM 元素。
querySelectorAll方法在文档对象和元素节点上都被定义,它接受一个选择器字符串并返回一个包含所有匹配元素的NodeList。
<p>And if you go chasing
<span class="animal">rabbits</span></p>
<p>And you know you're going to fall</p>
<p>Tell 'em a <span class="character">hookah smoking
<span class="animal">caterpillar</span></span></p>
<p>Has given you the call</p>
<script>
function count(selector) {
return document.querySelectorAll(selector).length;
}
console.log(count("p")); // All <p> elements
// → 4
console.log(count(".animal")); // Class animal
// → 2
console.log(count("p .animal")); // Animal inside of <p>
// → 2
console.log(count("p > .animal")); // Direct child of <p>
// → 1
</script>
与getElementsByTagName等方法不同,querySelectorAll返回的对象是非活跃的。它在文档更改时不会变化。不过,它仍然不是一个真正的数组,因此如果你想将其视为数组,需要调用Array.from。
querySelector方法(没有All部分)以类似的方式工作。如果你想要特定的单个元素,这个方法很有用。它只会返回第一个匹配的元素,或者在没有匹配元素时返回null。
定位与动画
position样式属性以强大的方式影响布局。其默认值为static,意味着元素在文档中的正常位置。当设置为relative时,元素仍然占据文档中的空间,但现在可以使用top和left样式属性相对于正常位置移动它。当position设置为absolute时,元素从正常文档流中移除——即它不再占据空间,并可能与其他元素重叠。其top和left属性可以用于相对于最近的包围元素的左上角进行绝对定位(该元素的position属性不能为static),或者如果没有这样的包围元素,则相对于文档进行定位。
我们可以用这个来创建动画。以下文档显示了一只在椭圆形轨道上移动的猫的图像:
<p style="text-align: center">
<img src="img/cat.png" style="position: relative">
</p>
<script>
let cat = document.querySelector("img");
let angle = Math.PI / 2;
function animate(time, lastTime) {
if (lastTime != null) {
angle += (time - lastTime) * 0.001;
}
cat.style.top = (Math.sin(angle) * 20) + "px";
cat.style.left = (Math.cos(angle) * 200) + "px";
requestAnimationFrame(newTime => animate(newTime, time));
}
requestAnimationFrame(animate);
</script>
灰色箭头显示了图像移动的路径。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0231-01.jpg
我们的图片居中显示在页面上,并设置为相对定位。我们将不断更新该图片的顶部和左侧样式以使其移动。
脚本使用requestAnimationFrame来安排animate函数在浏览器准备好重新绘制屏幕时运行。animate函数本身再次调用requestAnimationFrame以安排下一次更新。当浏览器窗口(或标签页)处于活动状态时,这将导致以每秒大约60次的速率进行更新,从而产生良好的动画效果。
如果我们在一个循环中仅更新 DOM,页面将会冻结,屏幕上什么都不会显示。浏览器在 JavaScript 程序运行时不会更新显示,也不允许与页面进行任何交互。这就是我们需要requestAnimationFrame的原因——它让浏览器知道我们暂时完成了,浏览器可以继续进行其应做的事情,例如更新屏幕和响应用户操作。
动画函数接收当前时间作为参数。为了确保猫的运动在每毫秒内是稳定的,它根据当前时间与上次函数运行时间之间的差异来确定角度变化的速度。如果它只是以固定的量每步移动角度,当例如计算机上另一个繁重的任务阻止函数运行时,运动会出现卡顿。
圆周运动是通过三角函数Math.cos和Math.sin实现的。对于那些不熟悉这些函数的人,我将简要介绍一下,因为我们在本书中会偶尔使用它们。
Math.cos和Math.sin对于查找围绕点(0, 0)半径为1的圆上的点非常有用。这两个函数将它们的参数解释为圆上的位置,其中0表示圆的最右侧点,顺时针方向直到2*π(大约6.28)使我们走完整个圆。Math.cos告诉你与给定位置对应的点的x坐标,而Math.sin则返回y坐标。大于2*π或小于0的位置(或角度)都是有效的——旋转会重复,因此a + 2*π表示与a相同的角度。
用于测量角度的单位称为弧度——一个完整的圆是2*π弧度,类似于用度数测量时为360度。常数π在JavaScript中可以用Math.PI表示。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0232-01.jpg
猫动画代码保持一个计数器angle,用于表示当前动画的角度,并在每次调用animate函数时递增。然后可以利用这个角度来计算图像元素的当前位置。顶部样式是通过Math.sin计算得出,并乘以20,这是我们椭圆的垂直半径。左侧样式则基于Math.cos,并乘以200,使得椭圆的宽度远大于高度。
注意,样式通常需要单位。在这种情况下,我们必须在数字后附加“px”,以告诉浏览器我们是在以像素为单位计数(而不是厘米、“ems”或其他单位)。这一点容易被忘记。使用没有单位的数字会导致你的样式被忽略——除非这个数字是0,因为无论单位是什么,0总是意味着同样的东西。
概述
JavaScript程序可以通过一个称为DOM的数据结构检查和干扰浏览器正在显示的文档。这个数据结构代表了浏览器对文档的模型,JavaScript程序可以修改它以更改可见文档。
DOM的组织结构像一棵树,元素根据文档的结构以层级方式排列。表示元素的对象具有父节点和子节点等属性,可以用来遍历这棵树。
文档的显示方式可以通过样式进行影响,包括直接将样式附加到节点以及定义匹配某些节点的规则。样式属性有很多种,例如颜色或显示。JavaScript代码可以通过其样式属性直接操作元素的样式。
练习
构建一个表格
HTML表格是通过以下标签结构构建的:
<table>
<tr>
<th>name</th>
<th>height</th>
<th>place</th>
</tr>
<tr>
<td>Kilimanjaro</td>
<td>5895</td>
<td>Tanzania</td>
</tr>
</table>
对于每个行,<table>标签包含一个<tr>标签。在这些<tr>标签内部,我们可以放置单元格元素:可以是表头单元格(<th>)或常规单元格(<td>)。
给定一个包含名称、高度和地点属性的山脉数据集,生成一个DOM结构的表格来列出这些对象。每个键对应一列,每个对象对应一行,并在顶部有一个包含<th>元素的标题行,列出列名。
这样编写,以便列自动从对象中派生,通过提取数据中第一个对象的属性名称。
通过将生成的表格附加到具有id属性为“mountains”的元素中,显示结果表格。
一旦你实现了这一点,通过将其style.textAlign属性设置为“right”,使包含数字值的单元格右对齐。
按标签名称获取元素
document.getElementsByTagName方法返回具有给定标签名的所有子元素。实现你自己的版本作为一个函数,该函数接受一个节点和一个字符串(标签名)作为参数,并返回一个包含所有具有给定标签名的后代元素节点的数组。你的函数应遍历文档本身。它可能不使用像querySelectorAll这样的方式来完成工作。
要找到元素的标签名,可以使用其nodeName属性。但请注意,这将返回全大写的标签名。可以使用toLowerCase或toUpperCase字符串方法来进行补偿。
猫的帽子
扩展之前定义的猫的动画,使得猫和他的帽子(<img src="img/hat.png">)在椭圆的两侧绕行。
或者让帽子围绕猫转动。或者以其他有趣的方式改变动画。
为了更方便地定位多个对象,你可能需要切换到绝对定位。这意味着顶部和左侧的位置是相对于文档的左上角计算的。为了避免使用负坐标,这会导致图像移动到可见页面之外,你可以在位置值中添加一个固定的像素值。
你对自己的思想拥有掌控权——而不是外部事件。认识到这一点,你将找到力量。
—马库斯·奥勒留,《沉思录》
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0234-01.jpg
第十六章:处理事件
一些程序处理直接的用户输入,例如鼠标和键盘操作。这种输入无法提前以良好组织的数据结构获取——它是实时逐步到达的,程序必须在发生时对此作出反应。
事件处理程序
想象一个界面,唯一知道键盘上的某个键是否被按下的方法是读取该键的当前状态。要能够对按键反应,你必须不断读取该键的状态,以便在其再次释放之前捕捉到它。进行其他耗时的计算是危险的,因为你可能会错过一个按键。
一些原始机器以这种方式处理输入。更高级的做法是让硬件或操作系统注意到按键并将其放入队列。然后程序可以定期检查队列中的新事件,并对所找到的事件作出反应。
当然,程序必须记得查看队列,并且要经常查看,因为在按键被按下和程序注意到事件之间的任何时间都会导致软件感觉无响应。这种方法称为轮询。大多数程序员倾向于避免它。
更好的机制是让系统在事件发生时主动通知代码。浏览器通过允许我们注册函数作为特定事件的处理程序来做到这一点。
<p>Click this document to activate the handler.</p>
<script>
window.addEventListener("click", () => {
console.log("You knocked?");
});
</script>
窗口绑定是浏览器提供的一个内置对象。它代表包含文档的浏览器窗口。调用它的addEventListener方法会注册第二个参数,以便在第一个参数描述的事件发生时被调用。
事件与DOM节点
每个浏览器事件处理程序在一个上下文中注册。在前面的例子中,我们在窗口对象上调用addEventListener来注册整个窗口的处理程序。这样的一个方法也可以在DOM元素和其他类型的对象上找到。事件监听器仅在事件发生在其注册对象的上下文中时被调用。
<button>Click me</button>
<p>No handler here.</p>
<script>
let button = document.querySelector("button");
button.addEventListener("click", () => {
console.log("Button clicked.");
});
</script>
该示例将一个处理程序附加到按钮节点上。点击按钮会导致该处理程序运行,但点击文档的其他部分则不会。
给节点一个onclick属性有类似的效果。这适用于大多数类型的事件——你可以通过名称为事件名称并在前面加上on的属性来附加处理程序。
但是一个节点只能有一个onclick属性,因此你只能通过这种方式为每个节点注册一个处理程序。addEventListener方法允许你添加任意数量的处理程序,这意味着即使元素上已经有另一个处理程序,也可以安全地添加新的处理程序。
removeEventListener方法与addEventListener的参数类似,用于移除一个处理程序。
<button>Act-once button</button>
<script>
let button = document.querySelector("button");
function once() {
console.log("Done.");
button.removeEventListener("click", once);
}
button.addEventListener("click", once);
</script>
传递给removeEventListener的函数必须是传递给addEventListener的同一个函数值。当你需要注销一个处理程序时,你会想给处理程序函数一个名称(在示例中只需一次),以便能够将相同的函数值传递给这两个方法。
事件对象
尽管我们到目前为止忽略了这一点,事件处理函数会接收一个参数:事件对象。这个对象包含关于事件的附加信息。例如,如果我们想知道哪个鼠标按钮被按下,我们可以查看事件对象的button属性。
<button>Click me any way you want</button>
<script>
let button = document.querySelector("button");
button.addEventListener("mousedown", event => {
if (event.button == 0) {
console.log("Left button");
} else if (event.button == 1) {
console.log("Middle button");
} else if (event.button == 2) {
console.log("Right button");
}
});
</script>
存储在事件对象中的信息因事件类型而异。(我们将在本章后面讨论不同的类型。)对象的type属性始终保存一个字符串,用于标识事件(例如“click”或“mousedown”)。
传播
对于大多数事件类型,在具有子节点的节点上注册的处理程序也会接收子节点发生的事件。如果段落内部的按钮被点击,段落上的事件处理程序也会看到点击事件。
但是如果段落和按钮都有处理程序,更具体的处理程序——按钮上的那个——将优先执行。事件被称为从发生的节点传播到该节点的父节点,再到文档的根节点。最后,在特定节点上注册的所有处理程序轮流执行后,注册在整个窗口上的处理程序也会有机会响应该事件。
在任何时刻,事件处理程序都可以在事件对象上调用stopPropagation方法,以防止更高层的处理程序接收该事件。这在某些情况下是有用的,例如,当你在另一个可点击元素内部有一个按钮时,你不希望按钮的点击激活外部元素的点击行为。
以下示例在按钮和周围的段落上注册“mousedown”处理程序。当用右键点击时,按钮的处理程序调用stopPropagation,这将防止段落上的处理程序运行。当用其他鼠标按钮点击按钮时,两个处理程序都会运行。
<p>A paragraph with a <button>button</button>.</p>
<script>
let para = document.querySelector("p");
let button = document.querySelector("button");
para.addEventListener("mousedown", () => {
console.log("Handler for paragraph.");
});
button.addEventListener("mousedown", event => {
console.log("Handler for button.");
if (event.button == 2) event.stopPropagation();
});
</script>
大多数事件对象都有一个指向其来源节点的target属性。你可以使用这个属性确保你不会意外处理来自不想处理的节点的传播事件。
也可以使用target属性对特定类型的事件进行广泛捕获。例如,如果你有一个包含长列表按钮的节点,注册一个点击处理程序在外部节点上可能更方便,并使用target属性来判断是否点击了某个按钮,而不是在所有按钮上注册单独的处理程序。
<button>A</button>
<button>B</button>
<button>C</button>
<script>
document.body.addEventListener("click", event => {
if (event.target.nodeName == "BUTTON") {
console.log("Clicked", event.target.textContent);
}
});
</script>
默认行为
许多事件都有默认行为。如果你点击一个链接,你将被带到链接的目标。如果你按下向下箭头,浏览器会向下滚动页面。如果你右键单击,你将获得一个上下文菜单。等等。
对于大多数类型的事件,JavaScript 事件处理程序在默认行为发生之前被调用。如果处理程序不希望发生这种正常行为,通常是因为它已经处理了事件,可以在事件对象上调用preventDefault方法。
这可以用来实现你自己的键盘快捷键或上下文菜单。它也可以用来干扰用户期望的行为。例如,这里有一个无法被点击的链接:
<a href="https://developer.mozilla.org/">MDN</a>
<script>
let link = document.querySelector("a");
link.addEventListener("click", event => {
console.log("Nope.");
event.preventDefault();
});
</script>
尽量不要在没有充分理由的情况下这样做。当预期的行为被打破时,这会让使用你页面的人感到不愉快。
根据浏览器的不同,有些事件根本无法被拦截。在 Chrome 中,例如,关闭当前标签页的键盘快捷键(CTRL-W或COMMAND-W)无法通过 JavaScript 处理。
键事件
当键盘上的一个键被按下时,你的浏览器会触发一个"keydown"事件。当它被释放时,你会得到一个"keyup"事件。
<p>This page turns violet when you hold the V key.</p>
<script>
window.addEventListener("keydown", event => {
if (event.key == "v") {
document.body.style.background = "violet";
}
});
window.addEventListener("keyup", event => {
if (event.key == "v") {
document.body.style.background = "";
}
});
</script>
尽管名称如此,"keydown"不仅在键被物理按下时触发。当一个键被按下并保持时,该事件会在每次键重复时再次触发。有时你需要对此格外小心。例如,如果你在按下键时向 DOM 添加一个按钮,并在释放键时将其移除,可能在按住键的过程中意外添加数百个按钮。
上一个示例查看事件对象的key属性,以了解该事件是关于哪个键的。该属性保存一个字符串,对于大多数键,对应于按下该键时会输入的内容。对于特殊键,如ENTER,它保存一个字符串来命名该键(在本例中为"Enter")。如果你在按下键时同时按住SHIFT,这可能也会影响键的名称——"v"变为"V",而"1"可能变为"!"(如果按SHIFT-1时你键盘上产生这样的结果)。
修饰键,如SHIFT、CTRL、ALT和META(在 Mac 上为COMMAND),生成的键事件与普通键一样。当查找键组合时,你也可以通过查看键盘和鼠标事件的shiftKey、ctrlKey、altKey和metaKey属性来了解这些键是否被按下。
<p>Press Control-Space to continue.</p>
<script>
window.addEventListener("keydown", event => {
if (event.key == " " && event.ctrlKey) {
console.log("Continuing!");
}
});
</script>
键事件来源的 DOM 节点取决于按下键时哪个元素具有焦点。大多数节点无法获得焦点,除非你给它们一个tabindex属性,但像链接、按钮和表单字段这样的元素可以获得焦点。我们将在第十八章中再次讨论表单字段。当没有特别的元素获得焦点时,document.body会作为键事件的目标节点。
当用户输入文本时,使用键事件来判断正在输入的内容是有问题的。一些平台,尤其是安卓手机上的虚拟键盘,不会触发键事件。但即使你使用的是传统键盘,有些类型的文本输入也不会简单地与按键相匹配,例如输入法编辑器(IME)软件,它用于那些脚本无法在键盘上完全适配的人,其中多个按键组合以创建字符。
为了注意到何时输入了内容,可以输入的元素,如<input>和<textarea>标签,每当用户更改其内容时会触发"input"事件。要获取实际输入的内容,最好直接从聚焦的字段中读取,我们在第十八章中讨论了这一点。
指针事件
目前有两种广泛使用的指向屏幕上事物的方式:鼠标(包括像触控板和轨迹球等起到鼠标作用的设备)和触摸屏。这些设备会产生不同类型的事件。
鼠标点击
按下鼠标按钮会触发多个事件。"mousedown"和"mouseup"事件类似于"keydown"和"keyup",分别在按钮按下和释放时触发。这些事件发生在事件发生时位于鼠标指针正下方的 DOM 节点上。
在"mouseup"事件之后,会在包含按钮按下和释放的最具体节点上触发一个"click"事件。例如,如果我在一个段落上按下鼠标按钮,然后将指针移动到另一个段落并释放按钮,"click"事件将发生在包含这两个段落的元素上。
如果两个点击发生得很接近,会触发一个"dblclick"(双击)事件,发生在第二次点击事件之后。
要获取关于鼠标事件发生位置的精确信息,可以查看其clientX和clientY属性,这些属性包含事件相对于窗口左上角的坐标(以像素为单位),或者pageX和pageY,这些是相对于整个文档左上角的坐标(当窗口滚动时可能不同)。
以下程序实现了一个原始的绘图应用程序。每次你点击文档时,它会在你的鼠标指针下添加一个点。
<style>
body {
height: 200px;
background: beige;
}
.dot {
height: 8px; width: 8px;
border-radius: 4px; /* Rounds corners */
background: teal;
position: absolute;
}
</style>
<script>
window.addEventListener("click", event => {
let dot = document.createElement("div");
dot.className = "dot";
dot.style.left = (event.pageX - 4) + "px";
dot.style.top = (event.pageY - 4) + "px";
document.body.appendChild(dot);
});
</script>
我们将在第十九章中创建一个不那么原始的绘图应用程序。
鼠标运动
每当鼠标指针移动时,都会触发"mousemove"事件。此事件可以用于跟踪鼠标的位置。这在实现某种形式的鼠标拖动功能时特别有用。
作为一个示例,以下程序显示了一个条,并设置了事件处理程序,以便在该条上向左或向右拖动时使其变窄或变宽:
<p>Drag the bar to change its width:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
let lastX; // Tracks the last observed mouse X position
let bar = document.querySelector("div");
bar.addEventListener("mousedown", event => {
if (event.button == 0) {
lastX = event.clientX;
window.addEventListener("mousemove", moved);
event.preventDefault(); // Prevent selection
}
});
function moved(event) {
if (event.buttons == 0) {
window.removeEventListener("mousemove", moved);
} else {
let dist = event.clientX - lastX;
let newWidth = Math.max(10, bar.offsetWidth + dist);
bar.style.width = newWidth + "px";
lastX = event.clientX;
}
}
</script>
最终页面看起来是这样的:
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0242-01.jpg>
请注意,"mousemove"处理程序注册在整个窗口上。即使在调整大小时鼠标移出条的范围,只要按钮被按住,我们仍然希望更新其大小。
我们必须在鼠标按钮释放时停止调整条的大小。为此,我们可以使用buttons属性(注意复数形式),它告诉我们当前被按下的按钮。当值为0时,表示没有按钮被按下。当按钮被按下时,buttons属性的值是这些按钮代码的总和——左键的代码是1,右键是2,中间键是4。例如,当左键和右键同时按下时,buttons的值将为3。
请注意,这些代码的顺序与button使用的顺序不同,在那里中间按钮在右边按钮之前。如前所述,一致性并不是浏览器编程接口的强项。
触摸事件
我们使用的图形浏览器样式是在触摸屏较为稀少的时代,以鼠标接口为设计理念的。为了使早期触摸屏手机上的网页“工作”,这些设备的浏览器在一定程度上假装触摸事件是鼠标事件。如果你轻触屏幕,会触发“mousedown”、“mouseup”和“click”事件。
但这种错觉并不是很稳健。触摸屏的工作方式与鼠标不同:它没有多个按钮,当手指不在屏幕上时,你无法追踪手指(以模拟“mousemove”),而且允许多个手指同时在屏幕上。
鼠标事件仅在简单情况下覆盖触摸交互——如果你为按钮添加“点击”处理程序,触摸用户仍然可以使用它。但像前面示例中的可调整大小条在触摸屏上则无法工作。
触摸交互会触发特定的事件类型。当手指开始接触屏幕时,你会收到一个“touchstart”事件。当手指在触摸时移动时,会触发“touchmove”事件。最后,当手指停止接触屏幕时,你会看到一个“touchend”事件。
由于许多触摸屏可以同时检测多个手指,因此这些事件没有与之相关联的单一坐标集。相反,它们的事件对象具有一个touches属性,该属性包含一个类似数组的点对象,每个点都有自己的clientX、clientY、pageX和pageY属性。
你可以做这样的事情,在每个触摸的手指周围显示红色圆圈:
<style>
dot { position: absolute; display: block;
border: 2px solid red; border-radius: 50px;
height: 100px; width: 100px; }
</style>
<p>Touch this page</p>
<script>
function update(event) {
for (let dot; dot = document.querySelector("dot");) {
dot.remove();
}
for (let i = 0; i < event.touches.length; i++) {
let {pageX, pageY} = event.touches[i];
let dot = document.createElement("dot");
dot.style.left = (pageX - 50) + "px";
dot.style.top = (pageY - 50) + "px";
document.body.appendChild(dot);
}
}
window.addEventListener("touchstart", update);
window.addEventListener("touchmove", update);
window.addEventListener("touchend", update);
</script>
你通常会希望在触摸事件处理程序中调用preventDefault,以覆盖浏览器的默认行为(可能包括在滑动时滚动页面),并防止触发鼠标事件,对于这些事件你也可能有一个处理程序。
滚动事件
每当一个元素滚动时,都会在其上触发“scroll”事件。这有多种用途,例如了解用户当前正在查看的内容(用于禁用屏幕外动画或向你邪恶的总部发送间谍报告)或显示某种进度指示(通过突出显示部分目录或显示页码)。
以下示例在文档上方绘制一个进度条,并在你向下滚动时更新它以填满:
<style>
#progress {
border-bottom: 2px solid blue;
width: 0;
position: fixed;
top: 0; left: 0;
}
</style>
<div id="progress"></div>
<script>
// Create some content
document.body.appendChild(document.createTextNode(
"supercalifragilisticexpialidocious ".repeat(1000)));
let bar = document.querySelector("#progress");
window.addEventListener("scroll", () => {
let max = document.body.scrollHeight - innerHeight;
bar.style.width = `${(pageYOffset / max) * 100}%`;
});
</script>
将一个元素的定位设置为固定的位置与绝对位置的效果类似,但也防止其与文档的其他部分一起滚动。其效果是使我们的进度条停留在顶部。其宽度会根据当前进度进行调整。我们在设置宽度时使用%而不是px作为单位,这样元素的大小相对于页面宽度。
全局的innerHeight绑定给出了窗口的高度,我们必须从总可滚动高度中减去这一数值——当你到达文档底部时,无法继续滚动。窗口宽度还有innerWidth。通过将当前滚动位置pageYOffset除以最大滚动位置并乘以100,我们得到进度条的百分比。
在滚动事件上调用preventDefault并不会阻止滚动的发生。事实上,事件处理程序仅在滚动发生后被调用。
聚焦事件
当一个元素获得焦点时,浏览器会在其上触发“focus”事件。当它失去焦点时,该元素会收到“blur”事件。
与之前讨论的事件不同,这两个事件不会传播。父元素上的处理程序不会在子元素获得或失去焦点时被通知。
以下示例为当前具有焦点的文本字段显示帮助文本:
<p>Name: <input type="text" data-help="Your full name"></p>
<p>Age: <input type="text" data-help="Your age in years"></p>
<p id="help"></p>
<script>
let help = document.querySelector("#help");
let fields = document.querySelectorAll("input");
for (let field of Array.from(fields)) {
field.addEventListener("focus", event => {
let text = event.target.getAttribute("data-help");
help.textContent = text;
});
field.addEventListener("blur", event => {
help.textContent = "";
});
}
</script>
该截图显示了年龄字段的帮助文本:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0245-01.jpg
当用户在显示文档的浏览器标签或窗口之间切换时,窗口对象会接收“focus”和“blur”事件。
加载事件
当页面加载完成时,“load”事件会在窗口和文档主体对象上触发。这通常用于安排需要整个文档构建完成后才能执行的初始化操作。请记住,<script>标签中的内容在遇到标签时会立即执行。这可能来得太早——例如,当脚本需要处理在<script>标签之后出现的文档部分时。
像图片和加载外部文件的<script>标签这样的元素也有一个“load”事件,表示它们所引用的文件已加载。与聚焦相关的事件一样,加载事件不会传播。
当你关闭页面或离开它(例如,通过点击链接)时,会触发一个“beforeunload”事件。这个事件的主要用途是防止用户通过关闭文档意外丢失工作。如果你在这个事件上阻止默认行为并将事件对象的returnValue属性设置为一个字符串,浏览器将向用户显示一个对话框,询问他们是否真的想离开页面。该对话框可能包含你的字符串,但由于一些恶意网站试图利用这些对话框来混淆人们,以使他们留在页面上观看可疑的减肥广告,大多数浏览器不再显示这些对话框。
事件与事件循环
在第十一章讨论的事件循环的上下文中,浏览器事件处理程序的行为类似于其他异步通知。它们在事件发生时被调度,但必须等待正在运行的其他脚本完成后才能获得执行机会。
事件只能在没有其他操作运行时处理,这意味着如果事件循环被其他工作占用,与页面的任何交互(通过事件发生)将会延迟,直到有时间处理它。因此,如果你调度了过多的工作,无论是使用长时间运行的事件处理程序还是大量短时间运行的事件处理程序,页面将变得缓慢和笨重。
在某些情况下,如果你确实想在后台执行一些耗时的操作而不冻结页面,浏览器提供了一种称为网络工作者的东西。工作者是一个JavaScript进程,它与主脚本并行运行,拥有自己的时间线。
想象一下,平方一个数字是一个耗时的长时间计算,我们希望在单独的线程中执行。我们可以编写一个名为code/squareworker.js的文件,它通过计算平方并发送消息返回来响应消息。
addEventListener("message", event => {
postMessage(event.data * event.data);
});
为了避免多个线程同时访问相同数据的问题,工作线程不与主脚本的环境共享它们的全局作用域或其他任何数据。相反,你必须通过发送消息来进行通信。
这段代码生成一个运行该脚本的工作者,发送几个消息,并输出响应。
let squareWorker = new Worker("code/squareworker.js");
squareWorker.addEventListener("message", event => {
console.log("The worker responded:", event.data);
});
squareWorker.postMessage(10);
squareWorker.postMessage(24);
postMessage函数发送消息,这将导致接收方触发一个“message”事件。创建工作者的脚本通过Worker对象发送和接收消息,而工作者则通过直接在其全局作用域上发送和监听,与创建它的脚本进行通信。只有可以表示为JSON的值才能作为消息发送——另一方将接收到它们的副本,而不是值本身。
定时器
我们在第十一章中看到的setTimeout函数会在给定的毫秒数后调度另一个函数被调用。有时你需要取消已经调度的函数。你可以通过存储setTimeout返回的值,并在其上调用clearTimeout来实现。
let bombTimer = setTimeout(() => {
console.log("BOOM!");
}, 500);
if (Math.random() < 0.5) { // 50% chance
console.log("Defused.");
clearTimeout(bombTimer);
}
cancelAnimationFrame函数的工作方式与clearTimeout相同。对requestAnimationFrame返回的值调用它将取消该帧(假设它尚未被调用)。
一组类似的函数setInterval和clearInterval用于设置每X毫秒重复的计时器。
let ticks = 0;
let clock = setInterval(() => {
console.log("tick", ticks++);
if (ticks == 10) {
clearInterval(clock);
console.log("stop.");
}
}, 200);
防抖
某些类型的事件可能会迅速连续触发多次,例如“mousemove”和“scroll”事件。在处理这些事件时,必须小心不要执行任何耗时的操作,否则你的处理程序会占用过多时间,从而使与文档的交互感觉缓慢。
如果你确实需要在这样的处理程序中做一些复杂的事情,可以使用setTimeout来确保不会过于频繁地执行。这通常被称为防抖事件。对此有几种稍微不同的方法。
例如,假设我们想在用户输入时做出反应,但不想在每次输入事件中立即执行。用户快速输入时,我们只想等到出现暂停再处理。我们在事件处理程序中设置一个超时,而不是立即执行某个操作。我们还会清除之前的超时(如果有的话),这样当事件发生得很接近(比我们的超时延迟更近)时,前一个事件的超时将被取消。
<textarea>Type something here...</textarea>
<script>
let textarea = document.querySelector("textarea");
let timeout;
textarea.addEventListener("input", () => {
clearTimeout(timeout);
timeout = setTimeout(() => console.log("Typed!"), 500);
});
</script>
将未定义的值传递给clearTimeout或在已经触发的超时上调用它不会产生任何效果。因此,我们不需要小心何时调用它,我们可以简单地对每个事件都调用它。
如果我们希望响应之间的间隔至少有一定时间,但又想在一系列事件发生期间触发响应,我们可以使用稍微不同的模式。例如,我们可能希望通过显示当前鼠标坐标来响应“mousemove”事件,但每250毫秒才响应一次。
<script>
let scheduled = null;
window.addEventListener("mousemove", event => {
if (!scheduled) {
setTimeout(() => {
document.body.textContent =
`Mouse at ${scheduled.pageX}, ${scheduled.pageY}`;
scheduled = null;
}, 250);
}
scheduled = event;
});
</script>
总结
事件处理程序使我们能够检测和响应在网页上发生的事件。addEventListener方法用于注册这样的处理程序。
每个事件都有一个类型(“keydown”、“focus”等),用于标识它。大多数事件是在特定的DOM元素上调用,然后传播到该元素的祖先,从而允许与这些元素关联的处理程序进行处理。
当事件处理程序被调用时,它会传递一个事件对象,包含有关事件的额外信息。该对象还有允许我们停止进一步传播(stopPropagation)和防止浏览器默认处理事件(preventDefault)的方法。
按下一个键会触发“keydown”和“keyup”事件。按下鼠标按钮会触发“mousedown”、“mouseup”和“click”事件。移动鼠标会触发“mousemove”事件。触摸屏交互会导致“touchstart”、“touchmove”和“touchend”事件。
可以通过“scroll”事件检测滚动,焦点变化可以通过“focus”和“blur”事件检测。当文档加载完成时,窗口会触发一个“load”事件。
练习
气球
编写一个页面,显示一个气球(使用气球表情符号,<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0249-01.jpg>)。当你按上箭头时,它应膨胀(增长)10%。当你按下箭头时,它应缩小(收缩)10%。
你可以通过在其父元素上设置字体大小的CSS属性(style.fontSize)来控制文本(表情符号也是文本)的大小。记得在值中包含单位,例如像素(10px)。
箭头键的关键名称是“ArrowUp”和“ArrowDown”。确保这些键只改变气球,而不会滚动页面。
一旦你完成了这项工作,添加一个功能:如果你将气球膨胀到某个大小,它将“爆炸”。在这种情况下,爆炸意味着它被替换为一个<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0249-02.jpg>表情符号,并且事件处理程序被移除(这样你就无法再膨胀或缩小爆炸效果)。
鼠标拖尾
在JavaScript的早期,正值华丽主页大量动画图像的高峰期,人们想出了许多真正鼓舞人心的使用该语言的方法。其中之一是鼠标拖尾——一系列元素将在你移动鼠标时跟随鼠标指针。
在这个练习中,我希望你实现一个鼠标拖尾。使用绝对定位的<div>元素,固定大小和背景颜色(请参阅第241页“鼠标点击”部分的代码示例)。创建一堆这些元素,并在鼠标移动时,跟随鼠标指针显示它们。
这里有多种可能的方法。你可以根据需要简化或复杂化你的拖尾。一个简单的起始解决方案是保持固定数量的拖尾元素,并在每次发生“mousemove”事件时,将下一个元素移动到鼠标的当前位置。
标签
标签面板在用户界面中很常见。它们允许你通过选择在元素上方“突出的”多个标签中的一个来选择界面面板。
实现一个简单的标签界面。编写一个函数asTabs,该函数接受一个DOM节点,并创建一个标签界面,显示该节点的子元素。它应在节点顶部插入一个按钮元素列表,每个按钮对应一个子元素,文本来自子元素的data-tabname属性。除一个外,所有原始子元素都应隐藏(设置为display: none)。通过点击按钮可以选择当前可见的节点。
当这能正常工作时,扩展功能,使当前选中的标签的按钮样式不同,以便明显显示哪个标签被选中。
所有现实都是一场游戏。
—伊恩·班克斯,游戏玩家
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0252-01.jpg>
第十七章:项目:一个平台游戏
我最初对计算机的迷恋,如同许多宅男孩童一样,源于电脑游戏。我被那些我可以操控的小型模拟世界吸引,故事(某种程度上)在其中展开——我想,这更多是因为我将想象投射到这些世界中,而不是因为它们实际提供的可能性。
我不希望任何人走上游戏编程的职业道路。与音乐产业一样,渴望从事这一行业的年轻人数量与实际需求之间的差异,创造了一个相当不健康的环境。但为了乐趣而编写游戏是令人愉快的。
本章将介绍一个小型平台游戏的实现。平台游戏(或称“跳跃跑动”游戏)是指玩家需要在一个通常为二维且从侧面观看的世界中移动角色,同时跳跃越过或登上物体的游戏。
游戏
我们的游戏将大致基于托马斯·帕雷夫的《深蓝》 (*[www.lessmilk.com/dark-blue/](https://www.lessmilk.com/dark-blue/)*)。我选择这个游戏是因为它既有趣又简约,并且可以在不需要过多代码的情况下构建。它的样子如下:
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0254-01.jpg>
黑色方框代表玩家,其任务是在避开红色物体(熔岩)的同时收集黄色方框(硬币)。当所有硬币被收集后,关卡就完成了。
玩家可以使用左箭头和右箭头键走动,并可以用上箭头键跳跃。跳跃是这个游戏角色的特长。它可以达到自身高度的几倍,并且可以在空中改变方向。这可能并不完全现实,但它帮助玩家感受到对屏幕上角色的直接控制。
游戏由一个静态背景构成,背景呈网格布局,移动元素叠加在该背景上。网格上的每个格子要么是空的,要么是固体,要么是熔岩。移动元素包括玩家、硬币和某些熔岩块。这些元素的位置不受网格的限制——它们的坐标可以是小数,从而实现平滑的移动。
技术
我们将使用浏览器的DOM来显示游戏,并通过处理键盘事件来读取用户输入。
与屏幕和键盘相关的代码只是我们构建这个游戏所需工作的一小部分。由于一切看起来像是彩色方框,因此绘制非常简单:我们创建DOM元素,并使用样式为它们设置背景色、大小和位置。
我们可以将背景表示为一个表格,因为它是一个不变的方格网。可以使用绝对定位的元素叠加自由移动的元素。
在需要动画图形并对用户输入做出快速响应的游戏和其他程序中,效率非常重要。尽管DOM最初并不是为了高性能图形设计的,但它在这方面实际上表现得比你想象的要好。你在第十四章中看到了某些动画。在现代计算机上,即使我们不太关心优化,这样简单的游戏运行也很好。
在下一章中,我们将探索另一种浏览器技术,<canvas>标签,它提供了一种更传统的绘制图形的方式,以形状和像素为单位,而不是DOM元素。
级别
我们希望有一种人类可读且可编辑的方式来指定级别。由于一切都可以从网格开始,我们可以使用大字符串,其中每个字符表示一个元素——要么是背景网格的一部分,要么是移动元素。
小级别的计划可能看起来是这样的:
let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;
句号表示空白,井号 (#) 字符表示墙,加号表示熔岩。玩家的起始位置是@符号。每个O字符是一个金币,而顶部的等号 (=) 是一个水平往返移动的熔岩块。
我们将支持两种额外类型的移动熔岩:管道字符 (|) 创建垂直移动的水滴,v表示*滴落*熔岩——垂直移动的熔岩,它不会前后弹跳,而是仅向下移动,当它碰到地面时跳回起始位置。
整个游戏由多个级别组成,玩家必须完成这些级别。当所有金币都被收集时,级别即被视为完成。如果玩家碰到熔岩,当前级别将恢复到其起始位置,玩家可以重新尝试。
读取级别
以下类存储一个级别对象。它的参数应该是定义级别的字符串。
class Level {
constructor(plan) {
let rows = plan.trim().split("\n").map(l => [...l]);
this.height = rows.length;
this.width = rows[0].length;
this.startActors = [];
this.rows = rows.map((row, y) => {
return row.map((ch, x) => {
let type = levelChars[ch];
if (typeof type != "string") {
let pos = new Vec(x, y);
this.startActors.push(type.create(pos, ch));
type = "empty";
}
return type;
});
});
}
}
trim方法用于去除计划字符串开头和结尾的空白。这允许我们的示例计划以换行符开头,以便所有行直接相互对齐。剩余的字符串在换行符上进行分割,每一行被展开成一个数组,从而生成字符数组。
所以行保存了一个字符的数组数组,即计划的行。我们可以从中推导出级别的宽度和高度。但我们仍然需要将移动元素与背景网格分开。我们将移动元素称为*演员*。它们将存储在一个对象数组中。背景将是一个字符串的数组数组,包含“空”、“墙”或“熔岩”等字段类型。
要创建这些数组,我们需要遍历行,然后遍历它们的内容。记住,map会将数组索引作为第二个参数传递给映射函数,这样我们就能知道给定字符的x和y坐标。游戏中的位置将以坐标对的形式存储,左上角为(0, 0),每个背景方块的高宽均为1个单位。
为了解释计划中的字符,Level构造函数使用levelChars对象,该对象为每个在关卡描述中使用的字符存储一个字符串(如果是背景类型)和一个类(如果生成一个演员)。当类型是演员类时,它的静态create方法用于创建一个对象,该对象被添加到startActors中,映射函数为这个背景方块返回“空”。
演员的位置存储为一个Vec对象。这是一个二维向量,一个具有x和y属性的对象,如第六章的练习中所示。
随着游戏的进行,演员将最终出现在不同的位置,甚至完全消失(如硬币被收集时)。我们将使用一个State类来跟踪正在运行的游戏的状态。
class State {
constructor(level, actors, status) {
this.level = level;
this.actors = actors;
this.status = status;
}
static start(level) {
return new State(level, level.startActors, "playing");
}
get player() {
return this.actors.find(a => a.type == "player");
}
}
当游戏结束时,status属性将切换为“lost”或“won”。
这再次是一个持久的数据结构——更新游戏状态会创建一个新状态并保持旧状态不变。
演员
演员对象表示我们游戏中给定移动元素(玩家、硬币或流动熔岩)的当前位置和状态。所有演员对象遵循相同的接口。它们具有size和pos属性,分别保存表示该演员的矩形的大小和左上角的坐标,以及一个更新方法。
这种更新方法用于计算在给定时间步长后它们的新状态和位置。它模拟了演员所做的事情——根据玩家的箭头键移动,以及熔岩的来回反弹——并返回一个新的、更新的演员对象。
type属性包含一个字符串,用于识别演员的类型——“player”、“coin”或“lava”。这在绘制游戏时非常有用——为演员绘制的矩形的外观基于其类型。
演员类具有一个静态create方法,由Level构造函数用于从关卡计划中的字符创建演员。它接收字符的坐标和字符本身,这是必要的,因为Lava类处理多种不同的字符。
这是我们将用于二维值的Vec类,例如演员的位置和大小。
class Vec {
constructor(x, y) {
this.x = x; this.y = y;
}
plus(other) {
return new Vec(this.x + other.x, this.y + other.y);
}
times(factor) {
return new Vec(this.x * factor, this.y * factor);
}
}
times方法通过给定的数字缩放向量。当我们需要将速度向量乘以时间间隔以获得在该时间内行驶的距离时,它将非常有用。
不同类型的演员拥有各自的类,因为它们的行为非常不同。让我们定义这些类。稍后我们将介绍它们的更新方法。
玩家类具有一个speed属性,用于存储其当前速度,以模拟动量和重力。
class Player {
constructor(pos, speed) {
this.pos = pos;
this.speed = speed;
}
get type() { return "player"; }
static create(pos) {
return new Player(pos.plus(new Vec(0, -0.5)),
new Vec(0, 0));
}
}
Player.prototype.size = new Vec(0.8, 1.5);
由于玩家的高度为一个半方块,因此其初始位置设置为在@字符出现的位置上方半个方块。这样,它的底部与出现的方块底部对齐。
size属性对于所有Player的实例都是相同的,因此我们将其存储在原型上而不是实例本身。我们可以像type一样使用getter,但这样做会每次读取属性时创建并返回一个新的Vec对象,这是浪费的。(字符串是不可变的,因此不必每次评估时重新创建它们。)
在构建熔岩角色时,我们需要根据其基础字符的不同进行对象的初始化。动态熔岩以当前速度移动,直到碰到障碍物为止。在那一点上,如果它有一个重置属性,它将跳回起始位置(滴落)。如果没有,它将反转速度并沿另一方向继续移动(弹跳)。
create方法检查Level构造函数传递的字符,并创建相应的熔岩角色。
class Lava {
constructor(pos, speed, reset) {
this.pos = pos;
this.speed = speed;
this.reset = reset;
}
get type() { return "lava"; }
static create(pos, ch) {
if (ch == "=") {
return new Lava(pos, new Vec(2, 0));
} else if (ch == "|") {
return new Lava(pos, new Vec(0, 2));
} else if (ch == "v") {
return new Lava(pos, new Vec(0, 3), pos);
}
}
}
Lava.prototype.size = new Vec(1, 1);
硬币角色相对简单。它们大多数时间只是呆在原地。但为了稍微活跃一下游戏,它们被赋予了“摇晃”,即轻微的垂直来回运动。为了跟踪这一点,硬币对象存储了一个基础位置以及一个追踪弹跳运动相位的wobble属性。这些属性共同决定了硬币的实际位置(存储在pos属性中)。
class Coin {
constructor(pos, basePos, wobble) {
this.pos = pos;
this.basePos = basePos;
this.wobble = wobble;
}
get type() { return "coin"; }
static create(pos) {
let basePos = pos.plus(new Vec(0.2, 0.1));
return new Coin(basePos, basePos,
Math.random() * Math.PI * 2);
}
}
Coin.prototype.size = new Vec(0.6, 0.6);
在第十四章中,我们看到Math.sin给出了圆上点的y坐标。随着我们沿着圆移动,该坐标在一个平滑的波形中来回移动,这使得正弦函数在建模波动运动时非常有用。
为了避免所有硬币同步上下移动的情况,每个硬币的起始相位被随机化。Math.sin波的周期,即它产生的波的宽度,是2*π*。我们将Math.random返回的值乘以该数字,以在波上为硬币赋予一个随机的起始位置。
我们现在可以定义levelChars对象,将平面字符映射到背景网格类型或角色类别。
const levelChars = {
".": "empty", "#": "wall", "+": "lava",
"@": Player, "o": Coin,
"=": Lava, "|": Lava, "v": Lava
};
这给了我们创建Level实例所需的所有部分。
let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9
接下来的任务是在屏幕上显示这些关卡,并在其中模拟时间和运动。
绘制
在下一章中,我们将以不同的方式显示同一款游戏。为了实现这一点,我们将绘图逻辑放在一个接口后面,并将其作为参数传递给游戏。这样,我们可以用不同的新显示模块来使用同一个游戏程序。
游戏显示对象绘制给定的关卡和状态。我们将其构造函数传递给游戏以允许替换它。我们在本章中定义的显示类称为DOMDisplay,因为它使用DOM元素来显示关卡。
我们将使用样式表来设置组成游戏的元素的实际颜色和其他固定属性。当创建它们时,也可以直接赋值给元素的style属性,但这会产生更冗长的程序。
下面的辅助函数提供了一种简洁的方法来创建一个元素,并为其添加一些属性和子节点:
function elt(name, attrs, ...children) {
let dom = document.createElement(name);
for (let attr of Object.keys(attrs)) {
dom.setAttribute(attr, attrs[attr]);
}
for (let child of children) {
dom.appendChild(child);
}
return dom;
}
显示元素是通过给它一个父元素来创建的,应该将其附加到该父元素上,并传入一个级别对象。
class DOMDisplay {
constructor(parent, level) {
this.dom = elt("div", {class: "game"}, drawGrid(level));
this.actorLayer = null;
parent.appendChild(this.dom);
}
clear() { this.dom.remove(); }
}
级别的背景网格是一次绘制的,并且不会改变。演员在每次更新显示时都会被重新绘制,更新时会给定状态。actorLayer属性将用于跟踪持有演员的元素,以便能够轻松地移除和替换它们。
我们的坐标和大小以网格单位进行追踪,其中1的大小或距离表示一个网格块。在设置像素大小时,我们必须将这些坐标放大——在每个方块只有一个像素的情况下,游戏中的一切都会显得极其微小。比例常数表示一个单位在屏幕上占用的像素数。
const scale = 20;
function drawGrid(level) {
return elt("table", {
class: "background",
style: `width: ${level.width * scale}px`
}, ...level.rows.map(row =>
elt("tr", {style: `height: ${scale}px`},
...row.map(type => elt("td", {class: type})))
));
}
<table>元素的形式与级别的行属性结构相对应——网格的每一行都被转化为表格行(<tr>元素)。网格中的字符串用作表格单元格(<td>元素)的类名。代码使用扩展(三个点)操作符将子节点数组作为单独的参数传递给elt。
以下CSS使表格看起来像我们想要的背景:
.background { background: rgb(52, 166, 251);
table-layout: fixed;
border-spacing: 0; }
.background td { padding: 0; }
.lava { background: rgb(255, 100, 100); }
.wall { background: white; }
其中一些(table-layout、border-spacing和padding)用于抑制不必要的默认行为。我们不希望表格的布局依赖于单元格的内容,也不希望单元格之间或内部有空间。
背景规则设置背景颜色。CSS允许颜色以单词(例如:白色)或使用格式如rgb(*R*, *G*, *B*)来指定,其中红、绿和蓝的颜色分量被分为从0到255的三个数字。在rgb(52, 166, 251)中,红色分量是52,绿色是166,蓝色是251。由于蓝色分量最大,结果颜色将偏蓝。在.lava规则中,第一个数字(红色)是最大的。
我们通过为每个演员创建一个DOM元素,并根据演员的属性设置该元素的位置和大小来绘制每个演员。数值必须乘以比例,以从游戏单位转换为像素。
function drawActors(actors) {
return elt("div", {}, ...actors.map(actor => {
let rect = elt("div", {class: `actor ${actor.type}`});
rect.style.width = `${actor.size.x * scale}px`;
rect.style.height = `${actor.size.y * scale}px`;
rect.style.left = `${actor.pos.x * scale}px`;
rect.style.top = `${actor.pos.y * scale}px`;
return rect;
}));
}
要给一个元素添加多个类名,我们用空格分隔类名。在以下CSS代码中,actor类为演员提供了绝对位置。它们的类型名称作为额外的类来给它们上色。我们不需要重新定义lava类,因为我们在之前定义的lava网格方块中重用了该类。
.actor { position: absolute; }
.coin { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64); }
syncState方法用于使显示显示给定状态。它首先移除旧的演员图形(如果有的话),然后在新位置重新绘制演员。尽管尝试重用演员的DOM元素可能很诱人,但为了使其工作,我们需要大量额外的管理,以将演员与DOM元素关联,并确保在演员消失时移除元素。由于游戏中通常只有少数演员,重新绘制它们并不昂贵。
DOMDisplay.prototype.syncState = function(state) {
if (this.actorLayer) this.actorLayer.remove();
this.actorLayer = drawActors(state.actors);
this.dom.appendChild(this.actorLayer);
this.dom.className = `game ${state.status}`;
this.scrollPlayerIntoView(state);
};
通过将当前级别的状态作为类名添加到包装器中,当游戏获胜或失败时,我们可以稍微不同地为玩家角色设置样式,添加一个仅在玩家具有特定类的祖先元素时生效的CSS规则。
.lost .player {
background: rgb(160, 64, 64);
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
在接触熔岩后,玩家变成深红色,暗示着灼烧。当最后一枚硬币被收集后,我们添加两个模糊的白色阴影——一个在左上方,一个在右上方——以创建白色光环效果。
我们不能假设级别总是适合视口,即我们绘制游戏的元素。这就是我们需要scrollPlayerIntoView调用的原因:它确保如果级别超出视口,我们会滚动视口,以确保玩家位于其中心附近。以下CSS为游戏的包装DOM元素设置了最大大小,并确保任何超出元素框的部分不可见。我们还为其设置了相对位置,以便其中的角色相对于级别的左上角进行定位。
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
在scrollPlayerIntoView方法中,我们找到播放器的位置并更新包装元素的滚动位置。当播放器太靠近边缘时,我们通过操作该元素的scrollLeft和scrollTop属性来改变滚动位置。
DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
let width = this.dom.clientWidth;
let height = this.dom.clientHeight;
let margin = width / 3;
// The viewport
let left = this.dom.scrollLeft, right = left + width;
let top = this.dom.scrollTop, bottom = top + height;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5))
.times(scale);
if (center.x < left + margin) {
this.dom.scrollLeft = center.x - margin;
} else if (center.x > right - margin) {
this.dom.scrollLeft = center.x + margin - width;
}
if (center.y < top + margin) {
this.dom.scrollTop = center.y - margin;
} else if (center.y > bottom - margin) {
this.dom.scrollTop = center.y + margin - height;
}
};
玩家中心的寻找方式展示了我们的Vec类型上的方法如何允许以相对可读的方式编写与对象的计算。要找到角色的中心,我们将其位置(左上角)和其大小的一半相加。那是在级别坐标中的中心,但我们需要它在像素坐标中,因此我们接着将结果向量乘以我们的显示比例。
接下来,一系列检查验证玩家的位置是否在允许范围内。请注意,有时这会设置低于零或超出元素可滚动区域的无意义滚动坐标。这是可以的——DOM会将它们限制在可接受的值。将scrollLeft设置为-10会使其变为0。
虽然始终尝试将玩家滚动到视口中心会稍微简单一些,但这会产生一种相当刺耳的效果。当你跳跃时,视图会不断上下移动。在屏幕中间有一个“中立”区域,可以在不引起任何滚动的情况下自由移动,会更加愉快。
我们现在能够显示我们的微小级别。
<link rel="stylesheet" href="css/game.css">
<script>
let simpleLevel = new Level(simpleLevelPlan);
let display = new DOMDisplay(document.body, simpleLevel);
display.syncState(State.start(simpleLevel));
</script>
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0264-01.jpg
当使用rel="stylesheet"的<link>标签时,它是一种将CSS文件加载到页面中的方式。文件game.css包含我们游戏所需的样式。
运动与碰撞
现在我们可以开始添加运动了。大多数这类游戏采用的基本方法是将时间分割成小步,并在每一步中,根据速度与时间步长的乘积移动角色。我们将时间以秒为单位进行测量,因此速度以每秒的单位表示。
移动物体是简单的。困难的部分是处理元素之间的相互作用。当玩家撞到墙壁或地板时,他们不应该简单地穿过它。游戏必须注意到某个运动导致一个物体撞上另一个物体,并作出相应反应。对于墙壁,运动必须被停止。当撞到硬币时,必须收集该硬币。当触碰到岩浆时,游戏应该结束。
解决一般情况是一个重大任务。你可以找到通常称为物理引擎的库,它们模拟二维或三维物体之间的交互。在这一章中,我们将采取更为谦逊的方法,仅处理矩形物体之间的碰撞,并以相对简单的方式处理它们。
在移动玩家或一块岩浆之前,我们测试运动是否会使其进入墙内。如果是这样,我们就简单地取消整个运动。对这种碰撞的反应取决于参与者的类型——玩家会停止,而岩浆块则会反弹。
这种方法要求我们的时间步长相对较小,因为这会导致运动在物体实际接触之前停止。如果时间步长(因此运动步长)过大,玩家将会悬浮在离地面明显的高度。另一种更复杂的、更好的方法是找到确切的碰撞点并移动到那里。我们将采取简单的方法,并通过确保动画以小步推进来掩盖其问题。
该方法告诉我们一个矩形(由位置和大小指定)是否接触到给定类型的网格元素。
Level.prototype.touches = function(pos, size, type) {
let xStart = Math.floor(pos.x);
let xEnd = Math.ceil(pos.x + size.x);
let yStart = Math.floor(pos.y);
let yEnd = Math.ceil(pos.y + size.y);
for (let y = yStart; y < yEnd; y++) {
for (let x = xStart; x < xEnd; x++) {
let isOutside = x < 0 || x >= this.width ||
y < 0 || y >= this.height;
let here = isOutside ? "wall" : this.rows[y][x];
if (here == type) return true;
}
}
return false;
};
该方法通过对物体的坐标使用Math.floor和Math.ceil来计算物体重叠的网格方块集合。请记住,网格方块的大小为 1 x 1 单位。通过将盒子的边缘向上和向下取整,我们得到盒子接触的背景方块范围。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0266-01.jpg
我们遍历通过四舍五入坐标找到的网格方块,当找到匹配的方块时返回true。关卡外的方块总是被视为“墙”,以确保玩家无法离开世界,并且我们不会意外尝试读取超出行数组边界的内容。
状态更新方法使用接触来判断玩家是否接触到熔岩。
State.prototype.update = function(time, keys) {
let actors = this.actors
.map(actor => actor.update(time, this, keys));
let newState = new State(this.level, actors, this.status);
if (newState.status != "playing") return newState;
let player = newState.player;
if (this.level.touches(player.pos, player.size, "lava")) {
return new State(this.level, actors, "lost");
}
for (let actor of actors) {
if (actor != player && overlap(actor, player)) {
newState = actor.collide(newState);
}
}
return newState;
};
方法接收一个时间步长和一个数据结构,告知它哪些按键被按下。它首先在所有参与者上调用更新方法,生成一个更新后的参与者数组。参与者还会获得时间步长、按键和状态,以便它们可以基于这些信息进行更新。只有玩家会实际读取按键,因为只有玩家是由键盘控制的参与者。
如果游戏已经结束,则不需要进一步处理(游戏在失败后无法获胜,反之亦然)。否则,该方法会测试玩家是否接触到背景熔岩。如果是,游戏就失败了,我们结束了。最后,如果游戏确实仍在进行中,它会检查是否有其他角色与玩家重叠。
通过重叠函数检测角色之间的重叠。它接受两个角色对象,当它们相碰时返回真——这发生在它们在 x 轴和 y 轴上都重叠时。
function overlap(actor1, actor2) {
return actor1.pos.x + actor1.size.x > actor2.pos.x &&
actor1.pos.x < actor2.pos.x + actor2.size.x &&
actor1.pos.y + actor1.size.y > actor2.pos.y &&
actor1.pos.y < actor2.pos.y + actor2.size.y;
}
如果任何角色发生重叠,其碰撞方法有机会更新状态。接触熔岩角色会将游戏状态设置为“失败”。当你触碰到硬币时,它们会消失,并在它们是关卡的最后一枚硬币时将状态设置为“胜利”。
Lava.prototype.collide = function(state) {
return new State(state.level, state.actors, "lost");
};
Coin.prototype.collide = function(state) {
let filtered = state.actors.filter(a => a != this);
let status = state.status;
if (!filtered.some(a => a.type == "coin")) status = "won";
return new State(state.level, filtered, status);
};
角色更新
角色对象的更新方法接受时间步长、状态对象和键对象作为参数。熔岩角色类型的更新方法会忽略键对象。
Lava.prototype.update = function(time, state) {
let newPos = this.pos.plus(this.speed.times(time));
if (!state.level.touches(newPos, this.size, "wall")) {
return new Lava(newPos, this.speed, this.reset);
} else if (this.reset) {
return new Lava(this.reset, this.speed, this.reset);
} else {
return new Lava(this.pos, this.speed.times(-1));
}
};
这个更新方法通过将时间步长与当前速度的乘积添加到其旧位置来计算新位置。如果没有障碍物阻挡新位置,它将移动到那里。如果有障碍物,行为将取决于熔岩块的类型——滴落的熔岩有一个重置位置,当它碰到某物时会跳回到该位置。反弹的熔岩通过将速度乘以-1来反转速度,使其开始朝相反的方向移动。
硬币使用其更新方法进行摇晃。它们忽略与网格的碰撞,因为它们只是摇晃在自己方块内。
const wobbleSpeed = 8, wobbleDist = 0.07;
Coin.prototype.update = function(time) {
let wobble = this.wobble + time * wobbleSpeed;
let wobblePos = Math.sin(wobble) * wobbleDist;
return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
this.basePos, wobble);
};
摇晃属性会递增以跟踪时间,然后用作Math.sin的参数,以找到波上的新位置。硬币的当前位置则根据其基础位置和基于此波的偏移量进行计算。
这就涉及到玩家本身。玩家的运动在每个轴上单独处理,因为碰到地面不应该阻止水平运动,而碰到墙壁不应该停止下落或跳跃运动。
const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;
Player.prototype.update = function(time, state, keys) {
let xSpeed = 0;
if (keys.ArrowLeft) xSpeed -= playerXSpeed;
if (keys.ArrowRight) xSpeed += playerXSpeed;
let pos = this.pos;
let movedX = pos.plus(new Vec(xSpeed * time, 0));
if (!state.level.touches(movedX, this.size, "wall")) {
pos = movedX;
}
let ySpeed = this.speed.y + time * gravity;
let movedY = pos.plus(new Vec(0, ySpeed * time));
if (!state.level.touches(movedY, this.size, "wall")) {
pos = movedY;
} else if (keys.ArrowUp && ySpeed > 0) {
ySpeed = -jumpSpeed;
} else {
ySpeed = 0;
}
return new Player(pos, new Vec(xSpeed, ySpeed));
};
水平运动是基于左箭头和右箭头键的状态进行计算的。当没有墙壁阻挡这个运动所创造的新位置时,就使用这个新位置。否则,保持旧位置不变。
垂直运动以类似的方式工作,但必须模拟跳跃和重力。玩家的垂直速度(ySpeed)首先会被加速,以考虑重力的影响。
我们再次检查墙壁。如果没有碰到任何墙,新位置将被使用。如果有墙壁,则有两种可能的结果。当上箭头被按下并且我们正在向下移动(这意味着我们碰到的东西在我们下面),速度会被设置为相对较大的负值。这会导致玩家跳跃。如果情况不是这样,玩家只是碰到了什么东西,速度则被设置为零。
游戏中的重力强度、跳跃速度和其他常量是通过尝试一些数字并观察哪种感觉合适来确定的。你可以尝试进行实验。
追踪键
对于这样的游戏,我们不希望键在每次按下时只生效一次。相反,我们希望它们的效果(移动玩家角色)在按住时保持有效。
我们需要设置一个键处理程序,存储左、右和上箭头键的当前状态。我们还希望对这些键调用preventDefault,以防止它们滚动页面。
以下函数在给定一个键名数组时,将返回一个跟踪这些键当前状态的对象。它为keydown和keyup事件注册事件处理程序,并在事件中的键代码存在于它所跟踪的代码集中时,更新该对象。
function trackKeys(keys) {
let down = Object.create(null);
function track(event) {
if (keys.includes(event.key)) {
down[event.key] = event.type == "keydown";
event.preventDefault();
}
}
window.addEventListener("keydown", track);
window.addEventListener("keyup", track);
return down;
}
const arrowKeys =
trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);
同一个处理程序函数用于这两种事件类型。它查看事件对象的type属性,以确定键状态是应该更新为true(keydown)还是false(keyup)。
运行游戏
requestAnimationFrame函数,如我们在第十四章中看到的,提供了一种很好的方式来动画游戏。但它的接口相当原始——使用它需要我们跟踪上一次调用函数的时间,并在每一帧之后再次调用requestAnimationFrame。
让我们定义一个助手函数,将这些内容封装在一个方便的接口中,并允许我们简单地调用runAnimation,传入一个期望时间差作为参数并绘制单帧。当帧函数返回false时,动画停止。
function runAnimation(frameFunc) {
let lastTime = null;
function frame(time) {
if (lastTime != null) {
let timeStep = Math.min(time - lastTime, 100) / 1000;
if (frameFunc(timeStep) === false) return;
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
我将最大帧步长设置为100毫秒(十分之一秒)。当带有我们页面的浏览器标签或窗口被隐藏时,requestAnimationFrame调用将被暂停,直到标签或窗口再次显示。在这种情况下,lastTime和time之间的差值将是页面被隐藏的整个时间。一次性将游戏推进如此之多看起来会很傻,并可能导致奇怪的副作用,例如玩家掉落到地面下。
该函数还将时间步长转换为秒,这比毫秒更容易理解。
runLevel函数接受一个Level对象和一个显示构造函数,并返回一个Promise。它在document.body中显示关卡,并让用户进行游戏。当关卡结束(失败或胜利)时,runLevel等待一秒钟(让用户看到发生了什么),然后清除显示,停止动画,并将Promise解析为游戏的结束状态。
function runLevel(level, Display) {
let display = new Display(document.body, level);
let state = State.start(level);
let ending = 1;
return new Promise(resolve => {
runAnimation(time => {
state = state.update(time, arrowKeys);
display.syncState(state);
if (state.status == "playing") {
return true;
} else if (ending > 0) {
ending -= time;
return true;
} else {
display.clear();
resolve(state.status);
return false;
}
});
});
}
游戏是一个关卡的序列。每当玩家死亡时,当前关卡会重启。当一个关卡完成时,我们会进入下一个关卡。这可以通过以下函数表达,该函数接受一个关卡计划(字符串)数组和一个显示构造函数:
async function runGame(plans, Display) {
for (let level = 0; level < plans.length;) {
let status = await runLevel(new Level(plans[level]),
Display);
if (status == "won") level++;
}
console.log("You've won!");
}
因为我们让runLevel返回一个Promise,runGame可以使用async函数来编写,如在第十一章中所示。它返回另一个Promise,当玩家完成游戏时会被解析。
本章沙盒中的GAME_LEVELS绑定提供了一组关卡计划([eloquentjavascript.net/code#16](https://eloquentjavascript.net/code#16))。该页面将它们传递给runGame,启动实际的游戏。
<link rel="stylesheet" href="css/game.css">
<body>
<script>
runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
练习
游戏结束
平台游戏通常让玩家从有限的生命开始,每次死亡时减去一条生命。当玩家没有生命时,游戏从头开始。
调整runGame来实现生命值。让玩家从三条生命开始。每当关卡开始时,输出当前的生命值(使用console.log)。
暂停游戏
通过按ESC键使游戏能够暂停(挂起)和恢复。你可以通过更改runLevel函数来设置一个键盘事件处理程序,当按下ESC时中断或恢复动画。
runAnimation接口乍一看可能不适合这个,但如果你重新安排runLevel调用它的方式,它就适合了。
当你完成这个后,还有其他可以尝试的事情。我们注册键盘事件处理程序的方式有些问题。arrowKeys对象当前是一个全局绑定,即使没有游戏在运行,其事件处理程序也会被保留。你可以说它们在我们的系统中泄漏了。扩展trackKeys提供一种注销其处理程序的方法,然后更改runLevel以在开始时注册其处理程序,在结束时注销它们。
一个怪物
对于平台游戏来说,传统上有敌人可以通过从上方跳跃来击败。这个练习要求你将这样的角色类型添加到游戏中。
我们称这个角色为怪物。怪物只能水平移动。你可以让它们朝玩家的方向移动,像水平熔岩一样来回反弹,或者采用你想要的任何其他移动模式。这个类不需要处理下落,但应该确保怪物不会穿过墙壁。
当怪物触碰到玩家时,效果取决于玩家是否在它们的上方跳跃。你可以通过检查玩家的底部是否靠近怪物的顶部来近似判断。如果是这种情况,怪物就会消失。如果不是,游戏就结束了。
绘画是一种欺骗。
—M.C. 艾舍,由布鲁诺·恩斯特在M.C. 艾舍的魔镜中引用
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0274-01.jpg
第十八章:在画布上绘图
浏览器为我们提供了几种显示图形的方式。最简单的方法是使用样式来定位和着色常规DOM元素。正如上一章中的游戏所示,这可以使我们走得很远。通过向节点添加部分透明的背景图像,我们可以使它们看起来完全符合我们的要求。甚至可以使用变换样式来旋转或倾斜节点。
但是我们将使用DOM做一些它最初并未设计的事情。有些任务,例如在任意点之间绘制一条线,使用常规HTML元素来完成非常笨拙。
有两种选择。第一种是基于DOM,但使用可缩放矢量图形(SVG)而不是HTML。可以将SVG视为一种文档标记方言,专注于形状而非文本。您可以直接在HTML文档中嵌入SVG文档,或使用<img>标签将其包含。
第二种选择被称为画布。画布是一个单一的DOM元素,用于封装一幅图像。它提供了一个编程接口,可以在节点占用的空间上绘制形状。画布和SVG图像之间的主要区别在于,在SVG中,形状的原始描述被保留,因此可以随时移动或调整大小。而画布则在绘制形状后立即将其转换为像素(光栅上的彩色点),并不记住这些像素代表什么。要在画布上移动一个形状,唯一的方法是清空画布(或清空形状周围的画布部分),然后在新的位置重新绘制该形状。
SVG
本书不会详细讲解SVG,但我会简要说明它的工作原理。在本章结束时,我会回到在决定适合特定应用程序的绘图机制时必须考虑的权衡。
这是一个包含简单SVG图像的HTML文档:
<p>Normal HTML here.</p>
<svg >
<circle r="50" cx="50" cy="50" fill="red"/>
<rect x="120" y="5" width="90" height="90"
stroke="blue" fill="none"/>
</svg>
xmlns属性将元素(及其子元素)更改为不同的XML 命名空间。这个由URL标识的命名空间指定了我们当前所使用的方言。<circle>和<rect>标签在HTML中不存在,但在SVG中具有意义——它们使用其属性指定的样式和位置绘制形状。
文档的显示效果如下:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0276-01.jpg
这些标签创建了DOM元素,就像HTML标签一样,脚本可以与之互动。例如,这将<circle>元素的颜色改为青色:
let circle = document.querySelector("circle");
circle.setAttribute("fill", "cyan");
画布元素
画布图形可以绘制到<canvas>元素上。您可以为此类元素设置宽度和高度属性,以确定其在像素中的大小。
一个新的画布是空的,这意味着它完全透明,因此在文档中显示为空白区域。
<canvas>标签旨在允许不同的绘图样式。要访问实际的绘图接口,我们首先需要创建一个上下文,这是一个对象,其方法提供绘图接口。目前有三种广泛支持的绘图样式:“2d”用于二维图形,“webgl”通过OpenGL接口用于三维图形,以及“webgpu”,这是一个更现代和灵活的WebGL替代方案。
本书不会讨论WebGL或WebGPU——我们将专注于二维。不过,如果你对三维图形感兴趣,我鼓励你了解WebGPU。它提供了一个直接访问图形硬件的接口,并允许你高效地使用JavaScript渲染复杂场景。
你可以通过在<canvas> DOM元素上使用getContext方法创建一个上下文。
<p>Before canvas.</p>
<canvas width="120" height="60"></canvas>
<p>After canvas.</p>
<script>
let canvas = document.querySelector("canvas");
let context = canvas.getContext("2d");
context.fillStyle = "red";
context.fillRect(10, 10, 100, 50);
</script>
创建上下文对象后,示例绘制了一个宽100像素、高50像素的红色矩形,其左上角的坐标为(10, 10)。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0277-01.jpg
就像在HTML(和SVG)中一样,画布使用的坐标系统将(0, 0)放在左上角,正y轴从那里向下。这意味着(10, 10)位于左上角下方和右侧10像素,例如。
线条和表面
在画布接口中,形状可以被填充,即其区域被赋予某种颜色或图案,或者可以被描边,即沿着其边缘绘制一条线。SVG使用相同的术语。
fillRect方法填充一个矩形。它首先接受矩形左上角的x和y坐标,然后是宽度,然后是高度。类似的方法strokeRect绘制矩形的轮廓。
这两种方法都不接受任何其他参数。填充的颜色、描边的粗细等,并不是通过方法的参数来确定的,正如你合理期望的那样,而是通过上下文对象的属性来确定的。
fillStyle属性控制形状的填充方式。它可以设置为指定颜色的字符串,使用CSS使用的颜色表示法。
strokeStyle属性类似地工作,但确定用于描边线的颜色。该线的宽度由lineWidth属性确定,lineWidth属性可以包含任何正数。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.strokeStyle = "blue";
cx.strokeRect(5, 5, 50, 50);
cx.lineWidth = 5;
cx.strokeRect(135, 5, 50, 50);
</script>
这段代码绘制了两个蓝色的正方形,第二个正方形使用了更粗的线条。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0278-01.jpg
当没有指定宽度或高度属性时,如示例所示,画布元素的默认宽度为300像素,高度为150像素。
路径
路径是一系列线条。2D画布接口以一种特殊的方式描述这样的路径。它完全通过副作用来完成。路径不是可以存储和传递的值。相反,如果你想对路径执行某个操作,你需要通过一系列方法调用来描述它的形状。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
for (let y = 10; y < 100; y += 10) {
cx.moveTo(10, y);
cx.lineTo(90, y);
}
cx.stroke();
</script>
该示例创建了一条包含多个水平线段的路径,然后使用stroke方法对其进行描边。每个使用lineTo创建的线段都从路径的当前位置开始。该位置通常是最后一个线段的末端,除非调用了moveTo。在这种情况下,下一条线段将从传递给moveTo的位置开始。
上一个程序描述的路径看起来像这样:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0279-01.jpg
填充路径时(使用fill方法),每个形状都是单独填充的。一个路径可以包含多个形状——每个moveTo动作都开始一个新的形状。但路径在被填充之前需要是闭合的(意味着起点和终点在同一位置)。如果路径尚未闭合,将从其末尾添加一条线到起点,填充由完成路径围成的形状。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(50, 10);
cx.lineTo(10, 70);
cx.lineTo(90, 70);
cx.fill();
</script>
该示例绘制一个填充三角形。请注意,三角形的两条边是显式绘制的。第三条边,从右下角返回到顶部,是隐含的,如果你描边路径,它将不会存在。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0279-02.jpg
你也可以使用closePath方法通过添加一条实际的线段返回路径的起点来显式闭合路径。当描边路径时,这条线段会被绘制。
曲线
一条路径也可以包含曲线。不幸的是,这些曲线的绘制稍微复杂一些。
quadraticCurveTo方法绘制一条到给定点的曲线。为了确定线的曲率,此方法需要一个控制点和一个目标点。想象这个控制点如同吸引线条,给予它曲线。线不会经过控制点,但起点和终点的方向会使得一条直线在该方向上指向控制点。以下示例说明了这一点:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(10, 90);
// control=(60,10) goal=(90,90)
cx.quadraticCurveTo(60, 10, 90, 90);
cx.lineTo(60, 10);
cx.closePath();
cx.stroke();
</script>
结果产生的路径看起来像这样:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0280-01.jpg
我们从左到右绘制一条二次曲线,控制点为(60, 10),然后绘制两条经过该控制点并返回到线段起点的线段。结果有些类似于星际迷航的徽章。你可以看到控制点的效果:从下角出发的线段最初朝着控制点的方向延伸,然后弯曲朝向目标。
bezierCurveTo方法绘制类似的曲线。与单一控制点不同,此方法有两个——分别对应于线段的两个端点。这里有一个类似的草图来说明这种曲线的行为:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
cx.moveTo(10, 90);
// control1=(10,10) control2=(90,10) goal=(50,90)
cx.bezierCurveTo(10, 10, 90, 10, 50, 90);
cx.lineTo(90, 10);
cx.lineTo(10, 10);
cx.closePath();
cx.stroke();
</script>
两个控制点指定了曲线两端的方向。它们距离各自的点越远,曲线在那个方向的“膨胀”就越明显。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0281-01.jpg
这样的曲线可能很难处理——并不总是清楚如何找到提供所需形状的控制点。有时你可以计算出它们,有时你只能通过反复试验找到合适的值。
弧形方法是一种沿圆边缘绘制曲线的线条。它需要一对弧心的坐标,一个半径,然后是起始角度和结束角度。
这两个最后的参数使得只绘制圆的一部分成为可能。角度以弧度为单位,而不是度数。这意味着完整圆的角度为2*π或2 * Math.PI,大约为6.28。角度从圆心右侧的点开始计数,然后顺时针方向进行。你可以使用0作为起始角度,并用大于2*π的结束角度(例如,7)来绘制一个完整的圆。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.beginPath();
// center=(50,50) radius=40 angle=0 to 7
cx.arc(50, 50, 40, 0, 7);
// center=(150,50) radius=40 angle=0 to 1/2 pi
cx.arc(150, 50, 40, 0, 0.5 * Math.PI);
cx.stroke();
</script>
生成的图像从完整圆的右侧(第一次调用弧)到四分之一圆的右侧(第二次调用)。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0281-02.jpg
像其他路径绘制方法一样,用弧绘制的线条与前一个路径段相连。你可以调用moveTo或开始新路径来避免这种情况。
绘制饼图
想象一下,你刚在EconomiCorp, Inc.找到了一份工作。你的第一个任务是绘制客户满意度调查结果的饼图。
结果绑定包含一个对象数组,代表调查响应。
const results = [
{name: "Satisfied", count: 1043, color: "lightblue"},
{name: "Neutral", count: 563, color: "lightgreen"},
{name: "Unsatisfied", count: 510, color: "pink"},
{name: "No comment", count: 175, color: "silver"}
];
要绘制饼图,我们绘制多个饼切片,每个切片由一个弧和一对线段连接到该弧的中心。我们可以通过将完整圆(2*π)除以总响应数来计算每个弧占用的角度,然后将这个数字(每个响应的角度)乘以选择特定选项的人数。
<canvas width="200" height="200"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let total = results.reduce((sum, {count}) => sum + count, 0);
// Start at the top
let currentAngle = -0.5 * Math.PI;
for (let result of results) {
let sliceAngle = (result.count / total) * 2 * Math.PI;
cx.beginPath();
// center=100,100, radius=100
// From current angle, clockwise by slice's angle
cx.arc(100, 100, 100,
currentAngle, currentAngle + sliceAngle);
currentAngle += sliceAngle;
cx.lineTo(100, 100);
cx.fillStyle = result.color;
cx.fill();
}
</script>
这将绘制以下图表:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0282-01.jpg
但是一个没有说明切片含义的图表并没有太大帮助。我们需要一种在画布上绘制文本的方法。
文本
2D画布绘图上下文提供了fillText和strokeText方法。后者对于描边字母很有用,但通常你需要的是fillText。它将使用当前fillStyle填充给定文本的轮廓。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.font = "28px Georgia";
cx.fillStyle = "fuchsia";
cx.fillText("I can draw text, too!", 10, 50);
</script>
你可以通过font属性指定文本的大小、样式和字体。这个例子仅给出了字体大小和家族名称。也可以在字符串开头添加斜体或粗体以选择样式。
fillText和strokeText的最后两个参数提供了绘制字体的位置。默认情况下,它们表示文本字母基线的起始位置,这是一条字母“站立”的线,不计算字母中的悬挂部分,如j或p。你可以通过将textAlign属性设置为“end”或“center”来改变水平位置,通过将textBaseline设置为“top”、“middle”或“bottom”来改变垂直位置。
我们将在本章末尾的练习中回到我们的饼图,以及标记切片的问题。
图片
在计算机图形学中,通常区分矢量图形和位图图形。前者是我们在本章中所做的——通过给出形状的逻辑描述来指定图片。位图图形则不同,它不指定实际形状,而是处理像素数据(彩色点的光栅)。
drawImage方法允许我们将像素数据绘制到画布上。这些像素数据可以来自<img>元素或另一个画布。以下示例创建一个分离的<img>元素并将图像文件加载到其中。但此方法不能立即开始从该图片绘制,因为浏览器可能尚未加载它。为了解决这个问题,我们注册一个“load”事件处理程序,并在图像加载后进行绘制。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/hat.png";
img.addEventListener("load", () => {
for (let x = 10; x < 200; x += 30) {
cx.drawImage(img, x, 10);
}
});
</script>
默认情况下,drawImage会以其原始大小绘制图像。你还可以给它两个额外的参数,以指定绘制图像的宽度和高度,当它们与原始图像不同时。
当drawImage给出九个参数时,它可以用于仅绘制图像的一部分。第二到第五个参数指示源图像中应复制的矩形(x,y,宽度和高度),第六到第九个参数给出应复制到画布上的矩形。
这可以用于将多个精灵(图像元素)打包到一个单独的图像文件中,然后只绘制你需要的部分。例如,这张图片包含了一个游戏角色的多个姿势:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0284-01.jpg
通过交替绘制不同的姿势,我们可以展示一个看起来像是走动角色的动画。
要在画布上动画化图像,clearRect方法非常有用。它类似于fillRect,但不是给矩形上色,而是使其透明,移除之前绘制的像素。
我们知道每个精灵、每个子图像的宽度为24像素,高度为30像素。以下代码加载图像,然后设置一个间隔(重复计时器)来绘制下一个帧:
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/player.png";
let spriteW = 24, spriteH = 30;
img.addEventListener("load", () => {
let cycle = 0;
setInterval(() => {
cx.clearRect(0, 0, spriteW, spriteH);
cx.drawImage(img,
// Source rectangle
cycle * spriteW, 0, spriteW, spriteH,
// Destination rectangle
0, 0, spriteW, spriteH);
cycle = (cycle + 1) % 8;
}, 120);
});
</script>
循环绑定跟踪我们在动画中的位置。对于每一帧,它会递增,然后通过使用余数运算符限制在0到7的范围内。此绑定用于计算当前姿势的精灵在图像中的x坐标。
变换
如果我们想让角色向左走而不是向右走呢?当然,我们可以绘制另一组精灵。但我们也可以指示画布反向绘制图像。
调用scale方法会使之后绘制的任何内容都进行缩放。此方法接受两个参数,一个设置水平缩放,另一个设置垂直缩放。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
cx.scale(3, .5);
cx.beginPath();
cx.arc(50, 50, 40, 0, 7);
cx.lineWidth = 3;
cx.stroke();
</script>
由于调用了scale,圆形的宽度绘制为三倍,高度为一半。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0285-01.jpg
缩放将导致绘制的图像的所有内容,包括线宽,按照指定的方式被拉伸或压缩。以负值进行缩放将翻转图片。翻转围绕点(0, 0)发生,这意味着它也会翻转坐标系统的方向。当应用水平缩放-1时,绘制在x位置100的形状将最终位于原本是位置-100的地方。
要旋转一张图片,我们不能简单地在调用drawImage之前添加cx.scale(-1, 1)。那样会将我们的图片移出画布,导致它不可见。我们可以调整传递给drawImage的坐标来弥补这个问题,将图片绘制在x位置-50而不是0。另一种解决方案是调整缩放发生的轴,不需要绘图代码了解缩放变化。
除了缩放之外,还有几种其他方法影响画布的坐标系统。你可以使用rotate方法旋转随后绘制的形状,并用translate方法移动它们。有趣且令人困惑的是,这些变换会叠加,意味着每个变换都是相对于先前的变换发生的。
如果我们平移10个水平像素两次,所有内容将向右绘制20个像素。如果我们首先将坐标系统的中心移动到(50, 50),然后按20度旋转(约0.1*π弧度),那么旋转将围绕点(50, 50)发生。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0286-01.jpg
但如果我们首先旋转20度,然后按(50, 50)进行平移,平移将在旋转的坐标系统中发生,从而产生不同的方向。变换应用的顺序很重要。
要围绕给定x位置的垂直线翻转图片,我们可以做以下操作:
function flipHorizontally(context, around) {
context.translate(around, 0);
context.scale(-1, 1);
context.translate(-around, 0);
}
我们将y轴移动到希望镜像的位置,应用镜像,最后将y轴移回在镜像宇宙中的正确位置。下图解释了为什么这样有效:
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0286-02.jpg
这显示了在中心线两侧镜像前后的坐标系统。三角形被编号以说明每一步。如果我们在一个正的x位置绘制一个三角形,它默认会位于三角形1所在的位置。首次调用flipHorizontally会向右平移,这使我们到达三角形2。接着它会缩放,将三角形翻转到位置3。如果按给定线进行镜像,这并不是它应该在的位置。第二次平移调用修正了这一点——它“抵消”了初始平移,使三角形4正好出现在应该的位置。
我们现在可以通过围绕字符的垂直中心翻转世界,在位置(100, 0)绘制一个镜像字符。
<canvas></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
let img = document.createElement("img");
img.src = "img/player.png";
let spriteW = 24, spriteH = 30;
img.addEventListener("load", () => {
flipHorizontally(cx, 100 + spriteW / 2);
cx.drawImage(img, 0, 0, spriteW, spriteH,
100, 0, spriteW, spriteH);
});
</script>
存储和清除变换
变换会持续存在。我们在绘制该镜像字符后绘制的其他所有内容也会被镜像。这可能会带来不便。
可以保存当前的转换,进行一些绘制和转换,然后恢复旧的转换。这通常是需要暂时转换坐标系统的函数所应做的事情。首先,我们保存调用该函数的代码所使用的任何转换。然后,该函数进行其操作,在当前转换上添加更多转换。最后,我们恢复到最初的转换。
2D画布上下文上的save和restore方法用于管理这种转换。它们在概念上保持着一个转换状态的堆栈。当你调用save时,当前状态会被推入堆栈,而当你调用restore时,堆栈顶部的状态会被弹出并用作上下文的当前转换。你还可以调用resetTransform来完全重置转换。
下面示例中的branch函数展示了你可以用一个改变转换的函数来做什么,然后再调用一个函数(在这个例子中是它自己),该函数继续使用给定的转换进行绘制。
这个函数通过绘制一条线、将坐标系统的中心移动到线的末端,并调用自身两次来绘制一个树状形状——第一次向左旋转,然后向右旋转。每次调用都会减少绘制的分支长度,当长度降到8以下时递归停止。
<canvas width="600" height="300"></canvas>
<script>
let cx = document.querySelector("canvas").getContext("2d");
function branch(length, angle, scale) {
cx.fillRect(0, 0, 1, length);
if (length < 8) return;
cx.save();
cx.translate(0, length);
cx.rotate(-angle);
branch(length * scale, angle, scale);
cx.rotate(2 * angle);
branch(length * scale, angle, scale);
cx.restore();
}
cx.translate(300, 0);
branch(60, 0.5, 0.8);
</script>
结果是一个简单的分形。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0288-01.jpg
如果没有save和restore的调用,第二次递归调用branch将会保留第一次调用所创建的位置和旋转。它将与当前分支没有关联,而是连接到第一次调用绘制的最内层、最右侧的分支。最终形状可能也会很有趣,但它绝对不是一棵树。
回到游戏。
我们现在对画布绘制有了足够的了解,可以开始为上一章的游戏构建一个基于画布的显示系统。新的显示不再仅仅显示彩色方块。相反,我们将使用drawImage来绘制代表游戏元素的图像。
我们定义了另一种显示对象类型,称为CanvasDisplay,它支持与第十六章中的DOMDisplay相同的接口——即方法syncState和clear。
这个对象比DOMDisplay保持了更多的信息。它并不使用其DOM元素的滚动位置,而是跟踪自己的视口,这告诉我们当前正在查看关卡的哪一部分。最后,它保留了一个flipPlayer属性,以便即使玩家静止不动,它也会面朝上次移动的方向。
class CanvasDisplay {
constructor(parent, level) {
this.canvas = document.createElement("canvas");
this.canvas.width = Math.min(600, level.width * scale);
this.canvas.height = Math.min(450, level.height * scale);
parent.appendChild(this.canvas);
this.cx = this.canvas.getContext("2d");
this.flipPlayer = false;
this.viewport = {
left: 0,
top: 0,
width: this.canvas.width / scale,
height: this.canvas.height / scale
};
}
clear() {
this.canvas.remove();
}
}
syncState方法首先计算一个新的视口,然后在适当的位置绘制游戏场景。
CanvasDisplay.prototype.syncState = function(state) {
this.updateViewport(state);
this.clearDisplay(state.status);
this.drawBackground(state.level);
this.drawActors(state.actors);
};
与DOMDisplay相反,这种显示样式确实需要在每次更新时重绘背景。由于画布上的形状仅仅是像素,绘制后没有好的方法来移动它们(或删除它们)。更新画布显示的唯一方法是清除它并重新绘制场景。我们可能还会滚动,这要求背景处于不同的位置。
updateViewport方法类似于DOMDisplay的scrollPlayerIntoView方法。它检查玩家是否太靠近屏幕边缘,并在这种情况下移动视口。
CanvasDisplay.prototype.updateViewport = function(state) {
let view = this.viewport, margin = view.width / 3;
let player = state.player;
let center = player.pos.plus(player.size.times(0.5));
if (center.x < view.left + margin) {
view.left = Math.max(center.x - margin, 0);
} else if (center.x > view.left + view.width - margin) {
view.left = Math.min(center.x + margin - view.width,
state.level.width - view.width);
}
if (center.y < view.top + margin) {
view.top = Math.max(center.y - margin, 0);
} else if (center.y > view.top + view.height - margin) {
view.top = Math.min(center.y + margin - view.height,
state.level.height - view.height);
}
};
对Math.max和Math.min的调用确保视口不会显示关卡之外的区域。Math.max(0, *x*)确保结果数字不小于零。Math.min同样保证值保持在给定的界限之内。
清除显示时,我们将根据游戏是胜利(更亮)还是失败(更暗)使用略微不同的颜色。
CanvasDisplay.prototype.clearDisplay = function(status) {
if (status == "won") {
this.cx.fillStyle = "rgb(68, 191, 255)";
} else if (status == "lost") {
this.cx.fillStyle = "rgb(44, 136, 214)";
} else {
this.cx.fillStyle = "rgb(52, 166, 251)";
}
this.cx.fillRect(0, 0, this.canvas.width, this.canvas.height);
};
为了绘制背景,我们遍历当前视口中可见的瓦片,使用与上一章的touches方法相同的技巧。
let otherSprites = document.createElement("img");
otherSprites.src = "img/sprites.png";
CanvasDisplay.prototype.drawBackground = function(level) {
let {left, top, width, height} = this.viewport;
let xStart = Math.floor(left);
let xEnd = Math.ceil(left + width);
let yStart = Math.floor(top);
let yEnd = Math.ceil(top + height);
for (let y = yStart; y < yEnd; y++) {
for (let x = xStart; x < xEnd; x++) {
let tile = level.rows[y][x];
if (tile == "empty") continue;
let screenX = (x - left) * scale;
let screenY = (y - top) * scale;
let tileX = tile == "lava" ? scale : 0;
this.cx.drawImage(otherSprites,
tileX, 0, scale, scale,
screenX, screenY, scale, scale);
}
}
};
非空的瓦片通过drawImage绘制。otherSprites图像包含用于除玩家之外的元素的图片。它从左到右包含墙壁瓦片、岩浆瓦片和硬币的精灵。
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0291-01.jpg
背景瓦片为20 x 20像素,因为我们将使用与DOMDisplay相同的比例。因此,岩浆瓦片的偏移量为20(比例绑定的值),墙壁的偏移量为0。
我们不费心等待精灵图像加载。用尚未加载的图像调用drawImage将什么也不做。因此,我们可能在图像仍在加载时无法正确绘制游戏的前几帧,但这不是一个严重的问题。由于我们持续更新屏幕,正确的场景将在加载完成后立即出现。
前面展示的行走角色将用于表示玩家。绘制它的代码需要根据玩家当前的运动选择正确的精灵和方向。前八个精灵包含行走动画。当玩家在地面上移动时,我们根据当前时间循环播放它们。我们希望每60毫秒切换帧,因此首先将时间除以60。当玩家静止时,我们绘制第九个精灵。在跳跃时(通过垂直速度不为零来识别),我们使用第十个、最右侧的精灵。
因为精灵比玩家对象稍宽——24像素而不是16像素,以便为脚和手臂留出一些空间——该方法必须按给定的量(playerXOverlap)调整x坐标和宽度。
let playerSprites = document.createElement("img");
playerSprites.src = "img/player.png";
const playerXOverlap = 4;
CanvasDisplay.prototype.drawPlayer = function(player, x, y, width, height) {
width += playerXOverlap * 2;
x -= playerXOverlap;
if (player.speed.x != 0) {
this.flipPlayer = player.speed.x < 0;
}
let tile = 8;
if (player.speed.y != 0) {
tile = 9;
} else if (player.speed.x != 0) {
tile = Math.floor(Date.now() / 60) % 8;
}
this.cx.save();
if (this.flipPlayer) {
flipHorizontally(this.cx, x + width / 2);
}
let tileX = tile * width;
this.cx.drawImage(playerSprites, tileX, 0, width, height,
x, y, width, height);
this.cx.restore();
};
drawPlayer方法由drawActors调用,负责绘制游戏中的所有角色。
CanvasDisplay.prototype.drawActors = function(actors) {
for (let actor of actors) {
let width = actor.size.x * scale;
let height = actor.size.y * scale;
let x = (actor.pos.x - this.viewport.left) * scale;
let y = (actor.pos.y - this.viewport.top) * scale;
if (actor.type == "player") {
this.drawPlayer(actor, x, y, width, height);
} else {
let tileX = (actor.type == "coin" ? 2 : 1) * scale;
this.cx.drawImage(otherSprites,
tileX, 0, width, height,
x, y, width, height);
}
}
};
在绘制非玩家对象时,我们查看其类型以找到正确精灵的偏移量。岩浆砖块位于偏移量20处,硬币精灵位于40处(两倍缩放)。
计算角色的位置时,我们必须减去视口的位置,因为我们canvas上的(0, 0)对应于视口的左上角,而不是关卡的左上角。我们也可以使用平移来实现这一点。两种方法都可行。
这就是新显示系统的总结。生成的游戏大致如下:
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0293-01.jpg>
选择图形接口
当你需要在浏览器中生成图形时,可以选择普通HTML、SVG和canvas。没有一种在所有情况下都能完美适用的最佳方法。每种选择都有其优缺点。
普通HTML的优点在于简单。它还与文本很好地集成。SVG和canvas都允许你绘制文本,但它们不会帮助你定位文本或在文本占用多于一行时换行。在基于HTML的图像中,包含文本块要容易得多。
SVG可以用于生成在任何缩放级别下都看起来清晰的图形。与HTML不同,SVG是为绘图设计的,因此更适合此目的。
SVG和HTML都会建立一个表示图像的数据结构(DOM)。这使得在绘制后修改元素成为可能。如果你需要根据用户的操作或作为动画的一部分,反复更改大图的一小部分,在canvas中这样做可能会无谓地昂贵。DOM还允许我们在图像中的每个元素上注册鼠标事件处理程序(即使是用SVG绘制的形状)。这在canvas中是做不到的。
但canvas的像素导向方法在绘制大量微小元素时可以成为一种优势。它不建立数据结构,而是重复在同一像素表面上绘制,这使得canvas每个形状的成本更低。还有一些效果只有在canvas元素中才实用,例如逐像素渲染场景(例如,使用光线追踪器)或使用JavaScript对图像进行后处理(模糊或扭曲图像)。
在某些情况下,你可能想要结合这些技术。例如,你可以用SVG或canvas绘制图表,但通过将HTML元素放在图像上方来显示文本信息。
对于不要求高的应用程序,选择哪个接口其实并没有太大关系。本章中为我们的游戏构建的显示可以使用这三种图形技术中的任何一种来实现,因为它不需要绘制文本、处理鼠标交互或处理数量极多的元素。
总结
在本章中,我们讨论了在浏览器中绘制图形的技术,重点是<canvas>元素。
canvas节点表示文档中我们程序可以绘制的区域。这个绘制是通过使用getContext方法创建的绘图上下文对象完成的。
2D绘图接口允许我们填充和描边各种形状。上下文的fillStyle属性决定形状的填充方式。strokeStyle和lineWidth属性控制线条的绘制方式。
矩形和文本片段可以通过一个方法调用绘制。fillRect和strokeRect方法绘制矩形,而fillText和strokeText方法绘制文本。要创建自定义形状,我们必须先构建一条路径。
调用beginPath开始一条新路径。其他一些方法可以向当前路径添加线条和曲线。例如,lineTo可以添加一条直线。当路径完成时,可以使用fill方法填充或使用stroke方法描边。
将图像或另一个画布的像素移动到我们的画布上是通过drawImage方法完成的。默认情况下,此方法绘制整个源图像,但通过提供更多参数,您可以复制图像的特定区域。我们在游戏中使用这一点,通过从包含多个姿势的图像中复制游戏角色的单个姿势。
变换允许您以多种方向绘制形状。2D绘图上下文具有一个当前变换,可以通过translate、scale和rotate方法进行更改。这些将影响所有后续的绘图操作。可以使用save方法保存变换状态,并使用restore方法恢复。
在画布上显示动画时,可以使用clearRect方法在重新绘制之前清除画布的部分区域。
练习
形状
编写一个程序,在画布上绘制以下形状:
-
一个梯形(一个一侧更宽的矩形)
-
一个红色菱形(一个旋转了45度或
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0294-01.jpg>弧度的矩形) -
一条锯齿形的线
-
由100段直线组成的螺旋形
-
一个黄色星星
<https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0295-01.jpg>
在绘制最后两个形状时,您可能想参考第十四章中关于Math.cos和Math.sin的解释,该章描述了如何使用这些函数获取圆上的坐标。
我建议为每个形状创建一个函数。将位置以及其他属性(如大小或点数)作为参数传递。另一种选择是将数字硬编码到代码中,这往往会使代码变得难以阅读和修改。
饼图
在本章早些时候,我们看到一个绘制饼图的示例程序。修改该程序,使每个类别的名称显示在表示它的切片旁边。尝试找到一种悦目的方式来自动定位这些文本,使其适用于其他数据集。您可以假设类别足够大,以留出足够的空间用于标签。
您可能需要再次使用Math.sin和Math.cos,它们在第十四章中进行了描述。
一个弹跳的球
使用我们在第十四章和第十六章中看到的requestAnimationFrame技术,绘制一个带有弹跳球的盒子。球以恒定速度移动,并在碰到盒子的边缘时反弹。
预计算镜像
变换的一个不幸之处是它们会减慢位图的绘制速度。每个像素的位置和大小都需要被变换,尽管浏览器未来可能会在变换方面变得更加智能,但目前它们确实会导致绘制位图所需时间的可测量增加。
在像我们这样的游戏中,我们只绘制一个变换后的精灵,这并不是问题。但想象一下,我们需要绘制数百个角色或成千上万的旋转粒子来自爆炸。
想出一种方法来绘制一个反转的角色,而不加载额外的图像文件,也不必每帧都进行变换的drawImage调用。
人们常常难以理解设计的一点是,除了URLs、HTTP和HTML之外,没有其他东西。没有一个中央计算机“控制”着网络,没有一个单一的网络可以使用这些协议,甚至没有任何地方的组织“运行”这个网络。网络并不是一个存在于某个“地方”的物理“东西”。它是一个可以存在信息的“空间”。
—蒂姆·伯纳斯-李
https://github.com/OpenDocCN/geekdoc-js-zh/raw/master/docs/elqt-js-4e/img/f0296-01.jpg


浙公网安备 33010602011771号