Angular 18+ 高级教程 – Coding Style Guide 编码风格

前言

Angular 从 v14 开始大改特改,改最多的就是编码风格。

以前是 class first,Decorator first,mutable first。

现在变成了 function first,immutable first。

本篇主要是探讨 v14 后,尤其是 Signal-based 后的 Angular 编码风格,看看怎么写会比较合理舒服😊。

注:只是探讨而已,不定义好与坏。

 

readonly property?

export class AppComponent {
  private readonly renderer = inject(Renderer2);
  readonly value = signal(0);
}

上面的 inject 和 signal 你觉得有必要加上 readonly 吗?

readonly 的意思是,这个属性只能在 constructor 阶段被赋值,过了这个阶段就不可以赋值了。

比如说

export class AppComponent implements OnInit {
  private readonly renderer = inject(Renderer2);
  readonly value = signal(0);

  constructor() {
    this.value = signal(100); // 这个是 ok 的
  }
  ngOnInit() {
    this.value = signal(100); // 这是不 ok 的,会报错
    this.value.set(100);      // 这是 ok 的
  }
}

理由

inject property 确实不太可能再赋值了,说它是 readonly 不过分,合理。

signal property 也同样不太可能被赋值,因为 signal 赋值是 value.set 而不是 value =,所以用 readonly 也合理。

市场

虽然写 readonly 合理,但这也不代表我们就需要或应该这样写。

我们还得看看市场有没有人这样写。

翻一翻 Angular 源码

哎哟,确实是有写 readonly 哦。

但是!

也是有没写 readonly 的😅。没写 readonly 并不是说它之后会再赋值,它就只是没有写 readonly 而已。

在看 Angular Material 源码也差不多一个样,有些有写,有些没写。

那 signal 呢?

不好意思啊,Angular 和 Angular Material 源码中都还没有使用到这么新潮流的 signal...WHAT😲?!

但是 Angular Material 在使用 RxJS Subject 时,确实有蛮多是添加 readonly 的

正方观点 -- 写 readonly

质问:既然有理有据,为什么不写?

我写,你绝对不可能说我错,挺多说没有必要。

有写就多一层保护,你难保哪天有个新手动你的代码时,一不小心就给 readonly property 赋值了。

反方观点 -- 不写 readonly

反问:有什么 property 是不需要写 readonly 的?

一个 property 如果它不可变,那需要写 readonly。

一个 property 如果可变,那我们用 signal,signal 也需要写 readonly。

所以不管可变,还是不可变,最终都需要写 readonly。

既然一定要写 readonly 那又何必写 readonly 呢?哲理🤔

readonly 和 let const 不同,let cost 是换一个 keyword,readonly 是增加多一个 keyword,越多代码看上去就越乱,所以没有必要就不应该增加代码量。

结论

最终还是回归到个人代码管理方式和喜好上。

如果你想特别严谨,那就 readonly first,就像 const first 一样,只要写 property 那就先加上 readonly,直到你赋值它 error 的时候,你再思考是否合理。

如果你不喜欢一堆的 readonly,那你要习惯不给 property 赋值,习惯 signal first,这样就不容易出 bug 了。

 

private property?

上一 part 探讨 readonly 的同时,我们也看见了 private property。

几乎所有的 inject property 都设置了 private。

在 Angular MVVM,组件实例代表 view model,它的属性和方法通常是用作于 template binding。

而 inject property 通常存放的是 Service,而这些 Service 通常也不会直接用作 template binding,所以设置成 private 还算是挺合理的。

那问题来了:

export class AppComponent {
  private readonly value = input.required<string>();
  private readonly templateRef = contentChild.required(TemplateRef);
  private readonly viewContainerRef = viewChild.required('container', { read: ViewContainerRef });
}

input, contentChild, viewChild 如果没有被直接用于 template binding,也需要加上 private 吗?

首先,input 绝对不可以设置 private

会直接报错。因为 input 会直接用作 template binding,所以当然不应该是 private。

至于 contentChild 和 viewChild 则都可以设置 private。

结论:但凡没有被 template binding 的属性,都可以设置成 private。

市场

可以不代表有必要。Angular Material 目前没有使用 signal-based query,所以只能参考它 old school 写法。

@ViewChild 属性不是 private。

private property 利与弊

class private property 最大的好处是当我们在使用 class 实例时,private property 不可见。

有两个场景我们会使用到组件实例:

  1. template binding

    这也是绝大部分的场景

  2. query 组件实例,调用属性或方法

    这个场景也会发生,只是比较少

如果我们不希望在以上两个场景中,调用组件实例时看见某些属性和方法,那我们就需要设置 private。

比如

没有 private 的情况下有 4 个选择

加上 private 后只剩下 2 个选择,这样使用起来就更干净

private 的代价和 readonly 一样,就是多了代码量。

结论

和 readonly 一样,最终还是回归到个人代码管理方式和喜好上。

如果你想特别严谨,那就 private first,需要用到属性时才解除 private。

如果你不喜欢一堆的 private,那就在使用组件实例时小心一点,不要选错属性就可以了。

 

inject,Injector.get,runInInjectionContext?

inject,Injector.get,runInInjectionContext 用哪一个比较好?

inject 的劣势

inject 函数有一个 injection context 的局限。它非常的烦人。

export class AppComponent {
  constructor() {
    inject(Renderer2); // it work
  }

  doSomething() {
    inject(Renderer2); // Error!
  }
}

constructor 内可以使用 inject,doSomething 方法内不可以使用 inject。

另外,如果 constructor 里 wrap 了一层

constructor() {
  afterNextRender(() => {
    inject(Renderer2); // Error!
  });
}

那它就变成不能使用 inject 了。

Injector.get 的优势

只要有 injector 对象在手,到哪里都一定可以注入。

export class AppComponent {
  private injector = inject(Injector);

  constructor() {
    this.injector.get(Renderer2);   // it work
    queueMicrotask(() => {
      this.injector.get(Renderer2); // it work
    });
  }

  doSomething() {
    this.injector.get(Renderer2);   // it work
  }
}

Injector.get 劣势 vs inject 优势

如果有一个在组件外的函数,它需要注入 Renderer2。

使用 Injector.get 的话,我们必须把 Injector pass 进去函数里。

假如使用 inject 函数就不需要 passing。

early inject vs lazy inject

假如我们需要在 afterNextRender 里注入使用 Renderer2。

constructor() {
  afterNextRender(() => {
    const renderer2 = inject(Renderer2); // Error!
    console.log(renderer2);              // use renderer2
  });
}

after render callback 里是不能使用 inject 函数的。

我们可以提早 inject,像这样

constructor() {
  const renderer2 = inject(Renderer2); // inject early
  afterNextRender(() => {
    console.log(renderer2); // use renderer2
  });
}

虽然是破解了,但是这样就变成 early inject,不是 lazy inject 了。

试想想,本来在 SSR (Server-Side Rendering) 的环节,after render callback 是不执行的,所以也不会 inject Renderer2。

但由于我们改成了 early inject,变成即使是 SSR (Server-Side Rendering) 环节,它也一定会去 inject Renderer2。

inject 不到的地方用 injector.get 做替代

为了避免 early inject,我们唯一的方法就是用 injector.get 来替代 inject。

constructor() {
  const injector = inject(Injector); // 这里可以用 inject 函数
  afterNextRender(() => {
    const renderer2 = injector.get(Renderer2); // 这里用不到 inject 函数,就改用 injector.get
    console.log(renderer2); // use renderer2
  });
}

runInInjectionContext 的优势与劣势

虽然 injector.get 可以替代 inject,但是 injector.get 也有它自己的劣势。

当遇上这种情况时,我们可以 wrap 一层 runInInjectionContext,让它去替代 Injector.get。

runInInjectionContext 的劣势是嵌套很难看。

export class AppComponent {
  private injector = inject(Injector);

  doSomething() {
    runInInjectionContext(this.injector, () => {
      inject(Renderer2);   // it work

      queueMicrotask(() => {
        inject(Renderer2); // Error again!
      });
    });
  }
}

而且,假如它里面又 wrap 一层,inject 又不能用了,你还得再 wrap 一层 runInInjectionContext...

结论

可以看到各个都有优势和劣势,我感觉有两条路可以走:

  1. everything inject first,inject 不到的地方用 injector.get 来代替,injector.get 不灵时就 wrap runInInjectionContext。

  2. everything injector.get,统一,真的没办法 (比如调用组件外的函数,它里面需要注入,但又不想 passing injector) 才 wrap 一层 runInInjectionContext 使用 inject。

 

constructor, OnInit, ContentHooks, ViewHooks, AfterRenderHooks, effect, ngOnDestroy, DestroyRef

听说 Signal-based 以后,AfterContentInit,AfterViewInit,ngOnChanges 都会被废弃,还有 ngOnDestroy 也岌岌可危。

取而代之的是新潮流 effect,AfterNextRender 和 DestroyRef。

AfterRenderHooks vs ViewHooks

以前讲过了,这里不再赘述。

ngOnDestroy vs DestroyRef

以前讲过了,这里不再赘述。

组件 class 新结构

old school 的写法

export class AppComponent implements AfterViewInit, OnDestroy {
  ngAfterViewInit() {
    console.log();
  }

  ngOnDestroy() {
    console.log();
  }
}

Lifecycle hooks 是通过 class methods 来表述。

新潮流的写法

export class AppComponent {
  constructor() {
    afterNextRender(() => console.log());
    inject(DestroyRef).onDestroy(() => console.log());
  }
}

最大的区别是,代码都跑到 constructor 里去了。

在 Signal-based 后,组件 class 结构有了一些新 pattern。

export class AppComponent {
  constructor() {
    // lifecycle hooks
    afterNextRender(() => console.log());
    inject(DestroyRef).onDestroy(() => console.log());
  }

  value = signal(0); // use for template binding
  
  doSomething() {}   // use for template binding
}

组件 class 的属性和方法专注服务于 template binding,而 lifecycle hooks 则包裹在 constructor 里。

虽然这样没有 follow 原生的 Custom Element,但我个人感觉,这样是比较合理的。

因为除了框架,没有人会去调用 AppComponent.ngAfterViewInit() 或 ngOnDestroy()。

强迫使用 class implements + public method 去定义只有框架才会调用的 lifecycle hooks,这对其它使用组件的人就是一种干扰。

effect 取代 ngOnChanges, ContentHooks, ViewHooks

Signal based 以后,@Input,@ContentChild, @ViewChild 都变成了 Signal。

export class AppComponent {
  value = input.required<string>();
  contentTemplate = contentChild.required(TemplateRef);
  viewContainer = viewChild.required('container', { read: ViewContainerRef });
}

要监听 Signal 变更就得使用 effect,要使用 effect 就要在 injection context 里。

组件 class constructor 是天然的 injection context

constructor() {
  effect(() => {
    console.log(this.value());
    console.log(this.contentTemplate());
    console.log(this.viewContainer());
  });
}

于是,代码又都跑到 constructor 里去了。这样也挺好,大家都集合在 constructor 里,让 class 属性和方法专注服务于 template binding 就好。

无可替代的 OnInit?

我给一个简单的例子

export class AppComponent implements OnInit {
  loading = signal(false);
  datas = signal<string[]>([]);
  private injector = inject(Injector);

  async ngOnInit() {
    this.loading.set(true);
    const httpClient = this.injector.get(HttpClient);
    this.datas.set(await firstValueFrom(httpClient.get<string[]>('')));
    this.loading.set(false);
  }
}

三个动作,show loading,ajax datas, hide loading and show datas。

我们尝试不使用 ngOnInit,用其它方式代替 OnInit,看看会长啥样。

用 constructor 代替 OnInit

constructor() {
  this.loading.set(true);
  const httpClient = inject(HttpClient);
  httpClient.get<string[]>('').subscribe(datas => {
    this.datas.set(datas);
    this.loading.set(false);
  });
}

由于 constructor 不支持 async await 语法,所以只能用 subscribe 替代 await。

或者 wrap 一层 IIFE (Immediately invoked function expression)

constructor() {
  (async () => {
    this.loading.set(true);
    const httpClient = inject(HttpClient);
    this.datas.set(await firstValueFrom(httpClient.get<string[]>('')));
    this.loading.set(false);
  })();
}

没有 async await 是硬伤,两个 workaround 都不理想。

另外,constructor 阶段执行的太早,此时 @Input 的 value 都还没有被赋值,如果 ajax 依赖 @Input value 那更不可能用 constructor 了。

AfterRenderHooks 代替 OnInit

SSR (Server-Side Rendering) 环境下不执行 AfterRenderHooks,这和 OnInit 逻辑上就不一样了,完全不用考虑这个方案。

effect 代替 OnInit

effect 的触发时机非常的晚,比 AfterRenderHooks 还晚,也就是说所有 LView 都已经执行完 renderView 和 refreshView 了,这时你说要来 show loading?

而且 effect 内也不鼓励再修改 signal (虽然它可以,但不鼓励),所以 effect 视乎也不合适。

OnInit is the best choice

constructor 太早,又不支持 async await,不好。

effect 太晚,又不鼓励修改 Signal,虽然勉强可以,但还是不好。

Oninit 时机刚好,它在 @Input 赋值后就立刻执行,也支持 async await,也可以改 Signal。

唯一的缺陷是,如果依赖 @Input 并且需要监听后续的变更,那最终我们还是得用上 effect。

硬硬改成 Signal-based onInit 风格

如果我们偏偏就是想用 Signal-based 风格去写 onInit 有没有办法可以做到呢?

比如说像这样

export class HelloWorldComponent {
  readonly value = input.required<string>();

  constructor() {
    onInit(async () => {
      console.log(inject(ElementRef));
      console.log('init1', this.value());
      await delayAsync();
    });

    onInit(() => {
      console.log('init2', this.value());
    });
  }
}

hacking way 还是有的,只是不顺风水而已。

参考我之前写的文章:Signal-based OnInit? (the hacking way...🤪)

All in constructor 后遗症

forgot out of injection context

当你写多了 constructor 内的代码,你的潜意识会认为总是可以直接使用 inject 和 effect,因为 constructor 是 injection context。

但是,一旦你脱离 constructor,而你的潜意识没有脱离的话,就会报错了🙈

讨厌 this

当你写多了 constructor 内的代码,你会开始讨厌 this,你会觉得它很多余。

从前

export class AppComponent implements AfterContentInit, AfterViewInit {
  private sharedMethod() {
    console.log();
  }
  
  ngAfterContentInit() {
    this.sharedMethod();
  }

  ngAfterViewInit() {
    this.sharedMethod();
  }
}

如今

constructor() {
  const sharedMethod = () => console.log();

  afterNextRender(() => sharedMethod());
  effect(() => sharedMethod());
}

因为 all in constructor,所以 no need implements, no need private, no need this。

如果你不喜欢 variable + arrow function,也可以改成下面这样

export class AppComponent {
  constructor() {
    const that = this;

    afterNextRender(() => sharedMethod());
    effect(() => sharedMethod());

    function sharedMethod() {
      console.log(that); // can use 'this' here
    }
  }
}

不过,如果 sharedMethod 需要用到 this,那就要偷龙转风成 'that' 了。(注:ESLint 要 by pass 哦)

讨厌 this 2.0

除了 all in constructor 不需要 this 以外,Signal 也让我们可以减少 this 的使用。

从前

export class AppComponent {
  firstName = 'Derrick';
  lastName = 'Yam';
  age = 11;

  doSomething() {
    console.log(this.firstName);
    console.log(this.lastName);
    console.log(this.age);

    this.firstName = '';
    this.lastName = '';
    this.age = 0;
  }
}

一堆 this dot,看得都烦。

我们可以用 Object Destructuring Assignment 减少 this

但遇到值类型属性要赋值,这招就不灵了。

换成 Signal 以后,哎哟,它就可以了。

export class AppComponent {
  firstName = signal('Derrick');
  lastName = signal('Yam');
  age = signal(11);

  doSomething() {
    const { firstName, lastName, age } = this;
    console.log(firstName());
    console.log(lastName());
    console.log(age());

    firstName.set('');
    lastName.set('');
    age.set(0);
  }
}

因为 Signal 是对象嘛,不是值类型。

虽然 Signal 的 getter setter 也很丑,但是它把很丑的 this 换掉了,也算将功补过吧。

 

MVVM vs DOM Manipulation

MVVM 指的是利用 Template Binding Syntax 去 add event listener 和修改 DOM property。

DOM Manipulation 指的是用原生 DOM API add event listener 和修改 DOM property。

Angular 肯定是鼓励我们多使用 MVVM 远离 DOM Manipulation 的。

但有时候使用 DOM Manipulation 真的会比 MVVM 来的简单直观,尤其是在开发 UI 组件库的时候。

MVVM 的优势

<button (click)="doSomething()">click me</button>

这种 template binding 是最简单直观的。一个点击事件,一个方法调用。

方面名字就说明了点击后会做些什么。

MVVM 的局限

如果每一个都是这么简单,那用 MVVM 就够了。但可惜,现实就不是这样。

再一个搜索的例子

<input #input (input)="search(input.value)">

input 之后要做 searching,上面这样写没有问题。

但如果要求先 debounce time 500ms 才做 searching 呢?

<input #input (input)="debounceTime(500); search(input.value)">

上面这样写是不成立的。

要像下面这样才可以

<input #input (input)="debounceSearch(input.value)">
export class AppComponent {
  private debounceTime = 500;
  private timeout: number | null = null;
debounceSearch(value: string) {
if (this.timeout) window.clearTimeout(this.timeout); this.timeout = window.setTimeout(() => { console.log('search', value); this.timeout = null; }, this.debounceTime); } }

虽然实现了,但是没有利用上 RxJS,代码量有点多。

我们可以换成这样

<input #input (input)="inputSubject.next(input.value)">
export class AppComponent {
  readonly inputSubject = new Subject<string>();

  private search(searchText: string) {
    console.log(searchText);
  }

  constructor() {
    this.inputSubject.pipe(debounceTime(500)).subscribe(searchText => this.search(searchText));
  }
}

为了使用 RxJS debounceTime,我们需要把 MVVM 事件监听 convert to RxJS Observable。

Subject 上面扮演的角色是一个 Proxy,模板 (input) 监听事件后通过 Subject.next 做转发。

组件内通过 Subject.subscribe 做监听。整个代码看上去就不太高雅。而且换成这个写法后,模板上就不再表述 input 事件后的处理方式了。

DOM Manipulation replace MVVM

既然 MVVM 和 RxJS 八字不合,那倒不如直接用 DOM Manipulation。

export class AppComponent {
  private readonly input = viewChild.required<string, ElementRef<HTMLInputElement>>('input', { read: ElementRef });

  constructor() {
    const injector = inject(Injector);

    afterNextRender(() => {
      const search = (searchText: string) => console.log('search', searchText);
      const destroyRef = injector.get(DestroyRef);

      const input = this.input().nativeElement;
      const input$ = fromEvent(input, 'input').pipe(debounceTime(500));
      input$.pipe(takeUntilDestroyed(destroyRef)).subscribe(() => search(input.value));
    });
  }
}

query element + add event listener,非常传统的方式。

用 DOM Manipulation 方式需要注意几个点:

  1. viewChild 的 input 最少要在 OnInit 之后才能使用

  2. add event listener 最好是放在 after next render callback 里

  3. 当组件销毁时记得 remove event listener

  4. 如果想使用 Angular 扩展的 add event listener 语法,比如 keydown.enter,那我们需要配上 Renderer2.listen

  5. 如果 input 是在 ng-template 里,那我们还需要监听 input signal

市场

参考 Angular Material 源码,真的是五花八门。

MVVM binding 的方法有些有声明事件后的处理方式,有些没有。

直接使用 RxJS fromEvent 对 element 做事件监听。

用 Subject 作为 Proxy 转发事件。

总结

个人感受,做业务项目,尽可能使用 MVVM,省事。

做 UI 组件库,优先使用 MVVM,但如果感觉不适,那果断换成 DOM Manipulation。

提醒:用 DOM Manipulation 要注意上面提到的几个事项哦。

 

Partial Options

Partial Options 不仅仅在 Angular,任何地方都很常见。

A simple example

有一个 function

function doSomething () {}

这个 function 需要一些 options

function doSomething (
  options: { disabled: boolean, prefix: string; useComponent: boolean }
) {}

假如这些 options 都是 optional 的,那我们会这么写

function doSomething (
  options?: { disabled?: boolean, prefix?: string; useComponent?: boolean } // all optional
) {
  const { disabled = false, prefix = 'stg', useComponent = false } = options ?? {}; // set default options

  console.log([disabled, prefix, useComponent]); // use options
}

如果某一些是 required,那我们会这么写

function doSomething (
  options: { disabled?: boolean, prefix: string; useComponent?: boolean } // prefix is required
) {
  const { disabled = false, prefix, useComponent = false } = options; // set default options except prefix

  console.log([disabled, prefix, useComponent]); // use options
}

doSomething({ prefix: 'stg' });

如果 options 不多,而且不需要 pass 来 pass 去,上面这样写就已经很 ok 了。

但如果 options 很多,或者要 pass 来 pass 去,上面这样写就不太灵活。

Angular Material partial options?

我们参考 Angular Material 怎么写。

下图是 Angular Material Dialog open 方法,源码在 dialog.ts

注:Config 和 Options 这里我们不区分词汇意义,我们把它们当同义词看待就好了。

由于 Config 内有太多的属性,所以 Angular Material 用了一个 DialogConfig class 来管理。

虽然它是 class,但在官网的例子中,调用 Dialog.open 时所传入的 config 并不是 DialogConfig 实例,而只是一个满足鸭子类型的对象。

另外,在 Dialog.open 方法里,最终使用的 config 对象,也不是 DialogConfig 实例

既然都不是用实例,那为什么要用 class 呢?用 interface 不是更合理吗?

是的,不合理,Angular Material 之所以使用 class 是因为它想利用 class constructor 来提供属性的 default value。

class 和 interface 傻傻分不清楚是它的第一个问题。

第二个问题是它没有很好的区别哪些属性是 optional。

既然都给了 default value 'dialog',role 属性为什么会是 optional?

role 根本不可能是 undefined,除非我们去 delete role 属性

delete dialogConfig.role;

但这显然不可能发生。

Angular Material 之所以让 role 是 optional 是因为它拿 DialogConfig 作为 Dialog.open 的参数。

我们要知道,DialogConfig 或者任何 options 都有 2 个角度。

第一个角度是提供者,比如,我提供一个 options 给 doSomething function 使用。

提供者只需要提供最低要求,也就是说只有 required 的需要提供,其它 optional 的可以不提供。

第二个角度是消费者,比如在 doSomething function 里使用 options。

消费 options 的时候,我们会希望 options 是完整的,那些 optional 的都应该要补上 default value,不应该再是 undefined 了。(除非它在业务设计上是真正意义上的 optional,那就没有 default value)

结论,一个 DialogConfig 不可能同时满足两个角度,这是 Angular Material 的第二个问题。

Partial options best practice

首先,做一个消费者用的 Options interface 

interface Options {
   disabled: boolean;
   prefix: string;
   useComponent: boolean;
}

接着定义哪些 keys 在提供者角度是 optional 的

type OptionalKeys = 'disabled' | 'useComponent';

接着定义 partial options 和 default options

type PartialOptions = Partial<Pick<Options, OptionalKeys>> & Omit<Options, OptionalKeys>
type DefaultOptions = Pick<Options, OptionalKeys>;

// 上面两个等价于下面两个

// interface PartialOptions {
//    disabled?: boolean;
//    prefix: string;
//    useComponent?: boolean;
// }

// interface DefaultOptions {
//    disabled: boolean;
//    useComponent: boolean;
// }

接着做一个 init function,它负责 assign default value

export function initOptions(partialOptions: PartialOptions): Options {
  const defaultOptions: DefaultOptions = {
     disabled : false,
     useComponent: false
  };
  return {
    ...defaultOptions,
    ...partialOptions
  }
}

最后在 doSomething 函数里面和外面使用它们

function doSomething (partialOptions: PartialOptions) {
  const options = initOptions(partialOptions);
}

doSomething({ prefix: 'stg' });

上面使用 interface + initOptions 函数替代了 class。其实要换成 class 也 ok,只要不要像 Angular Material 那样乱就行了。

提醒:

object assign 时要小心 undefined 值。

return {
  ...defaultOptions,
  ...partialOptions
}

最好开启 tsconfig 的 exactOptionalPropertyTypes

开启后,如果遇到 undefined 值可以使用 conditional add property 来添加属性。

 

 

总结

以后有新纠结,我会再补上,分享给大家。

 

 

目录

上一篇 Angular 18+ 高级教程 – 盘点 Angular v14 到 v18 的重大改变

下一篇 Angular 18+ 高级教程 – Memory leak, unsubscribe, onDestroy

想查看目录,请移步 Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻

 

posted @ 2024-05-31 21:58  兴杰  阅读(387)  评论(3编辑  收藏  举报