Web小程序引擎实现

小程序架构和原理可以从微信小程序架构小程序底层实现原理学习到,本文主要是通过实现一个 Web 版小程序引擎,让大家可以更好的理解抽象的架构说明,清楚一个小程序是如何运行的。

本项目地址:mini-app

名词解释

原生应用、客户端:承载 webview 的应用,安卓、IOS app

Webview

首先我们需要了解一下 Webview 是什么。WebView 是一种嵌入式浏览器,原生应用可以用它来展示网络内容。WebView 就是浏览器引擎部分,可以像插入 iframe 一样将 Webview 插入到原生应用中。

webview

传统 web 开发,浏览器提供了诸多 api 供前端开发者使用,那么在 hybird app 的开发模式下,网页开发者除了调用浏览器提供的 api 外还可以通过桥与原生应用进行通信,然后由原生应用执行浏览器没有提供的功能(例如开关蓝牙),然后再通过桥将结果返回给网页开发者(蓝牙开启成功或失败)。

bridge

小程序运行原理

一份小程序源码目录一般长这个样子(微信小程序的示例代码)

file

要让这份代码运行起来,一般需要两个过程:

  • 编译,将小程序源码编译为浏览器/webview 可以识别的 html 和 js
  • 执行,原生应用加载编译后的小程序程序包,按照规则执行不同的文件

整个过程是这样的,后面会详细展开说明

run

所以开发一个引擎我们需要关注的主要也就是两部分

  • 编译工具
  • 运行环境

编译工具

首先来了解一下需要编译的文件有哪些,本文仅实现引擎的主要逻辑,所以后续会忽略对全局文件(app.js 等)和配置文件(.json)的处理。
file

  • .json后缀的配置文件
  • .wxml后缀的模板文件
  • .css后缀的样式文件
  • .js后缀的脚本逻辑文件

先说一下编译结果,后续会说明为什么要这么编译

  • 包含所有页面逻辑的 app.js:由 app.js 和哥哥页面的 index.js 组成
  • 页面的视图信息(index.html + index.js):各个页面的模板、样式组成( .wxml、.css)

示例程序

本文的小程序源码结构如下图,相当于刚才给出的小程序示例的目录结构中的 pages/index 下的文件,本文不会对配置和样式扩展进行实现,所以去除了 json 文件和将 css 文件合入了.vue 文件中。

sample

此处编译的逻辑比较简单,主要就是使用 webpack,借助 vue-loader 对.vue 文件进行编译,此处需要注意的配置是入口和 HtmlWebpackPlugin 的配置

entry: {
  page: path.resolve(__dirname, "../Demo/index.js"),
  index: path.resolve(__dirname, "./template/index.js"),
},
plugins: [
  new HtmlWebpackPlugin({
    template: path.resolve(__dirname, "./template/index.html"),
    chunks: ["index"],
  })
]

实际的小程序编译过程中,入口的路径都是按照配置动态生成的。主要处理两个文件,一个是页面的逻辑 index.js,另一个是提前在编译器中准备好的模板 template/index.js。

然后我们刚才运行流程图中有提到,页面的逻辑文件是需要在一个地方单独执行的,不能和生成的 html 关联,所以需要在 HtmlWebpackPlugin chunks 上配置生成的 html 仅需要 index 这个 chunk。

另外一个 entry 也可以看到并不是.vue 文件,而是提前准备好的一个模板文件,这个模板文件除了引用.vue 文件外,还需要运行额外的操作,后面会解释为什么。

import Page from "../../Demo/index.vue";
window.pageLoad(Page);

配置编写完后就能执行构建了,这就是最后构建的结果

dist

架构设计

此处直接引用微信的架构设计了,各家大同小异,为什么这么做请看文章开头提到的两篇文章,本文剩余部分将解释如何让构建出的小程序代码在这个架构下运行。

frame

要让小程序代码直接执行肯定是不可能的,因为逻辑层没有 Page 方法,视图层也需要 vue 框架驱动构建出的 vue 文件执行,所以在执行小程序的代码之前,还需要在逻辑层和视图层分别执行引擎代码进行初始化,逻辑层执行的引擎代码为 service.js,视图层执行的引擎代码为 view.js,他们分别需要实现什么功能,会在下面一一说明。

抽象

为了方便说明运行原理,我对这个架构进行抽象,用前端的技术实现。其中 js 线程就是 web woeker,webview 就是 iframe,原生应用就对应网页应用。

abstract

通信

首先就需要解决我们文章开头提到的原生应用和不同线程的通信问题,也就是搭建所谓的桥。

v8 引擎和 webview 组件都提供了对应的接口供开发者通信,此处不扩展。用抽象的模型的基础上,对通信的过程有一个等效的认识。

三个主要的通信问题

  • 逻辑层和 native 通信
  • 渲染层和 native 通信
  • 逻辑层和渲染层通信

逻辑层和 native 通信

原生应用向逻辑层(webworker)发送消息

const service = new Worker("service.js"); // 逻辑层
service.postMessage({
  type: "initializeFeature",
  data: INTERFACE,
});

原生应用接收 webworker 发送的消息

service.onmessage = function (e) {};

逻辑层向原生发送消息

postMessage({
  target: "view",
});

逻辑层接收原生发送的消息

onmessage = function (e) {};

视图层和 native 通信

原生向视图层(iframe)发送消息

const view = document.getElementById("View"); // 视图层 iframe
const viewContent = view.contentWindow;
viewContent.postMessage(eventData, "*");

原生层接收视图层发送的消息

window.addEventListener("message", function (event) {}, false);

视图层向原生发送消息

top.postMessage({ type: "webviewReady" }, "*");

视图层接收原生发送的消息

window.addEventListener("message", function (event) {}, false);

视图层和逻辑层通信

视图层和逻辑层的通信需要通过原生中转,所以解决方案就是在通信协议中规定一个 target 字段,当逻辑层发送消息时,指定 target 是视图层,则原生直接将该消息直接转发给视图层,若没有指定 target 则默认为发送给原生的消息,原生应用自己处理,视图层发送消息也是相同的。

service.onmessage = function (e) {
  const { data: eventData } = e;
  const { type, data, target } = eventData;
  if (target === "view") {
    viewContent.postMessage(eventData, "*"); // 转发给使视图层
  } else {
    // 原生应用自己处理
  }
};

逻辑层

原生应用主要就是负责管理逻辑层、渲染层、消息分发和处理,那么逻辑层的作用是什么呢。

首先看一下运行在逻辑层的文件:
page

可以看到主要需要实现三个功能

  • 接口的调用(获取用户信息)
  • 处理数据并给到渲染层
  • 处理事件

当然还有 Page 方法,不然这个文件一运行就会提示 Page 方法 undefined。我们一个个的处理

处理事件

这个比较简单,就是用我们刚才说的 onmessage 接口,监听原生发送的消息,消息有一定协议,主要就是规定一个 type 指定是什么事件,然后触发对应事件就好了。

处理数据

通过 Page 方法获取到页面数据,然后使用 proxy 劫持,在数据初始化和发生变化时发送消息到视图层即可。

接口调用

首先需要原生通知逻辑层有哪些接口,逻辑层将接口注册到全局。可以看到接口注册的过程其实就是定义一个函数,在用户调用接口时候发送消息给原生,并使用 uuid 将回调保存起来,原生执行完后,会将执行结果和 uuid 回传给逻辑层,逻辑层根据 uuid 执行回调即可

/**
 * 原生应用
 */
const INTERFACE = ["getUserProfile"]; // 原生接口列表
// 原生通知逻辑层注册原生提供的接口
service.postMessage({
  type: "initializeFeature",
  data: INTERFACE,
});

/**
 * 逻辑层 service.js
 */
// 将原生提供的接口注入全局属性wx.xxx
function initializeFeature(features) {
  features.forEach((feature) => {
    wx[feature] = function (data) {
      const { success } = data;
      const callbackId = Date.now();
      // 收集回调
      CALLBACK[callbackId] = success;
      postMessage({
        type: "executeFeature",
        data: {
          id: callbackId,
          feature,
        },
      });
    };
  });
}

整个过程是这样的

call

视图层

视图层需要做的东西很多,这里只把最精简的部分提出来

  • 视图层框架(不然 vue 怎么执行呢)
  • 响应事件
  • 全处初始化(全局属性、方法、指令注入等)

视图层是怎么加载 view.js 的呢,玄机就在模板 html 里,可以看到模板 html 在 head 处引用了 view.js ,view.js 其实是内置在引擎内部了

<!DOCTYPE html>
<html lang="en">
  <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>Document</title>
    <script src="/view.js"></script>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

总结

整体的执行流程图

flow

欢迎访问项目的原地址体验文中提到的所有代码:mini-app