原文:https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e(作者 @allenarduino)
大多数对这类概念的讲解都止步于定义。讲作用域,就给一条关于变量提升(hoisting)的规则;讲闭包,就写一个给计数器不断加一的函数,没人会把那种代码发上线;讲事件循环,就画一张图;讲 any 和 unknown 的区别,就给一行规则;讲泛型,就举一个数字和字符串的玩具示例,真实代码库里根本见不到。
这些内容够你在面试里答上题,但不够让你在自己的代码里认出这些模式——而后者才是你通过面试、真正开始上线交付之后真正要紧的事。
这是一个短系列的第一篇,讨论面试中反复出现的那些概念,讲法就是我平时给团队同事讲的样子,例子全部来自生产代码,而不是教科书版本。
var、let 与 const
var、let 和 const 是 JavaScript 里声明变量的三种方式。var 是函数作用域,可以随意重复声明、随意重新赋值。let 是块级作用域,可以重新赋值,但在同一作用域内不能重复声明。const 也是块级作用域,初始值确定之后不能再重新赋值,不过它指向的对象或数组仍然可以在内部被修改。
人人都会学到的规则是“默认用 const,值会变就用 let,避免 var"。光靠这条规则不容易看出来的地方在于:一旦牵涉到真实状态、而不只是一次单纯的重新赋值,它会是什么样子。
以线索或提交列表的分页为例——Formgrid.dev 的管理后台就真实在做这类事情:
const pageSize = 20;
let currentPage = 1;
function nextPage() {
currentPage++;
}
pageSize 在这个列表的整个生命周期里都不会变,const 直接把这一点表达了出来。currentPage 每次有人点击翻页都会变化,let 才是如实反映情况的选择。在这里选对声明的价值不在于风格,而在于后来读这段代码的人不用逐行追踪,就能分辨哪些值可以放心当作固定值、哪些会动。var 完全给不出这种信号,而且它不像 let 和 const 那样遵守块级作用域——光凭这一点就足够在新代码里彻底弃用它。
闭包
闭包指的是这样一个函数:即使它创建时所处的外层作用域已经执行完毕,它仍然保有对那个作用域里变量的访问。
闭包最清晰的真实用途,是一个小型的 API 客户端工厂:
function createApiClient(baseUrl: string) {
return {
get(endpoint: string) {
return fetch(`${baseUrl}${endpoint}`);
},
post(endpoint: string, data: unknown) {
return fetch(`${baseUrl}${endpoint}`, {
method: "POST",
body: JSON.stringify(data),
});
},
};
}
const api = createApiClient("https://api.example.com");
api.get("/users");
api.get("/products");
createApiClient() 在 return 的那一刻就执行结束了,但它返回的对象仍然记得 baseUrl。这就是闭包:get 和 post 一直能访问一个技术上已经执行完毕的作用域里的变量。
这件事超出定义之外的意义,在于它让你能做到的事:用同一个函数创建多个相互独立的客户端,各自记住自己的配置。
const productionApi = createApiClient("https://api.example.com");
const stagingApi = createApiClient("https://staging.example.com");
每一个都是围绕不同 baseUrl 形成的独立闭包,彼此之间没有任何共享状态。凡是函数需要在不用类的前提下记住配置的场景,我都会用这个结构:事件处理器、回调、工厂,或者任何需要私有状态又不直接暴露出去的东西。
Promise.all:请求互不依赖时
Promise.all() 接收一个 promise 数组,等其中每一个都 resolve 后才 resolve,并按同样的顺序返回所有结果。如果数组里任何一个 promise reject,Promise.all() 会立刻 reject,其余所有结果全部丢弃。
Formgrid 的后台在页面加载时要拉取几项相互独立的数据:线索管道统计、最近提交记录、集成状态和账户用量。它们谁也不依赖谁的结果。
最朴素的写法是依次等待每一个请求:
const stats = await fetchPipelineStats(); const submissions = await fetchRecentSubmissions(); const integrations = await fetchIntegrationStatus(); const usage = await fetchAccountUsage();
假设这几个请求分别耗时约 500ms、800ms、600ms 和 300ms,一个等完再等下一个,加起来大约 2.2 秒之后,后台才能渲染出任何东西。
const [stats, submissions, integrations, usage] = await Promise.all([ fetchPipelineStats(), fetchRecentSubmissions(), fetchIntegrationStatus(), fetchAccountUsage(), ]);
改成并发执行,总耗时就接近最慢的那一个请求,大约 800ms,而不是四者之和。Promise.all 恰恰适用于这种场景:请求确实相互独立,并且你需要拿到全部结果才能继续。
Promise.allSettled:当一处失败不该拖垮其余请求时
Promise.allSettled() 同样接收一个 Promise 数组,但它会等所有 Promise 全部跑完——无论成败——然后返回一个数组,逐个描述每个结果:状态为 fulfilled 的附带对应的值,状态为 rejected 的附带失败的原因。不会因为一个 Promise 失败,就把其他结果一起丢掉。
Promise.all 的坑在于:只要有一个 Promise 被拒绝,整批请求就宣告失败。如果必须拿到全部结果、页面才能渲染任何内容,这没问题;但如果某一部分失败不应该连累其他部分,用它就是错误的选择。
假设同一个仪表盘上的 AI 智能收件箱分类统计组件出了点岔子,请求失败了,而线索统计、提交记录和集成状态的请求都正常返回。用 Promise.all 的话,这单独一次失败会把另外三个完全正常的结果一起扔掉。
const results = await Promise.allSettled([
fetchPipelineStats(),
fetchRecentSubmissions(),
fetchIntegrationStatus(),
fetchAiInboxBreakdown(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Loaded:", result.value);
} else {
console.error("Failed:", result.reason);
}
});
这样一来,仪表盘就能正常渲染成功的那三个区块,只在 AI 统计真正失败的位置显示一个低调的错误状态。凡是“部分结果”确实比“要么全有、要么全无”的失败更有价值的场景,Promise.allSettled 都是正确的选择。
事件循环不只是一张图:它正是真实应用中会刻意使用 setTimeout 的原因
事件循环是这样一种机制:它让运行在单线程上的 JavaScript 能够在不阻塞的情况下处理异步工作。它会不断检查调用栈是否为空,一旦为空,就把下一个等待中的回调拉到栈上执行——先处理微任务,再处理任务队列,setTimeout 的回调就在任务队列里等待。
先从一个容易让人栽跟头的概念说起:发后即忘(fire and forget)。
sendEmail();
console.log("Done");
如果 sendEmail() 启动的是一个异步操作,而你没有 await 它,代码就不会等它完成:操作启动后立刻往下走,邮件在稍后的某个时刻于一旁自行完成。对比下面这段:
await sendEmail();
console.log("Done");
这段会等邮件完全发送完毕才继续。发后即忘的写法在生产环境中确实有用;那些不该阻塞响应的后台任务用的正是同一套模式,但它有一个真实的坑:如果那个没被 await 的 promise 被 reject 了,你会得到一个没有任何人盯着的 unhandled rejection。刻意为之的发后即忘,配上一个 .catch(),是一种有意识的选择;因为忘写 await 而意外造成的发后即忘,则是一颗等着在生产日志里爆雷的 bug。
正是这套异步思维模型,让 setTimeout 不只是一道面试冷知识题。它在真实应用里一直都很实用。
显示临时成功提示。 假设 Formgrid 上的一次表单提交成功了,你想把"表单创建成功"这条提示显示三秒钟:
function showSuccessMessage() {
const message = document.querySelector("#success");
message.style.display = "block";
setTimeout(() => {
message.style.display = "none";
}, 3000);
}
JavaScript 不会为此冻结三秒干等。它显示消息,继续做其他该做的事,等计时器到点、调用栈空出来之后,事件循环再把隐藏消息的回调捡回来执行。
给搜索框做防抖。 这个模式在真实产品里出现得极其频繁。如果用户一个字符一个字符地输入"formgrid",你肯定不想每敲一个键就发一次 API 请求:
let timeout;
function searchUsers(query) {
clearTimeout(timeout);
timeout = setTimeout(() => {
fetch(`/api/users?search=${query}`);
}, 500);
}
每敲一次键都会取消上一个计时器并启动一个新的。只有当用户真的停手 500 毫秒不再输入,请求才会发出去。
延迟后重试失败的请求。
async function fetchData() {
try {
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error("Request failed");
}
return await response.json();
} catch (error) {
console.log("Retrying...");
setTimeout(() => {
fetchData();
}, 2000);
}
}
这里的 setTimeout 是在有意识地安排未来的工作,给一个不稳定的依赖留几秒钟恢复的时间再重试,而不是立刻一顿猛捶。
拆分耗时操作,避免页面卡死。
function processChunk() {
// 处理一部分工作
if (moreWork) {
setTimeout(processChunk, 0);
}
}
processChunk();
这里的 0 不是"立即执行"的意思。它的含义是"让当前的工作先做完,给事件循环一个机会去处理其他已排队的事项,然后再运行下一块"。
即使延迟为零,微任务也会抢在 setTimeout 前面插队
下面这个例子,能真正区分出哪些人只是背熟了事件循环图,哪些人已经把它内化了。设想用户点击“生成报表”:
console.log("Starting report");
setTimeout(() => {
console.log("Report notification");
}, 0);
Promise.resolve().then(() => {
console.log("Update UI");
});
console.log("Request submitted");
输出是:
Starting report Request submitted Update UI Report notification
大多数人第一眼猜的结果都不是这样。同步代码行最先按顺序执行:先是 Starting report,然后是 Request submitted。接着,在 setTimeout 回调还没靠近调用栈之前,promise 的 .then() 回调先执行了——因为 promise 回调进入的是微任务队列,而事件循环在去看任务队列(setTimeout 回调所在的地方)之前,会把微任务队列彻底清空。只有在那之后,Report notification 才终于执行。
这正是 setTimeout(fn, 0) 永远不等于“立即执行”的原因。它的真正含义是“在当前同步代码执行完之后、在所有排队的微任务也都执行完之后再运行”。无论是 React 应用、Node 服务,还是任何混用 promise 与定时器的代码中出现意外的执行顺序,调试时几乎最后都会归结到这套顺序上。
如果面试中被问到,防抖是 setTimeout 最有力的真实案例,而这个报表生成的例子,则是解释微任务与宏任务为什么不是同一个队列的最有力案例。
TypeScript 中的 any 与 unknown
any 和 unknown 都是 TypeScript 里可以容纳任意形状值的类型。区别在于 TypeScript 之后会怎么处理这个值:any 会完全关掉对它的类型检查,而 unknown 要求你先收窄它、证明它到底是什么,然后才允许使用。
any 等于在告诉 TypeScript:别检查这个值,我知道自己在干什么。
let value: any = "hello"; value.foo.bar.baz();
TypeScript 对此一声不吭。但 JavaScript 在运行时照样会崩,因为字符串上根本没有 .foo.bar.baz()——只是一个值一旦被标成 any,TypeScript 就不再盯着它了。
unknown 的立场正好相反:我还不知道这是什么,所以用之前先证明它。
const response = await fetch("/api/user");
const data: unknown = await response.json();
请求成功并不代表服务器真的返回了你期望的数据。把它标成 any,等于让 TypeScript 完全信任它;标成 unknown,则会逼你在碰它之前先做检查:
if (
typeof data === "object" &&
data !== null &&
"name" in data
) {
console.log(data.name);
}
像这样收窄之后,TypeScript 会重新信任你——但只在这个分支内,而且只信你实际检查过的那部分。
这在那些你控制不了的边界上最重要:
Your TypeScript application
↓
External API
↓
JSON
↓
unknown
↓
Validate or narrow
↓
Trusted data
API 响应、用户输入、第三方库,以及任何从数据库或文件里读出来的东西,都不能仅因为编译通过就在类型层面被信任。unknown 强制你做检查;any 让你跳过检查,把问题留到运行时才发现——而且通常是在生产环境,通常是在比开发阶段更糟糕的时机。
值得记住的简短版本是:any 说「相信我」,于是 TypeScript 不再保护你;unknown 说「我还不知道」,于是 TypeScript 要你拿出证明。
TypeScript 中的泛型
泛型让你可以编写一个能处理多种类型的函数、类或接口,同时保留完整的类型信息——做法是引入一个占位类型(惯用 T 表示),在代码实际被使用的地方再用真实类型把它填上。
泛型背后的思想比它的语法看起来要简单:代码只写一遍,但让它保留传入的具体类型,而不是把这份信息丢掉。
不用泛型时:
function first(items: any[]) {
return items[0];
}
const result = first([1, 2, 3]);
这里 result 的类型是 any。函数能用,但你已经丢掉了「一开始拿到的是数字数组」这个事实。
用泛型:
function first<T>(items: T[]): T {
return items[0];
}
const number = first([1, 2, 3]); // T = number
const name = first(["Allen", "John"]); // T = string
<T> 是一个占位符:给我一个类型,我就在所有写了 T 的地方用这个确切的类型。TypeScript 会根据你实际传入的参数推断出 T 是什么,并把这个信息一路带到返回类型上。
这个模式最常见的实际形态是 API 响应包装器:
interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
const response: ApiResponse<User> = {
success: true,
data: { id: 1, name: "Allen" },
};
ApiResponse<User>、ApiResponse<Product>、ApiResponse<Order> 复用的是同一个接口,且全程带着完整类型,不用写三个几乎一样的接口。
同样的思路在前端也一样常见,比如可复用的表格或列表组件:
<DataTable<User> data={users} />
<DataTable<Product> data={products} />
同一个组件、同一套渲染逻辑,但每次使用都保持与其实际接收数据完全对应的类型。这就是泛型在 API 客户端、可复用组件和各类工具函数里无处不在的原因:它就是那套让一段代码保持可复用、又不在此过程中退化成无类型代码的机制。
这些概念如何串起来
这些概念其实并不彼此孤立。const 和 let 讲的是对「什么允许改变」保持明确。闭包讲的是函数记住自己被创建时所在的作用域,哪怕那个作用域在技术上早已执行完毕。Promise.all 和 Promise.allSettled 讲的都是并发地跑互不依赖的任务,区别只在有一项失败时该怎么办。而这一切底下的事件循环,决定着这些代码实际执行的顺序:先同步代码,然后是所有微任务,再到任务队列。unknown 的存在,是因为你有时确实还不知道某个值的类型,需要先证明再使用。泛型的存在,是因为你往往在调用处其实知道类型,不想为了让代码通用就把它当 any 处理、白白丢掉这份信息。
这也是本系列下一篇文章要延续的思路:数据结构、算法和测试,按它们在生产环境里真实出现的样子来讲,而不是当成孤立的面试知识点。
原文:https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e(作者 @allenarduino)



