# 保哥笔记 — 技术SEO > 本分片含 35 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:技术SEO **生成**:2026-09-12 16:15:14 CST --- ## 页内锚点三成指不到目标,点下去地址栏变了页面不动 - URL:https://zhangwenbao.com/in-page-anchor-dead-target-audit.html - 分类:技术SEO - 发布:2026-09-10 | 更新:2026-09-12 - 摘要:页内锚点坏了没人知道:搜索引擎索引时会把井号后面砍掉,死链工具按惯例整类跳过它。这篇实测131个站,把指不到的那些拆成第三方钩子和自己写坏的两档。 - 关键词:技术SEO,电商SEO,网页语义化 > **TLDR**:摘要:131个电商站的181个页面上一共2801条井号开头的页内链接,其中84.1%连名字都没写,是纯占位。真正写了名字的443条里,有142条在本页找不到目标,用了页内锚点的88个站里有32个中招。这些链接不会出现在任何一份死链报告里。死链工具按惯例整类跳过它们,搜索引擎索引时又会把井号后面那截砍掉,于是它们在所有监控口径里都不存在。分档之后更有意思:58.5%是第三方组件留的钩子,41.5%是自己写坏的,其中24条是把CSS转义的反斜杠原样粘进了链接地址。 > 摘要:131个电商站的181个页面上一共2801条井号开头的页内链接,其中84.1%连名字都没写,是纯占位。真正写了名字的443条里,有142条在本页找不到目标,用了页内锚点的88个站里有32个中招。这些链接不会出现在任何一份死链报告里。死链工具按惯例整类跳过它们,搜索引擎索引时又会把井号后面那截砍掉,于是它们在所有监控口径里都不存在。分档之后更有意思:58.5%是第三方组件留的钩子,41.5%是自己写坏的,其中24条是把CSS转义的反斜杠原样粘进了链接地址。 页面上有一类链接,点下去不换页。它以井号开头,后面跟一个名字,意思是请滚到这一页上叫这个名字的那块地方去。目录跳章节、页脚跳到顶部、商品页跳到评价区,用的都是它。 这类链接的目标不在别的服务器上,就在同一份HTML里。按理说这应该是最不容易坏的一种链接:同一个人写的页面,同一个模板输出的内容,指的还是自己。 结果呢,它坏起来没人知道。上一篇量的是这一页上的名字重没重 (https://zhangwenbao.com/duplicate-element-id-audit.html),这一篇量的是另一头:指过去的那个名字,在不在。 ## 点一个井号开头的链接,浏览器到底做什么? HTML规范里滚动到片段那一节 (https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment)把这个动作拆成了4步,按顺序试: - 在文档里找id等于这个名字的元素,找到就用第一个; - 找不到,就找name属性等于这个名字的a元素,找到就用第一个; - 还找不到,如果这个名字正好是top,就用文档根元素,也就是回到页首; - 都不是,返回空——什么都不做。 第四步是本文的主角。什么都不做的意思是:地址栏会变,页面不会动,控制台不会有任何输出。浏览器认为这是完全合法的一次导航,它只是没找到落点而已。 这个静默还有几重加成。搜索引擎索引URL时会把井号后面那截整个砍掉,同一页的10条锚点在爬虫眼里是同一个地址,所以它们永远不会以404的形式出现在任何一份报告里。死链检测工具那篇 (https://zhangwenbao.com/deadlink-checker-404-redirect-link-health-guide.html)里写得明明白白,工具会跳过4类地址,纯锚点排在第一位,理由是页内跳转探活没有意义。至于用户,点了没反应通常会归因为自己没点准,不会去报。 于是这一类链接同时躲开了机器和人这2条监控线。 ## 这一轮量了什么? 取的是131个电商站,每站首页一份,其中56个站另取一个商品页,一共187个页面,用真浏览器打开、等待、滚到底再滚回来才读。200且解析成功的181个页面,来自127个站。 读法是:把页面上所有a标签的href取出来,只留2种:直接以井号开头的,以及解析成绝对地址之后前半截跟当前页完全一样、只多一个井号的。后一种容易被漏掉,模板里拼绝对地址是很常见的写法。 拿到名字之后先解一次URL编码,再按规范那4步走一遍:查id、查a元素的name、判断是不是top。都不中就记为找不到目标。 有件事得交代。同一批页面前后跑了2趟,第二趟修了一个口径错误,顺带把锚点也重读了一次。2趟数字不完全一样:页内锚点2860对2801,找不到目标的144对142,差异来自页面本身在2次抓取之间的微小变化,不是脚本读法变了。下面凡是锚点与引用属性的数字一律用第二趟,其余的沿用第一趟。 ## 2800条页内锚点,84.1%连名字都没写 形态 | 条数 | 占比 | 写了名字的 | 444 | 15.8% | 纯一个井号,后面什么都没有 | 2355 | 84.1% | 井号加0或者叹号这类占位 | 2 | 0.1% | 井号top | 1 | 0.0% | 84.1%的井号链接根本不是链接。它们的href就是一个孤零零的井号,真正干活的是绑在上面的脚本:点了之后打开抽屉、展开菜单、弹出弹层、切换标签页。井号在这儿只起一个作用:让这个东西看上去像个链接,能被键盘聚焦、能有手型光标。 这2355条里有604条带着role属性,也就是写页面的人已经用ARIA告诉辅助技术这其实是个按钮或者标签页;另有31条挂着onclick。剩下的既没有role也没有onclick,行为完全由外部脚本绑定。过时HTML标签实测 (https://zhangwenbao.com/obsolete-html-tags-platform-fossil-audit.html)那篇讲的是老写法怎么在换平台时被原样搬过来,用a标签加井号冒充按钮属于同一类惯性,只是它老得没那么显眼。 数量分布很极端。everlane.com的首页一页就有387条纯井号占位,商品页393条;liquiddeath.com的2个页面各202条;jackery.com首页和商品页各95条,一条具名的都没有。这些站上,几乎每一个可交互的元素都是用a标签加井号搭出来的。 这种写法的代价不在SEO,爬虫根本不会把它当成一条待抓的链接。Google关于可抓取链接的说明 (https://developers.google.com/search/docs/crawling-indexing/links-crawlable)里明确要求href指向真实地址,一个孤立的井号显然不是。代价在别处:AI代理只能猜着用你的站 (https://zhangwenbao.com/agent-actionability-html-audit.html)那篇量过,代理判断一个元素能不能点、点了会发生什么,靠的就是标签语义和ARIA角色。一个href只有井号、又没写role的a标签,在代理眼里是一条不知道通向哪里的链接,它多半会试着跟过去,然后什么都没发生。 ## 写了名字的那些,有多少能指到东西? 剩下443条具名锚点,这才是真正想用页内跳转的那部分。 指标 | 数值 | 具名锚点 | 443条 | 本页找不到目标 | 142条=32.1% | 靠id指到的 | 301条,占指得到的100% | 靠a元素的name指到的 | 0条 | 至少有一条指不到的站 | 32 / 88=36.4% | 至少有一条指不到的页面 | 53 / 133=39.8% | 指不到却当场看得见能点的 | 48条=33.8% | 还有一个数字没进表:第一趟量的时候顺手统计过,指得到的那些里有8条指向的是一个重名的id,也就是落到了第一个同名元素身上,这一档的后果上一篇讲透了 (https://zhangwenbao.com/duplicate-element-id-audit.html)。 32.1%指不到,这个比例本身就够高了。但真正要看的是表里第三、四行:规范里那条备用路径,也就是用a元素的name属性接住的那条,在这批站上一条都没派上用场。老写法确实已经退出历史舞台了,现在这件事100%押在id上。 ## 32.1%指不到,而老写法一条都没派上用场 把死锚点按站排一下,前几名分别是mackweldon.com 32条、charleskeith.com 24条、everlane.com 12条。这3个站合起来68条,占了全部死锚点的47.9%。问题不是均匀分布的,它扎堆在少数几个站上。 另一头也得说:taylorstitch.com的24条具名锚点全部指得到,theordinary.com的7条、bollandbranch.com的7条、gymshark.com的6条也都是满分。这件事是能做对的。 ## 指不到的那批,是第三方组件的钩子还是自己写坏的? 32%这个数很容易被读成10条里有3条是坏的。实际情况要复杂一层,得先分档。 把142条死锚点按名字里有没有第三方组件的标记拆开: 分档 | 条数 | 占比 | 涉及站 | 其中当场可见的 | 第三方组件的钩子 | 83 | 58.5% | 18 | 33条 | 自己写的 | 59 | 41.5% | 18 | 15条 | 第三方那一档长这样:mackweldon.com页脚上那一整套会员中心入口,井号rivo、井号rivo-orders、井号rivo-profile、井号rivo-logout,一共32条,全是某个积分插件的锚点约定;6个站的收藏夹入口写着井号swym-wishlist;3个站的登录入口写着井号k-hub;awaytravel.com、flyingtiger.com和ugreen.com的隐私设置写着井号reopenBanner。 这一档不完全算bug。这些名字是插件跟自己约好的信号:脚本在页面上监听点击事件,看到这个名字就自己去把对应的面板造出来。目标在加载完那一刻确实不存在,但用户点下去多半是有反应的。 但它有2个真实代价,而且都能量出来。这83条里有33条是当场可见能点的,一旦那个插件的脚本没加载上,被广告拦截器挡掉也好,被内容安全策略拦掉也好,网络慢了没到也好,用户点的就是一个纯粹的死链接。第三方脚本拉进来的那些域名 (https://zhangwenbao.com/third-party-domain-dependency-invisible-in-html-audit.html)那篇数过一个页面上能有多少个外部域名要谈成,这里每一条都是一个成败点。而且这些约定还会自己变,两天半里就有四分之一的站被第三方悄悄改了 (https://zhangwenbao.com/third-party-default-changes-silent-drift-audit.html)。今天对得上的名字,下个月不一定还对得上。 另一个代价落在不执行脚本的读者身上,包括相当一部分抓取器和代理,这些链接从头到尾就是死的。智能体读的是无障碍树不是截图 (https://zhangwenbao.com/ai-agent-accessibility-tree-not-pixels.html),而无障碍树上这条链接是存在的、有名字的、看起来可以跟过去的。 ## 自己写的那59条,集中在几个不常被点的入口上 这59条有个规律,高度集中在同一类功能上:隐私选择、评价区、收藏、快速预览、跳过导航。也就是那些不是每天都被点、但一旦被点就说明用户真的在找它的入口。 brooklynbedding.com的页脚上有一条写着不出售不共享我的个人信息,指向井号do-not-sell-or-share-my-personal-info,当场可见,目标不存在。这是美国部分州法律要求必须提供的入口。reebok.com和nativecos.com的同类入口分别指向井号showCB和井号onetrust-modal,也都指不到。那2个是同意管理平台的钩子,属于上一档。 graza.co和wusthof.com的商品页上,评价区那条井号product-reviews指不到。用户看到4.9分4542条评价,点一下,页面不动。 ## 一个反斜杠是怎么把24条锚点一起弄死的? charleskeith.com那24条值得单独拿出来讲,因为它是一个特别干净的因果链。 它的商品页上每张商品卡都是一个小轮播,左右2个箭头,箭头是页内锚点,指向轮播容器的id。地址写成这样: Previous 注意点号前面那个反斜杠。这是CSS选择器的转义写法。在选择器里,点号表示类名,要匹配一个名字里真的带点号的id,就得写成反斜杠加点号。 问题是这一截是URL的片段,选择器那套转义规矩在这里不作数。URL里的反斜杠就是一个普普通通的字符,它成了名字的一部分。页面上那个容器的真实id是不带反斜杠的,于是浏览器按带反斜杠的名字去找,找不到。 脚本核实过:把反斜杠去掉之后,这24条全部能指到本页的元素。它们不是名字写错了,是名字写对了但多带了一层转义。 怎么就写成这样了呢?八成是有人在浏览器控制台里调这个轮播,用CSS选择器定位到了容器,把选择器字符串复制出来当成了id值,粘进了模板。这类错误在带点号、冒号、方括号的id上特别容易发生,商品SKU里带点号是很常见的事。商品标识码那篇 (https://zhangwenbao.com/gtin-upc-identifier-exists-gmc-geo.html)里也提过同一类麻烦:一个标识符长什么样,取决于它被放进了哪个系统。 另有一类邻近的:beistravel.com、fahertybrand.com、mackweldon.com、rothys.com、untuckit.com这5个站的15条死锚点,名字在本页找不到对应的id,却能找到同名的class。第三方组件的钩子约定的是类名,写模板的人当成id写进了链接地址。类名和id长得一样、写法只差一个符号,混起来毫不费力。3万条!important那篇 (https://zhangwenbao.com/css-important-override-attribution-audit.html)翻过这批站的样式表,类名的数量比id还多一个数量级,两边撞脸是迟早的事。 ## 有人在井号后面写了一整个网址 剩下的死锚点里,有几条坏的方式完全不一样。 decathlon.com的页脚上有一条社交媒体链接,锚文本写着自家的Instagram账号,地址是这样的: @DecathlonAmerica 整个网址前面多了一个井号。浏览器于是老老实实在本页找一个叫https://www.instagram.com/decathlonusa/的元素,找不到,不动。这条链接当场可见、锚文本正常、在页脚上挂着,唯一的问题是它一辈子也去不了Instagram。 philips.com的页脚上有2条我的飞利浦入口,地址是井号tab=sign-up.html。这看着像有人把查询参数的写法和片段的写法搞混了,也可能是某个老系统里的路由约定被原样搬了过来。 everlane.com的首页上有4条社交内容位,锚文本是网红的账号名,地址是井号pdpOverlay=加商品标识。这个是故意的,把片段当状态参数在用,脚本读到这段就知道该弹哪个商品的浮层。这种写法本身能跑,代价是这4条链接对任何不执行脚本的读者都是死的,而且地址栏里会留下一串外人看不懂的东西。 glossier.com的一条更像手滑:href写的是井号加Opens Findation,Opens Findation是这个按钮的行为描述,本该出现在无障碍标签里,结果出现在了地址里。 这几条的毛病是一样的。它们全都不算逻辑错误,错在内容被放进了错误的格子。井号后面那一截没有任何格式校验,写什么进去都不会报错。SEO爬虫报的死链和Googlebot抓的不是同一批 (https://zhangwenbao.com/relative-url-resolution-crawler-googlebot-broken-link-seo.html)那篇讲过地址被解析错会走到什么地步,这里是同一个道理的页内版本:写坏了没人拦,只有真去走那一趟才知道。 ## 跳过导航那条链,97.5%真跳得到 页内锚点里有一条特别重要,就是跳过导航。它通常是页面上第一个可聚焦的元素,平时藏着,用键盘按一下Tab就冒出来,点了直接跳到正文,让不用鼠标的人不必每页都从头听一遍导航菜单。 这条链接是纯粹的页内锚点,也是最容易在改版中被弄断的一条,因为它指向的那个正文容器的id经常跟着模板一起换。 指标 | 数值 | 页面上有跳过导航类锚点的 | 118 / 181=65.2% | 其中目标真在本页的 | 115=97.5% | 指不到的 | 3个站,共5条 | 这是全场做得最好的一项,97.5%。3个例外是insta360.com的井号maincontent、snowpeak.com的井号main-navigation-content、baseus.com的井号MainContent,其中baseus那条当场可见。 baseus那条尤其典型:MainContent这个名字是Shopify默认主题里正文容器的id,主题换了或者容器被改名之后,跳过导航那条链接留在原地没人动。无障碍改造那份清单 (https://zhangwenbao.com/website-accessibility-seo-optimization-guide.html)里跳过导航排得很靠前,它属于加一次就再没人回头看的那类改动,而这一类恰恰最需要回归检查。62个站的前端库版本中位停在四年前 (https://zhangwenbao.com/frontend-library-version-age-audit.html),说明这类一次性动作在这批站上普遍缺乏复查机制。 ## 那把尺子第一次量出来的数,为什么是错的? 页内锚点只是按名字指认的一种。页面上还有一批属性也在干同样的事:标签的for指认输入框,aria-labelledby指认说明文字,aria-controls指认被这个按钮控制的面板,输入框的form指认它属于哪张表单。这些指认断掉的后果和死锚点是同一类。 第一遍写脚本时,我把所有这些属性的值统一按空格拆成了若干个名字,逐个去查。跑出来的结果是:label的for有49.1%指不到东西。将近一半的标签是断的,这数字大得吓人,也很适合当标题。 然后去翻明细,看见了这几行:for指向&、for指向1、for指向2、for指向3、for指向Vanilla。 问题就在这儿。这些属性分2类,一类是空格分隔的名字列表,另一类只接受一个名字。aria-labelledby、aria-describedby、aria-controls、aria-owns和表格的headers属于前者,写成2个名字就是同时指2个元素。而label的for、输入框的form、input的list、aria-activedescendant只接受一个名字,值里就算有空格,那也是名字本身的一部分。 ## 改对之后,49.1%变成了0.2% 把for当成列表拆,for等于Color Vanilla就被拆成了Color和Vanilla,2条都查不到,失败率凭空翻了几倍。改对之后重新跑: 口径 | label的for指不到的比例 | 按空格拆(错的) | 49.1% | 按单值查(对的) | 9.4% | 再刨掉标签本身就把控件包在里面的 | 0.2% | 最后那一行还需要解释一句。MDN关于label元素的说明 (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label)里写着,标签和控件有2种关联方式:一种是用for指名字,另一种是直接把控件包在标签里面。2种都算数,而且包裹这种不需要名字。3279条for里指不到的307条,其中299条的标签本身就把控件包着,关联照样成立。真正谁也关联不上的只有8条,来自4个站。 结论就这样从一半的标签是断的,变成了标签这件事这批站做得相当好。 顺带一个副产品:3279条for里有734条,也就是22.4%,指向的名字里带着空格。按规范说id不能含空白字符,这734条全是违规写法,但它们绝大多数都指得到。浏览器按字符串原样匹配,不在乎里面有没有空格。上一篇里也跑过这个实验 (https://zhangwenbao.com/duplicate-element-id-audit.html),结论一致:规范违反、功能不坏。 ## label和aria指认的那些名字,有多少是空的? 把口径改对之后,10种按名字指认的属性一起排出来是这样: 属性 | 条数 | 指得到 | 指到的是重名的 | 指到的当场看不见 | 出现在几个站 | aria-labelledby | 3954 | 75.6% | 21.6% | 77.0% | 103 | label的for | 3279 | 90.6% | 6.4% | 95.3% | 94 | aria-controls | 3058 | 89.1% | 4.8% | 67.0% | 96 | aria-describedby | 562 | 78.5% | 50.6% | 84.4% | 58 | 输入框的form | 194 | 89.7% | 17.8% | 36.8% | 23 | aria-owns | 40 | 25.0% | 20.0% | 100.0% | 17 | aria-activedescendant | 37 | 100.0% | 21.6% | 78.4% | 10 | aria-details | 16 | 31.2% | 0.0% | 80.0% | 2 | aria-errormessage | 7 | 0.0% | — | — | 2 | input的list | 4 | 0.0% | — | — | 2 | 先说一句口径。哪些属性接受多个名字、哪些只接受一个,写在WAI-ARIA 1.2规范 (https://www.w3.org/TR/wai-aria-1.2/)里,上一节那个错就是没照着它分类造成的。下面这张表全部按正确的分类重算过。 aria-labelledby的失败率是这批属性里最高的之一,四分之一指不到,而它是可及名称计算里优先级最高的一步。它指空了,元素就退回去用aria-label或者原生标签,退不到就没有名字。132个首页里有28个是空壳 (https://zhangwenbao.com/html-shell-page-readable-text-audit.html)那篇量过拿不到名字的元素有多少,这张表补上了另一半原因:名字是写了的,只是指到了空处。 再看aria-owns,只有25%指得到,而且指到的那些100%当场看不见。40条里最常见的目标叫predictive-search-results,出现在7个站上,是Shopify默认主题搜索建议的容器,用户不打字它就不存在。同一个名字在aria-controls那边也有6个站在指。这一档跟死锚点里的第三方钩子是同一个性质:约定成立,只是加载完那一刻还没兑现。 还有指到的目标当场看不见这一列,普遍在七成以上,label的for更是高达95.3%。这个数字不代表出错。大部分标签本来就是给读屏软件用的,视觉上用占位符代替,藏起来是有意为之。51个站的邮箱框只有6个把四件事写全 (https://zhangwenbao.com/form-field-declaration-audit.html)那篇拆过这类框到底说清楚了什么,这里只补一句:藏起来的标签只要关联对了就是有效的,关联错了才是问题。 ## 标签那一头还有23.7%是纯装饰 还有一个从标签那头数的数字。181个页面上一共4565个label:只写for的52.2%,只靠包裹的4.6%,两样都有的19.5%,两样都没有的1081个,占23.7%。这最后一档是纯装饰:它长得像标签、读起来像标签,但没有任何一个控件跟它挂钩。收邮箱那张表一半没写去向 (https://zhangwenbao.com/form-submission-destination-audit.html)那篇是从表单这一头数的,两头的数字合在一起才是完整的账。 结构化数据那边也一样。商品页的结构化数据里一半找不到哪个节点是这一页 (https://zhangwenbao.com/jsonld-main-entity-identification-audit.html),用的也是同一套按标识符互相指认的机制,只不过换了一层,那里的名字叫@id,指空了同样不报错。 长文这一头还有个数字。这181个页面上,h2有1992个,带id的256个,只有12.9%;h3是13.2%,h1只有3.9%。H1排第几那篇 (https://zhangwenbao.com/visual-hierarchy-vs-heading-tags-audit.html)数的是标题的视觉层级跟标记对不对得上,这里数的是同一批标题有没有留下可以被指认的落点,两件事都缺,长文就没法被切片。标题不挂id,页内跳转就没有落点,段落级深链也无处可指。文章目录怎么挂锚点 (https://zhangwenbao.com/in-page-navigation-engineering-toc-anchor-fragment-passage.html)那篇给的命名规范正是为这件事准备的。当然还有另一条不需要id的路子。MDN讲的文本片段 (https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments)靠匹配可见文字定位,Google搜索结果里那个Read more链接 (https://zhangwenbao.com/google-read-more-deep-link-passage-anchor-best-practices.html)用的就是它,2条路各有各的前提。 ## 照着改,先动哪几处? 这套自查比上一篇还便宜,一行代码,跑完直接得到清单。 [...document.querySelectorAll('a[href^="#"]')] .map(a => a.getAttribute('href').slice(1)) .filter(t => t && t !== 'top' && !document.getElementById(decodeURIComponent(t))); 返回的每一个名字都是本页指不到的目标。拿到清单之后,我自己的处理顺序是这样。 第一步,先挑当场可见能点的。142条死锚点里48条是当场可见的,这48条是用户真的会点到的。看不见的那些多半藏在弹层里,等弹层打开时脚本已经把目标造好了,优先级低一档。 第二步,把第三方组件的钩子单独放一栏,不要当bug改。名字里带插件标识的那些,积分、收藏、同意管理、在线客服,它们的目标本来就是点了才造。该做的是确认那个脚本被拦掉的时候,这个入口有没有降级方案。最简单的降级是把href换成真实地址,让脚本在有能力接管时再阻止默认行为。 第三步,专门查一遍转义。名字里出现反斜杠的,几乎一定是从CSS选择器里复制出来的;出现http开头的,是整条网址被塞进了片段;出现等号的,多半是有人拿片段在传参数。这3种模式用正则一扫就出来,是所有死锚点里最好修的。 ## 转义、跳过导航、合规入口,这三处优先 第四步,单独验一次跳过导航。它平时不可见,改版时最容易被落下,而它坏掉的代价比其他任何一条都大。验法是按Tab让它显形,点一下,看页面动没动。 第五步,把法律和合规相关的入口过一遍。隐私选择、不出售个人信息、无障碍声明这几类,在实测里的失败率明显偏高,原因是它们大多由第三方接管、又极少被点开验证。 第六步,如果你的页面上有大量纯井号占位的a标签,至少给它们补上role属性。这不影响现有脚本,但能让读屏软件和代理知道这是个按钮不是链接。语义化标签那篇 (https://zhangwenbao.com/semantic-html-tags-seo.html)讲过标签选错的代价,这里是最省力的一种补救。 保哥的经验是,这套检查真正的价值不在当下能修几条,而在它能查出另一件事,一个站的页内锚点坏得越多,说明它的模板换过的次数越多,而换模板时被落下的东西绝不止锚点这一样。mackweldon.com那32条会员中心入口全部指不到,翻开一看是整套积分插件的前端约定跟当前主题对不上——那不是32个bug,那是一次没做完的迁移。 ## 常见问题解答 ## 页内锚点指不到目标,会影响SEO吗? 不会直接影响,搜索引擎索引URL时会把井号后面那截砍掉,同一页的所有锚点在爬虫眼里是同一个地址,所以它们不会以死链形式出现在任何报告里。间接影响有2条:一是页面内导航是段落级深链的落点,锚点断了搜索结果里的章节跳转就无处可指;二是读无障碍树的AI代理会把这些链接当成可跟随的入口,跟过去什么都没有。 ## 怎么一次查出本页所有指不到目标的锚点? 在控制台里把所有以井号开头的a标签的href取出来,去掉井号,排除空值和top,再逐个用getElementById查一遍,查不到的就是死锚点。注意要先对名字做一次URL解码,因为模板里带中文或者特殊字符的名字会被编码。 ## href只写一个井号算不算错? 语法上没错,但它称不上一条链接。实测里84.1%的页内锚点是这种纯占位写法,2355条来自52个站,真正干活的是绑在上面的脚本。代价是辅助技术和AI代理会把它当成一条通向未知地址的链接。如果这个元素的实际行为是按钮,正确做法是用button标签;改不动的话至少补一个role属性说明它是什么。 ## 为什么死链检测工具查不出页内锚点的问题? 因为工具按惯例整类跳过它们。纯锚点、JavaScript伪链接、邮件链接、电话链接这4类都不会被发请求探活,理由是它们不是HTTP资源,探活没有意义。这个设计对前3类是对的,唯独页内锚点留下了盲区。它确实需要验证,只是验证方式得换一个,不发请求,在本页查名字。 ## 第三方插件留的锚点指不到,需要修吗? 不按bug修,但要做一件事:确认脚本被拦掉时这个入口有没有降级方案。实测里58.5%的死锚点属于这一档,其中33条是当场可见能点的。这些名字对应的面板由插件脚本在点击时现场创建,脚本一旦没加载上,广告拦截器、内容安全策略、网络超时都可能,用户点到的就是一条纯粹的死链接。 ## id里带点号或者冒号,锚点该怎么写? 直接原样写进井号后面就行,URL片段部分不需要转义点号和冒号。需要转义的是CSS选择器。在选择器里点号表示类名,所以要用反斜杠转义。这2个场景的规则不一样,把选择器的写法复制进href是实测里最常见的一种错,一个站上就有24条锚点栽在这里。 ## 跳过导航的链接怎么验证还有效? 打开页面直接按Tab键,第一个显形的元素通常就是它,点一下看页面有没有跳到正文。这条链接平时不可见,改版换主题时最容易被落下,实测里65.2%的页面有它,97.5%指得到,剩下的失败案例全都是主题里正文容器的id变了,链接留在原地没人改。 ## 权威参考资料 ## id重复的页面占六成,浏览器只认第一个,而它多半看不见 - URL:https://zhangwenbao.com/duplicate-element-id-audit.html - 分类:技术SEO - 发布:2026-09-10 | 更新:2026-09-10 - 摘要:点标签没反应、锚点跳不动、脚本改错了元素,多半不是逻辑写错,是这一页上有两个同名的id。本文用131个电商站的实测数据讲清它怎么发生、后果分几种、先改哪几处。 - 关键词:技术SEO,电商SEO,网页语义化 > **TLDR**:摘要:131个电商站的181个页面,四万多个带id的元素里有1508组名字撞了车,64.6%的页面至少有一处,62.2%的站至少有一页有。撞名之后没有任何报错,浏览器只是安静地把标签关联、锚点跳转、脚本取元素这三件事全部落到文档里第一个同名元素身上——而这批数据里,第一个当场看不见的占78.2%。真浏览器把后果跑了一遍:第一个是display:none就完全不动,是visibility:hidden就滚过去一片空白,在折叠面板里浏览器反倒会替你展开。 > 摘要:131个电商站的181个页面,四万多个带id的元素里有1508组名字撞了车,64.6%的页面至少有一处,62.2%的站至少有一页有。撞名之后没有任何报错,浏览器只是安静地把标签关联、锚点跳转、脚本取元素这三件事全部落到文档里第一个同名元素身上——而这批数据里,第一个当场看不见的占78.2%。真浏览器把后果跑了一遍:第一个是display:none就完全不动,是visibility:hidden就滚过去一片空白,在折叠面板里浏览器反倒会替你展开。 一个页面上的元素之间是互相认识的。这一格输入框的说明文字是哪一段、这个链接要跳到哪一块、这段脚本要改的是谁,全靠一样东西串起来:名字。写在HTML里就是id。 名字这套机制有个前提,前提写在规范里,也写在每一个前端新人第一周就被告知的常识里——一页之内不能重名。但常识归常识,页面是拼出来的:主题给一份、插件给一份、营销部门临时插的代码片段再给一份,谁也不知道别人用了什么名字。 这件事跟另一种重复很像但不是一回事。重复的meta标签那篇 (https://zhangwenbao.com/duplicate-meta-tag-first-declaration-wins-audit.html)量的是页头里同一件事被声明了两遍、机器各按各的规矩挑一条,那是声明层的事。这一篇量的是正文里元素之间的指认关系——名字撞了,指认动作照样成立,只是永远落在同一个元素上。 所以真实的页面到底重不重?重了之后会怎么样?这两个问题以前只能靠经验回答。文章目录怎么挂锚点 (https://zhangwenbao.com/in-page-navigation-engineering-toc-anchor-fragment-passage.html)那篇给过一套锚ID的命名规范,还留了一行自查用的JavaScript,但那是方法论,没有数字。这次把那行自查跑到了131个大站的181个页面上。 ## 一页上的id,本来该管什么用? 先把这件事的边界说清楚,因为它比大多数人以为的窄,也比大多数人以为的深。 HTML规范里id属性那一节 (https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute)对这个值只提了三条要求:不能是空字符串、不能含有空白字符、在整个文档里必须唯一。注意第三条的措辞——它是对写页面的人提的要求,不是对浏览器提的。规范并没有说浏览器遇到重名要报错、要拒绝渲染、要挑一个丢一个。它什么都不说。 浏览器的处理写在另一份规范里。DOM规范对getElementById的定义 (https://dom.spec.whatwg.org/#dom-nonelementparentnode-getelementbyid)是:返回文档中第一个id等于给定值的元素。这个第一个指的是树序,也就是HTML源码里从上往下数的第一个。 顺着这条定义,一整串动作都被绑在了同一个元素上: - 脚本取元素,取到第一个; - 标签的for属性关联控件,关联第一个; - 地址栏里的井号跳转,跳到第一个; - 无障碍树算元素叫什么名字的时候,可及名称计算规范 (https://www.w3.org/TR/accname-1.2/)里aria-labelledby那一步取的也是第一个; - 输入框用form属性挂到某张表单上,挂到第一个。 只有一个东西不听这套:CSS。选择器里的井号写法在样式引擎眼里就是一个属性匹配,重名的两个元素样式都会生效。这个不对称是整件事最阴的地方——看得见的那一层对两个都生效,操作的那一层只认第一个,于是页面看上去完全正常,问题只在有人去点、去跳、去读的那一刻才出现。 还有个细节值得先摆出来。无障碍标准里原本有一条成功准则专门管这个,编号4.1.1,名字叫解析,要求包括id唯一在内的标记合法性。2023年的WCAG 2.2把4.1.1整条标成了已废除 (https://www.w3.org/TR/WCAG22/#parsing-obsolete-and-removed),理由是现代浏览器和辅助技术早就自己处理了这类问题,这条准则产出的多是无意义的失败判定。 废掉的是合规判定,不是后果。MDN关于id全局属性的说明 (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/id)把四个使用场景列得很清楚:锚点、标签关联、脚本取值、CSS选择器,前三个都只认第一个,只有最后一个例外。这一点下面会用实测数据说清楚。 ## 这一轮量了什么,为什么要先把SVG和空名字挑出去? 取的是131个站,每站首页一份,其中56个站另取一个商品页,一共187个页面。真浏览器打开、等三秒半、滚到底再滚回顶、再等一下才读,读的是渲染完的DOM,不是服务端吐出来的那份HTML。最后200且解析成功的181个页面,来自127个站。取不回的六个:403三个、404两个、加载报错一个。 读法很直接:把每个页面上所有带id的元素列出来,按名字分组,组里超过一个的就是撞名。加上每个元素的标签名、当场可不可见、以及往上四层的祖先链,用来判断这两个同名的东西是不是同一个组件的两次渲染。 第一遍跑完,最常撞的名字排行榜上前几名长这样:Vector、Vector_2、Layer_1、clip0_3601_92。这些不是谁起的烂名字,是设计师从Figma里导出SVG图标时软件自动生成的图层名。一个页面里放二十个内联图标,二十份Vector就都进来了。 如果把它们算进去,这篇文章的结论会变成一句废话:撞名的都是SVG图层。所以口径必须先拆。 类别 | 撞名组数 | 涉及元素 | 怎么判定 | 本文口径 | SVG内部标识 | 397 | 2007 | 组里全是SVG专属标签,或祖先链里有svg | 另算 | 空名字 | 100 | 1291 | id写成了空字符串 | 另算 | 页面结构id | 1508 | 4672 | 剩下的全部 | 本文只算这一类 | SVG内部那一档单独看也有意思:397组分布在76个页面、50个站上,Vector和Layer_1各在7个站里撞过。它们的后果比页面结构id轻,但不是零——渐变、裁剪路径、遮罩这类东西是靠id互相引用的,两个图标各带一份同名的裁剪路径,后加载的那个用的就是前一个的形状。这类视觉bug通常表现为某个图标莫名其妙缺一块,然后被归因为浏览器兼容问题。一页图片的属性怎么批量体检 (https://zhangwenbao.com/image-alt-checker-batch-audit-cls-accessibility-guide.html)那篇的检查项里没有这一条,因为内联SVG根本不走img标签那条路。 空名字那一档更简单:写成id等于空字符串的元素有1291个,出现在100个页面、71个站上。它谁也指认不了,也没人能指认它,纯粹是模板里一句取值没取到留下的痕迹。 ## 六成页面撞了名,撞的是些什么? 页面结构id这一档,181个页面上一共1508组。 指标 | 数值 | 带id的元素总数 | 41267个,每页中位169个 | 至少有一处撞名的页面 | 117 / 181=64.6% | 至少有一页撞名的站 | 79 / 127=62.2% | 卷进撞名的元素 | 4672 / 41267=11.3% | 一个名字被用几次 | 中位2次,四分之三的组也是2次,最多314次 | 一页都不撞的站 | 48个 | 中位数是2这件事挺重要。绝大多数撞名不是什么灾难现场,就是同一个名字被用了两次。麻烦的地方恰恰在这儿:两次太不起眼了,不会有人在控制台里看见,也不会有任何工具主动告诉你。 ## 中位数是2,最大值是314 另一头则完全是另一种画风。parachutehome.com的首页上,同一个名字用了314次——是分期付款组件Affirm挂在每一张商品卡上的容器,商品卡有多少张就有多少份。同一个站上还有另外两个同类的名字各用了104次和32次。casetify.com的首页有一个叫layer-6-checkbox的名字用了56次,另一个叫menu-list-checkbox的用了37次,这是纯CSS折叠菜单的经典写法:一个看不见的复选框加一个标签,点标签就等于勾复选框。名字全一样的话,五十六个标签点下去勾的是同一个复选框。 按站排一下,前几名是:everlane.com两个页面加起来228组、thirdlove.com 143组、misen.com 140组、menuspace.com和parachutehome.com各125组,casetify.com一个首页就69组。 ## 撞上的那份,为什么七成八当场看不见? 这是这一轮最出乎意料的数字。1508组里,文档顺序排第一的那个元素在页面加载完的那一刻当场看不见的,有1180组,占78.2%。 第一个为什么看不见 | 组数 | 占1180的比例 | display:none | 557 | 47.2% | visibility:hidden | 415 | 35.2% | 宽高为零 | 149 | 12.6% | 祖先带aria-hidden | 33 | 2.8% | 透明度为零 | 20 | 1.7% | 在未展开的折叠面板里 | 6 | 0.5% | ## 为什么偏偏是第一个 把撞名元素落在哪片区域数一遍就明白了:带移动版特征的992组、导航里的664组、弹层里的360组、带桌面版特征的338组、页脚323组、页头305组。 这就是电商模板最常见的那套做法:同一份导航渲染两遍,一份给手机一份给桌面,用CSS各自藏一半。而移动版那一份在HTML里通常写在前面。于是桌面用户看到的是第二份,所有按名字找元素的动作却都落在第一份上——那份他永远看不见。 innisfree.com的首页上有两个叫customer_login_link的链接,一个在页头一个在移动菜单里;ugreen.com的巨型菜单里,submenu-1这个名字用了10次;flyingtiger.com的Details-menu-drawer-submenu-1用了13次,两个页面上都一样。这些都是同一个模式。 顺带说一句,这套双份渲染的做法本身还有别的账要算。类目导航改版做了三轮 (https://zhangwenbao.com/category-navigation-scope-custody.html)那篇讲过同一套导航被两拨人各维护一份会怎么走形,撞名只是它在源码层的一个副作用。 ## 第一个看不见的时候,页面会怎么样? 光有比例还不够,得知道后果长什么样。这一节的数据不是从这批站上量的,是拿最小复现页面在真浏览器里跑出来的:一个链接指向某个名字,页面上有两个同名的目标,第一个用不同方式藏起来,看点下去会发生什么。 第一个的状态 | 点完滚到哪 | 用户看到什么 | display:none | 纹丝不动 | 地址栏多了个井号,页面停在原地 | visibility:hidden | 滚到第一个的位置 | 滚过去了,那块是空白 | 透明度为零 | 滚到第一个的位置 | 同上 | 宽高为零 | 滚到第一个的位置 | 同上 | 在未展开的折叠面板里 | 滚到第一个的位置 | 浏览器主动把折叠面板展开了 | hidden=until-found | 滚到第一个的位置 | 浏览器主动把hidden撤了 | 三种后果分得很干净。display:none这一档最糟——浏览器量不出这个元素在哪儿,干脆不动,用户点了一个链接,地址栏变了,页面一动不动,绝大多数人会以为是自己没点中,再点一次,还是不动。这一档在实测数据里占47.2%。 第二档是滚过去了但那儿什么都没有,占了另外将近一半。它比第一档更难查,因为页面确实响应了,只是停在一片空白上,看上去像样式没写好。 最后两行是好消息:折叠面板和hidden=until-found这两种藏法,浏览器会主动替你揭开。这是这些年才加进去的行为,为的就是让搜索结果里的深链能落到折叠内容上。至于折叠内容本身在机器眼里算不算数,16种藏法的实测裁决表 (https://zhangwenbao.com/hidden-content-machine-readability-verdict-audit.html)那篇按藏法逐个判过,结论跟这里是一致的:能被揭开的那几种,机器也认。 标签那一头的实验结果更干脆。两个同名的输入框,第一个display:none,标签的for指过去——点标签,焦点哪儿都没去。不是落到隐藏那个,也不是落到可见那个,是根本没有元素拿到焦点。用户点了那行说明文字,光标不出现,输入框不聚焦,什么反馈都没有。 这类问题不会产生任何一条报错,不会出现在任何一张监控图上。它只表现为某一小撮用户在这一步多点了几下,然后放弃。下拉框那篇 (https://zhangwenbao.com/form-dropdown-control-selection-conversion.html)拆过同一种沉默:控件选型错了不会报警,只会在转化数字上留下一点谁也归不了因的损耗。 ## 同一个组件渲染两遍,和两个不相干的东西撞名,哪种更难查? 把每组撞名元素往上数四层祖先,链完全一样的算同一个组件的多次渲染,不一样的算两个不相干的东西撞了名。结果几乎对半:同组件多次渲染804组占53.3%,不相干撞名704组占46.7%。 这两种的性质完全不同。 ## 不相干撞名才是会咬人的那一类 同组件多次渲染是模板问题,改起来清楚:给循环里的每一项拼上一个唯一后缀就行,商品ID、变体ID、索引号都可以。麻烦在于它数量大,一改就是一片。 不相干撞名才是真会咬人的那一类。bugaboo.com的首页上有三个叫csrf_token的隐藏输入框,祖先链完全不同——是三张不同的表单各自带了一个同名的安全令牌。表单提交时脚本如果按名字去取这个值,取到的永远是第一张表单里那个。这类问题一旦出错,表现是提交被服务端拒掉,而所有人第一反应都是去查后端。至于一张表单到底把数据交给谁、隐藏域里装了些什么,收邮箱那张表一半没写去向 (https://zhangwenbao.com/form-submission-destination-audit.html)那篇整篇都在拆这件事。 再举一个更隐蔽的。reebok.com的首页上有两个叫header-account的链接,一个在桌面版按钮区,一个在移动版抽屉里。它们指向同一个功能,写这段代码的两拨人多半也不知道对方存在。 ## 商品页为什么比首页糟一倍? 把首页和商品页分开算,差距大得不像同一批站。 分档 | 页数 | 有撞名的比例 | 中位撞名组数 | 中位id元素数 | 首页 | 127 | 55.9% | 1组 | 111个 | 商品页 | 54 | 85.2% | 5组 | 249个 | Shopify系 | 95 | 82.1% | 3组 | 256个 | 非Shopify | 86 | 45.3% | 0组 | 70个 | 商品页比首页高出将近三十个百分点,原因不复杂:商品页上有一整套按变体循环出来的东西——尺码、颜色、数量、加购、库存提示,每一项都要一个能被标签指认的名字,而这些名字往往是从模板变量拼的。首页上循环出来的主要是商品卡,卡上要指认的东西少得多。 Shopify系高出非Shopify将近一倍,但这句话得小心解读。Shopify主题的id数中位是256个,非Shopify只有70个。不是Shopify的模板写得差,是它往页面里放的可指认元素本来就多三倍多,分母大了撞车概率自然高。这跟DOM元素过多实测里那条1400的线 (https://zhangwenbao.com/dom-element-count-static-vs-runtime-audit.html)是同一件事的两面。同一套模板把同一段内容铺很多遍这件事还有更极端的形态,一张礼品卡的结构化数据119KB (https://zhangwenbao.com/product-page-jsonld-node-inventory-audit.html)那篇里同一段配送政策抄了60遍。 还有个对照可以当作站内一致性的检查:54个站首页和商品页都抓到了,两页结论一致的43个,79.6%。五分之一的站,只看首页会得出跟只看商品页相反的答案。所以这类自查不能只查首页。 ## 加入购物车那个按钮,同名的有十四个 抽象比例讲完了,来看几个具体的。下面这些都是从实测数据里直接翻出来的,每一条都能自己去页面上验。 italic.com的商品页上,叫pdp-add-to-cart-button的按钮有14个,第一个是visibility:hidden,藏在一个弹层里。也就是说,任何按这个名字去找加购按钮的脚本、埋点、自动化测试,找到的都是那个弹层里的隐形按钮,不是用户真正会点的那个。同一个站的首页上也有13个。 ## 尺码、订阅、收藏,撞的都是转化链上的那几格 gymshark.com的商品页更典型。七个尺码选项各自有两个同名的输入框,名字是product加变体ID加size拼出来的,第一个宽高为零。这一页上还有tab-1、tab-2、tab-3三个按钮各重了两次。尺码选择器是商品页上转化链条最靠前的一环,而它的名字在这一页上没有一个是唯一的。尺码表实测55个站 (https://zhangwenbao.com/product-page-size-chart-carrier-audit.html)那篇数的是这张表在不在,这里数的是选尺码那几个框叫什么。 dollarshaveclub.com的商品页上,subscribe-radio-button、ship-now-subscriber、add-one-time-to-next-box-subscriber这三个订阅相关的单选框各有两份,第一份都是display:none,都在移动版那一侧。用户在桌面上点的是第二份,任何按名字读取当前选中项的脚本读的是第一份。 innisfree.com的首页上,商品快速选购弹层里的ProductSelect下拉框和SingleOptionSelector输入框各有两份,连它们外面那两张表单也是同名的。这一组的祖先链完全一致,说明是整个快速选购组件被渲染了两遍。 搜索框也是重灾区。search-input是这批数据里跨站撞得最多的一个名字,burrow.com、underarmour.com、lookfantastic.com三个站的首页上各有两个。前两个站的第一份都是visibility:hidden,第三个站的第一份倒是可见的——同一个名字撞两次,后果还不一样。独立站搜索框怎么设计 (https://zhangwenbao.com/site-search-bar-ux-design-conversion.html)那篇讲过这个入口的转化价值有多高,而它在源码里同时存在两份、脚本只认得其中一份。 theordinary.com的首页上有11个叫wishlistUrl-grid的隐藏输入框,各自装着一个收藏接口的地址。第一个是display:none。点收藏的时候如果脚本按名字取地址,十一件商品收藏的会是同一件。 保哥前阵子给一个户外装备独立站排查过一个类似的现象:客服反馈说有用户投诉尺码选不动,但复现不出来。最后发现是主题升级之后,桌面版和移动版的尺码选择器共用了同一套名字,而某个第三方尺码推荐插件按名字去读当前选中值,读的永远是隐藏那份。桌面用户点了尺码,插件那边一直显示未选择。这个bug的表现是间歇性的,因为只有装了那个插件的页面才犯。 ## 这些名字是谁起的? 把181个页面上全部34433个不重复的名字按写法分了个类,能看出这些id大部分不是人一个一个想出来的。 写法 | 名字数 | 占比 | 典型样子 | 语义化的短横线写法 | 12661 | 36.8% | card-image-link | 归不了类的 | 6296 | 18.3% | 混合大小写、下划线、中文 | 短数字结尾 | 6177 | 17.9% | tab-1、submenu-2 | 长数字结尾 | 3671 | 10.7% | product-card-image-7243977588887 | 平台自动生成 | 2924 | 8.5% | shopify-section-template--… | 以数字开头 | 1386 | 4.0% | 18008641、2026WebRevamp_HomeBanner_10132 | 名字里带空格 | 508 | 1.5% | Mini Category Icon、pause button | 组件库生成 | 274 | 0.8% | headlessui-、radix- | 框架运行时生成 | 187 | 0.5% | :r7:这种 | 哈希串 | 183 | 0.5% | 一长串十六进制 | 空字符串 | 111 | 0.3% | id="" | UUID | 55 | 0.2% | 标准三十六位 | ## 短数字结尾那一类最容易出事 短数字结尾那17.9%值得单说。tab-1、submenu-2这类名字在单个组件里没问题,一旦这个组件在一页上出现两次就必撞。它们贡献了撞名组里的267组。 带空格和以数字开头这两类,直觉上应该是废名字,实测下来却没有。拿最小页面跑了一遍:id里带空格、以数字开头,getElementById都取得到,井号锚点也跳得过去,唯一会当场报错的是不加转义的CSS选择器——写成井号加9开头的选择器,浏览器直接抛异常,得先转义。所以这两类是规范违反、功能不坏,麻烦留给了写脚本的人。翻一眼真实的名字就知道它们是怎么来的:snowpeak.com的Mini Category Icon、brooklinen.com的pause button,全是设计稿里的图层名被原样带了出来;lookfantastic.com的18008641和getquip.com的24301532则是后台的商品编号直接当了名字。 ## 这些名字有一半是机器拼出来的 还有几个名字的来历一眼能看出来。oclean.com的页面上有两个id叫span-style-color-f-61-a-1-a-class-stk-hi,还有一个叫validated-by-a-2-min-brushing-period-twi。这是WordPress的一个区块插件按标题文字自动生成id,而那段标题文字里本身混着HTML标记,于是标记也被一起转成了名字的一部分。同一个页面上还有六个叫图层_1的——中文的图层,同样是从设计稿导出来的。 thirdlove.com的商品页上有11个style标签,id全叫isPasted。这是富文本编辑器的标记:有人在后台粘贴内容时,把编辑器自己的痕迹一起粘了进来,粘一次留一个,留了十一个。HTML注释里那些内部痕迹 (https://zhangwenbao.com/html-comment-internal-traces-audit.html)那篇说过页面会不小心记下很多本不该外露的东西,这是同一类东西的另一种形态。 tentree.com的首页则是另一个方向:它把商品名直接转成了id,womens-dunes-shacket-forest-river-green这种,同一件商品出现在几个轮播位里就重几次。这类自动生成的名字跟3万条!important那篇 (https://zhangwenbao.com/css-important-override-attribution-audit.html)里数出来的类名是同一批产物:都是构建工具或者可视化编辑器批量吐的,没有人逐个看过。 ## 只curl一次能看见多少?读三遍呢?换成手机呢? 这一节回答的是这套读数本身准不准。 先看原始HTML和渲染后的对照。每个页面在浏览器里再发一次请求拿到服务端吐出的那份HTML,用正则把id数一遍,和渲染完的DOM比: 181个页面里,两边撞名组数完全一致的只有28.2%;渲染后变多的51.4%,变少的20.4%。原始HTML里就已经有撞名的页面占76.8%,撞名id总数从只curl的2467涨到渲染后的2794。四分之三以上的撞名是服务端模板直接吐出来的,剩下的是脚本运行之后才添上的。 变少那20.4%更有意思。tentree.com的首页原始HTML里有25组撞名,渲染完只剩1组——脚本把重复的商品卡清理掉了。所以只看源码会高估,只看DOM会低估,两边都得看。这跟JS渲染实测131个站 (https://zhangwenbao.com/js-rendering-loss-three-myths-audit.html)那篇的结论方向一致:渲染前后的差异不是单向的。至于哪些第三方脚本会在渲染阶段往页面里塞东西,只看HTML一个域名都看不到 (https://zhangwenbao.com/third-party-domain-dependency-invisible-in-html-audit.html)那篇把这条通道摊开过。 再看重复读数的稳定性。挑了30个站——撞名最多的、只有一处的、一处都没有的各取一批——每个站的首页用桌面视口连读三遍,再换手机视口读一遍。 29个站拿到了完整的四遍读数。桌面视口连读三遍,撞名组数完全相同的是29个站,一个都没变;换成手机视口之后与桌面一致的27个,93.1%;因为换视口而让结论翻转的,零个。页内锚点数同样是三遍全相同。 三遍完全一样这件事值得强调。它说明这些撞名不是抓取时的偶然,不是脚本随机渲染的产物,也不是这套尺子自己有随机性——它们固化在模板里,天天如此。 换视口那一遍更有信息量,因为它直接检验了前面那个解释。如果撞名主要来自移动版和桌面版各渲染一份,那么换个视口读数就该大幅变化。结果是93.1%的站纹丝不动。两份都在HTML里,跟视口无关,CSS只负责各藏一半。 只有两个例外,都值得看一眼。bellroy.com在桌面视口下有230个带id的元素、20组撞名,换成手机视口只剩141个元素、2组撞名——它是真的按视口渲染不同的DOM。brooklinen.com则是15组变16组,差一组,属于噪音。 顺带一个反向的对照:撞名组数三遍完全稳定,但id总数并不都稳定。stokke.com三遍分别读到536、620、620个带id的元素,手机视口下是716个;burrow.com是37、53、54。会动的在动,不该动的没动,这个对照本身就说明脚本的解析没有歪。 ## 照着改,先动哪几处? 这套自查的成本很低,第一步只要一行代码。 第一步,在任意一个页面的控制台里跑这一行,拿到本页所有重名的名字: const ids = [...document.querySelectorAll('[id]')].map(e => e.getAttribute('id')); console.log(ids.filter((v, i) => ids.indexOf(v) !== i)); 注意这里用的是getAttribute而不是点id。表单里那个叫id的字段会把表单自己的名字顶掉 (https://zhangwenbao.com/form-submission-destination-audit.html),直接读属性能绕开这一类坑。 第二步,别只在首页跑。前面那个79.6%说明五分之一的站首页和商品页结论相反,商品页才是重灾区,商品页里又以带变体选择的那类最狠。 第三步,拿到重名清单之后先分档,不要一股脑全改。判据是这个名字有没有人在指认它: - 被label的for指着的——最高优先,它直接对应用户点了没反应; - 被井号锚点指着的——次高,对应链接点了页面不动; - 被aria-labelledby、aria-controls这类属性指着的——影响读屏软件和读无障碍树的智能体 (https://zhangwenbao.com/ai-agent-accessibility-tree-not-pixels.html); - 只是挂在那儿没人指认的——可以排到最后,但别忘了脚本随时可能开始用它。 ## 先改被指认的,再改没人指认的 第四步,处理最常见的那种模式,也就是移动版桌面版各渲染一份。三条路:一是给两份加不同前缀,二是改成一份DOM用CSS调整布局,三是把不需要重复的那部分抽出来共用。第一条最省事也最不容易出错。 第五步,循环里的名字必须拼唯一后缀。商品ID、变体ID都行,索引号也行但得连组件实例一起拼,否则第二个轮播的第一项还是会和第一个轮播的第一项撞上。tentree.com那个例子就栽在这儿。 第六步,别忘了标记本身的老账。过时HTML标签实测 (https://zhangwenbao.com/obsolete-html-tags-platform-fossil-audit.html)那篇说过换个平台只是换一批老代码,重名这件事同理——迁移完记得重跑一遍,别指望新模板天生干净。语义化标签那篇 (https://zhangwenbao.com/semantic-html-tags-seo.html)里那套判断也能顺手用上:一个元素该用什么标签、要不要名字,本来就是同一个问题的两面。 第七步,把这一行自查挂进上线前的检查里。每次换主题、装插件、加营销代码片段之后跑一次,成本几秒钟。第三方脚本两天半里就有四分之一的站自己变了 (https://zhangwenbao.com/third-party-default-changes-silent-drift-audit.html),指望装完一次就长期不变是不现实的。 最后提醒一件容易被误会的事。WCAG 2.2把要求id唯一的那条准则废掉了,不等于这件事不用管了。废掉的理由是它作为合规判定项产出的噪音太多,跟后果存不存在无关。上面那些点了没反应的标签、跳不动的链接、读错值的脚本,一条都没因为准则废除而消失。 ## 常见问题解答 ## 一个页面上有重复的id,会影响SEO排名吗? 直接影响没有证据支持,Google没有把标记合法性列为排名因素。但间接影响是实打实的:页内锚点跳不动会让搜索结果里的段落级深链落空,标签关联断掉会让无障碍树上的元素拿不到正确名字,而读无障碍树的不只是读屏软件,还有越来越多的AI代理。把它当成可用性问题处理,比当成排名问题处理更准确。 ## 怎么快速判断本页有没有重复id? 控制台里跑一行:把所有带id元素的id取出来成数组,再筛出索引位置和首次出现位置不一致的那些,剩下的就是重名清单。取id时用getAttribute,别用点id属性,否则遇到表单里的同名字段会拿到元素而不是字符串。 ## 重复的id会让页面报错吗? 不会。浏览器不报错、不警告,页面照常渲染。CSS选择器对所有同名元素都生效,所以视觉上通常看不出任何异常。只有在有人点标签、点锚点或者脚本按名字取元素的时候才会暴露,而且暴露形式是无声的——动作没有发生,没有任何提示。 ## 第一个同名元素是隐藏的,锚点跳转会怎样? 取决于怎么隐藏的。用display:none藏的,浏览器量不出位置,页面完全不动,只有地址栏多了个井号。用visibility:hidden、透明度为零或者宽高为零藏的,页面会滚到那个位置,用户看到一片空白。藏在未展开的折叠面板里或者用hidden=until-found标记的,浏览器会主动展开再滚过去,这两种反而是安全的。 ## id里可以有空格或者以数字开头吗? 规范说不行,id不能含空白字符。但实测下来这两种写法getElementById取得到、井号锚点也跳得到,唯一会当场出问题的是不转义的CSS选择器——以数字开头的id直接写进选择器会抛异常,必须先做转义处理。所以它们属于规范违反但功能不坏那一类,真正的代价是让后来写脚本的人踩坑。 ## 为什么商品页的重复id比首页多这么多? 因为商品页要按变体循环渲染一整套需要被指认的控件:尺码、颜色、数量、加购按钮、库存提示,每一个都得有名字给标签去关联。这些名字多半是从模板变量拼出来的,拼的时候少带一个变体ID就会重。实测数据里商品页有重名的比例是85.2%,首页是55.9%。 ## 内联SVG图标里的重复id要不要管? 比页面结构的id缓一档,但不是完全不用管。渐变、裁剪路径、遮罩这些是靠id互相引用的,两个图标各带一份同名的裁剪路径,后面那个会用上前面那个的形状,表现为图标缺一块。实测里这类撞名有397组、分布在50个站上,最常撞的名字是Vector和Layer_1,都是设计工具导出时的默认图层名。 ## 权威参考资料 ## 商品页的结构化数据里,一半找不到哪个节点是这一页 - URL:https://zhangwenbao.com/jsonld-main-entity-identification-audit.html - 分类:技术SEO - 发布:2026-09-09 | 更新:2026-09-09 - 摘要:搜索引擎怎么知道你商品页上那几十个Product节点里哪个才是主角?53个站392个页面实测,只有一半给出了确定答案,而canonical那一层的普及率是96.8%。 - 关键词:结构化数据,技术SEO,电商SEO > **TLDR**:摘要:53个英文独立站的392个商品页,把每一页的JSON-LD节点全取下来,问一个很朴素的问题——这一堆Product节点里,哪一个是这一页正在卖的东西?按完整地址严格比对,369个有商品节点的页面里只有188页能给出确定答案,占50.9%。2700个Product节点中,写了@id的占44.2%,写了mainEntityOfPage的只有0.9%;有5个节点的@id干脆就是一个井号。同一批页面的canonical标签写了96.8%、指向自己的有94.9%——同一件事,HTML那一层早就有了约定,结构化数据这一层还没有。 > 摘要:53个英文独立站的392个商品页,把每一页的JSON-LD节点全取下来,问一个很朴素的问题——这一堆Product节点里,哪一个是这一页正在卖的东西?按完整地址严格比对,369个有商品节点的页面里只有188页能给出确定答案,占50.9%。2700个Product节点中,写了@id的占44.2%,写了mainEntityOfPage的只有0.9%;有5个节点的@id干脆就是一个井号。同一批页面的canonical标签写了96.8%、指向自己的有94.9%——同一件事,HTML那一层早就有了约定,结构化数据这一层还没有。 ## 一页摆着几十个Product,机器怎么知道哪个是这一页? 上一篇数节点的时候 (https://zhangwenbao.com/product-page-jsonld-node-inventory-audit.html)发现一个绕不过去的问题:既然一个商品页动辄写几十个Product节点,那么当搜索引擎或者购物代理读到这一页,它凭什么知道这几十个里面哪一个是这一页在卖的东西? 这个问题在HTML那一层早就有答案了。link rel="canonical"就是干这个的——这一页的规范地址是谁,写清楚,一行搞定。本批392个页面里,96.8%写了canonical,其中94.9%指向自己。这一层的普及率高到几乎可以当成默认行为。 但JSON-LD那一层没有一个同样普及的写法。它有几个候选:给节点写@id,给节点写url,或者写mainEntityOfPage把节点和页面绑起来。这三样都是标准里有的,问题是有多少人用。 所以这一篇只做一件事:逐页问一遍,这一页的结构化数据能不能指出它自己。 ## 按完整地址算,有多少页真的认领了自己? 判据是这样定的:把每个Product节点上的url和@id取出来,补全成绝对地址,跟这个页面自己的地址逐字比对。对上了,就算这个节点认领了本页。 结果 | 页数 | 占比 | 正好一个节点对上——能确定主体 | 188 | 50.9% | 多个节点同时对上——有歧义 | 30 | 8.1% | 一个都对不上——机器只能猜 | 151 | 41.0% | 样本是369个至少写了一个Product节点的页面。能给出确定答案的刚过一半。 要说清楚的是,对不上不等于标记失效。多数情况下机器还是能猜对——一页只有一个Product节点的时候,那就是它,不需要指认。本批有近六成的页面属于这种情况,简单页面天然没有歧义。 麻烦的是另一批。当一页有几十个Product节点、而没有一个用地址认领自己的时候,机器只能靠位置和上下文去推:写在最外层的那个大概是主体,包在hasVariant里的大概是它的成员。这个推断多数时候成立,但它是推断,不是声明。 Google结构化数据的入门文档 (https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data)里有一句常被略过的话:标记必须描述这一页上的内容。这句话默认了一个前提——读的人知道哪部分是这一页的内容。当一页交出去四十个商品节点,这个前提就得由页面自己来保证,而多数页面没有保证它。 ## 四个站一页都指认不出来 framebridge、glossier、italic、wusthof这四个站,抽到的页面里没有任何一页的Product节点带上了能对上本页地址的url或@id。它们不是没写标记——framebridge八个页面全都有完整的Product加Offer加Brand,字段该有的都有,就是没有一处写明这份标记描述的是哪个网址。 这四个站的商品页都只有一个Product节点,所以实际影响很小。但它反映出一件事:写标记的时候,多数人想的是把这件商品的属性填全,没想过要说清这份标记属于哪一页。这跟规格字段那次实测 (https://zhangwenbao.com/product-spec-field-value-pairing-audit.html)看到的思路是一样的——大家都在按字段清单打勾,清单上没有的项目,就不会有人想起来。 ## 第一版算出来的数,为什么高了40个点? 这一节讲我自己差点写错的地方。 第一次跑这个统计的时候,我在浏览器里做地址比对,图省事写了个归一化函数:取协议、域名和路径,把查询参数和末尾斜杠都扔掉,再比。这么算出来的结果漂亮多了——91.3%的页面至少有一个节点对上,只有8.7%完全对不上。 然后我去翻具体案例,发现不对劲。 untuckit的一个页面有29个Product节点,按归一化口径,其中十几个都对上了本页地址。点开看才明白:这些变体节点的url全都是同一个商品路径加不同的?variant=参数,参数被我扔掉之后,它们当然全部相等。 口径 | 至少一个对上 | 多个对上 | 说明 | 忽略查询参数 | 91.3% | 34.4% | 变体参数被抹平,全算成同一个地址 | 完整地址逐字比对 | 59.0% | 8.1% | 只认真正指向本页的那一个 | 两个口径差了32个百分点,而它们回答的根本不是同一个问题。忽略参数那一版回答的是这堆节点是不是都在这个商品路径底下,答案当然是;完整比对那一版回答的才是有没有一个节点声明自己就是这一页。 这个坑跟从站点地图里找重复网址那次 (https://zhangwenbao.com/keyword-own-page-duplicate-wordset-audit.html)踩的是同一类:归一化是为了消除噪声,可一旦被消掉的恰好是承载区分信息的那部分,噪声没了,信号也没了。那次是把网址里的数字当噪声删掉,结果把5寸和7寸的厨刀算成了重复;这次是把查询参数当噪声删掉,结果把三十个变体算成了三十次自我认领。 两次的共同点还有一个:错的那一版,读起来都比正确的那一版更顺眼。91.3%是个让人放心的数字,看到它不会有人追问;50.9%才会让人想去核对。保哥现在给自己定的规矩是,凡是归一化过的字段,出结论之前必须拿原值再跑一遍,两个数差得越大,越要先解释差在哪儿,而不是挑好看的那个。 ## @id这一栏,53个站写了多少? 2700个Product节点,逐个看它们身上的四个字段: 字段 | 写了的节点 | 占比 | offers | 2344 | 86.8% | url | 1537 | 56.9% | @id | 1193 | 44.2% | mainEntityOfPage | 24 | 0.9% | 报价字段的填写率很高,这符合预期——它直接关系到价格能不能出现在搜索结果里,有人盯。身份类的三个字段一路往下掉,掉到最后一个只剩0.9%。 把写了@id的那批再摊开看取值形态,情况更具体: @id的写法 | 取样节点数 | 后果 | 完整的绝对网址 | 461 | 正确用法 | 相对路径 | 348 | 解析方需要自己拼基地址,跨文档引用会失效 | 页内锚点,如#product | 2 | 只在本页内唯一,出了这一页认不出 | 就一个井号 | 5 | 等于没写 | 相对路径那348个值得单说。schema.org的数据模型说明 (https://schema.org/docs/datamodel.html)里,@id的作用是给这个节点一个全局唯一的标识,好让别的文档、别的数据源能引用同一个实体。写成相对路径的时候,这个标识就只在当前文档里成立了,跨文档那一半功能直接没了。 至于那5个只写了一个井号的——它们出现在allbirds的商品页上,每一页的主商品节点都是"@id": "#"。这个值语法合法、校验器不报错、富媒体测试也能过,唯一的问题是它谁也标识不了。 ## 把各站的@id摆在一起看,写法有五种 同一个字段,53个站写出了五种互不兼容的形态: 写法 | 样例 | 能不能跨文档引用 | 绝对地址加语义锚点 | untuckit的商品地址后面缀#json-ld-for-seo | 可以,且能区分同页多个实体 | 绝对地址加变体参数 | stanley的商品地址后面缀?variant=加编号 | 可以,变体之间也分得开 | 纯绝对地址 | avocadogreenmattress直接用商品页地址 | 可以,但同页只能有一个实体用 | 相对路径加锚点 | ritual的/products/xxx#product | 不行,出了这份文档就失效 | 一个井号 | allbirds的# | 不行,等于没写 | 前三种都是对的,只是详略不同。要紧的是后两种:它们看起来像是写了,在任何一份统计填写率的报告里都会被算成合格。如果你只统计这一栏填没填,53个站的@id覆盖率是44.2%;如果统计的是填了之后能不能用,要再砍掉三成。 这跟品牌字段那次实测 (https://zhangwenbao.com/product-vendor-brand-field-variants-audit.html)得到的是同一个结论:一个字段的填写率和它的可用性,是两个数。区别在于品牌栏至少还有人肉眼看得懂对错,@id写成一个井号,连看的人都不会觉得有问题。 ## 有一个站的两个节点用了同一张身份证 ritual.com抽到的7个页面,每一页都有同样的毛病:页面上有两个Product节点,两个的@id写的是同一个值,形如商品路径加#product。 这件事的后果比看起来严重。@id在这套数据模型里的含义是节点标识,两个节点写了同一个@id,规范上它们就是同一个节点——解析方会把两份属性合并成一份,同名属性谁覆盖谁,取决于实现。 换句话说,ritual想描述的是两样东西,交出去的是一样东西,而合并之后剩下哪些属性它自己不知道。这不是漏填的问题,是填了但把两个实体粘成了一个。 这类错误在校验器里很难暴露,因为每一个节点单看都合法。JSON-LD校验调试那篇 (https://zhangwenbao.com/json-ld-validator-syntax-debug-guide.html)处理的是语法层——尾逗号、引号、编码;@id撞车是语义层的问题,语法完全正确,含义已经变了。 顺带说,@id这个机制本来是为合并服务的。@graph与知识图谱那篇 (https://zhangwenbao.com/schema-org-advanced-graph-entity-knowledge-panel-mechanism.html)讲的正是它的正面用法:把一个组织写一次,其余节点用同一个@id指过去,几份标记就连成了一张图。ritual这个案例是同一个机制的反面——它没打算合并,但写法上等于下了合并的指令。 ## mainEntityOfPage和WebPage节点,有几个站写了? 除了给商品节点写地址,标准里还有两条更直接的路可以说明这一页在讲什么。 第一条是mainEntityOfPage (https://schema.org/mainEntityOfPage):在商品节点上写明它是哪个页面的主要内容。本批2700个Product节点里,写了的有24个,占0.9%。24个全部来自同一个站。 第二条是反过来写:先声明一个WebPage类型的节点 (https://schema.org/WebPage)代表这一页,再用mainEntity指向那件商品。本批392个页面里,写了WebPage一类节点的有40页,占10.2%,来自5个站,每个站8页——也就是说这5个站是整站都写,其余48个站一页都不写。 而在这40页里,带了mainEntity属性的页面数是0。它们都声明了这是一个网页,没有一个说清这个网页的主角是谁。 指认方式 | 覆盖情况 | 实际有效的 | canonical标签 | 96.8%的页面写了 | 94.9%指向自己 | Product节点带完整地址 | 56.9%的节点写了url | 50.9%的页面能确定主体 | WebPage节点 | 10.2%的页面写了 | 0页带mainEntity | mainEntityOfPage | 0.9%的节点写了 | 集中在1个站 | 这张表从上往下,是同一件事的四种表达方式,普及率相差近百倍。越靠下的写法越明确,用的人越少——不是因为它们难写,一行的事;是因为没有任何一个环节会因为缺了它而报错。 面包屑那次实测 (https://zhangwenbao.com/breadcrumb-hierarchy-three-maps-audit.html)量出人看到的路径和机器读到的只有16%对得上,成因也在这儿:格式全对、内容各写各的,中间没有一道会拦人的关口。 ## 结构化数据里的商品名和页面上的h1,对得上吗? 既然地址那条路一半的页面走不通,那还有一条软一点的线索:名字。如果结构化数据里的商品名和页面上的h1是同一个,机器至少能靠文字对上。 338个页面能做这个比对,303页对得上,33页对不上。把这33页拆开,成因分四类,每一类的性质完全不同: 成因 | 样例 | 严重程度 | HTML实体没解码 | 结构化数据里写着Farmer's Market Alphabet,页面上是Farmer's Market Alphabet | 字面不等,语义相同 | 取到的h1不是商品名 | 结构化数据写Custom Shampoo,页面第一个h1是Your cart | 页面结构问题 | h1里塞了别的信息 | 结构化数据写Body Wash,h1是Stone Body Wash 18oz|$8 | h1被当成了展示位 | 结构化数据里写的是变体名 | 页面在卖一款手表,节点名字是Gold Black / M | 主体标错了 | 第一类最常见也最无害,framebridge三个页面都是这个毛病——转义了两遍,把'当成字面量写进了JSON。它不影响理解,但会让任何字符串比对的自动检查报出假问题。 第二类值得展开。functionofbeauty的五个商品页,页面上第一个h1是Your cart——购物车抽屉的标题在DOM里排在商品标题前面。用户看不见它,因为抽屉是收起来的;程序按顺序取第一个h1,取到的就是它。首页的title、og:title和H1说的常常不是同一件事 (https://zhangwenbao.com/self-description-copies-title-og-h1-schema-audit.html)那篇量的是首页三处自我描述互不一致,这里是另一回事:h1本身没写错,是它在文档里的位置不对。 第四类最要命。ice-watch的四个页面,结构化数据里的商品名是Gold Black / M这种颜色加尺码的组合,页面h1才是真正的表名。这说明它的模板把变体对象当成了主商品来输出——不是名字对不上,是主体本身选错了。这一类跟品牌字段那次 (https://zhangwenbao.com/product-vendor-brand-field-variants-audit.html)看到的脏值直通是同一种传导:模板拿到什么就往外写什么,中间没有一步在问这个值合不合理。 ## 一个商品页应该有几个h1? 做上面那个比对的时候,顺手数了一遍h1的数量,结果自成一个话题: 页面上的h1数量 | 页数 | 0个 | 43 | 1个 | 255 | 2个 | 57 | 3个 | 36 | 4个 | 1 | 一个h1的占了65%,其余三分之一要么没有,要么不止一个。 多个h1这件事,规范上是允许的,搜索引擎也早就说过不会因此扣分。但它跟本文的主题直接相关:当一页有三个h1的时候,把h1当成商品名的那套自动逻辑就不成立了,无论是我的检查脚本,还是任何一个想从页面上认出商品名的程序。 没有h1的那43页更麻烦一些。rothys、tentree、decathlon、materialkitchen这几个站是整站没有——我复验过,等到22秒、滚动过页面,仍然是0个。这些站的商品名只存在于title标签和结构化数据里,页面正文那一层没有任何标题级别的信号。对一个按无障碍树来读页面的智能体 (https://zhangwenbao.com/ai-agent-accessibility-tree-not-pixels.html)来说,这一页是没有标题的。 ## canonical做对了,为什么JSON-LD这一层没有? 同一批页面,canonical的写入率96.8%,指向自己的94.9%;结构化数据里能确定主体的50.9%。两个数字差得这么远,值得想一想原因。 我的判断是三件事叠出来的。 ## 第一,canonical有过一次全行业的疼 参数页、分页、跨域抄袭这些问题在十几年里反复咬人,每一次都很痛,于是canonical从一个可选项变成了默认动作。建站平台默认输出它,SEO插件默认输出它,审计工具把缺canonical列成红色错误。结构化数据里的主体指认从来没有过这样一次疼——写不写,短期内看不出任何差别。 顺带一提,这也解释了为什么各种结构化数据生成器 (https://zhangwenbao.com/schema-generator-jsonld-13-types-guide.html)的默认产出里通常没有@id:它们按富媒体类型的必填项生成,而这一项不在必填项里。 ## 第二,检查工具不查这一项 富媒体测试和结构化数据校验器查的是必填字段有没有、格式对不对。@id缺失不报错,mainEntityOfPage缺失不报错,两个节点@id撞车也不报错。结构化数据审计工具那篇 (https://zhangwenbao.com/schema-extractor-structured-data-audit-guide.html)讲的字段缺漏检查,同样不包含这一层——因为它不属于任何一个富媒体类型的必填项。 ## 第三,主体指认的收益是延迟的 它的价值不在今天的富媒体结果上,在跨来源对齐上。schema堆满了但实体之间的关系没人审 (https://zhangwenbao.com/integrity-graph-relationship-completeness-ai-visibility-audit.html)那篇讲的是同一件事的上层:一个能被稳定引用的标识,才能让这件商品在不同数据源之间被认成同一件。当购物代理需要把你的商品页、你的商品数据文件和第三方的价格记录对上号时,这个标识就是那根线。 值得留意的是,AI会不会推荐根本不存在的商品 (https://zhangwenbao.com/ai-recommends-nonexistent-fake-brands-hallucinated-products-geo.html)那篇讨论的幻觉问题,根子有一部分也在这儿:当来源本身就说不清哪一件是哪一件,下游的拼接只能靠猜。 ## 自己的商品页怎么补上这个身份? 这件事的好处是改起来很便宜——它不新增任何内容,只是把已经存在的东西标清楚。 ## 第一步:先测一遍现状 在商品页的控制台里,把所有Product节点的url和@id取出来,跟location.href去掉查询参数之前的完整地址比一比。比对的时候一定要用完整地址,别学我第一版把参数扔掉。结果只有三种:正好一个对上、多个对上、一个都没有。 ## 第二步:给主商品节点写上三样东西 主商品节点上补齐@id、url和mainEntityOfPage,三个值都指向这一页的规范地址,跟canonical保持一致。@id用绝对网址,不要用相对路径,更不要用一个井号。商品条码栏那次实测 (https://zhangwenbao.com/product-identifier-cross-copy-mismatch-audit.html)比的是handle、规范地址和og:url这三处外部地址对不对得上,这一步是它的内部版本——把结构化数据里那份也接进同一个地址。 ## 第三步:给变体节点一个不同的标识 变体节点的@id要跟主商品区分开,通常是主地址加变体参数。这一步的目的不是让变体被单独收录,是防止出现ritual那种两个节点共用一个标识的情况。 ## 第四步:把这一项写进上线检查 因为没有任何现成工具会替你查,这一项必须自己加进检查清单。判据一句话:这一页的结构化数据里,有且只有一个节点的地址等于这一页的规范地址。多了是歧义,少了是没认领。 ## 顺带把url这一栏也理一遍 本批取样的Product节点里,url写成绝对地址的896个,写成相对路径的60个,另有479个带着查询参数。相对路径那60个跟@id同理,解析方得自己拼基地址;带参数的那479个多数是变体节点,它们本来就该带参数,问题不在这儿。 真正要检查的是主商品节点那一个:它的url应该是不带参数的规范地址,跟canonical一模一样。如果主节点的url上也挂着一个变体参数,那这一页交出去的主体就是某个具体尺码,不是这件商品——ice-watch那四页就是这么来的。 ## 什么时候不必做 如果你的商品页只有一个Product节点、没有变体展开、没有推荐位带出来的其它商品,那机器不会认错,这件事可以放到很后面。本批有近六成页面属于这种情况。页面类型声明那次实测 (https://zhangwenbao.com/ecommerce-page-type-declaration-audit.html)里类目页的声明率只有三分之一,对多数站来说,那一项的优先级比这一项高。 真正该马上做的是三种页面:变体展开超过十个的、带商品推荐位的、以及模板里挂了多个第三方标记来源的。这三种页面上,节点多到机器必须做选择,而你没有给它任何依据。 ## 常见问题解答 ## 不写@id,商品富媒体结果会受影响吗? 短期不会。@id不在任何富媒体类型的必填字段里,缺了不影响价格和评分的展示。它影响的是跨来源识别——当同一件商品出现在你的页面、你的商品数据文件和第三方记录里,一个稳定的标识决定了这几份数据能不能被认成同一件。 ## @id应该填什么值? 填这一页的规范地址,用完整绝对网址,跟canonical保持一致。如果同一页里有多个需要区分的实体,可以在地址后面加锚点作为后缀。要避免的是相对路径、纯锚点,以及一页里多个节点共用一个值。 ## mainEntityOfPage和WebPage的mainEntity,选哪个写? 写一个就够。前者是在商品节点上指向页面,后者是在页面节点上指向商品,方向相反、效果一样。多数商品页模板只输出一份商品标记,那么在商品节点上加mainEntityOfPage是改动最小的做法。 ## 变体节点也要写@id吗? 要写,而且必须跟主商品的值不同。变体节点最常见的做法是用主地址加变体参数。真正要避免的是所有变体共用一个@id,那等于告诉解析方它们是同一个东西。 ## 页面上有多个h1,需要改吗? 从排名角度看不需要,搜索引擎明确说过多个h1不扣分。但如果你的商品页第一个h1是购物车或者别的组件标题,建议调整DOM顺序,因为很多自动化工具默认取第一个h1当页面主题,这个错会一路传下去。 ## 怎么快速判断自己站上有没有这个问题? 看两个数就行:这一页有几个Product节点,其中有几个的完整地址等于本页地址。第一个数大于1、第二个数不等于1,就属于本文说的情况。这个检查在控制台里几行就能跑完,不需要任何工具。 ## 这件事和canonical有冲突吗? 没有,两者应该保持一致。canonical告诉搜索引擎这一页的规范地址,结构化数据里的@id和url告诉它这份标记描述的实体在哪儿。两处写同一个地址,信号才是一致的;写成两个不同的值,反而会制造新的歧义。 ## 权威参考资料 ## 表单提交交给谁?收邮箱那张表一半没写去向 - URL:https://zhangwenbao.com/form-submission-destination-audit.html - 分类:技术SEO - 发布:2026-09-09 | 更新:2026-09-09 - 摘要:页面上那个收邮箱的框,按下提交之后东西去了哪儿,源码里其实有位置可写。55个站的实测结果是:一半的框根本没写,另有一小批直接写着别人的域名,而隐藏域里还躺着访客的公网IP和名单标签。 - 关键词:技术SEO,电商SEO,网页语义化 > **TLDR**:摘要:接着上一篇的样本往下拆,这次数的是表单本身。55个站的191个页面上共有985张form元素,站内按签名去重后362张,其中当场有可见可填项的只有120张,33.1%。收邮箱那一格去重后182个,源码里写明了提交地址的85个——46.7%。剩下的一半,去向要么写在JavaScript里,要么根本不在这一页上。另有12个邮箱框直接把地址写成了别人的域名,767个隐藏域里躺着访客的公网IP、浏览器名字和名单标签。 > 摘要:接着上一篇的样本往下拆,这次数的是表单本身。55个站的191个页面上共有985张form元素,站内按签名去重后362张,其中当场有可见可填项的只有120张,33.1%。收邮箱那一格去重后182个,源码里写明了提交地址的85个——46.7%。剩下的一半,去向要么写在JavaScript里,要么根本不在这一页上。另有12个邮箱框直接把地址写成了别人的域名,767个隐藏域里躺着访客的公网IP、浏览器名字和名单标签。 上一篇数的是输入框自己说了什么:这一格要邮箱还是要电话,允不允许浏览器代填,空着能不能提交。表单字段那四件事 (https://zhangwenbao.com/form-field-declaration-audit.html)写不写,取决于这个框是谁加上去的。 这一篇往后挪一格:用户把字打完,按下那个按钮,东西去哪儿。 这件事页面上永远不会写。没有哪个站会在订阅框底下加一行小字说“你的邮箱将被提交到manage.kmail-lists.com”。但在源码里它是有位置的——form元素上的action属性,就是干这个的。所以这一轮的问题很单纯:那个位置上,到底写了什么。 ## 按下提交那一刻,浏览器照着谁的话做? 先把这条路径摊开。一个规规矩矩的表单提交,浏览器读三样东西:action决定往哪个地址发,method决定用GET还是POST,enctype决定怎么打包。三样都不写也能跑——按MDN对form元素的说明 (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form),action缺省是当前页地址,method缺省是GET。 但今天大部分站不走这条路。前端脚本会拦下提交事件,自己拿fetch把数据送出去。这时候action写什么都不影响真实去向,它变成一个纯粹的声明。 声明没用吗?有三个场合它照样管事: - 脚本还没加载完、加载失败、被拦截器挡掉的时候,浏览器会退回去照HTML做; - 读页面的程序——搜索引擎、比价插件、AI代理——只看得见HTML,看不见那段脚本的意图; - 你自己排查问题的时候,它是唯一一个不用打开调试器就能看到的线索。 所以这一轮量的是声明,不是流量。这里得先说清楚边界:本文没有真的提交过任何一张表单,所有结论都是“如果按HTML写的去做会怎样”。这一点跟代理能不能在你站上做事那一轮 (https://zhangwenbao.com/agent-actionability-html-audit.html)的口径一致——那次判的是机器能不能构造出请求,这次问的是这张表交给谁。 ## 这一屋子表单里,有几张是给人填的? 55个站、191个页面,取到985张form元素。同一个站里action、method、控件数、隐藏域名字、按钮文案全都一样的算同一张,去重后362张,每站中位6张。 然后按“有没有一格是给人填的”分开数: 这张表单长什么样 | 张数 | 占比 | 当场有可见可填项(真表单) | 120 | 33.1% | 有可填项,但当场一个都看不见 | 128 | 35.4% | 只有隐藏域,没有一格给人填 | 86 | 23.8% | 完全空的,一个控件都没有 | 28 | 7.7% | 三分之一是给人填的,剩下三分之二各有各的用途。第三档那86张最典型:action写着/cart/add的32张、/localization的17张、/cart的13张、/api/cart的7张。加购按钮是一张表单,切换国家和币种是一张表单,购物车更新是一张表单。那批/localization表单背后是地区与币种的切换,发货范围与退货范围对不上 (https://zhangwenbao.com/product-offer-geographic-scope-audit.html)那一轮量的就是这条线的另一头。它们借用form这个壳,是因为它天生就能带一堆参数走。 这个用法本身没毛病,反而是规规矩矩的老派写法。有意思的是比例:页面上的form元素,多数不是用来收用户输入的,是站点自己跟后端说话的通道。所以“这一页有几张表单”这个数,对判断用户体验几乎没有参考价值。 再看真表单那120张有多小:可见可填项只有1个的69张,2个的32张。八成的真表单是一两个格子的小东西——页脚订阅框、搜索框、登录框。那种十几个字段的长表单在这批站上是稀有物。搜索框那一格尤其典型,它是站上被用得最多的输入框,实现上却最单薄——站内搜索页那一轮实测 (https://zhangwenbao.com/site-search-result-page-landing-collapse.html)把它下游那一页的问题也数过一遍。 顺带记一笔:整整191个页面里,一个form元素都没有的只有7个,占3.7%;其中dreametech的四个页面全都没有。这跟首页正文空壳那一轮 (https://zhangwenbao.com/html-shell-page-readable-text-audit.html)的结论对得上,能读到的东西越少的站,越是什么标记都不写。 ## 收邮箱那张表,action写的是什么? 选邮箱这一格的理由跟上一篇一样:它是这批站上唯一一件人人都在做的事,51个站都有,横着比才公平。去重后182个邮箱框,先看它们挂在谁名下。 167个在某张form里,占91.8%;15个不在任何form里,占8.2%,出自10个站——aloyoga、cluse、everlane、gymshark、jackery、liquiddeath、mackweldon、misen、reebok、taylorstitch。这15个框,去向连个位置都没有。 剩下167个里,action那一栏是这么分布的: action写了什么 | 个数 | 占在form里的比例 | 压根没写(提交回当前页) | 82 | 49.1% | 写了本站地址 | 73 | 43.7% | 写了别人的域名 | 12 | 7.2% | 把不在form里的那15个也算进来,结论是:182个收邮箱的框里,源码里能看出去向的85个,46.7%。另外那一半,你在HTML里翻不到任何线索——得打开调试器,或者干脆去读那段打包过的脚本。 写了本站地址的那73个也值得看一眼。前几名是/account/login(18个)、/account/recover(15个)、/contact#contact_form(7个)。这些全是电商平台默认主题里的原件,跟上一篇那条规律接得严丝合缝:写得全的那些框,几乎都是平台替你写的 (https://zhangwenbao.com/form-field-declaration-audit.html)。 ## 跨域的那12个,去了哪几个域名? 12个不多,但它们是这一轮最干净的样本——因为它们把去向写在了明处。 - manage.kmail-lists.com:8个; - a.klaviyo.com:2个; - app.powerfulform.com:2个。 前两个是同一家邮件营销服务的两个入口,第三个是表单托管工具。涉及10个站:beistravel、fahertybrand、glossier、liquiddeath、magicspoon、marinelayer、monos、outdoorvoices、rothys、taylorstitch。 用户在这些站的页脚填一个邮箱按下订阅,浏览器发出的那个请求,收件人不是这个品牌的服务器。这件事从合规角度讲得通——服务商是数据处理方,隐私政策里通常也提到了;从用户视角讲,页面上一个字都没有。顺着这条线还有更早的一层:USENIX那份Leaky Forms研究 (https://www.usenix.org/conference/usenixsecurity22/presentation/senol)实测过十万量级的站点,发现相当一部分站在用户按下提交之前,邮箱就已经被页面上的第三方脚本取走了。action写给谁,只是这条链路上最后一环。 更值得注意的是这12个框在上一篇里的表现:第三方托管的那一档,名字写得最漂亮(94.6%有正经名字),autocomplete几乎为零(8.1%)。营销工具在乎表单看起来专业,不在乎浏览器能不能替用户少打二十个字符。第三方脚本两天半就自己变一批 (https://zhangwenbao.com/third-party-default-changes-silent-drift-audit.html)这件事在这里也一样成立:这12张表单的写法什么时候变、变成什么样,不由站点定。 顺带说一句,跨域这件事在同一批页面上还有个更极端的形态。parachutehome的商品页里藏着一张action指向https://www.facebook.com/tr/的表单,整张display:none,里面83到160个type=text的输入框,名字是id、ev、dl、ts、ud[external_id]这种——那是跟踪像素把自己的请求参数做成了输入框。它不收用户输入,它只是借form这个壳装数据。这一张在上一篇里差点把统计口径带沟里去,最后被单独剔了出来。 ## 隐藏域里装的到底是什么? 362张表单一共767个隐藏域,95.2%带着值,值长度中位数只有7个字符。按名字归类之后是这样: 装的是什么 | 个数 | 典型名字 | 表单自述(我是哪种表单、用什么方法) | 402 | form_type、utf8、_method、type | 商品与购物车 | 93 | product-id、id、quantity、selling_plan | 页面上下文 | 55 | return_to、checkout_url、page[href] | 地区与语言 | 47 | country_code、locale_code、options[prefix] | 来路追踪 | 27 | login_with_shop[analytics_trace_id]、$source | 名单与同意 | 19 | contact[tags]、$consent、Interest | 用户信息 | 11 | customer[email]、customer[id]、attributes[...] | 令牌与校验 | 1 | g-recaptcha-response | ## 名单标签:你被分进哪一档,写在框边上 contact[tags]这个字段出现11次,值很有意思:allbirds、awaytravel、drinkolipop、baseus写的是newsletter,casper写的是footer,url-homepage——它连你是从页脚还是从首页订阅的都记下来了;aloyoga写的是cc:SG,rc:SG,国家和地区各一份。taylorstitch那张表里还有个Interest字段,值是Men,另有一个$consent写着web。marinelayer的订阅表单里躺着一个Sign Up Source,值是Web Footer Sign-up。 这些都不是错。给订阅来源打标签是邮件营销的基本功。值得留意的只是一点:用户看到的是一个只要邮箱的框,实际提交出去的是邮箱加上一小串关于他的判断。这跟浏览器本地存储里那些服务器删不掉的键 (https://zhangwenbao.com/browser-storage-keys-server-cannot-delete-audit.html)是同一类东西:数据的边界比界面显示的宽。 ## 一个页面把访客的公网IP写进了隐藏域 flyingtiger的购物车表单里有一组attributes[...]:attributes[customer-account]值是logged out,attributes[browser-name]值是Google Chrome,attributes[public-ip-address]值是一个完整的公网IP地址。 这是把访客的环境信息预先写进了页面HTML,等表单提交时一并带走。技术上完全能理解——多半是给风控或者物流分区用的。但它意味着两件事:这个IP出现在了页面源码里,任何拿得到这一页的人都能读;而这一页如果被缓存住,缓存里也会留着一个具体访客的IP。HTML注释里那些构建日期和供应商名单 (https://zhangwenbao.com/html-comment-internal-traces-audit.html)是同一类泄漏,只是这一次泄的是用户自己的东西。 ## 最长的那个值有2589个字符 italic的商品页上有个叫cartFormInput的隐藏域,值是一大段JSON,最长的一份2589个字符,里面装着整个购物车的商品清单。同一个站的另一个隐藏域叫analytics,值也是JSON,开头是{"products":[{"id":"gi...。liquiddeath的_keyLabel是582个字符。 这些字节每次渲染都要发一遍,而且是不可缓存的那一部分——首页字节里58%缓存寿命为零 (https://zhangwenbao.com/html-inline-bytes-cache-lifetime-audit.html)那一轮量过这笔账,隐藏域里的JSON正是其中一种。 ## 没写method的那些,如果那段脚本没接住会怎样? 167个在form里的邮箱框,method这一栏:POST的101个,明确写GET的9个,一个字没写的57个。没写就是GET,这是标准定的缺省值。 加起来66个邮箱框,按HTML写的做的话会走GET。GET与POST的差别 (https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Sending_and_retrieving_form_data)就在这一步:GET提交意味着表单数据被拼进地址栏的查询串——邮箱会出现在URL里,出现在浏览器历史里,出现在服务器访问日志里,还会跟着Referer头传给这一页上的第三方资源。 现实中这几乎不会发生,因为那段脚本会拦下提交。但“几乎”这个词值多少钱,取决于你的脚本有多可靠:CSP白名单漂移会把脚本拦下来 (https://zhangwenbao.com/csp-script-src-allowlist-drift-third-party-silent-block.html),SRI哈希对不上脚本会一行都不执行 (https://zhangwenbao.com/subresource-integrity-third-party-script-update-breakage.html),广告拦截器和企业代理也会。这些场景下,浏览器会老老实实照HTML里写的那句话去做。 所以这一栏该怎么填,其实是句大白话:你希望脚本挂掉的时候发生什么,就在这两个属性里写什么。写POST加一个真实能收数据的地址,最坏情况是页面跳转一次但数据收到了;什么都不写,最坏情况是把用户的邮箱糊在地址栏上。 ## 原始HTML里没有、渲染之后才冒出来的表单 每个页面在读完渲染后的DOM之后,又原样取了一次它的HTML源码,两边对照。190个页面里: - form数量一致的71个,37.4%; - 渲染后变多的81个,42.6%; - 渲染后反而变少的38个,20.0%; - 原始HTML里一张form都没有、渲染之后才有的27个。 input这一层差距更大:数量一致的只有19个页面,渲染后变多的139个。 变多好理解——弹层、抽屉、评价组件都是脚本插进来的。变少那20%才需要解释一下:脚本会把服务端渲染的那一份替换掉,重建时合并了几张表,或者干脆把某些不用的删了。只看HTML看不到那8个第三方域名 (https://zhangwenbao.com/third-party-domain-dependency-invisible-in-html-audit.html)说的是同一件事的另一面:你以为在读页面,其实读的只是页面的初始状态。 这对做审计的人有个直接后果:拿curl抓一份HTML去数表单,会漏掉四成多的页面上的东西;但只读渲染后的DOM,又会看不见那20%被替换掉的原件。两边都得看。 ## 表单里那个叫id的字段,把表单自己的名字顶掉了 这一轮的脚本第一版在44个页面上直接崩了,报错是“这个值上没有replace方法”。查下去发现的东西,比那个报错本身有意思得多。 HTML标准的表单控件章节 (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html)给form元素开了个后门:表单里那些控件的name和id,会被挂成表单对象自己的属性,而且优先级高过表单原本的属性。商品页上的加购表单通常有个装着变体ID,于是读form.id读回来的不是字符串,是那个输入框元素本身。 全样本里275张表单实例中招,出自33个站。被顶掉的属性绝大多数是id(274次),还有1次是submit——那张表单里有个name叫submit的控件,把表单的提交方法顶掉了,脚本要是调用form.submit()就会当场报错。 这个坑对写审计脚本的人是个直接教训:读表单属性一律用getAttribute,别用点号。对做站的人则是另一条:给控件起名字的时候,避开id、name、action、method、submit这几个词。字段名的写法能引出多少麻烦 (https://zhangwenbao.com/product-vendor-brand-field-variants-audit.html),这算是最物理的一种。 ## 读数取决于你等多久、点没点 上面所有百分比,量的都是同一个状态:页面载入后等9秒左右、不做任何操作。这个状态到底有多不稳,另挑了29个首页做对照,同一个地址读三次——等5秒、再等20秒、然后替用户点一下页面上的订阅或搜索入口。 对照 | 读数变了的页面 | 只多等20秒,form数变了 | 17.2% | 只多等20秒,可填控件数变了 | 20.7% | 点一下之后,form数变了 | 26.1% | 点一下之后,可填控件数变了 | 56.5% | 点了完全没变化 | 39.1% | 总量上看更直白:这29个首页上的可见可填控件,等5秒时37个,等25秒时42个,点一下之后70个。接近一半的输入框,是在有人动手之后才存在的。 几个具体的:gymshark首页原本一个form都没有,点开那个写着Email Sign Up的按钮之后冒出1张表单7个控件;jackery点完订阅按钮,form从2张变成5张;aloyoga点开账号入口,控件从2个变成6个;brooklinen什么都不点,光是多等20秒,form就从1张变成3张。 反过来,39.1%的页面点了也没有任何变化——那些框本来就在DOM里,点击只是掀开了一层样式。这跟隐藏内容那份裁决表 (https://zhangwenbao.com/hidden-content-machine-readability-verdict-audit.html)讲的是同一件事:藏起来有很多种藏法,机器算不算数要看藏法。 ## 照着改,先动哪几处? 四条,按性价比排。 第一条,给每个收邮箱、收询盘的表单补上一个真实可用的action和method。不是为了让浏览器去提交,是为了脚本挂掉那一刻页面还有个说法。做法很简单:后端提供一个能收表单编码数据的端点,写成method="post" action="/xxx",前端脚本照旧拦截。这一步只花几分钟,收益是把一半查不到去向的表单变成查得到。 第二条,把那些不在任何form里的输入框放回form里。182个邮箱框里有15个是孤儿,10个站中招。放回去顺手就能解决一串问题:回车键能提交了、密码管理器认得出来了、代理程序能看出这几个格子是一组了。 第三条,翻一遍自己站上的隐藏域,看看有没有本来不该出现在HTML里的东西。判据是问三个问题:这个值是不是关于某一个具体访客的?它会不会被缓存住?拿到这一页的人读到它要不要紧?公网IP、账号状态、完整的购物车JSON,都得按这三条过一遍。合规架构那篇 (https://zhangwenbao.com/seo-legal-compliance-gdpr-ccpa-consent-mode-cross-border-architecture.html)讲的同意与数据边界,落到实现层就是这一堆。 第四条,做审计时两边都读。只读HTML漏掉四成多,只读渲染后的DOM看不见被替换掉的两成。保哥自己那套体检脚本现在是固定跑两遍,两份对不上的页面单独列出来人工看,成本不高,能挡掉大部分误判。 最后说一句选型上的判断。form这个元素在很多前端框架里已经被当成可有可无的壳,数据用状态管住,提交用fetch发出去,看起来更干净。但这一轮的数据摆在这儿:那个壳是页面上唯一一处能写下“这些格子是一组、它们要去哪儿”的地方。省掉它,省的是十几个字符,丢的是这一页对外唯一的一份说明书。换个平台只是换一批老代码 (https://zhangwenbao.com/obsolete-html-tags-platform-fossil-audit.html)那篇讲过前端习惯的惯性有多大,这一条同样是惯性的产物。代理时代那笔账 (https://zhangwenbao.com/agent-actionability-html-audit.html)已经算过一次,这里只是再补一个更基础的注脚。 ## 常见问题解答 ## 表单本来就是JavaScript提交的,action写不写真的有区别吗? 有。三种场合它会真的生效:脚本没加载完就有人按了回车、脚本被CSP或者拦截器挡掉、用户的网络中途断了又恢复。另外读页面的程序只看HTML,你写了它就知道这张表往哪儿提交,不写它只能猜。成本是十几个字符,不值得省。 ## 订阅表单直接提交到邮件服务商的域名,有没有问题? 技术上没问题,合规上要看你有没有在隐私政策里把这家服务商列为数据处理方。真正要注意的是另一件事:这类表单的HTML由服务商生成,它的字段声明写得好不好、什么时候变,都不由你定。本轮那12个跨域邮箱框的autocomplete覆盖率只有8.1%,就是这么来的。 ## 隐藏域里放商品ID、来源标签这些,算不算多余的字节? 算,但通常值。要警惕的是两类:一是关于具体访客的值,比如IP、账号状态、完整购物车JSON,它们会跟着页面一起被缓存;二是几千字符的大块JSON,那部分字节每次渲染都要重发。判据是问一句:这个值能不能在提交那一刻由脚本现算出来。 ## 怎么快速查一遍自己站上的表单去向? 控制台里跑一句[...document.querySelectorAll('form')].map(f=>[f.getAttribute('action'),f.getAttribute('method'),f.elements.length])就能看个大概。注意一定要用getAttribute,别写f.action——表单里只要有个name叫action的控件,点号读回来的就是那个控件而不是地址。 ## 为什么页面上form元素那么多,能填的却那么少? 因为form这个元素在电商模板里被大量用作参数容器:加购、切换国家币种、更新购物车,全都是一张表单。本轮362张去重后的表单里,只有隐藏域的86张,完全空的28张。所以“这一页有几张表单”对判断用户体验没什么参考价值,得按有没有可见可填项再分一次。 ## 用curl抓HTML来做表单审计够不够? 不够,但也不能不抓。本轮190个页面里,渲染后form数量变多的占42.6%,只抓HTML会漏掉这一批;而渲染后变少的占20.0%,只读DOM又会看不见原件。两份都取,对不上的单独看,这是目前最省事的做法。 ## 权威参考资料 ## 一张礼品卡的结构化数据119KB,同一段配送政策抄了60遍 - URL:https://zhangwenbao.com/product-page-jsonld-node-inventory-audit.html - 分类:技术SEO - 发布:2026-09-08 | 更新:2026-09-08 - 摘要:商品页往外发的结构化数据有多大?53个海外独立站392个商品页实测,中位4KB,最大一页232KB,多出来的部分多半是模板循环里被复制几十遍的店铺配置。 - 关键词:结构化数据,技术SEO,电商SEO > **TLDR**:摘要:53个英文独立站的392个商品页,逐页把结构化数据取下来数了一遍。一页的JSON-LD中位数是4217字节,可四分之一的页面超过13KB,最大的一页有237214字节。最大那几页装的东西很有意思:一张电子礼品卡把同一段配送政策和退货政策照着60个面额抄了60遍,一件毛衣的每个尺码都带着一份门店营业时间,一个旅行箱品牌把82条导航菜单写进了每一个商品页。全样本13383个节点分属44种类型,而在一个页面里,真正描述这件商品本身的那个节点,中位数只占整页节点的10%。 > 摘要:53个英文独立站的392个商品页,逐页把结构化数据取下来数了一遍。一页的JSON-LD中位数是4217字节,可四分之一的页面超过13KB,最大的一页有237214字节。最大那几页装的东西很有意思:一张电子礼品卡把同一段配送政策和退货政策照着60个面额抄了60遍,一件毛衣的每个尺码都带着一份门店营业时间,一个旅行箱品牌把82条导航菜单写进了每一个商品页。全样本13383个节点分属44种类型,而在一个页面里,真正描述这件商品本身的那个节点,中位数只占整页节点的10%。 ## 一个商品页往外发多少字节的结构化数据? 这个数我以前没量过,凭感觉估的是两三KB。毕竟一件商品能写的东西就那么多——名字、价格、库存、图片、品牌,撑死了几十行。 实测下来,中位数确实是4217字节,跟感觉差不多。但中位数在这件事上基本没有参考价值。 样本是53个英文独立站,每个站挑8件商品,用真实浏览器打开商品页,等页面渲染完再把所有script[type="application/ld+json"]的内容取下来,逐个节点数。最后有392个页面拿到了可解析的结果。 指标 | 中位 | P75 | P90 | 最大 | JSON-LD字节数 | 4217 | 13447 | 26518 | 237214 | 节点数 | 16 | 38 | 83 | 636 | Product节点数 | 1 | 6 | 15 | 251 | script标签数 | 2 | 3 | 4 | 6 | 从中位到最大,字节数差了56倍。这不是几个离群点的问题,是整批数据本来就分成两个世界:一半的站老老实实写商品,另一半在往这块地方倒东西。 ## 按体积分档,两头都不小 这一页的JSON-LD | 页数 | 占比 | 一个字节都没有 | 12 | 3.1% | 不到1KB | 25 | 6.4% | 1到4KB | 154 | 39.3% | 4到10KB | 84 | 21.4% | 10到30KB | 86 | 21.9% | 超过30KB | 31 | 7.9% | 接近三成的商品页,光结构化数据这一块就超过10KB。作为参照,一份写得完整的商品标记——名字、描述、图片、品牌、一个报价、一条评分——大约在800到1500字节。 也就是说,超过10KB的那批页面,多写出来的部分是必需内容的七倍以上。这些字节要经过压缩、传输、解析,还要占掉抓取时读到的那部分预算。页面体积实测46个电商站那次 (https://zhangwenbao.com/html-byte-budget-crawl-truncation-audit.html)量的是整页HTML的字节,这次量的是其中专门写给机器看的那一块。 这一块有个特点:它不出现在任何一张设计稿上,改版评审也没人打开看。DOM元素数量那次实测 (https://zhangwenbao.com/dom-element-count-static-vs-runtime-audit.html)好歹还有个开发者工具的元素面板能翻,结构化数据连翻的人都没有——它躺在源码里,谁也不打扰谁,直到有一天它比商品描述还长。 ## 最大的那一页有232KB,里面装的是什么? 把最大的几页拆开看,比看分布有意思得多。 页面 | 字节 | 节点 | 节点构成 | rothys.com的一双平底鞋 | 237214 | 636 | 250个Product+250个Offer+131个单价说明 | rothys.com的另一款 | 155148 | 412 | 204个Product+204个Offer | hellotushy.com的电子礼品卡 | 119029 | 486 | 60个Offer,每个各带7个附属节点 | stanley1913.com的保温杯 | 118735 | 190 | 42个变体+43个Organization+42份退货政策 | taylorstitch.com的针织衫 | 100461 | 332 | 30个变体,每个带10个附属节点 | 第一页那双鞋,636个节点里有250个是它的尺码。这批节点每个只写了一个网址,名字、图片、描述、报价一律空着——它们存在的唯一意义是告诉机器这个尺码有个地址。 顺带一提,这一页的可见正文只有1479个字符。232KB的机器可读数据,配1479个字符的人类可读文字,比例是160比1。这个站的商品信息几乎全在图片和交互组件里,纯文本那一层很薄。一个只读文字的抓取方来到这一页,能拿走的东西还没有它读进去的零头多。 ## 先把边界划清楚 这一篇量的只有JSON-LD,不含微数据和RDFa。这个取舍会让某些站的数字偏低——比如hellotushy,我复验的时候在它一个页面上数出211个带itemtype的微数据元素,图集、图片对象、问答全在那一层,而JSON-LD里一个都没有。一次扒清页面五种格式的字段缺漏 (https://zhangwenbao.com/schema-extractor-structured-data-audit-guide.html)那篇讲过为什么审计工具要同时认五种格式,原因就在这儿。 另外三个站的数据从统计里剔掉了,理由各不相同:dreametech的8个页面全部返回Access Denied,那是反爬拦住了我,不是它没写;oclean的8个商品地址全部跳到了越南站首页,拿回来的根本不是商品页;ugreen的商品地址在采集时全部404。这三个站留在样本里,得出的每一个百分比都是假的。 ## 44种类型摊开,哪几种把节点数撑起来了? 全样本13383个节点,分属44种schema.org类型。前几名是这样: 类型 | 节点总数 | 出现在多少页 | Offer | 3007 | 362页(92%) | Product | 2550 | 366页(93%) | Organization | 1020 | 295页(75%) | QuantitativeValue | 760 | 72页(18%) | SiteNavigationElement | 656 | 8页(2%) | Brand | 420 | 326页(83%) | Question/Answer | 各414 | 37页(9%) | MerchantReturnPolicy | 389 | 55页(14%) | 这张表最值得看的是第三列。Offer出现在92%的页面上,属于正常配置;而SiteNavigationElement有656个节点,却只出现在8个页面里——平均每页82个。QuantitativeValue也一样,760个节点挤在72页。 换句话说,节点数的膨胀不是普遍现象,是少数站的少数几种类型在集中发力。 ## 一个有用的旁证:Organization和Product几乎一样多 1020个Organization节点,2550个Product节点。一个商品页需要几个Organization?一个就够,写清楚卖家是谁。可stanley1913那个保温杯页面上有43个,正好等于它的变体数——每个变体的报价里都完整重写了一遍卖家信息。 这不是它一家的做法。凡是把变体展开的站,只要报价里写了seller,Organization就会跟着翻倍。商品品牌字段那次实测 (https://zhangwenbao.com/product-vendor-brand-field-variants-audit.html)发现同一个品牌能被写成好几种拼法,这次看到的是同一个卖家被原样复制几十份,两件事的成因是同一个:这些结构是模板循环里拼出来的,没有人从整页的角度看过最终结果。 ## 一件商品的配送政策,为什么被抄了60遍? hellotushy那张电子礼品卡是本批最干净的样本,因为它只有1个Product节点,膨胀跟变体完全无关。 它的486个节点是这么构成的:60个Offer,对应礼品卡的60个面额。每个Offer下面挂着一份OfferShippingDetails、一份MonetaryAmount、一份DefinedRegion、一份ShippingDeliveryTime、一份MerchantReturnPolicy,外加两份QuantitativeValue。60乘以7,420个节点。 一张电子礼品卡是不需要配送的。但模板不管这个,模板只知道每个报价都要带上店铺的配送与退货配置,于是60个面额各拿到了一份一模一样的物流承诺,连退货政策都齐全——一张发到对方邮箱里的电子卡,配了60份退货说明。 taylorstitch那件针织衫更进一步:30个尺码,每个尺码除了上面这套,还额外带一份OpeningHoursSpecification。一件毛衣的每个尺码,都在告诉搜索引擎这家店周一到周日几点开门。不是这家店开三十次门,是这个信息被抄了三十遍。 这类节点本身没写错。配送和退货承诺那次实测 (https://zhangwenbao.com/shipping-return-promise-human-vs-machine-readable.html)说过,把承诺写成机器可读是件好事,136个首页里只有11个做到了。问题在于写的位置——它应该挂在店铺层,不该跟着每一个报价复制一遍。 ## 这个坑几乎全是模板循环造成的 我把这类重复的成因归了三类,每一类的修法完全不同: 成因 | 典型表现 | 怎么改 | 店铺级配置写进了报价循环 | 每个Offer带一份配送与退货政策 | 提到Organization或ProductGroup那一层,用引用而不是复制 | 卖家信息写进了报价循环 | Organization数量等于变体数量 | 同上,写一次,其余用@id引用 | 站点级结构写进了每个页面 | 导航菜单出现在商品页 | 直接删,这一类不该出现在商品页 | 第一类和第二类有个共同的正解:JSON-LD支持用引用代替复制,把重复的对象写一次,其余地方指过去就行。这一步能把上面几个例子的体积砍掉八成以上,而且不丢任何信息。 ## 导航菜单为什么会出现在商品页的结构化数据里? awaytravel的每一个商品页都带着82个SiteNavigationElement节点,8个页面一个不差。这是全样本里唯一出现这种类型的站。 schema.org对这个类型的定义 (https://schema.org/SiteNavigationElement)是网页上的导航区块,用来描述站点结构。它本身是合法的,历史上也确实有过用途——早年Google的站内链接展示会参考这类标记。 但那个展示形式后来被下线了,这批标记现在既不产生任何搜索结果特征,也不帮助理解页面主体,只是每次加载都跟着走一遍。82个节点,占了那个页面全部节点的一半以上。 类似的还有WebSite加SearchAction那一组,57个页面写了。这一组当年是为站内搜索框准备的,那个特性同样已经不在了。 这批标记有个共同点:它们不是错的,只是它们服务的那个功能已经不存在了。删掉不会有任何损失,留着也不会被罚,于是就一直留着——14个不相干的站发着同一份给老浏览器的补丁 (https://zhangwenbao.com/legacy-browser-bundle-factory-default-audit.html)是同一个道理,没人负责给这类东西定一个下线日期。 这类东西在结构化数据里格外容易囤积,因为它没有反馈。写错一个价格,前台会崩;多写82条导航,谁也不会知道,除非有人像这次一样把节点数出来。Schema官方第一次公开全网使用数据 (https://zhangwenbao.com/schema-org-usage-statistics-priority-guide.html)之后,判断该做哪些类型总算有了参照系,可判断该删哪些类型,仍然没有现成的清单。 ## 这些Product节点是从哪儿长出来的? 2550个Product节点,按它们在JSON里的位置归类: 位置 | 节点数 | 是什么 | hasVariant下 | 2157 | ProductGroup展开的尺码与颜色 | 根节点 | 445 | 这一页真正在卖的那件 | @graph里的hasVariant | 49 | 同上,只是包了一层@graph | itemListElement.item | 18 | 面包屑或列表里带出来的 | 其它 | 31 | 报价里的itemOffered、评分里的itemReviewed | 八成半来自变体展开。这件事本身是Google在商品变体文档里明确推荐的做法 (https://developers.google.com/search/docs/appearance/structured-data/product-variants):用ProductGroup加hasVariant把一组变体表达清楚,让搜索结果知道这件衣服有哪些颜色可选。 所以这不是错。要区分清楚:变体该不该有独立网址、canonical往哪儿指 (https://zhangwenbao.com/product-variant-seo-url-canonical-indexation-strategy.html)是另一个问题,WooCommerce那套三层治理 (https://zhangwenbao.com/woocommerce-product-variations-seo-schema-canonical-url-deep-dive.html)讨论的也是网址与索引。这一篇不碰网址,只数节点——同一个决定,在网址那一层是收敛还是发散的选择题,在字节这一层是一道加法题。 ## 展开到251个,还是不是那个推荐做法 rothys那双鞋展开了250个尺码节点。文档里的示例通常是几个到十几个变体,没有讨论过展开两百多个之后会怎样。 值得注意的是,这250个节点里没有一个写了名字、图片或描述,只有一个网址。Google对商品结构化数据的字段要求 (https://developers.google.com/search/docs/appearance/structured-data/product)里,name和image是拿到商品富媒体结果的底线。这批节点一个都不满足,它们进不了任何展示形式。 那它们的作用是什么?告诉机器这个ProductGroup有250个成员,各自的地址在哪。这个信息有价值,但用250个空壳节点来表达,代价是把这一页撑到232KB。 ## 同一个站,热门款和长尾款的差距有多大? 每个站我都取了三件主推款和两件长尾款——按图片数加标签数排序,最多的和最少的。同一个站、同一套模板,唯一的变量是这件商品本身有多少东西可写。 分组 | 页数 | 字节中位 | 节点中位 | 没有Product节点的页 | 热门款 | 157 | 7767 | 25 | 4 | 长尾款 | 99 | 2325 | 9 | 14 | 差3.3倍。51个能配成对的站里,43个是热门款更大,7个反过来,1个持平。 这个方向是符合直觉的:热门款变体多、评价多、图片多,写出来自然长。但它带来一个不太直觉的推论——你在自己站上抽查结构化数据的时候,如果只抽首页推荐的那几件,量到的数字会系统性地偏高;如果只抽最新上架的,又会系统性地偏低。 更值得留意的是最后一列。长尾款里有14页一个Product节点都没有,热门款只有4页。同一个站、同一套模板,长尾商品掉队的概率高出一截——这跟商品描述空值那次 (https://zhangwenbao.com/product-description-empty-field-attribution-audit.html)看到的方向一致:缺东西的商品,往往是被整体冷落的那一批。 这一条对做集合页与AI购物 (https://zhangwenbao.com/shopify-collection-page-geo-ai-shopping.html)的人尤其要紧。主推款有完整标记、长尾款连Product节点都没有,意味着一个按品类问过来的购物代理,能看清的永远是你已经在卖爆的那几件。 ## 有多少商品页干脆什么都不写? 392个页面里,12页一个ld+json标签都没有,占3.1%。23页有标签但没有任何Product节点,占5.9%。 零JSON-LD那批里,tentree是唯一一个整站如此的。我不太敢信这个结果——一个正经在卖货的品牌站,商品页一行结构化数据都不写,听起来更像是我没等到它渲染完。 所以我拿它单独复验了两轮。第一轮换成等网络空闲、再多等20秒,结果更糟:这个站压根等不到网络空闲,90秒超时,一个页面都没拿回来。第二轮改回等DOM就绪,把等待拉到22秒,中间滚动一次触发懒加载,同时查JSON-LD和微数据——三个不同的商品页全部200,script标签0个,itemtype元素0个,连一个h1都没有。这个站的商品页确实什么标记都没写。 顺带一说,第一轮失败本身也是条经验:等网络空闲这个条件在电商站上经常等不到,因为埋点和推荐组件会一直发请求。它看起来是更严谨的等待方式,实际更容易一无所获。 这件事的代价很直接:拿不到商品富媒体结果,进不了免费商品列表,AI购物代理读这一页的时候只能从纯文本里猜价格和库存。页面类型声明那次实测 (https://zhangwenbao.com/ecommerce-page-type-declaration-audit.html)发现产品页的声明率有94%,tentree属于剩下那6%里最彻底的一种。 ## 另外7个页面的JSON解析直接失败 这7页分布在4个站:graza占4页,untuckit、getquip、functionofbeauty各1页。它们的ld+json标签是有的,内容也在,但JSON.parse抛错——对搜索引擎来说,这跟没写是一个效果。 graza那4页的情况尤其值得说:它每页有5个ld+json标签,坏的只是其中1个,剩下4个能正常解析,所以在多数审计工具里这一页会显示为有结构化数据、字段齐全。一个尾逗号就能让整页结构化数据失效 (https://zhangwenbao.com/json-ld-validator-syntax-debug-guide.html)那篇讲的是单个标签内部的语法,这里补一条:一页有多个标签的时候,坏掉的那个通常是最晚注入的那个第三方应用,而不是主题自己输出的那份。 这类问题跟插件和主题各输出一套结构化数据 (https://zhangwenbao.com/cms-seo-plugin-theme-tag-conflict-duplicate-canonical-title-og-schema-cleanup.html)是一条线上的两个症状:多方往同一个位置写东西,谁也不知道最终拼出来是什么样。区别在于那一篇处理的是内容打架,这里是其中一方直接写坏了语法。 ## 我这把尺子量到的,是等了4秒之后的那一版 这一节讲我自己的口径问题,它比上面任何一个百分比都值得记。 主采集的流程是:打开页面,等DOM就绪,再等4秒,然后取JSON-LD。4秒这个数是抄的上一批参数,当时够用。 做自基线复验的时候,我把同一批网址重新跑了一遍,这次等22秒。三个页面里,两个的结果一模一样:rothys那双鞋两次都是237214字节,stanley那个保温杯两次都是118735字节。尺子是稳的。 第三个不一样。awaytravel那个旅行箱,第一次是5个script标签26518字节,第二次变成7个标签33785字节,多出来27%。多出来的那两个标签是异步注入的,4秒内没到。 页面 | 等4秒 | 等22秒 | 差异 | rothys平底鞋 | 237214字节 | 237214字节 | 0 | stanley保温杯 | 118735字节 | 118735字节 | 0 | awaytravel旅行箱 | 26518字节 | 33785字节 | +27% | brooklinen床品 | h1有0个 | h1有1个 | 结论会反转 | 最后一行最要命:同一个页面,等得短一点,我会写下这个站的商品页没有h1;等得久一点,结论就反了。 这个坑的一般形式是:凡是异步注入的东西,你量到多少取决于你等了多久,而不同的读者等的时间不一样。我等4秒,Googlebot等多久没有公开数字,AI爬虫多数根本不执行JavaScript。AI爬虫抓不到JS渲染那次实测 (https://zhangwenbao.com/js-rendering-ai-crawler-citation-rate-csr-ssr-isr-divergence.html)量的正是这个落差。 所以本文这批数字的正确读法是:它们是一个等了4秒的浏览器看到的版本,是下限。真实体积只会更大,而对不执行JavaScript的抓取方来说,真实体积只会更小。量出74%的页面对爬虫和用户不一样、补上对照组之后只剩2.2% (https://zhangwenbao.com/page-content-jitter-baseline-seo-diff-false-positive.html)那次的教训在这儿同样成立:任何一个跨页面的差异,先问它是不是自己抖出来的。 ## 自己站上这份清单怎么数? 不用装工具,浏览器控制台一行就够:把页面上所有ld+json标签的内容拼起来看长度,再递归数一遍带@type的对象有多少个。数出来之后按下面四步走。 ## 第一步:先看总量落在哪一档 拿本文那张分档表当基准。落在1到4KB是常态,超过10KB就该往下拆一层,看是哪种类型的节点在贡献数量。 ## 第二步:按类型排序,找出现次数远超页面数的那几种 判据很简单:一个商品页需要几个Organization?一个。需要几份退货政策?一份。需要几条导航菜单?零条。凡是数量明显超出这个常识的类型,都是模板循环漏出来的。 ## 第三步:把重复的对象改成引用 店铺信息、配送政策、退货政策、品牌,这四类在整页里应该只有一份实体,其余位置用@id指过去。这一步不改变任何语义,只减字节。 ## 第四步:删掉那些没有对应展示形式的类型 SiteNavigationElement、WebSite加SearchAction这两组,现在都没有对应的搜索结果特征了。Google的结构化数据通用准则 (https://developers.google.com/search/docs/appearance/structured-data/sd-policies)要求标记内容与页面主体相关,这两类跟商品页的主体没有关系。 ## 一个反例:什么时候不该删 变体展开是唯一一类我建议先别动的。它虽然贡献了85%的Product节点,但确实在为商品变体展示服务。真要减,方向不是删节点,是给每个变体补上name和image让它们够格进展示,或者在变体数量特别多的时候只展开有代表性的那一批——两百多个尺码全展开,展示端也用不上。 怎么估这一步能省多少?拿本批的样本算给你看:stanley那个保温杯有42个变体,每个变体的报价里带一份卖家信息和一份退货政策。这两类各留一份、其余用引用,节点数从190掉到106左右,字节能砍掉一多半,而进入展示形式的字段一个没少——因为展示端读的是根节点那一份。保哥的判断标准很简单:一个对象在这一页里被完整写了两遍以上,它就该变成引用。 ## 什么时候这套检查不该做 如果你的商品页JSON-LD在4KB以内、节点数在20以内,这件事的优先级很低,不值得排进这个季度。结构化数据里价格全写了、规格只有7个站写 (https://zhangwenbao.com/product-spec-field-value-pairing-audit.html)那种缺字段的问题,收益比减字节大得多。字节这一层只在两种情况下值得动手:整页已经逼近抓取的字节上限,或者你在做DOM元素数量 (https://zhangwenbao.com/dom-element-count-static-vs-runtime-audit.html)那一类的整体瘦身,顺手一起做。 ## 常见问题解答 ## 商品页的结构化数据太大,Google会不会因此不收录? 不会因为体积本身不收录。但结构化数据是HTML的一部分,会计入整页字节,而抓取时读取的字节是有实际上限的。如果整页已经很大,这一块又占了几十KB,被截断的风险就是真实的。判断方法是先看整页体积落在哪个区间,再看这一块占了多少。 ## 变体全部展开成Product节点,到底该不该做? 该做,这是官方推荐的表达方式。要注意的是两点:一是每个变体节点最好带上name和image,只有一个网址的空壳节点进不了任何展示形式;二是变体数量特别多的时候,全部展开的边际收益很低,把体积撑大的代价却是实打实的。 ## 用@id引用代替复制,会不会影响搜索引擎理解? 不会。用引用把重复对象合成一份是JSON-LD的标准用法,解析方会把引用还原成同一个实体。真正需要注意的是@id的取值必须在整页内唯一且稳定,写错了才会出问题。 ## SiteNavigationElement现在还有用吗? 没有对应的搜索结果展示形式了。它不会被判为违规,但也不产生任何收益。放在首页可能还有一点表达站点结构的意义,放在每一个商品页上就纯粹是负担。 ## 一页有多个ld+json标签,是不是也算问题? 数量本身不是问题,本批的中位数就是2个。要留意的是标签越多,其中一个语法出错而其余正常的概率越高,这种情况在多数审计工具里不会报出来。检查的时候要逐个标签解析,不能只看整页有没有结构化数据。 ## 只统计JSON-LD会不会漏掉很多? 会。本批就有站把图集和问答全放在微数据里,JSON-LD一个字都没写。做自己站的审计时,五种格式都要扫一遍,否则容易得出错误结论。 ## 怎么知道搜索引擎实际读到的是哪一版? 用能渲染的测试工具跑一遍,跟自己在浏览器里看到的对比。差异通常出在异步注入的第三方标记上——你等得久,它有;抓取方等得短,它没有。这一层的差异用肉眼在源码里是看不出来的。 ## 权威参考资料 ## 商品描述空了5537件,其中2542件出自同一个站 - URL:https://zhangwenbao.com/product-description-empty-field-attribution-audit.html - 分类:技术SEO - 发布:2026-09-08 | 更新:2026-09-08 - 摘要:商品描述缺了12.6%听着像笔该还的债,可里面有五分之一根本不用写。43912件商品实测,教你把这笔账算准了再派工单。 - 关键词:技术SEO,电商SEO,内容SEO > **TLDR**:摘要:43912件在售商品里有5537件没有商品描述,占12.6%。剔掉一个站之后这个数字掉到7.2%,剔掉五个站掉到4.7%——2542件空值出自同一家,它整站99.5%的商品都不用这一栏。逐条归因之后,确认不该算的有1247件——非商品条目、半成品和占位块,没有一件需要写文案。剩下的4290件才是真缺,占9.8%;把那个整站不写描述的站剥出去,其余站的漏填是1748件,八成挤在7个站上。另一头还有978件商品的描述超过5000字符,最长的一条55238字符,抵得上一篇白皮书。 > 摘要:43912件在售商品里有5537件没有商品描述,占12.6%。剔掉一个站之后这个数字掉到7.2%,剔掉五个站掉到4.7%——2542件空值出自同一家,它整站99.5%的商品都不用这一栏。逐条归因之后,确认不该算的有1247件——非商品条目、半成品和占位块,没有一件需要写文案。剩下的4290件才是真缺,占9.8%;把那个整站不写描述的站剥出去,其余站的漏填是1748件,八成挤在7个站上。另一头还有978件商品的描述超过5000字符,最长的一条55238字符,抵得上一篇白皮书。 ## 43912件商品里,有5537件的描述栏是空的 这是一个看上去很值得开会的数字:12.6%的在售商品没有商品描述。 样本是60个英文独立站的公开商品清单,最后56个站拿到了完整数据,走的是那个不用登录就能拿走250条的出口 (https://zhangwenbao.com/shopify-products-json-open-data-endpoint-audit.html)。描述字段读的是商品对象上那个装商品正文的字段 (https://shopify.dev/docs/api/liquid/objects/product),字符数为0就算空。43912件里有5537件为0,一件不多一件不少。 商品描述值不值得较真,这事没什么争议:它是商品详情页上唯一能做出差异的那块内容 (https://zhangwenbao.com/product-detail-page-onpage-seo-unique-content-engineering.html),同品类的参数、价格、图片在几百个站上长得都差不多。所以“缺了12.6%”听上去确实像笔该还的债。 如果这份报告到此为止,接下来的动作大概是给内容团队派一张“补齐12.6%商品描述”的工单,按每件20分钟估,5537件是1845个工时。 那张工单一开头就错了。这5537件里有1247件根本不需要文案,而剩下的4290件也不是一种毛病——其中2542件出自同一个站,是那个站从建站起就没打算写商品描述;散在其余站上的只有1748件,八成还挤在7个站里。 把这三档拆开各花了一步,最后一步得靠浏览器打开真页面才做得成。下面按顺序走一遍。 ## 这个12.6%,为什么剔掉一个站就掉到7.2%? 先做一件最省事的事:按站算一遍,然后从最严重的那个开始往外拿,看整体数字怎么走。 样本范围 | 空描述/商品数 | 整体空率 | 全部56个站 | 5537 / 43912 | 12.6% | 剔掉marinelayer.com | 2995 / 41356 | 7.2% | 再剔掉peakdesign.com | 2844 / 41148 | 6.9% | 再剔掉casper.com | 2720 / 40933 | 6.6% | 再剔掉parachutehome.com | 1931 / 39362 | 4.9% | 再剔掉framebridge.com | 1832 / 39123 | 4.7% | 拿掉一个站,行业指标腰斩。marinelayer一家贡献了2542件空描述,占全部空值的45.9%,而它只占样本商品的5.8%。这个站的2556件商品里有2542件描述为空——空率99.5%。它不是漏填,它是整站不用这个字段。 五个站拿掉之后,剩下51个站的整体空率是4.7%。同一批数据,同一个字段,12.6%和4.7%都是对的,区别只在于你有没有先看分布。 ## 空描述的56个站,是同一种毛病吗? 不是。把56个站按空率分档,形状是两头沉的: 该站空率 | 站数 | 商品数 | 其中空描述 | 正好0% | 17 | 14614 | 0 | 0%到2% | 10 | 13430 | 61 | 2%到10% | 10 | 4130 | 265 | 10%到30% | 11 | 5806 | 1062 | 30%到60% | 6 | 3168 | 1456 | 60%以上 | 2 | 2764 | 2693 | 27个站——将近一半——在这件事上几乎是满分:28044件商品里只有61件空。另一头2个站的2764件商品里空了2693件。中间那段才是常规意义上的“有点漏”。 把两头拉平取一个12.6%,等于把“完全不用这个字段的站”和“偶尔漏填的站”按人头平均。这跟行业榜单上那个前1%到底是几家 (https://zhangwenbao.com/industry-ranking-top-1-percent-denominator-slicing.html)是同一类账:平均数本身不撒谎,它只是把你最需要看见的那件事盖住了。 ## 那5537件空,拆开是哪几种空? 我给每一件空描述的商品跑了一遍归因,判据按优先级排:先看它所在的站是不是整站不用这个字段,再看它的地址像不像占位或测试,再看这条记录是不是压根不是货,再看它其余字段是不是也空着,都不是才算“像是真漏了”。 这是哪一种空 | 件数 | 占5537件 | 站级:这个站根本不用这一栏 | 2542 | 45.9% | 这条根本不是货 | 1001 | 18.1% | 整条记录都是半成品 | 221 | 4.0% | 条目本身是占位或测试 | 25 | 0.5% | 剩下的,像是真漏了 | 1748 | 31.6% | ## 站级:这个站根本不用这一栏 2542件,全部出自marinelayer一家。一个站99.5%的商品都不填这一栏,这不是漏,是这个站压根不这么用它。 但“不这么用”有两种可能,处理方式正好相反:一种是描述换了个地方存,比如放在主题模板或者自定义字段里,那前台是有文案的,这一栏空着没关系;另一种是整站就真的没有商品描述。接口数据分不出这两种,得去页面上看。这个问题先挂着,后面有一节专门回答它。 ## 这条根本不是货 1001件,18.1%。这一类最有意思。我用一组关键词扫商品的类目、品牌、地址和标题,命中的有2296件(占全样本5.23%,分布在52个站):运费险、延保、礼品卡、面料小样、碳中和抵扣、订阅计划、赠品捆绑。 典型长相是这样的:free-returns-coverage、alo-e-gift-card-black、womens-runner-protect-natural-black。它们在商品库里是商品,在结算流程里是一行价格,但它们没有商品描述是完全正常的——没人需要为一张礼品卡写三百字文案。 这2296件的空描述率是43.9%,而未命中的普通商品只有10.9%,前者是后者的4倍。这条差距本身就是判据:如果你的空描述里大比例是这类条目,那不是内容欠债,是统计口径没把非商品排除掉。 顺便解释一下上面那张表为什么写的是1001而不是1008:归因是按优先级往下走的,有7件非商品条目本身就在marinelayer那个整站不用这一栏的站里,已经被前一档收走了。两个数字都对,分母不同而已。 ## 占位、测试和半成品 占位与测试类只有25件,0.5%,比我预期的少得多——地址里带spacer、placeholder、test、copy-of这类词的商品,绝大多数其实是有描述的。另有221件是“整条记录都是半成品”:描述空、类目空、标签也是0。这221件才是真正意义上的建了个壳没填完。 ## 剩下1748件,是确定要补的那一批 31.6%。折算到全样本是3.98%。 先说清楚这个数字是什么:它是下限,不是最终答案。因为上面那张表里最大的一档还悬着——marinelayer那2542件到底该不该算,接口数据回答不了。所以现在手里有两个数:确定要补的1748件,和待定的2542件。 不管待定那批怎么落,能确定的是1247件该扣:非商品条目1001件、半成品221件、占位25件。这1247件占了全部空值的22.5%,它们在任何一份按“字段为空”出的报告里都会被算成内容欠债,而其中没有一件需要写文案。 我之前在商品价格那一栏做过一次类似的清算,六条报警扣到最后一条都不剩 (https://zhangwenbao.com/product-price-copies-false-positive-audit.html)。这次不一样:扣完还剩一大半,而且能指出确定的那部分在哪: 站点 | 真该补的件数 | 占该站商品 | parachutehome.com | 453 | 28.8% | ugreen.com | 363 | 27.2% | bollandbranch.com | 230 | 10.5% | brooklinen.com | 111 | 23.6% | framebridge.com | 99 | 41.4% | chubbiesshorts.com | 98 | 5.5% | awaytravel.com | 92 | 8.9% | 前七个站占了这1748件里的1446件,82.7%。剩下的散落在二十来个站上,每站几十件。 ## 接口里是空的,页面上就一定没字吗? 这是整篇里唯一不能靠数据库回答的问题。字段为空只说明那一栏没存东西,不说明商品页上没有文案——很多主题把商品正文放在自定义字段里,前台照样渲染得好好的。 先说清楚这一节不是在查渲染。JS渲染那三件担心的事 (https://zhangwenbao.com/js-rendering-loss-three-myths-audit.html)和首页是不是空壳 (https://zhangwenbao.com/html-shell-page-readable-text-audit.html)我都单独量过,那两次问的是“机器读不读得到”。这次问的是另一件事:数据库里那个空,和页面上那段字,到底是不是同一件事的两面。 按schema.org对description属性的定义 (https://schema.org/description),商品页的结构化数据里也有一个描述位。这个位置比接口更接近搜索引擎实际读到的东西,所以我拿它当尺子:用真实浏览器打开商品页,等渲染完成,把JSON-LD里Product节点的description取出来,再取一份页面的meta描述,跟接口值三方对照。 158个页面打开了151个。其中接口描述为空的有41个,非空的110个,正好构成一组同批同法的对照。 ## 接口说空的,一多半页面上也是真的空 接口描述为空的41个页面 | 页数 | 占比 | 结构化数据和meta描述都是空的 | 24 | 58.5% | 只有meta描述有内容 | 15 | 36.6% | 结构化数据和meta都有内容 | 2 | 4.9% | 作为对照,接口描述非空的110个页面里,87.3%的结构化数据有描述,93.6%的meta有描述。两组差得非常干净:接口有的,页面上九成也有;接口没有的,页面上近六成也真的没有。 这个结果推翻了我开工时的假设。我原本以为“字段为空只是描述换了个地方存”会是主要情况,实测下来它只占4.9%。那24个三处都空的页面,是货真价实的没有商品说明——用户打开看到的只有标题、图片、价格和一排尺码。 前面挂起来的那个问题,这里可以回答了:marinelayer抽到的两件空值商品,页面上的结构化数据和meta描述同样是空的。它不属于“描述换个地方存”,它属于第二种——整站就不写商品描述。那2542件不是口径问题,是2542个只有图和价格的商品页。 这一步没做之前,我差点把这2542件当成误报扣掉。“站级空率高就是架构选择”这个判断听起来很合理,但它只是一个假设,需要有人去页面上验一次。验完发现,最像误报的那一档,恰恰是全样本里最扎实的一块真缺失。 ## 把账重新算一遍 待定那批落地了,最终的账是这样: 口径 | 件数 | 占43912件 | 字段为空(最初的读数) | 5537 | 12.6% | 确认不该算的(非商品、半成品、占位) | 1247 | 2.8% | 本该有描述而没有 | 4290 | 9.8% | 其中:一个站的整站决策 | 2542 | 5.8% | 其中:其余站的零散漏填 | 1748 | 4.0% | 三个数字,三种用途。12.6%不能用,它把不是货的东西也算成了欠债。9.8%是这批站真实的内容缺口。而如果你要给内容团队派工单,该看的是最后那一行——把marinelayer这种整站策略问题剥出去之后,其余站的漏填率是1748件除以41356件,4.2%。 这三个数落在同一批数据上,谁也没算错。区别在于问题问得对不对:字段状态、内容缺口、可派工的活,本来就是三件事。 ## 中间那36.6%才是真正需要小心的一档 只有meta描述有内容的那15个页面,情况介于两者之间:主题拿别的字段(通常是标题加类目,或者一段站点通用文案)给meta兜了个底。这一段能进搜索结果的摘要,但它不是商品描述,几乎不携带这件商品独有的信息。 换句话说,这15个页面在任何“meta描述覆盖率”的检查里都会显示为合格,可它们和那24个真空页面的实际内容量是一个水平。拿覆盖率当尺子的第二个坑就在这儿——上一个坑是把不该算的算进来,这个坑是把该算的漏出去。 ## 顺手量到的另一件事:填了也未必全都进去 接口描述非空、且结构化数据里也有描述的页面有96个。把两边的字符数对一遍:只有10个页面长度基本一致,85个页面结构化数据里那段明显更短。 主题在往结构化数据里写description的时候做了截断,通常截到一两百字符。这不算错——Google对摘要长度本来就有实际限制 (https://developers.google.com/search/docs/appearance/snippet)——但它意味着一件事:你写的那八百字商品文案,进结构化数据的只有开头一小段。如果重要信息写在第三段,它在这条通道上是不存在的。 还是要说清楚抽样的边界。每个站取四件,只够判断“这个站属于哪一类”,不够估计任何一个站内部的比例。上面所有百分比的分母都是页面数,不是商品数;前面几节涉及商品比例的结论,全部来自43912件的全量接口数据,没有用这批抽样。 ## 缺描述的商品,是不是别的地方也缺? 为了避开两头极端站的干扰,这一节只看空率在2%到60%之间的那27个站,在同一批站内部做对比——同站比同站,不跨站比。 指标 | 描述为空的商品 | 有描述的商品 | 件数 | 2783 | 10321 | 一张图都没有 | 7.0% | 2.7% | 一个标签都没有 | 8.9% | 1.9% | 类目也是空的 | 33.6% | 28.0% | 图片数中位 | 3 | 5 | 标签数中位 | 6 | 11 | 变体数中位 | 1 | 2 | 六项全部同向。没有描述的商品,图更少、标签更少、变体更少。它不是一个字段漏了,是这件货整体被冷落了——建档的时候就没人认真对待过它。 这个结论把处理方式又改了一次:如果你打算按缺描述这条线去补,那么补的时候顺手把图和标签一起看了,返工成本比分三轮跑低得多。反过来,这也意味着单靠“描述是否为空”这一个信号,就能相当准地圈出一批被冷落的商品——它比人工挑选省事。 ## 新上架的商品反而更容易没描述 还是这27个站,按商品的建档年份分组: 建档年份 | 空描述 | 有描述 | 空占比 | 2026 | 1032 | 3537 | 22.6% | 2025 | 1350 | 4158 | 24.5% | 2024 | 301 | 1754 | 14.6% | 2023 | 42 | 405 | 9.4% | 2022 | 17 | 202 | 7.8% | 越新越空。2025和2026建档的商品空描述率是22%到24%,2022和2023只有8%到9%,差了近3倍。 这跟直觉是反的——直觉说老货才会被遗忘。但老货是熬过来的:它们经历过完整的上架流程,也经历过后来每一轮内容整理;新货还在流水线上,缺的那部分还没来得及补。我在按上架时间给43912件商品排队那次 (https://zhangwenbao.com/product-age-created-at-audit.html)得到过同向的结论:sitemap收录和站内可达那两项,被漏掉的也不是老货,是新货。同一批数据,两个不相干的维度,指向同一件事。 顺带纠正一个常见的做法。内容审计通常从内容剪枝 (https://zhangwenbao.com/content-pruning-deletion-consolidation-redirect-decision-framework.html)那一头切进去,先找旧的、找衰减的。放在商品库上,这个顺序会让你从最不缺的地方开始查。分类页那边的年份分布 (https://zhangwenbao.com/collection-age-published-at-audit.html)也是类似的形状:最老的那批反而最规矩,出问题的在中间和最近。 ## 另一头:有人把整个落地页塞进了这一栏 说完空的,说满的。全样本里描述超过5000字符的有978件,集中在9个站,其中两个站几乎全员超标: - ugreen.com:1336件商品里有814件超过5000字符,占60.9%,该站描述长度中位数7067; - baseus.com:218件里147件超标,占67.4%,中位数7853。 作为对照,全样本有描述的38375件,长度中位数是462字符。最长的一条55238字符——一条商品描述,抵得上一篇中等长度的白皮书。 这两个站不是文案写得比别人勤快十五倍,是把整个商品落地页——版式、表格、样式、参数矩阵——全塞进了这个字段。它现在同时是内容容器和页面构建器。HTML体积过大导致商品链接被截断 (https://zhangwenbao.com/html-byte-budget-crawl-truncation-audit.html)这件事,源头往往就在这儿;而把这么长一段折进标签页 (https://zhangwenbao.com/product-page-horizontal-tabs-adjacency-requirement.html)之后,用户能不能读到又是另一笔账。 字段被挪作他用这件事,在商品数据里不是孤例。条码栏里填变体ID的后八位 (https://zhangwenbao.com/product-identifier-cross-copy-mismatch-audit.html)是一种,品牌栏里填着开发环境的名字 (https://zhangwenbao.com/product-vendor-brand-field-variants-audit.html)是另一种——那一篇量的是同一批43912件商品的另一栏,结论正好反过来:那一栏一件没空,可填进去的东西同样不能直接用。空和满是同一个毛病的两面,这个字段没人定过规矩。 把空的和满的放在一起看,这一节才有意义:一个站全不用它,两个站拿它当页面构建器,中间的站按它本来的用途填一段文案——三种用法并存,说明这个字段从来没有过一个约定。而所有跨站的“描述覆盖率”统计,都默认了这个约定存在。 ## 这一栏到底该由谁负责填? 数据到这里就说完了,剩下是怎么处理。前面那些百分比之所以难看,根子不在文案能力,在于没人认领这个字段。 商品描述横跨三个岗位:运营建档的时候要填一版,内容团队后来要润色一版,投放那边还要一版符合购物数据规范 (https://support.google.com/merchants/answer/7052112)的。三方都写过,三方都不认为自己是最终责任人,于是新品上架时那一栏最容易被跳过——反正后面还有人补。而“后面那个人”是谁,多数团队没写在任何文档里。 保哥见过一个五百多SKU的家居站,情况和上表里的parachutehome很像:新品空描述率接近三成,老品几乎不空。翻工单记录才发现,2024年之前上新走的是内容团队排期,之后为了提速改成运营直接建档、内容团队每周回补,而那个每周回补的动作在半年前的一次人员交接里丢了,没人发现,因为没有任何一个报表在盯这个数。补完之后他们做的第一件事不是写文案,是给上架流程加了一道卡口:描述为空的商品不允许进入待发布队列。 ## 自己的站怎么把这笔账算准? 七步,顺序不能乱。中间那几步都是在往下扣,扣完才知道该派多少工。 - 先按站或按品类分组算,别先算总数。如果你管着多个站或多条产品线,总数没有意义。分组之后把空率超过90%的那一组单拎出来先放着,它的性质要等第五步才能定——千万别在这一步就当架构问题扣掉,本文最大的一次差点出错就在这儿。 - 把非商品条目扣掉。运费险、延保、礼品卡、面料小样、赠品、订阅计划、碳中和抵扣。用类目和地址里的关键词扫一遍就能圈出绝大部分,本次样本里这一类占了全部商品的5.23%、占空描述的18.1%。 - 把占位和测试条目扣掉。地址里带spacer、placeholder、test、dummy、copy-of、temp的。数量通常不多,但留着会污染后面每一个百分比。 - 把整条都空的记录单拎出来。描述、类目、标签同时为空的,那是建档没完成,该退回给建档流程,不是派给写文案的人。 - 用浏览器给每一组抽验前台。每组取一件空值商品和一件有描述的商品打开页面,看结构化数据里的description和meta描述有没有内容。这一步是给第一步收尾的:页面上有字,那一组是存储位置问题;页面上也没字,那一组就是实打实的缺内容。本次样本里那个99.5%的站,就是在这一步从“待定”变成了“真缺”,2542件全部回到工单里。 - 剩下的排成工单。按站或按品类排序,你会发现它高度集中——本次样本里除去那个整站问题,其余的漏填有八成挤在7个站上。 - 给上架流程加卡口,别指望回补。新品空率高于老品这件事,本身就说明回补机制不可靠。把校验放在发布前,比放在报表里有用得多。 最后提一句排序。这些商品真要补的时候,别按字母序也别按建档时间序——先看哪些商品从首页点得到 (https://zhangwenbao.com/product-two-click-reachability-audit.html),从有流量入口的那批开始补,比从头补一遍的回报快得多。 ## 常见问题解答 ## 商品描述为空,Google会不会直接不收录这个页面? 不会直接不收录。一个只有标题、图片和价格的商品页照样能进索引,也能出现在搜索结果里。真正的损失在别处:这一段文案通常是整个商品页上唯一属于你自己的原创内容,缺了它,页面剩下的东西——参数、价格、图片——在同品类的成百上千个页面里高度雷同。Google生成搜索摘要 (https://developers.google.com/search/docs/appearance/snippet)时也会优先从页面正文取材,没有正文就只能拿别的凑。 ## 那接口里没有、页面上有,算不算问题? 不算问题,但算一个必须记录在案的口径差。它的直接后果是:任何基于商品导出或者接口做的内容审计,在这个站上都会给出一个虚高的缺失率。真正需要警惕的是反过来的情况——接口里有、页面上没有,那说明主题没渲染这一段,用户和搜索引擎都看不到,那才是真丢了。 ## 礼品卡、运费险这类条目,要不要写描述? 面向用户的那一句要写,面向SEO的那一段不用。这类条目的价值在结算流程里,不在搜索里。更该做的是别让它们进sitemap、别让它们出现在分类页的商品数统计里,也别让它们参与你的内容覆盖率计算。本次样本里它们占了全部商品的5.23%,比例不大,但足以让一份报告的结论偏掉一档。 ## 为什么新品的空描述率反而比老品高? 因为老品是筛选后的幸存者。它们走完了完整的上架流程,还经历了之后每一轮内容整理和批量补齐;新品还在流程中间,缺的部分尚未被任何一轮回补动作覆盖。这个规律有个直接用处:内容审计不该只盯着陈旧页面,最近三到六个月新建的商品才是缺口最集中的地方。 ## 把整个页面塞进描述字段,除了体积还有什么坏处? 三个。一是这段内容会被原样带进结构化数据和商品数据源,带着表格和样式,下游解析容易出错;二是它绑死了平台,换主题或者迁站的时候这批内容基本没法复用;三是同一份文案在页面上和数据源里各有一份副本,改一处另一处不跟,时间一长必然对不上。真正需要复杂版式的商品,用页面模块搭,别用这一个字段扛。 ## 用无头浏览器抽验,抽多少件才够? 先按站抽,每站至少一件空值商品加一件有描述的商品做对照,这一步是判断这个站属于哪一类,不是估比例。确认了类型之后,只有需要估比例的那几个站才值得加量,每站三十件起。本文用的是每站四件的量级,够回答“这个站属于哪一类”——把那2542件从待定改判成真缺,靠的正是这一层;不够回答“这个站有百分之几”,所以正文里所有的比例,分母都是43912件的全量接口数据,没有一个是从这批抽样外推的。 ## 这批数字能直接拿去跟自己的站比吗? 比例不能,方法能。样本是56个跑在同一个电商平台上的英文独立站,品类偏服饰、家居和户外,多数是自营品牌。换平台字段语义就变了,换品类非商品条目的比例也会变。可以直接拿走的是那套四步扣减法和判据顺序,比例请在自己的库上重算。 ## 权威参考资料 ## 商品品牌字段一个空的都没有,17个站把自己写成了好几个牌子 - URL:https://zhangwenbao.com/product-vendor-brand-field-variants-audit.html - 分类:技术SEO - 发布:2026-09-08 | 更新:2026-09-08 - 摘要:搜索引擎认为你的商品是哪个牌子?后台那个品牌栏可能填了好几个答案。56个海外独立站43912件商品实测,一半重名是自己造的。 - 关键词:结构化数据,技术SEO,电商SEO > **TLDR**:摘要:56个英文独立站的43912件在售商品,品牌字段填写率100.0%,一件没漏。可37个站的商品品牌不止一个答案,非主品牌的3237件里有57.1%只是同一个牌子换了个写法;190件商品的品牌栏写着None,28件写着Exclude,还有21件带着DEV和production这样的环境标记。用真实浏览器打开商品页读结构化数据,130个页面里有118个把接口里那个值一字不差地交了出去,包括一句带冒号的广告语、一个写着DEV的开发环境名,以及一家画框店的企业客户。 > 摘要:56个英文独立站的43912件在售商品,品牌字段填写率100.0%,一件没漏。可37个站的商品品牌不止一个答案,非主品牌的3237件里有57.1%只是同一个牌子换了个写法;190件商品的品牌栏写着None,28件写着Exclude,还有21件带着DEV和production这样的环境标记。用真实浏览器打开商品页读结构化数据,130个页面里有118个把接口里那个值一字不差地交了出去,包括一句带冒号的广告语、一个写着DEV的开发环境名,以及一家画框店的企业客户。 ## 商品数据里的品牌那一栏,为什么56个站一个空的都没有? 先说个让人放心的数字:43912件在售商品,品牌字段的填写率是100.0%。一件都没漏。 这批数据来自60个英文独立站的公开商品清单接口,最后有56个站拿到了完整清单,读的是商品对象上那个叫vendor的字段 (https://shopify.dev/docs/api/admin-rest/latest/resources/product)。做技术审计的人看到这种数字,通常会在检查表上打个勾就翻页了——覆盖率满分,这一项没问题,下一项。 但我把这一栏的取值摊开数了一遍,发现56个站里有37个,商品的品牌不止一个答案。再往下拆,那些“多出来的品牌”里,超过一半根本不是别的牌子,是这个站自己的名字换了个写法。还有190件商品,品牌栏里工工整整地填着两个英文单词:None。 填写率和可用性是两回事,这中间的距离,这篇要一步步量出来。 技术SEO的检查表上,字段类的项目几乎清一色按“填没填”打分——结构化数据里价格64个站全写了、规格只有7个 (https://zhangwenbao.com/product-spec-field-value-pairing-audit.html)那次,我用的也是这把尺。这把尺在多数字段上够用,因为多数字段填错了会当场报错:价格填成字母,前台直接崩;日期填成汉字,校验器立刻叫。品牌栏不叫。它接受任何字符串,你写什么它存什么,包括你的开发环境名字。 ## 一栏全填了,为什么还有37个站对不上? 先把口径说清楚。我给每个站算了一个“主品牌”——就是这个站的商品里出现次数最多的那个品牌值。56个站里,19个站从头到尾只有这一个值,干干净净。剩下37个站,商品的品牌值不止一种。 非主品牌的商品一共3237件,占全样本的7.4%。这个数字乍看不大,落到单站上就很难看了:迪卡侬那个站,占比最高的品牌值只覆盖37.6%的商品;一个叫menuspace的家居站,主品牌只占54.9%,全站商品分给了44个不同的品牌值——平均每19件货换一个牌子。 站点 | 商品数 | 品牌值个数 | 主品牌占比 | menuspace.com | 821 | 44 | 54.9% | fahertybrand.com | 2293 | 13 | 97.3% | decathlon.com | 518 | 11 | 37.6% | taylorstitch.com | 3000 | 10 | 93.2% | avocadogreenmattress.com | 245 | 9 | 56.7% | framebridge.com | 239 | 9 | 56.1% | marinelayer.com | 2556 | 6 | 96.9% | wusthof.com | 444 | 5 | 69.8% | 看到这张表的第一反应大概是“这些站在卖别人的货”。有几个确实是,但只是少数。真正的答案要把这3237件拆开才看得见。 ## 多出来的那些品牌,拆开看是哪三类? 我给这3237件做了一次分类。判据很朴素:把品牌值去掉重音符号、把与号换成and、砍掉一批常见的修饰尾巴(公司后缀、地区代码、Official、Site、Store之类),再只保留字母和数字。归一之后如果和主品牌撞上了,就算同一个牌子;如果这个值本身的字面意思就是“没填”,单独算一类;剩下的才是真的别的牌子。 类别 | 件数 | 占非主品牌商品 | 同一个品牌换了个写法 | 1847 | 57.1% | 字面写着“没填” | 218 | 6.7% | 真的是别的牌子 | 1172 | 36.2% | ## 一半以上是同一个品牌换了个写法 1847件,57.1%。这些商品的品牌值和主品牌指的是同一个东西,只是拼法不同。把这一类归并掉之后,56个站的品牌值总数从211个降到189个,塌缩了10.4%;更直观的说法是:原本只有19个站是单一品牌,做完归一化之后变成25个——有6个站的“多品牌”是自己造出来的。 ## 有一批的字面意思就是“没填” 218件。品牌栏里写的是None、Not specified、Exclude这类词。它们通过了任何一个“非空校验”,但没有携带一个字节的品牌信息。这一类下面单独有一节讲,因为它比想象中有意思。 ## 剩下的才是真的别的牌子 1172件,36.2%。这一类是正常的,甚至是应该的——迪卡侬本来就是子品牌矩阵,家居买手店本来就卖设计师作品。问题不在于它们存在,而在于它们和前面两类混在同一个字段里,谁也分不出谁。 ## 同一个品牌能写出几种花样? 把1847件那一类逐条做字符级比对,差异类型是这样分布的: 差在哪 | 件数 | 占比 | 多带一截修饰词 | 1227 | 66.4% | 大小写、空格或标点 | 417 | 22.6% | 多带一个地区后缀 | 182 | 9.9% | 多带一个环境标记 | 21 | 1.1% | ## 多带一截修饰词,占了三分之二 最典型的是把品类词带进品牌名:Chubbies这个站有467件商品的品牌写作Chubbies,其余的写作Chubbies Shorts;Snow Peak有285件写作Snow Peak Apparel,其余的就是Snow Peak;Stanley的商品在Stanley和Stanley 1913之间分了两拨,124件挂在短的那个上。 Away这个行李箱站更彻底,三种写法并存:Away Travel、Away,还有106件商品的品牌栏里塞的是Away: Built for modern travel——那不是品牌名,那是一句广告语,中间还带着冒号。 ## 大小写、空格和那个与号 Wüsthof这个德国刀具站把自己的名字写出了三种:Wüsthof、WÜSTHOF、Wusthof——重音符号有和无、大小写全大和首字母大,排列组合玩了个遍。Baseus有36件商品的品牌是全小写的baseus。Mack Weldon有204件把中间那个空格省了,写成Mackweldon。TUSHY和Tushy各自占了一批。 Tuft & Needle则贡献了本次样本里最丰富的一组:Tuft & Needle、Tuft and Needle、Tuftandneedle、Tuft & Needle CA,加上后面要说的那个,一共5种。一个床垫品牌,在自己的商品库里有5个名字。 ## 地区后缀是第二常见的分叉 Baseus同时存在Baseus、Baseus US、Baseus EU;Our Place有7件是Our Place - US;Harry's的主值干脆就是Harry's US,另外7件才是Harry's。这类值往往是多店铺同步时带过来的,源站的地区标记跟着商品一起搬了家,没人在落地那一头把它摘掉。 保哥经手过一个从欧洲往北美开分店的配件站,两边共用一套商品库。北美店建档的时候,有人顺手在品牌名后面加了个US做区分,纯粹是为了后台筛选方便。这个习惯延续了大半年,直到投放那边发现Merchant Center里冒出了两个品牌,各自攒各自的历史数据,谁也没攒够。改法不复杂——品牌栏统一回一个值,地区区分挪到自定义标签上去。值得记的是,那个加US的人从头到尾没做错任何一步,他只是用了一个没人告诉过他不能这么用的字段。 ## 品牌栏里写着DEV和production,这批货还在卖 21件,比例只有1.1%,但它是这一节里最不该出现的: - Monos有10件商品的品牌值是Monos (DEV); - Tuft & Needle有11件写着Tuftandneedle-production。 括号里的DEV是开发环境,后缀里的production是生产环境。这两个词本来只该活在部署脚本和环境变量里,现在它们跟着商品记录一起躺在公开的商品清单接口里,任何人不用登录就能拿到。这个出口本来就是敞开的 (https://zhangwenbao.com/shopify-products-json-open-data-endpoint-audit.html),我之前专门量过它一次能端走多少东西。 这跟HTML注释里那些构建日期和供应商名单 (https://zhangwenbao.com/html-comment-internal-traces-audit.html)是同一类事故:内部标记跟着交付物一起出门了,只是这次它挂在一个搜索引擎会认真读的字段上。 ## 品牌名写成None的那190件,到底是什么货? Taylor Stitch这个站有166件商品的品牌值是字符串None,另有24件是Not specified。合计190件,占该站3000件商品的6.3%。 我原以为这是一批半成品记录——建了个壳,字段还没填完。数据不同意这个猜测:这190件里,商品描述为空的只有10件,商品类目为空的一件都没有。它们的类目老老实实写着Wovens、Outerwear、Knits、Pants、Accessories,是真的在卖的衣服。 ## 其中有一批的网址以spacer开头 把这190件的网址前缀数一遍,出现最多的两个是the(95件)和spacer(22件)。后面这22件长这样:spacer-usa-made-clothing、spacer-the-indigo-shop、spacer-all-footwear,还有一件叫copy-of-spacer-the-jack-01——连复制出来的占位块都留着。 spacer在前端行话里是占位块,专门用来撑版面,本身不该有内容。我用真实浏览器逐个打开了几件,结果一致:它们全部跳走了,落点是这个站的全部商品列表页。这些条目在商品库里是商品、在公开接口里是商品、在你的审计脚本里也是商品,唯独在前台它们不存在。 拿商品当版面积木使,这事可以理解,主题编辑器不好用的时候大家都干过类似的事。麻烦在于这批积木现在混在真商品里,参与了每一次数量统计、每一次覆盖率计算,还可能参与sitemap的收录名单 (https://zhangwenbao.com/product-two-click-reachability-audit.html)。你的商品总数比实际多22件,听着无所谓,可这22件里没有一件能打开。 ## 剩下那95件是真在卖的衣服 另外95件以the开头的,比如the-waterless-heavy-bag-tee,是这个站正儿八经的产品线。它们有描述、有类目、有图、有变体,唯独品牌栏里写着None。 这就是本篇标题那个意思的最直白版本:这一栏填了,而且填得很满,可它说的不是这件商品的品牌。任何以“非空”为判据的检查,都会给这95件商品打勾放行。 ## 有人把品牌字段当开关用 Wüsthof那边有28件商品的品牌值是Exclude。 这个词不是名字,是指令。它的类目栏写着Knife Block Set这种正经货,说明商品本身没问题,是有人在这个字段上写了一句给某个程序看的话——大概率是给某个导出插件或者广告投放工具看的:这批别导。 字段被挪去当开关,短期内往往真的管用,因为那个工具确实按这个值过滤了。代价是这个字段从此有了两套语义,而下游读它的程序不止那一个。商品条码栏里填变体ID的后八位 (https://zhangwenbao.com/product-identifier-cross-copy-mismatch-audit.html)是同一个毛病的另一种长相:那次是把一栏当另一栏用,这次是把一栏当命令行参数用。 ## 这些值会不会原样流进结构化数据? 前面全是接口层的账。真正决定它有多要紧的,是下一个问题:搜索引擎看得见吗。 按Google对商品结构化数据的说明 (https://developers.google.com/search/docs/appearance/structured-data/product),brand是商品富媒体结果里会被读取的属性之一。所以接口里的值只要原样进了页面的JSON-LD,它就不再是内部数据,而是你对搜索引擎的正式声明。 我用真实浏览器逐站打开了商品页,从渲染完成的页面里把所有JSON-LD捞出来,取Product节点的brand,跟接口里的品牌值逐条对照。为了不让抽样偏向某一类商品,每个站取了四件:一件是主品牌的普通商品当基准,一件是非主品牌的,一件是描述为空的,还有一件专挑品牌值异常的。 158个页面打开了151个,6个是404。其中130个页面写了Product结构化数据,全都带brand属性,覆盖率86.1%。 ## 九成的页面把接口里那个值一字不差地抄了出去 130个带brand的页面里,118个的值与接口完全相同,一个字符都没改,占90.8%。剩下12个分布在7个站上,是主题做过处理的。 按站算更直白:44个站全部直通,7个站部分处理,1个站一条都不直通,还有4个站的商品页压根没写brand。也就是说,绝大多数独立站的商品页,就是把后台那一栏原封不动地交给了搜索引擎。 ## 那些不该出门的值,确实出门了 这是本次实测最直接的结果。前面在接口层看到的每一类怪值,都在页面的结构化数据里找到了对应: 站点 | 接口里的品牌值 | 页面结构化数据里的brand | 这是什么 | monos.com | Monos (DEV) | Monos (DEV) | 开发环境标记 | tuftandneedle.com | Tuftandneedle-production | Tuftandneedle-production | 生产环境标记 | wusthof.com | Exclude | Exclude | 给某个工具看的开关 | awaytravel.com | Away: Built for modern travel | Away: Built for modern travel | 一句广告语 | framebridge.com | Saks Fifth Avenue | Saks Fifth Avenue | 企业客户的名字 | magicspoon.com | Order Protection | Order Protection | 第三方运费险应用 | outdoorvoices.com | Rise.ai | Rise.ai | 第三方礼品卡应用 | jackery.com | Seel | Seel | 第三方保障应用 | hellotushy.com | Angi | Angi | 第三方服务商 | 最后四行值得单独说一句。Order Protection、Rise.ai、Seel、Angi都不是这些站在卖的东西,是它们装的第三方应用往商品库里塞的条目——运费险、礼品卡、延保。这些应用建商品的时候把自己的名字填进了品牌栏,然后主题照单全收,写进了结构化数据。一个卖早餐麦片的站,它的某个商品页正在对搜索引擎声明:本商品的品牌是Order Protection。 ## 做了清洗的那七个站,各清洗各的 另一头也有意思。12个不一致里,有一部分是主题主动做了修正: - allbirds把接口里的re:do换成了Allbirds——这是唯一一个明确把第三方应用挡在外面的; - fromourplace把Our Place - US的地区后缀摘掉了;bollandbranch把Boll & Branch Hydrogen收敛成了Boll & Branch; - chubbies接口写Chubbies Shorts,页面写Chubbies;stanley1913接口写Stanley 1913,页面写Stanley——两家都把品类词和年份摘了; - taylorstitch那个Not specified,页面上直接没输出brand,主题把它当空值滤掉了。 但方向不一致。Faherty是反过来的:接口里写Faherty,页面上输出的却是Faherty Brand,主题给它加了一截。七个站做了七套自己的规则,没有一套是平台给的。 ## 还有一个站,两边都脏,而且脏得不一样 Gymshark是本次唯一一个所有页面都不直通的站。它接口里的品牌值是Gymshark | Be a visionary.,页面结构化数据里写的是Gymshark | We Do Gym。 两个值都在品牌名后面拖着一句标语,用竖线隔开——那是社交简介的写法,不是品牌名的写法。更麻烦的是两句标语还不一样,说明这两处的取数来自两个各自维护的地方,而且谁也不知道对方存了什么。这跟sameAs里的社交账号和页脚那排图标对不上 (https://zhangwenbao.com/brand-identity-sameas-footer-copies-audit.html)是同一种结构:同一个品牌事实存了两份,两份都错,而且错得不一样。 需要说明抽样的边界:每个站四件的量级,只够回答“这个站是不是把接口值直通到页面”这一个问题,不足以估计任何一个站内部的比例。上面所有百分比的分母都是页面数,不是商品数。 ## 真的是多品牌的那些站,这一栏用对了吗? 说回那1172件“真的是别的牌子”。这一类不是错误,但它揭示了一件事:同一个字段,在不同的站上装的东西完全不是一回事。 ## 子品牌矩阵 迪卡侬是最标准的例子。它的商品品牌里有Quechua(户外)、Simond(攀登)、Forclaz(徒步)、Kiprun(跑步)、Van Rysel(公路车)、Wedze(滑雪),还有18件直接写Decathlon。这是一家公司真实的品牌结构,字段用得没毛病。麻烦在于对外:Google要求商家名称只留一种写法 (https://zhangwenbao.com/business-name-single-form-structured-data-audit.html),可这个站的商品有11种品牌答案,机器得自己判断哪个是“这个站”。换个品类词AI就不推你了 (https://zhangwenbao.com/category-framing-ai-brand-recommendation.html)这件事的底层也在这儿——机器先得认出你是谁,才谈得上把你放进哪一档。 ## 设计师、联名和第三方 menuspace那44个品牌值里,绝大多数是设计师名字:Norm Architects 114件、Kroyer-Saetter-Lassen 35件、Afteroom Studio 21件、Ib Kofod-Larsen 17件、Mogens Lassen 14件、Colin King 14件。对一个高端家居站来说,设计师就是卖点,写进品牌栏有商业道理。但schema.org里的Brand类型 (https://schema.org/Brand)指的是商品的品牌,设计师更接近creator或者designer——用错了类型,Google拿到的实体关系就是歪的。schema.org那份全网使用统计 (https://zhangwenbao.com/schema-org-usage-statistics-priority-guide.html)里也提过这类错配:类型堆得越多,用错的概率越高。 Faherty的库里有12件BIRKENSTOCK,Avocado那边有63件Coyuchi,Marine Layer有69件Custom Club,tentree有13件Climate+。这些是真的在代销或者做子线,字段本身没写错,只是它们和前面那两类挤在同一栏里,从数据上分不出来。 ## 顾客的名字被写进了品牌栏 framebridge是做定制画框的,它的非主品牌值里有Shake Shack(30件)、CAVA(30件)、Heart + Paw(17件)、Saks Fifth Avenue(12件)。 汉堡店、地中海快餐、宠物医院、百货公司——这些显然不是画框的品牌,是给这些企业客户做的定制项目。这一栏在这里实际记录的是“这批货是给谁做的”,语义已经从供货方翻转到了收货方。而下游任何一个读brand的程序,都会以为这个站在卖Saks Fifth Avenue的东西。 ## 主品牌名和域名对不上的9个站,问题出在哪? 还有一个顺手能做的交叉校验:把每个站的主品牌值和它的域名拿去比对。去掉标点和大小写之后,47个站互相包含,9个站对不上。 域名 | 主品牌值 | 算不算问题 | avocadogreenmattress.com | Avocado Mattress | 否,域名比品牌长 | beistravel.com | BÉIS Travel | 否,重音符号 | bollandbranch.com | Boll & Branch | 否,与号 | tuftandneedle.com | Tuft & Needle | 否,与号 | wusthof.com | Wüsthof | 否,重音符号 | dreametech.com | DREAME US | 否,域名带后缀 | ice-watch.com | ICE sa | 存疑,sa是公司形式 | decathlon.com | Quechua | 是,主品牌不是自己 | menuspace.com | Audo Copenhagen | 是,主品牌不是自己 | 9个里有6个只是重音、与号或者后缀,属于误报。真正值得看的是最后三个:ice-watch写的ICE sa带着公司形式后缀(sa是比利时和法国的股份公司缩写),更像法务实体名而不是品牌名——这个毛病在网站页脚上更常见 (https://zhangwenbao.com/footer-legal-entity-name-verifiable-audit.html);后两个则是主品牌压根不等于站名,机器要认出“这个站是谁”,得绕一圈。 顺带说一句,这个校验的假阳性率是三分之二,别拿它当报警用,只当一份待人工过目的清单。这种“报警扣完之后还剩几条”的账,我在商品价格那次算过一回 (https://zhangwenbao.com/product-price-copies-false-positive-audit.html),那次是扣到一条不剩,这次好歹还剩三条。 ## 自己的站该怎么查这一栏? 整套动作不需要任何付费工具,半小时能跑完。 - 先把全量品牌值拉出来做频次表。用你平台的商品导出,或者直接读公开的商品清单接口。只要值的个数大于1,就往下走。 - 做三级归一化再比一次。去重音、与号换成and、砍掉地区码和公司后缀、只留字母数字。归一前后个数不一样,差额就是你自己造出来的重名。 - 用一张黑名单扫一遍字面空值。None、Null、N/A、Not specified、Unspecified、Exclude、Default、Test、TBD——这几个词覆盖了本次样本里的全部218件。 - 扫环境标记。正则里放dev、staging、production、test、qa、sandbox、copy这几个词,命中的一律是事故。 - 打开商品页看结构化数据里的brand。这一步不能省。接口里的值再脏,只要没进页面,影响面就小得多;一旦原样进了JSON-LD,它就是搜索引擎认定的品牌。校验JSON-LD语法 (https://zhangwenbao.com/json-ld-validator-syntax-debug-guide.html)和批量扒页面结构化数据 (https://zhangwenbao.com/schema-extractor-structured-data-audit-guide.html)都有现成工具。 - 把真多品牌的那部分单独建表。子品牌、代销、联名、客户定制,各自的正确写法不一样:子品牌归brand,设计师该用designer,客户定制项目更适合放进商品描述而不是品牌栏。 - 定一个唯一写法,然后往回改。写法定下来之后,Merchant Center那一侧的brand属性 (https://support.google.com/merchants/answer/6324351)也要跟着对齐,否则自然搜索和购物广告会认出两个实体。 第七步是最容易半途而废的一步。品牌名统一这件事没有立竿见影的排名回报,但它决定了搜索引擎把你的商品归到哪个实体名下——实体识别这一层的缺口 (https://zhangwenbao.com/entity-gap-analysis-vector-embedding-schema.html)不会体现在任何一份常规SEO报告上,只会体现在AI答案里没有你。 ## 常见问题解答 ## 品牌字段填得不统一,Google真的会当成两个品牌吗? 结构化数据这一层是会的。brand是一个实体属性,值不同就是不同的字面实体,至于Google的知识图谱会不会把它们合并到同一个实体上,取决于它掌握的其他信号——官网、社交账号、维基条目、外部提及。信号强的大牌,写成三种也多半能被认回去;新站或者小站没有这些冗余信号,就只能靠你自己写对。所以这件事对越小的站越要紧,这跟直觉是反的。 ## 那到底该以哪个写法为准? 以你在别处已经用得最多的那个为准,不要另起炉灶。具体说:看你的官网页脚、社交账号名、商标注册名这三处,取重合的那一个。品牌名不带品类词(不写Chubbies Shorts,写Chubbies),不带地区后缀,不带公司形式(LLC、Inc、sa这些留给法务实体字段),标点按商标注册的原样写。定完之后写进内部规范,新品建档时照抄。 ## 子品牌应该写母品牌还是子品牌? 写这件商品实际印着的那个牌子。用户在货架上看到的是Quechua,商品的brand就写Quechua。母品牌的位置在组织信息里,用Organization类型单独声明,通过parentOrganization把关系挂上去。把两层压进同一个字段,等于让机器替你猜层级,猜错的概率不低。 ## 设计师联名款,品牌栏写设计师名字对不对? 不对,但也不必删。schema.org里brand指的是商品的品牌,设计师应该用designer或者creator。做法是品牌栏保持你自己的品牌,设计师另开一个属性写。这样机器既知道货是谁的,也知道谁设计的,两条关系都完整。 ## 品牌栏里填了None,会不会直接被判成垃圾内容? 不会直接判垃圾,但会浪费掉一次表明身份的机会,并且可能触发Merchant Center的属性质量提示。更现实的风险是这个值原样出现在页面的结构化数据里,那就等于对着搜索引擎说这件商品的品牌叫None。填不了品牌的商品(比如无品牌配件),正确做法是按平台规则用允许的占位方式声明,而不是自己造一个英文单词填进去。 ## 怎么知道自己的商品页有没有把这个脏值吐出去? 随便打开一个商品页,看页面源码里的JSON-LD,找Product节点下的brand。要批量做就用无头浏览器渲染完再取,因为不少主题的结构化数据是JS插进去的,只看初始HTML会漏。取到之后跟你导出的品牌值做逐件比对,不一致的分两种:页面比接口干净说明主题做了清洗,页面跟接口一样脏说明它就是直通的。 ## 这批数据能代表所有电商站吗? 不能。样本是60个英文独立站,最终56个拿到完整商品清单,全部跑在同一个电商平台上,品类偏服饰、家居和户外。换个平台,字段名和默认行为都不一样;换个品类,多品牌的比例也会变。可以直接拿走的是方法和那张字面空值黑名单,比例数字请在自己的库上重新算一遍。 ## 顺手还能查哪几个相邻的坑? 三个。一是商品标识符那一族,条码、SKU、MPN经常互相串填;二是商品类目字段,和品牌栏一样容易被挪作他用;三是页面上的品牌显示和结构化数据里的brand是不是同一个值——很多主题的面包屑和标题走的是另一套取数逻辑,两边对不上的情况不罕见。 ## 权威参考资料 ## Range请求回了200,里面却连全文的千分之一都不到 - URL:https://zhangwenbao.com/range-request-partial-content-audit.html - 分类:技术SEO - 发布:2026-09-07 | 更新:2026-09-07 - 摘要:同一个地址,请求头里多写一行,服务器可能给你一段、可能给你全部、也可能给你一份标着完整却只有千分之一的东西。 - 关键词:技术SEO,HTTP协议,服务器配置 > **TLDR**:摘要:给107个海外品牌站的首页各多发一行Range请求头,只有17个真的只把那一段给了回来,90个当没看见、照样把整份文档推过来。声明了Accept-Ranges的一共7个,其中2个说了却给不出;反过来11个一声不吭却给得出。还有4个站同时干了两件互相打架的事:状态码写200,头里却带着Content-Range,实体只有全文的千分之几。同一批站上的CSS文件却有81/106规规矩矩回206——同一个请求头,文档和静态文件是两种命运。 > 摘要:给107个海外品牌站的首页各多发一行Range请求头,只有17个真的只把那一段给了回来,90个当没看见、照样把整份文档推过来。声明了Accept-Ranges的一共7个,其中2个说了却给不出;反过来11个一声不吭却给得出。还有4个站同时干了两件互相打架的事:状态码写200,头里却带着Content-Range,实体只有全文的千分之几。同一批站上的CSS文件却有81/106规规矩矩回206——同一个请求头,文档和静态文件是两种命运。 上一轮量HTTP/2的帧,问的是“这份东西被切成了几块”,答案是客户端和服务器各出一半。这一轮换个问法:我能不能主动只要其中一段? 这件事在协议里有正经写法。请求头里加一行Range,服务器如果认,就回206加一个Content-Range,把那一段字节交给你;如果不认,就当没看见,回200和整份文档。视频拖进度条、下载断点续传、CDN把大文件切片缓存、爬虫抓大文件的头部,走的都是这一条。它跟请求头里那些被反复重发的字节 (https://zhangwenbao.com/http2-hpack-cookie-request-header-bytes-seo.html)不一样:那些是你不得不带上的开销,这一行是你主动加的一句问话。 它跟上一轮那件事其实是同一个问题的两头。上一轮是服务器替你切,切法你插不上话;这一轮是你主动要一段,能不能拿到全看对方认不认。两头合起来才是完整的答案:一份资源被怎么分次交付,从来不是它自己一家说了算。 问题是,现网到底有几个站认这一行。 ## 一个标着200的响应,正文只有全文的千分之几? ## 先看最离谱的那两个站 kotn.com的首页,加一行Range: bytes=0-1023发过去,回来的是HTTP 200。按规范,200的意思是“这是完整的东西”。可同一个响应的头里还挂着Content-Range: bytes 0-1023/1524288,实体只有435字节的压缩流,解开来正好1024字节。一个标着200的响应,装的是全文的千分之零点七。 bigcommerce.com是同一种毛病,Content-Range: bytes 0-1023/1304781,状态码200,实体645字节。两个站分别在Vercel和Cloudflare后面,不是同一家实现的偶发问题。 ## 规范其实把话说死了 RFC 9110里关于Content-Range的那一节 (https://www.rfc-editor.org/rfc/rfc9110.html)写得很清楚:这个字段只出现在206响应里,或者出现在416那种“范围不满足”的响应里,用来告诉对方真实长度。200配Content-Range是个没有定义的组合,规范没说该怎么解释,客户端也就各解释各的。 这为什么要紧?因为绝大多数客户端只看状态码。看到200就认为拿到了完整文档,往下走解析、抽正文、算相似度。首页HTML里到底有多少可读正文 (https://zhangwenbao.com/html-shell-page-readable-text-audit.html)那一轮已经讲过,一份被截断的HTML在下游很难被发现——它不报错,只是内容少了一大半,指标悄悄往下走。 ## 这不是我的客户端搞错了 第一反应当然是怀疑自己。我把响应体按原始字节读了一遍,不解压、不跟随重定向:kotn那次回来的确实是435个字节的brotli流,而正常请求同一个地址回来的是99930字节。头里那个1524288则是解压之后的全文长度。三个数字各自都对,凑在一起就是一份自相矛盾的响应。 ## Accept-Ranges这句声明,有多少是真的? ## 先看有多少站愿意开口 服务器要是支持范围请求,规范建议它在普通响应里带一行Accept-Ranges: bytes,等于提前打个招呼;MDN对这一行的说明 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Ranges)里还写了另一个取值none,意思是明说“别问了,我不支持”。107个可用的首页里,带了bytes这一行的只有7个。 但“不说”不等于“不给”。把声明和实做交叉起来看,格子里的分布比想象的乱: 普通响应里的声明 | 发Range之后实际给的 | 站数 | 怎么理解 | Accept-Ranges: bytes | 206,只给那一段 | 5 | 说到做到 | Accept-Ranges: bytes | 200,给了全量 | 2 | 说了却给不出 | 什么都没说 | 206,只给那一段 | 11 | 不吭声但给得出 | 什么都没说 | 200,给了全量 | 88 | 没说也没给,唯一自洽的一格 | Accept-Ranges: none | 206,只给那一段 | 1 | 明说不支持,实际支持 | 先看第三格。11个站从来没说过自己支持,问它要一段却给得干干净净——anker.com、buckmason.com、dji.com、eufy.com、ikea.com、insta360.com等都在这一格。这说明Accept-Ranges这一行的缺席,不能拿来判断服务器支不支持,想知道答案只有一个办法,发一次真请求。 第二格只有2个站,arcteryx.com、vaude.com。它在普通响应里挂着Accept-Ranges: bytes,真发Range过去却回了整份文档。声明和实做之间隔着一层边缘缓存,声明是源站写的,实做是边缘做的。 还有一格更别扭:1个站(rab.equipment)在普通响应里写的是Accept-Ranges: none,等于明说“我不支持”,可真发Range过去,它照样回了206、照样只给那一段。明着否认的那一行,同样不能当判据用。 ## 为什么“沉默”这一格这么大 88个站既不声明也不支持,占了82.2%。这倒不算配置错误——首页是动态生成的,服务器边渲染边往外送,压根不知道最终有多长,自然也没法交出某一段。这跟上一轮量到的现象是一回事:相当一批站连Content-Length都报不出来,因为整份文档在开始发的时候还没生成完。 换句话说,文档层不支持范围请求,多半不是不想支持,是没法支持。真正值得追问的是另外那几格:声明和实做为什么会各走各的。 ## 声明是源站写的,实做是边缘做的 这几格错位的成因,说穿了就是响应在路上被改过。源站把Accept-Ranges写进响应头,边缘节点为了压缩、为了流式转发、为了拼自己的东西,把响应体重新组织了一遍,那一行却原样透传了下来。Vary这个头也是同一种命运 (https://zhangwenbao.com/vary-header-declared-vs-actual-response-variation.html):写它的人和最终决定响应长什么样的人,不是同一个。robots写的和送达层做的对不上 (https://zhangwenbao.com/robots-allow-vs-actual-delivery-bot-identity-audit.html)那一轮,量的也是这条缝。 ## 同一个地址,只换一个Accept-Encoding,为什么答案不一样? ## 6个站在两遍之间翻了面 这一轮我对每个首页问了两遍同样的Range:一遍带Accept-Encoding: gzip, deflate, br,一遍写identity明说不要压缩。 翻面的方向还不止一种:5个站是带压缩时回全量、去掉压缩才肯给那一段(article.com、boohoo.com、braun.com、notino.com等);1个站反过来,带压缩时给了206、明说不要压缩反而回了全量(jysk.com)。同一个地址、同一台服务器、前后差两秒,答案取决于你在另一个请求头里写了什么。 ## 在自己的nginx上,这件事能一个开关一个开关地复现 我在自己的机器上另起了一个独立的nginx实例,跑在几个不占用的端口上,服务同一个376890字节的CSS文件,配置之间只差压缩这一项: 服务器配置 | 普通响应 | 带压缩的Range | 不带压缩的Range | gzip off | Accept-Ranges: bytes | 206,分母376890 | 206,分母376890 | gzip on(边压边发) | 连Accept-Ranges都不发 | 200,全量 | 206,分母376890 | gzip_static on(预压好的文件) | Accept-Ranges: bytes | 206,分母15250 | 206,分母376890 | 中间那一行是关键:一旦开了动态压缩,nginx会主动把Accept-Ranges这一行撤掉,并且对带压缩的Range请求直接回全量。原因也很实在——它是边压边发的,压缩流的第几个字节对应原文第几个字节,它自己也算不准。压缩那一轮 (https://zhangwenbao.com/content-encoding-negotiation-gzip-brotli-crawl-budget-seo.html)量的是这套配置能省多少字节,这一轮量的是它顺手拿走了什么。条件请求那一轮 (https://zhangwenbao.com/conditional-request-304-etag-crawl-budget-seo.html)也撞见过它的另一个副作用:开压缩会把ETag换掉。 第三行则是另一条路:文件事先压好放在那儿,长度是确定的,于是范围请求又活了过来,只不过量的是压缩文件的字节。这也解释了现网那11个“不吭声却给得出”的站——它们交出去的多半是边缘缓存里那份已经定型的副本。 ## Content-Range后面那个分母,量的是压缩前还是压缩后? 这是本轮最容易被拿去当数据用、又最容易错的一个字段。Content-Range: bytes 0-1023/1524288里的1524288,规范里叫“完整表示的长度”。麻烦在于,“完整表示”指的是哪一份。MDN在206那一页 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/206)把它描述成整个资源的大小,可实际拿到手的数字,取决于这一次内容协商谈出了什么。 本轮拿到Content-Range的一共20个,其中15个的分母明显大于这次实际过线的字节数——因为这次响应是压缩过的,而分母描述的是解压之后那一份。 站点 | Content-Range里的分母 | 这次实际过线的字节 | 倍数 | kotn.com | 1524288 | 99930 | 15.254倍 | bigcommerce.com | 1304781 | 95934 | 13.601倍 | segway.com | 232516 | 26424 | 8.799倍 | vuoriclothing.com | 533016 | 62952 | 8.467倍 | traeger.com | 789190 | 96656 | 8.165倍 | anker.com | 1920319 | 250421 | 7.668倍 | babybjorn.com | 716135 | 95825 | 7.473倍 | soundcore.com | 1904748 | 260556 | 7.31倍 | 实验台上这件事能一次看清:同一个big.css,预压缩那一档,带gzip问过去分母是15250,换identity再问同一个地址,分母变成376890。同一个URL、同一台服务器,“总长度”在两种请求下差了24.7倍。 所以这个分母不能直接拿去当文件大小用。它只在同一次协商的上下文里成立,换个请求头就换了一个坐标系。响应头自相矛盾 (https://zhangwenbao.com/http-response-header-self-contradiction-audit.html)那一轮收集的“矛盾”,有相当一部分是这么读出来的:两个字段各自都对,只是描述的不是同一份东西。图片格式协商 (https://zhangwenbao.com/image-format-content-negotiation-who-decides-audit.html)那一轮的坑也一样,同一个地址返回的到底是哪种格式,取决于你在请求里写了什么。 ## 要末尾一百字节、要两段、要一段不存在的,分别会怎样? ## 三种写法,三种冷淡 Range这个头不止“从第几字节到第几字节”一种写法。RFC 9110 (https://www.rfc-editor.org/rfc/rfc9110.html)把单段、多段和越界三种情形都写进了语法里。我照着又问了三个问题,答案一个比一个说明问题。 问法 | 意思 | 206 | 200 | 416 | bytes=0-1023 | 要开头1KB | 17 | 90 | 0 | bytes=-100 | 只要末尾100字节 | 21 | 86 | 0 | bytes=0-99,500-599 | 一次要两段 | 11 | 93 | 3 | bytes=999999999- | 故意越界 | 1 | 84 | 22 | “只要末尾100字节”这个写法,21个站认,回来的正好是100字节。它跟前面那问的支持面基本重合,说明服务器要么整套认,要么整套不认,没有只支持一半的。而“一次要两段”只有11个站给了206,其中真的按multipart格式回的只有9个。 ## 越界那一问最值得看 我故意要了一段根本不存在的字节。规范说这时候该回416,并且带上真实长度。22个站这么做了,84个站直接把整份文档推了过来,中位数727727字节。回全量本身不算错得离谱,但它说明这些服务器压根没解析Range这一行——不是判断之后拒绝,是从头到尾没看见。这跟前面那88个“没说也没给”的站是同一批。 这里能顺出一条挺好用的判据:想知道一台服务器有没有真的在读Range这一行,不要问它一个合法的范围,问它一个不可能满足的范围。认真解析的会回416并告诉你真实长度,没解析的会把整份文档推过来,两者一眼可辨。合法范围反而分不出来——回200既可能是“我看懂了但给不了”,也可能是“我根本没看”。 ## 静态资源那一侧,为什么反而正常? ## 同一个请求头,两种命运 把同一批站上的CSS和JS挑出来,用一模一样的请求头再问一遍,画风完全变了。 目标 | 样本 | 声明了Accept-Ranges | Range回206 | 一次要两段也给 | 首页文档 | 107 | 7 | 17 | 9 | CSS文件 | 106 | 72 | 81 | 62 | JS文件 | 95 | 54 | 64 | 46 | CSS这一行的206比例是81/106,JS是64/95,而首页文档只有17/107。同一批站、同一个请求头,文档层和资源层的支持率差了好几倍。连“一次要两段”这种冷门写法,CSS都有62个站规规矩矩地回了multipart。 差别的根子还是那一条:静态文件躺在磁盘上或者边缘缓存里,长度确定、内容不变,服务器随时能从中间切一刀;首页是现做的,它自己都不知道有多长。静态资源那一轮 (https://zhangwenbao.com/static-asset-one-year-survival-404-audit.html)关心的是这些地址能活多久,这一轮关心的是它们能不能被切开——两件事的前提是同一个:它们是文件,不是流。 ## CSS比JS高出来的那几个百分点 两类文件的206比例不一样,这不是巧合。JS更容易挂在带动态压缩的应用服务器后面,也更容易带着查询参数走一层网关;CSS则多半是纯静态产物,直接由边缘节点出。CSS那一轮 (https://zhangwenbao.com/css-coverage-unused-rules-audit.html)顺带量过这些文件有多大,动辄几十上百KB——它们恰恰是最有可能被切片缓存拿去分块的那一类。 ## 顺手撞到的一个口径问题 这些资源地址是从两天前的首页语料里抽出来的,25个已经失效——带哈希的文件名换了一版,或者被参数校验挡了回来。这类地址一旦失效就是404或者400,我在统计里把它们剔掉了,只算普通请求能拿到200的那些。做基线对照 (https://zhangwenbao.com/page-content-jitter-baseline-seo-diff-false-positive.html)的时候这一步不能省,否则失效地址会被算成“不支持范围请求”,把支持率压低一大截。 ## 按平台看,这件事几乎是被几家决定的 把可用的首页按响应头里的服务器分组,这一维的分布跟上一轮量帧的时候一样整齐。 响应头里的服务器 | 可用站数 | 首页Range回206的 | cloudflare | 78 | 3 | 未标识 | 9 | 4 | netlify | 6 | 5 | cloudfront | 3 | 0 | vercel | 3 | 1 | tengine | 2 | 2 | openresty | 2 | 1 | envoy | 1 | 0 | 这跟上一轮的结论是同一件事的两面:帧大小那一轮 (https://zhangwenbao.com/http2-frame-size-flow-control-window-audit.html)量到的所谓“现网分布”,其实是几家托管平台默认配置的分布。范围请求这一维也一样,换一家托管,这一行的行为就换一套,跟你自己写的代码基本无关。服务器配置那份清单 (https://zhangwenbao.com/website-server-configurations-seo-impact.html)里能自己拍板的项,到了托管平台上已经少了一大半。 ## 爬虫会不会真的发Range?这件事什么时候伤到收录? ## 先说不会疼的部分 抓一个普通HTML页面,爬虫不会发Range,它就是一个完整的GET。所以上面那90个“不支持”的站,在日常抓取里一点问题都没有。真正能省下整份文档的是条件请求 (https://zhangwenbao.com/conditional-request-304-crawl-budget-audit.html),不是范围请求;那一轮量到的142个站里只有52个能正确回304,那才是抓取预算上真金白银的浪费。 Google对文档层的态度也很直白:Googlebot只处理每次抓取的前15MB (https://developers.google.com/search/blog/2022/06/googlebot-15mb),超出的部分直接丢掉,而不是回头再发一个Range去要剩下的。抓取上限那一轮 (https://zhangwenbao.com/googlebot-crawl-limits-2mb-deep-analysis.html)算过这条线对绝大多数页面意味着什么,首页字节预算那一轮 (https://zhangwenbao.com/html-byte-budget-crawl-truncation-audit.html)则量过真被截断的是哪几个站。 ## 再说真会疼的三种场景 第一种是视频。播放器要拖进度条、要边下边播,靠的就是范围请求;不支持的地址在很多播放器里干脆不能seek。首页视频那一轮 (https://zhangwenbao.com/homepage-video-real-download-cost-audit.html)量过这些文件有多大,单个文件的中位数就有四百多万字节,这种体量的东西不给切,用户体验直接塌掉。 第二种是被截断却标成200的响应,也就是开头那两个站。任何按状态码判断完整性的下游——自己的采集器、监控脚本、内容比对流程——都会被它骗过去,而且骗得悄无声息。 第三种是大文件下载中断之后接不上。用户下一份几十兆的手册、固件或者安装包,网一断就得从头来。移动网络下这种情况一点都不少见,而它在办公室的宽带上永远复现不出来。 ## 还有一层容易被忽略的:CDN自己在用 不少CDN把大文件切成固定大小的块分别缓存,靠的就是往源站发范围请求,nginx的slice模块 (https://nginx.org/en/docs/http/ngx_http_slice_module.html)就是这么干的。源站要是不支持,切片缓存会直接退化成每次回源拉全量——缓存命中率的账面数字可能还挺好看,回源带宽却下不来。多层缓存那一轮 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)讲过这类“看着命中了其实没省”的形态。 ## 我这套量法在哪几处会骗人? ## 四个已经踩实的坑 - 只看状态码不看响应体。差点写成“kotn支持范围请求,回了200”。把原始字节数出来才看清,435对99930。 - 只用一种Accept-Encoding问。6个站的结论会因此反过来,同一个地址得问两遍,一遍写identity。 - 把Content-Range的分母当文件大小。压缩前和压缩后会被混进同一列,实验台上同一个文件能量出两个分母。 - 拿失效的资源地址算支持率。25个404会被算成“不支持”,得先确认普通请求是200,再问Range。 ## 三处说在前头的边界 一是这批站有78个在同一家边缘网络后面,分布更像是几家平台的默认配置,不是全互联网的样子。自建服务器的样本只有个位数。 二是每个站只问了一次。范围请求的状态码属于“取自一份东西”的读数,不像时间那样每次都变,但边缘节点当时缓没缓住这份文档,确实可能改变答案——这一点我没有跑第二轮去证伪,写在这里当作已知的口径缺口。上一轮的教训是取自某个时刻的读数必须跑两遍;这一轮的读数不取自时刻,但取自缓存状态,严格说也该复核一遍。 三是资源层每个站只抽了一个CSS和一个JS,抽的是首页里第一个出现的那个。它代表不了全站,只能说明这个站的静态文件走的是哪一套。 ## 自己站上怎么查、怎么配 ## 三条命令看清现状 判断自己站在哪一格,不需要写脚本: curl -s -o /dev/null -D - https://你的域名/ | grep -i accept-ranges curl -s -o /dev/null -D - -H 'Range: bytes=0-1023' https://你的域名/ | head -20 curl -s -o /dev/null -D - -H 'Range: bytes=999999999-' https://你的域名/某个.css | head -20 第三条就是上面那个判据:拿一个不可能满足的范围去问,回416说明服务器真的在解析,回200说明它压根没看。另外别用-I只发HEAD,很多站给HEAD的头跟GET不是一套,这个坑单独踩过一次 (https://zhangwenbao.com/curl-head-get-response-header-diagnosis-seo.html)。 ## 该配什么,不该配什么 动态HTML页面不用管。没人会对一个首页发Range,不支持是常态,为它去关压缩得不偿失。 静态资源尽量让它保持可切。nginx上最省事的做法是用gzip_static而不是动态gzip (https://nginx.org/en/docs/http/ngx_http_gzip_module.html):预先压好的文件长度确定,Accept-Ranges不会被撤掉,压缩和范围请求可以同时存在。代价是构建流程里要多一步生成.gz文件。 视频和大文件一定要支持。这类东西通常直接从对象存储或者CDN出,默认就支持;真正要小心的是自己用后端程序代理输出的那种,很多框架的文件下载接口是一次性把整个文件读进内存再吐出去,既不发Accept-Ranges,也不认Range。 用了CDN切片缓存,回源那一段必须支持。否则切片模块会一直拉全量,白白多花回源带宽。这一项在自己的监控里很难看出来,得去翻回源日志,看请求有没有带Range。 ## 一个真实的排查 保哥去年帮一个做智能硬件的独立站查过一次固件下载的投诉,用户反馈“下到一半断了就得从头来”。查下来是下载接口用后端程序读文件再输出,响应头里既没有Accept-Ranges也不认Range,换成直接由nginx出静态文件之后问题就没了。这类问题的排查成本远高于修复成本,因为它在办公室的网络里永远复现不出来,只有断网重连的用户才碰得到,而他们通常不会来报障,只会不再下载。 ## 常见问题解答 ## 首页不支持范围请求,会影响SEO吗? 基本不会。爬虫抓HTML发的是完整GET,不会发Range。本轮107个首页里90个不支持,这在动态页面上是常态。真正跟抓取预算有关的是条件请求和压缩,不是范围请求。 ## 为什么我开了gzip之后Accept-Ranges就没了? 这是nginx的主动行为,不是bug。动态压缩是边压边发的,压缩流的第几个字节对应原文第几个字节它自己也算不准,所以干脆撤掉声明并对带压缩的Range请求回全量。想两者兼得就用预压缩,把.gz文件事先生成好,配gzip_static。 ## 状态码200却带着Content-Range,客户端会怎么处理? 绝大多数客户端只认状态码,会把那一小段当成完整文档收下。这类响应最危险的地方在于它不报错,下游拿到的是一份被悄悄截断的内容。如果你在做内容比对或者采集,判据应该加一条:拿到200的时候顺手看看有没有Content-Range,有就说明这不是全量。 ## Content-Range里的总长度能当文件大小用吗? 不能直接用。它描述的是这一次协商出来的那份表示有多长,压缩响应给的是压缩后的长度,不压缩给的是原文长度。本轮实验台上同一个文件量出15250和376890两个分母,差24.7倍。要文件大小,请用不带压缩的普通请求去看Content-Length。 ## 越界的Range应该返回什么? 规范说该回416,并且带上Content-Range: bytes */总长度告诉对方真实长度。本轮22个站这么做了,84个站直接把全量推了过来。回全量不算致命,但它意味着这台服务器压根没解析这个请求头。 ## 一次要两段的多段范围,值得支持吗? 对普通网站没什么必要。文档层真给了multipart/byteranges的本轮只有9个站。这个特性主要用在PDF按页取、视频按索引取这类场景,普通页面和资源用不上,不支持也不算缺陷。 ## 视频文件一定要支持范围请求吗? 要。播放器拖进度条靠的就是它,不支持的话很多播放器直接禁用seek,或者每次拖动都从头重下。视频通常从对象存储或CDN出,默认支持;自己用后端程序代理输出的要专门检查一遍。 ## CDN会不会替我把这件事解决了? 对静态资源多半会,对动态页面不会。本轮数据里同一家边缘网络后面的站,静态资源的206比例明显高于首页文档,说明边缘缓存住的那份是有确定长度的文件,能切;没缓存住的动态响应还是回源现取,切不了。 ## 这批数字能代表我的站吗? 机制能,比例不能。“动态页面切不了、静态文件切得了”“开了动态压缩就没有范围请求”“分母跟着协商结果走”这几条是机制,换个样本也成立。至于17比90这个比例,只描述这107个站在这一刻的样子。 ## 权威参考资料 ## 网页加载顺序实测:12个首页的前14KB里浏览器无事可做 - URL:https://zhangwenbao.com/html-byte-stream-first-work-offset-audit.html - 分类:技术SEO - 发布:2026-09-07 | 更新:2026-09-07 - 摘要:首页HTML已经送到了,浏览器却还没有活可干——中间隔着一段几乎没人量过的字节。这段字节里装的是什么,决定了第一张图什么时候开始下载。 - 关键词:技术SEO,页面性能,前端实测 > **TLDR**:摘要:131个海外品牌站的首页各抓两轮,能用于计时的107个和109个。首字节的等待中位1.081秒,首字节到末字节的下载窗口中位只有0.0846秒,占整段等待的9.3%。看上去下载不值一提,可这段时间里发生的事决定了浏览器什么时候能去下第二样东西。把112份首页HTML按字节位置摊开数:第一个能被预扫描发现的资源引用中位落在第531字节,71个站在1024字节之内,但有12个站要读过14336字节才第一次有活干,最晚的一个在第612968字节。挡在这12个站前面的字节,99.5%是内联脚本和内联样式;其余100个站的这个比例中位数是0。 > 摘要:131个海外品牌站的首页各抓两轮,能用于计时的107个和109个。首字节的等待中位1.081秒,首字节到末字节的下载窗口中位只有0.0846秒,占整段等待的9.3%。看上去下载不值一提,可这段时间里发生的事决定了浏览器什么时候能去下第二样东西。把112份首页HTML按字节位置摊开数:第一个能被预扫描发现的资源引用中位落在第531字节,71个站在1024字节之内,但有12个站要读过14336字节才第一次有活干,最晚的一个在第612968字节。挡在这12个站前面的字节,99.5%是内联脚本和内联样式;其余100个站的这个比例中位数是0。 上一轮量首页里那些内联字节的时候,一直是从缓存的角度看它们:不产生请求,所以没有独立的缓存条目,寿命只能跟着文档走。 这一轮换了个位置站。同样是那些内联字节,如果它们排在文档的最前面,就还有第二重身份——它们是浏览器读到第一个可下载资源之前,必须先啃完的那一截。 于是这一轮把两件事一起量了:一份首页HTML在网络上是怎么到的,以及它到了之后,浏览器要走到字节流的第几个位置才第一次有活可干。前一件事量了两轮,后一件事离线数了112份文档。 ## 抓一份首页HTML,时间到底花在哪几段? 口径先说死。这里把一次首页请求切成两段:首字节之前,是解析域名、建连接、握手,加上服务器自己想的时间;首字节之后,是响应体一块一块到达手里的时间,本文把它叫下载窗口。两段加起来是整个请求的时长。 ## 样本与量法 131个海外品牌电商域名,每站首页发一个请求,全部打各站自有域名,单线程、请求之间隔2.0秒,整套跑两轮。客户端用的是支持HTTP/2的Python库,流式读取,逐块记下到达时刻与字节数,同时把响应头整份留下来。 第一轮119个站返回200,第二轮121个。返回200还不等于能用:其中12个站的响应体不到20000字节,像aliexpress.com只有828字节、temu.com只有1357字节、innisfree.com只有751字节,这些是地区跳转页或者风控中间页,正文根本不在里面,拿它们算时间没有意义。这类页面的形态在首页空壳那一轮 (https://zhangwenbao.com/html-shell-page-readable-text-audit.html)里单独量过。剔掉之后,两轮各剩107个和109个站。 掉出样本的另一批是直接被挡的:10个站返回403,1个返回418,1个连接中途被重置。这个名单两轮几乎一样,都是aboutyou、farfetch、madewell、pandora这类做了较强机器人识别的大牌站。 ## 三段时间的分布 指标 | 第一轮中位 | 第二轮中位 | 四分位(第一轮) | 最大(第一轮) | 首字节等待 | 1.081秒 | 1.037秒 | 0.773 / 1.606 | 25.106秒 | 下载窗口 | 0.0846秒 | 0.0919秒 | 0.063 / 0.201 | 5.048秒 | 整段时长 | 1.178秒 | 1.182秒 | 0.896 / 1.870 | 25.532秒 | 下载窗口占整段 | 9.3% | 9.5% | 5.4% / 15.0% | 78.5% | 响应体分成几块到 | 13块 | 14块 | 9 / 20 | 161块 | 线上字节(压缩后) | 96656 | 96656 | 62902 / 140962 | 524510 | 前两行的对比就是这一轮最直白的结论:一次首页请求里,九成以上的时间花在第一个字节到来之前。首字节超过2秒的有15个站,超过3秒的4个,最慢的vaude.com要等25.106秒;另一头,10个站在0.5秒之内就出了第一个字节。这个方向跟多层缓存怎么左右首字节 (https://zhangwenbao.com/ttfb-multi-layer-cache-core-web-vitals-crawl-budget-seo.html)那一轮讲的机制一致,这次多了一份跨站分布。 协议这一栏没什么悬念:104个和106个走HTTP/2,只剩3个还在HTTP/1.1上。压缩这一栏也很集中,86个站用brotli,20个用gzip,1个没压。这个比例比服务器出厂只压哪几种类型 (https://zhangwenbao.com/content-encoding-negotiation-gzip-brotli-crawl-budget-seo.html)那一轮量到的中小站要好得多,说明大站在这一层基本做到位了。 ## 下载那一段只占9.3%,是不是就不用管了? 不能这么读。中位数说的是中间那个站,两头的差距大得多。 ## 响应体是分批落地的,不是一坨 中位13块,四分位9块和20块,最多的一个分了161块。块与块之间有间隔,浏览器就是在这些间隔里干活的——收到一段解析一段,不会等最后一块到齐才动手。 2102个块间隔里,中位数只有0.0017秒,九成在0.0165秒以内。也就是说绝大多数情况下,字节是连着来的,间隔小到可以忽略。真正拖长窗口的是少数几个大空档:只有5个站出现过超过0.3秒的空档,cybex-online.com一次空了0.892秒,eufy.com空了0.748秒,vuoriclothing.com、soundcore.com、byredo.com各有一次。窗口长的站,长在一两个卡顿上,不是整段均匀地慢。这个区别很要紧,因为均匀慢通常是带宽问题,一两个大空档通常是服务端在中途等某个东西。 ## 窗口的尾部 第一轮有2个站的下载窗口超过1秒,第二轮4个。eufy.com两轮分别是5.048秒和4.650秒,349043个字节在路上走了将近5秒;soundcore.com是0.937秒和1.467秒;ouraring.com的响应体分成94块走了0.814秒。这三个站有个共同点,响应体都在25万字节以上,属于样本里最胖的一档。 还有一个更极端的读数:窗口占整段时长的比例,最大值是78.5%。存在这样的站,服务器答得挺快,可字节在路上磨蹭掉了整段等待的四分之三。把这类站的问题归到服务器响应慢,方向就找错了。 ## 为什么不报总长度的那批站,第一个字节反而来得更晚? HTTP/2里没有分块传输编码这个头,RFC 9113明确禁止了连接专用的头字段 (https://www.rfc-editor.org/rfc/rfc9113.html),分块是靠数据帧本身实现的。所以在HTTP/2上判断一份文档是不是边生成边发,只剩一个可用信号:MDN对Content-Length的说明 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Length)写得很清楚,这个头报的是响应体的确切字节数——能报出这个数,说明发之前整份已经在手上了;报不出来,说明是一边生成一边往外送。 第一轮107个站里,58个报了总长度,49个没报。原本的预期是没报的那批更快出第一个字节,因为它不用等全文生成完。 实测反过来。 分组(只看Cloudflare后面的78个站) | 站数 | 首字节中位·第一轮 | 首字节中位·第二轮 | 下载窗口中位 | 报了总长度 | 51 | 0.952秒 | 0.910秒 | 0.0715秒 | 没报总长度 | 27 | 1.483秒 | 1.431秒 | 0.0838秒 | ## 为什么要把范围收进同一家边缘网络 因为整个样本里平台分布很不均。Vercel后面那3个站一个都没报,CloudFront后面的也没有,Netlify是混着来,而报了的绝大多数在Cloudflare后面。直接拿全样本比,比出来的是平台差异不是机制差异。收进Cloudflare那78个站之后,51对27才算可比。收窄之后差距还在,而且两轮都在——没报总长度的那批,首字节晚了0.5秒上下。 ## 我第一版的解释被自己的数据推翻了 看到这张表的时候,第一反应是:报得出总长度,多半因为这份文档已经在边缘节点上躺着了,直接吐出来就行。这个解释听着顺,写进草稿之前顺手查了一眼缓存状态头,当场作废。 107个响应里有69个带着Cloudflare的缓存状态头,其中62个写的是不缓存动态内容,命中边缘缓存的只有3个,另有1个未命中、2个被绕过、1个重新验证后命中。也就是说报了总长度的那51个站,绝大多数并没有命中边缘缓存。差异不在缓存命中,在源站怎么吐这份响应:把整页渲染完再交出去的,长度是现成的;一边渲染一边往外冲的,交出去的时候自己也不知道最后有多长。前者慢在生成,快在传输起点确定;后者理论上该更早出第一个字节,实测却没有——大概率是因为它们本来就在做更重的服务端渲染。 顺带说,这批首页的缓存指令本身也很统一:58个站压根没写这个头,写了的绝大多数是各种写法的“别缓存”。这跟响应头自相矛盾那一轮 (https://zhangwenbao.com/http-response-header-self-contradiction-audit.html)看到的分布对得上,也解释了为什么首字节普遍在1秒上下。 ## 同一个站跑两遍,哪些数字会变,哪些不会? 上一轮的教训是单次浏览器实测不能当尺子。这一轮开工就按两轮设计,结果比预想的更有意思——同一次请求里,有的读数是常量,有的读数是随机变量,它们混在同一张表上。 ## 字节是常量 两轮都拿到200的107个站,压缩后的响应体字节数相对差中位0.012%,超过1%的只有1个站。同一个页面隔一轮再抓,传过来的还是那么多字节。所以凡是以字节为单位的结论——体积、占比、成分构成——单次测量就够,这也是上一轮量内联字节 (https://zhangwenbao.com/html-inline-bytes-cache-lifetime-audit.html)敢只跑一遍的原因。 ## 时间不是 下载窗口两轮差值的中位数是0.0075秒,看着很稳,可尾部有8个站两轮相差3倍以上: 站点 | 第一轮窗口 | 第二轮窗口 | 倍数 | 两轮字节差 | cybex-online.com | 1.245秒 | 0.060秒 | 20.8倍 | 9字节 | babybjorn.com | 0.177秒 | 1.265秒 | 7.1倍 | 229字节 | assos.com | 0.536秒 | 0.139秒 | 3.9倍 | 6字节 | jysk.com | 0.698秒 | 0.189秒 | 3.7倍 | 31字节 | eufy.com | 5.048秒 | 4.650秒 | 1.09倍 | 0字节 | 最后那一行是对照组。eufy.com两轮都慢,慢得稳定,那才是这个站的属性;前面四行是偶发,单跑一次就下结论会写出方向完全相反的两篇文章。这条判据跟首页视频那一轮 (https://zhangwenbao.com/homepage-video-real-download-cost-audit.html)得到的是同一条,只是那边摆动的是字节,这边摆动的是时间。 ## 连响应头本身都会翻面 这是这一轮意外抓到的:有没有Content-Length这个头,也不是站的属性,是这一次响应的属性。107个站里103个两轮一致,4个翻了面——anker.com第一轮没报、第二轮报了250277;babybjorn.com第一轮报了96103、第二轮没报;sulwhasoo.com和vuoriclothing.com各翻一次。同一个地址、同样的请求头,这个头就是会变。所以上一节那张对照表,严格说比的是这一批响应的状态,不是这批站的固定配置,报结论的时候得把这句话带上。响应头里这种“说的和做的不是一回事”的现象并不罕见,响应头死字段那一轮 (https://zhangwenbao.com/http-response-header-dead-fields-audit.html)把另外一类记了下来。 ## 首字节到了,浏览器就有活干了吗? 这是本轮真正想问的问题。 浏览器不会等文档收齐再动手。web.dev那篇讲预扫描器的文章 (https://web.dev/articles/preload-scanner)把机制说得很细:主解析器一边收字节一边建对象模型,碰到阻塞资源会停下来;与此同时有一个轻量的扫描器提前往后翻原始标记,把能看见的图片、脚本、样式表地址先派出去下载。这个扫描器是浏览器白送的性能红利,前提是它得先在字节流里看见东西。 所以真正决定开工时刻的,不是文档多大,是第一个可下载资源的引用出现在第几个字节。 ## 112份文档的开工点 判据是从文档开头找第一个带src、href或srcset的图片、脚本、样式表、媒体或框架标签,记下字节偏移。语料是这批站的首页HTML全文,剔掉没有标题标签或者不足20000字符的,剩112份。 第一个资源出现的位置 | 站数 | 占112份的比例 | 1024字节之内 | 71 | 63.4% | 1024到14336字节之间 | 29 | 25.9% | 晚于14336字节 | 12 | 10.7% | 其中晚于100000字节 | 7 | 6.3% | 中位数是第531字节,四分位落在177和1834。绝大多数站没问题,第一个链接标签就在文档头几行。 ## 14336这条线是怎么来的 它对应常见的初始拥塞窗口大小:连接刚建立时服务器一次能发出去的数据量大约是十个报文段,按1460字节算就是14600上下,取整数常写成14KB。粗略地说,这是第一批不需要等确认就能送到的字节。第一个资源引用落在这条线之外,意味着第一批字节送到了,浏览器翻遍了也没找到一件可以顺手去做的事。这条线不是硬阈值,是量级参考——链路条件不同,实际能塞进第一批的字节数也不同。 最晚的三个站:taylorstitch.com在第612968字节,占整份文档的56.5%;buckmason.com在第595698字节,占84.9%;getquip.com在第155286字节。另一头,头部14336字节里已经派出去的资源引用中位是17.5条,最多的一个147条,说明多数站在这一层做得挺好。有15个站在前8192字节里一条都没有。 ## 挡在第一个资源前面的,到底是些什么字节? 把这12个站第一个资源之前的那段字节拆开看。统计口径是内联脚本正文与内联样式块正文求并集——之所以要求并集而不是相加,是因为内联脚本里常常又嵌着样式片段,直接相加会把同一段字节数两遍,第一版就这么错过一次,taylorstitch算出了158%这种不可能的数。 站点 | 第一个资源的字节位 | 其中内联脚本与样式 | 占比 | 整份文档字符数 | taylorstitch.com | 612968 | 612278 | 99.9% | 1085189 | buckmason.com | 595698 | 593971 | 99.7% | 701453 | getquip.com | 155286 | 154566 | 99.5% | 712424 | fromourplace.com | 152773 | 152194 | 99.6% | 1002946 | outdoorvoices.com | 148807 | 148454 | 99.8% | 606216 | casetify.com | 79126 | 76399 | 96.6% | 1463431 | 12个站的这个占比中位数是99.5%,最低的一个也有43.6%。而剩下那100个站,第一个资源之前的字节里内联内容占比中位数是0.0%。这不是程度差别,是两种写法。 换句话说,把大段脚本和样式内联进文档,代价不止是它们拿不到独立的缓存条目。只要这些内容排在第一个资源引用前面,它们还会把浏览器的开工时刻整个往后推。内联关键样式这件事本身没错,关键渲染路径那一轮 (https://zhangwenbao.com/critical-rendering-path-render-blocking-css-optimization.html)讲过它为什么值得做;出问题的是把六十万字节的东西塞在最前面,而真正该先下的图和样式表排在它后头。这一层跟资源提示那一轮 (https://zhangwenbao.com/resource-hints-preconnect-dead-domains-audit.html)互补:那边讲的是提示写了却指向空处,这边讲的是提示写得再对,也得先让扫描器读到。 ## 六十万字节,换算成时间是多少 字节数写出来挺唬人,但它不是时间,所以又单跑了一轮:挑24个站,这次按解压后的字节流边收边找,记下第一个资源引用是在首字节之后第几毫秒进入视野的。 答案比预想的温和。中位是0毫秒——第一个资源跟首字节在同一块里就到了;最晚的buckmason.com是111毫秒,24个站里超过100毫秒的只有它一个。那59万字节在本地这条链路上,只值零点一秒。 这个数得这么读:它不是说这件事不要紧,是说它的量级在毫秒不在秒。值得管的理由有三个——链路越慢这段放得越大,移动端尤其明显;它挡住的是整条资源链的起点,后面每一样东西都跟着顺延;而且修它几乎不花钱,改个顺序而已。顺带这一轮还做了个交叉核对:这24个站里,今天流式量到的第一个资源字节位,跟两天前那份离线语料算出来的完全一致,24个站一个不差,说明离线那一层的读数没有过期。 ## head到底该有多长? 顺手把文档头也量了。112份里每一份都能找到head的结束位置,中位落在第82456字节,占整份文档的12.9%。 ## 尾部三个站 dreametech.com的head到第2080487字节才结束,占整份文档的52.5%;brooklinen.com是第1487533字节,占87.1%;reebok.com第965809字节。这三个站的head,单看字节比很多站的整份首页还大。 head里的资源引用中位53条,四分位33和85,最多的一个419条。第一个外部样式表出现的位置中位在第9086字节;112份文档里有72份带着不带异步标记的外部脚本,第一个出现的位置中位落在两万两千多字节处——这类脚本会直接卡住主解析器,位置越靠前,卡得越早。 ## head过长会牵出的另外两类问题 head长到这个量级,麻烦不止一处。head提前闭合导致规范标记被解析进body (https://zhangwenbao.com/head-boundary-canonical-parsed-into-body-seo.html)那一轮,量的是同一个部位的解析边界;字符集声明的字节窗口 (https://zhangwenbao.com/charset-declaration-byte-window-parser-encoding-seo.html)那一轮,量的是声明来晚了会怎样。三件事发生在同一段字节上,但触发条件各不相同,排查的时候别混作一谈。 响应头这一层也顺手记了:字段数中位33个,最多45个;头本身的字节中位3776,最多18420。跟响应体比这不算什么,但它在每一次请求上都要付一遍,请求头里那份重复发送的Cookie (https://zhangwenbao.com/http2-hpack-cookie-request-header-bytes-seo.html)那一轮算过这笔账。 ## 这几段时间里,Googlebot在等的是哪一段? 对搜索引擎来说,三段时间的分量完全不一样。 ## 首字节那一段最要紧 Google关于大型站点抓取预算的文档 (https://developers.google.com/search/docs/crawling-indexing/large-site-managing-crawl-budget)说得直接:服务器响应变慢,抓取速度会自动降下来。这个过程在服务器慢下来那几天抓取速率被调低 (https://zhangwenbao.com/slow-server-truncated-response-crawl-rate-seo.html)那一轮里能看到全程,日志里一条错误都没有,速率就是掉了。抓取预算这条线怎么排,抓取频次优化那一篇 (https://zhangwenbao.com/google-crawl-frequency-optimization-guide-2026.html)列过十二项。 ## 下载窗口那一段,用户比爬虫更疼 Lighthouse那条服务器响应时间的审计 (https://developer.chrome.com/docs/lighthouse/performance/server-response-time)只盯首字节,窗口这一段它不单列,所以光看审计分数会漏掉eufy.com那种情况——首字节1.4秒不算离谱,可字节还要再走将近5秒。web.dev关于首字节时间的说明 (https://web.dev/articles/ttfb)把首字节之前那几段拆得很细,但它到首字节就收尾了,后面那段得自己量。 ## 开工时刻影响的是渲染那一步 抓取端跑的是同一套浏览器内核,预扫描器该慢也一样慢。这条线跟页面体积撞上抓取上限 (https://zhangwenbao.com/html-byte-budget-crawl-truncation-audit.html)是两回事:那一轮讲的是超过多少字节之后内容被截断,这一轮讲的是前面那些字节里有没有能提前派活的东西。两件事可以同时发生在一份文档上,而且往往是同一批站——毕竟把几十万字节塞进头部的站,整份文档通常也不小。渲染这一步各个环节怎么走,抓取与渲染分几步 (https://zhangwenbao.com/dom-crawling-rendering-indexing-seo-optimization.html)那篇讲过全流程。 ## 我这套量法,在哪几处会骗人? 五处,全都踩过。 第一处,工具本身量不到目标协议。本机那个curl编译时没带HTTP/2支持,一开始拿它跑,131个站清一色报HTTP/1.1,还顺带被十几个站直接403挡掉。换成带HTTP/2的Python客户端之后,同一批站里104个是HTTP/2,403也少了一大半。量协议层的东西,先确认手里的工具能说这门话。 第二处,判据在新协议里已经失效。最初打算用分块传输编码这个头来分辨流式与非流式,跑完发现HTTP/2上根本不存在这个头。判据只好换成有没有Content-Length,而这个替代判据也有软处,就是上文那4个翻面的站。类似的代际失效在HTTP/3那一轮 (https://zhangwenbao.com/http3-alt-svc-discovery-first-request-crawler-seo.html)也遇到过,判据得跟着协议版本走。 第三处,块边界不是服务端的帧边界。本文说的“分成13块到”,块的大小中位是8192字节、最大16384——这两个数一看就是本地读缓冲的尺寸,不是服务器发帧的尺寸。所以块数只能用来说明“响应体是分批落地的”,不能拿去反推服务端一次推了多少。真要看帧级别,得抓包或者用浏览器内核自己的记录。 第四处,两套坐标系不能混着用。网络那一层记的是压缩后的字节到达序列,离线那一层数的是解压后的字符偏移。同一份文档经brotli之后可能只剩十分之一,说“第一个资源在第531字节”和说“第几毫秒到”是两把尺子上的刻度,中间没有简单换算。这一轮的做法是两边分开报,不硬凑。 第五处,429是我自己造的。第一版用了两个并发,跑到字母r开头的那批站时连续12个返回429。改成单线程加2.0秒间隔之后,两轮各131个请求,一个429都没有。这不是站方限流严,是自己把节奏踩坏了。顺带说,这类限流规则误伤的第一个受害者常常是robots.txt,限速规则拒掉第4个请求 (https://zhangwenbao.com/nginx-rate-limit-robots-txt-crawl-rate-seo.html)那一轮量过后果。 ## 自己站上怎么量一遍,该改什么 四步,半小时够。 第一步,拿命令行量三个时间点。用curl的写出格式,一次拿到解析、建连、握手、首字节、总时长几个数:curl -s -o /dev/null -w "ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n" https://你的域名/。总时长减首字节就是下载窗口。跑五次取中位,别信单次;先用curl -V确认输出里有HTTP2这一项。 第二步,在浏览器里核一遍。开发者工具网络面板点开主文档,看时序里的等待响应与内容下载两段;或者在控制台执行performance.getEntriesByType('navigation')[0],里面responseStart与responseEnd之差就是窗口。两边对不上的时候优先信浏览器,它跟真实用户走的是同一条路。 第三步,找自己的开工点。把首页HTML原样存成文件,搜第一个 **TLDR**:摘要:131个海外品牌电商站,首页能打开的119个,能取到一张真实商品图逐格式对照的107个。同一条地址、同样宽度,只把请求头换一换:现代浏览器拿到的文件比老浏览器小37.0%,中位数从75092字节掉到41214字节,最大的一个差了95.6%。但真正决定发哪种格式的不是站主——107个站里只有14个在地址里写死了格式,其余全由图片服务自己挑;而当我发出一个只认AVIF的请求头,102条管线里有87条压根拿不出AVIF。 > 摘要:131个海外品牌电商站,首页能打开的119个,能取到一张真实商品图逐格式对照的107个。同一条地址、同样宽度,只把请求头换一换:现代浏览器拿到的文件比老浏览器小37.0%,中位数从75092字节掉到41214字节,最大的一个差了95.6%。但真正决定发哪种格式的不是站主——107个站里只有14个在地址里写死了格式,其余全由图片服务自己挑;而当我发出一个只认AVIF的请求头,102条管线里有87条压根拿不出AVIF。 一张商品图从服务器发到用户屏幕上,中间有一个环节几乎没人盯:这次到底发JPEG、WebP还是AVIF。它不写在HTML里,不写在主题设置里,也不在任何一个后台开关上。它发生在请求发出去的那一瞬间——服务器扫一眼浏览器随手递过来的Accept头,当场就把这事定了。 它值得单独测一次,因为同时占了两条:影响大,而且大部分人不知道自己有没有参与决定。图片通常是首屏最重的那一块,格式换一种,体积能差三分之一到一半;可如果你去问一个独立站运营“你们站上的图发的是WebP还是AVIF”,十有八九答不上来,因为从来没人做过这个决定——是平台替他做的。 页面上关于图片的其他事,我已经量过几轮。srcset里写着1920w、服务器给的却是同一张140宽的图 (https://zhangwenbao.com/srcset-declared-width-vs-real-image-audit.html),那一轮量的是声明的宽度和真实像素对不对得上;994张交付图里属于品牌自己的版权信息一个字都没剩 (https://zhangwenbao.com/product-image-metadata-survival-audit.html),量的是文件内部的元数据;alt与尺寸属性怎么批量体检 (https://zhangwenbao.com/image-alt-checker-batch-audit-cls-accessibility-guide.html)则停在标记层,大图上那62%的字页面里根本找不到 (https://zhangwenbao.com/image-text-not-in-text-layer-ocr-audit.html)量的又是图里的内容。这一轮换一个方向:同一条地址,我不改地址,只改请求头,看服务器给我换不换东西。 ## 页面上那条图片地址,最后发出去的是哪一种格式? 先说结论里最直白的那一半。按今天的Chrome会发的那种Accept头去请求,107个站页面上的那张图,回来是这样的分布: 回来的格式 | 站数 | 占比 | WebP | 65 | 60.7% | AVIF | 24 | 22.4% | JPEG | 14 | 13.1% | PNG | 4 | 3.7% | 这个分布本身没什么争议:八成以上的站已经在给现代浏览器发新格式了。真正好玩的是换个身份再问一遍。 我把Accept换成一个明确表示“我只认PNG和JPEG”的值,同样的地址、同样的一张图,回来是这样的: 回来的格式 | 站数 | 占比 | JPEG | 63 | 58.9% | PNG | 28 | 26.2% | WebP | 9 | 8.4% | AVIF | 7 | 6.5% | 八成五的站老老实实退回了老格式,说明它们确实在读请求头。剩下那16个站(15.0%)把新格式发给了一个刚刚声明自己不认识新格式的客户端。这两张表放在一起,才是这轮实测真正的入口。 ## 怎么把“谁决定的”这件事测出来? 整套测法只有三步,但每一步都有一个必须先堵上的口子。 ## 第一步:取到一张真正的商品图 从首页HTML里挑一张图,听起来是十分钟的活,实际上全是坑。第一版脚本在gymshark上挑中了一个MP4,因为那条地址躺在标签里、扩展名又被查询串盖住了;另一个坑是地址里的HTML实体没还原,&width=300被当成一个叫amp;width的参数,结果整个尺寸参数失效、下回来的是原图。这两个错都会让后面的对照全部作废。 定稿的做法是:从img与source标签的srcset里取最宽的那一条,取不到再退回src与data-src,仍取不到就用preload as=image、内联背景图和og:image兜底;地址一律先做实体还原;命中logo、图标、国旗、占位图、雪碧图等关键词的一律跳过;下回来之后不看扩展名也不看响应头,只读文件头的magic字节,必须是JPEG、PNG、WebP、AVIF四者之一且大于3000字节才算数。131个站里首页能打开的119个,最终有107个取到了合格的样本。没取到的12个分两种:9个的HTML里压根找不到一条可用的图片地址——aliexpress、innisfree、temu这三个回来的首页只有两三千字节,是一张挡在真页面前面的壳,这跟JS渲染那一轮量到的形态 (https://zhangwenbao.com/js-rendering-loss-three-myths-audit.html)对得上;另外3个(functionofbeauty.com、rapha.cc、segway.com)地址是有的,但前五个候选下下来都没通过校验。 ## 第二步:三档请求头 三档分别模拟现代浏览器(同时接受AVIF与WebP)、只认WebP的浏览器、以及两样都不认的老浏览器。MDN对Accept头的说明 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept)把这件事讲得很清楚:这个头是客户端在告诉服务器自己能处理什么,服务器据此挑一份最合适的表示返回,这套机制叫主动内容协商,HTTP语义规范RFC 9110 (https://www.rfc-editor.org/rfc/rfc9110.html)把它列为服务器选择表示的标准手段。 ## 第三步:把尺寸固定住 这一步是整轮里最容易被忽略、也最要命的一步。页面上那条地址自带宽度,各站各不相同,直接拿字节数横向比等于比了两件事。所以我又把每条地址的尺寸参数统一改成800,再跑一遍三档——同一张图、同样宽度、只有请求头不同,这三个数才具备可比性。这条规矩说白了就是:在问“A和B为什么不一样”之前,先问一句“A和A自己一样吗”。 尺子本身也得先自查一遍。“老浏览器”那一档我写的是image/png,image/jpeg,*/*;q=0.8,末尾那个通配符其实等于“别的也行”,所以服务器发WebP严格说来不算违规。于是对那16个被喂了新格式的站,我把通配符整个去掉、只写image/jpeg,image/png再问一次——16个站里16个照样发新格式。这条路堵死之后,才敢说它们是真的没在读请求头。 ## 那16个站,为什么把新格式发给了说不认它的浏览器? 把这16个站按图片服务归类,答案立刻浮出来:它们绝大多数根本没有在做协商,格式是在地址里写死的。 107个站里,有14个(13.1%)的图片地址上带着一个明确的格式参数:fm=webp七个、fm=avif三个,还有fmt=webp-alpha、format=auto、xg、png各一个。这14个里有10个完全不协商——请求头怎么写都不影响结果,地址里写的是什么就发什么。rituals.com那条Scene7地址上写着fmt=webp-alpha,我用只认JPEG的头去要,回来的还是同一份WebP,一个字节不差。 这就把“谁决定的”这个问题的答案说清楚了。写死格式,本质上是开发者在写模板的那一天,替未来所有访客一次性做了决定。那天做的判断可能是对的——2022年的时候,把fm=webp硬编码进模板确实比什么都不做强。问题在于这个决定被冻住了:将来AVIF普及了,浏览器再怎么声明自己认AVIF也没用,因为这条链路上已经没有一个环节会去读那句声明。 反过来看协商的那一侧:107个站里76个(71.0%)会随请求头改变返回结果。这批站什么都不用做,浏览器升级一次,它们就自动多省一截。 ## 协商与Vary这两件事,有10个站只做了后一半 顺手量到一组对不上的数:响应头里带Vary: Accept的有84个站(78.5%),而真正会随请求头改结果的只有76个。差出来的十个站声明了自己会按Accept变,实际不变;反过来也有两个站(mejuri.com与suitsupply.com)确实在变,却没有声明。MDN关于Vary的说明 (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary)里讲了这个头存在的意义:它是给中间缓存看的,告诉它们同一条地址下有多份不同的副本、不能混用。少写这个头的后果不是页面出错,是缓存可能把一个浏览器的副本发给另一个浏览器。Vary声明与真实变化的对账我单独做过一轮 (https://zhangwenbao.com/vary-header-declared-vs-actual-response-variation.html),结论跟这里一致:声明和行为对不上是常态,而且两个方向都有。 ## 同一张图、同样宽度,三种格式差多少字节? 把尺寸统一到800之后,102个站拿到了完整的三格数据,其中71个站现代格式与老格式确实不是同一种。这71个站的差距是这样的: 分位 | 现代格式比老格式小 | 四分之一分位 | 26.2% | 中位数 | 37.0% | 四分之三分位 | 59.3% | 最大 | 95.6% | 绝对值上,老格式中位75092字节,现代格式中位41214字节。这就是文章开头那个37.0%的来处:什么都不改,只是请求头里多了两个格式名,同一张图就轻了三分之一。 不过这三档里有一档几乎是白设的。只认WebP那一档的中位数也是41214字节,跟现代那一档一模一样;再往细里看,71个站里有59个(83.1%)在这两档下拿回来的是同一串字节。多声明一句自己认AVIF,在这批站上换不来任何东西——这跟后面那个87比102是同一件事的两种说法。 再往上一层,AVIF比WebP还能再省多少?这一格样本很薄——107个站里只有10个能在同一条地址上同时给出AVIF和WebP两份,因为大部分管线要么两种都给不了,要么只给其中一种。这10个站里,AVIF比WebP再小19.7%(中位),最大的一个是suitsupply.com:同一张图,WebP是2587256字节,AVIF是1222260字节,省掉52.8%。最小的两个只省了1.9%和3.4%。图小到一定程度,新格式的固定开销就开始反噬——一张135字节的图标压成WebP之后变成560字节 (https://zhangwenbao.com/image-compressor-webp-lossless-icc-icon-size-guide.html)就是这个道理,那一轮把这笔固定费用拆得很细。 把这两级叠起来看:从JPEG到WebP是一大步,从WebP到AVIF是一小步且波动很大。这个梯度的形状,直接决定了下一段那个数字为什么重要。 ## 想要AVIF,这条管线给不给? 协商这套机制有个隐含前提:服务器手上得真有那份东西。所以我又发了一轮请求,Accept写成image/avif,*/*;q=0.1——意思是“我最想要AVIF,别的也不是不行但很勉强”。 102个站给了回应,其中只有15个真的返回了AVIF,另外87个换来的是WebP、JPEG或PNG。Shopify那条图片CDN上的站尤其整齐:allbirds回了一张292230字节的PNG,aloyoga回了34236字节的JPEG,anker回了45002字节的JPEG——它们不是不给AVIF,是这个请求头里没有出现image/webp,于是它干脆退回了原始格式。 这里得给这个数字加个括号,免得说过头。这15比87不等于“87个站永远拿不到AVIF”,它只说明在我这一轮的请求条件下,这些管线没有拿出AVIF。整轮里任何一次请求吐出过AVIF的站是25个,占107个站的23.4%。换句话说,四分之三的站,今天不管来的是什么浏览器,拿到的最好也就是WebP。 ## 两个站明明编得出更小的AVIF,却没发 那两个例外更值得琢磨。arcteryx.com和kotn.com在现代请求头下给的是WebP,我强行只要AVIF时它们给出来了,而且那份AVIF比WebP还小——23426对22986,16404对15851。差得不多,但方向是明确的:不是编不出来,是这条管线的选择规则没把它选出来。 我原本的假设是“谁小发谁”,这两个例子把它推翻了。真正的规则更像是各家管线自己的配置:给不给AVIF、什么时候给,是管线一侧写死的策略,跟这张图AVIF会不会更小没有必然关系。 ## 开关到底在谁手上?两家平台给出了两种答案 到这里就要回答那个最实际的问题:假设你现在想让自己站上的图发AVIF,你有没有地方可以改。 Shopify这一侧,答案写在官方文档里。image_url这个Liquid过滤器的参数表 (https://shopify.dev/docs/api/liquid/filters/image_url)里确实有一个format参数,但它只接受pjpg和jpg两个值,能做的转换也只有PNG转JPEG、PNG转渐进式JPEG、JPEG转渐进式JPEG这三条。文档紧接着有一段说明:平台会自动检测客户端支持哪些格式(原文举的例子就是WebP和AVIF),然后按画质与体积挑一个。也就是说,站主手上根本没有一个叫“发AVIF”的开关,这件事从设计上就不归他管。我这一轮量到的59个走Shopify图片CDN的站里,56个会协商、7个在某次请求里吐出过AVIF,这个比例不是这些品牌各自的选择,是平台当下的策略。 Next.js这一侧,答案完全相反。翻它的源码,packages/next/src/shared/lib/image-config.ts里那份默认配置写着formats: ['image/webp'],我对了v15.5.0和v16.0.0两个标签,值都一样。AVIF不是不支持,是默认没开;把配置改成['image/avif','image/webp']就开了,一行的事。这跟上一轮量到的那份浏览器兼容名单 (https://zhangwenbao.com/legacy-browser-bundle-factory-default-audit.html)是一模一样的结构:一个躺在框架里、从来没人改过的出厂默认值,只不过这次它是可以改的。 这两个答案摆在一起,就是这一轮最想说的那件事。“平台替你做了决定”这句话底下藏着两种完全不同的处境:一种是没给你入口,另一种是入口一直在,只是没人去按。它们看上去一样——你的站发的都是WebP——但能不能改,差着一整个量级的行动成本。 顺带一个和直觉不符的观察:我这批里被识别为Next.js的11个站,只有2个真的把图片交给框架自带的那套优化在走,其余9个各接了一家第三方图片服务——Sanity、Contentful、imgix、Cloudinary、Scene7、Amplience各一两家。换句话说,就算你用的框架给了你开关,你也可能早就把这个决定外包出去了。这跟首页上那八个只读HTML看不见的第三方域 (https://zhangwenbao.com/third-party-domain-dependency-invisible-in-html-audit.html)是同一种结构:依赖是一次次加上去的,最后没人说得清哪个决定归谁。 ## 第一个点开这张图的人,下载的是什么? 这一段是整轮里最意外的一个发现,它是从我自己犯的一个错里掉出来的。 我第一遍跑完,发现三个站出现了“倒挂”:请求头里声明的能力更强,回来的文件反而更大。theordinary.com那张图,现代请求头拿到的是33775字节的PNG,只认WebP的请求头拿到的却是12244字节的WebP,差了2.76倍。bugaboo.com和charleskeith.com也是同样的形态。 第一反应是它们的协商逻辑被image/avif这个陌生的值带偏了。为了坐实这个猜测,我把请求头拆成六种排列逐个问了一遍:AVIF在前、WebP在前、只要AVIF、SVG在前做对照、裸通配符。结果全部返回同一份WebP——猜测被自己的实验推翻了。 真正的原因是请求顺序。我用一个从没被请求过的宽度,只用同一个请求头连问三次: 站点 | 第一次 | 第二次 | 第三次 | theordinary.com | PNG 33775字节 | WebP 12244字节 | WebP 12244字节 | bugaboo.com | PNG 589164字节 | WebP 385878字节 | WebP 385878字节 | charleskeith.com | JPEG 92877字节 | WebP 76178字节 | WebP 76178字节 | allbirds.com | WebP 49988字节 | WebP 49988字节 | WebP 49988字节 | 三次的请求头一模一样,唯一的区别是先后。这几台图片服务器碰到一个没生成过的尺寸时,第一次请求直接回原图,转码在后台排队;从第二次起才拿得到转好的那一份。allbirds那一行是对照,Shopify的CDN从第一次就是稳定的。 所以我原来的读法错在哪:我把“排在前面的那次请求”的结果,当成了“那个请求头得到的答案”。三档请求头是顺序发出去的,现代那一档排第一,替后面两档承担了生成变体的成本,于是它显得最吃亏。 把定宽那一组全部重跑一遍之后,102个站里有5个站的答案变了,而且五个全是第一遍拿到的更大:theordinary多2.76倍、bugaboo多1.54倍、charleskeith多1.22倍,kotn和arcteryx各多百分之二三。五个站合计多下载了243327字节,中位16493字节。 这件事对读者的意义比对我这把尺子的意义大得多。它不只是一个测量陷阱——真实世界里,第一个访问某个尺寸的那个人,拿到的就是那份没转码的原图。如果你刚上线一个新的图片尺寸,或者刚清过一遍缓存,那么最先到的那批访客承担的是完整体积——而最先到的那位,有时候恰好是爬虫。更麻烦的是你未必知道缓存什么时候空了,缓存指令写给谁、谁真的听 (https://zhangwenbao.com/cache-control-directive-audience-scope-audit.html)这件事本身就常常和你以为的不一样。静态资源的地址一年后还剩多少能取回来 (https://zhangwenbao.com/static-asset-one-year-survival-404-audit.html)那一轮讲的是缓存与文件生命周期的另一头,这里是同一条链路的入口端。 ## 格式这件事,对搜索到底有多大影响? 这一节我想把话说得保守些。图片格式这个题目上,过度承诺的说法实在太多了。 先说那16个把新格式发给老浏览器的站。今天不支持WebP的浏览器已经很少,兼容性数据上AVIF的覆盖情况 (https://caniuse.com/avif)虽然还没到WebP那个程度,但WebP本身早就是绝大多数环境的默认能力。所以这16个站的直接损失,接近于零——不会有多少真实用户因此看不到图。 但它仍然值得记一笔,理由不在今天而在以后。不读请求头意味着这条链路上没有任何一个环节会因为客户端变强而改变行为。格式升级唯一的自动通道就是协商,掐掉它,将来每一次升级都得靠人去改模板。这跟排名没关系,跟五年后的维护成本有关系。 真正跟搜索沾边的是体积那一头,而且路径是间接的:图片通常是首屏最大的那个元素,体积直接反映在加载速度上,速度是页面体验的一部分。关键渲染路径那一篇 (https://zhangwenbao.com/critical-rendering-path-render-blocking-css-optimization.html)把这条链路拆得比较细,首屏那张图该不该抢优先级则是fetchpriority那一篇 (https://zhangwenbao.com/fetchpriority-priority-hints-resource-loading-optimization.html)的题目。但有三条得跟上:第一,格式换新省下的字节,只有在图片确实是首屏瓶颈时才转化成可感知的提速;第二,监控这一头本身就有盲区,页面上近一半的跨源资源在监控里是一排零 (https://zhangwenbao.com/cross-origin-timing-allow-origin-web-vitals-blind-spot.html),图片恰恰是重灾区;第三,图片文件名不是排名因素 (https://zhangwenbao.com/image-file-name-ranking-factor-myth.html)、alt也帮不上网页排名 (https://zhangwenbao.com/image-alt-text-ranking-factor-myth.html),格式同理——它不是一个独立的排名信号。 抓取那一侧还有一笔账容易被忽略。爬虫每次抓一张图也要下载完整字节,图多的电商站上这笔账不小。压缩类型协商那一轮 (https://zhangwenbao.com/content-encoding-negotiation-gzip-brotli-crawl-budget-seo.html)算的是文本资源的同一笔账,图片这一头的量级只会更大。条件请求能不能省下重复抓取 (https://zhangwenbao.com/conditional-request-304-crawl-budget-audit.html)则是从另一个方向省。 最后一点老实话:这一轮没有测Googlebot自己会发什么Accept头。我用的是普通浏览器的身份,得到的结论只覆盖真实用户这一侧。爬虫拿到哪一种格式,需要另一套测法,这是本文明确的口径边界。 ## 自己站上怎么查一遍,按什么顺序改? 整套自查不需要任何工具,一条命令行就够,五步走完大概二十分钟。顺序别乱,后面三步都建立在前两步的答案上。 ## 先确认自己在不在协商 拿首页上一张真实的商品图地址,用两种请求头各要一次,比对回来的Content-Type。两次一样就说明没在协商,两次不同就说明在协商。注意别只看响应头里的Vary,前面那10个站已经证明了声明和行为可以对不上。 ## 再确认自己有没有把格式写死 看地址里有没有fm=、fmt=、format=这类参数。有,就说明决定是在模板里做的;这时候要问的问题是:这个值是谁在哪一年写下的,当时的判断今天还成立吗。第三方默认值会自己漂移 (https://zhangwenbao.com/third-party-default-changes-silent-drift-audit.html),而写死的值只会一直躺在那儿。 ## 第三步是问一句“AVIF给不给” 发一个偏向AVIF的请求头试试。给不了,就去查你的图片服务商有没有这个能力、要不要额外开;给得了但平时不给,那就是它的选择策略,你多半改不动。 ## 第四步是把新尺寸预热一遍 这一条是这轮实测的直接产物。改版、换主题、上新的图片尺寸之后,别指望第一个访客替你承担转码:拿脚本把新尺寸的地址扫一遍,让转码提前发生。成本是几十个请求,收益是所有真实访客都拿到转好的那一份。 ## 最后才轮到改模板 顺序很重要。前面四步都是在摸清“这个决定归谁”,只有确认了决定权在自己手上,改模板才有意义。如果你用的是Shopify这类不给入口的平台,把时间花在写死格式上是白费;Shopify上哪些页面元素能改、哪些被平台锁死 (https://zhangwenbao.com/shopify-on-page-seo-title-meta-url-platform-constraints.html)这个问题在别的维度上也一样成立。 顺带说一件不该做的事:别为了图片格式去堆标签。我这批里59个站用了picture,但真正用source标签上的type属性声明格式的少得可怜——WebP九处、AVIF三处、JPEG两处、PNG一处。原因很简单:只要图片服务在做协商,标记层就不需要重复一遍同样的事,多写一层反而把逻辑分散到两个地方。MDN对picture元素的定位 (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/picture)说得很明确,它解决的是艺术指导和尺寸选择,格式回退只是顺带能力。 ## 这一轮的口径限制,一次说清 有几条边界得写在正文里,否则上面那些数字会被读得比它们该有的分量重。 每个站只测了一张图。挑的是首页上第一张能通过校验的真实商品图,它代表这个站的主图片管线,但不代表这个站所有的图——同一个站上完全可能有几条管线并行,尤其是那些既有自有CDN又接了第三方图片服务的。 定宽800这个数是我定的,不是各站的真实展示宽度。它的作用是把尺寸这个变量固定住,让三种格式之间可比;它不适合拿来讨论“这个站的图该是多宽”,那是声明宽度与真实像素那一轮 (https://zhangwenbao.com/srcset-declared-width-vs-real-image-audit.html)的题目。 格式判定只认文件头的magic字节,不认扩展名也不认响应头。这个选择让判定极其可靠,代价是无法区分同一格式内部的编码差异——比如渐进式JPEG和基线JPEG在我这把尺子下都叫JPEG。 最后,13个站在采集尾段撞上了429。它们不是拦爬虫,是我自己的请求密度累到了共享边缘的阈值——同一批站在上一轮前端库版本普查 (https://zhangwenbao.com/frontend-library-version-age-audit.html)里全都是200。把间隔拉长单独补跑之后,13个站全部恢复,数据完整。这条教训值得单独记:请求密度的瓶颈不只在“每个站发几个请求”,还在于这些请求是不是都落在同一家共享CDN上。 ## 常见问题解答 ## 我的站发的是WebP还是AVIF,怎么最快看出来? 用命令行请求一张商品图,看响应的Content-Type。更稳的做法是把文件下下来读前十二个字节:JPEG开头是两个固定字节,PNG有八字节签名,WebP是RIFF加WEBP,AVIF在第五到第十二字节能读到ftyp和avif。别看地址后缀,我这批里63个站的地址后缀写着jpg,实际发出去的多数不是JPEG。 ## 把图片格式换成AVIF,排名会涨吗? 不会直接涨。格式不是排名信号,它影响的是文件体积,体积影响加载速度,速度是页面体验的一部分——这条链路有三级传导,每一级都会衰减。真正能感知到的场景是首屏那张大图确实卡住了加载,其余情况下省下的字节更多体现在带宽账单和抓取成本上。 ## 为什么我在浏览器里看到的是WebP,用命令行请求却拿到JPEG? 因为命令行工具默认发的Accept头通常是一个裸通配符,服务器读到之后没有任何理由挑新格式,就退回原始格式了。测协商必须自己把请求头写全,否则量到的是工具的行为不是站点的行为。 ## 地址里写死fm=webp算不算好做法? 在今天不算错,但它把一个本该由客户端参与的决定固化成了模板里的常量。代价不在当下而在以后:将来想升级到新格式,得回去改模板;而做协商的那批站什么都不用做。如果你的图片服务支持自动格式,优先用自动那一档。 ## Shopify站主真的完全没法指定图片格式吗? 就image_url这个过滤器而言是的,它的format参数只接受pjpg和jpg。想绕过去只有一条路:把图片托管到平台之外的图片服务上,那样你就把决定权换了一个地方放,同时也换来了一套新的依赖。 ## 那16个把WebP发给老浏览器的站,会有用户看不到图吗? 今天基本不会,因为不支持WebP的浏览器份额已经很小。真正的问题不是兼容性,是这条链路没有在读请求头——这意味着它也永远不会因为浏览器变强而自动给出更好的格式。 ## 第一个访客拿到未转码原图这件事,我该怎么防? 上线新尺寸或清完缓存之后,用脚本把关键页面上的图片地址扫一遍,让转码提前发生。这件事只需要几十个请求,但能让所有真实访客避开那一次冷启动。要注意的是并不是所有管线都有这个问题,我这批102个站里只有5个出现,先测再动手。 ## 这批数据能代表我的站吗? 样本是131个海外品牌电商站,Shopify占了大头,所以“六成发WebP”这个比例带着明显的平台色彩。能迁移的不是比例而是方法:三档请求头、定宽对照、连问三次看稳不稳,这三步在任何站上都能复现,二十分钟就能测出自己那一份答案。 ## 权威参考资料 ## 网页体积实测:118个首页58%的字节,缓存寿命0秒 - URL:https://zhangwenbao.com/html-inline-bytes-cache-lifetime-audit.html - 分类:技术SEO - 发布:2026-09-05 | 更新:2026-09-06 - 摘要:给首页设不缓存是对的,给带哈希的静态资源设一年也是对的。可你有没有算过,被写进那份不缓存的文档里的脚本和数据有多少?回头客每来一次,这些字节就要重传一次。 - 关键词:技术SEO,页面性能,缓存策略 > **TLDR**:摘要:118个海外品牌电商站的首页HTML,解压后合计106433920字节,其中62287198字节是内联进文档里的脚本、数据、样式和图片——占58.5%,每站中位56.8%,最高的一个站是98.5%。这些字节有一个共同点:它们不产生独立请求,所以缓存寿命只能等于HTML文档本身的缓存寿命。而同一批站里,HTML的缓存寿命中位数是0秒,外部样式表和外部脚本是31536000秒。46个能配对的站里,41个站给外面的文件签了一年,给自己文档里那几十万字节签的是一秒都不留。 > 摘要:118个海外品牌电商站的首页HTML,解压后合计106433920字节,其中62287198字节是内联进文档里的脚本、数据、样式和图片——占58.5%,每站中位56.8%,最高的一个站是98.5%。这些字节有一个共同点:它们不产生独立请求,所以缓存寿命只能等于HTML文档本身的缓存寿命。而同一批站里,HTML的缓存寿命中位数是0秒,外部样式表和外部脚本是31536000秒。46个能配对的站里,41个站给外面的文件签了一年,给自己文档里那几十万字节签的是一秒都不留。 上一篇量首页视频的时候,顺手记了一笔:那些视频文件的Cache-Control几乎清一色是一年。当时觉得理所当然——视频不会改,缓存久一点天经地义。 转头去量同一批站的HTML文档,数字反过来了:能拿到缓存头的57个站,中位数是0秒。 这本身也很合理,首页内容天天变,不该被缓存住。问题出在夹在中间的那一层:被写进HTML文档里的那些脚本、数据和样式,既不是首页内容,也拿不到外部文件的缓存待遇。它们跟着文档走,文档不留,它们就不留。这一轮就是把这层字节量了一遍。 ## 一份首页HTML里,有多少字节根本不会单独发请求? 口径先说死。这里说的“内联字节”指五类东西:按MDN对script元素的说明 (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script),带src的脚本正文会被忽略,所以这里只算没有src的