阅读视图

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

从零构建坚固的前端堡垒:TypeScript 与 React 实战深度指南

在现代前端开发的浪潮中,JavaScript 无疑是统治者。然而,随着项目规模的指数级增长,JavaScript 灵活的动态类型特性逐渐从“优势”变成了“隐患”。你是否经历过项目上线后页面白屏,控制台赫然写着 Cannot read property 'name' of undefined?你是否在维护前同事的代码时,对着一个名为 data 的对象变量,完全猜不出里面到底装了什么?

这就是 TypeScript (TS) 诞生的意义。

作为 JavaScript 的超集,TypeScript 并不是要取代 JS,而是给它穿上了一层名为“静态类型系统”的钢铁侠战衣。它将类型检查从“运行时”提前到了“编译时”,让 Bug 扼杀在代码编写的那一刻。

今天,我们将通过一个经典的 Todo List(待办事项)项目,从基础语法到 React 组件实战,带你深入理解 TypeScript 如何让你的代码变得更安全、更易读、更易维护

第一部分:TypeScript 的核心基石

在进入 React 实战之前,我们需要先掌握 TS 的几块基石。这些概念通常定义在项目的通用逻辑或类型声明文件中。

1. 给变量一张“身份证”:基础类型

在 JS 中,变量是自由的,今天是数字,明天可以是字符串。但在 TS 中,我们强调“契约精神”。

// 简单类型:显式声明
let age: number = 18;
let username: string = 'hello';

// 类型推导:TS 的智能之处
// 即使不写类型,TS 也会根据初始值推断出 count 是 number
let count = 1; 
// count = '11'; // 报错!你不能把字符串赋值给数字类型的变量

这种类型推导机制意味着你不需要每一行都写类型定义,TS 编译器会默默地在后台守护你。

2. 更有序的集合:数组与元组

在处理列表数据时,TS 提供了两种方式。

如果你想要一个纯粹的数组(比如全是数字),可以这样写:

let scores: number[] = [85, 92, 78];
// 或者使用泛型写法(两者等价)
let names: Array<string> = ['Alice', 'Bob'];

但有时候,我们需要一个固定长度、且每个位置类型都确定的数组,这就是元组 (Tuple) 。这在 React Hooks 中非常常见:

// 元组:第一个位置必须是数字,第二个必须是字符串
let userRecord: [number, string] = [1001, 'Tom'];

3. 告别魔法数字:枚举 (Enum)

你一定见过这种代码:if (status === 1) { ... }。这个 1 到底代表什么?成功?失败?还是进行中?这种让人摸不着头脑的数字被称为“魔法数字”。

TS 的枚举类型完美解决了这个问题:

enum Status {
    Pending, // 0
    Success, // 1
    Failed,  // 2
}

let currentStatus: Status = Status.Pending;

代码的可读性瞬间提升。当其他开发者阅读代码时,Status.Pending 远比 0 具有语义价值。

4. 逃生舱与安全门:Any vs Unknown

这是 TS 新手最容易混淆的概念。

  • Any (任意类型) :这是 TS 的“逃生舱”。当你把变量设为 any,你就放弃了所有类型检查。

    let risky: any = 1;
    risky.hello(); // 编译器不报错,但运行时会崩!
    

    建议:除非万不得已,否则尽量少用 any,否则你写的只是“带注释的 JS”。

  • Unknown (未知类型) :这是更安全的 any。它的原则是:“你可以存任何东西,但在你证明它是谁之前,不能使用它。”

    let safeData: unknown = 1;
    // safeData.toUpperCase(); // 报错!TS 说:我不知道这是不是字符串,不准用。
    
    // 类型断言(Type Assertion)
    if (typeof safeData === 'string') {
        console.log(safeData.toUpperCase()); // 现在可以用了,因为你证明了它是字符串
    }
    

5. 契约精神:接口 (Interface)

接口是 TS 面向对象编程的核心。它定义了一个对象应该“长什么样”。

interface IUser {
    name: string;
    age: number;
    readonly id: number; // 只读属性:生下来就不能改
    hobby?: string[];    // 可选属性:可以有,也可以没有
}
  • readonly:保证了数据的不可变性,防止我们在业务逻辑中意外修改了核心 ID。
  • ? (可选) :处理后端接口返回的不完整数据时非常有用。配合可选链操作符 user.hobby?.length,可以优雅地避免报错。

6. 灵活多变:自定义类型 (Type Aliases)

除了接口,TS 还提供了 type 关键字来创建类型别名。很多人会问:“它和接口有什么区别?”

接口主要用于定义对象的形状(Shape),而 type 更加灵活,它可以定义基础类型的别名,最重要的是它支持联合类型 (Union Types)

场景一:联合类型(最常用) 当一个变量可能是字符串,也可能是数字时,接口就无能为力了,但 type 可以轻松搞定:

// 定义一个 ID 类型,它可以是 string 或者 number
type ID = string | number; 

let userId: ID = 111;      // 合法
userId = "user_123";       // 也合法
// userId = false;         // 报错!

场景二:定义对象别名 虽然通常用 interface 定义对象,但 type 也可以做到:

type UserType = {
    name: string
    age: number
    hobby?: string[]
}

最佳实践建议

  • 如果你在定义对象或组件的 Props,优先使用 Interface(因为它可以被继承和合并)。
  • 如果你需要定义基础类型的组合(如 string | number)或函数类型,使用 Type

第二部分:React + TypeScript 项目架构设计

理解了基础语法后,我们来构建应用。一个优秀的 React + TS 项目,其目录结构应该清晰地分离数据定义逻辑视图

我们将按照以下结构组织代码:

  1. src/types:存放通用的类型定义(接口)。
  2. src/utils:存放工具函数。
  3. src/hooks:存放自定义 Hooks(业务逻辑)。
  4. src/components:存放 React 组件(视图)。

1. 数据模型先行 (src/types)

在写任何 UI 代码之前,先定义数据。这是 TS 开发的最佳实践。我们在 types 目录下定义 Todo item 的结构:

// 这是整个应用的数据核心
export interface Todo {
    id: number;
    title: string;
    completed: boolean;
}

有了这个接口,应用中任何涉及 Todo 的地方都有了“法律依据”。

2. 泛型的妙用 (src/utils)

我们需要将数据持久化到 localStorage。为了让这个存储函数通用(既能存 Todo 数组,也能存用户信息),我们使用泛型 (Generics)

泛型就像是一个“类型的占位符”。

// <T> 就是这个占位符,调用时才决定它是什么类型
export function getStorage<T>(key: string, defaultValue: T): T {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
}

当我们调用 getStorage<Todo[]>('todos', []) 时,TS 就知道返回值一定是 Todo 类型的数组。如果不用泛型,JSON.parse 返回的是 any,我们就会丢失宝贵的类型保护。

3. 逻辑与视图分离 (src/hooks)

我们将 Todo 的增删改查逻辑抽离到自定义 Hook 中。这里展示了 TS 如何保护业务逻辑。

import { useState } from 'react';
import type { Todo } from '../types';

export function useTodos() {
    // 显式声明 state 存放的是 Todo 类型的数组
    const [todos, setTodos] = useState<Todo[]>([]);

    const addTodo = (title: string) => {
        const newTodo: Todo = {
            id: Date.now(),
            title: title.trim(),
            completed: false, 
        }
        // 如果这里少写了 completed 属性,TS 会立即标红报错!
        setTodos([...todos, newTodo]);
    }
    
    // ... toggleTodo, removeTodo 的逻辑
    
    return { todos, addTodo, toggleTodo, removeTodo };
}

在 JS 中,如果你在创建 newTodo 时拼写错误(比如把 completed 写成 complete),这个错误会一直潜伏到页面渲染时才暴露。而在 TS 中,编辑器会当你面直接画红线拒绝编译。

第三部分:组件化开发实战

接下来我们进入 src/components 目录,看看 TS 如何增强 React 组件的健壮性。

1. 组件 Props 的强契约

React 组件通信依靠 Props。在 TS 中,我们不再需要 PropTypes 库,直接用 Interface 定义 Props。

输入组件 (TodoInput):

import * as React from 'react';

// 定义父组件必须传给我什么
interface Props {
    onAdd: (title: string) => void; // 一个函数,接收 string,没有返回值
}

// React.FC<Props> 告诉 TS:这是一个函数式组件,它的 Props 符合上面的定义
const TodoInput: React.FC<Props> = ({ onAdd }) => {
    const [title, setTitle] = React.useState<string>('');

    const handleAdd = () => {
        if(!title.trim()) return;
        onAdd(title); // TS 会检查这里传入的是否是 string
        setTitle('');
    }
    // ... JSX 渲染 input 和 button
}

2. 列表项组件 (TodoItem)

这里展示了接口的复用。我们可以直接引入之前定义的 Todo 接口。

import type { Todo } from '../types';

interface Props {
    todo: Todo; // 直接复用核心类型
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, onToggle, onRemove }) => {
    return (
        <li>
            <input 
                type="checkbox" 
                checked={todo.completed} 
                onChange={() => onToggle(todo.id)}
            />
            {/* 样式处理:如果完成则加删除线 */}
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none'}}>
                {todo.title}
            </span>
            <button onClick={() => onRemove(todo.id)}>删除</button>
        </li>
    )
}

TS 的威力展示

如果在 span 标签里,你试图渲染 {todo.name},TS 会立刻报错:“属性 'name' 在类型 'Todo' 中不存在”。这避免了运行时出现 undefined 的尴尬。

3. 整合组件 (TodoList & App)

最后,我们将这些组件组合起来。

// TodoList 组件
interface ListProps {
    todos: Todo[];
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}
// ... 遍历 todos 并渲染 TodoItem

在根组件 App 中:

export default function App() {
  // 从自定义 Hook 中获取数据和方法
  const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

  return (
    <div>
      <h1>TodoList</h1>
      {/* 这里通过 Props 传递函数。
         TS 会自动比对:addTodo 的类型是否匹配 TodoInput 要求的 onAdd 类型。
         如果不匹配(比如参数个数不对),这里就会报错。
      */}
      <TodoInput onAdd={addTodo} />
      
      <TodoList 
        todos={todos}
        onToggle={toggleTodo}
        onRemove={removeTodo}
      />
    </div>
  )
}

第四部分:总结与展望

通过这个 Todo List 项目,我们不仅学习了 TypeScript 的语法,更重要的是体会到了它带来的开发模式的变革。

TypeScript 带来的核心价值:

  1. 代码即文档

    以前你需要看半天代码逻辑或者是过时的注释才能知道 todos 数组里存的是什么。现在,只需要把鼠标悬停在 Todo 接口上,数据结构一目了然。

  2. 重构的信心

    想象一下,如果产品经理让你把 title 字段改成 content。在 JS 项目中,你需要全局搜索替换,还担心漏改或改错。在 TS 项目中,你只需要修改 interface Todo 里的定义,编译器会立刻列出所有报错的地方(所有用到 title 的组件),你逐一修正即可。这种“指哪打哪”的安全感是 JS 无法比拟的。

  3. 极致的开发体验

    IDE 的智能提示(IntelliSense)会让你爱不释手。当你输入 todo. 时,自动弹出 idtitlecompleted,这不仅提高了输入速度,更减少了记忆负担和拼写错误。

结语

学习 TypeScript 是现代前端开发的必经之路。起初,你可能会觉得编写类型定义增加了代码量,甚至觉得编译器频繁的报错很烦人。但请相信,这些前期的投入,会在项目维护阶段以减少 Bug提高可读性提升团队协作效率的形式,给你百倍的回报。

一个很实用的vue视频播放器:vue-video-player

引言

目标

实现类似el-image组件的视频查看器,支持预览和切换。但 element -ui 中没有封装对于视频的查看组件,在多方调研后,引入vue-video-player实现这一功能。

功能介绍

vue-video-player 是一个基于 Video.js 封装的 Vue 组件库,旨在为 Vue 开发者提供一套简洁、可复用的视频播放器集成方案。其本质是将 Video.js 的强大功能(如 HLS 支持、字幕加载、全屏控制等)通过 Vue 的组件化机制进行封装,从而实现声明式调用和响应式更新。

官方文档

  1. vue-video-player:github.com/surmon-chin…
  2. video.js:docs.videojs.com/docs/api/pl…

安装

版本兼容性

随着 Vue3 的发布及其 Composition API 的普及,vue-video-player 的维护团队逐步将开发重心转向 Vue3 生态。对于新版本有如下改变:

  • 6.x 及以上版本开始依赖 Vue3 的 runtime-core 和新的组件模型;
  • 不再支持Vue.use()这种全局注册方式;
  • 使用了 Vue3 特有的响应式系统(Proxy 代替 defineProperty);
  • 构建工具链升级至 Vite,导致与 Vue2 项目的 webpack 配置存在冲突风险。

版本选择策略

需求场景 建议版本 安装命令 引入方式
Vue2 项目 ^5.0.2 npm install vue-video-player@^5.0.2 Vue.use(VueVideoPlayer)
Vue3 项目 ^6.0.0 npm install vue-video-player@latest app.use(VueVideoPlayer)

错误使用案例

  1. 在安装依赖时未注意版本约束,导致运行时报错:
[Vue warn]: Unknown custom element: <video-player>
Did you register the component correctly?

2. Vue2 版本最佳实践建议:

// package.json 中显式锁定版本
"dependencies": {
  "vue-video-player": "^5.0.2",
  "video.js": "^7.10.2"
}

// main.js 中正确引入
import Vue from 'vue'
import VueVideoPlayer from 'vue-video-player'
import 'vue-video-player/node_modules/video.js/dist/video-js.css'

Vue.use(VueVideoPlayer)

使用

基本用法

  1. 属性配置

我们可以通过playerOptions配置自定义属性,关键属性包括src(视频地址)、:controls(是否显示控制栏)、:autoplay(自动播放)、:loop(循环播放)以及:volume(音量设置)等。

<template>
  <div v-if="visible" class="video-mask-wrapper" tabindex="0">
    <div class="viewer-wrapper" @click.self="handleClose">
         ……
      <div class="video-player-wrapper" :style="videoBoxStyle">
        <video-player
          :key="`${index}-${viewerData.subLink}`"
          ref="videoPlayer"
          class="video-player"
          :options="playerOptions"
        />
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    ……
  },
  computed: {
    playerOptions() {
      return {
        autoplay: true, // 自动播放
        controls: true, // 显示播放控制条
        preload: 'metadata',
        fluid: false, // 自适应容器,设为false,使用自定义css样式控制
        sources: [{ src: this.viewerData.subLink, type: 'video/mp4' }],
        controlBar: {
          volumePanel: { inline: false }, // 音量面板,inline置为false时:点击音量图标时弹出独立的垂直滑块
          playToggle: true, // 控制条的播放暂停按钮
        },
        bigPlayButton: false, // 隐藏大播放按钮
      };
    },
  },
};
</script>

<style scoped lang="scss">
.video-mask-wrapper {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 2000;
  outline: none;
  .viewer-wrapper {
    position: absolute;
    inset: 0;
    z-index: 2002;
    display: flex;
    align-items: center;
    justify-content: center;
    .video-player-wrapper {
      position: relative;
      max-width: 1000px;
      max-height: 850px;
      .video-player {
        width: 100%;
        height: 100%;
      }
    }
  }
}
::v-deep .video-js {
  width: 100% !important;
  height: 100% !important;
}
::v-deep .video-js .vjs-tech {
  width: 100% !important;
  height: 100% !important;
  object-fit: contain;
  background-color: transparent;
}
</style>

2. 方法

const player = this.$refs.videoPlayer && this.$refs.videoPlayer.player;

player.pause();
player.load();
player.src([{ src: newSrc, type: 'video/mp4' }]);
  • addTextTrack():向音频/视频添加新的文本轨道。
  • canPlayType():检测浏览器是否能播放指定的音频/视频类型。
  • load():重新加载音频/视频元素。
  • play():开始播放音频/视频。
  • pause():暂停当前播放的音频/视频。
  • ……
  1. 事件
  • waiting:当视频由于需要缓冲下一帧而停止时触发。
  • canplay:当浏览器可以开始播放音频/视频时触发。
  • error:当在音频/视频加载期间发生错误时触发。
  • loadedmetadata:当浏览器已加载音频/视频的元数据时触发。
  • ……
  1. 支持的视频格式
  • 可参考文档【测试说明】部分,cloud.tencent.com/developer/a…
  • 若要播放m3u8视频流:1、需要引入video.js并绑定到window上;2、安装依赖videojs-contrib-hls并引入;3、sources 要指定type: application/x-mpegURL

二次封装

基于用户操作习惯,我们需要对播放器进行二次封装,主要包括:

播放器动态宽高

  1. 解决什么问题
    • 播放器配置项中自带的fluid 属性,可以调整视频比例来自适应容器大小,但这会导致与原始比例严重失调,比如在网页上通常是宽〉高,但如果视频是竖屏的,这时就会压缩视频高度适应容器,视觉效果大打折扣。
    • 视频不足以撑满整个容器时,会存在黑边
  2. 解决方案:如下流程图所示,基于当前传入的视频原始尺寸、视窗宽高、设定的最大宽高,动态计算当前视频下播放器的宽高,实现在设定的最大宽高范围内:
    • 视频宽或者高大于设定最大宽高,基于比例缩放视频宽高
    • 视频宽和高都不超过设定的最大宽高,使用原始视频宽高
  3. 实现效果
    • 视频宽高和播放器宽高完全一致,避免存在黑边的现象
    • 缩放后依然保持视频原始比例,保证视觉效果
    • 通过CSS 样式调整,可以将播放器背景设置为transparent,当视频加载时,就不会一直呈现黑色背景

动态计算播放器尺寸.jpg 4. 部分代码

 computed: {
    // 将动态计算的视频宽高绑定到播放容器
    getVideoBoxStyle() {
      const w = this.boxWidth || 0;
      const h = this.boxHeight || 0;
      const style = {};

      if (w > 0 && h > 0) {
        style.width = w + 'px';
        style.height = h + 'px';
      }

      return style;
    },
  },    
methods:{
    // 基于原始尺寸和当前可用空间,计算播放器尺寸    
    handleBoxSizeResize() {
      const { w, h } = this.naturalVideo || {};
      const { LIMIT_W, LIMIT_H } = this;

      // 取视窗宽高与设置的最大宽高的最小值,作为播放器的最大宽高
      const maxW = Math.min(window.innerWidth, LIMIT_W);
      const maxH = Math.min(window.innerHeight, LIMIT_H);

      // 取宽度、高度缩放比例的最小值,保证视频完整显示
      const scale = Math.min(1, Math.min(maxW / w, maxH / h));

      this.boxWidth = Math.max(0, Math.round(w * (isFinite(scale) ? scale : 1)));
      this.boxHeight = Math.max(0, Math.round(h * (isFinite(scale) ? scale : 1)));
    },

    // 基于最大宽高,动态计算视频宽高
    getVideoContainerSize() {
      const player = this.$refs.videoPlayer.player;
      const node = player.el().querySelector('video');

      const compute = () => {
        const originVideoWidth = node.videoWidth;
        const originVideoHeight = node.videoHeight;
        this.naturalVideo = { w: originVideoWidth, h: originVideoHeight };
        this.handleBoxSizeResize();
      };

      // 若已加载元数据,直接计算;否则监听到加载后,执行compute
      if (node && node.readyState >= 1) {
        compute();
      } else if (node) {
        node.addEventListener('loadedmetadata', compute, { once: true });
      }
    },
}

多视频切换播放

  1. 解决什么问题:当前业务背景下,多个视频在弹窗内按顺序排列,如果要查看其他视频,需要退出当前视频后,再点击另一个视频查看,操作麻烦
  2. 解决方案:
    • 在视频预览页面增加左、右箭头icon,绑定click事件,基于当前视频索引,当切换上一条视频时,父组件将index-1索引的视频信息传入播放器组件,播放器重新渲染;切换下一条视频时,同理。
    • 监听keyDown事件,按下键盘左箭头、右箭头时,同上面逻辑。
    • 增加watch监听,当监听到视频数据 viewerData 更新时,重置视频预览数据,同时进行视频切源
  3. 实现效果
    • 点击左右箭头,支持上一条/下一条切换视频
    • 监听键盘事件,支持键盘左右箭头事件来切换视频
  4. 部分代码
// 重置视频状态
    handleVideoStateReset() {
      this.boxWidth = 0;
      this.boxHeight = 0;
      this.naturalVideo = { w: 0, h: 0 };
      this.isLoading = true;
      this.loadError = false;
    },
    // 视频切源
    handleVideoCutResource() {
      const player = this.$refs.videoPlayer && this.$refs.videoPlayer.player;
      const newSrc = this.viewerData && this.viewerData.subLink;

      if (player && newSrc) {
        player.pause();
        player.src([{ src: newSrc, type: 'video/mp4' }]);
        player.load();

        // 监听视频事件
        this.bindVideoEvents(player);

        player.one('loadedmetadata', () => {
          this.getVideoContainerSize();
        });
      }
    },    
    // 上一个视频
    handlePreVideoChange(index) {
      this.handleVideoSwitch(index, -1, this.dialogData.carveUrlList.length);
    },
    // 下一个视频
    handleNextVideoChange(index) {
      this.handleVideoSwitch(index, 1, this.dialogData.carveUrlList.length);
    },
    // 键盘左右箭头切换视频
    handleVideoKeyDown(event) {
      const length = this.dialogData.carveUrlList.length;
      const index = Number(this.videoSafeAreaViewer.index) || 0;

    // 视频查看器未渲染、视频列表为空、只有一个视频时,不执行切换事件
      if (!this.videoSafeAreaViewer.visible || !length || length === 1) {
        return;
      }

      if (event.key === 'ArrowRight') {
        this.handleVideoSwitch(index, 1, length);
      } else if (event.key === 'ArrowLeft') {
        this.handleVideoSwitch(index, -1, length);
      }
    },
    // 切换视频
    handleVideoSwitch(index, step, len) {
      const videoList = this.dialogData.carveUrlList || [];
      const idx = (index + step + len) % len;

      this.handleVideoPreview(videoList[idx], idx, this.dialogData.carveUrlList);
    },

视频加载提示

  1. 解决什么问题
    • 视频加载时,页面没有内容显示,用户对视频加载无感知
  2. 解决方案:在视频播放器容器中,增加提示块
    • 视频加载中,设置 isLoading: true,渲染提示块,提示内容:视频正在加载中,请稍后……
    • 视频加载失败,设置 loadingError: true,渲染提示块,提示内容:视频加载失败;增加el-icon-refresh-left图标,绑定click事件支持重新加载
    • 重新加载事件包括:1、重置预览数据,2、视频切源
    • 视频加载完成,提示块不可见,播放视频
  3. 实现效果
    • 视频加载中、加载失败提示,用户可感知视频加载进度
    • 加载失败时支持重新加载,避免因偶发网络原因导致的失败,用户无需刷新/退出就能再次尝试加载视频
  4. 部分代码
 <div class="video-player-wrapper" :style="getVideoBoxStyle">
        <!-- 视频加载提示 -->
        <div v-show="isLoading || loadingError" class="video-status-tip">
          <div class="status-content">
            <!-- 加载中 -->
            <div v-if="isLoading && !loadingError" class="loading-state">
              <i class="el-icon-loading status-icon"></i>
              <span class="status-text">视频正在加载中,请稍后...</span>
            </div>
            <!-- 加载失败 -->
            <div v-else-if="loadingError" class="error-state">
              <span class="status-text" style="color: #f56c6c"
                >视频加载失败<i class="el-icon-refresh-left" @click.stop="handleVideoReload"></i
              ></span>
            </div>
          </div>
        </div>
        <!-- 视频播放器 -->
        <video-player
          :key="`${index}-${viewerData.subLink}`"
          ref="videoPlayer"
          class="video-player"
          :options="playerOptions"
          @dblclick.native="toggleFullscreen"
          @waiting="handleVideoWaiting"
          @canplay="handleVideoCanPlay"
          @loadeddata="handleVideoLoadedData"
          @error="handleVideoError"
        />
      </div>

双击进入/退出全屏

  1. 解决什么问题:video-player组件未显示配置双击进入/退出全屏事件,需要手动绑定dbclick事件
  2. 解决方案:为视频播放器绑定dbclick事件
  3. 实现效果:非全屏状态下双击全屏播放,反之退出全屏状态
  4. 部分代码
  // 双击切换全屏
    toggleFullscreen() {
      const videoPlayer = this.$refs.videoPlayer;
      const player = videoPlayer && videoPlayer.player;

      if (!player) {
        console.warn('[MKVideoSafeAreaViewer] toggleFullscreen: player not ready');
        return;
      }

      if (player.isFullscreen()) {
        player.exitFullscreen();
      } else {
        player.requestFullscreen();
      }
    },

深度解析 React Router v6:构建企业级单页应用(SPA)的全栈式指南

在 Web 开发的演进史中,从早期的多页应用(MPA)到现代的单页应用(SPA),我们见证了前端工程师角色的巨大转变。曾几何时,前端开发被戏称为“切图仔”,路由和页面跳转的控制权完全掌握在后端手中。每一次页面的切换,都意味着浏览器需要向服务器发起一次全新的 HTTP 请求,重新下载 HTML、CSS 和 JavaScript。这种模式不仅由于网络延迟导致页面频繁出现“白屏”闪烁,更加重了服务器的渲染压力。

随着 React 等现代框架的崛起,前端路由应运而生。它将页面的跳转逻辑从后端剥离,移交至客户端处理。当路由发生改变时,浏览器不再刷新页面,而是通过 JavaScript 动态卸载旧组件、挂载新组件。这种“无刷新”的体验,让 Web 应用拥有了媲美原生桌面软件的流畅度。

本文将基于一套成熟的 React Router v6 实践方案,深入剖析如何构建一个高性能、安全且交互友好的路由系统。

第一章:路由模式的抉择与底层原理

在初始化路由系统时,我们面临的第一个架构决策就是:选择哪种路由模式?

1.1 HashRouter:传统的妥协

在早期的 SPA 开发中,HashRouter 是主流选择。它的 URL 特征非常明显,总是带着一个 # 号(例如 http://domain.com/#/user/123)。

  • 原理:它利用了浏览器 URL 中的 Hash 属性。Hash值的变化不会触发浏览器向服务器发送请求,但会触发 hashchange 事件,前端路由通过监听这个事件来切换组件。
  • 优势:即插即用。由于 # 后面的内容不被发送到服务器,因此无论如何刷新页面,服务器只接收到根路径请求,不会报 404 错误。
  • 适用场景:适合部署在 GitHub Pages 等无法配置服务器重定向规则的静态托管服务上,或者完全离线的本地文件系统应用(如 Electron 包裹的本地网页)。

1.2 BrowserRouter:现代的标准

我们在项目中采用了 BrowserRouter,并将其重命名为 Router 以保持代码的可读性。这是基于 HTML5 History API 构建的模式,它生成的 URL 干净、标准(例如 http://domain.com/user/123)。

  • 原理——一场精心的“骗局”

    所谓的 History 路由,本质上是前端与浏览器合谋的一场“欺骗”。

    1. 跳转时:当你点击链接,React Router 阻止了 <a> 标签的默认跳转行为,调用 history.pushState() 修改地址栏 URL,同时渲染新组件。浏览器认为 URL 变了,但实际上并没有发起网络请求。
    2. 后退时:当你点击浏览器后退按钮,Router 监听 popstate 事件,根据历史记录栈(Stack)中的状态,手动把旧组件渲染回来。
  • 部署的挑战

    这种模式的代价在于“刷新”。当你在 /user/123 页面按下 F5 刷新时,这场“骗局”就穿帮了。浏览器会真的拿着这个 URL 去请求服务器。如果服务器(Nginx/Apache)上只有 index.html 而没有 user/123 这个目录,服务器就会一脸茫然地返回 404 Not Found

    • 解决方案:这需要后端配合。在 Nginx 配置中,必须将所有找不到的路径重定向回 index.html,让前端接管路由渲染。

第二章:性能优化的核心——懒加载策略

随着应用规模的扩大,构建产物(Bundle)的体积会呈指数级增长。如果采用传统的 import 方式,所有页面的代码(首页、个人中心、支付页、后台管理)都会被打包进同一个 bundle.js 文件中。用户仅仅是为了看一眼首页,却被迫下载了整个应用的代码,导致首屏加载时间过长,用户体验极差。

2.1 代码分割(Code Splitting)

为了解决这个问题,我们在路由配置中全面引入了 React 的 lazy 函数。

// 静态引入(不推荐用于路由组件)
// import Product from './pages/Product';

// 动态引入(推荐)
const Product = lazy(() => import('../pages/Product'));
const UserProfile = lazy(() => import('../pages/UserProfile'));

这种写法的魔力在于,Webpack 等打包工具在识别到 import() 语法时,会自动将这部分代码分割成独立的 chunk 文件。只有当用户真正点击了“产品”或“用户资料”的链接时,浏览器才会去通过网络请求下载对应的 JS 文件。这大大减少了首屏的资源消耗。

2.2 优雅的加载过渡(Suspense & Fallback)

由于网络请求是异步的,从点击链接到组件代码下载完成之间,存在一个短暂的时间差。为了避免页面在这个空档期“开天窗”(一片空白),React 强制要求配合 Suspense 组件使用。

我们在路由配置的外层包裹了 Suspense,并提供了一个 fallback 属性:

<Suspense fallback={<LoadingFallback />}>
    <Routes>...</Routes>
</Suspense>

这里引入的 LoadingFallback 组件并非简单的文字提示,而是一个精心设计的 CSS 动画组件。

2.3 CSS 关键帧动画的艺术

为了缓解用户的等待焦虑,我们在 index.module.css 中实现一个双环旋转的加载动画。

  • 布局:使用 Flexbox 将加载器居中定位,背景设置为半透明白,遮罩住主要内容。

  • 动画原理:利用 CSS3 的 @keyframes 定义了 spin 动画,从 0 度旋转至 360 度。

    • 外层圆环:顺时针旋转,颜色为清新的蓝色(#3498db)。
    • 内层圆环:通过 animation-direction: reverse 属性实现逆时针旋转,颜色为活力的红色(#e74c3c),并调整了大小和位置。
  • 呼吸灯效果:下方的 "Loading..." 文字应用了 pulse 动画,通过透明度(opacity)在 0.6 到 1 之间循环变化,产生呼吸般的节奏感。

这种视觉上的微交互(Micro-interaction)能显著降低用户对加载时间的感知。

第三章:路由配置的立体化网络

路由不仅仅是 URL 到组件的映射,更是一个分层的立体网络。在我们的配置中,涵盖了普通路由、动态路由、嵌套路由和重定向路由等多种形态。

3.1 动态路由与参数捕获

在用户系统中,每个用户的个人主页结构相同,但数据不同。我们通过在路径中使用冒号(:)来定义参数,例如 /user/:id

在组件内部,我们不再需要解析复杂的 URL 字符串,而是通过 React Router 提供的 useParams Hook 直接获取参数对象:

const { id } = useParams();

这样,无论是访问 /user/123 还是 /user/admin,组件都能精准捕获 ID 并请求相应的数据。

3.2 嵌套路由(Nested Routes)

对于像“产品中心”这样复杂的板块,通常包含“列表”、“详情”和“新增”等子功能。我们采用了嵌套路由的设计:

<Route path='/products' element={<Product />}>
    <Route path=':productId' element={<ProductDetail />}></Route>
    <Route path='new' element={<NewProduct />}></Route>
</Route>

这种结构清晰地反映了 UI 的层级关系。父组件 Product 充当布局容器,子路由通过父组件中的 <Outlet />(虽未直接展示但在 React Router v6 中隐含)进行渲染。这使得代码结构与页面结构高度统一。

3.3 历史记录管理与重定向

在处理旧链接迁移时,我们使用了 <Navigate /> 组件。

例如,将 /old-path 重定向到 /new-path

<Route path='/old-path' element={<Navigate replace to='/new-path' />}></Route>

这里的 replace 属性至关重要。如果不加它,跳转是 push 行为,用户重定向后点击“后退”按钮,又会回到 /old-path,再次触发重定向,从而陷入死循环。加上 replace 后,新路径会替换掉历史栈中的当前记录,保证了导航历史的干净。

第四章:安全防线——高阶路由守卫

在企业级应用中,安全性是不可忽视的一环。对于“支付”、“订单管理”等敏感页面,必须确保用户已登录。我们没有在每个组件里重复写判断逻辑,而是封装了一个 ProtectRoute(路由守卫) 组件。

4.1 鉴权逻辑的封装

ProtectRoute 作为一个高阶组件(HOC),包裹在需要保护的子组件外层。

  1. 状态检查:它首先从持久化存储(如 localStorage)中读取登录标识(例如 isLogin)。

  2. 条件渲染

    • 未登录:直接返回 <Navigate to='/login' />。这会在渲染阶段立即拦截请求,并将用户“踢”回登录页。
    • 已登录:原样渲染 children(即被包裹的业务组件)。

4.2 路由层面的应用

在路由表中,我们这样使用守卫:

<Route path='/pay' element={
    <ProtectRoute>
        <Pay />
    </ProtectRoute>
}></Route>

这种声明式的写法让权限控制逻辑一目了然,且易于维护。

第五章:交互细节——导航反馈与 404 处理

一个优秀的应用不仅要功能强大,还要体贴入微。

5.1 智能导航高亮

在导航菜单中,用户需要知道自己当前处于哪个页面。我们编写了一个辅助函数 isActive,它利用 useLocation Hook 获取当前路径。

const isActive = (to) => {
    const location = useLocation();
    return location.pathname === to ? 'active' : '';
}

通过这个逻辑,当用户访问 /about 时,对应的导航链接会自动获得 active 类名,我们可以通过 CSS 为其添加高亮样式。这种即时的视觉反馈大大增强了用户的方位感。

5.2 友好的 404 页面

当用户迷路(访问了不存在的 URL)时,展示一个冷冰冰的错误页是不够的。我们配置了通配符路由 path='*' 来捕获所有未定义的路径,并渲染 NotFound 组件。

NotFound 组件中,我们不仅告知用户页面丢失,还实现了一个自动跳转机制:

useEffect(() => {
    setTimeout(() => {
        navigate('/');
    }, 6000)
}, [])

利用 useEffectsetTimeout,页面会在 6 秒后自动通过 useNavigate 导航回首页。这种设计既保留了错误提示,又无需用户手动操作,体现了产品的温度。

结语

通过 React Router v6,我们不仅仅是将几个页面简单地链接在一起。

  • 利用 History APIBrowserRouter,我们构建了符合现代 Web 标准的 URL 体系。
  • 通过 Lazy LoadingSuspense,我们兼顾了应用体积与首屏性能。
  • 借助 路由守卫Hooks,我们实现了严密的安全控制和灵活的数据交互。

这套路由架构方案,从底层的原理到上层的交互,构成了一个健壮、高效且用户体验优秀的单页应用骨架。对于任何致力于构建现代化 Web 应用的开发者来说,深入理解并掌握这些模式,是通往高级前端工程师的必经之路。

AI全栈筑基:React Router DOM 路由配置

在AI全栈项目的开发征途中,路由配置往往是前端“骨架”搭建完成的标志性节点。当我们敲下最后一行路由代码,看着项目目录从混沌走向清晰,这不仅仅是功能的实现,更是架构思维的落地。

最近在搭建一个基于 React + NestJS + AI 的全栈项目时,我对前端路由有了更深层次的思考。路由不仅仅是URL的映射,它是连接用户与功能的桥梁,更是决定应用性能与可维护性的核心。

本文将结合我在项目中的实际配置,深入探讨 React Router DOM 在企业级应用中的核心应用、易错点以及与全栈架构的协同。

🚦 1. 路由模式的选择:History 与 Hash 的博弈

在项目初始化阶段,选择合适的路由模式是至关重要的决策。

现代 React 应用普遍倾向于使用 BrowserRouter(History 模式)。它利用 HTML5 History API 提供了干净、美观的 URL 结构(如 /home),符合 RESTful 规范,对 SEO 友好。

// src/App.jsx
import { BrowserRouter as Router } from 'react-router-dom';

export default function App() {
  return (
    <Router>
      {/* 路由内容 */}
    </Router>
  );
}

💡 架构思考:
虽然 BrowserRouter 看起来很“温柔”,但它背后隐藏着锋利的一面:它要求服务器端必须配置“兜底”策略
如果你的应用部署在 Nginx 或 Node 服务上,必须确保所有非 API 请求都重定向到 index.html。否则,当用户直接访问 /user/123 时,后端会因为找不到该路径而返回 404。这标志着在前后端分离架构中,前端不再是孤立的,而是需要与后端部署策略紧密配合。

🏗️ 2. 路由形态的深度解析:从嵌套到鉴权

在构建复杂应用时,单一的路由模式显然不够用。我们需要构建一套层次分明的路由体系。

2.1 嵌套路由:保持布局一致性

在项目中,我为产品模块配置了嵌套路由。父组件 Product 负责承载公共的导航栏或侧边栏,而子组件(详情页、新增页)通过 <Outlet> 渲染在指定位置。

// src/router/index.jsx
{
  path: "/product",
  element: <Product />, // 父级布局
  children: [
    { path: ":productId", element: <ProductDetail /> }, // 子路由
    { path: "new", element: <NewProduct /> },           // 子路由
  ],
}

这种模式避免了在每个子页面中重复编写相同的布局代码,极大地提升了用户体验的连贯性。

2.2 鉴权路由:路由守卫的实现

对于支付等敏感页面,直接暴露是危险的。我在路由配置中引入了 ProtectRoute 组件。

{
  path: "/pay",
  element: (
    <ProtectRoute>
      <Pay />
    </ProtectRoute>
  ),
}

💡 核心逻辑:
ProtectRoute 本质上是一个高阶组件(HOC)。它在渲染 props.children(即 Pay 组件)之前,会先检查用户的登录状态(如检查 Token)。如果未通过校验,直接重定向到登录页;如果通过,则放行。这种将横切关注点(Cross-Cutting Concerns)剥离的方式,是企业级应用的必备手段。

⚡ 3. 性能优化:懒加载与用户体验

单页应用(SPA)的一大痛点是首屏体积过大。为了解决这个问题,我采用了路由级代码分割(Code Splitting)

3.1 React.lazy 与 Suspense

利用 Webpack 的动态导入功能,我将不同页面的代码拆分成独立的 Chunk。

const Home = React.lazy(() => import('../pages/Home'));
const About = React.lazy(() => import('../pages/About'));

// 在渲染层
<Suspense fallback={<LoadingFallback />}>
  <Routes>{/* 路由配置 */}</Routes>
</Suspense>

只有当用户访问 /about 路径时,About 组件的代码才会被动态加载。这显著减小了首包体积,提升了首屏渲染速度。

3.2 加载状态的优雅处理

React.lazy 的动态导入是异步的,网络延迟不可避免。如果直接展示白屏,用户体验极差。

因此,<Suspense fallback={<LoadingFallback />}> 的作用至关重要。LoadingFallback 组件(如骨架屏或加载动画)作为“占位符”,在组件加载完成前提供视觉反馈。这是提升用户体验的微小但关键的细节。

🚨 4. 容错与边界处理:NotFound 的自动化

对于无效的 URL,我们需要一个“守门员”。我配置了通配符路由 * 来捕获所有未匹配的请求。

// NotFound.jsx
const NotFound = () => {
  let navigate = useNavigate();
  
  useEffect(() => {
    // 6秒后自动跳回首页,防止用户迷失
    setTimeout(() => { navigate('/') }, 6000)
  }, []);

  return <> 404 Not Found </>
}

这种自动化的跳转策略,比单纯展示一个死板的 404 页面更加人性化,能有效挽留因误操作而流失的用户。

🔮 5. 结语:全栈视角下的路由未来

路由配置的完成,标志着前端骨架的搭建完毕。从 BrowserRouter 的部署考量,到 ProtectRoute 的逻辑复用,再到 React.lazy 的性能优化,每一个细节都体现了工程化的思维。

站在这个基石上,我们已经可以看到后端 NestJS 框架的轮廓,以及 AI 模型接入的无限可能。未来的路由或许不仅仅是页面的跳转,它可能结合 AI 能力,根据用户的意图动态生成内容或调整导航路径。

全栈之路,始于足下,路由为引,未来可期。

虚拟DOM:React和Vue 浅浅对比

背景 这篇着重从虚拟dom 的角度比较一下 React 和Vue的底层实现,我们可以分别体会到他们各自的特点和他们如何发挥各自特点到极致的美! 核心差异概览 一、虚拟DOM(Virtual DOM)基
❌