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

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

请注意,本文编写于 109 天前,最后修改于 13 天前,其中某些信息可能已经过时。

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 小时之多)。不过权衡一下授权弹窗带来的差劲体验,这种误差也许可以接受吧。

添加新评论

已有 47 条评论

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

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

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

很不错的功能啊,不过位置信息怎么说也是一个较为敏感的权限,一般看到第一反应就是拒绝了233。这个不能直接通过IP来判断经纬度进而用sun-js计算时间吗

熊猫小A 熊猫小A 回复 @左岸

不是不可以,但是这样势必要引入第三方的(大概率既不靠谱也不安全的)IP 库,因此我还是倾向使用原生的 API,至少我能确保自己没有滥用位置信息。

打赏正常!(穷逼学生开溜

PS:建议VOID用户更新至最新开发版以体验此次更新
PPS:打赏好像不能用诶真香警告?

沙发!
感觉一直开着夜间模式就行啊...

也许你觉得只有夜间模式就行,但我必须给出多个选项啊。

夏季 夏季 回复 @Ryoma

那是你的想法,但是别人也有别人的需要撒. ヾ(≧∇≦*)ゝ

  1. 1
  2. 2