前言
经理给了我一个比较奇葩的需求:在自身的项目(物联网中台)把组件分成"私有组件"和"公共组件"两个模块,私有组件是这个项目自身才能使用的,公共组件就是把该文件夹内的组件当成组件库打包放到公司的库里面去。当时的想法是公共组件抽出来当成一个项目来打包发布,不过经理的意见是这个公共组件,在中台这个项目里面导进来就能用而不需要当成依赖来用,emmm。行吧行吧,菜鸡前端只能听从上级的。
项目目录结构、组件库结构
iotPublicComponents可以放在src里面的components文件夹,不过我这里抽了出来。
iotPublicComponents结构:里面有组件文件夹、静态资源文件夹、打包配置、以及一些请求和路由的封装。
组件封装
<!-- 空间选择组件 --> <script lang="ts" setup> import { ref, reactive, onMounted } from 'vue'; import { Search, ArrowRight } from '@element-plus/icons-vue'; import componentApis from '../api/componentApis'; /** * @note 组件所需配置 * @note isShowSelectSpace 控制组件显隐 * @note selectSpaceType 单选raion、多选multiple * @note spaceType 可选属性,限制空间的类型 * @note selectSpaceData 父组件传入的数组(子组件所抛出给父的选中数据) */ interface Props { isShowSelectSpace: boolean; selectSpaceType: string; spaceType?: string; selectSpaceData?: any[]; } interface SelectSpaceInfo { id: string; parentId: string; name: string; spaceType: string; hierarchy: number; subNumber: number; spacePathText: string; check?: string; active?: string; } const props = withDefaults(defineProps<Props>(), { selectSpaceType: 'radio', }); /** * @function closeDialog 弹窗关闭 * @function confirmSelection 确认所选数据 */ const emit = defineEmits<{ (e: 'closeDialog', isShowSelectSpace: boolean): void; (e: 'confirmSelection', data: SelectSpaceInfo[]): void; }>(); // let flag = false; let parentId: string = '0'; const spaceType = ref<string>('All'); const searchSpaceName = ref<string>(''); const spaceLoading = ref<boolean>(false); const showArrowRight = ref<boolean>(true); // 面包屑 const breadcrumbList: SelectSpaceInfo[] = reactive([ { id: '0', name: '全部', parentId: '', active: '1', hierarchy: 0, spaceType: 'All', subNumber: 1, spacePathText: '', }, ]); // 空间列表 const spaceData: SelectSpaceInfo[] = reactive([]); // 所选空间 const selectData: SelectSpaceInfo[] = reactive([]); /** * @function 处理列表多选 * @param event 点击列表或点击勾选框 * @param data 所点击的数据 */ function handleMultipleSelect(data: SelectSpaceInfo): void { // if (typeof event !== 'string') { // data.check = data.check == '1' ? '0' : '1'; // } let index = selectData.findIndex((item: SelectSpaceInfo) => { return item.id === data.id; }); if (index == -1) { selectData.push(data); } else { selectData.splice(index, 1); } } /** * @function 处理列表单选 * @param event 点击列表或点击勾选框 * @param data 所点击的数据 */ function handleRadioSelect(event: Event, data: SelectSpaceInfo): void { if (typeof event === 'string') { spaceData.map((item: SelectSpaceInfo) => { if (item.id !== data.id) { item.check = '0'; } }); } let index = selectData.findIndex((item: SelectSpaceInfo) => { return item.id === data.id; }); if (index === -1) { if (selectData.length === 0) { selectData.push(data); } else { selectData.splice(0, 1, data); } } else { selectData.splice(index, 1); } } /** * @function 根据多选、单选条件以及是否空间类型限制触发相应事件 * @param event 点击列表或点击勾选框 * @param data 所点击的数据 * @note multiple 多选、Radio 单选; * @note spaceType:All为不限制空间类型,其它类型需在使用组件时传入 */ function handleChangeSpaceDataCheck(event: Event, data: SelectSpaceInfo): void { const type = props.selectSpaceType; const flag = data.spaceType === spaceType.value ? true : false; switch (type) { case 'multiple': if (spaceType.value === 'All') { handleMultipleSelect(data); } if (spaceType.value !== 'All') { if (flag) { handleMultipleSelect(data); } else { return; } } break; case 'radio': if (spaceType.value === 'All') { handleRadioSelect(event, data); } if (spaceType.value !== 'All') { if (flag) { handleRadioSelect(event, data); } else { return; } } break; } } /** * @function 处理点击父级空间获取子空间、头部面包屑的增减 * @param data 所点击的父级空间 * @param type 事件类型 * @note showArrowRight为true进行查询操作,为false则return */ function handleAddOrRemoveBreadcrumbList(data: SelectSpaceInfo, type: 'Remove' | 'Add'): void { if (showArrowRight.value) { if (parentId !== data.id) { parentId = data.id; let index = breadcrumbList.findIndex((item: SelectSpaceInfo) => { return item.id === data.id; }); switch (type) { case 'Remove': breadcrumbList.splice(index + 1); breadcrumbList.map((item: SelectSpaceInfo, idx: number) => { item.active = '0'; if (idx == index) { item.active = '1'; } }); break; case 'Add': if (index === -1) { breadcrumbList.push(data); breadcrumbList.map((item: SelectSpaceInfo, idx: number) => { item.active = '0'; if (idx == breadcrumbList.length - 1) { breadcrumbList; item.active = '1'; } }); } break; } handleChangeSearchParams('ParentId', data, parentId); } else { return; } } else { return; } } /** * @function 处理根据名称模糊查询、根据父级Id的查找 * @param type SearchSpaceName名称模糊查询 ParentId父级Id查找 * @param data 父级Id查找时所选数据 * @param parentId 父级Id */ function handleChangeSearchParams(type?: 'SearchSpaceName' | 'ParentId', data?: SelectSpaceInfo, parentId?: string): void { const params: any = { criteria: { key: '$and', items: [{ key: 'deleteFlag', data: { '$eq': '0' } }], }, }; if (type) { switch (type) { case 'SearchSpaceName': if (searchSpaceName.value === '') { showArrowRight.value = true; breadcrumbList.splice(1); breadcrumbList.map((item: SelectSpaceInfo) => { item.active = '1'; }); parentId = '0'; const searchSpaceHierarchy = { key: 'hierarchy', data: { $eq: 1 } }; params.criteria.items.push(searchSpaceHierarchy); } else { showArrowRight.value = false; const searchSpaceNameObject = { key: 'name', data: { $regex: searchSpaceName.value } }; params.criteria.items.push(searchSpaceNameObject); } break; case 'ParentId': showArrowRight.value = true; const searchSpaceHierarchy = { key: 'hierarchy', data: { $eq: Number(data?.hierarchy) + 1 } }; const searchSpaceParentId = { key: 'parentItemList.parentId', data: { $eq: parentId } }; if (parentId == '0') { params.criteria.items.push(searchSpaceHierarchy); } else { const obj = { key: '$and', items: [searchSpaceHierarchy, searchSpaceParentId] }; params.criteria.items.push(obj); } break; } } else { const searchSpaceHierarchy = { key: 'hierarchy', data: { $eq: 1 } }; params.criteria.items.push(searchSpaceHierarchy); } spaceLoading.value = true; componentApis .getSpaceList(params) .then((res) => { spaceLoading.value = false; if (res) { spaceData.length = 0; let arr = res.map((item: SelectSpaceInfo) => { return { id: item.id, parentId: item.parentId, name: item.name, spaceType: item.spaceType, hierarchy: item.hierarchy, subNumber: item.subNumber, spacePathText: item.spacePathText.replace(new RegExp('/', 'g'), '>'), check: '0', active: '0', }; }); if (selectData.length > 0) { selectData.forEach((selectItem: SelectSpaceInfo) => { arr.map((item: SelectSpaceInfo) => { if (selectItem.id === item.id) { item.check = '1'; } }); }); } Object.assign(spaceData, arr); } }) .catch((err) => { console.log('err==>', err); }); } /** * @function 处理清空搜索名称 * @note 清空时会显示面包屑 */ function handleClearSearchSpaceName(): void { showArrowRight.value = true; breadcrumbList.splice(1); breadcrumbList.map((item: SelectSpaceInfo) => { item.active = '1'; }); parentId = '0'; handleChangeSearchParams('SearchSpaceName'); } /** * @function 处理移除标签 * @param id 所选数据的Id */ function handleRmoveTag(id: string): void { let index = selectData.findIndex((item: SelectSpaceInfo) => { return item.id === id; }); if (index !== -1) { selectData.splice(index, 1); } spaceData.map((item: SelectSpaceInfo) => { if (item.id === id) { item.check = '0'; } }); } function handleCloseDialog(): void { emit('closeDialog', false); } function handleConfirmSelection(): void { emit('closeDialog', false); emit('confirmSelection', selectData); } onMounted(() => { handleChangeSearchParams(); Object.assign(selectData, props.selectSpaceData); spaceType.value = props.spaceType ? props.spaceType : 'All'; }); </script> <template> <div> <el-dialog v-model="props.isShowSelectSpace" width="35%" align-center @close="handleCloseDialog()"> <template #header> <span class="h4">选择空间</span> </template> <template #default> <div class="mt-20"> <el-input v-model="searchSpaceName" placeholder="搜索" @keyup.enter.native="handleChangeSearchParams('SearchSpaceName')" clearable @clear="handleClearSearchSpaceName()"> <template #suffix> <el-icon @click="handleChangeSearchParams('SearchSpaceName')"> <Search /> </el-icon> </template> </el-input> </div> <div class="mt-20" v-if="showArrowRight"> <el-breadcrumb :separator-icon="ArrowRight"> <el-breadcrumb-item class="p-10" :class="item.active == '1' ? 'dialog-body-active' : ''" v-for="item in breadcrumbList" :key="item.id" @click="handleAddOrRemoveBreadcrumbList(item, 'Remove')" > <el-tooltip placement="top" :show-after="500"> <template #content> {{ item.name }}</template> <div class="w-50 dialog-body-breadcrumb">{{ item.name }}</div> </el-tooltip> </el-breadcrumb-item> </el-breadcrumb> </div> <div class="mt-20"> <el-scrollbar height="400px" v-loading="spaceLoading"> <div v-if="spaceData.length > 0"> <div class="mh-10 mb-10" v-for="item in spaceData" :key="item.id"> <div class="ds-fx ai-c"> <el-checkbox v-if="spaceType == 'All'" @change="handleChangeSpaceDataCheck($event, item)" v-model="item.check" size="large" true-label="1" false-label="0" /> <el-checkbox v-if="spaceType !== 'All' && spaceType === item.spaceType" @change="handleChangeSpaceDataCheck($event, item)" v-model="item.check" size="large" true-label="1" false-label="0" /> <div class="hideBox" v-else></div> <div @click.stop="handleAddOrRemoveBreadcrumbList(item, 'Add')" :class="item.check == '1' ? 'dialog-body-items-active' : ''" class="dialog-body-items ml-10 pv-20 ds-fx ai-c jc-sb w-full" > <div class="ds-fx ai-c"> <img class="ml-10 dialog-body-items-image" src="../assets/images/spaceIcon.png" /> <span class="ml-10 dialog-body-items-spacePathText" v-if="showArrowRight">{{ item.name }}</span> <el-tooltip v-else placement="top" :show-after="500"> <template #content> {{ item.spacePathText }} </template> <span class="ml-10 dialog-body-items-tooltip">{{ item.spacePathText }}</span> </el-tooltip> </div> <el-icon v-if="showArrowRight" class="pr-10"><ArrowRight /></el-icon> </div> </div> </div> </div> <div v-else> <el-empty description="暂无数据" /> </div> </el-scrollbar> </div> </template> <template #footer> <div class="ds-fx jc-sb dialog-footer"> <div class="ds-fx ai-c"> <el-tag class="mr-5" v-for="tag in selectData.slice(0, 3)" :key="tag.id" closable type="primary" @close="handleRmoveTag(tag.id)"> <el-tooltip placement="top" :show-after="500"> <template #content> {{ tag.name }} </template> <div class="w-50 dialog-footer-tag"> {{ tag.name }} </div> </el-tooltip> </el-tag> <el-dropdown v-if="selectData.length > 3"> <span class="tagList-link">+{{ selectData.length - 3 }}</span> <template #dropdown> <el-scrollbar max-height="200px"> <el-dropdown-menu class="ds-fx fd-c ml-10"> <el-tag class="mr-5 mb-5" v-for="tag in selectData.slice(3)" :key="tag.id" closable :disable-transitions="false" @close="handleRmoveTag(tag.id)"> <el-tooltip placement="right" :show-after="500"> <template #content> {{ tag.name }} </template> <div class="w-50 dialog-footer-tag"> {{ tag.name }} </div> </el-tooltip> </el-tag> </el-dropdown-menu> </el-scrollbar> </template> </el-dropdown> </div> <div class="ds-fx"> <el-button @click="handleCloseDialog()">取消</el-button> <el-button type="primary" @click="handleConfirmSelection()">确定</el-button> </div> </div> </template> </el-dialog> </div> </template> <style scoped lang="scss"> $BackgroundColor: #eff0f1; .hideBox { width: 18px; } .dialog-footer { &-tag { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } .dialog-body-active { background-color: $BackgroundColor; } .dialog-body-breadcrumb { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dialog-body-items { border-radius: 4px; &-active { background-color: $BackgroundColor; } &-image { width: 30px; height: 30px; } &-spacePathText { letter-spacing: 0.2rem; line-height: 25px; } &-tooltip { width: 500px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: 0.1rem; line-height: 25px; } } :deep(.el-dialog__header) { margin-right: 0; padding: 20px; background-color: $BackgroundColor; .el-dialog__headerbtn { top: 5px; } } :deep(.el-dialog__body) { padding: 0 20px; } :deep(.el-input__wrapper) { border-radius: 5px; } :deep(.el-checkbox.el-checkbox--large .el-checkbox__inner) { width: 18px; height: 18px; } :deep(.el-checkbox.el-checkbox--large .el-checkbox__inner)::after { top: 3px; left: 5px; } :deep(.el-dropdown) { padding: 5px; background-color: var(--el-color-primary-light-9); color: var(--el-color-primary); } :deep(.el-input__suffix-inner) { flex-direction: row-reverse; -webkit-flex-direction: row-reverse; display: flex; } </style>
创建index.ts来抛出组件
import type { App } from 'vue';
import DsSelectSpace from './commons/DsSelectSpace.vue';
// 所有组件列表
const components = [DsSelectSpace];
// 定义 install 方法
const install = (app: App): void => {
// 遍历注册所有组件
/*
component.__name ts报错
Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.ts(2345)
解决方式一:使用// @ts-ignore
解决方式二:使用类型断言 尖括号语法(component.__name) 或 as语法(component.__name as string)
*/
components.forEach((component) => app.component(component.__name as string, component));
};
const VueAmazingUI = {
install,
};
export { DsSelectSpace };
export default VueAmazingUI;
vite.config.ts配置
接下来就是比较重要的配置打包,当然自己也是个菜鸡,看了一下打包配置的文章和框架的打包方式才写出来的。我这里试了两种配置方式来打包,最后采用了第一种
第一种配置:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
build: {
emptyOutDir: true,
target: "modules",
outDir: "es",
minify: false,
sourcemap: true,
cssCodeSplit: false,
rollupOptions: {
external: ["vue"],
input: ["'../iotPublicComponents/src/index.ts'"],
output: [
{
format: "es",
entryFileNames: "[name].js",
preserveModules: true,
dir: "es",
preserveModulesRoot: "src",
},
{
format: "cjs",
entryFileNames: "[name].js",
preserveModules: true,
dir: "lib",
preserveModulesRoot: "src",
},
],
},
lib: {
entry: path.resolve(__dirname, "'../iotPublicComponents/src/index.ts'"),
name: "dscloud",
formats: ["es", "cjs"],
},
},
plugins: [vue()],
});
第二种配置:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
plugins: [vue()],
build: {
outDir: 'lib',
lib: {
entry: path.resolve(__dirname, '../iotPublicComponents/src/index.ts'),
name: 'dscloudIotPublicComponent',
fileName: 'dscloud-iot-public-component',
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue',
},
},
},
},
});
package.json配置
{
"name": "dscloud-iot-public-component",
"version": "0.0.12", // 每次推到库上的版本都需要与历史版本不一样才行
"description": "Iot公共组件",
"type": "module",
"scripts": {
"build:dev": "vite build --mode dev",
"build:prod": "vite build --mode prod",
},
"files": [
"lib"
],
"main": "./lib/dscloud-iot-public-component.umd.js",
"module": "./lib/dscloud-iot-public-component.js",
"exports": {
"./lib/style.css": "./lib/style.css",
".": {
"import": "./lib/dscloud-iot-public-component.js",
"require": "./lib/dscloud-iot-public-component.umd.js"
}
},
"git-checks": false,
"keywords": [],
"author": "",
"license": "ISC",
"repository": "", // 仓库地址
"dependencies": {
"@element-plus/icons-vue": "^2.0.6",
"axios": "^1.3.4",
"element-plus": "^2.3.7",
"nprogress": "^0.2.0",
"vue": "^3.2.37"
},
"devDependencies": {
"@types/nprogress": "^0.2.0",
"@vitejs/plugin-vue": "3.0.1",
"sass": "^1.54.3",
"typescript": "^4.7.4",
"vite": "^3.0.2",
"vite-plugin-dts": "^1.4.0",
"vue-tsc": "^0.38.9"
}
}
gitignore配置,上传时所忽略的文件
.vscode
node_modules
es
lib
dist
package-lock.json
pnpm-lock.yaml
打包
根据package.json配置的build来打包。
发布
用npm publish推送到公司的库上面。
全局注册、局部注册和使用
在项目里面拉取依赖
使用方式有两种:全局注册、局部注册
全局注册:
import { createApp } from 'vue';
import App from './App.vue';
import dscloudIotPublicComponent from 'dscloud-iot-public-component';
import 'dscloud-iot-public-component/lib/style.css'; // 样式
const app = createApp(App);
app.use(dscloudIotPublicComponent ).mount('#app');
局部注册:
在所需要用组件的地方导入:
import { DsSelectSpace } from 'dscloud-iot-public-component';
页面使用:
<DsSelectSpace
v-if="isShowSelectSpace"
v-model="isShowSelectSpace"
:is-show-select-space="isShowSelectSpace"
:select-space-data="formData.spaceList"
select-space-type="radio"
@close-dialog="isShowSelectSpace = false"
@confirm-selection="handleConfirmSelection($event)"
>
</DsSelectSpace>
组件效果