阅读视图

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

多 Agent 系统容错与恢复机制:OAuth 过期、Cron 级联失败的工程解法

多 Agent 系统容错与恢复机制:OAuth 过期、Cron 级联失败的工程解法

📖 踩坑实录系列,详细过程见公众号「Wesley AI 日记」,微信搜索关注。

标签:AI Agent、容错设计、Cron、OAuth、系统韧性、OpenClaw


前言:险些全军覆没的那一天

3 月 10 日,我的 OpenClaw Agent Team 几乎集体瘫痪。

时间线如下

  • 06:00 — 小红书热点追踪 Cron 触发,OAuth Token 过期,静默失败
  • 09:00 — 小红书内容准备 Cron 触发,依赖热点追踪结果,读到空数据,生成无效内容
  • 12:10 — 发布 Cron 触发,拿到无效内容,发布失败(参数格式错误)
  • 12:20 — CEO Agent 发现失败,spawn 补发 Agent,因无发布互斥锁,触发竞态
  • 13:08 — 并发的多个 Agent 各自「修复」问题,结果发布了 3 条重复内容(标题还被改了)

一个 OAuth Token 的过期,引发了整条 Agent 链路的级联崩溃。

这不是单个 Bug,这是多 Agent 系统在容错设计缺失时的系统性失效。

本文从工程角度,系统梳理多 Agent 系统的容错与恢复机制设计。


理解级联失败

多 Agent 系统中,Agent 之间存在依赖关系。一个上游 Agent 的失败,会以不可预期的方式传导给下游:

              ┌─────────────────┐
OAuth过期 ──▶ │ 热点追踪 Agent   │ ──▶ 静默失败(无告警)
              └────────┬────────┘
                       │ 输出:空数据
                       ▼
              ┌─────────────────┐
              │ 内容生成 Agent   │ ──▶ 生成无效内容(基于空输入)
              └────────┬────────┘
                       │ 输出:无效内容
                       ▼
              ┌─────────────────┐
              │ 发布 Agent       │ ──▶ 发布失败
              └────────┬────────┘
                       │ 触发
                       ▼
              ┌─────────────────┐
              │ CEO 补救 Agent ×3│ ──▶ 并发冲突,重复发布
              └─────────────────┘

级联失败的关键特征

  1. 失败的静默传播:上游失败没有立即终止下游,而是以「空数据」或「部分数据」的形式传递
  2. 错误放大:每一层都在「尽力完成任务」,却把问题越放越大
  3. 干预引发新问题:人工干预(spawn 补救 Agent)因缺乏协调机制,产生了新的竞态条件

容错机制设计:五个维度

1. OAuth Token 生命周期管理

OAuth Token 过期是多 Agent 系统中最常见的静默失败源。

错误的处理方式(我们之前的做法)

# 错误:调用 API 失败时,直接返回空结果
def fetch_hot_topics(token: str) -> list:
    try:
        resp = api.get_hot_topics(token)
        return resp.data
    except Exception:
        return []  # 🚨 静默返回空,下游无感知

正确的 Token 生命周期管理

class OAuthTokenManager:
    """OAuth Token 生命周期管理器"""
    
    def __init__(self, token_store: TokenStore):
        self.token_store = token_store
        self.refresh_threshold_minutes = 30  # 过期前 30 分钟主动刷新
    
    def get_valid_token(self, service: str) -> str:
        """获取有效 Token,必要时自动刷新"""
        token = self.token_store.get(service)
        
        if token is None:
            raise TokenNotFoundError(f"服务 {service} 未配置 Token")
        
        if self.is_expiring_soon(token):
            token = self.refresh_token(service, token)
        
        return token
    
    def is_expiring_soon(self, token: OAuthToken) -> bool:
        """检查 Token 是否即将过期"""
        remaining = token.expires_at - datetime.now()
        return remaining < timedelta(minutes=self.refresh_threshold_minutes)
    
    def refresh_token(self, service: str, old_token: OAuthToken) -> OAuthToken:
        """刷新 Token,失败时告警但不静默"""
        try:
            new_token = oauth_client.refresh(old_token.refresh_token)
            self.token_store.save(service, new_token)
            return new_token
        except OAuthRefreshError as e:
            # 刷新失败:告警 + 抛出异常,不返回空
            alert_manager.send_alert(
                level="P1",
                message=f"服务 {service} OAuth Token 刷新失败,需要重新授权",
                detail=str(e),
                notify_channel="feishu"
            )
            raise TokenExpiredError(f"服务 {service} Token 已过期且无法自动刷新")

主动刷新 Cron

# 每天凌晨 2 点检查所有 Token 状态
# 0 2 * * * /home/admin/.openclaw/scripts/token-health-check.sh

#!/bin/bash
# token-health-check.sh

SERVICES=("xhs_main" "xhs_account_b" "wechat_mp")

for service in "${SERVICES[@]}"; do
    expiry=$(token-manager get-expiry $service)
    remaining_hours=$(( (expiry - $(date +%s)) / 3600 ))
    
    if [ $remaining_hours -lt 48 ]; then
        echo "⚠️ $service Token 将在 ${remaining_hours}h 后过期,尝试刷新..."
        token-manager refresh $service || \
            notify-feishu "P1: $service Token 刷新失败,需要手动重新授权"
    fi
done

2. Cron 链路断路器(Circuit Breaker)

当上游 Cron 任务失败时,下游任务应该感知并选择正确的策略,而不是盲目继续执行。

class CronCircuitBreaker:
    """Cron 链路断路器"""
    
    def __init__(self, redis_client):
        self.redis = redis_client
    
    def check_upstream_health(self, upstream_task_id: str) -> UpstreamStatus:
        """检查上游任务的健康状态"""
        status_key = f"cron:status:{upstream_task_id}"
        status = self.redis.get(status_key)
        
        if status is None:
            return UpstreamStatus.UNKNOWN
        
        status_data = json.loads(status)
        
        # 上游任务是否在预期时间内成功完成
        last_success = datetime.fromisoformat(status_data["last_success_at"])
        expected_interval = timedelta(minutes=status_data["interval_minutes"])
        
        if datetime.now() - last_success > expected_interval * 2:
            return UpstreamStatus.STALE  # 上游已过期未更新
        
        if status_data["last_run_result"] == "failed":
            return UpstreamStatus.FAILED
        
        return UpstreamStatus.HEALTHY
    
    def gate_downstream_task(
        self, 
        task: CronTask,
        upstream_task_id: str,
        strategy: FailStrategy = FailStrategy.HALT
    ) -> GateResult:
        """
        检查上游状态,决定下游任务是否应该执行。
        
        strategy:
          - HALT: 上游失败则停止(默认,安全优先)
          - DEGRADE: 降级执行(使用缓存/默认值)
          - PROCEED: 继续执行(用于不依赖上游输出的任务)
        """
        upstream_status = self.check_upstream_health(upstream_task_id)
        
        if upstream_status == UpstreamStatus.HEALTHY:
            return GateResult.allow()
        
        if strategy == FailStrategy.HALT:
            alert_manager.send_alert(
                level="P2",
                message=f"任务 {task.name} 因上游 {upstream_task_id} 异常而暂停",
                detail={
                    "upstream_status": upstream_status,
                    "upstream_task": upstream_task_id,
                    "downstream_task": task.name
                }
            )
            return GateResult.halt(reason=f"上游任务 {upstream_task_id} 状态异常")
        
        if strategy == FailStrategy.DEGRADE:
            return GateResult.degrade(
                fallback_data=self.get_cached_output(upstream_task_id)
            )
        
        return GateResult.allow()

使用示例

# 内容生成 Cron 执行前,检查热点追踪上游
def content_generation_cron():
    circuit_breaker = CronCircuitBreaker(redis)
    
    gate = circuit_breaker.gate_downstream_task(
        task=content_gen_task,
        upstream_task_id="hot-topic-tracker",
        strategy=FailStrategy.HALT  # 上游失败则停止,不生成无效内容
    )
    
    if not gate.allowed:
        logger.warning(f"内容生成任务暂停: {gate.reason}")
        return
    
    # 正常执行
    topics = hot_topic_cache.get()
    generate_content(topics)

3. 发布互斥锁与幂等性

发布操作是整个 Agent 链路中副作用最大的操作。必须保证:

  1. 同一内容只发布一次(幂等性)
  2. 同一时间只有一个 Agent 在发布同一内容(互斥性)
  3. 网络超时后重试不导致重复(幂等重试)
class PublishLockManager:
    """原子性发布锁管理"""
    
    LOCK_TTL = 600  # 锁最多持有 10 分钟
    
    def acquire_publish_lock(self, content_id: str) -> bool:
        """
        原子性获取发布锁。
        使用 Redis SET NX EX 确保原子性。
        """
        lock_key = f"publish:lock:{content_id}"
        lock_holder = f"agent:{os.getpid()}:{time.time()}"
        
        acquired = self.redis.set(
            lock_key, 
            lock_holder,
            nx=True,    # 只有不存在时才设置
            ex=self.LOCK_TTL
        )
        
        return bool(acquired)
    
    def check_already_published(self, content_id: str) -> Optional[str]:
        """
        检查内容是否已经发布成功。
        返回发布结果的帖子 ID,或 None(未发布)。
        """
        result_key = f"publish:result:{content_id}"
        result = self.redis.get(result_key)
        
        if result:
            data = json.loads(result)
            if data["status"] == "published":
                return data["post_id"]
        
        return None
    
    def record_publish_result(
        self, 
        content_id: str, 
        post_id: str,
        metadata: dict
    ):
        """记录发布结果,供幂等检查使用"""
        result_key = f"publish:result:{content_id}"
        self.redis.setex(
            result_key,
            86400 * 7,  # 保留 7 天
            json.dumps({
                "status": "published",
                "post_id": post_id,
                "published_at": datetime.now().isoformat(),
                **metadata
            }, ensure_ascii=False)
        )

# 使用
def publish_content_safely(content: Content):
    lock_mgr = PublishLockManager(redis)
    
    # 检查是否已发布(幂等检查)
    existing_post_id = lock_mgr.check_already_published(content.id)
    if existing_post_id:
        logger.info(f"内容 {content.id} 已发布为 {existing_post_id},跳过")
        return existing_post_id
    
    # 获取发布锁(互斥)
    if not lock_mgr.acquire_publish_lock(content.id):
        raise PublishLockBusyError(
            f"内容 {content.id} 正在被另一个 Agent 发布,请稍后查看结果"
        )
    
    try:
        # 执行发布
        post_id = platform_api.publish(content)
        
        # 记录结果
        lock_mgr.record_publish_result(content.id, post_id, {
            "platform": content.platform,
            "account_id": content.target_account
        })
        
        return post_id
    finally:
        # 无论成功失败,释放锁
        lock_mgr.release_lock(content.id)

4. 多 Agent 任务状态协调

当多个 Agent 需要协作完成一项任务时,必须有显式的状态协调机制,防止并发冲突。

class AgentTaskCoordinator:
    """多 Agent 任务协调器"""
    
    def claim_task(
        self, 
        task_id: str, 
        agent_id: str
    ) -> bool:
        """
        Agent 认领任务的原子操作。
        确保同一任务只被一个 Agent 处理。
        """
        claim_key = f"task:claim:{task_id}"
        
        # 原子性认领
        claimed = self.redis.set(
            claim_key,
            agent_id,
            nx=True,  # 只有未被认领时才成功
            ex=1800   # 最多持有 30 分钟
        )
        
        if not claimed:
            current_claimer = self.redis.get(claim_key)
            logger.info(
                f"任务 {task_id} 已被 {current_claimer} 认领,"
                f"Agent {agent_id} 放弃"
            )
        
        return bool(claimed)
    
    def update_task_status(
        self,
        task_id: str,
        agent_id: str,
        status: TaskStatus,
        detail: dict = None
    ):
        """更新任务状态,供其他 Agent 和 CEO 查询"""
        status_key = f"task:status:{task_id}"
        self.redis.setex(
            status_key,
            3600,  # 1 小时
            json.dumps({
                "task_id": task_id,
                "claimer": agent_id,
                "status": status.value,
                "updated_at": datetime.now().isoformat(),
                "detail": detail or {}
            }, ensure_ascii=False)
        )

# CEO Agent 在 spawn 补救 Agent 前检查任务状态
def ceo_handle_publish_failure(failed_task_id: str):
    coordinator = AgentTaskCoordinator(redis)
    
    # 检查是否已有其他 Agent 在处理
    existing_status = coordinator.get_task_status(failed_task_id)
    
    if existing_status and existing_status["status"] in ["claimed", "in_progress"]:
        logger.info(
            f"任务 {failed_task_id} 已被 {existing_status['claimer']} 处理中,"
            f"CEO 等待结果"
        )
        return  # 不重复 spawn
    
    # 安全地 spawn 补救 Agent
    coordinator.update_task_status(
        failed_task_id, "ceo", TaskStatus.REMEDIATION_SPAWNED
    )
    
    sessions_spawn({
        "agent": "xhs-main",
        "task": f"补发任务 {failed_task_id},先检查是否已发布,再决定是否执行"
    })

5. 系统韧性监控与自动告警

好的容错设计不是等问题发生再处理,而是在问题扩散前主动发现。

class SystemResilienceMonitor:
    """系统韧性监控器"""
    
    def run_health_checks(self):
        """综合健康检查,由 SRE Agent 的 Cron 每 15 分钟运行"""
        
        checks = [
            self.check_cron_task_freshness(),    # Cron 是否按时执行
            self.check_oauth_token_validity(),    # Token 是否即将过期
            self.check_mcp_services_alive(),      # MCP 服务是否存活
            self.check_publish_lock_stale(),      # 是否有卡住的发布锁
            self.check_task_queue_depth(),        # 任务队列是否积压
        ]
        
        issues = [c for c in checks if not c.healthy]
        
        if issues:
            self.send_health_report(issues)
    
    def check_cron_task_freshness(self) -> HealthCheck:
        """检查所有 Cron 任务是否在预期时间内执行"""
        stale_tasks = []
        
        for cron_id, expected_interval in CRON_REGISTRY.items():
            last_run = self.get_last_successful_run(cron_id)
            
            if last_run is None:
                stale_tasks.append({
                    "cron_id": cron_id,
                    "reason": "从未成功执行"
                })
                continue
            
            elapsed = datetime.now() - last_run
            if elapsed > expected_interval * 1.5:
                stale_tasks.append({
                    "cron_id": cron_id,
                    "last_run": last_run.isoformat(),
                    "elapsed_minutes": elapsed.total_seconds() / 60,
                    "expected_minutes": expected_interval.total_seconds() / 60
                })
        
        if stale_tasks:
            return HealthCheck.unhealthy(
                f"{len(stale_tasks)} 个 Cron 任务未按时执行",
                detail=stale_tasks,
                alert_level="P2"
            )
        
        return HealthCheck.healthy("所有 Cron 任务正常执行")
    
    def check_publish_lock_stale(self) -> HealthCheck:
        """检查是否有超时未释放的发布锁"""
        stale_locks = []
        
        for lock_key in self.redis.scan_iter("publish:lock:*"):
            ttl = self.redis.ttl(lock_key)
            original_ttl = PublishLockManager.LOCK_TTL
            
            # 如果锁持有时间超过一半,可能卡住了
            if ttl < original_ttl / 2:
                content_id = lock_key.split(":")[-1]
                stale_locks.append({
                    "content_id": content_id,
                    "remaining_ttl": ttl,
                    "suspected_stuck": ttl < 60
                })
        
        if any(l["suspected_stuck"] for l in stale_locks):
            return HealthCheck.unhealthy(
                "存在疑似卡住的发布锁,可能导致发布阻塞",
                detail=stale_locks,
                alert_level="P1"
            )
        
        return HealthCheck.healthy("所有发布锁状态正常")

容错分级策略

不同失败场景需要不同的容错策略:

失败类型 响应策略 自动化程度 告警级别
OAuth Token 过期 自动刷新;失败则告警 半自动 P1
MCP 服务未运行 Fail Fast,告警 自动告警 P1
Cron 执行超时 中止本次,标记为失败 自动 P2
上游 Cron 失败 断路器阻断下游 自动 P2
发布验证失败 上报 CEO,不自动重试 人工决策 P2
并发发布冲突 互斥锁阻止 自动 P3
配置漂移 启动前检测,告警 自动 P1

「韧性」与「可靠性」的区别

构建多 Agent 系统时,容易混淆两个概念:

  • 可靠性(Reliability):系统在正常条件下无故障运行的能力
  • 韧性(Resilience):系统在异常条件下自我恢复的能力

可靠性追求的是「不出错」,韧性追求的是「出了错能自愈」。

对于 AI Agent 系统,韧性比可靠性更重要——因为 Agent 的外部依赖(OAuth 服务、平台 API、MCP 工具)的不稳定性超出你的控制范围,「不出错」是不现实的。

韧性设计的三条原则

  1. 隔离失败边界:一个 Agent 失败,不应该扩散到整个系统
  2. 快速失败优于静默失败:失败应该立即可见,而不是以「空数据」的形式传播
  3. 恢复是可观测的:人工介入时,系统状态必须透明可查

总结

多 Agent 系统的级联失败不是偶然事件,而是在容错设计缺失时的必然结果。

本文介绍的五个机制(OAuth 生命周期管理、Cron 断路器、发布互斥锁、任务状态协调、韧性监控)构成了一套完整的容错防护体系。每一条都来自真实的生产事故,每一条都在 OpenClaw 上得到了验证。

核心设计哲学只有一句话:

不要假设上游会成功,不要假设下游会感知失败,不要假设并发不会发生。

用显式的机制保证,而非隐式的假设。


📖 详细踩坑日记 → 公众号「Wesley AI 日记」,微信搜索关注,每周 AI Agent 实战经验分享。

闭包:那个“赖着不走”的家伙,到底有什么用?

昨天我们认识了闭包——那个“虽然离开了家,但还记得家里密码”的神奇函数。今天咱们来深挖一下:闭包这玩意儿到底能干啥?有没有什么副作用?怎么防止它把内存吃光?看完这篇,你不仅知道闭包怎么用,还能在面试官面前侃侃而谈。

前言

闭包就像一个“赖着不走”的租客。你以为人走了,结果他还留着你的钥匙,时不时回来拿点东西。这在JavaScript里有时候特别好用,有时候又特别坑。

今天我们就来盘点闭包的几个经典应用场景,顺便聊聊怎么让它“体面退场”,别把你的内存吃光。

一、闭包的应用场景:这个“赖着不走”的家伙还挺有用

1. 模块化:私有变量与公共方法

没有ES6模块之前,闭包是JS实现模块化的主要手段。它能把内部细节藏起来,只暴露需要公开的接口。

const counter = (function() {
  let count = 0; // 私有变量,外面访问不到
  
  function increment() {
    count++;
    console.log(count);
  }
  
  function decrement() {
    count--;
    console.log(count);
  }
  
  function getCount() {
    return count;
  }
  
  return {
    increment,
    decrement,
    getCount
  };
})();

counter.increment(); // 1
counter.increment(); // 2
console.log(counter.count); // undefined,拿不到
console.log(counter.getCount()); // 2

这个模式叫IIFE(立即执行函数),它创建了一个闭包,里面的count变量被返回的方法“记住”了,外部无法直接修改,只能通过提供的接口操作。像不像一个“保险箱”?钥匙只给了你几个特定的人。

2. 函数工厂:批量生产定制函数

闭包可以用来创建带有特定“预设”的函数,比如一个能记录调用次数的函数。

function createCounter(initial = 0) {
  let count = initial;
  return function() {
    count++;
    return count;
  };
}

const counterA = createCounter(10);
console.log(counterA()); // 11
console.log(counterA()); // 12

const counterB = createCounter(0);
console.log(counterB()); // 1

每个计数器都独立拥有自己的count变量,互不干扰。这个工厂就像是做定制蛋糕,每个客户拿到的是自己专属的那一份。

3. 防抖与节流:控制函数执行频率

防抖和节流是前端性能优化的常见手法,它们的核心都依赖闭包来保存计时器和状态。

防抖:用户连续触发事件时,只有最后一次等待结束后才执行(比如搜索框输入)。

function debounce(fn, delay) {
  let timer = null; // 闭包保存timer
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

// 使用
const search = debounce(() => console.log('搜索中...'), 500);

节流:限制函数在单位时间内最多执行一次(比如滚动事件)。

function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      last = now;
      fn.apply(this, args);
    }
  };
}

这两个函数返回的都是闭包,里面的timerlast被“记住”了,所以每次调用都能访问到上一次的状态。

4. 柯里化:提前固定参数

柯里化是把多参数函数变成一系列单参数函数的技术,本质也是闭包。

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...more) {
        return curried.apply(this, args.concat(more));
      };
    }
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6

每次返回新函数时,原来的args被闭包保存,直到参数凑齐才执行。就像是你给一家餐厅留了订单,每次打电话加菜,最后一起结算。

5. 事件监听中的回调

在事件回调里访问外部变量,其实也是闭包。比如一个简单的计数器按钮:

let count = 0;
document.getElementById('btn').addEventListener('click', function() {
  count++;
  console.log(count);
});

这里的匿名函数“记住”了外部的count变量,每次点击都能访问到最新的值。

二、闭包的“阴暗面”:内存泄漏与性能

闭包这么香,为什么还有人说它不好?因为它会“赖着不走”——那些被记住的变量,即使外部函数已经执行完了,也不会被垃圾回收,只要闭包函数还活着,它们就一直存在。

1. 什么是内存泄漏?

内存泄漏就是程序用完了内存,但系统没有及时回收,导致内存占用越来越大,最后浏览器变卡、甚至崩溃。

闭包导致泄漏的典型场景:

function leak() {
  let bigData = new Array(1000000).fill('leak');
  return function() {
    console.log('I am a closure');
    // 虽然没有直接使用bigData,但闭包还是引用了它
  };
}

const closureFn = leak(); // 泄漏了100万个元素的数组

上面这个例子中,返回的函数虽然没有用到bigData,但因为bigData和它在同一个作用域,闭包会保留整个作用域链上的所有变量。所以如果闭包一直存在,那些无用的变量也一直占用内存。

2. 如何避免闭包导致的内存泄漏?

  • 用完后解除引用:把闭包函数的变量置为null
closureFn = null; // 这样bigData就可以被回收了
  • 只保留需要的变量:如果闭包中只用到部分变量,可以用let声明在闭包外部提前“过滤”。
function good() {
  let bigData = new Array(1000000).fill('data');
  let needed = 'only me';
  return function() {
    console.log(needed); // 只引用needed,bigData会被回收
  };
}

因为闭包只引用了needed,引擎可以优化,把bigData标记为不可达。

  • 避免在循环中创建闭包(除非必要),因为循环中的闭包可能会意外持有大量变量。

3. 弱引用:救星Map和Set

ES6引入了WeakMapWeakSet,它们的键是弱引用的——如果键对象不再被其他地方引用,那么即使还在WeakMap里,也会被垃圾回收。

这在闭包中可以用来缓存数据,而不阻止回收。

const cache = new WeakMap();

function process(obj) {
  if (!cache.has(obj)) {
    const result = heavyComputation(obj);
    cache.set(obj, result);
  }
  return cache.get(obj);
}

如果obj在其他地方被销毁了,cache里的键值对也会自动消失,不会造成泄漏。

三、实战:闭包的最佳实践

  1. 用闭包封装私有数据:在不需要完全隔离的情况下,闭包是模块化的好帮手。但现代开发可以用ES6模块(import/export)替代IIFE,更清晰。

  2. 防抖节流用闭包保存状态:这是闭包的经典应用,没啥好纠结的。

  3. 谨慎使用返回闭包的高阶函数:如果闭包持有大量数据,确保及时清理。

  4. 善用let替代varlet有块级作用域,能避免一些意外的闭包问题。

  5. 在DevTools里监控内存:用Chrome的Memory面板,可以拍快照,看看哪些闭包对象一直存在,帮助定位泄漏。

四、总结:闭包是个好员工,但别让它996

闭包是JavaScript的强大特性,它让函数拥有了“记忆”,能实现模块化、柯里化、防抖节流等高级功能。但也要注意它的副作用:被记住的变量不会自动消失,如果不注意,容易造成内存泄漏。

记住几个原则:

  • 用完闭包,及时解除引用。
  • 在闭包里只引用需要的变量,减少内存占用。
  • 现代开发中,能用ES6模块就用模块,减少手动闭包模式。
  • 遇到缓存场景,优先考虑WeakMap

掌握了闭包,你就掌握了JS高级编程的核心钥匙。明天我们将走进JS的另一个灵魂领域——原型和原型链,看看那个让新手望而生畏的概念,到底是怎么一回事。

如果你觉得今天的闭包应用和内存管理讲得透彻,点个赞让更多人看到。有疑问评论区见,我们明天见!

Promise.try () 完全指南

在 JavaScript 异步编程中,开发者常面临一个痛点:同步代码的错误无法被 Promise 的 .catch () 捕获,而 setTimeout/setInterval 等宏任务的错误更是 “逃逸” 到全局,难以统一处理。Promise.try () 作为解决这类问题的关键 API,本文将从作用、用法、兼容性、与其他 API 的对比等维度,全面解析其价值和使用场景。

一、核心问题:setTimeout/setInterval 的错误能捕获吗?

1. 直接捕获:几乎不可能

setTimeout/setInterval 的回调函数运行在新的宏任务执行栈中,脱离了原有的 Promise 链 /try-catch 作用域,因此:

js

// ❌ 无法捕获 setTimeout 内的错误
try {
  setTimeout(() => {
    throw new Error('定时器错误');
  }, 100);
} catch (err) {
  console.log('捕获到错误:', err); // 永远不会执行
}

// ❌ Promise.catch 也抓不到
Promise.resolve()
  .then(() => {
    setTimeout(() => {
      throw new Error('定时器错误');
    }, 100);
  })
  .catch(err => console.log('捕获到错误:', err)); // 同样无效

2. 间接处理:手动封装为 Promise

唯一能 “捕获” 定时器错误的方式,是在回调内主动处理,并封装为 Promise:

js

function delayTask(fn, ms) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        const result = fn(); // 执行任务
        resolve(result);
      } catch (err) {
        reject(err); // 手动捕获错误并 reject
      }
    }, ms);
  });
}

// 使用示例
delayTask(() => {
  throw new Error('定时器内的错误');
}, 100)
.then(res => console.log(res))
.catch(err => console.log('捕获到错误:', err)); // 生效!

3. 关键结论

  • setTimeout/setInterval 的错误无法被外层 try-catch/Promise.catch 直接捕获
  • 必须在回调内部用 try-catch 包裹逻辑,并手动 reject 才能纳入 Promise 链;
  • 而 Promise.try () 的核心价值,正是无需手动 try-catch,自动统一同步 / 异步错误(但对定时器这类宏任务仍需额外封装)。

二、Promise.try () 核心功能:统一同步 / 异步错误处理

1. 为什么需要 Promise.try ()?

日常开发中,一个函数可能混合同步逻辑和异步逻辑,同步错误会直接抛出(而非进入 Promise.catch):

js

// 问题代码:同步错误无法被 catch 捕获
function getUser(id) {
  if (!id) throw new Error('id 不能为空'); // 同步错误
  return fetch(`/api/user/${id}`); // 异步 Promise
}

// 使用时
getUser() // 直接抛出错误,不会进入 catch
  .then(res => res.json())
  .catch(err => console.log('错误:', err));

而 Promise.try () 能把同步代码 “包装” 成 Promise 链,让同步错误也能被 .catch () 捕获:

js

// 修复:用 Promise.try 包裹
function getUser(id) {
  return Promise.try(() => {
    if (!id) throw new Error('id 不能为空'); // 同步错误
    return fetch(`/api/user/${id}`); // 异步 Promise
  });
}

// 使用时
getUser()
  .then(res => res.json())
  .catch(err => console.log('错误:', err)); // 同步/异步错误都能捕获!

2. Promise.try () 的核心作用

表格

核心作用 具体说明
统一错误捕获 同步代码抛出的错误 → 自动转为 Promise.reject,可被 .catch () 捕获
简化代码 无需手动写 try-catch 包裹同步逻辑,代码更简洁
语义化启动 Promise 链 new Promise((resolve) => resolve(fn())) 更直观
兼容返回值类型 无论回调返回同步值、Promise、还是抛出错误,都统一为 Promise 实例

3. 核心特性:“穿透” 异步层级

即使回调内是多层异步逻辑,Promise.try () 也能保持错误捕获的一致性:

js

Promise.try(async () => {
  const userId = await getUserId(); // 异步获取 ID
  if (!userId) throw new Error('无用户 ID'); // 同步判断
  const user = await fetchUser(userId); // 异步请求
  return user;
})
.catch(err => console.log('所有错误都能捕获:', err));

三、Promise.try () 用法全解析

1. 基本语法

js

// 语法 1:基础用法
Promise.try(executor)
  .then(result => { /* 处理成功结果 */ })
  .catch(error => { /* 处理所有错误(同步+异步) */ });

// 语法 2:结合 async/await
Promise.try(async () => {
  // 混合同步/异步逻辑
  const data = await fetchData();
  if (data.length === 0) throw new Error('无数据');
  return data;
})
.catch(err => console.error(err));

2. 常见使用场景

场景 1:封装混合同步 / 异步的函数

js

// 封装工具函数:统一错误处理
function getCache(key) {
  return Promise.try(() => {
    // 同步:先查内存缓存
    const cacheData = localStorage.getItem(key);
    if (cacheData) return JSON.parse(cacheData); // 同步返回
    
    // 异步:缓存不存在则请求接口
    return fetch(`/api/cache/${key}`).then(res => res.json());
  });
}

// 使用:同步/异步错误都能 catch
getCache('user_123')
  .then(data => console.log('数据:', data))
  .catch(err => console.log('错误:', err));

场景 2:替代 try-catch + Promise 手动封装

js

// 传统写法(繁琐)
function doTask() {
  return new Promise((resolve, reject) => {
    try {
      const result = syncOperation(); // 同步操作
      resolve(result);
    } catch (err) {
      reject(err);
    }
  });
}

// Promise.try 写法(简洁)
function doTask() {
  return Promise.try(() => {
    return syncOperation(); // 自动处理同步错误
  });
}

场景 3:处理可能抛出错误的同步函数

js

// 同步函数可能抛错
function parseJSON(str) {
  return JSON.parse(str); // 无效 JSON 会同步抛错
}

// 用 Promise.try 包装,转为 Promise 错误
Promise.try(() => parseJSON('{invalid json}'))
  .catch(err => console.log('JSON 解析错误:', err)); // 生效

3. 与其他类似写法的对比

表格

写法 能否捕获同步错误 代码简洁度 语义化
Promise.try(fn) ✅ 能 ✅ 极简 ✅ 高(明确启动 Promise 链)
new Promise(resolve => resolve(fn())) ❌ 不能(同步错误直接抛出) ❌ 繁琐 ❌ 低
(async () => fn())() ✅ 能 ✅ 简洁 ❌ 语义不明确
Promise.resolve().then(fn) ❌ 不能(同步错误直接抛出) ✅ 简洁 ❌ 低

结论:Promise.try () 是唯一兼顾 “简洁 + 语义化 + 同步错误捕获” 的方案。

四、Promise.try () 兼容性与替代方案

1. 原生兼容性

  • 原生支持:Promise.try() 并非 ES 标准 API,是 Bluebird.js(第三方 Promise 库)率先实现的特性,Node.js/ 浏览器原生 Promise 未内置;

  • 环境支持

    • 直接使用:需引入 Bluebird.js、Q 等第三方 Promise 库;
    • 原生替代:可手动实现 polyfill。

2. 手动实现 Promise.try ()(兼容所有环境)

如果不想引入第三方库,可自己封装一个极简版:

js

// 兼容所有环境的 Promise.try 实现
if (!Promise.try) {
  Promise.try = function (executor) {
    return new Promise((resolve, reject) => {
      try {
        // 执行回调,获取返回值
        const result = executor();
        // 如果返回的是 Promise,直接 resolve;否则包装为 Promise
        resolve(result);
      } catch (err) {
        // 同步错误直接 reject
        reject(err);
      }
    });
  };
}

// 测试:完全兼容原生用法
Promise.try(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err)); // 生效

3. 用 async/await 替代(ES2017+)

ES2017 后的 async/await 也能实现类似效果,本质是语法糖:

js

// 等价于 Promise.try 的 async/await 写法
async function wrapFn(fn) {
  try {
    return await fn(); // await 会处理同步值/Promise
  } catch (err) {
    return Promise.reject(err);
  }
}

// 使用
wrapFn(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err));

注意:async 函数本身返回 Promise,因此 await fn() 会自动将同步值转为 resolved Promise,同步错误会被 try-catch 捕获。

五、避坑指南:Promise.try () 的常见误区

误区 1:认为能捕获 setTimeout 等宏任务错误

js

// ❌ 错误认知:Promise.try 无法直接捕获定时器错误
Promise.try(() => {
  setTimeout(() => {
    throw new Error('定时器错误');
  }, 100);
})
.catch(err => console.log('捕获到:', err)); // 无效

原因:setTimeout 回调是新的宏任务,脱离了当前 Promise 链的执行栈,必须在回调内手动 try-catch + reject。

误区 2:忽略回调返回非 Promise 的情况

js

// ✅ 正确:Promise.try 会自动包装同步返回值为 Promise
const res = Promise.try(() => 123);
console.log(res instanceof Promise); // true
res.then(num => console.log(num)); // 123

误区 3:与 Promise.resolve () 混淆

js

// ❌ Promise.resolve 无法捕获同步错误
Promise.resolve(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err)); // 无效

// ✅ Promise.try 能捕获
Promise.try(() => {
  throw new Error('同步错误');
})
.catch(err => console.log('捕获到:', err)); // 生效

核心区别:Promise.resolve () 只是包装 “值” 为 Promise,不会执行回调;而 Promise.try () 会立即执行回调,并捕获执行过程中的错误。

六、总结

核心要点

  1. setTimeout/setInterval 错误:无法被外层 try-catch/Promise.catch 直接捕获,需在回调内手动 try-catch + 封装为 Promise;
  2. Promise.try () 核心价值:统一同步 / 异步错误捕获,让同步代码的错误也能进入 Promise.catch,无需手动写 try-catch;
  3. 兼容性:非原生 ES 标准,需引入 Bluebird 或手动实现 polyfill,也可通过 async/await 实现等价效果;
  4. 关键误区:Promise.try () 无法捕获宏任务(如定时器)的错误,仅能处理当前执行栈内的同步 / 微任务错误。

最佳实践

  • 封装混合同步 / 异步逻辑的函数时,优先使用 Promise.try () 统一错误处理;
  • 处理定时器 / 事件回调等宏任务时,需在回调内手动 try-catch,并封装为 Promise;
  • 无第三方库时,用 async/await + try-catch 作为 Promise.try () 的替代方案。

Promise.try () 虽非原生标准,但它解决了异步编程中 “同步错误逃逸” 的核心痛点,是编写健壮、统一的异步代码的重要工具。

Module Federation 2.0 共享策略翻车实录:版本协商、热更新与依赖冲突的排查工具链

三个月前,我们把一个 B 端 SaaS 平台从 Webpack 5 的 Module Federation 1.0 迁到了 2.0。主应用加 6 个远程团队的子应用,涉及 React 18、antd 5.x、三套不同版本的 lodash,外加一个用了 moment 死活不肯迁 dayjs 的老团队。

迁完当天,线上白屏了。

控制台报了一个极其隐晦的错:Shared module is not available for eager consumption。查了两个小时才定位到根因——两个子应用对 react-dom 的版本协商结果不一致,一个拿到了 18.2.0,另一个拿到了 18.3.1。而 18.3.1 那个由于加载顺序的问题,在协商窗口关闭后才注册上来,直接被跳过了。

这篇文章是那次翻车之后,团队花三周搞出来的调优方案和诊断工具链的复盘。

MF 2.0 的共享运行时到底在干什么

协商窗口——最容易翻车的地方

关键问题来了:什么时候协商?

MF 2.0 有一个隐式的"协商窗口"概念。主应用调用 init() 初始化共享作用域后,会等待所有已知的远程容器注册完它们的共享模块,然后进入消费阶段。一旦某个模块开始消费某个共享依赖,协商窗口就关闭了——后来者注册的版本不会被纳入考量。

用时间线表示会更直观:

  init()  → 注册窗口打开
  Remote A 注册 react@18.2.0  
  Remote B 注册 react@18.3.1  
  Remote A 消费 react → 协商:选 18.3.1
  ════════ 协商窗口关闭 ════════
  Remote C 注册 react@18.2.0   来晚了
  Remote C 消费 react → 拿到 18.3.1
  (如果 C 配了 ~18.2.0 + strictVersion: true → 直接报错)

我们线上白屏就是这个时序问题。Remote C 是一个懒加载的子应用,用户点击菜单才加载,注册得晚,但它的 react-dom 配了 strictVersion: true,协商结果不满足它的版本要求,页面直接崩了。

版本协商算法:不只是 semver 匹配

默认协商策略的三层逻辑

MF 2.0 的版本协商不是简单的"找最新版",而是一个三层决策逻辑。

第一层是 singleton 模式判断。如果某个包被标记为 singleton,全局只保留一个版本,取已注册版本中最高的那个。这时如果同时配了 strictVersion,而最高版本不满足消费者的 requiredVersion,就会直接抛错;不配 strictVersion 的话,即使版本不匹配也硬上。

第二层是 semver 范围匹配。在所有已注册版本中,筛选出满足消费者 requiredVersion 范围的,取其中最高的。

第三层是兜底。没有满足条件的版本时,先尝试消费者自带的 fallback 版本;fallback 也没有的话,看 strictVersion ——配了就报错,没配就拿最高版本硬上,赌一把兼容性。

这三层逻辑在文档里分散在好几个地方,拼起来才能看到全貌。实际运行时还要考虑 eager 标记的影响——eager: true 的模块会在 init() 阶段就被加载,直接跳过协商窗口。

singleton 的隐式降级——我们踩过最阴的坑

reactreact-dom 几乎所有人都会配 singleton: true,这没问题。

我们有一个子应用依赖了 React 18.3.1 新增的 useFormStatus hook,但全局协商结果是 18.2.0(主应用锁了 18.2.0 且最先注册)。子应用拿到了 18.2.0 的 React,调用 useFormStatus 时直接 undefined is not a function

排查这个问题花了大半天,因为没有任何 warning。看配置,一切正常;看网络请求,React 确实加载了;看版本号——这一步才意识到拿到的不是预期版本。教训很明确:

//  只写 singleton → 版本不匹配时静默降级,运行时才爆炸
react: { singleton: true, requiredVersion: '^18.3.0' }

//  加上 strictVersion → 至少报一个明确的错误
react: { singleton: true, strictVersion: true, requiredVersion: '^18.3.0' }

两行配置的差别,决定了你是花 10 分钟看报错信息定位问题,还是花半天在毫无线索的情况下大海捞针。

远程模块热更新:比想象中复杂得多

静态远程 vs 动态远程

MF 2.0 支持两种远程模块加载方式。静态远程在构建时确定入口 URL,动态远程在运行时决定从哪里加载。热更新的难度完全不同。静态远程的"热更新"其实是个伪命题——URL 不变,浏览器缓存不失效,用户刷新页面才能拿到新版本。

动态远程才有真正的热更新能力。核心流程是:从配置中心拿到最新的远程入口 URL(带版本 hash),动态初始化远程容器,让新容器和当前的共享作用域完成握手,再获取模块工厂。

热更新的真正难点:共享依赖的状态一致性

假设 Team B 发布了子应用新版本,主应用通过动态远程加载了新的 remoteEntry.js,新版本把 antd 从 5.12 升到了 5.15。

问题在于:旧版本的子应用已经通过共享作用域拿到了 antd@5.12,全局样式和 ConfigProvider 的上下文状态都已经注入到 DOM 里了。新版本注册了 antd@5.15,但共享作用域里 antd 早就被消费过了,协商窗口关了。结果就是新子应用用的还是 5.12 的 antd,但代码是按 5.15 的 API 写的——该有的方法不存在,该变的行为没变。

我们的方案:分代共享作用域

最终我们搞了一个"分代"机制。每次有远程模块热更新时,不在原来的共享作用域上修修补补,而是创建一个新的作用域"代",让新版本的模块在新代里协商。新代会继承上一代已锁定的共享模块(除了需要升级的部分),新版本的远程模块在新代中注册和解析,旧版本继续用旧代,互不干扰。代价是内存占用增加——同一个依赖可能在不同代里各加载一份。

分代方案不是万能的。有几种情况我们选择直接强制整页刷新:

  • react / react-dom 版本变了——这俩 singleton 没法分代,React 的内部状态是全局的
  • 共享的状态管理库(zustand、redux)大版本变了——store 结构不兼容
  • CSS-in-JS 运行时(styled-components、emotion)版本变了——样式上下文会出问题

分代方案的其他代价

调试变得更复杂了。多代并存意味着同一个 antd Button 组件可能在页面上有两个版本同时渲染,样式不一致。我们的解法是在分代切换时对旧代组件做一次强制 unmount + remount,但这会导致短暂的 UI 闪烁。

GC 也是个问题。

诊断工具链:从"猜"到"看见"

共享作用域可视化面板

排查共享依赖问题最痛苦的地方是看不见。

我们做了一个 Chrome DevTools 面板插件。核心逻辑不复杂:遍历 __webpack_share_scopes__ 的全部条目,提取包名、版本号、注册来源、是否 eager、是否已被消费等信息,外加我们通过 monkey-patch 注入的注册时间戳和消费时间戳,结构化成扁平的数据数组。

面板 UI 分两个视图。表格视图列出所有共享包及其版本,标记出哪些是协商胜出的、哪些是 fallback、哪些被跳过了。时间线视图展示每个远程容器的注册和消费顺序——时序问题在这个视图里一眼就能看出来。

版本协商模拟器

线上出了问题再排查太晚了,我们需要在 CI 阶段就发现潜在冲突。

思路是:构建阶段收集所有子应用的 shared 配置,模拟运行时的协商过程,提前暴露不兼容。模拟器遍历每个应用注册的包版本,对每个消费者的 requiredVersion 做匹配检查。两种情况会标红:一是 strictVersion 配了但没有满足条件的版本,二是 singleton 模式下最高版本不满足某个消费者的 requiredVersion——也就是说该消费者运行时会拿到一个不兼容的版本。

我们把这个 checker 集成到了 GitLab CI 的 merge request 流程里。每次有子应用提交 MR,CI 会从 federation registry 服务拉取所有其他子应用当前的 shared 配置,跑一遍模拟协商。有冲突的话 MR 直接标红,强制人工 review。上线两个月,这个 checker 在 CI 阶段拦截了 14 次潜在的版本冲突,其中 3 次如果上了线上就是白屏级别的故障。

运行时依赖图谱追踪

最后一个工具解决的是:页面上真出了共享依赖相关的 bug,怎么快速定位到是哪条依赖链路出了问题。

我们对 MF 的 __webpack_init_sharing__ 和远程容器的 init / get 方法做了 monkey-patch,记录每一次共享依赖的注册、协商和消费事件,包括时间戳、来源容器名、注册了哪些共享模块等。trace 数据导出为 JSON 后,扔到可视化面板里就能画出完整的依赖图谱和时间线。排查问题时,不用再在控制台里一层层展开对象了。

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

Vue3 响应式原理:从 Proxy 到依赖收集,手撸一个迷你 reactivity

手撸开始:一个 200 行以内的迷你 reactivity

第二步:effect——注册一个"关心数据变化"的函数

function effect(fn: () => void) {
  const run = () => {
    activeEffect = run
    effectStack.push(run)
    fn()                      // 执行 fn 的过程中会触发 get → 收集依赖
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null
  }
  run() // 立即执行一次,触发首次依赖收集
}

为什么要用栈?看这个场景:

effect(() => {          // effectA
  console.log(state.a)
  effect(() => {        // effectB
    console.log(state.b)
  })
  console.log(state.c)  // 这里 activeEffect 应该是 effectA,不是 effectB
})

如果不用栈,内层 effect 执行完后 activeEffect 就丢了,外层的 state.c 会收集不到依赖。这不是什么边界 case,嵌套 computed 就会触发这个场景。

第三步:依赖存储结构

// 数据结构:target → key → Set<effect>
// 翻译成人话:哪个对象的哪个属性,被哪些 effect 函数关心
const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>()

function track(target: object, key: string | symbol) {
  if (!activeEffect) return  // 没人在执行 effect,不用收集
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect) // 把当前 effect 函数记下来
}

function trigger(target: object, key: string | symbol) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const deps = depsMap.get(key)
  if (!deps) return
  deps.forEach(fn => fn()) // 数据变了,挨个通知
}

为什么用 WeakMap

第四步:reactive——把普通对象变成响应式

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key)                     // 读的时候:收集依赖
      const result = Reflect.get(obj, key, receiver)
      // 如果值还是对象,递归代理(懒代理,用到才代理)
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key as keyof T]
      const result = Reflect.set(obj, key, value, receiver)
      if (oldValue !== value) {
        trigger(obj, key)                 // 写的时候:触发更新
      }
      return result
    }
  })
}

注意这里的懒代理——不是一上来就递归把所有嵌套对象全代理了,而是访问到某个属性发现它是对象时才代理。这是 Vue3 相比 Vue2 的一个性能优化。Vue2 的 observe 是初始化时递归全量遍历,数据量大的时候初始化会卡。


设计权衡:为什么 Vue3 这么设计

Proxy vs defineProperty

维度 defineProperty Proxy
新增属性 拦不到,需要 $set 自动拦截
数组变异 需要 hack 7 个方法 原生支持
初始化成本 全量递归 懒代理,按需
兼容性 IE9+ IE 完全不支持
性能 属性多时慢 整体更优

Vue3 放弃 IE 不是任性,是 Proxy 没法 polyfill。这是个技术选型的取舍——用兼容性换来了更好的 API 和性能。

为什么不用脏检查(Angular 1 的方式)

脏检查是每次变化都全量对比。数据少的时候没啥,数据一多就是灾难。就像你为了看快递到没到,每五分钟打开门看一次,不如让快递员到了给你打电话。

为什么依赖收集在 get 里而不是手动声明

手动声明依赖意味着你需要自己维护"谁依赖谁"的关系。React 的 useEffect 就是这个思路——你得手写依赖数组。忘写一个?恭喜你喜提一个 stale closure bug。

Vue 的自动依赖收集虽然有运行时成本,但开发体验好太多。你用到了哪些数据,框架自动知道。


边界与踩坑

解构丢失响应式

回到开头的问题:

const state = reactive({ count: 0 })

//  解构出来是个普通值,和 state 断开了
const { count } = state // count = 0,就是个数字

//  用 toRefs 保持连接
const { count } = toRefs(state) // count 是个 ref,.value 和 state.count 同步

reactive 只能用于对象

//  基本类型不能用 reactive
const count = reactive(0) // 报错,Proxy 只能代理对象

//  基本类型用 ref
const count = ref(0) // 内部其实是 reactive({ value: 0 })

ref 本质上就是把基本类型包了一层对象,这就是为什么你要写 .value——不是 Vue 团队故意折磨你。

大数组 / 大对象的性能

响应式不是免费的。每次 get 都要执行 track,每次 set 都要 trigger。几千个属性的对象做响应式,初始化和更新都有开销。

// 大列表只读展示?别用 reactive
import { markRaw, shallowRef } from 'vue'

//  shallowRef:只有 .value 本身的替换是响应式的,内部属性不追踪
const bigList = shallowRef(fetchHugeList())

//  markRaw:标记对象永远不被代理
const rawData = markRaw(someHugeObject)

循环引用

const a = reactive({} as any)
const b = reactive({} as any)
a.b = b
b.a = a // ← 不会爆栈,因为是懒代理,访问到才代理

Vue3 的懒代理在这里救了你一命。如果是 Vue2 的全量递归,这就直接栈溢出了。


总结:响应式的通用思维模型

Vue3 的响应式不是什么独创发明,它是一个经典模式的精致实现:

拦截 → 收集 → 通知

  • 拦截:Proxy 拦截对象的读和写
  • 收集:读的时候记下"谁在读"
  • 通知:写的时候告诉所有读过的人"值变了"

这个模型不止用于 UI 框架。数据库的触发器、Excel 的公式联动、消息队列的发布订阅,底层都是这个模式。以后遇到类似的问题——"A 变了,B 要自动跟着变"——你就知道该用什么结构了:建一个依赖图,读时收集,写时触发。

最后送一句:理解响应式最好的方式不是读文档,是自己撸一遍。 200 行代码,一杯咖啡的时间,你能获得的理解比读十篇文章都多。

不会 Rust 也能玩 WebAssembly:3 个 npm install 就能用的 WASM 神器

刷掘金热榜发现 WebAssembly 又上去了,评论区一堆人说「学 WASM 得先学 Rust」,劝退了不少人。

说实话我之前也是这么想的——直到上个月做一个内部工具的时候,发现有些 npm 包底层就是 WASM,安装完直接用,完全不需要碰 Rust。今天分享 3 个我实际用过的,都是 npm install 一把梭,零 Rust 基础也能直接上手。

先说结论

干什么的 性能提升 上手难度
sql.js 浏览器里跑 SQLite 比 IndexedDB 查询快 5-10x ⭐ 极低
@ffmpeg/ffmpeg 浏览器里处理视频 JS 根本做不到的事 ⭐⭐ 低
photon-wasm 图片滤镜/裁剪/压缩 比 Canvas API 快 2-5x ⭐ 极低

场景一:浏览器里跑 SQLite(sql.js)

做后台管理系统的时候遇到一个需求:前端要对一个几万行的 CSV 做复杂筛选和聚合。一开始用 JS 数组硬撸 filter + reduce,代码写得我自己都看不懂,而且 5 万行数据一个聚合查询要卡 3 秒。

后来想到——为什么不在浏览器里直接用 SQL?

npm install sql.js
import initSqlJs from 'sql.js';

// 初始化,需要指定 wasm 文件位置
const SQL = await initSqlJs({
  locateFile: file => `https://sql.js.org/dist/${file}`
});

// 创建内存数据库
const db = new SQL.Database();

// 建表 + 导入 CSV 数据
db.run(`CREATE TABLE sales (
  date TEXT,
  region TEXT,
  product TEXT,
  amount REAL,
  quantity INTEGER
)`);

// 批量插入(用事务,不然会巨慢)
db.run('BEGIN TRANSACTION');
csvData.forEach(row => {
  db.run(
    'INSERT INTO sales VALUES (?, ?, ?, ?, ?)',
    [row.date, row.region, row.product, row.amount, row.quantity]
  );
});
db.run('COMMIT');

// 现在可以用 SQL 了!
const result = db.exec(`
  SELECT region,
         SUM(amount) as total_sales,
         COUNT(*) as order_count,
         AVG(amount) as avg_order
  FROM sales
  WHERE date >= '2026-01-01'
  GROUP BY region
  ORDER BY total_sales DESC
`);

console.log(result[0].values);
// [['华东', 2847563.5, 12847, 221.6], ['华南', ...]]

5 万行数据,这个聚合查询 60ms 搞定。之前纯 JS 要 3 秒多。

踩坑点

  1. wasm 文件要单独加载。如果用 Vite,需要把 sql-wasm.wasm 放到 public 目录,locateFile 指向 /sql-wasm.wasm
  2. 数据库在内存里,刷新就没了。想持久化可以用 db.export() 导出 Uint8Array,存到 IndexedDB 或者 localStorage
  3. 不支持并发写入。如果有 Web Worker 也在操作同一个数据库实例,会出问题。建议把 sql.js 整个跑在一个 Worker 里
// 持久化方案
const data = db.export();
const buffer = new Uint8Array(data);
localStorage.setItem('mydb', JSON.stringify(Array.from(buffer)));

// 恢复
const saved = JSON.parse(localStorage.getItem('mydb'));
const db = new SQL.Database(new Uint8Array(saved));

场景二:浏览器里剪视频(@ffmpeg/ffmpeg)

这个是真没想到的——FFmpeg 编译成了 WASM,能在浏览器里跑。

我的场景是做一个视频剪辑小工具,用户上传视频后自动截取前 30 秒作为预览。之前都是传到后端处理,现在直接前端搞定,省了一台服务器。

npm install @ffmpeg/ffmpeg @ffmpeg/util
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

// 加载 WASM(首次会比较慢,约 25MB)
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
  coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
  wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});

// 监听进度
ffmpeg.on('progress', ({ progress }) => {
  console.log(`处理进度: ${(progress * 100).toFixed(1)}%`);
});

// 写入文件到虚拟文件系统
const videoFile = document.querySelector('input[type="file"]').files[0];
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));

// 截取前 30 秒 + 压缩
await ffmpeg.exec([
  '-i', 'input.mp4',
  '-t', '30',           // 只要前 30 秒
  '-vf', 'scale=720:-2', // 压缩到 720p
  '-c:v', 'libx264',
  '-preset', 'fast',
  '-crf', '28',
  'output.mp4'
]);

// 读取结果
const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);

// 直接在页面上播放
document.querySelector('video').src = url;

踩坑点

  1. WASM 文件巨大。ffmpeg-core.wasm 大概 25MB,首次加载会很慢。建议做 loading 动画 + 缓存到 Service Worker
  2. SharedArrayBuffer 限制。多线程版本需要页面设置 COOP/COEP 响应头,很多部署环境不支持。单线程版也能用,就是慢一些:
    // 单线程版本,兼容性更好
    const baseURL = 'https://unpkg.com/@ffmpeg/core-st@0.12.6/dist/esm';
    
  3. 2GB 文件上限。WASM 内存限制,超过 2GB 的视频处理不了。不过前端场景一般也碰不到这个上限
  4. iOS Safari 有坑。部分老版本 Safari 对 WASM 内存分配有 bug,大文件处理可能崩溃。2026 年的 Safari 17+ 基本没问题了

场景三:图片处理快到飞起(photon-wasm)

Canvas API 做图片处理不是不能用,但一旦图片大一点(比如 4K),肉眼可见地卡。photon 是 Rust 写的图片处理库,编译成 WASM 后性能碾压 Canvas。

npm install @aspect-build/photon-wasm
# 或者直接用 CDN
import * as photon from '@aspect-build/photon-wasm';

// 从 Canvas 获取图片数据
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

// 加载图片到 canvas
const img = new Image();
img.src = 'photo.jpg';
await new Promise(resolve => img.onload = resolve);
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);

// 创建 PhotonImage
const image = photon.open_image(canvas, ctx);

// 应用滤镜 —— 一行代码搞定
photon.filter(image, 'oceanic');    // 海洋风滤镜
// photon.grayscale(image);         // 灰度
// photon.gaussian_blur(image, 3);  // 高斯模糊
// photon.sharpen(image);           // 锐化

// 调整亮度对比度
photon.alter_channel(image, 0, 20);  // R通道+20

// 写回 canvas
photon.putImageData(canvas, ctx, image);

// 导出为 Blob 下载
canvas.toBlob(blob => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'processed.jpg';
  a.click();
}, 'image/jpeg', 0.9);

实测一张 4000x3000 的照片:

操作 Canvas API photon-wasm 提速
灰度转换 180ms 35ms 5.1x
高斯模糊 2400ms 480ms 5.0x
批量滤镜(3个叠加) 850ms 190ms 4.5x

踩坑点

  1. npm 包名很混乱。搜 photon wasm 会找到好几个包,认准 GitHub(silvia-odwyer/photon)上的官方版本
  2. 内存要手动管理PhotonImage 对象用完记得调用 .free() 释放 WASM 内存,不然会内存泄漏
  3. 不支持 HEIC 格式。苹果的 HEIC 图片需要先用其他库转成 JPEG/PNG 再处理

什么时候该用 WASM,什么时候别折腾

说实话大部分前端场景不需要 WASM。如果你只是做个 CRUD 后台,加什么 WASM 纯属给自己找事。

但这几种情况值得考虑:

  • 计算密集型:大量数据处理、加解密、图片/音视频处理
  • 现有 C/C++/Rust 轮子:比如 SQLite、FFmpeg,直接编译过来比用 JS 重写强一万倍
  • 需要离线能力:数据库、文档解析这些在端上跑,不依赖后端

而且 WASI 0.3 刚在今年 2 月发布了,以后 WebAssembly 不只是浏览器的东西——边缘计算、Serverless 都能用。路线图里甚至有 wasi:nn 专门用来跑 AI 推理,以后在浏览器里直接跑模型可能也不是梦。

小结

说白了 WebAssembly 对前端来说就是一个性能工具箱。不需要学 Rust,不需要懂编译原理,npm install 完就能享受 native 级别的性能。

这三个库我在实际项目里都用过,sql.js 用得最多(报表系统的前端聚合),ffmpeg.wasm 偶尔用(用户端视频预处理),photon 适合需要批量图片处理的场景。

热榜在问「前端要不要学 WASM」——我觉得不用「学」它,就行了。

从“输入网址”到“帧级控制”:我对事件循环与主线程管理的终极认知

摘要:你是否真正清楚从输入 URL 到页面渲染的每一毫秒里,浏览器到底做了什么?渲染究竟发生在事件循环的哪一步?为什么 Promise 总是比 setTimeout 快?为什么时间切片必须依赖宏任务?本文将以全链路视角,从用户输入地址开始,还原“同步代码 -> 微任务清空 -> 多队列宏任务调度 -> 渲染”的完整闭环。我们将深入浏览器内核的多队列优先级机制,并重新定义性能优化的核心:利用宏任务的“单次执行”机制,主动切割主线程的连续占用时间。


🚀 第一部分:起点——从输入 URL 到主线程的“第一行代码”

一切始于用户在地址栏输入 URL 并按下回车。这一刻,一场精密的交响乐正式奏响。

1. 前置状态:空标签页的主线程在做什么?

在请求发出前,如果我们打开的是一个空白标签页:

  • 渲染进程(Renderer Process) :已分配,进程存在。
  • 主线程(Main Thread) :已创建,处于**“空闲等待(Idle/Waiting)”状态。它运行着底层的消息循环(Message Loop)**,但没有任何业务代码。
  • 结论:主线程就像一个亮着灯的空舞台,演员(JS 引擎)待命,只等剧本(HTML/JS)通过网络送达。

2. 网络阶段:被忽视的“宏观异步”

  • 网络请求:浏览器发起 HTTP 请求。此时主线程继续空闲或处理其他已有任务。

  • 资源下载:HTML 文件通过网络传输(这可能耗时几十毫秒到几秒)。

  • 颠覆性视角

    我们常认为首屏脚本是“同步代码”,但从系统视角看,它本质上是一段“基于网络 I/O 的宏观异步代码”
    主线程一直在“异步等待”资源到位。一旦 HTML 下载完成,解析器开始工作,生成的 <script> 执行任务才被推入主线程。

3. 第一阶段:执行同步代码(Synchronous Code)

当 HTML 解析遇到 <script> 标签(非 async/defer):

  • 动作:解析暂停,JS 引擎立即执行脚本中的全局同步代码

  • 现象:这是主线程第一次被连续独占

  • 细节

    • 变量声明、函数定义立即执行。
    • 如果遇到 setTimeoutPromise,它们的注册代码(如设置定时器、创建 Promise 对象)会同步执行,但回调函数会被分别扔进宏任务队列微任务队列此时绝不执行
    • DOM 操作同步更新内存中的 DOM 树,但屏幕尚未渲染

⚙️ 第二部分:事件循环的微观全流程(含多队列优先级与渲染时机)

当全局同步代码执行完毕,调用栈清空。此时,主线程并没有立刻去拿宏任务,而是进入了著名的事件循环(Event Loop) 。这是一个严格的多队列调度闭环。

1. 多队列的真实架构

浏览器并非只有一个“宏任务队列”,而是维护着多个不同优先级的宏任务队列(Task Queues) ,它们对应不同的任务源(Task Sources):

  • 🔴 用户交互队列(User Interaction) :点击、滚动、键盘输入。优先级最高,为了保障极致的流畅度,这类任务往往会被优先调度。
  • 🟠 网络回调队列(Network)fetchXHR、WebSocket 消息到达。
  • 🟡 定时器队列(Timer)setTimeoutsetInterval 到期任务。
  • 🟢 解析任务队列(Parsing) :HTML 解析过程中产生的后续脚本执行任务。
  • 🔵 微任务队列(Microtask Queue)只有一个。存放 Promise 回调、MutationObserverqueueMicrotask

2. 事件循环的精确执行步骤(The Loop)

一个完整的事件循环迭代(Iteration)遵循以下铁律顺序

Step 0: 同步代码执行完毕

  • 全局脚本或上一个宏任务中的同步代码运行结束,调用栈清空。

Step 1: 🔴 强制清空微任务队列(Microtask Checkpoint)

  • 这是铁律! 在去取任何宏任务之前,事件循环必须先检查微任务队列。
  • 动作:只要微任务队列不为空,就依次取出并执行所有微任务
  • 循环机制:如果在执行微任务 A 时产生了微任务 B,B 会被立即加入队列并在当前轮次紧接着执行。这个过程会一直持续,直到微任务队列彻底为空
  • 注意:在此阶段,渲染尚未发生。如果微任务无限循环,宏任务和渲染将永远被阻塞。

Step 2: 🟢 尝试更新渲染(Rendering Update)

  • 时机:只有当微任务队列彻底清空后。

  • 动作:浏览器检查是否有需要更新的视觉变化(DOM 变更、样式计算、布局、绘制)。

  • 条件

    1. 如果有 DOM/CSS 变动。
    2. 且距离上一帧渲染已超过一定时间(通常目标是 16.6ms/60fps)。
  • 结果:浏览器进行Render(渲染) ,将画面呈现给用户。

    • 🌟 这就是用户感知到“页面动了”或“点击有反应”的时刻。

Step 3: 🔵 智能选择并取出一个宏任务(Macrotask Selection)

  • 现在,微任务空了,渲染也做完了(或不需要做)。事件循环开始处理宏任务。

  • 关键机制:多队列优先级调度

    • 事件循环不会简单地按“先进先出”从一个大池子里取任务。

    • 它会扫描所有宏任务队列(用户交互、网络、定时器等)。

    • 策略:根据队列优先级任务饥饿防止算法进行选择。

      • 例如:如果“用户交互队列”里有点击事件,即使“定时器队列”里的任务更早进入,浏览器也极可能优先取出用户交互任务,以保证响应性。
      • 例如:网络回调通常比普通定时器优先级高。
  • 动作:从选中的那个队列中,取出第一个任务(Task)

    • 🔴 核心限制每次循环只取一个任务! 即使该队列后面还有 99 个任务,本次也只处理这 1 个。剩下的留在队列里,等下一轮循环再竞争。

Step 4: 执行宏任务

  • 主线程开始执行这个被取出的任务。
  • 在此期间,如果产生了新的微任务,它们会被加入微任务队列,但不会立即执行,必须等到下一个循环的 Step 1

Step 5: 回到 Step 1(循环继续)

  • 宏任务执行完毕 -> 回到 Step 1 清空新产生的微任务 -> Step 2 渲染 -> Step 3 选下一个宏任务...

💡 第三部分:从原理到实践——为何我们需要“时间切片”?

理解了上述从同步代码到多队列调度的完整流程,我们终于来到了前端性能优化的核心战场。

1. 现实的痛点:长任务的“霸权”

回顾一下 Step 3 和 Step 2 的关系:

  • 浏览器只有在当前宏任务执行完毕后,才有机会去检查渲染(Step 2)。

  • 如果一个宏任务(比如处理 10 万条数据、复杂的 DOM 计算)执行了 500ms,那么在这 500ms 内:

    • 微任务队列无法被清空(因为宏任务没结束)。
    • 渲染更新无法进行(因为宏任务没结束)。
    • 用户交互(点击、滚动)即使进入了高优先级队列,也必须等待当前这个“霸道”的宏任务跑完才能被取出(Step 3)。

后果:页面白屏、点击无反应、滚动卡顿。这就是典型的主线程阻塞

2. 破局之道:主动“切割”主线程

既然浏览器的事件循环机制是 “每次只取一个宏任务,然后强制检查渲染” ,那么聪明的工程师就会想:

“如果我无法改变浏览器的调度规则,那我能不能主动迎合它?如果我故意把一个需要 500ms 的大任务,拆分成 10 个 50ms 的小任务,分别作为 10 个独立的宏任务放入队列,会发生什么?”

答案

  • 每执行完一个 50ms 的小任务,事件循环就会进入 Step 1(清微任务)  和 Step 2(渲染)
  • 浏览器获得了 10 次刷新屏幕的机会。
  • 用户交互队列获得了 10 次插队执行的机会。
  • 结果:原本卡死的 500ms,变成了丝滑的 10 帧动画。

这就是时间切片(Time Slicing) 的本质:它不是一种新的 API,而是一种利用事件循环“单任务执行 + 渲染间隙”机制的工程化策略。

3. 为什么 Promise 做不到,而 setTimeout 可以?

现在,让我们用刚才学到的铁律顺序来验证两种实现方式。

❌ 错误示范:试图用 Promise (微任务) 切片

javascript

编辑

1function badSlice() {
2  processChunk(); // 处理一小块
3  Promise.resolve().then(badSlice); // 尝试递归
4}
  • 原理分析

    • processChunk() 执行完。
    • 进入 Step 1 (清微任务) :发现队列里有 badSlice 的回调。
    • 立即执行 badSlice
    • 在 badSlice 里又产生新的微任务...
    • 死循环:根据铁律,微任务必须彻底清空才能进入 Step 2 (渲染)。只要你的递归不停止,微任务队列永远不为空,渲染永远被阻塞
  • 结局:页面依然卡死,甚至可能因栈溢出或微任务过多导致崩溃。

✅ 正确示范:利用 setTimeout (宏任务) 切片

1function goodSlice() {
2  processChunk(); // 处理一小块(同步执行,耗时短,如 10ms)
3  setTimeout(goodSlice, 0); // 将下一块推入【宏任务队列】
4}
  • 原理深度解析

    1. processChunk() 执行完毕(当前宏任务结束)。

    2. Step 1:检查微任务队列(为空)。

    3. Step 2 (关键!) :微任务已空,浏览器立即触发渲染。用户看到画面更新,感觉到页面是“活”的。

    4. Step 3:事件循环根据优先级,从宏任务队列中取出下一个任务(即刚才 setTimeout 放入的 goodSlice)。

      • 注意:这里完美利用了“每次只取一个宏任务”的机制。
    5. Step 4:执行下一块数据。

    6. 循环:回到 Step 1,再次为渲染腾出空间。

核心结论:

时间切片的成功,完全依赖于我们将大任务拆解为独立的“宏任务”。
只有宏任务的边界,才是事件循环中 “执行 -> 渲染 -> 再执行” 的天然分割线。微任务由于必须在渲染前全部清空,无法充当这个分割线。

🎯 第四部分:重新定义性能优化——管理“连续占用时长”

基于以上全链路理解,我们需要修正对性能优化的认知:

❌ 误区:优化是为了减少总计算时间

  • 事实:如果你的业务逻辑需要计算 100 万个数据,无论是否切片,CPU 的总指令数是固定的。
  • 代价:切片甚至会因为多次进出事件循环、上下文切换(Context Switch)和定时器开销,导致总耗时略微增加

✅ 真相:优化是为了管理“连续占用时长”

  • 目标:控制主线程连续执行同步代码的时间片(Time Slice)

  • 标准:确保每个宏任务的执行时间 < 50ms(理想情况 < 16.6ms),以便在 Step 2 能够及时触发渲染。

  • 收益

    • 虽然总耗时可能从 2.0s 变成 2.1s。
    • 但用户感受到的是:每隔 16ms 页面就能响应一次操作,全程丝滑,而不是前 2s 完全卡死,第 2.1s 突然恢复。

核心结论

“前端性能优化的核心,不在于改变业务的总计算量,而在于管理主线程的‘连续占用时长’。通过时间切片,我们将长任务拆解为符合帧率要求的短宏任务,利用事件循环的‘单任务执行’机制,在任务间隙强制插入渲染和用户响应窗口。这是以微小的总效率损耗,换取极致的用户体验响应性(Responsiveness) 。”


🌟 总结:全链路异步世界观

通过这次从“输入 URL”开始的深度剖析,我们建立了一套完整的认知体系:

  1. 宏观视角(网络层) :从输入网址开始,所有代码本质上都是网络异步到达主线程的。首屏脚本只是第一个“大宏任务”。

  2. 执行顺序(时间轴铁律)

    • 同步代码:立即执行,阻塞解析。
    • 微任务:同步代码结束后,立即全部清空(优先级高于宏任务)。
    • 渲染:发生在微任务清空后、下一个宏任务前
    • 宏任务:每次循环只取一个,且需经过多队列优先级调度(用户交互 > 网络 > 定时器等)。
  3. 优化策略(工程层) :利用 setTimeout 等宏任务机制实现时间切片,主动切割主线程的连续占用时间,确保渲染时机能按时到来。

🌟 终极结语:从“码农”到“系统架构师”的觉醒

如果把这篇关于事件循环的深度解析作为终点,那么它留给我们的不应该仅仅是几个面试题的答案,而应该是一种思维模式的转变

不要只做需求的“翻译官”,要做系统的“操盘手”。

当你写下每一行代码时,请试着透过语法糖,看到背后正在发生的:

  • 主线程是否在连续空转?
  • 微任务队列是否正在无限膨胀?
  • 渲染管线是否被阻塞在下一个宏任务之前?
  • 用户的点击是否能在 100ms 内得到响应?

真正的工程能力,不在于你掌握了多少 API,而在于你能否利用这些 API,去精细地管理浏览器的每一毫秒,去驾驭那个看不见的、复杂的异步世界,最终交付给用户一个如丝般顺滑的系统。

这就是我们从“输入 URL”一直推导到“时间切片”的终极奥义。

愿你在未来的架构设计中,都能游刃有余地驾驭主线程,打造出极致流畅的用户体验!🚀


如果你觉得这篇深度解析对你有启发,欢迎点赞、收藏、转发,让我们一起在前端底层原理的道路上不断精进!

开发环境优化完全指南:告别等待,让开发如丝般顺滑

前言

想象一下这个场景:

我们正在写一个复杂的组件,思路如泉涌。保存文件,想看看效果:5 秒... 10 秒... 30 秒...

等页面刷新出来的时候,我们已经忘了刚才在想什么。心流被打断,灵感消失,只能重新理清思路。

这不是技术问题,这是对开发者时间的浪费。

根据 Stack Overflow 2023 年的调查,前端开发者平均每天要等待 30 - 60 分钟用于构建和热更新。

好消息是:这些等待时间,大部分都可以被优化掉。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮你一步步把开发环境的等待时间从“喝杯咖啡”缩短到“眨个眼”。

为什么会慢?先找到问题在哪

# 早上9点,开始工作
$ npm run dev

# 等待... 30 秒后项目终于启动了
# 打开浏览器,还要等 10 秒才能看到页面

# 修改一个文件,保存
# 等待... 10 秒后热更新完成

# 一天下来:
# 启动次数:10次 × 30 秒 = 300秒
# 修改次数:100次 × 10 秒 = 1500秒
# 总等待时间:1500秒 = 25分钟

这还只是保守估计。在大项目中,等待时间可能是这个数字的 3-5 倍。

开发环境的性能瓶颈

开发环境的速度主要受四个因素影响:

  1. 依赖处理:扫描、预构建 node_modules
  2. 文件编译:转换 .vue.ts.scss 等文件
  3. 模块图维护:跟踪文件之间的依赖关系
  4. 网络传输:浏览器加载文件的速度

如何判断瓶颈在哪?

我们可以使用 Vite 的调试模式:

vite --debug

我们会看到类似这样的输出:

vite:deps 扫描依赖中... 245.3ms
vite:deps 找到 156 个依赖 245.3ms
vite:deps 预构建中... 3240.5ms  ← 这里最慢!
vite:server 服务器启动完成 3512.8ms

根据输出结果,我们就可以做出正确的决断:

  • 如果 预构建 时间最长 → 优化依赖预构建
  • 如果 转换文件 时间最长 → 优化文件编译
  • 如果 服务器启动 时间最长 → 优化配置

依赖预构建优化 - 80%的性能提升从这里开始

什么是依赖预构建?

想象我们要整理一个巨大的图书馆(node_modules):

  • 不预构建:每次有人要看书,都要现场整理那一本书
  • 预构建:提前把所有书整理好,有人要就直接拿

Vite 的预构建就是提前把第三方库整理成浏览器可以直接使用的格式。

为什么需要手动配置预构建?

Vite 默认会自动预构建,但它其实没有那么智能,以下场景,Vite 并不会预构件:

场景1:动态导入

if (user.isAdmin) {
  const Chart = await import('echarts')  // 不会被预构建!
}

场景2:Monorepo 本地包

import { Button } from '@company/ui'  // 不会被预构建!

场景3:深层依赖

import 'a'  // a 依赖 b,b 依赖 c  // c 可能不会被预构建! 

include 优化:告诉 Vite 需要预构建什么

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  
  optimizeDeps: {
    // ✅ 需要预构建的依赖
    include: [
      // 1. 体积大的库(减少请求数)
      'echarts',           // 原来可能有几百个文件,合并成一个
      'lodash-es',         // lodash-es 有 600+ 个文件!
      'ant-design-vue',    // UI 库通常都很大
      
      // 2. Monorepo 中的本地包
      '@company/ui',
      '@company/utils',
      '@company/hooks',
      
      // 3. 动态导入的库
      'monaco-editor',     // 只在需要时加载,但预构建后加载更快
      'xlsx',              // 导出功能可能不常用,但需要时希望快
      
      // 4. 有深层依赖的库
      'date-fns',          // 有很多子模块
      'lodash'             // 虽然不推荐,但如果用了就预构建
    ]
  }
})

exclude 优化:告诉 Vite 不需要预构建什么

// vite.config.js
export default defineConfig({
  optimizeDeps: {
    exclude: [
      // 1. 已经提供 ESM 格式的现代库
      'vue',           // Vue 本身已经优化好
      'vue-router',    // 不需要再打包
      'pinia',
      
      // 2. 很少用到的大库(按需加载更好)
      'pdfjs-dist',    // 只在查看 PDF 时用到
      'three',         // 只在 3D 页面用到
      
      // 3. 有特殊构建要求的库
      '@sentry/browser',  // 有自己的构建工具
      'firebase'          // 复杂的构建配置
    ]
  }
})

include 还是 exclude?一个流程看懂

遇到一个依赖 →
    ↓
是本地包(@company/xxx)? → 是 → include
    ↓否
是动态导入的? → 是 → include
    ↓否
体积 > 1MB? → 是 → include(除非很少用)
    ↓否
依赖深度 > 3层? → 是 → include
    ↓否
已提供 ESM 格式? → 是 → 可以 exclude
    ↓否
用默认行为

实战:如何找出需要 include 的依赖

// scripts/analyze-deps.js
import fs from 'fs'
import path from 'path'

// 分析 node_modules 中哪些包体积大
function findHeavyDeps() {
  const nodeModules = path.resolve('node_modules')
  const deps = fs.readdirSync(nodeModules)
    .filter(d => !d.startsWith('.'))
    .map(dep => {
      const pkgPath = path.join(nodeModules, dep)
      try {
        const stats = fs.statSync(pkgPath)
        return { name: dep, size: stats.size }
      } catch {
        return { name: dep, size: 0 }
      }
    })
    .sort((a, b) => b.size - a.size)
    .slice(0, 20)  // 前20个最大的
  
  console.log('体积最大的依赖:')
  deps.forEach(d => {
    console.log(`${d.name}: ${(d.size / 1024 / 1024).toFixed(2)}MB`)
  })
}

findHeavyDeps()

文件监听优化 - 让电脑知道该看哪

为什么需要优化文件监听?

Vite 默认会监听项目中的所有文件。在大型项目中,这可能会导致很多问题:

  • CPU 占用高:要监控几万个文件的变化
  • 内存占用大:要维护所有文件的状态
  • 更新慢:变化时要检查的文件太多

配置监听范围

// vite.config.js
export default defineConfig({
  server: {
    watch: {
      // ❌ 不要监听这些文件夹
      ignored: [
        '**/node_modules/**',  // 依赖包,不需要监听
        '**/dist/**',          // 构建输出,不需要监听
        '**/.git/**',          // git 目录
        '**/.idea/**',         // IDE 配置
        '**/.vscode/**',       // VSCode 配置
        '**/*.log',            // 日志文件
        '**/coverage/**',      // 测试覆盖率报告
        '**/tests/**',         // 测试文件(通常不需要热更新)
        '**/__tests__/**',     // 同上
        '**/__mocks__/**'      // Mock 文件
      ],
      
      // 只在需要的地方监听
      // 默认会监听整个项目,但我们可以更精确
      paths: [
        'src/**',              // 源代码
        'index.html',          // 入口文件
        'vite.config.js'       // 配置文件
      ]
    }
  }
})

热更新优化 - 从“等 5 秒”到“眨眼就好”

热更新为什么慢?

修改文件
    ↓
Vite 发现变化
    ↓
重新编译这个文件
    ↓
找出所有依赖这个文件的模块(可能很多!)
    ↓
重新编译所有受影响的模块
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求新模块
    ↓
执行更新

优化一:减少模块依赖范围

// 不好的做法:一个文件导入太多东西
// UserManagement.vue
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
import { useSettingsStore } from '@/stores/settings'
import UserList from './UserList.vue'
import UserForm from './UserForm.vue'
import UserFilters from './UserFilters.vue'
import UserStats from './UserStats.vue'
// ... 20 个 import

// ✅ 好的做法:按需加载,拆分组件
// UserManagement.vue
import { useUserStore } from '@/stores/user'  // 只导入需要的

// 其他组件通过异步加载
const UserList = defineAsyncComponent(() => import('./UserList.vue'))
const UserForm = defineAsyncComponent(() => import('./UserForm.vue'))
const UserFilters = defineAsyncComponent(() => import('./UserFilters.vue'))

优化二:定义热更新边界

// 在组件中明确告诉 Vite 如何处理更新
if (import.meta.hot) {
  // 1. 接受自身更新(默认行为)
  import.meta.hot.accept()
  
  // 2. 只接受某些依赖的更新
  import.meta.hot.accept(['./api.js', './utils.js'], (modules) => {
    console.log('API 或工具函数更新了')
    // 重新执行某些逻辑
  })
  
  // 3. 拒绝更新(某些模块不适合热更新)
  import.meta.hot.decline('./heavy-chart.js')
  
  // 4. 清理资源(更新前执行)
  import.meta.hot.dispose(() => {
    // 清理定时器、事件监听器等
    clearInterval(timer)
    window.removeEventListener('resize', handler)
  })
}

优化三:CSS 热更新优化

// vite.config.js
export default defineConfig({
  css: {
    // 开发时的 CSS 选项
    devSourcemap: false,  // 关闭 sourcemap,加快速度
    
    preprocessorOptions: {
      scss: {
        // 缓存编译结果
        implementation: 'sass',
        // 避免使用 fiber(会导致热更新慢)
        fiber: false,
        // 全局注入变量(只注入需要的)
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

优化四:使用更快的编译器

// vite.config.js
export default defineConfig({
  // 使用 esbuild 替代 tsc 进行 TypeScript 转译
  esbuild: {
    target: 'es2020',
    // 启用 esbuild 的 JSX 编译
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
    // 排除不需要转译的文件
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules/
  },
  
  // 生产构建时才使用 TypeScript 检查
  plugins: [
    vue(),
    // 开发环境不检查类型,加快速度
    process.env.NODE_ENV === 'production' && tsChecker()
  ]
})

内存优化 - 让浏览器喘口气

为什么内存占用高?

内存占用主要来自:

  • 模块图:记录所有文件的依赖关系
  • 转换缓存:每个文件转换后的结果
  • sourcemap:调试用的映射信息
  • 浏览器缓存:编译后的代码

配置内存限制

// vite.config.js
export default defineConfig({
  server: {
    // 模块缓存限制
    moduleCache: {
      maxSize: 500  // 最多缓存 500 个模块
    },
    
    // 模块图清理间隔
    moduleGraph: {
      pruneInterval: 60000  // 每 60 秒清理一次未使用的模块
    }
  },
  
  // 开发环境关闭 sourcemap
  build: {
    sourcemap: false
  },
  
  // 限制处理的文件大小
  esbuild: {
    exclude: [/\.(png|jpe?g|gif|webp|mp4|webm|ogg|mp3|wav|flac|aac)$/]
  }
})

内存监控和自动清理

// 在 vite.config.js 中添加内存监控
export default defineConfig({
  plugins: [
    {
      name: 'memory-monitor',
      configureServer(server) {
        let timer = setInterval(() => {
          const used = process.memoryUsage().heapUsed / 1024 / 1024 / 1024
          
          if (used > 1.5) {  // 超过 1.5GB
            console.log(`🧹 内存使用 ${used.toFixed(2)}GB,正在清理...`)
            
            // 清理模块缓存
            server.moduleGraph.clear()
            
            // 强制垃圾回收(如果可用)
            if (global.gc) {
              global.gc()
            }
          }
        }, 60000)  // 每分钟检查一次
        
        // 服务器关闭时清理定时器
        server.httpServer?.on('close', () => {
          clearInterval(timer)
        })
      }
    }
  ]
})

一键优化配置模板

完整的优化配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { dependencies } from './package.json'

// 需要预构建的重型依赖
const heavyDeps = [
  'echarts',
  'ant-design-vue',
  'lodash-es',
  'xlsx',
  'monaco-editor',
  'd3',
  'three',
  '@company/ui',
  '@company/utils',
  '@company/charts'
]

// 不需要预构建的现代库
const esmDeps = ['vue', 'vue-router', 'pinia', 'vueuse']

export default defineConfig({
  plugins: [vue()],
  
  // 依赖优化
  optimizeDeps: {
    include: heavyDeps,
    exclude: esmDeps,
    // 使用 esbuild 加速
    esbuildOptions: {
      target: 'es2020',
      define: {
        'process.env.NODE_ENV': '"development"'
      }
    }
  },
  
  // 开发服务器配置
  server: {
    // 启用 HTTP/2 加速请求
    https: true,
    http2: true,
    
    // 文件监听优化
    watch: {
      ignored: [
        '**/node_modules/**',
        '**/dist/**',
        '**/.git/**',
        '**/.idea/**',
        '**/.vscode/**',
        '**/*.log',
        '**/coverage/**',
        '**/tests/**',
        '**/__tests__/**',
        '**/__mocks__/**'
      ]
    },
    
    // 内存优化
    moduleCache: {
      maxSize: 500
    },
    
    // 热更新优化
    hmr: {
      timeout: 5000,
      overlay: false  // 关闭错误覆盖,加快速度
    }
  },
  
  // 编译优化
  esbuild: {
    target: 'es2020',
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules|\.(png|jpe?g|gif|webp|mp4)$/,
    jsxFactory: 'h',
    jsxFragment: 'Fragment'
  },
  
  // CSS 优化
  css: {
    devSourcemap: false,
    preprocessorOptions: {
      scss: {
        implementation: 'sass',
        fiber: false,
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

NPM 脚本优化

{
  "scripts": {
    "dev": "vite",
    "dev:debug": "vite --debug",
    "dev:fresh": "rm -rf node_modules/.vite && vite",
    "dev:profile": "vite --profile",
    "build": "vite build",
    "preview": "vite preview",
    "analyze": "node scripts/analyze-deps.js"
  }
}

常见问题速查表

启动很慢

可能原因 解决方案
预构建太多 优化 include 配置
文件监听范围太大 配置 watch.ignored
依赖版本冲突 删除 node_modules 重装
磁盘 I/O 瓶颈 迁移到 SSD

热更新慢

可能原因 解决方案
模块图过大 拆分大组件
没有定义热更新边界 使用 import.meta.hot.accept()
CSS 编译慢 优化预处理器配置
浏览器卡顿 关闭不必要的扩展

内存占用高

可能原因 解决方案
缓存太多 限制 moduleCache.maxSize
没有垃圾回收 添加内存监控和清理
sourcemap 太大 关闭 devSourcemap
内存泄漏 检查插件和代码

优化检查清单

  • 使用 vite --debug 分析启动时间
  • 确认 include 包含所有重型依赖
  • 确认 exclude 排除了已优化的依赖
  • 优化文件监听范围
  • 拆分大文件为小组件
  • 使用虚拟列表处理长列表
  • 启用 HTTP/2
  • 监控内存使用
  • 配置合理的缓存策略

结语

记住:开发者的时间比机器的时间更宝贵。花一个小时优化开发环境,可能每天能为团队节省数小时的等待时间。这是性价比最高的投资之一。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

从动漫水面到赛博飞船:这位开发者的Three.js作品太惊艳了

不是游戏引擎做不起,而是 React Three Fiber更有性价比。

今天给大家安利一个宝藏开发者 Christian Ortiz,以及他的两个开源项目——看完你会明白,用Web技术做3D视觉效果,已经卷到什么程度了。


项目一:Anime Water Scene —— 动漫风格水面场景

GitHub: github.com/cortiz2894/…

先看效果:

  • 类似《海贼王》《鬼灭之刃》那种手绘风格的动漫水面
  • 物体入水时有经典的动漫式涟漪圆环
  • 水下有随水流波动的Voronoi纹理海底
  • 水面与物体交界处有发光轮廓线

技术亮点拆解:

1. 多层渲染管线(6层叠加)

SeabedFloor(海底纹理)
  ↓
WaterFloor(水面着色)
  ↓
WaterDepthIntersection(深度发光)
  ↓
WaterWaveSimulation(波浪模拟)
  ↓
WaterSparkles(水面闪光粒子)
  ↓
Ripple System(涟漪系统)

这不是简单的"贴图+水面",而是真正的分层合成渲染

2. 自定义GLSL着色器 —— 核心黑科技

Voronoi Cel-Shading(赛璐珞着色)

// 简化版核心逻辑
float voronoi = voronoiF1(pos) - smoothVoronoiF1(pos);
vec3 waterColor = mix(deepColor, highlightColor, voronoi);

Voronoi F1 − SmoothF1 算法,复刻了Blender的动态绘画效果,实现了那种"一块一块"的动漫水面质感。

3. GPU物理波浪模拟(PDE方程)

不是简单的正弦波,而是真正的偏微分方程 模拟

h_next = 2·h_cur − h_prev + c²·∇²h

每帧三次渲染通道:

  1. Injection —— 检测物体入水形状
  2. Wave Update —— 求解波浪方程(ping-pong双缓冲)
  3. Display —— 根据高度梯度渲染波纹

4. 屏幕空间深度检测

物体与水面交界处的效果,用深度图比较实现:

  • 渲染一遍场景深度到纹理
  • 水面像素对比自身深度和场景深度
  • 差值越小 → 发光越强

这技术在各种3A游戏里都在用,现在Web端也能跑了。


项目二:Ship Selection Page —— 赛博飞船选择界面

GitHub: github.com/cortiz2894/…

这是游戏《Laser Drift: Neon Blast》的飞船选择界面,有完整的YouTube教程系列。

核心效果:

截屏2026-03-20 19.44.22.png

截屏2026-03-20 19.45.36.png

  • 蒸汽波(Vaporwave)美学风格
  • 飞船线框揭示动画(Wireframe Reveal)
  • 3D飞船展示 + 属性面板
  • 粒子背景系统
  • 手势控制支持

技术亮点:

1. 线框揭示动画(Wireframe Reveal)

不是简单的淡入淡出,而是从线框到实体的渐变:

  • 先用GLSL把模型渲染成线框
  • 通过shader的discard逻辑,控制像素显示/隐藏
  • 配合GSAP动画,实现"绘制出来"的效果

2. GLB模型烘焙纹理

  • 从Blender导出GLB格式
  • 烘焙光照贴图(Lightmap)
  • 在Web端还原高质量的静态光照

3. 完整的UI+3D融合

ShipSelection/
├── BaseModel/      # 3D展示平台
├── Ships/          # 飞船模型数据
├── ShipGrid/       # 选择网格UI
├── ShipStats/      # 属性面板
└── ShipDescription/# 描述面板

3D场景和React UI组件完美融合,不是"3D画布上面盖一层HTML"的简单做法。


两个项目的共同技术栈

技术 用途 学习价值
Next.js 15 框架 App Router + 服务端渲染
React Three Fiber 3D渲染 React式声明化3D开发
Drei R3F辅助库 常用3D组件开箱即用
GSAP 动画 时间轴控制、缓动函数
Leva GUI调试 实时参数调节
Tailwind CSS 样式 快速UI开发
TypeScript 类型 大型3D项目必备

你可以从中学到什么?

1. 动漫风格渲染的秘密

  • Cel-Shading(赛璐珞着色)不是"卡通材质"那么简单
  • Voronoi噪声可以实现手绘质感的纹理
  • 多层合成比单一大shader更可控

2. 物理模拟不用全靠库

  • 自己写PDE求解器,理解GPU计算的本质
  • Ping-pong双缓冲是实现反馈效果的关键
  • WebGL的FrameBuffer对象可以玩出很多花样

3. 3D项目工程化

  • 用React组件化思维组织3D代码
  • Store模式管理跨组件的3D状态
  • 自定义Hook封装可复用的3D逻辑

4. 性能优化技巧

  • DPR-aware渲染(适配高分辨率屏)
  • GPU粒子系统(gl_PointCoord)
  • 深度图复用(避免重复渲染)

如何运行这两个项目

# 项目一:动漫水面
git clone https://github.com/cortiz2894/water-anime-shader.git
cd water-anime-shader
pnpm install
pnpm dev

# 项目二:飞船选择
git clone https://github.com/cortiz2894/ship-selection-page.git
cd ship-selection-page
npm install
npm run dev

注意:都需要Node 18+,推荐用pnpm(项目一作者用的pnpm)。


适合谁学?

人群 建议重点看
前端开发者 React Three Fiber的组件化思维
Three.js初学者 两个项目的shader入门
创意开发者 视觉效果实现思路
游戏开发者 UI与3D场景融合方案
设计师 技术可行性参考

写在最后

Christian Ortiz 的作品最打动我的地方:他把Blender的动态绘画、3A游戏的深度检测、物理模拟的 PDE 方程,全部搬进了Web端

而且代码组织得非常干净——不是那种"shader写2000行"的硬核风格,而是组件化、模块化、React化的现代前端工程实践。

如果你想:

  • 做创意视觉网站
  • 做游戏风格的3D交互
  • 深入理解WebGL shader
  • 看如何用React做3D工程

这两个项目都值得clone下来,一行行啃。


项目链接:


如果对你有帮助,点个关注呗!

Vite 核心原理:ESM 带来的开发时“瞬移”体验

前言

还记得用Webpack开发时的日常吗? 控制台输入 npm run dev ,等待 30 秒后项目终于启动了 ;过了一会儿,修改了一个文件,保存,等待 10 秒之后热更新完成;后来项目变大了,每次保存要等 20 秒以上...

这是 Webpack 时代的真实写照,而 Vite 的出现,彻底改变了这一切: 控制台输入 npm run dev ,1 秒后项目就启动了;修改了一个文件,保存,50ms 页面就更新了。

Vite是怎么做到的? 它不是魔法,而是巧妙地利用了现代浏览器的原生能力。本文将从最基础的概念讲起,带领我们一步步理解 Vite 的核心原理。

为什么传统构建工具这么慢?

Webpack的工作方式

Webpack 就像我们去参加宴席,必须要等酒店把所有的菜品都准备好,再一次性全部端上来;如果有一道菜没做好,我们就全部得等着:

Webpack的打包过程:
1. 找到入口文件 (main.js)
2. 解析import语句,找出所有依赖
3. 递归解析所有依赖的依赖
4. 把所有文件打包成一个bundle.js
5. 启动开发服务器
6. 浏览器加载bundle.js

随着项目越大,依赖越多,打包就会越慢。

为什么Webpack会越来越慢?

假如我们有这样一个项目结构:

project
├── vue (100个文件)
├── vue-router (50个文件)
├── pinia (30个文件)
├── element-plus (500个文件)
├── 你自己的组件 (200个文件)
└── 各种第三方库 (300个文件)

Webpack 启动时要处理 1180 个文件,并全部打包成一个文件,才能启动开发服务器。

ESM 基础:现代浏览器的模块系统

什么是ES Module?

在 ES Module 出现之前,我们是这样引入 JavaScript 的:

<!-- 老方式:必须按顺序,否则报错 -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="app.js"></script>

有了 ES Module 之后,我们可以这样写:

<script type="module">
  // 浏览器会自动加载这些依赖
  import $ from 'https://unpkg.com/jquery'
  import _ from 'https://unpkg.com/lodash'
  import app from './app.js'
</script>

浏览器如何加载ES Module?

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

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

当浏览器遇到这个脚本时,会进行以下操作:

第1步:下载 main.js
     ↓
第2步:解析 main.js,发现需要 vue、App.vue、router
     ↓
第3步:同时下载 vue、App.vue、router (并行下载)
     ↓
第4步:解析 router.js,发现新的依赖
     ↓
第5步:继续下载新的依赖
     ↓
直到所有依赖都加载完成

而且,浏览器可以并行下载多个文件,互不影响。

ESM的核心特性

特性1:静态导入(编译时确定依赖)

import { ref } from 'vue'  // 打包工具可以静态分析

特性2:动态导入(运行时加载)

if (user.isAdmin) {
  const adminPanel = await import('./AdminPanel.vue')
  // 只有在需要时才加载
}

特性3:模块作用域

// a.js
const name = 'module-a'
export { name }

// b.js
const name = 'module-b'  // 同名变量,互不干扰
export { name }

Vite 的核心思想 - 让浏览器做它擅长的事

Vite 的开发服务器

Vite 的开发服务器做了什么?

// 简化的Vite服务器
class ViteDevServer {
  constructor() {
    this.app = require('koa')()  // HTTP服务器
    this.watcher = require('chokidar').watch('src')  // 文件监听
  }
  
  async start() {
    // 1. 启动HTTP服务器
    this.app.listen(3000)
    
    // 2. 注册中间件
    this.app.use(this.transformMiddleware())
    
    // 3. 开始监听文件变化
    this.watcher.on('change', this.handleFileChange.bind(this))
  }
  
  // 处理文件请求
  async transformMiddleware(ctx, next) {
    if (ctx.path.endsWith('.vue')) {
      // 当浏览器请求 .vue 文件时,才进行编译
      const code = await compileVueFile(ctx.path)
      ctx.body = code
    }
  }
}

Vite的启动流程

传统方式(Webpack):
启动 → 打包所有文件 → 启动服务器 → 浏览器请求 → 返回打包后的文件

Vite方式:
启动 → 启动服务器 → 浏览器请求 → 按需编译 → 返回单个文件

还是用餐厅来比喻:

  • Webpack:客人来之前做好所有菜;如果菜没做好,所有客人都得等着
  • Vite:客人点一道,做一道;做好一道,上一道

一个完整的请求流程

假设我们的项目结构是这样的:

src/
├── main.js
├── App.vue
└── components/
    └── HelloWorld.vue

浏览器访问页面的过程如下:

// 第1步:浏览器请求 index.html
GET /index.html

// index.html 内容
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/src/main.js"></script>
  </head>
</html>

// 第2步:浏览器发现需要 main.js
GET /src/main.js

// main.js 内容
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// 第3步:浏览器发现需要 vue 和 App.vue
GET /@modules/vue  // Vite 特殊处理
GET /src/App.vue

// 第4步:App.vue 中又引用了 HelloWorld.vue
GET /src/components/HelloWorld.vue

// 第5步:全部加载完成,页面显示

依赖预构建 - 解决性能瓶颈

如果没有预构建,会有什么问题?

问题1:CommonJS 模块无法在浏览器直接运行

import _ from 'lodash'  // lodash 是 CommonJS 格式,浏览器不认识

问题2:大量小文件请求

import { debounce } from 'lodash-es'
// lodash-es 有 600 多个文件!
// 浏览器要发 600 多个请求!

问题3:深度嵌套的依赖

import A from 'package-a'
// package-a 依赖 package-b
// package-b 依赖 package-c
// 每个包都要单独请求

预构建做了什么?

  1. 扫描项目中的所有 import
  2. 找出第三方依赖(不是相对路径的)
  3. esbuild 打包成单个文件
  4. 存到 node_modules/.vite/
  5. 下次直接使用打包后的文件

esbuild 为什么这么快?

  1. 用 Go 语言写的(直接编译成机器码)
  2. 充分利用 CPU 多核
  3. 一切从零设计,没有历史包袱
  4. 高度并行化

热更新 - 瞬间响应的秘密

热更新模式

修改代码 → 页面自动更新 → 状态保持不变 → 继续工作

热更新的工作原理

我们修改了一个文件
    ↓
Vite 监听到文件变化
    ↓
重新编译这个文件
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求更新的文件
    ↓
执行热更新回调
    ↓
页面局部更新,状态保留

WebSocket 通信

// 服务器端
class HMRServer {
  constructor(server) {
    // 创建 WebSocket 服务
    this.ws = new WebSocket.Server({ server })
    
    // 所有连接的客户端
    this.clients = new Set()
    
    this.ws.on('connection', (socket) => {
      this.clients.add(socket)
      
      socket.on('close', () => {
        this.clients.delete(socket)
      })
    })
  }
  
  // 文件变化时通知所有客户端
  sendUpdate(file) {
    const message = JSON.stringify({
      type: 'update',
      file: file,
      timestamp: Date.now()
    })
    
    this.clients.forEach(client => {
      client.send(message)
    })
  }
}

// 浏览器端
const socket = new WebSocket(`ws://${location.host}`)

socket.onmessage = async ({ data }) => {
  const { type, file, timestamp } = JSON.parse(data)
  
  if (type === 'update') {
    // 重新加载修改的文件
    const module = await import(`${file}?t=${timestamp}`)
    
    // 执行热更新
    if (import.meta.hot) {
      import.meta.hot.accept(file, module)
    }
  }
}

Vue 组件的热更新

// Vue 组件的热更新实现
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 更新组件
    const { render, data } = newModule
    
    // 保留当前组件的状态
    const oldData = instance.data
    
    // 应用新的渲染函数
    instance.render = render
    
    // 重新渲染
    instance.update()
  })
}

插件系统:Vite 的扩展能力

插件的工作流程

请求进入
    ↓
resolveId(解析模块 ID)
    ↓
load(加载模块内容)
    ↓
transform(转换代码)
    ↓
返回给浏览器

插件的钩子函数

// 一个完整的 Vite 插件
const myPlugin = {
  name: 'vite:my-plugin',
  
  // 构建阶段钩子
  options(options) {
    // 修改或扩展配置
    return options
  },
  
  buildStart() {
    // 构建开始时调用
    console.log('构建开始')
  },
  
  // 解析模块 ID
  resolveId(source, importer) {
    if (source === 'virtual-module') {
      return '\0virtual-module' // \0 标记为虚拟模块
    }
  },
  
  // 加载模块
  load(id) {
    if (id === '\0virtual-module') {
      return 'export default "virtual module content"'
    }
  },
  
  // 转换代码
  async transform(code, id) {
    if (id.endsWith('.special')) {
      // 转换特殊文件格式
      const result = await compileSpecial(code)
      return {
        code: result.js,
        map: result.sourcemap
      }
    }
  },
  
  // 配置解析完成后
  configResolved(config) {
    console.log('配置已解析', config)
  },
  
  // 热更新处理
  handleHotUpdate(ctx) {
    // 自定义热更新逻辑
  },
  
  // 构建结束
  buildEnd() {
    console.log('构建结束')
  },
  
  // 关闭服务
  closeBundle() {
    console.log('服务关闭')
  }
}

常用插件示例

// 环境变量注入插件
function injectEnvPlugin(env: Record<string, string>) {
  return {
    name: 'vite:inject-env',
    
    transform(code, id) {
      if (id.includes('node_modules')) return
      
      // 替换环境变量
      return code.replace(
        /import\.meta\.env\.(\w+)/g,
        (_, key) => JSON.stringify(env[key])
      )
    }
  }
}

// 文件大小监控插件
function sizeMonitorPlugin() {
  return {
    name: 'vite:size-monitor',
    
    generateBundle(_, bundle) {
      Object.entries(bundle).forEach(([name, asset]) => {
        if (asset.type === 'chunk') {
          const size = asset.code.length
          const kb = (size / 1024).toFixed(2)
          
          if (size > 100 * 1024) {
            console.warn(`⚠️ 大文件警告: ${name} (${kb}KB)`)
          } else {
            console.log(`✅ ${name}: ${kb}KB`)
          }
        }
      })
    }
  }
}

Vite vs Webpack

启动时间对比

项目规模 Webpack Vite 差距
小项目(50组件) 8.5秒 1.2秒 Vite快7倍
中项目(200组件) 22秒 2.1秒 Vite快10倍
大项目(1000组件) 58秒 3.8秒 Vite快15倍

热更新时间对比

操作 Webpack Vite 差距
修改一个组件 2.8秒 45ms Vite快62倍
修改CSS 1.5秒 8ms Vite快187倍
保存后恢复 3.1秒 60ms Vite快52倍

资源消耗对比

指标 Webpack Vite 差距
CPU占用 45% 18% 降低60%
内存占用 1.8GB 420MB 降低77%
电池消耗 延长2-3倍

常见问题与优化技巧

问题一:依赖预构建失效

修改了 node_modules 里的代码,但是不生效:

解决方案1:强制重新预构建

// vite.config.ts
export default {
  optimizeDeps: {
    // 强制重新预构建
    force: true
  }
}

解决方案2:删除缓存目录

$ rm -rf node_modules/.vite

解决方案3:重启开发服务器

npm run dev

问题二:热更新不生效

修改了文件,但页面不更新,可以按以下步骤排查:

步骤1:检查 WebSocket 连接

打开浏览器控制台,看是否有 WebSocket 连接。

步骤2:检查文件监听配置

export default {
  server: {
    watch: {
      // 确保没有忽略我们的文件
      ignored: ['!**/node_modules/**']
    }
  }
}

步骤3:手动触发更新

if (import.meta.hot) {
  import.meta.hot.accept()
}

问题三:首次加载慢

第一次打开页面要等很久。

解决方案:预加载关键路由

export default {
  optimizeDeps: {
    include: [
      // 预构建这些依赖
      'vue',
      'vue-router',
      'pinia',
      // 你的常用组件
      'src/components/Button.vue',
      'src/components/Modal.vue'
    ]
  }
}

问题四:内存占用过高

// vite.config.ts
export default {
  server: {
    // 限制缓存大小
    moduleCache: {
      maxSize: 100 * 1024 * 1024 // 100MB
    },
    
    // 清理未使用的模块
    moduleGraph: {
      pruneInterval: 60000 // 每 60 秒清理一次
    }
  }
}

Vite 的最佳实践

Vite 配置文件模板

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  // 插件
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true,  // 自动打开浏览器
    proxy: {
      '/api': 'http://localhost:8080'  // 代理
    }
  },
  
  // 构建配置
  build: {
    target: 'es2020',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true
  },
  
  // 依赖优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia']
  },
  
  // 别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

性能优化清单

  • 依赖预构建:配置 optimizeDeps.include 预构建常用依赖
  • 路由懒加载:使用动态 import() 分割代码
  • 图片优化:使用 vite-plugin-image-optimizer
  • CSS 提取:生产环境提取独立 CSS 文件
  • Gzip 压缩:使用 vite-plugin-compression

学习要点

  1. 理解 ESM 的核心特性:静态导入、模块作用域、浏览器加载机制
  2. 掌握依赖预构建的作用:解决 CommonJS 兼容性、减少请求数
  3. 熟悉热更新的工作流程:WebSocket 通信、模块边界、HMR API
  4. 学会编写 Vite 插件:钩子函数、虚拟模块、代码转换
  5. 能够诊断和优化性能问题:预构建失效、热更新慢、内存占用高

结语

Vite 的出现,标志着前端构建工具从打包时代进入了原生 ESM 时代。理解它的核心原理,不仅能让我们更高效地使用它,更能让我们对现代前端开发有更深的理解。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

前端工程师转型 AI Agent 工程师:后端能力补全指南

一、为什么前端工程师需要补后端能力

AI Agent 正在成为软件开发的新范式。作为前端工程师,我们天然具备用户界面、交互体验方面的优势,但 AI Agent 的开发远不止于前端界面。一个完整的 AI Agent 系统需要:

  • 后端服务:承载模型推理、任务调度、数据存储
  • API 设计:定义 Agent 与外部系统的交互协议
  • 数据库设计:管理对话历史、知识库、用户配置
  • 安全认证:保护 API 密钥、用户数据

本文系统梳理前端工程师转型 AI Agent 开发时需要补齐的后端能力,助你建立完整的全栈视角。

二、HTTP 与 RESTful API 深度掌握

2.1 HTTP 协议核心概念

理解 HTTP 是后端开发的基础。AI Agent 需要与各种服务交互:

// 使用 fetch 发送请求
const response = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${API_KEY}`
  },
  body: JSON.stringify({
    model: 'gpt-4',
    messages: [{ role: 'user', content: 'Hello' }],
    temperature: 0.7
  })
});

const data = await response.json();
console.log(data.choices[0].message.content);

2.2 RESTful API 设计规范

设计良好的 API 是 Agent 与服务交互的基础:

// RESTful API 设计示例
// 资源命名使用名词复数
GET    /api/agents          // 获取 Agent 列表
GET    /api/agents/:id      // 获取单个 Agent
POST   /api/agents          // 创建 Agent
PUT    /api/agents/:id      // 更新 Agent
DELETE /api/agents/:id      // 删除 Agent

// 嵌套资源
GET    /api/agents/:id/memory      // 获取 Agent 的记忆
POST   /api/agents/:id/memory      // 添加记忆
DELETE /api/agents/:id/memory/:mid // 删除记忆

2.3 状态码与错误处理

// 合理使用 HTTP 状态码
200 OK                    // 成功
201 Created               // 创建成功
204 No Content            // 删除成功,无返回内容

400 Bad Request           // 参数错误
401 Unauthorized          // 未认证
403 Forbidden             // 无权限
404 Not Found             // 资源不存在
422 Unprocessable Entity  // 业务逻辑错误
429 Too Many Requests     // 限流

500 Internal Server Error // 服务器错误
503 Service Unavailable   // 服务不可用

三、Node.js 服务端开发

3.1 Express 框架快速入门

const express = require('express');
const app = express();

// 中间件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 路由
app.get('/api/agents', (req, res) => {
  res.json({ agents: [] });
});

app.post('/api/agents', async (req, res) => {
  try {
    const { name, systemPrompt } = req.body;
    const agent = await createAgent({ name, systemPrompt });
    res.status(201).json(agent);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

3.2 中间件设计模式

中间件是 Express 的核心概念:

// 日志中间件
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
  next();
});

// 认证中间件
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  try {
    req.user = verifyToken(token);
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

// 限流中间件
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 分钟
  max: 100 // 限制 100 次请求
});
app.use('/api/', limiter);

3.3 异步处理与错误捕获

// 异步路由处理
app.get('/api/agents/:id', async (req, res) => {
  try {
    const agent = await database.agents.findById(req.params.id);
    if (!agent) {
      return res.status(404).json({ error: 'Agent not found' });
    }
    res.json(agent);
  } catch (error) {
    console.error('Database error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// 全局错误处理
app.use((err, req, res, next) => {
  console.error('Error:', err);
  res.status(500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message
  });
});

四、数据库设计与操作

4.1 数据库选型

AI Agent 项目常用数据库:

数据库 适用场景 优点
PostgreSQL 关系型数据、事务 可靠性强、JSON 支持
MongoDB 文档存储、灵活结构 开发快、易扩展
Redis 缓存、会话、消息队列 速度快、功能丰富
Elasticsearch 全文搜索、知识检索 搜索能力强
Pinecone/Weaviate 向量存储、语义搜索 专为 AI 设计

4.2 PostgreSQL 基础操作

const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

// 创建表
await pool.query(`
  CREATE TABLE IF NOT EXISTS agents (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    system_prompt TEXT,
    model VARCHAR(100),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
  )
`);

// 插入数据
const result = await pool.query(
  'INSERT INTO agents (name, system_prompt, model) VALUES ($1, $2, $3) RETURNING *',
  ['ChatAgent', 'You are a helpful assistant.', 'gpt-4']
);
console.log('Created:', result.rows[0]);

// 查询数据
const agents = await pool.query(
  'SELECT * FROM agents WHERE model = $1 ORDER BY created_at DESC',
  ['gpt-4']
);
console.log('Agents:', agents.rows);

// 更新数据
await pool.query(
  'UPDATE agents SET system_prompt = $1, updated_at = NOW() WHERE id = $2',
  ['You are a coding expert.', agentId]
);

// 删除数据
await pool.query('DELETE FROM agents WHERE id = $1', [agentId]);

4.3 ORM 使用:Prisma

// schema.prisma
model Agent {
  id           String   @id @default(uuid())
  name         String
  systemPrompt String
  model        String
  memories     Memory[]
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}

model Memory {
  id        String   @id @default(uuid())
  content   String
  type      String
  agentId   String
  agent     Agent    @relation(fields: [agentId], references: [id])
  createdAt DateTime @default(now())
}

// 代码中使用
const agent = await prisma.agent.create({
  data: {
    name: 'ChatAgent',
    systemPrompt: 'You are helpful.',
    model: 'gpt-4'
  }
});

const agents = await prisma.agent.findMany({
  where: { model: 'gpt-4' },
  include: { memories: true },
  orderBy: { createdAt: 'desc' }
});

4.4 向量数据库:Pinecone

AI Agent 需要存储和检索向量:

const { Pinecone } = require('@pinecone-database/pinecone');
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });

// 创建索引
await pinecone.createIndex({
  name: 'agent-memories',
  dimension: 1536, // OpenAI ada-002 维度
  metric: 'cosine'
});

// 存储向量
const index = pinecone.index('agent-memories');
await index.upsert([
  {
    id: 'memory-1',
    values: [0.1, 0.2, 0.3, ...], // 1536 维向量
    metadata: { type: 'conversation', topic: 'React' }
  }
]);

// 相似性检索
const queryResponse = await index.query({
  vector: [0.1, 0.2, 0.3, ...],
  topK: 5,
  includeMetadata: true
});
console.log('Similar memories:', queryResponse.matches);

五、认证与安全

5.1 JWT 认证

const jwt = require('jsonwebtoken');

// 生成 Token
function generateToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );
}

// 验证 Token
function verifyToken(token) {
  return jwt.verify(token, process.env.JWT_SECRET);
}

// 中间件
const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader) {
    return res.status(401).json({ error: 'No token' });
  }
  
  const token = authHeader.split(' ')[1];
  try {
    req.user = verifyToken(token);
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

5.2 API 密钥管理

// 安全的 API 密钥存储
const crypto = require('crypto');

// 生成 API 密钥
function generateApiKey() {
  return `sk_${crypto.randomBytes(32).toString('hex')}`;
}

// 密钥哈希存储
function hashKey(key) {
  return crypto.createHash('sha256').update(key).digest('hex');
}

// 验证密钥
async function validateApiKey(key) {
  const hashed = hashKey(key);
  const stored = await database.apiKeys.findOne({ hashed });
  return stored;
}

5.3 CORS 与安全头

const cors = require('cors');
const helmet = require('helmet');

app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

// 输入验证
const Joi = require('joi');
const schema = Joi.object({
  name: Joi.string().min(1).max(100).required(),
  systemPrompt: Joi.string().max(10000),
  model: Joi.string().valid('gpt-3.5-turbo', 'gpt-4')
});

app.post('/api/agents', async (req, res) => {
  const { error, value } = schema.validate(req.body);
  if (error) {
    return res.status(400).json({ error: error.details[0].message });
  }
  // ...
});

六、消息队列与任务调度

6.1 BullMQ 任务队列

const { Queue, Worker } = require('bullmq');
const IORedis = require('ioredis');

// 创建队列
const agentQueue = new Queue('agent-tasks', {
  connection: new IORedis(process.env.REDIS_URL)
});

// 添加任务
await agentQueue.add('process-message', {
  agentId: 'agent-123',
  message: 'Hello',
  userId: 'user-456'
});

// 处理任务
const worker = new Worker('agent-tasks', async job => {
  const { agentId, message, userId } = job.data;
  
  // 调用 AI 模型
  const response = await callAgent(agentId, message);
  
  // 存储结果
  await saveMessage({ agentId, userId, message, response });
  
  return response;
}, { connection: new IORedis(process.env.REDIS_URL) });

worker.on('completed', job => {
  console.log(`Job ${job.id} completed`);
});

6.2 定时任务

const cron = require('node-cron');

// 每天凌晨清理过期会话
cron.schedule('0 0 * * *', async () => {
  await cleanupExpiredSessions();
  console.log('Cleaned up expired sessions');
});

// 每小时同步数据
cron.schedule('0 * * * *', async () => {
  await syncData();
  console.log('Data synced');
});

七、WebSocket 实时通信

7.1 Socket.io 基础

const { Server } = require('socket.io');
const io = new Server(3000, {
  cors: { origin: '*' }
});

io.on('connection', socket => {
  console.log('User connected:', socket.id);
  
  // 加入 Agent 房间
  socket.on('join-agent', ({ agentId, userId }) => {
    socket.join(`agent:${agentId}`);
    console.log(`User ${userId} joined agent ${agentId}`);
  });
  
  // 发送消息
  socket.on('send-message', async ({ agentId, message, userId }) => {
    // 广播消息给房间内所有人
    io.to(`agent:${agentId}`).emit('message', {
      role: 'user',
      content: message,
      userId
    });
    
    // 调用 Agent 处理
    const response = await callAgent(agentId, message);
    
    // 返回 Agent 响应
    io.to(`agent:${agentId}`).emit('message', {
      role: 'assistant',
      content: response,
      agentId
    });
  });
  
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

7.2 客户端使用

import { io } from 'socket.io-client';

const socket = io('https://api.example.com');

socket.on('connect', () => {
  console.log('Connected to server');
  
  socket.emit('join-agent', { agentId: 'agent-123', userId: 'user-456' });
});

socket.on('message', message => {
  console.log('Received:', message);
  // 更新 UI
});

function sendMessage(content) {
  socket.emit('send-message', {
    agentId: 'agent-123',
    message: content,
    userId: 'user-456'
  });
}

八、Docker 容器化部署

8.1 Dockerfile 编写

# 使用 Node.js 官方镜像
FROM node:20-alpine

# 设置工作目录
WORKDIR /app

# 复制依赖文件
COPY package*.json ./

# 安装依赖
RUN npm ci --only=production

# 复制源代码
COPY . .

# 构建
RUN npm run build

# 暴露端口
EXPOSE 3000

# 启动命令
CMD ["node", "dist/index.js"]

8.2 Docker Compose

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/agentdb
      - REDIS_URL=redis://cache:6379
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    depends_on:
      - db
      - cache
    networks:
      - agent-network

  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=agentdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - agent-network

  cache:
    image: redis:7-alpine
    networks:
      - agent-network

networks:
  agent-network:
    driver: bridge

volumes:
  postgres_data:

九、监控与日志

9.1 结构化日志

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

logger.info('Agent created', { agentId: 'agent-123', model: 'gpt-4' });
logger.error('API call failed', { error: error.message, agentId: 'agent-123' });

9.2 健康检查

const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

app.get('/health', async (req, res) => {
  try {
    // 检查数据库连接
    await pool.query('SELECT 1');
    res.json({ status: 'healthy', timestamp: new Date().toISOString() });
  } catch (error) {
    res.status(503).json({ status: 'unhealthy', error: error.message });
  }
});

app.get('/ready', (req, res) => {
  // 检查所有依赖是否就绪
  const checks = [
    { name: 'database', ready: true },
    { name: 'redis', ready: true }
  ];
  
  const allReady = checks.every(c => c.ready);
  res.status(allReady ? 200 : 503).json({ ready: allReady, checks });
});

十、学习路径建议

10.1 分阶段学习计划

第一阶段(1-2 周):Node.js 基础

  • Express 框架
  • 中间件机制
  • 路由设计

第二阶段(2-3 周):数据库

  • PostgreSQL 基础
  • Prisma ORM
  • Redis 缓存

第三阶段(1-2 周):认证与安全

  • JWT 认证
  • API 密钥管理
  • 安全最佳实践

第四阶段(1-2 周):消息队列

  • BullMQ 任务队列
  • 定时任务
  • WebSocket 实时通信

第五阶段(1 周):部署运维

  • Docker 容器化
  • 监控日志
  • CI/CD 流程

10.2 推荐资源

  • Node.js 官方文档
  • Express.js 指南
  • Prisma 文档
  • PostgreSQL 教程
  • Docker 官方文档

总结

前端工程师转型 AI Agent 开发,后端能力是必经之路。核心技能包括:

  • HTTP 与 API 设计:理解请求响应模型
  • Node.js 服务端开发:构建 API 服务
  • 数据库操作:存储和检索数据
  • 认证与安全:保护系统安全
  • 消息队列:处理异步任务
  • 实时通信:支持交互式对话
  • 容器化部署:实现可重复部署

掌握这些技能后,你将能够独立构建完整的 AI Agent 系统,从前端界面到后端服务,从数据存储到部署运维,真正成为 AI Agent 工程师。

如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区分享你的学习心得。

CSS Modules完全指南:CSS模块化的特性,生态工具和实践

简介

在之前的文章中,我们了解了很多CSS命名规范:BEM、OOCSS、SMACSS、ITCSS、AMCSS、SUITCSS:CSS命名规范简介。它们可以解决CSS样式全局生效容易引发污染和冲突的问题。但这些方案基本都是写一个前缀或后缀,通过手写命名的方式避免类名重复。但这在多人协作或引入大量外部库时,依然不能完全避免问题,还需依赖团队规范管理。那么,是否有工具可以自动做这件事,而且完全避免组件内的类名与其它组件重复?有的,这就是CSS Modules。

CSS Modules中文叫做CSS模块。默认情况下,我们定义的CSS类名标识符是全局的。使用CSS Modules之后,每个类名将变为唯一的全局名称,包含不会重复的哈希值。引入CSS文件时,我们可以拿到CSS文件导出的类名到全局名称的对应关系,从而在HTML中提供相应的类名。

对于希望共享的类名,CSS Modules也提供了方案使其全局生效。同时CSS Modules还提供了定制标识符,class组合等功能。要想实现CSS Module的功能,代码需要经过打包,而且由于不同前端框架代码的组织方式不一样,CSS Module的具体使用也有区别,我们逐一介绍一下。

纯JavaScript使用方式

首先我们抛开各种前端框架,在纯粹的JavaScript代码中演示CSS Modules的效果。这里选用Vite,首先命令行执行代码,创建工程:

npm init -y
npm add -D vite

然后在package.json的scripts中增加几个构建相关命令。dev开发模式,build生产模式构建,preview生产模式预览。

{
  "dev": "vite",
  "build": "vite build",
  "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,里面引入index.js。

<html>
  <script src="./index.js" type="module"></script>
  <body>
    <div>jsplp CSS Modules</div>
  </body>
</html>

然后创建两个CSS文件,分别是使用CSS Modules的index.module.css和没有使用的index.css:

/* index.css */
.class1 {
  color: red;
}

/* index.module.css */
.class2 {
  background-color: yellow;
}
.abcDef {
  background-color: yellow;
  .qazwsx {
    background-color: yellow;
  }
}
#id1 {
  background-color: yellow;
}

然后是index.js文件,引入这两个CSS文件,并在DOM中增加几个div元素,使用这些CSS类:

import './index.css';
import styles from './index.module.css';

console.log(styles)

const test1 = document.createElement('div');
test1.textContent = 'test1';
document.body.appendChild(test1);

const test2 = document.createElement('div');
test2.className = 'class1';
test2.textContent = 'test2';
document.body.appendChild(test2);

const test3 = document.createElement('div');
test3.className = styles.class2;
test3.textContent = 'test3';
document.body.appendChild(test3);

const test4 = document.createElement('div');
test4.className = 'class1' + " " + styles.class2;
test4.textContent = 'test4';
document.body.appendChild(test4);

/* 输出结果
{
  abcDef: "_abcDef_1wjui_7",
  class2: "_class2_1wjui_1",
  id1: "_id1_1wjui_1",
  qazwsx: "_qazwsx_1wjui_11",
}
*/

我们在index.module.css中列举了几个场景,分别是class名,嵌套class名,id名。将其引入为一个对象并输出结果,发现它是一个key为原来的标识符名称,value为包含哈希值的新标识符的对象。要使用类名时,需要将这个新标识符提供给DOM进行渲染。而对比普通CSS文件,只需要简单引入即可生效。在index.js中我们还创建了四个场景,分别是:

  • test1 没有类名
  • test2 普通CSS类名 class1
  • test3 CSS Modules类名 class2
  • test4 普通 class1 + 模块的 class2

因为新标识符实际上也是个字符串,因此可以和普通类名结合使用,中间加个空格即可。当然也可以使用classnames等辅助工具组合类名。执行 npm run dev,看一下效果:

css-modules-1.png

通过浏览器可以看到,CSS Modules类名不仅成功作为CSS类名,而且还能和对应的CSS文件里面的规则对应上。test4这种结合class属性也可以生效。我们再执行npm run build,看一下打包后的文件内容:

css-modules-2.png

查看dist目录中打包后的文件内容,可以看到CSS文件和JS文件被分开单独引入到HTML文件中。两个CSS文件被合并为一个,普通CSS文件还是维持原来的类名,index.module.css则变为了带哈希值的新标识符名。对应的JS文件中引入的styles变为了常量对象,内容也是新标识符的映射关系。

通过上面的代码演示,我们能够了解CSS Modules的核心思路,即改变CSS标识符的名称,使其不会重复;需要使用对应标识符的地方要用JavaScript手动引入;同时更改CSS文件中的标识符以匹配新的名称。

CSS Modules特性

CSS Modules除了上面的核心特性之外,还包含一些特性。这里我们介绍和尝试一下它的主要特性:

多文件引用CSS模块

前面我们在同一个JavaScript文件中引入了CSS Modules的CSS文件,多次使用引入的标识符,发现值实际是一样的。那么如果在不同的文件中引入CSS Modules的CSS文件,新标识符会一样么?这里来试一下。首先创建两个CSS文件:

/* index1.module.css */
.class1 {
  color: red;
}

/* index2.module.css */
.class1 {
  color: yellow;
}

可以看到两个CSS文件中类名标识符是一致的,都是class1。然后是两个JavaScript文件index1.js和index2.js,里面总共举了三个例子:

// index1.js
import styles1 from "./index1.module.css";
import styles2 from "./index2.module.css";

const test1 = document.createElement("div");
test1.className = styles1.class1;
test1.textContent = "test1";
document.body.appendChild(test1);

const test2 = document.createElement("div");
test2.className = styles2.class1;
test2.textContent = "test2";
document.body.appendChild(test2);

//index2.js
import styles1 from "./index1.module.css";

const test3 = document.createElement("div");
test3.className = styles1.class1;
test3.textContent = "test3";
document.body.appendChild(test3);

最后是index.html,引入两个JavaScript文件:

<html>
  <script src="./index1.js" type="module"></script>
  <script src="./index2.js" type="module"></script>
  <body>
    <div>jsplp CSS Modules</div>
  </body>
</html>

经过Vite打包后,在浏览器看下输出结果:

css-modules-3.png

  • test1和test3对比,分别在两个JavaScript文件中引入了同一个CSS模块文件index1.module.css,最后生成的类标识符是一致的,样式效果也一致。这是因为CSS文件只有一个,最后只会生成一份CSS规则。而且既然引入同一文件,规则肯定是一样的,没有必要分开两个类名。
  • test1和test2对比,在同一个JavaScript文件中引入了两个CSS模块文件,虽然各自CSS文件中类名是一样的,但因为所属文件不同,因此生成的新类名不一样,这样有效避免了同名的样式冲突问题。
  • test2和test3对比,分别在两个JavaScript文件中引入了两个CSS模块文件,生成的新类名也不一样,也避免了同名的样式冲突问题。

global全局规则

通过前面的例子可以看到,使用CSS Modules之后,所有自定义标识符名都变成了新的,只有引用才能生效的局部CSS规则。如果希望在这个CSS文件内定义部分全局都能生效的规则,CSS Modules也给出了方法,而且允许全局规则和局部规则混合嵌套使用。使用:global,就可以在CSS模块文件中使用全局规则。我们来看下例子。首先是index.module.css文件:

/* index.module.css */
.class1 {
    color: red;
}
:global(.class1) {
    color: blue;
}
:global(.class2) {
    .class3 {
        color: yellow;
    }
}
:local(.class4) {
    color: grey;
}

然后是index.js,这里给出了四种情况。可以看到带:global的标识符是不会被JavaScript文件导入的:

import styles from "./index.module.css";

console.log(styles);

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}
genEle("test1", styles.class1);
genEle("test2", "class1");

const div = document.createElement("div");
div.className = "class2";
div.innerHTML = `<div class='${styles.class3}'>test3</div>`;
document.body.appendChild(div);

genEle("test4", styles.class4);

/* 输出结果
{
  class1: '_class1_8tmyt_1',
  class3: '_class3_8tmyt_8',
  class4: '_class4_8tmyt_12'
}
*/

css-modules-4.png

  • test1: 正常的模块化CSS规则,做对比用
  • test2: 与test1一样都用class1做类名,但这里没有使用导出的新类名,因此匹配到了带:global的全局CSS规则
  • test3: 外层class2是全局类名,里面的class3没有用:global,因此还是局部规则。这是一个混合使用的例子,在CSS模块文件中,只有包裹在:global里面的类名才是全局规则,嵌套选择器和组合选择器需要单独包裹, 或者这样包裹在一起也可以::global(.cls1 + .cls2)
  • test4: :local表示模块化的CSS规则,与不增加标识效果一致。一般为了强调才使用。

composes组合规则

使用CSS Modules,使用composes属性,在规则中可以组合另一个类选择器的规则。这里举个例子看一下是如何组合的。首先是index.module.css文件:

.class1 {
  background: yellow;
}
.class2 {
  color: red;
  composes: class1;
}
.class1:hover {
  border: 1px solid blue;
}

然后是index.js中引入CSS文件,这里仅使用class2做类名:

import styles from "./index.module.css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}
genEle("test1", styles.class2);

css-modules-5.png

我们的test1元素只定义了class2这个类名,但在浏览器中,却同时有了class1的类名。这时因为在CSS文件中定义class2的规则时,增加了composes属性,值为class1的类名。这相当于让class2继承class1,因此元素也具有了class1的类名和样式。同时还举了一个伪类的例子,这个组合规则对于伪类/为元素和选择器组合等都可以生效。composes属性也支持全局规则和跨文件引用,这里也举下例子:

/* index.module.css */
:global(.class1) {
  background: yellow;
}
.class2 {
  color: red;
  composes: class1 from global;
  composes: class3 from './index2.module.css';
}

/* index2.module.css */
.class3 {
  border: 1px solid blue;
}

这里仅更改了index.module.css文件,新增了index2.module.css文件,index.js文件内容没有变化。然后我们查看浏览器效果:

css-modules-6.png

通过这个例子我们发现,CSS Modules可以组合全局规则,composes的类名后面加from global即可。同时composes可以在同一个类中使用很多次,都会生效。另外composes也可以跨文件组合,直接from文件名即可。

实现主题功能

使用CSS Modules主动引入类名的特性,通过不同场景下的类名切换,可以实现主题功能。首先定义两个CSS文件,其中的选择器一致,但是主题不一样:

/* red.module.css */
.class1 {
  color: red;
}
.class2 {
  border: 1px solid red;
}

/* blue.module.css */
.class1 {
  color: blue;
}
.class2 {
  border: 1px solid blue;
}

然后是index.js文件:

import styleRed from "./red.module.css";
import styleBlue from "./blue.module.css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

function componentJz(styles) {
  genEle("test1", styles.class1);
  genEle("test2", styles.class2);
}

// 渲染红色主题
componentJz(styleRed);
// 渲染蓝色主题
componentJz(styleBlue);

可以看到,将引入的CSS Modules标识符对象传递给组件,组件中的元素使用这个对象作为类名。这样可以实现根据不同的条件传入不同的CSS文件对象,页面主题样式也随之变化。这里其实使用React组件举例更合适,但React使用方式要留到下面介绍,因此先使用纯JS示意。

React使用方式

React中使用CSS Modules与纯JavaScript使用基本一致。这里我们使用Vite创建一个React工程,展示在React中使用CSS Modules。首先执行命令行:

# 提示中选择React
npm create vite
# 进入工程
cd vite-react
# 安装依赖
npm install
# 增加依赖
npm add -S classnames 
# 开发模式运行工程
npm run dev

创建App.module.css文件,内容如下:

.class1 {
  color: red;
}
.class2 {
  color: blue;
}
:global(.class3) {
  border: 1px solid yellow;
}

然后将App.jsx中的内容删掉,替换为下面的代码。这就是React中的使用方式,CSS文件引入的标识符对象作为className属性。同时这里演示了classnames的用法,可以方便的组合多种类名。

import styles from './App.module.css';
import cn from 'classnames';

export default function App() {
  return (
    <div>
      <div className={styles.class1}>test1</div>
      <div className='class3'>test2</div>
      <div className={cn(styles.class2, 'class3')}>test3</div>
    </div>
  )
}

css-modules-7.png

Vue使用方式

Vue框架对于组件的组织方式比较特别,使用一个“单文件组件”的方式来组织代码,将所属同一个组件的HTML模板,JavaScript代码和CSS样式同时写到一个组件中。而且单文件组件中最流行的写法是“组件作用域CSS”,不是CSS Modules。下面我们分别介绍一下。

组件作用域CSS

组件作用域CSS,可以做到本组件的CSS样式就只影响本组件,不会影响别的组件;即使非类名选择器,例如标签选择器,属性选择器等,都仅限在本组件范围内生效。注意组件作用域CSS并不是CSS Modules,只不过功能上有部分相似之处。我们看一下例子,首先使用命令行创建Vue工程:

# 根据提示创建Vue工程
npm create vue@latest
# 进入工程
cd vite-vue
# 安装依赖
npm install
# 开发模式运行工程
npm run dev

然后我们删除App.vue中的内容,填充下面的代码。作为父组件。

<script setup>
import Comp1 from './comp1.vue'
import Comp2 from './comp2.vue'
</script>

<template>
  <div>
    父组件
    <div class="class1"> 父组件元素 </div>
    <p> 父组件p元素 </p>
  </div>
  <Comp1 />
  <Comp2 />
</template>

<style scoped>
p {
  color: yellow;
}
.class1 {
  background-color: aqua;
}
</style>

然后是样式选择器与父组件一致的子组件comp1.vue:

<template>
  <div>
    子组件1
    <div class="class1"> 子组件1元素 </div>
    <p> 子组件1p元素 </p>
  </div>
</template>

<style scoped>
p {
  color: red;
}
.class1 {
  background-color: blue;
}
</style>

最后是没有开启组件作用域CSS的comp2.vue组件:

<template>
  <div>
    子组件2
    <div class="class1"> 子组件2元素 </div>
    <p> 子组件2p元素 </p>
  </div>
</template>

<style>
div {
  border: 1px solid brown;
}
.class1 {
  color: brown;
}
</style>

在代码中可以看出,单文件组件将<template> <script> <style>在同一个vue文件中封装。如果使用作用域CSS,就在style标签上加scoped属性。有什么效果呢?我们看下浏览器截图:

css-modules-8.png

可以看到,在设置了scoped属性之后,组件生成的HTML代码中便会多了dat-v-xxxx的属性,每个组件的属性是单独的不会重复。对应的CSS选择器中也添加了属性选择器的条件。这样不管是类选择器还是标签选择器等,都只有匹配到了对应的data-v属性才会生效。

对于组件内CSS样式污染全局的问题,组件作用域CSS比CSS Modules的隔离更全面,基本可以做到完全不污染全局。例如App.vue组件和comp1.vue组件,两个选择器一致,但是样式却没有被污染。不过要注意,在父组件中引入子组件,子组件的根元素会同时被附加上父组件和子组件的data-v属性,例如comp1.vue组件的根结点。

comp2.vue组件没有使用组件作用域CSS,因此它的CSS能影响全局。包括使用scoped属性的组件内部,如果符合规则也能匹配上。这与CSS Modules不一致,因为CSS Modules修改了类名,因此源码中的符合规则的元素类名,生成代码中就不符合规则了。

特殊选择器

与CSS Modules一样,组件作用域CSS也有一些特殊的选择器用于处理一些特殊场景,主要有这几个:

  • :deep() 深度选择器 样式可以影响子组件
  • :slotted() 插槽选择器 样式可以影响插槽内容
  • :global() 全局选择器 样式可以影响全局

下面我们举个例子演示一下选择器的使用方法。首先是父组件App.vue文件:

<script setup>
import Comp1 from './comp1.vue'
</script>

<template>
  <div>
    父组件
    <div class="class1"> 父组件元素 </div>
    <Comp1>
      <div class="class2"> slot元素 </div>
    </Comp1>
  </div>
  <Comp1 />
</template>

<style scoped>
:global(.class1) {
  color: red;
}
:deep(.class1) {
  background-color: aqua;
}
</style>

然后是子组件comp1.vue:

<template>
  <div>
    子组件1
    <div class="class1"> 子组件1元素 </div>
    <slot> </slot>
  </div>
</template>

<style scoped>
:slotted(.class2) {
  background-color: blue;
}
</style>

css-modules-9.png

上面例子中展示了三种选择器的使用方式,其中全局选择器的效果和CSS Modules基本一致;深度选择器只能影响自己和子组件;插槽选择器影响的父组件中被包括在子组件插槽中的部分。通过对于组件作用域CSS的介绍,可以发现它虽然实现原理与CSS Modules不一致,但作用却有些相似,而且扩展了CSS Modules的作用范围。

Vue与CSS Modules

Vue中不仅有组件作用域CSS,单文件组件也可以直接集成CSS Modules开发。在style标签上加module属性,即可开启CSS Modules。我们继续举个例子演示用法,首先是父组件App.vue:

<script setup>
import Comp1 from './comp1.vue'
</script>

<template>
  <div>
    父组件
    <div :class="$style.class1"> 父组件元素 </div>
    <Comp1>
      <div :class="$style.class2"> slot元素 </div>
    </Comp1>
  </div>
</template>

<style module>
.class1 {
  color: red;
}
.class2 {
  background: yellow;
  composes: class1;
}
</style>

然后是子组件comp1.vue:

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

const styles = useCssModule();
console.log(styles);
</script>

<template>
  <div>
    子组件1
    <div class="class1"> 子组件1元素 </div>
    <slot> </slot>
    <div :class="styles.class3"> 子组件1元素2 </div>
  </div>
</template>

<style module>
.class3 {
  border: 1px solid blue;
}
</style>

<!-- 输出结果
{ class3: "_class3_1wpng_3" }
-->

css-modules-10.png

在父组件的template中,使用$style就可以拿到CSS Modules引入的标识符映射对象。如果希望在JavaScript文件中使用,则可以参考子组件的方式,使用useCssModule拿到标识符映射对象,进行处理后再提供给template。

Webpack使用方式

前面介绍了两种前端框架中CSS Modules的使用方式。因为它最终还需要构建工具进行处理才能生效,因此我们再关注一下它在不同构建工具中的使用方式。首先看下在Webpack中的使用。

接入CSS Modules

先创建一个Webapck工程,执行如下命令行:

npm init -y
npm install webpack webpack-cli style-loader css-loader html-webpack-plugin --save-dev

然后修改package.json中的scripts,增加"build": "webpack",后面执行npm run build即可构建结果。然后创建src/index.js,内容如下:

import * as styles from "./index.module.css";

console.log(styles);

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("test1", styles.class1);
genEle("test2", styles.class2);

然后创建index.module.css,包含内容如下:

.class1 {
    color: red;
}
.class2 {
    background: yellow;
    composes: class1;
}

然后创建webpack.config.js配置文件,内容如下:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "管理输出",
    }),
  ],
  output: {
    path: path.resolve(__dirname, "dist"),
  },
};

/* 输出结果
{
  class1:  "qbnq8h84_UYLiRusvV1S",
  class2:  "cofz5uKLVjU6TCKJqeBc qbnq8h84_UYLiRusvV1S",
  default: undefined,
  __esModule: true
  ...其它内容
}
*/

然后执行构建命令,生成dist目录。在浏览器中打开dist/index.html,可以看到CSS Modules已经接入成功,类名变成了带哈希的标识符,test2元素因为使用了composes特性,还包含了两个类名,在console输出时也带着。

css-modules-11.png

提供CSS Modules功能的,实际上是css-loader。默认情况下,当CSS文件的中间包含.module或者.icss时,css-loader会将其识别为CSS模块处理。可以看到JavaScript代码中引入CSS标识符时使用的是import * as,这也是因为css-loader并不支持默认导出所有标识符到一个对象中,而是只能单个导入,类似于import { class1 } from "./index.module.css";

css-loader可以配置全部CSS文件开启CSS模块,配置modules选项为true即可。然后将index.module.css改为index.css也能使用CSS Modules功能。这里展示webpack.config.js中的配置改动:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [
          "style-loader",
          {
            loader: "css-loader",
            options: {
              modules: true,
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "管理输出",
    }),
  ],
  output: {
    path: path.resolve(__dirname, "dist"),
  },
};

value变量

在css-loader文档中描述了开启CSS模块后,可以使用value变量的功能。虽然CSS Modules自己的主文档中没写,但是Webpack和Vite实际上都支持。value变量有点像CSS变量,一次定义,多处使用。这里我们举个例子,直接在前面的基础上修改CSS文件:

@value varRed red;
@value borderBlue 1px solid blue;

.class1 {
  color: varRed;
}
.class2 {
  background: varRed;
  border: borderBlue;
}

css-modules-12.png

可以看到,使用方式和CSS变量类似,预先定义变量,随后引用标识符。但CSS变量是运行时,CSS Modules的变量是编译时。由于没有CSS变量好用,而且LESS和SCSS等都提供了更完善的编译时变量功能,因此CSS Modules的变量功能好像用的人不多。

模式

css-loader的modules配置表示CSS Modules相关配置,其中有一个mode配置,表示模式,有四种选项值,这里列举一下:

  • local 将所有标识符编译为局部规则标识符
  • global 将所有标识符编译为全局标识符,相当于所有标识符使用:global
  • pure 与local模式一致,但是检测每一个CSS选择器是否存在局部标识符,如果不存在报错
  • icss 仅处理composes特性,但是不转换局部标识符

local模式既是默认模式,与上面正常使用CSS Modules一致。global标识符会处理value变量,但不会转换成局部标识符。icss模式处理composes特性,但是也不转换局部标识符。pure模式则在普通local模式的基础上,增加了对于每个选择器的判断,这么判断的原因为:检测CSS模块文件中是否存在可以全局生效的CSS选择器。这里设置pure模式试一下,我们修改webpack.config.js中的css-loader配置:

{
  loader: "css-loader",
  options: {
    modules: {
      mode: 'pure'
    },
  },
}

然后创建修改index.module.scss文件:

.class1 {
  color: red;
}
.class2 {
  background: blue;
}
div {
  color: yellow;
}

执行npm run build,结果失败,命令行输出结果如下:

css-modules-13.png

可以看到选择器div被标出来了,说它不“pure”,应该至少包含一个局部class或者id。这是因为div属性选择器,无法对属性选择器进行局部标识符的处理,因此这个选择器会全局生效,使用pure模式后,css-loader会寻找CSS模块文件中的全局选择器并报错,防止意外影响全局。

自定义标识符格式

在前面用Vite尝试CSS Modules时生成的新标识符格式,与Webpack生成的新标识符格式,看起来有点区别:Vite生成的带原来的类名,但是Webpack不带。这种标识符格式,实际上是可以更改的,使用localIdentName配置项即可:

{
  loader: "css-loader",
  options: {
    modules: {
      localIdentName: '[hash:base64]'
    },
  },
}

默认配置是[hash:base64],即原标识符的哈希值。还可以扩展其他格式:

  • [name] 源文件名称
  • [path] 目录名称
  • [file] 目录和文件名
  • [ext] 文件拓展名
  • [hash] 原标识符的哈希值
  • [local] 原来的标识符名
  • [hash:base64] 将hash做Base64处理
  • [hash:5] hash的长度限制为5

这些配置可以组合成模板字符串,例如[name]_[ext]_[hash]。这里举几个配置和对应的生成标识符的例子:

配置项 举例1 举例2
[name]_[ext]_[hash:7] index-module_-css_a9b9eaf index-module_-css_f3e6e28
[path]_[local]_[hash:base64:5] src-_class1_qbnq8 src-_class2_cofz5

使用postcss-modules

Webpack使用css-loader来支持CSS Modules,Vite背后则采用postcss-modules来支持CSS Modules。如果开启了lightningcss,则使用Lightning CSS来支持CSS Modules。postcss-modules是一个PostCSS插件,这里我们介绍一下。

引入postcss-modules

首先我们创建一个工程,引入PostCSS和postcss-modules,使其可以成功编译CSS Modules。首先执行命令行:

npm init -y
npm add -D postcss postcss-cli postcss-modules
# 后面执行下面命令行,可以编译CSS
# src 源文件目录 output 生成文件目录
npx postcss src -d output --no-map

创建PostCSS的配置文件postcss.config.js,里面引入postcss-modules插件。

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [postcssModules],
};

创建src/index.module.css,里面包含如下内容:

.class1 {
  color: red;
}
.class2 {
  composes: class1;
  background: blue;
}

css-modules-14.png

执行编译后,生成结果如上图。首先看左边的目录树,生成了output/index.module.css,内容是标识符改变后的CSS规则。但是在src目录中却还生成了一个index.module.css.json文件,内容为原标识符和新标识符的映射关系。因为PostCSS没有编译JavaScript文件的能力,因此这个映射关系作为额外的JSON文件导出了。

获取标识符映射数据

前面引入postcss-modules中,我们发现生成的标识符映射关系文件被放到了src源文件目录中。这有点让人不适,源代码目录不应该被生成的内容污染。postcss-modules提供了我们自行控制标识符映射数据的方式,需要修改配置文件postcss.config.js:

const path = require("path");
const fs = require("fs");
const postcssModules = require("postcss-modules");

module.exports = {
  plugins: [
    postcssModules({
      getJSON: (cssFileName, json, outputFileName) => {
        // 源CSS文件路径
        console.log(cssFileName);
        // 标识符映射数据
        console.log(json);
        // 默认要输出的文件路径 可以弃用
        console.log(outputFileName);
        console.log("---");

        // 创建目录 如果已经创建则静默成功
        fs.mkdirSync(path.resolve("./classMap"), { recursive: true });
        // 获取源文件名
        const cssName = path.basename(cssFileName);
        // 拼合新的路径
        const jsonFileName = path.resolve("./classMap/" + cssName + ".json");
        // 文件写入新路径
        fs.writeFileSync(jsonFileName, JSON.stringify(json));
      },
    }),
  ],
};

可以看到,对getJSON配置项传入函数,可以拿到文件路径和标识符数据,可以对它进行任意处理。上面给出了一个写入其它目录的例子。这里创建两个CSS文件index.module.css和index2.module.css,执行编译输出结果如下:

css-modules-15.png

自定义标识符格式

在介绍Webpack使用方式时,我们提到css-loader支持自定义标识符格式,同样的postcss-modules也支持,而且格式也一样,通过修改generateScopedName配置:

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      generateScopedName: '[name]_[ext]_[hash:base64:5]'
    }),
  ],
};

因此,我们可以参考上面css-loader的文档来修改generateScopedName配置。generateScopedName还支持自定义函数,可以随心所欲的配置:

const path = require("path");
const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      generateScopedName: (name, filename, css) => {
        // name 文件路径 filename 文件路径
        console.log(name, filename);
        // CSS文件内容
        console.log(css);
        console.log('-----');
        return `${path.basename(filename).replaceAll('.', '-')}_${name}`;
      }
    }),
  ],
};

在上面的例子中,我们将class名改为文件名+class名(仅供示例,实际使用还是会重复)。我们看下效果,成功的将class类名改为了我们自定义的格式。

css-modules-16.png

原始标识符转换

CSS Modules推荐使用camelCase驼峰命名法来写CSS标识符,因为可以轻松在JavaScript中使用,例如styles.abcDef。但如果用kebab-case中划线命名法,在JavaScript中使用就稍微麻烦一些,但也不是不能用,例如:styles['abc-def']。但如果已有的CSS规则,postcss-modules提供了localsConvention配置项,可以让我们将原始标识符转换为驼峰命名法的形式。这样即使我们在CSS文件中使用中划线命名法,在JavaScript代码中也能用驼峰命名法引入。它共有四个选项可以配置。

  • camelCase 输出为骆驼命名法,输出保留原标识符
  • camelCaseOnly 输出为骆驼命名法,不保留原标识符
  • dashes 仅转换中划线为骆驼命名法,输出保留原标识符
  • dashesOnly 仅转换中划线为骆驼命名法,不保留原标识符

这里我们构造一个CSS文件,带有几种命名法:

.abcDef {
  color: red;
}
.bcd-efg {
  color: red;
}
.cde_fgh {
  color: red;
}

对于不同配置项,我们看一下输出结果:

// camelCase
{
  "abcDef": "_abcDef_cmy82_1",
  "bcd-efg": "_bcd-efg_cmy82_7",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cde_fgh": "_cde_fgh_cmy82_13",
  "cdeFgh": "_cde_fgh_cmy82_13"
}

// camelCaseOnly
{
  "abcDef": "_abcDef_cmy82_1",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cdeFgh": "_cde_fgh_cmy82_13"
}

// dashes
{
  "abcDef": "_abcDef_cmy82_1",
  "bcd-efg": "_bcd-efg_cmy82_7",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cde_fgh": "_cde_fgh_cmy82_13"
}

// dashesOnly
{
  "abcDef": "_abcDef_cmy82_1",
  "bcdEfg": "_bcd-efg_cmy82_7",
  "cde_fgh": "_cde_fgh_cmy82_13"
}

localsConvention配置还可以接受函数入参,这样我们可以自定义JavaScrript引入的标识符名,这里我们举了个例子,将标识符后面加了固定的后缀。

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      localsConvention: (originalClassName, generatedClassName, filPath) => {
        // 原标识符 转换后标识符
        console.log(originalClassName, generatedClassName);
        // CSS文件路径
        console.log(filPath);
        console.log("-----");
        return originalClassName + "_jzplp";
      },
    }),
  ],
};

/* 输出结果
{
  "abcDef_jzplp": "_abcDef_cmy82_1",
  "bcd-efg_jzplp": "_bcd-efg_cmy82_7",
  "cde_fgh_jzplp": "_cde_fgh_cmy82_13"
}
*/

处理路径别名

在CSS Modules的composes特性中,可以引入其它CSS文件中选择器的规则。很多人喜欢使用路径别名,例如@common/css等,postcss-modules也提供了resolve这个配置项,可以让我们自行控制别名,甚至修改路径。首先我们构造一个文件路径的例子:

/* src/common/com.css */
.classCommon {
  color: red;
}

/* src/copm1/index.module.css */
.class1 {
  background: yellow;
  composes: classCommon from '@common/com.css';
}

可以看到,我们先在common目录中设置了CSS文件,然后在copm1目录中,使用@common将其引入。如果不处理,@common这个路径CSS Module肯定是识别不了的。这里我们设置postcss.config.js:

const path = require("path");
const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      resolve: (file, importer) => {
        // composes特性引入的文件路径
        console.log(file);
        // 当前处理的CSS文件
        console.log(importer);
        const newPath = path.resolve(
          process.cwd() + file.replace("@common", "/src/common"),
        );
        return newPath;
      },
    }),
  ],
};

/*
命令行输出结果
@common/com.css
E:\testProj\css-modules\postcss-proj\src\comp1\index.module.css

index.module.css.json输出结果
{"class1":"_class1_u2c4w_1 _classCommon_pc5si_1"}
*/

可以看到,提供自定义的resolve函数,可以接收composes中的路径与当前处理的文件路径,然后将我们的路径别名转化为真正的路径名。

解析PostHTML模板

postcss-modules导出的JSON文件,可以被posthtml-css-modules使用,用来提供给PostHTML模板提供新的标识符。首先我们将postcss-modules中的所有选项删除(为了不干扰这个例子)。然后创建一个CSS文件src/index.module.css:

.class1 {
  background: yellow;
}
.class2 {
  color: red;
  composes: class1;
}

然后创建index.js,处理PostHTML模板:

const posthtml = require('posthtml');
const posthtmlCssModules = require("posthtml-css-modules");

const template = `
<div css-module="class1">test1</div>
<div css-module="class2">test2</div>
`
posthtml([posthtmlCssModules("./src/index.module.css.json")])
.process(template)
.then(function (result) {
console.log(result.html);
});

我们还没安装新依赖呢。执行如下命令行,安装依赖和拿到编译结果:

# 安装依赖
npm add posthtml posthtml-css-modules
# 编译CSS 处理CSS Modules
postcss src -d output --no-map
# 执行上面代码,处理PostHTML模板
node index.js

最后输出结果如下。可以看到关键在于css-module属性,我们将其设置为原始的标识符,经过处理后就变为了转换后的标识符。

<div class="_class1_go5lk_1">test1</div>
<div class="_class2_go5lk_7 _class1_go5lk_1">test2</div>

posthtml-css-modules还支持传入目录,可以处理多个文件。同时在css-module中使用点符号分隔文件和属性。但由于postcss-modules默认生成的文件中肯定会出现点,例如 index1.css -> index1.css.json,因此我们先要处理postcss.config.js:

const path = require("path");
const fs = require("fs");

const postcssModules = require("postcss-modules");
module.exports = {
  plugins: [
    postcssModules({
      getJSON: (cssFileName, json) => {
        // 创建目录 如果已经创建则静默成功
        fs.mkdirSync(path.resolve("./classMap"), { recursive: true });
        // 获取源文件名
        const cssName = path.basename(cssFileName, ".css");
        // 拼合新的路径
        const jsonFileName = path.resolve("./classMap/" + cssName + ".json");
        // 文件写入新路径
        fs.writeFileSync(jsonFileName, JSON.stringify(json));
      },
    }),
  ],
};

例如我们有src目录,里面有两个JSON文件insex1.json和insex2.json,此时处理模板的代码修改为:

const posthtml = require('posthtml');
const posthtmlCssModules = require("posthtml-css-modules");

// 文件名.标识符
const template = `
<div css-module="index1.class1">test1</div>
<div css-module="index2.class2">test2</div>
`
posthtml([posthtmlCssModules("./classMap/")])
.process(template)
.then(function (result) {
console.log(result.html);
});

/* 输出结果
<div class="_class1_1c28r_1">test1</div>
<div class="_class2_10f6k_1">test2</div>
*/

Lightning CSS

Lightning CSS是一个用Rust编写的CSS编译工具,可以解析,编译,打包和压缩CSS代码,性能比用JavaScript 的同类工具要强很多。Lightning CSS也支持CSS modules,这里我们简单讲一下用法。

接入Lightning CSS

首先执行命令行新建工程,安装lightningcss依赖。然后创建index.mjs,内容如下:

import { transform } from 'lightningcss';

const cssData = `
.class1 { color: red }
.class2 { color: blue }
`;
let { code, exports } = transform({
  cssModules: true,
  code: Buffer.from(cssData),
});

console.log(code.toString());
console.log('-----');
console.log(exports);

/* 输出结果
._8Z4fiW_class1 {
  color: red;
}

._8Z4fiW_class2 {
  color: #00f;
}

-----
{
  class1: { name: '_8Z4fiW_class1', composes: [], isReferenced: false },
  class2: { name: '_8Z4fiW_class2', composes: [], isReferenced: false }
}
*/

从上面代码可以看到,Lightning CSS接收和返回的都是Buffer对象;设置cssModules为true可以处理CSS modules。其中code是返回代码,exports是返回的映射关系。Lightning CSS返回的映射关系格式与其它工具不同,它一个标识符为一个对象,其中的name为转换后的标识符。

composes特性的映射关系

与其它工具不一样,Lightning CSS处理composes特性并不将其直接作为name,而是单独放到composes数组中。这里我们举个例子试一下:

.class1 { color: red }
.class2 {
  color: blue;
  composes: class1;
  composes: jzplp from global;
  composes: abc from './style.css';
}

/* 输出结果
._8Z4fiW_class1 {
  color: red;
}

._8Z4fiW_class2 {
  color: #00f;
}

-----
{
  class1: { name: '_8Z4fiW_class1', composes: [], isReferenced: false },
  class2: {
    name: '_8Z4fiW_class2',
    composes: [
      { type: 'local', name: '_8Z4fiW_class1' },
      { type: 'global', name: 'jzplp' },
      { type: 'dependency', name: 'abc', specifier: './style.css' }
    ],
    isReferenced: false
  }
}
*/

这里举了三种composes例子,有不同的类型:

  • local 本文件中的标识符
  • global 全局标识符
  • dependency 其它文件的标识符

注意dependency类型,这里没有实际读取另一个CSS文件(因为我们使用transform来编译,不能读取其它文件),而且这个name值也并没有变成转换后的值。Lightning CSS的文档中要求调用方自行处理。

使用bundle方法

前面我们提到,在使用composes特性引入另一个文件的标识符,最后映射关系中只给我们返回了文件路径,没有帮我们实际引入。这时候我们不用transform方法,转为使用bundle,Lightning CSS就可以帮我们读取文件了。我们构造一个例子。首先创建两个CSS文件:

/* index.css */
.class2 {
  background-color: red;
  composes: abc from './style.css';
}

/* style.css */
.abc {
  color: blue;
}
.bcd {
  font-size: 14px;
}

然后修改index.mjs:

import { bundle } from "lightningcss";

let { code, exports } = bundle({
  cssModules: true,
  filename: "./index.css",
});

console.log(code.toString());
console.log("-----");
console.dir(exports, { depth: null });

/* 输出结果
.Zvw1Mq_abc {
  color: #00f;
}

.Zvw1Mq_bcd {
  font-size: 14px;
}

.vkZoAa_class2 {
  background-color: red;
}

-----
{
  class2: {
    name: 'vkZoAa_class2',
    composes: [ { type: 'local', name: 'Zvw1Mq_abc' } ],
    isReferenced: false
  }
}
*/

可以看到,虽然我们只输入了一个index.css文件,但两个CSS文件实际上都被编译了。但映射关系还是只输出了index.css。之前的dependency类型消失了,因为拿到了转换后的类名,所以类型也被转为了local。

模块化CSS变量

Lightning CSS并不提供value变量功能,理由是CSS本身已经支持变量了。但Lightning CSS支持了将CSS变量标识符转换为hash标识符的功能,即局部CSS变量。这里我们试一下:

import { transform } from "lightningcss";

const cssData = `
.root {
  --abc: red;
}
.class1 {
  color: var(--abc);
  background: var(--bcd from global);
  font-size: var(--def from './style.css');
}
`;
let { code, exports } = transform({
  cssModules: {
    dashedIdents: true,
  },
  code: Buffer.from(cssData),
});

console.log(code.toString());
console.log("-----");
console.log(exports);

/* 输出结果
._8Z4fiW_root {
  --_8Z4fiW_abc: red;
}

._8Z4fiW_class1 {
  color: var(--_8Z4fiW_abc);
  background: var(--bcd);
  font-size: var(--tCZyqW);
}

-----
{
  class1: { name: '_8Z4fiW_class1', composes: [], isReferenced: false },
  '--abc': { name: '--_8Z4fiW_abc', composes: [], isReferenced: true },
  root: { name: '_8Z4fiW_root', composes: [], isReferenced: false }
}
*/

开启dashedIdents选项才能够局部化CSS变量。 这里列举了三个情形,与composes非常类似:

  • 本文件的CSS变量 使用方法与CSS写法一致,工具会自己转换。exports中也导出了对应标识符。
  • 全局CSS变量 使用from global可以设置为全局变量
  • 从其它文件引入局部CSS变量

注意看从其它文件引入的情形,exports中并没有导出这个标识符,但是CSS文件却被转换了。这也是因为transform方法没有访问文件的能力,但这种“自行转换”的形式有些不妥,如果没有和另一个文件匹配,这个CSS变量引入就无法生效。这里我们换成bundle方法试一下。还是创建两个CSS文件:

/* index.css */
.root {
  --abc: red;
}
.class1 {
  color: var(--abc);
  background: var(--bcd from global);
  font-size: var(--def from './style.css');
}

/* style.css */
.root {
  --def: 14px;
}

然后修改index.mjs。通过执行结果可以看到,bundle方法将两个文件一起编译,保证结果的正确性。

import { bundle } from "lightningcss";

let { code, exports } = bundle({
  cssModules: {
    dashedIdents: true,
  },
  filename: "./index.css",
});

console.log(code.toString());
console.log("-----");
console.dir(exports, { depth: null });

/* 
.Zvw1Mq_root {
  --Zvw1Mq_def: 14px;
}

.vkZoAa_root {
  --vkZoAa_abc: red;
}

.vkZoAa_class1 {
  color: var(--vkZoAa_abc);
  background: var(--bcd);
  font-size: var(--Zvw1Mq_def);
}

-----
{
  root: { name: 'vkZoAa_root', composes: [], isReferenced: false },
  '--abc': { name: '--vkZoAa_abc', composes: [], isReferenced: true },
  class1: { name: 'vkZoAa_class1', composes: [], isReferenced: false }
}
*/

Lightning CSS的CSS Modules还支持一些其它功能,例如自定义标识符,自定义标识符转换范围,pure模式等,这里就不赘述了。

背后的Postcss插件

观察css-loader和postcss-modules的依赖,发现它们都引用了四个前缀一致的PostCSS插件:

  • postcss-modules-local-by-default
  • postcss-modules-scope
  • postcss-modules-extract-imports
  • postcss-modules-values

这四个插件名称都以postcss-modules-开头,都是实现CSS Modules相关的插件,而且也在css-modules自己的仓库列表中。我们逐一介绍一下这几个插件。

postcss-modules-local-by-default

postcss-modules-local-by-default插件的作用是将标识符增加:local(),同时将:global()去掉。这样所有应该被局部化处理的标识符都有:local()标志,没有标志则说明无需处理。首先修改postcss.config.js,引入插件:

const postcssModules = require("postcss-modules-local-by-default");
module.exports = {
  plugins: [postcssModules],
};

然后是要编译的CSS文件和编译结果:

.class1 {
  color: red;
}
.class2:hover {
  color: blue;
}
:global(.class3) {
  color: blue;
}

/* 输出结果
:local(.class1) {
  color: red;
}
:local(.class2):hover {
  color: blue;
}
.class3 {
  color: blue;
}
*/

postcss-modules-scope

postcss-modules-scope插件的作用是将带:local()的CSS标识符转换成新标识符,同时输出一个:export,里面包含标识符转换关系。首先修改postcss.config.js,引入插件:

const postcssModules = require("postcss-modules-scope");
module.exports = {
  plugins: [postcssModules],
};

通过功能说明,我们发现postcss-modules-scope插件的输入实际上就是postcss-modules-local-by-default插件的输出,因此我们把前面的结果拿过来继续编译。可以看到输出的转换关系还是放在CSS文件中。

:local(.class1) {
  color: red;
}
:local(.class2):hover {
  color: blue;
}
.class3 {
  color: blue;
}
/* 输出结果
._E_testProj_css_modules_postcss2_proj_src_index__class1 {
  color: red;
}
._E_testProj_css_modules_postcss2_proj_src_index__class2:hover {
  color: blue;
}
.class3 {
  color: blue;
}
:export {
  class1: _E_testProj_css_modules_postcss2_proj_src_index__class1;
  class2: _E_testProj_css_modules_postcss2_proj_src_index__class2;
}
*/

postcss-modules-extract-imports

postcss-modules-extract-imports插件的作用是处理跨文件的composes,但并不是实际引入文件。首先修改配置文件postcss.config.js:

const postcssModules = require("postcss-modules-extract-imports");
module.exports = {
  plugins: [postcssModules],
};

然后是要编译的CSS文件和编译结果。通过结果可以看到,插件将跨文件的composes输出为了一个特殊标识符,然后在文件上面增加了:import,给出了对应CSS文件中文件标识符的映射关系。

.class1 {
  color: red;
  composes: abc from './style.css';
}
.class2 {
  color: red;
  composes: class1;
}

/* 输出结果
:import("./style.css") {
  i__imported_abc_0: abc;
}
.class1 {
  color: red;
  composes: i__imported_abc_0;
}
.class2 {
  color: red;
  composes: class1;
}
*/

postcss-modules-values

postcss-modules-values插件的作用是处理value变量特性的。首先修改配置文件postcss.config.js:

const postcssModules = require("postcss-modules-values");
module.exports = {
  plugins: [postcssModules],
};

然后是要编译的CSS文件和编译结果。通过结果可以看到,本文件的value变量直接替换成对应值,跨文件的处理方式则类似于postcss-modules-extract-imports,在文件上面增加了:import,标识出了对应关系。

@value colorRed: red;
@value colorBlue: from './styles.css';

.class1 {
  color: colorRed;
  background-color: colorBlue;
}

/* 输出结果
:import('./styles.css') {
  i__const_colorBlue_0: colorBlue;
}
:export {
  colorRed: red;
  colorBlue: i__const_colorBlue_0;
}
.class1 {
  color: red;
  background-color: i__const_colorBlue_0;
}
*/

总结

CSS Modules是非常流行的CSS工具,大部分构建工具都提供了对它的支持。这是因为:

  1. 较好的解决了前端组件化中CSS的问题,避免了CSS污染和冲突问题。
  2. 写法上就是普通CSS文件,不需要修改语法,容易被大众接收。
  3. 与现代前端框架开发集成较好,尤其是React。
  4. 与SCSS和Less等CSS预处理工具都兼容。

相对的,像是一些和CSS语法有区别的特性,例如value变量等,使用的人就少了。甚至新的Lightning CSS干脆不支持。

通过【背后的Postcss插件】这部分,我们了解到CSS Modules功能的实现是由css-modules提供的PostCSS插件实现的。但这些插件不能读取文件,也不能实现将CSS文件导出为JavaScript对象。处理文件和打包是构建工具负责的内容,因此还要构建工具自行适配,完成整个功能的开发提供给使用者。

参考

作用域与作用域链:JS 的“找东西”逻辑,闭包到底是个啥?

为什么有的变量在函数里能用,在外面却报错?为什么循环里的i总是最后一个值?今天我们就来聊聊JavaScript的作用域和作用域链,顺便揭开闭包的神秘面纱。保证你看完之后,再也不用背面试题了。

前言

想象一下这样的场景:你在自己房间里找手机,找不到就去客厅找,再找不到就去邻居家借手机打电话。如果所有地方都找不到,那就只能放弃——手机丢了。

JavaScript在查找变量时,也是这么个流程。这个“找东西”的规则,就是作用域链。而变量能在哪些地方被找到,由它的作用域决定。

今天我们就来把这件事彻底捋清楚。

一、作用域:变量的“活动范围”

作用域就是变量能够被访问到的范围。JS中有三种主要作用域:

1. 全局作用域:公共场所

在函数外面定义的变量,或者没加任何关键字直接写的变量(严格模式会报错),都属于全局作用域。

var globalVar = '我是全局的';
let alsoGlobal = '我也是全局的';

function sayHello() {
  console.log(globalVar); // 能访问
}

全局变量就像公共场所的设施,谁都能用,但正因为谁都能改,所以容易出问题。而且全局变量会一直存在,直到页面关闭。

2. 函数作用域:自己家

在函数内部用var声明的变量,只能在这个函数内部访问。外面进不去,里面可以出去(找外面的变量)。

function myHouse() {
  var secret = '我藏起来的零食';
  console.log(secret); // 能访问
}
console.log(secret); // 报错:secret is not defined

函数作用域像自己家,外人不能随便进,但你可以从家里出去(访问全局)。

3. 块级作用域:卧室里的保险柜

ES6新增的letconst带来了块级作用域。块就是大括号{}包起来的地方,比如ifforwhile里面。

if (true) {
  let blockVar = '我只能在块里用';
  var functionVar = '我可以在整个函数用'; // var没有块级作用域
}
console.log(blockVar); // 报错
console.log(functionVar); // 能访问,因为var只有函数作用域

块级作用域就像卧室里的保险柜,只有在这个房间里才能打开。var则像家里的公共区域,虽然写在卧室里,但实际还是公共的。

二、作用域链:找变量的路径

当你在一个作用域里使用变量时,JS引擎会按照这个顺序找:

  1. 当前作用域:先看自己家里有没有。
  2. 外层作用域:没有就去上一层找。
  3. 继续往外:一层一层往上,直到全局作用域。
  4. 全局也没有:那就报错not defined

这种嵌套的作用域形成的链条,就是作用域链

来看个例子:

var global = '全球通';

function outer() {
  var outerVar = '外层的';
  
  function inner() {
    var innerVar = '内层的';
    console.log(innerVar); // 找到自己家的
    console.log(outerVar); // 自己家没有,去外层找
    console.log(global);   // 自己家没有,外层没有,再去全局
  }
  
  inner();
}

outer();

这个过程就像你在家找东西:先翻自己口袋,没有就去客厅找,还没有就去小区便利店,再没有就只能放弃了。

三、闭包:虽然离开了,但我还记得

闭包是JS里一个常考常新、常学常忘的概念。简单来说:闭包就是函数记住了它定义时的作用域,即使这个函数在其他地方执行,也能访问那个作用域里的变量

举个例子:

function createCounter() {
  let count = 0; // count 被闭包记住了
  
  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

这里createCounter执行后返回了一个函数,按说count应该被销毁了,但返回的函数依然能访问count——这就是闭包的力量。

闭包的生活比喻

想象你从小长大的家,后来搬走了,但你还记得家里的WiFi密码。每次你路过楼下,还能连上那个WiFi。这个“记住密码”的能力,就是闭包。

闭包的用途:

  • 数据私有化(比如上面的计数器,外部无法直接修改count)
  • 函数工厂(生成特定功能的函数)
  • 回调函数中保持状态(比如事件监听)

闭包的坑

闭包虽然好用,但也要注意内存问题。因为被记住的变量不会释放,如果闭包一直存在,这些变量就会一直占用内存。比如上面例子,只要counter这个函数还在,count就不会被垃圾回收。

四、经典面试题:循环中的var

这是JS初学者最容易踩的坑之一:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

你期望输出0,1,2,3,4,但实际输出5,5,5,5,5。为什么?

因为var没有块级作用域,循环里的i其实是全局(或函数级)的同一个变量。循环结束后i变成了5,然后setTimeout的回调执行时,访问的都是同一个i,所以全是5。

解决方式:

  1. 用let:let有块级作用域,每次循环都会创建一个新的变量。
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0,1,2,3,4
  }, 100);
}
  1. 用闭包(老办法):
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 100);
  })(i);
}

用立即执行函数创建新的作用域,把每次的i传进去保存下来。

五、词法作用域:写在哪就在哪找

JS采用的是词法作用域(也叫静态作用域),也就是说变量的查找范围在代码编写时就决定了,而不是在运行时。

var value = 1;

function foo() {
  console.log(value);
}

function bar() {
  var value = 2;
  foo(); // 输出什么?
}

bar(); // 输出1

这里foo定义在全局,所以它访问的value是全局的1,而不是bar里的2。因为作用域由函数定义的位置决定,而不是调用位置。

这个特性是闭包能工作的基础。

六、执行上下文:运行时的小剧场

作用域是静态的规则,而执行上下文是运行时动态的环境。每当函数执行,都会创建自己的执行上下文,里面包含了变量、参数、以及对外部作用域的引用。

执行上下文有点像每次进家门时拿的钥匙串,上面有自己家的钥匙,还有父母家的钥匙(通过作用域链)。

七、总结:今天你学到了什么?

  • 作用域就是变量的可见范围:全局(公共场所)、函数(自己家)、块级(卧室保险柜)。
  • 作用域链就是找变量的路径:当前 → 外层 → 全局,找不到就报错。
  • 闭包是函数记住了它出生时的环境,即使离开了也能访问那些变量。用途广泛,但要注意内存。
  • 词法作用域意味着变量的查找在写代码时决定,和运行位置无关。
  • 循环中用var容易踩坑,用let或闭包解决。

现在你再看到作用域相关的问题,应该能像老司机一样游刃有余了。明天我们将继续深入,聊聊JavaScript里最让人迷惑的概念之一:闭包的应用场景和内存管理,看看闭包在实际项目中到底怎么用,怎么避免内存泄漏。

如果你觉得今天的文章对你有帮助,点个赞让更多人看到,也欢迎在评论区聊聊你遇到过的作用域坑。我们明天见!

生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南

前言

当我们的应用从开发环境走向生产环境,真正的挑战才刚刚开始。用户不会关心我们的代码写得多么优雅,他们只关心页面加载快不快、交互流不流畅。一个未经优化的生产构建,可能让我们的用户在第一秒就流失。

为什么要优化生产构建?

一个真实的反面教材

我们先来看一个系统打包后的产物:

dist/
├── index.html                5KB
├── assets/index.abc123.js    2.8MB  ← 一个文件包含了所有代码
├── assets/vendor.def456.js   1.2MB  ← 第三方库
├── assets/style.ghi789.css   180KB
└── images/
    ├── logo.png              120KB  ← 未压缩
    ├── banner.jpg            850KB  ← 巨大
    └── ...

当用户访问这个系统时:

  • 下载 2.8MB + 1.2MB + 180KB + 970KB = 约 5MB
  • 4G 网络下需要 2 秒;3G 网络会更慢
  • 用户早跑了

构建优化的核心目标

优化维度 目标 收益
拆包优化 分离业务代码和第三方库 利用浏览器缓存,二次访问提速
图片压缩 减少图片体积 平均减少 60-80% 体积
Gzip/Brotli 压缩文本资源 减少 70-90% 传输体积
长期缓存 文件名哈希,内容变化才更新 最大化缓存利用率

优化能带来什么?

指标 优化前 优化后 提升
首屏 JS 体积 4.2 MB 2.1 MB 50%
图片总体积 2.8 MB 0.6 MB 78%
传输体积(Gzip后) 3.2 MB 0.8 MB 75%
首次加载时间 3.2 秒 1.1 秒 65%
二次加载时间 2.1 秒 0.3 秒 85%

先诊断,后开药 - 构建分析工具

为什么要先分析?

就像医生看病要先做检查一样,优化构建也要先找到问题在哪。在主观上,我们可能会觉得是不是某个依赖太大了?但实际上可能是另一个我们没想到的库!

使用 rollup-plugin-visualizer 分析

安装

npm install --save-dev rollup-plugin-visualizer

配置

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    visualizer({
      filename: 'dist/stats.html',  // 输出文件
      open: true,                   // 构建后自动打开
      gzipSize: true,                // 显示 gzip 后大小
      brotliSize: true,              // 显示 brotli 后大小
      template: 'treemap'            // 图表类型: treemap, sunburst, network
    })
  ]
}

运行构建

npm run build
// 浏览器会自动打开一个酷炫的图表
// 一眼就能看出哪些文件最大

使用 vite-bundle-visualizer 分析

安装

npm install --save-dev vite-bundle-visualizer

运行分析

npx vite-bundle-visualizer

输出示例

┌───────────────────────┬─────────────┬──────────┬───────┐
│       Module          │    Size     │  Gzip    │ Brotli│
├───────────────────────┼─────────────┼──────────┼───────┤
│ node_modules/         │ 2.3 MB      │ 680 KB   │ 520 KB│
│   vue/                │ 680 KB      │ 210 KB   │ 160 KB│
│   element-plus/       │ 890 KB      │ 280 KB   │ 210 KB│
│   echarts/            │ 520 KB      │ 150 KB   │ 115 KB│
│   lodash-es/          │ 210 KB      │ 62 KB    │ 48 KB │
│ src/                  │ 1.8 MB      │ 480 KB   │ 360 KB│
└───────────────────────┴─────────────┴──────────┴───────┘

自定义分析脚本

// scripts/analyze.js
import fs from 'fs'
import path from 'path'
import { gzipSizeSync } from 'gzip-size'
import { brotliSizeSync } from 'brotli-size'

function analyzeDist() {
  const distDir = path.resolve('./dist/assets')
  const files = fs.readdirSync(distDir)
  
  let totalSize = 0
  let totalGzip = 0
  let totalBrotli = 0
  
  console.log('📦 构建产物分析\n')
  
  files
    .filter(f => f.endsWith('.js') || f.endsWith('.css'))
    .forEach(file => {
      const filePath = path.join(distDir, file)
      const content = fs.readFileSync(filePath)
      const size = content.length
      const gzip = gzipSizeSync(content)
      const brotli = brotliSizeSync(content)
      
      totalSize += size
      totalGzip += gzip
      totalBrotli += brotli
      
      console.log(`${file}:`)
      console.log(`  Raw:    ${(size / 1024).toFixed(2)} KB`)
      console.log(`  Gzip:   ${(gzip / 1024).toFixed(2)} KB (${(gzip/size*100).toFixed(0)}%)`)
      console.log(`  Brotli: ${(brotli / 1024).toFixed(2)} KB (${(brotli/size*100).toFixed(0)}%)\n`)
    })
  
  console.log('📊 总计:')
  console.log(`  Raw:    ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Gzip:   ${(totalGzip / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Brotli: ${(totalBrotli / 1024 / 1024).toFixed(2)} MB`)
}

analyzeDist()

看懂分析结果

分析结果能告诉我们什么?

1. 找出最大的依赖

  • echarts: 520KB → 考虑按需加载
  • monaco-editor: 2.8MB → 考虑动态导入

2. 找出重复的依赖

  • lodash 和 lodash-es 同时存在? → 统一用 lodash-es
  • moment 和 dayjs 同时存在? → 用 dayjs 替代 moment

3. 找出可以拆分的点

  • node_modules 打包在一起太大了 → 拆成多个 chunk
  • 所有页面代码都在一个文件里 → 按路由拆分

拆包策略 - 把大象放进冰箱

为什么要拆包?

用一个比喻来解释

不拆包:把所有东西都塞进一个行李箱
├─ 想拿牙刷 → 要翻遍整个箱子
├─ 箱子破了 → 所有东西都掉出来
└─ 箱子太大 → 搬不动

拆包:分成多个小包
├─ 洗漱包:牙刷、牙膏、毛巾
├─ 衣物包:衣服、裤子、袜子
├─ 电子包:充电器、数据线
├─ 哪个包破了 → 只损失那部分
└─ 每个包都很轻 → 好搬

技术层面的好处

不拆包:
├─ 修改一行代码 → 整个大文件缓存失效
└─ 用户每次更新都要重新下载所有代码

拆包后:
├─ 第三方库独立 → 几乎不变,长期缓存
├─ 业务代码拆分 → 只下载修改的部分
└─ 多个小文件可以并行下载

基础拆包配置

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 最基本的拆包策略
        manualChunks: {
          // 将 Vue 全家桶打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia', 'vuex'],
          
          // 将 UI 库打包在一起
          'vendor-ui': ['element-plus', '@element-plus/icons-vue', 'ant-design-vue'],
          
          // 将工具库打包在一起
          'vendor-utils': ['lodash-es', 'dayjs', 'axios', 'date-fns'],
          
          // 将图表库打包在一起
          'vendor-charts': ['echarts', 'd3', 'chart.js']
        }
      }
    }
  }
}

智能拆包:根据依赖关系自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          // node_modules 中的依赖
          if (id.includes('node_modules')) {
            // 按包名拆分
            if (id.includes('vue')) {
              return 'vendor-vue'  // 所有 vue 相关
            }
            
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'   // UI 库
            }
            
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts' // 图表库
            }
            
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'  // 工具库
            }
            
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'  // 编辑器单独打包
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
          
          // 业务代码按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) {
              return `page-${match[1]}` // 按页面拆分
            }
          }
          
          // 公共组件按模块拆分
          if (id.includes('/src/components/')) {
            const match = id.match(/\/src\/components\/([^\/]+)/)
            if (match) {
              return `components-${match[1]}`
            }
          }
        }
      }
    }
  }
}

高级拆包:基于大小的自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string, { getModuleInfo }) {
          // 如果模块大于 500KB,单独拆包
          const moduleInfo = getModuleInfo(id)
          if (moduleInfo && moduleInfo.code) {
            const size = Buffer.byteLength(moduleInfo.code, 'utf8')
            if (size > 500 * 1024) { // 500KB
              const name = id.match(/[^/]+\.(js|ts|vue)$/)?.[0]
              return `large-${name}`  // 大文件单独打包
            }
          }
          
          // 继续其他拆分逻辑
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
          }
        }
      }
    }
  }
}

异步 chunk 的命名优化

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 异步 chunk 命名
        chunkFileNames: 'assets/chunks/[name]-[hash].js',
        
        // 入口文件命名
        entryFileNames: 'assets/[name]-[hash].js',
        
        // 资源文件命名
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
        
        manualChunks: {
          // ... 拆包配置
        }
      }
    }
  }
}

// 输出结果:
// assets/index-abc123.js                (入口)
// assets/chunks/vendor-vue-def456.js    (Vue 相关)
// assets/chunks/page-dashboard-ghi789.js (页面)
// assets/images/logo-jkl012.png         (图片)

拆包后的效果

拆包方式 文件数量 缓存利用率 适用场景
不拆包 1个 极低 小项目
按依赖拆分 5-10个 中大型项目
按页面拆分 10-50个 较高 多页面应用
按大小拆分 可变 中等 有大文件的项目

图片压缩 - 看不见的优化

为什么图片是优化重点?

我们先来看一个典型的页面资源分布:

const pageResources = {
  js: '2.8MB (40%)',
  css: '180KB (3%)',
  images: '3.5MB (50%)',  // 图片占了一半!
  fonts: '500KB (7%)'
}

在页面中,图片通常占页面总体积的 50-70%,因此优化图片是最容易见效的!

vite-plugin-image-optimizer 配置

安装

npm install --save-dev vite-plugin-image-optimizer

配置

// vite.config.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default {
  plugins: [
    ViteImageOptimizer({
      // 配置文件类型和压缩参数
      png: {
        quality: 80,  // PNG 质量 0-100
        compressionLevel: 9, // 压缩级别 0-9
      },
      jpeg: {
        quality: 75,  // JPEG 质量
        progressive: true, // 渐进式 JPEG
      },
      jpg: {
        quality: 75,
      },
      webp: {
        quality: 75,  // WebP 质量
        lossless: false, // 是否无损
      },
      avif: {
        quality: 60,  // AVIF 质量
        lossless: false,
      },
      svg: {
        // SVG 优化选项
        plugins: [
          {
            name: 'preset-default',
            params: {
              overrides: {
                removeViewBox: false, // 保留 viewBox
                cleanupIds: false,     // 保留 ID
              },
            },
          },
        ],
      },
      tiff: {
        quality: 70,
      },
      gif: {
        optimizationLevel: 3, // 优化级别 1-3
      },
    })
  ]
}

不同图片类型的优化策略

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 根据不同用途设置不同参数
      
      // 1. 图标类:需要清晰,适当压缩
      'src/assets/icons/**/*': {
        png: { quality: 90 },
        svg: { plugins: ['preset-default'] }
      },
      
      // 2. 背景图:可以牺牲一些质量换取体积
      'src/assets/backgrounds/**/*': {
        jpeg: { quality: 65 },
        webp: { quality: 60 }
      },
      
      // 3. 产品图:平衡质量和体积
      'src/assets/products/**/*': {
        jpeg: { quality: 80 },
        webp: { quality: 75 }
      },
      
      // 4. 用户上传:保持较好质量
      'src/assets/uploads/**/*': {
        jpeg: { quality: 85 },
        png: { quality: 85 }
      }
    })
  ]
}

使用现代图片格式

配置

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 生成 WebP 版本(浏览器支持更好)
      webp: {
        quality: 75
      },
      
      // 生成 AVIF 版本(压缩率更高)
      avif: {
        quality: 60
      }
    })
  ]
}

在组件中配合使用

<template>
  <!-- picture 元素让浏览器选择最佳格式 -->
  <picture>
    <!-- 现代浏览器优先使用 AVIF -->
    <source srcset="/image.avif" type="image/avif">
    <!-- 其次使用 WebP -->
    <source srcset="/image.webp" type="image/webp">
    <!-- 降级到 JPEG -->
    <img src="/image.jpg" alt="图片" loading="lazy">
  </picture>
</template>

懒加载与图片优化结合

<template>
  <img 
    v-lazy="optimizedImageUrl"
    :data-srcset="`
      ${smallImage} 400w,
      ${mediumImage} 800w,
      ${largeImage} 1200w
    `"
    sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
    loading="lazy"
    :alt="alt"
  >
</template>

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

const props = defineProps<{ 
  imagePath: string,
  alt?: string 
}>()

// 根据视图宽度选择合适大小的图片
const optimizedImageUrl = computed(() => {
  // 假设构建时生成了不同尺寸的图片
  // logo-small.jpg, logo-medium.jpg, logo-large.jpg
  const width = typeof window !== 'undefined' ? window.innerWidth : 1200
  
  if (width < 600) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-small.$1')
  }
  if (width < 1200) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-medium.$1')
  }
  return props.imagePath.replace(/\.(jpg|png)$/, '-large.$1')
})
</script>

图片优化的效果

图片类型 优化前 优化后 节省
PNG 图标 120KB 35KB 71%
JPG 产品图 850KB 180KB 79%
WebP 背景 650KB 110KB 83%
SVG 矢量 15KB 8KB 47%
总体积 2.8MB 0.6MB 78%

Gzip/Brotli 压缩 - 让传输更轻盈

什么是 Gzip/Brotli?

我们可以用快递来比喻,比如我们有一件很大的“羽绒服”要邮寄给浏览器:

  • 原始文件:一件羽绒服(很大,但很轻)
  • Gzip:真空压缩袋,把羽绒服压扁
  • Brotli:更好的真空压缩袋,压得更扁

当浏览器收到压缩后的文件,它只需要打开压缩袋,羽绒服(文件)就可以恢复原状!

压缩算法的对比

算法 压缩率 压缩速度 解压速度 浏览器支持
Gzip 中等 所有浏览器
Brotli 中等 现代浏览器 (92%)
Deflate 极快 极快 所有浏览器

相同文件对比

  • 原始 JS: 1000 KB
  • Gzip: 280 KB (72% 减少)
  • Brotli: 220 KB (78% 减少)
  • Brotli 比 Gzip 再减少 21% 体积

使用 vite-plugin-compression 配置

安装

npm install --save-dev vite-plugin-compression

配置

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240, // 10KB 以上才压缩
      deleteOriginFile: false, // 保留原文件
      verbose: true, // 输出压缩信息
      filter: /\.(js|css|html|svg)$/ // 只压缩文本文件
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240,
      deleteOriginFile: false,
      verbose: true,
      filter: /\.(js|css|html|svg)$/
    })
  ]
}

// 构建结果:
// index.abc123.js
// index.abc123.js.gz    (Gzip)
// index.abc123.js.br    (Brotli)

智能压缩策略 - 多算法混合策略

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // 对不同的资源使用不同的策略
    
    // 1. HTML: 使用 Brotli(最高压缩率)
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.html$/,
      threshold: 1024
    }),
    
    // 2. JS/CSS: 同时生成 Gzip 和 Brotli
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    
    // 3. 大文件用 Brotli,小文件用 Gzip
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 51200 // 50KB 以上用 Brotli
    }),
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240, // 10-50KB 用 Gzip
      deleteOriginFile: true // 小文件可以删除原文件
    })
  ]
}

Nginx 配置示例

# nginx.conf
server {
  listen 80;
  server_name example.com;
  root /usr/share/nginx/html;
  
  # 开启 Gzip
  gzip on;
  gzip_vary on;
  gzip_min_length 10240;
  gzip_types text/plain text/css text/xml text/javascript 
             application/javascript application/x-javascript 
             application/xml application/json;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  
  # Brotli 支持(需要编译 brotli 模块)
  brotli on;
  brotli_min_length 10240;
  brotli_types text/plain text/css text/xml text/javascript 
               application/javascript application/x-javascript 
               application/xml application/json;
  brotli_comp_level 6;
  
  location / {
    try_files $uri $uri/ /index.html;
    
    # 尝试 Brotli,然后是 Gzip,最后是原始文件
    location ~* \.(js|css)$ {
      try_files $uri.br $uri.gz $uri =404;
      
      # 根据 Accept-Encoding 设置正确的 Content-Encoding
      if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
        add_header Content-Type $content_type;
      }
      if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
        add_header Content-Type $content_type;
      }
      
      # 长期缓存
      expires 1y;
      add_header Cache-Control "public, immutable";
      add_header Vary Accept-Encoding;
    }
    
    # 图片缓存
    location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
      expires 30d;
      add_header Cache-Control "public";
    }
  }
}

验证压缩效果

# 使用 curl 验证压缩

# 查看是否支持压缩
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js

# 响应头应该包含
Content-Encoding: br
Content-Type: application/javascript
Content-Length: 220000

# 下载并解压验证
curl -H "Accept-Encoding: br" https://example.com/app.js | brotli -d

# 或者使用 httpie
http https://example.com/app.js Accept-Encoding:br

长期缓存策略:让缓存最大化

文件名哈希的原理

// 构建后的文件名
// index.[hash].js

// 哈希是基于文件内容生成的
// 内容不变 → 哈希不变 → 缓存有效
// 内容变化 → 哈希变化 → 重新下载

dist/
├── index.abc123.js    // 哈希基于内容生成
├── index.def456.js    // 内容变化,哈希变化
├── vendor-vue.123abc.js // 第三方库几乎不变
└── vendor-ui.456def.js   // UI 库偶尔更新

配置文件名哈希

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 入口文件
        entryFileNames: 'assets/[name].[hash].js',
        
        // 异步 chunk
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        
        // 资源文件
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks: {
          // 稳定的第三方库单独打包(几乎不变)
          'vendor-stable': [
            'vue',
            'vue-router',
            'pinia',
            'vuex'
          ],
          
          // 可能更新的 UI 库单独打包
          'vendor-ui': [
            'element-plus',
            '@element-plus/icons-vue',
            'ant-design-vue'
          ],
          
          // 可能更新的工具库
          'vendor-utils': [
            'lodash-es',
            'dayjs',
            'axios'
          ]
        }
      }
    },
    
    // 生成 manifest.json
    manifest: true
  }
}

Nginx 缓存配置

# nginx.conf
server {
  # 静态资源缓存配置
  
  # JS/CSS 长期缓存(带 hash 的文件)
  location ~* \.(js|css)$ {
    # 匹配带 hash 的文件
    if ($uri ~* "\.[a-f0-9]{8,20}\.(js|css)$") {
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
    
    # 如果不带 hash,短时间缓存
    expires 1h;
    add_header Cache-Control "public";
    
    # 尝试压缩版本
    try_files $uri.br $uri.gz $uri =404;
    add_header Vary Accept-Encoding;
  }
  
  # 图片等资源
  location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
    expires 30d;
    add_header Cache-Control "public";
  }
  
  # 字体文件
  location ~* \.(woff2?|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";
  }
  
  # HTML 文件不缓存
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
  }
}

Service Worker 缓存策略

// sw.js
const CACHE_NAME = 'v1'
const CACHE_URLS = [
  '/',
  '/index.html',
  '/manifest.json'
]

// 安装时缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CACHE_URLS))
  )
})

// 缓存策略:缓存优先,网络回退
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url)
  
  // 静态资源使用 Cache First 策略
  if (url.pathname.match(/\.(js|css|png|jpg|webp)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          // 缓存命中直接返回
          if (response) return response
          
          // 未命中则请求网络并缓存
          return fetch(event.request).then(response => {
            const clone = response.clone()
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, clone)
            })
            return response
          })
        })
    )
  } 
  // HTML 使用 Network First 策略
  else if (url.pathname.endsWith('.html') || url.pathname === '/') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone()
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, clone)
          })
          return response
        })
        .catch(() => caches.match(event.request))
    )
  }
})

缓存命中率的提升

文件类型 更新频率 缓存策略 命中率
vendor-vue.js 几乎不变 永久缓存 99%
vendor-ui.js 偶尔更新 永久缓存 92%
page-*.js 经常更新 永久缓存 65%
图片 很少更新 30天缓存 95%
字体 从不更新 永久缓存 99%

实战案例:一个中大型项目的构建优化

优化前的状态

// 项目信息
// - 页面数量:45 个
// - 组件数量:850 个
// - 第三方依赖:230 个
// - 图片数量:1200 张

// 构建产物
dist/ 总大小: 45 MB
├── js/      28 MB
├── css/     2.5 MB
├── images/  14 MB
└── others/  0.5 MB

// 性能指标
// - 构建时间:3 分 45 秒
// - 首屏体积:4.2 MB
// - 加载时间:3.2 秒

优化步骤

第一步:分析找出问题

# 运行分析
npx vite-bundle-visualizer

# 发现问题
echarts: 1.2MB        ← 太大
monaco-editor: 2.8MB  ← 巨大!
lodash-es: 210KB      ← 还好
moment: 450KB         ← 可以用 dayjs 替代

第二步:优化拆包

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把 echarts 单独打包
            if (id.includes('echarts')) {
              return 'vendor-echarts'
            }
            
            // 把 monaco-editor 单独打包
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            
            // 其他分组
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            
            return 'vendor-other'
          }
          
          // 按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    }
  }
}

第三步:图片压缩

// vite.config.js
export default {
  plugins: [
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    })
  ]
}

第四步:开启压缩

// vite.config.js
export default {
  plugins: [
    compression({
      algorithm: 'brotliCompress',
      threshold: 10240
    })
  ]
}

第五步:按需加载

// 大组件使用动态导入
const MonacoEditor = defineAsyncComponent(() => 
  import('monaco-editor')
)

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 按需加载
  }
]

优化后的结果

指标 优化前 优化后 提升
构建时间 3 分 45 秒 2 分 20 秒 38%
总大小 45 MB 18 MB 60%
首屏 JS 体积 4.2 MB 1.8 MB 57%
图片体积 14 MB 3.5 MB 75%
传输体积 3.2 MB 0.8 MB 75%
加载时间 3.2 秒 1.1 秒 65%

常见问题与解决方案

问题一:拆包过多导致请求数爆炸

// ❌ 错误:拆得太细
manualChunks(id) {
  // 每个依赖都单独打包
  return id.match(/node_modules\/([^\/]+)/)?.[1]
}
// 结果:产生 200+ 个文件,HTTP/1.1 下性能差

// ✅ 正确:合理分组
manualChunks(id) {
  if (id.includes('node_modules')) {
    if (id.includes('vue')) return 'vendor-vue'
    if (id.includes('lodash')) return 'vendor-utils'
    if (id.includes('echarts')) return 'vendor-charts'
    if (id.includes('monaco')) return 'vendor-monaco'
    return 'vendor-other' // 其他合并
  }
}

问题二:图片压缩后质量下降

// 解决方案:选择性压缩
ViteImageOptimizer({
  // 图标保留较高品质
  'src/assets/icons/**/*': {
    png: { quality: 90 },
    svg: { plugins: ['preset-default'] }
  },
  
  // 背景图可以接受较低品质
  'src/assets/backgrounds/**/*': {
    jpeg: { quality: 65 },
    webp: { quality: 60 }
  },
  
  // 产品图需要平衡
  'src/assets/products/**/*': {
    jpeg: { quality: 80 },
    webp: { quality: 75 }
  }
})

// 或者使用图片 CDN 动态处理
<img src="https://cdn.example.com/image.jpg?x-oss-process=image/resize,w_400/quality,q_80">

问题三:Brotli 压缩太慢

// ✅ 解决方案:选择性使用 Brotli
compression({
  algorithm: 'brotliCompress',
  threshold: 50000,  // 50KB 以上才用 Brotli
  filter: /\.(js|css)$/
})

// 小文件继续用 Gzip
compression({
  algorithm: 'gzip',
  threshold: 10240,  // 10-50KB 用 Gzip
  filter: /\.(js|css)$/
})

问题四:CDN 不支持 Brotli

# ✅ 解决方案:同时生成 Gzip 和 Brotli
location /assets {
    # 优先尝试 Brotli
    try_files $uri.br $uri.gz $uri =404;
    
    # 根据 Accept-Encoding 返回正确的 Content-Encoding
    if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
    }
    if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
    }
}

生产环境优化的最佳实践

优化检查清单

  • 使用 visualizer 分析构建产物
  • 配置 manualChunks 合理拆包
  • 图片资源压缩优化
  • 启用 Gzip/Brotli 压缩
  • 配置长期缓存策略
  • 设置性能预算
  • 在 CI/CD 中集成检查
  • 定期监控 Web Vitals

配置文件模板

// vite.config.ts - 生产环境优化完整配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import compression from 'vite-plugin-compression'

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    
    // 图片压缩
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    }),
    
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240
    }),
    
    // 构建分析(只在需要时开启)
    process.env.ANALYZE && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true
    })
  ].filter(Boolean),
  
  build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: mode === 'production',
        drop_debugger: true
      }
    },
    
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'
            }
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts'
            }
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            return 'vendor-other'
          }
          
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    },
    
    chunkSizeWarningLimit: 500,
    sourcemap: mode !== 'production',
    manifest: true
  }
}))

性能目标参考

指标 优秀 一般
首屏 JS 体积 < 200KB 200-500KB > 500KB
总构建体积 < 2MB 2-5MB > 5MB
图片体积占比 < 30% 30-50% > 50%
压缩率 > 70% 50-70% < 50%
缓存命中率 > 80% 50-80% < 50%
FCP < 1.5s 1.5-2.5s > 2.5s
LCP < 2.5s 2.5-4s > 4s

三个核心原则

  1. 测量优先:没有数据的优化是盲目的
  2. 渐进改进:每次只优化一个指标
  3. 用户优先:始终以用户体验为导向

结语

优化的终极目标是让用户感受不到加载的存在。当用户打开我们的应用时,内容瞬间呈现,交互立即响应,这就说明我们的优化成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

JavaScript 对象操作进阶:从属性描述符到对象创建模式

背景与收益

在实际开发中,我们经常遇到这样的场景:需要批量创建结构相似的对象,或者需要精确控制对象属性的行为(可写、可枚举、可配置等)。如果只用最基础的对象字面量和 Object.defineProperty,代码会变得冗长且难以维护。

本文将带你深入理解:

  • 如何高效地批量定义对象属性及其描述符
  • JavaScript 提供的对象限制方法及其实战应用场景
  • 创建多个同类对象的最佳实践:工厂模式 vs 构造函数

适合已掌握 JavaScript 基础语法、希望提升对象操作能力的开发者。


一、批量定义对象属性

1.1 问题场景

在上一章节中,我们学习了 Object.defineProperty 来定义单个属性的描述符。但实际开发中,一个对象往往有多个属性需要配置。如果每个属性都调用一次 defineProperty,代码会非常冗余:

let obj = { JS: 1 };

Object.defineProperty(obj, 'name', {
  value: 'XiaoWu',
  writable: true,
  enumerable: true,
  configurable: true
});

Object.defineProperty(obj, 'age', {
  value: 18,
  writable: false,
  enumerable: true,
  configurable: true
});

能否通过遍历来优化?当然可以。

1.2 手动实现批量定义

我们可以将多个属性的描述符封装成对象,然后遍历处理:

let obj = {
  JS: 1
};

let props = {
  name: {
    value: 'XiaoWu',
    writable: true,
    enumerable: true,
    configurable: true
  },
  age: {
    value: 18,
    writable: false,
    enumerable: true,
    configurable: true
  }
};

function defineProperties(obj, properties) {
  for (let prop in properties) {
    // hasOwnProperty 用于判断是否为对象自有属性(非继承属性)
    if (properties.hasOwnProperty(prop)) {
      Object.defineProperty(obj, prop, properties[prop]);
    }
  }
  return obj;
}

defineProperties(obj, props);

console.log(obj.name);  // XiaoWu
console.log(obj.age);   // 18

1.3 原生方法:Object.defineProperties

JavaScript 原生提供了 Object.defineProperties 方法,功能与我们手动实现的一致,但处理了更多边界情况:

Object.defineProperties(obj, props);

实战案例:私有属性的访问控制

在实际开发中,我们常用 _ 前缀标识私有属性,并通过 getter/setter 控制访问:

var obj = {
  _age: 20  // 私有属性,存储真实数据
};

Object.defineProperties(obj, {
  name: {
    configurable: true,
    enumerable: true,
    value: "小吴",
    writable: true
  },
  age: {
    configurable: false,
    enumerable: false,  // 不可枚举,for-in 遍历时不会出现
    get: function() {
      return this._age;
    },
    set: function(value) {
      this._age = value;
    }
  }
});

console.log(obj.age);  // 20
console.log(obj);      // { _age: 20, name: '小吴' }  注意:age 不可枚举
obj.age = 18;
console.log(obj.age);  // 18

设计思想

  • _age 是真实数据存储,外部不应直接访问
  • age 是对外暴露的接口,通过 getter/setter 控制访问逻辑
  • 这种"马甲模式"可以在 setter 中加入校验、日志等逻辑,保证数据安全

1.4 对象字面量中的 getter/setter

除了使用 defineProperties,我们也可以直接在对象字面量中定义 getter/setter:

var obj = {
  _age: 20,
  set age(value) {
    this._age = value;
  },
  get age() {
    return this._age;
  }
};

两种写法的差异

写法 控制台输出 精细控制
对象字面量 { _age: 20, age: [Getter/Setter] } 无法配置 configurable/enumerable
defineProperties { _age: 20 } 可精确控制所有描述符

图 1:getter/setter 在终端的表达形式

选择建议

  • 简单场景:直接在对象字面量中定义,代码更简洁
  • 需要精细控制(如设置不可枚举):使用 defineProperties

二、对象方法补充

2.1 获取属性描述符

之前我们提到,[[]] 标记的内部属性无法直接访问,需要通过特定 API 获取:

// 获取单个属性的描述符
Object.getOwnPropertyDescriptor(obj, prop);

// 获取所有自有属性的描述符
Object.getOwnPropertyDescriptors(obj);

示例

var obj = {
  names: "小吴",
  age: 18
};

console.log(Object.getOwnPropertyDescriptor(obj, 'names'));
// { value: '小吴', writable: true, enumerable: true, configurable: true }

console.log(Object.getOwnPropertyDescriptors(obj));
// {
//   names: { value: '小吴', writable: true, enumerable: true, configurable: true },
//   age: { value: 18, writable: true, enumerable: true, configurable: true }
// }

图 2:obj 对象的属性描述符详情

2.2 对象限制方法

JavaScript 提供了三个方法来限制对象的可变性,它们的限制程度逐级递增:

2.2.1 Object.preventExtensions - 禁止扩展

禁止给对象添加新属性,但可以修改和删除现有属性:

var obj = {
  names: "小吴",
  age: 18
};

Object.preventExtensions(obj);
obj.newProperty = 'new';  // 添加失败(严格模式下报错)
console.log(obj.newProperty);  // undefined

2.2.2 Object.seal - 密封对象

preventExtensions 基础上,将所有现有属性的 configurable 设为 false,禁止删除和重新配置属性:

Object.seal(obj);
delete obj.age;  // 删除失败
console.log(obj.age);  // 18
obj.names = "JS高级";  // 可以修改值
console.log(obj.names);  // JS高级

2.2.3 Object.freeze - 冻结对象

seal 基础上,将所有现有属性的 writable 设为 false,完全冻结对象:

Object.freeze(obj);
obj.names = "why";  // 修改失败
console.log(obj.names);  // JS高级

实战应用:Vue 性能优化

在 Vue 中,响应式系统会劫持对象的 getter/setter。如果有大量静态数据(如几十万条配置数据)不需要响应式,可以用 Object.freeze 冻结,避免 Vue 进行响应式处理,显著提升性能:

// 大量静态数据
const staticData = Object.freeze([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  // ... 几十万条
]);

export default {
  data() {
    return {
      list: staticData  // 不会被 Vue 响应式处理
    };
  }
};

三种方法对比

方法 禁止新增 禁止删除 禁止修改值 禁止重新配置
preventExtensions
seal
freeze

三、创建多个对象的方案

3.1 问题场景

假设我们需要创建多个 Person 对象,每个对象都有 name、age、sex、address 等属性,以及 eating、running 等方法。如果用对象字面量:

var p1 = {
  name: "小吴",
  age: 20,
  sex: "男",
  address: "福建",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

var p2 = {
  name: "why",
  age: 35,
  sex: "男",
  address: "广州",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

问题:代码重复率极高,难以维护。

解决方案

  1. 工厂模式
  2. 构造函数
  3. ES6 Class(后续章节)
  4. 原型 + Object.create(后续章节)

本文重点讲解前两种。

3.2 方案一:工厂模式

3.2.1 基本实现

工厂模式的核心思想:抽离共性,参数化差异,流水线生产

function createPerson(name, age, sex, occupation, address) {
  var p = new Object();
  p.name = name;
  p.age = age;
  p.sex = sex;
  p.occupation = occupation;
  p.address = address;
  p.eating = function() {
    console.log(this.name + "在吃满汉全席");
  };
  return p;
}

var p1 = createPerson("小吴", 20, "男", "大三学生", "福建");
var p2 = createPerson("why", 35, "男", "全栈工程师兼教师", "广州");

console.log(p1, p2);

图 3:new 调用所产生的结构共性

3.2.2 工厂模式的缺点

  1. 类型信息丢失:所有对象的类型都是 Object,无法区分是 Person 还是其他类型
  2. 无法利用原型链:每个对象都有自己的方法副本,无法共享,浪费内存
  3. 调试困难:堆栈跟踪中难以定位对象的创建源
console.log(p1);  // Object { name: '小吴', age: 20, ... }
// 无法看出这是一个 Person 对象

适用场景

  • 简单的对象创建,不需要类型区分
  • 临时性的数据结构封装

3.3 方案二:构造函数

3.3.1 什么是构造函数

构造函数本质上是普通函数,但通过 new 关键字调用时,会执行特殊的对象创建流程:

function foo() {
  console.log("foo~");
}

// 普通调用
foo();

// 构造函数调用
new foo();  // 或 new foo

3.3.2 new 操作符的执行流程

当使用 new 调用函数时,会自动执行以下步骤:

  1. 在内存中创建一个新的空对象
  2. 将这个对象的 [[Prototype]] 指向构造函数的 prototype 属性
  3. 将构造函数内部的 this 指向这个新对象
  4. 执行构造函数的代码(给 this 添加属性)
  5. 如果构造函数返回一个对象,则返回该对象;否则返回步骤 1 创建的对象
function foo() {
  // 内部隐式执行:
  // var obj = {};
  // this = obj;
  console.log("foo~");
  // 隐式返回 this
}

var f1 = new foo();  // foo~
console.log(f1);     // foo {}

类型验证

function XiaoWu(name) {
  this.name = name;
  console.log("我是小吴");
}

var f1 = new XiaoWu("小吴");  // 我是小吴
console.log(f1);  // XiaoWu { name: '小吴' }
console.log(f1.__proto__.constructor.name);  // XiaoWu

3.3.3 构造函数实现

function Person(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new Person("小吴同学", 20, "男", "福建");
console.log(f1);
// Person {
//   name: '小吴同学',
//   age: 20,
//   sex: '男',
//   address: '福建',
//   eating: [Function (anonymous)],
//   running: [Function (anonymous)]
// }

var f2 = new Person("小满zs", 23, "男", "北京");
var f3 = new Person("洛洛", 20, "萌妹子", "福建");

图 4:构造函数 Person 调用结果

3.3.4 如何识别构造函数

构造函数与普通函数在语法上没有区别,社区约定了以下规范:

  1. 命名规范:首字母大写,使用大驼峰命名(PascalCase)
  2. 编辑器提示:当函数内使用 this 赋值时,编辑器会提示"此构造函数可能会转换为类声明"
function XiaoWu(name) {
  this.name = name;  // 使用 this 赋值,编辑器识别为构造函数
}

图 5:如何区分是否为构造函数(编辑器中的构造函数)

注意:只有通过 new 调用时,函数才真正成为构造函数。

3.3.5 构造函数的缺点

每次创建对象时,方法都会被重新创建,导致内存浪费:

function foo() {
  function bar() {
    console.log("你猜一不一样");
  }
  return bar;
}

var f1 = foo();
var f2 = foo();
console.log(f1 === f2);  // false  每次调用都创建新的函数对象

应用到构造函数

function XiaoWu(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  // 每次 new 都会创建新的函数对象
  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new XiaoWu("小吴同学", 20, "男", "福建");
var f2 = new XiaoWu("小吴同学", 20, "男", "福建");

console.log(f1.eating === f2.eating);  // false
console.log(f1.running === f2.running);  // false

问题分析

  • 虽然 f1f2eating 方法功能完全相同,但它们是两个不同的函数对象
  • 当创建大量实例时,会造成内存浪费

解决方案:使用原型(Prototype),将方法定义在原型上,所有实例共享。这将在下一章节详细讲解。


四、工厂模式 vs 构造函数

对比维度 工厂模式 构造函数
调用方式 普通函数调用 使用 new 关键字
类型识别 所有对象都是 Object 可以识别具体类型(如 Person
原型链 无法利用 可以利用原型共享方法
内存占用 每个对象独立方法 每个对象独立方法(未优化时)
代码复杂度 简单直观 需要理解 newthis
适用场景 简单对象创建 需要类型区分和原型链的场景

选择建议

  • 简单场景、不需要类型区分:工厂模式
  • 需要类型识别、后续会用到原型链:构造函数
  • 现代开发:优先使用 ES6 Class(本质是构造函数的语法糖)

五、实战建议

5.1 属性描述符使用场景

  1. 配置对象保护:将配置对象冻结,防止被意外修改
  2. 私有属性模拟:通过不可枚举 + getter/setter 实现访问控制
  3. 数据校验:在 setter 中加入校验逻辑

5.2 对象创建模式选择

  1. 单个对象:对象字面量
  2. 少量同类对象:工厂模式或构造函数
  3. 大量同类对象:构造函数 + 原型(下一章)
  4. 现代项目:ES6 Class

5.3 性能优化要点

  1. 避免在构造函数中定义方法:应该定义在原型上(下一章详解)
  2. 大量静态数据使用 Object.freeze:特别是在 Vue 等响应式框架中
  3. 合理使用属性描述符:不要过度使用,会增加代码复杂度

六、总结与下一步

6.1 核心要点

  1. Object.defineProperties 可以批量定义属性描述符,比多次调用 defineProperty 更高效
  2. preventExtensionssealfreeze 三个方法提供了不同级别的对象保护
  3. 工厂模式简单直观,但无法识别对象类型
  4. 构造函数通过 new 调用,可以创建具有特定类型的对象
  5. 构造函数的缺点是方法无法共享,需要通过原型解决

6.2 遗留问题

在本文中,我们多次提到"原型"(Prototype),并且发现构造函数存在方法无法共享的问题。在控制台查看对象时,总能看到神秘的 [[Prototype]] 属性:

图 6:对象中的原型世界

6.3 下一章预告

在下一章节中,我们将深入学习:

  • 什么是原型(Prototype)和原型链
  • 如何通过原型实现方法共享,解决构造函数的内存浪费问题
  • 原型链的查找机制和继承原理
  • 大量内存图帮助理解原型的指向关系

原型是 JavaScript 中最重要的概念之一,理解原型是掌握 JavaScript 面向对象编程的关键。


从进程线程到 async/await,一文吃透前端异步核心原理

事件循环(Event Loop)是 JavaScript 实现单线程非阻塞异步执行的核心机制,也是浏览器与 Node.js 环境中,JS 代码能够有序执行、处理异步任务(网络请求、定时器、DOM 事件等)的底层逻辑。

本文将从进程、线程的基础概念出发,逐步拆解浏览器渲染机制、V8 引擎单线程模型、Event Loop 事件循环,最终落地到 async/await 的原理与实践,帮助前端开发者建立完整的异步编程知识体系。

一、进程与线程:浏览器的底层基石

1. 基础概念

  • 进程:进程就是操作系统中正在运行的一个程序实例,是CPU 运行指令时保存和加载上下文所需的时间与资源集合,是操作系统资源分配的最小单位
  • 线程:CPU 执行具体指令所需的最小单位,依附于进程存在,一个进程可以包含多个线程。

2. 浏览器中的进程与线程

我们日常使用浏览器多开 Tab 页,本质上就是为每个 Tab 单独创建一个进程,这样做的好处是:

  • 单个 Tab 崩溃不会影响整个浏览器
  • 资源隔离更安全,避免恶意页面窃取其他页面数据

而在每个进程内部,又包含多个关键线程:

  1. 渲染线程:负责页面的 HTML、CSS 解析与布局绘制
  2. JS 引擎线程:负责解析和执行 JavaScript 代码
  3. HTTP 请求线程:处理网络请求(如 Ajax、Fetch
  4. 事件触发线程、定时器线程等

⚠️ 核心限制:由于 JavaScript 可以直接操作 DOM,为了避免 DOM 渲染冲突,渲染线程与 JS 引擎线程必须互斥,不能同时工作。这也是 JS 执行会阻塞页面渲染的根本原因。

二、V8 引擎:单线程与异步的诞生

V8是Chrome和Node.js所使用的JS引擎,它在执行JS代码时默认只开一个线程

正是这种单线程特性,催生了JS的异步编程模式

  • 遇到同步任务:直接执行。
  • 遇到异步任务:先挂起,存入任务队列,等待同步任务执行完毕后再执行异步任务。

这种“先同步,后异步”的执行流程,就是我们常说的事件循环的基础。

三、Event Loop:微任务与宏任务

1. 任务分类

在异步任务中,又分微任务与宏任务。

微任务:指在异步任务中耗时更短的任务,优先级更高,会在当前同步代码执行完毕后立即执行

常见的微任务有:

  • Promise.then()
  • process.nextTick() (Node.js 环境)
  • MutationObserver (浏览器环境)

宏任务:指在异步任务中耗时更长的任务,优先级较低,会在微任务全部清空后才会执行

常见的宏任务有:

  • 全局 script 代码
  • setTimeout() / setInterval()
  • AJAX请求、I/O 操作
  • UI 渲染(UI-rendering

2. 完整执行顺序

事件循环机制的执行流程可以总结为 4 步:

  1. 先执行同步代码,执行过程中遇到异步任务,将其存入对应的任务队列,微任务存入微任务队列,宏任务存入宏任务队列
  2. 同步代码执行完毕后,立即执行微任务队列中的所有任务
  3. 微任务全部执行结束后,如有需要则执行页面渲染
  4. 渲染完成后,执行宏任务队列中的任务

这个循环会一直持续,直到所有任务都被处理完毕。

四、async/await

async/await 是 ES2017 引入的语法,本质是 Promise 的替代,让异步代码看起来更像同步代码。

核心规则

  • async:函数前加 async,修饰函数(函数声明 / 表达式 / 箭头函数),表示这是一个异步函数,等价于函数内部自动返回了一个 Promise 实例对象。

    • 异步函数的返回值会被自动包装成 Promise(即使你返回普通值,也会变成 Promise.resolve(值))。
    • 如果函数内部抛出错误,返回的 Promise 会变成 rejected 状态。
  • await:必须配合 async 使用,只能在 async 函数内部使用,作用是等待一个 Promise 完成(resolve/reject),如果 await 后面不是 Promise 对象,它就无法 “等待” 该操作完成。

    • 等待期间,JS 引擎会暂停当前 async 函数的执行,去执行其他代码(不会阻塞主线程)。
    • Promise 完成后,await 会返回 Promise 的 resolve 值;如果 Promise 被拒绝(reject),会抛出错误,需要用 try/catch 捕获。
    • await fn() 会把 fn() 当作同步代码看待,并将 await 之后的代码加入到微任务队列中,等待当前同步代码和微任务执行完毕后再执行

代码示例1

// async/await 基础用法 
async function asyncDemo() { 
  console.log('1. async 函数内同步代码'); 
  const res = await Promise.resolve('await 结果'); 
  console.log('3. await 之后的代码(微任务)'); 
  console.log('res:', res);
} 

console.log('0. 全局同步代码'); 
asyncDemo(); 
console.log('2. 全局同步代码结束');

//输出结果:
//0. 全局同步代码
//1. async 函数内同步代码
//2. 全局同步代码结束
//3. await 之后的代码(微任务)
//res: await 结果

上述代码示例表明:async 函数内的同步代码会立即执行,await 之后的代码会被放入微任务队列。

代码示例2

// async/await 处理异步请求 
async function fetchData() {
    try {
        console.log('开始请求数据');
        // 模拟网络请求 
        const response = await new Promise(resolve => {
            setTimeout(() => {
                resolve({ data: '用户信息' });
            }, 1000);
        });
        console.log('请求成功:', response.data);
        return response.data;
    } catch (err) {
        console.error('请求失败:', err);
    }
} 
fetchData().then(data => {
    console.log('最终处理数据:', data);
});
console.log('同步代码继续执行');

运行结果:

image.png

可以看到,async/await 让异步代码的写法和同步代码几乎一致,可读性大大提升

五、总结

从进程、线程到 async/await,我们可以了解:

  1. 浏览器是多进程多线程架构,每个 Tab 是一个独立进程,内部包含渲染线程、JS 引擎线程等
  2. V8 引擎是单线程执行 JS,因此诞生了异步编程模型
  3. Event Loop 是 JS 异步的核心,通过同步代码优先异步代码,微任务优先于宏任务的执行顺序,保证了异步代码的有序执行
  4. async/await

理解了这些底层原理,有助于我们更好了解JavaScript中的异步编程,实现更复杂高效的功能。

前端空值处理规范:Vue 实战避坑,可选链、?? 兜底写法|项目规范篇

帮助同学们学会在前端真实业务项目里,到底该怎么写空值处理(?.、??、||、if判断、兜底逻辑),以及为什么这么选、会踩哪些高频坑,顺便帮你拉直JS/TS空值、真值假值的基础概念,助力写出规范可维护的团队级代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

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

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

引子:为什么要专门聊“空值处理规范”?

一句话定位这篇文章:

教你在真实项目里,到底该怎么写空值处理( ?. ?? || if ** 判断、兜底逻辑),以及为什么这么选、会踩哪些坑**,顺便帮你把 JS/TS 的一些基础概念拉直。

适用人群:

  • 已经会写 JS / Vue,但概念有点混== null||?.?? 到底差在哪?

  • 刚入门前端的小伙伴:想从一开始就养成靠谱的代码习惯

  • 像我这样工作多年想回炉重造的工程师:系统校准一下“老习惯”是不是已经过时了 本文不会讲太多过度底层的规范条文,而是:

  • 围绕真实业务代码的写法

  • 配合 完整示例 + 场景解释

  • 重点放在:怎么选写法、为什么这么选、常见坑在哪里

一、先把“空值家族”讲清楚:null、undefined、空字符串、0、false…

日常开发中经常混在一起的几个值:


null           // 明确的“空值”,一般表示“这里有个位置,但现在没有值”
undefined      // 未定义,通常是“压根没传”、“没赋值”
''             // 空字符串
0              // 数字 0
false          // 布尔 false
NaN            // 不是一个合法数字

1.1 “真值/假值”概念(很关键)

在 JS 里,if (xxx) 判断的是“真值/假值(truthy / falsy)”,而不是严格意义上的 true/false。下面这些都是 falsy(假)

  • false
  • 0
  • -0
  • ''(空字符串)
  • null
  • undefined
  • NaN

其他的基本都被当成 truthy(真)

为什么要先讲这个?

因为 ||&& 这些逻辑运算符,走的就是“真值/假值”逻辑。

比如:


const value = 0;
const result = value || 100;
console.log(result); // 100,而不是 0

0 在 JS 里是假值,所以 value || 100 会拿到 100

这也是我们后面会反复提的一个大坑:“用 ** || ** 做默认值会把合法值 0/''/false 当成没传”

二、可选链 ?.:安全访问深层属性的标准写法

场景:从后端拿到一个复杂对象,但某一层可能是 null / undefined,直接访问就会炸:


// 假设 user 可能是 null
const city = user.profile.address.city; 
// TypeError: Cannot read properties of null (reading 'profile')

2.1 传统写法 VS 可选链

传统写法(防御式编程):


const city =
  user &&
  user.profile &&
  user.profile.address &&
  user.profile.address.city;

  • 可读性差
  • 很啰嗦
  • 稍微一改结构就容易漏一个判断

可选链写法:


const city = user?.profile?.address?.city;

  • 短很多
  • 语义清晰:如果中间任何一层是 null/undefined,就直接返回 undefined,而不是抛异常

2.2 在 Vue 模板里的使用

Vue 2 + Babel 环境Vue 3 默认 Vite 脚手架 一般都支持可选链。

在模板里:


<template>
  <div>
    <p>用户名:{{ user?.profile?.name || '未设置' }}</p>
    <p>城市:{{ user?.profile?.address?.city || '未知城市' }}</p>
  </div>
</template>

<script setup>
const user = ref(null);
// 后端请求完成后,再赋值
</script>

注意:模板表达式里也可以用 ?.||??,和 JS 里一样。

2.3 规范建议:何时必须用可选链?

我在项目里通常建议:

  • 从接口拿来的数据 + 多层嵌套对象默认用可选链
  • SDK / 第三方库返回的结构:尽量用可选链保护
  • 对于我们自己完全可控、结构固定的内部数据,可以不用(比如本地写死的配置)

统一规则示例:

  • 接口 Model 层(TypeScript 类型 + 接口封装):尽量把可选属性处理掉,往下传固定结构
  • 页面 / 组件层
    • 对于“接口原始数据”:用 ?. + 兜底字符串 / 兜底组件
    • 对于“内部状态”:减少可选,用默认值初始化

三、空值合并运算符 ??:给“真空”兜底,而不是给所有假值兜底

回顾刚才的例子:


const value = 0;
const result = value || 100;
console.log(result); // 100

如果 0 在业务里是合法值(比如“价格 0 元”、“数量 0 个”),那上面这行其实是错的。

我们想要的是:“只有在值为 null 或 undefined 的时候才给默认值”。

这就是 ?? 的作用。

3.1 || vs ?? 对比示例


console.log(0 || 100);       // 100
console.log(0 ?? 100);       // 0

console.log('' || '默认');   // '默认'
console.log('' ?? '默认');   // ''

console.log(null || '默认'); // '默认'
console.log(null ?? '默认'); // '默认'

console.log(undefined || '默认'); // '默认'
console.log(undefined ?? '默认'); // '默认'

总结一句话:

  • ||:只要左边是假值(包括 0 / '' / false / NaN / null / undefined),就用右边
  • ??:只有左边是 nullundefined 时,才用右边

3.2 在真实业务中的推荐用法

典型错误写法(很常见):


// 单价和数量来自接口
const price = item.price || 0;
const count = item.count || 1;
const total = price * count;

在这些场景会出错:

  • 价格为 0 元:price 会变成 0 || 0 → 0(这里还好)
  • 数量为 0:count 会变成 1(业务错了)
  • 用户输入了空字符串 '' 需要区分,但被直接当成没填

推荐写法:


const price = item.price ?? 0;  // 价格缺失才用 0
const count = item.count ?? 1;  // 只有未传 count 才默认 1

再比如配置项对象


function createDialog(options = {}) {
  const width = options.width ?? 400;         // 未传 width 才采用默认 400
  const closable = options.closable ?? true;  // 未传 closable 才用 true
}

3.3 在 Vue 模板中用 ??


<template>
  <div>
    <!-- 后端没给 nickName 时显示 '游客',但如果是空字符串就保持空 -->
    <p>昵称:{{ user.nickName ?? '游客' }}</p>
  </div>
</template>

规范建议:

  • 只要你的兜底逻辑只想针对 null/undefined,统一用 ??,不要用 ||
  • 保留 || 用于“逻辑或”场景,而不是“兜底默认值”。

四、兜底逻辑:不仅是运算符,还有“业务上的安全网”

可选链和空值合并属于“语法层面的防御”。

真实项目里,还需要“业务层面的兜底”,比如:

  • 数据为 null 时显示一个“空态组件”
  • 钱包余额为 null 时,不显示数字而是展示“--”
  • 列表为空时展示“暂无数据”

4.1 文本兜底:别让页面渲染出 undefined / null

错误示例:


<template>
  <div>
    <!-- 假设 user.name 可能 undefined -->
    <p>用户名:{{ user.name }}</p>
  </div>
</template>

页面可能出现:


<p>用户名:undefined</p>

推荐写法:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
  </div>
</template>

如果你更谨慎一点,还可以抽成一个小工具函数或指令:


function displayText(value, fallback = '--') {
  if (value === null || value === undefined) return fallback;
  return String(value);
}

模板中:


<p>用户名:{{ displayText(user?.name, '未设置') }}</p>

4.2 数字兜底:0、null、undefined 要区分

常见场景:金额 / 数量 / 积分


<template>
  <div>
    <!-- 如果 amount 为 0,要显示 0 元,而不是 “--” -->
    <p>金额:{{ formatAmount(order?.amount) }}</p>
  </div>
</template>

<script setup>
function formatAmount(value) {
  if (value === null || value === undefined) return '--'; // 真空
  const num = Number(value);
  if (Number.isNaN(num)) return '--';                     // 非法数字
  return num.toFixed(2) + ' 元';
}
</script>

这里的思路是:

  • 对于“真空”(null/undefined)和“非法值”(NaN),直接兜底成 --
  • 对于合法的 0、10.5 等,按正常格式化逻辑展示

4.3 列表兜底:空数组 vs null/undefined

错误写法:


<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script setup>
const list = ref(null);
</script>

list 为 null 时,Vue 其实不会崩溃,但可读性很差,而且 TypeScript 下会疯狂报错。

推荐规范:

  • 列表类型的数据,初始化为 [],不要初始化为 null

  • 接口响应里如果是 null在数据层统一转成 [],不要把“既可以是数组又可以是 null”的结构传到视图层


// 假设后端可能返回 { list: null }
interface ApiResponse<T> {
  list: T[] | null;
}

async function fetchUsers(): Promise<User[]> {
  const res: ApiResponse<User> = await request('/api/users');
  return res.list ?? [];
}

Vue 组件里直接:


const users = ref<User[]>([]);

onMounted(async () => {
  users.value = await fetchUsers(); // 一定是数组
});

好处:

  • 模板里 v-for="user in users" 不用可选判断
  • 业务逻辑中也不用 if (!users) 乱判
  • 类型更干净,TS 也容易推断

五、可读性 vs 防御性:别让“防空代码”毁了代码结构

经常看到这样的代码:


if (user && user.profile && user.profile.address && user.profile.address.city) {
  showCity(user.profile.address.city);
} else {
  showDefaultCity();
}

可读性非常差。我们可以结合 ?. 和业务逻辑重写:

5.1 利用中间变量提高可读性


const city = user?.profile?.address?.city;

if (city) {
  showCity(city);
} else {
  showDefaultCity();
}

如果业务含义更复杂,比如:

  • city 为空字符串也视为没填

可以:


const rawCity = user?.profile?.address?.city;
const city = rawCity?.trim(); // string 或 undefined

if (!city) {
  showDefaultCity();
} else {
  showCity(city);
}

规范建议:

  • 不要在 if (...) 里面写一大串可选链,可以先提取出来
  • 对于复杂逻辑(例如 if (a && b && c && d)),考虑拆成几个语义明确的变量

六、项目中推荐的“空值处理规范(示例版)”

以下是一份可直接落地到团队规范里的示例,你可以根据团队实际情况调整。

6.1 基础规则

  • 规则 1:接口层统一做“空值归一化”
    • 列表字段:null / undefined 统一转成 []
    • 数字字段:null / undefined 转成约定好的业务默认(如 0),或者保持 null,但要有清晰设计文档
    • 字符串字段:如果是必展示项,可以转 '',或保留 null,但组件层要有兜底文案
  • 规则 2:组件 / 页面层永远不要直接信任后端
    • 访问深层属性一律用 ?.
    • 模板输出中不要让 null / undefined 直接裸露
  • 规则 3:兜底默认值尽量用 ??,而不是 ||
    • 只有当你有意要把 0 / '' / false 也视为“空”时,才可以用 ||

6.2 风格对比示例(推荐 vs 不推荐)

不推荐:


// 1. 访问深层属性不做保护
const city = user.profile.address.city;

// 2. 用 || 做默认值
const price = item.price || 0;
const count = item.count || 1;

// 3. 列表用 null 表示“还没加载”
const list = ref(null);

推荐:


// 1. 使用可选链保护
const city = user?.profile?.address?.city;

// 2. 用 ?? 严格处理 null/undefined
const price = item.price ?? 0;
const count = item.count ?? 1;

// 3. 列表统一用 [] 作为初始值
const list = ref([]);

在 Vue 模板中的统一写法示例:


<template>
  <div>
    <p>用户名:{{ user?.name ?? '未设置' }}</p>
    <p>年龄:{{ user?.age ?? '--' }}</p>

    <p>余额:{{ formatAmount(account?.balance) }}</p>

    <ul v-if="orders.length">
      <li v-for="order in orders" :key="order.id">
        订单号:{{ order.id }},金额:{{ formatAmount(order.amount) }}
      </li>
    </ul>
    <p v-else>暂无订单</p>
  </div>
</template>

<script setup>
const user = ref(null);
const account = ref(null);
const orders = ref([]); // 一定是数组

function formatAmount(value) {
  if (value === null || value === undefined) return '--';
  const num = Number(value);
  if (Number.isNaN(num)) return '--';
  return num.toFixed(2) + ' 元';
}
</script>

七、常见踩坑案例拆解

7.1 “把 0 当成没填”——报表类页面的大坑

需求:展示一个指标的环比增长率,后端字段 growthRate,可能是:

  • 0:说明没涨没跌
  • 正数:增长
  • 负数:下降
  • null:没有数据

错误写法:


<p>环比:{{ growthRate || '--' }}%</p>

growthRate = 0 时,会显示 --%,业务含义严重错误。

正确写法:


<p>环比:{{ growthRate ?? '--' }}{{ growthRate === null || growthRate === undefined ? '' : '%' }}</p>

或者包装一下:


function displayPercent(value) {
  if (value === null || value === undefined) return '--';
  return `${value}%`;
}

模板:


<p>环比:{{ displayPercent(growthRate) }}</p>

7.2 “深层属性访问炸页面”——常见于接口变更

场景:后端有一天把 user.profile 改成 user.info,但你代码里到处是:


user.profile.address.city

迁移时推荐策略:

  1. 先统一加可选链防御(短期止血):

const city = user?.profile?.address?.city;

  1. 在“数据适配层”做映射,避免在视图层直接跟后端结构硬绑定:

interface UserViewModel {
  city?: string;
  // ...
}

function mapUserDtoToViewModel(dto: any): UserViewModel {
  const profile = dto.profile || dto.info || {};
  return {
    city: profile.address?.city,
    // ...
  };
}
  1. 视图层只用 viewModel.city,再配合兜底:

<p>城市:{{ user.city ?? '未知城市' }}</p>

这样即使后端再改结构,你只需要改映射函数,不会到处是 ?. 打补丁。

八、结合 TypeScript:从“到处防空”升级为“类型上减少空值”

如果你的项目已经用 TypeScript,可以进一步 把“空值问题”提前到类型设计阶段解决

8.1 接口类型:把“可选”缩到最小

错误示例(很多后端生成工具会这样):


interface UserDto {
  id?: number;
  name?: string;
  age?: number | null;
  address?: {
    city?: string;
  } | null;
}

视图层到处是:


user?.address?.city ?? '未知城市'

更好的做法是:

  • 在“接口模型”层承认这些都是可选

  • 但在往页面传的时候,通过构造 ViewModel 把这些变成“非可选 + 有默认值”


interface UserViewModel {
  id: number;
  name: string;
  age: number | null;   // 业务上允许为 null
  city: string;         // 至少有兜底
}

function toUserViewModel(dto: UserDto): UserViewModel {
  return {
    id: dto.id ?? 0,                         // 或抛错,看业务
    name: dto.name ?? '未命名用户',
    age: dto.age ?? null,
    city: dto.address?.city ?? '未知城市',
  };
}

组件里就可以大胆用:


<p>用户名:{{ user.name }}</p>
<p>城市:{{ user.city }}</p>

而不是到处防空。

九、落地建议:如何在现有项目里逐步推行这套规范?

9.1 从“新代码”开始做对

  • 自己写的新组件、新方法,从一开始就用 ?.??
  • 审 PR 的时候,对用 || 做默认值的地方特别敏感,看清楚是否需要保留 0/''/false

9.2 为高风险页面补一层“空值巡检”

优先排查:

  • 面向 C 端用户的关键页面(订单、支付、结算)
  • 报表、数据面板类页面(数字特别多)

从这些点切入:

  • 所有深层属性访问,加上可选链或前置的空值判断
  • 所有数值展示,考虑是否需要 formatXXX 方法来统一兜底逻辑
  • 所有默认值逻辑,检查 || 能否替换为 ??

9.3 写到团队规范 / README / Contributing 里

可以直接摘抄下面一段到你们项目的规范文档里:

空值处理规范(摘要)

  1. 从接口拿到的原始数据,访问深层属性一律使用可选链 ?.
  2. 兜底默认值优先使用空值合并运算符 ??,只有在需要把 0 / '' / false 也当成“空”的场景才使用 ||
  3. 列表数据初始化为 [],不要用 null 表示“尚未加载”。接口返回 null 时在数据适配层统一转为 []
  4. 数字和金额展示需通过统一的格式化方法处理,避免页面出现 NaNundefined
  5. 模板中禁止直接输出可能为 null / undefined 的字段,必须有兜底显示(如 '--''未设置' 等)。

十、总结:把“空值处理”当成一个硬规范,而不是临时脑补

  • 可选链 ?.:用来安全访问深层属性,防止“Cannot read properties of undefined” 直接把页面干崩。
  • 空值合并 ??:只在 null / undefined 时兜底,避免误伤合法的 0 / '' / false
  • 兜底逻辑:不仅是语法问题,更是业务体验和数据安全网的问题,最好沉淀为项目级规范,而不是随手一写。

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

前端代码注释规范:Vue 实战避坑,让 3 年后的自己还能看懂代码|项目规范篇

一套真正能落地的前端代码注释规范,从 Vue 项目实战出发,告诉你注释该写什么、不该写什么,避开常见坑点,写出让 3 年后的自己还能看懂的可维护代码。

在这里插入图片描述

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

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

很多前端开发者都会遇到一个瓶颈:

代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。

想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验

这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。

帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。

前言:为什么要认真对待“写注释”这件小事?

你有没有遇到过这些场景:

  • 半年前自己写的业务,今天改个小需求,打开文件之后第一反应:“这谁写的垃圾代码?”,再一看作者:是自己。

  • 接手别人老项目,逻辑绕来绕去,偶尔看到一行注释:// TODO// 这里有点问题,先这么写……然后就没有然后了。

  • 为了“规范”,团队强行要求每个函数、每个变量都加注释,结果:注释和代码一起过期,甚至误导后来的人。

这篇文章就想解决一个现实问题:

日常写代码时,注释到底该怎么写?为什么这么写?坑会踩在哪?

目标是:让 3 年后的自己和队友,打开代码就能快速搞懂上下文,而不是骂人。

本文不是讲晦涩的底层原理,而是站在一线开发、项目规范的视角,用 Vue / 前端开发场景来聊聊“代码注释规范”。

一、第一原则:好代码胜过好注释,但没有注释也不一定是好代码

1.1 一句话核心原则

能用清晰的命名和结构表达含义,就不要用注释补课。注释只做代码无法表达的“额外信息”。

很多团队会陷入两个极端:

  • 极端 1:注释洁癖“好的代码不需要注释”,结果写一堆晦涩难懂的缩写变量,没人看得懂。

  • 极端 2:注释狂魔几乎每一行都要注释:

    
    // 声明一个变量 a
    let a = 1;
    // a 加 1
    a++;
    
    

    这种注释只会浪费时间、增加维护成本。

正确姿势:

  • 优先改代码,让代码本身更清晰(变量名、函数名、拆分方法、抽象组件……)

  • 其次用注释补充“代码表达不到的信息”,例如:

    • 为什么要这么写(业务背景 / 历史原因 / 兼容性)
    • 注意事项(性能、边界条件、已知坑)
    • 和其他模块的约定(接口协议、调用顺序)

二、注释的四大黄金场景:该写什么?

下面是我在项目里常用、非常推荐的四类注释场景。

2.1 解释“为什么这么写”(Why),而不是“代码在干嘛”(What)

What 代码自己能看出来,Why 只能靠你写出来。

❌ 错误示例:只是重复代码


// 获取用户列表
const users = await fetchUsers();

  • 这行注释几乎就是在重复变量名,没有信息增量

✅ 推荐示例:解释设计/业务原因


// 这里不能直接用缓存的用户列表:
// 1. 用户状态(在线/离线)是实时的
// 2. 后端会根据当前登录态过滤可见用户
// 所以每次都强制请求最新数据
const users = await fetchUsers({ forceRefresh: true });

这里的注释说明了为什么不能优化成缓存,以后有人想“优化性能”时,看到注释就会收手,避免踩坑。

2.2 标记“约定”和“前置条件”:别人需要遵守什么?

在 Vue 组件、工具函数、API 调用中,最容易出问题的往往不是“实现细节”,而是使用前提

  • 参数有没有默认值?
  • 有哪些边界情况?
  • 调用顺序有没有依赖?

✅ Vue 组件示例:在 props / emits 上写注释


// UserForm.vue <script setup lang="ts">
interface Props {
  /**
   * 表单模式:
   * - 'create':新建用户,所有字段可编辑
   * - 'edit':编辑用户,用户名不可修改
   * - 'readonly':只读模式,所有字段禁用
   */
  mode: 'create' | 'edit' | 'readonly';

  /**
   * 编辑/只读模式下必传:
   * 后端返回的完整用户信息。
   * create 模式下可以不传(内部会使用默认值)
   */
  user?: User;
}

const props = defineProps<Props>();

/**
 * 表单提交事件:
 * - create: 提交的 user.id 由后端生成
 * - edit: 必须包含原有的 user.id
 */
const emit = defineEmits<{
  (e: 'submit', payload: User): void;
}>();

这里注释的作用非常明确:

  • 告诉你 mode 不同模式的差别
  • 告诉你 user 在什么模式下是必传的
  • 告诉你 submit 的 payload 长什么样

重点:这类注释是“契约”的一部分,写在类型(interface / props / emits)附近最合适。

2.3 记录“历史遗留”和“坑点说明”:这块代码为什么这么丑?

有些代码你也知道写得不优雅,但短期内又不能重构,比如:

  • 老接口的奇怪字段命名
  • 历史版本遗留的时间格式
  • 奇怪的兼容写法(低版本浏览器 / 特定设备)

与其未来被队友(或自己)怒喷:

“这谁写的?怎么这么鬼畜?”

不如提前写清楚原因。

✅ 示例:兼容老接口


/**
 * 注意:后端这个接口是老系统保留的,字段命名非常诡异。
 * - 'usr_nm' 对应用户姓名
 * - 'crt_tm' 是创建时间字符串,格式为 'YYYY/MM/DD HH:mm:ss'
 * 暂时不能动这个接口,只在这里统一做一次映射。
 */
function normalizeLegacyUser(raw: any): User {
  return {
    id: raw.id,
    name: raw.usr_nm,
    createdAt: dayjs(raw.crt_tm, 'YYYY/MM/DD HH:mm:ss').toDate(),
  };
}

以后谁要改这个接口时,看到注释就会明白:

  • 这是历史债务,不是你写代码水。
  • 如果要改,要 连后端 / 老系统一并考虑

2.4 对复杂算法 / 业务流程做“概览说明”:给后人一张思维导图

有些模块就算代码写得再优雅,逻辑本身就是复杂的

  • 多步骤审批流
  • 复杂的优惠券 / 价格计算规则
  • 权限控制(菜单 + 按钮 + 数据权限)

这种时候,不要指望“代码自解释”,加一段流程性注释是对所有人的救赎。

✅ 示例:订单价格计算(假设你在 calculateOrderPrice.ts 里)


/**
 * 订单价格计算规则(简化版):
 *
 * 1. 基础金额 = 所有商品单价 * 数量 之和
 * 2. 商品级优惠:
 *    - 满减券:优先按商品分类应用,不能跨分类凑单
 *    - 折扣券:在满减之后应用,最多 2 张
 * 3. 订单级优惠:
 *    - 平台券:在所有商品级优惠之后应用
 *    - 封顶逻辑:总优惠金额不能超过基础金额的 30%
 * 4. 运费:
 *    - 满 99 元包邮
 *    - 其他情况按地区和重量计算
 *
 * 注意:
 * - 所有金额都用「分」为单位在内部计算,避免浮点误差
 * - 对外展示时再转换为「元」
 */
export function calculateOrderPrice(order: Order): OrderPriceDetail {
  // 具体实现略
}

这里注释的价值在于:

  • 给出了整体流程(按步骤)
  • 标明了关键约束(封顶 30%、单位是“分”)
  • 以后别人改逻辑时,有一个可以“对齐口径”的地方

三、哪些注释是坚决不要写的?

知道“该写什么”之后,更重要的是:哪些注释写了只会拖团队后腿?

3.1 重复代码的注释:浪费时间 + 增加维护成本

❌ 示例 1:重复变量名


// 用户名称
const userName = getUserName();

❌ 示例 2:重复函数名 / 类型名


/**
 * 获取用户列表
 */
function getUserList() { ... }

这些注释的问题:

  • 没有额外信息
  • 只要一改函数名/变量名,注释就有可能不一致
  • 时间久了变成“看着像对的,其实是错的”

解决办法:

  • 优先把命名改清晰:getListgetUserListdatauserList / formState
  • 确实没啥要补充的,就不要写注释,空着反而更安全。

3.2 “心情日志”注释:TODO / FIXME 不写清楚内容

❌ 典型反面教材:


// TODO: 后续优化
// FIXME: 有 bug

半年后你自己也不知道:

  • 要优化什么?
  • 有什么 bug?复现步骤是什么?
  • 是否已经修了?是否还有影响?

✅ 推荐写法:


// TODO(v2.1): 表格数据量>1w时,滚动卡顿,需要引入虚拟列表
// 影响范围:订单列表、用户列表


// FIXME(2025-03-18 by 张三):
// 后端偶发返回重复的 orderId,导致 set 里丢数据
// 临时方案:前端用 (orderId + createdAt) 拼接作为 key,等后端修复后移除

规范建议:

  • TODO / FIXME 注释建议包含:

    • 触发条件 / 复现方式
    • 影响范围
    • (可选)目标版本/时间 & 责任人缩写
  • 团队可以规定:重要 TODO / FIXME 必须对应 Jira/禅道/飞书任务号,比如:


// TODO(JIRA-1234 v2.2): 支持多语言,先写死为中文

3.3 和真实逻辑不一致的注释:比没有注释更可怕

注释一旦和代码不一致,就会变成误导信息

❌ 示例:注释没更新


/**
 * 返回 true 表示用户未登录
 */
function isLoggedIn() {
  return !!localStorage.getItem('token');
}

显然逻辑是“有 token 才是登录”,但注释写反了。

如果后来别人只看注释不看实现,很容易写出一堆反逻辑的代码。

经验结论:

写过时注释 = 欺骗未来的同事。

写了就要维护,维护不了就少写。

所以在团队规范里可以明确:

  • 改动逻辑时,必须同步检查相关注释是否仍然正确
  • Code Review 时,把**“注释是否仍然成立”**当成一个检查点

3.4 写在实现细节里的“小说故事”:越写越乱

有同学特别喜欢在函数内部“边写边感想”,比如:


function fetchData() {
  // 这里先判断一下是不是有缓存
  // 如果有缓存的话就不用请求接口了
  // 但是这里我们又觉得可能缓存会不准
  // 所以又加了一个时间戳的判断
  // 总之就是很复杂,先这么写吧……
}

这种注释的问题:

  • 没有结构,像碎碎念日记
  • 讲了一堆感受,没有讲清楚最终规则
  • 以后别人看的时候,只会更迷惑

更好的做法:

  • 把真正关键的规则整理成条目
  • 其他的犹豫、不确定、吐槽,写到需求文档 / 评审记录里,而不是代码里

✅ 重写示例:


/**
 * 缓存策略说明:
 * 1. 默认命中缓存,避免重复请求
 * 2. 如果缓存时间超过 5 分钟,则强制请求最新数据
 * 3. 切换用户时,必须清空缓存(用户隔离)
 */
function fetchData() {
  // 实现略
}

四、不同层级怎么写?以 Vue 项目为例的一套落地规范

下面从 Vue 项目常见几层结构出发,给一套可直接落地到项目里的注释建议

4.1 组件层(Vue SFC):注释重点放在哪里?

4.1.1 props / emits / expose 是最值得写注释的地方

因为它们构成了组件的“对外接口”。

✅ 示例:表单组件


<script setup lang="ts">
interface Props {
  /**
   * 表单初始值:
   * - 不传则使用内部默认值
   * - 传入时会完全覆盖默认值(不要只传部分字段)
   */
  modelValue?: UserFormModel;

  /**
   * 是否立即在 mounted 后拉取远程选项数据
   * 默认 true;如果父组件要控制时机,可以传 false 后手动调用 `reloadOptions`
   */
  autoLoadOptions?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  autoLoadOptions: true,
});

const emit = defineEmits<{
  /**
   * 表单提交成功时触发
   * payload 包含表单内的所有字段
   */
  (e: 'submit', payload: UserFormModel): void;

  /**
   * 任意字段变化时触发(用于实时保存草稿)
   */
  (e: 'update:modelValue', value: UserFormModel): void;
}>();

defineExpose({
  /**
   * 重新拉取远程下拉选项
   */
  reloadOptions,
});
</script>

这里的注释能让你在不看实现的情况下,就知道怎么用这个组件,这就是高价值注释。

4.1.2 复杂模板逻辑,优先拆组件,其次写块级注释

当模板里出现大量条件判断 / 嵌套 v-if / v-for 时:

  1. 优先选择“拆小组件 / 抽函数”
  2. 仍然复杂时,可以在逻辑块上方加一段块级注释,说明大体意图

✅ 示例:


<template>
  <!-- 展示可见的菜单项:
       1. 已被后端标记为启用
       2. 当前用户有权限
       3. 如果是移动端,只显示前 5 个
  -->
  <MenuItem
    v-for="item in visibleMenuItems"
    :key="item.id"
    :item="item"
  />
</template>

这里注释的作用:

  • 总结了 visibleMenuItems过滤规则
  • 方便别人查找时快速定位逻辑(比如“为什么这个菜单在移动端消失了?”)

4.2 业务逻辑层(hooks / composables / services)

很多 Vue 3 项目会把复杂逻辑拆到:

  • useXXX.ts(逻辑复用)
  • xxxService.ts(调用后端接口 + 业务规则)

这部分逻辑往往最需要注释,但注释也最容易乱写。

4.2.1 统一写在函数/方法签名上方,说明职责和返回值

✅ 示例:组合式函数


/**
 * 订单列表的分页 + 筛选逻辑:
 * - 对外暴露响应式数据:list、loading、pagination
 * - 支持关键字搜索、状态筛选
 * - 初始化时自动加载一次数据
 */
export function useOrderList() {
  const list = ref<Order[]>([]);
  const loading = ref(false);
  const pagination = reactive({
    page: 1,
    pageSize: 20,
    total: 0,
  });

  // ...

  return {
    list,
    loading,
    pagination,
    reload,
    resetFilters,
  };
}

4.2.2 和后端接口交互的地方,注释协议差异/约束

✅ 示例:Service 层


/**
 * 获取订单详情:
 * - 后端只在 status='PAID' 时返回 payInfo 字段
 * - 如果订单已退款,refoundInfo 字段存在但可能为空对象
 * - 接口有 500ms 左右的延迟,注意不要在输入框输入时频繁调用
 */
export async function fetchOrderDetail(orderId: string): Promise<OrderDetail> {
  const { data } = await request.get(`/api/orders/${orderId}`);
  return normalizeOrderDetail(data);
}

这些信息如果不写在这里,很难在代码中第一时间发现,却又对上层调用逻辑影响极大。

4.3 工具层(utils / helpers):何时需要注释?

  • 通用的小工具函数,命名清晰时可以不用注释:

    
    export function formatPrice(amountInCent: number): string { ... }
    
    
  • 如果函数有一些隐含约束或性能特征,就应该注释说明:

✅ 示例:


/**
 * 深拷贝对象(仅用于小对象):
 * - 基于 JSON 序列化,不支持函数 / Date / Map / Set
 * - 遇到循环引用会抛错
 * 适合用于「接口 mock 数据」等简单场景,不要在核心路径频繁使用。
 */
export function simpleClone<T>(obj: T): T {
  return JSON.parse(JSON.stringify(obj));
}

五、团队层面的“注释规范建议”:可以直接抄到你们 RULE.md 里

下面给一份可以直接落地的团队规范草稿,你可以根据实际情况微调。

5.1 总体原则

  • P1:注释是代码的一部分,写了就要维护。
  • P2:注释说明“为什么 / 有什么坑 / 有什么约定”,不要“翻译代码”。
  • P3:宁可少写,也不要写错;宁可写在“合适位置”,也不要乱丢。

5.2 “必须注释”的场景

  • 对外接口:
    • 组件的 props / emits / expose
    • 公共工具函数 / Service 层函数的入参、返回值说明(特别是有约束时)
  • 复杂业务逻辑 / 算法:
    • 在函数 / 模块顶部写整体流程说明或规则列表
  • 历史遗留 / 兼容代码:
    • 必须说明历史背景 / 兼容对象 / 计划替换方案
  • TODO / FIXME:
    • 必须写明触发条件 / 影响范围 / 预期目标
    • 建议关联任务号(如:TODO(JIRA-1234)

5.3 “禁止/不鼓励”的注释

  • 重复代码内容的注释(变量名 / 函数名已经表达清楚)
  • 空泛的 TODO / FIXME(未说明问题和上下文)
  • 纯吐槽 / 情绪化注释
  • 长篇大论但没有结构的“感想式注释”

六、一个完整的小案例:从“糟糕注释”到“可维护代码”

下面用一个实际例子,演示如何从“混乱风格”改到“规范易读”。

6.1 初版(很多人项目里真实存在的写法)


<!-- OrderList.vue -->
<script setup lang="ts">
// 订单列表组件

const data = ref([]);
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);

// 获取列表
async function getList() {
  loading.value = true;
  // 调接口
  const res = await request.get('/api/list', {
    params: {
      p: page.value,
      ps: pageSize.value,
    },
  });
  // 处理数据
  data.value = res.data.list;
  total.value = res.data.total;
  loading.value = false;
}

// TODO: 后面要加筛选
</script>

<template>
  <!-- 列表 -->
  <Table :data="data" />
</template>

问题:

  • 命名不清晰(data / getList / /api/list
  • 注释几乎都是废话,没有说明任何约束
  • TODO 没有说明到底怎么“要加筛选”

6.2 改进版:结合命名 + 注释一起升级


<!-- OrderList.vue -->
<script setup lang="ts">
/**
 * 订单列表页:
 * - 支持分页
 * - 计划后续增加:状态筛选、关键字搜索(见 TODO)
 */
import { fetchOrderList } from '@/services/order';

const orders = ref<Order[]>([]);
const loading = ref(false);
const pagination = reactive({
  page: 1,
  pageSize: 10,
  total: 0,
});

/**
 * 拉取订单列表:
 * - 后端的页码从 1 开始(不要传 0)
 * - pageSize 最大不超过 100,否则后端会报错
 */
async function loadOrders() {
  loading.value = true;
  const res = await fetchOrderList({
    page: pagination.page,
    pageSize: pagination.pageSize,
  });
  orders.value = res.list;
  pagination.total = res.total;
  loading.value = false;
}

// TODO(v2.1): 增加筛选条件(状态 / 下单时间区间)
// - 与后端对齐接口 GET /api/orders:新增 status / startAt / endAt 参数
// - UI 上用折叠面板隐藏高级筛选
</script>

<template>
  <OrderTable
    :data="orders"
    :loading="loading"
    :pagination="pagination"
    @change="loadOrders"
  />
</template>

这里我们做了几件事:

  • 改变量名:dataordersgetListloadOrders
  • 提取 Service 层:fetchOrderList(便于复用与测试)
  • 用注释补充约束和未来计划,而不是重复代码

这就是一个**“代码 + 注释配合良好”的例子**。

七、如何把“注释规范”写成一篇能发 CSDN 的文章?

你可以按本文结构,稍作润色,就能产出一篇完整的博客。建议大致结构如下:

  1. 引子(痛点故事)
    • 自嘲+团队真实场景,引出“注释到底该不该写”的问题
  2. 第一原则:好代码优先,注释补充 Why & 限制
  3. 四大高价值注释场景
    • Why / 约定 / 历史坑点 / 复杂流程概览
  4. 四类反面注释示例
    • 重复代码、空 TODO/FIXME、过期注释、碎碎念
  5. 结合 Vue 项目结构的一套实践
    • 组件层、业务层、工具层分别给建议和示例
  6. 前后对比小案例
    • “糟糕版” vs “改进版”
  7. 总结 + 个人习惯分享
    • 比如:写完函数先写注释再实现、Review 时检查注释等

你可以直接把上文复制到 CSDN,稍微调整标题 / 小节顺序,并补充你自己项目中的真实故事和代码片段,会更有代入感和说服力。

八、结语:写给 3 年后的自己

注释不是给现在的你看的,是给“未来的你”和“曾经不认识你的同事”看的。

  • 多写一点“为什么这么写”,少写一点“这行在干嘛”
  • 多写一点“有什么坑 / 有什么约束”,少写一点“将来再说”
  • 写得少,但每一行都值钱,比写一堆废话强太多

技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护

哪怕每次只吃透一条规范,长期下来,差距会非常明显。

后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。

我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~

Electron+React必看:electron-router-dom 完整实战指南(含路由守卫/传参/多窗口)

做过 Electron + React 桌面端开发的兄弟,大概率都被路由兼容坑惨过:原生 react-router-dom 在开发环境跑得溜,打包生产直接失效;多窗口场景下路由互相污染,关窗还残留历史栈;开发/生产环境加载逻辑不一致,调试到头秃。

今天带来 electron-router-dom 完整版教程,基于官方入门文扩展,不仅保留基础上手流程,还把路由守卫、动态传参、嵌套路由、生产优化、底层逻辑一次性讲透,10年桌面端开发踩坑经验全塞进去,复制代码直接落地。

核心定位:react-router-dom 官方适配器,专为 Electron 多窗口、开发/生产双环境定制

核心解决:环境兼容、多窗口路由隔离、路由污染、生产失效四大痛点

一、先搞懂:为什么原生 react-router-dom 不适配 Electron?

很多人直接把网页路由搬到 Electron,踩坑了都不知道原因。底层逻辑很简单:

  • 网页是单窗口、hash/history 路由模式,Electron 多窗口是独立渲染进程,路由状态无法隔离
  • 开发环境用 localhost 服务,生产环境加载本地 HTML 文件,路由路径解析规则不一致
  • 原生路由没有窗口 ID 绑定,多窗口共用一个路由栈,导致跳转混乱、内存泄漏

electron-router-dom 就是做了一层封装:通过窗口 ID 绑定路由,让每个窗口拥有独立路由栈,自动适配开发/生产环境的路径解析,完美兼容 react-router-dom 原有 API(useNavigate、useParams 等)。


二、完整安装流程(含依赖避坑)

该库依赖 react-router-dom,必须同步安装,别漏装导致启动报错:

# npm 安装
npm i electron-router-dom react-router-dom

# yarn 安装
yarn add electron-router-dom react-router-dom

# pnpm 安装(推荐)
pnpm add electron-router-dom react-router-dom

重点提醒:react-router-dom 必须是 v6 版本(v5 不兼容),当前主流项目都是 v6,直接安装即可。

三、主进程全配置(开发/生产双环境+多窗口)

主进程核心是创建窗口 + 绑定窗口 ID + 区分环境加载路由,这步是路由生效的关键,窗口 ID 必须和渲染进程严格对应,不能错!

import { app, BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import { createFileRoute, createURLRoute } from 'electron-router-dom'
import { join } from 'path'

// 封装创建窗口函数,id 为路由唯一标识
function createWindow(id: string, options: BrowserWindowConstructorOptions = {}) {
  const window = new BrowserWindow({
    width: 700,
    height: 473,
    ...options,
    // 推荐开启,避免白屏
    show: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      // 关闭跨域限制(桌面端常用)
      webSecurity: false,
      nodeIntegration: false,
      contextIsolation: true
    }
  })

  // 开发环境:加载本地服务路由
  const devURL = createURLRoute(process.env.ELECTRON_RENDERER_URL!, id)
  // 生产环境:加载本地 HTML 文件路由
  const prodRoute = createFileRoute(join(__dirname, '../renderer/index.html'), id)

  // 环境区分加载
  if (process.env.NODE_ENV === 'development') {
    window.loadURL(devURL)
  } else {
    window.loadFile(...prodRoute)
  }

  // 页面加载完毕再显示,避免闪烁
  window.once('ready-to-show', () => window.show())
  return window
}

// 应用就绪后创建窗口
app.whenReady().then(() => {
  // 主窗口,id = main
  createWindow('main')
  // 关于窗口,id = about
  createWindow('about', { width: 450, height: 350 })
})

// 关闭所有窗口退出(mac 除外)
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

四、渲染进程路由配置(基础+嵌套+多窗口)

渲染进程通过 Router 组件按窗口 ID 配置路由,和主进程 ID 一一对应,支持嵌套路由、路由分组。

1. 路由配置文件(routes.tsx)

import { Router, Route } from 'electron-router-dom'
// 引入页面组件
import { MainScreen, SearchScreen, AboutScreen, UserDetailScreen } from './screens'
// 引入路由守卫组件
import { AuthGuard } from './guards/AuthGuard'

export function AppRoutes() {
  return (
    <Router
      // 主窗口路由id=main),支持嵌套/多路由
      main={
        <>
          {/* 基础路由 */}
          <Route path="/" element={<MainScreen />} />
          {/* 带路由守卫的路由(需登录) */}
          <Route path="/search" element={<AuthGuard><SearchScreen /></AuthGuard>} />
          {/* 动态传参路由 */}
          <Route path="/user/:id" element={<UserDetailScreen />} />
        </>
      }
      // 关于窗口路由(id=about),独立路由栈
      about={<Route path="/" element={<AboutScreen />} />}
    />
  )
}

2. 入口文件挂载路由(index.tsx)

import React from 'react'
import ReactDOM from 'react-dom/client'
import { AppRoutes } from './routes'

const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(
  <React.StrictMode>
    <AppRoutes />
  </React.StrictMode>
)

五、核心进阶用法(原文缺失,必看)

1. 路由跳转(useNavigate 用法)

和原生 react-router-dom 完全一致,直接复用原有写法,无需改逻辑:

import { useNavigate } from 'react-router-dom'

export function MainScreen() {
  const navigate = useNavigate()

  return (
    <main style={{ padding: '20px' }}>
      <h1>主窗口</h1>
      {/* 普通跳转 */}
      <button onClick={() => navigate('/search')}>跳转搜索页</button>
      {/* 动态传参跳转 */}
      <button onClick={() => navigate('/user/1001')}>查看用户详情</button>
      {/* 返回上一页 */}
      <button onClick={() => navigate(-1)}>返回</button>
    </main>
  )
}

2. 动态路由参数获取(useParams)

import { useParams } from 'react-router-dom'

export function UserDetailScreen() {
  // 获取路由上的 id 参数
  const { id } = useParams<{ id: string }>()

  return (
    <div>
      <h2>用户详情页</h2>
      <p>用户ID:{id}</p>
    </div>
  )
}

3. 路由守卫(权限控制,登录拦截)

封装高阶组件,实现未登录跳转登录页,桌面端权限控制必备:

// src/guards/AuthGuard.tsx
import { Navigate } from 'react-router-dom'

interface AuthGuardProps {
  children: React.ReactNode
}

export function AuthGuard({ children }: AuthGuardProps) {
  // 判断登录状态(可从 store/preload 读取)
  const isLogin = localStorage.getItem('token') ? true : false

  // 未登录跳转首页
  if (!isLogin) return <Navigate to="/" replace /&gt;
  // 已登录放行
  return children
}

4. 多窗口通信 + 路由联动

通过 preload 暴露方法,主进程打开新窗口,渲染进程触发,路由自动隔离:

// 页面组件内调用
const { App } = window // preload 暴露的 API
<button onClick={() => App.openAboutWindow()}>打开关于窗口</button>

六、生产打包避坑(关键!)

  • 打包前务必校验 窗口 ID 一致性,主进程和渲染进程必须完全匹配
  • 生产环境关闭 devTools,路由文件路径别写错,避免加载失败
  • 路由不要用绝对路径,统一用相对路径,防止跨域/文件找不到
  • 多窗口关闭时,同步清理路由状态,避免内存泄漏

七、底层逻辑简析(看懂不踩坑)

electron-router-dom 本质是路由分发器

  1. 主进程通过窗口 ID 标记路由,开发环境拼接 URL,生产环境拼接文件路径
  2. 渲染进程通过 ID 匹配对应路由组,每个窗口路由栈独立,互不干扰
  3. 内部兼容 react-router-dom v6 核心 API,上层写法无感知,底层做环境适配

八、总结

electron-router-dom 是 Electron + React 开发的路由神器,解决了原生路由最头疼的环境和多窗口问题。基础用法简单易上手,进阶用法(守卫、传参、嵌套)完全兼容 react-router-dom,上手成本极低。

建议大家把这份教程收藏,项目里直接复制配置,再也不用折腾路由兼容问题。如果碰到窗口白屏、路由失效,优先检查窗口 ID 是否一致、环境路径是否正确,90% 的坑都能解决。

点赞+收藏,Electron 开发少走一周弯路,需要完整项目模板的评论区留言~

❌