JavaScript
已经长久以来并且目前依然是浏览器运行时的主流开发语言,然而近年来,WebAssembly
的诞生为我们提供了一个全新的选择。这就引出了一个值得我们探索的问题:在浏览器运行环境中,哪个语言的性能更优越,JavaScript
还是 WebAssembly
?
笔者最近在工作中正好面临了这样的选择,我需要在浏览器运行时动态插入一些策略,用于在用户的浏览器运行时实现一些安全功能,例如网站请求的 CSRF 防护,网站存储数据的加解密等等,那么这种动态的运行时策略到底该使用 JavaScript
还是 WebAssembly
呢,为了找到答案,我做了一些验证,本文将详细对比两者在各项性能指标上的表现。
基础概念
JavaScript
,诞生于1995
年的一种高级编程语言,最初用于在Web
浏览器中添加交互式元素。互动效果如弹出新的窗口,响应按钮点击,改变网页内容等,几乎都离不开JavaScript
。它的核心设计理念是"简单易懂”,语言本身易于上手,对新手友好。随着Node.js
的出现,JavaScript
已不仅限于前端开发,而是成为一种全栈编程语言。WebAssembly
,或者简称Wasm
,是一种在浏览器环境下执行的新型二进制指令集,这就让浏览器拥有了执行其他代码(如C、C++、Rust、Java
)的能力。相较于JavaScript
的文本格式,WebAssembly
以二进制格式表达代码,使得其具有较高的执行效率。WebAssembly
是为了满足对高性能和低级功能的需求而产生的,比如游戏,音频视频编辑等。与JavaScript
一样,Wasm
可以在几乎所有现代浏览器中运行。
测试代码
JavaScript
我们首先添加一个用于测试密集 CPU
计算的 cycle
函数,其他按照安全策略格式增加 20
个其他的函数(用于测试体积)。
window.StrategySet = { | |
cycle: { | |
key: 'cycle', | |
name: '循环计算测试', | |
expression: function (n) { | |
let result = 0; | |
for (let i = 0; i < n; i++) { | |
result += i; | |
} | |
return result; | |
} | |
}, | |
detectTextHttp: { | |
key: 'detectTextHttp', | |
name: '检测网页明文传输请求', | |
expression: function (event) { | |
const { payload } = event; | |
if (payload.url.startsWith('http://')) { | |
console.log({ action: "REPORT_ONLY", reason: "HTTP REQUEST" }, event); | |
} else { | |
console.log({ action: "PASS", }, event); | |
} | |
} | |
} | |
// 剩余 20 个函数 ... | |
} | |
window.Strategys = StrategyGroup = { | |
NETWORK_RESOURCE_REQUEST: ['detectTextHttp'], | |
PAGE_INITIALIZED: ['fibonacci'], | |
NETWORK_XHR_REQUEST: [], | |
API_LOCALSTORAGE_GET: ['cycle'], | |
API_CLIPBOARD_READ: [], | |
} |
WebAssembly(Rust)
同 JS
实现完全一样的逻辑:添加一个用于测试密集 CPU
计算的 cycle
函数,其他按照相同的格式增加 20 个函数。
pub fn cycle(n: u64) -> u64 { | |
let mut result = 0; | |
for i in 0..n { | |
result += i; | |
} | |
result | |
} | |
struct DetectTextHttp { | |
key: &'static str, | |
name: &'static str, | |
expression: Box<dyn Fn(Event)>, | |
} | |
struct Event { | |
payload: Payload, | |
} | |
struct Payload { | |
url: String, | |
} | |
impl DetectTextHttp { | |
fn new(key: &'static str, name: &'static str, expr: Box<dyn Fn(Event)>) -> DetectTextHttp { | |
DetectTextHttp { | |
key: key, | |
name: name, | |
expression: expr, | |
} | |
} | |
} | |
fn main() { | |
let detect_text_http = DetectTextHttp::new( | |
"detectTextHttp", | |
"检测网页明文传输请求", | |
Box::new(|event: Event| { | |
if event.payload.url.starts_with("http://") { | |
println!("{{ action: \"REPORT_ONLY\", reason: \"HTTP REQUEST\" }}, {:?}, event"); | |
} else { | |
println!("{{ action: \"PASS\" }}, event"); | |
} | |
}), | |
); | |
// 剩余 20 个检测规则 ... | |
} |
资源体积
JavaScript
JavaScript
在未经过任何压缩的情况下,代码体积为 1.8KB
:
WebAssembly(Rust)
Rust
源代码行数为 259
行,使用 cargo build --target wasm32-unknown-unknown
打包为 wasm
代码,最终网页中的加载的体积为 1.7MB
:
但这个是未经过任何优化和压缩的代码,我们使用 Rust
编译参数对产物的编译体积进行优化后:
[profile.release] | |
opt-level = 'z' # 代码大小最小化 | |
lto = true # 启用链接时优化,可以减少代码体积 | |
panic = 'abort' # 抛弃默认的包含堆栈展开的恐慌处理器 |
最终压缩后的代码大小为 4.6KB:
所以,在同样的代码情况下,浏览器中可执行的代码文件体积上 JavaScript
更胜一筹。
代码初始化
因为是需要动态执行的策略,代码需要有一个动态拉取的过程,而不能直接打包在业务代码内部。
我们先添加一个测试的 HTML
:
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>WebAssembly 测试</title> | |
<!-- 基础 SDK --> | |
<script id="" src="./basic.js"></script> | |
<!-- 一个外部 CDN JS --> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> | |
<!-- 一个内联的 Script 脚本 --> | |
<script> | |
// 调用 localStorage API,触发 localStorage Hook | |
localStorage.setItem('name', 'ConardLi'); | |
// 调用 fetch API ,触发 fetch Hook | |
fetch('https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js'); | |
</script> | |
</head> | |
<body> | |
<img src="https://pic0.sucaisucai.com/11/50/11050520_2.jpg"> | |
</body> | |
</html> |
然后我们在基础 SDK 中添加一些运行时的 Hook:
function initHook() { | |
const observer = new PerformanceObserver((list) => { | |
for (const entry of list.getEntries()) { | |
log('[Hook] PerformanceObserver:', entry.name, window.Strategys); | |
} | |
}); | |
observer.observe({ entryTypes: ['resource'] }); | |
var originalFetch = window.fetch; | |
window.fetch = function () { | |
log('[Hook] Fetch:', arguments, window.Strategys); | |
return originalFetch.apply(this, arguments); | |
}; | |
var originalSetItem = localStorage.setItem; | |
localStorage.setItem = function (key, value) { | |
log('[Hook] localStorage.setItem:' + key, window.Strategys); | |
}; | |
} |
JavaScript
策略拉取逻辑:
async function initStrategy() { | |
log('[initStrategy] start download') | |
document.write(` | |
<script | |
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/kyhuvjeh7pxpozps/snoopy/security-strategy4.js" | |
onload="console.log('动态策略已经加载并执行完毕!', performance.now() - window.time)"> | |
</script>` | |
); | |
log('[initStrategy] end download', performance.now() - window.time) | |
} |
可见拉取、解析策略共花费的时间为 34ms
,且后续同步执行的 JavaScript Hook
都可以拿到策略:
WebAssembly(Rust)
策略拉取逻辑(执行 WebAssembly 前还需要进行 ArrayBuffer 的转换、实例创建等流程,均为异步动作):
async function initStrategy() { | |
log('[initStrategy] start download') | |
const response = await fetch('https://lf3-static.bytednsdoc.com/obj/eden-cn/kyhuvjeh7pxpozps/snoopy/security-strategy1.wasm'); | |
log('[initStrategy] end download', performance.now() - window.time) | |
const buffer = await response.arrayBuffer(); | |
log('[initStrategy] to arrayBuffer', performance.now() - window.time) | |
const module = await WebAssembly.instantiate(buffer); | |
log('[initStrategy] WebAssembly.instantiate', performance.now() - window.time) | |
const cycle = module.instance.exports.cycle; | |
window.Strategys = cycle; | |
} |
- 从开始到资源下载完成花费
142ms
- ArrayBuffer 数据结构转换花费
363ms
- WebAssembly 实例化花费
23ms
从开始拉取 WebAssembly
模块到最终可执行策略共消耗 528ms
。然后使用进行编译体积优化后的模块进行测试:
- 从开始到资源下载完成花费
75ms
- ArrayBuffer 数据结构转换花费
242ms
- WebAssembly 实例化花费
24ms
整个过程均为异步,在这段时间页面上下载并解析的 JS 还是会继续执行的,在这期间 Hook 点位上拿不到策略。
长任务测试
为了让这段异步下载的过程更加直观,在业务代码中模拟一个纯 CPU
计算的长任务:
<script> | |
// 模拟一个长任务,用于体现策略拉取的异步动作 | |
console.log('[业务] start cpu', performance.now() - window.time); | |
for (let i = 0; i < 999999999; i++) { | |
} | |
console.log('[业务] end cpu', performance.now() - window.time); | |
</script> |
可见 WebAssembly
模块实例化一定在业务长任务执行完后执行:
而 JavaScript
则会先解析好策略后再开始执行后续的 Script
逻辑:
代码执行
JavaScript
测试代码,调用 cycle 函数:
log('[initStrategy] 策略计算性能测试 init', performance.now() - window.time); | |
const result = window.StrategySet[window.Strategys['API_LOCALSTORAGE_GET'][0]].expression(999999999); | |
log('[initStrategy] 策略计算性能测试 end', result, performance.now() - window.time); |
WebAssembly(Rust)
测试代码,调用 cycle 函数:
async function initStrategy() { | |
log('[initStrategy] start download') | |
const response = await fetch('https://lf3-static.bytednsdoc.com/obj/eden-cn/kyhuvjeh7pxpozps/snoopy/security-strategy1.wasm'); | |
log('[initStrategy] end download', performance.now() - window.time) | |
const buffer = await response.arrayBuffer(); | |
log('[initStrategy] to arrayBuffer', performance.now() - window.time) | |
const module = await WebAssembly.instantiate(buffer); | |
log('[initStrategy] WebAssembly.instantiate', performance.now() - window.time) | |
const cycle = module.instance.exports.cycle; | |
window.Strategys = cycle; | |
console.time('策略计算性能测试') | |
const result = cycle(999999999n); | |
log('[initStrategy] 策略计算性能测试', result, performance.now() - window.time); | |
console.timeEnd('策略计算性能测试') | |
} |
最终结论
- 代码体积:
JavaScript
:1.8KB ✅ VSWebAssembly(Rust)
4.6KB ❌- 初始化时间:
JavaScript
:34ms ✅ VSWebAssembly(Rust)
528ms ❌- 10亿次循环代码执行时间:
JavaScript
:1.3s ❌ VSWebAssembly(Rust)
0.1 ms ✅JavaScript
:首屏加载快、可同步加载、计算性能差:需要在业务首屏渲染前执行的策略、计算逻辑简单的策略,优先考虑使用 JavaScript 执行,例如 CSRF 防护、API 调用鉴权等策略。WebAssembly
:首屏初始化慢、只能异步加载、计算性能好:可以在业务首屏渲染完成后异步执行的策略,计算逻辑非常复杂、有密集 CPU 计算的策略,考虑使用 WebAssembly 模块执行,例如需要给业务图片在前端增加水印,需要对图片数据进行重写等策略。