业界已经有很多功能强大,成熟的微信小程序组件库,比如vant
,为什么自己还要搞一套微信小程序组件库出来?
vant
组件库,实际上开发中,我们需要用到瀑布流
,密码键盘
,悬浮按钮
,回到顶部
等组件,这些组件都是vant
没有的组件。所以我们需要将这些组件封装起来,发布到 npm 或者私服上面,方便下次使用。vant
组件库的代码,参考了vant
组件库的代码架构,然后根据自己的实际开发工作能力,制定了一套自己的组件库架构。每一个组件开发完毕之后,我会去阅读vant
的组件代码,看一下别人的设计思想跟我的设计思想有什么不一样,有什么优缺点,然后改进自己的代码。通过这种方式,我对阅读代码能力有了很大的提升,并且学到了不少的设计思想。这篇文章主要是记录技术选型,环境搭建,组件开发常用技巧还有组件单元测试。希望可以帮助到有需要的同学吧
在开始前,我阅读过vant
的源码,它是用typescript
+less
+gulp
的。并且内部封装了一个vantComponent
函数,感觉这对新手来说阅读起来不太友好。但是我对typescript
编写小程序并不是很熟悉,所以我还是选择了javascript
这个原汁原味的语言。css 预处理方方面,由于平时工作开发中我都是使用scss
的,所以我选择了scss
来编写 css。所以我的最终技术选型是javascript
+scss
+gulp
。
这套组件库都是基于微信原生的语法来写,并没有借助第三方框架,比如mpvue
,uni-app
等这些框架。所以我们甚至可以连环境都不用搭建就可以进行开发了,只需要新建一个文件夹,然后在文件夹下面新建.wxml
,.wxss
,.js
,.json
这些文件就可以进行开发了。但是这种开发模式效率太慢了,缺点如下:
wxss
其实跟 web 的css
差不多,并不支持嵌套语法,函数,变量,循环等功能。packages
目录,还有一个dist
或者lib
目录的,这是将来发布到 npm 上会使用到的,同时还有一个小程序演示目录。当我们编写完一个组件的时候,需要借助其他工具拷贝到小程序演示目录下,否则你需要自己手动 cv 一下拷贝过去,这将会大大降低我们的开发效率。针对上面的缺点,我们需要搭建一套开发环境出来解决上面的问题。
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));
wxml
,js
,json
文件和图片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
是用来父子组件用来进行通讯的。这个跟vue
的 props 十分相似。主要有下面四个参数:
Boolean
或者Number
等多种类型时,使用该字段value
,可能受vue
的影响,我经常会写成default
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
的写法跟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
主要用到的有 2 个参数,分别如下:
externalClasses
字段,下面会讲到的wxml
中需要用到多个slot
插槽的时候,必须将这个属性置位true
,不然插槽不生效。只有一个slot
插槽的时候可不设置。代码示例:
Component({
options: {
addGlobalClass: true,
multipleSlots: true,
},
});
外部样式类,可指定那些类受页面影响,但是由于外部样式类和普通样式类的优先级没有定义,所以一般来说都需要在外部样式类中添加!important
。这个字段在组件开启了样式隔离或者在自定义组件中使用自定义组件,提供给外部修改组件内部样式的一种方法。详情可查看这里
代码示例:
Button 组件
<button class="custom-class">自定义组件button>
Component({
externalClasses: ["custom-class"],
});
Page 页面
<lin-button custom-class="button-class" />
.button-class {
color: red;
}
relations
是用来定义组件与组件之间的关系的,比如父子关系,祖孙关系等。说白了就是类似 html 中ul
和li
这种关系吧。通常用来作为组件之间的通信方式,常见于checkbox
和checkbox-group
这种组合关系的组件中。组件间的关系用的最多的就是ancestor
和descendant
祖孙关系,需要特别注意的是必须在两个组件定义中都加入 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
字段中声明(推荐这种,优先级最高)。
setData
,该生命周期适合给this
添加一个自定义属性。组件页面生命周期包含三个,在pageLifetimes
字段中声明:
微信小程序是依赖于基础库的,随着小程序的功能不断增加,旧版本的基础库并不支持新功能。所以有些功能需要判断一下当前基础库是否支持该功能。比如小程序当前最低基础库版本是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;
}
if (wx.previewMedia) {
wx.previewMedia({
// ...
});
} else {
// 如果希望用户在最新版本的客户端上体验您的小程序,可以这样子提示
wx.showModal({
title: "提示",
content: "当前微信版本过低,无法使用该功能,请升级到最新微信版本后重试。",
});
}
// 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();
});
}
熟悉vue
开发的同学应该知道,像element-ui
中的一些组件如message
,可以直接在 js 中通过Message()
来调用的,然后组件的元素就会被插入到文档当中。小程序可以实现类似的效果,但是因为小程序限制得原因,我们需要在使用到 js 组件中的页面中插入自定义组件。
代码示例:
Toast 组件
来触发双向数据绑定的更新
注意:我并不推荐在自定义组件中传递双向绑定。一是要求的版本号太高,二是上面所说的会破坏单向数据流的原则,可能会导致一些不可预知的 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 个状态,分别是
enter
和enter-to
。enter
包含类名${name}-enter ${name}-enter-active
,enter-to
包含类名${name}-enter-to ${name}-enter-active
- 离开(隐藏)有 2 个状态,分别是
leave
和leave-to
。leave
包含类名${name}-leave ${name}-leave-active
,leave-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个,分别是console
,Math
,JSON
,Number
,Date
,Global
。
- 其他
变量,运算符,语句等跟es5
的写法一致,但是千万不要写成es6
,如const
,let
等语法
组件单元测试
为什么要讲这部分的东西呢,因为现在关于微信小程序的单元测试资料非常少。我见过的一些微信小程序组件库,如vant-weapp
,iView 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: ["/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: [
"/packages/**/*.js" ,
"!/packages/common/**" ,
"!/packages/behaviors/**" ,
"!/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: `默认按钮 `,
});
注意:同一个测试文件中重复使用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("child");
// 获取多个
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 地址,希望喜欢的同学或者对你有帮助的同学可以给我点个赞,给我持续维护下去的动力。