前言
去年写了一款 Web 音乐 App, 并发表了系列文章, 介绍了开发的过程, 当时使用 create-react-App 官方脚手架搭建的项目, react-scripts 是 1.x 版本, 而 react 版本是 16.2.0, 去年 10 月份 create-react-App 已经发布了 2.0 版本, react 在去年 12 月份升级到了 16.7.0
前端领域的技术迭代更新实在是太快了, 经常有人吐槽求不要更新, 我学不动了, 我学不完了
做前端就要做好随时学习的准备, 不然就会被淘汰啦⊙﹏⊙∥∣°
只要是做开发的都要保持一颗积极学习的心, 不管是前端领域还是后端领域, 不过前端学习新技术的间隔时间要比后端长. 作为 Java 出身的我深有体会 o(╯□╰)o
更新介绍
create-react-App
时至今日, create-react-App 更新到了 2.x 的版本了, 主要是升级了它所依赖的许多工具, 这些工具已经发布了包含新特性和性能改进的新版本, 比如 babel7,webpack4,babel7 和 webpack4 具体更新了哪些, 优化了哪些大家可以去查阅资料. 以下列出来 create-react-App 更新了的几个要点
新增 Sass 预处理器, CSS 模块化支持
更新到 Babel7
更新到 webpack4
新增 https://preset-env.cssdb.org/features#stage-3
更多更新内容请戳这里
react16.3
因为之前使用的是 react16.2, 说到 react16.7 得从 16.3 说起
16.3 新增了几个新的生命周期函数, context API https://reactjs.org/docs/context.html ,createRef API https://reactjs.org/docs/refs-and-the-dom.html 和 forwardRef API https://reactjs.org/docs/forwarding-refs.html , 新增的两个生命周期函数 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 主要是替代之前的 componentWillMount, componentWillReceiveProps 和 componentWillUpdate, 目的是为了支持 error boundaries 和即将到来的 async rendering mode(异步渲染). 当使用 async rendering mode 时, 会中断初始化渲染, 错误处理的中断行为可能导致内存泄漏, 而使用 componentWillMount, componentWillReceiveProps 和 componentWillUpdate 会加大这类问题产生的几率
在之前的版本, 获取 dom 或组件时, 有两种方法, 一种是给一个 ref, 指定一个 name, 再用 refs.name 或 ReactDOM.findDOMNode(name) 获取, 另一种就是使用 ref 回调, 给 ref 一个回调函数. 在开始的时候我用的是第一种, 后面改用了 ref 回调, 现在官方不推荐使用了, 推荐使用 ref 回调的方式, 因为第一种有几个缺点 https://github.com/facebook/react/issues/1373 , 使用 ref 回调有些麻烦, 所以官方提供了新的操作就是 createRef API https://reactjs.org/docs/refs-and-the-dom.html
当使用函数组件时如何获取 dom,forwardRef API https://reactjs.org/docs/forwarding-refs.html 允许你使用函数组件并传递 ref 给子组件, 这样就能方便的获取子组件中的 dom
更多内容请戳这里
react16.6
这个版本的更新我还是很喜欢的, 官方终于和 vue 一样支持 Code Splitting 了
在 React 中使用 Code Splitting, 麻烦点自己写一个懒加载组件, 简单点使用第三方库. 现在官方新增 React.lazy 和 Suspense 用来支持 Code Splitting
- import React, {lazy, Suspense} from 'react';
- const LazyComponent = lazy(() => import('./LazyComponent'));
- function MyComponent() {
- return (
- <Suspense fallback={<div>Loading...</div>}>
- <LazyComponent />
- </Suspense>
- );
- }
注意: React.lazy and Suspense 目前不支持服务端渲染, 服务端渲染官方推荐使用 Loadable Components https://github.com/smooth-code/loadable-components
类组件中有个生命周期函数 shouldComponentUpdate 用来告诉组件是否进行 render, 继承 React.component, 可以自己重新这个方法来判断决定该怎样进行 render, 继承 React.PureComponent, 默认已经实现了 shouldComponentUpdate, 它会把 props 和 state 进行浅比较, 不相等才进行 render, 不能自己重写 shouldComponentUpdate. 对于函数组件, 它没有这样的功能, 在这个版本中新增了 React.memo, 使函数组件具有和 React.PureComponent 一样的功能
16.3 中新增了 context API https://reactjs.org/docs/context.html , 当使用 context 时你需要使用 Consumer 像下面这样
- const ThemeContext = React.createContext('light');
- ...
- class MyComponent extends React.Component {
- render() {
- return (
- <ThemeContext.Consumer>
- {theme => /* 使用 context */}
- </ThemeContext.Consumer>
- );
- }
- }
现在可以使用更方便的 static contextType
- const ThemeContext = React.createContext('light');
- ...
- class MyComponent extends React.Component {
- render() {
- let value = this.context;
- /* 使用 context */
- }
- }
- MyComponent.contextType = ThemeContext;
更多内容请戳这里
升级
此次升级基于此源码
在开始之前, 先把组件目录做一下调整, 使用约定俗成的目录名称来存放对应的组件, 新建 views 目录, 把 components 目录下的组件移到 views 目录下, 然后把 common 目录下的组件移到 components 目录
修改配置
现在开始升级, 将 react-scripts 升级到 2.1.3,react 升级到 16.7.0
NPM install --save --save-exact react-scripts@2.1.3
NPM install react@16.7.0 react-dom@16.7.0
稍等片刻
运行 NPM run start
发现报错了, 之前是基于 react-scripts1.x 的版本自定义了脚本, react-scripts2.x 中配置变化了很多, 导致原来自定义的脚本不能用了. 另外寻找修改配置的方法太费时间, 如果你熟悉 webpack 配置运行自带的 eject 将配置文件提取出来, 或者寻找第三方 https://github.com/arackaf/customize-cra , 这样的话就要多学习一下配置方法, 如果作者不维护了, react-scripts 发生大的更新, 也不能及时适配新的版本, 这里我选择暴力, 将配置文件提取出来
let's do it
运行 NPM run eject
scripts 目录已经在项目中存在了 (之前自定义配置写的脚本), 删了它, 再次运行, 稍等片刻, 执行完后在 package.JSON 中添加了很多依赖, 还有一些 postcss,babel 和 eslint 配置
wait
package.JSON 中 scripts 的脚本并未更新, 参考了其它 NPM run eject 后的 scripts, 然后将其修改如下
- "scripts": {
- "start": "npm run dev",
- "dev": "node scripts/start.js",
- "build": "node scripts/build.js"
- }
eject 后, 开发相关依赖都到 dependencies 中去了, 然后将开发相关依赖放到 devDependencies 并且去掉 jest 相关依赖
运行 NPM run dev
提示是否添加 browserslist 配置, 输入 Y 回车, 然后会出现如下报错, 页面样式错乱
Module not found: Can't resolve'@/API/config'
此时还没配置别名 @和 stylus
打开 config 目录下面的 webpack.config.JS, 找到配置 resolve 节点下的 alias, 增加别名
config/webpack.config.JS
- module.exports = function(webpackEnv) {
- ...
- return {
- ...
- resolve: {
- ...
- alias: {
- // Support React Native Web
- // https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
- 'react-native': 'react-native-web',
- '@': path.join(__dirname, '..', "src")
- },
- }
- }
- ...
- }
关于 alias, 使用 alias 可以减少 webpack 打包的时间, 但是对 ide 或工具不友好, 无法进行跳转, 查看代码时非常不方便. 如果你能忍受, 就配置, 不能忍受 import 时就写相对路径吧, 这里使用 alias 做演示, 最终的源码没有使用 alias
接着就是 stylus, 官方居然只支持 Sass, 可能是 Sass 使用的人多, 你好歹都多支持几个吧≡(▔﹏▔)≡
之前用原始的方式使用 CSS, 存在很严重的问题, 就是会出现 CSS 冲突的问题, 这类问题有很多解决方案如 styled-compoents,styled-jsx 和 CSS modules, 前面两个简直是另类, CSS modules https://github.com/css-modules/css-modules 没有颠覆原始的 CSS, 同时还支持 CSS 处理器, 不依赖框架, 不仅在 react 中还可以在 vue 中使用. 在 webpack 中启用 CSS modules 只需要给 CSS-loader 一个 modules 选项即可, 在项目中有时候 CSS 文件会用到 CSS modules 而有些并不需要, 对于这种需求, resct-scripts 是这么配的
config/webpack.config.JS
- ...
- // style files regexes
- const cssRegex = /\.CSS$/;
- const cssModuleRegex = /\.module\.CSS$/;
- const sassRegex = /\.(SCSS|Sass)$/;
- const sassModuleRegex = /\.module\.(SCSS|Sass)$/;
- // This is the production and development configuration.
- // It is focused on developer experience, fast rebuilds, and a minimal bundle.
- module.exports = function(webpackEnv) {
- ...
- return {
- ...
- module: {
- strictExportPresence: true,
- rules: [
- ...,
- {
- test: cssRegex,
- exclude: cssModuleRegex,
- use: getStyleLoaders({
- importLoaders: 1,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- }),
- sideEffects: true,
- },
- // Adds support for CSS Modules (https://github.com/css-modules/css-modules)
- // using the extension .module.CSS
- {
- test: cssModuleRegex,
- use: getStyleLoaders({
- importLoaders: 1,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- modules: true,
- getLocalIdent: getCSSModuleLocalIdent,
- }),
- },
- // Opt-in support for Sass (using .SCSS or .Sass extensions).
- // By default we support Sass Modules with the
- // extensions .module.SCSS or .module.Sass
- {
- test: sassRegex,
- exclude: sassModuleRegex,
- use: getStyleLoaders(
- {
- importLoaders: 2,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- },
- 'sass-loader'
- ),
- sideEffects: true,
- },
- // Adds support for CSS Modules, but using Sass
- // using the extension .module.SCSS or .module.Sass
- {
- test: sassModuleRegex,
- use: getStyleLoaders(
- {
- importLoaders: 2,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- modules: true,
- getLocalIdent: getCSSModuleLocalIdent,
- },
- 'sass-loader'
- ),
- },
- ...
- ]
- }
- }
- }
上述配置中, getStyleLoaders 是一个返回样式 loader 配置的函数, 根据传入的参数返回不同的配置, 在 rules 中, 以. CSS 或.(SCSS|Sass) 结尾就使用常规的 loader, 以. moduels.CSS 或. module.(SCSS|Sass) 结尾就启用 CSS moduels. 当需要使用 CSS modules 时, 就在文件名后面后缀前面加一个. module,react 中样式文件命名约定和组件文件名一致, 并且组件和样式放到同一个目录, 如果有一个名为 RecommendList.JS 文件, 那么样式文件命名为 recommend-list.module.CSS, 放到一起时, 就成了下面这样
怎么会有这么长的尾巴
如何去掉这个长尾巴而不影响使用 CSS modules, 我们使用 webpack 配置中的 Rule.oneOf 和 Rule.resourceQuery
在 webpack.config.JS 中增加 stylus 配置
config/webpack.config.JS
- ...
- // style files regexes
- const cssRegex = /\.CSS$/;
- const cssModuleRegex = /\.module\.CSS$/;
- const sassRegex = /\.(SCSS|Sass)$/;
- const sassModuleRegex = /\.module\.(SCSS|Sass)$/;
- const stylusRegex = /\.(styl|stylus)$/;
- // This is the production and development configuration.
- // It is focused on developer experience, fast rebuilds, and a minimal bundle.
- module.exports = function(webpackEnv) {
- ...
- return {
- ...
- module: {
- strictExportPresence: true,
- rules: [
- ...,
- // Adds support for CSS Modules, but using Sass
- // using the extension .module.SCSS or .module.Sass
- {
- test: sassModuleRegex,
- use: getStyleLoaders(
- {
- importLoaders: 2,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- modules: true,
- getLocalIdent: getCSSModuleLocalIdent,
- },
- 'sass-loader'
- ),
- },
- {
- test: stylusRegex,
- oneOf: [
- {
- // Match *.styl?module
- resourceQuery: /module/,
- use: getStyleLoaders(
- {
- camelCase: true,
- importLoaders: 2,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- modules: true,
- getLocalIdent: getCSSModuleLocalIdent,
- },
- 'stylus-loader'
- )
- },
- {
- use: getStyleLoaders(
- {
- importLoaders: 2,
- sourceMap: isEnvProduction && shouldUseSourceMap,
- },
- 'stylus-loader'
- )
- }
- ]
- },
- ...
- ]
- }
- }
- }
oneOf 用来取其中一个最先匹配到的规则, resourceQuery 用来匹配 import style from 'xxx.styl?module', 这样需要使用 CSS module 就在后面加? module, 不需要就直接 import 'xxx.styl',camelCase: true 是 CSS-loader 中的一个配置选项, 表示启用驼峰命名, 使用 CSS moduels 需要通过对象. 属性获取编译后样式名称, 样式名使用短横线分割, 就需要使用属性选择器如 style['css-name'], 启用驼峰命名后, 就可以 style.cssName
至此, 页面样式就正常了, 不过还并未使用到 CSS modules, 接着就需要把所有的 CSS 改成 CSS modules, 这是一个繁琐的过程, 就拿 Recommend 组件来举例
先 import 样式
import style from "./recommend.styl?module"
再通过 style 对象获取样式
- class Recommend extends React.Component {
- ...
- render() {
- return (
- <div className="music-recommend">
- <Scroll refresh={this.state.refreshScroll}
- onScroll={(e) => {
- /* 检查懒加载组件是否出现在视图中, 如果出现就加载组件 */
- forceCheck();
- }}>
- <div>
- <div className="slider-container">
- <div className="swiper-wrapper">
- {
- this.state.sliderList.map(slider => {
- return (
- <div className="swiper-slide" key={slider.id}>
- <div className="slider-nav" onClick={this.toLink(slider.linkUrl)}>
- <img src={slider.picUrl} width="100%" height="100%" alt="推荐" />
- </div>
- </div>
- );
- })
- }
- </div>
- <div className="swiper-pagination"></div>
- </div>
- <div className={style.albumContainer} style={this.state.loading === true ? { display: "none" } : {}}>
- <h1 className={`${style.title} skin-recommend-title`}> 最新专辑 </h1>
- <div className={style.albumList}>
- {albums}
- </div>
- </div>
- </div>
- </Scroll>
- ...
- </div>
- );
- }
- }
有些是插件固定的样名, 有些是用来做皮肤切换固定的样名, 这些都不能使用 CSS modules, 这个时候就需要使用: global(), 表示全局样式, CSS-loader 就不会处理样式名, 如
- :global(.music-recommend)
- width: 100%
- height: 100%
- :global(.slider-container)
- height: 160px
- position: relative
- :global(.slider-nav)
- display: block
- width: 100%
- height: 100%
- :global(.swiper-pagination-bullet-active)
- background-color: #DDDDDD
因为加入了 eslint, 出现了以下警告
- ./src/components/recommend/Recommend.JS
- Line 131: The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid
- ./src/components/singer/SingerList.JS
- Line 153: The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid
- Line 159: The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid
这个规则规定 a 标签必须指定有效的 href, 把 a 标签替换成其它即可
ref
之前说过 react16.3 新增了 createRef API https://reactjs.org/docs/refs-and-the-dom.html , 那么就用这个新的 API 替换 ref 回调. 以 Album 组件为例
在 constructor 中使用 React.createRef() 初始化
- src/views/album/Album.JS
- class Album extends React.Component {
- constructor(props) {
- super(props);
- // React 16.3 or higher
- this.albumBgRef = React.createRef();
- this.albumContainerRef = React.createRef();
- this.albumFixedBgRef = React.createRef();
- this.playButtonWrapperRef = React.createRef();
- this.musicalNoteRef = React.createRef();
- }
- ...
- }
使用 ref 指定初始化的值
- render() {
- ...
- return (
- <CSSTransition in={this.state.show} timeout={300} classNames="translate">
- <div className="music-album">
- <Header title={album.name}></Header>
- <div style={{ position: "relative" }}>
- <div ref={this.albumBgRef} className={style.albumImg} style={imgStyle}>
- <div className={style.filter}></div>
- </div>
- <div ref={this.albumFixedBgRef} className={style.albumImg + " " + style.fixed} style={imgStyle}>
- <div className={style.filter}></div>
- </div>
- <div className={style.playWrapper} ref={this.playButtonWrapperRef}>
- <div className={style.playButton} onClick={this.playAll}>
- <i className="icon-play"></i>
- <span > 播放全部 </span>
- </div>
- </div>
- </div>
- <div ref={this.albumContainerRef} className={style.albumContainer}>
- <div className={style.albumScroll} style={this.state.loading === true ? { display: "none" } : {}}>
- <Scroll refresh={this.state.refreshScroll} onScroll={this.scroll}>
- <div className={`${style.albumWrapper} skin-detail-wrapper`}>
- ...
- </div>
- </Scroll>
- </div>
- <Loading title="正在加载..." show={this.state.loading} />
- </div>
- <MusicalNote ref={this.musicalNoteRef}/>
- </div>
- </CSSTransition>
- );
- }
通过 current 属性获取 dom 或组件实例,
- scroll = ({ y }) => {
- let albumBgDOM = this.albumBgRef.current;
- let albumFixedBgDOM = this.albumFixedBgRef.current;
- let playButtonWrapperDOM = this.playButtonWrapperRef.current;
- if (y <0) {
- if (Math.abs(y) + 55> albumBgDOM.offsetHeight) {
- albumFixedBgDOM.style.display = "block";
- } else {
- albumFixedBgDOM.style.display = "none";
- }
- } else {
- let transform = `scale(${1 + y * 0.004}, ${1 + y * 0.004})`;
- albumBgDOM.style.webkitTransform = transform;
- albumBgDOM.style.transform = transform;
- playButtonWrapperDOM.style.marginTop = `${y}px`;
- }
- }
- selectSong(song) {
- return (e) => {
- this.props.setSongs([song]);
- this.props.changeCurrentSong(song);
- this.musicalNoteRef.current.startAnimation({
- x: e.nativeEvent.clientX,
- y: e.nativeEvent.clientY
- });
- };
- }
当 ref 使用在 HTML 标签上时, current 就是 dom 元素的引用, 当 ref 使用在组件上时, current 就是组件挂载后的实例. 组件挂载后 current 就会指向 dom 元素或组件实例, 组件卸载就会赋值为 null, 组件更新前会更新 ref
Code Splitting
Code Splitting 能减少 JS 文件体积, 加快文件传输速度, 做到按需加载, 现在 react 官方提供了 React.lazy 和 Suspense 来支持 Code Splitting, 关于它们的详细内容请戳这里
在之前, 路由都是直接写在组件中的, 现在将路由拆开, 在配置文件中统一配置路由, 便于集中管理
在 src 目录下新增 router 目录, 然后新建 router.JS
- import React, { lazy, Suspense } from "react"
- let RecommendComponent = lazy(() => import("../views/recommend/Recommend"));
- const Recommend = (props) => {
- return (
- <Suspense fallback={null}>
- <RecommendComponent {...props} />
- </Suspense>
- )
- }
- let AlbumComponent = lazy(() => import("../containers/Album"));
- const Album = (props) => {
- return (
- <Suspense fallback={null}>
- <AlbumComponent {...props} />
- </Suspense>
- )
- }
- ...
- const router = [
- {
- path: "/recommend",
- component: Recommend,
- routes: [
- {
- path: "/recommend/:id",
- component: Album
- }
- ]
- },
- ...
- ];
- export default router
在使用 lazy 方法包裹后的组件外层需要用 Suspense 包裹, 并指定 fallback,fallback 在组件对应的资源下载时渲染, 这里不渲染任何东西, 指定 null. 官方示例中, 在 Route 外层只用了一个 Suspense, 见此, 这里会有子路由, 如果在最外层使用一个 Suspense, 子路由懒加载时渲染 fallback 会把父路由视图组件内容替换, 导致父组件页面内容丢失, 子路由视图组件渲染完成后, 才出现完整内容, 中间有一个闪烁的过程, 所以最好在每个路由视图组件上都用 Suspense 包裹. 你需要将 props 手动传给懒加载组件, 这样就能获取 react-router 中的 match,history 等
上诉使用 Suspense 的部分存在重复代码, 我们用高阶组件改造一下
- const withSuspense = (Component) => {
- return (props) => (
- <Suspense fallback={null}>
- <Component {...props} />
- </Suspense>
- );
- }
- const Recommend = withSuspense(lazy(() => import("../views/recommend/Recommend")));
- const Album = withSuspense(lazy(() => import("../containers/Album")));
- const router = [
- {
- path: "/recommend",
- component: Recommend,
- routes: [
- {
- path: "/recommend/:id",
- component: Album
- }
- ]
- },
- ...
- ];
接下来, 使用这些配置
先将一级路由, 放到 App 组件中, 常规操作就是这样 < Route path="/recommend" component={Recommend} />, 借助 https://www.npmjs.com/package/react-router-config , 不需要手动写, 只需要调用 renderRoutes 方法, 传入路由配置即可
注意: 路由配置必须使用固定的几个属性, 大部分和 Route 组件的 props 相同
安装 react-router-config, 这里 react-router 版本较低, react-router-config 也是用了低版本
NPM install react-router-config@1.0.0-beta.4
- src/views/App.JS
- import { renderRoutes } from "react-router-config"
- import router from "../router"
- class App extends React.Component {
- ...
- render() {
- return (
- <Router>
- ...
- <div className={style.musicView}>
- {/*
- Switch 组件用来选择最近的一个路由, 否则没有指定 path 的路由也会显示
- Redirect 重定向到列表页
- */}
- <Switch>
- <Redirect from="/" to="/recommend" exact />
- {/* 渲染 Route */}
- { renderRoutes(router) }
- </Switch>
- </div>
- </Router>
- );
- }
- }
Redirect 用来做重定向, 需要放到最前面, 否则不生效. renderRoutes 会根据配置生成 Route 组件类似 < Route path="/recommend" component={Recommend} />
接着在 Recommend 组件中使用子路由配置
- src/views/recommend/Recommend.JS
- import { renderRoutes } from "react-router-config"
- class Recommend extends React.Component {
- render() {
- let { route } = this.props;
- return (
- <div className="music-recommend">
- ...
- <Loading title="正在加载..." show={this.state.loading} />
- { renderRoutes(route.routes) }
- </div>
- );
- }
- }
调用 renderRoutes 后, 会把当前层级的路由配置传递给 route, 然后通过 route.routes 获取子路由配置, 以此类推子级, 子子级都是这样做
renderRoutes 源码见此
还有其它组件路由需要改造, 都使用这种方式即可
预览
预览地址: http://music.codemcx.work
二维码:
源码
GitHub https://github.com/code-mcx/mango-music
觉得不错请给个 Star, 谢谢啦~
最后
新年将至, 提前祝大家新年快乐, 早日脱单, 永无 bug
来源: https://juejin.im/post/5c4a9ca551882525331607e0