使用Webpack等搭建一个适用于React项目的脚手架(2 - React Router、Redux、Sass)

762 阅读8分钟

《使用Webpack等搭建一个适用于React项目的脚手架(1 - React、TypeScript)》中记录了React、TypeScritpt相关配置。这篇文章主要记录在项目中使用React-Router、Redux和Sass,以及引入图片。

因为本文中新增的代码略多,所以文中只列出了部分代码。完整的代码可以查看本文源码

使用React Router

首先安装好react-router-dom以及@types/react-router-dom

npm i --save react-router-dom
npm i --save-dev @types/react-router-dom

1.调整webpack.dev.js

devServer: {
  ...
  historyApiFallback: true,
},

使用路由之后,需要在webpack的devServer中配置historyApiFallback的值为true,否则刷新页面的时候就会找不到路径(Cannot GET /authors)。关于historyApiFallback的详细内容,可以查看 connect-history-api-fallback

2.调整tsconfig.json:

  "include": [
    "src/*",
    "src/**/*",
    "typings/*"
  ]

增加了一行"src/**/*",**/的意思是递归匹配任何子目录。很奇怪的是,上文的代码中没有在include中添加"src/**/*",,代码没有出错,但是本文在pages文件夹下创建新组件的时候,出错了:Cannot find module '@/router/index'.ts(2307)。加上"src/**/*",才能解决这个问题。

3.添加页面并参照route config中的方法配置路由。

文件结构变成了这样:

...
├── src
    ...
│   ├── pages
│   │   ├── App
│   │   │   └── App.tsx
│   │   ├── Author
│   │   │   └── Author.tsx
│   │   ├── Home
│   │   │   └── Home.tsx
│   │   └── Novel
│   │       └── Novel.tsx
│   └── router
│       └── index.ts
...

在src目录下创建路由文件夹和文件:

mkdir src/router && touch src/router/index.ts

index.ts

const Home = React.lazy(() => import('@/pages/Home/Home'));
const Novel = React.lazy(() => import('@/pages/Novel/Novel'));
const Author = React.lazy(() => import('@/pages/Author/Author'));

const routes = [
  {
    path: '/',
    exact: true,
    component: Home,
  },
  {
    path: '/novels',
    exact: true,
    component: Novel,
  },
  {
    path: '/authors',
    exact: true,
    component: Author,
  }
];

export default routes;

这里使用了React的Code Splitting,React.lazy能使import()动态引入的内容作为常规组件来渲染。React.lazy需要配合React的Suspense来使用。如果使用import Author from '@/pages/Author/Author';引入模块,那么引入的模块会被打包到app.hash.js文件中,如果打包到app.hash.js中的模块过多,打开页面的时候就需要花较长的时间来等待app.hash.js文件的请求以及js代码的执行。使用import()动态引入模块的话,引入的模块会单独打包为一个文件,只有在需要的时候才会去请求和执行该文件。

exact: true,,配置精准匹配。

App.tsx

import Header from '@/components/Header/Header';
import routes from '@/router/index';

import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link
} from "react-router-dom";

export default function Home(): JSX.Element {
  return (
    <div>
      <Header userName="任沫"/>
      <Router>
        <nav>
          <Link to="/">首页</Link>
          <span> | </span>
          <Link to="/novels">小说</Link>
          <span> | </span>
          <Link to="/authors">作者</Link>
        </nav>
        <Switch>
          {routes.map((route, index) => (
            <RouteWithSubRoutes key={index} {...route} />
          ))}
        </Switch>
      </Router>
    </div>
  );
}
interface RouteType {
  path: string;
  component: Function;
  routes?: object;
}

function RouteWithSubRoutes(route: RouteType) {
  return (
    <Route
      path={route.path}
      render={props => {
        return (
          <React.Suspense fallback={<div>加载中...</div>}>
            <route.component {...props} routes={route.routes}/>
          </React.Suspense>
        );
      }}
    />
  );
}

只有点击<Link to="/novels">小说</Link>跳转到/novels路径下,才会去请求Novel模块的代码。React.Suspense会等待动态引入的模块加载完成,fallback中的内容就是等待过程中展示的内容。

执行npm start查看页面。

使用Redux

参考之前入门Redux的时候写的学习在React项目中使用Redux在项目中添加Redux。

安装reduxreact-redux以及react-redux的类型定义。redux是JavaScript的可预测状态容器,react-redux用于在React中使用Redux。

npm i --save redux react-redux
npm i --save-dev @types/react-redux

1.设置全局变量ReduxConnect。

全局变量ReduxConnect引用react-redux中的connect模块,这样就就不用在每个容器组件中都引用一遍connect了。webpack.common.js

new webpack.ProvidePlugin({
  React: 'react',
  ReduxConnect: ['react-redux', 'connect'],
}),

TypeScript(typings/redux.d.ts)中也需要定义ReduxConnect变量的类型:

import { connect } from 'react-redux';
declare global {
  const ReduxConnect: typeof connect;
}

定义好之后,在组件中可以直接使用ReduxConnect。

2.安装开发工具

安装浏览器插件Redux devTools之后,浏览器的开发者工具中会有Redux选项,在该选项下能看到state和action等,方便开发。要使用浏览器插件Redux devTools,需要在createStore的时候加上一行代码:

export default createStore(
  rootReducer,
  // @ts-ignore
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);

加上// @ts-ignore的目的是忽略下一行代码的TypeScript检查。

3.创建redux相关的文件

有改变的文件目录如下:

├── src
│   ├── components
│   │   └── Header
│   │       └── Header.tsx
    ...
│   ├── index.tsx
│   ├── pages
│   │   ├── Home
│   │   │   └── Home.tsx
        ...
│   ├── redux
│   │   ├── actionTypes
│   │   │   ├── novel.ts
│   │   │   └── user.ts
│   │   ├── actions
│   │   │   ├── novel.ts
│   │   │   └── user.ts
│   │   ├── reducers
│   │   │   ├── index.ts
│   │   │   ├── novel.ts
│   │   │   └── user.ts
│   │   └── store.ts
    ...
└── typings
		...
    └── redux.d.ts

Header.tsx

interface HeaderProps {
  userName: string;
  wordsNumber: number;
}
interface NovelState {
  wordsNumber: number;
}
function Header (props: HeaderProps): JSX.Element {
  const { userName, wordsNumber } = props;
  return (
    <div>
      <p>
        {userName}
        <span style={{ display: 'inline-block', paddingLeft: '20px' }}>字数统计:{wordsNumber}</span>
      </p>
    </div>
  );
}
const mapStateToProps = (state): NovelState => {
  const { novel } = state;
  const { wordsNumber } = novel;
  return { wordsNumber };
}

export default ReduxConnect(
  mapStateToProps,
)(Header);

Header组件中的wordsNumber是从Redux的state中取的。

Home.tsx

import { addNovelWord, subtractNovelWord } from '@/redux/actions/novel';

interface HomeProps {
  addWordsNumber: Function;
  substructWordsNumber: Function;
}

function Home(props: HomeProps) {
  const { addWordsNumber, substructWordsNumber } = props;
  const handleAddWordsNumber = (): void => {
    addWordsNumber(1);
  };
  const handleSubstructWordsNumber = (): void => {
    substructWordsNumber(2);
  };
  return (
    <div>
      <h1>使用Webpack等搭建一个适用于React项目的脚手架</h1>
      <div onClick={handleAddWordsNumber}>增加1个字</div>
      <div onClick={handleSubstructWordsNumber}>减少2个字</div>
    </div>
  );
}
const mapDispatchToProps = (dispatch: Function ): object => ({
  addWordsNumber: (number): void => dispatch(addNovelWord(number)),
  substructWordsNumber: (number): void => dispatch(subtractNovelWord(number)),
})

export default ReduxConnect(
  null,
  mapDispatchToProps,
)(Home);

在Home组件中调用dispatch方法,更新Redux的state。

4.使用HTTP请求。

安装redux-thunkaxios

npm i redux-thunk axios

redux-thunk是用于增强createStore的中间件,有了它就能发起异步action了。axios是基于Promise的一个用于发起HTTP请求的插件。

在Webpack(config/webpack.common.js)中设置全局变量:

new webpack.ProvidePlugin({
  ...
  Axios: 'axios',
}),

TypeScript(typings/axios.d.ts)

import axios from 'axios';

declare global {
  const Axios: typeof axios;
}

相同的方法设置了全局变量UseEffect(引用react中的useEffect)。

在使用中间件之后,要使用浏览器插件Redux DevTools需要修改store.ts文件:

import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

export default createStore(
  rootReducer,
  composeEnhancers(applyMiddleware(thunk))
);

由于同源策略,在本地服务器上不能请求远程的接口,所以需要使用Webpack(webpack.dev.js)的devServer中的proxy配置代理:

  devServer: {
    open: true,
    hot: true,
    historyApiFallback: true,
    proxy: {
      '/api': {
        target: 'http://www.a-fake-url.com',
        changeOrigin: true,
      },
    },
  },

上文贴出代码的target是随便写的一个路径(练习的时候直接使用了真实的接口)。

添加请求用户信息的代码:

actions/user.ts

import { GET_USER_INFO } from '@/redux/actionTypes/user';
import api from '@/request/api';

const getUserInfo = (payload: object): object => ({
  type: GET_USER_INFO,
  payload,
});

export function fetchUserInfo (): Function {
  return async (dispatch: Function) => {
    const { data } = await Axios.get(api.getUserInfo);
    dispatch(getUserInfo(data.data));
  };
}

Header.tsx

import { fetchUserInfo } from '@/redux/actions/user';

interface HeaderProps {
  userName: string;
  wordsNumber: number;
  fetchUserInfo?: Function;
}

interface StateProps  {
  userName: string;
  wordsNumber: number;
}

function Header (props: HeaderProps): JSX.Element {
  const { userName, wordsNumber, fetchUserInfo } = props;
  UseEffect(() => {
    fetchUserInfo();
  }, []);
  return (
    <div>
      <p>
        {userName}
        <span style={{ display: 'inline-block', paddingLeft: '20px' }}>字数统计:{wordsNumber}</span>
      </p>
    </div>
  );
}
const mapStateToProps = (state): StateProps => {
  const { novel, user } = state;
  const { wordsNumber } = novel;
  const { userInfo: { userName } } = user;
  return { wordsNumber, userName };
}
const mapDispatchToProps = (dispatch: Function): object => ({
  fetchUserInfo: (): void => dispatch(fetchUserInfo())
});

export default ReduxConnect(
  mapStateToProps,
  mapDispatchToProps,
)(Header);

使用Sass

在Webpack中使用sass-loader解析sass/scss文件。

首先安装依赖:

npm i --save-dev css-loader sass-loader node-sass
npm i --save-dev postcss postcss-loader autoprefixer
npm i --save-dev mini-css-extract-plugin

sass-loader需要安装node-sass,sass-loader将sass/scss文件解析为css。css-loader解析css文件,使得我们能用require()或者import引入css文件。

postcss-loader使用postcss对css进行添加属性前缀等处理。autoprefixer是在postcss中使用的插件,它解析CSS并根据Can I Use中值给CSS添加供应商前缀,比如-webkit-之类的。使用postcss需要在根目录创建文件postcss.config.js,文件内容如下:

module.exports = {
  plugins: [
    require('autoprefixer')
  ]
}

MiniCssExtractPlugin将每个js中引入的css提取为单独的一个css文件。默认情况下MiniCssExtractPlugin使用CommonJs模块语法生成JS模块,配置esModule: true支持ES模块语法。hmr:true时表示支持CSS文件的模块热重载。

Webpack(config/webpack.common.js)配置:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

...

module.exports = {
  plugins: [
    ...
    new MiniCssExtractPlugin({
      filename: devMode ? '[name].css' : '[name].[hash].css',
    }),
  ],
  module: {
    rules: [
      ...
      {
        test: /\.s[ac]ss$/i,
        exclude: /[\\/]node_modules[\\/]/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              esModule: true,
              hmr: devMode,
            },
          },
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },
    ],
  },
  ...
}

loader的执行顺序也是从后到前的,也就是会按照sass-loader -> postss-loader -> css-loader -> MiniCssExtractPlugin.loader的顺序执行。

之前App.tsx中的部分代码是这样的:

<nav>
  <Link to="/">首页</Link>
  <span> | </span>
  <Link to="/novels">小说</Link>
  <span> | </span>
  <Link to="/authors">作者</Link>
</nav>

创建app.sass文件并在App.tsx中引入:

app.sass:

.nav-item:not(:last-child):after {
  content: '|';
  padding: 0 20px;
}

App.tsx:

...
import './app.scss';

export default function Home(): JSX.Element {
  return (
    <div>
    		...
        <nav>
          <Link to="/" className="nav-item">首页</Link>
          <Link to="/novels" className="nav-item">小说</Link>
          <Link to="/authors" className="nav-item">作者</Link>
        </nav>
        ...
    </div>
  );
}

执行npm start在浏览器中查看效果。

引入图片

安装依赖:

npm i --save-dev file-loader

file-loader将import、require、url()等引入的文件解析为一个路径,然后再将该路径下的文件放到output目录下,不光是图片文件,像字体文件等也可以使用file-loader来处理。

Webpack(config/webpack.common.js)中配置如下:

module.exports = {
  ...
  module: {
    rules: [
      ...
      {
        test: /\.(png|jpe?g|gif)$/,
        exclude: /[\\/]node_modules[\\/]/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'assets/[name].[sha512:hash:base64:7].[ext]',
            },
          }
        ],
      },
    ],
  },
  ...
}

可以在options中设置publicPath,没有设置的时候是output中的publicPath的值。

设置输出文件的name中assets/指的是将文件放到assets目录下,sha512:hash:base64:7指的是对文件内容进行hash处理, sha512 是hash的类型,base64 是摘要的类型, 7 是摘要的长度,得到的就是7个字符的经过hash处理后的文件名,ext是文件后缀名。

在src目录下新建一个assets文件夹用于存放图片。在Home.tsx中引入图片:

...
import personImage from '@/assets/images/hetian.png';
import './home.scss';

function Home(props: HomeProps) {
	...
  return (
    <div>
      <h1>使用Webpack等搭建一个适用于React项目的脚手架</h1>
      <div>
        <div onClick={handleAddWordsNumber} className="common-button">增加1个字</div>
        <div onClick={handleSubstructWordsNumber}  className="common-button">减少2个字</div>
      </div>
      <img src={personImage} />
    </div>
  );
}
...

在home.scss中定义了按钮的样式:

.common-button {
  display: inline-block;
  padding: 10px;
  margin: 10px;
  border-radius: 5px;
  background-image: url('~@/assets/images/hetian.png');
  background-position: center center;
  cursor: pointer;
  user-select: none;
  color: #fff;
  text-shadow: 1px 1px 2px black;
  &:first-child {
    margin-left: 0;
  }
}

根据sass-loader处理import的方式,如果要使用Webpack中定义的alias,就要在路径前面加一个~

在引入图片的时候报错找不到模块:Cannot find module '../../assets/images/test.png'.ts(2307)

按照这个解决方法,增加一个files.d.ts定义模块。

declare module "*.png" {
  const value: any;
  export default value;
}
declare module "*.jpg" {
  const value: any;
  export default value;
}
declare module "*.jpeg" {
  const value: any;
  export default value;
}

declare module "*.gif" {
  const value: any;
  export default value;
}

下一篇:《使用Webpack等搭建一个适用于React项目的脚手架(3 - Eslint、Jest)》