likes
comments
collection
share

基于vite2+react+typescript前端开发工程化(二)

作者站长头像
站长
· 阅读数 18

一、回顾简述

上一篇文章,主要讲述了前端工程初始化、目录结构、以及前端规范体系:

  • 代码格式规范
  • 代码质量规范
  • 代码提交规范
  • 版本管理规范

标准化和统一前端团队的代码风格和代码验证,可以大幅度提高前端团队的生产力。

接下来我们继续探讨,基于vite2+react+typescript前端开发工程化搭建。这一章准备和大家一起探讨样式管理以及组件化方面的问题。

二、样式管理

这一章,我们讨论,前端工程化中的重要模块————样式管理。

基于vite2+react+typescript前端开发工程化(二)

1、为什么先讲样式管理?

讨论前端绕不开视觉,也绕不开前端布局。前端大部分工作的最终结果,都是为了通过客户端,呈现给用户视觉画面和交互操作。

布局重构视觉,是前端开发的第一步。每当前端布开发的前置工作,需求文档+视觉设计+交互设计原型准备就绪,前端工程师首先要做的事情就是页面布局开发。前端布局是css加html,共同作用的结果。为了保证前端布局开发的效率,通常前端团队都会按照视觉和交互规范,制定好基础样式和基础组件。

所以,制定基础样式和基础组件,是前端架构高效和规范的基础保障。高效的完成前端布局工作,才能把更多的精力放在,前端交互逻辑和业务逻辑上面。

2、前端样式布局技术的发展

前端发展的基石是Javascript的发展和统一。尤其是Node.js和ES6以及Babel的出现,大幅度提升了前端工程化的发展速度。与此同时,CSS层叠样式表(英文全称:Cascading Style Sheets),也经历数次革命性的发展:

基于vite2+react+typescript前端开发工程化(二)

  • css3,flex、grid、transform、css vars等
  • 预编译语言sass、less、stylus
  • Postcss,把CSS代码解析成抽象语法树结构(AST),再交由插件处理,比如,浏览器前缀、嵌套语法、css变量等
  • 命名方法论BEM、OOCSS、SMACSS、ITCSS
  • 组件化相伴而生的All In JS,CSS Module、styled-component、emotion、styled-jsx、jss
  • Vue Scope
  • 原子CSS,tailwind.css
  • 样式验证Stylelint

简单整理了一部分,琳琅满目一大堆。那么我们应该如何选择适合自己团队,且效率最优化呢?

3,如何选择?

选择的基础是需求,你需要解决什么问题,就会选择对应的方法和工具。前端布局面临的问题有很多,比如:

  • A、css样式的全局性,造成命名冲突问题和管理困难
  • B、css样式缺乏编程能力,书写繁琐低效
  • C、css和javascript无法共享变量,导致样式管理混乱
  • D、浏览器快速更新迭代,版本层出不穷,兼容性实现愈发困难
  • E、css文件越来越庞大,加载速度越来越慢
  • F、css语法无法验证和风格不统一

这些就是长期以来,前端布局所面临的诸多问题。也正是伴随解决这些问题的过程,出现了很多新的技术方案。下面我们一起基于上述问题,筛选一下工程中需要用到的技术。

  • Stylelint前面已经讲述过,必选项,解决语法验证的问题
  • CSS3是必选项,它是原生的样式语言,也是最终产物
  • 我们需要一门预编译语言,增加编程能力,可以大幅度提高工作效率。如何选择,视团队情况而定
  • Postcss也是必选项,我不想自己去实现浏览器兼容性,效率低,易出错
  • 命名方法论,可以借鉴其思想,毕竟样式名称的可读性是很重要的
  • 原子CSS,tailwind.css是我极力推崇的工具,因为它可以解放90%的样式布局生产力,这一点就够了。之所以没选它,是因为我要在其他工程里面讲它
  • CSS in js 和 CSS Module取舍。两者都能解决命名空间和变量共享的痛点。但是我更愿意选择,接近原生处理模式的工具,因为可以以更少的代价复用css生态,语法高亮、语法提示、语法验证等等。无疑CSS module是更理想的选择。

4、选择结果

基于上述的分析,基于关键痛点和目标,例如,语法验证、命名空间、浏览器兼容、代码压缩、可编程、theme config、CSS Base等,我们筛选出,实现样式管理的目标工具。如下图:

基于vite2+react+typescript前端开发工程化(二)

三、工程搭建与配置

现在我们已经有了初步的搭建思路和相应的工具。首先,我们需要在vite配置中开启sass和css module。而实际上,vite一直秉承“开箱即用”原则。其默认配置,支持这两种需求。

1、配置sass生效

虽然,内置了.sass / .scss文件的编译功能,但还是需要安装相应的依赖。

yarn add sass -D

下面创建一个样式文件,试一下是否成功支持sass

cd src && mkdir styles

cd styles && mkdir themes

cd themes && touch base.scss

echo '.color-green { color: green; }' >> base.scss

编译结果:

.color-green { color: green; }

引入sass,是为了解决css编写低效率问题。基于sass的语法特性和可编程能力,一方面可以延续css的语法风格,开发者可以无障碍顺利上手编写sass;另一方面sass带来诸多的便利工具,例如:变量、逻辑判断、类型、语法嵌套、继承、混入、函数、内置工具等等。这些工具,大幅度减少了代码总量,大幅度提高了代码的复用性,减轻了代码管理负担的同时,大幅度提高了开发者的生产效率。

2、 配置css module生效

vite默认,任何以 .module.{css,sass,scss,less,stylus,styl} 为后缀名的 CSS 文件都被认为是一个 CSS modules 文件

看一下,vite的css module配置选项,设置scopeBehaviour='local',开启css module:

interface CSSModulesOptions {
  scopeBehaviour?: 'global' | 'local'
  globalModulePaths?: RegExp[]
  generateScopedName?:
    | string
    | ((name: string, filename: string, css: string) => string)
  hashPrefix?: string
  /**
   * 默认:null
   */
  localsConvention?:
    | 'camelCase'
    | 'camelCaseOnly'
    | 'dashes'
    | 'dashesOnly'
    | null
}

a、vite css module机制

导入符合 vite css module的文件会返回一个相应的模块对象。

重命名base.scss为base.module.scss

/* base.module.scss*/

.colorGrreen {
    color: green;
}

src目录下面创建components目录,用于存放项目中的公用组件。

cd src && mkdir components

创建Text组件,导入使用base.module.scss,目录结构如下

|--components
    |--text
        |--Text.ts
        |--text.module.scss

Text.js

import Styles from './text.module.scss
import Base from '@/styles/themes/base.module.scss'

console.log(Base)

const Text = ()=><div className={styles.colorGreen}> hello world </div>

console打印控制台结果得到一个映射对象:

{colorGreen: '_colorGreen_2x4kt_1'}

而Text组件最终编译成了

<div class='_colorGreen_2x4kt_1'> hello world </div>

看一下header,你会发现,多出了如下代码:

<style type="text/css">
._colorGreen_2x4kt_1 { color: green; } 
</style>

这段代码是从哪里来的呢?其实并不难猜测,这是vite css module机制作用的结果。

为了证明这一点,我们一起看一下network中vite css module加载机制。你会发现,base.module.scss文件是通过js的方式加载的。代码如下:

import { createHotContext as __vite__createHotContext } from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/styles/base.module.scss");
import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from "/@vite/client"

const __vite__id = "/Users/yangfei/workspace/react-ts-shop/src/styles/base.module.scss"
const __vite__css = "._colorGreen_2x4kt_1 {\n  color: green;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)
export const colorGreen = "_colorGreen_2x4kt_1";

export default {
    colorGreen: colorGreen,
};

import.meta.hot.prune(() => __vite__removeStyle(__vite__id))

上面的代码不难理解,整体来说做了以下几个事情:

  • 将css module文件编译成了es module,这样css文件就有实现模块化管理
  • 把每个css name编译成带hash的唯一值,避免样式冲突
  • 将最终的样式,inject到header里面,以供后面引用使用
  • 以es module的形式导出css name的映射文件,方便引用指定的样式
  • 增加hot reload,实时编译更新

b、自定义class name

开发环境,生成这种带hash的class name,可读性相对较差,不利于代码调试。那么如何配置生成,可读性比较强的css代码呢?经过多年的实践,命名方法论可读性最能体现样式的作用,其中具有代表性的BEM,虽然不能完全照搬BEM的实现,但是可以参照其思路,增强class name的可读性。

添加配置generateScopedName选项,dev环境生成可读性比较强的class name,便于调试:

css: {
    modules: {
        scopeBehaviour: 'local',
        generateScopedName: process.env.NODE_ENV === 'development' ?
        `[name]_[local]` :
        `_[local]-[hash:5]`,,
    }
}

上面的例子,生成的映射对象会变成:

{colorGreen: 'base-module_colorGreen'}

此外,generateScopedName选项支持回调函数,其返回值被用作相应的class name。基于这个特性,可以按照个人意愿,生成对应的规则的class name。

/**
 * @param name - class name
 * @param filename - 源文件全路径
 * @param css - 编译结果 css string
 **/
generateScopedName: ((name: string, filename: string, css: string) => string)

除此之外,还需要配置生成class name的命名风格、全局class name(不转译)、配置stylelint冲突等,都很简单,这里就不细讲了。重要的是,css module带来了模块化管理能力;解决css样式全局属性带来的命名冲突问题;以及相应程度上的css和js通信能力,例如共享变量问题。

3、theme变量配置管理

theme配置管理,也就是配置变量的管理。变量可以是基于sass变量,css变量链路,也可以是基于js变量链路。无论采用哪种变量,声明和维护都有成熟的管理体系。但是有一个很重要,且无法避免的问题——共享变量,css和js如何共享变量声明?下面我们讨论一下,基于sass链路和基于js链路管理theme变量的问题。

a、基于sass链路的theme变量配置管理

CSS In JS模式,是All In JS理念的一部分。天然具备CSS 和 JS的通信能力。但在CSS Module理念下面,实现和JS共享变量,却需要费一番功夫。可能你会很自然的想到,这样做:

themes文件夹下面创建vars.module.scss文件,并输入以下内容:

/* vars.module.scss */
$colorPrimary: orange;

在tsx文件里面使用:

import vars from './vars.module.scss'

const Text = ()=> <section style={{color: vars.colorPrimary}}>hello world</section>

但很遗憾,css module只会转译并导出class name,但不会导出sass变量。

不过,令人兴奋的是,css module提供了:export伪类,用于导出sass元素。我们可以改造一下上面的例子:

/* vars.module.scss */
$colorPrimary: orange;

:export {
    colorPrimary: $colorPrimary
}

我们顺利的拿到了导出的变量:

console.log(Vars)
// {colorPrimary:"orange"}

但这并不意味着,万事大吉。:export导出value只能是string类型。这意味着,导出数据必须经过stringify处理,并且只有一个层级。如果是复杂数据类型,Map、List等,甚至会出现数据嵌套,就不得不经过多次转换处理。

虽然,Map,List的stringify,以及数据格式化,会带来一定的挑战。但是基于sass管理theme的思路是行得通的。虽然,这种模式,需要我们对sass的语法掌握比较精通。

比如,下面sass map的转换,对sass语法的要求已经超出大部分前端开发者认知水平,即便这都是sass本身的api。

@function json-stringify($var) {
  $var-type: meta.type-of($var);
  $stringify-func-name: '_stringify-#{$var-type}';

  @if (function-exists($stringify-func-name)) {
    $stringify-func: meta.get-function($stringify-func-name);

    @return call($stringify-func, $var);
  }

  @return $var; // strings and booleans don't need to be modified
}

styles目录下面创建utils目录,用于存放sass util

cd src/styles && mkdir utils

创建stringify.scss,并输入实现代码

@function json-stringify($var) {
    // ...
}

@function stringify($var) {
  // ..
}

@function _stringify-string($string) {
  @return stringify($string);
}

@function _stringify-number($number) {
  // ...
}

@function _stringify-null($string) {
  // ...
}

@function _stringify-color($color) {
  @return stringify($color);
}

@function _stringify-list($list) {
  // ...
}

@function _stringify-map($map) {
  // ...
}

有了这些sass stringify工具,我们就可以将任意的sass变量:export向js文件,实现变量共享。

创建export.module.scss,用于导出所有vars.module.scss中声明的sass变量。

@import './vars.module';
@import './utils/stringify';

:export{
    colors: json-stringify($colors);
    spacings: json-stringify($spacings);
}

创建src/themes/themes.ts文件用于导出export.module.scss中的变量。

import sassVars from '@/styles/themes/export.module.scss'

export colors = jsonParse(sassVars.colors);

export spacings = jsonParse(sassVars.spacings);

//export  ....

export const themes = {colors, spacings}

export default themes

为了便于共享和使用这些变量,可以创建ThemeContext和对应的hooks

创建src/themes/context.ts文件

import React,{useContext} from 'react'
import themes from './themes.js' 
export const ThemeContext = createContext<ThemeContextProps>({}) 
export const useThemes = ()=>useContext(ThemeContext)

const ThemeProvider = ({children}) => (<ThemeContext.Provider value={themes}> 
    {children}
</ThemeContext.Provider>)

应用ThemeProvider之后,整上面的src/components/text/Text.ts代码

import {useThemes} from '@/themes/context.ts'

const Text = ()=> {
    const {colors} = useThemes()
    return (<section style={{color: colors.primary}}>hello world</section>)
}

最终相关目录结构:

|--src
    |--styles
        |--utils
            |--stringify.scss
        |--themes
            |--vars.module.scss
            |--export.module.scss
    |--components
        |--text
            |--Text.ts
            |--text.module.scss
    |--themes
        |--themes.ts
        |--context.ts
    // ...
    |--pages

// ...
|--vite.config.js

这就是基于sass链路的theme变量管理实现。

b、基于js变量或者json配置管理theme

虽然,sass的stringify会给我们带来一定的挑战,但是js的stringify对我们来说却很简单。

我们可以换个思路,我们把配置放进theme.config.js。然后,再把config里的配置转化成sass变量,这样做比sass转化js要简单的多。

而且vite的sass、less、stylus都提供了,注入变量的方法:

css: { 
    preprocessorOptions: { 
        scss: { 
            additionalData: `$injectedColor: orange;`, 
        }, 
        less: { 
            math: 'parens-division', 
        }, 
        styl: { 
            define: { 
                $specialColor: new stylus.nodes.RGBA(51, 197, 255, 1), 
            }, 
       }, 
  }, 
},

创建config/themes.config.js文件,像下面这样:

export default {
    colors: {
        primary: 'blue',
        warning: 'orange',
        error: 'red',
        success:'green'
    },
    // ...
}

相对应的,我们需要转化成colors的sass Map应该是这样:

$colors: (
    primary:blue,
    warning: orange,
    error: red,
    success: green
);

基于vite的相关配置additionalData,我们只需要,把js配置转化成对应格式的sass string即可。

我们把vite.config.js调整一下,

import themes from './config/themes.config.js'

const mapJSToSass = (origin):string => {
    return // origin to string
}

export default {
    css:{
        preprocessorOptions: {
            scss: {
              additionalData: `
                @use "sass:map";\n
                @use "sass:list";\n
                @use "sass:math";\n
                @use "sass:color";\n
                @use "sass:string" as string;\n\n
                
                $colors: ${mapJSToSass(themes.colors)};
                // ...
              `
            }
      }
    }
}

这样就可以在每个被加载的scss文件的头部注入 $colors 变量。

例如,我们可以在src/components/text/text.module.scss文件中直接使用$colors

.text {
    color: map.get($colors, primary);
}

而js文件是可以直接导入并使用themes.config.js。如此简单,我们就实现了变量共享。

稍加调整一下src/themes/context.ts文件,同样可以在任何组件中使用useThmes hooks

import React,{useContext} from 'react'
- import themes from 'themes.js' 
+ import themes from '@config/themes.config.js' 

// ...

需要注意的是只有经过@import 或者 import语法加载的scss文件,才会被注入$colors变量。而@use语法导入的scss文件,并不会注入变量。

总之,无论采用哪种思路,都可以解决js和css共享变量的问题,规范样式配置的统一管理。themes.config.js将是前端对接UI主通道。只要UI设计是规范且统一的,随之,前端布局也将实现规范统一。

4、原子化css

虽然,我们拥有了规范统一的配置,但是配置必须生成对应的样式,才能最终体现到界面上面。 虽然,我们可以借助组件化,实现配置变现。但是步子迈的太大,就会忽略细节。尽管组件化以及前端成熟的组件库,已经解决了大部分布局问题。但是真正的实践中,总是有很多细粒度的问题,比如,颜色、边距、字体大小、尺寸、行高等,困扰着我们。涉及到细粒度的元素的调整,既分散又不规范,且不易管理。因此,我们需要更细粒度的css样式,专门处理诸如此的问题。

我们可以,在sass变量的基础上,进一步生成相对应的css样式,用于细粒度的样式调整:

@each $key, $val in $colors {
  $name: capitalize($key);

  .fc#{$name} {
    color: $val;
  }

  // ...
}

生成以下代码:

.base__fcPrimary {
  color: #ff4b6d;
}
.base__fcSecondary {
  color: #fe8eaf;
}
// ...

如果进一步考虑到可能会需要动态定制主题。可以多生成一层runntime 变量:css vars

:root {
    @each $key, $val in $colors {
       @include decleare-css-var(('color', #{$key}), $val);
    }
}

@each $key, $value in $colors {
  $name: capitalize($key);
  $val: use-css-var(('color', #{$key}));

  .fc#{$name} {
    color: $val;
  }

  // ...
}

相应生成的css

:root{
  --color-primary: #ff4b6d;
  --color-secondary: #fe8eaf;
  // ...
}

.base__fcPrimary {
  color: var(--color-primary);
}
.base__fcSecondary {
  color: var(--color-secondary);
}
// ...

有了原子css,我们就拥有了快速布局的解决方案。虽然这并不是布局的最佳实践,但是基于theme配置基础,生成的基础样式,是规范有序且、可管理的。我们将散乱、随意的细粒度元素,全部收归到同一个基础源,从而实现统一管理。

import Base from '@/styles/base.module.scss'

export default const Page = () => (<div className={[Base.fcPrimary, Base.p12, Base.w120, Base.h120].join(' ')}>page info</div>)

5、base styles hooks

前面我们讨论了css变量到基本原子样式生成,为了方便使用,下面我们调整一下ThemeContext,useBaseCss hooks。

修改src/themes/context.ts文件

import themes from '@config/themes.config.js'
import Styles from '@/styles/base.module.scss'

const ThemeContext = createContext<AppContextProps>({ baseCss: {}, themes: {} })
const useThemeContext = ()=>useContext(ThemeContext)
const useThemes = ()=>{
    const {themes} = useThemeContext()
    return themes
}
const useBaseCss = ()=>{
    const {baseCss} = useThemeContext()
    return baseCss
}
const ThemeProvider = ({children}) => (<ThemeContext.Provider value={{ baseCss: Styles, themes }}>
    {children}
</ThemeContext.Provider>)

组件里应用:

const Test = ({color, width, height})=>{
    const { sizes, colors } = useThemes()
    const Base = useBaseCss()
    
    // padding 12
    return (<div className={Base.p12}>
        <Icon color={colors[color]} width={sizes[width]} height={sizes[height]}  />
    </div>)
}

6、base Component

虽然,我们有最佳的主题管理方案。但仅仅靠原子css布局,效率虽然很高,但是写起来很繁琐、冗余。还需要在此基础构建,通用且可复用的组件。比如,基础的layouts组件,文本组件,Flex组件等

以文本组件为例:

type TextProps = {
  size?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17
  line?: 'under' | 'over' | 'through' | 'blink' | 'none' | 'inherit'
  weight?: 'lightest' | 'lighter' | 'light' | 'normal' | 'medium' | 'bold' | 'bolder' | 'boldest'
  align?: 'center' | 'left' | 'right' | 'justify' | 'none' | 'inherit'
  italic?: boolean
} & HTMLAttributes<HTMLElement>

/**
 * 文本组件 props {@link TextProps}
 * @param param0
 * @returns
 */
export const Text: FC<TextProps> = ({
  children,
  size,
  color = '',
  className = '',
  italic = false,
  weight,
  style,
  align
}) => {
  const Base = useBaseCss()
  const classes = useMemo(() => {
    let c = [
      size ? Base[`text${size}`] : '',
      color ? Base[`fc${capitalize(color)}`] : '',
      weight ? Base[`fw${capitalize(weight)}`] : '',
      italic ? Base.italic : '',
      align ? Base[`al${capitalize(align)}`] : ''
    ]

    return c
  }, [size, color, italic,weight,align])

  return (
    <span className={cssx(classes, className)} style={style}>
      {children}
    </span>
  )
}

Icon组件为例:

import { useColors, useSizes } from '@/hooks/theme'

export interface IconProps {
  name: string
  color?: string
  size?: number | string
  prefix?: string
  style?: React.CSSProperties
  className?: string
}

const { prefix: appPrefix } = __CONFIG__

const Icon: FC<IconProps> = (props) => {
  let { name = '', color = 'grey3', size = 4, prefix = appPrefix, className, style } = props

  let { classes: colorClasses } = useColors({ fill: color })

  let { classes } = useSizes({
    width: size,
    height: size
  })

  return (
    <div className={cssx(IconCss.icon, classes, className)} style={style}>
      <svg className={cssx(classes, colorClasses)}>
        <use xlinkHref={`#${prefix}-${name}`}></use>
      </svg>
    </div>
  )
}

除了Text组件和Icon组件,布局常用的基础组件,还包括:

  • Box组件:基本容器,尺寸、边距、边框、圆角、背景等
  • FlexBox:Flex布局组件,主轴对齐、辅助轴对齐、主轴方向(行/列)、wrap,等
  • Image:图片组件
  • Table组件:行、列、分页、表头、排序
  • 等等

当然,最理想的方式,制定团队内部的UI规范,并在此基础开发一套UI组件库。如果,时间和精力都不允许,市场上也有很多成熟的组件库。而且,大多数组件库,都有自己的基础配置,跟theme.config.js适配并不是很难。

四、总结

这篇文章,我们讨论样式管理最佳实践。基本的思路,也就是本篇文章的写作顺序和思路,其实并不复杂。下面画了一个思维导图,算是一个总结吧。

基于vite2+react+typescript前端开发工程化(二)