普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月30日首页

前端 Token 无感刷新全解析:Vue3 与 React 实现方案

2025年12月30日 13:54

在前后端分离架构中,Token 是主流的身份认证方式。但 Token 存在有效期限制,若在用户操作过程中 Token 过期,会导致请求失败,影响用户体验。「无感刷新」技术应运而生——它能在 Token 过期前或过期瞬间,自动刷新 Token 并继续完成原请求,全程对用户透明。

本文将先梳理 Token 无感刷新的核心原理,再分别基于 Vue3(Composition API + Pinia)和 React(Hooks + Axios)给出完整实现方案,同时解析常见问题与优化思路,帮助开发者快速落地。

一、核心原理:为什么需要无感刷新?怎么实现?

1. 基础概念:Access Token 与 Refresh Token

无感刷新依赖「双 Token 机制」,后端需返回两种 Token:

  • Access Token(访问 Token) :有效期短(如 2 小时),用于接口请求的身份认证,放在请求头(如 Authorization: Bearer {token});
  • Refresh Token(刷新 Token) :有效期长(如 7 天),仅用于 Access Token 过期时请求新的 Access Token,安全性要求更高(建议存储在 HttpOnly Cookie 中,避免 XSS 攻击)。

2. 无感刷新核心流程

  1. 前端发起接口请求,携带 Access Token;
  2. 拦截响应:若返回 401 状态码(Access Token 过期),则触发刷新逻辑;
  3. 用 Refresh Token 调用后端「刷新 Token 接口」,获取新的 Access Token;
  4. 更新本地存储的 Access Token;
  5. 重新发起之前失败的请求(携带新 Token);
  6. 若 Refresh Token 也过期(刷新接口返回 401),则跳转至登录页,要求用户重新登录。

关键优化点:避免重复刷新——当多个请求同时因 Token 过期失败时,需保证只发起一次 Refresh Token 请求,其他请求排队等待新 Token 生成后再重试。

二、前置准备:Axios 拦截器封装(通用基础)

无论是 Vue 还是 React,都可基于 Axios 的「请求拦截器」和「响应拦截器」实现 Token 统一处理。先封装一个基础 Axios 实例:

// utils/request.js
import axios from 'axios';

// 创建 Axios 实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量中的接口基础地址
  timeout: 5000 // 请求超时时间
});

// 1. 请求拦截器:添加 Access Token
service.interceptors.request.use(
  (config) => {
    const accessToken = localStorage.getItem('accessToken'); // 简化存储,实际建议 Vue 用 Pinia/React 用状态管理
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 2. 响应拦截器:处理 Token 过期逻辑(核心,后续框架差异化实现)
// 此处先留空,后续在 Vue/React 中补充具体逻辑
service.interceptors.response.use(
  (response) => response.data, // 直接返回响应体
  (error) => handleResponseError(error, service) // 错误处理,传入 service 用于重试请求
);

export default service;

三、Vue3 实现方案(Composition API + Pinia)

Vue3 中推荐用 Pinia 管理全局状态(存储 Token),结合 Composition API 封装刷新逻辑,保证代码复用性。

1. 步骤 1:Pinia 状态管理(存储 Token)

创建 Pinia Store 管理 Access Token 和 Refresh Token,提供刷新 Token 的方法:

// stores/authStore.js
import { defineStore } from 'pinia';
import axios from 'axios';

export const useAuthStore = defineStore('auth', {
  state: () => ({
    accessToken: localStorage.getItem('accessToken') || '',
    refreshToken: localStorage.getItem('refreshToken') || '' // 实际建议存 HttpOnly Cookie
  }),
  actions: {
    // 更新 Token
    updateTokens(newAccessToken, newRefreshToken) {
      this.accessToken = newAccessToken;
      this.refreshToken = newRefreshToken;
      localStorage.setItem('accessToken', newAccessToken);
      localStorage.setItem('refreshToken', newRefreshToken); // 仅演示,生产环境用 HttpOnly Cookie
    },
    // 刷新 Token 核心方法
    async refreshAccessToken() {
      try {
        const res = await axios.post('/api/refresh-token', {
          refreshToken: this.refreshToken
        });
        const { accessToken, refreshToken } = res.data;
        this.updateTokens(accessToken, refreshToken);
        return accessToken; // 返回新 Token,用于重试请求
      } catch (error) {
        // 刷新 Token 失败(如 Refresh Token 过期),清除状态并跳转登录
        this.clearTokens();
        window.location.href = '/login';
        return Promise.reject(error);
      }
    },
    // 清除 Token
    clearTokens() {
      this.accessToken = '';
      this.refreshToken = '';
      localStorage.removeItem('accessToken');
      localStorage.removeItem('refreshToken');
    }
  }
});

2. 步骤 2:实现响应拦截器的错误处理

完善之前的响应拦截器,添加 Token 过期处理逻辑,核心是「避免重复刷新」:

// utils/request.js(Vue3 版本补充)
import { useAuthStore } from '@/stores/authStore';

// 用于存储刷新 Token 的请求(避免重复刷新)
let refreshPromise = null;

// 响应错误处理函数
async function handleResponseError(error, service) {
  const authStore = useAuthStore();
  const originalRequest = error.config; // 原始请求配置

  // 1. 不是 401 错误,直接 reject
  if (error.response?.status !== 401) {
    return Promise.reject(error);
  }

  // 2. 是 401 错误,但已经重试过一次,避免死循环
  if (originalRequest._retry) {
    return Promise.reject(error);
  }

  try {
    // 3. 标记当前请求已重试,避免重复
    originalRequest._retry = true;

    // 4. 若没有正在进行的刷新请求,发起刷新;否则等待已有请求完成
    if (!refreshPromise) {
      refreshPromise = authStore.refreshAccessToken();
    }

    // 5. 等待刷新完成,获取新 Token
    const newAccessToken = await refreshPromise;

    // 6. 刷新完成后,重置 refreshPromise
    refreshPromise = null;

    // 7. 更新原始请求的 Authorization 头,重新发起请求
    originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
    return service(originalRequest);
  } catch (refreshError) {
    // 刷新失败,重置 refreshPromise
    refreshPromise = null;
    return Promise.reject(refreshError);
  }
}

// 响应拦截器(补充完整)
service.interceptors.response.use(
  (response) => response.data,
  (error) => handleResponseError(error, service)
);

3. 步骤 3:组件中使用

封装好后,组件中直接使用 request 发起请求即可,无需关注 Token 刷新逻辑:

// components/Example.vue
<script setup>
import request from '@/utils/request';
import { ref, onMounted } from 'vue';

const data = ref(null);

onMounted(async () => {
  try {
    // 发起请求,Token 过期时会自动无感刷新
    const res = await request.get('/api/user-info');
    data.value = res.data;
  } catch (error) {
    console.error('请求失败:', error);
  }
});
</script>

<template>
  <div>{{ data ? data.name : '加载中...' }}</div>
</template>

四、React 实现方案(Hooks + Context)

React 中推荐用「Context + Hooks」管理全局 Token 状态,结合 Axios 拦截器实现无感刷新,逻辑与 Vue3 类似,但状态管理方式不同。

1. 步骤 1:创建 Auth Context(管理 Token 状态)

用 Context 提供 Token 相关的状态和方法,供全局组件使用:

// context/AuthContext.js
import { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';

// 创建 Context
const AuthContext = createContext();

//  Provider 组件:提供 Token 状态和方法
export function AuthProvider({ children }) {
  const [accessToken, setAccessToken] = useState(localStorage.getItem('accessToken') || '');
  const [refreshToken, setRefreshToken] = useState(localStorage.getItem('refreshToken') || '');

  // 更新 Token
  const updateTokens = (newAccessToken, newRefreshToken) => {
    setAccessToken(newAccessToken);
    setRefreshToken(newRefreshToken);
    localStorage.setItem('accessToken', newAccessToken);
    localStorage.setItem('refreshToken', newRefreshToken); // 演示用,生产环境用 HttpOnly Cookie
  };

  // 刷新 Token
  const refreshAccessToken = async () => {
    try {
      const res = await axios.post('/api/refresh-token', { refreshToken });
      const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data;
      updateTokens(newAccessToken, newRefreshToken);
      return newAccessToken;
    } catch (error) {
      // 刷新失败,清除状态并跳转登录
      clearTokens();
      window.location.href = '/login';
      return Promise.reject(error);
    }
  };

  // 清除 Token
  const clearTokens = () => {
    setAccessToken('');
    setRefreshToken('');
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
  };

  // 提供给子组件的内容
  const value = {
    accessToken,
    refreshToken,
    updateTokens,
    refreshAccessToken,
    clearTokens
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// 自定义 Hook:方便组件获取 Auth 状态
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

2. 步骤 2:在入口文件中包裹 AuthProvider

确保全局组件都能访问到 Auth Context:

// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AuthProvider } from './context/AuthContext';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <AuthProvider>
    <App />
  </AuthProvider>
);

3. 步骤 3:完善 Axios 响应拦截器

逻辑与 Vue3 一致,核心是避免重复刷新,通过 useAuth Hook 获取刷新 Token 方法:

// utils/request.js(React 版本补充)
import { useAuth } from '../context/AuthContext';

// 注意:React 中不能在 Axios 拦截器中直接使用 useAuth(Hook 只能在组件/自定义 Hook 中使用)
// 解决方案:用一个函数封装,在组件初始化时调用,注入 auth 实例
export function initRequestInterceptors() {
  const { refreshAccessToken } = useAuth();
  let refreshPromise = null;

  // 响应错误处理函数
  async function handleResponseError(error, service) {
    const originalRequest = error.config;

    // 1. 非 401 错误,直接 reject
    if (error.response?.status !== 401) {
      return Promise.reject(error);
    }

    // 2. 已重试过,避免死循环
    if (originalRequest._retry) {
      return Promise.reject(error);
    }

    try {
      originalRequest._retry = true;

      // 3. 避免重复刷新
      if (!refreshPromise) {
        refreshPromise = refreshAccessToken();
      }

      // 4. 等待新 Token
      const newAccessToken = await refreshPromise;
      refreshPromise = null;

      // 5. 重试原始请求
      originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
      return service(originalRequest);
    } catch (refreshError) {
      refreshPromise = null;
      return Promise.reject(refreshError);
    }
  }

  // 重新设置响应拦截器(注入 auth 实例后)
  service.interceptors.response.use(
    (response) => response.data,
    (error) => handleResponseError(error, service)
  );
}

export default service;

4. 步骤 4:在组件中初始化拦截器并使用

在根组件(如 App.js)中初始化拦截器,确保 useAuth 能正常使用:

// App.js
import { useEffect } from 'react';
import { initRequestInterceptors } from './utils/request';
import request from './utils/request';
import { useState } from 'react';

function App() {
  const [userInfo, setUserInfo] = useState(null);

  // 初始化 Axios 拦截器(注入 Auth 上下文)
  useEffect(() => {
    initRequestInterceptors();
  }, []);

  // 发起请求(Token 过期自动刷新)
  const fetchUserInfo = async () => {
    try {
      const res = await request.get('/api/user-info');
      setUserInfo(res.data);
    } catch (error) {
      console.error('请求失败:', error);
    }
  };

  useEffect(() => {
    fetchUserInfo();
  }, []);

  return (
    <div className="App">
      {userInfo ? <h1>欢迎,{userInfo.name}</h1> : <p>加载中...</p>}
    </div>
  );
}

export default App;

五、关键优化与安全注意事项

1. 避免重复刷新的核心逻辑

用「refreshPromise」变量存储正在进行的刷新 Token 请求,当多个请求同时失败时,都等待同一个 refreshPromise 完成,避免发起多个刷新请求,这是无感刷新的核心优化点。

2. 安全优化:Refresh Token 的存储方式

  • 不建议将 Refresh Token 存储在 localStorage/sessionStorage 中,容易遭受 XSS 攻击;

  • 推荐存储在「HttpOnly Cookie」中,由浏览器自动携带,无法通过 JavaScript 访问,有效防御 XSS 攻击;

  • 若后端支持,可给 Refresh Token 增加「设备绑定」「IP 限制」等额外安全措施。

3. 主动刷新:提前预防 Token 过期

被动刷新(等待 401 后再刷新)可能存在延迟,可增加「主动刷新」逻辑:

  • 记录 Access Token 的生成时间和过期时间;
  • 在请求拦截器中判断 Token 剩余有效期(如小于 5 分钟),主动发起刷新请求;
  • 避免在用户无操作时刷新,可结合「用户活动监听」(如 click、keydown 事件)触发主动刷新。

4. 异常处理:刷新失败的兜底方案

当 Refresh Token 过期或无效时,必须跳转至登录页,并清除本地残留的 Token 状态,避免死循环请求。同时,可给用户提示「登录已过期,请重新登录」,提升体验。

六、Vue3 与 React 实现方案对比

对比维度 Vue3 实现 React 实现
状态管理 Pinia(官方推荐,API 简洁,支持 TypeScript) Context + Hooks(原生支持,无需额外依赖)
拦截器初始化 可直接在 Pinia 中获取状态,无需额外注入 需在组件中初始化拦截器,注入 Auth Context
核心逻辑 基于 Composition API,逻辑封装更灵活 基于自定义 Hooks,符合函数式编程思想
学习成本 Pinia 学习成本低,适合 Vue 生态开发者 Context + Hooks 需理解 React 状态传递机制

本质差异:状态管理方式不同,但无感刷新的核心逻辑(双 Token、拦截器、避免重复刷新)完全一致,开发者可根据自身技术栈选择对应方案。

七、总结

前端 Token 无感刷新的核心是「双 Token 机制 + Axios 拦截器」,关键在于解决「重复刷新」和「安全存储」问题。Vue3 和 React 的实现方案虽在状态管理上有差异,但核心逻辑相通:

  1. 用请求拦截器统一添加 Access Token;
  2. 用响应拦截器捕获 401 错误,触发刷新逻辑;
  3. 通过一个全局变量控制刷新请求的唯一性,避免重复请求;
  4. 刷新成功后重试原始请求,失败则跳转登录。

实际项目中,需结合后端接口设计(如刷新 Token 的接口地址、参数格式)和安全需求(如 Refresh Token 存储方式)调整实现细节。合理的无感刷新方案能大幅提升用户体验,避免因 Token 过期导致的操作中断。

Vue 与 React 数据体系深度对比

2025年12月30日 11:05

在前端框架生态中,Vue 和 React 无疑是两大主流选择。两者的核心差异不仅体现在语法风格上,更根植于数据管理的设计理念——前者追求“渐进式”与“易用性”,后者强调“函数式”与“可预测性”。本文将从数据核心设计、状态管理、数据绑定、性能优化等关键维度,结合实际代码案例,深度解析 Vue(以 Vue3 为主)与 React 的数据体系差异,帮助开发者根据项目需求做出更合适的技术选型。

一、核心设计理念:响应式 vs 单向数据流

Vue 和 React 对“数据如何驱动视图”的核心认知不同,直接决定了两者数据体系的底层逻辑。

1. Vue:响应式数据驱动(自动追踪依赖)

Vue 的核心设计之一是响应式系统。其核心思想是:当数据发生变化时,视图会自动更新,开发者无需手动处理数据与视图的同步逻辑。Vue3 采用 ES6 Proxy 实现响应式,相比 Vue2 的 Object.defineProperty,解决了数组索引监听、对象新增属性等痛点。

Vue 的响应式流程可概括为:

  • 初始化时,通过 Proxy 代理数据对象,拦截数据的读取(get)和修改(set)操作;
  • 读取数据时(如渲染视图),收集依赖(即当前使用该数据的组件/DOM);
  • 数据修改时(如赋值操作),触发依赖更新,自动重新渲染相关视图。

代码示例(Vue3 响应式数据):

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

// 基本类型响应式数据
const count = ref(0)
// 引用类型响应式数据
const user = reactive({ name: '张三', age: 20 })

// 直接修改数据,视图自动更新
const increment = () => {
  count.value++ // ref 需通过 .value 访问/修改
  user.age++    // reactive 可直接修改属性
}
</script>

<template>
  <div>计数:{{ count }}</div>
  <div>姓名:{{ user.name }}, 年龄:{{ user.age }}</div>
  <button @click="increment">增加</button>
</template>

从代码可以看出,Vue 对开发者的“侵入性”较低,数据修改逻辑直观,更接近原生 JavaScript 写法,降低了学习成本。

2. React:单向数据流(手动触发更新)

React 的核心设计是单向数据流函数式组件。其核心思想是:数据通过 props 从父组件传递到子组件,子组件不能直接修改父组件传递的数据;当数据需要更新时,必须通过“修改状态 + 重新渲染”的方式触发视图更新,全程数据流可追踪、可预测。

React 的数据更新流程可概括为:

  • 通过 useState/useReducer 定义状态(state);
  • 视图由状态和 props 计算得出(纯函数渲染);
  • 数据更新时,必须调用 setState 或 dispatch 方法(不可直接修改 state);
  • 状态更新后,组件会重新执行渲染函数,生成新的虚拟 DOM,通过 Diff 算法对比新旧虚拟 DOM,最终只更新变化的 DOM 节点。

代码示例(React 函数式组件状态):

import { useState } from 'react';

function App() {
  // 定义状态:count 和 user(不可直接修改)
  const [count, setCount] = useState(0);
  const [user, setUser] = useState({ name: '张三', age: 20 });

  const increment = () => {
    // 1. 基本类型:通过 setCount 传递新值
    setCount(count + 1);
    // 2. 引用类型:必须创建新对象(不可直接修改 user.age)
    setUser({
      ...user, // 浅拷贝原有属性
      age: user.age + 1
    });
  };

  return (
    <div>
      <div>计数:{count}</div>
      <div>姓名:{user.name}, 年龄:{user.age}</div>
      <button onClick={increment}>增加</button>
    </div>
  );
}

React 强制要求“状态不可变”(Immutability),直接修改 state 不会触发视图更新。这种设计虽然增加了一定的代码量,但保证了数据流的清晰可追踪,尤其在复杂项目中,能有效减少因数据突变导致的 Bug。

二、状态管理:内置简化 vs 生态完善

当项目规模扩大时,组件间的数据共享和状态管理成为核心需求。Vue 和 React 在状态管理上的思路差异明显:Vue 倾向于内置简化方案,React 则依赖生态插件。

1. Vue:内置 API + Pinia 轻量方案

Vue 为不同规模的项目提供了渐进式的状态管理方案:

  • 小型项目:无需额外插件,通过 provide/inject API 实现跨组件数据共享。provide 在父组件提供数据,inject 在子组件(无论层级深浅)注入数据,适用于简单的跨层级通信。
  • 中大型项目:官方推荐 Pinia(替代 Vuex)。Pinia 是 Vue 团队开发的状态管理库,设计简洁,支持 TypeScript,无需嵌套模块(Vuex 的 modules),直接通过定义“存储(Store)”管理状态,且与 Vue3 的 Composition API 无缝衔接。

Pinia 代码示例:

// stores/counter.js
import { defineStore } from 'pinia'

// 定义并导出 Store
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }), // 状态
  actions: { // 修改状态的方法(支持异步)
    increment() {
      this.count++
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  },
  getters: { // 计算属性
    doubleCount: (state) => state.count * 2
  }
})

// 组件中使用
<script setup>
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
</script>

<template>
  <div>计数:{{ counterStore.count }}</div>
  <div>双倍计数:{{ counterStore.doubleCount }}</div>
  <button @click="counterStore.increment">增加</button>
  <button @click="counterStore.incrementAsync">异步增加</button>
</template>

Pinia 的优势在于“轻量”和“易用”,去掉了 Vuex 中繁琐的概念(如 mutations),异步操作直接在 actions 中处理,符合开发者的直觉。

2. React:useContext + useReducer 基础方案 + Redux 生态

React 本身没有内置的状态管理库,而是通过“基础 API + 生态插件”的方式满足不同规模的需求:

  • 小型项目:使用 useContext + useReducer 组合实现跨组件状态管理。useContext 用于传递数据(类似 Vue 的 provide/inject),useReducer 用于管理复杂状态逻辑(类似 Vuex 的 mutations/actions)。
  • 中大型项目:使用 Redux 生态(如 Redux Toolkit、Zustand、Jotai 等)。其中,Redux Toolkit 是官方推荐的 Redux 简化方案,解决了原生 Redux 代码繁琐、模板化严重的问题;Zustand 和 Jotai 则是更轻量的替代方案,API 更简洁,学习成本更低。

useContext + useReducer 代码示例:

import { createContext, useContext, useReducer } from 'react';

// 1. 创建上下文
const CounterContext = createContext();

// 2. 定义 reducer(处理状态更新逻辑)
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'INCREMENT_ASYNC':
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
}

// 3. 父组件:提供状态和方法
function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  const increment = () => {
    dispatch({ type: 'INCREMENT' });
  };

  const incrementAsync = async () => {
    await new Promise(resolve => setTimeout(resolve, 1000));
    dispatch({ type: 'INCREMENT_ASYNC' });
  };

  return (
    <CounterContext.Provider value={{ state, increment, incrementAsync }}>
      {children}
    </CounterContext.Provider>
  );
}

// 4. 子组件:注入并使用状态
function Child() {
  const { state, increment, incrementAsync } = useContext(CounterContext);
  return (
    <div>
      <div>计数:{state.count}</div>
      <button onClick={increment}>增加</button>
      <button onClick={incrementAsync}>异步增加</button>
    </div>
  );
}

// 5. 根组件:包裹 Provider
function App() {
  return (
    <CounterProvider>
      <Child />
    </CounterProvider>
  );
}

Redux Toolkit 则进一步简化了 Redux 的使用,通过 createSlice 自动生成 actions 和 reducers,无需手动编写模板代码。React 状态管理生态的优势在于“灵活”和“成熟”,但也存在学习成本较高的问题,需要开发者根据项目复杂度选择合适的方案。

三、数据绑定:双向绑定 vs 单向绑定

数据绑定是“数据与视图同步”的具体实现方式,Vue 和 React 在此处的差异直接影响表单处理等场景的开发体验。

1. Vue:默认支持双向绑定(v-model)

Vue 提供了 v-model 指令,实现了“数据 - 视图”的双向绑定。v-model 本质是语法糖,底层通过监听输入事件(如 input、change)和设置数据值实现同步。在表单元素(输入框、复选框等)中使用时,开发者无需手动编写事件处理逻辑,极大简化了表单开发。

Vue 双向绑定代码示例:

<script setup>
import { ref } from 'vue'
const username = ref('')
const isAgree = ref(false)
</script>

<template>
  <div>
    <input v-model="username" placeholder="请输入用户名" />
    <p>用户名:{{ username }}</p>

    <input type="checkbox" v-model="isAgree" />
    <p>是否同意:{{ isAgree ? '是' : '否' }}</p>
  </div>
</template>

此外,Vue 还支持自定义组件的 v-model,通过 props 和 emits 实现父子组件间的双向数据同步,灵活性极高。

2. React:单向绑定(需手动处理事件)

React 严格遵循单向绑定原则:数据从 state 流向视图,视图中的用户操作(如输入)不会直接修改 state,而是需要通过事件处理函数调用 setState 手动更新 state,进而驱动视图重新渲染。在表单开发中,开发者需要手动编写 onChange 事件处理逻辑,将输入值同步到 state 中。

React 单向绑定代码示例:

import { useState } from 'react';

function App() {
  const [username, setUsername] = useState('');
  const [isAgree, setIsAgree] = useState(false);

  // 手动处理输入事件,同步到 state
  const handleUsernameChange = (e) => {
    setUsername(e.target.value);
  };

  const handleAgreeChange = (e) => {
    setIsAgree(e.target.checked);
  };

  return (
    <div>
      <input
        value={username}
        onChange={handleUsernameChange}
        placeholder="请输入用户名"
      />
      <p>用户名:{username}</p>

      <input
        type="checkbox"
        checked={isAgree}
        onChange={handleAgreeChange}
      />
      <p>是否同意:{isAgree ? '是' : '否'}</p>
    </div>
  );
}

React 16.8 后推出的 useForm 等库可以简化表单处理,但核心依然遵循单向绑定原则。这种设计虽然代码量稍多,但保证了数据流的清晰可追踪,避免了双向绑定中“数据来源不明确”的问题。

四、性能优化:自动优化 vs 手动优化

数据更新引发的重新渲染是影响前端性能的关键因素。Vue 和 React 在性能优化的思路上差异显著:Vue 倾向于“自动优化”,减少开发者的手动干预;React 则需要开发者通过 API 手动优化。

1. Vue:细粒度响应式 + 自动 Diff 优化

Vue 的响应式系统本身就是一种性能优化:由于响应式数据会精准追踪依赖,只有使用了该数据的组件才会在数据更新时重新渲染,实现了“细粒度更新”。此外,Vue3 在编译阶段会进行一系列优化,如:

  • 静态提升:将静态 DOM 节点(如无数据绑定的 div)提升到渲染函数外部,避免每次渲染都重新创建;
  • PatchFlags:标记动态节点的更新类型(如仅文本更新、仅 class 更新),在 Diff 时只检查标记的动态节点,减少 Diff 开销;
  • 缓存事件处理函数:避免每次渲染都创建新的函数实例,减少不必要的重新渲染。

对于复杂场景,Vue 也提供了手动优化 API,如 computed(缓存计算结果)、watch(精准监听数据变化)、shallowRef/shallowReactive(浅响应式,避免深层监听开销)等,但大多数情况下,开发者无需手动优化即可获得较好的性能。

2. React:全组件重新渲染 + 手动优化 API

React 的默认行为是:当组件的 state 或 props 发生变化时,组件会重新渲染,并且会递归重新渲染所有子组件。这种“全组件重新渲染”在复杂项目中可能导致性能问题,因此 React 提供了一系列手动优化 API:

  • React.memo:缓存组件,只有当 props 发生浅变化时才重新渲染;
  • useMemo:缓存计算结果,避免每次渲染都重新计算;
  • useCallback:缓存事件处理函数,避免因函数实例变化导致子组件不必要的重新渲染;
  • useMemoizedFn(第三方库,如 ahooks):进一步优化函数缓存,支持深层依赖对比。

React 手动优化代码示例:

import { useState, useCallback, memo } from 'react';

// 子组件:使用 React.memo 缓存
const Child = memo(({ count, onIncrement }) => {
  console.log('子组件重新渲染');
  return (
    <button onClick={onIncrement}>
      子组件:增加计数(当前:{count})
    </button>
  );
});

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('张三');

  // 使用 useCallback 缓存事件处理函数
  const handleIncrement = useCallback(() => {
    setCount(count + 1);
  }, [count]); // 依赖 count,只有 count 变化时才重新创建函数

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="修改姓名"
      />
      <Child count={count} onIncrement={handleIncrement} />
    </div>
  );
}

在上述示例中,若不使用 React.memo 和 useCallback,修改 name 时,Child 组件也会重新渲染(因为父组件重新渲染会创建新的 onIncrement 函数实例);使用优化 API 后,只有 count 变化时,Child 组件才会重新渲染。React 的优化思路要求开发者对“重新渲染”有清晰的认知,学习成本较高,但也赋予了开发者更精细的性能控制能力。

五、总结:差异对比与选型建议

通过以上维度的对比,我们可以清晰地看到 Vue 和 React 数据体系的核心差异,下表对关键特性进行了汇总:

对比维度 Vue React
核心设计理念 响应式数据驱动,自动同步视图 单向数据流,函数式组件,可预测性优先
状态管理 内置 provide/inject,官方推荐 Pinia(轻量易用) 基础 useContext + useReducer,生态丰富(Redux Toolkit、Zustand 等)
数据绑定 默认支持双向绑定(v-model),表单开发简洁 单向绑定,需手动处理事件同步数据
性能优化 细粒度响应式 + 编译时自动优化,手动优化需求少 默认全组件重新渲染,需手动使用 memo/useMemo 等 API 优化
学习成本 较低,API 直观,接近原生 JavaScript,渐进式学习 较高,需理解函数式编程、不可变数据、重新渲染等概念

选型建议:

  1. 小型项目/快速迭代项目:优先选择 Vue。其响应式系统和双向绑定能大幅提升开发效率,学习成本低,团队上手快。
  2. 中大型项目/复杂状态管理项目:两者均可。若团队熟悉函数式编程,追求数据流可预测性,可选择 React + Redux Toolkit/Zustand;若团队更注重开发效率,希望减少手动优化工作,可选择 Vue3 + Pinia。
  3. 跨端项目:React 生态的 React Native 成熟度更高,适合需开发原生 App 的项目;Vue 生态的 Uni-app、Weex 更适合多端(小程序、H5、App)快速开发。
  4. 团队技术栈:若团队已有 JavaScript 基础,Vue 上手更平滑;若团队熟悉 TypeScript 和函数式编程,React 更易融入。
昨天以前首页

Vue3 中的 <keep-alive> 详解

2025年12月26日 09:56

<keep-alive> 是 Vue3 内置的抽象组件(自身不会渲染为真实 DOM 元素),核心作用是缓存包裹在其中的组件实例,保留组件的状态和 DOM 结构,避免组件反复创建和销毁带来的性能损耗,常用于需要保留状态的场景(如标签页切换、列表页返回详情页等)。

一、核心特性与作用

1. 核心功能

  • 缓存组件状态:被 <keep-alive> 包裹的组件,在切换隐藏时不会触发 unmounted(销毁),而是被缓存起来;再次显示时不会触发 mounted(重新创建),而是恢复之前的状态。
  • 优化性能:避免组件反复创建 / 销毁、数据重新请求、DOM 重新渲染,减少资源消耗。
  • 保留组件上下文:比如表单输入内容、滚动条位置、组件内部的状态数据等,切换后仍能保持原有状态。

2. 关键特点

  • 是抽象组件,不生成 DOM 节点,也不会出现在组件的父组件链中;
  • 仅对动态组件<component :is="componentName">)或路由组件生效;
  • 可通过属性配置缓存规则(指定缓存 / 排除缓存的组件)。

二、基本使用方式

1. 基础用法:包裹动态组件

用于切换多个组件时,缓存不活跃的组件状态:

<template>
  <div>
    <!-- 切换按钮 -->
    <button @click="currentComponent = 'ComponentA'">组件A</button>
    <button @click="currentComponent = 'ComponentB'">组件B</button>

    <!-- keep-alive 包裹动态组件,缓存组件实例 -->
    <keep-alive>
      <component :is="currentComponent"></component>
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';

// 控制当前显示的组件
const currentComponent = ref('ComponentA');
</script>

此时切换组件 A/B,组件不会被销毁,再次切换回来时会保留之前的状态(如 ComponentA 中的输入框内容)。

2. 常用场景:包裹路由组件

在路由切换时缓存页面状态(如列表页滚动位置、筛选条件),是项目中最常用的场景:

<!-- App.vue 或路由出口组件 -->
<template>
  <router-view v-slot="{ Component }">
    <!-- 缓存路由组件 -->
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

三、核心属性:配置缓存规则

<keep-alive> 提供 3 个核心属性,用于灵活控制缓存的组件范围:

1. include:指定需要缓存的组件

  • 类型:String | RegExp | Array

  • 作用:只有名称匹配的组件才会被缓存(组件名称通过 name 选项定义,Vue3 单文件组件中 <script> 内的 name 或 <script setup> 配合 defineOptions({ name: 'xxx' }) 定义)。

  • 示例:

    <!-- 字符串(逗号分隔多个组件名) -->
    <keep-alive include="ComponentA,ComponentB">
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 正则表达式(需用 v-bind 绑定) -->
    <keep-alive :include="/^Component/">
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 数组(需用 v-bind 绑定) -->
    <keep-alive :include="['ComponentA', 'ComponentB']">
      <component :is="currentComponent"></component>
    </keep-alive>
    

2. exclude:指定不需要缓存的组件

  • 类型:String | RegExp | Array

  • 作用:名称匹配的组件不会被缓存,优先级高于 include

  • 示例:

    <keep-alive exclude="ComponentC">
      <component :is="currentComponent"></component>
    </keep-alive>
    

3. max:设置缓存组件的最大数量

  • 类型:Number

  • 作用:限制缓存的组件实例数量,当缓存实例超过 max 时,会按照「LRU(最近最少使用)」策略,销毁最久未使用的组件缓存。

  • 示例:

    <!-- 最多缓存 3 个组件实例 -->
    <keep-alive :max="3">
      <component :is="currentComponent"></component>
    </keep-alive>
    

四、缓存组件的生命周期钩子

被 <keep-alive> 缓存的组件,不会触发 mounted/unmounted,而是触发专属的生命周期钩子:

1. onActivated:组件被激活时触发

  • 时机:缓存的组件从隐藏状态切换为显示状态时(第一次渲染时,会在 mounted 之后触发;后续激活时,仅触发 onActivated)。
  • 用途:恢复组件激活后的状态(如重新监听事件、刷新数据等)。

2. onDeactivated:组件被失活时触发

  • 时机:缓存的组件从显示状态切换为隐藏状态时(不会触发 unmounted)。
  • 用途:清理组件失活后的资源(如取消事件监听、清除定时器等)。

示例:组件内使用钩子

<!-- ComponentA.vue -->
<template>
  <div>组件A:<input type="text" v-model="inputValue"></div>
</template>

<script setup>
import { ref, onActivated, onDeactivated, onMounted } from 'vue';

const inputValue = ref('');

// 第一次渲染时触发(后续激活不触发)
onMounted(() => {
  console.log('组件A 首次挂载');
});

// 组件被激活时触发(切换显示时)
onActivated(() => {
  console.log('组件A 被激活');
  // 可在此恢复滚动条位置、重新请求最新数据等
});

// 组件被失活时触发(切换隐藏时)
onDeactivated(() => {
  console.log('组件A 被失活');
  // 可在此取消定时器、取消事件监听等
});
</script>

五、高级用法:结合路由配置缓存

在实际项目中,常需要针对特定路由进行缓存,可通过「路由元信息(meta)」配合 <keep-alive> 实现精准缓存:

1. 配置路由元信息

在 router/index.js 中,给需要缓存的路由添加 meta.keepAlive: true

// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import ListPage from '../views/ListPage.vue';
import DetailPage from '../views/DetailPage.vue';
import HomePage from '../views/HomePage.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: HomePage,
    meta: { keepAlive: false } // 不缓存
  },
  {
    path: '/list',
    name: 'List',
    component: ListPage,
    meta: { keepAlive: true } // 需要缓存
  },
  {
    path: '/detail/:id',
    name: 'Detail',
    component: DetailPage,
    meta: { keepAlive: false } // 不缓存
  }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

2. 根据路由元信息缓存

在路由出口处,通过 v-if 判断路由的 meta.keepAlive 属性,决定是否缓存:

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 缓存需要保留状态的路由组件 -->
    <keep-alive>
      <component
        :is="Component"
        v-if="route.meta.keepAlive"
      />
    </keep-alive>
    <!-- 不缓存的组件直接渲染 -->
    <component
      :is="Component"
      v-if="!route.meta.keepAlive"
    />
  </router-view>
</template>

六、注意事项与常见问题

1. 注意事项

  • <keep-alive> 仅对动态组件或路由组件生效,对普通组件(直接渲染的组件)无效;
  • 组件名称必须正确定义:<script setup> 中需通过 defineOptions({ name: 'XXX' }) 定义组件名,否则 include/exclude 无法匹配;
  • 缓存的组件会占用内存,若缓存过多组件,可能导致内存泄漏,建议通过 max 属性限制缓存数量;
  • 对于需要实时刷新数据的组件,避免使用 <keep-alive>,或在 onActivated 钩子中手动刷新数据。

2. 常见问题

  • 问题 1:缓存后组件数据不更新?解决方案:在 onActivated 钩子中重新请求数据或更新组件状态,确保激活时获取最新数据。

  • 问题 2include/exclude 配置不生效?解决方案:检查组件名称是否正确定义,正则 / 数组形式是否通过 v-bind 绑定,避免直接写字面量。

  • 问题 3:路由切换后滚动条位置未保留?解决方案:在 onDeactivated 中记录滚动条位置,在 onActivated 中恢复滚动条位置:

    // ListPage.vue
    import { ref, onActivated, onDeactivated } from 'vue';
    
    // 记录滚动条位置
    const scrollTop = ref(0);
    
    onDeactivated(() => {
      // 失活时记录滚动位置
      scrollTop.value = document.documentElement.scrollTop || document.body.scrollTop;
    });
    
    onActivated(() => {
      // 激活时恢复滚动位置
      document.documentElement.scrollTop = scrollTop.value;
      document.body.scrollTop = scrollTop.value;
    });
    

总结

  1. <keep-alive> 是 Vue3 内置抽象组件,核心作用是缓存组件实例、保留组件状态、优化性能;
  2. 基础用法:包裹动态组件或路由组件,通过 include/exclude/max 配置缓存规则;
  3. 生命周期:缓存组件触发 onActivated(激活)和 onDeactivated(失活),替代 mounted/unmounted
  4. 高级用法:结合路由元信息 meta.keepAlive,实现特定路由的精准缓存;
  5. 注意:合理控制缓存数量,避免内存泄漏,需要实时刷新数据的场景在 onActivated 中手动更新。

JavaScript 中的深拷贝与浅拷贝详解

2025年12月26日 09:16

深拷贝和浅拷贝是 JavaScript 中处理引用类型数据(对象、数组等)的核心概念,二者的本质区别在于是否复制引用类型的深层嵌套数据,直接影响数据操作的独立性,是开发中避免数据污染的关键。

一、先明确:为什么需要拷贝?(引用类型的特性)

JavaScript 数据类型分为两类,拷贝行为仅对引用类型有区分(原始类型为值传递,不存在深浅拷贝):

数据类型类别 包含类型 拷贝特性
原始类型 String、Number、Boolean、Null、Undefined、Symbol、BigInt 赋值 / 拷贝时传递「值本身」,修改新值不会影响原值
引用类型 Object(普通对象、数组、函数、正则等) 赋值 / 浅拷贝时传递「内存地址(引用)」,修改新数据会影响原数据;深拷贝才会复制数据本身,实现完全独立

示例:引用类型的默认赋值(引用传递,非拷贝)

// 引用类型:数组
const arr1 = [1, 2, { name: "张三" }];
const arr2 = arr1; // 仅传递引用,不是拷贝
arr2[0] = 100;
arr2[2].name = "李四";
console.log(arr1); // [100, 2, { name: "李四" }](原值被修改)
console.log(arr2); // [100, 2, { name: "李四" }]

二、浅拷贝(Shallow Copy):仅复制表层数据

1. 核心定义

浅拷贝是指只复制引用类型的表层属性(第一层数据) ,对于深层嵌套的引用类型(如对象中的对象、数组中的数组),仅复制其内存地址(引用),新旧数据的深层嵌套部分会共享同一块内存,修改其中一个的深层数据会影响另一个。

2. 常见实现方式

(1)数组浅拷贝

  • Array.prototype.slice()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.slice(); // 浅拷贝数组
    // 修改表层数据:不影响原值
    arr2[0] = 100;
    console.log(arr1[0]); // 1
    console.log(arr2[0]); // 100
    // 修改深层引用类型:影响原值
    arr2[2].age = 30;
    console.log(arr1[2].age); // 30(原值被修改)
    console.log(arr2[2].age); // 30
    
  • Array.prototype.concat()

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = arr1.concat(); // 浅拷贝
    
  • 扩展运算符 [...arr]

    const arr1 = [1, 2, { age: 25 }];
    const arr2 = [...arr1]; // 浅拷贝
    

(2)对象浅拷贝

  • Object.assign(target, ...sources)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = Object.assign({}, obj1); // 浅拷贝到空对象
    // 修改表层数据:不影响原值
    obj2.name = "李四";
    console.log(obj1.name); // 张三
    console.log(obj2.name); // 李四
    // 修改深层引用类型:影响原值
    obj2.info.age = 30;
    console.log(obj1.info.age); // 30(原值被修改)
    console.log(obj2.info.age); // 30
    
  • 扩展运算符 {...obj}

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = { ...obj1 }; // 浅拷贝
    

3. 浅拷贝的特点

  • 优点:实现简单、性能开销小,适合仅包含表层数据的引用类型;
  • 缺点:无法独立深层嵌套数据,修改深层数据会造成原数据污染;
  • 适用场景:只需复制表层数据,无需修改深层嵌套内容的场景(如展示数据副本、临时修改表层属性)。

三、深拷贝(Deep Copy):复制所有层级数据

1. 核心定义

深拷贝是指递归复制引用类型的所有层级数据,不仅复制表层属性,还会对深层嵌套的每个引用类型都创建独立的副本,新旧数据完全隔离,修改其中一个不会影响另一个,实现真正意义上的 “复制”。

2. 常见实现方式

(1)JSON 序列化 / 反序列化(简单场景首选)

通过 JSON.stringify() 将对象转为 JSON 字符串,再通过 JSON.parse() 解析为新对象,实现深拷贝。

const obj1 = { name: "张三", info: { age: 25 }, hobbies: ["篮球", "游戏"] };
const obj2 = JSON.parse(JSON.stringify(obj1)); // 深拷贝

// 修改表层数据:不影响原值
obj2.name = "李四";
// 修改深层数据:不影响原值
obj2.info.age = 30;
obj2.hobbies[0] = "足球";

console.log(obj1.name); // 张三
console.log(obj1.info.age); // 25
console.log(obj1.hobbies[0]); // 篮球
console.log(obj2.name); // 李四
console.log(obj2.info.age); // 30
console.log(obj2.hobbies[0]); // 足球

注意:JSON 方式的局限性(无法处理特殊类型)

  • 无法拷贝函数、正则表达式、Date 对象(会转为字符串 / 对象字面量,丢失原有特性);
  • 无法拷贝 Symbol 类型属性、undefined 类型属性(会被忽略);
  • 无法处理循环引用(如 obj.a = obj,会报错)。

(2)手动递归实现(灵活可控,支持特殊类型)

通过递归遍历对象 / 数组的每一层,对原始类型直接赋值,对引用类型创建新副本,可自定义处理特殊类型。

// 深拷贝工具函数
function deepClone(target) {
  // 1. 处理原始类型和 null
  if (typeof target !== "object" || target === null) {
    return target;
  }

  // 2. 处理 Date 对象
  if (target instanceof Date) {
    return new Date(target);
  }

  // 3. 处理 RegExp 对象
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // 4. 处理数组和普通对象(创建新副本)
  const result = Array.isArray(target) ? [] : {};

  // 5. 递归遍历,拷贝所有层级属性
  for (let key in target) {
    // 仅拷贝自身属性,不拷贝原型链属性
    if (target.hasOwnProperty(key)) {
      result[key] = deepClone(target[key]);
    }
  }

  return result;
}

// 测试
const obj1 = {
  name: "张三",
  info: { age: 25 },
  hobbies: ["篮球", "游戏"],
  birth: new Date("1999-01-01"),
  reg: /abc/gi,
  fn: () => console.log("hello")
};
const obj2 = deepClone(obj1);

obj2.info.age = 30;
obj2.birth.setFullYear(2000);
obj2.fn = () => console.log("world");

console.log(obj1.info.age); // 25(不影响原值)
console.log(obj1.birth.getFullYear()); // 1999(不影响原值)
console.log(obj1.fn()); // hello(函数独立)
console.log(obj2.fn()); // world

(3)第三方库(成熟稳定,推荐生产环境)

  • Lodash 库的 _.cloneDeep()(支持所有类型,处理循环引用)

    // 安装:npm i lodash
    const _ = require("lodash");
    
    const obj1 = { name: "张三", info: { age: 25 }, a: obj1 }; // 循环引用
    const obj2 = _.cloneDeep(obj1); // 深拷贝,正常处理循环引用
    
    obj2.info.age = 30;
    console.log(obj1.info.age); // 25
    
  • jQuery 库的 $.extend(true, {}, obj)(true 表示深拷贝)

    const obj1 = { name: "张三", info: { age: 25 } };
    const obj2 = $.extend(true, {}, obj1); // 深拷贝
    

3. 深拷贝的特点

  • 优点:新旧数据完全独立,修改任意一方不会影响另一方,避免数据污染;
  • 缺点:实现复杂(手动递归需处理多种特殊类型)、性能开销大(递归遍历所有层级);
  • 适用场景:需要修改拷贝后的数据,且数据包含深层嵌套引用类型的场景(如表单提交、状态管理、复杂数据处理)。

四、深拷贝 vs 浅拷贝 核心对比

对比维度 浅拷贝(Shallow Copy) 深拷贝(Deep Copy)
拷贝层级 仅拷贝表层(第一层)数据 递归拷贝所有层级数据
引用类型处理 深层嵌套引用类型仅复制内存地址(共享) 深层嵌套引用类型创建独立副本(不共享)
数据独立性 深层数据共享,修改会相互影响 完全独立,修改互不影响
实现难度 简单(原生 API 即可实现) 复杂(需处理特殊类型、循环引用)
性能开销 小(仅遍历表层) 大(递归遍历所有层级)
适用场景 表层数据拷贝、无需修改深层数据 复杂嵌套数据拷贝、需要独立修改数据
常见实现 数组:slice、concat、[...arr];对象:Object.assign、{...obj} JSON.parse (JSON.stringify ())、手动递归、_.cloneDeep ()

五、常见误区

  1. 认为 Object.assign 是深拷贝Object.assign 仅对第一层数据实现值拷贝,深层引用类型仍为引用传递,属于浅拷贝;
  2. JSON 方式能处理所有数据:JSON 序列化无法处理函数、正则、循环引用、Symbol 等类型,仅适用于简单 JSON 数据;
  3. 原始类型需要深浅拷贝:原始类型赋值时直接传递值,不存在引用,无需区分深浅拷贝;
  4. 深拷贝一定优于浅拷贝:深拷贝性能开销大,若数据无深层嵌套,浅拷贝更高效,无需过度使用深拷贝。

总结

  1. 核心区别:是否拷贝深层嵌套的引用类型,决定数据是否独立;
  2. 原始类型无深浅拷贝之分,引用类型才需要区分;
  3. 浅拷贝:简单高效,适合表层数据,推荐 [...arr]/{...obj}/Object.assign
  4. 深拷贝:完全独立,适合复杂嵌套数据,简单场景用 JSON.parse(JSON.stringify()),生产环境推荐 _.cloneDeep()
  5. 选型原则:根据数据结构选择,无需深层独立时优先浅拷贝,避免性能浪费。

JS原型链详解

2025年12月24日 13:52

原型链是 JavaScript 实现继承的核心机制,本质是一条「实例与原型之间的引用链条」,用于解决属性和方法的查找、共享与继承问题,理解原型链是掌握 JavaScript 面向对象编程的关键。

一、先搞懂 3 个核心概念(原型链的基础)

在讲原型链之前,必须先明确 prototype__proto__constructor 这三个不可分割的概念,它们是构成原型链的基本单元。

1. prototype(原型属性 / 显式原型)

  • 定义:只有函数(构造函数)才拥有 prototype 属性,它指向一个对象(称为「原型对象」),这个对象的作用是存放所有实例需要共享的属性和方法

  • 通俗理解:构造函数的「原型仓库」,所有通过该构造函数创建的实例,都能共享这个仓库里的内容,避免方法重复创建浪费内存。

  • 示例

    // 构造函数
    function Person(name) {
      this.name = name; // 实例私有属性
    }
    // prototype 指向原型对象,存放共享方法
    Person.prototype.sayName = function() {
      console.log('我的名字:', this.name);
    };
    
    console.log(Person.prototype); // { sayName: ƒ, constructor: ƒ Person() }
    

2. __proto__(原型链指针 / 隐式原型)

  • 定义:几乎所有对象(除 null/undefined都拥有 __proto__ 属性(ES6 规范中称为 [[Prototype]]__proto__ 是浏览器提供的访问接口),它指向创建该对象的构造函数的原型对象(prototype

  • 通俗理解:对象的「原型导航器」,通过它可以找到自己的 “原型仓库”,进而向上查找属性 / 方法。

  • 示例

    const person1 = new Person('张三');
    // person1 的 __proto__ 指向 Person.prototype
    console.log(person1.__proto__ === Person.prototype); // true
    console.log(person1.__proto__.sayName === Person.prototype.sayName); // true
    

3. constructor(构造函数指向)

  • 定义:原型对象(prototype)中默认包含 constructor 属性,它指向对应的构造函数本身,用于标识对象的创建来源。

  • 作用:修复原型指向后,保证实例能正确追溯到构造函数(避免继承时构造函数指向混乱)。

  • 示例

    // 原型对象的 constructor 指向构造函数
    console.log(Person.prototype.constructor === Person); // true
    // 实例可通过 __proto__ 找到 constructor
    console.log(person1.__proto__.constructor === Person); // true
    console.log(person1.constructor === Person); // true(自动向上查找)
    

二、原型链的核心定义与形成过程

1. 核心定义

原型链是由 __proto__ 串联起来的「对象 → 原型对象 → 上层原型对象 → ... → null」的链式结构,当访问一个对象的属性 / 方法时,JavaScript 会先在对象自身查找,找不到则通过 __proto__ 向上查找原型对象,依次类推,直到找到属性 / 方法或到达原型链末端(null)。

2. 原型链的形成过程(三步成型)

我们以 Person 实例为例,拆解原型链的形成:

  1. 第一步:创建构造函数 Person,其 prototype 指向 Person 原型对象(包含 sayName 方法和 constructor);
  2. 第二步:通过 new Person() 创建实例 person1person1.__proto__ 指向 Person.prototype(形成第一层链接);
  3. 第三步Person.prototype 是一个普通对象,它的 __proto__ 指向 Object.prototype(JavaScript 所有对象的根原型),Object.prototype.__proto__ 指向 null(原型链末端)。

最终形成的原型链:

plaintext

person1(实例)
  ↓ __proto__
Person.prototypePerson 原型对象)
  ↓ __proto__
Object.prototype(根原型对象)
  ↓ __proto__
null(原型链末端)

可视化示例

// 验证原型链结构
const person1 = new Person('张三');

// 第一层:person1 -> Person.prototype
console.log(person1.__proto__ === Person.prototype); // true
// 第二层:Person.prototype -> Object.prototype
console.log(Person.prototype.__proto__ === Object.prototype); // true
// 第三层:Object.prototype -> null
console.log(Object.prototype.__proto__ === null); // true
// 完整原型链:person1 -> Person.prototype -> Object.prototype -> null

三、原型链的核心作用:属性 / 方法查找机制

这是原型链最核心的功能,遵循「自身优先,向上追溯,末端终止」的规则:

1. 查找规则步骤

  1. 当访问对象的某个属性 / 方法时,先在对象自身的属性中查找(比如 person1.name,直接在 person1 上找到);
  2. 如果自身没有,就通过 __proto__ 向上查找原型对象(比如 person1.sayName(),自身没有,找到 Person.prototype 上的 sayName);
  3. 如果原型对象也没有,继续通过原型对象的 __proto__ 向上查找上层原型(比如 person1.toString()Person.prototype 没有,找到 Object.prototype 上的 toString);
  4. 直到找到目标属性 / 方法,或到达原型链末端 null,此时返回 undefined(属性)或报错(方法)。

2. 代码示例

const person1 = new Person('张三');

// 1. 查找自身属性:name
console.log(person1.name); // 张三(自身存在,直接返回)

// 2. 查找原型方法:sayName
console.log(person1.sayName()); // 我的名字:张三(自身没有,向上找到 Person.prototype)

// 3. 查找上层原型方法:toString
console.log(person1.toString()); // [object Object](Person.prototype 没有,向上找到 Object.prototype)

// 4. 查找不存在的属性:age
console.log(person1.age); // undefined(原型链末端仍未找到,返回 undefined)

3. 注意:属性修改仅影响自身,不影响原型

原型链是「只读」的查找链路,修改对象的属性时,只会修改对象自身,不会改变原型对象的属性(除非直接显式修改原型):

// 错误:试图修改原型方法(实际是给 person1 新增了一个私有方法 sayName,覆盖了原型查找)
person1.sayName = function() {
  console.log('我是私有方法:', this.name);
};
person1.sayName(); // 我是私有方法:张三(优先访问自身方法)
console.log(Person.prototype.sayName()); // 我的名字:undefined(原型方法未被修改)

四、原型链与继承的关系

原型链是 JavaScript 继承的底层支撑,所有继承方式(原型链继承、组合继承等)本质都是通过修改 __proto__ 或 prototype,构建新的原型链结构,实现子类对父类属性 / 方法的继承。

示例:简单继承的原型链结构

// 父类构造函数
function Animal(name) {
  this.name = name;
}
Animal.prototype.sayName = function() {
  console.log('名称:', this.name);
};

// 子类构造函数
function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
// 构建继承:让 Dog.prototype.__proto__ 指向 Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

// 子类实例的原型链
const dog1 = new Dog('旺财', '中华田园犬');
// 原型链:dog1 -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
console.log(dog1.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
// 继承生效:dog1 能访问 Animal.prototype 的 sayName 方法
dog1.sayName(); // 名称:旺财

五、原型链的末端:Object.prototype 与 null

  1. Object.prototype:是 JavaScript 所有对象的「根原型」,所有对象最终都会继承它的属性和方法(如 toString()hasOwnProperty()valueOf() 等);

  2. null:是原型链的「终点」,Object.prototype.__proto__ 指向 null,表示没有上层原型,查找过程到此终止;

  3. 验证

    console.log(Object.prototype.__proto__); // null
    console.log(Object.prototype.hasOwnProperty('toString')); // true(根原型的自有方法)
    console.log(person1.hasOwnProperty('name')); // true(自身属性)
    console.log(person1.hasOwnProperty('sayName')); // false(原型上的方法,非自身属性)
    

六、常见误区

  1. 混淆 prototype 和 __proto__prototype 是函数的属性,__proto__ 是对象的属性,两者的关联是「对象.proto = 构造函数.prototype」;
  2. 原型链是可写的__proto__ 可以手动修改(不推荐,会破坏原有继承结构,影响性能);
  3. 所有对象都有 prototype:只有函数才有 prototype,普通对象只有 __proto__
  4. hasOwnProperty 能查找原型属性hasOwnProperty 仅判断对象自身是否有该属性,不会向上查找原型链。

总结

  1. 原型链的核心是 __proto__ 串联的链式结构,末端是 null,根节点是 Object.prototype
  2. 3 个核心概念:prototype(函数的原型仓库)、__proto__(对象的原型指针)、constructor(原型的构造函数指向);
  3. 核心功能:实现属性 / 方法的分层查找(自身 → 原型 → 上层原型 → ... → null),支撑 JavaScript 继承机制;
  4. 本质:通过共享原型对象的属性 / 方法,实现代码复用,减少内存消耗。

JS继承方式详解

2025年12月24日 13:47

JavaScript 继承基于原型链实现,不存在类继承的原生语法(ES6 class 是语法糖,底层仍为原型继承),常见继承方式按演进逻辑可分为以下 6 种,各有优劣与适用场景:

一、原型链继承(最基础的继承方式)

核心原理

将父类的实例作为子类的原型(SubType.prototype = new SuperType()),子类实例通过原型链向上查找父类的属性和方法,实现继承。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name; // 实例属性
  this.colors = ['black', 'white']; // 引用类型实例属性
}
Animal.prototype.sayName = function() { // 原型方法
  console.log('动物名称:', this.name);
};

// 子类
function Dog() {}
// 核心:将父类实例赋值给子类原型
Dog.prototype = new Animal('小狗');
Dog.prototype.constructor = Dog; // 修复构造函数指向

// 测试
const dog1 = new Dog();
const dog2 = new Dog();
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white', 'brown'](引用类型属性被共享)
dog1.sayName(); // 动物名称:小狗

优点与缺点

  • 优点:实现简单,子类可继承父类原型上的所有方法;

  • 缺点

    1. 父类的引用类型实例属性会被所有子类实例共享(一个实例修改会影响其他实例);
    2. 无法向父类构造函数传递参数(子类实例创建时,无法自定义父类实例属性)。

二、构造函数继承(借用父类构造函数)

核心原理

在子类构造函数中,通过 call()/apply() 调用父类构造函数,将父类的实例属性绑定到子类实例上,实现实例属性的继承。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
  this.sayName = function() {
    console.log('动物名称:', this.name);
  };
}

// 子类
function Dog(name, breed) {
  // 核心:借用父类构造函数,传递参数
  Animal.call(this, name);
  this.breed = breed; // 子类自有属性
}

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型属性不共享)
dog1.sayName(); // 动物名称:旺财
console.log(dog1.breed); // 中华田园犬

优点与缺点

  • 优点

    1. 解决了原型链继承中引用类型属性共享的问题;
    2. 可以向父类构造函数传递参数;
  • 缺点

    1. 只能继承父类的实例属性和方法,无法继承父类原型上的方法(每个子类实例都会复制一份父类方法,浪费内存);
    2. 子类实例无法共享父类方法,违背原型链的设计初衷。

三、组合继承(原型链 + 构造函数,最常用)

核心原理

结合原型链继承和构造函数继承的优点:

  1. 原型链继承继承父类原型上的方法(实现方法共享);
  2. 构造函数继承继承父类的实例属性(避免引用类型共享,支持传参)。

代码示例

javascript

运行

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}
Animal.prototype.sayName = function() {
  console.log('动物名称:', this.name);
};

// 子类
function Dog(name, breed) {
  // 构造函数继承:继承实例属性,传参
  Animal.call(this, name);
  this.breed = breed;
}
// 原型链继承:继承原型方法,实现方法共享
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog; // 修复构造函数指向
// 子类原型方法
Dog.prototype.sayBreed = function() {
  console.log('犬种:', this.breed);
};

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 动物名称:旺财(继承父类原型方法)
dog1.sayBreed(); // 犬种:中华田园犬(子类自有方法)
console.log(dog1 instanceof Animal); // true( instanceof 检测正常)

优点与缺点

  • 优点

    1. 兼顾了原型链继承和构造函数继承的优点,既实现了方法共享,又避免了引用类型属性共享;
    2. 支持向父类传参,instanceof 检测正常;
  • 缺点:父类构造函数被调用了两次(一次是创建子类原型时 new Animal(),一次是子类构造函数中 Animal.call(this)),导致子类原型上存在多余的父类实例属性(虽不影响使用,但造成内存冗余)。

四、原型式继承(基于已有对象创建新对象)

核心原理

通过 Object.create()(或手动封装的原型方法),以一个已有对象为原型,创建新的对象,实现对已有对象属性和方法的继承。

代码示例

javascript

运行

// 已有对象(作为原型)
const animal = {
  name: '动物',
  colors: ['black', 'white'],
  sayName: function() {
    console.log('动物名称:', this.name);
  }
};

// 核心:用 Object.create 创建新对象,继承 animal
const dog = Object.create(animal);
dog.name = '旺财'; // 重写实例属性
dog.breed = '中华田园犬'; // 新增自有属性

// 测试
const cat = Object.create(animal);
dog.colors.push('brown');
console.log(dog.colors); // ['black', 'white', 'brown']
console.log(cat.colors); // ['black', 'white', 'brown'](引用类型属性共享)
dog.sayName(); // 动物名称:旺财
console.log(dog.breed); // 中华田园犬

优点与缺点

  • 优点:无需定义构造函数,实现简单,适合快速创建基于已有对象的新对象;

  • 缺点

    1. 引用类型属性会被所有新对象共享(与原型链继承一致);
    2. 无法向父对象传递参数,只能在创建新对象后手动修改属性。

五、寄生式继承(原型式继承的增强版)

核心原理

在原型式继承的基础上,封装一个创建对象的函数,在函数内部为新对象添加自有属性和方法,增强新对象的功能,最后返回新对象。

代码示例

javascript

运行

// 封装创建继承对象的函数(寄生函数)
function createAnimal(proto, name, breed) {
  // 原型式继承:创建新对象
  const obj = Object.create(proto);
  // 增强新对象:添加自有属性和方法
  obj.name = name;
  obj.breed = breed;
  obj.sayBreed = function() {
    console.log('犬种/品种:', this.breed);
  };
  return obj;
}

// 原型对象
const animal = {
  colors: ['black', 'white'],
  sayName: function() {
    console.log('名称:', this.name);
  }
};

// 测试
const dog = createAnimal(animal, '旺财', '中华田园犬');
const cat = createAnimal(animal, '咪咪', '橘猫');
dog.colors.push('brown');
console.log(dog.colors); // ['black', 'white', 'brown']
console.log(cat.colors); // ['black', 'white', 'brown'](引用类型共享)
dog.sayName(); // 名称:旺财
dog.sayBreed(); // 犬种/品种:中华田园犬

优点与缺点

  • 优点:无需定义构造函数,可灵活增强新对象的功能,实现简单;

  • 缺点

    1. 引用类型属性共享问题依然存在;
    2. 每个新对象的自有方法都是独立的(无法共享),浪费内存;
    3. 无法实现方法的复用,类似构造函数继承的缺点。

六、寄生组合式继承(完美继承方案)

核心原理

结合组合继承和寄生式继承的优点,解决组合继承中父类构造函数被调用两次的问题:

  1. 寄生式继承继承父类的原型(仅继承原型方法,不调用父类构造函数);
  2. 构造函数继承继承父类的实例属性(支持传参,避免引用类型共享)。

代码示例

javascript

运行

// 寄生函数:继承父类原型,不调用父类构造函数
function inheritPrototype(SubType, SuperType) {
  // 创建父类原型的副本(避免直接修改父类原型)
  const prototype = Object.create(SuperType.prototype);
  prototype.constructor = SubType; // 修复构造函数指向
  SubType.prototype = prototype; // 将副本赋值给子类原型
}

// 父类
function Animal(name) {
  this.name = name;
  this.colors = ['black', 'white'];
}
Animal.prototype.sayName = function() {
  console.log('名称:', this.name);
};

// 子类
function Dog(name, breed) {
  // 构造函数继承:继承实例属性,传参(仅调用一次父类构造函数)
  Animal.call(this, name);
  this.breed = breed;
}

// 核心:寄生式继承父类原型
inheritPrototype(Dog, Animal);

// 子类原型方法
Dog.prototype.sayBreed = function() {
  console.log('犬种:', this.breed);
};

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 名称:旺财
dog1.sayBreed(); // 犬种:中华田园犬
console.log(dog1 instanceof Animal); // true
console.log(Dog.prototype.constructor === Dog); // true(构造函数指向正确)

优点与缺点

  • 优点

    1. 父类构造函数仅被调用一次,避免了内存冗余;
    2. 实现了方法共享,避免了引用类型属性共享;
    3. 支持向父类传参,instanceof 检测和构造函数指向均正常;
    4. 是 JavaScript 继承的 “完美方案”,ES6 class extends 底层基于此实现。
  • 缺点:实现相对复杂(需封装寄生函数),但可复用该函数。

七、ES6 Class 继承(语法糖)

核心原理

通过 class 定义类,extends 关键字实现继承,super() 调用父类构造函数,底层仍是寄生组合式继承,只是语法更简洁、更接近传统类继承。

代码示例

javascript

运行

// 父类
class Animal {
  constructor(name) {
    this.name = name;
    this.colors = ['black', 'white'];
  }

  sayName() {
    console.log('名称:', this.name);
  }
}

// 子类:extends 实现继承
class Dog extends Animal {
  constructor(name, breed) {
    super(name); // 必须先调用 super(),才能使用 this
    this.breed = breed;
  }

  sayBreed() {
    console.log('犬种:', this.breed);
  }
}

// 测试
const dog1 = new Dog('旺财', '中华田园犬');
const dog2 = new Dog('小白', '萨摩耶');
dog1.colors.push('brown');
console.log(dog1.colors); // ['black', 'white', 'brown']
console.log(dog2.colors); // ['black', 'white'](引用类型不共享)
dog1.sayName(); // 名称:旺财
dog1.sayBreed(); // 犬种:中华田园犬
console.log(dog1 instanceof Animal); // true

优点与缺点

  • 优点:语法简洁直观,符合面向对象编程习惯,易于理解和维护,支持静态方法继承(static 关键字);
  • 缺点:本质是语法糖,底层仍依赖原型链,新手可能忽略原型继承的本质。

八、各类继承方式对比与选型建议

继承方式 核心优点 核心缺点 适用场景
原型链继承 实现简单,方法共享 引用类型共享,无法传参 简单场景,无需传参,不关心引用类型共享
构造函数继承 支持传参,引用类型不共享 无法继承原型方法,方法冗余 仅需继承实例属性,无需共享方法
组合继承 方法共享,支持传参,功能完善 父类构造函数调用两次 常规业务场景,兼容性要求高
原型式继承 无需构造函数,快速创建对象 引用类型共享,无法传参 基于已有对象快速创建新对象
寄生式继承 灵活增强对象功能 方法冗余,引用类型共享 快速创建并增强新对象,简单场景
寄生组合式继承 完美解决所有缺陷,性能最优 实现复杂 追求性能和严谨性的场景,框架开发
ES6 Class 继承 语法简洁,符合 OOP 习惯 底层仍是原型继承 现代项目开发,兼容性良好(ES6+)

总结

  1. 原型链是 JavaScript 继承的基础,所有继承方式均围绕原型链展开;
  2. 寄生组合式继承是 “完美方案”,ES6 class extends 是其语法糖,推荐现代项目优先使用;
  3. 简单场景可使用原型式 / 寄生式继承,兼容旧环境可使用组合继承,仅需实例属性继承可使用构造函数继承。
❌
❌