深入了解 timeago.js

1,434 阅读8分钟
原文链接: zhuanlan.zhihu.com
当你在朋友圈发了一条动态后,左下角显示 “刚刚”,过一会儿变成了“x分钟前”,再过一些时间变成了“x小时前”、“x天前”。怎么做到的?今天分享一个好玩的东东 — timeago.js !

分两部分说明:
  1. 使用方法及一些说明;
  2. 源码分析

timeago.js 是一个非常简洁、轻量级、不到 2kb 的很简洁的 Javascript 库,用来将 datetime 时间转化成类似于*** 时间前的描述字符串,例如:“3小时前”。
  • 本地化支持,默认自带中文和英文语言;

  • 之前 xxx 时间前、xxx 时间后;

  • 支持自动实时更新;

  • 支持npm方式和浏览器script方式;

  • 测试用例完善,执行良好;


一、使用方法

1. 下载timeago.js

npm install timeago.js

2. 引入 timeago.js

使用import引入

// ES6
import timeago from 'timeago.js';

// commonjs
var timeago = require("timeago.js");

或者通过script标签引入到html文件中

<script src="dist/timeago.min.js"></script>

3. 使用timeago类

var timeagoInstance = timeago();
timeagoInstance.format('2016-06-12');

4. 自动实时渲染

HTML为

<div class="need_to_be_rendered" datetime="2017-08-01 21:55:28"></div>

<div class="need_to_be_rendered" data-timeage="2017-08-01 21:55:28"></div>

Js为

var timeagoInstance = timeago();
timeagoInstance.render(document.querySelectorAll('.need_to_be_rendered'));

render方法传入一个或多个节点,表示需要实时渲染这些节点,被渲染的节点必须要有 datetime 或者 data-timeago 属性,属性值为日期格式的字符串。

5. 本地化

默认的语言是英文en, 这个库自带语言en和zh_CN

var timeagoInstance = timeago();
timeagoInstance.format('2016-06-12', 'zh_CN');

可以在构造函数中传入默认语言,也可以调用setLocale方法。

var timeagoInstance = timeago(currentDate, 'zh_CN');
// or
timeago().setLocale('zh_CN');

6. 注册本地语言

你可以自己自定义注册register你自己的语言。

// 本地化的字典样式
var test_local_dict = function(number, index, total_sec) {
  // number:xxx 时间前 / 后的数字;
  // index:下面数组的索引号;
  // total_sec:时间间隔的总秒数;
  return [
    ['just now', 'a while'],
    ['%s seconds ago', 'in %s seconds'],
    ['1 minute ago', 'in 1 minute'],
    ['%s minutes ago', 'in %s minutes'],
    ['1 hour ago', 'in 1 hour'],
    ['%s hours ago', 'in %s hours'],
    ['1 day ago', 'in 1 day'],
    ['%s days ago', 'in %s days'],
    ['1 week ago', 'in 1 week'],
    ['%s weeks ago', 'in %s weeks'],
    ['1 month ago', 'in 1 month'],
    ['%s months ago', 'in %s months'],
    ['1 year ago', 'in 1 year'],
    ['%s years ago', 'in %s years']
  ][index];
};

timeago.register('test_local', test_local_dict);

var timeagoInstance = timeago();
timeagoInstance.format('2016-06-12', 'test_local');

二、源码分析

外层结构如下:

可以看到timeago.js支持node端和浏览器端

!function (root, factory) {
  if (typeof module === 'object' && module.exports) {
    module.exports = factory(root); // nodejs support
    module.exports['default'] = module.exports; // es6 support
  }
  else
    root.timeago = factory(root);
}(typeof window !== 'undefined' ? window : this,
function () { 
});

这一块是一些初始的参数声明赋值:

其中locales对象里有en和zh_CN两个方法,前边说的本地化支持,默认自带中文和英文语言,就是这里实现的!

var indexMapEn = 'second_minute_hour_day_week_month_year'.split('_'),
    indexMapZh = '秒_分钟_小时_天_周_月_年'.split('_'),
    // build-in locales: en & zh_CN
    locales = {
      'en': function(number, index) {
        if (index === 0) return ['just now', 'right now'];
        var unit = indexMapEn[parseInt(index / 2)];
        if (number > 1) unit += 's';
        return [number + ' ' + unit + ' ago', 'in ' + number + ' ' + unit];
      },
      'zh_CN': function(number, index) {
        if (index === 0) return ['刚刚', '片刻后'];
        var unit = indexMapZh[parseInt(index / 2)];
        return [number + unit + '前', number + unit + '后'];
      }
    },
    // second, minute, hour, day, week, month, year(365 days)
    SEC_ARRAY = [60, 60, 24, 7, 365/7/12, 12],
    SEC_ARRAY_LEN = 6,
    ATTR_DATETIME = 'datetime',
    ATTR_DATA_TID = 'data-tid',
    timers = {}; // real-time render timers

toDate方法会把各种时间的表现形式input值转化成Date对象。

// format Date / string / timestamp to Date instance.
  function toDate(input) {
    if (input instanceof Date) return input;
    if (!isNaN(input)) return new Date(toInt(input));
    if (/^\d+$/.test(input)) return new Date(toInt(input));
    input = (input || '').trim().replace(/\.\d+/, '') // remove milliseconds
      .replace(/-/, '/').replace(/-/, '/')
      .replace(/(\d)T(\d)/, '$1 $2').replace(/Z/, ' UTC') // 2017-2-5T3:57:52Z -> 2017-2-5 3:57:52UTC
      .replace(/([\+\-]\d\d)\:?(\d\d)/, ' $1$2'); // -04:00 -> -0400
    return new Date(input);
  }

diffSec方法返回当前时间和被比较时间秒数差值。后边的formatDiff方法的第一个参数值就是diffSec方法返回值。

// calculate the diff second between date to be formated an now date.
  function diffSec(date, nowDate) {
    nowDate = nowDate ? toDate(nowDate) : new Date();
    return (nowDate - toDate(date)) / 1000;
  }

formatDiff方法是把当期时间和被比较时间的秒数差值转换成 *** time ago这种格式。是库里核心方法之一!

// format the diff second to *** time ago, with setting locale
  function formatDiff(diff, locale, defaultLocale) {
    // if locale is not exist, use defaultLocale.
    // if defaultLocale is not exist, use build-in `en`.
    // be sure of no error when locale is not exist.
    locale = locales[locale] ? locale : (locales[defaultLocale] ? defaultLocale : 'en');
    // if (! locales[locale]) locale = defaultLocale;
    var i = 0,
      agoin = diff < 0 ? 1 : 0, // timein or timeago
      total_sec = diff = Math.abs(diff);

    for (; diff >= SEC_ARRAY[i] && i < SEC_ARRAY_LEN; i++) {
      diff /= SEC_ARRAY[i];
    }
    diff = toInt(diff);
    i *= 2;

    if (diff > (i === 0 ? 9 : 1)) i += 1;
    return locales[locale](diff, i, total_sec)[agoin].replace('%s', diff);
  }

如果没有设置locale(以哪种语言展示),使用defaultLocale;如果defaultLocale也没设置,默认使用en(英文形式)展示。

diff值大于0,展现形式为xxx时间前;diff值小于0, 展现形式为xxx时间后。

再往下是一个for循环,for循环的过程就是不断地把diff值的单位往大变的过程。(由秒变分钟,再变小时、天等,只要可以)

module.exports = function(number, index) {
  return [
    ['just now', 'right now'],
    ['%s seconds ago', 'in %s seconds'],
    ['1 minute ago', 'in 1 minute'],
    ['%s minutes ago', 'in %s minutes'],
    ['1 hour ago', 'in 1 hour'],
    ['%s hours ago', 'in %s hours'],
    ['1 day ago', 'in 1 day'],
    ['%s days ago', 'in %s days'],
    ['1 week ago', 'in 1 week'],
    ['%s weeks ago', 'in %s weeks'],
    ['1 month ago', 'in 1 month'],
    ['%s months ago', 'in %s months'],
    ['1 year ago', 'in 1 year'],
    ['%s years ago', 'in %s years']
  ][index];
}

再往下的if条件判断是相当重要的一行代码!i的最终值决定了取上图二维数组的第几项。

i 值为0,表示diff值小于60秒,

而且当diff值小于9秒时,i值为0,取数组第一项['just now', 'a while']; 当diff值大等于9秒时,i值为1,取数组第一项;

i值大于0,表示diff值大等于60秒,

当diff值大于1时,表示复数,i值加1;diff值等于1,表示单数。i *= 2和 i += 1两个条件的约束下,根据diff的大小,获取二维数组里对应的索引项。

formatDiff方法里的这几行代码写的棒极了!

/**
   * nextInterval: calculate the next interval time.
   * - diff: the diff sec between now and date to be formated.
   *
   * What's the meaning?
   * diff = 61 then return 59
   * diff = 3601 (an hour + 1 second), then return 3599
   * make the interval with high performace.
  **/
  function nextInterval(diff) {
    var rst = 1, i = 0, d = Math.abs(diff);
    for (; diff >= SEC_ARRAY[i] && i < SEC_ARRAY_LEN; i++) {
      diff /= SEC_ARRAY[i];
      rst *= SEC_ARRAY[i];
    }
    // return leftSec(d, rst);
    d = d % rst;
    d = d ? rst - d : rst;
    return Math.ceil(d);
  }

如前边说,节点可以自动实时渲染,什么时候恰好刷新?由此方法返回值决定。nextInterval也是库非常重要的一个方法!

当前时间距被比较时间间隔为3601秒时,即1小时+1秒,那么下次节点渲染时间为3599秒后;如果diff值为3610秒,下次节点渲染时间为3590秒后。正如方法注释最后一行所说,节点渲染时间恰到好处是高性能的前提。

下边四个方法: getDateAttr获取属性data-timeago或者datetime的值;getAttr获取节点的某个属性的值;后两个方法分别是设置、获取节点的tid值。

// get the datetime attribute, jQuery and DOM
  function getDateAttr(node) {
    if(node.dataset.timeago) return node.dataset.timeago; // data-timeago supported
    return getAttr(node, ATTR_DATETIME);
  }
  function getAttr(node, name) {
    if(node.getAttribute) return node.getAttribute(name); // native
    if(node.attr) return node.attr(name); // jquery
  }
  function setTidAttr(node, val) {
    if(node.setAttribute) return node.setAttribute(ATTR_DATA_TID, val); // native
    if(node.attr) return node.attr(ATTR_DATA_TID, val); // jquery
  }
  function getTidFromNode(node) {
    return getAttr(node, ATTR_DATA_TID);
  }

Timeago声明一个构造函数,实例的原型对象上挂载了doRender、format、render、setLocale四个方法。

timeaogFactory方法用来实例化,在js里一切皆对象,它自身也有两个方法:register和cancel,register前边已提到,cancel后边说。

function Timeago(nowDate, defaultLocale) {
    
}
Timeago.prototype.doRender = function(node, date, locale) {
    
};
Timeago.prototype.format = function(date, locale) {
    
};
Timeago.prototype.render = function(nodes, locale) {
    
};
Timeago.prototype.setLocale = function(locale) {
    
};
function timeagoFactory(nowDate, defaultLocale) {

}
timeagoFactory.register = function(locale, localeFunc) {
    
};
timeagoFactory.cancel = function(node) {
    
};

render方法里遍历每个节点,分别调用doRender方法。在doRender方法里setTimeout方法里继续调用doRender方法,节点自动实时渲染的原理在此!

每次doRender的时候都会把setTimeout返回值作为timers的属性存起来,以便于清除定时器时,释放定时器资源。同时移除timer对象里上一次的tid属性,设置节点最新的tid值。

通过node.innerHTMl更新节点文本

为什么出现这个数 — 0x7FFFFFFF?

答:0x7FFFFFFF 是 setTimeout 的官方标准,如果时间超过 0x7FFFFFFF,会导致异常,换成十进制约为24.86天。

在时间间隔毫秒数diff与0x7FFFFFFF中取的最小值为下一次执行render的时间

Timeago.prototype.doRender = function(node, date, locale) {
    var diff = diffSec(date, this.nowDate),
      self = this,
      tid;
    // delete previously assigned timeout's id to node
    node.innerHTML = formatDiff(diff, locale, this.defaultLocale);
    // waiting %s seconds, do the next render
    timers[tid = setTimeout(function() {
      self.doRender(node, date, locale);
      delete timers[tid];
    }, Math.min(nextInterval(diff) * 1000, 0x7FFFFFFF))] = 0; // there is no need to save node in object.
    // set attribute date-tid
    setTidAttr(node, tid);
  };
Timeago.prototype.render = function(nodes, locale) {
    if (nodes.length === undefined) nodes = [nodes];
    for (var i = 0, len = nodes.length; i < len; i++) {
      this.doRender(nodes[i], getDateAttr(nodes[i]), locale); // render item
    }
  };

节点上存的tid值在释放定时器时用到。cancel调用之后会清除定时器方法。

可以清除所有节点的定时器,也可以清除某个节点的定时器。

timeagoFactory.cancel = function(node) {
    var tid;
    // assigning in if statement to save space
    if (node) {
      tid = getTidFromNode(node);
      if (tid) {
        clearTimeout(tid);
        delete timers[tid];
      }
    } else {
      for (tid in timers) clearTimeout(tid);
      timers = {};
    }
  };

写在最后

timeago.js轻量、无依赖、代码精干,值得我们学习下!

有时间线的场景需求,你可能用的到。

更多详情,移步这里

如果想关注最新技术,请关注微信公众号 AutoHome车服务前端团队