EMP — 基于 Vue.js 的小程序引擎底层框架实现

背景

本文正式开篇讲实现原理之前,我想先简单说明一下小程序领域的现状,以及 EMP 框架研究方向的出发点和个人思考。目前基于统一的 DSL (Vue/React/自定义DSL) 转换到各家的小程序的框架一大堆,比如 mpvue、uni-app、Taro 等,这些框架解决的核心问题是实现一套开发代码编译到多端小程序。

如果我们想从零开始做一个类似微信小程序的引擎,或者将微信小程序现有资源转换成 H5 容器、App 容器能够运行的资源,那么和一套 DSL 编译到各个平台的思路是恰好相反的。我们需要搞清楚小程序在小程序引擎是怎么跑起来的,各家小程序引擎实现机制不太一样,但是基本上都是参考微信小程序引擎做的实现,保留了小程序的双线程模型。各小程序平台的基础库是不一样的,微信小程序开发框架是 mina 框架,百度小程序小程序开发框架是 swan 框架,视图层都有一套自己实现的 mvvm 框架,逻辑层就相对比较一致,都是 JavaScript 引擎,无非是基于 V8 或 JavaScriptCore。

目前小程序相关的框架可以分成以下大类:

小程序引擎可以参考的资料和内容比较少,目前可以借鉴的思路有两种:

  • 参考微信小程序的实现,反编译微信开发者工具和基础库,基于 Hera 进行二次开发,将 Hera 中微信小程序开发框架 mina 框架(组件系统 exparser、vdom 等) 换成 Vue.js 框架等成熟的前端框架,引入组件系统开发类微信规范的小程序组件;
  • 参考百度小程序的实现,目前百度小程序前端核心框架 swan-js 开源,也可以考虑基于 swan 进行改造,比如有人将其中的 mvvm san 框架换成了 Vue 框架,swanvue-core,然后去实现宿主容器。

EMP 出发点来源于构建自定义小程序引擎时,尝试基于 Vue.js 框架作为视图层底层框架,然后组件标准和逻辑层模仿微信小程序,目前已经初步实现了简单的一个小程序容器。如果以 Vue 作为初始 DSL 直接开发小程序也不是不可以,但是小程序生态现有的资源无非直接复用,能不能将小程序转换成 Vue.js 框架能运行的代码呢,这就是 EMP 框架最初的起点,定位是一种小程序引擎的实现,保留小程序双线程模型,基于 Vue.js 框架实现视图层渲染容器,使用 JavaScript 引擎实现小程序逻辑层调用。

小程序 & Native & Web

从 PC 时代开始,Native 与 Web 就一直是相互竞争、相互融合的关系,我们先来简单看看它们之间的优势与劣势。

Native Web
性能
用户体验 白屏、交互反馈差
功能 可以充分利用平台自身的能力 只有使用 W3C 的标准能力
迭代 周期长,需要发布 周期短,随时发布
维护成本
跨平台
线程 多线程 单线程

可以看出,Native 与 Web 之间各有优劣,在移动互联网发展的过程中,开发者们也一直在寻找融合双方优势的方案,经历了以下四个阶段的发展:

  • Hybrid 1.0:为 Web 页面提供 Naive API 的能力,也就是用 JS Bridge 去增强系统的 WebView 的功能。缺点是:体验差,如滚动、动画与交互等,稳定性差,如列表内存占用大等。
  • Hybrid 2.0:将 Native 组件覆盖在 WebView 之上,例如微信的 cover-view,提供更多的扩展能力。缺点是:用户体验融合性不好,如层级、事件、布局等。
  • Hybrid 3.0:前端 DSL 开发,Native 渲染,例如 React Native 与 Weex。缺点:对 W3C 标准能力支持有限,存在平台差异,三端并不完全统一。
  • Hybrid 4.0:百花齐放的小程序、快应用、轻应用方案,这个容器也有两种:WebView 容器与类 ReatNative 容器。技术方案上并没有太多的花样,这种方案的兴起由微信带起,本质上还是源于各大公司对于流量入口和生态的竞争。

小程序的特点:

  • 使用 WebView 开发,门槛低,可云端更新。
  • 通过提供基础能力、原生组件结合等方式,提升用户体验。
  • 通过平台发布、审核、下架、封禁等能力,具备对小程序的管控能力。
  • 双线程(逻辑层和渲染层分开),隔离 DOM、BOM 能力,提升体验的同时,可保证 WebView 安全性。

小程序性能优化:

  • 安装包缓存
  • 分包加载
  • 独立渲染线程
  • webview 预加载
  • Native 组件

可以看出来小程序是介于 Nativa 与 Web 两者间的应用形态,下面我们会从 Native 与 Web 两个不同的角度的看一下小程序的设计原理。

重新认识浏览器内核

在研究小程序引擎运行原理之前,我们需要对浏览器内核有一些基本认识,这样我们可以更好的理解小程序的设计思路。

Browser engine

浏览器内核,浏览器引擎(Browser engine),也称排版引擎(layout engine)、页面渲染引擎(rendering engine)。浏览器内核无非需要以下几个主要部分,如 HTML/CSS 解析器,网络处理,JavaScript 引擎,2D/3D 图形引擎,多媒体支持等。

内核 浏览器 发布年份 发布厂商 维护状态 JavaScript 引擎
Trident IE4 - IE11、360 浏览器 1997 Microsoft 停止 JScript/Chakra
Gecko Firefox 2004 Netscape 维护 SpiderMonkey
WebKit Safari、Chromium、Chrome(-2013)、Android 浏览器、Chrome OS、Web OS 等 2005 Apple 维护 JavascriptCore
Blink Chrome(2013-)、 Opera 2013 Google 维护 V8
Edge Edge 2015 Microsoft 停止 Chakra

JavaScript Engine

目前在 Android/iOS 上运行 JavaScript,主要有两种方法:一种方法是利用系统的浏览器组件 WebView(Android)和 UIWebView/WKWebView(iOS);另一种方法是编译和集成一个功能全面的 JavaScript 引擎。JavaScript 引擎是一个专门处理 JavaScript 脚本的虚拟机,一般会附带在网页浏览器之中。

JavaScript Engine Android iOS 维护厂商/个人
V8 JIT JIT only for jailbroken devices Google
JavaScriptCore Interpreter and JIT Interpreter only Apple
SpiderMonkey Interpreter and JIT Interpreter only Mozilla
Rhino Interpreter Unsupported Mozilla
quickjs - - Fabrice Bellard

How WebKit works

WebKit 工作原理可以看 How WebKit works 详细介绍。

WebKit 就是一个页面渲染以及逻辑处理引擎,前端工程师把 HTML、JavaScript、CSS 这“三驾马车”作为输入,经过 WebKit 的处理,就输出成了我们能看到以及操作的 Web 页面。从上图我们可以看出来,WebKit 由图中框住的四个部分组成,而其中最主要的就是 WebCore(KDE 开发的排版引擎) 和 JSCore(或者是其它 JS 引擎)。除此之外,WebKit Embedding API 是负责浏览器 UI 与 WebKit 进行交互的部分,而 WebKit Ports 则是让 Webkit 更加方便的移植到各个操作系统、平台上,提供的一些调用 Native Library 的接口,比如在渲染层面,在 iOS 系统中,Safari 是交给 CoreGraphics 处理,而在 Android 系统中,Webkit 则是交给 Skia。

排版引擎 WebCore 的工作流程:

JavaScript 引擎 JSCore 的工作流程:

Blink 工作原理可以看 How Blink works 详细介绍。

Blink 渲染原理: bit.ly/lifeofapixel

主流小程序引擎架构

目前主流的小程序平台架构差异不小,但是核心实现设计思路相同,我们可以从这些通用的设计中去思考背后的思想。

微信小程序架构

微信小程序技术原理可以看之前写的一篇文章《微信小程序技术原理分析》

微信小程序技术栈系统架构:

Flutter 渲染优化 - 整体系统架构:

百度小程序架构

支付宝小程序架构

快应用架构

EMP 小程序引擎设计

整体架构

JS 层 Framework 架构:

Native 层 Android 平台小程序容器架构:

Emp Android SDK 被设计为运行在独立的进程中,独享进程内存,提供了可供小程序运行的丰富的 API 和组件,同时也提供了扩展 API 的能力。SDK 的核心功能主要有两部分: 逻辑流程控制API实现

  • 逻辑流程控制:SDK Native 层 [EmpActivity] 作为逻辑流程控制中心,建立了页面视图层 [Page] 与应用逻辑层 [AppService] 之间的联系,处理两层之间的事件传递及数据流转,同时也处理 API 的调用并返回结果。

  • API 实现:SDK 本身提供了丰富的 API 实现,同时也提供了扩展 API 的接口,方便被接入的 App 实现自定义的 API 功能。由于 SDK 运行于独立进程,因此通过进程通信的方式调用宿主提供的扩展 API。

包结构

小程序开发工程以微信小程序工程作为输入,经过编译器编译后生成如下结构的包。其中 framework 包独立于小程序包,通常内置于小程序引擎内。

├── framework            # 框架内置包
|   ├── service.js       # 框架 AppService 层
|   ├── view.js          # 框架 PageView 层
|   └── framework.css    # 框架通用样式
├── pages                # 视图层入口
|   ├── index/index.html
|   ├── ...
├── assets               # 静态资源
├── scripts
|   ├── app-config.js    # 全局配置
|   └── app-service.js   # 应用逻辑层
└── service.html         # 逻辑层入口

启动流程

小程序引擎 Android 平台小程序启动流程图:

编译器

大多数编译器流程可以分为三个阶段:

  • 解析(parse):将代码字符串解析成抽象语法树;
  • 转换(transform):对抽象语法树进行转换操作;
  • 生成(generate): 根据变换后的抽象语法树再生成代码字符串。

根据 HTML、JS、CSS 不同的语法规则,采用不同的中间编译工具,Unified、Babel、PostCSS 都支持插件系统。中间转换插件主要解决语法转换,wxml2vue 核心逻辑主要是提供了将小程序文件编译成能够用于 Vue 框架运行的代码。

属性转换

将微信小程序标签上的属性绑定语法变为 Vue 绑定语法,如:

微信小程序 Vue
id="item" id="item"
id="" :id="item"

Class 转换

WXML 源代码:

<view class="static-class {{ dynamicClassFlag ? 'dynamic-class': ''}}">Class</view>

AST 转换代码:

/**
 * 判断是否{{}}数据绑定
 */
const hasDataBindBrackets = (value) => {
  return (/{{(.+?)}}/).test(value.trim());
};

/**
 * 获取{{}}数据绑定模板表达式
 * @param {*} value
 */
const removeDataBindBrackets = (value) => {
  const match = !isEmptyString(value) && /{{(.+?)}}/.exec(value);
  return (match == null) ? value : match[1].trim();
};

function transformClassAtrr(node) {
  const { attribs } = node;
  const value = attribs[attr];
  if (hasDataBindBrackets(value)) {
    attribs['class'] = value.split(/{{.+?}}/).join(' ').trim();
    attribs['v-bind:class'] = removeDataBindBrackets(value);
  }
  return attribs;
}

经过 AST 转换后生成代码为:

<ui-view class="static-class" v-bind:class="dynamicClassFlag ? 'dynamic-class': ''">Class</ui-view>

指令转换

将微信小程序的指令转换为 Vue 指令:

微信小程序 Vue
wx:for="" wx:for-item="xxx"wx:for-index="yyy" v-for="(xxx, yyy) in array"
wx:if v-if
wx:elif v-else-if
wx:else v-else

组件转换

将微信小程序组件转换为 Vue 组件。

事件转换

引入手势库hammerjs,默认支持 Tap、Press、Swipe、Pan、Pinch、Rotate 等基本手势。

WXML 源代码:

<button type="primary" data-type="tap" bindtap="showToast">tap</button>
<button type="primary" data-type="longpress" bindlongpress="showToast">longpress</button>

AST 转换代码:

function transformOnDirective(attr, value) {
  const eventType = attr.substring(4);
  const eventName = value;
  let newAttr = attr;
  switch (eventType) {
    case 'touchstart':
    case 'touchmove':
    case 'touchcancel':
    case 'touchend':
    case 'transitionend':
    case 'animationstart':
    case 'animationiteration':
    case 'animationend':
    case 'touchforcechange':
      newAttr = `v-on:${eventType}`;
      break;
    case 'tap':
      newAttr = 'v-on:tap.native';
      break;
    case 'longtap':
    case 'longpress':
      newAttr = 'v-on:press.native';
      break;
    default:
      console.warn('不支持的事件: ', eventType);
      break;
  }
  return {
    attr: newAttr,
    value: `__eventHandleProxy__('${eventName}', $event)`
  };
}

经过 AST 转换后生成代码为:

<ui-button type="primary" data-type="tap" v-on:tap.native="__eventHandleProxy__('showToast', $event)">tap</ui-button>
<ui-button type="primary" data-type="longpress" v-on:press.native="__eventHandleProxy__('showToast', $event)">longpress</ui-button>

小程序框架试视图层:

this.vm = new Vue({
  data: options.data,
  render: options.render,
  methods: {
    __eventHandleProxy__(eventName: string, event: Event) {
      // 转发 Page 事件到 AppService
      global.emp.publishPageEvent(eventName, eventTransform.intercept(event));
    }
  }
});

通信机制

JavaScript 层设计

JSBridge 中有 invoke、invokeCallbackHandler、on、publish、subscribe、subscribeHandler 五个方法,下面会逐个介绍其作用与实现。

class JSBridge {
  private callbackIndex: number = 0;
  private eventPrefix: string = 'custom_event_';
  private callbacks: Map<number, Function> = new Map();
  private defaultEventHandlers: Map<string, Function> = new Map();
  private handlers: Map<string, Function> = new Map();

  /**
   * 逻辑层 JS 层异步调用 Native 层
   * @param {*} event
   * @param {*} params
   * @param {*} callback
   */
  invoke(eventName: string, params: any, callback: Function) {
    const paramsString = JSON.stringify(params);
    const callbackId = ++this.callbackIndex;
    this.callbacks.set(callbackId, callback);
    if (os.isAndroid) {
      let JSCoreHandleResult = global.EmpJSCore.invokeHandler(eventName, paramsString, callbackId);
      let callback = this.callbacks.get(callbackId) as Function;
      if (
        !isUndefined(JSCoreHandleResult) && isFunction(callback) && JSCoreHandleResult !== ''
      ) {
        try {
          JSCoreHandleResult = JSON.parse(JSCoreHandleResult);
        } catch (e) {
          JSCoreHandleResult = {};
        }
        callback(JSCoreHandleResult);
        this.callbacks.delete(callbackId);
      }
    } else if (os.isIOS) {
      global.webkit.messageHandlers.invokeHandler.postMessage({
        C: eventName,
        paramsString,
        callbackId
      });
    }
  }

   /**
   * Native 层将 invoke 回调结果传递给 JS 层
   * @param {*} callbackId
   * @param {*} params
   */
  invokeCallbackHandler(callbackId: number, params: any) {
    const callback = this.callbacks.get(callbackId) as Function;
    if (isFunction(callback)) {
      callback(params);
      this.callbacks.delete(callbackId);
    }
  }

  /**
   * 监听默认事件
   * @param {*} eventName
   * @param {*} handler
   */
  on(eventName: string, handler: Function) {
    this.defaultEventHandlers.set(eventName, handler);
  }

  /**
   * 发布消息,由 Service 层或 View 层的 JSBridge 调用
   * @param {*} eventName
   * @param {*} params
   * @param {*} webviewIds
   */
  publish(eventName: string, params: any, webviewIds: string) {
    const event = this.eventPrefix + eventName;
    const paramsString = JSON.stringify(params);
    webviewIds = JSON.stringify(webviewIds);
    if (os.isAndroid) {
      global.EmpJSCore.publishHandler(event, paramsString, webviewIds);
    } else if (os.isIOS) {
      global.webkit.messageHandlers.publishHandler.postMessage({
        event,
        paramsString,
        webviewIds
      });
    }
  }

  /**
   * 订阅消息
   * @param {*} eventName
   * @param {*} handler
   */
  subscribe(eventName: string, handler: Function) {
    this.handlers.set(this.eventPrefix + eventName, handler);
  }

  /**
   * 消息订阅处理器
   * @param {*} eventName
   * @param {*} data
   * @param {*} webviewId
   * @param {*} reportParams
   */
  subscribeHandler(eventName: string, data: any, webviewId: number) {
    const handler = (eventName.includes(this.eventPrefix)
      ? this.handlers.get(eventName)
      : this.defaultEventHandlers.get(eventName)) as Function;
    if (isFunction(handler)) {
      handler(data, webviewId);
    }
  }
}
global.EmpJSBridge = new JSBridge();

JS 层主动调用 Native 层:JS 层逻辑层使用 EmpJSBridge.invoke 异步调用 Native 层,Native 层将 invoke 方法的回调结果通过 Native 层调用 JS 层的 JSBridge.invokeCallbackHandler 方法回调给 JS 层。

JS 层监听 Native 层的消息:EmpJSBridge.on 方法主要用于监听来自 Native 层主动发送的消息,例如 onPullDownRefresh、onShareAppMessage 等。

JS 层逻辑层与视图层的消息通信:Service 层或 View 层的 JSBridge 调用 EmpJSBridge.publish 发布消息,publish 发布的消息首先会发给 Native 层,Native 层收到消息后经过处理会调用 JS 层的 EmpJSBridge.subscribeHandler 方法,通过 EmpJSBridge.subscribe 实现消息订阅。

Android 层设计

JSBridge 中的 invoke 方法和 publish 方法在不同平台调用不同的方法:Android 平台调用 EmpJSCore.invokeHandler / EmpJSCore.publishHandler 方法,iOS 平台调用 webkit.messageHandlers.invokeHandler.postMessage。这里的 JSCore 对象通过 WebView addJavascriptInterface 方法对外暴露接口。

addJavascriptInterface(new JSInterface(handler), "EmpJSCore");

mpJSCore.invokeHandler 为例说明,JSInterface 接口实现如下,EmpJSCore.invokeHandler 将 JS 层传递过来的 eventName、paramsString、callbackId 三个参数传入到 mBridgeHandler.invoke 方法中。

public class JSInterface {
    private static final String TAG = "JSInterface";

    private IBridge mBridgeHandler;
    private Handler mHandler = new Handler(Looper.getMainLooper());

    public JSInterface(IBridge handler) {
        mBridgeHandler = handler;
    }

    @JavascriptInterface
    public void publishHandler(final String event, final String params, final String viewIds) {
        EmpTrace.d(TAG, String.format("publishHandler is called! event=%s, params=%s, viewIds=%s", event, params, viewIds));
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                if (mBridgeHandler != null) {
                    mBridgeHandler.publish(event, params, viewIds);
                }
            }
        });
    }

    @JavascriptInterface
    public void invokeHandler(final String event, final String params, final String callbackId) {
        EmpTrace.d(TAG, String.format("invokeHandler is called! event=%s, params=%s, callbackId=%s",
                event, params, callbackId));
        mHandler.post(new Runnable() {
            @Override
            public void run() {
                if (mBridgeHandler != null) {
                    mBridgeHandler.invoke(event, params, callbackId);
                }
            }
        });
    }
}

这里的 mBridgeHandler 是一个 IBridge 接口实现类实例,具体实现在 AppService 和 Page 中,mBridgeHandler.invoke 方法具体实现为:

@Override
public void invoke(String event, String params, String callbackId) {
  EmpTrace.d(TAG, String.format("api invoke, event=%s, params=%s, callbackId=%s",
                                event, params, callbackId));
  Event e = new Event(event, params, callbackId);
  mApisManager.invoke(e, this);
}

Native 层调用 API Manager 的invoke 方法实现功能调用,处理完成后 Native 层调用 AppService 层的 callback 方法实现消息回调:

@Override
public void callback(String callbackId, String result) {
  mServiceWebView.loadUrl(String.format("javascript:EmpJSBridge.invokeCallbackHandler(%s,%s)", callbackId, result));
}

视图层与逻辑层通信

视图层 DOM Ready、点击等操作事件通过 bridge.publish 发布事件消息:

/**
 * 发布页面事件
 * @param {string} eventName 事件名称
 * @param {any} data 事件数据
 */
function publishPageEvent(eventName: string, data: any) {
  bridge.publish(EventType.PAGE_EVENT, {
    eventName,
    data
  });
}

逻辑层监听事件:

/**
 * 订阅页面事件
 * @param {*} fn
 */
export function onWebviewEvent(callback: Function) {
  bridge.subscribe(EventType.PAGE_EVENT, (data: EMP.PageEventData, webviewId: number) => {
    const event = Object.assign(data, { webviewId })
    callback(event);
    global.__empconsole__(
      'AppService',
      `Subscribe PageEvent: data: ${JSON.stringify(data)}, webviewId: ${webviewId}`
    );
  });
}

参考

写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!