React Hook 和 SCSS 结合的响应式布局方案

593 阅读9分钟

背景

公司中有多个项目需要同时开发 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 的值:

mobile-pc.gif

样式部分

我们通过 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;
  }
}

px2vw-result.gif

从图中可以看到,当页面大小切换到 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 开头的值时就会有智能提示,回车即可一键输入:

px2vw-intellisense.gif

还可以对 SCSS 代码选中区域内的 px 值统一加上 px2vw() 调用:

px2vw-select-and-replace.gif

另外还提供了代码片段,可以快捷输入 :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: github.com/heruns/reac…

本文所介绍的响应式方案就是逻辑上通过 React Hook 来实时获取视口是 PC 还是移动端大小,然后在尽可能复用代码的情况下在不同端渲染不同的组件、编写不同的逻辑或样式,移动端使用 SCSS 函数将 px 转成 vw,并且尽量利用构建和开发工具提升开发体验和效率。

以上就是我个人对公司项目响应式方案的思考,欢迎大家提出自己的见解,一起讨论、学习。

参考资料