前言
本文将会从业务的视角阐述跨端的应用场景,从技术视角分析不同的技术路线的优劣势,同时以 RaxJS 为模板,从多个维度对运行时与编译时两套技术路线的原理做深入分析。阅读完本文,你会有以下收获:
- 了解跨端的应用场景与技术路线。
- 了解一个跨端框架的基本原理。
跨端的应用场景
在移动互联网时代,流量成为企业获取用户和推动业务增长的核心。公域流量的有效获取尤为关键,例如微信小程序等渠道。然而,公域与私域产品在用户体验上有所不同,私域通常更优。因此,提升公域流量需要改善其用户体验,使之匹配私域的水平。技术上,使用跨端框架可以降低成本,提高开发效率,实现多渠道产品的一致性。这样不仅降低了开发和维护的难度,还能快速应对市场变化,增强竞争力。
跨端框架的技术路线选择
跨端框架的两种主要技术路线分别是编译时(Compile-Time)和运行时(Runtime)的技术路线。这两种路线在实现方式、优劣势和适用场景上有一些显著的差异。
编译时方案
优势 | 劣势 |
---|---|
- 性能更优:由于代码在编译时生成原生代码,无需在运行时进行解释,减少了运行时的开销,性能较好,更接近原生应用。 | - 编译时语法限制: 在编译时,由于需要提前确定目标端的代码结构,可能会对部分高级语法进行限制,影响一些动态特性的使用。 |
- 更好的静态分析:编译时方案可以在编译阶段进行静态分析,检测潜在的问题,提前发现并解决一些潜在的错误,有利于代码质量的保证。 | - 研发效率影响: 在编译时,于在编译时需要对代码进行额外处理,可能会导致开发效率相对较低,特别是在不熟悉的情况下。 |
运行时方案
优势 | 劣势 |
---|---|
- 灵活度高:运行时方案更加灵活,能够在运行时根据不同端的环境动态调整代码,支持更丰富的语法和特性。 | - 性能相对差: 相比编译时方案,运行时方案通常性能相对较差。由于需要在运行时动态适配不同平台,可能引入一些运行时的性能开销。 |
- 开发效率高:运行时方案在开发阶段更为友好,能够充分利用原生平台的特性,提高开发效率。 | - 潜在的兼容问题: 由于在运行时需要适配不同平台,可能面临一些潜在的兼容性问题,需要在代码中进行一些条件判断和处理。 |
跨端框实现原理
无论是编译时方案,还是运行时方案,一个跨端框架都可以简化抽象为三层:应用层、框架层、构建层

应用层:在跨端框架的应用层,开发者使用一套统一的组件和API进行业务开发。这些组件和API包括诸如View、Text等基础组件,以及与具体业务相关的高级组件。应用层的代码是开发者直接编写的,不需要考虑目标端的差异性。
框架层:框架层作为跨端框架的核心,负责处理应用层代码在不同端的运行差异。它通过提供垫片(Shim)的能力,在运行时抹平多端差异,暴露统一的容器接口。
构建层:构建层是整个跨端框架的底层基础,它通过编译工具将开发者编写的标准 DSL 编译为适应不同端的具体 DSL。这一层的工作包括代码转译、资源管理、性能优化等。
下面我们对以上三层进行逐层分析,了解具体实现原理
应用层
无论采用编译时方案还是运行时方案,应用层都需遵循多端一致的核心原则,即制定标准。这一过程首先涉及最上层的 DSL 的选择,通常我们会根据团队的技术现状来进行决策。例如,如果团队主要使用 Vue 技术栈,DSL 层就可以以 Vue 的 DSL 为标准。
在原子组件与 API 标准的制定上,同样需要根据团队的技术现状和业界标准做出明智的决策。一个很好的选择参考,在组件方面,可以采用 React-Native 的组件标准来实现;而在API方面,可以采用微信开放 API 为标准来进行实现。
然而需要注意的是,考虑到性能和包体积的因素,原子组件的 API 可能更适合选择在多端分别用对应端的源码实现。因此,在构建过程中,需要进行按需打包和深度 tree-shaking 处理,以确保最终产品在性能和体积方面都能达到最佳状态。
框架层
垫片技术在确保框架一致性方面扮演着关键的角色。通过巧妙运用垫片技术,框架能够在各种不同的终端上模拟出类似的运行环境,有效地遮蔽了底层的差异性。以此为例,对于不同的容器环境,框架层能够提供一致的操作接口,使得开发者能够摆脱对底层实现细节的烦扰。鉴于小程序容器与端内容器、浏览器环境之间的显著差异,垫片技术在小程序环境中发挥着核心的应用作用。值得注意的是,不同的技术路线在框架层的能力存在差异。接下来,我们将以 RaxJS 在微信小程序下为例,对不同技术路线的实现进行深入分析。
我们简单实现一个页面,这个页面包含以下两个功能:1)引用了一个本地组件,其展示内容为父组件传递,且点击后,会通过父组件修改展示内容2)有一个自加器,每点击一次,数字会自动增加一次。先看一下源码:
javaScript
// 页面组件
import { createElement, useState } from 'rax';
import View from 'rax-view';
import Text from 'rax-text';
import Demo1 from '../../components/Demo1';
import styles from './index.module.css';
export default function Home() {
const [count, setCount] = useState(0);
const [txt, setTxt] = useState('hello demo1');
const addCount = () => {
setCount(count + 1);
};
const changeTxt = () => {
setTxt(`展示随机数:${Math.random().toFixed(3)}`);
};
return (
<View className={styles.homeContainer}>
<Demo1 txt={txt} onTxtClick={changeTxt} />
<Text className={styles.homeInfo} onClick={addCount}>{count}</Text>
</View>
);
}
// Demo1 组件
import { createElement } from 'rax';
import View from 'rax-view';
import Text from 'rax-text';
import styles from './index.module.css';
export default function Demo1({ txt, onTxtClick }) {
return (
<View className={styles.homeContainer}>
<Text className={styles.homeInfo} onClick={onTxtClick}>父组件传递{txt}</Text>
</View>
);
}
真机表现如下:
小程序 | H5 |
---|---|
![]() |
![]() |
接下来我们分析一下,在编译时与运行时产物(注:代码均为简化后代码
)
编译时:对于编译时框架而言,垫片能力会比较轻,更多的是做好 dom 与框架的映射关系
微信小程序:
Home 页面组件产物
javaScript
// JS 产物
function Home() {
var _useState = (0, _jsx2mpRuntime.useState)(0),
_useState2 = _slicedToArray(_useState, 2),
count = _useState2[0],
setCount = _useState2[1];
var _useState3 = (0, _jsx2mpRuntime.useState)('hello demo1'),
_useState4 = _slicedToArray(_useState3, 2),
txt = _useState4[0],
setTxt = _useState4[1];
var changeTxt = function changeTxt() {
setTxt("\u5C55\u793A\u968F\u673A\u6570\uFF1A".concat(Math.random().toFixed(3)));
};
this._updateChildProps("2", {
"txt": txt,
"onTxtClick": changeTxt
});
this._updateData({
"_d0": txt,
"_d1": count
});
this._updateMethods({
"_e0": changeTxt,
"_e1": function addCount() {
setCount(count + 1);
}
});
}
var __def__ = Home;
Page((0, _jsx2mpRuntime.createPage)(__def__, {
events: ["_e0", "_e1"]
}));
// WXML 产物
<block wx:if="{{$ready}}">
<view class="__rax-view homeContainer">
<c-44b3ce
txt="{{_d0}}"
onTxtClick="_e0"
__tagId="{{__tagId}}-2"
/>
<text class="__rax-text homeInfo" bindtap="_e1">{{ _d1 }}</text>
</view>
</block>
// JSON 产物
{
"usingComponents": {
"c-44b3ce": "../../components/Demo1/index"
}
}
组件 Demo1 产物
javaScript
// JS 产物
function Demo1(_ref) {
var txt = _ref.txt,
onTxtClick = _ref.onTxtClick;
this._updateData({
"_d0": txt
});
this._updateMethods({
"_e0": onTxtClick
});
}
var __def__ = Demo1;
Component((0, _jsx2mpRuntime.createComponent)(__def__, {
events: ["_e0"]
}));
// WXML 产物
<block wx:if="{{$ready}}">
<view class="__rax-view homeContainer">
<text class="__rax-text homeInfo" bindtap="_e0">父组件传递{{ _d0 }}</text>
</view>
</block>
// JSON 产物
{
"component": true
}
微信小程序对应的页面与组件分别有两个构造器:Page、Component。分别会接受对应构造器的生命周期与基础数据与方法。通过产物看,我们传递给 Page与 Component 的均为 jsx2mpRuntime 所构造的对象。接下来我们会从三个维度去分析垫片的功能。
生命周期映射分析:抹平容器差异。
以 Page 为例,通过构造类对象,制定框架生命周期,同时通过事件机制触发多频次生命周期钩子,最后再通过时机映射与微信小程序的 Page 生命周期关联。

逻辑层一致性分析:抹平框架差异。
-
数据渲染
当页面构造完成后,会触发组件 render,render 本身是执行函数式组件,触发函数内部的 _updateData 方法,_updateData 方法最终会触发 Page 构造器本身的 setData 方法,从而触发页面重新渲染,最终页面呈现。(这里需要注意,实际的渲染是发送了两次,第一次是构造 Page 生命周期,这一次是一个空白渲染,并在渲染完成后会吧,$ready 置为 true。页面配置挂载完成后,会触发 Page 的 onLoad 回调,在 onLoad 回调中会触发函数式组件首次 render,这一次 render 会调用一次 setData 方法,从而渲染真正的页面数据)
-
事件机制与状态变更
在组件构造时,会将对应的时间挂载到实例上,当点击触发就会调用对应方法,前后数据对比完成后,重新触发render,渲染过程与上述描述一致,只是这次从 hook 中取的值是最新的值。
-
父子组件通信
通过编译时 tagID 的注入,在父组件 render 的过程中,会通过 _updateChildProps 与子组件关联,从而注入方法,实现父子组件绑定。
UI 层一致性分析:UI 单位样式抹平。
-
UI 容器样式一致
除了逻辑层的垫片外,还需要保障 CSS 在多端的表现一致。通过上面的分析我们可以知道,在开发过程中,我们会使用原子组件来开发,如 View 组件对应 RN 侧的 VIew 标签,H5 的 Div 标签,微信小程序的 View 标签,通过这一层我们就可以实现容器层的一致。
H5侧 View 容器默认样式 小程序侧 View 容器默认样式 -
CSS 单位一致
微信小程序支持 rpx 为单位,但是 H5 不支持,我们可以通过构建过程中的单位转换实现一致。以下为:源码、微信小程序、H5 单位转换。
源码 H5 侧表现 小程序侧表现
运行时:对于运行时框架而言,垫片的能力会比较重,需要提供统一的渲染模板,与虚拟dom的处理。
Home 页面组件产物
javaScript
// JS 产物
const render = require('./../../render');
function init(window, document) {require('./../../bundle.js')(window, document);}
Page(render.createPageConfig('pages/Home/index', [], init, ''))
// WXML 产物
<import src="./../../root.wxml"/>
<template is="RAX_TMPL_ROOT_CONTAINER" data="{{r: root}}" />
// JSON 产物
{
"usingComponents": {
"element": "./../../comp"
}
}
通用 Comp 产物
javaScript
// JS 产物
const render = require('./render.js');
Component(render.createElementConfig());
// WXML 产物
<import src="./root.wxml"/>
<template is="RAX_TMPL_ROOT_CONTAINER" data="{{r: r}}" />
// JSON 产物
{
"component": true,
"usingComponents": {
"element": "./comp"
}
}
构建后产物分析,我们会发现运行时与编译时的产物差别非常大,产物只有一个页面产物,并没有 Demo1 的产物,但是多了一个通用的 Comp 产物与 bundle.js。那么运行时又是如何渲染,如何抹平差异的呢?接下来,我们依然从三个维度来分析整个过程
生命周期映射分析:抹平容器差异
以 Page 为例分析,与编译时一样,会通过 render 函数构造出微信小程序的生命周期钩子,但是生命周期的执行函数比编译时要复杂很多,具体的逻辑会在接下来的渲染过程详细分析。

逻辑层一致性分析:抹平框架差异。
-
数据渲染
通过调用堆栈分析可以看到,运行时也是渲染了两次,第一次为空渲染,第二次在 onLoad 阶段进行渲染,这里的渲染与编译时差别很大,在渲染的过程中,会抽象出一个虚拟Dom,在最后的调用 root.appendChild 时将构造好的节点插入,实际是将虚拟dom更新到整个 data 节点。
再通过微信开发者工具查看组件的数据信息,整个 data 就是一棵 Dom 树
UI 层拿到这颗 Dom 树后,会通过模板进行循环渲染,最后渲染出整个页面。(这里说个题外话:小程序的动态化方案也是可以采取类似的方案,绕过微信审核,动态发版)
-
事件机制与状态变更
通过事件委托的机制,在生成 Page 实例配置时,将所有的事件进行注册,当通用模板对应节点触发事件时,就会响应到对应的代理事件,代理事件再通过当前触发的节点,去维护的 Dom 树中查找对应的元素及其绑定的事件,从而进行响应,当响应时间触发对应的函数后,调用 setData,修改虚拟 Dom 节点,修改完成后进行页面结构更新。
-
父子组件通信
通过上述的分析可以知道,因为不存在子组件,本质上父子通信都发生在 JS 的 runtime 阶段,状态变更引起最后的 Dom 树变化,从而导致渲染不同的元素。这里就不再赘述。
阅读至此,或许您已经注意到一个关键点:我们可以在微信小程序中引入 React,并进一步通过保留 React 接口的方式,在微信小程序环境下实现所需的功能,从而创建一个类似于 React-Dom 或 React-Native 的容器框架。通过这种方式,我们能够充分发挥 React 在不同平台下的强大能力,实现对应接口的功能,使其在微信小程序中得以充分展现。Remax 就是一款主打 React 运行时框架,在小程序端,完整的引入了 React 框架。
UI 层一致性分析:UI 单位样式抹平。
UI容器与单位的标准化方案与编译时相同,但与编译时不同的是,由于不存在独立的组件文件,所有CSS代码都汇聚在一个文件中。此时的关键问题在于解决CSS权重的问题,通过不同的哈希值可以有效解决权重冲突。
构建层
构建层大致的工作流程是,针对不同的容器采用不同的打包工具(如 RN 的metro,web 端的 webpack),将代码编译的对源码进行拆解成符合对应容器的标准文件。在所有容器转换过程中,小程序的转换最为复杂,接下来我们以 RaxJS 小程序的转换过程进行分析,了解其运作流程与转换原理。
编译时
通过产物分析,我们发现构建层,将一个 JSX 文件拆分为三个文件:WXML、JS、JSON。同时针对 app.json 也做了对应转换。RaxJS 在小程序端的打包工具采用的是 webpack,让我们看一下,webpack的配置文件(注:配置为精简后配置,只展示了跟小程序转换的核心loader与plugin):
javaScript
{
target: 'node',
resolve: {...},
module: {
rules: [
{ test: /\.(tsx?)$/, use: [{ loader: 'ts-loader/index.js', }] },
{ test: /\.t|jsx?$/,
enforce: 'post',
use: [
{ loader: 'jsx2mp-loader/src/component-loader.js' },
{ loader: 'rax-platform-loader/lib/index.js' },
{ loader: 'jsx2mp-loader/src/script-loader.js' },
{ loader: 'jsx2mp-loader/src/app-loader.js' },
{ loader: 'jsx2mp-loader/src/page-loader.js' }] },
{ test: /\.js$/,
use: [
{ loader: 'jsx2mp-loader/src/script-loader.js' },
],
},
{
test: /\.(bmp|webp|svg|png|webp|jpe?g|gif)$/i,
use: [
{ loader: 'jsx2mp-loader/src/file-loader.js' }
]
},
{
test: /\.json$/,
use: [
{ loader: 'jsx2mp-loader/src/script-loader.js' },
{ loader: 'json-loader/index.js' }
]
},
],
},
plugins: [
JSX2MPRuntimePlugin,
MiniAppConfigPlugin,
],
entry: {
app: [
'/Users/pengrongshu/work/doc-demos/rax-example/src/app.ts?role=app',
],
'@page@pages/Home/index': [
'/Users/pengrongshu/work/doc-demos/rax-example/src/pages/Home/index?role=page',
],
},
};
通过简化后的 webpack 配置分析可以知道,loader 与 plugin 的工作流大致如下:

那么我们以项目维度、页面&组件维度、编译维度,看看 loader 与 plugin 是如何做处理转换的
项目维度
项目维度的源码非常简单,对于微信小程序而言,需要 app.js、app.wxss、app.json 三个文件。
javaScript
import { runApp, IAppConfig } from 'rax-app';
const appConfig: IAppConfig = {};
runApp(appConfig);
通过对 loader 执行的分析,app.ts 经过处理后会得到 app.js 与 app.wxss 两个文件,那么 app.json 又是什么时候生成的呢?

MiniAppConfigPlugin 插件会在编译时,对项目的 app.json 与对应小程序的配置容器进行融合处理,最后生成所需的 json 文件
页面&组件维度
通过对页面组件的解析,在 page-loader 阶段,完成对页面 page.js、page.json、page.wxml、page.json 四个文件生产。同时通过依赖分析,将 import 的各个依赖,依次进入自己的 loader 处理,直至处理完成。
编译维度
通过上述分析,我们可以了解到,无论是组件还是页面,都是通过 loader 进行解析转换,二者在处理过程上区别并不大,核心都是基于 jsx-compiler 整体转换流程大致分为三步:
- 将jsx解析为三部分:import、export、AST
- 将上一步解析的内容根据不同的诉求在不同的 module 进行处理
- 将处理好的内容,生成与容器对应的 JS、WXML、WXSS、JSON 数据
在编译处理完成后,会通过 JSX2MPRuntimePlugin 将运行时垫片引入。通过上述分析,我们可以发现,编译时方案的构建处理的过程非常复杂。这也解释了为什么编译时方案的迭代效率相对较低。实际开发中,开发人员的编码风格千差万别,对应不同的编码风格需要相应的转换。这种多样性难以通过全面枚举来解决,导致多端展示不一致的问题,进而需要进行排查和分析,增加了开发工作量。同时,语法的约束也天然地限制了开发效率。
运行时
相较于编译时各种文件转换语法处理,运行时的构建过程相对简单,通过简化后的 webpack 配置文件分析:
javaScript
{
output: {
assetModuleFilename: 'assets/[hash][ext][query]',
crossOriginLoading: 'anonymous',
filename: '[name].js',
path: '/Users/pengrongshu/work/doc-demos/rax-example/build/wechat-miniprogram',
publicPath: '/',
},
module: {
rules: [
{
test: 'jsx',
exclude: [null],
use: [
{
loader: '@builder/pack/deps/babel-loader/index.js',
},
{
loader: '/rax-platform-loader/lib/index.js',
},
],
},
{
test: 'tsx',
exclude: [null],
use: [
{
loader: '/Users/pengrongshu/work/doc-demos/rax-example/node_modules/@builder/pack/deps/babel-loader/index.js',
},
{
loader: '/Users/pengrongshu/work/doc-demos/rax-example/node_modules/ts-loader/index.js',
},
{
loader: '/Users/pengrongshu/work/doc-demos/rax-example/node_modules/rax-platform-loader/lib/index.js',
},
],
},
],
},
optimization: {},
plugins: [
MiniAppRuntimePlugin,
MiniAppConfigPlugin,
],
entry: {
bundle: ['/Users/pengrongshu/work/doc-demos/rax-example/src/app.ts'],
},
};
整体处理过程分为三部分:1)前置编译前生产 app.json;2)编译打包将所有依赖打包生成 bundle.js 3)处理页面,根据页面配置信息生成通用页面文件,组件文件以及项目app.js 等
与编译时的解决方案相比,运行时的方法在构建过程中更为简单,并且整体的 AST 转换与常用的转换工具相比并无太大差异,只对部分扩充语法进行了有针对性的转换。最终,所有文件被统一打包到 bundle.js 中。由此可见,运行时方法对语法几乎没有严格的限制,这有助于提高开发效率。然而,随着项目复杂度的增加,bundle.js 的加载和运行时大量的 DomData 更新可能会影响页面性能。
总结
我们来回顾一下跨端解决方案:
应用场景:1)提供业务获取公域流量的手段;2)保障用户多渠道体验一致性;3)降低多渠道开发成本,提高迭代效率
技术方案&实现:技术路线分两种:编译时、运行时
-
编译时方案:将JSX构建为符合各个容器所需的文件,并实现生命周期映射与数据映射。优点:性能好;缺点:迭代效率低
-
运行时方案:将页面组件统一打包到一个 bundle,通过虚拟 Dom 的方式进行页面视图渲染更新。优点:迭代效率高;缺点:性能相对较差