基於 react, redux 最佳實踐構建的 2048

devrsi0n發表於2017-10-12

前段時間 React license 的問題鬧的沸沸揚揚,搞得 React 社群人心惶惶,好在最終 React 團隊聽取了社群意見把 license 換成了 MIT。不管 React license 如何,React 都是一個值得好好學習的優秀檢視庫。

本專案算不上什麼大型專案,但依然按照大型專案的標準採用前端流行的最佳實踐來打造一個有良好程式碼質量,高效能,高可維護性,模組化的應用。本專案是基於 react, redux 構建的 2048,此外也使用了近兩年優秀的開源工具來提高程式碼質量,包括 eslintstylelintprettier 等等,以及 traviscodecov 等持續整合,持續部署等服務來保障程式碼質量和提高開發效率。

專案地址,喜歡的話 github 點個 star 支援下吧?

預覽

桌面端


screenshot

移動端


screenshot

特性

響應式

自適應桌面和移動平臺不同解析度和尺寸,支援移動平臺瀏覽器觸控操作。下面的動圖模擬了不同解析度下的顯示效果。實現方式主要是把 css 單位從 px 換成了 vw 和 rem ,各元素的尺寸是按照解析度來進行縮放的。css 媒體查詢到移動瀏覽器的話,調整部分元件的位置,隱藏部分不重要的元件,使頁面更加緊湊。


screenshot

資料持久化

網頁應用最怕斷電和離線,第一個問題通過 store.subscribe 訂閱 redux 狀態更新,把狀態序列化到 localStorage 儲存,即使重新整理,斷電,程式奔潰再次開啟仍然是最新的狀態,第二個問題藉助 chrome 的 PWA 技術,即使斷開網路仍然可以訪問快取的資原始檔。


screenshot

Redux 狀態

redux 是一個可預測的 JS 狀態管理容器,結合 Redux DevTools extension 擴充套件可以很方便的進行應用狀態穿梭,對輔助開發和debug大有裨益。不僅可以檢視 redux 儲存的狀態,還可以隨時回到到過去某個時刻的狀態就像時間穿梭機一樣,也看得到 redux 每次 action 的觸發,以及每次觸發造成的狀態改動。


screenshot

評論系統

藉助 github issue api,使用 github 賬號登入之後以回覆 issue 的方式留言。留言支援 markdown 格式,和 github issue 體驗類似。


screenshot

PWA

在支援 PWA 技術的瀏覽器上(比如較新的 chrome)開啟頁面會自動詢問你新增到螢幕,新增過程就像原生應用的安裝一樣。應用新增之後就可以像原生應用一樣離線操作,也可以解除安裝應用。下圖演示了 PWA 在 chrome 上面的新增過程,新增完成之後桌面會出現新增的應用,即便關閉所有網路仍然可以像原生應用一樣正常操作。


screenshot

i18n

應用支援多語言,且自動適配瀏覽器語言設定。目前檢測到瀏覽器支援中文優先使用中文,否則預設使用英文顯示。需要更多語言支援,編輯 src/utils/i18n.jsdata 物件,新增對應語言文字即可。


screenshot

react 最佳實踐

  • 一個檔案一個元件。
  • 儘量使用無狀態(Stateless)元件,也就是如果只是寫一個單純展示的元件,不需要元件儲存自己的狀態,不需要生命週期方法或者 refs 來操作 DOM 的元件則優先使用無狀態元件,採用函式的形式。以專案 Tips 元件示例:

      import React from "react";
      import PropTypes from "prop-types";
      import styles from "./tips.scss";
    
      export default function Tips({ title, content }) {
        return (
          <div className={styles.tips}>
            <p className={styles.title}>{title}</p>
            <p className={styles.content}>{content}</p>
          </div>
        );
      }
    
      Tips.propTypes = {
        title: PropTypes.string.isRequired,
        content: PropTypes.string.isRequired
      };複製程式碼
  • 和上面相反,如果你需要元件生命週期方法優化元件效能(典型應用,重寫 shouldComponentUpdate 方法),需要元件儲存自己的狀態,或者用 refs 操作 DOM,你就需要一個有狀態元件,採用 es6 class 繼承 React.Component 的寫法。元件示例:

      import React from "react";
      import PropTypes from "prop-types";
      import classnames from "classnames";
      import styles from "./cell.scss";
      import { isObjEqual } from "../../utils/helpers";
    
      export default class Cell extends React.Component {
        static propTypes = {
          value: PropTypes.number.isRequired
        };
    
        shouldComponentUpdate(nextProps, nextState) {
          return (
            !isObjEqual(nextProps, this.props) || !isObjEqual(nextState, this.state)
          );
        }
    
        render() {
          const { props: { value } } = this;
    
          const color = `color-${value}`;
          return (
            <td>
              <div
                className={classnames([styles.cell, { [styles[color]]: !!value }])}
              >
                <div className={styles.number}>{value || null}</div>
              </div>
            </td>
          );
        }
      }複製程式碼
  • 事件繫結 this 方法。在建構函式裡面繫結一次 this 之後後面就可以正常使用。以 ControlPanel 元件部分程式碼示例:

    constructor(...args) {
      super(...args);
    
      this.handleMoveUp = this.handleMoveUp.bind(this);
      this.handleMoveDown = this.handleMoveDown.bind(this);
      this.handleMoveLeft = this.handleMoveLeft.bind(this);
      this.handleMoveRight = this.handleMoveRight.bind(this);
      this.handleKeyUp = this.handleKeyUp.bind(this);
      this.handleSpeakerClick = this.handleSpeakerClick.bind(this);
      this.handleUndo = this.handleUndo.bind(this);
    }複製程式碼
  • 使用 propTypes 屬性進行傳入 prop 的校驗。可以校驗 prop 的型別和是否必需,非必需的 prop 還必需填寫 defaultProps 預設值。以無狀態元件 Button 的部分程式碼示例:

      Button.propTypes = {
        children: PropTypes.oneOfType([PropTypes.node]),
        onClick: PropTypes.func,
        size: PropTypes.oneOf(["lg", "md", "sm", "xs"]),
        type: PropTypes.oneOf([
          "default",
          "primary",
          "warn",
          "danger",
          "success",
          "royal"
        ]).isRequired
      };
    
      Button.defaultProps = {
        children: "",
        onClick() {},
        size: "md",
      };複製程式碼
  • 使用 HOC(Higher-Order Components) 代替 mixin。mixin 官方已經不推薦使用了,redux 的 connect 方法就是 HOC 的應用。
  • 為了提高應用效能,避免不必要的檢視重繪,在需要的元件使用 shouldComponentUpdate 方法;以元件 Row 示例:
    // 如果該行沒有格子需要重新整理也沒有元件自己的狀態重新整理,
    // 則該元件不執行 render 方法,
    // 避免每次別的行資料重新整理也跟著重新渲染。
    shouldComponentUpdate(nextProps, nextState) {
      return (
        !isObjEqual(nextProps, this.props) || !isObjEqual(nextState, this.state)
      );
    }複製程式碼

專案結構

本專案是基於 Facebook 官方出品的 create-react-app 腳手架搭建的,reject 後做了適當修改以適配專案需求。

調整如下

  • webpack 新增 scss 支援。之所以沒有用 CssInJS 的方案是因為這些方案普遍不完美,也考慮到要遵循樣式和結構分離的原則,scss 是目前比較成熟的 css 前處理器,社群輪子也比較多,開發起來很方便。推薦學習 scss/sass 教程。新增 sass-loader 到 scss 規則下面最下面即可。配置程式碼
  • 開啟 css module 支援。在大型專案裡面元件之間需要儘量解耦,但是 css 類名的全域性特性很容易導致意料之外的錯誤。開啟 css module 之後,所有的類名最終都會被一小段 hash 值填充,所以類名也就有一定的唯一性,不容易汙染全域性的程式碼。配置程式碼
  • 新增 stylelint 支援。js 程式碼已經有 eslint (但採用了更流行,校驗更嚴格的 airbnb 規則) 來檢查程式碼,但是樣式程式碼也需要保持程式碼風格統一,同時校驗規則一般有社群的最佳實踐。配置程式碼
  • 新增靜態資源 cdn 支援。由於專案部署在 github page 在國內訪問速度不是很理想,所以在可能的情況下儘量減小 js 包的大小對頁面載入速度至關重要。像 ReactDOM 這類較大的 npm 包從打包檔案剝離出去採用 CDN 來載入,可顯著減小打包檔案的大小。(PS:之所以 CDN 載入比較快,是因為 CDN 提供商在全國各地都建立了快取伺服器,資源就近獲取比自己從 github 獲取快得多,而且一般 CDN 的頻寬也比較充裕)把 React 和 ReactDOM 剝離出去只需要在 html 檔案新增 CDN 的 script 標籤,同時在 webpack 新增 externals 屬性,該屬性指定程式碼 import 該包時直接從全域性變數獲取。剝離後打包的 js 檔案大小從 278kb 減小到 164 kb。
  • 新增 webpack 程式碼壓縮外掛。預設的 webpack 配置直接輸出原始的 js,css 程式碼,但新增壓縮過後,檔案顯著減小(js 檔案從 164kb 到 49kb),對於移動瀏覽器來說開啟速度得到明顯提升。配置程式碼
  • 新增 webpack-bundle-analyzer 外掛,通過各模組包所佔打包檔案後的比重來分析專案程式碼,藉此優化程式碼。比如,React 和 ReactDOM 的剝離就是因為分析後發現這兩個包所佔比重較大。

檔案結構

  • src, 專案原始碼大部分都在這裡,主要是 react 元件 js 程式碼 和 scss 樣式程式碼。次級目錄包含了 jest 單元測試程式碼,測試程式碼儘量和原始碼挨著,以方便編寫。
    • assets,主要存放一些全域性樣式程式碼,icon svg 檔案,遊戲音效 mp3 檔案,圖片等等;
    • components,存放 react dumb 元件, 每個元件包含在採用首字母大寫的目錄的 index.js 裡面,同時該目錄包含該元件用到樣式的 scss 檔案,儘量一個目錄包含該元件所需的所有程式碼避免汙染其他程式碼,提高元件複用性。
    • containers,存放 react smart 元件,該目錄結構和 components 類似,但因為是 smart 元件,所以這裡的元件可以操作 redux 的資料,不用太考慮複用性。
    • reducers,這是 redux 包含的是無副作用的純函式式計算狀態操作的函式。
    • utils,包括評論元件初始化,i18n 多語言檔案,移動瀏覽器滑動檢測和註冊 ServiceWorker 等等。
    • index.js,專案入口檔案,主要把 react 根元件 渲染到指定 DOM 節點,並且註冊 ServiceWorker
    • store.js,redux store 初始化,同時 store.subscribe 訂閱應用狀態更新,序列化狀態存到 localStorage
  • public,包括專案的 html 檔案,網站 icon favicon 和 PWA manifest 檔案。
  • config,主要包括 webpack 的各種配置檔案。
  • scripts,npm 的啟動指令碼,啟動開發模式,專案打包,執行 jest 單元測試等等。
  • build,專案打包後的輸出目錄。
  • screenshots,README 各種圖片的原圖,為了國內使用者訪問方便實際上 README 的圖片來自新浪微博的圖床。
  • .editorconfig,通用的編輯器配置,統一不同編輯器 / IDE 的程式碼格式。
  • .eslintignore,需要 eslint 忽略的檔案或者目錄,規則類似 .gitignore
  • .travis.yml, 持續整合指令碼,每次提交程式碼到 github 之後,測試伺服器都會自動執行該指令碼執行測試用例,並輸出程式碼覆蓋率,最後自動部署到 github page。所有狀態都在專案中 README 的徽章中可見。
  • package.json,專案基本資訊和部分配置都存在這裡。常見的內容包括專案的各類依賴包,各種啟動指令碼,專案 homepage 等等;為了減少根專案的檔案數目,jest,babel,eslint,stylelint 的配置也寫在這裡。值得注意的是,專案中引入 husky,在每次程式碼 commit 之前都會執行 lint-staged,以自動執行 prettier 來美化程式碼格式。每次程式碼推送 到 github 之前也會執行所有單元測試用例,全部通過才可以繼續推送。
  • yarn.locl,yarn 首次安裝依賴包之後生成的 lock 檔案。通過 yarn 來安裝依賴包時,yarn 自動把專案的依賴包(包括依賴包依賴的父級包)固定在指定的版本(包括依賴包安裝的 url 和 hash 值),這樣所有開發環境都使用 yarn 來管理專案,不同的機器不同的系統安裝出來包都是一樣的,這樣就避免了之前 npm 的缺陷(版本要求太鬆或者父級包版本更新等等導致每次安裝出來的依賴版本不一樣)。

技術棧

  • react,元件式構建 UI
  • redux,管理應用狀態
  • babel,把 es2017+ 語法轉成 es5 相容語法
  • webpack,程式碼熱載入,scss 樣式檔案處理,元件編譯打包等等
  • scss,成熟的 css 前處理器(之所以沒有用 CssInJS 的方案是因為這些方案普遍不完美,也考慮到要遵循樣式和結構分離的原則)
  • eslint,使用流行的 airbnb 的程式碼規範嚴格約束程式碼風格
  • stylelint,scss 程式碼風格檢查
  • jest,fb 出品的程式碼測試框架,snapshot 功能對測試 react 元件 UI 十分方便
  • Prettier,js 和 scss 程式碼格式美化工具
  • PWA(Progressive Web Apps),藉助瀏覽器 service worker 能力,使 web 應用在移動平臺有接近原生應用的能力,可離線使用,接收通知訊息等等

執行 & 測試 & 打包

因為配置檔案用了 es6+ 語法所以要求 node 的版本大於 6.10,同時建議使用 yarn 來管理依賴包。fork 專案之後可以按如下命令操作。

  npm i -g yarn # 安裝 yarn
  git clone git@github.com:<你的名字>/React-2048-game.git
  cd React-2048-game
  yarn # 安裝依賴包
  yarn start # 開啟除錯模式,啟動後自動開啟瀏覽器 http://localhost:3000 
  yarn test # 自動測試
  yarn build # 打包程式碼複製程式碼

踩坑記錄

  • 在調煙花動畫的時候發現沒效果,仔細對比了下 webpack 編譯後的 css 檔案發現所有的 @keyframes 的名字都加了 hash 值(也就是當成普通的區域性 css 類名),解決辦法就是在 @keyframes 的名字前面和整個 scss 檔案新增偽類 :global,可以參考煙花的 scss 檔案,這不是完美的解決辦法(css 類名不再有區域性特性),後續再深挖一下。
  • css module 用到的 :global 這個不是標準的偽類,所以 stylelint 需要新增配置以忽略這個錯誤。參見 package.jsonstylelint.rules

專案地址,喜歡的話 github 點個 star 支援下吧?

相關文章