阅读视图

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

构建无障碍组件之Tabs Pattern

标签页(Tabs)是一种分层的内容展示组件,通过标签列表(Tab List)和对应的内容面板(Tab Panel)来组织和展示内容。本文基于 W3C WAI-ARIA Tabs Pattern 规范,详解如何构建无障碍的标签页组件。

一、Tabs 的定义与核心概念

1.1 什么是 Tabs

Tabs 是一种将内容分层展示的界面模式:

  • Tab List(标签列表):包含一组标签元素的容器
  • Tab(标签):作为对应内容面板的标签,激活后显示该面板
  • Tab Panel(标签面板):包含与标签关联的内容的元素
  • 通常一次只显示一个标签面板
  • 标签列表通常排列在当前显示面板的边缘(最常见的是顶部)

1.2 核心术语

术语 说明
Tab List 包含在 tablist 元素中的一组标签元素
Tab 标签列表中的元素,作为对应面板的标签
Tab Panel 包含与标签关联内容的元素
┌─────────────────────────────────────────┐
│  Tab List (role="tablist")              │
│  ┌─────────┬─────────┬─────────┐        │
│  │ Tab 1   │ Tab 2   │ Tab 3   │        │
│  │(active) │         │         │        │
│  └────┬────┴─────────┴─────────┘        │
│       │                                 │
│       ▼                                 │
│  ┌─────────────────────────────────┐    │
│  │  Tab Panel 1 (role="tabpanel")  │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │                         │    │    │
│  │  │      Content Panel 1    │    │    │
│  │  │      (currently shown)  │    │    │
│  │  │                         │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
│                                         │
│  ┌─────────────────────────────────┐    │
│  │  Tab Panel 2 (role="tabpanel")  │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │      Content Panel 2    │    │    │
│  │  │      (hidden)           │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

1.3 自动激活 vs 手动激活

Tabs 有两种激活模式:

自动激活(Automatic Activation)

  • 标签获得焦点时自动激活并显示对应面板
  • 要求面板内容已预加载,避免延迟
  • 用户导航更高效

手动激活(Manual Activation)

  • 用户需要按 Space 或 Enter 激活标签
  • 适用于面板内容加载有延迟的场景
  • 避免焦点移动时的不必要加载

二、WAI-ARIA 角色与属性

2.1 基本角色

<div
  role="tablist"
  aria-label="产品信息">
  <button
    role="tab"
    aria-selected="true"
    id="tab-1">
    产品详情
  </button>
  <button
    role="tab"
    aria-selected="false"
    id="tab-2">
    规格参数
  </button>
  <button
    role="tab"
    aria-selected="false"
    id="tab-3">
    用户评价
  </button>
</div>

<div
  role="tabpanel"
  aria-labelledby="tab-1">
  <!-- 产品详情内容 -->
</div>

2.2 Tab List 属性

Tab List 容器具有 role="tablist"

<!-- 水平标签页(默认) -->
<div
  role="tablist"
  aria-label="产品信息">
  ...
</div>

<!-- 垂直标签页 -->
<div
  role="tablist"
  aria-label="设置选项"
  aria-orientation="vertical">
  ...
</div>

2.3 Tab 属性

每个标签具有 role="tab"

<button
  role="tab"
  id="tab-1"
  aria-selected="true"
  aria-controls="panel-1"
  tabindex="0">
  产品详情
</button>

<button
  role="tab"
  id="tab-2"
  aria-selected="false"
  aria-controls="panel-2"
  tabindex="-1">
  规格参数
</button>

2.4 Tab Panel 属性

每个面板具有 role="tabpanel"

  • aria-labelledby:引用对应标签的 ID
  • 未激活的面板通常使用 hidden 属性或 CSS 隐藏
<div
  role="tabpanel"
  id="panel-1"
  aria-labelledby="tab-1">
  <!-- 激活的面板内容 -->
</div>

<div
  role="tabpanel"
  id="panel-2"
  aria-labelledby="tab-2"
  hidden>
  <!-- 未激活的面板内容 -->
</div>

三、键盘交互规范

3.1 Tab 键导航

场景 行为
焦点进入 Tab List 焦点置于当前激活的标签上
焦点在 Tab List 中 焦点移动到 Tab List 外的下一个元素(通常是 Tab Panel 或其内部第一个可聚焦元素)

3.2 方向键导航(水平标签页)

按键 功能
左箭头 焦点移到上一个标签;如果在第一个标签,移到最后一个
右箭头 焦点移到下一个标签;如果在最后一个标签,移到第一个
Home(可选) 焦点移到第一个标签
End(可选) 焦点移到最后一个标签

3.3 方向键导航(垂直标签页)

按键 功能
上箭头 等同于水平标签页的左箭头
下箭头 等同于水平标签页的右箭头

3.4 激活操作

按键 功能
Space / Enter 激活当前聚焦的标签(手动激活模式下)
Shift + F10(Windows)
Control + 点击(Mac)
如果标签有关联的弹出菜单,打开菜单
Delete(可选) 如果允许删除,删除当前标签及其面板

3.5 自动激活说明

  • 推荐在面板内容已预加载时使用自动激活
  • 自动激活时,方向键移动焦点会立即激活对应标签
  • 如果面板加载有延迟,使用手动激活避免阻碍导航

四、实现方式与样式要点

4.1 基础 HTML 结构

<div class="tabs">
  <!-- Tab List -->
  <div
    role="tablist"
    aria-label="产品信息">
    <button
      role="tab"
      id="tab-1"
      aria-selected="true"
      aria-controls="panel-1"
      tabindex="0">
      产品详情
    </button>
    <button
      role="tab"
      id="tab-2"
      aria-selected="false"
      aria-controls="panel-2"
      tabindex="-1">
      规格参数
    </button>
    <button
      role="tab"
      id="tab-3"
      aria-selected="false"
      aria-controls="panel-3"
      tabindex="-1">
      用户评价
    </button>
  </div>

  <!-- Tab Panels -->
  <div
    role="tabpanel"
    id="panel-1"
    aria-labelledby="tab-1">
    <h2>产品详情</h2>
    <p>这是一款高性能的...</p>
  </div>

  <div
    role="tabpanel"
    id="panel-2"
    aria-labelledby="tab-2"
    hidden>
    <h2>规格参数</h2>
    <table>
      <tr>
        <th>尺寸</th>
        <td>100 x 50 x 20 mm</td>
      </tr>
      <tr>
        <th>重量</th>
        <td>200g</td>
      </tr>
    </table>
  </div>

  <div
    role="tabpanel"
    id="panel-3"
    aria-labelledby="tab-3"
    hidden>
    <h2>用户评价</h2>
    <p>"非常满意这款产品..."</p>
  </div>
</div>

4.2 样式实现注意事项

4.2.1 激活状态样式

激活的标签需要有明显的视觉区分:

  • 下划线/边框:使用边框颜色变化指示激活状态
  • 背景色:激活标签使用不同的背景色
  • 文字样式:加粗或改变颜色增强对比

4.2.2 焦点状态样式

确保键盘用户可以清楚看到当前焦点位置:

  • 使用 outlinebox-shadow 创建焦点环
  • 焦点环颜色与背景有足够对比度
  • 避免使用 outline: none 而不提供替代样式

4.2.3 面板显示/隐藏

  • 未激活的面板应使用 hidden 属性或 display: none 完全隐藏
  • 避免使用 visibility: hiddenopacity: 0,这会让内容仍可被屏幕阅读器访问

4.2.4 垂直标签页样式

垂直布局时需要注意:

  • 标签列表使用 flex-direction: column
  • 激活指示器从底部边框改为右侧边框
  • 确保足够的点击区域(最小 44x44px)

4.2.5 响应式设计

移动端适配建议:

  • 小屏幕下标签可以换行或使用水平滚动
  • 考虑将水平标签页切换为垂直布局
  • 调整标签内边距和字体大小

五、常见应用场景

5.1 产品详情页

<div
  role="tablist"
  aria-label="产品信息">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="panel-overview">
    概览
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-features">
    功能
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-reviews">
    评价
  </button>
</div>

5.2 设置面板

<div
  role="tablist"
  aria-label="设置选项"
  aria-orientation="vertical">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="panel-account">
    账户
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-privacy">
    隐私
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-notifications">
    通知
  </button>
</div>

5.3 代码示例展示

<div
  role="tablist"
  aria-label="代码示例">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="panel-html">
    HTML
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-css">
    CSS
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-js">
    JavaScript
  </button>
</div>

六、最佳实践

6.1 选择合适的激活模式

  • 自动激活:面板内容已预加载,无明显延迟
  • 手动激活:面板内容需要异步加载,或加载时间较长

6.2 确保键盘可访问

  • 所有标签都必须可以通过键盘聚焦
  • 方向键在标签之间循环导航
  • Tab 键从标签列表移动到面板内容

6.3 提供清晰的视觉反馈

  • 激活的标签使用不同的样式(颜色、边框)
  • 焦点状态清晰可见
  • 未激活的面板完全隐藏

6.4 避免嵌套 Tabs

不要在 Tab Panel 内部嵌套另一个 Tabs,这会造成:

  • 键盘导航复杂且容易迷失
  • 屏幕阅读器用户难以理解层级关系
  • 视觉上的混乱

6.5 处理大量标签

如果标签数量过多:

  • 考虑使用垂直方向节省水平空间
  • 或者重新组织内容结构
  • 避免标签需要水平滚动

6.6 移动端适配

@media (max-width: 640px) {
  [role='tablist'] {
    flex-wrap: wrap;
  }

  [role='tab'] {
    flex: 1;
    min-width: 80px;
    padding: 10px;
    font-size: 14px;
  }
}

七、Tabs 与其他组件的选择

7.1 Tabs 与 Accordion 的选择

场景 Tabs Accordion
需要同时对比多个内容 ❌ 一次只能看一个 ✅ 可同时展开多个
水平空间有限 ❌ 需要水平排列标签 ✅ 垂直展开更省空间
内容有明确顺序 ✅ 标签顺序暗示优先级 ❌ 各面板相对独立
需要频繁切换内容 ✅ 点击标签即可切换 ❌ 需要展开/折叠操作
移动端优先 ❌ 水平空间受限 ✅ 垂直空间更充裕
键盘导航效率 ✅ 方向键快速循环 ⚠️ Tab 键逐个遍历
内容关联性 ✅ 同一主题不同方面 ✅ 相对独立的内容块
典型应用场景 产品详情页、设置面板 FAQ、内容列表、过滤器

选择建议:

  • 用 Tabs:内容关联紧密、空间充足、需要快速切换
  • 用 Accordion:需要对比多个内容、移动端、垂直空间充裕

7.2 Tabs 与 Menu 的选择

场景 Tabs Menu
页面内内容切换 ✅ 显示对应面板,不跳转 ❌ 预期会跳转页面
导航到不同页面 ❌ 不适合页面导航 ✅ 专为导航设计
执行操作(如下载) ❌ 不适用于操作 ✅ 可触发各种操作
内容关联性 ✅ 同一上下文相关内容 ❌ 通常相互独立
视觉反馈 ✅ 始终显示激活状态 ✅ 显示当前页面
键盘导航 ✅ 方向键循环,Tab 离开 ✅ 方向键导航,Enter 激活
ARIA 角色 tablist + tab + tabpanel menubar + menuitemnavigation + link
典型应用场景 产品详情、代码示例展示 主导航、用户操作菜单

选择建议:

  • 用 Tabs:同一页面内切换相关内容
  • 用 Menu:页面导航或执行操作

常见误区:

  • ❌ 使用 Tabs 作为主导航菜单(应使用 <nav> + <a>
  • ❌ 使用 Menu 展示同一页面内容(应使用 Tabs)
  • ❌ Tabs 标签使用 <a> 链接(应使用 <button>
  • ❌ 在移动端使用过多 Tabs(应考虑 Accordion)

八、总结

构建无障碍的 Tabs 组件需要关注:

  1. 正确的 ARIA 角色tablisttabtabpanel
  2. 完整的状态管理aria-selectedaria-controlstabindex
  3. 键盘导航支持:方向键循环、Home/End 快捷键
  4. 合适的激活模式:根据内容加载情况选择自动或手动激活
  5. 清晰的视觉反馈:激活状态、焦点状态明确可辨

遵循 W3C Tabs Pattern 规范,我们能够创建既美观又包容的标签页组件,为所有用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

fsck Cheatsheet

Basic Usage

Common fsck command forms.

Command Description
fsck /dev/sdb1 Check a filesystem
fsck -n /dev/sdb1 Check only, do not write changes
fsck -f /dev/sdb1 Force a check even if the filesystem looks clean
fsck -t ext4 /dev/sdb1 Check only filesystems of the given type
fsck -A Check filesystems listed in /etc/fstab

Safe Repair Workflow

Use this sequence before making repairs.

Command Description
lsblk -f Identify the device, mount point, and filesystem type
mount | grep /dev/sdb1 Confirm whether the device is mounted
sudo umount /dev/sdb1 Unmount the filesystem before repair
sudo fsck -n /dev/sdb1 Run a read-only check first
sudo fsck -p /dev/sdb1 Auto-fix safe problems without prompts

Repair Modes

Choose how interactive the repair should be.

Option Description
-n Do not make changes, useful for a safe first pass
-p Automatically repair safe problems
-y Answer yes to all prompts
-r Prompt before each repair
-f Force checking even if the filesystem appears clean

Filesystem Selection

Target one filesystem, one type, or all entries in fstab.

Command Description
sudo fsck /dev/nvme0n1p2 Check one device directly
sudo fsck -t ext4 /dev/nvme0n1p2 Check one device as ext4
sudo fsck -A Check all eligible filesystems in /etc/fstab
sudo fsck -AR Check all except the root filesystem
sudo fsck.ext4 /dev/nvme0n1p2 Run the filesystem-specific checker directly

Root Filesystem Recovery

fsck cannot repair the mounted root filesystem on a running system.

Method Description
Recovery mode Boot into recovery and choose the filesystem check option
Live USB Boot a live system, identify the root partition, then run fsck there
fsck.mode=force Kernel parameter to force a check during boot on systemd systems
fsck.repair=yes Kernel parameter to approve repairs during boot
sudo touch /forcefsck Older non-systemd pattern to force a boot-time check

tune2fs Scheduling

Control when ext filesystems are checked automatically.

Command Description
sudo tune2fs -l /dev/sdb1 | grep -i 'last checked|mount count' Show last check time and mount counters
sudo tune2fs -c 25 /dev/sdb1 Run a check after every 25 mounts
sudo tune2fs -c -1 /dev/sdb1 Disable mount-count-based checks
sudo tune2fs -i 1m /dev/sdb1 Run a check at most once per month
sudo tune2fs -i 0 /dev/sdb1 Disable time-based checks

fstab Pass Values

The sixth /etc/fstab column controls boot-time check order.

Value Description
0 Do not check this filesystem at boot
1 Check first, usually the root filesystem
2 Check after root, for other local filesystems

Example: /dev/sda2 /home ext4 defaults 0 2

Exit Codes

Use exit codes to understand what fsck found.

Code Description
0 No errors
1 Filesystem errors corrected
2 System should be rebooted
4 Filesystem errors left uncorrected
8 Operational error
16 Usage or syntax error
32 Checking canceled by user
128 Shared-library error

Other Filesystems

Some filesystems use tools other than fsck.

Filesystem Tool
XFS xfs_repair
Btrfs btrfs check or btrfs scrub
NTFS ntfsfix
FAT/VFAT fsck.vfat
Ext2/3/4 fsck.ext2, fsck.ext3, fsck.ext4

Related Guides

Use these articles for the full workflow around filesystem repair.

Guide Description
Fsck Command in Linux (Repair Filesystem) Full fsck guide with examples
How to Check Disk Space in Linux Using the df Command Check mounted filesystems and free space
How to Mount and Unmount File Systems in Linux Unmount a filesystem before repair
Sudo Command in Linux: Run Commands as Root Run fsck with the required privileges

7-Eleven将在日本推出手机下单门店取货服务

7-Eleven日本公司3月27日宣布,4月1日起推出一项新服务,顾客用智能手机下单后,就可在门店拿到在店内烹饪的刚出炉的油炸食品、面包和披萨。该服务将陆续导入至约1.5万家门店,相当于总数的约7成。(界面新闻)

把尾货卖到50亿,好特卖靠AI算法,拿捏情绪经济


作者丨卜松

编辑丨刘恒涛

图源丨好特卖

在零售行业,好特卖有些另类。

很多渠道都在精简SKU,最多也就几千个,好特卖的货品不固定,并且品类庞杂,每年会有大几万个SKU。

一些零食量贩店开在街边,好特卖却聚焦在商场开店;零食量贩品牌主要开在下沉市场,好特卖只在新一线、一线城市开店;零食量贩品牌追求厂家直供、定制化产品,甚至开发自有品牌,在供应链端,努力追求低价和货源稳定,好特卖却坚定做尾货,渠道错配、品牌推新失利、包装更替、季节性库存,这些“不确定性”的商品,是好特卖的货源基础。甚至好特卖的店铺数都没有那么大,某零食量贩品牌,店铺数量达到2.2万家,好特卖目前只有1000家左右。

但是,从2020年开始的好特卖,2025年的销售额超过50亿,找到了属于自己的市场。

在这条不一样的赛道,好特卖是如何做到的?

近期,在好特卖上海总部,联合创始人张宁向创业邦讲述了好特卖背后的逻辑。

01不是刚需卖场,是消费者的情绪场

好特卖不是一个刚需场,是一个百分百的情绪场。这是张宁对好特卖的定位。

张宁说,来好特卖的顾客,大多没有目的性。他透露,好特卖和复旦大学人类学团队做过一个研究,发现消费者去好特卖的时候常常不做任何计划,没有购物清单,“纯粹是逛逛”。

“不论开心不开心都可以去,吃个冰激凌就很开心,买个盲盒就很开心。”张宁说,“我认为好特卖就是消费者花小钱去寻宝、寻找惊喜的一个地方。”

好特卖只开在一线和新一线城市,聚焦在商场开店,而不是像很多量贩零食品牌那样,开在街边。这背后也有一套逻辑。

张宁说,好特卖也试过在三四线城市开店,但是发现这些城市的商场人流量不好。这背后有几个原因:

第一,三四线城市缺少白领客群,主要是小老板、公务员和学生,而好特卖主要服务白领。“研究很多三四五线的Shopping Mall,你会发现,它上面是没有办公楼的。”张宁说。

第二个原因,是这些城市大多没有地铁。“地铁是一个人流走向的核心,有地铁的地方,街边店人很少,因为大家不去街边,直接地铁走了。三四线城市街边店烟火气很好,但是商场到8点半以后,几乎空无一人。”而好特卖主要在商场开店。

满足情绪体验,是好特卖的一个重要定位。在好特卖的货架上,有各种新奇的产品,周黑鸭味的彩虹糖、六神味的炫迈口香糖、白象香菜面、星巴克生咖等等。

“在其他的传统渠道,消费者看到这种稀奇古怪东西,因为缺少尝新的氛围,他很可能是不会买的,这东西万一不好吃呢?但是在好特卖,反正主流口味大概率也没有,那还不如试试。”张宁说,“我觉得我们给消费者营造的,是每次来,想买的东西不一定有,但最后也不会空手离开的感觉。”

很多人认为,好特卖卖的是农夫山泉、可口可乐这些大品牌核心SKU的临期产品,但事实恰恰相反。这类成熟产品的生产计划非常精准,分销网络高度成熟,很少规模性出现尾货。好特卖的货架上,往往是品牌的创新产品,这才是好特卖的主力商品。

目前好特卖在全国已有近1000家门店

在中国快消品行业,每一次推新,都有失败的风险。张宁展示了一组内部数据:一个典型的成熟快消品品牌,每年推新的成功率往往不足10%。这意味着,市面上看到的大多数新品,都将因为市场反应不佳而退出。

“因为一个新品能够成功,涉及到的环节非常多。成熟公司做一个新品,是很严格的,这个品完全不好的概率其实不高。但是能卖好的概率却非常低。”张宁说。

一个新品要走向消费者,中间有很多环节,首先是在第一批投放的市场,品牌方和销售之间的配合。“品牌方想推新品,经销商买不买账?渠道会不会让你付条码费?如果一开始投入不够,新品连上货架的资格都没有,就已经变成尾货。”

这些新品的尾货,就是好特卖的上游商品来源。这也解释了为什么好特卖的货架上,总是有各种稀奇古怪的商品。


虽然卖的是临期产品、尾货产品,但张宁认为,好特卖也并不是消费降级。

“根据尼尔森IQ统计的线下渠道数据,即使是在重点城市,消费者100块钱的销售额里,95块钱是牙膏,5块钱是漱口水。好特卖75块是牙膏,25块钱漱口水。漱口水不管卖什么价格,都是一个消费升级的类目。”张宁说,年轻人在好特卖完成了付少量的钱就能享受到他认为更好品质或更丰富体验的生活,“这其实不是消费降级,是消费升级。”

02每年大几万个SKU,靠的全是AI算法

根据张宁介绍,好特卖每年卖大几万个条码的商品。这和很多零售店恰恰相反。根据公开数字,鸣鸣很忙2025年9月份的在库SKU为3997个,山姆店单店SKU约4000个,一般商超的SKU,大概也只有2-3万个。

聚焦刚需产品、精简SKU是当下很多渠道的共识。好特卖却有大几万个SKU,并且货源极不稳定,如何进货、定价、分配,是一项极高的挑战。

“我觉得好特卖的优势在于算法,如果未来能赢,就是赢在我们的柔性决策链上。”张宁说,任何上游供应链的商品过来,好特卖很快就能够把采购量和定价反馈给品牌商。“我认为,这是好特卖最核心的竞争力,其他都是辅助。”

张宁说,2020 年好特卖一出来就很火,第二年市场上出现300 多个竞争对手,几年过去,很多竞争对手都看不见了,原因就是上规模了以后,很多竞争对手没有投入算法。

好特卖的AI算法,主要用在两个核心点:

第一,判断品牌商的货能不能要,以什么样的价格,能要多少。AI通过预测计算货品可销售价格,结合消费者的大数据,来倒推报价。

好特卖每天要面临各种供应商寄来的各种各样的品,有时候一个货单上就几百个品。尾货又有很大的效期压力。而且很多货从来没有卖过,没有任何一个买手能马上报出价格。张宁说,好特卖也希望帮品牌商解决问题,尽快把进货量和价格报给对方,基于这样的需求,好特卖才开发了AI。

第二,就是负责货物的分配。因为货的量不固定,最常规的量是几百箱,最少有几十箱,最大的能到十几万箱。这些货在哪些区域、哪些门店好卖?该如何分到好特卖的一千家门店?也是一门大学问。

好特卖的商品都很新奇

好特卖每年要卖大几万个条码,很早就建了会员系统,所有的购买行为形成了消费者的画像。哪里的消费者爱喝带气饮料,哪里的消费者爱喝养生茶,都有据可查。AI算法可以迅速匹配到最快能够动销的门店。

在好特卖,AI算法还是店长的辅助。

传统零售商以店长选货为主,店长会根据之前的动销经验买入货物,但前提是他每天换品概率没那么高。在好特卖,经常是每次都是新品,从来没卖过,而且每周要换 20% 以上的品,店长无法用经验判断。

“AI算法也无形当中变成了我们的竞争门槛。”张宁说,“当竞争对手只有十几二十家店的时候,是能够跟我们比一比的,但开到一两百家门店的时候,他就没办法了。因为每天上的品太多,店长没办法凭经验做决策。”

张宁说,这些都是好特卖一路踩坑学习过来的。“但好特卖不太会踩大坑,因为我们大部分的货量,每家门店最多配两箱,所以我们可能会快速地犯一些小错,用这些小错不断去喂数据。”

消费行业市场瞬息万变,曾经卖的很好的单品,很可能第二年完全卖不动。比如奶酪棒,2024 年是好特卖的明星品,但2025 年动销明显下滑。张宁说,好特卖的AI 无法提前预知,但可以快速反应。

“第一批 500 箱货下去两周,数据就已经成熟了,对比之前的动销数据,AI马上修正算法,发出预警,买手也会马上拿到信息,调整进货。”张宁说,“很多新的渠道,投新品,可能需要个半年,给不出一个完整的反馈数据,我们两周就有了。你也可以理解为好特卖是一个柔性货架。”

算法同样介入了最敏感的选址与加盟决策。在好特卖内部,有一套成熟的ROI(投资回报率)预测模型。当一个加盟商看中了一个铺位,后台会迅速调用该区域的历史流量数据、预估进店率,并结合该城市的平均客单价,直接算出预估收入。

好特卖整合了很多了解市场的专业加盟商。但是,和零食量贩、茶饮品牌不同,好特卖整合的是专业的选址加盟商,而不是经营加盟商。

“因为开一家店,除了钱以外,有另外两个能力,一个是选址能力,一个是经营能力。我们要的是选址能力。”张宁说,好特卖的加盟商,平均都是开 10 家店以上,基本没有小加盟商。

选址加盟商有丰富的经验,好特卖AI算法也会对选址加盟商赋能。选好址,经营由好特卖来“负责”。好特卖有一个严格的规定:店长不能听加盟商的。加盟店的货是 AI 配的,店长负责货品陈列、收银和社群维护。

“这就是分工,你就选好址,好特卖就帮你把消费者服务好,我们不允许加盟商干扰店长。”

03做秃鹫,也做创新推动者

张宁喜欢把好特卖比作自然界的秃鹫。

“好特卖有一个很强的观点叫生态观,我们不去做掠夺者和竞争者,我们希望好特卖成为整个生态良性循环的关键节点。就像秃鹫,它把腐肉或者把骨头都吃掉,其实是解决整个草原的生态环境。”张宁说,“消费者知道,因为好特卖的是尾货,所以以折扣价卖掉,这叫有理由破价。而且好特卖单条码不会长期卖,尾货卖完就没有了。”

张宁认为,在整个生态中,好特卖变成了品牌管理全域价盘的解压阀。“你可以去全域去管理你的价盘,但是你不能硬管。你要把核心的因素找到,然后通过好特卖把这个核心因素给消化掉。”

在好特卖,目前超过九成的货,都是品牌方给的,有一部分是经销商供货,但也都是品牌方认可认同的。

“我们卖的这个价格,品牌方如果不点头,经销商也不能供给我们。”张宁说,过去几年,好特卖做的最大的一个事情,就是让各个品牌把好特卖从他们传统渠道的抽屉里拿出来,“我们就不是他们传统管理的那个渠道。”

好特卖有专门给品牌方的数据看板。

“有些大的国际零售商,他会有数据看板,但主要是让品牌方管理在店库存。我们给品牌方看的,是我们卖力的数据。比如好特卖同时在售的有哪几个品,过去这一年在卖的几十个品,消费者反馈怎么样。我们这个逻辑,是把我们的数据能力赋能给品牌,他可以在他的销售和市场的决策上面引用我们数据。”

张宁说,近些年,零售业态发生了巨大的变化,新品测试已经没有了以往的场景。

“以前大卖场人流很大,新品有专门的促销员,请顾客试喝、试吃、试饮,可以进行低成本的试错,但是现在无论线上货架还是线下货架,都变少了,整个逻辑对新品推出很不利。整个快消品行业的新品,尤其单价10块钱以内的新品,跟消费者亲密接触遇见的机会非常非常小,因此,使得大量的新品滞销。”张宁说。

根据凯度的数据,过去三年,平均每一年上市的新品超过30万,一年以后,仍然存活的比例,从2022年的31%已经跌到了2024年的22%。新品推出的成功率在变低。


而好特卖做的,就是给新品的托底的工作,“你外面卖的不好了。没事,我们最后来托底,帮你把成本多少拿回来一点。”

张宁认为,尾货的生意要扩大,扩类目是一条绕不开的路。

“我们发现只要有情绪类的产品,或者有大尾货商机的类目,我们都可以做。比方说像日化品。”张宁说,日化、盲盒、宠物,好特卖都在做,也在看服装,包括二手奢侈品。

张宁透露,目前的好特卖整体已经盈利。同时,好特卖也试图把食品饮料的经验,复制到服装领域,开始开大店。因为好特卖的客群,78%在40岁以下,也是高频买服装的客群。

好特卖的小店,一般是200平米左右,大店面积5000-10000平,一般会开在腰部商场,进行服装等品类的销售。张宁说,好特卖的大店,目前主要开在华北地区,目前,公司在北京、南京都已经开了大店。据公开报道,2025年7月开始营业的北京大店,分上下两层,销售服装、箱包、鞋类,还有酒水、饮品、酸奶、冰激凌等,以及美妆、奢侈品、运动服饰、日用品。

好特卖门店

张宁说,好特卖还想做一点为品牌赋能的功能。利用好特卖的消费数据,让品牌把未上市或即将上市的新品,在好特卖的货架上做测试。

“要推动行业的创新,目前看来渠道还是必须要参与的。”张宁说,在他的理想规划里,是好特卖变成新品的孵化器。

2025年,好特卖和全国糖酒会合作,一起发起了ideas创新产品大赛,今年进入第二届。

“我们今年的口号是:让新品成为爆品,通过我们过往的一些 AI 数据,做初步的选择,拉了很多渠道方一起来做评委,去做选品,希望能够在我们获奖的产品中最终能走出几个爆品。”

“行业一定要通过创新和效率这两条腿去推进,如果只做效率,没有做创新,这个行业往往会越卷越死。”张宁说。

本文为创业邦原创,未经授权不得转载,否则创业邦将保留向其追究法律责任的权利。如需转载或有任何疑问,请联系editor@cyzone.cn。

昆仑万维携AIGC全家桶亮相2026中关村论坛,三大世界第一梯队模型正式发布

3月25日,2026中关村论坛年会如期而至。来自100多个国家和地区的上千名嘉宾汇聚百余场论坛,共话“科技创新与产业创新深度融合”发展之道。

作为中国AI领域领军企业与2026中关村论坛会员单位,昆仑万维携手中关村论坛,共同构建多元协同的科技交流生态圈,以AI技术创新赋能产业升级,助力北京打造全球人工智能创新高地。

3月27日下午,昆仑万维集团(300418)旗下天工AI成功举办“世界模型前沿技术与天工AIGC全家桶大模型生态”专场发布会,携Matrix-Game 3.0、SkyReels V4、Mureka V9三大核心模型惊艳亮相,正式宣告三大模型均跻身世界第一梯队;同时发布昆仑万维2026 AGI战略,目标是通过打造AI经济平台,赋予每一个创作者匹敌一家公司的全栈生产力。

北京市经济和信息化局党组书记、局长姜广智,中共北京市东城区委副书记、区长陈献森,北京市东城区人民政府党组成员、副区长邓慧敏,北京市经济和信息化局二级巡视员张宇航,国家发改委宣传中心战略规划处负责人聂正标等嘉宾出席本次论坛。

会议伊始,北京市东城区人民政府党组成员、副区长邓慧敏出席并致辞。她充分肯定了昆仑万维近年来在人工智能领域取得的突出成就,并表示“十五五”期间,东城将大力发展动漫影视、数字演绎、艺人经纪、游戏电竞等新型赛道,做大做强数字文娱产业,以科技创新赋能产业升级,加快实现资源数字化、场景沉浸化、产业数智化发展,积极打造文化、科技创新融合的高地。未来,东城区将持续优化营商环境,积极搭建协同创新平台,助力构建开放共享、互利共赢的人工智能产业生态。

随后,北京市经济和信息化局二级巡视员张宇航发表致辞。他表示,昆仑万维作为扎根北京、辐射全国、走向世界的优秀科技企业,为人工智能的产业发展注入新的活力。同时,北京市经济和信息化局也将聚焦大模型的关键技术,以文娱、文旅、教育等场景为牵引,加速AI在游戏创作、数字文娱等领域的融合应用。未来也希望更多创新成果在北京转化,更多的优秀企业在北京成长,彼此凝聚共识、深化合作,共同开创AI生态创新发展的新局面,为我国的数字经济高质量发展做出更大的贡献。

两位领导的致辞充分彰显了北京市及东城区政府部门对人工智能产业发展的高度重视、对昆仑万维在人工智能领域取得成绩的认可,也为本次论坛奠定了开放、共创、共赢的基调。

紧接着,天工AI董事长兼CEO周亚辉发表题为《2026昆仑万维AGI战略发布:从全模态突破到AI平台经济》的重磅演讲,全面阐释昆仑万维面向2026年的人工智能战略布局,深度拆解从全模态技术突破到AI平台经济生态构建的整体路径。

周亚辉正式发布昆仑万维2026年的AGI战略升级为“3+1”生态架构——以4个SOTA大模型为底座,支撑3大AI原生平台+1个超级智能体。

其中,DramaWave、Mureka和猫森学园三个大平台的底层逻辑是用SOTA AIGC大模型×内容生产×内容分发,做到端到端的闭环;另外的“1”——Skywork Super Agents,则是背后通用的操作系统,我们把它定义为面向全球所有内容创作者的操作系统,从长远来看,未来可能是五亿内容创作者规模的一人公司的操作系统。

展望未来,周亚辉表示,2028年是平台经济年,昆仑万维有望成长为一个AI native的平台经济体。四大SOTA模型、一个超级智能体操作系统和三大平台经济体,归根到底只是为了实现一个目标——让每一个创作者,都拥有匹配一家公司的全栈AI生产力,在确定的AGI时代与千万超级个体共建繁荣。

其后,昆仑万维首席科学家、香港中文大学教授成宇带来题为《构建物理仿真驱动下的实时交互世界:天工世界模型与AIGC全家桶发布》的模型发布演讲,发布 Matrix-Game 3.0游戏大模型、SkyReels V4视频大模型、Mureka V9音乐大模型,这三大模型的迭代升级,将推动游戏、视频、音乐领域实现从技术突破到产业落地的跨越式发展。

首先,昆仑万维正式发布Matrix-Game 3.0模型:物理仿真驱动下的实时交互式世界模型创新实践。从 2.0到3.0模型,我们完成了一次关键跃迁:从“可运行的交互Demo”,走向“具备稳定性与工业级性能的世界模型系统”。

过去一年,我们看到一个非常清晰的趋势:AI已经从“理解世界”走向“生成世界”。世界模型正在推动AI从“静态内容生成”走向“动态世界建模”,并成为连接感知、决策与生成的核心基础能力。在此背景下,构建物理仿真驱动下的实时交互世界是世界模型的核心内容。

今天,昆仑万维正式发布的Matrix-Game 3.0模型,在数据、模型和推理三个层面完成了一次全新技术升级。首先是数据层面——数据决定下限。我们基于 Unreal Engine 构建了大规模数据引擎,能够自动生成带有精确动作、相机位姿以及物理一致性的高质量交互数据,目前已经覆盖1000+场景,对于这些数据我们按照5s时序间隔给所有数据生成了局部描述和全局描述。这些数据为世界模型学习真实的“动作—环境变化”关系提供了基础。

第二是模型层面——模型决定能力。我们设计了带有Memory机制的DiT架构,使模型不仅能够生成当前帧,还可以持续维护历史信息,从而解决长时序生成中的一致性问题,实现分钟级的稳定生成与记忆能力。

第三是推理层面——推理决定落地。通过一系列推理加速优化,包括少步采样、并行生成以及缓存机制(KV Cache)和decoder蒸馏等过程,我们实现了高效的实时推理能力。最终在5B模型规模下,实现720P分辨率、最高40FPS的实时生成。

整体来看,Matrix-Game 3.0 的核心在于:通过“数据—模型—推理”的协同优化,首次将开源世界模型推向了带有记忆能力的长时序一致性 + 高质量 + 实时交互的统一框架。

接着,昆仑万维发布SkyReels V4模型:下一代视听创作的全新范式。

当前,AI视频不仅仅是一个工具,它正在重构整个视听创作的逻辑,成为下一代产业的核心基础设施。“好的AI视频生成”对于普通用户来说,它意味着自由表达和可控可改;对于产业创作者,它意味着更低的门槛、更高的效率和更多的可能性。

SkyReels V4通过四大技术突破——音画一体双流联合生成架构、全模态理解和精准控制、全模态强化学习体系和攻克电影级画质与高效生成的平衡难题,分别解决了音画同步、精准控制、一站式编辑和高效生成等行业痛点,将AI视频生成的普惠生产力和解决方案切实地赋予内容创作者和行业。

SkyReels V4模型实力也得到了全球权威榜单的认可。在Artificial Analysis Arena的排名中(截至2026年3月18日评测结果),SkyReels V4在多个核心赛道均名列前茅——在Text to Video (With Audio) 赛道和Image to Video (With Audio) 赛道,均位列全球第一;在Text to Video (No Audio) 赛道,位列全球第二。

此外,SkyReels V4同步推出了全场景开放API与行业解决方案,能够将SkyReels V4的强大能力开放给所有合作伙伴,共同推动视听产业的智能化升级。未来,SkyReels V4致力于成为AI视听时代的技术灯塔,用技术打破创作的边界,赋能每一个创作者。

最后,Mureka V9音乐大模型正式发布。Mureka V9是一次更可控、更懂表达的创作升级。团队将重点进一步推进到“音乐创作意图的可控表达”上:不仅生成得更快、更清晰,也开始更准确地理解创作者在歌词段落中的具体表达诉求。

Mureka V9在段落内文本控制、生成效率、混音质量与整体听感等多个关键维度持续进化,使AI音乐从“能够生成一首歌”,进一步迈向“能够更稳定地按创作意图完成一首歌”:

段落内文本控制能力增强,生成更精准混音与音质表现继续提升,成品感更强

人声表达更符合创作意图,能恰到好处

生成效率进一步提升,创作反馈更及时

生成结果更丰富,重复感进一步缓解

Mureka V9依然建立在 MusiCoT(Music Chain-of-Thought) 技术体系的持续演进之上,进一步强化了模型对音乐结构、歌词段落、表达意图与声音呈现之间关系的协同建模,使模型不再只是依据提示词“生成一段声音”,而是能以更接近真实创作逻辑的方式,理解“这一段该唱什么、怎么唱、为什么要这样推进”。

可以理解为,MusiCoT在Mureka V9中进一步融合了对歌词段落设计、语义重心与音乐组织方式的细粒度思考,让模型在旋律推进、段落表达、音色安排与整体完成度之间建立更稳定的内在一致性。

如果说传统音乐时代的平台,连接的是歌曲、歌手和听众;那么AI音乐时代的平台,连接的将是所有人的意图、生成、编辑、二创、分发和表达。Mureka想推动的,是AI音乐从生成工具走向创作系统,从单次输出走向版本化工作流,从做一首歌走向持续共创,从功能集合走向平台能力。我们希望把音乐从少数人的专业技能,变成更多人的表达语言。

[1080p.mp4]

从Matrix-Game 3.0实现 “场景 Demo” 到 “真实可玩” 的跨越,到SkyReels V4登顶全球AI视频大模型榜首,再到Mureka V9推动AI音乐向高质感、强情感升级,每一项突破都令人振奋,每一个成果都彰显着中国AIGC技术的全球竞争力。

本次论坛尾声,举行了题为《全模态内容革命:AI游戏、AI视频、AI音乐的创新、落地与生态共建》的圆桌讨论。由量子位创始人孟鸿主持,昆仑万维董事长兼CEO方汉,南洋理工大学副教授刘子纬(线上参与),北京深空交响有限公司创始人、QQ炫舞创始人姚勇,腾讯专家研究员、混元3D负责人郭春超,电子音乐人、多领域创作者朱婧汐,以及青年导演、梦见城(北京)科技文化有限公司创始人周楠共同参与讨论。

多元视角的碰撞,将为游戏、视频、音乐三大AI创新赛道带来深度思考与行业共鸣。

南洋理工大学副教授刘子纬就AI内容生成的学术前沿与工程落地进行了深入解析。他将持续关注全模态的创作模型,包括本次昆仑万维展示的大模型系列,期待它们在各个领域都能涌现出“神之一手”,类似于当时AlphaGo和李世石下棋,能够出现让人类很震惊、有创造力那一步,会非常期待在未来一到两年内,无论是在音乐还是影视上,AI模型会涌现出“神之一手”的创造力。

北京深空交响有限公司创始人、QQ炫舞创始人姚勇从游戏领域分享了AIGC实际落地的宝贵经验。他谈及,做游戏要解决三块,首先是一定要新鲜,之前的人没有做过,第二个是视觉上一定要好看,第三块是好玩,这三块都特别费钱和时间。AI能够快速应对“好看”这个问题,并且擅长解决“新鲜”的问题。

AI能够在同样的场景变出不同新鲜的东西来,对于用户而言感受非常明显。好玩从另一个角度讲,需要长时间打磨玩法。在打磨的过程中,70%-80%的时间都消耗在搭建系统能让它运行起来,这部分AI又能够起到很大的支持作用,能够让更多的创意实现。因此从这三部分来讲,AI起到的辅助作用和带来的改变都非常明显。

腾讯专家研究员、混元3D负责人郭春超谈及3D生成技术和未来发展时,他表示,世界模型不只涉及游戏,还有自动驾驶领域、具身机器人领域也有世界模型,每个方向聚焦和解决的问题不一样,导致世界模型可能大家对它的理解众说纷纭。世界模型的用武之地已经在逐步地被验证,一部分接近商用的模型已经出现。世界模型虽尚未成熟,但目前已经阶段性地找到了能够局部落地的场景,相信给这个领域一些时间,再结合上多模态技术和大语言模型技术的发展,一定会展现出越来越强的能力。

展望未来,郭春超表示,从技术角度主要关注两个点,第一是各种AI生成模型和人类意图的对齐,能不能做到真正的人类意图的可控,也就是言出法随。无论是AI 3D还是其他模态,如果真正能做到说它生成的结果和人类意图的高度对齐,那时候生成模型的可用性就非常大。第二是Agent系统,如果Agent系统的可用性、执行任务的成功率以及成本等方面能够持续优化,它必将深刻改变我们办公和生活的方方面面。

电子音乐人、多领域创作者朱婧汐以创作者视角诠释了AI赋能音乐创作的无限可能。她表示,从技术层面来讲,AI音乐创作的技术发展非常迅速,对于普通的用户来说,这是一个非常好的事情。让更多人能够不借助任何其他的能力或者是别人,使用音乐表达我自己,这个事情非常有价值。

另一个层面,从职业音乐人的身份来讲,朱婧汐认为在AI进入音乐行业以后,势必会替代很多高同质化、高模板化的音乐甚至是功能性音乐。作为一个音乐人需要更多去关注“如何创作”,因为创作不是生成这么简单,创作包含了情感、表达、选择,他的审美,甚至包含了挣扎。

对于朱婧汐来说,在使用AI音乐模型的时候,她的方式可能会让AI音乐模型帮助生成很多的可能性。她描述与AI的模式或者是关系时说道,“更像是AI可以帮我同时打开很多扇窗,我站在原地就可以看到不同窗外的风景。我选择哪个风景描绘,把它变成一幅画展现给我的听众听,这是我自己才能做决策的事情。”

青年导演、梦见城(北京)科技文化有限公司创始人周楠则从影视内容创作角度,探讨了AI技术和视频生成工具对导演和创作流程的积极影响。周楠认为,影视就是由艺术和技术这一对似乎不搭界的父母共同生下的一个孩子。电影这门艺术如果没有电力的发明,没有照相术的发明,没有留声机的发明,没有数字技术的发明和崭新传播技术的发明,它不会如此辉煌,甚至可能根本不存在。

电影一直是拥抱技术的,在AI时代,我们会看到一个现象:很多人用一台电脑,动动手指,就能创造出以前上百人协作才能做到的惊人的影像,这对每个人来说是非常兴奋的。AIGC技术对于普罗大众来说最大的价值,是它把行业里面中层偏下那些廉价的缺乏原创性的作品的位置,让给了我们每个人——用独特的创意去创造最新鲜、最有趣、最丰富、最与众不同的内容,而且可以被无限分享和迅速迭代,这AIGC技术为这个行业带来最大挑战和价值。

昆仑万维董事长兼CEO方汉最后总结道,整个内容生态是一个金字塔,为什么视频是全世界最容易被理解的,因为不识字的人能够看视频,但是读不懂小说。人长了眼睛、鼻子、耳朵,多模态就是满足人类所有对内容的需求。无论是音乐、视频、游戏,都是供给全人类的视听娱乐,AIGC技术要让世界更加快乐。

方汉认为,全模态的价值是让整个内容行业都能够受益于创作门槛的降低、成本的降低,让更多的创作者加入进来。他表示,希望全球将来不是有60亿观众,而是可能有10亿创作者每天用昆仑万维的视频模型、音乐模型、游戏模型,给70亿人创作内容,我们相信这个局面一定会到来。

2026年,人工智能正以前所未有的速度重塑世界。昆仑万维以“实现通用人工智能,让每个人更好地塑造和表达自我”为使命,致力于推动多模态技术突破与商业化落地,用AI重构工作与创作方式。本次昆仑万维在2026中关村论坛的亮相与模型发布,既是向行业展示中国AI企业的技术自信,也是向全球伙伴发出生态共建的开放邀约。

我们始终坚信,AI技术的真正价值在于与产业的深度融合,在于与开发者、创作者、合作伙伴的携手共创。从音乐、视频、游戏等全模态内容到AI平台经济,昆仑万维以开放的姿态邀请各方共建AI时代的内容新生态,推动中国AIGC技术与产业走向世界。



苹果挖角谷歌高管 Lilian Rincon 执掌 AI 营销战略

当地时间周五,苹果公司宣布已聘请前谷歌购物产品副总裁 Lilian Rincon 负责 AI 产品营销工作,目前该公司正在为 Siri 大改版做准备。Lilian Rincon 将担任苹果 AI 产品营销副总裁,向苹果全球产品营销高级副总裁格雷格 · 乔斯维亚克(Greg Joswiak)汇报。(IT之家)

为什么很多复杂跳转,最后都得先回首页?

首页真正开始变重,往往不是 tab 能不能动态,而是它一边要处理远端配置,一边要处理登录态差异和多入口跳转。用户从不同地方进来,默认先到哪一页、旧状态要不要保、资源位和不同内容往哪儿走,都会一起变。走到这一步,首页处理的就不只是页面显示,而是整套跳转和页面安排。

我这次重新看首页相关代码,最先让我停住的,不是动态 tab,也不是某个配置字段,而是一段跳转逻辑。

很多入口并不是直接去目标页,而是要先退回首页,再继续往下走:

if (currentRoute != AppRoutes.initial && currentRoute != AppRoutes.splash) {
  Get.until((route) => route.settings.name == AppRoutes.initial);
}
Get.toNamed(AppRoutes.gameDetail, arguments: args);

如果首页真的只是一个普通页面,这种写法其实很奇怪。

你明明可以直接跳去详情页,为什么还要先回首页?

只有一种解释说得通:

首页在这个项目里,早就不只是首页了。

它已经成了很多复杂跳转最后都要借一下力的固定落点。

顺着这段逻辑再往下看,后面很多原本零散的问题就会重新连起来:

  • 为什么同样是首页,从不同地方进来体验不一样
  • 为什么某些 tab 一改,旧状态就开始乱
  • 为什么资源位、Deep Link、小程序和首页会互相牵扯
  • 为什么启动阶段很多初始化最后也会绕回首页这套逻辑里

所以这篇文章真正想讨论的,不是“首页怎么做成配置化”,而是首页什么时候开始从一个页面,慢慢变成一个要负责承接跳转、安排入口、重新组织内容的总入口。

1. 首页一旦成了很多跳转共同的落脚点,角色就已经变了

项目继续长一段时间以后,首页很容易悄悄长出一种新角色:

  • 不是用户点一下就进来的首页
  • 而是别的流程收不住时,要先退回来的那个位置

这件事比“tab 能不能动态”更值得注意。

因为它说明系统已经默认把首页当成:

  • 一个稳定基点
  • 一个统一入口
  • 一个可以继续展开下一步跳转的地方

这类角色一旦成立,首页真正开始关心的就不再只是页面怎么画、tab 怎么排,而会变成:

  • 哪些跳转应该先回首页
  • 哪些目标页可以直接进
  • 哪些入口需要先做登录校验
  • 哪些内容得先回到首页这一层再继续往下分

也就是说,首页最先变复杂的地方,往往不是页面显示,而是很多流程最后都要借它走一遍。

2. 真正把首页拖复杂的,不是配置字段本身,而是不同地方进来后要不要当成同一个首页处理

首页一旦开始接很多不同入口,页面本身就会变得没那么“固定”。

项目里 fromTaskPage 这种开关很小,但代表性特别强。它说明同样都是回首页,系统还得继续区分:

  • 你是正常点进来的
  • 还是从任务页跳回来的
  • 是从资源位带进来的
  • 还是从 Deep Link 直达后又退回来的
  • 甚至是不是从 WebView、小程序退出以后再回到首页这套逻辑里

一旦这些路径同时存在,“首页默认长什么样”这个问题就已经不够用了。

更接近真实的问题反而是:

  • 这次默认应该落在哪一页
  • 之前的状态还要不要保
  • 这次回来的用户,应该看到原来的首页,还是改过组织后的首页

所以首页复杂度真正开始长出来,不是因为页面本身变花了,而是因为同样叫首页,从不同地方进来的其实已经不是同一种体验。

3. 配置化后面最重的,不是 tab 显示,而是老页面、旧状态和默认先到哪一页怎么一起调整

不少人会先把首页配置化理解成:

  • tab 从写死改成接口返回
  • 某些频道能开关
  • 用户 A 和用户 B 看到的顺序不一样

这些当然都算配置化,但还不是最重的地方。

真正把事情变复杂的,是首页调整以后,老页面和旧状态还在不在。

DynamicTabController 这段逻辑就很能说明问题:

final Map<String, Widget> existingViews = {};
final oldTabs = _previousTabs.toList();

for (int i = 0; i < oldTabs.length && i < tabViews.length; i++) {
  existingViews[_normalizeTabName(oldTabs[i])] = tabViews[i];
}

for (String tab in tabs) {
  final normalizedTab = _normalizeTabName(tab);

  if (existingViews.containsKey(normalizedTab)) {
    tabViews.add(existingViews[normalizedTab]!);
  } else {
    ...
  }
}

这段代码真正在回答的,不是“tab 对不对”,而是:

  • 老页面要不要继续沿用
  • 老状态要不要保
  • 页面切回来时该不该刷新

DynamicPageConfig 这一层又把另一个问题抬了出来。

它已经不只是在说“显示不显示”,而是在决定:

  • 谁能看到什么
  • 某个页面什么时候出现
  • 默认先落在哪个 tab
  • 从不同入口回来时,首页要不要换一种页面安排

比如这段默认配置:

static const List<TabConfig> defaultTabs = [
  TabConfig(
    name: '论坛',
    key: 'forum',
    requireAuth: false,
    requireConfig: false,
    order: 0,
  ),
  TabConfig(
    name: '关注',
    key: 'follow',
    requireAuth: true,
    requireConfig: false,
    order: 1,
  ),
  ...
];

static const String defaultSelectedTab = 'discover';
static bool fromTaskPage = false;

这些字段一旦组合在一起,首页真正要处理的就不再只是“显示哪些 tab”,而是:

  • 入口筛选
  • 默认先到哪一页
  • 登录态差异
  • 配置差异
  • 老状态怎么保留下来

所以首页配置化真正变重的地方,常常不是 UI,而是页面重新调整以后,旧东西和新入口怎么一起处理。

4. 当资源位、H5、小程序都接进来以后,首页已经在替整套页面关系决定去向了

首页会继续变重,还有一个经常被低估的原因:

它后面接的内容类型越来越杂了。

项目里这几层一接进来,首页就已经很难只被当成一个普通页面看了:

  • 资源位服务会主动预加载
  • 小程序控制器会在启动阶段预热默认小程序
  • 本地 LocalServer 会被拉起来组织小程序内容
  • 远端配置 game_suport_show 还会影响小程序页要不要显示

这说明首页最后要处理的,已经不只是几个原生模块,而是一整套内容能力:

  • 原生页
  • H5 页面
  • 小程序页
  • 资源位驱动内容

这时候,很多看起来像“首页问题”的事情,最后都会被拉成另一类问题:

  • 内容该落在哪一页
  • 不同内容进来以后首页怎么接
  • 跳转规则怎么统一
  • 初始化时机怎么安排

走到这里,首页其实已经在替整套页面关系决定去向了。

所以我现在再看“配置化首页”这类需求,已经不会先去想“tab 怎么写”,而是:

  • 这个首页最后要处理多少种内容
  • 它是不是已经成了很多跳转共同的落脚点
  • 它是不是已经在替整套页面关系决定入口怎么走

只要这些问题开始出现,首页就已经不只是首页了。

「一人一天一部剧」时代降临,但AI短剧供给过剩不是末日|专访巨日禄杰夫

2026春节前,由字节跳动旗下视频生成模型Seedance2.0掀起的巨浪,正推动AI剧走向规模化落地。身在这场巨变中的短剧公司,或惊恐、焦虑,抑或面对新的机会热情高涨。

AI短剧创作平台巨日禄创始人杰夫预判,3—4月将成为行业关键节点,“一人一天一部剧”的生产能力成为现实,商业化标准的短剧制作成本将被大幅压缩,随之而来的是成本结构的彻底倒挂:人力成本占比从8成骤降至2成,算力与Token成为核心成本。供给过剩将在两三个月后如期而至,但这并非赛道式微的信号,反而意味着市场细分需求将被充分激活,行业总盘持续扩大。

在他看来,这场变革远不止于效率提升。从“跟风盯榜单”到“回归用户本身”,从“平台找内容”到“内容求平台”,行业逻辑正发生根本性转变;互动影游迎来新生……

除却情绪上的复杂,身处当下,该如何择机遇而安。近日,我们与巨日禄创始人杰夫聊了聊。

巨日禄创始人杰夫

以下为36氪与巨日禄创始人杰夫对话(经编辑)

一定数量级的集群,就能支撑影视内容的生产与消费

36氪:你对 2026-2027 年 AI剧赛道有什么核心的预判?

杰夫:我认为,今年 3—4 月,就会出现「一人一天一部剧」的生产能力,我们也会推出对应的技术能力。一部达到中等偏上、商业化标准的短剧,整体制作成本会被压缩到单人单日即可完成。

这会带来成本结构的重大颠覆:以前一部剧的成本里,算力、Token 等技术成本只占 2 成,人力成本占 8 成。

而从今年 3—4 月之后,这个结构会彻底倒挂:剧集的 BOM 成本会大幅下降,核心就是人力成本被极大压缩,最终变成算力、Token 等技术成本占 8 成,人力成本只占 2 成。

我再强调一下:我这里说的不是顶级超精品内容,而是达到商业化标准、中等偏上质量的剧集。

36氪:怎么才算达到商业化标准?

杰夫:你可以理解它是超精品之下,垃圾质量之上,值得被商业化的。你要说他好,一定是有很多毛病可以挑的。而在今年3-4月达到以后,会在未来两三个月实现供给过剩。

36氪:实现供给过剩,实际上是说这个赛道不值得做的吗?

杰夫:不会,这个赛道会持续值得做,因为个人对于这种个性化休娱内容的需求是非常旺盛的,AI剧赛道总盘会因此变大,题材也会变得相当之丰富。

36氪:这倒是。

杰夫:所以到今年 5 月左右,行业做剧的思路会彻底改变。之前大家做剧,基本都盯着能不能进榜前十,去研究榜单题材、模仿头部成功经验,以此为目标去创作。

等再过两三个月,单人单日一部剧的成本再次大幅下降后,整个行业的商业逻辑就必须变了,再用老思路一定会出问题。

到时候大家的核心思路不再是跟风盯榜单上的宝石,而是真正回归用户口味、内容品味、选题和选材本身。

以运动为例,以前用户只看足球小将、灌篮高手,但如果是跳高这个运动,就太小众了。但到了5月份左右,一定数量级的爱好集群就能支撑一个影视内容的生产和消化。

现在大家生产内容的思路,还是都想着怎么样去做爆款,这是一个底层逻辑。而且那个爆款指的就是榜1榜2榜3,所有人都围绕着大基数人群的供给,寻找大基数人群的痛点和共性。但后面在AIGC推动下,大家就会去找小众爱好,或者说细分人群的痛点。

36氪:海内外机会会有什么变化吗?

杰夫:我觉得国内和海外都会有机会出现一些细分品类和细分口味的内容平台。因为供给成本非常低,到2026年底的时候,一部剧的生产成本可能就1万块钱。

而5000部内容能支撑起一个独立的平台,此前因成本过高无法存在的垂直内容平台(如文艺片、复古爱好专属平台)将迎来机会。

从 “平台找内容” 变成 “内容求平台”

36氪:未来一年,内容平台的采买和保底逻辑会发生什么变化?

杰夫: 26年底到27年初,除了极头部的内容和超级创意工作室(仅 0.01%),行业内的保底、采买逻辑会基本消失。此前平台会给 CP 方保底,未来不仅没有保底,CP 方想在红果等平台上线内容,还需要自己推流、买量,从 “平台找内容” 变成 “内容求平台”。

目前行业已经开始取消部分保底,核心驱动因素是 AI 带来的供给成本降低、供给数量暴增,这一趋势会在 2026-2027 年彻底落地。

36氪:取消保底这个事情,是AI推动的结果吗?

杰夫:根上是AI,实际上是供给。供给成本的降低,带来供给数量的增加。

36氪:“超头部的保底还是会有”怎么理解?

杰夫:不管未来任何一天,超头部、极致好内容的保底是一定有的。

36氪:AI仿真人剧成本与真人实拍的差距有多少?

杰夫:此前纯真人拍摄一部剧成本超 50 万,现在 AI 做的真人画风内容成本已降至 20 万以内;今年 3 月后,AI 剧成本会进入 “小万时代”,5000-50000 元就能制作一部,具体成本取决于企业的组织能力、工具使用先进性和资源储备。

对比去年 12 月,AI 剧成本已降低超一半,而算力、token 会逐渐成为核心资源,就像能源一样,需求大于供给时会成为稀缺品。

互动影游迎来机会

36氪:除了生产效率,2026 年 AIGC 内容赛道还有哪些新的玩法和题材趋势?

杰夫: 今年 5-6 月,还会有新的东西、新玩法出现,互动影游会成为趋势。早期互动影游的剧情是前置生成的,而到 2026 年底 2027 年初,结合 AI 能力的实时后置生成将成为主流 ,用户无需在官方设定的节点选择剧情,可随时介入,甚至实时决定剧情走向,内容的互动性会被彻底拉满。

36氪:互动影游不是新鲜的东西,以前没做起来,现在就能吗?

杰夫:核心是AI 技术积淀达到,且成本大幅下降。互动影游确实是古早玩法,此前做不起来的原因一是分支剧情只能前置生成,互动性有限;二是制作成本过高,无法支撑多分支、个性化的剧情生产。而现在 AI 能实现实时后置生成,且成本骤降,让互动剧的商业化成为可能,技术和成本的双重突破是关键。

36氪:模式上会有一些新的探索吗?

杰夫:以前是互联网那一套思维逻辑,AI时代反而像工厂逻辑,就是甚至可以出现单边内容平台,上面的内容是自己生产的,就你和用户。

36氪:从用户端来看,大家对AI剧的我接受度似乎没有高,大家都在说那是没有灵魂的。

杰夫:自动挡、自动驾驶出来的时候,都有经历过类似的过程,大家对新事物接受需要一点时间。

36氪:那些短剧拍摄基地是不是也会受影响?

杰夫:我没有专门关注他们。说实话我没有热情关注谁的消亡,而是要花时间想下一个时代的节奏,因为时代变化太快,可能下一个消亡的就是我们自己,自己危机感都很强,所以没功夫看人家的挽歌。

36氪:你会有fomo情绪吗?

杰夫:我状态更多是兴奋,焦虑是处于一种失控和未知带来的情绪。我更像是现在冲浪冲得比较有激情、比较开心的状态。

我会好好享受这个时代给我们带来的机会,享受比赛,不要老了以后回想当初哥们站在风口浪尖,想太多,天天焦虑睡不着、动作变形。现在就是别留遗憾,能留下身影最好,留下一个帅气的身影就更好了。

AIGC 赛道的周期波动快,但波峰和波谷都很高,既然享受了行业快速发展的红利,就不必抱怨行业快速变化的压力。

另外,我发现大家现在有点过度焦虑 AI 模型迭代了。AI 视频、视觉生成模型迭代太快,没有一款能长期保持第一,技术路径曝光后,其他厂商很快就能跟上,2026 年不会有某一个模型独大,模型领域会一直是百花齐放的状态。关键是选一个能把模型能力和资金生产流程深度融合的伙伴,让技术真正为内容服务。

36氪:未来哪些企业能活下来,核心的生存逻辑是什么?

杰夫: 一是高效的生产型团队,能控制成本、实现高产能,且具备找到细分场景、细分人群需求的能力,靠 “在一定水平上走量 + 精准匹配” 盈利,比如一年生产 2000 部 AI 剧,每部平均赚 1-2 万,就能实现稳定盈利;二是超级精品创意工作室,能做出眼前一亮的极致创意内容,这类内容永远有市场。

36氪:今年应该很多真人剧的公司过来找你们合作吧?

杰夫:很多,以前做漫剧那帮人和做真人的那帮人重叠度已经很高了。

36氪:AI叙事下,长视频平台还有机会吗?

杰夫:所有视频平台都有机会。因为供给上实现了平权。

小团队抓细分,核心练 “从0 到 1” 能力

36氪:春节前你预测2026年会是真人短剧特别难的一年,现在来看好像都在成为现实。

杰夫:当时我说真人短剧80%会被AI替代,很多人和我抬杠,现在没有人抬了。当然不可能100%替代,就像iPhone都到17了,还有人在用诺基亚,肯定还会有人坚持古法手搓。

36氪:大公司小公司都会难受?

杰夫:在上一个周期里体量越大的真人剧公司,现在越难受,这类公司有庞大的拍摄团队、重资产,转身难度大;而此前没有真人剧资源的中小团队,反而能快速抓住 AI 剧的机会,行业的供给单元正在从大型集中式转向中小分散式。

中小团队的核心是放弃 “做爆款、赚大钱” 的思维,转向 “小成本、高频次、精准匹配”,哪怕一部剧只赚几千、几万,靠量的积累也能实现稳定盈利。

36氪:所以身在AI洪流中,你觉得大家应该如何应对冲击?

杰夫:我觉得核心还是考验你不断move on的能力,这会挑战创始人的认知行动、能力和决心。这是非常反人性的、非常tough的一个状态。

很多人说去年巨日禄进展挺顺的。实际上我们隔一段时间都要打一轮新仗,没有打赢的话就掉队了,你看,AI做漫剧,这是从0到1的新仗,然后就move on 到 agent做漫剧,当建立优势的时候我意识到要做仿真人。我们做AI真人剧的时候其实看不到明确的机会,但我们就是先做了,这么多场战,随便一个我们没有跟上的话就出局了。

36氪:你们是做工具的,那么做内容的该怎么建立自己的生存机会?

杰夫:第一种就是找到细分需求,细分场景,你的效率是高的,你的生产量是大的。其中考验的就是你发现机会的能力,你发现机会就吃一口,走人就行了,然后再去发现一些新机会。另外一种就是极端精品,极端超头部和创意。

36氪:2026 年,工具方、平台之间的关系会发生哪些变化?

答: 对于工具方而言,新玩家的机会渺茫,行业身位会变狭窄,头部工具方的核心工作会变成做“苦活”—— 比如引导客户使用工具、解决落地问题、提供陪跑服务等,而非单纯的技术研发。

36氪:真人剧的时候大家都在说同质化,AI剧会改变这种情况吗?

杰夫:以前同质化的核心原因是生产成本高,必须聚焦大基数人群的共性需求,大集群人群喜欢的内容就那么几类,自然会出现同质化;而 AI 剧成本骤��后,小众运动、小众爱好的内容都能被生产,此前不敢拍的题材,现在 1 万块就能做一部,只要有小范围的受众就能盈利,内容的多样性会大幅提升,同质化问题会明显缓解。

36氪:AI 对内容行业的工种会带来哪些影响,哪些岗位会更重要,哪些会被替代?

杰夫:大部分工种会被 AI 替代,编剧是相对稀缺的工种,但也只是 “矮子里拔将军”——AI 已经能生成比 80%-90% 人类编剧更好的剧本,未来编剧的核心价值是做极致创意、挖掘细分人群痛点,而非基础的剧本撰写。演员行业会受到颠覆性冲击,未来人类的表演行为甚至可能成为非物质文化遗产,目前已经有演员开始售卖自己的 IP 形象用于 AI 剧制作,这会成为行业常态。

36氪:2026 年成为 AI 剧元年,行业内最稀缺的能力是什么?

杰夫: 核心是找到细分口味、匹配细分人群和场景的能力,其余能力都不稀缺。甚至未来很难通过 “稀缺性” 做生意,因为 AI 让生产、技术等能力都实现了平权,能否精准抓住小众需求,成为决定企业能否盈利的关键。

36氪:未来AI内容赛道的增量市场在哪里,企业该如何布局?

杰夫:海外市场是核心的增量机会,且机会远大于国内。国内的平台垄断、流量壁垒会限制行业发展,而海外的市场环境更开放,细分平台、互动剧的发展空间更大,我们也会重点拥抱海外市场。

36氪:你对 AI 的未来发展有哪些颠覆性的想象?

杰夫: 目前已经出现挂在脖子上的 AI 记录相机,这只是开始,未来AI 会通过各类便携设备 “寄生” 在人类身上,成为人类的 “电子项圈”。这类设备初期只是做信息输入,未来会逐渐实现信息输出 —— 指导人类的决策、行为,甚至告诉人类该吃什么、去哪、怎么回答问题,人类会越来越依赖 AI。更极致的是,越牛逼的 AI 模型对 token 的消耗越大,未来这类 “电子项圈” 会分三六九等,买不起高端模型的人会逐渐落后,AI 可能会形成新的阶层差异,这是非常颠覆性的想象。

JavaScript 严格模式下 arguments 的区别

标签:#前端 #JavaScript #严格模式 #arguments #学习笔记

一、arguments 是什么?

arguments 是函数内部的类数组对象(Array-like),包含了函数调用时传入的所有实参。

function foo() {
  console.log(arguments);       // [1, 2, 3]
  console.log(arguments[0]);    // 1
  console.log(arguments.length); // 3
}
foo(1, 2, 3);

⚠️ 它不是真正的数组,没有 pushmapforEach 等方法(除非用 Array.from() 转换)。

二、如何开启严格模式

// 全局严格模式
'use strict';

// 函数级严格模式
function bar() {
  'use strict';
  // 此函数内启用严格模式
}

三、严格模式 vs 非严格模式的核心区别

🔴 区别一:arguments 不可被修改来影响命名参数(最重要)

非严格模式(二者联动)

function foo(a, b) {
  console.log(a, b);        // 1, 2
  arguments[0] = 100;
  console.log(a, b);        // 100, 2  ← a 被改变了!
}
foo(1, 2);

非严格模式下,修改 arguments[n] 会同步修改对应的命名参数。

严格模式(二者独立)

'use strict';
function foo(a, b) {
  console.log(a, b);        // 1, 2
  arguments[0] = 100;
  console.log(a, b);        // 1, 2  ← a 没变!
}
foo(1, 2);

严格模式下,arguments 和命名参数完全独立,互不影响。

🔴 区别二:arguments.callee 被禁用

非严格模式(可用)

function factorial(n) {
  if (n <= 1) return 1;
  return n * arguments.callee(n - 1);  // ✅ 正常执行
}
console.log(factorial(5)); // 120

arguments.callee 指向当前正在执行的函数本身,常用于匿名函数递归。

严格模式(报错)

'use strict';
function factorial(n) {
  if (n <= 1) return 1;
  return n * arguments.callee(n - 1);  // ❌ TypeError!
}

严格模式下访问 arguments.callee 会直接抛出 TypeError

✅ 替代方案

// 方案1:命名函数直接调用自身
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

// 方案2:使用箭头函数 + 函数名(尾递归友好)
const factorial = (n) => {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
};

// 方案3:用函数表达式赋给变量
const factorial = function f(n) {
  if (n <= 1) return 1;
  return n * f(n - 1);
};

🔴 区别三:arguments.caller 被禁用

非严格模式

function inner() {
  console.log(arguments.caller);  // 返回调用 inner 的外部函数
}
function outer() {
  inner();
}
outer();

严格模式

'use strict';
function inner() {
  console.log(arguments.caller);  // ❌ TypeError!
}

arguments.callerarguments.callee 在严格模式下都被禁止,原因是它们存在安全隐患(可以访问调用栈)。

🟡 区别四:arguments 不会追踪剩余参数(Spread Rest)

严格模式引入了 ...rest 语法,它与 arguments 的行为完全不同:

'use strict';

function foo(a, ...rest) {
  console.log(arguments.length); // 实参个数
  console.log(rest.length);      // 剩余参数个数
}

foo(1, 2, 3, 4);
// arguments.length → 4(所有实参)
// rest.length → 3(去掉 a 之后的部分:[2, 3, 4])

关键区别

  • arguments:类数组,包含所有实参
  • ...rest真正的数组,只包含未匹配命名参数的部分

四、完整对比表

特性

非严格模式

严格模式

修改 arguments[n] 影响命名参数

✅ 会影响

❌ 不影响

arguments.callee

✅ 可用

❌ TypeError

arguments.caller

✅ 可用

❌ TypeError

arguments 与 rest 参数共存

✅ 可共存

✅ 可共存(但行为独立)

五、为什么严格模式要限制 arguments?

1. 性能优化

非严格模式下,JS 引擎必须维护 arguments 和参数之间的双向绑定关系,这导致无法对函数参数进行某些优化。严格模式下二者独立,引擎可以更高效地处理参数。

2. 安全性

arguments.calleearguments.caller 允许访问调用栈,存在被利用来进行安全攻击的风险。

3. 代码可读性

现代 JS 推荐使用命名函数rest 参数替代 arguments 的各种黑魔法,代码更清晰。

六、现代推荐写法

'use strict';

// ❌ 旧写法:依赖 arguments
function sum() {
  let total = 0;
  for (let i = 0; i < arguments.length; i++) {
    total += arguments[i];
  }
  return total;
}

// ✅ 新写法:使用 rest 参数
function sum(...nums) {
  return nums.reduce((acc, n) => acc + n, 0);
}

// ✅ 新写法:rest + 命名参数结合
function log(prefix, ...messages) {
  messages.forEach(msg => console.log(prefix, msg));
}
log('[INFO]', 'hello', 'world');

总结

严格模式的核心改变:将 arguments 从一个"神奇的对象"变成了一个普通的类数组,切断了它与命名参数的隐式绑定,并禁用了不安全的 callee/caller 属性。在现代 JS 开发中,推荐使用 ...rest 参数替代 arguments

2026清明档新片预售票房破1500万

据猫眼专业版数据,截至3月28日16时,2026年清明档新片预售总票房破1500万,《我,许可》《阳光女子合唱团》《我的妈耶》暂列清明档新片预售票房榜前三位。 ​​​

如何借助Github pages部署React+vite静态前端项目

这里只针对静态页面啊,需要向后端服务器访问数据的不行,所以还是有点受限了。

在部署时遇到了许多问题,所以想和大家分享一下我的解决方案。

我自己也去找了许多教程,下面贴出一些个人觉得有帮助的:

来认真学一下,项目部署到 github pages-腾讯云开发者社区-腾讯云

在 GitHub Pages 上部署 React 应用|极客教程

【‌轻松上手:React Vite 应用快速部署至 Github Pages】www.bilibili.com/video/BV1ch…

【免费部署一个静态网站!在GitHub上部署静态网站教程】www.bilibili.com/video/BV1D2…

虽然啊,这些都没有解决我的问题,但是讲的还是不错的。其中有些是使用git命令推到Github的仓库上,但是不知道为什么输命令的话连不上github

报错如下:

fatal: unable to access 'https://github.com/仓库名/项目名.git/': Failed to connect to github.com port 443 after 21143 ms: Couldn't connect to server

遇到过404的问题即访问不到资源,也遇到过不报错但全白屏的情况。其实都是路径不对的原因,接下来进行分析。

GitHub Pages 的两种部署方式

  1. 用户/组织站点

    • 仓库名:username.github.io
    • 访问地址:https://username.github.io/
    • 部署在根路径,base 配置为 /
  2. 项目站点

    • 仓库名:任意名称(如 myWeb
    • 访问地址:https://username.github.io/myWeb/
    • 部署在子路径,需要特殊配置

关键配置项

  • base:Vite 配置,控制静态资源(JS/CSS/图片)的引用路径
  • basename:React Router 配置,控制路由的基础路径

失败原因分析

问题 1:文件结构错误

现象: 404 错误,资源找不到

原因: 上传时把 dist 文件夹本身拖到 GitHub,导致结构变成:

仓库根目录/
  └── dist/
      ├── index.html
      └── assets/

实际访问路径变成 https://xxx.github.io/myWeb/index.html,而 HTML 里的资源路径是 /myWeb/assets/...,路径不匹配。

解决方案:

  • 进入 dist 文件夹内部
  • 全选里面的文件和文件夹(不选 dist 本身)
  • 拖到 GitHub 仓库根目录

正确结构:

仓库根目录/
  ├── index.html
  ├── assets/
  └── vite.svg

问题 2:base 配置错误

现象: 资源 404,或者图标能显示但 JS/CSS 加载失败

原因: base 配置不正确导致资源路径错误

尝试过的配置:

  1. base: '/' (默认)

    <script src="/assets/index.js"></script>
    

    实际访问:https://xxx.github.io/assets/index.js

    正确路径:https://xxx.github.io/myWeb/assets/index.js

  2. base: '/visual-editor/' (绝对路径)

    <script src="/myWeb/assets/index.js"></script>
    

    生产环境:✅ 正确

    本地开发:❌ npm run devnpm run preview 都会失败

  3. base: './' (相对路径)

    <script src="./assets/index.js"></script>
    

    看似完美,但遇到了问题 3...

问题 3:React Router 路由匹配失败

现象:

  • 资源加载成功(304 /200 状态码)
  • 控制台无报错
  • 页面白屏
  • DOM 中只有 <div id="root"><div id="app"></div></div>,app 内部为空

原因: BrowserRouter 在子路径下路由匹配失败

技术细节:

  • GitHub Pages 访问地址:https://xxx.github.io/myWeb/
  • BrowserRouter 默认 basename 是 /
  • 当前实际路径是 /myWeb/
  • 路由配置的 path="/" 匹配不上实际路径 /myWeb/
  • React 渲染了 <div id="app"> 但 Routes 内没有匹配的组件

为什么本地 preview 也白屏?

  • npm run preview 运行在 http://localhost:4173/
  • 如果 base 是 /myWeb/,资源路径变成 http://localhost:4173/myWeb/assets/...
  • 但实际文件在 http://localhost:4173/assets/...
  • 路径不匹配导致加载失败或路由失败

问题 4:开发/生产环境冲突

矛盾点:

  • 开发环境(npm run dev)需要 base 为 /
  • 生产环境(GitHub Pages)需要 base 为 /myWeb/
  • 写死任何一个值都会导致另一个环境失败

最终解决方案

1. 动态配置 base(vite.config.ts)

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig(({ mode }) => ({
  plugins: [react()],
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  server: {
    port: 3000,
  },
  base: mode === 'production' ? '/myWeb/' : '/',
}));

原理:

  • mode 参数由 Vite 自动传入
  • npm run dev:mode = 'development',base = '/'
  • npm run build:mode = 'production',base = '/visual-editor/'

2. 动态配置 basename(src/main.tsx)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/base.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter basename={import.meta.env.BASE_URL}>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

原理:

  • import.meta.env.BASE_URL 是 Vite 内置环境变量
  • 自动读取 vite.config.ts 中的 base 值
  • 开发环境:basename = '/'
  • 生产环境:basename = '/myWeb/'

3. 部署流程

1. 构建项目
npm run build

2. 上传文件
打开本地 dist 文件夹
全选里面的所有内容(index.html、assets 文件夹等)
拖到 GitHub 仓库根目录上传

3. 配置 GitHub Pages
仓库 Settings → Pages
Source: Deploy from a branch
Branch: main
Folder: / (root)
Save

4. 等待 1-2 分钟后访问
https://username.github.io/myWeb/

常见问题排查

白屏无报错

  1. 检查 DOM 结构,看 <div id="root"> 里有没有内容
  2. 如果只有空的 <div id="app">,是路由问题
  3. 确认 BrowserRouter 的 basename 配置

资源 404

  1. F12 → Network 面板查看失败的资源路径
  2. 对比实际文件在 GitHub 上的位置
  3. 检查 vite.config.ts 的 base 配置

缓存问题

  1. 强制刷新:Ctrl + Shift + R(Windows)
  2. 无痕模式访问
  3. 清除浏览器缓存
  4. URL 加随机参数:?v=123(没试过有没有用)

本地预览失败

npm run preview

如果白屏,说明配置有问题,不要急着部署,先在本地解决。

总结

部署 Vite + React Router 到 GitHub Pages 的核心是:

  1. 理解路径差异:开发环境根路径 vs 生产环境子路径
  2. 两处配置:静态资源路径(base)+ 路由基础路径(basename)
  3. 环境区分:使用 Vite 的 mode 参数动态配置
  4. 正确上传:上传 dist 内容,不是 dist 文件夹

配置正确后,开发和部署都能正常工作,无需手动切换配置。

如果网络上实在找不到解决方法,可以去问ai的,现在大模型越来越强,将问题和目标清楚地描述给ai,大概率可以解决问题。

🔥 前端人必看:浏览器安全核心知识点全解析(XSS/CSRF/DDoS)

作为前端开发者,我们每天都在和浏览器打交道,但你真的了解浏览器背后的安全隐患吗?从 XSS 脚本注入到 CSRF 跨站请求伪造,再到 DDoS 流量攻击,这些威胁时刻潜伏在我们的代码里。

本文将用最通俗的语言,拆解浏览器安全的核心知识点,让你轻松掌握防御手段,写出更安全的 Web 应用。

一、同源策略:浏览器安全的第一道防线

同源策略是浏览器最核心的安全基石,它规定了:协议、域名、端口必须完全一致,两个页面才属于 “同源”,才能自由读取对方的资源。

  • 举个例子:

    • https://juejin.cnhttps://juejin.cn:8080 → 端口不同,不同源
    • https://juejin.cnhttps://m.juejin.cn → 子域名不同,不同源
    • http://juejin.cnhttps://juejin.cn → 协议不同,不同源
  • 作用:同源策略限制了不同源之间的脚本、Cookie、DOM 等资源的访问,从根源上防止恶意网站窃取用户数据。如果没有同源策略,恶意网站可以轻易读取你在其他网站的登录状态、个人信息,后果不堪设想。

二、XSS 攻击:藏在网页里的 “隐形脚本”

1. 什么是 XSS?

XSS(Cross-Site Scripting,跨站脚本攻击)是指黑客在目标网站的网页中嵌入恶意脚本,当用户访问该网站时,浏览器会执行这些脚本,导致用户信息泄露、网站被劫持等严重后果。

2. XSS 的三种注入方式

  • 反射型 XSS:用户在不知情的情况下,将恶意脚本作为参数传递给网站请求,服务器会把参数原封不动返回给用户,浏览器会解析执行这段脚本。

    • 示例:https://example.com/search?keyword=<script>stealCookie()</script>
  • 存储型 XSS:黑客将恶意脚本直接存储在目标网站的数据库中,当其他用户访问相关页面时,脚本会被加载执行。

    • 示例:在评论区、留言板提交包含恶意脚本的内容
  • DOM 型 XSS:黑客通过篡改页面 DOM 结构,劫持 Web 资源,直接在前端执行恶意代码,不需要服务器参与。

3. 如何防御 XSS?

  1. 关键字符过滤:对用户输入的内容进行严格过滤,转义掉 <>&"' 等特殊字符,防止浏览器将其解析为 HTML 标签。

  2. CSP(内容安全策略) :在响应头中添加 Content-Security-Policy 字段,限制浏览器只能加载指定来源的资源,防止 XHR 请求加载恶意脚本。

    • 示例:Content-Security-Policy: default-src 'self'
  3. HttpOnly Cookie:在设置 Cookie 时添加 HttpOnly 属性,禁止 JavaScript 访问 Cookie,即使被 XSS 攻击也无法窃取用户登录凭证。

    • 示例:Set-Cookie: sessionId=xxx; HttpOnly

三、CSRF 攻击:披着 “合法外衣” 的恶意请求

1. 什么是 CSRF?

CSRF(Cross-Site Request Forgery,跨站请求伪造)是指黑客在恶意网站中嵌入表单或脚本,当用户登录过目标网站并访问恶意网站时,浏览器会自动携带目标网站的 Cookie,发起伪造的请求(比如转账、修改密码)。

2. CSRRF 的攻击方式

  • 自动发起 GET 请求:通过 <img><link> 等标签,在页面加载时自动发起请求。
  • 自动发起 POST 请求:通过隐藏表单,在页面加载时自动提交表单。

3. 如何防御 CSRF?

  1. 验证码:在关键操作(如转账、修改密码)前添加验证码,要求用户手动输入,防止机器人自动提交。

  2. CSRF 令牌:在表单中添加随机生成的令牌,服务器验证令牌有效性后才处理请求,确保请求来自合法页面。

  3. SameSite Cookie:在设置 Cookie 时添加 SameSite 属性,限制 Cookie 只能在同站请求中携带,防止跨站请求携带 Cookie。

    • Strict:完全禁止跨站携带 Cookie
    • Lax:允许部分安全的跨站请求携带 Cookie(如 GET 请求)

四、DDoS 攻击:压垮服务器的 “流量洪水”

1. 什么是 DDoS?

DDoS(Distributed Denial of Service,分布式拒绝服务攻击)是指黑客通过控制大量被感染的设备,向目标网站发送海量请求,耗尽服务器的带宽、CPU、内存等资源,导致网站无法正常响应合法用户的请求。

2. 如何防御 DDoS?

  1. 防火墙:在服务器端部署防火墙,对进入的流量进行过滤,只允许合法流量通过,拦截恶意请求。
  2. 负载均衡:通过负载均衡器将流量分发到多台服务器,避免单台服务器被流量压垮,提升系统的抗攻击能力。
  3. CDN 加速:将静态资源缓存到全球各地的 CDN 节点,分散流量压力,同时隐藏源服务器的真实 IP。
  4. 流量清洗:使用专业的 DDoS 防护服务,对流量进行清洗,过滤掉恶意请求,只将合法流量转发给源服务器。

五、浏览器系统安全:从底层筑牢防线

除了上述攻击类型,浏览器本身也提供了一系列安全机制,帮助我们保护用户数据:

  • 沙箱机制:将浏览器的不同进程(如渲染进程、插件进程)隔离在沙箱中,防止恶意代码突破进程边界,影响系统安全。
  • 自动更新:浏览器会定期更新安全补丁,修复已知的漏洞,建议始终保持浏览器为最新版本。
  • 安全浏览:主流浏览器会内置安全浏览功能,拦截已知的恶意网站、钓鱼网站,提醒用户注意风险。
  • 权限控制:浏览器会对网站的权限(如摄像头、麦克风、地理位置)进行严格控制,用户可以手动授权或拒绝。

六、总结

浏览器安全是 Web 开发中不可忽视的一环,从同源策略到 XSS、CSRF、DDoS 攻击,每一个知识点都关系到用户数据的安全。作为前端开发者,我们不仅要写出功能完善的代码,更要具备安全意识,在开发过程中主动采取防御措施,为用户打造更安全的上网环境。

东风日产NX8将于4月8日上市

3月28日,东风日产宣布,东风日产NX8将于2026年4月8日正式上市并公布价格,同步启动首批车主的交付工作。

国家数据局已新设国际数据治理合作司

近日,国家数据局官网公布的《国家数据局2026年度部门预算》披露,国家数据局共有行政单位1个,为机关本级,内设6个司:综合司、政策和规划司、数据资源司、数字经济司、数字科技和基础设施建设司、国际数据治理合作司。局属事业单位1个,为国家数据发展研究院。 (澎湃新闻)

React如何远程加载组件

前言

做过低代码开发的应该都遇到过组件加载的问题,一开始我们是把组件和整个项目放在同一个仓库中,打包的时候每个组件都是一个单独的chunk,这样加载的时候按需加载。但是随着项目越来越大,组件越来越多,甚至一些自定义的等等,这一套逻辑就不能支持下去。

而远程组件的好处就是,每个开发都能编写组件直接提交到一个公共的地方,那么我们的项目在获取组件列表的时候就能自动拿到以及使用。

构想

既然我们需要加载远程组件,就需要实现一个模块,需要处理从远端加载我们的组件然后进行渲染。

1.加载远端js
2.缓存组件(避免多次加载)
3.获取组件
4. 渲染

低代码通常会返回一个配置项,描述了内部有哪些组件以及他们的层级关系,这些我们不需要关注,默认我们现在有这么一个配置项。

首先要加载远程组件,然后把他放到react中渲染,类似下面的结构:

  const json = {
  components: [
  {
"id": "remote-button-a",
"url": "https://cdn.jsdelivr.net/npm/@luke358/remote-component@latest/dist/umd/button.umd.js",
"globalName": "RemoteUI",
"exportName": "Button",
"componentProps": {
"children": "远程按钮 A"
},
"loadingText": "远程按钮 A 加载中",
"clickMessage": "Remote UMD button A is working."
},
  ]
  }

这里我们定义了一些简单的结构。

  • import 远程的js
  • name 我们最终把这个组件挂载到window上,用于存取组件

实现

远程组件的构建和打包

这里主要关注组件的实现、以及通过配置远程加载组件,这里我们需要把远程组件打包为 umd 或者 iife。

我们新建一个项目来存放我们的组件。直接使用vite+react+ts脚手架搭建即可。

我们把所有的组件都放在 src/components 下。

主要的打包配置如下

import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { build } from "vite";
import react from "@vitejs/plugin-react";

const rootDir = process.cwd();
const srcComponentsDir = resolve(rootDir, "src/components");
const outDir = resolve(rootDir, "dist/umd");
const entryExtensions = [".tsx", ".ts", ".jsx", ".js"];
const globalNamespace = "RemoteUI";

function toKebabCase(value) {
  return value
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
    .replace(/[_\s]+/g, "-")
    .toLowerCase();
}

async function getComponentEntries() {
  const dirents = await readdir(srcComponentsDir, { withFileTypes: true });
  const entries = [];

  for (const dirent of dirents) {
    if (!dirent.isDirectory()) {
      continue;
    }

    const componentName = basename(dirent.name);
    const entry = entryExtensions
      .map((extension) => resolve(srcComponentsDir, componentName, `index${extension}`))
      .find((entryPath) => existsSync(entryPath));

    if (entry) {
      entries.push({
        componentName,
        entry,
        fileName: `${toKebabCase(componentName)}.umd.js`,
        globalName: globalNamespace
      });
    }
  }

  if (entries.length === 0) {
    throw new Error("No component entry files were found under src/components.");
  }

  return entries;
}

async function buildComponent(entryConfig) {
  await build({
    configFile: false,
    plugins: [
      react({
        jsxRuntime: "classic"
      })
    ],
    resolve: {
      alias: {
        "@": resolve(rootDir, "src")
      }
    },
    build: {
      outDir,
      emptyOutDir: false,
      sourcemap: true,
      lib: {
        entry: entryConfig.entry,
        name: entryConfig.globalName,
        formats: ["umd"],
        fileName: () => entryConfig.fileName
      },
      rollupOptions: {
        external: ["react", "react-dom"],
        output: {
          extend: true,
          globals: {
            react: "React",
            "react-dom": "ReactDOM"
          }
        }
      }
    }
  });
}

async function main() {
  const entries = await getComponentEntries();

  await rm(outDir, { recursive: true, force: true });
  await mkdir(outDir, { recursive: true });

  for (const entry of entries) {
    await buildComponent(entry);
  }

  await writeFile(
    resolve(outDir, "manifest.json"),
    JSON.stringify(
      entries.map(({ componentName, fileName, globalName }) => ({
        componentName,
        fileName,
        globalName,
        accessPath: `${globalName}.${componentName}`
      })),
      null,
      2
    )
  );

  console.log(`Built ${entries.length} UMD bundle(s) into ${outDir}`);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

主要关注的就是 formats, globalNameexternal.

我们不需要把 React 和 ReactDOM 打包进来,到时候全部都适用主应用的,所以后续需要在主应用把 React 和 ReactDOM 挂载到 window 上。

然后编写一个简单的 button 组件

import React from "react";

export interface ButtonProps {
  children: React.ReactNode;
  type?: "button" | "submit" | "reset";
  disabled?: boolean;
  onClick?: () => void;
}

const buttonStyle: React.CSSProperties = {
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  padding: "12px 18px",
  border: "none",
  borderRadius: "999px",
  background:
    "linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(37, 99, 235, 1) 100%)",
  color: "#ffffff",
  fontSize: "14px",
  fontWeight: 600,
  cursor: "pointer",
  boxShadow: "0 12px 30px rgba(37, 99, 235, 0.24)"
};

const disabledStyle: React.CSSProperties = {
  opacity: 0.45,
  cursor: "not-allowed",
  boxShadow: "none"
};

export function Button({
  children,
  type = "button",
  disabled = false,
  onClick
}: ButtonProps) {
  return (
    <button
      type={type}
      disabled={disabled}
      onClick={onClick}
      style={{
        ...buttonStyle,
        ...(disabled ? disabledStyle : undefined)
      }}
    >
      {children}
    </button>
  );
}

执行打包之后 "build:umd": "node ./scripts/build-umd.mjs" 就会在dist/umd 下看到一个 button.umd.js 文件。

之后把这个文件上传到 oss 或者其他地方就能进行远程加载。

远程组件的加载使用

添加React到全局对象

// main.ts
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

declare global {
  interface Window {
    React?: typeof React;
    ReactDOM?: typeof ReactDOM;
  }
}

window.React = React;
window.ReactDOM = ReactDOM;

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

这样在渲染远程组件的时候就能确保 React 能正确的使用

RemoteComponentLoader 实现


function loadRemoteScript(url: string) {
  const cachedPromise = scriptPromiseCache.get(url);

  if (cachedPromise) {
    return cachedPromise;
  }

  const scriptPromise = new Promise<void>((resolve, reject) => {
    const existingScript = document.querySelector<HTMLScriptElement>(`script[data-remote-src="${url}"]`);

    if (existingScript) {
      if (existingScript.dataset.loaded === "true") {
        resolve();
        return;
      }

      existingScript.addEventListener("load", () => resolve(), { once: true });
      existingScript.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)), {
        once: true
      });
      return;
    }

    const script = document.createElement("script");
    script.src = url;
    script.async = true;
    script.dataset.remoteSrc = url;
    script.onload = () => {
      script.dataset.loaded = "true";
      resolve();
    };
    script.onerror = () => reject(new Error(`Load failed: ${url}`));
    document.head.appendChild(script);
  }).catch((error) => {
    scriptPromiseCache.delete(url);
    removeRemoteScript(url);
    throw error;
  });

  scriptPromiseCache.set(url, scriptPromise);
  return scriptPromise;
}

export function loadRemoteComponent<TProps extends Record<string, unknown>>(
  config: RemoteComponentConfig
) {
  const cacheKey = getCacheKey(config);
  const resolvedComponent = resolvedComponentCache.get(cacheKey);

  if (resolvedComponent) {
    return Promise.resolve(resolvedComponent as RemoteComponentType<TProps>);
  }

  const pendingComponent = componentPromiseCache.get(cacheKey);

  if (pendingComponent) {
    return pendingComponent as Promise<RemoteComponentType<TProps>>;
  }

  const componentPromise = loadRemoteScript(config.url)
    .then(() => {
      const remoteComponent = resolveRemoteExport(config);
      resolvedComponentCache.set(cacheKey, remoteComponent);
      return remoteComponent;
    })
    .catch((error) => {
      componentPromiseCache.delete(cacheKey);
      resolvedComponentCache.delete(cacheKey);
      throw error;
    });

  componentPromiseCache.set(cacheKey, componentPromise);
  return componentPromise as Promise<RemoteComponentType<TProps>>;
}

这里主要是展示怎么通过配置加载我们远端的组件。

后续使用只需要:


const [ResolvedComponent, setResolvedComponent] = React.useState<RemoteComponentType<TProps> | null>(

useEffect(() => {
loadRemoteComponent<TProps>(config)
      .then((component) => {
        if (!disposed) {
          setResolvedComponent(() => component);
        }
      })
      .catch((error: Error) => {
        if (!disposed) {
          setLoadError(error);
        }
      });

    return () => {
      disposed = true;
    };
}, [])

return <div> {ResolvedComponent ? 'loading' : <ResolvedComponent />} </div>

这里只是给了个简单的例子。

正常情况我们需要考虑组件的并发加载处理,缓存,错误捕获避免导致应用全部崩溃等一些细节问题。

具体实现代码(主要由AI生成):
github.com/luke358/tes…

安装依赖后,执行 npm run dev 可以直接看到效果

解决大数据渲染卡顿:Vue3 虚拟列表组件的完整实现方案

文章简介

本文介绍的是 vue3 中虚表组件的实现方式。当需要展示的数据量达到几百上千条时就需要使用虚表,否则大量组件的渲染会导致页面卡顿甚至卡死。 备注:本文介绍的虚表只支持固定且高度相同的数据元素。

实现原理

滚动容器
┌─────────────────────────────┐
│                             │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│  ┌─────────────────────┐    │
│  │  实际渲染区域     │    │
│  │  (visibleRows)      │    │
│  └─────────────────────┘    │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│                             │
└─────────────────────────────┘
  • 虚表由 3 个元素组成,分别为有固定高度的根元素(滚动容器)提供数据滚动能力、用于撑开根容器的占位元素、用于展示信息的区域渲染元素。
  • 渲染区域在根元素内部使用绝对定位 position: absolute; 脱离文档流。
    • 实时计算需要渲染的元素行。
    • 备注:当需要渲染的元素发生变化时,通过 transform: translateY(100px); 属性对渲染区域进行偏移,确保渲染连续。
  • 根元素使用相对定位 position: relative; 使渲染元素在根元素内部定位、滚动。
  • 占位元素只用来撑开根元素内部空间,让根元素提供滚动能力。
    • 备注:占位元素的高度计算方式:数据量 * 数据展示元素高度。

外部属性定义

  • items: 使用虚表的父组件传入的所有要展示数据源。
  • itemHeight:每个数据元素的展示行高
  • width、height:可由父组件传入固定数值,默认撑满父组件。
  • space:展示元素之间的间距
  • bufferSize:渲染区域上下缓冲区大小
const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认5px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});

插槽定义

主要用于定义数据展示元素插槽的数据类型,否则使用虚表的父组件在定义数据展示元素时会飘红

defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

html 部分

  • viewportRef 绑定根元素对象,用于获取实际视口高度,视口高度会用来计算可展示元素数量
  • containerStyle: 用于设置父组件传递的根容器宽高,或设置默认值
  • virtual-viewport:根元素 css 属性
  • virtual-phantom:占位块 css 属性
  • totalHeight:虚表需要展示的总数据占位高度
  • virtual-content:渲染区 css 属性
  • offsetY:渲染区偏移量
  • visibleRows:实际渲染元素
  • itemHeight:插槽定义的数据展示元素高度,由使用虚表的父组件通过属性传入
  • itemActualHeight:渲染元素实际高度 = 插槽定义的数据展示元素高度(itemHeight) + 元素间隔(space)
<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

css 部分

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

实时计算变量实现

核心逻辑

  1. 实时计算父组件设置的根元素宽高
  2. 虚表组件挂载后获得视口高度,并订阅根元素的大小变化。
  3. 监听展示数据的变化,超出滚动范围时修正滚动范围。
  4. 实时计算每项元素实际高度
  5. 实时计算占位元素总高度
  6. 实时计算起始结束索引
  7. 实时计算实际渲染的数据行
  8. 实时计算偏移量
  • 实时计算使用 vue3 的 computed() 方法

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

对外暴露滚动事件、滚动距离

// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

对外暴露根容器

defineExpose({
  $el: viewportRef,
});

使用虚表的父组件可以通过 ref 绑定虚表的根元素。

假设父组件通过 ref="parent" 绑定虚表根元素,通过父组件控制虚表滚动的方法为

parent.value.$el.scrollTop = 100;

父组件使用

<virtual-table
  ref="parent"
  :items="data"
  :space="8"
  :itemHeight="150"
  @scroll="(value: number) => (scrollTop = value)"
>
  <template #item="{ item, index }">
    <div>序号:{{ index }}</div>
    <div>内容:{{ item }}</div>
  </template>
</virtual-table>

附源码

<script setup lang="ts">
import {
  ref,
  computed,
  onMounted,
  onBeforeUnmount,
  watch,
  nextTick,
} from "vue";

const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认8px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});
defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

defineExpose({
  $el: viewportRef,
});
</script>

<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

聊聊我逃离前端开发前的思考

我在22年底chatGPT出现后的第一时间选择了从前端转型,并精准预测了25年AI产品、agent工程师岗位的诞生,以及26年将会是AI代替人类岗位的元年。

回头想一下,我能做出这些预测,并及时调整我的人生轨迹,全因为我的思考方式:像规划企业一样规划我的人生。

这个思考方式确实让我少走了非常多弯路,早在23年4月份,我写下图中的思考,而这份思考也是我放弃前端选择转型的基础逻辑。

cc99ba4232d28d3ff3b2196882a3d28.jpg

像规划企业一样规划我们的人生

如何像规划企业一样规划我们的人生?

首先大家要对我们的参与社会工作的人生阶段有一个概念:

从24岁大学毕业开始工作到65岁退休,足足有41年。

要知道2026年我们建国才77年;

中华老字号(创立50年以上)认证的企业也只有1455家;

倒闭了多少家企业才有了这1455的老字号。

所以,各位认为选择一个行业之后,能干满40年概率有多大?

干满40年一个行业,需要极大的运气与实力才可以的。

所以,今天我们所面对的,本就是这个世界应该发生的事情,大可不必过于担心焦虑。

比尔·盖茨强调企业需保持"离破产仅18个月"的危机意识。

保持这个意识的企业为了活下去, 都在不停地想办法赚钱、扩展业务:

  • 要不停地迭代产品功能、服务,建立企业护城河;
  • 要不停地找新到新的业务方向、新的客户、新的合作者;
  • 要不停审视市场环境、政策变化、竞争对手,决定进入\离开某个市场。
  • 等等.....

企业面临着市场缩小、政策变化、竞争变多、扩张业务等因素,都在不断研究方向,研究战略,生怕走错一步被彻底淘汰。

但是很多人却从不给自己做未来规划,直到事情发生才后知后觉,然后开始怨天尤人。

殊不知,个人面临着年龄变大、精力衰退、技能落后、新人顶替等等因素,被淘汰的风险一点都不比企业小。

所以个人也应该随时保持距离被辞退仅18个月的风险意识,尤其是现在身处AI的年代,这个时间被压缩的更少了;

我们要不断地审视自己:

  • 是否处于同行业较高水平?
  • 是否存在被淘汰的风险?风险在哪?
  • 是否要选择进入\退出某个岗位\行业?
  • 等等......

试一下吧,现在开始,审视一下你自己,规划一下你自己,像规划一家企业一样。

结语

最后送给读者一句话:

when the facts change, I change my mind ——凯恩斯

这也正应了咱们那句老话:君子审时度势,顺势而为。

我是华洛,关注我学习更多AI落地的实战经验与技巧。

加油,共勉。

☺️你好,我是华洛,All in AI多年,专注于AI在产品侧的应用以及企业AI员工的设计。

关注我:华洛AI转型纪实

专栏文章

# 多写点skill吧,写的越多这行业死的越快。

# 聊聊我们公司的AI应用工程师每天都干啥?

# SEO还没死,GEO之战已经开始

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

❌