React16.7升級Web音樂App
去年寫了一款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
- 新增 preset-env
更多更新內容請戳這裡
react16.3
因為之前使用的是react16.2,說到react16.7得從16.3說起
16.3新增了幾個新的生命週期函式、 context API 、 createRef API 和forwardRef API,新增的兩個生命週期函式 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回撥的方式,因為第一種有幾個 缺點 ,使用ref回撥有些麻煩,所以官方提供了新的操作就是createRef API
當使用函式元件時如何獲取dom,forwardRef API允許你使用函式元件並傳遞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
類元件中有個生命週期函式 shouldComponentUpdate
用來告訴元件是否進行render,繼承 React.component
,可以自己重新這個方法來判斷決定該怎樣進行render,繼承 React.PureComponent
,預設已經實現了 shouldComponentUpdate
,它會把props和state進行淺比較,不相等才進行render,不能自己重寫 shouldComponentUpdate
。對於函式元件,它沒有這樣的功能,在這個版本中新增了 React.memo ,使函式元件具有和 React.PureComponent
一樣的功能
16.3中新增了context API,當使用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 [email protected] 複製程式碼
npm install [email protected] [email protected] 複製程式碼
稍等片刻
執行 npm run start

發現報錯了,之前是基於 react-scripts
1.x的版本自定義了指令碼, react-scripts
2.x中配置變化了很多,導致原來自定義的指令碼不能用了。另外尋找修改配置的方法太費時間,如果你熟悉webpack配置執行自帶的 eject
將配置檔案提取出來,或者尋找第三方 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 沒有顛覆原始的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.mdjsx-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.mdjsx-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.mdjsx-a11y/anchor-is-valid 複製程式碼
這個規則規定a標籤必須指定有效的href,把a標籤替換成其它即可
ref
之前說過react16.3新增了createRef API,那麼就用這個新的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} />
,藉助react-router-config,不需要手動寫,只需要呼叫 renderRoutes
方法,傳入路由配置即可
注意:路由配置必須使用固定的幾個屬性,大部分和 Route 元件的props相同
安裝react-router-config,這裡react-router版本較低,react-router-config也是用了低版本
npm install [email protected] 複製程式碼
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原始碼 見此
還有其它元件路由需要改造,都使用這種方式即可