目录

9.Stencil的装饰器

我们在项目初始化过程中接触过 Stencil 组件的相关 demo,不记得的同学不用急,我们先放出一个示例 demo 来回忆一下:

export class MyComponent {
  @Prop() first: string;
  @Prop() middle: string;
  @Prop() last: string;
  @State() num: number = 0;
  private getText(): string {
    return format(this.first, this.middle, this.last);
  }

  render() {
    return <div><button onClick={() => this.num += 1}>add</button>Hello, World! I'm {this.getText()}, number is {this.num}</div>;
  }
}

可能会有同学有疑问了。@Prop()、@State() 是用来干嘛的,怎么在其它的技术框架没有看到类似的一些用法。这是 Stencil 组件class类中特有的一种装饰器 Decorators。首先我们来看下 Stencil 官网是怎么定义它的:

Decorators are a pure compiler-time construction used by stencil to collect all the metadata about a component, the properties, attributes and methods it might expose, the events it might emit or even the associated stylesheets. Once all the metadata has been collected, all the decorators are removed from the output, so they don’t incur any runtime overhead.

简单翻译来说就是:Stencil 的各种装饰器就是为了收集当前组件的一些特性和数据,是一个纯编译时的特性,也就是说在收集到各个特性后,会经过编译逻辑处理对应的元数据,但最终输出是没有这些特性的。

所以可以这么理解:装饰器就是一个特殊的代理,链接着我们自定义书写的元数据和 Stencil 的编译逻辑,也可以理解它为一个语法糖,让我们快速高效的使用 Stencil 封装好的特性。

装饰器的种类和作用

Stencil 提供的装饰器种类有8种,丰富了我们的所有使用场景,接下来我们来一一的了解下它们的特性和定位:

  1. @Component()

@Component() 是用来定义 Stencil 组件的装饰器,每个 Stencil 组件必须声明这个装饰器,一般存在于组件 class 上方,示例为:

@Component({
  tag: 'my-component',
  styleUrl: 'my-component.css',
  shadow: true,
})

@Component() 一般会带有 options 来声明一些组件的特性和配置。 必填的是 tag 字段,必须是一个由‘-’连接的字符串,用于声明当前组件应该匹配到那个 tag 标签下。其他字段可选,比较常见的有:

  • shadow 当前组件是否启用 shadow-dom。
  • styleUrl 当前组件引用的 css 文件路径。
  • … 其他字段查阅 Stencil 装饰器官方文档

可以看出 @Component 装饰器是用来声明当前 class 是一个 Stencil 组件的关键装饰器,只有声明后,编译脚本才会正确识别编译,是一个必需的装饰器。

  1. @Prop()

@Prop() 是用来定义声明当前 Stencil 组件 element 元素上的属性和继承数据。它可以自动解析出这些数据,并声明成当前组件 class 的一个变量,允许我们开发使用,其实类似于 Vue 和 React。它的作用就是用来获取继承的数据并加以使用。比如我们示例的 demo 里面:

export class MyComponent {
  @Prop() first: string;
  @Prop() middle: string;
  @Prop() last: string; 

  private getText(): string {
    return format(this.first, this.middle, this.last);
  }

  render() {
    return <div>World! I'm {this.getText()}</div>;
  }
}


// index.html
<my-component first="Stencil" last="'Don't call me a framework' JS"></my-component>

如上:我们在组件里面声明了三个属性 first、middle、last,在我们组件初始化的过程中,会把 <my-componen``> 标签的三个 attr 数据与组件 @Prop() 声明的变量进行一一对应,所以在我们 class 里面就可以通过 this 访问到这三个变量。通过 render 函数,就可以在页面上渲染出完整的 div。

并且,@Prop() 支持的类型包含布尔、字符串、number,甚至是对象和数组。并且我们可以通过 @Prop() aString = 'defaultValue' 这种形式来定义默认值,或者通过 @Prop() thingToDo!: string 这种形式来声明我们的属性是必需的。

@Prop() 也可以携带配置来进行对相关 props 的预处理。例如:

// 把 tag 里面的 complete 映射到组件里的 isComplete
@Prop({ attribute: 'complete' }) isComplete: boolean;

// 声明当前 props 不可变更
@Prop({ mutable: true }) thingToDo: string;

// reflect 可以决定当前的属性是不是在 tag 上可访问
@Prop({ reflect: true }) timesCompletedInPast: number = 2;

可以看出 @Prop() 是数据传输的关键,也是组件传参的最核心的部分。

  1. @State()

@State() 是用来管理组件的内部数据,在 Stencil 组件 class 里,它类似 private 的特性,所以在组件外部是无法进行更改和访问的,但是在组内部是可以的。而且它还关系到了组件的状态渲染,只要@State() 声明的属性有值的变更,就会触发组件的 render 函数最终导致重新渲染。

@State() num: number = 0;
....

render() {
    return <div><button onClick={() => this.num += 1}>add</button>Hello, World! I'm {this.getText()}, number is {this.num}</div>;

  }

如上示例。在我们声明 num 为一个 State 的时候,在我们触发 button,导致 num 的值+1,因为 num 的 state 进行了变更,即 oldValue !== newValue 的时候。render 函数就会重新进行渲染。

PS:如果定义变量不为重新触发渲染,可以在组件 class 里面直接定义:

class Component {
  cacheData = SOME_BIG_DATA; // 这样数据变更不会触发渲染
  @State() value; // 这样会触发
}

注意:如果我们需要把一个数组 Array 定义为一个 State。一些 push 和 pop 等方法是无法触发 Stencil 组件的render 函数的,我们需要返回一个新的数组,推荐使用 ES6 的结构写法:

@State() items: string[];
 // our original array
this.items = ['ionic', 'stencil', 'webcomponents'];
 // update the array
this.items = [
  ...this.items,
  'awesomeness'
]

Object 也类似,我们需要使用结构返回一个新的 object。

  1. @Watch()

@Watch() 的使用也是比较直观,用于监控 prop/state 的更新,从而执行一些副作用。类似于 Vue 的 watch。它的用法是直接把要监听的字段以 string 的格式传进来:

export class LoadingIndicator {
  @Prop() activated: boolean;
  @State() busy: boolean;

  @Watch('activated')
  watchPropHandler(newValue: boolean, oldValue: boolean) {
    console.log('The new value of activated is: ', newValue);
  }


  @Watch('busy')
  watchStateHandler(newValue: boolean, oldValue: boolean) {
    console.log('The new value of busy is: ', newValue);
  }
}

所以说,@Watch 也是一个很常用的装饰器,在我们监控 props 传值的变化去做一些不影响主要流程的副作用,可以分离核心逻辑与副作用。

  1. @Element()

@Element() 其实是为了提供在组件内访问当前 host element 示例的能力。当我们在组件内部定义了 @Element() el: HTMLElement 的时候,在组件初始化过程中会把当前的 HTMLElement 实例 return 到 el 这个变量上,让我们可以访问当前的 element,比如获取属性,外包围的信息等。

...

export class TodoList {

  @Element() el: HTMLElement;

  getListHeight(): number {
    return this.el.getBoundingClientRect().height;
  }
}

@Element() 也是一个必要的装饰器,在我们构建组件库的时候,避免不了会根据当前组件的一些位置信息和尺寸信息进行一些逻辑的计算。比如根据当前元素的大小确定要弹出的 tooltip 的中间位置,等等。

可以说它是连接了组件内部和具体挂载 ELement 的一个桥梁。

  1. @Method()

@Method() 的作用是声明当前的 function 可以在组件的外部访问到,类似于 class 的 public 属性。它的用法是直接装饰在 function 的上面,如下:

@Method()
  async showPrompt() {
    // show a prompt
  }

这样,我们从组件外部就可以访问当前 tag 的暴露的方法:

(async () => {
  await customElements.whenDefined('todo-list');
  const todoListElement = document.querySelector('todo-list');
  await todoListElement.showPrompt();
})();

值得注意的是,我们并不推荐一个组件对外暴露过多的 methods,因为它会使组件的数据流动变的不可控或难以维护,我们应该尽量使用 props 来控制一些组件的内部逻辑。

在 Stencil 的文档里关于 methods 有这样一段话:

Public methods must be async

Stencil’s architecture is async at all levels which allows for many performance benefits and ease of use. By ensuring publicly exposed methods using the @Method decorator return a promise:

具体意思为,公共的方法必须是保持异步的,这样会发挥框架最大的性能。

  1. @ Event()

@Event() 装饰器的作用是用来声明 DOM event,并提供了 emit 的方法来触发。使用方法如下:

...
export class TodoList {
  @Event() todoCompleted: EventEmitter<Todo>;

  todoCompletedHandler(todo: Todo) {
    this.todoCompleted.emit(todo);
  }
}

我们在 Stencil 的组件中声明了一个 Event todoCompleted,并声明了它是一个 EventEmitter 类型,携带的参数类型为 Todo,在组件中,我们需要触发这个 DOM 事件的时候,我们只需要调用 todoCompleted 的 emit api 并填入携带参数即可。而在组件外层我们就可以使用如下的形式进行 DOM 事件的捕获:

// jsx
<todo-list onTodoCompleted={ev => this.someMethod(ev)} />
// js

Element.addEventListener('todoCompleted', event => {
    console.log(event.detail.value);
});

Event() 还有几个可选的 options 如下:

@Event({
    eventName: 'todoCompleted', // 事件名称
    composed: true, // 冒泡事件是否逃逸出当前的 shadowdom
    cancelable: true, // 事件是否可以被取消
    bubbles: true, // 事件是否冒泡到父级
  }) todoCompleted: EventEmitter<Todo>;

@Event() 可以说你连接了DOM事件,可以在组件中快速的声明,使用 DOM 事件的特性,最终还是编译回到原生的 DOM 事件。

  1. Listen()

有了 @Event() 肯定还会有 @Listen() ,Listen 的作用就是监听子组件冒泡上传的 DOM 事件,和一些公共的事件,比如 scroll、keydown、mousemove 等。可以说用处也是非常广泛,也比较高效。具体用法如下:

...

export class TodoApp {

  @Listen('todoCompleted')
  todoCompletedHandler(event: CustomEvent<Todo>) {
    console.log('Received the custom todoCompleted event: ', event.detail);
  }
}

例如我们上面举例 @Event() 时的例子,在上面 event 开启冒泡后,我们可以在父组件里面 @Listen('todoCompleted') 装饰到一个回调函数上,当子组件触发 emit ,我们父组件就能收到冒泡过来的 DOM 事件,从而执行一些逻辑操作。

或者在我们 input 组件的时候,监听用户是否按下了回车,我们也可以监听一个键盘事件:

@Listen('keydown')
handleKeyDown(ev: KeyboardEvent){
 // xxx
}

Listen() 装饰器也可以监控除了本身之外的 dom 节点的事件,这需要传入 options 的 target 的取值:

export interface ListenOptions {
  target?: 'body' | 'document' | 'window';
  capture?: boolean;
  passive?: boolean;
}



 @Listen('scroll', { target: 'window' })
  handleScroll(ev) {
    console.log('the body was scrolled', ev);
  }

总结

Stencil 为我们提供了丰富的 Decorators api,让我们在各个场景都能高效且方便的使用 dom 或者 js 的各种特性,所谓「磨刀不误砍柴工」,我们必须先要熟悉各个 api 的使用场景和用法,才能在我们构建组件的过程中得心应手。