本文为稀土技能社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!

1. 前言

我们好,我是若川。我倾力继续组织了一年每周我们一同学习200行左右的源码共读活动,感兴趣的能够点此扫码加我微信 ruochuan12 参加。别的,想学源码,极力引荐重视我写的专栏《学习源码整体架构系列》,现在是重视人数(4.1k+人)榜首的专栏,写有20余篇源码文章。

咱们开发事务时经常会运用到组件库,一般来说,许多时候咱们不需要关怀内部完成。可是假如期望学习和深究里边的原理,这时咱们能够剖析自己运用的组件库完成。有哪些高雅完成、最佳实践、前沿技能等都能够值得咱们学习。

比较于原生 JS 等源码。咱们或许更应该学习,正在运用的组件库的源码,因为有助于协助咱们写事务和写自己的组件。

假如是 Vue 技能栈,开发移动端的项目,大多会选用 vant 组件库,现在(2022-11-20)star 多达 20.5k,最新版本是 v4.0.0-rc7。咱们能够选择 vant 组件库学习,我会写一个组件库源码系列专栏,欢迎我们重视。

  • vant 4 行将正式发布,支撑暗黑主题,那么是怎么完成的呢
  • 跟着 vant4 源码学习怎么用 vue3+ts 开发一个 loading 组件,仅88行代码
  • 剖析 vant4 源码,怎么用 vue3 + ts 开发一个瀑布流翻滚加载的列表组件?

这次咱们来学习倒计时组件,countdown

学完本文,你将学到:

1. 怎么开发一个更高雅的毫秒级烘托的倒计时组件
2. 学会运用 requestAnimationFrame
3. 等等

2. 准备工作

看一个开源项目,咱们能够先看 README.md 再看 github/CONTRIBUTING.md

2.1 克隆源码

You will need Node.js >= 14 and pnpm.

# 引荐克隆我的项目
git clone https://github.com/lxchuan12/vant-analysis
cd vant-analysis/vant
# 或许克隆官方仓库
git clone git@github.com:vant-ui/vant.git
cd vant
# 装置依赖,假如没装置 pnpm,能够用 npm i pnpm -g 装置,或许检查官网经过其他方式装置
pnpm i
# 启动服务
pnpm dev

履行 pnpm dev 后,这时咱们打开倒计时组件 http://localhost:5173/#/zh-CN/count-down

3. 倒计时组件可谓是十分常用

在各种电商类或许其他的移动端页面中,倒计时真的是太常见了。咱们自己也根本能够快速的写一个倒计时组件。代码完成参阅这儿,首要是 JavaScript

代码中,我直接运用的 setInterval 和每秒钟履行一次。把倒计时的时候减去1s,当倒计时毫秒数不足时用 clearInterval 清除中止定时器。

但假如要完成毫秒级的倒计时这种办法行不通。 别的 setInterval 这种做法,并不是最优的。 那么,vant 倒计时组件中,是怎么处理毫秒级和完成倒计时呢。

带着问题咱们直接找到 countdown demo 文件:vant/packages/vant/src/count-down/demo/index.vue。为什么是这个文件,我在之前文章跟着 vant4 源码学习怎么用 vue3+ts 开发一个 loading 组件,仅88行代码剖析了其原理,感兴趣的小伙伴点击检查。这儿就不赘述了。

4. 运用 demo 调试源码

组件源码中的 TS 代码我不会过多解说。没学过 TS 的小伙伴,引荐学这个TypeScript 入门教程。 别的,vant 运用了 @vue/babel-plugin-jsx 插件来支撑 JSX、TSX

剖析 vant4 源码,学会用 vue3 + ts 开发毫秒级烘托的倒计时组件,真是妙啊

// vant/packages/vant/src/count-down/demo/index.vue
<script setup lang="ts">
import VanGrid from '../../grid';
import VanGridItem from '../../grid-item';
import VanCountDown, { type CountDownInstance } from '..';
import { ref } from 'vue';
import { useTranslate } from '../../../docs/site';
import { showToast } from '../../toast';
const t = useTranslate({
  'zh-CN': {
    reset: '重置',
    pause: '暂停',
    start: '开端',
    finished: '倒计时完毕',
    millisecond: '毫秒级烘托',
    customStyle: '自定义款式',
    customFormat: '自定义格局',
    manualControl: '手动操控',
    formatWithDay: 'DD 天 HH 时 mm 分 ss 秒',
  },
});
const time = ref(30 * 60 * 60 * 1000);
const countDown = ref<CountDownInstance>();
// 开端
const start = () => {
  countDown.value?.start();
};
// 暂停
const pause = () => {
  countDown.value?.pause();
};
// 重置
const reset = () => {
  countDown.value?.reset();
};
const onFinish = () => showToast(t('finished'));
</script>
<template>
  <!-- 根本运用 -->
  <demo-block :title="t('basicUsage')">
    <van-count-down :time="time" />
  </demo-block>
  <!-- 自定义烘托 -->
  <demo-block :title="t('customFormat')">
    <van-count-down :time="time" :format="t('formatWithDay')" />
  </demo-block>
  <!-- 毫秒级烘托 -->
  <demo-block :title="t('millisecond')">
    <van-count-down millisecond :time="time" format="HH:mm:ss:SS" />
  </demo-block>
  <!-- 自定义款式-->
  <demo-block :title="t('customStyle')">
    <van-count-down :time="time">
      <template #default="currentTime">
        <span class="block">{{ currentTime.hours }}</span>
        <span class="colon">:</span>
        <span class="block">{{ currentTime.minutes }}</span>
        <span class="colon">:</span>
        <span class="block">{{ currentTime.seconds }}</span>
      </template>
    </van-count-down>
  </demo-block>
  <!-- 手动操控 -->
  <demo-block :title="t('manualControl')">
    <van-count-down
      ref="countDown"
      millisecond
      :time="3000"
      :auto-start="false"
      format="ss:SSS"
      @finish="onFinish"
    />
    <van-grid clickable :column-num="3">
      <van-grid-item icon="play-circle-o" :text="t('start')" @click="start" />
      <van-grid-item icon="pause-circle-o" :text="t('pause')" @click="pause" />
      <van-grid-item icon="replay" :text="t('reset')" @click="reset" />
    </van-grid>
  </demo-block>
</template>

demo 文件中,咱们能够看出 import VanCountDown, { type CountDownInstance } from '..';,引入自 vant/packages/vant/src/count-down/index.ts。咱们继续来看进口 index.ts

5. 进口 index.ts

首要便是导出一下类型和变量等。

// vant/packages/vant/src/count-down/index.ts
import { withInstall } from '../utils';
import _CountDown from './CountDown';
export const CountDown = withInstall(_CountDown);
// 默许导出
// import xxx from 'vant'
export default CountDown;
export { countDownProps } from './CountDown';
export type { CountDownProps } from './CountDown';
export type {
  CountDownInstance,
  CountDownThemeVars,
  CountDownCurrentTime,
} from './types';
declare module 'vue' {
  export interface GlobalComponents {
    VanCountDown: typeof CountDown;
  }
}

withInstall 函数在之前文章5.1 withInstall 给组件目标添加 install 办法 也有剖析,这儿就不赘述了。

咱们能够在这些文件,任意位置加上 debugger 调试源码。

截两张调企图。

调试 Countdown setup

剖析 vant4 源码,学会用 vue3 + ts 开发毫秒级烘托的倒计时组件,真是妙啊

调试 useCountDown

剖析 vant4 源码,学会用 vue3 + ts 开发毫秒级烘托的倒计时组件,真是妙啊

咱们跟着调试,继续剖析 Countdown

6. 主文件 Countdown

// vant/packages/vant/src/count-down/CountDown.tsx
import { watch, computed, defineComponent, type ExtractPropTypes } from 'vue';
// Utils
import {
  truthProp,
  makeStringProp,
  makeNumericProp,
  createNamespace,
} from '../utils';
import { parseFormat } from './utils';
// Composables
import { useCountDown } from '@vant/use';
import { useExpose } from '../composables/use-expose';
const [name, bem] = createNamespace('count-down');
export const countDownProps = {
  time: makeNumericProp(0),
  format: makeStringProp('HH:mm:ss'),
  autoStart: truthProp,
  millisecond: Boolean,
};
export type CountDownProps = ExtractPropTypes<typeof countDownProps>;
export default defineComponent({
  name,
  props: countDownProps,
  emits: ['change', 'finish'],
  setup(props, { emit, slots }) {
    // 代码省掉,下文叙说
  },
});

6.1 setup 部分

这一部分首要运用了useCountDown

setup(props, { emit, slots }) {
  // useCountDown 组合式 API
  const { start, pause, reset, current } = useCountDown({
    // 传入的时刻毫秒数,+ 是字符串转数字
    time: +props.time,
    // 毫秒级烘托
    millisecond: props.millisecond,
    // 回调事情,onChange, onFinish
    onChange: (current) => emit('change', current),
    onFinish: () => emit('finish'),
  });
  // 格局化时刻
  const timeText = computed(() => parseFormat(props.format, current.value));
  // 重置,重新开端
  const resetTime = () => {
    reset(+props.time);
    if (props.autoStart) {
      start();
    }
  };
  watch(() => props.time, resetTime, { immediate: true });
  // 导出 start、pause、reset
  useExpose({
    start,
    pause,
    reset: resetTime,
  });
  return () => (
    // 有传入插槽,运用插槽,支撑自定义款式,传入解析后的时刻目标
    <div role="timer" class={bem()}>
      {slots.default ? slots.default(current.value) : timeText.value}
    </div>
  );
},

6.2 useExpose 暴露

import { getCurrentInstance } from 'vue';
import { extend } from '../utils';
// expose public api
export function useExpose<T = Record<string, any>>(apis: T) {
  const instance = getCurrentInstance();
  // 合并到 getCurrentInstance().proxy 上
  if (instance) {
    extend(instance.proxy as object, apis);
  }
}

经过 ref 能够获取到 Countdown 实例并调用实例办法,详见组件实例办法。

Vant 中的许多组件供给了实例办法,调用实例办法时,咱们需要经过 ref 来注册组件引用信息,引用信息将会注册在父组件的 $refs 目标上。注册完成后,咱们能够经过 this.$refs.xxx 或许

const xxxRef = ref();
xxxRef.value.xxx();

访问到对应的组件实例,并调用上面的实例办法。

7. useCountDown 组合式 API

7.1 parseTime 解析时刻

// vant/packages/vant-use/src/useCountDown/index.ts
import {
  ref,
  computed,
  onActivated,
  onDeactivated,
  onBeforeUnmount,
} from 'vue';
import { raf, cancelRaf, inBrowser } from '../utils';
export type CurrentTime = {
  days: number;
  hours: number;
  total: number;
  minutes: number;
  seconds: number;
  milliseconds: number;
};
export type UseCountDownOptions = {
  time: number;
  // 毫秒
  millisecond?: boolean;
  onChange?: (current: CurrentTime) => void;
  onFinish?: () => void;
};
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// 解析时刻
function parseTime(time: number): CurrentTime {
  const days = Math.floor(time / DAY);
  const hours = Math.floor((time % DAY) / HOUR);
  const minutes = Math.floor((time % HOUR) / MINUTE);
  const seconds = Math.floor((time % MINUTE) / SECOND);
  const milliseconds = Math.floor(time % SECOND);
  return {
    total: time,
    days,
    hours,
    minutes,
    seconds,
    milliseconds,
  };
}

以上这大段代码,parseTime 是首要函数,解析时刻,生成天数、小时、分钟、秒、毫秒的目标。

7.2 useCountDown 实在逻辑

实在逻辑这一段能够不必细看。能够调试时再细看。

首要便是运用 Date.now() 会自己走的原理。

初始化开端:完毕时刻 = 当时时刻戳 + 剩余时刻
获取:剩余时刻 = 完毕时刻 - 当时时刻戳
加上自己定时器逻辑循环
剩余时刻便是实在消逝的时刻
假如是毫秒级烘托,就直接赋值剩余时刻
假如不是,那就判别是同一秒才赋值

设计的十分巧妙,看到这儿,咱们或许感慨:不得不敬服。

// 简化版 一
const useCountDown = (options) => {
  let endTime;
  let remain = options.time;
  const getCurrentRemain = () => Math.max(endTime - Date.now(), 0);
  const start = () => {
    endTime = Date.now() + remain;
  }
  const setRemain = (value) => {
    remain = value;
  };
  return {
    start,
  }
}
const { start } = useCountDown({time: 3 * 1000});
start();

码上倒计时简化版二

// vant/packages/vant-use/src/useCountDown/index.ts
function isSameSecond(time1: number, time2: number): boolean {
  return Math.floor(time1 / 1000) === Math.floor(time2 / 1000);
}
export function useCountDown(options: UseCountDownOptions) {
  let rafId: number;
  let endTime: number;
  let counting: boolean;
  let deactivated: boolean;
  const remain = ref(options.time);
  const current = computed(() => parseTime(remain.value));
  const pause = () => {
    counting = false;
    cancelRaf(rafId);
  };
  const getCurrentRemain = () => Math.max(endTime - Date.now(), 0);
  const setRemain = (value: number) => {
    remain.value = value;
    options.onChange?.(current.value);
    if (value === 0) {
      pause();
      options.onFinish?.();
    }
  };
  const microTick = () => {
    rafId = raf(() => {
      // in case of call reset immediately after finish
      if (counting) {
        setRemain(getCurrentRemain());
        if (remain.value > 0) {
          microTick();
        }
      }
    });
  };
  const macroTick = () => {
    rafId = raf(() => {
      // in case of call reset immediately after finish
      if (counting) {
        const remainRemain = getCurrentRemain();
        if (!isSameSecond(remainRemain, remain.value) || remainRemain === 0) {
          setRemain(remainRemain);
        }
        if (remain.value > 0) {
          macroTick();
        }
      }
    });
  };
  const tick = () => {
    // should not start counting in server
    // see: https://github.com/vant-ui/vant/issues/7807
    if (!inBrowser) {
      return;
    }
    if (options.millisecond) {
      microTick();
    } else {
      macroTick();
    }
  };
  const start = () => {
    if (!counting) {
      endTime = Date.now() + remain.value;
      counting = true;
      tick();
    }
  };
  const reset = (totalTime: number = options.time) => {
    pause();
    remain.value = totalTime;
  };
  // 组件被卸载之前被调用
  onBeforeUnmount(pause);
  // 激活
  onActivated(() => {
    if (deactivated) {
      counting = true;
      deactivated = false;
      tick();
    }
  });
  onDeactivated(() => {
    if (counting) {
      pause();
      deactivated = true;
    }
  });
  // 回来办法和当时时刻目标
  return {
    start,
    pause,
    reset,
    current,
  };
}

咱们继续来看 rafcancelRaf,是怎么完成的。

8. raf、cancelRaf、inBrowser 完成

// 判别是不是浏览器环境,你或许会问,为啥要判别?因为 SSR (服务端烘托)不是浏览器环境。
export const inBrowser = typeof window !== 'undefined';
// Keep forward compatible
// should be removed in next major version
export const supportsPassive = true;
export function raf(fn: FrameRequestCallback): number {
  return inBrowser ? requestAnimationFrame(fn) : -1;
}
export function cancelRaf(id: number) {
  if (inBrowser) {
    cancelAnimationFrame(id);
  }
}
// double raf for animation
export function doubleRaf(fn: FrameRequestCallback): void {
  raf(() => raf(fn));
}

上文代码,首要一个 APIrequestAnimationFrame、cancelAnimationFrame

咱们这儿简略理解为 window.requestAnimationFrame() 中的回调函数,每 16.67ms 履行一次回调函数即可。

也便是类似 setTimeout、clearTimeout

const timeId = setTimeout( () => {
  // 16.67ms 履行一次
  console.log('16.67ms 履行一次');
}, 16.67);
clearTimeout(timeId);

也能够自行查找这个 API 查阅更多资料。比方 MDN 上的解说。

mdn window.requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你期望履行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该办法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前履行

回调函数履行次数通常是每秒 60 次,但在大多数遵循 W3C 主张的浏览器中,回调函数履行次数通常与浏览器屏幕改写次数相匹配。

备注: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身有必要再次调用 window.requestAnimationFrame()

9. 支撑格局化时刻,默许 HH:mm:ss

9.1 parseFormat 处理格局化

再来看看,组件中,是怎么格局化时刻的。这个值得咱们参阅。咱们许多时候或许都是写死天数、小时等文案。不支撑自定义格局化。

// vant/packages/vant/src/count-down/utils.ts
import { padZero } from '../utils';
import { CurrentTime } from '@vant/use';
export function parseFormat(format: string, currentTime: CurrentTime): string {
  const { days } = currentTime;
  let { hours, minutes, seconds, milliseconds } = currentTime;
  // 有 DD 参数,补零替换,没有则小时数加上天数
  if (format.includes('DD')) {
    format = format.replace('DD', padZero(days));
  } else {
    hours += days * 24;
  }
  // 有 HH 参数,补零替换,没有则分钟数加上小时数
  if (format.includes('HH')) {
    format = format.replace('HH', padZero(hours));
  } else {
    minutes += hours * 60;
  }
  // 有 mm 参数,补零替换,没有则秒数加上分钟数
  if (format.includes('mm')) {
    format = format.replace('mm', padZero(minutes));
  } else {
    seconds += minutes * 60;
  }
  // 有 mm 参数,补零替换,没有则毫秒数加上秒数
  if (format.includes('ss')) {
    format = format.replace('ss', padZero(seconds));
  } else {
    milliseconds += seconds * 1000;
  }
  // 毫秒数 默许补三位数,依照格局终究给出对应的位数
  if (format.includes('S')) {
    const ms = padZero(milliseconds, 3);
    if (format.includes('SSS')) {
      format = format.replace('SSS', ms);
    } else if (format.includes('SS')) {
      format = format.replace('SS', ms.slice(0, 2));
    } else {
      format = format.replace('S', ms.charAt(0));
    }
  }
  // 终究回来格局化的数据
  return format;
}

9.2 padZero 补零

// vant/packages/vant-compat/node_modules/vant/src/utils/format.ts
// 补零操作
export function padZero(num: Numeric, targetLength = 2): string {
  let str = num + '';
  while (str.length < targetLength) {
    str = '0' + str;
  }
  return str;
}

行文自此,咱们就剖析完了毫秒级烘托的倒计时组件的完成。

10. 总结

咱们来简略总结下。经过 demo 文件调试,进口文件,主文件,useCountDown 组合式 API,插槽等。 剖析了自定义格局、毫秒级烘托、自定义款式(运用插槽)等功能的完成。

其中毫秒级烘托,首要便是运用 Date.now() 和 (window.requestAnimationFrame)每 16.67ms 履行一次回调函数。

大致流程如下:

初始化开端:完毕时刻 = 当时时刻戳 + 剩余时刻
获取:剩余时刻 = 完毕时刻 - 当时时刻戳
加上自己定时器逻辑循环(`window.requestAnimationFrame`)每 16.67ms 履行一次回调函数
剩余时刻便是实在消逝的时刻
假如是毫秒级烘托,就直接赋值剩余时刻
假如不是,那就判别是同一秒才赋值

看完这篇源码文章,再去看 CountDown 组件文档,或许就会有豁然开朗的感觉。再看其他组件,或许就能够猜测出大约完成的代码了。

假如是运用 reactTaro 技能栈,感兴趣也能够看看 taroify CountDown 组件的完成 文档,源码。

假如看完有收获,欢迎点赞、谈论、共享支撑。你的支撑和肯定,是我写作的动力

11. 加源码共读群交流

最终能够继续重视我@若川。我会写一个组件库源码系列专栏,欢迎我们重视。

我倾力继续组织了一年每周我们一同学习200行左右的源码共读活动,感兴趣的能够点此扫码加我微信 ruochuan12 参加。

别的,想学源码,极力引荐重视我写的专栏《学习源码整体架构系列》,现在是重视人数(4.2k+人)榜首的专栏,写有20余篇源码文章。包含jQueryunderscorelodashvuexsentryaxiosreduxkoavue-devtoolsvuex4koa-composevue 3.2 发布vue-thiscreate-vue玩具vitecreate-vite 等20余篇源码文章。