Stories

Detail Return Return

React Hook 和 SCSS 結合的響應式佈局方案 - Stories Detail

背景

公司中有多個項目需要同時開發 PC 端和 H5 端,大部分地方邏輯和交互比較類似,主要是樣式上有些區別。為了更好地複用代碼、提高開發效率,經過一段時間的實踐後,我們總結出這套 React Hook 和 SCSS 結合、px 和 vw 共存的響應式佈局方案。

基礎代碼

創建項目

首先,我們來創建一個項目,這裏我用的是 Create React App,選擇了 typescript 模板,通過以下命令即可創建項目:

npx create-react-app my-app --template typescript

邏輯部分

邏輯這塊,主要思路是通過寫一個 Hook,用於檢測當前視口是否為移動端大小,並根據視口大小實時更新返回值,這樣我們在組件中需要的時候直接調用這個 Hook 就行了,非常方便。

首先, App.tsx 負責實時檢測是否移動端,並且將該值通過 Context 傳遞給所有子組件和後代組件,代碼如下:

import { createContext, useState, useEffect } from 'react';
import MyComponent from './components/MyComponent/MyComponent';

// 判斷當前視口是否為移動端大小
export const checkIsMobile = () => {
  return window.innerWidth <= 620
}
// 保存是否移動端狀態的 context
export const IsMobileContext = createContext<boolean>(false)

function App() {
  const [isMobile, setIsMobile] = useState(checkIsMobile)
  useEffect(() => {
    const resizeHandler = () => {
      const currentIsMobile = checkIsMobile()
      setIsMobile(currentIsMobile)
    }
    // 監聽 window 的 resize 事件,窗口大小改變時重新計算 isMobile 的值
    window.addEventListener('resize', resizeHandler)
    // 組件銷燬時取消事件監聽
    return () => window.removeEventListener('resize', resizeHandler)
  }, [])

  return (
    // 通過 IsMobileContext 將 isMobile 的值傳遞給所有子組件
    <IsMobileContext.Provider value={isMobile}>
      <MyComponent/>
    </IsMobileContext.Provider>
  );
}

export default App;

然後我們創建 useIsMobile.ts 這個文件,這裏面主要保存我們的 useIsMobile hook,用於在組件中響應式獲取 App.tsx 中傳遞下來的 isMobile 值,代碼很簡單:

// 從 IsMobileContext 中獲取當前是否移動端狀態的 hook
export const useIsMobile = () => {
  const isMobile = useContext(IsMobileContext)
  return isMobile
}

最後,我們將 useIsMobile 導入到 MyComponent 中使用,在 PC 端顯示 "pc",在移動端顯示 "mobile":

const MyComponent = () => {
  const isMobile = useIsMobile()

  return (
    <div className={styles.myComponent}>{ isMobile ? 'mobile' : 'pc' }</div>
  )
}

可以看到,我們的 React 組件已經可以實時獲取 isMobile 的值:

樣式部分

我們通過 npm install sass 命令安裝 sass,用於將我們的 SCSS 代碼編譯成 CSS。

postcss-px-to-viewport 很好,如果只是開發 H5 項目,那我推薦你用這個工具。但是我們經常會遇到 px 和 vw 共存的情況,比如需要避免 PC 端字體和元素大小相對 H5 等比例放大的時候,我們不能簡單地將全部 px 轉成 vw,而是選擇在 PC 端主要使用 px,在 H5 端主要用 vw。

我們最終選擇的方案是在移動端通過以下函數將 px 轉成 vw,$px 為設計稿中 px 值的大小, $base 為設計稿寬度,這裏默認為 375,可根據項目情況自行修改:

@function px2vw($px, $base: 375px) {
  @return calc($px / $base) * 100vw;
}

我之所以沒有選擇 rem,是因為 vw 更直觀,現在的兼容性也非常好,並且不需要像 rem 那樣額外引入腳本來動態設置 html 元素的字體大小。

接下來我們在 MyComponent 中添加一個 div,並且在移動端給它設置寬高和字體大小等屬性,以檢查 px2vw 函數的效果:

<div className={[styles.myComponent, isMobile && styles.isMobile].filter(Boolean).join(' ')}>
  <div className={styles.box}>{ isMobile ? 'mobile' : 'pc' }</div>
</div>

對應的樣式文件 MyComponent.module.scss 代碼如下:

@import "../../styles/function.scss";

.isMobile {
  .box {
    width: px2vw(300px);
    height: px2vw(300px);
    font-size: px2vw(35px);
    background-color: #0099CC;
  }
}

從圖中可以看到,當頁面大小切換到 H5 的寬度後,即使繼續縮小視口寬度,我們的容器與視口寬度的比例一直保持不變,通過檢查元素也可以看到 width 等屬性的值已經被轉換為 vw

優化開發體驗

上面的步驟已經實現了我們想要的效果,在需要寫移動端樣式的組件中使用 useMobile Hook 可以實時獲取當前是 PC 還是移動端,然後通過給 DOM 動態添加 className,即可為 PC 和移動端編寫不同的樣式。

但是在開發體驗上還有很多可以優化的地方,下面讓我們一起逐步優化它。

統一添加 className

在上面的 MyComponent 組件中,我們在移動端給外層 div 添加了 isMobile 這個 className,然後在 SCSS 中使用這個選擇器來編寫移動端代碼。如果每個組件都這樣寫,那就太麻煩了。

我們可以統一在 App 中為 body 元素添加 pcmobile 這個 class,然後在各個組件中就不用再單獨添加了,需要寫移動端樣式的地方直接用 :global(.mobile) {...} 即可。

App.tsx 中添加如下代碼:

useLayoutEffect(() => {
  type BodyClassName = 'pc' | 'mobile'
  const bodyClass: BodyClassName = isMobile ? 'mobile' : 'pc'
  const classToRemove: BodyClassName = bodyClass === 'mobile' ? 'pc' : 'mobile'
  document.body.classList.remove(classToRemove)
  document.body.classList.add(bodyClass)
}, [isMobile])

這裏的邏輯很簡單,監聽 isMobile 值的變化,然後動態切換 body 元素的 className 即可。需要注意的是,這裏用的是 useLayoutEffect,而不是 useEffect,否則在移動端首次加載的時候頁面會先繪製 PC 端的樣式,隨後才立即繪製移動端樣式,從而導致頁面閃爍,關於 useLayoutEffect,如果不瞭解的同學可以參考 官方文檔。

然後把 MyComponent 組件中關於 className 的判斷去掉:

<div className={styles.myComponent}>
  <div className={styles.box}>{ isMobile ? 'mobile' : 'pc' }</div>
</div>

再稍微改造一下樣式文件 MyComponent.module.scss:

@import "../../styles/function.scss";

:global(.mobile) {
  .myComponent {
    .box {
      width: px2vw(300px);
      height: px2vw(300px);
      font-size: px2vw(35px);
      background-color: #0099CC;
    }
  }
}

現在的代碼效果跟改造之前是一樣的,但是 JSX 部分看起來更乾淨、寫起來更方便了,在任意組件中需要寫移動端樣式的時候只需要將代碼添加到 :global(.mobile) {...} 選擇器中即可。

px2vw 函數自動導入

現在我們的 MyComponent.module.scss 頂部有這樣的一行代碼:@import "../../styles/function.scss"; 用於導入 px2vw 函數,每個需要用到該函數的 SCSS 文件中都要引入這個 function.scss,並且由於我們使用的是相對路徑,引用路徑根據組件所在位置不同也會不一樣,這也是一件比較煩人的事,接下來我們看看怎麼通過修改 webpack 配置去解決這個問題。

安裝 craco

通過 npm install -D @craco/craco @craco/types 命令安裝 craco,用於修改 webpack 配置。

需將 package.json 文件 scripts 模塊中的所有 react-scripts 改成 craco:

"scripts": {
-  "start": "react-scripts start"
+  "start": "craco start"
-  "build": "react-scripts build"
+  "build": "craco build"
-  "test": "react-scripts test"
+  "test": "craco test"
}

配置路徑別名

在項目根目錄下創建 craco.config.js,內容如下:

const path = require('path')

module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      webpackConfig.resolve = webpackConfig.resolve || {}
      webpackConfig.resolve.alias = webpackConfig.resolve.alias || {}
      webpackConfig.resolve.alias['@'] = path.resolve(__dirname, 'src')
      return webpackConfig
    }
  }
}

這裏通過 webpack 的 resolve.alias 為 src 目錄設置了 @ 這個別名,這樣我們在任意 SCSS 文件中都可以通過 @import "@/styles/function.scss"; 引入 px2vw 函數了,而不需要關心組件所處的位置。

function.scss 文件自動導入

讓我們再修改一下 craco.config.js,添加 style 對象:

const path = require('path')

module.exports = {
  style: {
    sass: {
      loaderOptions: {
        // 全局添加 scss 前綴代碼
        additionalData: (content, loaderContext) => {
          const { resourcePath, rootContext } = loaderContext
          // 當前文件的相對路徑
          const relativePath = path.relative(rootContext, resourcePath)
            .split(path.sep)
            .join('/')
          // 待引入的文件
          const filesToImport = ['src/styles/function.scss']

          if (/\.scss$/.test(relativePath) && !filesToImport.includes(relativePath)) {
            // 如果當前文件後綴名為 .scss,則在文件開頭添加需要引入的文件
            const importStatements = filesToImport
              .map(file => `@import "${file.replace('src', '@')}";`)
              .join('\n')
            return `${importStatements}\n${content}`
          } else {
            return content
          }
        },
      }
    },
  }
}

在這裏,我們利用了 sass-loader 的 additionalData 配置來實現 function.scss 文件的自動導入。需要注意的是,使用 additionalData 自動導入 SCSS 文件時,要避免導入包含代碼輸出的文件,否則會導致重複打包,重複生成相同的 CSS 代碼。

現在我們就可以把我們組件樣式文件中的 function.scss import 語句刪掉了,開發體驗得到進一步提升。

px2vw 智能提示、一鍵添加調用

現在還有一個問題,在我們需要用到 px2vw 函數的時候,每個地方都要一個個字符手敲也是一件麻煩事,有什麼辦法可以解決這個問題呢?

這就不得不介紹一下我們團隊開發的 VS Code 插件:Bihu FE Tools。

在 SCSS 值中輸入數字值或 px2vw 開頭的值時就會有智能提示,回車即可一鍵輸入:

還可以對 SCSS 代碼選中區域內的 px 值統一加上 px2vw() 調用:

另外還提供了代碼片段,可以快捷輸入 :global(.mobile) {...}

以上功能都是根據本文所介紹的響應式方案量身定製的。不僅如此,該插件還提供了格式化或將 JSON 轉為 TS 類型、組件重命名、帶自動導入功能的 useStateuseEffect 代碼片段等特性,適用於 JS/TS/React/SCSS 等技術棧,歡迎嘗試,也歡迎提改進建議。

優缺點

優點

  • 該方案可以很方便地實現代碼邏輯複用,樣式也大部分可以複用,不一致的地方只需要在 .mobile 選擇器中覆蓋之前的 CSS 即可,極大提高了開發效率;
  • 項目中往往會有些地方不僅需要區分移動端和 PC 端的樣式,還需要在不同端執行不同的邏輯,這時候用 isMobile 來判斷就很方便;
  • 比起單純使用 CSS 實現的響應式,我們可以通過 isMobile 值靈活控制組件的渲染,避免渲染多餘組件,從而造成不必要的開銷;
  • 由於是通過 SCSS 函數對 px 進行轉換,既不需要自己手動計算 vw 值,還可以直觀地看到原來的 px 大小,方便在編輯器中與設計稿進行比較。

缺點

  • 適用於簡單場景,也就是隻需要區分 H5 和 PC 端的情況,複雜場景需要看情況修改,或使用 react-responsive 等庫;
  • 由於需要使用 SCSS 函數,移動端 CSS 編寫不太方便,建議搭配 Bihu FE Tools VS Code 插件)使用。

適用和不適用場景

適用場景

  • 需求上只需要區分 H5 和 PC 端
  • 代碼上 px (PC 端) 和 vw (H5) 共存
  • 實現邏輯和樣式的複用,提高開發效率

不適用場景

  • 如果需要對樣式進行更細維度的控制,可以用上面提到的 react-responsive 或媒體查詢來實現
  • 如果需要將全部或絕大部分 px 轉換成 vw,則推薦使用 postcss-px-to-viewport

總結

該項目的完整代碼已上傳到 Github: https://github.com/heruns/react-responsive-layout。

本文所介紹的響應式方案就是邏輯上通過 React Hook 來實時獲取視口是 PC 還是移動端大小,然後在儘可能複用代碼的情況下在不同端渲染不同的組件、編寫不同的邏輯或樣式,移動端使用 SCSS 函數將 px 轉成 vw,並且儘量利用構建和開發工具提升開發體驗和效率。

以上就是我個人對公司項目響應式方案的思考,歡迎大家提出自己的見解,一起討論、學習。

參考資料

  • React Hooks 響應式佈局
  • Developing responsive layouts with React Hooks - LogRocket Blog

Add a new Comments

Some HTML is okay.