背景
网页上的内容分享至朋友圈、微信好友时总是显示干巴巴的一个链接地址,对于预期的打开率、转化率提升显然很不利,也弱化了潜在的分享动机。于是便想着能否自定义标题、描述、题图(如Logo)?
了解到微信网页开发JS-SDK便是需要的解决方案,于是就此动手开始踩坑之旅……
基础步骤
- 绑定域名
- 引入JS文件
- 通过
config
接口注入权限验证配置 - 通过
ready
接口处理成功验证 - 通过
error
接口处理失败验证
在此不再赘述,详见微信开放文档官方说明。
获取AppID和AppSecret
在公众号后台→开发→基本配置获取开发者ID(AppID
)和开发者密码(AppSecret
),并设置IP白名单(包括线上服务器IP和本地开发调试IP)。
获取access_token
因为request
包提示deprecated,因此不得已需寻找替代方案。Node.js原生的http
模块无甚封装,调用实在麻烦,于是转而使用request
官方issue中提到的一轻量包:node-fetch
。
另,考虑到ES7(8)的支持和应用已经很成熟,想想自己也应与时俱进,于是在expressjs
中也试着采用async
方式。
代码如下:
javascriptCopy code- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
async fetchAccessToken() {
const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential';
const data = await fetch(`${url}&appid=${credentials.wxMpAppID}&secret=${credentials.wxMpAppSecret}`)
.then((res) => res.json());
if (data.access_token) {
return data.access_token;
}
logger.error(formatOpLog({
fn: 'fetchAccessToken',
msg: data.errmsg || 'Fetch access_token fail.',
data: {
code: data.errcode || STATUS_CODES.UNKNOWN_ERROR
}
}));
throw new Error('Fetch access_token fail.');
}
async fetchAccessToken() {
const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential';
const data = await fetch(`${url}&appid=${credentials.wxMpAppID}&secret=${credentials.wxMpAppSecret}`)
.then((res) => res.json());
if (data.access_token) {
return data.access_token;
}
logger.error(formatOpLog({
fn: 'fetchAccessToken',
msg: data.errmsg || 'Fetch access_token fail.',
data: {
code: data.errcode || STATUS_CODES.UNKNOWN_ERROR
}
}));
throw new Error('Fetch access_token fail.');
}
获取jsapi_ticket
同理,使用node-fetch
发请求获取jsapi_ticket
,代码如下:
javascriptCopy code- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
async fetchJsApiTicket(param) {
const url = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi';
const data = await fetch(`${url}&access_token=${param.token}`)
.then((res) => res.json());
if (data.ticket) {
return data.ticket;
}
logger.error(formatOpLog({
fn: 'fetchJsApiTicket',
msg: data.errmsg || 'Fetch jsapi_ticket fail.',
data: {
code: data.errcode || STATUS_CODES.UNKNOWN_ERROR
}
}));
throw new Error('Fetch jsapi_ticket fail.');
}
async fetchJsApiTicket(param) {
const url = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi';
const data = await fetch(`${url}&access_token=${param.token}`)
.then((res) => res.json());
if (data.ticket) {
return data.ticket;
}
logger.error(formatOpLog({
fn: 'fetchJsApiTicket',
msg: data.errmsg || 'Fetch jsapi_ticket fail.',
data: {
code: data.errcode || STATUS_CODES.UNKNOWN_ERROR
}
}));
throw new Error('Fetch jsapi_ticket fail.');
}
存储access_token和jsapi_ticket
微信开放文档中数次强调需对access_token
和jsapi_ticket
进行缓存,以避免API调用受限,因此还需对获取到的access_token
和jsapi_ticket
进行持久化存储。此处采用数据库存储方式进行持久化,利用现有的options
竖表,增加4条键值对记录。
代码如下:
javascriptCopy code- 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
async saveTokenAndTicket(data) {
await optionService.saveOptions({
settings: [{
name: constants.KEY_WX_MP_ACCESS_TOKEN,
value: data.token
}, {
name: constants.KEY_WX_MP_ACCESS_TOKEN_TIME,
value: data.fetchTime
}, {
name: constants.KEY_WX_MP_JSAPI_TICKET,
value: data.ticket
}, {
name: constants.KEY_WX_MP_JSAPI_TICKET_TIME,
value: data.fetchTime
}]
});
}
async saveOptions(param) {
const {settings} = param;
let transaction;
try {
transaction = await models.sequelize.transaction();
for (let setting of settings) {
await Option.update({
optionValue: setting.value
}, {
where: {
optionName: {
[Op.eq]: setting.name
}
},
transaction
});
}
await transaction.commit();
} catch (err) {
logger.error(formatOpLog({
fn: 'saveOptions',
msg: err.message,
data: {
options: settings
}
}));
if (transaction) {
await transaction.rollback();
}
throw err;
}
}
async saveTokenAndTicket(data) {
await optionService.saveOptions({
settings: [{
name: constants.KEY_WX_MP_ACCESS_TOKEN,
value: data.token
}, {
name: constants.KEY_WX_MP_ACCESS_TOKEN_TIME,
value: data.fetchTime
}, {
name: constants.KEY_WX_MP_JSAPI_TICKET,
value: data.ticket
}, {
name: constants.KEY_WX_MP_JSAPI_TICKET_TIME,
value: data.fetchTime
}]
});
}
async saveOptions(param) {
const {settings} = param;
let transaction;
try {
transaction = await models.sequelize.transaction();
for (let setting of settings) {
await Option.update({
optionValue: setting.value
}, {
where: {
optionName: {
[Op.eq]: setting.name
}
},
transaction
});
}
await transaction.commit();
} catch (err) {
logger.error(formatOpLog({
fn: 'saveOptions',
msg: err.message,
data: {
options: settings
}
}));
if (transaction) {
await transaction.rollback();
}
throw err;
}
}
此处,修改前原采用async.times
调用,通过日志查看到调用顺序是根据迭代的顺序,因此改为async+for+await貌似并无不妥,但最终的执行效率似乎相比async.times
相差了60-80%,只是总计大概20-30ms的差距似乎可以忽略不计。
完整流程
将以上步骤串联起来,包括:判断是否已缓存access_token
、jsapi_ticket
,调用API获取access_token
、jsapi_ticket
,重新缓存,返回结果。
代码如下:
javascriptCopy code- 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
async getTokenAndTicket() {
try {
const data = await optionService.getOptionsByKeys({
optionKeys: [
constants.KEY_WX_MP_ACCESS_TOKEN,
constants.KEY_WX_MP_ACCESS_TOKEN_TIME,
constants.KEY_WX_MP_JSAPI_TICKET,
constants.KEY_WX_MP_JSAPI_TICKET_TIME
]
});
let token = data[constants.KEY_WX_MP_ACCESS_TOKEN].optionValue;
let tokenStart = +data[constants.KEY_WX_MP_ACCESS_TOKEN_TIME].optionValue || 0;
let ticket = data[constants.KEY_WX_MP_JSAPI_TICKET].optionValue;
let ticketStart = +data[constants.KEY_WX_MP_JSAPI_TICKET_TIME].optionValue || 0;
const nowTime = Math.ceil(Date.now() / 1000);
let refresh = false;
if (tokenStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) {
refresh = true;
token = await actions.fetchAccessToken();
}
if (ticketStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) {
ticket = await actions.fetchJsApiTicket({
token
});
}
if (refresh) {
await actions.saveTokenAndTicket({
token,
ticket,
fetchTime: nowTime
});
}
return {
token,
ticket,
fetchTime: nowTime
};
} catch (e) {
logger.error(formatOpLog({
fn: 'saveOptions',
msg: e.message
}));
}
}
async getTokenAndTicket() {
try {
const data = await optionService.getOptionsByKeys({
optionKeys: [
constants.KEY_WX_MP_ACCESS_TOKEN,
constants.KEY_WX_MP_ACCESS_TOKEN_TIME,
constants.KEY_WX_MP_JSAPI_TICKET,
constants.KEY_WX_MP_JSAPI_TICKET_TIME
]
});
let token = data[constants.KEY_WX_MP_ACCESS_TOKEN].optionValue;
let tokenStart = +data[constants.KEY_WX_MP_ACCESS_TOKEN_TIME].optionValue || 0;
let ticket = data[constants.KEY_WX_MP_JSAPI_TICKET].optionValue;
let ticketStart = +data[constants.KEY_WX_MP_JSAPI_TICKET_TIME].optionValue || 0;
const nowTime = Math.ceil(Date.now() / 1000);
let refresh = false;
if (tokenStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) {
refresh = true;
token = await actions.fetchAccessToken();
}
if (ticketStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) {
ticket = await actions.fetchJsApiTicket({
token
});
}
if (refresh) {
await actions.saveTokenAndTicket({
token,
ticket,
fetchTime: nowTime
});
}
return {
token,
ticket,
fetchTime: nowTime
};
} catch (e) {
logger.error(formatOpLog({
fn: 'saveOptions',
msg: e.message
}));
}
}
签名
获取到access_token
和jsapi_ticket
后,便需根据文档中的算法对其进行签名,参考微信开放文档提供的Demo(代码无力吐槽),简化如下:
javascriptCopy code- 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 createNonceStr = () => {
const alphaDigitCount = 36;
const strLength = 15;
return Math.random().toString(alphaDigitCount).substr(2, strLength);
};
const createTimestamp = () => Math.ceil(Date.now() / 1000) + '';
const transformArgs = (args) => {
let keys = Object.keys(args);
keys = keys.sort();
let argsArr = [];
keys.forEach((key) => {
argsArr.push(key.toLowerCase() + '=' + args[key]);
});
return argsArr.join('&');
};
/**
* 签名算法
* @param {string} ticket 用于签名的 jsapi_ticket
* @param {string} url 用于签名的 url
* @returns {Object} 签名结果
*/
const sign = (ticket, url) => {
const argsObj = {
'jsapi_ticket': ticket,
'nonceStr': createNonceStr(),
'timestamp': createTimestamp(),
url
};
const argsStr = transformArgs(argsObj);
const shaObj = new JsSHA('SHA-1', 'TEXT');
shaObj.update(argsStr);
argsObj.signature = shaObj.getHash('HEX');
logger.info(formatOpLog({
fn: 'sign',
msg: 'Sign success.',
data: argsObj
}));
return argsObj;
};
const createNonceStr = () => {
const alphaDigitCount = 36;
const strLength = 15;
return Math.random().toString(alphaDigitCount).substr(2, strLength);
};
const createTimestamp = () => Math.ceil(Date.now() / 1000) + '';
const transformArgs = (args) => {
let keys = Object.keys(args);
keys = keys.sort();
let argsArr = [];
keys.forEach((key) => {
argsArr.push(key.toLowerCase() + '=' + args[key]);
});
return argsArr.join('&');
};
/**
* 签名算法
* @param {string} ticket 用于签名的 jsapi_ticket
* @param {string} url 用于签名的 url
* @returns {Object} 签名结果
*/
const sign = (ticket, url) => {
const argsObj = {
'jsapi_ticket': ticket,
'nonceStr': createNonceStr(),
'timestamp': createTimestamp(),
url
};
const argsStr = transformArgs(argsObj);
const shaObj = new JsSHA('SHA-1', 'TEXT');
shaObj.update(argsStr);
argsObj.signature = shaObj.getHash('HEX');
logger.info(formatOpLog({
fn: 'sign',
msg: 'Sign success.',
data: argsObj
}));
return argsObj;
};
其中,加密库jssha
相较Demo中的版本已做了大幅更新,API无法直接使用(否则会报错),o(╯□╰)o。
增加接口路由
最后,新增/sign
路由,以提供post接口供前端调用(也可以无需调用直接输出在页面上,只需要保证签名的url和api调用的url的一致性)。
代码如下:
javascriptCopy code- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
app.post('/wechat/sign', wechat.getSignature);
async getSignature(reqUrl) {
const data = await actions.getTokenAndTicket();
const signData = actions.sign(data.ticket, reqUrl);
return {
appId: credentials.wxMpAppID,
timestamp: signData.timestamp,
nonceStr: signData.nonceStr,
signature: signData.signature
};
}
app.post('/wechat/sign', wechat.getSignature);
async getSignature(reqUrl) {
const data = await actions.getTokenAndTicket();
const signData = actions.sign(data.ticket, reqUrl);
return {
appId: credentials.wxMpAppID,
timestamp: signData.timestamp,
nonceStr: signData.nonceStr,
signature: signData.signature
};
}
至此,服务端部分结束。测试/sign
调用,完美返回需要的结果:
javascriptCopy code- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
{
"code": 0,
"message": null,
"token": "rzRO9fCb-Ur31Yb5GvVfSpfCJIZLTmX_ZxMM",
"data": {
"appId": "...",
"timestamp": "1593315860",
"nonceStr": "...",
"signature": "..."
}
}
{
"code": 0,
"message": null,
"token": "rzRO9fCb-Ur31Yb5GvVfSpfCJIZLTmX_ZxMM",
"data": {
"appId": "...",
"timestamp": "1593315860",
"nonceStr": "...",
"signature": "..."
}
}
前端调用实现
参考微信开放文档,代码如下:
javascriptCopy code- 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
const service = {
initWxConfig: function () {
wx.config({
debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
appId: service.signData.appId, // 必填,公众号的唯一标识
timestamp: service.signData.timestamp, // 必填,生成签名的时间戳
nonceStr: service.signData.nonceStr, // 必填,生成签名的随机串
signature: service.signData.signature, // 必填,签名
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'] // 必填,需要使用的JS接口列表
});
},
initWxEvent: function () {
wx.ready(function () {
service.checkJsApi(() => {
service.initWxShareEvent();
});
});
wx.error(function (res) {
logger.log(res.errMsg);
});
},
checkJsApi: function (cb) {
wx.checkJsApi({
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'],
success: function (res) {
// 以键值对的形式返回,可用的api值true,不可用为false
service.wxApiFlag = res.checkResult;
cb();
}
});
},
initWxShareEvent: function () {
const shareData = {
title: document.title,
desc: $('[name="description"]').attr('content'),
link: url,
imgUrl: 'https://www.ifuyun.com/logo.png',
success: function () {
logger.info('已分享');
}
};
if (service.wxApiFlag.updateAppMessageShareData) {
wx.updateAppMessageShareData(shareData);
}
if (service.wxApiFlag.updateTimelineShareData) {
wx.updateTimelineShareData(shareData);
}
}
}
const service = {
initWxConfig: function () {
wx.config({
debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
appId: service.signData.appId, // 必填,公众号的唯一标识
timestamp: service.signData.timestamp, // 必填,生成签名的时间戳
nonceStr: service.signData.nonceStr, // 必填,生成签名的随机串
signature: service.signData.signature, // 必填,签名
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'] // 必填,需要使用的JS接口列表
});
},
initWxEvent: function () {
wx.ready(function () {
service.checkJsApi(() => {
service.initWxShareEvent();
});
});
wx.error(function (res) {
logger.log(res.errMsg);
});
},
checkJsApi: function (cb) {
wx.checkJsApi({
jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'],
success: function (res) {
// 以键值对的形式返回,可用的api值true,不可用为false
service.wxApiFlag = res.checkResult;
cb();
}
});
},
initWxShareEvent: function () {
const shareData = {
title: document.title,
desc: $('[name="description"]').attr('content'),
link: url,
imgUrl: 'https://www.ifuyun.com/logo.png',
success: function () {
logger.info('已分享');
}
};
if (service.wxApiFlag.updateAppMessageShareData) {
wx.updateAppMessageShareData(shareData);
}
if (service.wxApiFlag.updateTimelineShareData) {
wx.updateTimelineShareData(shareData);
}
}
}
测试
一切准备就绪并发布上线后,兴奋地在浏览器和微信中进行测试分享,结果……
"errMsg": "updateAppMessageShareData:permission denied"
"errMsg": "updateTimelineShareData:permission denied"
在网上搜了一圈,才发现没有权限……没有权……没有……没……
搞了半天,你告诉我只有认证号才有此权限?(╯‵□′)╯︵┻━┻
缓过神后到微信开放文档仔细逛了一圈,才发现在某个小小的角落里提到了这么几个字:在申请到认证公众号之前……原来“认证”两个字在这里摆着……无奈,只能雪藏了开发了近两天的代码,等待重见天日的那一天……
后记
完整代码参见:GitHub。ღ( ´・ᴗ・` )