前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
  • 场景篇

# 场景篇

这套题从项目里真会碰到的事讲起:进度条为什么不动、流式输出怎么不卡、配置合并怎么串了数据,再落到背后的原理。一共 550 道,分在 11 个模块、61 个分类里。每道题先给一张 30 秒能说完的速记卡,再展开讲原理和代码,最后附上面试官顺着问下去的几个追问。

不知道从哪开始的话,挑自己最没底的分类先看,看完一个分类再回头对照速记卡复述一遍。下面是全部模块和分类,点分类名直接跳过去:

  • 语言基础:JavaScript 核心、JavaScript 数据处理、TypeScript、HTML 与语义化、CSS、布局与移动端适配
  • 浏览器与网络:浏览器渲染原理、事件与页面生命周期、网络协议、浏览器缓存与存储、异步请求与数据获取、Web 安全
  • 框架:React 核心、React 工程实践、Vue 核心、Vue 工程实践、SSR 与同构、状态管理
  • 工程化:构建工具原理、包管理与 Monorepo、代码规范与 CI/CD、前端测试、部署与运维
  • 性能与体验:加载性能、运行时性能、无障碍、国际化
  • 业务场景:登录鉴权与权限、表单与表格、文件上传与下载、实时通信、组件设计与组件库、富文本与在线编辑器、低代码与可视化搭建、数据可视化、埋点与监控
  • 大前端与跨端:移动端 H5、小程序、React Native、Flutter、鸿蒙 HarmonyOS、跨端框架、桌面端、Hybrid 与 JSBridge
  • 图形与多媒体:Canvas 与 SVG、WebGL 与 Three.js、音视频与 WebRTC、WebAssembly
  • 服务端与架构:Node.js 核心、BFF 与服务端、Serverless 与边缘计算、微前端、前端架构与设计模式
  • AI 专题:流式输出与对话界面、AI 对话产品工程、Agent 与工具调用、RAG 与知识库、AI 应用安全与成本、端侧 AI、AI 辅助研发
  • 综合:项目经验与技术选型

# JavaScript 核心

语言机制、异步模型与内存管理的核心原理

# 数据类型与类型判断

30 秒速记

  • 8 种类型:7 种原始类型(undefined、null、boolean、number、string、symbol、bigint)加 object,函数和数组都属于对象
  • typeof 只适合判断原始类型和函数,typeof null === 'object' 是历史遗留 bug
  • instanceof 查的是原型链,跨 iframe、跨 Node 的 vm 上下文就失效,数组请用 Array.isArray
  • 想拿到精确类型用 Object.prototype.toString.call(v),但它会被 Symbol.toStringTag 改写,不要拿它做安全校验

先列 8 种类型,再按「typeof → instanceof → toString」对比各自的盲区,最后落到项目里怎么组合使用。

低代码编辑器把画布放在 iframe 里,外层的属性面板直接调用画布暴露的方法拿选中的组件列表。某次重构后属性面板永远显示「未选中组件」,打日志一看,数组明明有 3 个元素:

const frame = document.querySelector('iframe')
const list = frame.contentWindow.getSelectedNodes()   // iframe 里创建的数组
console.log(list.length)              // 3
console.log(list instanceof Array)    // false
console.log(Array.isArray(list))      // true
@前端进阶之旅: 代码已经复制到剪贴板

每个 iframe 都有一套自己的全局对象,Array、Object、Date 这些构造函数也各有一份。instanceof 的原理是沿着 list.__proto__ 一路往上找,看能不能碰到右边的 Array.prototype。iframe 里的数组原型链上挂的是它自己那份 Array.prototype,和外层的不是同一个对象,自然找不到。Node 里用 vm.runInNewContext('[1, 2, 3]') 能复现出一模一样的结果。

类型判断的几种手段各有盲区,面试时把这张表讲清楚基本就够了:

方式 能判断 盲区
typeof 原始类型、函数 null、数组、Date 都返回 'object'
instanceof 自定义类、内置对象 原始类型不行;跨 realm 失效;原型能被改
Array.isArray 数组 只管数组,但跨 realm 可靠
Object.prototype.toString.call 几乎所有内置类型 被 Symbol.toStringTag 覆盖;自定义类统一是 Object

typeof 有两个行为要记住:

console.log(typeof null)              // 'object'
console.log(typeof [])                // 'object'
console.log(typeof function () {})    // 'function'
console.log(typeof 10n)               // 'bigint'
console.log(typeof undeclaredVar)     // 'undefined',未声明的变量也不报错
@前端进阶之旅: 代码已经复制到剪贴板

null 返回 'object' 是第一版引擎里用类型标签判断留下的 bug,改了会破坏大量线上代码,就一直保留着。对未声明变量不报错这一点,以前常被用来做环境探测,比如 typeof window !== 'undefined' 判断是不是在浏览器里。

Object.prototype.toString 返回的是 [object Xxx],大部分工具库的 isDate、isRegExp 都基于它:

const tag = v => Object.prototype.toString.call(v).slice(8, -1)
console.log(tag(null), tag([]), tag(new Date()), tag(/a/), tag(new Map()))
// Null Array Date RegExp Map
console.log(tag({ [Symbol.toStringTag]: 'Array' }))   // 'Array'
@前端进阶之旅: 代码已经复制到剪贴板

最后一行说明它能被伪造。ES6 之后这个标签读的是对象的 Symbol.toStringTag 属性,任何人都能改,所以判断数组请老老实实用 Array.isArray。

NaN 也常被拎出来问。NaN === NaN 是 false,全局的 isNaN('abc') 会先把参数转成数字所以返回 true,判断时用 Number.isNaN;要同时区分 0 和 -0 就用 Object.is。

console.log(isNaN('abc'), Number.isNaN('abc'))     // true false
console.log(Object.is(NaN, NaN), Object.is(0, -0))  // true false
@前端进阶之旅: 代码已经复制到剪贴板

项目里的写法一般是组合起来:原始类型用 typeof,数组用 Array.isArray,内置对象用 toString 标签,判断「是不是自己写的某个类」才用 instanceof,并且确保对象不会跨 realm 传递。

相关阅读: 进阶篇 · 类型及检测方式

面试官追问

追问 1怎么判断一个值是纯对象(plain object)?
参考回答

先用 toString 标签确认是 Object,再看原型:原型是 null(Object.create(null) 创建的),或者原型的原型是 null(也就是原型为某个 realm 的 Object.prototype)。这样跨 iframe 的字面量对象也能通过,类实例会被排除。Redux 的 isPlainObject 用的就是这个思路。

追问 2手写 instanceof 怎么写?
参考回答

拿到右侧的 prototype,用 Object.getPrototypeOf 沿着左侧对象的原型链循环往上找,相等返回 true,找到 null 返回 false。左侧是原始类型直接返回 false。另外右侧如果定义了 Symbol.hasInstance,原生 instanceof 会优先调用它。

追问 3postMessage 传过来的数组,instanceof Array 也会失败吗?
参考回答

不会。postMessage 走结构化克隆,接收方拿到的是在自己 realm 里新建的数组,原型就是本地的 Array.prototype。只有直接跨窗口访问对象引用的时候才会出问题。

# 隐式类型转换

30 秒速记

  • 转换只有三个目标:转布尔、转数字、转字符串;假值只有 false、0、-0、0n、''、null、undefined、NaN 这几个,[]、{}、'0' 都是真值
  • 默认值用 ?? 而不是 ||:|| 会把 0 和空字符串一起替换掉;??(ES2020)只对 null、undefined 生效
  • == 的规则:null 只和 undefined 相等;有布尔先转数字;字符串和数字比转数字;对象和原始值比先把对象转原始值
  • 对象转原始值先看 Symbol.toPrimitive,没有就按 hint 调 valueOf / toString;+
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
  • 场景篇
    • JavaScript 核心
      • 数据类型与类型判断
      • 隐式类型转换
      • 作用域与变量提升
      • 执行上下文与调用栈
      • 闭包
      • this 指向
      • call、apply 与 bind
      • 箭头函数与普通函数
      • 原型与原型链
      • 继承的实现方式
      • new 操作符原理
      • 深浅拷贝
      • 事件循环
      • Promise 原理与链式调用
      • Promise 并发方法对比
      • async/await 原理
      • 异步错误处理
      • 迭代器与生成器
      • Proxy 与 Reflect
      • 模块化:ESM 与 CommonJS
      • 垃圾回收机制
      • WeakMap 与 WeakRef
      • Map、Set 与普通对象
      • Symbol 的用途
      • 函数柯里化与组合
      • ES6+ 常用新特性
    • JavaScript 数据处理
    • TypeScript
    • HTML 与语义化
    • CSS
    • 布局与移动端适配
    • 浏览器渲染原理
    • 事件与页面生命周期
    • 网络协议
    • 浏览器缓存与存储
    • 异步请求与数据获取
    • Web 安全
    • React 核心
    • React 工程实践
    • Vue 核心
    • Vue 工程实践
    • SSR 与同构
    • 状态管理
    • 构建工具原理
    • 包管理与 Monorepo
    • 代码规范与 CI/CD
    • 前端测试
    • 部署与运维
    • 加载性能
    • 运行时性能
    • 无障碍
    • 国际化
    • 登录鉴权与权限
    • 表单与表格
    • 文件上传与下载
    • 实时通信
    • 组件设计与组件库
    • 富文本与在线编辑器
    • 低代码与可视化搭建
    • 数据可视化
    • 埋点与监控
    • 移动端 H5
    • 小程序
    • React Native
    • Flutter
    • 鸿蒙 HarmonyOS
    • 跨端框架
    • 桌面端
    • Hybrid 与 JSBridge
    • Canvas 与 SVG
    • WebGL 与 Three.js
    • 音视频与 WebRTC
    • WebAssembly
    • Node.js 核心
    • BFF 与服务端
    • Serverless 与边缘计算
    • 微前端
    • 前端架构与设计模式
    • 流式输出与对话界面
    • AI 对话产品工程
    • Agent 与工具调用
    • RAG 与知识库
    • AI 应用安全与成本
    • 端侧 AI
    • AI 辅助研发
    • 项目经验与技术选型
完整面试题地址:
作者:程序员poetry
扫码关注作者公众号:「前端进阶之旅」 每天分享技术干货
前端进阶之旅公众号二维码

前端场景面试题:550 道大前端高频考点与项目实战回答

# 场景篇

这套题从项目里真会碰到的事讲起:进度条为什么不动、流式输出怎么不卡、配置合并怎么串了数据,再落到背后的原理。一共 550 道,分在 11 个模块、61 个分类里。每道题先给一张 30 秒能说完的速记卡,再展开讲原理和代码,最后附上面试官顺着问下去的几个追问。

不知道从哪开始的话,挑自己最没底的分类先看,看完一个分类再回头对照速记卡复述一遍。下面是全部模块和分类,点分类名直接跳过去:

  • 语言基础:JavaScript 核心、JavaScript 数据处理、TypeScript、HTML 与语义化、CSS、布局与移动端适配
  • 浏览器与网络:浏览器渲染原理、事件与页面生命周期、网络协议、浏览器缓存与存储、异步请求与数据获取、Web 安全
  • 框架:React 核心、React 工程实践、Vue 核心、Vue 工程实践、SSR 与同构、状态管理
  • 工程化:构建工具原理、包管理与 Monorepo、代码规范与 CI/CD、前端测试、部署与运维
  • 性能与体验:加载性能、运行时性能、无障碍、国际化
  • 业务场景:登录鉴权与权限、表单与表格、文件上传与下载、实时通信、组件设计与组件库、富文本与在线编辑器、低代码与可视化搭建、数据可视化、埋点与监控
  • 大前端与跨端:移动端 H5、小程序、React Native、Flutter、鸿蒙 HarmonyOS、跨端框架、桌面端、Hybrid 与 JSBridge
  • 图形与多媒体:Canvas 与 SVG、WebGL 与 Three.js、音视频与 WebRTC、WebAssembly
  • 服务端与架构:Node.js 核心、BFF 与服务端、Serverless 与边缘计算、微前端、前端架构与设计模式
  • AI 专题:流式输出与对话界面、AI 对话产品工程、Agent 与工具调用、RAG 与知识库、AI 应用安全与成本、端侧 AI、AI 辅助研发
  • 综合:项目经验与技术选型

# JavaScript 核心

语言机制、异步模型与内存管理的核心原理

# 数据类型与类型判断

30 秒速记

  • 8 种类型:7 种原始类型(undefined、null、boolean、number、string、symbol、bigint)加 object,函数和数组都属于对象
  • typeof 只适合判断原始类型和函数,typeof null === 'object' 是历史遗留 bug
  • instanceof 查的是原型链,跨 iframe、跨 Node 的 vm 上下文就失效,数组请用 Array.isArray
  • 想拿到精确类型用 Object.prototype.toString.call(v),但它会被 Symbol.toStringTag 改写,不要拿它做安全校验

先列 8 种类型,再按「typeof → instanceof → toString」对比各自的盲区,最后落到项目里怎么组合使用。

低代码编辑器把画布放在 iframe 里,外层的属性面板直接调用画布暴露的方法拿选中的组件列表。某次重构后属性面板永远显示「未选中组件」,打日志一看,数组明明有 3 个元素:

const frame = document.querySelector('iframe')
const list = frame.contentWindow.getSelectedNodes()   // iframe 里创建的数组
console.log(list.length)              // 3
console.log(list instanceof Array)    // false
console.log(Array.isArray(list))      // true
@前端进阶之旅: 代码已经复制到剪贴板

每个 iframe 都有一套自己的全局对象,Array、Object、Date 这些构造函数也各有一份。instanceof 的原理是沿着 list.__proto__ 一路往上找,看能不能碰到右边的 Array.prototype。iframe 里的数组原型链上挂的是它自己那份 Array.prototype,和外层的不是同一个对象,自然找不到。Node 里用 vm.runInNewContext('[1, 2, 3]') 能复现出一模一样的结果。

类型判断的几种手段各有盲区,面试时把这张表讲清楚基本就够了:

方式 能判断 盲区
typeof 原始类型、函数 null、数组、Date 都返回 'object'
instanceof 自定义类、内置对象 原始类型不行;跨 realm 失效;原型能被改
Array.isArray 数组 只管数组,但跨 realm 可靠
Object.prototype.toString.call 几乎所有内置类型 被 Symbol.toStringTag 覆盖;自定义类统一是 Object

typeof 有两个行为要记住:

console.log(typeof null)              // 'object'
console.log(typeof [])                // 'object'
console.log(typeof function () {})    // 'function'
console.log(typeof 10n)               // 'bigint'
console.log(typeof undeclaredVar)     // 'undefined',未声明的变量也不报错
@前端进阶之旅: 代码已经复制到剪贴板

null 返回 'object' 是第一版引擎里用类型标签判断留下的 bug,改了会破坏大量线上代码,就一直保留着。对未声明变量不报错这一点,以前常被用来做环境探测,比如 typeof window !== 'undefined' 判断是不是在浏览器里。

Object.prototype.toString 返回的是 [object Xxx],大部分工具库的 isDate、isRegExp 都基于它:

const tag = v => Object.prototype.toString.call(v).slice(8, -1)
console.log(tag(null), tag([]), tag(new Date()), tag(/a/), tag(new Map()))
// Null Array Date RegExp Map
console.log(tag({ [Symbol.toStringTag]: 'Array' }))   // 'Array'
@前端进阶之旅: 代码已经复制到剪贴板

最后一行说明它能被伪造。ES6 之后这个标签读的是对象的 Symbol.toStringTag 属性,任何人都能改,所以判断数组请老老实实用 Array.isArray。

NaN 也常被拎出来问。NaN === NaN 是 false,全局的 isNaN('abc') 会先把参数转成数字所以返回 true,判断时用 Number.isNaN;要同时区分 0 和 -0 就用 Object.is。

console.log(isNaN('abc'), Number.isNaN('abc'))     // true false
console.log(Object.is(NaN, NaN), Object.is(0, -0))  // true false
@前端进阶之旅: 代码已经复制到剪贴板

项目里的写法一般是组合起来:原始类型用 typeof,数组用 Array.isArray,内置对象用 toString 标签,判断「是不是自己写的某个类」才用 instanceof,并且确保对象不会跨 realm 传递。

相关阅读: 进阶篇 · 类型及检测方式

面试官追问

追问 1怎么判断一个值是纯对象(plain object)?
参考回答

先用 toString 标签确认是 Object,再看原型:原型是 null(Object.create(null) 创建的),或者原型的原型是 null(也就是原型为某个 realm 的 Object.prototype)。这样跨 iframe 的字面量对象也能通过,类实例会被排除。Redux 的 isPlainObject 用的就是这个思路。

追问 2手写 instanceof 怎么写?
参考回答

拿到右侧的 prototype,用 Object.getPrototypeOf 沿着左侧对象的原型链循环往上找,相等返回 true,找到 null 返回 false。左侧是原始类型直接返回 false。另外右侧如果定义了 Symbol.hasInstance,原生 instanceof 会优先调用它。

追问 3postMessage 传过来的数组,instanceof Array 也会失败吗?
参考回答

不会。postMessage 走结构化克隆,接收方拿到的是在自己 realm 里新建的数组,原型就是本地的 Array.prototype。只有直接跨窗口访问对象引用的时候才会出问题。

# 隐式类型转换

30 秒速记

  • 转换只有三个目标:转布尔、转数字、转字符串;假值只有 false、0、-0、0n、''、null、undefined、NaN 这几个,[]、{}、'0' 都是真值
  • 默认值用 ?? 而不是 ||:|| 会把 0 和空字符串一起替换掉;??(ES2020)只对 null、undefined 生效
  • == 的规则:null 只和 undefined 相等;有布尔先转数字;字符串和数字比转数字;对象和原始值比先把对象转原始值
  • 对象转原始值先看 Symbol.toPrimitive,没有就按 hint 调 valueOf / toString;+ 只要有一边是字符串就拼接

先从一个 || 默认值的线上 bug 讲起,再展开三种转换规则和 ==,最后讲对象转原始值。

设置页有个「自动刷新间隔」,产品约定填 0 表示关闭。上线后用户反馈:明明设了 0,页面还是每 30 秒刷新一次。

排查从接口开始。Network 面板里看保存接口的响应,interval 字段确实是数字 0,说明存储没问题。再到 Sources 面板里搜 setInterval,在创建定时器那一行打断点,Scope 里看到传进去的延迟是 30000。往上翻几行,找到了这句:

const saved = { interval: 0, title: '' }
console.log(saved.interval || 30000)   // 30000
console.log(saved.interval ?? 30000)   // 0
console.log(saved.title || '未命名')    // '未命名'
console.log(saved.title ?? '未命名')    // ''
@前端进阶之旅: 代码已经复制到剪贴板

a || b 的语义是「a 转成布尔后为假就取 b」。0 和空字符串都是假值,合法的配置被当成「没填」替换掉了。?? 只在左边是 null 或 undefined 时才取右边,0 和 '' 原样保留。改成 ?? 之后还要补一处:0 表示关闭,就应该不创建定时器,而不是把 0 传给 setInterval,那样会变成尽可能快地反复执行。另外 ?? 和 ||、&& 混写必须加括号,null || 0 ?? 1 直接是 SyntaxError。

这个 bug 是隐式类型转换里最常见的一类。转布尔的规则很简单,记住假值列表就行,空数组和空对象是真值,字符串 '0' 和 'false' 也是真值。

麻烦的是转数字和 ==。== 两边类型不同时会按固定规则往数字靠:

console.log('0' == false, '' == 0, null == 0, null == undefined)  // true true false true
console.log([] == false, [] == ![])                                // true true
console.log(null >= 0, null > 0, null == 0)                        // true false false
@前端进阶之旅: 代码已经复制到剪贴板

[] == ![] 拆开看:![] 是 false;布尔先转数字得 0;[] 是对象,转原始值得 '',再转数字得 0,所以相等。null 那一行更反直觉:关系运算符 >= 会把 null 转成 0,而 == 对 null 有专门规则,只和 undefined 相等。

+ 也有自己的逻辑,只要有一边转原始值后是字符串,就做拼接,其余算术运算符一律转数字:

console.log(1 + '2', '3' - 1, true + 1, [1, 2] + [3])   // '12' 2 2 '1,23'
console.log(+'', +' 12 ', +'12px', Number(null), Number(undefined))
// 0 12 NaN 0 NaN
@前端进阶之旅: 代码已经复制到剪贴板

+'12px' 是 NaN,而 parseInt('12px') 是 12,处理带单位的字符串时别弄混。

对象转原始值是这些规则的底层。引擎先查 Symbol.toPrimitive,并传入一个 hint:数学运算是 'number',模板字符串是 'string',+ 和 == 是 'default'。没有这个方法时,'string' 先调 toString,其余先调 valueOf,普通对象的 valueOf 返回自己,于是落到 toString 得到 '[object Object]'。

const price = {
  [Symbol.toPrimitive](hint) { return hint === 'number' ? 99 : '¥99' },
}
console.log(+price, `${price}`, price + '')   // 99 '¥99' '¥99'
@前端进阶之旅: 代码已经复制到剪贴板

项目里的约定很简单:比较一律用 ===,唯一常见的例外是 x == null,一次判断 null 和 undefined;接口返回的 id 可能是字符串也可能是数字,在入口处统一 String() 或 Number(),别靠 == 兜底,['1', '2'].includes(2) 是 false,它用的是严格相等。

面试官追问

追问 1eval('{} + []') 的结果是 0,为什么?
参考回答

作为语句解析时,开头的 {} 被当成空代码块,剩下的是一元 +[],[] 转原始值得 '',再转数字得 0。放进括号 ({} + []) 或赋值给变量,结果才是 '[object Object]'。Node 的 REPL 会自动给 { 开头的输入套括号,所以在那里直接敲看到的是后者。

追问 2Object.is 和 === 有什么区别?
参考回答

只有两处:Object.is(NaN, NaN) 为 true,Object.is(0, -0) 为 false,=== 正好相反。React 的 useState、useEffect 依赖对比用的就是 Object.is。

追问 3TypeScript 能把这类问题都拦住吗?
参考回答

能拦住一部分,比如 number 和 string 用 === 比较会报 TS2367,提示两个类型没有交集。但 || 默认值吞掉 0 这种类型完全合法,要靠 typescript-eslint 的 prefer-nullish-coalescing 规则提示。

# 作用域与变量提升

30 秒速记

  • JS 是词法作用域,变量能在哪访问由代码写在哪决定;ES6 前只有全局和函数作用域,let、const 带来了块级作用域
  • 代码执行前引擎先扫一遍声明:var 提升并初始化为 undefined,函数声明连函数体一起提升
  • let、const、class 也会提升,但在声明语句执行前处于暂时性死区,访问直接抛 ReferenceError,typeof 也救不了
  • 函数表达式只提升变量名,同名时函数声明优先于 var;工程上统一用 const/let,开 no-use-before-define

先讲作用域的种类,再讲提升的本质是「创建阶段登记声明」,然后用暂时性死区解释那个线上报错。

埋点 SDK 重构时,有人按「函数在上、常量在下」的风格整理了入口文件,发布后接入方的页面全部白屏,控制台只有一行报错:

init()
function init() { console.log(API_BASE) }
const API_BASE = '/api'
// ReferenceError: Cannot access 'API_BASE' before initialization
@前端进阶之旅: 代码已经复制到剪贴板

init() 能在声明之前调用,说明函数声明被提升了;API_BASE 却报错,说明 const 的处理方式不一样。要讲清这两件事,得知道引擎执行一段代码前做了什么。

进入一个作用域(全局、函数体、块)时,引擎会先扫一遍里面的声明,在环境里登记好名字,然后才逐行执行。登记时三类声明待遇不同:

  • var:登记并立刻初始化成 undefined,所以声明前访问拿到 undefined
  • 函数声明:登记并直接绑定函数体,声明前就能调用
  • let、const、class:只登记不初始化,执行到声明那一行才初始化,中间这段叫暂时性死区(TDZ)

所以 let 不提升的说法并不准确。下面这段如果 let 真不提升,块里的 count 应该读到外面的 1:

let count = 1
{
  console.log(count)   // ReferenceError: Cannot access 'count' before initialization
  let count = 2
}
@前端进阶之旅: 代码已经复制到剪贴板

块里的 count 已经登记了,把外层的同名变量遮住了,只是还没初始化。typeof 对未声明变量会返回 'undefined',对 TDZ 里的变量照样抛错。函数参数默认值也有自己的 TDZ,function f(a = b, b = 1) 调用 f() 会报 b 未初始化。

var 的提升是很多老 bug 的来源:

var x = 'global'
function show() {
  console.log(x)   // undefined,不是 'global'
  var x = 'local'
}
show()

function demo() {
  if (false) { var hidden = 1 }
  console.log(hidden)   // undefined,var 无视块,if 不执行也会提升
}
demo()
@前端进阶之旅: 代码已经复制到剪贴板

show 里的 var x 被提升到函数顶部,遮住了全局的 x,打印时还没赋值。var 只认函数作用域,写在 if、for 里也会被提到整个函数,这也是循环里 var i 被所有回调共享的原因(闭包那题细讲)。

函数表达式和函数声明也要分开看。var fn = function () {} 只有 fn 这个变量被提升,声明前调用报 fn is not a function;换成 const 则是 TDZ 错误。同一个名字既有函数声明又有 var,创建阶段函数声明胜出,var foo = 1 执行到那一行才会覆盖成数字。

回到开头的白屏:在 ESM 里这种问题还会以另一种形式出现。两个模块循环引用时,先执行的模块读到另一个模块里尚未执行到的 export const,同样是 TDZ 报错。修法是把入口调用放到所有声明之后,或者把常量抽到独立模块,消除执行顺序依赖。工程上统一 const/let,配合 ESLint 的 no-use-before-define,这类问题在提交前就能发现。

相关阅读: 进阶篇 · 变量提升、 进阶篇 · 作用域

面试官追问

追问 1既然 let 也提升,为什么要设计暂时性死区?
参考回答

为了让「先用后声明」变成显式错误。var 读到 undefined 会静默跑下去,错误要很久之后才暴露;const 如果也允许读到 undefined,「常量只有一个值」的承诺就破了。

追问 2全局作用域里 var 和 let 声明的变量有什么区别?
参考回答

浏览器脚本里,顶层 var a 会变成 window.a;顶层 let b 放在一个独立的声明式环境里,window.b 是 undefined,但其他 <script> 标签依然能访问 b。模块(type="module")里两者都不会挂到 window。

追问 3块里写函数声明会怎样?
参考回答

严格模式下函数只在块内可见,和 let 类似。非严格模式下浏览器为了兼容老代码有一套附加规则,块外也能访问到这个名字,但执行到块之前它是 undefined。各引擎早期实现不一致,项目里别这么写,改用 const fn = () => {}。

# 执行上下文与调用栈

30 秒速记

  • 每调用一次函数就创建一个执行上下文压进调用栈,函数返回就弹出;栈顶永远是正在执行的那个
  • 执行上下文里有三样东西:词法环境(let/const/class)、变量环境(var 和函数声明)、this 绑定;环境里的外部引用在函数定义时就确定,串起来就是作用域链
  • 栈的大小有上限,递归太深或无限递归会抛 RangeError: Maximum call stack size exceeded,层数取决于引擎配置和每帧大小
  • 排查靠 DevTools 的 Call Stack 面板和「异常时暂停」;await 之后原来的栈已经清空,看到的是 Async 拼接出来的栈

按「调用栈怎么压弹 → 上下文里有什么 → 爆栈怎么查」来讲,最后补异步栈。

评论区按层级缩进显示,深度由一个递归函数算出来。某天一篇文章的评论区直接白屏,Sentry 上报的是 RangeError: Maximum call stack size exceeded,其他文章都正常。

先在本地复现。打开 Sources 面板,勾上「Pause on uncaught exceptions」,刷新后断在了 depthOf 里。右侧 Call Stack 面板里是几千个一模一样的 depthOf,Scope 里看当前帧的 id 是 3,往下点几帧还是 3。问题就清楚了:数据里有一条评论的 parentId 指向了自己。

const nodes = [
  { id: 1, parentId: 0 },
  { id: 2, parentId: 1 },
  { id: 3, parentId: 3 },   // 脏数据:自己是自己的父节点
]
function depthOf(id) {
  const node = nodes.find(n => n.id === id)
  if (!node || node.parentId === 0) return 0
  return depthOf(node.parentId) + 1
}
console.log(depthOf(2))   // 1
depthOf(3)                // RangeError: Maximum call stack size exceeded
@前端进阶之旅: 代码已经复制到剪贴板

为什么会「爆栈」,要从执行上下文说起。引擎运行代码时,先为全局代码创建一个全局执行上下文压进调用栈;每调用一次函数,就新建一个函数执行上下文压在栈顶,函数 return 后弹出,控制权回到下面那一帧。depthOf(2) 的过程大致是这样:

depthOf(1)  ← 栈顶,parentId 为 0,返回 0 后弹出
depthOf(2)  ← 等 depthOf(1) 的结果,加 1 后返回
全局上下文
@前端进阶之旅: 代码已经复制到剪贴板

depthOf(3) 每次都调用自己,永远走不到 return,栈帧只压不弹。调用栈占用的是一块固定大小的内存,压满就抛 RangeError。能递归多少层没有统一的数字,Node 22 默认配置下,一个只调用自己的空函数大约能递归 9000 层,用 --stack-size 调大栈空间层数会跟着变;函数局部变量越多,每帧越大,层数越少。

每个执行上下文里装着这次调用需要的状态:

  • 词法环境:let、const、class,块级作用域会再嵌套一层
  • 变量环境:var 和函数声明
  • this 绑定:由调用方式决定(箭头函数没有,沿外层找)

环境里还有一个指向外部环境的引用,它在函数定义的时候就定好了,和在哪调用无关:

var name = 'global'
function outer() {
  var name = 'outer'
  function inner() { return name }
  return inner
}
const fn = outer()
console.log(fn())   // 'outer'
@前端进阶之旅: 代码已经复制到剪贴板

outer 的上下文早就出栈了,但 inner 的外部引用还指着 outer 的环境,这个环境不能被回收,这就是闭包能存在的原因。创建上下文时会先登记声明再执行,var 和函数声明的提升就发生在这一步,细节在作用域那题里讲。

修复分两步:数据侧在写入时校验 parentId 不能指向自身或自己的子孙;前端侧把递归改成循环,加上环检测,碰到环就停止上溯,脏数据只影响这一条评论的缩进:

const byId = new Map(nodes.map(n => [n.id, n]))
function depthOf(id) {
  const seen = new Set()
  let depth = 0
  let node = byId.get(id)
  while (node && node.parentId !== 0) {
    if (seen.has(node.id)) return depth   // 有环,停在这里,顺手上报一条日志
    seen.add(node.id)
    node = byId.get(node.parentId)
    depth++
  }
  return depth
}
console.log(depthOf(2))   // 1
console.log(depthOf(3))   // 1,不再爆栈
@前端进阶之旅: 代码已经复制到剪贴板

顺手把 nodes.find 换成 Map,每次查找从 O(n) 变成 O(1)。

还有一点要知道:V8 的 Error.stackTraceLimit 默认是 10,爆栈错误的 stack 只保留最上面 10 帧,全是重复的 depthOf,看不出入口在哪。排查时可以在本地把它调大,再结合 Sentry 的面包屑找触发路径。

相关阅读: 进阶篇 · 执行上下文

面试官追问

追问 1await 之后报错,调用栈里为什么还能看到调用方?
参考回答

await 之后的代码是在微任务里恢复执行的,原来那批栈帧早已弹出。DevTools 和 V8 会把异步调用链记下来,在 Call Stack 面板里用 Async 分隔线拼在下面,这是调试器额外记录的。线上拿到的 error.stack 只有 V8 的 async stack trace 补上的 await 链,.then 回调或 setTimeout 里抛的错,error.stack 就只剩回调自己那一段。

追问 2尾调用优化能解决递归爆栈吗?
参考回答

规范 ES2015 写了严格模式下的尾调用优化,但只有 Safari 的 JavaScriptCore 实现了,V8 曾经实现过又移除了。在 Chrome 和 Node 里写成尾递归照样爆栈,可靠的做法还是改成循环或手动维护一个栈数组。

追问 3ES3 里说的变量对象、活动对象和现在的词法环境是什么关系?
参考回答

是同一套东西在不同版本规范里的叫法。ES3 用变量对象(VO)、活动对象(AO)和作用域链描述;ES5 换成了词法环境和环境记录,执行上下文里已经有词法环境、变量环境两个组件,当时主要为 with、catch 服务;ES6 有了块级作用域后,let/const 放进词法环境,var 和函数声明留在变量环境。面试时用新术语讲,提一句旧叫法能对上就行。

# 闭包

30 秒速记

  • 函数会记住它定义时所在的作用域,函数在外面被调用时,依然能读写那个作用域里的变量,这就是闭包
  • 经典坑:var 没有块级作用域,循环里创建的回调共享同一个 i;改成 let 每轮迭代都有新绑定
  • 用处:私有变量、柯里化、防抖节流里保存定时器、模块模式
  • 内存代价:V8 里同一个作用域创建的多个闭包共享一个上下文对象,只要有一个闭包活着,所有被引用过的变量都释放不了

先讲定义,再讲 var 循环那个坑,最后用「共享上下文导致内存泄漏」拉开差距。

运营后台有个批量操作栏,给每个按钮绑定点击事件,结果点哪个按钮都是删第 3 条:

var actions = []
for (var i = 0; i < 3; i++) {
  actions.push(() => i)
}
console.log(actions.map(fn => fn()))  // [3, 3, 3]
@前端进阶之旅: 代码已经复制到剪贴板

三个箭头函数都通过闭包引用了变量 i,而 var 声明的 i 属于整个函数作用域,只有一份。点击发生时循环早就结束了,i 已经是 3。把 var 换成 let,引擎会给每一轮迭代创建一个新的 i 绑定,三个函数各自记住各自的值:

const actions = []
for (let i = 0; i < 3; i++) {
  actions.push(() => i)
}
console.log(actions.map(fn => fn()))  // [0, 1, 2]
@前端进阶之旅: 代码已经复制到剪贴板

闭包本身不神秘:JS 是词法作用域,函数在哪里定义,就能看到哪里的变量。只要函数还被别人引用着,它定义时的作用域就不能被回收。这一点在日常开发里很有用,防抖函数就是靠闭包把 timer 藏在函数内部:

function debounce(fn, wait) {
  let timer = null              // 只有返回的函数能访问
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), wait)
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

面试真正拉开差距的是内存问题。看下面这段代码,返回的函数明明没用到 big:

const cache = []
function load() {
  const big = new Array(1e6).fill(1.5)   // 约 8MB
  const unused = () => big.length        // 从来没被调用
  return () => 'ok'
}
for (let i = 0; i < 10; i++) cache.push(load())
// node --expose-gc 实测:gc 后堆内存多了 77MB
// 删掉 unused 这一行再跑:多了 0MB
@前端进阶之旅: 代码已经复制到剪贴板

原因是 V8 不会给每个闭包单独存一份变量。一个函数作用域里只要有任何闭包引用了 big,big 就会被放进这个作用域共享的上下文对象里,作用域里创建的所有闭包都指向同一个上下文。unused 虽然没被调用,但它的存在让 big 进了上下文,返回出去的那个函数又被 cache 持有,于是 8MB 就一直挂着。2013 年 Meteor 团队排查的那个著名泄漏就是这个机制。

排查这类问题,用 Chrome DevTools 的 Memory 面板拍两次堆快照,在对比视图里找持续增长的对象,看 Retainers 链路,会看到一个 (closure) 下面挂着 context,再往下就是那个大数组。修法是让大对象别和长期存活的闭包待在同一个作用域里,或者用完手动置成 null。

相关阅读: 进阶篇 · 闭包

面试官追问

追问 1闭包一定会造成内存泄漏吗?
参考回答

不会。闭包持有的作用域,在闭包本身没人引用时会和它一起回收。泄漏的前提是闭包被一个长期存活的东西拿着,比如全局数组、没解绑的事件监听、没清掉的定时器,同时它的上下文里又挂着大对象。

追问 2React 里的「闭包陷阱」和这里是一回事吗?
参考回答

机制一样。每次渲染都会创建新的函数,useEffect 里如果只在挂载时注册了一个 setInterval,回调闭包记住的就是第一次渲染时的 count,之后永远读到旧值。解决办法是把依赖写全、用函数式更新 setCount(c => c + 1),或者用 useRef 存最新值。

追问 3不用闭包怎么实现私有变量?
参考回答

ES2022 的类私有字段 #count,外部访问会直接抛 SyntaxError,比闭包写法更直观,也能被 DevTools 正常查看。

# this 指向

30 秒速记

  • this 由调用方式决定,和函数在哪定义无关;箭头函数例外,它没有自己的 this,取定义时外层的
  • 四条规则按优先级:new 绑定 > call/apply/bind 显式绑定 > obj.fn() 隐式绑定 > 默认绑定(严格模式 undefined,否则全局对象)
  • 隐式丢失:把 obj.fn 赋值给变量、传给 setTimeout 或事件监听,调用时就没有 obj. 了
  • bind 每次返回新函数,removeEventListener 传一个新 bind 的函数是解绑不掉的

按「调用方式决定 → 四条规则 → 隐式丢失 → 箭头函数」的顺序讲。

上传组件里有个定时上报,写法很自然:

class Uploader {
  constructor() { this.queue = ['a.png'] }
  flush() { return this.queue.length }
}
const uploader = new Uploader()
setTimeout(uploader.flush, 1000)
// TypeError: Cannot read properties of undefined (reading 'queue')
@前端进阶之旅: 代码已经复制到剪贴板

uploader.flush 这个表达式只是把函数取出来交给了 setTimeout,uploader 这个对象没有跟着传过去。一秒后定时器调用的是一个光秃秃的函数,没有 xxx. 前缀,走默认绑定;类的方法体默认是严格模式,this 就是 undefined。

这说明 this 跟函数定义在哪没关系,只看调用的那一刻是怎么调的:

function who() { return this?.name }
const a = { name: 'a', who }
const b = { name: 'b' }

console.log(a.who())          // 'a',隐式绑定
console.log(who.call(b))      // 'b',显式绑定
const fn = a.who
console.log(fn())             // undefined,隐式丢失,走默认绑定
console.log(new who() instanceof who)  // true,new 绑定到新对象
@前端进阶之旅: 代码已经复制到剪贴板

修法有两种:传 bind 过的函数,或者传一个箭头函数,在里面用 uploader.flush() 正常调用。箭头函数没有自己的 this,它的 this 在定义时就从外层作用域定下来了,call 和 bind 都改不了。所以类里常见这种写法:

class Uploader {
  queue = ['a.png']
  flush = () => this.queue.length   // 类字段 + 箭头函数,this 永远是实例
}
@前端进阶之旅: 代码已经复制到剪贴板

代价是每个实例都会有一份自己的 flush,没放在原型上,实例很多时会多占内存。

事件监听那里还有一个相关的坑:

el.addEventListener('click', this.onClick.bind(this))
el.removeEventListener('click', this.onClick.bind(this))  // 解绑失败
console.log(uploader.flush.bind(uploader) === uploader.flush.bind(uploader))  // false
@前端进阶之旅: 代码已经复制到剪贴板

每次 bind 都返回一个新函数,removeEventListener 是按引用比较的,两次 bind 出来的函数不相等,监听器永远解不掉,组件销毁后还在响应点击。正确做法是在构造函数里 bind 一次存起来,注册和解绑用同一个引用;或者直接用 AbortController,addEventListener 时传 { signal },销毁时 abort() 一次性解绑。

相关阅读: 进阶篇 · This

面试官追问

追问 1bind 之后再 bind,this 是哪个?
参考回答

第一次的。bind 返回的函数内部已经把 this 固定了,再 bind 只能追加参数,改不了 this。唯一能覆盖它的是 new:用 new 调用 bind 过的函数,this 指向新创建的对象。

追问 2对象字面量里用箭头函数定义方法,this 指向什么?
参考回答

指向对象字面量外面那一层,而不是这个对象。对象字面量的花括号不构成作用域,在模块顶层写 { fn: () => this },this 就是 undefined(ESM)或 module.exports(CommonJS)。

# call、apply 与 bind

30 秒速记

  • 三者都用来指定函数执行时的 this:call 逐个传参、apply 传数组,都立即执行;bind 不执行,返回一个绑定好 this 和部分参数的新函数
  • apply 和展开运算符都会把数组展开成真实的参数,参数太多会爆栈,Node 22 实测 Math.max 传 11 万个参数就抛 RangeError
  • 手写 call 的核心是「把函数临时挂到目标对象上,用 obj.fn() 隐式绑定来调用」,用 Symbol 做键避免覆盖
  • 手写 bind 的难点在 new:被 new 调用时绑定的 this 要失效,实例要能 instanceof 原函数

先讲三者区别,再用 apply 爆栈引出参数展开的代价,最后手写 call 和 bind。

监控大盘画折线图之前,要先算出纵轴最大值。代码一直是 Math.max.apply(null, points),按天查看没问题,切到「最近 7 天」后图表空白,控制台报 RangeError: Maximum call stack size exceeded。

7 天的秒级数据是 604800 个点。apply 会把数组里的每个元素都变成函数的一个真实参数,参数在调用时要放进栈帧,几十万个参数直接把栈撑爆了。换成展开运算符 Math.max(...points) 结果一样,它也是把数组展开成参数。在 Node 22 里实测,10 万个参数还能跑,11 万个就报错,浏览器的上限各不相同,数据量不可控的地方别这么写:

const points = Array.from({ length: 604800 }, (_, i) => i % 1000)
Math.max.apply(null, points)
// RangeError: Maximum call stack size exceeded
console.log(points.reduce((max, v) => (v > max ? v : max), -Infinity))  // 999
@前端进阶之旅: 代码已经复制到剪贴板

这个例子顺带说明了三个方法的定位。它们都是 Function.prototype 上的方法,第一个参数指定 this,区别只在传参和是否立即执行:

function greet(greeting, mark) { return `${greeting}, ${this.name}${mark}` }
const user = { name: 'Tom' }
console.log(greet.call(user, 'Hi', '!'))         // 'Hi, Tom!'
console.log(greet.apply(user, ['Hello', '?']))   // 'Hello, Tom?'
const hi = greet.bind(user, 'Hi')
console.log(hi('~'))                               // 'Hi, Tom~'
@前端进阶之旅: 代码已经复制到剪贴板

apply 在 ES6 之前最大的用处就是把数组当参数列表传,现在这件事交给展开运算符了。call 常用来借用别的对象的方法,比如 Array.prototype.slice.call(arguments);bind 用在需要「先存起来、以后再调」的地方:事件回调、定时器、偏函数。

面试更常见的是手写。call 的思路是利用隐式绑定:obj.fn() 调用时 this 就是 obj,那就把函数临时挂到目标对象上,调完再删掉。用 Symbol 做键,不会覆盖对象上原有的同名属性:

Function.prototype.myCall = function (ctx, ...args) {
  ctx = ctx == null ? globalThis : Object(ctx)   // 原始值要包装成对象才能挂属性
  const key = Symbol('fn')
  ctx[key] = this
  try { return ctx[key](...args) } finally { delete ctx[key] }
}
Function.prototype.myApply = function (ctx, args = []) {
  return this.myCall(ctx, ...args)
}
console.log(greet.myCall(user, 'Hi', '!'))   // 'Hi, Tom!'
console.log(Object.keys(user))                // [ 'name' ],临时属性已删除
@前端进阶之旅: 代码已经复制到剪贴板

这个版本和原生有一处差别:原生 call 在严格模式函数里不会把 null 换成全局对象,也不会把原始值包装成对象,手写版统一做了包装。

bind 要多处理两件事:参数分两次传入要拼起来;返回的函数被 new 调用时,绑定的 this 要作废,改成正常构造实例:

Function.prototype.myBind = function (ctx, ...preset) {
  const fn = this
  function bound(...args) {
    return new.target
      ? new fn(...preset, ...args)              // 被 new 调用,忽略 ctx
      : fn.apply(ctx, [...preset, ...args])
  }
  if (fn.prototype) bound.prototype = fn.prototype   // 让 instanceof bound 也成立
  return bound
}
function Point(x, y) { this.x = x; this.y = y }
const AtOrigin = Point.myBind({ ignored: true }, 0)
const p = new AtOrigin(5)
console.log(p, p instanceof Point, p instanceof AtOrigin)   // Point { x: 0, y: 5 } true true
@前端进阶之旅: 代码已经复制到剪贴板

new.target 是 ES6 加的,老版本手写通常用 this instanceof bound 判断,效果接近。

bind 返回新函数这一点在事件解绑时也会踩坑,this 指向那题细讲,这里不重复。

相关阅读: 进阶篇 · apply/call/bind 原理

面试官追问

追问 1原生 bind 返回的函数和手写版有什么不同?
参考回答

原生的叫 bound function,name 是 'bound Point',length 是原函数参数个数减去预置参数个数,而且它没有 prototype 属性;instanceof 遇到它会直接转去检查原函数。手写版是普通函数,这些细节都对不上,面试时说出来就够了。

追问 2一个函数连续 bind 两次,this 以哪次为准?
参考回答

以第一次为准。fn.bind(a).bind(b) 第二次绑的是那个 bound function,它内部调用原函数时用的始终是 a,后面传的 b 不起作用;预置参数倒是会依次累加。

追问 3箭头函数能用 call 改 this 吗?
参考回答

改不了。箭头函数没有自己的 this,call、apply、bind 传进去的第一个参数会被忽略,后面的参数照常传递,所以 bind 对箭头函数只剩偏函数的作用。

追问 4Function.prototype.call.bind(Array.prototype.slice) 是什么写法?
参考回答

把「方法借用」固化成一个普通函数:bind 把 call 的 this 定为 slice,之后 slice('abc', 1) 就等于 Array.prototype.slice.call('abc', 1),结果是 ['b', 'c']。一些库用这种写法提前缓存原生方法,防止后来被别的代码改写。

# 箭头函数与普通函数

30 秒速记

  • 箭头函数没有自己的 this、arguments、super、new.target,用到时沿外层作用域查找,和查普通变量一样,箭头函数创建时就定了
  • call、apply、bind 改不了箭头函数的 this,传进去的第一个参数被忽略
  • 箭头函数没有 prototype,new 它会抛 TypeError,也没有生成器写法
  • 对象方法、Vue 的 methods、需要动态 this 的事件回调用普通函数;方法内部的回调、React 类组件的事件处理用箭头函数

先用 methods 里写箭头函数的报错引出「没有自己的 this」,再对比两者的差异表,最后讲类字段箭头函数的取舍。

一个 Vue 2 的表单组件,代码评审时有人把 methods 里的方法统一改成了箭头函数,说是「风格一致」。上线后点保存按钮没反应,控制台报 TypeError: Cannot read properties of undefined (reading 'form'):

export default {
  data() {
    return { form: { title: '' } }
  },
  methods: {
    submit: () => {
      api.save(this.form)   // this 是 undefined
    },
  },
}
@前端进阶之旅: 代码已经复制到剪贴板

Vue 在初始化组件时会把 methods 里的每个函数用 bind 绑到组件实例上,所以普通函数里的 this 就是组件。箭头函数根本没有自己的 this,bind 传进去的实例被直接忽略。它里面的 this 按词法规则往外找:外层是模块顶层,单文件组件编译后是 ESM,模块顶层的 this 是 undefined。Vue 3 的选项式 API 也一样,两个版本的文档都明确写了 methods 里别用箭头函数。

脱离框架也能复现,存成 .mjs 用 Node 跑:

const form = {
  data: { title: '周报' },
  submit: () => this.data.title,
  submit2() { return this.data.title },
}
console.log(form.submit2())   // '周报'
form.submit()
// TypeError: Cannot read properties of undefined (reading 'data')
@前端进阶之旅: 代码已经复制到剪贴板

很多人以为箭头函数的 this「指向定义时所在的对象」,这个说法不对。对象字面量的花括号不构成作用域,submit 往外找到的是模块顶层。准确的说法是:箭头函数的 this 取外层函数(或模块、脚本)的 this,查找方式和查一个普通变量一样。

箭头函数是 ES2015 加入的,和普通函数的差异可以列成一张表:

特性 普通函数 箭头函数
this 调用时决定 继承外层,call/bind 改不了
arguments 有 没有,读到的是外层函数的
new 和 prototype 可以 new,有 prototype 抛 TypeError,没有 prototype
super、new.target super 只在方法里有,new.target 普通函数都有 继承外层
yield function* 可用 没有生成器写法
const Arrow = () => {}
console.log(Arrow.prototype)            // undefined
try { new Arrow() } catch (e) { console.log(e.message) }  // Arrow is not a constructor

function outer() {
  const inner = () => arguments[0]
  return inner('inner')
}
console.log(outer('outer'))             // 'outer'

const team = {
  name: 'FE',
  later() { return (() => this.name).call({ name: 'other' }) },
}
console.log(team.later())               // 'FE'
@前端进阶之旅: 代码已经复制到剪贴板

箭头函数要拿自己的参数列表,用剩余参数 (...args) => {}。

反过来,箭头函数最适合的位置正是方法内部的回调。setTimeout、map、Promise.then 的回调由别人调用,用普通函数 this 会丢,ES5 时代只能写 var self = this 或 .bind(this),箭头函数让回调直接继承方法的 this。

React 类组件常把事件处理写成类字段箭头函数(类字段是 ES2022 标准,Babel 早就支持),省掉构造函数里的 bind。它有几个代价:

class Button {
  label = '保存'
  onField = () => this.label
  onProto() { return this.label }
}
const a = new Button()
const b = new Button()
console.log(a.onField === b.onField)          // false,每个实例一份
console.log(a.onProto === b.onProto)          // true,共享原型上那一份
console.log('onField' in Button.prototype)    // false

const { onField, onProto } = a
console.log(onField())                        // '保存'
try { onProto() } catch (e) { console.log(e.message) }
// Cannot read properties of undefined (reading 'label')
@前端进阶之旅: 代码已经复制到剪贴板

类字段箭头函数挂在实例上,每 new 一次就新建一个函数,列表里渲染上千个实例时多出的内存要算进去;原型上没有它,子类里 super.onField() 调不到,测试里 jest.spyOn(Button.prototype, 'onField') 也会报错。好处是解构出来或传给子组件后 this 不会丢。普通方法共享原型、能被继承覆盖,但传出去之前要自己 bind。

还有一个写法上的坑:箭头函数返回对象字面量要加括号,ids.map(id => { id }) 里的花括号会被当成函数体,id 只是一条没有返回的表达式语句,结果是一组 undefined,写成 id => ({ id }) 才对。

相关阅读: 进阶篇 · This

面试官追问

追问 1箭头函数的 this 会不会变?
参考回答

会跟着外层变。箭头函数定义在普通函数里时,它的 this 就是那次调用时外层函数的 this。同一个箭头函数定义,外层用 obj.method() 调用和单独调用,拿到的 this 不同;只是箭头函数创建出来之后,自己再怎么被调用都改不了。

追问 2addEventListener 的回调用箭头函数有什么区别?
参考回答

普通函数回调里 this 是 e.currentTarget,也就是绑定监听的元素;箭头函数拿到的是外层的 this。用箭头函数时改用 e.currentTarget 取元素。另外要移除监听时,必须把箭头函数存进变量,传同一个引用给 removeEventListener,内联写的箭头函数永远移不掉。

追问 3为什么箭头函数不能当构造函数?
参考回答

规范里箭头函数没有 [[Construct]] 内部方法,new 一检查就抛 TypeError。它也没有 prototype 属性,就算能 new,实例也没有原型可连。这样箭头函数更轻,引擎创建它时不用顺带生成一个原型对象。

fe