阅读视图

发现新文章,点击刷新页面。

Nginx 部署 Vue3 项目完整指南

Nginx 部署 Vue3 项目完整指南

本文档详细说明如何使用 Nginx 部署 Vue3 单页应用,包括本地开发环境和服务端生产环境的配置差异。


目录

  1. Nginx 基础知识
  2. 本地开发环境配置
  3. 服务器生产环境配置
  4. 本地与服务器的配置差异
  5. 完整部署流程
  6. 常用命令速查
  7. 常见问题排查

1. Nginx 基础知识

1.1 什么是 Nginx?

Nginx 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 服务器。在 Web 开发中,主要用途:

  • 静态资源服务器:托管 HTML、CSS、JS、图片等静态文件
  • 反向代理:将请求转发到后端服务器
  • 负载均衡:将请求分发到多台服务器

1.2 Nginx 目录结构

nginx-1.24.0/
├── conf/               # 配置文件目录
│   ├── nginx.conf      # 主配置文件(最重要)
│   ├── mime.types      # 文件类型映射
│   └── ...
├── html/               # 默认静态文件目录
│   ├── index.html
│   └── 50x.html
├── logs/               # 日志目录
│   ├── access.log      # 访问日志
│   └── error.log       # 错误日志
├── temp/               # 临时文件目录
├── contrib/            # 扩展模块
├── docs/               # 文档
└── nginx.exe           # Windows 可执行文件

1.3 配置文件基本结构

# 全局块 - 影响 Nginx 整体运行
worker_processes  1;  # 工作进程数

# events 块 - 影响网络连接
events {
    worker_connections  1024;  # 每个进程最大连接数
}

# http 块 - Web 服务器配置
http {
    # http 全局配置
    include       mime.types;

    # server 块 - 虚拟主机配置
    server {
        listen       80;        # 监听端口
        server_name  localhost; # 域名/IP

        # location 块 - 路由匹配
        location / {
            root   html;        # 静态文件目录
            index  index.html;  # 默认首页
        }
    }
}

2. 本地开发环境配置

2.1 当前项目配置

配置文件位置C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0\conf\nginx.conf

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        # 你的 Vue3 项目打包后的目录
        root   D:/test/vue3-h5/dist;
        index  index.html index.htm;

        location / {
            # Vue 是单页应用,所有路由都要返回 index.html
            try_files $uri $uri/ /index.html;
        }

        # 开启 gzip 压缩,加快加载速度
        gzip on;
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
        gzip_min_length 1000;

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

2.2 配置详解

配置项 说明
listen 80 监听端口,本地访问用 localhost:80
server_name localhost 本地测试用 localhost
root D:/test/vue3-h5/dist 指向本地打包目录
try_files uriuri uri/ /index.html Vue SPA 路由支持
gzip on 开启压缩,提升加载速度

2.3 启动步骤

# 1. 进入 Nginx 目录
cd C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0

# 2. 启动 Nginx
start nginx

# 3. 访问测试
# 浏览器打开 http://localhost

3. 服务器生产环境配置

3.1 完整的服务器配置

# 生产环境配置示例

# 根据 CPU 核心数设置工作进程
worker_processes  auto;

# 错误日志级别设为 warn
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    # 最大连接数,根据服务器配置调整
    worker_connections  2048;
    # 提高并发性能
    use epoll;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # 日志格式
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    # 访问日志
    access_log  /var/log/nginx/access.log  main;

    # 性能优化
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    types_hash_max_size 2048;

    # 开启 gzip 压缩
    gzip  on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 1000;
    gzip_types
        text/plain
        text/css
        text/xml
        application/json
        application/javascript
        application/xml
        application/xml+rss
        text/javascript
        image/svg+xml;

    # 安全头配置
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # 上传文件大小限制(如果需要)
    client_max_body_size 10M;

    # Vue 应用服务器配置
    server {
        listen       80;
        server_name  your-domain.com;  # 替换为你的域名

        # 静态文件目录(服务器上的路径)
        root   /var/www/vue3-h5/dist;
        index  index.html index.htm;

        # Vue Router history 模式支持
        location / {
            try_files $uri $uri/ /index.html;
        }

        # 静态资源缓存(js、css、图片等)
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # 禁止访问隐藏文件
        location ~ /\. {
            deny all;
        }

        # 错误页面
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }

    # HTTPS 配置(推荐使用)
    # server {
    #     listen       443 ssl http2;
    #     server_name  your-domain.com;
    #
    #     # SSL 证书配置
    #     ssl_certificate      /etc/nginx/ssl/your-domain.com.pem;
    #     ssl_certificate_key  /etc/nginx/ssl/your-domain.com.key;
    #
    #     # SSL 优化配置
    #     ssl_session_timeout  1d;
    #     ssl_session_cache    shared:SSL:50m;
    #     ssl_session_tickets  off;
    #
    #     # 现代 SSL 配置
    #     ssl_protocols TLSv1.2 TLSv1.3;
    #     ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    #     ssl_prefer_server_ciphers  on;
    #
    #     # HSTS
    #     add_header Strict-Transport-Security "max-age=31536000" always;
    #
    #     root   /var/www/vue3-h5/dist;
    #     index  index.html;
    #
    #     location / {
    #         try_files $uri $uri/ /index.html;
    #     }
    # }

    # HTTP 自动跳转 HTTPS
    # server {
    #     listen 80;
    #     server_name your-domain.com;
    #     return 301 https://$server_name$request_uri;
    # }
}

3.2 反向代理配置(如果需要调用后端 API)

server {
    listen       80;
    server_name  your-domain.com;

    root   /var/www/vue3-h5/dist;
    index  index.html;

    # 前端路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端 API 代理
    location /api/ {
        proxy_pass http://backend-server:8080/;  # 后端服务地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

4. 本地与服务器的配置差异

4.1 差异对比表

配置项 本地开发环境 服务器生产环境 说明
worker_processes 1 auto 生产环境根据 CPU 核心数自动设置
worker_connections 1024 2048+ 服务器并发要求更高
server_name localhost your-domain.com 本地用 localhost,服务器用域名
root 路径 D:/test/vue3-h5/dist /var/www/vue3-h5/dist Windows 和 Linux 路径格式不同
error_log 注释掉 /var/log/nginx/error.log warn 生产环境需要记录日志
access_log 注释掉 /var/log/nginx/access.log main 生产环境需要访问日志
gzip 基础配置 完整配置 生产环境需要更细致的压缩配置
缓存配置 生产环境需要静态资源缓存
安全头 生产环境需要安全防护
HTTPS 推荐 生产环境强烈推荐 HTTPS
反向代理 通常不需要 常需要 生产环境常需要代理后端 API

4.2 路径格式差异

Windows 本地路径

root   D:/test/vue3-h5/dist;    # 使用正斜杠 /
root   D:\\test\\vue3-h5\\dist;  # 或使用双反斜杠转义

Linux 服务器路径

root   /var/www/vue3-h5/dist;   # Linux 标准路径格式

4.3 域名配置差异

本地开发

server_name  localhost;          # 本机访问
# 或
server_name  127.0.0.1;         # 本机 IP

服务器生产

server_name  your-domain.com;    # 你的域名
server_name  www.your-domain.com; # 多个域名
server_name  192.168.1.100;      # 或直接用服务器 IP

4.4 端口配置差异

本地开发(80 端口可能被占用):

listen  8080;   # 如果 80 被占用,可以用其他端口

服务器生产

listen  80;     # HTTP 默认端口
listen  443 ssl http2;  # HTTPS 端口

5. 完整部署流程

5.1 本地部署步骤

# 1. 打包 Vue 项目
cd D:/test/vue3-h5
npm run build
# 打包后会生成 dist 目录

# 2. 修改 Nginx 配置
# 编辑 C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0\conf\nginx.conf
# 设置 root 指向 dist 目录

# 3. 启动 Nginx
cd C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0
start nginx

# 4. 访问测试
# 浏览器打开 http://localhost

5.2 服务器部署步骤

第一步:准备服务器环境
# 以 Ubuntu/Debian 为例
# 1. 更新系统
sudo apt update && sudo apt upgrade -y

# 2. 安装 Nginx
sudo apt install nginx -y

# 3. 检查 Nginx 状态
sudo systemctl status nginx

# 4. 设置开机自启
sudo systemctl enable nginx
第二步:上传打包文件
# 方式一:使用 scp 上传
scp -r D:/test/vue3-h5/dist user@server-ip:/var/www/vue3-h5/

# 方式二:使用 FTP 工具(如 FileZilla)上传

# 方式三:在服务器上直接打包
# 先上传源码,在服务器上运行 npm run build
第三步:配置 Nginx
# 1. 创建配置文件
sudo nano /etc/nginx/sites-available/vue3-h5

# 2. 写入配置内容(参考上面的生产环境配置)

# 3. 创建软链接启用配置
sudo ln -s /etc/nginx/sites-available/vue3-h5 /etc/nginx/sites-enabled/

# 4. 测试配置是否正确
sudo nginx -t

# 5. 重载 Nginx
sudo systemctl reload nginx
第四步:配置域名(如果有)
# 1. 在域名服务商处添加 DNS 解析
#    类型: A
#    主机: @
#    值: 服务器 IP

# 2. 等待 DNS 生效(几分钟到几小时)

# 3. 测试访问
curl -I http://your-domain.com
第五步:配置 HTTPS(推荐)
# 使用 Let's Encrypt 免费证书

# 1. 安装 Certbot
sudo apt install certbot python3-certbot-nginx -y

# 2. 自动配置 HTTPS
sudo certbot --nginx -d your-domain.com -d www.your-domain.com

# 3. 测试自动续期
sudo certbot renew --dry-run

# Certbot 会自动修改 Nginx 配置,添加 SSL 相关配置

5.3 部署检查清单

  • 项目已打包(npm run build)
  • dist 目录已上传到服务器
  • Nginx 已安装并运行
  • Nginx 配置文件已正确设置
  • root 路径指向正确的 dist 目录
  • server_name 已设置正确的域名/IP
  • 防火墙已开放 80/443 端口
  • 域名 DNS 已解析到服务器
  • HTTPS 证书已配置(推荐)
  • 网站可以正常访问

6. 常用命令速查

6.1 Windows 本地命令

# 进入 Nginx 目录
cd C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0

# 启动 Nginx
start nginx

# 停止 Nginx
nginx -s stop          # 快速停止
nginx -s quit          # 优雅停止(处理完当前请求)

# 重载配置(修改配置后)
nginx -s reload

# 重新打开日志文件
nginx -s reopen

# 测试配置文件语法
nginx -t

# 查看 Nginx 版本
nginx -v

# 查看 Nginx 进程
tasklist | findstr nginx

# 强制结束所有 Nginx 进程
taskkill /F /IM nginx.exe

6.2 Linux 服务器命令

# 启动 Nginx
sudo systemctl start nginx

# 停止 Nginx
sudo systemctl stop nginx

# 重启 Nginx
sudo systemctl restart nginx

# 重载配置(不中断服务)
sudo systemctl reload nginx

# 查看 Nginx 状态
sudo systemctl status nginx

# 设置开机自启
sudo systemctl enable nginx

# 取消开机自启
sudo systemctl disable nginx

# 测试配置文件
sudo nginx -t

# 查看 Nginx 版本
nginx -v

# 查看错误日志
sudo tail -f /var/log/nginx/error.log

# 查看访问日志
sudo tail -f /var/log/nginx/access.log

6.3 Vue 项目相关命令

# 开发环境运行
npm run dev

# 生产环境打包
npm run build

# 预览打包结果
npm run preview

7. 常见问题排查

7.1 页面空白

可能原因

  1. 路由模式问题
  2. 静态资源路径问题
  3. 打包配置问题

排查步骤

# 1. 检查 dist 目录是否有 index.html
ls dist/index.html

# 2. 检查浏览器控制台错误
# F12 打开开发者工具,查看 Console 和 Network

# 3. 检查 vite.config.ts 的 base 配置
# 如果部署在子路径,需要设置 base

解决方案

// vite.config.ts
export default defineConfig({
  // 部署在根路径
  base: '/',

  // 如果部署在子路径(如 http://domain.com/app/)
  // base: '/app/',
})

7.2 404 Not Found

可能原因

  1. root 路径配置错误
  2. 静态文件未正确上传

排查步骤

# 1. 检查 root 路径是否正确
ls /var/www/vue3-h5/dist/index.html

# 2. 检查 Nginx 配置
sudo nginx -t

# 3. 检查文件权限
ls -la /var/www/vue3-h5/dist

解决方案

# 修复文件权限
sudo chown -R www-data:www-data /var/www/vue3-h5
sudo chmod -R 755 /var/www/vue3-h5

7.3 刷新页面 404

原因:Vue Router 使用 history 模式,需要 Nginx 配置支持

解决方案:确保 Nginx 配置中有:

location / {
    try_files $uri $uri/ /index.html;
}

7.4 端口被占用

Windows 排查

# 查看端口占用
netstat -ano | findstr :80

# 结束占用进程(PID 是上面查到的进程 ID)
taskkill /PID <进程ID> /F

Linux 排查

# 查看端口占用
sudo lsof -i :80

# 或
sudo netstat -tlnp | grep :80

# 结束占用进程
sudo kill -9 <PID>

7.5 Nginx 配置修改不生效

# 1. 测试配置是否正确
nginx -t

# 2. 重载配置
nginx -s reload      # Windows
sudo systemctl reload nginx  # Linux

# 3. 清除浏览器缓存后刷新页面
# Ctrl + F5 强制刷新

7.6 跨域问题

问题现象:API 请求报 CORS 错误

解决方案一:Nginx 反向代理

location /api/ {
    proxy_pass http://backend-server:8080/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

解决方案二:添加 CORS 头

location /api/ {
    add_header 'Access-Control-Allow-Origin' '*';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
    add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';

    if ($request_method = 'OPTIONS') {
        return 204;
    }

    proxy_pass http://backend-server:8080/;
}

附录:配置文件模板

A. 本地开发配置模板

# 简化版本地配置
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        root   D:/test/vue3-h5/dist;
        index  index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        gzip on;
        gzip_types text/plain text/css application/json application/javascript;
    }
}

B. 服务器生产配置模板

# 生产环境完整配置
worker_processes  auto;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  2048;
    use epoll;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;

    gzip  on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 1000;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    server {
        listen       80;
        server_name  your-domain.com;

        root   /var/www/vue3-h5/dist;
        index  index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        location ~ /\. {
            deny all;
        }

        error_page 500 502 503 504 /50x.html;
        location = /50x.html {
            root /usr/share/nginx/html;
        }
    }
}

总结

环境 关键配置 访问方式
本地 localhost + 本地路径 http://localhost
服务器 域名/IP + 服务器路径 + 优化配置 your-domain.com

部署核心流程:

  1. 本地打包 npm run build
  2. 上传 dist 到服务器
  3. 配置 Nginx 指向 dist 目录
  4. 配置 try_files 支持 Vue Router
  5. 重载 Nginx 配置
  6. 测试访问

如有问题,优先查看 Nginx 错误日志进行排查。

你的 Vue 组件正在偷偷吃掉内存!5 个常见的内存泄漏陷阱与修复方案

上周,我们收到用户反馈:“你们的后台系统,用一天后 Chrome 占了 4GB 内存!”

打开 DevTools 的 Memory 面板,一拍快照——
已分离的 DOM 节点(Detached DOM trees)堆积如山,组件实例成百上千……

问题不在业务逻辑,而在 “你以为组件销毁了,其实它还在”

今天,我就带你揪出 Vue 3 项目中 5 个最隐蔽的内存泄漏陷阱,并给出一行代码就能修复的方案。尤其第 3 个,90% 的人都中过招。


先搞懂:Vue 组件什么时候会“泄漏”?

理想情况下,组件卸载时:

  • 响应式数据自动清理
  • 事件监听器自动移除
  • 定时器/异步任务自动取消

但现实是:如果你手动绑定了外部资源,Vue 不会帮你清理!

记住:Vue 只管理“自己创建的东西”,不管理你“借来的资源”。


陷阱 1:忘记清理全局事件监听器

// 危险!组件卸载后,window.resize 依然触发 oldHandler
onMounted(() => {
  const handleResize = () => { /* ... */ };
  window.addEventListener('resize', handleResize);
});

修复:在 onUnmounted 中移除

onMounted(() => {
  const handleResize = () => { /* ... */ };
  window.addEventListener('resize', handleResize);
  
  onUnmounted(() => {
    window.removeEventListener('resize', handleResize);
  });
});

进阶技巧:封装成 composable

// composables/useEventListener.ts
export function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler));
  onUnmounted(() => target.removeEventListener(event, handler));
}

陷阱 2:未取消的定时器 or 异步请求

// 组件销毁后,setTimeout 仍会执行,可能操作已销毁的 ref
onMounted(() => {
  setTimeout(() => {
    someRef.value = 'updated'; // Ref 已失效,但 JS 仍在跑
  }, 5000);
});

修复:用 AbortController 或 isMounted 标志

onMounted(() => {
  const timer = setTimeout(() => {
    if (!isUnmounted) someRef.value = 'updated';
  }, 5000);

  onUnmounted(() => {
    clearTimeout(timer);
    isUnmounted = true;
  });
});

更优雅:用 AbortSignal(适用于 fetch / WebSocket)

const controller = new AbortController();
fetch('/api/data', { signal: controller.signal });

onUnmounted(() => controller.abort());

陷阱 3:第三方库实例未销毁(最常见!)

比如 ECharts、Monaco Editor、Mapbox……

// 组件卸载了,但 echarts 实例还在内存中持有 DOM 引用
let chart;
onMounted(() => {
  chart = echarts.init(dom);
});

修复:调用库提供的 destroy 方法

onMounted(() => {
  chart = echarts.init(dom);
});

onUnmounted(() => {
  chart?.dispose(); // 关键!
  chart = null;
});

如果库没提供 destroy?用 markRaw + 手动置 null(见下文技巧)


陷阱 4:响应式对象持有外部引用

const state = reactive({
  element: document.getElementById('my-el') // 持有 DOM 引用
});

即使组件卸载,state 若被其他地方引用(如全局缓存),整个 DOM 树都无法 GC

修复:避免将非响应式对象(DOM、第三方实例)放入 reactive/ref

// 用 shallowRef 或普通变量
const element = document.getElementById('my-el'); // 普通变量,无响应式包裹
const chart = shallowRef(null); // 内部不递归响应式

原则:只有需要“驱动视图更新”的数据,才放进响应式系统。


陷阱 5:闭包导致的隐式引用

onMounted(() => {
  const largeData = new Array(100000).fill('data');
  
  const callback = () => {
    console.log(largeData.length); // 闭包持有 largeData
  };

  someGlobalEmitter.on('event', callback);
  
  // 忘记在 onUnmounted 中 off!
});

即使组件卸载,callback 仍被全局 emitter 持有 → largeData 无法释放。

修复:确保移除所有外部注册

onUnmounted(() => {
  someGlobalEmitter.off('event', callback);
});

自查清单:上线前必做 3 件事

  1. 打开 Chrome DevTools → Memory → 拍快照

    • 切换路由多次,看组件实例是否持续增长
    • 搜索 “Detached” 查看游离 DOM
  2. 审查所有 onMounted

    • 是否有 addEventListener / setInterval / 第三方 init?
    • 是否都有对应的 onUnmounted 清理?
  3. 避免在 reactive 中存非 UI 状态

    • 图表实例、WebSocket、大型配置 → 用 shallowRef 或普通变量

最后说两句

内存泄漏不像报错那样“大声提醒你”,
它像温水煮青蛙——等你发现时,用户已经流失了

但只要记住一句话:

“你借的资源,你负责还。”

Vue 会管好自己的事,剩下的,靠你。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再忽略 Promise 拒绝了!你的 Node.js 服务正在“静默自杀”

它不报错、不报警、不重启——直到凌晨三点用户投诉全线崩溃。

你是否写过这样的代码?

app.post('/api/notify', (req, res) => {
  sendEmail(req.body.email); // 忘记 await,也没 catch
  res.status(200).send('OK');
});

async function sendEmail(email) {
  await smtpClient.send({ to: email, subject: 'Welcome!' });
}

看起来一切正常?
但只要 smtpClient.send() 抛出异常(比如网络超时、邮箱无效),一个未处理的 Promise 拒绝(Unhandled Rejection)就诞生了

而在 Node.js 中,这颗“定时炸弹”可能直接导致进程退出——悄无声息,不留痕迹。


为什么 Unhandled Rejection 如此危险?

从 Node.js v15 开始,官方默认行为已改为:

任何未处理的 Promise 拒绝都会导致进程直接退出!

是的,你没看错——不是警告,不是日志,是直接 kill 掉整个服务

即使你用 PM2、Docker 或 Kubernetes 托管,服务也会不断重启 → 崩溃 → 再重启,形成“死亡循环”。

更可怕的是:

  • 错误可能发生在非主流程(如埋点、日志上报、异步通知);
  • 用户请求已返回成功(res.send 已调用),你以为“没问题”;
  • 实际后台任务失败,且无人知晓,直到数据丢失、订单漏发……

真实案例:一封邮件毁掉整站

某电商平台在用户下单后异步发送通知:

orderService.create(order);
sendNotification(order.userId); // 忘记处理异常

某天第三方通知服务宕机,sendNotification 抛出错误。
由于未捕获,Node.js 进程退出。
K8s 自动重启 Pod,但新请求进来又触发同样逻辑 → 全站每分钟崩溃一次
运维查了两小时日志才发现:根本没有 error 日志!只有进程退出记录

根源?一个被忽略的 await


三大常见“漏网之鱼”

场景一:忘记 await 且不 catch

// 危险!fire-and-forget 但未处理拒绝
fireAndForgetTask();

// 正确做法:至少 catch
fireAndForgetTask().catch(err => logger.warn('Task failed', err));

场景二:在 Promise.all 中部分失败

// 只要一个 reject,整个 Promise.all 就 reject
// 如果外层没 catch,就是 unhandled rejection!
await Promise.all([
  fetchA(),
  fetchB(), // 假设这个失败了
  fetchC()
]);

解决方案:用 Promise.allSettled 或单独 catch 每个任务。

场景三:在事件监听器或定时器中抛出异步错误

emitter.on('data', async (d) => {
  await process(d); // 如果 process 抛错,没人 catch!
});

这类错误完全脱离主调用栈,极易遗漏。


防御策略:四重保险,杜绝静默崩溃

第一重:全局监听(兜底)

在应用入口添加:

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // 发送告警(如 Sentry、企业微信)
  // 注意:不要在这里 exit!先记录,再优雅关闭
});

// 同样建议监听 uncaughtException(同步错误)
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
});

全局监听只是“最后防线”,不能替代代码层面的错误处理


第二重:严格使用 await + try/catch

app.post('/api/notify', async (req, res) => {
  try {
    await sendEmail(req.body.email);
    res.send('OK');
  } catch (err) {
    logger.error('Send email failed', err);
    res.status(500).send('Failed');
  }
});

第三重:对“fire-and-forget”任务显式处理

如果确实不需要等待结果(如打点、日志),也要 .catch

// 明确表示“我知道可能失败,但我选择忽略”
sendAnalytics(event).catch(err => {
  // 至少记录,避免 unhandled rejection
  logger.debug('Analytics failed (ignored)', err);
});

第四重:ESLint + TypeScript 防呆

配置 ESLint 规则:

{
  "rules": {
    "require-await": "error",
    "no-void": "warn"
  }
}

或者用 TypeScript 的 Promise<void> 显式标注,配合 lint 工具提醒未处理的 Promise。


终极心法:所有异步操作,必须有“归宿”

无论是:

  • API 调用
  • 数据库写入
  • 消息队列投递
  • 文件读写

只要它返回 Promise,你就必须回答一个问题:

“如果它失败了,谁来负责?”

如果没有答案,那就是隐患。


结语

Node.js 的优雅在于异步非阻塞,
但它的脆弱也藏在每一个被忽略的 reject 里。

别让一个小小的 await 缺失,
毁掉你精心构建的高可用服务。

从今天起,没有“无所谓”的异步调用,只有“已处理”和“待修复”

转发给你团队里那个总说“异步不用 catch”的人吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端经典面试题:从 URL 输入到页面展示,中间经历了什么?

从 URL 输入到页面展示的完整流程

先来看一下下面两张整体流程示意图:

B7C4BAB1-BEE7-4916-9A43-928C4EC1C4EF.png

QQ20260308-170528.png

该流程是前端春招核心考题(考察覆盖率 80%),横跨前端渲染、计算机网络、操作系统(进程通信) 三大领域,核心是浏览器多进程协同完成 “导航”(用户输入 URL 到页面开始解析的全过程),以下按阶段拆解所有细节:

一、前置概念基础:浏览器多进程架构(操作系统层面)

浏览器采用多进程架构,整个流程依赖不同进程的 IPC(Inter-Process Communication,进程间通信)协作,先明确核心进程的职责:

进程类型 核心职责(全流程关键动作)
浏览器主进程 1. 接收用户 URL 输入、处理交互反馈(如输入框响应);2. 管理浏览历史(新 URL 入栈);3. 控制 loading 状态(请求开始显示、完成隐藏);4. 管理子进程(网络进程 / 渲染进程),通过 IPC 通信;5. 触发页面卸载事件(beforeunload)、更新页面状态;6. 管理缓存、Cookie、localStorage 等文件存储
网络进程 1. 为渲染进程 / 主进程提供网络下载能力;2. 处理 HTTP 请求 / 响应的封装与解析;3. 与渲染进程建立 “数据管道” 传输 HTML / 静态资源;4. 处理 DNS 解析、TCP 握手等网络层逻辑
渲染进程 1. 接收网络进程传输的页面数据;2. 解析 HTML/CSS、构建 DOM/CSSOM/ 渲染树;3. 向主进程 “确认提交”,表示准备好接收数据;4. 负责页面最终渲染(本流程截止到 “准备解析数据”,渲染细节为后续环节)

补充概念

  • 进程(Process):操作系统分配资源的最小单位(如内存、CPU);
  • 线程(Thread):操作系统执行指令的最小单位(一个进程可包含多个线程)。

二、阶段 1:URL 输入与预处理(用户交互→标准化 URL)

当用户在浏览器地址栏输入内容并回车,主进程首先完成 URL 预处理:

1. URL 标准化补全

结合文档中的实际示例,URL 补全逻辑如下:

  • 自动补充协议 / 域名前缀:如输入time.geekbang.org → 补全为https://time.geekbang.org(https 为浏览器默认安全协议);输入www.baidu.com → 补全为https://www.baidu.com
  • 补全默认端口:https 默认 443、http 默认 80(如https://www.baidu.com → 实际访问https://www.baidu.com:443
  • 关键词识别:若输入非 URL(如 “前端面试”),自动拼接至默认搜索引擎 URL 后(如https://www.baidu.com/s?wd=前端面试,文档中https://www.baidu.com/s?wd=即为搜索引擎的查询格式)。

2. 重定向预处理(提前拦截跳转逻辑)

若输入的原始 URL 需要跳转(如文档中的http://time.geekbang.org),会触发服务器重定向:

  • 触发条件:服务器返回 301/302/307 状态码 + Location响应头;

  • 重定向类型细节:

    • 301(永久重定向):浏览器会缓存跳转关系,后续直接访问新 URL;
    • 302(临时重定向):不缓存,每次访问都需服务器返回跳转指令;
    • 307(临时重定向):不允许修改请求方法(如 POST 请求跳转后仍为 POST,302 可能改为 GET);
  • 浏览器强制优化:即使服务器未返回重定向,部分浏览器会强制将 http 升级为 https(如http://time.geekbang.org → 直接跳转https://time.geekbang.org,两者会返回的内容一致也验证了这一优化)。

三、阶段 2:DNS 域名解析(域名→IP,分布式数据库查询)

网络通信依赖 IP 地址(如127.0.0.1),但用户输入的是域名(如www.baidu.comtime.geekbang.org),需通过 DNS(分布式数据库)完成 “域名→IP” 映射,解析层级从本地到全球逐步降级:

1. DNS 解析全流程(优先级从高到低)

表格

解析层级 细节说明
本浏览器 DNS 缓存 Chrome 可通过chrome://net-internals/#dns查看缓存的 IP 数组;缓存有过期时间,不同浏览器独立维护
本地操作系统 DNS 缓存 多浏览器共享(如 Chrome/Firefox 共用 Windows/macOS 的 DNS 缓存),由操作系统内核维护
Hosts 文件 - 路径(Windows):C:\Windows\System32\drivers\etc\hosts(需管理员权限编辑);- 用途:本地开发测试(如映射127.0.0.1 → douyin.com,模拟带域名访问本地代码);- 特殊规则:localhost/0.0.0.0等域名无需解析,默认指向127.0.0.1
局域网 DNS 缓存 路由器 / 局域网内其他设备访问过的域名记录(如公司内网缓存常用域名)
运营商 DNS 服务器 电信 / 移动 / 联通的城市级节点(缓存全网高频域名,如文档中的www.baidu.comtime.geekbang.org
全球 DNS 层级 根服务器(全球 13 台)→ 顶级域服务器(如.com/.cn 服务器)→ 权威服务器(域名所属商服务器)

2. DNS 扩展细节(面试高频)

  • 分布式集群:DNS 返回的 IP 并非直接指向业务服务器,而是 Nginx 等反向代理服务器的 IP;
  • 负载均衡:反向代理通过 “轮询(Round Robin)” 将请求分配给后端多台服务器,动态适配服务器负载;
  • 地域优化:DNS 根据用户 IP 归属地,优先返回就近机房的 IP(用户当前位置为中国上海,访问www.baidu.comtime.geekbang.org时,DNS 会优先返回离上海近的机房 IP),降低网络延迟。

3.DNS 相关知识补充

  • Chrome 可通过 chrome://net-internals/#dns 查看 DNS 缓存中记录的 IP 地址列表。若缓存中存在对应域名的有效记录,浏览器在解析该域名时,会优先使用缓存中的 IP 地址,而无需发起新的 DNS 查询。

image.png

  • Hosts 文件用途:本地开发测试(如映射 127.0.0.1 → douyin.com,模拟带域名访问本地代码)。hosts 文件的本质是一个本地的 “域名→IP” 映射表,它的优先级比 DNS 服务器更高。当你在浏览器中输入一个域名时,系统会先检查 hosts 文件中是否有对应的 IP 映射。如果有,就直接使用这个 IP,而不会去请求 DNS 服务器。

image.png

四、阶段 3:TCP 三次握手(建立可靠传输连接)

HTTP/HTTPS 基于 TCP 协议(可靠传输),传输数据前需通过 “三次握手” 确认双方收发能力,核心是交换SYN(同步序号)和ACK(确认序号):

握手阶段 通信方向 核心报文(简化版) 核心目的
第一次 客户端→服务端 发送SYN x(x 为随机初始序号) 客户端向服务端 “请求建立连接”,告知自己的发送起始序号
第二次 服务端→客户端 发送ACK x+1 + SYN y(k 为服务端随机序号) 1. ACK x+1:确认接收客户端的 SYN 请求;2. SYN y:向客户端确认自己的发送能力
第三次 客户端→服务端 发送ACK y+1 客户端确认接收服务端的 SYN 请求,双方确认 “收发能力均正常”,连接建立

48ca501712822a88fa93e67cbf982da9.png

关键补充

  • 为何是 “三次” 而非两次:两次握手仅能确认 “客户端→服务端” 的单向能力,三次才能确认双向收发能力

  • HTTPS 额外步骤:需在 TCP 握手后完成 TLS 握手(验证证书、协商加密算法),比 HTTP 多一层安全校验。HTTPS 的本质:“HTTP 套上 TLS 安全壳”,

    • 无 TLS 时(HTTP) :TCP 连接建立后,HTTP 数据以明文直接传输,任何人截取网络数据包都能看到内容(如账号密码、请求参数),且数据可能被篡改。
    • 有 TLS 时(HTTPS) :TCP 三次握手建立连接后,先通过 TLS 握手建立加密通道,再把 HTTP 数据(请求行、请求头、响应体等)传入这个通道,数据会被加密后传输,截取后无法直接解读,且能检测是否被篡改。
  • TCB本质上是操作系统内核为每一条 TCP 连接单独维护的一个 “专属档案”,用来记录这条连接的所有关键状态和上下文信息。它会记录连接的所有关键信息,主要包括:

    • 连接状态:CLOSEDLISTENSYN-SENTSYN-RCVDESTABLISHED 等(就是你图里看到的那些状态)。
    • 序号信息:当前的发送序号(seq)、确认序号(ack)、窗口大小等,用于保证数据可靠传输。
    • 缓冲区指针:指向发送和接收缓冲区的地址,用于数据的收发。
    • 定时器:重传定时器、保活定时器等,用于超时重传和连接保活。
    • 对端信息:对端的 IP 地址、端口号等。

五、阶段 4:HTTP 请求与响应传输(应用层数据交互)

TCP 连接建立后,网络进程开始封装 HTTP 请求、与服务器交互:

1. 发送 HTTP 请求(客户端→服务端)

请求由 “请求行 + 请求头 + 请求体(可选)” 组成:

  • 请求行:核心信息,格式为请求方法 路径 HTTP版本,示例:

    • 访问https://time.geekbang.orgGET / HTTP/1.1
    • 访问https://www.baidu.com/s?wd=前端面试GET /s?wd=前端面试 HTTP/1.1
    • 常见请求方法:GET(查询数据,如文档中所有网页的访问均使用 GET)、POST(提交数据)、HEAD(仅获取响应头);
  • 请求头:携带业务 / 认证信息,高频字段:

    • Authorization:JWT Token/OAuth2.0 等认证信息;
    • Cookie:浏览器存储的用户标识(由服务端Set-Cookie响应头设置);
    • User-Agent:浏览器 / 设备信息(如Chrome/114.0.0.0 Windows NT 10.0);
  • 请求体:仅 POST/PUT 等方法使用,存储提交的数据(如 JSON、表单参数)。

2. 接收 HTTP 响应(服务端→客户端)

响应由 “状态行 + 响应头 + 响应体” 组成:

  • 状态行:HTTP版本 状态码 状态描述,示例:HTTP/1.1 200 OK(返回 200 状态码,表示请求成功);

    • 核心状态码:

      • 200:请求成功,响应体返回页面 / 资源数据(https://time.geekbang.org返回极客时间页面内容,https://www.baidu.com返回百度热搜页面);
      • 301/302/307:重定向,需重新请求Location头的 URL;
      • 404:资源不存在;500:服务端内部错误;
  • 响应头:控制浏览器行为,高频字段:

    • Content-Type:标识响应体类型(核心!):

      • text/html:HTML 文档,网络进程将数据传给渲染进程解析;
      • text/css/image/jpeg/application/javascript:静态资源,浏览器直接缓存;
      • application/json:接口数据,交给 JS 处理;
    • Location:重定向目标 URL;

    • Cache-Control:控制资源缓存策略(如max-age=3600表示缓存 1 小时);

  • 响应体:实际数据(https://time.geekbang.org返回的极客时间页面源码、https://www.baidu.com返回的百度热搜页面源码)。

六、阶段 5:导航提交与页面接收(进程协作)

HTTP 响应返回后,浏览器主进程、网络进程、渲染进程协同完成 “导航提交”:

  1. 建立数据管道:浏览器主进程通知网络进程,与渲染进程建立数据管道(直接传输 HTML 数据,无需中转);

  2. 渲染进程确认提交:渲染进程接收数据后,向主进程发送 “确认提交” 消息,表示已准备好解析页面;

  3. 页面状态更新:主进程接收到 “确认提交” 后,执行 3 个关键动作:

    • 移除当前标签页的旧文档(如之前打开的百度页面);
    • 更新浏览器的页面状态(URL、标题、历史记录,如访问https://time.geekbang.org后,URL 栏显示该地址,标题更新为 “极客时间”);
    • 显示 loading 状态(直到渲染进程完成首次渲染)。

核心定义

用户从输入 URL 回车,到渲染进程 “确认提交” 准备解析页面的全过程,称为导航(这是面试回答的核心边界)。

七、底层支撑:OSI 七层协议与传输优化

整个流程依赖网络协议栈,核心是 OSI 七层协议(实际常用 TCP/IP 五层模型),以下拆解关键层:

OSI 层级 核心职责 关键细节
物理层 传输 0/1 二进制数据(物理介质:网线、光纤、无线) 无逻辑处理,仅负责 “传输信号”
数据链路层 封装数据为 “帧”,携带 MAC 地址(设备唯一标识) MAC 地址由网卡厂商分配,用于局域网内设备通信
网络层 封装数据为 “数据包”,携带 IP 地址 IP 地址负责跨网络定位主机;可能丢包、出错,依赖传输层修复
传输层 封装数据为 “段 / 报”,标识端口号(对应应用程序) 核心协议:TCP/UDP;端口号范围 0-65535(80/443 为 HTTP/HTTPS 默认端口)
应用层(/表示层/会话层) 定义应用间通信规则(HTTP/HTTPS/DNS) 基于传输层实现业务逻辑,如 HTTP 的请求 / 响应格式

1. UDP 协议(用户数据报协议)

  • 特性:简单、快速、无可靠性保证(无重传、无排序);
  • 适用场景:音视频直播 / 通话(允许少量丢包,优先保证实时性);
  • 核心问题:数据包可能丢失、乱序到达,无法传输 HTML/CSS 等 “要求完整” 的 Web 资源(网页使用 TCP 协议传输)。

2. TCP 协议(传输控制协议)

  • 特性:可靠、有序、速度略慢(有重传、排序机制);

  • 适用场景:浏览器请求、邮件、文件下载(要求数据完整,文档中所有网页的访问均基于 TCP 协议);

  • 核心解决的问题:

    • 丢包重传:为数据包设置 “过期时间”,超时未接收则重传;
    • 乱序重排:为每个数据包分配 “序号”,接收端按序号组装,解决乱序问题;
  • **TCP 完整生命周期:三次握手(建连)→ 数据传输 → 四次挥手(关连)**四次挥手是 TCP 关闭可靠连接的标准流程,和三次握手成对出现,关于它在「从 URL 输入到页面展示」流程中的定位:

    • 它不属于页面首屏渲染的前置核心步骤(页面展示不依赖连接关闭);
    • 它属于 TCP 连接完整生命周期的必要收尾,是整个页面加载全流程的一部分。

    (1)四次挥手的触发时机(与 HTTP 版本强相关)

    HTTP 版本 连接策略 触发四次挥手的场景
    HTTP/1.0 默认短连接 每传输完 1 个资源(如 HTML、单张图片)后,立即触发四次挥手
    HTTP/1.1 默认长连接(Connection: keep-alive ① 页面所有核心资源传输完成后,连接空闲超过超时时间(浏览器默认约 60s);② 页面关闭 / 刷新 / 标签页销毁时;③ 服务器主动关闭(如单连接最大请求数超限)
    HTTP/2/3 多路复用长连接 仅在页面关闭、标签页销毁、浏览器关闭或连接长时间空闲时触发

    (2)四次挥手的完整详细过程(客户端主动发起关闭为例)

    TCP 是全双工通信,客户端和服务端的发送 / 接收通道独立,关闭时需双向确认 “不再发送数据”,因此需要四次挥手:

    挥手阶段 通信方向 核心报文 核心含义
    第一次 客户端→服务端 发送FIN M 客户端告知服务端:我已无数据发送,请求关闭「客户端→服务端」的发送通道
    第二次 服务端→客户端 发送ACK M+1 服务端告知客户端:我已收到关闭请求,先确认;但我可能还有数据没传完,你继续等待接收
    第三次 服务端→客户端 发送FIN N 服务端告知客户端:我也无数据发送了,请求关闭「服务端→客户端」的发送通道
    第四次 客户端→服务端 发送ACK N+1 客户端告知服务端:我已收到关闭请求,双向通道均确认关闭,连接可彻底释放

image.png

**(3)面试高频补充细节**
  • 为什么挥手是四次,握手是三次?

三次握手时,服务端的「ACK 确认客户端能力」和「SYN 告知自身能力」可合并成一次报文;但四次挥手时,服务端收到客户端的 FIN 后,大概率还有未传输完的数据(如文档中https://time.geekbang.org的页面资源可能分多个数据包传输),不能立即回 FIN 关闭自身通道,只能先回 ACK 确认,等自身数据传完后再单独发 FIN,因此必须拆分成两次,总共四次。

  • TIME_WAIT 状态(必考点)

客户端第四次挥手发送 ACK 后,不会立即关闭连接,会进入TIME_WAIT状态,等待2MSL(最长报文寿命,通常 2 分钟) 后才彻底释放连接。核心目的:防止最后一个 ACK 报文丢包,若服务端没收到 ACK,会重发 FIN 报文,客户端需在 TIME_WAIT 状态内处理重传请求,避免新连接收到旧连接的残留报文。

3. 数据包传输优化

  • 大数据拆分:大文件(如 100MB 的视频)拆分为多个小数据包(MTU 限制,通常 1500 字节 / 包),分批次、多通道并发传输;
  • 多路复用:单个 TCP 连接内同时传输多个请求 / 响应(HTTP/2 核心特性),提升带宽利用率;
  • 负载均衡:反向代理服务器(如 Nginx)接收请求后,通过轮询 / 权重分配至后端多台服务器,避免单服务器过载。

八、前端性能与浏览器优化(面试加分项)

1. 核心性能指标

  • FP(First Paint,首次渲染时间):从页面加载到首次绘制像素的时长,计算公式: FP = TTFB + 响应下载时间 + HTML DOM构建时间 + CSSOM构建时间 + 渲染树构建时间 + 布局树构建时间 + 首次渲染

  • TTFB(Time To First Byte,首字节时间):从请求发送到接收第一个响应字节的时长,包含:DNS解析时间 + TCP/TLS握手时间 + 服务器执行时间(如数据库慢查询)

  • 性能影响:FP/TTFB 直接影响用户留存、付费转化、PV(页面访问量)、UV(独立访客数)。

2. 浏览器缓存优化

  • 缓存类型:静态资源(CSS / 图片 / JS)优先缓存,无需重复请求;
  • 缓存逻辑:浏览器根据响应头Cache-Control/Expires判断是否读取本地缓存,缓存命中则跳过 DNS/TCP/HTTP 流程,直接渲染。

3. 页面卸载事件(beforeunload/pagehide)

当用户关闭标签页 / 刷新页面时,浏览器触发卸载相关事件(主进程管控),核心代码示例:

javascript

// 监听beforeunload:提示用户是否离开
window.addEventListener('beforeunload', function (event) {
    console.log('beforeunload 事件已触发');
    event.preventDefault(); // 阻止默认行为(浏览器强制显示默认提示文案)
    event.returnValue = ''; // 兼容各浏览器的提示信息设置
});

// 监听pagehide:处理bfcache场景(浏览器后退/前进缓存)
window.addEventListener('pagehide', function (e) {
    if (e.persisted) {
        console.log('⚠️ 页面进入bfcache(未触发beforeunload),属于浏览器优化');
    } else {
        console.log('✅ 页面正常卸载流程');
    }
});
  • 关键补充:bfcache(后退 / 前进缓存)是浏览器优化,会缓存页面状态,导致beforeunload不触发,需通过pagehide监听e.persisted判断。

总结(面试回答逻辑)

回答该问题时,需按 “进程协作→URL 预处理→DNS 解析→TCP 握手→HTTP 交互→导航提交→协议支撑→性能优化” 的逻辑组织,核心是体现 “多进程协同” 和 “网络协议栈” 两大主线,而非零散罗列知识点。

核心逻辑链:用户输入URL(主进程)→ URL标准化(主进程)→ DNS解析(网络进程)→ TCP握手(网络进程)→ HTTP请求/响应(网络进程)→ 数据管道传输(网络+渲染进程)→ 导航提交(主+渲染进程)→ 准备渲染(渲染进程)→ 数据传输完成后TCP四次挥手(网络进程)

纯函数、柯里化与函数组合:从原理到源码,构建更可维护的前端代码体系

为什么要关注纯函数和柯里化?

在日常开发中,你是否遇到过这些问题:

  • 修改一个函数后,其他看似无关的模块出现了 bug
  • 相同的输入有时返回不同的结果,导致测试用例不稳定
  • 代码复用困难,类似的逻辑在多处重复编写
  • 阅读 React、Redux、Vue3 源码时,对某些设计模式感到困惑

这些问题的根源往往在于:缺乏对函数式编程核心概念的理解。纯函数和柯里化作为函数式编程的两大基石,不仅能帮助我们写出更稳定、可测试的代码,更是理解现代前端框架设计思想的关键。

本文收益:

  • 掌握纯函数的定义与实践,避免副作用带来的隐患
  • 理解柯里化的本质,学会用单一职责原则优化代码结构
  • 从 Vue3、Redux 源码中看到这些思想的实际应用
  • 获得可直接落地的编码实践和团队推广建议

一、纯函数:稳定性的基石

1.1 什么是纯函数

JavaScript 符合函数式编程范式,纯函数是其中最重要的概念之一。在 React 开发中,组件被要求像纯函数一样工作;在 Redux 中,reducer 必须是纯函数。理解纯函数,是掌握现代前端框架的必经之路。

下图展示了 Redux 官方文档对数据不可变性的强调:

图 1:React 中的数据不可变性

根据维基百科定义,纯函数需要满足三个条件:

  1. 确定性输出:相同的输入必然产生相同的输出
  2. 无外部依赖:输出只依赖于输入参数,不依赖外部状态或 I/O 设备
  3. 无副作用:不触发事件、不修改外部状态、不改变输入参数

简单总结:

  • 确定的输入 → 确定的输出(可预测性)
  • 执行过程中不产生副作用(隔离性)

"纯"字表达的是"纯粹"的含义,即函数只做一件事:根据输入计算输出,不做任何额外操作。

1.2 副作用:bug 的温床

什么是副作用?

副作用(Side Effect)源自医学概念,指药物在治疗疾病之外产生的额外影响。在计算机科学中,副作用指函数执行时,除了返回值之外对外部环境产生的影响,例如:

  • 修改全局变量
  • 修改传入的参数对象
  • 发起网络请求
  • 操作 DOM
  • 写入文件或数据库
  • 打印日志(严格来说也是副作用,但通常可接受)

为什么副作用是问题?

副作用会破坏代码的可预测性和可测试性。当函数依赖或修改外部状态时:

  • 相同输入可能产生不同输出
  • 函数行为难以追踪和调试
  • 并发执行时可能产生竞态条件
  • 单元测试需要复杂的 mock 和环境准备

在编程中,我们提倡"数据的不可变性"(Immutability):尽量不修改原有数据,而是创建新数据。这是避免副作用的重要实践。

1.3 纯函数实战案例

让我们通过数组操作来理解纯函数:

案例 1:slice vs splice

const names = ["小吴", "why", "JS高级"];

// slice 是纯函数
// 1. 相同输入产生相同输出
// 2. 不修改原数组
const newNames1 = names.slice(0, 2);
console.log("newNames1:", newNames1); // ["小吴", "why"]
console.log("names:", names);          // ["小吴", "why", "JS高级"] - 原数组未变

// splice 不是纯函数
// 会修改原数组,产生副作用
const newNames2 = names.splice(2);
console.log("newNames2:", newNames2); // ["JS高级"]
console.log("names:", names);          // ["小吴", "why"] - 原数组被修改!

案例 2:对象操作

// ❌ 非纯函数:直接修改传入的对象
function baz(info) {
  info.age = 100; // 副作用:修改了外部对象
}

const obj = { name: "小吴", age: 23 };
baz(obj);
console.log(obj); // { name: "小吴", age: 100 } - 原对象被修改

// ✅ 纯函数:返回新对象,不修改原对象
function test(info) {
  return {
    ...info,
    age: 100
  };
}

const obj2 = { name: "小吴", age: 23 };
const newObj = test(obj2);
console.log(obj2);   // { name: "小吴", age: 23 } - 原对象未变
console.log(newObj); // { name: "小吴", age: 100 } - 新对象

案例 3:React 组件

// React 函数组件应该像纯函数一样
// ✅ 正确:不修改 props
function HelloWorld(props) {
  // 只读取 props,不修改
  return <div>{props.message}</div>;
}

// ❌ 错误:修改 props
function BadComponent(props) {
  props.count++; // 违反纯函数原则!
  return <div>{props.count}</div>;
}

1.4 纯函数的优势

为什么纯函数在函数式编程中如此重要?

  1. 编写时更专注

    • 只需实现业务逻辑,不用担心外部状态
    • 不需要关心参数来源或依赖的外部变量
  2. 使用时更安心

    • 确定输入不会被篡改
    • 确定的输入必然产生确定的输出
    • 可以安全地并发执行
  3. 测试更简单

    • 不需要复杂的 mock 和环境准备
    • 测试用例稳定可靠
  4. 易于调试和重构

    • 函数行为可预测,问题容易定位
    • 可以安全地替换或组合函数

React 官方文档明确要求:无论是函数组件还是 class 组件,都必须像纯函数一样保护 props 不被修改。

图 2:React 的严格规则

本节小结

  • 纯函数三要素:确定性输出、无外部依赖、无副作用
  • 副作用是 bug 的温床:修改外部状态会破坏可预测性
  • 数据不可变性:优先创建新数据而非修改原数据
  • 实践原则:使用 slicemapfilter 等不修改原数组的方法
  • 框架要求:React/Redux 等框架强制要求纯函数思想

二、柯里化:单一职责的艺术

2.1 柯里化的本质

柯里化(Currying)是函数式编程的另一个核心概念。它的名字来源于数学家 Haskell Curry。

维基百科定义:

  • 把接收多个参数的函数,转换成接受单一参数的函数
  • 返回接受余下参数的新函数
  • 最终返回结果

简单理解: 只传递给函数一部分参数来调用它,让它返回另一个函数处理剩余参数。

对比示例:

// 普通函数:一次性传入所有参数
function foo(m, n, x, y) {
  return m + n + x + y;
}
foo(10, 20, 30, 40); // 100

// 柯里化函数:分步传入参数
function bar(m) {
  return function(n) {
    return function(x, y) {
      return m + n + x + y;
    };
  };
}
bar(10)(20)(30, 40); // 100

这就像调节风扇档位:复杂需求可以分档次调节,每个档位的调用都基于前一档位,档位之间紧密关联且有明确顺序。

2.2 柯里化的结构演进

2.2.1 基础多参数函数

function add(x, y, z) {
  return x + y + z;
}

const result = add(10, 20, 30);
console.log(result); // 60

2.2.2 柯里化改造

// 通过闭包实现参数保存
function sum(x) {
  return function(y) {
    return function(z) {
      return x + y + z;
    };
  };
}

const result1 = sum(10)(20)(30);
console.log(result1); // 60

关键点:

  • 每个函数接收一个参数并返回新函数
  • 通过闭包访问上层函数的参数
  • 最内层函数执行最终计算

2.2.3 箭头函数简化

// 方式 1:保留 return 关键字
const sum2 = x => y => z => {
  return x + y + z;
};

// 方式 2:隐式返回(推荐)
const sum3 = x => y => z => x + y + z;

const result2 = sum3(20)(30)(40);
console.log(result2); // 90

箭头函数的链式写法大幅简化了柯里化代码,这也是现代 JavaScript 中常见的写法。

2.3 柯里化的核心价值

2.3.1 单一职责原则(SRP)

为什么需要柯里化?

在函数式编程中,我们希望:

  • 一个函数处理的问题尽可能单一
  • 不要将一大堆处理过程交给一个函数
  • 每次传入的参数在单一函数中处理
  • 处理完后在下一个函数中使用处理结果

这体现了单一职责原则(Single Responsibility Principle):一个类(或函数)应该只有一个引起它变化的原因。

对比示例:

// ❌ 所有逻辑挤在一起
function add(x, y, z) {
  x = x + 2;
  y = y * 2;
  z = z * z;
  return x + y + z;
}
console.log(add(10, 20, 30)); // 972

// ✅ 柯里化:每层处理一个职责
function sum(x) {
  x = x + 2;  // 第一层:处理 x
  return function(y) {
    y = y * 2;  // 第二层:处理 y
    return function(z) {
      z = z * z;  // 第三层:处理 z
      return x + y + z;
    };
  };
}
console.log(sum(10)(20)(30)); // 972

注意边界:

  • 单一职责不是越细越好,过度拆分会增加复杂度
  • 职责的"粒度"需要根据实际项目判断
  • 通常 2-3 层嵌套是最常见的情况

2.3.2 逻辑复用

柯里化的另一个重要优势是复用重复的参数,这和 bind 函数的思想类似。

案例 1:固定第一个参数

function foo(m, n) {
  return m + n;
}

// 传统方式:重复传入相同的第一个参数
console.log(foo(5, 1)); // 6
console.log(foo(5, 2)); // 7
console.log(foo(5, 3)); // 8
console.log(foo(5, 4)); // 9
console.log(foo(5, 5)); // 10

// ✅ 柯里化:复用第一个参数
function makeAdder(count) {
  return function(num) {
    return count + num;
  };
}

const adder5 = makeAdder(5);
console.log(adder5(1)); // 6
console.log(adder5(2)); // 7
console.log(adder5(3)); // 8
console.log(adder5(4)); // 9
console.log(adder5(5)); // 10

案例 2:日志函数优化

// ❌ 传统方式:重复传入时间和类型
function log(date, type, message) {
  console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
}

log(new Date(), "DEBUG", "查找到轮播图的bug");
log(new Date(), "DEBUG", "查询菜单的bug");
log(new Date(), "DEBUG", "查询数据的bug");

// ✅ 柯里化优化:复用时间和类型
const logCurried = date => type => message => {
  console.log(`[${date.getHours()}:${date.getMinutes()}][${type}]:[${message}]`);
};

// 复用时间
const nowLog = logCurried(new Date());
nowLog("DEBUG")("查找小吴去哪了");

// 复用时间 + 类型
const debugLog = logCurried(new Date())("DEBUG");
debugLog("查找信息1");
debugLog("查找信息2");
debugLog("查找信息3");

优势总结:

  • 减少重复代码
  • 提高函数灵活性
  • 便于创建专用工具函数

2.4 通用柯里化函数实现

2.4.1 实现思路

如何将普通函数自动转换为柯里化函数?

需求分析:

  1. 传入一个普通函数,返回柯里化版本
  2. 需要知道函数的参数个数(通过 fn.length 获取)
  3. 支持多种调用方式:fn(1,2,3)fn(1,2)(3)fn(1)(2)(3)
// 获取函数参数个数
function foo(x, y, z, q) {
  console.log(foo.length); // 4
}

2.4.2 完整实现

function hyCurrying(fn) {
  // 返回柯里化函数
  function curried(...args) {
    // 1. 参数足够时,直接执行原函数
    if (args.length >= fn.length) {
      // 使用 apply 绑定 this,避免指向问题
      return fn.apply(this, args);
    } else {
      // 2. 参数不足时,返回新函数继续收集参数
      function curried2(...args2) {
        // 递归调用 curried,拼接参数
        return curried.apply(this, args.concat(args2));
      }
      return curried2;
    }
  }
  return curried;
}

// 测试
function add1(x, y, z) {
  return x + y + z;
}

const curryAdd = hyCurrying(add1);
console.log(curryAdd(10, 20, 30));    // 60
console.log(curryAdd(10, 20)(30));    // 60
console.log(curryAdd(10)(20)(30));    // 60

实现要点:

  • fn.length:获取原函数的形参数量(上限)
  • ...args:收集用户传入的实参(不固定)
  • 参数足够时调用原函数,不足时递归返回新函数
  • 使用 apply 绑定 this,防止指向偏移
  • 使用 concat 拼接历史参数和新参数

2.5 柯里化在源码中的应用

2.5.1 Vue3 源码案例

Vue3 源码中大量使用了柯里化思想。下图展示了 createApp 的实现:

图 3:Vue3 源码中的柯里化

在源码中,柯里化的运用方式更加灵活:

图 4:Vue3 源码 createAppAPI 的柯里化运用

代码结构:

return {
  render,
  hydrate,
  createApp: createAppAPI(render, hydrate)
};

createAppAPI 返回的函数就是 createApp,通过 ES6 对象简写形式:

// 完整形式
createApp: createApp

// 简写形式
createApp

最终形成嵌套调用:

createAppAPI(render, hydrate)(rootComponent, rootProps)

这种写法进一步扩大了封装的灵活性,但也提高了抽象程度。

2.5.2 Redux 源码案例

Redux 中也有典型的柯里化应用:

图 5:Redux 柯里化调用

参考链接:redux-thunk/src/index.ts

本节小结

  • 柯里化本质:将多参数函数转换为单参数函数链
  • 核心价值:单一职责 + 逻辑复用
  • 实现关键:闭包保存参数 + 递归收集参数
  • 应用场景:工具函数封装、参数预设、延迟执行
  • 源码体现:Vue3、Redux 等框架广泛使用
  • 注意事项:避免过度嵌套(2-3 层为宜)

三、组合函数:函数的乐高积木

3.1 什么是组合函数

组合函数(Compose Function)是函数式编程中的一种使用技巧,用于将多个函数组合成一个新函数。

场景描述:

  • 需要对数据依次执行两个函数 fn1fn2
  • 每次都要手动调用两次,操作重复
  • 能否将这两个函数组合起来,自动依次调用?

基础示例:

// 乘以 2
function double(num) {
  return num * 2;
}

// 平方
function square(num) {
  return num ** 2;
}

const count = 10;
// 传统方式:嵌套调用
const result = square(double(count)); // (10 * 2) ** 2 = 400
console.log(result);

// ✅ 组合函数:将两个函数组合
function composeFn(m, n) {
  return function(count) {
    return n(m(count));
  };
}

const newFn = composeFn(double, square);
console.log(newFn(10)); // 400

核心思想:

  • 第一层函数接收需要组合的函数
  • 返回第二层函数(组合后的函数)接收数据
  • 第二层函数内部依次执行传入的函数

3.2 组合函数的优势

  1. 保持函数独立性doublesquare 各自功能独立
  2. 减少重复调用:组合一次,多次使用
  3. 提高可读性newFn(10)square(double(10)) 更清晰
  4. 灵活组合:可以调整执行顺序 n(m(count))m(n(count))

这种模式和 bind 函数类似:所有操作都在第二层函数中完成。


四、通用组合函数实现

4.1 需求分析

前面的 composeFn 只能组合两个函数,实际开发中可能需要组合更多函数。我们需要实现一个通用的组合函数:

需求:

  • 支持传入任意数量的函数
  • 验证传入的都是函数类型
  • 按顺序依次执行函数
  • 上一个函数的返回值作为下一个函数的参数

4.2 完整实现

function hyCompose(...fns) {
  const length = fns.length;

  // 1. 验证:确保传入的都是函数
  for (let i = 0; i < length; i++) {
    if (typeof fns[i] !== 'function') {
      throw new TypeError('所有参数必须是函数类型');
    }
  }

  // 2. 返回组合后的函数
  function compose(...args) {
    let index = 0;
    // 执行第一个函数,传入所有参数
    let result = length ? fns[index].apply(this, args) : args;

    // 依次执行剩余函数,每次传入上一个函数的返回值
    while (++index < length) {
      result = fns[index].call(this, result);
    }

    return result;
  }

  return compose;
}

// 测试
function double(m) {
  return m * 2;
}

function square(n) {
  return n ** 2;
}

function addTen(x) {
  return x + 10;
}

// 组合多个函数
const newFn = hyCompose(double, square, addTen);
console.log(newFn(5)); // ((5 * 2) ** 2) + 10 = 110

实现要点:

  1. 参数验证:遍历检查每个参数是否为函数
  2. 边界处理
    • 第一个函数使用 apply 接收多个参数
    • 后续函数使用 call 接收单个参数(上一个函数的返回值)
  3. this 绑定:使用 apply/call 确保 this 指向正确
  4. 执行顺序:按传入顺序依次执行(先 double,再 square,最后 addTen

4.3 执行流程图解

newFn(5)
  ↓
double(5) → 10square(10) → 100addTen(100) → 110

本节小结

  • 组合函数:将多个函数组合成一个新函数
  • 适用场景:多个函数需要依次执行,且关联性强
  • 实现关键:第一个函数接收多参数,后续函数接收单参数
  • 执行顺序:按传入顺序依次执行
  • 注意事项:需要验证参数类型,绑定 this 指向

五、实战落地建议

5.1 代码层面

纯函数实践清单:

  1. 优先使用不可变方法

    • 数组:mapfilterreducesliceconcat
    • 对象:Object.assign({},...){...obj}
    • 避免:pushsplicesort(会修改原数组)
  2. 函数设计原则

    • 输入通过参数传递,不依赖全局变量
    • 输出通过 return 返回,不修改外部状态
    • 避免在函数内部发起网络请求或操作 DOM
  3. React 组件规范

    • 函数组件不修改 props
    • 使用 useState 管理内部状态
    • 副作用统一放在 useEffect

柯里化应用场景:

  1. 工具函数封装

    // 通用请求函数
    const request = baseURL => endpoint => params => {
      return fetch(`${baseURL}${endpoint}`, params);
    };
    
    const apiRequest = request('https://api.example.com');
    const getUserInfo = apiRequest('/user');
    getUserInfo({ id: 123 });
    
  2. 事件处理优化

    // 避免在 JSX 中创建匿名函数
    const handleClick = id => event => {
      console.log('Clicked item:', id);
    };
    
    <button onClick={handleClick(item.id)}>Click</button>
    
  3. 参数预设

    const logger = level => message => {
      console.log(`[${level}] ${message}`);
    };
    
    const errorLog = logger('ERROR');
    const infoLog = logger('INFO');
    

5.2 团队推广

渐进式推广策略:

  1. 第一阶段:意识培养

    • 团队分享会讲解纯函数和柯里化概念
    • Code Review 中指出副作用问题
    • 建立最佳实践文档
  2. 第二阶段:工具支持

    • ESLint 规则:禁止修改参数(no-param-reassign
    • 引入 Immutable.js 或 Immer.js
    • 封装常用的柯里化工具函数
  3. 第三阶段:规范落地

    • 新项目强制使用纯函数
    • 老项目逐步重构
    • 建立代码质量指标

常见问题应对:

问题 解决方案
性能担忧(创建新对象) 使用 Immer.js 优化,实际性能影响很小
学习成本高 提供代码示例和最佳实践文档
历史代码改造难 新代码严格执行,老代码逐步重构
调试困难 使用 Redux DevTools 等工具

5.3 验证指标

代码质量指标:

  • 单元测试覆盖率提升(纯函数更易测试)
  • Bug 率下降(副作用减少)
  • 代码复用率提升(柯里化提高复用性)
  • Code Review 时间减少(代码更清晰)

六、总结与展望

6.1 核心要点回顾

纯函数:

  • 确定的输入产生确定的输出
  • 不产生副作用,不修改外部状态
  • 是构建可预测、可测试代码的基础
  • React、Redux 等框架的核心要求

柯里化:

  • 将多参数函数转换为单参数函数链
  • 体现单一职责原则
  • 提高代码复用性和灵活性
  • 在 Vue3、Redux 等源码中广泛应用

组合函数:

  • 将多个函数组合成新函数
  • 保持函数独立性的同时提高复用
  • 函数式编程的重要技巧

6.2 进阶方向

  1. 深入函数式编程

    • 学习 Functor、Monad 等高级概念
    • 研究 Ramda.js、Lodash/fp 等函数式库
    • 理解函数式编程在大型项目中的应用
  2. 框架源码阅读

    • Vue3 响应式系统中的纯函数应用
    • Redux 中间件的柯里化设计
    • React Hooks 的函数式思想
  3. 性能优化

    • 使用 Immer.js 优化不可变数据操作
    • 理解 React.memo 和纯组件的关系
    • 掌握函数式编程的性能优化技巧

6.3 团队落地路线图

短期(1-2 个月):

  • 团队技术分享,统一认知
  • 建立编码规范和最佳实践文档
  • 新项目试点应用

中期(3-6 个月):

  • 封装团队通用的工具函数库
  • 配置 ESLint 规则自动检查
  • Code Review 中强化纯函数要求

长期(6 个月以上):

  • 老项目逐步重构
  • 建立代码质量监控体系
  • 沉淀团队函数式编程最佳实践

附录:常见误区

  1. 误区:纯函数不能有任何副作用

    • 正解:console.log 等调试代码是可接受的副作用
    • 关键是不影响函数的核心逻辑和可预测性
  2. 误区:柯里化会降低性能

    • 正解:现代 JavaScript 引擎优化很好,性能影响微乎其微
    • 代码可维护性的提升远大于微小的性能损失
  3. 误区:所有函数都要柯里化

    • 正解:根据实际需求选择,不要过度设计
    • 参数固定且无复用需求的函数不需要柯里化
  4. 误区:纯函数不能调用其他函数

    • 正解:可以调用其他纯函数
    • 关键是整体不产生副作用

参考资源:


本文适合有一定 JavaScript 基础的前端工程师阅读。如有疑问或建议,欢迎交流讨论。

JavaScript 中的 `this` 与变量查找:一场关于“身份”与“作用域”的深度博弈

JavaScript 中的 this 与变量查找:一场关于“身份”与“作用域”的深度博弈

在 JavaScript 的浩瀚宇宙中,有两个概念让无数开发者爱恨交织:一个是像变色龙一样的 this,另一个是像迷宫一样的 作用域链(Scope Chain)

很多初学者容易混淆这两者:以为 this 也是沿着作用域链查找的,或者以为变量查找会受 this 影响。事实恰恰相反

  • 变量查找:遵循词法作用域(Lexical Scope),由代码写在哪里决定(静态的)。
  • this 指向:遵循动态绑定(Dynamic Binding),由代码怎么被调用决定(动态的)。

就像一个人的社会身份(this)取决于他此刻站在哪个舞台上,而他的记忆(变量查找)取决于他出生和成长的地方(代码声明的位置)。

本文将基于深度对话中的四个经典场景,从变量查找陷阱到构造函数迷局,再到 DOM 事件与调用方式的终极对比,带你彻底看透 JavaScript 的核心机制。


第一幕:错位的记忆 —— 变量查找 vs this 指向

让我们从一个极具迷惑性的代码片段开始。这段代码完美展示了**“变量去哪找”this 指向谁**是完全平行的两条线。

var bar = { 
  myName: "time.geekbang.com",
  printName: function() {
    // 【变量查找】:沿着作用域链向上找
    // 1. 函数内部有没有 myName? 没有。
    // 2. 外层作用域(全局)有没有 myName? 有!值是 '极客邦'
    console.log(myName); // 输出:极客邦
    
    // 【对象属性访问】:直接访问 bar 对象的属性
    console.log(bar.myName); // 输出:time.geekbang.com
    
    // 【this 指向】:取决于调用方式
    console.log(this); 
    console.log(this.myName);
  }
}

function foo() {
  let myName = '极客时间'; // 注意:这是 foo 内部的局部变量
  return bar.printName;    // 返回的是函数引用,带走了吗?没有!
}

// 全局变量
var myName = '极客邦';

// 获取函数引用
var _printName = foo();

// 【关键调用】:独立函数调用
_printName(); 

🕵️‍♂️ 深度剖析:当 _printName() 执行时

假设我们在浏览器环境(非严格模式)下运行 _printName(),结果如下:

  1. console.log(myName) -> 输出 '极客邦'

    • 原因:这是自由变量查找。
    • 路径:函数内部找不到 -> 沿着词法作用域链向外找 -> 找到全局作用域下的 var myName = '极客邦'
    • 误区:很多人以为它会找到 foo 里的 '极客时间'错! printName 函数是在 bar 对象里定义的(全局作用域),它的“出生地”决定了它只能看到全局变量,根本看不见 foo 内部的 let myName。哪怕它是通过 foo 返回的,它的作用域链依然在定义时就固定了。
  2. console.log(bar.myName) -> 输出 'time.geekbang.com'

    • 原因:这是显式的对象属性访问,与 this 无关,直接读取 bar 对象上的值。
  3. console.log(this) & this.myName -> 输出 Windowundefined (或全局 myName)

    • 原因_printName()独立函数调用(前面没有点号)。
    • 规则:在非严格模式下,独立调用的 this 指向全局对象 window
    • 结果thiswindowwindow.myName 的值正是全局变量 '极客邦'(因为 var 声明的全局变量会自动挂载到 window 上)。

⚖️ 变量修改实验:let vs var 的蝴蝶效应

现在,我们来玩两个“如果”,看看世界如何改变。

实验 A:把 foo() 里的 let 换成 var
function foo() {
  var myName = '极客时间'; // 换成 var
  return bar.printName;
}
  • 结果毫无变化
  • 解析:无论 foo 内部用 let 还是 varmyName 依然是 foo局部变量printName 函数的作用域链依然只包含它自己、全局作用域,不包含 foo 的执行上下文。变量查找依然跳过 foo,直接找到全局的 '极客邦'
实验 B:把全局的 var myName 改为 let myName
// 全局
let myName = '极客邦'; // 换成 let
  • 结果
    • console.log(myName) -> 报错!ReferenceError: myName is not defined (如果在某些模块环境) 或者依然能访问到?
    • 修正解析:在全局作用域用 let 声明的变量不会挂载到 window 对象上,但它依然在全局词法环境中。
    • console.log(myName) (第一行) -> 依然输出 '极客邦'。因为变量查找是沿着词法作用域链,能找到全局 let 变量。
    • console.log(this.myName) (最后一行) -> 输出 undefined
    • 核心差异this 指向 window,而 window 对象上没有 myName 属性(因为 let 不挂载到 window)。
    • 结论:变量查找找到了值,但 this 查找失败了。这再次证明了变量查找路径this 指向是两套完全独立的系统。

💡 核心洞察函数带走的是“代码”,不是“环境”printName 被返回后,它依然坚守着它出生时的作用域链(全局),对 foo 内部的秘密(局部变量)一无所知。而 this 则像个墙头草,谁调用它,它就指向谁。


第二幕:身份的切换 —— 两种调用方式的终极对决

紧接着上面的代码,如果我们换一种调用方式,世界瞬间反转:

// 方式一:独立调用
_printName(); 

// 方式二:对象方法调用
bar.printName();

🥊 巅峰对决

特性 独立调用 (_printName()) 对象方法调用 (bar.printName())
语法形式 函数名直接加括号,前面无归属 对象.函数名(),前面有点号
this 指向 window (非严格模式) bar 对象
this.myName window.myName ('极客邦') bar.myName ('time.geekbang.com')
变量 myName 依然找全局 ('极客邦') 依然找全局 ('极客邦')
本质逻辑 函数失去了上下文,回归默认 函数明确了所有者,指向调用者
  • _printName():就像把一个员工从公司(bar)开除,让他去大街上(全局)流浪。此时他代表的是“路人甲”(window)。
  • bar.printName():员工在公司打卡上班。此时他明确代表“极客时间官网”(bar)。

💡 核心洞察点号(.)是 this 的开关。只要有 obj.func() 的形式,this 就是 obj。一旦把函数赋值给变量再调用(var f = obj.func; f()),点号消失,this 也就迷失了。


第三幕:错位的时空 —— 构造函数中的递归迷局

除了对象方法,new 操作符是 this 的另一个重要舞台。但这里同样藏着陷阱。

function CreateObj() {
    var temObj = {};             
    CreateObj.call(temObj);      // ⚠️ 致命递归
    temObj.__proto__ = CreateObj.prototype;
    return temObj;               
    console.log(this);           // 死代码
    this.name = '极客时间';      
}

var myObj = new CreateObj();

🚨 崩溃现场

这段代码试图在构造函数内部手动模拟 new,却导致了 栈溢出(RangeError)

  1. new 的隐式魔法:执行 new CreateObj() 时,引擎已经创建了实例 instance 并绑定了 this
  2. 致命的递归CreateObj.call(temObj) 并不是改变当前的 this,而是开启了一次全新的函数调用
    • 新调用 -> 创建新 temObj -> 再次 call -> 无限循环。
  3. 死代码return temObj 导致后面的 this.name 永远无法执行。且因为显式返回了对象,new 原本创建的 instance 被丢弃。

✅ 正确的“手动 New”姿势

要在外部模拟 new,必须在函数外控制:

function CreateObj() {
    this.name = '极客时间'; // 这里的 this 由外部 call 决定
}

var temObj = {};
temObj.__proto__ = CreateObj.prototype;
CreateObj.call(temObj); // 只调用一次,绑定 temObj
var myObj = temObj;

💡 核心洞察this 在函数执行瞬间即被定格。你无法在函数内部通过 call 篡改当前执行的 this,那只会开启新的轮回。


第四幕:舞台的主角 —— DOM 事件中的本能反应

最后,来到浏览器前端。

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener("click", function(){
    console.log(this); // <a href="#" id="link">点击我</a>
});
</script>

🎭 舞台规则

addEventListener 的普通函数回调中:

this 自动指向触发事件的 DOM 元素。

  • 谁被点了? <a> 标签。
  • this 是谁? <a> 标签。

⚠️ 陷阱:若改用箭头函数 () => {}this 将不再指向 <a>,而是继承外层(通常是 window)。所以在处理 DOM 事件时,普通函数是首选


🏁 终极总结:掌握 JavaScript 的双核驱动

通过这四幕大戏,我们理清了 JavaScript 中最容易混淆的两个核心机制:

1. 变量查找(静态的·出身的烙印)

  • 规则:沿着词法作用域链向上查找。
  • 决定因素:函数写在哪里(声明位置)。
  • 特点:一旦函数定义完成,它能访问哪些变量就永久固定了,不受调用方式影响。
    • 案例printName 无论在哪儿调用,它永远只能找到全局的 myName,找不到 foo 内部的 myName

2. this 指向(动态的·舞台的身份)

  • 规则:看调用方式(Call Site)。
  • 决定因素:函数怎么被调用
  • 四大场景
    1. 独立调用 (func()) -> window (非严格模式)。
    2. 方法调用 (obj.func()) -> obj
    3. 构造调用 (new Func()) -> 新实例。
    4. 事件回调 (element.addEventListener(..., function)) -> DOM 元素。
    5. 显式绑定 (call/apply/bind) -> 指定的对象(开启新调用)。

🗝️ 钥匙在手

  • 如果你想访问外层变量,请关心作用域链(代码写在哪)。
  • 如果你想操作当前对象,请关心 this(代码怎么调)。
  • 切记:不要试图在函数内部用 call 改变当前的 this,那是徒劳的;也不要以为函数被传递后能带走它的局部变量环境,那也是错觉。

JavaScript 的灵活性赋予了它强大的能力,也带来了复杂性。但只要分清**“静态的作用域”“动态的 this”**,你就能在代码的迷宫中游刃有余,写出既精准又优雅的逻辑!

JSX & ReactElement 核心解析

在 React 面试中,JSX 与 ReactElement 是基础且高频的考点——难度低、记忆点集中,掌握后能轻松拿下基础分,尤其适合面试突击复习。本文以「通俗解读+专业拆解」的方式,帮你理清核心逻辑,所有内容均适配面试答题场景,可直接背诵套用,同时补充高频考题及标准答案,助力高效备考。

一、核心结论(面试开门见山必备)

面试时遇到相关问题,先抛出以下结论,能快速建立专业认知,给面试官留下清晰印象,直接背诵即可:

  • JSX 只是语法糖,核心作用是简化 UI 描述,编译后会转化为 React.createElement 函数的调用。

  • ReactElement 是一次「UI 描述快照」,本质是一个不可变、轻量的 JavaScript 对象,用于精准描述你想要的 UI 结构。

  • ReactElement 既不是 DOM,也不是 Fiber;真正参与 React 调度、虚拟 DOM 比对与页面更新的,是 Fiber(React 内部的运行时工作单元)。

  • 面试标准答句:JSX → React.createElement → ReactElement(UI 描述);渲染器(如 ReactDOM)和 React 内部的 Fiber,会把这份描述落地为真实 DOM 并完成更新。

二、JSX 编译后是什么?(通俗+具体,易理解好背诵)

通俗解读

很多初学者会误以为 JSX 是 HTML 的延伸,或是 React 独有的语法,其实都不对。JSX 本质就是「长得像 HTML 的语法糖」——我们写 JSX,只是为了摆脱繁琐的 React.createElement 写法,让 UI 描述更直观、更简洁,就像用“简化版代码”代替“完整版代码”,核心功能没有变化。

专业拆解

JSX 本身无法被浏览器直接识别,必须经过 Babel 等编译器编译,最终转化为 React.createElement 的函数调用,而这个函数的返回值,就是我们下一节要讲的 ReactElement。

具体示例(面试可直接举例,加分项):

// 我们写的 JSX
const el = <App name="x" />;

// 经过 Babel 编译后,转化为
const el = React.createElement(App, { name: 'x' }, null);

补充记忆点(易混淆,必背):React.createElement 的第一个参数(type),决定了元素的类型——当 type 是字符串(如 'div'、'span')时,表示原生 DOM 节点;当 type 是函数或类时,表示 React 组件(如上述示例中的 App 组件)。

三、ReactElement 是什么数据结构?(面试必背,精准踩分)

ReactElement 是 React 描述 UI 结构的基础数据结构,核心是「纯 JavaScript 对象」,可以理解为 UI 的“静态快照”,不包含真实 DOM、不存储组件状态,也不参与任何更新操作,仅用于描述“UI 长什么样”。

典型结构(面试可直接背诵,绝对踩分)

const el = {
  $$typeof: Symbol(react.element), // 类型标签,标记这是 React 元素,避免与普通对象混淆
  type: App,                        // 元素类型:字符串(原生DOM)或函数/类组件
  props: { name: 'x' },             // 元素属性:传入的 props、children 也包含在其中
  key: null,                        // 列表渲染的唯一标识,用于优化 diff 算法
  ref: null                         // 用于获取真实 DOM 或组件实例
}

核心特性(通俗+专业,帮你加深记忆)

  • 轻量:仅包含 UI 描述所需的核心信息,不占用浏览器额外资源,也不包含状态、生命周期等逻辑。

  • 不可变:一旦创建,就无法修改其属性(如 props、type);组件更新时,会创建一个新的 ReactElement,而非修改原有对象。

  • 核心作用:作为 React diff 算法的“对比依据”,React 会通过对比前后两个 ReactElement 树的差异,决定哪些部分需要更新。

常见误解(避坑必记)

很多面试者会混淆“ReactElement”与“组件实例”“DOM 节点”,这里明确区分:ReactElement 只是「UI 描述」,既不是组件实例(组件实例包含状态、生命周期),也不是真实 DOM 节点(DOM 是浏览器中可渲染的实体),它只是告诉 React“该如何构建 UI”。

四、ReactElement ≠ DOM ≠ Fiber(三者职责+关系,面试高频易错点)

这三个概念是面试必问的易错点,很多人会将三者混淆,其实它们的职责、生命周期完全不同,用“通俗定位+专业职责”的方式,一次性记牢:

1. ReactElement:静态 UI 描述

  • 通俗定位:UI 的“设计图纸”,只记录“要做什么”,不负责“怎么做”。

  • 专业职责:描述 UI 的结构、属性和类型,是声明式的数据,创建后就固定不变,不参与 React 的调度和更新流程。

2. DOM:真实 UI 呈现

  • 通俗定位:“设计图纸”落地后的“实体建筑”,是浏览器中真实可见、可交互的节点。

  • 专业职责:承载页面的视觉呈现和用户交互(如点击、输入),占用浏览器资源;由 React 渲染器(如 ReactDOM)负责根据 Fiber 和 ReactElement 的描述,创建、更新或删除 DOM 节点。

3. Fiber:React 内部运行时单元

  • 通俗定位:“施工队长”,负责统筹调度、拆分任务,确保“建筑”(DOM)能高效更新。

  • 专业职责:React 16+ 引入的核心结构,是 React 内部调度、协调更新的最小单位,包含组件状态、更新优先级、指向子/兄弟节点的指针等信息;负责实现虚拟 DOM diff、时间切片(可中断渲染),是真正参与 React 更新流程的“主角”。

三者关系总结(面试必背)

  1. JSX 编译后生成 ReactElement(UI 描述);

  2. React 的协调算法(Reconciliation)读取 ReactElement,构建或更新 Fiber 树(补充状态、副作用等信息);

  3. Fiber 树驱动渲染器(如 ReactDOM),将更新应用到真实 DOM,最终完成页面渲染。

五、为什么要区分这些概念?(面试拓展加分点)

很多面试会追问“为什么 React 要拆分这三个概念”,记住以下3个核心要点,无需拓展,直接背诵即可加分:

  • 支持可中断渲染:Fiber 可以将大型更新任务拆分为多个小任务,避免阻塞浏览器主线程,提升页面响应性(这一功能与 ReactElement 无关,核心依赖 Fiber 的设计)。

  • 简化 diff 算法:ReactElement 的不可变性,让 React 对比前后两棵 UI 树的差异时更高效,无需遍历所有属性,只需对比核心标识即可。

  • 解耦渲染目标:ReactElement、Fiber 与真实宿主环境(DOM、React Native)分离,让 React 可以适配不同的渲染场景(如网页、移动端),只需更换渲染器即可。

六、从 到浏览器显示的完整流程(简化版,易背诵)

面试时若被问到“JSX 如何渲染到页面”,按以下步骤回答,逻辑清晰、重点突出:

  1. 开发者编写 JSX:<App name="x" />

  2. Babel 编译 JSX,转化为 React.createElement(App, { name: 'x' })

  3. 调用 React.createElement,返回 ReactElement(UI 描述对象);

  4. React 协调阶段(Reconciliation):对比 ReactElement 与当前 Fiber 树,创建/更新 Fiber 节点,确定需要执行的更新操作(插入、修改、删除);

  5. Commit 阶段:Fiber 应用副作用,调用渲染器 API(如 ReactDOM),创建或更新真实 DOM;

  6. 浏览器渲染 DOM,最终呈现出页面效果。

七、面试够用的补充要点(精准踩分,可直接背诵)

  • $$typeof:用于标记对象类型,值为 Symbol(react.element),防止外部伪造 React 元素,避免安全风险。

  • key:列表渲染时的唯一标识,帮助 React 在列表重排时复用已有节点,避免不必要的 DOM 重建,优化性能。

  • ref:用于获取真实 DOM 节点或组件实例,注意函数组件本身没有实例,需使用 forwardRef 才能接收 ref。

  • props.children:所有 JSX 子元素(如 <App>孩子</App>),都会被挂载到 props.children 上,包含在 ReactElement 的 props 中。

  • ReactElement 不包含状态(state)、生命周期方法和内部指针,这些信息都存储在 Fiber 节点上。

  • 更新机制:React 会对比新旧两个 ReactElement 树,生成副作用列表(插入、更新、删除),再通过 Fiber 执行这些副作用,最终更新 DOM。

八、简短口语版答案(面试应急,自然不生硬)

若面试时紧张,可用以下口语化表述,既专业又易懂,避免卡顿:

  • “JSX 是语法糖,编译后会变成 React.createElement 的调用,返回 ReactElement——一个不可变的 JS 对象,用来描述 UI 长什么样。React 会根据这个描述做 diff,内部构建 Fiber 树来调度和执行更新,最后由渲染器把变化用到 DOM 上。”

  • “简单说,ReactElement 是‘描述’,DOM 是‘呈现’,Fiber 是‘执行单元’,三者各司其职,互不相同。”

九、面试常考问题(带要点提示,可直接背诵答案)

以下是该考点 90% 以上的高频考题,每个问题均搭配“核心要点+标准答句”,无需拓展,背诵即可直接答题:

1. 问:JSX 和 React.createElement 有什么关系?

答:JSX 是 React.createElement 方法的语法糖,目的是简化 UI 描述的编写;经过 Babel 编译后,JSX 会直接转化为 React.createElement 的函数调用,二者本质是同一功能的不同写法。

2. 问:ReactElement 是什么?包含哪些核心字段?

答:ReactElement 是一个描述 UI 结构的纯 JavaScript 对象,本质是 UI 的“静态快照”;核心字段有5个:$$typeof(标记 React 元素)、type(元素类型)、props(元素属性)、key(列表唯一标识)、ref(获取 DOM/组件实例)。

3. 问:ReactElement 和 DOM 的区别是什么?

答:① ReactElement 是纯 JS 对象,仅用于描述 UI 信息,不参与渲染和更新,是“设计图纸”;② DOM 是浏览器中的真实节点,是“图纸落地后的实体”,承载页面呈现和用户交互;③ DOM 由 React 渲染器根据 ReactElement 和 Fiber 的描述创建/更新,二者本质不同。

4. 问:ReactElement、Fiber、DOM 三者的关系与职责分别是什么?

答:① 职责:ReactElement 负责描述 UI(静态快照),Fiber 负责 React 内部调度、协调更新(运行时单元),DOM 负责真实 UI 呈现;② 关系:JSX 编译生成 ReactElement,React 根据 ReactElement 构建/更新 Fiber 树,Fiber 驱动渲染器生成/更新 DOM。

5. 问:为什么 React 要用 Fiber?解决了什么问题?

答:Fiber 是 React 内部的更新单元,核心解决了“大型更新任务阻塞浏览器主线程”的问题;它可以将大任务拆分为多个小任务,实现可中断、可恢复的渲染,支持优先级调度,提升页面响应性。

6. 问:key 是什么,为什么重要?

答:key 是 ReactElement 的核心字段之一,是列表渲染时的唯一标识;它的重要性在于,帮助 React 在列表重排时快速识别哪些节点可以复用,避免不必要的 DOM 重建,从而优化渲染性能。

7. 问:ReactElement 可变吗?组件更新时会怎样?

答:ReactElement 是不可变的,一旦创建就无法修改其属性;组件更新时,React 会创建一个新的 ReactElement(携带新的 props、type 等信息),再通过 Fiber 对比新旧 ReactElement 的差异,执行相应的更新操作。

8. 问:ReactElement 的 type 可以是什么类型?

答:type 的类型主要有4种:① 字符串(如 'div'、'span'),表示原生 DOM 节点;② 函数或类,表示 React 组件;③ React.Fragment(碎片,用于包裹多个元素);④ Context、Portals 等特殊类型。

9. 问:ReactDOM.render 时,是如何把 ReactElement 转为 DOM 的?

答:分为两个核心阶段:① 协调阶段(Reconciliation):React 根据 ReactElement 与当前 Fiber 树对比,创建/更新 Fiber 节点,确定需要执行的更新操作;② Commit 阶段:Fiber 应用副作用,调用 ReactDOM 的 API,根据 Fiber 信息创建或更新真实 DOM,最终完成渲染。

10. 问:ref 存放在哪里?函数组件怎样获取 ref?

答:ref 是 ReactElement 的核心字段之一,用于存储对真实 DOM 节点或组件实例的引用;函数组件本身没有实例,无法直接接收 ref,需要使用 forwardRef 高阶组件,将 ref 转发到组件内部的 DOM 节点或子组件上。

面试背诵提示:答题时,优先用“JSX 是语法糖 → ReactElement 是描述 → Fiber 是执行单元”这条主线,串联三者的关系;遇到涉及 ReactElement 结构的问题,直接背诵其5个核心字段;所有答案无需过度拓展,精准踩中要点即可,既节省时间,又能体现专业性。

JavaScript 面向对象编程全景指南:从原始字面量到原型链的终极进化

JavaScript 面向对象编程全景指南:从原始字面量到原型链的终极进化

在编程语言的浩瀚星海中,JavaScript 无疑是一颗独特而耀眼的星辰。它既不像 Java 那样拥有严谨的类结构,也不像 Python 那样直观易懂,但它却以一种灵活多变、甚至略带“野性”的方式,构建了整个现代 Web 的基石。从早期的静态网页交互,到如今支撑起庞大的单页应用(SPA)、服务端渲染(SSR)乃至跨平台移动开发,JavaScript 的演进史就是一部前端技术的进化史。

而在 JavaScript 的核心深处,隐藏着一套独特而强大的面向对象编程(OOP)机制。今天,我们将基于详实的代码文档与教学记录,深入探索这一机制的全貌。这不仅是一次语法的回顾,更是一场从混沌走向秩序、从孤立走向关联的进化史诗。我们将见证从简单的对象字面量,到构造函数的封装,再到原型链继承的终极奥秘,彻底揭开 JavaScript“基于原型”的灵魂面纱。本文将详尽剖析每一个阶段的代码实现、内存模型、设计哲学以及底层原理,力求为读者呈现一份深度技术指南。


第一章:蛮荒时代——对象字面量的原始模式与孤立困境

1.1 初始的尝试:白纸上的涂鸦

一切始于简单。在 JavaScript 诞生的初期,或者说在开发者尚未形成系统化面向对象思维的阶段,创建对象最直接的方式就是对象字面量(Object Literal)。这种方式如同在白纸上直接画出一个个独立的个体,直观、快速且无需任何前置定义。

// 这里的 Cat 大写,是开发者的约定俗成,暗示它是一个“类”或模板
// name 和 color 是模板属性,体现了初步的抽象和封装意识
var Cat = {
    name: "",
    color: ""
};

// 创建第一个实例
var cat1 = {}; // 创建一个空对象
cat1.name = '加菲猫';
cat1.color = '橘色';

// 创建第二个实例
var cat2 = {};
cat2.name = '黑猫';
cat2.color = '黑色';

在这种模式下,Cat 对象仅仅作为一个参考模板存在,它本身并不具备创建新对象的能力。开发者需要手动创建空对象 {},然后逐一赋值。

1.2 模式的困境:孤岛的代价

随着项目规模的扩大,这种原始模式的弊端迅速暴露,成为了代码维护的噩梦:

  1. 代码冗余与重复劳动:每创建一个新对象,开发者都要重复编写相同的属性赋值代码。如果有十个属性,就要写十行赋值语句;如果要创建一百个猫对象,就要重复一百次。这不仅效率低下,而且极易出错。
  2. 缺乏类型关联与身份认同cat1cat2 在内存中是完全孤立的岛屿。JavaScript 引擎无法识别它们属于同一个“类别”。如果你问引擎 "cat1Cat 吗?”,它会毫不犹豫地回答“不是”,因为 cat1 的构造函数是 Object,而不是 Cat。这种缺乏类型系统的状态,使得代码的多态性和可扩展性几乎为零。
  3. 方法定义的灾难:如果我们需要给猫添加一个“叫”的方法,在字面量模式下,我们必须在每个对象中单独定义:
    cat1.sayHi = function() { console.log("喵~"); };
    cat2.sayHi = function() { console.log("喵~"); };
    
    这意味着,每创建一个实例,内存中就会多出一份完全相同的函数副本。对于成千上万个实例来说,这是对内存资源的极大浪费。

我们需要一种机制,能够将对象的“模板”与“实例”紧密联系起来,让代码具备复用性、封装性和多态性。于是,构造函数应运而生,开启了 JavaScript 面向对象的启蒙运动。


第二章:启蒙运动——构造函数与实例化的诞生

2.1 封装实例化过程:从散沙到蓝图

为了解决对象孤立的问题,JavaScript 引入了**构造函数(Constructor Function)**的概念。构造函数本质上是一个普通的函数,但通过特定的命名规范(首字母大写)和调用方式(配合 new 关键字),它被赋予了创建对象的特殊使命。

function Cat(name, color) {
    // 此时 this 指向谁?这取决于函数是如何被调用的
    // 如果以 new 的方式运行,this 指向新创建的空对象
    console.log(this); 
    this.name = name;  // 将参数赋值给实例属性
    this.color = color;
    // 隐式返回 this
}

2.2 new 关键字的魔法:四步创世记

当使用 new 关键字调用函数时,JavaScript 引擎内部发生了一系列精密而神奇的操作。理解这四步,是掌握 JavaScript OOP 的关键:

  1. 创建空对象(Creation):引擎首先在内存中创建一个全新的空对象。这个对象最初没有任何属性,它的原型默认指向 Object.prototype
  2. 绑定 this(Binding):引擎将该函数内部的 this 关键字强制绑定到这个新创建的对象上。从此,函数内部所有的 this.xxx 操作,实际上都是在操作这个新对象。
  3. 执行代码(Execution):引擎执行函数体中的代码。在这个阶段,开发者编写的属性赋值逻辑(如 this.name = name)被执行,新对象被填充了具体的数据。
  4. 返回实例(Return):除非函数内部显式返回了一个对象,否则引擎会隐式地返回这个新创建并填充好的对象。
const cat1 = new Cat("加菲猫", "橘色"); 
const cat2 = new Cat("黑猫警长", "黑色");

警示:如果忘记使用 new,直接调用 Cat("黑猫警长", "黑色"),函数内部的 this 将指向全局对象(在浏览器中是 window,在 Node.js 中是 global)。这不仅导致无法返回预期的实例对象,还会污染全局作用域,引发难以追踪的 Bug。

2.3 建立身份认同:constructor 与 instanceof

通过构造函数创建的对象,终于建立了彼此之间的联系,形成了真正的“类”的概念:

  • constructor 属性:每个实例对象都自动拥有一个 constructor 属性,它指向创建该对象的构造函数。

    console.log(cat1.constructor === Cat); // true
    console.log(cat1.constructor === cat2.constructor); // true
    

    这证明了 cat1cat2 拥有共同的“父亲”。

  • instanceof 操作符:这是检测对象类型的利器。它用于判断一个对象是否属于某个构造函数的实例。其原理是检查构造函数的 prototype 属性是否存在于对象的原型链上。

    console.log(cat1 instanceof Cat); // true
    console.log(cat1 instanceof Object); // true (因为 Cat 也是对象)
    

然而,构造函数虽然解决了属性和类型的问题,却依然没有解决方法共享的难题。如果在构造函数内部定义方法,依然会导致内存浪费。

function Cat(name, color) {
    this.name = name;
    this.color = color;
    // 错误示范:每次 new 都会创建一个新的函数实例
    this.eat = function() {
        console.log("eat jerry");
    };
}

为了解决这个问题,JavaScript 祭出了其最核心的武器——原型(Prototype)


第三章:黄金时代——原型模式与共享智慧

3.1 原型的引入:对象继承对象

JavaScript 最独特的魅力在于其**基于原型(Prototype-based)**的继承机制。不同于 Java、C# 等传统面向对象语言的“类继承”(Class-based Inheritance),JavaScript 采用的是“对象继承对象”。

每个构造函数都有一个特殊的属性叫做 prototype,它是一个对象。所有通过该构造函数创建的实例,都会共享这个 prototype 对象。我们可以将不变的属性和公用方法放到构造函数的 prototype 对象上。

function Cat(name, color) {
    this.name = name;
    this.color = color;
    // 注意:这里不再定义 type 和 eat,而是交给原型
}

// 把不变的属性和公用方法,都放到原型对象上
Cat.prototype.type = "猫科动物";
Cat.prototype.eat = function() {
    console.log("eat jerry");
};

3.2 内存优化与动态共享

这种设计带来了巨大的优势:

  • 内存节省:无论创建多少个 Cat 实例,eat 方法在内存中只存在一份,所有实例共享同一个函数引用。

  • 动态性:原型是动态的。如果在创建实例后修改了原型上的属性或方法,所有实例(包括已经创建的)都能立即反映出这种变化。

    const cat1 = new Cat("Tom", "蓝色");
    const cat2 = new Cat("Jerry", "灰色");
    
    console.log(cat1.type, cat2.type); // "猫科动物" "猫科动物"
    
    // 动态修改原型
    Cat.prototype.type = "变异猫科";
    console.log(cat1.type, cat2.type); // "变异猫科" "变异猫科"
    
  • 属性遮蔽(Shadowing):如果实例自身定义了与原型同名的属性,实例自身的属性会优先被访问,这被称为“属性遮蔽”。

    cat1.type = "铲屎官的主人"; // 在 cat1 自身添加属性
    console.log(cat1.type); // "铲屎官的主人" (访问自身)
    console.log(cat2.type); // "变异猫科" (访问原型)
    

3.3 属性的探测工具集

为了精确控制属性的归属,JavaScript 提供了一套完善的探测工具:

  • hasOwnProperty(key):判断某个属性是否属于对象“自身”,而不包括原型链。
    console.log(cat1.hasOwnProperty("type")); // false (在原型上)
    console.log(cat1.hasOwnProperty("name")); // true (在自身上)
    
  • in 操作符:检查属性是否存在于整个原型链中(包括自身和所有层级的原型)。
    console.log("name" in cat1); // true
    console.log("type" in cat1); // true
    console.log("toString" in cat1); // true (来自 Object.prototype)
    
  • isPrototypeOf(obj):判断某个对象是否存在于另一个对象的原型链上。
    console.log(Cat.prototype.isPrototypeOf(cat1)); // true
    
  • for...in 循环:遍历对象时,会自动遍历到自身可枚举属性以及原型链上的所有可枚举属性。通常配合 hasOwnProperty 使用,以过滤掉原型属性。

第四章:融合与升华——组合继承与原型链的奥秘

4.1 继承的挑战:单一模式的局限

随着业务逻辑的复杂化,我们需要让一个类继承另一个类的特性。例如,让 Cat 继承 Animal。早期的开发者尝试了多种方法,但都发现了缺陷:

  • 借用构造函数(Call/Apply)

    function Animal() { this.species = '动物'; }
    function Cat() { Animal.apply(this); }
    

    缺点:只能继承父类的实例属性(如 species),无法继承父类定义在 prototype 上的方法。因为 apply 只是执行了一次函数,并没有建立原型链接。

  • 原型链继承

    function Cat() {}
    Cat.prototype = new Animal();
    

    缺点:虽然能继承方法,但父类构造函数中的引用类型属性(如数组、对象)会被所有子类实例共享。修改一个实例的属性,会影响其他所有实例。

4.2 组合继承:取长补短的终极方案

为了解决上述矛盾,组合继承(Combination Inheritance) 成为了最经典、最实用的继承模式。它结合了前两种方式的优点:

  1. 借用构造函数继承属性:在子类构造函数中调用父类构造函数,确保每个子类实例拥有独立的属性副本。
  2. 原型链继承方法:将子类的原型指向父类的一个实例,从而让子类实例能够通过原型链访问到父类的方法。
// 父类
function Animal() {
    this.species = '动物';
    this.friends = ['狗', '鸟']; // 引用类型属性
}
Animal.prototype.sayHi = function() {
    console.log('啦啦啦啦');
};

// 子类
function Cat(name, color) {
    // 1. 继承属性:调用父类构造函数,this 指向当前 cat 实例
    // 这样每个 cat 都有自己独立的 species 和 friends 数组
    Animal.apply(this); 
    this.name = name;
    this.color = color;
}

// 2. 继承方法:将 Cat 的原型指向 Animal 的实例
// 这一步建立了原型链,使得 cat 可以访问 sayHi
Cat.prototype = new Animal();

// 修正 constructor 指向(可选但推荐)
// 因为上一步重写了 prototype,constructor 指向了 Animal,需改回 Cat
Cat.prototype.constructor = Cat;

4.3 原型链:通往智慧的桥梁

为什么加上 Cat.prototype = new Animal() 后,cat 就能调用 sayHi 了?这背后是**原型链(Prototype Chain)**在起作用。

当你访问 cat.sayHi 时,JavaScript 引擎启动了一场精彩的“寻根之旅”:

  1. 自查:检查 cat 对象自身有没有 sayHi?❌ 没有。
  2. 问父(原型):去 cat 的构造函数 Catprototype 对象上找。
    • 此时 Cat.prototype 是什么?它是 new Animal() 的结果,即一个 Animal 的实例。
    • 这个 Animal 实例身上有 sayHi 吗?❌ 没有(sayHiAnimal.prototype 上,不在实例身上)。
  3. 问祖(原型的原型):既然 Cat.prototype 是一个 Animal 实例,那么它的内部原型 __proto__ 自然指向 Animal.prototype
    • Animal.prototype 上找。✅ 找到了!sayHi 定义在这里。

于是形成了一条清晰的链条:

cat  -->  Cat.prototype (Animal 实例)  -->  Animal.prototype (包含 sayHi)  -->  Object.prototype  -->  null

这条链条打破了对象的孤岛效应,让知识和能力得以在对象间传递和共享。尽管文档中提到早期的继承方式“不好理解”,但一旦掌握了原型链的精髓,你会发现这是一种极其优雅且强大的设计。


第五章:现代纪元——ES6 Class 语法糖与底层真相

5.1 语法的革新:更像“类”的写法

时光流转到了 ES6(ECMAScript 2015)时代,JavaScript 终于迎来了 class 关键字。这让习惯了 Java、C# 等传统面向对象语言的开发者能更平滑地过渡到 JavaScript 的世界。

class Animal {
    constructor() {
        this.species = '动物';
    }
    sayHi() {
        console.log('啦啦啦啦');
    }
}

class Cat extends Animal {
    constructor(name, color) {
        super(); // 调用父类构造函数,等价于 Animal.apply(this)
        this.name = name;
        this.color = color;
    }
    
    eat() {
        console.log("eat jerry");
    }
}

const cat1 = new Cat('tom', '蓝色');
cat1.sayHi(); // 输出:啦啦啦啦

代码变得如此整洁、语义清晰。extends 关键字直观地表达了继承关系,super 关键字简化了父类调用。

5.2 本质未变:糖衣下的原型灵魂

然而,必须清醒地认识到:class 仅仅是语法糖(Syntax Sugar)。剥开这层华丽的外衣,其底层依然是我们前面探讨的原型机制在运作。JavaScript 引擎在执行 class 代码时,依然是在操作构造函数和原型链。

我们可以通过控制台打印来验证这一点:

console.group("Cat 原型链深度分析");
console.log("1. cat1.__proto__:", cat1.__proto__); 
// 输出: Cat.prototype { eat: [Function], constructor: [class Cat] }
// 证明:实例的原型指向类的 prototype

console.log("2. Cat.prototype.__proto__:", Cat.prototype.__proto__); 
// 输出: Animal.prototype { sayHi: [Function], constructor: [class Animal] }
// 证明:extends 实现了原型链的连接

console.log("3. 原型链终点:", cat1.__proto__.__proto__.__proto__); 
// 输出: null
console.groupEnd();

无论语法如何变迁,cat1.__proto__ 依然指向 Cat.prototype,而 Cat.prototype.__proto__ 依然指向 Animal.prototype。JavaScript 的核心灵魂——原型链,从未改变。ES6 的 class 只是让代码更易读、更易维护,并没有引入新的底层机制。


结语:掌握 JavaScript 的灵魂

从简单的对象字面量到复杂的原型链继承,再到 ES6 的 Class 语法,JavaScript 的面向对象之路充满了探索与创新。

  • 对象字面量让我们看到了初始的简陋与孤立,是原型的起点。
  • 构造函数带来了实例化的规范与身份认同,解决了批量创建的问题。
  • 原型模式解决了内存浪费与共享难题,体现了“对象继承对象”的独特哲学。
  • 组合继承原型链实现了属性与方法的完美传承,构建了复杂的对象关系网。
  • ES6 Class 则披上了现代语法的外衣,让代码更符合人类直觉,但内核依旧坚韧。

虽然 JavaScript 早期没有 class 关键字,甚至至今仍被称作“基于对象”的语言,但这并不妨碍它成为一门真正的面向对象编程语言。理解这一机制,不仅有助于我们写出更高效、更健壮的代码,更能让我们深刻体会到 JavaScript 设计的哲学:灵活、动态、万物皆对象

在这个前端技术日新月异的时代,框架层出不穷(React, Vue, Angular, Svelte),工具链不断迭代。但无论上层建筑如何变迁,这些核心概念始终屹立不倒。掌握 JavaScript 的原型与继承原理,就如同掌握了开启 Web 开发大门的钥匙。它指引着我们在代码的海洋中乘风破浪,透过纷繁复杂的语法表象,直抵技术的本质,构建出更加精彩、健壮的应用世界。这不仅是技术的进化,更是思维的升华。

Vue生态精选篇:Element Plus 的“企业后台常用组件”用法扫盲

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、选型与定位

  • Element Plus:面向 Vue 3 + TypeScript 的 UI 组件库,适合管理后台、中台、后台系统。
  • 为什么用组件库而不是手写? 统一规范、减少重复开发、内置表单校验、表格、弹窗等常见能力。
  • 本文涉及组件:Form、Table、Dialog、Message/MessageBox、Upload。

二、表单 Form:数据收集与校验

2.1 核心概念

Form 的作用:收集、校验、提交 数据,包含输入框、选择器、日期等。

表单的三层结构:

  1. el-form:表单容器,绑定数据和校验规则
  2. el-form-item:单个表单项,承载 label、校验、布局
  3. el-input / el-select 等:具体输入控件

2.2 正确用法示例

<template>
  <el-form 
    ref="formRef" 
    :model="form" 
    :rules="rules" 
    label-width="100px"
    @submit.prevent
  >
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" placeholder="请输入用户名" />
    </el-form-item>
    
    <el-form-item label="密码" prop="password">
      <el-input v-model="form.password" type="password" placeholder="请输入密码" />
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
      <el-button @click="handleReset">重置</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  username: '',
  password: ''
})

// 校验规则:字段名要与 form 中的属性、el-form-item 的 prop 完全一致
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' }
  ]
}

const handleSubmit = async () => {
  // validate 返回 Promise,通过则无参数,失败则返回校验错误
  try {
    await formRef.value.validate()
    console.log('校验通过,提交数据:', form)
    // 这里调用接口提交
  } catch (error) {
    console.log('校验失败')
  }
}

const handleReset = () => {
  formRef.value.resetFields()
}
</script>

说明要点:

  • :model="form" 绑定表单数据,注意是 :model,不是 v-model
  • :rules="rules" 绑定校验规则
  • prop="username" 绑定到表单项,用于关联 rules 中的字段
  • @submit.prevent 防止回车键意外提交表单

2.3 常见踩坑

错误写法 正确写法
Form 绑定 v-model="form" :model="form"
不写 prop <el-form-item> 无 prop <el-form-item prop="username">
prop 写错位置 写在 el-input 必须写在 el-form-item
prop 与 rules 不一致 rules 里是 name,prop 是 username 两者字段名完全一致

记住:el-form 用 :model、el-form-item 必须有 prop、prop 与 rules 字段名一致

2.4 常用 API

  • validate():整表校验
  • validateField(prop):校验单个字段
  • resetFields():重置表单
  • clearValidate():清除校验状态

三、表格 Table:列表展示

3.1 核心概念

Table 用于展示列表数据,支持排序、分页、选择、展开等。

3.2 基础用法示例

<template>
  <el-table 
    :data="tableData" 
    stripe 
    border
    style="width: 100%"
    @selection-change="handleSelectionChange"
  >
    <!-- 多选列 -->
    <el-table-column type="selection" width="55" />
    
    <!-- 普通列 -->
    <el-table-column prop="name" label="姓名" width="120" />
    <el-table-column prop="age" label="年龄" width="80" />
    <el-table-column prop="address" label="地址" show-overflow-tooltip />
    
    <!-- 自定义列 -->
    <el-table-column label="状态" width="100">
      <template #default="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
    </el-table-column>
    
    <!-- 操作列 -->
    <el-table-column label="操作" width="180" fixed="right">
      <template #default="{ row }">
        <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
        <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script setup>
import { ref } from 'vue'

const tableData = ref([
  { id: 1, name: '张三', age: 28, address: '上海市浦东新区某某路100号', status: 1 },
  { id: 2, name: '李四', age: 32, address: '北京市朝阳区某某大街200号', status: 0 }
])

const handleSelectionChange = (selection) => {
  console.log('选中的行:', selection)
}

const handleEdit = (row) => {
  console.log('编辑', row)
}

const handleDelete = (row) => {
  console.log('删除', row)
}
</script>

说明要点:

  • :data 绑定数据数组,每一行是一个对象
  • prop 对应数据字段名,决定显示哪个字段
  • show-overflow-tooltip:内容过长时显示省略号并悬浮显示完整内容
  • #default="{ row }":插槽提供当前行数据

3.3 配置选型建议

场景 推荐配置
数据较多 heightmax-height 固定高度,出现纵向滚动
树形数据 使用 row-key + tree-props
需要合计 show-summary + summary-method
列宽不稳定 设置 widthmin-width,避免抖动
多选 type="selection" + @selection-change

3.4 常见踩坑

  • 表格数据不更新:确保 tableData 是响应式的(如 ref),修改后要触发更新
  • 树形表格:必须设置 row-key 为唯一字段(如 id
  • 固定列fixed="right"fixed="left" 时,注意右侧固定列写在最后

四、弹窗 Dialog:模态对话框

4.1 核心概念

Dialog 用于在保留当前页面的前提下,弹出一个模态层展示内容,常用于表单弹窗、详情、确认等。

4.2 基础用法示例

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <el-dialog
    v-model="dialogVisible"
    title="编辑用户"
    width="500px"
    :close-on-click-modal="false"
    :before-close="handleBeforeClose"
    @opened="handleOpened"
  >
    <!-- 弹窗内容 -->
    <el-form ref="formRef" :model="form" :rules="rules">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
    </el-form>
    
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, watch } from 'vue'

const dialogVisible = ref(false)
const formRef = ref()
const form = reactive({ username: '' })
const rules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] }

// 弹窗关闭前:可做二次确认、校验等
const handleBeforeClose = (done) => {
  // 简单示例:直接关闭
  done()
  // 如需确认:ElMessageBox.confirm('确定关闭?').then(() => done()).catch(() => {})
}

// 弹窗打开动画结束后
const handleOpened = () => {
  formRef.value?.clearValidate()
}

// 关闭时清空表单(按需)
watch(dialogVisible, (val) => {
  if (!val) {
    form.username = ''
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    // 提交逻辑
    dialogVisible.value = false
  } catch (e) {
    // 校验失败
  }
}
</script>

说明要点:

  • v-model="dialogVisible" 控制显示/隐藏
  • :close-on-click-modal="false":点击遮罩不关闭,避免误关
  • before-close:可做二次确认、阻止关闭
  • #footer:自定义底部按钮

4.3 常见配置选型

配置 说明 建议
destroy-on-close 关闭时销毁内容 表单弹窗建议开启,避免数据残留
close-on-click-modal 点击遮罩关闭 表单弹窗建议关闭
append-to-body 挂载到 body 有嵌套弹窗时建议开启

五、消息 Message 与 MessageBox

5.1 ElMessage:轻量提示

用于操作后的简单反馈(成功、失败、警告等),通常显示几秒后自动消失。

import { ElMessage } from 'element-plus'

// 成功
ElMessage.success('保存成功')

// 错误
ElMessage.error('保存失败,请重试')

// 警告
ElMessage.warning('请先填写必填项')

// 自定义
ElMessage({
  message: '操作成功',
  type: 'success',
  duration: 3000,
  showClose: true
})

5.2 ElMessageBox:确认与输入

用于需要用户确认或输入的场景,比 Dialog 更轻量。

import { ElMessageBox } from 'element-plus'

// 确认删除
const handleDelete = async (row) => {
  try {
    await ElMessageBox.confirm(
      `确定要删除「${row.name}」吗?`,
      '提示',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    // 用户点击确定
    await deleteApi(row.id)
    ElMessage.success('删除成功')
  } catch (e) {
    // 用户点击取消或关闭
  }
}

// 简单提示(类似 alert)
ElMessageBox.alert('操作完成', '提示')

5.3 选型建议

场景 用 Message 用 MessageBox
保存成功、失败提示
删除前确认
需要用户输入 ✅(prompt)
复杂表单、多内容 改用 Dialog

六、上传 Upload:文件上传

6.1 核心概念

Upload 支持自动上传和手动上传:自动上传是选完即传,手动上传是选完后由按钮触发上传。

6.2 自动上传(选完即传)

<template>
  <el-upload
    action="/api/upload"
    :headers="uploadHeaders"
    :on-success="handleSuccess"
    :on-error="handleError"
    :before-upload="beforeUpload"
  >
    <el-button type="primary">点击上传</el-button>
  </el-upload>
</template>

<script setup>
import { reactive } from 'vue'

// 请求头,常用于 Token
const uploadHeaders = reactive({
  Authorization: `Bearer ${localStorage.getItem('token')}`
})

// 上传前:校验格式、大小
const beforeUpload = (file) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传 JPG/PNG 格式')
    return false  // 阻止上传
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过 2MB')
    return false
  }
  return true
}

const handleSuccess = (response, file, fileList) => {
  ElMessage.success('上传成功')
  // response 一般为后端返回的 URL 等
}

const handleError = () => {
  ElMessage.error('上传失败')
}
</script>

6.3 手动上传(和表单一起提交)

<template>
  <el-form :model="form">
    <el-form-item label="附件">
      <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :limit="3"
        :on-exceed="handleExceed"
        :on-change="handleChange"
      >
        <el-button type="primary">选择文件</el-button>
      </el-upload>
    </el-form-item>
    <el-button @click="submitForm">提交表单(含文件)</el-button>
  </el-form>
</template>

<script setup>
import { ref } from 'vue'

const uploadRef = ref()
const form = ref({ files: [] })

// 手动上传时,选中的文件会进入 fileList,需要自己调用接口上传
const handleChange = (file, fileList) => {
  form.value.files = fileList
}

const handleExceed = () => {
  ElMessage.warning('最多上传 3 个文件')
}

const submitForm = async () => {
  const formData = new FormData()
  form.value.files.forEach(f => {
    formData.append('files', f.raw)
  })
  // 再 append 其他表单字段...
  // await uploadApi(formData)
}
</script>

说明要点:

  • :auto-upload="false" 关闭自动上传
  • on-change 拿到选中的文件列表
  • 手动上传时用 FormData 组装并调用自己的接口

6.4 常见踩坑

原因 处理
before-upload 返回 false 仍上传 理解错误 返回 falsePromise.reject() 会阻止上传
上传后列表不更新 未绑定 file-list v-model:file-list:file-list 绑定
跨域、Cookie 未带凭证 设置 :with-credentials="true"
需要 Token 接口要鉴权 通过 :headers 传入

七、小结

  • Form:用 :model + prop + rules,三者字段名一致
  • Tableprop 对数据字段,复杂展示用 #default 插槽
  • Dialog:用 v-model 控制显隐,表单弹窗建议 destroy-on-close
  • Message:轻量提示;MessageBox:确认、输入
  • Upload:自动上传用 action + 钩子;手动上传用 :auto-upload="false" + 自定义提交

按上述方式选型和编码,可以避开大部分常见坑。如果你希望我按某一块(比如 Form、Table、Upload)再单独细化成一篇更长的教程,可以说明一下侧重点(例如:复杂表单、动态表格、多图上传等)。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

JavaScript回归基本功之---类型判断--typeof篇

在学习JavaScript的过程中,我们不难发现一件事,类型判断在我们日常的开发中是最基础也最容易踩坑的知识点之一,我们有时会产生一些困惑

1. 明明被判断的那个数据是一个数组,但 typeof 却返回了 object

2. 使用 instanceof 判断基本类型时得到了一个 false

3. 不知道该用什么方法能够精准的判断一个值的类型

别急,本系列文章中我将带你重新复习一遍 JavaScript 中类型判断的各种方法,从 typeof 到 Object.prototype.toString.call()这些使用场景及底层实现原理,以及开发过程会遇到的难题,让你能够 回归基本功 ,再一次重新认识我们的老朋友 --JavaScript

一、学会类型判断首先我们来了解一下JS中的各种数据类型

1.1 基本类型(原始类型)

  • string : 字符串类型,如: "hello world"
  • number : 数字类型 "123" , "1.1"
  • boolean : 布尔类型 ,true 或 false,
  • undefined : 未定义类型,变量声明但为赋值时的类型
  • null : 空类型,表示一个空对象指针
  • symbol :符号类型,表示唯一且不可变的值
  • bigint: 大整数类型,用于表示超过 number 范围的整数

1.2 引用类型

  • Object :普通对象
  • Array:数组对象
  • Function:函数对象
  • Date:日期对象

1.3 基本类型与引用类型的区别

  • 基本类型存储在栈的内存中,值不可改变。
  • 引用类型存储在堆内存中,变量存储的是指向堆内存的引用地址
  • 基本类型的比较是值的比较,引用类型的比较是引用的比较

二、了解了各种数据类型后,让我们来了解一些常用的类型判断方法

js中的类型判断方法有:

  1. typeof
  2. instanceof
  3. Object.prototype.toString.call()
  4. constructor
  5. Array.isArray

三、本篇文章着重要讲的就是 typeof 这一类型判断方法,typeof 是 JavaScript 中最基础的类型判断操作符,传入一个数据后,它会返回一个表示该传入数据类型的字符串。

image.png

typeof的工作原理

1. typeof 的判断基于 JavaScript 引擎内部的类型标签,每个值在内存中都有一个类型标签:

0: undefined

1: null

2: boolean

3: number

4: string

5: symbol

6: bigint

7: object

8: function

这就能解释为什么 typeof(null) "object" ,因为在js中,null的类型标签为 0 ,于是返回了"object" ,这是一个远古的 bug ,为了向后兼容一直保留至今没有进行修改

2. 什么时候选择使用typeof,为什么?

  1. 对于基本类型进行判断时(null与array除外),如:string、number、boolean、undefined、symbol 或 bigint 类型时,优先使用 typeof。

image.png

原因:typeof 对这些基本类型的判断精准且可靠,语法简单,可读性高,且由于typeof 能直接访问 js 引擎内部的类型标签,是最快的类型判断方法

  1. 当你需要判断一个值是否为函数类型时

image.png

原因: typeof 可以精确识别所有函数类型 ,包括普通函数,箭头函数,生成器函数,异步函数和类,相比于instanceof等其它判断方法,typeof 不需要考虑原型链的问题,且语法更简洁

3. 当你需要检查一个变量是否已经被声明,避免抛出 ReferenceError时

image.png

原因:typeof 是唯一可以安全检查未声明变量的方法,不会抛出 ReferenceError ,这是 typeof 独有的特性,是其它的类型判断方法都不具备的

  1. 当遇到性能敏感的场景时,即需要进行频繁的类型判断且对性能要求高的时候时

image.png

原因:typeof 对这些基本类型的判断精准且可靠,语法简单,可读性高,且由于typeof 能直接访问 js 引擎内部的类型标签,是最快的类型判断方法

3. 什么时候我们不使用typeof

  1. 当需要对 null 类型进行判断时

image.png

  1. 当需要对不同类型的对象进行区分时

image.png

四、总结一下typeof该类型判断方法

  1. 优先用于基本类型判断(除了null 以外)
  2. 用于函数类型判断
  3. 用于检查变量是否已经声明
  4. 性能敏感场景时优先使用
  5. 避免在对复杂对象类型判断时使用(优先使用 instanceof 或 Object.prototype.toString.call())

事件循环底层原理:从 V8 引擎到浏览器实现

前阵子面试被问到:async/await 被编译成什么样了?

我答不上来。面试官说:你用了这么久 async/await,连它怎么实现的都不知道?

回来研究了 V8 源码和 ECMAScript 规范,才发现异步编程的水比想象中深得多。

一、async/await 不是语法糖

很多人说 async/await 是 Promise 的语法糖,严格来说不对。

它更接近 Generator + Promise 的自动执行器。V8 引擎会把 async 函数编译成状态机。

看这段代码:

async function foo() {
  console.log(1);
  await bar();
  console.log(2);
}

V8 编译后大致等价于:

function foo() {
  return new Promise(resolve => {
    const stateMachine = {
      state: 0,
      next(value) {
        switch (this.state) {
          case 0:
            console.log(1);
            this.state = 1;
            return Promise.resolve(bar()).then(v => this.next(v));
          case 1:
            console.log(2);
            resolve();
            return;
        }
      }
    };
    stateMachine.next();
  });
}

每个 await 把函数分成不同的状态,执行完一个 await 就切换到下一个状态。

这就是为什么 await 后面的代码会被放进微任务队列——因为它实际上是 .then() 的回调。

面试追问:为什么 async/await 比 Promise.then 性能好?

因为 V8 对 async/await 做了优化,减少了 Promise 对象的创建。手写 .then().then().then() 会创建多个 Promise 实例,而 async/await 内部可能只创建一个。

二、微任务队列的真实实现

网上都说"微任务队列",但实际上不止一个队列。

根据 HTML 规范,浏览器有:

  1. 微任务队列(Microtask Queue)

    • Promise.then/catch/finally
    • MutationObserver
    • queueMicrotask
  2. Job Queue(ECMAScript 层面)

    • Promise Jobs
    • 这是 ES 规范定义的,比 HTML 规范更底层

Node.js 更复杂:

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);

Node.js 输出:nextTickpromisetimeoutimmediate

Node.js 有多个队列:

  • nextTick Queue(优先级最高)
  • Promise Queue
  • Timer Queue(setTimeout/setInterval)
  • Check Queue(setImmediate)
  • Poll Queue(I/O)
  • Close Queue

这是一个很多人不知道的点:Node.js 和浏览器的事件循环实现完全不同。

浏览器:HTML 规范定义,一个微任务队列 + 一个宏任务队列

Node.js:libuv 实现,多个阶段,每个阶段有自己的队列

三、MutationObserver 为什么是微任务?

MutationObserver 用来监听 DOM 变化:

const observer = new MutationObserver(() => {
  console.log('DOM changed');
});
observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));
console.log('sync');

输出:syncDOM changed

DOM 变化后,回调不是立即执行,而是放进微任务队列。

为什么这样设计?

假设一个循环里改了 100 次 DOM:

for (let i = 0; i < 100; i++) {
  document.body.appendChild(document.createElement('div'));
}

如果每次 DOM 变化都触发回调,会执行 100 次。但如果放进微任务队列,100 次修改完成后只执行一次回调(批量处理)。

这是性能优化的经典设计。

四、Promise 的 then 为什么返回新 Promise?

看这道题:

const p = Promise.resolve(1);
const p2 = p.then(val => val + 1);

console.log(p === p2); // false

then 返回的是新 Promise,不是原来的。

为什么?

为了链式调用。如果返回同一个 Promise,链就会断掉:

Promise.resolve(1)
  .then(val => val + 1) // 返回新 Promise,resolve(2)
  .then(val => val + 2) // 拿到上一个 then 返回的 Promise
  .then(console.log);   // 4

每个 then 都返回新 Promise,形成一条链。

深层问题:then 返回的 Promise 什么时候 settle?

const p = new Promise(resolve => {
  setTimeout(() => resolve('done'), 1000);
});

const p2 = p.then(val => val + '!');

p2 不是立即 settle 的,而是等 p resolve 后,then 的回调执行完,p2 才 resolve。

这涉及 Promise Resolution Procedure(Promise 解决过程),是 ES 规范里最复杂的部分之一。

五、手写 Promise 的核心难点

网上手写 Promise 的文章很多,但大部分都漏了关键点。

1. then 的回调可以返回 Promise

Promise.resolve(1)
  .then(val => Promise.resolve(val + 1))
  .then(console.log); // 2

then 的回调如果返回 Promise,要等这个 Promise settle 后,外层 then 返回的 Promise 才 settle。

then(onFulfilled) {
  return new Promise((resolve, reject) => {
    const result = onFulfilled(this.value);
    // 关键:如果 result 是 Promise,要等它
    if (result instanceof Promise) {
      result.then(resolve, reject);
    } else {
      resolve(result);
    }
  });
}

2. then 可以被调用多次

const p = Promise.resolve(1);
p.then(console.log); // 1
p.then(console.log); // 1
p.then(console.log); // 1

每个 then 都要执行,所以要维护一个回调数组:

class MyPromise {
  constructor(executor) {
    this.callbacks = [];
    
    const resolve = value => {
      this.value = value;
      this.callbacks.forEach(cb => cb(value));
    };
    
    executor(resolve);
  }
  
  then(onFulfilled) {
    this.callbacks.push(onFulfilled);
  }
}

3. 错误穿透

Promise.reject('error')
  .then(val => val + 1)
  .then(val => val + 2)
  .catch(err => console.log(err)); // error

错误会沿着链传递,直到遇到 catch。

then(onFulfilled, onRejected) {
  return new Promise((resolve, reject) => {
    const handle = () => {
      if (this.state === 'fulfilled') {
        try {
          const result = onFulfilled(this.value);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      } else if (this.state === 'rejected') {
        if (onRejected) {
          try {
            const result = onRejected(this.reason);
            resolve(result);
          } catch (err) {
            reject(err);
          }
        } else {
          // 错误穿透:没有 onRejected 就继续传递
          reject(this.reason);
        }
      }
    };
    
    if (this.state) {
      // 已 settle,异步执行
      queueMicrotask(handle);
    } else {
      // pending,加入队列
      this.callbacks.push(handle);
    }
  });
}

六、性能优化:避免 Promise 地狱

问题:Promise 创建是有开销的

// 不好:创建大量不必要的 Promise
async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await Promise.resolve(item).then(x => x * 2);
    results.push(result);
  }
  return results;
}

// 好:直接处理
async function processItems(items) {
  return items.map(item => item * 2);
}

问题:微任务队列堆积

// 这段代码会导致微任务队列堆积,阻塞渲染
async function bad() {
  while (true) {
    await Promise.resolve();
    // 这个循环会永远执行,UI 会卡死
  }
}

微任务不会让出执行权给渲染,所以长时间运行的微任务会让页面卡顿。

解决方案:偶尔让出控制权

async function good() {
  while (true) {
    await new Promise(resolve => setTimeout(resolve, 0));
    // 让出控制权,让浏览器有机会渲染
  }
}

setTimeout(0) 会创建宏任务,每次宏任务之间浏览器有机会渲染。

七、冷门但重要的知识点

1. Promise 的构造函数是同步执行的

const p = new Promise(resolve => {
  console.log('executor');
  resolve(1);
});

console.log('after new');

// 输出:executor → after new

Promise 构造函数里的代码是同步执行的,只有 then 回调是异步的。

2. unhandledrejection 事件

Promise.reject('error');

window.addEventListener('unhandledrejection', event => {
  console.log('未处理的 rejection:', event.reason);
});

Promise 被 reject 但没有 catch,会触发这个事件。

Node.js 类似:

process.on('unhandledRejection', (reason, promise) => {
  console.log('未处理的 rejection:', reason);
});

3. Promise.finally 的特殊行为

Promise.resolve(1)
  .finally(() => {
    console.log('finally');
    return 2; // 返回值被忽略
  })
  .then(console.log); // 1,不是 2

finally 不改变传递的值,只执行副作用。

但如果 finally 返回 rejected Promise:

Promise.resolve(1)
  .finally(() => {
    return Promise.reject('error');
  })
  .then(
    val => console.log(val),
    err => console.log(err) // error
  );

4. async 函数的隐式 try-catch

async function foo() {
  throw new Error('fail');
}

foo();
// 错误被包装成 rejected Promise,不会抛到全局

等价于:

function foo() {
  return new Promise((resolve, reject) => {
    try {
      throw new Error('fail');
    } catch (err) {
      reject(err);
    }
  });
}

八、调试异步代码的技巧

1. Chrome DevTools 的 Async Stack Trace

勾选 Console 的 "Async" 选项,可以看到异步调用栈:

async function a() {
  await b();
}

async function b() {
  await c();
}

async function c() {
  console.log('here');
  throw new Error('fail');
}

a();

不开启 Async Stack Trace,调用栈只有 c。

开启后,可以看到 a → b → c 的完整调用链。

2. Node.js 的 --async-stack-traces

node --async-stack-traces app.js

Node.js 12+ 支持,让异步错误堆栈更清晰。

总结

异步编程的难点不在 API,而在于:

  1. 理解底层机制 — V8 如何编译 async/await,事件循环如何调度
  2. 知道边界情况 — Node.js 和浏览器的差异,微任务堆积问题
  3. 能写出正确实现 — Promise 的 resolve procedure,then 的链式调用

面试时,面试官问你"async/await 怎么实现的",不是让你背答案,而是看你是否真的理解原理。


参考资料:

从 URL 输入到页面展示:一场跨越进程与协议的“装修”大戏

摘要:春招季将至,“从 URL 输入到页面展示”是前端与后端面试中出场率高达 80% 的“八股文”之王。很多候选人习惯堆砌知识点,却难以串联成线。本文将摒弃枯燥的列表式回答,以“装修房子”为喻,结合浏览器多进程架构、操作系统原理、网络协议栈及 DNS 解析机制,为你构建一套清晰、深刻且通俗易懂的知识体系。这不仅是一次面试通关指南,更是一次对计算机底层逻辑的深度巡礼。


引言:不仅仅是“回车”那么简单

当你在浏览器地址栏输入 www.geekbang.org 并按下回车键的那一刻,看似平静的操作背后,实则上演着一场横跨应用层、网络层、传输层乃至操作系统内核的宏大交响乐。

在面试中,如果你只回答“DNS 解析 -> TCP 握手 -> 发送请求 -> 渲染”,考官可能会觉得你只是背了书。真正的高手,能够像项目经理一样,清晰地描述出浏览器主进程如何调度、网络进程如何采购、渲染进程如何在沙箱中施工,以及底层操作系统如何分配资源。

今天,我们就把整个页面加载过程比作一次**“装修房子”**,带你深入这场技术大戏的幕后。


第一幕:项目经理接单(浏览器主进程)

在现代浏览器(如 Chrome)的多进程架构中,浏览器主进程(Browser Process) 扮演着“项目经理”的角色。它不直接干活(不渲染页面,不下载数据),但它负责指挥、调度、验收以及处理用户交互。

1.1 接收指令与导航启动

当你输入 URL 时,主进程首先介入:

  • URL 补全与预处理:如果你只输入了关键词,主进程会将其交给默认搜索引擎;如果输入的是域名,它会尝试补全 http://https://
  • 历史管理:主进程会将此次导航记录压入“后退栈”(Backward Stack),并清空“前进栈”(Forward Stack)。这就是为什么刷新后无法“前进”的原因。
  • 状态反馈:界面立刻显示 Loading 图标,告知用户“工程已启动”。

1.2 安全拦截:beforeunload

在正式动工前,主进程会检查当前页面是否有未保存的数据。它会通知旧的渲染进程触发 beforeunload 事件。如果页面返回了拦截信号,浏览器会弹出原生确认框:“您确定要离开吗?未保存的修改可能会丢失。”这是防止用户误操作导致数据丢失的最后一道防线。

一旦确认无误,主进程正式将 URL 转发给网络进程,准备开始“采购材料”。


第二幕:采购员出动与地址查询(网络进程 & DNS)

网络进程(Network Process) 是浏览器的“采购员 + 物流司机”。它的核心任务是搞定网络连接,把服务器上的资源(HTML、CSS、图片等)拉取回来。但在此之前,它必须知道“仓库”在哪里。

2.1 DNS 解析:分布式的全球电话簿

计算机之间通信靠的是 IP 地址,而不是人类可读的域名。因此,第一步是将域名转换为 IP。DNS(Domain Name System)是一个巨大的分布式数据库。

解析过程遵循“就近原则”,层层递进:

  1. 浏览器缓存:Chrome 内部有独立的 DNS 缓存(可通过 chrome://net-internals/#dns 查看)。这是最快的路径。

  2. 操作系统缓存:如果浏览器没找到,会查询操作系统的 DNS 缓存。这里涉及一个特殊的文件——Hosts 文件(Windows 位于 C:\Windows\System32\drivers\etc\hosts)。开发者常在此配置本地域名映射(如 127.0.0.1 www.douyin.com)进行本地测试。

    • 面试题深挖:为什么修改 Hosts 文件后有时不生效?因为浏览器有自己的缓存机制,甚至可能复用了之前的 TCP 长连接(Keep-Alive)。此时需清除浏览器 DNS 缓存或重启浏览器。
  3. 本地 DNS 服务器(LDNS) :通常由 ISP(如抚州电信)提供。

  4. 根域名服务器与顶级域名服务器:如果 LDNS 也没有,请求会逐级向上,经过根服务器(.)、顶级域服务器(.org),最终找到权威域名服务器,拿到目标 IP。

负载均衡的奥秘
DNS 返回的往往不是一个 IP,而是一组 IP 数组。这背后是负载均衡技术在起作用。就像“媒婆”介绍对象,DNS 会根据你的地域(地域特性机房)、服务器负载情况(轮询算法 Round Robin),将你引导至离你最近、压力最小的服务器集群(Nginx 反向代理)。

2.2 建立连接:三次握手

拿到 IP 后,网络进程需要与服务器建立可靠的传输通道。这就用到了 TCP 协议

  • 为什么是 TCP? 网页内容要求完整无误,不能像视频流(UDP)那样允许丢包。TCP 提供了可靠性保证。

  • 三次握手

    1. 客户端发送 SYN:我想和你聊天。
    2. 服务器回复 SYN + ACK:好的,我也想和你聊,我准备好了。
    3. 客户端回复 ACK:收到,那我们开始吧。

    这三次握手确保了双方都具备发送和接收能力,并同步了初始序列号,为后续数据传输打下基础。

2.3 发送请求与接收响应

连接建立后,网络进程发送 HTTP 请求:

  • 请求行GET /index.html HTTP/1.1
  • 请求头:携带 Cookie(会话信息)、Authorization(JWT 令牌)、User-Agent 等关键信息。

服务器处理后返回响应:

  • 状态码

    • 200 OK:成功。
    • 301/302:重定向。例如访问 http://time.geekbang.org 会被强制跳转到 https:// 版本。
    • 404:资源未找到。
    • 500:服务器内部错误。
  • Content-Type:告诉浏览器接下来收到的数据是什么。如果是 text/html,浏览器就知道要准备渲染了;如果是 image/jpeg,则直接下载展示。


第三幕:沙箱中的施工队(渲染进程)

当网络进程拿到 HTML 数据流后,它不能直接渲染,而是通过 IPC(进程间通信) 将数据交给渲染进程(Renderer Process)

3.1 为什么要用沙箱?

渲染进程是浏览器的“施工队”,负责画图、砌墙(解析 DOM/CSS)、刷漆(合成图层)。但它运行在**安全沙箱(Sandbox)**中。

  • 最小权限原则:沙箱不是操作系统送的,而是浏览器利用 OS 底层机制(Windows Token、Linux Seccomp-BPF、macOS Seatbelt)主动构建的“牢房”。
  • 限制:渲染进程不能直接读写磁盘、不能直接访问网络、不能调用敏感系统 API。
  • 意义:即使渲染进程加载了恶意代码被黑客攻破,黑客也仅仅控制了“牢房”里的内容,无法窃取用户硬盘数据或控制系统。所有的网络请求和文件读写,都必须通过 IPC 请求主进程或网络进程代劳。

3.2 提交文档与解析

  1. 提交文档:渲染进程向主进程发送“确认提交”消息。主进程收到后,移除旧文档,更新 UI 状态。
  2. 构建 DOM 树:渲染进程接收 HTML 字节流,将其解析为 DOM 树(Document Object Model)。这是页面的骨架。
  3. 构建 CSSOM 树:同时,解析 CSS 文件,生成 CSSOM 树(CSS Object Model)。这是页面的样式规则。
  4. 生成渲染树(Render Tree) :将 DOM 和 CSSOM 合并,剔除不可见节点(如 display: none),形成渲染树。
  5. 布局(Layout) :计算每个节点在屏幕上的确切位置和大小。
  6. 绘制(Paint) :将渲染树转换为像素,生成位图。
  7. 合成(Composite) :如果有多个图层(如视频、固定定位元素),GPU 会将它们合成为最终的图像展示给用户。

在这个过程中,如果遇到 <script> 标签,解析可能会暂停(除非标记为 asyncdefer),去加载并执行 JavaScript。JS 可以修改 DOM 和 CSSOM,导致重新布局(Reflow)和重绘(Repaint)。


第四幕:底层基石与协议深析

在上述流程中,有几个核心的计算机基础概念支撑着整个大厦。

4.1 操作系统:进程与线程

  • 进程(Process) :资源分配的最小单元。浏览器的每个标签页通常对应一个独立的渲染进程,互不干扰。一个标签页崩溃不会影响其他标签页。
  • 线程(Thread) :CPU 调度的最小单元。一个进程内包含多个线程,如主线程(负责 JS 执行、DOM 操作)、合成线程(负责图层合成)、网络线程等。
  • 进程间通信(IPC) :由于进程隔离,主进程、网络进程、渲染进程之间必须通过 IPC 传递消息。这是多进程架构的开销所在,也是安全性的保障。

4.2 OSI 七层模型与 TCP/IP

虽然实际应用中常用 TCP/IP 四层模型,但理解 OSI 七层有助于厘清职责:

  1. 物理层:比特流传输(光纤、网线)。

  2. 数据链路层:MAC 地址寻址,帧传输。

  3. 网络层:IP 地址寻址,路由选择(路由器工作在此层)。

  4. 传输层:TCP/UDP 协议,端到端连接,流量控制,差错重传。

    • 丢包重传:TCP 通过序号和确认应答机制,确保数据包丢失后能重发,保证文件不损坏。
  5. 会话层:管理会话(如保持登录状态)。

  6. 表示层:数据格式转换(加密、压缩)。

  7. 应用层:HTTP、DNS 等协议,直接面向用户。

4.3 正向代理 vs 反向代理

  • 正向代理(代购) :客户端主动配置代理,代表客户端去访问服务器。服务器不知道真实客户端是谁,只知道代理。场景:翻墙、突破内网限制。
  • 反向代理(前台) :服务端部署代理,代表服务器接收请求。客户端不知道真实服务器是谁,只知道代理。场景:负载均衡、隐藏后端架构、SSL 卸载。Nginx 是最典型的反向代理服务器。

结语:从知识点到知识体系

回顾整个过程,从用户在地址栏敲下第一个字符,到页面绚丽地展现在眼前:

  1. 浏览器主进程像项目经理一样统筹全局,管理历史、处理交互、调度子进程。
  2. 网络进程像精明的采购员,通过复杂的 DNS 层级找到目标,利用 TCP 三次握手建立可靠通道,并通过负载均衡策略获取最优资源。
  3. 渲染进程像被关在沙箱中的专业施工队,在严格的安全限制下,将 HTML/CSS 代码一步步转化为像素图像。
  4. 底层的操作系统提供了进程隔离、线程调度和 IPC 机制,保障了系统的稳定与安全。
  5. 网络协议栈则像精密的交通规则,确保数据包在全球网络中准确、有序地抵达。

在春招面试中,当你能够用这样一条清晰的逻辑线,配合生动的比喻,将操作系统、计算机网络、浏览器原理串联起来时,你就不再是一个只会背诵“八股文”的考生,而是一个具备系统观的工程师。

记住,技术不仅仅是知识点的堆砌,更是万物互联的逻辑之美。 祝各位在春招中旗开得胜,Offer 多多!


作者注:本文基于 Chromium 架构及通用网络原理编写。实际浏览器实现可能因版本不同略有差异,但核心思想一致。希望这篇文章能成为你面试路上的坚实护城河。

从 0 手写 Promise:拆解 Promise 链式调用的实现原理

手写promise思路

1. promise本质

本质promise就是一个状态机 + 回调队列 + 链式调用规则

核心就3件事:

  1. 状态管理
  2. 回调存储执行
  3. then 链式调用

2. 第一步: 实现 Promise状态机

promise有三种状态

pending   初始状态
fulfilled 成功
rejected  失败

状态转换规则:

pending -> fulfilled
pending -> rejected

注意:

状态一旦改变就不能再变

所以需要:

this.status = "pending"
this.value = undefined
this.reason = undefined

3. 第二步: 实现resolve / reject

Promise 构造函数会接受一个 executor

new Promise((resolve,reject)=>{})

这个函数:

  • 立即执行
  • 会收到resolvereject

实现逻辑:

// value: resolve的值
// reason: reject的值, 失败原因

const resolve = (value)=>{
  if(this.status !== "pending") return
  this.status = "fulfilled"
  this.value = value
}

const reject = (reason)=>{
  if(this.status !== "pending") return
  this.status = "rejected"
  this.reason = reason
}

注意两点:

  1. 状态只能改一次
  2. 保留value / reason

4. 第三步: 实现then (核心)

Promise必须支持:

promise.then(onFulfilled, onRejected)

then 有三个行为:


4.1 情况1: Promise 已经fulfilled

立即执行 onFulfilled

但注意:

必须放到微任务

queueMicrotask(()=>{
  onFulfilled(this.value)
})

4.2 情况2: Promise 已经rejected

执行onRejected

queueMicrotask(()=>{
  onRejected(this.reason)
})

4.3 情况3: Promise 还在 pending

这时候问题来了:

resolve 可能未来才执行

所以: 要把回调存起来

this.onFulfilledCallbacks = []
this.onRejectedCallbacks = []

then 里:

// 保证回调可以正常使用
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
onRejected =
    typeof onRejected === "function"
    ? onRejected
: (r) => {
    throw r;
};


this.onFulfilledCallbacks.push(() => {
    queueMicrotask(() => {
        onFulfilled(this.value)
    });
});

this.onRejectedCallbacks.push(() => {
    queueMicrotask(() => {
        onFulfilled(this.value)
    });
});

等到 resolve / rejected时

this.onFulfilledCallbacks.forEach(fn=>fn());
或
this.onRejectedCallbacks.forEach(fn=>fn());

5. 第四步: then必须返回新的Promise

规范规定:

then 一定要返回一个新的 Promise
const promise2 = new MyPromise(...)
return promise2

因为Promise需要支持链式调用

promise
  .then()
  .then()
  .then()

6. 第五步: then 返回值决定下一个Promise

最难的部分

const x = onFulfilled(this.value)

然后:

promise2 的状态 = x 决定

规则

6.1 情况1: x是普通值

resolve(x)

例:

then(()=>100)

6.2 情况2: x是 promise

then(()=>Promise)

那就:

promise 跟随这个 Promise最后的执行状态

例:

then(()=>new Promise(...))

6.3 情况3: x是 thenable

thenable:

const obj = { then: function(){} };
// 或
function fn(){
    // ....
}

fn.prototype.then = function(){
    // ....
}

也要按照 Promise处理


7. 第六步:reslovePromise 算法

所以需要写一个 统一解析函数

resolvePromise(promise2,x,resolve,reject)

作用:

解析x的类型

步骤:

7.1 防止循环引用

if(promise2 === x){
 reject(new TypeError("循环引用"))
}

例:

p.then(() => p) // 会死循环

7.2 如果 x 是对象或函数

typeof x === 'object' || typeof x === 'function'

说明可能是 thenable。


7.3 取 then

then = x.then

7.4 如果then是函数

当做Promise处理:

then.call(x, resolve, reject)

使用call的原因是防止里面有this调用

const obj = {
    value: 111,
    then(){
        console.log(this.value);
    }
}

7.5 如果then 不是函数

说明只是普通对象, 直接resolve:

resolve(x)

7.6 called锁

Promise规范规定

resolve / reject 只能调用一次

所以:

let called = false;

8. 第七步: 为什么要微任务

Promise 规范规定:

then 回调必须是异步执行的

所以必须:

queueMicrotask: 传入一个回调函数, 将回调函数中的代码加入到微任务队列中执行
// https://developer.mozilla.org/zh-CN/docs/Web/API/Window/queueMicrotask

而不是同步:

例:

Promise.resolve(1)
console.log(2)

// 2
// 1

9. 完整代码:

实现顺序

1 实现 Promise 状态
2 实现 resolve / reject
3 executor 立即执行
4 then 方法
5 then 返回新 Promise
6 回调队列
7 resolvePromise 解析返回值
8 微任务

完整结构其实只有 三块

class MyPromise
    constructor
    then

resolvePromise
class MyPromise {
  constructor(executor) {
    // 初始状态
    this.status = "pending";
    // 成功的值
    this.value = undefined;
    // 失败的原因
    this.reason = undefined;

    // 存储成功和失败的回调函数
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      // 保证状态不可逆
      if (this.status !== "pending") return;
      this.status = "fulfilled";
      this.value = value;
      // 执行成功的回调函数
      this.onFulfilledCallbacks.forEach((callback) => callback());
    };

    const reject = (reason) => {
      // 保证状态不可逆
      if (this.status !== "pending") return;
      this.status = "rejected";
      this.reason = reason;
      // 执行失败的回调函数
      this.onRejectedCallbacks.forEach((callback) => callback());
    };

    // 执行 executor,并捕获异常
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (r) => {
            throw r;
          };

    // .then需要可以返回一个新的promise
    // 并且promise的状态是按照回调函数的结果来做的
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === "fulfilled") {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.status === "rejected") {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (error) {
            reject(error);
          }
        });
      } else if (this.status === "pending") {
        // 将回调函数保存起来,等到状态改变的时候再执行
        onFulfilled &&
          this.onFulfilledCallbacks.push(() => {
            queueMicrotask(() => {
              try {
                const x = onFulfilled(this.value);
                resolvePromise(promise2, x, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          });
        onRejected &&
          this.onRejectedCallbacks.push(() => {
            queueMicrotask(() => {
              try {
                const x = onRejected(this.reason);
                resolvePromise(promise2, x, resolve, reject);
              } catch (error) {
                reject(error);
              }
            });
          });
      }
    });
    return promise2;
  }

  /**
   * 1. 首先需要判断x是否和promise2相等, 如果相等将会造成循环引用, 需要reject出去一个error
   * 2. 如果 x 不是对象 或 函数, 则说明是普通值, resolve出去即可
   * 3. 如果是对象/函数, 需要看属性/原型上是否有 `then` 函数, 只要有就当成 promise 来处理
   * 4. 如果是对象/函数, 但没有`then`函数 或 `then`不是函数, 则直接resolve出去即可
   *
   * @param {*} promise2 将要返回的promise实例
   * @param {*} x 回调函数的返回值
   * @param {*} resolve
   * @param {*} reject
   * @returns
   */
}

function resolvePromise(promise2, x, resolve, reject) {
  if (x == promise2) {
    reject(new TypeError("Chaining cycle detected for promise"));
    return;
  }

  // 因为null也是object, 所以组合判断下
  if ((typeof x === "object" && x !== null) || typeof x == "function") {
    // 到这里说明是对象/函数

    let then;
    // Promise 只能 resolve 或 reject 一次, 做个锁
    let called = false;
    // 获取 then放到 try...catch中, 防止找不到then属性报错
    try {
      then = x.then;

      // 如果是个函数, 调用它
      // called做锁, 避免多次调用resolve 或 reject
      // 并且递归调用resolvePromise, 处理then返回的值
      if (typeof then === "function") {
        then.call(
          x,
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          (r) => {
            // reject就不需要再递归调用了
            if (called) return;
            called = true;
            reject(r);
          },
        );
      } else {
        // then不是函数就直接 resolve出去
        resolve(x);
      }
    } catch (error) {
      // 这边也要判断一下,如果called已经被调用过了, 就不再调用
      if (called) return;
      called = true;
      reject(error);
    }
  } else {
    resolve(x);
  }
}

深度解构JavaScript:作用域链与闭包的内存全景图

深度解构JavaScript:作用域链与闭包的内存全景图

引言:看见不可见的执行世界

JavaScript 常常被误解为一门简单的脚本语言,但在其看似随性的语法背后,隐藏着一套严谨而精密的执行机制。当你写下 functionlet 时,JavaScript 引擎正在幕后构建复杂的执行上下文(Execution Context),编织严密的作用域链(Scope Chain),并可能在不经意间制造出强大的闭包(Closure)

很多开发者在面对“变量为什么找不到”、“闭包为什么内存泄漏”或者“this 指向为何诡异”等问题时感到困惑,根本原因在于缺乏对这套底层机制的直观认知。

本文将摒弃枯燥的定义堆砌,结合核心的代码案例与可视化的内存模型图,带您像调试器一样“透视”JavaScript 的运行过程。我们将通过七张关键的原理图,层层剥开作用域与闭包的神秘面纱。


第一章:执行的基石——执行上下文模型

1.1 代码运行的“容器”

在 JavaScript 中,任何代码的执行都发生在执行上下文中。你可以把它想象成一个容器,里面装着代码运行所需的所有信息。这个容器并非铁板一块,而是被精细地划分为两个核心区域:

  1. 变量环境(Variable Environment):主要存储由 var 声明的变量和函数声明。
  2. 词法环境(Lexical Environment):主要存储由 letconst 声明的变量以及代码块级作用域信息。

此外,每个上下文还持有一个指向外部环境的引用(Outer),这是形成作用域链的关键。

325d94b0befca7bc834520d10ad7a1d9.jpg

图解 1:如上图所示,一个标准的执行上下文(如 setName 函数)内部清晰地分为了“变量环境”和“词法环境”。注意右侧红色的 foo(closure),它暗示了内部函数可能形成的闭包,保留了对外部变量的引用。这是理解后续所有复杂逻辑的基石。

1.2 全局上下文的初始化

当脚本加载时,首先建立的是全局执行上下文。此时,全局变量被登记在册,而 outer 指针指向 null,因为它处于作用域链的顶端。


第二章:作用的层级——词法作用域链

2.1 嵌套的世界

JavaScript 采用词法作用域,这意味着函数的作用域在代码**编写(定义)**时就已经确定,而非运行时。当函数嵌套时,就形成了作用域链。

让我们看一个经典的嵌套模型:

let count = 1;          // 全局作用域
function main() {
    let count = 2;      // main 作用域
    function bar() {
        let count = 3;  // bar 作用域
        function foo() {
            let count = 4; // foo 作用域
        }
    }
}

在这个结构中,foo 可以访问 barmain 甚至全局的 count,但查找顺序是严格的“由内向外”。

cf22f379419ba33500ddeedda82f29ca.jpg

图解 2:这张图生动地展示了作用域的嵌套关系。下方的箭头链条(词法作用域链)清晰地表明:foo 的作用域指向 barbar 指向 main,最终指向全局。无论函数在哪里被调用,这条链在定义时就已经固化。


第三章:实战深潜——调用栈与变量查找迷雾

理论总是清晰的,但现实代码往往充满了陷阱。让我们进入一个复杂的实战场景,看看引擎如何在调用栈中处理变量遮蔽(Shadowing)和作用域查找。

3.1 复杂的变量查找案例

请仔细阅读以下代码,尝试判断 console.log(test) 的输出结果:

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3; // 块级作用域遮蔽
        bar();        // 在这里调用 bar
    }
}

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome浏览器";
        console.log(test); // 问题核心:test 是多少?
    }
}

var myName = "极客时间";
let test = 1; // 全局 test
foo();

直觉误区:很多人认为 bar 是在 foo 内部调用的,所以应该能访问 foo 里的 test(值是 2 或 3)。 真相:输出结果是 1

为什么?因为 bar 函数是在全局作用域定义的。根据词法作用域规则,bar 的作用域链直接指向全局,它与 foo 的执行上下文毫无关系,哪怕它是被 foo 调用的。

5ac9a8e8ca249b0d0bb1a948a2d697aa.jpg

图解 3:这张图是理解本案例的“钥匙”。

  • 左侧展示了当前的调用栈:顶层是 bar,中间是 foo,底部是全局。
  • 请注意红色的虚线箭头(作用域链指向):barouter 指针直接跳过了 foo,指向了全局执行上下文(标记⑤)。
  • 因此,当 bar 查找 test 时,它在自身环境和全局环境中找到了 test=1(标记④),而完全无视了 foo 环境中的 test=2test=3

3.2 常见的认知陷阱

为了进一步巩固这个概念,我们看一个更简化的例子,这也是面试题中的常客:

var myName = "极客时间";

function foo() {
    var myName = "极客邦";
    bar(); 
}

function bar() {
    console.log(myName); // 这里打印什么?
}

foo();

d0fb219c234722b2498d69dbd3ef0bf9.jpg

图解 4:图中的气泡提出了灵魂拷问:“myName 的值应该使用全局执行上下文的,还是使用 foo 函数执行上下文的?” 答案显而易见:全局。因为 bar 定义在全局,它的作用域链只连接全局。调用栈的压入(foo 调用 bar)不会改变 bar 的作用域链指向。


第四章:闭包的魔力——留住时间的变量

4.1 什么是闭包?

当函数返回后,通常其执行上下文会被销毁,局部变量随之消失。但是,如果返回的函数引用了外部函数的变量,JavaScript 引擎就会“网开一面”,将这些变量保留在内存中。这就是闭包

4.2 闭包的内存驻留

看这段代码:

function setName() {
    var myName = "极客时间";
    let test1 = 1;
    
    function foo() {
        console.log(myName);
    }
    
    return foo; // 返回内部函数
}

var closureFunc = setName(); // setName 执行完毕
closureFunc(); // 依然能访问 myName

setName 执行结束后,按理说它的上下文应该出栈。但因为 foo 被返回并赋值给了 closureFunc,且 foo 依赖 myName,引擎必须保留 setName 的变量环境。

5f7c408f09b3634f02407b8eba774e13.jpg

图解 5:注意看图中,调用栈(Call Stack)中已经没有了 setName 的身影。但是,一个标记为 foo(closure) 的对象独立存在于内存中,它紧紧抱着 myName = "极客时间"test1 = 1。这就是闭包的本质:函数与其词法环境的组合

4.3 综合场景:对象方法与闭包

闭包常用于创建私有变量或对象方法。考虑以下场景:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    let test2 = 2;
    
    // 返回一个包含方法的对象
    return {
        innerBar: function() {
            console.log(myName);
        }
    };
}

var obj = foo();
obj.innerBar(); // 输出 "极客时间"

016cde03c3179056885990fc5682083b.jpg

图解 6:这张图展示了 foo 函数执行上下文的细节,变量环境中不仅有基本类型,还有函数对象 innerBar。当 foo 返回后,这些变量并没有立即消失,而是成为了闭包的一部分。


第五章:终极视角——指针的指向艺术

最后,我们需要从宏观视角审视整个内存模型。无论是普通函数调用,还是闭包,核心都在于那个看不见的 outer 指针。

  • 如果函数在全局定义,outer 指向全局上下文。
  • 如果函数在另一个函数内定义,outer 指向外部函数的上下文。
  • 无论函数在哪里被调用,outer 指针在函数创建那一刻就已定格。

6452bdc165bf3f0a043e0bbdc74746c1.jpg

图解 7:这张图用红色虚线明确标注了“指向全局执行上下文”。我们可以看到,barfoo 虽然可能在不同的调用栈层级,但它们各自的 outer 指针都诚实地指向了它们定义时所在的环境。这解释了为什么作用域链不会被动态的调用栈所迷惑。


结语:从“知其然”到“知其所以然”

通过这七张图谱的深度解析,我们重新梳理了 JavaScript 的核心机制:

  1. 执行上下文是舞台,区分了 varlet/const 的存放位置。
  2. 作用域链是导航图,它在代码定义时生成,决定了变量查找的路径,与调用位置无关。
  3. 闭包是时光机,它让函数能够跨越生命周期,继续访问定义时的环境变量。

理解这些,你就不再是在盲目地试错代码,而是在脑海中构建出了一幅清晰的内存地图。当下一次遇到作用域问题或闭包陷阱时,请在脑中画出那张“调用栈”与“红色虚线箭头”的图,答案自会浮现。

🚀《JavaScript 灵魂深处:从 V8 引擎的“双轨并行”看执行上下文的演进之路》


引言

“如果你只懂 varlet 的语法区别,那你只看到了冰山一角。真正的魔法,藏在 V8 引擎执行上下文的双轨存储架构里。”

在 JavaScript 的发展历程中,有一个著名的“历史遗留问题”——变量提升(Hoisting)。它曾让无数开发者抓狂,也让 JS 背上了“设计缺陷”的骂名。然而,随着 ES6 的诞生,JavaScript 通过一种巧妙的**“双轨并行”策略**,不仅完美兼容了旧代码,还引入了现代化的块级作用域。

今天,我们将结合您提供的完整文档(readme.md8.js),深入 V8 引擎的底层机制,剖析执行上下文、作用域链、变量环境 vs 词法环境的奥秘。特别是针对 7.js 中的经典案例,我们将借助两张精美的示意图,为您揭开 JavaScript 变量管理的终极真相。


📜 第一章:历史的回响——为什么 JavaScript 会有“变量提升”?

1.1 一个“KPI 项目”的意外走红

正如 readme.md 中所言,JavaScript 最初只是 Netscape 为了浏览器竞争而快速推出的“KPI 项目”。设计周期极短,目标简单:给静态页面加点动态效果

在那个年代,复杂的面向对象特性(如 class, constructor, private 等)并不是首要任务。为了追求最快、最简单的实现方案,设计师做出了两个关键决定:

  1. 不支持块级作用域if, for, while 等代码块 {} 内部声明的变量,直接暴露在外层。
  2. 引入变量提升:将所有变量声明统一“抬升”到函数顶部,简化编译器的实现逻辑。

1.2 变量提升的“双刃剑”

让我们看看 4.js 中的经典案例:

showName();
console.log(myname);
var myname = "张三";
function showName() {
    console.log("函数 showName 执行了");
}

这段代码之所以能运行(不报错),是因为在编译阶段,JS 引擎做了如下处理:

// 编译后的伪代码
function showName() { ... } // 函数声明提升
var myname;                 // 变量声明提升,初始化为 undefined

showName();                 // 输出:函数 showName 执行了
console.log(myname);        // 输出:undefined (因为赋值语句还没执行)
myname = "张三";            // 执行赋值

⚠️ 缺陷暴露

  • 变量容易被意外覆盖(见 2.js 中的 var name 遮蔽全局变量)。
  • 本应销毁的变量因提升而长期驻留内存。
  • 代码行为与直觉不符,增加调试难度。

🌍 第二章:ES6 的救赎——“双轨并行”的巧妙设计

面对历史包袱,ES6 没有选择“推倒重来”(那样会破坏海量旧代码),而是采取了一种兼容性极强的解决方案:在执行上下文中实行“双轨并行”存储机制

2.1 执行上下文的双核架构

当 JavaScript 引擎执行一个函数时,会创建一个执行上下文(Execution Context)。在 ES6 及以后,这个上下文被划分为两个独立但协同工作的区域:

轨道 名称 管理对象 特性 对应关键字
轨道一:变量环境 (Variable Environment) 传统轨道 var 声明的变量 函数作用域、变量提升、可重复声明 var
轨道二:词法环境 (Lexical Environment) 现代轨道 let, const 声明的变量 块级作用域、暂时性死区 (TDZ)、不可重复声明 let, const

💡 核心思想

  • var 继续留在变量环境轨道,享受“提升特权”,保证旧代码正常运行。
  • let/const 进入全新的词法环境轨道,支持块级作用域,杜绝提升带来的隐患。
  • 两条轨道在同一个执行上下文中并行存在,互不干扰却又协同工作。

2.2 词法环境的“栈结构”秘密

readme.md 中提到:“块级作用域中通过 let/const 声明的变量,会被放在词法环境的一个单独的区域中,维护了一个小型栈结构。

这意味着:

  • 每进入一个块级作用域 {},引擎就在词法环境中压入一个新的“帧”(Frame)。
  • 变量查找时,优先从栈顶(当前块)开始。
  • 块执行完毕,该帧弹出,内部变量立即销毁,外界无法访问。

这正是 6.jsfor(let i=0;...) 循环后 i 未定义的原因,也是 8.js 中“暂时性死区”产生的根源。


🔍 第三章:实战演练——从 1.js8.js 的全景解析

现在,让我们遍历所有文件,逐一验证上述理论。

🧪 案例 1:作用域链的基础(1.js & 5.js

// 1.js
let name = "流萤";
function showName(){
    console.log(name); // 流萤
    if(true){
        let name = "大厂的苗子" // 块级变量,不影响外层
    }
}
showName();

// 5.js
var globalVar='我是全局变量';
function myFunction() {
    var localVar = '我是局部变量';
    console.log(globalVar); // 可访问
    console.log(localVar);  // 可访问
}
myFunction();
console.log(localVar); // ❌ ReferenceError: localVar is not defined

解析

  • 1.js 展示了 let 的块级隔离性:块内 name 不影响块外。
  • 5.js 展示了函数作用域的边界:localVar 仅在函数内有效。

🧪 案例 2:变量提升的陷阱(2.js & 4.js

// 2.js
var name = '张三';
function showName() {
    console.log(name); // undefined (局部变量遮蔽全局)
    if(false) {
        var name = '李四'; // 声明提升,赋值不执行
    }
    console.log(name); // undefined
}
showName();

解析

  • var name 在函数内被提升,导致全局 name 被遮蔽。
  • 即使 if(false) 不执行,name 仍存在于局部作用域,值为 undefined

🧪 案例 3:块级作用域的胜利(6.js & 8.js

// 6.js
function foo() {
    for(let i=0;i<7;i++) { }
    console.log(i); // ❌ ReferenceError: i is not defined
}
foo();

// 8.js
let name = '流萤';
{
    console.log(name); // ✅ 输出 "流萤" (访问外层)
    let othername = '大厂的苗子';
}
// 若取消注释下方代码,将触发 TDZ
// {
//     console.log(name); // ❌ ReferenceError
//     let name = '大厂的苗子';
// }

解析

  • 6.js 证明 let 循环变量仅限块内。
  • 8.js 展示两种情况:
    • 块内无同名 let → 访问外层变量。
    • 块内有同名 let → 触发暂时性死区 (TDZ),禁止在声明前访问。

🖼️ 第四章:深度图解——7.js 与执行上下文的视觉化

现在,我们来到本文的高潮部分:7.js 的代码与您提供的两张示意图。这两张图完美诠释了“双轨并行”机制在实际运行中的状态变化。

📄 代码回顾

function foo() {
    var a = 1;
    let b = 2;
    {
        let b = 3;
        var c = 4;
        let d = 5;
        console.log(a); // 1
        console.log(b); // 3
    }
    console.log(b); // 2
    console.log(c); // 4
    console.log(d); // ❌ ReferenceError
}
foo();

🖼️ 图一:函数初始化状态(预编译阶段)

image.png

此时,函数刚被调用,引擎完成“预编译”,双轨开始运作:

  • 左轨:变量环境

    • a = 1var a 已声明并赋值。
    • c = undefinedvar c 被提升到函数顶(变量环境顶层),但尚未赋值。
  • 右轨:词法环境

    • 外层帧:b = 2let b 已初始化。
    • 内层帧(块级):b = undefined, d = undefined ← 已绑定但未初始化(处于 TDZ)。

📌 关键点var c 虽在块内代码中书写,却出现在变量环境的顶层;而 let b/d 则严格限制在词法环境的块级帧中。这就是双轨并行的直观体现。

🖼️ 图二:执行到块内 console.log 时的状态

image.png

程序执行流进入块内,并完成赋值操作,双轨状态发生动态变化:

  • 左轨:变量环境

    • a = 1 ← 保持不变。
    • c = 4var c = 4 已执行,赋值成功!注意它依然位于函数级的变量环境中。
  • 右轨:词法环境

    • 外层帧:b = 2 ← 保留,暂时被遮蔽。
    • 内层帧(当前激活):
      • b = 3 ← 块内 let b = 3 已赋值,遮蔽了外层帧的 b
      • d = 5 ← 已赋值。

🔄 查找规则(双轨协同)

  • console.log(a) → 引擎查询变量环境 → 找到 1
  • console.log(b) → 引擎查询词法环境,从栈顶(内层帧)开始 → 找到 3(忽略外层 b=2)。

🎬 完整执行流程表

步骤 代码 输出/结果 原因分析
1 console.log(a) 1 访问变量环境中的 a
2 console.log(b) 3 访问词法环境栈顶的 b(块内遮蔽外层)
3 块结束 块级词法环境帧弹出,b=3, d=5 销毁
4 console.log(b) 2 恢复访问词法环境外层的 b
5 console.log(c) 4 访问变量环境中的 c(函数级有效)
6 console.log(d) ❌ Error d 位于已销毁的块级词法环境帧中,外界不可见

🛠️ 第五章:开发者指南——如何驾驭这套机制?

✅ 最佳实践

  1. 优先使用 letconst:利用词法环境轨道的块级特性,避免 var 的提升和函数作用域陷阱。
  2. 明确作用域边界:用 {} 包裹逻辑块,防止变量泄露到不必要的范围。
  3. 警惕 TDZ:不要在 let/const 声明前访问变量,理解这是词法环境的保护机制。
  4. 利用 DevTools 调试:观察 Scope 面板,你会清晰地看到“Variable”和“Local/Lexical”两个不同的区域。

常见误区

  • ❌ “let 也会提升” → 错!let 有“绑定提升”,但存在 TDZ,在声明前不可访问。
  • ❌ “块级作用域是新的作用域类型” → 不准确!它是词法环境中的“栈帧”,而非独立的作用域类型。
  • ❌ “var 在块内无效” → 错!var 无视块级,始终提升至变量环境的函数顶层。

🌟 结语:理解执行上下文,就是理解 JavaScript 的灵魂

readme.md 的历史回顾,到 7.js 的深度图解,我们走完了一段从“设计缺陷”到“优雅兼容”的旅程。JavaScript 通过变量环境与词法环境的“双轨并行”架构,成功实现了新旧语法的完美融合:既尊重了历史,又拥抱了未来。

下次当你写下 letvar 时,请记住:

你不仅仅是在声明一个变量,你是在指挥 V8 引擎在两条不同的轨道上存储数据。

掌握这套机制,你将不再畏惧任何作用域谜题,写出更健壮、更高效的代码。


📚 附录:核心概念速查表

概念 描述 示例
变量提升 var 声明移至函数顶 var x; x=1;
暂时性死区 (TDZ) let/const 声明前不可访问 console.log(y); let y=1; → Error
作用域链 变量查找路径:当前 → 外层 → 全局 内层 b 遮蔽外层 b
词法环境 存储 let/const,支持块级栈结构 { let a=1; }
变量环境 存储 var,函数级作用域 function(){ var b; }
双轨并行 执行上下文中同时存在变量环境和词法环境 var 走左轨,let 走右轨

🎉 恭喜! 你现在已掌握 JavaScript 执行上下文的核心精髓。无论是面试、工作还是开源贡献,这套知识都将是你最强大的武器。

Vue 3 新标准:<script setup> 核心特性、宏命令与避坑指南

<script setup> 是 Vue 3.2 引入的一种编译时语法糖,旨在简化 Composition API 的使用。它并不是一个新的功能,而是对原有 <script> 中使用 Composition API 写法的一种语法优化

简单来说,它让你用更少的代码更直观的写法来实现同样的功能,同时在性能上也有显著提升。


1. 核心对比:传统写法 vs <script setup>

❌ 传统写法 (Vue 3.2 之前)

你需要手动导入 API,定义数据/方法,并显式 return 给模板使用。

<script>
import { ref, reactive } from 'vue'

export default {
  components: { MyComponent }, // 需手动注册组件
  props: ['title'],           // 需手动定义 props
  
  setup(props, { emit }) {
    const count = ref(0)
    const user = reactive({ name: 'Alice' })
    
    function increment() {
      count.value++
    }

    // ⚠️ 必须手动 return,模板才能访问
    return {
      count,
      user,
      increment,
      title // props 也要 return
    }
  }
}
</script>

✅ <script setup> 写法

无需 export default,无需 return,顶层变量自动暴露。

<script setup>
import { ref, reactive } from 'vue'
import MyComponent from './MyComponent.vue' // ✅ 自动注册组件

// ✅ 直接定义 props (编译后自动生成)
defineProps(['title'])

// ✅ 直接定义 emits
const emit = defineEmits(['change'])

// 顶层变量自动暴露给模板,无需 return
const count = ref(0)
const user = reactive({ name: 'Alice' })

function increment() {
  count.value++
  emit('change', count.value)
}
</script>

2. <script setup> 的五大核心好处

1. 代码更简洁(少写样板代码)

  • 无需 export default:组件选项直接在标签内定义。
  • 无需 return:在 <script setup> 中声明的所有顶层变量(reffunctionimport 的组件等)自动暴露给模板使用。这减少了大量的重复代码和出错可能。
  • 组件自动注册:导入的组件(如 import MyComp from ...)可以直接在模板中使用 <MyComp />,无需在 components 选项中注册。

2. 更好的 TypeScript 支持

  • 类型推导更精准:由于不需要通过 return 对象来暴露变量,TS 可以直接推断顶层变量的类型,无需复杂的泛型声明。
  • Props/Emits 类型化:配合 defineProps<Type>() 和 defineEmits<Type>(),可以获得完美的类型提示和校验,而传统写法需要繁琐的 withDefaults 或接口定义。

3. 更高的运行时性能

  • 编译优化<script setup> 的组件会被编译为一个匿名函数,作为 setup() 钩子的实现。
  • 避免代理开销:传统写法中,setup 返回的对象会被 Vue 包装成代理(Proxy)以便模板访问。而 <script setup> 中的绑定是通过闭包直接访问的,省去了创建代理对象的开销,访问速度更快。
  • Tree-shaking:未使用的代码更容易被打包工具剔除。

4. 逻辑更清晰

  • 消除“割裂感” :在传统写法中,定义的变量和模板中使用的变量之间隔着一个 return 块,阅读时需要上下跳转。<script setup> 让代码从上到下线性执行,定义即使用。
  • 专注于逻辑:开发者可以更专注于业务逻辑本身,而不是 Vue 的样板结构。

5. 原生支持宏(Macros)

提供了一些编译时宏,无需导入即可直接使用:

  • defineProps: 声明 props。
  • defineEmits: 声明 emits。
  • defineExpose: 显式暴露属性给父组件(默认情况下 <script setup> 组件实例是关闭的,即父组件无法通过 ref 访问其内部属性,除非使用此宏)。
  • defineOptions: (Vue 3.3+) 声明组件选项(如 nameinheritAttrs)。
  • withDefaults: 为 defineProps 设置默认值。

3. 特殊用法详解

A. 定义 Props 和 Emits

<script setup>
// 接收 props,具有类型推导
const props = defineProps({
  msg: String,
  count: { type: Number, required: true }
})

// 定义 emits
const emit = defineEmits(['update:count', 'submit'])

function update() {
  emit('update:count', props.count + 1)
}
</script>

B. 暴露给父组件 (defineExpose)

默认情况下,父组件通过 ref 获取子组件实例时,无法访问 <script setup> 内部的变量。如果需要暴露,必须显式声明:

<!-- Child.vue -->
<script setup>
import { ref } from 'vue'

const secret = 'hidden'
const publicData = ref(100)

function publicMethod() {
  console.log('called')
}

// 只暴露 publicData 和 publicMethod
defineExpose({
  publicData,
  publicMethod
})
</script>

C. 配合 TypeScript

<script setup lang="ts">
interface User {
  id: number
  name: string
}

// 泛型支持
const props = defineProps<{
  userId: number
  list: User[]
}>()

// 默认值
withDefaults(defineProps<{
  msg?: string
  labels?: string[]
}>(), {
  msg: 'Hello',
  labels: () => ['new'] // 对象/数组默认值需用工厂函数
})
</script>

4. 总结:为什么它是“最佳实践”?

特性 传统<script>+setup() <script setup>
代码量 多 (需 export, return, register) 极少 (声明即用)
性能 正常 (有代理开销) 更高 (闭包访问,无代理)
TS 支持 良好 (但需额外类型声明) 完美 (原生推导)
组件注册 手动 自动
推荐度 ⭐⭐ (兼容旧项目) ⭐⭐⭐⭐⭐ (新项目首选)

结论
除非你需要维护非常古老的 Vue 3 早期代码,否则在所有新的 Vue 3 项目中,都应该无条件使用 <script setup> 。它是 Vue 团队官方推荐的默认写法,代表了 Vue 未来的发展方向。

深入 React19 Diff 算法

一、为什么 React 需要 Diff 算法

早期前端如果直接操作 DOM:

div.innerHTML = newHTML

问题:

  1. DOM 操作极其昂贵
  2. 无法知道哪些节点真的变化
  3. 频繁重绘

如果使用传统树 diff 算法

复杂度:O(n^3)

浏览器根本无法接受。

因此 React 提出 对 diff 算法做了各种优化,最终复杂度 O(n)。

React 的核心约束:

约束 1:不同类型节点一定不同

<div />
<span />

直接删除重建。

约束 2:同层节点对比

React 不会跨层比较

只比较:

oldChildren
newChildren

约束 3:key 用来稳定节点

{list.map(item => (
  <Item key={item.id}/>
))}

key 让 React 知道:

这个节点是不是同一个

二、架构层面看 diff

React19 的更新流程:

setState
   ↓
scheduleUpdateOnFiber
   ↓
render阶段
   ↓
beginWork
   ↓
reconcileChildren  ← diff发生在这里
   ↓
completeWork
   ↓
commit阶段
   ↓
DOM mutation

源码位置:react-reconciler/src/ReactChildFiber.js

  • 核心函数:reconcileChildren、reconcileChildFibers
  • 创建 Fiber:createFiberFromElement
  • 复用 Fiber:useFiber
  • 处理数组:reconcileChildrenArray

React Diff 的核心逻辑:

oldFiberTree(current fiber node)
        ↓
newReactElementTree(jsx)
        ↓
生成 newFiberTree(wip fiber node)
        ↓
打 flags(等到 commit 再处理)

Fiber 结构(简化):

type Fiber = {
  tag: WorkTag
  key: null | string
  type: any

  stateNode: any

  return: Fiber
  child: Fiber
  sibling: Fiber

  pendingProps
  memoizedProps

  alternate: Fiber

  flags
}

1 alternate

current fiber 和 workInProgress fiber 形成 双缓存树

2 flags

记录需要执行的操作:Placement | Update | Deletion

commit 阶段使用。

三、最简单的 Diff:单节点

如果 DOM 更新后,还是一个节点的话,那么就采用单节点 diff。

function reconcileSingleElement(
  returnFiber,
  currentFirstChild,
  element
)

逻辑:

Step1:寻找 key 相同节点 child.key === element.key(如果都没有设置 key,那么都为 null,也属于相同)

Step2:type 是否相同 child.type === element.type

Step3:复用 Fiber useFiber(oldFiber)(如果内部文本不同,直接将内部文本节点更新即可)

否则:删除旧节点,创建新节点

四、数组 Diff

如果 DOM 更新后,为多个节点的话,就采用多节点 diff(数组 diff) 。

真正复杂的是:reconcileChildrenArray。React 团队认为,对节点更新操作的情况往往要多于对节点“新增、删除、移动”的操作。因此,源码逻辑分为 两轮遍历。

第一轮:从左到右对比

React 会先 顺序对比,目的就是希望尽可能的复用单节点。

  • 如果新旧子节点的 key 和 type 都相同,直接复用
  • 如果新旧子节点的 key 相同,但是 type 不相同,这时会根据 ReactElement 来生成一个全新的 Fiber,旧的 Fiber 被放入到 deletions 数组里面,之后统一删除。但是此时遍历并不会终止
  • 如果新旧子节点的 key 和 type 都不相同,结束遍历

旧:

A B C D

新:

A B E

流程(同时对比 key 和 type):

A = A ✔
B = B ✔
C ≠ E ✘

停止。

这一步叫:快速路径(Fast Path)

源码:

while (oldFiber && newIdx < newChildren.length)

复杂度:O(n)。

第二轮:构建 key map,遍历新 children

如果第一轮遍历被提前终止了,那么意味着有新的 React 元素或者旧的 FiberNode 没有遍历完,此时就会采用第二轮遍历。

情况一:有旧节点剩余,放入 deletions 数组中之后删掉。

情况二:有新节点出现,创建新的 fiber。

情况三:

  • 新旧子节点都有剩余:会将旧节点剩余的 FiberNode 节点放入一个 map 里面,遍历剩余的新节点,然后从 map 中去寻找能够复用的 FiberNode 节点,如果能够找到就复用。(移动的情况)
  • 如果不能找到就新增。然后如果剩余新节点都遍历完了,map 结构中还有剩余的 Fiber 节点,就将这些 Fiber 节点添加到 deletions 数组里面,之后统一做删除操作

React19 这里没有使用像 Vue3 那样的双端 diff 算法,具体原因 React 直接写在了源码内:

由于双端 diff 需要向前查找节点,但每个 FiberNode 节点上都没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,只能从前往后遍历,而不能反过来,因此该算法无法通过双端搜索来进行优化。

React 想看下现在用这种方式能走多远,如果这种方式不理想,以后再考虑实现双端 diff。React 认为对于列表反转和需要进行双端搜索的场景是少见的,所以在这一版的实现中,先不做额外的优化。

五、diff 和调度的关系

React Diff 不是一次完成。

因为 React 有:

Concurrent Rendering

Fiber 可以:

中断
恢复
优先级调度

例如:

render 10000 节点

React 可以:

render 200
yield
render 200

Diff 过程变成:

可中断计算

六、完整 Diff 流程图

beginWork
   │
   │
   ▼
reconcileChildren
   │
   │
   ▼
reconcileChildFibers
   │
   │
   ├── 单节点 diff
   │
   ├── 文本节点 diff
   │
   └── 数组 diff
           │
           │
           ├── 第一轮:顺序比较
           │
           ├── 第二轮:构建 map
           │
           └── 第三轮:查找复用 / 新建
                    │
                    │
                    ▼
              打 flags
                    │
                    ▼
              completeWork
                    │
                    ▼
                commit

前端设计模式

前言

前端开发中的设计模式就像是“代码模版”,它们提供了一套经过验证的解决方案,用于解决常见的设计问题,是为了解决开发中反复出现的特定问题而总结出的最佳实践。接下来会介绍一些常见的前端设计模式,并提供示例代码。

常见设计模式

1. 模块模式(Module Pattern)

模块模式是一种常见的设计模式,用于创建具有私有和公共成员的模块。它通过闭包来实现数据封装,避免了全局变量的污染。

const MyModule = (function () {
  // 私有变量
  let privateVariable = "I am private";
  // 私有函数
  function privateFunction() {
    console.log(privateVariable);
  }
  // 公共接口
  return {
    publicMethod: function () {
      privateFunction();
    },
  };
})();
MyModule.publicMethod(); // 输出: I am private

2. 单例模式(Singleton Pattern)

单例模式确保一个类只有一个实例,并提供一个全局访问点。它常用于管理全局状态或资源。

const Singleton = (function () {
  let instance;
  function createInstance() {
    return { name: "I am the only instance" };
  }
  return {
    getInstance: function () {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    },
  };
})();
const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();
console.log(instance1 === instance2); // 输出: true

3. 观察者模式(Observer Pattern)

观察者模式是一种设计模式,其中一个对象(称为“主题(Subject)”)维护一系列依赖于它的对象(称为“观察者(Observer)”),并在状态发生变化时通知它们。它常用于事件处理系统。

class Subject {
  constructor() {
    this.observers = [];
  }
  subscribe(observer) {
    this.observers.push(observer);
  }
  unsubscribe(observer) {
    this.observers = this.observers.filter((obs) => obs !== observer);
  }
  notify(data) {
    this.observers.forEach((observer) => observer.update(data));
  }
}
class Observer {
  update(data) {
    console.log("Observer received data:", data);
  }
}
const subject = new Subject();
const observer1 = new Observer();
const observer2 = new Observer();
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify("Hello Observers!"); // 输出: Observer received data: Hello Observers!

4. 发布-订阅模式(Publish-Subscribe Pattern)

发布-订阅模式是一种设计模式,其中发布者(Publisher)发布事件,订阅者(Subscriber)订阅事件,并在事件发生时接收通知。它常用于解耦组件之间的通信。

class PubSub {
  constructor() {
    this.events = {};
  }
  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  publish(event, data) {
    if (this.events[event]) {
      this.events[event].forEach((callback) => callback(data));
    }
  }
}
const pubSub = new PubSub();
pubSub.subscribe("event1", (data) =>
  console.log("Subscriber 1 received:", data),
);
pubSub.subscribe("event1", (data) =>
  console.log("Subscriber 2 received:", data),
);
pubSub.publish("event1", "Hello PubSub!");
// 输出: Subscriber 1 received: Hello PubSub!
// 输出: Subscriber 2 received: Hello PubSub!

5. 工厂模式(Factory Pattern)

工厂模式是一种创建对象的设计模式,它提供一个接口用于创建对象,但允许子类决定实例化哪个类。它常用于需要根据条件创建不同类型对象的场景。

class Car {
  constructor(model) {
    this.model = model;
  }
}
class CarFactory {
  createCar(model) {
    return new Car(model);
  }
}
const factory = new CarFactory();
const car1 = factory.createCar("Tesla Model S");
console.log(car1.model); // 输出: Tesla Model S

6. 策略模式(Strategy Pattern)

策略模式是一种设计模式,它定义了一系列算法,并将每个算法封装起来,使它们可以互换。它常用于需要在运行时选择算法的场景。

class Strategy {
  execute() {
    throw new Error("Strategy#execute must be overridden");
  }
}
class ConcreteStrategyA extends Strategy {
  execute() {
    console.log("Executing strategy A");
  }
}
class ConcreteStrategyB extends Strategy {
  execute() {
    console.log("Executing strategy B");
  }
}
class Context {
  constructor(strategy) {
    this.strategy = strategy;
  }
  setStrategy(strategy) {
    this.strategy = strategy;
  }
  executeStrategy() {
    this.strategy.execute();
  }
}
const context = new Context(new ConcreteStrategyA());
context.executeStrategy(); // 输出: Executing strategy A
context.setStrategy(new ConcreteStrategyB());
context.executeStrategy(); // 输出: Executing strategy B

7. 装饰器模式(Decorator Pattern)

装饰器模式是一种设计模式,它允许向一个对象添加新的功能,而不改变其结构。它常用于需要动态地为对象添加功能的场景。

function decorator(func) {
  return function (...args) {
    console.log("Before executing the function");
    const result = func(...args);
    console.log("After executing the function");
    return result;
  };
}
function originalFunction() {
  console.log("Executing original function");
}
const decoratedFunction = decorator(originalFunction);
decoratedFunction();
// 输出: Before executing the function
// 输出: Executing original function
// 输出: After executing the function

单例模式实现方式

单例模式可以通过多种方式实现,以下是两种常见的实现方式:

1. 使用闭包实现单例模式

const Singleton = (function () {
  let instance;
  function createInstance() {
    return { name: "I am the only instance" };
  }
  return {
    getInstance() {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    },
  };
})();

const instance1 = Singleton.getInstance();
const instance2 = Singleton.getInstance();
console.log(instance1 === instance2); // 输出: true

2. 使用类实现单例模式

class Singleton {
  constructor(name) {
    if (Singleton.instance) {
      return Singleton.instance;
    }
    this.name = name;
    this.startTime = new Date();
    Singleton.instance = this;
  }

  static getInstance(name) {
    if (!this.instance) {
      this.instance = new Singleton(name);
    }
    return this.instance;
  }

  showTime() {
    console.log(`Instance created at: ${this.startTime}`);
  }
}

const instance1 = new Singleton("First Instance");
const instance2 = new Singleton("Second Instance");

console.log(instance1 === instance2); // 输出: true
console.log(instance1.name); // 输出: First Instance
console.log(instance2.name); // 输出: First Instance(因为 instance2 实际上是 instance1 的引用,instance2并没有被创建)

为什么单例模式用 class 的方式实现更合适?

  1. 命名空间与组织性:使用class可以将属性和方法组织在一个命名空间中,可以清晰地看到单例的结构和行为,而闭包方式可能会导致代码分散,难以维护。

  2. 延迟初始化:使用class的getInstance方法可以在有需要的时候才创建实例,而如果直接定义一个全局对象const singleton = new Singleton(),则在模块加载时就会创建实例,可能会导致不必要的资源浪费。

  3. 继承和扩展性:使用class支持extends,这样可以让单例可以拥有父类的通用能力,比如EventEmitter等,而闭包方式则不太方便实现原型链继承。

单例模式如何防止他人强行创建实例?

在Java或C++中,可以把构造函数设为private来防止外部直接创建实例,但在JavaScript中没有private构造函数的概念。JS虽然提供了private class fields(以#开头的字段)来实现私有属性,但它们并不能完全阻止外部通过new操作符创建实例,而我们可以通过直接抛出错误来强制限制。

class StrictSingleton {
    static #instance = null;

    constructor() {
        if(StrictSingleton.#instance) {
            throw new Error("请使用 StrictSingleton.getInstance() 获取实例");
        }
        StrictSingleton.#instance = this;
    }

    static getInstance() {
        if(!StrictSingleton.#instance) {
            StrictSingleton.#instance = new StrictSingleton();
        }

        return StrictSingleton.#instance;
    }
}

发布订阅模式和观察者模式的区别

发布订阅模式和观察者模式看起来都是在某一对象发生变化时通知其他对象,但它们之间实际上有一些区别,接下来会结合代码示例来说明它们的区别。

1. 核心对象

  • 观察者模式:核心对象是“主题(Subject)”,它维护一系列依赖于它的对象(称为“观察者(Observer)”),并在状态发生变化时通知它们。

  • 发布订阅模式:核心对象是“事件中心(Event Bus)”,它负责管理事件的订阅和发布,发布者(Publisher)发布事件,订阅者(Subscriber)订阅事件,并在事件发生时接收通知,在上面的代码中,PubSub类就是事件中心。

2. 耦合度

  • 观察者模式:主题和观察者之间存在直接的依赖关系,主题需要知道观察者的存在,并且在状态变化时直接调用观察者的方法。

  • 发布订阅模式:发布者和订阅者之间没有直接的依赖关系,它们通过事件中心进行通信,发布者只需要发布事件,而订阅者只需要订阅事件,彼此之间是完全解耦的。

3. 通信方式

  • 观察者模式:主题直接调用观察者的方法进行通信。

  • 发布订阅模式:发布者通过事件中心发布事件,订阅者通过事件中心接收事件进行通信,由事件中心决定通知的时间和方式。

4. 应用场景

  • 观察者模式:在基础库的内部逻辑中比较常见,比如Vue.js中的响应式系统就是基于观察者模式实现的。

ref 函数接受一个初始值,并返回一个包含该值的响应式对象。其核心原理如下:

function ref(initialValue) {
  const r = {
    get value() {
      // 依赖收集
      track(r, 'value')
      return initialValue
    },
    set value(newValue) {
      initialValue = newValue
      // 触发更新
      trigger(r, 'value')
    }
  }
  return r
}

reactive 函数接受一个对象,并返回该对象的响应式代理。其核心原理如下:

function reactive(target) {
  return new Proxy(target, {
    get(target, key) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key)
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value)
      // 触发更新
      trigger(target, key)
      return result
    }
  })
}
  • 发布订阅模式:在跨组件通信、事件驱动的系统中比较常见,比如前端框架中的事件总线就是基于发布订阅模式实现的。如Vuex和Redux中的状态管理也是基于发布订阅模式实现的。

装饰器模式的优缺及使用场景

装饰器模式的核心理念是在不改变对象代码,不使用继承的情况下,动态地为对象添加额外的功能。

1. 与传统类继承的对比

  • 灵活性:装饰器模式允许在运行时动态地为对象添加功能,而不需要在编译时就确定对象的行为。这使得代码更加灵活,易于维护和扩展。

  • 遵循开闭原则:装饰器模式遵循开闭原则,即对扩展开放,对修改关闭。可以在不修改现有代码的情况下,通过添加新的装饰器来扩展对象的功能。

  • 避免了类的数量爆炸:使用类继承来组合各种功能,随着功能排列组合的增加,派生类的数量会呈指数级增长,而装饰器模式通过组合装饰器来实现功能的扩展,只需要少量装饰类即可完成复杂的组合。

  • 职责分离:装饰器模式将对象的功能分解为独立的装饰器,每个装饰器只负责一个特定的功能,使得代码更加模块化,易于维护和测试。

2. 适用场景

  • 动态添加功能:当需要在运行时动态地为对象添加功能时,装饰器模式是一个很好的选择,如在一个支付系统中,基础功能是“支付”。你可以根据用户需求,动态地叠加上“短信通知”、“积分奖励”、“多币种转换”等功能。

  • 处理多种功能的组合:如果一个对象有5个独立的功能扩展,使用继承可能需要实现2的五次方个子类,而装饰器模式只需要5个装饰器类。

  • 无法通过继承扩展的类:当类被定义为final或者无法修改时,装饰器模式可以通过组合的方式来扩展其功能,而不需要修改原有类的代码。

3. 具体示例

class Coffee {
  cost() {
    return 5;
  }
}

class MilkDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }
  cost() {
    return this.coffee.cost() + 2; // 加牛奶的费用
  }
}

class SugarDecorator {
  constructor(coffee) {
    this.coffee = coffee;
  }
  cost() {
    return this.coffee.cost() + 1; // 加糖的费用
  }
}

const myCoffee = new SugarDecorator(new MilkDecorator(new Coffee()));
console.log(myCoffee.cost()); // 输出: 8 (5 + 2 + 1)

在这个例子中,Coffee是基础类,MilkDecorator和SugarDecorator是装饰器类,通过组合的方式为Coffee对象动态地添加了牛奶和糖的功能,而不需要修改Coffee类的代码。

4. 装饰器的缺点

  • 增加了系统的复杂性:装饰器模式引入了更多的类和对象,可能会使系统变得更加复杂,尤其是在装饰器层次较深时,可能会导致代码难以理解和维护。

  • 调试困难:由于装饰器模式涉及多个对象的组合,调试时可能需要跟踪多个对象的状态和行为,增加了调试的难度。

  • 性能开销:每个装饰器都需要创建一个新的对象,这可能会导致性能开销,尤其是在装饰器层次较深时,可能会影响系统的性能。

5. 一些扩展

  • Python中的装饰器:Python内置了装饰器语法,可以直接使用@符号来装饰函数或类,极大地简化了装饰器的使用。

def decorator(func):
    def wrapper(*args, **kwargs):
        print("Before executing the function")
        result = func(*args, **kwargs)
        print("After executing the function")
        return result
    return wrapper

@decorator
def original_function():
    print("Executing original function")

original_function()

# 输出:
# Before executing the function
# Executing original function
# After executing the function
  • Java中的装饰器:Java中可以通过接口和抽象类来实现装饰器模式,常见的例子是Java IO库中的InputStream和OutputStream类。
public interface Coffee {
    double cost();
}

public class SimpleCoffee implements Coffee {
    @Override
    public double cost() {
        return 5;
    }
}

public class MilkDecorator implements Coffee {
    private Coffee coffee;

    public MilkDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public double cost() {
        return coffee.cost() + 2; // 加牛奶的费用
    }
}

public class SugarDecorator implements Coffee {
    private Coffee coffee;

    public SugarDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public double cost() {
        return coffee.cost() + 1; // 加糖的费用
    }
}

public class Main {
    public static void main(String[] args) {
        Coffee myCoffee = new SugarDecorator(new MilkDecorator(new SimpleCoffee()));
        System.out.println(myCoffee.cost()); // 输出: 8 (5 + 2 + 1)
    }
}

结语

设计模式实在太多了,以上只是提到了几个比较重要和常见的设计模式,个人感觉不用过于苛求每个设计模式都要熟练掌握,了解它们的核心思想和基本适用场景就足够了,在实际开发中,根据具体问题选择合适的设计模式来解决问题才是最重要的。设计模式是工具,不是目的,过度使用设计模式可能会导致代码过于复杂,反而不利于维护和理解,所以在使用设计模式时要根据实际情况进行权衡和选择。

面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了

一道"经典"的 Event Loop 面试题,背了八股文的你以为稳了,结果一运行却被 ESM 背刺。本文带你深挖 Node.js 事件循环中一个 99% 的人都不知道的坑:同样的代码,CJS 和 ESM 输出顺序竟然不一样。

前言

如果你准备过前端/Node.js 面试,大概率刷到过这类题目:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

你自信地写下答案:3 → 2 → 6 → 4 → 5 → 1

面试官微微一笑,说:"没问题,回去等通知吧。"

你心满意足地回家,顺手建了个项目想验证一下,npm init,改了下 package.jsonnode index.js 一跑——

3
4
2
5
6
1

你揉了揉眼睛,又跑了一遍。没错,4 跑到 2 前面去了

你开始怀疑人生:是我八股文背错了?还是 Node.js 出 bug 了?

都不是。是 ESM 在背后捅了你一刀。


一、先复习:Node.js 事件循环到底怎么转的

在搞清楚这个坑之前,我们得先把 Node.js 的事件循环机制理清楚。这部分是基础,老手可以快速跳过,但建议还是扫一遍,因为后面的分析会用到。

1.1 事件循环的六个阶段

Node.js 的事件循环基于 libuv,分为以下几个阶段,每一轮循环(tick)按顺序执行:

   ┌───────────────────────────┐
┌─>│           timers          │  ← setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  ← 系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  ← 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  ← I/O 回调(fs.readFile 等)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  ← setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  ← socket.on('close') 等
│  └───────────────────────────┘

1.2 微任务的位置

在 Node.js v11 之后,每个阶段之间以及每个宏任务执行之后,都会清空微任务队列。微任务分为两类:

队列 API 优先级
nextTick 队列 process.nextTick()
微任务队列 Promise.then() / queueMicrotask()

也就是说,在传统认知中(CJS 模式下),优先级排序是:

同步代码 > process.nextTick > Promise 微任务 > 宏任务(timers / check / ...)

这也是为什么你面试时会回答 3 → 2 → 6 → 4 → 5 → 1 的原因——nextTick 队列会在 Promise 微任务之前被清空

1.3 微任务递归清空

一个重要的细节:nextTick 队列在清空时,如果回调中又注册了新的 nextTick,会在同一轮继续清空,直到队列为空。Promise 微任务也是同理。整个流程:

1. 清空 nextTick 队列(递归)
2. 清空 Promise 微任务队列(递归)
3. 如果上述步骤产生了新的 nextTick 或微任务,回到 1
4. 全部清空后,进入下一个事件循环阶段

到这里,一切都很"标准",也符合绝大多数八股文的描述。


二、验证"标准答案"——CJS 模式

我们先在 CJS 模式下跑一下,证明八股文没有骗你。

创建一个 test-cjs.js(注意:不要在 package.json 里设置 "type": "module"):

// test-cjs.js(CommonJS 模式)
setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

执行:

node test-cjs.js

输出:

3
2
6
4
5
1

完美,和八股文一模一样。我们来走一遍流程:

第一步:执行同步代码

  • setImmediate(cb) → 注册到 check 阶段队列
  • process.nextTick(cb) → 注册到 nextTick 队列(打印 2)
  • console.log(3)输出 3
  • Promise.resolve().then(cb) → 注册到微任务队列(打印 4)

此时队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:清空 nextTick 队列

  • 执行 cb(2)输出 2,并注册 nextTick(cb(6))
  • 队列没清空,继续 → 执行 cb(6)输出 6

第三步:清空微任务队列

  • 执行 cb(4)输出 4,并注册 nextTick(cb(5))
  • 微任务执行完毕,检查 nextTick → 执行 cb(5)输出 5

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 2 → 6 → 4 → 5 → 1


三、翻车现场——ESM 模式

现在,我们做一件"无害"的事情——在 package.json 里加上一行:

{
  "type": "module"
}

代码一个字都不改,再跑一次:

node index.js

输出:

3
4
2
5
6
1

4 跑到 2 前面去了!Promise 微任务比 nextTick 先执行了!

等等,不是说好了 nextTick > Promise 吗?

这并不是 Node.js 的 bug,这是 ESM 模块系统的执行机制 导致的必然结果。


四、为什么 ESM 会改变执行顺序?

这是本文的核心。要理解这个差异,必须搞清楚 CJS 和 ESM 在 Node.js 中的执行方式有什么本质不同。

4.1 CJS 的执行方式

在 CJS 模式下,Node.js 的执行流程大致是:

1. 同步加载模块(require 是同步的)
2. 同步执行模块代码
3. 模块代码执行完毕,进入事件循环
4. 事件循环开始前,先清空 nextTick 队列,再清空微任务队列

关键点:模块代码的执行是在 Node.js 的"主执行流"中完成的。执行完毕后,Node.js 通过自己的调度逻辑先处理 nextTick,再处理 Promise 微任务。

4.2 ESM 的执行方式

ESM 就完全不一样了。根据 ECMAScript 规范,ES Module 的加载和求值是 异步 的:

1. 解析模块依赖图(静态分析 import/export)
2. 异步加载所有模块
3. 按照依赖顺序对模块进行求值(evaluate)

关键来了:ESM 模块的求值(evaluate)过程本身就是在一个微任务(microtask)上下文中进行的。

这意味着什么?当你的 ESM 代码执行时,它已经处在 V8 引擎的微任务调度体系中了。代码执行完毕后:

  1. V8 引擎会先执行自己的微任务检查点(microtask checkpoint)
  2. Promise 微任务是 V8 原生管理的,所以会被 V8 先消费
  3. 然后控制权交还给 Node.js
  4. Node.js 再清空 nextTick 队列

用一张对比图来看:

CJS 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  Node.js 接管                    │
│  1. 清空 nextTick 队列            │  ← Node.js 自己的机制
│  2. 清空 Promise 微任务队列        │  ← V8 的微任务
│  3. 进入事件循环                   │
└─────────────────────────────────┘

ESM 执行完毕后的清空顺序:
┌─────────────────────────────────┐
│  V8 微任务检查点触发               │
│  1. 清空 Promise 微任务队列        │  ← V8 先动手了!
│  2. Node.js 接管                  │
│  3. 清空 nextTick 队列            │  ← Node.js 的 nextTick 被延后了
│  4. 进入事件循环                   │
└─────────────────────────────────┘

本质区别:在 CJS 中,Node.js 拥有微任务调度的主动权,所以它能让 nextTick 先走;而在 ESM 中,V8 引擎的微任务检查点先于 Node.js 的 nextTick 调度触发,所以 Promise 反而先执行了。

4.3 一句话总结

process.nextTick 是 Node.js 的"私货",不属于 ECMAScript 标准。在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,自然不会优先照顾 Node.js 的私货。


五、用代码证明这不是玄学

为了彻底证实这个结论,我们做一组最简实验——只用 nextTickPromise 对比:

实验 1:CJS 模式

node -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

nextTick
promise

nextTick 先于 Promise

实验 2:ESM 模式

node --input-type=module -e "
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
"

输出:

promise
nextTick

Promise 先于 nextTick

同样的代码,两行都没改,只是切换了模块系统,执行顺序就反过来了。

实验 3:在事件循环内部(两者一致)

// 无论 CJS 还是 ESM,事件循环内部的行为是一致的
setTimeout(() => {
    process.nextTick(() => console.log('nextTick'));
    Promise.resolve().then(() => console.log('promise'));
}, 0);

输出(两种模式都一样):

nextTick
promise

这说明:ESM 只影响顶层代码(Top-Level)的微任务执行顺序,一旦进入事件循环内部,nextTick 和 Promise 的优先级关系恢复正常。


六、回到那道面试题——ESM 下的完整解析

现在我们用 ESM 的规则重新分析原题:

setImmediate(() => {
    console.log(1);
});

process.nextTick(() => {
    console.log(2);
    process.nextTick(() => {
        console.log(6);
    });
});

console.log(3);

Promise.resolve().then(() => {
    console.log(4);
    process.nextTick(() => {
        console.log(5);
    });
});

第一步:执行同步代码

和 CJS 完全一样:

  • 注册 setImmediate(cb(1)) → check 队列
  • 注册 nextTick(cb(2)) → nextTick 队列
  • 输出 3
  • 注册 Promise.then(cb(4)) → 微任务队列

队列状态:

nextTick 队列:[cb(2)]
微任务队列:  [cb(4)]
check 队列:  [cb(1)]

第二步:V8 微任务检查点(ESM 的关键差异!)

因为是 ESM 模式,V8 的微任务检查点先触发:

  • 执行 cb(4)输出 4,注册 nextTick(cb(5))

此时队列状态:

nextTick 队列:[cb(2), cb(5)]
微任务队列:  [](已清空)
check 队列:  [cb(1)]

第三步:Node.js 清空 nextTick 队列

  • 执行 cb(2)输出 2,注册 nextTick(cb(6))
  • 执行 cb(5)输出 5
  • 执行 cb(6)输出 6

此时队列状态:

nextTick 队列:[](已清空)
微任务队列:  []
check 队列:  [cb(1)]

第四步:进入事件循环 check 阶段

  • 执行 setImmediate 回调 → 输出 1

最终:3 → 4 → 2 → 5 → 6 → 1


七、面试怎么答?

如果你在面试中遇到这道题,我建议分三层回答:

第一层:给出标准答案

在 CJS 模式下,输出顺序是 3 → 2 → 6 → 4 → 5 → 1。因为同步代码优先执行,然后 process.nextTick 队列会在 Promise 微任务之前被清空,最后才是 setImmediate 宏任务。

第二层:主动提出 ESM 的差异

但如果这段代码运行在 ESM 模式下("type": "module".mjs 文件),输出顺序会变成 3 → 4 → 2 → 5 → 6 → 1。因为 ESM 模块的求值本身处于 V8 的微任务上下文中,Promise 微任务会被 V8 引擎优先消费,先于 Node.js 的 nextTick 队列。

第三层:解释根本原因

这个差异的本质是 process.nextTick 是 Node.js 自己的调度机制,不属于 ECMAScript 标准。在 CJS 模式下,Node.js 对执行流有完全的控制权,可以让 nextTick 优先;但在 ESM 模式下,V8 引擎遵循标准的微任务执行机制,Node.js 的私有调度会被延后。不过这个差异只存在于顶层代码中,进入事件循环后两者行为一致。

这三层答出来,面试官绝对会对你刮目相看。


八、延伸思考

8.1 这算是 Node.js 的 Bug 吗?

不算。这是 CJS 和 ESM 两种模块系统的 设计差异 导致的必然结果。Node.js 官方文档中也有相关说明:

Microtask callbacks take priority over nextTick callbacks in this specific case because of V8's microtask checkpoint behavior during ES module evaluation.

8.2 process.nextTick 还值得用吗?

process.nextTick 在 Node.js 生态中仍然有其存在价值,比如:

  • 在事件循环内部,它的优先级依然高于 Promise
  • 用来确保回调在当前操作完成后、I/O 之前执行
  • 在流(Stream)和 EventEmitter 中广泛使用

但考虑到 ESM 正在成为 Node.js 的主流模块系统,你需要意识到 在顶层代码中,nextTick 的优先级不再是绝对的。如果你对执行顺序有严格要求,应该通过代码结构来保证,而不是依赖 nextTick 和 Promise 的微妙优先级差异。

8.3 queueMicrotask vs process.nextTick

还有一个相关的知识点:queueMicrotask() 是 Web 标准 API,Node.js 也支持。它创建的微任务和 Promise 处于同一级别。在 CJS 中,queueMicrotask 的回调在 nextTick 之后执行;在 ESM 中,它和 Promise 一样会先于 nextTick 执行。

process.nextTick(() => console.log('nextTick'));
queueMicrotask(() => console.log('queueMicrotask'));
Promise.resolve().then(() => console.log('promise'));

CJS 输出:nextTick → queueMicrotask → promise ESM 输出:queueMicrotask → promise → nextTick

8.4 面试中常见的相关题目

理解了上面的原理后,下面这些变种你也能轻松应对:

题目 1:async/await 的执行顺序

async function foo() {
    console.log(1);
    await Promise.resolve();
    console.log(2);
}

process.nextTick(() => console.log(3));
foo();
console.log(4);

CJS 下:1 → 4 → 3 → 2 ESM 下:1 → 4 → 2 → 3

原理相同:await 后面的代码本质上就是 Promise.then,在 ESM 中会先于 nextTick 执行。

题目 2:setTimeout vs setImmediate

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

这道题的输出顺序是 不确定的(取决于系统调度),但如果放在 I/O 回调中,setImmediate 一定先于 setTimeout

const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('timeout'), 0);
    setImmediate(() => console.log('immediate'));
});
// 始终输出:immediate → timeout

九、总结

维度 CJS ESM
模块加载 同步 (require) 异步 (import)
顶层代码执行上下文 Node.js 主执行流 V8 微任务上下文
顶层 nextTick vs Promise nextTick 优先 Promise 优先
事件循环内部 nextTick vs Promise nextTick 优先 nextTick 优先
"type" 设置 默认 / "commonjs" "module"
文件扩展名 .js / .cjs .js(需配置) / .mjs

一句话记忆:CJS 中 Node.js 说了算,nextTick 是大哥;ESM 中 V8 说了算,Promise 是大哥。但进了事件循环,nextTick 依然是大哥。


写在最后

这个坑之所以"阴间",是因为:

  1. 代码完全一样,只是 package.json 多了一行 "type": "module"
  2. 绝大多数八股文和面试题都没有区分模块系统来讨论
  3. 现在的新项目基本都用 ESM 了,所以你跑出来的结果大概率和背的不一样

下次面试官再问事件循环,别忘了反问一句:"请问这段代码是跑在 CJS 还是 ESM 下?"

如果面试官愣住了——恭喜你,你已经赢了。


如果这篇文章帮到了你,欢迎点赞收藏,关注我获取更多 Node.js 深水区技术分享。

❌