React16.7升級Web音樂App

code_mcx發表於2019-01-29

前言

去年寫了一款Web音樂App,並發表了系列文章,介紹了開發的過程,當時使用create-react-app官方腳手架搭建的專案,react-scripts1.x版本,而react版本是16.2.0,去年10月份create-react-app已經發布了2.0版本,react在去年12月份升級到了16.7.0

前端領域的技術迭代更新實在是太快了,經常有人吐槽求不要更新我學不動了我學不完了

React16.7升級Web音樂App

做前端就要做好隨時學習的準備,不然就會被淘汰啦⊙﹏⊙∥∣°

React16.7升級Web音樂App

只要是做開發的都要保持一顆積極學習的心,不管是前端領域還是後端領域,不過前端學習新技術的間隔時間要比後端長。作為Java出身的我深有體會o(╯□╰)o

更新介紹

create-react-app

時至今日,create-react-app更新到了2.x的版本了,主要是升級了它所依賴的許多工具,這些工具已經發布了包含新特性和效能改進的新版本,比如babel7webpack4babel7webpack4具體更新了哪些,優化了哪些大家可以去查閱資料。 以下列出來create-react-app更新了的幾個要點

  • 新增Sass前處理器,CSS模組化支援
  • 更新到Babel7
  • 更新到webpack4
  • 新增preset-env

更多更新內容請戳這裡

react16.3

因為之前使用的是react16.2,說到react16.7得從16.3說起

16.3新增了幾個新的生命週期函式context APIcreateRef APIforwardRef API,新增的兩個生命週期函式getDerivedStateFromPropsgetSnapshotBeforeUpdate主要是替代之前的componentWillMount, componentWillReceivePropscomponentWillUpdate,目的是為了支援error boundaries和即將到來的async rendering mode(非同步渲染)。當使用async rendering mode時,會中斷初始化渲染,錯誤處理的中斷行為可能導致記憶體洩漏,而使用componentWillMount, componentWillReceivePropscomponentWillUpdate會加大這類問題產生的機率

在之前的版本,獲取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.lazySuspense用來支援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.3react升級到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

React16.7升級Web音樂App

發現報錯了,之前是基於react-scripts1.x的版本自定義了指令碼,react-scripts2.x中配置變化了很多,導致原來自定義的指令碼不能用了。另外尋找修改配置的方法太費時間,如果你熟悉webpack配置執行自帶的eject將配置檔案提取出來,或者尋找第三方customize-cra,這樣的話就要多學習一下配置方法,如果作者不維護了,react-scripts發生大的更新,也不能及時適配新的版本,這裡我選擇暴力,將配置檔案提取出來

let's do it

執行npm run eject

React16.7升級Web音樂App

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

React16.7升級Web音樂App

提示是否新增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-compoentsstyled-jsxcss 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,放到一起時,就成了下面這樣

React16.7升級Web音樂App

怎麼會有這麼長的尾巴

React16.7升級Web音樂App

如何去掉這個長尾巴而不影響使用css modules,我們使用webpack配置中的Rule.oneOfRule.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,那麼就用這個新的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.lazySuspense來支援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包裹,並指定fallbackfallback在元件對應的資源下載時渲染,這裡不渲染任何東西,指定null。官方示例中,在Route外層只用了一個Suspense見此,這裡會有子路由,如果在最外層使用一個Suspense,子路由懶載入時渲染fallback會把父路由檢視元件內容替換,導致父元件頁面內容丟失,子路由檢視元件渲染完成後,才出現完整內容,中間有一個閃爍的過程,所以最好在每個路由檢視元件上都用Suspense包裹。你需要將props手動傳給懶載入元件,這樣就能獲取react-router中的matchhistory

上訴使用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
      }
    ]
  },
  ...
];

複製程式碼

React16.7升級Web音樂App

接下來,使用這些配置

先將一級路由,放到App元件中,常規操作就是這樣<Route path="/recommend" component={Recommend} />,藉助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原始碼見此

還有其它元件路由需要改造,都使用這種方式即可

預覽

預覽地址:music.codemcx.work

二維碼:

React16.7升級Web音樂App

原始碼

Github

覺得不錯請給個Star,謝謝啦~

相關文章