跨端框架原来如此简单

前言

本文将会从业务的视角阐述跨端的应用场景,从技术视角分析不同的技术路线的优劣势,同时以 RaxJS 为模板,从多个维度对运行时与编译时两套技术路线的原理做深入分析。阅读完本文,你会有以下收获:

  1. 了解跨端的应用场景与技术路线。
  2. 了解一个跨端框架的基本原理。

跨端的应用场景

在移动互联网时代,流量成为企业获取用户和推动业务增长的核心。公域流量的有效获取尤为关键,例如微信小程序等渠道。然而,公域与私域产品在用户体验上有所不同,私域通常更优。因此,提升公域流量需要改善其用户体验,使之匹配私域的水平。技术上,使用跨端框架可以降低成本,提高开发效率,实现多渠道产品的一致性。这样不仅降低了开发和维护的难度,还能快速应对市场变化,增强竞争力。

跨端框架的技术路线选择

跨端框架的两种主要技术路线分别是编译时(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
}

微信小程序对应的页面与组件分别有两个构造器:PageComponent。分别会接受对应构造器的生命周期与基础数据与方法。通过产物看,我们传递给 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 整体转换流程大致分为三步:

  1. 将jsx解析为三部分:import、export、AST
  2. 将上一步解析的内容根据不同的诉求在不同的 module 进行处理
  3. 将处理好的内容,生成与容器对应的 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 的方式进行页面视图渲染更新。优点:迭代效率高;缺点:性能相对较差

相关推荐
沐爸muba14 分钟前
JS中的for...in和for...of有什么区别?
前端·javascript
lljss202014 分钟前
表格HTML
前端·html
桃花加酥18 分钟前
js笔记(二进制由0和1两个数字组成)
java·javascript
慕仲卿1 小时前
为什么要使用补码表示负数
javascript
集成显卡1 小时前
快来用 Rspack/Rsbuild + pnpm 构建你的 monorepo 全栈项目
javascript·webpack·rust
xmh-sxh-13141 小时前
前端常用的主流框架有哪些
前端
程序员大金1 小时前
基于SpringBoot+Vue+MySQL的校园一卡通系统
java·javascript·vue.js·spring boot·后端·mysql·tomcat
m0_528723811 小时前
vue2与vue3的区别
前端·javascript·vue.js
J不A秃V头A2 小时前
el-table使用el-switch选择器没效果
javascript·vue.js·elementui
huangfuyk2 小时前
Vue3+Element Plus:使用el-dialog,对话框可拖动,且对话框弹出时仍然能够在背景页(对话框外部的页面部分)上进行滚动以及输入框输入信息
前端·javascript·vue.js·vue 3