跳至主要內容

Vue 2.x 相关组件实现

Mr.LRH大约 4 分钟

Vue 2.x 相关组件实现

具有数据校验功能的表单组件

Form组件 的核心功能是数据校验,一个 Form 中包含了多个 FormItem,当点击提交按钮时,逐一对每个 FormItem 内的表单组件校验,而校验是由使用者发起,并通过 Form 来调用每一个 FormItem 的验证方法,再将校验结果汇总后,通过 Form 返回出去

要在 Form 中逐一调用 FormItem 的验证方法,而 FormFormItem 是独立的,需要预先将 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.jsalert.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 };
上次编辑于:
贡献者: lingronghai