目标
了解npm机制,掌握常用命令
了解webpack打包的基本含义
掌握组件的生命周期
掌握React路由
React进阶
npm
npm(Node package manager)是随Node.js发布的包管理工具,最初主要是用来管理Node.js依赖包,如今已经扩展到整个JavaScript生态。
在没有 npm 之前,如果我们想要在自己的JS项目中引用第三方的框架或库,比如 React、Bootstrap 等等,需要把代码一个一个下载回来添加到自己的工程中,随着项目的扩大,依赖会变得越来越多,而第三方库可能也会有自己的依赖,一旦有版本升级,以来的管理很容易失控。
npm 的工作方式大致如下:
架设一个中心化的代码仓库服务器(registry),用来存放共享的代码,官方的npm网站为 https://www.npmjs.com/,在国内我们通常会使用阿里的 npm 镜像,下载速度会更快,切换方式如下
npm config set registry https://registry.npm.taobao.org
开源软件的作者将自己的代码封装成npm包(package),并且确定一个在registry中唯一的名字,如 react,然后将代码publish到registry
其他开发者想要使用 react 这个包,在自己的项目中运行 npm i react ,npm就会自动帮他们下载代码。
下载完成的代码会出现在项目根目录的 node_modules 目录
包也可以依赖npm上面其他的包,npm在安装的时候会自动解析、安装这些依赖
package.json
https://docs.npmjs.com/creating-a-package-json-file
package.json 是一个包或者工程的描述文件,里面记录了项目的名字、版本、依赖等信息,代表当前目录是一个独立的npm工程,一个基本的 package.json 内容大致如下:
{ "name": "react-template", "version": "1.0.0", "main": "index.js", "license": "MIT", "dependencies": { "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { "@types/react": "^17.0.5", "@types/react-dom": "^17.0.3" } }
一些基本的字段意义如下:
name::包名,必填字段,不可以和依赖中的包名重复,如果将来要发布到npm仓库,需要保持在npm仓库中唯一
version:版本号,必填字段,遵循 semantic-versioning 规则
main:包入口代码文件,其他代码来引用此模块的时候,会自动引入此文件
license:开源协议
dependencies:生产环境依赖的包列表,通常是运行时依赖的库,会被安装在 node_modules 目录
devDependencies:开发环境依赖的包列表,通常是辅助开发构建用到的一些工具,比如 webpack,也会安装到 node_modules 目录
创建package.json
当我们想要初始化一个项目的时候,我们可以通过下面的步骤创建一个 package.json 文件来管理整个工程依赖
创建一个新的工程目录,如 react-template
进入该目录,执行命令 npm init ,按照提示一步一步填写信息
执行完毕之后,该目录会生成对应内容的 package.json,然后我们就可以在这个目录安装依赖了
npm scripts
在 package.json 里面有一个 scripts 属性,它可以添加一些自定义的命令,让我们非常方便地去执行一些构建程序,如
{ "dev": "webpack --config scripts/webpack.config.js", "start": "webpack serve --config scripts/webpack.config.js --open", "build": "webpack --config scripts/webpack.prod.js" }
对于其中定义的任务,我们可以直接通过 npm run <name> 来执行,如
npm run build
实际执行的就是
webpack --config scripts/webpack.prod.js
这里 webpack 是作为一个项目依赖安装到 node_modules 目录的,因为 webpack 提供了命令行的支持,我们在 npm scripts 中调用它时就可以正常找到它,而不需要专门安装成一个全局命令。
有一些名字是 npm 默认支持的,比如 start,我们可以直接使用 npm start 来调用。
安装依赖
如果我们要安装一个生产环境依赖,如 React, 可以在项目根目录(package.json所在目录)执行命令
npm i react
安装完之后,依赖关系会自动记录在 package.json 文件的 dependencies 中,如果我们要安装一个开发环境依赖,如 webpack,可以添加一个 --save-dev 参数,如
npm i webpack --save-dev
安装完之后,依赖关系会自动记录在 package.json 文件的 devDependencies 中,这两种方式安装的依赖,代码都会被下载到项目根目录的 node_modules 目录,除此之外还会生成一个名为 package-lock.json,这里面记录了整个已安装的依赖关系和下载地址,可以帮助我们锁定依赖版本。node_modules 通常比较庞大,通常来说当我们发布项目或者将项目代码提交到Git仓库时,需要将 node_modules 目录添加到忽略列表,只提交 package.json、package-lock.json 即可,其他人拿到代码之后只需要在项目根目录执行命令
npm i
npm就会根据 package.json、package-lock.json 中的记录自动帮你安装所需要的依赖模块
需要注意,在执行npm安装模块的命令时,一定要保证该目录存在正确的 package.json 文件,否则npm会一层一层往上递归寻找 package.json,直到分区根目录
全局安装
有些npm模块提供了命令行调用的支持,我们可以用 -g 来将它安装成一个全局命令,比如之前我们安装过的 typescript
npm i typescript -g
通过这种方式安装的模块安装为一个命令,比如 typescript 定义的命令为 tsc,我们就可以全局使用这个命令来编译TS代码了,需要注意的是,通过 -g 安装的模块并不会存在于本地项目的 node_modules 中,而是由npm全局管理,也不能在代码中直接引用。
删除依赖
如果我们想要删除一个已安装的依赖,如 react,只需要在 package.json 所在的目录执行命令
npm remove react
如果要删除一个全局安装的模块,需要再加上 -g 的参数,如
npm remove typescript -g
types
在我们使用TypeScript的过程中避免不了需要引用第三方的库,例如react,有些库自身就使用TS开发,发布到npm的时候也携带了对应的类型定义文件,对于这种库TS可以直接识别它的类型,但是大部分的库还是采用传统的JS开发,TS无法直接识别它的类型,所以TS采取了一种机制,可以为传统的JS模块编写类型定义文件,来描述他们的接口类型,然后通过npm仓库 @types/<name> 来发布,比如react是标准JS开发的,社区为它编写了TS类型定义模块,可以让TS识别react中的接口和数据类型,然后通过 @types/react 来发布,对应的地址为 https://www.npmjs.com/package/@types/react,我们前面的课程示例中也有用到它,可以用下面的方式来安装
npm i @types/react --save-dev
在以后的开发中,如果你要在TS中使用某一个第三方的npm模块,需要先看一下它自身有没有携带类型定义文件,如果没有的话,再去 @types 下面看一下有没有社区提供的版本,目前主流的常用库基本都覆盖了。
webpack
上面我们学习了如何通过npm来安装、管理依赖,现在我们来看一下如何使用这些依赖。
npm最早是针对Node.js依赖管理设计的,后来随着 webpack 之类的打包工具的出现,让前端也可以利用npm生态来做模块依赖管理了。
在原生Modules出现之前,前端只能通过一些模拟的方式来实现模块化,webpack 最核心的功能是可以将依赖的模块代码进行转换,然后合并在一起,这样开发效率、加载效率都会有明显提升,例如我们可以用下面的方式来引用 react
import * as React from 'react'
有了npm之后,我们import导入的代码就会分为两种情况,一种是npm安装的模块,另外一种就是我们自己本地编写的代码,这两种的引用方式有所不同,对于npm安装的模块,我们引用的时候 from 后面直接写模块名,就像上面的引用 react 的方式
import * as React from 'react'import * as ReactDOM from 'react-dom'
对于本地模块代码,就是我们之前熟悉的方式了,通过相对路径的方式来引用,通过 webpack 的配置,我们可以省略掉 .js 或者 .ts 的后缀名,如
import Button from './components/Button'import Modal from './components/Modal'
除了JS代码之外,webpack通过加载器(loader)机制还可以用来处理其他类型的资源,例如图片、CSS、字体等等,还可以通过插件(plugin)机制来自动生成HTML模板、合并输出CSS文件等,我们通过demo的代码来看一下简单的webpack配置,webpack是基于Node.js开发的,它的配置文件就是一个Node.js脚本
const path = require('path')const HtmlWebpackPlugin = require('html-webpack-plugin')const MiniCssExtractPlugin = require('mini-css-extract-plugin')const { CleanWebpackPlugin } = require('clean-webpack-plugin')module.exports = { entry: { // 入口模块 index: './src/index.tsx' }, output: { // 输出路径 path: path.resolve('./dist'), // 输出的文件名格式,这里采用名字+内容hash片段的方式,用于清理缓存 filename: 'scripts/[name].[contenthash:6].js', // 添加公共路径,主要用于history路由模式下 publicPath: '/' }, resolve: { // 支持的脚本后缀名,可以让我们在导入的时候省略掉 extensions: ['.ts', '.tsx', '.js'] }, // 模式,development开发环境,production生产环境,会压缩代码 mode: 'development', // 生成sourcemap devtool: 'source-map', module: { // 加载器规则 rules: [ { // 使用ts-loader处理ts|tsx后缀的代码 test: /\.(ts|tsx)$/, exclude: /node_modules/, loader: 'ts-loader' }, { // 使用MiniCssExtractPlugin.loader、css-loader处理css后缀的代码 test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, { // 使用file-loader处理jpg|png|svg|ico格式的文件 test: /\.(jpg|png|svg|ico)$/, loader: 'file-loader', options: { // 输出的文件名格式 name: 'imgs/[name].[contenthash:6].[ext]' } } ] }, plugins: [ // 清理插件,可以在每次编译之前清空dist目录 new CleanWebpackPlugin(), // 可以将引入的样式合并输出到css文件 new MiniCssExtractPlugin({ filename: 'styles/[name].[contenthash:6].css' }), // 根据模板生成html文件 new HtmlWebpackPlugin({ filename: 'index.html', template: './src/index.html' }) ]}
loader
https://webpack.js.org/loaders/
在我们的代码中会引用很多不同类型的资源,例如TS、图片、CSS,我们可以像引用JS模块一样来引用他们,例如
import './style.css'import logo from '../imgs/logo.png'import Button from '../components/Button.tsx'
但是很明显,这几种都不是合法的JS模块,无法被正常执行,这时候就需要webpack的各种loader来帮我们处理这些类型的文件,以上面的代码为例,我们希望import进来的css最终输出成css文件,并且自动插入到html页面中,这里我们用到了两个loader,分别是 MiniCssExtractPlugin.loader 和 css-loader,webpack会从右往左来调用指定的loader,其中 css-loader 可以获得css路径、内容等信息,MiniCssExtractPlugin.loader 可以用来将css内容导出到文件。
对于图片类型如png,我们使用了 file-loader,webpack在打包代码的时候如果遇到导入png,就会把这个任务交给 file-loader,此时它会按照我们配置的格式,将文件输出到指定位置,并返回新的路径名,上面我们得到的 logo 就是输出之后该图片的路径,这样我们就可以在JSX中插值引用该图片了。
对于TS代码,我们使用把它交给了 ts-loader 来处理,它会帮我们自动完成TS到JS的编译。
经过上面几个loader对不同资源的处理,我们得到了可以在浏览器中正常执行的代码。loader也是一个npm模块,需要通过npm来安装
plugin
https://webpack.js.org/plugins/
loader用来处理不同类型的资源,而plugin可以用来扩展webpack的功能,webpack内置了一些插件,也有很多社区提供的,上面的例子中我们使用了三个插件
CleanWebpackPlugin:这个插件可以帮助我们清理输出目录,避免混入旧版本的文件
MiniCssExtractPlugin:这个插件让我们把导入的css代码合并输出到css文件
HtmlWebpackPlugin:这个插件可以根据模板来生成我们的入口html文件,而且会自动把输出的JS、CSS文件引入进来
webpack-dev-server
webpack-dev-server 是一个小型嵌入式的Web服务器,它可以和webpack配合使用,能够支持静态文件、API代理、自动刷新等功能,通过它我们可以在开发环境给每一个前端工程启动一个独立的http server,方便我们调试,而部署到生产环境的时候再使用Nginx。webpack-dev-server 的配置格式大致如下,可以直接添加到webpack的配置文件:
devServer: { // 静态文件目录 contentBase: path.resolve('./dist'), // 自动刷新 hot: true, // 监听端口 port: 3360, // 支持history路由模式 historyApiFallback: true, // API代理 proxy: { '/data': { target: 'https://data.example.com', changeOrigin: true } }}
添加完配置之后,我们可以通过 npm scripts 来定义一个启动服务的命令,如
"scripts": { "start": "webpack serve --config scripts/webpack.config.js --open"}
然后我们执行命令 npm start 就可以启动http服务了,也会自动打开默认浏览器进行访问,当我们修改了代码引起输出文件的变化时,会通知浏览器自动刷新。
webpack-dev-server 默认会将webpack输出的文件写入到内存中,而不是磁盘中,所以在启动它的时候,dist目录是没有输出文件的。
react有一个著名的脚手架工具 create-react-app,有兴趣的同学以后可以自己研究一下,不在本课程的讨论范围。
组件的生命周期
https://react.docschina.org/docs/react-component.html
一个React组件从初始化到被删除,会经历一个完整的生命周期,我们可以为类组件添加一些特殊方法,在组件生命周期的的一些关键节点,React会来执行这些方法。
componentDidMount()
componentDidMount() 会在组件挂载之后(插入DOM树中)立即调用,依赖于DOM节点的初始化操作应该放在这里,通常我们会在这里通过网络获取组件所需的数据、订阅一些事件通知等等
componentDidUpdate()
componentDidUpdate() 会在更新后被立即调用,首次渲染不会执行此方法,可以让我们监控组件传入数据发生的变化。
componentWillUnmount()
componentWillUnmount() 会在组件卸载及销毁之前直接调用,在此方法中执行必要的清理操作,例如:清除定时器,取消网络请求或者之前在 componentDidMount() 中创建的事件订阅等。在此方法中不应该调用 setState(),因为该组件将永远不会重新渲染,组件实例卸载后,将永远不会再挂载它。
路由
https://reactrouter.com/web/guides/quick-start
目前我们所接触到的react例子都是单页面的,只有一个页面组件,在实际项目中通常都会有多个页面存在,可以通过链接来进行切换,达到多页面的效果。要实现这个功能,我们需要借助于React的路由模块 react-router-dom 来实现,它可以让我们监听浏览器地址的变化,并且解析这个URL对象,然后router根据URL的路径匹配到路由对应页面组件,最后正确地渲染对应地组件,这个就是路由工作的基本原理。
我们平时会使用的主要是两种路由模式:
HashRouter:这种模式基于浏览器location的hash片段来实现,实现比较简单,不需要服务器的支持,缺点是url样式不够优雅,而且hash参数容易丢失,如下:
http://example.com/#/home/files
BrowserRouter:这种模式基于浏览器的history API,可以让我们创建一个像 http://example.com/home/files 这样真实的URL,而且切换url不会引起页面的刷新,用户体验比较好,是我们比较推荐的路由方式,不过这种模式需要服务器比如Nginx的支持,因为路径 /home/files 只是一个前端定义的路由,当用户刷新页面的时候浏览器会去向服务器请求这个资源,服务器因为没有对应的这个资源,就会返回404,导致页面无法显示,所以需要Nginx将所有404的请求返回入口文件 /index.html,大概配置如下
location / { root d:/www; try_files $uri $uri/ /index.html;}
路由的定义
我们可以把React组件分为路由组(页面)件和普通组件,路由组件对应的就是一个路由页面,我们可以用下面的方式来定义一个路由组件 About
import * as React from 'react'import { RouteComponentProps, Link } from 'react-router-dom'export default class About extends React.Component<RouteComponentProps> { render() { return ( <h1>About</h1> ) }}
这里我们要给组件添加一个props的泛型 RouteComponentProps,添加之后TS就可以识别到传入的和路由控制相关的props参数,具体参考示例代码
然后我们需要定义一个根组件 App,用来确定路由和组件的映射关系,如下
import * as React from 'react'import { BrowserRouter, Route, Switch } from 'react-router-dom'import Main from '../Main'import About from '../About'export default class App extends React.Component { render() { return ( <BrowserRouter> <div className="container"> <Switch> <Route path="/" exact component={Main} /> <Route path="/about" component={About} /> </Switch> </div> </BrowserRouter> ) }}
在上面的代码中我们定义了两个路由规则,放在了 Switch 中,路由规则的定义通过 Route 组件来进行,它有两个关键的参数,分别是:
path:要匹配的URL路径
component:该路由对应的页面组件
需要注意的是,path的路径默认会匹配到子路径,例如 /detail 也会匹配到 /detail/123,如果不像匹配子路径,需要加上 exact 属性。
Switch 会根据当前的URL路径匹配它下面的 Route,然后将匹配到的第一个组件渲染到当前位置。
带参数的路由
有时候我们的页面会接收一些参数,比如根据传入的关键词去进行搜索,react-router-dom 支持路由参数,我们可以用下面的方式来定义
<Route path="/search/:keyword" component={Search} />
然后我们可以在对应的路由组件 Search 中来接收这些参数,通过下面的方式来进行
首先我们需要定义合适的 Props 类型,RouteComponentProps 也支持传入参数泛型,如下
interface Params { keyword: string}type Props = RouteComponentProps<Params>export default class Search extends React.Component<Props> { render() { return ( <div>{this.props.match.params.keyword}</div> ) }}
我们首先定义了当前路由组件接收的参数类型 Params,跟我们在 Route 中定义的参数一致,然后我们可以在组件内通过 this.props.match.params 来访问到我们的路由参数对象,它的类型就是 Params
路由导航
我们可以使用两种方式来在页面中切换路由,react-router-dom 提供了一个 Link 组件,我们可以用它来替代传统的a标签,因为我们使用的 history 模式的 BrowserRouter,它其实是利用浏览器的 history.pushState 来实现的,它可以在浏览器添加历史状态,修改当前的URL而不会引起页面的刷新,Link 可以帮我们拦截用户的点击,然后完成 pushState 相关的操作,这样就可以在切换路由的同时保持页面的状态,而不是刷新页面。它的基本用法如下:
import { Link } from 'react-router-dom'export default class Main extends React.Component { render() { return <Link to="/about">About</Link> }}
参数 to 就是要导航到的路由,需要注意,Link标签只用来切换应用内的路由,如果你想要跳转到外部链接,例如 MDN,那你应该使用传统的a标签,如
<a href="https://developer.mozilla.org/zh-CN/">MDN</a>
还有一种方式是通过API来进行导航,对于路由组件,react-router-dom 会通过 props 传入一些数据和API,我们可以通过调用下面的API来进行路由导航
this.props.history.push('/about')
需要注意的是,this.props.history 只在路由组件内部才可以使用,如果你想要在普通组件内部也能访问路由信息,需要使用 withRouter 来包裹一下,例如
import * as React from 'react'import { Link, withRouter, RouteComponentProps } from 'react-router-dom'class Nav extends React.Component<RouteComponentProps> { componentDidMount() { console.log(this.props.history) } render() { return ( <nav className="nav"> <Link to="/" className="nav-item"> Main </Link> <Link to="/about" className="nav-item"> About </Link> </nav> ) }}export default withRouter(Nav)
这样我们就可以访问路由信息了
NavLink
react-router-dom 还提供了一个特殊的Link组件叫 NavLink,它可以让我们来创建一些导航元素,当前的路由如果和它的链接匹配的话,可以自动帮我们添加选中的状态,例如上面的 Nav 组件我们可以用 NavLink 来替换一下
import * as React from 'react'import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'class Nav extends React.Component<RouteComponentProps> { render() { return ( <nav className="nav"> <NavLink to="/" className="nav-item" exact activeClassName="nav-active"> Main </NavLink> <NavLink to="/about" className="nav-item" activeClassName="nav-active"> About </NavLink> </nav> ) }}export default withRouter(Nav)
当路由和 NavLink 中的 to 匹配时,会自动添加一个 nav-active 类名