微信小程序 eval 的另一种替代方案:WebAssembly

lxf2023-03-20 17:14:01

前言

微信小程序基于安全考虑,不支持动态执行 JS 代码,即:

  • 不支持使用 eval 执行 JS 代码
  • 不支持使用 new Function 创建函数

随后出现了 eval5 等一系列的 JS 解释器库作为替代方案,但是官方还是出于安全考虑,小程序未能过审或者直接封禁。而本文中所使用的另一套方案,或许也会被官方禁止,因此仅做技术讨论,不建议使用

来源:

  1. 小程序 JavaScript 支持情况
  2. 小程序eval/Function终极替代方案:eval5

方案

eval5 是使用 TS 实现的 JS 解释器,而本文则是通过一个使用 Rust 语言实现的 JS 解释器库 boa_engine,将其编译成 wasm,然后在小程序中引入并调用函数,实现动态执行 JS 代码的功能。

所需的前置知识:

  1. Rust 语言的学习以及安装
  2. WebAssembly 技术
  3. 通过 WebAssembly 用 Rust 构建浏览器原生的库

实现

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

微信小程序 eval 的另一种替代方案:WebAssembly

需要测试其他功能,直接修改 test.rs 文件即可。

3、编译生成 wasm 库

wasm-pack build --target web --release

微信小程序 eval 的另一种替代方案:WebAssembly

4、小程序中引入 wasm 库

微信小程序中实现了 WebAssembly 技术,叫 WXWebAssembly,类似于 Web 标准 WebAssembly,能够在一定程度上提高小程序的性能,只不过使用的方式会有所不同,因此需要对胶水代码做一些处理。

创建一个小程序 demo 项目,这里直接使用 Taro 框架搭建微信小程序项目。

在 src 目录下创建 wasm 目录,将上一步编译生成的 wasm 库的 js_engine.jsjs_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 构建配置自动导入,这里先手动导入。

微信小程序 eval 的另一种替代方案:WebAssembly

运行结果:

微信小程序 eval 的另一种替代方案:WebAssembly

压缩

可以看到编译出来的 js_engine_bg.wasm 文件大小已经超过了2M,这显然是不可用的。针对这种情况微信小程序也给了方案:

微信小程序 eval 的另一种替代方案:WebAssembly

本文通过最大限度减少文件体积的方式做优化:

(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 的函数,提供一些思路而已。

完结~