Vue 2.x 相关组件实现
Vue 2.x 相关组件实现
具有数据校验功能的表单组件
Form组件
的核心功能是数据校验,一个 Form
中包含了多个 FormItem
,当点击提交按钮时,逐一对每个 FormItem
内的表单组件校验,而校验是由使用者发起,并通过 Form
来调用每一个 FormItem
的验证方法,再将校验结果汇总后,通过 Form
返回出去。
要在 Form
中逐一调用 FormItem
的验证方法,而 Form
和 FormItem
是独立的,需要预先将 FormItem
的每个实例缓存在 Form
中。当每个 FormItem
渲染时,将其自身(this)作为参数通过 dispatch 组件通信方法派发到 Form
组件中,然后通过一个数组缓存起来;同理当 FormItem
销毁时,将其从 Form
缓存的数组中移除。
/docs/.vuepress/components/vue2/formValidator
|--- form.vue
|--- formIndex.vue
|--- formItem.vue
|--- input.vue
formValidator/form.vue
<template>
<form>
<slot></slot>
</form>
</template>
<script>
export default {
name: 'iForm',
props: {
model: {
type: Object,
},
rules: {
type: Object,
},
},
provide() {
return {
form: this,
};
},
data() {
return {
fields: [],
};
},
created() {
this.$on('on-form-item-add', (field) => {
if (field) this.fields.push(field);
});
this.$on('on-form-item-remove', (field) => {
if (field.prop) this.fields.splice(this.fields.indexOf(field), 1);
});
},
methods: {
// 公开方法:全部重置数据
resetFields() {
this.fields.forEach((field) => {
field.resetField();
});
},
// 公开方法:全部校验数据,支持 Promise
validate(callback) {
return new Promise((resolve) => {
let valid = true;
let count = 0;
this.fields.forEach((field) => {
field.validate('', (errors) => {
if (errors) {
valid = false;
}
if (++count === this.fields.length) {
// 全部完成
resolve(valid);
if (typeof callback === 'function') {
callback(valid);
}
}
});
});
});
},
},
};
</script>
formValidator/formIndex.vue
<template>
<div>
<!-- <h3>具有数据校验功能的表单组件 - From</h3> -->
<i-form class="i-form-container" ref="form" :model="formValidate" :rules="ruleValidate">
<i-form-item label="用户名" prop="name">
<i-input v-model="formValidate.name"></i-input>
</i-form-item>
<i-form-item label="邮箱" prop="mail">
<i-input v-model="formValidate.mail"></i-input>
</i-form-item>
</i-form>
<button @click="handleSubmit">提交</button>
<button @click="handleReset">重置</button>
</div>
</template>
<script>
import iForm from './form.vue';
import iFormItem from './formItem.vue';
import iInput from './input.vue';
export default {
components: { iForm, iFormItem, iInput },
data() {
return {
formValidate: {
name: '',
mail: '',
},
ruleValidate: {
name: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
mail: [
{ required: true, message: '邮箱不能为空', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
},
};
},
methods: {
handleSubmit() {
this.$refs.form.validate((valid) => {
if (valid) {
window.alert('提交成功!');
} else {
window.alert('表单校验失败!');
}
});
},
handleReset() {
this.$refs.form.resetFields();
},
},
};
</script>
<style scoped>
.i-form-container {
padding-top: 24px;
}
</style>
formValidator/formItem.vue
<template>
<div class="i-form-item-container">
<label v-if="label" class="i-form-item-label" :class="{ 'i-form-item-label-required': isRequired }"
>{{ label }}:</label
>
<div class="i-form-item-wrapper">
<slot></slot>
<div v-if="validateState === 'error'" class="i-form-item-message">
{{ validateMessage }}
</div>
</div>
</div>
</template>
<script>
import AsyncValidator from 'async-validator';
import Emitter from '../mixins/emitter.js';
export default {
name: 'iFormItem',
mixins: [Emitter],
inject: ['form'],
props: {
label: {
type: String,
default: '',
},
prop: {
type: String,
},
},
data() {
return {
isRequired: false, // 是否为必填
validateState: '', // 校验状态
validateMessage: '', // 校验不通过时的提示信息
};
},
computed: {
// 从 Form 的 model 中动态得到当前表单组件的数据
fieldValue() {
return this.form.model[this.prop];
},
},
// 组件渲染时,将实例缓存在 Form 中
mounted() {
// 如果没有传入 prop,则无需校验,也就无需缓存
if (this.prop) {
this.dispatch('iForm', 'on-form-item-add', this);
// 设置初始值,以便在重置时恢复默认值
this.initialValue = this.fieldValue;
this.setRules();
}
},
// 组件销毁前,将实例从 Form 的缓存中移除
beforeDestroy() {
this.dispatch('iForm', 'on-form-item-remove', this);
},
methods: {
setRules() {
let rules = this.getRules();
if (rules.length) {
rules.every((rule) => {
// 如果当前校验规则中有必填项,则标记出来
this.isRequired = rule.required;
});
}
this.$on('on-form-blur', this.onFieldBlur);
this.$on('on-form-change', this.onFieldChange);
},
// 从 Form 的 rules 属性中,获取当前 FormItem 的校验规则
getRules() {
let formRules = this.form.rules;
formRules = formRules ? formRules[this.prop] : [];
return [].concat(formRules || []);
},
// 只支持 blur 和 change,所以过滤出符合要求的 rule 规则
getFilteredRule(trigger) {
const rules = this.getRules();
return rules.filter((rule) => !rule.trigger || rule.trigger.indexOf(trigger) !== -1);
},
/**
* 校验数据
* @param trigger 校验类型
* @param callback 回调函数
*/
validate(trigger, callback = function () {}) {
let rules = this.getFilteredRule(trigger);
if (!rules || rules.length === 0) {
return true;
}
// 设置状态为校验中
this.validateState = 'validating';
// 以下为 async-validator 库的调用方法
let descriptor = {};
descriptor[this.prop] = rules;
const validator = new AsyncValidator(descriptor);
let model = {};
model[this.prop] = this.fieldValue;
// firstFields: Boolean|String[], 对于指定字段,遇见第一条未通过的校验规则时便调用 callback 回调,而不再校验该字段的其他规则 ,传入 true 代表所有字段。
validator.validate(model, { firstFields: true }, (errors) => {
this.validateState = !errors ? 'success' : 'error';
this.validateMessage = errors ? errors[0].message : '';
callback(this.validateMessage);
});
},
// 重置数据
resetField() {
this.validateState = '';
this.validateMessage = '';
this.form.model[this.prop] = this.initialValue;
},
onFieldBlur() {
this.validate('blur');
},
onFieldChange() {
this.validate('change');
},
},
};
</script>
<style>
.i-form-item-container {
margin-bottom: 24px;
vertical-align: top;
font-size: 15px;
}
.i-form-item-label {
float: left;
width: 80px;
}
.i-form-item-label-required {
font-weight: bold;
}
.i-form-item-label-required:before {
content: '*';
color: #ed4014;
}
.i-form-item-wrapper {
position: relative;
margin-left: 80px;
}
.i-form-item-message {
position: absolute;
top: 100%;
left: 0;
color: #ed4014;
}
</style>
formValidator/input.vue
<template>
<input
type="text"
:value="currentValue"
@input="handleInput"
@blur="handleBlur"
/>
</template>
<script>
import Emitter from '../mixins/emitter.js';
export default {
name: 'iInput',
mixins: [Emitter],
props: {
value: {
type: String,
default: '',
},
},
data() {
return {
currentValue: this.value,
};
},
watch: {
value(val) {
this.currentValue = val;
},
},
methods: {
handleInput(event) {
const value = event.target.value;
this.currentValue = value;
this.$emit('input', value);
this.dispatch('iFormItem', 'on-form-change', value);
},
handleBlur() {
this.dispatch('iFormItem', 'on-form-blur', this.currentValue);
},
},
};
</script>
全局提示组件
显示一个信息提示组件的流程:入口 alert.js
--> info()
--- add()
---> 创建实例 notification.js
--- add()
---> 增加数据
--> 渲染alert.vue
/docs/.vuepress/components/vue2/alert
|--- alert.js
|--- alert.vue
|--- alertIndex.vue
|--- notification.js
alert/alert.js
import Notification from './notification.js';
let messageInstance;
// getMessageInstance 函数用来获取实例,
// 它不会重复创建,如果 messageInstance 已经存在,就直接返回了
// 只在第一次调用 Notification 的 newInstance 时来创建实例。
function getMessageInstance() {
messageInstance = messageInstance || Notification.newInstance();
return messageInstance;
}
function notice({ duration = 1.5, content = '' }) {
let instance = getMessageInstance();
instance.add({
content: content,
duration: duration,
});
}
// alert.js 对外提供 info方法
// 如果需要各种显示效果,比如成功的、失败的、警告的,可以在 info 下面提供更多的方法,比如 success、fail、warning 等,并传递不同参数让 Alert.vue 知道显示哪种状态的图标。
export default {
info(options) {
return notice(options);
},
};
alert/alert.vue
<template>
<div class="alert">
<div class="alert-main" v-for="item in notices" :key="item.name">
<div class="alert-content">{{ item.content }}</div>
</div>
</div>
</template>
<script>
let seed = 0;
function getUuid() {
return "alert_" + seed++;
}
export default {
data() {
return {
notices: []
};
},
methods: {
add(notice) {
const name = getUuid();
let _notice = Object.assign(
{
name: name
},
notice
);
this.notices.push(_notice);
// 定时移除,单位:秒
const duration = notice.duration;
setTimeout(() => {
this.remove(name);
}, duration * 1000);
},
remove(name) {
const notices = this.notices;
for (let i = 0; i < notices.length; i++) {
if (notices[i].name === name) {
this.notices.splice(i, 1);
break;
}
}
}
}
};
</script>
<style>
.alert {
position: fixed;
width: 100%;
top: 16px;
left: 0;
text-align: center;
pointer-events: none;
z-index: 999;
}
.alert-content {
display: inline-block;
padding: 8px 16px;
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
margin-bottom: 8px;
}
</style>
alert/alertIndex.vue
<template>
<div class="alert-container">
<button @click="handleOpen1">打开提示 1</button>
<button @click="handleOpen2">打开提示 2</button>
</div>
</template>
<script>
import Vue from 'vue';
import Alert from './alert.js';
Vue.prototype.$Alert = Alert;
export default {
methods: {
handleOpen1() {
this.$Alert.info({
content: '我是提示信息 1',
});
},
handleOpen2() {
this.$Alert.info({
content: '我是提示信息 2',
duration: 3,
});
},
},
};
</script>
<style scoped>
.alert-container {
padding-top: 24px;
}
</style>
alert/notification.js
import Alert from './alert.vue';
import Vue from 'vue';
// alert.vue 包含了 template、script、style 三个标签,并不是一个 JS 对象
// alert.vue 会被 Webpack 的 vue-loader 编译,把 template 编译为 Render 函数,最终就会成为一个 JS 对象,自然可以对它进行扩展。
Alert.newInstance = (properties) => {
const props = properties || {}; // 为了扩展性,添加props属性
const Instance = new Vue({
data: props,
render(h) {
return h(Alert, {
props: props,
});
},
});
const component = Instance.$mount();
document.body.appendChild(component.$el);
const alert = Instance.$children[0]; // Render 的 Alert组件实例
// 使用闭包暴露了两个方法 add 和 remove
return {
add(noticeProps) {
alert.add(noticeProps);
},
remove(name) {
alert.remove(name);
},
};
};
export default Alert;
注意:
alert.vue
的最外层是有一个 .alert 节点的,它会在第一次调用$Alert
时,在 body 下创建,因为不在<router-view>
内,它不受路由的影响,也就是说一经创建,除非刷新页面,这个节点是不会消失的,所以在alert.vue
的设计中,并没有主动销毁这个组件,而是维护了一个子节点数组 notices。- .alert 节点是
position: fixed
固定的,因此要合理设计它的z-index
,否则可能被其它节点遮挡。 notification.js
和alert.vue
是可以复用的,如果还要开发其它同类的组件,比如二次确认组件$Confirm
, 只需要再写一个入口confirm.js
,并将alert.vue
进一步封装,将 notices 数组的循环体写为一个新的组件,通过配置来决定是渲染 Alert 还是 Confirm,这在可维护性上是友好的。- 在
notification.js
的 new Vue 时,使用了 Render 函数来渲染alert.vue
,这是因为使用 template 在 runtime 的 Vue.js 版本下是会报错的。 - 本例的
content
只能是字符串,如果要显示自定义的内容,除了用v-html 指令
,也能用Functional Render
。
可用 Render 自定义列的表格组件
/docs/.vuepress/components/vue2/renderTable
|--- render.js
|--- renderTableIndex.vue
|--- tableRender.vue
renderTable/render.js
/**
* @description:
* @param {row} 当前行的数据
* @param {column} 当前列的数据
* @param {index} 当前是第几行
* @param {render} 具体的 render 函数内容
* @return:
*/
export default {
functional: true,
props: {
row: Object,
column: Object,
index: Number,
render: Function,
},
// h: createElement
// ctx: 提供上下文信息
render: (h, ctx) => {
const params = {
row: ctx.props.row,
column: ctx.props.column,
index: ctx.props.index,
};
return ctx.props.render(h, params);
},
};
renderTable/renderTableIndex.vue
<template>
<div>
<table-render :columns="columns" :data="data"></table-render>
</div>
</template>
<script>
import TableRender from './tableRender.vue';
export default {
components: { TableRender },
data() {
return {
columns: [
{
title: '姓名',
key: 'name',
// h: createElement
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [
h('input', {
domProps: {
value: row.name,
},
on: {
input: (event) => {
this.editName = event.target.value;
},
},
}),
];
} else {
edit = row.name;
}
return h('div', [edit]);
},
},
{
title: '年龄',
key: 'age',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [
h('input', {
domProps: {
value: row.age,
},
on: {
input: (event) => {
this.editAge = event.target.value;
},
},
}),
];
} else {
edit = row.age;
}
return h('div', [edit]);
},
},
{
title: '出生日期',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [
h('input', {
domProps: {
value: row.birthday,
},
on: {
input: (event) => {
this.editBirthday = event.target.value;
},
},
}),
];
} else {
const date = new Date(parseInt(row.birthday));
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
edit = `${year}-${month}-${day}`;
}
return h('div', [edit]);
},
},
{
title: '地址',
key: 'address',
render: (h, { row, index }) => {
let edit;
// 当前行为聚焦行时
if (this.editIndex === index) {
edit = [
h('input', {
domProps: {
value: row.address,
},
on: {
input: (event) => {
this.editAddress = event.target.value;
},
},
}),
];
} else {
edit = row.address;
}
return h('div', [edit]);
},
},
{
title: '操作',
render: (h, { row, index }) => {
if (this.editIndex === index) {
return [
h(
'button',
{
on: {
click: () => {
this.data[index].name = this.editName;
this.data[index].age = this.editAge;
this.data[index].birthday = this.editBirthday;
this.data[index].address = this.editAddress;
this.editIndex = -1;
},
},
},
'保存'
),
h(
'button',
{
style: {
marginLeft: '6px',
},
on: {
click: () => {
this.editIndex = -1;
},
},
},
'取消'
),
];
} else {
return h(
'button',
{
on: {
click: () => {
this.editName = row.name;
this.editAge = row.age;
this.editAddress = row.address;
this.editBirthday = row.birthday;
this.editIndex = index;
},
},
},
'修改'
);
}
},
},
],
data: [
{
name: '王小明',
age: 18,
birthday: '919526400000',
address: '北京市朝阳区芍药居',
},
{
name: '张小刚',
age: 25,
birthday: '696096000000',
address: '北京市海淀区西二旗',
},
{
name: '李小红',
age: 30,
birthday: '563472000000',
address: '上海市浦东新区世纪大道',
},
{
name: '周小伟',
age: 26,
birthday: '687024000000',
address: '深圳市南山区深南大道',
},
],
editIndex: -1, // 当前聚焦的输入框的行数
editName: '', // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁
editAge: '', // 第二列输入框
editBirthday: '', // 第三列输入框
editAddress: '', // 第四列输入框
};
},
};
</script>
renderTable/tableRender.vue
<template>
<table>
<thead>
<tr>
<th v-for="col in columns">{{ col.title }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in data">
<td v-for="col in columns">
<template v-if="'render' in col">
<Render
:row="row"
:column="col"
:index="rowIndex"
:render="col.render"
></Render>
</template>
<template v-else>{{ row[col.key] }}</template>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import Render from './render.js';
export default {
components: { Render },
props: {
columns: {
type: Array,
default() {
return [];
},
},
data: {
type: Array,
default() {
return [];
},
},
},
};
</script>
<style>
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
empty-cells: show;
/* border: 1px solid #e9e9e9; */
/* border: none; */
}
table th {
background: #f7f7f7;
color: #5c6b77;
font-weight: 600;
white-space: nowrap;
}
table td,
table th {
padding: 8px 16px;
border: 1px solid #e9e9e9;
text-align: left;
}
</style>
可用 slot-scope 自定义列的表格组件
slot(插槽)
: 用于分发内容。(常规的 slot
无法实现对组件循环体的每一项进行不同的内容分发)
slot-scope
: 本质上跟 slot
一样,只不过可以传递参数
方案一:
slot-scope
实现,同时兼容Render
函数的旧用法。适用于组件层级简单的表格。docs/.vuepress/components/vue2/slotScopeTable |--- render.js |--- slotScopeTableIndex1.vue |--- tableRender1.vue -- Table组件
slotScopeTable/render.js
export default { functional: true, props: { row: Object, column: Object, index: Number, render: Function, }, render: (h, ctx) => { const params = { row: ctx.props.row, column: ctx.props.column, index: ctx.props.index, }; return ctx.props.render(h, params); }, };
slotScopeTable/slotScopeTableIndex1.vue
<template> <div> <table-render :columns="columns" :data="data"> <template slot-scope="{ row, index }" slot="name"> <input type="text" v-model="editName" v-if="editIndex === index" /> <span v-else>{{ row.name }}</span> </template> <template slot-scope="{ row, index }" slot="age"> <input type="text" v-model="editAge" v-if="editIndex === index" /> <span v-else>{{ row.age }}</span> </template> <template slot-scope="{ row, index }" slot="birthday"> <input type="text" v-model="editBirthday" v-if="editIndex === index" /> <span v-else>{{ getBirthday(row.birthday) }}</span> </template> <template slot-scope="{ row, index }" slot="address"> <input type="text" v-model="editAddress" v-if="editIndex === index" /> <span v-else>{{ row.address }}</span> </template> <template slot-scope="{ row, index }" slot="action"> <div v-if="editIndex === index"> <button @click="handleSave(index)">保存</button> <button @click="editIndex = -1">取消</button> </div> <div v-else> <button @click="handleEdit(row, index)">操作</button> </div> </template> </table-render> </div> </template> <script> import TableRender from './tableRender1.vue'; export default { components: { TableRender }, data() { return { columns: [ { title: '姓名', slot: 'name', }, { title: '年龄', slot: 'age', }, { title: '出生日期', slot: 'birthday', }, { title: '地址', slot: 'address', }, { title: '操作', slot: 'action', }, ], data: [ { name: '王小明', age: 18, birthday: '919526400000', address: '北京市朝阳区芍药居', }, { name: '张小刚', age: 25, birthday: '696096000000', address: '北京市海淀区西二旗', }, { name: '李小红', age: 30, birthday: '563472000000', address: '上海市浦东新区世纪大道', }, { name: '周小伟', age: 26, birthday: '687024000000', address: '深圳市南山区深南大道', }, ], editIndex: -1, // 当前聚焦的输入框的行数 editName: '', // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁 editAge: '', // 第二列输入框 editBirthday: '', // 第三列输入框 editAddress: '', // 第四列输入框 }; }, methods: { handleEdit(row, index) { this.editName = row.name; this.editAge = row.age; this.editAddress = row.address; this.editBirthday = row.birthday; this.editIndex = index; }, handleSave(index) { this.data[index].name = this.editName; this.data[index].age = this.editAge; this.data[index].birthday = this.editBirthday; this.data[index].address = this.editAddress; this.editIndex = -1; }, getBirthday(birthday) { const date = new Date(parseInt(birthday)); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); return `${year}-${month}-${day}`; }, }, }; </script>
slotScopeTable/tableRender1.vue
<template> <table> <thead> <tr> <th v-for="col in columns">{{ col.title }}</th> </tr> </thead> <tbody> <tr v-for="(row, rowIndex) in data"> <td v-for="col in columns"> <template v-if="'render' in col"> <Render :row="row" :column="col" :index="rowIndex" :render="col.render" ></Render> </template> <template v-else-if="'slot' in col"> <slot :row="row" :column="col" :index="rowIndex" :name="col.slot" ></slot> </template> <template v-else>{{ row[col.key] }}</template> </td> </tr> </tbody> </table> </template> <script> import Render from './render.js'; export default { components: { Render }, props: { columns: { type: Array, default() { return []; }, }, data: { type: Array, default() { return []; }, }, }, }; </script> <style> table { width: 100%; border-collapse: collapse; border-spacing: 0; empty-cells: show; /* border: 1px solid #e9e9e9; */ } table th { background: #f7f7f7; color: #5c6b77; font-weight: 600; white-space: nowrap; } table td, table th { padding: 8px 16px; border: 1px solid #e9e9e9; text-align: left; } </style>
方案二: 如果组件已经成型(某 API 基于 Render 函数),但一时间不方便支持
slot-scope
,而使用者又想用,则可以使用此方案。一种 hack 方式,不推荐使用。docs/.vuepress/components/slotScopeTable |--- render.js |--- slotScopeTableIndex2.vue |--- tableRender2.vue -- Table组件
slotScopeTable/render.js
export default { functional: true, props: { row: Object, column: Object, index: Number, render: Function, }, render: (h, ctx) => { const params = { row: ctx.props.row, column: ctx.props.column, index: ctx.props.index, }; return ctx.props.render(h, params); }, };
slotScopeTable/slotScopeTableIndex2.vue
<template> <div> <table-render ref="table" :columns="columns" :data="data"> <template slot-scope="{ row, index }" slot="name"> <input type="text" v-model="editName" v-if="editIndex === index" /> <span v-else>{{ row.name }}</span> </template> <template slot-scope="{ row, index }" slot="age"> <input type="text" v-model="editAge" v-if="editIndex === index" /> <span v-else>{{ row.age }}</span> </template> <template slot-scope="{ row, index }" slot="birthday"> <input type="text" v-model="editBirthday" v-if="editIndex === index" /> <span v-else>{{ getBirthday(row.birthday) }}</span> </template> <template slot-scope="{ row, index }" slot="address"> <input type="text" v-model="editAddress" v-if="editIndex === index" /> <span v-else>{{ row.address }}</span> </template> <template slot-scope="{ row, index }" slot="action"> <div v-if="editIndex === index"> <button @click="handleSave(index)">保存</button> <button @click="editIndex = -1">取消</button> </div> <div v-else> <button @click="handleEdit(row, index)">操作</button> </div> </template> </table-render> </div> </template> <script> import TableRender from './tableRender2.vue'; export default { components: { TableRender }, data() { return { columns: [ { title: '姓名', render: (h, { row, column, index }) => { return h( 'div', this.$refs.table.$scopedSlots.name({ row: row, column: column, index: index, }) ); }, }, { title: '年龄', render: (h, { row, column, index }) => { return h( 'div', this.$refs.table.$scopedSlots.age({ row: row, column: column, index: index, }) ); }, }, { title: '出生日期', render: (h, { row, column, index }) => { return h( 'div', this.$refs.table.$scopedSlots.birthday({ row: row, column: column, index: index, }) ); }, }, { title: '地址', render: (h, { row, column, index }) => { return h( 'div', this.$refs.table.$scopedSlots.address({ row: row, column: column, index: index, }) ); }, }, { title: '操作', render: (h, { row, column, index }) => { return h( 'div', this.$refs.table.$scopedSlots.action({ row: row, column: column, index: index, }) ); }, }, ], data: [], editIndex: -1, // 当前聚焦的输入框的行数 editName: '', // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁 editAge: '', // 第二列输入框 editBirthday: '', // 第三列输入框 editAddress: '', // 第四列输入框 }; }, methods: { handleEdit(row, index) { this.editName = row.name; this.editAge = row.age; this.editAddress = row.address; this.editBirthday = row.birthday; this.editIndex = index; }, handleSave(index) { this.data[index].name = this.editName; this.data[index].age = this.editAge; this.data[index].birthday = this.editBirthday; this.data[index].address = this.editAddress; this.editIndex = -1; }, getBirthday(birthday) { const date = new Date(parseInt(birthday)); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); return `${year}-${month}-${day}`; }, }, mounted() { this.data = [ { name: '王小明', age: 18, birthday: '919526400000', address: '北京市朝阳区芍药居', }, { name: '张小刚', age: 25, birthday: '696096000000', address: '北京市海淀区西二旗', }, { name: '李小红', age: 30, birthday: '563472000000', address: '上海市浦东新区世纪大道', }, { name: '周小伟', age: 26, birthday: '687024000000', address: '深圳市南山区深南大道', }, ]; }, }; </script>
slotScopeTable/tableRender2.vue
<template> <table> <thead> <tr> <th v-for="col in columns">{{ col.title }}</th> </tr> </thead> <tbody> <tr v-for="(row, rowIndex) in data"> <td v-for="col in columns"> <template v-if="'render' in col"> <Render :row="row" :column="col" :index="rowIndex" :render="col.render" ></Render> </template> <template v-else>{{ row[col.key] }}</template> </td> </tr> </tbody> </table> </template> <script> import Render from './render.js'; export default { components: { Render }, props: { columns: { type: Array, default() { return []; }, }, data: { type: Array, default() { return []; }, }, }, }; </script> <style> table { width: 100%; border-collapse: collapse; border-spacing: 0; empty-cells: show; /* border: 1px solid #e9e9e9; */ } table th { background: #f7f7f7; color: #5c6b77; font-weight: 600; white-space: nowrap; } table td, table th { padding: 8px 16px; border: 1px solid #e9e9e9; text-align: left; } </style>
方案三: 将
slot-scope
集成在Table组件
中,并使用provide
/inject
进行数据传递,用于组件层级复杂的表格。不会破坏原有的任何内容,但会额外支持slot-scope
用法,关键是改动简单。docs/.vuepress/components/vue2/slotScopeTable |--- render.js |--- slot.js |--- slotScopeTableIndex3.vue |--- tableRender3.vue -- Table组件
slotScopeTable/render.js
export default { functional: true, props: { row: Object, column: Object, index: Number, render: Function, }, render: (h, ctx) => { const params = { row: ctx.props.row, column: ctx.props.column, index: ctx.props.index, }; return ctx.props.render(h, params); }, };
slotScopeTable/slot.js
export default { functional: true, inject: ['tableRoot'], props: { row: Object, column: Object, index: Number, }, render: (h, ctx) => { // 通过 $scopedSlots 获取到 slot return h( 'div', ctx.injections.tableRoot.$scopedSlots[ctx.props.column.slot]({ row: ctx.props.row, column: ctx.props.column, index: ctx.props.index, }) ); }, };
slotScopeTable/slotScopeTableIndex2.vue
<template> <div> <table-render :columns="columns" :data="data"> <template slot-scope="{ row, index }" slot="name"> <input type="text" v-model="editName" v-if="editIndex === index" /> <span v-else>{{ row.name }}</span> </template> <template slot-scope="{ row, index }" slot="age"> <input type="text" v-model="editAge" v-if="editIndex === index" /> <span v-else>{{ row.age }}</span> </template> <template slot-scope="{ row, index }" slot="birthday"> <input type="text" v-model="editBirthday" v-if="editIndex === index" /> <span v-else>{{ getBirthday(row.birthday) }}</span> </template> <template slot-scope="{ row, index }" slot="address"> <input type="text" v-model="editAddress" v-if="editIndex === index" /> <span v-else>{{ row.address }}</span> </template> <template slot-scope="{ row, index }" slot="action"> <div v-if="editIndex === index"> <button @click="handleSave(index)">保存</button> <button @click="editIndex = -1">取消</button> </div> <div v-else> <button @click="handleEdit(row, index)">操作</button> </div> </template> </table-render> </div> </template> <script> import TableRender from './tableRender3.vue'; export default { components: { TableRender }, data() { return { columns: [ { title: '姓名', slot: 'name', }, { title: '年龄', slot: 'age', }, { title: '出生日期', slot: 'birthday', }, { title: '地址', slot: 'address', }, { title: '操作', slot: 'action', }, ], data: [ { name: '王小明', age: 18, birthday: '919526400000', address: '北京市朝阳区芍药居', }, { name: '张小刚', age: 25, birthday: '696096000000', address: '北京市海淀区西二旗', }, { name: '李小红', age: 30, birthday: '563472000000', address: '上海市浦东新区世纪大道', }, { name: '周小伟', age: 26, birthday: '687024000000', address: '深圳市南山区深南大道', }, ], editIndex: -1, // 当前聚焦的输入框的行数 editName: '', // 第一列输入框,当然聚焦的输入框的输入内容,与 data 分离避免重构的闪烁 editAge: '', // 第二列输入框 editBirthday: '', // 第三列输入框 editAddress: '', // 第四列输入框 }; }, methods: { handleEdit(row, index) { this.editName = row.name; this.editAge = row.age; this.editAddress = row.address; this.editBirthday = row.birthday; this.editIndex = index; }, handleSave(index) { this.data[index].name = this.editName; this.data[index].age = this.editAge; this.data[index].birthday = this.editBirthday; this.data[index].address = this.editAddress; this.editIndex = -1; }, getBirthday(birthday) { const date = new Date(parseInt(birthday)); const year = date.getFullYear(); const month = date.getMonth() + 1; const day = date.getDate(); return `${year}-${month}-${day}`; }, }, }; </script>
slotScopeTable/tableRender3.vue
<template> <table> <thead> <tr> <th v-for="col in columns">{{ col.title }}</th> </tr> </thead> <tbody> <tr v-for="(row, rowIndex) in data"> <td v-for="col in columns"> <template v-if="'render' in col"> <Render :row="row" :column="col" :index="rowIndex" :render="col.render" ></Render> </template> <template v-else-if="'slot' in col"> <slot-scope :row="row" :column="col" :index="rowIndex"></slot-scope> </template> <template v-else>{{ row[col.key] }}</template> </td> </tr> </tbody> </table> </template> <script> import Render from './render.js'; import SlotScope from './slot.js'; export default { components: { Render, SlotScope }, provide() { return { tableRoot: this, }; }, props: { columns: { type: Array, default() { return []; }, }, data: { type: Array, default() { return []; }, }, }, }; </script> <style> table { width: 100%; border-collapse: collapse; border-spacing: 0; empty-cells: show; /* border: 1px solid #e9e9e9; */ } table th { background: #f7f7f7; color: #5c6b77; font-weight: 600; white-space: nowrap; } table td, table th { padding: 8px 16px; border: 1px solid #e9e9e9; text-align: left; } </style>
树形控件(递归组件) — Tree
递归组件的两个必要条件:
- 要给组件设置 name;
- 要有一个明确的结束条件
这类组件一般都是数据驱动型的,父级有一个字段 children,然后递归。
/docs/.vuepress/components/vue2/tree
|--- node.vue
|--- tree.vue
|--- treeIndex.vue
/docs/.vuepress/components/vue2/utils
|--- assist.js
tree/node.vue
<template>
<ul class="tree-ul">
<li class="tree-li">
<!-- 是否展开标识 - 条件: -->
<!-- 当前节点不含子节点(即:没有 children 字段或 children 的长度是 0) -->
<!-- 是否设置子节点展开 expand -->
<span class="tree-expand" @click="handleExpand">
<span v-if="data.children && data.children.length && !data.expand"
>+</span
>
<span v-if="data.children && data.children.length && data.expand"
>-</span
>
</span>
<!-- 此处,将 prop: value 和事件 @input 分开绑定,没有使用 v-model 语法糖 -->
<!-- 原因: @input 里要额外做一些处理,不是单纯的修改数据。 -->
<i-checkbox
v-if="showCheckbox"
:value="data.checked"
@input="handleCheck"
></i-checkbox>
<span>{{ data.title }}</span>
<tree-node
v-if="data.expand"
v-for="(item, index) in data.children"
:key="index"
:data="item"
:show-checkbox="showCheckbox"
></tree-node>
</li>
</ul>
</template>
<script>
import iCheckbox from '../checkbox/checkbox.vue';
import { findComponentUpward } from '../utils/assist.js';
export default {
name: 'TreeNode',
components: { iCheckbox },
props: {
// data 包含当前节点的所有信息
// >>> expand: 是否展开子节点标识
// >>> checked: 是否选中
// >>> children: 子节点数据
data: {
type: Object,
default() {
return {};
},
},
showCheckbox: {
type: Boolean,
default: false,
},
},
data() {
return {
// 此处使用,findComponentUpward 向上查找 Tree 实例,而不用 $parent
// 原因:因为它是递归组件,父级有可能还是自己
tree: findComponentUpward(this, 'Tree'),
};
},
methods: {
// 子节点展开操作:
// 点击 + 号时,会展开直属子节点,点击 - 号关闭
handleExpand() {
this.$set(this.data, 'expand', !this.data.expand);
if (this.tree) {
this.tree.emitEvent('on-toggle-expand', this.data);
}
},
// 节点选中或取消选中操作:(考虑上下级关系)
// 当选中(或取消选中)一个节点时:
// >>> 1. 它下面的所有子节点都会被选中
// >>> 2. 如果同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点
// 节点选中或取消选中操作 1:当选中(或取消选中)一个节点时, 【它下面的所有子节点都会被选中】:
// 只要递归地遍历它下面所属的所有子节点数据,修改所有的 checked 字段即可
handleCheck(checked) {
this.updateTreeDown(this.data, checked);
if (this.tree) {
this.tree.emitEvent('on-check-change', this.data);
}
},
updateTreeDown(data, checked) {
this.$set(data, 'checked', checked);
if (data.children && data.children.length) {
data.children.forEach((item) => {
this.updateTreeDown(item, checked);
});
}
},
},
watch: {
// 节点选中或取消选中操作 2:当选中(或取消选中)一个节点时,【如果同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点】
// >>> 如果这个节点的所有直属子节点(就是它的第一级子节点)都选中(或反选)时,这个节点就自动被选中(或反选),递归地,可以一级一级响应上去。
// 当前组件为递归组件,每个组件都会监听【data.children】
// >>> 【data.children】有两个"身份": 它既是下属节点的父节点,同时也是上级节点的子节点
// >>> 【data.children】作为下属节点的父节点被修改的同时,也会触发上级节点中的 watch 监听函数
'data.children': {
handler(data) {
if (data) {
// 返回当前子节点是否【都被选中】
const checkedAll = !data.some((item) => !item.checked);
this.$set(this.data, 'checked', checkedAll);
}
},
deep: true,
},
},
};
</script>
<style>
.tree-ul,
.tree-li {
list-style: none;
padding-left: 10px;
}
.tree-expand {
cursor: pointer;
}
</style>
tree/tree.vue
<template>
<div>
<tree-node
v-for="(item, index) in cloneData"
:key="index"
:data="item"
:show-checkbox="showCheckbox"
></tree-node>
</div>
</template>
<script>
import TreeNode from './node.vue';
import { deepCopy } from '../utils/assist.js';
export default {
name: 'Tree',
components: { TreeNode },
props: {
// data 是一个 Object 而非 Array,因为它只负责渲染当前的一个节点,并递归渲染下一个子节点(即 children)
// 所以对 cloneData 进行循环,将每一项节点数据赋给了 tree-node 组件。
data: {
type: Array,
default() {
return [];
},
},
// 是否显示选择框,只进行数据传递
showCheckbox: {
type: Boolean,
default: false,
},
},
data() {
return {
cloneData: [],
};
},
methods: {
// 为了不破坏使用者传递的源数据 data,所以会克隆一份数据(cloneData)
rebuildData() {
this.cloneData = deepCopy(this.data);
},
emitEvent(eventName, data) {
this.$emit(eventName, data, this.cloneData);
},
},
created() {
this.rebuildData();
},
watch: {
data() {
this.rebuildData();
},
},
};
</script>
tree/treeIndex.vue
<template>
<div>
<Tree
:data="data"
show-checkbox
@on-toggle-expand="handleToggleExpand"
@on-check-change="handleCheckChange"
></Tree>
</div>
</template>
<script>
import Tree from './tree.vue';
export default {
components: { Tree },
data() {
return {
data: [
{
title: 'parent 1',
expand: true,
children: [
{
title: 'parent 1-1',
expand: true,
children: [
{
title: 'leaf 1-1-1',
},
{
title: 'leaf 1-1-2',
},
],
},
{
title: 'parent 1-2',
children: [
{
title: 'leaf 1-2-1',
},
{
title: 'leaf 1-2-1',
},
],
},
],
},
],
};
},
methods: {
handleToggleExpand(data) {
console.log(data);
},
handleCheckChange(data) {
console.log(data);
},
},
};
</script>
utils/assist.js
// 由一个组件,向上找到最近的指定组件
function findComponentUpward(context, componentName) {
let parent = context.$parent;
let name = parent.$options.name;
while (parent && (!name || [componentName].indexOf(name) < 0)) {
parent = parent.$parent;
if (parent) name = parent.$options.name;
}
return parent;
}
export { findComponentUpward };
// 由一个组件,向上找到所有的指定组件
function findComponentsUpward(context, componentName) {
let parents = [];
const parent = context.$parent;
if (parent) {
if (parent.$options.name === componentName) parents.push(parent);
return parents.concat(findComponentsUpward(parent, componentName));
} else {
return [];
}
}
export { findComponentsUpward };
// 由一个组件,向下找到最近的指定组件
function findComponentDownward(context, componentName) {
const childrens = context.$children;
let children = null;
if (childrens.length) {
for (const child of childrens) {
const name = child.$options.name;
if (name === componentName) {
children = child;
break;
} else {
children = findComponentDownward(child, componentName);
if (children) break;
}
}
}
return children;
}
export { findComponentDownward };
// 由一个组件,向下找到所有指定的组件
function findComponentsDownward(context, componentName) {
return context.$children.reduce((components, child) => {
if (child.$options.name === componentName) components.push(child);
const foundChilds = findComponentsDownward(child, componentName);
return components.concat(foundChilds);
}, []);
}
export { findComponentsDownward };
// 由一个组件,找到指定组件的兄弟组件
function findBrothersComponents(context, componentName, exceptMe = true) {
let res = context.$parent.$children.filter((item) => {
return item.$options.name === componentName;
});
let index = res.findIndex((item) => item._uid === context._uid);
if (exceptMe) res.splice(index, 1);
return res;
}
export { findBrothersComponents };
function typeOf(obj) {
const toString = Object.prototype.toString;
const map = {
'[object Boolean]': 'boolean',
'[object Number]': 'number',
'[object String]': 'string',
'[object Function]': 'function',
'[object Array]': 'array',
'[object Date]': 'date',
'[object RegExp]': 'regExp',
'[object Undefined]': 'undefined',
'[object Null]': 'null',
'[object Object]': 'object',
};
return map[toString.call(obj)];
}
// deepCopy
function deepCopy(data) {
const t = typeOf(data);
let o;
if (t === 'array') {
o = [];
} else if (t === 'object') {
o = {};
} else {
return data;
}
if (t === 'array') {
for (let i = 0; i < data.length; i++) {
o.push(deepCopy(data[i]));
}
} else if (t === 'object') {
for (let i in data) {
o[i] = deepCopy(data[i]);
}
}
return o;
}
export { deepCopy };