在《使用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。
安装redux、react-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-thunk和axios:
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;
}