/ 前端工程

一步步搭建基于 React 技术栈的中型前端项目开发环境(一) --- 基本环境

一步步搭建基于 React 技术栈的中型前端项目开发环境(一) --- 基本环境

2017年12月26日

本文涉及到的技术主要有: React 全家桶,webpack,PostCSS,TypeScript,ImmutableJS。目标是搭建一个基本的开发环境,没有集成测试和协作方面的工具进来。

推荐结合GitHub仓库上的代码阅读本文,如果对环境搭建比较陌生,这篇文章很详细,可以按照本文一步步地进行下去。为了体现这些步骤,代码是增量变化的,但是为了展现每块内容的完整性,很多地方都不是只写增量,而是写了完整的代码块,配合代码块附近的说明和代码块内的注释,应该会更容易理解增量所在,同时因为这个原因,文章看起来很长,实际上读起来没有看上去那么长的,只看增量就好了。

(增量用 "+++" 表示,删减用 "---" 表示,修改用 "~~~" 表示)


目录

  1. 项目结构
  2. 技术版本信息及简介
  3. 安装 devDependencies 及 dependencies
  4. webpack 等开发工具的配置
  5. React 及 react-hot-loader 搭建
  6. Redux 及 redux-devtools 搭建
  7. react-router 及 react-router-redux 搭建
  8. 总结

零、项目结构

├── build
│   └── webpack.dev.config.js
├── package.json
├── src
│   ├── Root.tsx
│   ├── app.html
│   ├── constants
│   │   └── actions.ts
│   ├── containers
│   │   ├── Hello.tsx
│   │   └── World.tsx
│   ├── index.tsx
│   ├── store
│   │   ├── index.ts
│   │   └── reducers
│   │       ├── index.ts
│   │       ├── routing.ts
│   │       └── test.ts
│   └── utils
│       └── history.ts
├── tsconfig.json
├── tslint.json
└── yarn.lock

一、技术版本信息及简介

为什么要说版本,因为这个真的坑啊……具体原因有:

  1. 在版本依赖有问题的情况下,如果代码也写得还有问题,但又是那种可以被容错的小问题,不那么容易暴露出来,就会非常难定位问题;
  2. 有一些包,在大的版本更新后API也会更新,直接对上一版本不兼容,换句话说就是包的版本时效性,如果看到这篇文章时,文中技术的版本已经太旧了,请谨慎评估本文的价值,避免浪费时间。

以下是本文用到的技术栈的版本信息:

"dependencies": {
  "@types/history": "^4.6.2",
  "@types/react": "^16.0.31",
  "@types/react-dom": "^16.0.3",
  "@types/react-redux": "^5.0.14",
  "@types/react-router-dom": "^4.2.3",
  "@types/react-router-redux": "^5.0.11",
  "@types/redux-immutable": "^3.0.38",
  "history": "^4.7.2",
  "immutable": "^3.8.2",
  "react": "^16.2.0",
  "react-dom": "^16.2.0",
  "react-redux": "^5.0.6",
  "react-router": "^4.2.0",
  "react-router-dom": "^4.2.2",
  "react-router-redux": "^5.0.0-alpha.9",
  "redux": "^3.7.2",
  "redux-immutable": "^4.0.0"
},
"devDependencies": {
  "@types/react-hot-loader": "^3.0.5",
  "@types/webpack-env": "^1.13.3",
  "autoprefixer": "^7.2.3",
  "awesome-typescript-loader": "^3.4.1",
  "css-loader": "^0.28.7",
  "file-loader": "^1.1.6",
  "html-webpack-plugin": "^2.30.1",
  "postcss": "^6.0.14",
  "postcss-loader": "^2.0.9",
  "precss": "^2.0.0",
  "react-hot-loader": "^3.1.3",
  "redux-devtools": "^3.4.1",
  "source-map-loader": "^0.2.3",
  "style-loader": "^0.19.1",
  "tslint": "^5.8.0",
  "tslint-loader": "^3.5.3",
  "tslint-react": "^3.2.0",
  "typescript": "^2.6.2",
  "url-loader": "^0.6.2",
  "webpack": "^3.10.0",
  "webpack-dev-server": "^2.9.7",
}

这里稍微介绍一下所用到的主要技术:

  • React 全家桶: 包括 React, Redux, react-router,分别是开发基础框架,状态管理库,前端路由工具。前端应该都很熟悉,就不过多介绍了。

  • webpack: 前端打包构建工具,通过各种 loader 和 plugin 的配置,拥有强大的前端打包构建功能,但是官方文档写得很朦胧,导致配置也比较麻烦,坊间甚至有了"webpack 配置工程师"的传闻。

  • PostCSS: 一个 CSS 预处理工具,非常强大,因为 CSS 也是前端重要的一部分,所以在这里提及一下。本文主要用到了它的 autoprefixer, precss 插件, autoprefixer 为一些 CSS 属性加上浏览器前缀,precss 可以在不装 sass 的情况下写类 sass 的样式,这样可以避免遇到 node-sass 包的坑。

  • TypeScript: 之前看过一句话甚是吓人:“动态类型一时爽,重构全家火葬场”……,TypeScript 是 JavaScript 的超集,支持静态类型,弥补 JS 这门动态语言在项目复杂度大了以后的不足,它还有接口等其它特性,并且紧跟 ES 标准,这样就可以不用 babel 转义就直接使用许多 ES 新特性(但有些特性还是需要加上polyfill使用的)。用了 TypeScript 以后还可以方便与他人协作,调用方法时有参数提醒和检验,不用去打开文件花时间看方法该怎么调用,还可以在编译的时候就找出代码的错误,不用在运行时去找,在项目复杂度大了以后,TS 是一种有效的防止精力浪费在不必要的事情上的工具。(在NPM中大部分包都是用JS写的,所以想要在TS中使用这些包还需要对应的TS声明文件,比如本文中用到的"@types/***",具体可看JavaScript 和 TypeScript 交叉口 —— 类型定义文件(*.d.ts)

  • ImmutableJS: 用于生成不可变数据结构,其价值在于: 1. 因为数据不可变,所以新生成的数据是一个基于原数据生成的全新对象,可以方便地找出与原来数据的不同之处,从而方便快速地得到虚拟DOM的更新;2. 因为每次都是不同的对象,所以天然可以做状态的回溯、切换,从一个状态切换到前面或者后面的某个状态非常方便。

二、安装 devDependencies 及 dependencies

虽然上面的章节已经把所有项目依赖都"暴露"了,但这里还是一步步地来安装依赖,这样有便于梳理清楚都用了什么东西,心里能"有谱":

1. 安装 devDependencies

(1) 没有打包构建就没法运行起来,先安装 webpack

yarn add webpack webpack-dev-server @types/webpack-env -D

(2) 安装 webpack 所需要的基本 loader 和 plugin

yarn add postcss postcss-loader autoprefixer precss css-loader style-loader file-loader url-loader html-webpack-plugin -D

(3) 安装 webpack 的功能增强工具

yarn add source-map-loader react-hot-loader redux-devtools @types/react-hot-loader -D

(4) 安装 TypeScript 及 loader

yarn add typescript awesome-typescript-loader -D

(5) 安装 lint 工具

yarn add tslint tslint-loader tslint-react -D

2. 安装 dependencies

这里把 react 等包依赖的 TypeScript 声明"@types/***"也放在 dependencies 里面,是因为虽然这些类型声明依赖不会被打包到最后的运行代码中,但它们和项目的生产依赖版本紧密相关,不同版本的包对应的 TS 声明文件也是不同的,考虑到这种强相关性,所以把它们放到了 dependencies 里面。下面按照步骤安装生产依赖:

(1) 安装 react, react-dom

yarn add react react-dom @types/react @types/react-dom

(2) 安装 redux, react-redux

yarn add redux react-redux @types/react-redux

因为 redux 自带了 TS 类型声明文件,所以不需要额外安装

(3) 安装 react-router, react-router-dom 等

yarn add react-router react-router-dom react-router-redux@next history @types/react-router-dom @types/react-roter-redux @types/history

注意这里 react-router-redux 安装的版本是 next 版,目标就是为了安装上面小节所列出的版本以实现多个包版本的一致性。

(4) 安装 immutable, redux-immutable

yarn add immutable redux-immutable

三、webpack 等开发工具的配置

1. TypeScript 配置: tsconfig.json

{
  "compilerOptions": {
    "lib": [
      "es6",
      "dom"
    ],
    "outDir": "./dist/",
    "sourceMap": true,
    "noImplicitAny": true,
    "target": "es5",
    "jsx": "react",
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true
  },
  "include": [
    "./src/**/*"
  ]
}

2. TSlint配置: tslint.json

{
  "extends": [
    "tslint-react"
  ],
  "rules": {
    "quotemark": [
      true,
      "single"
    ],
    "ordered-imports": [
      false
    ],
    "jsx-boolean-value": [
      false
    ],
    "semicolon": [
      false,
      "always"
    ],
    "space-before-function-paren": [true, "always"],
    "whitespace": [true,"check-branch", "check-operator", "check-typecast"]
  }
}

3. webpack 开发环境配置: webpack.dev.config.js

先不考虑生产环境或者别的环境的 webpack 配置,只把开发环境的配置配出来,之后需要配置其它环境及引入其它工具都可以基于这个配置去做,相当于局部的 MVP(minimum viable product)。webpack 配置的分步骤可以按照下面的配置一点点配下去就行,因为下面的配置已经用心排过序了,具体的步骤说明可以参看里面的注释。

var path = require('path')
var webpack = require('webpack')
var HTMLWebpackPlugin = require('html-webpack-plugin')

/* 本块代码为后面的 dev-server 的配置做准备 */
var PORT = 2333
var HOST = 'http://localhost'
var URL = `${HOST}:${PORT}`

module.exports = {
  // 配置 webpack 的上下文,entry 以及 loader, plugin 中的相对路径都会以这个上下文为起始路径。先把上下文配好,后面的相对路径才好根据上下文路径来配
  context: path.resolve(__dirname, '../src'),

  // 项目入口,单页应用配置一个数组
  entry: [
    `webpack-dev-server/client?${URL}`,
    './index.tsx'
  ],

  // 打包输出目录及文件名
  output: {
    path: path.join(__dirname, '../dist'),
    filename: 'bundle.js',
    publicPath: ''
  },

  // 配置 webpack 处理得文件扩展名
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.json']
  },

  // 配置各种 loader
  module: {
    rules: [
      // 为打包编译后的文件添加 source-map,注意 enfore: 'pre',这样可以在被编译的 JS 文件被加载钱就先加载 source-map-loader,从而在文件加载后得到 source-map
      {
        test: /\.js$/,
        enforce: 'pre',
        use: ['source-map-loader']
      },
      // ts(x)文件被 loader 编译前先进行 lint 检查
      {
        test: /\.tsx?$/,
        enforce: 'pre',
        include: [path.resolve(__dirname, '../src')],
        use: [{
          loader: 'tslint-loader',
          options: {
            tsConfigFile: 'tsconfig.json'
          }
        }]
      },
      // 编译 ts(x) 文件的 loader
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'awesome-typescript-loader',
            options: {
              useCache: true
            }
          }
        ]
      },
      // 编译 CSS, SCSS 的 loader
      {
        test: /\.(css|scss)$/,
        use: [
          'style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              plugins: function() {
                return [
                  require('precss'),
                  require('autoprefixer')
                ]
              }
            }
          }
        ]
      },
      // 处理字体的 loader
      {
        test: /\.(ttf|eot|woff(?:2)?)(\?[a-z0-9]+)?$/,
        use: ['url-loader?limit=1000']
      },
      // 处理 svg 的 loader
      {
        test: /\.svg?(\?[a-z0-9]+)?$/,
        use: ['url-loader?limit=1000']
      },
      // 处理一般图片的 loaders
      {
        test: /\.(jpe?g|png|gif|ogg|mp3)$/,
        use: ['url-loader?limit=1000']
      }
    ]
  },

  plugins: [
    // 在 webpack 编译时定义全局变量
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('development')
    }),
    // 配置 HTML 模板的 plugin 
    new HTMLWebpackPlugin({
      template: './app.html',
      filename: 'index.html'
    }),
    new webpack.NamedModulesPlugin() // 当模块更新的时候,会在浏览器控制台里 log 出更新的模块名称以及模块路径
  ],

  // source map 配置
  devtool: 'source-map',

  // 配置 dev server
  devServer: {
    hot: true,
    compress: true,
    contentBase: path.resolve(__dirname, '../src'),
    port: PORT,
    publicPath: URL,
    historyApiFallback: true,
    stats: {
      colors: true,
      modules: false,
      children: false,
      chunks: false,
      chunkModules: false
    }
  }
}

四、React 及 react-hot-loader 搭建

react-hot-loader 是一个做 react 组件热更新的 loader,由于其配置需要给最外层组件包裹一层父组件,所以在src/index.tsx入口中进行控制,另外再写一个 src/Root.tsx 组件作为项目的实际根组件:

(1) 创建项目的入口以及加上热更新

src/index.tsx:

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import Root from './Root'

const mountingElement: HTMLElement = document.getElementById('app')

// 在开发环境下加载 react-hot-loader,非开发环境不加载
if (process.env.NODE_ENV === 'development') {
  const render = (Component: React.ComponentClass<any>) => {
    ReactDOM.render(
      <AppContainer>
        <Component />
      </AppContainer>,
      mountingElement
    )
  }

  render(Root)

  if (module.hot) {
    module.hot.accept('./Root', () => {
      const Root = require('./Root').default // 以 CommonJS 规范取一个 ES6 模块,默认需要以 require('***').default 的方式来取
      render(Root)
    })
  }
} else {
  ReactDOM.render(
    <Root />,
    mountingElement
  )
}

(2) 应用的根组件,不考虑添加 redux 还有 router 的情况

src/Root.tsx:

import * as React from 'react'

export default class Root extends React.Component<object, object> {
  render () {
    return (
      <div>Hello, world!</div>
    )
  }
}

(3) 更改 webpack 的一些配置以实现热更新

因为引入了 react-hot-loader,所以需要在 webpack 配置文件里面做一些修改,分别为:

  • 在 entry 里添加两个 react-hot-loader 相关的条目
...

entry: [
  'react-hot-loader/patch', // +++
  `webpack-dev-server/client?${URL}`,
  'webpack/hot/only-dev-server', // +++ 可以在有错误发生时不热加载刷新页面
  './index.tsx'
],

...
  • 在 tsx 文件的 loader 里面加上一个 react-hot-loader/webpack

...
{
  test: /\.tsx?$/,
  exclude: /node_modules/,
  use: [
    'react-hot-loader/webpack', // +++
    {
      loader: 'awesome-typescript-loader',
      options: {
        useCache: true
      }
    }
  ]
},

...
  • plugins 里面添加 HotModuleReplacementPlugin
...

plugins: [
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify('development')
  }),
  new HTMLWebpackPlugin({
    template: './app.html',
    filename: 'index.html'
  }),
  new webpack.HotModuleReplacementPlugin(), // +++
  new webpack.NamedModulesPlugin()
],

...

更改后的完整结果可以参考GitHub仓库

五、Redux 及 redux-devtools 搭建

利用 redux 进行状态管理,还是先从基本的入手,建立一个 reducer,那么需要建立一个 actions.ts 模块专门管理 action 对应的常量:

(1) 在专门放常量的文件夹内专门建一个模块来放 action 对应的常量

src/constants/actions.ts:

export const THISIAATEST = 'THISISATEST'
export type THISIAATEST = typeof THISIAATEST

(2) 创建一个简单的 reducer

src/store/reducers/test.ts:

import { THISIAATEST } from '../../constants/actions'
import * as Immutable from 'immutable'

const testState = Immutable.fromJS({
  anyState: ''
})

export default function (state = testState, action: { type: string, payload: string }) {
  return {
    anyState: action.payload
  }
}

(3) 利用 combineReducers 将所有 reducer 组合起来

src/store/reducers/index.ts:

import { combineReducers } from 'redux-immutable'
import test from './test'

export default combineReducers({
  test
})

注意这里是从 redux-immutable 中取得 combineReducers 方法,因为 store 中的状态都是 Immutable 的,所以不能用 redux 自带的方法。

(4) 编写建立 store 的模块

有了 reducer 以后,就可以准备建立 store 了,同时在开发环境下加上 redux-devtools :

src/store/index.ts:

import { createStore, compose, applyMiddleware } from 'redux'
import * as Immutable from 'immutable'

import reducers from './reducers'

const initState = Immutable.fromJS({
  test: {}
})

export default function (initialState = initState) {
  const middleWares = []

  if (process.env.NODE_ENV === 'development') {
     /* 这一小块是 redux-devtools 的知识,直接查阅官方文档就行 */
    const { persistState } = require('redux-devtools')
    const enhancers = compose(
      applyMiddleware(...middleWares),
      (window as any).devToolsExtension ? (window as any).devToolsExtension() : (f) => f,
      persistState(
        window.location.href.match(/[?&]debug_session=([^&#]+)\b/)
      )
    )

    const store = createStore(reducers, initialState, enhancers)

    // 各个 reducer 也添加热更新
    if (module.hot) {
      module.hot.accept('./reducers', () => {
        const reducers = require('./reducers').default
        store.replaceReducer(reducers)
      })
    }

    return store
  } else {
    const store = createStore(reducers, initialState, applyMiddleware(...middleWares))
    return store
  }
}

(5) 创建 store 并添加

在项目入口创建 store ,并放入 <Provider> 中,更改src/index.tsx为这样:

src/index.tsx:

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux' // +++
import getStore from './store' // +++
import Root from './Root'

const mountingElement: HTMLElement = document.getElementById('app')
const store = getStore() // +++

if (process.env.NODE_ENV === 'development') {
  const render = (Component: React.ComponentClass<any>) => {
    ReactDOM.render(
      <AppContainer>
        <Provider store={store}> {/* +++ */}
          <Component />
        </Provider> {/* +++ */}
      </AppContainer>,
      mountingElement
    )
  }

  render(Root)

  if (module.hot) {
    module.hot.accept('./Root', () => {
      const Root = require('./Root').default
      render(Root)
    })
  }
} else {
  ReactDOM.render(
    <Provider store={store}> {/* +++ */}
      <Root />
    </Provider>, // +++
    mountingElement
  )
}

到这里,redux 及 redux-devtools 也搭建完毕了。Redux 这一块还是很好处理的,因为概念较少,就是利用各种 reducer 来处理 action 从而实现状态的变化,利用这些 reducer 及一个初始状态,就可以得到一个 store 了。

六、react-router 及 react-router-redux 搭建

最后,是把 react-router 加进来,同时把 router 相关的东西放到 redux 的 store 中,就可以非常方便地获取以及管理 route 信息。这一步主要有这些工作:

  1. 创建一个浏览器 history 管理对象,并传入前端的路由中,这样才可以更好地管理和捕获路由的变化;
  2. 因为路由的信息放在了 redux 的 store 中,所以需要写一个 reducer 来专门处理路由的状态;
  3. 在创建 store 时添加控制路由变化的中间件,以实现用 dispatch action 的方式来变更路由路径。

接下来实现这些工作:

(1) 创建 history 来管理路由信息的变化,并传入前端路由中:

首先利用 history 这个 NPM 包创建一个 history 对象:

src/utils/history:

import { createHashHistory as createHistory } from 'history'

const history  = createHistory()

export default history

之后将这个 history 传入前端路由中,此时不可以直接用 react-router 自带的 <Router> ,因为这样的话前端路由状态的变化就没法在 store 中同步,因此需要使用 react-router-redux 提供的 <ConnectedRouter>,本质上这个组件是 <Router> 的高阶组件,在使用 <Router> 前,其会把路由的状态变化在 store 中进行同步。具体代码是这样:

src/index.tsx:

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux'
import getStore from './store'
import { ConnectedRouter } from 'react-router-redux' // +++
import history from './utils/history' // +++
import Root from './Root'

const mountingElement: HTMLElement = document.getElementById('app')
const store = getStore()

if (process.env.NODE_ENV === 'development') {
  const render = (Component: React.ComponentClass<any>) => {
    ReactDOM.render(
      <AppContainer>
        <Provider store={store}>
          <ConnectedRouter history={history}> {/* +++ */}
            <Component />
          </ConnectedRouter> {/* +++ */}
        </Provider>
      </AppContainer>,
      mountingElement
    )
  }

  render(Root)

  if (module.hot) {
    module.hot.accept('./Root', () => {
      const Root = require('./Root').default
      render(Root)
    })
  }
} else {
  ReactDOM.render(
    <Provider store={store}>
      <ConnectedRouter history={history}> {/* +++ */}
        <Root />
      </ConnectedRouter> {/* +++ */}
    </Provider>,
    mountingElement
  )
}

(2) 创建管理路由信息的 reducer

首先进行 reducer 创建

src/store/reducers/routing.ts:

import { LOCATION_CHANGE } from 'react-router-redux'
import * as Immutable from 'immutable'

const initState = Immutable.fromJS({
  location: null
})

export default function (state = initState, action: { type: string, payload: object}) {
  if (action.type === LOCATION_CHANGE) {
    return state.merge(action.payload)
  }
  return state
}

修改一下 combineReducers 方法的调用,加入 routing 这个 reducer

src/store/reducers/index.ts:

import { combineReducers } from 'redux-immutable'

import test from './test'
import routing from './routing' // +++

export default combineReducers({
  test,
  routing
}) // +++

这样处理路由变化的 reducer 就写完了。

(3) 在 store 中增加控制路由变化的中间件

使用 react-router-redux 的 routerMiddleware API 来增加控制路由变化的中间件,以实现可以在组件中 dispatch action 的方式来更改路由信息,利用 react-router-redux 带的 push, go 等 API,可以方便地得到对应的 action:

src/store/index.ts:

import { createStore, compose, applyMiddleware } from 'redux'
import { routerMiddleware } from 'react-router-redux' // +++
import * as Immutable from 'immutable'

import reducers from './reducers'
import history from '../utils/history' // +++

const initState = Immutable.fromJS({
  test: {}
})

export default function (initialState = initState) {
  const middleWares = [routerMiddleware(history)] // ~~~

  if (process.env.NODE_ENV === 'development') {
    const { persistState } = require('redux-devtools')

    const enhancers = compose(
      applyMiddleware(...middleWares),
      (window as any).devToolsExtension ? (window as any).devToolsExtension() : (f) => f,
      persistState(
        window.location.href.match(/[?&]debug_session=([^&#]+)\b/)
      )
    )

    const store = createStore(reducers, initialState, enhancers)

    if (module.hot) {
      module.hot.accept('./reducers', () => {
        const reducers = require('./reducers').default
        store.replaceReducer(reducers)
      })
    }

    return store
  } else {
    const store = createStore(reducers, initialState, applyMiddleware(...middleWares))
    return store
  }
}

七、总结

到这里,整个开发环境的基础就搭建完毕了,可以马上修改一下 src/Root.tsx 组件,添加上路由配置试试效果。真实的开发环境还需要做一些调整,比如完成 webpack 各个环境下的配置,还有动态加载,提取公共模块,代码压缩等等……这些属于 webpack 定制化的部分,就不在这篇文章里写了。其实写这么多,可能会看到这篇文章的人不会有很多,但是我了解到网上的 react 技术栈的开发环境的搭建都是基础入门的例子,没有更多的深入,因此觉得写一篇这样的文章还是有价值的,另外,写这篇文章也极大地帮我加深了对开发环境搭建的记忆和理解,相信能起到助人助己的作用。这篇是第一篇,基础开发环境的搭建,第二篇会加上测试,这块我也还不太熟悉,需要熟悉了再写,敬请期待吧~