抽象节点

这个特性自微信小程序基础库版本 1.9.6 开始支持。

在组件中使用抽象节点

有时,自定义组件模板中的一些节点,其对应的自定义组件不是由自定义组件本身确定的,而是自定义组件的调用者确定的。这时可以把这个节点声明为“抽象节点”。

例如,我们现在来实现一个“选框组”(selectable-group)组件,它其中可以放置单选框(custom-radio)或者复选框(custom-checkbox)。这个组件的 wxml 可以这样编写:

代码示例:

在开发者工具中预览效果

<!-- selectable-group.wxml -->
<view wx:for="{{labels}}">
  <label>
    <selectable disabled="{{false}}"></selectable>
    {{item}}
  </label>
</view>

其中,“selectable”不是任何在 json 文件的 usingComponents 字段中声明的组件,而是一个抽象节点。它需要在 componentGenerics 字段中声明:

{
  "componentGenerics": {
    "selectable": true
  }
}

使用包含抽象节点的组件

在使用 selectable-group 组件时,必须指定“selectable”具体是哪个组件:

<selectable-group generic:selectable="custom-radio" />

这样,在生成这个 selectable-group 组件的实例时,“selectable”节点会生成“custom-radio”组件实例。类似地,如果这样使用:

<selectable-group generic:selectable="custom-checkbox" />

“selectable”节点则会生成“custom-checkbox”组件实例。

注意:上述的 custom-radiocustom-checkbox 需要包含在这个 wxml 对应 json 文件的 usingComponents 定义段中。

{
  "usingComponents": {
    "custom-radio": "path/to/custom/radio",
    "custom-checkbox": "path/to/custom/checkbox"
  }
}

抽象节点的默认组件

抽象节点可以指定一个默认组件,当具体组件未被指定时,将创建默认组件的实例。默认组件可以在 componentGenerics 字段中指定:

{
  "componentGenerics": {
    "selectable": {
      "default": "path/to/default/component"
    }
  }
}

注意事项

  • 节点的 generic 引用 generic:xxx="yyy" 中,值 yyy 只能是静态值,不能包含数据绑定。因而抽象节点特性并不适用于动态决定节点名的场景。

自定义组件扩展

为了更好定制自定义组件的功能,可以使用自定义组件扩展机制。从微信小程序基础库版本 2.2.3 开始支持。

扩展后的效果

为了更好的理解扩展后的效果,先举一个例子:

在开发者工具中预览效果

// behavior.js
module.exports = Behavior({
  definitionFilter(defFields) {
    defFields.data.from = 'behavior'
  },
})

// component.js
Component({
  data: {
    from: 'component'
  },
  behaviors: [require('behavior.js')],
  ready() {
    console.log(this.data.from) // 此处会发现输出 behavior 而不是 component
  }
})

通过例子可以发现,自定义组件的扩展其实就是提供了修改自定义组件定义段的能力,上述例子就是修改了自定义组件中的 data 定义段里的内容。

使用扩展

Behavior() 构造器提供了新的定义段 definitionFilter ,用于支持自定义组件扩展。 definitionFilter 是一个函数,在被调用时会注入两个参数,第一个参数是使用该 behavior 的 component/behavior 的定义对象,第二个参数是该 behavior 所使用的 behavior 的 definitionFilter 函数列表。

以下举个例子来说明:

// behavior3.js
module.exports = Behavior({
    definitionFilter(defFields, definitionFilterArr) {},
})

// behavior2.js
module.exports = Behavior({
  behaviors: [require('behavior3.js')],
  definitionFilter(defFields, definitionFilterArr) {
    // definitionFilterArr[0](defFields)
  },
})

// behavior1.js
module.exports = Behavior({
  behaviors: [require('behavior2.js')],
  definitionFilter(defFields, definitionFilterArr) {},
})

// component.js
Component({
  behaviors: [require('behavior1.js')],
})

上述代码中声明了1个自定义组件和3个 behavior,每个 behavior 都使用了 definitionFilter 定义段。那么按照声明的顺序会有如下事情发生:

  1. 当进行 behavior2 的声明时就会调用 behavior3 的 definitionFilter 函数,其中 defFields 参数是 behavior2 的定义段, definitionFilterArr 参数即为空数组,因为 behavior3 没有使用其他的 behavior 。
  2. 当进行 behavior1 的声明时就会调用 behavior2 的 definitionFilter 函数,其中 defFields 参数是 behavior1 的定义段, definitionFilterArr 参数是一个长度为1的数组,definitionFilterArr[0] 即为 behavior3 的 definitionFilter 函数,因为 behavior2 使用了 behavior3。用户在此处可以自行决定在进行 behavior1 的声明时要不要调用 behavior3 的 definitionFilter 函数,如果需要调用,在此处补充代码 definitionFilterArr[0](defFields) 即可,definitionFilterArr 参数会由基础库补充传入。
  3. 同理,在进行 component 的声明时就会调用 behavior1 的 definitionFilter 函数。

简单概括,definitionFilter 函数可以理解为当 A 使用了 B 时,A 声明就会调用 B 的 definitionFilter 函数并传入 A 的定义对象让 B 去过滤。此时如果 B 还使用了 C 和 D ,那么 B 可以自行决定要不要调用 C 和 D 的 definitionFilter 函数去过滤 A 的定义对象。

代码示例:

在开发者工具中预览效果

真实案例

下面利用扩展简单实现自定义组件的计算属性功能:

// behavior.js
module.exports = Behavior({
  lifetimes: {
    created() {
      this._originalSetData = this.setData // 原始 setData
      this.setData = this._setData // 封装后的 setData
    }
  },
  definitionFilter(defFields) {
    const computed = defFields.computed || {}
    const computedKeys = Object.keys(computed)
    const computedCache = {}

    // 计算 computed
    const calcComputed = (scope, insertToData) => {
      const needUpdate = {}
      const data = defFields.data = defFields.data || {}

      for (let key of computedKeys) {
        const value = computed[key].call(scope) // 计算新值
        if (computedCache[key] !== value) needUpdate[key] = computedCache[key] = value
        if (insertToData) data[key] = needUpdate[key] // 直接插入到 data 中,初始化时才需要的操作
      }

      return needUpdate
    }

    // 重写 setData 方法
    defFields.methods = defFields.methods || {}
    defFields.methods._setData = function (data, callback) {
      const originalSetData = this._originalSetData // 原始 setData
      originalSetData.call(this, data, callback) // 做 data 的 setData
      const needUpdate = calcComputed(this) // 计算 computed
      originalSetData.call(this, needUpdate) // 做 computed 的 setData
    }

    // 初始化 computed
    calcComputed(defFields, true) // 计算 computed
  }
})

在组件中使用:

const beh = require('./behavior.js')
Component({
  behaviors: [beh],
  data: {
    a: 0,
  },
  computed: {
    b() {
      return this.data.a + 100
    },
  },
  methods: {
    onTap() {
      this.setData({
        a: ++this.data.a,
      })
    }
  }
})
<view>data: {{a}}</view>
<view>computed: {{b}}</view>
<button bindtap="onTap">click</button>

实现原理很简单,对已有的 setData 进行二次封装,在每次 setData 的时候计算出 computed 里各字段的值,然后设到 data 中,以达到计算属性的效果。

此实现只是作为一个简单案例来展示,请勿直接在生产环境中使用。

官方扩展包

  • computed

开发第三方自定义组件

微信小程序从基础库版本 2.2.1 开始支持使用 npm 安装第三方包,因此也支持开发和使用第三方自定义组件包。关于 npm 功能的详情可先阅读相关文档。

准备

开发一个开源的自定义组件包给他人使用,首先需要明确他人是要如何使用这个包的,如果只是拷贝微信小程序目录下直接使用的话,可以跳过此文档。此文档中后续内容是以 npm 管理自定义组件包的前提下进行说明的。

在开发之前,要求开发者具有基础的 node.js 和 npm 相关的知识,同时需要准备好支持 npm 功能的开发者工具,点此下载。

下载模板

为了方便开发者能够快速搭建好一个可用于开发、调试、测试的自定义组件包项目,官方提供了一个项目模板,下载使用模板的方式有三种:

  • 直接从 github 上下载 zip 文件并解压。
  • 直接将 github 上的仓库 clone 下来。
  • 使用官方提供的命令行工具初始化项目,下面会进行介绍。

项目模板中的构建是基于 gulp + webpack 来执行的,支持开发、构建、测试等命令,详情可参阅项目模板的 README.md 文件。

命令行工具

官方提供了命令行工具,用于快速初始化一个项目。执行如下命令安装命令行工具:

npm install -g @wechat-miniprogram/miniprogram-cli

然后新建一个空目录作为项目根目录,在此根目录下执行:

miniprogram init --type custom-component

命令执行完毕后会发现项目根目录下生成了许多文件,这是根据官方的项目模板生成的完整项目,之后开发者可直接在此之上进行开发修改。

命令行工具的更多用法可以查看 github 仓库上的 README.md 文件。

PS:第一次使用 miniprogram init 初始化项目会去 github 上拉取模板,因此需要保证网络畅通。

测试工具

针对自定义组件的单元测试,可参阅文档单元测试。

自定义组件示例

以下为官方提供的自定义组件,可以参考并使用:

  • weui-miniprogram
  • recycle-view

自定义组件扩展示例

以下为官方提供的自定义组件扩展,可以参考并使用:

  • computed

单元测试

在编写高质量的自定义组件过程中,单元测试是永远避不开的一个话题。完善的测试用例是提高自定义组件可用性的保证,同时测试代码覆盖率也是必不可少的一个环节。微信小程序从基础库版本 2.2.1 开始拥抱开源,支持使用 npm 安装自定义组件,那针对自定义组件的单元测试也是必须支持的。

以下就来介绍如何对自定义组件进行单元测试。

测试框架

现在市面上流行的测试框架均可使用,只要它能兼顾 nodejs 端和 dom 环境。因为我们需要依赖到 nodejs 的一些库来完善测试环境,同时 dom 环境也是必须的,因为我们需要建成完整的 dom 树结构,才能更好的模拟自定义组件的运行。例如可以选用 mocha + jsdom 的组合,亦可选用 jest,下述例子选用 jest 作为测试框架来说明。

自定义组件测试工具集

微信小程序的运行环境比较特殊,不同于常见的浏览器环境,它采用的是双线程的架构。而在进行单元测试时,我们并不需要用到这样复杂的架构带来的利好,我们进行的是功能测试而无需苛求性能、安全等因素,因此我们提供了一个测试工具集以支持自定义组件在 nodejs 单线程中也能运行起来。

我们先安装一下测试工具集——miniprogram-simulate

npm i --save-dev miniprogram-simulate

编写测试用例

假设我们有如下自定义组件:

<!-- /components/index.wmxl -->
<view class="index">{{prop}}</view>
// /components/index.js
Component({
  properties: {
    prop: {
      type: String,
      value: 'index.properties'
    },
  },
})
/* /components/index.wxss */
.index {
  color: green;
}

我们想要测试渲染的结果,可以按照如下方式编写测试用例:

// /test/components/index.test.js
const simulate = require('miniprogram-simulate')

test('components/index', () => {
    const id = simulate.load('/components/index') // 此处必须传入绝对路径
    const comp = simulate.render(id) // 渲染成自定义组件树实例

    const parent = document.createElement('parent-wrapper') // 创建父亲节点
    comp.attach(parent) // attach 到父亲节点上,此时会触发自定义组件的 attached 钩子

    const view = comp.querySelector('.index') // 获取子组件 view
    expect(view.dom.innerHTML).toBe('index.properties') // 测试渲染结果
    expect(window.getComputedStyle(view.dom).color).toBe('green') // 测试渲染结果
})

PS:测试工具集中的 wx 对象和内置组件都不会实现真正的功能,如果需要测试一些特殊场景的话,可以自行覆盖掉测试工具集中的 api 接口和内置组件。

PS:目前因为有部分自定义组件功能仍未支持(如抽象节点等),故测试工具暂无法全部覆盖自定义组件的特性,后续会继续完善。

测试工具集中提供了一些方便测试的接口,比如:

  • 模拟 touch 事件、自定义事件触发
  • 选取子节点
  • 更新自定义组件数据
  • 触发生命周期

更多详细的用法可以参阅 github 仓库上的文档。

获取更新性能统计信息

基础库 2.12.0 开始支持,低版本需做兼容处理。

如果想要知道 setData 引发界面更新的开销,可以使用更新性能统计信息接口。它将返回每次更新中主要更新步骤发生的时间戳,可以用来大体上估计自定义组件(或页面)更新性能。例如:

Component({
  attached() { // 调用时机不能早于 attached
    this.setUpdatePerformanceListener({withDataPaths: true}, (res) => {
      console.log(res)
    })
  }
})

setUpdatePerformanceListener 方法接受一个 options 对象和回调函数 listener 作为参数。

其中, options 对象包含以下字段:

字段 类型 说明
withDataPaths Boolean 是否返回变更的 data 字段信息

listeners 返回携带一个 res 对象,表示一次由 setData 引发的 更新过程 。根据 setData 调用时机的不同,更新过程大体可以分为三类:

  1. 基本更新 ,它有一个唯一的 updateProcessId
  2. 子更新 ,它是另一个基本更新的一个子步骤,也有唯一的 updateProcessId ,但还有一个 parentUpdateProcessId
  3. 被合并更新 ,它被合并到了另一个基本更新或子更新过程中,无法被独立统计。

每次成功的 setData 调用都会产生一个更新过程,使得 listener 回调一次。不过 setData 究竟触发了哪类更新过程很难判断,更新性能好坏与其具体是哪类更新也没有必然联系,只是它们的返回值参数有所不同。

res 中包含以下字段:

字段 类型 说明
updateProcessId Number 此次更新过程的 ID
parentUpdateProcessId Number 对于子更新,返回它所属的更新过程 ID
isMergedUpdate Boolean 是否是被合并更新,如果是,则 updateProcessId 表示被合并到的更新过程 ID
dataPaths Array 此次更新的 data 字段信息,只有 withDataPaths 设为 true 时才会返回
pendingStartTimestamp Number 此次更新进入等待队列时的时间戳
updateStartTimestamp Number 更新运算开始时的时间戳
updateEndTimestamp Number 更新运算结束时的时间戳

说明:

  • setUpdatePerformanceListener 只会激活当前组件或页面的统计, parentUpdateProcessId 有可能是其他组件或者页面的更新过程 ID 而未被统计回调,如果想要知道页面内所有的更新过程,需要在所有组件中都调用 setUpdatePerformanceListener
  • 统计本身有一点点开销,如果想要禁用统计,调用 setUpdatePerformanceListener 时传入第二个参数 listenernull 即可。

占位组件

基础库 2.11.2 及以上版本支持,2.11.2 以下和未配置的效果相同

在使用如 分包异步化 或 用时注入 等特性时,自定义组件所引用的其他自定义组件,在刚开始进行渲染时可能处于不可用的状态。此时,为了使渲染过程不被阻塞,不可用的自定义组件需要一个 「占位组件」(Component placeholder)。基础库会用占位组件替代不可用组件进行渲染,在该组件可用后再将占位组件替换回该组件。

一个自定义组件的占位组件可以是另一个自定义组件、或一个内置组件。

配置

页面或自定义组件对应的 JSON 配置中的 componentPlaceholder 字段用于指定占位组件,如:

{
  "usingComponents": {
    "comp-a": "../comp/compA",
    "comp-b": "../comp/compB",
    "comp-c": "../comp/compC"
  },
  "componentPlaceholder": {
    "comp-a": "view",
    "comp-b": "comp-c"
  }
}

该配置表示:

  • 组件 comp-a 的占位组件为内置组件 view
  • 组件 comp-b 的占位组件为自定义组件 comp-c(其路径在 usingComponents 中配置)

假设该配置对应的模板如下:

<button ontap="onTap">显示组件</button>
<comp-a wx-if="{{ visible }}">
  <comp-b prop="{{ p }}">text in slot</comp-b>
</comp-a>

微信小程序启动时 visiblefalse,那么只有 button 会被渲染;点击按钮后,this.setData({ visible: true }) 被执行,此时如果 comp-a, comp-b 均不可用,则页面将被渲染为:

<button>显示组件</button>
<view>
  <comp-c prop="{{ p }}">text in slot</comp-c>
</view>

comp-acomp-b 准备完成后,页面被替换为:

<button>显示组件</button>
<comp-a>
  <comp-b prop="{{ p }}">text in slot</comp-b>
</comp-a>

注意事项

  1. 当一个组件被指定为占位组件时(如上例中的 comp-c),为其指定占位组件是无效的。可以理解为如果一个组件需要作为其他组件的占位组件,则它必须在一开始就是可用的;
  2. 目前自定义组件不可用的情况包括:
    • 使用分包异步化特性的情况下,引用了其他分包的组件,而对应分包还未下载;
    • 使用用时注入特性的情况下,该组件还未注入;
  3. 如果一个组件不可用,且其占位组件不存在,则渲染时会报错并抛出;
  4. 如果一个组件不存在,但为其指定了可用的占位组件,则占位组件可以被正常渲染,但后续尝试准备替换时会报错并抛出。

附:有占位组件参与的渲染流程

基础库尝试渲染一个组件时,会首先递归检查 usingComponents,收集其将使用到的所有组件的信息;在这个过程中,如果某个被使用到的组件不可用,基础库会先检查其是否有对应的占位组件。如果没有,基础库会中断渲染并抛出错误;如果有,则会标记并在后续渲染流程中使用占位组件替换该不可用的组件进行渲染。不可用的组件会在当前渲染流程结束后尝试准备(下载分包或注入代码等);等到准备过程完成后,再尝试渲染该组件(实际上也是在执行这个流程),并替换掉之前渲染的占位组件。