微信小程序相对于微信网页开发更麻烦一点,尤其是前端开发不太熟练的情况下,因为小程序前端使用专门的微信开发者工具进行开发,好在和普通网页开发的项目结构差不多,前端语法也类似,同时js也完全兼容。

仍然用上一个项目进行演示,开发一套小程序版。前端也是只有一个页面,业务流程和功能完全一样,这样后端可以也使用同一套。

小程序项目在创建后,自带一套简单的示例代码,根目录下是APP级别的js、配置/数据、样式;pages目录存放的是应用的页面,每个页面用单独目录存放。

预览和调试

微信开发者工具具有实时预览的功能,既可以在IDE中(默认在左侧窗格,可以选择不同机型查看对应效果)进行,也可以直接在真机/PC模拟器上进行预览和调试(工具栏的真机调试按钮),使用方法类似Chrome的F12开发者工具。在详细教程可以参考官方文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools.html

安全性

仍然是十分重要的一条原则:不要把敏感信息和处理过程暴露到前端。这条原则并不局限于小程序开发,在任何场景里都应该遵循。另外,小程序平台为此也专门做了限制,request合法域名无法添加微信API服务器,阻止开发者直接从前端调用小程序API。

后台常用设置

小程序后台需要设置的地方比较少,主要集中在开发页面中:

  • 开发设置:获取AppID、获取或重置AppSecret;
  • 代码上传IP白名单;
  • 服务器域名:主要是各类网络请求的合法域名,出于安全性考虑,小程序只能向有限的服务器发起请求,服务器白名单即在此设置。

开发和部署流程

小程序后台管理部分:

  • 注册小程序账号;
  • 按上一小节后台常用设置进行配置,记录AppID,创建AppSecret;

小程序后端开发和部署不需要做特殊处理,按正常的网站开发、部署流程去执行即可。

小程序前端开发和部署:

  • 下载微信WEB开发者工具(可以用来开发、调试小程序,调试微信网页,下载地址:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Web_Developer_Tools.html
  • 开发和调试小程序均在web开发者工具中进行;
  • 发布小程序需要分两步
    • 在开发工具中上传项目(此时版本被标记为开发版本,只有开发和测试人员才可以访问);
    • 在网页管理后台对上传的项目进行初审,并提交官方审核(此时会被标记为审核版本进入审核状态);
    • 官方审核通过后,即成为正式的线上版本,也就意味着小程序正式上线,可以被所有人访问了。

免登录

这儿的免登只能理解为不需要输入用户名和密码,只要用户点击授权按钮即可自动获取到用户信息并在后台为用户自动登录网站。但是小程序使用完全的前后端分离的架构,所以一旦登录成功,必须保存好COOKIE,以便后面每次网络请求都能带上SESSION,保持登录状态。

流程比网页应用更简单:前端获取code -> 后端拿code换取openid -> 创建新的User对象并登录 -> 小程序向后端上传用户昵称和头像(也可以由后端获取,但是小程序的所有信息都是加密的,后端需要在code换取openid时保存session_key用作解密密钥,再对后续请求到的用户信息进行解密,因为本项目需要的信息能够全部直接取到,所以就不用这种复杂的手段了)。

后端请求到的openid,加上小程序前端API拿到的用户昵称和头像,就可以组合起来一个完整的用户对象。

DEMO

还是拿附近的人应用来做为演示DEMO。

项目原有内容微调

  • 每个平台在用户名后面标注平台来源;
  • General静态类中,维护了一个List<WxUser>,所有访客登录时会自动将对应的WxUser对象添加进来,但是有几处添加代码没有做重复检验(已有的不再添加),现在已经补上了;
  • 示例后端使用.net core环境,为了小程序Session保持功能,需要在项目Startup.cs的ConfigureServices方法里添加session服务,在Configure方法中为项目启用session。

        public void ConfigureServices(IServiceCollection services)
        {
              // ……
              services.AddSession();
              // ……
         }
         
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
              // ……
              app.UseSession();
              // ……
         }

大致结构:

后端的结构和钉钉、微信WEB应用类似,三个应用共用公共接口,各自有一个自己的Helper类用作辅助。


    public static class MiniHelper
    {
        public const string AppId = "……";
        public const string AppSecret = "……";
    }
    public class CodeResponse 
    {
        public string session_key { get; set; }
        public string openid { get; set; }
        public string unionid { get; set; }
        public string errcode { get; set; }
        public string errmsg { get; set; }
    }

因为本质上只是为同一个项目写了三个客户端,所以小程序的前后端功能和微信网页、钉钉H5应用大致一样,除了小程序因为本身的限制,需要额外多一个更新信息(/MiniController/UpdateInfo),和一个专用的登录接口(/MiniController/Index)。


// MiniController
    public async Task<IActionResult> Index(string code = "")
    {
        //一定一定别从前端请求微信服务端API接口,
        //不过小程序本身已经做了阻止,也只能从后端来请求服务端API。
        if (string.IsNullOrEmpty(code)) return BadRequest("BAD_REQUEST");
        using WebClient client = new WebClient();
        string url =
        $"https://api.weixin.qq.com/sns/jscode2session?appid={MiniHelper.AppId}&secret={MiniHelper.AppSecret}&js_code={code}&grant_type=authorization_code";
        string resp = client.DownloadString(url);
        CodeResponse respObj = JsonConvert.DeserializeObject<CodeResponse>(resp);
        if (string.IsNullOrEmpty(respObj.openid))
        {
        return BadRequest("INVALID_REQUEST");
        }
        else
        {
        //拿到openid就可以先登录了,小程序收到本接口响应后,会立刻将缺的信息由/UpdateInfo接口传过来
        if (General.Users.Count(u => u.Openid == respObj.openid) == 0) General.Users.Add(new WxUser() { Openid = respObj.openid });
        var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
        identity.AddClaim(new Claim(ClaimTypes.Sid, respObj.openid));
        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)).ConfigureAwait(false);
        return Content(HttpContext.Session.Id);
        }
    }

    public string UpdateInfo(string nick, string avatar)
    {
        // 由小程序前端补充缺失的昵称、头像信息,避免在后端调用专门的接口,再解密消息
        WxUser user = General.GetUser(HttpContext);
        if (user is null) return "unauthorized";
        user.Nickname = HttpUtility.UrlDecode(nick) + "(小程序)";
        user.Avatar = HttpUtility.UrlDecode(avatar);
        return "succ";
    }

小程序如何和服务器通信

小程序前端的核心功能都由js实现,与服务器通信的时候,可以用wx.request()方法。不过与网页相比,它有一个致命的问题——不具备SESSION保持的能力,每次wx.request()对服务器而言都是一个新的客户端。所以为了能让服务器正常识别客户端身份,就需要在第一次与服务器通信后,将SESSION保存下来,每次联系后端服务器,均在COOKIE中附上该内容。这种做法类似蜘蛛,可以参考我之前写的一篇蜘蛛示例合集写几个爬虫

在这个地方有一个比较大的坑,开发工具调试的时候,取ResponseHeader里的Cookie,需要用set-cookie索引,但是到了手机微信,却变成了Set-Cookie(小程序项目在创建的时候,就已经很贴心地把获取用户昵称、头像的代码自动写好了,可以在这基础之上实现自己的功能)。

免登录小节所说,wx.getUserInfo只能获取头像和昵称,如果想获取openid还需要和微信网页一样,用wx.login()方法里获得的code去换取。


    // 小程序代码:app.js登录部分
    wx.login({      
      success: res => {
        // 由code换userinfo,一定一定一定不能在前端直接获取,要交给后端处理
        // 在此把code传递到后端,由后端换取openid,小程序取cookie保存,
        // 用在后续的登录中,借此维持登录状态。
        wx.request({
          url: 'https://wx.chenxin.info/Mini?code=' + res.code,          
          success (e){
            if(e.statusCode==200){
              console.log(e.header);
              var cookie = e.header["set-cookie"];
              // 这的大小写问题,是个非常坑的地方
              if(cookie == null) cookie=e.header["Set-Cookie"];
              wx.setStorageSync('cookie', cookie);
              console.log("成功登录:" + cookie);
            }
          }
        })
      }
    })

// index.js,实现如下方法:
// 初始化,在getUserInfo后调用,主要用于完善头像、昵称信息;
// 获取个性签名列表;
// 获取附近的人;
// 修改个性签名并上传定位。
  init: function(){
    var _this = this;
    console.log(_this.data.userInfo);
    // 借由小程序前端向后端提交昵称、头像,避免后端的繁琐步骤。
    wx.request({
      url: 'https://wx.chenxin.info/Mini/UpdateInfo?nick=' +
        encodeURI(_this.data.userInfo.nickName)+'&avatar=' +
        encodeURI(_this.data.userInfo.avatarUrl),
      header:{'cookie': wx.getStorageSync('cookie')},
      success (e){
        _this.getNearby();
      }});
    this.getWords();
  },
  getWords: function(){
    var _this=this;
    wx.request({
      url: 'https://wx.chenxin.info/Home/Words',
      method: "GET",
      data: {},
      success (res) {
        console.log(res);
        _this.setData({words:res.data});
        _this.getNearby();
      }
    })
  },
  getNearby: function(){
    var _this = this;
    wx.request({
      url: 'https://wx.chenxin.info/Home/Nearby',
      method: "GET",
      header:{
        'cookie': wx.getStorageSync('cookie')
      },
      success (res) {
        console.log('users api: '+JSON.stringify(res.data));
        _this.setData({users : res.data});
      },
      fail (err){console.log(err)}
    })
  },
  upload:function(e){    
    var msgid=e.target.dataset.id;
    var _this=this;
    _this.setData({'word':msgid});

    wx.getLocation({
      type: 'wgs84',
      success (loc){
        _this.setData({
          x : loc.latitude,
          y : loc.longitude,
          word : msgid});
        wx.request({
          url: 'https://wx.chenxin.info/Home/Upload',
          header:{
            'cookie': wx.getStorageSync('cookie'),
            'content-type':'application/x-www-form-urlencoded'
          },
          data:{
            x: loc.latitude,
            y: loc.longitude,
            message: msgid},
          method: "POST",
          success (eee){
            console.log(eee);
            _this.getNearby();
          }
        })
      }
    })
  }

小程序的数据存取

这个项目用到了两个存取方式:Data对象(分页面和全局两种),本地存储。

Data对象需要获取到页面对象以后才可以使用,在很多地方(比如wx.request回调里)由于作用域干扰,只能在外部将它赋给另一个变量,然后再获取。


// 全局
App({
  globalData: {
    userInfo: null
  }
}) 
// 调用方式
  getUserInfo: function(e) {
    app.globalData.userInfo = e.detail.userInfo
    this.setData({
      userInfo: e.detail.userInfo,      
      hasUserInfo: true
    })
  }
  
// 页面  
Page({
  data: {
    userInfo: {},
    hasUserInfo: false,
    canIUse: wx.canIUse('button.open-type.getUserInfo'),
    en_data:'',
    users:{},
    words:{},
    word:'',
    x:'',
    y:''
  },
// ……

function(){
    var _this = this; //this对象赋给另一个变量
    wx.request({
      url: 'https://wx.chenxin.info/Home/Words',
      method: "GET",
      data: {},
      success (res) {
        // setData方法设置值,读取的时候用_this.data.words
        _this.setData({words:res.data});
        _this.getNearby();
      }
    })
  }

本地存储更为简单,完全不会有作用域干扰的问题


//小程序自带的本地存储存值和取值的 代码
    var logs = wx.getStorageSync('logs') || []
    logs.unshift(Date.now())
    wx.setStorageSync('logs', logs)

最终效果

小程序(真机)

小程序(微信开发工具模拟器)

钉钉

微信网页应用