阅读视图

发现新文章,点击刷新页面。

【DeepSeek帮我准备前端面试100问】(十五)Vue Hooks详解

Vue Hooks 是 Vue 3 引入的一种新的逻辑复用方式,借鉴了 React Hooks 的概念,但在实现和使用上有自己的特点。下面我将详细介绍 Vue Hooks 的相关知识。

1. 什么是 Vue Hooks

Vue Hooks 是 Vue 3 组合式 API (Composition API) 的核心概念之一,它允许你在组件中提取和复用状态逻辑。与 React Hooks 不同,Vue Hooks 不是真正的"钩子"函数,而是基于 Vue 响应式系统的组合函数。

2. 核心 Hooks

2.1 生命周期 Hooks

Vue 3 提供了与选项式 API 对应的组合式 API 生命周期钩子:

import { onMounted, onUpdated, onUnmounted } from 'vue'

export default {
  setup() {
    onMounted(() => {
      console.log('组件挂载完成')
    })
    
    onUpdated(() => {
      console.log('组件更新完成')
    })
    
    onUnmounted(() => {
      console.log('组件卸载完成')
    })
  }
}

2.2 响应式 Hooks

ref

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    
    function increment() {
      count.value++
    }
    
    return {
      count,
      increment
    }
  }
}

reactive

import { reactive } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      name: 'Vue'
    })
    
    function increment() {
      state.count++
    }
    
    return {
      state,
      increment
    }
  }
}

computed

import { ref, computed } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubleCount = computed(() => count.value * 2)
    
    return {
      count,
      doubleCount
    }
  }
}

watch

import { ref, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    
    watch(count, (newVal, oldVal) => {
      console.log(`count changed from ${oldVal} to ${newVal}`)
    })
    
    return {
      count
    }
  }
}

2.3 自定义 Hooks

你可以创建自己的 Hooks 来复用逻辑:

// useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count,
    increment,
    decrement,
    reset
  }
}

// 在组件中使用
import { useCounter } from './useCounter'

export default {
  setup() {
    const { count, increment } = useCounter(10)
    
    return {
      count,
      increment
    }
  }
}

3. Vue Hooks 与 React Hooks 的区别

  1. 执行时机

    • Vue Hooks 只在 setup() 中调用一次
    • React Hooks 在每次渲染时都会调用
  2. 依赖管理

    • Vue 自动追踪依赖,不需要手动声明依赖数组
    • React 需要手动声明依赖数组
  3. 条件调用

    • Vue Hooks 可以有条件地调用
    • React Hooks 必须在顶层无条件调用
  4. 响应式系统

    • Vue 基于 Proxy 的响应式系统
    • React 基于状态更新的重新渲染

4. 常用第三方 Hooks 库

  1. VueUse:一个强大的 Vue 组合式工具集合

    import { useMouse, useLocalStorage } from '@vueuse/core'
    
    export default {
      setup() {
        const { x, y } = useMouse()
        const storedValue = useLocalStorage('my-key', 'default')
        
        return { x, y, storedValue }
      }
    }
    
  2. vue-composition-toolkit:提供更多实用 Hooks

5. 最佳实践

  1. 命名约定:自定义 Hooks 应以 use 开头,如 useFetchuseCounter

  2. 单一职责:每个 Hook 应该只关注一个特定的功能

  3. 组合使用:可以将多个简单 Hooks 组合成更复杂的逻辑

  4. 类型支持:使用 TypeScript 可以获得更好的类型提示

// useFetch.ts
import { ref } from 'vue'

interface UseFetchOptions {
  immediate?: boolean
}

export function useFetch<T>(url: string, options: UseFetchOptions = {}) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  
  async function execute() {
    loading.value = true
    try {
      const response = await fetch(url)
      data.value = await response.json()
    } catch (err) {
      error.value = err as Error
    } finally {
      loading.value = false
    }
  }
  
  if (options.immediate) {
    execute()
  }
  
  return {
    data,
    error,
    loading,
    execute
  }
}

6. 总结

Vue Hooks 通过组合式 API 提供了一种更灵活、更可复用的代码组织方式。它使得逻辑关注点可以更好地分离和组合,提高了代码的可读性和可维护性。随着 Vue 3 的普及,Vue Hooks 已经成为现代 Vue 开发的重要模式。

跟着文档学VUE3(五)- 条件渲染

🧭 Vue 条件渲染:v-if 与 v-show 的使用详解

在 Vue 开发中,条件渲染是一个非常基础且常用的特性。通过 v-ifv-show 指令,我们可以灵活控制 DOM 元素的显示与隐藏逻辑。

🧱 v-if —— 条件性地渲染元素

✅ 基本特点:

  1. 只在表达式为真值时才会渲染该元素
  2. 支持链式结构v-else 和 v-else-if 必须紧接在 v-if 或另一个 v-else-if 元素之后,否则不会被识别。
  3. 可用于 <template> 标签上:这意味着你可以同时控制多个元素的显示,而最终渲染结果不会包含 <template> 这个标签

💡 使用示例:

<template v-if="isLoggedIn"> 
  <h1>欢迎回来!</h1>
  <p>您已成功登录。</p>
</template>

🎬 v-show —— 切换 CSS 的 display 属性

✅ 基本特点:

  1. 通过切换 display: none 控制元素的可见性,元素始终存在于 DOM 中。

  2. 不支持 <template> 标签

  3. 性能对比

    • v-if 是“惰性”的,如果条件为假,元素不会被渲染,因此有更高的切换开销。
    • v-show 则是“懒惰程度较低”,无论条件真假都会渲染,只是控制显示状态,所以更适合频繁切换的场景

⚠️ 注意事项:不要混用 v-if 与 v-for

v-ifv-for 同时出现在一个元素上时:

  • v-if 会优先于 v-for 执行
  • 不推荐在同一元素上同时使用这两个指令,因为这样会导致不必要的性能浪费或逻辑混乱。

❌ 不推荐写法:

<li v-for="item in items" v-if="item.isActive">{{ item.name }}</li>

✅ 推荐做法(使用 <template>):

<template v-for="item in items">
  <li v-if="item.isActive">{{ item.name }}</li>
</template>

📊 总结对比表

特性 v-if v-show
渲染方式 条件渲染,元素可能不存在 通过 display 控制显示
初始开销 较低 较高
切换开销 较高 较低
支持 <template> ✅ 是 ❌ 否

🔚 结语

  • 如果你希望按需渲染并节省资源,优先使用 v-if
  • 如果你需要频繁切换显示状态,推荐使用 v-show
  • 避免在同一元素上混用 v-if 和 v-for,保持代码清晰高效

Web端录屏方案调研

话不多说,直接上方案~

方案对比

方案 star 实现成本 原理 特点 live demo
原生API - 调用getDisplayMedia和MediaRecorder,手动管理流和数据块如果需要实现区域录屏,需要canvas绘制 如果需要区域录屏、将录屏文件转为GIF,需要额外开发
RecordRTC 6.7k 需二次封装 封装MediaRecorder 自动合并数据块生成完整视频;支持webm→mp4转换(需集成FFmpeg.js);兼容旧版浏览器(通过adapter.js)如果需要区域录屏、将录屏文件转为GIF,需要额外开发 www.webrtc-experiment.com/RecordRTC/s…Screen RecordingRecord Cropped Screen

文件转GIF,可使用 gif.js

技术方案

原生API方案(无第三方依赖)

实现方式:直接调用getDisplayMediaMediaRecorder,手动管理流和数据块。

核心代码(React示例):

import React, { useState, useRef } from 'react';
import { Button, Modal } from 'antd';
const NativeScreenRecorder = () => {
  const [isRecording, setIsRecording] = useState(false);
  const [previewUrl, setPreviewUrl] = useState('');
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
  const chunksRef = useRef<BlobPart[]>([]);
  const startRecording = async () => {
    try {
      // 获取屏幕流(含系统声音)
      const stream = await navigator.mediaDevices.getDisplayMedia({
        video: { cursor: 'always' },
        audio: { echoCancellation: false }
      });
      // 初始化MediaRecorder
      mediaRecorderRef.current = new MediaRecorder(stream);
      mediaRecorderRef.current.ondataavailable = (e) => chunksRef.current.push(e.data);
      mediaRecorderRef.current.onstop = () => {
        const blob = new Blob(chunksRef.current, { type: 'video/webm' });
        const url = URL.createObjectURL(blob);
        setPreviewUrl(url);
        stream.getTracks().forEach(track => track.stop()); // 释放资源
      };
      mediaRecorderRef.current.start();
      setIsRecording(true);
    } catch (err) {
      console.error('录屏启动失败:', err);
    }
  };
  const stopRecording = () => {
    mediaRecorderRef.current?.stop();
    setIsRecording(false);
    chunksRef.current = []; // 清空数据块
  };
  return (
    <div>
      <Button onClick={startRecording} disabled={isRecording}>开始录屏</Button>
      <Button onClick={stopRecording} disabled={!isRecording} danger>结束录屏</Button>

      <Modal
        visible={!!previewUrl}
        title="录制预览"
        onCancel={() => {
          URL.revokeObjectURL(previewUrl);
          setPreviewUrl('');
        }}
      >
        <video src={previewUrl} controls autoPlay style={{ width: '100%' }} />
      </Modal>
    </div>
  );
};

优缺点:

优点 缺点
无第三方依赖,体积小 需手动处理流管理、错误捕获
支持最高质量录制(原始流) 仅支持WebM格式
完全控制录制流程 系统声音录制需用户手动启用实验功能

RecordRTC

功能:封装MediaRecorder,支持屏幕/摄像头/音频录制,提供pause/resume/格式转换等功能。

核心特性:

  • 自动合并数据块生成完整视频;

  • 支持webmmp4转换(需集成FFmpeg.js);

  • 兼容旧版浏览器(通过adapter.js)。

示例代码:

import RecordRTC from 'recordrtc';
const startRecording = async () => {
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
  const recordRTC = new RecordRTC(stream, {
    type: 'video',
    mimeType: 'video/webm',
    disableLogs: true
  });
  recordRTC.startRecording();
  // 停止录制并获取视频
  const stop = () => {
    recordRTC.stopRecording(() => {
      const videoBlob = recordRTC.getBlob();
      const videoUrl = URL.createObjectURL(videoBlob);
      // 预览或下载...
    });
    stream.getTracks().forEach(track => track.stop());
  };
};

优缺点:

优点 缺点
简化流管理和数据处理 依赖外部库(~30KB)
支持格式转换(需FFmpeg) 最新浏览器特性支持滞后

面临的挑战

性能与资源问题

问题场景 具体表现 解决方案
长时间录制内存溢出 - 数据块(chunks)积累导致内存占用过高- 浏览器崩溃或录制卡顿 - 分段录制(每5分钟生成一个文件)- 使用WritableStream实时写入磁盘- 实现内存监控与警告机制

用户体验问题

问题场景 具体表现 解决方案
录屏权限弹窗干扰 - 每次录制都需用户手动授权 - 缓存用户授权状态?(提供"记住此选择"选项)
操作反馈不直观 - 录制中缺少明显状态指示- 暂停/继续操作不流畅 - 提供快捷键支持

开发挑战

问题场景 具体表现 解决方案
API复杂度高 - getDisplayMedia/MediaRecorder参数配置复杂- 状态管理困难 优先使用成熟框架:如RecordRTC,避免重复造轮子

明确功能边界:根据目标用户需求,合理取舍功能

渐进式增强:先实现基础录屏功能,再逐步添加选区录制、编辑等扩展功能

Three.js 完全学习指南(十一)3D 产品展示

3D 产品展示

在 Three.js 中,创建 3D 产品展示是一个常见的应用场景。本章将介绍如何创建一个专业的产品展示系统,包括模型加载、交互控制、动画效果等。

基础场景搭建

1. 场景配置

产品展示示例

图 11.1: 3D 产品展示示例

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);

// 创建相机
const camera = new THREE.PerspectiveCamera(
    45,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(0, 2, 5);

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

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

// 创建主光源
const mainLight = new THREE.DirectionalLight(0xffffff, 1);
mainLight.position.set(5, 5, 5);
mainLight.castShadow = true;
scene.add(mainLight);

// 创建补光
const fillLight = new THREE.DirectionalLight(0xffffff, 0.3);
fillLight.position.set(-5, 3, -5);
scene.add(fillLight);

2. 相机控制

// 创建轨道控制器
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 3;
controls.maxDistance = 10;
controls.maxPolarAngle = Math.PI / 2;

// 创建自动旋转控制器
class AutoRotateController {
    constructor(controls) {
        this.controls = controls;
        this.enabled = false;
        this.speed = 0.5;
    }

    enable() {
        this.enabled = true;
    }

    disable() {
        this.enabled = false;
    }

    update() {
        if (this.enabled) {
            this.controls.autoRotate = true;
            this.controls.autoRotateSpeed = this.speed;
        } else {
            this.controls.autoRotate = false;
        }
    }
}

产品展示系统

1. 产品加载器

// 产品加载器
class ProductLoader {
    constructor() {
        this.loader = new GLTFLoader();
        this.dracoLoader = new DRACOLoader();
        this.dracoLoader.setDecoderPath('/path/to/draco/');
        this.loader.setDRACOLoader(this.dracoLoader);
    }

    loadProduct(url) {
        return new Promise((resolve, reject) => {
            this.loader.load(
                url,
                (gltf) => {
                    const model = gltf.scene;
                    this.setupModel(model);
                    resolve(model);
                },
                undefined,
                (error) => reject(error)
            );
        });
    }

    setupModel(model) {
        model.traverse((child) => {
            if (child.isMesh) {
                child.castShadow = true;
                child.receiveShadow = true;

                // 设置材质
                if (child.material) {
                    child.material.envMapIntensity = 1;
                    child.material.needsUpdate = true;
                }
            }
        });
    }
}

2. 产品控制器

// 产品控制器
class ProductController {
    constructor(model) {
        this.model = model;
        this.animations = new Map();
        this.currentAnimation = null;
        this.setupAnimations();
    }

    setupAnimations() {
        // 创建旋转动画
        this.animations.set('rotate', {
            update: (time) => {
                this.model.rotation.y = time * 0.5;
            }
        });

        // 创建缩放动画
        this.animations.set('scale', {
            update: (time) => {
                const scale = 1 + Math.sin(time) * 0.1;
                this.model.scale.set(scale, scale, scale);
            }
        });
    }

    playAnimation(name) {
        this.currentAnimation = this.animations.get(name);
    }

    update(time) {
        if (this.currentAnimation) {
            this.currentAnimation.update(time);
        }
    }
}

交互系统

1. 热点标记

// 热点标记系统
class HotspotSystem {
    constructor() {
        this.hotspots = new Map();
        this.raycaster = new THREE.Raycaster();
        this.mouse = new THREE.Vector2();
    }

    createHotspot(position, data) {
        const geometry = new THREE.SphereGeometry(0.1, 32, 32);
        const material = new THREE.MeshBasicMaterial({
            color: 0xff0000,
            transparent: true,
            opacity: 0.8
        });

        const hotspot = new THREE.Mesh(geometry, material);
        hotspot.position.copy(position);
        hotspot.userData = data;

        this.hotspots.set(data.id, hotspot);
        return hotspot;
    }

    update(camera, mouse) {
        this.raycaster.setFromCamera(mouse, camera);
        const intersects = this.raycaster.intersectObjects(
            Array.from(this.hotspots.values())
        );

        if (intersects.length > 0) {
            const hotspot = intersects[0].object;
            this.onHotspotHover(hotspot);
        }
    }

    onHotspotHover(hotspot) {
        // 处理热点悬停事件
        console.log('Hotspot hover:', hotspot.userData);
    }
}

2. 交互控制器

// 交互控制器
class InteractionController {
    constructor(camera, renderer) {
        this.camera = camera;
        this.renderer = renderer;
        this.raycaster = new THREE.Raycaster();
        this.mouse = new THREE.Vector2();
        this.selectedObject = null;
        this.setupEventListeners();
    }

    setupEventListeners() {
        this.renderer.domElement.addEventListener('mousemove', (event) => {
            this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        });

        this.renderer.domElement.addEventListener('click', (event) => {
            this.raycaster.setFromCamera(this.mouse, this.camera);
            const intersects = this.raycaster.intersectObjects(scene.children, true);

            if (intersects.length > 0) {
                this.onObjectSelected(intersects[0].object);
            }
        });
    }

    onObjectSelected(object) {
        if (this.selectedObject) {
            // 重置之前选中的对象
            this.selectedObject.material.emissive.setHex(0x000000);
        }

        this.selectedObject = object;
        object.material.emissive.setHex(0x333333);
    }
}

特效系统

1. 环境效果

// 环境效果系统
class EnvironmentSystem {
    constructor(scene) {
        this.scene = scene;
        this.envMap = null;
        this.setupEnvironment();
    }

    setupEnvironment() {
        // 加载环境贴图
        const envLoader = new THREE.CubeTextureLoader();
        this.envMap = envLoader.load([
            'px.jpg', 'nx.jpg',
            'py.jpg', 'ny.jpg',
            'pz.jpg', 'nz.jpg'
        ]);

        this.scene.environment = this.envMap;
        this.scene.background = this.envMap;
    }

    updateLighting(time) {
        // 更新光照效果
        const mainLight = this.scene.getObjectByName('mainLight');
        if (mainLight) {
            mainLight.position.x = Math.sin(time) * 5;
            mainLight.position.z = Math.cos(time) * 5;
        }
    }
}

2. 后期处理

// 后期处理系统
class PostProcessingSystem {
    constructor(renderer) {
        this.renderer = renderer;
        this.composer = new EffectComposer(renderer);
        this.setupPostProcessing();
    }

    setupPostProcessing() {
        // 添加渲染通道
        const renderPass = new RenderPass(scene, camera);
        this.composer.addPass(renderPass);

        // 添加泛光效果
        const bloomPass = new UnrealBloomPass(
            new THREE.Vector2(window.innerWidth, window.innerHeight),
            1.5,
            0.4,
            0.85
        );
        this.composer.addPass(bloomPass);

        // 添加色彩调整
        const colorPass = new ShaderPass(ColorCorrectionShader);
        this.composer.addPass(colorPass);
    }

    render() {
        this.composer.render();
    }
}

实战:创建产品展示场景

让我们创建一个完整的产品展示场景:

// 创建场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 创建控制器
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const autoRotateController = new AutoRotateController(controls);

// 创建产品加载器
const productLoader = new ProductLoader();

// 创建交互系统
const interactionController = new InteractionController(camera, renderer);
const hotspotSystem = new HotspotSystem();

// 创建特效系统
const environmentSystem = new EnvironmentSystem(scene);
const postProcessingSystem = new PostProcessingSystem(renderer);

// 加载产品模型
let productController;
productLoader.loadProduct('product.gltf').then(model => {
    scene.add(model);
    productController = new ProductController(model);

    // 添加热点
    const hotspot1 = hotspotSystem.createHotspot(
        new THREE.Vector3(1, 1, 0),
        { id: 'feature1', title: 'Feature 1' }
    );
    scene.add(hotspot1);
});

// 动画循环
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);

    const time = clock.getElapsedTime();

    // 更新控制器
    controls.update();
    autoRotateController.update();

    // 更新产品控制器
    if (productController) {
        productController.update(time);
    }

    // 更新环境系统
    environmentSystem.updateLighting(time);

    // 更新交互系统
    interactionController.update();
    hotspotSystem.update(camera, interactionController.mouse);

    // 渲染场景
    postProcessingSystem.render();
}

animate();

// 窗口大小改变时更新
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
    postProcessingSystem.composer.setSize(window.innerWidth, window.innerHeight);
});

练习

  1. 创建一个基础的产品展示场景
  2. 实现产品旋转和缩放动画
  3. 添加交互热点和说明
  4. 实现环境效果和后期处理

下一步学习

在下一章中,我们将学习:

  • 数据可视化
  • 图表创建
  • 动态数据更新
  • 交互控制

http-server 的使用说明

http-server 是一个简单的、零配置的命令行静态 HTTP 服务器。它对于生产使用来说足够强大,但它足够简单和可破解,常用于前端开发测试、静态网站预览等场景。

一、File 协议和 Http 协议

  • FIle 协议也叫本地文件传输协议 ,主要用于访问本地计算机中的文件,即 File 协议是访问你本机的文件资源; 基本的格式:file:///文件路径。
  • http 协议是 HyperText Transfer Protocol,即超文本传送协议的缩写。是用来从万维网服务器传输超文本到本地浏览器的传送协议,基于 TCP/IP 通信协议来传输数据。http 协议工作于客户端-服务器架构上,浏览器作为 http 客户端通过 url 向 http 服务器端发送请求,服务器接收到请求后,向客户端发送请求;基本的格式:http://10.4.121.22:9999/index.html

二、 安装方法

# 通过npm全局安装
npm install -g http-server

# 通过yarn安装
yarn global add http-server

三、 使用方法

1、常见命令

命令 描述
-p 或者 --port 端口设置,默认 8080
-P 或者 --proxy 所有无法在本地解析的请求代理到给定的 URL
--proxy-options 传递代理选项。例如:--proxy-options.secure false
-a 要使用的地址,默认为 0.0.0.0
-o 启动服务器后打开浏览器窗口 (可选)提供要打开的 URL 路径。例如:-o /其他/目录/
-g 或者 --gzip 默认 false,是否开启 gzip 访问
–cors 通过 Access-Control-Allow-Origin 标头启用 CORS
-S 或者 –-ssl 启用 https。
-C 或者 -–certssl cert 文件的路径(默认值:) cert.pem。
-K 或者 –-keyssl 密钥文件的路径(默认值:) key.pem。
-c 设置缓存控制 max-age 标头的缓存时间(秒,默认为 3600),-c1010(缓存 1010 秒)。禁用缓存(-c-1)
--mimetypes 用于自定义 mimetype 定义的 .types 文件的路径
-r 或者 -–robots 提供/robots.txt(其内容默认为 User-agent: *\nDisallow: /)
-h 或着 -–help 打印此列表并退出。

2、基础使用

语法 : http-server [path] [options]

# 开启服务
http-server  //启动服务

# 设置监听地址
http-server -a 127.0.0.1

# 设置监听端口
http-server -p 3500

# 禁用缓存
http-server -c-1

# 设置服务代理
//将请到代理到http://172.11.1.10:3500
http-server -P http://172.11.1.10:3500
http-server --proxy http://172.11.1.10:3500

# 命令同时使用
http-server -a 127.0.0.1 -p 35000 -c-1

3、高阶使用

跨域支持

http-server --cor

启动 https 服务

首先,确保您有 key.pem 和 cert.pem 文件。如果安装了 openssl 可使用以下命令生成证书 openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem

//会生成一个证书密钥对,有效期大约为 10 年(准确地说是 3650 天)。
openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem

如果安装了 git,也可以使用 git 的 openssl 模块,生成证书,到对应文件路径下,启动 git bash;执行命令openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem

openssl-git.png

cert.png

生成证后启动 https 服务

http-server -S -C cert.pem

Web端截屏方案调研

话不多说,直接上方案~

方案对比

方案 star 实现成本 原理 特点 live demo
HTML Canvas原生绘制 - 通过HTMLCanvasElement的drawImage方法,将页面元素(如、或DOM节点)绘制到Canvas,再通过toDataURL或toBlob生成图片 如果需要区域截屏、对截图进行标注,需要额外开发 如果需要支持对页面任意结构截图,最好在html2canvas/dom-to-image基础上二次封装
第三方库(html2canvas 31.3k 需二次封装 通过解析DOM结构,模拟浏览器渲染过程,将CSS样式和DOM节点转换为Canvas绘制指令,最终生成图片 支持大部分CSS(flex、grid、伪类),但对SVG支持有限如果需要区域截屏、对截图进行标注,一些方案需要额外开发
第三方库(dom-to-image 10.6k 需二次封装 核心原理是利用 SVG 的 标签嵌入 HTML,再通过 Canvas 渲染为图像 支持更多现代CSS(如filter、clip-path)如果需要区域截屏、对截图进行标注,需要额外开发
第三方库(region-screenshot-js 52 基于 dom-to-imagejquerygetDisplayMedia 实现 实现选区框绘制、拖动缩放、标注工具集成,最终生成带标注的截图 weijun-lab.github.io/region-scre…
视频捕获 - 通过 drawImage(video, 0, 0) 将当前视频帧绘制到 canvas调用 canvas.toDataURL('image/png') 生成图片数据 截取视频某一帧

技术方案

基于HTML Canvas的原生绘制

原理:通过HTMLCanvasElementdrawImage方法,将页面元素(如<video><img>或DOM节点)绘制到Canvas,再通过toDataURLtoBlob生成图片。

适用场景

  • 截取特定DOM节点(如图表、卡片)

  • 截取视频帧或实时画面

实现步骤(React+TS示例)

// 截取指定Ref的DOM节点
const captureElement = async (ref: React.RefObject<HTMLElement>) => {
  const element = ref.current;
  if (!element) return null;
  // 获取元素尺寸
  const { width, height } = element.getBoundingClientRect();

  // 创建Canvas并绘制
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  // 绘制DOM节点(需处理跨域)
  const blob = await new Promise<Blob | null>(resolve =>
    html2canvas(element).then(canvas => canvas.toBlob(resolve))
  );

  return blob;
};

优缺点

优点 缺点
无需第三方依赖 复杂CSS(如阴影、伪类)丢失
支持实时视频帧截取 跨域资源(图片、字体)无法绘制

第三方截图库(html2canvas/dom-to-image)

原理:通过解析DOM结构,模拟浏览器渲染过程,将CSS样式和DOM节点转换为Canvas绘制指令,最终生成图片。

实现示例(React+html2canvas)

import html2canvas from 'html2canvas';
const ScreenshotComponent = () => {
  const targetRef = useRef<HTMLDivElement>(null);
  const handleCapture = async () => {
    if (!targetRef.current) return;

    // 生成截图
    const canvas = await html2canvas(targetRef.current, {
      useCORS: true, // 解决跨域图片问题
      logging: false, // 关闭日志
      backgroundColor: null // 保留透明背景
    });

    // 生成下载链接
    const url = canvas.toDataURL('image/png');
    const a = document.createElement('a');
    a.href = url;
    a.download = 'screenshot.png';
    a.click();
  };
  return (
    <div>
      <div ref={targetRef} style={{ padding: '20px', border: '1px solid #ddd' }}>
        <h3>待截图内容</h3>
        <p>示例文本与图片:<img src="/assets/example.png" alt="示例" /></p>
      </div>
      <Button onClick={handleCapture}>截图</Button>
    </div>
  );
};

兼容性

  • 支持现代浏览器(Chrome 60+、Firefox 53+、Edge 79+)

  • 不支持IE(需polyfill)

  • 部分CSS特性(如box-shadow: insetbackdrop-filter)可能无法正确渲染

选区截屏插件(region-screenshot-js)

原理:基于Canvas和鼠标事件监听,实现选区框绘制、拖动缩放、标注工具集成,最终生成带标注的截图。

核心功能

  • 自由选区(矩形/圆形/多边形)

  • 标注工具(画笔、文字、表情、马赛克)

  • 截图参数配置(初始选区、输出格式)

实现示例(React+TS)

import RegionScreenshot from 'region-screenshot-js';
const RegionScreenshotTool = () => {
  const [screenshotUrl, setScreenshotUrl] = useState('');
  const handleCapture = () => {
    const screenshot = new RegionScreenshot({
      initialRegion: { top: 100, left: 100, width: 800, height: 600 }, // 初始选区
      customDrawing: [
        {
          className: 'emoji',
          optionsHtml: 
            <img class="active" src="/assets/emoji-1.png"/>             <img src="/assets/emoji-2.png"/>          
,
          onDrawingOpen: (canvas, options, save) => {
            canvas.onclick = (e) => {
              const img = options.querySelector('img.active') as HTMLImageElement;
              canvas.getContext('2d')?.drawImage(img, e.offsetX - 10, e.offsetY - 10);
              save();
            };
          }
        }
      ]
    });
    screenshot.on('screenshotGenerated', (url) => {
      setScreenshotUrl(url);
    });
  };
  return (
    <div>
      <Button onClick={handleCapture}>选区截图</Button>
      {screenshotUrl && <img src={screenshotUrl} alt="选区截图" />}
    </div>
  );
};

优势

  • 提供完整的交互组件(选区框、工具面板)

  • 支持自定义标注工具扩展

  • 输出包含标注的最终图片

视频捕获

关键技术点:

  • Canvas 绘图
  • 使用隐藏的 <canvas> 元素
  • 通过 drawImage(video, 0, 0) 将当前视频帧绘制到 canvas
  • 调用 canvas.toDataURL('image/png') 生成图片数据
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
  • 视频时间控制

  • 通过 videoRef.current.currentTime 获取当前播放时间

  • 监听 onTimeUpdate 事件实时更新时间状态

实现示例:

import React, { useState, useRef, useEffect } from 'react';
import { Button, message, Upload, Modal, Image } from 'antd';
import { UploadOutlined, CameraOutlined } from '@ant-design/icons';
import { 
  containerStyle, 
  videoContainerStyle, 
  videoStyle, 
  controlsStyle, 
  previewStyle 
} from './styles';
import { VideoFrameCaptureProps, VideoState } from './types';

const VideoFrameCapture = ({ 
  onCapture,
  previewWidth = 300,
  previewHeight = 180 
}: VideoFrameCaptureProps) => {
  const [videoState, setVideoState] = useState<VideoState>({
    videoUrl: '',
    isLoaded: false,
    currentTime: 0,
  });
  const [capturePreview, setCapturePreview] = useState<string>('');
  const [isPreviewVisible, setIsPreviewVisible] = useState(false);
  const videoRef = useRef<HTMLVideoElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // 处理视频文件上传
  const handleUpload = (file: File) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      if (e.target?.result) {
        setVideoState(prev => ({
          ...prev,
          videoUrl: e.target.result as string,
          isLoaded: false,
        }));
      }
    };
    reader.readAsDataURL(file);
    return false; // 阻止默认上传行为
  };

  // 视频加载完成事件
  const handleVideoLoaded = () => {
    setVideoState(prev => ({ ...prev, isLoaded: true }));
  };

  // 截取当前帧
  const captureFrame = () => {
    if (!videoRef.current || !canvasRef.current) return;

    const video = videoRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');

    // 设置Canvas尺寸与视频一致
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;

    // 绘制当前帧到Canvas
    ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);

    // 生成图片Data URL
    const dataUrl = canvas.toDataURL('image/png');
    setCapturePreview(dataUrl);
    setIsPreviewVisible(true);
    onCapture?.(dataUrl);
    message.success('截图成功!');
  };

  // 视频时间更新监听
  const handleTimeUpdate = () => {
    if (videoRef.current) {
      setVideoState(prev => ({
        ...prev,
        currentTime: videoRef.current.currentTime,
      }));
    }
  };

  return (
    <div style={containerStyle}>
      <h2>视频帧截取</h2>

      {/* 视频上传 */}
      <Upload
        accept="video/*"
        beforeUpload={handleUpload}
        showUploadList={false}
      >
        <Button icon={<UploadOutlined />}>上传视频文件</Button>
      </Upload>

      {/* 视频播放器 */}
      {videoState.videoUrl && (
        <div style={videoContainerStyle}>
          <video
            ref={videoRef}
            style={videoStyle}
            src={videoState.videoUrl}
            controls
            onLoadedData={handleVideoLoaded}
            onTimeUpdate={handleTimeUpdate}
            aria-label="视频播放器"
          />
          
          {/* 隐藏的Canvas用于截图 */}
          <canvas ref={canvasRef} style={{ display: 'none' }} />
        </div>
      )}

      {/* 操作按钮 */}
      <div style={controlsStyle}>
        <Button
          type="primary"
          icon={<CameraOutlined />}
          onClick={captureFrame}
          disabled={!videoState.isLoaded}
          aria-label="截取当前帧"
        >
          截取当前帧
        </Button>
      </div>

      {/* 截图预览模态框 */}
      <Modal
        title="截图预览"
        visible={isPreviewVisible}
        onOk={() => setIsPreviewVisible(false)}
        onCancel={() => setIsPreviewVisible(false)}
        width={previewWidth + 48} // 加上模态框内边距
      >
        <div style={previewStyle}>
          <Image
            src={capturePreview}
            width={previewWidth}
            height={previewHeight}
            alt="截取帧"
          />
        </div>
      </Modal>
    </div>
  );
};

export default VideoFrameCapture;

Three.js 完全学习指南(十)模型加载与动画

模型加载与动画

在 Three.js 中,模型加载和动画是创建复杂 3D 场景的重要组成部分。本章将介绍如何加载和管理 3D 模型,以及如何实现和控制动画效果。

模型加载

1. 基础模型加载

模型加载示例

图 10.1: GLTF 模型加载示例

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';

// 创建加载器
const loader = new GLTFLoader();

// 配置 DRACO 加载器(用于压缩模型)
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/path/to/draco/');
loader.setDRACOLoader(dracoLoader);

// 加载模型
loader.load(
    'model.gltf',
    (gltf) => {
        const model = gltf.scene;
        scene.add(model);
    },
    (xhr) => {
        console.log((xhr.loaded / xhr.total * 100) + '% loaded');
    },
    (error) => {
        console.error('An error happened:', error);
    }
);

2. 模型管理器

// 模型管理器
class ModelManager {
    constructor() {
        this.loader = new GLTFLoader();
        this.models = new Map();
        this.loadingManager = new THREE.LoadingManager();
        this.setupLoadingManager();
    }

    setupLoadingManager() {
        this.loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
            console.log(`Loading: ${itemsLoaded}/${itemsTotal}`);
        };

        this.loadingManager.onError = (url) => {
            console.error('Error loading:', url);
        };
    }

    loadModel(name, url) {
        return new Promise((resolve, reject) => {
            this.loader.load(
                url,
                (gltf) => {
                    this.models.set(name, gltf);
                    resolve(gltf);
                },
                undefined,
                (error) => reject(error)
            );
        });
    }

    getModel(name) {
        return this.models.get(name);
    }

    dispose() {
        this.models.forEach(model => {
            model.scene.traverse((object) => {
                if (object.isMesh) {
                    object.geometry.dispose();
                    object.material.dispose();
                }
            });
        });
        this.models.clear();
    }
}

骨骼动画

1. 基础骨骼动画

// 骨骼动画控制器
class SkeletonAnimation {
    constructor(model) {
        this.model = model;
        this.mixer = new THREE.AnimationMixer(model);
        this.actions = new Map();
        this.currentAction = null;
        this.init();
    }

    init() {
        // 获取所有动画
        this.model.animations.forEach(clip => {
            const action = this.mixer.clipAction(clip);
            this.actions.set(clip.name, action);
        });
    }

    play(name, options = {}) {
        const action = this.actions.get(name);
        if (!action) return;

        // 停止当前动画
        if (this.currentAction) {
            this.currentAction.fadeOut(0.5);
        }

        // 设置新动画
        action.reset();
        action.fadeIn(0.5);
        action.play();

        // 设置动画参数
        if (options.loop !== undefined) {
            action.loop = options.loop;
        }
        if (options.timeScale !== undefined) {
            action.timeScale = options.timeScale;
        }

        this.currentAction = action;
    }

    update(deltaTime) {
        this.mixer.update(deltaTime);
    }
}

2. 动画混合

// 动画混合器
class AnimationBlender {
    constructor(model) {
        this.model = model;
        this.mixer = new THREE.AnimationMixer(model);
        this.actions = new Map();
        this.activeActions = new Set();
    }

    addAction(name, clip, weight = 1.0) {
        const action = this.mixer.clipAction(clip);
        action.weight = weight;
        this.actions.set(name, action);
    }

    blend(name, weight, duration = 0.5) {
        const action = this.actions.get(name);
        if (!action) return;

        // 淡入新动画
        action.reset();
        action.fadeIn(duration);
        action.play();

        // 淡出其他动画
        this.activeActions.forEach(activeAction => {
            if (activeAction !== action) {
                activeAction.fadeOut(duration);
            }
        });

        this.activeActions.add(action);
    }

    update(deltaTime) {
        this.mixer.update(deltaTime);
    }
}

动画控制

1. 动画状态机

// 动画状态机
class AnimationStateMachine {
    constructor(model) {
        this.model = model;
        this.blender = new AnimationBlender(model);
        this.states = new Map();
        this.currentState = null;
        this.transitions = new Map();
    }

    addState(name, clip, weight = 1.0) {
        this.blender.addAction(name, clip, weight);
        this.states.set(name, { clip, weight });
    }

    addTransition(from, to, condition) {
        if (!this.transitions.has(from)) {
            this.transitions.set(from, new Map());
        }
        this.transitions.get(from).set(to, condition);
    }

    update(deltaTime, input) {
        // 检查状态转换
        if (this.currentState) {
            const transitions = this.transitions.get(this.currentState);
            if (transitions) {
                for (const [nextState, condition] of transitions) {
                    if (condition(input)) {
                        this.transitionTo(nextState);
                        break;
                    }
                }
            }
        }

        // 更新动画
        this.blender.update(deltaTime);
    }

    transitionTo(state) {
        if (this.states.has(state)) {
            this.currentState = state;
            this.blender.blend(state, this.states.get(state).weight);
        }
    }
}

2. 动画事件系统

// 动画事件系统
class AnimationEventSystem {
    constructor() {
        this.events = new Map();
    }

    addEvent(name, time, callback) {
        if (!this.events.has(name)) {
            this.events.set(name, new Map());
        }
        this.events.get(name).set(time, callback);
    }

    checkEvents(mixer, clip, time) {
        const events = this.events.get(clip.name);
        if (!events) return;

        events.forEach((callback, eventTime) => {
            if (Math.abs(time - eventTime) < 0.1) {
                callback();
            }
        });
    }
}

实战:创建一个动画场景

让我们创建一个展示模型加载和动画效果的场景:

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

// 创建相机
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(0, 2, 5);

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

// 创建模型管理器
const modelManager = new ModelManager();

// 创建动画状态机
let animationStateMachine;

// 加载模型
modelManager.loadModel('character', 'character.gltf').then(gltf => {
    const model = gltf.scene;
    model.scale.set(1, 1, 1);
    model.position.set(0, 0, 0);
    scene.add(model);

    // 设置动画状态机
    animationStateMachine = new AnimationStateMachine(model);

    // 添加动画状态
    gltf.animations.forEach(clip => {
        animationStateMachine.addState(clip.name, clip);
    });

    // 添加状态转换
    animationStateMachine.addTransition('idle', 'walk', () => {
        return input.keys.has('w') || input.keys.has('a') ||
               input.keys.has('s') || input.keys.has('d');
    });

    animationStateMachine.addTransition('walk', 'idle', () => {
        return !input.keys.has('w') && !input.keys.has('a') &&
               !input.keys.has('s') && !input.keys.has('d');
    });

    // 设置初始状态
    animationStateMachine.transitionTo('idle');
});

// 创建输入控制器
const input = {
    keys: new Set(),
    init() {
        window.addEventListener('keydown', (e) => this.keys.add(e.key));
        window.addEventListener('keyup', (e) => this.keys.delete(e.key));
    }
};
input.init();

// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);

// 动画循环
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);

    const deltaTime = clock.getDelta();

    // 更新动画状态机
    if (animationStateMachine) {
        animationStateMachine.update(deltaTime, input);
    }

    renderer.render(scene, camera);
}

animate();

练习

  1. 加载一个 3D 模型
  2. 实现基本的骨骼动画
  3. 创建动画状态机
  4. 添加动画事件系统

下一步学习

在下一章中,我们将学习:

  • 3D 产品展示
  • 模型交互
  • 相机控制
  • 场景优化

我的 package.json 中的一些有趣的脚本

持续更新中…… 第一次更新 2025-5-23

1. 📝 package.json 的“巧妙”注释

常用于分组或者注释某一条比较难以阅读命令

比如分组:

{
  "scripts": {
    "========== Linting ==========": "",
    "lint": "biome lint && tsc --noEmit",
    "lint:fix": "biome check --write --unsafe",
    "prepare": "husky",
    "lint-staged": "biome check --staged --fix",
    
    "========== Testing ==========": "",
    "test": "vitest run --typecheck",
    "test:cov": "vitest run --typecheck --coverage",
    "ci": "npm run test:cov",
    
    "========== Publishing ==========": "",
    "pub:patch": "npm version patch",
    "pub:minor": "npm version minor",
    "pub:major": "npm version major",
    
    "preversion": "nvm use 22 && git pull && git push origin HEAD && pnpm i && npm run build-test-lint-concurrently",
    "postversion": "npm publish && git push origin HEAD && git push --tags",

    "build-test-lint-concurrently": "npm run build && concurrently -n 🧪,🔍 -p name \"npm run test:cov\" \"npm run lint\"",
    "publishd": "npm publish --dry-run",
  }
}

或解释某条 script:

{
  "scripts": {
     "bun-test-staged": "bun test $(git status -s | awk '{print $2}')",
     "// 失败自动 watch": "",
     "test:staged": "bun bun-test-staged || bun bun-test-staged --watch",
    }

2. 🧪 仅运行修改新增的测试文件

{
  "scripts": {
    "test:staged": "bun test $(git status -s | awk '{print $2}')",
  }
}

为什么不用 git diff --name-only? 因为新增文件不会输出。

如果你只想测试某次修改或新增的单测文件,执行 bun test:staged 即可,这在本地测试阶段非常有用。比如存在如下修改:

modified:   package.json
modified:   src/openApp.edit-last-one-multi-modal.test.tsx
modified:   src/openApp.edit.test.tsx
modified:   tests/formatHTML.ts
Added:      demo # git diff 不会列出新增的文件,只会列出文件夹

将只会执行 test 结尾的两个文件 demo 文件夹内的 test 文件。

bun 很聪明,一股脑将文件给他,自己会过滤出符合测试模式的文件,无需 grep 一大堆正则表达式,而且虽然 git status 不会列出新增的文件,只会列出文件夹,但是 bun 也能自动过滤 😎!

能否更完善点?如果测试文件没有改动但是对应的源码动了,也执行那就更好了。

比如修改了 foo.ts,foo.test.ts 也自动执行,有个难点,若仅按照文件名规则 *.test.ts[x] 会有遗漏,还是需要检查引用了 foo 模块且是 test 后缀的文件。但这样速度就很慢了,需要结合 oxc 或 biome 的快速 parser。 暂时先这样吧!

3. 🚚 本地一行命令自动发布 npm 包

{
  "scripts": {
    "========== Publishing ==========": "",
    "pub:patch": "npm version patch",
    "pub:minor": "npm version minor",
    "pub:major": "npm version major",
    "preversion": "nvm use 22 && git pull && git push origin HEAD && pnpm i && npm run build",
    "postversion": "npm publish && git push origin HEAD && git push --tags",
    "prebuild": "concurrently -n 🧪,🔍 -p name \"npm run test:cov\" \"npm run lint\"",
    "publishd": "npm publish --dry-run",
  }
}

详见 0 依赖 1 行命令发布 NPM 项目

4. 📦 指定特定包管理器

比如某个老项目必须使用 yarn 安装:

"preinstall": "npx only-allow yarn",

上述钩子会在安装前通过 only-allow 校验包管理器是否是 yarn。

若尝试后发现会拖慢包安装的速度,可改成 pnpxbunx

5. 🕵️‍♂️ npm run ... 之前检查 Node.js 版本号

虽然我们已经通过脚本在切换项目目录的时候自动切换版本号,但是若存在多个项目,A 项目仍然可能被其他项目“意外”切换版本号,所以仍然需要在每次运行的时候再次确认下版本号。

假设某个项目必须 Node.js v22 (LTS) 以上版本:

{
  "scripts": {
    "check-node-version": "node -e \"const version = process.versions.node.split('.')[0]; console.log('\\n', version >= 22 ? '✅ ' + require('chalk').green('Node.js 版本号正确') : (process.exitCode = 1, '❌ ' + require('chalk').bgRed('需 >= v22')), '\\n')\"",
    "predev": "check-node-version"
  }
}

不限于 dev 之前检查,predev 改成 pre{target} 即可。

上述一行代码展开后:

const version = process.versions.node.split('.')[0]; 

console.log(
  '\n',
  version >= 22 ? 
    '✅ ' + require('chalk').green('Node.js 版本号正确') : 
    (process.exitCode = 1, '❌ ' + require('chalk').bgRed('需 >= v22')),
  '\n'
)

关于为什么使用 process.exitCode 而非“粗暴”的 process.exit(),详见 process.exit([code]),总结来说就是因为太粗暴,而前者更“优雅”。还有一种优雅方式是 throw Error,针对版本号范围检测可以使用 RangeError

image.png

Three.js 基础

Three.js 基础

数学基础

常用三角函数值:

  • sqrt(3)=1.732sqrt(3) = 1.732
  • sqrt(2)=1.414sqrt(2) = 1.414
  • sqrt(2)/2=0.707sqrt(2) / 2 = 0.707

常用向量值:

  • sqrt(3)/3=0.577sqrt(3) / 3 = 0.577 立方体对角线单位向量

仅讨论四维向量与矩阵,欧拉角和四元数作为补充

Vector

一个四维列向量:

vector=[x,y,z,w]Tvector = [x,y,z,w]^T

其中

  • x, y, z三个值可以在三维坐标系中确定一个点的位置

  • 坐标系原点(0, 0, 0)不再另行表示

那么

  • 如果w为0,那么vector就表示从原点到(x, y, z)的连线指向(x, y, z)的这个方向
  • 如果w不为0,那么vector就表示一个点的位置,w为从原点到这个点的距离

Matrix

image.png

其中[m11,m21,m31,0]T[m_{11}, m_{21}, m_{31}, 0]^T表示旋转后X轴所在的新轴位置,及缩放系数(新向量长度/1)

同理,[m12,m22,m32,0]T[m_{12}, m_{22}, m_{32}, 0]^T表示旋转后Y轴所在的新轴位置及缩放系数;[m13,m23,m33,0]T[m_{13}, m_{23}, m_{33}, 0]^T表示旋转后Z轴所在的新轴位置及缩放系数

[tx,ty,tz,1]T[t_x, t_y, t_z, 1]^T表示旋转后基于新X, Y, Z轴进行平移

缩放、旋转、平移的顺序参考opengl-tutorial##累积变换

参考注意行优先列优先的顺序,注意three.js中的Matrix,用set赋值时是行优先,而打印出来是列优先填充的,即

const m = new THREE.Matrix4();

m.set( 11, 12, 13, 14,
    21, 22, 23, 24,
    31, 32, 33, 34,
    41, 42, 43, 44 );

console.log(m);

image.png

补充:欧拉角 Euler

欧拉角是除了变换矩阵之外的另一种描述物体旋转的方式,通过给定绕XYZ三个轴进行旋转的角度和旋转顺序(旋转顺序不同会导致结果不同,这里不详细说明),来描述物体旋转。

欧拉角的局限性:
万向节死锁

动态欧拉角:当一个轴已经旋转过了,那么它不会再随着后面的角再次旋转。

示例如图,按照红绿蓝的顺序从外向里转动,那么先动的轴不会随着后动的轴转动 (参考无伤理解欧拉角中的“万向死锁”现象) image.png没有万向节死锁时,对任意方向上的旋转都可以只按一个轴旋转达成

假设旋转顺序为XYZ,

  • 第一次旋转绕X轴,带动Y轴和Z轴旋转
  • 第二次旋转绕Y轴,带动Z轴旋转,并将Z轴旋转到了X轴平行的位置(例如旋转90度)
  • 第三次旋转绕Z轴,但是此时Z轴与X轴平行(蓝圈与红圈共面)

此时出现了万向节死锁问题:即此时缺少了一个方向上的自由度,如果想要在这个方向上移动的话,那么需要动用其他两个轴来达成想要的移动,那么会导致中间状态不是线形变化,如果只观测首尾状态那么没有影响,但如果需要动画观测,则会出现弧线变化 (参考 Euler (gimbal lock) Explained

image.png image.png

补充:四元数 Quaternion

简单理解,四元数是欧拉角的上位替换,用四个值表示绕任意轴的旋转并且没有万向节死锁问题、支持线形插值。

image.png

Matrix4、欧拉角与四元数

欧拉角和四元数可以替换,四元数没有万向节死锁并且支持线形插值。

但是Matrix4中的旋转矩阵可以表示的内容并不能被欧拉角或四元数完全替代,例如以原点对称镜像

原点对称镜像:

const addBox = (x,y,z) => {
    const geometry = new THREE.BoxGeometry(10, 5, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const cube = new THREE.Mesh(geometry, material);
    // 将所有轴正负方向反转
    const matrix =  new THREE.Matrix4(
        -1, 0,  0, x,
        0, -1,  0, y,
        0,  0, -1, z,
        0,  0,  0, 1,
    )
    cube.applyMatrix4(matrix)
    console.log(
        {
            'cube.rotation': cube.rotation,
            'cube.scale': cube.scale
        }
    )
    const axis = new THREE.AxesHelper(10);
    cube.add(axis);
    scene.add(cube);
    return cube;
};
const box = addBox(10,10,10);

image.png

根据原点镜像对称不是一个可以用欧拉角表示的变换,需要在欧拉角的基础上再附加scale来进行表示 image.png

场景

Scene

一切继承自Object3D的类(Cameras、Lights、Mesh)的实例都应该被添加到Scene中使用。

Light

光源主要是按照来源进行区分。

  • 不能投射阴影的光源: 环境光、半球光、平面光
  • 可以投射阴影的充分条件:
    1. 光源是一个点(点光源、聚光灯)
    2. 光源方向一致(平行光源、聚光灯)
  • 环境光AmbientLight

    环境光会均匀的照亮场景中的所有物体。
    环境光不能用来投射阴影,因为它没有方向。

  • 点光源(PointLight)

    • 从一个点向各个方向发射的光源。一个常见的例子是模拟一个灯泡发出的光。
    • 该光源可以投射阴影
  • 平行光(DirectionalLight)

    • 平行光是沿着特定方向发射的光。这种光的表现像是无限远,从它发出的光线都是平行的。常常用平行光来模拟太阳光的效果。 太阳足够远,因此我们可以认为太阳的位置是无限远,所以我们认为从太阳发出的光线也都是平行的。
    • 平行光可以投射阴影
  • 半球光(HemisphereLight)

    • 光源直接放置于场景之上,光照颜色从天空光线颜色渐变到地面光线颜色。
    • 半球光不能投射阴影。

  • 平面光光源(RectAreaLight)

    • 平面光光源从一个矩形平面上均匀地发射光线。这种光源可以用来模拟像明亮的窗户或者条状灯光光源。
    • 不支持阴影。
  • 聚光灯(SpotLight)

    • 光线从一个点沿一个方向射出,随着光线照射的变远,光线圆锥体的尺寸也逐渐增大。
    • 该光源可以投射阴影

实体

这里讨论的实体是添加在场景中的部分对象。

还有很多实体,参考three.js文档/物体

Mesh、Line、Group

  • Mesh

    • Mesh基于 Geometry和Material 描述的一个实体,继承自Object3D,是一个具有局部坐标系的实体
  • Line

    const points = []; 
    points.push( new THREE.Vector3( - 10, 0, 0 ) ); 
    points.push( new THREE.Vector3( 0, 10, 0 ) ); 
    points.push( new THREE.Vector3( 10, 0, 0 ) ); 
    const geometry = new THREE.BufferGeometry().setFromPoints( points );
    
    • Line 也是Object3D的子类,跟Mesh同层,基于BufferGeometry().setFromPoints方法绘制出的线框几何体再结合material绘制出Line实体
    const line = new THREE.Line( geometry, material );
    
  • Group

    • 将几个实体组合在一起,看做一个整体,提供一个新的局部坐标系,便于统一操作位姿

Material

材质描述了对象Object3D的外观。

材质决定了实体如何跟光照互动,模拟现实世界中的各种材质在光照下的不同视觉效果。

材质分类:
绘制Line
绘制Mesh

Geometries(GeometryBuffer)

TextGeometry

Cameras

参考这个例子来直观看到透视相机和正交相机的区别。

PerspectiveCamera(透视摄像机)

模拟人眼,近大远小

具有这些属性来定义相机的视锥体:

  • fov — 摄像机视锥体垂直视野角度
  • aspect — 摄像机视锥体长宽比
  • near — 摄像机视锥体近端面
  • far — 摄像机视锥体远端面

通过fovaspect可以修改可见视野角度和宽高比,注意是fov是从垂直方向给定的角度

通过nearfar来修改可见视野距离

const perspectiveCamera = new THREE.PerspectiveCamera(
    60,  // fov
    2,   // aspect
    10,  // near
    20   // far
)

const addPerspectiveCamera = () => {
    scene.add(perspectiveCamera)
    perspectiveCamera.position.set(0, 0, 0);
    const destination = new THREE.Vector3(0, 0, 1);
    perspectiveCamera.lookAt(destination);
    const rotation = new Euler().copy(perspectiveCamera.rotation);
    const cameraHelper = new THREE.CameraHelper(perspectiveCamera);
    scene.add(cameraHelper);
}
addPerspectiveCamera();

对以上代码,借助 CameraHelper,可以将添加的相机视锥体可视化:

image.png 垂直方向上为设定的fov = 60, 水平方向上为根据fov和aspect计算出的结果,这里水平方向计算结果应该是90deg

image.png image.png
垂直方向视锥角度 60度 水平方向视锥角度 90度

OrthographicCamera(正交摄像机)

物体大小始终保持不变

具有这些属性来定义相机的视锥体:

  • left — 摄像机视锥体左侧面。
  • right — 摄像机视锥体右侧面。
  • top — 摄像机视锥体上侧面。
  • bottom — 摄像机视锥体下侧面。
  • near — 摄像机视锥体近端面。
  • far — 摄像机视锥体远端面。
const orthographicCamera = new THREE.OrthographicCamera(
    -10, // left
    10,  // right
    10,  // top
    -10, // bottom
    10,  // near
    20   // far
)
const addOrthographicCamera = () => {
    scene.add(orthographicCamera)
    orthographicCamera.position.set(0, 0, 0);
    const destination = new THREE.Vector3(0, 0, 1);
    orthographicCamera.lookAt(destination);
    const cameraHelper = new THREE.CameraHelper(orthographicCamera);
    scene.add(cameraHelper);
}
addOrthographicCamera();

对以上代码,借助 CameraHelper,可以将添加的相机视体可视化:

image.png 正交相机视体是一个矩形,根据left、right、top、bottom、near、far圈定正交相机视体。

透视相机与正交相机对比

在相机的视体中添加一个矩形Mesh:

const addBox = (x, y, z) => {
    const geometry = new THREE.BoxGeometry(5, 3, 1);
    const material = new THREE.MeshBasicMaterial({color: 0xffffff});
    const box = new THREE.Mesh(geometry, material);
    box.position.set(x, y, z);
    const axis = new THREE.AxesHelper(10);
    box.add(axis);
    scene.add(box);
    return box;
};
const box = addBox(0, 0, 15);

image.png

  1. 切换到正交相机观看:
补充:Layers

相机具有一个layers属性,其值为一个Layers对象。Layers类是与Object3D同层的一个类。对每个Object3D对象,都具有一个layers属性,用于控制该Object3D对象是否在某个camera中显示。当 camera 的内容被渲染时,与其共享图层相同的物体会被显示。每个对象都需要与一个 camera 共享图层。

补充:

如何获取任意一个点在相机上投影的二维坐标(相对屏幕坐标系,原点位于canvas左上角)?

借助该点在世界坐标系中的三维向量表示,并将其使用vector3.project()方法投射到相机上:

window.getProjectedPointPosition = () => {
    const point = box.position.clone();
    const projectedPoint = point.clone().project(currentCamera);
    const width = window.innerWidth;
    const height = window.innerHeight;
    const screenX = Math.round((projectedPoint.x + 1) * width / 2);
    const screenY = Math.round((-projectedPoint.y + 1) * height / 2);
    console.log(projectedPoint);
    console.log("Screen Coordinates:", screenX, screenY);
}
  • projectedPoint 是标准化的坐标,范围在 [-1, 1] 之间, -1 是屏幕的最左边,1 是屏幕的最右边,-1 是屏幕的最下边,1 是屏幕的最上边
  • 计算结果超出这个范围则不在视锥可见范围内

project方法内,相机的世界坐标的逆矩阵和projectionMatrix被用来计算投影:

project( camera ) {

    return this.applyMatrix4( camera.matrixWorldInverse ).applyMatrix4( camera.projectionMatrix );

}

其中,applyMatrix4执行的是如下运算,行向量右乘:

Vectorthis×worldTcamera×cameraTscreenVector_{this} \times ^{world}T_{camera} \times ^{camera}T_{screen}

另:如何获取屏幕上任意一点在三维世界坐标系中的位置?

这个问题是从2D获取3D位置,有一个自由度上的信息缺失,所以需要补充额外信息,即问题变为

如何获取屏幕上任意一点,其在三维世界坐标系中的射线,与某个Mesh上第一次相交的点的三维坐标系位置?

借助于Three.js的核心类:光线投射Raycaster

window.addEventListener('click', (event) => {
    // 同样需要将屏幕坐标转为[-1, 1]的标准化设备坐标(**Normalized Device Coordinates**)
    const NCD = new THREE.Vector2();
    console.log('x,y', event.clientX, event.clientY);
    // 一元方程转换
    // 对于x,屏幕从左到右x递增,NCD.x从-1变到1,NCD.x与x正相关,NCD.x = ax + b, a > 0, b < 0
    // 对于y,屏幕从上到下y递增,NCD.y从1变到-1,NCD.y与y负相关,NCD.y = ax + b, a < 0, b > 0
    NCD.x = (event.clientX / window.innerWidth) * 2 - 1;
    NCD.y = -(event.clientY / window.innerHeight) * 2 + 1;
    console.log('NCD', NCD)

    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(NCD, camera);    // 从相机沿NCD方向发出射线
    // 与box的相交点
    const intersects = raycaster.intersectObject(box);

    if (intersects.length > 0) {
        const point = intersects[0].point;
        console.log("Intersection point:", point);
    }
});

设置几何体的位姿的方法:

1. 通过设置四维matrix

参考数学基础

优势:可以对不同几何体中方便根据已有位置进行copy

缺点:对使用者来说需要阅读matrix,转为人类语言较不容易

2. 通过position、up、lookAt属性

  • step1: position可以确定物体的位置,固定3个自由度,还剩3个自由度
  • step2: lookAt 将物体 z 轴指向 世界坐标系中的目标点,还剩1个自由度
  • step3: up 在step1和step2基础上,将物体绕z轴旋转,使得物体的y轴与物体的up属性的向量在世界坐标系中重合/平行/共面;这一步可能无法达成,如果up向量与step2确定的z轴平行。(Three.js 会自动处理这种情况,选择一个合理的up方向。)

注意up属性的设置需要在lookAt调用之前

示例:
  1. 默认情况,up[0,1,0][0, 1, 0]
const addBox = (
    // position={x:0, y:0, z:0},
    //             lookAt={x:0, y:0, z:0},
    //             up={x:0, y:1, z:0},
    x,y,z
                ) => {
    const geometry = new THREE.BoxGeometry(10, 5, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const box = new THREE.Mesh(geometry, material);
    box.position.set(x, y, z); // step1: 设置位置
    // 默认情况,这行代码可以省略
    box.up = new THREE.Vector3(0, 1, 0); // step3: 沿 z 轴旋转,使得 y 轴 尽可能 与 世界坐标中的 cube.up向量平行/重合/共面
    box.lookAt(new THREE.Vector3(0, 0, 0)); // step2: z 轴指向 世界坐标原点
    const axis = new THREE.AxesHelper(100);
    box.add(axis);
    scene.add(box);
    return box;
};
const box = addBox(10,10,10);

image.png box的z轴指向世界坐标原点,并尽量让box的y轴与设定的up向量重合/平行/共面,图中是共面的情况。 如果up[1,1,1][-1, 1, -1], 会得到相同的显示结果,但是已经是重合/平行的情况了

  1. up[1,0,1][1, 0, -1]
const addBox = (x,y,z) => {
    const geometry = new THREE.BoxGeometry(10, 5, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const cube = new THREE.Mesh(geometry, material);
    cube.position.set(x, y, z); // step1: 设置位置
    cube.up = new THREE.Vector3(1, 0, -1); // step3: 沿 z 轴旋转,使得 y 轴 尽可能 与 世界坐标中的 cube.up向量平行/重合
    cube.lookAt(new THREE.Vector3(0, 0, 0)); // step2: z 轴指向 世界坐标原点
    const axis = new THREE.AxesHelper(100);
    cube.add(axis);
    console.log(cube);
    scene.add(cube);
    return cube;
};
const box = addBox(10,10,10);

image.pngup[1,0,1][1, 0, -1],box的z轴指向世界坐标原点,并尽量让box的y轴与设定的up向量重合/平行/共面,图中是重合/平行的情况。

补充:

  1. 当启用Control时,再对Camera使用lookAt方法需要注意:

    如果对camera启用了Control,那么再使用lookAt时,需要同步更新controls的target。

  2. 如果lookAt的target为相机当前所在位置,那么会出错,显示为视野内无内容

camera.lookAt(cube.position);
trackballControls.target.copy(cube.position);

3. 通过translate 和 rotation方法

对平移和旋转,有些场景希望能够在局部坐标系内动作(人在地球场景上的火车上行走/地球在太阳系场景内自转),而有些场景希望能够在全局坐标系内动作(火车在地球场景上运动/地球在太阳系场景内公转),这里的例子可能不准确,但核心都是为了使用不同坐标系来简化一个动作的描述。

rotation

Three.js提供了物体相对局部坐标旋转和相对世界坐标系旋转的API:

基于局部坐标系旋转
// 基于XYZ轴
.rotateX ( rad : Float ) : this
.rotateY ( rad : Float ) : this
.rotateZ ( rad : Float ) : this

// 基于任意轴
.rotateOnAxis ( axis : Vector3, angle : Float ) : this
.setRotationFromAxisAngle ( axis : Vector3, angle : Float ) : undefined //会采用四元数旋转

// 基于给定值
    // 基于转换矩阵
    .setRotationFromMatrix ( m : Matrix4 ) : undefined // 务必确保 m 具有有效的 旋转矩阵,否则请使用 .applyMatrix4
    .matrix = new THREE.Matrix4()
    // 基于欧拉角
    .setRotationFromEuler ( euler : Euler ) : undefined
    .rotation = new THREE.Euler(0,0,0, Euler.DEFAULT_ORDER)
    // 基于四元数
    .setRotationFromQuaternion ( q : Quaternion ) : undefined
    .quaternion = new THREE.Quaternion(0,0,0,1);
基于全局(世界/场景)坐标系旋转
// 基于任意轴
.rotateOnWorldAxis ( axis : Vector3, angle : Float) : this
.applyMatrix4 ( matrix : Matrix4 ) : undefined

applyMatrix4是常用且有效的方法

translate

同样具有基于局部坐标旋转和相对世界坐标系旋转的API:

基于局部坐标系平移
// 基于任意轴
.translateOnAxis ( axis : Vector3, distance : Float ) : this
// 基于XYZ轴
.translateX ( distance : Float ) : this
.translateY ( distance : Float ) : this
.translateZ ( distance : Float ) : this
基于全局(世界/场景)坐标系平移
.applyMatrix4 ( matrix : Matrix4 ) : undefined

applyMatrix4是常用且有效的方法

补充:Scale

一个 Vector3对象,表示在XYZ轴上的坐标缩放系数。

注意顺序:缩放、旋转、平移是Matrix4中约定的顺序

如果通过scale.set() rotation.set() position.set()进行位姿设定,也需要遵循这个顺序。

用户交互

Controls

简单罗列提供的手势交互方式:


  • 拖放控制器(DragControls)
    • 该类被用于提供一个拖放交互。
  • 飞行控制器(FlyControls)
    • FlyControls 启用了一种类似于数字内容创建工具(例如Blender)中飞行模式的导航方式。 你可以在3D空间中任意变换摄像机,并且无任何限制(例如,专注于一个特定的目标)。
  • 第一人称控制器(FirstPersonControls)
    • 该类是 FlyControls 的另一个实现。在人脑袋上加一个飞行控制器。 提供了更多在人情景下的更多API封装,例如蹲起时的相机移动速度左右上下看的相机移动边界约束
  • 指针锁定控制器(PointerLockControls)
    • 该类的实现是基于Pointer Lock API的。 对于第一人称3D游戏来说, PointerLockControls 是一个非常完美的选择。
  • 变换控制器(TransformControls)
    • 该类可提供一种类似于在数字内容创建工具(例如Blender)中对模型进行交互的方式,来在3D空间中变换物体。 和其他控制器不同的是,变换控制器不倾向于对场景摄像机的变换进行改变。
    • TransformControls 期望其所附加的3D对象是场景图的一部分。

开发调试

  • AxesHelper
  • CameraHelper

JavaScript Temporal API 深度解析:现代化的日期时间处理方案

引言:告别 Date 的痛点

JavaScript 中的日期和时间处理一直是开发者的痛点。自 1995 年 JavaScript 诞生以来,Date 对象就伴随着各种设计缺陷,给开发者带来了无尽的困扰。为了解决这些问题,TC39 委员会提出了 Temporal API 提案,旨在为 JavaScript 提供一个更现代、更强大、更直观的日期和时间处理方案。

本文将深入探讨 Temporal API 的设计理念、核心特性、使用方法,并与现有的 Date 对象以及流行的第三方库进行对比,帮助你全面了解这一即将到来的重要 JavaScript 特性。

Temporal API 的核心概念与设计理念

Temporal API 是 ECMAScript 的一个 Stage 3 提案,由 TC39 委员会推动,目前正在等待最终的标准化和浏览器实现。它的设计目标是提供一个全面的、现代化的日期和时间 API,以替代现有的 Date 对象。

Temporal 的设计原则

  1. 所有 Temporal 对象都是不可变的:这意味着所有操作都会返回新的对象,而不是修改原始对象,避免了意外的副作用。

  2. 日期值可以在不同的日历系统中表示:Temporal 支持多种日历系统,但它们都可以与公历(Proleptic Gregorian Calendar)相互转换。

  3. 所有时间值都基于标准的 24 小时制:提供统一的时间表示方式。

  4. 不表示闰秒:简化了时间计算,避免了闰秒带来的复杂性。

Temporal 的核心类型

Temporal API 提供了多种专门的类型,每种类型都针对特定的日期和时间处理场景:

  • Temporal.Instant:表示时间线上的一个精确时刻,不考虑日历或位置,例如 1969 年 7 月 20 日 20:17 UTC。

  • Temporal.ZonedDateTime:带有时区的日期时间对象,表示在地球上特定区域发生的真实事件,例如美国太平洋时间 1995 年 12 月 7 日凌晨 3:24(格里高利历)。

  • Temporal.PlainDate:表示不与特定时间或时区关联的日历日期,例如 2006 年 8 月 24 日。

  • Temporal.PlainTime:表示不与特定日期或时区关联的挂钟时间,例如下午 7:39。

  • Temporal.PlainDateTime:表示不与特定时区关联的日期和时间组合。

  • Temporal.PlainYearMonth:表示特定年份和月份,不包含日期信息。

  • Temporal.PlainMonthDay:表示特定月份和日期,不包含年份信息。

  • Temporal.Duration:表示两个时间点之间的持续时间。

  • Temporal.Now:提供获取当前时间的各种方法。

Temporal API 与 Date 对象的深度对比

Date 对象的主要问题

  1. 可变性导致的副作用:Date 对象是可变的,这意味着方法调用会修改原始对象,容易导致意外的副作用。
// Date 对象的可变性问题
function addOneWeek(myDate) {
    myDate.setDate(myDate.getDate() + 7);
    return myDate;
}
 
const today = new Date();
const oneWeekFromNow = addOneWeek(today);
 
console.log(`today is ${today.toLocaleString()}, and one week from today will be ${oneWeekFromNow.toLocaleString()}`);
// today 和 oneWeekFromNow 是相同的,因为 today 被修改了
  1. 混乱的月份编号:月份从 0 开始(0-11),而日期从 1 开始(1-31),这种不一致性容易导致错误。
// 混乱的月份编号
const date = new Date(2024, 0, 1); // 2024年1月1日
console.log(date.getMonth()); // 0(表示1月)
  1. 有限的时区支持:Date 对象主要依赖系统的本地时区,对多时区处理支持有限。

  2. 解析行为不可靠:Date 对象的解析行为在不同浏览器中可能不一致。

  3. 不支持非格里高利历:无法处理其他日历系统。

Temporal API 的解决方案

  1. 不可变性:所有操作都返回新对象,避免副作用。
// Temporal 的不可变性
const date = Temporal.PlainDate.from('2024-01-01');
const nextMonth = date.add({ months: 1 });
console.log(date.toString()); // '2024-01-01' - 原始对象不变
console.log(nextMonth.toString()); // '2024-02-01' - 新对象
  1. 一致的索引:所有单位都使用基于 1 的编号,更符合直觉。
// 一致的索引
const date = Temporal.PlainDate.from({ year: 2024, month: 1, day: 1 }); // 2024年1月1日
console.log(date.month); // 1(表示1月)
  1. 强大的时区支持:明确的时区处理,支持所有 IANA 时区。
// 明确的时区处理
const nyDateTime = Temporal.ZonedDateTime.from({
  timeZone: 'America/New_York',
  year: 2024,
  month: 1,
  day: 1,
  hour: 9
});

const tokyoDateTime = nyDateTime.withTimeZone('Asia/Tokyo');
console.log(tokyoDateTime.toString()); // 显示东京时间,自动处理时区转换
  1. 可靠的解析:严格指定的字符串格式,确保一致的解析行为。

  2. 支持非格里高利历:支持多种日历系统。

Temporal API 与主流时间处理库的对比

与 Moment.js 的对比

Moment.js 曾是 JavaScript 中最流行的日期处理库,但现在已经进入维护模式,不再积极开发。

相似点

  • 都提供丰富的日期和时间操作方法
  • 都支持时区处理
  • 都支持格式化和解析

区别

  • Temporal 是不可变的,而 Moment.js 是可变的
  • Temporal 将成为语言内置特性,不需要额外引入库
  • Temporal 支持更精确的时间(纳秒级别)
  • Temporal 提供更清晰的类型区分(PlainDate、PlainTime 等)
// Moment.js
moment().add(7, 'days');

// Temporal
Temporal.Now.plainDateTimeISO().add({ days: 7 });

与 date-fns 的对比

date-fns 是一个函数式编程风格的日期库,支持 tree-shaking。

相似点

  • 都采用不可变的设计理念
  • 都提供丰富的日期和时间操作方法

区别

  • date-fns 使用函数式 API,而 Temporal 使用面向对象的 API
  • Temporal 将成为语言内置特性,不需要额外引入库
  • Temporal 提供更强大的时区支持
  • Temporal 支持更多的日历系统
// date-fns
import addDays from 'date-fns/addDays';
addDays(new Date(), 7);

// Temporal
Temporal.Now.plainDateTimeISO().add({ days: 7 });

与 Luxon 的对比

Luxon 是由 Moment.js 的一些维护者开发的,旨在提供更现代的 API。

相似点

  • 都是不可变的
  • 都提供丰富的日期和时间操作方法
  • 都有强大的时区支持

区别

  • Temporal 将成为语言内置特性,不需要额外引入库
  • Temporal 提供更清晰的类型区分
  • Temporal 支持更多的日历系统
// Luxon
DateTime.local().plus({ days: 7 });

// Temporal
Temporal.Now.plainDateTimeISO().add({ days: 7 });

与 Day.js 的对比

Day.js 是一个轻量级的日期库,API 与 Moment.js 类似,但体积更小。

相似点

  • 都提供丰富的日期和时间操作方法
  • 都支持链式调用

区别

  • Temporal 是不可变的,而 Day.js 的某些操作是可变的
  • Temporal 将成为语言内置特性,不需要额外引入库
  • Temporal 提供更强大的时区支持
  • Temporal 支持更多的日历系统
// Day.js
dayjs().add(7, 'day');

// Temporal
Temporal.Now.plainDateTimeISO().add({ days: 7 });

特性对比表

下表汇总了 Temporal API 与 Date 对象以及主流时间处理库的主要差异:

特性 Date Temporal Moment.js date-fns Luxon Day.js
不可变性 部分支持
时区支持 有限 完整 完整 有限 完整 插件支持
国际化支持 有限 完整 完整 有限 完整 插件支持
日历系统 仅格里高利历 多种日历 仅格里高利历 仅格里高利历 仅格里高利历 仅格里高利历
精确度 毫秒 纳秒 毫秒 毫秒 毫秒 毫秒
类型区分 单一类型 多种类型 单一类型 函数式 少量类型 单一类型
语言内置 即将支持
包大小 0 0 小(可tree-shaking)
API风格 面向对象 面向对象 面向对象 函数式 面向对象 面向对象
维护状态 活跃 活跃(提案) 维护模式 活跃 活跃 活跃
解析可靠性

这个对比表清晰地展示了 Temporal API 相比于 Date 对象和其他库的优势,尤其是在不可变性、时区支持、日历系统和类型区分方面。作为未来的语言内置特性,Temporal API 将为 JavaScript 开发者提供更强大、更可靠的日期和时间处理能力。

Temporal API 的常用场景和使用示例

创建日期和时间对象

// 创建日期对象
const date = Temporal.PlainDate.from('2024-01-01');
console.log(date.toString()); // '2024-01-01'

// 创建时间对象
const time = Temporal.PlainTime.from('09:00:00');
console.log(time.toString()); // '09:00:00'

// 创建日期时间对象
const dateTime = Temporal.PlainDateTime.from('2024-01-01T09:00:00');
console.log(dateTime.toString()); // '2024-01-01T09:00:00'

// 创建带时区的日期时间对象
const zonedDateTime = Temporal.ZonedDateTime.from('2024-01-01T09:00:00[America/New_York]');
console.log(zonedDateTime.toString()); // '2024-01-01T09:00:00-05:00[America/New_York]'

// 创建持续时间对象
const duration = Temporal.Duration.from({ hours: 1, minutes: 30 });
console.log(duration.toString()); // 'PT1H30M'

获取当前日期和时间

// 获取当前时刻(UTC)
const now = Temporal.Now.instant();
console.log(now.toString()); // 例如 '2024-01-01T12:00:00Z'

// 获取当前日期(本地)
const today = Temporal.Now.plainDateISO();
console.log(today.toString()); // 例如 '2024-01-01'

// 获取当前时间(本地)
const currentTime = Temporal.Now.plainTimeISO();
console.log(currentTime.toString()); // 例如 '12:00:00'

// 获取当前日期和时间(本地)
const currentDateTime = Temporal.Now.plainDateTimeISO();
console.log(currentDateTime.toString()); // 例如 '2024-01-01T12:00:00'

// 获取当前带时区的日期和时间
const currentZonedDateTime = Temporal.Now.zonedDateTimeISO();
console.log(currentZonedDateTime.toString()); // 例如 '2024-01-01T12:00:00+08:00[Asia/Shanghai]'

日期和时间运算

// 日期加法
const date = Temporal.PlainDate.from('2024-01-01');
const futureDate = date.add({ days: 10 });
console.log(futureDate.toString()); // '2024-01-11'

// 日期减法
const pastDate = date.subtract({ months: 1 });
console.log(pastDate.toString()); // '2023-12-01'

// 使用持续时间进行加法
const duration = Temporal.Duration.from({ days: 5 });
const anotherFutureDate = date.add(duration);
console.log(anotherFutureDate.toString()); // '2024-01-06'

// 计算两个日期之间的差异
const date1 = Temporal.PlainDate.from('2024-01-01');
const date2 = Temporal.PlainDate.from('2024-01-10');
const difference = date1.until(date2);
console.log(difference.toString()); // 'P9D'(9天)
console.log(difference.days); // 9

时区处理

// 创建带时区的日期时间对象
const londonTime = Temporal.ZonedDateTime.from({
  timeZone: 'Europe/London',
  year: 2024,
  month: 1,
  day: 1,
  hour: 9
});
console.log(londonTime.toString()); // '2024-01-01T09:00:00+00:00[Europe/London]'

// 转换到另一个时区
const tokyoTime = londonTime.withTimeZone('Asia/Tokyo');
console.log(tokyoTime.toString()); // '2024-01-01T18:00:00+09:00[Asia/Tokyo]'

// 获取当前时区
const currentTimeZone = Temporal.Now.timeZoneId();
console.log(currentTimeZone); // 例如 'Asia/Shanghai'

格式化日期和时间

// 基本格式化
const date = Temporal.PlainDate.from('2024-01-01');
console.log(date.toString()); // '2024-01-01'

// 使用 toLocaleString 进行本地化格式化
const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
console.log(date.toLocaleString('zh-CN', options)); // '2024年1月1日星期一'

// 自定义格式化
const dateTime = Temporal.PlainDateTime.from('2024-01-01T09:00:00');
console.log(dateTime.toLocaleString('zh-CN', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit'
})); // '2024年1月1日 09:00'

日历系统

// 创建使用特定日历系统的日期
const chineseDate = Temporal.PlainDate.from({
  year: 2024,
  month: 1,
  day: 1,
  calendar: 'chinese'
});
console.log(chineseDate.toString()); // '2024-01-01[u-ca=chinese]'

// 转换到另一个日历系统
const gregorianDate = chineseDate.withCalendar('iso8601');
console.log(gregorianDate.toString()); // '2024-01-01'

Temporal API 的兼容性和使用方法

当前兼容性状态

Temporal API 目前处于 TC39 提案的 Stage 3 阶段,这意味着它已经被推荐实现,但尚未成为 ECMAScript 标准的一部分。目前,主流浏览器尚未原生支持 Temporal API,但 Firefox Nightly 版本已经添加了对 Temporal 提案的实验性支持。

使用 Polyfill

在 Temporal API 被广泛支持之前,你可以使用 polyfill 来在你的项目中使用它。目前有两个主要的 polyfill 可供选择:

  1. @js-temporal/polyfill:由 Temporal 提案的一些拥护者开发的 polyfill。
npm install @js-temporal/polyfill
// 导入 polyfill
import { Temporal } from '@js-temporal/polyfill';

// 使用 Temporal API
const now = Temporal.Now.instant();
console.log(now.toString());
  1. temporal-polyfill:一个更轻量级的 polyfill,体积约为 20KB。
npm install temporal-polyfill
// 导入 polyfill
import { Temporal } from 'temporal-polyfill';

// 使用 Temporal API
const now = Temporal.Now.instant();
console.log(now.toString());

你也可以通过 CDN 在浏览器中直接使用 polyfill:

<script src="https://cdn.jsdelivr.net/npm/temporal-polyfill@0.3.0/global.min.js"></script>
<script>
  const now = Temporal.Now.instant();
  console.log(now.toString());
</script>

浏览器支持

使用 temporal-polyfill,最低需要的浏览器版本为:

  • Chrome 60(2017年7月)
  • Firefox 55(2017年8月)
  • Safari 11.1(2018年3月)
  • Safari iOS 11.3(2018年3月)
  • Edge 79(2020年1月)
  • Node.js 14(2020年4月)

如果你使用转译工具,可以支持更早的浏览器版本。

Temporal API 的未来发展

标准化进程

Temporal API 目前处于 TC39 提案的 Stage 3 阶段,这意味着它已经被推荐实现,但尚未成为 ECMAScript 标准的一部分。根据 TC39 的流程,Stage 3 之后是 Stage 4,即最终阶段,此时提案将被纳入 ECMAScript 标准。

Temporal API 的规范文本已经完成,并且正在进行实现和测试。一旦通过足够的测试和实现验证,它将进入 Stage 4,并在未来的 ECMAScript 版本中正式发布。

浏览器实现计划

目前,Firefox Nightly 版本已经添加了对 Temporal 提案的实验性支持,这是浏览器原生支持 Temporal API 的第一步。其他主流浏览器(如 Chrome、Safari 和 Edge)也在关注这一提案,并可能在未来实现它。

一旦 Temporal API 进入 Stage 4,我们可以期待主流浏览器在未来的版本中逐步实现它。然而,考虑到浏览器的发布周期和向后兼容性的考虑,可能需要一段时间才能在所有主流浏览器中看到完全的原生支持。

对 JavaScript 生态系统的影响

Temporal API 的引入将对 JavaScript 生态系统产生深远的影响:

  1. 减少对第三方库的依赖:随着 Temporal API 成为语言的一部分,开发者将不再需要依赖 Moment.js、date-fns、Luxon 等第三方库来处理日期和时间,这将减少项目的依赖和体积。

  2. 统一的日期和时间处理方式:Temporal API 提供了一套统一的、现代化的日期和时间处理方式,这将减少开发者之间的学习成本和代码差异。

  3. 更好的国际化支持:Temporal API 的设计考虑了国际化需求,支持多种日历系统和时区,这将使开发国际化应用程序变得更加容易。

  4. 更可靠的日期和时间处理:Temporal API 的不可变设计和明确的类型区分将减少日期和时间处理中的常见错误,提高代码的可靠性。

结论

Temporal API 代表了 JavaScript 日期和时间处理的未来。它解决了 Date 对象的许多痛点,提供了更现代、更强大、更直观的 API,使日期和时间处理变得更加简单和可靠。

虽然 Temporal API 尚未成为 ECMAScript 标准的一部分,但你可以通过 polyfill 在你的项目中开始使用它,为未来的标准化做好准备。随着浏览器对 Temporal API 的原生支持逐步增加,我们可以期待在不久的将来看到它成为 JavaScript 开发的标准工具。

无论你是正在开发新项目,还是维护现有代码库,了解 Temporal API 都将帮助你为未来的 JavaScript 开发做好准备,并在日期和时间处理方面做出更明智的选择。

参考资源

🌳Node的文件操作

写在前面


大家好我是一溪风月🤠一名前端开发者,今天我们将开始学习服务端相关的知识,这篇文章我们将学习和了解Node服务端相关的东西了,那么会有很多开发小伙伴会说为什么不学习Java或者Go语言哪?因为作为一个前端开发其实很多的开发小伙伴前端的知识掌握的并不是很好,这个时候去学习其他语言往往会比较吃力,另外一个原因是Node开发服务器的性能其实是非常高的,在后续的服务端渲染的内容中也会只用Node来做,所以作为一个前端开发在目前看来学习服务端知识从Node切入是个非常正确的选择,好了那么就让我们开始吧!

一.服务端开发


这幅图展示的是一个完整的软件系统,包括很多个部分,我们可以看到图中不仅仅包含,网页端,电脑端,手机端等等,在这个软件系统中,最重要的就是服务端,因为服务端是给各个客户端提供数据的,常见的服务端技术方案有很多,在国内使用最频繁的是 Java 语言,那么为什么推荐 Node 哪?因为 Node 可以直接使用 JS 来开发,减少了很多的学习成本,其次 Node 的性能是比较高的,在后续学习的 SSR 中其实就是使用 Node 来做的服务端渲染的。

二.Node.js 是什么?


官方对 Node 的定义是,Node.js是一个基于 V8 引擎的 JavaScript 运行环境,也就是说 Node 是基于 V8 引擎来运行 JavaScript 代码的,但是不仅仅只有 V8 引擎。

  • 前面我们知道 V8 可以用来嵌入到任何 C++应用程序中,无论是 Chorme 还是 Node.js,事实上都是嵌入了 V8 引擎来执行 JavaScript 代码的。
  • 但是在 Chorme 浏览器中,还需要解析,渲染 HTML,CSS 等相关渲染引擎,另外还需要提供支持浏览器操作的 API,浏览器自己的事件循环等。
  • 另外,在 Node.js 中我们也需要进行一些额外的操作,比如文件的读写,网络 IO,加密,压缩解压文件等。

三.Node 和浏览器的区别


四.Node.js 的架构


我们编写的Application会交给V8 引擎 进行解析,然后通过Node.js BindingsLIBUV进行交互,任务会进入 Event Queue 通过事件循环来进行执行调用操作系统,当任务执行完毕后又会通过回调函数进行逐步返回。

五.内置模块 fs


fs 是File System的缩写,表示文件系统,对于任何一个为服务器端服务的语言或者框架通常都会有自己的文件系统,因为服务器需要将各种数据,文件等放置到不同的地方,比如用户数据可能大多数是放在数据库中,比如某些配置文件或者用户资源(图片,音视频)都是以文件的形式存在于操作系统上的,Node 也有自己的文件系统操作模块,就是 fs,借助与 Node 帮我们封装的系统,我们就可以在任务,这也是 Node 可以开发服务器的一个原因,也是它可以成为前端自动化脚本等热门工具的原因。

六.fs 的 API 介绍


fs 中的 API 非常的多,我们没有必要去一个一个的学习,在使用到的时候可以单独的查找 nodejs.org/docs/latest… 但是这些 API 大致分为三类:

方式一:同步操作文件,代码会被阻塞,不会继续执行。

const fs = require('fs')

const state = fs.readFileSync('./files/files.txt', {
  encoding: 'utf-8',
})

console.log(state)

方式二:异步回调函数操作文件,文件不会被阻塞,需要传入回调函数,当获取到结果的时候,回调函数被执行。

const fs = require('fs')

fs.readFile('./files/files.txt', 'utf8', (err, data) => {
  if (err) {
    console.log('文件读取发生错误~')
    return
  }
  console.log(data)
})

方式三:异步 Promise 操作文件,代码不会被阻塞,通过fs.promises调用方法操作,会返回一个 Promise,可以通过 then 和 catch 进行处理。

const fs = require('fs')
const state = fs.promises.readFile('./files/files.txt', {
  encoding: 'utf-8',
})

state.then((res) => {
  console.log(res)
})

七.文件描述符


文件描述符是什么呢?😆 在常见的操作系统上,对于每个进程,内核都维护着一张当前打开着的文件和资源表格,每个打开的文件都分配了一个称为文件描述符的简单的数字标识符,在系统层,所有的文件系统操作都使用这些文件描述符来标识和跟踪每个特定的文件,Window 系统使用了一个虽然不同,但是概念上类似的机制来跟踪资源,为了简化用户的工作,Node 抽象出操作系统之间的特定差异,并为所有打开的文件分配一个数字型的文件描述符。

fs.open()方法用于分配新的文件描述符,一旦被分配,则文件描述符可用于从文件读取数据,向文件写入数据,或请求关于文件的信息。

const fs = require('fs')

fs.open('./files/files.txt', 'r', (err, fd) => {
  console.log(fd)
  fs.fstat(fd, (err, state) => {
    // 获取文件描述符指定的文件信息
    console.log(state)
    fs.close(fd) // 对打开的文件进行关闭
  })
})

八.文件的读写


如果我们想要对文件的内容进行操作,这个时候可以使用文件的读写

文件的读取:fs.readFile(path[, options], callback)

const fs = require('fs')

fs.readFile('./files/files.txt', 'utf8', (err, data) => {
  if (err) {
    console.log('文件读取发生错误~')
    return
  }
  console.log(data)
})

文件的写入:fs.writeFile(file, data[, options], callback)

const fs = require('fs')

const content = '人间不过是你~'

fs.writeFile('./files/files.txt', content, { flag: 'a+' }, (err) => {
  console.log(err)
})

在上述的代码中你可能会发现有一个对象类型,这个是写入时候填入的option参数

  1. flag:写入的方式
  2. encoding:字符的编码

九.flag 选项


我们先来看 flag,flag 的值有很多: nodejs.org/dist/latest…

  • w 打开文件写入,默认值
  • w+打开文件进行读写(可读可写),如果不存在则创建文件
  • r打开文件读取,读取时的默认值
  • r+ 打开文件进行读写,如果不存在那么抛出异常
  • a打开要写入的文件,将流放在文件末尾。如果不存在则创建文件
  • a+打开文件以进行读写(可读可写),将流放在文件末尾。如果不存在则创建文件

十.encoding 选项


目前我们在开发中基本使用的都是utf-8 在文件读取中,如果不填写encoding的话,返回的结果是Buffer

十一.文件夹操作


fs模块除了可以对某一个文件进行操作之外,还可以通过fs.mkdir()fs.mkdirSync()操作文件夹.

获取文件夹的内容

const fs = require('fs')

function readDirectory(dir) {
  fs.readdir(dir, { withFileTypes: true }, (err, files) => {
    files.forEach((item) => {
      if (item.isDirectory()) {
        readDirectory(`${dir}/${item.name}`)
      } else {
        console.log(item.name)
      }
    })
  })
}

readDirectory('./fileDictory')

文件重命名

const fs = require('fs')

fs.rename('./fileDictory', './fileDictoryNew', (err) => {
  console.log(err)
})

十二.总结


这篇文章到这里就结束了🥸,这篇文章我们首先了解了Node对于一个前端开发的重要性,然后我们学习这篇文章中最重要的内容,文件的操作,在Node中进行文件操作一般分为三类。

  • 同步文件的处理
  • 异步文件的处理
  • 基于Promise的文件处理

除此之外我们还学习了文件的描述符的相关知识,以及文件夹相关的操作,当学习完这些内容之后我们就可以处理一些文件和文件夹相关的内容了。

深入理解HTML列表元素及其应用

列表是HTML中非常重要的结构化元素,能够清晰地展示信息层次和关系。本文将详细介绍三种主要的HTML列表类型:有序列表、无序列表和定义列表,并探讨它们的应用场景。

有序列表(Ordered List)

有序列表使用<ol>标签作为父元素,表示列表项之间有明确的顺序关系。

基本结构

<ol>
  <li>第一项</li>
  <li>第二项</li>
  <li>第三项</li>
</ol>

主要属性

  1. reversed:倒序显示列表编号

    <ol reversed>
      <li>这项显示为3</li>
      <li>这项显示为2</li>
      <li>这项显示为1</li>
    </ol>
    
  2. type:设置编号类型

    • "1":数字(默认)
    • "a":小写字母
    • "A":大写字母
    • "i":小写罗马数字
    • "I":大写罗马数字
    <ol type="A">
      <li>项目A</li>
      <li>项目B</li>
    </ol>
    

注意:虽然type属性可以实现样式控制,但现代Web开发推荐使用CSS的list-style-type属性来控制列表样式,以实现更好的样式分离。

无序列表(Unordered List)

无序列表使用<ul>标签,适用于项目之间没有特定顺序的情况。

基本结构

<ul>
  <li>项目一</li>
  <li>项目二</li>
</ul>

应用场景

  1. 导航菜单:网站的主导航通常使用无序列表构建
  2. 新闻列表:展示多条新闻标题
  3. 功能列表:产品特性或服务项目的展示
<nav>
  <ul>
    <li><a href="/">首页</a></li>
    <li><a href="/about">关于我们</a></li>
    <li><a href="/contact">联系我们</a></li>
  </ul>
</nav>

定义列表(Definition List)

定义列表用于展示术语及其定义的场景,由三个标签组成:

  • <dl>:定义列表的父元素
  • <dt>:定义术语(Definition Title)
  • <dd>:术语的描述(Definition Description)

基本结构

<dl>
  <dt>HTML</dt>
  <dd>超文本标记语言,用于创建网页结构</dd>
  
  <dt>CSS</dt>
  <dd>层叠样式表,用于控制网页表现</dd>
</dl>

应用场景

  1. 词汇表或术语表
  2. 问题与解答(FAQ)
  3. 元数据展示(如键值对信息)
<dl>
  <dt>作者</dt>
  <dd>张三</dd>
  
  <dt>出版日期</dt>
  <dd>2023年5月</dd>
</dl>

最佳实践

  1. 语义化使用:根据内容性质选择正确的列表类型,不要仅仅为了视觉效果而选择某种列表
  2. 样式控制:使用CSS而不是HTML属性来控制列表样式
  3. 嵌套列表:可以创建多级嵌套列表来展示更复杂的信息结构
<ul>
  <li>一级项目
    <ul>
      <li>二级项目</li>
      <li>二级项目</li>
    </ul>
  </li>
  <li>一级项目</li>
</ul>
  1. 无障碍性:确保屏幕阅读器能正确解读列表结构,避免滥用列表进行页面布局

通过合理使用这三种HTML列表元素,你可以创建出结构清晰、语义明确的网页内容,既有利于用户体验,也有助于搜索引擎优化。

我在 microApp 和 wujie 中选择了 wujie:深度技术对比与选型思考

近年来微前端架构逐渐成为大型前端工程化的主流解决方案,在众多微前端框架中,microApp 和 wujie 凭借其独特的设计理念脱颖而出。本文将从技术实现、性能表现、开发体验等维度进行深度对比,解析我最终选择 wujie 的技术决策过程。

一、核心架构对比

1.1 沙箱机制

  • microApp:基于 Proxy 的运行时沙箱
    • 采用 ES6 Proxy 代理全局对象
    • 通过闭包隔离作用域链
    • 存在样式污染风险(需配合 Shadow DOM)
  • wujie:WebComponents + iframe 混合沙箱
    • 主应用使用 WebComponents 容器
    • 子应用运行在 iframe 沙箱环境
    • 天然隔离 JavaScript/CSS 作用域

1.2 通信机制

维度 microApp wujie
通信方式 基于 CustomEvent 的全局事件总线 props + 事件总线
数据同步 手动触发更新 自动响应式更新
类型支持 基础类型 支持复杂对象/函数传递
TS 支持 需自行定义类型 内置完整类型声明

1.3 资源加载

  • microApp
    // 配置式资源声明
    registerApp({
      name: 'subApp',
      entry: 'https://sub.domain.com',
      container: '#container'
    })
    
  • wujie
    // 声明式组件化加载
    <WujieVue
      name="subApp"
      url="https://sub.domain.com"
      :props="{ user: currentUser }"
    />
    

二、性能关键指标

2.1 冷启动耗时(单位:ms)

场景 microApp wujie
简单子应用 320 280
复杂子应用 850 720
多实例场景 2100 1800

2.2 内存占用对比

  • 10 个子应用实例场景:
    • microApp 平均内存: 142MB
    • wujie 平均内存: 118MB

2.3 渲染优化

  • wujie 特有优化
    • 预执行模式:提前加载子应用资源
    • DOM 缓存池:复用已卸载的 DOM 结构
    • 插件系统:支持性能监控、错误追踪等

三、开发体验深度对比

3.1 调试支持

  • microApp
    • 依赖浏览器原生调试工具
    • 子应用日志输出到主应用控制台
  • wujie
    • 独立 Chrome DevTools 调试
    • 源码映射(sourcemap)自动同步
    • 性能分析插件

3.2 生态支持

  • wujie 优势
    • 官方提供 Vue/React 适配器
    • 支持 Vite 热更新
    • 内置路由冲突解决方案
    • 支持子应用保活(Keep-Alive)

3.3 异常处理

// wujie 错误边界示例
<WujieVue
  name="subApp"
  url="..."
  :props="{...}"
  :errorBoundary="(err) => <ErrorFallback />"
/>

四、选型决策关键点

  1. 沙箱安全性
    • wujie 的 iframe 沙箱在 CSS 隔离方面表现更彻底
    • 金融级应用对样式污染零容忍
  2. 通信效率
    • 复杂对象传递效率提升 40%
    • 自动响应式更新减少样板代码
  3. 工程化适配
    • 现有 Vue 技术栈无缝集成
    • 支持子应用独立 CI/CD 流水线
  4. 动态加载
    • 实测 wujie 首屏加载快 18%
    • 子应用切换无白屏现象

五、实践建议

5.1 适用场景推荐

  • 选择 wujie 当:
    • 需要 iframe 级安全隔离
    • 使用 Vue/React 技术栈
    • 有复杂通信需求
    • 重视开发体验
  • 选择 microApp 当:
    • 需要最小化包体积(wujie 比 microApp 大 30KB)
    • 简单集成场景
    • 已深度定制微前端方案

5.2 性能优化实践

// wujie 预加载配置
preloadApp({
  name: 'subApp',
  exec: false // 仅预加载不执行
})

5.3 迁移成本对比

项目 microApp wujie
接入成本 0.5人日 0.5人日
改造成本
学习曲线 平缓 平缓

总结

经过技术指标、性能数据和实际场景的全面对比,wujie 在沙箱安全性、通信效率、框架适配性等方面展现出明显优势。其创新的 WebComponents + iframe 混合架构,既保留了 iframe 的隔离优势,又通过 WebComponents 实现了良好的开发体验。对于需要长期迭代的复杂前端系统,wujie 提供了更面向未来的解决方案。

从音乐垃圾场到云端歌单!Navidrome+cpolar拯救我这个混乱的音乐博主

作者:乐库里找营养的猫 cpolarNAS10用户

🚨 问题一:我的音乐库是个“黑洞”

以前整理音乐素材像在“翻垃圾桶找薯片”——

  • 文件夹命名乱七八糟(比如“2019年某歌单”、“临时备份_别删啊!”,最后自己都不记得是啥);
  • 格式混乱:MP3、WAV、FLAC混在一起,手机播不了的格式只能干瞪眼;
  • 远程创作时更绝望:想用一首歌却要连公司NAS(?),结果网络卡成PPT……

直到遇见 Navidrome + cpolar,我的音乐世界终于从“灾难片”变成了“科幻大片”!

🌟 痛点破解1:Navidrome——你的私人音乐管家

🔍 智能整理:告别“找歌困难症”

以前想找一首2018年收藏的《XXX乐队-回忆.mp3》,得在十几个文件夹里翻半天。现在Navidrome自动扫描所有歌曲,按歌手、专辑、流派分类,输入关键词秒出结果!

真实场景:上周要给视频配一首“90年代英伦摇滚”,直接搜索“1995-2000年+英国+摇滚”——BAM!Nirvana的《Smells Like Teen Spirit》蹦出来,效率提升200%!

🎵 格式通吃:MP3到FLAC全兼容

再也不用担心素材库里的高清FLAC文件在手机播不了。Navidrome会自动转码流媒体,无论iOS/Android还是小爱音箱都能流畅播放。

搞笑经历:朋友问我“为啥你发的歌单连我妈微信都能听?”——因为Navidrome把我的黑胶翻录WAV悄悄变成了MP3啊!

🌐 痛点破解2:cpolar——给音乐库开个“任意门”

🔓 远程访问:咖啡厅也能调歌单

用cpolar穿透内网后,我可以在任何地方登录Navidrome网页版或APP。上周在星巴克赶视频稿时,客户临时要加一首法语香颂,掏出手机直接搜索、拖拽——不用回公司!

救命场景:直播前半小时发现BGM不够燃?用cpolar远程调出冷门摇滚专辑,观众弹幕都在夸我“神级歌单”!

🛠️ 无痛配置:小白也能三分钟搞定

我可不会什么专业的电脑操作,cpolar可是友好的照顾到了我这一点。整个配置过程简单的不能再简单,容易的不能再容易。还有专门针对各种软件的图文教程。

看图+照做就能轻松解决!

🎉 综合体验:效率提升+创作自由=幸福感爆表!

  • 时间节省:整理素材的时间从每天1小时缩减到10分钟,多出的精力用来剪辑和创意;
  • 脑洞放大:想用什么风格的音乐随时调取,再也不怕“卡在选曲环节”;
  • 跨设备协作:手机、电脑、平板同步歌单,甚至能直接把歌曲链接甩给剪辑师!

💡 给新手的一句话建议

Navidrome负责整理你的音乐宝藏,cpolar帮你把宝库钥匙放在口袋里。 两者组合简直是创作型博主的“神仙CP”!

📌 悄悄放福利

Navidrome软件部署+cpolar内网穿透二合一教程整理如下,音乐小伙伴们快快快来切克闹!

最后彩蛋:用Navidrome整理出的“怀旧歌单”居然被粉丝催更了三期……这工具,牛爆了! 🚀

通过Navidrome来搭建自己的本地化音乐管理和流媒体平台,可以享受更加便捷和个性化的音乐体验。本例中,我们在Linux系统使用Docker快速进行本地部署。

image-20240821174243765

1. 安装Docker

本教程操作环境为Linux Ubuntu系统,在开始之前,我们需要先安装Docker与docker-compose。

在终端中执行下方命令安装docker:

sudo curl -fsSL https://github.com/tech-shrimp/docker_installer/releases/download/latest/linux.sh| bash -s docker --mirror Aliyun

如果上边命令中访问不了Github,可以使用Gitee的链接安装:

sudo curl -fsSL https://gitee.com/tech-shrimp/docker_installer/releases/download/latest/linux.sh| bash -s docker --mirror Aliyun

然后启动Docker即可

sudo systemctl start docker

下载docker-compose文件

sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

给他一个执行权限

sudo chmod +x /usr/local/bin/docker-compose

查看是否安装成功

docker-compose -version

2. Docker镜像源添加方法

sudo docker pull deluan/navidrome

如因网络问题拉取不到镜像,

可尝试在终端执行 sudo nano /etc/docker/daemon.json

输入:

{
"registry-mirrors": [
"https://do.nark.eu.org",
"https://dc.j8.work",
"https://docker.m.daocloud.io",
"https://dockerproxy.com",
"https://docker.mirrors.ustc.edu.cn",
"https://docker.nju.edu.cn"
]
}

保存退出

然后执行:

sudo systemctl restart docker

3. 创建并启动Navidrome容器

成功拉取镜像后,创建navidrome项目文件夹并编辑docker-compose.yml,内容如下:

version: "3"
services:
  navidrome:
    image: deluan/navidrome:latest
    ports:
      - "4533:4533" #自定义端口
    restart: unless-stopped
    environment:
      ND_SCANSCHEDULE: 1h
      ND_LOGLEVEL: info
      ND_SESSIONTIMEOUT: 24h
      ND_BASEURL: ""
    volumes:
      - "~/Music/data:/data"   #这里的~/Music/data为data数据真实路径
      - "~/Music:/music:ro"    #这里的~/Music为音乐文件真实路径

保存退出后,执行下方命令启动容器

sudo docker-compose up -d

image-20240822111905554

启动成功后,在浏览器中输入localhost:4533,可以看到进入到了Navidrome的登录界面,需要设置一个用户名和密码。

image-20240821175844172

登录后,点击界面右上角的头像图标进入个性化,可以设置语言为中文:

image-20240821180156239

Navidrome会自动扫描你存放在上边设置的真实存储路径中的音乐,并自动将歌曲分类为各个专辑的形式展现:

image-20240822112249164

image-20240822113149405

4. 公网远程访问本地Navidrome

不过我们目前只能在本地访问刚刚使用docker部署的Navidrome音乐服务器,如果出门在外,想要随时远程访问在家中主机上部署的Navidrome听歌,应该怎么办呢?

我们可以使用cpolar内网穿透工具来实现无公网ip环境下的远程访问需求。

4.1 内网穿透工具安装

下面是安装cpolar步骤:

Cpolar官网地址: www.cpolar.com

使用一键脚本安装命令

curl https://get.cpolar.sh | sudo sh

image-20240801132238671

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

Cpolar安装和成功启动服务后,在浏览器上输入ubuntu主机IP加9200端口即:【http://localhost:9200】访问Cpolar管理界面,使用Cpolar官网注册的账号登录,登录后即可看到cpolar web 配置界面,接下来在web 界面配置即可:

image-20240801133735424

4.2 创建远程连接公网地址

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了:navidrome 注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:4533
  • 域名类型:随机域名
  • 地区:选择China VIP

image-20240822112759133

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了两个公网地址,接下来就可以在其他电脑或手机平板(异地)上,使用任意一个地址在浏览器中访问即可。

image-20240822112854795

如下图所示,成功实现使用公网地址异地远程访问本地部署的 Navidrome音乐服务器 !

image-20240822113023314

image-20240822113052384

小结

为了方便演示,我们在上边的操作过程中使用了cpolar生成的HTTP公网地址隧道,其公网地址是随机生成的。

这种随机地址的优势在于建立速度快,可以立即使用。然而,它的缺点是网址是随机生成,这个地址在24小时内会发生随机变化,更适合于临时使用。

如果有长期远程访问本地搭建的Navidrome听音乐或者其他本地部署的服务的需求,但又不想每天重新配置公网地址,还想地址好看又好记,那我推荐大家选择使用固定的二级子域名方式来远程访问。

4.3 使用固定公网地址远程访问

登录cpolar官网,点击左侧的预留,选择保留二级子域名,地区选择China VIP,设置一个二级子域名名称,点击保留,保留成功后复制保留的二级子域名名称,这里我填写的是mynavid,大家也可以自定义喜欢的名称。

image-20240822113517375

保留成功后复制保留成功的二级子域名的名称:mynavid,返回登录Cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道Navidrome,点击右侧的编辑:

image-20240822113646177

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名:mynavid
  • 地区:选择China VIP

点击更新(注意,点击一次更新即可,不需要重复提交)

image-20240822113741401

更新完成后,打开在线隧道列表,此时可以看到公网地址已经发生变化,地址名称也变成了固定的二级子域名名称的域名:

image-20240822113823092

最后,我们使用任意一个固定公网地址在浏览器访问,可以看到访问成功,这样一个固定且永久不变的公网地址就设置好了,随时随地都可以远程访问本地部署的 Navidrome 音乐服务器来听音乐了!

image-20240822113954734

image-20240822114024383

以上就是如何在Linux Ubuntu系统使用Docker部署Navidrome 音乐服务器,并结合cpolar内网穿透工具配置公网地址,实现随时随地远程访问本地搭建的曲库站点的全部流程,感谢您的观看,如果你也有远程访问本地部署服务的需求,不妨下载体验一下cpolar!

《移动端布局避坑指南:从100vh到dvh,彻底解决动态视口适配难题》

前言

在移动互联网浪潮下,前端开发者常常遭遇「桌面完美,移动翻车」的尴尬困境:精心设计的页面在手机端频繁出现内容截断、布局抖动,甚至因浏览器工具栏的显隐引发「位移惨案」。这一切的罪魁祸首,往往是我们习以为常的 CSS 单位 height: 100vh—— 它在桌面端是「省心神器」,却在移动端成为「兼容性噩梦」。

为什么 100vh 在手机上会「水土不服」?动态变化的浏览器工具栏如何让布局「牵一发而动全身」?又有哪些 CSS 新特性能够根治这些顽疾?本文将深入解析移动端视口单位的底层逻辑,结合实战案例演示如何用 svh/lvh/dvh 替代传统 vh,让你的页面在手机端真正实现「丝滑适配」,同时附赠兼容性解决方案与选型指南,助你打造无懈可击的移动端体验。

一、100vh的移动端困境

在桌面浏览器环境下,100vh(Viewport Height)的定义非常直观,它代表浏览器视窗的完整高度。但在移动端,情况却大不相同。移动浏览器的顶部地址栏和底部工具栏是动态变化的 —— 当用户首次打开页面时,这些 UI 元素会显示;而当用户开始滚动页面时,它们可能会自动隐藏,从而导致视窗高度发生变化。

问题的关键在于,100vh并不会感知这种动态变化。它始终以浏览器可能达到的最大高度为基准进行计算,这就导致页面内容经常出现截断或溢出的情况,严重影响用户体验。为了解决这个问题,许多开发者不得不使用 JavaScript,通过window.innerHeight来动态调整布局。但这种方法不仅增加了开发复杂度,还容易产生维护上的难题。

如下图:

vw 和 vh 单位是我们都比较熟悉的两个单位,100vw 和 100vh 代表着视图窗口的宽和高。 image.png

兼容性:

image.png

然而有一个问题,当我们使用 100vh ,且有顶部地址栏或底部操作栏的时候,会出现溢出屏幕的情况:

image.png

当滑动滚动条的时候,地址栏和操作栏又会搜索,此时 100vh 又会充满整个窗口:

宽度也是如此,会受滚动条宽度的影响;

image.png

二、CSS 新单位:svh、lvh 和 dvh

幸运的是,随着 CSS 技术的发展,我们有了更好的解决方案。CSS 的 Values and Units Module Level 4 引入了三个专门针对移动端布局问题的新单位:

1. svh(Small Viewport Height,小视窗高度)

svh代表视窗的最小高度,也就是浏览器工具栏完全展开时的高度。这个单位特别适合那些必须始终保持可见的页面元素,例如固定在页面内的按钮、表单组件或底部导航栏。

image.png

使用示例

    .element {
      height: 100svh;
    }

2. lvh(Large Viewport Height,大视窗高度)

lvh表示视窗的最大高度,即浏览器工具栏完全隐藏时的高度。如果你希望为用户提供一种完全沉浸式的体验,比如设计全屏启动页或欢迎界面,lvh就是理想的选择。

使用示例

image.png

    .full-screen {
      height: 100lvh;
    }

3. dvh(Dynamic Viewport Height,动态视窗高度)

dvh是这三个单位中最灵活、最实用的一个。它能够自动适应浏览器工具栏的显示与隐藏:当工具栏显示时,dvh接近svh;当工具栏隐藏时,dvh接近lvh。可以说,dvh真正实现了开发者期待的动态 "100vh" 效果。

使用示例

    .responsive-element {
      height: 100dvh;
    }

三、实际案例:从100vh到100dvh的转变

在一个产品登陆页项目中,开发者最初使用height: 100vh来设置 hero 区域的高度。在桌面端,效果非常理想,但在移动端却出现了一系列问题:

  • 背景图片超出可视区域
  • 重要的 CTA 按钮被 Safari 的工具栏遮挡
  • 页面滚动时布局发生明显抖动

当开发者将 CSS 属性改为height: 100dvh后,所有问题都迎刃而解,而且无需借助任何 JavaScript 代码或复杂的布局调整。这种方法已经成为移动端全屏布局的新趋势。

四、为什么要尽快采用新单位?

  1. 简化开发流程:不再需要与浏览器 UI 进行反复的调试和优化
  2. 提升用户体验:避免因工具栏变化导致的布局抖动和内容显示异常
  3. 减少代码复杂度:无需使用 JavaScript 来辅助布局调整
  4. 增强兼容性:在真实设备上提供更稳定、可预测的显示效果

五、浏览器兼容性

截至 2025 年,主流浏览器如 Chrome、Safari 和 Firefox 都已全面支持svhlvhdvh这三个新单位。这意味着,开发者可以放心地在项目中使用这些新特性。

不过,在一些旧版本的浏览器或者一些不太主流的移动端浏览器上,可能存在兼容性问题。因此,在实际开发中,建议进行充分的测试,并为不支持这些单位的浏览器提供合适的备用方案,以确保页面在各种移动设备上都能有良好的显示效果。

image.png

在不支持 svhlvh 和 dvh 的浏览器上,可以通过以下几种方式提供备用方案:

1. 使用 JavaScript 动态计算视口高度

通过 JavaScript 监听窗口尺寸变化并动态设置 CSS 变量或样式,模拟 dvh 的行为:

function updateViewportHeight() {
  // 计算动态视口高度
  const dvh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--dvh', `${dvh}px`);
  
  // 计算小视口高度(可选)
  const svh = Math.min(window.innerHeight, screen.height) * 0.01;
  document.documentElement.style.setProperty('--svh', `${svh}px`);
  
  // 计算大视口高度(可选)
  const lvh = Math.max(window.innerHeight, screen.height) * 0.01;
  document.documentElement.style.setProperty('--lvh', `${lvh}px`);
}

// 初始化并监听窗口变化
updateViewportHeight();
window.addEventListener('resize', updateViewportHeight);
window.addEventListener('orientationchange', updateViewportHeight);

然后在 CSS 中使用这些变量作为备用值:

.element {
  height: 100vh; /* 备用方案 */
  height: 100dvh; /* 优先使用 dvh */
  height: calc(100 * var(--dvh, 1vh)); /* 动态计算的备用方案 */
}

2. 使用 CSS @supports 特性检测

通过 @supports 检测浏览器是否支持新单位,并提供降级方案:

/* 默认使用 vh 作为备用 */
.element {
  height: 100vh;
}

/* 如果支持 dvh,则覆盖上面的规则 */
@supports (height: 100dvh) {
  .element {
    height: 100dvh;
  }
}

/* 或者结合 CSS 变量 */
:root {
  --viewport-height: 1vh;
}

@supports (height: 100dvh) {
  :root {
    --viewport-height: 1dvh;
  }
}

.element {
  height: calc(100 * var(--viewport-height));
}

3. 针对 Safari 的地址栏问题特殊处理

在 iOS Safari 中,地址栏收起时 vh 会变小,导致内容溢出。可以结合 JavaScript 和 CSS 解决:

// 检测是否为 iOS Safari
function isIOSSafari() {
  const userAgent = window.navigator.userAgent.toLowerCase();
  return (
    userAgent.indexOf('safari') !== -1 &&
    userAgent.indexOf('chrome') === -1 &&
    (userAgent.indexOf('iphone') !== -1 || userAgent.indexOf('ipad') !== -1)
  );
}

if (isIOSSafari()) {
  // 设置 CSS 变量以补偿 iOS Safari 的地址栏
  document.documentElement.classList.add('ios-safari');
}
/* 针对 iOS Safari 的特殊处理 */
.ios-safari .fullscreen-element {
  height: 100vh;
  height: -webkit-fill-available;
}

/* 现代浏览器直接使用 dvh */
@supports (height: 100dvh) {
  .fullscreen-element {
    height: 100dvh;
  }
}

4. 使用 min() 和 max() 函数组合

结合 vh 和固定值,通过 min() 或 max() 函数减少地址栏的影响:

.element {
  /* 防止地址栏展开时内容被遮挡 */
  min-height: 100vh;
  min-height: calc(100vh - 56px); /* 减去底部导航栏高度 */
  
  /* 防止地址栏收起时内容溢出 */
  max-height: 100vh;
  max-height: calc(100vh + 56px); /* 加上地址栏高度 */
}

5. 组合多种方案

通常最佳实践是结合多种方法,提供渐进增强的体验:

/* 基础方案 */
.element {
  height: 100vh;
}

/* 针对支持 -webkit-fill-available 的浏览器(如 iOS Safari) */
@supports (-webkit-touch-callout: none) {
  .element {
    height: -webkit-fill-available;
  }
}

/* 针对支持 dvh 的现代浏览器 */
@supports (height: 100dvh) {
  .element {
    height: 100dvh;
  }
}

/* 针对所有浏览器的 JavaScript 增强 */
.element {
  height: calc(100 * var(--dvh, 1vh));
}
总结
  • 优先使用原生单位:直接在 CSS 中写 100dvh,让支持的浏览器直接使用。

  • 结合 CSS 变量和 JavaScript:通过动态计算和变量提供备用值。

  • 特性检测:使用 @supports 为不同浏览器提供不同规则。

  • 渐进增强:从基础的 vh 开始,逐步增强到 dvh 和 JavaScript 方案。

通过这些方法,可以在大多数浏览器中实现一致的视口高度布局效果。

六、如何选择合适的单位?

  • 始终可见的内容:使用100svh,确保元素不会被工具栏遮挡
  • 沉浸式全屏体验:使用100lvh,打造无干扰的视觉效果
  • 动态自适应布局:使用100dvh,让页面高度随工具栏状态自动调整

扩展

Chrome 108 中,几个新的 CSS 视口单位

视口(viewport)代表当前可见的计算机图形区域。在 Web 浏览器术语中,通常与浏览器窗口相同,但不包括浏览器的 UI, 菜单栏等 — 即指你正在浏览的文档的那一部分。

一般我们提到的视口有三种:布局视口、视觉视口、理想视口,在我之前写的下面这篇文章中详细介绍了视口相关的概念和原理看兴趣可以看:

在响应式布局中,我们经常会用到两个视口相关的单位:

  • vw(Viewport's width)1vw 等于视觉视口的 1%
  • vh(Viewport's height) : 1vh 为视觉视口高度的 1%

另外还有两个相关的衍生单位:

  • vmin : vwvh 中的较小值
  • vmax : 选取 vwvh 中的较大值

如果我们将一个元素的宽度设置为 100vw 高度设置为 100vh,它将完全覆盖视觉视口:

这些单位有很好的浏览器兼容性,也在桌面端布局中得到了很好的应用。

但是,在移动设备上的表现就差强人意了,移动设备的视口大小会受动态工具栏(例如地址栏和标签栏)存在与否的影响。视口大小可能会更改,但 vwvh 的大小不会。因此,尺寸过大的 100vh 元素可能会从视口中溢出。

当网页向下滚动时,这些动态工具栏可能又会自动缩回。在这种状态下,尺寸为 100vh 的元素又可以覆盖整个视口。

为了解决这个问题,CSS 工作组规定了视口的各种状态。

  • Large viewport(大视口):视口大小假设任何动态工具栏都是收缩状态。
  • Small Viewport(小视口):视口大小假设任何动态工具栏都是扩展状态。

新的视口也分配了单位:

  • 代表 Large viewport 的单位以 lv 为前缀:lvw、lvh、lvi、lvb、lvmin、lvmax
  • 代表 Small Viewport 的单位以 sv 为前缀:svw、svh、svi、svb、svmin、svmax

除非调整视口本身的大小,否则这些视口百分比单位的大小是固定的。

除了 Large viewportSmall Viewport ,还有一个  Dynamic viewport(动态视口)

  • 当动态工具栏展开时,动态视口等于小视口的大小。
  • 当动态工具栏被缩回时,动态视口等于大视口的大小。

相应的,它的视口单位以 dv 为前缀:dvw, dvh, dvi, dvb, dvmin, dvmax

目前,各大浏览器均已经对新的视口单位提供了支持:

你学废了吗?

键盘风暴:一个 完全 用 AI 完成的前端白板项目

键盘风暴:一个 完全 用 AI 完成的前端白板项目

作为一个初出茅庐的小程序员,看到最近 AI 代码能力越来越强,就忍不住想要用 AI,来尝试完成一个项目,看看只靠 AI 的能力,一个几乎完全不懂前端的人,能做到什么程度,同时,也想,如果这个项目能有人瞧瞧,希望能推广一下键盘风暴这个概念

构思

项目的构思是这样的:用市场上的白板工具做流程图或者脑图的时候,工作流大概是这样的:

  1. 鼠标创建一个容器(在屏幕上拖拽得到一个矩形框,或者从元素库里拖拽一个元素到白板上)
  2. 鼠标双击进入到这个容器中
  3. 手离开鼠标,到键盘上,键入内容
  4. 保存内容,回到鼠标上,创建下一个容器

当我们需要画的内容变得越来越多的时候,我们需要频繁的这样进行切换,对比于在纸上构建思维导图或者脑图,我们需要来回切换鼠标和键盘,需要考虑容器的位置,拖拽考虑容器的位置,而又到了真正输入内容的时候,思绪可能已经被这些小动作打断了。

所以,我很讨厌这样的工作流,但又不得不接受。我也考虑过别的工作流,比如:

  1. 在草稿纸上大概画好所有的内容
  2. 然后在屏幕上根据草稿画好我需要的所有容器
  3. 再逐个点进去,编辑内容

我会觉得这样的方式会更好一点,思绪不会太容易被打断。但这有点失去它们的一部分意义了,它们变成了一个单纯的,美化工具了,美化我原本画的比较丑的图的工具。而我找不到一款更合适的,能打破这个工作流的工具,正好最近 AI 的代码能力越来越强,我就尝试性的,在不太懂前端的情况下,用 AI 完成这个项目。

概况

这个项目我尝试了 cursor、Trae、Trae CN 和 vscode 的 copilot,尝试了Cluade 3.7 Sonnet、Cluade 3.7 Sonnet、Cluade 3.7 Sonnet Thinking、Gemini 2.5 Pro、GPT-4.1、GPT-4o、deepseek-v3、deepseek-r1 等模型。如果说,以前有的孩子是吃百家饭长大的,那也不怕您笑话,可以说**这个项目是吃百家模长大的。**喂孩子的模型可能很懂这些代码,但是孩子的主人几乎不太懂它们,可能也有点像什么都不懂的老板指挥手下的工程师干活?

您大概也能猜到,键盘风暴(kb-storm)是一个 copy 自头脑风暴(Brian storm)的词。这个项目是以键盘为主导的程序,当您进入了网页,按下 ctrl + d就可以得到一个矩形框或者叫卡片、便利贴(我很喜欢卡片的背景颜色,像便利贴一样),然后就可以直接使用键盘进行输入您的想法了。当您写完了您的想法,您可以继续按下 ctrl + d,创建下一张便利贴,然后进行输入。

对比于市场上的大部分工具,它使用快捷键就直接创建一个容器并进入了编辑状态,省去了我上面的那个工作流的鼠标步骤,我们只需要用键盘输入我们的想法就好了,不用离开键盘寻找鼠标,不用考虑下一个容器创建的位置,不用考虑容器的大小,不用再双击容器进入编辑状态了。您思维的流出速度,只取决于您的手速。

您可能会想,按下 ctrl + d 后,便利贴(卡片/容器)的位置会出现在哪?是随机出现在屏幕显示区域的位置的。当您移动或者缩放画布了,它就会在画布新的位置(依旧是您的可视区域中)随机生成。您可以运用这一个特性,在白板的不同区域,创建不同的想法区。

为什么是随机生成呢?因为按网格排列太死板了,就像随机生成的便利贴的颜色也不一样。随机生成的便利贴需要整理,就像我们的思绪需要整理。当我们在整理便利贴阶段,进行移动的时候,也正是在对我们的思绪整理的一个过程,而便利贴在移动的时候可能会与其他的便利贴进行碰撞,两个想法相互碰撞,得到新的火花。

卡片的大小呢?我自设了一个比较合适的值,可以容纳一个小想法的内容。而当您输入的内容比较多的时候,它会自适应变大来容纳您的文字,保证的能完全显示。

其它键盘功能

为了让键盘的功能更强大,除了 ctrl + d 我还设置了一些别的操作:

1:按下数字 1,您就进入了卡片选择模式,移动您的方向键,您就可以再卡片之间进行选择,按⬆️,会选中当前卡片该方向上的另一张卡片,按其它方向键同理。目前我没有遇到过有卡片无法选中的情况,可能在极端情况下会有卡片无法选中的情况,您可能不得不使用鼠标进行操作了。

2:按下数字 2,您就进入了卡片移动模式,移动您的方向键,您就可以移动您当前选中的卡片,如果您没有选中任何卡片,则会回到卡片选择模式。如果您移动方向键的时候同时按下 shift 键,那么卡片移动的速度会变快。

3:按下数字 3,您就进入了线条选择模式,移动您的方向键,您就可以选择卡片之间的连线了,这个是循环选择的,暂时没有像卡片那样设置算法。

ctrl + i :既然说到了线条,那就说一下连线方式,在选择模式有选中卡片的请胯下,按下这个快捷键,您就可以,使用方向键,移动到另一张卡片上,选择好后,按下 enter 就会在这两张卡片之间连上一条线。也是一个少用鼠标的功能。

tab:曾经的 tab 键也是用于线和卡片之间的循环,当前 tab 设计的功能是,切换选中卡片的颜色,和切换选中线条的箭头方向。您可以在卡片选择模式和线条选择模式下尝试一下。

快捷键修改:为什么创建卡片的快捷键是 ctrl + d 呢?(为了骗您收藏这个网页(bushi))答:随便设的。您可以对最常用的 ctrl + dctrl + i 的快捷键需要修改,改成你喜欢的,更方便的快捷键。

主要场景与工作流

介绍完了主要功能,就基本可以使用键盘风暴了,在给您项目地址前,我想先给您讲讲我设想的一些工作场景和工作流。

毫无疑问,第一个场景就是头脑风暴,当您在电脑上想要进行头脑风暴的时候,您不再需要使用之前的那些白板工具,先用鼠标创建卡片,点进去,再用键盘输入的工作流了,您可以直接用键盘进行一场键盘风暴

  1. 产生一个 idea,按下键盘快捷键就可以输入 idea
  2. 产生第二个 idea,再次按下键盘快捷键,又可以进行输入
  3. 整个过程行云流水,头脑风暴畅通无阻
  4. idea 写完的,整理卡片之间的位置和关系,拖动卡片就是整理思绪,也能产生新的火花🔥🔥🔥

另一个场景是整理,整理读完一本书的内容,整理学完一个知识的内容,做事件的复盘,做一天的回忆

  1. 您不用从头开始回忆了,从头回忆能梳理内容,但是有时很痛苦
  2. 您可以用这个工具直接回忆您记得的,印象最深的内容,直接写到屏幕上
  3. 然后印象第二深刻的知识点,事件点,or 单纯印象深刻
  4. 这样写完你能想到的 anything
  5. 最后再在卡片的分类,移动的过程中,您能直观的看到,您记忆中最深的东西,记忆中记得最少的东西,对于记忆缺失的内容,您可以进一步的去复习,去和别人复盘,去梳理。您可以在整理分类后看到您对这个东西的所有印象,能更好的进行整理和输出。

不知道您有没有心动想要尝试的想法了,还有一个小场景是做自由书写,想到哪,写到哪,很符合这个工具的作用,在写完后,进行卡片之间的整理,也是一个对自己思想的考虑过程。

总结:主要的工作流就是先用键盘输入您想要输入的一切,然后再对随机分布的卡片进行整理。整理的过程中您可以用键盘,也可以用鼠标,还可以碰撞产生新的火花。

项目地址

那么下面是项目地址:

体验地址:(github page 静态部署)

qkyufw.github.io/kb-storm/

项目主页

github.com/qkyufw/kb-s…

用户手册:(AI 写的,大部分功能没毛病,您且看看)

github.com/qkyufw/kb-s…

更多功能

既然您还在阅读,那给您分享一下我设计的一些其它功能

导入导出 markdown

我设计了导出图片,这个功能不是很完善,但能用吧。导入导出 mermaid 功能我也有加上,这个很有效,能同步到其它工具中使用,不太保证稳定性。

导入导出 markdown,我想过如何保存这些个内容,又能保证可读性。就大胆的设计了用 markdown 来导出内容。导出的标题是 kbstorm 开头的,卡片的内容写在 markdown 正文,每张卡片用 --- 进行分割。在 markdown 的最后面是元数据,如果导入的时候识别到了有元数据,那将可以完全恢复这整个图。

对于导入 markdown,您也可以选择自己手写一个 markdown 进行导出。具体设计的工作流是这样的:

  1. 当您需要快速使用文本记录的时候,您打开 Typora 或其它 markdown 编辑软件,之间在正文开始写您的想法
  2. 您写完一个想法后,在下一行写下 ---,写一个分隔符
  3. 然后就可以在再下一行写新的想法内容了
  4. 写完 markdown 后,您不需要写标题或者元数据,直接在网站进行导入
  5. 它会分割内容,在随机位置生成卡片,您可以继续使用 kbstorm 继续您的工作了

自由连线功能

当您按下工具栏上的自由连线按钮后,您可以用鼠标在白板上自由的拖拽画线了,对于线的起点终点,您无需费尽脑汁对准卡片的边缘了,起点是一个卡片内的任意一个点,终点是另一张卡片上的任意一个点,起点终点的区域都很大,连上了之后会自动连接两张卡片的边缘并吸附上。当然,起点终点如果有一个不再卡片上,都无法进行连线

一点畅想

其它的一些功能就大差不差的像普通的白板工具的功能了,哦对,还有,双击卡片进入该卡片的编辑,双击白板空白位置则是创建新的卡片,直接可以开始用键盘输入了(这个功能畅想的是在移动端很好用,可以用手双击任意区域,就可以开始新的卡片内容编辑了,不需要拖拽画卡片,方便,简单)

布局系统:您可以看到右上角设计了一种布局——随机布局,意味着您用快捷键,或者导入的卡片,都将出现在屏幕可视区域的随机位置,我希望用户对于这个布局算法能有更高的自定义,比如按网格排列,按螺旋排列?等等,暂时这个系统还不完善。

还有些其它小功能,这里就暂时不一一枚举了

对 AI 的感受

对于这个吃百家模长大的孩子,我感觉磕磕绊绊的,对于一个完整可用的项目来说,它应该能算完成了百分之七十吧?

对于 AI 写代码的最大感受还是,它们对于普通人还是有些距离,如果您完全不了解一个方向,您最好还是有一个引导者,或者有一份完整的教程,因为我开始对前端几乎一窍不通,我最初使用 AI 搭建的过程中,AI 也没有告诉我需要创建 React 项目该如何进行,我就直接让它创建一个 html 网页,差不多就像我拿着剪刀就来拆手机了。

如果您不了解一个东西,您指挥会的人做,您也可能会被蒙蔽。AI,暂时也没到全知全能的程度,您还是得了解代码,您才能灵活的用 AI,去解决。大概 AI,对于高级程序员还是更有用一些。

另外讲讲我遇到的一些问题,记录的不全,见谅:

  1. 出现错误,让 AI,进行修改,它给出 AB两种方案,A方案错误,告诉它不行,它使用B方案重试,B方案错误,告诉它不行,它又用回 A 方案,来回横跳……,无语
  2. 出现错误,它给出 ABC三种方案,然后它三种方案都用上了,造成代码严重冗余,还可能你看它噼里啪啦输出一大堆内容,依旧无法解决 bug
  3. 它生成的代码出现引用错误,让它进行更新,它不去更新路径,而在在错误的路径从新生成该文件……,我们能说什么呢……
  4. 出现一个 bug,ABC 环节都可能有问题,它也能分析出 AB环节可能有问题,就认死理只去修改 A环节

使用 AI 生成内容最头疼的问题还是时间问题。您必须要比较精准的描述您的需求,或者说,要用它能听懂的话来和它进行沟通。一旦有什么理解错误,网络波动,调试错误,您就会大量浪费时间,尤其是在您可能不懂它写的内容的情况下。

另一个头疼的问题是,如何与它沟通,我在和它的沟通过程中,学会了一种方式,将您的需求交给另一个 AI,让它来帮你写一份提示词,您可以用它生成一份您满意的,完善的提示词,再交给编辑程序去修改。不少时候,它写的提示词确实很完美,如果您觉的不完善,也可以让它再修改。

如果出现了一个 bug,AI,一直解决不了,您不仅可以尝试改提示词,也可以尝试换一个模型,很多时候都有奇效!!!这也是这个项目是吃百家模长大的原因。

目前位置,我还是很喜欢使用 ask 模式,和这些个 AI 斗志斗勇还是有一段时间了,我不太了解 React 都稍微了解一些了。然后能通过 ask 模式去和 AI 设计架构,重构价格,探讨功能如何设计,探讨 bug 如何解决,和它探讨更好的解决方案。

最后的建议:善用 git,善用回滚。

AI 依旧还只是工具。

结束

感谢您看完这篇长文,还是再说一下,我做这个项目是想看看,我能用 AI 将这个项目构建到什么程度,也希望能宣传一下键盘风暴这个概念,可能不止会有键盘党才喜欢。

我没有期待键盘风暴这个项目能得到流行,它只是我一个前端门外汉用 AI 设计的一个尚且能用的工具。如果您觉得这个项目还不错,您想要进一步完善,非常欢迎!

最后:求点赞,求三连,求分享,求收藏,求关注(bushi,社恐不需要这个),总之就是小扑街求点热度,球你了QAQ

欢迎留下您的想法

BilibiliBlock 哔哩哔哩一键拉黑扩展。它允许用户直接在视频卡片上拉黑视频作者,无需进入UP主的个人空间页面。

BilibiliBlock 是一个浏览器扩展,为哔哩哔哩(Bilibili)用户提供一键拉黑UP主的功能。它允许用户直接在视频卡片上拉黑视频作者,无需进入UP主的个人空间页面。

项目链接:github.com/yanstu/Bili…

功能特点

  • 一键拉黑:在视频卡片上直接拉黑UP主,方便快捷
  • 黑名单管理:查看、导出已拉黑的用户列表
  • 自动淡化:拉黑后自动淡化相关视频卡片
  • 适配多种设备:支持不同分辨率和浏览器的暗色模式
  • 高度自定义:可自定义拉黑按钮的位置、文本等

安装方法

Chrome/Edge 应用商店安装

  1. 访问 Microsoft Edge Add-ons
  2. 搜索 "BilibiliBlock"
  3. 点击 "添加到Chrome" 或 "获取"

手动安装(开发者模式)

  1. 下载此仓库代码(下载ZIP或克隆仓库)
  2. 打开Chrome/Edge浏览器,进入扩展管理页面(在地址栏输入 chrome://extensionsedge://extensions
  3. 开启右上角的"开发者模式"
  4. 点击"加载已解压的扩展程序",选择下载的文件夹
  5. 扩展将被安装到浏览器中

使用方法

  1. 安装扩展后,访问哔哩哔哩网站
  2. 在视频卡片中,作者名称旁边会出现一个"拉黑"按钮
  3. 点击"拉黑"按钮即可将该UP主加入黑名单
  4. 点击浏览器工具栏的扩展图标,可以查看黑名单

屏幕截图

首页拉黑功能首页拉黑功能

搜索结果页面拉黑功能 搜索结果页面拉黑功能

扩展弹出窗口 扩展弹出窗口

选项设置 选项设置

技术实现

  • 纯JavaScript实现,无第三方依赖
  • 使用Chrome扩展API进行存储和通信
  • MutationObserver监听页面变化,处理动态加载内容
  • 使用模块化设计,分离UI、API和工具函数

浏览器兼容性

  • Google Chrome 80+
  • Microsoft Edge 80+
  • 其他基于Chromium的浏览器应该也可以运行

隐私声明

BilibiliBlock 尊重用户隐私:

  • 不收集任何用户数据
  • 所有设置和黑名单仅存储在浏览器本地
  • 不包含任何跟踪或分析代码
  • 仅请求必要的网站权限

vue引入deepseek

首先需要申请key: 地址

deepseek文档地址

使用fetch或者axios发送请求获取数据

    const response = await axios('https://api.deepseek.com/chat/completions', {
      method: 'POST',
      responseType: 'text', 
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer YOUR_API_KEY'
      },
      data: {
      model: 'deepseek-chat',
      messages: [{ role: 'user', content: '发送到deepseek的内容'}], // role有其他的角色,具体可以查看文档
      temperature: 0.7,
      stream: true, // 是否发送流式数据
      },
      onDownloadProgress: (progressEvent) => { // 获取流式数据
            const text = progressEvent.currentTarget.responseText.replace(/\\n/g, '<br/>') // 换乘br,防止换行符解析在浏览器失效
            const infoList = text.match(/data:\s*(\{[^}]*\})/g).splice(length) // 通过正则获取想要的内容
            length = text.match(/data:\s*(\{[^}]*\})/g).length
            infoList.forEach(item => {
              console.log(item, 'item')
              const jsonStr = item.replace(/^data:\s*/, '')
              try {
                this.deepSeekData += JSON.parse(jsonStr).content
              } catch (e) {
                console.error('解析JSON失败:', e, '原始数据:', jsonStr)
                return null
              }
            })
          }
    });

在页面渲染

使用vue-markdown解析md文本内容

  • 使用方法
npm install vue-markdown --save
<template>
<!-- 
source: 需要渲染的Markdown原始文本
html:是否解析HTML标签(开启需注意XSS风险)
breaks:是否将换行符\n渲染为<br>
toc:是否生成目录(Table of Contents)
toc-anchor-link: 是否在标题旁显示目录锚点链接
toc-first-level: 目录包含的最小标题级别(1-6)
plugins: ['markdown-it-emoji']
 -->
<vue-markdown
   :html="true"
   :breaks="true"
   :tables="true"
   :task-lists="true"
   :emoji="true"
   :source="source"
 />
</template>

<script>
import VueMarkdown from 'vue-markdown';

export default {
  data() {
    return {
      markdown: '# Hello, Vue Markdown!'
    }
  },
  components: {
    VueMarkdown
  }
}
</script>

Core Web Vitals 指标 - Largest Contentful Paint (LCP) 最大内容绘制

good-lcp-values.svg

Largest Contentful Paint (LCP) 是 Core Web Vitals 三大核心指标之一,用于衡量页面加载性能,具体指用户从开始加载页面到视口中最大内容元素(如图像、视频或文本块)完全渲染完成的时间。

LCP 会报告视口内可见的最大图片、文本块或视频的渲染时间(相对于用户首次导航到网页的时间)

包含哪些元素?

根据Largest Contentful Paint API中所述,系统会考虑以下类型的元素来计算 Largest Contentful Paint:

  • <img> 元素(第一帧呈现时间适用于 GIF 或动画 PNG 等动画内容)
  • <svg> 元素中的 <image> 元素
  • <video> 元素(使用海报图片加载时间或视频的第一帧呈现时间,以较早者为准)
  • 使用 url() 函数加载背景图片的元素
  • 包含文本节点的块级元素或其他内联文本元素子元素。

元素的大小是怎么确定的?

LCP 报告的元素大小通常是指用户在视口中看到的大小,包含文本节点的快级元素或文本元素,大小是包含文字大小决定的,不是块级元素的w*h

  • 如果元素超出视口范围,或者元素的任何部分被剪裁或具有不可见的溢出,则这些部分不会计入元素的大小。
  • 对于已从固有大小调整大小的图片元素,系统会报告较小的可见大小或固有大小。
  • 对于文本元素,LCP 仅考虑可包含所有文本节点的最小矩形。
  • 对于所有元素,LCP 均不会考虑使用 CSS 应用的外边距、内边 距或边框。

LCP的每条记录是什么时候产生的?

网页通常分阶段加载,因此网页上最大的元素可能会发生变化。浏览器会在绘制第一帧后立即调度类型为 largest-contentful-paint 的 PerformanceEntry,以标识最大的内容元素。但是,在渲染后续帧后,每当 Largest Contentful Element 发生变化时,它都会调度另一个 PerformanceEntry

几个关键点:

  • 只有在元素呈现并可供用户看到后,才能将其视为包含内容的最大元素。尚未加载的图片不计入“呈现”次数。在字体阻塞周期内,文本节点也不会使用 Web 字体。在这种情况下,系统可能会报告较小的元素为最大内容渲染时间元素,但一旦较大的元素完成渲染,系统就会创建另一个 PerformanceEntry
  • 除了延迟加载的图片和字体之外,向 DOM 添加新元素。如果其中任何新元素的大小大于之前最大的有内容元素,系统也会记录新的 PerformanceEntry
  • 如果从视口中移除(甚至从 DOM 中移除)最大的内容元素,除非渲染出更大的元素,否则它仍然是最大的内容元素。
  • 一旦用户与网页互动(通过点按、滚动或按键操作),浏览器就会停止记录新条目。

元素布局和大小更改了怎么处理? 对元素的大小或位置所做的更改不会生成新的 LCP 候选项。系统仅会考虑元素在视口中的初始尺寸和位置。这意味着:

  1. 最初在屏幕外呈现,然后转换到屏幕上的图片可能不会被记录。
  2. 最初在视口中呈现但随后被推下、超出视野的元素仍会记录其初始的视口内大小。

获取 largest-contentful-paint 记录方式:

// 此代码展示了如何将 `largest-contentful-paint` 条目记录到控制台
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log('LCP candidate:', entry.startTime, entry);
  }
}).observe({type: 'largest-contentful-paint', buffered: true});

实例 :

<script>
  // 添加 max-box
  setTimeout(() => {
    const div = document.createElement('div');
    div.style.width = '300px';
    div.style.height = '300px';
    div.style.backgroundColor = 'green';
    div.classList.add('max-box');

    const textNode = document.createTextNode('1');
    div.appendChild(textNode);
    document.body.appendChild(div);
  }, 1000)

  // 添加 img 1920 * 900
  setTimeout(() => {
    const img = new Image();
    img.src = 'https://web.developers.google.cn/static/articles/optimize-lcp/image/a-network-waterfall-the-919387402b1d3_856.png?hl=zh-cn'
    document.body.appendChild(img);
  }, 2000)

  // 修改 min-box 的大小
  setTimeout(() => {
    console.log('change size');
    const div = document.getElementById('min-box');
    div.style.width = '360px';
    div.style.height = '360px';
  }, 3000)
</script>

<body>
  <div id="min-box" style="width: 200px; height: 100px; background-color: antiquewhite;">
    <span>1</span>
  </div>
</body>

控制台打印结果:

Pasted_image_20250520155854.png

修改min-box并没有打印新的记录,但是max-box没有打印出来。

实例 2:修改max-box的文本内容为12

控制台打印结果:

Pasted_image_20250520160203.png

max-box 打印了

指标计算

在上面的示例中,每个记录的 largest-contentful-paint 条目都代表当前的 LCP 候选项。一般来说,发出的最后一个条目的 startTime 值就是 LCP 值,但也不总是如此。并非所有 largest-contentful-paint 条目都适用于衡量 LCP。

指标与 API 之间的差异

  • 该 API 会为在后台标签页中加载的网页分派 largest-contentful-paint 条目,但在计算 LCP 时应忽略这些网页。
  • 网页进入后台后,该 API 将继续调度 largest-contentful-paint 条目,但在计算 LCP 时应忽略这些条目(仅当网页在整个时间都处于前台时,才能考虑元素)。
  • 当网页从返回/前进缓存恢复时,API 不会报告 largest-contentful-paint 条目,但在这些情况下,应衡量 LCP,因为用户会将其视为不同的网页访问。
  • API 不会考虑 iframe 中的元素,但该指标会考虑,因为它们是网页用户体验的一部分。
  • 该 API 会从导航开始时刻开始衡量 LCP,但对于预渲染网页,应从 activationStart 开始衡量 LCP,因为这与用户体验到的 LCP 时间相对应。

开发者无需记住所有这些细微差异,只需使用 [web-vitals ] (github.com/GoogleChrom… LCP,该库会处理这些差异(在可能的情况下,请注意不涵盖 iframe 问题):

import {onLCP} from 'web-vitals';

onLCP(console.log);

优化 LCP

LCP 的标准

使用 Chrome 开发者工具 可以看到 LCP 的指标信息,以及细分的四个子部分。

LCP 描述
<= 2.5s 良好
2.5s - 4s 需要改进
>4s 较差

Pasted_image_20250521100750.png

Pasted_image_20250521100646.png

最佳子部分时间

LCP 子部分 LCP 占比 (%)
加载第一个字节所需时间 ~40%
资源加载延迟 <10%
资源加载时长 ~40%
元素渲染延迟 <10%
总计 100%

各部分优化

减少资源加载延迟

一般来说,有两个因素会影响 LCP 资源的加载速度:

  • 发现资源的时间。
  • 资源的优先级。
  1. 在发现资源时进行优化
  • LCP 元素是 <img> 元素,其 src 或 srcset 属性存在于初始 HTML 标记中。
  • LCP 元素需要 CSS 背景图片,但该图片是在 HTML 标记中使用 <link rel="preload">(或使用 Link 标头)预加载的。
  • LCP 元素是一个文本节点,需要 Web 字体才能呈现,并且该字体是在 HTML 标记中使用 <link rel="preload">(或使用 Link 标头)加载的。
<!-- 加载将引用LCP图片的样式表 -->
<link rel="stylesheet" href="/path/to/styles.css">

<!-- 预加载LCP图片并设置高fetchpriority,以便其与样式表同时开始加载。 -->
<link rel="preload" fetchpriority="high" as="image" href="/path/to/hero-image.webp" type="image/webp">
  1. 优化资源的优先级
  • 如果 LCP 元素是图片可以设置 fetchpriority="high"
<img fetchpriority="high" src="/path/to/hero-image.webp">
减少元素渲染延迟

此步骤的目标是确保 LCP 元素可在其资源完成加载(无论何时)后立即渲染

LCP 元素在其资源完成加载后_无法_立即渲染的主要原因是,渲染因其他原因而被阻止

  • 由于 <head> 中的样式表或同步脚本仍在加载,因此整个页面的呈现被阻止。
  • LCP 资源已加载完毕,但 LCP 元素尚未添加到 DOM(正在等待某些 JavaScript 代码加载)。
  • 该元素被其他代码隐藏了,例如仍在确定用户应参与哪项实验的 A/B 测试库。
  • 主线程因长时间运行的任务而被阻塞,渲染工作需要等到这些长时间运行的任务完成。

对应优化:

  • 减少阻塞渲染的样式表或将其内嵌,HTML 加载样式表会阻止渲染其后面的所有内容。
  • 延迟或内嵌会阻止渲染的 JavaScript。
  • 使用服务器端渲染(SSR),SSR 有两个主要优势:图片资源将可从 HTML 源代码中找到;网页内容无需等待其他 JavaScript 请求完成。主要缺点是需要额外的服务器处理时间,这可能会减慢 TTFB。不过,这种权衡通常是值得的,因为服务器处理时间在您的控制范围内,而用户的网络和设备功能则不在您的控制范围内。
  • 拆分长任务
缩短资源加载时长

此步骤的目标是减少将资源字节通过网络传输到用户设备所花费的时间。通常,有以下四种方法可以实现此目的:

  1. 缩减资源的大小。
  1. 减少资源传输的距离。
  2. 减少网络带宽争用。
  3. 完全消除网络时间。
缩短加载第一个字节所需时间

有关优化 TTFB 的具体指导,请参阅优化 TTFB 指南

资料:

扩展: [[判断元素是否在视口内]]

❌