vue keep-alive 从原理到实现

从原生 DOM 到 Web Component:手写 Vue Keep-Alive

前言

Vue 的 <keep-alive> 是一个很实用的组件,它能在组件切换时保留状态(DOM、滚动位置、输入框内容等),避免重复渲染。

本文将从最基础的原生 DOM 实现开始,逐步演进到 Web Component 封装,深入理解 keep-alive 的核心原理。

一、Keep-Alive 的核心思路

在动手写代码之前,先理清 keep-alive 需要解决的问题:

  1. 组件切换时不销毁 DOM:切换回旧组件时,DOM 还在

  2. 状态保留:输入框内容、滚动位置等不丢失

  3. 生命周期 :需要 mounted(首次挂载)、activated(激活)、deactivated(失活)、unmounted(销毁)

关键问题:scrollTop 为什么会丢失?

当 DOM 节点从文档树中移除时(removeChildinnerHTML = ''),浏览器会重置 scrollTop 为 0

复制代码
组件 A 滚动到 100px

↓

removeChild(dom) → DOM 离开文档树

↓

浏览器重置 scrollTop = 0

↓

appendChild(dom) → DOM 重新加入文档树

↓

scrollTop 已经是 0 了

所以必须手动保存和恢复滚动位置

二、第一版:原生 DOM 实现

最朴素的实现方式:用一个 cache 对象存储组件实例,切换时通过 appendChild 操作 DOM。

2.1 数据结构

js 复制代码
const cache = {

A: null, // 组件A的实例

B: null, // 组件B的实例

current: null, // 当前显示的组件

};

2.2 组件实例

每个组件是一个对象,包含 DOM 引用和生命周期钩子:

js 复制代码
function createComponentA() {

const dom = document.createElement("div");

dom.style.width = "180px";

dom.style.height = "120px";

dom.style.overflow = "auto";

dom.style.background = "#e3f2fd";

dom.innerHTML = `<strong>组件A</strong><br>

<input placeholder="输入内容保留"><br>

${Array.from({ length: 25 }, (_, i) => `A内容${i + 1}`).join("<br>")}`;



return {

name: "A",

dom,

savedScrollTop: 0, // 保存滚动位置

isFirstMount: true,

mounted: () => console.log("A mounted:组件首次挂载完毕"),

activated: () => {

console.log("A activated:组件激活");

dom.scrollTop = cache.A.savedScrollTop; // 恢复

},

deactivated: () => {

cache.A.savedScrollTop = dom.scrollTop; // 保存

console.log("A deactivated:组件离开视图");

},

unmounted: () => console.log("A unmounted:组件销毁"),

};

}

2.3 切换逻辑

js 复制代码
function switchTo(target) {

if (cache.current === target) return;



// 1. 失活当前组件

if (cache.current) {

const currentInstance = cache[cache.current];

currentInstance.deactivated(); // 保存 scrollTop

container.innerHTML = ""; // DOM 离开文档树

}



// 2. 获取或创建目标组件

let instance = cache[target];

if (!instance) {

// 首次创建

instance = target === "A" ? createComponentA() : createComponentB();

cache[target] = instance;

container.appendChild(instance.dom);

instance.mounted();

} else {

// 复用缓存

container.appendChild(instance.dom);

instance.activated(); // 恢复 scrollTop

}



cache.current = target;

}

2.4 完整代码

html 复制代码
<!DOCTYPE html>

<html>

<head>

<title>keepalive实现1</title>

<style>

#view-container {

overflow: hidden;

}

</style>

</head>

<body>

<button id="btnA">切换到组件A</button>

<button id="btnB">切换到组件B</button>

<button onclick="destroyCache()">销毁所有缓存</button>

<div id="view-container"></div>



<script>

const container = document.querySelector("#view-container");

const cache = {

A: null,

B: null,

current: null,

};



function createComponentA() {

const dom = document.createElement("div");

dom.style.width = "180px";

dom.style.height = "120px";

dom.style.overflow = "auto";

dom.style.background = "#e3f2fd";

dom.innerHTML = `<strong>组件A</strong><br><input placeholder="输入内容保留"><br>${Array.from({ length: 25 }, (_, i) => `A内容${i + 1}`).join("<br>")}`;



return {

name: "A",

dom,

savedScrollTop: 0,

isFirstMount: true,

mounted: () => console.log("A mounted:组件首次挂载完毕"),

activated: () => {

console.log("A activated:组件激活,进入视图");

dom.scrollTop = cache.A.savedScrollTop;

console.log("A 恢复滚动位置", dom.scrollTop);

},

deactivated: () => {

cache.A.savedScrollTop = dom.scrollTop;

console.log(

"A deactivated:组件离开视图,保存滚动位置",

cache.A.savedScrollTop,

);

},

unmounted: () => console.log("A unmounted:组件彻底销毁"),

};

}



function createComponentB() {

const dom = document.createElement("div");

dom.style.width = "180px";

dom.style.height = "120px";

dom.style.overflow = "auto";

dom.style.background = "#f3e5f5";

dom.innerHTML = `<strong>组件B</strong><br><input placeholder="输入内容保留"><br>${Array.from({ length: 25 }, (_, i) => `B内容${i + 1}`).join("<br>")}`;



return {

name: "B",

dom,

savedScrollTop: 0,

isFirstMount: true,

mounted: () => console.log("B mounted:组件首次挂载完毕"),

activated: () => {

console.log("B activated:组件激活,进入视图");

dom.scrollTop = cache.B.savedScrollTop;

console.log("B 恢复滚动位置", dom.scrollTop);

},

deactivated: () => {

cache.B.savedScrollTop = dom.scrollTop;

console.log(

"B deactivated:组件离开视图,保存滚动位置",

cache.B.savedScrollTop,

);

},

unmounted: () => console.log("B unmounted:组件彻底销毁"),

};

}



function switchTo(target) {

if (cache.current === target) return;



if (cache.current) {

const currentInstance = cache[cache.current];

currentInstance.deactivated();

container.innerHTML = "";

}



let instance = cache[target];

if (!instance) {

instance = target === "A" ? createComponentA() : createComponentB();

cache[target] = instance;

container.appendChild(instance.dom);

instance.mounted();

instance.isFirstMount = false;

} else {

container.appendChild(instance.dom);

instance.activated();

}



cache.current = target;

}



document.querySelector("#btnA").onclick = function () {

switchTo("A");

};

document.querySelector("#btnB").onclick = function () {

switchTo("B");

};



window.destroyCache = function () {

if (cache.A) {

cache.A.unmounted();

cache.A = null;

}

if (cache.B) {

cache.B.unmounted();

cache.B = null;

}

cache.current = null;

container.innerHTML = "";

console.log("所有缓存已销毁");

};

</script>

</body>

</html>

2.5 第一版的问题

| 问题 | 说明 |

| ---------------- | ----------------------------- |

| 代码重复 | 组件 A 和 B 几乎一样的代码 |

| 没有封装 | 所有逻辑写在一起,不可复用 |

| 滚动位置手动管理 | 每个组件都要自己保存/恢复 |

| 没有组件化 | 不是真正的组件,只是 DOM 操作 |

三、第二版:Web Component 封装

用 Web Component 解决第一版的问题:

  • <keep-alive> 自定义元素:管理子组件的切换和缓存

  • withScrollCache mixin:自动收集可滚动元素,保存/恢复滚动位置

  • 子组件:只需继承 mixin,不需要关心滚动逻辑

3.1 架构设计

复制代码
<keep-alive>

<child-a></child-a> ← 继承 withScrollCache

<child-b></child-b> ← 继承 withScrollCache

</keep-alive>

数据流:

复制代码
KeepAlive.switchTo('child-a')

↓

1. 当前组件 B.deactivated()

└── mixin 自动查找所有 overflow:auto/scroll 元素

└── 保存 scrollTop/scrollLeft 到 _scrollCache

↓

2. removeChild(B) → DOM 离开文档树

↓

3. appendChild(A) → DOM 加入文档树

↓

4. A.activated()

└── mixin 从 _scrollCache 恢复所有元素的滚动位置

3.2 ScrollCacheMixin:滚动位置自动缓存

核心思路:deactivated 时自动查找所有可滚动元素并保存,activated 时恢复。

js 复制代码
function withScrollCache(BaseClass) {

return class extends BaseClass {

constructor() {

super();

this._scrollCache = []; // [{ element, scrollTop, scrollLeft }]

}



// 查找所有可滚动元素(shadow DOM + light DOM)

_findScrollableElements() {

const scrollable = [];

const pattern = /^(auto|scroll)$/;



const check = (el) => {

const style = getComputedStyle(el);

if (

pattern.test(style.overflow) ||

pattern.test(style.overflowX) ||

pattern.test(style.overflowY)

) {

scrollable.push(el);

}

};



// shadow DOM

if (this.shadowRoot) {

this.shadowRoot.querySelectorAll("*").forEach(check);

}

// light DOM

this.querySelectorAll("*").forEach(check);



return scrollable;

}



activated() {

this._scrollCache.forEach(({ element, scrollTop, scrollLeft }) => {

if (element) {

element.scrollTop = scrollTop;

element.scrollLeft = scrollLeft;

}

});

console.log(

`${this.tagName} activated:恢复了 ${this._scrollCache.length} 个元素的滚动位置`,

);

}



deactivated() {

const elements = this._findScrollableElements();

this._scrollCache = elements.map((el) => ({

el: el,

scrollTop: el.scrollTop,

scrollLeft: el.scrollLeft,

}));

console.log(

`${this.tagName} deactivated:保存了 ${this._scrollCache.length} 个元素的滚动位置`,

);

}



mounted() {

console.log(`${this.tagName} mounted:首次挂载`);

}



unmounted() {

console.log(`${this.tagName} unmounted:销毁`);

this._scrollCache = [];

}

};

}

为什么用 mixin 而不是继承?

Web Component 已经继承了 HTMLElement,JavaScript 是单继承,不能再继承其他类。mixin 通过函数返回一个新类,可以叠加能力而不占用继承链。

3.3 KeepAlive 自定义元素

js 复制代码
class KeepAlive extends HTMLElement {

constructor() {

super();

this.attachShadow({ mode: "open" });

this.shadowRoot.innerHTML = `

<style>

:host { display: block; }

#container { width: 100%; }

</style>

<div id="container"></div>

`;

this._container = this.shadowRoot.querySelector("#container");

this._components = new Map();

this._current = null;

}



connectedCallback() {

// 收集子组件,从 light DOM 移除

const children = Array.from(this.children);

children.forEach((child) => {

const name = child.tagName.toLowerCase();

this._components.set(name, child);

this.removeChild(child);

});



console.log("KeepAlive 收集到:", [...this._components.keys()]);



// 获取默认激活的组件(通过 default-active 属性指定)

const defaultActive = this.getAttribute("default-active");

let firstName = null;



if (defaultActive && this._components.has(defaultActive)) {

firstName = defaultActive;

} else if (children.length > 0) {

firstName = children[0].tagName.toLowerCase();

}



if (firstName) {

this.switchTo(firstName);

}

}



disconnectedCallback() {

this._components.forEach((comp) => {

if (comp.unmounted) comp.unmounted();

});

this._components.clear();

}



switchTo(name) {

if (this._current === name) return;



console.log("switchTo:", name, "当前:", this._current);



// 失活当前组件

if (this._current) {

const current = this._components.get(this._current);

if (current) {

if (current.deactivated) current.deactivated();

this._container.removeChild(this._container.firstChild);

}

}



// 激活目标组件

const target = this._components.get(name);

if (!target) {

console.error(`组件 ${name} 不存在`);

return;

}



const isFirstMount = !target._hasMounted;

this._container.appendChild(target);



if (isFirstMount) {

target._hasMounted = true;

if (target.mounted) target.mounted();

} else {

if (target.activated) target.activated();

}



this._current = name;

}



destroyAll() {

this._components.forEach((comp) => {

if (comp.unmounted) comp.unmounted();

});

this._components.clear();

this._current = null;

this._container.innerHTML = "";

console.log("所有缓存已销毁");

}

}



customElements.define("keep-alive", KeepAlive);

3.4 子组件:只需关心 UI

js 复制代码
class ChildA extends withScrollCache(HTMLElement) {

constructor() {

super();

this.attachShadow({ mode: "open" });

this.shadowRoot.innerHTML = `

<style>

:host { display: block; padding: 20px; background: #e3f2fd; border-radius: 8px; }

h2 { margin-top: 0; color: #1976d2; }

.scroll-container {

height: 200px; overflow-y: auto;

border: 1px solid #90caf9; padding: 10px;

background: white; border-radius: 4px;

}

.item { padding: 8px; margin: 4px 0; background: #f5f5f5; border-radius: 4px; }

input {

width: 100%; padding: 8px; margin-bottom: 10px;

border: 1px solid #90caf9; border-radius: 4px; box-sizing: border-box;

}

</style>

<h2>组件 A</h2>

<input type="text" placeholder="输入内容(切换后保留)">

<div class="scroll-container">

${Array.from(

{ length: 30 },

(_, i) => `<div class="item">ChildA - 项目 ${i + 1}</div>`,

).join("")}

</div>

`;

}

// 不需要实现 getScrollElement()、activated()、deactivated()

// mixin 会自动处理所有滚动逻辑

}

customElements.define("child-a", ChildA);

3.5 使用方式

html 复制代码
<!-- 默认显示第一个子组件 -->

<keep-alive id="keepalive">

<child-a></child-a>

<child-b></child-b>

</keep-alive>



<!-- 通过 default-active 属性指定默认激活的组件 -->

<keep-alive default-active="child-b">

<child-a></child-a>

<child-b></child-b>

</keep-alive>



<script>

// 切换组件

document.querySelector("#keepalive").switchTo("child-a");

document.querySelector("#keepalive").switchTo("child-b");



// 销毁缓存

document.querySelector("#keepalive").destroyAll();

</script>

四、两版对比

| 对比项 | 第一版(原生 DOM) | 第二版(Web Component) |

| ------------ | --------------------- | --------------------------------- |

| 组件化 | 无,纯 DOM 操作 | 自定义元素,可复用 |

| 滚动位置 | 每个组件手动保存/恢复 | mixin 自动处理 |

| 可滚动元素 | 只能处理一个 | 自动查找所有 overflow:auto/scroll |

| 样式隔离 | 无 | Shadow DOM |

| 生命周期 | 手动管理 | mixin 提供标准生命周期 |

| 子组件代码量 | 多(重复逻辑) | 少(只写 UI) |

五、关键知识点

5.1 DOM 离开文档树时 scrollTop 会重置

这是浏览器行为,无法改变。解决方案:

  • 手动保存/恢复(本文方案)

  • display: none 隐藏(DOM 不离开文档树,scrollTop 自动保留,但两份 DOM 同时在页面中)

5.2 Mixin 模式

js 复制代码
function withScrollCache(BaseClass) {

return class extends BaseClass {

// 叠加能力...

};

}



// 使用

class ChildA extends withScrollCache(HTMLElement) {}

不占用继承链,可以叠加多个 mixin:

js 复制代码
class ChildA extends withScrollCache(withLogger(HTMLElement)) {}

5.3 Shadow DOM 中的样式隔离

子组件使用 Shadow DOM,样式完全隔离,不会互相影响。mixin 在查找可滚动元素时,同时搜索 shadow DOM 和 light DOM。

5.4 Web Component 生命周期

| 回调 | 触发时机 |

| -------------------------- | ---------------- |

| connectedCallback | 元素插入 DOM |

| disconnectedCallback | 元素从 DOM 移除 |

| adoptedCallback | 元素移动到新文档 |

| attributeChangedCallback | 属性变化 |

本文在此基础上扩展了 mountedactivateddeactivatedunmounted,模拟 Vue 的 keep-alive 生命周期。

六、总结

从原生 DOM 到 Web Component 的演进过程:

  1. 先理解原理:keep-alive 本质是缓存 DOM + 手动保存/恢复状态

  2. 再封装抽象:把重复逻辑(滚动位置保存/恢复)抽到 mixin

  3. 最后组件化 :用 Web Component 实现可复用的 <keep-alive> 标签

这个思路也适用于理解 Vue 的 keep-alive 实现:Vue 内部也是通过缓存 VNode、在 deactivated 时保存状态、在 activated 时恢复状态来实现的。