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 節點,不是伺服器上的紀錄。
要聲稱伺服器已儲存新資料,還需要後續鏈條:瀏覽器傳送資料、服務接受資料,以及已完成的儲存操作具有所需的壽命。單憑畫面文字改變,不能確立任何一個後續步驟。若處理函式只更新變數與 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分;說明 caption 命名整個表格1分;把連結解析至該表格的 ID 1分。 |
| c | 2 | 準確字面文字1分;解釋跳脫字元是文字,因此不會建立強調元素1分。 |
Q03 — 推導樣式與響應式盒寬(10 分)
(a) 在兩個指定視口寬度,顏色均為藍色。Article 匹配三個顏色選擇器,其優先度為:
| 選擇器 | 優先度 |
|---|---|
.tile | (0,1,0) |
#board .tile | (1,1,0) |
.tile.notice | (0,2,0) |
三元組依次記錄 ID、類別類型選擇器、元素類型選擇器 的數目。對本題選擇器而言,#board 貢獻一個 ID,每個 .tile 或 .notice 各貢獻一個類別。後代組合符的空格表達祖先與 article 之間的關係,不會另加優先度計數。
從左至右比較三元組,遇到第一個不同的位置便停止。它們不是十進制數值,也不是把三個分量相加。先比較 (1,1,0) 與 (0,2,0):第一個分量已較大,因此含 ID 的選擇器勝出,無須再比較類別數。然後 (0,2,0) 在第二個分量勝過 (0,1,0)。在題目所述的相同來源、重要性與層級條件下,#board .tile 因而提供藍色。由於優先度已有不同,後面的 .tile.notice 規則不能單憑原始碼次序勝出。媒體查詢中亦沒有顏色宣告。
用這個方法解釋某個匹配規則為何落敗:先匹配元素、確認競爭規則處於相同的層疊比較階段、比較優先度,若仍相同才使用原始碼次序。只讀最後一項顏色宣告,會跳過本例中決定勝負的階段。
(b) 在600個 CSS 像素寬度,媒體查詢不匹配。Article 使用 content-box,所以內容寬度是240像素。左右各12像素內距與各3像素邊框,都在宣告的內容寬度之外:
border-box width = 240 + 12 + 12 + 3 + 3 = 270 CSS pixels
這裏的 px 指 CSS 像素,是樣式表與視口條件所用的單位,不一定是實體顯示像素的數目。視口的600像素寬度決定哪些規則生效;article 宣告的240像素寬度,則描述該視口內另一個盒。
計算前先畫出一個水平切面:
| 從左側外緣至右側外緣 | 左邊框 | 左內距 | 內容 | 右內距 | 右邊框 |
|---|---|---|---|---|---|
| 視口寬600個 CSS 像素時的寬度 | 3 | 12 | 240 | 12 | 3 |
內容佔240像素,兩側內距合共24,兩側邊框合共6。相加 240 + 24 + 6,便得到由左側外緣至右側外緣的邊框盒寬度270。題目沒有提供相關外距,所以計算沒有外距;也沒有另外加600,因為視口是選擇計算規則的條件,不是 article 寬度的另一部分。
(c) 在800個 CSS 像素寬度,媒體查詢匹配,盒大小的計算改為 border-box。宣告的240像素現在包含內距與邊框。邊框盒寬度為240像素,內容寬度為 240 - 24 - 6 = 210 像素。改變寬度的解讀方式,並不會移除內距或邊框。
這次由外往內計算。從總寬240先扣起左右各3像素邊框,剩下234;再從餘額扣起左右各12像素內距,剩下210作內容。相應切面是 3 + 12 + 210 + 12 + 3 = 240。模式改變的是 width 描述哪個盒,不會把 article 拉闊至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 的項目數,以及第一個 session 物件的數值分鐘值。陣列長度計算項目,不是時間。所以 [2,2,3,25] 包含三個計數,之後才是一個25分鐘的量。
(b) slice() 複製的是外層陣列內的元素參照,而不是巢狀物件本身。因此 sessions[0] 與 copy[0] 仍指向同一物件;透過任何一個參照修改其屬性,都會被另一個參照看見。push 則修改獨立的 copy 陣列成員,不會向 sessions 附加元素。後續流程從 sessions 開始,其分鐘值現為25與35,兩者均符合篩選條件,分別除以5得到 [5,7]。新增的10分鐘物件不在這個流程的輸入中。
用物件標籤把兩個層次分開。把原外層陣列稱為 A,複製的外層陣列稱為 B,初始兩個 session 物件稱為 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 minutes / 5 minutes = 5 個這種單位,以及 35 minutes / 5 minutes = 7 個這種單位。JavaScript 在這裏儲存普通數值,這個解讀才賦予它們單位。若把結果稱為「5分鐘與7分鐘」,便改變了該解讀。程式沒有取整,也沒有把 session 劃分為完整組別:若保留的時長是26分鐘,便得到 26 / 5 = 5.2。若要求只計算完整的五分鐘時段,還須另外訂明規則。
| 流程階段 | 資料形狀 | 該階段的值 |
|---|---|---|
sessions | 物件陣列,每個物件有數值 minutes 屬性 | 分別含25與35分鐘的物件。 |
filter 之後 | 由保留物件組成的另一個陣列 | 原有兩物件均通過;篩選尚未把它們轉成數值,也沒有複製物件。 |
map 之後 | 數值陣列 | 回呼傳回縮放後的時長5與7。 |
用文字說明,資料由物件陣列變成保留物件的陣列,最後成為數值陣列。映射後元素的形狀由回呼傳回值決定;篩選回呼則決定是否保留現有元素。應按所需結果選擇操作:先選出合資格的 session 紀錄,再把每個保留時長轉換至所需的數值尺度。
若要求只修改複製列表的第一項紀錄,可在一次全新執行中,把原有屬性賦值改成 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 }。已完成的產生器維持完成狀態;最後傳回的字串並不是另一個 yield 項目。
把屬性名稱 done 與字串值 "done" 分開。屬性是布林完成旗標;字串只是本產生器最後 return 所選的值。每次 next() 都延續同一次已暫停的執行。重新呼叫產生器函式會建立新的 cursor;推進已完成的 cursor,則不會令它重新開始。
| 小題 | 分數 | 得分條件 |
|---|---|---|
| a | 3 | 每行準確輸出各1分,包括產生器的完成旗標。 |
| b | 3 | 共用巢狀物件身分1分;獨立的外層陣列成員1分;追蹤25與35經 filter/map 成為5與7 1分。 |
| c | 2 | 分辨 yield 與 return/完成1分;指出額外呼叫的 value 為 undefined、done 為 true 1分。 |
Q06 — 追蹤節點與冒泡事件(10 分)
2,3,Changed
button:word:choose
bubble:word:panel
(a) 集合觀察在事件之前輸出,因為該行位於最後的 .click() 之前。其後的點擊先觸發按鈕監聽器,再到達祖先 panel 的監聽器。
(b) 原來的 querySelectorAll 結果記錄初始兩個匹配節點,所以附加新節點後,長度仍為2。它仍指向那些實際節點,因此修改第一個節點的文字,也能透過 snapshot[0] 看見。即時類別集合跟隨文件成員變化,包含剛附加的 li,所以長度為3。對點擊而言,起始 span 位於 button 內,而 button 位於 panel 內;一般冒泡先到達 button 的監聽器,再到 panel 的監聽器。
「靜態」描述這次結果包含哪些節點,不是指節點能否改變。重做本題時,先在查詢當刻列出已記錄的兩個節點參照,再把第三個節點附加至文件,但不把它加入那份已記錄列表。到輸出那行時,讀取第一個已記錄節點的目前文字。固定的成員數與已改變的屬性值因此可同時出現,並無矛盾。
(c) 兩行中的 word 都是起始元素的 ID,來自 event.target。監聽器的 event.currentTarget 在按鈕監聽器中為 choose,在 panel 監聽器中則為 panel。監聽器可觀察到由後代開始的事件,所以註冊監聽器的元素不必是事件起點。冒泡並不會把原有 target 依次改成各個祖先。
當一個祖先監聽器處理來自多個後代的點擊時,這個區分便有用途。Current target 告訴我們正在執行哪個監聽器,原有 target 則告訴我們點擊從哪裏開始。不要因為有兩行事件輸出,便推斷發生了第二次點擊;它們是同一次已派送點擊在冒泡途中的兩個觀察。
| 小題 | 分數 | 得分條件 |
|---|---|---|
| a | 3 | 每行準確輸出且位置正確,各1分。 |
| b | 4 | 固定成員、已記錄節點仍可修改、即時成員,以及按鈕先於 panel 的冒泡解釋,各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。其後透過別名修改,令原巢狀物件變成2,卻不會改變另一個已解析的巢狀物件;後者的 count 仍為1。
複製與修改的先後次序很重要。首先,程式從 count 為1的狀態建立資料表示;解析該表示時,建立新的外層物件及巢狀 meta 物件。之後,別名才把原物件的巢狀 count 改成2。這與 Q05 只對外層陣列作 slice 不同:本題這次 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。數值控制項的值在這些項目中仍以字串表示。要計算總頁數,應刻意把取出的 pages 字串轉成數值,或讀取該數值輸入項的 valueAsNumber,再檢查預期的數值規則。對初始值而言,Number("3") 得到數值3;轉換後加上數值2得到5,但直接對字串 "3" 與數值2使用 +,則得到 "32"。建立項目、展開陣列、JSON 序列化、數值轉換和驗證各有不同工作;要辨認自己正在使用哪個階段的值。
(b) 最小值為1、step 為1時,有效的步進數值為1、2、3等。把 pages.value 設成 "1.5" 會造成步進不符,令表單無效,輸出 [true,false]。
下一個呼叫記錄了非空的自訂有效性訊息。把值改為 "4" 雖然修正了數值步進不符,卻沒有清除自訂錯誤。因此 customError 為 true,表單仍無效,輸出 [true,false]。
最後,把自訂訊息設為空字串,清除自訂錯誤。目前值4符合 required、最小值及步進限制,其他控制項亦符合各自適用的限制,因此結果為 [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。Step 為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 以建立好的回應 fulfilled,其狀態是503,且 ok === false。主體仍含有效 JSON,訊息為 later。其後兩個獨立的 catch 分別記錄各自失敗的階段。
沿前兩行處理追蹤手上持有的值。new Response(...) 先建立一個有狀態、標頭和主體的回應物件;Promise.resolve(...) 把它包在一個已 fulfilled 的 promise 內。等待該 promise 後,response 得到 Response 物件,而不是把其主體變成 JavaScript 物件。呼叫 response.json() 會開始另一項讀取及解析主體的操作,並傳回另一個 promise。等待第二個 promise,才得到已解析物件,然後讀取其中的 message 屬性。
型別與形狀的路徑因此是 Promise<Response> → Response → Promise<parsed data> → { message: "later" } → 字串 "later"。每個箭嘴都是程式中的一項指定操作,不是上一個箭嘴自動完成的轉換。這樣分層很有用,因為回應狀態與從其主體解讀出的資料,可能描述不同類型的失敗。
(b) 取得回應的 promise fulfilled,與 HTTP 狀態成功,是不同觀察。本例的 Promise.resolve 以一個 Response fulfilled;狀態503不會把該 promise 轉成 rejection。由於503不在200至299的成功範圍,ok 為 false。
回應的錯誤狀態不妨礙讀取及解析其中有效的主體。JSON 物件含 message: "later",因此第二行是 later。
下一個回應的狀態為200,但主體文字 {broken 不是有效 JSON。Rejected 的操作是 .json() 傳回的 promise,由外圍的 try/catch 處理。最後一段的 Promise.reject(...) 代表在傳回可用回應之前已失敗的操作,等待它時會拋出至最後的 catch。
用四個問題決定下一步:
| 層次 | 持有或檢查甚麼? | 本例觀察 | 適當的下一步判斷 |
|---|---|---|---|
| 回應是否可用 | 預期會產生回應的 promise | 最後一個明確 rejected 的 promise 沒有產生回應。 | 處理該拒絕;這次操作沒有可供檢查的回應狀態或主體。 |
| HTTP 狀態 | 已取得回應的 status 和 ok | 第一個回應存在,但503令 ok 為 false。 | 進入狀態不成功的分支;有需要時讀取服務規定的錯誤表示。 |
| 主體表示 | 讀取主體的 promise 及其解析結果 | 503主體是有效 JSON;另一個200主體則在解析 JSON 時拒絕。 | 解析成功後才使用資料;成功狀態不能修補格式錯誤的 JSON。 |
| 應用層意義 | 已解析物件的欄位與操作約定 | 第一個物件提供 later 訊息,卻沒有提供儲存已完成的結果。 | 把欄位與不成功的狀態一併解讀,再決定向使用者顯示甚麼。 |
例如,介面不能只因第一個主體可解析,便宣稱儲存完成;它取得的是錯誤回應和說明訊息。反過來,200回應解析 JSON 失敗時,若用空物件製造一般結果,就會掩蓋表示形式失敗的階段。找準失敗層次,才知道應檢查拒絕、HTTP 結果、格式錯誤的表示,還是格式正確但須按約定解讀的應用層答案。本題以記憶體內建立的回應和 rejected 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 已 fulfilled,函式在 await 後的程式亦稍後才續行。因此,當前任務到達 E 後,兩個續行才會輸出。產生 B 的回呼由較早的 .then() 註冊排入佇列,早於產生 D 的 await 續行。在題目指定、沒有其他排程工作的情況下,它們按這個次序執行。單單把函式宣告為 async,並不會延後其整個主體,亦不會暫停整份外層指令碼。
下表的佇列只列出與本指令碼有關的 promise 續行。當前任務完成後,先執行最左邊的一項。
| 目前執行的步驟 | 依序等候的續行 | 已輸出內容 |
|---|---|---|
| 執行第一個 log | 沒有 | A |
註冊 .then 回呼 | B 回呼 | A |
呼叫 study() 並執行其第一個 log | B 回呼 | A C |
到達 await Promise.resolve(),暫停該函式的續行 | B 回呼、從 D 恢復 study | A C |
| 返回外層指令碼並執行最後的 log | B 回呼、從 D 恢復 study | A C E |
| 完成當前任務,執行第一個排隊續行 | 從 D 恢復 study | A C E B |
| 在 await 之後恢復 async 函式 | 沒有 | A C E B D |
這個方法可避免兩種常見誤判。async 一字沒有把 C 移入佇列:該指令位於第一次 await 之前,在呼叫期間就會執行。等待的 promise 即使已 fulfilled,也不會把 D 放回目前的同步片段;續行仍要稍後恢復。把正在執行、正在等候及已經輸出的內容分開記錄,不要把原始碼當成一串不中斷、由上而下執行的 log。
| 小題 | 分數 | 得分條件 |
|---|---|---|
| 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 |
Updater 的 value 是前一個待處理值,不一定等於處理函式捕捉的 count。乘法確實執行了,但最後的取代操作覆寫了該結果。第二次點擊時,新捕捉的 count 是2:佇列先變成3、再變成9、最後回到3。三次呼叫是三項有次序的佇列操作,不是三次隱含的加一。
逐步代入數值。第一次呼叫在排入取代操作前,已經算出普通數值 1 + 1 = 2。第二次呼叫提供一個函式,所以 React 稍後可把它套用於待處理值2,得到 2 × 3 = 6。最後一次呼叫仍從處理函式未改變的快照計算:1 + 1 = 2。因此它用2取代6,而不是在6之上加一。React 用最終狀態2計算畫面,再把按鈕文字提交到 DOM。中途的待處理值6,不一定會在這些操作之間出現在畫面上。
新渲染是另一次元件函式呼叫,當中有新的區域 count 綁定,值為2,也有可讀取該綁定的處理函式。這次提交後,下一次點擊使用新處理函式。這就解釋了第二次點擊為何從2開始,而不是永遠重用最初處理函式的1。
這與 Q04 的普通閉包行為一致。該題傳回的函式用 += 直接修改保留的 let value 綁定,所以下一次呼叫會讀到已修改的綁定。本題處理函式讀取特定渲染的 const count,而 setCount 並不重新賦值給該綁定。React 在多次呼叫之間保留元件的掛載身分和狀態,再把下一個狀態提供給另一次渲染呼叫。實用規則是辨認哪個綁定被保留、哪個是新建的,以及哪項操作改變它;閉包並不會一律凍結它能讀取的所有值。
(c) 把兩個加法改用 updater 函式:
setCount(value => value + 1);
setCount(value => value * 3);
setCount(value => value + 1);
從全新 count 值1開始,待處理值依次為2、6、7,所以提交後顯示7。原有運算次序得到保留,而且每項操作現在都接收前一個待處理結果。再點擊一次則依次得到8、24、25。
當下一項操作按前一個待處理狀態定義時,使用 updater;當某個已計算好的指定值才是預期下一狀態時,使用取代操作。修正版本中的最後加法表示「在前一個結果6加一」,得到7;原版則表示「用這次渲染算出的數值2取代狀態」,得到2。選擇哪種形式,就是把令答案不同的相依關係表達出來。
| 小題 | 分數 | 得分條件 |
|---|---|---|
| a | 3 | 初始1、首次點擊2、第二次點擊3,每個記錄各1分。 |
| b | 4 | 捕捉的 count 一直為1 1分;待處理值2→6→2 1分;updater 接收待處理值1分;解釋最後取代覆寫乘法,而非三次加一1分。 |
| c | 3 | 兩個加法各自改寫為 updater,各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 先排序;最後的清理會移除兩項登記。
先分開三項觀察:某位置顯示哪個項目、哪個已掛載卡片保留某個 pinned 值,以及哪個項目 ID 有生效中的 Effect 登記。位置改變,不一定代表已掛載卡片的身分也改變。
| 已完成階段 | DOM 次序 | 卡片 a 保留的 pinned 狀態 | 卡片 b 保留的 pinned 狀態 | 已登記 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 開始。集合那行則把 ID 排序,與 DOM 次序無關,因此仍是 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。Root 卸載時,各卡片的清理刪除自己的 ID。兩項清理完成後,集合為空,大小為0。這顯示登記與清理操作互相平衡。
時間點很重要。渲染描述列表元素與按鈕;Effect 設定在提交後執行,讓外部登記與已掛載卡片同步。題目在讀取集合前等待這些 Effect 完成,所以初始觀察已有兩個 ID。點擊按鈕會改變 pinned,但 Effect 相依的是 item.id 而不是 pinned,因此相依值沒有改變。重排也讓每張有 key 的卡片保留同一 ID,沒有相依值變更需要替換舊設定。
清理函式屬於那次設定。卡片 a 的設定加入 a,其清理就移除 a;b 也有相同配對。卸載會結束兩項同步並呼叫相應清理。本題沒有改變任何 ID,最後卸載令集合大小成為0。不要只因函式再次渲染就重設釘選,也不要只因項目移到另一位置就建立另一項登記。
| 小題 | 分數 | 得分條件 |
|---|---|---|
| a | 3 | 初始與釘選後觀察均正確1分;重新排序後保留 a=true 1分;active 與卸載後觀察均正確1分。 |
| b | 3 | 狀態由子元件持有1分;穩定 ID 保留預期實例1分;解釋索引 key 識別位置1分。 |
| c | 2 | 未變的項目 ID 相依值保留兩項登記1分;卸載時兩項清理令集合變空1分。 |
利用分數決定下一步複習
分數把結果與解釋分開。若以錯誤理由猜中輸出,可取得輸出分,但不能據此取得相應推理分。反過來,小的抄寫錯誤也不必抹去已正確證明的獨立解釋。利用各題結果找出需要修正的模型,再測試改變後的輸入,而非背誦這次的答案次序。
總分為100分。把總分與各題結果一起使用,找出下一步應重溫的具體主題與推理步驟。