前段时间研究了几个比较常用的浏览器翻译源以后,我写了 translator-utils 的 最初版本,后来打算将它从大的 monorepo 拆分出来时,麻烦就来了
引入依赖
上一篇文章我也提到过,有一部分的翻译源的链接是允许跨域的,所以在把这玩意拆分出来的同时我准备打一个 umd bundle 出来给浏览器直接引入。然而,我使用的 Axios 并没有自带用于代理请求的 Http(s) Agent,于是我用了 hpagent
模块,而这个模块使用的 Agent 是 Node.js 的 http
/https
模块独有的,因此并不能直接用于浏览器环境,这时就需要想办法绕过。
动态导入❌
在看完前人留下的文章后,我先想到的是用动态引入的办法绕过,于是有了第一版:
...
//为什么要套一个async函数,因为UMD不支持 top level await 啊
const axiosConfig = async () => {
if (typeof process !== 'undefined') {
const https_proxy = process.env.https_proxy ?? process.env.HTTPS_PROXY ?? ''
const http_proxy = process.env.http_proxy ?? process.env.HTTP_PROXY ?? ''
const { HttpProxyAgent, HttpsProxyAgent } = await import('hpagent')
axios.create({
httpsAgent: HttpsProxyAgent({proxy: https_proxy})
httpAgent: HttpProxyAgent({proxy: https_proxy})
})
}
}
...
显而易见地,这种操作本身没啥问题,放到一个新建的 vite 工程里也能用,然而到了打UMD包的时候就会提示缺少依赖,并且正式丢进浏览器中后就会发现完全不能用,所以需要换一种思路
(!) Missing shims for Node.js built-ins Creating a browser bundle that depends on "https", "http" and "url". You might need to include https://github.com/FredKSchott/rollup-plugin-polyfill-node
另外在研究这种操作时我看到了一条issue
导入空 Object ✔
反复翻阅 Rollup 的文档后,我发现可以通过 external 先将 haproxy
变成一个从外部引入的模块,再通过 output.globals 为 haproxy
设置一个默认值,再加点判断条件,那么就得到了第二个版本:
//rollup.config.js
export default [{
input: 'src/index.browser.ts',
output: {
file: 'dist/translator.js',
format: 'umd',
sourcemap: true,
name: "translator",
exports: "default",
globals: {
hpagent: '{}'
}
},
external: ['hpagent'],
plugins: [
commonjs(),
typescript({tsconfig: './tsconfig.rollup.json'}),
resolve({browser: true}),
peerDepsExternal('./package.json'),
babel({
babelHelpers: 'bundled',
presets: ['@babel/preset-env']
})
]
}]
//axios.config.js
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"
...
const axiosConfig = (config) => {
if (typeof process !== 'undefined' && HttpProxyAgent && HttpsProxyAgent) {
//这里面跟上一版基本一致
}
}
...
虽然确实能用,但这玩意属于奇技淫巧……不能称作优雅
Workspaces ✔✔
Workspaces 是 npm 7.x+ / Yarn 支持的功能,可以用来写自己的模块,而npm引入模块时是可以根据 package.json
的键 exports
区分入口的,于是我搞了第三版:
//package.json
{
...
"workspaces": [
"packages/*"
]
}
//packages/translator-utils-axios-helper/package.json
{
"name": "translator-utils-axios-helper",
"version": "0.0.1",
"private": true,
"main": "index.node.js",
"types": "index.node.d.ts",
"exports": {
".": {
"browser": {
"default": "./index.js"
},
"default": {
"default": "./index.node.js"
}
},
"./package.json": "./package.json"
},
"type": "module"
}
//packages/translator-utils-axios-helper/index.js
import axios from 'axios'
const axiosFetch = axios.create({})
export default axiosFetch
//packages/translator-utils-axios-helper/index.node.js
import axios from 'axios'
import { HttpsProxyAgent } from "hpagent"
const axiosFetch = axios.create({ httpsAgent = new HttpsProxyAgent({proxy: HTTPS_PROXY}) })
export default axiosFetch
//other scripts
import axiosFetch from 'translator-utils-axios-helper'
这下就彻底分了家,后面可以通过插件 @rollup/plugin-node-resolve
来区分入口
运行环境
这就完了吗,那确实还没有,经过一番操作我彻底删掉了axios,然后麻烦又来了:不同的运行环境都会有一些独有的方法,这时候就需要挨个适配了
node:https
既然去掉了axios,那后续的操作自然就是给原生的https库套Promise,这没啥好说的,也就只有 data
事件并非一次性读完全部内容可能会被忽略掉(也只有我这种没接触过的会被坑了吧)
new Promise((resolve, reject) => {
const req = https.request(url, options, (res) => {
let chunkArray = []
res.on('data', (data) => {
chunkArray.push(data)
})
res.on('close', () => {
resolve(Buffer.concat(chunkArray))
})
})
req.on('error', (e) => {
reject(e)
})
req.end()
})
fetch
浏览器/Workers/Deno/Node.js 18.x 所自带的 fetch()
都自带一系列的转换方法,不需要像 node:https
那样手操 Buffer
(想硬干也不是不行),然而 fetch 看似轻松简单,实际暗藏玄机……平时在浏览器经常用 fetch,但很少用到headers,于是也忽略了这玩意
正好这次需要拿cookie,而Set-Cookie是一个比较特殊的存在,是唯一允许重复使用的标头,而 fetch.headers
的类型 Headers
本质上就是个 Map
,因此会常规的 fetch.headers.get('set-cookie')
只会得到第一个 set-cookie
的值,因此不同的环境都做出了自己的解决方案
- 浏览器:根本没有! 所以不需要管,我直接摘抄MDN的描述:
- Workers:CloudFlare给
set-cookie
一个专有的方法.getAll()
,将会返回一个数组;我在翻issue过程中发现 github/fetch #236 也是这样操作的const res = await fetch('https://www.google.com') res.headers.getAll('set-cookie') res.headers.getAll('content-type') //Uncaught (in promise) TypeError: getAll() can only be used with the header name "Set-Cookie".
- Deno:Deno 通过合并 <Merged> All multiple Set-Cookie headers #5100 终结了这个问题
const res = await fetch('https://www.google.com') res.headers.get('set-cookie') //"1P_JAR=2023-03-30-09; expires=Sat, 29-Apr-2023 09:42:39 GMT; path=/; domain=.google.com; Secure, AEC=AUEFqZdWR3Fxxxxxxxxxl6U1vCxxxxSeb6VhSYLLrQ5jzo--xxxg9Hak_g; expires=Tue, 26-Sep-2023 09:42:39 GMT; path=/; domain=.google.com; Secure; HttpOnly; SameSite=lax, NID=511=Q_yBq0zEyZXlxxxxxcmbHsMPEVo6ytDJCixxxxx_G-nNOtJ-FvnznDxxxxx5ey-WCFP7_ye4DUW_CLC1ok3xxxxxdQ9KXLPh7q7o_bHDQI83sioqoDTPubYdUrLKKiie5L4icxxxxxjUk_hcZNK3bOvr3jeJG3xxxxxAzz_wLI; expires=Fri, 29-Sep-2023 09:42:39 GMT; path=/; domain=.google.com; HttpOnly" [...res.headers.entries()].filter(header => header[0] === 'set-cookie').map(header => header[1]) //[ // "1P_JAR=2023-03-30-09; expires=Sat, 29-Apr-2023 09:42:39 GMT; path=/; domain=.google.com; Secure", // "AEC=AUEFqZdWR3Fxxxxxxxxxl6U1vCxxxxSeb6VhSYLLrQ5jzo--xxxg9Hak_g; expires=Tue, 26-Sep-2023 09:42:39 GM...", // "NID=511=Q_yBq0zEyZXlxxxxxcmbHsMPEVo6ytDJCixxxxx_G-nNOtJ-FvnznDxxxxx5ey-WCFP7_ye4DUW_CLC1ok3xxxxxdQ9..." //]
- Node.js:用法同上
- Bun:同上
上面说了一大堆,总结起来就是除了 Workers 其他环境都是通用的,因此最终我处理这部分的代码就变成
let headers = Object.fromEntries(res.headers.entries())
if (headers['set-cookie'] && res.headers.getAll) {
headers['set-cookie'] = res.headers.getAll('set-cookie')// Workers
} else if (headers['set-cookie']) {
headers['set-cookie'] = [...res.headers.entries()].filter(header => header[0] === 'set-cookie').map(header => header[1])// Deno / Node.js 18 / Bun
}
别的
- 注1:这玩意本来应该在两周前就完成了,然而处理 Git 的时候手抖把写了一点的内容全删了……
注2:写文章这天又被workspaces坑了一把,最后被迫回滚,感觉还需要继续学习一个- 注3:写这文章的第二天又发现
axios
是通过xhr
实现请求的,Workers 往哪来的xhr
……于是又将浏览器环境的axios
换成了用fetch api
实现axios
功能的redaxios
,其实redaxios
挺好的,gzip后1KB都不到 - 注4:最后徒手用
fetch
(browser/Workers/Deno) 和node:https
(Node.js) 实现了上述的功能……应该彻底结束了 - 注5:开启
node_compat
的 CloudFlare Workers 是有process
的,不过可以通过process.title === "browser"
来判断是否真为 Node.js 环境