普通视图

发现新文章,点击刷新页面。
昨天以前首页

鼠标跟随倾斜动效

作者 Mh
2026年4月21日 23:28

前言

最近在 gsap 上看到一个有趣的动效(Cursor-driven perspective tilt),于是决定自己实现一下,下面将介绍实现的过程,希望你能喜欢。

202604111231046.gif

观察动效

  1. 卡片的倾斜角度会随着鼠标的移入在 x 轴和 y 轴上向内进行倾斜。
  2. 卡片上的文字是悬浮在卡片,给人一种悬空在空中的错觉。

技术拆解

要实现这种 3D 的效果,在 css 中你首先想到的是什么?

在 CSS 中有三个属性实现 3D 效果至关重要。它们分别是 perspective、transform-style: preserve-3dtransform: rotateX() rotateY()。下面将详细的介绍他们在 3D 动效中的作用。

  1. perspective (透视/视距):它是 3D 的灵魂,如果没有它,你看到的效果看起来只像是在平面上进行拉伸和缩放。你可以理解它是3维空间中的z轴,定义观察者距离 z = 0平面的距离。通常设定在父容器上,数值越小(如500px),透视畸变越强烈(近大远小极度明显);数值越大(如 2000px),效果越平缓。
  2. transform-style: preserve-3d :它的作用是告诉子元素(文字层)也要保持在 3D 空间中,这样我们看到的容器的内容是有深度的,同时也可以在侧面看到元素与元素之间的距离。当父元素设置了transform-style: preserve-3d 的时候,同时子元素需要设置 transform: translateZ()。
  3. transform: rotateX() rotateY():这个属性相信大家都知道,这也是这次动效能实现的关键。rotateX 控制卡片绕水平轴转动,rotateY 控制卡片绕垂直轴转动。

总结一下

如果把 CSS 3D 比作一场电影:

  • perspective 是摄影机,决定了画面的纵深感。
  • transform-style: preserve-3d 是舞台搭建,决定了演员(元素)能不能在台前幕后来回走动,而不是画在背景板上。
  • transform: rotate / translate 是演员的动作,决定了物体怎么摆放和移动。

效果展示

如果你已经理解了上面属性,相信实现效果只是时间的问题,下面我就提前剧透一下效果吧!同时在浏览器中为你演示各个的属性的具体效果,让你更加深刻的理解上面的属性。

试想一下,如果没有设置 perspective 属性会怎么样呢?

为了更好的演示,我会将卡片绕着它的y轴固定旋转30度。然后对比设置了 perspective 属性和没有设置 perspective 的效果如下。

image.png

在对比了设置 perspective 的作用后,接下来为你演示 transform-style: preserve-3d 的效果,为了更好的演示,接下来调整一下卡片在y轴的旋转角度为-80度,同时对子元素设置 transform: translateZ(50px); 将背景调整为白色,让文字和背景不会重合。对比效果如下:

image.png

从上面的效果可以看出,设置了 transform-style: preserve-3d 的文字和背景卡片是分离的,没有设置 transform-style: preserve-3d 的文字被拍扁在卡片上面。

注意事项: 当容器设置了 transform-style: preserve-3d; 的时候,不能再设置 overflow: hidden; 不然 transform-style: preserve-3d; 不会生效。

经过上面的对比可以帮助我们更好的理解每个属性在具体场景中的使用,下面就使用 vue3 去实现具体的功能。

代码拆解

完整代码

<template>
  <div class="container">
    <div 
      class="card"
      ref="cardRef"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="content">
        <span>ANIMATION</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';

const cardRef = ref(null);

// 存储旋转角度
const transform = reactive({
  rotateX: 0,
  rotateY: 0
});

// 计算最终的 CSS 样式
const cardStyle = computed(() => {
  const scale = 1;
  return {
    transform: `rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg)`,
    transition: 'transform 0.5s ease-out'
  };
});

const handleMouseMove = (e) => {
  if (!cardRef.value) return;

  const rect = cardRef.value.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  
  // 计算鼠标距离中心点的偏移量 (-1 到 1)
  const percentX = (e.clientX - centerX) / (rect.width / 2);
  const percentY = (e.clientY - centerY) / (rect.height / 2);

  const deg = 25; // 最大旋转角度
  transform.rotateY = percentX * deg;
  transform.rotateX = -percentY * deg; // 取反是因为鼠标向上移动时图片应向下倾斜
};

const handleMouseLeave = () => {
  transform.rotateX = 0;
  transform.rotateY = 0;
};
</script>

<style scoped>
.container {
  /* 3D 透视的关键 */
  perspective: 1000px; 
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  background-color: #0f0f0f;
}

.card {
  position: relative;
  width: 320px;
  height: 200px;
  background: linear-gradient(135deg, #6ee7b7, #3b82f6);
  border-radius: 20px;
  transform-style: preserve-3d;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* overflow: hidden; */
}

.content {
  font-family: 'Arial Black', sans-serif;
  font-size: 2.5rem;
  color: #000;
  /* 让文字在 3D 空间悬浮 */
  transform: translateZ(50px); 
  pointer-events: none;
}
</style>

简要分析:

  1. 绑定事件:鼠标移入卡片触发 mousemove 事件,设置卡片旋转。鼠标移除触发 mouseleave 事件将旋转的角度置为0。
  2. 样式动态计算:动态绑定 style,通过计算属性实时更新旋转的角度。
  3. 计算偏移量: 这里主要利用鼠标当前的位置减去卡片中心点计算出偏移距离,然后再除以卡片宽高的一半,等到一个-1到1的偏移值。
  4. 角度映射:通过得到的偏移值乘以 deg (25度),刚好可以映射到对应的角度,比如鼠标移动到最左边,卡片正好偏转 -25度。

优化补充

下面是一些优化的建议,有兴趣的同学可以自己实现一下:

  1. 增加光影变化,跟随鼠标移动的卡片增加渐变层的光影,让整体更加真实。
  2. mousemove 在移动端不支持,增加移动端的支持。

Element UI 实践踩坑- date-picker 组件 定制化type="daterange"

作者 lemon_yyds
2026年4月21日 12:12

针对 datetime-pickerel-date-picker 设置 type="daterange" 修改选中颜色,通常涉及两个部分的样式修改:

  1. 输入框边框颜色:即选中该组件时,外围边框的高亮颜色。
  2. 日历面板选中颜色:即点击日期后,日历面板上日期范围的背景色。

由于不确定你具体使用的是 Element UI (Vue 2) 还是 Element Plus (Vue 3) ,我为你整理了这两种情况下的解决方案。

1. 修改输入框边框颜色 (Focus 状态)

当输入框获得焦点(被选中)时,Element UI 默认会有一个蓝色的外边框或阴影。你可以通过覆盖 CSS 变量或直接修改类名来改变它。

Element Plus (Vue 3)

Element Plus 推荐使用 CSS 变量来修改,这样更规范且容易维护。

/* 修改选中时的边框颜色 */
.el-date-picker {
  --el-input-focus-border-color: #FF6A00; /* 你想要的颜色 */
}

/* 如果是范围选择器,可能需要强制覆盖 box-shadow */
:deep(.el-range-editor.is-active) {
  box-shadow: 0 0 0 1px #FF6A00 inset !important;
}

Element UI (Vue 2)

Element UI 通常需要通过深度选择器来修改边框颜色。

/* 修改边框颜色 */
/deep/ .el-range-editor.is-active {
  border-color: #FF6A00;
}

/* 修改阴影颜色 (部分版本生效) */
/deep/ .el-range-editor.is-active {
  box-shadow: 0 0 2px 0 rgba(255, 106, 0, 0.5); 
}

2. 修改日历面板选中范围颜色

这部分比较特殊,因为日期选择器的下拉面板默认是挂载在 body 下的,而不是组件内部。因此,普通的 scoped 样式可能无法生效。

关键步骤: 你需要给 datetime-picker 添加 popper-class 属性,以便定位到下拉面板。

第一步:HTML 模板设置

<el-date-picker
  v-model="dataValue"
  type="daterange"
  range-separator="至"
  start-placeholder="开始日期"
  end-placeholder="结束日期"
  popper-class="custom-date-range-picker" <!-- 添加这个自定义类名 -->
  :teleported="false" <!-- Element Plus 建议加上这个,防止挂载到 body 导致样式难穿透 -->
>
</el-date-picker>

第二步:CSS 样式设置

你需要使用全局样式(去掉 scoped)或者使用深度选择器配合 popper-class 来修改选中背景色。

/* 针对 Element UI / Element Plus 通用逻辑 */

/* 1. 选中范围的背景色 (开始日期和结束日期中间的区域) */
.custom-date-range-picker .el-date-table td.in-range div,
.custom-date-range-picker .el-date-table td.in-range div:hover {
  background-color: #FFE0B2; /* 浅色背景 */
}

/* 2. 开始日期和结束日期的选中圆点/背景 */
.custom-date-range-picker .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
  background-color: #FF6A00; /* 深色主色调 */
  color: #fff;
}

/* 3. 鼠标悬停时的颜色 */
.custom-date-range-picker .el-date-table td:hover:not(.in-range) div {
  color: #FF6A00;
}

3. 常见问题排查

如果你发现样式写了但不生效,请检查以下几点:

  1. 样式作用域

    • 如果你使用的是 <style scoped>,必须使用深度选择器,如 ::v-deep (Vue 2/3) 或 :deep() (Vue 3)。
    • 最稳妥的方法:将上述 CSS 代码放在一个不包含 scoped<style> 标签中,或者放在全局 CSS 文件中。
  2. 挂载位置

    • 如果下拉面板挂载在 body 上,你的组件 scoped 样式是绝对无法直接穿透的。务必使用 popper-class 属性来“钩住”下拉面板。
  3. 优先级

    • 如果样式依然不生效,可以在 CSS 属性后加上 !important 强制覆盖。

总结表格

修改目标 Element Plus (Vue 3) 推荐写法 Element UI (Vue 2) 推荐写法
输入框边框 修改 --el-input-focus-border-color 变量 覆盖 .el-range-editor.is-activeborder-color
下拉面板样式 添加 popper-class + :teleported="false" 添加 popper-class + 全局 CSS
选中背景色 覆盖 .el-date-table td.in-range div 同上

CSS作用域穿透选择器

2026年4月20日 16:44

:deep

名称:CSS作用域穿透选择器

核心作用

  1. 穿透样式隔离 允许父组件样式影响子组件的深层元素,解决组件化开发中样式被隔离的问题。
  2. 覆盖第三方组件样式 当使用 UI 库(如 Element UI、Ant Design)时,直接修改子组件内部元素的样式。

vue3语法 新版语法 ::v-deep:deep

:deep称为CSS作用域穿透选择器,在vue中,默认有样式作用域隔离(通过<style scoped>)。若需要覆盖子组件样式,需用 ::v-deep(Vue 3 推荐,取代废弃的 /deep/>>>)。

/* Vue 3 语法 */
::v-deep(.child-class) {
  color: red;
}

/* 简写(某些预处理器如 SASS 可能需要括号) */
:deep(.child-class) {
  color: red;
}

vue2语法 旧版语法 /deep/>>>

/* Vue 2 或旧版语法 */
.parent-class /deep/ .child-class {
  color: red;
}

/* 或 */
.parent-class >>> .child-class {
  color: red;
}

注意事项

  1. 谨慎使用 过度使用会破坏组件独立性,应优先通过组件接口(如 Props)或 CSS 变量(--custom-color)修改样式。
  2. 替代方案 现代框架推荐通过 CSS 变量或 :global(如 CSS Modules)实现样式共享。
  3. 废弃语法 /deep/>>>、Vue 3 推荐 ::v-deep

THREE.JS实现一个魔法镜子!

作者 苏武难飞
2026年4月20日 08:44

分享一个THREE.JS实现的魔法镜子效果!

最近依然在学习THREE.JS发现了一个比较有意思的方法renderTarget,借助这个方法我们能实现很多有意思的效果

13

初始renderTarget

简单来说,RenderTarget(渲染目标) 就像是给相机准备的一张“离屏画布”或“隐藏显示器”。

以下是它的核心概念:

  1. 它是干什么的?

平时渲染时,相机拍到的画面直接显示在你的显示器屏幕上。 而使用 RenderTarget 时,相机拍到的画面被画在了一张内存中的纹理(Texture) 上。这被称为“离屏渲染”。

  1. 为什么要用它? 当你需要“在 3D 场景里显示 3D 场景”时,它是必不可少的。
  • 后处理效果:先把场景拍下来,加上模糊或调色滤镜,再贴到屏幕上。

  • 镜面与传送门:用一个虚拟相机拍下镜子背后的场景存入 RenderTarget,然后把这张图贴在镜子的平面上。

  • 动态贴图:比如做一个监控显示屏,画面是另一个房间的实时录像。

React Three Fiber中使用的方法如下

const mainRenderTarget = useFBO();

useFrame((state) => {
  const { gl, scene, camera } = state;

  gl.setRenderTarget(mainRenderTarget);
  gl.render(scene, camera);

  mesh.current.material.map = mainRenderTarget.texture;

  gl.setRenderTarget(null);
});

为什么要放到useFrame中呢,是因为我们需要绘制每一帧的状态并把当前的状态当作纹理传递给几何体

先从一个简单的例子开始🌰



const InfinityMirror = () => {
    const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);

    const renderTarget = useFBO();

    useFrame((state) => {
        const {gl, scene, camera} = state;

        if (mesh.current) {
            mesh.current.material.map = null;
        }

        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

        if (mesh.current) {
            mesh.current.material.map = renderTarget.texture;
        }

        gl.setRenderTarget(null);
    });


    return (
        <>
            <Sky sunPosition={[10, 10, 0]}/>
            <directionalLight position={[10, 10, 0]} intensity={1}/>
            <ambientLight intensity={0.5}/>
            <Environment preset="sunset"/>
            <mesh position={[-2, 0, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[0, 2, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[2, 0, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[0, -2, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={mesh} scale={1}>
                <planeGeometry args={[2, 2]}/>
                <meshBasicMaterial/>
            </mesh>
        </>
    );
};



function App() {

    return <Canvas camera={{position: [0, 0, 9]}} dpr={[1, 2]}>
        <InfinityMirror/>
        <OrbitControls autoRotate={false}/>
        <GizmoHelper alignment="bottom-right" margin={[80, 80]}>
            <GizmoViewport axisColors={['red', 'green', 'blue']} labelColor="white" />
        </GizmoHelper>
    </Canvas>
}

20260415143110

核心是利用renderTarget把当前的屏幕内容当作纹理传递给了我们的planeGeometry,逻辑图如下

01

画外渲染

目前我们已经知道renderTarget是使用渲染目标来拍摄当前场景的快照,并将结果用作纹理,那么我们是不是可以考虑用到createPortal来渲染一个不在当前屏幕中的场景呢!使用createPortal的基本语法如下

import { Canvas, createPortal } from '@react-three/fiber';
import * as THREE from 'three';

const Scene = () => {
  const otherScene = new THREE.Scene();

  return (
    <>
      <mesh>
        <planeGeometry args={[2, 2]} />
        <meshBasicMaterial />
      </mesh>
      {createPortal(
        <mesh>
          <sphereGeometry args={[1, 64]} />
          <meshBasicMaterial />
        </mesh>,
        otherScene
      )}
    </>
  );
};

还是通过一个例子来学习

const Portal = () => {

    const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);
    const otherMesh = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
    const otherCamera = useRef<THREE.PerspectiveCamera>(null);
    const otherScene = new THREE.Scene();

    const renderTarget = useFBO();


    useFrame((state) => {
        const {gl, clock, camera} = state;
        if (otherCamera.current) {
            otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
        }

        gl.setRenderTarget(renderTarget);

        if (otherCamera.current) {
            gl.render(otherScene, otherCamera.current);
        }

        if (mesh.current) {
            mesh.current.material.map = renderTarget.texture;
        }

        if (otherMesh.current) {
            otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
            otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
            otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
        }

        gl.setRenderTarget(null);
    });


    return (
        <>
            <PerspectiveCamera
                manual
                ref={otherCamera}
                aspect={1.5 / 1}
            />
            {createPortal(
                <>
                    <Sky sunPosition={[10, 10, 0]}/>
                    <Environment preset="sunset"/>
                    <directionalLight args={[10, 10, 0]} intensity={1}/>
                    <ambientLight intensity={0.5}/>
                    <ContactShadows
                        frames={1}
                        scale={10}
                        position={[0, -2, 0]}
                        blur={8}
                        opacity={0.75}
                    />
                    <group>
                        <mesh ref={otherMesh}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                        <mesh position={[-3, 1, -2]}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                        <mesh position={[3, -1, -2]}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                    </group>
                </>,
                otherScene
            )}
            <mesh ref={mesh}>
                <planeGeometry args={[3, 2]}/>
                <meshBasicMaterial color="white"/>
            </mesh>
        </>
    );

}

02

这里有几个重点

  1. PerspectiveCamera因为是画外渲染所以我们必须再建立一个摄影机
  2. otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);新建立的摄影机视角和外部主视角保持一致
  3. <PerspectiveCamera aspect={1.5 / 1}/> 1.5 / 1 是为了和 <planeGeometry args={[3, 2]}/> 保持一致

我们这里讨论一下重点3,如果我们把画外场景的摄影机比例改变了呢!

// <PerspectiveCamera aspect={1.5 / 1}/>
<PerspectiveCamera aspect={1 / 1}/>

03

可以很明显的看到我们的纹理比例被压缩了,这也引申出了另一个问题

uv坐标 or 屏幕坐标

首先我们还是先看一张示意图

04

我们现在使用的就是默认的UV坐标,我们生成的纹理图会自动的根据几何体的宽高来压缩以适配我们的UV坐标

如果我们要实现一个屏幕坐标需要几个步骤

  1. 实现自定义着色器
  2. uniform来传递纹理和屏幕坐标

1. 实现自定义着色器


<mesh ref={mesh}>
                <planeGeometry args={[3, 2]}/>
                <meshBasicMaterial color="white"/>
</mesh>

// 👆之前我们一直用的是meshBasicMaterial现在需要改成 

<mesh ref={mesh}>
    <planeGeometry args={[3, 2]}/>
    {/*<meshBasicMaterial color="white"/>*/}

    <shaderMaterial
        fragmentShader={fragmentShader}
        vertexShader={vertexShader}
        uniforms={uniforms}/>
</mesh>


// 顶点着色器vertexShader
void main() {
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    vec4 mvPosition = viewMatrix * worldPos;
    gl_Position = projectionMatrix * mvPosition;
}

// fragmentShader
uniform vec2 winResolution;
uniform sampler2D uTexture;

void main() {
    vec2 uv = gl_FragCoord.xy / winResolution.xy;
    vec4 color = texture2D(uTexture, uv);
    gl_FragColor = color;
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

这里我们的顶点着色器vertexShader就是默认的几何体空间定位设置,我们重点看一下fragmentShader

  • gl_FragCoord.xy 几何体在屏幕空间的位置
  • winResolution 通过uniform传递进来的屏幕大小
  • uTexture 通过uniform传递进来的纹理

所以我们现在uv计算逻辑就变成了几何体在屏幕空间中的坐标位置百分比!

2. 用uniform来传递纹理和屏幕坐标


    const uniforms = useMemo(() => ({
            uTexture: {value: null,},
            winResolution: {
                value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
            },
        }),
        []
    );


    useFrame((state) => {
        const {gl, clock, camera} = state;
        if (otherCamera.current) {
            otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
        }

        gl.setRenderTarget(renderTarget);

        if (otherCamera.current) {
            gl.render(otherScene, otherCamera.current);
        }

        if (mesh.current) {
            mesh.current.material.uniforms.uTexture.value = renderTarget.texture;
            mesh.current.material.uniforms.winResolution.value = new THREE.Vector2(
                window.innerWidth,
                window.innerHeight
            ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
        }

        if (otherMesh.current) {
            otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
            otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
            otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
        }

        gl.setRenderTarget(null);
    });


屏幕坐标

调整一下几何体和摄影机看的更明显

...
...
...

 <PerspectiveCamera
                        makeDefault
                        manual
                        ref={otherCamera}
                        position={[0, 0, 8]}
                    />

   <mesh ref={mesh}>
                <boxGeometry args={[3, 3,3]}/>
                {/*<meshBasicMaterial color="white"/>*/}

                <shaderMaterial
                    fragmentShader={fragmentShader}
                    vertexShader={vertexShader}
                    uniforms={uniforms}/>

   </mesh>

05

一起来使用魔法吧!

我们的基础已经打完了,接下来我们就正式开始我们的魔法教程!

06

之前我们都是把整个的纹理绘制到几何体中,在这一章节我们开始使用动态的纹理图并把其传递给几何体

const Lens2: React.FC = () => {

    const mesh1 = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
    const lens = useRef<THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial>>(null);
    const renderTarget = useFBO();

    const uniforms = useMemo(
        () => ({
            uTexture: {value: null,},
            winResolution: {
                value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
            },
        }),
        []
    );

    useFrame((state) => {
        const {gl, clock, scene, camera, pointer} = state;

    });

    return (
        <>
            <Sky sunPosition={[10, 10, 0]}/>
            <Environment preset="sunset"/>
            <directionalLight position={[10, 10, 0]} intensity={1}/>
            <ambientLight intensity={0.5}/>
            <ContactShadows
                frames={1}
                scale={10}
                position={[0, -2, 0]}
                blur={4}
                opacity={0.2}
            />
            <mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
                <sphereGeometry args={[1, 128]}/>
                <shaderMaterial
                    fragmentShader={fragmentShader}
                    vertexShader={vertexShader}
                    uniforms={uniforms}
                    wireframe={false}
                />
            </mesh>
            <group>
                <mesh ref={mesh1}>
                    <dodecahedronGeometry args={[1]}/>
                    <meshPhysicalMaterial
                        roughness={0}
                        clearcoat={1}
                        clearcoatRoughness={0}
                        color="#73B9ED"
                    />
                </mesh>
            </group>
        </>
    );
}

这是一个基础模板代码,此时的效果是

07

1. 使用 MeshTransmissionMaterial

MeshTransmissionMaterialThree.js 生态中(特别是在 react-three-drei 库中)非常受欢迎的一种高级材质,它主要用于模拟毛玻璃、塑料、水、或变色玻璃等具有物理感的高级透明效果。

相比于原生的 MeshPhysicalMaterial,它的性能更优化,且效果更具“数字美感”。


...
...

    useFrame((state) => {
        const {gl, clock, scene, camera, pointer} = state;

        const viewport = state.viewport.getCurrentViewport(state.camera, [0, 0, 2.5]);

        if (!lens.current) return;

        lens.current.position.x = THREE.MathUtils.lerp(
            lens.current.position.x,
            (pointer.x * viewport.width) / 2,
            0.1
        );
        lens.current.position.y = THREE.MathUtils.lerp(
            lens.current.position.y,
            (pointer.y * viewport.height) / 2,
            0.1
        );

        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

        gl.setRenderTarget(null);

    });

...
...
...
 <mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
                <sphereGeometry args={[1, 128]}/>
                <MeshTransmissionMaterial
                    buffer={renderTarget.texture}
                    ior={1.025}
                    thickness={0.5}
                    chromaticAberration={0.05}
                    backside/>
            </mesh>

08

2. 保存瞬时状态!

useFrame中我们是根据用户设备刷新率进行刷新的如1秒60次调用,而我们的

 gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

是保留当前帧的状态,所以我们可以利用这个特性做很多事情!

 mesh1.current.material.wireframe = true;

 // 👇保留线框状态
        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

// 👇取消线框状态保证用户不能直接看到线框
        mesh1.current.material.wireframe = false;

        gl.setRenderTarget(null);

09

可以看到此时我们的效果看起来就像是鼠标滑过的地方直接展示了几何体的内部!!

3. 魔法筒

有了上面的基础我们再来画一个示意图看我们的魔法筒效果应该如何实现

图片来自Beautiful and mind-bending effects with WebGL Render Targets

图片来自Beautiful and mind-bending effects with WebGL Render Targets

也就是说我们利用两个圆柱体并且两个圆柱体的纹理分别使用小球和圆锥

  • 圆柱A-纹理使用小球,所以视角在圆柱A时看不到圆锥只能看到小球
  • 圆柱B-整体和圆柱A相反
 return <>
        <Sky sunPosition={[10, 10, 0]}/>
        <Environment preset="sunset"/>
        <directionalLight position={[10, 10, 0]} intensity={1}/>
        <ambientLight intensity={0.5}/>
        <group ref={groupRef}>
            <mesh
                ref={cylinder1}
                position={[0, 0, -4]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <meshStandardMaterial
                    color="green"
                    transparent
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    color="red"
                    transparent
                />
            </mesh>
            <mesh>
                <torusGeometry args={[3, 0.2, 16, 100]}/>
                <meshStandardMaterial color="#F9F9F9"/>
            </mesh>
        </group>
    </>

20260416173256

圆柱体本身是由以下形状组成的

  • 顶部
  • 柱体
  • 底部

所以我们可以分别设置纹理!

<mesh
                ref={cylinder2}
                position={[0, 0, 4]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>

10

接下来旋转一下!

 <mesh
                ref={cylinder1}
                position={[0, 0, -4]}
                rotation={[-Math.PI / 2, 0, 0]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
                rotation={[Math.PI / 2, 0, 0]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>

11

接下来我们暂时先把这两个圆柱体隐藏,来做一个几何体的移动效果



    useFrame((state, delta) => {

        const {gl, scene, camera, clock} = state;

        const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
        boxRef.current!.position.z = newPosZ;
        torusRef.current!.position.z = newPosZ;

        boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

        torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

    });



<mesh ref={torusRef} position={[0, 0, 0]}>
                <torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={boxRef} position={[0, 0, 0]}>
                <boxGeometry args={[2, 2, 2]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"/>
            </mesh>

12

接下来就是见证奇迹的时候了!我们要运用我们之前保留关键帧的模式来动态的visible模型

const TransformPortal2: React.FC = () => {

    const groupRef = React.useRef<THREE.Group>(null)
    const boxRef = React.useRef<THREE.Mesh<THREE.BoxGeometry, THREE.MeshPhysicalMaterial>>(null)
    const torusRef = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.MeshPhysicalMaterial>>(null)
    const cylinder1 = React.useRef<THREE.Mesh<THREE.CylinderGeometry, THREE.ShaderMaterial[]>>(null)
    const cylinder2 = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.ShaderMaterial[]>>(null)


    const renderTarget1 = useFBO();
    const renderTarget2 = useFBO();

    const uniforms = useMemo(() => ({
        uTexture: {
            value: null,
        },
        winResolution: {
            value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
        },
    }), []);


    useFrame((state, delta) => {

        const {gl, scene, camera, clock} = state;

        if (cylinder1.current) {
            cylinder1.current.material.forEach((material) => {
                if (material.type === "ShaderMaterial") {
                    material.uniforms.winResolution.value = new THREE.Vector2(
                        window.innerWidth,
                        window.innerHeight
                    ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
                }
            });
        }


        cylinder2.current!.material.forEach((material) => {
            if (material.type === "ShaderMaterial") {
                material.uniforms.winResolution.value = new THREE.Vector2(
                    window.innerWidth,
                    window.innerHeight
                ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
            }
        });

        if (torusRef.current) {
            torusRef.current.visible = false;
        }
        if (boxRef.current) {
            boxRef.current.visible = true;
        }
        gl.setRenderTarget(renderTarget1);
        gl.render(scene, camera);


        if (torusRef.current) {
            torusRef.current.visible = true;
        }
        if (boxRef.current) {
            boxRef.current.visible = false;
        }

        gl.setRenderTarget(renderTarget2);
        gl.render(scene, camera);

        gl.setRenderTarget(null);

        const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
        boxRef.current!.position.z = newPosZ;
        torusRef.current!.position.z = newPosZ;

        boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

        torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

    });


    return <>
        <Sky sunPosition={[10, 10, 0]}/>
        <Environment preset="sunset"/>
        <directionalLight position={[10, 10, 0]} intensity={1}/>
        <ambientLight intensity={0.5}/>
        <group ref={groupRef}>
            <mesh
                ref={cylinder1}
                position={[0, 0, -4]}
                rotation={[-Math.PI / 2, 0, 0]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget1.texture,
                        },
                    }}
                    attach="material-0"
                />
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget1.texture,
                        },
                    }}
                    attach="material-1"
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                    opacity={0}
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
                rotation={[Math.PI / 2, 0, 0]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget2.texture,
                        },
                    }}
                    attach="material-0"
                />
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget2.texture,
                        },
                    }}
                    attach="material-1"
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                    opacity={0}
                />
            </mesh>
            <mesh>
                <torusGeometry args={[3, 0.2, 16, 100]}/>
                <meshStandardMaterial color="#F9F9F9"/>
            </mesh>
            <mesh ref={torusRef} position={[0, 0, 0]}>
                <torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={boxRef} position={[0, 0, 0]}>
                <boxGeometry args={[2, 2, 2]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"/>
            </mesh>
        </group>
    </>


}

13

参考资料

Beautiful and mind-bending effects with WebGL Render Targets

运行时vs编译时:CSS in JS四种主流方案介绍和对比

作者 漂流瓶jz
2026年4月18日 18:44

CSS作为前端代码中的重要组成部分,在工程中一般是以独立CSS文件的形式存在的。而CSS in JS,顾名思义,是在JavaScript中写CSS代码。尤其是React框架的流行,JavaScript和HTML模板都在JavaScript文件中描述了,只有CSS代码的组织还比较疏离。因此出现了很多CSS in JS的开源库,帮助我们将CSS放到JavaScript代码中,实现React组件代码的耦合性。

React工程示例

首先我们先展示一下React工程中是如何使用CSS的,以及React本身有没有CSS in JS的能力。

不使用CSS in JS

首先使用Vite创建React工程,执行命令行:

# 选择React
npm create vite@latest
# 安装依赖
npm install
# 启动开发服务
npm run dev

然后将src/App.jsx文件修改为如下内容,这是一个React组件。

import "./App.css";
export default function App() {
  return <div className="class1">你好 jzplp</div>;
}

然后是对应的src/App.css内容:

.class1 {
  color: red;
  font-size: 15px;
}

打开浏览器,可以看到对应的组件样式是生效的。这就是普通React工程中CSS的使用方式,在独立文件中被引用。像CSS Modules, Less和SCSS等也都是类似这种使用模式。那如果CSS样式本身需要根据不同的场景变化呢?可以根据不同的场景提供不同的类名。这里修改下App.jsx作为示例,就不展示App.css文件了。

import "./App.css";
export default function App({ state }) {
  return <div className={state === 1 ? "class1" : "class2"}>你好 jzplp</div>;
}

内联样式

使用类名控制样式变化可以生效,但这算是间接控制样式。有没有直接可以在JavaScript代码中控制CSS的方式呢?有的,React提供了内联样式,可以让我们直接控制:

import "./App.css";
export default function App({ state }) {
  return (
    <div
      style={{
        color: state === 1 ? "red" : "blue",
        fontSize: "14px",
      }}
    >
      你好 jzplp
    </div>
  );
}

对style属性设置为对象,对象的key是CSS属性驼峰形式,可以实现对HTML中内联样式的直接控制。看起来这样挺好用的,但是它的限制还是非常大。例如不能使用伪类或者媒体查询这种CSS规则。

直接操作DOM可以使用CSS规则,但这不优雅也失去了使用React的优势。因此,如果想要实现真正的CSS in JS,还是要看专门的工具。

styled-components初步

styled-components是最知名的CSS in JS工具,可以在React中使用。

接入方式

首先安装依赖styled-components,然后删除App.css,我们不再需要独立的CSS文件了。修改App.tsx:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <Button>你好 jzplp</Button>
    </div>
  );
}

此时在浏览器上可以看到生效的结果。通过代码可以看到,styled-components是利用了‌ECMAScript中模板字符串的“标签模板字符串”特性。因此我们提供的CSS字符串可以被styled-components对应的函数解析,最终生成样式。

实现方式

上面的代码是如何生效的呢?这里我们修改一下代码,增加不同状态:

import styled from "styled-components";
import { useState } from "react";

const Div0 = styled.div``;
const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div0>1你好 jzplp</Div0>
      <Div>2你好 jzplp</Div>
      {state % 2 === 1 && <Button>3你好 jzplp</Button>}
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

css-in-js-1.png

首先看下初始状态的效果。这里有Div0和Div两个组件,都是用styled-components生成的,因此都有一个sc-开头的类名,但这个类名上并不包含样式。Div因为有了样式,所以有另一个类名,这个类名的具体样式写在head中的style标签里面。Button组件因为没有展示,所以对应的样式也没有注入。我们再切换状态试试:

css-in-js-2.png

切换状态之后,Button组件展示并附带着样式。注意style标签中增加了一行,正是Button的样式。此时如果再次切换状态将Button组件销毁,style标签中对应的样式并不会删除。

通过对于浏览器现象的观察,我们发现了styled-components的实现方式:当组件被渲染时,将JavaScript中的CSS属性集合放到style标签中,同时动态提供hash类名。将类名提供给HTML标签作为属性渲染。这样就实现了JavaScript控制CSS代码,且在组件被渲染时才注入CSS。此时我们执行如下命令:

npm run build
npm run preview

然后查看打包后生产模式的效果,发现style标签中并没有CSS代码了,但样式还是生效的,也是通过hash类名。且在浏览器调试工具上点击类的来源,也能点到那个style标签,只不过浏览器上看不到其中的内容。

传参方式

前面的例子中CSS代码全是字符串,但使用模板字符串除了能换行之外,好像也没有什么优势。使用标签模板字符串的优势在于传参。组件可以根据不同的传参切换样式:

import styled from "styled-components";

interface DivProps {
  bgColor: string;
  lineHeight?: number;
}

const Div = styled.div<DivProps>`
  color: red;
  background: ${props => props.bgColor};
  font-size: 14px;
  line-height: ${props => props.lineHeight || 20}px;
`;

export default function App() {
  return (
    <div>
      <Div bgColor="blue" lineHeight={30}>2你好 jzplp</Div>
      <Div bgColor="yellow">2你好 jzplp</Div>
    </div>
  );
}

可以看到,首先我们在styled对应标签的函数中增加了props入参的泛型TypeScript类型。然后在模板字符串的插值中传入函数,函数的入参为props,返回对应场景下的CSS代码值。由于我们使用的“标签模板字符串”功能,因此标签函数可以读取并处理模板字符串中的插值,最后整合成完整的CSS代码。我们看下浏览器效果:

css-in-js-3.png

可以看到,不同的入参会生成不同的类名。这里我有一个疑问,如果我们的入参一直在变化,会不会一直生成类名?我们试一下:

import styled from "styled-components";
import { useState } from "react";

interface DivProps {
  color: number;
}

const Div = styled.div<DivProps>`
  color: #${(props) => props.color};
`;

export default function App() {
  const [state, setState] = useState(0);
  return (
    <div>
      <Div color={state}>你好 {state}</Div>
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

在上面代码中,点击一下state值加1,同时Div中的入参也会变化,生成的CSS值也会不一样。我们多点几次看看效果:

css-in-js-4.png

通过浏览器效果可以看到,我们每点击一次,就会生成一个新的类名和CSS规则。旧的CSS规则虽然永远不会被使用到了,但依然保存在浏览器中。不过代码其实不知道我们的CSS规则今后会不会被使用到。

styled-components特性

前面我们引入了styled-components,简单介绍了实现方式和传参。这里再介绍一下更多特性。

组件继承

类似于面向对象,使用styled-components生成的组件也有继承特性,子组件可以继承父组件的样式。我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
`;
const DivChild = styled(Div)`
  background: yellow;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <DivChild>你好 jzplp</DivChild>
      <Div as="p">你好 jzplp</Div>
    </div>
  );
}

我们定义了Div父组件,DivChild继承并提供了自己的样式,通过结果可以看到,两个样式都生效了。第三个组件我们使用了as属性,它可以在使用预定义styled组件的同时,修改标签名。

css-in-js-5.png

父组件的props参数,子组件也是继承的,同时子组件也可以有自己的参数。我们看下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size:${(props) => props.size}px;
`;
const DivChild = styled(Div)`
  background: yellow;
  line-height: ${(props) => props.size + 10}px;
  border: ${(props) => props.borderSize}px solid blue;
`;

export default function App() {
  return (
    <div>
      <Div size={20}>你好 jzplp</Div>
      <DivChild size={30} borderSize={2}>你好 jzplp</DivChild>
    </div>
  );
}

css-in-js-6.png

通过结果可以看到,组件的参数和子组件都有自己的参数,子组件也可以使用父组件的参数,可以同时生效。

与React组件继承

前面我们看到的是styled组件互相的继承关系。事实上,它与普通React组件也可以互相继承。首先是普通React组件作为父组件:

import styled from "styled-components";

function Comp({ state, className }) {
  return <div className={className}>{state}</div>;
}

const DivComp = styled(Comp)`
  color: ${(props) => props.color};
  font-size: 20px;
`;

export default function App() {
  return (
    <div>
      <Comp state={1} />
      <DivComp state={2} color='red' />
    </div>
  );
}

css-in-js-7.png

可以看到,作为父组件的Comp组件,需要提供className参数,并在组件内部恰当位置作为属性。这样子组件和父组件的props都可以正常使用,styled-components会将接收到的属性透传给父组件。然后我们再看下普通React组件作为子组件的场景:

import styled from "styled-components";

const Div = styled.div`
  color: ${(props) => props.color};
  font-size: 20px;
`;

function Comp(props = {}) {
  return <Div {...props}>{props.state}</Div>;
}

export default function App() {
  return (
    <div>
      <Div color="red">1</Div>
      <Comp state={2} color="yellow" />
    </div>
  );
}

css-in-js-8.png

当普通React组件作为子组件时,我们需要手动处理透传需要的prop到子组件中。

嵌套选择器

前面使用React内联样式的时候,我们提到内联样式并不支持嵌套选择器,这其实是直接用React做CSS in JS的最大阻碍。 styled-components引入了stylis工具来处理,支持使用嵌套选择器。这里举下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  &:hover {
    background: yellow;
  }
  &.class1 {
    border: 2px solid blue;
  }
  .class2 & {
    border: 2px solid green;
  }
`;

export default function App() {
  return (
    <div>
      <Div>1 jzplp</Div>
      <Div>2 jzplp</Div>
      <Div className="class1">3 jzplp</Div>
      <div className="class2">
        <Div>4 jzplp</Div>
      </div>
    </div>
  );
}

css-in-js-9.png

通过例子可以看到,无论是伪类,还是各种选择器都可以,其中使用&标识本标签的选择器。也能生效到子元素中,即使这个元素非styled组件:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  .class1 {
    border: 2px solid blue;
  }
`;

export default function App() {
  return (
    <div>
      <Div>
        <div className="class1">3 jzplp</div>
      </Div>
    </div>
  );
}

css-in-js-10.png

CSS片段

styled-components支持创建一个CSS片段,可以提供给组件使用,并且可以带参数,使用css方法即可。

import styled, { css } from "styled-components";

const jzCss = css`
  color: blue;
  font-size: ${props => props.size}px;
`

const Div = styled.div`
${
  props => {
    if(props.type === 1) return jzCss;
    else return `
      color: red;
      background: green;
    `;
  }
}
`;

export default function App() {
  return (
    <div>
      <Div type={1} size={20}>jzplp 1</Div>
      <Div type={1} size={30}>jzplp 2</Div>
      <Div type={2}>jzplp 3</Div>
    </div>
  );
}

css-in-js-11.png

可以看到代码中先创建了一个带参数的CSS片段,然后在组件字符串插值的返回中传入这个片段,参数可以正常生效渲染。else情况则直接返回了字符串CSS属性,看浏览器上也可以生效。那是不是说不需要css函数处理,直接返回模板字符串就可以了?这是肯定不行的,我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  ${() => {
    return `
        color: blue;
        font-size: ${(props) => props.size}px;
      `;
  }}
`;

export default function App() {
  return (
    <div>
      <Div size={20}>jzplp 1</Div>
    </div>
  );
}

css-in-js-12.png

在浏览器中可以看到,模板字符串中的插值并没有被成功处理,而是被直接转为了普通字符串,最后成了错误的CSS代码。因此必须使用css函数创建CSS片段。

CSS动画

styled-components也支持创建@keyframes的CSS动画,同时在CSS中被引用。

import styled, { keyframes } from "styled-components";

const colorChange = keyframes`
  0% {
    color: red;
  }
  50% {
    color: blue;
  }
  100% {
    color: red;
  }
`;

const Div = styled.div`
  animation: ${colorChange} 2s infinite;
`;

export default function App() {
  return (
    <div>
      <Div>你好,jzplp</Div>
    </div>
  );
}

可以看到我们使用keyframes方法创建了一个keyframes动画,在需要的CSS位置中,当作animation-name属性插入动画即可。效果如下:

css-in-js-13.gif

这里只是简单介绍了styled-components的部分特性,如果希望深入了解请看styled-components相关文档。

@emotion/styled

下面来介绍一下Emotion这个库。这个库有好几种使用方式,首先我们从类似styled-components,即styled组件的使用方式开始介绍,主要使用@emotion/styled这个包。

接入方式

首先安装@emotion/styled依赖,然后修改src/App.jsx:

import styled from "@emotion/styled";

const Div = styled.div`
  color: red;
  background: ${(props) => props.bg};
`;

export default function App() {
  return (
    <div>
      <Div bg="blue">你好,jzplp</Div>
      <Div bg="yellow">你好,jzplp</Div>
    </div>
  );
}

上面代码的使用方式与styled-components一模一样,换个包名也能生效。效果也一样,区别在于开发模式下@emotion/styled有两个style标签,如下图。生产模式与styled-components一样,都是一个style标签且看不到内容。

css-in-js-14.png

CSS片段

不仅接入方式,@emotion/styled的大部分特性都和styled-components一样,包括继承,嵌套选择器等。但CSS片段有些不一样:

import styled from "@emotion/styled";
import { css } from '@emotion/react';

// 错误方式
const commonStyle1 = css`
  font-size: 20px;
  background: ${(props) => props.bg};
`;

const commonStyle2 = (props) => css`
  font-size: 20px;
  background: ${props.bg};
`;

const Div1 = styled.div`
  color: red;
  ${commonStyle1}
`;

const Div2 = styled.div`
  color: green;
  ${commonStyle2}
`;

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div2 bg="yellow">你好,jzplp2</Div2>
    </div>
  );
}

css-in-js-15.png

首先CSS片段的函数是在另一个包@emotion/react中引入的。commonStyle1是用styled-components模式引入的,将函数直接放到CSS片段中,是不生效的。必须要将片段本身放到一个大函数内部才行,如commonStyle2的形式。

函数入参

@emotion/styled中的组件也支持函数作为入参,而非模板字符串。函数可以返回style对象,也能返回拼好的字符串。

import styled from "@emotion/styled";

const Div1 = styled.div((props) => {
  return {
    color: "red",
    background: props.bg,
  };
});

const Div2 = styled.div((props) => {
  return `
    color: pink;
    background: ${props.bg};
  `;
});

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div1 bg="green">你好,jzplp2</Div1>
      <Div2 bg="yellow">你好,jzplp3</Div2>
      <Div2 bg="green">你好,jzplp4</Div2>
    </div>
  );
}

css-in-js-16.png

@emotion/react

接入css属性

Emotion除了styled组件使用方式之外,也可以使用组件css属性(css Prop)的使用方式。这种方式是在React中JSX的组件上增加一个css属性。因此这种方式需要修改React编译相关参数。这里以vite@8和@vitejs/plugin-react@6为例来说明。首先安装依赖@emotion/react。然后修改vite.config.js配置文件:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
      // 指定转换jsx语法的模块
      jsxImportSource: '@emotion/react',
    })],
})

然后就可以使用了。如果需要TypeScript类型正确提示,则需要修改tsconfig.json:

{
  "compilerOptions": {
    // types在原有基础上新增"@emotion/react/types/css-prop"
    "types": ["...", "@emotion/react/types/css-prop"],
    "jsxImportSource": "@emotion/react",
  },
}

然后我们修改App.jsx,内容如下:

export default function App() {
  return (
    <>
      <div
        css={{
          color: "red",
          "&:hover": {
            background: "green",
          },
        }}
      >
        你好 jzplp
      </div>
    </>
  );
}

可以看到,我们直接以对象的形式对组件JSX的css属性赋值,而且还包含hover伪类,最后在开发模式和生产模式都可以正常生效。

css-in-js-17.png

除了对象形式之外,css属性还支持CSS片段的形式:

import {css} from "@emotion/react";
export default function App() {
  return (
    <>
      <div
        css={css`
          color: red;
          &:hover {
            background: green;
          }
        `}
      >
        你好 jzplp
      </div>
    </>
  );
}

css属性继承

从前面的接入例子中我们看到,css属性还是通过class名的形式生效的。因此它的优先级低于style属性,即内联样式。如果父组件提供了css属性想让子组件生效,则需要传入className参数。那么如果父子组件都有css属性,他们的优先级如何呢?这里举个例子:

function Comp1({ className }) {
  return (
    <div
      css={{
        background: 'yellow',
        color: "red",
      }}
      className={className}
    >
      你好 jzplp
    </div>
  );
}

function Comp2() {
  return (
    <Comp1
      css={{
        fontSize: "20px",
        color: "blue",
      }}
    >
      你好 jzplp
    </Comp1>
  );
}

export default function App() {
  return (
    <>
      <Comp1 />
      <Comp2 />
    </>
  );
}

这里创建了两个组件,Comp1是子组件,Comp2是父组件。子组件和父组件中的CSS属性不冲突的可以同时生效,冲突属性以父组件的为准,例如这里的color。

css-in-js-18.png

样式对象

前面我们演示过,不管是styled组件,css属性还是CSS片段,都可以接收对象类型的样式数据。关于对象类型还有其它特性,这里我们一起描述一下。首先是数组类型,这里列举了两个例子:

import styled from "@emotion/styled";

const styleList = [
  {
    color: "red",
  },
  {
    fontSize: "20px",
  },
];

const Comp1 = styled.div(styleList);

function Comp2() {
  return <div css={styleList}>你好 jzplp2</div>;
}

export default function App() {
  return (
    <>
      <Comp1>你好 jzplp1</Comp1>
      <Comp2 />
    </>
  );
}

styled组件也同时支持多个入参:

import styled from "@emotion/styled";

const Comp1 = styled.div(
  {
    color: "red",
  },
  (props) => {
    return {
      fontSize: `${props.size}px`,
    };
  },
);

export default function App() {
  return <Comp1 size={20}>你好 jzplp1</Comp1>;
}

样式对象还支持回退值(默认值)。通过对属性值传入一个数组,最后面的值优先级最高,如果不存在则取前面的值:

function Div1({ color, children }) {
  return (
    <div
      css={{
        color: ["red", color],
      }}
    >{children}</div>
  );
}

export default function App() {
  return (
    <>
      <Div1>你好 jzplp1</Div1>
      <Div1 color="blue">你好 jzplp2</Div1>
    </>
  );
}

css-in-js-19.png

全局样式

@emotion/react支持创建全局样式,也是使用组件的形式。当组件被渲染时,全局样式生效,组件不被渲染时则不生效。

import { useState } from "react";
import { Global } from "@emotion/react";

export default function App() {
  const [state, setState] = useState(0);
  return (
    <>
      <Global
        styles={{
          ".class1": {
            color: "red",
          },
        }}
      />
      {!!(state % 2) && (
        <Global
          styles={{
            ".class2": {
              background: "yellow",
            },
          }}
        />
      )}
      <div className="class1 class2">你好 jzplp</div>
      <div onClick={() => setState(state + 1)}>按下变换</div>
    </>
  );
}

css-in-js-20.png

使用全局样式时,我们只要使用普通类名即可生效。当我们切换state的状态时,黄色的背景颜色也在变化。对应Global组件不渲染时,插入的CSS代码会被移除,渲染时会被引入。

@emotion/css

前面我们描述Emotion相关包,都是使用在React框架之内的。在React之外,还提供了@emotion/css包,用法和前面类似。

接入方式

首先我们创建一个工程接入@emotion/css。这里选用Vite。首先执行命令行:

npm init -y
npm add -D vite
npm add @emotion/css

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的@emotion/css实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现,引入了@emotion/css:

import { css } from '@emotion/css'

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
`;
const styles2 = css({
  color: 'blue',
});

console.log(styles1, styles2);
genEle('test1', styles1);
genEle('test2', styles2);

/* 输出结果
css-qm6gfa css-14ksm7b
*/

最后执行npm run dev命令,在浏览器查看结果。可以看到,我们和在React中使用一样,还是用模板字符串或者样式对象创建,但是创建后得到的结果是类名,我们直接放到DOM元素上即可。

css-in-js-21.png

样式数据

除了上面的模板字符串和样式对象之外,@emotion/css也支持数组类型以及嵌套选择器等属性,这里举例看一下。

import { css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
  &:hover {
    background: yellow;
  }
`;
const styles2 = css([
  {
    color: "blue",
  },
  {
    "&:hover": {
      background: "yellow",
    },
  },
]);

console.log(styles1, styles2);
genEle("test1", styles1);
genEle("test2", styles2);

全局样式

@emotion/css也支持全局样式,但是引入方式不一样:

import { injectGlobal  } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

injectGlobal`
  .class1 {
    color: red;
    &:hover {
      background: yellow;
    } 
  }
`;

genEle("test1", 'class1');

cx优先级处理

@emotion/css提供了一个cx方法,它的作用和知名的classnames包一样,都是合并class类名的。但是这个cx提供了优先级功能,即后面的类中的样式优先级比前面的高:

import { cx, css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cls1 = css`
  font-size: 20px;
  background: green;
`;
const cls2 = css`
  font-size: 20px;
  background: blue;
`;

genEle("test1", cx(cls1, cls2));
genEle("test2", cx(cls2, cls1));

这里我们创建了两个样式,其中背景颜色是冲突的。然后我创建了两个div,使用cx方法合并类名,但是连接各个div合并的顺序相反。在浏览器查看效果发现,实际展示的背景色也不一样,以后面的类名为准。

css-in-js-22.png

linaria与React

前面介绍的styled-components和@emotion,都是运行时CSS,即对应的生成样式的代码执行到的时候,这段CSS才会生成。因此这种库需要在打包后的代码中保留注入CSS的逻辑。还有另一类CSS in JS的库是零运行时的,即编译时生成CSS文件,在生产代码中直接引入即可,无需额外注入。这里先介绍linaria,它就是一个零运行时的库。

接入方式

我们依然以vite为例接入。linaria的背后依赖WyW-in-JS,它是一个零运行时CSS库的辅助工具包。首先执行命令行:

npm create vite@latest
npm add @linaria/core @linaria/react @wyw-in-js/vite

修改vite.config.ts,增加wyw配置:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wyw from '@wyw-in-js/vite';

export default defineConfig({
  plugins: [react(), wyw()],
})

然后修改App.tsx,接入linaria:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

可以看到,这个使用方式就是styled组件的使用方式,和前面的库基本一致。以开发模式在浏览器中运行效果如下:

css-in-js-23.png

零运行时特性

在前面的接入方式中,我们并没有看到它与其它CSS in JS库的不同点。这一节我们专门看一下零运行时和前面的库究竟有什么不一样。首先修改App.tsx:

import { useState } from "react";
import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div1>jzplp1</Div1>
      {state % 2 === 1 && <Div2>jzplp2</Div2>}
      <div onClick={() => setState(state + 1)}>按下切换</div>
      <div>当前state {state}</div>
    </div>
  );
}

在代码中我们设置了变化的state状态,一开始为0时不展示Div2,当按下切换时,Div2才被执行和创建。注意当一开始Div2没被执行的时候,运行时的CSS in JS库是不会创建Div2对应的CSS代码的(因为代码都没执行到那里)。但linaria却会将CSS代码全都创建和引入,即使这些代码没有被使用。我们看下初始化时,linaria的效果:

css-in-js-24.png

注意看浏览器网络请求中有一个CSS请求,它的内容为当前引入的CSS代码,包括没被创建的元素的CSS代码:

css-in-js-25.png

然后我们将代码打包(npm run build)后,查看一下打包文件。可以看到我们在JavaScript中写的CSS代码,已经被编译成一个独立的CSS文件被引入到HTML中了,变成了普通CSS的形式。

css-in-js-26.png

零运行时中的参数

前面我们说过零运行时的库,会把CSS提前编译好放到文件中。对于固定的CSS代码来说非常容易,但当CSS中出现变量参数时,即CSS代码不确定时,应该怎么实现呢?这里我们举个例子看一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: ${(props) => props.color || "yellow"};
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div1 color="red">jzplp1</Div1>
      <Div1 color="blue">jzplp1</Div1>
    </div>
  );
}

通过代码可以看到,传参方式和其它库基本一致。但是查看生成的代码,却发现不一致之处。运行时的库会直接生成完整的CSS代码提供,并且有一个自己专属的类名。但零运行时的库却将参数作为一个CSS变量引用。同时在对应组件的style属性中提供对应的CSS属性值,以此实现代码的零运行时特性。

css-in-js-27.png

打包后查看生成代码,也可以看到生成的带变量的CSS规则代码,以及我们根据处理props属性生成CSS变量的函数。这个函数只能在运行时处理。

css-in-js-28.png

CSS变量参数的限制

CSS变量虽然有效,但它的逻辑并不是字符串匹配,因此使用方式还是和运行时库有区别。我们先举一个普通的CSS代码作为例子,尝试了三种变量组合的场景。

.class1 {
  --classVar1: 21px;
  font-size:var(--classVar1);
}
.class2 {
  --classVar1: 21;
  font-size:var(--classVar1)px;
}
.class3 {
  --classVar1: 1px;
  font-size: 2var(--classVar1);
}

然后是对应的React代码:

import "./App.css";

export default function App() {
  return (
    <div>
      <div className="class1">jzplp1</div>
      <div className="class2">jzplp2</div>
      <div className="class3">jzplp3</div>
    </div>
  );
}

css-in-js-29.png

看可以看到只有第一个例子生效了,是21px整个作为CSS变量值。将这个值拆开是不能生效的,更不能像字符串那样随意拼合。那利用CSS变量作为参数方案的linaria呢?我们试一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  font-size: ${(props) => props.size + "px"};
`;

const Div2 = styled.div`
  font-size: ${(props) => props.size}px;
`;

const Div3 = styled.div`
  font-size: ${(props) => props.size}1px;
`;

const Div4 = styled.div`
  font-size: 2${(props) => props.size}px;
`;

export default function App() {
  return (
    <div>
      <Div1 size={21}>jzplp1</Div1>
      <Div2 size={21}>jzplp2</Div2>
      <Div3 size={2}>jzplp3</Div3>
      <Div4 size={1}>jzplp4</Div4>
    </div>
  );
}

css-in-js-30.png

通过例子可以看到,21px整个作为参数与21作为参数都是可以的,2或者1单独作为参数就不行了,会造成CSS代码解析错误。这里21生效应该是linaria特殊处理过,把后面的px拼接上了。

CSS声明块作为参数

linaria也可以接收CSS声明块作为模板参数:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-31.png

可以看到,不管是字符串模板还是对象形式都是支持的。但整个块对象不能是由函数返回的,即只能在编译时处理完成,不能有运行时特性。这还是由于前面CSS变量作为实现方案的原因,导致无法生效。这里举个例子:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${() => cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${() => cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-32.png

通过结果看到,用函数包裹起来(假设它是接收props之后运行时计算的CSS代码)并未生效。这里为了方便查看效果,我们打个包看一下构建成果:

css-in-js-33.png

通过生产代码可以看到,这个函数逻辑以及返回值被原封不动的保留下来返回了,但是linaria却无法识别整个CSS声明,因此不能生效。

linaria中的css片段

linaria中也有css方法,使用方式与其它库类似,而且支持不在React框架中使用。

纯JavaScript接入方式

这里我们抛弃React,使用纯JavaScript的方式接入linaria。使用linaria必须要编译,这里依然选用Vite。首先执行命令行:

npm init -y
npm add -D vite @wyw-in-js/vite 
npm add @linaria/core

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的linaria实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现:

import { css } from '@linaria/core';

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cssData = css`
  color: red;
  font-size: 20px;
`;

console.log(data);
genEle('jzplp', cssData);

/* 输出结果
cf71da1
*/

可以看到,css函数也是使用模板字符串来写CSS规则,返回值是一个类名,可以直接用在元素上。使用linaria还要修改打包配置,创建vite.config.js:

import { defineConfig } from "vite";
import wyw from "@wyw-in-js/vite";

export default defineConfig({
  plugins: [wyw()],
});

然后执行npm run dev,开发模式下正常生效:

css-in-js-34.png

然后我们执行npm run build,查看打包后的生成代码:

css-in-js-35.png

可以看到,我们用css函数生成的CSS代码,已经被放到独立的CSS文件中,css函数使用的位置,已经直接变成了class类名。这也是零运行时库的效果,在编译时就将CSS代码生成完毕。

全局样式

linaria也支持创建全局样式,这里我们试一下。

import { css } from "@linaria/core";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

css`
  :global() {
    div {
      color: red;
    }
    .class1 {
      background: yellow;
    }
  }
`;

const cssData = css`
  font-size: 20px;
`;

console.log(cssData);
genEle("jzplp1", "class1");
genEle("jzplp2", cssData);

css-in-js-36.png

将全局特性包裹在:global()中,即可生效。这段CSS甚至都不需要被哪个标签引用。我们再看看打包后的结果:

css-in-js-37.png

可以看到,对应的这段css函数代码没有了,全局特性转移到了CSS文件中。

vanilla-extract

vanilla-extract是另一个CSS in JS库,正如它的名字,vanilla表示不使用框架的纯JavaScript。vanilla-extract这个库是框架无关的,同样他也是一个零运行时库。

接入方式

我们还是使用Vite接入,但这次试一下Vite提供的vanilla模板。执行命令行:

npm create vite@latest
# 选择 Vanilla + TypeScript模板
npm add @vanilla-extract/css @vanilla-extract/vite-plugin

创建vite.config.js文件,内容如下:

import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';

export default {
  plugins: [vanillaExtractPlugin()]
};

创建src/styles.css.ts文件,内容为创建样式,并导出。

import { style } from '@vanilla-extract/css';

export const cls1 = style({
  color: 'red'
});

然后删除无用的文件,修改src/main.js的内容为引入创建的样式,并作为类名放到HTML标签上。

import { cls1 } from "./styles.css.ts";

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-38.png

在开发模式运行,通过浏览器可以看到,也是在head中插入了style标签提供样式。我们再打包看看生成文件:

css-in-js-39.png

通过生成文件可以看到,vanilla-extract也是在编译时就生成独立的样式文件引入,不需要运行时处理。

独立.css.ts文件

vanilla-extract与其它CSS in JS方案不同点在于,虽然它确实是用JavaScript写CSS代码,但却要求独立的文件类型“.css.ts”。如果在其它文件中写入会造成错误。这里我们试一试,修改前面的src/main.js:

import { style } from '@vanilla-extract/css';

const cls1 = style({
  color: 'red'
});

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

这时候开发模式打开浏览器,会看到报错,元素也没有正常展示:

css-in-js-40.png

回想起使用CSS in JS方案的重要原因就是希望CSS代码与组件的联系更紧密。这样强制的独立.css.ts文件,看起来没有增加紧密感。

生成style

vanilla-extract使用style创建样式,返回对应的类名。style方法接收对象形式的CSS规则,但是与常规CSS写法有点不同,这里介绍部分不同点。

px单位

首先是如果属性值的单位是px,可以省略,写成数字形式。这里举个例子:

export const cls1 = style({
  fontSize: 10,
  margin: 20,
  padding: "10px",
  flex: 1,
});

/* 生成结果
.r9osg00 {
  flex: 1;
  margin: 20px;
  padding: 10px;
  font-size: 10px;
}
*/

我们直接对代码打包,观察生成的CSS文件。可以看到vanilla-extract并不是所有数字都会转换,那些没有单位的CSS属性并不会被转换。

浏览器引擎前缀写法

在对象中写CSS属性需要以camelCase驼峰命名法,但对于浏览器引擎前缀这种最前面带中划线的形式,vanilla-extract要求使用PascalCase帕斯卡命名法,即最前面大写。

export const cls1 = style({
  WebkitTapHighlightColor: "rgba(0, 0, 0, 0)",
});

/* 生成结果
.r9osg00 {
  -webkit-tap-highlight-color: #0000;
}
*/

媒体查询/容器查询/@layer等

它们的写法多了一层嵌套:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  "@container": {
    "(min-width: 768px)": {
      padding: 10,
    },
  },
  "@media": {
    "screen and (min-width: 768px)": {
      padding: 10,
    },
    "(prefers-reduced-motion)": {
      transitionProperty: "color",
    },
  },
  "@layer": {
    typography: {
      fontSize: "1rem",
    },
  },
  "@supports": {
    "(display: grid)": {
      display: "grid",
    },
  },
});

/* 生成结果
@layer typography {
  .r9osg00 {
    font-size: 1rem;
  }
}
@media screen and (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
@media (prefers-reduced-motion) {
  .r9osg00 {
    transition-property: color;
  }
}
@supports (display: grid) {
  .r9osg00 {
    display: grid;
  }
}
@container (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
*/

后备值

在之前讲PostCSS中postcss-custom-properties插件的时候,我们提到过当浏览器读取到一个不支持的CSS属性值时,如果这个属性前面已经有一个后备值了,那就使用那个后备值,不会应用不支持的属性值。

但以对象的形式写CSS属性,key同一个的情况下,没办法写两个值。这里vanilla-extract接收一个值数组,实现后备值功能:

export const cls1 = style({
  overflow: ['auto', 'overlay']
});

/* 生成结果
.r9osg00 {
  overflow: auto;
  overflow: overlay;
}
*/

CSS变量

vanilla-extract支持使用CSS变量,需要在vars属性内部。创建后的CSS变量可以在任意位置使用,但需要满足选择器的条件。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  vars: {
    "--jzplp1": "red",
  },
  color: "var(--jzplp1)",
});

export const cls2 = style({
  color: "var(--jzplp1)",
  fontSize: 20,
});

/* 生成结果
.r9osg00 {
  --jzplp1: red;
  color: var(--jzplp1);
}
.r9osg01 {
  color: var(--jzplp1);
  font-size: 20px;
}
*/

还可以通过createVar方法创建带有哈希的模块化CSS变量,在使用的位置引用即可。

import { style, createVar } from "@vanilla-extract/css";

const cssVar = createVar();

export const cls1 = style({
  vars: {
    [cssVar]: "red",
  },
  color: cssVar,
});

export const cls2 = style({
  color: cssVar,
  fontSize: 20,
});

/* 生成结果
.r9osg01 {
  --r9osg00: red;
  color: var(--r9osg00);
}
.r9osg02 {
  color: var(--r9osg00);
  font-size: 20px;
}
*/

嵌套选择器

vanilla-extract支持使用嵌套选择器,但是有一些特殊的规则。

顶层使用

对于简单的无参数的伪类或者伪元素选择器,可以与其它CSS属性放在一起顶层使用。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  ":hover": {
    background: "yellow",
  },
  "::before": {
    content: "jzplp",
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:before {
  content: "jzplp";
}
*/

可以看到,与属性一同使用时可以省略&符号。但是这里不能添加带参数或者复杂的组合选择器,否则会编译报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  "&:hover": {
    background: "yellow",
  },
  ":not(.cls)": {
    content: "jzplp",
  },
});

/* 分别报错
error TS2353: Object literal may only specify known properties, and '"&:hover"' does not exist in type 'ComplexStyleRule'.
error TS2353: Object literal may only specify known properties, and '":not(.cls)"' does not exist in type 'ComplexStyleRule'.
*/

selectors中使用

在selectors属性中可以编写复杂的选择器,但要自己写&符号。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    "&:hover": {
      background: "yellow",
    },
    "&:not(.cls)": {
      content: "jzplp",
    },
    ".abc &": {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:not(.cls) {
  content: "jzplp";
}
.abc .r9osg00 {
  font-size: 20px;
}
*/

禁止场景

选择器也不是随便写都行的,vanilla-extract要求选择器必须作用于当前类。如果作用于其它类,那么编译时也会报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    ":hover": {
      background: "yellow",
    },
    "& .abc": {
      fontSize: 20
    },
  },
});

/* 分别报错
Error: Invalid selector: :hover
Error: Invalid selector: & .abc
Style selectors must target the '&' character (along with any modifiers), e.g. `${parent} &` or `${parent} &:hover`.
*/

它会实际分析CSS规则,并不是把&写在什么位置就能避开的。

传入类名

嵌套选择器也支持传入其它style函数生成的类名,同样需要遵守选择器必须作用于当前类。

import { style } from "@vanilla-extract/css";

const cls = style({
  color: 'red',
})

export const cls1 = style({
  selectors: {
    [`.${cls} &`]: {
      background: "yellow",
    },
    [`&:not(.${cls})`]: {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00 .r9osg01 {
  background: #ff0;
}
.r9osg01:not(.r9osg00) {
  font-size: 20px;
}
*/

全局样式

vanilla-extract可通过globalStyle函数创建全局样式,第一个参数是选择器,第二是样式对象。

import { globalStyle } from "@vanilla-extract/css";

globalStyle("body", {
  vars: {
    "--jzplp": "10px",
  },
  margin: 0,
});

globalStyle(".abc .bcd:hover", {
  color: "red",
});

/* 生成结果
body {
  --jzplp: 10px;
  margin: 0;
}
.abc .bcd:hover {
  color: red;
}
*/

全局样式中也能包含使用style函数生成的模块化类名,这时候就没有选择器作用限制了:

import { globalStyle, style } from "@vanilla-extract/css";

const cls = style({
  color: "red",
});

globalStyle(`body .${cls}`, {
  margin: 0,
});

globalStyle(`.${cls} :not(div)`, {
  fontSize: 10,
});

/* 生成结果
.r9osg00 {
  color: red;
}
body .r9osg00 {
  margin: 0;
}
.r9osg00 :not(div) {
  font-size: 10px;
}
*/

主题

创建主题

vanilla-extract支持使用createTheme方法创建主题,主题实际上就是一组预设CSS变量。首先我们创建主题并直接打包,看下输出结果:

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

console.log(theme1);
console.log(vars);

/* 打包命令行输出
r9osg00
{
  color: { banner: 'var(--r9osg01)', font: 'var(--r9osg02)' },
  size: { margin: 'var(--r9osg03)' }
}
*/

/* 打包后CSS文件内容
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
*/

上面创建了一个主题。其中vars表示这个主题模式的对象,其中包含变量的引用。theme1是类名,使用这个类即可对这些CSS变量提供值。

使用主题

下面我们再来看使用方式。首先是styles.css.ts,除了创建主题之外还创建了样式,其中引用了vars中的变量。

import { createTheme, style } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const cls1 = style({
  color: vars.color.font,
});

export { theme1, cls1 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  color: var(--r9osg02);
}
*/

然后是main.js,将主题类放到body中,再将应用主题的类放到div上。这样div会使用body上的变量,使主题生效。

import { theme1, cls1 } from './styles.css';

document.body.className = theme1;

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-41.png

复用主题

既然是主题,那么应该不会只有一个,主题应该是同变量但是值不同的一组结构,这时候可以复用vars继续创建主题。

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  --r9osg01: yellow;
  --r9osg02: pink;
  --r9osg03: 30px;
}
*/

可以看到,创建的theme2应该遵守同样的结构,但是值不同。theme2仅生成类名,在对应的标签上赋值即可实现主题切换。

仅创建vars

通过上面的例子可以看到,vars表示主题的结构和引用,生成的类名表示实际的主题值。但是现在创建主题结构和创建主题值合二为一了,如果希望分开生成,vanilla-extract提供了createThemeContract方法。可以将上面的代码改下如下,效果不变。

import { createTheme, createThemeContract } from "@vanilla-extract/css";

const vars = createThemeContract({
  color: {
    banner: "",
    font: "",
  },
  size: {
    margin: "",
  },
});

const theme1 = createTheme(vars, {
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

总结

库列表

前面我们介绍了四个CSS in JS的库,但这仅仅是九牛一毛。社区中CSS in JS的库非常非常多,这里用表格列举一些知名度较高的:

库名称 零运行时 纯JS 适配React框架 备注
styled-components 不支持 支持 本文已介绍
Emotion 支持 支持 本文已介绍
linaria 支持 支持 本文已介绍
vanilla-extract 支持 不支持 本文已介绍
Panda CSS 支持 支持 -
Compiled 不支持 支持 -
Radium 不支持 支持 已停止维护
JSS 插件支持 支持 支持 已停止维护

特点总结

本文只是简单介绍了几个CSS in JS库的使用方式和特点,并未详细探究原理和区别。事实上关于CSS in JS库还有很多值得探讨的主题,例如服务端渲染,性能优化等。根据介绍的几个CSS in JS库以及网络上相关分析,这里我们总结一下CSS in JS的特点,其中有优点,也有缺点:

  • 使用方式和API各有特色,但也有很多相似之处
    • 可以看到很多API设计都是类似的,例如styled组件、css的props、模板字符串或对象作为CSS规则表示,这些API在很多库中都以类似的方式出现
    • 但很多库的设计都有自己的特点和使用方式,并没有千篇一律
  • 虽然CSS in JS的设计各有特色,但还是可以大致分成 运行时库和零运行时库两类
    • 运行时库对于CSS传参的灵活性高,但是运行时生成CSS,有额外的性能损失
    • 零运行时库在编译时生成CSS文件,没有运行时性能损失,但对于CSS参数等灵活性低
  • CSS in JS库对于类型检查和提示更友好,支持TypeScript更完善
  • 由于CSS是在JavaScript中撰写的,因此控制和切换CSS要更方便
  • CSS in JS的类名时根据hash生成的,自带模块化类名,省去了起名的烦恼,也防止了CSS污染
  • 类名是自动生成的,因此有些开发者认为比较难看,不方便查找元素等。相比较CSS Modules可以配置原类名+hash,可以轻松识别类名
  • 在JavaScript中写CSS,更方便组织代码,例如将CSS与HTML和JS放在一起,以组件化的形式组织
  • 对于运行时CSS,CSS代码只有在需要的时候才加载,对于部分页面场景,具有更小的首屏文件体积和其它优势
  • 使用独立的CSS文件我们不清楚哪些样式是真正使用的,哪些样式没有被用到。而CSS in JS的CSS规则引用关系明显,我们可以轻松找到未被使用的样式,也可以利用tree-shaking等技术编译时去掉不需要的样式
  • 有些人很喜欢CSS in JS来组织CSS代码,但是有些人却觉得多此一举。萝卜青菜,各有所爱
  • CSS in JS在React框架使用居多,Vue框架有自己的方案(组件作用域CSS,我们之前介绍过),基本不需要CSS in JS

参考

❌
❌