CSCI2720 · 阶段模拟 A:详解与评分指引
先完成试卷,再阅读本指引。对于能够正确解释指定程序的推理点,分别给分;以清晰状态表、引用关系图或执行轨迹表达的等价解释,同样可以得分。
Q01 — 解析资源地址并解释请求(7 分)
https://notes.example.test:8443/course/assets/timetable.svg?size=2#caption
https://notes.example.test:8443
/course/assets/timetable.svg?size=2
(a) 三行分别回答三个不同问题:“完整地址是什么?”“它属于哪个源?”“用哪个路径与查询标识所请求的资源?”URL构造函数把地址解析为各字段。仅仅构造该对象,并不会加载资源。
从左到右阅读解析后的地址:
| 字段 | 本题的值 | 含义 |
|---|---|---|
| 协议 | https | 地址采用HTTPS。 |
| 主机名 | notes.example.test | 地址中的服务器名称。 |
| 端口 | 8443 | 明确指定的端口,不是HTTPS默认端口443。 |
| 路径 | /course/assets/timetable.svg | 资源在该服务器URL空间中的位置。 |
| 查询 | size=2 | 额外的请求数据,在序列化URL中以?引出。 |
| 片段 | caption | 资源内部的位置或标识符,以#引出。 |
target.href把所有字段包含在一个字符串中。target.origin保留协议、主机名和明确指定的非默认端口,得到https://notes.example.test:8443,不包含路径、查询或片段。区分源,有助于判断两个地址是否处于同一个协议/主机/端口环境。两个资源可以同源,但路径不同;即使主机名不变,改变端口也可能改变源。
(b) 从基准文档出发,而不是猜测服务器的文件路径。它的路径是/course/week3/index.html。进行相对解析时,最后一部分是文档名,所以所在目录为/course/week3/。接下来,../assets/timetable.svg开头的..向上移动一级到/course/,再接上assets/timetable.svg,得到/course/assets/timetable.svg。由于相对地址没有替换协议、主机名和端口,这些字段从基准地址继承。
解析后的pathname是/course/assets/timetable.svg。search属性包含开头的标点,因此是?size=2。把两个字符串连接起来,就是第三行输出,程序不需要再加一个问号。片段另外存放,不包含在普通HTTP请求目标中。
这个区别告诉我们诊断请求时应该查看哪一部分。处理该路径的服务器可以检查查询size=2,但不会把#caption作为请求目标的一部分收到。浏览器可以在取得资源后使用片段。如果把可见地址中的每个字符都当成服务器输入,就会把浏览器的导航决定归到错误的系统部分。
(c) 根据负责执行的对象逐项追踪操作。仅改变片段,可以移动到现有文档中的另一个位置。处理函数给段落的textContent赋值,改变的是当前浏览器文档中的DOM节点。这个节点是第二个操作的直接结果,本身并不是服务器上的记录。
要认定服务器保存了新数据,还需要更完整的链条:浏览器发送数据,服务接受数据,而且已经完成的存储操作具备所要求的存续时间。可见文本改变本身不能确立其中任何后续步骤。如果处理函数只更新变量与DOM,新文档就会从自己的初始化状态开始。解释界面时,应区分成功的本地更新与已完成的保存,不要用同一个“已保存”来描述二者。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 三行准确输出各1分,包括端口及片段位置。 |
| b | 2 | 正确解析父目录1分;区分请求查询数据与片段1分。 |
| c | 2 | 识别文档内导航或DOM工作1分;解释服务器持久化需要实际通信/存储操作1分。 |
Q02 — 阅读文档所表达的含义(8 分)
(a) title提供浏览器界面使用的文档标题,例如标签页标题。h1是页面内容中可见的最高级标题。main标识文档的主要内容区域。即使title和h1的文字相同,也不能互换:它们的位置与角色不同。
这些元素让读者和工具能够用不同方式识别同一份文档。标签页标题用于区分已打开的文档;进入文档后,可见标题引入内容;主要内容区域则定位核心材料。按角色选用元素,即使CSS后来改变外观,这些关系仍有意义。
(b) 该数据单元格说明CSS学习时段为25分钟。CSS使用scope="row",因此是该行的标题;Minutes使用scope="col",因此是该列的标题。表题Study sessions为整个表格命名。链接中的#schedule片段标识id="schedule"的元素,在这里就是表格。它是本文档内的目标,而不是另一个独立文档资源。
要重建该单元格的含义,先找到它所在的行:行标题指出CSS。再沿所在列向上看:列标题给出单位——分钟。把两者结合,得到“CSS:25分钟”,而不只是一个未解释的数字25。表题说明表格容纳的是哪一组事实,不能替代行标题或列标题的关系。
(c) code元素的文本是<em>focus</em>,其中没有em后代元素。源代码中的字符引用在解析时解码成字面的文本字符;所得字符序列不会再被当成标记解析一次,因而不会创建强调元素。code本身也不会普遍禁用HTML解析:这里是转义写法表达了字面的尖括号。
把<读成文本中的小于号,把>读成文本中的大于号。这些字符成为文本节点的一部分。相反,如果HTML源代码中直接写出未转义的<em>focus</em>,它就是元素标记。如果目的是讲解或显示源语法,转义括号才能把源代码保留为可读文本;只把外围元素改成code,不能达到这个目的。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 文档标题、最高级可见标题、主要内容区域的角色各1分。 |
| b | 3 | 通过标题将25与CSS及Minutes关联1分;表题为全表命名1分;链接定位到表格ID 1分。 |
| c | 2 | 准确写出字面文本1分;说明转义字符属于文本、不会创建强调元素1分。 |
Q03 — 推导样式与响应式盒尺寸(10 分)
(a) 两种指定视口宽度下,颜色都是蓝色。文章匹配三种颜色选择器,其优先级分别如下:
| 选择器 | 优先级 |
|---|---|
.tile | (0,1,0) |
#board .tile | (1,1,0) |
.tile.notice | (0,2,0) |
三元组依次记录ID、类选择器类项、元素选择器类项的数量。在这些选择器中,#board贡献一个ID,每个.tile或.notice贡献一个类。后代选择器中的空格表达祖先与文章之间的关系,不额外增加优先级计数。
从左到右比较三元组,遇到第一个不同分量就停止。它们不是十进制数,各分量也不能相加。先比较(1,1,0)与(0,2,0):第一个分量已经更大,因此含ID的选择器胜出,不必再比较类数量。然后,(0,2,0)在第二个分量胜过(0,1,0)。所以,在题目所述共同来源、重要性和分层条件下,#board .tile提供蓝色。较后的.tile.notice不能仅凭源代码顺序胜出,因为优先级已经不同。媒体查询中没有颜色声明。
解释某条匹配规则为什么落败时,按这个方法处理:先确认元素匹配,再检查竞争规则处于同一个层叠比较阶段,然后比较优先级,只有打平才用源代码顺序。只看最后一条颜色声明,会跳过决定本例结果的关键阶段。
(b) 视口为600个CSS像素时,媒体查询不匹配。文章使用content-box,内容宽度为240像素。左右各12像素的内边距,以及左右各3像素的边框,都在声明的内容宽度之外:
border-box width = 240 + 12 + 12 + 3 + 3 = 270 CSS pixels
这里的px指CSS像素,是样式表和视口条件使用的单位,不一定等于物理屏幕像素的数量。600像素的视口宽度用来决定哪些规则适用;文章声明的240像素宽度,描述的是视口内另一个盒子。
计算前先画出一条水平切面:
| 从左侧外缘到右侧外缘 | 左边框 | 左内边距 | 内容 | 右内边距 | 右边框 |
|---|---|---|---|---|---|
| 600个CSS像素视口下的宽度 | 3 | 12 | 240 | 12 | 3 |
内容占240像素,两侧内边距合计24,两侧边框合计6。相加240 + 24 + 6,得到两外缘之间的边框盒宽270。题目没有提供相关外边距,因此这次计算不含外边距;也不额外加上600,因为视口只是选择尺寸规则的条件,不是文章宽度的第二个组成部分。
(c) 视口为800个CSS像素时,媒体查询匹配,尺寸计算改为border-box。声明的240像素现在包含内边距和边框。因此边框盒宽度为240像素,内容宽度是240 - 24 - 6 = 210像素。改变宽度所包含部分的解释,并不会让内边距或边框消失。
这次从外向内计算。先从总宽240中预留两侧各3像素的边框,剩234;再预留两侧各12像素的内边距,剩210用于内容。相应切面是3 + 12 + 210 + 12 + 3 = 240。计算模式改变的是width描述哪个盒子,并不会把文章拉伸到800像素,也不会改变内边距声明的数值。
(d) 在600像素时,还未达到默认md阈值768像素。两列都使用col-12,所以各自占满一行,上下排列。在800像素时,每列的col-md-6生效:各占一行12个宽度单位中的6个,能够并排。这些比例相对于网格行,不代表列宽一定等于整个视口宽度的一半。
先确定断点,再计算当前比例:在md以下,每列为一行宽度的12/12 = 1;从md开始,每列为一行宽度的6/12 = 1/2。外层容器、行与沟槽共同决定被划分的实际区域。因此,“占半行”与“视口为800,所以宽400个CSS像素”是两个不同结论。前者可由类名推出,后者还需要知道行的实际宽度和布局细节。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 蓝色1分;正确写出竞争选择器的优先级1分;在所述条件下先比较优先级再比较源顺序1分。 |
| b | 3 | 内容宽240像素1分;加上两侧内边距和边框1分;边框盒宽270像素1分。 |
| c | 2 | 边框盒宽240像素1分;正确相减得到内容宽210像素1分。 |
| d | 2 | 600时全行上下排列1分;利用768阈值说明800时两列各半行1分。 |
Q04 — 追踪类型转换与独立闭包(10 分)
["51",4,false]
5
3
4
(a) JSON.stringify使第一行中的值类型明确可见:"51"是字符串,4是数值,false是布尔值。其余三行是三次闭包调用返回的值。
(b) 每次调用makeCounter(2),都会创建一次独立调用及其自己的value绑定。返回的箭头函数保留对那次调用绑定的访问:
| 操作 | first保留的值 | second保留的值 | 返回值 |
|---|---|---|---|
| 创建两个计数器 | 2 | 2 | — |
first(3) | 5 | 2 | 5 |
second(1) | 5 | 3 | 3 |
first(-1) | 4 | 3 | 4 |
虽然函数定义与初始数值相同,它们并不共用一个保留绑定。makeCounter返回后,也不会抹除其返回函数仍在使用的绑定。
可以把每次工厂调用看成创建一个单独编号的计数格。把返回的函数赋给first或second,就获得访问相应格子的途径。重现结果时,先给这些格子编号,再只更新实际调用的函数所访问的那个格子。这个方法能够区分闭包保留的状态,与每次调用新传入的delta参数。
(c) "5" + 1执行字符串拼接,得到"51"。这里的减法会进行数值转换,因此"5" - 1是数值4。严格相等比较保持数值0与字符串"0"的类型区别,所以结果是false,不会先把它们转换成同一类型再比较。
即使两个值显示出来很相似,输入阶段的引号仍然重要。如果表单计算的意图是数值加法,应在使用+之前明确转换字符串,否则运算符可能构造文本。预测中间类型和值,比记住某一对数字的输出模式更可靠。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 4 | 四行准确输出各1分。 |
| b | 4 | 两次工厂调用创建独立绑定1分;2→5、2→3、5→4三次状态变化各1分。 |
| c | 2 | 解释拼接与数值减法的区别1分;严格相等对不同类型不作转换1分。 |
Q05 — 区分外层数组与其中的元素(8 分)
[2,2,3,25]
[5,7]
[{"value":"start","done":false},{"value":"finish","done":false},{"value":"done","done":true}]
(a) sessions和alias指向同一个长度为2的外层数组。copy是另一个外层数组,追加后长度为3。第一个嵌套对象仍被共享,因此通过sessions读取它的minutes时得到25。
第一行输出把四种不同观察装进同一个JSON数组。依次是sessions的条目数、alias的条目数、copy的条目数,以及第一个学习时段对象的数值分钟数。数组长度计数的是条目,不是时长。因此,[2,2,3,25]包含三个计数,最后才是25分钟这个量。
(b) slice()复制的是外层数组的元素引用,并不复制嵌套对象本身。因此,sessions[0]与copy[0]仍指向同一个对象;修改该对象属性,通过任一引用都能看到。追加操作修改的是独立的copy数组成员,并未向sessions追加元素。流水线的输入是sessions,其中分钟数现为25和35。二者均满足筛选条件,再分别除以5,得到[5,7]。新追加的10分钟对象不属于这条流水线的输入。
给对象加上图示标签,以区分这两个层次。把原外层数组记为A,复制出的外层数组记为B,两个初始学习时段对象记为P和Q。这些只是关系图的标签,不是额外的程序变量。
| 名称或数组位置 | 指向什么? | 后果 |
|---|---|---|
sessions与alias | 同一个外层数组A | 本轨迹中,两者读到的数组长度始终相同。 |
copy | 另一个外层数组B | 改变B的成员,不会改变A的成员。 |
| A与B的第一个条目 | 同一个对象P | 修改P的minutes,通过任一数组都能看到。 |
| A与B的第二个条目 | 同一个对象Q | Q仍有35分钟。 |
| B新追加的第三个条目 | 新的10分钟对象 | A没有指向这个新对象的条目。 |
现在跟随实际操作,不要把“副本”理解成所有内容都完全独立的承诺:
| 执行此操作后 | A的长度 | B的长度 | P的分钟数 |
|---|---|---|---|
copy = sessions.slice() | 2 | 2 | 20 |
copy[0].minutes = 25 | 2 | 2 | 25 |
copy.push(...) | 2 | 3 | 25 |
只要列表成员需要独立,浅复制就可能足够;如果嵌套记录也要能够独立编辑,只复制外层数组就不满足要求。被编辑的嵌套记录需要独立归属,或另外复制。
对流水线,先判断谓词,再进行变换。25分钟对象满足25 >= 25,35分钟对象满足35 >= 25。除法把每个保留时长按1/5缩放。如果把5解释为一个五分钟单位,结果就是25分钟 / 5分钟 = 5个这样的单位,以及35分钟 / 5分钟 = 7个这样的单位。JavaScript在这里保存普通数值,单位来自我们的解释。把结果称为“5分钟和7分钟”,就改变了这种解释。代码没有取整,也没有把学习时段划分为完整组:保留下来的时长若为26分钟,便得到26 / 5 = 5.2。如果要求只统计完整的五分钟段,还需要额外说明相应规则。
| 流水线阶段 | 数据形状 | 该阶段的值 |
|---|---|---|
sessions | 对象数组,每个对象有数值minutes属性 | 对象分别包含25和35分钟。 |
filter之后 | 由保留对象组成的另一个数组 | 两个原对象都通过;筛选尚未把它们转换成数字,也没有复制对象。 |
map之后 | 数值数组 | 回调返回缩放后的时长5和7。 |
用文字说,数据先由对象数组变为保留对象的数组,再变为数值数组。映射后元素的形状,由回调的返回值决定;筛选回调则决定是否保留已有元素。应根据所需结果选择操作:先选择合格的学习时段记录,再把每个保留时长转换到所需的数值尺度。
如果要求只改变复制列表中的第一条记录,在全新运行中,应把原来的属性赋值改为copy[0] = { ...copy[0], minutes: 25 }。这个表达式创建另一条记录,把其引用赋给B的第一个条目,并不修改P。因此,原来的sessions[0].minutes仍为20。后续流水线依旧读取sessions,所以只有35分钟记录通过阈值,结果变为[7]。这个变化直接检验编辑是否落在目标记录上,而不是仅仅通过另一个名字引用并修改了同一记录。
(c) 前两次next()停在yield,所以done都是false。第三次到达生成器的return "done",结束执行并给出done: true。再调用一次将返回{ value: undefined, done: true }。完成的生成器仍保持完成状态;最后返回的字符串并不是另一个产出的条目。
把属性名done与字符串值"done"分开。属性是布尔完成标志,字符串则恰好是该生成器最终返回时选择的值。每次next()都会恢复同一段暂停中的执行。再次调用生成器函数,才会创建新游标;继续推进已经完成的游标,不会使它重新开始。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 三行准确输出各1分,包括生成器完成标志。 |
| b | 3 | 共享嵌套对象身份1分;外层数组成员独立1分;将25和35经筛选/映射追踪为5和7得到1分。 |
| c | 2 | 区分yield与return/完成1分;补充调用得到undefined值且done为true 1分。 |
Q06 — 追踪节点与冒泡事件(10 分)
2,3,Changed
button:word:choose
bubble:word:panel
(a) 集合观察先于事件输出,因为它在最后的.click()之前记录。随后,点击先调用按钮监听器,再到达祖先面板的监听器。
(b) 最初的querySelectorAll结果固定了两个初始匹配节点,因此追加后长度仍为2。但它仍引用实际节点,所以改变第一个节点文本后,可以通过snapshot[0]读到新文本。实时类集合跟随文档成员变化,包含新追加的li,所以长度为3。点击起点span位于button内,button又位于panel内;普通冒泡先到达按钮监听器,再到达面板监听器。
“静态”描述的是哪些节点属于这一次结果,而不是节点能否改变。重新推导本题时,先列出查询时记录的两个节点引用,再把第三个节点追加到文档中,但不要把它加入已记录的列表。到输出那一行时,读取第一个已记录节点的当前文本。这样就同时得到固定成员数和改变后的属性值,两者并不矛盾。
(c) 两行中的word都是起始元素的ID,来自event.target。监听器的event.currentTarget在按钮监听器中是choose,在面板监听器中是panel。这些监听器观察的是在后代元素上开始的事件;注册监听器的元素不必是事件起点。冒泡不会依次把原始目标改成各个祖先。
当一个祖先监听器处理多个后代的点击时,这个区别很有用。当前目标说明正在运行哪个监听器,原始目标说明本次点击从哪里开始。不能因为有两行事件输出就推断发生了第二次点击:它们是同一个已派发点击在冒泡过程中产生的两次观察。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 三行准确且位置正确的输出各1分。 |
| b | 4 | 静态成员、记录节点仍可变、实时成员、按钮先于面板的冒泡解释各1分。 |
| c | 3 | target为word 1分;currentTarget先为choose后为panel 1分;说明祖先监听器能观察从后代开始的事件1分。 |
Q07 — 区分对象身份、数据与方法接收对象(6 分)
[true,false,1,"undefined"]
Sketch
(a) alias接收与original相同的对象引用,所以严格身份比较为true。序列化再解析会构造新对象,因此original === roundTrip为false。构造时,原对象中嵌套的count仍为1。之后通过别名进行修改,会把原嵌套对象的count变成2,但不会改变独立解析出来的嵌套对象;后者仍保留1。
复制与编辑的顺序很重要。先根据count 1创建数据表示,解析该表示会构造新的外层对象和嵌套meta对象。之后,别名才把原嵌套count改为2。这与Q05的外层数组切片不同:本题中的JSON往返重建了被表示的嵌套数据,而不是保留同一个嵌套对象引用。
(b) 方法是一个以函数为值的属性。该对象的JSON序列化不会包含这个方法,解析JSON也不会重新创建函数。读取不存在的roundTrip.describe得到undefined,其类型报告为字符串"undefined"。在original.describe()中,接收方法调用的对象是original,所以this.name读取它的name属性,即"Sketch"。方法体并没有以roundTrip为接收对象被调用。
当所需结果是JSON支持的数据表示时,可以采用JSON,例如交换名称与计数。不要因为希望任意对象的每个方面——包括行为——都保留下来,就选用它。分析方法结果时,应定位实际调用中点号左边的对象;这个接收对象解释了本题的this值。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 两行输出均正确1分;区分别名与新对象身份1分;说明count在后续修改之前以1构造出来1分。 |
| b | 3 | JSON省略函数值方法1分;不存在的属性使typeof结果为字符串"undefined"得1分;原方法接收对象决定返回Sketch 1分。 |
Q08 — 检查条目与持续生效的有效性状态(9 分)
[["title","CSS"],["pages","3"],["include","yes"],["format","notes"]]
[true,false]
[true,false]
[false,true]
(a) 条目按符合提交条件的控件在文档中的顺序排列。title贡献"CSS",数字控件贡献字符串"3",已勾选复选框贡献include=yes,所选选项贡献format=notes。未勾选的skip没有条目;禁用的hint也没有条目。这次FormData调用未指定提交按钮,因此提交按钮的action=save同样不会出现。
沿着每次数据变换阅读第一行。new FormData(form)构造一个包含符合提交条件条目的FormData对象。展开这个可迭代对象,会产生一个普通的外层数组,共有四个条目。每个条目本身又是一个二元素数组:先是名称,再是值。本题这四对中的八个成员全是字符串,形成4 × 2的条目结构。例如第二对["pages","3"],第一个字符串是字段名,第二个字符串包含字段值。随后,JSON.stringify把这个数组结构转换成一个JSON文本字符串,控制台输出的就是这段文本。输出中的括号描述数组和二元素对,不表示每一对都是独立的JSON对象。
名称来自name,不是可见标签或ID。数字控件在这些条目中仍以字符串表示值。要计算页数合计,应把取出的页数字符串明确转换成数字,或读取该数字输入的valueAsNumber,再检查所要求的数值规则。对于初始值,Number("3")给出数值3;转换后加上数值2得到5,而直接把字符串"3"与数值2用+组合则得到"32"。构造条目、展开数组、JSON序列化、数值转换与验证分别承担不同工作,应识别当前使用的到底是哪一阶段的值。
(b) 最小值为1、步长为1,允许的数值步点是1、2、3,依此类推。把pages.value设为"1.5"会产生步长不匹配,使表单无效,输出[true,false]。
下一次调用记录了非空的自定义有效性信息。把值改为"4"修复了数值步长不匹配,却没有清除自定义错误。因此customError为true,表单仍然无效,输出[true,false]。
最后,把自定义信息设为空字符串,清除自定义错误。当前值4现在满足必填、最小值和步长约束,其余控件也满足各自适用的约束,结果为[false,true]。纠正输入值与清除自定义错误,是两个不同的状态变化。
把这些状态明确列出来。下表中的“表单有效?”指在该阶段检查全部适用约束的结果,其他控件保持不变。
| 阶段 | pages中的当前文本 | stepMismatch | customError | 表单有效? | 观察 |
|---|---|---|---|---|---|
| 构造初始条目 | "3" | false | false | true | 条目保存字符串"3"。 |
赋值"1.5" | "1.5" | true | false | false | 第一组有效性数对读取步长标志与表单结果。 |
设置自定义信息后赋值"4" | "4" | false | true | false | 第二组数对读取自定义标志与表单结果。 |
| 清除自定义信息 | "4" | false | false | true | 最后一组确认两个障碍都已清除。 |
步长从步长基准量起,这里的基准是最小值1。步长为1时,步点网格为1 + n,其中步数n为整数。独立的最小值约束还要求值至少为1,所以本题允许的值又要求n >= 0。差值1.5 - 1 = 0.5不是整数个步长,而4 - 1 = 3是。这解释了考虑自定义规则之前,浏览器的步长标志为什么不同。非空自定义信息又提供了另一个无效原因。替换字段中的文本,不会自动抹去应用代码先前设置的信息。
修复这类表单时,应检查哪个有效性标志仍为true。如果数值违反步长规则,应视实际要求修正输入值或规则;如果剩下的问题是一条已经过时的自定义信息,则应在重新评估相关用户输入时清除它。重复调用checkValidity()只观察当前状态,不会完成这两种修复中的任何一种。
(c) 启用、具名、只读的文本控件会贡献当前值,这里是hint=draft。只读控件与禁用控件的提交行为不同。只读限制的是当前界面,并不能证明所有到达服务的请求都来自该界面或遵守它的约束。接收服务必须验证自己接受的值。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 有序条目准确且pages为字符串1分;解释未勾选/禁用控件被省略1分;未指定提交按钮时排除按钮条目1分。 |
| b | 4 | 三行有效性输出全部正确1分;数值步长不匹配1分;自定义错误持续生效1分;清除错误后当前值有效1分。 |
| c | 2 | 启用的只读控件产生条目1分;说明接收端仍须验证1分。 |
Q09 — 定位失败的异步阶段(8 分)
fulfilled:503:false
later
json-error
request-error
(a) 第一个等待的Promise以构造出的响应兑现;该响应状态为503,且ok === false。响应体仍是有效JSON,含有消息later。后面两个独立的catch分别记录各自阶段的失败。
沿着前两行处理过程,逐次说明程序手里拿到的值。new Response(...)先建立一个具有状态、响应头和响应体的响应对象。Promise.resolve(...)把这个对象包在一个已经兑现的Promise里。等待这个Promise,得到的是赋给response的Response对象,并不会同时把响应体变成JavaScript对象。调用response.json()才会开始另一次读取和解析响应体的操作,并返回另一个Promise。等待第二个Promise,才得到解析后的对象,然后读取其message属性。
因此,类型与形状的路径是Promise<Response> → Response → Promise<parsed data> → { message: "later" } → 字符串"later"。每个箭头都对应程序中的一项具体操作,并不是上一个箭头自动附带完成的转换。把这些阶段分开,才能理解:响应状态和从响应体解释出的数据,可能在说明不同层面的失败。
(b) “取得响应的Promise已兑现”与“HTTP状态成功”是不同观察。示例中的Promise.resolve以一个Response兑现;503状态不会把该Promise变为拒绝。因为503不属于成功范围200–299,所以ok为false。
响应的错误状态不妨碍读取并解析其中有效的响应体。JSON对象包含message: "later",因此第二行是later。
下一个响应的状态虽然为200,但其响应体文本{broken不是有效JSON。被拒绝的操作是.json()返回的Promise,由外围try/catch捕获。最后一段的Promise.reject(...)表示在返回可用响应之前就失败的操作;等待它会抛出错误并进入最后的catch。
用四层问题决定下一步该做什么:
| 层次 | 当前持有或检查什么? | 本例的观察 | 相应的下一步判断 |
|---|---|---|---|
| 响应是否可用 | 一个应当给出响应的Promise | 最后一个被明确拒绝的Promise没有给出响应。 | 处理这次拒绝;该操作没有响应状态或响应体可供检查。 |
| HTTP状态 | 已取得响应的status与ok | 第一个响应确实存在,但503使ok为false。 | 进入状态不成功的处理分支;需要时读取约定的错误表示。 |
| 响应体的表示 | 读取响应体的Promise及其解析结果 | 503响应体是有效JSON;另一个200响应体却在JSON解析时拒绝。 | 解析成功后才能使用解析数据;成功状态不能修复格式错误的JSON。 |
| 应用含义 | 解析对象的字段及操作约定 | 第一个对象提供消息later,没有提供存储已完成的结果。 | 将字段与不成功的状态结合解释,再决定向用户显示什么结果。 |
例如,界面不应只因第一个响应体能够解析,就报告保存完成;它取得的是错误响应和一条解释信息。反过来,200响应在JSON解析时失败,若随意用空对象拼出一个正常结果,就会掩盖表示层已经失败的事实。先定位失败的层次,才能决定要检查的是Promise拒绝、HTTP结果、格式错误的表示,还是格式正确的应用回答。这里构造的响应和被拒绝的Promise,只是在内存中让这些区别可观察,并未测定某个远端服务的行为。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 4 | 按序准确输出的四行各1分。 |
| b | 4 | 取得响应与状态不同、503下JSON仍可读、200下响应体解析拒绝、最后的响应前拒绝阶段,各1分。 |
Q10 — 区分当前任务与其续执行(6 分)
A
C
E
B
D
(a) 同步顺序为A、C、E。随后排队的Promise续执行依次产生B和D。
(b) 注册.then()回调会安排稍后的续执行,不会在当前任务中途立即运行回调。调用study()会马上进入该async函数,执行第一个await之前的代码,所以先记录C,再返回外层脚本并记录E。
即使被等待的Promise已经兑现,await之后的函数代码也要稍后恢复。因此,当前任务会先到达E,之后两个续执行才有机会输出。产生B的回调,在较早的.then()注册时已经入队;产生D的await续执行随后才入队。在题目所述脚本且没有其他排队工作的条件下,它们按这个次序运行。仅仅把函数声明为async,不会推迟整个函数体,也不会暂停整个外层脚本。
下表的队列只列出与这段脚本有关的Promise续执行。队列最左边的一项,是当前任务结束后接下来运行的一项。
| 当前正在执行的步骤 | 按先后排列的待续执行 | 已经输出 |
|---|---|---|
| 执行第一条日志 | 无 | A |
注册.then回调 | B回调 | A |
调用study()并执行其中第一条日志 | B回调 | A C |
到达await Promise.resolve(),暂停该函数的后续部分 | B回调,恢复study至D | A C |
| 返回外层脚本并执行最后一条日志 | B回调,恢复study至D | A C E |
| 当前任务结束,运行队列中的第一项 | 恢复study至D | A C E B |
| 从await之后恢复async函数 | 无 | A C E B D |
这样追踪,可以排除两种常见的错误预测。async这个词不会把C搬进队列:那条指令位于第一个await之前,在调用函数时就执行。等待的Promise已经兑现,也不会把D重新塞回当前同步片段;它仍要作为续执行稍后恢复。应把正在执行、正在等待和已经输出的内容分别记录,而不是把整份源码当成一张不间断地从上读到下的日志清单。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | A最先1分;同步部分C/E顺序正确1分;之后B/D依序出现1分。 |
| b | 3 | 第一个await之前立即执行1分;当前任务先于其续执行完成1分;较早注册使B先于D入队1分。 |
Q11 — 处理更新队列,而不是数setter调用次数(10 分)
1
2
3
(a) 新组件显示1。第一次点击完成提交后显示2,第二次完成提交后显示3。每次点击使用当前已提交渲染中的处理函数。
这里需要分别追踪三种东西。快照是某次渲染提供给组件函数的count;待处理值是React处理排队状态操作时正在计算的结果;已提交视图是按钮当前真正显示的文本。调用setter,是请求对保留的状态执行更新工作,并不是给当前函数调用中的const count赋一个新值。
(b) 第一次点击时,处理函数捕获的count在整个函数运行期间都是1。因此,两处count + 1都计算出替换值2。队列按顺序处理:
| 排队操作 | 此处理函数捕获的count | 随后的待处理值 |
|---|---|---|
替换为捕获的count + 1,即2 | 1 | 2 |
把value => value * 3应用于待处理值2 | 1 | 6 |
再替换为捕获的count + 1,仍为2 | 1 | 2 |
更新函数的value是前一个待处理值,不一定等于处理函数捕获的count。乘法确实执行了,但最后一次替换覆盖了它的结果。第二次点击时,新捕获的count为2,队列依次变成3、9,再回到3。三次调用代表三项有序的队列操作,而不是三次隐式递增。
逐项代入数值。第一次调用在排入替换操作之前,就已算出普通数值1 + 1 = 2。第二次调用交给React的是一个函数,所以React随后可以把它应用于待处理值2,得到2 × 3 = 6。最后一次调用仍从处理函数未改变的快照计算:1 + 1 = 2。因此,它用2替换6,并不是在6上再加1。React用最终状态2计算渲染结果,再提交对应的按钮文本;中间暂时得到的待处理值6,不一定会显示到屏幕上。
这次新渲染,是对组件函数的另一次调用,带来一个值为2的新局部count绑定,以及能读取该绑定的新处理函数。提交完成后,下一次点击使用这个新处理函数。因此,第二次点击从2开始,并不是永远复用初始处理函数中的1。
这个行为与Q04的普通闭包并不矛盾。Q04返回的函数用+=直接修改自己保留的let value绑定,因此下一次调用读到的是改变后的绑定。这里的处理函数读取特定一次渲染的const count,而setCount不会给该绑定重新赋值。React在多次调用之间保留组件的挂载身份和状态,再把下一状态交给另一次渲染调用。真正应判断的是:哪个绑定被保留或重新建立,以及哪项操作改变了它;不能把闭包理解成一律冻结它能够读取的所有值。
(c) 把两次加法改成更新函数:
setCount(value => value + 1);
setCount(value => value * 3);
setCount(value => value + 1);
从全新的count 1开始,待处理值依次变成2、6、7,所以提交后的显示为7。所要求的运算顺序保持不变,但每一步现在都会收到前一步的待处理结果。再点击一次会依次得到8、24、25。
当下一项操作应根据前一个待处理状态定义时,使用更新函数;当已经算好的某个特定值就是所需下一状态时,使用替换值。修复版最后一次加法的意思是“给前一个结果6加1”,得到7;原版的意思则是“用这次渲染算出的数值2替换状态”,得到2。选择哪种形式,就是在表达使两个结果不同的那项依赖关系。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 初始1、第一次点击2、第二次点击3三个记录各1分。 |
| b | 4 | 捕获的count保持1不变1分;待处理值2→6→2 1分;更新函数收到待处理值1分;说明最后替换覆盖乘法而非累计三次递增1分。 |
| c | 3 | 两次加法各正确改写为更新函数得1分;按正确运算顺序得到修复后的首个结果7得1分。 |
Q12 — 保留条目身份并配平生命周期工作(8 分)
initial:a=false,b=false
marked:a=true,b=false
reordered:b=false,a=true
active:a,b
after-unmount:0
(a) 两张新卡片起初都未置顶。点击a只更新那张卡片的状态。重新排序后,DOM顺序变成b,a,但置顶状态仍属于a。观察集合时,两个已登记ID会先排序;最终清理会移除两个登记项。
先把三种观察分开:某个位置上显示哪个条目、哪张挂载卡片保留了哪个置顶值,以及哪个条目ID有正在生效的Effect登记。位置改变,不一定改变挂载卡片的身份。
| 已完成的阶段 | DOM顺序 | 卡片a保留的置顶状态 | 卡片b保留的置顶状态 | 已登记ID |
|---|---|---|---|---|
| 首次挂载并完成Effect设置 | a,b | false | false | a,b |
点击a并提交 | a,b | true | false | a,b |
| 重排具有key的条目并提交 | b,a | true | false | a,b |
| 卸载并完成清理 | 没有卡片 | 其挂载状态已移除 | 其挂载状态已移除 | 空 |
输出观察行按DOM顺序列出卡片,所以重排后必须先写b=false。集合观察行则独立于DOM顺序,将ID排序,因此仍是active:a,b。
(b) 每个SessionCard拥有自己的pinned状态。列表用各卡片稳定的条目ID作为key。父组件重新排列条目时,React可以根据这些key匹配同一批SessionCard实例。a的实例即使移到第二位,也会保留true状态;b仍为false。若key根据数组索引生成,标识的就是位置。重新排列后,原先属于第一张卡片的状态,可能被关联到现在占据第一位的b。
这里的实例,是React在这棵树中保留的挂载组件身份,由组件类型及位置/key关系标识。这不是说函数的局部变量会因为仍是同一次函数调用而一直存在。每次渲染时,React都会再次调用组件函数;useState提供与匹配到的挂载卡片关联的保留状态,而该次调用会建立新的局部快照与处理函数。置顶值属于卡片a保留的状态,并不属于“它碰巧显示在第一位”这个位置事实。
索引key的变体说明了何时必须关心这种方法。最初,索引key 0对应显示a的挂载卡片,其置顶值变成true。若重排时仍按索引给key,key 0的挂载身份现在改为显示b,却可能保留那个true值;下一个位置显示a,并带着另一份false状态。这就得到b=true,a=false,与稳定ID的结果不同。如果状态应当在条目重排时继续跟随该条目,就应使用条目稳定的身份作为key。
(c) 每张挂载卡片的Effect会登记其条目ID。重新排序保留了由key标识的实例,而且各实例的item.id依赖没有改变,因此不需要为任何卡片额外登记或清理;集合仍包含a与b。根卸载时,每张卡片的清理函数都会删除自己的ID。两个清理函数都完成后,集合为空,大小为0。这个观察说明登记与清理相互配平。
时机也很重要。渲染描述列表元素与按钮;Effect设置在提交之后运行,使外部登记与挂载卡片保持同步。题目先等待这些Effect完成,再读取集合,所以第一次观察时两个ID都已经在集合里。点击按钮改变的是pinned,但Effect依赖item.id,不是pinned,因此它的依赖没有改变。重排后,每张由key匹配的卡片也仍有相同ID,所以没有依赖变化要求替换旧的设置。
清理函数属于那一次设置:卡片a的设置加入a,对应清理移除a;b同样一一配对。卸载结束两份同步关系,并调用这两个清理函数。本题没有任何ID改变,最终卸载后集合大小为0。不要只因为组件函数再次渲染就重置置顶值,也不要只因为条目换了位置就再建立一份登记。
| 小问 | 分值 | 得分要求 |
|---|---|---|
| a | 3 | 初始与点击后观察正确1分;排序后仍保持a=true 1分;活动集合与卸载后观察均正确1分。 |
| b | 3 | 状态属于子组件1分;稳定ID保留对应实例1分;解释索引key标识位置1分。 |
| c | 2 | 条目ID依赖不变,因此保留两个登记项1分;两次卸载清理后集合为空1分。 |
根据得分选择下一步复习
评分把结果与解释分开。理由错误却猜中输出,可以得到输出分,但并不能证明相应推理点已经掌握。反过来,小的抄写错误也不必抹去独立且正确的解释。利用逐题结果识别需要修正的模型,再用改变过的输入检验理解,而不是背诵这套答案顺序。
总分为100分。将总分与各题结果结合,选择下一步应复习的具体主题及推理步骤。