前言
微信小程序基于安全考虑,不支持动态执行 JS 代码,即:
- 不支持使用
eval
执行 JS 代码 - 不支持使用
new Function
创建函数
随后出现了 eval5 等一系列的 JS 解释器库作为替代方案,但是官方还是出于安全考虑,小程序未能过审或者直接封禁。而本文中所使用的另一套方案,或许也会被官方禁止,因此仅做技术讨论,不建议使用。
来源:
- 小程序 JavaScript 支持情况
- 小程序eval/Function终极替代方案:eval5
方案
eval5 是使用 TS 实现的 JS 解释器,而本文则是通过一个使用 Rust 语言实现的 JS 解释器库 boa_engine,将其编译成 wasm,然后在小程序中引入并调用函数,实现动态执行 JS 代码的功能。
所需的前置知识:
实现
1、用 Rust 实现功能函数
需要实现的两个函数:parse_js_value()
和 spirit_realm()
(1)parse_js_value:用于将 "1"
、"'1'"
、"[1]"
、"({a: 1})"
这样的代码字符串转成 JS 可用的数据类型,其结果分别是 1
、'1'
、[1]
、{a: 1}
。为什么对象类型需要加小括号?因为如果直接写成 "{a: 1}"
,会被识别成一个代码块,而不是对象。
(2)spirit_realm:其实就是近似 eval 一样的函数,第一个参数是代码字符串,第二个参数是代码字符串动态执行上下文中设置的全局变量。
下面直接贴代码:
lib.rs
#[cfg(test)]
mod test;
use boa_engine::property::Attribute;
use boa_engine::{Context, JsValue as BoaJsValue};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsValue as WasmJsValue;
#[wasm_bindgen]
pub fn parse_js_value(code: &str) -> Result<String, WasmJsValue> {
let ref mut context = Context::default();
context
.eval(code)
.map_err(|e| WasmJsValue::from(format!("Uncaught Error {:?}", e)))
.map(|v| v.to_json(context).unwrap().to_string())
}
#[wasm_bindgen]
pub fn spirit_realm(code: &str, global_property: &str) -> Result<String, WasmJsValue> {
let ref mut context = Context::default();
let json: serde_json::Value = serde_json::from_str(global_property).expect("Error");
let global_value = BoaJsValue::from_json(&json, context).expect("Error");
context.register_global_property("$data", global_value, Attribute::all());
context
.eval(code)
.map_err(|e| WasmJsValue::from(format!("Uncaught Error {:?}", e)))
.map(|v| v.to_json(context).unwrap().to_string())
}
test.rs
use crate::{parse_js_value, spirit_realm};
use wasm_bindgen_test::console_log as test_console_log;
use wasm_bindgen_test::*;
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test() {
let result1 = parse_js_value(
r#"
({a:1})
"#,
);
match result1 {
Ok(r) => test_console_log!("{}", r),
Err(e) => test_console_log!("{:?}", e),
}
let result2 = spirit_realm(
r#"
function run () {
return { $data };
}
run();
"#,
r#"{ "a": 1 }"#,
);
match result2 {
Ok(r) => test_console_log!("{}", r),
Err(e) => test_console_log!("{:?}", e),
}
}
Cargo.toml
[package]
name = "js_engine"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.release]
lto = true
opt-level = "z"
[package.metadata.wasm-pack.profile.release]
wasm-opt = true
[dependencies]
boa_engine = { version = "0.16.0", features = ["console"] }
wasm-bindgen = "0.2.83"
getrandom = { version = "0.2.8", features = ["js"] }
serde_json = "1.0.87"
wasm-bindgen-test = "0.3.33"
2、运行测试用例
wasm-pack test --chrome --headless
需要测试其他功能,直接修改 test.rs 文件即可。
3、编译生成 wasm 库
wasm-pack build --target web --release
4、小程序中引入 wasm 库
微信小程序中实现了 WebAssembly 技术,叫 WXWebAssembly,类似于 Web 标准 WebAssembly,能够在一定程度上提高小程序的性能,只不过使用的方式会有所不同,因此需要对胶水代码做一些处理。
创建一个小程序 demo 项目,这里直接使用 Taro 框架搭建微信小程序项目。
在 src 目录下创建 wasm 目录,将上一步编译生成的 wasm 库的 js_engine.js 和 js_engine_bg.wasm 文件复制进 wasm 目录,然后创建 index.ts 文件,用于功能函数的封装,代码如下:
index.ts
import { getImports, finalizeInit, spirit_realm, parse_js_value } from './js_engine.js';
export const spiritRealm = spirit_realm;
export const parseJsValue = parse_js_value;
async function wxWebAssemblyInstantiate() {
try {
const { instance, module } = await WXWebAssembly.instantiate('/wasm/js_engine_bg.wasm', getImports());
finalizeInit(instance, module)
} catch (error) {
console.error(error)
return Promise.reject()
}
}
export const wasmReady = wxWebAssemblyInstantiate()
除了封装初始化函数之外,在小程序中引入 wasm 还需要对生成的胶水代码做进一步处理,而生成出来的胶水代码其实就是 js_engine.js 文件,用于 JS 与 wasm 之间的互操作。处理如下:
- 导出两个函数:finalizeInit、getImports
- 注释掉在小程序内无法执行的函数:initSync、init
- 注释掉三行代码:
// init.__wbindgen_wasm_module = module;
// export { initSync }
// export default init;
5、小程序 demo 测试
新建一个测试页面,代码如下:
import './index.css';
import { View, Button } from '@tarojs/components';
import { wasmReady, spiritRealm } from '../../wasm';
import { Component, PropsWithChildren } from 'react';
export default class Index extends Component<PropsWithChildren> {
async evaluate() {
await wasmReady;
const result = spiritRealm(
`
function run () {
$data.a += 1;
return $data;
}
run();
`,
`{ "a": 1 }`
);
console.log('evaluate -> ', result);
}
render() {
return (
<View className='index'>
<Button onClick={this.evaluate}>Evaluate</Button>
</View>
);
}
}
开发模式编译小程序:
npx dev:weapp
需要注意,编译出来的小程序在 dist 目录下需要有 wasm/js_engine_bg.wasm 文件,可以修改 Taro 构建配置自动导入,这里先手动导入。
运行结果:
压缩
可以看到编译出来的 js_engine_bg.wasm 文件大小已经超过了2M,这显然是不可用的。针对这种情况微信小程序也给了方案:
本文通过最大限度减少文件体积的方式做优化:
(1)在编译 wasm 库的时候,采用 release 模式进行构建产物,并且使用 wasm-opt 工具对 .wasm 文件做进一步的代码简化,具体可参考:Shrinking .wasm Code Size。
(其实编译出来的文件依然超过2M,没有实质上的提升)
(2)使用 brotli 压缩 .wasm 文件
brotli -o js_engine_bg.wasm.br js_engine_bg.wasm
压缩生成一个 .br 后缀的文件,体积大小降到563KB,有了实质上的提升。
在小程序中引入时,使用 js_engine_bg.wasm.br 替换 js_engine_bg.wasm 文件,同时修改封装的初始化函数中引用的文件即可。
结语
使用 Rust 实现的 JS 解释器库 boa_engine 还有其他的功能特性,这里不再详细讨论,有兴趣的可以看看官方的文章以及文档,本文也只是基于此库粗略实现了一个类似 eval 的函数,提供一些思路而已。
完结~