Vue3+TS+Threejs封装一个3D动画框架

需求背景

你现在有多个glb格式的模型文件,其中一些模型包含了动画,你现在需要在h5上渲染出模型

在此之前,你可能需要一点Threejs的基础~

threejs:threejs.org/docs/index....

普通版html怎么写

废话不多说,直接上代码

xml 复制代码
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>3D demo</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <style>
        body { margin: 0; }
        canvas { display: block; }
    </style>
</head>
<body>
<div class="ctls"></div>

<script src="https://cdn.jsdelivr.net/npm/three@0.137.5/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three/examples/js/controls/OrbitControls.js"></script>

<script>

    const scene = new THREE.Scene();

    //环境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 3);
    scene.add(ambientLight);


    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    camera.position.z = -80;
    camera.position.x = 0;
    camera.position.y = 0;

    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    
    const controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.minDistance = 0; 
    controls.maxDistance = 20;

    // 限制上下旋转角度
    controls.minPolarAngle = Math.PI / 6; 
    controls.maxPolarAngle = Math.PI / 2;

    const loader = new THREE.GLTFLoader();
    const mixers = []; 
    const clock = new THREE.Clock(); // 计算时间差

    // 加载多个模型
    function loadModel(url) {
        loader.load(
            url,
            function (gltf) {
                scene.add(gltf.scene);
            },
            function (xhr) {
                // 监控加载进度
                console.log((xhr.loaded / xhr.total * 100) + '% loaded');
            },
            function (error) {
                console.error('An error happened loading ' + url, error);
            }
        );
    }
    // 加载含动画多个模型
    function loadAnimationModel(url) {
        loader.load(
            url,
            function (gltf) {
                scene.add(gltf.scene);
                if (gltf.animations && gltf.animations.length) {
                    const mixer = new THREE.AnimationMixer(gltf.scene);
                    gltf.animations.forEach((clip) => {
                        mixer.clipAction(clip).play();
                    });
                    mixers.push(mixer);
                }
            },
            function (xhr) {
                // 监控加载进度
                console.log((xhr.loaded / xhr.total * 100) + '% loaded');
            },
            function (error) {
                console.error('An error happened loading ' + url, error);
            }
        );
    }

    // 假设我们需要加载三个模型
    const modelUrls = ['./QYN_Mesh_Build_01.glb', './QYN_Mesh_Build_02.glb', './QYN_Mesh_Build_03.glb','./QYN_Mesh_Build_04.glb'];
    const animationModelUrls = ['./QYN_Animation_Bird.glb','./QYN_Animation_ZhuLian.glb'];
    const totalModels = modelUrls.length;

    modelUrls.forEach((url) => {
        loadModel(url);
    });

    animationModelUrls.forEach((url) => {
        loadAnimationModel(url);
    })

    function animate() {
        requestAnimationFrame(animate);

        const delta = clock.getDelta(); 
        mixers.forEach(function(mixer) {
            mixer.update(delta); 
        });

        controls.update();
        renderer.render(scene, camera);
    }

    animate();
    
</script>
</body>
</html>

分几部分细说一下

初始化

首先需要对Threejs的一些固定的api进行初始化

  • scene:场景,渲染的容器
  • camera:相机,可以理解为人的眼睛,用于观察的
  • control:控制器,用于控制相机的
  • renderer:渲染器,用于渲染模型的
  • light:灯光,用于控制光照效果的
ini 复制代码
    const scene = new THREE.Scene();

    //环境光
    const ambientLight = new THREE.AmbientLight(0xffffff, 3);
    scene.add(ambientLight);


    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    camera.position.z = -80;
    camera.position.x = 0;
    camera.position.y = 0;

    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    
    const controls = new THREE.OrbitControls(camera, renderer.domElement);
    // 限制远近
    controls.minDistance = 0; 
    controls.maxDistance = 20;

    // 限制上下旋转角度
    controls.minPolarAngle = Math.PI / 6; 
    controls.maxPolarAngle = Math.PI / 2;

加载模型

我们拥有了一些模型资产,想要通过Threejs渲染出来,就需要利用Threejs提供的一些Loader对模型资产进行加载转变成Threejs可以使用的资产,加载完成后添加到场景scene中就可以渲染出来了

如果是包含动画的模型,还需要额外处理下,简单介绍下用于控制动画的三个重要的东西

  • AnimationClip:动画剪辑,是一个可重用的关键帧轨道集,它代表动画,一般美术会直接设计绑定放在模型的animations数组中了
  • AnimationMixer:动画混合器,是用于场景中特定对象的动画的播放器
  • AnimationAction:用来调度存储在AnimationClips中的动画,可以理解为一个动作

这三者是什么关系呢?

AnimationAction = AnimationMixer.clipAction(AnimationClip)

AnimationAction.play()就代表了开启了这个动作的动画

javascript 复制代码
    // 加载模型
    function loadModel(url) {
        loader.load(
            url,
            function (gltf) {
                scene.add(gltf.scene);
            },
            function (xhr) {
                // 监控加载进度
                console.log((xhr.loaded / xhr.total * 100) + '% loaded');
            },
            function (error) {
                console.error('An error happened loading ' + url, error);
            }
        );
    }
    // 加载含动画模型
    function loadAnimationModel(url) {
        loader.load(
            url,
            function (gltf) {
                scene.add(gltf.scene);
                if (gltf.animations && gltf.animations.length) {
                    const mixer = new THREE.AnimationMixer(gltf.scene);
                    gltf.animations.forEach((clip) => {
                        mixer.clipAction(clip).play();
                    });
                    mixers.push(mixer);
                }
            },
            function (xhr) {
                // 监控加载进度
                console.log((xhr.loaded / xhr.total * 100) + '% loaded');
            },
            function (error) {
                console.error('An error happened loading ' + url, error);
            }
        );
    }

    // 假设我们需要加载三个模型
    const modelUrls = ['./QYN_Mesh_Build_01.glb', './QYN_Mesh_Build_02.glb', './QYN_Mesh_Build_03.glb','./QYN_Mesh_Build_04.glb'];
    const animationModelUrls = ['./QYN_Animation_Bird.glb','./QYN_Animation_ZhuLian.glb'];

    modelUrls.forEach((url) => {
        loadModel(url);
    });

    animationModelUrls.forEach((url) => {
        loadAnimationModel(url);
    })

循环动画

利用requestAnimationFrame开启流畅的动画循环

前面我们讲到包含动画的模型,在load时需要进行额外处理,开启了AnimationAction的动画

但是在循环动画中还需要根据deltaTime去更新AnimationMixer才会真正的渲染动画

scss 复制代码
    function animate() {
        requestAnimationFrame(animate);

        const delta = clock.getDelta(); 
        mixers.forEach(function(mixer) {
            mixer.update(delta); 
        });

        controls.update();
        renderer.render(scene, camera);
    }

    animate();

ok根据以上代码就可以简单以html文件形式实现模型的渲染和动画了

进阶版vue怎么写

在项目中要引进3D渲染怎么搞呢?

其实很简单,和html相比区别没有很大

相比html的版本,这里新增了几个方法

  • reset:重置模型视角
  • updateSize:监听resize,动态更新渲染区域大小
  • zoomIn: 前进
  • zoomOut:后退
  • gotoTargetPoint:点击去到某个点

直接上代码

ini 复制代码
<template>
  <div ref="modelContainer" class="model-container" />
</template>

<script lang="ts" setup name="ModelRenderer">
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { ref, onMounted, onUnmounted } from 'vue';

const modelContainer = ref<HTMLDivElement>();
let requestId = 0;
let camera: THREE.PerspectiveCamera;
let scene: THREE.Scene;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let loader: GLTFLoader;
const clock: THREE.Clock = new THREE.Clock();
const mixers: THREE.AnimationMixer[] = [];

const emit = defineEmits(['model-loaded']);

const init = () => {
  if (!modelContainer.value) return;
  const width = modelContainer.value.offsetWidth;
  const height = modelContainer.value.offsetHeight;
  scene = new THREE.Scene();

  renderer = new THREE.WebGLRenderer();
  renderer.setSize(width, height);
  modelContainer.value.appendChild(renderer.domElement);

  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
  camera.position.z = -80;
  camera.position.x = 0;
  camera.position.y = 0;

  controls = new OrbitControls(camera, renderer.domElement);
  controls.minDistance = 0;
  controls.maxDistance = 20;
  // 限制上下旋转角度
  controls.minPolarAngle = Math.PI / 6;
  controls.maxPolarAngle = Math.PI / 2;
};

// 加载多个模型
const loadModel = (url: string) => {
  loader.load(
    url,
    function (gltf) {
      scene.add(gltf.scene);
    },
    () => {},
    function (error) {
      console.error('An error happened loading ' + url, error);
    }
  );
};
// 加载含动画多个模型
const loadAnimationModel = (url: string) => {
  loader.load(
    url,
    function (gltf) {
      scene.add(gltf.scene);
      if (gltf.animations && gltf.animations.length) {
        const mixer = new THREE.AnimationMixer(gltf.scene);
        gltf.animations.forEach((clip) => {
          mixer.clipAction(clip).play();
        });
        mixers.push(mixer);
      }
    },
    () => {},
    function (error) {
      console.error('An error happened loading ' + url, error);
    }
  );
};

const render = (options?: string[]) => {
  console.log('🚀 ~ render ~ options:', options);
  try {
    const modelUrls = [
      'src/assets/models/QYN_Mesh_Build_01.glb',
      'src/assets/models/QYN_Mesh_Build_02.glb',
      'src/assets/models/QYN_Mesh_Build_03.glb',
      'src/assets/models/QYN_Mesh_Build_04.glb',
    ];
    const animationModelUrls = [
      'src/assets/models/QYN_Animation_Bird.glb',
      'src/assets/models/QYN_Animation_ZhuLian.glb',
    ];

    const manager = new THREE.LoadingManager(
      // Loaded
      async () => {
        emit('model-loaded');
      },

      // Progress
      (itemUrl, itemsLoaded, itemsTotal) => {
        const loadedData = Math.floor((itemsLoaded / itemsTotal) * 100);
        if (loadedData >= 100) {
          console.log('🚀 ~ render ~ loadedData:', loadedData);
          emit('model-loaded');
        }
      },

      // error
      () => {
        console.log('error');
      }
    );

    loader = new GLTFLoader(manager);

    modelUrls.forEach((url) => {
      loadModel(url);
    });

    animationModelUrls.forEach((url) => {
      loadAnimationModel(url);
    });
  } catch (e) {
    console.error(e);
  }
};

const animate = () => {
  requestAnimationFrame(animate);

  const delta = clock.getDelta();
  mixers.forEach(function (mixer) {
    mixer.update(delta);
  });

  controls.update();
  renderer.render(scene, camera);
};

const resetControls = () => {
  controls.reset();
};

const updateSize = () => {
  if (!modelContainer.value) return;
  const width = modelContainer.value.offsetWidth;
  const height = modelContainer.value.offsetHeight;
  renderer.setSize(width, height);
  renderer.render(scene, camera);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
};


const zoomToggle = (isIn: boolean) => {
  const direction = new THREE.Vector3()
    .subVectors(scene.position, camera.position)
    .normalize();
  const minDistance = 1; // 最小距离为1米
  let currentDistance = camera.position.distanceTo(scene.position);

  if (isIn && currentDistance > minDistance) {
    camera.position.add(direction.multiplyScalar(1));
  } else if (!isIn) {
    camera.position.sub(direction.multiplyScalar(1));
  }

  // 重新计算距离,以确保不会因为移动而超过最小距离
  currentDistance = camera.position.distanceTo(scene.position);
  if (currentDistance < minDistance) {
    camera.position.add(
      direction.multiplyScalar(currentDistance - minDistance)
    );
  }

  camera.lookAt(scene.position);
  if (controls) controls.update();
};

const zoomIn = () => zoomToggle(true);
const zoomOut = () => zoomToggle(false);

// 添加鼠标双击啊事件监听
const gotoTargetPoint = (event: MouseEvent) => {
  var mouse = new THREE.Vector2();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  var raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(mouse, camera);

  var intersects = raycaster.intersectObjects(scene.children);
  console.log('🚀 ~ intersects:', intersects);

  if (intersects.length > 0) {
    // 设置相机的位置到被点击的点
    camera.position.set(
      intersects[0].point.x,
      intersects[0].point.y,
      intersects[0].point.z
    );
  }
};

defineExpose({
  render,
  updateSize,
  resetControls,
  zoomIn,
  zoomOut,
});

onMounted(() => {
  init();
  animate();
  window.addEventListener('resize', updateSize, false);
  window.addEventListener('dblclick', onMouseDbClick, false);
  render();
});

onUnmounted(() => {
  window.removeEventListener('resize', updateSize, false);
  window.removeEventListener('dblclick', onMouseDbClick, false);
  cancelAnimationFrame(requestId);
});
</script>

<style scoped>
.model-container {
  width: 100vw;
  height: 100vh;
}
</style>

高阶版动画系统怎么写

其实通过进阶版的写法,已经可以在Vue项目中游刃有余了,但是如果3D场景很多,且模型格式不同、渲染需求不同,那就要写很多像这样的Vue组件,但是像zoomIn、zoomOut、gotoTargetPoint或者是一些其他控制动画的方法是可以复用的

因此我们可以适当的抽离一些功能,按照开闭原则设计一个高复用性的集合3D渲染和动画的简易框架

参考UE中的GamePlay框架,我们可以这么设计,将整个3D场景划分为这么几个类

  • World:顾名思义为一个世界,即虚拟数字人存在的世界。在 world 里可对场景进行设置,如灯光效果、天空效果、地板效果等。
  • Actor:可以认为 Actor 为一个个体,可以是一个人或者一个物品,其可以拥有多个组件(Component)来表示其外观或操作;Actor 也可以拥有子 Actor。
  • Component:Component是Actor 的组成部分,可以理解为人身上的挂件;组件拥有有位置属性;组件也可以拥有子组件。
  • Camera:相机相当于人的眼睛,又分为正交投影和透视投影,不同的投影方式,模型的渲染效果也有差异。通过相机的位置可以 3D 预览模型,可近看也可以远观。其可以利用Controller 控制相机,进行缩放、旋转等操作。

设计类图大概如下:

这个设计是比较符合开闭原则的

  • 可以利用多态增加不同的Actor继承于ActorBase,比如人Character、车Car、树Tree
  • Actor上可以增加不同类型的component,控制不同的功能,比如用于控制mesh的MeshComponent、用于控制动画的DriveComponent。这也是符合单一责任原则的。

这个简易框架设计好后,现在的vue文件应该长什么样呢

除了一些和dom相关的,其他的代码逻辑被封装了,完全不需要理解了,直接开箱即用

ini 复制代码
<template>
  <div
    ref="modelContainer"
    class="model-container"
  ></div>
</template>

<script lang="ts" setup name="ModelRenderer">
import * as THREE from 'three';
import { WebGLRenderer } from 'three';
import Stats from 'three/examples/jsm/libs/stats.module.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { onMounted, onUnmounted, PropType, ref } from 'vue';

import ActorBase from '@/common/3D-render/core/actors/actor-base';
import GltfActor from '@/common/3D-render/core/actors/gltf-actor';
import type DriveComponent from '@/common/3D-render/core/components/drive-component';
import CameraActor from '@/common/3D-render/logic/camera-actor';
import World from '@/common/3D-render/logic/world';
import { IS_DEBUG } from '@/utils/index';

const modelContainer = ref<HTMLDivElement>();
let requestId = 0;
let camera: CameraActor;
let renderer: THREE.WebGLRenderer;
let loader: GLTFLoader;
let world: World;
let stats: Stats;
const clock: THREE.Clock = new THREE.Clock();

const emit = defineEmits<{
  (e: 'modelLoaded', value: number): void;
  (e: 'modelLoadError'): void;
  (e: 'modelLoadTimeout'): void;
}>();

/**
 * 初始化模型
 * 初始化World:初始化scene、灯光、hdr场景等
 * 初始化Render:初始化WebGL渲染器
 * 初始化Camera:初始化相机,设置相机参数、远近、旋转角度等
 * 初始化stats:初始化性能监测工具
 */
const init = () => {
  if (!modelContainer.value) return;
  const width = modelContainer.value.offsetWidth;
  const height = modelContainer.value.offsetHeight;
  // world
  world = new World();
  // renderer
  renderer = new WebGLRenderer();
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(width, height);
  modelContainer.value.appendChild(renderer.domElement);
  // camera
  camera = new CameraActor(renderer.domElement, width, height);
  world.addToWorld(camera);
  // stats
  if (import.meta.env.MODE === 'development' || IS_DEBUG) {
    stats = new Stats();
    document.body.appendChild(stats.dom);
  }
};

/**
 * 更新渲染窗口大小
 */
function updateSize() {
  if (!modelContainer.value) return;
  const width = modelContainer.value.offsetWidth;
  const height = modelContainer.value.offsetHeight;
  renderer.setSize(width, height);
  camera.getCamera().aspect = width / height;
  camera.getCamera().updateProjectionMatrix();
  renderer.render(world.getScene(), camera.getCamera());
}

/**
 * 动画循环
 */
function animate() {
  requestId = requestAnimationFrame(animate);
  const deltaTime = clock.getDelta();
  // 更新模型动画
  world.tickActor(deltaTime);
  renderer.render(world.getScene(), camera.getCamera());
  // stats状态更新
  if (import.meta.env.MODE === 'development' || IS_DEBUG) {
    stats && stats.update();
  }
}

/**
 * 加载模型
 * @param url
 */
const loadModel = (url: string) => {
  loader.load(
    url,
    (gltf) => {
      const actor = new GltfActor(gltf);
      world.addGltfToWorld(actor);
    },
    () => {},
    (error) => {
      logger.error('loadModel error:', error);
      emit('modelLoadError');
    },
  );
};

/**
 * 渲染模型
 * 可以当模型获取完毕后或到达渲染时机时调用
 * @param modelUrls 模型URl数组
 */
const render = (modelUrls: string[]) => {
  const renderStartTime = performance.now();
  try {
    const manager = new THREE.LoadingManager(
      // Loaded
      async () => {
      },

      // Progress
      (itemUrl, itemsLoaded, itemsTotal) => {
        const loadedData = Math.floor((itemsLoaded / itemsTotal) * 100);
        emit('modelLoaded', loadedData);
      },

      // error
      (error) => {
        logger.error('THREE.LoadingManager Error', error);
        emit('modelLoadError');
      },
    );

    loader = new GLTFLoader(manager);

    modelUrls.forEach((url) => {
      loadModel(url);
    });
  } catch (e) {
    logger.error('render ~ error', e);
  }
};

/**
 * 驱动单个动画
 * @param actor
 */
const startActorAnimation = (actor: ActorBase) => {
  try {
    const driveComponent = actor.getComponentByName('DriveComponent') as DriveComponent;
    driveComponent && driveComponent.startEvaluate();
  } catch (e) {
    console.error(e);
  }
};

/**
 * 驱动全部动画
 * @param actor
 */
const startAnimation = () => {
  world.getActors().forEach((actor) => {
    startActorAnimation(actor);
  });
};

/**
 * 重置模型(重置为上一次保存的状态
 */
const reset = () => {
  camera.getControls().reset();
};

/**
 * 前进/后退
 * @param step 前进/后退 步长
 */
const zoomIn = (step = 1) => camera.zoomIn(step);
const zoomOut = (step = 1) => camera.zoomOut(step);

defineExpose({
  render,
  updateSize,
  reset,
  zoomIn,
  zoomOut,
  startAnimation,
});

onMounted(() => {
  init();
  animate();
  window.addEventListener('resize', updateSize, false);
});

onUnmounted(() => {
  window.removeEventListener('resize', updateSize, false);
  cancelAnimationFrame(requestId);
});
</script>

<style scoped>
.model-container {
  width: 100vw;
  height: 100vh;
}
</style>

解释几个比较重要的函数

  • init:初始化3D场景

    这个方法一般会在onMouted中调用

    在World类中已经设置好了灯光等参数

    在Camera类中已经设置好了相机参数

  • render:开始渲染模型

    这个方法是对外暴露的,可以由业务自己控制模型开始渲染的时机,一般是在获取到模型文件后

    渲染过程中会利用THREE.LoadingManager进行渲染进度的监控,并通过emit向业务传递加载进度

  • updateSize:更新渲染窗口

    这个方法是对外暴露的,会监听resize,当屏幕窗口改变时会及时更新渲染区域大小,也可以用于全屏展示3D场景的一些需求来调用

  • reset:重置模型

    这个方法是对外暴露的,可以由业务在一些需要重置模型到初始视角的时候调用

  • zoomIn:前进

    这个方法是对外暴露的,可以用来向世界原点前进一定的距离,距离可传参控制,默认是1m

  • zoomOut:后退

    这个方法是对外暴露的,可以用来向世界原点后退一定的距离,距离可传参控制,默认是1m

  • startAnimation:开始动画

    这个方法是对外暴露的,可以用来在特定的时机开启动画

如果你的需求仅限于【模型都是glb格式,且只需要无限循环动画】,那么你完全可以直接使用上面这个组件,只需要

  1. 在你的业务代码中引入其作为你的子组件
  2. 通过ref调用该组件的render方法,并传递你的模型url数组
  3. 在合适的时机调用startAnimation开始动画

注意,目前World和CameraActor里初始化的参数是写死的,如果不满足你的业务需求,你可以在这个框架上进行一些重构,例如:

抽离一个CameraBase,然后new一个Camera类继承于CameraBase,在这个新类中配置业务独特的参数。当然你可以有更好的方案设计。

此外,如果你需要更多的功能,你也可以在框架内部进行补充,目前的功能是比较简单的

  • 如果你需要更多的动画控制方法

    你可以在DriveComponent中,动态的添加,目前支持的功能较少,根据需要你还可以添加

    播放第N个动作,播放上一个动作,播放下一个动作,拖动播放,修改帧率等等

  • 如果你需要更多的模型操作方法,比如双击移动到某个视角

    你可以在Camera类中添加相关的方法

  • 如果你的模型格式不是glb,是obj或者fbx格式,或者你有一些texture贴图要渲染

    那么你可能需要重新写一个vue组件,或者另外写一个loadModel方法,来适配不同的模型格式

  • 如果你的动画不是像glb模型这样,可以利用AnimationMixer控制动画,比如实现骨骼动画

    那么你可能需要在你的Actor上挂载新的DriveComponent,然后实现你特定的动画逻辑

  • 如果你需要对模型更换皮肤等功能

    那么你可以设计一个MeshComponent类挂载到你的Actor上,用于控制换肤等操作 ......

相关推荐
LFly_ice2 分钟前
Next-1-启动!
开发语言·前端·javascript
小时前端4 分钟前
谁说 AI 历史会话必须存后端?IndexedDB方案完美翻盘
前端·agent·indexeddb
wordbaby9 分钟前
TanStack Router 基于文件的路由
前端
wordbaby13 分钟前
TanStack Router 路由概念
前端
wordbaby16 分钟前
TanStack Router 路由匹配
前端
cc蒲公英17 分钟前
vue nextTick和setTimeout区别
前端·javascript·vue.js
程序员刘禹锡21 分钟前
Html中常用的块标签!!!12.16日
前端·html
我血条子呢32 分钟前
【CSS】类似渐变色弯曲border
前端·css
DanyHope32 分钟前
LeetCode 两数之和:从 O (n²) 到 O (n),空间换时间的经典实践
前端·javascript·算法·leetcode·职场和发展
hgz071033 分钟前
企业级多项目部署与Tomcat运维实战
前端·firefox