vue组件封装的基本步骤

490 阅读5分钟

1.ESLint配置文件

  • 优先级(同一级下):
  1. .eslintrc.js
  2. .eslintrc.cjs
  3. .eslintrc.yaml
  4. .eslintrc.yml
  5. .eslintrc.json
  6. package.json
    ESLint 会自动要检查文件的目录中寻找它们,并在其直系父目录中寻找,直到文件系统的根目录(/)、当前用户的主目录(~/)或指定 root: true 时停止
    eslint rules
{
    "root": true,// 是否是根目录
    "env": {
      "node": true // 环境
    },
    "extends": [// 扩展
      "plugin:vue/vue3-essential",
      "eslint:recommended",
      "@vue/typescript/recommended"
    ],
    "parserOptions": {
      "ecmaVersion": 2020 // ES版本
    },
    "rules": {// 自定义规则
      "eqeqeq": "warn",
      "quotes": ["error", "double"]
    },
    "overrides": [// 覆盖规则
      {
        "files": ["./src/test/*"],
        "rules": {
          "quotes": ["error", "single"]
        }
      }
    ]
  },

2.插件具体是如何实现扩展配置的
通过extends设置的配置包加载的时候,是递归的形式去查找配置文件一步步派生继承的。 例如: extends: ["test"],然后对应的eslint-config-testplugins: ["children"], ESLint 会先找到 ./node_modules/ 下的eslint-plugin-children, (而不是 ./node_modules/eslint-config-foo/node_modules/),更不会从祖先目录去查找。

image.png "plugin:vue/vue3-essential":有 plugin: 前缀,显然要先去找./node_modules/eslint-plugin-vue3,然后看它导出的配置里的essential属性。在./node_modules/eslint-plugin-vue/lib/index.js 里找到了

image.png

"eslint:recommended": 没有eslint-config-eslint插件,说明是插件名/路径,就直接到eslint的npm包下去找了,它封装得比较复杂,没找到在哪里导出配置和configs 关键字,只知道"eslint:recommended" 的配置规则在 ./node_modules/@eslint/js/configs/eslint-recommended.js

"@vue/typescript/recommended":同上没有相关插件,直接到@vue包里找,,找到eslint-config-typescript;再到里面找到recommended.js

image.png

3.扩展配置文件

module.exports = { 
   // ...
    "rules": {
        "eqeqeq": "warn",
    }
};

要改变规则的设置,你必须把规则 ID 设置为这些值之一:

  • "off" 或 0 - 关闭规则

  • "warn" 或 1 - 启用并视作警告(不影响退出)。

  • "error" 或 2 - 启用并视作错误(触发时退出代码为 1)

  • 改变一个继承的规则的严重程度,而不改变其选项。

    • 基本配置:"eqeqeq": ["error", "allow-null"]
    • 派生配置:"eqeqeq": "warn"
    • 产生的实际配置:"eqeqeq": ["warn", "allow-null"]
  • 覆盖基础配置中的规则选项:

    • 基本配置:"quotes": ["error", "single", "avoid-escape"]
    • 派生配置:"quotes": ["error", "single"]
    • 产生的实际配置:"quotes": ["error", "single"]

image.png

image.png

4.覆盖配置文件

{
    "rules": {
      "eqeqeq": "warn",
      "quotes": ["error", "double"]
    },
    "overrides": [
      {
        "files": ["./src/test/*"],
        "rules": {
          "quotes": ["error", "single"]
        }
      }
    ]
}

image.png

VitePress

1.markdown基础语法

![图片转存失败,建议将图片保存下来直接上传
        ## 标题
**加粗样式**
*斜体样式*
~~删除线格式~~ 

 - 无序
 - 222
 - 2222
 - 2222  

 1. List item
 2. 有序
 3. 影院

 图片(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2b09cc15a21149d7ad31232091f841c9~tplv-k3u1fbpfcp-zoom-1.image)

|序号|表头1  |
|--|--|
|3333  |  44444|

[链接](http://1111.com)
]()

 // “半角空格”
 // “全角空格”

2.VitePress基础配置

image.png

  • Markdown 扩展

  • 首页配置

---
layout: home

hero:
  name: Docs Name #文档标题
  text: Vite & Vue powered static site generator. #文档副标题
  tagline: Lorem ipsum... #文档标语
  image:
    src: /logo.png
    alt: 首页的logo图标
  # type ThemeableImage =
  # | string
  # | { src: string; alt?: string }
  # | { light: string; dark: string; alt?: string }
  actions:
    - theme: brand #按钮的主题
      text: 开始使用 #按钮的文字
      link: /guide/start #按钮的链接
    - theme: alt
      text:  GitHub 上查看
      link: https://github.com/ox4f5da2

features:
  - icon: 🛠️ #盒子的图标
    title: Simple and minimal, always #盒子的标题
    details: Lorem ipsum... #详细描述
---

image.png

  • 主题配置
import { DefaultTheme, defineConfig } from 'vitepress'
const nav: DefaultTheme.NavItem[] = [
  { text: '指南', link: '/guide/' },
  { text: '源码', link: 'https://vitepress.vuejs.org/guide/theme-home-page' },
]
const sidebar: DefaultTheme.Sidebar = {
  '/guide': [
    {
      text: '指南',
      items: [
        { text: '组件库介绍', link: '/guide/' },
        { text: '快速开始', link: '/guide/start' },
      ]
    }
  ],
}

module.exports = {
  title: 'Test VitePress',
  description: 'Just playing around.',
  lang: 'cn-ZH',
  base: '/',
  lastUpdated: true,
  markdown: {
    lineNumbers: true // md文件中的代码启用行号
  },
  config: (md) => {
    // 使用markdown-it插件
    md.use(require('markdown-it-xxx'))
  },
  themeConfig: {
    logo: '/logo.png',// 图标
    siteTitle: '组件库标题',// 标题
    outline: 10,
    socialLinks: [
      { icon: 'github', link: 'https://github.com/vuejs/vitepress' }
    ],
    nav,
    sidebar
  }
}
<pre>{{ test }}</pre>
<script setup>
const test = 'just for test'
</script>

image.png

vue组件封装及测试

<template>
  <div>
    <van-form>
      <van-cell-group inset>
        <van-field
          v-for="item in props.schema"
          v-bind="item.fieldProps"
          :key="item.field"
          v-model="formData[item.fieldProps.field]"
        >
          <template #input v-if="item.compotent">
            <component
              :is="item.compotent"
              v-bind="item.compotentProps"
              v-model="formData[item.fieldProps.field]"
            >
            </component>
          </template>
        </van-field>
      </van-cell-group>
    </van-form>
  </div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, PropType, ref } from 'vue'
// eslint-disable-next-line no-undef
const props = defineProps({
  schema: {
    type: Array as PropType<Record<string, any>[]>,
    default: () => [],
  },
})
const formData = ref<Record<string, any>>({})
const setFormData = (values: any) => {
  nextTick(() => Object.assign(formData.value, values))
  console.log(formData)
}
onMounted(() => {
  props.schema.forEach((item) => {
    formData.value[item.fieldProps.field] = item.compotentProps?.value
  })
})
// eslint-disable-next-line no-undef
defineExpose({
  setFormData,
})
</script>
<style lang="less" scoped></style>
<template>
  <div>
    <van-popup :show="props.showPicker" position="bottom">
      <van-picker
        :columns="(props.currentOption as any)"
        @confirm="onConfirm"
        @cancel="onCancel"
      />
    </van-popup>
    <slot name="action"> </slot>
  </div>
</template>
<script setup lang="ts">
// eslint-disable-next-line no-undef
const props = defineProps({
  showPicker: {
    type: Boolean,
    default: false,
  },
  currentOption: {
    type: Array,
    default: () => [],
  },
})
// eslint-disable-next-line no-undef
const emit = defineEmits(['update:showPicker', 'setSelectValue'])
const onConfirm = ({ selectedOptions }) => {
  emit('setSelectValue', selectedOptions)
  emit('update:showPicker', false)
}
const onCancel = () => {
  emit('update:showPicker', false)
}
</script>

<template>
  <div>
    <CustomForm :schema="schema" ref="customFormRef" />
    <CustomPicker
      :currentOption="currentOption"
      v-model:showPicker="showPicker"
      @setSelectValue="setSelectValue"
    />
  </div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import CustomForm from './customForm.vue'
import CustomPicker from './customPicker.vue'
const schema = [
  {
    fieldProps: {
      field: 'switch',
      name: 'switch',
      rules: [],
      label: '开关',
    },
    compotent: 'van-switch',
    compotentProps: {
      defaultValue: 0,
    },
  },
  {
    fieldProps: {
      field: 'rate',
      name: 'rate',
      rules: [],
      label: '开关',
    },
    compotent: 'van-rate',
    compotentProps: {
      value: 3,
    },
  },
  {
    fieldProps: {
      field: 'stepper',
      name: 'stepper',
      rules: [],
      label: '步进器',
    },
    compotent: 'van-stepper',
    compotentProps: {
      value: 3,
    },
  },
  {
    fieldProps: {
      field: 'picker',
      label: '选择器',
      onclick: () => {
        openPicker('picker', [
          {
            text: 'test-1',
            value: 1,
          },
          {
            text: 'test-2',
            value: 2,
          },
        ])
      },
    },
    compotent: '',
    compotentProps: {
      value: 3,
      ['is-link']: true,
      readonly: true,
      label: '选择器',
    },
  },
]
const customFormRef = ref<any>(null)
const currentOption = ref<Array<Record<string, any>>>([])
const currentSelectField = ref('')
const showPicker = ref(false)
const openPicker = (field: string, Options: Array<Record<string, any>>) => {
  currentOption.value = Options
  currentSelectField.value = field
  showPicker.value = true
}
const setSelectValue = (selectedOptions) => {
  customFormRef.value.setFormData({
    [currentSelectField.value]: selectedOptions[0].text,
  })
}
</script>
<style lang="less" scoped></style>

image.png

Vitest+Vue Test Utils组件测试

  • vitest.config.ts配置
// vitest.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
 
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  test: {
    clearMocks: true,
    environment: "happy-dom",//使用 happy-dom 模拟 DOM
    transformMode: {
      web: [/\.[jt]sx$/],
    },
   deps: {
    //  使用UI库
    inline: ['vant']
   }
  },
})
  • 基础测试
// 组件
<template>
  <div @click="add">{{ count }}</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
// eslint-disable-next-line no-undef
const emit = defineEmits(['change'])
const add = () => {
  emit('change', 123)
  count.value++
}
</script>

// 测试文件
import { it, describe, expect } from 'vitest'
import { mount } from '@vue/test-utils'
// import CustomPicker from './customPicker.vue'
import counter from './counter.vue'
import exp from 'constants'
describe('基础测试', () => {
  const wapper = mount(counter)
  it('测试组件是否正确渲染', () => {
    expect(wapper.html()).toBe('<div>0</div>')
  })
  it('测试vue语法正确渲染', () => {
    expect(wapper.html()).toBe('{{count}}')
  })
})

image.png

  • 模拟用户交互
import { it, describe, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
// import CustomPicker from './customPicker.vue'
import counter from './counter.vue'
describe('模拟交互', () => {
  it('点击事件', () => {
    const wapper = mount(counter)
    wapper.trigger('click')
    expect(wapper.html()).toBe('<div>1</div>')
  })
  it('点击事件-await', async () => {
    const wapper = mount(counter)
    await wapper.trigger('click')
    expect(wapper.html()).toBe('<div>1</div>')
  })
  it('点击事件-nextTick', () => {
    const wapper = mount(counter)
    wapper.trigger('click')
    nextTick(() => {
      expect(wapper.html()).toBe('<div>1</div>')
    })
  })
})

image.png

  • 测试props
import { it, describe, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import CustomPicker from './customPicker.vue'
describe('设置props', () => {
  const wapper = mount(CustomPicker)
  it('查看props', () => {
    expect(wapper.props('showPicker')).toBe(true)
  })
  it('设置节点渲染', () => {
    const picker = wapper.find('van-picker')
    expect(picker.isVisible()).toBe(true)
  })
  it('设置props', async () => {
    wapper.setProps({ showPicker: true })
    nextTick(() => {
      const picker = wapper.find('van-picker')
      expect(wapper.props('showPicker')).toBe(true)
      expect(picker.isVisible()).toBe(true)
    })
  })
})

image.png

  • 测试emit事件
import { it, describe, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('测试emit事件', async () => {
  const wapper = mount(Counter)
  wapper.trigger('click')
  it('测试emit事件',  () => {
    expect(wapper.emitted().change).toMatchObject([[12]])
  })
})

image.png

  • 测试插槽
// 路由文件
import { it, describe, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
import CustomPicker from './customPicker.vue'
describe('设置props', async () => {
  const wapper = mount(CustomPicker, {
    slots: {
      action: '<button class="action-btn">确定</button>',
      error: '<button class="error">错误</button>'
    }
  })
  it('测试slot-1', async () => {
    expect(wapper.find('.action-btn').text()).toBe('确定')
  })
  it('测试slot-2', async () => {
    expect(wapper.find('.error').text()).toBe('错误')
  })
})

image.png

  • router
import { createRouter, createWebHistory } from 'vue-router'
import customButton from '@/components/customButton/index.vue'
import routerPageOne from '@/client/routerPageOne.vue'
import routerPageTwo from '@/client/routerPageTwo.vue'
// 路由按需引入有问题
export const  routes = [
  {
    path: '/routerTest',
    component: routerPageOne
  },
  {
    path: '/routerSkip',
    component: routerPageTwo
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})


export default router

// 测试文件

//页面1
<template>
 <div>just for test router</div>
</template>
<script lang="ts" setup></script>
<style lang="less" scoped></style>

// 页面二
<template>
 <div>just for test router skip</div>
</template>
<script lang="ts" setup></script>
<style lang="less" scoped></style>

// 主页面
<template>
 <div>
  <router-link to="/routerSkip">skip</router-link>
  <router-view />
 </div>
</template>
<script lang="ts" setup>
</script>
<style lang="less" scoped>
</style>

// 测试文件
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import customButton from '../components/customButton/index'
import customField from '../components/customField/index'
import App from './App.vue'
import testRouter from './testRouter.vue'
import { createRouter, createWebHistory } from 'vue-router'
import router from "@/router/index"
import { nextTick } from 'vue'

  it('是否正确渲染', async () => {
    router.push('/routerTest')
    await router.isReady()
    const wrapper = mount(testRouter, {
      global: {
        plugins: [router]
      }
    })
    console.log(111, wrapper.html())
    expect(wrapper.html()).contains('just for test router')

    await wrapper.find('a').trigger('click')
    await flushPromises()
    console.log(111, wrapper.html())
    expect(wrapper.html()).contains('just for test router skip')
  })

image.png

  • mock数据

// 模拟接口
export const getList = () => new Promise((resolve,reject) => {
  resolve({
    data: { name: '李四' }
  });
})


// 组件
<template>
  <div>
    <van-popup :show="props.showPicker" position="bottom">
      <van-picker
        v-if="props.showPicker"
        :columns="(props.currentOption as any)"
        @confirm="onConfirm"
        @cancel="onCancel"
      />
    </van-popup>
    <div @click="onConfirm">
      <slot name="action"> </slot>
    </div>
    <div @click="getData">
      <slot name="getData"></slot>
    </div>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getList } from '../api/request'
// eslint-disable-next-line no-undef
const props = defineProps({
  showPicker: {
    type: Boolean,
    default: false,
  },
})
let currentOption = ref({})
// eslint-disable-next-line no-undef
const emit = defineEmits(['update:showPicker', 'setSelectValue'])
const onConfirm = async () => {
  emit('setSelectValue', currentOption.value)
  emit('update:showPicker', false)
}
const onCancel = () => {
  emit('update:showPicker', false)
}
const getData = async () => {
  const res = await getList()
  currentOption.value = res
}
</script>


// 测试代码
import { it, describe, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
import CustomPicker from './customPicker.vue'
import flushPromises from 'flush-promises';
import { nextTick } from 'vue';
vi.doMock('../api/request');
describe('测试接口', async () => {
  const wapper = mount(CustomPicker, {
    slots: {
      action: '<button class="action-btn">确定</button>',
      getData: '<button class="getData">  请求数据</button>'
    }
  })
  it('测试接口', async () => {
    await wapper.find('.getData').trigger('click')
    await flushPromises()
    wapper.find('.action-btn').trigger('click')
    expect(wapper.emitted().setSelectValue).toMatchObject([[{
      name: '11',
    },]])
  })
})

image.png

//自定义按钮
<template>
  <div> 
    <van-button class="custom-btn" @click="emitClick" v-bind="$attrs">
      <template #[item]="data" v-for="item in Object.keys($slots)" :key="item">
        <slot :name="item" v-bind="data || {}"></slot>
      </template>
    </van-button>
  </div>
</template>
<script setup lang="ts">
const emit = defineEmits(['onClick'])
const emitClick = () => {
  emit('onClick', 'you has clicked it')
}
</script>
<style></style>

// 自定义输入框
<template>
  <div>
    <van-field class="custom-field" v-bind="$attrs" @focus="onFocus($attrs.label)" v-model="msg">
      <template #[item]="data" v-for="item in Object.keys($slots)" :key="item">
        <slot :name="item" v-bind="data || {}"></slot>
      </template>
    </van-field>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const emit = defineEmits(['onFocus'])
const msg = ref('')
const onFocus = (label: any) => {
  emit('onFocus', label)
}
</script>
<style></style>

// 测试vue文件
<template>
  <div>
    <CustomButton :text="'test'">
      <template #icon>
        <img class="icon" :src="'https://fastly.jsdelivr.net/npm/@vant/assets/user-active.png'" />
      </template>
    </CustomButton>
    <CustomField :label="'测试'" :placeholder="'请输入'" @onFocus="onFocus"/>
    <span class="notify">{{ label }}已聚焦</span>
  </div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import CustomButton from '../components/customButton/index'
import CustomField from '../components/customField/index'
const label = ref('')
const onFocus = (value: any) => {
  label.value = value
}
</script>
<style>
.icon {
  width: 20px;
  height: 20px;
}
</style>

// 测试文件
import { describe,it,expect} from 'vitest'
import { mount } from '@vue/test-utils'
import customButton from '../components/customButton/index'
import customField from '../components/customField/index'
import App from './App.vue'
describe('测试App是否正确渲染',() => {
  const wrapper = mount(App)
  const node = wrapper.findComponent(customButton)
  it('测试按钮文字',async () => {
    console.log(1111, node.text())
    expect(node.text()).toBe('test')
  })
  it('测试按钮插槽',async () => {
    console.log(node.find('.icon'))
    expect(node.find('.icon').isVisible()).toBe(true)
  })
})

image.png

**import { describe,it,expect} from 'vitest'
import { mount } from '@vue/test-utils'
import customButton from '../components/customButton/index'
import customField from '../components/customField/index'
import App from './App.vue'
describe('测试App是否正确渲染',() => {
  const wrapper = mount(App)
  const node = wrapper.findComponent(customField).find('input')
  it('测试输入框',async () => {
    await node.setValue(1111)
    console.log(111, wrapper.find('.notify').text())
    expect(wrapper.find('.notify').text()).toBe('1111已聚焦')
  })
})

image.png

组件打包

// 组件ts文件
// 按钮
import zButton from "./index.vue";

zButton.install = function (Vue: any) {
  Vue.component(zButton.name, zButton);
};

export default zButton;


// 输入框
import zField from './index.vue';
zField.install = function (Vue: any) {
  Vue.component(zField.name, zField);
};

export default zField;

// main.ts

// 引入封装好的组件
import zButton from "./components/customButton/index";
import zField  from "./components/customField/index"

const components = [zButton,zField]; // 如果有多个其它组件,都可以写到这个数组里

// 批量组件注册
const install = function (Vue: any) {
  components.forEach((com) => {
    Vue.component(com.name, com);
  });
};

export default {
  install,
  zButton,
  zField
}; // 这个方法使用的时候可以被use调用

// vite.config.ts
export default defineConfig({
  plugins: [
    vue(), 
    vueJsx(),
    Components({
    resolvers: [VantResolver()],
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  build: {
    // 到处文件目录,zmhu-ui 用于存放package.json,避免被覆盖
    // 这里不设置也是默认dist
    outDir:"dist",
    // 兼容
    target: "es2015",
    lib: {
      // Could also be a dictionary or array of multiple entry points
      entry: resolve(__dirname, "src/main.ts"),
      name: "zmh-ui",
      // the proper extensions will be added
      // 如果不用format文件后缀可能会乱
      fileName: (format) => `zmh-ui.${format}.js`,
    },
    rollupOptions: {
      // 确保外部化处理那些你不想打包进库的依赖
      external: ["vue", "vant"],
      output: {
        // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
        globals: {
          vue: "Vue",
          vant: "Vant",
        },
      },
    },
  },
})

// package.json
  "version": "2.0.0", // 版本,每次发包不能相同
  "private": false,// 是否私有
  "files": ["dist"],// 别人npm 下载的文件
  "main": "./dist/zmh-ui.umd.js", 
  "module": "./dist/zmh-ui.es.js",
  "name": "zmh-ui",// 包名
  "exports": {
    ".": {
      "import": "./dist/zmh-ui.es.js",
      "require": "./dist/zmh-ui.umd.js"
    },
    "./dist/style.css": {
      "import": "./dist/style.css",
      "require": "./dist/style.css"
    }
  },
 

image.png

使用

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
import customUI from 'zmh-ui'
import "zmh-ui/dist/style.css"

const app = createApp(App)

app.use(router).use(customUI)

app.mount('#app')

// vue-shim.d.ts
declare module 'zmh-ui'


// App.vue
<script setup lang="ts">
import customUI from 'zmh-ui'
const { zButton } = customUI
</script>

<template>
    <zButton :text="'测试'">
      <template #icon>
        <img class="btn-icon" :src="'../public/favicon.ico'">
      </template>
    </zButton>
</template>

<style scoped>
:deep(.btn-icon) {
  width: 20px;
  height: 20px;
}
</style>

image.png