查看原文
其他

分析 vant4 源码,如何用 vue3 + ts 开发一个瀑布流滚动加载的列表组件?

若川视野 若川视野 2023-02-21

2022年11月14日首发于掘金,现在同步到公众号。

11. 前言

大家好,我是若川。我倾力持续组织了一年多源码共读,感兴趣的可以加我微信 lxchuan12 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.6k+人)第一的专栏,写有20余篇源码文章。


我们开发业务时经常会使用到组件库,一般来说,很多时候我们不需要关心内部实现。但是如果希望学习和深究里面的原理,这时我们可以分析自己使用的组件库实现。有哪些优雅实现、最佳实践、前沿技术等都可以值得我们借鉴。

相比于原生 JS 等源码。我们或许更应该学习,正在使用的组件库的源码,因为有助于帮助我们写业务和写自己的组件。

如果是 Vue 技术栈,开发移动端的项目,大多会选用 vant 组件库,目前(2022-11-13) star 多达 20.4k。我们可以挑选 vant 组件库学习,我会写一个组件库源码系列专栏[1],欢迎大家关注。

学完本文,你将学到:

1. 学会如何用 vue3 + ts 开发一个 List 组件
2. 学会封装各种组合式 `API`
3. 等等

22. 准备工作

看一个开源项目,第一步应该是先看 README.md[2] 再看贡献文档 github/CONTRIBUTING.md[3]

2.1 克隆源码 && 跑起来

You will need Node.js >= 14[4] and pnpm[5].

# 推荐克隆我的项目
git clone https://github.com/lxchuan12/vant-analysis
cd vant-analysis/vant

# 或者克隆官方仓库
git clone git@github.com:vant-ui/vant.git
cd vant

# 安装依赖,会运行所有 packages 下仓库的 pnpm i 钩子 pnpm prepare 和 pnpm i
pnpm i

# Start development
pnpm dev

我们先来看 pnpm dev 最终执行的什么命令。

vant 项目使用的是 monorepo 结构。查看根路径下的 package.json

vant/package.json => "dev": "pnpm --dir ./packages/vant dev"vant/packages/vant/package.json => "dev": "vant-cli dev"

pnpm dev 最终执行的是:vant-cli dev 执行测试用例。本文主要是学习 List 组件[6] 的实现,所以我们就不深入 vant-cli dev 命令了。

33. List 组件

List 组件文档[7]

瀑布流滚动加载,用于展示长列表,当列表即将滚动到底部时,会触发事件并加载更多列表项。

从这个描述和我们自己体验 demo 来。 至少有以下三个问题值得去了解学习。

  • 如何监听滚动
  • 如何计算滚动到了底部
  • 如何触发事件加载更多

带着问题我们直接找到 list demo 文件:vant/packages/vant/src/list/demo/index.vue。为什么是这个文件,我在上篇文章跟着 vant4 源码学习如何用 vue3+ts 开发一个 loading 组件,仅88行代码分析了其原理,感兴趣的小伙伴点击查看。这里就不赘述了。

3.1 利用 demo 调试

组件源码中的 TS 代码我不会过多解释。没学过 TS 的小伙伴,推荐学这个TypeScript 入门教程[8]。 另外,vant 使用了 @vue/babel-plugin-jsx[9] 插件来支持 JSX、TSX

// vant/packages/vant/src/list/demo/index.vue
// 代码有删减
<script setup lang="ts">
import VanList from '..';
import { ref } from 'vue';

const t = useTranslate({
  'zh-CN': {
    errorInfo'错误提示',
    errorText'请求失败,点击重新加载',
    pullRefresh'下拉刷新',
    finishedText'没有更多了',
  },
  'en-US': {
    errorInfo'Error Info',
    errorText'Request failed. Click to reload',
    pullRefresh'PullRefresh',
    finishedText'Finished',
  },
});

const list = ref([
  {
    items: [] as string[],
    refreshingfalse,
    loadingfalse,
    errorfalse,
    finishedfalse,
  },
]);

// 加载数据
const onLoad = (index: number) => {
  const currentList = list.value[index];
  currentList.loading = true;

  setTimeout(() => {
    if (currentList.refreshing) {
      currentList.items = [];
      currentList.refreshing = false;
    }

    for (let i = 0; i < 10; i++) {
      const text = currentList.items.length + 1;
      currentList.items.push(text < 10 ? '0' + text : String(text));
    }

    currentList.loading = false;
    currentList.refreshing = false;

    // show error info in second demo
    if (index === 1 && currentList.items.length === 10 && !currentList.error) {
      currentList.error = true;
    } else {
      currentList.error = false;
    }

    if (currentList.items.length >= 40) {
      currentList.finished = true;
    }
  }, 1000);
};
</script>
<template>
  <van-tabs>
    <van-tab :title="t('basicUsage')">
      <van-list
        v-model:loading="list[0].loading"
        :finished="list[0].finished"
        :finished-text="t('finishedText')"
        @load="onLoad(0)"
      >
        <van-cell v-for="item in list[0].items" :key="item" :title="item" /
>
      </van-list>
    </
van-tab>
<template>

44. 入口文件

主要就是导出一下类型和变量等。

// vant/packages/vant/src/list/index.ts
import { withInstall } from '../utils';
import _List, { ListProps } from './List';

export const List = withInstall(_List);
export default List;
export { listProps } from './List';
export type { ListProps };
export type { ListInstance, ListDirection, ListThemeVars } from './types';

declare module 'vue' {
  export interface GlobalComponents {
    VanListtypeof List;
  }
}

withInstall 函数在上篇文章5.1 withInstall 给组件对象添加 install 方法[10] 也有分析,这里就不赘述了。

我们可以在这些文件,任意位置加上 debugger 调试源码。

55. 主文件

import {
  ref,
  watch,
  nextTick,
  onUpdated,
  onMounted,
  defineComponent,
  type ExtractPropTypes,
from 'vue';

// Utils
import {
  isHidden,
  truthProp,
  makeStringProp,
  makeNumericProp,
  createNamespace,
from '../utils';

// Composables
import { useRect, useScrollParent, useEventListener } from '@vant/use';
import { useExpose } from '../composables/use-expose';
import { useTabStatus } from '../composables/use-tab-status';

// Components
import { Loading } from '../loading';

// Types
import type { ListExpose, ListDirection } from './types';

const [name, bem, t] = createNamespace('list');

export const listProps = {
  errorBoolean,
  offset: makeNumericProp(300),
  loadingBoolean,
  finishedBoolean,
  errorTextString,
  direction: makeStringProp<ListDirection>('down'),
  loadingTextString,
  finishedTextString,
  immediateCheck: truthProp,
};

export type ListProps = ExtractPropTypes<typeof listProps>;

List 组件 api[11]

export default defineComponent({
  name,
  props: listProps,

  emits: ['load''update:error''update:loading'],

  setup(props, { emit, slots }) {
    // TODODEL: 可以在这里打上断点调试,或者其他地方。
    debugger;
    // 省略若干代码
    const loading = ref(false);
    const root = ref<HTMLElement>();
    const placeholder = ref<HTMLElement>();
    const tabStatus = useTabStatus();
    const scrollParent = useScrollParent(root);
    // 省略若干代码
    return () => {
      const Content = slots.default?.();
      const Placeholder = <div ref={placeholder} class={bem('placeholder')} />;

      return (
        <div ref={root} role="feed" class={bem()} aria-busy={loading.value}>
          {props.direction === 'down' ? Content : Placeholder}
          //   比如:加载中
          {renderLoading()}
          //   结束文字 比如:没有更多了
          {renderFinishedText()}
          //   加载错误文字:比如加载失败
          {renderErrorText()}
          {props.direction === 'up' ? Content : Placeholder}
        </div>

      );
    };
  }
}

debugger 调试截图。

debugger 调试截图

接着我们来看其他一些事件。

5.1 一些事件 useExpose、useEventListener

// 省略若干代码
setup(props, { emit, slots }) {
    // 省略 check 函数,后文讲述
    const check = () => {}

    // 监听参数变更,执行 check
    watch(() => [props.loading, props.finished, props.error], check);

    // van-tabs tab 切换状态变更时 执行 check
    if (tabStatus) {
      watch(tabStatus, (tabActive) => {
        if (tabActive) {
          check();
        }
      });
    }

    onUpdated(() => {
    // !是 ts中的非空断言,很多人问过
      loading.value = props.loading!;
    });

    // 如果参数是立即检测,执行 check 函数
    onMounted(() => {
      if (props.immediateCheck) {
        check();
      }
    });

    // 导出 check 函数,让 refs.xxx 可以使用
    useExpose<ListExpose>({ check });

    // 监听滚动事件,执行 check 函数
    useEventListener('scroll', check, {
      target: scrollParent,
      passivetrue,
    });
}

由上面代码可以看出,check 函数非常重要,我们在下文分析它。

我们先分析上面代码用到的 useExposeuseEventListener 组合式 API

5.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();
  if (instance) {
    extend(instance.proxy as object, apis);
  }
}

通过 ref 可以获取到 List 实例并调用实例方法,详见组件实例方法[12]

Vant 中的许多组件提供了实例方法,调用实例方法时,我们需要通过 ref 来注册组件引用信息,引用信息将会注册在父组件的 $refs 对象上。注册完成后,我们可以通过 this.$refs.xxx 访问到对应的组件实例,并调用上面的实例方法。

5.3 useEventListener 绑定事件

方便地进行事件绑定,在组件 mountedactivated 时绑定事件,unmounteddeactivated 时解绑事件。

useEventListener
import { Ref, watch, isRef, unref, onUnmounted, onDeactivated } from 'vue';
import { onMountedOrActivated } from '../onMountedOrActivated';
import { inBrowser } from '../utils';

type TargetRef = EventTarget | Ref<EventTarget | undefined>;

export type UseEventListenerOptions = {
  target?: TargetRef;
  capture?: boolean;
  passive?: boolean;
};

// TS 函数重载
// 重载 可以参考这里:http://ts.xcatliu.com/basics/type-of-function.html#%E9%87%8D%E8%BD%BD
export function useEventListener<K extends keyof DocumentEventMap>(
  type: K,
  listener: (event: DocumentEventMap[K]) => void,
  options?: UseEventListenerOptions
): void;
export function useEventListener(
  type: string,
  listener: EventListener,
  options?: UseEventListenerOptions
): void;
export function useEventListener(
  type: string,
  listener: EventListener,
  options: UseEventListenerOptions = {}
{
    // 如果不是浏览器环境,直接返回,比如 SSR
  if (!inBrowser) {
    return;
  }

  const { target = window, passive = false, capture = false } = options;

  let attached: boolean;

  // 添加事件
  const add = (target?: TargetRef) => {
    const element = unref(target);

    if (element && !attached) {
      element.addEventListener(type, listener, {
        capture,
        passive,
      });
      attached = true;
    }
  };

  // 移除事件
  const remove = (target?: TargetRef) => {
    const element = unref(target);

    if (element && attached) {
      element.removeEventListener(type, listener, capture);
      attached = false;
    }
  };

  // 移除事件
  onUnmounted(() => remove(target));
  onDeactivated(() => remove(target));
  onMountedOrActivated(() => add(target));

  if (isRef(target)) {
    watch(target, (val, oldVal) => {
      remove(oldVal);
      add(val);
    });
  }
}

66. steup check 函数

const check = () => {
    nextTick(() => {
        // 正在 loading 或者已经完成加载
        // 或者加载失败,或者tab的状态不是激活时,返回。
        if (
            loading.value ||
            props.finished ||
            props.error ||
            // skip check when inside an inactive tab
            tabStatus?.value === false
        ) {
            return;
        }

        // offset 默认 300
        const { offset, direction } = props;
        // 滚动的父级元素的位置
        const scrollParentRect = useRect(scrollParent);

        if (!scrollParentRect.height || isHidden(root)) {
            return;
        }

        // 触底计算
        // 滚动父元素 和 占位元素
        let isReachEdge = false;
        const placeholderRect = useRect(placeholder);

        if (direction === 'up') {
            isReachEdge = scrollParentRect.top - placeholderRect.top <= offset;
        } else {
            isReachEdge =
            placeholderRect.bottom - scrollParentRect.bottom <= offset;
        }

        // 触底了
        if (isReachEdge) {
            loading.value = true;
            emit('update:loading'true);
            emit('load');
        }
    });
};

check 函数可以看出,主要就是利用滚动高度,接下来我们看这个函数中,使用到的组合式 APIuseTabStatususeScrollParentuseRect

6.1 useTabStatus tab 组件的状态

import { inject, ComputedRef, InjectionKey } from 'vue';

// eslint-disable-next-line
export const TAB_STATUS_KEY: InjectionKey<ComputedRef<boolean>> = Symbol();

export const useTabStatus = () => inject(TAB_STATUS_KEY, null);

代码根据 commit 可以发现 useTabStatus 有这样一次提交。

fix(List): skip check when inside an inactive tab[13]

主要是在 van-tabs 组件中,provide(TAB_STATUS_KEY, active); 提供了一个状态。tab 不活跃时,跳过 check 函数,不执行。

6.2 useScrollParent 获取元素最近的可滚动父元素

获取元素最近的可滚动父元素。

给定参数 el, root 节点,遍历父级节点查找 style 包含 scroll|auto|overlay 的元素,如果没找到,返回第二个 root 参数(没有第二个参数则是 window)。

useScrollParent 文档[14]

import { ref, Ref, onMounted } from 'vue';
import { inBrowser } from '../utils';

type ScrollElement = HTMLElement | Window;

const overflowScrollReg = /scroll|auto|overlay/i;
const defaultRoot = inBrowser ? window : undefined;

// 元素节点
function isElement(node: Element) {
  const ELEMENT_NODE_TYPE = 1;
  return (
    node.tagName !== 'HTML' &&
    node.tagName !== 'BODY' &&
    node.nodeType === ELEMENT_NODE_TYPE
  );
}

// https://github.com/vant-ui/vant/issues/3823
export function getScrollParent(
  el: Element,
  root: ScrollElement | undefined = defaultRoot
{
  let node = el;

  // 遍历得到父级滚动的元素,style 样式包含 scroll|auto|overlay 的节点
  while (node && node !== root && isElement(node)) {
    const { overflowY } = window.getComputedStyle(node);
    if (overflowScrollReg.test(overflowY)) {
      return node;
    }
    node = node.parentNode as Element;
  }

  // 没找到返回参数 root,如果没传参,默认是 window
  return root;
}

export function useScrollParent(
  el: Ref<Element | undefined>,
  root: ScrollElement | undefined = defaultRoot
{
  const scrollParent = ref<Element | Window>();

  onMounted(() => {
    if (el.value) {
      scrollParent.value = getScrollParent(el.value, root);
    }
  });

  return scrollParent;
}

6.3 useRect 获取元素的大小及其相对于视口的位置

https://vant-contrib.gitee.io/vant/#/zh-CN/use-rect

获取元素的大小及其相对于视口的位置,等价于 Element.getBoundingClientRect[15]

getBoundingClientRect
// vant/packages/vant-use/src/useRect/index.ts
import { Ref, unref } from 'vue';

const isWindow = (val: unknown): val is Window => val === window;

const makeDOMRect = (width: number, height: number) =>
  ({
    top0,
    left0,
    right: width,
    bottom: height,
    width,
    height,
  } as DOMRect);

export const useRect = (
  elementOrRef: Element | Window | Ref<Element | Window | undefined>
) => {
    // unref():如果参数是 ref,则返回内部值,否则返回参数本身。这是 val = isRef(val) ? val.value : val 计算的一个语法糖。
  const element = unref(elementOrRef);

  // 如果是 window 直接返回 innerWidth 和 innerHeight 
  if (isWindow(element)) {
    const width = element.innerWidth;
    const height = element.innerHeight;
    return makeDOMRect(width, height);
  }

  // 否则用 getBoundingClientRect api
  if (element?.getBoundingClientRect) {
    return element.getBoundingClientRect();
  }

  // 不支持的情况下返回 0 0
  return makeDOMRect(00);
};

6.4 isHidden 是否隐藏

// vant/packages/vant/src/utils/dom.ts
export function isHidden(
  elementRef: HTMLElement | Ref<HTMLElement | undefined>
{
  const el = unref(elementRef);
  if (!el) {
    return false;
  }

  const style = window.getComputedStyle(el);
  const hidden = style.display === 'none';

  // offsetParent returns null in the following situations:
  // 1. The element or its parent element has the display property set to none.
  // 2. The element has the position property set to fixed
  const parentHidden = el.offsetParent === null && style.position !== 'fixed';

  return hidden || parentHidden;
}

接着我们来分析开头的插槽部分。

77. 插槽

插槽部分基本都是有插槽用插槽没有则用默认的。

插槽是函数,比如 slots.default()

// setup 函数
return () => {
    const Content = slots.default?.();
    const Placeholder = <div ref={placeholder} class={bem('placeholder')} />;

    return (
        <div ref={root} role="feed" class={bem()} aria-busy={loading.value}>
            {props.direction === 'down' ? Content : Placeholder}
            //   比如:加载中
            {renderLoading()}
            //   结束文字 比如:没有更多了
            {renderFinishedText()}
            //   加载错误文字:比如加载失败
            {renderErrorText()}
            {props.direction === 'up' ? Content : Placeholder}
        </div>

    );
};

7.1 renderFinishedText 渲染加载完成文字

const renderFinishedText = () => {
    if (props.finished) {
        const text = slots.finished ? slots.finished() : props.finishedText;
        if (text) {
            return <div class={bem('finished-text')}>{text}</div>;
        }
    }
};

7.2 renderErrorText 渲染加载失败文字

const clickErrorText = () => {
    emit('update:error'false);
    check();
};

const renderErrorText = () => {
    if (props.error) {
        const text = slots.error ? slots.error() : props.errorText;
        if (text) {
            return (
                <div
                    role="button"
                    class={bem('error-text')}
                    tabindex={0}
                    onClick={clickErrorText}
                >
                    {text}
                </div>

            );
        }
    }
};

7.3 renderLoading  渲染 loading

const renderLoading = () => {
    if (loading.value && !props.finished) {
        return (
            <div class={bem('loading')}>
            {slots.loading ? (
                slots.loading()
            ) : (
                <Loading class={bem('loading-icon')}>
                {props.loadingText || t('loading')}
                </Loading>
            )}
            </div>

        );
    }
};

88. 总结

我们主要分析了 `List` 组件[16] 实现原理。

原理:使用 addEventListener 监听父级元素的 sroll 事件,用 Element.getBoundingClientRect[17] 获取元素的大小及其相对于视口的位置,(滚动父级元素和占位元素计算和组件属性 offset(默认300) 属性比较),检测是否触底,触底则加载更多。

emit('update:loading'true);
emit('load');

同时分析了一些相关组合式 API

  • useExpose 暴露接口供 this.$refs.xxx 使用
  • useEventListener 绑定事件
  • useTabStatus 当前 tab 是否激活的状态
  • useScrollParent 获取元素最近的可滚动父元素
  • useRect 获取元素的大小及其相对于视口的位置

组件留有四个插槽,分别是:

  • default  列表内容
  • loading  自定义底部加载中提示
  • finished  自定义加载完成后的提示文案
  • error  自定义加载失败后的提示文案

至此,我们就分析完了 List 组件,主要与 DOM 操作会比较多。List 组件 主文件的代码仅有 100 多行,但封装了很多组合式 API 。看完这篇源码文章,再去看 List 组件文档[18],可能就会有豁然开朗的感觉。再看其他组件,可能就可以猜测出大概实现的代码了。

如果是使用 reactTaro 技术栈,感兴趣也可以看看 taroify List 组件的实现 文档[19]源码[20]

如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力

参考资料

[1]

组件库源码系列专栏: https://juejin.cn/column/7140264842954276871

[2]

README.md: https://github.com/youzan/vant

[3]

github/CONTRIBUTING.md: https://github.com/youzan/vant/blob/main/.github/CONTRIBUTING.md

[4]

Node.js >= 14: https://nodejs.org

[5]

pnpm: https://pnpm.io

[6]

List 组件: https://vant-contrib.gitee.io/vant/#/zh-CN/list

[7]

List 组件文档: https://vant-contrib.gitee.io/vant/#/zh-CN/list

[8]

TypeScript 入门教程: http://ts.xcatliu.com/

[9]

@vue/babel-plugin-jsx: https://www.npmjs.com/package/@vue/babel-plugin-jsx

[10]

5.1 withInstall 给组件对象添加 install 方法: https://juejin.cn/post/7160465286036979748#heading-10

[11]

List 组件 api: https://vant-contrib.gitee.io/vant/v4/#/zh-CN/list#api

[12]

组件实例方法: https://vant-contrib.gitee.io/vant/v4/#/zh-CN/advanced-usage#zu-jian-shi-li-fang-fa

[13]

fix(List): skip check when inside an inactive tab: https://github.com/youzan/vant/pull/8741/files

[14]

useScrollParent 文档: https://vant-contrib.gitee.io/vant/v4/#/zh-CN/use-scroll-parent

[15]

Element.getBoundingClientRect: https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect

[16]

List 组件: https://vant-contrib.gitee.io/vant/#/zh-CN/list

[17]

Element.getBoundingClientRect: https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect

[18]

List 组件文档: https://vant-contrib.gitee.io/vant/#/zh-CN/list

[19]

文档: https://taroify.gitee.io/taroify.com/components/list

[20]

源码: https://github.com/mallfoundry/taroify/tree/main/packages/core/src/list

················· 若川简介 ·················

你好,我是若川,毕业于江西高校。现在是一名前端开发“工程师”。写有《学习源码整体架构系列》20余篇,在知乎、掘金收获超百万阅读。
从2014年起,每年都会写一篇年度总结,已经坚持写了8年,点击查看年度总结
同时,持续组织了一年多源码共读活动,帮助5000+前端人学会看源码。公众号愿景:帮助5年内前端人走向前列。


扫码加我微信 lxchuan12、拉你进源码共读


今日话题
目前建有江西|湖南|湖北 籍 前端群,想进群的可以加我微信 lxchuan12 进群。分享、收藏、点赞、在看我的文章就是对我最大的支持~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存