微信小程序组件库开发记录

38 篇文章 2 订阅
18 篇文章 1 订阅

背景

业界已经有很多功能强大,成熟的微信小程序组件库,比如vant,为什么自己还要搞一套微信小程序组件库出来?

  • 市面上的组件库功能虽然很强大,很成熟,但并不能百分百完全满足我们的需求。比如我们现在使用的vant组件库,实际上开发中,我们需要用到瀑布流密码键盘悬浮按钮回到顶部等组件,这些组件都是vant没有的组件。所以我们需要将这些组件封装起来,发布到 npm 或者私服上面,方便下次使用。
  • 技术能力的提升。其实开发这套小程序组件库也算是造轮子吧。只是在轮子上面添加一些属于自己业务特点的东西。一套流程下来,你会发现学到了不少的东西,自身能力也得到了提升。比如代码规范,git 工作流,工程化等,这些东西都是平时实际开发中很难学到的东西,毕竟实际开发中已经有固定的模板或者脚手架等工具,只需要一行命令就可以把所有环境搭建好,你只需要在上面开发即可,根本就不想要理会其他东西。
  • 提升代码阅读能力。在开始这套组件库开发之前,我去阅读了vant组件库的代码,参考了vant组件库的代码架构,然后根据自己的实际开发工作能力,制定了一套自己的组件库架构。每一个组件开发完毕之后,我会去阅读vant的组件代码,看一下别人的设计思想跟我的设计思想有什么不一样,有什么优缺点,然后改进自己的代码。通过这种方式,我对阅读代码能力有了很大的提升,并且学到了不少的设计思想。
  • 热情和情怀,这一点很重要。这套组件库是利用我下班时间还有周末的时间开发出来的。每天下班都会花费 1-2 小时去搞这个组件库。这也算是我对前端的一种热爱吧,否则也不会坚持下来。热情和情怀就是我坚持搞这套组件库的动力。好在这套组件库搞下来之后,收到不少人的喜爱,有人愿意去看,愿意去使用,虽然不多,但也是对我的一种鼓舞。

前言

这篇文章主要是记录技术选型,环境搭建,组件开发常用技巧还有组件单元测试。希望可以帮助到有需要的同学吧

技术选型

在开始前,我阅读过vant的源码,它是用typescript+less+gulp的。并且内部封装了一个vantComponent函数,感觉这对新手来说阅读起来不太友好。但是我对typescript编写小程序并不是很熟悉,所以我还是选择了javascript这个原汁原味的语言。css 预处理方方面,由于平时工作开发中我都是使用scss的,所以我选择了scss来编写 css。所以我的最终技术选型是javascript+scss+gulp

环境搭建

这套组件库都是基于微信原生的语法来写,并没有借助第三方框架,比如mpvueuni-app等这些框架。所以我们甚至可以连环境都不用搭建就可以进行开发了,只需要新建一个文件夹,然后在文件夹下面新建.wxml.wxss.js.json这些文件就可以进行开发了。但是这种开发模式效率太慢了,缺点如下:

  • 编写 css 的时候,开发过微信小程序的同学应该都知道wxss其实跟 web 的css差不多,并不支持嵌套语法,函数,变量,循环等功能。
  • 开发一个新的组件都要重复同样的工作,就是新建文件夹,然后新建对应的文件,初始化每个文件的模板。
  • 微信小程序对包的大小是有要求的。所以我们需要对文件进行压缩,减少包的体积。如果不进行代码压缩,这将会对使用者来说是个极差的体验。
  • 一般来说,我们有一个编写代码的packages目录,还有一个dist或者lib目录的,这是将来发布到 npm 上会使用到的,同时还有一个小程序演示目录。当我们编写完一个组件的时候,需要借助其他工具拷贝到小程序演示目录下,否则你需要自己手动 cv 一下拷贝过去,这将会大大降低我们的开发效率。

针对上面的缺点,我们需要搭建一套开发环境出来解决上面的问题。

安装 gulp

npm i gulp -D

scss编译为wxss

  • 安装依赖
npm i gulp-sass gulp-clean-css gulp-rename gulp-insert node-sass sass -D
  • 编译
const { src, dest } = require("gulp");
const sass = require("gulp-sass");
const cssmin = require("gulp-clean-css");
const jsmin = require("gulp-uglify-es").default;
const rename = require("gulp-rename");
const insert = require("gulp-insert");
const path = require("path");

const buildWxss = (srcPath, distPath) => () =>
  src(srcPath)
    //  编译scss
    .pipe(sass().on("error", sass.logError))
    // 压缩
    .pipe(cssmin())
    .pipe(
      // 插入内容
      insert.transform((contents, file) => {
        const commonScssPath = `packages${path.sep}common`;
        if (!file.path.includes(commonScssPath)) {
          const relativePath = "../common/base.wxss";
          contents = `@import '${relativePath}';${contents}`;
        }
        return contents;
      })
    )
    .pipe(
      // 将.scss后缀名改成.wxss
      rename((srcPath) => {
        srcPath.extname = ".wxss";
      })
    )
    // 输出到指定目录
    .pipe(dest(distPath));

压缩wxmljsjson文件和图片

  • 安装依赖
npm i gulp-htmlmin gulp-uglify-es gulp-jsonminify gulp-imagemin -D
  • 压缩
const { src, dest } = require("gulp");
const wxmlmin = require("gulp-htmlmin");
const jsmin = require("gulp-uglify-es").default;
const jsonmin = require("gulp-jsonminify");
const imagemin = require("gulp-imagemin");

// 压缩wxml
const buildWxml = (srcPath, distPath) => () =>
  src(srcPath)
    .pipe(
      wxmlmin({
        removeComments: true,
        keepClosingSlash: true,
        caseSensitive: true,
        collapseWhitespace: true,
      })
    )
    .pipe(dest(distPath));
// 压缩js
const buildJs = (srcPath, distPath) => () =>
  src(srcPath).pipe(jsmin()).pipe(dest(distPath));
// 压缩json
const buildJson = (srcPath, distPath) => () =>
  src(srcPath).pipe(jsonmin()).pipe(dest(distPath));
// 压缩图片
const buildImage = (srcPath, distPath) => () =>
  src(srcPath).pipe(imagemin()).pipe(dest(distPath));

拷贝文件到另一个目录

const { src, dest, parallel } = require("gulp");
const copy = (srcPath, distPath, ext) => () => {
  return src(`${srcPath}/*.${ext}`).pipe(dest(distPath));
};
const copyStatic = (srcPath, distPath) => {
  return parallel(
    copy(srcPath, distPath, "wxml"),
    copy(srcPath, distPath, "wxs"),
    copy(srcPath, distPath, "json"),
    copy(srcPath, distPath, "js"),
    copy(srcPath, distPath, "png")
  );
};

删除目录

  • 安装依赖
npm i del -D
  • 删除
const del = require("del");
const clean = (cleanPath) => () =>
  del(cleanPath, {
    force: true,
  });

整合

const { series, parallel, watch } = require("gulp");

const path = require("path");

const distPath = path.resolve(__dirname, "../dist");

const examplePath = path.resolve(__dirname, "../examples/dist");

let packagesPath = path.resolve(__dirname, "../packages");

packagesPath = `${packagesPath}/**`;

module.exports = {
  // 打包
  build: series(
    clean(distPath),
    parallel(
      buildWxss(`${packagesPath}/*.scss`, distPath),
      buildWxml(`${packagesPath}/*.wxml`, distPath),
      buildImage(`${packagesPath}/*.png`, distPath),
      buildJson(`${packagesPath}/*.json`, distPath),
      buildJs(`${packagesPath}/*.js`, distPath),
      buildWxs(`${packagesPath}/*.wxs`, distPath)
    )
  ),
  //   开发环境,拷贝packages目录下面的组件到小程序演示目录下
  dev: series(
    clean(examplePath),
    parallel(
      buildWxss(`${packagesPath}/*.scss`, examplePath),
      copyStatic(packagesPath, examplePath)
    )
  ),
  //   监听packages目录文件变化,拷贝变化文件到小程序演示目录下
  watch: parallel(() => {
    watch(
      "../packages/**/*.scss",
      buildWxss(`${packagesPath}/*.scss`, examplePath)
    );
    watch("../packages/**/*.wxml", copy(packagesPath, examplePath, "wxml"));
    watch("../packages/**/*.wxs", copy(packagesPath, examplePath, "wxs"));
    watch("../packages/**/*.json", copy(packagesPath, examplePath, "json"));
    watch("../packages/**/*.js", copy(packagesPath, examplePath, "js"));
    watch("../packages/**/*.png", copy(packagesPath, examplePath, "png"));
  }),
};

package.json新增如下 script 脚本命令行

  "scripts": {
    "dev": "gulp -f build/index.js dev",
    "build": "gulp -f build/index.js build",
    "watch": "gulp -f build/index.js watch",
  }

创建组件模板

我们使用 node 命令行来代替手动创建组件文件和模板

const fs = require("fs");
const path = require("path");
// 模板
const template = require("./template.js");
const argv = process.argv;
// 获取创建的组件名
const componentName = argv[2];
// 将组件名转化为-连接
const componentNameLine = componentName
  .replace(/([A-Z])/g, "-$1")
  .toLowerCase()
  .substring(1);
// 组件开发目录
const packagesPath = path.resolve(__dirname, "../packages");
// 组件js模板
const compJsTemplate = template.compJsTemplate();
// 组件json模板
const compJsonTemplate = template.compJsonTemplate();
// 组件scss模板
const compScssTemplate = template.compScssTemplate(componentNameLine);
// 组件wxml模板
const compWxmlTemplate = template.compWxmlTemplate(componentNameLine);
// 创建文件夹
function createDir(pathSrc) {
  try {
    fs.statSync(pathSrc);
    console.log(`${componentName} 文件夹已经存在`);
    return false;
  } catch (error) {
    fs.mkdirSync(pathSrc);
    return true;
  }
}
// 创建组件模板
function createPackagesFile(pathSrc) {
  try {
    const wxml = path.resolve(pathSrc, "./index.wxml");
    const json = path.resolve(pathSrc, "./index.json");
    const js = path.resolve(pathSrc, "./index.js");
    const scss = path.resolve(pathSrc, "./index.scss");
    fs.writeFileSync(wxml, compWxmlTemplate);
    fs.writeFileSync(json, compJsonTemplate);
    fs.writeFileSync(js, compJsTemplate);
    fs.writeFileSync(scss, compScssTemplate);
    return true;
  } catch (error) {
    console.log("创建文件失败");
    return false;
  }
}

function createPackageComponent() {
  const pathSrc = path.resolve(packagesPath, componentName);
  const result = utils.createDir(pathSrc);
  if (result) {
    const flag = utils.createPackagesFile(pathSrc);
  }
}

createPackageComponent();

package.json新增如下 script 脚本命令行

  "scripts": {
    "add": "node ./build/createComponent.js"
  },

使用

npm run add button

开发技巧

微信小程序给我们提供了很多的组件,api 等强大的功能,但是实际上在组件库开发的过程中,来来去去都是那几样东西。掌握下面组件开发常用的技能,基本上能够开发出 99%的组件了。

properties 父组件给子组件传递数据

properties是用来父子组件用来进行通讯的。这个跟vue的 props 十分相似。主要有下面四个参数:

  • type:定义数据的类型,只能是单个类型
  • optionalTypes:定义数据的类型,当数据有可能是Boolean或者Number等多种类型时,使用该字段
  • value:初始值。切记是value,可能受vue的影响,我经常会写成default
  • observer:值变化回调,可以是一个函数或者字符串。如果是字符串,就会调用methods下面的同名函数。但是现在不推荐使用这个字段了,而是推荐使用Component构造器的observers,这个功能和性能会更好

代码示例:

Component({
  properties: {
    // 简写
    disabled: Boolean,
    block: {
      type: Boolean,
      value: false,
    },
    iconSize: {
      optionalTypes: [String, Number],
    },
    plain: {
      type: Boolean,
      value: false,
      observer: "setColor",
    },
  },
  methods: {
    setColor() {},
  },
});

behaviors 公共行为

behaviors实际上是用来定义行为的。通常来说,如果多个组件存在相同的行为,那么就可以将这些公共行为提取出来,封装成behaviors,这样多个组件就可以共享了。behaviors的写法跟Component构造器的写法实际上是一样的。熟悉vue开发的同学应该知道这其实就是跟mixins的功能一样的。而且微信小程序中内置了三个form表单组件的behaviors,这三个behaviors都是用来开发表单组件的。详情可查看这里

代码示例:

// behaviors/button.js
const ButtonBehavior = Behavior({
  properties: {
    // 标识符
    id: String,
    // 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文
    lang: String,
    // 客服消息子商户
    businessId: Number,
    // 会话来源
    sessionFrom: String,
    // 会话内消息卡片标题
    sendMessageTitle: String,
    // 会话内消息卡片点击跳转小程序路径
    sendMessagePath: String,
    // 当前分享路径
    sendMessageImg: String,
    // 显示会话内消息卡片
    showMessageCard: Boolean,
    // 打开 APP 时,向 APP 传递的参数
    appParameter: String,
    // 无障碍访问
    ariaLabel: String,
  },
});

export default ButtonBehavior;

// Button/index.js
import ButtonBehavior from "behaviors/button";

Component({
  behaviors: [ButtonBehavior, "wx://form-field-button"],
});

options

options主要用到的有 2 个参数,分别如下:

  • addGlobalClass:
    • true:页面的样式会影响到自定义组件组件内部的样式,但是自定义组件样式不能影响到页面的样式。好处就是页面可以很方便的改写自定义组件中的样式。但是自定义组件中的样式并不能影响到在该组件内部使用的自定义样式,需要借助externalClasses字段,下面会讲到的
    • false:开启样式隔离,组件和页面样式互不影响
    • 关于样式隔离,详情可以看这里
  • multipleSlots:当wxml中需要用到多个slot插槽的时候,必须将这个属性置位true,不然插槽不生效。只有一个slot插槽的时候可不设置。

代码示例:

Component({
  options: {
    addGlobalClass: true,
    multipleSlots: true,
  },
});

externalClasses 外部样式类

外部样式类,可指定那些类受页面影响,但是由于外部样式类和普通样式类的优先级没有定义,所以一般来说都需要在外部样式类中添加!important。这个字段在组件开启了样式隔离或者在自定义组件中使用自定义组件,提供给外部修改组件内部样式的一种方法。详情可查看这里

代码示例:

Button 组件

<button class="custom-class">自定义组件</button>
Component({
  externalClasses: ["custom-class"],
});

Page 页面

<lin-button custom-class="button-class" />
.button-class {
  color: red;
}

relations 定义组件之间的关系

relations是用来定义组件与组件之间的关系的,比如父子关系,祖孙关系等。说白了就是类似 html 中ulli这种关系吧。通常用来作为组件之间的通信方式,常见于checkboxcheckbox-group这种组合关系的组件中。组件间的关系用的最多的就是ancestordescendant祖孙关系,需要特别注意的是必须在两个组件定义中都加入 relations 定义,否则不会生效。组件与组件之间的关系关联可使用组件路径或者behaviors。其实这里有点类似vue中的$parent$children。详情可查看这里

代码示例:

使用路径关联组件关系

// checkbox组件
Component({
  relations: {
    "../CheckboxGroup/index": {
      type: "ancestor",
      linked(parent) {
        // parent是checkbox-group组件的实例
        this.parent = parent;
      },
      unlinked() {
        // 当关系脱离页面节点树时清空
        this.parent = null;
      },
    },
  },
});

// checkbox-group组件
Component({
  relations: {
    "../Checkbox/index": {
      type: "descendant",
      linked(child) {
        // children为checkbox组件的实例,孩子节点插入到checkbox-group组件时会调用linke生命周期函数
        this.children = this.children || [];
        this.children.push(child);
      },
      unlinked(child) {
        // 当孩子节点树移除的时候需要移除对应的实例
        this.children = (this.children || []).filter((it) => it !== child);
      },
    },
  },
});

使用behaviors关联组件关系

// behaviors/form-controls.js
export default Behavior({
  methods: {
    // 调用FormItem组件的onChange事件
    triggerParentChange(data) {
      if (this.parent) {
        this.parent.onChange(data);
      }
    },
    // 调用FormItem组件的onBlur事件
    triggerParentBlur(data) {
      if (this.parent) {
        this.parent.onBlur(data);
      }
    },
  },
});

// FormItem组件
import FormControls from "behaviors/form-controls";
Component({
  relations: {
    // 关联有FormControls这个behaviors的组件
    FormControls: {
      type: "descendant",
      target: FormControls,
    },
  },
});

// Field组件
import FormControls from "behaviors/form-controls";
Component({
  behaviors: ["wx://form-field", FormControls],
  relations: {
    "../FormItem/index": {
      type: "ancestor",
      linked(parent) {
        this.parent = parent;
      },
      unlinked() {
        this.parent = null;
      },
    },
  },
});

组件生命周期

组件声明周期一共包含 6 个,可直接写在Component构造器的第一级参数中。也可以在lifetimes字段中声明(推荐这种,优先级最高)。

  • created:this.data 数据初始化阶段,此时不能调用setData,该生命周期适合给this添加一个自定义属性。
  • attached:组件完全初始化,进入到页面节点树后,该生命周期会被触发。绝大多数初始化工作可在这个阶段执行
  • ready:组件视图层布局完成,此时可获取元素的一些信息,比如宽高
  • moved:组件实例被移动到节点树的另一个位置时触发
  • detached:组件从页面节点树中移除时触发
  • error:组件方法抛出错误时执行,可用于错误收集(但是一般会在 App 中进行收集)

组件页面生命周期

组件页面生命周期包含三个,在pageLifetimes字段中声明:

  • show:组件所在页面显示是触发
  • hide:组件所在页面隐藏时触发
  • resize:组件所在页面尺寸发生变化时执行(感觉没多大用处)

兼容性处理

微信小程序是依赖于基础库的,随着小程序的功能不断增加,旧版本的基础库并不支持新功能。所以有些功能需要判断一下当前基础库是否支持该功能。比如小程序当前最低基础库版本是1.9.0,但是现在需要使用wx.previewMedia预览图片和视频,该功能要求的最低基础库版本是2.12.0,对于低于2.12.0的用户是不能使用这个功能的,我们需要给出适当提示给用户。小程序 api 或者组件有些是需要判断基础库的版本号的,主要有三种:

  • 对比版本号
function compareVersion(v1, v2) {
  v1 = v1.split(".");
  v2 = v2.split(".");
  const len = Math.max(v1.length, v2.length);

  while (v1.length < len) {
    v1.push("0");
  }
  while (v2.length < len) {
    v2.push("0");
  }

  for (let i = 0; i < len; i++) {
    const num1 = parseInt(v1[i], 10);
    const num2 = parseInt(v2[i], 10);

    if (num1 > num2) {
      return 1;
    }
    if (num1 < num2) {
      return -1;
    }
  }

  return 0;
}

// 判断能否使用wx.previewMedia这个api
function canIUsePreviewMedia() {
  const system = wx.getSystemInfoSync();
  return compareVersion(system.SDKVersion, "2.12.0") >= 0;
}
  • 判断 api 是否存在
if (wx.previewMedia) {
  wx.previewMedia({
    // ...
  });
} else {
  // 如果希望用户在最新版本的客户端上体验您的小程序,可以这样子提示
  wx.showModal({
    title: "提示",
    content: "当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。",
  });
}
  • wx.canIUse
// api
if (wx.canIUse("previewMedia.success.cancel")) {
  wx.previewMedia({
    // ...
  });
}

// 组件
if (wx.canIUse("cover-view")) {
  // ...
}

查询元素或者视图窗口信息

不少组件都是需要获取元素或者视图窗口的位置和大小信息,可以使用wx.createSelectorQuery进行查询,但是自定义组件中需要使用this.createSelectorQuery()来代替。考虑到很多组件需要用到,所以统一封装一下,方便后期使用或者修改

/**
 * 查询单个元素信息
 * @param {*} context 查询的上下文,在自定义组件中使用直接传入this即可
 * @param {*} element 元素选择器
 * @returns
 */
function getRect(context, element) {
  return new Promise((resolve) => {
    wx.createSelectorQuery()
      .in(context)
      .select(element)
      .boundingClientRect(resolve)
      .exec();
  });
}

/**
 * 查询所有元素信息
 * @param {*} context 查询的上下文,在自定义组件中使用直接传入this即可
 * @param {*} element 元素选择器
 * @returns
 */
function getAllRect(context, element) {
  return new Promise((resolve) => {
    wx.createSelectorQuery()
      .in(context)
      .selectAll(element)
      .boundingClientRect()
      .exec((rect = []) => resolve(rect[0]));
  });
}

/**
 * 查询视图窗口信息
 * @param {*} context 查询的上下文,在自定义组件中使用直接传入this即可
 * @returns
 */
function getViewPort(context) {
  return new Promise((resolve) => {
    wx.createSelectorQuery()
      .in(context)
      .selectViewport()
      .scrollOffset(resolve)
      .exec();
  });
}

js 调用组件

熟悉vue开发的同学应该知道,像element-ui中的一些组件如message,可以直接在 js 中通过Message()来调用的,然后组件的元素就会被插入到文档当中。小程序可以实现类似的效果,但是因为小程序限制得原因,我们需要在使用到 js 组件中的页面中插入自定义组件。

代码示例:

Toast 组件

<view wx:if='{{show}}>{{message}}</view>
Component({
  properties: {
    show: Boolean,
    message: String,
  },
});

toast.js

// 默认配置
const defaultOptions = {
  // 是否显示
  show: true,
  // 自定义选择器
  selector: "#lin-toast",
};

// 获取上下文
function getContext() {
  const pages = getCurrentPages();
  return pages[pages.length - 1];
}

function Toast(options) {
  // 合并配置
  options = { ...defaultOptions, ...options };
  // 获取上下文
  const context = options.context || getContext();
  // 获取组件实例
  const toast = context.selectComponent(options.selector);
  if (!toast) {
    console.warn("未找到 lin-toast 节点,请确认 selector 及 context 是否正确");
    return;
  }
  // 删除上下文和自定义选择器
  delete options.context;
  delete options.selector;
  // 关闭toast
  toast.clear = () => {
    toast.setData({ show: false });
  };
  // 设置数据
  toast.setData(options);
  return toast;
}

Page 页面

<lin-toast id="lin-toast" />
import Toast from "/dist/Toast/toast.js";

Page({
  onClick1() {
    Toast({
      message: "提示内容",
    });
  },
});

关于 properties 和 setData

熟悉vue或者react开发的同学应该都知道,props数据都是单向流数据,并不适合直接在组件内部去修改他们,而是应该在父组件中修改这些传入的props数据,否则会破坏单向数据流的原则,导致组件内部和父组件的数据不一致,从而产生报错。但是在小程序中,可以直接使用setData修改properties中的数据,而且不报错,但是我并不推荐直接在组件内部使用setData修改properties中的数据,因为properties的数据是从父组件中传递给组件的,是一种单向数据流。我更为推荐的是在组件内部调用this.triggerEvent方法把事件派发出去,然后在父组件接收派发出来的事件,然后再去修改properties中的数据,这样子就不会破坏单向数据流的原则了,虽然这种做法在某些场景下可能会比较麻烦,但是却能避免不少的 bug(这个我深有体会)

双向数据绑定

小程序从2.9.3开始支持双向数据绑定,通过model前缀实现的

  • 简单是数据绑定

代码示例:

<input model:value="{{value}}" />

注意:目前双向数据绑定只支持绑定单一一个字段,不能说是多路径的。如<input model:value="{{ a.b }}" />,这种是非法的。

  • 在自定义组件中传递双向绑定

代码示例:

Field 组件

<input model:value="{{myValue}}" />
Component({
  properties: {
    myValue: String,
  },
});

Page 页面

<Field model:my-value="{{pageValue}}" />

在组件内部还可以使用this.setData({myValue:'张三'})来触发双向数据绑定的更新

注意:我并不推荐在自定义组件中传递双向绑定。一是要求的版本号太高,二是上面所说的会破坏单向数据流的原则,可能会导致一些不可预知的 bug

获取当前页面的上下文

可以通过全局 apigetCurrentPages获取页面的栈。返回的是一个数组,数组第一个元素为首页,最后一个元素为当前页。需要注意的是,页面栈只能读,不能改,否则会导致错误,并且也不能在App.onLaunch中调用,此时page还没生成。

代码示例:

// 获取上下文
function getContext() {
  const pages = getCurrentPages();
  return pages[pages.length - 1];
}

那么获取当前页面上下文具体有什么用或者有什么场景需要呢。比方说现在有一个backtop组件,需要监听页面的滚动事件,当页面滚动到一定距离的时候,组件显示出来,点击组件回到页面顶部。这个需求很简单,唯一要解决的是在自定义组件中如何监听页面的滚动,Component构造器是没有onPageScroll事件的,只有Page构造器有onPageScroll事件。这个时候我们就需要获取到当前页面的上下文,然后改写当前页的onPageScroll事件,把我们自定义组件监听页面滚动的事件添加进去。我们可以抽离出一个Behavior,这样就可以在需要监听页面滚动的自定义组件中使用了。

代码示例:

// 页面滚动事件监听
function onPageScroll(event) {
  // 获取绑定在该页面上监听页面滚动的事件数组
  const { linPageScroll = [] } = getCurrentPage();

  linPageScroll.forEach((scroller) => {
    if (typeof scroller === "function") {
      scroller(event);
    }
  });
}

// scroller是自定义组件中处理页面滚动行为的函数
const pageScrollBehavior = (scroller) =>
  Behavior({
    // 组件插入到页面节点树时触发
    attached() {
      const page = getCurrentPage();
      // 由于组件内部是不能监听到页面的滚动行为事件,所以需要将组件的滚动行为事件存储在页面实例当中,当页面滚动行为触发的时候,就调用每个组件的滚动行为事件
      if (Array.isArray(page.linPageScroll)) {
        page.linPageScroll.push(scroller.bind(this));
      } else {
        // 页面已经定义了onPageScroll方法,此时需要改写onPageScroll方法
        page.linPageScroll =
          typeof page.onPageScroll === "function"
            ? [page.onPageScroll.bind(page), scroller.bind(this)]
            : [scroller.bind(this)];
      }

      page.onPageScroll = onPageScroll;
    },
    // 组件在页面中移除的时候触发
    detached() {
      const page = getCurrentPage();
      // 删除该组件绑定的滚动行为事件
      page.linPageScroll = (page.linPageScroll || []).filter(
        (item) => item !== scroller
      );
    },
  });

动画

组件库中的不少组件都是需要动画的。微信小程序在1.9.6的基础库版本中提供了wx.createAnimation方法,通过创建一个动画实例animation,调用实例的方法来描述动画,详情查看这里。在2.9.0的基础版本库中提供了this.animate,通过使用CSS 渐变CSS 动画来创建简易的界面动画,详情查看这里。但是wx.createAnimation这个方法并不好用,官方现在都推荐使用this.animate来创建动画了。this.animate需要的基础库版本号太高,存在兼容性问题。所以我参考了vue的过渡动画,实现了一个动画组件。大概思路如下:

  • 进入(显示)有 2 个状态,分别是enterenter-toenter包含类名${name}-enter ${name}-enter-activeenter-to包含类名${name}-enter-to ${name}-enter-active
  • 离开(隐藏)有 2 个状态,分别是leaveleave-toleave包含类名${name}-leave ${name}-leave-activeleave-to包含类名${name}-leave-to ${name}-leave-active
  • 进入过渡:借助Promise的链式调用和异步特点,先插入enter状态的类名,等待一定时间,移除enter状态的类名,插入enter-to状态的类名。这里需要注意的是,如果你是使用wx:if='{{false}}'或者display:none来控制元素的隐藏,需要先把元素显示(wx:if='{{true}}'或者display:bolck)出来在进行过渡动画
  • 离开过渡:同样是借助Promise,先插入leave状态的类名,等待一定时间,移除leave状态的类名,插入leave-to状态的类名。等待过渡动画时间,在隐藏元素(wx:if='{{false}}'或者display:none

代码示例:

.box {
  width: 100px;
  height: 100px;
  background-color: red;
}

.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 500ms;
}
<view class="box {{className}}" style="display:{{show?'':'none'}}"
  ><slot
/></view>
function createAnimationClassname(name) {
  return {
    enter: `${name}-enter ${name}-enter-active`,
    "enter-to": `${name}-enter-to ${name}-enter-active`,
    leave: `${name}-leave ${name}-leave-active`,
    "leave-to": `${name}-leave-to ${name}-leave-active`,
  };
}
const nextTick = (time) => () =>
  new Promise((resolve) => setTimeout(resolve, time ? time : 1000 / 30));
Component({
  properties: {
    show: {
      type: Boolean,
      value: false,
      observer: "observeShow",
    },
  },
  data: {
    className: "",
  },
  methods: {
    observeShow(newVal, oldVal) {
      if (newVal === oldVal) {
        return;
      }
      if (newVal) {
        this.enter();
      } else {
        this.leave();
      }
    },
    enter() {
      const className = createAnimationClassname("fade");
      this.setData(
        {
          show1: true,
        },
        () => {
          Promise.resolve()
            .then(() => {
              this.setData({
                className: className["enter"],
              });
            })
            .then(nextTick())
            .then(() => {
              this.setData({
                className: className["enter-to"],
              });
            });
        }
      );
    },
    leave() {
      const className = createAnimationClassname("fade");
      Promise.resolve()
        .then(() => {
          this.setData({
            className: className["leave"],
          });
        })
        .then(nextTick())
        .then(() => {
          this.setData({
            className: className["leave-to"],
          });
        })
        .then(nextTick(500))
        .then(() => {
          this.setData({
            show1: false,
          });
        });
    },
  },
});

WXS

其实这部分没啥好讲的,wxs虽然跟javascript不是同一个东西,但是你基本上可看成wxs就是javascript,因为他们的语法相似度达到了90%。大家看一遍官网的wxs文档就能懂了,详情可查看这里

wxs 是运行在wxml上的,是小程序的一套脚本语言,特点如下:

  • 它不依赖于基础库版本,可运行在所有版本的小程序中
  • javascript不是同一个语言。只是写法上面javascript相似,所以不要在WXS上面写 es6,es7 那些语法(但是我经常会写一些 es6 的语法下去,导致报错)。基本上你会写 js 就会写WXS了,因为它跟 js 的语法相似度达到了 90%。
  • wxs运行环境跟其他javascript代码是隔离的
  • ios 上面小程序的wxs会比javascript代码快 2-20 倍。安卓上面没有差异。所以你可以借助wxs进行一些复杂逻辑计算,这样可能会有助于你的性能提升

wxs 在组件库中使用的最多的就是根据条件生成不同类名,还有样式。下面我对wxs做一个简单的介绍。

  • 使用

wxs可写在.wxs文件中,也可以直接写在.wxml文件中,不管写在哪里,都需要使用module.exports将你需要的东西暴露出去。

首先新建一个common.wxs文件

var message = "this is message";
module.exports = {
  // 千万不要简写,简写是es6的语法,wxs是不支持的,我经常犯这个错
  message: message,
};

然后在.wxml文件中引入common.wxs文件

<wxs src="common.wxs" module="computed" /> <view> {{computed.message}} </view>

当然,你也可以直接在wxml文件中写wxs

<wxs module="computed">
  var some_msg = "hello world"; module.exports = { msg : some_msg, }
</wxs>
<view> {{computed.msg}} </view>

wxs文件可通过require函数相互引用
tools.wxs 文件

var getMessage = function (d) {
  return d;
};

module.exports = {
  getMessage: getMessage,
};

index.wxs 文件

var tools = require("./tools.wxs");

console.log(tools.getMessage("hello"));
  • 数据类型

wxs数据类型包含8个,分别是number数值,string字符串,boolean布尔值,object对象,function函数,array数组,date日期,regexp正则。其实跟javascript差不多的。

  • 基础类库

基础类库包含6个,分别是consoleMathJSONNumberDateGlobal

  • 其他

变量,运算符,语句等跟es5的写法一致,但是千万不要写成es6,如constlet等语法

组件单元测试

为什么要讲这部分的东西呢,因为现在关于微信小程序的单元测试资料非常少。我见过的一些微信小程序组件库,如vant-weappiView Weapp这些组件库都是没有做单元测试的,可以参考的资料也非常少。所以我打算将自己摸索出来的小程序单元测试相关东西分享一下,希望可以帮助其他有需要做微信小程序单元测试的同学。

那么为什么要做组件的单元测试呢,原因如下:

  • 完善的单元测试可以帮助我们减少 bug 的数量,及时发现问题并解决
  • 测试用例驱动代码开发。一般来说都是测试用例先行的,然后再根据测试用例编写代码,等你写完测试用例之后你就会发现自己需要做什么功能。(但是一般实际开发中并不是先编写测试用例,二是先写代码,在进行测试。。。)
  • 好的测试用例有助于后期的维护,比如修改或者新增功能。我们修改或者新增功能的时候需要充分考虑向下兼容的问题,不能因为新增一个字段用法都变了。所以当你不小心修改了核心功能的逻辑,此时,当你运行测试用例的时候肯定是会报错,你就可以及时发现自己那些代码影响到了核心的功能逻辑。

当然,我们做组件的单元测试不能一味的追求测试的覆盖率,行覆盖率,分支覆盖率,语句覆盖率等等。特别是分支覆盖率,基本上是不可能每个组件都能达到 100%的,因为有些分支只有if,没有else,你不能为了达到 100%的覆盖率而去添加一个没有意义的else。单元测试不可能百分百的测出你所有的问题,我们首先要保证我们组件的核心功能逻辑没问题,剩下的就要靠我们的测试人员了。单元测试做的是白盒测试,测试人员做的是黑盒测试。

这里我找到了一个有单元测试的项目,大家可以参考一下weui-miniprogram

初始化环境

  • 安装依赖
npm i miniprogram-simulate jest -D
  • 添加 jest 配置文件

在项目根目录下添加jest.config.js文件,写入一下配置:

const path = require("path");
module.exports = {
  bail: 1,
  verbose: true,
  // 根目录
  rootDir: path.join(__dirname),
  moduleFileExtensions: ["js"],
  // 需要匹配的测试文件,我们的测试用例都写在tests目录下,并且以.test.js结尾
  testMatch: ["<rootDir>/tests/**/*.test.js"],
  // jest 是直接在 nodejs 环境进行测试,使用 jsdom 进行 dom 环境的模拟。在使用时需要将 jest 的 `testEnvironment` 配置为 `jsdom`。
  // jest 内置 jsdom,所以不需要额外引入。
  testEnvironment: "jsdom",
  // 配置 jest-snapshot-plugin 从而在使用 jest 的 snapshot 功能时获得更加适合肉眼阅读的结构
  snapshotSerializers: ["miniprogram-simulate/jest-snapshot-plugin"],
  // 测试报告需要覆盖的文件
  collectCoverageFrom: [
    "<rootDir>/packages/**/*.js",
    "!<rootDir>/packages/common/**",
    "!<rootDir>/packages/behaviors/**",
    "!<rootDir>/packages/wxs/**",
  ],
};
  • 修改 package.json 文件
    package.json文件中添加如下script脚本
  "scripts": {
    "test-watch": "jest --watch",
    "codecov": "jest --coverage && codecov"
  },

test-watch是用来监听文件变化,然后自动运行测试用例文件。codecov是运行测试用例,并生成测试报告的。

miniprogram-simulate 使用

  • 引入测试工具
import simulate from "miniprogram-simulate";
  • 获取自定义组件 ID

通过load加载一个自定义组件,返回组件 id。可传入组件的路径,也可以传入自定义组件的定义对象

load(componentPath, tagName, options) / load(definition)

代码示例:

// 这种写法适合单个组件的,比如Button
const buttonId = simulate.load(
  path.resolve(__dirname, "packages/Button/index"),
  {
    rootPath: path.resolve("packages/"),
  }
);

// or
// 这个写法适合组合型组件的,比如 checkbox,checkbox-group;或者是测试插槽的
const id = simulate.load({
  usingComponents: {
    "lin-button": buttonId,
  },
  template: `<lin-button class='lin-button'>默认按钮</lin-button>`,
});

注意:同一个测试文件中重复使用load方法获取自定义组件的 id 是会报错的。当然,你可以使用jest.resetModules()重置一下,这样就不会报错了。

  • 渲染组件

render(componentId:string,properties?:Object)
componentId:自定义组件 id,必传项;properties:组件的 properties 参数,可选

代码示例:

const comp = simulate.render(buttonId, { type: "primary" });
comp.attach(document.createElement("parent-wrapper"));

需要注意的是,我们需要创建一个容器节点,将组件插入到容器节点中,这样才能触发attached生命周期

  • 获取和更新数据

代码示例:

// 获取组件数据
comp.data;

// 更新组件数据
comp.setData({
  title: "你好",
});
  • 获取组件

代码示例:

test("comp", () => {
  // 获取单个
  const childComp = comp.querySelector(".child-item");
  expect(childComp.dom.innerHTML).toBe("<div>child</div>");

  // 获取多个
  const childrenComp = comp.querySelectorAll(".child-item");
  expect(childrenComp.length).toBe(3);
});
  • 组件事件

代码示例:

// 触发组件事件
comp.dispatchEvent("tap", {
  touches: [{ x: 0, y: 0 }],
}); // 触发组件的 tap 事件

// 外部监听组件触发的事件
comp.addEventListener("tap", (evt) => {
  console.log(evt);
});

// 取消外部监听组件触发的事件
comp.addEventListener("tap", handler);
  • 生命周期

代码示例:

// 触发组件生命周期
comp.triggerLifeTime("ready", {
  // ...
}); // 触发组件的 ready 生命周期

// 触发组件所在页面的生命周期函数
comp.triggerPageLifeTime("show", { test: "xxx" });
  • 获取组件实例

代码示例:

const that = comp.instance; // 组件方法定义里的 this
that.data; // 获取组件的 data 对象,这里和 comp.data 拿到的对象是一样的
that.xxx(); // 调用组件 methods 定义段里定义的方法
  • 生成 JSON 树

这个功能一般用来查看渲染情况的,用来跟快照对比

代码示例:

test("render", () => {
  expect(comp.toJSON()).toMatchSnapshot();
});

测试工具类开发

miniprogram-simulate只是给我们提供了一个测试的框架,但是并没有给我提供类似@vue/test-utils方便好用的 api,比如判断元素上是否存在类名,元素是否存在等等。所以我们需要自行封装一个工具类,来简化我们的测试代码。需要注意的是,下面自行封装的方法都是miniprogram-simulate没有提供的,也没有告知怎么去获取的,weui-miniprogram这个项目的测试用例代码也是没有的。比如我想要获取元素上面的类名或者其他东西,大家需要打印一下render,querySelector或者querySelectorAll函数返回来的东西,里面基本囊括了你所需要的东西了,麻烦的是你需要自行查找在那个字段里面,因为里面的字段非常多。

  • 获取元素上面挂载的属性
function getAttribute(component, attr) {
  const attrsArr = component.dom.__wxElement._vt.attrs;
  const attrs = {};
  for (let i = 0; i < attrsArr.length; i++) {
    const attrItem = attrsArr[i];
    attrs[attrItem.name] = attrItem.value;
  }
  return attr ? attrs[attr] : attrs;
}
  • 获取外部样式类
function getExternalClasses(component, externalClass) {
  const classesObj = component._exparserNode.__externalClassAlias;
  return externalClass ? classesObj[externalClass] : classesObj;
}
  • 判断组件上面是否存在类名
function hasClassName(component, classname) {
  const classes = getAttribute(component, "class");
  if (!classes) {
    return false;
  }
  const classArr = classes.split(/\s+/);
  return classArr.includes(classname);
}
  • 集合成一个类
export function getElement(context, selector) {
  return new CompUtils(context, selector);
}

class CompUtils {
  constructor(context, selector) {
    this.context = context;
    this.selector = selector;
    this.initDom();
  }

  initDom() {
    this.component = this.context.querySelector(this.selector);
  }

  getClassNames() {
    this.initDom();
    return getAttribute(this.component, "class");
  }

  hasClassName(className) {
    this.initDom();
    return hasClassName(this.component, className);
  }

  getAttribute(attr) {
    this.initDom();
    return getAttribute(this.component, attr);
  }

  exists() {
    this.initDom();
    return !!this.component;
  }

  async dispatchEvent(evnetName) {
    this.initDom();
    const fn = jest.fn();
    this.context.instance.triggerEvent = fn;
    await this.component.dispatchEvent(evnetName);
    return fn;
  }

  getExternalClasses(externalClass) {
    this.initDom();
    return getExternalClasses(this.component, externalClass);
  }

  hasExternalClass(externalClass, className) {
    this.initDom();
    const classList = getExternalClasses(this.component, externalClass);
    if (Array.isArray(classList)) {
      return classList.includes(className);
    }
    return false;
  }

  getHtml() {
    this.initDom();
    return this.component.dom.innerHTML;
  }
}
  • 工具类使用

代码示例:

test("button", () => {
  const comp = simulate.render(buttonId);
  comp.attach(document.createElement("parent-wrapper"));
  const button = getElement(comp, ".lin-button");
  const icon = getElement(comp, ".lin-button-icon");
  // 判断button是否存在类名
  expect(button.hasClassName("lin-button-default")).toBeTruthy();
  // 判断元素是否存在
  expect(icon.exists()).toBeFalsy();
  // 派发事件
  const fn = await button.dispatchEvent("tap");
  // triggerEvent是否被调用
  expect(fn).toBeCalled();
  // triggerEvent被调用次数
  expect(fn).toHaveBeenCalledTimes(1);
  // triggerEvent参数
  expect(fn).toBeCalledWith("click");
});

总结

上面就是我在开发微信小程序组件库时所遇见的一些知识点和一些开发技巧,可能会有一些遗漏,欢迎大家在下方补充留言。最后,如果有同学想要一起学习交流,欢迎联系我,虽然我的组件库可能没有vant等组件库优秀,但是总有值得你学习借鉴的地方,对于代码的每一行基本都有详细的代码注释,说明每一步是干什么的,而且是基于微信小程序原生语言来写的,浅显易懂,只要会小程序和 js 的同学基本可以看懂,对于新手来说是非常的友好,方便阅读。不像vant那样使用的是typescript语言开发,并且其内部还封装了一个vantComponent,对于新手来说可能阅读起来就有点困难了。最后贴上github 地址,希望喜欢的同学或者对你有帮助的同学可以给我点个赞,给我持续维护下去的动力。

扫描下方的小程序二维码可在手机上进行预览
小程序二维码

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值