2
本篇文章记录一下,仿写一个el-tabs组件,有助于大家更好理解,饿了么ui的轮子具体工作细节。本文是elementui源码学习仿写系列的又一篇文章,后续空闲了会不断更新并仿写其他组件。源码在github上,大家可以拉下来,npm start运行跑起来,结合注释有助于更好的理解。github仓库地址如下:https://github.com/shuirongsh...

知识点复习

为了更好的阅读后续代码,我们需要再来复习一下知识

vue中的render函数书写jsx语法

vue中我们写组件页面,常常是结构、逻辑、样式分离,如:

<template>
    <!-- 这里写结构 -->
</template>
<script>
    // 这里写逻辑
</script>
<style>
    // 这里写样式
</style>

如果大家熟悉react语法,就会发现,react中是把结构和逻辑书写在一块的(jsx)语法,其实vue中也是可以使用jsx语法的,不过要在render函数中去写。比如我们要使用render函数写一个红色字体的H3标签、背景色黄绿色。代码可以如下书写:

<script>
export default {
  data() { return { name: "孙悟空" }; },
  render(h) {
    /**
     * 第一步,准备一个dom,dom中使用单大括号作为占位符,单大括号中可以使用变量
     *        vue中一般是双大括号中使用变量,区别不大。再一个就是两头使用小括号
     *        包裹(方便换行书写)
     * */
    let dom = (
      <div class="box">
        <h3 class="h3Class">{this.name}</h3>
      </div>
    );
    // 第二步,返回。如此render函数就会自动翻译渲染之
    return dom;
  },
};
</script>
<style>
.box { background: yellowgreen; }
.h3Class { color: red; }
</style>

效果图:

上述代码我们只需要记住,jsx语法中使用单大括号去表示变量使用。

再一个就是render函数中,如何给子组件传参数呢?也是使用单大括号搭配点点点...,因为单大括号才表示变量。如下代码:

render(h) {
    const sendParamsData = { // 准备参数
      props: {
        name: this.name, 
        age: this.age, 
        home: this.home, 
      },
    };
    return (
      <div class="kkk">
        {/* 传递参数 */}
        <child {...sendParamsData}></child>
      </div>
    );
  },

饿了么官方el-tabs组件就是使用了jsx语法进行编写的(因为jsx语法更加灵活),故:普通业务需求template够用了。灵活复杂需求,考虑使用jsx

this.$slots.default拿到组件标签内容中非具名插槽的部分

this.$slots.default这个api,大家平常使用的可能不是特别多。这个api可以顾名思义。$slots,插槽的意思(复数,可能有多个),default默认的意思。那就是默认插槽的意思。即this.$slots.default这个变量保存了所有不是命名插槽且不是作用域插槽的所有普通插槽内容。是个数组(如果有的话)。我们来看下面的代码简单使用便明了

// 父组件
<template>
    <child>
      <h3>孙悟空</h3>
      <h3>猪八戒</h3>
      <h3>沙和尚</h3>
    </child>
</template>
<script>
import child from "./child.vue";
export default { components: { child }};
</script>

// 子组件
<template>
  <div>我是子组件</div>
</template>
<script>
export default {
  mounted() { console.log(this)},
};
</script>

打印的this组件实例图如下:


当然,这里我们写的是普通标签h3,这里也可以写组件标签,那么this.$slots.default存储的就是组件标签了,能拿到组件所有东西,就可以去进一步,加工存储,传递使用。结合官方el-tabs组件的使用,代码如下:

<el-tabs v-model="activeName" @tab-click="handleClick"> 
    <el-tab-pane label="用户管理" name="first">用户管理</el-tab-pane> 
    <el-tab-pane label="配置管理" name="second">配置管理</el-tab-pane> 
    <el-tab-pane label="角色管理" name="third">角色管理</el-tab-pane> 
</el-tabs>

我们发现,好像很相似。是的,官方就是通过this.$slots.default这个数组,拿到每一个el-tab-pane组件上的labelname以及其他的信息,然后传递到tab-nav组件上,于是乎,tab-nav组件,就会显示出一个又一个的选项卡信息了(供用户点击)

父组件v-model传参,子组件props中key为'value'接收,'input'事件更改

我们看官方使用案例中,也发现v-model绑定在组件el-tabs组件上。平常我们使用v-model一般都是绑定在输入框、下拉框、切换之类的表单控件组件上。只需要写一个v-model就可以了,不用做别的操作。不过v-model若绑定在普通自定义组件上,用于父子组件传递参数(双向数据绑定),就需要我们多写点代码了。我们来看一下案例:

// 父组件
<template>
  <child v-model="age"></child>
</template>
<script>
import child from "./child.vue";
export default {
  components: { child },
  data() {
    return {
      age: 500,
    };
  },
};
</script>

// 子组件
<template>
  <div>
    <h3>孙悟空年龄是:{{ ageValue }}</h3>
    <button @click="add">加一</button>
  </div>
</template>

<script>
export default {
  props: {
    value: null, // 声明接收
  },
  data() {
    return {
      ageValue: this.value, // 取到值用于显示
    };
  },
  methods: {
    add() {
      this.$emit("input", this.ageValue); // 父组件更新值的时候,子组件也要更新
      this.ageValue = this.ageValue + 1; // 子组件手动更新(另,使用watch监听value值变化自动更新也行)
    },
  },
};
</script>

效果图如下:

原因:

// 父组件v-model=age相当于
<child v-model="age"></child>

<child :value="age" @input="age = $event"></child>

所以,子组件需要在props中使用value接收,同时使用this.$emit("input", xxx)触发

开始仿写

首先要搭建一个tabs结构

我们知道tabs就是选项卡切换的意思,整体可以分为三部分:选项卡部分内容区部分整个选项卡盒子部分,所以这里我们新建三个文件,去实现这个tabs组件。

  1. tabs.vue文件(整个选项卡盒子部分),用来作为整个选项卡切换的大容器的文件,在这个文件中去处理选项卡部分逻辑,以及内容区部分逻辑
  2. tabNav.vue文件(选项卡部分)
  3. tabContent.vue文件(内容区部分)

图示如下:

当然这里笔者已经封装好了,先来看看如何使用,以及效果图,然后再看看封装的代码呗

使用封装好的组件

<template>
  <div>
    <my-tabs v-model="activeName" @tabClick="tabClick">
      <my-tab-content label="孙悟空" name="sunwukong"
        >孙悟空内容</my-tab-content
      >
      <my-tab-content label="猪八戒" name="zhubajie">猪八戒内容</my-tab-content>
      <my-tab-content label="沙和尚" name="shaheshang"
        >沙和尚内容</my-tab-content
      >
      <my-tab-content label="唐僧" name="tangseng">唐僧内容</my-tab-content>
      <my-tab-content label="白龙马" name="bailongma"
        >白龙马内容</my-tab-content
      >
    </my-tabs>
    <br />
    <hr />
    <my-tabs v-model="activeName2" :before-leave="beforeLeave">
      <my-tab-content label="武松" name="wusong">武松内容</my-tab-content>
      <my-tab-content label="宋江" name="songjiang">松江内容</my-tab-content>
      <my-tab-content label="林冲" name="linchong">林冲内容</my-tab-content>
      <my-tab-content disabled label="吴用" name="wuyong"
        >吴用内容</my-tab-content
      >
    </my-tabs>
  </div>
</template>

<script>
export default {
  data() {
    return {
      activeName: "sunwukong",
      activeName2: "wusong",
    };
  },
  methods: {
    tabClick(tabItem) {
      console.log("外层点击", tabItem);
    },
    beforeLeave(newTabName, oldTabName) {
      console.log("外层", newTabName, oldTabName);
      /**
       * 方式一:
       *    return true; // 表示允许切换tab
       *    return false; // 表示不允许切换tab
       * */
      /**
       * 方式二:
       *    使用Promise处理异步相关切换tab操作,比如问询操作
       * */
      var p = new Promise((resolve, reject) => {
        this.$confirm(`确认由${oldTabName}切换到${newTabName}`, "tab change", {
          confirmButtonText: "确认切换",
          cancelButtonText: "取消",
          type: "warning",
        })
          .then(() => {
            resolve(true); // 允许放行通过切换tab
          })
          .catch((err) => {
            reject(false); // 不允许切换tab
          });
      }).catch((reason) => {
        // 注意此处须用Promise的catch方法捕获错误,否则控制台报错 Uncaught (in promise)
        console.log("reason", reason);
      });
      // 最后返回Promise的结果
      return p;
    },
  },
};
</script>

效果图

tabs.vue组件(相当于数据中转站)

<script>
import tabNav from "./tabNav.vue"; // 引入tab导航页组件
export default {
  name: "myTabs",
  components: { tabNav }, // 注册之
  props: {
    // 父组件用v-model传参,子组件须用value接参,方可接到v-model="activeName"绑定的activeName的值
    value: null, // 接收到的值即为当前高亮的是哪一项
    // 传递一个函数,作为tab切换的钩子函数
    beforeLeave: {
      // 切换标签之前的钩子,若返回 false 或者返回 Promise 且被 reject,则阻止切换
      type: Function,
      default: () => {
        return true; // 默认为true,始终允许切换tab
      },
    },
  },
  data() {
    return {
      tabItemArr: [], // 用于传递给tabNav组件信息数据的数组
      activeName: this.value, // 高亮的是哪个tab标签页选项卡
    };
  },
  mounted() {
    /**
     * 计算收集tab页内容信息,将需要用到的信息存在tabItemArr数组中
     * 并传递给tabNav组件,tabNav组件根据tabItemArr信息去v-for渲染有哪些
     * */
    this.calcTabItemInstances();
  },
  methods: {
    calcTabItemInstances() {
      // 重点方法
      // 获取使用的地方的my-tab标签中间的内容
      if (this.$slots.default) {
        // 收集my-tab标签中间的插槽内容数组
        let slotTabItemArr = this.$slots.default; // console.log("slotTabItemArr", slotTabItemArr);
        // 然后把这些数据交给tab-nav动态渲染
        this.tabItemArr = slotTabItemArr.map((item) => {
          return item.componentInstance; // 只保留componentInstance组件实例即可,可以理解为组件的this
        });
        // consoloe.log('this.tabItemArr',this.tabItemArr)
      } else {
        this.tabItemArr = []; // 没传递就置为空,当然需要规范使用组件,规范传递相关参数
      }
    },
    handleTabClick(tabItem) {
      this.$emit("tabClick", tabItem); // 通知父元素点击的是谁,是哪个tab-nav
      let newTabName = tabItem.name; // 获取传出来的最新的name名字
      this.setCurrentName(newTabName); // 执行更新方法
    },
    // 考虑到可能有异步操作,所以加上async await(比如在切换tab标签页之前,做一个问询)
    async setCurrentName(newTabName) {
      let oldTabName = this.activeName; // 要更新了,所以当下的就变成旧的了
      let res = await this.beforeLeave(newTabName, oldTabName);
      if (res) {
        this.$emit("input", newTabName); // 更新父组件的v-model绑定的值
        this.activeName = newTabName; // 自身也更新一下
      }
    },
  },
  render(h) {
    // 准备参数,以便把参数传递给tab-nav组件
    const navData = {
      props: {
        tabItemArr: this.tabItemArr, // 内容区相关信息数组
        activeName: this.activeName, // 当前高亮的是哪一项
        onTabClick: this.handleTabClick, // 点击某一tab项的回调
      },
    };
    return (
      <div class="tab-Box">
        <tab-nav {...navData}></tab-nav>
        <div class="my-tab-content-item-box">{this.$slots.default}</div>
      </div>
    );
    /**
     * 注意:<div class="my-tab-content-item-box">{this.$slots.default}</div>写法,正常会把所有的都渲染出来
     * 所以我们在myTabContent组件中再加一个判断(v-show="isActiveToShowContent"),看看当前高亮的名字是否和组件的名字一致,
     * 一致才渲染.这样的话,同一时刻,根据myTabContent组件的name属性,只会对应渲染一个
     * */
  },
};
</script>

tabNav.vue组件(接收tabs.vue的数据进行v-for动态渲染)

<template>
  <div class="my-tab-nav-item-box">
    <div
      :class="[
        'my-tab-nav-item',
        tabItem.name === activeName ? 'highLight' : '',
        tabItem.disabled ? 'isForbiddenItem' : '',
      ]"
      v-for="(tabItem, index) in tabItemArr"
      :key="index"
      @click="changeActiveName(tabItem)"
    >
      {{ tabItem.label }}
    </div>
  </div>
</template>
<script>
export default {
  name: "myTabNav",
  props: {
    // 源自于内容区的数组数据,非常重要
    tabItemArr: {
      type: Array,
      default: [],
    },
    // 当前激活的名字
    activeName: {
      type: String,
      default: "",
    },
    // 接收点击选项卡函数,在点击tab选项卡的时候,通过此函数传递出去
    onTabClick: {
      type: Function,
    },
  },
  methods: {
    changeActiveName(tabItem) {
      // 自己点自己就不让执行了
      if (tabItem.name === this.activeName) {
        return;
      }
      // 如果包含禁用项disabled属性(即处于禁用状态),也不让执行(搭配.isForbiddenItem类名)
      if (tabItem.disabled) {
        return;
      }
      this.onTabClick(tabItem);
    },
  },
};
</script>
<style lang="less" scoped>
.my-tab-nav-item-box {
  width: 100%;
  border-bottom: 1px solid #e9e9e9;
  .my-tab-nav-item {
    // 转换成行内盒子,每一项都水平排列
    display: inline-block;
    // 垂直居中
    height: 40px;
    line-height: 40px;
    // 字体样式位置设置一下
    font-size: 14px;
    font-weight: 500;
    color: #303133;
    margin: 0 12px;
    cursor: pointer;
  }
  // 非禁用时鼠标悬浮样式,注意这里not的使用
  .my-tab-nav-item:not(.isForbiddenItem):hover {
    color: #409eff;
  }
  // 高亮项样式
  .highLight {
    color: #409eff;
    border-bottom: 1px solid #409eff;
  }
  // 禁用项样式
  .isForbiddenItem {
    cursor: not-allowed;
    color: #aaa;
  }
}
</style>

myTabContent.vue组件(搭配v-show条件对比只渲染一个)

<template>
  <div class="my-tab-content-item" v-show="isActiveToShowContent">
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: "myTabContent",
  props: {
    label: String, // 标签名
    name: String, // 每个下方内容区都有自己的name名字
    disabled: {
      // 是否禁用这一项
      type: Boolean,
      default: false, // 默认不禁用
    },
  },
  computed: {
    // 控制根据高亮的tab显示对应标签页内容
    isActiveToShowContent() {
      let activeName = this.$parent.value; // 比如当前高亮的是 sunwukong
      let currentName = this.name; // this.name的值有很多个,有:sunwukong、zhubajie、shaheshang...
      // 谁等于,就显示谁
      return activeName === currentName;
    },
  },
};
</script>

<style>
.my-tab-content-item { padding: 12px;}
</style>

总结

对于tabs切换效果,可以自己写,比如使用动态组件方式去写(笔者也写过动态组件的文章链接:https://juejin.cn/post/695769... ),或者自己封装一个tabs组件。

不过本文只是仿照饿了么官方封装的一个简单的组件(某些地方的写法也可能和官方不太一样,不过思路是相通的)。再一个官方封装组件的时候,需要考虑到非常多的情况(可能某些情况很少用到),本文只是仿写并实现常用的效果。实际开发中,组件的封装的程度,需要根据项目的需求情况,进行考量。不可过度封装,也不能不封装。毕竟组件复用的确能够提升开发效率。

如果本篇文章能够帮助您更好的理解el-tabs的流程原理、数据传递方式,万分感谢给咱的github仓库star一下哦。毕竟elementui源码学习之仿写xxx是一个系列文章,您的start是咱创作的动力哦^_^


水冗水孚
1.1k 声望585 粉丝

每一个不曾起舞的日子,都是对生命的辜负