前端译趣我爱编程

你需要知道的单页应用四层架构

2018-06-06  本文已影响18人  linc2046
你需要知道的单页应用四层架构

简介

本文以 React 应用架构为例,探索单页应用的四层架构: 领域层、存储层、应用服务层、视图层。

四层架构图解

四层架构图解

每个项目都需要一个所有团队成员都能理解的清晰架构。

如果你刚进某个团队,技术负责人向你展示产品路线图上的新应用推荐架构。

他告诉你下面的需求:

应用需要展示文章列表,用户可以创建、删除和喜欢文章。

这里选用Create React AppFlow作为类型检查工具,同时为了简便,

应用没有样式。 首先我们来谈下现代框架的声明式特性,了解下状态的概念。

React、Angular、Vue 都是声明式框架

框架鼓励我们使用函数式编程的元素。

看下 React 框架的部分定义:

设计应用中每个状态对应的视图,数据变化时,React 将会高效更新和渲染视图

下面是 Angular 框架的部分定义:

使用简单、声明式模板快速构建功能,使用自定义组件扩展模板语言

有点类似,对吧?

框架帮助我们创建包含各种视图的应用。视图是状态的呈现,但何为状态。

状态

状态表示应用中每次变化的数据片段。

访问一个 URL 地址,也是状态。

发送 ajax 请求获取电影列表,也是状态。

在本地存储中维持信息存储,也还是状态。

状态由不可变对象组成。

不可变架构有就视图层面来说有很多好处。

下面引自React 性能优化指南

不可变可以很容易追踪变化,变化最终会形成一个全新对象,所以我们只需要检测对象引用是否发生变化。

Domain 领域层

领域层描述状态并维持业务逻辑,领域层代表应用的核心部分。

同时应该与视图进行隔离。

不管选用哪种框架,我们应该都能使用领域层。

因为要处理不可变结构,领域层会包含实体和领域服务。

作为 OOP 中的争议点,特别是大型应用程序,贫血领域模型特别适合处理不可变数据。

展示文章列表,首先需要创建Article实体。

Article 所有对象类型都是不可变的。

Flow 可以设置属性之都,实现强制不可变。

// @flow
export type Article = {
  +id: string,
  +likes: number,
  +title: string,
  +author: string
};

现在我们使用工厂函数模式创建articleService.

由于应用中只需要一个articleService, 这里导出成单例。

createArticle允许创建Article的冻结类型。

每个新文章实体会有自动生成的唯一 id。

Object.freeze()冻结对象:禁止添加新属性。

createArticle可能返回 Article 类型。

如果创建文章时必备字段校验失败,createArticle 将会返回空。

依次创建 updateLikes、isTitleValid、isAuthorValid 方法。

// @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();

数据验证在领域层数据维持特别重要。

我们可以组合验证服务成多个纯函数。

// @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;

至此领域层已经完成,即使没有框架支持,也可以立刻使用领域层代码。

我们看下如何使用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 存储层

创建和更新文章返回的结果代表应用状态。

我们需要进行数据存储,store 就是最佳选择。

示例状态可以简单表示为文章数据

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

export type ArticleState = Article[];

ArticleStoreFactory函数采用发布-订阅模式实现,并导出articleStore单例。

store 保存文章数据,执行添加、删除和更新不可变操作。

记住 store 只会对文章进行操作, 只有 articleService 可以创建和更新文章。

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();

这里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);
// It will print JORDAN PETERSON

if (article) {
  console.log(article.author);
  // It will print Jordan Peterson
}

视图层

目前我们已经完成整个应用,没有用任何框架,准备接入React。

视图层由展示组件和容器组件构成。

展示组件关注数据如何展示,容器组件关注数据流动,详细解释可以参考Dan Abramov(redux作者)的文章

我们创建App组件,包含ArticleFormContainer组件和ArticleListContainer组件。

// @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组件接收用户文本输入并传递至articleService, 服务创建对应的文章并添加ArticleStore

所有逻辑基本都在submitForm方法中。

// @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)}
      />
    )
  }
}

注意ArticleFormContainer容器组件返回的是用户可见的真实表单,也就是ArticleFormComponent展示组件。

展示组件显示容器组件传递的数据并触发changeArticleTitle, changeArticleAuthorsubmitForm

// @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组件。

// @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是个展示组件,通过props接收文章数据并渲染ArticleContainer组件。

// @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方法通过替换store中对应文章来更新喜欢数。

removeArticle方法删除store中的文章。

// @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展示组件,同时展示组件通知容器组件喜欢或删除按钮点击时机,

并执行对应回调。

ArticleComponent使用应用服务层中的ArticleUiService来展示状态中的一部分数据,并将原始值转换成预期的大写字符串。

// @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仓库

译者注

上一篇下一篇

猜你喜欢

热点阅读