单wasm项目与react结合:react-single-rust-wasm

109 阅读4分钟

项目地址:github.com/leek-empero…

项目初始化

wasm-pack new react-single-rust-wasm  # 搭建rust项目
cd react-single-rust-wasm && npm init -y # 创建前端环境

在package.json中加入,然后pnpm i

"scripts": {
    "build": "webpack --progress --config webpack/webpack.config.js",
    "dev": "webpack serve --config webpack/webpack.config.js"
},
"devDependencies": {
    "@wasm-tool/wasm-pack-plugin": "1.5.0",
    "html-webpack-plugin": "^5.3.2",
    "text-encoding": "^0.7.0",
    "webpack": "^5.49.0",
    "webpack-cli": "^4.7.2",
    "webpack-dev-server": "^3.11.2"
},

然后创建web和webpack文件夹,安装React和TS依赖

pnpm add --save-dev typescript ts-loader source-map-loader
pnpm add --save react react-dom @types/react @types/react-dom   

配置tsconfig.json文件

{
    "compilerOptions": {
        "outDir": "./dist/",
        "sourceMap": true,
        "noImplicitAny": true,
        "module": "commonjs",
        "esModuleInterop": true,
        "target": "es5",
        "jsx": "react-jsx"
    },
    "include": ["./web/**/*"],
    "exclude": ["./node_modules/**/*"]
}

web下建一个components文件夹,写一个Hello.tsx

interface Props {
    msg: string;
}
export default function Hello(props: Props) {
    const { msg } = props;
    return <h1 style={{ color: 'pink' }}>{msg}</h1>;
}

在web目录下建立index.html和index.tsx,index.tsx内容如下:

import { createRoot } from 'react-dom/client';

import Hello from './components/Hello';

const root = createRoot(document.getElementById('root'));
root.render(<Hello msg={'Hello World'} />);

接下来可以配置webpack文件夹下的webpack.config.js了

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const WasmPackPlugin = require('@wasm-tool/wasm-pack-plugin'); // 赋予 webpack 处理 wasm 能力的插件

/**
 * @type import('webpack').Configuration
 */
module.exports = {
    entry: path.resolve(__dirname, '../web/index.tsx'), // 入口文件(相对于config文件的位置)
    devServer: {
        port: '3042',
        progress: true, //打包进度条
        open: true, //打包完成自动打开浏览器
        compress: false, //启用压缩
    },
    devtool: 'inline-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        filename: 'index.js',
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js'],
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/,
            },
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: path.resolve(__dirname, '../web/index.html'), //需要放打包文件的html模板路径
            filename: 'index.html', //打包完成后的这个模板叫什么名字
        }),
        // 这里暂时注释
        // new WasmPackPlugin({
        //     crateDirectory: path.resolve(__dirname, '.'),
        // }),
        // Have this example work in Edge which doesn't ship `TextEncoder` or
        // `TextDecoder` at this time. 处理浏览器兼容问题
        new webpack.ProvidePlugin({
            TextDecoder: ['text-encoding', 'TextDecoder'],
            TextEncoder: ['text-encoding', 'TextEncoder'],
        }),
    ],
    mode: 'development',
    experiments: {
        asyncWebAssembly: true, // 打开异步 WASM 功能
    },
};

现在运行pnpm run dev命令,发现可以正常启动网页,就说明之前的配置没啥问题。注意,此时我们并没有使用rust,因为webpack.config.js里面暂时注释了WasmPackPlugin,现在对它进行配置

new WasmPackPlugin({
    // 这里是crate的路径,这是根目录,所以要上去一层,其实不上一层也找得到(不知道为什么),但是改动rust将不会触发热更新
    crateDirectory: path.resolve(__dirname, '..'),
    outDir: path.resolve(__dirname, '../web/rwasm'), // 输出文件夹路径,默认是'pkg'
    outName: 'index', // 默认文件以index开头
}),

然后运行pnpm run dev,如果没有意外,你的web目录下是这样的

image

现在我们删掉src目录下的utils.rs,更改一下lib.rs的内容

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, react-singe-rust-wasm!");
}

#[wasm_bindgen]
pub fn test(s: &str) -> String {
    return format!("Get {s} !");
}

如果你还开着pnpm run dev,那么会自动重新编译

然后我们在Hello组件中去使用wasm

import { test, greet } from '../rwasm';
interface Props {
    msg: string;
}
export default function Hello(props: Props) {
    const { msg } = props;
    return (
        <>
            <h1 style={{ color: 'pink' }}>{msg}</h1>
            <h1>Rust Get: {test('this is js')}</h1>
            <button onClick={() => greet()}>Greet</button>
        </>
    );
}

效果如下,成功调用RUST函数

image

看一下pnpm run build的产物,里面也有wasm包,可以serve dist启动服务看一下,也是正常的。

image

后续开发就和正常的网页开发一样啦,rwasm当成一个Npm包用就可以了

关于异步加载WASM

为什么上方例子和网上的部分教程不一致,不需要调用import init, {greet} from 'rwasm'这种方式导入,而是可以直接同步使用?

看一下rwasm/index.d.ts文件

image

根本没有默认导出的函数,而且可以启动项目,查看network,发现wasm文件是首屏直接加载的。

image

WasmPackPlugin其实还是用的wasm-pack build去打包,在默认情况下执行命令为:wasm-pack build --target bundler,具体区别如下:

image

现在我们指定target参数为web,去修改一下WasmPackPlugin插件参数

new WasmPackPlugin({
    ......
    extraArgs: '--target web',
}),

再次运行pnpm run dev,不出意外的话,会报错🤔,因为Hello组件的代码没有修改。再看一下rwasm/index.d.ts文件,不出意外会多了很多东西,以及有一个默认导出的函数,所以我们修改一下Hello组件的代码。

import { useEffect, useState } from 'react';
import init, { test, greet } from '../rwasm';
interface Props {
    msg: string;
}

export default function Hello(props: Props) {
    const { msg } = props;
    const [txt, setTxt] = useState('');
    useEffect(() => {
        (async () => {
            await init();
            setTxt(test(msg));
        })();
    }, []);

    return (
        <>
            <h1 style={{ color: 'pink' }}>{msg}</h1>
            <h1>Rust Get: {txt}</h1>
            <button onClick={() => greet()}>Greet</button>
        </>
    );
}

注意不要先于init()之前调用test!!不能直接在返回的html里面直接调用wasm的导出函数,根据react的运行机制,那样相当于在useEffect之前调用,会直接报错。

这样的好处是,wasm会在init函数调用完成后再加载,在更复杂的场景里,按需加载可以有更好的体验。

关于wasm-pack build --target bundler和web的更多讲解:Building Performant Web Apps with Rust, WebAssembly, and Webpack