阅读视图

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

深入理解 Vue keep-alive:缓存本质、触发条件与生命周期对比

一、先明确核心结论

keep-alive 是 Vue 内置的抽象组件(不渲染真实 DOM),它的核心作用是缓存被包裹的组件实例,缓存的关键数据结构如下:

  • 缓存容器:keep-alive 实例上的 this.cache(一个对象,key 是组件的「缓存标识」,value 是组件实例);
  • 辅助记录:this.keys(一个数组,存储缓存组件的 key,用于实现 max 缓存数量限制);
  • 挂载关系:被缓存的组件实例 → 作为 this.cache 对象的属性值 → 挂在 keep-alive 组件实例上,而非被缓存组件自己的实例上。

二、keep-alive 挂载缓存的完整过程(分步骤拆解)

以 Vue 2 为例(Vue 3 逻辑一致,仅源码实现细节略有差异),核心流程如下:

步骤 1:keep-alive 初始化,创建缓存容器

keep-alive 组件初始化时,会在自身实例上创建两个核心属性,用于存储缓存:

// keep-alive 组件的初始化逻辑(简化版)
export default {
  name: 'keep-alive',
  abstract: true, // 抽象组件,不参与DOM渲染
  props: {
    include: [String, RegExp, Array], // 需缓存的组件
    exclude: [String, RegExp, Array], // 排除缓存的组件
    max: [String, Number] // 最大缓存数量
  },
  created() {
    this.cache = Object.create(null); // 缓存容器:{ key: 组件实例 }
    this.keys = []; // 缓存key列表:[key1, key2...]
  },
  // ...其他生命周期
}
  • this.cache :空对象,后续用来存「缓存标识 → 组件实例」的映射;
  • this.keys :空数组,记录缓存 key 的顺序,用于 LRU 淘汰(超出 max 时删除最久未使用的缓存)。

步骤 2:组件首次渲染,判断是否缓存

当 keep-alive 包裹的组件首次渲染时,keep-alive 的 render 函数会执行核心逻辑:

  1. 获取被包裹组件的**「缓存标识」**(key):
    • 默认 key:组件名 + 组件实例的uid(避免同组件不同实例冲突);
    • 自定义 key:可通过 key 属性指定(如 <keep-alive><component :is="comp" :key="compKey" /></keep-alive>)。
  1. 判断是否符合缓存规则(include / exclude):
    • 若符合:将组件实例存入 this.cache,并把 key 加入 this.keys
    • 若不符合:不缓存,直接渲染组件(和普通组件一样)。

举个例子:

<keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
</keep-alive>

步骤 3:缓存组件实例,挂载到 keep-alive 上

核心逻辑简化如下:

// keep-alive 的 render 函数核心逻辑(简化版)
render() {
  const slot = this.$slots.default;
  const vnode = getFirstComponentChild(slot); // 获取被包裹的第一个组件vnode
  const componentOptions = vnode && vnode.componentOptions;
  
  if (componentOptions) {
    // 1. 生成缓存key(核心:唯一标识组件实例)
    const key = this.getCacheKey(vnode);
    const { cache, keys } = this;

    // 2. 判断是否需要缓存(符合include,不符合exclude)
    if (this.shouldCache(componentOptions)) {
      // 3. 若缓存中已有该组件实例,直接复用
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // 更新key顺序(LRU:把当前key移到最后,标记为最近使用)
        remove(keys, key);
        keys.push(key);
      } else {
        // 4. 首次渲染:将组件vnode(包含实例)存入缓存
        cache[key] = vnode;
        keys.push(key);
        // 5. 超出max时,删除最久未使用的缓存
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // 标记组件为“被缓存”,避免重复初始化
      vnode.data.keepAlive = true;
    }
  }
  return vnode;
}

关键挂载动作cache[key] = vnode → 组件的 vnode(包含 componentInstance 即组件实例)被作为 cache 对象的属性值,挂载到 keep-alive 实例的 this.cache 上。

步骤 4:组件再次渲染,复用缓存实例

当被缓存的组件需要再次渲染时(比如路由切换后返回):

  1. keep-alive this.cache 中根据 key 取出对应的组件实例;
  2. 将缓存的实例赋值给新的 vnode 的 componentInstance
  3. 直接复用该实例渲染,不再执行组件的 created / mounted 等生命周期(而是触发 activated 钩子)。

三、关键细节:为什么缓存不挂在被缓存组件自己的实例上?

  1. 逻辑合理性keep-alive 是「缓存管理者」,应该由管理者统一存储和管理所有被缓存的组件,而非让组件自己存储;
  2. 避免内存泄漏:若缓存挂在被缓存组件实例上,组件实例本身无法被销毁(因为缓存引用了它),而 keep-alive 统一管理可通过 maxexclude 主动清理缓存;
  3. 多实例隔离:多个 keep-alive 组件的缓存是隔离的(比如页面 A 和页面 B 各有一个 keep-alive),每个 keep-alive 实例有自己的 cache,不会互相干扰。

总结:

一、先肯定你的正确认知

  1. ✅ keep-alive 是 Vue 官方内置的抽象组件(不渲染真实 DOM),被包裹的组件实例 / 状态会挂载在keep-alive 实例的 cache 对象上(而非组件自身);
  2. ✅ 缓存的核心内容是组件的 VNode(包含组件实例、DOM 节点描述、数据状态如 data/props/ 输入框值等);
  3. ✅ 组件的缓存触发和「组件失活」强相关(比如路由跳转导致组件被隐藏)。

二、需要修正 / 补充的关键细节

细节 1:“组件经过 keep-alive 时被缓存” → 不是 “经过”,而是 “组件被 keep-alive 包裹且失活时才缓存”

keep-alive 不会 “主动拦截” 组件,而是当被包裹的组件从「激活状态」变为「失活状态」时,才会将其 VNode 存入缓存(而非加载时就缓存)。

  • 激活状态:组件在页面中可见(比如当前路由匹配的 Home 组件);
  • 失活状态:组件被隐藏(比如跳转到 List 路由,Home 组件被 router-view 卸载)。

简单说:keep-alive 是 “挽留” 即将被销毁的组件 —— 默认情况下,组件失活会被销毁,而 keep-alive会把它存入缓存,避免销毁。

细节 2:“只有页面跳转才缓存” → 不完全对,「组件失活」都触发缓存(不止路由跳转)

路由跳转是最常见的 “组件失活” 场景,但不是唯一场景:

  • 场景 1(路由跳转):/home → /list,Home 组件失活 → 被缓存;
  • 场景 2(条件渲染):<keep-alive><component :is="compName" /></keep-alive>,当compName 从 Home 改为 List 时,Home 失活 → 被缓存;
  • 场景 3(v-if 隐藏):<keep-alive><div v-if="show">Home组件</div></keep-alive>,当show 从 true 改为 false 时,Home 失活 → 被缓存。

核心:只要 keep-alive 包裹的组件从 “渲染在页面上” 变为 “不渲染”,且符合 include/exclude 规则,就会被缓存。

细节 3:“只加载页面不跳转,不会缓存” → 准确说:“组件未失活,缓存容器中已有该组件的 VNode,但未触发「缓存复用」”

即使不跳转,只要组件被 keep-alive 包裹并完成首次渲染:

  1. keep-alive cache 已经存入了该组件的 VNode (可以通过前面的代码查到);
  2. 只是因为组件未失活,所以不会触发 activated 钩子,也不会体现出 “缓存效果”(比如输入框输入内容,不跳转的话,内容本来就在,看不出缓存);
  3. 只有当组件失活后再次激活(比如跳转回来),才会从缓存中复用 VNode,此时能看到 “状态保留”(比如输入框内容还在)—— 这才是缓存的 “可见效果”。

举个直观例子:

  • 步骤 1:访问 /home,Home 组件渲染(激活),keep-alive.cache 中已有 Home 的 VNode(但未体现缓存);
  • 步骤 2:在 Home 输入框输入 “123”,跳转到 /list(Home 失活),keep-alive 保留 Home 的 VNode(包含输入框的 “123”);
  • 步骤 3:跳回 /home(Home 激活),keep-alive 复用缓存的 VNode,输入框仍显示 “123”—— 这就是缓存的效果。

如果只停留在步骤 1(不跳转),虽然缓存容器中有 Home 的 VNode,但因为没有 “失活→激活” 的过程,所以看不到缓存的效果,并非 “没有缓存”。

细节 4:缓存的 VNode 包含什么?→ 不止节点 / 属性,还有组件的「完整实例状态」

VNode 是组件的 “虚拟描述”,缓存 VNode 本质是缓存组件实例:

  • 包含 DOM 结构描述(比如 <div class="home">);
  • 包含组件的响应式数据data/computed/props);
  • 包含组件的 DOM 状态(输入框值、滚动条位置、复选框勾选状态);
  • 包含组件的生命周期状态不会再执行 created / mounted ,而是执行 activated)。

三、总结:精准理解 keep-alive 的缓存逻辑

  1. 挂载关系keep-alive 是 “缓存管理者”,被包裹组件的 VNode(含实例 / 状态)挂载在 keep-alive实例的 cache 对象上;
  2. 缓存触发:组件被 keep-alive 包裹 + 组件从「激活→失活」(路由跳转 / 条件隐藏等)→ 存入缓存;
  3. 缓存复用:组件从「失活→激活」→ 从 cache 中取出 VNode 复用(不重新创建实例,保留状态);
  4. 可见效果:只有 “失活→激活” 的过程,才能体现缓存(状态保留),仅加载组件不跳转,缓存存在但无 “可见效果”。

简单记: keep-alive 的核心是 “保活”—— 不让失活的组件销毁,而是存入缓存,下次激活时直接复用,避免重复创建 / 销毁,同时保留组件状态。


keep-alive组件加载生命周期对比

阶段 Vue 2 生命周期 Vue 3 组合式 API 核心特点
首次加载 beforeCreate → created → beforeMount → mounted → activated setup → onBeforeMount → onMounted → onActivated 完整生命周期,最后触发激活钩子
失活缓存 deactivated onDeactivated 仅触发失活钩子,不销毁组件
二次加载 activated onActivated 仅触发激活钩子,跳过创建 / 挂载
缓存销毁 deactivated → beforeDestroy → destroyed onDeactivated → onBeforeUnmount → onUnmounted 先失活,再销毁

前端JS脚本放在head与body是如何影响加载的以及优化策略

JS放在不同位置确实有重要区别!这涉及到页面加载性能、用户体验和代码执行时机等多个方面。

1. 基础区别

放在 <head> 中

<!DOCTYPE html>
<html>
<head>
    <title>页面标题</title>
    <!-- JS在head中 -->
    <script src="script.js"></script>
    <script>
        console.log('head中的脚本执行');
        // 此时DOM还没有构建完成
        document.querySelector('#myButton'); // 可能返回null
    </script>
</head>
<body>
    <div id="content">页面内容</div>
    <button id="myButton">点击按钮</button>
</body>
</html>

特点:

  • 脚本会阻塞HTML解析
  • DOM元素还未创建,无法直接操作
  • 会延迟页面渲染

放在 <body> 底部

<!DOCTYPE html>
<html>
<head>
    <title>页面标题</title>
</head>
<body>
    <div id="content">页面内容</div>
    <button id="myButton">点击按钮</button>
    
    <!-- JS在body底部 -->
    <script src="script.js"></script>
    <script>
        console.log('body底部的脚本执行');
        // 此时DOM已经构建完成
        document.querySelector('#myButton'); // 可以正常获取元素
    </script>
</body>
</html>

特点:

  • DOM元素已经创建完成,可以直接操作
  • 不会阻塞页面首次渲染
  • 用户能更快看到页面内容

2. 页面加载过程详解

浏览器解析流程

// 模拟浏览器解析过程
console.log('1. 开始解析HTML');

// 遇到head中的script
console.log('2. 暂停HTML解析');
console.log('3. 下载并执行JS文件');
console.log('4. JS执行完成,继续解析HTML');

// 解析body内容
console.log('5. 构建DOM元素');
console.log('6. 遇到body底部的script');
console.log('7. 执行body底部的JS');
console.log('8. 页面解析完成');

实际测试示例

<!DOCTYPE html>
<html>
<head>
    <script>
        console.time('head-script');
        console.log('Head脚本开始执行');
        
        // 尝试获取DOM元素
        const button1 = document.getElementById('test-button');
        console.log('Head中获取按钮:', button1); // null
        
        // 模拟一些计算任务
        let sum = 0;
        for(let i = 0; i < 1000000; i++) {
            sum += i;
        }
        
        console.log('Head脚本执行完成');
        console.timeEnd('head-script');
    </script>
</head>
<body>
    <h1>页面标题</h1>
    <p>这是页面内容</p>
    <button id="test-button">测试按钮</button>
    
    <script>
        console.time('body-script');
        console.log('Body脚本开始执行');
        
        // 尝试获取DOM元素
        const button2 = document.getElementById('test-button');
        console.log('Body中获取按钮:', button2); // HTMLButtonElement
        
        console.log('Body脚本执行完成');
        console.timeEnd('body-script');
    </script>
</body>
</html>

3. 性能影响对比

Head中的JS - 阻塞渲染

// head中的大型脚本会阻塞页面渲染
// script.js (放在head中)
console.log('开始执行大型脚本...');

// 模拟复杂计算
function heavyComputation() {
    let result = 0;
    for(let i = 0; i < 10000000; i++) {
        result += Math.random();
    }
    return result;
}

const result = heavyComputation();
console.log('计算完成:', result);

// 在这个脚本执行期间,页面完全是白屏状态
// 用户看不到任何内容

Body底部的JS - 非阻塞渲染

// body底部的相同脚本
// 用户已经能看到页面内容,然后才执行这个脚本
console.log('页面内容已经可见,现在执行脚本...');

function heavyComputation() {
    let result = 0;
    for(let i = 0; i < 10000000; i++) {
        result += Math.random();
    }
    return result;
}

const result = heavyComputation();
console.log('计算完成:', result);

4. 现代解决方案 - 脚本属性

async 属性

<head>
    <!-- async: 异步下载,下载完立即执行 -->
    <script src="analytics.js" async></script>
    <script src="ads.js" async></script>
</head>
<body>
    <div>页面内容</div>
</body>
// async脚本的执行时机不确定
// analytics.js
console.log('Analytics脚本执行'); // 可能在DOM准备好之前或之后执行

// ads.js  
console.log('广告脚本执行'); // 执行顺序无法保证

defer 属性

<head>
    <!-- defer: 延迟执行,等DOM构建完成后按顺序执行 -->
    <script src="jquery.js" defer></script>
    <script src="main.js" defer></script>
</head>
<body>
    <div>页面内容</div>
</body>
// defer脚本会按顺序执行,且在DOM准备好之后
// jquery.js
window.$ = function() { /* jQuery实现 */ };
console.log('jQuery加载完成');

// main.js (会在jquery.js之后执行)
$(document).ready(function() {
    console.log('DOM准备完成,jQuery可用');
});

5. 实际应用场景

必须放在Head中的情况

<head>
    <!-- 1. 页面配置脚本 -->
    <script>
        // 全局配置,需要在页面渲染前设置
        window.APP_CONFIG = {
            apiUrl: 'https://api.example.com',
            theme: 'dark'
        };
    </script>
    
    <!-- 2. 字体加载优化 -->
    <script>
        // 字体预加载,避免FOUT (Flash of Unstyled Text)
        if ('fonts' in document) {
            document.fonts.load('1em Arial');
        }
    </script>
    
    <!-- 3. 关键CSS内联 -->
    <script>
        // 根据条件动态插入关键CSS
        const criticalCSS = `
            body { font-family: Arial; }
            .hero { background: #333; }
        `;
        const style = document.createElement('style');
        style.textContent = criticalCSS;
        document.head.appendChild(style);
    </script>
    
    <!-- 4. 用户代理检测 -->
    <script>
        // 需要在页面渲染前确定设备类型
        window.isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
        if (window.isMobile) {
            document.documentElement.classList.add('mobile');
        }
    </script>
</head>

应该放在Body底部的情况

<body>
    <header>
        <h1>网站标题</h1>
        <nav id="navigation"><!-- 导航内容 --></nav>
    </header>
    
    <main>
        <article id="content"><!-- 主要内容 --></article>
        <aside id="sidebar"><!-- 侧边栏 --></aside>
    </main>
    
    <footer>
        <p>版权信息</p>
    </footer>
    
    <!-- 这些脚本放在底部 -->
    <script>
        // 1. DOM操作脚本
        const navigation = document.getElementById('navigation');
        navigation.addEventListener('click', function(e) {
            // 处理导航点击
        });
        
        // 2. 第三方分析脚本
        (function() {
            const ga = document.createElement('script');
            ga.async = true;
            ga.src = 'https://www.google-analytics.com/analytics.js';
            document.head.appendChild(ga);
        })();
        
        // 3. 非关键功能
        function initImageLazyLoading() {
            const images = document.querySelectorAll('img[data-src]');
            // 懒加载逻辑
        }
        
        initImageLazyLoading();
        
        // 4. 社交媒体插件
        if (typeof window.FB === 'undefined') {
            const fb = document.createElement('script');
            fb.src = 'https://connect.facebook.net/en_US/sdk.js';
            document.body.appendChild(fb);
        }
    </script>
</body>

6. 性能优化策略

智能加载策略

// 创建动态脚本加载器
class ScriptLoader {
    constructor() {
        this.loadedScripts = new Set();
        this.loadingScripts = new Map();
    }
    
    async loadScript(src, options = {}) {
        if (this.loadedScripts.has(src)) {
            return Promise.resolve();
        }
        
        if (this.loadingScripts.has(src)) {
            return this.loadingScripts.get(src);
        }
        
        const promise = new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = src;
            script.async = options.async !== false;
            script.defer = options.defer || false;
            
            script.onload = () => {
                this.loadedScripts.add(src);
                this.loadingScripts.delete(src);
                resolve();
            };
            
            script.onerror = () => {
                this.loadingScripts.delete(src);
                reject(new Error(`Failed to load script: ${src}`));
            };
            
            // 决定插入位置
            const target = options.target === 'head' ? 
                document.head : document.body;
            target.appendChild(script);
        });
        
        this.loadingScripts.set(src, promise);
        return promise;
    }
    
    // 批量加载脚本
    async loadScripts(scripts) {
        const promises = scripts.map(script => 
            typeof script === 'string' ? 
                this.loadScript(script) : 
                this.loadScript(script.src, script.options)
        );
        
        return Promise.all(promises);
    }
}

// 使用示例
const loader = new ScriptLoader();

// 页面加载完成后再加载非关键脚本
document.addEventListener('DOMContentLoaded', async () => {
    try {
        // 并行加载多个脚本
        await loader.loadScripts([
            'https://cdn.jsdelivr.net/npm/chart.js',
            { src: 'analytics.js', options: { defer: true } },
            'social-widgets.js'
        ]);
        
        console.log('所有脚本加载完成');
        initializeFeatures();
    } catch (error) {
        console.error('脚本加载失败:', error);
    }
});

条件加载

// 根据页面类型条件加载脚本
function loadPageSpecificScripts() {
    const currentPage = document.body.dataset.page;
    
    switch (currentPage) {
        case 'product':
            // 产品页面特定脚本
            loadScript('product-viewer.js');
            loadScript('review-system.js');
            break;
            
        case 'checkout':
            // 结账页面特定脚本
            loadScript('payment-processor.js');
            loadScript('address-validator.js');
            break;
            
        case 'blog':
            // 博客页面特定脚本
            loadScript('comment-system.js');
            loadScript('social-share.js');
            break;
            
        default:
            // 通用脚本
            loadScript('common-features.js');
    }
}

// 根据用户交互加载脚本
function loadOnInteraction() {
    // 用户首次交互时加载重型脚本
    function loadHeavyScripts() {
        loadScript('heavy-animation-library.js');
        loadScript('complex-ui-components.js');
        
        // 移除事件监听器,只加载一次
        ['mousedown', 'touchstart', 'keydown'].forEach(event => {
            document.removeEventListener(event, loadHeavyScripts, true);
        });
    }
    
    ['mousedown', 'touchstart', 'keydown'].forEach(event => {
        document.addEventListener(event, loadHeavyScripts, true);
    });
}

// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', () => {
    loadPageSpecificScripts();
    loadOnInteraction();
});

7. 最佳实践总结

现代推荐做法

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>现代网页</title>
    
    <!-- 1. 关键CSS内联或预加载 -->
    <style>/* 关键CSS */</style>
    <link rel="preload" href="main.css" as="style">
    
    <!-- 2. 重要配置脚本(最小化) -->
    <script>
        window.APP_CONFIG = {/* 基础配置 */};
    </script>
    
    <!-- 3. 核心库使用defer -->
    <script src="react.min.js" defer></script>
    <script src="main.js" defer></script>
    
    <!-- 4. 分析脚本使用async -->
    <script src="analytics.js" async></script>
</head>
<body>
    <!-- 页面内容 -->
    <div id="root">
        <h1>页面内容</h1>
        <!-- 其他内容 -->
    </div>
    
    <!-- 5. 非关键脚本放在底部或动态加载 -->
    <script>
        // 延迟加载非关键功能
        setTimeout(() => {
            import('./non-critical-features.js');
        }, 1000);
        
        // 基于用户交互加载
        document.addEventListener('scroll', () => {
            import('./scroll-effects.js');
        }, { once: true });
    </script>
</body>
</html>

使用搭配

位置 适用场景 优点 缺点
Head 关键配置、用户代理检测 执行早、配置及时生效 阻塞渲染、延迟首屏
Head + defer 核心功能库、框架 按顺序执行、DOM可用 需要浏览器支持
Head + async 独立分析脚本、广告 不阻塞解析、并行下载 执行时机不确定
Body底部 DOM操作、事件绑定 不阻塞渲染、DOM可用 可能延迟功能可用时间
动态加载 非关键功能、条件功能 最优性能、按需加载 实现复杂、可能影响SEO
❌