【实战】使用 Vue3 + Tsx 仿写一个 ElementPlus 的卡片组件

使用 Vue3 + Tsx 实现一个卡片组件
(参考 Element-Plus

前置背景:

最近项目组在逐步使用 Vue3 + Tsx 进行开发,本文想通过一个卡片组件的开发,来感受一下 Vue3.0 + Tsx 的开发体验。其中包括了:

  • 声明组件
  • 组件的 props 校验
  • Tsx 中使用插槽
  • Tsx 中使用自定义指令

开发前准备:

首先,我们需要准备一个 Vue3.0 的开发环境,本文采用 Vite 从零开始搭建一个项目。

  1. 准备一个空的文件夹,使用终端打开该文件夹,并执行以下命令:
$ npm init -y # 初始化 package.json 文件
  1. 安装一些开发依赖和生产依赖:
  • 手动安装
# 开发依赖安装
npm i -D @types/node vite @vitejs/plugin-vue @vitejs/plugin-vue-jsx typescript@4.x @vue/babel-preset-jsx vue@3.x sass
  • 或者,复制这里的 package.json 文件,然后直接 npm i 安装:
{
  "name": "vite-tsx",
  "version": "1.0.0",
  "description": "",
  "main": "src/main.ts",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^20.11.28",
    "@vitejs/plugin-vue": "^5.0.4",
    "@vitejs/plugin-vue-jsx": "^3.1.0",
    "@vue/babel-preset-jsx": "^1.4.0",
    "sass": "^1.72.0",
    "typescript": "4.x",
    "vite": "^5.1.6",
    "vue": "^3.4.21"
  },
  "peerDependencies": {
    "vue": "^3.3"
  }
}
  1. 在根目录下面创建以下几个文件:vite.config.ts, tsconfig.json, vue-shims.d.ts, global.d.ts
/* vite.config.ts */

import { defineConfig } from 'vite';
import { resolve } from 'path';
import vuePlugin from '@vitejs/plugin-vue';
import vueJsxPlugin from '@vitejs/plugin-vue-jsx';

export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: 5173,
  },
  plugins: [vuePlugin(), vueJsxPlugin()],
  resolve: {
    alias: [
      { find: '@', replacement: resolve(__dirname, 'src') },
    ],
    extensions: [
      '.js',
      '.jsx',
      '.mjs',
      '.ts',
      '.tsx',
    ],
  },
});
/* tsconfig.json */

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    "strict": true,
    "lib": [
      "esnext",
      "dom"
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "jsx": "preserve",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment",
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "declaration": true,
    "esModuleInterop": true,
    "types": ["vue/jsx"]
  },
  "exclude": ["node_modules"],
  "include": [
    "./src/**/*.ts",
    "./src/**/*.tsx",
    "./src/**/*.vue",
    "./**/*.d.ts"
  ]
}
/* vue-shims.d.ts */

import { DefineComponent, VNode } from 'vue';

declare module '*.vue' {

  // 定义组件
  const component: DefineComponent<Record<string, any>, Record<string, any>, any>;

  export default component;
}

declare module "@vue/jsx-runtime" {
  export interface HTMLAttributes {
    // 添加自定义属性
    dataTest?: string;

    // 添加其他属性
    // ...
  }

  export interface IntrinsicElements {
    // 添加自定义元素
    'my-custom-element': HTMLAttributes;
    // 添加其他元素
    // ...
  }
  
  // 添加其他类型
  // ...

  // 添加其他模块
  // ...
}

/* global.d.ts */

// 声明 css 样式
declare module '*.css' {
  const content: { [className: string]: string };
}
// 声明 scss 样式
declare module '*.scss' {
  const content: { [className: string]: string };
}

// 声明图片
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.svg';

// 声明 markdown
declare module '*.md';
  1. 创建 index.html, 并编写一下文件:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vite + TSX</title>
</head>
<body>
  <div id="app"></div>

  <script type="module" src="/src/main.ts"></script>
</body>
</html>
  1. 创建 src/main.tssrc/App.vue,编写一下基本的架子:
// src/main.ts

import { createApp } from 'vue';
import App from './App.vue';

import './styles/index.scss';

const app = createApp(App);

app.mount('#app');
<!-- src/App.vue -->
<script lang="ts" setup>
  console.log('App.vue');
</script>

<template>
  <h1>Hello Vue3 + Tsx</h1>
</template>
  1. package.json 中添加一下 vite 运行和部署相关的命令,并运行 npm run dev 启动项目:
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "serve": "vite preview"
  }
}
  1. 在 chrome 浏览器地址栏输入 http://localhost:5173 打开项目,如果能够看到大大的 Hello Vue3 + Tsx 字样,那么恭喜你,项目已经成功运行起来了。

组件设计

搭建如下的项目结构:

├── src
│   ├── main.ts
│   ├── App.vue
│   ├── components
│   │   ├── index.ts           # 所有组件的出口
│   │   └── Card
│   │       ├── index.ts       # 组件导出的出口
│   │       └── Card.tsx       # 组件实现
│   │       └── types.ts       # 组件类型声明
│   │       └── config.js      # 组件用到的配置
│   │       └── vCardShadow.ts # 控制卡片阴影的指令
│   ├── styles
│   │   └── index.scss
│   │   └── Card
│   │       └── index.scss     # 组件的样式
│   │       └── ...            # 其他组件的样式

代码实现

  1. 先编写 components/index.ts, 导出 Card 组件本身及其 props
export { default as Card } from './Card';

export type { CardProps } from './Card';
  1. 编写 Card 组件的出口:
import type { App } from 'vue';

import Card from './Card';

Card.install = function (app: App) {
  app.component(Card.name, Card);
}

export default Card;

export type * from './types';
  1. 创建配置文件 config.ts 和类型文件 types.ts (主要是定义 propsslots)
/* config.ts */
import type { CSSProperties, PropType } from 'vue';

/** 触发的方式 */
export type TriggerShadowWay = 'always' | 'hover' | 'never';

/** `Card` 组件的 `props` 定义 */
export const cardProps = {
  /** 卡片的标题 */
  header: {
    type: String,
    default: '',
  },
  /** 卡片页脚 */
  footer: {
    type: String,
    default: '',
  },
  /** body 的 CSS 样式  */
  bodyStyle: {
    type: Object as PropType<CSSProperties>,
    default: () => ({}),
  },
  /** body 的自定义类名 */
  bodyClass: {
    type: String,
    default: '',
  },
  /** 卡片阴影显示时机 */
  shadow: {
    type: String as PropType<TriggerShadowWay>,
    default: 'always',
    validator: (v: string | undefined): boolean => {
      if (v === undefined) {
        v = 'always';
      }
      return ['always', 'hover', 'never'].includes(v);
    }
  },
} as const;
/* types.ts */
import { cardProps } from './configs';
export type { TriggerShadowWay } from './configs';

/** 卡片内部的插槽定义 */
export type CardSlotsType = SlotsType<{
  /** `main` 主要区域渲染内容 */
  default: undefined | (() => VNode | VNode[] | undefined);
  /** `header` 顶部渲染内容 */
  header: undefined | (() => VNode | VNode[] | undefined);
  /** `footer` 底部渲染内容 */
  footer: undefined | (() => VNode | VNode[] | undefined);
}>;

/** 卡片的 Props 定义 */
export type CardProps = ExtractPropTypes<typeof cardProps>;
  1. 编写核心组件文件 Card.tsx
import { computed, defineComponent } from 'vue';
import type { VNode } from 'vue';

export default defineComponent({
  name: 'MyCard',
  props: cardProps,
  slots: Object as CardSlotsType,
  setup(props, { slots, attrs, /* expose, */ /* emit */ }) {
    const cardHeader = computed<VNode|string>(() => {
      if (!slots.header && !props.header.length) {
        return '';
      }

      return (
        <header class="my-card-header">
          {slots.header?.() ?? props.header}
        </header>
      );
    });

    const cardContent = computed<VNode|string>(() => {
      return (
        <div class="my-card-content">
          {slots.default?.()}
        </div>
      );
    })

    const cardFooter = computed<VNode|string>(() => {
      if (!slots.footer && !props.footer.length) {
        return '';
      }
      return (
        <footer class="my-card-footer">
          {slots.footer?.() ?? props.footer}
        </footer>
      );
    });

    return () => (
      <div class="my-card" {...attrs}>
        {cardHeader.value}
        {cardContent.value}
        {cardFooter.value}
      </div>
    );
  }
});
  1. 编写自定义指令 vCardShadow, 并在 MyCard 组件中使用该指令。
import type { Directive } from 'vue';
import type { CardProps } from './types';

const vCardShadow: Directive<HTMLElement, CardProps> = {
  mounted(el, bindings, vNode) {
    switch (bindings.value.shadow) {
      case 'always':
        el.classList.add('my-card-shadow');
        break;
      case 'hover':
        el.addEventListener('mouseenter', handleContainerEnterLeave, false);
        el.addEventListener('mouseleave', handleContainerEnterLeave, false);
        break;
      case 'never':
      default:
        el.classList.remove('my-card-shadow');
        break;
    }
  }
};

function handleContainerEnterLeave(e: Event) {
  const eventType = e.type.toLowerCase();
  const el: HTMLElement = <HTMLElement> e.currentTarget;

  switch (eventType) {
    case 'mouseenter':
      el.classList.add('my-card-shadow');
      break;
    case 'mouseleave':
      el.classList.remove('my-card-shadow');
    default:
      break;
  }
}

export default vCardShadow;
import { computed, defineComponent, withDirectives } from 'vue';
import type { VNode } from 'vue';

import { cardProps } from './configs';
import type { CardSlotsType } from './types';

import vCardShadow from './vCardShadow';

export default defineComponent({
  name: 'MyCard',
  props: cardProps,
  slots: Object as CardSlotsType,
  setup(props, { slots, attrs, /* expose, */ /* emit */ }) {
    const cardHeader = computed<VNode|string>(() => {
      if (!slots.header && !props.header.length) {
        return '';
      }

      return (
        <header class="my-card-header">
          {slots.header?.() ?? props.header}
        </header>
      );
    });

    const cardContent = computed<VNode|string>(() => {
      return (
        <div class="my-card-content">
          {slots.default?.()}
        </div>
      );
    })

    const cardFooter = computed<VNode|string>(() => {
      if (!slots.footer && !props.footer.length) {
        return '';
      }
      return (
        <footer class="my-card-footer">
          {slots.footer?.() ?? props.footer}
        </footer>
      );
    });

    return () => withDirectives(
      <div class="my-card" {...attrs}>
        {cardHeader.value}
        {cardContent.value}
        {cardFooter.value}
      </div>,
      [
        [vCardShadow, props]
      ],
    );
  }
});
  1. 最后编写一下样式 (我这里使用的是 scss):
/* styles/index.scss */
@import "./Card/index.scss";
/* styles/Card/index.scss */
.my-card {
  position: relative;
  width: 500px;
  border: 1px solid #ddd;
  box-sizing: border-box;
  border-radius: 3px;
  transition: shadow .25s ease-in;

  &-shadow {
    border: none;
    box-shadow: 1px 3px 5px #9d9d9d;
  }

  &-header {
    width: 100%;
    height: 44px;
    padding: 8px;
    line-height: 30px;
    vertical-align: center;
    border-bottom: 1px solid #ddd;
    box-sizing: border-box;
  }

  &-footer {
    width: 100%;
    height: 44px;
    line-height: 44px;
    padding: 8px;
    line-height: 30px;
    border-top: 1px solid #ddd;
    box-sizing: border-box;
  }

  &-content {
    width: 100%;
    max-height: 500px;
    min-height: 60px;
    height: calc(100% - 88px);
    overflow: auto;
    padding: 10px;
    box-sizing: border-box;

    &:hover {
      &::-webkit-scrollbar {
        display: block;
      }
    }

    // 设置盒子滚动条的样式
    &::-webkit-scrollbar {
      z-index: 9999;
      width: 8px; // 水平滚动条的宽度
      height: 8px; // 垂直滚动条的高度
      border-radius: 5%;
      display: none;
      transition: all .25s ease-in;
    }

    // 设置滚动条滑块的上边距
    &::-webkit-scrollbar-thumb {
      margin-bottom: -20px;
      background-color: #888; // 滚动条的滑块颜色
      border-radius: 3px;
    }

    &::-webkit-scrollbar-track {
      background-color: #f1f1f1; // 滚动条的背景颜色
    }
  }
}

测试用例

注册组件

  1. 首先,我们需要在 src/main.ts 中注入我们写好的组件及其样式:
import { createApp } from 'vue';
import App from './App.vue';

+ import { Card } from './components';

import './styles/index.scss';

const app = createApp(App);

+ app.use(Card);

app.mount('#app');

使用组件

  • 测试案例一 (基本使用):
<template>
  <my-card style="max-width: 480px">
    <template #header>
      <div class="card-header">
        <span>Card name</span>
      </div>
    </template>
    <p v-for="o in 4" :key="o" class="text item">{{ 'List item ' + o }}</p>
    <template #footer>Footer content</template>
  </my-card>
</template>
  • 测试案例二 (简单卡片):
<template>
  <my-card style="max-width: 480px">
    <p v-for="o in 4" :key="o" class="text item">{{ 'List item ' + o }}</p>
  </my-card>
</template>
  • 测试案例三 (有图片内容的卡片)
<template>
  <my-card style="max-width: 480px">
    <template #header>Yummy hamburger</template>
    <img
      src="https://shadow.elemecdn.com/app/element/hamburger.9cf7b091-55e9-11e9-a976-7f4d0b07eef6.png"
      style="width: 100%"
    />
  </my-card>
</template>
  • 测试案例四 (带有阴影效果的卡片)
<template>
  <div class="flex flex-wrap gap-4">
    <my-card style="width: 480px" shadow="always">Always</my-card>
    <my-card style="width: 480px" shadow="hover">Hover</my-card>
    <my-card style="width: 480px" shadow="never">Never</my-card>
  </div>
</template>

项目源代码:

📎vite-tsx.zip

原文链接:https://juejin.cn/post/7346884660444299315 作者:疯狂法外狂徒张三同学

(0)
上一篇 2024年3月17日 下午4:15
下一篇 2024年3月17日 下午4:26

相关推荐

发表回复

登录后才能评论