虚拟滚动与无限滚动的完整心法

一次面试,面试官问:"虚拟滚动怎么算可视区域?"我答了个大概。他接着问:"那动态高度呢?前缀和为什么要二分?"我卡住了。后来我花了两周,把这条链路从公式到代码全部跑通。这篇文章就是那两周的沉淀------从原理、图解、边界到可直接运行的 Demo,一篇讲透。


一、先把两个问题分开

很多人把"虚拟滚动"和"无限滚动"混为一谈。面试时这么答,第一句就露怯。它们解决的是两个不同的问题:

问题 虚拟滚动 无限滚动
核心痛点 渲染的 DOM 太多,页面卡 不知道"何时该加载下一页"
解决方式 只渲染可视区 + 缓冲 用 Intersection Observer 观察底部哨兵
性能关键 减少渲染量、索引计算 避免 scroll 回调里同步查布局

一句话 :虚拟滚动管"渲染多少",无限滚动管"何时拉数据"。它们经常配合使用,但不能混着讲。


二、虚拟滚动:到底在解决什么?

假设你有 10000 条数据 。全量渲染,DOM 节点爆炸,滚动直接卡死。但用户屏幕一次只能看到 10 条。

虚拟滚动的思路很朴素:

只渲染用户能看到的这 10 条,再加前后几条缓冲。其他的不渲染,但用一块"空白高度"把滚动条撑起来。

text

yaml 复制代码
滚动容器(可视区高度 500px)
┌─────────────────────────┐
│  ↑ 上面 9900 条,不渲染    │
│  ─────────────────────  │
│  第 5001 条  ← 真正渲染的  │
│  第 5002 条                │
│  ...                       │
│  第 5010 条                │
│  ─────────────────────  │
│  ↓ 下面 4990 条,不渲染    │
└─────────────────────────┘

问题来了:用户滚动时,我怎么知道现在该渲染第几条到第几条? 这就是"动态计算可视区域"。


三、固定高度:一个除法就够了

假设每条高度都是 50px ,容器高度 500px 。用户滚了 1200px。

text

ini 复制代码
1200 ÷ 50 = 24

第 24 条(从 0 开始数)正好在顶部。容器能放 500 ÷ 50 = 10 条,所以渲染第 24 到第 33 条,再加缓冲。

js

ini 复制代码
startIndex = Math.floor(scrollTop / itemHeight)
endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + buffer
offsetY = startIndex * itemHeight

为什么快? 除法是常数时间,不管列表 1 万条还是 100 万条,算一次就出结果。

偏移量是什么? 你只渲染了第 24 到 33 条,但它们在页面上应该出现在"第 24 条的位置",而不是顶部。所以要把这组渲染节点往下推 24 × 50 = 1200px。

关键规则:偏移量 = 渲染区间的第一个索引 × 每条高度。永远跟渲染切片起点走,不跟可视区起点走。

一道容易错的题

scrollTop = 1000,每条 50px,容器 400px,缓冲 2 条(只加尾部),渲染哪几条?偏移多少?

text

ini 复制代码
startIndex = floor(1000 / 50) = 20
visibleCount = ceil(400 / 50) = 8       → 可视区 20 ~ 27
渲染区间 = 20 ~ 27 + 2 = 20 ~ 29
偏移量 = 20 × 50 = 1000px

如果上下都加 2 条缓冲,渲染变成 18~29,偏移就变成 18 × 50 = 900px。渲染起点变了,偏移必须跟着变。


四、动态高度:为什么除法直接崩了?

真实业务里,列表项高度很少固定------消息长短不一、图片高度不同、折叠面板展开收起。

用一组具体数据看除法怎么崩的:

js

ini 复制代码
heights = [40, 80, 60, 120, 60, 140, 60]

scrollTop = 200,视口顶部第一条是第几条?

硬用除法(拿平均高度 80 除):

text

ini 复制代码
200 / 80 = 2.5 → 你以为第 2 条

但真实坐标是这样的:

索引 高度 页面坐标
0 40 0 ~ 40
1 80 40 ~ 120
2 60 120 ~ 180
3 120 180 ~ 300
4 60 300 ~ 360

200 落在 180 ~ 300 里,实际是第 3 条,不是第 2 条。

为什么错? 除法默认"每条一样高",但第 3 条高达 120px,把后面的坐标全推后了。平均高度只是近似,误差会累积。极端一点:

js

ini 复制代码
heights = [40, 40, 40, 40, 40, 500]
scrollTop = 250

平均高度看起来很小,除法告诉你"在第 2 条附近",但实际第 5 条从 200 开始、高 500,250 明明在第 5 条里。除法彻底失效。


五、前缀和 + 二分:动态高度的正解

既然除法不行,就必须知道"前面所有项累计多高"。这就是前缀和。

5.1 前缀和是什么?

js

less 复制代码
heights = [40, 80, 60, 120, 60, 140, 60]

offsets[0] = 0        // 第 0 条从 0 开始
offsets[1] = 40       // 第 1 条从 40 开始
offsets[2] = 120      // 40+80
offsets[3] = 180      // 40+80+60
offsets[4] = 300      // +120
offsets[5] = 360
offsets[6] = 500

offsets = [0, 40, 120, 180, 300, 360, 500]

offsets[i] 就是第 i 条的上边缘在页面上的坐标。

5.2 为什么能二分?------因为单调

很多人会问:"高度不同,为什么坐标还是有序的? "

因为:

js

css 复制代码
offsets[i+1] = offsets[i] + heights[i]

只要 heights[i] > 0,就一定有 offsets[i+1] > offsets[i]。

高度不同,只影响"每一步跨多大",不影响"每一步都往前走"。有序的不是高度,是坐标;坐标有序不是因为排序,是因为高度恒为正、累加只增不减。

虚拟列表的高度不会为负,所以前缀和天然严格递增,不需要任何额外排序。

5.3 二分到底在比什么?

已知 scrollTop = 200,要找:

第一个满足 offsets[i] + heights[i] > 200 的 i(即"覆盖 200 的那一条")

先算底部坐标:

js

ini 复制代码
bottoms = [40, 120, 180, 300, 360, 500, 560]

在 bottoms 上二分找第一个 > 200 的数:

text

ini 复制代码
初始:    [0 ───────────────────────── 6]
第1步:   [0 ───────── 3]              (300 > 200,砍掉右边)
第2步:           [2 ─ 3]              (120 ≤ 200,砍掉左边)
第3步:               [3]              (180 ≤ 200,left 移到 3)
                      ↑
                    答案 = 3

1 万条数据,最多约 14 次比较。这就是 O(log n)。

js

ini 复制代码
function findStartIndex(scrollTop) {
  let lo = 0;
  let hi = TOTAL;
  while (lo < hi) {
    const mid = (lo + hi) >>> 1;
    if (offsets[mid] + heightFor(mid) <= scrollTop) {
      lo = mid + 1;   // 底部没超过 scrollTop,答案在右边
    } else {
      hi = mid;       // 覆盖了 scrollTop,答案在左边或就是它
    }
  }
  return Math.min(lo, TOTAL - 1);
}

易错点 :> 目标 时只能 right = mid,不能 mid - 1,因为 mid 本身可能就是答案。

5.4 动态高度的数据从哪来?------ResizeObserver

没渲染的项没有真实高度。所以前缀和里的高度是 "已测量真实值 + 未测量预估值"的混合。

js

ini 复制代码
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const index = Number(entry.target.dataset.index);
    const newHeight = entry.contentRect.height;
    if (newHeight !== heights[index]) {
      onHeightChange(index, newHeight);
    }
  }
});

关键认知 :高度不是"每次计算",而是 "渲染到哪条,就测哪条" 。已测过的缓存,不重复测。

5.5 高度变了,怎么更新?------增量后缀

第 k 条高度从 80 变成 120,delta = +40。只需要:

js

ini 复制代码
// offsets[k+1..] 全部 +40(第 k 条之后的起点后移)
for (let i = k + 1; i <= TOTAL; i++) {
  offsets[i] += delta;
}

为什么只改后缀? 因为第 k 条之前的项,位置完全没变。

text

复制代码
第0条 起点 0    不变
第1条 起点 80   不变
第2条 起点 160  不变(自己起点没变,只是变高了)
第3条 起点 240 → 280  变了
第4条 起点 320 → 360  变了

滚动是高频操作,但它只做二分 O(log n);前缀和是低频操作,才做 O(n) 更新。两者不叠加。


六、滚动锚定:不修正 scrollTop,画面就会跳

这是动态高度虚拟列表区分"能用"和"好用" 的关键。

当第 k 条高度从预估变真实,且 k 在可视区上方 时,它变高会把可视区内容往下推。用户看到画面跳了一下。

解法:

js

ini 复制代码
// 变化前:记录锚点
const anchorIndex = findStartIndex(scrollTop);
const anchorOffset = scrollTop - offsets[anchorIndex];

// 更新 offsets 后:把 scrollTop 修正回去
scrollTop = offsets[anchorIndex] + anchorOffset;

效果:不管第 k 条高度怎么变,用户当前看的那条内容位置不变。

同时,滚动容器要设置:

css

css 复制代码
.scroll-container {
  overflow-anchor: none; /* 关闭浏览器原生滚动锚定,避免两套机制打架 */
}

七、无限滚动:为什么不能用 scroll 事件?

传统写法:

js

ini 复制代码
container.addEventListener('scroll', () => {
  const rect = sentinel.getBoundingClientRect();
  if (rect.top < window.innerHeight) loadMore();
});

问题 :scroll 事件按像素高频触发,getBoundingClientRect() 会强制浏览器立刻计算布局(同步 reflow)。滚动回调里反复查布局,主线程极易掉帧。

Intersection Observer 快在哪?

最核心两点:

第一,避免在滚动回调里强制同步布局。 IO 把"交叉判断"交给浏览器底层调度,不需要你在 JS 里同步查布局。

第二,触发频率从"每次滚动"降到"状态变化"。 scroll 滚 1000px 可能触发几十次,IO 只在"进入/离开"时各触发一次。

js

scss 复制代码
const io = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) loadMore();
}, {
  root: container,
  rootMargin: '0px 0px 200px 0px', // 提前 200px 触发
  threshold: 0
});
io.observe(sentinel);

IO 快,不是因为"不算布局",而是因为它不在高频 scroll 回调里同步算布局,并且只在状态变化时通知你。

四个工程细节

细节 做法 原因
rootMargin 底部提前 200px 给网络请求留提前量,但别太大,否则一次触发多页
单例 observer 只建一个,回调用 ref 拿最新函数 避免每次 loadMore 重建 observer
及时 unobserve 加载完 io.unobserve(sentinel) 防止无效触发和内存泄漏
loading 标志位 请求中不重复触发 防止快速滚动重复请求

八、最容易被忽略的边界:大跳转到未渲染区域

用户从顶部直接拖滚动条到第 8000 条,但第 8000 条附近从来没渲染过,全是预估值。会发生什么?

两个独立的问题

问题 A:定位偏差。 真实平均高度 100px,预估 80px。用户想看第 8000 条,正确坐标 800000,但预估前缀和里 800000 对应第 10000 条。偏差 2000 条。

问题 B:渲染后跳动。 假设定位碰巧准了,渲染后测出真实高度 100px,比预估多 20px。可视区内容下移 20px,画面跳动。

偏差是"定位错了",跳动是"定位对了但画面不稳"。这是两个问题,解法不同。

工程上怎么兜底?

手段 解决什么 做法
预估值更准 偏差 样本测量、按类型分档、动态校准
拖动中近似 + 停下后校准 偏差 拖动中快速定位,debounce 100ms 后精确修正
滚动锚定 跳动 记录锚点,更新后修正 scrollTop
惰性校准 全局误差 不追求全局准确,只保证可视区及附近准确

核心取舍:

不追求全局前缀和 100% 准确,只保证"当前可视区及附近"准确。 用户滚到哪,哪就变准。

锚定救不了偏差。 锚定只保证"当前可视区不跳",不保证"定位到用户想去的地方"。偏差要靠预估值更准或用户自己微调。面试时能区分这两者,说明你真的想通了。


九、可运行 Demo(Vue 3 单文件,双击即可跑)

下面是一个单 HTML 文件、CDN 引入 Vue 3 的完整 Demo,覆盖了上面所有点:ResizeObserver 测高、前缀和、二分定位、滚动锚定、IO 无限滚动、大跳转边界。

html

ini 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Vue3 动态高度虚拟列表 + 无限滚动</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, sans-serif; background: #f0f2f5; padding: 20px; display: flex; justify-content: center; }
  .app { width: 480px; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.12); overflow: hidden; }
  .header { padding: 14px 18px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; font-size: 13px; color: #666; }
  .header strong { color: #1a1a1a; font-size: 15px; }
  .scroll-container { height: 560px; overflow-y: auto; position: relative; overflow-anchor: none; }
  .phantom { position: relative; width: 100%; }
  .slice { position: absolute; top: 0; left: 0; width: 100%; will-change: transform; }
  .item { padding: 14px 18px; border-bottom: 1px solid #f0f0f0; }
  .item .idx { display: inline-block; font-size: 11px; font-weight: 700; color: #8b5cf6; background: #f3e8ff; padding: 2px 8px; border-radius: 99px; margin-bottom: 6px; }
  .item .title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
  .item .desc { font-size: 12px; color: #888; line-height: 1.5; }
  .sentinel { height: 1px; }
  .loading-bar { text-align: center; padding: 12px; font-size: 12px; color: #aaa; }
  .controls { padding: 10px 18px; border-top: 1px solid #eee; display: flex; gap: 8px; }
  .controls button { flex: 1; padding: 8px; border: 1px solid #ddd; background: #fafafa; border-radius: 8px; font-size: 12px; cursor: pointer; }
  .controls button.primary { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
</style>
</head>
<body>
<div id="app">
  <div class="app">
    <div class="header">
      <strong>动态高度虚拟列表</strong>
      <span>{{ statText }}</span>
    </div>
    <div ref="containerRef" class="scroll-container" @scroll.passive="onScroll">
      <div class="phantom" :style="{ height: phantomHeight + 'px' }">
        <div class="slice" :style="{ transform: `translateY(${offsetY}px)` }">
          <div v-for="item in renderItems" :key="item.index"
               :ref="(el) => setItemRef(el, item.index)" class="item">
            <span class="idx">#{{ item.index }}</span>
            <div class="title">{{ item.data.title }}</div>
            <div class="desc" :style="{ minHeight: item.data.heightHint - 70 + 'px' }">
              {{ item.data.desc }}
            </div>
          </div>
        </div>
      </div>
      <div ref="sentinelRef" class="sentinel"></div>
      <div v-if="loading" class="loading-bar">加载中...</div>
    </div>
    <div class="controls">
      <button @click="jumpTo(8000)">跳到第 8000 条</button>
      <button @click="jumpTo(0)">回到顶部</button>
      <button class="primary" @click="resetAll">重置</button>
    </div>
  </div>
</div>

<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp, ref, computed, onMounted, onBeforeUnmount, nextTick, shallowRef } = Vue;

const App = {
  setup() {
    const TOTAL = 10000;
    const ESTIMATED_HEIGHT = 80;
    const BUFFER = 3;

    const rawData = Array.from({ length: TOTAL }, (_, i) => ({
      id: i,
      title: `第 ${i} 条数据`,
      desc: `这是第 ${i} 条数据的描述。高度动态变化,模拟真实业务场景。`,
      heightHint: 44 + Math.floor(Math.random() * 176)
    }));

    const measuredHeights = new Map();
    const offsets = new Float64Array(TOTAL + 1);
    let observedEls = new WeakSet();
    let rafId = 0;
    let io = null;
    let ro = null;

    const loadedCount = ref(200);
    const hasMore = ref(true);
    const loading = ref(false);
    const renderStart = ref(0);
    const renderEnd = ref(0);
    const renderItems = shallowRef([]);
    const offsetY = ref(0);
    const phantomHeight = ref(0);
    const containerRef = ref(null);
    const sentinelRef = ref(null);

    const statText = computed(
      () => `已加载 ${loadedCount.value} 条 · 渲染 ${renderStart.value}~${Math.max(0, renderEnd.value - 1)}`
    );

    function heightFor(i) {
      const m = measuredHeights.get(i);
      return m != null ? m : ESTIMATED_HEIGHT;
    }

    function rebuildOffsets() {
      let acc = 0;
      offsets[0] = 0;
      for (let i = 0; i < TOTAL; i++) {
        offsets[i] = acc;
        acc += heightFor(i);
      }
      offsets[TOTAL] = acc;
    }

    function applyHeightDelta(k, delta) {
      if (!delta) return;
      for (let i = k + 1; i <= TOTAL; i++) offsets[i] += delta;
    }

    function findStartIndex(scrollTop) {
      let lo = 0, hi = TOTAL;
      while (lo < hi) {
        const mid = (lo + hi) >>> 1;
        if (offsets[mid] + heightFor(mid) <= scrollTop) lo = mid + 1;
        else hi = mid;
      }
      return Math.min(lo, TOTAL - 1);
    }

    function render() {
      const el = containerRef.value;
      if (!el) return;
      const st = el.scrollTop, vh = el.clientHeight;
      const limit = Math.min(TOTAL, loadedCount.value);
      let first = findStartIndex(st);
      if (first >= limit) first = Math.max(0, limit - 1);
      let end = first;
      while (end < limit) {
        if (offsets[end] + heightFor(end) > st + vh) break;
        end++;
      }
      end = Math.min(end + BUFFER, limit);
      let start = Math.max(0, first - BUFFER);
      if (start >= end) start = Math.max(0, end - 1);

      renderStart.value = start;
      renderEnd.value = end;

      const items = [];
      for (let i = start; i < end; i++) items.push({ index: i, data: rawData[i] });
      renderItems.value = items;
      offsetY.value = offsets[start];
      phantomHeight.value = offsets[limit];
    }

    function setItemRef(el, index) {
      if (!el || !ro) return;
      el._index = index;
      if (observedEls.has(el)) return;
      observedEls.add(el);
      ro.observe(el);
    }

    function onResize(entries) {
      const el = containerRef.value;
      if (!el) return;
      const stBefore = el.scrollTop;
      const anchorIndex = findStartIndex(stBefore);
      const anchorOffset = stBefore - offsets[anchorIndex];
      let needsRender = false, minChanged = Infinity;

      for (const entry of entries) {
        const target = entry.target;
        const index = target._index;
        if (index == null) continue;
        const newH = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
        const oldH = measuredHeights.get(index);

        if (oldH == null) {
          const delta = newH - ESTIMATED_HEIGHT;
          measuredHeights.set(index, newH);
          if (delta !== 0) {
            applyHeightDelta(index, delta);
            needsRender = true;
            if (index < minChanged) minChanged = index;
          }
        } else if (Math.abs(newH - oldH) > 0.5) {
          const delta = newH - oldH;
          measuredHeights.set(index, newH);
          applyHeightDelta(index, delta);
          needsRender = true;
          if (index < minChanged) minChanged = index;
        }
      }

      if (needsRender) {
        if (minChanged <= anchorIndex) el.scrollTop = offsets[anchorIndex] + anchorOffset;
        render();
      }
    }

    function onScroll() {
      if (rafId) return;
      rafId = requestAnimationFrame(() => { rafId = 0; render(); });
    }

    function setupInfinite() {
      if (io) io.disconnect();
      if (!sentinelRef.value) return;
      io = new IntersectionObserver((entries) => {
        if (!entries[0].isIntersecting) return;
        if (loading.value || !hasMore.value) return;
        loadMore();
      }, { root: containerRef.value, rootMargin: '0px 0px 200px 0px', threshold: 0 });
      io.observe(sentinelRef.value);
    }

    function loadMore() {
      if (loading.value || !hasMore.value) return;
      loading.value = true;
      setTimeout(() => {
        loadedCount.value = Math.min(TOTAL, loadedCount.value + 200);
        loading.value = false;
        if (loadedCount.value >= TOTAL) {
          hasMore.value = false;
          if (io) io.unobserve(sentinelRef.value);
        }
        render();
      }, 300);
    }

    function jumpTo(index) {
      const el = containerRef.value;
      if (!el) return;
      if (index >= loadedCount.value) {
        loadedCount.value = Math.min(TOTAL, index + 100);
        phantomHeight.value = offsets[loadedCount.value];
      }
      nextTick(() => { el.scrollTop = offsets[index]; render(); });
    }

    function resetAll() {
      if (io) { io.disconnect(); io = null; }
      if (ro) ro.disconnect();
      ro = new ResizeObserver(onResize);
      observedEls = new WeakSet();
      measuredHeights.clear();
      rebuildOffsets();
      loadedCount.value = 200;
      hasMore.value = true;
      loading.value = false;
      renderStart.value = 0;
      renderEnd.value = 0;
      renderItems.value = [];
      offsetY.value = 0;
      phantomHeight.value = 0;
      if (containerRef.value) containerRef.value.scrollTop = 0;
      nextTick(() => { render(); setupInfinite(); });
    }

    onMounted(() => {
      ro = new ResizeObserver(onResize);
      rebuildOffsets();
      render();
      nextTick(() => setupInfinite());
    });

    onBeforeUnmount(() => {
      if (ro) ro.disconnect();
      if (io) io.disconnect();
      if (rafId) cancelAnimationFrame(rafId);
    });

    return {
      statText, containerRef, sentinelRef, phantomHeight, offsetY,
      renderItems, loading, onScroll, setItemRef, jumpTo, resetAll
    };
  }
};

createApp(App).mount('#app');
</script>
</body>
</html>

运行方式 :保存为 index.html,双击用 Chrome 打开即可。如果 CDN 不通,把 unpkg.com 换成 cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js。



十、面试怎么答?

按这个结构说,面试官会觉得你有体系:

先分问题:虚拟滚动解决"渲染太多",无限滚动解决"何时加载"。

再讲方案:固定高度用除法 O(1);动态高度用 ResizeObserver + 前缀和 + 二分 O(log n);无限滚动用 IO 哨兵替代 scroll 监听。

再讲权衡:固定高度快但受限;动态高度灵活但要处理测量、锚定;IO 异步低开销,但要注意单例、rootMargin、unobserve。

最后讲坑:快速滚动白屏、高度变化跳动、大跳转偏差、重复请求。

三个高频追问的标准答案:

  • 为什么不用 scroll? scroll 高频触发,getBoundingClientRect 强制同步布局;IO 由浏览器异步调度,只在状态变化时回调。
  • 动态高度为什么二分? 前缀和单调递增,找"第一个底部超过 scrollTop"就是有序数组查找,O(log n)。
  • 大跳转偏差怎么办? 预估值更准 + 拖动中近似停下后校准;跳动靠滚动锚定,偏差靠预估,两者独立。

十二、总结

把整条链路浓缩成一张图:

text

markdown 复制代码
数据 → 预估值/实测值 → 前缀和 → 滚动时二分定位 → 渲染切片 + 偏移
                                  ↑
              ResizeObserver 测高 → 增量更新后缀 → 滚动锚定
                                  ↑
              IntersectionObserver 哨兵 → 拉下一页

三句话记住:

  1. 固定高度靠除法,动态高度靠前缀和 + 二分。 有序的不是高度,是坐标。
  2. 滚动只做 O(log n) 二分,前缀和只在高度变化时增量更新后缀。 两者不叠加。
  3. 偏差靠预估值,跳动靠锚定。 大跳转是预估值误差最大的场景,靠"拖动中近似 + 停下后校准 + 锚定防跳"兜底。
相关推荐
乘风gg1 小时前
AI coding 时代最值钱的东西,软件工程(下)
前端·ai编程·claude
核验笔记的老王1 小时前
前端表单防呆实践:如何排查自动填充导致的提交失败?
前端
用户406122081111 小时前
Vue vs React 核心差异全对比
前端
李纲明1 小时前
微信小程序 web-view 打开空白或提示未配置业务域名:校验文件、HTTPS 与跳转域名排查
前端·微信小程序·https·web-view·小程序开发·业务域名
涛涛ing1 小时前
一个框架为了“不水合”,把HTML注释都删了
前端
计算机魔术师2 小时前
AI渗透框架3小时破云,中国黑客组织正在升级打法
前端
蒲公英内测分发2 小时前
智能手环出海测试,海外合作方分散在不同国家,Android 测试包怎么持续分发和更新?
android·前端·人工智能·测试工具·智能手表
朱涛的自习室2 小时前
高价软件已死,我让 AI 开启了「平替计划」(Day 7 / 7)
android·前端·github
沙蒿同学2 小时前
Wails v3 实战:一个桌面提醒客户端,从 v2 升到 v3 的全过程
前端·后端·github