微信小程序自定义组件

前置

微信小程序官方文档自定义组件章节内容比较杂,以下是我的学习记录。

小程序自定义组件

原生微信小程序支持简洁的组件化编程,通过组件化编程可以:

  • 将页面内的功能模块抽象成自定义组件,以便在不同的页面中重复使用
  • 将复杂的页面拆分成多个低耦合的模块,有助于代码维护
  • 发布第三方原生小程序自定义组件或组件扩展,壮大社区

快速开始

创建组件

一个组件包含四个文件,分别是 json、wxml、wxss、js。

  • 在 Com.json 中将 component 字段设为 true

    Comp.json

    {
      "component": true
    }
    
  • 在 wxml 文件中编写组件模板,在 wxss 文件中加入组件样式

    Comp.wxml

    <view class="title"> {{title}} </view>
    

    Comp.wxss

    .title {
      color: green;
    }
    
  • 编写自定义组件的 js 文件

    在自定义组件的 js 文件中,需要使用 Component 方法注册组件。

    Comp.js

    Component ({
      data: {
        title: "Hi",
      },
    });
    

使用组件

使用已注册的自定义组件前,首先要在页面的 json 文件中进行引用声明。此时需要提供自定义组件的标签名和对应的自定义组件文件路径:

page.json

{
  "usingComponents": {
    "component-tag-name": "path/to/the/custom/component"
  }
}

page.wxml

<component-tag-name></component-tag-name>

组件样式

:host 选择器

组件可以指定它所在节点的默认样式,使用 :host 选择器。

Comp.wxss

:host {
  color: yellow;
}

组件样式隔离

默认情况下,自定义组件的样式只受到自定义组件 wxss 的影响。app.wxss 或页面的 wxss 中使用了标签名选择器(或一些其他特殊选择器)来直接指定样式,这些选择器会影响到页面和全部组件。通常情况下这是不推荐的做法。通过 styleIsolation 指定样式隔离。

Comp.js

Component ({
  options: {
    styleIsolation: "isolated",
  },
});

styleIsolation 支持以下取值:

  • isolated 表示启用样式隔离,在自定义组件内外,使用 class 指定的样式将不会相互影响(一般情况下的默认值);
  • apply-shared 表示页面 wxss 样式将影响到自定义组件,但自定义组件 wxss 中指定的样式不会影响页面;
  • shared 表示页面 wxss 样式将影响到自定义组件,自定义组件 wxss 中指定的样式也会影响页面和其他设置了 apply-shared 或 shared 的自定义组件。(这个选项在插件中不可用。)

如果这个 Component 构造器用于构造页面 ,则默认值为 shared ,且还有以下选项可用:

  • page-isolated 表示在这个页面禁用 app.wxss ,同时,页面的 wxss 不会影响到其他自定义组件;
  • page-apply-shared 表示在这个页面禁用 app.wxss ,同时,页面 wxss 样式不会影响到其他自定义组件,但设为 shared 的自定义组件会影响到页面;
  • page-shared 表示在这个页面禁用 app.wxss ,同时,页面 wxss 样式会影响到其他设为 apply-shared 或 shared 的自定义组件,也会受到设为 shared 的自定义组件的影响。

也可以在页面或自定义组件的 json 文件中配置 styleIsolation (这样就不需在 js 文件的 options 中再配置)。例如:

Comp.json

{
  "styleIsolation": "isolated"
}

在 Component 的 options 中设置 addGlobalClass: true。 这个选项等价于设置 styleIsolation: apply-shared ,但设置了 styleIsolation 选项后这个选项会失效。

外部样式类

有时,组件希望接受外部传入的样式类。此时可以在 Component 中用 externalClasses 定义段定义若干个外部样式类。

类型:Array

注意:在同一个节点上使用普通样式类和外部样式类时,两个类的优先级是未定义的,因此最好避免这种情况。

Comp.js

Component ({
  externalClasses: ["custom-class"],
});

Comp.wxml

<view class="custom-class"></view>

也可以指定多个对应的 class。

page.wxml

<custom-component custom-class="red-text large-text" />

引用页面或父组件的样式

即使启用了样式隔离 isolated ,组件仍然可以在局部引用组件所在页面的样式或父组件的样式。如果在页面 wxss 中定义了:

.blue-text {
  color: blue;
}

在这个组件中可以使用 ~ 来引用这个类的样式:

<view class="~blue-text"> 这段文本是蓝色的 </view>

如果在一个组件的父组件 wxss 中定义了:

.red-text {
  color: red;
}

在这个组件中可以使用 ^ 来引用这个类的样式:

<view class="^red-text"> 这段文本是红色的 </view>

也可以连续使用多个 ^ 来引用祖先组件中的样式。

注意事项

组件对应 wxss 文件的样式,只对组件 wxml 内的节点生效。编写组件样式时,需要注意以下几点:

  • 如果组件是比较独立、通用的组件,请优先使用外部样式类的方式,而非直接引用父组件或页面的样式。

  • 组件和引用组件的页面不能使用 id 选择器(#a)、属性选择器([a])和标签名选择器,请改用 class 选择器。

    #a {
      /* 在组件中不能使用 */
    } 
    [a] {
       /* 在组件中不能使用 */
    }
    button {
      /* 在组件中不能使用 */
    } 
    
  • 组件和引用组件的页面中使用后代选择器(.a .b)在一些极端情况下会有非预期的表现,如遇,请避免使用。

  • 子元素选择器(.a>.b)只能用于 view 组件与其子节点之间,用于其他组件可能导致非预期的情况。

    .a > .b {
      /* 除非 .a 是 view 组件节点,否则不一定会生效 */
    } 
    
  • 继承样式,如 font 、 color ,会从组件外继承到组件内。

  • 除继承样式外, app.wxss 中的样式、组件所在页面的的样式对自定义组件无效(除非更改组件样式隔离选项)。

通用属性、方法

生成的组件实例可以在组件的方法、生命周期函数和属性 observer 中通过 this 访问。组件包含一些通用属性和方法。

通用属性

  • is [String] 组件的文件路径
  • id [String] 节点 id
  • dataset [String] 节点 dataset
  • data [Object] 组件数据,包括内部数据和属性值
  • properties [Object] 组件数据,包括内部数据和属性值(与 data 一致)
  • router [Object] 相对于当前自定义组件的 Router 对象
  • pageRouter [Object] 相对于当前自定义组件所在页面的 Router 对象
Component({
  created () {
    console.log(this.is); // components/my-component
  },
})

通用方法

  • setData [Object newData] 设置 data 并执行视图层渲染
  • hasBehavior [Object behavior] 检查组件是否具有 behavior (检查时会递归检查被直接或间接引入的所有 behavior)
  • triggerEvent [String name], [Object detail], [Object options] 触发事件,参见 组件间通信与事件
  • createSelectorQuery 创建一个 SelectorQuery 对象,选择器选取范围为这个组件实例内
  • createIntersectionObserver 创建一个 IntersectionObserver 对象,选择器选取范围为这个组件实例内
  • createMediaQueryObserver 创建一个 MediaQueryObserver 对象
  • selectComponent [String selector] 使用选择器选择组件实例节点,返回匹配到的第一个组件实例对象(会被 wx://component-export 影响)
  • selectAllComponents [String selector] 使用选择器选择组件实例节点,返回匹配到的全部组件实例对象组成的数组(会被 wx://component-export 影响)
  • selectOwnerComponent 选取当前组件节点所在的组件实例(即组件的引用者),返回它的组件实例对象(会被 wx://component-export 影响)
  • getRelationNodes [String relationKey] 获取这个关系所对应的所有关联节点,参见 组件间关系
  • groupSetData [Function callback] 立刻执行 callback ,其中的多个 setData 之间不会触发界面绘制(只有某些特殊场景中需要,如用于在不同组件同时 setData 时进行界面绘制同步)
  • getTabBar 返回当前页面的 custom-tab-bar 的组件实例,详见自定义 tabBar
  • getPageId 返回页面标识符(一个字符串),可以用来判断几个自定义组件实例是不是在同一个页面内
  • animate [String selector], [Array keyframes], [Number duration], [Function callback] 执行关键帧动画
  • clearAnimation [String selector], [Object options], [Function callback] 清除关键帧动画
  • setUpdatePerformanceListener [Object options], [Function listener] 清除关键帧动画

Data

描述:组件的内部数据,和 properties 一同用于组件的模板渲染
类型:Object
是否必填:否

Component ({
  data: {
    title: "Hi",
  },
});
<view>{{title}}</view>

注意事项:

  • 使用 this.data 可以获取内部数据和属性值;但直接修改它不会将变更应用到界面上,应使用 setData 修改
  • 属性名应避免以 data 开头,即不要命名成 dataXyz 这样的形式,因为在 WXML 中, data-xyz="" 会被作为节点 dataset 来处理,而不是组件属性
  • data 和 组件的属性名字段相互间都不能冲突

纯数据字段

有些情况下,某些 data 中的字段(包括 setData 设置的字段)既不会展示在界面上,也不会传递给其他组件,仅仅在当前组件内部使用。此时,可以指定这样的数据字段为 “纯数据字段”,它们将仅仅被记录在 this.data 中,而不参与任何界面渲染过程,这样有助于提升页面更新性能。指定 “纯数据字段” 的方法是在 Component 构造器的 options 定义段中指定 pureDataPattern 为一个正则表达式,字段名符合这个正则表达式的字段将成为纯数据字段。

Component ({
  options: {
    pureDataPattern: /^_/ // 指定所有 _ 开头的数据字段为纯数据字段
  },
  data: {
    a: true, // 普通数据字段
    _b: true, // 纯数据字段
  },
  methods: {
    myMethod () {
      this.data._b// 纯数据字段可以在 this.data 中获取
      this.setData ({
        c: true, // 普通数据字段
        _d: true, // 纯数据字段
      })
    }
  }
})

上述组件中的纯数据字段不会被应用到 WXML 上:

<view wx:if="{{a}}"> 这行会被展示 </view>
<view wx:if="{{_b}}"> 这行不会被展示 </view>

Methods

描述:组件的方法,包括事件响应函数和任意的自定义方法
类型:Object
是否必填:否

Observers

描述:组件数据字段监听器,用于监听 properties 和 data 的变化。
类型:Object
是否必填:否

Component ({
  attached () {
    this.setData ({
      numberA: 1,
      numberB: 2,
    });
  },
  observers: {
    "numberA, numberB"(numberA, numberB) {
      // 在 numberA 或者 numberB 被设置时,执行这个函数
      this.setData ({
        sum: numberA + numberB,
      });
    },
  },
});

数据监听器支持监听属性或内部数据的变化,可以同时监听多个。一次 setData 最多触发每个监听器一次。同时,监听器可以监听子数据字段。

Component ({
  observers: {
    "some.subfield": function (subfield) {
      // 使用 setData 设置 this.data.some.subfield 时触发
      // 使用 setData 设置 this.data.some 也会触发
      subfield === this.data.some.subfield;
    },
    "arr [12]": function (arr12) {
      // 使用 setData 设置 this.data.arr [12] 时触发
      // 使用 setData 设置 this.data.arr 也会触发
      arr12 === this.data.arr [12];
    },
  },
});

如果需要监听所有子数据字段的变化,可以使用通配符 **

Component ({
  observers: {
    "some.field.**"(field) {
      // 使用 setData 设置 this.data.some.field 本身或其下任何子数据字段时触发
      // 使用 setData 设置 this.data.some 也会触发
      field === this.data.some.field;
    },
  },
  attached () {
    // 会触发上面的 observer
    this.setData ({
      "some.field": {
        /* ... */
      },
    });
    // 会触发上面的 observer
    this.setData ({
      "some.field.xxx": {
        /* ... */
      },
    });
    // 会触发上面的 observer
    this.setData ({
      some: {
        /* ... */
      },
    });
  },
});

特别地,仅使用通配符 \*\* 可以监听全部 setData 。

Component ({
  observers: {
    "**" () {
      // 每次 setData 都触发
    },
  },
});

注意事项

  • 数据监听器监听的是 setData 涉及到的数据字段,即使这些数据字段的值没有发生变化,数据监听器依然会被触发。
  • 如果在数据监听器函数中使用 setData 设置本身监听的数据字段,可能会导致死循环,需要特别留意。
  • 数据监听器和属性的 observer 相比,数据监听器更强大且通常具有更好的性能。

Options

描述:组件的选项,选项中可以声明该组件是否支持多 slot 等
类型:Object Map
是否必填:否

Behaviors

behaviors 是用于组件间代码共享的特性,类似于一些编程语言中的 “mixins” 或 “traits”。

类型:Array
是否必填:否

每个 behavior 可以包含一组属性、数据、生命周期函数和方法。组件引用它时,它的属性、数据和方法会被合并到组件中,生命周期函数也会在对应时机被调用。 每个组件可以引用多个 behavior ,behavior 也可以引用其它 behavior 。

定义段 类型 是否必填 描述
properties Object Map 同组件的属性
data Object 同组件的数据
methods Object 同自定义组件的方法
behaviors String Array 引入其它的 behavior
created Function 生命周期函数
attached Function 生命周期函数
ready Function 生命周期函数
moved Function 生命周期函数
detached Function 生命周期函数

自定义 Behaviors

my-behavior.js

module.exports = Behavior ({
  behaviors: [],
  properties: {
    myBehaviorProperty: {
      type: String
    }
  },
  data: {
    myBehaviorData: 'my-behavior-data'
  },
  created () {
    console.log ('[my-behavior] created')
  },
  attached () {
    console.log ('[my-behavior] attached')
  },
  ready () {
    console.log ('[my-behavior] ready')
  },

  methods: {
    myBehaviorMethod () {
      console.log ('[my-behavior] log by myBehaviorMehtod')
    },
  }
})

组件引用时,在 behaviors 定义段中将它们逐个列出即可。

my-component.js

const myBehavior = require ('my-behavior')

Component ({
  behaviors: [myBehavior],
  properties: {
    myProperty: {
      type: String
    }
  },
  data: {
    myData: 'my-component-data'
  },
  created () {
    console.log ('[my-component] created')
  },
  attached () { 
    console.log ('[my-component] attached')
  },
  ready () {
    console.log ('[my-component] ready')
  },
  methods: {
    myMethod () {
      console.log ('[my-component] log by myMethod')
    },
  }
})

在上例中, my-component 组件定义中加入了 my-behavior

my-behavior 结构为:

  • 属性:myBehaviorProperty
  • 数据字段:myBehaviorData
  • 方法:myBehaviorMethod
  • 生命周期函数:attachedcreatedready

这将使 my-component 最终结构为:

  • 属性:myBehaviorPropertymyProperty
  • 数据字段:myBehaviorDatamyData
  • 方法:myBehaviorMethodmyMethod
  • 生命周期函数:attachedcreatedready

当组件触发生命周期时,上例生命周期函数执行顺序为:

  1. [my-behavior] created
  2. [my-component] created
  3. [my-behavior] attached
  4. [my-component] attached
  5. [my-behavior] ready
  6. [my-component] ready

同名字段的覆盖和组合规则

组件和它引用的 behavior 中可以包含同名的字段,对这些字段的处理方法如下:

  • 如果有同名的属性 (properties) 或方法 (methods):

    1. 若组件本身有这个属性或方法,则组件的属性或方法会覆盖 behavior 中的同名属性或方法;
    2. 若组件本身无这个属性或方法,则在组件的 behaviors 字段中定义靠后的 behavior 的属性或方法会覆盖靠前的同名属性或方法;
    3. 在 2 的基础上,若存在嵌套引用 behavior 的情况,则规则为:父 behavior 覆盖 子 behavior 中的同名属性或方法。
  • 如果有同名的数据字段 (data):

    • 若同名的数据字段都是对象类型,会进行对象合并;
    • 其余情况会进行数据覆盖,覆盖规则为:组件 > 父 behavior > 子 behavior靠后的 behavior > 靠前的 behavior。(优先级高的覆盖优先级低的,最大的为优先级最高)
  • 生命周期函数不会相互覆盖,而是在对应触发时机被逐个调用:

    • 对于不同的生命周期函数之间,遵循组件生命周期函数的执行顺序;
    • 对于同种生命周期函数,遵循如下规则:
      • behavior 优先于组件执行;
      • 子 behavior 优先于 父 behavior 执行;
      • 靠前的 behavior 优先于 靠后的 behavior 执行;
    • 如果同一个 behavior 被一个组件多次引用,它定义的生命周期函数只会被执行一次。

内置 Behaviors

自定义组件可以通过引用内置的 behavior 来获得内置组件的一些行为。

Component ({
  behaviors: ['wx://form-field']
})

在上例中, wx://form-field 代表一个内置 behavior ,它使得这个自定义组件有类似于表单控件的行为。

内置 behavior 往往会为组件添加一些属性。在没有特殊说明时,组件可以覆盖这些属性来改变它的 type 或添加 observer

  1. wx://form-field
    使自定义组件有类似于表单控件的行为。 form 组件可以识别这些自定义组件,并在 submit 事件中返回组件的字段名及其对应字段值。
  2. wx://form-field-group
    使 form 组件可以识别到这个自定义组件内部的所有表单控件。
  3. wx://form-field-button
    使 form 组件可以识别到这个自定义组件内部的 button 。如果自定义组件内部有设置了 form-type 的 button ,它将被组件外的 form 接受。
  4. wx://component-export
    使自定义组件支持 export 定义段。这个定义段可以用于指定组件被 selectComponent 调用时的返回值。

生命周期

组件的生命周期,指的是组件自身的一些函数,这些函数在特殊的时间点或遇到一些特殊的框架事件时被自动触发。

生命周期方法可以直接定义在 Component 构造器的第一级参数中。组件的的生命周期也可以在 lifetimes 字段内进行声明(这是推荐的方式,其优先级最高)。

生命周期函数无法在组件方法中通过 this 访问到。

组件生命周期

Component ({
  lifetimes: {
    attached () {
      // 在组件实例进入页面节点树时执行
    },
    detached () {
      // 在组件实例被从页面节点树移除时执行
    },
  },
  // 以下是旧式的定义方式,可以保持对 <2.2.3 版本基础库的兼容
  attached () {
    // 在组件实例进入页面节点树时执行
  },
  detached () {
    // 在组件实例被从页面节点树移除时执行
  },
  //...
})

在 behaviors 中也可以编写生命周期方法,同时不会与其他 behaviors 中的同名生命周期相互覆盖。但要注意,如果一个组件多次直接或间接引用同一个 behavior ,这个 behavior 中的生命周期函数在一个执行时机内只会执行一次。

  • Lifetimes
    描述:组件生命周期声明对象
    类型:Object
    是否必填:否
  • Created
    描述:此时,组件数据 this.data 就是在 Component 构造器中定义的数据 data 。 此时还不能调用 setData 。通常情况下,这个生命周期只应该用于给组件 this 添加一些自定义属性字段。
    类型:Function
    是否必填:否
  • Attached
    描述:在组件完全初始化完毕、进入页面节点树后, attached 生命周期被触发。此时, this.data 已被初始化为组件的当前值。这个生命周期很有用,绝大多数初始化工作可以在这个时机进行。
    类型:Function
    是否必填:否
  • Ready
    描述:在组件布局完成后执行
    类型:Function
    是否必填:否
  • Moved
    描述:在组件实例被移动到节点树另一个位置时执行
    类型:Function
    是否必填:否
  • Detached
    描述:在组件离开页面节点树后, detached 生命周期被触发。退出一个页面时,如果组件还在页面节点树中,则 detached 会被触发。
    类型:Function
    是否必填:否
  • Error
    描述:每当组件方法抛出错误时执行
    类型:Object Error
    是否必填:否

组件所在页面的生命周期

还有一些特殊的生命周期,它们并非与组件有很强的关联,但有时组件需要获知,以便组件内部处理。这样的生命周期称为 “组件所在页面的生命周期”,在 pageLifetimes 定义段中定义。

类型:Object
是否必填:否

其中可用的生命周期包括:

生命周期 参数 描述
show 组件所在的页面被展示时执行
hide 组件所在的页面被隐藏时执行
resize Object Size 组件所在的页面尺寸变化时执行
Component ({
  pageLifetimes: {
    show () {
      // 页面被展示
    },
    hide () {
      // 页面被隐藏
    },
    resize (size) {
      // 页面尺寸变化
    }
  }
})

Relations 组件间关系

描述:组件间关系定义
类型:Object
是否必填:否

relations 定义段:

选项 类型 是否必填 描述
type String 目标组件的相对关系,可选的值为 parentchildancestordescendant
linked Function 关系生命周期函数,当关系被建立在页面节点树中时触发,触发时机在组件 attached 生命周期之后
linkChanged Function 关系生命周期函数,当关系在页面节点树中发生改变时触发,触发时机在组件 moved 生命周期之后
unlinked Function 关系生命周期函数,当关系脱离页面节点树时触发,触发时机在组件 detached 生命周期之后
target String 如果这一项被设置,则它表示关联的目标节点所应具有的 behavior,所有拥有这一 behavior 的组件节点都会被关联

基础示例

有时需要实现这样的组件:

<custom-ul>
  <custom-li> item 1 </custom-li>
  <custom-li> item 2 </custom-li>
</custom-ul>

这个例子中, custom-ulcustom-li 都是自定义组件,它们有相互间的关系,相互间的通信往往比较复杂。此时在组件定义时加入 relations 定义段,可以解决这样的问题。

path/to/custom-ul.js

Component ({
  relations: {
    './custom-li': {
      type: 'child', // 关联的目标节点应为子节点
      linked (target) {
        // 每次有 custom-li 被插入时执行,target 是该节点实例对象,触发在该节点 attached 生命周期之后
      },
      linkChanged (target) {
        // 每次有 custom-li 被移动后执行,target 是该节点实例对象,触发在该节点 moved 生命周期之后
      },
      unlinked (target) {
        // 每次有 custom-li 被移除时执行,target 是该节点实例对象,触发在该节点 detached 生命周期之后
      }
    }
  },
  methods: {
    _getAllLi (){
      // 使用 getRelationNodes 可以获得 nodes 数组,包含所有已关联的 custom-li,且是有序的
      const nodes = this.getRelationNodes ('path/to/custom-li')
    }
  },
  ready (){
    this._getAllLi ()
  }
})

path/to/custom-li.js

Component ({
  relations: {
    './custom-ul': {
      type: 'parent', // 关联的目标节点应为父节点
      linked (target) {
        // 每次被插入到 custom-ul 时执行,target 是 custom-ul 节点实例对象,触发在 attached 生命周期之后
      },
      linkChanged (target) {
        // 每次被移动后执行,target 是 custom-ul 节点实例对象,触发在 moved 生命周期之后
      },
      unlinked (target) {
        // 每次被移除时执行,target 是 custom-ul 节点实例对象,触发在 detached 生命周期之后
      }
    }
  }
})

注意:必须在两个组件定义中都加入 relations 定义,否则不会生效。

关联一类组件

有时,需要关联的是一类组件,如:

<custom-form>
  <view>
    input
    <custom-input></custom-input>
  </view>
  <custom-submit> submit </custom-submit>
</custom-form>

custom-form 组件想要关联 custom-inputcustom-submit 两个组件。此时,如果这两个组件都有同一个 behavior:

custom-form-controls.js

module.exports = Behavior ({})

custom-input.js

const customFormControls = require ('./custom-form-controls')
Component ({
  behaviors: [customFormControls],
  relations: {
    './custom-form': {
      type: 'ancestor', // 关联的目标节点应为祖先节点
    }
  }
})

custom-submit.js

const customFormControls = require ('./custom-form-controls')
Component ({
  behaviors: [customFormControls],
  relations: {
    './custom-form': {
      type: 'ancestor', // 关联的目标节点应为祖先节点
    }
  }
})

则在 relations 关系定义中,可使用这个 behavior 来代替组件路径作为关联的目标节点:

custom-form.js

const customFormControls = require ('./custom-form-controls')
Component ({
  relations: {
    'customFormControls': {
      type: 'descendant', // 关联的目标节点应为子孙节点
      target: customFormControls
    }
  }
})

组件间通信

  1. 通过 WXML 数据绑定 Properties。用于父组件向子组件的指定属性设置数据,仅能设置 JSON 兼容数据(还可以在数据中包含函数)。
  2. 通过 Properties 传递事件。用于子组件向父组件传递数据,可以传递任意数据。
  3. 通过 this.selectComponent 方法获取子组件实例对象,可以直接访问组件的任意数据和方法。

Properties

描述:properties 是组件的对外属性,是属性名到属性设置的映射表。
类型:Object Map
是否必填:否

Comp.js

Component ({
  properties: {
    title: {
      type: String,
      value: "",
    },
    //title: '', // 简化的方式
  },
});

Comp.wxml

<view>{{title}}</view>

page.wxml

<comp title="Hi"></comp>

如果 properties 中的属性值是一个对象,即不采用简写的形式,它有以下属性可选

  • type 属性的类型(必填)
  • value 属性的初始值
  • optionalTypes [Array] 属性的类型(可以指定多个)
  • observer [Function] 属性值变化时的回调函数(不推荐,有 bug,使用 Component 构造器的 observers 字段代替)

注意:

  • 在 properties 定义段中,属性名采用驼峰写法(propertyName);在 wxml 中,指定属性值时则对应使用连字符写法(component-tag-name property-name="attr value"),应用于数据绑定时采用驼峰写法。
  • 属性名应避免以 data 开头,即不要命名成 dataXyz 这样的形式,因为在 WXML 中, data-xyz="" 会被作为节点 dataset 来处理,而不是组件属性。
  • 组件的属性名和 data 字段相互间不能冲突

纯数据字段

属性也可以被指定为纯数据字段(遵循 pureDataPattern 的正则表达式)。属性中的纯数据字段可以像普通属性一样接收外部传入的属性值,但不能将它直接用于组件自身的 WXML 中。

Component ({
  options: {
    pureDataPattern: /^_/
  },
  properties: {
    a: Boolean,
    _b: {
      type: Boolean,
      observer () {
        // 不要这样做!这个 observer 永远不会被触发
      }
    },
  }
})

注意:属性中的纯数据字段的属性 observer 永远不会触发!如果想要监听属性值变化,使用 数据监听器 代替。

也可以在页面或自定义组件的 json 文件中配置 pureDataPattern (这样就不需在 js 文件的 options 中再配置)。此时,其值应当写成字符串形式:

{
  "pureDataPattern": "^_"
}

数据监听器(Observers)可以用于监听纯数据字段(与普通数据字段一样)。这样,可以通过监听、响应纯数据字段的变化来改变界面。

示例

下面的示例是一个将 JavaScript 时间戳转换为可读时间的自定义组件。

Component ({
  options: {
    pureDataPattern: /^timestamp$/// 将 timestamp 属性指定为纯数据字段
  },
  properties: {
    timestamp: Number,
  },
  observers: {
    timestamp () {
      //timestamp 被设置时,将它展示为可读时间字符串
      const timeString = new Date (this.data.timestamp).toLocaleString ()
      this.setData ({
        timeString: timeString
      })
    }
  }
})
<view>{{timeString}}</view>

triggerEvent

自定义组件可以触发任意的事件,引用组件的页面可以监听这些事件。监听自定义组件事件的方法与监听基础组件事件的方法完全一致:

page.wxml

<!-- 当自定义组件触发 “myevent” 事件时,调用 “onMyEvent” 方法 -->
<component-tag-name bindmyevent="onMyEvent" />
<!-- 或者可以写成 -->
<component-tag-name bind:myevent="onMyEvent" />

page.js

Page ({
  onMyEvent: function (e) {
    e.detail; // 自定义组件触发事件时提供的 detail 对象
  },
});

自定义组件触发事件时,需要使用 triggerEvent 方法。

Comp.wxml

<button bindtap="onTap"> 点击这个按钮将触发 “myevent” 事件 </button>

Comp.js

Component ({
  properties: {},
  methods: {
    onTap () {
      this.triggerEvent ("myevent", {}, {});
    },
  },
});

triggerEvent 接收三个参数:

  1. 要触发的事件名
  2. detail 对象,提供给事件监听函数
  3. 触发事件的选项

触发事件的选项,包括:

  • bubbles [Boolean] 事件是否冒泡,默认为 false
  • composed [Boolean] 事件是否可以穿越组件边界,为 false 时,事件将只能在引用组件的节点树上触发,不进入其他任何组件内部,默认为 false
  • capturePhase [Boolean] 事件是否拥有捕获阶段,默认为 false

selectComponent

若需要自定义 selectComponent 返回的数据,可使用内置 behavior: wx://component-export,使用该 behavior 时,自定义组件中的 export 函数返回对象为父组件调用时 selectComponent 的返回值。

  1. 在子组件件中定义 behaviors、export
Component ({
  behaviors: ["wx://component-export"],
  export () {
    return { myField: "myValue" };
  },
});
  1. 使用组件时添加 id 属性

page.html

<my-component id="the-id" />

page.js

const child = this.selectComponent ('#the-id') // { myField: 'myValue' }

组件插槽

基础示例

在组件模板中可以提供一个 <slot> 节点,用于承载组件引用时提供的子节点。

Comp.wxml

<view class="wrapper">
  <view> 这里是组件的内部节点 </view>
  <slot></slot>
</view>

page.wxml

<component-tag-name>
  <view> 这里是插入到组件 slot 中的内容 </view>
</component-tag-name>

多 slot

在组件定义时的选项中启用多 slot 支持。

Comp.js

Component ({
  options: {
    multipleSlots: true,
  },
});

使用多个 slot ,以不同的 name 来区分。

Comp.wxml

<view class="wrapper">
  <slot name="before"></slot>
  <view > 这里是组件的内部细节 </view>
  <slot name="after"></slot>
</view>

使用时,用 slot 属性来将节点插入到不同的 slot 上。

page.wxml

<component-tag-name>
  <!-- 这部分内容将被放置在组件 <slot name="before"> 的位置上 -->
  <view slot="before"> 这里是插入到组件 slot name="before" 中的内容 </view>
  <!-- 这部分内容将被放置在组件 <slot name="after"> 的位置上 -->
  <view slot="after"> 这里是插入到组件 slot name="after" 中的内容 </view>
</component-tag-name>

虚拟化组件节点

默认情况下,自定义组件本身的那个节点是一个 “普通” 的节点,使用时可以在这个节点上设置 class style 、动画、 flex 布局等,就如同普通的 view 组件节点一样。

page.wxml

<view style="display: flex">
  <custom-component style="color: blue; flex: 1"> 蓝色、满宽的 </custom-component>
</view>

但有些时候,自定义组件并不希望这个节点本身可以设置样式、响应 flex 布局等,而是希望自定义组件内部的第一层节点能够响应 flex 布局或者样式由自定义组件本身决定。这种情况下,可以将这个自定义组件设置为 “虚拟的”:

Component ({
  options: {
    virtualHost: true,
  },
  properties: {
    style: {
      // 定义 style 属性可以拿到 style 属性上设置的值
      type: String,
    },
  },
  externalClasses: ["class"], // 可以将 class 设为 externalClasses
});

这样,可以将 flex 放入自定义组件内:

page.wxml

<view style="display: flex">
  <!-- 如果设置了 virtualHost ,节点上的样式将失效 -->
  <custom-component style="color: blue"> 不是蓝色的 </custom-component>
</view>

custom-component.wxml

<view style="flex: 1">
  满宽的
  <slot></slot>
</view>

需要注意的是,自定义组件节点上的 class style 和动画将不再生效,但仍可以:

  • 将 style 定义成 properties 属性来获取 style 上设置的值;
  • 将 class 定义成 externalClasses 外部样式类使得自定义组件 wxml 可以使用 class 值。

抽象节点

基础示例

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

例如,我们现在来实现一个 “选框组”(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 只能是静态值,不能包含数据绑定。因而抽象节点特性并不适用于动态决定节点名的场景。

占位组件

在使用如分包异步化或用时注入等特性时,自定义组件所引用的其他自定义组件,在刚开始进行渲染时可能处于不可用的状态。此时,为了使渲染过程不被阻塞,不可用的自定义组件需要一个 「占位组件」(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,收集其将使用到的所有组件的信息;在这个过程中,如果某个被使用到的组件不可用,基础库会先检查其是否有对应的占位组件。如果没有,基础库会中断渲染并抛出错误;如果有,则会标记并在后续渲染流程中使用占位组件替换该不可用的组件进行渲染。不可用的组件会在当前渲染流程结束后尝试准备(下载分包或注入代码等);等到准备过程完成后,再尝试渲染该组件(实际上也是在执行这个流程),并替换掉之前渲染的占位组件。

自定义组件扩展

基础示例

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 中,以达到计算属性的效果。

更新的性能信息

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

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 即可。

发布自定义组件

如果你想通过 NPM 发布一个自定义组件或自定义组件扩展,官方提供了一个 项目模板,项目模板中的构建是基于 gulp + webpack 来执行的,支持开发、构建、测试等命令。下载使用模板的方式有三种:

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

初始化项目

npm install -g @wechat-miniprogram/miniprogram-cli
miniprogram init --type custom-component

单元测试

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

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

  1. 安装测试工具集 miniprogram-simulate
npm i --save-dev miniprogram-simulate
  1. 编写测试用例:

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

<view class="index">{{prop}}</view>
Component ({
  properties: {
    prop: {
      type: String,
      value: 'index.properties'
    },
  },
})
.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') // 测试渲染结果
})

测试工具集中的 wx 对象和内置组件都不会实现真正的功能,如果需要测试一些特殊场景的话,可以自行覆盖掉测试工具集中的 api 接口和内置组件。目前因为有部分自定义组件功能仍未支持(如抽象节点等),故测试工具暂无法全部覆盖自定义组件的特性,后续会继续完善。

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

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

官方示例

自定义组件示例

自定义组件扩展示例

注意事项

一些需要注意的细节:

  • 因为 WXML 节点标签名只能是小写字母、中划线和下划线的组合,所以自定义组件的标签名也只能包含这些字符。
  • 自定义组件也是可以引用自定义组件的,引用方法类似于页面引用自定义组件的方式(使用 usingComponents 字段)。
  • 自定义组件和页面所在项目根目录名不能以 “wx-” 为前缀,否则会报错。

注意,是否在页面文件中使用 usingComponents 会使得页面的 this 对象的原型稍有差异,包括:

  • 使用 usingComponents 页面的原型与不使用时不一致,即 Object.getPrototypeOf (this) 结果不同。
  • 使用 usingComponents 时会多一些方法,如 selectComponent 。
  • 出于性能考虑,使用 usingComponents 时, setData 内容不会被直接深复制,即 this.setData ({ field: obj })this.data.field === obj。(深复制会在这个值被组件间传递时发生。)

如果页面比较复杂,新增或删除 usingComponents 定义段时建议重新测试一下。

参考资料

posted @ 2022-02-12 12:33  guangzan  阅读(1467)  评论(3编辑  收藏  举报