从原生 DOM 到 Web Component:手写 Vue Keep-Alive
前言
Vue 的 <keep-alive> 是一个很实用的组件,它能在组件切换时保留状态(DOM、滚动位置、输入框内容等),避免重复渲染。
本文将从最基础的原生 DOM 实现开始,逐步演进到 Web Component 封装,深入理解 keep-alive 的核心原理。
一、Keep-Alive 的核心思路
在动手写代码之前,先理清 keep-alive 需要解决的问题:
-
组件切换时不销毁 DOM:切换回旧组件时,DOM 还在
-
状态保留:输入框内容、滚动位置等不丢失
-
生命周期 :需要
mounted(首次挂载)、activated(激活)、deactivated(失活)、unmounted(销毁)
关键问题:scrollTop 为什么会丢失?
当 DOM 节点从文档树中移除时(removeChild、innerHTML = ''),浏览器会重置 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>自定义元素:管理子组件的切换和缓存 -
withScrollCachemixin:自动收集可滚动元素,保存/恢复滚动位置 -
子组件:只需继承 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 | 属性变化 |
本文在此基础上扩展了 mounted、activated、deactivated、unmounted,模拟 Vue 的 keep-alive 生命周期。
六、总结
从原生 DOM 到 Web Component 的演进过程:
-
先理解原理:keep-alive 本质是缓存 DOM + 手动保存/恢复状态
-
再封装抽象:把重复逻辑(滚动位置保存/恢复)抽到 mixin
-
最后组件化 :用 Web Component 实现可复用的
<keep-alive>标签
这个思路也适用于理解 Vue 的 keep-alive 实现:Vue 内部也是通过缓存 VNode、在 deactivated 时保存状态、在 activated 时恢复状态来实现的。