基于 h5 (vue) node(koa2) 微信jssdk实现的微信自定义分享

背景

最近团队有个需求,需要h5在微信中打开然后分享给同事或者朋友圈的时候自定义微信分享图标,标题等,即将如下的图片1做成图片2的形式
图片1
图片2

实现方案

前期准备

- 微信sdk官方文档(http://caibaojian.com/wxwiki/0030551f015f01ecaa56d20b88ee3c6cb32503bf.html)
- 团队公众号
- 服务器域名
- 准备
  * 公众号设置,功能设置,配置安全域名
    ![功能设置](/img/wx-share/share_03.jpg)
  * 开发,开通开发者功能,配置ip白名单(包含自己本地机器,测试环境机器)
- 代码方案
  * node层开启8411端口,package.json 中引入 concurrently同时启动客户端和服务端,vue3 开启到 8411 端口

  package.json

  
1
"dev": "concurrently \"npm run client\" \"npm run server\" --color ",
vue.config.js
1
2
3
4
5
6
7
8
9
10
'/wechat': {
// target: 'http://[::1]:8411',
target: 'http://127.0.0.1:8411',
changeOrigin: true,
wx: true,
pathRewrite: {
'^/wechat': '/wechat'
}
}
}
* 在node层按照jssdk微信公众平台的方案用koa2,koa-router写一个接口,监听8411端口 server/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const Koa = require('koa');
const path = require('path');
const staticFiles = require('koa-static')
const bodyParser = require('koa-bodyparser')
const cors = require('koa-cors')
const onerror = require('koa-onerror') //错误处理
const getAccess = require('./lib/wxAccess')
const Router = require('koa-router')
const router = new Router();
const app = new Koa();

onerror(app)
// logger

app.use(staticFiles(path.resolve(__dirname, "../public")))
app.use(cors())

app.use(bodyParser())

app.use(async (ctx, next) => {
await next();
const rt = ctx.response.get('X-Response-Time');
console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

router.post('/wechat/getConfig', async (ctx, next) => {
const url = ctx.request.body.url;
const resp = await getAccess(url);
console.log('resp in index', resp);
ctx.body = {
data: resp,
success: true,
message: ''
};
await next();
})


app.use(router.routes())

app.use(router.allowedMethods())

app.listen(8411, () => {
console.log('server is listen port:8411')
})
/lib/wxAccess
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const sha1 = require('sha1')
const config = require('./wxConfig')
const cache = require('memory-cache')
const rp = require('request-promise')
const crypto = require('crypto');


const jsApiList = [
'checkJsApi',
'onMenuShareTimeline',
'onMenuShareAppMessage',
'onMenuShareQQ',
'onMenuShareWeibo',
'hideMenuItems',
'showMenuItems',
'hideAllNonBaseMenuItem',
'showAllNonBaseMenuItem',
'translateVoice',
'startRecord',
'stopRecord',
'onRecordEnd',
'playVoice',
'pauseVoice',
'stopVoice',
'uploadVoice',
'downloadVoice',
'chooseImage',
'previewImage',
'uploadImage',
'downloadImage',
'getNetworkType',
'openLocation',
'getLocation',
'hideOptionMenu',
'showOptionMenu',
'closeWindow',
'scanQRCode',
'chooseWXPay',
'openProductSpecificView',
'addCard',
'chooseCard',
'openCard'
];
const getAccess = async (url) => {
return new Promise(async (resolve, reject) => {
const noncestr = crypto.randomBytes(Math.ceil(32 / 2)).toString('hex').slice(0, 32);;
const timestamp = Math.floor(Date.now() / 1000); //???
let wxdata = null;
try {
// ???????????????????
if (cache.get('ticket')) {
// console.log('ticket in cache', cache.get('ticket'))
const jsapi_ticket = cache.get('ticket');
wxdata = {
appId: config.wxappid,
noncestr,
timestamp,
url,
jsapi_ticket,
signature: sha1(`jsapi_ticket=${jsapi_ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=${url}`),
jsApiList
}
} else {
const tokenMap = await rp({uri: `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.wxappid}&secret=${config.wxappsecret}`});
// console.log('rp res', JSON.parse(tokenMap));
const resp = await rp({uri: `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${JSON.parse(tokenMap).access_token}&type=jsapi`});
const ticketMap = JSON.parse(resp);
cache.put('ticket', ticketMap.ticket, (1000 * 7200));
var resultCode = sha1(`jsapi_ticket=${ticketMap.ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=http://192.168.3.9:8080/sharepage/download`);
// console.log('resultCode', resultCode);

wxdata = {
appId: config.wxappid,
noncestr,
timestamp,
url,
jsapi_ticket: ticketMap.ticket,
signature: resultCode,
jsApiList
}
}
console.log('wx data', wxdata);
resolve(wxdata);
} catch(e) {
console.log('catch exception', JSON.stringify(e));
reject('catch exception', JSON.stringify(e));
}
})
}
module.exports = getAccess;
./wxConfig
1
2
3
4
5
6
7
const config = {
wxappid: 'wx2389aa4213cf6e5b', //AppID
wxappsecret: 'deec172ab3ba65ceef13a41c1218ef0e', //AppSecret
}


module.exports = config
* 前端vue引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
  // weixin.js
import wx from 'weixin-js-sdk' // 微信sdk依赖
import http from '@/utils/http'


const getWxConfig = (wxConf = {}) => {
return new Promise((resolve, reject) => {
if (!wxConf.appId) {
reject({})
} else {
resolve({
debug: false,
appId: wxConf.appId,
timestamp: wxConf.timestamp,
nonceStr: wxConf.noncestr,
signature: wxConf.signature,
jsApiList: wxConf.jsApiList
})
}
})
}

export const shareOnWx = async () => {
let url = window.location.href.split('#')[0] //url不能写死
// console.log('url', url);
const imgUrl = 'https://iflybudstest.iflytek.com/sharepage/download/sharelogo.jpg';
const {data: wxConf} = await http({
url: `/wechat/getConfig`,
data: {url},
method: 'post',
headers: {
'Content-type': 'application/json; charset=utf-8',
'Accept': 'application/json'
}
});
const wxConfig = await getWxConfig(wxConf);
wx.config(wxConfig);
// 分享给朋友
wx.ready(function () {
wx.onMenuShareAppMessage({
title: '点击下载iFLYBUDS App,通话转文字,记录更轻松', // 分享标题
desc: window.location.href.split('?')[0], // 分享描述
link: url, // 分享链接
imgUrl: 'https://iflybudstest.iflytek.com/img/wx_logo.jpg',
type: 'link', // 分享类型,music、video或link,不填默认为link
success: function (res) {
},
cancel: function () {
}
})
// 分享到朋友圈
wx.onMenuShareTimeline({
title: '点击下载iFLYBUDS App,通话转文字,记录更轻松', // 分享标题
desc: window.location.href.split('?')[0], // 分享描述
link: url, // 分享链接
imgUrl: 'https://iflybudstest.iflytek.com/img/wx_logo.jpg',
type: 'link', // 分享类型,music、video或link,不填默认为link
success: function () {
},
cancel: function () {
}
})


wx.error(function (res) {
alert(JSON.stringify(res))
// config信息验证失败会执行error函数,如签名过期导致验证失败,具体错误信息可以打开config的debug模式查看,也可以在返回的res参数中查看,对于SPA可以在这里更新签名。
})
})
}
  • 踩坑点

    • vue端口不监听8411, 需要在vue.config.js中调用
    • 签名出错,记得要按照微信公众平台的步骤来,可以通过https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=jsapisign看签名是否正确
    • 图片不展示,图片地址需要为服务器地址,并且该服务器域名为准备工作中配的js安全域名下
    • 问题跟进

      • 最近把整个vue项目由history路由换成hash模式,当时好开心啊,解决了运维每次配置nginx这个问题,可是,当沉浸在开心的快乐中时,没想到,测试提出来一个问题,下载页无法分享了,分享出来再点击进去是空白了,好慌好慌啊

        • 问题定位:
          1、vue hash模式下的路径自动带#,然后微信会直接截取#后面的字符串,然后加上自己的标识字符串,这样会导致自己的vue项目无法辨别自己的vue路径,会自动跳到首页,如下:
          原: https://iflybudstest.iflytek.com/#/download
          新: https://iflybudstest.iflytek.com/?from=singlemessage&isappinstalled=0#/download
          2、如果在vue hash模式下与history模式下对微信做签名的url还是全路径的话,比如history模式下的路径是 http://192.168.3.9:8080/sharepage/download,那么在hash模式下传递到微信签名端的url只要是域名就可以了,也即是第一个# 之前的部分
          在前端写微信分享的时候,会有两个字符串,一个是图片,一个是分享链接
          a) 对于分享链接,因为微信会对分享的链接做#拦截,所以这里需要对分享链接做组合:如下: url + ‘#’ + window.location.href.split(‘#’)[1]
          b) 对于分享图片,要保证图片的地址路径在域名服务器上可查找,但是试了这个方法后,分享出去的图片还是错误的,所以百度试了其他方法,在head里面加个img标签,如下:

          1
          <img id="shareImg" src="<%= BASE_URL %>sharelogo.jpg" width="0" height="0" />

          然后在分享的配置图片中写

          1
          imgUrl: document.getElementById('shareImg').src,
  • 如上,整个与微信分享从测试环境的测试与线上环境的测试已完毕,下面我把全部代码贴出,如下:
    .vue项目中

    1
    2
    3
    4
    5
    6
    import { shareOnWx } from '../utils/wx'
    export default {
    created() {
    shareOnWx();
    },
    }

    wx.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    // weixin.js
    import wx from 'weixin-js-sdk'; // 微信sdk依赖
    import http from '@/utils/http'


    const getWxConfig = (wxConf = {}) => {
    return new Promise((resolve, reject) => {
    if (!wxConf.appId) {
    reject({})
    } else {
    resolve({
    debug: false,
    appId: wxConf.appId,
    timestamp: wxConf.timestamp,
    nonceStr: wxConf.noncestr,
    signature: wxConf.signature,
    jsApiList: wxConf.jsApiList
    })
    }
    })
    }

    export const shareOnWx = async () => {
    let url = window.location.href.split('#')[0] //url不能写死
    // console.log('share img', `${url}sharelogo.jpg`)
    // console.log('url', `${url}?path=download`)
    // console.log('shareImg.src',document.getElementById('shareImg').src)
    const {data: wxConf} = await http({
    url: `/wechat/getConfig`,
    data: {url},
    method: 'post',
    headers: {
    'Content-type': 'application/json; charset=utf-8',
    'Accept': 'application/json'
    }
    });
    const wxConfig = await getWxConfig(wxConf);
    wx.config(wxConfig);
    // 分享给朋友
    wx.ready(function () {
    wx.onMenuShareAppMessage({
    title: '点击下载iFLYBUDS App,通话转文字,记录更轻松', // 分享标题
    desc: window.location.href.split('?')[0], // 分享描述
    link: url + '#' + window.location.href.split('#')[1],
    imgUrl: document.getElementById('shareImg').src,
    type: 'link', // 分享类型,music、video或link,不填默认为link
    success: function (res) {
    // console.log('分享给朋友 onMenuShareAppMessage', res);
    // 设置成功
    },
    cancel: function () {
    alert('分享cancel')
    // 用户取消分享后执行的回调函数
    // console.log('分享给朋友cacel');
    }
    })
    // 分享到朋友圈
    wx.onMenuShareTimeline({
    title: '点击下载iFLYBUDS App,通话转文字,记录更轻松', // 分享标题
    desc: window.location.href.split('?')[0], // 分享描述
    link: url + '#' + window.location.href.split('#')[1], // 分享链接
    imgUrl: document.getElementById('shareImg').src,
    type: 'link', // 分享类型,music、video或link,不填默认为link
    success: function () {
    // 设置成功
    },
    cancel: function () {
    // 用户取消分享后执行的回调函数
    }
    })
    wx.error(function (res) {
    alert(JSON.stringify(res))
    // config信息验证失败会执行error函数,如签名过期导致验证失败,具体错误信息可以打开config的debug模式查看,也可以在返回的res参数中查看,对于SPA可以在这里更新签名。
    })
    })
    }

    node 端 .wxAccess.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    const sha1 = require('sha1')
    const config = require('./wxConfig')
    const cache = require('memory-cache')
    const rp = require('request-promise')
    const crypto = require('crypto');


    const jsApiList = [
    'checkJsApi',
    'onMenuShareTimeline',
    'onMenuShareAppMessage',
    'onMenuShareQQ',
    'onMenuShareWeibo',
    'hideMenuItems',
    'showMenuItems',
    'hideAllNonBaseMenuItem',
    'showAllNonBaseMenuItem',
    'translateVoice',
    'startRecord',
    'stopRecord',
    'onRecordEnd',
    'playVoice',
    'pauseVoice',
    'stopVoice',
    'uploadVoice',
    'downloadVoice',
    'chooseImage',
    'previewImage',
    'uploadImage',
    'downloadImage',
    'getNetworkType',
    'openLocation',
    'getLocation',
    'hideOptionMenu',
    'showOptionMenu',
    'closeWindow',
    'scanQRCode',
    'chooseWXPay',
    'openProductSpecificView',
    'addCard',
    'chooseCard',
    'openCard'
    ];
    const getAccess = async (url) => {
    return new Promise(async (resolve, reject) => {
    console.log('1321414324324325435');
    const noncestr = crypto.randomBytes(Math.ceil(32 / 2)).toString('hex').slice(0, 32);;
    const timestamp = Math.floor(Date.now() / 1000); //timestamp
    let wxdata = null;
    try {
    // ticket
    if (cache.get('ticket')) {
    console.log('ticket in cache', cache.get('ticket'))
    const jsapi_ticket = cache.get('ticket');
    wxdata = {
    appId: config.wxappid,
    noncestr,
    timestamp,
    url,
    jsapi_ticket,
    signature: sha1(`jsapi_ticket=${jsapi_ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=${url}`),
    jsApiList
    }
    } else {
    const tokenMap = await rp({uri: `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.wxappid}&secret=${config.wxappsecret}`});
    // console.log('rp res', JSON.parse(tokenMap));
    const resp = await rp({uri: `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${JSON.parse(tokenMap).access_token}&type=jsapi`});
    const ticketMap = JSON.parse(resp);
    // console.log('ticketMap', ticketMap);
    cache.put('ticket', ticketMap.ticket, (1000 * 7200));
    var resultCode = sha1(`jsapi_ticket=${ticketMap.ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=${url}`);
    // console.log('resultCode', `jsapi_ticket=${ticketMap.ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=${url}`);
    // console.log('download url', downloadUrl)
    wxdata = {
    appId: config.wxappid,
    noncestr,
    timestamp,
    url,
    jsapi_ticket: ticketMap.ticket,
    signature: resultCode,
    jsApiList
    }
    }
    console.log('wx data', wxdata);
    resolve(wxdata);
    } catch(e) {
    console.log('catch exception', JSON.stringify(e));
    reject('catch exception', JSON.stringify(e));
    }
    })
    }

    module.exports = getAccess;

    参考文档
    https://www.jianshu.com/p/97729dd2c94d
    https://cn.bing.com/search?q=vue+hash%E6%A8%A1%E5%BC%8F%E5%BE%AE%E4%BF%A1%E5%88%86%E4%BA%AB&PC=U316&FORM=CHROMN