普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月19日首页

撸一个小程序运行容器

2025年5月19日 08:00

作者:蔡钧

一、背景

现在几大热门的APP都能运行小程序,每个APP都有自己的小程序开发工具(有谁懂。。。。。。),每个APP的小程序都有自己的“语法”,虽然有细微的差别但大差不差,那如果我希望自己写的APP也能拥有小程序运行能力的话应该怎么做呢?

叠甲:本文实现思路参考smallapp,在其基础上解决了一些bug和删减,能跑起一个demo,主要讲解实现思路,中间还有很多可以优化和拓展的东西就不展开了。

再叠:微信有自己的WMPF,它更偏向应用型,再拆一层就是我们要做的东西。

二、目标

构建一个 "可插拔" 的小程序容器,支持多平台运行(Web/App/Desktop),并且展示效果一致。

三、架构设计

容器层需要关注的核心

  • 数据-视图,正常渲染
  • 基础组件
  • 事件处理
  • 生命周期

四、容器层实现

小程序转换层

我们把webview作为运行容器的宿主,把小程序语法转换成html即可

画板

css

通过postcss和postcss-import打包成一个css文件

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

通过esbuild把js文件打包成一个bundle

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

逐行处理wxml内容

// 0. 原始状态
<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. 状态处理后的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>

// 2. 词法分析结果(tokens)
[
  { type: 'tag', value: 'view', attributes: { class: 'wrap' } },
  { type: 'text', value: '{{state.arr.length}}' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  {
    type: 'tag',
    value: 'view',
    attributes: { 'wx:for': '{{state.arr}}' }
  },
  { type: 'tag', value: 'text', attributes: {} },
  { type: 'text', value: '{{item.a}}:{{item.b}}:{{index}}' },
  { type: 'tag', value: 'text', attributes: {}, closeStart: true },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  {
    type: 'tag',
    value: 'view',
    attributes: { 'wx:if': '{{state.arr.length > 5}}' }
  },
  { type: 'text', value: 'show' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  {
    type: 'tag',
    value: 'view',
    attributes: { 'wx:elseif': '{{state.arr.length > 2}}' }
  },
  { type: 'text', value: 'show2' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  { type: 'tag', value: 'view', attributes: { 'wx:else': '' } },
  { type: 'text', value: 'hidden' },
  { type: 'tag', value: 'view', attributes: {}, closeStart: true },
  { type: 'tag', value: 'button', attributes: { 'bind:tap': 'add' } },
  { type: 'text', value: 'add' },
  { type: 'tag', value: 'button', attributes: {}, closeStart: true }
]

// 3. 语法分析结果(AST)
{
  children: [
    {
      type: 'node',
      name: 'view',
      attributes: [Object],
      children: [Array]
    },
    ...
  ],
}

// 4. 生成jsx
{
  '0': '<comp.View class={`wrap`}>{String(state.arr.length)}</comp.View>',
  '1': '{$for(state.arr,(item, index) => (<comp.View ><comp.Text >{String(item.a)}:{String(item.b)}:{String(index)}</comp.Text></comp.View>))}',
  '2': '{state.arr.length > 5?<comp.View >show</comp.View>:state.arr.length > 2?<comp.View >show2</comp.View>:<comp.View >hidden</comp.View>}',
  '3': '<comp.Button  onClick={$handleEvent("add", "2", "bind:tap")} >add</comp.Button>'
}

举几个有意思的方法

在生成过程jsx中

把wx:开头的方法放进directives中

把bind方法都转换成on"Event"事件并绑定在$handleEvent触发下

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 + ">";
};

在生成direct的时候,我们把wx:for通过自定义方法$for包裹起来,把wx:if用三元的表达式转换

<view wx:for="{{arr}}">
    <text>{{item.a}}:{{item.b}}:{{index}}</text>
</view>

转换为

{$for(state.arr,(item, index) => (<comp.View><comp.Text>{String(item.a)}:{String(item.b)}:{String(index)}</comp.Text></comp.View>))}
<view wx:if="{{arr.length > 5}}">show</view>
    <view wx:elseif="{{arr.length > 2}}">show2</view>
<view wx:else>hidden</view>

转换为

{state.arr.length > 5?<comp.View >show</comp.View>:state.arr.length > 2?<comp.View show2</comp.View>:<comp.View >hidden</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;
  }
};

到这里我们已经可以转换好了每个页面对应的css,js和jsx,但wxml转换后的代码还需要被React包裹成组件才能执行,包裹一下就能得到完整的jsx文件代码。

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}</>
};\n`;
  return code;
};

app包

最终我们打包编译并生成一个app.js文件作为完整的构建完成文件

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\nvar test = () => {\n  console.log("test");\n};\n\n// example/pages/page1/index.js\nPage({\n  data: {\n    arr: [\n      {\n        a: 0,\n        b: 0\n      }\n    ]\n  },\n  onLoad(options) {\n    console.log(1, options);\n  },\n  add() {\n    this.setData({\n      arr: this.data.arr.concat([\n        {\n          a: this.data.arr.length,\n          b: this.data.arr.length * 2\n        }\n      ])\n    });\n  },\n  test() {\n    test();\n  }\n});\n\n',
        'var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n  for (var name in all)\n    __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n  if (from && typeof from === "object" || typeof from === "function") {\n    for (let key of __getOwnPropNames(from))\n      if (!__hasOwnProp.call(to, key) && key !== except)\n        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n  }\n  return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);\nvar stdin_exports = {};\n__export(stdin_exports, {\n  default: () => stdin_default\n});\nmodule.exports = __toCommonJS(stdin_exports);\nvar stdin_default = (props) => {\n  const [state, setState] = React.useState(props.data);\n  React.useEffect(() => {\n    setStates[2] = setState;\n  }, []);\n  return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(comp.View, { class: `wrap` }, String(state.arr.length)), $for(state.arr, (item, index) => /* @__PURE__ */ React.createElement(comp.View, null, /* @__PURE__ */ React.createElement(comp.Text, null, String(item.a), ":", String(item.b), ":", String(index)))), state.arr.length > 5 ? /* @__PURE__ */ React.createElement(comp.View, null, "show") : state.arr.length > 2 ? /* @__PURE__ */ React.createElement(comp.View, null, "show2") : /* @__PURE__ */ React.createElement(comp.View, null, "hidden"), /* @__PURE__ */ React.createElement(comp.Button, { onClick: $handleEvent("add", "2", "bind:tap") }, "add"));\n};\n',
      ],
      styles: ["/2.css"],
      path: "/pages/page1/index",
    },
    {
      id: 3,
      info: { usingComponents: {} },
      scripts: [
        '// example/pages/page2/index.js\nPage({\n  data: {\n    num: 0\n  },\n  async getBatteryInfo() {\n    const res = await wx.getBatteryInfo();\n    console.log(res);\n    this.setData({\n      num: res\n    });\n  },\n  onLoad() {\n    console.log("onload");\n  },\n  onShow() {\n    console.log("onshow");\n  }\n});\n\n',
        'var __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n  for (var name in all)\n    __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n  if (from && typeof from === "object" || typeof from === "function") {\n    for (let key of __getOwnPropNames(from))\n      if (!__hasOwnProp.call(to, key) && key !== except)\n        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n  }\n  return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);\nvar stdin_exports = {};\n__export(stdin_exports, {\n  default: () => stdin_default\n});\nmodule.exports = __toCommonJS(stdin_exports);\nvar stdin_default = (props) => {\n  const [state, setState] = React.useState(props.data);\n  React.useEffect(() => {\n    setStates[3] = setState;\n  }, []);\n  return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(comp.Button, { onClick: $handleEvent("getBatteryInfo", "3", "bind:tap") }, "get"), /* @__PURE__ */ React.createElement(comp.Text, null, "\\u5F53\\u524D\\u8BBE\\u5907\\u7535\\u91CF\\uFF1A", String(state.num)));\n};\n',
      ],
      styles: ["/3.css"],
      path: "/pages/page2/index",
    },
  ],
};

运行层

代码已经生成了,想要把这个页面运行起来还需要完善前面埋下的一些全局变量以及Page方法

Page

将Page中的参数全都丢进自定义实体中,实现this.setData并实际调用对应组件的setState

const pages = manifest.pages;
const pageGraph = {};

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

if (path === "/") {
  window.location.href = window.location.origin + pages[0].path;
} else {
  p = pages.find((i) => i.path === path);
}

const { scripts, styles, id } = p;

execScript(scripts[1], global); //new Function -> call 将编译后的组件挂在到global.modules下
execScript(scripts[0], global); //new Function -> call 将页面的方法挂在到pageGraph下

execStyles(styles); // 添加css的link

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

var _Page = class {
  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);
  }
};

comp

全局自定义组件,想要实现原生的组件就可以在这里编写

// components.js
import Button from "./Button";

var comp = {
  Button,
  View: "div",
  Text: "span",
};

window.comp = comp;


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

$for

遍历dom

function $for(arr, fn, key) {
  arr = arr || [];
  return arr.map((item, index) => {
    const vdom = fn(item, index);
    vdom.key = key || index;
    return vdom;
  });
}

$handleEvent

call触发

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);
  };
}

渲染

执行过scripts[1]后拿到export出来的页面组件Comp并将其渲染

const Comp = global.modules[scripts[1]].default;

ReactDOM.render(
  React.createElement(wrap, {
    page: pageGraph[id],
    tabBar,
    path,
    manifest,
    Comp,
  }),
  document.body
);

// wrap.jsx
export const wrap = (props) => {
  const { page, manifest, tabBar, Comp, path } = props;

  const [show, setShow] = React.useState(false);

  React.useEffect(() => {
    page.onLoad && page.onLoad();
    return () => {
      page.onUnload && page.onUnload();
    };
  }, []);
  
  return (
    <>
      <Comp data={page.data} />
      {show && (
        <div
          style={{
            position: "fixed",
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
            top: 0,
            left: 0,
            right: 0,
            padding: "8px 0 20px",
            height: "100vh",
            width: "100vw",
            fontSize: "20px",
            background: "rgba(0, 0, 0, 0.5)",
            color: "#fff",
          }}
        >
          <span>编译中</span>
        </div>
      )}
      {!manifest.origin.tabBar.custom && (
        <div
          style={{
            position: "fixed",
            display: "flex",
            bottom: 0,
            left: 0,
            right: 0,
            padding: "8px 0 20px",
            fontSize: "10px",
            backgroundColor: tabBar.backgroundColor,
            borderTop: `1px solid ${tabBar.borderStyle}`,
            color: tabBar.color,
          }}
        >
          {tabBar.list.map((item) => {
            const isSelect = "/" + item.pagePath === path;
            return (
              <div
                key={item.pagePath}
                style={{
                  display: "block",
                  width: "100%",
                  textAlign: "center",
                }}
                onClick={() => {
                  if (isSelect) return;
                  location.href = "/" + item.pagePath;
                }}
              >
                <img
                  src={isSelect ? item.selectedIconPath : item.iconPath}
                  style={{ width: "30px", height: "30px" }}
                />
                <div
                  style={{
                    color: isSelect ? tabBar.selectedColor : tabBar.color,
                  }}
                >
                  {item.text}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </>
  );
}

node层

页面

我使用的是多页的方式实现,在node层启动了一个express并且注册了这些页面

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("brower env");
}
</script>
</html>`;
}

更新重新编译

node层&前端引入socket.io,监听入口文件

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");
};
const socketUrl = window.location.origin.replace(/:\d+/, ":8109");
const io = socket(socketUrl, {
  transports: ["websocket"],
  autoConnect: false,
  reconnection: false,
  forceNew: true,
  multiplex: false,
});

io.on("reload-start", () => {
  setShow(true);
});

io.on("reload-end", () => {
  setShow(false);
  window.location.reload();
});

五、应用层接入

在index.html里面我们判断是否含有JSBridge来判断宿主环境是app还是浏览器,所以只要在应用层接入时往webview的wx挂载一个变量并实现wx API即可

tauri实现

上层用tauri然后实现方法wx.getBatteryInfo

省事改下index.html的代码

<script>
var wx = {}
if (window.__TAURI_INTERNALS__) {
  const { invoke } = window.__TAURI_INTERNALS__;
  wx.getBatteryInfo = async () => {
    const res = await invoke('getBatteryInfo')
    return res;
  };
}
</script>
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!("设备电量: {:.1}%", battery.state_of_charge().value * 100.0);
        println!("电池状态: {:?}", battery.state());
    }

    return 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运行

微信开发者工具效果

七、总结

把这个容器的实现类比成乐高积木

  1. 拆解(编译转换)
    • 把wxml拆成ast
  2. 组装说明(运行时规则)
    • ast组装成jsx
    • 确定规则(数据更新/事件触发)
  3. 随处可玩(多平台接入)
    • 多平台可接入运行

还能怎么玩?

  • 安装电机(用编译型语言替换node实现/wasm加速)
  • 安装熊孩子保护罩(沙箱防护)
  • 拼接其他品牌的积木(从wxml -> ast -> jsx 提供直接从 ast ->jsx 的方法)
❌
❌