ARTICLE DETAIL

资讯详情

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

原生HTML一键实现网站黑白模式:灰度滤镜、状态记忆与避坑指南

原生HTML一键实现网站黑白模式:灰度滤镜、状态记忆与避坑指南 做web开发这些年几乎人人都会接到“让网站变灰”的需求。突发公共事件的哀悼时刻、定向纪念日、或者产品想推出一种更专注的阅读模式都会用到黑白模式。把整个站从彩色切成黑白听起来只是一行CSS的事可真要在原生HTML、CSS、JavaScript里做一个可以一键切换、刷新不丢状态、还不跟老项目冲突的方案里面坑并不少。我前后在多个内容型站点上搞过这功能也帮同事排查过不少翻车现场今天把一套“原生HTML一键实现网站黑白模式”的完整思路、代码和避坑手册整理出来。无论你是刚入门的同学还是要接手的开发只要目标是给网站提供灰度开关不依赖React/Vue、不想加一堆库这篇就能帮你省下调研时间。文中所有代码可以直接复制到任意静态页面里跑通再按自己的视觉微调。1. 内容整体设计与思路拆解1.1 核心原理一行CSS搞定灰度先明确一点所谓黑白模式本质是给页面整体套一层灰度滤镜。CSS的filter属性里grayscale(1)可以把元素饱和度压到0让画面变成黑白。只需要html { filter: grayscale(1); }整站就会立即变灰。但把这一行挂到html上后你会发现几个问题fixed导航栏可能漂移、页面刷新后会闪一下彩色、用户切掉之后没有记忆。所以“一行CSS”只是起点想让黑白模式变成一个可以正常交互的功能必须搭配原生JavaScript做状态管理。这里还要解释一个概念filter不是普通CSS属性那样“改了颜色值”就完事它是在元素渲染完成后做一次后处理。所以它并不改变DOM里元素的原有颜色也不会改变布局。这意味着加滤镜不会影响页面尺寸滚动条位置也不会变。这是原生实现最舒服的地方。1.2 为什么不直接引入插件或框架有些同学看到功能就想去npm搜一个grayscale插件或者为了做一个按钮把Vue挂进来。我的建议是别。这个需求的复杂度原生JS四十行以内就能解决引入框架会带来三个问题首屏加载多出几十KB甚至几百KB资源只为了一个开关项目本身的全局样式、事件绑定可能被框架覆盖运营和接手维护的人不一定会框架改起来费劲。当然如果你的项目本身就用框架那用组件方式实现也可以。我这里坚持“原生”更适合老项目、CMS模板、单页活动站以及任何想保持零依赖的场景。它的最大优势是体积小、逻辑透明、容易嵌入任何页面。下面用一个表说明几种方案对我这类场景的取舍。方案代码量依赖对老项目友好度维护成本原生CSS JS约40行无高低jQuery插件约20行需要jQuery一般中React/Vue组件组件代码需要框架低高我实际使用中原生方案基本是完胜尤其当你不想改全局构建工具链的时候。1.3 交互形态手动开关、自动日期、后台配置黑白模式并不只是“一个按钮”这么简单。按需求不同我归纳了三种常见形态手动切换页面固定一个开关用户点击后立即灰度再次点击恢复彩色。这个要记住用户偏好。自动日期到了某个指定日期无需点击自动灰度过了日期自动恢复。常见于纪念日、专题页面。被动开关由运营在后台或配置文件中打开前端自动生效不给普通用户手动入口。三种形态的底层逻辑其实是同一个用一个布尔状态值驱动html根节点的类名。手动切换是用户改状态自动日期是代码计算状态后台配置是读取外部变量。所以后面我写的setState函数可以同时满足这三种需求。后面第3节会具体演示。2. 核心细节解析与实操要点2.1 grayscale取值与视觉差异grayscale支持0到1之间的小数也支持百分比。0%保留原色100%完全黑白50%是半灰度。这个参数在视觉上差别很大。全灰适合严肃场景半灰适合“柔和模式”或者预览样式不会让图片太死板。/* 全灰 */ filter: grayscale(1); filter: grayscale(100%); /* 半灰 */ filter: grayscale(0.5); filter: grayscale(50%);有人把grayscale写成gray或grey那是无效的。正确拼写是grayscale。这里的“gray”和“grey”在英语里都对但CSS函数名固定是grayscale别顺手写错。其实这样的错误还很常见尤其代码提示不完整时。还有一个细节filter对所有的后代元素一起起作用包括图片、视频、Canvas、背景色。但注意CSS变量和背景图片里的颜色不会被“物理地”改变只是渲染结果显示为灰度。所以如果页面有依赖颜色值判断的JS不会受影响。2.2 filter的层叠上下文和fixed定位问题这是最容易踩的深坑。给元素设置filter后这个元素会变成一个层叠上下文和包含块。包含块就是后代元素定位的参照物。正常情况下position: fixed元素的包含块是视口但当它的某个祖先有filter、transform、perspective等属性时fixed元素会以该祖先为包含块导致原本固定位置不固定。比如我遇到过一个数据后台顶部导航是position: fixed; top: 0;。我加上html.grayscale之后导航不再固定在视口顶部而是跟着页面上下滚动滚动到下面时导航就跑到页面开头去了。排查了很久最后定位到就是滤镜导致的。解决方案有几种如果项目结构可控把固定元素从被滤镜作用的容器中移出去常见做法是把浮动按钮、导航放到body外层的独立div但整个html都加了滤镜这招不一定百分百奏效。如果filter加在body上导航放在html和body外面其实不可能因为所有DOM都在body内。最实用的是给被灰化的根元素加上一个独立的DIV包裹让fixed元素在这个DIV之外。但全局站点结构不一定能重构成这样。如果只是弹窗层级乱掉可以给弹窗容器也加filter: grayscale(1)一般能恢复层叠顺序。这里我想多说一句很多“网上的方案”只告诉你加一行filter但没有告诉你固定定位会漂移真正上线后被测试发现再返工很痛苦。我建议你在做灰度功能时专门把导航、弹窗、下拉菜单都点一遍确认没有跳出或者错位。2.3 按钮文案与无障碍状态按钮不能只是一个写着“黑白”的方块还得让读屏用户知道状态。这里给出一个相对完整的按钮button idgrayscale-toggle typebutton aria-pressedfalse aria-label开启黑白模式开启黑白/buttonaria-pressed用来表示切换按钮的按下状态。页面为灰度时它的值是true彩色时是false。当按钮只是视觉上变了个样没有语义读屏用户无法感知这容易造成可访问性问题。文字内容也要跟着切换。在点击事件里除了切换类名还要改按钮文字。如果按钮里放了图标建议用内联SVG或者伪元素来承载图标变更时只改span里的文字图标通过CSS的aria-pressed状态切换。这样比直接改整段innerHTML更安全也不会因为用户按钮里还有别的元素而被误删。2.4 状态存储选型localStorage最省心保存用户选择的灰度状态有localStorage、sessionStorage、cookie三种选项。我用表格对比一下存储方式生命周期作用域请求携带适合场景localStorage永久单个域名否用户长期偏好sessionStorage标签页关闭即失效单个标签页否临时状态cookie可设置过期时间按域名/路径会被带上需要服务端读取所以默认选localStorage。它存储在浏览器本地大小一般有5MB存几个字符完全够用。写入时用setItem读取用getItem值只能是字符串所以我们用1和0来代表开和关。有个小坑是在隐私模式或用户关闭站点数据时localStorage.setItem可能抛出SecurityError。如果你在init里直接写localStorage页面可能未捕获异常后续事件绑定断掉。所以务必用try-catch包一层。另外不要把其他数据也塞进同一个键里比如存一个JSON对象每次读取还要.parse。这里只需要一个布尔值保持简单。2.5 兼容性与性能要点filter语法的兼容性要看浏览器。Chrome、Edge、Firefox、Safari从很早的版本就支持但当时不少版本需要带-webkit-前缀。现在新浏览器不加前缀完全没问题老Safari可能需要-webkit-filter。保险起见灰度规则里两个都写上html.grayscale { -webkit-filter: grayscale(1); filter: grayscale(1); }性能上整页滤镜会调用合成器把页面内容重新渲染为灰度图。静态文本没感觉但如果页面有大量高清图片、视频、Canvas动画开启灰度后滑动时帧率会下降。解决办法我在第4.5节里会说。3. 完整实现与步骤3.1 HTML结构根类名加浮动按钮为了让方案“即插即用”我在页面里放一个固定浮动按钮同时利用html根节点的类名控制整体灰度。避免去改body因为html包裹的视口内容最完整。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title示例页面/title !-- 这里放防止闪动的内联脚本 -- script try { if (localStorage.getItem(site-grayscale) 1) { document.documentElement.classList.add(grayscale); } } catch (e) {} /script /head body !-- 页面内容 -- button idgrayscale-toggle typebutton aria-pressedfalse开启黑白/button !-- 脚本放在body底部 -- /body /html内联脚本必须放在head中而且要在任何样式和内容渲染之前执行。这样刷新页面时html根节点一开始就带着grayscale类CSS一加载就会以灰度状态渲染不会出现先彩色后变灰的闪烁。这里有个前提脚本里的localStorage读取要快。内联脚本本身就阻塞渲染但这段代码只有几行成本极低。如果你把它放到body底部刷新时页面已经渲染了一大段彩色再去变灰用户会很明显地看到闪烁这就是顺序的重要性。3.2 CSS样式灰度规则与按钮美化样式文件里除了灰度规则还有按钮的固定定位和两种状态下的背景、文字。CSS代码可以这样写html.grayscale { -webkit-filter: grayscale(1); filter: grayscale(1); } #grayscale-toggle { position: fixed; right: 20px; bottom: 20px; z-index: 99999; padding: 8px 16px; border: 1px solid rgba(0,0,0,0.3); border-radius: 20px; background: #fff; color: #333; font-size: 14px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.12); transition: background-color 0.2s, color 0.2s; } html.grayscale #grayscale-toggle { background: #222; color: #fff; border-color: #555; }按钮放在body内部因此它也处于html的滤镜作用范围内。在灰度模式下按钮本身是黑白的所以CSS里我通过html.grayscale #grayscale-toggle让它变成暗底白字提高对比度。注意由于父级filter把颜色变灰这里的#222在渲染后会变成一个深灰色而不是纯黑但辨识度足够。如果你不想按钮在黑白模式下跟随变灰可以把按钮放到iframe里但那样复杂度就上去了不推荐。对大多数页面来说让按钮一起变成黑白反而是整体统一的表现。3.3 JavaScript逻辑状态三件套完整的JS如下复制即用。这段代码兼容现代浏览器不依赖任何框架。(function () { var TOGGLE_ID grayscale-toggle; var STORAGE_KEY site-grayscale; function isGrayscale() { return document.documentElement.classList.contains(grayscale); } function applyState(state) { document.documentElement.classList.toggle(grayscale, state); var btn document.getElementById(TOGGLE_ID); if (btn) { btn.innerText state ? 关闭黑白 : 开启黑白; btn.setAttribute(aria-pressed, state ? true : false); } try { localStorage.setItem(STORAGE_KEY, state ? 1 : 0); } catch (e) { // 隐私模式下写入失败可以忽略本次会话状态依然生效 } } function init() { var saved 1 localStorage.getItem(STORAGE_KEY); applyState(saved); var btn document.getElementById(TOGGLE_ID); if (btn) { btn.addEventListener(click, function () { applyState(!isGrayscale()); }); } } if (document.readyState loading) { document.addEventListener(DOMContentLoaded, init); } else { init(); } })();这里有几个细节值得展开。第一applyState是唯一修改状态的函数所有入口都走它逻辑不会散落。第二读取localStorage在init里因为head那段脚本已经提前处理过类名所以init里的applyState(saved)实际上会再次同步按钮文字和存储值保证UI一致。第三classList.toggle的第二个参数是强制状态写state不是简单切换。这样做避免从多个入口调用时状态不一致。如果你写成classList.toggle(grayscale)那每次点击确实翻转但如果自动日期也调这个方法可能一会儿黑一会儿白出现状态竞争。所以强烈建议使用带默认值的toggle或add/remove。如果你担心实际项目中会有多个按钮可以简单地把getElementById换成querySelectorAll遍历绑定。但我更喜欢事件委托因为按钮可能是动态生成的document.addEventListener(click, function (e) { if (e.target.closest(#grayscale-toggle)) { applyState(!isGrayscale()); } });这样只要页面里存在带该id的元素就能正常响应也省去了在DOMContentLoaded里二次绑定的麻烦。3.4 部署到任意页面的三个步骤如果你要在现有网站里接入不要复制整个页面只要按顺序改三个位置在head里加入3.1中防止闪动的内联脚本在CSS文件末尾加入灰度样式和按钮样式在任意一个JS文件底部复制3.3的JS代码并在HTML合适位置加入按钮。如果网站有多个页面每个页面都要加按钮和内联脚本。不想重复的话可以抽一个公共header/footer文件或者直接用模板注入。总之原生的好处就是每个页面都能独立运行不必担心构建产物。需要注意如果网站启用了内容安全策略(CSP)内联脚本和eval可能会被拦。此时需要把读取localStorage的代码放到外部脚本里并接受可能出现的微小闪烁。或者你把CSP的script-src加上unsafe-inline。但出于安全考虑不建议为一个小功能放松CSP所以外部脚本是更稳妥的选择。3.5 扩展到期自动灰度与后台开关我经常被问到“能不能到了某天自动变灰”可以。在init里加一段日期判断。代码如下function isAutoGrayDate() { var config window.SITE_CONFIG || {}; if (!config.grayscaleDate) { return false; } var now new Date(); var dateStr now.getFullYear() - String(now.getMonth() 1).padStart(2, 0) - String(now.getDate()).padStart(2, 0); return dateStr config.grayscaleDate; }然后init里var saved localStorage.getItem(STORAGE_KEY); if (saved ! null) { applyState(saved 1); } else { applyState(isAutoGrayDate()); }但如果按用户手动开关比如用户手动“关闭黑白”在自动日期当天也会被覆盖掉这是业务逻辑问题。我一般建议把自动日期当作“默认状态”用户手动操作仍然有最高优先级。所以更好的逻辑是var saved localStorage.getItem(STORAGE_KEY); if (saved ! null) { applyState(saved 1); } else { applyState(isAutoGrayDate()); }这个逻辑我第一次落地的时候就翻车了因为一开始总是先判断自动日期导致用户手动设置被无视运营以为按钮坏了。这里单独提出来希望大家别再踩。4. 常见问题与排查技巧实录4.1 点击按钮后没有任何反应可能的排查顺序是控制台报错 - html类名是否变化 - CSS里是否被覆盖文件覆盖。我遇到最多的是id不一致。比如按钮id写成了grayscaleToggleJS里却写grayscale-toggle绑定失败。其次脚本在按钮DOM之前执行如果没做DOMContentLoaded判断getElementById拿到nulladdEventListener自然报错。所以我在init里加入readyState判断就是为了兼容脚本放在head和body底部的不同情况。另一个容易忽略的点如果页面上有多个按钮或者按钮被动态渲染你可能需要事件委托。原生可以写成document.addEventListener(click, function (e) { if (e.target.closest(#grayscale-toggle)) { applyState(!isGrayscale()); } });这样即使按钮DOM后生成也能响应点击。如果只用静态按钮不用事件委托也行。4.2 页面变灰后fixed导航栏“乱跑”或弹窗层级异常这个问题来自filter的包含块机制前面已经解释。实际操作中如果导航栏是fixed且出现漂移可以用一个变通方案不要对整个html加filter改成对一个包住主要内容但不包含导航的div加filter导航区域保持原色。这样能够保留固定定位因为filter容器不再是fixed元素的祖先。缺点就是导航和内容风格不同视觉上可能有些割裂。如果问题不是漂移而是弹窗被滤镜背景“盖住”可以尝试把弹窗的z-index调得极高并也给它加filter: grayscale(1)。实测大多数弹窗都能正常显示只是层级关系需要重新调。4.3 灰度状态下按钮看不清按钮自身的颜色也被压成灰阶这是filter的正常效果。如果你非要让按钮在黑白模式下保持彩色的美观在原生HTML架构下基本无解。你可以做的只是提高对比度比如把背景改为纯黑、文字改为纯白虽然在滤镜下都偏灰但总比原来的浅色背景清楚很多。如果你真的需要按钮完全不受灰度影响一个思路是用固定定位的浮动层放在html本身之外——但浏览器不允许DOM节点在html之外。另一个思路是把这个按钮用iframe嵌入iframe里是另一个文档父页面滤镜不会影响iframe内容。但为了一个按钮引入iframe维护成本和页面安全收益都不划算。除非是运营核心入口否则我建议让它跟随页面一起变灰。4.4 刷新后状态丢失先从三个角度排查是否用了file://协议打开页面有些浏览器对本地文件的localStorage支持不稳定直接用localhost或者部署到测试域名再看。setItem是否抛异常打开控制台在状态切换后查看Application里的Storage面板确认site-grayscale的值是否更新。跨页面或跨域问题localStorage按域名隔离如果你从a.com切到b.com那边自然没有这个键。如果希望全站统一需要把偏好同步到服务端。还有一个相对隐蔽的问题head里的内联脚本先读取localStorage并给html加类如果此时存储的值是1但是CSS样式文件还没加载那么等CSS加载后html.grayscale规则自然生效不会闪。但如果你的CSS加载方式是通过异步或者动态插入就可能先渲染彩色再加载CSS这个不算状态丢失算是渲染时序。检查时先保证样式表在head里正常同步加载。4.5 大页面开启灰度后滚动卡顿整页filter的计算确实会增加渲染压力。最直接的降级方案是缩小滤镜作用域。比如只给#main-content加灰度类导航、页脚保持不变。另一个方法是在灰度状态下减少动画和视频数量。对于视频可以加一个简单的处理var videos document.querySelectorAll(video); if (grayscale) { videos.forEach(v v.pause()); }暂停视频的代价是用户体验变差但特殊日子整站变灰时暂停自动播放的视频反而显得更庄重也省去滤镜处理视频的负担。这是我在某个活动页上线时做的处理效果不错。如果还想减少首帧压力可以在CSS里用content-visibility: auto对长列表进行渲染优化但这是另一个话题灰度模式下能用普通模式下也能用。4.6 常见问题速查表现象原因处理点击没反应id不一致、脚本未绑定检查id、事件绑定、控制台报错整页不变灰CSS被覆盖、选择器写错检查html.grayscale规则临时加!importantfixed导航漂移filter创建包含块缩小作用域或在容器内调整fixed弹窗不灰或层级乱iframe/filter层叠上下文弹窗加filter调整z-index刷新闪彩色再变灰内联脚本位置太晚放到head同步脚本里状态不保存localStorage异常/跨域检查storage值、try-catch视频、动画卡顿滤镜计算压力大暂停视频缩小滤镜范围这张表是我自己排查时的快捷索引实际遇到问题时按行查一遍大部分都能解决。最后再说说我个人的习惯。很多功能看起来简单但上线前的自测清单不能省在灰度模式下把导航、弹窗、下拉、键盘Tab都走一遍尤其要试刷新页面。别只想“CSS能变灰就完事”用户真正感受到的是一个连贯的状态切换过程。希望这篇“原生HTML一键实现网站黑白模式”的完整记录能帮你在下一个需求里少走几次弯路。有更好做法欢迎在评论区聊。
返回列表