Babel Coder

[Angular2#4] จัดการเส้นทางด้วย Routing และ เข้าถึงข้อมูลผ่าน Service

beginner

 บทความนี้เป็นส่วนหนึ่งของชุดบทความ [ชุดบทความ] สอนสร้างและใช้งานเว็บแอพพลิเคชันด้วย Angular2

การสร้างแอพพลิเคชันด้วย Angular2 ต้องประกอบด้วยโมดูลอย่างน้อยหนึ่งโมดูลที่เป็นโมดูลหลักของแอพพลิเคชันเรา เราสามารถสร้างคอมโพแนนท์และใช้งานคอมโพแนนท์เหล่านั้นเพื่อแสดงผลผ่านทางเทมเพลตได้ อาศัยความสามารถของ decorator และ metadata ทำให้คลาสธรรมดาของเรากลายร่างเป็นส่วนต่างๆของ Angular2 ได้ตามที่เราต้องการ นั่นคือทั้งหมดที่เราพูดถึงกันในบทความที่แล้ว

หากแอพพลิเคชันเรามีเพจเดียวย่อมจืดชืด บทความนี้จึงจำใจเกิดขึ้นเพื่อแนะนำให้เพื่อนๆรู้จักกับ routing หรือการจัดการเส้นทางใน Angular2 ก่อนเรื่องราวจะดำเนินต่อไป พวกเธอว์จงทำให้ชัดว่าได้อ่านบทความเหล่านี้ดีแล้ว~

สารบัญ

วิเคราะห์เส้นทางในวิกิ

ถ้าเพื่อนๆทำตามบทความที่แล้วหน้าสุดท้ายที่เราได้ควรเป็นแบบนี้ครับ

Home Page

เราจะลงมือทำให้ปุ่ม All Pages ด้านขวาบนของเราตลิกได้กัน แน่นอนครับแค่คลิกเฉยๆคงไม่มีความหมายใดๆ แต่เราจะให้แสดงวิกิทั้งหมดออกมาด้วย ทั้งนี้ URL ของเราต้องเปลี่ยนเป็น http://localhost:4200/pages เช่นเดียวกัน

URL ต่างๆที่เราจะใช้ในแอพพลิเคชันนี้เป็นดังนี้ครับ

/               แสดงหน้า Homepage
/pages          ดู wiki page ทั้งหมด
/pages/:id      ดู wiki หน้าที่มี ID ตามที่ระบุ
/pages/:id/new  สร้าง wiki หน้าใหม่
/pages/:id/edit แสดงหน้าแก้ไข wiki ที่มี ID ตามที่ระบุ
/about          แสดงหน้า about

โดยหน้าเพจที่จะแสดงเมื่อ URL เปลี่ยนไปเป็นไปตามรูปข้างล่างนี้เลยครับ

Wireframe

Wiki ของเราจะมีส่วนหลักๆคือหน้า Homepage, หน้า Pages, หน้า Page และหน้า About ครับ ในส่วนของ Page นั้นเราต้องสามารถทำการดู Wiki Page ได้ทั้งหมด สามารถสร้าง Page ใหม่ได้รวมถึงสามารถแก้ไขได้ด้วย แต่ไม่อนุญาตให้ลบ

จะเห็นว่าแอพพลิเคชันของเราเต็มไปด้วยเส้นทาง (Route) ในการวิ่งไปมาระหว่างหน้าเพจ เราจึงต้องอาศัยตัวควบคุมเส้นทาง (Router) ในการจัดการการเปลี่ยนหน้าเพจ และพระเอกของเราที่จะมาช่วยจัดการเรื่องนี้คือสื่งที่บรรจุอยู่ในโมดูล RouterModule นั่นเอง

เพื่อให้การจัดการเส้นทางใน Angular2 ของเราลื่นไหนจนหัวแตก เราจึงต้องนำเข้า RouterModule และติดตั้งมันไว้กับ app.module.ts ของเรา

import { ApplicationRef, NgModule } from [email protected]/core';
import { BrowserModule } from [email protected]/platform-browser';
import { CommonModule } from [email protected]/common';
import { FormsModule } from [email protected]/forms';
// RouterModule ฉันเลือกนาย~
import { Routes, RouterModule } from [email protected]/router';

import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { HeaderComponent } from './shared/header/header.component';

const appRoutes: Routes = [
  // เราจะนิยาม Route หรือเส้นทางของเราในนี้
  // เช่น
  // { path: 'pages', component: PageListComponent },
  // เพื่อบอกว่าเมื่อไหร่ที่เข้ามาจาก /pages ให้วิ่งไปใช้บริการคอมโพแนนท์ชื่อ PageListComponent 
]

@NgModule({
  declarations: [
    AppComponent, 
    HomeComponent, 
    HeaderComponent
  ],
  imports: [
    BrowserModule,
    CommonModule,
    FormsModule,
    // จ๊ะเอ๋
    RouterModule.forRoot(appRoutes)
  ],
  providers: [],
  entryComponents: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule {

}

โรงพยาบาลแห่งหนึ่งมีพนักงานต้อนรับหน้าทางเข้าเป็นสาวสวยคนหนึ่ง ทุกครั้งที่ผู้ป่วยเข้าโรงพยาบาลก็จะถามทางเธอว่าไปแต่ละแผนกทางไหน เพื่อนๆจะเห็นว่าเส้นทางที่จะไปแต่ละแผนกนั้นมีหลายทาง ในขณะที่คนบอกทางสามารถมีได้คนเดียว

เปรียบผู้ป่วยเหมือนเว็บบราวเซอร์ครับ ถ้าเราอยากไป /pages แต่มืดบอดไม่รู้จะไปทางไหน พนักงานสาวต้อนรับที่เป็น Router จะช่วยตอบคำถามนี้ให้เพื่อเลือกหนทางที่สว่างให้กับคุณ เส้นทาง (Route) นั้นมีหลายทางแต่เมื่อ Router สาวแสนสวยกำหนดเส้นทางให้แล้ว เบราเซอร์อย่างคุณก็มีแต่ต้องไปตามเส้นทางนั้น สุดท้ายคุณจะได้พบกับแผนกหรือคอมโพแนนท์ที่คุณต้องใช้ในการแสดงผลนั่นเอง

ถึงเวลาชำแหละไฟล์ app.module.ts ที่เราเพิ่ม RouterModule เข้าไปกันแล้ว เริ่มจาก

import { Routes, RouterModule } from [email protected]/router';

ถ้าไม่มีเส้นทางให้เดินทาง ต่อให้มีพนักงานแสนสวยยืนหลอกล่อให้เดินไปก็ไปไหนไม่ได้ เราจึงต้องประกาศเส้นทางหรือ Routes ขึ้นมาก่อน

const appRoutes: Routes = [
]

เส้นทางของเรามีหลายเส้น เราจึงต้องเก็บในอาร์เรย์ แต่ละเส้นทางประกอบด้วย path และ component เช่น

const appRoutes: Routes = [
  { path: 'pages', component: PageListComponent },
]

Routes หรือเส้นทางจากตัวอย่างบนมีหนึ่งเดียวคือเส้นทางสำหรับ pages เมื่อเบราเซอร์เข้าถึง /pages ตัว Router หรือพนักงานต้อนรับของเราจะคอยบอกให้ว่าคอมโพแนนท์ไหนคือปลายทางของเรา ในที่นี้ก็คือคอมโพแนนท์ PageListComponent นั่นเอง

ถึงแม้เราจะมีเส้นทางแล้ว แต่หาก root module ของเราไม่รู้จักก็เท่านั้น เราจึงต้องสถาปนาให้ root module ของเรารับรู้ผ่าน RouterModule.forRoot()

imports: [
  BrowserModule,
  CommonModule,
  FormsModule,
  // จ๊ะเอ๋
  // root module เอ๊ย
  // เจ้าจงรู้ถึงการมีอยู่ของบรรดา routes ใน appRoutes ซะ
  RouterModule.forRoot(appRoutes)
],

app.routing.ts

ลองจินตนาการเมื่อเส้นทางของเรามีซัก 10 ตัว ไฟล์ app.module.ts ของเราก็จะเริ่มบวมใช่ไหมครับ ทั้งๆที่ชื่อของมันคือ module แต่ทำไมมันต้องรับรู้ด้วยหละว่าการจัดการเส้นทางคืออะไร? แบบนี้ไม่ดีแน่เราควรแยกการจัดการเส้นทางของเราออกมาอีกไฟล์ และนั่นหละครับคือ app.routing.ts ที่เรากำลังจะสร้างขึ้นมา

import { ModuleWithProviders }  from [email protected]/core';
import { Routes, RouterModule } from [email protected]/router';

const appRoutes: Routes = [

];

// เรา export ตัวแปรประเภทค่าคงที่ (const) ชื่อ routing ออกไป
// routing นี้เป็นผลลัพธ์จากการเรียก RouterModule.forRoot(appRoutes)
// โดย routing ของเราเป็น ModuleWithProviders
// เพื่อนๆคนไหนไม่เข้าใจว่าทำไมเราต้องเขียน routing: ModuleWithProviders
// แนะนำให้อ่าน ชุดบทความสอนใช้งาน TypeScript ที่ https://www.babelcoder.com/blog/series/typescript ครับ
export const routing: ModuleWithProviders = RouterModule.forRoot(appRoutes);

แน่นอนว่าเพื่อให้ root module ของเรารับรู้ถึงการมีอยู่ของเส้นทางเหล่านี้ เราต้องเพิ่ม routing จาก app.routing.ts เข้าไปเป็นส่วนหนึ่งของโมดูลเรา

import { ApplicationRef, NgModule } from [email protected]/core';
import { BrowserModule } from [email protected]/platform-browser';
import { CommonModule } from [email protected]/common';
import { FormsModule } from [email protected]/forms';

// ตรงนี้
import { routing } from './app.routing';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { HeaderComponent } from './shared/header/header.component';

@NgModule({
  declarations: [
    AppComponent, 
    HomeComponent, 
    HeaderComponent
  ],
  imports: [
    BrowserModule,
    CommonModule,
    FormsModule,
    // และตรงนี้
    routing
  ],
  providers: [],
  entryComponents: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule {

}

รู้จักกับ Router Outlet

ก่อนหน้านี้เราไม่มีการใช้ Router เพื่อจัดการเส้นทางกันเลย แต่ทำไม Angular2 ถึงนำ HomeComponent มาแสดงผลได้อย่างถูกต้อง เมื่อเราเข้าถึงด้วย /

app.module.ts ของเรานิยามไว้ว่า bootstrap component หรือคอมโพแนนท์เริ่มต้นคือ AppComponent เมื่อ Angular2 ทำงาน AppComponent จึงถูกทำและแสดงผลด้วยเทมเพลตของมัน

@NgModule({
  declarations: [
    AppComponent, 
    HomeComponent, 
    HeaderComponent,
    PageListComponent
  ],
  imports: [
    BrowserModule,
    CommonModule,
    FormsModule,
    routing
  ],
  providers: [],
  entryComponents: [AppComponent],
  // ข้าอยู่นี่
  bootstrap: [AppComponent]
})

ภายในเทมเพลตของ AppComponent (app.component.html) เราบอกให้มันนำ HomeComponent มาเป็นส่วนหนึ่งของการแสดงผลด้วยผ่านการเรียก <app-home> จึงไม่แปลกที่เราสามารถแสดงผล HomeComponent ออกมาได้อย่างสวยงาม

<app-header></app-header>
<app-home></app-home>

แต่ตอนนี้สถานการณ์ของเราเปลี่ยนไปแล้ว เราไม่อยากให้ AppComponent ของเรายึดติดอยู่กับ HomeComponent อีกต่อไป เราอยากให้ AppComponent ของเรายืดหยุ่นมากขึ้น เมื่อมีผู้ร้องขอ / เข้ามาค่อยนำ HomeComponent มาแสดง แต่ถ้ามีคนร้องขอ /pages ให้นำ PageListComponent มาแสดงแทน เหตุนี้เราจึงมิอาจใช้ <app-home> ใส่ไปใน app.component.html ได้อีกต่อไป

RouterOutlet คือวีรบุรุษที่ช่วยกอบกู้สถานการณ์ของเรา เมื่อเรามีการตั้งค่าเส้นทาง (routes) เรียบร้อยแล้ว ตัว Router จะเป็นผู้จัดการเส้นทางนั้นให้ โดยจะพิจารณาเลือกคอมโพแนนท์ที่เหมาะสมตามแต่ path ที่เข้ามา เมื่อเลือกคู่ครองได้แล้วคอมโพแนนท์นั้นก็จะแสดงผลใน RouterOutlet

เปลี่ยน app.component.html ของเราเพื่อให้มีการใช้ RouterOutlet ดังนี้

<app-header></app-header>
<router-outlet></router-outlet>

เราจะเรียกคอมโพแนนท์ที่มีการใช้งาน RouterOutlet เพื่อแสดงผลคอมโพแนนท์ที่สัมพันธ์กับ path ใน URL ว่า Routing Component

เนื่องจากผู้เขียนอยากจัดสไตล์ให้กับคอมโพแนนท์ที่จะแสดงผลใต้ RouterOutlet ซะหน่อย จึงขอเพิ่ม div และคลาสบางตัวดังนี้

<app-header></app-header>
<div class='container'>
  <div class='content'>
    <router-outlet></router-outlet>
  </div>
</div>

จัดการเพิ่มสไตล์อย่างมีสีสันที่ app.component.scss

.container {
  width: 50%;
  margin: 0 auto;
}

.content {
  margin-top: 4rem;
}

ก่อนที่เราจะกลับไปดูที่เว็บเบราเซอร์กัน เราลืมอะไรไปบางอย่างใช่ไหม… แน่นอน เรายังไม่ได้เพิ่ม route ให้หน้า homepage ของเราเลย จัดการเปิด app.routing.ts ขึ้นมาแล้วปรับแต่งดังนี้ครับ

import { ModuleWithProviders }  from [email protected]/core';
import { Routes, RouterModule } from [email protected]/router';

import { HomeComponent } from './home/home.component';

const appRoutes: Routes = [
  // ถ้าไม่ระบุ path อะไรเข้ามา (เข้ามาด้วย /) เช่น http://localhost:4200
  // ขอให้ปลุก HomeComponent ขึ้นมาใส่ใน RouterOutlet ของ app.component.html
  { path: '', component: HomeComponent }
];

export const routing: ModuleWithProviders = RouterModule.forRoot(appRoutes);

เท่านี้ก็เรียบร้อย เข้าหน้า http://localhost:4200 เพื่อนๆควรจะพบกับหน้าโฮมเพจของเราแบบนี้ครับ

Home Page

จัดการ /pages เส้นทางที่สองของเรา

ถึงเวลาที่เราจะเพิ่มเส้นทางให้กับ /pages แล้วครับ เมื่อไหร่ก็แล้วแต่ที่เราเข้าที่ /pages ขอให้ Router ช่วยปลุก PageListComponent ขึ้นมาทำงาน แน่นอนครับว่าคอมโพแนนท์ตัวนี้ต้องมีเทมเพลตที่สัมพันธ์กับมัน เป็นผลให้หน้าเพจดังรูปข้างล่างปรากฎตัวออกมา

Pages

เริ่มจากเพิ่มคอมโพแนนท์ PageListComponent ของเราผ่านคำสั่งนี้ครับ หากใครออกคำสั่งแล้วขึ้นข้อผิดพลาดว่า "src\app\pages"" is not a valid path. ให้ทำการสร้างโฟลเดอร์ src/app/pages ขึ้นมาก่อนครับ

$ ng g component pages/page-list

เมื่อออกคำสั่งดังกล่าวเราก็จะได้โครงสร้างไฟล์ของเราแบบนี้

app
|------ pages
        |------ page-list
                |------ page-list.component.scss
                |------ page-list.component.html
                |------ page-list.component.spec.ts
                |------ page-list.component.ts
                |------ index.ts
                |------ shared
                        |------ index.ts

จุดนี้เป็นหลักการสร้างโฟลเดอร์ที่อยากให้เพื่อนๆเรียนรู้ครับ ก่อนที่เราจะลงมือสร้างโฟลเดอร์เพื่อยัดคอมโพแนนท์ใดๆลงไป ขอให้เพื่อนๆตั้งคำถามในใจก่อนว่าสิ่งที่กำลังจะสร้างเป็นฟีเจอร์หรือคุณสมบัติหลักอันหนึ่งของแอพพลิเคชันเราไหม

pages เป็นคุณสมบัติหนึ่งของแอพพลิเคชันเรา เราจึงสร้างโฟลเดอร์แยกออกมาชื่อ pages และแน่นอนว่า page-list ที่ใช้แสดงหน้าเพจทั้งหมดเป็นคุณสมบัติย่อยของ pages เราจึงสร้างโฟลเดอร์ page-list ไว้ภายใต้ pages อีกทีนึง ในอนาคตหากเรามี page-detail สำหรับใช้แสดงหน้าวิกิย่อยแต่ละตัว เราก็สามารถวางไว้ใต้ pages ได้ เพราะถือว่ามันคือคุณสมบัติย่อยที่ pages สามารถทำได้

มุมมองกลับกัน ถ้าเพื่อนๆมองว่าคุณสมบัติหลักคือ page-list เพื่อนๆก็สามารถสร้าง page-list ไว้ใต้ app ได้โดยตรง ไม่ต้องขึ้นตรงเป็นทาสรับใช้ของ pages

ด้วยหลักการสร้างโฟลเดอร์โดยคำนึงถึงคุณสมบัติหรือ feature เป็นสำคัญนี้เราจะเรียกว่า Folders-by-Feature เมื่อไหร่ก็ตามที่เราเริ่มเห็นว่า pages ของเราใหญ่พอควร เราสามารถแยก pages ของเราออกเป็นอีกโมดูลที่อิสระได้ครับ

กฎเหล็กข้อที่ 1: จงสร้างโครงสร้างไฟล์แบบ Folders-by-Feature

ทำการเพิ่ม PageListComponent เข้าไปใน root module ของเรา เพื่อให้ตลอดทั้งแอพพลิเคชันเรารับรู้ถึงการมีอยู่ของคอมโพแนนท์นี้

// app.module.ts
import { ApplicationRef, NgModule } from [email protected]/core';
import { BrowserModule } from [email protected]/platform-browser';
import { CommonModule } from [email protected]/common';
import { FormsModule } from [email protected]/forms';

import { routing } from './app.routing';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { HeaderComponent } from './shared/header/header.component';
// import ตรงนี้ครับ
import { PageListComponent } from './pages/page-list/page-list.component';

@NgModule({
  declarations: [
    AppComponent, 
    HomeComponent, 
    HeaderComponent,
    // เรียกใช้งานตำแหน่งนี้
    PageListComponent
  ],
  imports: [
    BrowserModule,
    CommonModule,
    FormsModule,
    routing
  ],
  providers: [],
  entryComponents: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule {

}

เมื่อเราเข้าถึง /pages เราก็อยากให้ Router ช่วยพา PageListComponent ไปใส่ใน RouterOutlet ซะหน่อย ดังนั้นเราจึงต้องทำการตั้งค่าเส้นทางของเราแบบนี้ใน app.routing.ts

import { ModuleWithProviders }  from [email protected]/core';
import { Routes, RouterModule } from [email protected]/router';

import { PageListComponent } from './pages/page-list/page-list.component';
import { HomeComponent } from './home/home.component'

const appRoutes: Routes = [
  { path: '',      component: HomeComponent },
  // เมื่อเข้าถึง /pages
  // ให้นำ PageListComponent ไปแสดงผลใน RouterOutlet ของ AppComponent
  { path: 'pages', component: PageListComponent }
];

export const routing: ModuleWithProviders = RouterModule.forRoot(appRoutes);

สิ่งสุดท้ายที่เราจะกระทำต่อหน้าเพจอันจืดชืดของเราก็คือ เสกเวทมนตร์ใส่ลิงก์ All Pages ทางมุมขวาบนให้สามารถคลิกได้และนำเราไปสู่ /pages อย่างแท้จริง

Home Page

Route สำหรับ /pages เราก็มีแล้ว ตอนนี้เราอยากให้การคลิก All Pages นำเราไปสู่การแสดงผล PageListComponent หรือพูดง่ายๆก็คือการคลิก All Pages ต้องนำพาเราไปสู่ Route ที่มีชื่อว่า /pages นั่นเอง

ด้วยความสามารถของ directive ตัวหนึ่งที่ชื่อว่า RouterLink ทำให้เราสามารถระบุได้ว่าหลังการคลิกแท็ก a แล้วจะให้เปลี่ยนเส้นทางไปยังเส้นทางไหน

ตอนนี้ All Pages ของเราอยู่ในส่วนของ HeaderComponent เราจึงเปิด header.component.html ขึ้นมาเพื่อให้ความสามารถที่ต้องการเกิดขึ้น

<header class='header'>
  <nav>
    <a href='javascript:void(0)' class='brand'>Babel Coder Wiki!</a>
    <ul class='menu'>
      <li class='menu__item'>
        <a 
          routerLink='/pages' 
          routerLinkActive='active' 
          class='menu__link'>
          All Pages
        </a>
      </li>
      <li class='menu__item'>
        <a 
          href='javascript:void(0)' 
          class='menu__link'>
          About Us
        </a>
      </li>
    </ul>
  </nav>
</header>

เอาหละครับต่อไปนี้คือจุดที่เพื่อนๆควรให้ความสนใจเป็นพิเศษ

<a 
  routerLink='/pages' 
  routerLinkActive='active' 
  class='menu__link'>
  All Pages
</a>

นี่เป็นการบอกว่าเมื่อไหร่ที่เราคลิก All Pages ขอให้ Router เธอจงช่วยนำพาเราสู่ /pages ทีเถอะ

จากตัวอย่างข้างบน เพื่อนๆน่าจะเห็น directive โผล่ขึ้นมาอีกตัวนึงครับนั่นคือ RouterLinkActive directive ตัวนี้มีหน้าที่กำกับแท็ก a ของเรา เมื่อไหร่ก็ตามที่ URL ของเราอยู่ที่ path ที่กำหนดไว้ (ในที่นี้คือ /pages) RouterLinkActive จะเพิ่มชื่อ CSS class ให้ตามที่เรากำหนด (ในที่นี้คือ active) และถอนชื่อคลาสนั้นออกเสียเมื่อเราไม่ได้อยู่ที่ path นั้น

ถึงตอนนี้แล้วก็อย่ารอช้าครับ เข้าไปที่ http://localhost:4200 แล้วจิ้มพรวดไปที่ All Pages ซะ! ถ้าทุกอย่างถูกต้อง เพื่อนๆต้องเห็นข้อความ page-list works! ครับ

การสร้างคลาสเพื่อเป็นตัวแทนของโมเดล

เราต้องการให้หน้า /pages แสดงวิกิทั้งหมดที่เรามี แต่ตอนนี้เรายังไม่มีข้อมูลที่จะนำมาแสดงเลย ดังนั้นเราจะสร้าง Page ขึ้นมาก่อน เพื่อให้เป็นตัวแทนของหน้าวิกิหนึ่งหน้า จากนั้นเมื่อเราพูดถึงวิกิทั้งหมดมันจึงหมายถึงอาร์เรย์ของ Page หรือก็คือ Page[] นั่นเอง

สร้างคลาส Page เพื่อเป็นตัวแทนของวิกิหนึ่งเพจของเราด้วยคำสั่งในการสร้างคลาสดังนี้ครับ ถ้าเจอข้อความ "src\app\pages\shared is not a valid path. ให้สร้างโฟลเดอร์ src/app/pages/shared ขึ้นมาก่อนออกคำสั่งครับ

$ ng g class pages/shared/page

หลังการออกคำสั่งข้างต้น เพื่อนๆจะได้ไฟล์ใหม่ใต้โครงสร้างไฟล์ดังนี้

src/app/pages
|------ shared
        |------ page.spec.ts
        |------ page.ts

page.ts ของเราจะใช้ร่วมกันในหลายๆที่ภายใต้ฟีเจอร์เดียวกันคือ pages ดังนั้น page.ts จึงควรอยู่ภายใต้โฟลเดอร์ pages/shared

กฎเหล็กข้อที่ 2: จงสร้างไฟล์ที่ใช้ร่วมกันใน feature นั้นๆในโฟลเดอร์ shared

เนื่องจาก page.ts ของเราเป็นคลาสที่ทำหน้าที่เป็นโมเดล ตามหลักการตั้งชื่อไฟล์ของ Angular2 คือ <ชื่อ>.<หน้าที่>.<ประเภท> เราจึงควรเปลี่ยนชื่อไฟล์เสียใหม่เป็น page.model.ts เพื่อเป็นการบอกว่าไฟล์ดังกล่าวทำหน้าที่เป็นโมเดลนั่นเอง

เปลี่ยนชื่อไฟล์ต่อไปนี้

page.ts      ---> page.model.ts
page.spec.ts ---> page.model.spec.ts

หลังจากการเปลี่ยนแปลงไฟล์ เพื่อนๆจะได้โครงสร้างไฟล์ใหม่ดังนี้

src/app/pages
|------ shared
        |------ page.model.spec.ts
        |------ page.model.ts

ทำความรู้จัก Structural Directives

ถึงเวลาที่ข้อมูลวิกิของเราทั้งหมดต้องออกไปแสดงตัวทางหน้าจอแล้ว แต่… ไหนละข้อมูลเรา?

เพื่อให้เรามีข้อมูลพร้อมแสดงผล เราจะใส่ข้อมูลของเราลงไปใน page-list.component.ts ดังนี้ครับ

import { Component, OnInit } from [email protected]/core';
import { Page } from '../shared/page';

@Component({
  selector: 'app-page-list',
  templateUrl: 'page-list.component.html',
  styleUrls: ['page-list.component.scss']
})
export class PageListComponent implements OnInit {
  // สร้าง property ชื่อ pages เพื่อเก็บค่าวิกิทั้งหมด
  // pages ตัวนี้เทมเพลตของเราจะเข้าถึงได้
  // เราจึงนำข้อมูลจาก pages ไปแสดงผลบนเทมเพลตของคอมโพแนนท์นี้ได้นั่นเอง
  pages: Page[];

  constructor() { }

  // เมื่อ Angular2 เริ่มต้นทำงานคอมโพแนนท์นี้ ngOnInit จะถูกเรียกขึ้นมาทำงาน
  ngOnInit() {
    // ในการทำงานนี้เราให้มันตั้งค่าข้อมูลวิกิทั้งหมดของเราเป็น...
    this.pages = [
      {
        "id": 1,
        "title": "test page#1",
        "content": "TEST PAGE CONTENT#1"
      },
      {
        "id": 2,
        "title": "test page#2",
        "content": "TEST PAGE CONTENT#2"
      },
      {
        "id": 3,
        "title": "test page#3",
        "content": "TEST PAGE CONTENT#3"
      }
    ]
  }

}

เมื่อข้อมูลพร้อมก็ถึงเวลาแสดงผลแล้ว เราจะแสดงผลวิกิทั้งหมดของเราในรูปแบบตารางกันครับ และนี่คือโค๊ดที่เราจะใส่ใน page-list.component.html

<table class='table'>
  <thead>
    <tr>
      <th>ID</th>
      <th>Title</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr *ngFor="let page of pages">
      <td>{{page.id}}</td>
      <td>{{page.title}}</td>
      <td>{{page.action}}</td>
    </tr>
  </tbody>
</table>

จุดน่าสนใจของเราอยู่ตรงนี้ครับ

<tr *ngFor="let page of pages">
  <td>{{page.id}}</td>
  <td>{{page.title}}</td>
  <td>{{page.action}}</td>
</tr>

ในบางครั้งเรามักมีเงื่อนไขที่ใช้กำหนดการแสดงผล เช่น ถ้า page นั้นเป็น private ก็ขอให้ไม่แสดงผลออกมา หรือเรามีกลุ่มของวิกิทั้งหมดอยู่ใน pages เราอยากจะวนลูปรอบ pages เพื่อสร้างแถวในตารางตามแต่ละ page ที่อยู่ภายใต้ pages ที่เรามีอยู่

directive ที่ใช้ในการเปลี่ยนแปลงโครงสร้าง DOM เพื่อให้เกิดการเพิ่มหรือลบอีลีเมนต์ของ HTML ตามที่เราระบุนี้เรียกว่า Structural Directive

ngFor เป็นหนึ่งใน structural directive ที่ใช้ในการวนลูปรอบ property ที่เราส่งเข้ามา เราใช้ ngFor เพื่อวนลูปรอบ pages ของเราซึ่งเป็น property ใน PageListComponent

<tr *ngFor="let page of pages">

จากคำสั่งนี้ทำให้เกิดแท็ก tr ขึ้นตามจำนวนของ pages ที่มีอยู่ ในแต่ละรอบของการวนลูปรอบ pages จะมี page เกิดขึ้นมาเพื่อเป็นตัวแทนของแต่ละวิกิเพจ อาศัย page นี้เราสามารถแสดงข้อมูลภายใต้ page ออกมาได้ดังนี้

<td>{{page.id}}</td>
<td>{{page.title}}</td>
<td>{{page.action}}</td>

เราแสดงผล id, title และ action ของ page ออกมาผ่าน {{}} เช่นเดียวกันครับถ้าเราอยากแสดงผลสิ่งอื่นออกมาเช่นผลลัพธ์จากการนำ 1 + 1 เราก็สามารถใส่ใน {{}} ได้เช่นกันเป็น {{1 + 1}}

ในหัวข้อนี้เราจะยังไม่ลงลึกถึงรายละเอียดต่างๆของ structural directive ครับ เพื่อนๆจะได้เรียนรู้อย่างละเอียดในบทความอื่นต่อไป

ก่อนที่เราจะลาหัวข้อนี้ มาเพิ่มสไตล์ให้กับตารางของเราเพื่อความฟรุ้งฟริ้งกันครับ แก้ไข page-list.component.scss ดังนี้

@import '../../theme/variables';

$border-style: 1px solid $gray1-color;

.table {
  border-collapse: collapse;
  border-spacing: 0;
  empty-cells: show;
  border: $border-style;

  td, th {
    border-left: $border-style;
    border-width: 0 0 0 1px;
    font-size: inherit;
    margin: 0;
    overflow: visible;
    padding: 0.5rem 1rem;
  }

  thead {
    background-color: $gray2-color;
    color: $black-color;
    text-align: left;
    vertical-align: bottom;
  }
}

รู้จักกับ Service ใน Angular2

ลองย้อนกลับไปดู page-list.component.ts ของเรากันครับ

export class PageListComponent implements OnInit {
  pages: Page[];

  constructor() { }

  ngOnInit() {
    this.pages = [
      {
        "id": 1,
        "title": "test page#1",
        "content": "TEST PAGE CONTENT#1"
      },
      {
        "id": 2,
        "title": "test page#2",
        "content": "TEST PAGE CONTENT#2"
      },
      {
        "id": 3,
        "title": "test page#3",
        "content": "TEST PAGE CONTENT#3"
      }
    ]
  }

}

เราจะเห็นว่าตอนนี้เรายัดก้อนข้อมูลของวิกิทุกหน้าไว้ภายใต้ ngOnInit แน่นอนครับว่าถ้านี่คือคอมโพแนนท์ตัวเดียวของเราในระบบ มันก็ไม่น่าจะมีปัญหาอะไร แต่ถ้าเกิดวันนึงมีคอมโพแนนท์อื่นอยากใช้งาน pages เช่นเดียวกันหละ เราก็จะประกาศตัวแปร pages เพื่อเก็บค่าวิกิทุกเพจเช่นเดียวกับที่ทำในคอมโพแนนท์นี้เช่นนั้นหรือ?

การเข้าถึงข้อมูลไม่ใช่หน้าที่หลักของคอมโพแนนท์ครับ ดังนั้นเราจึงควรแยกการได้มาซึ่งข้อมูลออกไปจากคอมโพแนนท์ เพื่อให้คอมโพแนนท์ของเราโฟกัสเพียงหน้าที่เดียว ให้ง่ายต่อการทดสอบโปรแกรมด้วยเช่นกัน

เซอร์วิสคือก้อนข้อมูล ฟังก์ชัน หรืออะไรก็ได้ ขอเพียงให้เกิดมาเพื่อทำงานเฉพาะทางอะไรซักอย่างและทำให้ดีด้วยนะเออ งานที่สามารถเป็นเซอร์วิสได้ เช่น

  • Logging Service สำหรับพิมพ์ log
  • Data Service สำหรับดึงข้อมูลจากเซิร์ฟเวอร์

แน่นอนครับว่าก้อนข้อมูล pages ที่เราถือครองอยู่นี้ ความเป็นจริงแล้วต้องมาจากฝั่งเซิร์ฟเวอร์ที่เรายังไม่ได้สร้างในตอนนี้ เราจะย้ายก้อน pages ของเราออกจากคอมโพแนนท์ เพื่อให้มันไปสิงสถิตย์เป็นนางตานีอยู่ใน service ต่อไป

ออกคำสั่งเพื่อสร้าง service ตัวใหม่ดังนี้ครับ

$ ng g service pages/shared/page

ผลจากคำสั่งข้างต้นเราจะได้โครงสร้างไฟล์ใหม่ดังนี้

|------ src/app/pages/shared
        |------ page.service.spec.ts
        |------ page.service.ts

จากนั้นก็ใส่ก้อนข้อมูล pages ลงไปใน page.service.ts ได้เลย

import { Injectable } from [email protected]/core';

@Injectable()
export class PageService {

  getPages() {
    return [
      {
        "id": 1,
        "title": "test page#1",
        "content": "TEST PAGE CONTENT#1"
      },
      {
        "id": 2,
        "title": "test page#2",
        "content": "TEST PAGE CONTENT#2"
      },
      {
        "id": 3,
        "title": "test page#3",
        "content": "TEST PAGE CONTENT#3"
      }
    ];
  }

}

แต่ช้าก่อน… ตามที่เราคุยกันข้างต้น pages ของเราความจริงแล้วมาจากเซิร์ฟเวอร์ครับ แม้ตอนนี้เราจะยังไม่มี API server ให้ดึงข้อมูลก็ตาม แต่เราก็ไม่ควร hard code เขียนก้อนข้อมูลลงไปตรงๆแบบนี้ใน service

เพื่อให้ service ของเราดูดีขึ้น เราจะสร้าง mock-pages ขึ้นมาเป็นตัวแทนจำลองการเก็บข้อมูลของเราผ่านการสร้างไฟล์ชื่อ pages/shared/mock-pages.ts

|------ src/app/pages/shared
        |------ mock-pages.ts

เปิดไฟล์ mock-pages.ts ขึ้นมาครับ เราจะลงมือย้าย pages ของเราไว้ในนี้

import { Page } from './page.model';

export const PAGES: Page[] = [
  {
    "id": 1,
    "title": "test page#1",
    "content": "TEST PAGE CONTENT#1"
  },
  {
    "id": 2,
    "title": "test page#2",
    "content": "TEST PAGE CONTENT#2"
  },
  {
    "id": 3,
    "title": "test page#3",
    "content": "TEST PAGE CONTENT#3"
  }
]

ย้อนกลับไปที่ page.service.ts ของเรากันครับ ตอนนี้ service ของเราก็จะดูดีงามพระรามแปดมากขึ้นเช่นนี้

import { Injectable } from [email protected]/core';

import { PAGES } from './mock-pages';

@Injectable()
export class PageService {

  getPages() {
    // ไม่มีการ hard code ใน service อีกต่อไป
    // แต่ตอนนี้เราจะเข้าถึง pages จาก mock-pages แทน
    return PAGES;
  }

}

และเพื่อให้ตลอดทั้งโมดูลของเรารู้จัก service ตัวนี้ เราจึงต้องไปเพิ่ม service ของเราใน app.module.ts

// อย่าลืม import เข้ามาก่อนครับ
import { PageService } from './pages/shared/page.service';
import { PageListComponent } from './pages/page-list/page-list.component';

@NgModule({
  declarations: [
    AppComponent, 
    HomeComponent, 
    HeaderComponent,
    PageListComponent
  ],
  imports: [
    BrowserModule,
    CommonModule,
    FormsModule,
    routing
  ],
  providers: [
    // ตรงนี้
    PageService
  ],
  entryComponents: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule {

}

สุดท้ายเราก็ต้องเปลี่ยน page-list.component.ts ของเราเพื่อให้ดึงค่า pages มาจาก service แทนดังนี้

import { Component, OnInit } from [email protected]/core';

import { PageService } from './pages/shared/page.service';
import { Page } from '../shared/page';

@Component({
  selector: 'app-page-list',
  templateUrl: 'page-list.component.html',
  styleUrls: ['page-list.component.scss']
})
export class PageListComponent implements OnInit {
  pages: Page[];

  constructor(private pageService: PageService) { }

  ngOnInit() {
    this.getPages();
  }

  getPages() {
    this.pages = this.pageService.getPages();
  }

}

เมื่อเซอร์วิสเป็นที่ต้องการของคอมโพแนนท์เรา Angular2 จึงต้องมีกลไกในการส่งเซอร์วิสไปให้ถึงมือคอมโพแนนท์ที่ต้องการใช้งานเซอร์วิสนั้นๆ และนั่นคือสิ่งที่เราประกาศเป็นพารามิเตอร์ของ constructor ครับ

constructor(private pageService: PageService) { }

เมื่อ Angular2 สร้างคอมโพแนนท์มันจะดูซิว่าคอมโพแนนท์นั้นต้องการเซอร์วิสอะไรบ้าง จากตัวอย่างของเรามีเพียง PageService เท่านั้นที่คอมโพแนนท์นี้ต้องการ Angular2 จะเริ่มโวยวายถามหาเซอร์วิสตัวนี้จาก injector

Injector จะเป็นผู้ดูแลกล่องเก็บเซอร์วิสใบหนึ่ง (container) ถ้าเซอร์วิสที่ Angular2 ร้องขอนั้นมีอยู่ใน container แล้วมันก็จะคืนเซอร์วิสนั้นจากในกล่องกลับไป หากไม่มี injector จะสร้างเซอร์วิสนั้นขึ้นมาใหม่ โยนใส่กล่อง พร้อมทั้งคืนค่าเซอร์วิสนั้นกลับไปให้ Angular2 และนี่หละครับคือกลไกของ dependency injection

ถ้าเราย้อนกลับไปดู page.service.ts ของเรา พบว่ามีการใส่ @Injectable() เอาไว้

import { Injectable } from [email protected]/core';

@Injectable()
export class PageService {
  // ...
}

Injectable จะเป็นตัวเปิดเผยให้ Injector สามารถนำ service นี้ไปสร้างไว้ใน container ได้

เมื่อเรามี pageService ไว้ใช้งานเรียบร้อยแล้ว เราจึงสามารถเรียกเมธอด getPages จาก service ดังกล่าวได้

getPages() {
  this.pages = this.pageService.getPages();
}

ngOnInit ที่ทำงานเมื่อคอมโพแนนท์ถูกสร้างเพื่อใช้งาน จะเรียกใช้ getPages เป็นผลให้เมื่อคอมโพแนนท์ทำงานจะมีการดึงข้อมูล pages เกิดขึ้น

ngOnInit() {
  this.getPages();
}

กลับไปดูที่ http://localhost:4200/pages อีกครั้ง เพื่อนๆควรจะพบหน้ารวมวิกิอันแสนสวยงามแบบนี้ครับ

Pages without Action

บทความนี้เป็นเพียงการแนะนำให้รู้จักการใช้งาน Routing และ Service ใน Angular2 ที่ในความเป็นจริงแล้วยังมีรายละเอียดอีกมากครับ เพื่อนๆจะได้ศึกษาเรื่องนี้มากขึ้นในบทความถัดๆไป สำหรับเพื่อนๆที่ต้องการดูโค๊ดของบทความนี้ สามารถเข้าชมได้ที่นี่ครับ


แสดงความคิดเห็นของคุณ


ไม่ระบุตัวตน9 เดือนที่ผ่านมา

ถ้านำ file angular 2 ที่ build แล้วมาอัพโหลดขึ้น host จริงจะมีปัญหาเกี่ยวกับ Router หรือเปล่าครับ เช่นในกรณีนี้ เราสร้าง /pages เวลาเข้าผ่าน Browser http://localhost:4200/pages จะแสดงผลหรือเปล่าครับ

ข้อความตอบกลับ
ไม่ระบุตัวตน9 เดือนที่ผ่านมา

มีปัญหาแน่นอนครับ

ปกติเรามีวิธีจัดการกับ URL สองแบบ

  • hash style วิธีนี้ URL เราจะมี hash แปะอยู่ เช่น http://localhost:4200/#/pages
  • HTML5 browser style (browser history) ด้วยวิธีนี้เราอาศัย history API ในการจัดการครับ สำหรับวิธีจัดการให้ค้น google ด้วยคำว่า browser history api fallback ครับ 😃