阅读视图

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

每日一题-仅含 1 的子串数🟡

给你一个二进制字符串 s(仅由 '0' 和 '1' 组成的字符串)。

返回所有字符都为 1 的子字符串的数目。

由于答案可能很大,请你将它对 10^9 + 7 取模后返回。

 

示例 1:

输入:s = "0110111"
输出:9
解释:共有 9 个子字符串仅由 '1' 组成
"1" -> 5 次
"11" -> 3 次
"111" -> 1 次

示例 2:

输入:s = "101"
输出:2
解释:子字符串 "1" 在 s 中共出现 2 次

示例 3:

输入:s = "111111"
输出:21
解释:每个子字符串都仅由 '1' 组成

示例 4:

输入:s = "000"
输出:0

 

提示:

  • s[i] == '0's[i] == '1'
  • 1 <= s.length <= 10^5

枚举子串右端点,简洁写法(Python/Java/C++/C/Go/JS/Rust)

遍历 $s$ 的过程中,记录上一个 $0$ 的位置 $\textit{last}_0$。

如果 $s[i]=1$,那么对于右端点为 $i$ 的全 $1$ 子串,左端点可以是

$$
\textit{last}_0+1,\textit{last}_0+2,\ldots,i-1,i
$$

一共有 $i - \textit{last}_0$ 个,加到答案中。

比如 $\textit{last}_0=2$,现在 $s[5]=1$,那么子串 $[3,5],[4,5],[5,5]$ 都只包含 $1$,有 $5-2=3$ 个以 $5$ 为右端点的全 $1$ 子串。

可能一开始没有 $0$,为了让公式兼容这种情况,初始化 $\textit{last}_0=-1$。

注意答案不超过 $64$ 位整数的最大值,可以在最后返回时再取模。

###py

class Solution:
    def numSub(self, s: str) -> int:
        MOD = 1_000_000_007
        ans = 0
        last0 = -1
        for i, ch in enumerate(s):
            if ch == '0':
                last0 = i  # 记录上个 0 的位置
            else:
                ans += i - last0  # 右端点为 i 的全 1 子串个数
        return ans % MOD

###java

class Solution {
    public int numSub(String s) {
        final int MOD = 1_000_000_007;
        long ans = 0;
        int last0 = -1;
        for (int i = 0; i < s.length(); i++) {
            char ch = s.charAt(i);
            if (ch == '0') {
                last0 = i; // 记录上个 0 的位置
            } else {
                ans += i - last0; // 右端点为 i 的全 1 子串个数
            }
        }
        return (int) (ans % MOD);
    }
}

###cpp

class Solution {
public:
    int numSub(string s) {
        constexpr int MOD = 1'000'000'007;
        long long ans = 0;
        int last0 = -1;
        for (int i = 0; i < s.size(); i++) {
            if (s[i] == '0') {
                last0 = i; // 记录上个 0 的位置
            } else {
                ans += i - last0; // 右端点为 i 的全 1 子串个数
            }
        }
        return ans % MOD;
    }
};

###c

#define MOD 1000000007

int numSub(char* s) {
    long long ans = 0;
    int last0 = -1;
    for (int i = 0; s[i]; i++) {
        if (s[i] == '0') {
            last0 = i; // 记录上个 0 的位置
        } else {
            ans += i - last0; // 右端点为 i 的全 1 子串个数
        }
    }
    return ans % MOD;
}

###go

func numSub(s string) (ans int) {
const mod = 1_000_000_007
last0 := -1
for i, ch := range s {
if ch == '0' {
last0 = i // 记录上个 0 的位置
} else {
ans += i - last0 // 右端点为 i 的全 1 子串个数
}
}
return ans % mod
}

###js

var numSub = function(s) {
    const MOD = 1_000_000_007;
    let ans = 0, last0 = -1;
    for (let i = 0; i < s.length; i++) {
        if (s[i] === '0') {
            last0 = i; // 记录上个 0 的位置
        } else {
            ans += i - last0; // 右端点为 i 的全 1 子串个数
        }
    }
    return ans % MOD;
};

###rust

impl Solution {
    pub fn num_sub(s: String) -> i32 {
        const MOD: i64 = 1_000_000_007;
        let mut ans = 0;
        let mut last0 = -1;
        for (i, ch) in s.bytes().enumerate() {
            if ch == b'0' {
                last0 = i as i32; // 记录上个 0 的位置
            } else {
                ans += (i as i32 - last0) as i64; // 右端点为 i 的全 1 子串个数
            }
        }
        (ans % MOD) as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

相似题目

2348. 全 0 子数组的数目

专题训练

见下面双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

仅含 1 的子串数

方法一:遍历字符串寻找最长子串

如果一个所有字符都为 $1$ 的字符串的长度为 $k$,则该字符串包含的所有字符都为 $1$ 的子字符串(包括该字符串本身)的数量是 $\dfrac{k * (k + 1)}{2}$。

首先寻找到所有的只包含字符 $1$ 的最长子字符串。这里的「只包含字符 $1$ 的最长子字符串」的意思是,假设该子字符串的下标范围是 $[i, j]$(包含下标 $i$ 和下标 $j$),其中 $i \le j$,该子字符串中的所有字符都是 $1$,且下标 $i$ 满足 $i$ 位于字符串 $s$ 的最左侧或者下标 $i - 1$ 位置的字符是 $0$,以及下标 $j$ 满足 $j$ 位于字符串 $s$ 的最右侧或者下标 $j + 1$ 位置的字符是 $0$。

寻找到所有的只包含字符 $1$ 的最长子字符串之后,就可以计算所有字符都为 $1$ 的子字符串的数量。

具体做法是,从左到右遍历字符串,如果遇到字符 $1$ 则计算连续字符 $1$ 的数量,如果遇到字符 $0$ 则说明上一个只包含字符 $1$ 的最长子字符串遍历结束,根据最长子字符串的长度计算子字符串的数量,然后将连续字符 $1$ 的数量清零。遍历结束后,如果连续字符 $1$ 的数量大于零,则还有最后一个只包含字符 $1$ 的最长子字符串,因此还需要计算其对应的子字符串的数量。

###Java

class Solution {
    public int numSub(String s) {
        final int MODULO = 1000000007;
        long total = 0;
        int length = s.length();
        long consecutive = 0;
        for (int i = 0; i < length; i++) {
            char c = s.charAt(i);
            if (c == '0') {
                total += consecutive * (consecutive + 1) / 2;
                total %= MODULO;
                consecutive = 0;
            } else {
                consecutive++;
            }
        }
        total += consecutive * (consecutive + 1) / 2;
        total %= MODULO;
        return (int) total;
    }
}

###C++

class Solution {
public:
    static constexpr int P = int(1E9) + 7;
    
    int numSub(string s) {
        int p = 0;
        long long ans = 0;
        while (p < s.size()) {
            if (s[p] == '0') {
                ++p;
                continue;
            }
            int cnt = 0;
            while (p < s.size() && s[p] == '1') {
                ++cnt;
                ++p;
            }
            ans = ans + (1LL + (long long)cnt) * cnt / 2;
            ans = ans % P;
        }
        return ans;
    }
};

###Python

class Solution:
    def numSub(self, s: str) -> int:
        total, consecutive = 0, 0
        length = len(s)
        for i in range(length):
            if s[i] == '0':
                total += consecutive * (consecutive + 1) // 2
                consecutive = 0
            else:
                consecutive += 1
        
        total += consecutive * (consecutive + 1) // 2
        total %= (10**9 + 7)
        return total

###C#

public class Solution {
    public int NumSub(string s) {
        const int MODULO = 1000000007;
        long total = 0;
        long consecutive = 0;
        foreach (char c in s) {
            if (c == '0') {
                total += consecutive * (consecutive + 1) / 2;
                total %= MODULO;
                consecutive = 0;
            } else {
                consecutive++;
            }
        }
        total += consecutive * (consecutive + 1) / 2;
        total %= MODULO;
        return (int)total;
    }
}

###Go

func numSub(s string) int {
    const MODULO = 1000000007
    total := 0
    consecutive := 0
    for _, c := range s {
        if c == '0' {
            total += consecutive * (consecutive + 1) / 2
            total %= MODULO
            consecutive = 0
        } else {
            consecutive++
        }
    }
    total += consecutive * (consecutive + 1) / 2
    total %= MODULO
    return total
}

###C

int numSub(char * s) {
    const int MODULO = 1000000007;
    long total = 0;
    long consecutive = 0;
    for (int i = 0; s[i]; i++) {
        if (s[i] == '0') {
            total += consecutive * (consecutive + 1) / 2;
            total %= MODULO;
            consecutive = 0;
        } else {
            consecutive++;
        }
    }
    total += consecutive * (consecutive + 1) / 2;
    total %= MODULO;
    return (int)total;
}

###JavaScript

var numSub = function(s) {
    const MODULO = 1000000007;
    let total = 0;
    let consecutive = 0;
    for (const c of s) {
        if (c === '0') {
            total += consecutive * (consecutive + 1) / 2;
            total %= MODULO;
            consecutive = 0;
        } else {
            consecutive++;
        }
    }
    total += consecutive * (consecutive + 1) / 2;
    total %= MODULO;
    return total;
};

###TypeScript

function numSub(s: string): number {
    const MODULO = 1000000007;
    let total = 0;
    let consecutive = 0;
    for (const c of s) {
        if (c === '0') {
            total += consecutive * (consecutive + 1) / 2;
            total %= MODULO;
            consecutive = 0;
        } else {
            consecutive++;
        }
    }
    total += consecutive * (consecutive + 1) / 2;
    total %= MODULO;
    return total;
}

###Rust

impl Solution {
    pub fn num_sub(s: String) -> i32 {
        const MODULO: i64 = 1000000007;
        let mut total: i64 = 0;
        let mut consecutive: i64 = 0;
        for c in s.chars() {
            if c == '0' {
                total += consecutive * (consecutive + 1) / 2;
                total %= MODULO;
                consecutive = 0;
            } else {
                consecutive += 1;
            }
        }
        total += consecutive * (consecutive + 1) / 2;
        total %= MODULO;
        total as i32
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是字符串的长度。需要遍历字符串一次。

  • 空间复杂度:$O(1)$。只需要维护有限的变量,空间复杂度是常数。

前端开发小技巧-【JavaScript】- 获取元素距离 document 顶部的距离

获取元素距离 document 顶部的距离

方案1:使用 offsetTop(最简单)

const element = document.getElementById('myDiv')
const distance = element.offsetTop

console.log(distance)  // 500(像素)

方案2:使用 getBoundingClientRect() + scrollY(最准确)

const element = document.getElementById('myDiv')
const distance = element.getBoundingClientRect().top + window.scrollY

console.log(distance)  // 500(像素)

方案3:递归计算(处理嵌套位置)

function getDistanceFromTop(element) {
  let distance = 0
  let current = element

  while (current) {
    distance += current.offsetTop
    current = current.offsetParent
  }

  return distance
}

const distance = getDistanceFromTop(document.getElementById('myDiv'))
console.log(distance)

对比表格

方法 优点 缺点 推荐度
offsetTop 简单快速 只返回相对最近定位父元素 ⭐⭐⭐
getBoundingClientRect().top + scrollY 精确、兼容性好 稍复杂 ⭐⭐⭐⭐⭐
递归计算 处理复杂嵌套 代码复杂 ⭐⭐

完整示例

// 最推荐的方法
const element = document.getElementById('myDiv')
const distanceFromDocTop = element.getBoundingClientRect().top + window.scrollY

console.log(`元素距离文档顶部: ${distanceFromDocTop}px`)

React 中使用

import { useRef, useEffect } from 'react'

function MyComponent() {
  const elementRef = useRef(null)

  useEffect(() => {
    if (elementRef.current) {
      const distance = elementRef.current.getBoundingClientRect().top + window.scrollY
      console.log('距离文档顶部:', distance)
    }
  }, [])

  return <div ref={elementRef}>内容</div>
}

实际应用

// 获取元素到文档顶部的距离
function getElementDistanceFromTop(elementId, offset = 0) {
  const element = document.getElementById(elementId)
  
  if (!element) {
    console.warn(`元素 ${elementId} 不存在`)
    return 0
  }

  return element.getBoundingClientRect().top + window.scrollY - offset
}

// 使用
const distance = getElementDistanceFromTop('myDiv', 100)
console.log(distance)  // 返回元素距离文档顶部,减去 100px 的偏移

// 滚动到该位置
window.scrollTo({
  top: distance,
  behavior: 'smooth'
})

总结

最佳方案element.getBoundingClientRect().top + window.scrollY

/**
 * 滚动到指定元素
 * @param id 元素ID
 * @param offsetTop 偏移量【指定元素上下偏移量】
 */
const scrollTo = (id: string, offsetTop: number = 0) => {
  const element = document.getElementById(id)
  if (!element) {
    return
  }

  const elementTop = element.getBoundingClientRect().top + window.scrollY
  const targetTop = elementTop - offsetTop

  window.scrollTo({
    top: targetTop,
    behavior: 'smooth',
  })
}

开发一个计时器组件

✅ 1、计时逻辑放在 Web Worker,不放在主线程

  • 现代浏览器为了省电和性能,会对非当前显示的标签页实施限制,setTimeout / setInterval可能延迟;
  • 不会被主线程的渲染/JS执行阻塞影响。

✅ 2、递归 setTimeout,不用 setInterval

1、setInterval 的缺陷:

setInterval(fn, 1000) 的含义是: “每隔 1000ms,将 fn 放入任务队列”,但不保证 fn 何时开始执行。

如果主线程繁忙(如渲染卡顿、大量计算),fn 可能延迟执行,而下一次回调仍按固定间隔排队,导致:累计误差越来越大,实际执行频率远低于预期。

2、setTimeout 递归的优势:

动态调整下一次延迟时间,补偿上一次的执行耗时,setTimeout 递归是“执行完再约下一次”,避免任务堆积。

function tick() {
  // ...
  setTimeout(tick, 1000); // 下一次在“本次结束后”再安排
}
tick();

每次都是“上一次结束 + 1000ms”,不会堆积,误差不累积!

✅ 3、完整代码

Timer.vue:

<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import {
  COMPLETE,
  COUNTDOWN,
  formatSeconds,
  PAUSE,
  RESET,
  START,
  UPDATE,
} from './timer.utils';

const worker = new Worker(new URL('./timer.worker.ts', import.meta.url), {
  type: 'module',
});

const props = withDefaults(
  defineProps<{
    mode?: 'countdown' | 'countup'; // 计时模式: countdown:倒计时, countup:计时
    remMinutes?: number; // 倒计时初始分钟数(仅 countdown 模式有效)
    immediate?: boolean; // 是否立即开始计时
    control?: boolean; // 是否显示控制按钮
    step?: number; // 计时步长(单位:秒,限制整秒)
    onStart?: () => void;
    onUpdate?: (time: string[]) => void;
    onPause?: (time: string[]) => void;
    onComplete?: () => void;
    onReset?: () => void;
  }>(),
  {
    mode: COUNTDOWN,
    immediate: true,
    control: true,
    step: 1,
  }
);

// 当前运行状态:
// - undefined:未开始(初始或重置后)
// - true:正在运行
// - false:已暂停
const isStart = ref<boolean>();

// 当前总秒数
let currentSeconds = props.mode === COUNTDOWN ? (props.remMinutes ?? 0) * 60 : 0;

// 初始化时间格式 [HH, MM, SS]
const initialTime = formatSeconds(currentSeconds);
const hours = ref(initialTime[0]);
const minutes = ref(initialTime[1]);
const seconds = ref(initialTime[2]);

listenWorkerMsg();

if (props.immediate) {
  start();
}

function listenWorkerMsg() {
  worker.onmessage = event => {
    const { time, type, rawSeconds } = event.data;

    if (type === UPDATE) {
      [hours.value, minutes.value, seconds.value] = time;
      currentSeconds = rawSeconds;
      props.onUpdate?.([hours.value, minutes.value, seconds.value] as string[]);
    }

    if (type === COMPLETE) {
      isStart.value = undefined;
      props.onComplete?.();
    }
  };
}

function start() {
  if (isStart.value === true) {
    return;
  }
  isStart.value = true;
  const { mode, step } = props;
  worker.postMessage({
    action: START,
    step: Math.max(1, Math.floor(step)), // 仅支持秒级更新
    mode,
    currentSeconds,
  });
  props.onStart?.();
}

function pause() {
  if (isStart.value !== true) {
    return;
  }
  isStart.value = false;
  worker.postMessage({ action: PAUSE });
  props.onPause?.([hours.value, minutes.value, seconds.value] as string[]);
}

function reset() {
  worker.postMessage({ action: RESET });
  isStart.value = undefined;
  const { mode, remMinutes, immediate } = props;
  currentSeconds = mode === COUNTDOWN ? (remMinutes ?? 0) * 60 : 0;
  if (immediate) {
    start();
  }
  props.onReset?.();
}

onBeforeUnmount(() => {
  worker.terminate();
});

defineExpose({
  start,
  pause,
  reset,
});
</script>
<template>
  <div class="timer">
    <div class="timer-container">
      <slot :hours="hours" :minutes="minutes" :seconds="seconds">
        <span>{{ hours }}</span>
        <span class="delimiter">:</span>
        <span>{{ minutes }}</span>
        <span class="delimiter">:</span>
        <span>{{ seconds }}</span>
      </slot>
    </div>

    <div class="controls-container">
      <slot name="controls">
        <div v-if="control" class="controls">
          <button class="btn" :disabled="isStart === true" @click="start">开始</button>
          <button class="btn" :disabled="isStart === false" @click="pause">暂停</button>
          <button class="btn" @click="reset">重置</button>
        </div>
      </slot>
    </div>
  </div>
</template>
<style lang="scss" scoped>
.timer-container {
  font-size20px;
  font-weight500;
  letter-spacing3px;
  display: flex;
  align-items: center;
  justify-content: center;

  .delimiter {
    transformtranslateY(-1px);
  }
}

.controls {
  margin-top8px;
  display: flex;
  align-items: center;
  justify-content: center;

  .btn {
    margin0 4px;
    border-radius8px;
    border1px solid transparent;
    padding0.6em 1.2em;
  }

  .btn:hover {
    border-color#646cff;
  }

  .btn:focus,
  .btn:focus-visible {
    outline4px auto -webkit-focus-ring-color;
  }

  &.disabled {
    .btn {
      background#f4f4f4;
      cursor: not-allowed;
    }
  }
}
</style>

timer.worker.ts:

/**
 * web worker 防止主线程阻塞、浏览器标签页未激活时,影响计时精度
 */

import {
  COMPLETE,
  COUNTDOWN,
  COUNTUP,
  formatSeconds,
  PAUSE,
  RESET,
  START,
  UPDATE,
} from './timer.utils.ts';

let isRunning = false; // 是否运行中
let timeoutId: number | null;

self.onmessage = event => {
  const { action, mode, step, currentSeconds } = event.data;

  // 重置/暂停
  if (action === RESET || action === PAUSE) {
    stopTimer();
    return;
  }

  // 开始
  if (action === START) {
    stopTimer();
    isRunning = true;

    if (mode === COUNTDOWN) {
      startCountdown(currentSeconds, step);
    }

    if (mode === COUNTUP) {
      startCountup(currentSeconds, step);
    }
  }
};

function startCountdown(remaining: number, step: number) {
  const tick = () => {
    if (!isRunning) return;

    // 发送当前时间状态
    self.postMessage({
      type: UPDATE,
      time: formatSeconds(remaining),
      rawSeconds: remaining,
    });

    // 如果已结束
    if (remaining <= 0) {
      self.postMessage({ type: COMPLETE });
      stopTimer();
      return;
    }

    // 计算下一次剩余时间(确保不小于 0)
    const nextRemaining = Math.max(0, remaining - step);
    const delay = (remaining - nextRemaining) * 1000; // 精确延迟

    if (nextRemaining > 0) {
      // 继续倒计时
      timeoutId = self.setTimeout(tick, delay);
    } else {
      // 最后一次归零
      timeoutId = self.setTimeout(() => {
        self.postMessage({
          type: UPDATE,
          time: formatSeconds(0),
          rawSeconds: 0,
        });
        self.postMessage({ type: COMPLETE });
        stopTimer();
      }, delay);
    }

    remaining = nextRemaining;
  };

  tick(); // 立即触发第一次更新(显示初始值)
}

function startCountup(elapsed: number, step: number) {
  const tick = () => {
    if (!isRunning) return;

    self.postMessage({
      type: UPDATE,
      time: formatSeconds(elapsed),
      rawSeconds: elapsed,
    });

    elapsed += step;
    timeoutId = self.setTimeout(tick, step * 1000);
  };

  tick();
}

function stopTimer() {
  if (timeoutId) {
    self.clearTimeout(timeoutId);
    timeoutId = null;
  }
  isRunning = false;
}

timer.utils.ts:

function formatSeconds(totalSeconds: number) {
  totalSeconds = Math.max(0, totalSeconds);
  const h = Math.floor(totalSeconds / 3600);
  const m = Math.floor((totalSeconds % 3600) / 60);
  const s = totalSeconds % 60;
  return [h, m, s].map(v => String(v).padStart(2'0'));
}

const COUNTDOWN = 'countdown';
const COUNTUP = 'countup';

const START = 'start';
const PAUSE = 'pause';
const RESET = 'reset';

const UPDATE = 'update';
const COMPLETE = 'complete';

export {
  formatSeconds,
  COUNTDOWN,
  COUNTUP,
  START,
  PAUSE,
  RESET,
  UPDATE,
  COMPLETE,
};

学习React-DnD:核心组件与Hooks

上一篇我们完成了React-DnD的环境搭建,通过安装依赖和全局注入后端,让整个应用具备了拖放能力的基础。这一篇,我们将深入React-DnD的核心——那些支撑起拖放功能的组件和Hooks。从全局管理的DndProvider,到定义拖动源的useDrag、拖放目标的useDrop,每一个都至关重要。掌握它们,你就能轻松实现各种复杂的拖放场景。

在开始之前,先明确一个核心逻辑:React-DnD通过“组件提供上下文 + Hooks连接组件”的模式工作。DndProvider作为上下文提供者,为所有子组件传递拖放能力;而useDrag、useDrop等Hooks则负责将普通组件“改造”为拖动源或拖放目标,实现具体的交互逻辑。下面我们逐个拆解。

一、核心组件:拖放能力的“基石”

React-DnD的组件数量不多,但每一个都是构建拖放功能的关键。其中DndProvider是必用组件,DragPreviewImage则用于优化拖动体验,我们重点讲解这两个。

1. DndProvider:拖放上下文的“提供者”

如果把React-DnD的拖放能力比作“水电”,那么DndProvider就是“水电总闸”。它负责将拖放后端的能力注入到整个应用,让所有子组件都能共享这份能力。上一篇我们已经在入口文件中用过它,现在来深入理解它的核心作用和配置项。

核心作用

DndProvider的本质是一个React上下文(Context)的提供者,它会创建一个拖放上下文,并将后端(如HTML5Backend)的功能传递给所有子组件。这样一来,子组件通过useDrag、useDrop等Hooks就能直接获取拖放能力,无需单独配置后端。

如果不使用DndProvider包裹应用,后续编写拖动源或拖放目标时会直接报错——组件找不到拖放上下文,就像没接水电的房子无法使用电器一样。

关键配置项

DndProvider的配置项不多,但每一个都有明确的用途,其中backend是必填项,其他为可选项。

  • backend(必填) :React-DnD的后端引擎,负责处理原生DOM事件(如鼠标拖动、悬停),并将其转化为React-DnD能识别的逻辑。我们开发PC端应用时,基本都使用官方提供的react-dnd-html5-backend;如果是移动端,则可以使用react-dnd-touch-backend。
  • context(可选) :用于配置后端的上下文对象,具体用法取决于你使用的后端实现。一般情况下,使用默认配置即可,无需额外设置。
  • options(可选) :用于配置后端的选项对象,同样依赖于后端实现。例如,某些后端支持配置拖动的延迟时间、触摸反馈等,都可以通过这个参数传递。

实战示例(回顾与强化)

在入口文件src/index.js中,我们用DndProvider包裹整个App组件,并注入HTML5Backend。这里再强调一下核心代码的逻辑:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// 导入 DndProvider 和 HTML5Backend
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  // 用DndProvider包裹App,注入后端
  <DndProvider backend={HTML5Backend}>
    <App />
  </DndProvider>
);

这里的关键是将HTML5Backend作为backend属性传递给DndProvider,这样整个应用的子组件都能使用拖放能力了。

2. DragPreviewImage:自定义拖动预览的“工具”

默认情况下,拖动元素时,浏览器会生成一个该元素的“快照”作为拖动预览。但在实际开发中,我们可能需要自定义预览效果(比如拖动时显示一个简化的图标,而不是整个元素),这时候就需要用到DragPreviewImage组件。

核心作用

DragPreviewImage组件用于将一张HTML图像元素(img)渲染为拖动时的预览效果,替代浏览器默认的预览快照。它需要配合useDrag钩子的拖动预览连接器使用。

关键配置项

DragPreviewImage只有一个必填配置项:connect。

  • connect(必填) :拖动预览的连接器函数,来自useDrag钩子的返回值。它的作用是将自定义的预览图像与拖动操作关联起来,让浏览器在拖动时显示这张图像。

实战示例

下面的例子中,我们创建一个可拖动的任务卡片,拖动时显示一张自定义的预览图片:

import React from 'react';
import { useDrag } from 'react-dnd';
import { DragPreviewImage } from 'react-dnd';
// 导入自定义预览图片
import taskPreview from './task-preview.png';

function TaskCard({ id, title }) {
  // 从useDrag中获取拖动预览连接器
  const [, dragSourceRef, dragPreviewRef] = useDrag({
    type: 'TASK',
    item: { id, title }
  });

  return (
    <div ref={dragSourceRef} style={{ padding: 16, border: '1px solid #ccc', margin: 8 }}>
      {/* 关联自定义预览图片 */}
      <DragPreviewImage connect={dragPreviewRef} src={taskPreview} />
      {title}
    </div>
  );
}

export default TaskCard;

这里的核心逻辑是:从useDrag的返回值中获取dragPreviewRef(拖动预览连接器),然后将其传递给DragPreviewImage的connect属性,同时通过src属性指定自定义预览图片的路径。这样,拖动TaskCard时,就会显示taskPreview.png这张图片作为预览效果。

二、核心Hooks:拖放交互的“实现者”

如果说组件是React-DnD的“骨架”,那么Hooks就是“肌肉”——它们负责实现具体的拖放交互逻辑。React-DnD提供了多个实用Hooks,其中useDrag(定义拖动源)、useDrop(定义拖放目标)是最常用的两个,useDragLayer和useDragDropManager则用于更复杂的场景。

1. useDrag:让组件成为“拖动源”

useDrag是将普通React组件转化为“拖动源”的核心钩子。通过向它传递一个规范对象(spec),我们可以声明性地描述拖动源的类型、拖动的数据、拖动过程中的回调等。

基本用法:参数与返回值

useDrag的用法可以总结为“传入spec配置,返回三个核心对象”,具体如下:

const [collectedProps, dragSourceRef, dragPreviewRef] = useDrag(spec, deps);
参数说明
  • spec(必填) :规范对象或返回规范对象的函数,用于配置拖动源的核心逻辑。这是useDrag的核心,我们后面会详细拆解其成员。
  • deps(可选) :依赖关系数组,用于备忘录化(类似React的useMemo)。如果spec是函数,deps默认是空数组;如果spec是对象,deps默认是包含spec的数组。当deps中的值发生变化时,useDrag会重新计算spec配置。
返回值说明

useDrag返回一个包含三个元素的数组,每个元素都有明确的用途:

  • collectedProps(索引0) :从collect函数中收集的属性对象。collect函数用于从拖放监控器(monitor)中获取拖动状态(如是否正在拖动),并将其转化为组件的props。如果没有定义collect函数,返回空对象。
  • dragSourceRef(索引1) :拖动源的连接器函数,必须绑定到组件的DOM元素上。它的作用是告诉React-DnD“哪个元素是可拖动的”,如果不绑定,组件将无法被拖动。
  • dragPreviewRef(索引2) :拖动预览的连接器函数,用于关联自定义的拖动预览(如DragPreviewImage组件)。如果不需要自定义预览,可以忽略它。

核心:spec规范对象详解

spec对象是useDrag的灵魂,它定义了拖动源的所有行为。其中type和item是必填项,其他为可选项。

必填成员
  • type(必填) :字符串或符号(Symbol),用于标识拖动源的类型。只有注册了相同类型的拖放目标(useDrop),才会对该拖动源的拖放操作做出反应。这是React-DnD实现“拖动源与目标匹配”的核心机制。例如,我们可以将任务卡片的type设为'TASK',将任务列表的accept设为'TASK',这样任务卡片就能拖放到任务列表中。
  • item(必填) :描述拖动数据的对象,或返回该对象的函数。这是拖动源传递给拖放目标的“核心数据”,也是两者之间唯一的通信桥梁。 如果是对象,应只包含拖放目标需要的最小数据(如id、名称),避免传递复杂引用(比如整个组件实例),否则会导致拖动源和目标过度耦合。
  • 如果是函数,会在拖动操作开始时执行,并返回上述对象。如果返回null,拖动操作会被取消。
可选成员(回调与配置)

end(item, monitor)(可选) :拖动操作结束时触发的回调函数,无论拖动是否成功(比如拖到目标后释放,或拖到无效区域释放),都会执行。

  • item:拖动的核心数据(与spec.item一致)。

  • monitor:拖放监控器,用于获取拖动状态(如monitor.didDrop()可以判断拖放是否被目标接受,monitor.getDropResult()可以获取目标返回的结果)。

  • 常用场景:拖动结束后更新数据(如将任务从“待办”列表移到“已办”列表)。

canDrag(monitor)(可选) :用于判断当前组件是否允许被拖动。返回true则允许拖动,返回false则禁止。 monitor:拖放监控器,可以通过monitor.getItem()获取拖动数据,结合组件props判断是否允许拖动(如某些任务卡片不允许被拖动)。

注意: 不能在该函数中调用monitor.canDrag(),否则会导致死循环。

isDragging(monitor)(可选) :用于自定义“是否正在拖动”的判断逻辑。默认情况下,只有启动拖动的组件会被视为“正在拖动”。 常用场景:当有多个相同类型的组件时(如多个任务卡片),通过item.id与组件props.id对比,确保只有当前拖动的组件显示“拖动中”的样式。

示例:return monitor.getItem().id === props.id;

collect(monitor, props)(可选) :收集函数,用于从监控器中获取拖动状态,并转化为组件的props。返回的对象会作为useDrag的第一个返回值(collectedProps)传递给组件。

常用监控器方法:monitor.isDragging()(是否正在拖动)、monitor.getInitialClientOffset()(拖动开始时的鼠标位置)等。

示例:(monitor) => ({ isDragging: monitor.isDragging() })

useDrag实战:可拖动的任务卡片

结合上面的知识点,我们实现一个完整的可拖动任务卡片组件,包含拖动状态判断、拖动结束回调等功能:

// DraggableTask.jsx
import React from 'react';
import { useDrag } from 'react-dnd';

// 定义拖动类型(建议用Symbol避免冲突)
export const TASK_TYPE = Symbol('TASK');

function DraggableTask({ id, title, onDragEnd }) {
  const [collectedProps, dragSourceRef] = useDrag({
    // 拖动类型
    type: TASK_TYPE,
    // 拖动数据(只传递必要的id和title)
    item: () => ({ id, title }),
    // 拖动结束回调
    end: (item, monitor) => {
      // 判断拖放是否被目标接受
      if (monitor.didDrop()) {
        // 获取目标返回的结果(如目标列表的id)
        const dropResult = monitor.getDropResult();
        // 调用父组件方法更新数据
        onDragEnd(item.id, dropResult.listId);
      }
    },
    // 收集拖动状态
    collect: (monitor) => ({
      isDragging: monitor.isDragging()
    }),
    // 只有id为偶数的任务可以拖动(示例)
    canDrag: () => id % 2 === 0
  });

  // 根据拖动状态设置样式(拖动时半透明)
  const cardStyle = {
    padding: 16,
    border: '1px solid #ccc',
    margin: 8,
    opacity: collectedProps.isDragging ? 0.5 : 1,
    cursor: collectedProps.isDragging ? 'grabbing' : 'grab'
  };

  return <div ref={dragSourceRef} style={cardStyle}>{title}</div>;
}

export default DraggableTask;

这个例子中,我们实现了以下功能:

  • 只有id为偶数的任务卡片可以被拖动(canDrag配置)。
  • 拖动时卡片显示半透明效果(通过collect获取isDragging状态,动态设置opacity)。
  • 拖动结束后,根据拖放结果调用父组件的onDragEnd方法更新数据(end回调)。

2. useDrop:让组件成为“拖放目标”

useDrag负责“发起”拖放,useDrop则负责“接收”拖放——它将普通组件转化为“拖放目标”,用于接收拖动源传递的数据,并处理拖放相关的逻辑(如悬停、接收拖放)。

基本用法:参数与返回值

useDrop的用法与useDrag类似,都是“传入spec配置,返回核心对象”,具体如下:

const [collectedProps, dropTargetRef] = useDrop(spec, deps);
参数说明
  • spec(必填) :规范对象或返回规范对象的函数,用于配置拖放目标的核心逻辑,是useDrop的核心。
  • deps(可选) :依赖关系数组,作用与useDrag的deps一致,用于备忘录化。
返回值说明

useDrop返回一个包含两个元素的数组:

  • collectedProps(索引0) :从collect函数中收集的属性对象,与useDrag的collectedProps类似,用于获取拖放状态(如是否有元素悬停在目标上)。
  • dropTargetRef(索引1) :拖放目标的连接器函数,必须绑定到组件的DOM元素上,告诉React-DnD“哪个元素是拖放目标”。

核心:spec规范对象详解

useDrop的spec对象与useDrag类似,但核心关注点是“接收拖放”,其中accept是必填项。

必填成员
  • accept(必填) :用于指定当前拖放目标可以接受的拖动源类型,与useDrag的type对应。它可以是字符串、符号,也可以是包含多个类型的数组。
    • 示例:accept: TASK_TYPE(接受类型为TASK_TYPE的拖动源)。
    • 示例:accept: [TASK_TYPE, PROJECT_TYPE](接受两种类型的拖动源)。
可选成员(回调与配置)

drop(item, monitor)(可选) :当兼容类型的拖动源在目标上释放时触发的回调函数,是处理拖放逻辑的核心。

  • item:拖动源传递的核心数据(与useDrag的spec.item一致)。
  • monitor:拖放监控器,可以通过monitor.isOver({ shallow: true })判断是否是直接悬停(而非嵌套目标)。
  • 返回值:可以返回一个对象,该对象会作为拖放结果,通过monitor.getDropResult()传递给拖动源的end回调。
  • 常用场景:接收拖动的任务数据,将其添加到当前列表中。

hover(item, monitor)(可选) :当拖动源悬停在目标上时持续触发的回调函数(即使鼠标不动也会触发)。 常用场景:实现“拖入时高亮目标”“拖动排序”等交互(如在列表中拖动任务时,调整任务的位置)。

注意:即使canDrop返回false,该函数也会触发,可以通过monitor.canDrop()判断当前是否允许接收拖放。

canDrop(item, monitor)(可选) :用于判断当前目标是否允许接收该拖动源的数据。返回true则允许,返回false则禁止。 示例:根据拖动源的id判断是否允许接收(如禁止将任务拖放到自己所在的列表)。

注意:不能在该函数中调用monitor.canDrop()。

collect(monitor, props)(可选) :收集函数,用于从监控器中获取拖放状态,转化为组件的props。 常用监控器方法:monitor.isOver()(是否有元素悬停)、monitor.canDrop()(是否允许接收拖放)等。

示例:(monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop() })

useDrop实战:可接收任务的列表

结合useDrag的任务卡片,我们实现一个可接收任务的列表组件,包含悬停高亮、接收任务等功能:

// TaskList.jsx
import React from 'react';
import { useDrop } from 'react-dnd';
import { TASK_TYPE } from './DraggableTask';

function TaskList({ id, title, tasks, onAddTask }) {
  const [collectedProps, dropTargetRef] = useDrop({
    // 接受TASK_TYPE类型的拖动源
    accept: TASK_TYPE,
    // 接收拖放时的回调
    drop: (item, monitor) => {
      // 调用父组件方法,将任务添加到当前列表
      onAddTask(id, item);
      // 返回拖放结果,传递给拖动源
      return { listId: id };
    },
    // 悬停时的回调
    hover: (item) => {
      // 可以在这里实现拖动排序逻辑(如调整任务在列表中的位置)
      console.log(`任务 ${item.id} 悬停在 ${title} 列表上`);
    },
    // 收集拖放状态
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop()
    })
  });

  // 根据悬停状态和是否允许拖放设置样式
  const listStyle = {
    padding: 16,
    border: collectedProps.isOver && collectedProps.canDrop 
      ? '2px solid #2196F3' 
      : '1px solid #eee',
    margin: 16,
    minHeight: 200
  };

  return (
    <div ref={dropTargetRef} style={listStyle}>
      <h3>{title}</h3>
      {tasks.map(task => (
        <div key={task.id} style={{ padding: 8, borderBottom: '1px solid #eee' }}>
          {task.title}
        </div>
      ))}
    </div>
  );
}

export default TaskList;

这个例子中,我们实现了以下功能:

  • 列表只接受TASK_TYPE类型的拖动源(accept配置)。
  • 当任务卡片悬停在列表上且允许拖放时,列表边框变为蓝色高亮(通过collect获取isOver和canDrop状态)。
  • 接收任务卡片后,调用父组件的onAddTask方法将任务添加到当前列表,并返回列表id给拖动源。

3. 其他实用Hooks:应对复杂场景

除了useDrag和useDrop,React-DnD还提供了两个用于复杂场景的Hooks:useDragLayer和useDragDropManager。

useDragLayer:自定义全局拖动层

当需要实现超越单个组件的拖动预览(如拖动时显示一个覆盖整个页面的提示)时,useDragLayer就派上用场了。它可以创建一个独立于拖动源和目标的“全局拖动层”,不受其他组件的样式影响。

核心用法

useDragLayer只接收一个必填参数collect(收集函数),返回从collect函数中获取的属性对象。collect函数的作用是从监控器中获取拖动状态,用于渲染拖动层的内容。

// CustomDragLayer.jsx
import React from 'react';
import { useDragLayer } from 'react-dnd';

function CustomDragLayer() {
  // 收集拖动状态
  const { item, isDragging } = useDragLayer(monitor => ({
    item: monitor.getItem(),
    isDragging: monitor.isDragging()
  }));

  // 没有拖动时不渲染
  if (!isDragging) return null;

  // 拖动时显示自定义提示
  return (
    <div style={{
      position: 'fixed',
      zIndex: 9999,
      pointerEvents: 'none',
      left: 0,
      top: 0,
      width: '100%',
      height: '100%',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center'
    }}>
      <div style={{ padding: 20, backgroundColor: 'rgba(0,0,0,0.7)', color: 'white' }}>
        正在拖动:{item.title}
      </div>
    </div>
  );
}

export default CustomDragLayer;

这个自定义拖动层会在拖动时显示一个居中的提示框,显示当前拖动的任务标题,且不会影响其他组件的交互(pointerEvents: 'none'确保点击事件能穿透到下层组件)。

useDragDropManager:获取拖放管理器实例

DragDropManager是React-DnD的核心单例对象,包含了拖放系统的状态、监控器、后端等核心资源。useDragDropManager钩子用于获取这个实例,一般用于自定义后端或高级扩展场景(如手动触发拖放事件)。

基本用法
import { useDragDropManager } from 'react-dnd';

function AdvancedComponent() {
  const manager = useDragDropManager();
  // 可以通过manager获取监控器、后端等资源
  const monitor = manager.getMonitor();

  // 高级用法:手动监听拖动状态变化
  React.useEffect(() => {
    const unsubscribe = monitor.subscribeToStateChange(() => {
      console.log('拖放状态变化:', monitor.isDragging());
    });
    return () => unsubscribe();
  }, [monitor]);

  return <div>高级扩展组件</div>;
}

对于大多数普通开发场景,我们很少会直接使用useDragDropManager,除非需要深度定制React-DnD的行为。

三、总结

这一篇我们深入讲解了React-DnD的核心组件和Hooks,核心要点总结如下:

  • 组件:DndProvider是基础(提供拖放上下文),DragPreviewImage用于自定义预览。
  • Hooks:useDrag定义拖动源,useDrop定义拖放目标,两者通过type和accept匹配;useDragLayer用于全局预览,useDragDropManager用于高级扩展。
  • 核心逻辑:拖动源通过item传递数据,拖放目标通过drop接收数据,两者通过monitor实现状态通信。

掌握了这些内容,你已经能实现大多数常见的拖放场景了。

Vite 5.x 开发模式启动流程分析

Vite 5.x 开发模式启动流程分析

Vite 作为新一代前端构建工具,其核心优势在于开发模式下的极速启动热模块替换(HMR)能力。与 Webpack 等传统构建工具的“先打包再启动”模式不同,Vite 基于 ES 模块(ESM)的原生支持,采用“按需编译”策略,大幅提升开发体验。本文将详细拆解 Vite 5.x 版本在开发模式下的首次启动流程代码更新流程

一、核心前置知识

在分析流程前,需明确 Vite 开发模式的两个核心设计:

  1. 原生 ESM 支持:现代浏览器已原生支持 import/export,Vite 直接将项目源码以 ESM 格式交给浏览器,避免传统构建工具的全量打包过程。

  2. 按需编译:仅当浏览器请求某个模块时,Vite 才会对该模块进行编译(如 TypeScript 转 JS、Sass 转 CSS 等),而非启动时编译所有文件。

  3. 依赖预构建:对第三方依赖(如 node_modules 中的包)进行预构建,将非 ESM 格式的依赖转为 ESM 格式,并合并重复依赖,减少请求次数。

二、首次启动流程(开发模式)

首次启动是指项目从“未运行”到“浏览器可访问”的完整过程,核心分为「依赖预构建」「服务启动」「页面请求与模块编译」三个阶段,共 8 个关键步骤。以下以 Vue 3 + TypeScript 项目(初始化命令:npm create vite@latest my-vue-app -- --template vue-ts)为例进行说明。

阶段 1:依赖预构建(启动前的准备)

依赖预构建是 Vite 首次启动的核心优化步骤,目的是解决第三方依赖的兼容性和性能问题,仅在首次启动或依赖变动时执行。

步骤 1:解析依赖图谱

Vite 启动时会先读取项目根目录的 package.json,识别 dependencies 中的第三方依赖(如 vue@vue/compiler-sfc 等),并通过 esbuild 快速解析这些依赖的依赖图谱(即依赖的依赖,如 vue 依赖的 @vue/runtime-core)。解析完成后,会生成依赖关系数据并暂存于内存,同时为后续预构建产物生成提供依据,最终体现在预构建阶段输出的 node_modules/.vite/_metadata.json 缓存文件中。

示例:在 Vue 3 项目中,Vite 会解析出 vue 及其关联的运行时、编译器等子依赖,形成完整的依赖链。该依赖链信息会被记录到 _metadata.jsondependencyGraph 字段中,示例如下:


{
  "version": "5.0.0",
  "dependencyGraph": {
    "vue": {
      "imports": ["@vue/runtime-core", "@vue/runtime-dom"],
      "exports": ["createApp", "ref", "reactive"],
      "file": "node_modules/.vite/deps/vue.js"
    },
    "vue-router": {
      "imports": ["vue"],
      "exports": ["createRouter"],
      "file": "node_modules/.vite/deps/vue-router.js"
    }
  },
  "optimized": {
    "vue": {
      "src": "node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "file": "node_modules/.vite/deps/vue.js",
      "hash": "1a2b3c"
    }
  }
}

该缓存文件中的依赖图谱信息,会用于后续启动时快速校验依赖是否变动(如子依赖版本更新会导致 dependencyGraph 变化),从而决定是否需要重新执行预构建。

步骤 2:预构建非 ESM 依赖

部分第三方依赖(如一些老的 npm 包)仍采用 CommonJS 格式(module.exports/require),浏览器无法直接识别。Vite 会通过 esbuild 将这些非 ESM 依赖转为 ESM 格式。

:若项目中引入了采用 CommonJS 格式的 lodash@4.17.21,Vite 会将其编译为 ESM 格式,生成可被浏览器直接导入的代码:


// 编译前(CommonJS)
module.exports = {
  debounce: function(func, wait) { ... }
};

// 编译后(ESM)
export function debounce(func, wait) { ... };
步骤 3:生成预构建产物

预构建后的依赖产物会被存入项目根目录的 node_modules/.vite/deps 目录中(Vite 2.x 为 node_modules/.vite,Vite 3.x 及以上版本统一迁移至 deps 子目录),同时生成 node_modules/.vite/_metadata.json 缓存文件(替代旧版本的 deps_cache.json),用于后续启动时判断依赖是否变动(若未变动则跳过预构建)。

示例node_modules/.vite/ deps/ vue.js 即为 vue 预构建后的 ESM 产物,可直接被浏览器导入。

步骤 4:合并重复依赖( deduplication )

若多个依赖同时依赖某个子依赖(如 vue-routerpinia 都依赖 vue),Vite 会将重复的子依赖合并为一个模块,避免浏览器重复请求。

vue-router@4pinia@2 均依赖 vue@3,预构建时会将 vue 抽离为单独模块,供两者共同引用。具体引用逻辑如下:

// 1. 预构建前:vue-router 和 pinia 各自内部引用 vue
// vue-router 内部代码(简化)
import Vue from './node_modules/vue/dist/vue.runtime.esm-bundler.js'
export function createRouter() { /* 依赖 Vue 实现逻辑 */ }

// pinia 内部代码(简化)
import Vue from './node_modules/vue/dist/vue.runtime.esm-bundler.js'
export function createPinia() { /* 依赖 Vue 实现逻辑 */ }

// 2. 预构建后:合并为共同引用预构建的 vue 模块
// 预构建产物:node_modules/.vite/deps/vue.js(单独模块,Vite 5.x 路径)
export * from 'vue'

// 预构建后 vue-router 产物(简化)
import { ref, reactive } from '/node_modules/.vite/deps/vue.js?v=1a2b3c'
export function createRouter() { /* 依赖共享的 Vue API 实现逻辑 */ }

// 预构建后 pinia 产物(简化)
import { ref, reactive } from '/node_modules/.vite/deps/vue.js?v=1a2b3c'
export function createPinia() { /* 依赖共享的 Vue API 实现逻辑 */ }

通过合并,浏览器仅需请求一次 /node_modules/.vite/deps/vue.js 即可满足两个依赖的需求,避免了重复请求导致的性能损耗。

阶段 2:开发服务器启动

依赖预构建完成后,Vite 会启动一个基于 connect 的开发服务器,用于处理浏览器的请求、提供模块编译服务和 HMR 支持。

步骤 5:初始化服务器配置

Vite 读取项目中的 vite.config.ts(或 .js)配置文件,初始化服务器参数,如端口(默认 5173)、代理(server.proxy)、跨域(server.cors)等。

:若配置了代理解决跨域问题:

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

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000', // 后端服务地址
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
});

Vite 会将服务器的 /api 路径请求代理到 http://localhost:3000

步骤 6:启动服务器并监听端口

基于 connect 启动 HTTP 服务器,监听配置的端口(默认 5173),同时注册一系列核心中间件。从本质上来说,中间件是拦截并处理 HTTP 请求的“管道式”函数——浏览器的请求会按顺序流经各个中间件,每个中间件完成特定职责(如编译、缓存、代理)后,要么将请求传递给下一个中间件,要么直接返回响应结果,类似工厂流水线中“各司其职、依次处理”的工序。其核心特性是“职责单一”和“顺序执行”,通过组合不同中间件实现复杂的请求处理逻辑,也让功能扩展更灵活(如新增预处理语法支持时,仅需添加对应编译中间件)。

为更直观理解中间件的“管道式”工作逻辑,以下通过模拟 Vite 核心中间件的简化代码,展示请求从接收至响应的流转过程(基于 connect 中间件机制):

// 模拟 Vite 开发服务器中间件管道(简化版)
import connect from 'connect';
const app = connect(); // 创建 connect 服务器实例

// 1. 日志中间件(模拟请求入口记录)
app.use((req, res, next) => {
  console.log(`[请求接收] ${new Date().toLocaleTimeString()} - ${req.url}`);
  next(); // 调用 next() 传递给下一个中间件
});

// 2. 静态资源中间件(模拟处理图片等静态资源)
app.use((req, res, next) => {
  const staticExts = ['.png', '.jpg', '.svg'];
  const isStatic = staticExts.some(ext => req.url.endsWith(ext));
  if (isStatic) {
    // 模拟读取静态文件并返回
    res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
    res.end('<svg width="100" height="100"><circle cx="50" cy="50" r="40" fill="#42b983"/></svg>');
  } else {
    next(); // 非静态资源,传递给下一个中间件
  }
});

// 3. 模块编译中间件(模拟处理 .vue 模块)
app.use((req, res, next) => {
  if (req.url.endsWith('.vue')) {
    // 模拟 Vue 组件编译:模板转渲染函数 + 脚本处理
    const componentName = req.url.split('/').pop().replace('.vue', '');
    const compiledCode = `
      import { h } from '/node_modules/.vite/deps/vue.js';
      export default {
        name: '${componentName}',
        render() { return h('div', '编译后的${componentName}组件'); }
      }
    `;
    // 返回编译后的 ESM 代码
    res.writeHead(200, { 'Content-Type': 'application/javascript' });
    res.end(compiledCode);
  } else {
    next(); // 非 .vue 模块,传递给下一个中间件
  }
});

// 4. 错误处理中间件(捕获后续中间件抛出的错误)
app.use((err, req, res, next) => {
  console.error(`[请求错误] ${err.message}`);
  res.writeHead(500, { 'Content-Type': 'text/plain' });
  res.end(`服务器错误:${err.message}`);
});

// 启动服务器
app.listen(5173, () => {
  console.log('开发服务器启动:http://localhost:5173');
});

上述代码核心逻辑与 Vite 实际中间件机制一致:

  • 通过 app.use() 按顺序注册中间件,请求会依次流经日志→静态资源→模块编译中间件;

  • 每个中间件通过 next() 传递请求,若能处理当前请求(如静态资源中间件处理 .svg 请求)则直接返回响应;

  • 错误处理中间件通过特殊的四参数函数定义,可捕获前序中间件抛出的异常并统一处理。

核心中间件及其作用如下:

  • HMR 中间件(热更新中间件):核心作用是建立并维护 WebSocket 长连接,实时向浏览器推送文件变更通知(如代码修改、新增文件),同时接收浏览器的 HMR 状态反馈;当检测到无法热更新的场景时,触发全页刷新逻辑。

  • 模块编译中间件:开发模式的核心中间件,负责拦截浏览器对模块的请求(如 .ts、.vue、.scss 文件),调用对应处理器(如 esbuild、@vitejs/plugin-vue)完成编译、转译和路径重写,将处理后的 ESM 代码或 CSS 内容返回给浏览器;同时会缓存编译结果到内存,提升重复请求的响应速度。

  • 静态资源中间件:处理图片、字体、JSON 等静态资源的请求,直接读取项目根目录下的静态文件并返回;支持对小资源(如小于 4KB 的图片)自动转为 Base64 编码,减少 HTTP 请求次数。

  • HTML 处理中间件:专门处理入口 HTML 文件(index.html)的请求,完成脚本标签改造(补充 type="module"、注入 /@vite/client)、环境变量注入等操作,确保返回的 HTML 能正确触发后续模块请求。

  • 代理中间件:根据 vite.config.ts 中的 server.proxy 配置,将特定路径的请求(如 /api)转发到目标服务器(如后端开发服务),并处理跨域相关的请求头(如 changeOrigin),解决前端开发中的跨域问题。

  • 缓存控制中间件:为不同类型的响应设置合理的缓存策略,例如对预构建产物(node_modules/.vite/deps 下的文件)添加强缓存头,对源码编译后的模块添加协商缓存头,平衡缓存效率与更新及时性。

  • 错误处理中间件:捕获请求处理过程中的异常(如模块编译失败、文件不存在),将错误信息格式化(如转为友好的页面级错误提示或控制台日志)后返回给浏览器,帮助开发者快速定位问题。

这些中间件按“请求接收→缓存校验→静态资源判断→模块编译/代理转发→热更新通知→响应返回”的流程协同工作,确保开发模式下的请求处理高效且可靠。

示例:启动成功后,终端会输出以下信息,提示服务器已就绪,所有中间件均已完成初始化:

  VITE v5.0.0 ready in 300 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

阶段 3:页面请求与模块编译

服务器启动后,需等待用户在浏览器中访问地址(如 http://localhost:5173),才会触发后续的页面渲染和模块编译流程(按需编译的核心体现)。

步骤 7:处理入口 HTML 请求

当用户在浏览器中访问服务器地址时,浏览器首先请求项目的入口 HTML 文件(默认是 index.html)。Vite 会读取根目录的 index.html,并对其中的 script 标签进行改造:将指向源码的 src 路径改为服务器可识别的绝对路径,确保未添加 type="module" 标识时自动补充(因原生 ESM 需该标识才能被浏览器解析),同时注入热更新相关的客户端脚本(/@vite/client),为后续 HMR 功能做准备。

示例:项目根目录原始 index.html 内容:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" type="image/svg+xml" href="/vite.svg">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite + Vue + TS</title>
</head>
<body>
  <div id="app"></div>
  <script src="/src/main.ts"></script>
</body>
</html>

示例:Vite 处理后返回给浏览器的 HTML 内容(关键改造处标红):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="icon" type="image/svg+xml" href="/vite.svg">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite + Vue + TS</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/@vite/client"></script><script type="module" src="/src/main.ts"></script>
</body>
</html>
步骤 8:编译入口模块并处理依赖路径

当浏览器通过处理后的 index.html 发起入口模块请求(如 /src/main.ts)时,Vite 的模块编译中间件会拦截该请求,按“类型识别→语法转译→路径重写→返回结果”的完整流程处理,核心依托 esbuild 实现毫秒级编译。

1. 模块类型识别与处理逻辑匹配

Vite 通过请求路径的后缀(如 .ts)快速识别模块类型,自动匹配预设处理逻辑:TypeScript 模块默认使用内置 esbuild 转译器,Vue 组件依赖 @vitejs/plugin-vue,样式文件则根据后缀匹配 Sass/LESS 等预处理插件(若已配置)。

2. 语法转译与依赖路径重写

这是入口模块编译的核心环节,针对 TypeScript 模块主要完成两项工作:

  • 语法转译esbuild 仅对 TypeScript 进行语法层面转译,剔除类型注解、接口定义等 TS 特有语法,保留 ES6+ 语法(现代浏览器已原生支持),不执行类型检查(类型校验交给 IDE 或 tsc --noEmit 单独执行,提升编译速度);

  • 依赖路径重写:将源码中第三方依赖的简洁路径(如 import { createApp } from 'vue')重写为预构建产物的绝对路径(如 /node_modules/.vite/vue.js?v=1a2b3c),既避免浏览器直接访问 node_modules 目录的权限问题,又通过 v=1a2b3c 这类缓存标识实现后续更新的缓存失效控制。

示例:main.ts 编译前后对比

// 编译前(项目源码:src/main.ts)
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

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

// 编译后(Vite 返回给浏览器的 ESM 代码)
import { createApp } from '/node_modules/.vite/vue.js?v=1a2b3c' // 重写预构建产物路径
import './style.css' // 相对路径保留,将触发后续样式请求
import App from './App.vue' // Vue 组件路径,将触发后续组件请求

createApp(App).mount('#app')
3. 结果返回与依赖请求触发

Vite 将编译后的 ESM 代码通过 HTTP 响应返回给浏览器,浏览器解析该代码时,会立即识别到 ./style.css./App.vue 两个未加载的依赖,自动向 Vite 服务器发起新的请求,由此进入依赖模块的递归编译流程。

步骤 9:递归编译依赖模块(按需编译核心体现)

Vite 的“按需编译”核心就体现在递归处理依赖请求的过程中——仅当浏览器请求某个依赖时才对其编译,而非启动时全量编译所有文件。以下针对前端项目中最常见的两类依赖模块,详细说明编译流程:

1. Vue 单文件组件(SFC)编译(以 App.vue 为例)

当浏览器请求 /src/App.vue 时,@vitejs/plugin-vue 插件会主导编译过程,将 SFC 拆分为模板、脚本、样式三部分分别处理后再组合为 ESM 模块:

<!-- 编译前(项目源码:src/App.vue) -->
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div class="app">
    <HelloWorld msg="Hello Vite + Vue" />
  </div>
</template>

<style scoped>
.app {
  text-align: center;
  padding: 2rem;
  background: #f5f5f5;
}
</style>

编译核心步骤

  1. 模板编译:将<template>标签中的HTML结构转为Vue可执行的渲染函数(render函数),例如上述模板会转为() => h('div', { class: 'app' }, [h(HelloWorld, { msg: 'Hello Vite + Vue' })])

  2. 脚本编译:对<script setup lang="ts">语法糖进行解糖处理,转为普通ESM导出格式,同时重写HelloWorld组件的引入路径;

  3. 样式编译:为<style scoped>中的样式规则添加作用域哈希(如.app转为.app_123abc),避免组件间样式污染,同时生成独立的样式请求路径(如/src/App.vue?v=1a2b3c&type=style&scoped);

  4. 组合导出:将编译后的模板(渲染函数)、脚本(组件逻辑)、样式(请求路径)整合为一个ESM模块,返回给浏览器并触发HelloWorld.vue和样式文件的后续请求。

编译后简化代码 示例


// 导入预构建依赖和子组件
import { defineComponent, h } from '/node_modules/.vite/vue.js?v=1a2b3c'
import HelloWorld from './components/HelloWorld.vue'
// 引入编译后的作用域样式
import '/src/App.vue?v=1a2b3c&type=style&scoped'

// 模板转译后的渲染函数
const render = () => h('div', { class: 'app_123abc' }, [
  h(HelloWorld, { msg: 'Hello Vite + Vue' })
])

// 组合为 Vue 组件并导出
export default defineComponent({
  name: 'App',
  components: { HelloWorld },
  render
})
2. 样式文件编译(以 style.css 为例)

当浏览器请求样式文件时,Vite 会根据文件类型执行对应处理,普通 CSS 和 SCSS/LESS 等预处理文件的处理流程如下:

  • 若为普通CSS文件,直接读取文件内容,添加必要的浏览器前缀(若配置autoprefixer)后返回;

  • 若为Sass/LESS等预处理文件,先安装对应插件(如SCSS需安装sassvite-plugin-sass),插件会将预处理语法编译为普通CSS后返回;

  • 最终浏览器会将返回的CSS内容通过<style>标签注入页面,无需像传统构建工具那样打包为单独的CSS文件。

示例1:普通CSS文件编译与注入

// 编译前(项目源码:src/style.cssbody {
  margin: 0;
  font-family: 'Inter', sans-serif;
  color: #333;
}

// 编译后返回的 CSS 内容
body {
  margin: 0;
  font-family: 'Inter', sans-serif;
  color: #333;
}

// 浏览器自动注入页面的 DOM 结构
<style>
body {
  margin: 0;
  font-family: 'Inter', sans-serif;
  color: #333;
}
</style>

示例2:SCSS文件编译与注入(需提前配置)

首先需安装依赖:npm install sass vite-plugin-sass --save-dev,并在vite.config.ts中配置插件:

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import sass from 'vite-plugin-sass';

export default defineConfig({
  plugins: [vue(), sass()]
});

SCSS文件编译流程:

// 编译前(项目源码:src/style.scss,含变量和嵌套语法)
$primary-color: #42b983;
$font-size: 16px;

body {
  margin: 0;
  font-family: 'Inter', sans-serif;
  font-size: $font-size;
  color: #333;

  .app-container {
    background: $primary-color;
    padding: 2rem;
  }
}

// 编译后返回的 CSS 内容(预处理语法解析完成)
body {
  margin: 0;
  font-family: 'Inter', sans-serif;
  font-size: 16px;
  color: #333;
}
body .app-container {
  background: #42b983;
  padding: 2rem;
}

// 浏览器自动注入页面的 DOM 结构
<style>
body {
  margin: 0;
  font-family: 'Inter', sans-serif;
  font-size: 16px;
  color: #333;
}
body .app-container {
  background: #42b983;
  padding: 2rem;
}
</style>
Vite的moduleGraph(内存缓存

实时编译结果存储在哪里?答案是 Vite 内部的 moduleGraph(内存缓存)。这个缓存的作用是:

当浏览器短时间内重复请求同一模块(比如刷新页面),且模块内容未修改时,Vite 不需要重新执行编译(转译、插件处理等),直接从内存缓存中读取上次的编译结果,快速返回给浏览器。

示例:

  1. 第一次请求 src/App.vue:Vite 实时编译(解析模板、转译脚本)→ 内存缓存编译结果 → 返回给浏览器(带 ETag)→ 浏览器存储该模块的缓存。
  2. 未修改文件,再次刷新页面请求 src/App.vue:Vite 从内存缓存中直接取编译结果 → 对比浏览器请求的 If-None-Match → 返回 304 → 浏览器复用本地缓存,无需重新下载。
  3. 修改 src/App.vue 后刷新:Vite 检测到文件变化 → 重新编译 → 更新内存缓存 → 返回新的编译结果(新 ETag)→ 浏览器下载并替换旧缓存,显示最新效果。
步骤 10:所有模块加载完成并渲染页面

随着依赖模块的递归编译和加载,浏览器会逐步获取页面渲染所需的全部资源(TS/JS 模块、Vue 组件、样式文件、静态资源等),最终执行入口模块的渲染逻辑,完成页面构建。整个流程的收尾环节如下:

  1. 应用实例化:浏览器执行入口模块中的createApp(App).mount('#app')代码,基于编译后的Vue根组件App创建应用实例;

  2. 虚拟DOM挂载:Vue框架通过组件的渲染函数生成虚拟DOM,再将虚拟DOM转换为真实DOM并挂载到页面的#app节点;

  3. 资源全局就绪:所有样式通过<style>标签注入生效,图片、字体等静态资源通过直接请求加载完成,页面呈现最终效果;

  4. HMR客户端初始化:此前注入的/@vite/client脚本完成初始化,通过WebSocket与Vite开发服务器建立长连接,随时等待后续代码更新的通知。

示例:启动完成的标识

  • 浏览器控制台会输出 Vite HMR 客户端的初始化日志:[vite] connected.

  • 终端会显示浏览器连接成功的提示:➜ Local: http://localhost:5173/,此时开发环境正式就绪,支持代码热更新。

三、代码更新流程(热模块替换 HMR)

首次启动后,开发者修改代码时,Vite 不会重启开发服务器或刷新整个页面,而是通过「热模块替换(HMR)」机制仅更新修改的模块,实现毫秒级更新响应。其核心原理是通过 WebSocket 建立服务器与浏览器的长连接,实时推送模块变更信息,避免全页刷新导致的开发状态丢失。

以下将修改 src/components/HelloWorld.vue 中的文本内容(将 msg 属性值从 Hello Vite + Vue 改为 Hello Vite 5.x + Vue 3)为例

  1. (修改前)
<script setup lang="ts">
  const msg = 'Hello Vite + Vue';
</script>

<template>
  <h1>{{ msg }}</h1> 
</template>
  1. 修改组件的msg值(触发热更新)
<script setup lang="ts">
    // msg值修改为新内容
    const msg = 'Hello Vite 5.x + Vue 3';
</script>

<template>
  <h1>{{ msg }}</h1> 
</template>

拆解代码更新的 5 个关键步骤:

步骤 1:文件变更监听

Vite 启动后会通过 chokidar 库(高效的文件监听工具)实时监听项目源码目录(默认是 src 目录)的文件变化,包括文件的修改、新增、删除以及重命名等操作。当开发者修改 HelloWorld.vue 并保存后,chokidar 会立即捕获到该文件的修改事件,并将文件路径等信息传递给 Vite 核心处理逻辑。

步骤 2:变更模块定位与重新编译

Vite 收到文件变更事件后,会根据文件路径快速定位到对应的模块(即 HelloWorld.vue),并触发该模块的重新编译流程。重新编译的逻辑与首次启动时的模块编译完全一致:对于 Vue 组件,@vitejs/plugin-vue 会重新拆解 SFC 并编译模板、脚本、样式;对于 TypeScript 或样式文件,也会执行与首次编译相同的转译和处理逻辑,确保输出的模块代码与当前源码一致。

:重新编译 HelloWorld.vue 后,模板中的 msg 内容会更新为 Hello Vite 5.x + Vue 3,对应的渲染函数也会同步修改。

步骤 3:生成模块变更标识(hash)

为了让浏览器能够准确识别模块是否发生更新,Vite 会为重新编译后的模块生成一个唯一的哈希(hash)标识。该哈希值基于模块的内容计算得出,只要模块内容发生变化,哈希值就会随之改变。同时,Vite 会更新内存中的模块映射表,将新的哈希值与模块路径关联,便于后续浏览器请求时的身份校验。

:修改后的 HelloWorld.vue 对应的请求路径会变为 /src/components/HelloWorld.vue?hash=abc123,其中 abc123 就是新的哈希标识。

步骤 4:WebSocket 推送变更通知

Vite 开发服务器通过 WebSocket 长连接向浏览器端的 HMR 客户端推送模块变更通知。通知信息是一个结构化的 JSON 数据,主要包含以下核心字段:

  • type:更新类型,如 update 表示模块更新、delete 表示模块删除;

  • updates:变更模块列表,每个元素包含模块路径(path)、新哈希值(hash)以及可接受更新的模块路径(acceptedPath)等信息。

示例:推送的变更通知数据(简化版)

{
  "type": "update",
  "updates": [
    {
      "path": "/src/components/HelloWorld.vue",
      "hash": "abc123",
      "acceptedPath": "/src/components/HelloWorld.vue",
      "type": "js"
    }
  ]
}
步骤 5:浏览器执行热替换逻辑

浏览器端的 Vite HMR 客户端(即 /@vite/client 脚本)收到 WebSocket 推送的变更通知后,会按以下流程执行热模块替换:

  1. 请求新模块代码:根据通知中的模块路径和新哈希值,向 Vite 服务器发起新模块的请求(如 GET /src/components/HelloWorld.vue?hash=abc123),获取重新编译后的模块代码;

  2. 模块替换与状态保留:由对应的框架插件(如 @vitejs/plugin-vue)提供热替换逻辑,将页面中的旧模块实例替换为新模块实例。对于 Vue 组件,会销毁旧的组件实例,创建新的组件实例并重新渲染对应的 DOM 节点,同时尽可能保留组件的局部状态(如输入框中的内容);

  3. 失败降级处理:若模块替换失败(如修改了入口文件 main.ts 这类无法单独热更新的模块,或插件未提供对应的热替换逻辑),HMR 客户端会自动降级为全页刷新,确保页面内容与源码一致。

:浏览器获取新的 HelloWorld.vue 模块后,@vitejs/plugin-vue 的热替换逻辑会仅重渲染 HelloWorld 组件对应的 DOM 节点,页面上的文本会从 Hello Vite + Vue 变为 Hello Vite 5.x + Vue 3,而页面中其他组件的状态(如顶部导航栏的选中状态、输入框中的内容)不会受到任何影响。

四、Vite 5.x 开发模式关键优化点总结

Vite 5.x 开发模式的极速体验得益于其底层的四大核心优化设计,这些设计也是其与传统构建工具(如 Webpack)的核心差异:

  1. 依赖预构建 + esbuild 加速:利用 esbuild 的极速编译能力(比传统 JS 转译器快 10-100 倍),将第三方依赖转为 ESM 格式并合并重复依赖,减少首次启动时的编译耗时;同时通过缓存机制,二次启动时直接复用预构建产物,跳过重复工作。

  2. 按需编译减少无效工作:仅在浏览器请求模块时才执行编译,避免传统工具“启动时全量打包”的无效工作,尤其对于大型项目,首次启动速度提升极为明显。

  3. HMR 精准更新保留开发状态:通过 WebSocket 实时推送变更,仅更新修改的模块而非全页刷新,既提升了更新速度,又保留了开发者的工作状态(如表单输入、组件状态),大幅提升开发效率。

  4. 内存缓存复用编译结果:所有编译后的模块都会缓存到内存中,当浏览器再次请求同一模块时(如页面刷新后),Vite 直接从内存中返回编译结果,无需重复编译,进一步减少响应时间。

五、常见问题与解决方案(内容由AI生成)

1. 首次启动比二次启动慢很多?

原因:首次启动需要执行依赖预构建流程,将第三方依赖转为 ESM 并生成缓存;二次启动时,Vite 会读取 node_modules/.vite 目录中的缓存文件,跳过预构建步骤,因此启动速度更快。

解决方案:这是正常现象,无需特殊处理。若需强制重新执行预构建,可删除 node_modules/.vite 目录,或执行命令 npx vite --force

2. HMR 热更新失效,修改代码后页面无反应?

可能原因

  • 修改了无法单独热更新的模块,如入口文件 main.ts、全局状态管理文件等;

  • 框架插件版本与 Vite 5.x 不兼容(如 @vitejs/plugin-vue 版本过低);

  • WebSocket 连接失败(如端口被占用、网络环境限制)。

解决方案

  • 检查修改的模块是否为可热更新模块,入口文件等核心模块修改后需手动刷新页面;

  • 升级框架插件至与 Vite 5.x 兼容的版本(如 @vitejs/plugin-vue@5.x);

  • 查看浏览器控制台是否有 WebSocket 连接失败的错误,尝试重启服务器或更换端口(通过 vite.config.tsserver.port 配置)。

3. 开发模式下 TypeScript 类型错误未被检测到?

原因:Vite 开发模式下的 TypeScript 转译仅做语法转译,不执行类型检查,目的是提升编译速度。类型检查工作默认由 IDE(如 VS Code)实时执行。

解决方案:在 package.json 中添加类型检查脚本:"type-check": "tsc --noEmit",开发过程中可通过 npm run type-check 手动执行类型检查,或在 CI/CD 流程中加入该步骤确保代码类型正确。

六、对按需加载的理解补充:无需加载与延迟加载场景

基于 Vite 「按需编译+动态引入」的核心设计,首次启动时存在大量无需加载的文件,部分文件会在后续代码更新时因引用关系激活而被首次加载,包含两类核心场景。

1. 首次启动无需加载的文件场景

首次启动仅加载页面初始渲染必需的资源,未被依赖或非渲染相关的文件均不会触发加载,具体分为三类场景:

1.1 未被任何模块引入的源码文件

项目 src 目录中存在但未被入口模块或依赖链关联的文件,完全不会触发请求和编译,是最常见的无需加载场景:

  • 独立未引用组件:新建的 src/components/UnusedComponent.vue 未在 App.vue 或其他业务组件中通过 import 引入,浏览器无请求,Vite 不编译;

  • 冗余工具函数src/utils/legacy-utils.ts 包含历史工具函数,但所有业务代码均未调用,处于“定义未使用”状态;

  • 未注册路由组件:路由配置文件中未注册的页面组件(如 src/views/TestPage.vue 未加入 routes 数组),即使存在也不会被加载。

1.2 已预构建但未引入的第三方依赖

首次启动时 Vite 会对 package.jsondependencies 所有第三方依赖执行预构建,但仅当源码实际引入时才会被浏览器请求:

  • 安装后未使用的依赖:通过 npm install axios 安装后,未在任何源码中写 import axios from 'axios',其预构建产物 node_modules/.vite/deps/axios.js 不会被请求;

  • 按需引入库的未使用部分:使用 lodash-es 时仅引入 import debounce from 'lodash-es/debounce',则 lodash-es 其他函数(如 throttle)的预构建相关代码不会被加载。

1.3 非渲染相关的配置与辅助文件

项目根目录或子目录中用于配置、文档、构建等目的的文件,仅在 Vite 启动时被读取配置或完全不参与开发流程,不会被浏览器请求:

  • 配置文件vite.config.tstsconfig.json.eslintrc.js 等,仅在 Vite 初始化时解析配置,不进入浏览器渲染流程;

  • 文档与日志README.mdCHANGELOG.mdlogs/ 目录下的日志文件,与前端渲染完全无关;

  • 构建产物与缓存dist/ 目录(构建产物)、node_modules/.vite/cache/ 目录(预构建缓存),仅在构建或预构建时使用,不被浏览器请求。

2. 首次启动未加载、代码更新时加载的场景

这类文件本身存在且引用关系/内容未变,但因首次启动时未满足加载条件,在后续代码更新触发引用关系激活后才被首次加载,核心驱动力是“动态引入”和“条件激活”:

2.1 路由懒加载的非初始页面

Vue Router、React Router 等支持的路由懒加载,是最典型的延迟加载场景,首次启动仅加载首页路由,其他路由组件在代码更新激活跳转后加载:

  1. 首次启动未加载:路由配置中通过 () => import() 定义非首页路由,首次启动仅加载 / 对应组件,其他路由组件未被请求:// 路由配置(首次启动仅加载 Home.vue)

    
    const routes = [
      { path: '/', component: () => import('./Home.vue') }, // 初始加载
      { path: '/about', component: () => import('./About.vue') } // 首次未加载
    ];
    
  2. 代码更新触发加载:修改 Home.vue 新增“关于页”跳转按钮(仅更新页面内容,About.vue 引用关系和内容不变):

    
     <!-- Home.vue 代码更新:新增跳转按钮 -->
    <template>
      <div>
        <h1>首页</h1>
        <router-link to="/about">去关于页</router-link> <!-- 新增跳转 -->
      </div>
    </template>
    

    Vite 触发 HMR 更新 Home.vue 后,用户点击跳转按钮时,浏览器会首次请求 About.vue 并完成编译加载。

2.2 条件渲染触发的动态引入组件

首次启动时不满足渲染条件的组件,通过动态引入方式定义,在代码更新调整条件后被激活加载:

  1. 首次启动未加载App.vue 中通过条件判断动态引入弹窗组件,首次启动时 showModalfalseModal.vue 未被请求:<script setup>

    
    import { ref } from 'vue';
    const showModal = ref(false); // 初始为false,不触发引入
    const openModal = async () => {
      showModal.value = true;
      const { default: Modal } = await import('./components/Modal.vue'); // 动态引入
      // 渲染弹窗逻辑
    };
    </script>
    
  2. 代码更新触发加载:修改 App.vue 新增“打开弹窗”按钮(无需修改 Modal.vue),用户点击按钮后 showModal 变为 true,触发 Modal.vue 首次请求和加载。

2.3 组件库按需引入的新增组件

使用 Element Plus、Ant Design Vue 等支持按需引入的组件库时,首次启动仅加载已使用组件,代码更新新增组件引用后触发未加载组件的加载:

  1. 首次启动未加载:首次启动仅使用 ElButton,按需引入插件仅编译加载 ElButton 相关代码,ElTable 等未使用组件未被加载;

  2. 代码更新触发加载:修改 TablePage.vue 新增 <ElTable> 组件并补充引入代码 import { ElTable } from 'element-plus',Vite 会在 HMR 时识别新增引用,触发 ElTable 及其依赖的首次加载(组件库本身内容未变,仅引用关系激活)。

3. 核心结论

Vite 开发模式的加载逻辑始终围绕「按需」核心:首次启动仅为“初始渲染”服务,未被依赖的文件均无需加载;而后续代码更新时,只要通过修改代码激活了新的引用关系(如新增跳转、调整渲染条件),即使文件本身内容未变,也会被首次加载。这一特性既保证了首次启动的极速体验,又兼顾了开发过程中动态扩展的灵活性。

了解响应式Web设计:viewport网页可视区域

在移动设备普及的今天,我们访问同一个网页时,可能会在手机、平板、笔记本电脑等不同尺寸的设备上获得截然不同的浏览体验。这背后离不开两个关键技术:响应式Web设计和viewport可视区域控制。本文将深入探讨这两个概念,帮助你构建真正适配多设备的现代网页。

响应式Web设计

响应式Web设计(Responsive Web Design)是一种网页设计方法,使网站能够响应不同设备的屏幕尺寸、方向和分辨率,自动调整布局和内容呈现方式,以提供最佳的用户体验。

Viewport:网页的可视区域

概念

Viewport(视口)是指用户在网页上可见的区域。在桌面浏览器中,viewport就是浏览器窗口的可见区域。但在移动设备上,情况就复杂得多。

移动设备分类

  • 布局viewport:网页实际渲染的区域
  • 可视viewport:用户在屏幕上看到的区域

!!注意:

在没有设置viewport的情况下,移动浏览器会默认使用一个较宽的布局viewport(通常约980px),然后将整个网页缩放至屏幕宽度,导致文字过小,用户需要缩放才能阅读。

Viewport元标签

为了解决移动设备上的显示问题,我们需要使用viewport元标签:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Viewport示例</title>
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

Viewport属性详解

viewport元标签的content属性可以包含多个值,用逗号分隔:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

常用属性说明

属性 取值 说明
width device-width 设备宽度(推荐)
具体像素值 width=600
height device-height 设备高度
具体像素值 设置布局视口的高度(很少使用)
initial-scale 数值 初始缩放比例,通常设为 1.0
minimum-scale 数值 允许的最小缩放比例
maximum-scale 数值 允许的最大缩放比例
user-scalable yes 允许缩放
no 禁止缩放

实际应用示例

示例1:基础Viewport设置

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>基础Viewport设置</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 20px;
            line-height: 1.6;
        }
        
        .container {
            max-width: 100%;
        }
        
        h1 {
            color: #333;
            border-bottom: 2px solid #3498db;
            padding-bottom: 10px;
        }
        
        .box {
            background: #f9f9f9;
            border: 1px solid #ddd;
            padding: 15px;
            margin: 10px 0;
            border-radius: 5px;
        }
        
        @media (min-width: 768px) {
            .container {
                max-width: 750px;
                margin: 0 auto;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Viewport设置示例</h1>
        
        <div class="box">
            <h2>这个页面正确设置了Viewport</h2>
            <p>在移动设备上,这个页面的文字大小合适,布局正常,无需用户缩放。</p>
            <p>尝试在手机和平板上查看此页面,体验正确的显示效果。</p>
        </div>
        
        <div class="box">
            <h2>Viewport的作用</h2>
            <p>Viewport元标签告诉浏览器如何控制页面的尺寸和缩放比例。</p>
            <p>没有正确设置viewport的网页在移动设备上会显示为缩小的桌面版本,用户需要手动缩放才能阅读内容。</p>
        </div>
        
        <div class="box">
            <h2>如何检测当前Viewport</h2>
            <p>视口宽度: <span id="viewportWidth"></span>px</p>
            <p>设备像素比: <span id="devicePixelRatio"></span></p>
            <p>屏幕宽度: <span id="screenWidth"></span>px</p>
        </div>
    </div>

    <script>
        // 显示当前视口信息
        document.getElementById('viewportWidth').textContent = 
            Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
        
        document.getElementById('devicePixelRatio').textContent = 
            window.devicePixelRatio;
            
        document.getElementById('screenWidth').textContent = 
            screen.width;
    </script>
</body>
</html>

示例2:Viewport设置对比

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <!-- 这个页面故意不设置viewport,用于展示问题 -->
    <title>无Viewport设置的问题</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            width: 980px; /* 模拟桌面版网站宽度 */
            margin: 0 auto;
            padding: 20px;
            line-height: 1.6;
        }
        
        h1 {
            color: #333;
            font-size: 24px;
        }
        
        p {
            font-size: 16px;
            margin-bottom: 15px;
        }
        
        .warning {
            background: #fff3cd;
            border: 1px solid #ffeaa7;
            color: #856404;
            padding: 15px;
            border-radius: 5px;
            margin: 20px 0;
        }
    </style>
</head>
<body>
    <div class="warning">
        <strong>注意:</strong>这个页面没有设置viewport,在移动设备上会显示为缩小的桌面版本。
    </div>
    
    <h1>未设置Viewport的网页示例</h1>
    
    <p>这个页面模拟了没有设置viewport的桌面版网站。在移动设备上查看时,你会发现:</p>
    
    <ul>
        <li>整个页面被缩小以适应屏幕宽度</li>
        <li>文字太小,需要放大才能阅读</li>
        <li>需要水平滚动才能看到全部内容</li>
        <li>用户体验很差</li>
    </ul>
    
    <p>这就是为什么现代网页开发必须设置viewport的原因。</p>
    
    <h2>如何修复这个问题?</h2>
    
    <p>只需在HTML的&lt;head&gt;部分添加以下代码:</p>
    
    <pre>&lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;</pre>
    
    <p>添加这行代码后,网页在移动设备上就会正常显示,文字大小合适,布局自适应屏幕宽度。</p>
</body>
</html>

示例3:不同Viewport设置的效果

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Viewport设置比较</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        
        body {
            font-family: Arial, sans-serif;
            line-height: 1.6;
            color: #333;
            padding: 20px;
        }
        
        .container {
            max-width: 1000px;
            margin: 0 auto;
        }
        
        h1 {
            text-align: center;
            margin-bottom: 30px;
            color: #2c3e50;
        }
        
        .comparison {
            display: flex;
            flex-wrap: wrap;
            gap: 20px;
            margin-top: 30px;
        }
        
        .example {
            flex: 1;
            min-width: 300px;
            border: 1px solid #ddd;
            border-radius: 8px;
            overflow: hidden;
        }
        
        .example-header {
            background: #3498db;
            color: white;
            padding: 15px;
            text-align: center;
        }
        
        .example-content {
            padding: 20px;
        }
        
        code {
            background: #f8f9fa;
            padding: 2px 6px;
            border-radius: 3px;
            font-family: 'Courier New', monospace;
        }
        
        .note {
            background: #e8f4fd;
            border-left: 4px solid #3498db;
            padding: 15px;
            margin: 20px 0;
        }
        
        @media (max-width: 768px) {
            .comparison {
                flex-direction: column;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>不同Viewport设置的效果比较</h1>
        
        <div class="note">
            <p>这个页面展示了不同viewport设置对网页在移动设备上显示的影响。请尝试在移动设备上查看此页面,或使用浏览器开发者工具模拟移动设备。</p>
        </div>
        
        <div class="comparison">
            <div class="example">
                <div class="example-header">
                    <h2>推荐设置</h2>
                </div>
                <div class="example-content">
                    <p><strong>Viewport代码:</strong></p>
                    <pre><code>&lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;</code></pre>
                    
                    <p><strong>效果:</strong></p>
                    <ul>
                        <li>布局视口宽度等于设备宽度</li>
                        <li>初始缩放比例为1.0</li>
                        <li>允许用户手动缩放</li>
                        <li>适合大多数响应式网站</li>
                    </ul>
                </div>
            </div>
            
            <div class="example">
                <div class="example-header">
                    <h2>固定宽度</h2>
                </div>
                <div class="example-content">
                    <p><strong>Viewport代码:</strong></p>
                    <pre><code>&lt;meta name="viewport" content="width=600, initial-scale=1.0"&gt;</code></pre>
                    
                    <p><strong>效果:</strong></p>
                    <ul>
                        <li>布局视口宽度固定为600px</li>
                        <li>在小屏幕上可能需要水平滚动</li>
                        <li>适合需要固定宽度的特殊场景</li>
                    </ul>
                </div>
            </div>
            
            <div class="example">
                <div class="example-header">
                    <h2>禁止缩放</h2>
                </div>
                <div class="example-content">
                    <p><strong>Viewport代码:</strong></p>
                    <pre><code>&lt;meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"&gt;</code></pre>
                    
                    <p><strong>效果:</strong></p>
                    <ul>
                        <li>禁止用户缩放页面</li>
                        <li>可能影响可访问性</li>
                        <li>仅在某些特殊应用中使用</li>
                    </ul>
                </div>
            </div>
        </div>
        
        <div class="note">
            <p><strong>提示:</strong>大多数情况下,使用推荐的设置即可:<code>&lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;</code></p>
        </div>
    </div>
</body>
</html>

总结

  • Viewport元标签控制网页在移动设备上的布局视口和缩放行为
  • 没有正确设置viewport的网页在移动设备上体验很差
  • 大多数情况下,使用<meta name="viewport" content="width=device-width, initial-scale=1.0">是最佳选择
  • 谨慎限制用户缩放功能,以免影响可访问性
  • Viewport设置需要与实际内容和响应式设计结合使用

object和map 和 WeakMap 的区别

object 和 map的区别

PixPin_2025-11-15_00-40-56.png选择 Object 的场景:

  1. 简单的结构化数据/记录:当你需要创建一个结构固定的“东西”,并且属性名是简单、已知的字符串时。例如,一个用户对象 const user = { name: 'John', email: '...' }。
  2. JSON 操作:当数据需要通过 JSON.stringify() 或 JSON.parse() 进行序列化和反序列化时,必须使用 Object,因为 JSON 只支持字符串键。
  3. 表示一个实体:当代码的意图是描述一个具体事物的属性时。

Object 当作一个“记录 (Record)”或“结构体 (Struct)”,把 Map 当作一个真正的“字典 (Dictionary)”或“哈希表 (Hash Map)”。

map的key可以是任何类型map对它的key强引用的关系。也就是即使对象类型置空,对象类型依然会被map引用。导致无法被回收。(引用计数器不为零,无法被回收)

WeakMap 是 JavaScript (ES6) 中引入的一种新的集合类型。要理解它的核心作用,关键在于理解两个词:“Map” 和 “Weak”(弱引用)。


1. 首先,它是一个 Map

和普通的 Map 一样,WeakMap 也是一个键值对(key-value)的集合。你可以用 set(key, value) 来添加数据,用 get(key) 来获取数据,用 has(key) 来检查是否存在,用 delete(key) 来删除。

codeJavaScript

const wm = new WeakMap();
const obj = {};

// 设置键值对
wm.set(obj, '一些与obj相关的数据');

// 获取值
console.log(wm.get(obj)); // '一些与obj相关的数据'

// 检查是否存在
console.log(wm.has(obj)); // true

// 删除
wm.delete(obj);
console.log(wm.has(obj)); // false

但是,WeakMap 和 Map 有几个关键的区别,这些区别正是 WeakMap 价值的体现。


2. 核心特性:“Weak”(弱引用)

这是 WeakMap 最重要、最核心的概念。要理解“弱引用”,我们先要明白什么是“强引用”。

强引用 (Strong Reference)

在 JavaScript 中,我们平时使用的都是强引用。比如:

codeJavaScript

let obj = { name: 'Alice' }; // 变量 obj 对 { name: 'Alice' } 这个对象是一个强引用
let anotherObj = obj;       // 变量 anotherObj 也对这个对象产生了一个强引用

只要至少还有一个强引用指向一个对象,垃圾回收机制(Garbage Collection, GC)就不会回收这个对象。在上面的例子中,即使我们设置 obj = null,anotherObj 仍然引用着那个对象,所以它不会被回收。

弱引用 (Weak Reference)

弱引用则不同。一个弱引用不会阻止垃圾回收机制回收它所引用的对象

WeakMap 对它的**键(key)**就是弱引用

让我们通过一个 Map 和 WeakMap 的对比来理解这一点:

使用 Map (强引用) 的情况:

codeJavaScript

let myMap = new Map();
let keyObject = { id: 1 };

myMap.set(keyObject, "一些元数据");

// 现在,myMap 强引用着 keyObject。

// 我们尝试“忘记”keyObject,把对它的所有其他引用都断开
keyObject = null;

// 此时,keyObject 还能被访问到吗?
// 答案是:可以,通过 myMap。
// 因为 myMap 内部的引用是强引用,所以 { id: 1 } 这个对象无法被垃圾回收。
// 这就造成了潜在的内存泄漏!只要 myMap 存在,这个对象就永远存在。
console.log(myMap.keys().next().value); // { id: 1 }

使用 WeakMap (弱引用) 的情况:

codeJavaScript

let myWeakMap = new WeakMap();
let keyObject = { id: 1 };

myWeakMap.set(keyObject, "一些元数据");

// 现在,myWeakMap 弱引用着 keyObject。

// 我们同样尝试“忘记”keyObject
keyObject = null;

// 此时,再也没有任何强引用指向 { id: 1 } 这个对象了。
// 垃圾回收机制在下一次运行时,就会发现这个对象可以被安全地回收。
// 一旦对象被回收,WeakMap 中对应的这个键值对也会被自动移除。

// 注意:我们无法直接验证这一点,因为垃圾回收的时机是不确定的。
// 但我们可以确信,这个键值对不会永久存在,从而避免了内存泄漏。

总结一下弱引用的效果:  WeakMap 允许你将数据与一个对象关联起来,但当这个对象在程序的其他地方不再被需要(即没有任何强引用指向它)时,WeakMap 不会成为它继续存活下去的“救命稻草”。它会自动“放手”,让垃圾回收机制清理内存。


3. WeakMap 的主要作用和应用场景

基于“弱引用”这个核心特性,WeakMap 主要被用来解决以下几类问题:

场景一:存储对象的私有数据或元数据(最经典的应用)

当你想要为一个对象附加一些额外信息,但又不想直接修改这个对象本身时,WeakMap 是完美的解决方案。

问题:  假设你正在编写一个库,需要为用户传入的对象添加一些内部状态,但你不能在对象上添加属性(比如 userObject._myLibraryState = ...),因为这会污染对象,可能与用户的代码冲突。

使用 Map 的缺陷:  你可以用一个全局的 Map 来存储:map.set(userObject, myState)。但这会导致前面提到的内存泄漏问题。如果用户用完 userObject 并将其设置为 null,你的 Map 仍然会“抓住”它不放。

WeakMap 的解决方案:

codeJavaScript

const privateData = new WeakMap();

class User {
  constructor(name) {
    this.name = name;
    // 将私有数据存储在 WeakMap 中,用 this (实例对象) 作为键
    privateData.set(this, { loginCount: 0, lastLogin: null });
  }

  login() {
    const data = privateData.get(this);
    data.loginCount++;
    data.lastLogin = new Date();
    console.log(`${this.name} 登录成功,已登录 ${data.loginCount} 次。`);
  }
}

let user1 = new User('Bob');
user1.login(); // Bob 登录成功,已登录 1 次。

// 当 user1 不再被使用时,比如:
user1 = null;
// 在未来的某个时刻,垃圾回收机制会回收原来 user1 指向的对象。
// 同时,privateData 中与该对象关联的数据也会被自动清除,不会造成内存泄漏。

这个模式在 JavaScript 实现私有属性的早期探索中非常流行(在 # 私有字段语法普及之前)。

场景二:缓存计算结果 (Memoization)

当你有一个对对象进行复杂计算的函数时,你可以用 WeakMap 来缓存结果,以避免重复计算。

问题:  有一个函数 process(obj),计算成本很高。如果多次传入同一个 obj,我们希望直接返回缓存的结果。

WeakMap 的解决方案:

codeJavaScript

const cache = new WeakMap();

function process(obj) {
  // 如果缓存中有,直接返回
  if (cache.has(obj)) {
    console.log('从缓存中读取...');
    return cache.get(obj);
  }

  // 否则,进行复杂计算
  console.log('正在进行复杂计算...');
  const result = /* ... 一些非常耗时的操作 ... */ obj.value * 10;

  // 将结果存入缓存
  cache.set(obj, result);
  return result;
}

let myObj = { value: 5 };
console.log(process(myObj)); // 正在进行复杂计算...  50
console.log(process(myObj)); // 从缓存中读取...    50

// 当 myObj 不再需要时
myObj = null;
// 缓存中的 { value: 5 } -> 50 这条记录会自动被清理,释放内存。
// 如果用 Map,这条缓存会一直存在。
场景三:管理 DOM 节点与数据的关联

在前端开发中,我们经常需要为 DOM 元素附加一些状态或数据。

问题:  你为一个按钮添加了一个点击事件监听器,并且需要存储一些与该按钮相关的状态。如果把这些状态存在一个全局的 Map 或对象中,当这个按钮从页面上被移除后,Map 中对它的引用依然存在,导致这个(已经分离的)DOM 节点无法被回收,造成内存泄漏。

WeakMap 的解决方案:

codeJavaScript

const elementData = new WeakMap();

const button = document.getElementById('myButton');

elementData.set(button, { clickCount: 0 });

button.addEventListener('click', () => {
  const data = elementData.get(button);
  data.clickCount++;
  console.log(`按钮被点击了 ${data.clickCount} 次`);
});

// 假设在某个时刻,我们从 DOM 中移除了这个按钮
// button.parentNode.removeChild(button);
// 并且没有其他地方引用这个 button 变量了。
// 那么这个 DOM 节点就会被垃圾回收,elementData 中对应的数据也会被自动清除。

4. WeakMap 的限制

为了实现弱引用和自动垃圾回收,WeakMap 付出了一些代价,导致它有以下限制:

  1. 键必须是对象:WeakMap 的键不能是原始类型值(如 string, number, symbol),因为原始值是不可变的,没有被垃圾回收的概念。
  2. 不可遍历:WeakMap 没有 keys(), values(), entries() 方法,也不支持 forEach 循环,也没有 size 属性。因为键值对可能在任何时候被垃圾回收机制移除,所以它的成员列表是不确定的,遍历它没有意义。

总结

WeakMap 的核心作用是在不影响对象生命周期的前提下,将数据与该对象进行关联

✅ WeakMap 的 key 可以是什么?

👇 这些都是合法的 key:

const wm = new WeakMap();

wm.set({}, 'obj');          // 普通对象
wm.set([], 'array');        // 数组
wm.set(() => {}, 'fn');     // 函数
wm.set(new Map(), 'map');   // Map
wm.set(new Set(), 'set');   // Set
wm.set(document.body, 'dom'); // DOM 节点
wm.set(new Number(1), 'wrapped number'); // 包装对象

从原理到实践,吃透 Lit 响应式系统的核心逻辑

一、为什么 Lit 的响应式 “轻而强”?

在前端框架林立的当下,响应式系统早已不是新鲜概念。Vue 的reactive、React 的useState,都通过抽象封装让开发者摆脱了手动操作 DOM 的繁琐。但 Lit 的响应式却走出了一条差异化路线 ——基于原生 JavaScript,无额外依赖,却能实现高效的状态驱动视图更新

我在 4 年前端开发中,曾用 Vue、React 搭建过多个中后台项目,也踩过不少性能坑:大型列表渲染时的卡顿、复杂状态依赖导致的更新混乱、框架打包后体积过大影响首屏加载。直到接触 Lit,才发现它的响应式设计恰好解决了这些痛点。

Lit 的响应式核心体积仅几 KB,却能实现 “精准更新”—— 只有状态发生变化时,才会触发组件的重新渲染,且仅更新变化的 DOM 节点。这背后的关键,在于它没有引入复杂的虚拟 DOM,而是基于原生Proxy和 “属性访问追踪” 机制,让状态与视图的绑定更直接、更高效。

更重要的是,Lit 的响应式不依赖任何框架 runtime,组件编译后可直接在浏览器中运行,既能独立使用,也能嵌入其他框架。这种灵活性,让它在跨项目组件复用、设计系统搭建等场景中极具优势。也正因为如此,我决定深入拆解 Lit 响应式的底层逻辑,帮大家不仅 “会用”,更能 “吃透”。

二、Lit 响应式的底层原理:3 个核心机制

要掌握 Lit 的响应式,不能只停留在 API 调用层面。我通过阅读 Lit 源码(v3.0 版本)、调试组件更新流程,总结出其核心依赖 3 个关键机制:状态追踪、依赖收集、精准更新

1. 状态追踪:基于 Proxy 的 “属性访问监听”

Lit 的响应式状态通过@state()装饰器或createSignal函数创建,其本质是对原始数据的 Proxy 代理。与 Vue 3 的reactive类似,但 Lit 的 Proxy 封装更轻量,仅聚焦 “属性访问” 和 “状态修改” 两个核心场景。

当你在组件中定义如下状态时:

javascript

运行

import { LitElement, html, state } from 'lit';

class MyComponent extends LitElement {
  @state() user = { name: '张三', age: 28 };
  @state() count = 0;

  render() {
    return html`
      <div>姓名:${this.user.name}</div>
      <div>年龄:${this.user.age}</div>
      <div>计数:${this.count}</div>
      <button @click=${() => this.count++}>点击+1</button>
    `;
  }
}

@state()会将usercount转化为 Proxy 对象。当组件首次执行render函数时,访问this.user.namethis.count等属性的过程,会被 Proxy 拦截并记录 —— 这一步就是 “属性访问追踪”。

Lit 内部维护了一个 “当前有效更新上下文”(currentUpdateContext),当属性被访问时,会将该属性与当前组件的更新函数关联起来。简单说:组件渲染时,哪些属性被用到了,Lit 就会记住 “这个属性变化时,需要重新执行渲染”

2. 依赖收集:建立 “状态 - 组件” 的映射关系

依赖收集是响应式系统的核心,它决定了 “状态变化时,该通知哪些组件更新”。Lit 的依赖收集机制非常简洁,核心是一个 “依赖映射表”(depsMap),结构如下:

javascript

运行

// 简化后的依赖映射表结构
const depsMap = new WeakMap([
  // key: 响应式状态对象(如user)
  [userProxy, new Map([
    // key: 属性名(如name)
    ['name', new Set([
      // value: 依赖该属性的组件更新函数
      componentUpdateFn1,
      componentUpdateFn2
    ])]
  ])]
]);

当组件渲染时,每访问一个响应式属性,Lit 都会执行三步操作:

  1. 检查当前是否存在 “更新上下文”(即组件是否在渲染中);
  2. 若存在,从依赖映射表中找到该属性对应的依赖集合;
  3. 将当前组件的更新函数加入依赖集合。

这个过程就像 “订阅 - 发布” 模式:响应式属性是 “发布者”,组件更新函数是 “订阅者”。依赖收集完成后,当属性发生变化,所有订阅它的更新函数都会被触发。

这里有个容易被忽略的细节:Lit 的依赖收集是 “懒收集” 的 —— 只有属性被实际访问时,才会建立依赖关系。如果某个状态定义后从未在render中使用,即使修改它,也不会触发组件更新。这种设计避免了无效的更新开销,提升了性能。

3. 精准更新:跳过虚拟 DOM,直接操作真实 DOM

这是 Lit 响应式最具特色的一点。与 React、Vue 通过虚拟 DOM 对比差异来更新视图不同,Lit 采用 “模板字面量 + DOM 差异标记” 的方式,实现更高效的真实 DOM 更新。

当响应式状态变化时,Lit 会执行以下流程:

  1. 状态修改触发 Proxy 的set拦截器;
  2. 从依赖映射表中取出该属性对应的所有更新函数,执行这些函数重新生成组件模板;
  3. Lit 的模板编译器会对比新旧模板的差异,仅标记变化的 DOM 节点(如文本内容、属性值);
  4. 直接操作真实 DOM,只更新标记的差异节点,无需整体重新渲染。

举个例子:如果只修改this.count,Lit 会重新生成模板,但仅对比出 “计数” 对应的文本节点发生变化,随后直接更新该节点的textContent,而不会触碰user相关的 DOM 节点。

这种 “精准更新” 机制,让 Lit 组件的渲染性能远超许多框架。我曾做过一个测试:在同样渲染 1000 条列表数据的场景下,Lit 组件的首次渲染时间比 React 组件快 30%,更新时间快 50%(数据基于 Chrome 浏览器性能面板测试)。

三、实战避坑:Lit 响应式的 5 个关键技巧

理解原理后,更重要的是在实战中灵活运用。结合我在项目中使用 Lit 的经验,总结了 5 个高频场景的避坑技巧,帮你避开 90% 的使用误区。

1. 复杂对象状态:避免 “深层属性更新不触发渲染”

Lit 的@state()对复杂对象的响应式支持是 “浅层” 的吗?很多开发者会遇到这个问题:修改对象的深层属性时,组件没有触发更新。比如:

javascript

运行

// 错误示例:修改深层属性,未触发更新
this.user.address.city = '北京';

这不是 Lit 的缺陷,而是 Proxy 的特性 —— 默认情况下,Proxy 只能监听对象的第一层属性变化。要解决这个问题,有两种方案:

方案一:使用@state()装饰器时,确保对象的深层属性修改时,触发顶层属性的 setter:

javascript

运行

// 正确示例:重新赋值顶层属性
this.user = { ...this.user, address: { ...this.user.address, city: '北京' } };

方案二:对于需要频繁修改的复杂对象,使用createDeepSignal(Lit v3.0 + 新增 API),它会递归代理对象的所有层级属性:

javascript

运行

import { createDeepSignal } from 'lit/decorators/signal.js';

class MyComponent extends LitElement {
  user = createDeepSignal({ name: '张三', address: { city: '上海' } });

  // 直接修改深层属性,会触发更新
  updateCity() {
    this.user.value.address.city = '北京';
  }
}

2. 数组状态:避免 “直接修改数组不触发更新”

与对象类似,直接修改数组的元素或长度(如pushsplicearr[0] = xxx),默认不会触发 Lit 的响应式更新。这是因为数组的这些操作不会触发 Proxy 的set拦截器(针对数组的索引或length属性)。

解决方案有两个:

  • 对于简单数组,使用扩展运算符创建新数组:

    javascript

    运行

    // 正确示例:添加元素
    this.list = [...this.list, newItem];
    // 正确示例:修改元素
    this.list = this.list.map((item, index) => index === 0 ? newItem : item);
    
  • 对于复杂数组,使用createSignal结合不可变数据处理:

    javascript

    运行

    import { createSignal } from 'lit/decorators/signal.js';
    
    class MyComponent extends LitElement {
      list = createSignal([1, 2, 3]);
    
      addItem() {
        // 基于原数组创建新数组,触发更新
        this.list.set([...this.list.value, 4]);
      }
    }
    

3. 状态依赖:使用computed处理衍生状态

在实际开发中,经常会遇到 “基于多个状态计算衍生状态” 的场景。比如,根据userageisStudent状态,计算是否享受优惠:

javascript

运行

// 错误示例:直接在render中计算,可能导致重复计算
render() {
  const hasDiscount = this.user.age < 25 && this.user.isStudent;
  return html`<div>是否优惠:${hasDiscount ? '是' : '否'}</div>`;
}

这种写法的问题是:每次组件更新时,hasDiscount都会重新计算,即使依赖的状态没有变化。Lit 提供了computed函数,专门处理衍生状态,且具备缓存特性 —— 只有依赖的状态变化时,才会重新计算。

javascript

运行

import { computed } from 'lit/decorators/computed.js';

class MyComponent extends LitElement {
  @state() user = { age: 22, isStudent: true };

  // 正确示例:使用computed缓存衍生状态
  @computed()
  get hasDiscount() {
    return this.user.age < 25 && this.user.isStudent;
  }

  render() {
    return html`<div>是否优惠:${this.hasDiscount ? '是' : '否'}</div>`;
  }
}

computed函数会自动收集依赖的响应式状态,当user.ageuser.isStudent变化时,才会重新计算hasDiscount的值,避免无效计算。

4. 批量更新:使用requestUpdate避免多次渲染

如果一次操作中需要修改多个状态,直接修改可能会触发多次组件更新。比如:

javascript

运行

// 可能触发两次渲染
this.count++;
this.user.name = '李四';

虽然 Lit 内部有一定的更新合并机制,但在复杂场景下,仍可能出现多次渲染的情况。此时可以使用requestUpdate方法,手动批量处理状态更新,确保只触发一次渲染:

javascript

运行

// 正确示例:批量更新,仅触发一次渲染
this.requestUpdate(() => {
  this.count++;
  this.user = { ...this.user, name: '李四' };
});

requestUpdate的回调函数中,所有状态修改都会被合并,回调执行完成后,才会触发组件的一次重新渲染,提升性能。

5. 状态共享:跨组件通信的 3 种方案

在中大型项目中,不可避免会遇到跨组件状态共享的需求。Lit 没有内置的全局状态管理工具,但结合其响应式特性,有 3 种实用方案:

方案一:使用 “父传子 + 子传父” 的传统方式,适用于父子组件通信:

javascript

运行

// 父组件
class ParentComponent extends LitElement {
  @state() count = 0;

  updateCount(newCount) {
    this.count = newCount;
  }

  render() {
    return html`<child-component .count=${this.count} @count-change=${(e) => this.updateCount(e.detail)}></child-component>`;
  }
}

// 子组件
class ChildComponent extends LitElement {
  @property() count = 0;

  handleClick() {
    this.dispatchEvent(new CustomEvent('count-change', { detail: this.count + 1 }));
  }

  render() {
    return html`<button @click=${this.handleClick}>${this.count}</button>`;
  }
}

方案二:使用createContext创建全局上下文,适用于跨层级组件通信:

javascript

运行

// context.js
import { createContext } from 'lit';

export const CountContext = createContext(0);

// 根组件
import { CountContext } from './context.js';

class RootComponent extends LitElement {
  @state() count = 0;

  render() {
    return html`
      <CountContext.Provider value=${this.count}>
        <child-component></child-component>
      </CountContext.Provider>
    `;
  }
}

// 子组件(任意层级)
import { useContext } from 'lit';
import { CountContext } from './context.js';

class ChildComponent extends LitElement {
  count = useContext(CountContext);

  render() {
    return html`<div>全局计数:${this.count}</div>`;
  }
}

方案三:使用外部响应式状态,适用于全局共享状态(如用户信息):

javascript

运行

// globalState.js
import { createSignal } from 'lit/decorators/signal.js';

export const userState = createSignal({ name: '张三', role: 'admin' });

// 任意组件
import { userState } from './globalState.js';

class AnyComponent extends LitElement {
  render() {
    return html`<div>用户名:${userState.value.name}</div>`;
  }
}

// 修改全局状态(任意组件中)
userState.set({ ...userState.value, name: '李四' });

这种方案无需上下文嵌套,灵活度高,且修改状态后,所有使用该状态的组件都会自动更新。

四、原理延伸:Lit 响应式与其他框架的核心差异

通过前面的分析,我们已经掌握了 Lit 响应式的核心逻辑。但要真正理解它的优势,还需要与 Vue、React 的响应式系统做对比,看清背后的设计思路差异。

1. 与 Vue 3 响应式的差异

Vue 3 的响应式同样基于 Proxy,但两者的设计目标不同:

  • Vue 的响应式更 “全能”,支持深层代理、数组变异方法(如push)、依赖自动收集等,封装程度高,开发者无需关注底层实现;
  • Lit 的响应式更 “轻量化”,默认只支持浅层代理,数组变异方法需要手动处理,但其核心逻辑更简洁,无额外 runtime 开销,组件兼容性更强。

适用场景:如果是纯 Vue 项目,使用 Vue 自带的响应式更高效;如果需要跨框架复用组件,Lit 的响应式更有优势。

2. 与 React 响应式的差异

React 的响应式基于 “状态更新触发重新渲染”,核心是虚拟 DOM 对比:

  • React 的useState不追踪状态的具体变化,只要调用setState,就会触发组件重新渲染(除非使用React.memouseMemo优化);
  • Lit 的响应式精准追踪属性访问,只有被渲染使用的状态变化时,才会触发更新,且直接操作真实 DOM,无需虚拟 DOM 对比。

性能差异:在小型组件、简单状态场景下,两者性能差距不大;但在大型列表、复杂状态依赖场景下,Lit 的精准更新机制能显著提升性能。

五、总结:掌握 Lit 响应式的 3 个关键

Lit 的响应式系统看似简单,实则蕴含着 “轻量、高效、兼容” 的设计哲学。通过本文的拆解,希望能帮你建立起完整的知识体系:

  1. 核心逻辑:记住 “状态追踪 - 依赖收集 - 精准更新” 三步流程,理解 Proxy 在其中的作用;
  2. 实战技巧:掌握复杂对象 / 数组的更新方式、computed衍生状态、批量更新、跨组件通信等高频场景的解决方案;
  3. 差异认知:明确 Lit 与其他框架响应式的区别,根据项目场景选择合适的技术方案。

Lit 的响应式是其组件化开发的基础,吃透这部分内容,后续学习组件封装、性能优化、工程化配置等知识都会事半功倍。在接下来的小册内容中,我会结合更多实战项目,带你深入 Lit 的组件开发、工程化落地等核心场景,让你真正能用 Lit 搭建出高效、可复用的前端系统。

如果想提前查看 Lit 官方对响应式的详细说明,可以参考:Lit 响应式文档

跟着ECMAScript 规范,手写数组方法之map

不止于会用:跟着 ECMAScript 规范,手写 map

开宗明义:以传入的数组,回调函数 和 thisArg 加工成新数组。

Array.prototype.myMap 接受一个可调用的 callback 和可选的 thisArg,按从 0 到 ToLength(O.length)-1 的索引依次对传入的(类)数组元素调用 callback(参数为 currentValue, index, 原对象),把每次调用的返回值放到同一索引的新数组并返回该新数组;回调不会改变原数组,也不会为原数组中“空位”(hole)创建值。

主干

Array.prototype.myMap = function(callback) {
    // 创建空数组
  const result = [];

  // this即传入数组
  for (let i = 0; i < this.length; i++) {
    result.push(callback(this[i], i, this));
  }

  return result;
};


树枝

考虑到边界的处理

处理 thisArg

如果想在回调函数里使用一个特定的 this 上下文

Array.prototype.myMap = function(callback, thisArg) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    result.push(callback.call(thisArg, this[i], i, this));
  }
  return result;
};

变化:

  1. 在函数签名中接收 thisArg 参数。
  2. 调用回调时,不用 callback(),而是用 callback.call(thisArg) 来手动指定 this

处理稀疏数组

原生的 map 会返回 [2, <1 empty item>, 4],它会跳过空位。主干版本没有这个能力,它会把空位当成 undefined 来处理。

目标: 识别并跳过空位

Array.prototype.customMap = function(callback, thisArg) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    if (i in this) {
      result.push(callback.call(thisArg, this[i], i, this));
    }
  }
  return result;
};

变化:

  1. 在循环中,访问元素前,先检查这个索引位置上是否真的有值。
  2. JavaScript 的 in 操作符:if (i in this)

防御性编程

  • callback 不是一个函数?
  • 在非数组上调用(比如 document.getElementsByTagName('div') 这种类数组)?
  • this 是 null 或 undefined
Array.prototype.customMap = function(callback, thisArg) {
    // 修剪1:检查 callback
  if (typeof callback !== 'function') {
    throw new TypeError('callback is not a function');
  }
    // 修剪2: 包装类
  const O = Object(this);
    // 修剪3: 安全长度
  const len = O.length >>> 0;

  const result = [];
  for (let i = 0; i < len; i++) {
    if (i in O) {
      result.push(callback.call(thisArg, O[i], i, O));
    }
  }
  return result;
}

修剪:

  1. 检查 callback:在函数开头 if (typeof callback !== 'function'),如果不是函数就立刻报错。
  2. 转换 this:使用 const O = Object(this) 确保 this 总是一个对象,这样即使在字符串 hello 上调用,也能把它变成一个类数组对象 {0: 'h', 1: 'e', ...} 来处理。
  3. 安全获取长度:使用 const len = O.length >>> 0 确保长度总是一个非负整数,避免负数或奇怪值导致循环出错。

测试

Array.prototype.myMap = function(callback, thisArg) {
 基本功能测试
console.log([1, 2, 3].myMap(x => x * 2)); // [2, 4, 6]

// 2. 稀疏数组测试
const sparse = [1, , 3];
console.log(sparse.myMap(x => x + 1)); // [2, <1 empty item>, 4] (正确跳过空位)

// 3. thisArg 测试
const multiplier = { factor: 10 };
function multiply(item) {
  return item * this.factor;
}
console.log([1, 2, 3].myMap(multiply, multiplier)); // [10, 20, 30]

// 4. 类数组对象测试
function f() {
  return Array.prototype.myMap.call(arguments, x => x + 1);
}
console.log(f(4, 5, 6)); // [5, 6, 7]

// 5. 字符串测试
console.log(Array.prototype.myMap.call('abc', char => char.toUpperCase())); // ['A', 'B', 'C']

参考

文档地址:ECMAScript® 2023 Language Specification - Array.prototype.map

【JavaScript】大厂前端面试官最爱考的JS手写题整理出来了!你能答对几道题?_哔哩哔哩_bilibili

文档原文

001_map.png

002_map.png

003_map.png

只有前端 Leader 才会告诉你:那些年踩过的模块加载失败的坑

前端部署后模块加载 404:从崩溃到自动恢复的解决方案

测试时发现的诡异问题

最近在开发一个新项目,用的 Vite + React 技术栈,开发体验挺不错。测试阶段却发现了一个诡异的问题。

测试同学反馈:点击某个功能模块时,页面直接白屏了。打开控制台一看:

Failed to fetch dynamically imported module: /assets/chat-abc123.js
GET /assets/chat-abc123.js 404 Not Found

image-20251029200535769.png

用户刷新页面就又好了。但问题是——用户不该看到这个错误页面。

几个疑问立刻冒出来:

  • 为啥好端端的 JS 文件会 404?
  • 为啥用户刷新就好了?
  • 这问题是偶发还是必现?
  • 怎么让用户无感知地解决这个问题?

问题复盘:为什么会 404?

捋了下时间线,问题原因就清晰了:

sequenceDiagram
    participant U as 用户浏览器
    participant S as 服务器
    participant CI as CI/CD

    U->>S: 10:00 打开页面
    S->>U: 返回 index.html (引用 chat.abc123.js)
    Note over U: 用户保持页面打开
    
    CI->>S: 14:30 部署新版本
    Note over S: chat.abc123.js → chat.def456.js
    
    U->>S: 15:00 点击聊天按钮
    U->>S: 请求 chat.abc123.js
    S->>U: 404 Not Found ❌
    Note over U: 页面崩溃

说白了就是:用户浏览器里的 index.html 是旧的,但服务器上的 JS 文件已经是新的了

这问题只在三个条件同时满足时才会出现:

  1. 用户长时间不刷新页面(保持旧版 HTML)
  2. 后端部署了新版本(旧 chunk 被替换)
  3. 用户触发懒加载(动态 import 新模块)

如果项目没用代码分割,所有 JS 都在首次加载,反而不会有这问题。但为了性能做了懒加载,结果踩了这个坑。

解决思路:自动刷新 + 兜底提示

想了几种方案:

方案 优点 缺点
保留旧版本文件 彻底避免 404 需要改造部署流程,清理策略复杂
版本检测轮询 可以主动通知用户 增加服务器压力,体验一般
捕获错误自动刷新 实现简单,用户无感知 需要防止无限刷新

最后选了第三种——简单有效,改动最小。

核心逻辑很简单:

  1. 检测到模块加载失败 → 自动刷新页面
  2. 刷新后还失败 → 显示友好错误提示
  3. 用 sessionStorage 防止无限刷新

具体实现

1. 创建错误处理工具函数

// src/utils/moduleLoadErrorHandler.ts

export const RELOAD_FLAG_KEY = 'module_error_reloaded';

// 检测是不是模块加载错误
export function isModuleLoadError(error: Error | string): boolean {
  const message = typeof error === 'string' ? error : error.message || '';
  
  return (
    // 各种模块加载错误的特征
    message.includes('Failed to fetch dynamically imported module') ||
    message.includes('Loading chunk') ||
    message.includes('ChunkLoadError')
  );
}

// 尝试自动刷新(只刷一次)
export function attemptModuleErrorReload(): boolean {
  // 已经刷过了?那就别再刷了
  if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
    console.error('❌ 模块加载持续失败,请手动强刷 (Ctrl+F5)');
    sessionStorage.removeItem(RELOAD_FLAG_KEY);
    return false;
  }

  // 标记一下,防止无限刷新
  sessionStorage.setItem(RELOAD_FLAG_KEY, '1');
  console.warn('⚠️ 检测到模块加载失败,自动刷新页面...');
  
  // 稍微延迟一下,避免页面闪烁
  setTimeout(() => window.location.reload(), 100);
  return true;
}

// 全局监听(兜底机制)
export function setupModuleLoadErrorHandler(): void {
  window.addEventListener('error', (event: ErrorEvent) => {
    if (isModuleLoadError(event.message || '')) {
      attemptModuleErrorReload();
    }
  });

  // 页面正常加载完,清除标记
  window.addEventListener('load', () => {
    sessionStorage.removeItem(RELOAD_FLAG_KEY);
  });
}

关键点:

  • sessionStorage 存活期刚好是一个会话,关闭标签页就清除
  • 延迟 100ms 刷新,避免用户看到闪烁
  • 刷新失败后给出明确的手动操作提示

2. 在 ErrorBoundary 中处理

React 项目一般都有 ErrorBoundary,正好在这里统一处理:

// src/components/ErrorBoundary/index.tsx

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  // 模块加载错误?自动刷新试试
  if (isModuleLoadError(error)) {
    attemptModuleErrorReload();
    return; // 页面马上刷新,不显示错误界面
  }

  // 其他错误正常显示
  this.setState({ error, errorInfo });
}

3. 应用入口初始化

// src/main.tsx
import { setupModuleLoadErrorHandler } from './utils/moduleLoadErrorHandler';

// 尽早初始化,捕获所有错误
setupModuleLoadErrorHandler();

测试验证:模拟真实场景

方法一:Chrome DevTools 拦截请求(最简单)

这个方法不用改代码,直接在浏览器里模拟:

  1. 打开 DevTools,切到 Network 标签
  2. 右上角三个点 → More tools → Request blocking
  3. 添加拦截规则:*page/chat**chunk*
  4. 切换路由触发懒加载
graph LR
    A[添加拦截规则] --> B[触发懒加载]
    B --> C{首次失败?}
    C -->|是| D[自动刷新页面]
    C -->|否| E[显示错误提示]
    D --> F[刷新后重试]
    F --> E

看到页面自动刷新就说明成功了。保持拦截规则,再触发一次,应该直接显示错误界面(不会无限刷新)。

方法二:模拟真实部署

更接近生产环境的测试:

# 1. 构建项目
pnpm build

# 2. 启动预览服务
pnpm preview

# 3. 打开页面,不要刷新

# 4. 删除某个 chunk 文件(模拟新版本部署)
rm dist/assets/chat-*.js

# 5. 在页面中点击聊天按钮

应该看到页面自动刷新,然后正常加载(如果文件还在的话)。

上线后的效果

部署这个方案一周了,效果挺好:

  • 用户反馈的"页面崩溃"问题消失了
  • 监控显示模块加载错误减少了 95%
  • 剩下 5% 是真的网络问题,有错误提示兜底

唯一的小问题:用户正在填表单时如果触发了自动刷新,数据会丢失。不过这种情况很少,后续可以考虑加个表单数据缓存。

Nginx 配置:从根源预防问题

前端的自动刷新是兜底,更重要的是 Nginx 配置要正确。

当前的 nginx.conf 配置

server {
    listen       80;
    server_name  _;

    root   /usr/share/nginx/html;
    index  index.html;

    # 静态资源:找不到直接返回 404,不返回 index.html
    location /assets/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA 路由:HTML 完全不缓存
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # gzip 压缩
    gzip on;
    gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
    gzip_min_length 1024;
}

配置说明

1. /assets/ 路径的关键配置

try_files $uri =404;
  • 作用:chunk 文件找不到时,直接返回 404,而不是返回 index.html
  • 为什么重要:如果返回 HTML,浏览器会尝试把 HTML 当作 JavaScript 执行,导致 MIME type 错误
  • 效果:前端能正确捕获到 404 错误,触发自动刷新

2. index.html 不缓存

add_header Cache-Control "no-cache, no-store, must-revalidate";
  • 作用:确保用户每次访问/刷新都获取最新的 index.html
  • 为什么重要:新版本 HTML 会引用新的 chunk 文件名
  • 局限性:只对"刷新页面"有效,对"已打开的页面"无效(这就是为什么需要前端自动刷新)

3. 静态资源长期缓存

expires 1y;
add_header Cache-Control "public, immutable";
  • 作用:带 hash 的文件名可以永久缓存
  • 好处:减少带宽消耗,提升加载速度
  • 安全性:文件名变了就是新文件,不会有缓存问题

为什么这样配置?

这个配置实现了双层防护

┌─────────────────────────────────┐
│  Nginx 层(预防 60-70%)         │
│  - HTML 不缓存                   │
│  - 404 正确返回                  │
└─────────────────────────────────┘
              ↓
┌─────────────────────────────────┐
│  前端层(兜底 30-40%)           │
│  - ErrorBoundary 自动刷新        │
│  - window.error 监听             │
└─────────────────────────────────┘

Nginx 能解决的场景

  • ✅ 用户刷新页面 → 获取最新 HTML
  • ✅ 新用户访问 → 获取最新版本
  • ✅ 正确的 404 响应 → 前端能捕获错误

Nginx 不能解决的场景

  • ❌ 用户长时间不刷新 + 触发懒加载
  • ❌ 多标签页旧版本问题

这些场景就需要前端的自动刷新来兜底。

本次代码修改说明

这次修复主要解决了一个关键问题:线上环境模块加载错误没有触发自动刷新

问题原因

之前只在全局监听了 window.error 事件:

window.addEventListener('error', (event) => {
  // 处理模块加载错误
});

但 React 的 ErrorBoundary 会先捕获错误,导致错误无法冒泡到 window.error

React.lazy() 加载失败
    ↓
ErrorBoundary.componentDidCatch() 捕获 ← 在这里被拦截!
    ↓
显示错误界面,等待用户点击
    ↓
❌ window.error 永远不会触发
    ↓
❌ 自动刷新逻辑从未执行

解决方案

1. 重构为可复用的工具函数

// 导出检测函数
export function isModuleLoadError(error: Error | string): boolean

// 导出刷新函数
export function attemptModuleErrorReload(): boolean

// 保留全局监听(兜底)
export function setupModuleLoadErrorHandler(): void

2. 在 ErrorBoundary 中集成

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  // 检测并处理模块加载错误 - 自动刷新
  if (isModuleLoadError(error)) {
    attemptModuleErrorReload();
    return; // 不显示错误UI,页面即将刷新
  }

  // 其他错误正常处理
  this.setState({ error, errorInfo });
}

修改效果

现在有了双层防护

  1. ErrorBoundary(第一道防线) - 捕获 React 组件错误,快速响应
  2. window.error(兜底) - 捕获其他未处理的错误

无论错误从哪里来,都能被正确处理并自动刷新。


参考资源

  1. Vite 文档 - 构建生产版本 - 关于 chunk 分割的配置
  2. MDN - Dynamic import() - 动态导入的原理
  3. React Error Boundaries - 错误边界的使用

一文搞懂 AI 流式响应

这是 OpenAI 文档中流式响应的代码 platform.openai.com/docs/guides…

import { OpenAI } from "openai";
const client = new OpenAI();

const stream = await client.responses.create({
    model: "gpt-5",
    input: [
        {
            role: "user",
            content: "Say 'double bubble bath' ten times fast.",
        },
    ],
    stream: true,
});

for await (const event of stream) 
    console.log(event);
}

在我第一次看到这段代码时,有无数的疑惑出现在了我的大脑中:

  • stream 是什么?
  • 为什么可以通过 for await of 来遍历?
  • 这和异步有什么关系吗?
  • 服务端要如何将 stream 一点点返回给前端?
  • 前端要如何接收数据?
  • ……

如果你也有类似的疑问,请耐心阅读本文,相信你一定能找到答案。

本文的代码在这里github.com/wangkaiwd/a… ,建议结合文章一起阅读

Iterable protocol 和 Iterator protocol

支持 for...of 循环的变量,一定要符合 Iterable protocolIterator protocol

Iterable protocol :

  • 变量是一个对象
  • 对象必须实现 [Symbol.iterator] 方法
  • [Symbol.iterator] 方法必须返回遵循 Iterator protocol 约定的对象

Iterator protocol :

  • 变量是一个对象
  • 对象必须实现 next 方法
  • next 方法要返回一个对象 { done: boolean, value: any }
    • done 表示迭代是否结束
    • value 表示迭代器的返回值

下面是一个示例:

function makeIterableObj (array: any[]) {
  return {
    [Symbol.iterator] () {
      let nextIndex = 0
      return {
        next () {
          if (nextIndex < array.length) {
            const result = { value: array[nextIndex], done: false }
            nextIndex++
            return result
          }
          return { done: true, value: undefined }
        },
      }
    },
  }
}

const iterableObj = makeIterableObj(['one', 'two'])

可以手动循环 iterableObj

const iterator = iterableObj[Symbol.iterator]()
while (true) {
  const { value, done } = iterator.next()
  if (done) {
    break
  }
  console.log('value', value)
}

// 输出结果
// value one
// value two

也可以通过 for...of 来循环 iterableObj :

// 这里的 item 就是 next 方法执行后得到的 value
for (const item of iterableObj) {
  console.log('item', item)
}

// 输出结果
// item one
// item two

Async iterable protocol 和 Async iterator protocol

理解了 iterable protocoliterator protocol 再来理解 async iterable protocolasync iterator protocol 就会容易很多。

异步相比于同步,有以下区别:

同样的示例改为异步版本:

const sleep = (result: IResult) => {
  return new Promise<IResult>((resolve) => {
    setTimeout(() => {
      resolve(result)
    }, 1000)
  })
}

function makeIterableObj (array: any[]) {
  return {
    [Symbol.asyncIterator] () {
      let nextIndex = 0
      return {
        next () {
          if (nextIndex < array.length) {
            const promise = sleep({ value: array[nextIndex], done: false })
            nextIndex++
            return promise
          }
          return sleep({ done: true, value: undefined })
        },
      }
    },
  }
}

手动循环:

const asyncIterableObj = makeIterableObj(['one', 'two'])
const iterator = asyncIterableObj[Symbol.asyncIterator]()
while (true) {
  const { value, done } = await iterator.next()
  if (done) {
    break
  }
  console.log('value', value)
}

使用 for await ... of 循环

for await (const item of makeIterableObj(['one', 'two'])) {
  console.log('item', item)
}

此时再回到开篇的示例:

const stream = await client.responses.create()

stream 其实就是一个遵循 async iterable protocol 的对象

可读流 ReadableStream

下面是一个 ReadableStream 的示例:每隔 1s 向流中写入4个字符,直到字符完全写入到流中

let mockData = `This is a sample string that will be streamed in chunks.`

let timer: any = null
const step = 4

const stream = new ReadableStream({
  start (controller) {
    timer = setInterval(() => {
      const chunk = mockData.slice(0, step)
      // 删除已经写入的字符
      mockData = mockData.slice(step)
      if (!mockData) {
        // 字符处理完成后,停止写入
        controller.close()
        if (timer) {
          clearInterval(timer)
          timer = null
        }
      }
      // 添加字符到 stream
      controller.enqueue(chunk)
    }, 1000)
  },
  cancel () {
    clearInterval(timer)
  },
})

ReadableStream 默认实现了 Symbol.asyncIterator ,所以它是一个异步可迭代对象,可以使用 for await ... of 来循环

for await (const chunk of stream) {
  console.log('chunk', chunk)
}

ReadableStream 自己也提供了 getReader 方法来读取流:

const stream = createStream()
const reader = stream.getReader()
// 循环直到 done 为 true 时结束
while (true) {
  const { done, value } = await reader.read()
  if (done) {
    break
  }
  console.log('value', value)
}

这是 mdn 官方仓库中的一个示例,也可以结合一起学习:github.com/mdn/dom-exa…

服务端 SSE

目前的 AI 应用服务端流式响应使用 Server-Sent Events 来实现,简称 SSE 。下面是 ChatGPT 网页版的响应内容:

mdn 的相关介绍在这里:developer.mozilla.org/en-US/docs/…

sse 示例

MDN 的示例是使用 PHP 实现的,代码比较难懂,我也没有找到一个可以直接运行的案例。为了方便理解,我参考 stackoverflow.com/questions/3… ,使用 express 实现了流式响应:

import express from 'express'

const app = express()
app.use(express.static('public'))

app.get('/countdown', function (req, res) {
  // sse 响应头设置
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  })
  let timer: NodeJS.Timeout | null = null
  let count = 10
  timer = setInterval(() => {
    if (count >= 0) {
      // 返回内容必须严格遵守格式
      res.write('data: ' + count + '\n\n')
      count--
      return
    }
    // count 小于0时,停止响应
    if (timer) {
      clearInterval(timer)
      timer = null
    }
    res.end()
  }, 1000)
})

app.listen(3000, () => console.log('SSE app listening on port 3000'))

这段代码会每隔 1s 在响应中写入 count ,直到 count < 0 时结束响应。

代码中以下内容需要注意:

  • 响应头设置: 'Content-Type': 'text/event-stream'

  • 返回内容必须严格遵守格式: data: + 空格 + 字符串 + 两个换行符 (\n\n)

AI 流式响应

上面我们先实现了一个简单的流式响应,现在我们把 AI 结合进来

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASEURL,
})

const app = express()
app.use(express.static('public'))

app.get('/chat', async function (req, res) {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  })
  const stream = await client.chat.completions.create({
    model: 'deepseek-chat',
    messages: [{ role: 'user', content: '你是谁?' }],
    stream: true,
  })
  for await (const chunk of stream) {
    const content = chunk.choices[0].delta.content
    // 注意:这里通过 JSON.stringify 来返回 JSON 字符串,更加灵活
    res.write(`data: ${JSON.stringify({ content })}\n\n`)
  }
  res.write(`data: [DONE]\n\n`)
  res.end()
})

app.listen(3000, () => console.log(`
SSE app listening on port 3000
Open http://localhost:3000/sse-ai.html in your browser to access page.
`))

有以下几点需要注意:

  1. 如果使用的是 OpenAI 兼容的 api ,例如我在当前示例中使用的 deepseek ,要使用之前的 OpenAI 请求标准:github.com/openai/open… 。文档中有提到,但是我一开始没有仔细看文档,困扰了很久。 用法和传参都不一样,需要特别留意
  2. 返回内容要通过 JSON.stringify 来处理,方便我们给前端返回更多字段
  3. 结束时返回 res.write('data: \[DONE]\n\n') ,方便前端使用 EventSource 时终止请求

前端处理流式响应

EventSource

前端可以使用 EventSource 来处理 sse 响应的内容,代码如下:

const stop = document.getElementById('stop')
const start = document.getElementById('start')
let eventSource = null
start.addEventListener('click', () => {
  const eventSource = new EventSource('/chat')
  eventSource.onmessage = function (event) {
    // 要手动关闭,否则会一直请求服务器
    if (event.data === '[DONE]') {
      eventSource.close()
      return
    }
    const json = JSON.parse(event.data)
    document.getElementById('log').innerHTML += json.content
  }
})
stop.addEventListener('click', function () {
  eventSource.close()
})

完整代码:github.com/wangkaiwd/a…

EventSource 有一个细节需要注意

如果没有调用 eventSource.close() 方法,那么请求会一直不停的发起。所以我在服务端特意在响应结束时返回 data: [DONE]\n\n 来让前端知道什么时候关闭 eventSource

fetch

前面我们介绍了通过 EventSource 来处理服务端的流式响应,但其实它存在很多问题:

  • 只能发起 get 请求
  • 请求参数只能在 url 中传递,但是一般要传入给 AI 的提示词长度可能较大,容易超过 url 长度的最大限制
  • 无法自定义请求头来设置 Authorization ,给服务端传递用户 token

基于上述的这些原因,我们通常会使用 fetch 方法来处理服务端的流式响应。github.com/Azure/fetch… 就是基于 fetch 实现的用来发起 EventSource 请求的开源库,下面是它的使用示例:

<script type="module">
  import { fetchEventSource } from "https://esm.sh/@microsoft/fetch-event-source";

  const stop = document.getElementById("stop");
  const start = document.getElementById("start");
  const controller = new AbortController();
  start.addEventListener("click", () => {
    // 发起post请求
    fetchEventSource("/chat", {
      signal: controller.signal,
      method: "POST",
      // 一点点处理服务端响应
      onmessage: (event) => {
        const data = event.data;
        if (data === "[DONE]") {
          console.log("done");
          return;
        }
        const json = JSON.parse(data);
        document.getElementById("log").innerHTML += json.content;
      },
    });
  });
  stop.addEventListener("click", function () {
    controller.abort();
  });
</script>

完整代码:github.com/wangkaiwd/a…

这里使用的 POST 请求,我把服务端的示例改为了 all 方法来接收请求,可以同时处理 GETPOST 请求

我们也可以自己通过 fetch 请求来看看具体的响应内容

const response = await fetch("/chat", {
  signal: controller.signal,
  method: "POST",
});

这里的 response.body 就是一个 ReadableStream (ps: 前面的章节有介绍过ReadableStream ,忘记的同学可以再回去看一下 ),所以我们可以通过 for await ... of 或者 getReader 方法来拿到 ReadableStream 中的数据:

const textDecoder = new TextDecoder();
// response.body 是可读流
for await (const chunk of response.body) {
  // chunk 是 Uint8Array ,通过 TextDecoder 转换为字符串
  console.log('chunk', chunk)
  const text = textDecoder.decode(chunk);
  if (text === "[DONE]") {
    console.log("done");
    return;
  }
  console.log('text', text)
}

// 使用 getReader 方法获取数据
//   const reader = response.body.getReader();
//   while (true) {
//     const { done, value } = await reader.read();
//     if (done) {
//       break;
//     }
//     const text = textDecoder.decode(value);
//     if (text === "[DONE]") {
//       console.log("done");
//       return;
//     }
//     console.log('text', text)
//   }

最终结果如下:

我们拿到的是服务端返回符合 SSE 规范的字符串,将字符根据规则解析后,就能拿到最终的结果了。这其实就是 fetch-event-source 帮我们实现的逻辑

踩坑

我在使用 fetch-event-source 的过程中发现了如下问题:

如果服务端返回的内容只包含 \n ,那么前端接收到的内容为空字符。在 markdown 渲染的场景下,会导致格式完全错乱。 下面是伪代码,方便理解

// 服务端如果返回的内容如果只包含 \n
res.write('data: ' + '\n\n' + '\n\n')

// 前端拿到的内容为空字符串
onmessage: (event) => {
  const data = event.data;
  // true
  console.log(data === '')
}

官方也有相关的 issue 一直没有修复:github.com/Azure/fetch…

所以在使用 fetch-event-source 时可以通过 JSON.stringify 来传入 json 字符串,防止前端接收到空字符串

const content = chunk.choices[0].delta.content
// JSON.stringify 避免了返回内容只有 `\n` 的情况
res.write(`data: ${JSON.stringify({ content })}\n\n`)

结语

AI 出现之前,这些知识很少有使用场景。但随着 AI 的快速发展,这些代码不断地出现在我眼前,也让我有了更多实践的机会。这篇文章是我在实践中的一些沉淀和总结,希望能帮到你。

参考

Monorepo性能噩梦:一行配置解决VSCode卡顿与TS类型崩溃

Monorepo性能噩梦:一行配置解决VSCode卡顿与TS类型崩溃

前言

在现代前端开发中,Monorepo已成为管理大型项目的流行方案。它通过将多个项目、库和组件集中在同一个代码仓库中,简化了依赖管理和代码复用。然而,随着项目规模的扩大,Monorepo也可能带来一些意想不到的性能问题。

本文将分享一个我在实际项目中遇到的性能噩梦,以及如何通过一行简单的VSCode配置解决这个问题的经历。

问题背景

我所在的项目是一个大型的Monorepo,其中包含一个主项目A,以及从A中拆分出的子包B和十几个组件库C-G。项目的引用关系变得非常复杂,TypeScript配置为直接读取源码进行类型生成,而不是依赖打包后的dist文件。

拆包之后,VSCode的性能急剧下降,出现了以下问题:

  • 路径跳转卡顿:每次安装依赖后,第一次跳转到定义需要等待长达一分钟的初始化时间
  • 类型推断崩溃:TypeScript类型提示经常失效,导致出现大量unsafe type error
  • 编辑器假死:需要频繁重启VSCode才能恢复正常

我尝试了各种方法,包括调整TypeScript配置、升级VSCode、禁用插件等,但都无济于事。

问题根源

经过反复排查,我发现问题的根源在于VSCode的文件监听机制

由于我们的TypeScript配置是直接读取源码,dist目录下的打包文件实际上对类型推断是无用的。然而,VSCode的文件监听器(File Watcher)默认会监控项目中的所有文件变化,包括dist目录。

当我们在Monorepo中进行构建或安装依赖时,大量的dist文件会被生成或修改,导致VSCode的文件监听器不堪重负,占用了大量系统资源,从而引发了上述性能问题。

解决方案

最终,我通过在项目的.vscode/settings.json文件中添加一行简单的配置,彻底解决了这个问题:

{
  "files.watcherExclude": {
    "**/dist/**": true
  }
}

这行配置的作用是告诉VSCode的文件监听器忽略所有dist目录下的文件变化

为什么这个配置有效?

  1. 减少不必要的监听:排除了对dist目录的监听,大大减少了VSCode需要处理的文件变化事件
  2. 释放系统资源:降低了CPU和内存占用,使VSCode能够更流畅地运行
  3. 专注源码:让VSCode专注于监听源码文件,确保类型推断和路径跳转的性能

总结

如果你也在使用Monorepo,并且遇到了类似的性能问题,不妨检查一下你的VSCode配置。通过files.watcherExclude配置,排除掉不需要监听的目录,可能会给你带来意想不到的惊喜。

这个经历告诉我们,有时候解决复杂问题的答案可能就隐藏在最简单的配置中。希望这篇文章能帮助你摆脱Monorepo性能噩梦,享受更流畅的开发体验!

解决 uniapp H5 与原生应用通信的坑:一个经过实战验证的解决方案

解决 uniapp H5 与原生应用通信的坑:一个经过实战验证的解决方案

📖 前言

在开发 uniapp 项目时,你是否遇到过这样的问题:将 uniapp 打包成 H5 后,需要向原生应用发送消息,但发现常用的 uni.postMessagewindow.parent.postMessage 方法都不起作用?

这个问题困扰了我很久,经过大量的测试和验证,我终于找到了唯一有效的解决方案,并基于此封装了自定义的 webUni 通信工具。今天就来分享这个经过实战验证的解决方案。

🔍 问题背景

在混合开发场景中,我们经常需要将 uniapp 打包成 H5,然后嵌入到不同的容器中:

  1. 原生 App 的 WebView:H5 页面需要与原生应用进行通信
  2. 微信小程序的 web-view:H5 页面需要与小程序进行通信

常见的通信需求包括:

  • 关闭 WebView
  • 传递登录信息
  • 同步页面状态
  • 触发原生功能

❌ 常见的失败尝试

在寻找解决方案的过程中,我尝试了多种方法,但都失败了:

方法一:使用 uni.postMessage

// ❌ 在 H5 环境下无法正常工作
uni.postMessage({
  data: {
    action: 'closeWebView'
  }
})

方法二:使用 window.parent.postMessage

// ❌ 在 H5 环境下无法正常工作
window.parent.postMessage({
  type: 'message',
  data: { action: 'closeWebView' }
}, '*')

方法三:使用 plus.webview.postMessage

// ❌ 在 H5 环境下 plus 对象不存在
plus.webview.postMessage({
  data: { action: 'closeWebView' }
})

✅ 唯一有效的解决方案

经过大量测试验证,我找到了不同环境下的有效通信方式:

  1. App 环境:使用 webUni.postMessage 方式
  2. 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage 方式

核心原理

我基于不同环境的通信机制封装了一个统一的工具对象,核心特性包括:

  1. 自动环境检测:自动判断运行环境(App、微信小程序、普通 H5)
  2. 手动指定环境:支持通过参数显式指定目标环境(推荐使用,避免检测问题)
  3. 多端兼容:支持多种原生容器(DCloud、5+ Runtime、微信小程序等)
  4. 统一 API:提供统一的接口,无需关心底层实现
  5. 消息封装:将消息封装成标准格式传递给对应容器

微信小程序特殊处理

重要发现:在微信小程序的 web-view 中,如果只调用 wx.miniProgram.postMessage,消息会在页面销毁后才被小程序接收到。这是微信官方的已知问题。

解决方案:在发送消息后,立即调用 wx.miniProgram.navigateBack(),这样消息会立即被小程序接收。插件提供了 autoNavigateBack 选项来自动处理这个问题。

实现代码(自定义封装摘录)

// ✅ 支持手动指定环境或自动检测环境的完整实现
const webviewPostMessage = {
    // 静态方法:主动检测环境
    detectEnv: function() {
        const env = {};
        // 检测App环境
        if (window.plus) {
            env.plus = true;
            env.app = true;
        }
        // 检测微信小程序环境
        else if (window.wx && window.wx.miniProgram) {
            env.wechat = true;
            env.miniprogram = true;
        }
        // 其他为普通H5环境
        else {
            env.h5 = true;
        }
        return env;
    },
    
    // 发送消息方法(支持手动指定环境)
    postMessage: function(options) {
        try {
            if (!options || typeof options !== 'object') {
                console.warn('[webview-postmessage] postMessage 需要传入一个对象参数');
                return;
            }
            if (!options.data) {
                console.warn('[webview-postmessage] postMessage 需要传入 data 属性');
                return;
            }
            
            // 优先使用手动指定的环境
            const manualEnv = options.env;
            
            // 根据手动指定的环境或自动检测来决定发送方式
            if (manualEnv === 'mp-weixin') {
                console.log('[webview-postmessage] 手动指定微信小程序环境');
                // 强制使用微信小程序方式发送消息
                try {
                    wx.miniProgram.postMessage({ data: options.data });
                    
                    // 如果设置了autoNavigateBack为true,则自动调用navigateBack
                    if (options.autoNavigateBack === true) {
                        setTimeout(() => {
                            wx.miniProgram.postMessage({ data: options.data });
                        }, 100);
                    }
                } catch (wechatError) {
                    console.error('[webview-postmessage] 微信小程序环境发送消息失败:', wechatError);
                    // 失败后尝试回退到原始方式
                    webUni.postMessage(options);
                }
            } else if (manualEnv === 'app') {
                console.log('[webview-postmessage] 手动指定App环境');
                // 强制使用App方式发送消息
                webUni.postMessage(options);
            } else {
                // 自动检测环境(向后兼容)
                if (window.wx && window.wx.miniProgram) {
                    console.log('[webview-postmessage] 自动检测到微信小程序环境');
                    wx.miniProgram.postMessage({ data: options.data });
                    
                    if (options.autoNavigateBack === true) {
                        setTimeout(() => {
                            wx.miniProgram.postMessage({ data: options.data });
                        }, 100);
                    }
                } else {
                    console.log('[webview-postmessage] 自动检测到App或普通H5环境');
                    webUni.postMessage(options);
                }
            }
        } catch (error) {
            console.error('[webview-postmessage] 发送消息失败:', error);
        }
    }
};

// 使用方式1:手动指定环境(推荐)
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    },
    env: 'app'  // 手动指定环境为App
})

// 使用方式2:手动指定微信小程序环境
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    },
    env: 'mp-weixin',  // 手动指定环境为微信小程序
    autoNavigateBack: true  // 自动调用navigateBack
})

// 使用方式3:自动检测环境(兼容性模式)
webviewPostMessage.postMessage({
    data: {
        action: 'closeWebView'
    }
})

🚀 自研通信插件(uni_modules 版本)

目前我将这套封装以 uni_modules/webview-postmessage 的形式维护在项目里,尚未发布到 npm 或插件市场。想要使用时,只需把整个目录复制到自己的项目中即可。

获取方式

  1. 在代码仓库中找到 uni_modules/webview-postmessage
  2. 将该目录复制到你项目的 uni_modules
  3. 若需要分享给团队,可直接 zip 打包该目录发送

基础使用

强烈推荐手动指定宿主类型!

import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 推荐:手动指定App环境
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  },
  env: 'app'  // 手动指定环境为App
})

// 推荐:手动指定微信小程序环境(并自动返回)
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  },
  env: 'mp-weixin',  // 手动指定环境为微信小程序
  autoNavigateBack: true  // 自动调用 navigateBack,确保消息立即被接收
})

// 不推荐:自动检测环境(可能存在兼容性问题)
/*
webviewPostMessage.postMessage({ 
  data: { 
    action: 'closeWebView' 
  } 
})
*/

Vue3 Composition API 示例

<template>
  <view>
    <button @click="sendMessage">发送消息</button>
    <button @click="sendMessageAndBack">发送消息并返回</button>
  </view>
</template>

<script setup>
import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 普通发送消息 - 推荐手动指定环境类型
const sendMessage = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView',
      // 可以传递其他数据
      token: 'xxx',
      userId: 123
    },
    env: 'app'  // 手动指定环境为App
  })
}

// 微信小程序环境:发送后自动返回(推荐手动指定环境类型)
const sendMessageAndBack = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView',
      token: 'xxx',
      userId: 123
    },
    env: 'mp-weixin',  // 手动指定环境为微信小程序
    autoNavigateBack: true  // 自动返回,确保消息立即被接收
  })
}
</script>

Vue2 Options API 示例

<template>
  <view>
    <button @click="handleClose">关闭页面</button>
  </view>
</template>

<script>
import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

export default {
  methods: {
    handleClose() {
      webviewPostMessage.postMessage({
        data: {
          action: 'closeWebView'
        },
        env: 'app'  // 推荐:手动指定环境为App
      })
    }
  }
}
</script>

💡 实际应用场景

场景一:关闭 WebView

// 用户点击返回按钮时关闭 WebView - 推荐手动指定环境
const handleBack = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'closeWebView'
    },
    env: 'app'  // 手动指定环境为App
  })
}

场景二:传递登录信息

// 登录成功后,将 token 传递给原生应用 - 推荐手动指定环境
const handleLogin = async (username, password) => {
  const result = await loginAPI(username, password)
  
  webviewPostMessage.postMessage({
    data: {
      action: 'login',
      token: result.token,
      userId: result.userId,
      userInfo: result.userInfo
    },
    env: 'app'  // 手动指定环境为App
  })
}

场景三:同步页面状态

// 页面加载完成后通知原生应用 - 推荐手动指定环境
onMounted(() => {
  webviewPostMessage.postMessage({
    data: {
      action: 'pageLoaded',
      pageId: 'home',
      timestamp: Date.now()
    },
    env: 'app'  // 手动指定环境为App
  })
})

场景四:触发原生功能

// 需要调用原生分享功能 - 推荐手动指定环境
const handleShare = () => {
  webviewPostMessage.postMessage({
    data: {
      action: 'share',
      title: '分享标题',
      content: '分享内容',
      url: 'https://example.com'
    },
    env: 'app'  // 手动指定环境为App
  })
}

📋 API 说明

postMessage(options)

向原生应用或微信小程序发送消息(推荐手动指定环境类型)

参数:

参数 类型 必填 说明
options Object 消息选项
options.data Object 要发送的数据对象
options.env String 推荐 手动指定环境类型:'mp-weixin'(微信小程序)、'app'(App环境) - 推荐使用,避免自动检测问题
options.autoNavigateBack Boolean 是否在微信小程序环境中自动调用 navigateBack(解决消息延迟问题,默认 false)

示例:

// App 环境 - 推荐手动指定环境
webviewPostMessage.postMessage({
  data: {
    action: 'customAction',
    type: 'userAction',
    value: 'someValue',
    // 可以传递任意 JSON 可序列化的数据
    nested: {
      key: 'value'
    }
  },
  env: 'app'  // 手动指定环境为App
})

// 微信小程序环境,发送后自动返回 - 推荐手动指定环境
webviewPostMessage.postMessage({
  data: {
    action: 'closeWebView',
    token: 'xxx'
  },
  env: 'mp-weixin',  // 手动指定环境为微信小程序
  autoNavigateBack: true  // 自动调用 navigateBack,确保消息立即被接收
})

⚠️ 注意事项

  1. 适用于 H5 环境:此插件主要用于 uniapp 打包成 H5 后的消息通信

    • App 环境:使用 webUni.postMessage
    • 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage
    • 需要引入 jweixin.js 才能支持微信小程序环境
  2. 必须传递 data 属性:postMessage 方法必须传入包含 data 属性的对象

  3. 数据格式:data 可以是任意 JSON 可序列化的对象

  4. 强烈推荐手动指定宿主类型:为了避免自动环境检测可能出现的问题(如环境特征不明显导致检测失败),强烈推荐通过 env 参数手动指定当前环境类型为 'app''mp-weixin'

  5. 环境选择指南:在 App 中嵌套运行时指定为 'app',在微信小程序 web-view 中运行时指定为 'mp-weixin'

  6. 微信小程序消息延迟问题:在微信小程序的 web-view 中,如果只发送消息不调用 navigateBack,消息会在页面销毁后才被接收。建议使用 autoNavigateBack: true 选项自动处理

  7. 原生应用/小程序需要监听消息:需要在对应端实现消息监听逻辑来接收消息

🧪 测试验证

此插件经过以下环境测试:

  • ✅ uniapp 打包成 H5
  • ✅ App 环境(Android/iOS)
  • ✅ 微信小程序 web-view 环境
  • ✅ 微信浏览器
  • ✅ Chrome 浏览器
  • ✅ Safari 浏览器
  • ✅ Android 浏览器
  • ✅ iOS Safari
  • ✅ Vue2 和 Vue3

❓ 常见问题

Q: 为什么不能使用 uni.postMessage

A: 经过测试,uni.postMessage 在 H5 环境下无法正常工作,只有 webUni.postMessage 方式有效。这是因为 uniapp 在 H5 环境下的消息通信机制与 App 端不同。

Q: 支持哪些平台?

A: 插件支持以下平台:

  • App 环境:使用 webUni.postMessage(DCloud、5+ Runtime)
  • 微信小程序 web-view 环境:使用 wx.miniProgram.postMessage(需要引入 jweixin.js)
  • 普通 H5 环境:使用 webUni.postMessagewindow.parent.postMessage

注意:虽然插件支持自动检测环境,但强烈推荐通过 env 参数手动指定环境类型,以避免检测问题。

Q: 微信小程序中消息为什么在页面销毁后才收到?

A: 这是微信官方的已知问题。在 web-view 中只调用 postMessage 时,消息会被缓存,直到页面销毁才被小程序接收。解决方案是在发送消息后立即调用 wx.miniProgram.navigateBack(),插件提供了 autoNavigateBack: true 选项来自动处理这个问题。

Q: 如何在微信小程序环境中使用?

A: 需要确保在 H5 页面中引入了 jweixin.js(微信 JS-SDK)。可以通过以下方式引入:

<!-- 在 template.h5.html 中引入(Vue2) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

<!-- 或者在 Vue3 项目中,在 main.js 中动态引入 -->

然后在代码中使用:

import webviewPostMessage from '@/uni_modules/webview-postmessage/index.js'

// 发送消息并自动返回(解决延迟问题)
webviewPostMessage.postMessage({
  data: { action: 'closeWebView' },
  autoNavigateBack: true
})

Q: 可以传递什么类型的数据?

A: 可以传递任何 JSON 可序列化的数据,包括对象、数组、字符串、数字等。

Q: 原生应用如何接收消息?

A: 原生应用需要在 WebView 中实现消息监听。以 Android 为例:

webView.addJavascriptInterface(new Object() {
    @JavascriptInterface
    public void postMessage(String message) {
        // 处理接收到的消息
        JSONObject data = new JSONObject(message);
        String action = data.optString("action");
        // ... 处理逻辑
    }
}, "webUni");

Q: 微信小程序如何接收消息?

A: 在小程序页面的 web-view 组件上绑定 @message 事件:

<template>
  <web-view :src="url" @message="onMessage"></web-view>
</template>

<script>
export default {
  methods: {
    onMessage(e) {
      // e.detail.data 是一个数组,包含所有发送的消息
      const messages = e.detail.data || [];
      messages.forEach(msg => {
        if (msg.action === 'closeWebView') {
          uni.navigateBack();
        }
        // 处理其他消息...
      });
    }
  }
}
</script>

📦 项目状态

  • 存放位置uni_modules/webview-postmessage
  • 使用方式:直接复制到业务项目的 uni_modules 目录
  • 插件市场:已发布到 uni-app 插件市场
  • 维护方式:通过 changelog.md 记录改动,手动同步到各个项目
  • 最新版本:v1.4.0
  • 最新版本特性:支持手动指定环境类型,优化了错误处理和文档说明

🎯 总结

通过本文的分享,我们了解到:

  1. 问题根源

    • uniapp 在 H5 环境下的消息通信机制与 App 端不同
    • 微信小程序 web-view 环境有特殊的通信方式和延迟问题
  2. 解决方案

    • App 环境:使用 webUni.postMessage
    • 微信小程序环境:使用 wx.miniProgram.postMessage + navigateBack(解决延迟)
    • 支持手动指定环境类型,避免自动检测可能出现的问题
  3. 最佳实践

    • 强烈推荐手动指定环境类型,通过 env 参数明确指定 'app''mp-weixin'
    • 封装统一的 API,自动检测环境作为备用方案
    • 提供 autoNavigateBack 选项解决微信小程序消息延迟问题
    • 以插件形式提供,方便使用和维护

希望这个解决方案能够帮助到遇到同样问题的开发者。如果你觉得有用,欢迎 Star 和分享!

🔗 相关链接


如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 🎉

JavaScript 原型/原型链

热身

先来看一道题:

请你编写一个函数,检查给定的值是否是给定类或超类的实例。

可以传递给函数的数据类型没有限制。例如,值或类可能是 undefined 。

Leetcode:2618 检查是否类的对象实例

你可能会很轻松的用 instanceof 写出来:obj instanceof class 这种形式的判断。

class 是 es6 引入的新的一个关键字,他可以让我们在 JavaScript 中更加容易使用面向对象的思维去编写代码,让对象,原型这些东西更加清晰。

同样的,引入 instanceof 则是方便我们去理解,不必写一套复杂的代码来判断实例和类的关系。

那么,class 这个关键字帮我们做了什么?而 instanceof 又帮我们简化了什么?

原型

首先依旧是来讲那些前人已经说烂了的东西,原型。

原型设计模式

原型首先是面向对象编程中的一个概念,是一种设计模式,他的核心思想是,通过共享属性和方法来实现对象之间的代码重用。说实话,这个听着令人感觉像是在讲类继承,目前的编程语言多是通过类继承的方式支持面向对象的,而我们所使用的 JavaScript 则是通过原型链来支持的面向对象的[1],且按下不表。

我们不妨先给原型下一个定义:原型就是一个用于创建对象的模板,他定义了一系列的属性和方法,基于原型创建的对象之间共享一些属性和方法。

根据定义,我们写出的 JavaScript 的实现代码应该是这样的:

const boxPrototype = {
  value: 1,
  getValue() {
    return this.value;
  }
}

const box1 = {};
const box2 = {};
const box3 = {};
Object.assign(box1, boxPrototype);
Object.assign(box2, boxPrototype);
box2.value = 2;
Object.assign(box3, boxPrototype);
box3.value = 3;

box1.getValue === boxPrototype.getValue; // true
box2.getValue === boxPrototype.getValue; // true
box3.getValue === boxPrototype.getValue; // true
  1. 我们定义了一个原型 boxPrototype
  2. 基于原型 boxPrototype 创建(即拷贝、复制)了三个对象
  3. 三个对象之间各自有自己的 value 值,但是引用都是的 boxPrototypegetValue 函数,即共享了方法。

原型设计模式最主要的优点是减少了对象创建的时间和成本,通过拷贝原型对象来创建新的对象,避免重复使用构造函数初始化的操作,提高了创建对象的效率。

在不同的编程语言中,原型设计模式的实现均有差异(深拷贝、浅拷贝以及语言特性),在 JavaScript 自然也是可以有不一样的实现。

JavaScript 中的原型

先给出答案:原型实际就是一个对象

我们在上文的原型设计模式中接触到了原型对象克隆对象,并给出了 JavaScript 中的一种实现,但是我们不难发现,在这样的实现下,原型对象和克隆对象是比较独立的两个对象(除了函数能够共享,其他属性互相之间是无法访问到的)。

想要被访问到也不是不可以,只要我们保存原型对象的链接即可,同时为了避免混乱,我们为其制定了标准:

遵循 ECMAScript 标准,符号 someObject.[[Prototype]] 用于标识 someObject 的原型。内部插槽 [[Prototype]] 可以通过 Object.getPrototypeOf()Object.setPrototypeOf() 函数来访问。这个等同于 JavaScript 的非标准但被许多 JavaScript 引擎实现的属性 __proto__ 访问器。为在保持简洁的同时避免混淆,在我们的符号中会避免使用 obj.__proto__,而是使用 obj.[[Prototype]] 作为代替。其对应于 Object.getPrototypeOf(obj) [1]。

规范很好理解,规范中声明的 [[Prototype]] 可以认为是一个对象的私有属性,想要对其操作,那么就需要实现对应的 get/set 方法。__proto__ 则是以前没有一套行业执行规范时,各个厂家自己实现的一套逻辑。

值得注意的是,JS 函数中的 prototype 属性和 [[Prototype]] 还有一定的差别,不可以混淆。

原型链

在使用原型创建对象的时候,每个对象都有一个指向原型的链接,这个一条链就被称为原型链。

有了这条链接,我们便可以使通过原型创建出来的对象可以访问到原型上的方法和属性,也就是说,当我们访问一个对象的属性时,如果这个对象本身不存在该属性,那么就会去原型上查找,其访问顺序应当是:当前对象 -> 原型 -> 原型的原型 -> …… -> 最初的原型。null 标志着原型链的终结,当某个对象的原型指向 null 时,便不再继续向上搜索。

继承

如果你已经有了一定的编程语言的基础,那么你可能首先会联想到的是类的继承,A extends B 那么 A 便可以访问 A 对象中的所有属性和方法,以及从 B 中继承下来的 public 和 protected 属性和方法,而 B extends C则可以类推。

可以通过这个去理解原型链,但是要知道他们是不同的。

硬要说的话,可以说他是一个 继承链 ,同样也是做到了方法属性的复用,但不一样的是,他们无法“共享”。

举个例子:

// 继承
class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  changeName(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
}

class Cat extends Animal {
  changeName(name: string) {
    this.name = 'cat' + name;
  }
}

const animal = new Animal("1");
console.log(animal.name); // 1
animal.changeName("111");
console.log(animal.name); // 111
const dog = new Dog("2");
dog.changeName("222");
console.log(dog.name, animal.name); // 222 111
const cat = new Cat("3");
console.log(cat.name); // 3
cat.changeName("333");
console.log(cat.name, dog.name, animal.name); // cat333 222 111

上面这段代码是比较典型的继承,Dog 和 Cat 类都继承自 Animal 类,自然的,这里 Animal 类中定义的属性 name 和方法 changeName 都被继承下来了,但不同的是,Cat 类里面我们覆写了 changeName 方法,所以我们在实例化这三个类后,分别调用 changeName 方法,并打印方法调用前后 name 属性变化的情况,不难发现,每个对象仅改变了自己的属性,并没有对其他对象造成影响(也就是说他们互相之间无法访问属性),而且即使 Dog 类中没有写任何内容,但是他继承了父类的所有内容,所以他依旧可以访问到对应的属性和方法。

原型链

原型是“共享”属性和方法,以做到代码复用:

const anObj = {};
const anObjPrototype = { df: 1 };

Object.setPrototypeOf(anObj, anObjPrototype);

console.log(anObj.df); // 1,访问到了 anObjPrototype 的 df 属性值
anObj.df = 2;
console.log(anObj.df, anObjPrototype.df); // 2, 1,修改了 anObj 的 df 属性值,但对于其原型 anObjPrototype 没有影响,等于在 anObj 对象中创建了一个 df 属性,并赋值为 1

Object.getPrototypeOf(anObj) === anObjPrototype; // true,说明 anObj 的原型保存的是 anObjPrototype 的对象地址

这段代码非常简单,我们让 anObj 对象保存了原型对象 anObjPrototype 的地址,按照我们之前对原型链的定义,由于 anObj 中没有属性 df ,所以我们去他的原型上搜索,获取到值。

而我们在后面给 anObjdf 属性赋值为 2,由于其本身是没有这个属性的,所以我们这里的操作实际可以看做两步:

  1. anObj 对象中创建属性 df
  2. 为该属性赋值

而后再访问属性 df 时,由于已经在 anObj 对象中找到了对应的属性,所以就不再继续向上搜索了,即使其原型对象上存在一个相同的属性,这个就是所谓的属性遮蔽

如果在 anObjPrototype 中也没找到的话,那就返回 undefined。

对比上面两种形式,不难看出我们之前所说的继承实际上各个实例对象之间是没有关联的,而在原型链上,对象及其原型对象是通过一个链接(或者说一个指针指向的关系)关联上的,对象可以访问到其原型上的一些属性。

一些特别的情况

查漏补缺。

我们通过字面量创建的对象,会隐式的设置其 prototype:

// 对象字面量(没有 `__proto__` 键)自动将 `Object.prototype` 作为它们的 `[[Prototype]]`
const object = { a: 1 };
Object.getPrototypeOf(object) === Object.prototype; // true

// 数组字面量自动将 `Array.prototype` 作为它们的 `[[Prototype]]`
const array = [1, 2, 3];
Object.getPrototypeOf(array) === Array.prototype; // true

// 正则表达式字面量自动将 `RegExp.prototype` 作为它们的 `[[Prototype]]`
const regexp = /abc/;
Object.getPrototypeOf(regexp) === RegExp.prototype; // true

本段代码来源于 MDN。

func.prototype 指向的是构造函数,通过 new func() 方式创建的对象,会自动的将 func.prototype 作为自己的原型。

function Box(name: string) {
  this.name = name;
}

Box.prototype; // {constructor: f}

const box = new Box('hen');
box.prototype; // 

定义构造函数是使用 new 调用的函数,如 new Box();

基于原型链的继承

图穷匕见,JavaScript 的继承模式是 原型继承 ,尽管现在语言已经支持了 class 关键字,即类的说法,但实际并没有改变他的继承模式。

那么什么是原型继承呢?

我们在原型链一节中讲到,JavaScript 通过原型链来实现代码的复用,同时阅读对应的示例代码可以发现,我们通过构造了一个原型链,使得对象能够访问到其原型的属性,这就是继承了属性

继承“方法” ,本质和继承属性一样,这时候的 属性遮蔽 我们可以则类比为 “方法重写”。但是这里有一个 JavaScript 的特别之处,也是一个难点,即 this ,当继承的函数被调用的时候,this 会指向当前对象,而不是拥有该函数的原型对象。

const anObjPrototype = {
  a: 1,
  getValue() {
    return this.a;
  }
}

const anObj = {};
Object.setPrototypeOf(anObj, anObjPrototype);

// 修改 anObj 中属性 a 的值
anObj.a = 2;
anObj.getValue(); // 2,得到的是 anObj 自己的属性值。

运行上述代码,你会发现虽然 getValue 是属于原型对象 anObjPrototype 的,但是最终 anObj 调用该方法的时候得到的是对象 anObj 的属性值,而非原型对象的值。

最后

回到我们开篇的问题,class 关键字帮我们做了什么?instanceof 关键字又帮我们简化了什么?

这两个问题的答案已经十分清晰了,但也不妨在这里做个总结:

class 本质是一个语法糖,他帮我们处理绑定每个对象的原型,实现属性的共享。

// 无 class 的写法
function Box(name: string) {
  this.name = name;
}

Box.prototype.getName = function () {
  return this.name;
}

const box1 = new Box(1);
const box2 = new Box(2);
box1.getValue === box2.getValue; // true

// 对应的 class 写法
class Box {
  constructor(name: string) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

显而易见的,class 的写法更符合我们面向对象的编程习惯,function 的写法则相对不那么直观。

instanceof 则是帮我们检查实例对象是否是某个类的对象,实际上,我们在 JavaScript 中的描述应该是,用于检测构造函数的 prototype 是否出现在了实例对象的原型链上。

function Box() {
}
const box = new Box(); // 前文定义了使用 new 调用的函数就是构造函数

box instanceof Box; // true
Object.getPrototypeOf(box) === Box.prototype; // true

由此我们可以推导出,如果想在 JavaScript 中实现继承,我们可以构造一个很长的原型链:

function Animal(name: string) {
  this.name = name;
}

function Cat() {}
Object.setPrototypeOf(Cat.prototype, Animal.prototype); // Cat 继承自 Animal
function Dog() {}
Object.setPrototypeOf(Dog.prototype, Animal.prototype); // Dog 继承自 Animal

代码中我们在修改了原型之后,Cat 和 Dog 就变成了 Animal 的一个子类,Animal 则作为基类存在,在 function 这种写法下,他并不是那么直观,但是,我们将其转换成 class 的写法后,一切会更加清晰易读。

不妨躬身一试。

一些思考

说 js 中类的性能比较差,但这并不是说我们一定要用以前这种比较拗口的形式去面向对象编程,这是一种取舍,让我们在代码的可读性和可维护性和性能之间权衡,我们未必需要保证我们编写的代码去追求极致的性能,但是我们需要保证我们的代码符合一定的规范,让别人维护时不至于会高兴的蹦起来去指责前人的代码。

时代总是在进步的,编译器、引擎也是在更新换代,在努力解决这些问题,有时候极致的性能就意味着厚重的技术债务,后人对你当前引以为豪的代码无从下手。

当然,以上是从业务角度出发的思考,如果你是写高性能库的,看看就好。

参考文章

[1]: 继承与原型链 - JavaScript | MDN

[2]: 对象原型 - 学习 Web 开发 | MDN

[3]: 基于原型编程 - MDN Web 文档术语表:Web 相关术语的定义 | MDN

ArkTs单元测试 UnitTest 指南

ArkTS 提供了完善的单元测试框架,支持对 HarmonyOS 应用进行单元测试。以下是完整的 ArkTS 单元测试指南:

1. 测试环境配置

1.1 项目结构

project/
├── src/
│   └── main/
│       └── ets/
│           └── ...
├── ohosTest/
│   └── src/
│       └── test/
│           └── ets/
│               └── test/
│                   └── Example.test.ets
│               └── TestAbility.ts
│           └── resources/
│       └── module.json5

1.2 module.json5 配置

// ohosTest/src/module.json5
{
  "module": {
    "name": "test",
    "type": "feature",
    "srcEntrance": "./ets/TestAbility.ts",
    "description": "$string:TestAbility_desc",
    "mainElement": "TestAbility",
    "deviceTypes": [
      "phone",
      "tablet"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:test_pages",
    "abilities": [
      {
        "name": "TestAbility",
        "srcEntrance": "./ets/TestAbility.ts",
        "description": "$string:TestAbility_desc",
        "icon": "$media:icon",
        "label": "$string:TestAbility_label",
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "visible": true,
        "skills": [
          {
            "actions": [
              "action.system.home"
            ],
            "entities": [
              "entity.system.home"
            ]
          }
        ]
      }
    ]
  }
}

2. 基础单元测试

2.1 工具类测试

// src/main/ets/utils/MathUtil.ets
export class MathUtil {
  static add(a: number, b: number): number {
    return a + b;
  }

  static subtract(a: number, b: number): number {
    return a - b;
  }

  static multiply(a: number, b: number): number {
    return a * b;
  }

  static divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('Division by zero');
    }
    return a / b;
  }

  static isEven(num: number): boolean {
    return num % 2 === 0;
  }

  static factorial(n: number): number {
    if (n < 0) throw new Error('Negative number');
    if (n === 0 || n === 1) return 1;
    return n * this.factorial(n - 1);
  }
}
// ohosTest/src/test/ets/test/MathUtil.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { MathUtil } from '../../../src/main/ets/utils/MathUtil';

export default function mathUtilTest() {
  describe('MathUtil Tests', () => {
    it('should_add_two_numbers', 0, () => {
      const result = MathUtil.add(2, 3);
      expect(result).assertEqual(5);
    });

    it('should_subtract_two_numbers', 0, () => {
      const result = MathUtil.subtract(5, 3);
      expect(result).assertEqual(2);
    });

    it('should_multiply_two_numbers', 0, () => {
      const result = MathUtil.multiply(4, 3);
      expect(result).assertEqual(12);
    });

    it('should_divide_two_numbers', 0, () => {
      const result = MathUtil.divide(10, 2);
      expect(result).assertEqual(5);
    });

    it('should_throw_error_when_dividing_by_zero', 0, () => {
      try {
        MathUtil.divide(10, 0);
        expect(true).assertFalse(); // 不应该执行到这里
      } catch (error) {
        expect(error.message).assertEqual('Division by zero');
      }
    });

    it('should_detect_even_numbers', 0, () => {
      expect(MathUtil.isEven(4)).assertTrue();
      expect(MathUtil.isEven(7)).assertFalse();
    });

    it('should_calculate_factorial', 0, () => {
      expect(MathUtil.factorial(5)).assertEqual(120);
      expect(MathUtil.factorial(0)).assertEqual(1);
    });

    it('should_throw_error_for_negative_factorial', 0, () => {
      try {
        MathUtil.factorial(-1);
        expect(true).assertFalse();
      } catch (error) {
        expect(error.message).assertEqual('Negative number');
      }
    });
  });
}

2.2 业务逻辑测试

// src/main/ets/services/UserService.ets
export class UserService {
  private users: Map<string, User> = new Map();

  addUser(user: User): boolean {
    if (this.users.has(user.id)) {
      return false;
    }
    this.users.set(user.id, user);
    return true;
  }

  getUser(id: string): User | undefined {
    return this.users.get(id);
  }

  deleteUser(id: string): boolean {
    return this.users.delete(id);
  }

  getAllUsers(): User[] {
    return Array.from(this.users.values());
  }

  updateUser(user: User): boolean {
    if (!this.users.has(user.id)) {
      return false;
    }
    this.users.set(user.id, user);
    return true;
  }
}

export class User {
  constructor(
    public id: string,
    public name: string,
    public email: string,
    public age: number
  ) {}
}
// ohosTest/src/test/ets/test/UserService.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { UserService, User } from '../../../src/main/ets/services/UserService';

export default function userServiceTest() {
  describe('UserService Tests', () => {
    let userService: UserService;

    beforeEach(() => {
      userService = new UserService();
    });

    it('should_add_user_successfully', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      const result = userService.addUser(user);
      
      expect(result).assertTrue();
      expect(userService.getUser('1')).assertEqual(user);
    });

    it('should_not_add_duplicate_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      const result = userService.addUser(user);
      
      expect(result).assertFalse();
    });

    it('should_get_user_by_id', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      
      const retrievedUser = userService.getUser('1');
      expect(retrievedUser).assertEqual(user);
    });

    it('should_return_undefined_for_nonexistent_user', 0, () => {
      const retrievedUser = userService.getUser('nonexistent');
      expect(retrievedUser).assertUndefined();
    });

    it('should_delete_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      
      const deleteResult = userService.deleteUser('1');
      expect(deleteResult).assertTrue();
      expect(userService.getUser('1')).assertUndefined();
    });

    it('should_return_false_when_deleting_nonexistent_user', 0, () => {
      const deleteResult = userService.deleteUser('nonexistent');
      expect(deleteResult).assertFalse();
    });

    it('should_get_all_users', 0, () => {
      const user1 = new User('1', 'John Doe', 'john@example.com', 30);
      const user2 = new User('2', 'Jane Smith', 'jane@example.com', 25);
      
      userService.addUser(user1);
      userService.addUser(user2);
      
      const allUsers = userService.getAllUsers();
      expect(allUsers.length).assertEqual(2);
      expect(allUsers).assertContain(user1);
      expect(allUsers).assertContain(user2);
    });

    it('should_update_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      userService.addUser(user);
      
      const updatedUser = new User('1', 'John Updated', 'updated@example.com', 31);
      const updateResult = userService.updateUser(updatedUser);
      
      expect(updateResult).assertTrue();
      expect(userService.getUser('1')).assertEqual(updatedUser);
    });

    it('should_return_false_when_updating_nonexistent_user', 0, () => {
      const user = new User('1', 'John Doe', 'john@example.com', 30);
      const updateResult = userService.updateUser(user);
      
      expect(updateResult).assertFalse();
    });
  });
}

3. 异步测试

3.1 异步服务测试

// src/main/ets/services/ApiService.ets
export class ApiService {
  async fetchData(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (url.startsWith('https://')) {
          resolve(`Data from ${url}`);
        } else {
          reject(new Error('Invalid URL'));
        }
      }, 100);
    });
  }

  async processUserData(userId: string): Promise<UserData> {
    // 模拟 API 调用
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({
          id: userId,
          name: `User ${userId}`,
          score: Math.random() * 100
        });
      }, 50);
    });
  }
}

export interface UserData {
  id: string;
  name: string;
  score: number;
}
// ohosTest/src/test/ets/test/ApiService.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { ApiService, UserData } from '../../../src/main/ets/services/ApiService';

export default function apiServiceTest() {
  describe('ApiService Tests', () => {
    const apiService = new ApiService();

    it('should_fetch_data_successfully', 0, async () => {
      const result = await apiService.fetchData('https://api.example.com/data');
      expect(result).assertEqual('Data from https://api.example.com/data');
    });

    it('should_reject_invalid_url', 0, async () => {
      try {
        await apiService.fetchData('invalid-url');
        expect(true).assertFalse(); // 不应该执行到这里
      } catch (error) {
        expect(error.message).assertEqual('Invalid URL');
      }
    });

    it('should_process_user_data', 0, async () => {
      const userData: UserData = await apiService.processUserData('123');
      
      expect(userData.id).assertEqual('123');
      expect(userData.name).assertEqual('User 123');
      expect(userData.score).assertLarger(0);
      expect(userData.score).assertLess(100);
    });

    it('should_process_multiple_users', 0, async () => {
      const promises = [
        apiService.processUserData('1'),
        apiService.processUserData('2'),
        apiService.processUserData('3')
      ];
      
      const results = await Promise.all(promises);
      
      expect(results.length).assertEqual(3);
      results.forEach((userData, index) => {
        expect(userData.id).assertEqual((index + 1).toString());
        expect(userData.name).assertEqual(`User ${index + 1}`);
      });
    });
  });
}

4. 组件测试

4.1 自定义组件测试

// src/main/ets/components/CounterComponent.ets
@Component
export struct CounterComponent {
  @State count: number = 0;
  private maxCount: number = 10;

  build() {
    Column() {
      Text(`Count: ${this.count}`)
        .fontSize(20)
        .fontColor(this.count >= this.maxCount ? Color.Red : Color.Black)
      
      Button('Increment')
        .onClick(() => {
          if (this.count < this.maxCount) {
            this.count++;
          }
        })
        .enabled(this.count < this.maxCount)
      
      Button('Reset')
        .onClick(() => {
          this.count = 0;
        })
    }
  }

  // 公共方法用于测试
  increment(): void {
    if (this.count < this.maxCount) {
      this.count++;
    }
  }

  reset(): void {
    this.count = 0;
  }

  getCount(): number {
    return this.count;
  }

  isMaxReached(): boolean {
    return this.count >= this.maxCount;
  }
}
// ohosTest/src/test/ets/test/CounterComponent.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { CounterComponent } from '../../../src/main/ets/components/CounterComponent';

export default function counterComponentTest() {
  describe('CounterComponent Tests', () => {
    let counter: CounterComponent;

    beforeEach(() => {
      counter = new CounterComponent();
    });

    it('should_initialize_with_zero', 0, () => {
      expect(counter.getCount()).assertEqual(0);
      expect(counter.isMaxReached()).assertFalse();
    });

    it('should_increment_count', 0, () => {
      counter.increment();
      expect(counter.getCount()).assertEqual(1);
    });

    it('should_not_exceed_max_count', 0, () => {
      for (let i = 0; i < 15; i++) {
        counter.increment();
      }
      expect(counter.getCount()).assertEqual(10);
      expect(counter.isMaxReached()).assertTrue();
    });

    it('should_reset_count', 0, () => {
      counter.increment();
      counter.increment();
      expect(counter.getCount()).assertEqual(2);
      
      counter.reset();
      expect(counter.getCount()).assertEqual(0);
      expect(counter.isMaxReached()).assertFalse();
    });

    it('should_handle_multiple_operations', 0, () => {
      // 增加 5 次
      for (let i = 0; i < 5; i++) {
        counter.increment();
      }
      expect(counter.getCount()).assertEqual(5);
      
      // 重置
      counter.reset();
      expect(counter.getCount()).assertEqual(0);
      
      // 再次增加
      counter.increment();
      expect(counter.getCount()).assertEqual(1);
    });
  });
}

5. Mock 和 Stub 测试

5.1 依赖注入测试

// src/main/ets/services/WeatherService.ets
export class WeatherService {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async getWeather(city: string): Promise<WeatherData> {
    // 实际应用中这里会调用真实的 API
    throw new Error('Not implemented in tests');
  }
}

export interface WeatherData {
  city: string;
  temperature: number;
  description: string;
}

// 模拟实现用于测试
export class MockWeatherService extends WeatherService {
  constructor() {
    super('test-key');
  }

  override async getWeather(city: string): Promise<WeatherData> {
    return Promise.resolve({
      city: city,
      temperature: 25,
      description: 'Sunny'
    });
  }
}
// ohosTest/src/test/ets/test/WeatherService.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { WeatherService, MockWeatherService, WeatherData } from '../../../src/main/ets/services/WeatherService';

export default function weatherServiceTest() {
  describe('WeatherService Tests', () => {
    it('should_create_weather_service_with_api_key', 0, () => {
      const weatherService = new WeatherService('real-api-key');
      // 可以验证构造函数逻辑
      expect(weatherService).not().assertUndefined();
    });

    it('should_get_weather_data_using_mock', 0, async () => {
      const mockService = new MockWeatherService();
      const weatherData: WeatherData = await mockService.getWeather('Beijing');
      
      expect(weatherData.city).assertEqual('Beijing');
      expect(weatherData.temperature).assertEqual(25);
      expect(weatherData.description).assertEqual('Sunny');
    });

    it('should_handle_multiple_cities_with_mock', 0, async () => {
      const mockService = new MockWeatherService();
      
      const cities = ['Beijing', 'Shanghai', 'Guangzhou'];
      const promises = cities.map(city => mockService.getWeather(city));
      const results = await Promise.all(promises);
      
      expect(results.length).assertEqual(3);
      results.forEach((weatherData, index) => {
        expect(weatherData.city).assertEqual(cities[index]);
        expect(weatherData.temperature).assertEqual(25);
      });
    });
  });
}

6. 测试运行配置

6.1 测试列表文件

// ohosTest/src/test/ets/test/TestList.test.ets
import mathUtilTest from './MathUtil.test.ets';
import userServiceTest from './UserService.test.ets';
import apiServiceTest from './ApiService.test.ets';
import counterComponentTest from './CounterComponent.test.ets';
import weatherServiceTest from './WeatherService.test.ets';

export default function testList() {
  mathUtilTest();
  userServiceTest();
  apiServiceTest();
  counterComponentTest();
  weatherServiceTest();
}

6.2 运行测试

# 在项目根目录运行
./gradlew hmosTest
# 或者
npm test

7. 测试最佳实践

7.1 测试命名规范

  • 测试方法名应该描述性很强
  • 使用 should_ 前缀描述预期行为
  • 测试用例应该独立,不依赖其他测试

7.2 测试组织结构

  • 每个被测试类对应一个测试文件
  • 使用 describe 块组织相关测试
  • 使用 beforeEach 进行测试准备

7.3 断言使用

  • 使用明确的断言方法
  • 一个测试用例一个断言(理想情况)
  • 测试边界条件和异常情况

这样完整的单元测试框架可以确保 ArkTS 代码的质量和可靠性,支持 TDD(测试驱动开发)实践。

React 闭包陷阱详解

React 闭包陷阱详解

什么是闭包陷阱?

React 闭包陷阱是指函数组件中的回调函数(特别是事件处理函数或副作用函数)捕获了过时的状态值,而不是最新的状态值。这是因为这些函数在创建时形成了一个闭包,捕获了当时的变量值。

什么是闭包?

1. 存在外部函数和内部函数

  • 必须有一个外部函数(enclosing function)
  • 外部函数内部定义了内部函数(inner function)

2. 内部函数引用了外部函数的变量

  • 内部函数访问了外部函数作用域中的变量(自由变量)

3. 内部函数在外部函数作用域外被调用

  • 内部函数被返回或在外部函数外部被执行

触发条件

闭包陷阱通常在以下情况下发生:

  1. 在 useEffect 中使用状态但依赖项数组为空
  2. 在异步操作中使用状态
  3. 在事件处理函数中使用状态,但函数在组件挂载时创建

实际案例

案例 1:useEffect 中的闭包陷阱

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 问题:这里的 count 始终是初始值 0
    const timer = setInterval(() => {
      console.log(count); // 总是输出 0
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // 空依赖数组

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

解决方案:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 现在会输出最新的 count 值
  }, 1000);
  
  return () => clearInterval(timer);
}, [count]); // 添加 count 到依赖数组

案例 2:异步操作中的闭包陷阱

function AsyncCounter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    
    // 问题:这里的 count 是点击时的值,不是最新的值
    setTimeout(() => {
      console.log('Current count:', count); // 可能不是最新的值
    }, 3000);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment with delay</button>
    </div>
  );
}

解决方案:

function AsyncCounter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 使用函数式更新确保获取最新状态
    setCount(prevCount => {
      const newCount = prevCount + 1;
      
      setTimeout(() => {
        console.log('Current count:', newCount); // 确保是最新值
      }, 3000);
      
      return newCount;
    });
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment with delay</button>
    </div>
  );
}

案例 3:事件监听器中的闭包陷阱

function EventListenerExample() {
  const [value, setValue] = useState('');

  useEffect(() => {
    const handleKeyPress = (event) => {
      // 问题:这里的 value 始终是空字符串
      console.log('Current value:', value); // 总是空字符串
    };

    document.addEventListener('keypress', handleKeyPress);
    
    return () => {
      document.removeEventListener('keypress', handleKeyPress);
    };
  }, []); // 空依赖数组

  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.value)} 
        placeholder="Type something"
      />
    </div>
  );
}

解决方案:

function EventListenerExample() {
  const [value, setValue] = useState('');
  const valueRef = useRef(value);

  // 保持 ref 与状态同步
  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  useEffect(() => {
    const handleKeyPress = (event) => {
      // 通过 ref 获取最新值
      console.log('Current value:', valueRef.current);
    };

    document.addEventListener('keypress', handleKeyPress);
    
    return () => {
      document.removeEventListener('keypress', handleKeyPress);
    };
  }, []); // 依赖数组可以为空,因为我们使用 ref

  return (
    <div>
      <input 
        value={value} 
        onChange={(e) => setValue(e.target.value)} 
        placeholder="Type something"
    </div>
  );
}

更复杂的案例:多个状态交互

function ComplexExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const sendData = useCallback(async () => {
    // 问题:这里的 count 和 text 可能是过时的值
    const response = await fetch('/api/data', {
      method: 'POST',
      body: JSON.stringify({ count, text })
    });
    // 处理响应...
  }, []); // 空依赖数组,函数不会更新

  useEffect(() => {
    // 假设我们需要在特定条件下发送数据
    if (count > 5) {
      sendData();
    }
  }, [count, sendData]);

  return (
    <div>
      <p>Count: {count}</p>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)} 
        placeholder="Enter text"
      />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

解决方案:

function ComplexExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // 使用 ref 来存储最新状态
  const stateRef = useRef({ count, text });

  // 保持 ref 与状态同步
  useEffect(() => {
    stateRef.current = { count, text };
  }, [count, text]);

  const sendData = useCallback(async () => {
    // 通过 ref 获取最新状态
    const { count, text } = stateRef.current;
    
    const response = await fetch('/api/data', {
      method: 'POST',
      body: JSON.stringify({ count, text })
    });
    // 处理响应...
  }, []); // 依赖数组可以为空

  useEffect(() => {
    if (count > 5) {
      sendData();
    }
  }, [count, sendData]);

  return (
    <div>
      <p>Count: {count}</p>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)} 
        placeholder="Enter text"
      />
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

最佳实践和解决方案总结

  1. 正确使用依赖数组:确保 useEffect、useCallback、useMemo 的依赖数组包含所有使用的外部变量

  2. 使用函数式更新:对于基于前一个状态的计算,使用函数式更新

    setCount(prevCount => prevCount + 1);
    
  3. 使用 useRef 存储可变值:对于需要在回调中访问但不想触发重新渲染的值,使用 useRef

  4. 使用 useCallback 的正确依赖:确保 useCallback 的依赖数组包含所有在回调中使用的变量

  5. 使用自定义 Hook 封装逻辑:将复杂的状态逻辑封装到自定义 Hook 中

// 自定义 Hook 处理闭包问题
function useLatestRef(value) {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  });
  return ref;
}

// 使用示例
function MyComponent() {
  const [count, setCount] = useState(0);
  const countRef = useLatestRef(count);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Latest count:', countRef.current);
    }, 1000);
    
    return () => clearInterval(timer);
  }, []);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

理解 React 闭包陷阱的关键是认识到函数组件每次渲染都会创建新的作用域,而闭包会捕获这些作用域中的变量值。通过正确的依赖管理和使用适当的 React API,可以有效地避免闭包陷阱。

useEffectEvent(实验性 API)

React 团队正在开发一个名为 useEffectEvent的实验性 API,专门用于解决闭包陷阱问题。它允许你在 effect 中读取最新的 props 和 state,而无需将它们声明为依赖项。

import React, { useState, useEffect } from 'react';
import { useEffectEvent } from 'react'; // React 19+ 官方API

function Counter() {
  const [count, setCount] = useState(0);

  // 使用 useEffectEvent 创建一个能访问最新状态的事件处理函数
  const handleTick = useEffectEvent(() => {
    console.log('最新计数:', count); // 这里总是能访问到最新的 count
  });

  useEffect(() => {
    const timer = setInterval(() => {
      handleTick(); // 调用 Effect Event
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // 依赖数组为空,Effect 只执行一次

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
❌