# 微信小程序自定义组件封装,项目最佳实践综合应用

TIP

从本节开始对微信小程序的自定义组件封装进行实践应用,以项目中最常用的内容标签 tab 页面为例,从 V1.0 版迭代升级至 V2.0,不断重构、结合 WXS 完成项目中的最佳实践

  • 内容标签 tab 页面 V1.0 版
  • 分类筛选功能
  • 内容标签页 V2.0 版 - 自定义组件化,定义、插槽、通信
  • 手指滑动监听切换标签
  • 自定义组件封装 - 最佳实践总结

# 一、内容标签 tab 页面 V1.0 版

TIP

综合前面学过的 WXML、WXSS、JS 等综合应用,实现内容标签页效果 V1.0 版

image-20230421091053785

# 1、内容标签页 UI 结构渲染

pages/index/index.wxml 结构中

<!--pages/index/index.wxml-->
<view class="container">
  <view class="tabs">
    <view class="tab-item" wx:for="{{ tabs }}" wx:key="index">
      <view class="tab-label">{{ item }}</view>
      <!-- 分割线,通过逻辑控制,判断是否显示 -->
      <view class="divider"></view>
    </view>
  </view>
  <view class="category"> 分类 swiper </view>
  <view class="tab-panel"> 标签页面板区域 </view>
</view>

pages/index/index.js 页面逻辑中,定义 tabs 数据

// pages/index/index.js
Page({
  // 页面的初始数据
  data: {
    tabs: ["全部课程", "正在学", "基础入门", "架构"],
  },
});

# 2、设定全局样式

app.css 中定义全局样式

/**app.wxss**/

/* 去掉组件的默认值,在微信小程序中不支持 * 通配符的 */
page,
view,
text,
swiper,
swiper-item,
image,
navigator {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* 主题颜色,默认字体大小,高度 */
page {
  /* 定义主题色 */
  --themeColor: #7bd802;
  /* 默认字体大小 */
  font-size: 26rpx;
  height: 100%;
}

# 3、定义 tabs 的样式

pages/index/index.wxss 中定义样式

/* pages/index/index.wxss */
.container {
  /* background-color: skyblue; */
  height: 100%;
}
.tabs {
  display: flex;
  padding: 30rpx 0;
  background-color: #fff;
  align-items: center;
}
.tab-item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.divider {
  height: 4rpx;
  width: 50rpx;
  background-color: var(--themeColor);
  margin-top: 10rpx;
}

# 4、实现标签栏的切换效果

TIP

我们之前在学习小程序的列表渲染时,除了给我们注入了 item 变量之外, 同时还注入了一个 index ,指当前元素在数组中的下标位置。有了这个 index 后,实现思路是:

  • 在 data 中定义一个变量 currentTabIndex ,该变量记录了当前所点击/已经点击的下标 与 index 做对比
  • currentTabIndex === index 即当前点击了该元素

pages/index/index.js 页面逻辑中,定义 currentTabIndex 变量

// pages/index/index.js
Page({
  // 页面的初始数据
  data: {
    tabs: ["全部课程", "正在学", "基础入门", "架构"],
    currentTabIndex: 0,
  },
});

pages/index/index.wxml 页面结构中,根据 tab 切换标签,改变样式 和 分割线

<view class="container">
  <view class="tabs">
    <view class="tab-item" wx:for="{{ tabs }}" wx:key="index">
      <!-- 根据选中状态,添加样式 -->
      <view
        class="tab-label {{ currentTabIndex === index ? 'active-tab' : '' }}"
        >{{ item }}</view
      >
      <!-- 分割线,通过逻辑控制,判断是否显示 -->
      <view class="divider" wx:if="{{ currentTabIndex === index }}"></view>
    </view>
  </view>
  <view class="category"> 分类 swiper </view>
  <view class="tab-panel"> 标签页面板区域 </view>
</view>

pages/index/index.wxss 中添加 ,选中状态的样式

/* 未选中样式 */
.tab-label {
  /* 未选中时,默认颜色 */
  color: #888;
  /* 文本区域不换行 */
  white-space: nowrap;
}
/* 选中样式 */
.active-tab {
  color: #333;
  font-weight: bold;
}

# 5、添加点击事件处理函数

TIP

实现点击 tab 选项,动态切换效果。实现步骤如下

  • 给 tab 选项添加点击/触摸事件 bindtap="handleTabChange" ,同时传递当前点击的 tab 选项的索引 data-index=""
  • 在页面的 JS 逻辑中添加 tab 切换的事件处理函数,完成切换逻辑实现

pages/index/index.wxml 页面结构,给 tab 选项绑定事件

<!--pages/index/index.wxml-->
<view class="container">
  <view class="tabs">
    <view
      class="tab-item"
      wx:for="{{ tabs }}"
      wx:key="index"
      bindtap="handleTabChange"
      data-index="{{ index }}"
    >
      <!-- 根据选中状态,添加样式 -->
      <view
        class="tab-label {{ currentTabIndex === index ? 'active-tab' : '' }}"
        >{{ item }}</view
      >
      <!-- 分割线,通过逻辑控制,判断是否显示 -->
      <view class="divider" wx:if="{{ currentTabIndex === index }}"></view>
    </view>
  </view>
  <view class="category"> 分类 swiper </view>
  <view class="tab-panel"> 标签页面板区域 </view>
</view>

pages/index/index.js 页面逻辑中,添加事件处理函数

// pages/index/index.js
Page({
  // 页面的初始数据
  data: {
    tabs: ["全部课程", "正在学", "基础入门", "架构"],
    currentTabIndex: 0,
  },

  // 点击 tab 切换事件处理函数
  handleTabChange(e) {
    // 获取当前点击 tab 选项的 index
    const index = e.currentTarget.dataset.index;
    // 更新 data 中 currentTabIndex 值
    this.setData({
      currentTabIndex: index,
    });
  },
});

# 二、分类筛选功能

TIP

使用原生 swiper 组件实现分类滑动、点击分类筛选过滤信息的功能

image-20230421092121818

# 1、定义类型分类的 Mock 数据

pages/index/index.js 的 data 中新增 categoryList 数据

// pages/index/index.js
Page({
  // 页面的初始数据
  data: {
    // 省略其他 ......

    // // 分类数据
    categoryList: [
      { id: 1, name: "Web 前端" },
      { id: 2, name: "Java 架构" },
      { id: 3, name: "Python 实战" },
      { id: 4, name: "Node 后端" },
      { id: 5, name: "GO 语言" },
      { id: 6, name: "云原生" },
    ],
  },
});

# 2、分类 swiper 的 UI 结构渲染

pages/index/index.wxml 中定义 swiper 分类功能的 UI 结构

<!--pages/index/index.wxml-->
<view class="container">
  <!-- 省略其他 ...... -->

  <view class="category">
    <!-- 分类 swiper -->
    <swiper
      class="category-swiper"
      display-multiple-items="2"
      next-margin="80rpx"
      snap-to-edge="true"
    >
      <swiper-item
        class="category-swiper-item"
        wx:for="{{ categoryList }}"
        wx:key="id"
        bindtap="handleCategoryChange"
        data-id="{{ item.id }}"
      >
        <view class="category-name">{{ item.name }}</view>
      </swiper-item>
    </swiper>
  </view>

  <view class="tab-panel"> 标签页面板区域 </view>
</view>

# 3、定义分类功能的样式

pages/index/index.wxss 中定义样式

/* 省略其他 ...... */

/* swiper */
.category {
  padding: 10rpx 0 30rpx 30rpx;
}
.category-swiper {
  height: 150rpx;
}
.category-name {
  padding: 20rpx 0 0 20rpx;
  color: #fff;
  font-size: 28rpx;
  background: linear-gradient(to right, #96e6a1, #d4fc79);
  border-radius: 20rpx;
  position: absolute;
  width: 90%;
  height: 100%;
  right: 30rpx;
}

# 4、点击分类选项的事件处理函数

pages/index/index.js 页面逻辑中定义分类事件处理函数

// pages/index/index.js
Page({
  // 页面的初始数据
  data: {
    // 分类数据
    categoryList: [
      { id: 1, name: "Web 前端" },
      { id: 2, name: "Java 架构" },
      { id: 3, name: "Python 实战" },
      { id: 4, name: "Node 后端" },
      { id: 5, name: "GO 语言" },
      { id: 6, name: "云原生" },
    ],
  },

  // 点击 swiper-item 的事件处理函数
  handleCategoryChange(e) {
    // 获取当前点击分类选项的 id
    const id = e.currentTarget.dataset.id;
    console.log(id);
  },
});

注:

根据以上的代码实现逻辑,我们接下来就可以开始来实现分类列表数据的渲染和展示,以及其他功能的实现了。但,这个时候我们突然发现,在代码层面的实现上有很大问题。

接下来我们就开始应用一个完整的解决方案来实现。

# 三、内容标签页 V2.0 版 - 自定义组件化,定义、插槽、通信

TIP

在我们前面的小节中实现了,内容标签 tab 页面 V1.0 版 和 分类筛选的功能。在我们准备继续完成后面功能实现时,发现了我们前面代码实现中出现了问题。

如下

# 1、潜在的重复实现

TIP

可通过观察 UI 效果图 或 产品原型图等看到

我们发现内容标签需要在很多页面都会使用到,意味着我们之前写的 tab 标签的功能代码需要复制粘贴很多次。这样做的问题非常大

  • 如果内容标签 tab 部分的功能,需要调整、新增、Bug 修复等 就需要同时修改其他用到的部分,一旦功能复杂又用的多了就是个灾难。
  • 很难保证每一次的修改都能准确无误的同步应用到了其他使用 tab 功能的位置
  • 手动复制的方式效率也非常的底下,也没有必要

有没有好的解决办法呢 ? 答案是有的

将高频使用的功能模块,做封装。在其他地方使用,只需要调用封装后的结果即可。在这个基础上,当该功能模块有什么改动、新增、Bug 修复等不会影响到已经使用到的地方

# 2、在小程序中的解决方案实现

TIP

  • 使用 自定义组件,完成潜在重复实现的功能模块
  • 重构内容标签页,自定义 tabs 组件
  • 完成自定义组件的最佳实践要点总结,什么时候用,怎么用

# 3、自定义组件的难点

TIP

通过前面的章节我们以及学习有关自定义组件的核心基础,发现其实也并不难嘛。但会用和用得好还是两码事的,自定义组件本身的难点在于以下两点

通常我们在项目中使用自定义组件时,主要解决两个功能上的问题,第一个就是通用组件和业务组件

# 3.1、通用组件 与 业务组件的设计

TIP

  • 通用组件:组件本身不和具体业务挂钩。如:button 按钮 和 icon 图标,小程序是有原生提供了这种组件的,但原生的功能很单一、同时定制化能力也很差,通常会自己来封装。

我们封装好这些组件后,就可以在使用时实现个性化的需求,通过配置来实现,不需要改动任何样式。原生实现就会比较麻烦,需要改动很多样式。

  • 业务组件:对具体某个业务功能做了封装。最大的目的在于模块化页面,让页面变得更加容易维护,同时实现一定程度的复用性

这两者,在具体实现时所考虑的东西是不一样的也有很多讲究,这才是难点。

# 3.2、自定义组件的设计思想

TIP

微信小程序在早期是没有自定义组件这个功能的,这也是小程序进化史上很有里程碑意义的功能。通过自定义组件我们可以更加从容的应对复杂项目,为什么这么说呢 ?

如果我们有接触过面向对象(OOP)的概念就能明白,面向对象的三大特性

  • 封装
  • 继承
  • 多态

等我们重构完内容标签 tab 组件后,再来总结

# 4、重构内容标签页 - 自定义组件 tabs

TIP

利用自定义组件机制,封装 tabs 组件,实现内容标签页效果的 V2.0

先思考一个问题

tabs 组件属于通用组件 还是 业务组件呢 ?

这是一个很重要的问题,通常我们在做自定义组件开发时,第一个要明确的是这个组件是属于 通用组件 还是 业务组件 !这个定位会影响我们后续的实现思路以及方法。可以先思考下

# 4.1、自定义组件 tabs 的实现思路

TIP

  • ①、传入一个数组,按数组元素内容渲染我们的标签页选项(它是组件的输入)
  • ②、能够监听点击事件,并且通知使用组件的页面 或 父组件(是由于自定义组件中还可以嵌套使用自定义组件的)通过事件通知我们选择了什么(它是组件的输出)

从以上两点功能可以看出,当前的这个 tabs 自定义组件是通用组件 还是 业务组件呢 ?

很明显,它是一个 通用组件

原因是: 这个自定义组件所做的事情,都是跟自身有关的,它只是做了一个标签页的渲染,同时它能展示出点击的效果,并且告诉调用的地方你选择了什么 。至于你选择做什么事情是由使用自定义组件的页面 或 父组件决定的

也就是说,这个自定义 tabs 组件它本身是没有业务功能的。因此,它是属于通用组件。

# 4.2、创建自定义组件 tabs

TIP

在项目的根目录中,新建文件夹 components -> tabs,在 tabs 文件夹上 鼠标右键 选择 新建 Component ,输入 tabs 回车后即会自动生成对应的 4 个文件

icoding-com-course
├─ components
│ ├─ tabs
│ │ ├─ tabs.js
│ │ ├─ tabs.json
│ │ ├─ tabs.wxml
│ │ └─ tabs.wxss

在页面配置文件 pages/index/index.json 中引入 tabs 组件

{
  "usingComponents": {
    "i-tabs": "../../components/tabs/tabs"
  }
}

在页面 pages/index/index.wxml 中使用组件

<!--pages/index/index.wxml-->
<!-- 使用 tabs 自定义组件 -->
<i-tabs></i-tabs>

# 4.3、定义自定义组件 tabs 的 UI 结构和样式

TIP

将原来内容标签 tab 页面 V1.0 版中的 tab 页面相关结构 和 样式,剪切到对应的 tabs 自定义组件的 wxmlwxss

剪切 tab 内容标签的页面结构过来后,在 components/tabs/tabs.wxml

<!--components/tabs/tabs.wxml-->
<view class="container">
  <view class="tabs">
    <view
      class="tab-item"
      wx:for="{{ tabs }}"
      wx:key="index"
      bindtap="handleTabChange"
      data-index="{{ index }}"
    >
      <!-- 根据选中状态,添加样式 -->
      <view
        class="tab-label {{ currentTabIndex === index ? 'active-tab' : '' }}"
        >{{ item }}</view
      >
      <!-- 分割线,通过逻辑控制,判断是否显示 -->
      <view class="divider" wx:if="{{ currentTabIndex === index }}"></view>
    </view>
  </view>
</view>

剪切 tab 内容标签的 CSS 样式过来后,在 components/tabs/tabs.wxss

/* components/tabs/tabs.wxss */
.tabs {
  display: flex;
  padding: 30rpx 0;
  background-color: #fff;
  align-items: center;
}
.tab-item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.divider {
  height: 4rpx;
  width: 50rpx;
  background-color: var(--themeColor);
  margin-top: 10rpx;
}

.tab-label {
  /* 未选中时,默认颜色 */
  color: #888;
  /* 文本区域不换行 */
  white-space: nowrap;
}
.active-tab {
  color: #333;
  font-weight: bold;
}

注:

当我们将 tab 内容标签部分的 结构和样式剪切过来后,是无法直接成功遍历数据的。当我们封装为自定义组件后,就需要将对应的数据传入组件中,才能生效(即:自定义组件渲染什么内容由调用方决定)。

在自定义组件中接收外界属性,我们会在 properties 节点中先声明,如果是内部使用的数据属性放在 data 节点中

# 4.4、在页面中使用组件时,属性绑定传值

TIP

通过页面/父组件 向 子组件传值,通过属性绑定(即:属性绑定用于实现父向子传值

在页面 pages/index/index.wxml 中,使用 tabs 自定义组件时通过属性绑定传值

<!--pages/index/index.wxml-->
<view class="container">
  <!-- 使用 tabs 自定义组件,从页面中传入 tabs 数组 -->
  <i-tabs tabs="{{ tabs }}"></i-tabs>
</view>

# 4.5、重构自定义组件 tabs 逻辑

TIP

  • 接收由页面或父组件中传递过来的属性,在properties 节点中定义
  • 点击 tab 选项的当前索引 currentTabIndex 只是组件内部使用,在data节点中定义
  • 将页面逻辑index.js 中的 tab 切换事件处理函数 handleTabChange 剪切过来,放到 methods 节点中
// components/tabs/tabs.js
Component({
  // 组件的属性列表(由页面或父组件中传递过来的属性)
  properties: {
    tabs: {
      type: Array,
      value: [],
    },
  },

  // 组件的初始数据
  data: {
    currentTabIndex: 0,
  },

  // 组件的方法列表
  methods: {
    // 点击 tab 切换事件处理函数
    handleTabChange(e) {
      // 获取当前点击 tab 选项的 index
      const index = e.currentTarget.dataset.index;
      // 更新 data 中 currentTabIndex 值
      this.setData({
        currentTabIndex: index,
      });
    },
  },
});

注:

以上代码重构后,即可实现通过自定义组件的方式,在页面中引入使用。

但,当我们点击自定义组件 tab 标签切换时,需要操作页面/父组件中的 tab-panel 区域同步切换,就需要将 当前索引 index 传递给 页面/父组件。

我们知道,事件绑定用于子组件向父组件传递数据

# 5、通过事件绑定向页面/父组件传值

TIP

当点击 tab 标签切换时,发起一个自定义事件

在自定义组件components/tabs/tabs.js 逻辑中

// components/tabs/tabs.js
Component({
  // 省略其他 ......

  // 组件的方法列表
  methods: {
    // 点击 tab 切换事件处理函数
    handleTabChange(e) {
      // 获取当前点击 tab 选项的 index
      const index = e.currentTarget.dataset.index;
      // 更新 data 中 currentTabIndex 值
      this.setData({
        currentTabIndex: index,
      });

      // 触发自定义事件 change ,并携带当前索引值 index
      this.triggerEvent("change", { index });
    },
  },
});

在页面(父组件)pages/index/index.wxml 中监听绑定事件

<!--pages/index/index.wxml-->
<view class="container">
  <!-- 绑定自定义事件 change  -->
  <i-tabs tabs="{{ tabs }}" bind:change="handleChange"></i-tabs>
</view>

在页面(父组件)pages/index/index.js 逻辑中,定义自定义事件处理函数

// pages/index/index.js
Page({
  // 页面的初始数据
  data: {},

  // 自定义事件
  handleChange(e) {
    // 获取子组件向页面(父组件)传递的值
    const index = e.detail.index;
    console.log(index);
  },
});

总结:

  • 父组件(页面)通过属性给自定义组件传递参数
  • 自定义组件通过自定义事件给父组件(页面)传递参数

# 6、重构内容标签页 - 自定义组件插槽

TIP

由于标签页里边的内容和样式都是不确定的,是由自定义组件的调用方来决定。这时就会用到插槽 <slot>

根据需求在 components/tabs/tabs.wxml 中定义多插槽

<!--components/tabs/tabs.wxml-->
<view class="container">
  <view class="tabs">
    <!-- 省略部分 ...... -->
  </view>

  <!-- 定义一个用于扩展的插槽 -->
  <slot name="extend"></slot>

  <!-- 标签页面板区域 -->
  <view class="tab-panel">
    <!-- 对于内容、样式不确定内容,定义 slot 占位,具体的内容由组件的调用者决定 -->
    <!-- 标签页面板区域 插槽 -->
    <slot name="panel"></slot>
  </view>
</view>

components/tabs/tabs.js 中开启多插槽支持

// components/tabs/tabs.js
Component({
  // 新增 options 节点
  options: {
    // 开启多插槽支持
    multipleSlots: true,
  },

  // 省略部分 ......
});

在页面pages/index/index.wxml中使用插槽

<!--pages/index/index.wxml-->
<view class="container">
  <!-- 使用 tabs 自定义组件,从页面中传入 tabs 数组 -->
  <i-tabs tabs="{{ tabs }}" bind:change="handleChange">
    <!-- 使用扩展插槽 -->
    <view slot="extend">
      <view class="category">
        <!-- 分类 swiper -->
        <swiper
          class="category-swiper"
          display-multiple-items="2"
          next-margin="80rpx"
          snap-to-edge="true"
        >
          <swiper-item
            class="category-swiper-item"
            wx:for="{{ categoryList }}"
            wx:key="id"
            bindtap="handleCategoryChange"
            data-id="{{ item.id }}"
          >
            <view class="category-name">{{ item.name }}</view>
          </swiper-item>
        </swiper>
      </view>
    </view>

    <!-- 使用标签页面板区域插槽 -->
    <view slot="panel">标签页面板区域</view>
  </i-tabs>

  <!-- <view class="tab-panel">
        标签页面板区域
    </view> -->
</view>

效果如下

image-20230421002721024

# 四、手指滑动监听切换标签

TIP

前面的小节学习中,通过插槽的机制进一步提高了 tabs 组件的通用性,但在功能上还有一个缺陷,不支持手指滑动来切换页面标签。

这个在移动端是不能接受的,接下我们就来完善该功能

# 1、常规实现思路分析

TIP

我们需要用到微信小程序中的 WXS 来实现手指滑动监听切换标签,我先来看下如果使用我们之前学过的内容 + 查阅官方文档,该如何来实现呢 ?

image-20230421082930736

通常情况下,我们会想到以下的实现思路

  • 在 WXML 中(视图层)-> 做滑动事件监听 -> 等待用户触发滑动事件 ->
  • 如用户触发了滑动事件 -> 调用我们定义的事件处理函数 -> 在事件处理函数中会处理业务逻辑 -> 这个部分就是我们的 JS 文件中要做的事情(逻辑层)->
  • 在逻辑层最终实现数据绑定(setData)-> 最后在视图层实现标签页的切换效果

这个思路可以实现我们想要的功能,并且在绝大多数的场景下,我们都是使用这样的思路来实现的。

但,在我们这个场景下这个思路就有问题了。注意,这里说的场景不合适,不是思路有问题。

# 1.1、在标签页 panel 区域绑定事件监听

TIP

相关监听事件查阅,微信小程序官方文档 - 事件分类 (opens new window)

  • touchstart 手指触摸动作开始
  • touchend 手指触摸动作结束

同时监听这两个事件,当我们手指划动后,计算终点 距离 起点的坐标值的变化,判断是往左划了,还是往右划了。因此需要同时监听这两个事件。

当我们在页面触发 touchstart 和 touchend 这两个事件后,就会接收到一个参数,其中就会描述我们当前点击位置的坐标值。即拿到值就可以计算了

在 tabs 自定义组件 components/tabs/tabs.wxml 页面结构中

<!--components/tabs/tabs.wxml-->
<view class="container">
  <view class="tabs">
    <view
      class="tab-item"
      wx:for="{{ tabs }}"
      wx:key="index"
      bindtap="handleTabChange"
      data-index="{{ index }}"
    >
      <view
        class="tab-label {{ currentTabIndex === index ? 'active-tab' : '' }}"
        >{{ item }}</view
      >
      <view class="divider" wx:if="{{ currentTabIndex === index }}"></view>
    </view>
  </view>

  <slot name="extend"></slot>

  <!-- 绑定两个事件监听 touchstart 和 touchend -->
  <view
    class="tab-panel"
    bind:touchstart="handleTouchStart"
    bind:touchend="handleTouchEnd"
  >
    <slot name="panel"></slot>
  </view>
</view>

# 1.2、实现事件处理函数 及 步骤

在 tabs 自定义组件 components/tabs/tabs.js 中实现两个事件处理函数

// components/tabs/tabs.js
Component({
  // 省略部分 .......

  // 组件的方法列表
  methods: {
    // 省略部分 ......

    // 以下是常规实现思路(实际开发中不会这么用)

    // 手指触摸动作开始,事件处理函数
    handleTouchStart(e) {
      console.log(e);
      // 1、数据绑定,记录触摸开始的 X 轴的位置
    },

    // 手指触摸动作结束,事件处理函数
    handleTouchEnd(e) {
      console.log(e);
      // 2、把结束时的 X 轴位置 - 触摸开始时的位置
      // 3、判断是往左划还是往右划
      // 4、做数据绑定,改变 currentTabIndex 的值
    },
  },
});

在 panel 区域划动后,控制台打印输出如下,查看 changedTouches 节点的信息

image-20230421013529319

注:

当我们不断划动时,在页面上触发事件会反复在控制台打印输出。这就是上面讲的这种思路不适合该场景的原因。

在小程序开发中,要避免频繁的页面 与 JS 做通讯的情况,这样会增加性能的开销,这种情况一旦发生就是双向的(即:在页面中通知了 JS,做了数据绑定,只要做了数据绑定,就会触发一个页面的渲染)就是说只要页面不断地划动,就会频繁在 JS 和 WXML 之间做交互,重复渲染。

这也是官方明确提到的 !尽量要避免这种情况的出现。尤其绑定的数据量比较大的时候,性能消耗就会非常大,小程序就会非常的卡顿。

因此,要做到避免小程序中频繁的数据绑定 和 事件通知 就显得尤为重要了。

# 2、通过 WXS 优化频繁划动切换带来的性能问题

TIP

  • WXS(WeiXin Script)是小程序的一套脚本语言,结合 WXML,可以构建出页面的结构。
  • WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。
  • WXS 代码可以编写在 wxml 文件中的 <wxs> 标签内,或以 .wxs 为后缀名的文件内。

详细查阅,微信小程序官方文档 - WXS 语法参考 (opens new window)

注:

微信小程序中,WXS 本身有很多限制,且它的使用场景比较单一。但当我们的业务场景适合使用 WXS 时,它的意义就非常的巨大。

  • 可解决在某些场景下频繁的数据绑定 以及 JS 和 页面性能通讯问题
  • 还可为页面提供一些工具方法来简化我们项目的开发

具体是什么我们先用起来再说

# 3、创建 WXS 文件

TIP

考虑到后续我们会复用 WXS ,我们会在项目的根目录中创建一个专门的目录 common 用存储项目中的公共类库 或 样式。

目录如下

icoding-com-course
├─ common
│ ├─ wxs
│ │ ├─ touchMove.wxs

注:

  • 在 WXS 中只支持 ES5 的语法
  • 不能在 WXS 中调用微信小程序提供的 API,如 wx. 开头的 API 是不能用的

通过前面分析知道,使用常规方式会导致页面频繁的跟 JS 文件通讯,引入 WXS 的目的,就是把 事件处理函数 这部分通讯逻辑,移到 WXS 中 -> 当在 WXS 中做完处理后,再选择性的把结果告诉 JS ,这样就可以节省掉很多不必要的通讯流程。

具体改进方法的流程如下

# 4、通过 WXS 改进后的实现方法

image-20230421083849564

通过 WXS 改进后的实现方法思路

  • 在 WXML 中(视图层)-> 做滑动事件监听 -> 等待用户触发滑动事件 ->
  • 然后将事件给到 WXS -> 在 WXS 中进行处理,经过处理后 -> 再发送到 JS 文件
  • 最后,在逻辑层最终实现数据绑定(setData)-> 最后在视图层实现标签页的切换效果

相对常规思路,现在的方式是多加了一层 WXS。好处是:WXML 和 WXS 的通讯是不需要经过中间层的,是直接在视图层做通讯,这样它的性能开销就非常低了。在 WXS 中处理完成后,再通知 JS 文件,不必要的通讯就可以在 WXS 内部消化了

  • 不必要的通讯指:如,手指划动时,肯定是要划动到一定距离后,才会去触发一个标签切换,这个距离的判断就会放到 WXS 中来进行。
  • 常规方法,是直接放到 JS 中去判断的,那么视图层的渲染 和 逻辑层的通讯时避免不了的,频繁的划动就会带来很大的性能消耗。有了 WXS 后,就能很好的节省这部分性能消耗了。

这就是 WXS 的应用场景之一了

# 5、在 WXS 文件中定义事件处理函数

将原来自定义组件中手指划动的事件处理函数,直接放到 WXS 中来,在 common/wxs/touchMove.wxs

// WXS 中 仅支持 ES5 的语法,需将原来 ES6 语法改写

// 定义全局触摸开始变量
var touchStartX;

// 手指触摸动作开始,事件处理函数
function handleTouchStart(e) {
  // console.log(e)
  // 1、数据绑定,记录触摸开始的 X 轴的位置
  touchStartX = e.changedTouches[0].clientX;
}

// 手指触摸动作结束,事件处理函数
function handleTouchEnd(e, ownerInstance) {
  // console.log(e)
  // 2、把结束时的 X 轴位置 - 触摸开始时的位置
  var touchEndX = e.changedTouches[0].clientX;
  // 负数:表示手指向左滑动了;正数:手指向右滑动了
  var distance = touchEndX - touchStartX;

  // 由于相减后的结果是一个不确定的数字,因此我们需要统一定义一个状态值,否则无法明确数字具体的含义
  // 约定状态值:-1 :后退(向右滑);0 :不动;1 :前进(向左滑动);

  // 3、判断是往左划还是往右划

  // 定义方向变量,默认不动为 0
  var direction = 0;
  // 向左滑动(前进),-80 约定为灵敏度的值(如果不设置稍微手指动一下,tab标签就会切换,这也是一个用户体验的优化)
  if (distance < 0 && distance < -80) {
    direction = 1;
  }
  // 向右滑动(后退)
  if (distance > 0 && distance > 80) {
    direction = -1;
  }
  // 如果以上两个条件都不满足时,direction 依然是 0 ,保持不滑动

  // 判断完成后,开始触发事件,如果不动 direction = 0 时,就不用触发事件
  if (direction !== 0) {
    // 在 WXS 中触发事件的方法有两种
    // 1、直接触发事件(只能在页面中使用WXS时才能用,这里不适用);
    // 2、直接调用引用该 WXS 的页面 或 自定义组件的方法(只适用第二种方法)
    ownerInstance.callMethod("handleTouchMove", { direction: direction });
  }

  // 4、做数据绑定,改变 currentTabIndex 的值
}

// 将以上定义好的函数暴露出去,外界才能调用
module.exports = {
  handleTouchStart: handleTouchStart,
  handleTouchEnd: handleTouchEnd,
};

在自定义组件 components/tabs/tabs.js 中定义 handleTouchMove 方法

// components/tabs/tabs.js
Component({
  // 部分省略 ......

  // 组件的方法列表
  methods: {
    // 部分省略 ......

    // 触摸结束后,WXS 需要调用的函数
    handleTouchMove(e) {
      console.log(e);
    },
  },
});

# 6、在自定义组件结构中 引入 WXS 文件

TIP

  • 在自定义组件中,导入 WXS 文件,并指定 当前 <wxs> 标签的模块名,必填字段
  • 将调用自定组件内部 JS 中的事件处理函数,改为 WXS 中的事件处理函数

components/tabs/tabs.wxml

<!--components/tabs/tabs.wxml-->

<!-- 在自定义组件中,导入 WXS 文件,并指定 当前 <wxs> 标签的模块名,必填字段。 -->
<wxs src="../../common/wxs/touchMove.wxs" module="touch"></wxs>

<view class="container">
  <!-- 部分省略 ...... -->

  <!-- 定义一个用于扩展的插槽 -->
  <slot name="extend"></slot>

  <!-- 标签页面板区域 -->
  <!-- <view class="tab-panel" bind:touchstart="handleTouchStart" bind:touchend="handleTouchEnd"> -->

  <!-- 将调用自定组件内部JS中的事件处理函数,改为 WXS 中的事件处理函数 -->
  <view
    class="tab-panel"
    bind:touchstart="{{ touch.handleTouchStart }}"
    bind:touchend="{{ touch.handleTouchEnd }}"
  >
    <!-- 对于内容、样式不确定内容,定义 slot 占位,具体的内容由组件的调用者决定 -->
    <!-- 标签页面板区域 插槽 -->
    <slot name="panel"></slot>
  </view>
</view>

手指滑动测试,是否调用成功

image-20230421060514771

# 7、做数据绑定,实现滑动 tab 标签切换

TIP

实现步骤

  • 取到的方向值
  • 需要将方向值 转换为 滑动切换 tab 标签页的下标索引
  • 判断越界的情况
  • 做数据绑定,触发切换事件,即可滑动实现切换
  • 优化 Bug:如果当前点击 tab 选项 时 已经选中的状态,就不再更新数据和执行其它的事件

components/tabs/tabs.js

// components/tabs/tabs.js
Component({
  options: {
    // 开启多插槽支持
    multipleSlots: true,
  },

  // 组件的属性列表(由页面或父组件中传递过来的属性)
  properties: {
    tabs: {
      type: Array,
      value: [],
    },
  },

  // 组件的初始数据
  data: {
    currentTabIndex: 0,
  },

  // 组件的方法列表
  methods: {
    // 点击 tab 切换事件处理函数
    handleTabChange(e) {
      // 获取当前点击 tab 选项的 index
      const index = e.currentTarget.dataset.index;

      // 优化 Bug:如果当前点击 tab 选项 时 已经选中的状态,就不再更新数据和执行其它的事件
      if (index === this.data.currentTabIndex) return;

      // 更新 data 中 currentTabIndex 值
      this.setData({
        currentTabIndex: index,
      });

      // 触发自定义事件 change ,并携带当前索引值 index
      this.triggerEvent("change", { index });
    },

    // 触摸结束后,WXS 需要调用的函数
    handleTouchMove(e) {
      console.log(e);

      // 取到的方向值为:-1,0,1
      const direction = e.direction;

      // 需要将方向值 转换为 滑动切换 tab 标签页的下标索引

      // 当前选中的 tab 标签索引值
      const currentTabIndex = this.data.currentTabIndex;
      // 滑动目标标签页索引值转换
      const targetTabIndex = currentTabIndex + direction;
      // 需要判断边界情况(索引不能为负数 或 大于 tab 标签项的长度)如果不满足条件,直接 return
      if (
        targetTabIndex < 0 ||
        targetTabIndex > this.properties.tabs.length - 1
      ) {
        return;
      }

      // 最后做数据绑定,改变 currentTabIndex 的值,实现 tab 切换
      // 为了减少重复代码,思考如何复用 handleTabChange(e) 方法呢
      // 通过模拟 const index = e.currentTarget.dataset.index 数据结构,达到方法复用的目的
      // 定义一个伪变量
      const customEvent = {
        currentTarget: {
          dataset: {
            index: targetTabIndex,
          },
        },
      };

      // 调用 tab 切换事件处理函数(减少重复代码的编写)
      this.handleTabChange(customEvent);
    },
  },
});

实现效果:手指滑动页面时,也可完成切换

GIF-2023-4-21-6-51-13

# 8、Bug 修复

TIP

无法在整个标签页 面板区域滑动切换标签的问题

components/tabs/tabs.wxss 中新增样式,让 tab-panel 占满剩余空间即可

/* components/tabs/tabs.wxss */
.container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.tab-panel {
  /* background-color: red; */
  flex: 1;
}

# 9、WXS 小总结

TIP

通过以上自定义组件的不断优化过程,大家应该能感受到 WXS 在使用上确实比较麻烦,且容易出错,但如果有一个符合它的使用场景时,就可以很好的帮助我们提升性能,在后续项目中遇到了对应的场景我们将再次使用。

有了本节的学习希望大家对 WXS 有一个全面完整的认识,其实在实际开发中也有很多开发者对 WXS 有很多的抱怨,认为 WXS 没什么用 !但相信大家学过这一节后,就不会有这样的想法了 。

我们可以讲它比较难用,并不是不好用,还是有本质的区别的。希望大家多多总结和练习 !

同时,希望大家在我们封装自定义组件的过程中,慢慢体会到软件工程师实践中的 “高类聚 低耦合” 的思想。

# 五、iconfont 字体图标库 - 自定义组件封装

TIP

在微信小程序实际项目开发中 icon 图标库也是高频使用的组件,为了更方便和高效的开发,我们也可以将 iconfont 图标库进行封装后在使用。

# 1、在小程序中引入 iconfont 字体图标

TIP

iconfont 官网 (opens new window)上下载或复制对应项目中用到的图标库的样式文件

image-20230422192408374

在项目的根目录中新建 iconfont 文件夹,在其中再创建 iconfont.wxss 文件

icoding-com-course
├─ iconfont
│ ├─ iconfont.wxss

将 iconfont 官方复制的 CSS 样式粘贴到 iconfont.wxss

@font-face {
  font-family: "iconfont"; /* Project id 3872007 */
  src: url("//at.alicdn.com/t/c/font_3872007_896y23hxpst.woff2?t=1682160697843")
      format("woff2"), url("//at.alicdn.com/t/c/font_3872007_896y23hxpst.woff?t=1682160697843")
      format("woff"),
    url("//at.alicdn.com/t/c/font_3872007_896y23hxpst.ttf?t=1682160697843")
      format("truetype");
}

.iconfont {
  font-family: "iconfont" !important;
  font-size: 16px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.icon-fenlei1:before {
  content: "\e71b";
}

/* 部分省略 ....... */

# 2、在页面中使用 iconfont 字体图标

pages/index/index.wxss 引入 iconfont.wxss 文件

/** pages/index/index.wxss **/
@import "../../iconfont/iconfont.wxss";

在页面 pages/index/index.wxml 中使用 iconfont 字体图标

<!--pages/index/index.wxml-->
<!-- 在页面中使用 iconfont 字体图标 -->
<view
  class="iconfont icon-quanjixixian"
  style="font-size: 150rpx; color:red"
></view>

# 3、自定义 iconfont 组件封装

新建 iconfont 自定义组件,创建自定义组件文件目录

icoding-com-course
├─ components
│ ├─ icon
│ │ ├─ icon.js
│ │ ├─ icon.json
│ │ ├─ icon.wxml
│ │ └─ icon.wxss

components/icon/icon.wxml 中定义 icon 自定义组件结构

<!--components/icon/icon.wxml-->
<view class="container">
  <view
    class="iconfont icon-{{ name }}"
    style="font-size: {{ size }}rpx; color:{{ color }};"
  >
  </view>
</view>

components/icon/icon.css 导入 iconfont 字体图标的样式

/* components/icon/icon.wxss */
@import "../../iconfont/iconfont.wxss";

components/icon/icon.js 中接收父组件中传递参数

// components/icon/icon.js
Component({
  // 组件的属性列表
  properties: {
    name: String,
    color: {
      type: String,
      value: "#7bd802",
    },
    size: {
      type: String,
      value: "30",
    },
  },
  // 组件的初始数据
  data: {},
  // 组件的方法列表
  methods: {},
});

# 4、使用封装好的 iconfont 自定义组件

TIP

封装好的 icon 字体图标库,可以在任意页面 或 其他自定义组件中使用,非常的方便、高效、灵活

components/tabs/tabs.json 中引用 icon 自定义组件

{
  "component": true,
  "usingComponents": {
    "i-icon": "../icon/icon"
  }
}

components/tabs/tabs.wxml 中使用 icon 自定义字体图标组件

<!--components/tabs/tabs.wxml-->
<!-- 使用 icon 自定义组件 -->
<i-icon name="waimai-" color="red" size="150"></i-icon>

# 六、自定义组件封装 - 最佳实践总结

TIP

深入浅出微信小程自定义组件的封装本质 以及 原则,情况下适合做自定义组件的封装。

# 1、自定义组件封装的本质

TIP

①、简化 Page 页面元素、逻辑、样式,提高代码可读性

实现难度:低;重要程度:低

②、隔离 Page 页面元素、逻辑、样式实现,提高可维护性

实现难度:中;重要程度:中

③、页面元素、逻辑、样式复用

实现难度:高;重要程度:高

注:

这里的实现难度、重要程度是相对的,是基于当下你所处的项目环境而言。

# 2、自定义组件封装的原则

TIP

  • ①、同时能实现封装三大本质时,请毫不犹豫进行封装
  • ②、同时能实现第二、第三点意义时,可以进行封装
  • ③、通常情况下如果能实现第三点意义,第二点也能实现,可以进行封装
  • ④、仅能实现第二点意义时,参考当前项目的时间、成本、交付压力,可以选择进行封装
  • ⑤、仅能实现第一点意义时,警惕过度设计;参考当前项目的时间、成本、交付压力,可以选择进行封装

# 3、自定义组件特性

TIP

这里借用 面向对象 的三大特性来描述非常的贴切,如果你是一个接触过面向对象概念的开发者,那么你会一下子理解到自定义组件的精髓。

# 3.1、封装

TIP

我们把原本放在 Page 中的元素,封装到了自定义组件中。同时,自定义组件还能具备逻辑处理和样式定义的能力,因为它同样支持 js 和 wxss 编写。

也就是说,理论上自定义组件就是一个可以独立完成某个功能的模块。通过封装自定义组件,我们可以实现某些功能的快速复用,减少重复代码实现,同时简化我们的 Page 页面结构。

# 3.2、继承

TIP

我们在实际工程实践中,有时候会碰到一种情况,就是多个不同的组件,它们有相同的属性和组件方法,但同时又有一些特有的属性和方法,这时候我们可以利用自定义组件的 behaviors (opens new window)机制,来实现属性和组件方法的复用。

# 3.3、多态

TIP

有些自定义组件,我们为了让组件更加通用和易于扩展,我们会实现自定义组件部分必要的页面内容,然后利用 插槽 Slot (opens new window)机制来实现让自定义组件的调用方决定要展示的内容。

# 4、自定义组件使用场景

TIP

是否使用自定义组件,有两个前提,当满足其中一个的时候,就要考虑使用自定义组件:

  • Page 页面结构复杂、冗长(可读性差,不利于维护和扩展)
  • 重复实现(同样的功能代码散布在项目的各个地方是工程实践中的巨坑,我们要尽量避免出现这种代码)

但是考虑归考虑,具体是不是要做自定义组件的封装我们还需要慎重考虑。因为在实际开发过程中,你可能会被工期、业务理解、技术水平等诸多因素影响导致你不会去使用自定义组件。

# 5、通用组件与业务组件

TIP

自定义组件根据使用场景的不同,大体分为 通用组件业务组件 两种。

通用组件: 就类似我们本章节实现的tabs,另外还有项目中常用的ButtonIcon 组件等。

通用组件的最大特点就是不和具体业务实现关联,它本身的独立性很强,自己就是一个相对完整的功能。我们只需要通过给组件传递参数或者插入内容到插槽就能实现在不同业务场景下的使用,通用组件因为对通用性要求比较高,所以在设计和实现的难度上也会比较考验开发者的设计能力和编码能力。

业务组件: 是相对通用性没那么高的自定义组件,通常这类组件会和具体业务实现关联。主要作用是在于简化复杂 Page 页面的页面结构,并且支持在特定业务场景下的复用。

# 6、自定义组件通信

TIP

有两点原则非常重要,一定要清楚

  • ①、页面或者父组件通过属性给自定义组件传递参数
  • ②、自定义组件通过触发自定义事件给页面或者父组件传递参数

这个与 Vue 组件通信模式是一样的

上次更新时间: 6/8/2023, 9:23:17 PM

大厂最新技术学习分享群

大厂最新技术学习分享群

微信扫一扫进群,获取资料

X