
2023年那場小滿春招第二批筆試題我到現在還留著草稿紙。當時大環境不算好Web前端崗位的競爭比往年激烈不少筆試刷人刷得特別狠。我最后能走到offer環節靠的不完全是運氣更多是考前把常見考點吃透了尤其是那些看似基礎、實則暗藏坑的題目。這篇就把我當時拿到的那套題結合我自己的答題思路和事后復盤完整拆一遍。題型分布、知識點覆蓋、解題過程、踩坑記錄都會講到。如果你是準備校招或者實習面試的這套題的含金量很高值得認真走一遍。1. 內容整體設計與思路拆解1.1 筆試考察的核心方向這套筆試整體分四個部分計算機基礎、JavaScript語言深度、瀏覽器與工程化、代碼實戰。滿分100分考試時間90分鐘。從試卷結構能明顯感覺到面試官想看的是扎實的語言功底和工程思維不是背了多少API。計算機基礎部分考察了網絡協議、數據結構題目不多但很典型。JavaScript部分分值最高占了將近40分涉及原型鏈、閉包、異步、this綁定這些高頻考點。瀏覽器與工程化部分考了渲染流程、緩存機制、模塊化、構建工具。最后的代碼實戰有兩道題一道是算法題一道是手寫源碼題。這種出題邏輯其實反映了當前Web前端崗位的真實需求基礎要牢語言要熟工程化思維要有動手能力不能虛。如果你只刷過各種框架的API文檔這套題會做得很吃力。1.2 出題人的考察意圖分析把整套題看完能明顯感覺到出題人不是隨便拼題的每一道都在考察特定的能力維度基礎題考察的是你大學四年有沒有認真上計算機基礎課計算機網絡、數據結構這些科班功底是后續發展的天花板。JavaScript題目考察的是真正理解了還是只停留在會用的層面。很多應屆生項目經驗豐富但是問到底層原理就含糊不清這類題目專門篩選這種情況。工程化題目緊跟行業實踐考察你是否真的在真實項目中折騰過而不是只寫了幾個demo頁面。實戰題考察的是在線編碼能力和代碼風格45分鐘內完成兩道題時間安排和思路清晰度都很關鍵。這樣一套組合拳下來基本能把“背題型選手”和“實戰型選手”區分清楚。所以如果你要準備這種筆試不要只看知識點清單更要理解每道題背后的考察意圖。2. 核心細節解析與實操要點2.1 計算機基礎題網絡與數據結構的必考點這套題第一部分考了三個題目難度不高但很典型。第一題是TCP三次握手的過程描述。這個屬于必考中的必考但很多同學答不完整。要答好這道題不能只寫“客戶端發SYN服務端回SYNACK客戶端再回ACK”得把每個狀態說清楚包括為什么是三次而不是兩次以及SYN Flood攻擊為什么利用了這個機制。我當時還補充了序列號初始化的意義這會讓面試官覺得你是真的懂網絡而不是背了流程。第二題是數組和鏈表的區別與適用場景。這種題看起來簡單但想拿滿分需要答出幾個維度內存分配方式、插入刪除時間復雜度、隨機訪問時間復雜度、緩存局部性。我在答題時用了表格對比的方式把時間復雜度一列列列清楚然后舉了實際場景的例子比如LRU緩存用鏈表、頻繁按下標訪問用數組。第三題是哈希表的沖突解決方法。拉鏈法和開放定址法都要寫出來還要說明各自的優缺點。我當時還提到了Java HashMap在鏈表長度超過閾值時轉紅黑樹的優化雖然這是偏向后端的內容但能體現知識面的廣度。這類基礎題的經驗是不要只背結論要把“為什么”準備到位。面試官后續追問往往是順著你的答案深挖你答得越深入后續壓力反而越小。2.2 JavaScript基礎原型、閉包、this綁定與異步第二部分的JavaScript題目是整套題的核心我一個個拆。原型鏈考了一道代碼輸出題讓我寫出構造函數的實例查找屬性的完整鏈路過。這道題的本質是考察__proto__和prototype的關系。我當時的答題方式是畫了一條完整的鏈實例對象 → 構造函數.prototype → Object.prototype → null然后把每個環節的屬性查找邏輯標注出來。閉包考了一道經典場景題寫一個計數器函數每次調用返回的值加一。這個看似簡單但考察的是閉包的形成條件和變量生命周期。我當時寫的答案是立即執行函數加閉包實現的模塊化計數器還額外說明如果不用閉包變量會被全局污染的問題。this綁定考了四種調用方式的輸出結果包括普通函數調用、對象方法調用、call/apply調用、new調用。這種題沒有技巧只能把規則徹底搞清楚。我記得有一道是考察箭頭函數不綁定this的特性箭頭函數的this是在定義時確定的。這題我很確定自己答對了因為有道題里的setTimeout回調用了箭頭函數this指向定義時的外層對象而不是window。異步考察了事件循環和Promise的輸出順序。當時給了一段代碼有同步代碼、setTimeout、Promise.then要求寫出輸出順序。這個考點需要理解宏任務和微任務的執行順序Promise構造函數內的代碼是同步執行的resolve之后注冊的then回調才是微任務。我的記憶口訣是同步先跑、微任務先于宏任務、每個宏任務結束后清空微任務隊列。給準備筆試的同學一個建議JavaScript部分的題目一定要自己動手寫代碼驗證。很多人就靠看題以為懂了實際上手全是錯。每一種this綁定場景、每一個異步輸出順序都用Node跑一遍比看十遍文章都管用。2.3 瀏覽器與工程化從加載到構建的全鏈路理解這部分考察了瀏覽器從輸入URL到頁面展示的完整過程、HTTP緩存機制、ESModule和CommonJS的區別、打包工具的核心原理。URL到頁面展示這道題我在答題時盡量寫完整DNS解析、TCP連接、TLS握手、HTTP請求、服務器返回HTML、瀏覽器解析HTML構建DOM樹、解析CSS構建CSSOM樹、合成RenderTree、布局、繪制、合成。每一步展開說明可能涉及的關鍵點比如渲染進程中主線程和合成線程的分工重排和重繪的區別。HTTP緩存考察了強緩存和協商緩存的字段。這題我比較有把握因為我在項目里實際配置過CDN和處理過緩存問題。Cache-Control的max-age、Etag的If-None-Match、Last-Modified的If-Modified-Since這幾個字段的位置和作用我寫得很細還畫了一個判斷流程圖。ESModule和CommonJS的區別那題我從幾個方面回答語法層面import/export和require/module.exports的區別加載時機一個是靜態編譯時一個是運行時輸出值一個是值引用一個是拷貝還有tree-shaking依賴ESModule的靜態分析能力。這道題能延伸到構建優化我當時還有意識地把tree-shaking和Vite/Rollup聯系起來了。工程化部分問的是Webpack打包的核心流程。我按四個階段來寫入口解析、依賴收集、代碼轉換、打包輸出。loader和plugin的區別是重點一句話說清楚loader負責文件內容的轉換plugin負責在打包生命周期里做擴展操作。這塊內容有沒有真實項目經驗答題深度會有明顯區別。如果只是背概念很難寫出工程中的實際場景。建議準備筆試的同學自己動手把Webpack從零配一遍經歷過一次配置地獄很多概念自然就通了。3. 實操過程與核心環節實現3.1 代碼實戰數組扁平化與去重的多種解法實戰題第一道是給定一個多維數組要求扁平化并去重且需要保持原有順序不允許使用Array.prototype.flat和Set??吹筋}目后我第一反應是考察兩個基礎能力的組合遞歸處理和哈希表思想。不限制flat說明想考手寫遞歸不限制Set說明想考手動去重邏輯。我的解題思路分兩步先寫一個遞歸的扁平化函數遍歷數組的每一項如果是數組就遞歸調用否則收集起來然后寫一個去重的邏輯利用一個對象作為哈希表記錄已出現的值遍歷時判斷是否出現過沒有出現過才放入結果數組。完整代碼我整理了一下基本是這樣function flattenAndUnique(arr) { // 第一步扁平化這里用遞歸實現 const flattened []; function flatten(input) { for (let i 0; i input.length; i) { if (Array.isArray(input[i])) { flatten(input[i]); } else { flattened.push(input[i]); } } } flatten(arr); // 第二步去重利用對象鍵的唯一性 const result []; const seen {}; for (let i 0; i flattened.length; i) { const item flattened[i]; // 注意直接用 item 做鍵數字 1 和字符串 1 會沖突 // 所以這里要用類型加上值來做復合鍵 const key typeof item _ item; if (!seen[key]) { seen[key] true; result.push(item); } } return result; }這里有個坑要提醒大家直接用item作為對象的鍵1和1會被當成同一個鍵導致不同類型的相同值被誤去重。我當時想到了這個邊界情況用了typeof加item的組合鍵這里在閱卷時應該有加分。如果你用ES6語法還可以用更簡潔的寫法function flattenAndUniqueByReduce(arr) { const flattened arr.reduce((acc, item) { return acc.concat(Array.isArray(item) ? flattenAndUniqueByReduce(item) : item); }, []); return flattened.filter((item, index) { return flattened.indexOf(item) index; }); }遞歸加indexOf去重的方式時間復雜度偏高indexOf每次查找是O(n)的整體會到O(n2)。如果數組很長性能會不夠好。但作為筆試答案兩種思路各有利弊我是把第一種哈希的思路作為正式答案寫的并且在注釋里分析了復雜度扁平化過程是O(n)去重過程是O(n)整體是O(n)空間復雜度也是O(n)屬于最優解。3.2 代碼實戰手寫防抖函數與節流函數的區別實戰題第二道是手寫防抖函數與節流函數并說明它們的使用場景區別。這道題在真實業務中太常用了我在項目里優化搜索框輸入時就寫過。防抖的核心思路是事件被連續觸發時只有在最后一次觸發后等待指定時間才執行函數。如果等待期間再次觸發則重新計時??梢韵胂蟪呻娞蓐P門只要有人進來就重新等一會兒再關門。function debounce(fn, delay 300) { let timer null; return function(...args) { // 每次觸發都清掉上一次的定時器 if (timer) { clearTimeout(timer); } const context this; timer setTimeout(() { fn.apply(context, args); timer null; }, delay); }; }這里有一個面試官特別愛追問的點為什么要在返回的函數里保存context this因為直接調用fn(args)時this會指向window或undefined嚴格模式丟失原調用者。用fn.apply(context, args)就能把this綁定到正確的對象上。節流的核心思路是在規定時間內無論事件觸發多少次只執行一次??梢韵胂蟪捎螒蚶锏募寄芾鋮s冷卻沒結束你怎么按都沒效果冷卻一好按一下就能放出來。function throttle(fn, interval 300) { let lastTime 0; return function(...args) { const context this; const now Date.now(); // 當前時間與上次執行時間的差大于間隔就執行 if (now - lastTime interval) { fn.apply(context, args); lastTime now; } }; }這兩種寫法的使用場景區別我當時是這樣寫的防抖適合“只關心最終狀態”的場景比如搜索框輸入后發送請求、窗口調整后計算布局節流適合“需要持續響應但不想太頻繁”的場景比如滾動事件監聽、鼠標移動事件、游戲中的射擊。另外我還補充了帶立即執行版本的防抖也就是第一次觸發時立即執行后續連續觸發時只重置計時器。這個版本在有些場景下更實用比如提交按鈕防重復點擊第一次點擊要立即生效后面的點擊要攔掉function debounceImmediate(fn, delay 300, immediate true) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } const context this; if (immediate !timer) { // 第一次觸發立即執行 fn.apply(context, args); } timer setTimeout(() { timer null; }, delay); }; }這道題我寫完的時候距離考試結束還有不到十分鐘所以沒有做更深入的擴展。但事后復盤時意識到手寫防抖節流不僅要能寫出來還要能分析邊界情況和應用場景這才是這道題拿高分的關鍵。3.3 編程題實現一個簡單的事件發布訂閱這套題最后一道編程題是手寫一個簡單的事件發布訂閱EventEmitter要求實現on、emit、off、once四個方法。這道題考的是對觀察者模式的理解。事件發布訂閱在日常開發中無處不在Vue的$emit和$on就是基于這種模式實現的Node.js的EventEmitter也是。理解了這個機制對后續讀框架源碼會有很大幫助。我的實現思路是內部維護一個事件對象鍵是事件名值是一個回調函數的數組。on方法往數組里push回調emit方法遍歷數組并執行所有回調off方法根據傳入的回調函數過濾數組once方法則是包裝一層執行完后自動解綁。代碼實現如下class EventEmitter { constructor() { // 存儲事件和對應的回調函數數組 this.events {}; } // 訂閱事件 on(eventName, callback) { if (!this.events[eventName]) { this.events[eventName] []; } this.events[eventName].push(callback); // 返回取消訂閱函數便于鏈式調用和自動清理 return () this.off(eventName, callback); } // 發布事件 emit(eventName, ...args) { const callbacks this.events[eventName]; if (!callbacks || callbacks.length 0) { return; } // 遍歷時用slice復制一份避免回調里執行off時影響當前遍歷 callbacks.slice().forEach(callback { callback.apply(this, args); }); } // 取消訂閱 off(eventName, callback) { if (!this.events[eventName]) { return; } this.events[eventName] this.events[eventName].filter( item item ! callback ); } // 只訂閱一次 once(eventName, callback) { const wrapper (...args) { // 執行前先解綁 this.off(eventName, wrapper); callback.apply(this, args); }; // 把原函數掛到wrapper上方便通過原函數解綁 wrapper.original callback; this.on(eventName, wrapper); } }這段代碼里有幾個細節值得注意第一個是emit里為什么要用callbacks.slice()復制一份。如果不復制當回調函數內部調用了off方法會導致正在遍歷的數組被修改可能跳過某些回調。這是事件系統實現中非常經典的一個坑。第二個是once的實現方式。不能用簡單的this.on(eventName, callback)然后執行完再off因為回調里如果還有其它訂閱邏輯順序會出問題。用包裝函數統一處理解綁邏輯是最穩妥的方式。第三個是我加了一個返回值on方法調用后返回一個自動解綁的函數。這個設計在Vue 3的onUnmounted清理場景中很實用組件卸載時自動取消訂閱避免內存泄漏。這道題我最終拿了滿分因為不僅實現了基本功能還處理了邊界情況并且每個方法的注釋里都寫了設計理由。筆試不能只追求能跑思路的完整性和代碼的健壯性更重要。3.4 手寫深拷貝與淺拷貝的完整對比筆試最后一部分第一道手寫題是深淺拷貝的實現與對比。這道題在JavaScript面試里出現頻率極高不僅考察遞歸和類型判斷還能看出你對內存模型的理解。題目要求寫出淺拷貝和深拷貝的代碼實現并說明兩者區別。如果拷貝的嵌套對象中修改了基本類型和引用類型的值會影響原對象嗎淺拷貝的實現相對簡單只復制一層屬性。如果屬性值是引用類型拷貝的是引用地址不新開辟內存空間。常見實現方式有Object.assign和展開運算符function shallowCopy(obj) { // 處理基本類型和null if (obj null || typeof obj ! object) { return obj; } const result Array.isArray(obj) ? [] : {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] obj[key]; } } return result; }深拷貝則要遞歸處理所有層級的嵌套對象。這里要特別注意幾個方面數組的判斷、日期和正則等特殊對象的處理、循環引用的處理。筆試中能考慮到循環引用這個點絕對能加分。大多數人的答案只能處理普通對象和數組遇到循環引用就直接爆棧。一個相對完善的深拷貝實現function deepClone(obj, map new WeakMap()) { // 處理基本類型和函數函數直接復用 if (obj null || typeof obj ! object) { return obj; } // 處理循環引用如果已經拷貝過這個對象直接返回之前的結果 if (map.has(obj)) { return map.get(obj); } // 處理日期對象 if (obj instanceof Date) { return new Date(obj.getTime()); } // 處理正則對象 if (obj instanceof RegExp) { return new RegExp(obj.source, obj.flags); } // 數組和普通對象分開處理 const result Array.isArray(obj) ? [] : {}; // 保存到map里用于處理循環引用 map.set(obj, result); for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] deepClone(obj[key], map); } } return result; }我在答題時的實際書寫順序是這樣的先寫了淺拷貝的兩種方式展開運算符和Object.assign說明了它們都是淺層復制然后寫深拷貝的完整實現從基本類型判斷開始逐層遞歸每到一層先處理特殊類型再處理普通屬性。一個容易忽略的點是for...in會遍歷到原型鏈上的可枚舉屬性所以要用Object.prototype.hasOwnProperty.call來判斷屬性是本對象的還是繼承來的。這個細節寫上了就顯得你對JavaScript的掌握不只是表面功夫。這里想多說一句題外話我看到有些同學直接用JSON.parse(JSON.stringify(obj))來寫深拷貝。這樣寫在筆試中肯定不行因為這種方式有明顯的局限性函數會被忽略、undefined會被忽略、日期會被轉成字符串、循環引用會直接報錯。雖然日常開發中偶爾能用但筆試中最好還是手寫遞歸版本并且主動說出JSON方法的局限性。4. 常見筆試問題與提問陷阱4.1 明明會做但丟分的低級失誤有哪些考完對照答案復盤的時候我發現了幾個共性的低級失誤這也是很多人筆試成績不理想的原因。第一大失誤是不仔細讀題。當時的題目明確說了“不允許使用Array.prototype.flat和Set”但我聽說不少同學還是用arr.flat(Infinity)加上new Set()一把梭結果直接判錯。筆試不是做項目不是代碼能跑就行了條件里寫清楚的要求必須要遵守。第二大失誤是代碼風格太差。筆試答題是有閱卷的面試官會看你的代碼寫得好不好。變量命名為a、b、c函數沒有注釋邏輯混亂這些即使功能正確分數也會打折扣。我當時的做法是每個函數都寫上簡短的注釋說明思路關鍵步驟加上備注。第三大失誤是不寫復雜度分析。很多筆試題目的加分項是復雜度分析特別是算法題時間復雜度和空間復雜度寫出來能證明你真正理解了代碼的性能特征。我在數組扁平化去重那題寫了O(n)時間O(n)空間的分析在深拷貝那題也寫了遞歸深度的復雜度分析。第四大失誤是時間分配不合理。一開始就在一道難題上耗太久導致后面的簡單題沒時間做。我當時的策略是先瀏覽一遍所有題目把有把握的題先做掉拿到基礎分再回頭啃難題。這個策略在時間緊湊的筆試中很實用。4.2 高頻追問面試官會如何深挖這些題筆試過后是面試環節筆試中寫的代碼會被面試官拿出來逐一追問。提前考慮哪些點會被深挖筆試時把注釋寫好面試就輕松不少。以數組扁平化去重那道題為例面試官大概率會問如果數組里出現循環引用怎么辦如果元素中有對象怎么判斷兩個對象是否“相同”如果數組非常大內存和性能怎么取舍我當時沒有寫出循環引用的處理方案面試時被問到確實有點緊張?,F在回頭看在筆試階段就應該把這些邊界情況寫在注釋里哪怕不實現也表明你考慮過這層。防抖節流那題面試官喜歡追問防抖和節流的適用場景怎么分如果你要在搜索框里做聯想用防抖還是節流如果用戶一直在輸入停不下來怎么辦還有怎么讓防抖函數同時支持取消和立即執行這些擴展點在筆試代碼里提前想好面試時就能從容應對。事件發布訂閱那題面試官追問的典型問題是如果同一個事件名有100個訂閱者某個訂閱者拋異常了會影響其它訂閱者執行嗎如果emit和off同時發生怎么處理once的語義是在所有訂閱者里只回調一次還是只對該訂閱者只回調一次這些問題都是日常開發中真正會遇到的邊界場景。深拷貝那題最容易追問的點是JSON.parse(JSON.stringify())有什么局限循環引用怎么處理Symbol和函數怎么處理拷貝Map、Set、WeakMap時怎么辦現在的JavaScript數據類型越來越多深拷貝的完備性本身就值得展開思考。我把這些高頻追問整理成了一個表格方便對照復習筆試題高頻追問準備建議原型鏈實例到Object.create(null)之間發生了什么手畫完整原型鏈結構圖閉包計數器怎么在循環中正確使用閉包掌握var和let的區別IIFE閉包方案this綁定箭頭函數和普通函數在綁定上的本質區別掌握定義期綁定與調用期綁定宏任務/微任務async/await的輸出順序用Node實際跑代碼驗證HTTP緩存強緩存失效后瀏覽器如何處理會畫完整的緩存判斷流程圖事件發布訂閱回調拋異常會影響后續執行嗎理解try-catch與事件循環的關系深拷貝JSON深拷貝的局限和循環引用會寫完整版的deepClone函數防抖節流用戶一直輸入停不下來怎么處理熟悉maxWait手寫實現4.3 真實復盤這套筆試題對準備春招的參考價值站在現在的角度回看這套題它反映出的Web前端春招考察趨勢很清晰基礎越來越重要工程化越來越受重視片面追求框架API背誦越來越行不通。如果你想認真準備接下來的春招筆試有幾個方向值得投入JavaScript的每個核心概念都要能解釋“為什么”不能只知道“是什么”。原型鏈到底解決了什么問題、閉包為什么會造成內存泄漏、事件循環為什么分為宏任務和微任務這些根源性的問題想清楚后你寫代碼的思維方式會不一樣。手寫代碼題一定要掐時間練。筆試的節奏通常是比較快的45分鐘兩道題還要留時間檢查平時不練速度就很難跟得上。我是在考前一周每天做一套手寫題專門訓練鍵盤速度和思路組織速度。工程化方面的準備不能停留在看文章。自己把Vite或者Webpack的配置從頭寫一遍遇到報錯自己排查一遍比看十篇腳手架教程都有用。筆試里出現的工程化題目基本都是實踐場景的抽象。面試官深挖的能力在筆試階段就要開始儲備。寫代碼的時候多問自己幾個為什么多考慮幾個邊界情況這些思路寫到答題卡上就是加分項。從我個人的經驗來說春招筆試最難的其實不是題目本身而是如何在有限時間內把自己會的東西充分展示出來。有時候你明明知道某個概念但就是在考場上緊張或者時間緊了就寫不出來。所以有針對性的模擬練習特別重要。我當時把牛客網的題庫刷了幾百道還專門找了往年的真題來限時模擬。最后再分享一個我自己用起來很順的復習方法把做錯的題和不會的知識點整理成一份錯題筆記。注意這不是簡單地記題目和答案而是要把自己當時的錯誤思路、正確的解題思路、以及相關知識點都寫清楚。考前一天不需要再刷題只需要反復翻錯題筆記把薄弱點過一遍。這套方法在春招期間幫我穩住了很多分數。