下拉框要带头像和套餐价格:用 selectedcontent 干掉 div 模拟方案

2026-09-11 0 789

需求评审的时候,产品在图上圈了一下右上角的账号切换器,说:「这里要能看出每个人属于哪个套餐,最好带个小头像。」

我看了一眼现在的实现——一个 <select>,选项文本是「张三(专业版)」。挺符合 HTML 的用法,但确实不好看,也没法放头像。

然后产品补了一句:「不用太复杂,就两行就行。」

这句话让我在接下来一周里改了三次方案。

原生 select 的硬约束

先说清楚为什么这事不好办。<option> 元素的内容模型非常特殊——它只能包含文本,任何标签都会被浏览器当作文本内容的一部分处理(或者干脆被丢弃)。

<select>
  <option value="1">
    <img src="/avatar/zhang.png" alt="">
    <span>张三</span>
    <small>专业版</small>
  </option>
</select>

写是这样写没错,但渲染出来就是一串纯文字,或者干脆什么都没有。这是 HTML 规范层面的限制,不是你写错了。

所以过去十年,业内解决这个问题的标准做法是——干脆不用 <select>,用 <div><button> 自己搭一个。

第一次改造:div 模拟方案

我们做的第一版大概是这样:

<div class="account-switcher">
  <button type="button" aria-haspopup="listbox" aria-expanded="false">
    <img src="/avatar/zhang.png" alt="">
    <span>张三</span>
    <small>专业版</small>
    <svg class="chevron"><!-- 箭头 --></svg>
  </button>
  <ul role="listbox" hidden>
    <li role="option" aria-selected="true">
      <img src="/avatar/zhang.png" alt="">
      <span>张三</span>
      <small>专业版</small>
    </li>
    <li role="option" aria-selected="false">
      <img src="/avatar/li.png" alt="">
      <span>李四</span>
      <small>基础版</small>
    </li>
  </ul>
</div>

视觉上没问题。加上一堆 CSS 之后,跟产品给的图能有九成像。

但它带来了一串以前没意识到的麻烦:

键盘交互得全自己实现。方向键上下的移动、Home / End 跳到首尾、输入首字母快速定位、Esc 关闭并把焦点还回按钮——每一条都得写。我们那一版差不多写了 200 行 JS。

移动端体验明显变差。原生 <select> 在 iOS 上会弹出系统级别的选择器,滚轮手感是系统的、字号是系统的、深色模式是自适应的。换成 div 之后,那就是一个网页里的小弹窗,跟系统脱节。用户一上手就能感觉到「这个 app 的下拉框不太对」。

自动化测试也跟着变麻烦。以前测表单,直接 select.selectOption('1') 就完事了;现在 Playwright 得先点按钮、再点 li、再断言选中的是哪个,一次测试多了三四步操作,跑得慢了不少。

最要命的是表单。原生 select 是表单原生控件,提交的时候 value 会自动带上;我们这个是 div,得手动同步一个 <input type="hidden">,还得处理表单重置、自动填充、浏览器后退恢复状态这些边界情况。

这东西我们维护了两年多,中间改过七八次小问题。每次有人反馈「下拉框有点怪怪的」,我都得先判断是哪个环节的锅。

第二次改造:发现 base-select

去年年底翻 Chrome 的发布说明,看到一个叫 appearance: base-select 的东西。第一眼以为只是换皮,仔细看了才明白——它允许你在 <select> 内部放置本来被禁止的元素。

关键的两条规则:

第一,给 select 和它的 picker 都加上 appearance: base-select。picker 就是下拉出来的那个面板。

第二,可以在 <select> 里放一个 <button>,把原来被隐藏的原始按钮替换掉,然后在 option 里放任意元素。

<select name="account" id="account-select">
  <button type="button">
    <selectedcontent></selectedcontent>
    <svg class="chevron" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4"/></svg>
  </button>

  <option value="zhang" selected>
    <img src="/avatar/zhang.png" alt="" width="24" height="24">
    <span>张三</span>
    <small>专业版</small>
  </option>

  <option value="li">
    <img src="/avatar/li.png" alt="" width="24" height="24">
    <span>李四</span>
    <small>基础版</small>
  </option>
</select>

<selectedcontent> 是这套机制里的核心。它像一个占位槽——浏览器会自动把当前选中 option 的内容克隆过来填进去。也就是说,你在 option 里写的那份头像加名字加套餐,会被自动镜像到折叠状态下的按钮上。

这意味着你只需要维护一份内容。

更关键的是一点没有改变:它依然是一个真正的 <select> 元素。 表单提交、键盘导航、移动端原生选择器、可访问性树、自动化测试接口——全都和以前一模一样。

我们那 200 行 JS 键盘处理逻辑,全部删掉。

CSS 那边加了什么

切换需要的两行基础样式:

select,
select::picker(select) {
  appearance: base-select;
}

不写这个,<button><selectedcontent> 会被当作不被识别的元素静默忽略,你看到的还是原生那个朴素的下拉框。

然后是布局。里面的元素此时已经可以用普通 CSS 去控制了:

select > button {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border: 1px solid #d4d4d8;
  border-radius: 8px;
  background: #fff;
  min-width: 220px;
}

select > button img {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  flex-shrink: 0;
}

select > button small {
  margin-left: auto;
  color: #71717a;
  font-size: 12px;
}

select::picker(select) {
  margin-block-start: 4px;
  border: 1px solid #d4d4d8;
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
  padding: 4px;
}

select option {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-radius: 6px;
  cursor: pointer;
}

select option::checkmark {
  display: none;   /* 我们用背景色表示选中 */

}

select option:checked {
  background: #f4f4f5;
}

注意 option 上可以直接用 flex 了,这是 base-select 带来的最直接的变化。以前给 option 加 display: flex 是不会起作用的。

::checkmark 是个新伪元素,代表原生那个选中打勾的标记。我们用它来关掉默认的勾选图标,改用背景色表达选中状态,视觉上更贴合产品图。

踩到的第一个坑:selectedcontent 是克隆,不是引用

我给头像加了 alt 文本,给 img 挂了一个 onerror 用于加载失败时替换成文字首字母。结果发现:折叠状态下点了头像,那个 onerror 没有触发。

查了一下才明白原因——<selectedcontent> 里显示的内容是浏览器对选中 option 子树的深层克隆,不是同一个节点,也不共享事件监听器。用 addEventListener 出来绑的那种监听器,克隆后的副本上是不存在的。

这个行为本身是合理的(克隆能保证状态隔离),但踩到的时候挺费解,因为 DOM 检查工具里看到的结构明明是一样的。

解决方式是把逻辑挪到父级,用事件委托:

document.querySelector('select').addEventListener('error', (e) => {
  if (e.target.tagName === 'IMG') {
    e.target.replaceWith(fallbackInitials(e.target.dataset.user));
  }
}, true);   // 用捕获阶段接住 error 事件

用捕获阶段是因为 error 事件不冒泡。

第二个坑:动态改 option 内容不会实时刷新

我们的头像 URL 有缓存破坏参数,用户上传新头像之后,前端要更新 option 里那个 imgsrc。第一版的代码是这样:

const option = select.querySelector('option[value="zhang"]');
option.querySelector('img').src = newUrl + '?v=' + Date.now();

改完之后打开下拉框,列表里的头像是新的没错;但折叠状态下的按钮上还是旧头像。

原因是 <select> 的按钮内容是在某些时机才重新同步的,改 option 子节点的属性不会立刻触发这个同步。解决办法是把 option 的整个内容替换掉:

const option = select.querySelector('option[value="zhang"]');
option.innerHTML = `
  <img src="${newUrl}?v=${Date.now()}" alt="" width="24" height="24">
  <span>张三</span>
  <small>专业版</small>
`;
select.dispatchEvent(new Event('change'));   // 强制刷新

派发一次 change 事件能让浏览器重新同步 selectedcontent 的内容。这个技巧有一点副作用——它也会触发业务侧监听 change 的逻辑。如果监听里有比较重的操作,可能得加一层标记绕过去。我们的做法是在事件对象上挂一个自定义属性作为标识:

const evt = new Event('change');
evt.__synthetic = true;
select.dispatchEvent(evt);

select.addEventListener('change', (e) => {
  if (e.__synthetic) return;
  // 真正的用户交互逻辑
});

有点绕,但目前没找到更干净的办法。

第三个坑:picker 的定位

默认状态下,::picker(select) 弹出的位置是浏览器自己算的——它会尽量贴近原控件,但如果下方空间不够会自动翻到上方。这个行为本身是好的。

问题是它默认有个 position: absolute,定位基准是最近的一个定位祖先。如果 select 放在一个 overflow: hidden 或者 transform 的容器里,弹出面板可能会被裁掉,或者跟着容器一起被缩放。

我们的账号切换器放在顶栏,顶栏恰好有一个 position: sticky。第一次测试的时候,面板弹出来是横向偏移的,跑到右边屏幕外面去了。

解决办法是显式指定固定定位:

select::picker(select) {
  position: fixed;
  top: anchor(bottom);
  left: anchor(left);
  margin-top: 6px;
}

这里用的是 CSS 锚点定位。select 自己天然就是一个锚点,不需要额外声明 anchor-nameanchor(bottom) 取的就是原控件的下边缘。

加上这个之后,弹出面板会像 popover 一样挂在顶层,不受祖先容器影响。

不过要注意一点,position: fixed 的面板在页面滚动时不会跟着走——它算出来的是打开那一刻的位置。所以如果用户在面板打开的状态下滚动了页面,面板会孤零零地留在原地。我们的处理是监听滚动时关掉面板:

window.addEventListener('scroll', () => {
  const select = document.getElementById('account-select');
  if (select && select.matches(':open')) {
    select.hidePicker?.();
  }
}, { passive: true });

hidePicker() 是这套 API 新加的方法,配套的还有 showPicker():open 伪类。后者用于判断当前 picker 是不是展开的,比起以前用 document.activeElement 去猜方便多了。

第四个坑:表单提交的值还是 value 属性

这一点其实不算坑,但当时确实让我愣了一下。

改造完之后提交表单,发现后端拿到的还是「zhang」「li」这样干净的值,而不是 <option> 里那一坨带着 imgspan 的 HTML。

对的。表单提交取的是 option 的 value 属性,与里面的内容无关。这也正是它比 div 模拟方案好的地方——UI 和值是分离的,不用再维护那个 hidden input。

不过有一件事需要注意:如果 option 没有写 value,回退取的是它的文本内容。而文本内容现在包含了子元素里所有的文字——头像没有文字,但 spansmall 会连在一起。所以每个 option 都显式写上 value,不要偷懒

我们有一个 option 忘了写,提交上去变成了「张三专业版」,后端校验失败才发现。

第五个坑:自定义按钮里的图标会重复

我给 select 的按钮加了一个向右的小箭头,用的是 SVG:

<button type="button">
  <selectedcontent></selectedcontent>
  <svg class="chevron">...</svg>
</button>

期望的布局是左侧显示当前选中项,右侧显示箭头。

结果第一次渲染出来,箭头出现了两次,一左一右,很奇怪。仔细看 DOM 才发现——浏览器默认会在 select 按钮里也加一个箭头,用的是 ::picker-icon 伪元素。我们自己写的 SVG 加上去之后,就变成了两个箭头。

两种处理方式。要么去掉自己的 SVG,直接给 ::picker-icon 加样式;要么保留自己的 SVG,把原生的隐藏掉:

select::picker-icon {
  display: none;
}

我选了后者,因为产品图里的箭头形状是定制的,而且希望它在 picker 展开时能翻转 180 度。这个动画用自己写的 SVG 更容易控制:

select:open::picker-icon,
select:open > button > svg {
  transform: rotate(180deg);
  transition: transform 200ms;
}

第六个坑:移动端的判断

在 iOS Safari 和 Android Chrome 上跑第一版的时候,看到的下拉感觉和原来一模一样——层叠式滚轮,系统样式。我一度以为出了 bug。

其实这是正常的。这些浏览器还不支持 base-select,所以整个 <select> 就走的是原生逻辑。你写在里面的 <button><selectedcontent> 会被忽略,option 里的 <img> 也不会显示,但 option 的文本内容会正常露出来——比如「张三专业版」。

这个降级结果是可以接受的。功能完整,只是视觉上不那么花哨。比起 div 模拟方案在移动端体验变差的问题,这已经是很大的改观了。

但有一个细节需要注意——我们的 CSS 里有一些选择器是针对内部的 <button><small> 写的,在不支持 base-select 的浏览器里,这些选择器会匹配不到任何元素,不会报错,但也不生效。所以如果你在没有特性检测的情况下给这些元素加了关键样式(比如隐藏某些默认元素),在不支持的浏览器上不会出问题——因为那些目标元素本身就是无效的,不会渲染。

渐进增强的写法

核心思路是:不确定用户浏览器是否支持时,给一个安全的默认,再在支持的时候增强。

@supports (appearance: base-select) {
  select,
  select::picker(select) {
    appearance: base-select;
  }

  select > button { /* 自定义布局 */ }
  select option { display: flex; }
}

/* 不支持的浏览器会走原生渲染,不需要 fallback 样式 */

JS 侧的检测可以这样写:

const supportsSelectedContent =
  typeof HTMLSelectedContentElement !== 'undefined';

if (supportsSelectedContent) {
  // 初始化头像加载失败的处理等
}

这个判断比检测 CSS 支持更可靠,因为它直接对应到”这个元素是不是真的有特殊行为”。只判断 CSS.supports('appearance', 'base-select') 的话,某些只实现了 base-select 样式解析、但没实现 selectedcontent 行为的版本会误判。

上线后的实测数据

打包体积的变化是意外的收获。删掉那 200 行键盘处理逻辑之后:

改造前    gzip 之后 7.4KB
改造后    gzip 之后 1.1KB

节省下来的主要是键盘导航、焦点管理、Form 提交同步这几块。这部分代码的复杂度其实一直比体积更让人头疼——过去两年里它一共被删改过 11 次,每次都是因为不同浏览器的行为差异。

自动化测试的稳定性也上来了。切换账号这个用例以前偶尔会因为焦点定位超时,现在用 Playwright 的 selectOption() 就能搞定:

await page.selectOption('#account-select', 'zhang');
await expect(page.locator('#account-select')).toHaveValue('zhang');

不再需要模拟两次点击加一次等待。

什么时候不该用它

如果你的团队还有相当比例的用户在 Safari 上,而且产品对视觉的要求很严格(不愿意接受 Safari 上就是朴素样式),那这套东西目前的适用性还不够。

Safari 目前还没实现 base-select,具体时间表也不明确。Firefox 那边情况类似。所以现在上这套方案,本质上是「Chrome 上终于能用原生方案做出来了,其他浏览器保持现状」。

如果你的项目只需要支持 Chrome 内核,那没什么好犹豫的,直接换。因为它完全是渐进增强的,不会对任何用户造成功能损失。

如果项目需要跨浏览器一致体验,那得先想清楚:你是不是已经为了这件事付出了很大的代价。如果现在的 div 模拟方案已经稳定运行得很久,改与不改,收益其实不如预期那么大。

最后

这次改造最让人意外的不是代码量减少了 85%,而是解决方式本身。过去几年,面对 select 的这些限制,我们的直觉一直是「那就别用 select,自己造一个」。慢慢地,造出来的东西背上了越来越多的负担,而我们已经不太记得最初是为了什么放弃原生方案的。

这次的经历让我重新意识到:面对平台上达不到的需求,第一反应不该是「绕开平台」,而应该是「先看看平台这几年有没有变」。很多时候,多年前挡路的那个限制,现在可能已经不在了。

产品验收那天,只留了一句「这看起来不错」。不过看到那 200 多行删掉的代码,我觉得折腾这一周挺值的。

下拉框要带头像和套餐价格:用 selectedcontent 干掉 div 模拟方案
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html 下拉框要带头像和套餐价格:用 selectedcontent 干掉 div 模拟方案 https://www.taomawang.com/web/html/2746.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务