小程序原理解析,你理解了吗

最近在接触小程序的项目,并且使用的是taro作为小程序的框架作为基建,于是趁着无聊,就来学习一下小程序原理的解析。

在理解小程序框架之前,我们先需要知道以下几点

  • 小程序是什么
  • 小程序和网页的区别
  • 小程序的代码构成
  • 小程序如何部署上线
  • ……

小程序是什么

小程序是一种全新的连接用户与服务的方式,可以在微信内被便捷的获取和传播,同时具有出色的使用体验。 ——— 摘自小程序官网的介绍

小程序在大家生活中其实已经随处可见了,比如奶茶店点的点单小程序、粤省事小程序等。小程序的使用十分便捷,直接在微信便可启动,无需在手机上下载app。

小程序和网页的区别

首先说说网页:

  • 网页是通过输入一个网址,打开的一个页面;需要借助浏览器才能打开使用;
  • 网页开发者可以使用浏览器暴漏的各种DOM api来对dom节点进行操作。
  • 网页的开发中渲染线程和js线程是互斥的,也就是说我们在网页的渲染过程中js能够阻塞页面的响应。

再说说小程序:

  • 小程序的开发和网页很相似,但是小程序的开发使用需要借助微信开发者工具;
  • 小程序没有完整的浏览器,所以缺少相关DOM apiBOM api
  • 小程序的运行环境分为逻辑层和渲染层,其中页面的使用webview展示在渲染层(wxml和wxss工作在此层),js脚本工作在逻辑层,采用jsCore运行js脚本。
  • 小程序不需要面对各式各样的浏览器,在它的世界里,只需要面对两大操作系统:ios、Android的微信客户端。

小程序的代码构成

  • wxml:和网页的HTML类似,用来使用微信提供的标签编写出页面的结构;

  • wxss: 具有css的大部分的特性,并且新增了尺寸单位rpx,开发者可以无需换算不同像素比设备宽高,小程序底层已经替你做了这件事。

  • js:用来进行逻辑交互。

  • json: json文件在小程序中提供配置,

    • 项目的跟目录中有一个app.json用来配置小程序的全局配置。
    • project.config.json工具配置,比如配置上传是否压缩、小程序的appid、是否校验合法域名等
    • 每个页面下还有一个属于页面的配置文件page.json。可以配置该页面的顶部颜色风格或者独立属性,可以单独配置插件等。

小程序如何集成上线

网页的上线流程需要将打包后的产物通过nginx上传到服务器,并且配置域名进行项目的部署。

小程序发布流程:开发 ——— 构建 ——— 上传 ——— 提交审核 ——— 发布

  • 开发者通过结合开发者工具进行功能开发
  • 通过本地build后的产物文件,依赖微信开发者工具上传到微信公众平台的小程序后台
  • 将上传的版本进行提交审核
  • 微信官方审核通过后,小程序管理人员将小程序进行发布。

以上流程是一个普通小程序经历的上线流程。上面所说的是借助手动的去进行构建、上传、提审、发布。后续会讲到自动化的过程,大大提效~

了解了上面的一些基础问题,那么接下来可以开始进入小程序的一个原理解析了~

在这里我想带着几个问题来看小程序的原理

  1. 为什么小程序的逻辑层单独存在于一个线程中
  2. 为什么小程序无法通过js操作DOM
  3. 小程序的wxml是如何被识别成页面结构的
  4. 微信小程序的rpx如何进行的变换以及实现
  5. 小程序怎么识别wxss

双线程架构

  • 渲染层:小程序有多个页面,所以webview可以有多个
  • 逻辑层:用jscore进行处理js脚本。

两个线程之间无论是通信、数据传递还是网络请求都是通过native统一进行转发

webView渲染层

从微信官网中可以下载一个demo,或者尝试自己写一个demo(此文中用的官网例子,咱们关注看内部如何处理)

可以看到在page/index页面下的wxml元素的展示,确实是我们写的View等标签,并且关注到咱们页面的顶部是用一个page标签包裹着。

这个时候,看看在page/index页面的wxml标签是被转换成什么呢?

  1. 在微信开发者工具打开开发者工具调试模式,会新打开一个新的开发调试窗口,这个时候可以看到wxml标签在编译后展示成什么

  2. 打开之后,我们选中我们的页面,在devTools中可以看到,该页面是用一个webview标签包裹着,那内部其实是一个iframe内嵌。在这里我们关注到这个页面的行联样式中设置了position: absolute,在这里带着疑问🤔️为啥会设置为绝对定位呢,我们接着往下走

  3. 点击头像,跳转到page/log/log页面,这个时候观察发现,多了一个新的webview标签,并且这个时候,第一个webview的包裹div元素上的样式增加了z-index:1,新的webview的包裹div的样式上z-index:2,其实这个就是小程序的页面栈,通过z-index增加层级来对前面的页面进行覆盖,这也是为什么小程序的页面最多打开10个页面,因为打开的页面越多,小程序的页面层级以及逻辑复杂度提升,影响小程序的性能和用户体验。

  4. 那么一个page元素包裹着的就标志为这是一个页面webview,每个page都是不同的webview去进行渲染的,可以提供很好的用户交互体验。也避免了单个的webview的任务复杂度。

  5. 那么 iframe内部又是什么呢?在devtools中输入如下代码即可打开对应的webview下的渲染层

    document.querySelectorAll('webView')[0].showDevTools(true,null) // 代表第一个页面的渲染层
    

    可以看到View标签被解析成wx-view以及image标签解析成wx-image。这些标签是小程序自定义组件类型。通过小程序内置基础库进行处理。

    并且可以看到小程序处理标签会在最终解析的元素上有一个exparser字段。

    Exparser是微信小程序的组件组织框架,内置在小程序基础库中,为小程序的各种组件提供基础的支持。小程序内的所有组件,包括内置组件和自定义组件,都由Exparser组织管理。

    exparser组件模型其实也是参考了web component中的shadow DOM并且进行了一些修改。事件系统、插槽、属性传递等都基本一致。

逻辑层APP Service

在控制台输入document可以看到js逻辑层的代码

所有的js逻辑都会放到一个逻辑线程里面。

由上面的内容所说,逻辑层和渲染层的通信是通过native作为中间件进行转发。想要看到其中如何通信的,那么就需要进入到微信的基础库来进行查看。

微信基础库

通过在微信开发者工具console窗口中直接输入openVendor()会打开本地小程序的weappVendor目录,可以从目录中看到一下几个类型的文件:

  • wcc负责将wxml编译成js文件;将动态数据识别后,再转换为html
  • wcsc负责将wxss编译成js文件,wcsc 将 css 编译成对应的 js,因为要将 rpx 识别,然后再转换为 css
  • .wxvpkg结尾的文件,表示不同微信版本的基础库;

wcc

wcc是负责把wxml编译成js文件。可以在本地将weappVendor目录下的wcc和wcsc复制到项目文件下。

  • 创建两个基础文件,wxml和wxss,然后执行以下命令可以证实两个文件的作用。

    ./wcc -b index.wxml >> wxml.js
    

    并且可以在开发者工具控制台中,查看到逻辑层的处理逻辑里面,有个generataFunc函数,调用了$gwx函数,这个函数的作用是将组件标签转换成js,生成虚拟dom.

Wcsc

用来处理wxss文件,将css编译成js文件,并且转换rpx尺寸。

利用如下命令可以将wxss文件转换成js文件

./wcsc -js ./pages/index/index.wxss >> wxss_output.js
  • rpx单位的换算,转换成px;

    rpx(responsive pixel) : 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。

    设备 rpx换算px (屏幕宽度/750) px换算rpx (750/屏幕宽度)
    iPhone5 1rpx = 0.42px 1px = 2.34rpx
    iPhone6 1rpx = 0.5px 1px = 2rpx
    iPhone6 Plus 1rpx = 0.552px 1px = 1.81rpx
  • 通过setCssToHead方法,将转换好的css添加到head中。

.wxvpkg

代表着不同版本的基础库。可以通过工具解压后可以得到小程序基础库的具体实现逻辑的文件。

解压之后可以看到基础的有两个主要的文件:

  • WAWebview:小程序视图层基础库,提供视图层基础能力
  • WAService:小程序逻辑层基础库,提供逻辑层基础能力
WAWebview源码
// WAWebview.js
var __wxLibrary = {
  fileName: 'WAWebview.js',
  envType: 'WebView',
  contextType: 'others',
  execStart: Date.now()
};
​
var __WAWebviewStartTime__ = Date.now();
​
var __libVersionInfo__ = {
  "updateTime": "2020.11.25 23:32:34",
  "version": "2.13.2",
  "features": {
    "pruneWxConfigByPage": true,
    "injectGameContextPlugin": true,
    "lazyCodeLoading2": true,
    "injectAppSeparatedPlugin": true,
    "nativeTrans": true
  }
};
​
/**
 * core-js 模块
 */
!function(n, o, Ye) {
  ...
  }, function(e, t, i) {
    var n = i(3),
      o = "__core-js_shared__",
      r = n[o] || (n[o] = {});
    e.exports = function(e) {
      return r[e] || (r[e] = {})
    }
  ...
}(1, 1);
​
var __wxTest__ = !1,
var __wxConfig;
​
var wxRunOnDebug = function(e) {
  e()
};
​
/**
 * 基础模块
 */
var Foundation = function(i) {
  ...
}]).default;
​
var nativeTrans = function(e) {
  ...
}(this);
​
/**
 * 消息通信模块
 */
var WeixinJSBridge = function(e) {
  ...
}(this);
​
/**
 * 监听 nativeTrans 相关事件
 */
function() {
  ...
}();
​
/**
 * 解析配置
 */
function(r) {
  ...
  __wxConfig = _(__wxConfig), __wxConfig = v(__wxConfig), Foundation.onConfigReady(function() {
    m()
  }), n ? __wxConfig.__readyHandler = A : d ? Foundation.onBridgeReady(function() {
    WeixinJSBridge.on("onWxConfigReady", A)
  }) : Foundation.onLibraryReady(A)
}(this);
​
/**
 * 异常捕获(error、onunhandledrejection)
 */
function(e) {
  function t(e) {
    Foundation.emit("unhandledRejection", e) || console.error("Uncaught (in promise)", e.reason)
  }
  "object" == typeof e && "function" == typeof e.addEventListener ? (e.addEventListener("unhandledrejection", function(e) {
    t({
      reason: e.reason,
      promise: e.promise
    }), e.preventDefault()
  }), e.addEventListener("error", function(e) {
    var t;
    t = e.error, Foundation.emit("error", t) || console.error("Uncaught", t), e.preventDefault()
  })) : void 0 === e.onunhandledrejection && Object.defineProperty(e, "onunhandledrejection", {
    value: function(e) {
      t({
        reason: (e = e || {}).reason,
        promise: e.promise
      })
    }
  })
}(this);
​
/**
 * 原生缓冲区
 */
var NativeBuffer = function(e) {
  ...
}(this);
var WeixinNativeBuffer = NativeBuffer;
var NativeBuffer = null;
​
/**
 * 日志模块:wxConsole、wxPerfConsole、wxNativeConsole、__webviewConsole__
 */
var wxConsole = ["log", "info", "warn", "error", "debug", "time", "timeEnd", "group", "groupEnd"].reduce(function(e, t) {
  return e[t] = function() {}, e
}, {});
​
var wxPerfConsole = ["log", "info", "warn", "error", "time", "timeEnd", "trace", "profile", "profileSync"].reduce(function(e, t) {
  return e[t] = function() {}, e
}, {});
​
var wxNativeConsole = function(i) {
  ...
}([function(e, t, i) {
  ...
}]).default;
​
var __webviewConsole__ = function(i) {
  ...
}([function(e, t, i) {
  ...
}]);
​
/**
 * 上报模块
 */
var Reporter = function(i) {
  ...
}([function(e, L, O) {
  ...
}]).default;
​
var Perf = function(i) {
  ...
}([function(e, t, i) {
  ...
}]).default;
​
/**
 * 视图层 API
 */
var __webViewSDK__ = function(i) {
  ...
}([function(e, L, O) {
  ...
}]).default;
var wx = __webViewSDK__.wx;
​
/**
 * 组件系统
 */
var exparser = function(i) {
  ...
}([function(e, t, i) {
  ...
}]);
​
/**
 * 框架粘合层
 * 
 * 使用 exparser.registerBehavior 和 exparser.registerElement 方法注册内置组件
 * 转发 window、wx 对象上到事件转发到 exparser
 */
!function(i) {
  ...
}([function(e, t) {
  ...
}, function(e, t) {}, , function(e, t) {}]);
​
/**
 * Virtual DOM 
 */
var __virtualDOMDataThread__ = !1,
var __virtualDOM__ = function(i) {
  ...
}([function(e, t, i) {
  ...
}]);
​
/**
 * __webviewEngine__
 */
var __webviewEngine__ = function(i) {
  ...
}([function(e, t, i) {
  ...
}]);
​
/**
 * 注入默认样式到页面
 */
!function() {
  ...
  function e() {
     var e = i('...');
    __wxConfig.isReady ? void 0 !== __wxConfig.theme && i(t, e.nextElementSibling) : __wxConfig.onReady(function() {
      void 0 !== __wxConfig.theme && i(t, e.nextElementSibling)
    })
  }
  window.document && "complete" === window.document.readyState ? e() : window.onload = e
}();
​
var __WAWebviewEndTime__ = Date.now();
typeof __wxLibrary.onEnd === 'function' && __wxLibrary.onEnd();
__wxLibrary = undefined;

由上面源码可以看出,WAwebview主要实现了以下几个部分

  • Foundation: 基础模块
  • WeixinJSBridge: 消息通信模块
  • exparser: 组件系统模块
  • __virtualDOM__: Virtual DOM 模块
  • __webViewSDK__: WebView SDK 模块
  • Reporter: 日志上报模块(异常和性能统计数据)
WAService 源码
var __wxLibrary = {
  fileName: 'WAService.js',
  envType: 'Service',
  contextType: 'App:Uncertain',
  execStart: Date.now()
};
var __WAServiceStartTime__ = Date.now();
​
(function(global) {
  var __exportGlobal__ = {};
  var __libVersionInfo__ = {
    "updateTime": "2020.11.25 23:32:34",
    "version": "2.13.2",
    "features": {
      "pruneWxConfigByPage": true,
      "injectGameContextPlugin": true,
      "lazyCodeLoading2": true,
      "injectAppSeparatedPlugin": true,
      "nativeTrans": true
    }
  };
  var __Function__ = global.Function;
  var Function = __Function__;
​
  /**
   * core-js 模块
   */
  !function(r, o, Ke) {
  }(1, 1);
​
  var __wxTest__ = !1;
  wxRunOnDebug = function(A) {
    A()
  };
​
  var __wxConfig;
  /**
   * 基础模块
   */
  var Foundation = function(n) {
    ...
  }([function(e, t, n) {
    ...
  }]).default;
​
  var nativeTrans = function(e) {
    ...
  }(this);
​
  /**
   * 消息通信模块
   */
  var WeixinJSBridge = function(e) {
    ...
  }(this);
​
  /**
   * 监听 nativeTrans 相关事件
   */
  function() {
    ...
  }();
​
  /**
   * 解析配置
   */
  function(i) {
    ...
  }(this);
​
  /**
   * 异常捕获(error、onunhandledrejection)
   */
  !function(A) {
    ...
  }(this);
​
  /**
   * 原生缓冲区
   */
  var NativeBuffer = function(e) {
    ...
  }(this);
  WeixinNativeBuffer = NativeBuffer;
  NativeBuffer = null;
​
  var wxConsole = ["log", "info", "warn", "error", "debug", "time", "timeEnd", "group", "groupEnd"].reduce(function(e, t) {
      return e[t] = function() {}, e
    }, {});
​
  var wxPerfConsole = ["log", "info", "warn", "error", "time", "timeEnd", "trace", "profile", "profileSync"].reduce(function(e, t) {
    return e[t] = function() {}, e
  }, {});
​
  var wxNativeConsole = function(n) {
    ...
  }([function(e, t, n) {
    ...
  }]).default;
​
  /**
   * Worker 模块
   */
  var WeixinWorker = function(A) {
    ...
  }(this);
​
  /**
   * JSContext
   */
  var JSContext = function(n) {
    ...
  }([
    ...
  }]).default;
​
  var __appServiceConsole__ = function(n) {
    ...
  }([function(e, N, R) {
    ...
  }]).default;
​
  var Protect = function(n) {
    ...
  }([function(e, t, n) {
    ...
  }]);
​
  var Reporter = function(n) {
    ...
  }([function(e, N, R) {
    ...
  }]).default;
​
  var __subContextEngine__ = function(n) {
    ...
  }([function(e, t, n) {
    ...
  }]);
​
  var __waServiceInit__ = function() {
    ...
  }
​
  function __doWAServiceInit__() {
    var e;
    "undefined" != typeof wx && wx.version && (e = wx.version), __waServiceInit__(), e && "undefined" != typeof __exportGlobal__ && __exportGlobal__.wx && (__exportGlobal__.wx.version = e)
  }
  __subContextEngine__.isIsolateContext();
  __subContextEngine__.isIsolateContext() || __doWAServiceInit__();
  __subContextEngine__.initAppRelatedContexts(__exportGlobal__);
})(this);
​
var __WAServiceEndTime__ = Date.now();
typeof __wxLibrary.onEnd === 'function' && __wxLibrary.onEnd();
__wxLibrary = undefined;

以上源码结构也可以看到分为以下模块:

  • Foundation: 基础模块
  • WeixinJSBridge: 消息通信模块
  • WeixinNativeBuffer: 原生 Buffer
  • WeixinWorker: Worker 线程
  • JSContext: JS Engine Context
  • Protect: JS 保护的对象
  • __subContextEngine__: 提供 AppPageComponentBehaviorgetAppgetCurrentPages 等方法
Foundation模块

渲染层和逻辑层都拥有这个Foundation模块,这个模块被称为基础模块。那我们来看看这个模块具体做了什么:

在控制台输入Foundation代码回车,即可看到该模块输出的一些环境变量。

  • 环境变量env
  • 挂在window的全局变量
  • 发布订阅的EventEmitter
  • 配置/基础库/通信桥Ready事件等

Exparser模块

小程序的渲染层文章前面已经说过是由webview包裹渲染的。小程序里面无法使用web组件和动态执行js。exparser是微信小程序的组件组织框架,内置在小程序基础框架中,为小程序的各种组件提供基础的支持。Exparser 会维护整个页面的节点树相关信息,包括节点的属性、事件绑定等,相当于一个简化版的 Shadow DOM 实现。

Exparser 的主要特点包括以下几点:

  • 基于 Shadow DOM 模型:模型上与 WebComponents 的 ShadowDOM 高度相似,但不依赖浏览器的原生支持,也没有其他依赖库;实现时,还针对性地增加了其他 API 以支持小程序组件编程。
  • 可在纯 JS 环境中运行:这意味着逻辑层也具有一定的组件树组织能力。
  • 高效轻量:性能表现好,在组件实例极多的环境下表现尤其优异,同时代码尺寸也较小。

小程序中,所有节点树相关的操作都依赖于 Exparser,包括 WXML 到页面最终节点树的构建、createSelectorQuery 调用和自定义组件特性等。

Virtual DOM 模块

生成虚拟dom的模块,但是这里其他文章中提到生成的不是原生DOM,而是模拟了各种DOM接口的wx-element对象。

之前我们说过,微信小程序内部执行文件是通过wccwcsc分别编译wxml和wxss文件为对应的js文件。那么通过demo看出来,一个wxml文件中的组件节点被编译成如下内容:

wx-pagewx-textwx-view代表的是小程序自定义的组件。在这里渲染层的处理中调用$gwx将wxml处理完虚拟dom之后,怎么讲数据data进行绑定呢。在其中可以看到,在编译的js文件中有这样一段代码:

if (path && e_[path]) {
    window.__wxml_comp_version__ = 0.02
    return function (env, dd, global) {
      $gwxc = 0;
      var root = {
        "tag": "wx-page"
      };
      root.children = []
      var main = e_[path].f
      if (typeof global === "undefined") global = {};
      global.f = $gdc(f_[path], "", 1);
      if (typeof (window.__webview_engine_version__) != 'undefined' && window.__webview_engine_version__ + 1e-6 >= 0.02 + 1e-6 && window.__mergeData__) {
        env = window.__mergeData__(env, dd); // 合并data数据
      }
      try {
        main(env, {}, root, global);
        _tsd(root)
        if (typeof (window.__webview_engine_version__) == 'undefined' || window.__webview_engine_version__ + 1e-6 < 0.01 + 1e-6) {
          return _ev(root);
        }
      } catch (err) {
        console.log(err)
      }
      return root;
    }
  }

那么在经历wxml转换成js文件过程以下:

  • wcc编译wxml得到一个js文件
  • js文件接受一个wxml文件,得到一个虚拟的dom
  • 然后通过exparser将虚拟的dom解析成真实的dom。并且合并绑定的data数据。渲染到页面上。

wcsc将wxss编译成js文件过程:

  • wcsc将wxss编译成js文件
  • 进行rpx的转换
  • 创建style标签,将css插入到head
  • 生成webview在渲染层。
WeixinJSBridge模块

属于通信模块了,是渲染层和逻辑层之间的通信桥梁。可以看到提供了以下的方法来进行消息通信机制。

方法名 作用
invoke JS 调用 Native API
invokeCallbackHandler Native 传递 invoke 方法回调结果
on JS 监听 Native 消息
publish 视图层发布消息
subscribe 订阅逻辑层的消息
subscribeHandler 视图层和逻辑层消息订阅转发
setCustomPublishHandler 自定义消息转发

回到最初提出的问题:

  1. 为什么小程序的逻辑层单独存在于一个线程中 小程序双线程模式,逻辑层和视图层通过WeixinBridge进行通信,这样的互不影响,也不会出现js阻塞页面。

  2. 为什么小程序无法通过js操作DOM 小程序因为是双线程,逻辑层和视图层是不影响的,视图层无法访问到视图层的dom;

    如果开发者可以直接通过JS操作界面的DOM树,那么一些敏感数据就毫无安全性可言,故微信提供了一个沙箱的环境来运行开发者的JS代码,这个环境不能有任何的浏览器相关的接口,只能通过JS解释执行环境,类似于HTML5ServiceWorker启动另一个线程来执行JS

  1. 小程序的wxml是如何被识别成页面结构的

    • wcc编译wxml得到一个js文件
    • js文件接受一个wxml文件,得到一个虚拟的dom
    • 然后通过exparser将虚拟的dom解析成真实的dom。并且合并绑定的data数据。渲染到页面上。
  2. 微信小程序的rpx如何进行的变换以及实现

    小程序在最开始的时候会先获取设备的宽度和dpr。

    rpx(responsive pixel): 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素

    由此可以看出,小程序在实现rpx转换时,不论是什么屏幕的手机,都是将屏幕宽度固定设为750rpx,然后根据实际屏幕的设备像素比dpr(dpr = 设备像素 / css像素)来进行转换的。具体对应关系如下:

    1rpx = (number/ 750) * 设备宽度 px

    在视图层,wxss样式文件经rpx初始转换后并将样式注入到页面过程中,会向window.__rpxRecalculatingFuncs__数组中收集窗口变化时的回调;先看wcsc可执行程序输出的处理rpx转换相关的setCssToHead函数实现,其最终返回rewritor函数。换后的样式嵌入到document.head中后,依然保存有创建的style元素的句柄,在页面窗口变更时执行对应的回调来修正rpx转换后的px值。

    webview基础库中内部注册窗口变更事件回调来通知样式文件重新进行rpx转换。

  1. 小程序怎么识别wxss

    wcsc将wxss编译成js文件过程:

    • wcsc将wxss编译成js文件
    • 进行rpx的转换
    • 创建style标签,将css插入到head
    • 生成webview在渲染层。

小程序的自动化发布

小程序除了可以通过执行minprogram-ci命令来进行小程序代码的上传。可以通过微信第三方平台支撑的api接口来搭建一个小程序发布平台实现构建、上传、提交代码、审核代码、发布等各种流程。此处不再细说,可以去微信文档进行详细查看~

总结

大概的跟着教程以及其他可参考资料进行了一遍梳理,希望自己在写小程序代码中能够清楚小程序的内部处理原理。如果有啥不妥,欢迎指正和交流~

【参考文献】

微信小程序技术原理分析


这是一个从 https://juejin.cn/post/7368784048574693410 下的原始话题分离的讨论话题