普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月7日首页

一个前端开发者的救赎之路-JS基础回顾(五)-数组

作者 雲墨款哥
2025年9月7日 18:03

一: 创建数组

1. 数组字面量

let a = []
var b = [1, 'a', true]

注意: 还有一个稀疏数组,反正我没用过,工作中也很少见人用,大多数规范都不让用稀疏数组

2. 对可迭代对象使用...扩展操作符(ES6)

2.1 可迭代对象

  • 可迭代对象是指可以用for/of循环遍历的对象,如数组、字符串,集合和映射等

2.2 ...扩展操作符

  • ES2018以后,...扩展操作符在对象字面量也可以使用了
  • 出现在等号右边或参数位置的 ... 通常是展开(拆开)。
  • 出现在等号左边或参数声明的 ... 通常是剩余(收集)。

2.3 扩展操作符是创建数组(浅)副本的一种便携方式:(浅拷贝)

let originalArr = [1,2,3];
let copyArr = [...origonalArr];
copyArr[0] = 0;    // 修改copyArr不会影响originalArr
originalArr[0]      // => 1


const original = { hobbies: ['reading', 'swimming'] };
const copy = { ...original }; // 浅拷贝

// 修改嵌套数组中的元素(修改第二层)
copy.hobbies[0] = 'gaming';

console.log(original.hobbies); // 输出: ['gaming', 'swimming'] (被影响了!)
console.log(copy.hobbies);     // 输出: ['gaming', 'swimming']

3. Array()构造函数

3.1 不传参调用

  • let a = new Array(); 这样会创建一个没有元素的空数组,等价于字面量[]

3.2 传入一个数组参数,指定长度:

  • let a = new Array(10);
  • 这样会创建一个指定长度的数组。
  • 如果提前知道需要多少个数组元素,可以这样做来预先为数组分配空间
  • 注意:这时的数组中不会存储任何值,数组索引属性"0", "1"等甚至都没有定义

3.3 传入两个或更多个数组元素,或传入一个非数值元素

  • 这样调用的话,构造函数的参数会成为新数组的元素。使用数组字面量永远比这种方法简单。
// [5, 4, 3, 2, 1, 'testing, testing']
let a = new Array(5, 4, 3, 2, 1, "testing, testing")
// ['sddsdsdsd']
let b = new Array('sddsdsdsd')

3.4 工厂方法Array.of()和Array.from()

  1. Array.of()
    • 解决了Array()在使用数值参数时,如果只有一个参数,这个参数指定的是数组的长度,多个又变成了数组元素

    • Array.of(),可以使其参数值(无论多少个)多为数组的元素来创建并返回新数组

      Array.of([1,2,3]); // [[1,2,3]]
      Array.of(3);       // [3]
      
  2. Array.from()
    • 这个方法就是将一个类数组对象或者一个可迭代对象转换成新数组,如果传入的是可迭代对象,那他就和使用...扩展操作符操作一样
    • Array.from()定义了一种给类数组对象创建真正的数组副本的机制

二、数组的增删改查

1. 读写

  • []操作符中间包裹一个索引
  • 由于数组索引其实就是一种特殊的对象属性,所以JavaScript数组没有所谓的“越界”错误。查询任何对象中不存在的属性都不会导致错误,只会返回undefined。数组作为一种特殊对象也是如此。

2. 数组的长度

  • 每个数组都有length属性,正是这个属性让数组有别于常规的JavaScript对象,对于非稀疏数组,length属性就是数组中元素的个数。这个值比数组的最高索引大1

3. 增删

3.1 添加

  • 使用一个新索引赋值:例如:arr[arr.length] = 0
  • push(): 等同于arr[arr.length],末尾追加
  • unshift(): 从开头追加

3.2 删除

  • 可以使用delete操作符
let a = [1,2,3];
delete a[2];    // 现在索引2没有元素了
2 in a;         // => false: 数组索引2没有定义
a.length;       // => 3: 删除元素不影响数组长度
  • 把数组length设置成一个新长度值,也可以从末尾删除元素
  • splice()是一个可以插入,删除或替换数组元素的通用方法
  • pop()删除最后的元素,并返回删除值
  • shift()删除第一个元素,并返回删除值

三、数组的方法

1 迭代方法(循环)

简介

首先,所有这些方法都接收一个函数作为第一个参数,并且对数组的每一个元素(或某些元素)都调用一次这个函数。如果数组是稀疏的,则不会对不存在的元素调用传入这个函数。多数情况下,我们提供的这个函数被调用时都会接收到3个参数,分别是数组元素的值数组元素的索引数组本身通常我们只需要这几个参数中的第一个,可以忽略第二和第三个值。

多数迭代器方法都接收可选的第二个参数。如果指定这个参数,则第一个函数在被调用时就好像它是第二个参数的方法一样。换句话说,我们传入的第二个参数会成为作为第一个参数传入的函数内部的this值。传入函数的返回值通常不重要,但不同的方法会以不同的方式处理这个返回值。本节介绍的所有方法都不会修改调用它们的数组。(当然,传入的函数可能会修改这个数组)

forEach()

注意:forEach()并未提供一种提前终止迭代的方式。换句话说,在这里没有常规for循环中的break语句对等的机制。

map()

  • map()方法把调用它的数组的每个元素分别传给我们指定的函数,返回这个函数的返回值构成的数组。
  • 对于map()方法来说,我们传入的函数应该有返回值
  • 注意:map()返回一个新数组,并不修改原数组
  • 如果数组是稀疏的,则缺失的元素不会调用我们的函数,但返回的数组也会与原始数组一样稀疏:长度相同,缺失的元素也相同。

filter()

  • filter()方法返回一个数组,该数组包含调用它的数组的子数组
  • 传给这个方法的函数应该是断言函数即返回true或false的函数。这个函数与传给forEach()和map()的函数一样被调用。如果函数返回true或返回值能转换为true,则传给这个函数的的元素就是filter最终返回的子数组的成员
  • 注意:filter()会跳过稀疏数组中缺失的元素,它返回的数组始终是稠密的。因此可以使用该方法清掉稀疏数组中的空隙
  • 用自己的话来说,这就是一个过滤函数,返回一个包含满足条件元素的数组

find()与findIndex()

  • find(),在找到满足条件的第一个元素时停止迭代,返回匹配的值;找不到满足条件的元素,返回undefined。
  • findIndex(),在找到满足条件的第一个元素时停止迭代,返回匹配的值的索引;找不到满足条件的元素,返回-1。

every()与some()

  • every(),类似数学上的“全称”量词∀类似,它在且只在所有元素都满足断言函数的时候,才返回true
  • some(),类似数学上的“存在”量词∃类似,它是只要有一个元素满足断言函数的时候,就返回true,但必须所有元素都不满足的时候才返回false
  • 注意: some()遇到第一个返回true的就会停止迭代。同样,every()遇到第一个返回false的也会停止迭代。
  • 注意: 如果空数组调用它们,every()返回true,some()返回false

reduce()与reduceRight()

  • reduce()和reduceRight()方法使用我们指定的函数归并数组元素,最终产生一个值。

  • reduce()接收两个参数。第一个是执行归并的函数。第二个参数是可选的,是传给归并函数的初始值。

  • 在reduce()中使用的函数与在forEach()和map()中使用的函数不一样。我们熟悉的值、索引和数组本身在这里作为第二、第三和第四参数。第一个参数是目前为止归并操作的累积结果。

  • 如果reduce()调用时未传第二个参数,那么数组的第一个元素会被作为初始值

  • 如果不传初始值,在空数组上调用reduce()会导致TypeError。如果调用它时只有一个值,或者用空数组调用但传了初始值,则reduce直接返回这个值,不会调用归并函数

  • reduceRight()与reduce()类似,只不过从高索引向低索引(从右向左)处理数组,而不是从低向高。如果归并操作具有从右到左的结合性,那可能要考虑使用reduceRight(), 比如:

    // 计算2^(3^4)。求幂具有从右到左的优先级
    let a = [2, 3, 4]
    a.reduceRight((acc, val) => Math.pow(val, acc))
    
  • 注意: 无论reduce()还是reduceRight()都不接收用于指定归并函数this值的可选参数。它们用可选的初始值参数取代了这个值。如果需要可以考虑bind()方法

2. 使用flat()和flatMap()打平数组

  • flat()只能打平一级

    [1, 2, [3, 4, [5]]].flat() // =>[1, 2, 3, 4, [5]]
    
  • flatMap()方法与map()方法类似,只不过返回的数组会自动被打平,就像传给了flat()一样。换句话说,调用a.flatMap(f)等同于(但效率远高于)a.map(f).flat()

前端面试第 78 期 - 2025.09.07 更新 Nginx 专题面试总结(12 道题)

作者 晴小篆
2025年9月7日 14:24

2025.08.31 - 2025.09.07 更新前端面试问题总结(12 道题)
获取更多面试相关问题可以访问
github 地址: github.com/pro-collect…
gitee 地址: gitee.com/yanleweb/in…

目录

中级开发者相关问题【共计 2 道题】

  1. SPA 的 history 路由模式在 Nginx 部署时刷新 404,如何配置解决【热度: 488】【web 应用场景】
  2. 如何通过 Nginx 配置前端静态资源的 “hash 资源永久缓存 + 非 hash 资源协商缓存”?【热度: 75】【web 应用场景】

高级开发者相关问题【共计 10 道题】

  1. Nginx 配置跨域(CORS)需设置哪些关键响应头?复杂跨域(带 cookie、自定义头)有何注意点【热度: 124】【web 应用场景】
  2. Nginx 中 proxy_pass 路径结尾加 / 与不加 /,对前端接口代理路径有何差异?举例说明。【热度: 106】【web 应用场景】
  3. Nginx 如何通过 include 或 vhost 实现前端多环境隔离?同域名不同路径映射需解决哪些重写问题?【热度: 112】【web 应用场景】
  4. Nginx 如何配置支持前端大资源的 Range 分片请求?核心参数是什么?【热度: 146】【web 应用场景】
  5. Nginx 如何按资源类型(如.js/.png)分发到不同服务器?配置策略是什么?【热度: 159】【web 应用场景】【出题公司: 阿里巴巴】
  6. Nginx 配置 gzip/brotli 压缩时需考虑哪些因素?为何不建议对所有前端资源开启压缩?【热度: 106】【web 应用场景】【出题公司: 阿里巴巴】
  7. 微前端通过 Nginx 实现主 / 子应用路由分发,如何配置 location 和 try_files?需处理哪些资源路径问题?【热度: 120】【web 应用场景】
  8. Nginx 配置 HTTPS 需哪些核心项(协议、加密套件)?如何强制 HTTP 跳转 HTTPS?【热度: 182】【web 应用场景】
  9. 前端静态资源加载超时,Nginx 可通过哪些配置优化?【热度: 190】【web 应用场景】【出题公司: 阿里巴巴】
  10. Nginx 如何为不同前端资源配置缓存策略?如何强制刷新特定资源?【热度: 110】【web 应用场景】

中级开发者相关问题【共计 2 道题】

1137. SPA 的 history 路由模式在 Nginx 部署时刷新 404,如何配置解决【热度: 488】【web 应用场景】

关键词:nginx 刷新 404

要解决 SPA(单页应用)History 路由模式在 Nginx 部署时刷新 404 的问题,核心是理解 History 路由的原理缺陷Nginx 的请求匹配逻辑,再通过针对性配置让所有路由请求都指向 SPA 的入口文件(通常是 index.html)。以下是完整解决方案:

一、问题根源:为什么会出现 404?

首先要明确 SPA 两种路由模式的本质差异,这是理解问题的关键:

路由模式 原理 部署后刷新行为
Hash 模式(# 路由信息包含在 URL 的 # 后(如 https://xxx.com/#/about),# 后的内容不会发送给服务器,所有请求本质都是访问根路径(/ 刷新时服务器只接收 / 请求,返回 index.html,SPA 再解析 # 后的路由,不会 404
History 模式(无 # 路由信息是真实 URL 路径(如 https://xxx.com/about),刷新时浏览器会将完整路径(/about)发送给服务器 Nginx 会查找 /about 对应的物理文件/目录,而 SPA 只有 index.html 一个入口文件,找不到就返回 404

二、解决方案:Nginx 核心配置

核心思路:让 Nginx 接收到所有 SPA 路由相关的请求时,都返回入口文件 index.html,由 SPA 框架(Vue/React/Angular 等)再解析具体路由。

1. 基础配置(通用版)

在 Nginx 的 server 块中,通过 try_files 指令实现“优先匹配物理文件,匹配不到则返回 index.html”:

server {
    listen 80;                  # 监听端口(根据实际情况调整,如 443 用于 HTTPS)
    server_name your-domain.com; # 你的域名(如 localhost 用于本地测试)
    root /path/to/your/spa;     # SPA 打包后文件的根目录(绝对路径,如 /usr/local/nginx/html/spa)
    index index.html;           # 默认入口文件

    # 关键配置:解决 History 路由刷新 404
    location / {
        # try_files 逻辑:先尝试访问 $uri(当前请求路径对应的物理文件)
        # 再尝试访问 $uri/(当前请求路径对应的目录)
        # 最后都找不到时,重定向到 /index.html(SPA 入口)
        try_files $uri $uri/ /index.html;
    }
}
2. 进阶配置(处理子路径部署)

如果 SPA 不是部署在域名根路径(如 https://xxx.com/admin,而非 https://xxx.com),需调整 location 匹配规则和 try_files 目标路径,避免路由错乱:

server {
    listen 80;
    server_name your-domain.com;
    root /path/to/your/project; # 注意:这里是父目录(包含 admin 子目录)
    index index.html;

    # 匹配所有以 /admin 开头的请求(SPA 部署在 /admin 子路径)
    location /admin {
        # 1. 先尝试访问子路径下的物理文件(如 /admin/static/css/main.css)
        # 2. 再尝试访问子路径下的目录
        # 3. 最后重定向到 /admin/index.html(子路径下的入口文件,而非根目录)
        try_files $uri $uri/ /admin/index.html;

        # 可选:如果 SPA 框架需要 base 路径,需在框架配置中同步设置
        # 例:Vue 需配置 publicPath: '/admin/',React 需配置 homepage: '/admin/'
    }
}

三、注意事项(避坑点)

  1. 路径正确性

    • root 指令必须指向 SPA 打包后文件的 实际绝对路径(如 Linux 下的 /var/www/spa,Windows 下的 D:/nginx/html/spa),错误路径会导致 Nginx 找不到 index.html
    • 子路径部署时,try_files 最后一个参数必须是 完整的子路径入口(如 /admin/index.html),不能写 /index.html(会指向根目录,导致 404)。
  2. HTTPS 场景适配: 如果网站使用 HTTPS(listen 443 ssl),配置逻辑完全一致,只需在 server 块中补充 SSL 证书相关配置,不影响路由处理:

    server {
        listen 443 ssl;
        server_name your-domain.com;
        ssl_certificate /path/to/cert.pem;   # SSL 证书路径
        ssl_certificate_key /path/to/key.pem; # 证书私钥路径
    
        root /path/to/your/spa;
        index index.html;
    
        location / {
            try_files $uri $uri/ /index.html;
        }
    }
    
  3. 配置生效方式: 修改 Nginx 配置后,需执行以下命令让配置生效(避免重启服务导致短暂 downtime):

    # 1. 测试配置是否有语法错误(必须先执行,避免配置错误导致 Nginx 启动失败)
    nginx -t
    
    # 2. 重新加载配置(平滑生效,不中断现有连接)
    nginx -s reload
    
  4. 与后端接口的冲突处理: 如果 SPA 同时有后端接口请求(如 /api 开头的接口),需在 Nginx 中优先匹配接口路径,避免接口请求被转发到 index.html。配置示例:

    server {
        # ... 其他基础配置 ...
    
        # 第一步:优先匹配后端接口(/api 开头的请求),转发到后端服务
        location /api {
            proxy_pass http://your-backend-server:port; # 后端服务地址(如 http://127.0.0.1:3000)
            proxy_set_header Host $host;               # 传递 Host 头信息
            proxy_set_header X-Real-IP $remote_addr;   # 传递真实客户端 IP
        }
    
        # 第二步:剩余请求(SPA 路由)转发到 index.html
        location / {
            try_files $uri $uri/ /index.html;
        }
    }
    

四、原理总结

通过 try_files $uri $uri/ /index.html 这行核心配置,Nginx 实现了:

  1. 优先处理 静态资源请求(如 cssjsimg):如果请求路径对应物理文件(如 /static/css/main.css),则直接返回该文件。
  2. 兜底处理 SPA 路由请求:如果请求路径不对应任何物理文件(如 /about/user/123),则返回 index.html,由 SPA 框架根据 URL 解析并渲染对应的页面,从而解决刷新 404 问题。

1144. 如何通过 Nginx 配置前端静态资源的 “hash 资源永久缓存 + 非 hash 资源协商缓存”?【热度: 75】【web 应用场景】

关键词:nginx 资源缓存

要实现前端前端静态资源的“hash 资源永久缓存 + 非 hash 资源协商缓存”,需结合 Nginx 的缓存头配置,针对不同类型资源设计差异化策略。核心思路是:对带 hash 的指纹文件(如app.8f3b.js)设置长期强缓存,对无 hash 的文件(如index.html)使用协商缓存,既以下是具体实现方案:

一、两种缓存策略的适用场景

资源类型 特征 缓存策略 目的
带 hash 的资源 文件名含唯一 hash(如style.1a2b.css),内容变化则 hash 变化 永久强缓存 一次缓存后不再请求,减少重复下载
非 hash 的资源 文件名固定(如index.htmlfavicon.ico),内容可能动态更新 协商缓存 每次请求验证是否更新,确保获取最新内容

二、核心配置方案

通过location匹配不同资源类型,分别设置缓存头:

server {
    listen 80;
    server_name example.com;
    root /path/to/frontend/dist;  # 前端打包目录
    index index.html;

    # 1. 处理带hash的静态资源(JS/CSS/图片等):永久强缓存
    # 假设hash格式为 8-16位字母数字(如 app.8f3b1e7d.js)
    location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?)(\?.*)?$ {
        # 匹配带hash的文件名(如 .1a2b3c. 或 .v2.3.4. 等格式)
        # 正则说明:\.\w{8,16}\. 匹配 .hash. 结构(8-16位hash值)
        if ($request_filename ~* .*\.\w{8,16}\.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$) {
            # 永久缓存(1年)
            expires 365d;
            # 强缓存标识:告知浏览器直接使用缓存,不发请求
            add_header Cache-Control "public, max-age=31536000, immutable";
        }
    }

    # 2. 处理非hash资源(如 index.html):协商缓存
    location / {
        # 禁用强缓存
        expires -1;
        # 协商缓存:基于文件修改时间(Last-Modified)验证
        add_header Cache-Control "no-cache, must-revalidate";

        # 支持 History 路由(SPA必备)
        try_files $uri $uri/ /index.html;
    }

    # 3. 特殊资源补充:favicon.ico(通常无hash)
    location = /favicon.ico {
        expires 7d;  # 短期强缓存(7天)+ 协商缓存兜底
        add_header Cache-Control "public, max-age=604800, must-revalidate";
    }
}

三、配置详解与核心参数

1. 带 hash 资源的永久强缓存
  • 匹配规则
    通过正则.*\.\w{8,16}\.(js|css...)精准匹配带 hash 的文件(如app.8f3b1e7d.jslogo.a1b2c3.png),确保只有内容不变的文件被长期缓存。

  • 核心缓存头

    • expires 365d:设置浏览器缓存过期时间(1 年)。
    • Cache-Control: public, max-age=31536000, immutable
      • public:允许中间代理(如 CDN)缓存。
      • max-age=31536000:1 年内直接使用缓存(单位:秒)。
      • immutable:告知浏览器资源不会变化,无需发送验证请求(H5 新特性,增强缓存效果)。
  • 关键逻辑
    当资源内容更新时,打包工具(Webpack/Vite 等)会生成新的 hash 文件名(如app.9c4d2f8e.js),浏览器会将其视为新资源重新请求,完美解决“缓存更新”问题。

2. 非 hash 资源的协商缓存
  • 适用场景
    index.html(SPA 入口文件)、robots.txt等文件名固定的资源,需确保用户能获取最新版本。

  • 核心缓存头

    • expires -1:禁用强缓存(立即过期)。
    • Cache-Control: no-cache, must-revalidate
      • no-cache:浏览器必须发送请求到服务器验证资源是否更新。
      • must-revalidate:若资源过期,必须向服务器验证。
  • 协商验证机制
    Nginx 默认会返回Last-Modified头(文件最后修改时间),浏览器下次请求时会携带If-Modified-Since头:

    • 若文件未修改,服务器返回304 Not Modified(无响应体),浏览器使用缓存。
    • 若文件已修改,服务器返回200 OK和新内容。
3. 特殊资源处理(如 favicon.ico)
  • 对于不常变化但无 hash 的资源(如网站图标),可采用“短期强缓存 + 协商缓存兜底”:
    • expires 7d:7 天内直接使用缓存。
    • must-revalidate:过期后必须向服务器验证是否更新。

四、与前端打包的配合要点

  1. 确保 hash 生成规则可靠
    前端打包时,需保证“内容不变则 hash 不变,内容变化则 hash 必变”。例如:

    • Webpack:contenthash(基于文件内容生成 hash)。
    • Vite:默认对静态资源生成 contenthash。
  2. 避免 hash 资源依赖非 hash 资源
    确保带 hash 的 JS/CSS 不引用无 hash 的资源(如background: url(/img/bg.png)),否则 bg.png 更新后,引用它的 CSS 因 hash 未变而无法更新。
    解决方案:让被引用资源也带上 hash(如bg.a1b2c3.png)。

  3. index.html 必须无 hash
    作为入口文件,index.html需通过协商缓存确保用户每次获取最新版本,从而加载新的 hash 资源。

五、验证缓存是否生效

  1. 强缓存验证
    访问带 hash 的资源(如app.8f3b.js),在浏览器 Network 面板中查看:

    • 状态码为200 OK (from disk cache)200 OK (from memory cache)
    • Response Headers 包含Cache-Control: public, max-age=31536000, immutable
  2. 协商缓存验证
    访问index.html,刷新页面:

    • 第一次请求:状态码200 OK,Response Headers 有Last-Modified
    • 第二次请求:Request Headers 有If-Modified-Since,若未修改,状态码304 Not Modified

总结

通过 Nginx 实现“hash 资源永久缓存 + 非 hash 资源协商缓存”的核心是:

  1. 利用 hash 文件名的唯一性,对静态资源设置长期强缓存,最大化减少重复请求。
  2. 对入口文件等无 hash 资源启用协商缓存,确保内容更新时能被及时获取。
  3. 前端打包与 Nginx 配置协同,保证 hash 机制可靠,避免缓存不一致问题。

这种策略既能大幅提升页面加载速度,又能确保资源更新的即时性,是前端性能优化的关键实践。

高级开发者相关问题【共计 10 道题】

1138. Nginx 配置跨域(CORS)需设置哪些关键响应头?复杂跨域(带 cookie、自定义头)有何注意点【热度: 124】【web 应用场景】

关键词:nginx 配置跨域

Nginx 配置跨域(CORS)的核心是通过设置特定响应头,允许不同域的前端资源请求当前服务器资源。以下是关键配置和复杂跨域场景的注意事项:

一、基础跨域配置:关键响应头

基础跨域(无 cookie、无自定义头)需配置以下核心响应头,允许指定域的请求访问资源:

location / {
    # 1. 允许的源域名(必填)
    # 注意:生产环境建议明确指定域名(如 https://example.com),而非 *
    add_header Access-Control-Allow-Origin *;

    # 2. 允许的请求方法(必填)
    add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';

    # 3. 允许的请求头(可选,根据实际需求添加)
    add_header Access-Control-Allow-Headers 'Content-Type, Authorization';

    # 4. 预检请求(OPTIONS)的缓存时间(可选,减少预检请求次数)
    add_header Access-Control-Max-Age 3600;

    # 处理预检请求(OPTIONS):直接返回 204 成功状态
    if ($request_method = 'OPTIONS') {
        return 204;
    }
}

各头字段作用

  • Access-Control-Allow-Origin:指定允许跨域请求的源(* 表示允许所有源,不推荐生产环境使用)。
  • Access-Control-Allow-Methods:允许的 HTTP 方法(需包含实际使用的方法,如 OPTIONS 是预检请求必须的)。
  • Access-Control-Allow-Headers:允许请求中携带的自定义头(如 AuthorizationX-Custom-Header)。
  • Access-Control-Max-Age:预检请求(OPTIONS)的结果缓存时间(秒),避免频繁预检。

二、复杂跨域场景:带 cookie、自定义头的注意点

当跨域请求需要 携带 cookie自定义请求头 时,配置需更严格,且前后端需协同配合:

1. 带 cookie 的跨域(withCredentials: true
  • Nginx 必须明确指定允许的源(不能用 *),否则浏览器会拒绝响应:

    # 错误:带 cookie 时不能用 *
    # add_header Access-Control-Allow-Origin *;
    
    # 正确:明确指定允许的源(如 https://frontend.com)
    add_header Access-Control-Allow-Origin https://frontend.com;
    
    # 必须添加:允许携带 cookie
    add_header Access-Control-Allow-Credentials true;
    
  • 前端需配合设置:请求时需显式开启 withCredentials(以 Fetch 为例):

    fetch("https://backend.com/api/data", {
      credentials: "include", // 等价于 XMLHttpRequest 的 withCredentials: true
    });
    
2. 带自定义请求头(如 X-Token
  • 需在 Access-Control-Allow-Headers 中显式包含自定义头,否则预检请求会失败:

    # 例如允许 X-Token、X-User-Id 等自定义头
    add_header Access-Control-Allow-Headers 'Content-Type, X-Token, X-User-Id';
    
  • 浏览器会先发送 OPTIONS 预检请求,需确保 Nginx 正确处理(返回 204 或 200):

    if ($request_method = 'OPTIONS') {
        return 204;  # 预检请求成功,无需返回 body
    }
    
3. 其他注意事项
  • add_header 指令的作用域:如果 Nginx 配置中存在多个 location 块,跨域头需配置在对应请求的 location 中(如接口请求通常在 /api 路径)。

  • 避免重复设置头:如果后端服务(如 Node.js、Java)已设置 CORS 头,Nginx 无需重复添加,否则可能导致浏览器解析冲突。

  • 生产环境安全性

    • 禁止使用 Access-Control-Allow-Origin: *(尤其是带 cookie 的场景)。
    • 限制 Access-Control-Allow-Methods 为必要的方法(如仅允许 GET, POST)。
    • 避免 Access-Control-Allow-Headers 包含通配符(如 *),仅添加实际需要的头。

三、完整复杂跨域配置示例(带 cookie + 自定义头)

server {
    listen 80;
    server_name backend.com;

    # 接口路径的跨域配置(假设接口都在 /api 下)
    location /api {
        # 明确允许的前端域名(不能用 *)
        add_header Access-Control-Allow-Origin https://frontend.com;

        # 允许携带 cookie
        add_header Access-Control-Allow-Credentials true;

        # 允许的方法(包含预检请求 OPTIONS)
        add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';

        # 允许的头(包含自定义头 X-Token)
        add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Token';

        # 预检请求结果缓存 1 小时
        add_header Access-Control-Max-Age 3600;

        # 处理预检请求
        if ($request_method = 'OPTIONS') {
            return 204;
        }

        # 转发请求到后端服务(如 Node.js、Java 服务)
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
    }
}

总结

  • 基础跨域:核心配置 Access-Control-Allow-Origin-Methods-Headers
  • 带 cookie 跨域:必须指定具体 Origin,并添加 Access-Control-Allow-Credentials: true
  • 自定义头跨域:需在 Allow-Headers 中显式声明,并正确处理 OPTIONS 预检请求。
  • 生产环境需严格限制允许的源、方法和头,避免安全风险。

1139. Nginx 中 proxy_pass 路径结尾加 / 与不加 /,对前端接口代理路径有何差异?举例说明。【热度: 106】【web 应用场景】

关键词:nginx proxy_pass

Nginx 中 proxy_pass 路径结尾是否加 / 会直接影响代理后的 URL 拼接规则,对前端接口请求路径的映射结果有显著差异。理解这一差异是配置接口代理的关键。

核心差异:路径拼接规则

假设:

  • Nginx 配置的 location 匹配规则为 /api
  • 后端服务地址为 http://backend:3000

两种配置的区别如下:

proxy_pass 配置 拼接规则 最终代理地址
不加 /proxy_pass http://backend:3000 location 匹配的路径(/api完整拼接到后端地址后 http://backend:3000 + /api + 剩余路径
/proxy_pass http://backend:3000/ location 匹配的路径(/api替换为 /,仅拼接剩余路径 http://backend:3000 + / + 剩余路径

举例说明(前端请求路径对比)

假设前端发送请求:http://nginx-host/api/user/list

1. proxy_pass 不加 / 的情况
location /api {
    # 后端地址末尾无 /
    proxy_pass http://backend:3000;
}
  • 匹配逻辑:location /api 匹配到请求中的 /api 部分
  • 代理后地址:http://backend:3000 + /api + /user/listhttp://backend:3000/api/user/list
2. proxy_pass/ 的情况
location /api {
    # 后端地址末尾有 /
    proxy_pass http://backend:3000/;
}
  • 匹配逻辑:location /api 匹配到的 /api 被替换为 /
  • 代理后地址:http://backend:3000/ + /user/listhttp://backend:3000/user/list

扩展场景:location 带多级路径时

location 规则为 /api/v1,请求路径为 http://nginx-host/api/v1/user/list

1. 不加 /
location /api/v1 {
    proxy_pass http://backend:3000;
}
# 代理结果:http://backend:3000/api/v1/user/list
2. 加 /
location /api/v1 {
    proxy_pass http://backend:3000/;
}
# 代理结果:http://backend:3000/user/list

特殊场景:proxy_pass 包含子路径

proxy_pass 不仅是域名/IP,还包含子路径(如 http://backend:3000/service),加不加 / 的规则依然适用:

1. 后端路径不加 /
location /api {
    proxy_pass http://backend:3000/service;
}
# 请求 /api/user → 代理到 http://backend:3000/serviceapi/user
# (注意:/api 被完整拼接,导致 service 和 api 连在一起,通常不符合预期)
2. 后端路径加 /
location /api {
    proxy_pass http://backend:3000/service/;
}
# 请求 /api/user → 代理到 http://backend:3000/service/user
# (正确拼接:/api 被替换为 /,剩余路径 /user 拼接到 service/ 后)

总结

  • 不加 /location 匹配的路径会被完整拼接到 proxy_pass 地址后(适合后端接口路径与前端请求路径完全一致的场景)。
  • /location 匹配的路径会被替换为 /,仅将剩余路径拼接到 proxy_pass 地址后(适合需要简化后端接口路径的场景)。

实际配置时,建议通过 curl 测试或查看后端日志确认代理结果,避免因路径拼接错误导致接口 404。

1140. Nginx 如何通过 include 或 vhost 实现前端多环境隔离?同域名不同路径映射需解决哪些重写问题?【热度: 112】【web 应用场景】

关键词:nginx 多环境隔离

Nginx 可以通过 include 指令或虚拟主机(vhost)实现前端多环境(如开发、测试、生产)的隔离部署,同时针对同域名不同路径的映射场景,需要处理路径重写和资源引用问题。以下是具体实现方案和注意事项:

一、通过 include 或 vhost 实现多环境隔离

多环境隔离的核心是为不同环境(如 devtestprod)配置独立的 Nginx 规则,避免互相干扰。

1. 基于 include 指令的多环境配置(推荐)

适合单服务器部署多个环境,通过拆分配置文件实现隔离,便于维护。

目录结构

nginx/
├── conf.d/
│   ├── common.conf          # 公共配置(如日志、超时时间)
│   ├── frontend-dev.conf    # 开发环境配置
│   ├── frontend-test.conf   # 测试环境配置
│   └── frontend-prod.conf   # 生产环境配置
└── nginx.conf               # 主配置文件(通过 include 引入子配置)

主配置(nginx.conf)

http {
    # 引入公共配置
    include conf.d/common.conf;

    # 引入各环境配置(按需启用,生产环境可注释 dev/test)
    include conf.d/frontend-dev.conf;
    include conf.d/frontend-test.conf;
    include conf.d/frontend-prod.conf;
}

环境配置示例(frontend-dev.conf)

# 开发环境:监听 8080 端口
server {
    listen 8080;
    server_name localhost;

    # 开发环境前端文件目录
    root /path/to/frontend/dev;
    index index.html;

    # 开发环境特有的路由配置(如 History 模式支持)
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 开发环境接口代理(指向开发后端)
    location /api {
        proxy_pass http://dev-backend:3000;
    }
}

优势

  • 配置模块化,各环境规则独立,修改单个环境不影响其他环境。
  • 可通过注释 include 语句快速切换生效的环境。
2. 基于虚拟主机(vhost)的多环境配置

适合通过不同域名/端口区分环境(如 dev.example.comtest.example.com)。

配置示例

http {
    # 开发环境(域名区分)
    server {
        listen 80;
        server_name dev.example.com;  # 开发环境域名
        root /path/to/frontend/dev;
        # ... 其他配置(路由、代理等)
    }

    # 测试环境(端口区分)
    server {
        listen 8081;  # 测试环境端口
        server_name localhost;
        root /path/to/frontend/test;
        # ... 其他配置
    }

    # 生产环境(HTTPS)
    server {
        listen 443 ssl;
        server_name example.com;  # 生产环境域名
        root /path/to/frontend/prod;
        # ... SSL 配置和其他生产环境特有的规则
    }
}

优势

  • 环境边界清晰,通过域名/端口直接访问对应环境,适合团队协作。
  • 可针对生产环境单独配置 HTTPS、缓存等高级特性。

二、同域名不同路径映射的重写问题及解决方案

当多个前端应用部署在同一域名的不同路径下(如 example.com/app1example.com/app2),需要解决路径映射和资源引用的问题。

场景示例
  • 应用 A 部署在 /app1 路径,文件目录为 /var/www/app1
  • 应用 B 部署在 /app2 路径,文件目录为 /var/www/app2
1. 基础路径映射配置
server {
    listen 80;
    server_name example.com;
    root /var/www;  # 父目录

    # 应用 A:匹配 /app1 路径
    location /app1 {
        # 实际文件目录为 /var/www/app1
        alias /var/www/app1;  # 注意:这里用 alias 而非 root(关键区别)
        index index.html;

        # 解决 History 路由刷新 404
        try_files $uri $uri/ /app1/index.html;
    }

    # 应用 B:匹配 /app2 路径
    location /app2 {
        alias /var/www/app2;
        index index.html;
        try_files $uri $uri/ /app2/index.html;
    }
}

关键区别alias vs root

  • root /var/www:请求 /app1/static/css.css 会映射到 /var/www/app1/static/css.css(拼接完整路径)。
  • alias /var/www/app1:请求 /app1/static/css.css 会直接映射到 /var/www/app1/static/css.css(替换 /app1 为实际目录),更适合子路径部署。
2. 需要解决的重写问题及方案
(1)前端资源引用路径错误

问题:应用内的静态资源(如 jscss、图片)若使用绝对路径(如 /static/js/main.js),会被解析为 example.com/static/js/main.js,而非 example.com/app1/static/js/main.js,导致 404。

解决方案

  • 前端打包时配置 公共路径(publicPath)
    • Vue 项目:在 vue.config.js 中设置 publicPath: '/app1/'
    • React 项目:在 package.json 中设置 homepage: '/app1'
  • 资源引用使用相对路径(如 ./static/js/main.js),避免绝对路径。
(2)接口请求路径前缀问题

问题:应用内的接口请求(如 /api/user)会被发送到 example.com/api/user,若需要根据子路径区分接口(如 example.com/app1/api/user),需调整代理规则。

解决方案

  • 前端统一为接口添加路径前缀(如 axios.defaults.baseURL = '/app1/api')。
  • Nginx 配置对应代理规则:
    location /app1/api {
        # 移除 /app1 前缀后转发到后端
        proxy_pass http://backend:3000/api;  # 或 http://backend:3000/(根据后端路径调整)
    }
    
(3)路由跳转路径错误

问题:SPA 路由跳转(如从 /app1/home 跳转到 /app1/about)若使用绝对路径,可能因框架路由配置未添加基础路径导致跳转错误。

解决方案

  • 前端路由配置基础路径:
    • Vue Router:base: '/app1/'
    • React Router:basename="/app1"

三、总结

  1. 多环境隔离

    • 推荐使用 include 指令拆分配置文件,实现模块化管理。
    • 或通过虚拟主机(不同域名/端口)实现环境隔离,适合团队协作。
  2. 同域名不同路径映射

    • 使用 alias 指令正确映射子路径到实际文件目录。
    • 解决资源引用问题:前端配置 publicPath,使用相对路径。
    • 解决接口和路由问题:统一添加路径前缀,配置对应 Nginx 代理和前端路由基础路径。

通过以上配置,可以实现前端多环境的清晰隔离和同域名下多应用的无冲突部署。

1141. Nginx 如何配置支持前端大资源的 Range 分片请求?核心参数是什么?【热度: 146】【web 应用场景】

关键词:nginx 配置分片请求

Nginx 支持前端大资源(如视频、大型压缩包等)的 Range 分片请求,主要通过配置实现对 HTTP 范围请求的支持,允许客户端分段块下载资源,提升大文件传输效率。以下是具体配置方法和核心参数解析:

一、Range 分片请求的原理

HTTP 协议的 Range 请求头允许客户端只请求资源的一部分(如 Range: bytes=0-1023 表示请求前 1024 字节),服务器通过 Accept-RangesContent-Range 头响应支持状态和分片数据。

Nginx 默认已支持 Range 请求,但需确保配置正确以避免功能被禁用,尤其针对大文件场景需优化相关参数。

二、核心配置(支持 Range 请求)

1. 基础配置(启用 Range 支持)
server {
    listen 80;
    server_name example.com;
    root /path/to/large-files;  # 存放大资源的目录

    # 关键:确保未禁用 Range 请求(默认启用,无需额外配置,但需避免以下错误)
    # 错误示例:禁用 Range 的配置(生产环境需删除)
    # proxy_set_header Range "";  # 禁止传递 Range 头
    # add_header Accept-Ranges none;  # 告知客户端不支持 Range

    # 大文件传输优化(可选但推荐)
    location / {
        # 支持断点续传和分片请求(默认开启,显式声明更清晰)
        add_header Accept-Ranges bytes;

        # 读取文件的缓冲区大小(根据服务器内存调整)
        client_body_buffer_size 10M;

        # 发送文件的缓冲区大小(优化大文件传输效率)
        sendfile on;               # 启用零拷贝发送文件
        tcp_nopush on;             # 配合 sendfile 提高网络效率
        tcp_nodelay off;           # 减少小包发送,适合大文件

        # 超时设置(避免大文件传输中断)
        client_header_timeout 60s;
        client_body_timeout 60s;
        send_timeout 300s;         # 发送超时延长至 5 分钟
    }
}
2. 核心参数解析
  • Accept-Ranges: bytes
    响应头,明确告知客户端服务器支持字节范围的分片请求(这是支持 Range 的核心标志)。Nginx 默认会自动添加该头,无需显式配置,但显式声明可增强配置可读性。

  • sendfile on
    启用零拷贝(zero-copy)机制,让 Nginx 直接从磁盘读取文件并发送到网络,跳过用户态到内核态的数据拷贝,大幅提升大文件传输效率(对 Range 分片请求尤其重要)。

  • tcp_nopush on
    sendfile 配合使用,在发送文件时先积累一定数据量再一次性发送,减少网络包数量,适合大文件的连续分片传输。

  • proxy_set_header Range $http_range(反向代理场景)
    若大资源存储在后端服务(而非 Nginx 本地),需通过此配置将客户端的 Range 请求头传递给后端,确保后端能正确处理分片请求:

    location /large-files {
        proxy_pass http://backend-server;
        proxy_set_header Range $http_range;          # 传递 Range 头
        proxy_set_header If-Range $http_if_range;    # 传递 If-Range 头(验证资源是否修改)
        proxy_pass_request_headers on;               # 确保所有请求头被传递
    }
    

三、验证 Range 请求是否生效

可通过 curl 命令测试服务器是否支持分片请求:

# 测试请求前 1024 字节
curl -v -H "Range: bytes=0-1023" http://example.com/large-file.mp4

若响应中包含以下头信息,则表示配置生效:

HTTP/1.1 206 Partial Content  # 206 状态码表示部分内容响应
Accept-Ranges: bytes
Content-Range: bytes 0-1023/10485760  # 表示返回 0-1023 字节,总大小 10485760 字节

四、注意事项

  1. 避免禁用 Range 的配置
    确保配置中没有 add_header Accept-Ranges noneproxy_set_header Range "" 等禁用 Range 的指令,这些会导致客户端分片请求失败。

  2. 后端服务配合
    若资源通过反向代理从后端服务获取,需确保后端服务本身支持 Range 请求(如 Node.js、Java 服务需实现对 Range 头的处理),否则 Nginx 无法单独完成分片响应。

  3. 大文件存储优化
    对于超大型文件(如 GB 级视频),建议结合 open_file_cache 配置缓存文件描述符,减少频繁打开文件的开销:

    open_file_cache max=1000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;
    

总结

Nginx 支持 Range 分片请求的核心是:

  1. 确保默认的 Accept-Ranges: bytes 响应头有效(不被禁用)。
  2. 启用 sendfile 等传输优化参数提升大文件处理效率。
  3. 反向代理场景下需传递 Range 相关请求头给后端服务。

通过以上配置,前端可以实现大资源的断点续传、分片下载,显著提升用户体验。

1142. Nginx 如何按资源类型(如.js/.png)分发到不同服务器?配置策略是什么?【热度: 159】【web 应用场景】【出题公司: 阿里巴巴】

关键词:nginx 转发

Nginx 可以通过 location 指令匹配不同资源类型(如 .js.png),并将请求分发到不同服务器,实现资源的分类部署和负载均衡。这种配置策略适合将静态资源(JS、图片)与动态资源(API)分离部署,提升整体服务性能。

一、核心配置策略:按文件后缀匹配并转发

通过 location 块的正则表达式匹配符(区分大小写)或 ~* 匹配符(不区分大小写),根据文件后缀名匹配不同资源类型,再通过 proxy_pass 转发到对应服务器。

1. 基础配置示例(分离 JS/CSS 与图片资源)
http {
    # 定义后端服务器组(可配置负载均衡)
    # JS/CSS 资源服务器组
    upstream js_css_servers {
        server 192.168.1.101:8080;  # JS/CSS 服务器1
        server 192.168.1.102:8080;  # JS/CSS 服务器2(负载均衡)
    }

    # 图片资源服务器组
    upstream image_servers {
        server 192.168.1.201:8080;  # 图片服务器1
        server 192.168.1.202:8080;  # 图片服务器2(负载均衡)
    }

    # 其他资源(如HTML、API)服务器
    upstream default_server {
        server 192.168.1.301:8080;
    }

    server {
        listen 80;
        server_name example.com;

        # 1. 匹配 .js 和 .css 文件,转发到 JS/CSS 服务器组
        location ~* \.(js|css)$ {
            proxy_pass http://js_css_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            # 静态资源缓存优化(可选)
            expires 1d;  # 缓存 1 天
            add_header Cache-Control "public, max-age=86400";
        }

        # 2. 匹配图片文件(.png/.jpg/.jpeg/.gif/.webp),转发到图片服务器组
        location ~* \.(png|jpg|jpeg|gif|webp)$ {
            proxy_pass http://image_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            # 图片缓存时间更长(可选)
            expires 7d;  # 缓存 7 天
            add_header Cache-Control "public, max-age=604800";
        }

        # 3. 其他所有请求(如 HTML、API)转发到默认服务器
        location / {
            proxy_pass http://default_server;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
}

二、配置策略解析

1. 匹配规则说明
  • ~* \.(js|css)$

    • ~* 表示不区分大小写匹配(如 .JS.Css 也会被匹配)。
    • \.(js|css)$ 是正则表达式,匹配以 .js.css 结尾的请求。
  • 优先级注意
    Nginx 的 location 匹配有优先级,精确匹配(=)> 前缀匹配(不含正则)> 正则匹配(~/~*
    因此,按资源类型的正则匹配会优先于普通前缀匹配(如 /static),需确保规则无冲突。

2. 服务器组(upstream)配置
  • 通过 upstream 定义同类资源的服务器集群,支持负载均衡策略(默认轮询):
    • 可添加 weight=2 调整权重(如 server 192.168.1.101:8080 weight=2;)。
    • 可添加 backup 配置备用服务器(如 server 192.168.1.103:8080 backup;)。
3. 资源优化补充配置
  • 缓存策略:静态资源(JS、图片)通常不频繁变动,通过 expiresCache-Control 头设置浏览器缓存,减少重复请求。
  • 防盗链:图片等资源可添加防盗链配置,防止被其他网站盗用:
    location ~* \.(png|jpg|jpeg|gif|webp)$ {
        # 仅允许 example.com 域名引用图片
        valid_referers none blocked example.com *.example.com;
        if ($invalid_referer) {
            return 403;  # 非法引用返回 403
        }
        # ... 其他配置
    }
    

三、扩展场景:按目录 + 资源类型组合匹配

若资源按目录分类(如 /static/js/static/img),可结合目录和后缀匹配,进一步细化转发规则:

# 仅匹配 /static/js 目录下的 .js 文件
location ~* /static/js/.*\.js$ {
    proxy_pass http://js_servers;
}

# 仅匹配 /static/img 目录下的图片文件
location ~* /static/img/.*\.(png|jpg)$ {
    proxy_pass http://image_servers;
}

四、注意事项

  1. 正则表达式效率
    过多复杂的正则匹配会影响 Nginx 性能,建议资源类型规则尽量简洁(如合并同类后缀)。

  2. 后端资源路径一致性
    确保转发目标服务器的资源路径与请求路径一致。例如,请求 example.com/static/a.js 被转发到 js_css_servers 后,服务器需能在 /static/a.js 路径找到资源。

  3. HTTPS 场景适配
    若使用 HTTPS,配置逻辑不变,只需在 server 块中添加 SSL 证书配置,转发目标可保持 HTTP(内部通信)或 HTTPS(跨公网)。

总结

按资源类型分发的核心策略是:

  1. location ~* \.(后缀1|后缀2)$ 匹配不同资源类型。
  2. 通过 upstream 定义对应资源的服务器集群,支持负载均衡。
  3. 结合缓存、防盗链等配置优化静态资源访问。

这种方案能实现资源的分类部署,减轻单服务器压力,同时针对不同资源类型(如图片、JS)进行专项优化,提升整体服务性能。

1143. Nginx 配置 gzip/brotli 压缩时需考虑哪些因素?为何不建议对所有前端资源开启压缩?【热度: 106】【web 应用场景】【出题公司: 阿里巴巴】

关键词:nginx 配置资源压缩

在 Nginx 中配置 gzip 或 brotli 压缩时,需综合考虑压缩效率、服务器性能开销、客户端兼容性等核心因素;而不建议对所有前端资源开启压缩,本质是避免“无效压缩”(压缩后体积无明显减小)和“反向损耗”(压缩耗时 > 传输耗时)。以下是具体分析:

一、配置 gzip/brotli 需考虑的核心因素

无论是 gzip 还是 brotli(压缩率通常优于 gzip,但需额外模块支持),配置时需围绕“收益最大化、损耗最小化”展开,核心考虑因素如下:

1. 资源类型适配:选择“高压缩收益”的资源

不同资源的压缩潜力差异极大,需优先对文本类资源开启压缩(压缩率高、收益显著),对二进制资源谨慎处理(压缩率低、甚至体积变大)。

资源类型 压缩收益 建议配置 原因
HTML/CSS/JS 极高 强制开启 文本内容重复度高,压缩率可达 60%-80%,传输体积大幅减小。
JSON/XML 极高 强制开启 结构化文本,压缩率与 JS 接近,尤其适合 API 响应数据。
图片(PNG/JPG) 极低 禁止开启 本身已是压缩格式(PNG 无损压缩、JPG 有损压缩),再压缩体积基本不变,反而增加耗时。
视频(MP4/WEBM) 极低 禁止开启 视频编码已做深度压缩,gzip/brotli 无法进一步减小体积,纯浪费资源。
字体(WOFF2) 可选开启 WOFF2 本身已内置压缩(基于 brotli),再压缩收益有限;若使用旧字体格式(WOFF/TTF),可开启。
压缩包(ZIP/RAR) 极低 禁止开启 压缩包本身是压缩格式,二次压缩可能导致体积轻微增大。
2. 压缩级别:平衡“压缩率”与“服务器耗时”

gzip 和 brotli 均支持多级别压缩(级别越高,压缩率越高,但消耗 CPU 资源越多、压缩耗时越长),需根据服务器性能和业务需求选择:

  • gzip 压缩级别gzip_comp_level 1-9):

    • 级别 1-3:轻量压缩,CPU 消耗低,耗时短,适合高并发场景(如秒杀、峰值流量),压缩率约 40%-50%;
    • 级别 4-6:平衡压缩率与性能,默认推荐级别(Nginx 默认是 1,需手动调至 4-6),压缩率约 50%-70%;
    • 级别 7-9:高强度压缩,CPU 消耗高,耗时久,仅适合低并发、对带宽敏感的场景(如静态资源 CDN 后台)。
  • brotli 压缩级别brotli_comp_level 1-11):
    比 gzip 多 2 个级别,压缩率更高(同级别下比 gzip 高 10%-20%),但 CPU 消耗也更高。推荐级别 4-8,避免使用 9-11(耗时显著增加,收益边际递减)。

3. 客户端兼容性:避免“压缩后客户端无法解压”

压缩生效的前提是客户端支持对应压缩算法(通过 HTTP 请求头 Accept-Encoding: gzip, br 告知服务器),需避免对不支持的客户端发送压缩数据:

  • gzip 兼容性:几乎所有现代浏览器(IE6+)、客户端均支持,兼容性无压力。
  • brotli 兼容性:支持 95% 以上现代浏览器(Chrome 49+、Firefox 44+、Edge 15+),但需注意:
    • 仅支持 HTTPS 环境(部分浏览器限制 HTTP 下不使用 brotli);
    • 需 Nginx 额外安装 ngx_brotli 模块(默认不内置,需编译时添加或通过动态模块加载)。

配置时需通过 gzip_disable/brotli_disable 排除不支持的客户端,例如:

# gzip:排除 IE6 及以下不支持的客户端
gzip_disable "MSIE [1-6]\.";

# brotli:仅对支持的客户端生效(依赖 Accept-Encoding 头)
brotli on;
brotli_types text/html text/css application/javascript application/json;
4. 压缩阈值:避免“小文件压缩反而耗时”

极小文件(如 < 1KB 的 CSS/JS 片段)开启压缩,可能出现“压缩耗时 > 传输耗时”的反向损耗——因为压缩需要 CPU 计算,而小文件即使不压缩,传输耗时也极短。
需通过 gzip_min_length/brotli_min_length 设置“压缩阈值”,仅对超过阈值的文件开启压缩(Nginx 默认 gzip_min_length 20,即 20 字节,建议调整为 1KB 以上):

# 仅对 > 1KB 的文件开启压缩(单位:字节)
gzip_min_length 1024;
brotli_min_length 1024;
5. 缓存与预压缩:减少“重复压缩”损耗

Nginx 默认“实时压缩”(每次请求都重新压缩资源),若资源长期不变(如静态 JS/CSS),会导致重复的 CPU 消耗。需通过以下方式优化:

  • 开启压缩缓存:通过 gzip_buffers 配置内存缓存,减少重复压缩(Nginx 默认开启,建议调整缓存块大小适配资源):
    # gzip 缓存:4 个 16KB 块(总 64KB),适配中小型文本资源
    gzip_buffers 4 16k;
    
  • 预压缩静态资源:提前通过工具(如 gzip 命令、brotli 命令)生成压缩后的资源文件(如 app.js.gzapp.js.br),Nginx 直接返回预压缩文件,避免实时压缩:
    # 优先返回预压缩的 .gz 文件(若存在)
    gzip_static on;
    # 优先返回预压缩的 .br 文件(若存在,需 brotli 模块支持)
    brotli_static on;
    
6. 服务器性能:避免“压缩耗尽 CPU 资源”

压缩(尤其是高级别压缩)会消耗 CPU 资源,若服务器 CPU 核心数少(如 1-2 核)或并发量极高(如每秒万级请求),过度压缩可能导致 CPU 使用率飙升,影响其他服务(如动态请求处理)。
需结合服务器配置调整:

  • 低配置服务器(1-2 核):使用 gzip 级别 1-3,关闭 brotli;
  • 中高配置服务器(4 核以上):使用 gzip 级别 4-6 或 brotli 级别 4-8;
  • 可通过 gzip_threads(仅部分 Nginx 版本支持)开启多线程压缩,分摊 CPU 压力:
    # 开启 2 个线程处理 gzip 压缩
    gzip_threads 2;
    

二、为何不建议对所有前端资源开启压缩?

核心原因是“部分资源压缩无收益,反而增加损耗”,具体可归纳为 3 类:

1. 压缩收益为负:体积不变或增大
  • 已压缩的二进制资源(如 PNG/JPG/MP4/ZIP):本身已通过专业算法压缩(如 JPG 的 DCT 变换、MP4 的 H.264 编码),gzip/brotli 无法进一步减小体积,甚至因“压缩头额外开销”导致体积轻微增大(如 10MB 的 MP4 压缩后可能变成 10.01MB)。
2. 性能损耗 > 传输收益
  • 极小文件(如 < 1KB 的 CSS 片段、小图标 base64 字符串):压缩耗时(即使 1ms)可能超过“压缩后减少的传输时间”(假设带宽 100Mbps,1KB 传输时间仅 0.08ms),反而拖慢整体响应速度。
3. 客户端兼容性风险
  • 若对不支持 brotli 的旧客户端(如 IE11)发送 brotli 压缩数据,客户端无法解压,会直接返回“空白页面”或“乱码”;
  • 虽可通过 Accept-Encoding 头判断,但配置不当(如遗漏 brotli_disable)仍可能出现兼容性问题,而“不压缩所有资源”是更稳妥的规避方式。

三、推荐的 gzip + brotli 配置示例

结合上述因素,以下是兼顾“性能、兼容性、收益”的配置(需确保 Nginx 已安装 brotli 模块):

http {
    # -------------------------- gzip 配置 --------------------------
    gzip on;                          # 开启 gzip
    gzip_comp_level 5;                # 平衡级别(压缩率 ~60%,CPU 消耗适中)
    gzip_min_length 1024;             # 仅压缩 >1KB 的文件
    gzip_buffers 4 16k;               # 内存缓存块
    gzip_types
        text/html text/css application/javascript
        application/json application/xml
        text/plain text/javascript;   # 仅对文本类资源压缩
    gzip_disable "MSIE [1-6]\.";      # 排除 IE6 及以下
    gzip_static on;                   # 优先使用预压缩的 .gz 文件
    gzip_vary on;                     # 向客户端返回 Vary: Accept-Encoding 头(利于 CDN 缓存)

    # -------------------------- brotli 配置 --------------------------
    brotli on;                        # 开启 brotli
    brotli_comp_level 6;              # 平衡级别(压缩率 ~70%,比 gzip 高 10%)
    brotli_min_length 1024;           # 同 gzip 阈值
    brotli_types
        text/html text/css application/javascript
        application/json application/xml
        text/plain text/javascript;   # 仅对文本类资源压缩
    brotli_disable "MSIE [1-6]\.|Firefox/[1-43]\.";  # 排除不支持的旧浏览器
    brotli_static on;                 # 优先使用预压缩的 .br 文件
    brotli_vary on;                   # 同 gzip_vary
}

总结

配置 Nginx 压缩的核心逻辑是“针对性优化”:

  1. 只对“文本类资源”(HTML/CSS/JS/JSON)开启压缩,对“二进制资源”(图片/视频/压缩包)坚决关闭;
  2. 选择“平衡的压缩级别”,避免过度消耗 CPU;
  3. 通过“预压缩”和“缓存”减少实时压缩损耗;
  4. 兼容旧客户端,避免因压缩导致的访问异常。

这种策略既能最大化压缩带来的“带宽节省、加载加速”收益,又能最小化服务器性能损耗和兼容性风险。

1145. 微前端通过 Nginx 实现主 / 子应用路由分发,如何配置 location 和 try_files?需处理哪些资源路径问题?【热度: 120】【web 应用场景】

微前端通过 Nginx 实现主/子应用路由分发时,核心是通过 location 匹配不同应用的路由路径,并结合 try_files 处理 SPA 路由刷新 404 问题。同时需解决子应用资源路径、主/子应用路由冲突等关键问题。以下是具体实现方案:

一、基础场景:主应用与子应用通过路径前缀区分

假设:

  • 主应用路由:https://example.com/(根路径)
  • 子应用 A 路由:https://example.com/app1/(前缀 /app1
  • 子应用 B 路由:https://example.com/app2/(前缀 /app2
1. 目录结构(前端资源存放)
/var/www/
├── main-app/          # 主应用打包文件
│   ├── index.html
│   ├── static/
│   └── ...
├── app1/              # 子应用 A 打包文件
│   ├── index.html
│   ├── static/
│   └── ...
└── app2/              # 子应用 B 打包文件
    ├── index.html
    └── ...
2. Nginx 核心配置(location + try_files)
server {
    listen 80;
    server_name example.com;
    root /var/www;  # 父目录(包含所有应用)

    # 1. 主应用路由(根路径 /)
    location / {
        # 主应用实际目录为 /var/www/main-app
        alias /var/www/main-app/;
        index index.html;

        # 解决主应用 History 路由刷新 404
        # 逻辑:优先匹配物理文件,匹配不到则返回主应用 index.html
        try_files $uri $uri/ /main-app/index.html;
    }

    # 2. 子应用 A 路由(/app1 前缀)
    location /app1 {
        # 子应用 A 实际目录为 /var/www/app1
        alias /var/www/app1/;
        index index.html;

        # 解决子应用 A History 路由刷新 404
        # 注意:try_files 最后需指向子应用自己的 index.html
        try_files $uri $uri/ /app1/index.html;
    }

    # 3. 子应用 B 路由(/app2 前缀)
    location /app2 {
        alias /var/www/app2/;
        index index.html;
        try_files $uri $uri/ /app2/index.html;
    }
}

二、关键配置解析

1. aliasroot 的选择
  • 必须使用 alias:子应用路径(如 /app1)与实际目录(/var/www/app1)是“映射关系”,alias 会将 /app1 直接替换为实际目录(如请求 /app1/static.js 映射到 /var/www/app1/static.js)。
  • 若误用 rootroot /var/www 会在请求路径后拼接目录(/app1/static.js 会映射到 /var/www/app1/static.js,看似可行,但子应用内路由跳转可能出现异常)。
2. try_files 的路径规则
  • 主应用:try_files $uri $uri/ /main-app/index.html
    最后一个参数必须是主应用 index.html绝对路径(相对于 Nginx 根目录),确保主应用路由(如 /home)刷新时返回主应用入口。
  • 子应用:try_files $uri $uri/ /app1/index.html
    最后一个参数必须是子应用自己的 index.html(如 /app1/index.html),否则子应用路由(如 /app1/detail)刷新会返回主应用入口,导致路由错乱。

三、需处理的资源路径问题

微前端路由分发的核心坑点是资源路径引用错误,需从 Nginx 配置和前端打包两方面协同解决:

1. 子应用静态资源路径错误(404)

问题:子应用打包时若使用绝对路径(如 src="/static/js/app1.js"),会被解析为 https://example.com/static/js/app1.js,但实际路径应为 https://example.com/app1/static/js/app1.js,导致 404。

解决方案

  • 前端打包配置:子应用需设置 publicPath 为自身路径前缀(如 /app1/):
    • Vue 项目:vue.config.jspublicPath: '/app1/'
    • React 项目:package.jsonhomepage: '/app1'webpack.config.jsoutput.publicPath: '/app1/'
  • 效果:资源引用会自动添加 /app1 前缀(如 src="/app1/static/js/app1.js"),匹配 Nginx 配置的 alias 路径。
2. 主/子应用路由冲突

问题:若主应用存在 /app1 路由,会与子应用的 /app1 路径冲突,导致主应用路由被 Nginx 拦截并转发到子应用。

解决方案

  • 路由命名规范:子应用路径前缀需全局唯一(如 /micro-app1/micro-app2),避免与主应用路由重名。

  • Nginx 优先级控制:若必须使用相同前缀,可通过 location 精确匹配优先处理主应用路由:

    # 主应用的 /app1 路由(精确匹配,优先级高于子应用的 /app1 前缀匹配)
    location = /app1 {
        alias /var/www/main-app/;
        try_files $uri $uri/ /main-app/index.html;
    }
    
    # 子应用 /app1 前缀路由(优先级低)
    location /app1/ {
        alias /var/www/app1/;
        try_files $uri $uri/ /app1/index.html;
    }
    
3. 子应用接口请求路径错误

问题:子应用接口请求(如 /api/data)会被发送到 https://example.com/api/data,若需区分子应用接口(如 https://example.com/app1/api/data),需调整代理规则。

解决方案

  • 前端统一前缀:子应用接口请求添加自身路径前缀(如 axios.defaults.baseURL = '/app1/api')。
  • Nginx 代理转发
    # 子应用 A 的接口代理
    location /app1/api {
        # 移除 /app1 前缀后转发到后端(如后端接口实际路径为 /api)
        proxy_pass http://backend-server/api;
        proxy_set_header Host $host;
    }
    
4. 子应用懒加载路由资源 404

问题:子应用使用路由懒加载时(如 Vue/React 的 import('./page.vue')),打包后的 chunk 文件路径可能未包含子应用前缀,导致加载失败。

解决方案

  • 确保懒加载的 chunk 路径也使用 publicPath 配置的前缀,现代打包工具(Webpack 5+、Vite)会自动处理,只需正确设置 publicPath 即可。

四、复杂场景:子应用嵌套或动态路径

若子应用包含动态路由(如 /app1/user/:id)或嵌套路由(如 /app1/dashboard/settings),Nginx 配置无需额外调整,因为 location /app1 会匹配所有以 /app1 开头的路径,try_files 会统一指向子应用 index.html,路由解析由子应用前端框架完成。

五、总结

微前端 Nginx 路由分发的核心配置要点:

  1. location /app1 匹配子应用路径,alias 指向实际目录。
  2. try_files $uri $uri/ /app1/index.html 解决子应用 History 路由刷新 404。
  3. 必须处理的资源问题:
    • 子应用打包设置 publicPath 为路径前缀(如 /app1/)。
    • 避免主/子应用路由重名,接口请求添加子应用前缀。

通过以上配置,可实现主/子应用路由的无冲突分发,确保静态资源和路由正常访问。

1146. Nginx 配置 HTTPS 需哪些核心项(协议、加密套件)?如何强制 HTTP 跳转 HTTPS?【热度: 182】【web 应用场景】

关键词:nginx 配置 https

Nginx 配置 HTTPS 需重点关注协议版本、加密套件、证书配置等核心项,同时需正确设置 HTTP 到 HTTPS 的强制跳转。以下是详细配置方案:

一、HTTPS 核心配置项(协议、加密套件等)

HTTPS 配置的核心目标是确保安全性(禁用不安全协议和加密套件)和兼容性(支持主流浏览器),关键配置项缺一不可:

1. 证书与私钥配置(必备)

需指定 SSL 证书(公钥)和私钥文件路径,证书需由可信 CA 机构签发(如 Let's Encrypt、阿里云等):

server {
    listen 443 ssl;  # 监听 443 端口并启用 SSL
    server_name example.com;  # 证书绑定的域名

    # 证书文件路径(PEM 格式)
    ssl_certificate /path/to/fullchain.pem;  # 包含服务器证书和中间证书
    ssl_certificate_key /path/to/privkey.pem;  # 服务器私钥
}
2. 协议版本(禁用不安全协议)

需明确启用现代安全协议,禁用已被破解或不安全的旧协议(如 SSLv2、SSLv3、TLSv1.0、TLSv1.1):

# 仅启用 TLSv1.2 和 TLSv1.3(目前最安全的协议版本)
ssl_protocols TLSv1.2 TLSv1.3;
  • 为何禁用旧协议
    TLSv1.0/1.1 存在安全漏洞(如 BEAST 攻击),且不支持现代加密套件;SSL 协议已完全过时,必须禁用。
3. 加密套件(优先选择强加密算法)

加密套件决定数据传输的加密方式,需优先选择支持前向 secrecy(完美前向保密)AES-GCM 等强加密算法的套件:

# 现代浏览器兼容的强加密套件(TLSv1.2+)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

# 优先使用服务器端的加密套件选择(增强安全性)
ssl_prefer_server_ciphers on;
  • 核心原则
    避免使用 RSA 密钥交换(无 Forward Secrecy)和 CBC 模式加密(存在漏洞),优先 ECDHE 密钥交换 + GCM 模式。
4. 性能与安全性优化项
# SSL 会话缓存(减少握手耗时,提升性能)
ssl_session_cache shared:SSL:10m;  # 共享缓存,容量 10MB(约 40000 个会话)
ssl_session_timeout 1d;  # 会话超时时间(1天)

# 启用 HSTS(强制客户端后续使用 HTTPS 访问,防降级攻击)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# 禁用 SSL 压缩(防止 CRIME 攻击)
ssl_compression off;

# 启用 OCSP Stapling(减少证书验证步骤,提升加载速度)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /path/to/fullchain.pem;  # 与 ssl_certificate 一致即可
resolver 8.8.8.8 114.114.114.114 valid=300s;  # DNS 解析器(用于验证 OCSP 响应)

二、强制 HTTP 跳转 HTTPS 的配置方法

需将所有 HTTP(80 端口)请求强制重定向到 HTTPS(443 端口),确保用户始终使用加密连接。推荐两种可靠方案:

1. 方案一:通过 301 永久重定向(推荐)

在 80 端口的 server 块中直接返回 301 重定向,适用于大多数场景:

# HTTP 服务器(80端口):仅用于跳转 HTTPS
server {
    listen 80;
    server_name example.com;  # 需与 HTTPS 服务器的域名一致

    # 永久重定向到 HTTPS
    return 301 https://$host$request_uri;
}
  • 优势:简单高效,搜索引擎会记住重定向,将权重转移到 HTTPS 域名。
2. 方案二:通过 rewrite 指令(灵活适配复杂场景)

若需对特定路径做特殊处理(如临时不跳转某些路径),可使用 rewrite

server {
    listen 80;
    server_name example.com;

    # 对 /api/temp 路径临时不跳转(示例)
    location /api/temp {
        # 保持 HTTP 访问(仅临时使用,不推荐长期保留)
        proxy_pass http://backend;
    }

    # 其他所有路径跳转 HTTPS
    location / {
        rewrite ^(.*)$ https://$host$1 permanent;  # permanent 等价于 301
    }
}

三、完整 HTTPS 配置示例

# HTTP 服务器:强制跳转 HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

# HTTPS 服务器:核心配置
server {
    listen 443 ssl;
    server_name example.com;

    # 证书配置
    ssl_certificate /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    # 协议与加密套件
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers on;

    # 性能与安全优化
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_compression off;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /path/to/fullchain.pem;
    resolver 8.8.8.8 114.114.114.114 valid=300s;

    # 前端资源配置(如 SPA 路由、缓存等)
    root /path/to/frontend;
    index index.html;
    location / {
        try_files $uri $uri/ /index.html;
    }
}

四、关键注意事项

  1. 证书路径正确性
    确保 ssl_certificatessl_certificate_key 指向的文件存在且权限正确(Nginx 进程需可读,建议权限 600)。

  2. HSTS 配置风险
    Strict-Transport-Security 头一旦设置,客户端会严格遵守(即使后续关闭 HTTPS 也会强制使用),需确保 HTTPS 服务长期稳定后再添加 preload 选项。

  3. 兼容性平衡
    若需支持非常旧的浏览器(如 IE 10),可临时启用 TLSv1.1,但需知晓安全风险;现代网站建议仅保留 TLSv1.2+。

  4. 配置验证
    修改配置后需执行 nginx -t 检查语法,通过 nginx -s reload 生效;可使用 SSL Labs 工具 检测配置安全性(目标评分 A+)。

总结

HTTPS 核心配置包括:

  • 证书与私钥(基础)、TLSv1.2+ 协议(安全)、强加密套件(防破解);
  • 强制跳转通过 301 重定向实现,确保所有 HTTP 请求转向 HTTPS。

合理配置可在安全性、兼容性和性能之间取得平衡,是现代网站的必备实践。

1147. 前端静态资源加载超时,Nginx 可通过哪些配置优化?【热度: 190】【web 应用场景】【出题公司: 阿里巴巴】

关键词:nginx 加载超时优化

前端静态资源(如 JS、CSS、图片、视频等)加载超时,通常与网络传输效率服务器响应速度资源处理策略相关。Nginx 可通过针对性配置优化传输效率、延长超时阈值、减少阻塞风险,从而解决超时问题。以下是具体优化方案:

一、延长关键超时时间(避免传输中断)

针对大资源(如视频、大型 JS 包)或弱网络环境,默认超时时间可能不足,需调整以下参数:

server {
    # 1. 客户端与服务器建立连接的超时(握手阶段)
    client_header_timeout 120s;  # 等待客户端发送请求头的超时(默认 60s,延长至 2 分钟)
    client_body_timeout 120s;    # 等待客户端发送请求体的超时(默认 60s)

    # 2. 服务器向客户端发送响应的超时(传输阶段,核心!)
    send_timeout 300s;  # 大文件传输时,服务器发送数据的超时(默认 60s,延长至 5 分钟)

    # 3. 长连接保持时间(复用连接,减少重复握手开销)
    keepalive_timeout 120s;  # 连接空闲后保持的时间(默认 75s,延长至 2 分钟)
    keepalive_requests 200;  # 单个长连接可处理的请求数(默认 100,提高至 200)
}

关键逻辑send_timeout 是防止大资源传输中断的核心参数(如 100MB 的视频文件,弱网环境可能需要几分钟传输),需根据资源最大体积和目标用户网络环境调整。

二、优化资源传输效率(减少传输耗时)

通过零拷贝数据合并压缩等技术,减少资源在服务器与客户端之间的传输时间:

1. 启用零拷贝与 TCP 优化
location ~* \.(js|css|png|jpg|jpeg|webp|mp4)$ {
    # 零拷贝:直接从磁盘读取文件发送到网络,跳过用户态-内核态数据拷贝(核心优化!)
    sendfile on;

    # 配合 sendfile 使用,积累数据后一次性发送,减少网络包数量(提升大文件传输效率)
    tcp_nopush on;

    # 禁用 Nagle 算法(减少小数据包延迟,适合动态内容,但大文件建议关闭)
    tcp_nodelay off;
}
2. 启用压缩(减小传输体积)

对文本类资源(JS/CSS/HTML)启用 gzip 或 brotli 压缩,对图片等二进制资源确保已预压缩(如 WebP 格式):

# 全局压缩配置
gzip on;
gzip_comp_level 5;  # 压缩级别 1-9(5 为平衡值)
gzip_min_length 1024;  # 仅压缩 >1KB 的文件(小文件压缩收益低)
gzip_types
    text/html text/css application/javascript
    application/json image/svg+xml;  # 仅压缩文本类资源

# 若 Nginx 安装了 brotli 模块(压缩率高于 gzip)
brotli on;
brotli_comp_level 6;
brotli_types text/css application/javascript;
3. 预压缩静态资源(避免实时压缩耗时)

提前对静态资源进行压缩(如 app.jsapp.js.gz),Nginx 直接返回预压缩文件,减少实时压缩的 CPU 消耗和延迟:

location ~* \.(js|css)$ {
    gzip_static on;  # 优先返回 .gz 预压缩文件(需手动生成或通过打包工具生成)
    brotli_static on;  # 优先返回 .br 预压缩文件
}

三、优化文件读取效率(减少服务器内部延迟)

静态资源加载超时可能是服务器磁盘 I/O 慢文件打开频繁导致,可通过缓存文件描述符优化:

# 缓存打开的文件描述符(减少重复打开文件的磁盘 I/O 耗时)
open_file_cache max=10000 inactive=30s;  # 最多缓存 10000 个文件,30s 未访问则移除
open_file_cache_valid 60s;  # 每 60s 验证一次缓存有效性
open_file_cache_min_uses 2;  # 文件被访问至少 2 次才加入缓存
open_file_cache_errors on;  # 缓存"文件不存在"的错误(避免重复检查)

效果:频繁访问的静态资源(如首页 JS/CSS)会被缓存描述符,后续请求无需再次读取磁盘,响应速度提升 50%+。

四、限制并发与请求大小(避免服务器过载)

服务器资源耗尽(CPU/内存/磁盘 I/O 满)会导致响应延迟,需通过配置限制并发压力:

1. 限制单个请求体大小

防止超大文件请求阻塞服务器(如恶意上传 1GB 无效文件):

# 全局限制:单个请求体最大 100MB(根据业务调整,如图片站可设 50MB)
client_max_body_size 100m;

# 针对视频等超大资源单独限制
location /videos {
    client_max_body_size 500m;  # 视频文件最大 500MB
}
2. 调整 worker 进程与连接数

充分利用服务器 CPU 资源,提升并发处理能力:

# 在 nginx.conf 全局配置中
worker_processes auto;  # 自动设置为 CPU 核心数(如 4 核服务器则启动 4 个进程)
worker_connections 10240;  # 每个 worker 最大连接数(默认 1024,提高至 10240)
multi_accept on;  # 允许每个 worker 同时接受多个新连接

五、CDN 与资源分片配合(彻底解决跨地域超时)

若用户分布在不同地域,仅靠源站优化效果有限,需结合:

  1. 静态资源托管到 CDN
    将 JS/CSS/图片等静态资源上传至 CDN(如 Cloudflare、阿里云 CDN),CDN 节点就近分发,减少跨地域传输延迟。
    Nginx 需配置允许 CDN 缓存:

    location ~* \.(js|css|png)$ {
        add_header Cache-Control "public, max-age=31536000";  # 允许 CDN 长期缓存
        add_header Access-Control-Allow-Origin *;  # 解决 CDN 跨域问题
    }
    
  2. 大文件分片传输
    对视频等超大型文件(>100MB),前端通过 Range 请求分片下载(如每次请求 10MB),Nginx 需支持 Range 分片(默认支持,无需额外配置):

    location /videos {
        add_header Accept-Ranges bytes;  # 显式声明支持分片(默认已开启)
    }
    

六、完整优化配置示例

# nginx.conf 全局配置
worker_processes auto;
worker_connections 10240;
multi_accept on;

http {
    # 压缩配置
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_types text/html text/css application/javascript;
    gzip_static on;

    # 文件描述符缓存
    open_file_cache max=10000 inactive=30s;
    open_file_cache_valid 60s;
    open_file_cache_min_uses 2;

    server {
        listen 80;
        server_name example.com;

        # 超时配置
        client_header_timeout 120s;
        client_body_timeout 120s;
        send_timeout 300s;
        keepalive_timeout 120s;
        keepalive_requests 200;

        # 请求体大小限制
        client_max_body_size 100m;

        # 静态资源优化
        location ~* \.(js|css|png|jpg|jpeg|webp|mp4)$ {
            root /path/to/frontend;
            sendfile on;
            tcp_nopush on;
            tcp_nodelay off;
            expires 30d;  # 浏览器缓存,减少重复请求
        }

        # 视频等大资源单独配置
        location /videos {
            client_max_body_size 500m;
            add_header Accept-Ranges bytes;
        }
    }
}

总结

Nginx 优化静态资源加载超时的核心思路是:

  1. 延长传输超时send_timeout),适应大资源和弱网络;
  2. 提升传输效率(零拷贝、压缩、预压缩),减少传输时间;
  3. 优化服务器性能(文件缓存、并发调整),减少内部延迟;
  4. 结合 CDN 与分片,解决跨地域传输问题。

通过多层优化,可显著降低静态资源加载超时概率,提升前端页面加载体验。

1148. Nginx 如何为不同前端资源配置缓存策略?如何强制刷新特定资源?【热度: 110】【web 应用场景】

关键词:nginx 加载特定资源

Nginx 为不同前端资源配置缓存策略的核心是根据资源特性(是否常变、是否带版本标识)差异化设置缓存规则,同时通过特定机制实现特定资源的强制刷新。以下是详细方案:

一、按资源类型配置差异化缓存策略

前端资源可分为静态资源(JS、CSS、图片等)和入口文件(如 index.html),需根据其更新频率和版本管理方式设置不同缓存策略:

1. 带哈希/版本号的静态资源(永久强缓存)

特征:文件名含唯一哈希(如 app.8f3b.js)或版本号(如 v2/style.css),内容变化时文件名必变。
策略:设置长期强缓存,减少重复请求。

# 匹配带哈希的 JS/CSS/图片(假设哈希为 8-16 位字符)
location ~* \.\w{8,16}\.(js|css|png|jpg|jpeg|webp|svg)$ {
    # 缓存 1 年(31536000 秒)
    expires 365d;
    # 强缓存标识:浏览器直接使用本地缓存,不发送请求
    add_header Cache-Control "public, max-age=31536000, immutable";
}
  • 关键参数immutable(H5 新特性)告知浏览器资源不会变化,避免发送无效的条件请求(如 If-Modified-Since)。
2. 无哈希的静态资源(短期强缓存 + 协商缓存)

特征:文件名固定(如 favicon.icocommon.js),可能不定期更新但无版本标识。
策略:短期强缓存减少请求,过期后通过协商缓存验证是否更新。

# 匹配无哈希的图片、字体等
location ~* \.(png|jpg|jpeg|ico|woff2?)$ {
    # 短期强缓存 7 天
    expires 7d;
    # 过期后必须验证是否更新
    add_header Cache-Control "public, max-age=604800, must-revalidate";
}
3. 入口文件与动态页面(协商缓存)

特征:如 index.htmlpage.html,作为路由入口或动态内容载体,需确保用户获取最新版本。
策略:禁用强缓存,每次请求通过协商缓存验证。

# 入口文件(如 index.html)
location = /index.html {
    # 禁用强缓存(立即过期)
    expires -1;
    # 协商缓存:必须向服务器验证
    add_header Cache-Control "no-cache, must-revalidate";
}

# 其他 HTML 页面
location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
}
  • 协商缓存原理:Nginx 自动返回 Last-Modified(文件修改时间),浏览器下次请求携带 If-Modified-Since,服务器比对后返回 304(未修改)或 200(新内容)。
4. API 接口与动态数据(无缓存或短时缓存)

特征:如 /api/user,返回动态数据,需实时性。
策略:禁用缓存或设置极短缓存时间。

# API 接口
location /api {
    # 完全禁用缓存
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    expires -1;
    # 转发到后端服务
    proxy_pass http://backend;
}

二、强制刷新特定资源的方法

当资源更新但因缓存未生效时,需强制用户获取最新版本,核心思路是破坏缓存标识主动清理缓存

1. 前端主动更新资源标识(推荐)

利用“哈希/版本号与内容绑定”的特性,资源更新时修改文件名,浏览器会视为新资源自动请求:

  • 例:app.8f3b.js → 更新后变为 app.9c4d.js,无需 Nginx 配置,彻底避免缓存问题。
2. 通过 URL 参数强制刷新(临时方案)

对无哈希的资源,可在请求 URL 后添加随机参数(如 ?v=2),使浏览器认为是新资源:

  • 例:common.jscommon.js?v=2
  • Nginx 无需额外配置,但需前端手动更新参数,适合临时紧急更新。
3. 清理 CDN 缓存(若使用 CDN)

若资源通过 CDN 分发,需在 CDN 控制台手动清理特定资源缓存:

  • 例:阿里云 CDN 支持按路径(如 /*/*.js)或具体 URL 清理缓存,生效后用户请求会回源获取最新资源。
4. 动态修改资源的 Last-Modified(不推荐)

通过 Nginx 指令强制修改资源的 Last-Modified 头,触发协商缓存更新:

# 强制刷新某个资源(如 common.js)
location = /static/js/common.js {
    # 手动设置一个较新的修改时间(比实际文件新)
    add_header Last-Modified "Wed, 20 Sep 2025 08:00:00 GMT";
    # 协商缓存配置
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
}
  • 缺点:需手动修改 Nginx 配置并 reload,仅适合紧急情况,不建议长期使用。

三、完整缓存配置示例

server {
    listen 80;
    server_name example.com;
    root /path/to/frontend;

    # 1. 带哈希的静态资源(永久缓存)
    location ~* \.\w{8,16}\.(js|css|png|jpg|jpeg|webp|svg)$ {
        expires 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # 2. 无哈希的静态资源(短期+协商)
    location ~* \.(png|jpg|jpeg|ico|woff2?)$ {
        expires 7d;
        add_header Cache-Control "public, max-age=604800, must-revalidate";
    }

    # 3. 入口文件与 HTML(协商缓存)
    location = /index.html {
        expires -1;
        add_header Cache-Control "no-cache, must-revalidate";
    }

    # 4. API 接口(无缓存)
    location /api {
        add_header Cache-Control "no-store, no-cache";
        expires -1;
        proxy_pass http://backend;
    }

    # SPA 路由支持(配合 History 模式)
    location / {
        try_files $uri $uri/ /index.html;
    }
}

四、关键注意事项

  1. 缓存与版本管理协同:前端打包工具(Webpack/Vite)需确保“内容变则哈希变”,与 Nginx 强缓存配合,这是最可靠的刷新方式。
  2. 避免缓存 index.html:入口文件必须用协商缓存,否则用户可能无法获取新的哈希资源列表。
  3. HTTPS 环境下的缓存:若启用 HTTPS,需确保 Cache-Control 头正确传递(Nginx 默认不拦截),避免 CDN 或代理服务器篡改缓存策略。

总结

  • 差异化缓存:带哈希资源用永久强缓存,无哈希资源用短期+协商缓存,入口文件和 API 禁用强缓存。
  • 强制刷新:优先通过修改资源哈希/版本号实现,临时场景可用 URL 参数,CDN 资源需手动清理 CDN 缓存。

这种策略既能最大化利用缓存提升性能,又能确保资源更新及时生效。

🔥🔥🔥Vue部署踩坑全记录:publicPath和base到底啥区别?99%的前端都搞错过!

2025年9月7日 09:38

引言

在Vue项目开发和部署过程中,很多开发者都会遇到这样的困扰:本地开发时一切正常,但项目打包部署到服务器后却出现白屏、资源加载失败、路由跳转异常等问题。这些问题的根源往往在于路径配置不当。本文将深入解析Vue项目中的两个重要配置项:vue.config.js中的publicPath和Vue Router中的base,通过实际案例帮助您彻底理解它们的区别与联系。

一、认识publicPath与base

1.1 publicPath是什么?

publicPath是Vue CLI项目中vue.config.js的配置项,用于指定应用部署的基本URL路径。它决定了打包后静态资源(JS、CSS、图片等)的引用路径。

官方定义:部署应用包时的基本 URL。用法和 webpack 本身的 output.publicPath 一致,但是 Vue CLI 在一些其他地方也需要用到这个值,所以请始终使用 publicPath 而不要直接修改 webpack 的 output.publicPath

1.2 base是什么?

base是Vue Router的配置项,用于指定应用的基路径。当单页应用部署在非根目录时,需要通过设置base来确保路由的正确解析。

官方定义:应用的基路径。例如,如果整个单页应用服务在 /app/ 下,然后 base 就应该设为 "/app/"

二、两者的核心区别

特性 publicPath base
作用对象 静态资源路径 路由路径
配置位置 vue.config.js Vue Router配置
影响范围 资源加载 路由跳转
默认值 '/' '/'
使用场景 资源引用路径 路由匹配路径

2.1 功能差异详解

publicPath主要解决的是"资源在哪里"的问题:

  • 影响打包后index.html中引用的JS、CSS等资源路径
  • 决定开发环境下静态资源的访问路径
  • 控制webpack输出资源的公共路径

base主要解决的是"路由怎么匹配"的问题:

  • 为所有路由路径添加前缀
  • 确保路由在子目录部署时能正确解析
  • 影响路由的跳转和匹配逻辑

三、实际案例分析

3.1 案例背景

假设我们有一个Vue项目,包含两个页面:

  • 首页(Home):/
  • 关于页面(About):/about

项目需要部署到服务器的子目录/my-app/下,服务器地址为http://example.com

3.2 无任何配置的问题

部署情况:项目打包后部署到http://example.com/my-app/

访问结果

  • 访问http://example.com/my-app/:页面空白,控制台报错Failed to load resource: the server responded with a status of 404
  • 资源加载路径:http://example.com/js/app.js(错误,少了/my-app/)
  • 路由跳转:点击路由链接时路径为http://example.com/about(错误,少了/my-app/)

问题分析

  1. 资源加载失败:因为publicPath默认为/,资源引用路径为绝对路径,直接从域名根目录查找
  2. 路由跳转异常:因为base默认为/,路由路径没有包含子目录前缀

3.3 只配置publicPath

配置代码

// vue.config.js
module.exports = {
  publicPath: '/my-app/'
}

部署结果

  • 资源加载:http://example.com/my-app/js/app.js(正确)
  • 路由跳转:仍然为http://example.com/about(错误)

问题分析: 资源加载问题解决了,但路由跳转仍然有问题,因为base还未配置。

3.4 只配置base

配置代码

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: '/my-app/',
  routes: [...]
})

部署结果

  • 资源加载:http://example.com/js/app.js(错误)
  • 路由跳转:http://example.com/my-app/about(正确)

问题分析: 路由跳转问题解决了,但资源加载仍然有问题,因为publicPath还未配置。

3.5 同时配置publicPath和base

完整配置

// vue.config.js
module.exports = {
  publicPath: '/my-app/'
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: '/my-app/',
  routes: [...]
})

部署结果

  • 资源加载:http://example.com/my-app/js/app.js(正确)
  • 路由跳转:http://example.com/my-app/about(正确)
  • 页面访问:http://example.com/my-app/(正常显示)

四、进阶配置技巧

4.1 环境区分配置

// vue.config.js
module.exports = {
  publicPath: process.env.NODE_ENV === 'production' 
    ? '/my-app/' 
    : '/'
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: process.env.NODE_ENV === 'production' 
    ? '/my-app/' 
    : '/',
  routes: [...]
})

4.2 使用环境变量

# .env.production
VUE_APP_PUBLIC_PATH=/my-app/
VUE_APP_ROUTER_BASE=/my-app/
// vue.config.js
module.exports = {
  publicPath: process.env.VUE_APP_PUBLIC_PATH || '/'
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: process.env.VUE_APP_ROUTER_BASE || '/',
  routes: [...]
})

4.3 相对路径配置

在某些特殊场景下,可以使用相对路径:

// vue.config.js
module.exports = {
  publicPath: './'
}

注意:使用相对路径有局限性,不推荐在使用HTML5 history模式或构建多页面应用时使用。

五、常见问题排查

5.1 白屏问题

症状:页面空白,控制台显示资源加载404错误 排查步骤

  1. 检查浏览器开发者工具中的Network标签
  2. 确认资源请求路径是否正确
  3. 检查publicPath配置是否与部署路径一致

5.2 路由刷新404

症状:路由跳转正常,但刷新页面显示404 原因:服务器未配置history模式Fallback 解决方案

  • Nginx配置:
location /my-app/ {
  try_files $uri $uri/ /my-app/index.html;
}

5.3 资源路径错误

症状:部分资源加载失败,路径明显错误 排查方法

  1. 检查资源引用是否使用绝对路径
  2. 确认publicPath配置正确
  3. 检查是否有硬编码的路径

六、最佳实践建议

6.1 配置原则

  1. 同时配置:子目录部署时,publicPath和base必须同时配置
  2. 保持一致:两者的路径值应该保持一致
  3. 环境区分:开发环境和生产环境使用不同配置
  4. 避免硬编码:使用环境变量管理路径配置

6.2 部署 checklist

  • 确认部署路径
  • 配置publicPath
  • 配置base
  • 检查路由模式(hash/history)
  • 配置服务器重写规则(history模式)
  • 测试资源加载
  • 测试路由跳转
  • 测试页面刷新

七、总结

理解publicPath和base的区别与联系,对于Vue项目的正确部署至关重要:

  • publicPath解决的是资源加载路径问题
  • base解决的是路由匹配路径问题
  • 子目录部署时,两者需要同时配置且保持一致
  • 通过环境变量管理不同环境的配置

掌握这两个配置项的使用,可以避免90%以上的Vue项目部署问题。希望本文能帮助您在今后的项目开发中,更加游刃有余地处理路径配置相关的挑战。

参考资料

事件流:深入理解事件冒泡、事件捕获与事件委托

作者 Lingxing
2025年9月6日 23:51

事件流:深入理解事件冒泡、事件捕获与事件委托

掌握事件冒泡、事件捕获和事件委托不仅能帮助我们编写更高效的代码,还能解决许多实际开发中的复杂问题。

DOM事件流:三个阶段

当一个事件发生时,它会在DOM树中经历三个不同的阶段:

  1. 事件捕获阶段:从window对象向下传播到目标元素
  2. 目标阶段:事件到达目标元素
  3. 事件冒泡阶段:从目标元素向上传播回window对象
<!DOCTYPE html>
<html>
<head>
  <title>事件流演示</title>
  <style>
    div { padding: 20px; margin: 10px; border: 1px solid #ccc; }
    #outer { background-color: #fdd; }
    #middle { background-color: #dfd; }
    #inner { background-color: #ddf; }
  </style>
</head>
<body>
  <div id="outer">外层
    <div id="middle">中间
      <div id="inner">内层</div>
    </div>
  </div>

  <script>
    function logEvent(event) {
      console.log(`${event.currentTarget.id} 触发事件: ${event.eventPhase === 1 ? '捕获' : event.eventPhase === 2 ? '目标' : '冒泡'}`);
    }

    const elements = document.querySelectorAll('div');
    
    // 注册捕获阶段事件(第三个参数为true)
    elements.forEach(elem => {
      elem.addEventListener('click', logEvent, true);
    });
    
    // 注册冒泡阶段事件(第三个参数为false或省略)
    elements.forEach(elem => {
      elem.addEventListener('click', logEvent, false);
    });
  </script>
</body>
</html>

事件冒泡 (Event Bubbling)

事件冒泡是默认的事件传播机制。当事件在目标元素上触发后,它会沿着DOM树向上传播,依次触发每个祖先元素上的同类事件。

// 事件冒泡示例
document.getElementById('inner').addEventListener('click', function() {
  console.log('内层元素被点击');
});

document.getElementById('middle').addEventListener('click', function() {
  console.log('中间元素被点击');
});

document.getElementById('outer').addEventListener('click', function() {
  console.log('外层元素被点击');
});

// 点击内层元素时,控制台将输出:
// 内层元素被点击
// 中间元素被点击
// 外层元素被点击

事件捕获 (Event Capturing)

与事件冒泡相反,事件捕获是从最外层元素开始,沿着DOM树向下传播,直到到达目标元素。

// 事件捕获示例
document.getElementById('inner').addEventListener('click', function() {
  console.log('内层元素被点击');
}, true); // 第三个参数为true,表示在捕获阶段处理

document.getElementById('middle').addEventListener('click', function() {
  console.log('中间元素被点击');
}, true);

document.getElementById('outer').addEventListener('click', function() {
  console.log('外层元素被点击');
}, true);

// 点击内层元素时,控制台将输出:
// 外层元素被点击
// 中间元素被点击
// 内层元素被点击

事件委托 (Event Delegation)

事件委托是一种利用事件冒泡机制的技术,它将事件处理程序绑定到父元素而不是每个子元素上。这种方法对于动态内容或大量元素特别有效。

<!DOCTYPE html>
<html>
<head>
  <title>事件委托演示</title>
</head>
<body>
  <ul id="itemList">
    <li data-id="1">项目 1</li>
    <li data-id="2">项目 2</li>
    <li data-id="3">项目 3</li>
    <li data-id="4">项目 4</li>
    <li data-id="5">项目 5</li>
  </ul>
  
  <button id="addButton">添加新项目</button>

  <script>
    const itemList = document.getElementById('itemList');
    const addButton = document.getElementById('addButton');
    let counter = 5;

    // 使用事件委托处理所有li的点击事件
    itemList.addEventListener('click', function(event) {
      // 检查点击的元素是否是LI或者是LI的子元素
      let target = event.target;
      while (target && target !== itemList) {
        if (target.tagName === 'LI') {
          console.log(`点击了项目: ${target.textContent}, ID: ${target.dataset.id}`);
          // 可以在这里添加具体的处理逻辑
          target.classList.toggle('selected');
          break;
        }
        target = target.parentNode;
      }
    });

    // 添加新项目
    addButton.addEventListener('click', function() {
      counter++;
      const newItem = document.createElement('li');
      newItem.textContent = `项目 ${counter}`;
      newItem.dataset.id = counter;
      itemList.appendChild(newItem);
    });
  </script>
</body>
</html>

实际应用场景

1. 阻止事件传播

// 阻止事件冒泡
document.getElementById('inner').addEventListener('click', function(event) {
  console.log('内层元素被点击,但不会冒泡');
  event.stopPropagation();
});

// 阻止默认行为并阻止事件传播
document.getElementById('myLink').addEventListener('click', function(event) {
  event.preventDefault();
  event.stopPropagation();
  console.log('链接被点击,但不会跳转也不会冒泡');
});

2. 性能优化:大量元素处理

// 传统方式:为每个元素绑定事件(性能差)
const items = document.querySelectorAll('.item');
items.forEach(item => {
  item.addEventListener('click', handleClick);
});

// 事件委托方式:只需一个事件处理程序(性能好)
document.getElementById('container').addEventListener('click', function(event) {
  if (event.target.classList.contains('item')) {
    handleClick(event);
  }
});

3. 动态内容处理

// 对于动态添加的元素,事件委托仍然有效
function addNewItem(text) {
  const newItem = document.createElement('div');
  newItem.className = 'item';
  newItem.textContent = text;
  document.getElementById('container').appendChild(newItem);
  
  // 不需要为新元素单独绑定事件处理程序
  // 父元素上的事件委托会自动处理
}

// 初始化容器的事件委托
document.getElementById('container').addEventListener('click', function(event) {
  if (event.target.classList.contains('item')) {
    console.log('点击了项目:', event.target.textContent);
  }
});

总结

经过十年的开发经验,我深刻体会到:

  1. 事件冒泡是默认的机制,适用于大多数场景
  2. 事件捕获在某些特定场景下非常有用,但使用较少
  3. 事件委托是优化性能和处理动态内容的强大技术
  4. 理解事件流可以帮助我们更好地控制事件处理顺序和行为

掌握这些概念不仅能让代码更加高效,还能解决许多复杂的前端交互问题。希望这篇文章能帮助你更深入地理解DOM事件流的工作原理和实际应用。

JavaScript 入门精要:从变量到对象,构建稳固基础

作者 San30
2025年9月6日 22:28

本文系统梳理 JavaScript 核心概念,涵盖数据类型、变量声明、对象操作等基础知识,助你打下坚实 JS 基础。

一、代码书写位置与基本语法

在浏览器环境中,JavaScript 代码有两种书写方式:

<!-- 方式1:直接写在script标签中 -->
<script>
  console.log("这是内部JS代码");
</script>

<!-- 方式2:引用外部JS文件(推荐) -->
<script src="script.js"></script>

推荐使用外部文件的原因在于代码分离原则:内容(HTML)、样式(CSS)、功能(JS)三者分离,更易于维护和阅读。

注意

  • 页面中可以存在多个 script 元素,执行顺序为从上到下
  • script 元素引用了外部文件,其内部不能再书写任何代码
  • type 属性为可选属性,用于指定代码类型

基本语法规则

// 语句以分号结束(非强制但建议)
let name = "张三";

// 大小写敏感
let age = 20;
let Age = 30; // 这是不同的变量

// 代码从上到下执行
console.log("第一行");
console.log("第二行");

输入输出语句

需要注意的是,所有的输入输出语句都不是 ES 标准。

// 输出语句示例
document.write("这是页面输出"); // 输出到页面
alert("这是弹窗提示"); // 弹窗显示
console.log("这是控制台输出"); // 输出到控制台

// 输入语句示例
let userAge = prompt("请输入你的年龄"); // 获取用户输入
console.log("用户年龄是:" + userAge);

注释的使用

注释是提供给代码阅读者使用的,不会参与执行。

// 这是单行注释

/*
  这是多行注释
  可以跨越多行
*/

// 实际代码
let score = 100; // 设置分数为100

VScode 快捷键

  • Ctrl + /: 快速添加/取消单行注释
  • Alt + Shift + A: 快速添加/取消多行注释

二、数据类型与字面量

数据是指有用的信息,数据类型则是数据的分类。

原始类型(基本类型)

原始类型指不可再细分的类型:

// 1. 数字类型 (number)
let integer = 100; // 整数
let float = 3.14; // 浮点数
let hex = 0xff; // 十六进制:255
let octal = 0o10; // 八进制:8
let binary = 0b1100; // 二进制:12

// 2. 字符串类型 (string)
let str1 = 'Hello'; // 单引号
let str2 = "World"; // 双引号
let str3 = `Hello 
World`; // 模板字符串,可以换行

// 转义字符
let escaped = "这是第一行\n这是第二行\t这里有一个制表符";

// 3. 布尔类型 (boolean)
let isTrue = true; // 真
let isFalse = false; // 假

// 4. undefined 类型
let notDefined; // 值为undefined
console.log(notDefined); // 输出: undefined

// 5. null 类型
let empty = null; // 表示空值

引用类型

// 对象 (Object)
let person = {
  name: "张三",
  age: 25,
  isStudent: false
};

// 函数 (Function)
function sayHello() {
  console.log("Hello!");
}

获取数据类型

使用 typeof 运算符可以获取数据的类型。

console.log(typeof 100); // "number"
console.log(typeof "Hello"); // "string"
console.log(typeof true); // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" (注意这是JS的著名特性)

注意typeof null 得到的是 Object,这是 JavaScript 的一个著名特性(或称为 bug)。

字面量(常量)

直接书写的具体数据称为字面量

// 数字字面量
123
3.14

// 字符串字面量
"Hello"
'World'

// 布尔字面量
true
false

// 对象字面量
{ name: "张三", age: 25 }

// 数组字面量
[1, 2, 3, 4]

三、变量的声明与使用

变量是一块内存空间,用于保存数据。

变量的使用步骤

  1. 声明变量

    var message; // 使用var声明(旧方式)
    let count; // 使用let声明(推荐)
    const PI = 3.14; // 使用const声明常量
    

    变量声明后,其值为 undefined

  2. 变量赋值

    let name; // 声明
    name = "张三"; // 赋值
    
    let age = 25; // 声明并赋值
    
  3. 声明与赋值合并

    let name = "张三", age = 25, isStudent = true; // 多个变量声明
    

    这是语法糖——仅为方便代码书写或记忆,不会有实质性改变。

变量命名规范(标识符)

标识符是需要自行命名的位置,遵循以下规范:

// 合法标识符
let userName;
let _privateData;
let $element;
let data2023;

// 不合法标识符
// let 123abc; // 不能以数字开头
// let user-name; // 不能包含连字符
// let let; // 不能使用关键字

驼峰命名法

  • 大驼峰:每个单词首字母大写 UserName
  • 小驼峰:除第一个单词外,首字母大写 userName

目前常用的是小驼峰命名法。

重要特性

  • 变量提升: var变量的声明会自动提升到代码最顶部(但不超越脚本块)
// 变量提升示例
console.log(x); // undefined (不会报错)
var x = 5;
console.log(x); // 5

// 注意:let和const不存在变量提升
// console.log(y); // 报错
let y = 10;

// 重复声明
var z = 1;
var z = 2; // 允许重复声明
console.log(z); // 2

let w = 1;
// let w = 2; // 报错,不能重复声明
  • 任何可以书写数据的地方都可以书写变量
  • 使用未声明的变量会导致错误(例外:typeof 未声明的变量得到 undefined
  • 变量提升: 所有使用var声明的变量会自动提升到代码最顶部(但不超越脚本块)
  • JS 允许使用var定义多个同名变量(提升后会合并为一个)

四、变量与对象操作

在变量中存放对象

// 创建对象
let person = {
  name: "张三",
  age: 25,
  "favorite-color": "blue" // 属性名包含特殊字符
};

// 1. 读取对象属性
console.log(person.name); // "张三"
console.log(person.age); // 25
console.log(person.hobby); // undefined (属性不存在)
// console.log(nullObj.name); // 报错 (对象不存在)

// 2. 更改对象属性
person.age = 26; // 修改属性
person.hobby = "读书"; // 添加新属性

// 3. 删除属性
delete person.age;
console.log(person.age); // undefined

// 4. 属性表达式
console.log(person["name"]); // "张三"
let propName = "age";
console.log(person[propName]); // 26 (动态访问属性)
console.log(person["favorite-color"]); // "blue" (访问含特殊字符的属性)

注意: JS 对属性名的命名不严格,属性可以是任何形式的名字,但属性名只能是字符串(数字会自动转换为字符串)。

全局对象

// 浏览器环境中
console.log(window); // 全局对象

// 开发者定义的变量会成为window对象的属性
var globalVar = "我是全局变量";
console.log(window.globalVar); // "我是全局变量"

// 但使用let/const声明的变量不会添加到window对象
let localVar = "我是局部变量";
console.log(window.localVar); // undefined
  • 浏览器环境中,全局对象为 window(表示整个窗口)
  • 全局对象的所有属性可直接使用,无需写上全局对象名
  • 使用var定义的变量实际上会成为 window 对象的属性
  • 使用let/const定义的变量不会添加到window对象

五、引用类型的深层理解

// 原始类型存放具体值
let a = 10;
let b = a; // b是a的值副本
a = 20;
console.log(a); // 20
console.log(b); // 10 (值不变)

// 引用类型存放内存地址
let obj1 = { value: 10 };
let obj2 = obj1; // obj2和obj1指向同一对象
obj1.value = 20;
console.log(obj1.value); // 20
console.log(obj2.value); // 20 (值也跟着变了)

// 对象字面量每次都会创建新对象
let o1 = {};
let o2 = {}; // 这是两个不同的对象
console.log(o1 === o2); // false
  • 原始类型变量存放的是具体值
  • 引用类型变量存放的是内存地址
  • 凡是出现对象字面量的位置(两个大括号),都会在内存中创建一个新对象

拓展知识:垃圾回收

JavaScript 拥有自动垃圾回收机制:

function createObject() {
  let obj = { value: 100 }; // 对象被创建
  return obj;
}

let myObj = createObject(); // 对象被引用,不会被回收
myObj = null; // 对象不再被引用,将成为垃圾被回收
  • 垃圾回收器会定期发现内存中无法访问的对象
  • 这些对象被称为垃圾
  • 垃圾回收器会在合适的时间释放它们占用的内存

结语

掌握 JavaScript 的基础概念是成为优秀开发者的第一步。从变量声明到对象操作,从数据类型到内存管理,这些基础知识构成了 JavaScript 编程的基石。建议初学者多加练习,深入理解每个概念背后的原理,为后续学习更高级的 JavaScript 特性打下坚实基础。

学习建议:多动手实践,尝试不同的代码组合,使用控制台查看结果,加深对每个知识点的理解。

// 实践示例:创建一个简单的个人信息对象
let person = {
  name: prompt("请输入你的姓名"),
  age: parseInt(prompt("请输入你的年龄")),
  hobbies: ["读书", "运动", "音乐"]
};

console.log("姓名:", person.name);
console.log("年龄:", person.age);
console.log("爱好数量:", person.hobbies.length);

// 动态添加新属性
let newHobby = prompt("请添加一个新爱好");
person.hobbies.push(newHobby);

console.log("更新后的爱好:", person.hobbies);

通过这样的实践,你可以更好地理解JavaScript对象和变量的工作方式。

面试取经:浏览器篇-跨标签页通信

2025年9月6日 22:09

什么是跨标签页通信

标签页之间可以进行数据传递

业内常见方案

  • BroadCast Channel
  • Service Worker
  • LocalStorage window.onstorage 监听
  • Shared Worker 定时器轮询
  • IndexDB 定时器轮询
  • cookie 定时器轮询
  • window.open、window.postMessage
  • Websocket

BroadCast Channel

BroadCast Channel 可以帮我们创建一个用于广播的通信频道。当所有页面都监听同一频道的消息时,其中某一个页面通过它发送的消息就会被其他所有页面收到。但是前提是同源页面

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>

    <script>
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      // 创建一个名:b1的通信通道
      const bc = new BroadcastChannel("b1")

      btn.onclick = function () {
        // 发送消息
        bc.postMessage({
          value: input.value,
        })
      }
    </script>
  </body>
</html>

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1 id="content"></h1>
    <script>
      const content = document.querySelector("#content")

      // 创建一个名:b1的通信通道, 与之前创建的名称保持一致
      const bc = new BroadcastChannel("b1")
      // 监听消息
      bc.onmessage = function (message) {
        console.log(message.data.value)
        content.innerHTML = message.data.value
      }
    </script>
  </body>
</html>

Service Worker

Service Worker 实际上是浏览器和服务器之间的代理服务器,它最大的特点是在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求。

Service Worker 的目的在于离线缓存,转发请求和网络代理。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>

    <script>
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      // 注册 sw
      const sw = navigator.serviceWorker
      console.log(sw)

      navigator.serviceWorker.register("./sw.js").then(() => {
        console.log("sw注册成功")
      })
      btn.onclick = function () {
        // 发送消息
        navigator.serviceWorker.controller.postMessage({
          value: input.value,
        })
      }
    </script>
  </body>
</html>

self.addEventListener("message",async event=>{
    const clients = await self.clients.matchAll();
    clients.forEach(function(client){
        client.postMessage(event.data.value)
    });
});
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1 id="content"></h1>
    <script>
      const content = document.querySelector("#content")
      // 注册 sw
      const sw = navigator.serviceWorker
      navigator.serviceWorker.register("./sw.js").then(() => {
        console.log("sw注册成功")
      })
      // 监听消息
      navigator.serviceWorker.onmessage = function ({data}) {
        content.innerHTML = data
      }
    </script>
  </body>
</html>

LocalStorage window.onstorage 监听

Web Storage 中,每次将一个值存储到本地存储时,就会触发一个 storage 事件。

由事件监听器发送给回调函数的事件对象有几个自动填充的属性如下:

  • key:告诉我们被修改的条目的键。
  • newValue:告诉我们被修改后的新值。
  • oldValue:告诉我们修改前的值。
  • storageArea:指向事件监听对应的 Storage 对象。
  • url:原始触发 storage 事件的那个网页的地址。

注意:这个事件只在同一域下的任何窗口或者标签上触发,并且只在被存储的条目改变时触发。

示例如下:这里我们需要打开服务器进行演示,本地文件无法触发 storage 事件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <script>
      localStorage.name = "john"
      localStorage.age = "18"
      console.log("信息设置完毕")
    </script>
  </body>
</html>

在上面的代码中,我们在该页面下设置了两个 localStorage 本地数据。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <script>
      window.onstorage = function (e) {
        console.log("修改的键为", e.key)
        console.log("旧值", e.oldValue)
        console.log("新值", e.newValue)
        console.log(e.storageArea)
        console.log(e.url)
      }
    </script>
  </body>
</html>

在该页面中我们安装了一个 storage 的事件监听器,安装之后只要是同一域下面的其他 storage 值发生改变,该页面下面的 storage 事件就会被触发。

Shared Worker 定时器轮询( setInterval

SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域,如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>

    <script>
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      const worker = new SharedWorker('worker.js')

      btn.onclick = function () {
        // 发送消息
        worker.port.postMessage({
          value: input.value,
        })
      }
    </script>
  </body>
</html>

let data = ''
onconnect = function(e){
    const port = e.ports[0]
    port.onmessage = function(e){
        if(e.data === 'get'){
            port.postMessage(data)
            data = ''
        }else{
            data = e.data
        }
    }
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1 id="content"></h1>
    <script>
      const content = document.querySelector("#content")
      const worker = new SharedWorker("worker.js")
      worker.port.start()
      // 监听消息

      worker.port.onmessage = function (e) {
        if (e.data) {
          content.innerHTML = e.data.value
        }
      }
      setInterval(() => {
        worker.port.postMessage("get")
      }, 1000)
    </script>
  </body>
</html>

IndexedDB 定时器轮询( setInterval

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。

通过对 IndexedDB 进行定时器轮询的方式,我们也能够实现跨标签页的通信。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <h1>新增学生</h1>
    <div>
      <span>学号</span>
      <input type="text" name="stuId" id="stuId" />
    </div>
    <div>
      <span>姓名</span>
      <input type="text" name="stuName" id="stuName" />
    </div>
    <div>
      <span>年龄</span>
      <input type="text" name="stuAge" id="stuAge" />
    </div>
    <button id="btn">提交</button>
    <script src="./db.js"></script>
    <script>
      const btn = document.querySelector("#btn")
      let stuId = document.querySelector("#stuId")
      console.log(stuId.value)

      let stuName = document.querySelector("#stuName")
      let stuAge = document.querySelector("#stuAge")

      btn.onclick = function () {
        openDB("stuDB", 1).then(db => {
          addData(db, "stu", {
            stuId: stuId.value,
            stuName: stuName.value,
            stuAge: stuAge.value,
          })
          stuId.value = stuName.value = stuAge.value = ""
        })
      }
    </script>
  </body>
</html>

// db.js
/**
 * 打开数据库
 * @param {object} dbName 数据库的名字
 * @param {string} storeName 仓库名称
 * @param {string} version 数据库的版本
 * @return {object} 该函数会返回一个数据库实例
 */
function openDB(dbName, version = 1) {
  return new Promise((resolve, reject) => {
    var db // 存储创建的数据库
    // 打开数据库,若没有则会创建
    const request = indexedDB.open(dbName, version)

    // 数据库打开成功回调
    request.onsuccess = function (event) {
      db = event.target.result // 存储数据库对象
      console.log("数据库打开成功")
      resolve(db)
    }

    // 数据库打开失败的回调
    request.onerror = function (event) {
      console.log("数据库打开报错")
    }

    // 数据库有更新时候的回调
    request.onupgradeneeded = function (event) {
      // 数据库创建或升级的时候会触发
      console.log("onupgradeneeded")
      db = event.target.result // 存储数据库对象
      var objectStore
      // 创建存储库
      objectStore = db.createObjectStore("stu", {
        keyPath: "stuId", // 这是主键
        autoIncrement: true, // 实现自增
      })
      // 创建索引,在后面查询数据的时候可以根据索引查
      objectStore.createIndex("stuId", "stuId", { unique: true })
      objectStore.createIndex("stuName", "stuName", { unique: false })
      objectStore.createIndex("stuAge", "stuAge", { unique: false })
    }
  })
}

/**
 * 新增数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} data 数据
 */
function addData(db, storeName, data) {
  var request = db
    .transaction([storeName], "readwrite") // 事务对象 指定表格名称和操作模式("只读"或"读写")
    .objectStore(storeName) // 仓库对象
    .add(data)

  request.onsuccess = function (event) {
    console.log("数据写入成功")
  }

  request.onerror = function (event) {
    console.log("数据写入失败")
  }
}

/**
 * 通过主键读取数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} key 主键值
 */
function getDataByKey(db, storeName, key) {
  return new Promise((resolve, reject) => {
    var transaction = db.transaction([storeName]) // 事务
    var objectStore = transaction.objectStore(storeName) // 仓库对象
    var request = objectStore.getAll() // 通过主键获取数据

    request.onerror = function (event) {
      console.log("事务失败")
    }

    request.onsuccess = function (event) {
      // console.log("主键查询结果: ", request.result);
      resolve(request.result)
    }
  })
}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
    <style>
      table {
        border: 1px solid;
        border-collapse: collapse;
      }
      table td {
        border: 1px solid;
      }
    </style>
  </head>
  <body>
    <h1>学生表</h1>
    <table id="table">
      <!-- <tr>
            <td>学号</td>
            <td>姓名</td>
            <td>年龄</td>
        </tr>
        <tr>
            <td>1</td>
            <td>john</td>
            <td>18</td>
        </tr>
        <tr>
            <td>2</td>
            <td>tom</td>
            <td>20</td>
        </tr> -->
    </table>
    <script src="./db.js"></script>
    <script>
      function init() {
        openDB("stuDB", 1).then(db => {
          addData(db, "stu", { stuId: 1, stuName: "john", stuAge: 18 })
          addData(db, "stu", { stuId: 2, stuName: "tom", stuAge: 18 })
          addData(db, "stu", { stuId: 3, stuName: "jane", stuAge: 18 })
        })
      }
      function render(arr) {
        let tab = document.querySelector("#table")
        tab.innerHTML = `
            <tr>
            <td>学号</td>
            <td>姓名</td>
            <td>年龄</td>
        </tr>
            `
        let str = arr
          .map(item => {
            return `
        <tr>
            <td>${item.stuId}</td>
            <td>${item.stuName}</td>
            <td>${item.stuAge}</td>
        </tr>`
          })
          .join("")

        tab.innerHTML += str
      }
      async function renderTable() {
        let db = await openDB("stuDB", 1)
        let stuInfo = await getDataByKey(db, "stu")
        render(stuInfo)

        setInterval(async () => {
          let stuInfo2 = await getDataByKey(db, "stu")
          if (stuInfo2.length !== stuInfo.length) {
            render(stuInfo2)
          }
        }, 1000)
      }
      //   init()
      renderTable()
    </script>
  </body>
</html>

cookie 定时器轮询( setInterval

我们同样可以通过定时器轮询的方式来监听 Cookie 的变化,从而达到一个多标签页通信的目的。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>页面A</title>
</head>
<body>
    <script>
        document.cookie = 'name=john'
        console.log("coookie设置成功")
    </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <script>
      let cookie = document.cookie
      setInterval(() => {
        if (document.cookie !== cookie) {
          console.log("cookie发生了变化", document.cookie)
          cookie = document.cookie
        }
      }, 1000)
    </script>
  </body>
</html>

window.open、window.postMessage

MDN 上是这样介绍 window.postMessage 的:

window.postMessage( ) 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage( ) 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage( ) 方法分发一个 MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件 (en-US)。传递给 window.postMessage( ) 的参数(比如 message )将通过消息事件对象暴露给接收消息的窗口。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <button id="popBtn">弹出新窗口</button>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>
    <script>
      const popBtn = document.querySelector("#popBtn")
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      let opener = null
      popBtn.onclick = function () {
        opener = window.open(
          "2.html",
          "标题",
          "height=400,width=400,top=20,resizeable=yes"
        )
      }

      btn.onclick = function () {
        let data = {
          value: input.value,
        }
        // data 代表要发送的数据,*代表所有域
        opener.postMessage(data, "*")
      }
    </script>
  </body>
</html>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1>这是页面B</h1>
    <div>
      <span>接收到数据:</span>
      <p id="content"></p>
    </div>
    <script>
      const content = document.querySelector("#content")
      window.addEventListener("message", function (e) {
        content.innerHTML = e.data.value
      })
    </script>
  </body>
</html>

Websocket

WebSocket 协议在 2008 年诞生,2011 年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

server.js

// 初始化一个 node 项目 npm init -y
// 安装依赖 npm i -save ws

// 获得 WebSocketServer 类型
var WebSocketServer = require('ws').Server;

// 创建 WebSocketServer 对象实例,监听指定端口
var wss = new WebSocketServer({
    port: 8080
});

// 创建保存所有已连接到服务器的客户端对象的数组
var clients = [];

// 为服务器添加 connection 事件监听,当有客户端连接到服务端时,立刻将客户端对象保存进数组中
wss.on('connection', function (client) {
    // 如果是首次连接
    if (clients.indexOf(client) === -1) {
        // 就将当前连接保存到数组备用
        clients.push(client)
        console.log("有" + clients.length + "客户端在线");

        // 为每个 client 对象绑定 message 事件,当某个客户端发来消息时,自动触发
        client.on('message', function (msg) {
            console.log(msg, typeof msg);
            console.log('收到消息' + msg)
            // 遍历 clients 数组中每个其他客户端对象,并发送消息给其他客户端
            for (var c of clients) {
                // 排除自己这个客户端连接
                if (c !== client) {
                    // 把消息发给别人
                    c.send(msg.toString());
                }
            }
        });

        // 当客户端断开连接时触发该事件
        client.onclose = function () {
            var index = clients.indexOf(this);
            clients.splice(index, 1);
            console.log("有" + clients.length + "客户端在线")
        }
    }
});

console.log("服务器已启动...");

在上面的代码中,我们创建了一个 Websocket 服务器,监听 8080 端口。每一个连接到该服务器的客户端,都会触发服务器的 connection 事件,并且会将此客户端连接实例作为回调函数的参数传入。

我们将所有的客户端连接实例保存到一个数组里面。为该实例绑定了 messageclose 事件,当某个客户端发来消息时,自动触发 message 事件,然后遍历 clients 数组中每个其他客户端对象,并发送消息给其他客户端。

close 事件在客户端断开连接时会触发,我们要做的事情就是从数组中删除该连接。

index.html

<body>
  <!-- 这个页面是用来发送信息的 -->
  <input type="text" id="msg">
  <button id="send">发送</button>
  <script>
    // 建立到服务端 webSoket 连接
    var ws = new WebSocket("ws://localhost:8080");
    send.onclick = function () {
      // 如果 msg 输入框内容不是空的
      if (msg.value.trim() != '') {
        // 将 msg 输入框中的内容发送给服务器
        ws.send(msg.value.trim())
      }
    }
    // 断开 websoket 连接
    window.onbeforeunload = function () {
      ws.close()
    }
  </script>
</body>

index2.html

<body>
  <script>
    //建立到服务端webSoket连接
    var ws = new WebSocket("ws://localhost:8080");
    var count = 1;
    ws.onopen = function (event) {
          // 当有消息发过来时,就将消息放到显示元素上
          ws.onmessage = function (event) {
                var oP = document.createElement("p");
                oP.innerHTML = `第${count}次接收到的消息:${event.data}`;
                document.body.appendChild(oP);
                count++;
          }
    }
    // 断开 websoket 连接
    window.onbeforeunload = function () {
          ws.close()
    }
  </script>
</body

tips:以上信息来自渡一相关学习资料,供自己学习和面试使用。

昨天 — 2025年9月6日首页

📦 qiankun微前端接入实战

作者 wifi歪f
2025年9月6日 15:31

微前端这个内容在之前就做过分享,但是对于完整的项目实战没有写过,在公司刚好有后台,解决一下之前遗留的登录态和权限的问题。

主应用

1、安装依赖

pnpm i qiankun

2、main.ts

采用 registerMicroApps 来注册子应用

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import 'src/styles/index.scss'
import router from 'src/router/index'
import { registerMicroApps } from 'qiankun'


const app = createApp(App)


app.use(router)
app.mount('#app')


registerMicroApps([
  {
    name: 'h5App',
    entry: '//dev.yvyiai.com:8090/yvyiai-digital-human-h5',  // 确保路径不以斜杠结尾
    container: '#sub-container',
    activeRule: '/yvyiai-digital-human-web/h5App'
  }
])

3、编写路由

这里需要添加子应用的路径匹配(不然子应用带路由的话,主应用会404)

const routes = [
  {
    path: '/h5App',
    component: () => import('src/views/Layout/index.vue'),
    children: [
      {
        path: '/:pathMatch(.*)*',
        name: 'h5App',
        component: () => import('src/views/subApp/index.vue'),
        meta: {
          title: 'h5App'
        }
      }
    ]
  }
]

4、subApp/index.vue

需要添加一个子应用的渲染容器节点,需要和 registerMicroApps 注册的子应用的 container 节点一致

<template>
  <div id="sub-container"></div>
</template>


<script setup lang="ts">
import { start } from 'qiankun'
import { onMounted } from 'vue'


onMounted(() => {
  // 启动 qiankun,添加错误处理
  start({
    sandbox: {
      experimentalStyleIsolation: true
    },
    prefetch: false, // 禁用预加载避免冲突
    // 添加全局错误处理
    globalContext: window
  })
})
</script>

子应用

1、安装依赖

pnpm i vite-plugin-qiankun -D

2、配置vite.config.ts

  • 如果子应用是webpack的话,可以看qiankun官网,vite需要使用插件。

  • 子应用同时需要支持跨域

  • 配置打包为umd

import qiankun from 'vite-plugin-qiankun'

export default defineConfig({
  // 参数1:子应用名
  plugin: [qiankun('h5App', { useDevMode: true })],
  server: {
    // 开发环境的host
    host: 'dev.yvyiai.com',
    cors: true,
    // 添加跨域头部
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods':
      'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers':
      'X-Requested-With, content-type, Authorization'
    }
  },
  build: {
    lib: {
      entry: './src/main.ts', // 入口文件
      name: 'h5App', // 子应用名称
      fileName: 'h5App', // 打包后的文件名
      formats: ['umd'] // 打包为 UMD 格式
    }
  }
})

3、改造main.ts

webpack的应用改造方法有点不同,比vite简单

import { createApp, type App as AppInstance } from "vue";
import router from './router'
import App from './App.vue'
import {
  renderWithQiankun,
  qiankunWindow
} from 'vite-plugin-qiankun/dist/helper'

let app: AppInstance | null = null
function render(props: any = {}) {
  const { container } = props
  app = createApp(App)
  app.use(router)
  app.mount(container ? container.querySelector("#app") : "#app");
}

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render()
}

renderWithQiankun({
  mount(props) {
    render(props)
  },
  bootstrap() {},
  unmount(_props) {
    if (app) {
      app.unmount()
      if (app._container) {
        app._container.innerHTML = ''
      }
      app = null
    }
  },
  update() {}
})

4、路由改造

这个项目的主子应用都配置了base route的,子应用在qiankun环境下,需要把base route换成主应用的base+route path

  • 主应用base route:yvyiai-digital-human-web

  • 主应用对应子应用的出口路由:/h5App

// 子应用router/index.ts
const ROUTER_BASE = 'yvyiai-digital-human-h5'


const router = createRouter({
  history: createWebHistory(!qiankunWindow.__POWERED_BY_QIANKUN__ ? ROUTER_BASE : 'yvyiai-digital-human-web/h5App'),
  routes
})

对应如下:

子应用在主应用运行的路径就要以主应用的base为准了,不然会找不到资源的。

开发模式下接口问题

在开发模式下,我们的子应用通常会做proxy代理,但是这就导致我主应用是没有子应用的代理配置的,例如:

这里的 /liveApi 就是子应用在vite配置中做的代理,但是在主应用下看到是没有这个代理的,所以请求会报错。

解决办法:

  1. 在后端对接口处理跨域,不采用代理的方式

  2. 在主应用的vite中也配置同样的代理

但是方法2这种方法会存在一个缺点就是:我的子应用很多,代理也很多,我当前的子应用下面就有快10个代理接口,如果子应用也多,就会导致主应用的配置不好维护。

上面的解决方法只是基础原理

最终方法:

可以采用 CMS 进行后端配置,在运行前去加载代理的应用数据(也就是我的子应用需要提前去CMS系统上注册)。

这种方法对主应用去注册子应用也同样有用,可以通过远程数据配置来动态注册应用,这样基座代码不用每次注册都进行修改。

新建 remote-proxy-loader.js ,用于去加载不同子应用的proxy,去主应用的 vite.config.ts 去使用即可。

const fs = require('fs');
const path = require('path');
const axios = require('axios');

// 本地缓存文件路径
const PROXY_CACHE_PATH = path.resolve(__dirname, './proxy-cache.json');

/**
 * 从远程获取代理配置
 */
async function fetchRemoteProxyConfig() {
  try {
    const response = await axios.get('https://your-config-server.com/proxy-config');
    return response.data; // 假设返回格式: { subApp1: { ... }, subApp2: { ... } }
  } catch (error) {
    console.error('获取远程代理配置失败,使用本地缓存', error);
    // 尝试读取本地缓存
    if (fs.existsSync(PROXY_CACHE_PATH)) {
      return JSON.parse(fs.readFileSync(PROXY_CACHE_PATH, 'utf-8'));
    }
    return {}; // 无配置时返回空对象
  }
}

/**
 * 保存配置到本地缓存
 */
function saveProxyCache(config) {
  fs.writeFileSync(PROXY_CACHE_PATH, JSON.stringify(config, null, 2));
}

/**
 * 获取当前应用的代理配置
 */
async function getCurrentAppProxy(appName) {
  const allConfig = await fetchRemoteProxyConfig();
  // 保存到本地缓存
  saveProxyCache(allConfig);
  // 返回当前应用的配置
  return allConfig[appName] || {};
}

module.exports = { getCurrentAppProxy };

登录态的问题

项目中少不了的就是接口或页面的权限问题,这对项目的安全性也是非常重要的。

1、接口权限

公司的项目采用的是cookie作为登录态校验的,核心思路是利用 cookie 的跨域特性和 qiankun 的通信机制来做控制。

1.1 原理

cookie 具有域(domain)属性,若主应用和子应用配置在同一主域名下(如主应用 app.example.com ,子应用 sub1.example.com ),可通过设置 domain=.example.com 实现 cookie 共享

1.2 实现方案

同域的情况下直接共享:
  1. 登录接口设置 cookie 时指定主域名
// 登录接口响应头设置(后端)
Set-Cookie: token=xxx; domain=.example.com; path=/; HttpOnly; Secure
  1. 主应用登录后,子应用自动获取同域名下的 cookie
同域的情况下的登录态同步:

当主应用与子应用不在同一域时:

  1. 主应用登录后,通过 qiankun 的全局通信机制通知子应用
// 主应用登录成功后
import { initGlobalState } from 'qiankun';


const globalState = initGlobalState({
  token: 'xxx', // 登录后获取的 token
  isLogin: true
});


// 主应用监听子应用消息
globalState.onGlobalStateChange((state, prev) => {
  console.log('主应用监听到状态变化', state, prev);
});
  1. 子应用监听主应用的登录状态
// 子应用中
export function mount(props) {
  // 监听主应用传递的登录状态
  props.onGlobalStateChange((state, prev) => {
    if (state.isLogin) {
      // 子应用存储 token 到本地或内存
      localStorage.setItem('token', state.token);
    }
  }, true);
}

2、页面权限

页面权限可以在登录的时候,通过动态路由生成权限路由(还是靠CMS去拿)。但只限于主应用的路由,子应用的路由的话,只能通过主子应用通信+接口(接口返回403状态,可以直接返回login页面)解决权限

效果展示

由于没有做样式的特殊处理,导致子应用的样式污染到了主应用。

即使qiankun设置了样式隔离,vite还是会有影响,所以需要手动处理样式(webpack可以做到完美隔离)

截屏2025-09-06 15.00.56.png

从 “不会迭代” 到 “面试加分”:JS 迭代器现场教学

2025年9月6日 12:45

大家好,今天我们来聊聊 JavaScript 迭代器。别急着关掉页面,保证你看完不仅能学会迭代器,还能收获意料之外的知识

前言

你是否曾经在 for-of 循环前一脸懵逼?是否在面试官问你“迭代器协议”时只会尴尬微笑?今天这篇文章,带你从原理到实战,从协议到骚操作,彻底掌握迭代器!


1. 什么是迭代器?

迭代器就是一个能帮你“一个一个”拿数据的小工具。比如你有一堆糖果,迭代器就像一个自动分糖机,每次给你一个,直到分完。

在 JavaScript 里,迭代器是实现了 next() 方法的对象,每次调用 next(),它会返回一个形如 { value, done } 的对象。

2. for-of 的底层原理

for-of 能遍历数组、字符串、Map、Set……但它的本质其实是:

  1. 先获取对象的 Symbol.iterator 属性
  2. 调用它得到一个迭代器对象
  3. 不断调用迭代器的 next() 方法,直到 done: true
const arr = [1, 2, 3, 4, 5]
simpleForOf(arr, (item) => {
    console.log(item);
}) // 1, 2, 3, 4, 5

function simpleForOf(iterable, callback) {
    const iterator = iterable[Symbol.iterator]()
    while (true) {
        const {value, done}
        // console.log(value);
        callback(value) = iterator.next() // {value: 1, done: false}
        if (done) {
            break
        }
    }
}

代码解析

  • simpleForOf 就是 for-of 的底层实现!
  • 通过 Symbol.iterator 拿到迭代器,然后不断 next()
  • 每次拿到一个值,执行回调。

这就是 for-of 的灵魂!

3. 迭代器协议和 Symbol.iterator

# for of
 - 只能遍历拥有迭代器属性的对象  

 - 原理

# 迭代器属性
[Symbol.iterator]: function() {}

# 迭代器协议

重点

  • 只有实现了 [Symbol.iterator] 方法的对象才能被 for-of 遍历。
  • 迭代器协议要求对象有一个 next() 方法,每次返回 { value, done }

4. 手写迭代器,骚操作一箩筐

手写一个迭代器:

function createIterator(arr) {
    let index = 0
    return {
        next: function() {
            if (index < arr.length) {
                return {value: arr[index++], done: false}
            }
            return {value: undefined, done: true}
        }
    }
}
const myIterator = createIterator([1, 2, 3])
console.log(myIterator.next()); // { value: 1, done: false }
console.log(myIterator.next()); // { value: 2, done: false }
console.log(myIterator.next()); // { value: 3, done: false }
console.log(myIterator.next()); // { value: undefined, done: true }

代码解析

  • createIterator 返回一个有 next() 方法的对象。
  • 每次调用 next(),返回当前值和是否结束。
  • 这就是迭代器协议的标准实现!

手写迭代器,面试官看了都要点赞!👍

5. 对象也能 for-of?

正常情况下,对象不能 for-of,因为没有实现 [Symbol.iterator]。但你在自己给它加一个迭代器, 看我如何玩出花:

方案一:原型上加迭代器

Object.prototype[Symbol.iterator] = function*() {
    return yield* Object.values(this)
}

let [a, b] = {a: 1, b: 2}
console.log(a, b)

代码解析

  • 给所有对象加上 [Symbol.iterator],让它们可以 for-of!
  • 用生成器 function*,直接 yield 出所有值。
  • 这样就能解构对象了!

方案二:对象自定义迭代器

let obj = {
    a: 1,
    b: 2,
    c: 3
}
obj[Symbol.iterator] = function() {
    let index = 0
    let keys = Object.keys(this)
    return {
        next: () => {
            if (index < keys.length) {
                return {value: this[keys[index++]], done: false}
            }
            return {value: undefined, done: true}
        }
    }
}

代码解析

  • 给单个对象加迭代器属性。
  • 遍历对象的 key,每次返回对应的 value。

这样对象也能 for-of,骚操作 get!😎

方案三: 借用数组的迭代器

Object.prototype[Symbol.iterator] = function() {
    return Object.values(this)[Symbol.iterator]() 
}

代码解析

  • 直接用Object.values方法获取到对象上的所有属性值返回一个数组
  • 让对象的迭代器等于对象值数组的迭代器

让数组的迭代器顶替对象的迭代器,一手狸猫换太子😎

6. for-in 和 for-of 的区别

let arr = [1, 2, 3]
for (let index in arr) {
    console.log(index, arr[index])
}

代码解析

  • for-in 遍历的是索引(key),而不是值。
  • for-of 遍历的是值。

对象的 for-in

let obj = {
    a: 1,
    b: 2,
    c: 3
}
for (let key in obj) { // 可以遍历到对象原型上的属性
    console.log(key, obj[key])
}

for-in 会遍历原型上的属性,for-of 只遍历迭代器返回的值。

7. 总结与面试技巧

面试官问你 for-of 原理?

  • 说出 Symbol.iterator,迭代器协议,next 方法,done 属性。
  • 能手写一个迭代器,面试官直接给你 Offer!

for-in 和 for-of 的区别?

  • for-in 遍历 key,for-of 遍历 value。
  • for-in 能遍历原型链,for-of 只遍历迭代器返回的内容。

对象能不能 for-of?

  • 默认不能,但可以自己加 Symbol.iterator。
  • 还能用生成器让对象支持解构。

结语

迭代器其实很简单,只要你敢动手写一遍,面试、项目都能用得上!

最后送你一句话:

"会写迭代器的人,代码都不会太差!"


祝你面试顺利,代码越写越骚!

JS实现丝滑文字滚动

2025年9月6日 11:48

公告栏、弹幕、股票 ticker……垂直文字滚动是前端最常见的动效之一。本文用纯原生代码拆解一条「克隆 + 逐帧滚动」的实现思路,涵盖布局、滚动、边界衔接三大要点,开箱即用,零依赖零配置。

效果预览

JS实现丝滑文字滚动.gif

一、布局:一行标题 + 一条列表

HTML 极简骨架:

<div class="container">
  <h1 class="title">最新公告</h1>
  <ul class="list">
    <li>把大象装冰箱总共分几步?</li>
    <li>1. 邓哥打开冰箱门</li>
    <li>2. 邓哥把大象放进去</li>
    <li>3. 邓哥关上冰箱门</li>
  </ul>
</div>

CSS 只做两件事:

  • .container 负责背景色与水平排版
  • .list 固定高度并隐藏溢出内容,为滚动做准备

二、核心算法:克隆 + 逐帧滚动

1.克隆首项 —— 无缝衔接的秘诀

当最后一项滚动到可视区后,必须立即回到第一项而不闪屏。最简单做法:把第一项克隆一份追加到列表末尾。这样「最后一项」和「第一项」之间永远有一条平滑过渡。

const list = document.querySelector('.list');
const firstItem = list.children[0].cloneNode(true);
list.appendChild(firstItem);

2.逐帧滚动 —— 手写 Easing

浏览器原生 scrollTop 没有动画,我们用 setInterval 在 300 ms 内完成一次 30 px 的位移动画:

let curIndex = 0;
const itemHeight = 30;
const duration = 300; // 动画耗时
const interval = 10;  // 每帧间隔
const dis = itemHeight / (duration / interval);

function moveNext() {
  let from = curIndex * itemHeight;
  let to = (curIndex + 1) * itemHeight;
  let timer = setInterval(() => {
    from += dis;
    if (from >= to) {
      clearInterval(timer);
      curIndex++;
      // 到达克隆项时瞬间归零,形成无缝循环
      if (curIndex === list.children.length - 1) {
        curIndex = 0;
        list.scrollTop = 0;
      }
    } else {
      list.scrollTop = from;
    }
  }, interval);
}

三、边界处理:回到起点不闪屏

curIndex 等于克隆项索引时,立即把 scrollTop 设为 0,人眼几乎察觉不到跳变,从而形成无限循环。

四、一行命令替换内容

只要保持每条文本高度一致,替换 <ul>innerHTML 即可热插播新公告,无需重启动画逻辑。

总结

「克隆第一项 + 逐帧滚动」是公告栏场景下最轻量的无限循环方案,可让文字像胶片一样永远滚动,不闪、不抖、不掉帧。

昨天以前首页

面试官:手写一个深色模式切换过渡动画

作者 张海潮
2025年9月5日 09:55

在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。

读完本文,你将掌握:

  1. 使用View Transitions API实现流畅的主题切换动画
  2. 理解深色模式切换的核心原理与实现细节
  3. 能够将这套方案应用到实际项目中,提升用户体验
image.png

前言

在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark") 切换样式,再配合 transition 做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。

随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:

  • 了解 document.startViewTransition 的工作原理
  • 学会用 clipPath + animate 控制圆形扩散动画

核心铺垫:我们需要解决什么问题?

在设计方案前,先明确 3 个核心目标:

  1. 流畅过渡:避免普通 transition 的“整体闪烁”,实现局部扩散过渡。
  2. 交互感强:以用户点击位置为动画圆心,符合直觉。
  3. 可扩展:方案可适配 Vue3 组件体系,不依赖复杂第三方库。

为此,我们需要用到几个关键技术点:

  • View Transitions API:提供 document.startViewTransition,可以对 DOM 状态切换设置过渡动画。
  • clip-path:通过 circle(r at x y) 定义动画圆形,从 0px 扩展到最大半径。
  • computeMaxRadius:计算从点击点到四角的最大距离,确保圆形覆盖全屏。
  • .animate:使用 document.documentElement.animate 精确控制过渡过程。

Math.hypot:计算平面上点到原点的距离

Math.hypot()是ES2017引入的一个JavaScript函数,用于计算所有参数平方和的平方根,即计算n维欧几里得空间中从原点到指定点的距离。

image.png

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:

斜边计算

Math.hypot(maxX, maxY):使用勾股定理计算从点击点到对角的距离

image.png

clip-path

recording.gif

clip-path是CSS属性,允许我们定义元素的可见区域,将其裁剪为基本形状或SVG路径。在深色模式切换动画中,我们用它创建从点击点向外扩散的圆形动画效果。

<basic-shape>一种形状,其大小和位置由 <geometry-box> 的值定义。如果没有指定 <geometry-box>,则将使用 border-box 用为参考框。取值可为以下值中的任意一个:

  • inset()

    定义一个 inset 矩形。

  • circle()

    定义一个圆形(使用一个半径和一个圆心位置)。

  • ellipse()

    定义一个椭圆(使用两个半径和一个圆心位置)。

  • polygon()

    定义一个多边形(使用一个 SVG 填充规则和一组顶点)。

  • path()

    定义一个任意形状(使用一个可选的 SVG 填充规则和一个 SVG 路径定义)。

这里使用circle()来实现效果

该函数接受以下参数:

  • 半径:定义圆形的大小(0px到计算的最大半径)
  • at关键词:分隔半径和中心点位置
  • 中心点位置:使用x y坐标指定圆形中心

startViewTransition:浏览器视图转换API

基本概念

document.startViewTransition()是View Transitions API的核心方法,它告诉浏览器DOM即将发生变化,并允许我们为这些变化创建平滑的过渡动画。

生命周期与关键事件

  1. 调用startViewTransition:浏览器准备开始视图转换
  2. 执行回调函数:DOM状态更新
  3. transition.ready事件:视图转换准备就绪,可以应用动画
  4. 视图转换完成:动画结束,新状态成为稳定状态

浏览器兼容性处理

在实际应用中,我们需要检查浏览器是否支持此API:

const isAppearanceTransition =
    document.startViewTransition &&
    !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition) {
    // 不支持View Transitions API时的降级处理
    isDark.value = !isDark.value;
    setupThemeClass(isDark.value);
    return;
}

这种处理确保在不支持新特性的浏览器中,功能仍然可用,只是没有动画效果。

核心实现:从逻辑到代码

graph TD

    A[用户点击切换按钮] --> B{浏览器是否支持<br/>View Transitions API?}
    B -- 否 --> C[直接切换主题变量<br/>无动画效果]
    B -- 是 --> D[获取点击坐标X,Y]
    D --> E[计算覆盖全屏的最大半径]
    E --> F[启动视图转换]
    F --> G[执行回调函数<br/>更新isDark状态]
    G --> H[设置HTML的dark class<br/>更新CSS变量]
    H --> I[等待DOM更新完成<br/>nextTick]
    I --> J[视图转换准备就绪]
    J --> K[应用clipPath动画<br/>从点击点向外扩散]
    K --> L[动画完成<br/>主题切换完成]
    
    style B fill:#f9f,stroke:#333,stroke-width:2px
    style K fill:#9cf,stroke:#333,stroke-width:2px
  1. 用户交互:用户点击切换按钮,触发主题切换流程

  2. 浏览器兼容性检查:判断当前浏览器是否支持View Transitions API

  3. 降级处理:在不支持API的浏览器中直接切换主题

  4. 动画核心逻辑

    • 获取点击位置作为动画起点
    • 计算覆盖全屏的最大半径
    • 启动视图转换过程
  5. 状态更新:实际执行主题状态更新和CSS类设置

  6. 动画触发:在视图转换准备就绪后,应用clipPath动画效果

  7. 完成:动画结束,新主题状态稳定

步骤 1:封装主题切换

    function setupThemeClass(isDark) {
      document.documentElement.classList.toggle("dark", isDark);
      localStorage.setItem("theme", isDark ? "dark" : "light");
    }

作用:控制 html.dark 类名,完成主题切换。


步骤 2:计算扩散最大半径

    function computeMaxRadius(x, y) {
      const maxX = Math.max(x, window.innerWidth - x);
      const maxY = Math.max(y, window.innerHeight - y);
      return Math.hypot(maxX, maxY); // √(maxX² + maxY²)
    }
    

作用:确保无论点击哪里,扩散圆都能覆盖屏幕。


步骤 3:触发 View Transition

    function onToggleClick(event) {
      const isSupported =
        document.startViewTransition &&
        !window.matchMedia("(prefers-reduced-motion: reduce)").matches;

      if (!isSupported) {
        // 回退方案:直接切换
        isDark.value = !isDark.value;
        setupThemeClass(isDark.value);
        return;
      }

      const x = event.clientX;
      const y = event.clientY;
      const endRadius = computeMaxRadius(x, y);

      // 开启视图过渡
      const transition = document.startViewTransition(async () => {
        isDark.value = !isDark.value;
        setupThemeClass(isDark.value);
        await nextTick(); // 等 Vue DOM 更新
      });

      transition.ready.then(() => {
        const clipPath = [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ];

        document.documentElement.animate(
          {
            clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
          },
          {
            duration: 450,
            easing: "ease-in",
            pseudoElement: isDark.value
              ? "::view-transition-old(root)"
              : "::view-transition-new(root)",
          }
        );
      });
    }

要点:

*startViewTransition 接收一个回调函数,里面执行 DOM 更新(切换主题)。

*transition.ready.then(...) 可以在 DOM 更新后定义动画效果。

*clipPath 数组定义了从 小圆 → 大圆 的扩散过程。

*pseudoElement 控制是对 新视图 还是 旧视图 应用动画。


步骤 4:覆盖默认过渡样式


    ::view-transition-new(root),
    ::view-transition-old(root) {
      animation: none;
      mix-blend-mode: normal;
    }

    ::view-transition-old(root) {
      z-index: 1;
    }

    ::view-transition-new(root) {
      z-index: 2147483646;
    }

    html.dark::view-transition-old(root) {
      z-index: 2147483646;
    }

    html.dark::view-transition-new(root) {
      z-index: 1;
    }

作用:取消默认动画,手动用 clipPath 控制。通过 z-index 确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。


效果演示

recording.gif

运行后:

  • 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。
  • 若浏览器不支持 View Transitions API(如 Safari),则自动降级为普通切换,不影响使用。

完整demo


延伸与避坑

  1. 兼容性问题

    • View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。
    • 可加上 isSupported 判断,优雅降级。
  2. 性能优化

    • 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。
    • clip-path 本身是 GPU 加速属性,性能较好。
  3. 扩展思路

    • 除了圆形扩散,还可以用 polygon() 实现“百叶窗切换”或“对角线切换”。
    • 可以结合 路由切换 做“页面级过渡动画”。

总结

本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:

  • startViewTransition:声明 DOM 状态切换的动画上下文。
  • clipPath + animate:控制过渡动画形状与过程。
  • computeMaxRadius:计算圆形覆盖全屏的半径。
  • 优雅降级:确保不支持 API 的浏览器仍能正常切换。

复习-网络协议

作者 GHOME
2025年9月5日 02:57

网络协议

1. Http有哪些版本?如何查看?

http版本分布

  1. 0.9
  2. 1.0
  3. 1.1(最常见)
  4. 2.0(最常见,大厂基本都是2.0)
  5. 3.0

查看方式:在控制台打印

window.chrome.loadTimes()
在里面的npnNegotiatedProtocol可以看到http版本

或者

在网页的Network查看请求头,根据请求头判断
GET / HTTP/1.1就是1.1版本;不同版本结构会不一样;

2. http版本的发展

HTTP 0.9

是第一个版本的HTTP协议,已过时。

  • 只允许客户端发送GET请求,不支持请求头。
  • 无状态(请求没了就没了)

HTTP 1.0

  • 新增请求方式POST
  • 新增请求头、http状态码等
  • 新增Cookie(让HTTP协议有了状态,例:登录)

HTTP 1.1

  • 新增keep-alive长连接
  • 新增pipeline管道
  • 增加了 PUT、DELETE、OPTIONS、PATCH 等新的方法

HTTP 2.0

  • 头部支持二进制协议,支持头部压缩(提升性能)
  • 新增多路复用:避免了HTTP的队头阻塞问题

HTTP 3.0

革命性大改版

  • 将底层TCP协议改成UDP,彻底解决TCP的队头阻塞问题
  • 兼容性还不行,不能大规模使用

3. 什么是长连接?Keep-alive

tcp的每次连接建立都需要三次握手,如果开启长连接就可以避免这种情况。 只需一次“三次握手”就可以进行多次请求。

4. pipeline和长连接的区别?

pipeline:可以让请求并发,并且用的是同一条tcp连接

5. HTTP1.1有哪些特点

  1. ⭐HTTP1.1默认使用 Connection:keep-alive(长连接), 避免了连接建立和释放的开销。

  2. 支持pipeline管道传输,并发请求。

  3. 并发连接(谷歌浏览器最多6个):对一个域名的请求允许分配多个长连接(缓解了长连接中的【HTTP的队头阻塞】问题)

  4. 增加了 PUT、DELETE、OPTIONS、PATCH 等新的方法

  5. 允许数据分块(chunked),利于传输大文件

  6. 请求头中引入range字段,支持断点续传

6.HTTP2.0有哪些特点

  1. 二进制协议:http1.1版本的头部信息是文本,数据部分可以是文本或二进制;http2版本的头部和数据都是二进制,且统称为“帧”
  2. 多路复用:废弃了HTTP1.1的pipeline管道机制,在同一个TCP连接中,客户端和服务器可以同时发送多个请求和响应,并且不用按照顺序来。由于服务器不用按顺序处理响应,避免了“HTTP的队头阻塞”的问题
  3. 头部信息压缩:gzip压缩。
  4. 服务端主动推送:允许服务端主动向客户端推送数据
  5. 数据流:允许服务端以stream(流)的形式向客户端发送数据。(我们现在常见的deepseek 几个字几个字蹦出来,就是这样流式传输)

7.pipeline和多路复用的区别呢?

我们先对比以下几种发送和接收的形式:

  1. 长连接-非pipeline:发一条,收一条,没收到就不能发下一条。
  2. 长连接-pipeline:发1,发2,收1,收2。可以并发请求 但是,收和发的顺序必须一致,如果有一个卡住,后面全部卡住(HTTP的队头阻塞)。
  3. 多路复用:它是基于http的二进制“分帧”。做到让收到数据和发出请求的顺序不需要一致(解决HTTP的队头阻塞)。【人话:原本的请求得到数据需要一次性收到,现在的数据可以一点一点接收 然后组装起来,哪个接口的先收齐就先组装】

8. HTTP3.0有什么特点?

我们都知道http是基于TCP协议实现的。

TCP的主要作用是以正确的顺序将整个字节流从一个端点传输到另一个端点,但是当流中的某些数据包丢失时,TCP需要重新发送这些丢失的数据包,等到丢失的数据包到达对应端点时才能被HTTP处理,这被称为TCP的队头阻塞问题

HTTP3.0就是主要解决这个问题的

Google做了一个基于UDP协议的QUIC协议

QUIC的优势:

  1. ⭐使用了UDP协议,不需要进行三次握手,也会缩短TLS(HTTPS)建立连接的时间。
  2. 彻底解决了TCP队头阻塞问题
  3. 报文头和报文体分别进行认证和加密处理,保障安全性
  4. 连接能够平滑迁移(设备在流量和wifi等情况间切换,不会断线重连)

9. 网络是怎么分层的

1. 物理层

作用:传输 0 1 0 1 信号。 代表设备:网线、光纤

2. 数据链路层

该层管理网络层与物理层之间的通信。 作用:将 0 1 0 1 数据封装为(一帧是64-1518字节)。 代表作用:Mac地址确认,Arp广播 代表设备:交换机

3. 网络层

该层决定如何将数据从发送方路由到接收方。 代表作用:分配IP地址 代表设备:家用路由器

4. 传输层

该层为两台主机上的应用程序提供端到端的通信(微信聊天)。 代表作用:连接端到端,如:TCP UDP 代表设备:操作系统内核

5. 应用层

规定应用程序的数据格式。主要协议有:HTTP、FTP、Telnet、SMTP、POP3等。

10. TCP和UDP的区别

  • TCP面向连接(如打电话要先拨号建立连接)提供可靠的服务,UDP是无连接的,即发送数据之前不需要建立连接,UDP尽最大努力交付,即不保证可靠交付(没有保障一定收到)。
  • UDP具有较好的实时性工作效率比TCP高
  • TCP连接只能一对一,UDP支持一对一、一对多、多对一和多对多的交互通信。
  • UDP占据更小空间。
  • TCP面向字节流,UDP面向报文字节流可以一点一点发(流式传输),UDP一次需要交付一个完整的报文
  • UDP适合一次性传输较小数据的网络应用,如DNS、SNMP(专业的网关 服务器、路由器等)等。

应用场景:

  • UDP:直播、游戏
  • TCP:网页

11. TCP(建立连接)三次握手

首先我们模拟两个对象,客户端(Client)和服务器(Server)。握手的目的,是让这两位都确认能收到对方的消息

初始状态:Server(Listen) 服务器监听中

第一次握手

  1. Client发送一个syn数据包(seq=x),之后改变自己的状态为Client(SYN_SENT) 表示已发送SYN数据包。
  2. Server收到之后,改变自己的状态为Server(SYN_RCVD) 表示已收到SYN数据包。

第二次握手

  1. Server返回ACK包+SYN(ack=x+1,seq=y),其中x+1就表示自己收到了,即C->S已经没问题。
  2. Client收到之后,改变自己的状态为Client(Established) 表示已确认

第三次握手:最后,Client再发送ACK包(ack=y+1),Server收到后,改变自己的状态为Server(Established) 表示已确认

最终状态:Client(Established)、Server(Established)。双方都确定,那么TCP连接成功。

12. TCP(断开连接)四次挥手

  1. 为什么握手是三次,挥手是四次?
  2. 断开方为什么要等待2MSL(Maximum Segment Life)才能真正断开?

MSL:代表数据包在网络中能够存在的最长时间(常用是30s\60s\120s)

第一次挥手

  1. Client发送 FIN包(seq=x+2) ACK包(ack=y+1),改变状态为Client(FIN_WAIT_1)
  2. Server收到后,改变状态为Server(CLOSE_WAIT)

第二次挥手

  1. Server发送 ACK包(ack=x+3)
  2. Client收到后,改变状态为Client(FIN_WAIT_2)
  3. 重点在这里:服务器需要确认一下,有没有数据没传完。因此这里Client需要等待Server确认完后再告诉它是否断开。所以挥手会比握手多一次

第三次挥手

  1. Server发送 FIN包(seq=y+1),改变状态为LAST_ACK。
  2. Client收到后,改变状态为Client(TIME_WAIT)

第四次挥手

  1. Client发送 ACK包(ack=y+2)

第四次握手之后,Client并不知道Server收到了没有,有两种情况

  1. 如果Server没有收到ACK,会触发超时重传FIN
  2. 如果Server收到了ACK,就不会再发送消息。

前面的解释中说到,MSL代表数据包存储的最长时间。当第三次挥手后,Client收到消息保存FIN的最长时间是一个MSL,如果在这段时间内Server没有重传那么代表可以了。但最保险的是,Client传过去的ACK包存活时间也过去,因此加起来就是2个MSL的时间。Client就可以放心地释放TCP占用的资源、端口号。

13. 常见的http状态码有哪些

1xx:正在处理你的请求 2xx:请求成功,经典状态码200表示成功请求和响应 3xx:302重定向 304触发协商缓存 4xx:401未登录(过期) 403已登录但这个模块你没有权限 404请求没有的资源 5xx:后端发生问题

14. TCP中的滑动窗口有什么作用

滑动窗口(Sliding window)是一种流量控制技术。通信双方不考虑网络的拥挤情况直接发送数据,导致中间节点阻塞掉包,谁也发不了数据。

TCP中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区(内存占用)可以用于接收数据,发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据

客户端和服务器达成一致,确定要发送多大的数据包。

TCP维持了一个滑动窗口,它解决端到端的问题,并且动态变化。

15. TCP的拥塞控制有什么作用

某段时间内,对网络中某一资源的需求过多,网络性能就会变差,这种情况叫做拥塞。 拥塞控制 就是为了防止过多的数据注入到网络中。有一个前提,是网络其实是能够承受现有的网络负荷,但是想要通过流量控制,抑制发送端发送的速率,以便接收端来得及接收

TCP发送方要维持一个拥塞窗口的状态变量,窗口大小取决于网络的拥塞成都,且动态变化。

16. 滑动窗口拥塞控制的区别?

滑动窗口解决双方本身的问题。 拥塞控制解决双方之间传输的拥堵问题。

滑动窗口:

A去B家玩耍,A家人多准备了8台车。B家院子只能停4台,那么A家人 就分成2批过去,1批四台车。

拥塞窗口:

A去B家玩耍,A家人多准备了8台车。B家院子能停8台,但是今天超级堵车,车只能1台1台出发(隔5分钟出发一辆)。

发送方让自己的发送窗口取 滑动窗口 和 拥塞窗口 中较小的一个

17. https的加密过程

  • 对称加密:一把钥匙能解密也能加密
  • 非对称加密:有两把钥匙,一把只加密(公钥),一把只解密(私钥);Client拿公钥,Server拿私钥

过程:

  1. Client发起一个http请求,告诉Server自己支持哪些hash算法。

  2. ⭐下发公钥和证书信息:Server把自己的信息以数字证书的形式返回给Client(公钥、网站地址、证书颁发机构、失效日期等)。用公钥来加密信息,私钥由Server持有。

  3. 浏览器去校验证书的合法性

  4. ⭐生成随机密码(RSA签名):验证通过,浏览器会生成一个随机的对称密钥(session key) 并用公钥加密,让Server用私钥解密拿到密码。以后双方的通信都用这个密码来做对称加密。

  5. ⭐Client生成对称加密算法:经过第四步验证了Server身份之后,Client生成一个对称加密算法,把算法和session key一起用公钥加密后发送给Server。Server通过私钥解密后拿到算法和session key,就可以用算法解密了。

18. https是绝对安全的吗?

不是。有一种攻击叫做 “中间人攻击”

如何防范:一般只能去引导用户

  1. 不要轻易信任证书,不知名的小网站不要随意访问
  2. 浏览器给安全提示,就是有风险,谨慎
  3. 不要随意连接公共wifi

19. 浏览器的强缓存协商缓存

强缓存

强缓存是通过ExpiresCache-Control来控制缓存在本地的有效期,在控制台network可查看。

Expires

HTTP1.0提出的一种方案。由服务端给到过期时间。

Expires:Sun, 14 Jun 2020 02:50:57 GMT

Cache-Control

出现于HTTP 1.1,优先级高于Expires。

Cache-Control: max-age=300 我的资源要在浏览器强缓存300s

协商缓存

当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中:

  • 如果协商缓存命中,请求响应返回的HTTP状态为304(Not Modified),该请求不携带实体数据
  • 未命中,则返回200并携带实体数据
Last-Modified If-Modified-Since

是HTTP1.0引入的。表示文件的最后修改日期,浏览器会在请求中携带它,询问服务器在该日期之后资源是否更新,更新则发送新资源,没更新就使用缓存。

ETag If-None-Match

ETag就像一个指纹,资源变化就会导致ETag变化。

两者的区别?

Last-Modified精度只能到秒,但是性能更好 ETag更精细,但是性能不如前者,因为文件变化都要重新计算hash值

20. 什么是xss攻击?如何防范

XSS指的是跨站脚本攻击,是一种代码注入攻击。攻击者通过在网站注入恶意脚本,使之在用户的浏览器上运行,从而盗取用户的信息如cookie等。

如何防范?

  1. 前端对表单提交,敏感字符进行转义,例如:"<","/"等,破坏它的script标签或sql语句
  2. 但前端防范不一定是绝对安全的,因为可以抓包篡改
  3. 核心是后台进行处理,比如转义

21. 什么是csrf攻击?如何防范

csrf又叫跨站请求伪造,一般需要借助XSS产生的漏洞

  • 比如某网站的评论区有个同域名的链接,你不小心点到了,就把你的cookie也带了过去,然后他就可以用你的账号进行所有事情,例如转账给他。

如何防范?

  1. 堵住XSS漏洞
  2. 在http header中添加token校验(因为请求时cookie会自动携带,但token需要前端写才会带上,所以可以与后端协商带上token)
  3. 校验请求http reffer(可以查到,发起这个请求的网站域名是什么,如果不是我们的域名就不让他访问)

22. 什么是websocket?

是HTML5出的协议。跟HTTP协议基本没有关系(有一丢丢交集),可以说它是HTTP协议上的一种补充。 最大的特点就是:服务器和客户端都可以主动向对方推送消息,双向平等对话,允许服务端和客户端进行全双工(full-duplex)的通信

全双工通信:就像打电话,双方可以同时说话和同时听到对方说话。

其他特点:

  1. 建立在TCP协议上,服务端的实现比较容易
  2. 与HTTP协议有着良好的兼容性。默认端口也是80和443。握手阶段采用HTTP协议,因此握手时不容易屏蔽,能通过各种HTTP代理服务器。
  3. 数据轻量、性能开销小、通信高效
  4. 可发送文本、二进制数据
  5. 没有同源限制
  6. ⭐协议标识符是ws(如果加密,则为wss)

23. ⭐Socket和WebSocket的关系?

它们的关系就好像Java和JavaScript一样,没有关系

Socket是对TCP/IP协议的封装和应用:nodejs的内置模块socket,可以去操作TCP。

  1. ⭐socket是传输层接口,直接操作TCP/UDP协议
  2. ⭐websocket是应用层协议,依赖HTTP握手后升级为Websocket协议。

8 个高频 JS 手写题全面解析:含 Promise A+ 测试实践

作者 zhEng
2025年9月4日 21:56

✍️ 前言

在前端面试中,JS 手写题几乎是绕不过去的考点。无论是防抖、节流,还是手写 callapplybind,又或者是进阶的 instanceof、发布订阅模式,甚至是最重要的 Promise 的实现。最近我花时间练习了一波常见的JS手写题。这篇文章整理了我的实现过程、注释,以及测试记录,方便以后复盘。


1.实现一个防抖函数(debounce)

function debounce(fn,delay = 300) {
  let timeId = null;
  return function (...args) {
    if(timeId) clearTimeout(timeId);
    timeId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  }
}

// ================= 测试用例 =================
const debouncedFn = debounce((str,n)=> {
  console.log(str)
  console.log(n)
}, 1000);
debouncedFn('hello debounce','123');

要点解析

  • delay 时间窗口内重复触发,会清除上一次 setTimeout,最终只执行最后一次调用。
  • fn.apply(this, args) 可以保留触发者的 this

2.实现一个节流函数(throttle)

function throttle(fn,delay = 300) {
  let inThrottle;
  return function (...args) {
    if(inThrottle) return;
    inThrottle = true;
    fn.apply(this, args);
    setTimeout(() => {
      inThrottle = false;
    }, delay);
  }
}

// ================= 测试用例 =================
const throttleFn = throttle((str)=> {
  console.log(str);
},1000)
throttleFn('hello throttle') // 输出hello throttle
throttleFn('hello throttle') // 不执行

要点解析

  • 典型的前缘节流(leading:true,trailing:false):窗口开始执行一次,后续调用被忽略直到窗口结束。

3.手写 call

Function.prototype.myCall = function (context, ...args) {
  if(typeof this !== 'function') {
    throw new TypeError('this is not a function');
  }
  const fn = Symbol('fn');
  context = context === null || context === undefined ? globalThis : Object(context);
  context[fn] = this;
  const res = context[fn](...args);
  delete context[fn];
  return res;
}

要点解析

  • 核心思路:把函数临时挂到 context 上执行以改变 this,再删除。

4.手写 apply

Function.prototype.myApply = function (context, arr){
  if(typeof this !== 'function') {
    throw new TypeError('this is not a function');
  }
  if(!Array.isArray(arr)) {
    throw new TypeError('params is not an array');
  }
  const fn = Symbol('fn');
  context = context === null || context === undefined ? globalThis : Object(context);
  context[fn] = this;
  const res = context[fn](...arr);
  delete context[fn];
  return res;
}

要点解析

  • call 类似,区别在于第二参数为数组,通过展开 ...arr 传参。

5.手写 bind

Function.prototype.myBind = function(context, ...args) {
  if(typeof this !=='function') {
    throw new TypeError('this is not a function');
  }
  return (...args2)=> {
    console.log(args, args2);
    const key = Symbol('key');
    context[key] = this;
    const res = context[key](...args, ...args2);
    delete context[key];
    return res;
  }
}

// ================= 测试用例 =================
const fn = (a,b,c,d) =>{
  console.log(a,b,c,d)
}
const fn2 = fn.myBind(this, 1, 2)
fn2(3,4);

要点解析

  • 通过返回闭包保存 context + 预置参数(柯里化)。
  • 用了箭头函数返回,有个好处:箭头函数的 this捕获定义时的 this(即原函数对象),所以 context[key] = this 实际上是把原函数挂过去调用。

6.实现一个 instanceof

/**
 * 手写实现 instanceof 的功能
 * @param {Object} obj - 要检测的对象
 * @param {Function} constructor - 构造函数
 * @returns {boolean} 是否是 constructor 的实例
 */
function myInstanceOf(obj, constructor) {
  // 1. 非对象直接返回 false(排除 null 和基本类型)
  if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
    return false;
  }
  // 2. 取构造函数的 prototype
  const prototype = constructor.prototype;
  // 3. 获取 obj 的原型
  let proto = Object.getPrototypeOf(obj); // obj.__proto__
  // 4. 遍历原型链,逐层向上查找
  while (proto !== null) {
    if (proto === prototype) {
      return true;
    }
    proto = Object.getPrototypeOf(proto);
  }
  // 5. 查到顶层 Object.prototype 仍未匹配
  return false;
}

// ================= 测试用例 =================
console.log(myInstanceOf([], Array)); // true
console.log(myInstanceOf({}, Object)); // true
console.log(myInstanceOf(function(){}, Function)); // true

// 简易写法
function myInstanceOf(obj, constructor) {
  const proto = obj.__proto__;
  if(!proto) return false;
  if(proto === constructor.prototype) {
    return true;
  }
  return myInstanceOf(proto, fn);
}

// ================= 测试用例 =================
console.log(myInstanceOf([], Array)); // true
console.log(myInstanceOf({}, Object)); // true
console.log(myInstanceOf('', String)); // true
console.log(myInstanceOf('', Boolean)); // false

要点解析

  • 沿着原型链逐级查找,直到命中 fn.prototype 或到达 null

7.实现一个发布订阅模式(Event Emitter)

class MyEmitter {
  #events = {}
  constructor() {
    this.#events = {};
  }

  $on(eventName,callback) {
    // if(!this.#events[eventName]){
    //   this.#events[eventName] = [];
    // }
    // this.#events[eventName].push(callback);
    (this.#events[eventName] ??= []).push(callback);
  }

  $emit(eventName, ...args){
    if(this.#events[eventName]) {
      this.#events[eventName].forEach(cb => {
        cb(...args);
      })
    }
  }
  
  $off(eventName) {
    if (this.#events[eventName]) {
      delete this.#events[eventName];
    }
  }

  $once(eventName, callback) {
    this.$on(eventName, (...args) => {
       callback(...args);
       this.$off(eventName);
    });
  }
}

const bus = new MyEmitter();

bus.$on('on-ok',(payload)=> {
  console.log(payload);
});

bus.$emit('on-ok', { name:'张三' });
bus.$emit('on-ok', { name:'李四' });

8.手写一个 Promise(并跑 Promise A+ 测试)

/**
 * 异步执行一个函数
 * @param {Function} cb - 回调函数
 */
function runAsyncTask(cb) {
  if(typeof cb !== 'function') {
    throw new TypeError('cb must be a function');
  }
  if(typeof queueMicrotask === 'function'){
    queueMicrotask(cb);
  }else {
    setTimeout(() => {
      cb();
    }, 0);
  }
}

// 初始值
const PENDING ='pending';
// 成功
const FULFILLED = 'fulfilled';
// 失败
const REJECTED = 'rejected';

class MyPromise {
  #promiseStatus = PENDING;
  #promiseResult = undefined;
  #thenables = [];
  constructor(executor) {
    const resolve = (data) => {
      // if(this.#promiseStatus !== PENDING) return;
      // this.#promiseStatus = FULFILLED;
      // this.#promiseResult = data;
      this.changeStatus(FULFILLED ,data);
    }

    const reject = (err) => {
      // if(this.#promiseStatus !== PENDING) return;
      // this.#promiseStatus = REJECTED;
      // this.#promiseResult = err;
      this.changeStatus(REJECTED, err);
    }
    try {
      executor(resolve, reject);
    } catch(err) {
      reject(err)
    }
  }

  /**
   * 改变Promise状态
   * @param {*} status 
   * @param {*} data 
   */
  changeStatus(status,data) {
    if(this.#promiseStatus !== PENDING) return;
    this.#promiseStatus = status;
    this.#promiseResult = data;
    this.#thenables.forEach(({ onResolve,onReject })=> {
      if(status === FULFILLED){
        onResolve(data);
      } else if(status === REJECTED) {
        onReject(data);
      }
    })
  }

  /**
   * 处理返回Promise
   * @param {*} p 新的Promise
   * @param {*} x 上一个then回调的返回值
   * @param {*} resolve 新的Promise的resolve
   * @param {*} reject 新的Promise的reject
   */
  #resolvePromise(p, x, resolve, reject) {
    if (p === x) {
      return reject(new TypeError('Chaining cycle detected for promise #<Promise>'));
    }
    // 原生/外部 thenable 兼容
    if ((x !== null && (typeof x === 'object' || typeof x === 'function'))) {
      let then;
      try {
        then = x.then; // 取 getter 可能抛错
      } catch (e) {
        return reject(e);
      }
      if (typeof then === 'function') {
        let called = false;
        try {
          then.call(
            x,
            y => { if (called) return; called = true; this.#resolvePromise(p, y, resolve, reject); },
            r => { if (called) return; called = true; reject(r); }
          );
        } catch (e) {
          if (!called) reject(e);
        }
        return;
      }
    }
    // 非 thenable
    resolve(x);
  }

  then(onResolve, onReject) {
    onResolve = typeof onResolve === 'function' ? onResolve : x => x;
    onReject = typeof onReject === 'function' ? onReject : x => {
      throw x
    }
    const p = new MyPromise((resolve,reject)=> {
      if(this.#promiseStatus === FULFILLED){
        runAsyncTask(()=> {
          try {
            const x = onResolve(this.#promiseResult)
            this.#resolvePromise(p, x, resolve, reject);
            // if(x instanceof MyPromise) {
            //   x.then(res=> resolve(res), err=> reject(err));
            // } else {
            //   resolve(x)
            // }
          } catch(err) {
            reject(err)
          }
        })
      } else if(this.#promiseStatus === REJECTED) {
        runAsyncTask(()=> {
          try {
            const x = onReject(this.#promiseResult);
            this.#resolvePromise(p, x, resolve, reject);
            // reject(x);
          } catch(err) {
            reject(err)
          }
        })
      } else {
        this.#thenables.push({
          onResolve:()=> {
            runAsyncTask(()=> {
              try {
                const x = onResolve(this.#promiseResult);
                this.#resolvePromise(p, x, resolve, reject);
                // reject(x);
              } catch(err){
                reject(err);
              }
            })
          },
          onReject:()=> {
            runAsyncTask(()=> {
              try {
                const x = onReject(this.#promiseResult);
                this.#resolvePromise(p, x, resolve, reject);
                // reject(x);
              } catch(err){
                reject(err);
              }
            })
          }
        })
      }
    })
    return p;
  }

  /**
  * catch方法,实际上就是在触发then方法
  * 调用then方法,参数1:undefined 参数2:catch传入的cb
  */
  catch(onReject) {
    return this.then(undefined, onReject);
  }

  /**
   * finally方法
   * @param {function} onFinally 成功或者失败,执行的cb
   * @returns MyPromise
   * 调用then方法,参数1:finally传入的cb 参数2:finally传入的cb
 */
  finally(onFinally) {
    return this.then(onFinally ,onFinally);
  }

  /**
 * 静态方法,resolve
 * 如果传入一个MyPromise实例,则直接返回这个实例,否则返回一个MyPromise实例
 * @param {*} value
 * @returns MyPromise
 */
  static resolve(value){
    if(value instanceof MyPromise) {
      return value;
    }
    return new MyPromise((resolve)=> {
      resolve(value);
    })
  }

  /**
 * 静态方法,reject
 * 传入拒绝的原因,通过返回MyPromise,调用reject()方法将错误原因返回
 * @param {*} error 拒绝的原因
 * @returns MyPromise
 * 传入如果是MyPromise实例直接返回,否则包装成MyPromise返回
 */
  static reject(error) {
    if(error instanceof MyPromise) {
      return error;
    }
    return new MyPromise((undefined,reject)=> {
      reject(error);
    })
  }
  
  /**
  * 实现静态race方法
  * @param {Array} promiseList 传入一个数组,并返回一个Promise
  * @returns MyPromise 返回的 promise 会随着第一个 promise 的敲定而敲定。
  * 如果第一个敲定的 promise 被兑现,那么返回的 promise 也会被兑现;如果第一个敲定的 promise 被拒绝,那么返回的 promise 也会被拒绝
  */
  static race(promiseList) {
    const res = new MyPromise((resolve, reject) => {
      if(!Array.isArray(promiseList)) {
        throw new TypeError('promiseList must is an array');
      }
      promiseList.forEach(p=> {
        MyPromise.resolve(p).then((res)=> {
          resolve(res);
        },err=> {
          reject(err);
        })
      })
    });
    return res;
  }

  /**
  * 实现静态All方法
  * @param {Array} promiseList 传入一个数组,并返回一个Promise
  * 1.判断传入的参数是否为数组,不是数组的话直接reject('错误信息')
  * 2.判断传入的数组是否为空,为空的话直接resolve([])
  * 3.遍历传入的数组,将每个promise的返回结果放入一个数组中,并记录已经完成的次数,当完成的次数等于数组的长度时,将数组返回
  * 4.处理第一个拒绝,有拒绝的话直接reject(err)
  */
  static all (promiseList) {
    const r = new MyPromise((resolve, reject) => {
      if(!Array.isArray(promiseList)) {
        throw new TypeError('promiseList must is an array');
      }
      if(promiseList.length === 0) {
        resolve([]);
      }
      let count = 0;
      const arr = [];
      promiseList.forEach((p,index)=> {
        MyPromise.resolve(p).then(res=> {
          arr[index] = res;
          count++;
          if(count === promiseList.length) resolve(arr);
        },err => {
          reject(err);
        })
      })
    })
    return r;
  }

  /**
 * 实现静态方法allSettled
 * @param {Array} promiseList 传入一个数组,并返回一个Promise
 * @returns MyPromise 当所有传入的 Promise 都已敲定时(无论成功或失败),返回的 Promise 将被兑现
 * 成功:{ status: 'fulfilled', value: 'success' }, 失败 {status:'rejected',reason:err}
 */
  static allSettled(promiseList) {
    const r = new MyPromise((resolve, reject) => {
      if(!Array.isArray(promiseList)) {
        throw new TypeError('promiseList must is an array');
      }
      if(promiseList.length === 0) resolve([]);
      let count = 0;
      const arr = [];
      promiseList.forEach((p,index)=> {
        MyPromise.resolve(p).then(res=> {
          arr[index] = {status:'fulfilled', value:res };
        },err=> {
          arr[index] = { status:'rejected',reason:err }
        })
        .finally(()=> {
          count+=1;
          if(count === promiseList.length) {
            resolve(arr);
          }
        })
      })
    });
    return r;
  }

  /**
 * 实现静态方法any
 * @param {Array} promiseList 传入一个数组,并返回一个Promise
 * @returns MyPromise
 * 当有一个 Promise 敲定且敲定值为成功时,返回的 Promise 将被兑现
 * 当所有Promise都敲定且都敲定的状态为拒绝时,返回的Promise将被拒绝,并返回error数组
 */
  static any(promiseList) {
    const r = new MyPromise((resolve, reject) => {
      if(!Array.isArray(promiseList)) {
        throw new TypeError('promiseList must is an array');
      }
      if(promiseList.length === 0) {
        resolve(new AggregateError(promiseList,'All promises were rejected'));
      }
      const errArr = [];
      let count = 0;
      promiseList.forEach((p,index)=> {
        MyPromise.resolve(p).then(res=> {
          resolve(res);
        },(err)=> {
          errArr[index] = err;
          count++;
          if(count === promiseList.length){
            reject(new AggregateError(errArr,'All promises were rejected'));  
          }
        })
      })
    })
    return r;
  }
}

Promise 实现的要点

  • 通过私有字段维护 status/result/thenables,解决多次 then 与异步回调入队。
  • runAsyncTask 使用 queueMicrotask 优先,保证微任务语义(A+ 要求异步触发 then 回调)。
  • then 中包装返回新的 MyPromise,并执行 Promise 解析过程#resolvePromise)。

使用 promises-aplus-tests 进行 Promise A+ 测试

1)安装测试工具

npm i -D promises-aplus-tests

2)导出你的 Promise 构造器
新建 my-promise-adapter.js(适配器文件,按规范暴露):

// my-promise-adapter.js
const MyPromise = require('./path/to/your/MyPromise.js'); // 若是 ESM,请转为 CJS 导出
module.exports = {
  deferred: () => {
    let resolve, reject;
    const p = new MyPromise((res, rej) => { 
       resolve = res; 
       reject = rej; 
    });
    return { 
       p, 
       resolve, 
       reject
    };
  }
};

3)运行测试

npx promises-aplus-tests my-promise-adapter.js

4)完成测试

image.png


总结

通过这次的练习,从0实现了 前端开发中高频的 8 个 JavaScript 核心能力
在动手实现的过程中,你会发现:

  • 防抖与节流,本质上是对 函数执行时机的控制
  • call / apply / bind,考察了 this 的绑定机制与函数调用的底层逻辑;
  • instanceof,实际上就是沿着原型链查找;
  • 发布订阅模式,是事件总线和状态管理的基础;
  • Promise 的实现,最能考察你对 异步编程、状态机、事件循环 的理解。

这些题目并不仅仅是“面试题”,更是写出高质量前端代码的底层能力

fetch、axios和Ajax

作者 Aphasia
2025年9月3日 23:57

引言💭

我们知道请求一般有三种方式:fetch、axios和Ajax,那么该如何选择呢?

1. 基本概念与发展背景

  • AJAX(Asynchronous JavaScript and XML)
    早期的网页主要是同步加载,用户每次点击都需要整页刷新。AJAX 技术的出现,让前端可以通过 XMLHttpRequest(XHR)在后台与服务器通信,只更新部分页面,大幅提升了用户体验。虽然名字里有 XML,但现在更多用 JSON 作为数据格式。AJAX 是浏览器内置的 API,无需安装。
  • Fetch
    随着前端生态发展,XHR 的回调写法显得繁琐,缺乏链式调用,且不符合 Promise 风格。于是浏览器引入了 Fetch API,它基于 Promise,写法更简洁直观,能更好地和 async/await 搭配使用。缺点是对一些高级功能支持不足(如进度监听、超时设置)。
  • Axios
    第三方团队基于 XHR 和 Fetch 封装的 HTTP 客户端。Axios 在社区中非常流行,几乎成为前端项目(尤其是 React、Vue 项目)的“标配”。它不仅支持浏览器环境,还能在 Node.js 和 React Native 中使用。Axios 提供了拦截器、自动处理 JSON、统一错误处理、请求取消、进度监控等高级功能。

👉 发展脉络总结:XHR → Fetch(更现代写法) → Axios(功能最全的封装库)。


2. 主要区别与特点

  1. 语法与代码简洁度

    • AJAX 使用回调函数,嵌套多时容易形成“回调地狱”。
    • Fetch 使用 Promise,支持链式调用和 async/await,语法更简洁。
    • Axios 基于 Promise,进一步封装,写法最短。
  2. 数据处理方式

    • AJAX:返回的是字符串,通常要手动 JSON.parse()
    • Fetch:提供 .json() 方法,稍微方便一些,但仍需显式调用。
    • Axios:自动把响应转换为 JSON(如果是 JSON 格式),几乎不用手动处理。
  3. 错误处理机制

    • AJAX:需要开发者自己检查 xhr.status 来判断成功与否。
    • Fetch:默认只在网络错误时抛出异常,如果服务器返回 404/500,依然算作成功响应,需手动检查 response.ok
    • Axios:自动把 HTTP 错误(如 404、500)抛出,错误处理更自然。
  4. 高级功能支持

    • 拦截器:只有 Axios 内置,能在请求/响应前统一处理,比如在请求头加 token。

    • 取消请求:XHR 有 abort();Fetch 要用 AbortController;Axios 内置支持 CancelTokenAbortController

    • 上传/下载进度:XHR 和 Axios 支持进度监听,Fetch 不支持。

    • 跨域处理

      • XHR 需设置 withCredentials
      • Fetch 要配置 credentials: 'include'
      • Axios 默认会自动携带 Cookie,使用更方便。
  5. 兼容性

    • AJAX:所有浏览器都支持,包括旧版 IE。
    • Fetch:仅现代浏览器支持,不兼容 IE。
    • Axios:通过内部封装,兼容性较好,也能在 Node.js、React Native 中使用。

👉 一句话总结:Axios 功能最全面;Fetch 语法简洁;XHR 胜在兼容性。


3. 使用体验对比(代码示例)

(1) AJAX(XHR)

const xhr = new XMLHttpRequest();
xhr.open('GET', '/data');
xhr.onload = () => {
  if (xhr.status === 200) {
    console.log(JSON.parse(xhr.response));
  } else {
    console.error('Error:', xhr.status);
  }
};
xhr.send();

➡️ 缺点:写法冗长,处理复杂请求时更麻烦。


(2) Fetch

fetch('/data')
  .then(res => {
    if (!res.ok) throw new Error(res.status);
    return res.json();
  })
  .then(data => console.log(data))
  .catch(err => console.error('Error:', err));

➡️ 优点:语法简洁,天然支持 Promise。缺点:HTTP 错误需要手动处理。


(3) Axios

axios.get('/data')
  .then(res => console.log(res.data))
  .catch(err => console.error('Error:', err));

➡️ 优点:写法最短,自动处理 JSON 和 HTTP 错误,功能最全。


4. 选择建议

  • 现代浏览器中的简单请求 → 直接用 Fetch,语法简单、原生支持。
  • 需要高级功能(拦截器、全局错误处理、取消请求等) → 用 Axios,更省心。
  • 兼容旧浏览器(IE 等) → Axios 或 jQuery.ajax,保证兼容性。
  • 需要监控上传/下载进度(如文件上传) → 只能用 Axios 或 XHR。
  • 跨平台开发(React Native / Node.js) → 推荐 Axios。

总结✒️

  • 轻量请求 → Fetch
  • 复杂场景 → Axios
  • 兼容性优先 → XHR

猫抓爱心.gif

浅谈React中虚拟DOM、diff算法、fiber架构的关系(面试可用)

作者 天天扭码
2025年9月3日 23:02

写在前面

正如标题所讲,这里只是浅谈三者的关系,并不会很深入的去讲底层的源码之类的东西,大家可以有批判的去看

开始吧

一、偏虚拟DOM部分

首先是虚拟DOM树,为什么React要有虚拟DOM树,什么是虚拟DOM树。JS虽然是用来操作DOM的脚本,但是操作DOM的成本还是很大的,那么有没有一种成本低一点的DOM树操作?有的兄弟,我们可以用JS去模拟虚拟DOM树,哎,既然不操作真实的DOM树,页面也不会变,这不是自己骗自己吗兄弟?实则不然。现在就先理解成是自己骗自己吧。

让我们聚焦到虚拟DOM的模拟,JS如何模拟虚拟DOM,很简单一个对象代表一个节点就可以了,对象模拟节点其实很简单,简单来说只需要三个属性,现在是三个节点,三个,考虑的是最简单的情况,如下

三个属性就可以,我们拿一个最简单的DOM树来看

image.png

可以看一下这个DOM树,这里一个蓝色的圈是一个节点,彩色是附着在黑色上的哈

那么一个节点在HTML中是什么表现形式呢?

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8"> <!-- meta元素,设置charset属性 -->
    <title>这里是标题文本</title> <!-- title元素,包含文本 -->
</head>
<body>
    <a href="#">这里是链接文本</a> <!-- a元素,包含href属性和文本 -->
</body>
</html>

那么现在我们要模拟一个节点,我们要考虑什么,现在就最简单的考虑。

首先要有节点的类型——head、meta、title、body、a

其次要有节点中的属性——比如charset、href

最后我们要有子节点——比如meta、title是head的子节点;a是body的子节点等。

OK!三个属性齐活了,下面的Element就是我们的模拟,对,很漂亮的模拟。很多的节点对象就组成了一个虚拟DOM树。

// element.js
// 虚拟DOM元素的类,构建实例对象,用来描述DOM
class Element {
    constructor(type, props, children) {
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
// 创建虚拟DOM,返回虚拟节点(object)
function createElement(type, props, children) {
    return new Element(type, props, children);
}
export {
    Element,
    createElement
}

经过上述的讲解,我们大概了解了虚拟dom树的结构,那么接下来我们来考虑虚拟DOM树为何高效,以及虚拟DOM树是否是“自己骗自己”

虚拟DOM为何高效?

这个是对比来讲的,我们可以来看一下为什么js操作真实DOM昂贵之处就理解了——

  1. 布局:  当你修改了 DOM 元素的几何属性(如宽度、高度、位置、边距、边框等)时,浏览器需要重新计算所有受影响元素的几何位置和尺寸,这个过程叫做“布局”或“重排”。
    想象一下,你在一个大文档中移动了一个小方块。对于这个方块,浏览器需要算它的新位置。但如果这个方块周围的元素也受其影响(比如,如果它旁边的文本会因为方块移动而换行),那么浏览器需要重新计算这些周围元素的布局。更糟糕的是,如果这个方块的位置依赖于它父元素的大小,而父元素的大小又依赖于它的父元素…… 这种情况会形成一个连锁反应,可能需要浏览器重新计算整个页面或部分页面的布局。

  2. 绘制:  当像素颜色、背景、边框颜色等外观属性发生变化时,浏览器需要重新绘制受影响的区域,这个过程叫做“绘制”。
    改变一个元素的背景颜色,或者改变一个元素的文本颜色。浏览器只需要重新渲染这些受影响的像素。

为什么 JavaScript 直接操作会频繁触发这些操作?

考虑以下 JavaScript 代码:

const div = document.getElementById('myDiv');

// 第一次修改:宽度
div.style.width = '200px'; // 触发一次 Layout

// 第二次修改:高度
div.style.height = '300px'; // 再次触发一次 Layout

// 第三次修改:背景颜色
div.style.backgroundColor = 'blue'; // 触发一次 Repaint

// 第四次修改:文本颜色
div.style.color = 'white'; // 再次触发一次 Repaint

在这个简化的例子中,每一次对 div.style 的直接修改(尤其是修改几何属性)都可能立即触发一次浏览器对 DOM 的计算和更新(Reflow 或 Repaint)。如果在一个循环中进行多次 DOM 操作,浏览器就会非常忙碌,不断地 recalculate -> repaint -> recalculate -> repaint,导致明显的性能瓶颈。

那么,虚拟DOM是如何提升效率的?可以分为三个步骤

  1. 生成新的DOM树

    • 当 React 组件的状态或 props 发生变化时,React 不会立刻去操作真实 DOM。
    • 而是使用新的 state/props 去重新执行组件的 render 方法,生成一个新的虚拟 DOM 树
  2. Diff 算法的“批量更新”:

    • React 将这个新的虚拟 DOM 树与上一次渲染生成的旧虚拟 DOM 树进行比较。
    • 这个比较过程(Diff 算法)非常高效,是 O(n) 的时间复杂度,因为它遵循上述的启发式规则。Diff 算法会找出两棵虚拟 DOM 树之间“真正”的差异:哪些节点被修改了 props,哪些节点被删除,哪些节点是新增的,哪些节点被移动了。
    • React 会生成一个包含所有这些差异的“变更列表”(patch list)。
  3. 一次性更新真实 DOM:

    • 最后,React 会将这个“变更列表”一次性地应用到真实 DOM 上。
    • 因为 React 已经知道需要进行哪些更改,它可以将这些更改进行批量处理。比如,如果多个元素的位置发生了变化,React 可以一次性地计算出所有受影响元素的最终布局,而不是每次都单独计算。如果多个元素的背景色发生了变化,React 也可以批量地进行重绘。
    • 这种“批量更新(Batch Update)”策略极大地减少了浏览器执行昂贵的 Reflow 和 Repaint 的次数。

现在让我们回想第一个问题——虚拟DOM为何高效?

1.批量更新的策略把最耗性能的操作(重绘重排)减少了几倍不止,这在很多情况下就意味着性能提升了几倍,这是巨大的提升。

2.diff算法找出了虚拟DOM和真实DOM的变化差异的最小“变更列表”,没有虚拟 DOM 时,也可能只更新变化部分,但效率和控制程度不如使用虚拟 DOM 和 Diff 算法

那么第二个问题——虚拟DOM是“自己骗自己吗”?

如果虚拟DOM树只有前两步可能是的(生成新的DOM树、Diff 算法的“批量更新),因为它们并不影响真实的dom渲染,但是如果加上了最后一步(一次性更新真实 DOM),意义就变了,这就直接摆脱了“自己骗自己”的嫌疑,还被送上了“大幅提升性能”的锦旗。

虚拟DOM难道在进行完性能的优化之后就燃尽了吗?不会的,其实虚拟DOM还有其他的一些好处,我们拿一个来讲——跨平台性

通过前面的讲解,我们知道虚拟dom在不更改真实DOM前是和浏览器的DOM分离的,这就意味着虚拟DOM不止可以“映射”到浏览器DOM,可以使用 Virtual DOM 框架(如 React, Vue)来构建在多种平台运行的 UI,而无需重写核心逻辑。

但是总的来讲,虚拟DOM最大的优势还是性能上的优化。这里简单解决一下大家可能有的一个疑问——虚拟DOM可以批量更新,那么是如何判断哪些“是一批”呢?

简单来说,React 认为“一批”更新是指:

在同一个 JavaScript 事件循环周期内,或者在 React 自身的调度过程中,所有触发的状态更新被累积起来,并在一个合适的时机(通常是 React 准备进行 DOM 更新之前)进行合并和处理,最终生成一次对真实 DOM 的批量操作。

简单的总结:

React v17 及之前(非并发模式):

  • 事件处理器内的 setState 属于一批(同步批量)。
handleClick = () => {
   // 这几次 setState 发生在同一个事件处理函数内,
   // 它们会被 React 收集并合并处理。
   this.setState({ count: this.state.count + 1 });
   this.setState({ count: this.state.count + 1 }); // 第二个会覆盖第一个,最终 count + 2
   this.setState({ text: 'Clicked!' }); // 新的状态
};
  • 其他地方(setTimeout, Promise, 生命周期等)的 setState 属于另一批(异步批量)。
componentDidMount() {
   // 这是一个异步操作
   setTimeout(() => {
     this.setState({ visible: true });
   }, 100);

   // 另一个在 componentDidMount 中调用的 setState,
   // 尽管时间上不一致,但它们同属于 componentDidMount 的执行上下文,
   // 并且 React 会将它们放入一个大的异步更新队列里。
   this.setState({ data: fetchedData });
 }
  • React 会等待当前的同步任务(如事件处理器)执行完毕,然后处理所有累积的异步更新。

React v18 及以后(默认并发模式):

  • 所有 setState 默认都被视为“可以被延迟”的更新。
  • React 的调度器会根据任务的紧急程度(通过 startTransition 等 API 控制)将更新分组。
  • “一批”是指,能够被 React 在一个渲染阶段(或者一系列连续的微任务/宏任务)中一次性处理和应用到 DOM 的更新集合。

核心是:React 不会对每一次 setState 都进行一次完整的 DOM diff 和更新。它会尽可能地将多个状态更新的意图收集起来,进行合并和计算,最终通过一次或几次高效的 DOM 操作来完成 UI 的更新。

setState大家可以简单的理解为组件状态更新

所以简单来说,很大可能下的批量更新是这样进行的

1.执行调用栈中的同步代码 (如果有)。
2.检查并执行宏任务队列,每次只取出一个任务执行。
3.检查并清空微任务队列 (在每个宏任务执行后立即清空)。
4.批量更新渲染 (对于浏览器环境,例如,进行 DOM 更新)。

二、偏虚拟diff算法部分

OK,那么现在回归正题,我们在前面的讲解中可以发现在虚拟DOM批量更新的过程中有一个叫做diff算法的东西,我们可以来回顾一下diff算法的作用

image.png

我们可以得知diff算法发挥作用的阶段是比较新旧虚拟DOM树的阶段,会生成一个“变更列表”,根据这个更新列表才可以一次性更新虚拟DOM树。

我们知道,虚拟DOM树是庞大的,树与树的比较更是性能低下,而且直接js操作dom并不涉及到虚拟DOM树的比较,所以这个比较是一项额外的开销,如何把这个开销给压低到最小是很有意义的,这时候就要搬出diff算法了,那么diff算法到底是如何做到的高效比较呢?

React 的 Diff 算法是基于  “最优化的、启发式的”  策略。它的核心目标是在 O(n) 的时间复杂度内找到两个树形结构之间的最小差异,其中 n 是节点数。为了达到这个目标,它做了一些合理的假设和简化:

  1. 跨组件类型的比较:  如果根节点是不同的组件类型,React 会认为旧的组件树已经完全没有价值,直接销毁旧组件树,然后创建全新的组件树。例如,<div> 变成 <span>,或者 MyComponentA 变成 MyComponentB

    为什么?  因为跨组件类型的差异通常很大,而且维护它们的关联性(比如生命周期、state 迁移)会非常复杂和低效。

  2. 同一组件类型的比较:  如果两个组件是同一类型,React 会保留现有的 DOM 节点,并继续比较它们的子节点。

    如果子节点是不同类型:  同样遵循规则 1,直接替换。

    如果子节点是同一类型:

    • 同一种 DOM 标签:  React 会比较它们的属性。如果属性不同,则只更新修改的属性。然后,递归地对子节点进行 Diff。

    • 列表(数组)的子节点:  这是 Diff 算法中最复杂和关键的部分。如果一个节点有一个子列表,React 需要一种方式来高效地标识列表中的每个元素。

      问题:  如果列表顺序发生变化,或者有元素被插入、删除,如何快速找到对应的元素?

      解决方案:key 属性!  React 要求为列表中的每个元素提供一个 唯一且稳定的 key 属性

      key 的作用:  key 就像是列表中每个元素的身份 ID。当 Diff 算法比较列表时,它会根据 key 来匹配新旧节点。

      查找相同 key 的节点:  如果新旧列表中都有同一个 key 的节点,React 认为它们是同一个元素,然后比较它们的属性和子节点。
      查找新增加的 key  如果新列表中有 key,但在旧列表中不存在,则认为是一个新节点,进行插入。
      查找被删除的 key  如果旧列表中有 key,但在新列表中不存在,则被认为是旧节点,进行删除。

      为什么 key 必须稳定?  如果 key 每次渲染都变化,Diff 算法就无法有效地识别出哪些元素是“同一个”,反而会认为所有元素都发生了变化,导致不必要的 DOM 重建,性能更差。

      不推荐使用 index 作为 key  如果列表顺序会改变(插入、删除、移动),使用 index 作为 key 会导致 Diff 算法误判,带来性能问题。只有在列表是静态的、不会改变顺序,并且没有被插入/删除元素时,index 才勉强可以接受(但仍不推荐)。

Diff 算法的步骤概览 (针对比较两个节点 oldNode 和 newNode):

  1. 如果 newNode 为 null 或 undefined

    • 返回“删除节点”的操作。
  2. 如果 oldNode 为 null 或 undefined

    • 返回“创建节点”的操作。
  3. 如果 oldNode 和 newNode 不是同一个 DOM 元素(标签、组件类型不同):

    • 返回“替换节点”(即删除 oldNode,创建 newNode)的操作。
  4. 如果 oldNode 和 newNode 是同一个 DOM 元素(标签相同):

    • 比较属性:  找出新旧属性的差异,生成“更新属性”的操作。

    • 比较子节点:

      • 如果子节点数量不同:

        • 根据 key,找出需要新增、删除、移动的子节点,生成相应的操作。
      • 如果子节点数量相同:

        • 递归地对每一对相同 key 的子节点调用 Diff 算法。

Diff 算法的实现细节思考:

  • 虚拟 DOM 的层级遍历:  Diff 算法是在虚拟 DOM 树上进行的,它会从根节点开始,自顶向下地进行比较。
  • JavaScript 对象模拟 DOM:  虚拟 DOM 本质上就是用 JavaScript 对象来描述 DOM 结构,这使得在内存中进行高效的比较成为可能。
  • Patch:  Diff 算法的最终产物不是直接修改 DOM,而是一个包含所有实际 DOM 操作的  “补丁”(Patch)  对象。这个 Patch 对象会被传递给 DOM 更新模块,由它来批量地、高效地将这些操作应用到真实 DOM 上。

Diff 算法的核心就是通过智能的比较策略(如基于组件类型、DOM 标签、属性以及最重要的 key 属性)来找出两棵虚拟 DOM 树的差异,并生成一系列精确的 DOM 操作指令,最终实现高效的 DOM 更新。其中,key 属性对于列表的 Diff 至关重要,能够极大地提升列表项增删改查的性能。

我们知道了上面的比较逻辑之后,甚至可以自己写一个简单的diff算法

// diff.js
function diff(oldTree, newTree) {
    // 声明变量patches用来存放补丁的对象
    let patches = {};
    // 第一次比较应该是树的第0个索引
    let index = 0;
    // 递归树 比较后的结果放到补丁里
    walk(oldTree, newTree, index, patches);
    return patches;
}

function walk(oldNode, newNode, index, patches) {
    // 每个元素都有一个补丁
    let current = [];

    if (!newNode) { // rule1
        current.push({ type: 'REMOVE', index });
    } else if (isString(oldNode) && isString(newNode)) {
        // 判断文本是否一致
        if (oldNode !== newNode) {
            current.push({ type: 'TEXT', text: newNode });
        }

    } else if (oldNode.type === newNode.type) {
        // 比较属性是否有更改
        let attr = diffAttr(oldNode.props, newNode.props);
        if (Object.keys(attr).length > 0) {
            current.push({ type: 'ATTR', attr });
        }
        // 如果有子节点,遍历子节点
        diffChildren(oldNode.children, newNode.children, patches);
    } else {    // 说明节点被替换了
        current.push({ type: 'REPLACE', newNode});
    }
    
    // 当前元素确实有补丁存在
    if (current.length) {
        // 将元素和补丁对应起来,放到大补丁包中
        patches[index] = current;
    }
}

function isString(obj) {
    return typeof obj === 'string';
}

function diffAttr(oldAttrs, newAttrs) {
    let patch = {};
    // 判断老的属性中和新的属性的关系
    for (let key in oldAttrs) {
        if (oldAttrs[key] !== newAttrs[key]) {
            patch[key] = newAttrs[key]; // 有可能还是undefined
        }
    }

    for (let key in newAttrs) {
        // 老节点没有新节点的属性
        if (!oldAttrs.hasOwnProperty(key)) {
            patch[key] = newAttrs[key];
        }
    }
    return patch;
}

// 所有都基于一个序号来实现
let num = 0;

function diffChildren(oldChildren, newChildren, patches) {
    // 比较老的第一个和新的第一个
    oldChildren.forEach((child, index) => {
        walk(child, newChildren[index], ++num, patches);
    });
}

// 默认导出
export default diff;

这样看来也不是很难理解,不是么?

我们来总结一下diff算法的作用

Diff 算法的核心在于比较虚拟 DOM 的差异,生成描述这些差异的补丁(Patch)。 它通过对新旧虚拟 DOM 的节点进行递归比较,找出最小的更新集合,避免了直接操作 DOM 带来的性能损耗。 这个过程的关键在于高效地比较节点类型、属性和子节点,最终目标是只更新变化的部分,从而最大限度地减少对真实 DOM 的操作,提高页面更新的效率。

diff算法的高性能可以体现在两个方面

  • 最小限度的减少了新旧虚拟DOM树的比较开支(有负面buff,但是尽可能把buff的影响变小)
  • 最大限度地减少对真实 DOM 的操作(有正面buff)

三、偏虚拟fiber架构部分

讲实话,前面的优化已经很完美、很天才了,但是,还有高手——fiber架构

正如前面所讲,虚拟 DOM 和 Diff 算法的组合拳已经极大地提升了 React 的性能。它们通过“计算差异”和“批量更新”的策略,有效减少了直接操作真实 DOM 带来的昂贵开销。然而,随着前端应用变得越来越复杂,组件树越来越深,旧的 React 协调机制逐渐暴露出一个核心瓶颈:这是一个不可中断的同步计算过程

为什么“不可中断”是个问题?

在 React 16 之前,当组件的状态或 props 发生变化时,React 会立即开始工作:

  1. 调用 Render:  重新渲染整个组件子树(生成新的虚拟 DOM)。
  2. 进行 Diff:  递归比较新旧两棵虚拟 DOM 树。
  3. 应用更新:  将计算出的差异(Patch)应用到真实 DOM。

这个过程是同步的,并且会一次性完成。如果组件树非常庞大,这个计算过程就会长时间占用 JavaScript 主线程。

而浏览器的主线程是单线程的,它除了要执行 JavaScript,还负责样式计算、布局、绘制等任务。长时间被 JS 计算霸占主线程会导致:

  • 浏览器无法及时响应用户的输入(点击、滚动等),页面会感觉“卡顿”。
  • 浏览器无法按时完成帧渲染,导致动画掉帧、页面渲染不流畅。

“栈协调”的困境
你可以把旧的协调过程想象成 React 在“递归”地遍历整个组件树。JavaScript 的递归调用会形成一个很深的“调用栈”。React 必须完整地走完这个调用栈,才能进行下一步。它无法暂停,无法中途去处理更高优先级的任务(比如用户的点击)。这种基于递归深度优先遍历的架构被称为  “栈协调”

Fiber 架构的诞生

讲实话,前面的优化(虚拟DOM + Diff算法)已经很完美、很天才了。它们通过“计算差异”和“批量更新”的策略,极大地减少了直接操作DOM带来的性能开销。但是,随着前端应用变得越来越复杂,组件树越来越深,旧的React协调机制(Stack Reconciler)逐渐暴露出一个核心瓶颈:整个虚拟DOM的Diff过程是一个【不可中断】的同步计算过程

为什么“不可中断”是个致命问题?

在React 16之前,当状态变化触发更新时,React会这样做:

  1. 递归渲染:重新渲染整个受影响的组件子树,生成新的虚拟DOM树。
  2. 递归Diff:递归地比较新旧两棵虚拟DOM树。
  3. 提交更新:将计算出的差异(Patch)应用到真实DOM。

这个过程是同步且一气呵成的。如果组件树非常庞大,这个复杂的计算过程就会长时间霸占JavaScript主线程。

而浏览器的主线程是单线程的,它除了要执行JavaScript,还负责样式计算、布局、绘制以及响应用户交互(如点击、滚动)。长时间被JS计算阻塞会导致:

  • 页面卡顿:浏览器无法及时响应用户操作,点击按钮没反应,滚动起来一卡一卡。
  • 掉帧:动画无法在16.6ms内完成一帧的渲染,导致视觉上的不流畅。

“栈协调”的困境
旧的协调器基于递归。递归调用会在JavaScript引擎中形成一个很深的“调用栈”。React必须完整地走完这个调用栈(即处理完整个虚拟DOM树),才能进行下一步。它就像一列高速行驶的火车,无法在中间站点暂停让道,必须到终点站才能停下。这种机制被称为  “栈协调”(Stack Reconciler)

Fiber架构的诞生:为了解决“不可中断”

为了从根本上解决“同步更新不可中断”的问题,React团队从底层重写了协调算法,引入了Fiber架构Fiber Reconciler

Fiber是什么?
Fiber可以从两个层面理解:

  1. 一种数据结构:Fiber是React 16+中虚拟DOM的新表示形式。它是一个功能更强大的JavaScript对象,包含了比传统虚拟DOM节点更丰富的调度信息。
  2. 一个执行单元:Fiber代表了可以拆分、可以调度、可以中断的一个工作任务。React的渲染和更新过程不再是一次性递归完成,而是分解成一个个小的Fiber节点任务来处理。

这时候就有同学要问了:‘为什么不在原来的虚拟DOM树上直接加个中断机制,非要整一个新的Fiber出来?’

问得好!答案是:传统的虚拟DOM树数据结构不支持

  • 传统的虚拟DOM树节点之间只有父子关系,通过children数组连接。这是一种递归树形结构。中断后,你想恢复遍历,必须从头开始或者用非常复杂的方式记录进度,成本极高。
  • Fiber节点通过链表连接。每个Fiber节点不仅有指向第一个子节点(child)的指针,还有指向下一个兄弟节点(sibling)和父节点(return)的指针。这种链表树结构使得React可以用循环来模拟递归遍历。中断时,只需保存当前正在处理的Fiber节点引用,恢复时就能立刻从它开始继续处理它的childsibling,极其高效。

Fiber节点的核心属性(了解即可):

  • type & key: 同虚拟DOM,标识组件类型和列表项的Key。
  • child: 指向第一个子Fiber。
  • sibling: 指向下一个兄弟Fiber。
  • return: 指向父级Fiber。
  • alternate一个极其重要的指针。它指向另一棵树上对应的Fiber节点,是实现双缓存和Diff比较的基础。
  • stateNode: 对应的真实DOM节点或组件实例。
  • flags (旧版叫effectTag): 标记这个Fiber节点需要进行的操作(如Placement-插入, Update-更新, Deletion-删除)。

Fiber如何工作?可中断的“双缓存”策略

Fiber架构将协调过程分为两个截然不同的阶段:

  1. Render / Reconciliation Phase (渲染/协调阶段)

    • 可中断、可恢复、异步。  这个阶段负责计算“哪些需要更新”,但绝不操作真实DOM
    • React会在内存中构建一棵新的Fiber树,称为 WorkInProgress Tree(工作在进行树) 。它通过与当前屏幕上显示的 Current Tree(当前树)  上的Fiber节点进行Diff比较来完成构建。
    • 工作方式:React的调度器会循环处理每个Fiber单元。处理完一个单元,它就检查主线程是否还有空闲时间(通过requestIdleCallbackscheduler)。如果没有时间了,或者有更高优先级的任务(如用户输入),React就立刻中断当前工作,保存进度(下一个要处理的Fiber),把主线程交还给浏览器。等浏览器忙完了,React再回来从断点继续。
    • 这个阶段可能会被打断多次。
  2. Commit Phase (提交阶段)

    • 不可中断、同步执行。  这个阶段是React将协调阶段计算出的所有副作用(即需要更新的操作列表)一次性、同步地应用到真实DOM上的阶段。
    • 因为这个阶段会实际操作DOM,而DOM的变更会立刻触发浏览器的重绘重排,所以必须快速完成,用户不会看到“更新到一半”的UI。
    • 一旦开始提交,React就会一口气完成所有DOM操作。

刚才其实已经提到了“双缓存”的概念,这里展开讲讲 “双缓存”技术

Current Tree和WorkInProgress Tree通过alternate指针相互指向。当WorkInProgress Tree构建完成并在提交阶段渲染到屏幕后,这两棵树会“互换角色”:刚刚建好的WorkInProgress Tree就变成新的Current Tree,而旧的Current Tree就作为下一次更新的WorkInProgress Tree的基础。这保证了渲染的连贯性和性能。

  • 当 React 开始一个渲染周期(例如,响应一个事件,数据改变)时,它会创建一个新的 Fiber 树,称为  “工作” Fiber 树
  • 这个“工作” Fiber 树是独立于用户当前看到的 DOM 树的。
  • Fiber 节点在“工作” Fiber 树中进行计算,标记出需要更新、插入或删除的 DOM 节点。
  • 当整个“工作” Fiber 树的计算完成后,React 会执行一个  “提交”(commit)  阶段。
  • 在“提交”阶段,React 会遍历“工作” Fiber 树,并将所有需要进行的 DOM 操作一次性地应用到真实的 DOM 上。
  • 此时,老的一棵 Fiber 树(代表了用户当前看到的 UI)可以被标记为“已完成”或被丢弃,而新的 Fiber 树则成为了“当前”的 Fiber 状态,准备下一次更新。

OK,关于虚拟DOM、diff算法、fiber架构大概就是这么多内容,下面我们来进行一些有趣的探讨,来解决大家心中可能有些迷惑的地方。

react中diff算法发挥作用的阶段是比较新旧虚拟DOM树的阶段,还是生成新的虚拟DOM的阶段?

Diff 算法发挥作用的阶段是“比较新旧虚拟DOM树的阶段”,而不是“生成新的虚拟DOM的阶段”。

让我们来清晰地分解这两个阶段:

阶段一:生成新的虚拟DOM (Render Phase)

  • 发生了什么?  当组件的状态(state)或属性(props)发生变化时,React 会重新执行组件的 render 方法。
  • 结果是什么?  这个执行过程会返回一个新的 React 元素树(即新的虚拟DOM树)。这个过程是声明式的,React 只是根据当前最新的 state 和 props 计算出 UI 应该是什么样子。
  • Diff算法参与了吗?  没有。  这个阶段只是简单地根据数据生成一个新的UI描述(一棵新的树),不涉及任何比较操作。

阶段二:比较新旧虚拟DOM树 (Reconciliation Phase)

  • 发生了什么?  在生成了新的虚拟DOM树之后,React 需要弄清楚新的树和当前屏幕上显示的旧虚拟DOM树(current tree)之间的差异。
  • 如何工作?  React 会启动 Diffing 算法,递归地比较新旧的 React 元素(虚拟DOM节点)的 typekey 和 props
  • 结果是什么?  Diff 算法会得出一份精确的“变更清单”(或称为副作用 effect list),详细记录了为了将旧树更新为新树,需要对真实DOM进行的具体操作,例如:“在父节点下插入一个id为X的新节点”、“更新id为Y的节点的className属性”、“删除id为Z的节点”等。
  • Diff算法参与了吗?  是的!  这是 Diff 算法核心工作的阶段。

新虚拟DOM树的构建是否依赖于旧的虚拟DOM树?

不会。

新的虚拟DOM树的生成是一个完全独立纯粹的过程,它不依赖于旧的虚拟DOM树。这是一个非常关键的设计理念。

让我们来详细解释:

1. 生成新虚拟DOM树的逻辑

当组件的状态(state)或属性(props)发生变化时,React 会做的就是重新执行组件的渲染函数(对于类组件是 render() 方法,对于函数组件是函数体本身)。

这个过程可以简化为:
新的虚拟DOM树 = render(currentState, currentProps)

它只依赖于两个输入:

  1. 当前最新的状态(currentState)
  2. 当前接收到的属性(currentProps)

旧的虚拟DOM树(或旧的 Fiber 树)不是这个函数的输入参数。渲染函数就像一个“纯函数”,给定相同的 state 和 props,它总是会返回相同的 UI 描述(虚拟DOM树)。它完全不知道、也不关心上一次渲染出来的结果是什么。

2. 为什么这个设计如此重要?

这种“不依赖旧树”的设计是 React 声明式编程模型的核心优势:

  1. 可预测性 :UI 只由当前的 state 和 props 决定,这使得应用的行为非常容易理解和预测。你不需要考虑“当前屏幕上显示的是什么”,只需要思考“在这个数据状态下,UI 应该是什么样子”。
  2. 简化逻辑:开发者编写渲染逻辑时,只需要关注如何根据当前数据构建UI,而不需要处理如何从旧UI更新到新UI的复杂指令(这是 React 的职责)。
  3. 性能优化的基础:正因为生成新树是一个相对独立的过程,React 才能在背后灵活地调度这项工作。例如,在并发模式下,React 可以先在后台为一次即将到来的更新生成新的虚拟DOM树,而不立即提交,如果又有更高优先级的更新插队,它甚至可以丢弃这棵还没用完的树,重新开始生成,而不会破坏一致性。

在Fiber架构下diff算法到底是比较的什么?

在 Fiber 架构下,Diff 算法比较的并不是两棵完整的、传统的“虚拟DOM树”,而是两棵“Fiber 树”上的对应节点

理解这一点是理解 Fiber 架构如何工作的核心。让我们来彻底拆解它。

核心概念:两棵Fiber树

在 Fiber 架构中,React 在内存中同时维护着两棵 Fiber 树:

  1. Current Tree(当前树)

    • 这棵树代表当前已渲染到屏幕上的UI状态
    • 树中的每个 Fiber 节点都直接对应着一个真实的 DOM 节点(对于宿主组件如 divspan)或一个组件实例(对于类组件)。
    • 你可以把它想象成“旧的虚拟DOM树”的Fiber版本。
  2. WorkInProgress Tree(工作中树)

    • 当状态更新发生时,React 会在后台开始构建这棵新树。它代表了下一次渲染希望更新到的UI状态
    • 这棵树的构建过程是可中断的。
    • 你可以把它想象成“新的虚拟DOM树”的Fiber版本。

Diff 算法的本质,就是比较同一节点在 Current Tree 和 WorkInProgress Tree 上的两个 Fiber 节点。

Diff 的具体过程:ReconcileChildren

Diff 过程发生在 React 为 WorkInProgress 树创建子节点(Fiber节点)的时候。这个函数通常被称为 reconcileChildren 或 reconcileChildFibers

它的工作流程如下:

  1. 输入:一个父级 Fiber 节点(在 WorkInProgress 树中)和它通过 render 函数返回的 新的React元素(React Elements)
  2. 目标:为这些新的React元素创建或复用Fiber节点,从而构建出父级Fiber的 child 链表。
  3. 比较策略:React 会将新的React元素Current Tree中该父F节点下对应的旧子Fiber节点进行比较。

这个过程是逐层逐节点进行的,而不是一次性比较整棵树。

Diff 算法在Fiber中比较的具体内容

当处理一个新的React元素和一个旧的Fiber节点时,Diff 算法会按顺序检查以下属性:

  1. Fiber.key vs. Element.key

    • 这是第一优先级!  这是列表diff性能的关键。
    • 算法会首先检查key是否相同。如果key不同,React会认为这是一个不同的元素,即使type相同。
  2. Fiber.type vs. Element.type

    • 这是第二优先级!  检查节点类型(如 'div''MyComponent')是否相同。
    • 如果key相同但type不同,React会认为需要替换整个节点及其子树(因为一个 div 不可能直接变成一个 span)。
  3. Fiber.pendingProps vs. Element.props

    • 如果key和type都相同,React则认为这是一个可以复用的Fiber节点。
    • 接下来会比较新旧属性(props)的差异,并将需要更新的属性标记出来。

Fiber节点的“池化”

在Fiber架构中,“比较”的目的不仅仅是找出差异,更重要的是尽可能地复用现有的Fiber节点。这是一种性能优化策略,类似于“对象池”。

  • 如果可以复用(key和type都相同) :React不会销毁旧的Fiber节点并创建一个全新的对象,而是会 “克隆” 旧的Fiber节点(来自Current树),用它来构建WorkInProgress树。它只是用新的props和新的子元素引用更新这个克隆体的属性。

    • 好处:避免了频繁创建和销毁JavaScript对象的开销,极大提升了性能。
  • 如果不能复用(key或type不同) :React会为新的React元素创建一个全新的Fiber节点,并标记旧的Fiber节点及其整个子树需要被删除

读到这里,也可以猜到我的下一个问题

Fiber树的构建是否依赖虚拟DOM树

是的,Fiber树的构建完全依赖虚拟DOM树,并且这是一个持续依赖的关系。

更准确地说,虚拟DOM树(React Element Tree)是构建Fiber树的“蓝图”或“指令” 。没有虚拟DOM树,Fiber树就无法被创建或更新。

让我们来详细分解这个依赖关系:

1. 初始渲染:从虚拟DOM到Fiber树

当你的应用首次加载时,React 的工作流程是这样的:

  1. 生成虚拟DOM树:React 调用你的根组件的 render() 方法(或执行函数组件体)。这个执行过程会返回一个由 React 元素  组成的树,这就是最初的虚拟DOM树。它是对UI的声明式描述

  2. 构建Fiber树:React 接收到这棵虚拟DOM树后,以它为依据,开始创建对应的 Fiber 节点,并将这些节点连接成一棵 Fiber 树(也就是最初的 Current 树)。

    • 每个 Fiber 节点都是从对应的 React 元素中获取其 typekeyprops 等信息。
    • 虚拟DOM树描述了  “UI应该是什么样子” ,而Fiber树是 为了实现协调和更新而构建的“工作单元数据结构”

初始渲染的依赖关系:
组件render() -> 虚拟DOM树 -> Fiber树 -> 真实DOM

2. 状态更新:虚拟DOM是驱动Fiber树重建的源头

当状态发生变化,触发更新时,这个依赖关系更加明显:

  1. 生成新的虚拟DOM树:状态更新导致组件重新渲染,再次调用 render 方法。这会生成一棵新的虚拟DOM树。这个过程是纯粹的,不依赖旧的Fiber树,只依赖于当前的 state 和 props。

  2. 协调 :React 现在手上有两样东西:

    • 旧的Fiber树(Current 树):代表当前屏幕上显示的内容。
    • 新的虚拟DOM树:代表下一次渲染希望更新到的UI状态。
  3. 构建新的Fiber树:React 开始构建 WorkInProgress 树。它遍历新的虚拟DOM树,并逐节点地与旧的Fiber树进行比较(Diff算法)

    • 对于新的虚拟DOM树上的每一个 React 元素,React 都会去旧的Fiber树中寻找可以复用的Fiber节点。
    • 复用的决策(key和type是否相同)完全基于新的React元素和旧的Fiber节点的属性。
    • 最终,React 会根据比较结果,创建新的Fiber节点或复用旧的Fiber节点,从而构建出完整的 WorkInProgress 树。

更新时的依赖关系:
状态改变 -> 组件重新render() -> 新的虚拟DOM树 -> (Diff算法) -> 构建新的Fiber树(WorkInProgress) -> 更新真实DOM

现在(react18以后)UI的改变是比较两个虚拟DOM从而改变UI了,还是新旧fiber树的比较改变UI?

在 React 16 之后,UI 的改变不再仅仅依赖于比较两个虚拟 DOM (VDOM) 树来决定 DOM 的更新,更重要的是,React 的更新过程现在主要基于 Fiber 架构,并通过比较新旧 Fiber 树来实现高效的 UI 更新。

让我们更详细地解释一下:

1. 传统 (React 16 及以前) 的 VDOM 更新机制 (简化版)

  • 组件状态改变 -> 创建新的 VDOM
  • 新 VDOM 与旧 VDOM 比较 (diffing)
  • Diffing 算法识别变化,生成 DOM 更新指令
  • DOM 更新指令被应用到真实的 DOM

关键问题:

  • 同步过程:  整个 diffing 过程是同步的,会阻塞主线程,如果组件复杂,会影响用户体验(卡顿)。
  • 优先级问题:  所有更新都被同等对待,无法区分重要性和紧急程度。

2. Fiber 架构下的更新机制 (React 17及以后)

  • 组件状态改变 -> (触发更新)

  • React 根据新状态创建新的 Fiber 树 (工作 Fiber 树)

  • 构建 (render) 阶段:

    • React 遍历新的 Fiber 树。
    • 比较 新 Fiber 树与旧 Fiber 树 (旧 Fiber 树的父节点指向新 Fiber, new Fiber 存在 alternate 属性指向 old fiber , 即通过 Fiber 树的信息,来比较新旧UI)。
    • 计算需要进行的 DOM 操作 (插入、删除、更新)
    • React 可以中断这个过程,处理高优先级任务 (例如用户交互),之后 可以恢复
  • 提交 (commit) 阶段:

    • 一次性将所有 DOM 操作 (变化) 应用到真实的 DOM
    • flushPassiveEffects触发一些 useEffect
    • 此时,旧的 Fiber 树 (代表之前的 UI) 被标记为不可用, 新的 Fiber 树 (代表新的 UI) 成为“当前”的 Fiber 状态。

关键变化:

  • Fiber 树取代了 VDOM 树成为更重要的结构:  React 用 Fiber 节点来表示 UI 的工作单元。 Fiber 树 (新旧) 之间的比较,驱动了 DOM 的更新。
  • 构建阶段可中断:  React 可以在** render 阶段** 中中断耗时的计算任务,让出主线程。 这使得用户体验更好。
  • 并发 (Concurreny):  Fiber 开启了并发渲染的可能性。 React 可以在后台准备多个 UI 更新,然后以一种流畅的方式进行切换。
  • 优先级和调度:  Fiber 架构允许优先处理某些更新 (Transition, Sync 等)。

所以,现在, UI 的变化的核心是:

  • React 基于状态变化构建新的 Fiber 树 (工作 Fiber 树)。
  • React 通过比较新的 Fiber 树和旧的 Fiber 树 (主要是比较 Fiber 节点里的信息) 来确定需要进行的 DOM 操作。
  • DOM 操作在 commit 阶段一次性应用到 DOM。 这个阶段是同步的,但因为计算发生在后台,所以不会阻塞 UI 渲染太多时间。

一个更精确的解释是:

在 React 18及更高版本中,比较(diffing)仍然存在,但它在 Fiber 架构中得到了重新组织和增强。主要比较的核心是:

  • Fiber 节点:  比较的是新旧 Fiber 树中的 Fiber 节点。
  • 副作用管理:  比较 Fiber 的 effectTag 属性,effectTag 标记了需要进行哪些 DOM 操作 (例如:插入、更新、删除)。
  • 工作单元: ** Fiber 将更新过程分解成工作单元,并使用优先级**、中断和恢复等技术来优化更新。

虽然 Virtual DOM 仍然是 React 中的一个重要概念,它描述了 UI 的状态,但 Fiber 架构是核心,它控制了更新的调度、性能和并发。 新旧 Fiber 树之间的比较 (更确切地说, Fiber 节点之间的比较) 是触发 UI 变化的根本原因。

我们可以对UI的改变过程做一个React层面的分层

React的架构可以看作一个清晰的 pipeline(流水线),每一层职责分明:

第1层:开发者 (Your Code)

  • 职责:使用声明式的JSX或React.createElement编写组件。
  • 输出虚拟DOM(React元素) 。这只是UI的描述(“蓝图”),例如“这里应该有一个<div>,它的className是'active',里面有一个<span>”。

第2层:React核心 (React Core / Reconciler)

  • 职责协调(Reconciliation) 。这是Fiber架构的核心所在。
  • 输入:旧的Fiber树 + 新的虚拟DOM树(来自第1层)。
  • 工作:通过Diff算法比较新旧内容,计算出需要进行的更新操作(如PlacementUpdateDeletion)。
  • 输出:一个包含了所有更新操作的副作用链表(Effect List)注意:到这里为止,操作的仍然是JavaScript对象(Fiber节点),没有触及真实DOM。

第3层:渲染器 (Renderer - e.g., ReactDOM)

  • 职责渲染。这是真正操作平台特定API(如浏览器DOM)的层。

  • 输入:React核心计算出的副作用链表

  • 工作:遍历副作用链表,执行具体的、平台相关的命令。

    • 对于ReactDOM来说,就是调用document.createElement()element.appendChild()element.setAttribute()element.remove()浏览器DOM API
  • 输出:更新后的真实UI。

总结

OK,就谈到这里吧

从零到一开发一个Chrome插件(二)

2025年9月3日 18:06

引言

大家好啊,我是前端拿破轮。

作为一个前端工程师,Chrome在我们的工作中扮演着重要作用。它不仅是前端的主要运行环境,而且是我们代码调试的重要工具,也是平时学习生活使用的重要软件。

在Chrome中有许多优秀的扩展,包括但不限于我们使用的React Devtools沉浸式翻译插件等等。许多扩展极大地拓展了浏览器的能力边界,提高了我们工作和学校的效率。

所以笔者早有开发一个自己的插件的想法,苦于之前一直忙于学习和工作,没有合适的时间,最近忙里偷闲,决定抽出时间来进行这项工作。而且AI的时代浪潮也给浏览器的插件带来了新的生命力。所以笔者决定做一款浏览器端的AI助手插件。

本文属于Chrome插件开发专栏,会持续更新拿破轮的Chrome插件开发过程,欢迎各位读者订阅,希望对你有所帮助。如果还没有看过之前文章的读者,可以先阅读专栏中前面的文章。

阅读时间估计Demo

概述

在本文中我们会构建一个扩展程序,用来统计当前页面的阅读时间。

在本文中我们会涉及到以下概念:

  • 扩展程序清单
  • 扩展程序使用的图标大小
  • 如何使用内容脚本将代码注入网页
  • 如何使用匹配模式
  • 扩展程序的权限

步骤

第 1 步:添加扩展程序的相关信息

与在从零到一开发一个Chrome插件(一)中的步骤相同,还是要创建一个目录用来构建我们的扩展程序,并在根目录中创建一个清单,即manifest.json文件。

我们这里为了方便,直接在上一篇文章中的Demo的基础上进行改造。

 // manifest.json
 {
   "name": "Reading time",
   "description": "估计网页阅读时间",
   "version": "1.0",
   "manifest_version": 3,
 }

关于扩展程序清单(manifest.json)文件,我们有以下几点需要注意:

  • 它必须位于根目录当中。
  • 在这个文件中必须包含的键有manifest_version,nameversion
  • 它在开发期间支持注释(//),但是必须将注释移除后,才能将插件上传到Chrome应用商店。

第 2 步:提供图标

在开发过程中图标可有可无,但是如果我们想要在Chrome应用商店中上架我们的扩展,就必须提供图标

在这个Demo示例中,我们可以使用官方提供的GitHub图标仓库,将images文件夹也放在我们的插件的根目录下,然后在清单文件manifest.json中进行配置。

image-20250901134413296

 {
   "name": "Reading time",
   "description": "估计网页阅读时间",
   "version": "1.0",
   "manifest_version": 3,
   "icons": {
     "16": "images/icon-16.png",
     "32": "images/icon-32.png",
     "48": "images/icon-48.png",
     "128": "images/icon-128.png"
   }
 }

官方推荐使用png文件,当然其他文件也可以,但是svg不行

在上面的配置中我们可以看到,有16,32,48,128这四种尺寸的图标,他们分别在哪里显示呢?如下表所示:

图标大小 图标使用
16x16 扩展程序页面和上下文菜单中的图标。
32x32 Windows 计算机通常需要此大小。
48x48 显示在“扩展程序”页面上。
128x128 会在安装过程中和 Chrome 应用商店中显示。

第 3 步:声明内容脚本

扩展程序可以运行脚本,来读取和修改网页内容。这些脚本称为内容脚本。他们运行在隔离环境中。这意味着他们可以更改自己的JavaScript环境,而不会与其托管页面或其他扩展程序的内容脚本发生冲突。

我们可以将下面的代码添加到manifest.json文件中来注册一个名为content.js的内容脚本。

 {
   "name": "Reading time",
   "description": "估计网页阅读时间",
   "version": "1.0",
   "manifest_version": 3,
   "icons": {
     "16": "images/icon-16.png",
     "32": "images/icon-32.png",
     "48": "images/icon-48.png",
     "128": "images/icon-128.png"
   },
   "content_scripts": [
     {
       "js": ["scripts/content.js"],
       "matches": [
         "https://developer.chrome.com/docs/extensions/*",
         "https://developer.chrome.com/docs/webstore/*"
       ]
     }
   ]
 }

matches字段可以有一个或多个匹配模式。这些标记可以让浏览器确定要将内容脚本注入到哪些网站。匹配模式一般由以下三个部分组成:<scheme>://<host><path>。可以包含*字符。

第 4 步:计算并插入阅读时间

内容脚本可以使用标准DOM读取和更改网页内容。

该扩展会首先检查网页是否包含<article>元素。然后,它会统计元素中所有字词,并创建一个段落来显示总阅读时间。

在根目录下创建scripts文件夹,在其中创建一个content.js文件,然后添加如下代码。

 function renderReadingTime(article) {
   // 如果没有article,就不需要计算,直接返回
   if (!article) {
     return;
   }
 
   // 得到文本数据
   const text = article.textContent;
 
   // 正则表达式匹配单词: 匹配一个或多个非空白字符
   const wordMatchRegExp = /[^\s]+/g;
 
   // 匹配的单词
   const words = text.matchAll(wordMatchRegExp);
 
   // 单词数量
   const wordCount = [...words].length;
 
   // 阅读时间:每分钟大概读200词
   const readingTime = Math.round(wordCount / 200);
 
   // 展示标签DOM
   const badge = document.createElement("p");
 
   // 增加css类名
   badge.classList.add("color-secondary-text", "type--caption");
 
   // 添加展示内容
   badge.textContent = `⏱️ 预估阅读时间为 ${readingTime} 分钟`;
 
   // 获取标题
   const heading = article.querySelector("h1");
 
   // 获取时间
   const date = article.querySelector("time")?.parentNode;
 
   // 在date的后面加入时间卡片,如果没有date,就在heading后面加入
   (date ?? heading).insertAdjacentElement("afterend", badge);
 }
 
 renderReadingTime(document.querySelector("article"));

第 5 步:监听更改

使用我们上面的代码,如果我们使用左侧导航栏切换文章,阅读时间不会添加到新文章中。这是因为现在很多网站都是作为单页应用(SPA)实现的,这些应用使用History API执行软导航。

所以当我们点击导航栏切换文章时,看起来浏览器的地址栏中的URL变化了,但是实际上我们请求的一直是根文件index.html,所以renderReadingTime函数不会重新执行,阅读时间自然而然就不会添加到新文章中。

为了解决这个问题,我们可以使用MutationObserver来监听更改,并将阅读时间添加到新文章。

所以,我们需要再content.js中添加如下代码:

 function renderReadingTime(article) {
   // 如果没有article,就不需要计算,直接返回
   if (!article) {
     return;
   }
 
   // 得到文本数据
   const text = article.textContent;
 
   // 正则表达式匹配单词: 匹配一个或多个非空白字符
   const wordMatchRegExp = /[^\s]+/g;
 
   // 匹配的单词
   const words = text.matchAll(wordMatchRegExp);
 
   // 单词数量
   const wordCount = [...words].length;
 
   // 阅读时间:每分钟大概读200词
   let readingTime = Math.round(wordCount / 200);
 
   // 确保阅读时间最少为 1
   readingTime = Math.max(readingTime, 1);
 
   // 展示标签DOM
   const badge = document.createElement("p");
 
   // 增加css类名
   badge.classList.add("color-secondary-text", "type--caption");
 
   // 添加展示内容
   badge.textContent = `⏱️ 预估阅读时间为 ${readingTime} 分钟`;
 
   // 获取标题
   const heading = article.querySelector("h1");
 
   // 获取时间
   const date = article.querySelector("time")?.parentNode;
 
   // 在date的后面加入时间卡片,如果没有date,就在heading后面加入
   (date ?? heading).insertAdjacentElement("afterend", badge);
 }
 
 renderReadingTime(document.querySelector("article"));
 
 const observer = new MutationObserver((mutations) => {
   for (const mutation of mutations) {
     for (const node of mutation.addedNodes) {
       if (node instanceof Element && node.tagName === 'ARTICLE') {
         renderReadingTime(node);
       }
     }
   }
 })
 
 observer.observe(document.querySelector('devsite-content'), {
   childList: true,
 });
 

验证项目的文件结构是否如下所示:

阅读时光文件夹中的内容:manifest.json、scripts 文件夹中的 content.js 和 images 文件夹。

按照从零到一开发一个Chrome插件(一)的步骤加载本地未打包的扩展程序来进行测试。

由于我们在content.js中写的代码只针对Chrome Developer中的文章,所以我们要用这些文章来进行测试。下面是两个可以进行测试的示例文档。

image-20250903175337143

然后我们可以看到已经在标题下方成功显示了这篇文章的阅读时间。

总结

本文我们通过配置内容脚本,实现了在Chrome Dev的博客页面插入预估阅读时间的插件Demo,对于Chrome插件开发有了基本的认识和理解。

本文属于Chrome插件开发专栏,会持续更新拿破轮的Chrome插件开发过程,欢迎各位读者订阅,希望对你有所帮助。

好了,这篇文章就到这里啦,如果对您有所帮助,欢迎点赞,收藏,分享👍👍👍。您的认可是我更新的最大动力。由于笔者水平有限,难免有疏漏不足之处,欢迎各位大佬评论区指正。

往期推荐✨✨✨

我是前端拿破轮,关注我,一起学习前端知识,我们下期见!

前端面试第 77 期 - 2025.09.02 更新前端面试问题总结(15 道题)

作者 晴小篆
2025年9月2日 21:58

2025.07.06 - 2025.08.31 更新前端面试问题总结(15 道题)
获取更多面试相关问题可以访问
github 地址: github.com/pro-collect…
gitee 地址: gitee.com/yanleweb/in…

目录

中级开发者相关问题【共计 9 道题】

  1. 深层清理对象中的空值属性【热度: 234】【代码实现/算法】
  2. 介绍一下 git stash【热度: 386】【web 应用场景】
  3. JS 里面, 对于对象的读写, 是使用 object 好,还是 Map,性能差异如何?【热度: 610】【JavaScript】【出题公司: 阿里巴巴】
  4. less 与 scss 有何区别【热度: 61】【web 应用场景】【出题公司: 腾讯】
  5. less 与 css 有何区别【热度: 214】【web 应用场景】【出题公司: 腾讯】
  6. 用 css 实现一个 loading 动画, 该如何做(转圈)【热度: 180】【CSS】
  7. ts 有哪些常用的关键词【热度: 178】【TypeScript】【出题公司: 美团】
  8. 对比一下 ts 和 jsdoc【热度: 126】【TypeScript】
  9. react 开发的应用里面, 如何给系统设置一个全局的崩溃的提示页面【热度: 725】【web 框架】【出题公司: 小米】

高级开发者相关问题【共计 6 道题】

  1. 将网页 dom 元素转为图片, 有哪些办法【热度: 41】【web 应用场景】
  2. 介绍一下 git diff【热度: 396】【web 应用场景】
  3. less 是否支持条件判定【热度: 112】【web 应用场景】【出题公司: 腾讯】
  4. less 有那些高级特性, 驱使你们项目工程去使用它【热度: 336】【web 应用场景】【出题公司: 腾讯】
  5. ts 里面 infer 是什么关键词, 怎么用【热度: 975】【TypeScript】【出题公司: 美团】
  6. TypeScript 中,ReturnType 的作用和用法【TypeScript】

中级开发者相关问题【共计 9 道题】

1123. 深层清理对象中的空值属性【热度: 234】【代码实现/算法】

请实现一个函数 deepOmitNil,要求如下:

  1. 功能:递归处理一个可能包含嵌套结构的对象(或数组),移除所有层级中值为 nullundefined 的属性

  2. 要求:

    • 支持对象和数组的嵌套结构
    • 对于对象:移除值为 null/undefined 的属性,保留其他属性并继续递归处理属性值
    • 对于数组:递归处理每个元素,同时过滤掉值为 null/undefined 的元素
    • 不改变原始数据结构,返回处理后的新数据
  3. 示例:

    // 输入
    const data = {
      name: 'test',
      age: null,
      info: {
        address: undefined,
        contact: {
          phone: null,
          email: 'test@example.com'
        }
      },
      list: [1, null, { id: undefined, value: 2 }, undefined, 3]
    };
    
    // 输出
    {
      name: 'test',
      info: {
        contact: {
          email: 'test@example.com'
        }
      },
      list: [1, { value: 2 }, 3]
    }
    

请用 JavaScript 实现该函数,可使用 Lodash 工具库辅助开发。

解法

function deepOmitNil(value) {
  // 处理null和undefined的情况
  if (value === null || value === undefined) {
    return null;
  }

  // 处理数组:递归处理每个元素,并过滤掉null和undefined
  if (Array.isArray(value)) {
    return value.map((item) => deepOmitNil(item)).filter((item) => item !== null && item !== undefined);
  }

  // 处理普通对象:检查是否为纯粹的对象(排除数组、null等)
  if (Object.prototype.toString.call(value) === "[object Object]") {
    const result = {};
    // 遍历对象自身属性
    for (const key in value) {
      if (value.hasOwnProperty(key)) {
        const processedValue = deepOmitNil(value[key]);
        if (processedValue !== null && processedValue !== undefined) {
          result[key] = processedValue;
        }
      }
    }
    return result;
  }

  // 其他类型直接返回(如字符串、数字、布尔值等)
  return value;
}

// 示例用法
const data = {
  name: "test",
  age: null,
  info: {
    address: undefined,
    contact: {
      phone: null,
      email: "test@example.com",
    },
  },
  list: [1, null, { id: undefined, value: 2 }, undefined, 3],
};

console.log(deepOmitNil(data));

1124. 介绍一下 git stash【热度: 386】【web 应用场景】

关键词:git stash

git stash 是 Git 中一个非常实用的命令,用于临时保存工作区和暂存区的修改,让你可以在不提交当前变更的情况下,切换到其他分支或进行其他操作,之后还能恢复这些临时保存的变更。

核心作用

当你正在一个分支上开发,突然需要切换到其他分支(比如修复紧急 Bug),但当前工作还没完成不想提交时,git stash 可以:

  • 把工作区和暂存区的所有修改(包括新增、修改、删除的文件)暂存到一个“栈”中
  • 让工作区回到最近一次提交的干净状态(与 git reset --hard HEAD 效果类似,但变更被临时保存了)
  • 之后可以随时从“栈”中恢复这些变更,继续之前的工作

常用命令

  1. 暂存当前变更

    git stash
    # 或添加描述(推荐,方便区分多个stash)
    git stash save "描述信息:例如「首页导航栏修改」"
    

    执行后,工作区会恢复到干净状态,变更被存入 stash 栈。

  2. 查看所有暂存的变更

    git stash list
    

    输出类似:

    stash@{0}: On feature/login: 修复登录按钮样式
    stash@{1}: On develop: 临时添加调试日志
    

    stash@{n} 是每个暂存的唯一标识,n 越小表示越新。

  3. 恢复暂存的变更

    • 恢复最新的 stash(stash@{0}),且保留 stash 记录:
      git stash apply
      
    • 恢复指定的 stash(例如 stash@{1}):
      git stash apply stash@{1}
      
    • 恢复最新的 stash 并删除该 stash 记录(推荐用完即删的场景):
      git stash pop
      
  4. 删除暂存的变更

    • 删除最新的 stash:
      git stash drop
      
    • 删除指定的 stash:
      git stash drop stash@{1}
      
    • 删除所有 stash:
      git stash clear
      
  5. 查看 stash 中的具体修改
    查看最新 stash 与当前工作区的差异:

    git stash show
    

    查看详细差异(显示具体修改的内容):

    git stash show -p
    

注意事项

  • git stash 只会暂存已跟踪文件(即已被 Git 管理的文件)的修改,以及已添加到暂存区的新增文件。未跟踪的全新文件(未执行过 git add)不会被暂存,需要先执行 git add 或使用 git stash -u-u 表示包括未跟踪文件)。
  • 恢复 stash 时,如果当前工作区有修改,可能会出现冲突,需要手动解决。
  • stash 存储在本地仓库,不会被推送到远程,切换电脑后无法获取。

简单来说,git stash 就像一个“剪贴板”,让你可以临时“剪切”当前工作状态,稍后再“粘贴”回来,非常适合处理多任务切换的场景。

1126. JS 里面, 对于对象的读写, 是使用 object 好,还是 Map,性能差异如何?【热度: 610】【JavaScript】【出题公司: 阿里巴巴】

关键词:Object 与 Map

在 JavaScript 中,ObjectMap 都可以用于存储键值对,但它们的设计目标和性能特性存在差异,选择哪一种取决于具体使用场景。

核心差异与性能对比

1. 内存占用
  • Object
    本质是原型链继承的对象,会默认一些额外属性(如 __proto__constructor),且键只能是字符串或 Symbol。
    对于少量键值对,内存开销较小,但键名会被强制转换为字符串(如数字 1 会转为 "1")。

  • Map
    专为键值对存储设计,无原型链开销,键可以是任意类型(包括对象、函数等)。
    但内部实现会维护哈希表结构,存储相同数量的键值对时,内存占用通常比 Object 略高(尤其键值对较少时)。

2. 读写性能
  • Object

    • 读取/写入速度:对于静态键(提前确定的键名),访问速度极快,因为 JavaScript 引擎会对对象属性进行优化(如静态属性的偏移量缓存)。
    • 动态键场景:如果键名是动态生成的(如通过变量拼接),性能会略有下降(需哈希计算),但仍优于 Map 对非字符串键的处理。
  • Map

    • 读取/写入速度:对于频繁的增删改查(尤其是动态键或非字符串键),性能更稳定。
    • 优势体现在:键可以是任意类型(无需转换)、内部哈希表优化更适合高频动态操作。
    • 劣势:对于静态字符串键,访问速度通常比 Object 慢 10%-30%(不同引擎优化不同)。
3. 遍历性能
  • Object
    遍历需要先获取键名(Object.keys() 等),再迭代访问,步骤较多。
    且会遍历自身可枚举属性(需注意原型链污染问题),额外消耗性能。

  • Map
    原生支持迭代器(for...of 直接遍历),遍历速度通常比 Object 快,尤其是键值对数量较多时。
    Mapsize 属性可直接获取长度(Object 需要 keys().length 计算),更高效。

4. 极端场景测试
  • 小规模数据(<100 键值对)
    Object 性能略优,内存占用更低,适合简单配置、数据存储。

  • 大规模数据(>1000 键值对)
    Map 在频繁增删、动态键、遍历场景下性能更稳定,Object 可能因哈希冲突导致性能波动。

  • 非字符串键
    Map 优势明显(无需转换键类型),Object 需要手动处理键名转换(如将对象转为字符串标识),既麻烦又影响性能。

使用建议

场景 推荐选择 理由
静态键名(如 { name: 'a' } Object 访问速度快,语法简洁,适合数据结构固定的场景(如配置、DTO)。
动态键名(如变量作为键) Map 无需处理键名转换,增删改查性能更稳定。
非字符串键(对象、函数等) Map 原生支持任意类型键,Object 会强制转换键为字符串,可能导致冲突。
频繁增删或遍历 Map 迭代器优化更好,size 属性获取高效,适合缓存、集合类场景。
序列化需求(JSON.stringify Object Map 无法直接序列化,需手动转换为对象,Object 原生支持。

总结

  • Object 适合静态、简单的键值对存储,语法简洁,内存占用低,静态访问速度快。
  • Map 适合动态、复杂的键值对场景(尤其是非字符串键、高频增删遍历),性能更稳定。

性能差异在大多数业务场景中不明显,优先根据代码可读性和功能需求选择,极端性能敏感场景(如大数据处理)再针对性优化。

1129. less 与 scss 有何区别【热度: 61】【web 应用场景】【出题公司: 腾讯】

关键词:less 与 scss

Less 和 Sass(通常以 SCSS 语法使用)是最流行的两款 CSS 预处理器,都旨在解决原生 CSS 的局限性(如缺乏变量、嵌套、复用机制等),但在语法细节、功能设计和生态上存在诸多差异。以下从核心区别、使用场景等方面详细对比:

一、语法差异

这是最直观的区别,直接影响开发体验。

特性 Less SCSS(Sass 的新语法)
语法风格 更接近原生 CSS,无需强制使用分号和大括号 完全兼容 CSS 语法,必须使用分号和大括号
变量声明 @variable: value; $variable: value;
嵌套规则 支持,与 SCSS 类似 支持,与 Less 类似
注释 单行 //(编译后移除)和多行 /* */(保留) 同 Less
示例代码 less<br>.container {<br> color: @text-color;<br> .box { padding: 10px }<br>}<br> scss<br>.container {<br> color: $text-color;<br> .box { padding: 10px; }<br>}<br>

关键区别

  • Less 语法更灵活,允许省略分号和大括号(类似 Stylus),但通常推荐保留以保持一致性;
  • SCSS 严格要求分号和大括号,完全兼容 CSS,因此从 CSS 迁移到 SCSS 几乎零成本。

二、变量与作用域

两者都支持变量,但作用域规则和特性有差异。

  1. 变量符号

    • Less 用 @(如 @color: red;);
    • SCSS 用 $(如 $color: red;),避免与 CSS 原生 @ 规则(如 @media)冲突。
  2. 作用域行为

    • Less:变量遵循「延迟加载」(Lazy Loading),即变量在使用前无需声明,作用域内后定义的变量会覆盖先定义的。
      .box {
        color: @color; // 允许使用后定义的变量
        @color: red;
      }
      
    • SCSS:变量必须先声明后使用,作用域更严格(类似 JavaScript)。
      .box {
        color: $color; // 报错:$color 未定义
        $color: red;
      }
      
  3. 全局变量

    • SCSS 需用 !global 关键字显式声明全局变量(局部作用域中):
      .box {
        $color: red !global; // 声明为全局变量
      }
      .text {
        color: $color;
      } // 可访问
      
    • Less 中变量默认全局有效(局部变量会覆盖全局,但不会污染全局)。

三、混合(Mixins)与函数

两者都支持代码复用,但实现方式和功能有差异。

1. 混合(Mixins)

用于复用样式片段。

  • Less
    混合无需特殊关键字,直接定义类或 id 选择器,使用时加括号(可选):

    // 定义混合
    .border-radius(@radius: 4px) {
      border-radius: @radius;
    }
    // 使用混合(可省略括号)
    .btn {
      .border-radius; // 或 .border-radius(8px)
    }
    
  • SCSS
    混合必须用 @mixin 定义,用 @include 调用,语法更明确:

    // 定义混合
    @mixin border-radius($radius: 4px) {
      border-radius: $radius;
    }
    // 使用混合
    .btn {
      @include border-radius(8px);
    }
    
2. 函数(Functions)

用于计算值并返回结果(不直接生成样式)。

  • Less:函数功能较弱,主要依赖内置函数(如 darken()lighten()),自定义函数需通过混合模拟(不支持返回值)。
  • SCSS:支持用 @function 自定义函数,可返回值,功能更强大:
    // 自定义函数:计算百分比宽度
    @function col-width($n) {
      @return ($n / 12) * 100%;
    }
    .col-6 {
      width: col-width(6); // 50%
    }
    

四、条件与循环

处理动态逻辑的能力不同。

1. 条件判断
  • Less:通过 when 关键字实现条件(Guards),语法较特殊:

    .theme(@type) when (@type = "dark") {
      background: #333;
    }
    .box {
      .theme("dark");
    }
    
  • SCSS:支持 @if/@else 语句,更接近传统编程语言:

    @mixin theme($type) {
      @if $type == "dark" {
        background: #333;
      } @else {
        background: #fff;
      }
    }
    .box {
      @include theme("dark");
    }
    
2. 循环
  • Less:通过混合自调用实现循环,语法较繁琐:

    .loop(@n) when (@n > 0) {
      .item-@{n} {
        width: @n * 10px;
      }
      .loop(@n - 1);
    }
    .loop(3); // 生成 .item-3、.item-2、.item-1
    
  • SCSS:提供 @for/@each/@while 多种循环语法,更直观:

    // @for 循环
    @for $i from 1 through 3 {
      .item-#{$i} {
        width: $i * 10px;
      }
    }
    

五、模块化与导入

处理样式文件拆分的方式。

  • Less

    • @import "file.less"; 导入文件,支持条件导入(结合 when):
      @import "theme.less" when (@theme = "dark");
      
    • 无内置模块化机制,需通过工具(如 Webpack)实现按需加载。
  • SCSS

    • @import "file.scss"; 导入文件,支持嵌套导入(在选择器内导入,作用域受限):
      .box {
        @import "partial.scss"; // 仅在 .box 内生效
      }
      
    • 支持 @use@forward(Sass 3.8+),实现更严格的模块化(类似 ES6 模块),避免变量冲突:
      // 导入并命名空间
      @use "variables" as vars;
      .box {
        color: vars.$text-color;
      }
      

六、生态与工具链

  • SCSS

    • 由 Ruby 开发(后部分用 C 重写),但现在主流通过 dart-sass 编译(性能更好);
    • 生态更成熟,广泛用于 React、Vue 等框架的组件库(如 Ant Design、Bootstrap 4+);
    • 工具支持完善(如 VS Code 的 Sass 插件、Webpack 的 sass-loader)。
  • Less

    • 基于 JavaScript 开发,编译速度快(尤其在 Node.js 环境);
    • 生态相对较小,但在早期前端框架(如 Bootstrap 3)中广泛使用;
    • 工具支持同样完善(如 less-loader)。

七、选择建议

场景/需求 推荐选择 理由
从 CSS 平滑迁移 SCSS 语法完全兼容 CSS,学习成本低
需要强大的函数和逻辑能力 SCSS 支持 @function@if/@for 等,适合复杂计算和动态样式
追求简洁语法和快速上手 Less 语法更灵活,变量和混合使用更简单
大型项目/组件库开发 SCSS 模块化机制(@use/@forward)更规范,避免变量冲突
依赖 JavaScript 生态(如 Node.js) Less 基于 JS 开发,与 Node 工具链集成更自然
团队已有 CSS 代码库 SCSS 无需修改原有 CSS 语法即可直接使用

总结

  • Less 更轻量、语法更灵活,适合中小型项目或追求简单上手的场景;
  • SCSS 功能更强大、语法更规范,适合大型项目或需要复杂逻辑的场景,且生态更成熟。

两者核心功能(变量、嵌套、混合)重叠度高,选择时可根据团队熟悉度、项目规模和工具链兼容性决定。

1130. less 与 css 有何区别【热度: 214】【web 应用场景】【出题公司: 腾讯】

关键词:less 与 css

Less 是 CSS 的预处理器,它在原生 CSS 的基础上扩展了诸多功能,解决了原生 CSS 开发中的痛点(如缺乏变量、复用机制、逻辑处理等)。以下是 Less 与 CSS 的核心区别:

1. 语法与功能

特性 CSS(原生) Less
变量支持 无(CSS 变量 --var 是较新特性,兼容性和功能有限) 支持 @variable: value 定义变量,可全局复用、参与运算,优先级清晰
嵌套规则 需重复书写父选择器,代码冗余(如 .parent .child { ... } 支持选择器嵌套(类似 HTML 结构),减少重复代码,层级更清晰
代码复用 无原生复用机制,需复制粘贴或依赖 @import 导入整个文件 支持「混合(Mixin)」复用样式片段,可带参数实现个性化复用
逻辑处理 无(仅能通过 @media 实现简单条件,无循环、判断) 支持条件判断(when)、循环(混合自调用)、运算(加减乘除、颜色计算)
模块化 @import 导入整个 CSS 文件,增加请求数,无作用域隔离 支持导入 Less 文件,可结合 (reference) 仅引用变量/混合,避免冗余代码
内置工具 提供颜色处理(darken()lighten())、数值计算(percentage())等函数

2. 开发效率

  • CSS
    编写重复代码多(如相同颜色、尺寸需多次书写),修改时需全局搜索替换,维护成本高。
    示例(重复代码问题):

    .btn {
      background: #1890ff;
    }
    .card {
      border-color: #1890ff;
    }
    .title {
      color: #1890ff;
    }
    /* 若要修改主题色,需逐个修改 */
    
  • Less
    通过变量、混合等特性减少重复代码,修改时只需调整一处,开发和维护效率大幅提升。
    示例(解决重复问题):

    @primary: #1890ff; /* 变量定义 */
    .btn {
      background: @primary;
    }
    .card {
      border-color: @primary;
    }
    .title {
      color: @primary;
    }
    /* 修改主题色只需改 @primary 即可 */
    

3. 编译方式

  • CSS
    是浏览器可直接解析的样式语言,无需编译,写完即可运行。

  • Less
    是「预编译语言」,浏览器无法直接识别,必须通过 Less 编译器(如 lessc、Webpack 的 less-loader)转换为 CSS 后才能被浏览器解析。
    流程:Less 代码 → 编译 → CSS 代码 → 浏览器执行

4. 代码结构

  • CSS
    层级嵌套需通过后代选择器实现,代码冗长且层级不直观。
    示例:

    .header {
      padding: 20px;
    }
    .header .logo {
      width: 100px;
    }
    .header .nav {
      margin-left: 20px;
    }
    .header .nav li {
      display: inline-block;
    }
    
  • Less
    支持嵌套语法,结构与 HTML 一致,层级清晰,减少选择器冗余。
    示例:

    .header {
      padding: 20px;
      .logo {
        width: 100px;
      }
      .nav {
        margin-left: 20px;
        li {
          display: inline-block;
        }
      }
    }
    

5. 适用场景

  • CSS
    适合简单页面(如静态页面、小网站),或需要快速编写、无需复杂逻辑的场景。

  • Less
    适合中大型项目、组件库开发或需要频繁复用样式、动态调整主题的场景(如通过变量切换主题色、响应式适配)。

总结

Less 不是替代 CSS,而是对 CSS 的增强:

  • CSS 是基础:浏览器最终执行的是 CSS,它是样式的“目标语言”。
  • Less 是工具:通过提供变量、嵌套、复用等功能,让开发者更高效地编写 CSS,最终仍需编译为 CSS 才能运行。

简单说,Less 解决了原生 CSS“写起来麻烦、改起来痛苦”的问题,是现代前端开发中提升样式开发效率的主流选择。

1131. 用 css 实现一个 loading 动画, 该如何做(转圈)【热度: 180】【CSS】

关键词:css 动画

作者备注

这个问题主要是对 css 动画的考察, 比直接问 animation 和 transform 属性有意义。

可以利用 CSS 的 animation 和 transform 属性,通过旋转一个带有渐变边框的元素来实现。

这个转圈 loading 动画的核心实现思路如下:

  1. 创建圆形元素

    • 使用 widthheight 设置相同的尺寸
    • 通过 border-radius: 50% 将方形元素变成圆形
  2. 设计边框样式

    • 设置一个较粗的边框(border
    • 让大部分边框保持半透明(rgba(255, 255, 255, 0.3)
    • 只让顶部边框使用实色(border-top-color),形成旋转时的流动效果
  3. 添加旋转动画

    • 定义 spin 动画,通过 transform: rotate(360deg) 实现 360 度旋转
    • 使用 animation 属性应用动画,设置 1s 为一个周期,ease-in-out 缓动效果,infinite 无限循环

直接上代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>CSS Loading Spinner</title>
    <style>
      /* 基础样式设置 */
      body {
        margin: 0;
        padding: 0;
        min-height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
        background-color: #f0f2f5;
      }

      /* 加载动画容器 */
      .loading-spinner {
        /* 动画大小 */
        width: 50px;
        height: 50px;

        /* 创建圆形边框 */
        border: 5px solid rgba(255, 255, 255, 0.3); /* 浅色边框 */
        border-top-color: #1677ff; /* 高亮边框(旋转时形成流动效果) */
        border-radius: 50%; /* 圆形 */

        /* 旋转动画 */
        animation: spin 1s ease-in-out infinite;
      }

      /* 旋转动画定义 */
      @keyframes spin {
        to {
          /* 360度旋转 */
          transform: rotate(360deg);
        }
      }
    </style>
  </head>
  <body>
    <!-- 加载动画元素 -->
    <div class="loading-spinner"></div>
  </body>
</html>

1133. ts 有哪些常用的关键词【热度: 178】【TypeScript】【出题公司: 美团】

关键词:ts 关键词

作者备注

这个问题主要是对 ts 类型熟悉程度的考察, 比直接问 number、string 等基础类型有意义。

TypeScript 在 JavaScript 基础上扩展了许多用于类型定义和类型控制的关键字,这些关键字是构建 TypeScript 类型系统的核心。以下是常用的关键词分类及说明:

以下是 TypeScript 常用关键字的分类汇总表:

| 分类 | 关键字/操作符 | 主要用途 | | ------------- | ------------------------------ | ------------------------------------------------------------ | ---------------------------- | | 基础类型 | number string boolean | 定义数字、字符串、布尔值类型 | | | null undefined | 定义空值类型 | | | void | 表示函数无返回值 | | | any unknown | any 关闭类型检查;unknown 安全的未知类型(需断言后使用) | | | never | 表示永远不会发生的类型(如抛出错误) | | 复合类型 | arrayT[]Array<T>) | 定义数组类型 | | | interface | 定义对象结构(可扩展、继承) | | | type | 类型别名(支持联合、交叉等复杂类型) | | | enum | 定义命名常量集合 | | | tuple[T1, T2]) | 固定长度和类型的数组 | | 类型操作 | |(联合类型) | 表示“或”关系(类型可选其一) | | | &(交叉类型) | 表示“且”关系(合并多个类型) | | | extends | 泛型约束;接口/类继承 | | | infer | 条件类型中推断类型(如提取函数返回值) | | | keyof | 获取对象类型的所有键名组成的联合类型 | | | typeof | 获取变量/属性的类型 | | | as | 类型断言(指定变量的实际类型) | | 函数/泛型 | function | 定义函数(指定参数和返回值类型) | | | 泛型(T U 等) | 定义通用类型,实现类型复用 | | 模块/命名 | export import | 模块导出/导入 | | | namespace | 定义命名空间(避免全局变量冲突) | | 其他常用 | readonly | 定义只读属性或数组 | | | declare | 声明全局变量/模块类型(用于 .d.ts 文件) | | | abstract | 定义抽象类/抽象方法(需子类实现) |

这个表涵盖了日常开发中最常用的关键字,可根据场景快速查阅。核心重点关注 类型声明number/interface 等)、类型操作extends/keyof/infer)和 泛型相关 关键字,它们是 TypeScript 类型系统的核心。

1134. 对比一下 ts 和 jsdoc【热度: 126】【TypeScript】

关键词:ts 对比 jsdoc

TypeScript(TS)和 JSDoc 都是用于增强 JavaScript 代码类型安全性的工具,但它们的实现方式和适用场景有显著差异。以下是两者的对比分析及优劣总结:

TypeScript 与 JSDoc 对比表

维度 TypeScript(TS) JSDoc
本质 是 JavaScript 的超集,需要编译为 JS 才能运行,自带完整的类型系统 是 JavaScript 的注释规范,通过注释标注类型,无需编译,浏览器/Node 可直接运行
类型定义方式 使用专门的类型语法(如 : numberinterfacetype 等) 使用注释标签(如 @param@returns@type 等)标注类型
类型检查时机 编译时强制类型检查,错误会在开发阶段暴露 依赖 IDE/编辑器(如 VS Code)的 TypeScript 服务进行类型检查,非强制
功能丰富度 类型系统强大(泛型、交叉/联合类型、条件类型、枚举等) 支持基础类型标注,复杂类型(如泛型约束、条件类型)表达能力有限
生态与工具 生态成熟,支持所有主流框架(React、Vue 等),有大量类型声明文件(.d.ts 依赖 TypeScript 语言服务,可复用 .d.ts 文件,但工具链集成较弱
学习成本 较高,需学习专门的类型语法和概念 较低,基于注释,语法简单,熟悉 JS 即可快速上手
项目侵入性 高,需将文件改为 .ts 后缀,可能需要调整构建流程 低,不改变 JS 代码结构,仅添加注释,原有 JS 项目可平滑接入
运行时影响 无(编译后为纯 JS),但类型信息会被完全擦除 无(注释不影响运行),类型信息仅存在于代码中
适用场景 大型项目、团队协作、对类型安全性要求高的场景 小型项目、快速原型、希望保持纯 JS 但需要基础类型提示的场景

核心优劣总结

TypeScript 的优势:
  1. 强类型检查:编译阶段强制校验,能提前发现更多类型错误,减少运行时问题。
  2. 丰富的类型功能:支持泛型、条件类型、枚举等高级特性,能精确描述复杂数据结构。
  3. 工具链完善:与主流 IDE、构建工具(Webpack、Vite)深度集成,开发体验好。
  4. 团队协作友好:类型定义作为“活文档”,清晰传达接口设计,降低沟通成本。
TypeScript 的劣势:
  1. 学习成本高:需要掌握额外的类型语法和概念(如 inferkeyof 等)。
  2. 项目侵入性强:需要修改文件后缀、配置编译流程,对纯 JS 项目迁移有一定成本。
  3. 编译开销:增加编译步骤,可能略微影响开发效率(尤其大型项目)。
JSDoc 的优势:
  1. 零成本接入:无需修改代码结构,仅通过注释标注类型,原有 JS 项目可直接使用。
  2. 无编译步骤:保留纯 JS 特性,浏览器/Node 可直接运行,适合快速迭代。
  3. 学习成本低:语法简单,基于注释标签,熟悉 JS 的开发者可快速上手。
JSDoc 的劣势:
  1. 类型能力有限:复杂类型(如泛型约束、条件类型)难以表达,类型检查较弱。
  2. 非强制检查:依赖 IDE 提示,无法在构建阶段强制报错,可能遗漏类型问题。
  3. 注释冗余:大量类型注释会增加代码体积,影响可读性(尤其复杂类型)。

总结建议

  • 若项目规模大、团队协作频繁、对类型安全要求高,优先选 TypeScript
  • 若项目小、追求快速开发、希望保持纯 JS 生态,可选 JSDoc 作为轻量方案。
  • 实际开发中,两者也可结合使用(如用 JSDoc 在 TS 项目中补充复杂类型注释)。

1136. react 开发的应用里面, 如何给系统设置一个全局的崩溃的提示页面【热度: 725】【web 框架】【出题公司: 小米】

关键词:崩溃提示

在 React 应用中设置全局崩溃提示页面,核心是通过 错误边界边界(Error Boundary) 来捕获子组件树中的 JavaScript 错误,并显示备用 UI。以下是具体实现方案:

实现说明

  1. 错误边界组件(ErrorBoundary)

    • 这是一个 React 类组件,利用 React 的错误捕获生命周期 getDerivedStateFromErrorcomponentDidCatch 捕获子组件错误
    • getDerivedStateFromError:用于更新状态,触发错误 UI 渲染
    • componentDidCatch:用于记录错误信息(可上报到服务端)
    • 提供重试和返回首页功能,提升用户体验
import React from "react";
import { Button } from "your-ui-library"; // 可替换为你的 UI 库或自定义按钮

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null,
    };
  }

  // 捕获子组件树中的错误
  static getDerivedStateFromError(error) {
    // 更新 state,下一次渲染将显示错误 UI
    return { hasError: true };
  }

  // 记录错误信息(可用于上报)
  componentDidCatch(error, errorInfo) {
    this.setState({
      error: error,
      errorInfo: errorInfo,
    });

    // 可选:将错误信息上报到服务端
    console.error("全局错误捕获:", error, errorInfo);
    // 实际项目中可以调用接口上报:
    // fetch('/api/log-error', {
    //   method: 'POST',
    //   body: JSON.stringify({ error: error.message, stack: errorInfo.componentStack })
    // });
  }

  // 重置错误状态(重新加载应用)
  resetErrorHandler = () => {
    this.setState({
      hasError: false,
      error: null,
      errorInfo: null,
    });
    // 可选:如果需要完全重置应用状态,可以刷新页面
    // window.location.reload();
  };

  render() {
    if (this.state.hasError) {
      // 错误发生时显示的崩溃页面
      return (
        <div className="global-error-container">
          <div className="error-content">
            <h2>😱 应用发生错误</h2>
            <p>很抱歉,页面出现了意外错误,请尝试刷新或联系管理员。</p>

            {/* 可选:显示错误详情(生产环境可隐藏) */}
            {process.env.NODE_ENV === "development" && (
              <details style={{ whiteSpace: "pre-wrap" }}>
                <summary>错误详情</summary>
                {this.state.error?.message}
                <br />
                {this.state.errorInfo?.componentStack}
              </details>
            )}

            <div className="error-actions">
              <Button onClick={this.resetErrorHandler} variant="primary">
                重试
              </Button>
              <Button onClick={() => (window.location.href = "/")} variant="secondary" style={{ marginLeft: "10px" }}>
                返回首页
              </Button>
            </div>
          </div>
        </div>
      );
    }

    // 如果没有错误,渲染子组件
    return this.props.children;
  }
}

export default ErrorBoundary;
  1. 全局应用

    • 在应用入口(App.jsx)用 ErrorBoundary 包裹整个应用,确保所有子组件的错误都能被捕获
    • 错误边界会自动捕获其内部所有组件(包括嵌套组件)的渲染错误、生命周期错误等
import React from "react";
import ErrorBoundary from "./ErrorBoundary";
import Router from "./Router"; // 你的路由组件
import GlobalStyle from "./GlobalStyle"; // 全局样式

function App() {
  return (
    // 用错误边界包裹整个应用
    <ErrorBoundary>
      <GlobalStyle />
      <Router />
    </ErrorBoundary>
  );
}

export default App;

注意事项

  • 错误边界不能捕获以下错误

    • 事件处理函数中的错误(需手动 try/catch)
    • 异步代码中的错误(如 setTimeout、Promise)
    • 错误边界自身的错误
    • 服务端渲染的错误
  • 对于异步错误(如 API 请求失败),需要额外在代码中处理(如 try/catch 或状态管理)

  • 可以根据需要扩展错误边界,例如:

    • 增加错误分类显示不同提示
    • 实现自动重试逻辑
    • 集成错误监控工具(如 Sentry)

通过这种方式,你的 React 应用就能拥有一个全局的崩溃处理机制,在发生错误时给用户友好的提示,而不是白屏或控制台报错。

高级开发者相关问题【共计 6 道题】

1122. 将网页 dom 元素转为图片, 有哪些办法【热度: 41】【web 应用场景】

关键词:dom 转图片

在前端开发中,将 DOM 元素转换为图片有以下几种常见的方法:

1. 使用 HTML5 Canvas API (推荐)

这是最常用的方法,通过 Canvas 的drawImagegetContext方法绘制 DOM 内容,然后导出为图片。这种方法需要先将 DOM 内容转换为 Canvas 可绘制的格式,通常使用html2canvas库简化这个过程。

实现步骤

  1. 安装 html2canvas
npm install html2canvas
  1. 示例代码
import html2canvas from "html2canvas";

// 点击按钮触发截图
document.getElementById("captureBtn").addEventListener("click", async () => {
  const element = document.getElementById("targetElement");

  try {
    // 将DOM元素转换为Canvas
    const canvas = await html2canvas(element);

    // 将Canvas转换为图片URL
    const imgData = canvas.toDataURL("image/png");

    // 创建下载链接
    const link = document.createElement("a");
    link.download = "screenshot.png";
    link.href = imgData;
    link.click();
  } catch (error) {
    console.error("截图失败:", error);
  }
});

优点:兼容性好,支持大多数现代浏览器。
缺点:复杂元素(如阴影、SVG、iframe)可能渲染不完整。

2. 使用 Canvas 直接绘制

如果你只需要绘制简单的文本或图形,可以直接使用 Canvas API 手动绘制:

document.getElementById("captureBtn").addEventListener("click", () => {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");

  // 设置Canvas尺寸
  canvas.width = 300;
  canvas.height = 200;

  // 手动绘制内容
  ctx.fillStyle = "white";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = "black";
  ctx.font = "20px Arial";
  ctx.fillText("Hello, World!", 100, 100);

  // 导出为图片
  const imgData = canvas.toDataURL("image/png");
  const link = document.createElement("a");
  link.download = "manual-drawing.png";
  link.href = imgData;
  link.click();
});

优点:无需依赖外部库,可控性强。
缺点:仅适用于简单场景,复杂 DOM 难以手动绘制。

3. 使用 SVG

将 DOM 转换为 SVG 格式,然后导出为图片:

document.getElementById("captureBtn").addEventListener("click", () => {
  const targetElement = document.getElementById("targetElement");

  // 创建SVG元素
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  svg.setAttribute("width", targetElement.offsetWidth);
  svg.setAttribute("height", targetElement.offsetHeight);

  // 创建foreignObject嵌入HTML
  const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
  foreignObject.setAttribute("width", "100%");
  foreignObject.setAttribute("height", "100%");

  // 克隆目标元素并添加到foreignObject
  const clonedElement = targetElement.cloneNode(true);
  foreignObject.appendChild(clonedElement);
  svg.appendChild(foreignObject);

  // 转换为DataURL
  const svgData = new XMLSerializer().serializeToString(svg);
  const imgData = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgData)));

  // 下载图片
  const link = document.createElement("a");
  link.download = "svg-export.png";
  link.href = imgData;
  link.click();
});

优点:矢量图形,可无限缩放不失真。
缺点:对复杂 CSS 和 JavaScript 交互支持有限。

4. 使用第三方 API

对于服务器端渲染或复杂场景,可以使用第三方 API 如:

  • Puppeteer (Node.js 库):通过无头 Chrome 浏览器渲染页面并截图。
  • html2pdf.js:将 HTML 转换为 PDF 或图片。
  • ImgKit:基于 WebKit 的服务器端渲染服务。

示例(Puppeteer)

const puppeteer = require("puppeteer");

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.goto("https://example.com");
  await page.screenshot({ path: "page.png" });

  await browser.close();
})();

优点:渲染效果最接近浏览器,支持复杂场景。
缺点:需要服务器支持,增加了部署复杂度。

选择建议

  • 简单静态内容:使用 Canvas 直接绘制。
  • 复杂 DOM 元素:使用html2canvas库。
  • 需要高质量渲染:使用 Puppeteer 等服务器端方案。
  • 需要矢量图形:使用 SVG 方法。

根据你的具体需求选择最合适的方法即可。

1125. 介绍一下 git diff【热度: 396】【web 应用场景】

关键词:git diff

作者备注

这个比较冷门, 平常很多时候都用不上, 基本上可以当做科普了解 如果当面试官问到:代码有问题, 怎么排查是哪一个 commit 引入的, 可以参考以下的回答

git diff 是 Git 中用于查看文件修改差异的核心命令,能够展示不同版本、不同状态之间的代码变更,帮助你跟踪和理解代码的变化过程。

核心作用

比较不同版本或不同状态下的文件内容差异,主要场景包括:

  • 工作区与暂存区的差异
  • 暂存区与最新提交的差异
  • 不同提交之间的差异
  • 不同分支之间的差异

常用用法

1. 查看工作区与暂存区的差异(最常用)
git diff
  • 显示工作区中已修改但未暂存(未执行 git add)的文件与暂存区的差异
  • 输出格式:- 表示删除的内容,+ 表示新增的内容,行号用于定位位置
2. 查看暂存区与最新提交的差异
git diff --cached  # 或 git diff --staged
  • 显示已暂存(执行过 git add)但未提交的内容与最近一次提交(HEAD)的差异
  • 常用于提交前确认暂存的内容是否正确
3. 查看工作区与最新提交的差异
git diff HEAD
  • 同时显示未暂存已暂存的所有修改与最新提交的差异
  • 相当于 git diff(工作区 vs 暂存区) + git diff --cached(暂存区 vs HEAD)的合并结果
4. 比较两个提交之间的差异
git diff <提交ID1> <提交ID2>
  • 示例:比较 a1b2c3de4f5g6h 两个提交的差异
    git diff a1b2c3d e4f5g6h
    
  • 若只关心某个文件的差异,可在最后指定文件名:
    git diff a1b2c3d e4f5g6h src/index.js
    
5. 比较两个分支之间的差异
git diff <分支1> <分支2>
  • 示例:比较 feature/login 分支和 main 分支的差异
    git diff feature/login main
    
6. 查看某次提交相对于上一次提交的差异
git diff <提交ID>^ <提交ID>  # ^ 表示该提交的父提交
# 简化写法:
git diff <提交ID>~1 <提交ID>
  • 更简洁的方式:直接查看某次提交的修改内容
    git show <提交ID>  # 相当于 git diff <提交ID>^ <提交ID>
    

输出格式说明

git diff 的输出通常包含以下部分:

diff --git a/src/index.js b/src/index.js  # 比较的文件
index 1234567..abcdefg 100644            # 文件的索引信息
--- a/src/index.js                        # 源文件(旧版本)
+++ b/src/index.js                        # 目标文件(新版本)
@@ -5,7 +5,7 @@ function greet() {                # 差异所在的行范围
   console.log("Hello, world!");
-  console.log("This is old code");
+  console.log("This is new code");       # +表示新增内容
   return true;
 }

实用选项

  • -w:忽略空白字符的差异(如空格、换行的调整)
    git diff -w  # 忽略空白差异
    
  • --stat:只显示文件修改的统计信息(不显示具体内容)
    git diff --stat  # 例如:src/index.js | 2 +-(表示该文件有2行修改,1行新增1行删除)
    
  • -p:显示完整的差异内容(默认就是这个行为,可省略)

总结

git diff 是代码审查和变更跟踪的重要工具,核心是通过比较不同“版本快照”之间的差异,帮助你:

  • 提交前确认修改内容
  • 回顾历史变更
  • 了解分支之间的差异
  • 排查代码问题

熟练使用 git diff 能大幅提升对代码变更的掌控力,是日常 Git 操作中不可或缺的命令。

1127. less 是否支持条件判定【热度: 112】【web 应用场景】【出题公司: 腾讯】

关键词:less 条件判定

是的,Less 完全支持条件判定,其核心通过 when 关键字 实现,同时可结合比较运算符、逻辑运算符构建复杂的条件逻辑,主要用于动态控制样式规则的生效与否(如根据变量值切换样式、适配不同场景)。

一、核心语法:when 条件判断

Less 的条件判定并非像 JavaScript 那样的 if-else 语句,而是以 “条件附加在选择器/混合(Mixin)后” 的形式存在,只有当条件满足时,对应的样式才会被编译。

1. 基础语法结构
// 格式:选择器 / 混合名 when (条件) { 样式 }
选择器 when (条件) {
  // 条件满足时生效的样式
}

// 示例:当 @width 大于 500px 时,设置容器宽度
.container when (@width > 500px) {
  width: @width;
  padding: 20px;
}

二、支持的条件类型

Less 允许在 when 中使用 比较运算符逻辑运算符类型检查函数,覆盖绝大多数场景需求。

1. 比较运算符

用于数值(如长度、数字、百分比)的比较,支持 6 种运算符:

  • >:大于
  • <:小于
  • >=:大于等于
  • <=:小于等于
  • ==:等于(值和单位需完全匹配,如 500px == 500 不成立)
  • !=:不等于

示例:根据屏幕宽度变量适配样式

@screen-width: 1200px;

// 大屏幕(>1024px)
.header when (@screen-width > 1024px) {
  font-size: 18px;
  padding: 0 40px;
}

// 中屏幕(768px ~ 1024px)
.header when (@screen-width >= 768px) and (@screen-width <= 1024px) {
  font-size: 16px;
  padding: 0 20px;
}

// 小屏幕(<768px)
.header when (@screen-width < 768px) {
  font-size: 14px;
  padding: 0 10px;
}

编译后(因 @screen-width=1200px),仅大屏幕样式生效:

.header {
  font-size: 18px;
  padding: 0 40px;
}
2. 逻辑运算符

用于组合多个条件,支持 3 种逻辑关系:

  • and:逻辑“与”(所有条件均满足才生效)
  • ,(逗号):逻辑“或”(任意一个条件满足即生效,注意不是 or
  • not:逻辑“非”(否定单个条件,需用括号包裹)

示例:逻辑组合的应用

@theme: "dark";
@font-size: 16; // 无单位(后续需拼接)

// 条件1:主题为 dark OR 字体大小 >= 16
.text-style when (@theme == "dark"), (@font-size >= 16) {
  color: #fff;
  background: #333;
}

// 条件2:主题不是 light AND 字体大小 < 20
.text-style when not (@theme == "light") and (@font-size < 20) {
  font-weight: 500;
}

编译后(@theme=dark@font-size=16 满足所有条件):

.text-style {
  color: #fff;
  background: #333;
  font-weight: 500;
}
3. 类型检查函数

用于判断变量的 类型是否为数值,常见函数如下:

函数 作用 示例
isnumber(@value) 判断是否为数字(无论是否有单位) isnumber(123)true
isstring(@value) 判断是否为字符串 isstring("red")true
iscolor(@value) 判断是否为颜色值(如 #fffred iscolor(#333)true
isurl(@value) 判断是否为 URL(如 url(xxx.png) isurl(url(img.jpg))true
isunit(@value, 单位) 判断是否为指定单位的数值 isunit(50px, px)true

示例:类型检查控制样式

@border-width: 2px;
@border-color: "#000"; // 字符串类型的颜色

// 条件:边框宽度是 px 单位,且边框颜色是字符串(需转换为颜色)
.border when (isunit(@border-width, px)) and (isstring(@border-color)) {
  border: @border-width solid @border-color; // Less 会自动将字符串颜色转为颜色值
}

编译后:

.border {
  border: 2px solid #000;
}

三、进阶用法:结合混合(Mixin)

条件判定在 混合(Mixin) 中使用最广泛,可实现“动态复用样式”,甚至模拟“if-else 分支”。

1. 带条件的混合

定义仅在特定条件下生效的混合,调用时自动判断是否执行:

// 定义混合:仅当 @radius 是数字时,设置圆角
.border-radius(@radius) when (isnumber(@radius)) {
  border-radius: @radius * 1px; // 统一转为 px 单位
}

// 调用混合
.card {
  .border-radius(8); // 满足条件(8 是数字),编译为 border-radius: 8px
}

.button {
  .border-radius("8"); // 不满足条件("8" 是字符串),无样式输出
}
2. 模拟“if-else”分支

通过多个 when 条件的“互斥性”,实现类似 if-else 的逻辑(即“满足 A 则执行 A,否则执行 B”):

@is-disabled: true;

// 条件1:如果禁用(if)
.button-style when (@is-disabled = true) {
  background: #ccc;
  cursor: not-allowed;
  color: #999;
}

// 条件2:如果未禁用(else)
.button-style when (@is-disabled = false) {
  background: #007bff;
  cursor: pointer;
  color: #fff;
}

// 调用
.disabled-btn {
  .button-style; // 因 @is-disabled=true,编译为禁用样式
}

四、注意事项

  1. 条件仅支持“编译时判定”:Less 是预编译语言,条件判断基于 编译时的变量值,无法动态响应运行时(如浏览器窗口大小变化),运行时适配需结合 CSS @media 查询。

  2. 键名与变量的区别:条件中使用变量时,需确保变量已定义;若误写为未定义的键名(如 when (screen-width > 1000px)),Less 会视为 undefined,条件判定为 false

  3. 与 CSS @media 的分工

    • Less 条件:用于 编译时的静态变量控制(如主题切换、固定参数适配);
    • CSS @media:用于 运行时的动态环境适配(如屏幕宽度、设备像素比)。 两者可结合使用(如 Less 变量动态生成 @media 条件):
    @breakpoint: 768px;
    @media (max-width: @breakpoint) {
      .container when (@columns = 2) {
        // Less 条件 + CSS media
        display: grid;
        grid-template-columns: repeat(2, 1fr);
      }
    }
    

总结

Less 的条件判定通过 when 关键字实现,支持比较、逻辑、类型检查,核心价值是 在编译时动态控制样式的生成,尤其适合与混合结合实现可复用的条件样式。日常开发中,需根据“是否需要编译时变量控制”选择 Less 条件(静态)或 CSS @media(动态),两者配合可覆盖绝大多数适配场景。

1128. less 有那些高级特性, 驱使你们项目工程去使用它【热度: 336】【web 应用场景】【出题公司: 腾讯】

关键词:less 特性

Less 作为一款流行的 CSS 预处理器,核心价值在于通过增强 CSS 的可编程性、复用性和可维护性,简化样式开发流程。除了基础的变量、嵌套语法,它还提供了诸多“高级特性”,这些特性能应对复杂场景(如组件样式封装、主题切换、动态样式计算等)。以下是 Less 核心高级特性的详细解析,结合使用场景和示例帮助理解:

一、条件判定(Guards)

Less 不支持传统编程语言的 if-else 语句,但通过 Guards(守卫) 实现了“基于条件匹配样式规则”的能力,分为「规则守卫」和「混合守卫」,核心是通过表达式判断是否应用样式。

1. 规则守卫(Guards on Rulesets)

给选择器添加条件,只有满足条件时,该选择器下的样式才会生效。
语法& when (条件表达式)& 代表当前选择器)
支持的运算符>, <, >=, <=, ==, !=,以及逻辑运算符 and, or, not

示例:根据屏幕宽度动态调整字体大小

// 定义变量存储断点
@sm: 768px;
@md: 1024px;

.container {
  font-size: 14px; // 默认样式

  // 屏幕 >= 768px 时生效
  & when (@media-width >= @sm) {
    font-size: 16px;
  }

  // 屏幕 >= 1024px 时生效(and 连接多条件)
  & when (@media-width >= @md) and (@theme = "dark") {
    font-size: 18px;
    color: #fff;
  }
}
2. 混合守卫(Guards on Mixins)

给混合(Mixin)添加条件,只有满足条件时,混合中的样式才会被注入。常用于“动态复用样式片段”。

示例:根据主题切换按钮样式

// 定义带条件的混合
.button-style(@theme) when (@theme = "primary") {
  background: #1890ff;
  border: 1px solid #1890ff;
}

.button-style(@theme) when (@theme = "danger") {
  background: #ff4d4f;
  border: 1px solid #ff4d4f;
}

// 使用混合(传入不同主题,触发不同条件)
.btn-primary {
  .button-style("primary");
  color: #fff;
}

.btn-danger {
  .button-style("danger");
  color: #fff;
}

二、高级变量特性

Less 的变量不仅支持“值存储”,还支持变量插值变量作用域变量运算,灵活应对动态样式场景。

1. 变量插值(Variable Interpolation)

将变量值插入到选择器名、属性名、URL、字符串中,实现“动态生成标识符”。
语法@{变量名}

示例:动态生成选择器和 URL

// 1. 动态选择器(如组件前缀)
@component-prefix: "my-btn";

.@{component-prefix} {
  // 最终编译为 .my-btn
  padding: 8px 16px;
}

.@{component-prefix}-disabled {
  // 最终编译为 .my-btn-disabled
  opacity: 0.6;
  cursor: not-allowed;
}

// 2. 动态 URL(如图片路径)
@img-path: "../assets/img";

.logo {
  background: url("@{img-path}/logo.png"); // 最终编译为 url("../assets/img/logo.png")
}

// 3. 动态属性名(如主题色属性)
@property: "color";
@theme-color: #1890ff;

.title {
  @{property}: @theme-color; // 最终编译为 color: #1890ff
}
2. 变量作用域(Variable Scope)

Less 变量遵循“就近原则”:局部作用域(如选择器、混合内部)的变量会覆盖全局作用域的变量,且支持“向上查找”(局部没有时,查找父级作用域)。

示例:作用域优先级

@color: red; // 全局变量

.container {
  @color: blue; // 局部变量(覆盖全局)
  .box {
    color: @color; // 优先使用局部变量,最终为 blue
  }
}

.text {
  color: @color; // 无局部变量,使用全局变量,最终为 red
}
3. 变量运算(Operations)

支持对数字、颜色、长度单位进行算术运算(+, -, *, /),自动处理单位兼容(如 pxrem 混合运算)。

示例:动态计算样式值

@base-padding: 10px;
@base-font-size: 14px;

.card {
  // 数字运算(padding = 基础值 * 1.5)
  padding: @base-padding * 1.5; // 最终 15px

  // 颜色运算(深色 = 基础色降低亮度)
  @base-color: #1890ff;
  background: @base-color - #333; // 最终 #0066cc

  // 单位混合运算(font-size = 基础值 + 2rem)
  font-size: @base-font-size + 2rem; // 最终 16px(Less 自动统一单位)
}

三、混合(Mixins)进阶

混合是 Less 的核心复用特性,除了基础的“样式片段复用”,还支持带参数混合、默认参数、剩余参数,甚至可以“返回值”(通过变量传递)。

1. 带参数混合(Parametric Mixins)

给混合定义参数,使用时传入不同值,实现“个性化复用”。

示例:通用圆角混合

// 定义带参数的混合(@radius 为参数)
.border-radius(@radius) {
  -webkit-border-radius: @radius;
  -moz-border-radius: @radius;
  border-radius: @radius;
}

// 使用混合(传入不同半径值)
.btn {
  .border-radius(4px); // 小圆角
}

.card {
  .border-radius(8px); // 大圆角
}
2. 默认参数(Default Values)

给混合参数设置默认值,使用时可省略该参数(自动使用默认值)。

示例:带默认值的阴影混合

// 定义混合(@color 默认 #000,@opacity 默认 0.2)
.box-shadow(@x: 0, @y: 0, @blur: 4px, @color: #000, @opacity: 0.2) {
  box-shadow: @x @y @blur rgba(@color, @opacity);
}

// 使用混合(省略部分参数,使用默认值)
.card {
  .box-shadow(0, 2px); // 省略 @blur(默认 4px)、@color(默认 #000)、@opacity(默认 0.2)
  // 最终编译为:box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)
}
3. 剩余参数(Variadic Arguments)

当混合参数数量不确定时,用 ... 接收“剩余所有参数”,类似 JavaScript 的 rest 参数。

示例:灵活的过渡动画混合

// 定义混合(@props 接收所有过渡属性,@duration 默认 0.3s)
.transition(@props..., @duration: 0.3s) {
  transition: @props @duration ease;
}

// 使用混合(传入多个过渡属性)
.btn {
  .transition(color, background); // @props 接收 [color, background]
  // 最终编译为:transition: color background 0.3s ease
}

.card {
  .transition(transform, opacity, 0.5s); // 自定义 duration 为 0.5s
  // 最终编译为:transition: transform opacity 0.5s ease
}

四、导入(Import)进阶

Less 的 @import 不仅能导入其他 Less 文件,还支持条件导入引用导入导入变量/混合,灵活管理样式模块。

1. 条件导入(Conditional Import)

结合 Guards 实现“满足条件时才导入文件”,常用于“按需加载主题/适配样式”。

示例:根据主题导入不同样式文件

@theme: "dark"; // 可动态切换为 "light"

// 条件:主题为 dark 时,导入深色主题文件
@import (multiple) "theme-dark.less" when (@theme = "dark");

// 条件:主题为 light 时,导入浅色主题文件
@import (multiple) "theme-light.less" when (@theme = "light");
  • 注:(multiple) 表示“允许重复导入同一文件”(默认不允许)。
2. 引用导入(Reference Import)

@import (reference) 导入文件时,仅引用文件中的混合、变量,不编译文件本身的样式,避免冗余代码。

示例:引用工具类文件(仅用混合,不编译工具类样式)

// 导入工具类文件(reference 表示仅引用,不编译 utils.less 中的选择器)
@import (reference) "utils.less";

// 使用 utils.less 中的混合(如 .clearfix)
.container {
  .clearfix(); // 仅注入 .clearfix 的样式,utils.less 其他样式不编译
}
3. 导入变量/混合(Import for Variables/Mixins)

导入文件时,可直接使用目标文件中的变量和混合,实现“样式模块拆分”(如将变量、混合、组件样式分别放在不同文件)。

示例:模块化拆分样式

// 1. variables.less(存储全局变量)
@primary-color: #1890ff;
@font-size-base: 14px;

// 2. mixins.less(存储通用混合)
.clearfix() {
  &::after {
    content: "";
    display: table;
    clear: both;
  }
}

// 3. main.less(导入并使用)
@import "variables.less";
@import "mixins.less";

.btn {
  color: @primary-color; // 使用 variables.less 的变量
  font-size: @font-size-base;
}

.container {
  .clearfix(); // 使用 mixins.less 的混合
}

五、循环(Loops)

Less 没有专门的 for/while 循环语法,但通过混合自调用(混合内部调用自身)实现循环效果,常用于“生成重复样式”(如网格系统、层级样式)。

示例 1:生成 1-5 级标题样式

// 定义循环混合(@n 为当前层级,@max 为最大层级)
.generate-heading(@n, @max) when (@n <= @max) {
  // 动态生成选择器(h1, h2, ..., h@max)
  h@{n} {
    font-size: 16px + (@n - 1) * 4px; // 每级标题增大 4px
    margin-bottom: 8px + (@n - 1) * 2px;
  }
  // 自调用(层级 +1)
  .generate-heading(@n + 1, @max);
}

// 触发循环(生成 h1-h5 样式)
.generate-heading(1, 5);

编译结果

h1 {
  font-size: 16px;
  margin-bottom: 8px;
}
h2 {
  font-size: 20px;
  margin-bottom: 10px;
}
h3 {
  font-size: 24px;
  margin-bottom: 12px;
}
h4 {
  font-size: 28px;
  margin-bottom: 14px;
}
h5 {
  font-size: 32px;
  margin-bottom: 16px;
}

示例 2:生成网格系统(col-1 到 col-12)

.generate-col(@n) when (@n <= 12) {
  .col-@{n} {
    width: (@n / 12) * 100%; // 每列宽度 = (n/12)*100%
    float: left;
  }
  .generate-col(@n + 1);
}

.generate-col(1); // 生成 col-1 到 col-12

六、内置函数(Built-in Functions)

Less 提供了丰富的内置函数,覆盖颜色处理、字符串操作、数值计算等场景,无需手动编写复杂逻辑。以下是常用内置函数分类:

函数类别 常用函数 功能说明 示例
颜色处理 darken(@color, @percent) 降低颜色亮度(百分比) darken(#1890ff, 10%) → #096dd9
lighten(@color, @percent) 提高颜色亮度(百分比) lighten(#1890ff, 10%) → #3ba0ff
rgba(@color, @alpha) 设置颜色透明度 rgba(#1890ff, 0.5) → rgba(24,144,255,0.5)
字符串操作 replace(@str, @find, @replace) 替换字符串内容 replace("hello", "h", "H") → "Hello"
upper(@str) 字符串转大写 upper("hello") → "HELLO"
数值计算 ceil(@num) 向上取整 ceil(2.3) → 3
floor(@num) 向下取整 floor(2.7) → 2
percentage(@num) 小数转百分比 percentage(0.25) → 25%
其他 typeof(@value) 判断值的类型(number/string/color 等) typeof(#fff) → "color"

示例:用内置函数处理主题色

@primary: #1890ff;

.btn {
  background: @primary;
  //  hover 时加深 10% 亮度
  &:hover {
    background: darken(@primary, 10%);
  }
  //  active 时降低透明度到 0.8
  &:active {
    background: rgba(@primary, 0.8);
  }
}

总结

Less 的高级特性围绕“复用、动态、可维护”三大核心设计,适合复杂项目的样式开发:

  • 动态切换样式(如主题、响应式):用「条件判定」「变量插值」「条件导入」;
  • 复用样式片段(如组件、工具类):用「带参数混合」「剩余参数」「引用导入」;
  • 生成重复样式(如网格、层级):用「循环」;
  • 处理颜色/数值:用「内置函数」「变量运算」。

合理搭配这些特性,能大幅减少冗余 CSS 代码,提升样式开发效率和可维护性。

1132. ts 里面 infer 是什么关键词, 怎么用【热度: 975】【TypeScript】【出题公司: 美团】

关键词:ts infer

在 TypeScript 中,infer 是一个用于类型推断的关键字,通常与条件类型(Conditional types)配合使用,用于从泛型类型中提取或推断出某个具体类型。它的核心作用是“让 TypeScript 自动推导出我们需要的类型”,而无需需手动指定。

基本语法与作用

infer 只能在条件类型的 extends 子句中使用,语法格式如下:

type 类型名<T> = T extends 某个类型<infer 待推断类型> ? 待推断类型 : 其他类型;
  • infer X 表示“声明一个需要推断的类型变量 X
  • TypeScript 会自动分析 T 的结构,推导出 X 的具体类型

典型使用场景

1. 提取函数的返回值类型

最常见的场景之一:从函数类型中提取其返回值类型。

// 定义一个条件类型,提取函数的返回值类型
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// 使用示例
function getUser() {
  return { name: "张三", age: 20 };
}

// 推断 getUser 函数的返回值类型
type User = ReturnType<typeof getUser>;
// User 的类型为 { name: string; age: number }
  • T extends (...args: any[]) => infer R 表示:如果 T 是一个函数,就推断其返回值类型为 R
  • 最终 User 被推断为函数 getUser 的返回值类型
2. 提取函数的参数类型

类似地,可以提取函数的参数类型(单个参数或参数列表)。

// 提取单个参数类型
type ParamType<T> = T extends (param: infer P) => any ? P : never;

// 提取参数列表类型(返回元组)
type ParamsType<T> = T extends (...args: infer P) => any ? P : never;

// 使用示例
function sum(a: number, b: string): boolean {
  return a + Number(b) > 10;
}

type SumParam = ParamType<typeof sum>; // 错误!因为函数有多个参数,这里会返回 never
type SumParams = ParamsType<typeof sum>; // [number, string](参数列表组成的元组)
type SumReturn = ReturnType<typeof sum>; // boolean(返回值类型)
3. 提取数组的元素类型

从数组类型中推断出元素的类型。

// 提取数组元素类型
type ArrayItem<T> = T extends Array<infer Item> ? Item : T;

// 使用示例
type NumberItem = ArrayItem<number[]>; // number
type StringItem = ArrayItem<string[]>; // string
type UserItem = ArrayItem<{ name: string }[]>; // { name: string }
type Primitive = ArrayItem<boolean>; // boolean(非数组类型则返回自身)
4. 提取 Promise 的 resolve 类型

Promise 类型中推断出其最终解析(resolve)的类型。

// 提取 Promise 解析的类型
type PromiseResolve<T> = T extends Promise<infer R> ? R : T;

// 使用示例
type Resolve1 = PromiseResolve<Promise<string>>; // string
type Resolve2 = PromiseResolve<Promise<{ id: number }>>; // { id: number }
type Resolve3 = PromiseResolve<number>; // number(非 Promise 类型则返回自身)
5. 嵌套推断(复杂结构)

infer 支持多层嵌套推断,可用于复杂类型结构的提取。

// 从 { data: T } 结构中提取 T
type ExtractData<T> = T extends { data: infer D } ? D : T;

// 嵌套推断:从 Promise<{ data: T }> 中提取 T
type ExtractPromiseData<T> = T extends Promise<{ data: infer D }> ? D : T;

// 使用示例
type Data1 = ExtractData<{ data: { name: string } }>; // { name: string }
type Data2 = ExtractPromiseData<Promise<{ data: number[] }>>; // number[]

注意事项

  1. 只能在条件类型中使用infer 不能单独使用,必须放在 T extends ... 的子句中。

  2. 推断的不确定性:如果 TypeScript 无法明确推断类型(如多种可能的匹配),会返回 never 或联合类型。

    type Ambiguous<T> = T extends (a: infer A, b: infer A) => any ? A : never;
    type Test = Ambiguous<(x: number, y: string) => void>; // number | string(联合类型)
    
  3. 与内置工具类型的关系:TypeScript 内置的很多工具类型(如 ReturnTypeParameters)都是基于 infer 实现的,例如:

    // TypeScript 内置的 Parameters 实现
    type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
    

总结

infer 是 TypeScript 类型系统中用于自动推断类型的强大工具,核心价值在于:

  • 从复杂类型(如函数、数组、Promise 等)中“提取”我们需要的具体类型
  • 减少手动编写重复类型的工作量,提升类型定义的灵活性和可维护性

它最常见的应用场景包括提取函数参数/返回值、数组元素、Promise 解析值等,是编写高级类型工具的基础。

1135. TypeScript 中,ReturnType 的作用和用法【TypeScript】

关键词:ts RetrunType

在 TypeScript 中,ReturnType 是一个内置的工具类型,用于提取函数的返回值类型。它可以自动推断并返回函数的返回值类型,无需手动手动编写重复的类型定义,是处理函数类型时非常实用的工具。

作用

  • 从给定的函数类型中提取其返回值的类型,避免手动定义与函数返回值相同的类型,减少冗余代码。
  • 当函数返回值类型修改时,通过 ReturnType 提取的类型会自动同步更新,保证类型一致性。

用法

基础语法
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
  • 接收一个泛型参数 T,该参数必须是一个函数类型(通过 extends (...args: any) => any 约束)。
  • 使用 infer R 推断函数的返回值类型 R,最终返回 R
实际示例
  1. 提取普通函数的返回值类型

    // 定义一个返回对象的函数
    function getUser() {
      return { name: "张三", age: 20, isStudent: false };
    }
    
    // 提取函数返回值类型
    type User = ReturnType<typeof getUser>;
    // User 的类型为:{ name: string; age: number; isStudent: boolean }
    
  2. 提取箭头函数的返回值类型

    const calculate = (a: number, b: number) => a + b;
    
    // 提取返回值类型(number)
    type Result = ReturnType<typeof calculate>; // Result = number
    
  3. 提取泛型函数的返回值类型

    function createArray<T>(length: number, value: T): T[] {
      return Array(length).fill(value);
    }
    
    // 提取特定调用的返回值类型
    type StringArray = ReturnType<typeof createArray<string>>; // StringArray = string[]
    
  4. 在类型定义中复用

    // 定义一个回调函数类型
    type FetchData = (url: string) => Promise<{ code: number; data: string }>;
    
    // 提取该函数返回的 Promise 内部类型
    type FetchResult = ReturnType<FetchData>; // FetchResult = Promise<{ code: number; data: string }>
    
    // 进一步提取 Promise 的 resolve 类型(结合 Awaited)
    type Data = Awaited<FetchResult>; // Data = { code: number; data: string }
    

注意事项

  1. 仅支持函数类型ReturnType 的参数必须是函数类型,否则会报错。

    type Invalid = ReturnType<string>; // 报错:string 不是函数类型
    
  2. typeof 配合使用:当需要提取具体函数的返回值类型时,需用 typeof 获取该函数的类型(如 typeof getUser)。

  3. 内置工具类型ReturnType 是 TypeScript 内置的,无需手动定义,直接使用即可。

总结

ReturnType 的核心价值是自动同步函数返回值类型,尤其适合以下场景:

  • 函数返回值类型复杂,避免手动重复定义。
  • 函数返回值可能频繁修改,通过 ReturnType 确保依赖其类型的地方自动更新。
  • 在类型层面复用函数返回值结构,提升代码可维护性。

一文教你搞懂sessionStorage、localStorage、cookie、indexedDB

2025年9月3日 14:02

引言

在前端开发中,浏览器提供了多种本地存储方式,例如 localStorage、sessionStorage 和 IndexedDB。它们能让我们把部分数据保存在用户浏览器端,从而减少与服务器的交互,提升应用的响应速度,并且在网络状况不佳时依旧能维持基本的使用体验。

然而,这些存储机制如果使用不当,会带来隐患:比如过度依赖 localStorage 存放敏感信息,可能引发安全问题;频繁无序地写入数据,会导致存储混乱甚至性能下降;或者没有清理策略,造成用户浏览器空间被大量占用。

相反,如果使用得当,则可以带来显著收益:页面打开更快、用户体验更流畅、离线状态下依旧可用,甚至还能实现类似“小型数据库”的功能,帮助前端在复杂应用中承担更多逻辑。

因此,用不好 → 安全隐患、数据不同步、性能下降;用好 → 性能优化、用户体验提升、支持离线能力。这正是前端工程师需要掌握这些浏览器存储技术的关键原因。

sessionStorage

什么是 sessionStorage

  • 浏览器提供的每个标签页独享的键值存储(同源内共享)。
  • 生命周期:创建 → 刷新/前进后退仍在 → 关闭该标签页或窗口即清空(或被代码/用户清除)。
  • 不随请求发送到服务器(和 Cookie 不同)。
  • 容量:约 ~5MB/源(不同浏览器略有差异)。

作用域与隔离

  • 同源 + 同标签页共享:同一标签页内的所有同源页面共享一个 sessionStorage。
  • 不同标签页彼此隔离;新开标签(即使同 URL)也有全新的 sessionStorage。
  • 通过 window.open() 打开的同源页,起始内容会被克隆一份,但之后互不影响。
  • storage 事件:sessionStorage 不会跨标签页广播变更(因为不共享)。在当前页变更也通常不会触发监听器去“同步别的页”。

适合

  • 表单临时状态(页面刷新不丢失)
  • 向导/多步流程的中间数据(每个标签页互不干扰)
  • 单次会话内的缓存(如本页拉过的列表、分页位置)

不适合

  • 任何敏感信息(明文可读,易被 XSS 窃取)
  • 需要跨标签页/长期持久的数据(用 localStorage/IndexedDB)
  • 大量/结构化数据(用 IndexedDB)

常见坑

  • 超配额会抛 QuotaExceededError:用 try/catch 捕获。
  • 类型丢失:没做 JSON 序列化会变成字符串 [object Object]
  • 同步 API:频繁大量写入会阻塞主线程;控制写入频率与体积。
  • Safari/移动端:隐私模式/内存压力下可能更严格,注意兜底(失败时退回内存变量)。

localStorage

什么是 localStorage

  • 浏览器提供的同源共享的键值对存储(Key-Value)。
  • 持久化:关闭页面/浏览器后仍保留,直到被代码或用户清除(或清站点数据)。
  • 仅前端可见:不会随请求自动发给服务器(和 Cookie 不同)。
  • 字符串存储:值一律按字符串保存(对象需 JSON 序列化)。

生命周期与作用域

  • 生命周期:创建 → 刷新/切页仍在 → 手动清除/清站点数据 才消失。
  • 作用域(Origin) :协议+域名+端口三者相同才共享。
  • 跨标签页:同源所有标签页/窗口共享同一份 localStorage。
  • 第三方/隐私机制:现代浏览器在第三方 iframe、隐私/无痕模式下可能更严格或不可用。

适合

  • 表单临时状态(页面刷新不丢失)
  • 向导/多步流程的中间数据(每个标签页互不干扰)
  • 单次会话内的缓存(如本页拉过的列表、分页位置)

不适合

  • 任何敏感信息(明文可读,易被 XSS 窃取)
  • 需要跨标签页/长期持久的数据(用 localStorage/IndexedDB)
  • 大量/结构化数据(用 IndexedDB)

与 localStorage / Cookie 的关键区别

  • 生命周期:sessionStorage 关标签就没;localStorage 长久保存;Cookie 取决于 Expires/Max-Age
  • 是否随请求发送:只有 Cookie 会自动带到请求里;sessionStorage/localStorage 都不会。
  • 作用域:sessionStorage 是每标签页独享;localStorage 是同源所有标签共享

常见坑

  • 超配额会抛 QuotaExceededError:用 try/catch 捕获。
  • 类型丢失:没做 JSON 序列化会变成字符串 [object Object]
  • 同步 API:频繁大量写入会阻塞主线程;控制写入频率与体积。
  • Safari/移动端:隐私模式/内存压力下可能更严格,注意兜底(失败时退回内存变量)。

cookie

什么是 Cookie

  • 一种由浏览器保存的 小块文本数据(通常不超过 4KB),由服务器或前端 JS 设置。
  • Cookie 会自动随同域请求被发送到服务器,用于身份识别、会话保持、用户偏好等。
  • 每个 Cookie 都包含 键值对、域、路径、过期时间、安全属性等信息。

生命周期

  • 会话 Cookie:不设置 ExpiresMax-Age,浏览器关闭即消失。
  • 持久 Cookie:设置了过期时间,在过期前都会保留,即使浏览器关闭。
  • 删除 Cookie:可设置一个过去的时间作为过期时间,或用户手动清除。

适合

  • Session ID、用户登录状态(结合 HttpOnly + Secure + SameSite)
  • 必须随请求发送的小块状态

不适合

  • 大量数据(容量小)
  • 敏感信息(若没设置 HttpOnly,容易被 JS 读出)
  • 高频更新数据(每次请求都会带上,浪费带宽)

Cookie 是小容量、随请求自动发送、适合会话管理的存储方式;现代前端更多使用 localStorage / sessionStorage / IndexedDB 存储本地数据,而 Cookie 专注于“和服务器同步状态”

IndexedDB

什么是 IndexedDB

  • 浏览器提供的 非关系型本地数据库(NoSQL),适合存储大量结构化数据。
  • 面向对象的存储方式:数据存放在「对象仓库(Object Store)」里,而不是表格行列。
  • 支持 索引、事务、游标,能实现接近数据库的查询与操作。
  • 容量:可达数百 MB(甚至更大,具体取决于浏览器和用户设置)。

生命周期与作用域

  • 生命周期:持久保存,除非被代码/用户/清站点数据手动删除。
  • 作用域:基于同源策略(协议+域名+端口一致才共享)。
  • 多标签页共享:同源下所有标签页可访问同一数据库。

特点

  • 异步 API(不会阻塞主线程,基于事件回调或 Promise)。
  • 事务:读写操作必须在事务中进行,保证原子性。
  • 索引:可为字段建立索引,加快查询。

适用场景

  • 离线应用:在无网络时保存数据,下次上线再同步。
  • 大体量数据缓存:如文章列表、聊天记录、本地音乐缓存。
  • 复杂数据结构:存储 JSON、二进制数据(如 Blob、文件)。
  • 渐进式 Web 应用(PWA) :结合 Service Worker 做完整离线方案。

对比

特点 Cookie localStorage sessionStorage IndexedDB
生命周期 可设置过期时间;默认会话级别,关闭浏览器清除 永久保存,除非手动清除 会话级:关闭标签页/窗口清除 永久保存,除非手动清除
容量限制 ~4KB 左右 ~5MB ~5MB 可达数百 MB(依浏览器而定)
作用域 同源,且可配置路径;会随请求发送到服务器 同源 同源(同窗口/标签页) 同源
是否随请求发送 ✅ 每次请求都会带上(增加带宽) ❌ 不会 ❌ 不会 ❌ 不会
可访问性 JS 可访问(除非设置 HttpOnly);服务器也可读写 仅 JS 端可访问 仅 JS 端可访问 仅 JS 端可访问
适合场景 存储少量需随请求携带的信息(如 Session ID) 保存长期数据(主题、配置、token 等) 保存临时数据(表单状态、页面刷新不丢失) 保存大量结构化数据(离线应用、本地数据库)
安全性 容易被窃取;应结合 HttpOnly + Secure + SameSite 明文存储,易被 XSS 窃取,不建议存敏感信息 明文存储,易被 XSS 窃取,不建议存敏感信息 相对安全,支持事务,但依旧避免存敏感数据

结论

技术 生命周期 容量 特点 适合使用场景
Cookie 可设过期时间;会话级或持久 ~4KB 每次请求都会自动携带到服务器;支持 HttpOnly / Secure / SameSite 安全属性 身份认证(Session ID、CSRF Token)、服务端需要的少量状态
sessionStorage 浏览器标签页/窗口关闭即清空 ~5MB 同源、同标签页共享;不随请求发送;仅 JS 端可见 临时存储:表单数据、页面状态(刷新不丢)、单次会话流程
localStorage 永久保存,除非手动清除 ~5–20MB 同源所有标签页共享;不随请求发送;同步 API,值为字符串 长期存储:主题、语言偏好、轻量缓存(如最近浏览)、跨标签页共享小数据
IndexedDB 永久保存,除非手动清除 可达数百 MB+ 面向对象数据库,支持事务、索引、异步 API;结构化存储 复杂/大量数据:离线应用、大体量缓存(文章、消息)、需要查询的结构化数据

🔥面试官:说说看,用户登录后拿到的 Token,你应该怎么存?存哪里?

2025年9月2日 10:39

开篇:一个经典的面试题

“说说看,用户登录后拿到的 Token,前端应该怎么存?”

这个问题看似简单,却能清晰地分辨出一个前端开发者对安全的理解深度。是存到 localStoragesessionStorage?还是 Cookie?又或者是内存里?不同的选择背后,是截然不同的安全考量。

今天,来聊一聊 Token 的存储之道,让你不仅知道怎么做,更明白为什么这么做。

a007f1e0ly1i4uoqtydizj20k60k6tav.jpg

选项一:Web Storage(localStorage / sessionStorage

这是最直观、最容易想到的方案。

// 登录成功后
const token = 'your_jwt_token_here';
localStorage.setItem('auth_token', token);

// 后续请求时带上
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

优点:

  • 简单易用:API 简单,上手快。
  • 永久存储localStorage):除非手动清除,否则一直存在。
  • 会话期存储sessionStorage):页面关闭即清除,更安全一点。

致命缺点:

  • 极易受到 XSS (跨站脚本攻击) 的攻击。这是最核心的问题。如果网站存在 XSS 漏洞,攻击者的恶意脚本可以轻易地读取到 localStorage 中的所有 Token,从而窃取用户身份。

结论: 不推荐,尤其对于敏感应用。 除非你的应用完全不存在 XSS 风险(但这几乎不可能),或者 Token 的安全级别要求不高。

选项二:Cookie

Cookie 是传统且常见的身份验证载体。

// 服务端设置 Cookie (HTTP Response Header)
Set-Cookie: auth_token=your_jwt_token_here; Path=/; HttpOnly; Secure

// 前端无需特殊处理,浏览器会自动在每次请求中携带

注意这里的两个关键属性:

  • HttpOnly这是对抗 XSS 的神器。设置了 HttpOnly 的 Cookie 无法通过 JavaScript 的 document.cookie API 访问,这意味着即使发生 XSS,攻击者也无法窃取到 Token。
  • Secure:强制 Cookie 只能在 HTTPS 协议下被发送,防止在网络传输中被窃听。

优点:

  • 免疫 XSS(得益于 HttpOnly):无法通过 JS 读取,安全性高。
  • 可控制生命周期:通过 ExpiresMax-Age 设置过期时间。
  • 自动管理:浏览器自动在同源请求中携带。

缺点:

  • 容易受到 CSRF (跨站请求伪造) 的攻击。因为浏览器会自动在请求中带上 Cookie,攻击者可以诱导用户点击一个链接,从而以用户的身份发起恶意请求。
  • 需要额外的 CSRF 防护措施,如使用 Anti-CSRF Token、验证 Origin/Referer Header 等。

结论: 是一个可行的方案,但必须配套完善的 CSRF 防御机制。

选项三:内存(Memory)

将 Token 保存在 JavaScript 变量中。

let inMemoryToken = null;

// 登录成功后
const login = async () => {
  const response = await loginAPI(username, password);
  inMemoryToken = response.data.token; // 存到内存变量
};

// 请求拦截器中添加
axios.interceptors.request.use((config) => {
  if (inMemoryToken) {
    config.headers.Authorization = `Bearer ${inMemoryToken}`;
  }
  return config;
});

// 退出登录或页面刷新时,Token 消失

优点:

  • 安全性极高:由于 Token 只存在于当前页面的内存中,关闭页面或刷新页面后 Token 立即消失。XSS 攻击者很难通过一次性注入的脚本持续地窃取到 Token(除非攻击代码常驻内存)。

缺点:

  • 体验差:页面一旦刷新,Token 就没了,用户需要重新登录。这对于单页面应用 (SPA) 来说是致命的。

结论: 适用于安全要求极高、不介意频繁登录的场景(如银行系统)。 对于普通 Web 应用,体验不可接受。


终极方案:组合拳 + 架构思维

既然没有完美的银弹,现代前端的最佳实践通常是 组合方案架构优化

实践一:Cookie(HttpOnly + Secure) + 防御 CSRF

这是最传统但依然非常稳健的方案。

  1. 后端在登录成功后,设置一个 HttpOnlySecure 的 Cookie 来存放 Token(可以是 JWT,也可以是 Session ID)。
  2. 前端基本不用操心 Token 的存储和携带问题,由浏览器自动完成。
  3. 后端必须部署完善的 CSRF 防护策略,例如:
    • 从 Cookie 中读取 Token 或 Session ID 进行身份验证。
    • 同时,要求请求必须携带一个额外的(由后端生成并通过 API 返回给前端的)X-CSRF-Token Header,并与 Session 中存储的值进行比对。

实践二:Access Token + Refresh Token

这是目前 API 接口认证非常流行的方案,完美解决了内存存储体验差的问题。

  1. 登录:用户输入密码登录。
  2. 返回双 Token:服务端返回两个 Token:
    • access_token:短期有效(例如 2 小时),用于请求受保护的 API。
    • refresh_token:长期有效(例如 7 天),仅用于获取新的 access_token,不应具备API访问权限。
  3. 存储策略
    • access_token存入内存。这样即使被 XSS 窃取,有效期也很短,风险可控。
    • refresh_token存入 HttpOnly Cookie。因为它有效期长,必须严加保护。由于其本身不直接用于业务请求,即使遭遇 CSRF,攻击者也无法用它来做任何关键操作(只能用来换一个短期的 access_token,而该 access_token 又会因为存在于内存而难以被攻击者获取)。
  4. 无感刷新:当 access_token 过期后,前端自动(通过 refresh_token)调用刷新接口获取新的 access_token,用户无感知。

这个方案在安全性和用户体验之间取得了绝佳的平衡,是当前众多大型应用的首选。


总结与建议

存储位置 优点 缺点 适用场景
Web Storage 简单易用 极易被 XSS 不推荐用于存敏感 Token
Cookie 可防 XSS (HttpOnly) 需防御 CSRF 传统 Web 应用(需配 CSRF 防御)
内存 安全性极高 页面刷新即失效 安全至上、不介意体验的场景
组合策略 安全与体验的平衡 架构稍复杂 现代 Web 应用最佳实践

给你的最终建议:

  • 如果你在做的是一个简单的、内部使用的、对安全性要求不高的工具,用 localStorage 图个方便也无可厚非,但要清楚风险。
  • 如果你在做的是一个传统的、服务端渲染的多页应用,使用 HttpOnly Cookie 并配套 CSRF 防护是标准做法。
  • 如果你在做的是一个现代化的 SPA(如 React/Vue 应用),强烈推荐研究并采用 Access Token (内存) + Refresh Token (HttpOnly Cookie) 的方案。

安全没有绝对,只有相对的权衡。理解每种方案的利弊,并根据你的实际业务场景做出最适合的选择,才是最重要的。

下次再见!🌈

Snipaste_2025-04-27_15-18-02.png

❌
❌