微信小程序 Page,Component 二次封装
前言
相信很多开发过微信小程序的开发者都应该知道,微信小程序在语法上面跟vue
很像,但是又有点区别。本文将讲述如何对微信小程序的Page
页面构造器和Component
构造器进行二次封装,抹平一些微信小程序与vue
之间差别
目的
很多人可能会有疑问,为什么需要对Page
和Component
构造器进行二次封装。原因如下:
-
对于不是很熟悉微信小程序开发,但是熟悉
vue
开发的开发者来说,将Page
和Component
封装成更加符合vue
开发习惯,能够让他们更加快速上手小程序。对于新手来说是很友好的。 -
可自定义钩子函数(生命周期函数)。在实际的项目开发者,很多微信小程序都是需要获取用户信息的,而且获取用户信息是在
app.js
中的,但是页面的渲染跟app.js
的执行是异步的,也就是说,页面的onLoad
生命周期函数执行完毕了,app.js
可能还没获取到用户信息,导致页面获取不到用户信息。这也是很多人遇见的问题,网上也有很多解决的方案,但是比较麻烦。我们可以给二次封装的Page
构造器自定义一个onAppLoad
生命周期函数,该函数执行时间是在app.js
获取完用户信息之后执行,这样子页面就可以获取得到用户的信息了。 -
扩展自定义功能。我们知道
vue
是可以使用watch
监听属性和computed
计算属性。微信小程序也可以支持的,但是需要第三方npm包
,使用起来比较麻烦,我们也可以在二次封装中,把watch
监听属性和computed
计算属性添加进去,简化一些流程(引包,设置behaviors
等) -
统一管理。对于项目上来说,有些页面可能用户需要某种权限才能进来页面。基于这个需求,我们可以在二次封装好的
Page
构造器中,统一检查用户权限,而不是在每个页面中写一次检查权限。
Page 封装
Page 常用字段
我们先来看看 Page
常用的字段有哪些,不常用的我就不列举出来了,可自行查看文档。点击这里查看文档
Page({
// 混入
behaviors: [],
// 页面数据
data: {},
// 页面加载完毕
onLoad() {},
// 页面显示出来
onShow() {},
// 页面渲染完毕
onReady() {},
// 页面卸载
onUnload() {},
// 上拉触底
onReachBottom() {},
// 自定义函数
onClick() {},
});
vue 常用字段
我们再看看vue
有哪些常用的字段
export default {
mixins: [],
data() {
return {};
},
created() {},
mounted() {},
methods() {},
computed: {},
watch: {},
destroyed() {},
};
字段对应关系
从上面对比,我们不难发现微信小程序和vue
字段的对应关系
-
Page.data
->vue.data
-
Page.onLoad
->vue.created
-
Page.onReady
->vue.mounted
-
Page.onUnload
->vue.destroyed
-
Page.onClick
->vue.methods.onClick
-
Page.behaviors
->vue.mixins
-
computed
和watch
微信小程序需要结合miniprogram-computed
才能使用,我们在接下来会封装进去 -
onShow
,onReachBottom
等字段是Page
构造器特有的,需要在封装的时候保留下来 -
在
vue
中,页面上的查询参数是存放在this.$route.query
中的,但是微信小程序是在onLoad
的回调参数中获取的,我们把微信小程序的页面查询参数挂载到this.$query
中。
封装效果
最终我们封装出来的形式如下:
MyPage({
mixins: [],
data() {
return {};
},
// 也可以是
// data:{},
created() {},
mounted() {},
methods() {
onClick(){}
},
computed: {},
watch: {},
destroyed() {},
onShow(){},
onReachBottom(){}
});
流程
实际上,很多字段都还是做了个映射而已,实际上并没有很大的改动。流程如下:
-
检查
data
字段是函数还是对象,如果是函数,就需要执行这个函数,获取返回的对象 -
将
mixins
,data
,created
,mounted
,computed
,watch
,destroyed
,onShow
,onReachBottom
等字段映射到微信小程序对应的字段上面 -
methods
需要扁平特殊处理,因为微信小程序的自定义函数方法是跟data
,onShow
等字段同级的,是兄弟关系 -
检查是否使用了
computed
或者watch
等字段,如果使用了,就自动给behaviors
字段添加一个computedBehavior
(通过miniprogram-computed
引进来的) -
重写
onLoad
生命周期函数,将页面地址的查询参数挂载到this.$query
上
源码
最终代码如下:
import { isFunction } from "./is";
import { behavior as computedBehavior } from "miniprogram-computed";
function mapKeys(fromTarget, toTarget, map) {
Object.keys(map).forEach((key) => {
if (fromTarget[key]) {
toTarget[map[key]] = fromTarget[key];
}
});
}
function proxyMethods(fromTarget, toTarget) {
if (fromTarget) {
Object.keys(fromTarget).forEach((key) => {
toTarget[key] = fromTarget[key];
});
}
}
function proxyOnLoad(MyOptions, options) {
// 保存原有的onLoad函数
const oldLoad = options.onLoad;
options.onLoad = function (query) {
// 挂载查询参数
this.$query = query;
if (isFunction(oldLoad)) {
// 执行原有的onLoad函数
oldLoad.call(this, query);
}
};
}
function proxyComputedAndWatch(MyOptions, options) {
if (MyOptions.computed || MyOptions.watch) {
options.behaviors = options.behaviors || [];
// 如果使用到了`computed`或者`watch`,就需要添加对应的`behaviors`,否则无效
options.behaviors.push(computedBehavior);
}
}
function MyPage(MyOptions) {
const options = {};
// 检查`data`字段是否为函数
if (isFunction(MyOptions.data)) {
MyOptions.data = MyOptions.data();
}
// 字段映射
mapKeys(MyOptions, options, {
data: "data",
onReachBottom: "onReachBottom",
onShow: "onShow",
mounted: "onReady",
created: "onLoad",
mixins: "behaviors",
computed: "computed",
watch: "watch",
destroyed: "onUnload",
});
// 扁平methods字段中的数据
proxyMethods(MyOptions.methods, options);
// 检查是否使用了`computed`或者`watch`字段
proxyComputedAndWatch(MyOptions, options);
// 重写`onLoad`生命周期函数,将页面地址的查询参数挂载到`this.$query`上
proxyOnLoad(MyOptions, options);
Page(options);
}
export default MyPage;
扩展自定义功能或者生命周期函数
我们以扩展一个onAppLoad
生命周期函数为例,这个生命周期函数是在app.js
的onLaunch
函数执行完毕之后被调用
改造 app.js
这个生命周期函数需要对app.js
进行改造才能生效,改造点如下:
-
添加一个
isLoad
属性,用来标识app.js
的onLaunch
的函数是否执行完毕 -
添加一个
taskList
异步任务队列,存放需要在onLaunch
函数执行完毕之后所以需要执行的任务 -
onLaunch
函数中如果有异步任务,需要使用async
和await
等待异步任务执行完毕。 -
使用
try-catch
包裹函数体,在finally
中,将isLoad
属性标记为true
,然后执行taskList
中的任务
最终改造代码如下:
// app.js
App({
// 异步任务队列
taskList: [],
// 加载完毕
isLoad: false,
async onLaunch() {
// 加个try-catch,防止请求爆炸,taskList无法执行
try {
await this.getUserInfo();
// ...
} catch (error) {
console.log("首页出现的错误", error);
} finally {
this.isLoad = true;
// 加载完毕就执行任务队列
this.runTask();
}
},
// 获取用户信息
async getUserInfo(params) {
// ...
},
runTask() {
const taskList = this.taskList.slice();
if (taskList.length > 0) {
taskList.forEach((task) => {
task();
});
this.taskList = [];
}
},
});
实现 onAppLoad 函数
app.js
改造完成之后,我们需要准备一个函数,这个函数的作用就是通过getApp()
获取小程序全局唯一的app
实例(实际上就是app.js
中的this
),然后通过app.isLoad
判断app.js
中的onLaunch
函数是否执行完毕了,最终返回一个Promise
代码如下:
// on-app-load.js
const app = getApp();
function onAppLoad() {
return new Promise((resolve, reject) => {
try {
if (!app.isLoad) {
app.taskList.push(() => {
resolve();
});
} else {
resolve();
}
} catch (error) {
reject(error);
}
});
}
export default onAppLoad;
重新改造 onLoad 生命周期函数
最后回来到我们的proxyOnLoad
函数中,对onLoad
函数重新进行改造,改造如下:
import onAppLoad from "./on-app-load";
function proxyOnLoad(MyOptions, options) {
const oldLoad = options.onLoad;
options.onLoad = function (query) {
this.$query = query;
// 检查是否使用了onAppLoad这个生命周期函数
if (isFunction(MyOptions.onAppLoad)) {
// 调用封装好的onAppLoad函数,执行`MyOptions.onAppLoad`这个生命周期函数
onAppLoad().then(() => {
MyOptions.onAppLoad.call(this, query);
});
}
if (isFunction(oldLoad)) {
oldLoad.call(this, query);
}
};
}
最终效果
最终使用效果如下:
MyPage({
onAppLoad() {
// todo,在这里就可以确保获取到用户信息
},
});
Component 封装
Component
的封装跟Page
的封装差不多,只是字段上面会有点差别,还有相比于Page
构造器来说,Component
会多了一些功能
Component 常用字段
我们先来看看 Component
常用的字段有哪些,不常用的我就不列举出来了,可自行查看文档
Component({
// 一些选项
options: {},
// 组件接受的外部样式类
externalClasses: [],
// 混入
behaviors: [],
// 组件的对外属性
properties: {},
// 组件的内部数据
data: {},
// 组件自定义方法
methods: {},
// 组件实例刚刚被创建时执行
created() {},
// 组件实例进入页面节点树时执行
attached() {},
// 在组件布局完成后执行
ready() {},
// 组件间关系定义
relations: {},
// 组件实例被从页面节点树移除时执行
detached() {},
// 组件数据字段监听器
observers: {},
});
vue 常用字段
我们再看看vue
有哪些常用的字段
export default {
mixins: [],
data() {
return {};
},
props: {},
beforeCreate() {},
created() {},
mounted() {},
methods() {},
computed: {},
watch: {},
destroyed() {},
};
字段对应关系
从上面对比,我们不难发现微信小程序和vue
字段的对应关系
-
Page.data
->vue.data
-
Page.properties
->vue.props
-
Page.behaviors
->vue.mixins
-
Page.methods
->vue.methods
-
Page.created
->vue.beforeCreate
-
Page.attached
->vue.created
-
Page.ready
->vue.mounted
-
Page.detached
->vue.destroyed
-
Page.observers
->vue.watch
-
computed
需要结合miniprogram-computed
才能使用 -
watch
字段因为Component
构造器已经有对应的字段observers
实现了,所以watch
字段不需要结合miniprogram-computed
-
relations
用来定义组件之间的关系,详情可点击这里查看。Component
组件构造器特有字段,需要保留 -
options
用来定义一些选项,比如是否开启全局样式(addGlobalClass:true
),是否允许多插槽(微信小程序的组件默认只能允许有一个插槽,如果需要具名插槽,需要在该字段中声明multipleSlots:true
)。Component
组件构造器特有字段,需要保留 -
externalClasses
组件接受的外部样式类。微信小程序组件的样式默认是隔离的(即页面样式不影响组件样式,组件样式也不影响页面样式)。我们在封装的过程中,会默认添加options.addGlobalClass=true
,这样子组件就可以受页面样式的影响,方便修改组件的样式。但是如果是基于某个组件在进行二次封装组件的时候,组件样式是影响不了其他组件的样式的,需要通过externalClasses
来指定样式类。Component
组件构造器特有字段,对应封装后的是classes
字段 -
跟
Page
构造器封装一样,我们同样把页面的地址查询参数也挂载到this.$query
上,但是Component
获取页面地址的查询参数会跟Page
获取的方式不一样,Component
是在ready
生命周期函数中,通过获取组件所在页面的实例,然后在获取得到页面的查询参数
封装效果
MyComponent({
mixins: [],
data() {
return {};
},
// 也可以是
// data:{},
props: {},
methods() {},
beforeCreate() {},
created() {},
mounted: {},
relations: {},
destroyed() {},
classes: [],
watch: {},
computed: {},
});
流程
-
检查
data
字段是函数还是对象,如果是函数,就需要执行这个函数,获取返回的对象 -
将
data
,props
,mixins
,methods
,beforeCreate
,created
,mounted
,destroyed
,watch
,computed
,relations
,classes
等字段映射到微信小程序对应的字段上面 -
检查是否使用了
computed
等字段,如果使用了,就自动给behaviors
字段添加一个computedBehavior
(通过miniprogram-computed
引进来的) -
options
字段默认开启multipleSlots:true
和addGlobalClass:true
-
重写
ready
生命周期函数,将页面地址的查询参数挂载到this.$query
上
源码
import { behavior as computedBehavior } from "miniprogram-computed";
import { isFunction } from "./is";
function mapKeys(source, target, map) {
Object.keys(map).forEach((key) => {
if (source[key]) {
target[map[key]] = source[key];
}
});
}
function getCurrentPageParam() {
// 获取加载的页面
const pages = getCurrentPages();
//获取当前页面的对象
const currentPage = pages[pages.length - 1];
//如果要获取url中所带的参数可以查看options
const options = currentPage.options;
return options;
}
function proxyReady(MyOptions, options) {
// 保存原有的ready函数
const ready = options.ready;
options.ready = function () {
// 挂载查询参数
this.$query = getCurrentPageParam();
if (isFunction(ready)) {
// 执行原有的onLoad函数
ready.call(this);
}
};
}
function proxyComputed(MyOptions, options) {
// 如果使用到了`computed`,就需要添加对应的`behaviors`,否则无效
if (MyOptions.computed) {
options.behaviors = options.behaviors || [];
options.behaviors.push(computedBehavior);
}
}
function proxyProps(MyOptions, options) {
// vue的props写法和微信小程序的写法略有不同,需要特殊处理一下某些字段
if (options.properties) {
Object.keys(options.properties).forEach((name) => {
if (Array.isArray(options.properties[name])) {
options.properties[name] = null;
}
});
}
}
function MyComponent(MyOptions) {
const options = {};
// 检查`data`字段是否为函数
if (isFunction(MyOptions.data)) {
MyOptions.data = MyOptions.data();
}
// 字段映射
mapKeys(MyOptions, options, {
data: "data",
props: "properties",
mixins: "behaviors",
methods: "methods",
beforeCreate: "created",
created: "attached",
mounted: "ready",
relations: "relations",
destroyed: "detached",
classes: "externalClasses",
watch: "observers",
computed: "computed",
});
// 检查是否使用了`computed`字段
proxyComputed(MyOptions, options);
// 特殊处理props的某些字段
proxyProps(MyOptions, options);
// 默认开启一些选项
options.options = {
multipleSlots: true,
addGlobalClass: true,
};
// 重写`ready`生命周期函数,将页面地址的查询参数挂载到`this.$query`上
proxyReady(MyOptions, options);
Component(options);
}
export default MyComponent;
添加扩展功能
添加 field 字段
微信小程序可以为自定义组件添加内置的behaviors
(内置表单行为,详情点击这里查看),然后就可以使该自定义组件变成一个表单组件,拥有表单组件所对应的功能。
使用如下:
MyComponent({
// 声明为表单组件
field: true,
props: {
name: {
type: String,
},
value: {
type: String,
},
},
});
回到我们的MyComponent
中,我们要进行如下改造:
function MyComponent(MyOptions) {
// ...
// 如果声明为表单组件
if (MyOptions.field) {
options.behaviors = options.behaviors || [];
// 添加内置的behaviors
options.behaviors.push("wx://form-field");
}
// ...
}
添加 relation 字段
有时候,我们会遇见一些具有父子或者祖孙关系的组件,这些组件需要进行通信(需要获取父组件实例或者子组件实例,然后使用实例的方法或者属性),这个时候就需要使用relations
字段了,但是这个字段使用起来还是有点麻烦,所以我们可以继续在封装一个relation
字段来简化流程。注意,relation
只适用于只有一种身份的组件,如果具有多个身份的组件(既可以是作为某个组件子组件,也可以作为另外某些组件的父组件)并不适用,还是需要使用原生的relations
字段
我们需要实现的功能是,往子组件中挂在this.$parent
(即父组件实例),往父组件中挂在this.$children
(即子组件实例),卸载的时候自动销毁。
封装代码如下:
const relationFunctions = {
// 关联的目标节点应为祖先节点
ancestor: {
// 子组件插入到父组件中时触发
linked(parent) {
this.$parent = parent;
},
// 子组件脱离父组件时触发
unlinked() {
this.$parent = null;
},
},
// 关联的目标节点应为子孙节点
descendant: {
// 子组件插入到父组件中时触发
linked(child) {
this.$children = this.$children || [];
this.$children.push(child);
},
// 子组件脱离父组件时触发
unlinked(child) {
this.$children = (this.$children || []).filter((it) => it !== child);
},
},
};
function makeRelation(MyOptions, options) {
const { type, name, linked, unlinked, linkChanged } = MyOptions.relation;
const { created, detached } = options;
if (type === "descendant") {
// 父组件类型
options.created = function () {
created && created.bind(this)();
// 默认添加$children属性
this.$children = this.$children || [];
};
options.detached = function () {
this.$children = [];
detached && detached.bind(this)();
};
}
// 将`relation`字段的东西合并到`relations`字段中
options.relations = Object.assign(options.relations || {}, {
[name]: {
type,
linked(node) {
relationFunctions[type].linked.bind(this)(node);
linked && linked.bind(this)(node);
},
linkChanged(node) {
linkChanged && linkChanged.bind(this)(node);
},
unlinked(node) {
relationFunctions[type].unlinked.bind(this)(node);
unlinked && unlinked.bind(this)(node);
},
},
});
}
function MyComponent(MyOptions) {
// ...
const { relation } = MyOptions;
if (relation) {
// 处理组件之间的关系
makeRelation(MyOptions, options);
}
// ...
}
假设我们现在有Form
组件和FormItem
组件,Form
组件为父组件,FormItem
组件为子组件
wxml 上面的用法:
<form>
<form-item></form-item>
<form-item></form-item>
</form>
Form
组件
MyComponent({
relation: {
type: "descendant",
name: "../form-item/index", // 填写的是 form-item 组件所在路径
linked() {
this.updateChildren();
},
},
methods: {
updateChildren() {},
update() {
// this.$children
},
},
});
FormItem
组件
MyComponent({
relation: {
type: "ancestor",
name: "../form/index", // 填写的是 form 组件所在路径
},
methods: {
update() {
// this.$parent
},
},
});
经过上述的操作,this.$parent
和 this.$children
又向 vue
开发习惯更加靠近了
总结
通过对Page
和Component
的二次封装,我们让微信小程序开发变得像vue开发一样轻松简单了。我们在封装的基础上,不仅添加某些方便又好用的功能,比如computed
和watch
属性,还简化了一些操作,比如父子组件的this.$parent
和 this.$children
的获取和定义,已经在内部封装,定义好了,开箱即可使用,不需要再去定义和获取。
当然,我们还可以定义更多的功能,比如,微信小程序修改响应式数据是使用this.setData({xxx:xxx})
,而vue修改响应式数据是使用this.xxx=xxx
,我们可以自己写一个数据代理的方法,把微信小程序修改响应式数据的方式变成this.xxx=xxx