聊聊知乎小程序引擎

聊聊知乎小程序引擎

前言

本文基本上囊括了非端侧小程序引擎实现的方方面面,其中还涵盖了大量的微信小程序开发文档里不会告诉你的特性细节(可作为引擎侧的测试用例)。理论上本文可以作为实现小程序引擎的技术细节文档来查阅,不太适合通读。

整体架构

小程序引擎大致上可以分为两层,首先可以看一下微信关于这部分的解释:

  • 逻辑层: 运行在端内创建的 JS 线程中,用户的业务代码在该线程中执行,如你的 js 代码
  • 渲染层: 运行在端创建的 WebView 中,用户的模板和样式代码在其中执行,如你的 wxml、wxss 代码

那么为什么要如此设计呢?其实最最主要地目的就是为了"安全"(并不是为了保障渲染的更顺畅)是的,这是一个加了引号的安全,这里的安全是对小程序的平台方来说的。任何软件平台都有它的游戏规则,比如 UI 界面的一致性,网络请求域的收敛,平台功能限制等,只是小程序稍有不同的是虽然是基于 web 技术,但并不想让开发者使用到全量的 web 技术。所以把用户的代码放到一个脱离 web 的线程中去运行就是一个最稳妥的方案了。

然后小程序的一个个页面其实就是 WebView 打开的一个个网页,所以这部分应当还需要一个 web 端的渲染引擎来接管视图和交互,知乎小程序使用的是 Vue。原因是小程序和 Vue 的模板,指令和生命周期有不少相似之处,如:

  • 都是使用 xml 标签来描述 dom 节点
  • 都是使用 xml attr 来实现指令或配置属性
  • 模板语法几乎一致

所以基于 Vue 必然可以减少很多的编译工作量。同时 Vue 组件实例提供了很多有用的信息,比如 vnode 节点相关,这对于实现小程序的某些特性来说也会很方便。然而 Vue 是一个基于 web 平台的单线程的渲染引擎,那么整个小程序引擎的工作可以简单理解为是写一个让 Vue 支持多线程渲染的 Polyfill。

编译

首先解释一下知乎小程序源码中的各类文件后缀名,以及后文的指令名称、API 名称都是基于微信小程序的命名习惯由 wxxx 更名为 zxxx。如 zxml 对应的是 wxml、zcss 对应的是 wxss。其实基本上市面上的其他小程序平台,如阿里、百度、头条等也都类似,可以说微信小程序已经是事实上的小程序标准了。

言归正传,你会发现源码中的 zxml、zcss 和业务 js 代码都不见了,并且多了 5 个文件:

  • main.js - 来自上面的 app.js、index.js、log.js 等所有业务 js 代码,会被逻辑层接管。
  • render.js - 来自上面 app.zcss、log.zxml、log.zcss、index.zxml 等所有模板样式代码,会被渲染层接管。
  • framework.js - 逻辑层的基础库,也就是上面的 main.js 的实际接管者,它还负责跟渲染层打交道。
  • framework.render.js - 渲染层的基础库,也就是上面 render.js 的实际接管者,它还负责跟逻辑层打交道。
  • template.html - 渲染层的入口页面,也就是 webview 要加载的页面。

我们来一个一个说:

main.js

__init({
  // App
  app() {
    // app.js 及其引用被打包后的结果
  },
  // 页面
  pages: {
    '/pages/log/log': function() {
      // log.js 及其引用被打包后的结果a
    }
    '/pages/index/index': function() {
      // index.js 及其引用被打包后的结果
    }
  },
  // 自定义组件
  components: {
    '/components/userList/index': function() {
      // userList/index.js 及其引用被打包后的结果
    }
  }
})

看到 __init 了么?这个函数就是 framework.js 注册在逻辑层的全局对象上的,main.js 就是这样通过该函数被逻辑层的 framework 接管的。

这么多模块被打包成一个 main.js,要注意模块本身生命周期的处理。对于小程序的三大单元: app、页面、组件:

  1. 只有在对应单元被载入时该单元入口文件的代码才会被执行
  2. 所有模块在整个小程序的生命周期内都是单例的,即只会被执行一次
  3. 对应单元的传入的配置是绝对独立的,或者说配置是深拷贝的

知乎小程序和微信小程序略有不同的是第一条。假设现在有 Index 和 Log 两个页面,且小程序打开的默认页面为 Index 页。再假设 Log 页的代码为:

console.log('Log Page')
Page({...})

你会在首次打开小程序后,在 Index 页的控制台里输出了 Log 页的 log,这个在我看来十分不科学,所以没有和微信保持一致。

然后关于第 3 条是个特别神奇的存在,如下代码:

// page.js
const test = { a: 1 }
Page(
  {
    test,
    onLoad() {
      console.log(test === this.test) // 返回 false
    }
  }
)

render.js

为了方便说明,以下代码并不是最终的编译结果,最终代码中的依赖会被打包到 render.js。
import './main.css'
import c0 from './pages/log/log.vue'
import c1 from './pages/index/index.vue'

const pages = {
  '/pages/log/log': c0,
  '/pages/index/index': c1,
}

// 通过 template.html 中的 script src 获取当前页面路径
const path = getCurrentPagePath()
__init(pages[path])

没错!这里又出现了 __init。同样的道理,相信你已经猜到,它是被 framework.render.js 注册在渲染层的全局对象上的,render.js 就是这样通过该函数被渲染层的 framework 接管的。(对称即美)

除了 __init, 代码中还出现了 index.vuelog.vue 等 Vue 组件,要知道它们是怎么来的,还是得先看它长啥样子:

<template>
  <!-- log.zxml 被转换成了 Vue 模板 -->
</template>
<style>
  <!-- log.zcss 作为 Vue 组件的样式被插入到了这里 -->
</style>
<script>
  // 这部分代码将由编译器生成,为渲染层提供一个最最基础的 Vue 组件
  export default {
    pathKey: '/pages/log/log', // 注入路径信息
    components: {
      // ...注入该组件依赖的组件
    }
  }
</script>

我们再来逐个分解上方内容中的三大块:templatestylescript

template - 标签转换(基础组件方案)

众所周知,在小程序里面是没办法直接写 html 代码的(也可以写,但表现和 web 端很不一致),只能使用小程序基础组件。不过小程序的基础组件和 html 的几大基础标签还是有些对应关系的,比如

  • view: 可以看作是 div
  • text: 可以看作是 span
  • image: 可以。。。大概可以看作是 img,但它的功能更多些

那是否可以在编译的时候就直接做以上的映射转换呢?其实大部分情况下还是可以的,市面上不少小程序转 Vue,多端代码一套运行的工具确实好多就是这么干的。但我们要做的是小程序的渲染引擎啊,是要抄微信小程序。。咳咳,是要和微信小程序的表现一致的啊!那必然得还原地很细节。

在小程序里面,很多基础组件都有一些 html 基础标签不具备的小特性。比如 viewhover-class, 具体它用来干嘛的先不说,如果只是将 view 转成 div,那你就得以 div 为单元去封装一些特性,就不说 image 这些稍复杂的可能需要多个 html 基础标签组合成的小程序基础组件了。所以,无论从工程还是功能的角度来看,都不能直接转。

最后采用的方案其实很容易想到,既然都使用 Vue 了,那 Vue 组件来搞这个事情再合适不过了。

  • view => zhmp-view
  • text => zhmp-text
  • image => zhmp-image

加个前缀是为了绝对避免和 html 标签产生冲突,比如 view 和 text 其实还是一个 svg 标签。最后,framework.render.js 会在渲染开始之前统一把这些基础组件注册为 Vue 的全局组件

template - 指令转换

小程序指令转 Vue 指令同样不是直觉上哈希一下就能完事的,挑个复杂点的说了: zh:forv-for,也就是列表渲染指令。这一条指令会牵扯出若干条关联指令:

  • zh:for
  • zh:for-item
  • zh:for-index
  • zh:key

在上面的列表指令全家桶里,先说说 zh:key。在小程序里,你可以这样:

<block zh:for={{list}} zh:key="*this">
  <text>1</text>
  <text>2</text>
</block>

先不说 *this 这个特别的变量,小程序的 block 是可以看作是 Vue 的 template,而 block 可以写 key,template 不可以。让二者等价的唯一办法就是给 block 内的所有非 block 的一级子节点都以 block key 为基础添加一个不重复 key。

<template>
  <zhmp-text v-key="xxx-0">1</zhmp-text>
  <zhmp-text v-key="xxx-1">2</zhmp-text>
</template>

再说说 zh:for,它还支持这样的写法:

zh:for="{{list1}}{{list2}}123"

猜猜它会如何运行?不卖关子了,转成 Vue 应该是:

v-for="(item, index) of (list + list + '123')"

总之,列表指令的转译要做到完全和微信小程序保持一致还是挺费工夫的,没办法,微信小程序已经成了事实上的小程序规范,你不按着它来实现,就等于把小程序生态的工具链都拒之门外了,开发者就很难接受。

template - 模板数据兼容

  • 问题 1: 保留字段

在说这一节之前,先问个问题: 不知道大家在使用 Vue 的时候有没有好奇过:

<div>{{msg}}</div>

模板里面为什么可以不用写 this? 这个变量是如何从当前 vm 里获取到的?好了,思考三秒钟后你决定直接看看 Vue 的编译结果了:

// vue-template-compiler 的编译结果
render() {
  with(this){
    return _c('div', [_s(msg)]) 
  }
}
// vue-component-compiler,也就是 vue-loader 的编译结果
render() {
  var _vm = this
  return _vm._c('div', [_vm._s(_vm.msg)])
}

不过无论是上面的哪一种,都有个问题是那些 _s, _c 都必然会成为 Vue data 的保留字段,可以看下 Vue 的官方文档里对保留字段有详细描述: https: //cn.vuejs.org/v2/api/#data。这就意味着你在 data 的根节点里肯定就不能用这些字段名了: _ 和 $ 开头的所有。然而微信小程序没有这讲究,统统可以用(至少我还没测到)!

  • 问题 2: 动态添加响应式数据

在小程序的模板里,你可以引用一个并没有在 data 里声明过的数据:

{{ hello }}

然后,你在某个时间点去执行:

setData({ hello: 'hello' })

模板是可以成功响应的!然而,Vue 还是不可以!有同学可能会说,我记得 Vue.set 是可以动态添加响应式属性的啊。是的,但是不能在数据根节点添加: 见官方文档描述

  • 解决方案

以上两个问题的解决方案是:再嵌套一层数据节点,这里叫它 rootData 好了:

如此就可以通过 Vue.set 来动态添加响应式数据并可避免 Vue 的保留字段。不过模板里的引用数据可以无脑添加根节点么?比如下面的代码:

<view zh:for="{{array}}" zh:for-index="idx" zh:for-item="itemName">
  {{idx}}: {{itemName.message}}
</view>

除了 array,其中的 idxitemName 显然不应该被添加 rootData 引用,因为它们是 for 指令给节点注入的局部变量。

更复杂的还有 for 指令嵌套带来的局部变量覆盖问题。不过这些问题借助 posthtml 的子节点遍历,在 vNode 层面注入每一级的局部作用域,实现起来也不算很困难:

tree.walk(node => {
  ...
  const itemName = (attrs["zh:for-item"] || "item").trim();
  const indexName = (attrs["zh:for-index"] || "index").trim();
  injectLocalVariable(node, [itemName, indexName]);// 给 node 和 node 下的子节点注入 itemName 和 indexName 变量
  ...
  toJSExpressions(templateSyntaxOrAST, node.localVariables); // 将文本转换成带有 rootData 的 js 表达式的时候排除掉当前节点上注入的局部变量
})

另外,根节点方案其实也还是有「保留字段」的。不过这个情况及其特殊少见,如下模板代码:

{{ case === 1 }}
{{ in === 2 }}

即模板数据的键名和 js 关键字冲突。市面上的语法解析器在解析上述语法的时候都会报错,而微信小程序却是支持的。事实上确实是可以把 js 关键字用作对象键名,只是在模板语法里引用的对象被省略了才导致的这个问题。

以上其实还只是模板数据的基础解决方案,更进一步地,应当想到当模板为自定义组件模板的时候以上方案还得再做调整。因为组件的数据不仅仅来自于自身 data、还会来自外部传入的 props。

还是用代码来说明吧!假设你的自定义组件模板被编译成了这样:

<zhmp-view>{{rootData.msg}}</zhmp-view>

而 msg 此时实际上是被声明在了 props 里,所以还得再改造一下编译部分,使其结果为:

<zhmp-view>{{$zData.msg}}</zhmp-view>

$zData 是被 __wrapComponent 注入的 计算属性,它同时代理了 rootData 和 $props。

以上才是模板数据引用的终极解决方案。

template - 模板根节点

小程序模板和 Vue 模板还有一个最大的不同是小程序里模板可以没有根节点:

<text>123</text>
<text>456</text>

而 Vue 组件中上述写法是不允许的,必须得这样:

<template>
  <zhmp-xxx>
    <zhmp-text>123</zhmp-text>
    <zhmp-text>456</zhmp-text>
  </zhmp-xxx>
</template>

针对小程序的页面自定义组件,解法各有不同:

  • 页面

对于页面模板来说,微信小程序实际上也是有个根节点的,就是: <page>。然后为了能够让用户的如下样式代码生效:

page {}
page >.xxx{}

很自然地想到我们可以创建一个叫 page 的基础组件,用它来包裹页面模板:

<template>
  <zhmp-page>
    <zhmp-text>123</zhmp-text>
    <zhmp-text>456</zhmp-text>
  </zhmp-page>
</template>

然而。。。实际测试发现微信小程序页面的根节点实际上是 body,即使 zhmp-page 是 body 下面的第一个节点也是无法替代 body 的「样式根节点」的地位的,比如 body 的 background 特性: 在没有对 html 节点设置背景色的情况下,body 的背景色会作为全局背景色,也就是整个屏幕的背景色。

因此页面必须没有根节点了,同时 css 的 page 选择器应当被编译为 body。可是 Vue 组件又必须得有根节点,怎么办?好在社区已经有了解决方案,类似 React 的 fragment,vue 也有一个类似的: vue-fragment,然后页面模板的编译结果就变成了这样:

<template>
  <fragment>
    <zhmp-text>123</zhmp-text>
    <zhmp-text>456</zhmp-text>
  </fragment>
</template>

实际渲染时页面就不存在任何根节点了。

  • 自定义组件

和页面模板一样,它们都会被编译成 Vue 组件,所以自定义组件也会面临根节点问题,但并不能复用页面模板的解决方案。原因是基于小程序的事件系统(后文会详细介绍),自定义组件必须得有一个根节点用于承载事件绑定。比如:

<zhmp-custom-component v-zhmp-bind:tap="'onTap'"></zhmp-custom-component>

微信小程序的自定义组件是通过 WebComponent 实现的,而实际上 WebComponent 在真实 dom 节点上也是存在一个根节点的。然后通过分析 WebComponent 的样式表现,比如看起来就像没有根节点一样,不难想到这个根节点只能是一个行内元素了,通过开发者工具也能验证这个答案:

因此,自定义组件的根节点直接用 span 来充当即可。

<template>
  <span>
    <!-- xxx -->
  </span>
</template>

template - 标签保留属性

基于 vue 还会带来一个不好的问题,就是所有基于标签属性的 Vue 独有的指令,比如: v-xxx@xxx 等,在小程序里都要变成 attr 保留名称了,否则很可能会引起不可预料的问题。

然后值得一提的是,编译器在处理 attr 保留名称要注意同时兼顾中划线和驼峰风格:

<!-- v-bind 和 vBind 在小程序或 Vue 环境下是等价的,它们都不被允许 -->
<view v-bind="2"></view>
<view vBind="2"></view>

style - 基础组件的样式隔离

我们假设用户的 zxml 代码如下:

<view>hello zhmp</view>

zcss 代码如下:

view {
  color: red;
}

如何让这种直接给 view 的样式在 zhmp-view 这个基础组件上生效呢?毕竟 zhmp-view 组件的代码大概是这个样子:

<template>
  <div><slot></slot></div>
</template>

显然 view 这个标签选择器是不会对 div 生效的。那就直接将 view 转成 div 呗!

div {
  color: red;
}

然而这样的话比如我的 zhmp-image 组件也是基于 div 封装的,这样它的样式也会受到影响了。说到这里,又很自然地会想到在 web 里,有一种专门的样式隔离方案,对!WebComponent。其实打开微信开发者工具,你会发现微信小程序的 自定义组件 就是采用了该方案。然而实际使用后发现它也有一些目前难以解决的问题:

遂放弃。那再想想别的办法吧!反正 postcss 在手可以为所欲为啊。最终的解决方案是: 所有的基础组件,如果希望用户可以使用标签选择器来对某些节点应用样式,那就在那个节点上专门增加一个和组件同名的类名

<template>
  <div class="zhmp-view"><slot></slot></div>
</template>

然后 zcss 编译器会把样式给转成:

.zhmp-view {
  color: red;
}

然而,是的,又然而了!如果用户原本的样式代码里就有某个类名,它就叫 zhmp-view 该咋办?这个好办!一旦发现以 zhmp- 开头的类名,通通不给编译过!谁家的引擎还不能有点保留字段了!开个玩笑,这其实算是 WebComponent 方案可用前的折中方案了。不过值得一提的是,要想彻底防住还挺难的,因为还可能会有这样的代码:

[class="zhmp-xxx"] {
  color: red;
}

* {
  color: red;
}

这种开发者强行作的用例就暂不处理了。

然而,事情还没完,刚刚只是说了 view 这个最最简单的小组件,假设我的某个基础组件内部自己还使用了一些样式类名呢?比如:

<template>
  <div class="zhmp-xxx">
    <span class="name">来污染我啊!</span>
    <slot></slot>
  </div>
</template>

看!上面那个 zhmp-xxx 的内部使用了一个及其容易撞名的样式类名,.name。那用户的代码里如果也写了该类名的样式显然会对组件内不希望被影响的部分产生影响,总不能让 name 也成为保留字段吧。

这个问题其实也好办,基础组件内部的样式一律采用 CSS Modules 方案,这里不得不感叹赶上了前端技术蓬勃发展的好时代啊!而且 Vue 组件天然就对该方案支持的很好。

<template>
  <div class="zhmp-xxx">
    <span : class="$style.name">污染不到我了吧!</span>
    <slot></slot>
  </div>
</template>
<style module>
 .name {}
</style>

那为什么没用 Vue 最提倡的样式作用域方案呢,也就是喜闻乐见的关键字 scoped。 原因是即使父子组件都使用了 scoped,但父组件中依然可能会对子组件的根节点样式产生影响。比如 zhmp-image 是这样:

<template>
  <div class="zhmp-image container"><slot></slot></div>
</template>

用户的 zcss 里存在:

.container {}

熟悉 Vue scoped 方案的同学应当知道这会对 zhmp-image 的根节点产生作用,因为根节点会继承父节点的 data-vxxx。

然而。。。事情还没完!类似的,如果用户直接写了这样的样式代码呢?

div {
  color: red;
}

因为没有使用 scoped 了,这种写法会对整个组件中使用了 div 的标签都产生影响。解决办法也同样的粗暴,不给用!编译不通过!小程序里只准用小程序组件,未知的标签,未知的标签选择器,统统不允许!不过值得一提的是 css 的类型选择器是不区分大小写的,处理保留字段和做类型映射的时候要注意不要误伤和漏伤~

style - 自定义组件的样式隔离

目前只简单对用户的页面自定义组件使用了 Vue 模版样式的 scoped 指令简单实现了样式隔离,但还有全局样式(app.zcss)对自定义组件的污染以及页面同名样式对自定义组件根节点的污染以及更复杂的 外部样式类 的支持还待解决。

style - 页面样式的隔离

这个其实不用专门实现,每个小程序页面直接对应了一个 webview,浏览器环境都是隔离的。

style - 如何让页面根节点样式生效?

结合上文的模板根节点内容,我们知道页面在渲染期的根节点实际就是 Body,同时页面的样式又都是 scoped 的。假设用户需要在页面的 zcss 中想要修改 Page 样式,代码如下:

page {
  color: 'red';
}

编译的结果大致是:

body[data-v-xxx] {
  color: 'red'
}

发现问题了么?实际上 body 在页面中只是一条「光秃秃」的标签,因此这条带有属性选择器的样式是没法在 body 标签上生效的。解决方案是在某个时机,比如页面 created 的时候:

// _scopeId 是 vue 注入的私有属性,值就等于当前组件标签上被打上的 data-v-xxx
document.body.setAttribute(this.$options._scopeId, "");

style - 基础组件样式和用户样式的优先级问题

<image class="img" src="xxxx"></image>

如上代码,很自然地你可以用某个类名来给组件实现样式,比如 .img 修改了 image 的宽和高。不过 image 是一个有默认宽高的组件(320 * 240),也就是说组件内部其实给了组件的宽高样式,而为了不让用户修改一个图片的宽高都得这样:

.img {
  width: 100 !important;
}

所以组件内部的大部分样式应当处于一个较低的优先级,等待被用户的样式来覆盖。解决方案是用户的样式应当被最后插入 Dom,以保证一个高优先级


好了,样式的问题就告一段落吧(其实还有 rpx 的坑没有填)。

总结一下就是: 在用户不知情的情况下要保证别发生样式污染的事情,在用户明确要覆盖样式的情况下,要保证能够轻松覆盖,这样的结果才是符合预期的。

script - pathKey(资源引入)

这里就拿 Image 组件举例了,在小程序里使用 Image,就像在 web 里使用 img 那样,它的 src 规则可以是以当前模板文件代码所在目录为起点的相对路径。这个听起来很平常的事情好像没啥值得说的,然而如果是这种写法呢:

<image class="img" src="{{ src }}"></image>

大家在 webpack 工程里,应该都处理过动态图片的问题,一般套路就是提前把需要的图片 import 进来,然后去使用 import 进来的那个变量。但是上面代码里的那种引入方式就完全无法在编译期预知要打包的图片了,即使是借助 webpack 的 require with expression 特性也是无法处理一个纯变量的资源路径的。

所以最开始在小程序引擎还不支持自定义组件的时候,可以通过复制一份原工程结构及静态资源,走本地文件加载协议来简单解决这个问题。然而,在支持自定义组件后,发现上面那个处理方式并不够「普适」,因为自定义组件也可以引用 Image 组件,而它的 src 也可以是以自定义组件模板代码所在目录为起点的相对路径。所以当该自定义组件被页面引入并最后打包成一个文件的时候,静态资源的相对路径是没办法同时满足页面和自定义组件的。

后来采用的方案是编译期会给每个自定义组件(包括页面)注入它当时所在的位置,也就是上文 log.vue - script 中的 pathKey。然后 image 组件会根据它的有效父组件路径计算出图片的正确路径:

const parentPath = '/pages/index/index'
const imgSrc = '../../images/cat.jpg'
// 在根目录下的计算结果就是: './images/cat.jpg'

然而这个方案是无法覆盖 css 中的静态资源引入的,毕竟 css 没有什么好运行时的,不过遗憾(幸运)的是微信小程序直接就不支持在 css 中引入本地资源,同时 Image 组件本身就可以覆盖绝大多数背景图片的用例,所以就没有在编译期处理类似 background: url(./xxx) 必要了。

script - components(自定义组件)

首先编译器会根据 json 文件中的 pagesusingComponents 字段统一把所有页面和自定义组件的 zxml、zcss 都编译成类似上文中 log.vue 的样子。

我们知道,页面、基础组件以及自定义组件,它们之间可以互相依赖。基础组件上文已经说了,会被 framework 注册为全局组件。而通过 app.json 和 组件 json 文件中引用的自定义组件,会在组件的渲染过程中,通过 components 这个字段被注册为 Vue 局部组件。当然了,这里的局部组件是 Vue 的概念,对于小程序来说,app.json 中声明的是全局自定义组件,而对于组件 json 中声明的是局部组件。

只不过为了尽可能减少运行时的工作,编译器会把这两种按照局部优先级大于全局的原则统一处理成 Vue 的局部组件,比如:

// app.json(全局)
{
  "usingComponents": {
    "component1": "/components/component1",
    "component2": "/components/component2"
  }
}


// log.json(局部)
{
  "usingComponents": {
    "component1": "../components/component3",
  }
}

log.vue 的 script 内容会被编译成:

import c0 from '../components/component1'
import c1 from '../components/component3'


export default {
  components: {
    'zhmp-custom-component2': __wrapComponent('custom', c0), // 来自全局组件
    'zhmp-custom-component1': __wrapComponent('custom', c1), // 来自局部组件(覆盖全局组件)
  }
}

好了,除了全局和局部组件覆盖规则问题,上面的代码还出现了 3 个新的信息:

  1. 组件引入的绝对路径被转换成了相对路径
  2. 组件名被添加了 'zhmp-custom-' 前缀
  3. 多了个 __wrapComponent 函数

首先说第一条,编译器使用的是 webpack 来打包组件,资源引入按照 es6 模块规则是不存在绝对路径这一说的。不过有个变通的方案是使用 webpack 的 resolve.alias,但没啥必要,绝对路径转相对路径并不是很麻烦;

接着说第二条,和基础组件的命名方案类似,添加一个前缀的目的还是为了不和 html 标签冲突;

最后说第三条,__wrapComponent 也来自于 framework.render,它是包括页面在内的所有组件的包装器,不过它的具体内容这里先不说了,后文会详细说明。

template.html

<!DOCTYPE html>
<html lang="en">
 <head>
 <meta charset="UTF-8" />
 <meta
   name="viewport"
   content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
 />
 </head>
 <body>
    <div id="app"></div>
    <!-- 渲染层的基础库 -->
    <script src="framework.render.js"></script>
    <!-- 所有模板和样式的合并文件 -->
    <script src="render.js?page=pagePath"></script>
 </body>
</html>

是的,就是一份普普通通的 html 代码。如此,端在启动 WebView 之前唯一需要做的事情就是: 动态地去将 render.js 的 query 部分修改为当前页面的路径。

framework.js 和 framework.render.js

它们作为逻辑层和渲染层的基础库,可以对标微信小程序的 基础库 概念。显然作为基础库,它们肯定不是在编译阶段被生成的,而是在编译的时候根据用户的设定,编译器去加载对应的基础库版本,然后和上面的几个文件以及静态资源一起打包成小程序包,因此,这里就不介绍它们了。

编译速度优化

大体上分为三个方向:

  • 编译过程尽可能在内存中进行,相关库推荐: adm-zipmemory-fs
  • 编译过程尽可能并行,整体上的思路是先将有编译依赖导致无法并行的操作提前,准备好所有必要信息后,一步到位全量并行编译&打包剩余部分
  • 硬过滤掉无关的文件(夹),硬过滤的意思是连读都不读,比如过滤整个文件夹的时候确保过滤器不会深度遍历整个文件夹

通信机制

小程序通信具体有三个消息方:

  • 逻辑层
  • 渲染层

这个三者的互相通信需要借助 JSBridge: 在小程序初始化的时候,端会给逻辑层和渲染层的 JSBridge 中注入 callNative;逻辑层和渲染层会给 JSBridge 中注入 callJs。

  1. 端 → 逻辑层/渲染层: JSBridge.callJs
  2. 逻辑层/渲染层 → 端: JSBridge.callNative
  3. 逻辑层 <-> 渲染层: 通过先 2 后 3 实现,端变成了一个消息中转器

再看发送的消息类型,可以分为两种:

  1. 发的消息需要回复: 如渲染层在初始化组件的时候需要从逻辑层中获取初始化数据,这个就是一种需要回复的消息。
  2. 发的消息不需要回复: 如端给逻辑层推送的各种生命周期事件,这种属于不需要回复的消息。

需要回复的消息是通过给消息注入一个相同的 ID 来实现的,比如发送:

{ "action": "getData", "id": 0 }

对方在收到后,再给对方返回:

{ "action": "callback", "id": 0, response: {} }

可以说,通信是多线程模型的基础保障,设计一个合理好用的通信模块显然十分重要,尤其是消息对象的封装和异步消息的抽象。

逻辑层和渲染层消息初始化的部分代码示例

// 逻辑层
import { client, forward as webview } from "js-bridge"
import Message from 'message'


const clientMessage = new Message(client, clientMessageHandler)
const webviewMessage = new Message(webview, webviewMessageHandler)

clientMessage.send(action, param) // 逻辑层给端发送一条不需要回复的消息
const data = await webviewMessage.sendAsync(action, param) // 逻辑层给渲染层发送一条需要回复的消息
console.log(data) // 看看渲染层回复了啥!


// 渲染层
import { forward as logic } from "js-bridge" // 这个 forward 实际是按照 Message 类的接口封装的转发逻辑,在不同的端自然就变成了"对立端"的消息实体
import Message from 'message'


const message = new Message(logic, messageHandler)

const data = await message.sendAsync(action, param) // 给逻辑层发送一条需要回复的消息
console.log(data) // 看看渲染层回复了啥!

有了 Message 这个最基础的消息模型,之后再开发小程序开发者工具或者任何新的小程序容器,都只需要取适配该类的消息接口就 ok 了。

消息通信相关的性能优化

观察微信小程序的所有 api,包括 setData({}, callback),wx.xxx({ ... }).then(),他们都通过传入一个回调或使用 Promise.then 的方式获取异步执行结果,从上面的通信流程中不难发现,这类功能的执行必然都需要发送一个需要回复的消息,每一个需要回复的消息都必然要经过层 → 端、端 → 层、层 → 端、端 → 层,四次消息传递,可以说是成本高昂了。

所以在框架层面会对所有 api 的调用做回调检测,真正需要回调的才会去调用 sendAsync,否则就 send。

说到这里,就不得不提一下 setData 这个在实际业务中的消息通信大户了!除了上述的基本优化之外,还另外做了三件事情:

  • 只发送真正变更的数据(减小消息体)
  • 没有变更就不发送(减少消息数)
  • 合并多次同步变更(减少消息数)

还是用代码来说明吧:

// 假设目前 data 是: 
const c = { a: 1 }
const data = { a: 1, b: 2, c }


// setData( { a: 1, b: 3 } ) // 只会发送 { b: 3 }
// setData( { a: 1 } ) // 啥也不发


// 最终因为 a 还是 1,啥也不发
setData({ a: 3 })
...甚至中间还隔了一些同步代码
setData({ a: 1 })


// 只发送一次 { b: 3 }
setData({ b: 1 })
...甚至中间还隔了一些同步代码
setData({ b: 3 })


// 需要注意的用例!
// setData({ c: { a: 1 } }) // 看起来 c 的「值」没有变化,但是实际上对象引用发生了变化,因此会发送 { c: { a: 1 } }
// setData({ c }) // 不发送
// setData({ 'c.a': 1 }) // 不发送
// setData({ 'c.a': 2 }) // 发送 { 'c.a': 2 }


// setData 的 pathKey 用法还有个变态用例,强迫症还应当考虑 pathKey 格式化后的合并。如下只发送一次 { 'c.a': 3 }
setData({ 'c.a': 2 })
setData({ 'c["a"]': 3 })


// 还有个更变态的: 待提交的变更中的某个 pathKey 属于后面的某个 path 的子路径,只发送 { c: { a: 3 } }
setData({ 'c.a': 2 })
setData({ c: { a: 3 } })

然后 setDatacallback 会被存到一个队列当中,在 applyDataUpdates 阶段配合渲染层的 vm.$nextTick() 来顺序触发就行了。

API

小程序的 API 由逻辑层的 framework 注入。然后大部分 api 本质上是通过上述的通信机制给端发送消息去调用端能力,比如:

zh.navigateTo({ url: 'xxx', success(){} })

最终执行的应该是给端发送一条:

{
  "action": "navigateTo",
  "params": { "url": "xxx" }
}

因此应当设计一个通用的 API 模块,它能够自动处理以上逻辑。要注意的用例是:

  • 如果传入的配置不包含任何回调(success, fail, complete),且没有特殊返回(比如 request 会返回 RequestTask),则返回 Promise
  • 如果传入的配置不包含任何回调,且没有 Promise.catch(假如返回了 Promise 的话),则抛出一个 Promise Error

另外,还有一个比较重要的点,渲染层的某些功能可能也会依赖小程序 API,比如基础组件: navigator。该组件会触发小程序跳转,而小程序跳转的功能本身在 api 里已经提供了,所以最好能够在渲染层里也能够直接调用小程序 api,否则该组件的内部实现可能得专门再:

import { send } from "message";

send("navigateTo", { url })

远不如下面这种来的简单优雅好维护:

import { zh } from "api";

zh.navigateTo({ url })

但渲染层调用小程序 API 有个难点是: 前面说过,小程序 api 的调用本质上是给端发送 api 消息,但渲染层不能直接给端发送 api 消息(端实现起来比较麻烦),只能通过逻辑层来中转。

解决办法是可以再设计一个 API 消息模块来抹平该缺陷,它能够自动根据当前的执行环境来决定 api 消息是直接发送给端,还是先把消息转发给逻辑层,再由逻辑层发送 api 消息。于是经过一番「巧妙的」设计,小程序 api 的功能添加会变得很容易:

// zh.request
// 这里的 send 和 sendAsync 会自动根据当前的执行环境是渲染层还是逻辑层对消息体做处理以兼容上文中渲染层和端的通信
import { send, sendAsync } from "message";

export function request({ url, header, ...xxx }, response) {
  response(() => sendAsync('request', { url, header, ...xxx })) // response 用来处理 api 回调,如果没有传回调它会返回 Promise
  return RequestTask // 用户调用 zh.request() 得到的结果
}


// zh.navigateTo
export function navigateTo({ url }, response) {
  // 直接返回 response 的执行结果,用户调用 zh.navigateTo() 如果传入了回调 response 会自动将传入函数的执行结果回调给诸如 success、fail 的回调,如果没传会自动返回 Promise
  return response(() =>
    sendAsync("navigateTo", {
      url,
    })
  );
}

逻辑层

整体框架

组件实例

渲染层

整体框架

组件实例

看了以上两个层,有没有再次意识到那句话: 对称即美。

逻辑层和渲染层

基于以上所有,终于可以连起来说说上文中那个被编译成的 log.vue 是怎么在多线程环境下被运行起来的了!

  1. 端通过 JSBridge 告诉逻辑层哪个页面被打开了
  2. 逻辑层拿着页面 path 从 main.js 里获取到对应页面的业务代码,去执行 Page 的初始化
  3. 端启动 webview 去加载上面那个 template.html
  4. 渲染层的 __init 被调用后,告诉端我初始化好了,快去通知逻辑层给我初始化数据
  5. 逻辑层接收到这个消息后从上一步初始化好的 Page 里拿到 data,再去告诉端把这个 data 赶快给渲染层
  6. 渲染层终于拿到 data 了!流下了感动的泪水的同时顺便去 new 了一下 Vue

首先要解决的第一个问题:

基于异步的初始化数据如何去初始化一个 Vue 组件?

先看一下在标准 web 环境下是如何做的:

new Vue({
  data() {
    return { ...someData }
  }
})

可是在小程序环境里,组件的初始化数据在逻辑层里,所以在初始化页面之前,必然得先获取页面的初始化数据:

Page({
  data: { ...someData }
})

也就是上面用户代码里给 Page 函数传入配置中的 data。大致的实现如下(实际是下个小节末尾的样子):

// render.js 中会调用该方法传入当前页面的组件
window.__init = async PageComponent => {
  const {data} = await sendMessageAsync('getRenderData')
  Vue.extend({
    ...PageComponent,
    data() {
      return { rootData: data } // 上面编译部分提到过这里为什么需要 rootData
    },
  }).$mount("#app")
}

初始化页面组件看起来不是很复杂,接下来要解决第二个问题了:

如何基于异步的初始化数据去渲染组件依赖的自定义组件?

上文的编译部分提到过组件的样子:

import c0 from '../components/component1'
import c1 from '../components/component3'


export default {
  components: {
    'zhmp-custom-component2': __wrapComponent('custom', c0),
    'zhmp-custom-component1': __wrapComponent('custom', c1),
  }
}

我们假设上面就是小程序的某个页面 - PageComponent 的代码,可以看到它依赖了两个自定义组件。如此在执行上面 $mount 的时候 PageComponent 本身和其依赖的组件都将被 Vue 初始化和渲染,可是其依赖的组件的初始化同样也要依赖逻辑层中组件的 data:

Component({
  data: { ...someData }
})

然而 Vue 组件一旦开始渲染,是没办法打断生命周期的,也就是说你无法在 PageComponent 的任何生命周期阶段,哪怕是 beforeCreate 中也不能保证先获取完所有子组件的数据后再渲染所有子组件。如果不能保证就很可能会导致整体的渲染失败,因为子组件的模板里引用的数据在那时候还没有被声明。

生命周期无法被打断的话还有没有其他的办法了?有!异步组件,虽然它的原本用途不是为了这个。

所以这时候就可以说说 __wrapComponent 了,它是所有组件的包装器,它会获取组件初始化数据并根据需要的类型(页面、自定义组件)把编译后的最最基础的 Vue 组件变成支持多线程的 Vue 组件,页面和自定义组件的数据初始化、生命周期,消息发送等等都靠它来实现,最后返回一个符合 Vue 异步组件规范的组件。

因此,初始化页面的代码实际上是这样的:

window.__init = async PageComponent => {
  const Page = await __wrapComponent("page", PageComponent)();
  return new Page().$mount("#zhmp");
}

逻辑层和渲染层之间是如何知道消息和组件的对应关系的?

逻辑层和渲染层都有组件的概念,而且它们是一一对应的。

页面

打开页面的时候,端首先会通知逻辑层被打开页面的路径和该页面的唯一 ID。然后逻辑层通过页面路径从 main.js 中获取到页面的代码,形如:

Page({
  data: {},
  onLoad() {}
})

类似的还有 App、Component 都是这种代码形式。可以看到框架相关的配置都是通过函数传参的形式来声明的,这样的话逻辑层如何取到那个配置呢?解决方案如下:

let component
global.Page = option => {
  component = new Component(option)
};

这些暴露在用户侧的全局函数都是逻辑层注册的,自然可以通过上面这种回调形式获取到传入配置。不过上面的代码可能会有一个隐患,万一用户是异步调用这些函数,甚至没有调用呢?

首先可以通过继续改造这些函数来兼容异步,但是没必要,因为如果在执行完用户的代码后发现并没有组件被创建,逻辑层也会自动注册一个默认的配置,这其实也是微信小程序采取的方案,对应组件被创建时候的相关全局注册函数也只允许被调用一次。

然后逻辑层会以 上面端告知的页面 ID 为索引,将该页面实例存储起来。渲染层的某页面在发送消息的时候会被端注入页面 ID,比如上文中要获取初始化数据的时候,发送的消息大概是:

{ "action": "getRenderData", "pageId": "xxx" }

逻辑层收到后从上面存储的所有页面实例中根据该 pageId 获取到该页面实例,从而返回该实例的 data 数据,并同样给消息体中带上 pageId(这个就不是端注入的了,因为这里就成了多对一的关系),端就可以根据 pageId 把这条消息发送给对应页面的渲染层了。

自定义组件

这个就比较特殊了,因为它完全是基于小程序的基础框架上的应用层产物,它的整个生命周期跟端就没有关系了,它的「出生地」在渲染层,也就是上文中调用 __wrapComponent('custom', xxx) 的时候。

不过模拟端通知逻辑层去创建页面的过程,__wrapComponent 会通知逻辑层创建自定义组件,同样的会带上该组件路径。那么渲染层是怎么知道组件路径的?

上文中编译部分的资源引入提到了: 编译器会给每个组件注入一个 pathKey,它在这里也派上了用场!然后逻辑层通过组件路径从 main.js 中获取到组件代码,用类似创建页面实例的方式创建组件实例。

同样地,为了建立起渲染层和逻辑层组件之间一一对应的关系,类似页面 ID,显然还需要一个唯一的组件 ID。

像端生成 pageId 那样,理论上 componentId 也应当由渲染层生成,然而渲染层对应的是小程序页面,一个小程序可以打开多个页面,也就创建了多个渲染层,因此在多环境下生成唯一 ID 可能会有些困难。

不过幸运的是页面的初始化一定是发生在自定义组件创建之前的,那么组件 Id 就可以基于 PageId 来生成,这样 ID 就是全局唯一的了。(这么麻烦为什么不在通知逻辑层创建组件实例的时候顺便由逻辑层来生成 componentId?这个下个部分会解释

这样自定义组件在逻辑层和渲染层之间互相发送消息的时候都必须得带上 componentId 才可以正确关联到两端的实例,所以 __wrapComponent 在创建自定义组件的时候会比创建页面多一个步骤:

const componentId = makeComponentId(pageId)
await sendMessageAsync('createComponent', { path: component.pathKey, componentId }) // 通知逻辑层创建组件
const { data } = await sendMessageAsync('getRanderData', { componentId })

当然了,之后 __wrapComponent 在包装这两种组件的时候都会给组件注入一个消息发送器,组件内部调用该方法发送的消息就不用这么麻烦了,会自动带上 componentId:

this.$sendMessage('callMethod', { name: 'onClick' })

然后类似逻辑层存储所有页面实例那样,渲染层会以 上面逻辑层告知的 componentId 为索引,将该自定义组件实例存储起来(这里的实例事实上就是 vm)。这里再次: 对称即美。

衍生自定义组件

前文说了页面和自定义组件最终都被包装成了 Vue 的异步组件以及它们 ID 的由来。不过对于自定义组件来说,它在实际业务场景下很可能会被复用,比如列表渲染某个自定义组件:

<custom-component zh:for="{{list}}"><custom-component>

它被编译成的 Vue 组件大概是这样:

<template>
  <custom-component v-for="item of list"><custom-component>
</template>
<script>
  export default {
    components: {
      customComponent: __wrapComponent('custom', customComponent)
    }
  }
</script>

然而对于异步组件来说,Vue 做了一个缓存优化: 只有该组件被首次渲染的时候会执行异步过程,之后会直接使用缓存

这就会导致经过列表渲染等操作被二次渲染的自定义组件无法再:

  1. 通知逻辑层创建组件实例
  2. 获取初始化数据和 prop

仔细想下这个场景,其实过程2是可以省略的,因为组件的初始化数据和 prop 是不会变的,就像你写好一个 Vue 组件,无论你在模板里引用了多少次,组件模板代码本身是不会被改变的,这倒省了一次消息通信消耗。不过 过程1 是万万不能省去的,渲染层的每一个组件在逻辑层都必须要有一个对应的实例。

这个的解决方案是 __wrapComponent 会给组件注入一个动态的 $componentId,在组件首次渲染的时候它的值是基于 pageId 生成的。之后的渲染它的值会再基于首次渲染的值再生成一个新的唯一 id(套娃 pageId_comId_index)。

于是,被二次渲染的组件会以为此时逻辑层已经存在实例了,其实并没有。当它带着这个 $componentId 去发送消息后,逻辑层如果发现该 id 对应的实例不存在,再去立刻创建它。

不过前文中提过,创建组件不仅仅需要 id,还需要 path 信息,除了 createComponent 消息,普通的消息是不会带上该值的。好在逻辑层收到二次渲染的组件消息时,该组件之前一定已经至少被创建过一次了。所以可以把首次的 createComponent 过程中收到的 path 保存下来,再根据当前组件的 id 计算出一个 key 值(前文也说了,componentId 是根据一定的规则生成的,所以可以计算出一些「有用的信息」),从而取到该组件的 path 信息。

如此就是二次渲染的组件实例的「被动创建」机制。

如何实现自定义组件的 Props ?

同步方案

在微信小程序里,组件通过声明 properties 来定义类似 Vue 组件的 Props。在小程序组件内部,通过 this.data 或 this.properties 获取到的实际上是 data 和 props 的合并结果。

逻辑层中的组件 data 可以被用户显式的 setData 来改变,可是 props 呢?它的变化实际上是依赖渲染层中组件模板的传值,这个在逻辑层是感知不到的。所以 props 必然需要一个同步方案:

  1. 组件初始化的时候从逻辑层获取 props 默认值
  2. 组件 props 变化时通知逻辑层

所以上文中,初始化自定义组发送的 getRanderData 还会多返回一个 props:

const { data, props } = await sendMessageAsync('getRanderData', { componentId: 'xxx' })

然后包装后的组件要能够监听到所有 props 的变化并通知逻辑层,这个可以借助 Vue 的 watch$props 来实现:

watch: {
  $props: {
    deep: true,
    handler(props) {
      this.$sendMessage("setProps", props);
    },
    immediate: true
  }
}

之所以还有一个 immediate,原因是组件 props 的默认值会被父组件传入的值覆盖,这时候并不会触发 handler 来触发 props 同步,所以通过该选项可以在组件首次接收到父组件传值的时候也发起一次同步。

类型转换

微信小程序的 prop 在声明的时候可以定义一个数据类型,然后在组件内部通过 this.data.xx 或 this.properties.xx 获取到的值实际上是经过类型转换后的结果,比如:

{
  properties: {
    a: String
  },
  attached() {
    console.log(this.data.a) // 字符串 '1'
  }
}


<component a="{{1}}"></component>

不过有一种情况不会参与类型转换,就是没有给 prop 传值,但该 prop 又定义了 value,那 prop 的值就是 value,不会是类型转换后的 value(这可以理解成是开发者的故意行为):

{
  properties: {
    a: String,
    value: 1 // 不讲武德
  },
  attached() {
    console.log(this.data.a) // 数字 1
  }
}


<component a="{{1}}"></component>

另外!前文提到过 prop 同步方案,实际上也有类似前文 setData 的消息优化处理,就必然会涉及到变更检测。而 prop 又有一个类型转换操作,所以变更检测应当对比的是类型转换后的值。

默认值

在微信小程序里,有以下三种情况会返回 prop 默认值:

  • 不传
  • 传 null
  • 传的值无法完成类型转换

不过我这边实现的时候不会对传 null 的情况做默认值处理,而是会走上面的类型转换流程。同 Vue 的观念类似,null 被看作是一个有意义的值。

如下为不同 props 类型对应的不同的默认值:

  • String: ''
  • Number: 0
  • Boolean: false
  • Array: []
  • Object: null
  • Function: null
  • null: null(null 代表不限制类型,微信会把不限制类型的 prop 默认为 null,为什么不是 undefined?因为值为 undefined 的字段无法基于 json 序列化的消息系统来传递,具体可以 Json.stringify 测试就明白了)


名称规范

在 Vue 里所有中划线命名的 prop 都会被转成驼峰式,虽然微信小程序里可以定义和获取一个名称为「xx-xx」或「xx: xx」的 prop,然而这类命名方式在模板中会存在和 js 运算符冲突的问题,所以只靠运行时就天然可以保障用户基本不会这么命名(调皮的就让他接受模板报错的制裁吧!),因此也就没必要再做处理了。

函数传递

微信小程序还支持通过 prop 传递函数,基于只能传输文本的数据同步还得设计一套函数的同步逻辑,比如约定一种消息格式来传递函数名?这个只是先说下大概想法,其实我们也还没有支持。

如何实现自定义组件的生命周期?

这里就直接用代码说明了:

export default {
  mounted() {
    this.$lifecycle("attached");
    this.$nextTick(() => this.$lifecycle("ready"));
  },
  destroyed() {
    this.$lifecycle("detached");
  }
};

显然自定义组件的 attached、ready 和 detached 是由渲染层触发。根据 微信小程序自定义组件生命周期 ,还差 created、moved 和 error。

  • created: 由逻辑层在收到 createComponent 的之后,创建完逻辑层的组件实例后触发
  • moved: 仅会发生在列表渲染时,元素位置发生变化,用处实在不大,基于 Vue 实现起来稍复杂(监听组件 key 的变化)
  • error: 逻辑层捕获触发

如何获取自定义组件的事件作用域?

当给自定义组件绑定事件的时候:

<template>
  <custom-component1>
    <custom-component2 v-touch:tap="'onTap'"></custom-component2>
  </custom-component1>
</template>

如上代码,当在 custom-component2 上触发 tap 事件的时候,需要去通知逻辑层来调用对应组件实例中的 onTap 方法。那么这个组件实例是谁呢?customComponent2 ?显然不是。customComponent1 ?也不是。

那个组件实例只能是上述整个模板代码所在的那个组件,可能是页面,也可能是自定义组件。

小程序的事件系统是基于 Vue 的 自定义指令 的,那在自定义指令的几个钩子函数中是否能获取到那个组件呢?完全没问题!即: vNode.context。v-touch 实现的伪代码如下:

Vue.directive("touch", { update(el, binding, vNode) {
  el.onTouch = (e) => vNode.context.$sendMessage('callMethod', {name: 'onTap', arg: tapEvent(e)})
}});

基础组件的 id 属性

后文安全相关的 Attribute 部分 会提交组件会全局禁用 attr 继承,这就导致包括 id 在内的属性无法渲染到对应的组件节点。因为这是一个跟真实 dom 节点相关的属性,所以对用户侧基本不会有啥影响,但某些基础组件的实现还是会依赖 dom id。比如 Label 的 for 属性,显然和 id 是一个强相关的属性。这里再多赘述一点 Label 组件的实现细节,它是不能跨组件去代理组件的,比如:

<!-- 某自定义组件 custom-component -->
<checkbox id="ck"></checkbox>


<!-- 页面 -->
<custom-component></custom-component>
<label for="ck">label</label>

这时候页面内的 Label 是无法触发 custom-component 中的 checkbox 的。所以你得实现一套基础组件的 id 系统,它能够满足:

  • 根据 id 获取当前作用域下的 vm
  • 当前作用域下如果存在多个重复 id 的组件,返回最后一个
  • 能够响应 id 变化(id 可能是一个动态值)

事件系统

微信小程序事件系统

指令编译

类似 bindtap, bindtouchstart 等交互事件的支持,倒没有直接使用组件方案来做。因为如果采用组件方案,基于 Vue 大概是这么个套路: 所有组件通过 Mixin 来继承一个基础组件,基础组件里提供以上 bindtap, bindtouchstart 等 props,然后去监听这些 props 值的变化来绑定或解绑事件。听起来是一个比较简单的方案,不过这些事件大部分都是 dom 事件,除了 props 绑定的回调值可能会发生变化,对应组件的 dom 节点也可能发生变化,这在那个基础组件里就不太容易优雅地处理了。好在除了组件,Vue 的另一个功能和这个需求可以说是天作之合: 没错!自定义指令,它的 钩子函数 能够完美覆盖上述场景。

再观察微信小程序事件的特性和事件对象属性,几乎可以肯定是基于 CustomEvent + dispatchEvent 实现的。

因此我这边最终的方案是使用 Vue 的 directive 给 dom 节点绑定事件,再在触发事件的相应 dom 节点上: CustomEvent + dispatchEvent 来派发事件,需要自行实现的就只有 互斥事件mark 了。

所以最终的编译结果如下两个用例可以说明:

  • bind:tap => v-zhmp-bind:tap
  • capture-catch:tap => v-zhmp-bind:tap.capture(自定义指令 name + arg + modifiers)

基于以上,小程序的默认事件触发器如相关的触摸事件: tap、touchstart 等的实现就很方便了: 利用事件委托机制,直接监听 body 的触摸事件,然后在当前触摸的节点上派发事件即可。

基于上述的事件触发机制,还有一个好处是提供给小程序开发者使用的各类小程序事件(系统事件、基础组件事件、甚至是用户组件事件),框架层的基础组件也可以使用它们,某种程度上来说这可以使整个小程序的交互特性保持一致。比如 checkbox:

<!-- 如此,在实现基础组件的时候也可以使用 tap 事件 -->
<div class="zhmp-checkbox" @tap="onTap"></div>

原生事件过滤

如上事件绑定的编译结果,对于小程序组件和默认事件不支持的原生事件如: click、mousedown 等,还有和小程序事件名相同的原生事件如: input。如下代码:

<view bindinput="onInput" bindclick="onClick">

应当有机制来避免原生 input 事件触发 onInput 和 click 事件触发 onClick。比如你可以约定一个小程序事件:ZEvent,在底层的事件接收器里判断收到的事件对象是不是 ZEvent 实例(原生事件肯定不是)。

顺便,以上方案可能用得到一个问题:请问这个算是 Chrome 的 bug 还是 Safari 的?

tab 和 longpress

对于如下代码:

<view bindtap="onTap" bindlongpress="onLongPress" id="parent">
  <view bindtap="onTap" id="child"><view>
<view>

在 child 上长按同样会导致 child 上的 tap 不会被触发,哪怕 child 并没有绑定 longpress。所以这两个事件应当是全局互斥的,也就是说在本次触摸结束前,只要有任意节点触发过 longpress,之后所有节点的 tap 事件都不会被触发。不过这个看起来像是 bug 的特性,实现起来反而简单了。。。(感兴趣的可以想一下)

滑动防抖

比如手指按下,轻微的移动是不会导致 tap、longpress 事件失效的。而且实现的时候还得考虑到多点触控的情况,这里可以直接监听 pointercancel 事件来标记一个触摸事件的失效。

多点触控

在触摸首次发生和完全结束的过程中只会发生一次 tap、longpress,在实现的时候需要防止过程中多点触控对它们的触发。

longtap 废弃

微信小程序已经明确在文档里和开发者工具的提示里告诉开发者不推荐使用该事件了,这个显然可以直接不实现了。

触摸效果

触摸效果应当和触摸行为保持一致,比如手指按下对应组件的会发生样式变化,手指按下并移动一定距离触摸状态消失的同时触摸事件失效,等等,为此专门写过一篇相关的小文章:移动端的点击事件和基于 :active 实现的按钮点击态

再结合微信小程序中许多基础组件都提供了四个有关触摸效果的配置:

  • hover-class
  • hover-stop-propagation
  • hover-start-time
  • hover-stay-time

有必要基于当前封装好的小程序触摸事件系统再派发一些用于触发触摸效果的事件(触摸效果开始、触摸效果结束),这样便可以保证触摸效果和触摸行为的一致了。然后相关基础组件的触摸效果由统一监听这些事件来实现。

disabled 的处理

比如 Button 被 disabled 后,在微信小程序里 tap 就无效了,这也符合直觉。然而 longpress 却可以,这显然就不科学了。所以我这边实现的是:

  1. 所有被设置了 disabled 的基础组件,所有事件都不触发
  2. 所有被设置了 disabled 的组件,所有事件都不监听

不过有个特别的地方在于,如果某个基础组件提供了 disabled 属性且被用户 disabled 了,然而组件内部的某些节点在实现上依然需要监听小程序事件,比如:

<div class="zhmp-video" disabled>
  <div class="play" @tap="onTap"></div>
</div>

我们假设 video 组件内部的这个 play 按钮在组件本身被禁用的情况下依然需要能够监听到 tap 事件。如此,第 1 点对于小程序引擎来说应该实现为:

通过小程序事件绑定语法(bind)绑定的事件接收方应当过滤掉属于某个 disabled 组件触发的所有事件,而基础组件内部直接通过 Vue 事件绑定语法(@/addEventListener)绑定的事件不受 disabled 约束。

composed

系统的默认事件如各种触摸事件都是 composed 的,也就是说这些事件是可以一直传播到自定义组件里的。

mark 数据采集

按照微信小程序的定义: 「当事件触发时,事件冒泡路径上所有的 mark 会被合并,并返回给事件回调函数。(即使事件不是冒泡事件,也会 mark 。)」。实际测试后,这个规则应当这样描述会更精确:

  • 当事件触发时,沿着触发的 target 节点一路向上合并,直到遇到自定义组件
  • 遇到自定义组件时,要重新「开辟」合并空间,换句话说对应模板中的回调结果永远都是该模板中合并的 mark 结果,mark 不能跨模板
  • 合并过程中遇到的同名属性,以先合并的为准

相同事件的多绑定处理

<view
    mut-bind:tap="onMutBind"
    mut-catch:tap="onMutCatch"
    capture-bind:tap="onCaptureBind"
    capture-catch:tap=""
    bind:tap="onBind"
    catch:tap="onCatch"
    catchtap="onCatch"
></view>

对于如上代码,最终事件的触发结果是: onMutCatch,是的,仅此一个!具体规则:

  • 同一个组件上绑定的同一个事件,冒泡和捕获只能各自触发一次
  • 同一个组件上同时绑定了冒泡和捕获事件,优先触发捕获阶段事件(哪怕如上面代码那样冒泡事件的绑定在捕获事件之前)
  • 对于同一个阶段的事件,mut 绑定会覆盖普通绑定和普通 catch 绑定(所以 onBind 和 onCatch 没触发)
  • 对于同一个阶段的事件,catch 绑定会覆盖普通绑定(所以 onCaptureBind 和 onMutBind 没触发)
  • 哪怕事件绑定的 handle 为空,绑定也是有效的,如 capture-catch:tap 照样会导致 onCaptureBind 失效。
  • 对于支持冒号的绑定指令(不带中划线的指令),如果同时绑定了带冒号和不带冒号的,则只触发按照绑定顺序的最后一个(假设 onCatch 可以触发的话,只会触发 catchtap 的 onCatch)

然而有个操蛋的地方是那些因为上述规则导致失效的事件,它的相关特性却依然有效,比如:

<view bindtap="onBind">
  <view mut-bind:tap="onMutBind" catchtap="onCatch">
  </view>
</view>

按照规则,由于存在 mut 绑定事件,catchtap 应当不触发,然而即使不触发也还同样会使事件停止冒泡,导致 onBind 也不触发。这种变态的设定必然导致你不能在编译期将无须触发的事件给移除掉。

事件对象之 target

target 「变基」

一个 bubbles + composed 的事件在传播过程中,如果接收事件的节点(currentTarget)和发起事件的节点(target)跨越了组件,它的值遵循两个原则:

  1. currentTarget 所在的 Context 和 target 的 Context 的 Context 如果相同(一级跨越),那么 target = 事件发起节点所在的 Context 节点(请仔细理解,Context 并不是父节点的意思)
  2. currentTarget 所在的 Context 和 target 的 Context 的 Context 也不相同(多级跨越),那么 target = currentTarget

我们可以用下面的代码进行分析:

<!-- page -->
<component2 bindtap="onTap">
  <component1 bindtap="onTap"></component1>
</component2>


<!-- component1 -->
<button bindtap="onTap"></button>


<!-- component2 -->
<view2 bindtap="onTap">
  <view1 bindtap="onTap">
    <slot></slot>
  </view1>
</view2>
  1. 当 button 被点击时,首先是目标阶段,此时 target 和 currentTarget 都是 button,自然也不存在组件穿透, target = button
  2. 接着是冒泡阶段,到了 component1 节点,此时 currentTarget 为 component1,其所在的 context 为 page;而 button 所在的 Context 为 component1,而 component1 的 Context 为 page,Context 相同!所以根据 原则1,此时 target = component1
  3. 继续冒泡,到了 component2 的 view1 节点,此时从 view1 节点的 Context 为 component2,显然和 button 的 Context 的 Context 即 Page 不相同了!所以根据 原则2 此时 target = currentTarget = view1
  4. 继续冒泡,到了 component2 的 view2 节点,同 情况3 的逻辑一样,此时 target = currentTarget = view2
  5. 继续冒泡,这时候到了 page 的 component2 节点了,同 情况2 的逻辑一样,此时 target = component1

target.id

  • 该属性不会被同名 prop 覆盖(即使有个叫做 「id」 的 prop,target.id 仍然可以获取到值)
  • 大小写敏感
  • 空值为 ""

target.dataset

  1. 会被同名 prop 覆盖(假设真有个叫 'data-a' 的 prop,那么事件对象中的 dataset 就不会包含属性 a 了)
  2. 大小写敏感,即非 「data-」开头,比如 Data-xx 的 attr 不会被采集为 dataset(原生 Web 大小写不敏感

事件绑定的设计缺陷

指令和 prop 名冲突

不难发现微信小程序的事件绑定指令并没有像 Vue 那样有一套专用的解法(有专门的「@」关键字),而是复用的普通 atrr 写法。这就有几率导致事件绑定指令还有 mark 和组件 properties 冲突:

Component({
  properties: {
    'capture-bind:longpress': String,
    'mark: a': String,
  }
})


<component bindtap="onTap" capture-bind:longpress="onLongPress" mark: a="1"></component>

上面的代码只会触发 bindtap,且 mark 为空对象。值得一提的是在 Vue 的实例里 「capture-bind:longpress」的 prop 名会经过驼峰处理变成「captureBind:longpress」,所以上面的用例在我们的小程序引擎里并不会产生 prop 冲突。虽然这里也可以通过编译期注入原始 prop 名的办法来和微信保持一致,只是这种命名里包含中划线或冒号的 prop 对于小程序组件来说其实是没啥意义的,因为你不可能在模板里:

{{capture-bind:longpress}}

因为中划线和冒号都是 js 的运算符,所以这里就假定用户不会这么命名了。另外 prop 冲突导致的事件不触发是真正意义上的不触发,不会有上文中因为触发规则导致的不触发而事件特性还依然生效的问题。

指令写法不统一

比如上面你可以 bind:longpress,也可以 bindlongpress;可以 catchtap 也可以 catch:tap。然而对于存在中划线的组合指令,如 capture-bind:touchstart 就只能 capture-bind:touchstart 了。

然而我这边目前还是准备两种都支持了。按理说应该只保留冒号指令,只是考虑目前大多数人都习惯了不用冒号,而且其他的小程序平台也是支持不用冒号,所以这里就只好也将错就错吧。。

指令组合不完备

比如应该还得有 mut-capture-bind 和 mut-capture-catch ?

自定义组件的事件特性

target

首先说微信自定义组件是借助 WebComponent 实现的,所以自定义组件本身应当视为一个整体,所以在自定义组件本身被绑定了事件时,外部接受到的事件对象的 target 只能是该组件本身。而基于 Vue 组件,能作为 target 的就只能是 vm.$el 了,也就是前文编译部分中提到的那个 span。

bubbles

该指令 CustomEvent 被本身就支持,无须额外实现。

capturePhase

可通过在对应事件的 handle 中判断自身是否是绑定的捕获阶段,并且 e.capturePhase 这个私有属性是否为 true 来实现。注意,如果不符合这两个条件应当执行的是跳过事件触发,而不是 stopPropagation,否则会导致该事件的冒泡也被终止。

composed

实际上,如果是像微信那样去基于 WebComponent 来实现自定义组件,这个指令倒不用专门实现,因为是 CustomEvent 本身就支持的指令。而基于 Vue 组件,则可以通过对比 target 和 currentTarget 的 vNode.context 来判断是否发生了「事件穿透」。

基于上述原理,我这边会给小程序事件中实现一个叫做 outOfContext 的私有属性,然后在事件的 handle 中判断 e.composed 和 e.outOfContext 来决定是否「放行」。

mark 指令

小程序的 mark 指令是不能「穿透」组件的,因此类似 composed 的解法,事件传播到某个节点上的时候,handle 中获取的 e.mark 这个私有属性,返回的就直接是「模板作用域」的 mark 结果。

运行机制总结

经过上文的一通胡乱操作,我们终于可以总结下当用户点击小程序的启动图标后会发生什么事情了(精简后):

  1. 初始化逻辑层: 启动一个 js 线程,该线程先加载 framework.js,再加载 main.js
  2. framework 开始注册相关全局函数,如: __init、App、getCurrentApp 等,初始化 App 实例,初始化通信相关
  3. 端通知逻辑层触发 App 的相关生命周期,如: onLaunch、onShow 等
  4. 端通知逻辑层当前有个页面被打开了,framework 拿着接收到的页面地址去初始化页面实例
  5. 初始化渲染层: 根据用户在小程序 json 中定义的启动页,开始修改 template.html 中 framewok.render.js 的 query 字段,启动 webview 载入 template.html
  6. framework.render 开始注册相关全局函数,如: __init、____wrapComponent 等,初始化通信相关
  7. framework.render 接收到 render.js 传来的当前页面组件,开始初始化页面实例(开始渲染),如果需要的话,渲染过程中还会通知逻辑层去创建自定义组件并初始化自定义组件
  8. 组件渲染过程中通知逻辑层去触发相关生命周期,如: onLoad、ready、attached 等
  9. 小程序运行期间端和渲染层会继续通知逻辑层触发相关生命周期,根据业务逻辑和过程中的一些交互事件,渲染层和逻辑层持续交换消息

安全相关

模板语法

引言部分提到过将用户的 js 代码运行在逻辑层中是为了规避一些「安全问题」,那么运行在渲染层的模板会不会也有可能存在「安全隐患」呢?比如基于 Vue 的模板解释器,如果这么写会发生什么?

{{
  (() => {
    window.alert("123");
  })()
}}

怀着忐忑的心情打开浏览器运行后,果然。。。虚惊一场!甚至还收到了一个报错: Property or method "window" is not defined on the instance but referenced during render

其实在官方文档里也说的很清楚了:

不过如果是这样:

{{fun()}}

由于 Vue 可以直接在模板中调用当前 vm 实例上的函数,开发者是有机会调用到框架给组件注入的内部函数的。不过开发者读不到整个实例,凭空调用到内部函数的概率不大,后面想堵的话可以直接在编译阶段处理掉。

Attribute 绑定

用户的原始模板里通过给组件设置一些特殊的 attr 来执行 web 原生方法或获取属性:

<view onclick="console.log(window)"></view>

上述代码因为是用户在模板中写死的,属于开发侧的代码,所以 Vue 的 Attribute 安全 并不会拦截。不过对于小程序来说,任何情况都应当避免开发者访问到浏览器的原生属性,解决方案是: 所有组件 禁用 Attribute 继承

不过除了以上还有没有其他的安全风险,这个有待继续考证了。。。可参考: https: //cn.vuejs.org/v2/guide/security.html

结语

感谢跳着看到这里,这篇又臭又长的文章终于结束了!其实仍然还有一些值得分享的领域,比如:原生组件方案(同层渲染或混合渲染)这个在我看来也是小程序作为对内服务时相对于 Hybrid 方案的优势之一。不过这部分的工作量大部分在端侧,本文就不再赘述了。

而且说实话这篇文章的很多内容都是因为想要「像素级」拷贝微信小程序所以有那么多事和因为采用了某个技术方案所以带来了某些问题。核心还是作为后发的小程序平台你愿不愿意为了保障小程序开发者的体验去深度探究和实现已有平台的特性细节,如果愿意,那么本文或许能够给你带来一些启发和技术选型参考~

编辑于 2021-01-19 11:35