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上,用于控制换肤等操作 ......

相关推荐
轻口味17 分钟前
命名空间与模块化概述
开发语言·前端·javascript
前端小小王1 小时前
React Hooks
前端·javascript·react.js
迷途小码农零零发1 小时前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀1 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪2 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6414 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js