ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

原生HTML/CSS/JS一键实现网站黑白模式:滤镜原理与踩坑指南

原生HTML/CSS/JS一键实现网站黑白模式:滤镜原理与踩坑指南 前阵子运营同事突然找我说第二天上午全站要切黑白模式而且要求能一键恢复、一键再切过去最好运营自己也能操作。我当时第一反应就是给html加一行filter: grayscale(1)但真正落地的时候才发现原生 HTML 做黑白模式这件事表面是 CSS 一行背后全是细节fixed 定位乱了、Logo 要去彩色、图片没变灰、动画卡顿……这篇就把我从头到尾的完整方案和踩坑过程写清楚纯原生 HTML/CSS/JS 实现不引第三方库适合所有前端同学参考。1. 整站黑白不是改个颜色而是一套状态方案1.1 哪些场景需要全站黑白先说需求。网站黑白模式最常见的触发场景是特殊纪念日、重要仪式感时刻、产品下架停服、活动页面归档、极简阅读模式等。这类需求有几个共同特点时间敏感、范围全站、必须可逆。时间敏感意味着可能今天告知、明天生效留给你准备的时间很短范围全站意味着不是某个按钮、某张图变色而是所有页面、所有组件、所有弹窗全部统一变灰必须可逆意味着活动结束或指定时间过后要能一秒恢复正常不能改完代码再重新发布。很多刚入行的同学容易把这件事理解成“全局加个样式”实际上运营要的是一套能随时开关的机制。你不可能每次需求来了都改代码发版更好的做法是做成一个内置于页面中的“开关”——平时关闭、特殊时刻一键开启。这也是标题里“一键”两个字的真实含义。1.2 为什么坚持用原生 HTML/CSS/JS市面上其实有不少第三方插件和库能实现网站置灰比如有的站点直接引入一个 widget 脚本会自动给页面挂灰度滤镜。但我的建议始终是能原生解决的场景尽量别引第三方。原因有三条。第一可控性。原生方案完全掌握在自己手里滤镜加在哪一层、排除哪些元素、什么时候启用、什么时候关掉全部由你的代码决定不会出现第三方脚本在某个页面报错导致整站变灰失败的尴尬。第二性能与安全。第三方脚本本质上是在你的页面上执行一段外部代码你无法完全确认它做了什么而且它通常还会额外请求资源。特殊时期全站访问量大每多一个请求就多一分风险。原生方案零请求、零依赖一个 class 就能搞定干净利落。第三兼容性可自行兜底。第三方方案如果遇到老浏览器不兼容你很被动原生方案你可以自己写 SVG 滤镜兜底心里有数。1.3 “一键”的三个层次把需求拆开看“一键”其实可以理解成三个层次手动一键页面上放个按钮访客或运营点击整站切换黑白自动一键到了约定日期自动变灰过了日期自动恢复不需要人工干预强制一键运营通过链接参数强制指定某个页面变灰方便紧急情况下快速生效。三个层次并不互斥好的方案应该同时支持。后面我会给出一个三通道结合的完整实现先把核心原理讲清楚再一步步落地。2. grayscale 滤镜的底层逻辑与最简实现2.1 一张彩色图片是怎么变成黑白的要理解整站变灰首先得知道 CSS 的filter: grayscale()到底做了什么。其实它内部可以理解为一个颜色矩阵的变换把像素的 RGB 三个通道按照一定权重计算出一个灰度值然后把这个灰度值同时赋给 R、G、B 三个通道最终得到一个“没有饱和度”的像素。常见的灰度算法有三种最大值法、平均值法、加权平均法。CSS 用的是基于 sRGB 亮度感知的加权算法简单说就是绿色对亮度的贡献最大蓝色最小这样处理出来的黑白更接近人眼对画面明暗的真实感受。这里要区分另一个容易混淆的属性filter: saturate(0)。grayscale(1)和saturate(0)效果看起来非常像都是去色变灰但底层逻辑略有不同grayscale 是按灰度算法重新映射颜色saturate(0) 是直接把饱和度降为零。实际显示效果上两者差别很小但 grayscale灰度在语义上更准确而且兼容性更好所以我推荐使用grayscale。还有一点需要留意filter: grayscale(1)的括号里可以是0到1或者0%到100%。我习惯用1表示全灰度因为grayscale(0.5)代表半灰度日常切换更多是二值的直接给1最干脆。2.2 从“一行 CSS”到“一个 class”最直白的写法是在样式表里加上这段html { filter: grayscale(1); -webkit-filter: grayscale(1); }这段代码在绝大多数现代浏览器里都能让整个页面变灰也是网上流传最广的“一行实现”方案。但我不建议直接把样式写死在html上因为你要考虑一键切换和恢复。更好的做法是定义一个状态 classhtml.gray-mode { filter: grayscale(1); -webkit-filter: grayscale(1); }这样切换逻辑就从“改样式”变成了“增删一个 class”语义非常清楚。后期不管是交给按钮交互、定时器还是 URL 参数统一都是操作这个 class维护起来省心很多。2.3 老浏览器兼容SVG 滤镜兜底方案如果项目还需要照顾比较老的浏览器比如某些政府类、传统的企业内部系统还在用很久以前的浏览器版本filter属性可能不认。这种情况可以准备一份 SVG 滤镜兜底。思路是定义一个 SVG 灰度滤镜在 CSS 里通过filter: url(#grayscale)引用它。具体做法是在页面中放一个隐藏的 SVG 标签svg styleposition:absolute; width:0; height:0; overflow:hidden; filter idgrayscale feColorMatrix typesaturate values0 / /filter /svg然后在 CSS 里这样写html.gray-mode { filter: url(#grayscale); -webkit-filter: grayscale(1); }feColorMatrix的saturate类型配合values0效果就是彻底去饱和和grayscale(1)基本等效。兜底方案不需要锦上添花只要老浏览器里页面能正常变灰、内容可读即可。2.4 给变灰加一点过渡如果直接加 class页面会“啪”地一下瞬间变灰视觉上有点突兀。可以在 CSS 里补一个过渡html { transition: filter 0.3s ease; }filter是一个可过渡属性切换时会有一个柔和的变化过程。不过要注意全站级别的 filter 过渡在低端设备上可能造成掉帧因为浏览器需要重新计算整页的颜色矩阵。如果项目里动画元素很多建议过渡时间控制在 0.3s 以内或者干脆不加过渡直接硬切反而更稳。3. 一键切换的完整交互实现按钮、持久化、自动触发3.1 核心脚本一个按钮切换全站黑白我们先实现最基础的一键切换交互。HTML 结构如下!doctype html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title原生html一键实现网站黑白模式/title style html { transition: filter 0.3s ease; } html.gray-mode { filter: grayscale(1); -webkit-filter: grayscale(1); } .gray-toggle { position: fixed; right: 20px; bottom: 20px; padding: 10px 18px; border: 1px solid #ccc; background: #fff; cursor: pointer; z-index: 9999; } /style /head body button classgray-toggle idgrayToggle typebutton开启黑白模式/button script var toggleBtn document.getElementById(grayToggle); var rootEl document.documentElement; toggleBtn.addEventListener(click, function () { rootEl.classList.toggle(gray-mode); var isGray rootEl.classList.contains(gray-mode); toggleBtn.textContent isGray ? 关闭黑白模式 : 开启黑白模式; }); /script /body /html这个版本已经可以用了点击按钮后整站变灰再点恢复。按钮固定在右下角方便演示和手动测试实际生产环境可以把这个按钮隐藏只保留切换逻辑。这里有个容易忽略的点不要直接给document.body.style.filter赋值。body的滤镜只作用于 body 自身渲染的内容某些浏览器下html本身的背景色和滚动条区域不会被覆盖页面可能出现“顶部白条”或“滚动条不变灰”的情况。挂在document.documentElement也就是html根元素上覆盖范围才是真正的整站。3.2 记住用户选择localStorage 持久化手动切换做了但刷新页面之后状态就丢了体验不够完整。运营希望这次切了用户下次访问还是灰的这就需要把状态持久化。最轻量的方式是localStoragevar rootEl document.documentElement; var KEY gray-mode-enabled; function applyGray(enable) { rootEl.classList.toggle(gray-mode, enable); var btn document.getElementById(grayToggle); if (btn) { btn.textContent enable ? 关闭黑白模式 : 开启黑白模式; } } (function init() { var saved localStorage.getItem(KEY); if (saved 1) { applyGray(true); } })();点击事件里加上写入逻辑toggleBtn.addEventListener(click, function () { var isGray !rootEl.classList.contains(gray-mode); applyGray(isGray); localStorage.setItem(KEY, isGray ? 1 : 0); });这段逻辑注意几点首次进入页面时从localStorage读状态并立即应用选择权交给访客访客关掉黑白之后下次打开保持关闭状态运营强制开启的场景见下面。3.3 自动开启日期判断与 URL 参数控制除了用户手动切换还要支持运营侧强制。我常用的方案是“URL 参数 日期判断”双通道。URL 参数适合紧急控制运营在活动页链接后面加?gray1页面加载后立即变灰。代码可以这样写function getQueryParam(name) { var match new RegExp([?] name ([^]*)).exec(window.location.search); return match ? decodeURIComponent(match[1]) : null; } (function init() { var forced getQueryParam(gray); if (forced 1) { applyGray(true); return; } var saved localStorage.getItem(KEY); if (saved 1) { applyGray(true); } })();日期判断适合定时任务比如页面上配置了某个日期范围到了时间自动变灰。这个逻辑可以放在页面加载时function isInGrayPeriod() { var now new Date(); var start new Date(2025-10-01T00:00:00); var end new Date(2025-10-03T23:59:59); return now start now end; } (function init() { if (isInGrayPeriod()) { applyGray(true); return; } var forced getQueryParam(gray); if (forced 1) { applyGray(true); return; } var saved localStorage.getItem(KEY); if (saved 1) { applyGray(true); } })();优先级我定成日期范围 URL 参数 用户选择。日期范围是最高优先级因为纪念日场景必须无条件生效URL 参数排第二方便运营临时强切用户选择排最末日常使用时把选择权给访客。3.4 防闪烁脚本要放在 head 里提前执行把初始化脚本放在/body前有个问题页面先渲染出彩色内容JavaScript 再执行变灰用户会看到一瞬的彩色体感上很怪。尤其是在强切场景下这种闪变不能接受。解决办法是在head里用小段内联脚本提前执行。因为head里的脚本在 body 渲染之前就会执行可以先给html加好 class浏览器后续绘制时就直接按灰度渲染全程无闪变head style html.gray-mode { filter: grayscale(1); -webkit-filter: grayscale(1); } /style script (function () { var forced /[?]gray1/.test(location.search); if (forced) { document.documentElement.classList.add(gray-mode); } })(); /script /head注意localStorage的读取也可以放这里但要包一层 try/catch因为某些隐私模式或者旧浏览器下访问localStorage可能抛出异常不能让它阻断页面脚本。4. 实测高频踩坑变灰不彻底、fixed 错位、卡顿4.1 为什么 position: fixed 元素“乱跑”了这是我在实际项目中栽得最深的一个坑。当你给html加上filter后问题立刻出现页面里使用了position: fixed的弹窗、悬浮按钮、遮罩层它们的定位基准变了。原因在于filter作用在某个元素上时该元素会创建一个 containing block包含块。正常情况下fixed元素的包含块是视口viewport但当祖先元素上有filter时fixed元素的包含块会变成最近的那个有滤镜的祖先元素。也就是说html上加了滤镜后所有fixed定位元素就不再看浏览器窗口而是看整个html根元素。大多数情况下 html 根元素尺寸和视口一致视觉上问题不明显。但一旦页面有滚动条、或者根元素尺寸因为某些原因比视口小fixed 元素就可能出现在意料之外的角落。更稳妥的做法是不要直接给html加 filter而是给一个包裹全站内容的容器加比如body div idapp !-- 全站内容 -- /div /body#app.gray-mode { filter: grayscale(1); -webkit-filter: grayscale(1); }注意两个问题如果页面内容超高容器高度会超过视口fixed 元素的包含块会变成这个容器也可能出现类似问题有些弹窗是从 body 直接 append 的不在#app内就需要额外处理。所以这个方案不能一劳永逸需要根据自己的页面结构测试调整。我的经验是如果项目页面结构简单、fixed 元素不多直接在html上加 filter 问题不大但如果你有复杂弹窗体系一定要先单测 fixed 元素的定位再上灰度。4.2 变灰后对比度下降内容看不清整站变灰之后原本靠颜色区分的 UI 元素会变得很难辨认。最典型的是表格斑马纹、按钮主色状态、表单错误提示。全灰之后所有颜色都变成了不同深浅的灰色如果原设计的颜色明度相近变灰后可能完全分不清。几种实际情况红色错误提示变成灰色用户分不清是提示还是普通文案蓝色链接变成灰色看起来像普通文本带背景色的按钮和旁边的普通按钮灰度后几乎一样。遇到这种情况可以在灰度基础上加一点对比度补偿html.gray-mode { filter: grayscale(1) contrast(1.05); -webkit-filter: grayscale(1) contrast(1.05); }contrast(1.05)意思是把整体对比度稍微拉高一点让不同明度的元素拉开差距。这个值不要给太大否则页面会发“闷”我测试下来1.03 ~ 1.08是比较安全的区间。如果仍然有某些关键元素看不清最直接的办法是给它们单独调灰度下的样式。比如错误提示原本是红色可以在灰度模式下把它的文字加粗或者把背景加暗html.gray-mode .error-tip { font-weight: 600; background: #555; color: #fff; }4.3 图片和背景图“没变灰”正常来说父级加了filter里面的图片和背景图都会被一起变灰。如果你发现某些图片没有变灰大概率是这几个原因图片元素自身设置了filter: none或者更高的样式优先级覆盖了全局 filterCSS 里对图片单独定义了filter属性比如某些特效代码里给 img 加了阴影、模糊或者saturate导致灰度效果被部分抵消图片是 canvas、iframe、video 或某些第三方组件。filter对 canvas 和视频的渲染结果不一定都生效尤其是部分老浏览器iframe 内的文档属于独立文档父页面的滤镜无法穿透到 iframe 内部background-image 使用了 CSS 渐变或者通过 JavaScript 动态绘制的图形个别浏览器处理异常。遇到 canvas 和 iframe 内容不变灰的情况在原生方案下没有特别通用的解决办法。iframe 如果来自自己域名可以给 iframe 内的 html 元素也加同样的灰度 class如果是第三方域名那就只能接受原样显示或者用覆盖层处理。canvas 部分其实也算少见目前大多数现代浏览器对 canvas 的滤镜处理是没问题的。4.4 动画和滚动卡顿性能怎么调全站灰度叠加在动画复杂的页面里低端设备会比较吃力。原因在于页面每次重绘时浏览器都要对全站像素做一次颜色矩阵变换。动画越频繁开销越大。我能给到的实操建议灰度期间可以用 CSS 选择器临时禁掉高开销动画降低动画元素数量如果页面有will-change: transform之类的属性在灰度模式下可以临时移除或调整为auto。html.gray-mode *, html.gray-mode *::before, html.gray-mode *::after { animation-play-state: paused; }这个方案比较激进会把所有动画都暂停。如果不想全部暂停可以只针对已知的大面积动画元素做暂停html.gray-mode .particle-canvas, html.gray-mode .banner-carousel { animation: none !important; }灰度模式本来就是一种“克制”的视觉状态动画暂停反而更符合氛围。所以我在实际项目中一般选择灰度期间暂停大部分装饰性动画只保留文字呼吸等低开销效果。4.5 排除元素Logo 要保留彩色真没那么简单很多网站要求全站变灰但顶部 Logo 要保持彩色。这个需求看着简单实现起来是个大坑。先说结论如果灰度滤镜加在整个html或body上你没有办法通过给某个子元素加filter: none来还原它的彩色。因为filter不是继承属性但它作用于整个渲染结果子元素自身的filter: none只能取消它自己的滤镜无法抵消祖先层级已经施加的灰度变换。所以“保留 Logo 彩色”的正确做法是从一开始就不要把 filter 加在全体元素上而是在结构上分区域。比如把页面主体内容包在一个容器里Logo 区单独放外面然后只给内容容器加灰度body header classsite-header img srclogo.png altlogo classlogo !-- 保持彩色 -- /header div idmain classgray-area !-- 这部分内容变灰 -- /div /body.gray-area { filter: grayscale(1); -webkit-filter: grayscale(1); }这个方案才是最可靠的。如果页面上要排除的元素散落在各个位置不好统一包容器那就用另一种思路给除了排除元素之外的所有元素加灰度。思路比较粗暴但可行html.gray-mode body *:not(.keep-color):not(.keep-color *) { filter: grayscale(1) !important; -webkit-filter: grayscale(1) !important; }注意这里有个很重要的细节排除元素如果有子元素要同时把.keep-color本身和它的所有后代都排除掉不然 Logo 图片作为后代会再次被*的灰度规则击中。上面写法里:not(.keep-color *)就是干这个的。但这个方案有性能隐患选择器会匹配全站所有元素给每个元素单独加滤镜开销比整站加一个滤镜大得多。所以它只适合元素不算太多的页面如果页面有几万个 DOM 节点建议还是改结构、分区域加灰度。5. 把“能用”的方案变成“好用”的方案封装与上线清单5.1 完整封装一份可复制的独立脚本综合上面所有逻辑我整理了一份可以直接复制到项目里的独立脚本。它支持按钮切换、localStorage 记忆、URL 参数强切、日期范围自动切换并且把防闪烁处理放在了 head 内联脚本里。!doctype html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title原生html一键实现网站黑白模式/title style html { transition: filter 0.3s ease; } html.gray-mode { filter: grayscale(1) contrast(1.05); -webkit-filter: grayscale(1) contrast(1.05); } /* 需要排除彩色的元素 */ .keep-color, .keep-color * { filter: none !important; -webkit-filter: none !important; } /style script (function () { var KEY gray-mode-enabled; function apply(enable) { document.documentElement.classList.toggle(gray-mode, enable); var btn document.getElementById(grayToggle); if (btn) btn.textContent enable ? 关闭黑白模式 : 开启黑白模式; } function getQuery(name) { var m new RegExp([?] name ([^]*)).exec(location.search); return m ? decodeURIComponent(m[1]) : null; } function inGrayPeriod() { var now new Date(); var start new Date(2025-10-01T00:00:00); var end new Date(2025-10-03T23:59:59); return now start now end; } try { if (inGrayPeriod()) { apply(true); } else if (getQuery(gray) 1) { apply(true); } else if (localStorage.getItem(KEY) 1) { apply(true); } } catch (e) { // localStorage 不可用时静默降级不阻塞页面 } window.grayModeAPI { on: function () { apply(true); try { localStorage.setItem(KEY, 1); } catch (e) {} }, off: function () { apply(false); try { localStorage.setItem(KEY, 0); } catch (e) {} }, toggle: function () { var isGray document.documentElement.classList.contains(gray-mode); isGray ? this.off() : this.on(); } }; })(); /script /head body button classgray-toggle idgrayToggle typebutton开启黑白模式/button script document.getElementById(grayToggle).addEventListener(click, function () { window.grayModeAPI.toggle(); }); /script /body /html这段代码暴露了一个window.grayModeAPI除了给按钮用运营也可以直接在控制台执行grayModeAPI.on()或grayModeAPI.off()非常方便排查问题。如果你只需要某个单页临时强切复制这段代码进去改一下日期即可。5.2 上线前走一遍的灰度验证清单经历过几次“切了之后才发现问题”之后我给自己列了一份检查清单每次上线前逐项打勾全站主要页面是否都正常变灰首页、列表页、详情页、登录页页面上所有position: fixed元素弹窗、抽屉、悬浮按钮定位是否正常Logo 等需要保留彩色的元素是否正常保持彩色没有被父级灰度影响视频、canvas、iframe 是否达到预期效果如果无法变灰确认是否可以接受灰度模式下文字对比度是否可读尤其是表格、表单错误提示、链接灰度模式下图片是否正常特别是深色背景图和半透明 PNG移动端页面在低端 Android 机型上是否有明显卡顿从灰度切回正常模式后页面状态是否完全恢复没有残留样式。这份清单很适合让测试同学照着验收避免漏项。5.3 黑白模式、暗黑模式别搞混不少产品同学会把“黑白模式”和“暗黑模式Dark Mode”混为一谈。这两者目标完全不同黑白模式强调的是“去色”把所有彩色内容变成黑白灰常用于纪念氛围或归档页面暗黑模式强调的是“低亮度”把亮色背景换成深色背景同时通常仍然保留内容的色彩体系。技术上也不同黑白模式用filter: grayscale(1)就能做全员钝化暗黑模式则需要大量适配关掉 filter 的事。如果项目同时有暗黑模式和黑白模式需求建议把它们做成两套独立控制不要叠加。否则又暗又灰页面基本没法看。如果一个元素在暗黑模式有特定的深色样式灰度模式下其实不用额外处理灰度会把它压平但如果这个元素刻意保留了彩色比如灰度模式下的彩色 Logo在暗黑模式下反而要单独验证显示效果。5.4 配合自动化截图做回归检查黑白模式这种“全站视觉变化”的需求靠人肉测试很累而且容易漏页面。我的做法是在项目的自动化测试里加一条截图对比用例开启灰度模式后抓取关键页面的截图站在人工审核的角度快速扫一遍确认没有布局错位、元素消失、fixed 元素乱跑。没有自动化条件的项目可以在浏览器控制台手动执行document.documentElement.classList.add(gray-mode);然后逐个页面过一遍检查清单上的重点项目。切回来就是document.documentElement.classList.remove(gray-mode);这种方式和跑完整套代码没区别因为页面应用的就是同一套 class 逻辑。我个人的习惯是黑白模式这种功能平时就埋在项目里用一个隐藏的全局变量控制默认关闭只在特殊时期打开。这样每次上线前测试也能顺带回归到而不是需求来了临时改代码改完又要急着发版。灰度功能本身很简单真正有价值的是把它沉淀成一套可靠、可复用、可验证的机制下次再有类似需求时你只需要把日期改一下就行。
返回列表