How to Build a Cross‑Platform Mini‑Program Container with React and Node

This article explains how to create a plug‑in mini‑program container that runs across web, app, and desktop platforms by converting mini‑program syntax to HTML, bundling CSS and JavaScript, generating JSX, and integrating a runtime layer with React, Node, and optional Tauri support.

Goodme Frontend Team
Goodme Frontend Team
Goodme Frontend Team
How to Build a Cross‑Platform Mini‑Program Container with React and Node

Background

Many popular apps can run mini‑programs, each with its own development tool and syntax. The author wonders how to give their own app the ability to run mini‑programs.

Reference implementation is based on smallapp, fixing bugs and producing a demo. WeChat has its own WMPF, which is more application‑oriented; the next layer is what we need to build.

Goal

Build a "plug‑in" mini‑program container that supports multi‑platform execution (Web/App/Desktop) with consistent rendering.

Architecture Design

Core concerns of the container layer:

Data‑view rendering

Basic components

Event handling

Lifecycle

Container Layer Implementation

Mini‑program conversion layer

We use a webview as the host, converting mini‑program syntax to HTML.

CSS

Package CSS into a single file using postcss and postcss‑import.

export class WxssFileNode extends FileNode {
  constructor(path, type, name, tag) {
    super(path, type, name, tag);
    this.type = "wxss";
  }
  async transform(input) {
    this.ast = input;
    const that = this;
    const res = await postcss([
      require("postcss-import")({
        resolve(id) {
          const url = resolve(that.path, "../", id);
          return url;
        },
      }),
    ]).process(input, {
      from: that.path,
      to: that.path,
    });
    this.code = res.css;
  }
}

JS

Bundle JavaScript files into a single bundle using esbuild.

export class JsFileNode extends FileNode {
  constructor(path, type, name, tag) {
    super(path, type, name, tag);
    this.type = "js";
  }
  async transform() {
    const out = await build({
      entryPoints: [this.path],
      bundle: true,
      format: "esm",
      sourcemap: false,
      write: false,
      outdir: "out",
    });
    this.code = String.fromCharCode.apply(null, out.outputFiles[0].contents);
  }
}

WXML

Process WXML line by line.

// 0. Original state
<view class="wrap">{{arr.length}}<</view>
<view wx:for="{{arr}}">
    <text>{{item.a}}:{{item.b}}:{{index}}</text>
</view>
<view wx:if="{{arr.length > 5}}">show</view>
<view wx:elseif="{{arr.length > 2}}">show2</view>
<view wx:else>hidden</view>
<button bind:tap="add">add</button>

// 1. Processed WXML
<view class="wrap">{{state.arr.length}}</view>
<view wx:for="{{state.arr}}">
  <text>{{item.a}}:{{item.b}}:{{index}}</text>
</view>
<view wx:if="{{state.arr.length > 5}}">show</view>
<view wx:elseif="{{state.arr.length > 2}}">show2</view>
<view wx:else>hidden</view>
<button bind:tap="add">add</button>
bind:tap="add" → onClick={$handleEvent("add", pageid, "bind:tap")}
const generateProps = (node, state, asset) => {
  let code = "";
  for (let name in node.attributes) {
    const value = node.attributes[name];
    if (name.startsWith("wx:")) {
      node.directives = node.directives || [];
      node.directives.push([name, value]);
    } else if (name.startsWith("bind")) {
      if (state.methods.indexOf(value) < 0) {
        state.methods.push(value);
      }
      const key = wriedName(name);
      code += ` ${key}={$handleEvent("${value}", "${getId(asset)}", "${name}")}`;
    } else if (node.name === "import") {
      state.imports.push(value);
    } else {
      let compiled = compileExpression(value, node.type);
      code += `${name}=${compiled || "true"}`;
    }
  }
  return code + ">";
};

Convert wx:for to $for and wx:if to ternary expressions.

<view wx:for="{{arr}}">…</view> → {$for(state.arr,(item,index)=>(<comp.View>…</comp.View>))} <view wx:if="{{arr.length > 5}}">show</view> → {state.arr.length > 5 ? <comp.View>show</comp.View> : …}
const generateDirect = (node, code, next) => {
  for (let i = 0; i < node.directives.length; i++) {
    const [name, value] = node.directives[i];
    const compiled = compileExpression(value, "direct");
    if (code[0] === "{") {
      code = `<div>${code}</div>`;
    }
    if (name === "wx:for") {
      const item = findItem(node);
      code = `{$for(${compiled},(${item},index)=>(${code}))}`;
    }
    if (name === "wx:if") {
      ifcode += `{${compiled}?${code}:`;
      if (isElse(next)) { continue; } else { code = ifcode + "null}"; ifcode = ""; }
    }
    if (name === "wx:elseif") {
      ifcode += `${compiled}?${code}:`;
      if (isElse(next)) { continue; } else { code = ifcode + "null}"; ifcode = ""; }
    }
    if (name === "wx:else") {
      if (ifcode === "") {
        ifcode += `{!${compiled}?${code}:null}`;
      } else {
        ifcode += `${code}`;
      }
      code = ifcode;
      ifcode = "";
    }
  }
  return code;
};

After converting each page's CSS, JS, and JSX, wrap the WXML‑converted code into a React component.

export const packWxml = (fileNode) => {
  const code = `export default (props) => {
    const [state, setState] = React.useState(props.data);
    React.useEffect(() => {
      setStates[${fileNode.parent.id}] = setState;
    }, []);
    return <>{${fileNode.out}}</>;
  }`;
  return code;
};

App Bundle

Finally bundle everything into an app.js file.

window.manifest = {
  origin: {
    pages: ["pages/page1/index", "pages/page2/index"],
    tabBar: {
      color: "#7A7E83",
      selectedColor: "#3cc51f",
      borderStyle: "rgb(200,200,200)",
      backgroundColor: "#ffffff",
      list: [{
        iconPath: "/public/icon_API.png",
        selectedIconPath: "/public/icon_API_HL.png",
        pagePath: "pages/page1/index",
        text: "组件"
      }, {
        iconPath: "/public/icon_API.png",
        selectedIconPath: "/public/icon_API_HL.png",
        pagePath: "pages/page2/index",
        text: "组件2"
      }]
    },
    window: {
      backgroundTextStyle: "light",
      navigationBarBackgroundColor: "#fff",
      navigationBarTitleText: "WeChat",
      navigationBarTextStyle: "black"
    }
  },
  pages: [{
    id: 2,
    info: { usingComponents: {} },
    scripts: [
      "// example/pages/page1/test.js
var test = () => {
  console.log(\"test\");
};

// example/pages/page1/index.js
Page({
  data: { arr: [{ a: 0, b: 0 }] },
  onLoad(options) { console.log(1, options); },
  add() { this.setData({ arr: this.data.arr.concat([{ a: this.data.arr.length, b: this.data.arr.length * 2 }]) }); },
  test() { test(); }
});
",
      "var __defProp = Object.defineProperty; /* ... compiled module ... */"
    ],
    styles: ["/2.css"],
    path: "/pages/page1/index"
  }, {
    id: 3,
    info: { usingComponents: {} },
    scripts: [
      "// example/pages/page2/index.js
Page({ data: { num: 0 }, async getBatteryInfo() { const res = await wx.getBatteryInfo(); console.log(res); this.setData({ num: res }); }, onLoad() { console.log(\"onload\"); }, onShow() { console.log(\"onshow\"); } });
",
      "var __defProp = Object.defineProperty; /* ... compiled module ... */"
    ],
    styles: ["/3.css"],
    path: "/pages/page2/index"
  }]
};

Runtime Layer

Define a global Page function that stores page instances, implements setData, and handles event binding.

const pages = manifest.pages;
const pageGraph = {};
var global = { modules: {}, Page, $for, $handleEvent, useEffect: React.useEffect, setStates: {} };

var Page = (option) => {
  pageGraph[p.id] = new _Page(option, p.id);
};

class _Page {
  constructor(option, id) {
    this.id = id;
    this.parent = null;
    this.eventMap = {};
    for (const key in option) { this[key] = option[key]; }
  }
  setData(data) {
    this.data = { ...this.data, ...data };
    const setState = global.setStates[this.id];
    setState(this.data);
  }
}

Component Library

Global custom components are defined in components.js and a simple Button component.

import Button from "./Button";
var comp = { Button, View: "div", Text: "span" };
window.comp = comp;

// Button.jsx
export default (props) => {
  const { onClick, children } = props;
  return (<button className="wx-button" onClick={onClick}>{children}</button>);
};

Utility Functions

function $for(arr, fn, key) {
  arr = arr || [];
  return arr.map((item, index) => {
    const vdom = fn(item, index);
    vdom.key = key || index;
    return vdom;
  });
}
function $handleEvent(name, id, custom) {
  const ins = pageGraph[id];
  const method = ins[name] || (ins.methods || {})[name] || function() {};
  ins.eventMap[custom] = name;
  return (e) => { method.call(ins, e); };
}

Rendering

const Comp = global.modules[scripts[1]].default;
ReactDOM.render(
  React.createElement(wrap, { page: pageGraph[id], tabBar, path, manifest, Comp }),
  document.body
);

Node Layer

Server

An Express server serves the built files and provides hot‑reload via socket.io.

export const server = (options) => {
  const express = require("express");
  const distdir = resolve(options.o);
  const appEntry = resolve(options.e, "app.json");
  const appJson = require(appEntry);

  const app = express()
    .use(express.static(distdir))
    .get("/", (_, res) => {
      getIndexHtmlCode().then(data => res.end(data));
    });

  appJson.pages.forEach(page => {
    app.get("/" + page, (_, res) => {
      getIndexHtmlCode().then(data => res.end(data));
    });
  });

  app.listen(port, err => { if (err) throw err; console.log(`start:http://localhost:${port}`); });
  return app;
};

export async function getIndexHtmlCode() {
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>miniapp</title>
  <style>*{margin:0;padding:0;}</style>
  <link rel="stylesheet" href="/runtime.css">
</head>
<body>
  <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  <script src="/app.js"></script>
  <script src="/runtime.js"></script>
</body>
<script>
var wx = {};
if (window.JSBridge) { console.log("app env"); } else { console.log("browser env"); }
</script>
</html>`;
}

Hot Reload

if (options.w) {
  chokidar.watch(resolve(options.e), { persistent: true, awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 500 } })
    .on("change", async () => {
      ser.reloadStart?.();
      await rebuild(options);
      ser.reloadEnd?.();
    });
}

const http = require("http").createServer(app);
const io = require("socket.io")(http);
http.listen(8109, () => { console.log("socket.io listening on *:8109"); });
io.on("connection", socket => { socket.on("disconnect", () => {}); });
app.reloadEnd = () => { io.emit("reload-end"); };
app.reloadStart = () => { io.emit("reload-start"); };

Application Layer Integration

Detect JSBridge to decide host environment; mount wx API accordingly.

<script>
var wx = {};
if (window.__TAURI_INTERNALS__) {
  const { invoke } = window.__TAURI_INTERNALS__;
  wx.getBatteryInfo = async () => {
    const res = await invoke('getBatteryInfo');
    return res;
  };
}
</script>

Example Tauri implementation for wx.getBatteryInfo in Rust.

use battery::{units::ratio::percent, Manager, State};

#[tauri::command]
fn getBatteryInfo() -> u64 {
    let manager = Manager::new().unwrap();
    let batteries = manager.batteries().unwrap();
    let mut p = 0.0;
    for battery in batteries {
        let battery = battery.unwrap();
        p = battery.state_of_charge().value * 100.0;
        println!("Device battery: {:.1}%", p);
        println!("Battery state: {:?}", battery.state());
    }
    p as u64
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![getBatteryInfo])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Demo Run

WeChat developer tool screenshot:

Conclusion

The container can be likened to LEGO bricks:

Disassembly (compile conversion) – parse WXML into an AST.

Assembly rules – transform AST to JSX, define data updates and event triggers.

Play everywhere – the container runs on web, desktop, mobile, or within Tauri.

Future directions include replacing Node with a compiled language or WebAssembly for speed, adding sandbox protection, and exposing a direct AST‑to‑JSX API for other mini‑program ecosystems.

frontendcontainermini-programnodecross‑platform
Goodme Frontend Team
Written by

Goodme Frontend Team

Regularly sharing the team's insights and expertise in the frontend field

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.