細說 Angular 的自定義表單控件

我們在構建企業級應用時,通常會遇到各種各樣的定制化功能,因為每個企業都有自己獨特的流程、思維方式和行為習慣。有很多時候,軟件企業是不太理解這種情況,習慣性的會給出一個診斷,『你這么做不對,按邏輯應該這樣這樣』。但企業往往不會接受這種說法,習慣的力量是強大的,我們一定要尊重這種事實。所以在構建企業應用的時候,我們不僅僅要了解對方的基本需求,也要了解他們習慣于怎么處理流程,在設計的時候需要予以充分重視。當然這也不是說客戶說怎么改我們就怎么改,而是要了解到對方真正的訴求和背后的原因,在產品規劃設計的時候,將這種因素考慮進去,才能在維持產品統一的框架下滿足不同用戶的需求。

那么這里我們舉一個例子,比如我們正在開發一個醫療衛生領域的企業軟件,客戶要求提供一個出生日期的控件,但這個控件不光可以輸入年月日,而且可以輸入年齡數值以及選擇年齡單位。客戶的希望是:

  1. 填寫日期時,年齡和年齡單位隨之變化
  2. 填寫年齡和選擇年齡單位時出生日期也隨之變化

看起來好像很無用的一個需求,這個在面向互聯網的應用中確實如此。但在特定領域,其實有其背景原因,比如客戶提出這個需求是由于很多人,尤其是小城鎮的,是不記公歷生日的,這樣會導致出生日期不是很準確,另外還會有一些人的身份證日期和真實年齡是不一致的。這種情況對于成人來說還好,但對于兒童來說就偏差很大,但一般人會記得孩子現在是多少天或多少個月大。這樣的話是不是覺得這個需求還有些道理?

那么我們就接著來看一下這個需求應該怎樣實現,首先分析一下:

  1. 無論是輸入出生日期還是年齡,其實最終要得到一個日期,也就是說年齡只是得到日期的一個輔助手段。
  2. 年齡單位的轉換我們需要有一個界定,否則切換起來沒有規則的話會導致邏輯的混亂。那這里我們定義一下:以天為單位時的上限為:90,下限為 0,也就是只有小于等于 90 天的嬰兒我們會使用天作為年齡單位。類似的,以月為單位的上限為 24,下限為 1;以年為單位的上限為 150,下限為 1。
  3. 同樣的出生日期的驗證規則為:這個日期不能是未來的時間,一定是小于等于當前時間的,再有就是年齡的上限既然是 150,那么出生日期也不能比當前日期減去 150 年更早,對嗎?
  4. 聯動的規則應該是調整出生日期時,會將日期按上面規則轉換成年齡和單位,改變控件中的值;而調整年齡或者單位的時候,我們會根據年齡推算出出生日期,當然這里是估算,以當前日期減去年齡得出,然后更新出生日期輸入框中的值。
一個定制化的日期選擇控件
一個定制化的日期選擇控件

但這里面有幾個值得注意的地方:

  1. 可能存在反復聯動的問題,比如改變出生日期后,年齡和單位隨之改變,這又引發了由年齡和單位的變化而導致的出生日期的重算。
  2. 如果輸入非法的值,可能導致計算出現異常,因而控件狀態出現不正確的狀態值,進一步影響未來的計算。
  3. 如果每次輸入改動都會引發重新計算,會帶來大量的過程中無用計算,耗費資源,因此需要進行對輸入事件的『整流』控制。

搭建自定義表單控件的框架

首先為什么要實現一個自定義表單控件?我們當然可以直接把這個邏輯放在表單中,但問題是表單真的需要關心這幾個框的聯動嗎?

其實從表單的角度看,它只要一個值:那就是經過計算的出生日期。至于你是手動輸入的還是按年齡和單位計算的,表單根本就不應該關心。另外一點是隨著表單的復雜化,如果我們不把這些邏輯剝離出去的話,我們的表單本身的邏輯就會越來越復雜。最后是,封裝成表單控件意味著我們以后可以復用這個控件了。

知道了 why,我們看看 how。在 Angular 中實現一個自定義的表單控件還是比較簡單的,下面是一個表單控件的骨架。

import {ChangeDetectionStrategy, Component, forwardRef, OnInit, OnDestroy, Input} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';

@Component({
  selector: 'app-age-input',
  template: `
    // 省略
    `,
  styles: [`
    // 省略
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgeInputComponent implements ControlValueAccessor {

  private propagateChange = (_: any) => {};

  constructor() { }

  // 提供值的寫入方法
  public writeValue(obj: Date) 
  }

  // 當表單控件值改變時,函數 fn 會被調用
  // 這也是我們把變化 emit 回表單的機制
  public registerOnChange(fn: any) {
    this.propagateChange = fn;
  }

  // 這里沒有使用,用于注冊 touched 時的回調函數
  public registerOnTouched() {
  }

  // 驗證表單,驗證結果正確返回 null 否則返回一個驗證結果對象
  validate(c: FormControl): {[key: string]: any} {
    // 省略
  }
}

我們可以看到要實現一個表單控件的話,要實現 ControlValueAccessor 這樣一個接口。這個接口顧名思義是用于寫入控件值的,它是一個控件和原生 DOM 元素之間的橋梁,通過實現這個接口,我們可以對原生 DOM 元素寫入值。而這個接口需要實現三個必選方法: writeValue(obj: any)registerOnChange(fn: any)registerOnTouched(fn: any)

  • writeValue(obj: any):用于向元素中寫入值
  • registerOnChange(fn: any):設置一個當控件接受到改變的事件時所要調用的函數。
  • registerOnTouched(fn: any):設置一個當控件接受到 touch 事件時所要調用的函數。

另外的一個 validate(c: FormControl): {[key: string]: any} 是控件的驗證器函數。除了這些函數,你應該也注意到,我們注冊了兩個 provider,一個的 token 是 NG_VALUE_ACCESSOR 這是將控件本身注冊到 DI 框架成為一個可以讓表單訪問其值的控件。但問題來了,如果在元數據中注冊了控件本身,而此時控件仍為創建,這怎么破?這就得用到 forwardRef 了,這個函數允許我們引用一個尚未定義的對象。另外一個 NG_VALIDATORS 是讓控件注冊成為一個可以讓表單得到其驗證狀態的控件
。當然這里還有一個奇怪的東西,就是那個 multi: true,,這是聲明這個 token 對應的類很多,分散在各處。

控件的界面

我們這里使用了 @angular/materialinputdatepickerbutton-toggle 控件來分別實現日期輸入、年齡輸入和年齡單位的選擇。注意到我們在里面使用了響應式表單,這感覺好像有點怪,我們本身不是一個表單控件嗎?怎么自己的模板還是一個表單?這個其實沒啥問題,因為 Angular 中的組件是和外界隔離的,所以組件自身的模板其實想怎么使用都可以。

<div [formGroup]="form" class="age-input">
  <div>
    <md-input-container>
      <input mdInput [mdDatepicker]="birthPicker" type="text" placeholder="出生日期" formControlName="birthday" >
      <button mdSuffix [mdDatepickerToggle]="birthPicker" type="button"></button>
      <md-error>日期不正確</md-error>
    </md-input-container>
    <md-datepicker touchUi="true" #birthPicker></md-datepicker>
  </div>
  <ng-container formGroupName="age">
    <div class="age-num">
      <md-input-container>
        <input mdInput type="number" placeholder="年齡" formControlName="ageNum">
      </md-input-container>
    </div>
    <div>
      <md-button-toggle-group formControlName="ageUnit" [(ngModel)]="selectedUnit">
        <md-button-toggle *ngFor="let unit of ageUnits" [value]="unit.value">
          {{ unit.label }}
        </md-button-toggle>
      </md-button-toggle-group>
    </div>
    <md-error class="mat-body-2" *ngIf="form.get('age').hasError('ageInvalid')">年齡或單位不正確</md-error>
  </ng-container>
</div>

上面這個模板中值得注意的一點是,我們把年齡的數值和單位放在了一個 FormGroup 里面,這是由于這兩個值組合在一起才有意義,而且后面的表單驗證也是這兩個值在一起組合后驗證。

使用 Rx 的事件流來重新梳理邏輯

私以為 Rx 的兩大優點:

  1. 由于在 Rx 世界里,一切都是事件流,所以這『逼迫』開發者將時間維度納入設計的考量
  2. 提供的各種強大的操作符可以將邏輯非常輕松的組合

那么從 Rx 的角度看的話,這個控件會產生三個事件流:出生日期、年齡數值和年齡單位:

出生日期:-------d----------d---------------d--------------
年齡數值:----------num----------num----------------num----
年齡單位:----unit-------------unit-------------unit-------

寫成代碼的話就是下面的樣子,Angular 的響應式表單為我們提供了非常便利的方法可以得到這些變化的事件流,FormControlvalueChanges 屬性就是一個 Observable

// 得到出生日期的值的變化流
const birthday$ = this.form.get('birthday').valueChanges;
// 得到年齡數值的變化流
const ageNum$ = this.form.get('age').get('ageNum').valueChanges;
// 得到年齡單位的變化流
const ageUnit$ = this.form.get('age').get('ageUnit').valueChanges;

由于年齡數值和年齡單位需要合并在一起才有意義,所以這兩個流需要做一個合并操作,而且不管是數值變化還是單位變化,我們都要在新的合并流中有一個反映:

年齡數值:----------n1----------------n2------------------n3-------
年齡單位:----u1-------------u2------------------u3----------------
合并后:  ------(n1,u1)--(n1,u2)--(n2,u2)----(n2,u3)---(n3,u3)---

仔細觀察一下,你可能會發現這個合并流還有一個特點就是只有在參與合并的兩個流都有事件產生后才會有合并的事件發生,在這之后就是任何一個參與合并的流有新的事件,合并流就會產生一個事件,這個合并的值會取剛剛發生的那個事件和另一個參與合并的流中的『最新』事件。這種合并方法在 Rx 中叫做 combineLatest

const age$ = Observable
      .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}));

上面的代碼中,我們將年齡數值的事件流(ageNum$)以及年齡單位的事件流(ageUnit$)做了合并,而且通過一個 this.toDate 的工具函數將年齡和單位計算出了一個估算的出生日期。

出生日期:-------d----------d---------------d--------------
年齡合并:---d^----d^----d^---d^--------d^------d^---------
// 年齡合并后產生的出生日期用 d^ 來標識

現在看起來這兩個流都產生日期,只不過是不同的控件變化引起的。那么我們應該可以把它們也做一個合并,這個合并就比較簡單,可以想象成按照各自流中的位置把兩個流做投影。

最終合并:---d^--d--d^----d^--d-d^-------d^--d----d^-------

而這種合并在 Rx 中叫做 merge

const merge$ = Observable.merge(birthday$, age$);

但為了要能區分這個日期是來自于出生日期那個輸入框還是來自于年齡和單位的輸入變化,我們得標識出這個日期的來源。所以我們需要對 birthday$age$ 做一個變換處理,不在單純的發射日期,而是將日期和來源組合成一個新的對象 {date: string; from: string} 發射。

const birthday$ = this.form.get('birthday').valueChanges
      .map(d => ({date: d, from: 'birthday'}));
const age$ = Observable
      .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}))
      .map(d => ({date: d, from: 'age'}));

這樣處理之后,我們就可以根據不同情況,根據日期設置年齡和單位,或者反之,由年齡和單位的變化設置出生日期。

this.subBirth = merged$.subscribe(date => {
  const age = this.toAge(date.date);
  const ageNum = this.form.get('age').get('ageNum');
  const ageUnit = this.form.get('age').get('ageUnit');
  if(date.from === 'birthday') {
    if(age.age === ageNum.value && age.unit === ageUnit.value) {
      return;
    }
    ageUnit.patchValue(age.unit, {emitEvent: false, emitModelToViewChange: true, emitViewToModelChange: true});
    ageNum.patchValue(age.age, {emitEvent: false});
    this.selectedUnit = age.unit;
    this.propagateChange(date.date);

  } else {
    const ageToCompare = this.toAge(this.form.get('birthday').value);
    // 如果要設置的日期換算成年齡和單位,如果這兩個值和現有控件的值是一樣的,那就沒有必要更新日期的值了
    if(age.age !== ageToCompare.age || age.unit !== ageToCompare.unit) {
      this.form.get('birthday').patchValue(date.date, {emitEvent: false});
      this.propagateChange(date.date);
    }
  }
});

大致的邏輯就是這樣了,但我們還有幾個問題需要解決

  1. 現在的情況是不管你以多快的速度輸入日期,或者輸錯了按 backspace 都會產生新的事件,也因此會有計算。但顯然這樣做一方面浪費了性能,另一方面會導致一些不合法的值大量出現(比如本來要輸入 2000-12-11 , 但事實上現在當你剛剛敲了 2 ,事件就已經產生了,但顯然年份 2 不是一個合理的出生年份,我們畢竟不是在做一個考古信息系統)。
  2. 當你和上一次輸入相同的值時,現在的系統仍然會發射事件,但這其實是在做無用功。
  3. 我們現在的事件流沒有經過一個驗證就會把數據發射出來,但一個沒有驗證成功的值其實對我們來說是沒有意義的。
  4. 年齡和單位的合并流只有在年齡和單位都產生變化的時候才開始發射,但一開始的初始狀態,這兩個控件并沒有值,這顯然不是我們希望的(比如你可能不想填完年齡,例如 30,然后還得點一下『天』,再點回『歲』來得到合并計算的值)。
const birthday$ = this.form.get('birthday').valueChanges
  .map(d => ({date: d, from: 'birthday'}))
  .debounceTime(300)
  .distinctUntilChanged()
  .filter(date => this.form.get('birthday').valid);
const ageNum$ = this.form.get('age').get('ageNum').valueChanges
  .startWith(this.form.get('age').get('ageNum').value)
  .debounceTime(300)
  .distinctUntilChanged();
const ageUnit$ = this.form.get('age').get('ageUnit').valueChanges
  .startWith(this.form.get('age').get('ageUnit').value)
  .debounceTime(300)
  .distinctUntilChanged();
const age$ = Observable
  .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}))
  .map(d => ({date: d, from: 'age'}))
  .filter(_ => this.form.get('age').valid);
const merged$ = Observable
  .merge(birthday$, age$)
  .filter(_ => this.form.valid);

上面的代碼中,我們使用 debounceTime 過濾掉了短時間內的輸入,等待用戶略有停頓或輸入完成時才發射新的事件。我們還使用了 distinctUntilChanged 來過濾掉和之前一樣的輸入。而 startWith 其實是在幫事件流拼接一個初始值,使得合并流按我們想像中那樣運行。那么 filter 則是屏蔽掉驗證未通過的數據。

這樣簡單的通過幾個 Rx 的操作符我們就完成了核心邏輯,而且在核心邏輯不變的前提下對數據驗證、事件的『整流』、篩選等進行了調整。

總結和思考

針對復雜的表單,我們通常應該使用『復雜問題簡單化』的方法將一個復雜問題拆分成多個簡單問題。對于較復雜的表單來講,自定義表單控件是一個很有用的可以簡單化表單邏輯,封裝局部邏輯的一種方法。

而使用 Rx 進行邏輯的組裝、轉換、拼接以及合并是非常容易的事情,而且 Rx 的事件流特點會讓你把邏輯梳理的非常清晰,以時間維度把業務邏輯的先后和組裝的次序考慮周全。

源碼

import {ChangeDetectionStrategy, Component, forwardRef, OnInit, OnDestroy, Input} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import {
  subYears,
  subMonths,
  subDays,
  isBefore,
  differenceInDays,
  differenceInMonths,
  differenceInYears,
  parse
} from 'date-fns';
import {Observable} from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { toDate, isValidDate } from '../../utils/date.util';
export enum AgeUnit {
  Year = 0,
  Month,
  Day
}

export interface Age {
  age: number;
  unit: AgeUnit;
}

@Component({
  selector: 'app-age-input',
  template: `
    <div [formGroup]="form" class="age-input">
      <div>
        <md-input-container>
          <input mdInput [mdDatepicker]="birthPicker" type="text" placeholder="出生日期" formControlName="birthday" >
          <button mdSuffix [mdDatepickerToggle]="birthPicker" type="button"></button>
          <md-error>日期不正確</md-error>
        </md-input-container>
        <md-datepicker touchUi="true" #birthPicker></md-datepicker>
      </div>
      <ng-container formGroupName="age">
        <div class="age-num">
          <md-input-container>
            <input mdInput type="number" placeholder="年齡" formControlName="ageNum">
          </md-input-container>
        </div>
        <div>
          <md-button-toggle-group formControlName="ageUnit" [(ngModel)]="selectedUnit">
            <md-button-toggle *ngFor="let unit of ageUnits" [value]="unit.value">
              {{ unit.label }}
            </md-button-toggle>
          </md-button-toggle-group>
        </div>
        <md-error class="mat-body-2" *ngIf="form.get('age').hasError('ageInvalid')">年齡或單位不正確</md-error>
      </ng-container>
    </div>
    `,
  styles: [`
    .age-num{
      width: 50px;
    }
    .age-input{
      display: flex;
      flex-wrap: nowrap;
      flex-direction: row;
      align-items: baseline;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => AgeInputComponent),
      multi: true,
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgeInputComponent implements ControlValueAccessor, OnInit, OnDestroy {

  selectedUnit = AgeUnit.Year;
  form: FormGroup;
  ageUnits = [
    {value: AgeUnit.Year, label: '歲'},
    {value: AgeUnit.Month, label: '月'},
    {value: AgeUnit.Day, label: '天'}
  ];
  dateOfBirth;
  @Input() daysTop = 90;
  @Input() daysBottom = 0;
  @Input() monthsTop = 24;
  @Input() monthsBottom = 1;
  @Input() yearsBottom = 1;
  @Input() yearsTop = 150;
  @Input() debounceTime = 300;
  private subBirth: Subscription;
  private propagateChange = (_: any) => {};

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    const initDate = this.dateOfBirth ? this.dateOfBirth : toDate(subYears(Date.now(), 30));
    const initAge = this.toAge(initDate);
    this.form = this.fb.group({
      birthday: [initDate, this.validateDate],
      age:  this.fb.group({
        ageNum: [initAge.age],
        ageUnit: [initAge.unit]
      }, {validator: this.validateAge('ageNum', 'ageUnit')})
    });
    const birthday = this.form.get('birthday');
    const ageNum = this.form.get('age').get('ageNum');
    const ageUnit = this.form.get('age').get('ageUnit');

    const birthday$ = birthday.valueChanges
      .map(d => ({date: d, from: 'birthday'}))
      .debounceTime(this.debounceTime)
      .distinctUntilChanged()
      .filter(date => birthday.valid);
    const ageNum$ = ageNum.valueChanges
      .startWith(ageNum.value)
      .debounceTime(this.debounceTime)
      .distinctUntilChanged();
    const ageUnit$ = ageUnit.valueChanges
      .startWith(ageUnit.value)
      .debounceTime(this.debounceTime)
      .distinctUntilChanged();
    const age$ = Observable
      .combineLatest(ageNum$, ageUnit$, (_num, _unit) => this.toDate({age: _num, unit: _unit}))
      .map(d => ({date: d, from: 'age'}))
      .filter(_ => this.form.get('age').valid);
    const merged$ = Observable
      .merge(birthday$, age$)
      .filter(_ => this.form.valid)
      .debug('[Age-Input][Merged]:');
    this.subBirth = merged$.subscribe(date => {
      const age = this.toAge(date.date);
      if(date.from === 'birthday') {
        if(age.age === ageNum.value && age.unit === ageUnit.value) {
          return;
        }
        ageUnit.patchValue(age.unit, {emitEvent: false, emitModelToViewChange: true, emitViewToModelChange: true});
        ageNum.patchValue(age.age, {emitEvent: false});
        this.selectedUnit = age.unit;
        this.propagateChange(date.date);

      } else {
        const ageToCompare = this.toAge(this.form.get('birthday').value);
        if(age.age !== ageToCompare.age || age.unit !== ageToCompare.unit) {
          this.form.get('birthday').patchValue(date.date, {emitEvent: false});
          this.propagateChange(date.date);
        }
      }
    });
  }

  ngOnDestroy() {
    if(this.subBirth) {
      this.subBirth.unsubscribe();
    }
  }

  public writeValue(obj: Date) {
    if (obj) {
      const date = toDate(obj);
      this.form.get('birthday').patchValue(date, {emitEvent: false});
    }
  }

  public registerOnChange(fn: any) {
    this.propagateChange = fn;
  }
  
  public registerOnTouched() {
  }

  validate(c: FormControl): {[key: string]: any} {
    const val = c.value;
    if (!val) {
      return null;
    }
    if (isValidDate(val)) {
      return null;
    }
    return {
      ageInvalid: true
    };
  }

  validateDate(c: FormControl): {[key: string]: any} {
    const val = c.value;
    return isValidDate(val) ? null : {
      birthdayInvalid: true
    }
  }

  validateAge(ageNumKey: string, ageUnitKey:string): {[key: string]: any} {
    return (group: FormGroup): {[key: string]: any} => {
      const ageNum = group.controls[ageNumKey];
      const ageUnit = group.controls[ageUnitKey];
      let result = false;
      const ageNumVal = ageNum.value;

      switch (ageUnit.value) {
        case AgeUnit.Year: {
          result = ageNumVal >= this.yearsBottom && ageNumVal <= this.yearsTop
          break;
        }
        case AgeUnit.Month: {
          result = ageNumVal >= this.monthsBottom && ageNumVal <= this.monthsTop
          break;
        }
        case AgeUnit.Day: {
          result = ageNumVal >= this.daysBottom && ageNumVal <= this.daysTop
          break;
        }
        default:
          result = false;
      }
      return result ? null : {
        ageInvalid: true
      }
    }
  }

  private toAge(dateStr: string): Age {
    const date = parse(dateStr);
    const now = new Date();
    if (isBefore(subDays(now, this.daysTop), date)) {
      return {
        age: differenceInDays(now, date),
        unit: AgeUnit.Day
      };
    } else if (isBefore(subMonths(now, this.monthsTop), date)) {
      return {
        age: differenceInMonths(now, date),
        unit: AgeUnit.Month
      };
    } else {
      return {
        age: differenceInYears(now, date),
        unit: AgeUnit.Year
      };
    }
  }

  private toDate(age: Age): string {
    const now = new Date();
    switch (age.unit) {
      case AgeUnit.Year: {
        return toDate(subYears(now, age.age));
      }
      case AgeUnit.Month: {
        return toDate(subMonths(now, age.age));
      }
      case AgeUnit.Day: {
        return toDate(subDays(now, age.age));
      }
      default: {
        return this.dateOfBirth;
      }
    }
  }
}

慕課網 Angular 視頻課上線: http://coding.imooc.com/class/123.html?mc_marking=1fdb7649e8a8143e8b81e221f9621c4a&mc_channel=banner

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容