如何使用GSAP实现一个 `pinned` 滚动楼层叙事?

作者:Mh日期:2026/8/11

准备工作

技术栈: vue + gsap

最近在做的一个动画,特意去网上查了一下叫 pinned 滚动叙事,感觉蛮有趣,分享给你们。

实现的动画如下:

对应的模仿网址是:www.vaporesso.com/series-prod…

思考过程🤔

多滚动几次上面的动画,感官上大致可以得出一个粗浅的感受。

页面滚到某一段时,画面突然停住了。你继续滚动,页面没有向下走,而是像一条时间线一样开始播放:卡片移动到中心、放大、图片和文字拉开距离,然后卡片退场,背后的内容一层一层覆盖进来。等这一段讲完,卡片又回到原位,下一个卡片接着进入页面中心,重复类似上一个卡片的动作。

从上面的动画中可以看出,实际上页面一直是固定在屏幕中间的,只是通过鼠标滚动,控制 GSAP 时间轴的播放进度。相信这对于了解GSAP的同学一定很熟悉。

既然大致的思路有了,接下来就是如何去构建页面结构了。

第一步:拆解DOM

根据上面的已知了解,我们可以将上面的动作拆分成两部分,第一部分是卡片,第二部分是详情内容面板

所以你大致可以得出这样的DOM

1<template>
2  <div class="container">
3    <!-- 固定在屏幕中的舞台 -->
4    <div class="story-stage">
5      <!-- 第一层:概览卡片轨道 -->
6      <div class="story-card">
7        <article class="story-item"></article>
8        <article class="story-item"></article>
9        <article class="story-item"></article>
10      </div>
11
12      <!-- 第二层:详情内容面板 -->
13      <div class="story-panel">
14        <section class="story-section"></section>
15        <section class="story-section"></section>
16        <section class="story-section"></section>
17      </div>
18    </div>
19  </div>
20</template>
21

这一步是确定层级关系:

  1. container:负责撑开滚动距离。
  2. story-stage:固定在视口中的舞台。
  3. story-card:三张卡片组成的轨道,后面会整体移动。
  4. story-panel:详情内容的叠放容器。
  5. story-section:每一个需要淡入淡出的详情楼层。

第二步:确定布局关系

既然DOM已经有了,接下来就是确定布局关系了,在第一步将DOM拆分成了卡片轨道、详情面板、固定舞台,这样就可以写出简化的css了。

1.container {
2  position: relative;
3  height: 1200vh;
4}
5
6.story-stage {
7  position: sticky;
8  top: 0;
9  height: 100vh;
10  overflow: hidden;
11}
12
13.story-card,
14.story-panel {
15  position: absolute;
16  inset: 0;
17}
18
19.story-card {
20  display: grid;
21  grid-template-columns: repeat(3, 1fr);
22  align-items: center;
23}
24
25.story-section {
26  position: absolute;
27  inset: 0;
28  opacity: 0;
29  visibility: hidden;
30}
31

这样一来,页面滚动时,真正被滚动条推进的是 container 的高度,而用户看到的画面始终停留在 story-stage 这一屏里。

后面的动画,其实都是在这一屏固定舞台里做状态切换。

第三步:准备数据结构

布局确定之后,就可以考虑数据结构了。

因为一张卡片对应一组详情内容,所以这里不适合把 DOM 写死。更好的方式是用一份 storyGroups 数据同时生成卡片和详情 section。

1const storyGroups= [
2  {
3    id: 'signal',
4    kicker: 'Insight Layer',
5    title: '城市信号',
6    description: '从街区、交通和实时事件中提炼趋势。',
7    accent: '#35b7a8',
8    cardImage:
9      'https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1200&q=80',
10    panels: [
11      {
12        id: 'map',
13        kicker: 'Live Map',
14        title: '街区热度被压缩到一屏',
15        description: '用叠层图像表达空间变化,滚动只改变当前楼层透明度,不产生普通页面楼层。',
16        image:
17          'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1600&q=80',
18        stats: [
19          { value: '12', label: '重点街区' },
20          { value: '4.8x', label: '峰值变化' },
21        ],
22      },
23      {
24        id: 'motion',
25        kicker: 'Video',
26        title: '视频楼层进入时重新播放',
27        description: '当前视频只在对应内容完全进入时播放,离开或被下一层覆盖后立即暂停。',
28        image:
29          'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1600&q=80',
30        video: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
31      },
32      {
33        id: 'brief',
34        kicker: 'Decision',
35        title: '最后收束成行动摘要',
36        description: '同一组内容播放完成后,卡片回到初始位置,为下一组叙事让出节奏。',
37        image:
38          'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80',
39        stats: [
40          { value: '82%', label: '信号命中' },
41          { value: '18m', label: '刷新周期' },
42        ],
43      },
44    ],
45  },
46  {
47    id: 'craft',
48    kicker: 'Product Layer',
49    title: '产品工艺',
50    description: '把材质、结构和细节拆成连续镜头。',
51    accent: '#e86d5b',
52    cardImage:
53      'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1200&q=80',
54    panels: [
55      {
56        id: 'material',
57        kicker: 'Material',
58        title: '材质镜头先占据全屏',
59        description: '图片楼层绝对定位在 pinned 容器中,前后内容通过 scrub 时间轴交叉淡入淡出。',
60        image:
61          'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80',
62        stats: [
63          { value: '0.6mm', label: '边缘精度' },
64          { value: '32', label: '工艺步骤' },
65        ],
66      },
67      {
68        id: 'assembly',
69        kicker: 'Assembly',
70        title: '结构细节在原屏内切换',
71        description: '不是跳转,不是锚点,也不是后续普通楼层,而是同一个 timeline 的连续片段。',
72        image:
73          'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1600&q=80',
74      },
75      {
76        id: 'quality',
77        kicker: 'Quality',
78        title: '质检数据随后覆盖进来',
79        description: '每组内容结束后,概览卡片重新出现、缩小并恢复横排状态。',
80        image:
81          'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1600&q=80',
82        stats: [
83          { value: '99.2%', label: '通过率' },
84          { value: '7', label: '关键检测' },
85        ],
86      },
87    ],
88  },
89  {
90    id: 'future',
91    kicker: 'Experience Layer',
92    title: '未来体验',
93    description: '用滚动串联场景、情绪和最终状态。',
94    accent: '#d2a63f',
95    cardImage:
96      'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80',
97    panels: [
98      {
99        id: 'scene',
100        kicker: 'Scene',
101        title: '第一幕建立场景关系',
102        description: '卡片进入中心后放大,内部图像上移、文案下移,再把舞台交给内容楼层。',
103        image:
104          'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1600&q=80',
105      },
106      {
107        id: 'loop',
108        kicker: 'Video',
109        title: '动态片段承接情绪峰值',
110        description: '视频显示时从头开始,继续滚动离开后暂停,回滚进入也会重新对齐播放状态。',
111        image:
112          'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80',
113        video: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
114      },
115      {
116        id: 'finish',
117        kicker: 'Final',
118        title: '最后回到完整概览',
119        description: '第三组结束后粘性定位结束,页面才继续向下滚动。',
120        image:
121          'https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1600&q=80',
122        stats: [
123          { value: '3', label: '概览卡片' },
124          { value: '9', label: '叠层内容' },
125        ],
126      },
127    ],
128  },
129]
130
131

这样做的好处是,动画流程只需要写一套。后面如果要增加第四张卡片,只需要继续往数组里加数据。

第四步:计算轨道移动距离

这里有一个关键点:我需要移动的是整条卡片轨道,而不是单独移动某一张卡片。

所以当某张卡片要进入中心时,本质上是计算:

整条轨道需要移动多少,才能让当前卡片的中心点和舞台中心点重合。

1const getMoveX = targetCard => () => {
2  const stageRect = stageRef.value.getBoundingClientRect()
3  const cardRect = targetCard.getBoundingClientRect()
4  const stageCenter = stageRect.left + stageRect.width / 2
5  const cardCenter = cardRect.left + cardRect.width / 2
6  return stageCenter - cardCenter
7}
8

轨道需要移动的距离 = 舞台中心点 - 当前卡片中心点

如果结果是负数,说明卡片在舞台中心右侧,轨道需要往左移动;如果结果是正数,说明卡片在舞台中心左侧,轨道需要往右移动。

第五步:编排卡片动画

有了轨道移动距离之后,就可以开始编排单张卡片的动画了。

每一张卡片的动画都可以看成同一套流程:

  1. 找到当前卡片;
  2. 找到除当前卡片外的其他卡片;
  3. 移动整条轨道,让当前卡片进入中心;
  4. 其他卡片淡出;
  5. 当前卡片放大并淡出;
  6. 图片上移,文案下移;
  7. 整条轨道淡出,交给后面的详情 section。
1const addCardSequence = (timeline, group) => {
2  const cardNodes = getCardNodes(group.id)
3  if (!cardNodes) return
4
5  const { card, image, copy } = cardNodes
6  const cards = cardRef.value
7  const cardTrack = cardTrackRef.value
8  if (!cardTrack) return
9
10  const otherCards = cards.filter(item => item !== card)
11  const currentPanels = getPanelsByGroupId(group.id)
12
13  // 第一段:轨道整体移动到目标卡片居中,然后卡片放大淡出,交给详情面板。
14  timeline
15    .set(cards, { zIndex: 1 })
16    .set(card, { zIndex: 3 })
17    .set(cardTrack, { autoAlpha: 1 })
18    .to(cardTrack, { x: getMoveX(card), duration: 0.85 })
19    .to(otherCards, { opacity: 0, scale: 0.94, filter: 'saturate(0.55)', duration: 0.38 }, '<+=0.12')
20    .to(card, { opacity: 0, scale: 4, duration: 0.72 }, '<+=0.18')
21    .to(image, { y: -54, scale: 1.06, duration: 0.72 }, '<')
22    .to(copy, { y: 58, opacity: 0, duration: 0.58 }, '<')
23    .to(cardTrack, { autoAlpha: 0, duration: 0.28 }, '<+=0.35')
24
25  // 中间的内容
26  addPanelSequence(timeline, currentPanels, '<+=0.18')
27
28  // 第二段:详情面板结束后,先恢复当前卡片尺寸,再把整条轨道移回初始位置。
29  timeline
30    .to(cardTrack, { autoAlpha: 1, duration: 0.28 }, '>')
31    .to(card, { opacity: 1, scale: 1, duration: 0.75 }, '<')
32    .to(image, { y: 0, scale: 1, duration: 0.75 }, '<')
33    .to(copy, { y: 0, opacity: 1, duration: 0.65 }, '<')
34    .to(cardTrack, { x: 0, duration: 0.75 }, '>')
35    .to(otherCards, { opacity: 1, scale: 1, filter: 'saturate(1)', duration: 0.7 }, '<+=0.12')
36    .set(card, { zIndex: 1 })
37}
38

这里卡片使用的是 opacity,而不是 autoAlpha。

因为 autoAlpha 会额外控制 visibility,在反向滚动时可能会让后面的卡片残留隐藏状态。单个卡片只需要透明度变化,用 opacity 更合适。

第六步:处理详情 section 切换

卡片退场后,背后的详情内容开始依次出现。

每个 section 的逻辑都一样:淡入、停留、淡出。

1const addPanelSequence = (timeline, panels) => {
2  panels.forEach((panel, index) => {
3    const previousPanel = panels[index - 1]
4
5    if (previousPanel) {
6      timeline.to(previousPanel, { autoAlpha: 0, duration: 0.45 }, '>')
7    }
8
9    timeline
10      .to(panel, { autoAlpha: 1, duration: 0.55 }, previousPanel ? '<' : '>')
11      .to({}, { duration: 0.75 })
12  })
13}
14

这里的 .to({}, { duration: 0.75 }) 是一个空动画。

它不改变任何 DOM,只是占用时间线长度。因为动画被滚动控制,所以它的作用就是给当前 section 留出阅读时间。

第七步:卡片恢复和轨道归位

详情内容播放完成后,不能直接把卡片重置回原始状态。

更自然的顺序应该是:先让当前卡片缩回原始大小,再让整条轨道回到初始位置。

1timeline
2  .to(cardTrack, { autoAlpha: 1, duration: 0.28 }, '>')
3  .to(card, { opacity: 1, scale: 1, duration: 0.75 }, '<')
4  .to(image, { y: 0, scale: 1 }, '<')
5  .to(copy, { y: 0, opacity: 1 }, '<')
6  .to(cardTrack, { x: 0, duration: 0.75 }, '>')
7  .to(otherCards, { opacity: 1, scale: 1, filter: 'saturate(1)' }, '<+=0.12')
8

这样用户能看到卡片从详情状态重新收回到概览状态,过渡会更完整。

第八步:绑定 ScrollTrigger

最后,把整条 timeline 交给 ScrollTrigger 控制。

1const timeline = gsap.timeline({
2  defaults: { ease: 'power2.inOut' },
3  scrollTrigger: {
4    trigger: containerRef.value,
5    start: 'top top',
6    end: 'bottom bottom',
7    scrub: 1,
8    invalidateOnRefresh: true,
9  },
10})
11

scrub: 1 表示滚动进度和动画进度绑定,并带一点缓冲。 invalidateOnRefresh: true 很重要,因为轨道移动距离是动态计算的,窗口尺寸变化后需要重新计算。

第九步:处理视频播放

如果详情 section 里只有图片,动画切换到这里基本就结束了。但如果某个 section 里放了视频,还需要额外处理播放状态。

原因很简单:视频不会因为它所在的 section 透明度变成 0 就自动暂停。如果不处理,可能会出现两个问题:

  1. section 已经淡出了,但视频还在后台继续播放;
  2. 用户反向滚动回来时,视频不是从头开始,而是从中间继续播放。

所以这里需要根据当前可见的 section,动态控制视频播放。

先定义一个变量记录当前正在播放的视频:

1let activeVideo = null
2

然后封装一个从头播放视频的方法:

1const playFromStart = video => {
2  try {
3    video.currentTime = 0
4    video.play().catch(() => undefined)
5  } catch {
6    video.play().catch(() => undefined)
7  }
8}
9

这里用了 try...catch 和 .catch(),是因为浏览器对自动播放有策略限制。虽然视频已经设置了 muted 和 playsinline,但 video.play() 仍然可能失败,所以这里做一个兜底,不让播放失败影响主动画。

接着封装暂停所有视频的方法:

1const pauseAllVideos = () => {
2  activeVideo = null
3
4  containerRef.value?.querySelectorAll('video').forEach(video => {
5    video.pause()
6  })
7}
8

然后在初始化动画时,先找到所有视频,以及它们所在的 section:

1const videos = Array.from(containerRef.value.querySelectorAll('video'))
2
3const videoPanels = videos
4  .map(video => ({
5    video,
6    panel: video.closest('.story-section'),
7  }))
8  .filter(item => item.panel)
9

有了这个映射关系之后,就可以在 timeline 更新时判断当前该播放哪个视频:

1const syncVideos = () => {
2  const visibleVideo =
3    videoPanels.find(({ panel }) => Number(gsap.getProperty(panel, 'opacity')) > 0.65)
4      ?.video ?? null
5
6  videos.forEach(video => {
7    if (video !== visibleVideo) {
8      video.pause()
9    }
10  })
11
12  if (!visibleVideo || activeVideo === visibleVideo) {
13    if (!visibleVideo) activeVideo = null
14    return
15  }
16
17  activeVideo = visibleVideo
18  playFromStart(visibleVideo)
19}
20

这里的判断逻辑是:

  • 如果某个 section 的透明度大于 0.65,就认为它已经进入可见状态;
  • 如果这个 section 里有视频,就把它作为当前视频;
  • 其他视频全部暂停;
  • 如果当前视频已经在播放,就不要重复 currentTime = 0,否则滚动过程中视频会一直被重置。

最后,把 syncVideos 挂到 timeline 的 onUpdate 上:

1const timeline = gsap.timeline({
2  defaults: { ease: 'power2.inOut' },
3  onUpdate: syncVideos,
4  scrollTrigger: {
5    trigger: containerRef.value,
6    start: 'top top',
7    end: 'bottom bottom',
8    scrub: 1,
9    invalidateOnRefresh: true,
10    onLeave: pauseAllVideos,
11    onLeaveBack: pauseAllVideos,
12  },
13})
14

这里的 onLeave 和 onLeaveBack 只做一件事:离开当前滚动动画区域时,暂停所有视频。

这样处理之后,视频就和 section 的显示状态绑定起来了。section 出现,视频从头播放;section 离开,视频暂停。

第十步:组件卸载时清理动画

最后一步是清理。

在 Vue 单页应用里,页面切换时组件会卸载。如果不清理 ScrollTrigger 和 GSAP 动画,下一次再进入页面时,可能会出现多个时间线同时存在、滚动触发重复、动画状态错乱等问题。

所以这里使用 gsap.context() 来管理当前组件里的动画:

1let animationContext = null
2
3onMounted(async () => {
4  await nextTick()
5
6  if (!isReady()) {
7    return
8  }
9
10  animationContext = gsap.context(() => {
11    // 初始化状态
12    gsap.set(cardTrackRef.value, { autoAlpha: 1, x: 0 })
13    gsap.set(cardRef.value, {
14      opacity: 1,
15      scale: 1,
16      transformOrigin: 'center center',
17    })
18    gsap.set(panelRef.value, { autoAlpha: 0 })
19
20    // 创建 timeline
21    const timeline = gsap.timeline({
22      defaults: { ease: 'power2.inOut' },
23      onUpdate: syncVideos,
24      scrollTrigger: {
25        trigger: containerRef.value,
26        start: 'top top',
27        end: 'bottom bottom',
28        scrub: 1,
29        invalidateOnRefresh: true,
30        onLeave: pauseAllVideos,
31        onLeaveBack: pauseAllVideos,
32      },
33    })
34
35    storyGroups.forEach(group => addCardSequence(timeline, group))
36    timeline.call(pauseAllVideos)
37  }, containerRef.value)
38
39  requestAnimationFrame(() => ScrollTrigger.refresh())
40})
41

gsap.context() 的好处是,它会记录在这个回调里创建的 GSAP 动画和 ScrollTrigger。等组件卸载时,只需要调用:

1onBeforeUnmount(() => {
2  pauseAllVideos()
3  animationContext?.revert()
4})
5

这里做了两件事:

  1. pauseAllVideos():暂停可能还在播放的视频;
  2. animationContext?.revert():还原当前组件中创建的动画和 ScrollTrigger。

这样页面切走后,动画不会残留。再次进入页面时,会重新创建一套干净的时间线。

完整代码

代码地址

放在最后的话

虽然上面的代码有很大一部分不是我自己写的,而是借助 AI 生成后,再一步步拆开、调试和修改出来的,但这反而让我更确定一件事:在 AI 时代,代码能力不是变得不重要了,而是换了一种更实际的存在方式。

AI 可以很快给出一个看起来能跑的结果,甚至动画效果也足够惊艳。但当你真的把它放进自己的项目里,就会遇到很多需要判断和微调的地方。

为什么滚动高度要这样设置? 为什么卡片放大淡出后,要先恢复原大小,再移动整条轨道? 为什么 autoAlpha 会影响反向滚动时元素是否还能出现? 为什么视频播放要跟着详情 section 的可见状态同步? 为什么一个小小的时间点调整,就会影响整段动画的丝滑程度?

这些问题,AI 可以给你一个答案,但最终负责的人还是你。你需要看懂它生成的 DOM 结构、时间线顺序、状态切换和边界处理,才知道哪里能删、哪里不能动,哪里只是“看起来复杂”,哪里其实是在保证动画的稳定和流畅。

所以我越来越觉得,AI 并不是让我们不用写代码,而是把“写代码”的重心往前推了一层:从逐行敲实现,变成理解结构、验证逻辑、发现问题、精准微调。能不能看懂 AI 代码,决定了你能不能真正接管它;能不能微调细节,决定了这个结果能不能从 demo 变成项目里可用的东西。

关注我,一个在 AI 时代依旧认真读代码、拆代码、改代码的前端开发者。 我会持续分享前端实战、动画复刻、源码理解和 AI 辅助开发经验,希望我们都能借助 AI 跑得更快,也凭借代码基础走得更稳。


《如何使用GSAP实现一个 pinned 滚动楼层叙事?》 是转载文章,点击查看原文。


相关推荐


06|眼睛:LLM 的「视野」怎么拼出来,又怎么不爆
浪遏2026/8/2

这是《Agent全栈实战》的第 6 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆解 harness 设计。前面我们讲了心脏(Agent Loop)和手脚(工具系统)。这一篇聊「眼睛」——模型每次开口之前,它到底看到了什么。你给 LLM 的那一长串 messages,不是随手拼的,而是一条五层流水线装出来的;而对话一长,这串东西就会撑爆上下文窗口,撞上窗口红线直接 context_length_exceeded。所以这


Java Jetty 实战详解:从嵌入式 HTTP 服务到 Spring Boot 容器替换
唐青枫2026/7/25

简介 Jetty 是 Eclipse 基金会维护的 Java Web 服务器,也是一个 Servlet 容器。 它主要能做这些事: 监听 HTTP 端口 处理 HTTP 请求和响应 运行 Servlet / Filter / Listener 部署 WAR 包 支持 WebSocket、HTTP/2、HTTP/3 作为库嵌入到 Java 程序里启动 作为 Spring Boot 的内嵌容器 一句话概括: Jetty 既能像 Tomcat 一样单独部署 Web 应用,也能像普通 Java 库一样


学习 OpenMontage 的工具发现与打分选择器
日习一技2026/7/17

在前面的几篇文章里,我们已经体验了 OpenMontage 的零成本玩法:用 Piper 配音、用免费素材剪纪录片、用 Remotion 把图片做成动画,全程不花一分钱 API 费用。我们也试过贴一个参考视频,让 agent 帮我们拆解出差异化的制作方案。 不过零成本路径终究有上限。想要 FLUX 的图、Veo 或 Kling 的真实运动镜头、ElevenLabs 的高质量配音,就得接入对应的 provider。OpenMontage 支持的 provider 有几十个,同一个能力往往有好几个、


LeetCode 28. 找出字符串中第一个匹配项的下标
Best_Jerry2026/7/9

leetcode.cn/problems/fi… programmercarl.com/0028.%E5%AE… 给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回  -1 ****。   示例 1: 输入: haystack = "sadbutsad", needle = "sad" 输出: 0 解释: "sad" 在下标 0 和


图解 MongoDB 22|读写关注:持久性与一致性的档位选择
十三Tech2026/7/1

前面几篇多次提到 w: "majority",这篇把它彻底讲清楚。读写关注(read/write concern)是 MongoDB 控制持久性和一致性的核心参数——它们决定了「一个写入要被几个节点确认才算成功」「一个读取从哪个节点读、读到什么程度的一致」。理解了它们,才能在不同业务场景下精准调出「够用且不浪费」的持久性/一致性档位。 先把机制边界说清楚 读写关注是三个相关但独立的参数: writeConcern(写关注):写操作要被几个节点确认才算成功。控制持久性。 readPreferen


图解 MongoDB 05|文档模型设计:内嵌 vs 引用,反范式不是免费午餐
十三Tech2026/6/22

刚从 MySQL 迁到 MongoDB 的人,最容易把关系建模那一套照搬过来:每个实体建一个集合,用 userId、orderId 这种字段做关联,查询时再 $lookup 拼。这种写法能跑,但它把 MongoDB 用成了「没有外键约束的关系库」,丢掉了文档模型最大的优势——访问局部性。 文档模型真正的价值,不是「字段随便加」,而是把一个业务实体的相关信息内嵌成一个文档,应用读一次就能拿到全部信息。但内嵌也不是免费午餐:它换来访问效率的同时,要承担冗余、一致性维护和文档膨胀的成本。这一篇讲清楚内


从 WWDC 26 空间重构(Spatial Reframing)再看端侧 2D 转 3D 的技术演进
Layer2026/6/14

2026 年 6 月 8 日,WWDC26 上苹果发布了空间重构(Spatial Reframing):照片拍完之后,拖动画面重新选择机位,AI 实时补全新视角缺失的内容: 头部玩家在两年内相继入场,这背后是三项能力趋于成熟: 单目深度估计沉淀为基础模型:无需双摄或激光雷达,仅凭一张普通照片推断每个像素的远近;过去这类模型更像“专用工具”,换个场景就容易失准;2024 年前后,香港大学与字节跳动的 Depth Anything V2 的 25M 参数的 Small


Python 迭代器与生成器
copyer_xyf2026/6/7

本文面向已有前端开发基础、正在学习 Python 的开发者。 迭代器和生成器解决的是同一个问题:数据不一定要一次性全部准备好,可以在需要的时候一个一个取出来。前端里最接近的经验是 for...of、Symbol.iterator、生成器函数 function* 和 yield。 这几个概念可以先合在一起记: 可迭代对象表示“可以被遍历的数据源” 迭代器表示“真正负责一步一步取值的对象” 生成器表示“用 yield 快速创建出来的迭代器” 后面的 for 循环,本质上就是先从可迭代对象拿到迭代器


【架构实战】ElasticSearch搜索集群:全文检索的艺术
heimeiyingwang2026/5/31

【架构实战】ElasticSearchæœç´¢é›†ç¾¤ï¼šå ¨æ–‡æ£€ç´¢çš„è‰ºæœ¯ 倒排索引、分片副本、搜索优化、实战案例 ä¸€ã€ä»Žä¸€ä¸ªçœŸå®žçš„æ• äº‹è¯´èµ· 2024年双十一,某电商平台搜索系统在流量洪峰到来的那一刻,突


豆包收费了:3.45亿用户,一个“豆包型人格“的道歉经济学
倔强的石头_2026/5/9

5月4号,两个微博热搜几乎同时炸了——#豆包错误率# 和 #豆包笨还收费#。 前一天,豆包刚刚在App Store页面更新了付费订阅声明:标准版68元/月,加强版200元/月,专业版500元/月。作为目前国内月活超过3.45亿的AI助手——这个数字意味着大约每四个中国人里就有一个人在用豆包——这是字节跳动第一次正式给豆包贴上价格标签。 但市场的反应不是期待,而是愤怒。 “又笨又收费,说平时用免费版,经常答非所问,信息出错,逻辑也不严谨,有时候还一本正经地胡说八道,基础功能都没做好。” “免费的

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读