大家好,我是若川。我倾力持续组织了一年多源码共读,感兴趣的可以加我微信 lxchuan12
参与。之前送过N次书,可以点此查看回馈粉丝,现在又和北京大学出版社合作再次争取了几本书,具体送书规则看文末。
11. 前言
2022年11月23日首发于掘金,现在同步到公众号。
我们开发业务时经常会使用到组件库,一般来说,很多时候我们不需要关心内部实现。但是如果希望学习和深究里面的原理,这时我们可以分析自己使用的组件库实现。有哪些优雅实现、最佳实践、前沿技术等都可以值得我们借鉴。
相比于原生 JS
等源码。我们或许更应该学习,正在使用的组件库的源码,因为有助于帮助我们写业务和写自己的组件。
如果是 Vue
技术栈,开发移动端的项目,大多会选用 vant
组件库,目前(2022-11-20)star
多达 20.5k
,最新版本是 v4.0.0-rc7
。我们可以挑选 vant
组件库学习,我会写一个组件库源码系列专栏[1],欢迎大家关注。
这次我们来学习倒计时组件,`countdown`[2]。
学完本文,你将学到:
1. 如何开发一个更优雅的毫秒级渲染的倒计时组件
2. 学会使用 requestAnimationFrame
3. 等等
22. 准备工作
看一个开源项目,我们可以先看 README.md[3] 再看 github/CONTRIBUTING.md[4]
2.1 克隆源码
You will need Node.js >= 14[5] and pnpm[6].
# 推荐克隆我的项目
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
。
33. 倒计时组件可谓是十分常用
在各种电商类或者其他的移动端页面中,倒计时真的是太常见了。我们自己也基本能够快速的写一个倒计时组件。代码实现参考这里,主要是 JavaScript
。码上掘金倒计时初步代码@若川[7]
代码中,我直接使用的 setInterval
和每秒钟执行一次。把倒计时的时候减去1s
,当倒计时毫秒数不足时用 clearInterval
清除停止定时器。
但如果要实现毫秒级的倒计时这种方法行不通。 另外 setInterval
这种做法,并不是最优的。 那么,vant
倒计时组件中,是如何处理毫秒级和实现倒计时呢。
带着问题我们直接找到 countdown demo
文件:vant/packages/vant/src/count-down/demo/index.vue
。为什么是这个文件,我在之前文章跟着 vant4 源码学习如何用 vue3+ts 开发一个 loading 组件,仅88行代码分析了其原理,感兴趣的小伙伴点击查看。这里就不赘述了。
44. 利用 demo 调试源码
组件源码中的 TS
代码我不会过多解释。没学过 TS
的小伙伴,推荐学这个TypeScript 入门教程[8]。 另外,vant
使用了 @vue/babel-plugin-jsx[9] 插件来支持 JSX、TSX
。
![f1cb0d5227e6e71072310ac3f35b339c.png](https://i-blog.csdnimg.cn/blog_migrate/42f7ff33b0d218f1b8e0704ac274ecec.png)
// 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
。
55. 入口 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
。
![dc547d3c79aacb57e37d53211eebb309.png](https://i-blog.csdnimg.cn/blog_migrate/65daed8d44adbecd8d7b0d17b6cbd566.png)
调试 useCountDown
。
![7bd4e13facbb1fd542aab3f939cee3a2.png](https://i-blog.csdnimg.cn/blog_migrate/2bc5a686b91a03ec8cc67a854841f585.png)
我们跟着调试,继续分析 Countdown
。
66. 主文件 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
实例并调用实例方法,详见组件实例方法[10]。
Vant
中的许多组件提供了实例方法,调用实例方法时,我们需要通过ref
来注册组件引用信息,引用信息将会注册在父组件的$refs
对象上。注册完成后,我们可以通过this.$refs.xxx
或者
const xxxRef = ref();
xxxRef.value.xxx();
访问到对应的组件实例,并调用上面的实例方法。
77. 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();
码上掘金倒计时简化版二
码上掘金倒计时简化版二@若川[11]
// 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,
};
}
我们继续来看 raf
和 cancelRaf
,是如何实现的。
88. 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));
}
上文代码,主要一个 API
,requestAnimationFrame、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[12]
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。
备注: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用
window.requestAnimationFrame()
。
99. 支持格式化时间,默认 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;
}
行文自此,我们就分析完了毫秒级渲染的倒计时组件的实现。
1010. 总结
我们来简单总结下。通过 demo
文件调试,入口文件,主文件,useCountDown
组合式 API,插槽等。 分析了自定义格式、毫秒级渲染、自定义样式(利用插槽)等功能的实现。
其中毫秒级渲染,主要就是利用 Date.now()
和 (window.requestAnimationFrame
)每 16.67ms
执行一次回调函数。
大致流程如下:
初始化开始:结束时间 = 当前时间戳 + 剩余时间
获取:剩余时间 = 结束时间 - 当前时间戳
加上自己定时器逻辑循环(`window.requestAnimationFrame`)每 16.67ms 执行一次回调函数
剩余时间就是真实流逝的时间
如果是毫秒级渲染,就直接赋值剩余时间
如果不是,那就判断是同一秒才赋值
看完这篇源码文章,再去看 CountDown 组件文档[13],可能就会有豁然开朗的感觉。再看其他组件,可能就可以猜测出大概实现的代码了。
如果是使用 react
、Taro
技术栈,感兴趣也可以看看 taroify
CountDown
组件的实现 文档[14],源码[15]。
如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力。
内容简介
本书由浅入深地带领读者进入Flutter开发的世界,从Flutter的起源讲起,逐步深入Flutter进阶实战,并在最后配合项目实战案例,让读者不但可以系统地学习Flutter编程的相关知识,而且还能对Flutter应用开发有更为深入的理解。
本书分为三部分:
第一部分为入门篇(第1~4章),主要介绍Flutter技术的诞生背景、特点、语言及常用组件的使用,通过对本篇的学习,读者可以掌握如何使用Flutter来搭建UI界面;
第二部分为进阶篇(第5~11章),主要包含Flutter的手势和事件处理、动画、自定义组件、文件操作和网络请求、路由导航和存储、混合跨平台开发、国际化等,通过对本篇的学习,读者可以对Flutter的整体流程及原理有一个深入的认识;
第三部分为实战篇(第12~14章),主要通过一个实战项目把前面介绍的内容整合起来,并且结合Flutter应用发布和Flutter App升级等一条线流程,让读者对开发一个完整的Flutter App有一个整体的了解
福利时间
文末福利
小伙伴们,可以在本文留言区留言任意内容~抽奖规则:在我的公众号结合留言内容随机抽1位,获得本书包邮送。
截止时间:11月5日(周日)晚上8点,可能延后,以置顶留言为准。
其他几本,在我的社群等送出(福利倾斜)。
中奖小伙伴,我会联系兑奖,一般来说,如果当天联系不上视为作废。也可以提前扫码加我微信 lxchuan12 以防失联。或者发送源码两字参与源码共读。