普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月16日掘金 前端

向大家介绍「开发者博主联盟」🚀

2026年1月16日 14:43

'今之创作,非乏才也,乏其制耳。有才而无制,则劳而不继;有制而能协,则文自生,利自分,业自成。'

传送门

个人主页:tuaran.me/

博主联盟:blogger-alliance.cn/ (欢迎加入👏)

image.png

正文

2026.1.8

晚上在整理这一年的内容计划时,我重新回看了去年发起「博主联盟」时跟主任的那一轮讨论。AIGC 的冲击已经很明显了——写作门槛在被快速拉平,单篇内容的价值在被不断稀释,在 AI 的冲击下,平台推荐也越来越不可控。

提问:如果内容生产继续停留在“个人表达”层面,它还能不能支撑长期的增长与变现?

我认为,能写内容的,不缺人(偶尔产生几篇爆款);能长期稳定生产、能形成系统输出的,是极少。

每个平台都有红利窗口期,但没有人能永远踩中节奏。唯一能持续的,是“系统”。

于是我们开始想:如果把一个人的内容生产,变成一整个生态链——有人写、有人发、有人宣发、有人接广告、有人维护数据,那创作这件事,就不再是单点爆发,而是结构性产能。

从这个角度看,我们要做的不是一个“写作平台”,而是一个创作者的生产力系统,它能稳定地产出高质量内容,能让每个账号都被利用、被复用、被价值化。(大语言模型井喷的背景下,产出的内容在 AI 生成答案中获得更高可见度与被引用率)

【视角与结构】

从开发者视角看,个体创作者更像一个单点服务:吞吐不稳定、容灾能力弱、依赖灵感峰值;而内容行业真正需要的是一套可扩展的架构——把“写作”拆成可复用的模块,把“发布”变成可流水线调度的任务,把“分发与数据回流”做成可观测、可迭代的闭环。

从品牌方的视角看,痛点更直白:投放不缺渠道,缺的是“可预期”。今天找一个博主合作,最大的风险不是贵,而是不确定:内容能不能按时交付?口径能不能统一?数据能不能复盘?后续能不能复用?品牌方对于流量的诉求是“稳定供给”——一套你能按 SOP 采购、按数据验收、按规则结算的内容与分发能力。

所以“博主联盟”不是简单口号,如果把它进一步升维至“矩阵联盟”,两者的关系更像是:博主联盟是“前台应用层”:KOL承接广告与品牌测评;矩阵联盟是更大的平台,让内容成为商品。

如果用一张开发者熟悉的架构来类比,我会把它拆成四层:

应用层(对外项目与品牌界面)→ 联盟中枢(账号体系与结算/分账)→ 内容模型层(资讯模板、测评结构、写作框架、语言风格与口径约束)→ 能量与数据中心(账号池、资源与脚本库、投流与风控、数据看板)。

【路径与验证】

第一阶段:验证机制。跑通一个完整案例:从客户需求 → 联盟选人 → 内容产出 → 分发 → 数据回流 → 结算。

第二阶段:规模复制。启动矩阵联盟,让更多写作者进入系统,形成内容供给链,沉淀可复用的内容单元。

第三阶段:系统自驱。上线“狠活引擎”,让投流、分发、互动、复用追踪与结算逐步自动化运转。

之所以坚定,是因为这条路是我这些年从内容、产品到 AI 一路踩出来的。我见过许多人"潮起潮落",也见过太多团队赚不到钱。我们要干的这件事,就是让创作重新变得可持续:靠架构、靠协作、靠循环。

div滚动条是否存在?用 v-scroll-detect 增加一个辅助class

作者 火车叼位
2026年1月16日 14:36

你做了一个列表容器:顶部有吸顶标题,右侧有渐隐提示,底部有“更多内容”的阴影。

产品提了一个要求:只有当容器内部真的可滚动时,才显示这些提示;并且内容会异步加载、筛选、折叠/展开,容器高度也可能随窗口变化而变化。

问题来了:我怎么知道这个元素当前有没有滚动条?

传统做法的坑:你会不断踩同一类问题

常见实现大多从这几条路开始:

  • 监听 scroll:一滚动就更新 UI。
  • 首次 mounted/useEffect 里测一次:scrollHeight > clientHeight
  • 内容变更后手动再测一次:塞进一堆 nextTicksetTimeout、或自己维护“何时需要重新计算”。

这些方案通常会引出痛点:

  • 内容不滚也会变:异步加载、图片解码、字体加载、折叠/展开,会改变高度,但不一定触发 scroll
  • 容器尺寸也会变:响应式布局、侧栏收起、窗口缩放,都会改变 clientHeight,你需要额外监听。
  • 性能与维护成本高:到处绑定事件、到处分发“刷新”,最后很难保证“任何情况下都正确”。
  • 框架耦合:Vue/React/原生各写一套,重复劳动。

你真正想要的是:只要元素的尺寸或内容布局变化,就自动重新判断一次,并把结果映射成稳定的样式开关。

这个库解决什么:把“滚动条存在性”变成一个 CSS 开关

当你只需要“有/无滚动条”来驱动 UI 时,最佳形态往往是一个 class:

  • 有滚动条 → 添加 class(例如 with-scroll / has-v-scroll
  • 没滚动条 → 移除 class

v-scroll-detect 就是把这件事做成了可复用的工具:

  • ResizeObserver 感知元素布局变化
  • 用一次轻量判断(scrollHeight > clientHeight / scrollWidth > clientWidth)得出结果
  • 自动切换 class
  • 并提供 Vue 指令、React Hook 与原生核心 API

链接与名称

下面从“怎么用”开始,再解释“为什么这样实现更稳”。

快速开始

安装

npm i v-scroll-detect

Vue 3:指令(全局注册)

import { createApp } from 'vue'
import App from './App.vue'
import vScrollDetect from 'v-scroll-detect'

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

模板里直接用:

<div v-scroll-detect>
  <!-- content -->
</div>

默认会在任意方向出现滚动条时切换 class:with-scroll

Vue 3:自定义 class(字符串)

<div v-scroll-detect="'has-scroll'">
  <!-- content -->
</div>

Vue 3:分别处理纵/横向(对象)

<div v-scroll-detect="{ v: 'has-v-scroll', h: 'has-h-scroll' }">
  <!-- content -->
</div>

对象模式同时支持别名:

  • 纵向:vvertical(缺省类名:has-v-scroll
  • 横向:hhorizontal(缺省类名:has-h-scroll

React:Hook

import { useRef } from 'react'
import { useScrollDetect } from 'v-scroll-detect/react'

export function Panel() {
  const ref = useRef(null)
  useScrollDetect(ref, { v: 'has-v-scroll', h: 'has-h-scroll' })
  return <div ref={ref}>...</div>
}

原生/框架无关:核心函数

import { createScrollDetector } from 'v-scroll-detect/core'

const el = document.querySelector('.my-container')
const detector = createScrollDetector(el, 'has-scroll')

// 手动触发一次检测
detector.check()

// 销毁(解除监听并停止更新)
detector.destroy()

API 设计:用一个 options 覆盖 90% 需求

v-scroll-detect 的核心输入是 options,目标是让“样式开关”足够直观:

  • undefined:使用默认类名 with-scroll
  • string:任一方向有滚动条时切换该类名
  • object:分别配置纵向与横向类名(支持 v/verticalh/horizontal

这种设计的好处是:

  • 只关心“有没有滚动条”时,一行搞定
  • 需要精细 UI(比如仅纵向显示阴影)时,也不用额外写逻辑

实现思路:为什么用 ResizeObserver 更“对味”

滚动条出现/消失,本质上是“内容尺寸”和“容器可视尺寸”的对比结果:

  • 纵向:scrollHeight > clientHeight
  • 横向:scrollWidth > clientWidth

难点并不在判断式,而在“何时判断”。

库的做法是:观察元素尺寸变化,一旦变化就安排一次检查,并用 requestAnimationFrame 合并到下一帧执行,避免在频繁布局变化时抖动更新。最终结果就是稳定地 classList.add/remove

与监听 scroll 相比,它更贴近真实触发源:

  • 内容变化、图片加载、字体变化、容器缩放,都能驱动重新判断
  • 你不需要在业务代码里维护“何时需要 refresh”

适用与注意事项

  • 适用:任何需要根据“是否可滚动”切换样式的场景(阴影、渐隐、边界提示、吸顶边框等)
  • 注意:部分平台的“覆盖式滚动条”可能不占据布局空间,但滚动能力仍然存在;这种情况下判断仍然依赖 scrollHeight/clientHeight,一般是可靠的
  • 兼容:依赖 ResizeObserver,如需兼容较旧浏览器可考虑引入 polyfill

结语

把“滚动条存在性”抽象成 class 开关后,UI 细节会更干净:业务只写样式与结构,滚动能力的判断交给统一工具完成。v-scroll-detect 的价值就在于:让这个判断在动态布局下也足够可靠,并且跨 Vue/React/原生都能复用。

Qunar酒店搜索排序模型的演进

2026年1月16日 14:36

一、背景

在用户在线浏览酒店时,旅行平台需要解决一个重要问题:如何更好地为用户挑选适合的酒店,并降低用户选择的费力度。而为用户挑选符合需求的酒店,需要千人千面的模型排序。在去哪儿(Qunar)APP中,触发个性化排序的场景主要是欢迎度排序(见图1)。

图1 Qunar酒店搜索产品形态

二、Qunar酒店搜索排序架构

Qunar酒店搜索推荐系统的总体流程可以分成四块,主要是召回、粗排、精排、重排。其中精排主要会根据用户的搜索关键词和其他相关信息,选出与用户需求最相符合的酒店,确保在搜索结果中,用户能够看到那些与其需求最相关的酒店。而Qunar酒店长期优化的业务目标是s2o,即下单用户数/搜索用户数。

图2 Qunar搜索架构

三、Qunar酒店搜索精排排序的发展历程

第一阶段升级机器学习模型,酒店搜索业务准备使用机器学习模型替换人工规则排序。为了能快速上线验证模型效果,并考虑到酒店业务天生自带大量连续特征,如酒店价格、酒店距离等,我们选择了对连续特征比较友好的树模型LambdaMart模型。

第二阶段升级深度模型,LambdaMart排序模型常用的迭代优化手段,如添加特征、样本调整等几乎已经无法带来增量收益,因此我们准备升级成比树模型天花板更高的深度模型架构LambdaDNN。

第三阶段升级多目标模型,LambdaDNN排序模型主要还是聚焦单目标建模(CTCVR),而多目标建模更好的考虑了排序全空间的曝光样本,综合考虑了下单前的用户点击行为,降低样本偏差问题,我们借鉴了Google的MMOE模型,同时学习CTR目标任务和CTCVR目标任务。

第四阶段升级多场景多目标模型,酒店业务搜索场景分为同城空搜、异地空搜等,每个场景单独一个模型,使得维护成本很高,并且用户出行时,会在多个场景(同城搜索、异地搜索)下搜索找到符合自己期望的酒店。若将多个场景分别单独训练模型,会使得用户连贯的搜索行为割裂,学习不到用户在不同场景下的差异和共性。因此,我们提出将信息表征拆分,融合多场景样本的多场景多目标模型。

下面具体阐述我们在Qunar酒店搜索排序模型的演进。

四、Qunar酒店搜索精排排序算法的迭代

排序学习(Learning to Rank)是一个监督学习过程,给定一个query,搜索引擎会召回一系列相关的item,然后对这些召回的item进行排序,最后将Top N的item输出。而排序问题就是使用一个模型 f(q,d)来对该query下的item进行排序,分为PointWise,PairWise,ListWise三种排序学习方式。

PointWise:输入空间中样本是单个 item(和对应 query)构成的特征向量,输出空间中样本是单个 item(和对应 query)的相关度,是将所有用户的样本空间的样本同等对待。

PairWise:输入空间中样本是(同一 query 对应的)两个item构成的两个特征向量,输出空间中样本对的偏序关系,此方法学习了同一个query下的两个样本对比偏好。

ListWise:输入空间中样本是(同一 query 对应的)所有item构成的多个特征向量列表,输出空间是同一个query下的最优排序列表,是pairwise和lambda梯度的结合,如LambdaMart算法,对整个列表建模,学习一个最优排序列表。

pointWise pairWise listWise

模型的预测目标与业务指标之间存在一定的差距,Pointwise注重单个物品的点击率,对所有请求下的物品同等对待,与搜索业务指标存在较大的Gap,而搜索业务更关注在页面头部的搜索结果的优劣。

因此,初期阶段我们选择了更适合搜索排序的Listwise方式学习模型,即LambdaMart树模型作为base模型,同时树模型不需要做大量的特征处理,可以快速上线验证效果、拿到收益。

1. 由LambdaMart模型升级到LambdaDNN模型

将排序模型从LambdaMart升级到LambdaDNN模型主要考虑到以下几点:

  • 旅游具有强烈的节假日周期属性,需要长时间窗的大规模数据学习。而树模型容量有限,随着数据规模的增加,很难很好的表达数据内部隐藏的知识。LambdaDNN能够实现更复杂的模型,且具备强大的表征能力,有更高的天花板。
  • 基于深度学习的模型,具有更好的泛化能力,和处理高维稀疏数据的能力,此外应用深度学习模型后可以更好的应用新的研究成果,比如后面介绍的多目标排序。

树模型和深度模型的主要区别见表1。

a44cfa7ec9874f8d0a95ed9b79884305.png

2. LambdaDNN模型实践

为了验证深度模型的有效性,我们控制了loss的学习方式,统一为listwise方式,唯一变量只是模型的结构。而LambdaDNN模型是将神经网络DNN与LamdaRank [2] 定义的NDCG排序指标梯度相结合,演进出了LambdaDNN,是一种listwise的学习方式。因此我们尝试将LambdaDNN模型应用在了Qunar搜索推荐下的精排阶段,

模型学习目标定义为曝光转化率(ctcvr)。LambdaDNN的搭建主要包括了框架选型、样本构造、特征工程、模型离线训练及线上化,其中样本与特征决定了模型效果的上限。接下来分别介绍下这几个部分:

2.1 框架选型

TensorFlow Ranking (TFR) 是 TensorFlow 官方开发的 LTR 框架,旨在基于 TensorFlow 开发和整合 LTR 相关的技术,使开发人员可以更加方便的进行 LTR 算法的开发。TFR 使用了 Estimator API,提供了排序任务常见的损失函数和指标计算实现,比如 listwise Loss 和 NDCG指标。

根据训练流程,模块结构可以分成特征输入、特征转换、模型打分和损失计算等模块。这样的模块划分可以提高后续快速迭代的便利性和灵活性,而不用关注于各种实施上的细节。

图3 TFRanking框架

如上图3所示,model_fn 参数内需要自己设计和开发的算法模型模块。在这个 model_fn 中,需要自行设计模型结构 (Scoring Function),然后用模型计算的 logit 和 label 来计算 Loss 和 Metrics,最后利用 Optimizer 来进行模型的优化。图3中的下方部分为使用 TFR 服务的整个流程。

为了配合 TFR 框架中的 LTR 相关的 Loss 和 Metrics 来实现 LTR 的训练,训练数据需要以 listwise 的形式组织。即LTR 模型与一次只对一个item进行分类的标准分类模型不同,它会将整个items列表接收输入,并学习排序,充分提升整个list列表的效用,如图4所示。

图4 listwise的学习方式

2.2 模型结构

我们使用的模型结构,借鉴了Airbnb的深度学习[3]模型演进的经验,模型探索的原则是从简单到复杂,逐步积累经验,针对特征数量不多的情况下,使用非常深的网络,会导致过拟合。因此,如图5所示,采用了简单2层隐藏层的金字塔网络结构。

对一个query下的样本计算Lambda Loss,优化网络参数。其中,模型调优参数,batch size为1024,加入dropout和Batch Normalization(BN),梯度优化器是Adam,学习率为0.01。输入特征仅为77维,隐藏层神经元数为[128,86]。

图5 模型结构

2.3 样本构造

为了训练listwise模型,需要构造样本来表示用户的查询query和item之间的关系。图6(左图)显示了一个典型的用户搜索旅程的简化示例,其中客户在多天内进行多次query搜索,并点击多个搜索结果以查看其详细页面,最终在第M日query#3下的结果列表中用户点击了酒店(hotel-b)和下单了酒店(hotel-d),整个会话以用户下单酒店成功结束。

图6 用户搜索行为示例

在我们的业务场景下,线上业务优化目标为下单转化率,对应离线建模目标为曝光转化率(ctcvr),即从search到order的转化,训练的目标是从曝光到下单的转化。

  • list列表选择:
    • 在以Listwise方式学习时,我们只使用含有成功下单的搜索列表(Day M的query#3)为List训练样本,Day1和Day2下的无下单列表样本将丢弃,标签见图6(右图);
    • 原因是若将只有点击标签的list加入训练,在计算NDCG时,不管点击标签的值是0.01还是1,ndcg的值都是一样,此时计算LambdaLoss时,模型会更倾向于只有点击的list,重点优化了ctr。
  • 正样本构造:
    • 下单酒店标签1;
    • 点击酒店标签0.01,标签值表示样本和query的相关度大小,可根据实验效果调整(点击和下单相关度比值我们是数据统计得来)。我们认为用户点击过的酒店是具有一定的喜好,不能简单的设置标签为0。
  • 负样本构造:
    • hard negative sample:曝光未下单且未点击的酒店;
    • easy negative sample:在同一个list列表中,采样list列表最底部的n家(<<当次请求曝光酒店数)未曝光的酒店,标签为0。由于线上精排排序家数较多,只选取曝光样本训练,模型对easy负样本学习不好。推荐结果会有部分低质量酒店,需要采样部分低质酒店作为easy negative sample,修正线上线下样本分布偏差,提升模型泛化能力。
  • 样本量:模型训练的样本以时间维度切分,30天为训练集,7天作为验证集,7天作为测试集。

工程实践:Listwise学习方式的Lambda梯度需要对同Query下的样本进行计算。因此我们需要对样本进行预处理:将同一个查询(Query)的样本聚合在一起,采用ExampleListWithContext编码方式,将这些样本打包进一个TFRecord中。由于每次请求Query召回的item数不一样,对于可变Size的Query样本在拉取数据进行训练时需要注意,TF会自动补齐Mini-Batch内每个样本大小一致。

2.4特征工程

特征工程主要包括特征构建、特征分析、特征处理,以及特征选择等:

特征构建:

  • 用户特征:用户行为特征,如过去1年下单的次数,下单该酒店的次数,用户画像等
  • 酒店特征:属性特征等,如价格,档次,星级,点击率,转化率,节假日等
  • 交叉特征:用户到酒店的距离,poi到酒店的距离等
  • 上下文特征:时间,搜索场景等

构建原则:

  • 防止特征穿越
  • 尽量和业务相关
  • 有区分性,尽量使特征能很好的区分出正负样本。

特征选择:

  • 深度模型相对于树模型对特征值的大小更敏感,没有像树模型一样有自动特征选择功能,引入一些冗余特征会增大深度模型的噪声。因此我们基于LambdaMart的特征重要性排序,选取最重要的特征训练LambdaDnn,如图7所示,top49维(总维度是200维)时,测试集合的ndcg@10指标(test_ndcg)和全量特征ndcg@10(origin_test_ndcg)相比持平,且特征在top25维时指标不在增长。考虑到深度模型的参数空间更大,学习能力更强,我们在top49维特征的基础上,基于人工经验又挑选了28维特征,一共77维。

图7 选择不同top数量的特征训练后的模型效果

特征分析:

深度模型对特征大小较为敏感,因此特征处理的好坏直接影响后续模型的表现。

  • 长尾分布:例如点击率、点击次数等特征,80%的值分布在靠近0的附近,若直接加入模型训练,由于值差距太小,和其他特征相比没有区分性,因此需要做一些log变换后为正态分布,更有利于模型收敛。如下表:

6ba3501906803be67bc43b369df8b67b.png

  • 正态分布:价格等取值较大连续特征,使用了最大最小归一化方法,将特征值都转化为0-1之间的数值。
  • 缺失值填补:在训练样本中不可避免地有部分连续特征存在缺失值,合理处理缺失值会对最终效果有一定帮助。填补缺失值的方式有:
    • 取零值,会导致相应权重无法进行更新,收敛速度减慢。
    • 取平均值也略显武断,毕竟不同的特征缺失所表示的含义可能不尽相同。
    • 我们希望神经网络也可以通过学习的方式自适应地处理缺失值,而不是人为设置默认值。对较为重要的特征(特征重要度可通过模型的信息增益得分得到),且缺失率较大的,填补值为0,同时对缺失特征学习一个缺失权重,如下公式所示:

我们通过上述变换方式,让所有特征范围在0-1之间或者0附近,在输入模型后经过一层bn操作进行正态分布处理。当然还有其他处理方式,具体还要根据自己业务选择合适的处理方式。

2.5 实验评估

baseline模型即LambdaMart,离线ndcg@10效果如下表,LambdaDnn线上转化率提升0.5%。

61f15fcf5396064c9e98db66661b2597.png

3. 多目标模型优化

在类似电商搜索推荐系统中,用户的行为通常按照一种序列化的模式进行,即首先是曝光,然后是点击,最后是转化。因此,CTCVR模型的作用是预估在曝光发生后的转化概率。

3.1 为什么选择多目标建模?

单目标方式缺陷:

1)样本选择偏差问题

  • 训练阶段:Listwise模式训练样本是用户最后一次在Day M日query#3下的下单列表,下单前的行为列表都被丢弃。
  • 在线推断:query#1、query#2的列表都会经过推理,此时样本是有选择偏差的,离线训练和在线推理样本分布不一致,模型很难学习到真实样本分布信息,导致模型的泛化性能降低;
  • 对无下单行为的用户学习效果较差

2)模型分可解释性差

  • Listwise模式学习的输出是偏序关系,值范围较大,不是在[0-1]之间,且每一次请求的值范围区间不同,对后续业务重排策略调整难度较大。若归一化到[0-1]后,不像实际ctr一样具有物理含义。

3)数据稀疏,模型泛化能力不足

  • 若pointwise模式只使用曝光和下单样本训练CTCVR目标时,下单数据非常稀疏,下单样本的数量远远小于曝光样本。而利用CTR任务的丰富数据可以帮助CTCVR任务克服数据稀疏性问题,提高模型的泛化能力和预测性能

多目标优势:

1)缓解样本偏差,及稀疏性

  • 将曝光点击下单都可以加入到模型中学习

2)可解释性好

  • 输出值是概率意义,更好融合后面的重排加权策略;

3)促进作用

  • 通过共享一些浅层次的特征,多任务学习可以借助彼此之间的相似性和差异性来促进学习过程

4)约束作用

  • 当多个任务同时进行反向传播时,由于不同任务具有不同的噪声模式,多目标可以将多个任务的反馈信息结合起来,平均噪声模式,从而得到更一般和稳定的表征,有点像正则化的作用。通过这种方式,相对于单个任务,模型的过拟合风险会降低,泛化能力会提高。
3.2 多目标建模选型

MMOE是谷歌18年提出的模型结构,其思想是所有目标共享多个experts,但是考虑到不同任务之间的差异性,为每一个目标设置一个gate门控网络,用来控制不同目标选择每个expert的信号占比,类似于weighted sum pooling操作。

MMOE(如图8所示)通过共享experts学习到不同任务的联系和差异,提高了每个任务的学习效率和质量,同时通过gates来平衡多个任务对experts信号的选择,不要求目标之间必须是高相关性的,MMOE是业界通用的多目标实现算法。

多目标建模更好的考虑了排序全空间的曝光样本,综合考虑了下单前的用户点击行为,降低样本偏差问题,引入了多个专家、2个目标任务、一个CTR目标任务和一个CTCVR目标任务。

图8 MMOE网络结构

3.3 MMOE样本选择

1)只选取有行为的搜索列表,即该列表是有被点击或被下单的酒店的列表;

  • 原因:正负样本比例相差较大,过滤一部分认为无效负样本;其次训练时避免没有正行为用户的影响,在一批数据中如果某个用户对所有展示的物品都没有点击等行为,应当过滤掉该用户的记录(没有正行为用户只提供负例,看不出用户喜好,对模型训练没有帮助)。

2)选取每个列表的曝光样本,以及每个列表的最底部未曝光的n家酒店作为easy negative sample,防止排序出一些较差的酒店。

3)同一个请求列表中的酒店被点击多次的,只记录一次点击行为,即一次点击正样本。

3.4 MMOE训练过程中遇到的问题

1)实际的业务数据中,曝光到转化过于稀疏,具体训练过程中ctcvr的loss明显比ctr的loss小。为了使得2个目标任务的loss的量级一致,并没有采用loss加权处理,而是在样本构造过程中对下单样本进行上采样复制,保持正负样本1:1,基本上可以使得目标loss大小量级一样。

这样在训练shuffle时,不会使得一个正样本在batch内重复出现N次,使得ctcvr任务泛化性更好,同时对ctr任务中的上采样过的点击样本进行降权处理,恢复原先样本分布。

2)gate network输出进入饱和区,反向传播梯度消失:

expert gate输出,是由输入特征经过门网络后softmax得到,在训练的过程中,经过一段迭代次数,门控网络的输入值方差变大,经过softmax后gate network大部分输出是0,在这种情况下,multi expert的优势就没办法发挥出来,

一种解决方式是dropout,依然解决不了部分值偏大的问题;另一种方式对进入softmax之前的得分,进行得分除以一个lambda平滑处理(即,温度系数的调节),缓解部分专家退化现象;这里取lambda为输入特征的维度,也可以参考self-attention。

微信图片_2026-01-14_153851_295.png

如下表指数e函数曲线中所示,x的域范围在[-10,10]区间时,softmax的值很容易在x小于-2.5时为0,x大于10时为1。如果将x缩放尺度为1/100,就会缓解退化问题。

3b9aedd9a2ea098fd6d5f8fb47021e6d.png

3.5 实验结果

线上预测时,我们会加权融合两个目标预测值作为排序分,而融合权重可以通过离线数据拟合得到。

从下表中,我们的MMOE最终的离线指标NDCG@10没有提升,主要的原因是测试集是只能在有下单的请求上预测,而lambda直接学习的ndcg目标,所以ndcg更高。而多目标线上的ctr有明显提升,线上点击率提升2%,转化率持平。

e69c4c99b5bbf117b81801da690150a3.png

4. 多场景多目标优化

4.1 业务背景

在用户出行想要预定酒店时,搜索酒店列表是一个常见的行为。根据用户所处空间不同、搜索行为不同,分为:异地空搜,同城空搜等场景,并且一个用户会跨不同场景进行搜索。而之前模型建模,每个场景单独训练一个模型,一是树模型容量小等历史原因延续单独建模,二是每个场景下所使用的特征不一样,特征的索引也没有对齐。

单独建模不会受到其他场景下的特征影响,其次也可学到单个场景独有的一些特点。但是带来的问题是不同场景下的共有信息无法相互共享、无法迁移学习,造成了信息孤岛。因为同一个用户会在不同场景下都有搜索行为,将同一个用户行为样本分开建模显然是不合理的,同时对多个模型的维护成本也很高。

因此,升级到了MMOE模型后,我们考虑将几个场景下的样本融合学习一个模型,宗旨是既可以学习到场景间独有信息,也能迁移学习不同场景下的共有信息。

4.2 多场景样本如何进行融合?

不同场景下用户和item存在重叠,具有一定共性,不同场景下的用户行为,具有一定差异性;不同任务之间具有不同的稀疏性,同时也存在相互影响;如果在建模过程中忽略了多场景和多任务之间的共性和差异性,会影响建模效果,如果多个场景和任务不能很好地进行平衡,会存在场景跷跷板和 任务跷跷板现象。

一开始我们也是调研了目前不同场景下建模的方式,如阿里STAR、HMOE等多场景建模方式,但是效果不佳。原因是STAR、HMOE模型都是对不同场景下的分布差异较大的样本进行建模,会有比较好的效果。例如、淘宝的商品的主搜列表推荐、和主页的猜你喜欢等不同的多个频道的建模,而在去哪儿的酒店场景,其实还都是一个频道,用户的行为分布都是一致的,在目的地确定的前提下,无论是poi搜索、同城搜索,都是想预定目的地下的符合需求的酒店。

因此,我们尝试了直接将多个场景的样本放在一起学习,同时特征一起输入到MMOE中,简单的hard-share效果比STAR、HMOE效果会好。我们将训练完的模型,输出每个场景的专家权重,来分析不同场景下的权重是否分布一样。

图9 不同场景下样本在专家下的权重占比

从图9来看,不同场景下的专家权重,分布几乎一致,最高的权重是专家2。侧面验证了,在我们的业务下场景间的分布差异没有很大。线上的下单转化相对提升0.56%。
4.3 多场景下特征如何对齐?

对于酒店业务下的特征,特征主要分为4类:

  • 场景无关特征:如酒店价格、用户点击次数、下单次数等
  • 场景共有特征:如酒店在poi下的转化率、点击率等,此时特征会共用一个特征索引
  • 场景独有特征:如同城空搜下用户距离酒店的距离,poi场景下poi距离酒店的距离等,此时,特征索引不共用
  • 场景标识特征:主要就是场景 ID 类特征

如上所述,输入部分,将场景的domain信息作为特征和场景共有特征一起拼接输入到感知网络中,抽取场景间共性信息;将场景的domain信息作为特征和场景特有特征一起拼接输入到感知网络中,抽取场景间差异信息;

如果统一作为MMOE的输入直接输入,模型无法很好的学习到不同场景的差异,若人为先验的分为上述几类特征,拉平场景特征之间的差异。可以使得模型在一个稳定的样本分布下学习,增强模型对用户跨域行为的底层感知能力。

微信图片_2026-01-14_154104_798.png

图10 多场景多目标网络结构

4.4 实验评估
  • 在v2阶段多场景样本合并时,ndcg@10指标提升不大,多个场景下的样本融合后,网络的参数量不能很好的表达出场景间的特性。
  • 因此,将网络专家数扩大到8个,希望每个专家能充分学习到场景间差异,离线指标ndcg@10相对v1提升0.19%,上线后,s2o指标提升0.46%,另外,我们做了其他的专家数量的实验,发现增加9个以后,ndcg指标没有什么变化。
  • 为了更好的对齐场景的特征差异,我们对输入特征分类建模输入MMOE(v4)。此时,离线指标ndcg@10相对提升0.22%,上线后,s2o指标提升0.51%。

f523e796169819e3bd5fd590008d0956.png

5. 特征选择

5.1 为什么需要做特征选择?

随着Qunar酒店排序模型业务需求的迭代,越来越多的特征被引入模型中,导致出现如下问题:

  • 冗余特征增多
    • 有些特征可能与当下的问题无关。这意味着它们与模型要解决的任务完全无关,丢弃不相关的特征可以防止模型得到无关特征上可能携带的虚假相关性,从而避免过拟合。
  • 维度灾难
    • 特征选择技术在具有许多特征但训练示例很少的场景中尤为重要。这种情况受到所谓的维度灾难的影响:在高维空间中,每个训练示例都与其他所有示例相距甚远,因此模型无法学习任何有用的模式。
  • 训练时间长
    • 特征越多,训练时间就越长。这种权衡的具体细节取决于所使用的特定学习算法,但在需要实时进行再训练的情况下,可能需要限制在几个最佳特征中进行。
  • 部署工作成本高
    • 特征越多,机器学习系统在生产中就越复杂。这会带来多种风险,包括但不限于高维护工作量、纠纷、未申报的使用者或纠正级联。
  • 可解释性差
    • 由于特征太多,我们失去了模型的可解释性。虽然对模型结果的解释并不总是主要的建模目标,但通常很重要,在某些受监管的领域,甚至可能是法律要求。
5.2 传统特征选择方法
  • 无监督的特征选择方法
  • 包装器特征选择方法
  • 过滤器特征选择方法
  • Boruta(布尔塔)方法

上述传统方法更适合数量小,训练时间短,模型简单的场景。而我们深度模型采用了基于dropout 的特征选择,借鉴了论文Dropout Feature Ranking for Deep Learning Models 的工作。

5.3 使用变分dropout来做特征的排序

图11 dropout的训练前后的丢弃概率示意图

基于两阶段,第一阶段需要先训练一个pretrained Model 这样先学习模型的参数,第二阶段固定模型参数不做梯度更新,对输入的特征增加一个dropout丢弃变量(可导)与输入特征元素相乘,这个阶段的主要目的是优化该dropout丢弃概率。

如图11所示,论文提出了Dropout Feature Ranking方法,在训练完后,可以获取到每个特征的重要度。

本质的思想是将不可导的离散变量dropout,采用Concrete relaxation操作,对符合伯努利分布的离散变量近似估计为一个连续可导变量。松弛为连续性的噪声扰动过程,使其可以进行梯度更新。并对1-dropout_rate添加正则,使其在训练中尽可能最小化。图12 中的p就是特征的丢弃概率。

图12 dropout layer层

这种方案的优点:

  • 通用性高:可以应用于任何深度学习模型
  • 简单方便:在训练好的模型只进行一次dropout参数训练即可得出特征重要性排序,无需进行多次实验来剔除特征
  • 效果可靠:该方法在实际业务应用中展现出了出色的特征筛选效果。
实验效果:

1、为了验证无效特征会影响模型的效果,我们在模型的原特征基础上增加了10个随机生成的噪声生成噪声特征(在某些特征上添加均匀分布噪声、高斯分布噪声等)

2、加上DFR层后,删减了14维特征后,模型效果不但没有下降,反而有微弱的提升。删除特征重新训练模型,线上转化率提升0.2%。

4cbe3a5269eb4687a95e6edaaaa684d3.png

3、使用DFR算法后,特征重要性如下:

  • 基本上是实时特征更重要
  • 我们将低于对照特征(高斯噪声)重要度分的特征删除,基本上是一些冗余特征。

973e23cd3794b64133c5763dbd144d8d.png

五、阶段总结和未来规划

本文主要介绍Qunar在酒店搜索精排模型上的演进实践,从实验效果来看,证明了深度模型在酒店搜索排序的有效性,以及合理引入多场景特征间的差异建模可以让模型更好的理解数据。

对于接下来的深度模型优化:借鉴listwise的建模方式,将pointwise loss和listwise loss融合,既可以学习排序,又可以学习判别分类。

[1]Chris J.C. Burges. 2010. From RankNet to LambdaRank to LambdaMART: An Overview. Technical Report. www.microsoft.com/en-us/resea…

[2] Rama Kumar Pasumarthi, Sebastian Bruch, Xuanhui Wang, Cheng Li, Michael Bendersky, Marc Najork, Jan Pfeifer, Nadav Golbandi, Rohan Anil, Stephan Wolf.

TF-Ranking: Scalable TensorFlow Library for Learning-to-Rank.KDD 2019.

[3] Haldar M, Abdool M, Ramanathan P, et al. Applying deep learning to airbnb search[C]//Proceedings of the 25th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2019: 1927-1935.

【翻译】构建类型安全的复合组件

2026年1月16日 14:34

原文链接:tkdodo.eu/blog/buildi…

作者:Dominik

我认为在构建组件库时,复合组件是一种非常优秀的设计模式。它们赋予使用者灵活的组件组合方式,无需将所有变体强行塞进单一且属性繁重的API中。此外,它们还能在标记中明确组件间的关联关系。

这并非意味着复合组件永远适用。有时,使用props才是更优的选择。

一个反面示例

关于复合组件,我们常会看到一个选项列表的示例,这很可能是因为HTML中也是如此实现的。

import { Select, Option } from '@/components/select'

function ThemeSwitcher({ value, onChange }) {
  return (
    <Select value={value} onChange={onChange}>
      <Option value="system">🤖</Option>
      <Option value="light">☀️</Option>
      <Option value="dark">🌑</Option>
    </Select>
  )
}

我认为这个示例未能充分展现复合组件的优势,主要有以下原因:

1. 固定布局

复合组件的优势在于允许用户自由布局children。但对于Selects而言,这种灵活性往往并非必需。options需呈现在菜单中,且要求逐项显示。这正是许多人希望在类型层面限制children传递方式的原因——例如仅允许将Option类型传递给Select组件。

这不仅在当前版本中无法实现(相关问题自2018年就已提交),更是不可取的做法。我知道你期待本文能揭示复合组件实现类型安全的技巧——我确实会说明,但这与children无关。我的观点是:若想强制children类型,复合组件本身就是错误的抽象方式。

2. 动态内容

当内容以静态为主时,复合组件表现尤为出色。上例中三个硬编码选项恰好符合此类场景,对吧?

但实际应用中,Selects很少仅包含三个选项——内容通常来自API调用的动态结果集。此外,多数设计指南都建议选项少于五个时不应使用下拉菜单,因为将少量选项隐藏在下拉框中会增加不必要的点击次数和认知负荷。

事实上,在Adverity项目初期,我们曾采用复合组件实现Select,但随后发现多数场景都需要编写如下映射代码:

import { Select, Option } from '@/components/select'

function UserSelect({ value, onChange }) {
  const userQuery = useSuspenseQuery(userOptions)

  return (
    <Select value={value} onChange={onChange}>
      {userQuery.data.map((option) => (
        <Option value={option.value}>{option.label}</Option>
      ))}
    </Select>
  )
}

此时,我们直接切换为暴露一个使用 props 而不是children元素的 Select

import { Select } from '@/components/select'

function UserSelect({ value, onChange }) {
  const userQuery = useSuspenseQuery(userOptions)

  return (
    <Select
      value={value}
      onChange={onChange}
      options={userQuery.data}
    />
  )
}

这不仅让我们摆脱了无处不在的繁琐映射工作,还实现了我们期望的类型安全——因为无需限制任何children类型。此外,Select 组件可轻松泛型化,确保 valueonChangeoptions 均采用统一类型:

type SelectValue = string | number
type SelectOption<T> = { value: T; label: string }
type SelectProps<T extends SelectValue> = {
  value: T
  onChange: (value: T) => void
  options: ReadonlyArray<SelectOption<T>>
}

Slots

ModalDialog 组件是另一个我们不希望用户完全掌控复合组件的示例。毕竟,将 DialogFooter 渲染在 DialogHeader 上方毫无意义。我们也不希望有人意外遗漏 DialogBackdrop,或在 DialogBodyDialogFooter 之间设置不同间距。在强调一致性和顺序的场景中,slot通常是更优的抽象方案:

function ModalDialog({ header, body, footer }) {
  return (
    <DialogRoot>
      <DialogBackdrop />
      <DialogContent>
        <DialogHeader>{header}</DialogHeader>
        <DialogBody>{body}</DialogBody>
        <DialogFooter>{footer}</DialogFooter>
      </DialogContent>
    </DialogRoot>
  )
}

// usage:

<ModalDialog header="Hello" body="World" />

它们仍保留某种灵活性,允许将任意 React 组件注入特定位置,同时确保用户无需在各处复制粘贴冗余代码。将这些Dialog原语纳入设计系统固然很棒,但我不会将其直接暴露给用户。


因此存在两个指标——固定布局和主要动态内容——使我质疑是否真的需要复合组件。那么何时才是真正合适的场景?这又与类型安全有何关联?

更佳示例

在以固定元素为主的动态布局子元素中,<ButtonGroup><TabBar><RadioGroup> 都是能充分受益的典型用例:

import { RadioGroup, RadioGroupItem } from '@/components/radio'
import { Flex } from '@/components/layout'

function ThemeSwitcher({ value, onChange }) {
  return (
    <RadioGroup value={value} onChange={onChange}>
      <Flex direction={['row', 'column']} gap="sm">
        <RadioGroupItem value="system">🤖</RadioGroupItem>
        <RadioGroupItem value="light">☀️</RadioGroupItem>
        <RadioGroupItem value="dark">🌑</RadioGroupItem>
      </Flex>
    </RadioGroup>
  )
}

Select的主要区别在于,我们明确希望children不属于RadioGroupItem类型。能够按需布局这些子项(甚至添加辅助文本)至关重要。当然,某些场景下RadioGroup也需要动态选项,但这种情况下创建循环(如先前所示)并非不可行。

类型安全问题依然存在,因为传递给ThemeSwitchervalue不应仅是字符串——它很可能是字符串常量:

type ThemeValue = 'system' | 'light' | 'dark'

valueonChange 属性只需像之前展示的那样将 RadioGroup 设为泛型即可与 ThemeValue 关联,但 RadioGroupItem 呢?我们如何确保传递给每个 RadioGroupItem 的值都能被静态分析?

类型安全

当然,我们也可以将 RadioGroupItem 设计为泛型。但这种做法的问题在于,RadioGroup 的类型参数不会自动传递给子项,因为 JSX 子组件不会从父组件"继承"类型参数。因此即使 RadioGroup 类型定义完美且能推断出 <ThemeValue>,我们仍需为每个 RadioGroupItem 显式指定参数:

import { RadioGroup, RadioGroupItem } from '@/components/radio'
import { Flex } from '@/components/layout'

type ThemeValue = 'system' | 'light' | 'dark'

type ThemeSwitcherProps = {
  value: ThemeValue
  onChange: (value: ThemeValue) => void
}

function ThemeSwitcher({ value, onChange }) {
  return (
    <RadioGroup value={value} onChange={onChange}>
      <Flex direction={['row', 'column']} gap="sm">
        <RadioGroupItem<ThemeValue> value="system">🤖</RadioGroupItem>
        <RadioGroupItem<ThemeValue> value="light">☀️</RadioGroupItem>
        <RadioGroupItem<ThemeValue> value="dark">🌑</RadioGroupItem>
      </Flex>
    </RadioGroup>
  )
}

这不是一个理想的API,因为手动添加的类型注解很容易被遗忘。了解我的人都知道,我倾向于在可能的情况下让类型完全推断出来。对于复合组件而言,实现这一目标的最佳方式是避免直接暴露这些组件,而仅向用户提供调用方法。

组件工厂模式 不确定这个模式名称是否准确,但足以传达核心概念。本质上,我们无法完全消除手动类型注解,但可以尝试将其隐藏并显式化。不直接暴露RadioGroupRadioGroupItem,而是仅导出名为createRadioGroup的函数——该函数需带类型参数调用一次,随后返回静态类型的RadioGroup及其关联类型的RadioGroupItem

import { RadioGroup, RadioGroupItem } from './internal/radio'

export const createRadioGroup = <T extends GroupValue = never>(): {
  RadioGroup: (props: RadioGroupProps<T>) => JSX.Element
  RadioGroupItem: (props: Item<T>) => JSX.Element
} => ({ RadioGroup, RadioGroupItem })

这在运行时不会产生任何效果,只是将内部的 RadioGroupRadioGroupItem 包装成对象。但在类型层面上,它将类型参数绑定在一起。由于我们默认泛型参数为 never,用户必须传入参数才能对结果进行有效操作,从而实现如下用法:

import { createRadioGroup } from '@/components/radio'
import { Flex } from '@/components/layout'

type ThemeValue = 'system' | 'light' | 'dark'

type ThemeSwitcherProps = {
  value: ThemeValue
  onChange: (value: ThemeValue) => void
}

const Theme = createRadioGroup<ThemeValue>()

function ThemeSwitcher({ value, onChange }) {
  return (
    <Theme.RadioGroup value={value} onChange={onChange}>
      <Flex direction={['row', 'column']} gap="sm">
        <Theme.RadioGroupItem value="system">🤖</Theme.RadioGroupItem>
        <Theme.RadioGroupItem value="light">☀️</Theme.RadioGroupItem>
        <Theme.RadioGroupItem value="dark">🌑</Theme.RadioGroupItem>
      </Flex>
    </Theme.RadioGroup>
  )
}

当然,这个版本并非万无一失。我们仍然可以创建类型不同的RadioGroup,并将这些项传递给Theme.RadioGroup,但这种情况发生的可能性要小得多。

总而言之,这种方法既保留了复合组件的灵活性优势,又提供了强有力的类型保障。唯一的实际代价是用户无法直接导入组件,而需要通过函数调用创建组件家族的类型化实例。我认为这是值得的权衡,也是让设计系统用户获得最安全复合组件的最佳途径。

TypeScript:JavaScript 的“防坑装甲”,写代码不再靠玄学!

2026年1月16日 14:24

console.log() 到类型安全,我用 TS 把 Bug 拒之门外


🚀 开篇:你有没有被 JavaScript “背刺”过?

想象一下这个场景:

你信心满满地提交了 PR,CI 流水线绿得发亮。
结果上线 5 分钟后,用户反馈:“点按钮没反应!”
你打开控制台一看——

Uncaught TypeError: Cannot read property 'map' of undefined

……原来是你把 users 写成了 user,但 JS 并没有在你敲错的那一刻提醒你。

JavaScript 是个自由奔放的朋友,但它不负责帮你兜底。

而 TypeScript?它是那个在你写错时就拍桌子说:“兄弟,这不行!”的严谨搭档。


💡 TypeScript 是什么?一句话说清

TypeScript = JavaScript + 静态类型系统 + 编译时检查 + 更聪明的 IDE

它不是一门新语言,而是 JavaScript 的“超集”(Superset)——所有合法的 JS 代码都是合法的 TS 代码。
但反过来,TS 能在你写代码的时候就揪出潜在问题,而不是等到用户点击才崩溃。


🛠️ 安装?一行命令搞定!

npm install -g typescript

然后你就可以用 tsc 编译 .ts 文件,或者直接用 Vite / Create React App 等现代工具链,它们早已原生支持 TS。


✨ TypeScript 的五大“超能力”

1️⃣ 静态类型:让变量“有身份”

let name: string = "掘金读者";
name = 123; // ❌ 编译时报错!

不再是“万物皆可赋值”,每个变量都有明确的身份卡。

2️⃣ 边写边查错:IDE 实时预警

VS Code + TS = 黄金搭档。
你刚打错一个属性名,红色波浪线立刻出现:“亲,这个对象没有 useName,只有 username 哦~”

3️⃣ 编译时拦截 Bug,不让错误上生产

JS 是“运行时才知道错”,TS 是“写完就告诉你错”。
省下的不只是 debug 时间,更是半夜被 PagerDuty 叫醒的噩梦。

4️⃣ 智能提示 & 自动文档

接口定义即文档:

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

当你在组件里用 todo. 时,IDE 自动弹出 idtitlecompleted —— 不用翻文档,代码自己会说话!

5️️⃣ 清理“僵尸代码”

TS 会警告你:“这个变量声明了但从没用过!”
包括那些藏在角落的 console.log('调试中...') —— 重构时再也不怕残留垃圾代码。


🧪 实战:用 TS + Zustand 打造一个“坚不可摧”的 TodoList

我们不用 Redux(太重),也不用手搓 useState(太乱)。
Zustand + TypeScript = 轻量、简洁、类型安全的状态管理!

📦 状态定义:接口先行

// types/todo.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

🧠 自定义 Hook:useTodos

// hooks/useTodos.ts
import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';

export function useTodos() {
  const [todos, setTodos] = useState<Todo[]>(() => getStorage('todos', []));

  useEffect(() => {
    setStorage('todos', todos);
  }, [todos]);

  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: Date.now(),
      title,
      completed: false
    };
    setTodos([...todos, newTodo]);
  };

  const toggleTodo = (id: number) => {
    setTodos(todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
  };

  const removeTodo = (id: number) => {
    setTodos(todos.filter(t => t.id !== id));
  };

  return { todos, addTodo, toggleTodo, removeTodo };
}

🔍 注意:所有函数参数、返回值、状态都带类型!
即使半年后回来看代码,也能秒懂数据结构。

🎨 组件 Props 也用接口约束

// components/TodoInput.tsx
interface Props {
  onAdd: (title: string) => void;
}

const TodoInput: React.FC<Props> = ({ onAdd }) => {
  // ...
}

父子组件传参?TS 保证你不会传错类型、漏掉属性!


😂 对比:JS vs TS 的“加法”哲学

// JavaScript:自由但危险
function add(a, b) {
  return a + b; // 可能是 15,也可能是 "105"!
}
add(10, "5"); // 输出 "105" —— 你懵了?
// TypeScript:明确且安全
function add(a: number, b: number): number {
  return a + b;
}
add(10, "5"); // ❌ 类型错误!编译不过!

TS 不是限制你,而是保护你。


🧩 TS 的类型魔法箱(简要彩蛋)

  • 联合类型type ID = string | number;
  • 元组let user: [number, string] = [1, "Tom"];
  • 枚举enum Status { Pending, Success, Failed }
  • 泛型function getStorage<T>(key: string, def: T): T
  • unknown vs anyunknown 更安全,强制类型检查!

初学者别怕 any,它是“救命稻草”,但别让它变成“懒人借口”。


🌟 结语:TS 不是负担,而是生产力加速器

有人说:“TS 学习成本高。”
但我想说:调试一个线上 Bug 的成本,远高于写一个类型注解。

大型项目、团队协作、长期维护 —— TS 就是你的“代码保险”。

用 JS 写代码,靠运气;用 TS 写代码,靠实力。

所以,别再让 undefined is not a function 成为你项目的日常梗了。
拥抱 TypeScript,让你的代码既健壮又优雅。


今日行动建议

  1. 在现有项目中新建一个 .ts 文件试试
  2. 给你的 React 组件 Props 加上 interface
  3. useState 的初始值加上泛型 <T>

小步快跑,渐进式升级,你离“类型安全”只差一次尝试。

TypeScript:更加安全规范的JavaScript

作者 南山安
2026年1月16日 14:19

前言

曾经有一份真挚的 JavaScript 代码摆在我面前,我没有珍惜。直到 Uncaught TypeError: Cannot read property of undefined 这种红色报错占满屏幕,我才后悔莫及。如果上天能够给我一个再来一次的机会,我会对那个变量说三个字:“定类型!”。

如果非要在这份类型上加一个期限,我希望是——TypeScript

今天我们不聊高大上的架构,只聊怎么让你写代码时“心里有底”。我们要从最基础的弱类型痛点讲起,一路杀到 TypeScript 的核心——泛型,最后用 React 撸一个 实战小组件 TodoList。

系好安全带,我们要发车了!


第一章:JS 的温柔陷阱与 TS 的铁血秩序

1.1 弱类型的“二义性”之痛

JavaScript 是一个非常“随和”的语言,随和到什么程度?它允许你胡作非为。

看看这段代码

function add(a, b) {
  // js 是弱类型的优势:好学,易上手
  // 也就是这种“随和”,让你在大型项目中痛不欲生
  return a + b; // 二义性:是加法?还是字符串拼接?
}

const res = add("1", "2"); // 结果是 "12",而不是 3
console.log(res);

在你写下 add 的那一刻,你心里想的是数学加法。但 JS 运行时心想:“嘿,大哥给我俩字符串,那我给你拼起来呗。”

这就是 动态语言 的特点:Bug 只有在运行的时候才会发生。在大型项目中,这就像在排雷,你永远不知道哪一行代码会在用户点击按钮时爆炸。要保证 99.999% 不出问题,靠人脑去记 a 是数字还是字符串,简直是天方夜谭。

1.2 TypeScript:给JS穿上外骨骼

TypeScript 是什么?官方说它是 JS 的超集。

在集合论中,如果集合 A 包含了集合 B 的所有元素,并且集合 A 还有 B 没有的东西,那么 A 就是 B 的超集。通俗点说,它是 JS 的亲爹,专门负责管教这个熊孩子。

TS 是 强类型静态语言。它在代码编译阶段(运行前)就对其进行检查。

// 强类型可以杜绝 90% 的低级错误
// 其中,前两个:number规定的是参数的数据类型,最后一个:number规定的是函数返回值的数据类型
function addTs(a: number, b: number): number {
  return a + b;
}
// const result = addTs("1", "2"); // 报错!编译都不让你过!
const result = addTs(1, 2);
console.log(result);

这就是 TS 的核心价值:把错误扼杀在摇篮里。它不仅是类型约束,更是你免费的“结对编程伙伴”,时刻提醒你:“兄弟,这里不能传字符串。”

1. 安装编译器 (TSC)
打开终端,运行:

npm install -g typescript
  • 验证是否安装成功:输入 tsc -v,看到版本号即成功。

2. 编译(翻译)
在终端运行:

tsc index.ts

这时你会发现文件夹里多了一个 index.js 文件。这就是“翻译”后的结果。

3. 运行
运行生成的 JS 文件:

node index.js

第二章:TS 基础武器库 —— 不仅仅是加个冒号

在进入实战前,我们需要清点一下武器库。很多新手把 TS 写成了 AnyScript,遇见报错就加 any,这不仅违背了初衷,甚至让代码比原生 JS 更难维护。

TypeScript 的类型系统其实非常庞大,为了方便记忆,我们把它们分为五大类:基本底座、特殊兵种、对象建模、集合容器、以及逻辑运算

2.1 基本底座:JS 的老朋友与新面孔

基本数据类型: boolean, number, string, null, undefined, symbol, bigint

这部分大家最熟悉,它们直接对应 JavaScript 的原始类型。但在 TS 中,它们变得更加“铁面无私”。

// 1. 老三样:一板一眼
let isDone: boolean = false;
let age: number = 18;       // 支持十进制、十六进制等
let name: string = "Tom";

// 2. 只有在 ES2020+ 才有的新贵
// bigint: 处理超大整数,记得在 tsconfig 中开启 ES2020
let bigNumber: bigint = 100n; 
// symbol: 独一无二的标识
let sym: symbol = Symbol("key"); 

// 3. 让人头疼的空值:null 和 undefined
// 在 strictNullChecks: true (严格模式) 下,它们不能赋值给 number 等其他类型
let u: undefined = undefined;
let n: null = null;
// let num: number = undefined; // ❌ 报错!别想蒙混过关

2.2 特殊兵种:虚空与黑洞

这是 TS 特有的概念,理解它们是脱离新手村的标志。

1. Any vs Unknown:放纵与克制

新手最爱用 any,但资深工程师偏爱 unknown。

// any: 放弃治疗,跳过检查 (逃生舱)
let aa: any = 1; 
aa = "111"; 
aa.hello(); // ✅ 编译通过,但运行爆炸!这是 JS 的原罪

// unknown: 未知类型 (更安全的 Any)
let bb: unknown = 1;
bb = "hello";
// bb.hello(); // ❌ 报错!TS 说:我不确定它是啥,你不许乱动
// 必须先“验身” (类型收窄) 才能用
if (typeof bb === 'string') {
  console.log(bb.toUpperCase()); // ✅ 现在安全了
}

2. Void vs Never:空无一物与万劫不复

// void: 空。通常用于函数没有返回值
function logMessage(): void {
  console.log("只是打印一下,不返回东西");
}

// never: 绝不。表示永远不会有结果的类型 (黑洞)
// 场景1: 抛出错误,函数提前终结,执行不到结尾
function error(message: string): never {
  throw new Error(message);
}
// 场景2: 死循环
function loop(): never {
  while (true) {}
}

2.3 对象建模:描述世界的形状

在 TS 中,我们主要用两种方式描述对象:接口 (interface) 和 类型别名 (type)。

// 1. 字面量类型 (Literal Types)
// 只有 "male" 或 "female" 才是合法值,其他字符串不行
type Gender = "male" | "female"; 

// 2. 接口 (Interface):就像签订契约,适合定义对象形状
interface User {
  name: string;
  age: number;
  gender: Gender;
  readonly id: number; // 只读属性,不可篡改
  hobby?: string;      // 可选属性,有了更好,没有也行
  [key: string]: any;  // 索引签名:允许有额外的任意属性
}

const u: User = {
  name: "李四",
  age: 18,
  gender: "female",
  id: 1,
  school: "Qinghua" // ✅ 匹配索引签名
};

// 3. 小写的 object
// 代表非原始类型 (即不是 number/string/boolean...)
// 很少直接用,因为它太宽泛了,你无法访问里面的属性
function create(o: object | null): void {}
create({ prop: 0 }); // OK
// create(42); // Error

2.4 集合容器:数组与元组

// 1. 数组:两种写法
let list1: number[] = [1, 2, 3]; // 写法一:简洁(推荐)
let list2: Array<string> = ["a", "b"]; // 写法二:泛型写法(逼格高,且 foreshadow 了后面的泛型章节)

// 2. 元组 (Tuple):一种特殊的数组
// 它是定长、定类型的。React 的 useState 就是返回一个元组
let x: [string, number];
x = ["hello", 10]; // OK
// x = [10, "hello"]; // Error,顺序不对

2.5 高级逻辑:组合与枚举

最后,我们需要一些工具来处理复杂的类型关系。

1. 枚举 (Enum):让魔法数字滚出代码
不要在代码里写 if (status === 2),鬼知道 2 是什么。

enum Status {
  Pending = 0,
  Success = 1,
  Failed = 2,
}
let s: Status = Status.Pending; 
// 可读性爆炸:Status.Success 比 s = 1 强一万倍

2. 联合 (Union) 与 交叉 (Intersection)
这是类型的“逻辑或”与“逻辑与”。

// 联合类型 (|):是 A 或者 B
// 就像 ID,可能是数字 ID,也可能是字符串 UUID
type ID = string | number; 

function printId(id: ID) {
  // 这里需要注意,只能访问 string 和 number 共有的方法
  // 或者通过 typeof 判断类型
}

// 交叉类型 (&):是 A 并且也是 B
// 常用于对象合并
interface A { name: string }
interface B { age: number }
type C = A & B; // C 必须同时拥有 name 和 age

const person: C = {
  name: "Tony",
  age: 35
};

老司机总结

  • 能用 unknown 别用 any。
  • 能用 interface 描述对象就先用 interface。
  • 看到 | 竖线是“或者”,看到 & 符号是“合体”。
  • 基础打牢,后面讲泛型才不会晕车。

第三章:TS 的核武器 —— 泛型 (Generics)

好,前面的都是开胃菜。接下来我们要讲 TS 中最难理解但也最强大的特性:泛型

很多同学看泛型就像看天书,看到  就头大。其实,泛型就是类型的“传参”

3.1 为什么需要泛型?

想象一下,你要写一个函数,把传入的内容原样返回。
如果不这用泛型:

function echo(arg: number): number { return arg; } // 只能处理数字
function echoString(arg: string): string { return arg; } // 只能处理字符串
function echoAny(arg: any): any { return arg; } // 丧失了类型信息,传入 string 返回 any

我们希望:我传入什么类型,你就自动识别为什么类型,并保证返回值也是那个类型。

3.2 泛型实战:能够“变形”的容器

让我们看看项目中的 storages.ts,这是泛型最经典的应用场景:

// T 是一个占位符,就像函数的参数一样
// 当你调用 getStorage<User> 时,所有的 T 都会变成 User
export function getStorage<T>(key: string, defaultValue: T): T {
  const value = localStorage.getItem(key);
  return value ? JSON.parse(value) : defaultValue;
}

代码解析:

  1. getStorage:告诉 TS,这个函数有一个“类型变量”叫 T。

  2. defaultValue: T:默认值的类型必须是 T。

  3. : T (返回值):函数返回的类型也是 T。

优势:
当我们存储 Todo 列表时,我们可以这样用:

// 哪怕 localStorage 本质存储的是字符串
// 通过泛型,res 自动获得了 Todo[] 的类型提示!
const res = getStorage<Todo[]>("todos", []); 
// res.map... // 这里的 map 里面会自动提示 Todo 的属性!

如果你不用泛型,JSON.parse 返回的是 any,你后续对数据的操作将失去所有类型保护。泛型,让你的通用工具函数不仅通用,而且安全


第四章:React + TS 全栈实战 —— TodoList 架构解析

现在,我们把 TS 的知识应用到 React 项目中。不要小看一个 TodoList,麻雀虽小,五脏俱全。我们会按照企业级的代码规范来组织结构。

4.1 项目结构:井井有条

观察我们的文件树,这是一个非常标准的分层结构:

src
├── components  // 纯展示组件 (UI)
├── hooks       // 自定义 Hooks (逻辑核心)
├── types       // 类型定义 (契约)
├── utils       // 工具函数 (泛型的高发地)
├── App.tsx     // 根组件
└── assets

为什么要这样分?

  • 关注点分离 :UI 归 UI,逻辑归逻辑,类型归类型。
  • 可维护性 :当你想修改数据结构时,去 types;当你想修改业务逻辑时,去 hooks。

4.2 Step 1: 定义灵魂 —— Model (types/Todo.ts)

一切开发,先定数据结构。这是 TS 开发者的直觉。

// types/Todo.ts
// 接口用来约定对象必须实现的属性和方法
// export 导出,供全项目使用
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

有了这个 Todo 接口,全项目凡是涉及到 todo item 的地方,都有了标准。

4.3 Step 2: 逻辑抽离 —— Custom Hook (hooks/useTodos.ts)

在 App.tsx 里写一堆 useState 和 useEffect 是新手的做法。资深工程师会把业务逻辑抽离。

这里我们用到了刚才讲的 泛型 和 接口

import { useState, useEffect } from "react";
import type { Todo } from "../types/Todo"; // 显式引入 type
import { getStorage, setStorage } from "../utils/storages";

export default function useTodos() {
  // 泛型应用:useState<Todo[]>
  // 告诉 React,这个状态是一个 Todo 类型的数组
  const [todos, setTodos] = useState<Todo[]>(() =>
    // 泛型应用:getStorage<Todo[]>
    // 从本地存储取出来的一定是 Todo[]
    getStorage<Todo[]>("todos", [])
  );

  useEffect(() => {
    // 泛型应用:setStorage<Todo[]>
    setStorage<Todo[]>("todos", todos);
  }, [todos]);

  const addTodo = (title: string) => {
    const newTodo: Todo = {
      id: +new Date(),
      title,
      completed: false,
    };
    // 这里如果写 newTodo.xxx = 123,TS 马上会报错,因为 Todo 接口里没定义 xxx
    setTodos([...todos, newTodo]);
  };

  // ... toggleTodo, removeTodo 省略
  return { todos, addTodo, toggleTodo, removeTodo };
}

亮点分析:

  • useState<Todo[]>:这保证了 todos 变量在使用数组方法(如 .map, .filter)时,回调函数里的参数自动推断为 Todo 类型。
  • 逻辑复用:如果以后要把 TodoList 移植到别的页面,直接引入这个 Hook 即可。

4.4 Step 3: 组件开发与 Props 约束 (components/*.tsx)

在 React + TS 中,组件最重要的就是定义 Props 的接口。

TodoInput.tsx:

import * as React from "react";

// 定义 Props 接口
// 清晰地告诉调用者:你要用我,必须给我一个 onAdd 函数,参数是 string,没返回值
interface Props {
  onAdd: (title: string) => void;
}

// React.FC<Props>:
// FC = Function Component。泛型 P = Props。
// 这让 TS 知道 TodoInput 是一个组件,且接受符合 Props 接口的参数
const TodoInput: React.FC<Props> = ({ onAdd }) => {
  const [value, setValue] = React.useState<string>(""); 
  // ... 逻辑
};

TodoList.tsx:

import type { Todo } from "../types/Todo";
import TodoItem from "./TodoItem";
import * as React from "react";

interface Props {
  todos: Todo[]; // 核心数据
  onToggle: (id: number) => void; // 回调
  onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
  return (
    <ul>
      {/* 因为 todos 被定义为 Todo[],这里的 map 里 todo 自动识别为 Todo 类型 */}
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={onToggle}
          onRemove={onRemove}
        />
      ))}
    </ul>
  );
};

注意看:
在 TodoList 组件中,当我们在 map 里面渲染 TodoItem 时,如果忘记传 onRemove,IDE 会立刻划红线报 错。这就叫编译时检查。这比在浏览器里跑半天发现按钮没反应要强一万倍。

4.5 Step 4: 拼装 (App.tsx)

最后,我们在 App.tsx 里把所有积木搭起来。

import useTodos from "./hooks/useTodos";
import TodoList from "./components/TodoList";
import TodoInput from "./components/TodoInput";

export default function App() {
  // 就像去超市买东西,直接拿想要的逻辑
  const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

  return (
    <div>
      <h1>TodoList</h1>
      {/* TS 检查:addTodo 的类型匹配 TodoInput 的 props 要求吗?匹配! */}
      <TodoInput onAdd={addTodo} />
      <TodoList todos={todos} onToggle={toggleTodo} onRemove={removeTodo} />
    </div>
  );
}

第五章:细节拆分

一、 FC

在 React + TypeScript 的开发语境下,FC 是 Function Component(函数式组件)的缩写。

它是 React 官方类型库(@types/react)提供的一个泛型接口,用来专门定义“函数组件”的类型。

简单来说,它的作用就是告诉 TypeScript:“嘿,这个变量不仅仅是一个普通的函数,它是一个 React 组件。

1. 它的全貌

在代码中,你通常这样看到它:

// React.FC<Props>
//       ^^     ^^
//       ||     ||
//   接口名称   泛型(传入组件的Props类型)

const TodoInput: React.FC<Props> = ({ onAdd }) => { ... }

2. FC 到底帮我们做了什么?

当你把一个组件标注为 React.FC 时,TypeScript 会自动帮你做这几件事:

A. 约定返回值

它强制要求这个函数的返回值必须是 JSX 元素(或者 null)。如果你不小心返回了一个对象或者 undefined,TS 会立刻报错。

B. 泛型传参 (最重要的功能)

它接受一个泛型参数(就是尖括号 <> 里的东西)。
比如 React.FC< Props>,这意味着:

  1. 这个组件接收的 props 参数,必须符合 Props 接口的定义。
  2. 你在组件内部使用 props 时,会有自动补全提示。
  3. 父组件在使用这个组件时,必须传递 Props 里规定的属性,少传或错传都会报错。
C. 提供静态属性类型 (相对少用)

它还包含了组件的一些静态属性定义,比如 displayName、propTypes、defaultProps(注:defaultProps 在函数组件中已不推荐使用)。


3. 一个需要注意的“坑”:Children (React 18 的变化)

这是面试或实战中常遇到的知识点。

  • 在 React 17 及以前
    React.FC 实际上自带了一个隐含的属性 children。也就是说,即使你的 Props 接口里是空的,你也可以在组件里写 {props.children}。
    但这被认为是不安全的,因为有些组件本来就不该包含子元素。

  • 在 React 18 (现在)
    React.FC 移除了 隐式的 children。
    如果你的组件需要包含子元素(比如一个 ... 组件),你需要显式地在接口里定义它:

    // React 18+ 的正确姿势
    interface Props {
      title: string;
      children?: React.ReactNode; // 必须手动加上这一行,否则报错
    }
    
    const Layout: React.FC<Props> = ({ title, children }) => {
      return (
        <div>
          <h1>{title}</h1>
          {children}
        </div>
      );
    }
    

二、 storage.js中的 T 是什么?

在 storages.ts 那个文件中,T 代表 Type (类型)

它是 TypeScript 中 泛型 (Generics)  的标准占位符。

你可以把 T 看作是一个  “类型的变量”  或者  “类型的占位符” 。就像你在数学函数 

f(x)=x+1f(x)=x+1

 中,x 代表任意数字一样;在 TS 中,T 代表任意类型。

我们来深入剖析一下 getStorage 这个函数:

// 1. 定义泛型变量 <T>
export function getStorage<T>(key: string, defaultValue: T): T {
  // ...
}

1. 把它拆解来看

这里的 T 出现了三次,分别代表不同的含义:

  1. getStorage  (声明):

    这是在告诉 TypeScript:“嘿,老兄,我现在定义一个函数。我不确定用户将来要存取什么类型的数据,可能是数字,可能是字符串,也可能是 Todo 对象。所以我先用 T 占个坑。” 在这里的T就相当于一个声明,方便后续读取使用

  2. defaultValue: T (参数约束):
    这表示:“传入的默认值,必须和 T 是同一种类型。” 你不能一边说 T 是数字,一边传个字符串做默认值。

  3. : T (返回值约束):
    这表示:“这个函数运行结束吐出来的数据,一定也是 T 类型。”

2. 它是如何“变身”的?

泛型的神奇之处在于,当你调用函数的时候,T 才会确定它到底是什么。

让我们看看在 useTodos.ts 是怎么用的:

// 场景一:获取 Todo 列表
getStorage<Todo[]>("todos", []);

当你写下 <Todo[]> 的那一瞬间,TypeScript 会在后台自动把所有的 T 替换掉:

  • function getStorage 变成 -> function getStorage<Todo[]>

  • defaultValue: T 变成 -> defaultValue: Todo[] (所以第二个参数必须传数组 [])

  • 返回值 : T 变成 -> : Todo[]

如果换个场景:

// 场景二:获取一个计数器
getStorage<number>("count", 0);

此时,所有的 T 瞬间变成了 number。

3. 为什么要用 T?(不用行不行?)

如果你不用泛型,你只能面临两个糟糕的选择:

糟糕选择 A:写死类型

function getStorage(key: string, val: number): number { ... }

这样这个函数就废了,只能存取数字,存取 Todo 列表还得再写一个函数。

糟糕选择 B:使用 any

function getStorage(key: string, val: any): any { ... }

这是最常见的错误。虽然函数通用了,但当你拿到返回值时,它是 any。你敲代码时,IDE 无法提示你有 todo.title 还是 todo.name。你失去了 TS 所有的保护。


第六章:总结与思考

6.1 为什么这一套流程是“高质量”的?

  1. 类型即文档:你看一眼 interface Props 或 interface Todo,就知道数据长什么样,不用去猜后端返回的 JSON 到底有没有 id 字段。

  2. 泛型的妙用:在 utils/storages.ts 和 hooks/useTodos.ts 中,泛型极大地提高了代码的复用性和安全性。它让我们可以写出既通用又类型严格的代码。

  3. 开发体验 (DX) :智能提示(IntelliSense)让你敲代码如飞,重构代码时也不用担心漏改了哪个文件。

6.2 给初学者的建议

  • 不要害怕报错:TS 的红色波浪线不是在骂你,而是在救你。

  • 多用 Interface:养成先定义数据结构,再写业务逻辑的习惯。

  • 理解泛型:把泛型想象成一个“类型插槽”,它是 TS 进阶的分水岭。

  • 拒绝 Any:如果实在不知道写什么类型,先写 unknown,或者去查文档,不要轻易妥协用 any。

6.3 结语

从 JavaScript 到 TypeScript,是一次思维的升级。它让你从“大概也许可能是这样”变成了“肯定是这样”。在 AI 全栈的时代,代码的健壮性尤为重要。

希望这篇文章能帮你推开 TypeScript 的大门。记住,类型不是枷锁,而是你的铠甲。

现在,打开你的 IDE,把那个 .js 后缀改成 .ts,开始你的重构之旅吧!

对el-upload的上传文件显示名做长度限制

2026年1月16日 14:13

通过css样式my-upload对el-upload的上传文件显示名做长度限制

<el-upload
                  style="
                    display: flex;
                    flex-direction: column;
                    align-items: flex-start;
                  "
                  :action="x"
                  class="upload-demo my-upload"
                  :headers="{ token: $cookie.get('token') }"
                  ref="upload"
                  :on-preview="p_onPreview"
                  :on-success="
                    (response, file, fileList) => {
                      p_onSuccess(response, file, fileList, 'decideDoc');
                    }
                  "
                  :before-upload="beforeVideoUpload"
                  :on-remove="
                    (file, fileList) => {
                      handleRemove2(file, fileList, 'decideDoc');
                    }
                  "
                  :before-remove="beforeRemove"

                  :file-list="p_fileList_decideDoc"
                  :limit="3"
                  :on-exceed="
                    (files, fileList) => {
                      $message.warning(
                        `当前限制选择 3 个文件,本次选择了 ${
                          files.length
                        } 个文件,共选择了 ${
                          files.length + fileList.length
                        } 个文件`
                      );
                    }
                  "
                >
                  <el-button
                    slot="trigger"
                    size="mini"
                    type="primary"
                    icon="el-icon-upload2"
                    v-if="method != 'view'"
                    >上传附件</el-button
                  >
                </el-upload>
<style lang="scss" scoped>
.my-upload ::v-deep .el-upload-list__item-name {
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: inline-block;
}
</style>

image.png

image.png

Sass 模块化革命:告别 @import,拥抱 @use 和 @forward

作者 donecoding
2026年1月16日 14:01

为什么你的 Sass 代码突然开始报错?是时候彻底理解 Sass 的模块化系统了!

最近很多前端开发者突然发现自己的 Sass 代码开始报出各种警告和错误:

  • @import rules are deprecated
  • There's already a module with namespace "math"
  • Using / for division is deprecated

这一切都源于 Dart Sass 的模块化革命。如果你还在使用传统的 @import,那么这篇文章将带你彻底理解新的模块系统,并手把手教你如何迁移。

为什么要弃用 @import?

传统 @import 的问题

让我们先回顾一下 @import 的常见用法:

// variables.scss
$primary-color: #1890ff;
$font-size: 14px;

// main.scss
@import "variables";
@import "mixins";
@import "components/button";

.button {
  color: $primary-color;
  font-size: $font-size;
}

看起来很简单对吧?但 @import 有几个致命缺陷:

  1. 全局污染:所有变量、mixin、函数都混入全局作用域
  2. 无法避免冲突:同名变量会被覆盖,且很难追踪来源
  3. 无法控制可见性:无法隐藏私有变量
  4. 性能问题:每次 @import 都会重新计算
  5. 依赖混乱:无法知道模块间的依赖关系

新系统的优势

@use@forward 组成的模块系统解决了所有这些问题:

  • 命名空间隔离:每个模块有自己的作用域
  • 明确的依赖关系:清晰知道每个变量来自哪里
  • 更好的封装:可以隐藏私有成员
  • 更快的编译:模块只被计算一次

核心概念:@use vs @forward

@use:使用模块

@use 用于在当前文件中使用其他模块的功能。

// 基本用法
@use "sass:math";
@use "variables";

// 通过命名空间访问
.element {
  width: math.div(100%, 3);
  color: variables.$primary-color;
}

// 使用通配符(类似旧版行为)
@use "variables" as *;
.element {
  color: $primary-color; // 直接使用,无需前缀
}

// 自定义命名空间
@use "variables" as vars;
.element {
  color: vars.$primary-color;
}

@forward:转发模块

@forward 用于转发模块的成员,但不直接使用它们。常见于库的入口文件。

// 转发整个模块
@forward "variables";

// 选择性转发
@forward "sass:math" show div, ceil, floor;
@forward "components/button" hide _private-mixin;

// 重命名转发
@forward "sass:math" as math-*;
// 使用时会变成:math-div(), math-ceil()

实战迁移指南

场景1:基础变量和工具迁移

迁移前(@import):

// styles/variables.scss
$primary-color: #1890ff;
$border-radius: 4px;

// styles/mixins.scss
@mixin rounded-corners($radius: $border-radius) {
  border-radius: $radius;
}

// main.scss
@import "styles/variables";
@import "styles/mixins";

.button {
  color: $primary-color;
  @include rounded-corners;
}

迁移方案A:直接使用

// main.scss
@use "styles/variables" as vars;
@use "styles/mixins";

.button {
  color: vars.$primary-color;
  @include mixins.rounded-corners;
}

迁移方案B:创建库入口

// styles/_index.scss (库入口)
@forward "variables";
@forward "mixins";

// main.scss
@use "styles" as *; // 所有成员直接可用

.button {
  color: $primary-color;
  @include rounded-corners;
}

场景2:处理第三方库冲突

问题场景: 第三方库和你的代码都需要 sass:math

// ❌ 可能冲突的情况
// element-plus 内部已使用: @use "sass:math" as math;
// 你的代码中也使用: @use "sass:math" as math;

// ✅ 解决方案1:使用不同命名空间
@use "sass:math" as original-math;

.element {
  width: original-math.div(100%, 3);
}

// ✅ 解决方案2:创建包装函数
// utils/_math-utils.scss
@use "sass:math" as sass-math;

@function divide($a, $b) {
  @return sass-math.div($a, $b);
}

// 使用
@use "utils/math-utils" as math;
.element {
  width: math.divide(100%, 3);
}

场景3:构建组件库

项目结构:

ui-library/
├── foundation/
│   ├── _variables.scss
│   ├── _colors.scss
│   └── _index.scss
├── components/
│   ├── _button.scss
│   ├── _card.scss
│   └── _index.scss
└── index.scss

配置入口文件:

// ui-library/foundation/_index.scss
@forward "variables";
@forward "colors";
@forward "typography";

// ui-library/components/_index.scss
@forward "button" show button, button-variants;
@forward "card" show card;
// 隐藏私有成员
@forward "modal" hide _private-styles;

// ui-library/index.scss
@forward "foundation";
@forward "components";

// 业务代码中使用
@use "ui-library" as ui;

.custom-button {
  @extend ui.button;
  background-color: ui.$primary-color;
}

常见陷阱和解决方案

陷阱1:命名空间冲突

// ❌ 错误:相同的命名空间
@use "module1" as utils;
@use "module2" as utils; // 错误:命名空间 "utils" 重复

// ✅ 正确:使用不同的命名空间
@use "module1" as utils1;
@use "module2" as utils2;

陷阱2:@use 和 @forward 顺序错误

// ❌ 错误:@forward 必须在 @use 之前
@use "sass:color";
@forward "sass:math"; // 错误!

// ✅ 正确:正确的顺序
@forward "sass:math"; // 先转发
@use "sass:color";    // 后使用

陷阱3:忽略除法运算迁移

// ⚠️ 警告:传统除法将弃用
$ratio: 16/9; // 警告:Using / for division is deprecated

// ✅ 正确:使用 math.div()
@use "sass:math";
$ratio: math.div(16, 9);

陷阱4:在 @forward 文件中直接使用转发的成员

// utils/_index.scss
@forward "math-tools";

// ❌ 错误:不能在转发文件中直接使用转发的成员
$value: math.div(100, 2); // 错误!math 不可用

// ✅ 正确:需要单独 @use
@use "sass:math" as math;
$value: math.div(100, 2);
@forward "math-tools";

自动化迁移工具

Sass 官方提供了强大的迁移工具:

# 安装迁移工具
npm install -g sass-migrator

# 1. 迁移 @import 到 @use
sass-migrator import-to-use **/*.scss

# 2. 迁移除法运算
sass-migrator division **/*.scss

# 3. 同时处理多种文件类型
sass-migrator import-to-use --recursive "**/*.{scss,sass,vue}"

# 4. 带参数的迁移
sass-migrator import-to-use --namespace=lib "src/**/*.scss"

最佳实践总结

1. 命名策略

// 基础变量 → 通配符导入(方便使用)
@use "variables" as *;

// 工具函数 → 命名空间导入(避免冲突)
@use "utils/math" as math;

// 第三方库 → 使用短命名空间
@use "element-plus" as ep;

2. 文件组织

// 库/框架:使用 @forward 构建清晰的API
// _index.scss
@forward "foundation" show $colors, $typography;
@forward "components" hide _private-*;
@forward "utilities" as utils-*;

// 业务代码:使用 @use 明确依赖
@use "ui-library" as ui;
@use "project/utils" as utils;

3. 处理依赖关系

// 依赖图:A → B → C
// c.scss
$value: red;

// b.scss
@use "c" as *;
$color: $value;

// a.scss
@use "b" as *;
.element { color: $color; }

性能优化建议

  1. 减少重复计算:模块只计算一次,即使被多次 @use
  2. 合理使用缓存:构建工具通常会缓存编译结果
  3. 避免深层嵌套:过深的 @forward 链可能影响性能
  4. 按需导入:使用 show/hide 只导入需要的成员

版本兼容性

// package.json 版本建议
{
  "devDependencies": {
    "sass": "^1.58.0",     // 支持完整模块系统
    "sass-loader": "^13.2.0"
  }
}

写在最后

迁移到新的 Sass 模块系统看起来有些挑战,但带来的好处是实实在在的:

🎯 代码更清晰:明确的依赖关系和命名空间
🔧 维护更容易:模块化的结构便于重构
性能更好:智能的缓存和编译优化
🚀 面向未来:符合现代前端开发的最佳实践

迁移不是一次性的痛苦,而是一次性的投资。现在花时间迁移,未来将节省大量的调试和维护时间。

记住这个简单的决策流程:

  1. 构建库/框架 → 优先使用 @forward
  2. 编写业务代码 → 主要使用 @use
  3. 基础变量/配置 → 考虑 @use ... as *
  4. 工具函数 → 使用命名空间避免冲突

行动起来吧! 从今天开始,逐步将你的项目迁移到新的模块系统。你的未来代码库会感谢你现在做出的努力!


你的项目开始迁移了吗? 在迁移过程中遇到了什么有趣的问题或挑战?欢迎在评论区分享你的经验!

Zustand:若 React 组件是公民,谁来当“中央银行”?—— 打造轻量级企业级状态管理

2026年1月16日 13:48

前言:在 React 的世界里,如果说组件(Component)是勤勤恳恳工作的公民,那么状态(State)就是流动的货币。当应用规模扩大,仅仅靠父子组件间的“现金交易”(Props drilling)已经无法维持经济系统的运转。我们需要一个中央银行,一个专业的财务管理部门。

今天,我们不谈繁琐的 Redux,而是聊聊 Zustand —— 这个来自德国的“小而美”的状态管理库,看看它是如何通过极其精简的 API,帮我们把“企业做大做强”的。


一、 为什么我们需要“中央银行”?

我们在写 React 组件时,心中的公式往往是:

UI=f(State)UI = f(State)

但在实际开发中,如果不引入全局状态管理,我们面临着几个痛点:

  1. 层级地狱:想要把孙子的状态传给爷爷,Props 需要传递数层。
  2. 兄弟失联:兄弟组件之间无法直接通信,状态必须提升(Lifting State Up)到共同父级,导致不必要的重渲染。

“企业做大做强,请管理财务、状态以及修改状态的规矩。”

Zustand 就是这样一个基于 Hooks 思想实现的中央管理系统。它将状态存入 Store(仓库),实现全局共享,且不需要在最外层包裹繁琐的 Provider。


二、 建立你的第一家“分行”:基础状态管理

让我们从最简单的计数器开始。在 Zustand 中,创建一个 Store 就像开一家分店一样简单。

1. 定义规矩(Interface)与 存储(Store)

在“企业管理”中,不仅要有钱(Count),还要有动用这笔钱的规矩(Actions)。

TypeScript

// store/counter.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 1. 定义账本的结构:即使是小钱,也要有类型约束
interface CounterState {
    count: number;
    increment: () => void;
    decrement: () => void;
    reset: () => void;
}

// 2. 创建金库,并制定修改规则
export const useCounterStore = create<CounterState>()(
    persist( // 使用中间件,相当于给金库加了把“永久保存”的锁
        (set) => ({
            // 列出资产(状态)
            count: 0,
            
            // 状态要怎么改?必须通过合法的手段(Action)
            // set 函数是 Zustand 的核心,它是唯一合法的修改器
            increment: () => set((state) => ({ count: state.count + 1 })),
            decrement: () => set((state) => ({ count: state.count - 1 })),
            reset: () => set({ count: 0 }),
        }),
        {
            name: 'counter-storage', // 存到 localStorage 里的名字
        }
    )
);

核心解读

  • create: 建立仓库。
  • set: 这是唯一的“财务审批笔”。你不能直接 state.count++,必须通过 set 返回一个新的对象。这保证了数据的不可变性(Immutability)
  • persist: 这是 Zustand 的杀手锏中间件。它自动将状态同步到 localStorage,刷新页面数据不丢失。

三、 处理复杂资产:对象与数组的不可变性

当我们的资产不仅仅是数字,而是复杂的待办事项列表(TodoList)或用户信息(User)时,不可变性的操作显得尤为重要。

1. Todo List 的增删改查

useTodoStore 中,我们看到了数组操作的标准范式:

TypeScript

// store/todo.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Todo } from '../types';

export interface TodoState {
    todos: Todo[];
    addTodo: (text: string) => void;
    toggleTodo: (id: number) => void;
    removeTodo: (id: number) => void;
}

export const useTodoStore = create<TodoState>()(
    persist(
        (set) => ({
            todos: [],
            // 新增:利用解构 [...old, new] 创建新数组
            addTodo: (text: string) =>
                set((state) => ({
                    todos: [...state.todos, {
                        id: Date.now(),
                        text,
                        completed: false
                    }]
                })),
            // 切换状态:利用 map 生成新数组,不修改原对象
            toggleTodo: (id: number) =>
                set((state) => ({
                    todos: state.todos.map((todo) =>
                        todo.id === id 
                            ? { ...todo, completed: !todo.completed } 
                            : todo
                    )
                })),
            // 删除:利用 filter 过滤
            removeTodo: (id: number) =>
                set((state) => ({
                    todos: state.todos.filter(todo => todo.id !== id)
                })),
        }),
        { name: 'todos-storage' }
    )
);

深度思考

这里的 mapfilter 和展开运算符 ... 不是为了炫技,而是为了配合 React 的更新机制。React 依赖引用的变化来感知更新,如果我们直接 todos.push(),引用不变,UI 就不会刷新。这就是“修改状态的规矩”

2. 用户鉴权状态

同样的逻辑适用于用户信息管理:

TypeScript

// store/user.ts
interface UserState {
  isLoggin: boolean;
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}
// ... 代码省略,逻辑同上,利用 set 同时更新多个字段

四、 消费状态:在 UI 组件中提款

有了银行(Store),组件(App.tsx)就可以轻松地存取数据了。Zustand 的 Hook API 让这一切变得像使用 useState 一样自然。

TypeScript

// App.tsx
import { useCounterStore } from './store/counter';
import { useTodoStore } from './store/todo';

function App() {
  // 1. 直接提取所需的 State 和 Actions
  // 就像从 ATM 取钱一样简单
  const { count, increment, decrement, reset } = useCounterStore();
  
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  
  // 2. 结合本地 UI 逻辑 (Input value)
  const [inputValue, setInputValue] = useState("");

  const handleAdd = () => {
    if (!inputValue.trim()) return;
    addTodo(inputValue); // 调用 Store 的 Action
    setInputValue("");
  }

  return (
    <div>
       {/* UI 渲染逻辑,完全解耦 */}
       <h1>Count: {count}</h1>
       <button onClick={increment}>+1</button>
       
       <ul>
         {todos.map(todo => (
           <li key={todo.id}>
             {/* 这里的 toggleTodo 直接来自 Store */}
             <span onClick={() => toggleTodo(todo.id)}>
               {todo.text}
             </span>
           </li>
         ))}
       </ul>
    </div>
  )
}

五、 总结:Zustand 的企业级管理哲学

回到开头提到的代码注释: “专业管理状态,修改状态的规矩”

Zustand 相比于其他工具,胜在平衡

  1. 极简主义:没有 Boilerplate(样板代码),没有 Provider 包裹,即装即用。
  2. 规矩严明:通过 TypeScript 接口定义 State,通过 Actions 封装修改逻辑。组件只负责“触发”,Store 负责“执行”。
  3. 持久化persist 中间件让数据存储变得透明化。

如果把 React 应用比作一家公司,useState 是员工口袋里的零花钱,而 Zustand 就是那个高效、透明且严格执行财务制度的财务部。

别再折磨自己了!放弃 Redux 后,我用 Zustand + TS 爽到起飞

作者 NEXT06
2026年1月16日 13:42

前言:Redux 真的太难了...

作为一个刚入坑 React 不久的小白,我最近真的被状态管理搞得头皮发麻

跟着教程学 Redux,一会儿 Action,一会儿 Reducer,一会儿又是 Selector... 我只是想存个数字,却要写一堆模板代码,文件切来切去,人都绕晕了。直到昨天,我在社区看到大佬安利 Zustand,号称只有 1KB,而且不用包组件,不用写 Provider。

我不信邪试了一下... 哇!这也太香了吧!  

它写起来就像原生 JS 一样简单粗暴,配合 TypeScript 的智能提示,简直是为我们这种“手残党”量身定做的!今天就迫不及待把我的学习笔记(源码)分享给大家,希望能帮到同样迷茫的小伙伴!


第一关:从最简单的计数器开始

以前用 Redux 写个计数器要建好几个文件,用 Zustand 居然只要一个函数就搞定?

TypeScript

import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

// create 后面接个泛型 <CounterState>,TS 马上就知道里面有什么
export const useCounterStore = create<CounterState>()((set, get) => ({
  // 状态直接列出来,清晰明了!
  n: 1, // 虽然接口里没定义这个,先放着(小声bb)
  count: 0,
  
  // 👇 这里我要自我检讨一下!
  // 为了省事我用了 any... 大佬们轻喷 
  // set((state: any) => ...) 
  // 其实是因为我刚学 TS,有时候类型报错搞不定就用 any 大法保平安
  // 大家千万别学我,后面我会改进的!
  increment: () => set((state: any) => ({ count: state.count + 1 })),
  
  decrement: () => set((state: any) => ({ count: state.count - 1 })),
  
  // 这种直接重置的写法太舒服了,不用深拷贝什么的
  reset: () => set({ count: 0 })
}));

小白心得
虽然代码里那一坨 any 有点辣眼睛,但你们看这个逻辑!没有 switch-case,没有 dispatch,就是简单的函数调用!这才是人类该写的代码啊!


第二关:Todo List + 持久化魔法

接下来的需求是做一个待办事项列表。这里我发现 Zustand 有个超级厉害的中间件叫 persist。

以前我要把数据存到 localStorage,得在 useEffect 里写好几行。现在?只要配置一行代码!  刷新页面数据居然真的还在,当时我就震惊了!😲

TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 先定义清楚我们的 Todo 长什么样,TS 的好处体现出来了
export interface Todo {
    id: number,
    title: string,
    completed: boolean,
}

export interface TodoState {
    todos: Todo[],
    addTodo: (title: string) => void,
    removeTodo: (id: number) => void,
    toggleTodo: (id: number) => void,
}

// 这里的 <TodoState> 就像给代码装了导航仪
// 在写下面的 set 函数时,它会自动提示 todos 属性,太爽了
export const useTodoStore = create<TodoState>()(
  persist(
    (set, get) => ({
      todos: [],
      
      addTodo: (text: string) => 
        set((state) => ({
          // 这里的 ...state.todos 是不可变数据的写法
          // 虽然有点绕,但为了 React 能更新视图,我忍了!
          todos: [...state.todos, { 
            id: + Date.now(), 
            title: text,  
            completed: false,
          }]
        })),
        
      toggleTodo: (id: number) => 
        set((state) => ({
          todos: state.todos.map((todo) => 
            todo.id === id ? 
            {...todo, completed: !todo.completed} // 反转状态
            : todo
          )
        })),
        
      removeTodo: (id: number) => 
        set((state) => ({
          todos: state.todos.filter(todo => todo.id !== id)
        })),
    }),
    {
      name: 'todos', // 👇 见证奇迹的时刻!只要这一行,自动存 LocalStorage
    }
  )
)

真香时刻
只要加上 persist 和 { name: 'todos' },剩下的脏活累活 Zustand 全包了。这体验,简直是从原始社会直接跨入现代文明!🌆


第三关:用户登录 & 接口规范

最后是用户模块。以前写 JS 的时候,经常不知道 user 对象里到底有 username 还是 userName,拼错单词 debug 半天。

现在配合 TS 的 interface,把规矩立在前面:

TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware'; 

// 定义用户长啥样
export interface User {
    id: number,
    username: string,
    avatar?: string, // ? 表示头像可有可无
}

interface UserState {
  isLoggin: boolean; // 虽然这里我想写 isLoggedIn,但不小心拼错了...
  login: (user: { username: string; password: string }) => void;
  logout: () => void; 
  user: User | null;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLoggin: false,
      // 登录逻辑简直到离谱,一行代码搞定状态切换
      login: (user) => set({ isLoggin: true, user: null }), 
      logut: () => set({ isLoggin: false, user: null }),
      user: null,
    }),
    {
      name: 'user',
    }
  )
)

TS 初体验总结
虽然定义 interface User 和 UserState 确实要多写几行代码,但在TRAE里写代码时,那种敲一个点 . 就能自动弹出属性的感觉,真的太有安全感了!  再也不怕因为手滑写错单词而报错了。


结尾碎碎念

作为一个前端萌新,我觉得 Zustand + TypeScript 简直是绝配!

  • Zustand 负责简单(拒绝样板代码)。
  • TypeScript 负责安全(拒绝低级错误)。

如果你也像我一样被 Redux 折磨得痛不欲生,赶紧去试试 Zustand 吧!入股不亏!


❌
❌