/ez-wxlite

📦 一套微信小程序的开发模板

Primary LanguageJavaScriptMIT LicenseMIT

ez-client

ez-wxlite是一套小程序开发模板,旨在设计一套简洁、高效、可维护的开发框架。

本套模板总体上分为三部分:

  • server:为本地服务,不是后端服务,主要作用是mock接口以及静态文件服务;
  • client:小程序源码部分;
  • cloud_func:云开发中的云函数存放目录。

client

client部分是框架的核心,设计上分为:

  • req:网络请求;
  • router:路由;
  • config:配置信息;
  • utils:工具集,用于存放一些通用的公共方法。

使用

框架核心代码都包含在client/framework文件夹内,在app.js中一次性引入:

// app.js
require('./framework/index.js').patch();

App({});

调用patch方法会直接完成AppPageComponent这三个全局方法的代理,并完成相应的注入,所以上面的App({})其实已经是被代理之后的App,在这一实例中我们可以获取到注入的options数据,通过this.$opts获取到:

App({
  onLaunch() {
    console.log(this.$opts);
  },
  onShow() {
    console.log(this.$opts);
  },
});

原小程序的App方法只能在onLaunch中获取到options,代理过后的App,通过将options挂载在实例上,我们可以在所有生命周期里访问到,方便使用,Page同理。

config

config为全局配置信息,大家一定有过跨页面共享数据的需求,比如有十几个页面都是用的同一个转发标题、转发描述,以及转发图片,那么我们就可以将通用的转发信息shareData都保存在一个文件里,作为配置文件让所有页面都能访问:

// pages/index/index.js
const { config } = require('../../framework/index.js');

Page({
  onShareAppMessage() {
    return config.shareData;
  },
});

以上,我们将通用的分享信息存放在config.shareData里,这只是config的一个示例用法。

另外我们也发现了,config是集成在client/framework/index.js里的,事实上,之前我们提到的reqrouterutils也都是集成在client/framework/index.js里的,使用的时候不需要一个个单独引入,这么做的目的是减少模板代码,方便维护。

req

req是wx.request的高级封装,用于发起ajax请求以及文件上传。

wx.request是一个底层api,使用的不便之处在于:

  1. 返回结果比较底层,需要处理statusCode,而开发者往往更关注业务相关的data部分;
  2. 登录机制繁琐,设计上甚至有些反人类;
  3. 不具备良好的接口管理功能,可维护性差;

……

综上所述,wx.request需要一层高级的封装来简化操作,因此有了req,req代理了wx.request,并在这基础上做了一些设计工作,以提供良好的维护性:

  • promisify:支持promise,替代callback的方式;
  • 简化respone:简化返回的数据信息,只保留业务数据;
  • method替代url:使用js api的书写方式来替代直接书写url的方式;
  • 接口缓存:支持便捷的接口前端缓存;
  • 自动登录:登录态过期自动重新登录,过程对开发者透明。

promisify

涉及到ajax就避不开异步编程,谈到异步,怎么少得了promise,所以我们第一时间考虑将其promise化:

// url方式
wx.request({
  url: 'https://api.jack-lo.com/ez-wxlite/user/getInfo?id=123',
  success(res) {
    console.log(res);
  },
});

// method方式
req.user.getInfo({
  id: '123',
})
  .then((res) => {
    console.log(res);
  });

既然都已经promise了,你当然也可以方便地使用async/await

简化respone

为了更加通用,wx.request的返回值包含了完整的respone内容,但在大部分情况下,开发者关注的只有respone.data部分,于是我们做了一层过滤,req的请求返回结果就是respone.data,至于异常的statusCode(指的是除了(statusCode >= 200 && statusCode < 300) || statusCode === 304;以外的情况),我们将它归为了fail的范畴,也就是promise的catch通道。

我们还是以上面的示例代码来介绍,wx.request返回的res结构为:

{
  "data": {
    "name": "Jack",
    "age": 18,
    "gender": 1
  },
  "statusCode": 200,
  "header": {}
}

如果我们想要读取data的内容,首先要判断statusCode是否为“正常”的http状态码,也就是诸如200之类的,而如果是404,我们还得弹个窗报个错什么的:

// url方式
wx.request({
  url: 'https://api.jack-lo.com/ez-wxlite/user/getInfo?id=123',
  success(res) {
    if (res.statusCode === 200) {
      // 读取res.data
    } else {
      // 处理异常
    }
  },
  fail(err) {
    // 处理异常
  },
});

一般来说,我们的res.data里面还有业务层面的错误信息,这样的话,除了处理wx.requestfail的错误,以及success里异常的statusCode错误,我们还要再处理业务逻辑的res.data.code(这里假设你的数据结构是{code: number, data: object, msg: string})错误。。。

真是丧心病狂。。。

req.user.getInfo返回的res则仅为:

{
  "code": 0,
  "data": {
    "name": "Jack",
    "age": 18,
    "gender": 1
  },
  "msg": "success"
}

只关注业务部分的json,而fail和“bad statusCode”则一概交给catch通道去处理:

// method方式
req.user.getInfo({
  id: '123',
})
  .then((res) => {
    console.log(res);
    if (res.code === 0) {
      // 请求成功
    } else {
      // 请求失败
    }
  })
  .catch((err) => {
    console.log(err);
  });

此时,err有可能是fail或者“bad statusCode”产生的,而这两种情况产生的err结构并不一样,如果你想弹窗显示错误信息,你可能需要对err进行识别和提炼,为此我们内置了两个方法req.err.pickerreq.err.show,前者用于提炼错误信息文本,请放心,req.err.picker囊括了常见的error,能够很好地结合框架工作,而后者更方便,直接就是将error提炼并弹窗显示:

req.user.getInfo({
  id: '123',
})
  .then((res) => {
    console.log(res);
    if (res.code === 0) {
      // 请求成功
    } else {
      // 请求失败
      req.err.show(res.msg);
    }
  })
  .catch((err) => {
    req.err.show(err); // 弹窗显示错误信息
    console.log(req.err.picker(res)); // 打印错误信息
  });

method替代url

直接使用wx.request必然要面临手写url的问题,一方面书写不方便,另一方面难以维护。想象一下,一旦需要更换某个使用频率较高的接口,你可能要把每个调用的地方都修改一遍,而且由于url的拼接方式可能各不相同,使用find&replace功能可能会有纰漏。

req将接口进行统一管理,对外暴露的都是method式的api,我们举个栗子,比如获取用户信息

// url方式
wx.request({
  url: 'https://api.jack-lo.com/ez-wxlite/user/getInfo?id=123',
});

// method方式
req.user.getInfo({
  id: '123',
});

看到这里你可能会说,咦,url跑哪去了?当然,url需要在某处被集中定义,进入client/req/api目录,我们新建一个js文件user.js,并做如下定义:

/**
 * apiUrl是定义在config中的常量,
 * 它是当前使用接口的前缀,
 * 用于切换不同的环境
 * 这里的示例值为 https://api.jack-lo.com/ez-wxlite
 */
const { apiUrl } = require('../../config/index.js');

module.exports = {
  install(R, req) {
    R.user = {
      // 获取用户信息
      getInfo(data) {
        const url = `${apiUrl}/user/getInfo`;
        return req({ url, data });
      },
    };
  },
};

然后在client/req/index.js中进行挂载,req使用的是类似插件的方式,插件需要实现一个install方法,然后req使用useapi来进行安装:

const R = require('./prototype.js');
const userApi = require('./api/user.js');

R.use(userApi);

module.exports = R;

通过以上操作,我们将url转化为js api,这样的好处是方便调用和维护,同时,我们将接口做了一个归类,比如获取用户信息属于user类,将来user类也会继续添加其他一些接口,这样的接口更加的语义化,同时也起到命名空间的作用。

接口缓存

某些接口使用频率高但是变动又少,比如“获取当前用户的个人信息”、“获取省市区数据”,我们可以在前端通过缓存来提高性能,为此我们提供了如下几个api来控制接口缓存:

api 参数 返回值 示例 描述
req.cachify [string]req api [function]cachifyFn req.cachify('user.getMyInfo')() 调用接口并缓存数据
req.clearCache [string]req api, [string]id(optional) undefined req.clearCache('user.getMyInfo') 清除某个接口的缓存:接受两个参数,第一参数为req api名,第二参数为id(选填),也就是接口的唯一标识,这一般用在分页接口,默认可不填
req.clearAllCache [string]req api(optional) undefined req.clearAllCache('user.getMyInfo')() 清除所有缓存:接受一个参数req api(选填),当传值时,清除指定api的缓存,不传则清除所有api的缓存

我们假设你已经定义好了 “获取当前用户的个人信息”这一接口req.user.getMyInfo,我们要对这一接口进行调用后缓存,那么调用方式应该为:

req.cachify('user.getMyInfo')()
  .then((res) => {
    if (res.code === 0) {
      // res.data
    } else {
      req.err.show(res.msg);
    }
  })
  .catch((err) => {
    req.err.show(err);
  });

req.cachify接受的第一参数为一个req api名,并返回一个函数,这个函数入参同被定义的req api一致。

那么,什么时候清除缓存?当我对我的个人信息进行编辑并提交成功以后,我就需要清除缓存,以便获取最新的数据,假设已经定义好的“更新我的信息接口”为req.user.updateMyInfo

req.user.updateMyInfo()
  .then((res) => {
    if (res.code === 0) {
      req.clearCache('user.getMyInfo');
    } else {
      req.err.show(res.msg);
    }
  })
  .catch((err) => {
    req.err.show(err);
  });

注意:接口缓存是基于已定义接口的前提下,没有定义的接口是无法直接使用req.cachify调用的。

自动登录

按照官方文档,小程序的登录流程应该是这样的: 登录流程

简单来理解,小程序登录其实就是一个用code换取session_key的过程

当session_key过期了,我重新用新code换取新的session_key,再去发请求。

那么,session_key过期我们怎么知道?有些开发者可能会用wx.checkSession去定时检查,但是定多长的时间呢?不知道,因为微信不会把 session_key 的有效期告知开发者,而是根据用户使用小程序的行为对 session_key 进行续期,用户越频繁使用小程序,session_key 有效期越长,因此,定时刷新是个不好的实践,因为你把握不了时机,会造成资源浪费并且增加不确定性。

事实上,只有在需要跟微信(后端)接口打交道的时候,才需要有效的session_key,那么后端肯定知道什么时候过期了,因为微信后端会告诉我们,所以我们把过期的判断交给后端,只要后端被告知过期了,接口就返回一个固定的状态,比如code=3000,前端收到这一状态之后,便重新走一遍登录流程,获取到新的session_key,再重新发起请求。

大多数时候我们只停留在自己的业务里,并不需要跟微信打交道,我们可以约定自己的会话有效期,并且放宽一些,比如1天,只要是不需要跟微信打交道,这个时效性就会宽松的多,性能也会得到提高。

req的自动登录就是这么实现的,约定好登录过期状态(默认是res.code === 3000,请根据实际情况自行修改),req会自动调用wx.login重新获取js code,再用js code去调用登录接口换取新的sessionId,最后再发起一遍上次的请求。

这让开发者可以更加专注在业务开发上,而不必关心登录过期的问题。

router

页面的跳转存在哪些问题呢?

  1. 与接口的调用一样面临url的管理问题;
  2. 参数类型单一,只支持string。

第一个问题很好解决,我们就跟req一样,做一个集中管理。 第二个问题的情况是,当我们传递的参数argument不是string,而是number或者boolean时,也只能在下个页面得到一个argument.toString()值:

// pages/index/index.js
wx.navigateTo({
  url: '/pages/a/index?a=true',
});

// pages/a/index.js
Page({
  onLoad(options) {
    console.log(options.a); // => "true"
    console.log(typeof options.a); // => "string"
    console.log(options.a === true); // => false
  },
});

上面这种情况想必很多人都遇到过,而且感到很抓狂,本来就想传递一个boolean,结果不管传什么都会变成string。

我们的解决方案是:利用JSON.stringify+encodeURIComponentdecodeURIComponent+JSON.parse的方式让参数保真。

顺手也替换掉那不好记的navigate api,于是就出现了如下方式:

// pages/pageA/index.js
const { router } = require('../../framework/index.js');
Page({
  onReady() {
    router.push({
      name: 'home',
    });
  },
});


// pages/index/index.js
Page({
  onLoad() {
    console.log(this.$opts); // { id: '123', type: 1 }
  },
});

当然,上面的name: 'home'肯定也是事先配置好的,要不然鬼知道home到底跳转到哪里。

client/route/routes.js中我们可以看到:

module.exports = {
  // 主页
  home: {
    type: 'tab',
    path: '/pages/index/index',
  },
};

很明显,home其实就是/pages/index/index的一个别名,同时因为它是一个tab页面,所以我们也顺便指定了type: 'tab',默认是type: 'page'

除了支持别名之外,name也支持直接寻址,比如跳转home还可以写成这样:

router.push({
  name: 'index',  // => /pages/index/index
});

router.push({
  name: 'userCenter',  // => /pages/user_center/index
});

router.push({
  name: 'userCenter.phone',  // => /pages/user_center/phone/index
});

router.push({
  name: 'test.debug',  // => /pages/test/debug/index
});

注意,为了方便维护,我们规定了每个页面都必须存放在一个特定的文件夹,一个文件夹的当前路径下只能存在一个index页面,比如pages/index下面会存放pages/index/index.jspages/index/index.wxmlpages/index/index.wxsspages/index/index.json,这时候你就不能继续在这个文件夹根路径存放另外一个页面,而必须是新建一个文件夹来存放,比如pages/index/pageB/index.jspages/index/pageB/index.wxmlpages/index/pageB/index.wxsspages/index/pageB/index.json

router支持微信路由的所有方法,映射关系如下:

router.push => wx.navigateTo
router.replace => wx.redirectTo
router.pop => wx.navigateBack
router.relaunch => wx.reLaunch

可能你会发现这里少了一个wx.switchTab,这不是遗漏,而是被集成到了router.push当中去了,因为我们认为,跳转一个页面到底是page的方式还是tab的方式这类事情,根本与业务无关,它应该被透明化。

你可能会记得上面我们的home在路由配置的时候就已经指定了type: 'tab'的属性,这样一来我们便可以尽管调用router.push({name: 'home'}),至于具体是wx.navigateTo还是wx.switchTab,程序会自动帮我们处理的。

这里还有一个好处就是,当一个页面需要从tab转变为page的时候,我们只需要改一下routes的定义就可以了,完全不需要去一个个修改业务中的代码。

server

(内容待补充)

总结

由于精力有限,本应该在一开始就写好的文档,一直到现在都还只是陆陆续续在更新,感谢支持,如果有什么宝贵意见可以直接通过邮箱(jack-lo@foxmail.com)联系我,谢谢!