撸一个小程序运行容器
作者:蔡钧
一、背景
现在几大热门的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运行
微信开发者工具效果
七、总结
把这个容器的实现类比成乐高积木
- 拆解(编译转换)
- 把wxml拆成ast
- 组装说明(运行时规则)
- ast组装成jsx
- 确定规则(数据更新/事件触发)
- 随处可玩(多平台接入)
- 多平台可接入运行
还能怎么玩?
- 安装电机(用编译型语言替换node实现/wasm加速)
- 安装熊孩子保护罩(沙箱防护)
- 拼接其他品牌的积木(从wxml -> ast -> jsx 提供直接从 ast ->jsx 的方法)