JavaScript/TypeScript 面试题实战:生产代码讲透闭包与 Promise.all

原文:https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e(作者 @allenarduino)

大多数对这类概念的讲解都止步于定义。讲作用域,就给一条关于变量提升(hoisting)的规则;讲闭包,就写一个给计数器不断加一的函数,没人会把那种代码发上线;讲事件循环,就画一张图;讲 anyunknown 的区别,就给一行规则;讲泛型,就举一个数字和字符串的玩具示例,真实代码库里根本见不到。

这些内容够你在面试里答上题,但不够让你在自己的代码里认出这些模式——而后者才是你通过面试、真正开始上线交付之后真正要紧的事。

这是一个短系列的第一篇,讨论面试中反复出现的那些概念,讲法就是我平时给团队同事讲的样子,例子全部来自生产代码,而不是教科书版本。

var、let 与 const

varletconst 是 JavaScript 里声明变量的三种方式。var 是函数作用域,可以随意重复声明、随意重新赋值。let 是块级作用域,可以重新赋值,但在同一作用域内不能重复声明。const 也是块级作用域,初始值确定之后不能再重新赋值,不过它指向的对象或数组仍然可以在内部被修改。

人人都会学到的规则是“默认用 const,值会变就用 let,避免 var"。光靠这条规则不容易看出来的地方在于:一旦牵涉到真实状态、而不只是一次单纯的重新赋值,它会是什么样子。

以线索或提交列表的分页为例——Formgrid.dev 的管理后台就真实在做这类事情:

const pageSize = 20;
let currentPage = 1;

function nextPage() {
  currentPage++;
}


pageSize 在这个列表的整个生命周期里都不会变,const 直接把这一点表达了出来。currentPage 每次有人点击翻页都会变化,let 才是如实反映情况的选择。在这里选对声明的价值不在于风格,而在于后来读这段代码的人不用逐行追踪,就能分辨哪些值可以放心当作固定值、哪些会动。var 完全给不出这种信号,而且它不像 letconst 那样遵守块级作用域——光凭这一点就足够在新代码里彻底弃用它。

闭包

闭包指的是这样一个函数:即使它创建时所处的外层作用域已经执行完毕,它仍然保有对那个作用域里变量的访问。

闭包最清晰的真实用途,是一个小型的 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。这就是闭包:getpost 一直能访问一个技术上已经执行完毕的作用域里的变量。

这件事超出定义之外的意义,在于它让你能做到的事:用同一个函数创建多个相互独立的客户端,各自记住自己的配置。

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

anyunknown 都是 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 客户端、可复用组件和各类工具函数里无处不在的原因:它就是那套让一段代码保持可复用、又不在此过程中退化成无类型代码的机制。

这些概念如何串起来

这些概念其实并不彼此孤立。constlet 讲的是对「什么允许改变」保持明确。闭包讲的是函数记住自己被创建时所在的作用域,哪怕那个作用域在技术上早已执行完毕。Promise.allPromise.allSettled 讲的都是并发地跑互不依赖的任务,区别只在有一项失败时该怎么办。而这一切底下的事件循环,决定着这些代码实际执行的顺序:先同步代码,然后是所有微任务,再到任务队列。unknown 的存在,是因为你有时确实还不知道某个值的类型,需要先证明再使用。泛型的存在,是因为你往往在调用处其实知道类型,不想为了让代码通用就把它当 any 处理、白白丢掉这份信息。

这也是本系列下一篇文章要延续的思路:数据结构、算法和测试,按它们在生产环境里真实出现的样子来讲,而不是当成孤立的面试知识点。

原文:https://dev.to/allenarduino/javascript-and-typescript-interview-questions-explained-with-real-production-examples-176e(作者 @allenarduino)

发布评论
全部评论(0)