跳到主要内容

组件封装思想

组件封装思想

view

页面组件设计

一个页面分为上下三部分

page-search

page-content

page-modal

<page-search :searchConfig="searchFormConfig" />
<page-content
pageName="department"
ref="pageContentRef"
:contentConfig="contentTableConfig"
@newBtnClick="handleNewData"
@editBtnClick="handleEditData"
></page-content>
<page-modal
ref="pageModalRef"
:defaultInfo="modalInfo"
:modalConfig="modalConfigRef"
pageName="department"
></page-modal>

组件目录下config目录

searchFormConfig

export const contentTableConfig = {
title: '部门列表',
propList: [
{ prop: 'name', label: '部门名称', minWidth: '120' },
{ prop: 'leader', label: '部门领导', minWidth: '120' },
{ prop: 'parentId', label: '上级部门', minWidth: '120' },
{ prop: 'createAt', label: '创建时间', minWidth: '250', slotName: 'create' },
{ prop: 'updateAt', label: '更新时间', minWidth: '250', slotName: 'update' },
{ label: '操作', minWidth: '120', slotName: 'handler' }
],
showIndexColumn: true,
showSelectColumn: true
}

contentTableConfig

export const modalConfig: IForm = {
title: '新建部门',
formItems: [
{ field: 'name', type: 'input', label: '部门名称', placeHolder: '请输入部门名称' },
{
field: 'parentId',
type: 'select',
label: '上级部门',
placeHolder: '请选择上级部门',
options: []
},
{ field: 'leader', type: 'input', label: '领导名称', placeHolder: '请输入领导名称' }
],
colLayout: { span: 24 },
itemStyle: { padding: 0 }
}

modalConfig

export const searchFormConfig: IForm = {
formItems: [
{
field: 'name',
type: 'input',
label: '部门名称',
placeHolder: '请输入部门名称',
rules: []
},
{
field: 'leader',
type: 'input',
label: '部门领导',
placeHolder: '请输入部门领导',
rules: []
},
{
field: 'createAt',
type: 'datepicker',
label: '创建时间',
rules: [],
otherOption: {
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
type: 'daterange'
}
}
],
labelWidth: '100px',
itemStyle: { padding: '10px 40px' },
colLayout: { span: 8 }
}

方法调用

 const [pageContentRef, handleQueryClick, handleResetClick] = usePageSearch()
// 处理modal的hook
const [modalInfo, pageModalRef, handleNewData, handleEditData] = usePageModal()
// modal配置信息
const store = useStore()
const modalConfigRef = computed(() => {
const parentIdItem = modalConfig.formItems?.find((item) => item.field === 'parentId')
parentIdItem!.options = store.state.entireDepartments.map((item) => {
return { label: item.name, value: item.id }
})
return modalConfig
})

components

使用hy-form 设置footer插槽

给2个按钮绑定

<template>
<div>
<hy-form v-bind="searchConfig" v-model="formData">
<template #footer>
<div class="btns">
<el-button size="medium" icon="el-icon-refresh" @click="handleResetClick">重置</el-button>
<el-button type="primary" size="medium" icon="el-icon-search" @click="handleQueryClick"
>查询</el-button
>
</div>
</template>
</hy-form>
</div>
</template>

给按钮绑定方法 然后将按钮点击方法emit出去

emits: ['queryBtnClick', 'resetBtnClick'],
setup(props, { emit }) {
const handleResetClick = () => {
for (const key in originFormData) {
formData.value[`${key}`] = originFormData[key]
}
emit('resetBtnClick')
}

const handleQueryClick = () => {
console.log({ ...formData.value })
emit('queryBtnClick', formData.value)
}
}

在view的组件中调用 绑定了emit出去的方法

<page-search
:searchConfig="searchFormConfig"
@queryBtnClick="handleQueryClick"
@resetBtnClick="handleResetClick"
/>
const [pageContentRef, handleQueryClick, handleResetClick] = usePageSearch()

page-content(最复杂)

使用hy-table 使用插槽

<template>
<div class="page-content">
<hy-table
:totalCount="totalCount"
:listData="pageListData"
v-bind="contentConfig"
v-model:page="pageInfo"
>
<template #headerHandler>
<el-button v-if="isCreate" type="primary" size="medium" @click="handleNewData">
{{ contentConfig.newBtnTitle ?? '新建数据' }}
</el-button>
</template>

<template #status="scope">
<el-button :type="scope.row.enable ? 'success' : 'danger'" size="mini" plain>
{{ $filters.showStatus(scope.row.enable) }}
</el-button>
</template>
<template #create="scope">
{{ $filters.formatTime(scope.row.createAt) }}
</template>
<template #update="scope">
{{ $filters.formatTime(scope.row.updateAt) }}
</template>
<template #handler="scope">
<div class="handler">
<el-button
v-if="isUpdate"
type="text"
icon="el-icon-edit"
size="mini"
@click="handleEditClick(scope.row)"
>
编辑
</el-button>
<el-button
v-if="isDelete"
type="text"
icon="el-icon-delete"
size="mini"
@click="handleDeleteClick(scope.row)"
>
删除
</el-button>
</div>
<!-- 在page-content中动态插入剩余的插槽 -->
</template>
<template v-for="item in otherPropSlots" :key="item.prop" #[item.slotName]="scope">
<template v-if="item.slotName">
<slot :name="item.slotName" :row="scope.row"></slot>
</template>
</template>
<!-- <template #imageSlot="scope">
<slot name="imageSlot" :row="scope.row"></slot>
</template> -->
</hy-table>
</div>
</template>

接受信息

components: {
HyTable
},
props: {
contentConfig: {
type: Object,
required: true
},
pageName: {
type: String,
required: true
}
},

通过dispatch获取数据 删除操作

// 2.获取数据
const getPageData = (otherInfo: any = {}) => {
if (!isQuery) return
otherQueryInfo = otherInfo
store.dispatch('system/getPageListDataAction', {
pageName: props.pageName,
queryInfo: {
offset: (pageInfo.value.currentPage - 1) * pageInfo.value.pageSize,
size: pageInfo.value.pageSize,
...otherInfo
}
})
}

// 删除操作
const handleDeleteClick = (rowItem: any) => {
store.dispatch('system/deletePageDataAction', {
pageName: props.pageName,
queryInfo: {
offset: pageInfo.value.currentPage * pageInfo.value.pageSize,
size: pageInfo.value.pageSize,
...otherQueryInfo
},
id: rowItem.id
})
}

// 2.获取页面数据
const pageListData = computed(() => store.getters['system/pageListData'](props.pageName))

// 3.footer
const totalCount = computed(() => store.getters['system/pageListDataCount'](props.pageName))

emit方法

  emits: ['newBtnClick', 'editBtnClick'],
setup(props, { emit }) {



// 6.新建数据 弹出对话框
const handleNewData = () => {
emit('newBtnClick')
}
//
const handleEditClick = (item: any) => {
emit('editBtnClick', item)
}
}

page-modal

el-dialog内使用 hy-form

设置slot 接受 <pagemodal> 内容 </pagemodal>内容

设置template footer插槽模板

<template>
<div class="page-modal">
<el-dialog
:title="modalConfig.title"
v-model="dialogVisible"
width="30%"
center
destroy-on-close
>
<hy-form v-bind="modalConfig" v-model="formData"></hy-form>
<slot></slot>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="handleConfirmClick">确 定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
const handleConfirmClick = () => {
dialogVisible.value = false
if (Object.keys(props.defaultInfo).length) {
// 编辑
store.dispatch('system/editPageDataAction', {
pageName: props.pageName,
queryInfo: { ...formData.value, ...props.otherInfo },
id: props.defaultInfo.id
})
} else {
// 新建
store.dispatch('system/newPageDataAction', {
pageName: props.pageName,
queryInfo: { ...formData.value, ...props.otherInfo }
})
}

base-ui

hy-form

form 下使用

<slot name="header"> </slot>
<el-form></el-form>
<slot name="footer"> </slot>

page-search 下使用

<form>
<template #header> </template>
<template #footer> </template>
</form>

baseui/table 和 components/page-content table下使用

<slot name="header">
<div class="title">{{ title }}</div>
<div class="handler">
<slot name="headerHandler"></slot>
</div>
</slot>
<el-table>
<el-table-column>
<template #default="scope">
<slot :name="propItem.slotName" :row="scope.row">
{{ scope.row[propItem.prop] }}
</slot>
</template>
</el-table-column>

</el-table>
<slot name="footer"> <slot>CopyErrorCopied

page-content下使用 table包裹

<table>
<!-- 1.header中的插槽 -->
<template #headerHandler> </template>

<template #status="scope"> {scope.row} </template>
<template #createAt="scope"> </template>
<template #updateAt="scope"> </template>
<template #handler="scope"> </template>

<!-- 在page-content中动态插入剩余的插槽 -->
<template
v-for="item in otherPropSlots"
:key="item.prop"
#[item.slotName]="scope"
>
</table>

在role.vue下 search组件 导入相关配置

<page-search :searchFormConfig="searchFormConfig"></page-search>
<template>
<div class="hy-form">
<div class="header">
<slot name="header"></slot>
</div>
<el-form :label-width="labelWidth">
<el-row>
<template v-for="(item, index) in formItems" :key="index">
<el-col v-bind="colLayout">
<el-form-item
:label="item.label"
:rules="item.rules"
class="form-item"
:style="itemStyle"
v-if="!item.isHidden"
>
<template v-if="item.type === 'input' || item.type === 'password'">
<el-input
v-model="formData[`${item.field}`]"
:placeholder="item.placeHolder"
:show-password="item.type === 'password'"
/>
</template>
<template v-else-if="item.type === 'select'">
<el-select
v-model="formData[`${item.field}`]"
:placeholder="item.placeHolder"
style="width: 100%"
>
<el-option
v-for="option in item.options"
:key="option.value"
:value="option.value"
>{{ option.label }}</el-option
>
</el-select>
</template>
<template v-else-if="item.type === 'datepicker'">
<el-date-picker
v-model="formData[`${item.field}`]"
v-bind="item.otherOption"
style="width: 100%"
>
</el-date-picker>
</template>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
</template>

设定接口IFormItem IForm

interface ISelectOption {
label: string
value: any
}

type ItemType = 'input' | 'password' | 'select' | 'datepicker'

export interface IFormItem {
field: string
type: ItemType
label: string
placeHolder?: string
rules?: any[]
options?: ISelectOption[]
otherOption?: any
defaultValue?: any
isHidden?: boolean
}

export interface IForm {
title?: string
formItems?: IFormItem[]
labelWidth?: string
itemStyle: any
colLayout: any
}
emit: ['update:modelValue'],
setup(props, { emit }) {
const formData = ref({ ...props.modelValue })

watch(
formData,
(newValue) => {
emit('update:modelValue', newValue)
},
{ deep: true }
)

return {
formData
}
}

hy-table

<template>
<div class="hy-table">
<div class="header">
<slot name="header">
<div class="title">{{ title }}</div>
<div class="handler">
<slot name="headerHandler"></slot>
</div>
</slot>
</div>
<el-table :data="listData" border @selection-change="selectionChange" v-bind="childrenProps">
<el-table-column
v-if="showSelectColumn"
type="selection"
align="center"
width="60"
></el-table-column>
<el-table-column
v-if="showIndexColumn"
type="index"
label="序号"
align="center"
width="80"
></el-table-column>
<template v-for="item in propList" :key="item.prop">
<el-table-column v-bind="item" align="center" show-overflow-tooltip>
<template #default="scope">
<slot :name="item.slotName" :row="scope.row">
{{ scope.row[item.prop] }}
</slot>
</template>
</el-table-column>
</template>
<slot></slot>
</el-table>
<div class="footer" v-if="showFooter">
<slot name="footer">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="page.currentPage"
:page-size="page.pageSize"
:total="totalCount"
:page-sizes="[10, 20, 30, 40]"
layout="total, sizes, prev, pager, next, jumper"
>
</el-pagination>
</slot>
</div>
</div>
</template>

emit方法

emits: ['selectionChange', 'update:page'],
setup(props, { emit }) {
const selectionChange = (value: any) => {
if (props.showSelectColumn) {
emit('selectionChange', value)
}
}

const handleCurrentChange = (currentPage: number) => {
emit('update:page', { ...props.page, currentPage })
}
const handleSizeChange = (pageSize: number) => {
emit('update:page', { ...props.page, pageSize })
}

return {
selectionChange,
handleCurrentChange,
handleSizeChange
}
}

hooks

usePageModal

实际上是获取modal的引用 然后改变他的显示状态

import { ref } from 'vue'
import PageModal from '@/components/page-modal'

type CallbackFn = (item?: any) => void

export const usePageModal = (newHandleCallback?: CallbackFn, editHandleCallback?: CallbackFn) => {
const modalInfo = ref({})
const pageModalRef = ref<InstanceType<typeof PageModal>>()
const handleNewData = () => {
modalInfo.value = {}
if (pageModalRef.value) {
pageModalRef.value.dialogVisible = true
}
newHandleCallback && newHandleCallback()
}

const handleEditData = (item: any) => {
modalInfo.value = { ...item }
if (pageModalRef.value) {
pageModalRef.value.dialogVisible = true
}
editHandleCallback && editHandleCallback(item)
}

return [modalInfo, pageModalRef, handleNewData, handleEditData]
}

usePageSearch

获取引用然后执行page-content的getPageData方法去请求数据

import { ref } from 'vue'
import PageContent from '@/components/page-content'

export const usePageSearch = () => {
const pageContentRef = ref<InstanceType<typeof PageContent>>()
const handleQueryClick = (queryInfo: any) => {
console.log(pageContentRef.value)
pageContentRef.value?.getPageData(queryInfo)
}
const handleResetClick = () => {
pageContentRef.value?.getPageData()
}

return [pageContentRef, handleQueryClick, handleResetClick]
}

usePermission

获取store.state.login.permissions

import { useStore } from '@/store'

export function usePermission(pageName: string, handle: string) {
const store = useStore()
const permissions = store.state.login.permissions
const handlePermission = `${pageName}:${handle}`
return !!permissions.find((item) => item.indexOf(handlePermission) !== -1)
}

在page-content内

 // 7.按钮是否显示
const isCreate = usePermission(props.pageName, 'create')
const isDelete = usePermission(props.pageName, 'delete')
const isUpdate = usePermission(props.pageName, 'update')
const isQuery = usePermission(props.pageName, 'query')

v-permission

(20条消息) Vue---自定义指令directive的全局注册和局部注册(含vue3)_绝世唐门三哥的博客-CSDN博客_vue 全局注册directive

思路分析

  • 首先因为会涉及到元素的展示和隐藏操作,所以在我们自定义指令时就不能再用beforeMount钩子函数了,而是需要用mounted函数,也就是说等元素渲染后再去控制是否展示。
  • 在mounted函数中,我们首先需要获取登录用户所拥有的权限。一般情况下在用户登录后会去请求服务器获取用户权限,然后再把权限数据保存在vuex中。这里我们要做的就是把权限数据从vuex中解析出来,便于后续使用。(为了方便展示,我们就直接使用字符串代替了)
  • 权限数据拿到以后,我们还需要判断当前元素需要哪些权限,比如删除按钮需要的就是对应的删除权限,而这个权限在元素被定义时就应该已经确定了,所以我们应该在对应的元素中把需要的权限传给我们的自定义指令,然后再通过binding.value拿到该权限
  • 最后就是对比校验,看看当前元素所需要的权限是否存在于用户的权限列表中,如果存在则说明有权限元素应该显示,否则没有权限移除对应的元素。
  • 以上便是权限自定义指令的整体实现思路,相对来说还是比较简单的,下面我们来看一下具体的代码实现。

权限校验代码实现

<button v-permission="'add'">add</button>
<button v-permission="'del'">del</button>
<button v-permission="'update'">update</button>
<button v-permission="'query'">query</button>

const app = createApp();
app.directive('permission',{
mounted(el,binding){
//从服务获取用户的权限列表,一般获取后存放于vuex中,本案例为了方便演示将直接以字符串的形式展示
//权限之间以分号分隔
//管理员权限:"add;del;update;query"
//普通用户权限:"add;del;update;query"
let permission = "update;query",//权限字符串
permissionList = [];//权限列表
if(!permission) permission = "";
permissionList = permission.split(";");

//获取需要的权限标识,即元素给指令传进来的参数值
let passText = binding.value,//可以是多个值,中间以分号分隔
passTextArr = [];//将权限解析到数组中
if(!passText) passText = "";
passTextArr = passText.split(';');

//定义一个权限标识变量,用于标识是否有权限
let flag = false;
//循环遍历权限列表,检测用户是否有相应的操作权限
for(let i = 0; i < passTextArr.length; i++){
if(permissionList.includes(passTextArr[i])){
//如果从服务器中获取的权限列表中有组件所需的权限,则将flag置为true,同时跳出循环
flag = true;
break;
}
}
//如果flag为false,也就是没权限则直接将元素移除或者隐藏
if(!flag) el.parentNode && el.parentNode.removeChild(el);
}
})

代码运行起来后,我们会发现对应管理员来说,会看到全部按钮,而对于普通用户来说则只能看到update和query按钮。

如何设计组件

组件设计规范

1.扁平的,面向数据的 state/props 扁平 props 也可以很好地清除组件正在使用的数据值。如果你传给组件一个对象但是你并不能清楚的知道对象内部的属性值,所以找出实际需要的数据值是来自组件具体的属性值则是额外的工作。 state / props 还应该只包含组件渲染所需的数据。 (此外,对于数据繁重的应用程序,数据规范化可以带来巨大的好处,除了扁平化之外,你可能还需要考虑一些别的优化方法)。

2.更加纯粹的 State 变化 对 state 的更改通常应该响应某种事件,例如用户单击按钮或 API 的响应。此外它们不应该因为别的 state 的变化而做出响应,因为 state 之间这种关联可能会导致难以理解和维护的组件行为。state 变化应该没有副作用。

3.松耦合 组件的核心思想是它们是可复用的,为此要求它们必须具有功能性和完整性。

“耦合”是指实体彼此依赖的术语。

松散耦合的实体应该能够独立运行,而不依赖于其他模块。

就前端组件而言,耦合的主要部分是组件的功能依赖于其父级及其传递的 props 的多少,以及内部使用的子组件(当然还有引用的部分,如第三方模块或用户脚本)。

如果不是要设计需要服务于特定的一次性场景的组件,那么设计组件的最终目标是让它与父组件松散耦合,呈现更好的复用性,而不是受限于特定的上下文环境

4.辅助代码分离 一个有效的原则就是将辅助代码分离出来放在特定的地方,这样你在处理组件时就不必考虑这些。例如:

配置代码 假数据 5.及时模块化 我们在实际进行组件抽离工作的时候,需要考虑到不要过度的组件化 在决定是否将代码分开时,无论是 Javascript 逻辑还是抽离为新的组件,都需要考虑以下几点:

是否有足够的页面结构/逻辑来保证它? 代码重复(或可能重复)? 它会减少需要书写的模板吗? 性能会收到影响吗? 是否会在测试代码的所有部分时遇到问题? 是否有一个明确的理由? 这些好处是否超过了成本? 6.集中统一的状态管理 许多大型应用程序使用 Redux 或 Vuex 等状态管理工具(或者具有类似 React 中的 Context API 状态共享设置)。这意味着他们从 store 获得 props 而不是通过父级传递。在考虑组件的可重用性时,你不仅要考虑直接的父级中传递而来的 props,还要考虑 从 store 中获取到的 props。

由于将组件挂接到 store(或上下文)很容易并且无论组件的层次结构位置如何都可以完成,因此很容易在 store 和 web 应用的组件之间快速创建大量紧密耦合(不关心组件所处的层级)

组件设计原则

标准性

任何一个组件都应该遵守一套标准,可以使得不同区域的开发人员据此标准开发出一套标准统一的组件

独立性

描述了组件的细粒度,遵循单一职责原则,保持组件的纯粹性 属性配置等API对外开放,组件内部状态对外封闭,尽可能的少与业务耦合

复用与易用

UI差异,消化在组件内部(注意并不是写一堆if/else) 输入输出友好,易用

追求短小精悍

适用SPOT法则

Single Point Of Truth,就是尽量不要重复代码,出自《The Art of Unix Programming》

避免暴露组件内部实现

避免直接操作DOM,避免使用ref

使用父组件的 state 控制子组件的状态而不是直接通过 ref 操作子组件入口处检查参数的有效性,出口处检查返回的正确性

无环依赖原则(ADP)

设计不当导致环形依赖示意图

image

影响

组件间耦合度高,集成测试难 一处修改,处处影响,交付周期长 因为组件之间存在循环依赖,变成了“先有鸡还是先有蛋”的问题

那倘若我们真的遇到了这种问题,就要考虑如何处理

消除环形依赖

我们的追求是沿着逆向的依赖关系即可寻找到所有受影响的组件

创建一个共同依赖的新组件

image

稳定抽象原则(SAP)

  • 组件的抽象程度与其稳定程度成正比,
  • 一个稳定的组件应该是抽象的(逻辑无关的)
  • 一个不稳定的组件应该是具体的(逻辑相关的)
  • 为降低组件之间的耦合度,我们要针对抽象组件编程,而不是针对业务实现编程

避免冗余状态

  • 如果一个数据可以由另一个 state 变换得到,那么这个数据就不是一个 state,只需要写一个变换的处理函数,在 Vue 中可以使用计算属性

  • 如果一个数据是固定的,不会变化的常量,那么这个数据就如同 HTML 固定的站点标题一样,写死或作为全局配置属性等,不属于 state

  • 如果兄弟组件拥有相同的 state,那么这个state 应该放到更高的层级,使用 props 传递到两个组件中

合理的依赖关系

  • 父组件不依赖子组件,删除某个子组件不会造成功能异常

扁平化参数

  • 除了数据,避免复杂的对象,尽量只接收原始类型的值

良好的接口设计

  • 把组件内部可以完成的工作做到极致,虽然提倡拥抱变化,但接口不是越多越好

  • 如果常量变为 props 能应对更多的场景,那么就可以作为 props,原有的常量可作为默认值。

  • 如果需要为了某一调用者编写大量特定需求的代码,那么可以考虑通过扩展等方式构建一个新的组件。

  • 保证组件的属性和事件足够的给大多数的组件使用。

API尽量和已知概念保持一致、

组件分类

将组件应分为以下几类

  • 基础组件(通常在组件库里就解决了)
  • 容器型组件(Container)
  • 展示型组件(stateless)
  • 业务组件
  • 通用组件
    • UI组件
    • 逻辑组件
  • 高阶组件(HOC)
容器型组件

一个容器性质的组件,一般当作一个业务子模块的入口,比如一个路由指向的组件

image

特点
  • 容器组件内的子组件通常具有业务或数据依赖关系
  • 集中/统一的状态管理,向其他展示型/容器型组件提供数据(充当数据源)和行为逻辑处理(接收回调)
  • 如果使用了全局状态管理,那么容器内部的业务组件可以自行调用全局状态处理业务
  • 业务模块内子组件的通信等统筹处理,充当子级组件通信的状态中转站
  • 模版基本都是子级组件的集合,很少包含DOM标签
  • 辅助代码分离
表现形式(vue)
<template>
<div class="purchase-box">
<!-- 面包屑导航 -->
<bread-crumbs />
<div class="scroll-content">
<!-- 搜索区域 -->
<Search v-show="toggleFilter" :form="form"/>
<!--展开收起区域-->
<Toggle :toggleFilter="toggleFilter"/>
<!-- 列表区域-->
<List :data="listData"/>
</div>
</template>
展示型(stateless)组件

主要表现为组件是怎样渲染的,就像一个简单的模版渲染过程

image

特点
  • 只通过props接受数据和回调函数,不充当数据源
  • 可能包含展示和容器组件 并且一般会有Dom标签和css样式
  • 通常用props.children(react) 或者slot(vue)来包含其他组件
  • 对第三方没有依赖(对于一个应用级的组件来说可以有)
  • 可以有状态,在其生命周期内可以操纵并改变其内部状态,职责单一,将不属于自己的行为通过回调传递出去,让父级去处理(搜索组件的搜索事件/表单的添加事件)
表现形式(vue)
 <template>
<div class="purchase-box">
<el-table
:data="data"
:class="{'is-empty': !data || data.length ==0 }"
>
<el-table-column
v-for = "(item, index) in listItemConfig"
:key="item + index"
:prop="item.prop"
:label="item.label"
:width="item.width ? item.width : ''"
:min-width="item.minWidth ? item.minWidth : ''"
:max-width="item.maxWidth ? item.maxWidth : ''">
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" align="right" width="60">
<template slot-scope="scope">
<slot :data="scope.row" name="listOption"></slot>
</template>
</el-table-column>
<!-- 列表为空 -->
<template slot="empty">
<common-empty />
</template>
</el-table>

</div>
</template>
<script>
export default {
props: {
listItemConfig:{ //列表项配置
type:Array,
default: () => {
return [{
prop:'sku_name',
label:'商品名称',
minWidth:200
},{
prop:'sku_code',
label:'SKU',
minWidth:120
},{
prop:'product_barcode',
label:'条形码',
minWidth:120
}]
}
}}
}
</script>

业务组件

通常是根据最小业务状态抽象而出,有些业务组件也具有一定的复用性,但大多数是一次性组件

image

通用组件

可以在一个或多个APP内通用的组件

UI组件
  • 界面扩展类组件,比如弹窗

image

特点:复用性强,只通过 props、events 和 slots 等组件接口与外部通信

表现形式(vue)
<template>
<div class="empty">
<img src="/images/empty.png" alt>
<p>暂无数据</p>
</div>
</template>
逻辑组件
  • 不包含UI层的某个功能的逻辑集合
高阶组件(HOC)

高阶组件可以看做是函数式编程中的组合 可以把高阶组件看做是一个函数,他接收一个组件作为参数,并返回一个功能增强的组件

高阶组件可以抽象组件公共功能的方法而不污染你本身的组件 比如 debouncethrottle

用一张图来表示

image-20220914103439470

React中高阶组件是比较常用的组件封装形式,Vue官方内置了一个高阶组件keep-alive通过维护一个cache实现数据持久化,但并未推荐使用HOC :(

在 React 中写组件就是在写函数,函数拥有的功能组件都有

Vue更像是高度封装的函数,能够让你轻松的完成一些事情,但与高度的封装相对的就是损失一定的灵活,你需要按照一定规则才能使系统更

设计原则

前端组件设计之一——设计原则

组件设计的基本原则

一个组件的复杂度,主要来源就是自身的状态;即组件自身需要维护多少个不依赖于外部输入的状态。

组件开发中,如何将数据和UI解耦,是最重要的工作。

组件开发过程中,时刻谨记、思考是否符合以下的原则,可以帮助你开发一个更完善的通用组件。

单一职责

你的组件是否符合只实现一个职责,并且只有一个改变状态的理由

如fetch请求和渲染逻辑,应该分离。因为fetch请求时会造成组件重新渲染,渲染时的样式或数据格式变化,也会引起组件重新渲染。

单一职责可以保证组件是最细的粒度,且有利于复用。但太细的粒度有时又会造成组件的碎片化。

因此单一职责组件要建立在可复用的基础上,对于不可复用的单一职责组件,我们仅仅作为独立组件的内部组件即可。

通用性

组件开发要服务于业务,为了更好的复用,又要从业务中抽离。

下面代码实现了需求A:实现一个基础的select组件: menu:是select的下拉列表,menu上面的div是select的选择框头部,包含一个值和一个箭头。

<div className={dropdownClass}>
<div
className={`${baseClassName}-control ${disabledClass}`}
onMouseDown={this.handleMouseDown.bind(this)}
onTouchEnd={this.handleMouseDown.bind(this)}
>
{value}
<span className={`${baseClassName}-arrow`} />
</div>
{menu}
</div>
复制代码

此时又有一个新的需求B,要求将select选择框头部渲染为一个图片。

虽然B的交互模式和 A一模一样,但因为二者在 DOM 结构上的巨大差别,导致我们无法复用上面的这个 Select 来实现它。 只能去修改源代码、或重新写一个符合需求的组件。

因此组件开发时最好的做法是放弃对DOM的掌控,只提供最基础的DOM、交互逻辑,将DOM的结构转移给开发者。

下面的代码是Antd的组件DropDown,可以看到只有最基础的DOM,提供了多个渲染函数和处理逻辑。

return (
<Trigger
{...otherProps}
prefixCls={prefixCls}
ref="trigger"
popupClassName={overlayClassName}
popupStyle={overlayStyle}
builtinPlacements={placements}
action={trigger}
showAction={showAction}
hideAction={hideAction}
popupPlacement={placement}
popupAlign={align}
popupTransitionName={transitionName}
popupAnimation={animation}
popupVisible={this.state.visible}
afterPopupVisibleChange={this.afterVisibleChange}
popup={this.getMenuElement()}
onPopupVisibleChange={this.onVisibleChange}
getPopupContainer={getPopupContainer}
>
{children}
</Trigger>
);
复制代码
  • 复用一个组件时,即复用其职责,所以只有单一职责的组件,是最便于复用的
  • 当一个组件错误地有多个职责时,就会增加复用时的开销。
  • 尽量避免代码重复,重复两次及以上的代码,考虑一下是否可以复用?

通用性虽好,但会浪费开发者很多精力,因此在抽象业务组件之前,请问自己:

* 存在代码重复吗?如果只使用一次,或者只是某个特定用例,可能嵌入组件中更好。

* 如果它只是几行代码,分隔它反而需要更多的代码,那是否可以直接嵌入组件中?

* 性能会收到影响吗?更改state/props会导致重新渲染,当发生这种情况时,你需要的是 只是重新去渲染经过diff之后得到的相关元素节点。在较大的、关联很紧密的组件中,你可能会发现状态更改会导致在不需要它的许多地方重新呈现,这时应用的性能就可能会开始受到影响。

* 你是否有一个明确的理由?分离代码我想要实现什么?更松散的耦合、可以被复用等,如果回答不了这个问题,那最好先不要从组件中抽离。

* 这些好处是否超过了成本?分离代码需要花费一定的时间和精力,我们要在业务中去衡量,有所取舍。
复制代码

封装

良好的组件封装应该隐藏内部细节和实现意义,并通过props来控制行为和输出。

减少访问全局变量:因为它们打破了封装,创造了不可预测的行为,并且使测试变得困难。可以将全局变量作为组件的props,而不是直接引用。

组合

具有多个功能的组件,应该转换为多个小组件。
单一责任原则描述了如何将需求拆分为组件,封装描述了如何组织这些组件,组合描述了如何将整个系统粘合在一起。
复制代码

纯组件和非纯组件

非纯组件有显示的副作用,我们要尽量隔离非纯代码。

将全局变量作为props传递给组件,而非将其注入到组件的作用域中。

将网络请求和组件渲染分离,只将数据传递给组件,保证组件职责的单一性,也能将非纯代码从组件中隔离。
复制代码

可测试

测试不仅仅是自动检测错误,更是检测组件的逻辑。

如果一个组件测试不易于测试,很大可能是你的组件设计存在问题。
复制代码

富有意义

开发人员大部分时间都在阅读和理解代码,而不是实际编写代码。
有意义的函数、变量命名,可以让代码具有良好的可读性。
复制代码

组件设计的最佳实践

组件的UML类图

前端组件的架构其实是一个是树状图,当我们设计一个组件时,推荐用UML类图的形式,将组件结构、数据流动状态、处理函数明确标注。先构思组件细节,再写代码,可以避免代码的多次反复。

  • 用UML类图的形式,将网页中的组件树结构画出来
  • 每个类图中,标明需要的state、props、methods,

我们想要实现一个Table组件,Table组件包含行数(RowCount)、header、body。

Table的数据源data、行数RowCount,都来自props;Table内部的排序函数,需要的状态sortPerperty、ascending来自state;

Table的操作包括onRowClick,来自props;排序setSortProperty,来自state;

UML类图如下所示,可以直观的了解组件的UI层结构,数据流动和处理函数,写代码时再也不怕会重构了^_^

image-20220915141405110

辅助代码分离

为了让下一个接手的同事更好的理解代码,我们有时会在核心代码中,添加必要的注释,让代码更清晰。

let params = {
pageNum: pageNum,
pageSize: pageSize,
status: 4, // 参照:ROBOT_STATUS, 0-新导入,1-审核中,2-审核通过,3-审核未通过,4-上架,5-下架
title: search,
queryType: 0 // 0--只查询列表, 1--查询申请状态
};
复制代码

代码如上,阅读代码时,你能快速了解各个字段的含义,但你会额外分心去看注释,思路被打倒,导致中断了整个函数的逻辑分析。

因此,假数据、非技术说明文档、配置代码,建议放在代码外,而不要放在核心代码中,会影响用户体验。

扁平化的state和props

给组件传递props时,建议用更扁平化的props,而不要用嵌套的对象或数组。

<DetailModal
{...modalData}
visible={showModal}
tagType={ROBOT_TYPE}
sceneList={sceneList}
handleCloseModal={() => this.handleCloseModal()} />
复制代码
  • 如上面的代码所示,传递的数据结构是modalData,但用户不清楚modalData中包含哪些属性,且还可能存在多余的属性,开发中应尽量避免传递给组件不需要的属性。
  • react中,如果要修改对象、数组,必须创建一个副本;可能会因为浅复制而造成页面的重新渲染。