[譯] 關於 SPA,你須要掌握的 4 層

關於 SPA,你須要掌握的 4 層

咱們從頭來構建一個 React 的應用程序,探究領域、存儲、應用服務和視圖這四層

每一個成功的項目都須要一個清晰的架構,這對於全部團隊成員都是心領神會的。css

試想一下,做爲團隊的新人。技術負責人給你介紹了在項目進程中提出的新應用程序的架構。html

而後告訴你需求:前端

咱們的應用程序將顯示一系列文章。用戶可以建立、刪除和收藏文章。react

而後他說,去作吧!android

Ok,沒問題,咱們來搭框架吧

我選擇 FaceBook 開源的構建工具 Create React App,使用 Flow 來進行類型檢查。簡單起見,先忽略樣式。ios

做爲先決條件,讓咱們討論一下現代框架的聲明性本質,以及涉及到的 state 概念。git

如今的框架多爲聲明式的

React, Angular, Vue 都是聲明式的,並鼓勵咱們使用函數式編程的思想。github

你有見過手翻書嗎?編程

一本手翻書或電影書,裏面有一系列逐頁變化的圖片,當頁面快速翻頁的時候,就造成了動態的畫面。 [1]redux

如今讓咱們來看一下 React 中的定義:

在應用程序中爲每一個狀態設計簡單的視圖, React 會在數據發生變化時高效地更新和渲染正確的組件。 [2]

Angular 中的定義:

使用簡單、聲明式的模板快速構建特性。使用您本身的組件擴展模板語言。 [3]

大同小異?

框架幫助咱們構建包含視圖的應用程序。視圖是狀態的表象。那狀態又是什麼?

狀態

狀態表示應用程序中會更改的全部數據。

你訪問一個URL,這是狀態,發出一個 Ajax 請求來獲取電影列表,這是也狀態,將信息持久化到本地存儲,同上,也是狀態。

狀態由一系列不變對象組成

不可變結構有不少好處,其中一個就是在視圖層。

下面是 React 指南對性能優化介紹的引言。

不變性使得跟蹤更改變得更容易。更改老是會產生一個新對象,因此咱們只須要檢查對象的引用是否發生了更改。

領域層

域能夠描述狀態並保存業務邏輯。它是應用程序的核心,應該與視圖層解耦。Angular, React 或者是 Vue,這些都不重要,重要的是無論選擇什麼框架,咱們都可以使用本身的領。

由於咱們處理的是不可變的結構,因此咱們的領域層將包含實體和域服務。

在 OOP 中存在爭議,特別是在大規模應用程序中,在使用不可變數據時,貧血模型是徹底能夠接受的。

對我來講,弗拉基米爾·克里科夫(Vladimir Khorikov)的這門課讓我大開眼界。

要顯示文章列表,咱們首先要建模的是Article實體。

全部 Article 類型實體的將來對象都是不可變的。Flow 能夠經過使全部屬性只讀(屬性前面帶 + 號)來強制將對象不可變。

// @flow
export type Article = {
  +id: string;
  +likes: number;
  +title: string;
  +author: string;
}
複製代碼複製代碼

如今,讓咱們使用工廠函數模式建立 articleService

查看 @mpjme 的這個視頻,瞭解更多關於JS中的工廠函數知識。

因爲在咱們的應用程序中只須要一個articleService,咱們將把它導出爲一個單例。

createArticle 容許咱們建立 Article凍結對象。每一篇新文章都會有一個惟一的自動生成的id和零收藏,咱們僅須要提供做者和標題。

**Object.freeze()** 方法可凍結一個對象:即沒法給它新增屬性。 [5]

createArticle 方法返回的是一個 Article 的「Maybe」類型

Maybe 類型強制你在操做 Article 對象前先檢查它是否存在。

若是建立文章所須要的任一字段校驗失敗,那麼 createArticle 方法將返回null。這裏可能有人會說,最好拋出一個用戶定義的異常。若是咱們這麼作,但上層不實現catch塊,那麼程序將在運行時終止。 updateLikes 方法會幫咱們更新現存文章的收藏數,將返回一個擁有新計數的副本。

最後,isTitleValidisAuthorValid 方法能幫助 createArticle 隔離非法數據。

// @flow
import v1 from 'uuid';
import * as R from 'ramda';

import type {Article} from "./Article";
import * as validators from "./Validators";

export type ArticleFields = {
  +title: string;
  +author: string;
}

export type ArticleService = {
  createArticle(articleFields: ArticleFields): ?Article;
  updateLikes(article: Article, likes: number): Article;
  isTitleValid(title: string): boolean;
  isAuthorValid(author: string): boolean;
}

export const createArticle = (articleFields: ArticleFields): ?Article => {
  const {title, author} = articleFields;
  return isTitleValid(title) && isAuthorValid(author) ?
    Object.freeze({
      id: v1(),
      likes: 0,
      title,
      author
    }) :
    null;
};

export const updateLikes = (article: Article, likes: number) =>
  validators.isObject(article) ?
    Object.freeze({
      ...article,
      likes
    }) :
    article;

export const isTitleValid = (title: string) =>
  R.allPass([
    validators.isString,
    validators.isLengthGreaterThen(0)
  ])(title);

export const isAuthorValid = (author: string) =>
  R.allPass([
    validators.isString,
    validators.isLengthGreaterThen(0)
  ])(author);

export const ArticleServiceFactory = () => ({
  createArticle,
  updateLikes,
  isTitleValid,
  isAuthorValid
});

export const articleService = ArticleServiceFactory();
複製代碼複製代碼

驗證對於保持數據一致性很是重要,特別是在領域級別。咱們能夠用純函數來編寫 Validators 服務。

// @flow
export const isObject = (toValidate: any) => !!(toValidate && typeof toValidate === 'object');

export const isString = (toValidate: any) => typeof toValidate === 'string';

export const isLengthGreaterThen = (length: number) => (toValidate: string) => toValidate.length > length;
複製代碼複製代碼

請使用最小的工程來檢驗這些驗證方法,僅用於演示。

事實上,在 JavaScript 中檢驗一個對象是否爲對象並不容易。 :)

如今咱們有了領域層的結構!

好在如今就可使用咱們的代碼來,而無需考慮框架。

讓咱們來看一下如何使用 articleService 建立一篇關於我最喜歡的書的文章,並更新它的收藏數。

// @flow
import {articleService} from "../domain/ArticleService";

const article = articleService.createArticle({
  title: '12 rules for life',
  author: 'Jordan Peterson'
});
const incrementedArticle = article ? articleService.updateLikes(article, 4) : null;

console.log('article', article);
/*
   const itWillPrint = {
     id: "92832a9a-ec55-46d7-a34d-870d50f191df",
     likes: 0,
     title: "12 rules for life",
     author: "Jordan Peterson"
   };
 */

console.log('incrementedArticle', incrementedArticle);
/*
   const itWillPrintUpdated = {
     id: "92832a9a-ec55-46d7-a34d-870d50f191df",
     likes: 4,
     title: "12 rules for life",
     author: "Jordan Peterson"
   };
 */
複製代碼複製代碼

存儲層

建立和更新文章所產生的數據表明了咱們的應用程序的狀態。

咱們須要一個地方來儲存這些數據,而 store 就是最佳人選

狀態能夠很容易地由一系列文章來建模。

// @flow
import type {Article} from "./Article";

export type ArticleState = Article[];
複製代碼複製代碼

ArticleState.js

ArticleStoreFactory 實現了發佈-訂閱模式,並導出 articleStore 做爲單例。

store 可保存文章並賦予他們添加、刪除和更新的不可變操做。

記住,store 只對文章進行操做。只有 articleService 才能建立或更新它們。

感興趣的人能夠訂閱和退訂 articleStore

articleStore 保存全部訂閱者的列表,並將每一個更改通知到他們。

// @flow
import {update} from "ramda";

import type {Article} from "../domain/Article";
import type {ArticleState} from "./ArticleState";

export type ArticleStore = {
  addArticle(article: Article): void;
  removeArticle(article: Article): void;
  updateArticle(article: Article): void;
  subscribe(subscriber: Function): Function;
  unsubscribe(subscriber: Function): void;
}

export const addArticle = (articleState: ArticleState, article: Article) => articleState.concat(article);

export const removeArticle = (articleState: ArticleState, article: Article) =>
  articleState.filter((a: Article) => a.id !== article.id);

export const updateArticle = (articleState: ArticleState, article: Article) => {
  const index = articleState.findIndex((a: Article) => a.id === article.id);
  return update(index, article, articleState);
};

export const subscribe = (subscribers: Function[], subscriber: Function) =>
  subscribers.concat(subscriber);

export const unsubscribe = (subscribers: Function[], subscriber: Function) =>
  subscribers.filter((s: Function) => s !== subscriber);

export const notify = (articleState: ArticleState, subscribers: Function[]) =>
  subscribers.forEach((s: Function) => s(articleState));

export const ArticleStoreFactory = (() => {
  let articleState: ArticleState = Object.freeze([]);
  let subscribers: Function[] = Object.freeze([]);

  return {
    addArticle: (article: Article) => {
      articleState = addArticle(articleState, article);
      notify(articleState, subscribers);
    },
    removeArticle: (article: Article) => {
      articleState = removeArticle(articleState, article);
      notify(articleState, subscribers);
    },
    updateArticle: (article: Article) => {
      articleState = updateArticle(articleState, article);
      notify(articleState, subscribers);
    },
    subscribe: (subscriber: Function) => {
      subscribers = subscribe(subscribers, subscriber);
      return subscriber;
    },
    unsubscribe: (subscriber: Function) => {
      subscribers = unsubscribe(subscribers, subscriber);
    }
  }
});

export const articleStore = ArticleStoreFactory();
複製代碼複製代碼

ArticleStore.js

咱們的 store 實現對於演示的目的是有意義的,它讓咱們理解背後的概念。在實際運做中,我推薦使用狀態管理系統,像 ReduxngrxMobX, 或者是可監控的數據管理系統

好的,如今咱們有了領域層和存儲層的結構。

讓咱們爲 store 建立兩篇文章和兩個訂閱者,並觀察訂閱者如何得到更改通知。

// @flow
import type {ArticleState} from "../store/ArticleState";
import {articleService} from "../domain/ArticleService";
import {articleStore} from "../store/ArticleStore";

const article1 = articleService.createArticle({
  title: '12 rules for life',
  author: 'Jordan Peterson'
});

const article2 = articleService.createArticle({
  title: 'The Subtle Art of Not Giving a F.',
  author: 'Mark Manson'
});

if (article1 && article2) {
  const subscriber1 = (articleState: ArticleState) => {
    console.log('subscriber1, articleState changed: ', articleState);
  };

  const subscriber2 = (articleState: ArticleState) => {
    console.log('subscriber2, articleState changed: ', articleState);
  };

  articleStore.subscribe(subscriber1);
  articleStore.subscribe(subscriber2);

  articleStore.addArticle(article1);
  articleStore.addArticle(article2);

  articleStore.unsubscribe(subscriber2);

  const likedArticle2 = articleService.updateLikes(article2, 1);
  articleStore.updateArticle(likedArticle2);

  articleStore.removeArticle(article1);
}
複製代碼複製代碼

應用服務層

這一層用於執行與狀態流相關的各類操做,如Ajax從服務器或狀態鏡像中獲取數據。

出於某種緣由,設計師要求全部做者的名字都是大寫的。

咱們知道這種要求是比較無厘頭的,並且咱們並不想所以污化了咱們的模塊。

因而咱們建立了 ArticleUiService 來處理這些特性。這個服務將取用一個狀態,就是做者的名字,將其構建到項目中,可返回大寫的版本給調用者。

// @flow
export const displayAuthor = (author: string) => author.toUpperCase();
複製代碼複製代碼

讓咱們看一個如何使用這個服務的演示!

// @flow
import {articleService} from "../domain/ArticleService";
import * as articleUiService from "../services/ArticleUiService";

const article = articleService.createArticle({
  title: '12 rules for life',
  author: 'Jordan Peterson'
});

const authorName = article ?
  articleUiService.displayAuthor(article.author) :
  null;

console.log(authorName);
// 將輸出 JORDAN PETERSON

if (article) {
  console.log(article.author);
  // 將輸出 Jordan Peterson
}
複製代碼複製代碼

app-service-demo.js

視圖層

如今咱們有了一個可執行且不依賴於框架的應用程序,React 已經準備投入使用。

視圖層由 presentational componentscontainer components 組成。

presentational components 關注事物的外觀,而 container components 則關注事物的工做方式。更多細節解釋請關注 Dan Abramov 的文章

讓咱們使用 ArticleFormContainerArticleListContainer 開始構建 App 組件。

// @flow
import React, {Component} from 'react';

import './App.css';

import {ArticleFormContainer} from "./components/ArticleFormContainer";
import {ArticleListContainer} from "./components/ArticleListContainer";

type Props = {};

class App extends Component<Props> {
  render() {
    return (
      <div className="App">
        <ArticleFormContainer/>
        <ArticleListContainer/>
      </div>
    );
  }
}

export default App;
複製代碼複製代碼

接下來,咱們來建立 ArticleFormContainer。React 或者 Angular 都不重要,表單有些複雜。

查看 Ramda 庫以及如何加強咱們代碼的聲明性質的方法。

表單接受用戶輸入並將其傳遞給 articleService 處理。此服務根據該輸入建立一個 Article,並將其添加到 ArticleStore 中以供 interested 組件使用它。全部這些邏輯都存儲在 submitForm 方法中。

『ArticleFormContainer.js』

// @flow
import React, {Component} from 'react';
import * as R from 'ramda';

import type {ArticleService} from "../domain/ArticleService";
import type {ArticleStore} from "../store/ArticleStore";
import {articleService} from "../domain/ArticleService";
import {articleStore} from "../store/ArticleStore";
import {ArticleFormComponent} from "./ArticleFormComponent";

type Props = {};

type FormField = {
  value: string;
  valid: boolean;
}

export type FormData = {
  articleTitle: FormField;
  articleAuthor: FormField;
};

export class ArticleFormContainer extends Component<Props, FormData> {
  articleStore: ArticleStore;
  articleService: ArticleService;

  constructor(props: Props) {
    super(props);

    this.state = {
      articleTitle: {
        value: '',
        valid: true
      },
      articleAuthor: {
        value: '',
        valid: true
      }
    };

    this.articleStore = articleStore;
    this.articleService = articleService;
  }

  changeArticleTitle(event: Event) {
    this.setState(
      R.assocPath(
        ['articleTitle', 'value'],
        R.path(['target', 'value'], event)
      )
    );
  }

  changeArticleAuthor(event: Event) {
    this.setState(
      R.assocPath(
        ['articleAuthor', 'value'],
        R.path(['target', 'value'], event)
      )
    );
  }

  submitForm(event: Event) {
    const articleTitle = R.path(['target', 'articleTitle', 'value'], event);
    const articleAuthor = R.path(['target', 'articleAuthor', 'value'], event);

    const isTitleValid = this.articleService.isTitleValid(articleTitle);
    const isAuthorValid = this.articleService.isAuthorValid(articleAuthor);

    if (isTitleValid && isAuthorValid) {
      const newArticle = this.articleService.createArticle({
        title: articleTitle,
        author: articleAuthor
      });
      if (newArticle) {
        this.articleStore.addArticle(newArticle);
      }
      this.clearForm();
    } else {
      this.markInvalid(isTitleValid, isAuthorValid);
    }
  };

  clearForm() {
    this.setState((state) => {
      return R.pipe(
        R.assocPath(['articleTitle', 'valid'], true),
        R.assocPath(['articleTitle', 'value'], ''),
        R.assocPath(['articleAuthor', 'valid'], true),
        R.assocPath(['articleAuthor', 'value'], '')
      )(state);
    });
  }

  markInvalid(isTitleValid: boolean, isAuthorValid: boolean) {
    this.setState((state) => {
      return R.pipe(
        R.assocPath(['articleTitle', 'valid'], isTitleValid),
        R.assocPath(['articleAuthor', 'valid'], isAuthorValid)
      )(state);
    });
  }

  render() {
    return (
      <ArticleFormComponent
        formData={this.state}
        submitForm={this.submitForm.bind(this)}
        changeArticleTitle={(event) => this.changeArticleTitle(event)}
        changeArticleAuthor={(event) => this.changeArticleAuthor(event)}
      />
    )
  }
}
複製代碼複製代碼

這裏注意 ArticleFormContainerpresentational component,返回用戶看到的真實表單。該組件顯示容器傳遞的數據,並拋出 changeArticleTitlechangeArticleAuthorsubmitForm 的方法。

ArticleFormComponent.js

// @flow
import React from 'react';

import type {FormData} from './ArticleFormContainer';

type Props = {
  formData: FormData;
  changeArticleTitle: Function;
  changeArticleAuthor: Function;
  submitForm: Function;
}

export const ArticleFormComponent = (props: Props) => {
  const {
    formData,
    changeArticleTitle,
    changeArticleAuthor,
    submitForm
  } = props;

  const onSubmit = (submitHandler) => (event) => {
    event.preventDefault();
    submitHandler(event);
  };

  return (
    <form
      noValidate
      onSubmit={onSubmit(submitForm)}
    >
      <div>
        <label htmlFor="article-title">Title</label>
        <input
          type="text"
          id="article-title"
          name="articleTitle"
          autoComplete="off"
          value={formData.articleTitle.value}
          onChange={changeArticleTitle}
        />
        {!formData.articleTitle.valid && (<p>Please fill in the title</p>)}
      </div>
      <div>
        <label htmlFor="article-author">Author</label>
        <input
          type="text"
          id="article-author"
          name="articleAuthor"
          autoComplete="off"
          value={formData.articleAuthor.value}
          onChange={changeArticleAuthor}
        />
        {!formData.articleAuthor.valid && (<p>Please fill in the author</p>)}
      </div>
      <button
        type="submit"
        value="Submit"
      >
        Create article
      </button>
    </form>
  )
};
複製代碼複製代碼

如今咱們有了建立文章的表單,下面就陳列他們吧。ArticleListContainer 訂閱了 ArticleStore,獲取全部的文章並展現在 ArticleListComponent 中。

『ArticleListContainer.js』

// @flow
import * as React from 'react'

import type {Article} from "../domain/Article";
import type {ArticleStore} from "../store/ArticleStore";
import {articleStore} from "../store/ArticleStore";
import {ArticleListComponent} from "./ArticleListComponent";

type State = {
  articles: Article[]
}

type Props = {};

export class ArticleListContainer extends React.Component<Props, State> {
  subscriber: Function;
  articleStore: ArticleStore;

  constructor(props: Props) {
    super(props);
    this.articleStore = articleStore;
    this.state = {
      articles: []
    };
    this.subscriber = this.articleStore.subscribe((articles: Article[]) => {
      this.setState({articles});
    });
  }

  componentWillUnmount() {
    this.articleStore.unsubscribe(this.subscriber);
  }

  render() {
    return <ArticleListComponent {...this.state}/>;
  }
}
複製代碼複製代碼

ArticleListComponent 是一個 presentational component,他經過 props 接收文章,並展現組件 ArticleContainer

『ArticleListComponent.js』

// @flow
import React from 'react';

import type {Article} from "../domain/Article";
import {ArticleContainer} from "./ArticleContainer";

type Props = {
  articles: Article[]
}

export const ArticleListComponent = (props: Props) => {
  const {articles} = props;
  return (
    <div>
      {
        articles.map((article: Article, index) => (
          <ArticleContainer
            article={article}
            key={index}
          />
        ))
      }
    </div>
  )
};
複製代碼複製代碼

ArticleContainer 傳遞文章數據到表現層的 ArticleComponent,同時實現 likeArticleremoveArticle 這兩個方法。

likeArticle 方法負責更新文章的收藏數,經過將現存的文章替換成更新後的副本。

removeArticle 方法負責從 store 中刪除制定文章。

『ArticleContainer.js』

// @flow
import React, {Component} from 'react';

import type {Article} from "../domain/Article";
import type {ArticleService} from "../domain/ArticleService";
import type {ArticleStore} from "../store/ArticleStore";
import {articleService} from "../domain/ArticleService";
import {articleStore} from "../store/ArticleStore";
import {ArticleComponent} from "./ArticleComponent";

type Props = {
  article: Article;
};

export class ArticleContainer extends Component<Props> {
  articleStore: ArticleStore;
  articleService: ArticleService;

  constructor(props: Props) {
    super(props);

    this.articleStore = articleStore;
    this.articleService = articleService;
  }

  likeArticle(article: Article) {
    const updatedArticle = this.articleService.updateLikes(article, article.likes + 1);
    this.articleStore.updateArticle(updatedArticle);
  }

  removeArticle(article: Article) {
    this.articleStore.removeArticle(article);
  }

  render() {
    return (
      <div>
        <ArticleComponent
          article={this.props.article}
          likeArticle={(article: Article) => this.likeArticle(article)}
          deleteArticle={(article: Article) => this.removeArticle(article)}
        />
      </div>
    )
  }
}
複製代碼複製代碼

ArticleContainer 負責將文章的數據傳遞給負責展現的 ArticleComponent,同時負責當 「收藏」或「刪除」按鈕被點擊時在響應的回調中通知 container component

還記得那個做者名要大寫的無厘頭需求嗎?

ArticleComponent 在應用程序層調用 ArticleUiService,將一個狀態從其原始值(沒有大寫規律的字符串)轉換成一個所需的大寫字符串。

『ArticleComponent.js』

// @flow
import React from 'react';

import type {Article} from "../domain/Article";
import * as articleUiService from "../services/ArticleUiService";

type Props = {
  article: Article;
  likeArticle: Function;
  deleteArticle: Function;
}

export const ArticleComponent = (props: Props) => {
  const {
    article,
    likeArticle,
    deleteArticle
  } = props;

  return (
    <div>
      <h3>{article.title}</h3>
      <p>{articleUiService.displayAuthor(article.author)}</p>
      <p>{article.likes}</p>
      <button
        type="button"
        onClick={() => likeArticle(article)}
      >
        Like
      </button>
      <button
        type="button"
        onClick={() => deleteArticle(article)}
      >
        Delete
      </button>
    </div>
  );
};
複製代碼複製代碼

幹得漂亮!

咱們如今有一個功能完備的 React 應用程序和一個魯棒的、定義清晰的架構。任何新晉成員均可以經過閱讀這篇文章學會如何順利的進展咱們的工做。:)

你能夠在這裏查看咱們最終實現的應用程序,同時奉上 GitHub 倉庫地址

若是你喜歡這份指南,請爲它點贊。

掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索