微信小程序如何封装一个可复用的时间筛选组件

以前写小程序统计页的时候,遇到过一个很小但很烦的需求:页面上要按时间筛选数据。

这个需求第一眼看起来没什么技术含量,无非就是两个 picker,一个开始日期,一个结束日期,然后点确定请求接口。但真正写起来会发现,它很容易在项目里变成一堆重复代码:

  • A 页面要筛选订单时间。
  • B 页面要筛选客户创建时间。
  • C 页面要筛选发卡统计时间。
  • 有的页面切出去再回来,还希望保留上次筛选条件。
  • 有的页面只想临时筛一下,不需要缓存。

如果每个页面都复制一份日期选择和校验逻辑,后面改一次样式或者改一次文案,就要到处找。这个时候就应该把它拆成一个组件。

但我不想把它写成那种“大而全筛选器”。它只做一件事:收集时间条件,然后把结果交给父页面。

我对这个组件的定位

这个组件不是接口请求组件,也不是统计组件。它只是一个“时间条件收集器”。

它应该具备这几个能力:

  • 能打开和关闭。
  • 能选择开始日期和结束日期。
  • 能校验开始日期不能大于结束日期。
  • 能点“查看所有”清空筛选。
  • 能把筛选结果抛给父页面。
  • 如果传了缓存 key,就帮页面记住这次筛选条件。

最后用起来应该尽量短:

<screen
  isShow="{{isShow}}"
  bind:submit="setScreen"
  existKey="statisticsScreen"
/>

这行代码里有三个关键信息:

  • isShow:父页面控制弹层是否显示。
  • bind:submit:组件点确定以后,把结果交给父页面。
  • existKey:可选,如果传了,就把筛选条件缓存起来。

我比较喜欢这个设计的原因是:父页面看一眼就知道这个筛选器怎么工作,不需要再去组件内部翻半天。

组件的模板

模板里没有什么花哨东西,就是一个遮罩层、一个表单、两个日期选择器、三个按钮。

<view class="mask" hidden="{{isShow}}" bind:tap="cancel" data-cancel="true">
  <view class="screen_box">
    <view class="screen_title">筛选</view>

    <form bindsubmit="define">
      <view class="screen_picke_box">
        <view class="screen_picke_title">
          <text decode="{{true}}">时&nbsp;间</text>
        </view>

        <picker mode="date" class="ipt" bindchange="getDate" data-key="0" name="startTime">
          <view class="picker">{{startTime}}</view>
        </picker>

        <view class="text">至</view>

        <picker mode="date" class="ipt" bindchange="getDate" data-key="1" name="endTime">
          <view class="picker">{{endTime}}</view>
        </picker>
      </view>

      <button class="look" bind:tap="look">查看所有</button>

      <view class="bt_box">
        <button class="cancel" data-cancel="true">取消</button>
        <button class="define" form-type="submit">确定</button>
      </view>
    </form>
  </view>
</view>

这里有个细节我觉得挺实用:组件里留了一个“查看所有”按钮。

很多筛选弹层只考虑“确定”,没考虑“撤销筛选”。但业务页面里用户经常会遇到这种情况:筛了一个时间段以后,想回到全部数据。如果没有这个按钮,就只能重新进入页面或者手动清空两个日期,体验很别扭。

组件内部怎么处理日期

组件内部先定义两个属性:

properties: {
  isShow: {
    type: Boolean,
    value: true
  },
  existKey: {
    type: String
  }
}

isShow 是控制弹层显示的,existKey 是决定是否缓存筛选条件的。

日期本身放在 data 里:

data: {
  startTime: "起止日期",
  endTime: "截止日期"
}

选择日期时,根据 data-key 判断改开始日期还是结束日期:

getDate({ detail, currentTarget }) {
  currentTarget.dataset.key == "1"
    ? this.setData({ endTime: detail.value })
    : this.setData({ startTime: detail.value });
}

这段写法很老派,但够直接。两个 picker 共用一个方法,不需要分别写 changeStartTimechangeEndTime

点确定时,组件只做三件事

确定按钮对应的是 define 方法:

define({ detail }) {
  let date = new Date(this.data.startTime) - new Date(this.data.endTime);

  if (date > 0) {
    wx.showToast({
      title: "起止日期不能大于截止日期",
      icon: "none"
    });
    return false;
  }

  this.triggerEvent("submit", detail);
  this.data.existKey && wx.setStorageSync(this.data.existKey, detail);
  this.setData({ isShow: true });
}

这段代码的重点不是日期校验,而是后面两行:

this.triggerEvent("submit", detail);
this.data.existKey && wx.setStorageSync(this.data.existKey, detail);

组件把结果通过 triggerEvent 抛出去,父页面想请求接口也好,想拼参数也好,都在父页面处理。

缓存也是可选的。传了 existKey 就缓存,不传就不缓存。这样它既能用在统计页,也能用在普通列表页。

我觉得这个点比封装一个漂亮弹层更重要:组件不要替页面做太多决定。

父页面怎么接住这个组件

父页面里先准备一个开关:

data: {
  isShow: true
}

点筛选按钮时打开弹层:

screen() {
  this.setData({ isShow: false });
}

组件点确定以后,父页面重新请求:

setScreen({ detail }) {
  this.requestList();
}

如果这个页面需要跨层级保留筛选条件,比如统计页一层一层点进去,那父页面请求时可以从缓存里取:

requestList() {
  const screen = wx.getStorageSync("statisticsScreen");
  const params = screen
    ? { roleId: this.data.roleId, ...screen.value }
    : { roleId: this.data.roleId };

  this.$http.get("/getTeamInfo.do", params).then((res) => {
    this.setData({
      list: res.data.data
    });
  });
}

这样做的好处是:筛选条件不依赖某一个页面实例。用户从总统计点到团队统计,再点到员工统计,只要还在这个业务流里,筛选条件都可以继续生效。

这个场景下,本地缓存不是为了“永久保存用户偏好”,而是为了保存一次业务操作的上下文。

清空筛选别忘了通知父页面

“查看所有”按钮其实也很重要:

look() {
  this.setData({
    isShow: true,
    startTime: "起止日期",
    endTime: "截止日期"
  });

  this.data.existKey && wx.removeStorageSync(this.data.existKey);
  this.triggerEvent("submit", null);
}

这里做了三件事:

  • 重置组件内部显示。
  • 删除本地缓存。
  • 通知父页面重新请求全部数据。

很多筛选组件只把“筛选”当成事件,却忘了“取消筛选”也是事件。结果页面上的数据还停留在旧条件里,用户看着会很迷糊。

我现在会改掉的几个地方

这段旧代码能用,但如果现在让我再写一遍,我会改几个细节。

第一,startTimeendTime 不要同时承担“展示文案”和“真实日期”两个职责。

旧代码里:

data: {
  startTime: "起止日期",
  endTime: "截止日期"
}

这样写很省事,但 new Date("起止日期") 这种东西读起来不舒服。更干净的做法是把展示文案和真实值拆开:

data: {
  startDate: "",
  endDate: ""
}

模板里显示时再兜底:

<view class="picker">{{startDate || '起止日期'}}</view>
<view class="picker">{{endDate || '截止日期'}}</view>

第二,校验时应该先判断两个日期是否都存在:

if (this.data.startDate && this.data.endDate) {
  const start = new Date(this.data.startDate).getTime();
  const end = new Date(this.data.endDate).getTime();

  if (start > end) {
    wx.showToast({
      title: "开始日期不能大于结束日期",
      icon: "none"
    });
    return;
  }
}

第三,事件名可以更语义化一点。比如不叫 submit,叫 change 或者 confirm

如果是表单弹层,我更倾向于:

  • confirm:用户点确定。
  • reset:用户点查看所有。
  • close:用户只是关闭弹层。

这样父页面接事件时会更清楚。

一版更干净的写法

如果现在重新封装,我会把核心逻辑写成这样:

Component({
  properties: {
    visible: {
      type: Boolean,
      value: false
    },
    storageKey: {
      type: String,
      value: ""
    }
  },

  data: {
    startDate: "",
    endDate: ""
  },

  methods: {
    onDateChange({ detail, currentTarget }) {
      const field = currentTarget.dataset.field;
      this.setData({
        [field]: detail.value
      });
    },

    confirm() {
      const { startDate, endDate } = this.data;

      if (startDate && endDate && new Date(startDate) > new Date(endDate)) {
        wx.showToast({
          title: "开始日期不能大于结束日期",
          icon: "none"
        });
        return;
      }

      const value = {
        startTime: startDate,
        endTime: endDate
      };

      if (this.data.storageKey) {
        wx.setStorageSync(this.data.storageKey, value);
      }

      this.triggerEvent("confirm", value);
    },

    reset() {
      this.setData({
        startDate: "",
        endDate: ""
      });

      if (this.data.storageKey) {
        wx.removeStorageSync(this.data.storageKey);
      }

      this.triggerEvent("reset");
    }
  }
});

父页面使用时:

<time-filter
  visible="{{filterVisible}}"
  storageKey="statisticsScreen"
  bind:confirm="onFilterConfirm"
  bind:reset="onFilterReset"
/>

父页面逻辑:

onFilterConfirm({ detail }) {
  this.setData({
    filterVisible: false,
    filter: detail
  });

  this.requestList();
},

onFilterReset() {
  this.setData({
    filterVisible: false,
    filter: {}
  });

  this.requestList();
}

这样组件就更像一个标准控件:

  • 它不关心接口。
  • 它不关心列表。
  • 它不关心当前页面是订单、客户还是统计。
  • 它只负责把时间条件整理好,然后告诉父页面。

这个组件真正复用的不是 UI

很多人一说组件复用,就容易想到“样式复用”。但这个时间筛选组件真正复用的不是那两个日期选择器,而是一套交互规则:

  • 用户怎么打开筛选。
  • 用户怎么确认筛选。
  • 用户怎么清空筛选。
  • 非法日期怎么提示。
  • 筛选条件要不要跨页面保留。

这些东西如果散在每个页面里,短期看没问题,长期一定会变乱。

所以我现在看这种旧代码,反而觉得它最有价值的地方不是写法多高级,而是它已经开始把“页面里的重复业务动作”拆成组件了。

在真实项目里,能把小功能拆清楚,比一上来写复杂架构更重要。

小结

封装一个可复用的时间筛选组件,我会抓住这几个原则:

  • 组件只收集时间条件,不直接请求业务接口。
  • 通过事件把结果交给父页面。
  • 缓存能力做成可选,不强塞给所有页面。
  • “查看所有”也要当成一个正式事件处理。
  • 展示文案和真实日期值最好分开。

最后再说一句我自己的判断:一个组件好不好,不是看它能配置多少参数,而是看你下次换个页面用它时,会不会忍不住进去改源码。

不用改源码就能复用,才算真的封装好了。

posted @ 2026-06-22 14:20  万物皆object  阅读(17)  评论(0)    收藏  举报