最近在接入微信分享时发现一个奇怪的现象:

首次分享没问题,二次分享总是失败。

具体操作如下:

  1. 直接在微信扫码打开页面

  2. 分享 (分享正常,title、content都带上了)

  3. 点击分享链接打开页面

  4. 二次分享 (分享失败)

首先查看官方文档,显然是JS-SDK鉴权失败导致的。

在微信中分享时需要先去获取JS-SDK权限验证的签名。这个签名请求的参数中就需要带上当前页面的url。

签名生成规则如下:

参与签名的字段包括noncestr(随机字符串), 有效的jsapi_ticket, timestamp(时间戳), url(当前网页的URL,不包含#及其后面部分)。

对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式拼接成字符串string1。这里需要注意的是所有参数名均为小写字符。对string1作sha1加密,字段名和字段值都采用原始值,不进行URL 转义。

而二次分享和首次分享的区别在于——
分享出去的页面后面加上了类似from=singlemessageisappinstalled=1的参数,在二分分享时验证JS-SDK使用的是加上了微信参数后的地址。

例如,我们最终分享出去的页面地址就加上了微信参数:

http://h5.mogujie.com/pay/tthongbao_index.html?_fu=135hgdc&_mgjuuid=24b0c55e-04c7-4237-8184-8024cfc468ea&f=1002&ptp=m1.TTiXHb.0.0.eMNdRLs&shareUid=135hgdc&from=groupmessage&isappinstalled=1

但是这不是问题的直接原因,因为我们在每次打开页面时都会动态地用当前页面的url去请求签名,这个url已经带上了微信参数。

那么验证失败只可能有一个原因——我们的url在获得签名后发生了改变。

在微信中打开页面后打印当前页面url验证一下:

http://h5.mogujie.com/pay/tthongbao_index.html?_fu=135hgdc&_mgjuuid=24b0c55e-04c7-4237-8184-8024cfc468ea&f=1002&from=groupmessage&isappinstalled=1&ptp=m1.TTiXHb.0.0.eMNdRLs&shareUid=135hgdc

发现页面地址真的变了。这里注意到 query string 按照 key 的字母顺序重排了。这个重排动作并不是浏览器做的,因为当我访问别的活动页地址时,并不会有这样的重排。

观察到重排现象后,我开始怀疑vue-router了。果然在issue列表中找到了issues-926(看到掉进坑里的不止我一个,莫名其妙的开心了起来)。

按照 vue-router 的 release log ,在 2.1.0 版本的stringifyQuery函数中已经去掉了sort动作。

export function stringifyQuery (obj: Dictionary<string>): string {	
- const res = obj ? Object.keys(obj).sort().map(key => {
+ const res = obj ? Object.keys(obj).map(key => {
const val = obj[key]

if (val === undefined) {

升级到最新版本后再次测试果然二次分享ok了。


然而这里的坑并没有结束,只是被隐藏的更深了。

vue-routerparseQuery 时将 query 字符串构造为一个字典对象;然后在 history.pushState 时再将这个字典对象stringifyQuery转化为一个 queryString 字符串。

在序列化和反序列化的过程中,最重要的是保持前后的信息一致。

vue-router 2.1.0 之前的版本由于对Object.keys(obj)进行sort导致了顺序信息的丢失,引起序列化前后地址不一致。
那么去掉了sort操作后的vue-router能还原顺序信息了吗?

99%的情况下可以,除非 query 中带有 Array 类型的 value…

举例说明:

?foo=bar&baz=qux&foo=bla

经过vue-routerparseQuery后,变成了

{
foo: [bar,bla],
baz: qux
}

这里转化没问题,再通过 stringifyQuery 转为字符串呢,

?foo=bar&foo=bla&baz=qux

地址又变了!

标准实现 URLSearchParams 测试一下就会发现:在URLSearchParams上调用toString不会改变原来的queryString顺序:

var paramsString = "foo=bar&baz=qux&foo=bala";
var searchParams = new URLSearchParams(paramsString);

for (let p of searchParams) {
console.log(p);
}

searchParams.toString();
(2) ["foo", "bar"]
(2) ["baz", "qux"]
(2) ["foo", "bala"]
"foo=bar&baz=qux&foo=bala"

而在vue-router内部,parseQuery在遇到重复的key时,会把字典中原有的value转化为数组,再将新的 value push到数组尾部。这里不可避免的丢失了key数组原来出现在url中的顺序。

归根结底,还是数据结构选取的锅:parseQuery生成的对象应该是一个 key-value 组成的pair list而不是dictionary

找到思路了,vue-router源码也是现成的,那就改吧。

由于 query 对象是通过$router对外暴露的,直接改parseQuery的返回值类型代价太大了。我的思路是给 query 对象增加一个属性。

function parseQuery (query: string): Dictionary<string> {
const res = {}

+ Object.defineProperty(res, '_rawQuery', {
+ value: [],
+ enumerable: false
+ })

query = query.trim().replace(/^(\?|#|&)/, '')

if (!query) {
return res
}

- query.split('&').forEach((param) => {
+ query.split('&').forEach((param, index) => {
const parts = param.replace(/\+/g, ' ').split('=')
const key = decode(parts.shift())
const val = parts.length > 0
? decode(parts.join('='))
: null

+ if (res._rawQuery[index] === undefined) {
+ res._rawQuery[index] = []
+ res._rawQuery[index][0] = key
+ res._rawQuery[index][1] = val
}

if (res[key] === undefined) {
res[key] = val
} else if (Array.isArray(res[key])) {
res[key].push(val)
} else {
res[key] = [res[key], val]
}
})

return res
}

parseQuery()函数中额外添加_rawQuery数组属性,为了简单,这里使用了二维数组来模拟pair。按照queryString key-value 出现的顺序初始化数组。注意:为了不影响原来对Component暴露的query对象,特地将_rawQueryenumerable置为false。

export function stringifyQuery (obj: Dictionary<string>): string {
+ const hasRawQuery = !!obj._rawQuery
+ const queryObj = hasRawQuery ? obj._rawQuery : obj
- const res = obj ? Object.keys(obj).map(key => {
+ const res = queryObj ? Object.keys(queryObj).map(index => {
+ const key = hasRawQuery ? queryObj[index][0] : index
- const val = obj[key]
+ const val = hasRawQuery ? queryObj[index][1] : obj[index]

if (val === undefined) {
return ''
}

if (val === null) {
return encode(key)
}

if (Array.isArray(val)) {
const result = []
val.forEach(val2 => {
if (val2 === undefined) {
return
}
if (val2 === null) {
result.push(encode(key))
} else {
result.push(encode(key) + '=' + encode(val2))
}
})
return result.join('&')
}

return encode(key) + '=' + encode(val)
}).filter(x => x.length > 0).join('&') : null
return res ? `?${res}` : ''
}

stringifyQuery()函数中先判断是否有_rawQuery,有的话优先用_rawQuery。其余操作一致。

改完后,跑一遍vue-router单元测试。

npm run test:unit

Started
................................................................


64 specs, 0 failures
Finished in 0.3 seconds

测试通过,浏览器中的?foo=bar&baz=qux&foo=bla顺序也保留下来了,完美。

总结:

  1. query string 本身只是键值对,是顺序无关的,w3c规范也没有强调说一定要符合特定的顺序。vue-router 2.1.0之前的版本的sort操作纯属画蛇添足。最好的做法是保留原有的顺序信息,把问题抛到framework外部去解决。
  2. 微信js-sdk的签名接口设计的不合理:只考虑了key排序的问题,而忽视了作为value的url的排序问题。由于url query参数的顺序不影响url的相等性,在计算签名时应该在接口内部对url的query进行排序操作。