尝试使用vite+vitest构建你的vue3组件并发布到npm

1,365 阅读8分钟

本文内容略长,因此分为以下几点分别介绍:

  1. 封装el-table组件,除了使用JSON数组来配置表格列其实还有一种更为优雅的封装方式。
  2. WalTablePagination 组件的实现
  3. 使用Vite来构建项目,使用vue-test-utils来编写组件测试用例,使用Vitest来运行自动化组件测试程序。
  4. 将组件发布到npm上。

项目地址: wal-table-pagination

封装el-table组件,除了使用JSON数组来配置表格列其实还有一种更为优雅的封装方式

首先我觉得比较好的封装方式应该具有以下特点:

  • 组件使用方式上以及API风格尽可能与el-table的使用方式保持一致,这样可以减少组件的上手难度。(不知道你是否遇到过类似的问题,拿到前同事封装的el-table一看各种奇怪的配置和参数。读懂这些配置已经够累了。)
  • 组件的使用能够真正带来效率的提升(可以从上手API的难易程度,代码可维护性,代码可复用性等等角度体现出是否提升效率)
  • 是否保留了原有组件的所有功能,比如官方的展开行功能、树形数据和懒加载等等。

说到这里推荐使用JSON配置table列的同学肯定会抬杠了,JSON配置列写法简单可以少写html代码等等! 那好,接下来我用两种写法的代码来对比下让你看得更加直观:

使用JSON配置表格列:

<template>
  <WalTablePagination :tableData="tableData" :columns="columns"></WalTablePagination>
</template>
<script>
  //列配置
  const columns = [
    {
      label: '审核内容',
      prop: 'content',
      width: '220px',
      slotName: 'content',
      align: 'center',
    },
    {
      label: '得分',
      prop: 'score',
      width: '220px',
      slotName: 'score',
      align: 'center',
    },
    {
      label: '分类',
      prop: 'industry',
      width: '220px',
      align: 'center',
    },
    {
      label: '违规原因',
      prop: 'sensitiveWord',
      slotName: 'sensitiveWord',
      align: 'left',
      minWidth: '220',
    },
  ]
  const tableData = ref([])
</script>

我个人比较推荐的封装的使用方式:

<template>
  <WalTablePagination :data="tableData" >
    <el-table-column prop="content" label="审核内容" />
    <el-table-column prop="score" label="得分" />
    <el-table-column prop="industry" label="分类" />
    <el-table-column prop="sensitiveWord" label="违规原因" />
  </WalTablePagination>
</template>
<script>
  const tableData = ref([])
</script>

通过刚才的代码写法上的对比很明显的看出,为了少写html代码的做法反而需要写更多的json数组,而且从代码量上对比json列的配置方式一点也没有少写代码!!!

反而第二种方式可以一目了然的看出你的组件最终渲染的html结构,从风格上来讲更加符合原有的el-table组件的写法。整个项目的代码风格保持了一致性,可扩展性更好(支持el-table的所有功能)!

WalTablePagination 组件实现

我期望的组件用法是符合el-table的api并且也自带分页功能。其实这个需求在企业业务需求中是非常高频的。下面是组件用法示例:

<template>
  <WalTablePagination
    style="width:800px;margin: 20px auto 0px;"
    ref="waltablePagination"
    v-loading="loading"
    :max-height="500"
    :data="tableData"
    :total="pagination.total"
    :current-page="pagination.currentPage"
    @filter-change="handleFilterChange"
    @selection-change="handleSelectionChange"
    @size-change="handleSizeChange"
    @pagination-current-change="handlePaginationCurrentChange"
  >
    <el-table-column type="selection" fixed="left" width="40px" />
    <el-table-column prop="date" label="Date" />
    <el-table-column prop="name" label="Name" />
    <el-table-column
      prop="address"
      column-key="address"
      :filters="[
        { text: 'No. 1 , Grove St, Los Angeles', value: 'No. 1 , Grove St, Los Angeles' },
        { text: 'No. 2 , Grove St, Los Angeles', value: 'No. 2 , Grove St, Los Angeles' },
        { text: 'No. 3 , Grove St, Los Angeles', value: 'No. 3 , Grove St, Los Angeles' }
      ]"
      :filter-method="filterMethod"
      min-width="300px"
      label="Address" />
    <el-table-column label="Operations" fixed="right">
      <template #default="{ row }">
        <el-button size="small" type="primary" @click="handleEdit(row)">编辑</el-button>
      </template>
    </el-table-column>
  </WalTablePagination>
</template>

WalTablePagination实现原理

  1. WalTablePagination 作为父组件包裹 el-table、el-pagination 组件。
  2. 在 WalTablePagination 内部使用 useSlots().default() 接收传入的 el-table-column作为组件的slots处理后并作为 el-table的 slots使用。
  3. 在 WalTablePagination 内部使用 useAttrs() 接收组件的 props、events并且分发给el-table和el-pagination处理

因此我的 WalTablePagination 组件的html结构如下:

<template>
  <div>
    <el-table 
      ref="table" 
      v-bind="tableAttrs"
      @select="select"
      @select-all="selectAll"
      @selection-change="selectionChange"
      @cell-mouse-enter="cellMouseEnter"
      @cell-mouse-leave="cellMouseLeave"
      @cell-click="cellClick"
      @cell-dblclick="cellDblclick"
      @cell-contextmenu="cellContextmenu"
      @row-click="rowClick"
      @row-contextmenu="rowContextmenu"
      @row-dblclick="rowDblclick"
      @header-click="headerClick"
      @header-contextmenu="headerContextmenu"
      @sort-change="sortChange"
      @filter-change="filterChange"
      @current-change="tableCurrentChange"
      @header-dragend="headerDragend"
      @expand-change="expandChange"
    >
      <column :key="key" />
    </el-table>
    <el-pagination 
      v-bind="paginationAttrs" 
      @size-change="sizeChange"
      @current-change="paginationCurrentChange"
      @prev-click="prevClick"
      @next-click="nextClick"
    />
  </div>
</template>

v-bind 用来接收el-table和el-pagination组件的 props。

接下来就是 column 函数的实现思路:

  1. column是一个函数式组件并且返回了一个数组(数组是一个由el-table-column组成的vnodes)。
  2. column 可以对组件传入的 el-table-column 做进一步操作,比如fixed固定,列的显示隐藏等等功能。
  3. 我们需要对el-table-column 设置了 fixed="left"、fixed="right" 属性的列内容做一个位置固定。
tableSlots

tableSlots 用来保存 WalTablePagination 接收的原始 table 列:

const tableSlots = computed(() => {
  const defaults = useSlots().default?.();
  const tableLeft = [];  //固定到左侧列
  const tableRight = []; //固定到右侧列
  const contents = [];   //没有设置fixed属性的列就放到这里
  defaults?.forEach((vnode) => {
    if (isElTableColumn(vnode)) {
      //vnode.props就是用户传入的props
      const { fixed } = vnode.props || {};
      if (fixed) {
        if (fixed === "left") {
          return tableLeft.push(vnode);
        } else if (fixed === "right") {
          return tableRight.push(vnode);
        }
      } else {
        return contents.push(vnode);
      }
    }
  });
  return {
    tableLeft,
    tableRight,
    contents,
  };
});

得到了vnodes数组就可以将它返回给el-table去使用啦:

tableColumns

tableColumns 的作用就是对表格列作过滤。比如用户通过动态列的功能来设置列的显示隐藏功能。举个例子。当我们的表格列过多的时候全部展示在table中会变得比较拥挤,因此可以通过列配置来达到显示隐藏对应的表格列。这样用户就可以只看到自己比较关心的数据列。

// 收集动态修改visiable值后的列数据
const tableColumns = reactive({
  slot: computed(() =>
    tableSlots.value.contents.map(({ props }) => ({
      prop: props.prop, // prop
      label: props.label, // label
      visiable: props.visiable || true, // 默认情况下表格列是可见的,并且通过设置false来隐藏对应的列
    }))
  ),
  storage: [],  
  render: computed(() => {
    const slot = [...tableColumns.slot];
    const storage = [...tableColumns.storage];
    const result = [];
    storage.forEach((props) => {
      const index = slot.findIndex(({ prop }) => prop === props.prop);
      if (index >= 0) {
        result.push({
          ...props,
        });
        slot.splice(index, 1); // storage 里不存在的列
      }
      // slot 中没有找到的则会被过滤掉
    });
    result.push(...slot);
    return result;
  }),
});
finalSlot

finalSlot 表示最终需要传递给el-table的插槽。它会排除掉设置了visiable=false属性的列内容。

// 最终被呈现的slot
const finalSlot = computed(() => {
  const { contents } = tableSlots.value;
  const result = [];
  tableColumns.render.forEach(({ prop, visiable }) => {
    // 如果visiable为false则不渲染
    if (!visiable) return;
    // 从 slots.contents 中寻找对应 prop 的 VNode
    const vnode = contents.find((vnode) => prop === vnode.props?.prop);
    if (!vnode) return;
    // 克隆 VNode 并修改部分属性
    const cloned = cloneVNode(vnode);
    result.push(cloned);
  });
  return result;
});
column

最终column函数的任务就是返回需要渲染的vnodes

const column = () => [tableSlots.value.tableLeft, finalSlot.value, tableSlots.value.tableRight]

WalTablePagination 的完整代码

完整代码见 github WalTablePagination

导出组件,编写组件的install方法

导出组件,编写组件的install方法。这样组件就可以被vue.use调用并注册了


// packages/wal-table-pagination/index.js
import WalTablePagination from "./src/wal-table-pagination.vue";
WalTablePagination.install = function (app) {
  app.component(WalTablePagination.name, WalTablePagination);
};
export default WalTablePagination;

使用Vite来构建项目,使用vue-test-utils来编写组件测试用例,使用Vitest来运行自动化组件测试程序

构建项目的话我首选目前有着最快打包体验的Vite。Vite可以让你在开发阶段应用秒启动,从此告别了改一行代码需要等上40多秒的槽糕体验的webpack。

因为需要将项目发布到npm上,所以代码目录最好按照库的打包方式组织:

wal-table-pagination
├─ .gitignore  //git忽略文件
├─ .npmignore  //npm忽略文件
├─ .vscode     //vscode配置文件
│  ├─ extensions.json
│  └─ launch.json
├─ examples   // 项目运行示例代码
│  ├─ App.vue
│  └─ main.js
├─ index.html  
├─ lib        // 发布到npm上的库目录
│  ├─ wal-table-pagination.es.js
│  └─ wal-table-pagination.umd.js
├─ package-lock.json
├─ package.json
├─ packages  // 源码目录,存放组件源码和测试用例
│  └─ wal-table-pagination
│     ├─ index.js
│     ├─ src
│     │  └─ wal-table-pagination.vue
│     └─ __tests__
│        ├─ table-test-common.js
│        ├─ trigger-event.js
│        └─ wal-table-pagination.test.js
├─ README.md
└─ vite.config.js  
搭建 Vite 项目

兼容性注意 Vite 需要 Node.js 版本 >= 12.0.0。

使用npm

$ npm init vite@latest

给项目命名并按照提示操作即可初始化项目!

配置vite.config.js
//vite.config.js
import { resolve } from "path";
import { defineConfig } from "vite";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  server: {
    port: "3000",
  },
  build: {
    outDir: "lib",
    lib: {
      //库编译模式配置
      entry: resolve(__dirname, "packages/wal-table-pagination/index.js"), //指定组件编译入口文件
      name: "WalTablePagination",
      fileName: (format) => `wal-table-pagination.${format}.js`,
    },
    rollupOptions: {
      //rollup打包配置
      external: ["vue", "element-plus"], // 指定外部依赖
      output: {
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: "Vue",
          "element-plus": "elementPlus",
        },
      },
    },
  },
  test: {
    // 使用 jsdom 模拟 DOM
    // 这需要你安装 jsdom 作为对等依赖(peer dependency)
    environment: "jsdom",
  },
  resolve: {
    alias: {
      "@": resolve(__dirname, "./packages"),
    },
  },
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
});

安装如下依赖:

element-plus    //插件基于element-plus封装
@vue/test-utils //vue官方测试库
jsdom           //dom库,用于模拟浏览器的dom
vitest          //运行测试程序

完整的package配置如下:

//package.json
{
  "name": "wal-table-pagination",
  "author": "victor jiang",
  "private": false,
  "version": "1.1.0",
  "description": "基于element-plus实现的table带分页组件",
  "license": "MIT",
  "homepage": "https://github.com/JZH189/wal-table-pagination#README.md",
  "keywords": [
    "vue3",
    "element-plus",
    "table",
    "pagination"
  ],
  "files": [   //需要上传到npm的文件目录
    "lib"
  ],
  "main": "lib/wal-table-pagination.umd.js",  //cjs
  "module": "lib/wal-table-pagination.es.js", //esm
  "exports": {
    "./lib/style.css": "./lib/style.css",
    ".": {
      "import": "./lib/wal-table-pagination.es.js",
      "require": "./lib/wal-table-pagination.umd.js"
    }
  },
  "type": "module",
  "scripts": {
    "test": "vitest",
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "element-plus": "^2.2.16",
    "vue": "^3.2.37"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^3.0.3",
    "@vue/compiler-dom": "^3.2.38",
    "@vue/test-utils": "^2.0.2",
    "jsdom": "16.4.0",
    "pretty": "^2.0.0",
    "unplugin-auto-import": "^0.11.2",
    "unplugin-vue-components": "^0.22.7",
    "vite": "^3.0.7",
    "vitest": "^0.20.0"
  }
}
配置Vitest

Vitest 的主要优势之一是它与 Vite 的统一配置。如果存在,vitest 将读取你的根目录 vite.config.ts 以匹配插件并设置为你的 Vite 应用程序。例如,你的 Vite 有 resolve.alias 和 plugins 的配置将会在 Vitest 中起作用。如果你想在测试期间想要不同的配置,你可以:

  • 创建 vitest.config.ts,优先级将会最高。
  • 将 --config 选项传递给 CLI,例如 vitest --config ./path/to/vitest.config.ts。
  • 在 defineConfig 上使用 process.env.VITEST 或 mode 属性(如果没有被覆盖,将设置为 test)有条件地在 vite.config.ts 中应用不同的配置。

如果要配置 vitest 本身,请在你的 Vite 配置中添加 test 属性。 你还需要使用 三斜线命令 ,同时如果是从 vite 本身导入 defineConfig,请在配置文件的顶部加上三斜线命令。

还可以参阅Vitest中的配置

编写测试用例

在packages/wal-table-pagination 目录中创建 tests 文件夹,并且新建和组件相同名称的.test.js文件。 这样当我们 npm run test 的时候Vitest会自动运行包含了.test.js的文件。

其实自己写测试用例可以帮助我们更好的发现组件的潜在bug。

单元测试

编写单元测试是为了验证小的、独立的代码单元是否按预期工作。一个单元测试通常覆盖一个单个函数、类、组合式函数或模块。单元测试侧重于逻辑上的正确性,只关注应用整体功能的一小部分。一般来说,单元测试将捕获函数的业务逻辑和逻辑正确性的问题。

组件测试

组件测试应该捕捉组件中的 prop、事件、提供的插槽、样式、CSS class 名、生命周期钩子,和其他相关的问题。组件测试不应该模拟子组件,而应该像用户一样,通过与组件互动来测试组件和其子组件之间的交互。例如,组件测试应该像用户那样点击一个元素,而不是编程式地与组件进行交互。

组件测试主要需要关心组件的公开接口而不是内部实现细节。对于大部分的组件来说,公开接口包括触发的事件、prop 和插槽。当进行测试时,请记住,测试这个组件做了什么,而不是测试它是怎么做到的。 这样我们的测试程序将会更加健壮。比如业务调整组件功能的时候,我们只需要保证对应的组件功能测试代码能够通过测试就可以了。记住这点是非常重要的。

将组件发布到npm


npm run build  //将组件打包到lib
npm login  
npm publish

最后打开npm官方网站,直接搜 wal-table-pagination 就可以找到刚才发布的 npm 包了。