更进一步的夜间模式之细节一二

又是一篇 VOID 主题开发笔记。这篇文章主要讲述 VOID 中的新夜间模式,及其实现过程的一些琐碎细节。

VOID 2.0 版本起支持了夜间模式,其控制逻辑基础是 Jad 的这篇文章。最近在 Hran 那边看到了 macOS 10.14.4 及以上的 Safari 浏览器中通过媒体查询(media query)获得用户操作系统颜色偏好的方法,并且受到 iOS Nightshift 功能的启发,我决定使这个功能更上一层楼。

控制逻辑

本文主要关注功能逻辑,不讨论夜间模式样式方面的内容。为了得到良好的体验,这个功能需要前后端结合实现。

后端添加一个颜色模式的设置,分为「日间模式」、「夜间模式」、「自动模式」,其中:

  • 日间模式:前后端均不做任何处理
  • 夜间模式:后端直接输出 class 至 HTML 中,前端不处理
  • 自动模式:

    首先,为了防止前端闪烁,后端应该根据是否存在 cookie 来直接输出对应的 class 在 HTML 中。另外,前端的逻辑如下:

    1. 若操作系统为深色,则切换至深色,并设置较长的 cookie 过期时间,否则进行下一步
    2. 若能够获得地理位置,则计算该地日出日落时间,并且:

      • 若处于夜晚,则切换至夜间模式并设置 cookie,至日出时 cookie 过期
      • 若处于白天,切换至日间模式,清除 cookie
    3. 若不能获得地理位置,则以固定的时间作为日出日落时间,切换逻辑与 2 中相同

厘清逻辑后实现并不困难。剩下的部分说说实现中较为关键的步骤。

Cookie 用于在前端存储一些信息,常用于鉴权、保存标志位等。只要浏览器没有禁用 Cookie,前端的 Cookie 会随网络请求发送至后端,这使我们可以利用该技术为各用户(浏览器)提供针对性的服务。

在前端设置一个 Cookie:

var cookieString = '[NAME]=[VALUE];max-age=[AGE];path=[PATH]';
document.cookie = cookieString;

其中包括 [NAME][AGE][PATH] 参数,分别表示 Cookie 名,过期时间(秒),作用域。例如,设置 theme_dark=1,过期时间 1 小时,作用域为 /

var cookieString = 'theme_dark=1;max-age=3600;path=/';
document.cookie = cookieString;

在后端读取一个 Cookie(PHP):

$_COOKIE['theme_dark']; // = '1'

其结果为一个字符串。更严谨的操作中需要先检查 $_COOKIE 数组中是否包含 theme_dark 字段。

操作系统深色模式检查

由于这个属性尚没有 JS API,Hran 给出了一个迂回方法。首先设定 CSS 属性:

.dark-mode-state-indicator {
  position: absolute;
  top: -999em;
  left: -999em;
  z-index: 1;
}
@media (prefers-color-scheme: dark) {
  .dark-mode-state-indicator {
    z-index: 11;
  }
}

前端使用 JS 检查:

var getDeviceState = function(element) {
    var zIndex;
    if (window.getComputedStyle) {
        // 现代浏览器
        zIndex = window.getComputedStyle(element).getPropertyValue('z-index');
    } else if (element.currentStyle) {
        // ie8-
        zIndex = element.currentStyle['z-index'];
    }
    return parseInt(zIndex, 10);
};
var getPrefersDarkModeState = function () {
    var indicator = document.createElement('div');
    indicator.className = 'dark-mode-state-indicator';
    document.body.appendChild(indicator);
    return getDeviceState(indicator) === 11;
};

getPrefersDarkModeState(); // true or false

地理位置获取

根据 MDN:

Navigator.geolocation 只读属性返回一个 Geolocation 对象,通过这个对象可以访问到设备的位置信息。使网站或应用可以根据用户的位置提供个性化结果。

需注意,此 API 仅在 HTTPS 协议下、现代浏览器中可用,并且需要用户授权。根据我的实践,该 API 在不同浏览器中的行为并不是那么一致,若是更严肃的场合,可能需要使用百度等服务的 workaround。

检查浏览器是否支持该 API:

'geolocation' in navigator; // true or false

获取用户的位置信息:

navigator.geolocation.getCurrentPosition(function(position){
  // success
  console.log(position);
},function(data){
  // failed
  console.log(data);
});

getCurrentPosition 方法接受两个回调函数,第一个是成功时的回调,第二个是出错时的。出错时的回调中可以根据 data.code 获取出错原因,包括:

  1. PERMISSION_DENIED
  2. POSITION_UNAVAILABLE
  3. TIMEOUT
  4. UNKNOWN_ERROR

其中 PERMISSION_DENIED 表示用户手动禁止了网站访问位置,为了良好的体验,开发者应该向用户说明为什么会需要访问位置以及会如何使用位置信息,然后祈祷用户能重新赋予网站该权限。

时间计算与比较

日出日落时间

这个模块还是相对比较复杂的,其实我目前也并没有搞懂。但是令人开心的是已经有人为我们造好了轮子:Triggertrap/sun-js。这个库为原生的 Date 类注入了两个新的方法:

var sunset = new Date().sunset(latitude, longitude);
var sunrise = new Date().sunrise(latitude, longitude);

返回值是 Date 对象。结合 Geolocation,获取方法如下:

navigator.geolocation.getCurrentPosition(function(position) {
  var sunset = new Date().sunset(position.coords.latitude, position.coords.longitude);
  var sunrise = new Date().sunrise(position.coords.latitude, position.coords.longitude);
});

比较时间

sun-js 库得到的日出与日落时间根据当前时间不同不一定是当天的时间,例如晚间获取的日出时间其实是第二日的日出时间。考虑到 24 小时内日出日落时间不会有太大变化,为了方便比较,将日出日落时间均转换至同一天(当天),并且只精确至分钟。

navigator.geolocation.getCurrentPosition(function(position){
  sunset = new Date().sunset(position.coords.latitude, position.coords.longitude);
  sunrise = new Date().sunrise(position.coords.latitude, position.coords.longitude);
  // 全部转换至当天
  sunset = new Date(new Date().setHours(sunset.getHours(), sunset.getMinutes(), 0));
  sunrise = new Date(new Date().setHours(sunrise.getHours(), sunrise.getMinutes(), 0));
}

如此确定当前是否处于夜间:

var current = new Date();
// 格式化为小时
var sunset_s = sunset.getHours() + sunset.getMinutes()/60;
var sunrise_s = sunrise.getHours() + sunrise.getMinutes()/60;
var current_s = current.getHours() + current.getMinutes()/60;
if(current_s > sunset_s || current_s < sunrise_s){
  // 夜间
}else{
  // 日间
}

然后计算当前距离日出的时间:

if(current_s > sunset_s) // 如果当前为夜晚,日出时间应该切换至第二日
  sunrise = new Date(sunrise.getTime() + 3600000*24);
// 现在距日出还有 (s)
var toSunrise = (sunrise.getTime() - current.getTime())/1000; // 秒

这个时间就应该作为 Cookie 的过期时间,至日出时,该 Cookie 过期,网站则平滑地切换至日间模式。

代码

比较冗长,没必要贴在这里。我把代码摘出来建了一个 Gist,你可以点击查看:前往


2019-04-06 更新

事实证明利用精确的位置来计算日出与日落大材小用了,并且随之而来的授权弹窗更是让浏览体验大打折扣。在评论区的建议下,增加了利用时区来获取大概位置,并计算相应日出日落时间的方法。

核心的功能依赖 jsTimezoneDetect,通过该库获取到时区名称后,将其转换为大致的位置(即时区名称对应城市的位置),然后再按照前文所述方法计算对应的日出与日落时间。我制作了一个时区名称到经纬度的转换表,点击这里查看

如我在评论区中所述,仅使用时区来确定日出日落时间是不精确的,许多国家或地区只用一个时区(比如中国),但是从东到西时间差会很大(中国达到 4 小时之多)。不过权衡一下授权弹窗带来的差劲体验,这种误差也许可以接受吧。

添加新评论

已有 45 条评论

可以,手机访问感觉夜间模式看起来很舒服。

熊猫小A 熊猫小A 回复 @三棵树人

谢谢~

每次来博客都长的不一样 |´・ω・)ノ

熊猫小A 熊猫小A 回复 @柠檬酸

哼 说明你很久没来了 ╭(╯^╰)╮

可不可以实现白天主题都是白色,顶部尾部都自动切换呢?

熊猫小A 熊猫小A 回复 @wolone

暂不考虑。

大佬你好!我想在我的ty博客实现夜间模式,不知您这个方法可以吗?还有就是写的细腻但是我有点蠢 还是不大明白!希望得到大佬的帮助!

熊猫小A 熊猫小A 回复 @安涵

当然可以啊。文中只是专注切换逻辑,优秀的夜间模式样式才是关键~

安涵 安涵 回复 @熊猫小A

那真是太好了!就是上文中提到的完整代码,GitHub我打不开不知道是我的原因还是什么。方便发一份完整的代码到我的邮箱吗?麻烦了

熊猫小A 熊猫小A 回复 @安涵

已发至你的邮箱。

安涵 安涵 回复 @熊猫小A

收到了,但是不会使用,看不懂文章啊。大佬能给我贴个代码吗?

熊猫小A 熊猫小A 回复 @安涵

我没时间手把手教你,还请自行研究。

安涵 安涵 回复 @熊猫小A

好的

大佬就是不一样啊,要我就直接判断服务器时间,管你访客在哪里

熊猫小A 熊猫小A 回复 @Oasis Lee

其实实际体验的差别并不会很大。。。

泽泽 泽泽 回复 @Oasis Lee

我以前也这么干过

熊猫小A 熊猫小A 回复 @泽泽

服务器时间过分了,再不济也得是浏览器时间嘛

能不能一直使用黑夜模式呢

熊猫小A 熊猫小A 回复 @夏季

可以的,后台有设置

我好像是找到我浏览器一直是暗色主题的原因了。我发现Chrome 确定位置的方法是我访问 Google 网站的 IP 和搜索记录。(控制台打印发现是英国 GMT+1:00,可能是因为代理的原因)。在计算日出日落时候 sun.js 需要拿到一个`GMT+1:00Date 参数传递过去的是一个 GMT+8:00 的参数。这就出现了偏差。

latitude is 55.378051
VM700:10 longitude is -3.4359729999999997
VM700:12 现在的时区是-8
VM700:15 日落的时间是 2 点 58 分 // 修正以后是 19:58
VM700:16 日出的时间是 13 点35 分// 修正以后是 6:35
VM700:17 现在的时间是 17 点 49 分

熊猫小A 熊猫小A 回复 @孙洋

原来如此,总而言之是 Chrome 返回的位置出了偏差。

孙洋 孙洋 回复 @熊猫小A

对的,所以越精密的方法抗干扰性越差。最简单的还是简单粗暴的按照服务器时间指定好切换时间。在灵活一点的可以根据时区之类的方法。通过 getCurrentPosition 来定位的话有点高射炮打蚊子的感觉。而且加载起来是真的慢,希望能认真考虑一下。

熊猫小A 熊猫小A 回复 @孙洋

我已经添加了利用时区来获取大致位置,然后计算日出日落时间的方法,见 这里,新的方法已推送至 VOID 开发版,并作为默认方法。之前的方法仍然可以在高级设置中启用。

Hran Hran 回复 @熊猫小A

使用 JS 获取浏览器的时间就好,没必要这么麻烦

Hran Hran 回复 @熊猫小A

还有 Google Fonts 支持思源宋体了也不跟我讲,打你哦

主要是固定时间有点死板,所以还是想要取到日出日落时间……现在用时区的方法基本可以满意了~(当然还是比较麻烦

我也是刚知道的!求不打!

Hran Hran 回复 @熊猫小A

刚日落的时候没必要黑。。
可以先搞个日落模式
等到深夜了再搞黑

有道理!但是不会配色……

Hran Hran 回复 @熊猫小A


话说这个通过时区确认的没有纬度信息真的准确吗。。。。

说实话,并不是很准
缺少纬度信息、没有考虑东西方向上的跨度都会损失精度……但是怎么说呢,对首都人民来说还是比较准的 距离首都较远的朋友就……

Hran Hran 回复 @熊猫小A

我又看了一下还有两种不通过 ip 以及 定位来确定时区的实现。
*1 jstz
项目地址
*2. JS-timezone-detection
实现
原理

思路和代码都很漂亮,但是根据定位来确定时区,对访客来说体验很不好。先不说位置是一个很私人的权限,而且Safari 浏览器里每进入一个新页面都会弹窗一次。 这个很难受。个人觉得左岸同学提议的,利用 IP 地址来获取时区是个更好的解决方案。至于 IP 库问题。我觉得官方写在手册上的的 GeoIP应该是可以信赖的吧?(甚至可以单纯的利用时间段来控制就很好。中文博客国外访问需求很少。)
对了,现在是中午 13:18 分,我 Chrome ( 73.0.3683.75) 下这个网站一直是黑暗模式,不知道,是不是只有我是这样子?
还有最后提一个 Bug : 如果自定义高级配置中没有 nav 字段,高级配置就不会生效。

熊猫小A 熊猫小A 回复 @孙洋

感谢反馈。是的,根据定位来确定日出日落时间不是最佳体验,不过这是最精确的方法。实际上只获取到时区是不足够的,许多国家或地区只用一个时区(比如中国),但是从东到西时间差会很大(中国达到 4 小时之多)。这里也许需要做一个妥协,我会再考虑考虑。

另外你提到的两个 bug 我都没能复现。其中高级配置请检查是否符合 JSON 格式。

孙洋 孙洋 回复 @熊猫小A

另一个不是 bug 是我在复制的时候,多复制了一个 , ,十分感谢花时间帮我排查问题。

這個功能超棒,沒有夜間模式晚上看的時候眼睛簡直要瞎(晚上我接受不了白色

熊猫小A 熊猫小A 回复 @ohmyga

我是配色辣鸡,如果我会配色那就全天暗色了。这个夜间模式配色还是不太满意。

  1. 1
  2. 2