一个小bug引发的URL编码思考

980 阅读5分钟

场景

近期在做性能优化的时候,发现本想提前加载的一张图片却没有命中浏览器缓存,开发环境时明明验证是好好的,上线了却发现没命中导致加载了两张相同的图片,性能负优化

那到底发生了什么导致没有命中啊?

先说下现状吧:

一个普普通通的vue项目,开发环境时,写了一个简单的样式,设置了一张背景图片

.xx {
  background-image: url(https://xx.cdn/xx.jpg?a=/nooo/nooo)
}

然后为了让这张图片早点加载,等到要展示的时候,可以快速展现(因为此时浏览器会发现有缓存),优化用户体验,所以在某个前置位置,写了一个简单的JS来提前加载

const img = new Image()
img.src = 'https://xx.cdn/xx.jpg?a=/nooo/nooo'

开发时好好的,等打包之后再看,样式被改了

.xx{background-image:url(https://xx.cdn/xx.jpg?a=%2Fnooo%2Fnooo)}

核心在于图片地址中的query部分a=/nooo/noooencodea=%2Fnooo%2Fnooo,浏览器直接把这两个当做不同的图片了,导致没有命中缓存。

那么,谁干的?

其实看到这个状态,脑中直接有个结论就是:估计是打包的时候,哪个loader或者插件对链接中queryvalue部分做了encodeURIComponent

encodeURIComponent('/nooo/nooo') === '%2Fnooo%2Fnooo'
// encodeURI 不会处理 '/'
encodeURI('/nooo/nooo') === '/nooo/nooo'

跟着这个思路找下去,发现,根本没找到!!!

看来自己还是想得太简单了,只能经过了一段因人而异(本人对于webpack相关的并没有那么熟悉,所以花了一些时间)的排查时间,发现CSS在最后一步优化的时候,链接被encode了。而本人这边使用的是@vue/cli-servicev4版本,其代码中优化逻辑如下

// @vue/cli-service

const cssnanoOptions = {
  preset: ['default', {
    mergeLonghand: false,
    cssDeclarationSorter: false
  }]
}
if (rootOptions.productionSourceMap && sourceMap) {
  cssnanoOptions.map = { inline: false }
}

if (isProd) {
  webpackConfig
    .plugin('optimize-css')
      .use(require('@intervolga/optimize-cssnano-plugin'), [{
        sourceMap: rootOptions.productionSourceMap && sourceMap,
        cssnanoOptions
      }])
}

也就是说调用了@intervolga/optimize-cssnano-plugin来优化CSS代码的,而这个包是调用cssnano来进行优化

// @intervolga/optimize-cssnano-plugin

const cssnano = require('cssnano');

const promise = postcss([cssnano(cssnanoOptions)]).
	...

再看cssnano涉及到链接处理的包,最像的应该是postcss-normalize-url,不过文档上并没有说进行这个优化会导致encode,只能继续看其源码,找到一个最像的:其会调用normalize-url来优化链接

// cssnano/packages/postcss-normalize-url

import normalize from 'normalize-url';

normalizedURL = normalize(url, options);

再来看normalize-url里面做了啥。里面完全没有任何encode的代码,而最可能导致被encode的操作是:

// normalize-url

const urlObj = new URL(urlString);

// Sort query parameters
if (opts.sortQueryParameters) {
  urlObj.searchParams.sort();
}

难道sort会导致链接被encode?试了一下,确实会:

var a = new URL('https://xxx.com/add?a=/9&b=77')
console.log(a.search)
// ?a=/9&b=77

a.searchParams.sort()
console.log(a.search)
// ?a=%2F9&b=77

其实,对于URLSearchParams的增、删、改之类的操作,都会导致参数被encode,猜测是因为这些操作会让其先decode,处理之后再encode回去导致的。

那,怎么解决?

原因知道了,解决方案也就很明确了,主要是两个方向:

1. 改自身代码

严格意义上来说,本身写https://xx.cdn/xx.jpg?a=/nooo/nooo就不太规范,应该将query里面的参数都进行encode,也就是把代码中涉及的地方,都改成标准的,类似https://xx.cdn/xx.jpg?a=%2Fnooo%2Fnooo

2. 改打包配置

既然是因为打包时sort引起的,那就不要sort即可,另外,额外研究后,可以发现cssnanov5.0.11时,将这个当做Bug来修复了,默认不开启sortQueryParameters

5.0.11 (2021-11-16)

Bug fixes

  • c38f14c3ce3d0: postcss-normalize-url: avoid changing parameter encoding

另外,其实@vue/cli-service最新的v5版本,也已经使用了新版本的cssnano,正常也不会有这样问题。

既然这样,那么不使用sort似乎也可以理解,而在@vue/cli-servicev4版本时,可以通过修改配置来关闭

// vue.config.js

module.exports = {
  chainWebpack: config => {
    // 注意只有正式环境才需要
    if (process.env.NODE_ENV === 'production') {
      config.plugin('optimize-css').tap(([options]) => {
        // 直接改掉配置,虽然不太喜欢这样hack的写法
        options.cssnanoOptions.preset[1].normalizeUrl = {
          sortQueryParameters: false,
        }
        return [options]
      })
    }
  }
}

结束了?

我们似乎找到了原因,而且也找到了解决方案,从问题本身来说,已经结束了。

但是,URLSearchParams的操作导致的encodeencodeURIComponent是什么关系?是否等同呢?

要回答这个问题,也就引出了Percent-encoding

Percent-encoding

Percent-encoding, also known as URL encoding

说是Percent-encoding,直译过来就是百分号编码,其实就是URL编码,核心逻辑也很简单,基本上百分号加上两位十六进制的字符。其详细的编码过程,在whatwg上有,在percent-encoded-bytes 可以查看

URLSearchParamsencodeURIComponent都是Percent-encoding,所以它们的的基本逻辑是一致的,只不过它们的编码范围不一致

根据whatwgencodeURIComponent的编码范围为component percent-encode set

The component percent-encode set is the userinfo percent-encode set and U+0024 ($) to U+0026 (&), inclusive, U+002B (+), and U+002C (,).

URLSearchParams涉及的编码范围是application/x-www-form-urlencoded percent-encode set

The application/x-www-form-urlencoded percent-encode set is the component percent-encode set and U+0021 (!), U+0027 (') to U+0029 RIGHT PARENTHESIS, inclusive, and U+007E (~).

包括了encodeURIComponent的编码范围,还额外包含了 !、' 等字符

还有一个非常重要的区别:URLSearchParams会把空格编码成加号(+),而encodeURIComponent则会编码成%20!

URLSearchParams objects will percent-encode anything in the application/x-www-form-urlencoded percent-encode set, and will encode U+0020 SPACE as U+002B (+).

有了这个了解之后,再回头看之前的解决方案1(改自己代码),就要十分小心了,尤其是正好卡在URLSearchParamsencodeURIComponent之间有所区别的字符:

var a = new URL('https://xxx.com/add?a=%209&b=!77')
console.log(a.search)
// ?a=%209&b=!77

a.searchParams.sort()
console.log(a.search)
// ?a=+9&b=%2177

上述例子中,我们以为已经编码了(以为是和encodeURIComponent等效),结果就是被打脸。

这么看起来,还是第二个解决方案比较靠谱一点

最后

简单总结一下就是:

  • @vue/cli-servicev4版本在CSS优化压缩时,会默认使用URLSearchParams来编码,v5版本正常不会有该问题,如果想要修复,可以直接关闭cssnanosortQueryParameters
  • URLSearchParamsencodeURIComponent编码逻辑基本一致,但是范围更广,且对空格会做特殊处理