「Angular7+NgRx+SSR全家桶」開發(fā)QQ音樂

項目說明

現(xiàn)在主要是做React開發(fā),也是使用服務(wù)端渲染(DEMO),最近想用Angular寫一個項目體驗一下TypeScript大法,對比Angular對比React從開發(fā)體驗上來講個人覺得更加方便很多東西不需要你自己去單獨安裝.

線上地址:https://music.soscoon.com

Github: https://github.com/Tecode/angular-music-player/tree/QQ-music

目前還在努力開發(fā)中,目前完成了80%...

預(yù)覽圖

image
image

技術(shù)棧

  • Angular 7.2.0
  • pm2 3.4.1
  • better-scroll 1.15.1
  • rxjs 6.3.3
  • ngrx 7.4.0
  • hammerjs 2.0.8

NgRx配置

Actions

Vuex,Redux一樣都需要先定義一些actionType,這里舉了一個例子

src/store/actions/list.action.ts

import { Action } from '@ngrx/store';

export enum TopListActionTypes {
    LoadData = '[TopList Page] Load Data',
    LoadSuccess = '[TopList API] Data Loaded Success',
    LoadError = '[TopList Page] Load Error',
}

//  獲取數(shù)據(jù)
export class LoadTopListData implements Action {
    readonly type = TopListActionTypes.LoadData;
}

export class LoadTopListSuccess implements Action {
    readonly type = TopListActionTypes.LoadSuccess;
}

export class LoadTopListError implements Action {
    readonly type = TopListActionTypes.LoadError;
    constructor(public data: any) { }
}

合并ActionType

src/store/actions/index.ts

export * from './counter.action';
export * from './hot.action';
export * from './list.action';
export * from './control.action';

Reducers

存儲數(shù)據(jù)管理數(shù)據(jù),根據(jù)ActionType修改狀態(tài)

src/store/reducers/list.reducer.ts

import { Action } from '@ngrx/store';
import { TopListActionTypes } from '../actions';

export interface TopListAction extends Action {
  payload: any,
  index: number,
  size: number
}

export interface TopListState {
  loading?: boolean,
  topList: Array<any>,
  index?: 1,
  size?: 10
}

const initState: TopListState = {
  topList: [],
  index: 1,
  size: 10
};

export function topListStore(state: TopListState = initState, action: TopListAction): TopListState {
  switch (action.type) {
    case TopListActionTypes.LoadData:
      return state;
    case TopListActionTypes.LoadSuccess:
      state.topList = (action.payload.playlist.tracks || []).slice(state.index - 1, state.index * state.size);
      return state;
    case TopListActionTypes.LoadErrhammerjsor:
      return state;
    default:
      return state;
  }
}

合并Reducer

src/store/reducers/index.ts

import { ActionReducerMap, createSelector, createFeatureSelector } from '@ngrx/store';

//import the weather reducer
import { counterReducer } from './counter.reducer';
import { hotStore, HotState } from './hot.reducer';
import { topListStore, TopListState } from './list.reducer';
import { controlStore, ControlState } from './control.reducer';

//state
export interface state {
    count: number;
    hotStore: HotState;
    topListStore: TopListState;
    controlStore: ControlState;
}

//register the reducer functions
export const reducers: ActionReducerMap<state> = {
    count: counterReducer,
    hotStore,
    topListStore,
    controlStore,
}

Effects

處理異步請求,類似于redux-sage redux-thunk,下面這個例子是同時發(fā)送兩個請求,等到兩個請求都完成后派遣HotActionTypes.LoadSuccesstype到reducer中處理數(shù)據(jù).

當(dāng)出現(xiàn)錯誤時使用catchError捕獲錯誤,并且派遣new LoadError()處理數(shù)據(jù)的狀態(tài).

LoadError

export class LoadError implements Action {
    readonly type = HotActionTypes.LoadError;
    constructor(public data: any) { }
}
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { HotActionTypes, LoadError, LoadSongListError } from '../actions';
import { of, forkJoin } from 'rxjs';
import { HotService } from '../../services';


@Injectable()
export class HotEffects {

  @Effect()
  loadHotData$ = this.actions$
    .pipe(
      ofType(HotActionTypes.LoadData),
      mergeMap(() =>
        forkJoin([
          this.hotService.loopList()
            .pipe(catchError(() => of({ 'code': -1, banners: [] }))),
          this.hotService.popularList()
            .pipe(catchError(() => of({ 'code': -1, result: [] }))),
        ])
          .pipe(
            map(data => ({ type: HotActionTypes.LoadSuccess, payload: data })),
            catchError((err) => {
              //call the action if there is an error
              return of(new LoadError(err["message"]));
            })
          ))
    )

  constructor(
    private actions$: Actions,
    private hotService: HotService
  ) { }
}

合并Effect

將多個Effect文件合并到一起

src/store/effects/hot.effects.ts

import { HotEffects } from './hot.effects';
import { TopListEffects } from './list.effects';

export const effects: any[] = [HotEffects, TopListEffects];
export * from './hot.effects';
export * from './list.effects';

注入Effect Reducerapp.module

src/app/app.module.ts

import { StoreModule } from '@ngrx/store';
import { EffectsModule } from "@ngrx/effects";
import { reducers, effects } from '../store';

imports: [
  ...
    StoreModule.forRoot(reducers),
    EffectsModule.forRoot(effects),
    ...
],

請求處理

使用HttpClient

post get delate put請求都支持HttpClient詳細(xì)說明

src/services/list.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from "@angular/common/http";

@Injectable({
  providedIn: 'root'
})
export class TopListService {
  constructor(private http: HttpClient) {
  }
  // 輪播圖
  topList() {
    return this.http.get('/api/top/list?idx=1');
  }
}

src/services/index.ts

export * from "./hot.service";
export * from "./list.service";

響應(yīng)攔截器

這里處理異常,對錯誤信息進(jìn)行統(tǒng)一捕獲,例如未登錄全局提示信息,在這里發(fā)送請求時在消息頭加入Token信息,具體的需要根據(jù)業(yè)務(wù)來作變更.

import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpRequest,
  HttpResponse,
  HttpHandler,
  HttpEvent,
  HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { map, catchError } from 'rxjs/operators';

@Injectable()
export class HttpConfigInterceptor implements HttpInterceptor {
  // constructor(public errorDialogService: ErrorDialogService) { }
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    let token: string | boolean = false;
    // 兼容服務(wù)端渲染
    if (typeof window !== 'undefined') {
      token = localStorage.getItem('token');
    }

    if (token) {
      request = request.clone({ headers: request.headers.set('Authorization', 'Bearer ' + token) });
    }

    if (!request.headers.has('Content-Type')) {
      request = request.clone({ headers: request.headers.set('Content-Type', 'application/json') });
    }

    request = request.clone({ headers: request.headers.set('Accept', 'application/json') });

    return next.handle(request).pipe(
      map((event: HttpEvent<any>) => {
        if (event instanceof HttpResponse) {
          // console.log('event--->>>', event);
          // this.errorDialogService.openDialog(event);
        }
        return event;
      }),
      catchError((error: HttpErrorResponse) => {
        let data = {};
        data = {
          reason: error && error.error.reason ? error.error.reason : '',
          status: error.status
        };
        // this.errorDialogService.openDialog(data);
        console.log('攔截器捕獲的錯誤', data);
        return throwError(error);
      }));
  }
}

攔截器依賴注入

src/app/app.module.ts

需要把攔截器注入到app.module才會生效

// http攔截器,捕獲異常伺帘,加Token
import { HttpConfigInterceptor } from '../interceptor/httpconfig.interceptor';
...
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: HttpConfigInterceptor,
      multi: true
    },
    ...
  ],

發(fā)送一個請求

項目使用了NgRx,所以我就用NgRx發(fā)請求this.store.dispatch(new LoadHotData()),在Effect中會接收到type是HotActionTypes.LoadData,通過Effect發(fā)送請求.

設(shè)置hotStore$可觀察類型,當(dāng)數(shù)據(jù)改變時也會發(fā)生變化public hotStore$: Observable<HotState>,詳細(xì)見以下代碼:

到此就完成了數(shù)據(jù)的請求

import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { LoadHotData } from '../../store';
import { HotState } from '../../store/reducers/hot.reducer';

@Component({
  selector: 'app-hot',
  templateUrl: './hot.component.html',
  styleUrls: ['./hot.component.less']
})
export class HotComponent implements OnInit {
  // 將hotStore$設(shè)置為可觀察類型
  public hotStore$: Observable<HotState>;
  public hotData: HotState = {
    slider: [],
    recommendList: []
  };

  @ViewChild('slider') slider: ElementRef;

  constructor(private store: Store<{ hotStore: HotState }>) {
    this.hotStore$ = store.pipe(select('hotStore'));
  }

  ngOnInit() {
    // 發(fā)送請求,獲取banner數(shù)據(jù)以及列表數(shù)據(jù)
    this.store.dispatch(new LoadHotData());
    // 訂閱hotStore$獲取改變后的數(shù)據(jù)
    this.hotStore$.subscribe(data => {
      this.hotData = data;
    });
  }
}

服務(wù)端渲染

Angular的服務(wù)端渲染可以使用angular-cli創(chuàng)建ng add @nguniversal/express-engine --clientProject 你的項目名稱要和package.json里面的name一樣

angular-music-player項目已經(jīng)運行過了不要再運行

ng add @nguniversal/express-engine --clientProject angular-music-player

// 打包運行
npm run build:ssr && npm run serve:ssr

運行完了以后你會看見package.jsonscripts多了一些服務(wù)端的打包和運行命令

  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "compile:server": "webpack --config webpack.server.config.js --progress --colors",
    "serve:ssr": "node dist/server",
    "build:ssr": "npm run build:client-and-server-bundles && npm run compile:server",
    "build:client-and-server-bundles": "ng build --prod && ng run angular-music-player:server:production",
    "start:pro": "pm2 start dist/server"
  }

Angular引入hammerjs

hammerjs在引入的時候需要window對象,在服務(wù)端渲染時會報錯,打包的時候不會報錯,打包完成以后運行npm run serve:ssrReferenceError: window is not defined.

解決方法使用require引入

!!記得加上declare var require: any;不然ts回報錯typescript getting error TS2304: cannot find name ' require',對于其它的插件需要在服務(wù)端注入我們都可以使用這樣的方法.

src/app/app.module.ts

declare var require: any;

let Hammer = { DIRECTION_ALL: {} };
if (typeof window != 'undefined') {
  Hammer = require('hammerjs');
}

export class MyHammerConfig extends HammerGestureConfig {
  overrides = <any>{
    // override hammerjs default configuration
    'swipe': { direction: Hammer.DIRECTION_ALL }
  }
}
// 注入hammerjs配置
providers: [
...
    {
      provide: HAMMER_GESTURE_CONFIG,
      useClass: MyHammerConfig
    }
  ],
...

模塊按需加載

創(chuàng)建list-component

ng g c list --module app 或 ng generate component --module app

運行成功以后你會發(fā)現(xiàn)多了一個文件夾出來,里面還多了四個文件


image

創(chuàng)建module

ng generate module list --routing

運行成功會多出兩個文件list-routing.module.tslist.module.ts

image
image

配置src/app/list/list-routing.module.ts

導(dǎo)入ListComponent配置路由

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ListComponent } from './list.component';

const routes: Routes = [
  {
    path: '',
    component: ListComponent
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class ListRoutingModule { }

配置src/app/list/list.module.ts

ListComponent注冊到NgModule中,在模板內(nèi)就可以使用<app-list><app-list>,在這里要注意一下,當(dāng)我們使用ng g c list --module app創(chuàng)建component時會會幫我們在app.module.ts中聲明一次,我們需要將它刪除掉,不然會報錯.

image
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { ListRoutingModule } from './list-routing.module';
import { ListComponent } from './list.component';
import { BigCardComponent } from '../common/big-card/big-card.component';
import { ShareModule } from '../share.module';

@NgModule({
  declarations: [
    ListComponent,
    BigCardComponent
  ],
  imports: [
    CommonModule,
    ListRoutingModule,
    ShareModule
  ]
})
export class ListModule { }

配置src/app/list/list.module.ts

沒有配置之前是這樣的

image

配置以后

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/hot' },
  { path: 'hot', loadChildren: './hot/hot.module#HotModule' },
  { path: 'search', component: SearchComponent },
  { path: 'profile', component: ProfileComponent },
  { path: 'list', loadChildren: './list/list.module#ListModule' },
  { path: 'smile', loadChildren: './smile/smile.module#SmileModule' },
];

打開瀏覽器查看一下,會看見多了一個list-list-module.js的文件

image

到這里按需加載就已經(jīng)都結(jié)束

為什么需要src/app/share.module.ts這個模塊

先看看寫的什么

src/app/share.module.ts聲明了一些公共的組件,例如<app-scroll></app-scroll>,我們要時候的時候需要將這個module導(dǎo)入到你需要的模塊中

src/app/app.module.ts src/app/list/list.module.ts src/app/hot/hot.module.ts都有,可以去拉取源碼查看,慢慢的會發(fā)現(xiàn)其中的奧秘.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HammertimeDirective } from '../directive/hammertime.directive';
import { ScrollComponent } from './common/scroll/scroll.component';
import { SliderComponent } from './common/slider/slider.component';
import { FormatTimePipe } from '../pipes/format-time.pipe';

@NgModule({
  declarations: [
    ScrollComponent,
    HammertimeDirective,
    SliderComponent,
    FormatTimePipe
  ],
  imports: [
    CommonModule
  ],
  exports: [
    ScrollComponent,
    HammertimeDirective,
    SliderComponent,
    FormatTimePipe
  ]
})
export class ShareModule { }

跨域處理

這里要說明一下,我在項目中只配置了開發(fā)環(huán)境的跨域處理,生產(chǎn)環(huán)境沒有,我使用的是nginx做的代理.運行npm start才會成功.

新建文件src/proxy.conf.json

target要代理的ip或者是網(wǎng)址

pathRewrite路徑重寫

{
  "/api": {
    "target": "https://music.soscoon.com/api",
    "secure": false,
    "pathRewrite": {
      "^/api": ""
    },
    "changeOrigin": true
  }
}

請求例子

songListDetail(data: any) {
    return this.http.get(`/api/playlist/detail?id=${data.id}`);
}

配置angular.json

重啟一下項目跨域就配置成功了

"serve": {
    "builder": "@angular-devkit/build-angular:dev-server",
    "options": {
    "browserTarget": "angular-music-player:build",
    "proxyConfig": "src/proxy.conf.json"
      },
    "configurations": {
    "production": {
      "browserTarget": "angular-music-player:build:production"
        }
      }
    }

到這里先告一段落了,有什么建議或意見歡迎大家提,之后有補充的我再加上.

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末钮科,一起剝皮案震驚了整個濱河市周偎,隨后出現(xiàn)的幾起案子闺属,更是在濱河造成了極大的恐慌潜索,老刑警劉巖逢净,帶你破解...
    沈念sama閱讀 221,406評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件被芳,死亡現(xiàn)場離奇詭異,居然都是意外死亡嘱么,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,395評論 3 398
  • 文/潘曉璐 我一進(jìn)店門顽悼,熙熙樓的掌柜王于貴愁眉苦臉地迎上來曼振,“玉大人几迄,你說我怎么就攤上這事”溃” “怎么了映胁?”我有些...
    開封第一講書人閱讀 167,815評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長甲雅。 經(jīng)常有香客問我解孙,道長,這世上最難降的妖魔是什么抛人? 我笑而不...
    開封第一講書人閱讀 59,537評論 1 296
  • 正文 為了忘掉前任弛姜,我火速辦了婚禮,結(jié)果婚禮上妖枚,老公的妹妹穿的比我還像新娘廷臼。我一直安慰自己,他們只是感情好绝页,可當(dāng)我...
    茶點故事閱讀 68,536評論 6 397
  • 文/花漫 我一把揭開白布中剩。 她就那樣靜靜地躺著,像睡著了一般抒寂。 火紅的嫁衣襯著肌膚如雪结啼。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,184評論 1 308
  • 那天屈芜,我揣著相機與錄音郊愧,去河邊找鬼。 笑死井佑,一個胖子當(dāng)著我的面吹牛属铁,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播躬翁,決...
    沈念sama閱讀 40,776評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼焦蘑,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了盒发?” 一聲冷哼從身側(cè)響起例嘱,我...
    開封第一講書人閱讀 39,668評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎宁舰,沒想到半個月后拼卵,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,212評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡蛮艰,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,299評論 3 340
  • 正文 我和宋清朗相戀三年腋腮,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,438評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡即寡,死狀恐怖徊哑,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情聪富,我是刑警寧澤实柠,帶...
    沈念sama閱讀 36,128評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站善涨,受9級特大地震影響窒盐,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜钢拧,卻給世界環(huán)境...
    茶點故事閱讀 41,807評論 3 333
  • 文/蒙蒙 一蟹漓、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧源内,春花似錦葡粒、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,279評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至颂斜,卻和暖如春夫壁,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背沃疮。 一陣腳步聲響...
    開封第一講書人閱讀 33,395評論 1 272
  • 我被黑心中介騙來泰國打工盒让, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人司蔬。 一個月前我還...
    沈念sama閱讀 48,827評論 3 376
  • 正文 我出身青樓邑茄,卻偏偏與公主長得像,于是被迫代替她去往敵國和親俊啼。 傳聞我的和親對象是個殘疾皇子肺缕,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,446評論 2 359

推薦閱讀更多精彩內(nèi)容