像这样构建前端架构,除了 React 没有其他可说了

本文来自作者 叶光明 在 GitChat 上分享「如何用 React 构建前端架构」,阅读原文」查看交流实录

文末高能

编辑 | 短笛

早期的前端是由后端开发的,最开始的时候仅仅做展示,点一下链接跳转到另外一个页面去,渲染表单,再用 Ajax 的方式请求网络和后端交互,数据返回来还需要把数据渲染到 DOM 上。

写这样的代码的确是很简单。在 Web 交互开始变得复杂时,一个页面往往有非常多的元素构成,像社交网络的 Feed 需要经常刷新,展示内容也五花八门,为了追求用户体验需要做很多的优化。

当时说到架构时,可能会想前端需要架构吗?如果需要,应该使用什么架构呢?也有可能一下就想起了 MVC/MVP/MVVM 架构。

在无架构的状态下,我们可以写一个 HTML 文件,HTML、Script 和 Style 都一股脑的写在一起,这种原始的方式适合个人开发 Demo 用,或者只是做个玩玩的东西。

当我们写的 Script 和 Style 越来越多时,就考虑是否将这些片段代码整理放在同一个文件里,所以,我们就把 Script 写到了 JS 文件,把 Style 写到 CSS 文件。

一个项目肯定有许多的逻辑功能,就需要考虑分层,把独立的功能单独抽取出来可以复用的组件。

Anguar 和 Ember 作为 MVC 架构的框架,采用 MVVM 双向绑定技术能够快速开发一个复杂的 Web 应用。可是,我们不用。

React 将自己定位为 MVC 的 V 部分,仅仅是一个 View 库,这就给我们很大的自由空间,并且引入了基于组件的架构和基于状态的架构概念。

MVC 将 Model、Controller 和 View分离,它们的通信是单向的,View只和 Controller 通信, Controller 只跟 Model 交互,Model 也只更新 View,然而前端的重点在 View上,导致Controller非常薄,而View却很重,有些前端框架会把 Controller 当作 Router 层处理。

MVP 在 MVC 的基础上改进了一下,把 Controller 替换成了 Presenter,并将 Model 放到最后,整个模型的交互变成了 View 只能和 Presenter 之间互相传递消息,Presenter 也只能和 Model 相互通信,View 不能直接和 Model 交互,这样又导致 Presenter 非常的重,所有的逻辑基本上都写在这里。

MVVM 又在 MVP 的基础上修改了一下,将 Presenter 替换成了 ViewModel,通信方式基本上和 MVP 一样,并且采用双向绑定,View 的变动反应在 Model上,Model 的变动也反应在 View 上,所以称为 ViewModel,现在大部分的框架都是基于这种模型,如 Angular、Ember 和 Backbone。

从 MVC 和 MVP 可以看到,不是 View 层太重,就是把业务逻辑都写到了 Presenter 层,MVVM 也没有定义状态的数据流。

最早的时候 Elm 定义了一套模型,Model -> Update -> View,除了 Model 之外,其它都是纯函数的。

之后又有人提出了 SAM「State Action Model」模型,SAM 主要强调两个原则:1. 视图和模型之间的关系必须是函数式的,2. 状态的变化必须是编程模型的一等公民。

另外还有对开发友好的工具,如时光旅行。像 Redux 和 Mobx State Tree 都是这种架构模型。

让我们想象一下国家电网,或者更接近我们的经常接触的领域——网络。

网络有非常严格的定义,必须是有序的流,因为并不是所有连接到互联网的计算机都与其他计算机直接连接,它们通过路由节点间接连接。

只有这样,网络才变得可以理解,因而易于管理。状态管理也是如此,状态的流动必须是有序的。

组件架构

你可以将组件视为组成用户界面的一个个小功能。我们要描述 Gitchat 的用户界面,可以看到 Tabbar 是一个组件,发现页的达人课是一个组件,Chat 也是一个组件。

这些组件中都包装在一个容器内,它们彼此独立又互相交互。组件有自己的结构,自己的方法和自己的API,组件也是可重用的。

有些组件还有 AJAX 的请求,直接从客户端调用服务端,允许动态更新DOM,而无需页面刷新。组件每个都有自己的接口,可以调用服务端并更新其接口。

因为组件是独立的,所以一个组件可以刷新而不影响其他组件。React使用称为虚拟 DOM 的东西,它使用“diffing”算法来检测组件的更改,并且仅渲染这些更改,而不是重新渲染整个组件。

在设计组件的时候最好遵循组件的结构中仅存在与单个组件有关的所有方法和接口。

虽然这种组件的架构鼓励可重用性和单一责任,但它往往会导致臃肿。MV*的目的是确保应用程序的每个层次都有各自的职责,而基于组件的架构目的是将所有这些职责封装在一个空间内。当使用许多组件时,可读性可能会降低。

React提供了两种组件,Stateful 和 Stateless,简单来说,这两种组件的区别就是状态管理,Stateful 组件内部封装了 State 管理,Stateless 则是纯函数式的,由 Props 传递状态。

class App extends Component {  state = {      welcome: 'hello world'  }  componentDidMount() {    ...  }  componentWillUnmount() {    ...  }  render() {    return (      <div>        {this.state.welcome}      </div>    )  } }

我们先从一个简单的例子开始看,组件内部维护了一个状态,当状态发生变化是,会通知 render 更新。

这个组件不仅带有 State,还有和组件相关的 Hook。我们可以使用这种组件构建一个高内聚低耦合的组件,将复杂的交互细节封装在组件内部。

当然我们还可以使用 PureComponent 的组件优化,只有需要更新的时候才执行更新的操作。

const App = ({ welcome }) => (  <div>{welcome}</div>)

无状态的组件,状态由上层传递,组件纯展示,相比带状态的组件来说,无状态的组件性能更好,没有不必要的 Hook。

import { observer } from 'mobx-react'const App = observer(({ welcome }) => (  <div>{welcome}</div>))

observer 函数实际上是在组件上包装了一层,当可观察的State改变时,它会更新状态以 Props 的形式传递给组件。这样的组件设计能够帮助更好可复用组件。

当我们拿到设计稿的时候,一开始需要做的事情就是划分一个个小组件,并且保证组件的职责单一,而且越简单越短小越好。并尽量保持组件是无状态的。如果需要有状态,也仅仅是内部关联的状态,就是与业务无关的状态。

状态架构

如果你之前用过 jQuery 或 Angular 或任何其他的框架,通常使用命令式的编程方式调用函数,函数执行数据更新。在使用 React 就需要调整一下观念。

有限状态机是个十分有用的模型,可以用来模拟世界上大部分的事物,其有三个特征:

  1. 状态总数是有限的。

  2. 任一时刻,只处在一种状态之中。

  3. 某种条件下,会从一种状态转变到另一种状态。

state 定义初始状态,点击事件后使 counter 状态发生变化,而 render 则是描述当前状态呈现的样子。

React 自带的状态管理,Redux 和 MST 这里的工具都是一种状态机的实现,只是不同的是,React 的状态是内置组件里面,将组件渲染为组件树,而 Redux 或 MST 则是将状态维护成一棵树—状态树。

import React, { Component } from 'react'import { render } from 'react-dom'class Counter extends Component {  state = {    counter: 0,  }  increment = (e) => {    e.preventDefault()    this.setState({ counter: this.state.counter++ })  }  decrement = () => {     e.preventDefault()    this.setState({ counter: this.state.counter-- })  }  render() {    return (      <div>          <div id='counter'>{this.state.counter}</div>          <button onClick={this.increment}>+</button>          <button onClick={this.decrement}>-</button>      </div>    )  } } render(<Counter />, document.querySelector('#app'))

组件自己管理的状态数据相关联的一个缺点,就是将状态管理与组件生命周期相耦合。如果某些数据存在于组件的本地状态中,那么它将与该组件一起消失,没有进一步保存数据,那么只要组件卸载,State 的内容就会丢失。

组件的层次结构的在很大程度上取决于通过 DOM 布局。因为状态主要通过 React 中的 Props 分发,组件之间的父/子关系结构,影响了组件的通信,单向(父到子)对状态流动是比较容易的。

当组件嵌套的层级比较深时,依赖关系变得复杂时,必然会有子级组件需要修改父级组件的状态,这就需要回调函数在各个组件传递,状态管理又变得非常混乱。

这就需要一个独立于组件之外的状态管理,能够中心化的管理状态,解决多层组件依赖和状态流动的问题。

现在主流的状态管理有两种方案,基于 Elm 架构的 Redux,基于观察者的 Mobx。

import React from 'react'import { render } from 'react-dom'import { createStore } from 'redux';const counter = (state = 0, action) => {  switch (action.type) {    case 'INCREMENT':      return state + 1;    case 'DECREMENT':      return state - 1;    default:      return state;  } }const store = createStore(counter)const Counter = ({  value,  onIncrement,  onDecrement }) => (  <div>    <div id='counter'>{value}</div>    <button onClick={onIncrement}>+</button>    <button onClick={onDecrement}>-</button>  </div>); const App = () => (  <Counter    value={store.getState()}    onIncrement={() => store.dispatch({ type: 'INCREMENT' })}    onDecrement={() => store.dispatch({ type: 'DECREMENT' })}  /> ) const renderer = render(<App />, document.querySelector('#app')) store.subscribe(renderer)

Redux 状态的管理,它的数据流动必须严格定义,要求所有的State都以一个对象树的形式储存在一个单一的 Store 中。

惟一改变 State 的办法是触发 Action,为了描述 Action 如何改变 State 树,你需要编写 Reducers。Redux的替代方法是保持简单,使用组件的局部状态。

import React from 'react'import { render } from 'react-dom'import {observable} from 'mobx'import {observer} from 'mobx-react'class Counter {  @observable counter = 0  increment() { this.counter++ }  decrement() { this.counter-- } }const store = new CounterStore();const Counter = observer(() => (  <div>      <div id='counter'>{store.counter}</div>    <button onClick={store.increment}>+</button>    <button onClick={store.decrement}>-</button>  </div>)) render(<App />, document.querySelector('#app'))

Mobx 觉得这种方式太麻烦了,为了更新一个状态居然要绕一大圈,它强调状态应该自动获得,只要定义一个可观察的 State,让 View 观察 State 的变化,State 的变化之后发出更新通知。在Redux里要实现一个特性,你需要更改至少4个地方。

包括 reducers、actions、组件容器和组件代码。Mobx 只要求你更改最少2个地方,Store 和 View。很明显看到使用 Mobx 写出来的代码非常精简,OOP 风格和良好的开发实践,你可以快速构建应用。

import React from 'react'import { render } from 'react-dom'import { types } from 'mobx-state-tree'import { observer } from 'mobx-react'const CounterModel = types  .model({      counter: types.optional(types.number, 0)  })  .actions(self => ({      increment() {        self.counter++      },      decrement() {        self.counter--      }  }))const store = CounterModel.create()const App = observer(({ store }) => (  <div>    <div id='counter'>{store.counter}</div>    <button onClick={store.increment}>+</button>    <button onClick={store.decrement}>-</button>  </div>)) render(<App store={store}/>, document.querySelector('#app'))

这种管理状态看起来很像 MVVM 的双向绑定,MST「Mobx State Tree」受Elm 和 SAM 架构的影响,背后的思想也非常简单:

  1. 稳定的参考态和直接可变的对象。也就是有一个变量指向一个对象,并对其进行后续的读取或写入,不用担心你正在使用旧的数据。

  2. 状态为不可变的、结构性的共享树。

每次的操作,MST 都会将不可变的数据状态生成一个快照,类似虚拟 DOM 的实现方案,因为 React 的 render 也只是比较差异再渲染的,所以开销并不会太大。

与 MobX 不同的是,MST 是一种有架构体系的库,它对状态组织施行严格的管理。修改状态和方法现在由 MST 树处理。

使用 MobX 向父组件注入树。一旦注入,树就可以用于父组件及其子组件。

父组件不需要通过子组件将任何方法传递给子组件B。React 组件根本不需要处理任何状态。子组件B可以直接调用树中的动作来修改树的属性。

异步方案

我们都知道 Javascript 的代码运行在主线程上,像 DOM 事件、定时器运行在工作线程上。一般情况下,我们写一段异步操作的代码,一开始可能就想到使用回调函数。

asyncOperation1(data1,function (result1) {    asyncOperation2(data2,function(result2){        asyncOperation3(data3,function (result3) {            asyncOperation4(data4,function (result4) {                // do something            })        })    }) })

回调函数使用不当嵌套的层级非常多就造成回调地狱。

Promise 方案使用链式操作的方案这是将原来层级的操作扁平化。

asyncOperation1(data)    .then(function (data1) {        return asyncOperation2(data1)    }).then(function(data2){        return asyncOperation3(data2)    }).then(function(data3){    return asyncOperation(data3) })

ES6 语法中引入了 generator,使用 yeild 和 * 函数封装了一下 Promise,在调用的时候,需要执行 next() 函数,就像 python 的yield一样。

function* generateOperation(data1) {    var result1 = yield asyncOperation1(data1);    var result2 = yield asyncOperation2(result1);    var result3 = yield asyncOperation3(result2);    var result4 = yield asyncOperation4(result3);    // more}

ES7 由出现了 async/await 关键字,其实就是在 ES6 的基础上把 * 换成 async,把 yield 换成了 await,从语义上这种方案更容易理解。

async function generateOperation(data1) {    var result1 = await asyncOperation1(data1);    var result2 = await asyncOperation2(result1);    var result3 = await asyncOperation3(result2);    var result4 = await asyncOperation4(result3);    // more}

在调用 generateOperation 时,ES6 和 ES7 的异步方案返回的都是 Promise。

传统的异步方案:

  1. 嵌套太多

  2. 函数中间太过于依赖,一旦某个函数发生错误,就不能继续执行下去了

  3. 变量污染

使用 Promise 方案:

  1. 结构化代码

  2. 链式操作

  3. 函数式

使用 Promise 你可以像堆积木一样开发。

多入口与目录结构

Angluar 或者 Ember 这类框架提供了一套非常完备的工具,对于初学者非常友好,可以通过 CLI 初始化项目,启动开发服务器,运行单元测试,编译生产环境的代码。

React 在发布很久之后才发布了它的CLI工具:create-react-app。屏蔽了和 React 无关的配置,如 Babel、Webpack。我们可以使用这个工具快速创建一个项目。

用 npm 安装一个全局的 create-react-app,npm install -g create-react-app ,然后运行 create-react-app hello-world ,就初始化好了一份React项目了,只要运行npm run start就能启动开发服务器了。

然而,复杂的项目必然需要自定义 Webpack 配置,create-react-app 提供了eject 命令,这个命令是不可逆的,也就是说,当你运行了 eject 之后,就不能再用之前的命令了。这是必经的过程,所以我们继续来看 Webpack 的配置。

 Webpack 核心配置非常简单,只要掌握三个主要概念即可:

  1. entry 入口

  2. output 出口

  3. loader 加载器

entry 支持字符串的单入口和数组/对象的多入口:

{  entry: './src'}

entry: { // pagesDir是前面准备好的入口文件集合目录的路径  pageOne: './src/pageOne.js',  pageTwo: './src/pageTwo.js', }

output 是打包输出相关的配置,它可以指定打包出来的包名/切割的包名、路径。

{  output: {    path: './dist',    filename: '[name].bundle.js',    chunkFilename: '[name].chunk.js',    publicPath: '/',  } }

Loader 配置也非常简单,根据不同的文件类型对应不同的 Loader。

{  module: {    rules: [      {        test: /\.(js|jsx)$/,        loader: 'babel-loader',      }  } }

以及 resolve,plugins 配置提供更丰富的配置,create-react-app 就是利用 resolve 的 module 配置支持多个 node_modules,如 create-react-app 目录下的 node_modules 和当前项目下的 node_modules。

现在再来看看 Webpack 的多入口方案,Webpack 的 output 内置变量 [name] 打包出来的包名对应 entry 对象的 key,用 HtmlWebpackPlugin 插件自动添加 JS 和 CSS。

如果使用 Commons Chunk 可以将 Vendor 单独剥离出来,这样多入口就可以复用同一个Vendor。

通常,我们一个项目有许多独立的子项目,使用 Webpack 的多入口方案就会造成打包的速度非常慢,不必要更新的入口也一起打包了。

这时应该拆分为多个 package,每个 package 是单入口也是独立的,这种方案称为 monorepos「之前社区很流行将每个组件都建一个代码仓库,使用不太优雅的方案 npm link 在本地开发,这种方案的缺点非常明显,代码太分散,版本管理也是一个灾难」。

我们可以使用 Monorepos 的方案,将子项目都放在 packages 目录下,并且使用 Lerna「实现 Monorepos 方案的一个工具」管理 packages,可以批量初始化和执行命令。

配合 Lerna 加 Yarn 的 Workspace 方案,所有子项目的 node_modules 都统一放在项目根目录。

{  "lerna": "2.1.2",  "commands": {    "publish": {      "ignore": ["ignored-file", "*.md"]    }  },  "packages": ["packages/*"],  "npmClient": "yarn",  "version": "2.6.1",  "private": true}

正因为有 Webpack 的存在,似乎不怎么关注项目的目录结构,像 Angular 或者 Ember 这样的框架,开发者必须按照它的建议或者强制要求把按功能把文件放置到指定目录。

React 却没有这类的束缚,开发者可以随意定义目录结构。无论如何,我们依然可以有3种套路来定义目录结构。

  1. 扁平化

    比如可以将所有的组件都放在 components 目录,这种适合简单组件少或者比较单一的情况。

  2. 以组件为目录

    组件内需要的文件放在同一个目录下,如 Alert 和 Notification 可以建两个目录,目录内部有代码、样式和测试用例。

  3. 以功能为目录

    如 components、containers、stores 按其功能放在一个目录内,将组件都放在components 目录内,containers 则是组装 component。

团队协作

团队开发必然也会遇到一个问题,每个人写的代码风格都不一样,不同的编辑器也不尽相同。

有人喜欢双引号,也有人使用单引号,代码结尾要不要分号,最后一个对象要不要逗号,花括号放哪里,80列还是100列的问题。

还有更贱的情况,有人把代码格式化绑定在编辑器上,一打开文件就格式化了一下代码,如果他在提交一下代码,简直是异常灾难,花了半天写代码,又花了半天解决代码冲突问题。

像 Go 语言自带了代码格式化工具,使每个人写出来的代码风格是一致的,消除了程序员的战争。

前端也有类似的工具,Prettier 配合 ESLint 最近在前端大受欢迎,再使用husky和 lint-staged 工具,在提交代码的时候就将提交的代码格式化。

Prettier 是什么呢?就是强制格式化代码风格的工具,在这之前也有类似的工具,像 Standardjs,这个工具仅格式化 JS 代码,无法处理 JSX。

而 Prettier 能够格式化 JS 和 LESS 以及 JSON。

// lint-staged.config.jsmodule.exports = {  verbose: false,  globOptions: {    dot: false,  },  linters: {    '*.{js,jsx,json,less,css}': ['prettier --write', 'git add'],  }, }

在 package.json 的 scripts 增加一个 precommit

{  "scripts": {    "precommit": "lint-staged"  }}

这样,在提交代码时,就自动格式化代码,使每个开发者的风格强制的保存一致。

测试驱动

很多人都不喜欢写测试用例代码,觉得浪费时间,主要是维护测试代码非常的繁琐。

但是当你尝试开始写测试代码的时候,特别是基础组件类的,就会发现测试代码是多么好用。

不仅仅提高组件的代码质量,但是当发生依赖库更新,版本变化时,就能够马上发现这些潜在的问题。如果没有测试代码,也谈不上自动化测试。

前端有非常多的工具可以选择,Mocha、Jasmine、Karma、Jest 等等太多的工具,要从这些工具里面选择也是个困难的问题,有个简单的办法看社区的推荐,React 现在主流推荐使用 Jest 作为测试框架,Enzyme 作为 React 组件测试工具。

我们做单元测试也主要关注四个方面:组件渲染、状态变化、事件响应、网络请求。

而测试的方法论,可以根据自己的喜好实践,如 TDD 和 BDD,Jest对这两者都支持。

首先我们测试一个 Stateless 的组件

import React from 'react' import { string } from 'prop-types' const Link = ({ title, url }) => <a href={url}>{title}</a> Link.propTypes = {  title: string.isRequired,  url: string.isRequired } export default Link

我们想看看 Props 属性是否正确,是否渲染来测试这个组件。在第一次运行测试的时候会自动创建一个快照,然后看看结果是否一致。

import React from 'react' import { shallow } from 'enzyme' import { shallowToJson } from 'enzyme-to-json' import Link from './Link' describe('Link', () => {  it('should render correctly', () => {    const output = shallow(      <Link title="testTitle" url="testUrl" />    )    expect(shallowToJson(output)).toMatchSnapshot()  }) })

在运行测试之后,Jest 会创建一个快照。

exports[`Link should render correctly 1`] = ` <a  href="testUrl" >  testTitle </a> `;

彩蛋

阅读原文」了解更多 Chat 内容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值