自定义指令
组件上不推荐使用自定义指令
自定义指令种类
-
注册全局指令
const app = createApp({}) // 使 v-focus 在所有组件中都可用 app.directive('focus', { /* ... */ })
-
注册局部指令(很少使用)
const focus = { mounted: (el) => el.focus() } export default { directives: { // 在模板中启用 v-focus focus } }
钩子函数
-
一个指令的定义对象可以提供几种钩子函数 (都是可选的):
const myDirective = { // 在绑定元素的 attribute 前 // 或事件监听器应用前调用 created(el, binding, vnode, prevVnode) { }, // 在元素被插入到 DOM 前调用 beforeMount(el, binding, vnode, prevVnode) { }, // 在绑定元素的父组件 // 及他自己的所有子节点都挂载完成后调用 mounted(el, binding, vnode, prevVnode) { }, // 绑定元素的父组件更新前调用 beforeUpdate(el, binding, vnode, prevVnode) { }, // 在绑定元素的父组件 // 及他自己的所有子节点都更新后调用 updated(el, binding, vnode, prevVnode) { }, // 绑定元素的父组件卸载前调用 beforeUnmount(el, binding, vnode, prevVnode) { }, // 绑定元素的父组件卸载后调用 unmounted(el, binding, vnode, prevVnode) { } }
-
钩子函数参数:
- el:指令所绑定的元素,可以用来直接操作 DOM;
- binding:一个对象,包含以下 property:
- value:指令的绑定值,例如:v-my-directive=“1 + 1” 中,绑定值为 2;
- oldValue:指令绑定的前一个值,仅在 update 和 componentUpdated 钩子中可用,无论值是否改变都可用;
- arg:传给指令的参数,可选,例如 v-my-directive:foo 中,参数为 “foo”。
- modifiers:一个包含修饰符的对象,例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true };
- instance:使用该指令的组件实例;
- dir:指令的定义对象;
- vnode:Vue 编译生成的虚拟节点;
- prevNode:之前的渲染中代表指令所绑定元素的 VNode,仅在 update 和 componentUpdated 钩子中可用;
-
举例:
<!-- 像下面这样使用指令 --> <div v-example:foo.bar="baz"> // binding 参数会是一个这样的对象 { arg: 'foo', modifiers: { bar: true }, value: /* `baz` 的值 */, oldValue: /* 上一次更新时 `baz` 的值 */ }
批量挂载自定义指令
-
手动方式
JavaScriptJavaScript// main.js import { createApp } from 'vue'; import App from '@/App'; import Directives from '/directives/index.ts'; const app = createApp(App); app.use(Directives); app.mount('#app');
// directives/index.js import copy from './copy'; import screenfull from './screenfull'; import ellipsis from './ellipsis'; import backtop from './backtop'; import resize from './resize'; import debounce from './debounce'; import permission from './permission'; import waterMarker from './waterMarker'; // 自定义指令 const directives = { copy, screenfull, ellipsis, backtop, resize, debounce, permission, waterMarker, }; export default { install(Vue) { Object.keys(directives).forEach((key) => { Vue.directive(key, directives[key]); }); }, };
案例
点击外部指令 v-click-outside
import type { Directive, DirectiveBinding } from "vue";
interface ClickOutsideBinding extends DirectiveBinding {
value: (e: Event) => void;
}
const vClickOutside: Directive = {
beforeMount(el: HTMLElement, binding: ClickOutsideBinding) {
el._clickOutsideHandler = (event: Event) => {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value(event);
}
};
document.addEventListener("click", el._clickOutsideHandler);
},
unmounted(el: HTMLElement) {
if (el._clickOutsideHandler) {
document.removeEventListener("click", el._clickOutsideHandler);
}
},
};
declare global {
interface HTMLElement {
_clickOutsideHandler?: (e: Event) => void;
}
}
export default vClickOutside;
响应缩放指令 v-resize
可以监听绑定了 v-resize 指令的元素的尺寸
import type { Directive, DirectiveBinding } from "vue";
interface ResizeObserverEntry {
target: Element;
contentRect: DOMRectReadOnly;
}
interface ResizeDirectiveBinding extends DirectiveBinding {
value: (entries: ResizeObserverEntry[]) => void;
}
let lastWidth = 0,
lastHeight = 0;
function checkResize(currentWidth, currentHeight) {
let flag = true;
if (currentWidth == lastWidth && currentHeight == lastHeight) {
flag = true;
} else {
lastWidth = currentWidth;
lastHeight = currentHeight;
flag = false;
}
return flag;
}
function handleResize(currentWidth, currentHeight, cb) {
if (!checkResize(currentWidth, currentHeight) && typeof cb == "function") {
cb();
}
}
const vResize: Directive = {
mounted(el: HTMLElement, binding: ResizeDirectiveBinding) {
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
console.log(
"新尺寸:",
entry.contentRect.width,
entry.contentRect.height
);
handleResize(
entry.contentRect.width,
entry.contentRect.height,
binding.value
);
}
});
observer.observe(el as Element);
el._resizeObserver = observer;
},
unmounted(el: HTMLElement) {
// el._resizeObserver?.unobserve(el); // 适用于只需要停止监听某个特定的元素时
el._resizeObserver?.disconnect(); // 适用于需要完全停止所有元素的监听时
},
};
declare global {
interface HTMLElement {
_resizeObserver?: ResizeObserver;
}
}
export default vResize;
拖拽指令 (v-draggable)
import type { Directive } from "vue";
interface DraggableOptions {
axis?: "x" | "y" | "both";
handle?: HTMLElement | null;
}
const vDraggable: Directive<HTMLElement, DraggableOptions | undefined> = {
mounted(el, binding) {
const options = binding.value || {};
const handle = options.handle || el;
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
handle.style.cursor = "move";
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
initialX = el.offsetLeft;
initialY = el.offsetTop;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (options.axis !== "y") {
el.style.left = `${initialX + dx}px`;
}
if (options.axis !== "x") {
el.style.top = `${initialY + dy}px`;
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
handle.addEventListener("mousedown", onMouseDown);
el._draggableCleanup = () => {
handle.removeEventListener("mousedown", onMouseDown);
};
},
unmounted(el) {
el._draggableCleanup?.();
},
};
declare global {
interface HTMLElement {
_draggableCleanup?: () => void;
}
}
export default vDraggable;
权限控制指令 v-permission
import { useAccount } from "@/pinia/modules/account";
import type { Directive, DirectiveBinding } from "vue";
interface PermissionBinding extends DirectiveBinding {
value: string | string[];
}
const vPermission: Directive = {
beforeMount(el: HTMLElement, binding: PermissionBinding) {
const { permissionList } = useAccount();
const permissions = Array.isArray(binding.value)
? binding.value
: [binding.value];
const hasPermission = permissions.some((permission) =>
permissionList.includes(permission)
);
if (!hasPermission) {
el.style.display = "none";
el._permissionOriginalDisplay = el.style.display;
}
},
updated(el: HTMLElement, binding: PermissionBinding) {
const { permissionList } = useAccount();
const permissions = Array.isArray(binding.value)
? binding.value
: [binding.value];
const hasPermission = permissions.some((permission) =>
permissionList.includes(permission)
);
if (hasPermission) {
el.style.display = el._permissionOriginalDisplay || "";
} else {
el.style.display = "none";
}
},
};
declare global {
interface HTMLElement {
_permissionOriginalDisplay?: string;
}
}
export default vPermission;
复制文本指令 v-copy
import { ElMessage } from "element-plus";
import type { Directive, DirectiveBinding } from "vue";
interface CopyBinding extends DirectiveBinding {
value: string | (() => string);
}
const vCopy: Directive = {
mounted(el: HTMLElement, binding: CopyBinding) {
el._copyHandler = () => {
const text =
typeof binding.value === "function" ? binding.value() : binding.value;
navigator.clipboard
.writeText(text)
.then(() => {
ElMessage.success("复制成功!");
})
.catch((err) => {
console.error("复制失败:", err);
});
};
el.addEventListener("click", el._copyHandler);
el.style.cursor = "copy";
},
unmounted(el: HTMLElement) {
if (el._copyHandler) {
el.removeEventListener("click", el._copyHandler);
}
},
};
declare global {
interface HTMLElement {
_copyHandler?: () => void;
}
}
export default vCopy;
防抖指令 v-debounce
import type { Directive, DirectiveBinding } from "vue";
interface DebounceOptions {
event: string;
delay?: number;
}
const vDebounce: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<Function>) {
const defaultEvent = "click";
const defaultDelay = 500;
const options: DebounceOptions = {
event: binding.arg || defaultEvent,
delay: binding.arg ? parseInt(binding.arg.split("_")[1]) : defaultDelay,
};
let timeout: number;
el._debounceHandler = (e: Event) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
binding.value(e);
}, options.delay);
};
el.addEventListener(options.event, el._debounceHandler);
},
unmounted(el: HTMLElement) {
if (el._debounceHandler) {
const event = el._debounceHandler?.event || "click";
el.removeEventListener(event, el._debounceHandler);
}
},
};
declare global {
interface HTMLElement {
_debounceHandler?: ((e: Event) => void) & { event?: string };
}
}
export default vDebounce;
聚焦指令 v-focus
import type { Directive, DirectiveBinding } from "vue";
const vFocus: Directive = {
mounted(el: HTMLElement) {
el.focus();
},
updated(el: HTMLElement, binding: DirectiveBinding<boolean>) {
if (binding.value) {
el.focus();
}
},
};
export default vFocus;
无限滚动指令 v-infinite-scroll
import type { Directive, DirectiveBinding } from "vue";
interface InfiniteScrollOptions {
distance: number;
disabled?: boolean;
delay?: number;
}
const vInfiniteScroll: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<() => void>) {
const options: InfiniteScrollOptions = {
distance: 50,
disabled: false,
delay: 200,
...(binding.value as any),
};
let isLoading = false;
const onScroll = () => {
if (options.disabled || isLoading) return;
const scrollTop = el.scrollTop;
const scrollHeight = el.scrollHeight;
const clientHeight = el.clientHeight;
if (scrollHeight - (scrollTop + clientHeight) <= options.distance) {
isLoading = true;
setTimeout(() => {
binding.value();
isLoading = false;
}, options.delay);
}
};
el.addEventListener("scroll", onScroll);
el._infiniteScrollCleanup = () => {
el.removeEventListener("scroll", onScroll);
};
},
unmounted(el: HTMLElement) {
el._infiniteScrollCleanup?.();
},
};
declare global {
interface HTMLElement {
_infiniteScrollCleanup?: () => void;
}
}
export default vInfiniteScroll;
工具提示指令 v-tooltip
import type { Directive, DirectiveBinding } from "vue";
interface TooltipOptions {
content: string;
placement?: "top" | "bottom" | "left" | "right";
trigger?: "hover" | "click";
}
const vTooltip: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<TooltipOptions | string>) {
const options =
typeof binding.value === "string"
? { content: binding.value }
: binding.value;
const tooltip = document.createElement("div");
tooltip.className = "vue-tooltip";
tooltip.textContent = options.content;
Object.assign(tooltip.style, {
position: "absolute",
background: "#333",
color: "#fff",
padding: "5px 10px",
borderRadius: "4px",
fontSize: "12px",
zIndex: "9999",
display: "none",
});
document.body.appendChild(tooltip);
const positionTooltip = () => {
const rect = el.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let top = 0;
let left = 0;
switch (options.placement || "top") {
case "top":
top = rect.top - tooltipRect.height - 5;
left = rect.left + rect.width / 2 - tooltipRect.width / 2;
break;
case "bottom":
top = rect.bottom + 5;
left = rect.left + rect.width / 2 - tooltipRect.width / 2;
break;
case "left":
top = rect.top + rect.height / 2 - tooltipRect.height / 2;
left = rect.left - tooltipRect.width - 5;
break;
case "right":
top = rect.top + rect.height / 2 - tooltipRect.height / 2;
left = rect.right + 5;
break;
}
tooltip.style.top = `${top + window.scrollY}px`;
tooltip.style.left = `${left + window.scrollX}px`;
};
const showTooltip = () => {
positionTooltip();
tooltip.style.display = "block";
};
const hideTooltip = () => {
tooltip.style.display = "none";
};
if ((options.trigger || "hover") === "hover") {
el.addEventListener("mouseenter", showTooltip);
el.addEventListener("mouseleave", hideTooltip);
tooltip.addEventListener("mouseenter", showTooltip);
tooltip.addEventListener("mouseleave", hideTooltip);
} else {
el.addEventListener("click", () => {
if (tooltip.style.display === "block") {
hideTooltip();
} else {
showTooltip();
}
});
}
el._tooltipCleanup = () => {
document.body.removeChild(tooltip);
el.removeEventListener("mouseenter", showTooltip);
el.removeEventListener("mouseleave", hideTooltip);
el.removeEventListener("click", showTooltip);
};
},
unmounted(el: HTMLElement) {
el._tooltipCleanup?.();
},
};
declare global {
interface HTMLElement {
_tooltipCleanup?: () => void;
}
}
export default vTooltip;
图片懒加载指令 v-lazy
import type { Directive, DirectiveBinding } from "vue";
const vLazy: Directive = {
mounted(el: HTMLImageElement, binding: DirectiveBinding<string>) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value;
observer.unobserve(el);
}
});
},
{
rootMargin: "0px",
threshold: 0.1,
}
);
observer.observe(el);
el._lazyObserver = observer;
},
unmounted(el: HTMLImageElement) {
el._lazyObserver?.unobserve(el);
},
};
declare global {
interface HTMLElement {
_lazyObserver?: IntersectionObserver;
}
}
export default vLazy;
全屏指令 v-screenfull
import type { Directive, DirectiveBinding } from "vue";
interface ScreenfullElement extends HTMLElement {
_screenfullHandler?: () => void;
}
const vScreenfull: Directive = {
mounted(el: ScreenfullElement, binding: DirectiveBinding<boolean | undefined>) {
const isFullscreen = binding.value ?? true;
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
el.requestFullscreen().catch((err) => {
console.error("全屏失败:", err);
});
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
};
el._screenfullHandler = toggleFullscreen;
el.addEventListener("click", el._screenfullHandler);
if (isFullscreen && !document.fullscreenElement) {
el.requestFullscreen().catch((err) => {
console.error("初始全屏失败:", err);
});
}
},
unmounted(el: ScreenfullElement) {
if (el._screenfullHandler) {
el.removeEventListener("click", el._screenfullHandler);
}
},
};
export default vScreenfull;
文字超出省略指令 v-ellipsis
import type { Directive } from "vue";
interface EllipsisOptions {
line?: number; // 行数,默认1行
content?: string; // 自定义内容
}
const vEllipsis: Directive<HTMLElement, EllipsisOptions | string | undefined> = {
mounted(el, binding) {
const options: EllipsisOptions =
typeof binding.value === "object"
? binding.value
: { content: binding.value };
const line = options.line || 1;
const content = options.content || el.textContent || "";
el.style.overflow = "hidden";
el.style.textOverflow = "ellipsis";
el.style.display = "-webkit-box";
el.style.webkitLineClamp = String(line);
el.style.webkitBoxOrient = "vertical";
if (options.content) {
el.textContent = content;
}
},
updated(el, binding) {
const options: EllipsisOptions =
typeof binding.value === "object"
? binding.value
: { content: binding.value };
if (options.content) {
el.textContent = options.content;
}
},
};
export default vEllipsis;
回到顶部指令 v-backtop
import type { Directive } from "vue";
interface BacktopOptions {
visibilityHeight?: number; // 滚动多少距离显示按钮
right?: string; // 距离右侧距离
bottom?: string; // 距离底部距离
transition?: string; // 过渡效果
scrollContainer?: HTMLElement | string; // 滚动条容器
}
interface BacktopElement extends HTMLElement {
_backtopHandler?: () => void;
_backtopScrollHandler?: () => void;
_scrollContainer: HTMLElement;
}
const vBacktop: Directive<BacktopElement, BacktopOptions | undefined> = {
mounted(el, binding) {
const options: BacktopOptions = binding.value || {};
const {
visibilityHeight = 200,
right = "40px",
bottom = "40px",
transition = "all .3s",
} = options;
const scrollContainer =
(typeof options.scrollContainer == "string"
? document.getElementById(options.scrollContainer)
: options.scrollContainer) ?? window;
// 设置按钮样式
Object.assign(el.style, {
position: "fixed",
right,
bottom,
cursor: "pointer",
transition,
opacity: "0",
visibility: "hidden",
});
// 滚动事件处理
const scrollHandler = () => {
const scrollTop = scrollContainer==window?(document.documentElement || document.body).scrollTop:(scrollContainer as HTMLElement).scrollTop;
if (scrollTop > visibilityHeight) {
el.style.opacity = "1";
el.style.visibility = "visible";
} else {
el.style.opacity = "0";
el.style.visibility = "hidden";
}
};
// 点击返回顶部
const clickHandler = () => {
scrollContainer.scrollTo({
top: 0,
behavior: "smooth",
});
};
el._scrollContainer = scrollContainer as HTMLElement;
el._backtopScrollHandler = scrollHandler;
el._backtopHandler = clickHandler;
scrollContainer.addEventListener("scroll", scrollHandler);
el.addEventListener("click", clickHandler);
// 初始检查
scrollHandler();
},
unmounted(el: BacktopElement) {
if (el._backtopScrollHandler && el._scrollContainer) {
el._scrollContainer.removeEventListener(
"scroll",
el._backtopScrollHandler
);
}
if (el._backtopHandler) {
el.removeEventListener("click", el._backtopHandler);
}
},
};
export default vBacktop;
水印指令 vue-waterMarker
import type { Directive, DirectiveBinding } from "vue";
interface WatermarkOptions {
text?: string | string[]; // 水印文字
font?: string; // 字体样式
color?: string; // 字体颜色
opacity?: number; // 透明度
angle?: number; // 旋转角度
gap?: [number, number]; // 水印间距 [水平, 垂直]
}
const vWatermark: Directive<HTMLElement, WatermarkOptions | string> = {
mounted(el, binding) {
createWatermark(el, binding);
},
updated(el, binding) {
createWatermark(el, binding);
},
};
function createWatermark(
el: HTMLElement,
binding: DirectiveBinding<WatermarkOptions | string>
) {
// 清除现有水印
const existingWatermark = el.querySelector(".vue-watermark");
if (existingWatermark) {
el.removeChild(existingWatermark);
}
const options: WatermarkOptions =
typeof binding.value === "string"
? { text: binding.value }
: binding.value || {};
const {
text = "Watermark",
font = "16px Microsoft YaHei",
color = "rgba(128, 128, 128, 0.3)",
opacity = 0.3,
angle = -20,
gap = [100, 100],
} = options;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 测量文本宽度
ctx.font = font;
const texts = Array.isArray(text) ? text : [text];
let maxWidth = 0;
texts.forEach((t) => {
const metrics = ctx.measureText(t);
maxWidth = Math.max(maxWidth, metrics.width);
});
const canvasSize = Math.max(maxWidth, 200) * 1.5;
canvas.width = canvasSize + gap[0];
canvas.height = canvasSize * texts.length + gap[1];
// 绘制水印
ctx.font = font;
ctx.fillStyle = color;
ctx.globalAlpha = opacity;
ctx.rotate((angle * Math.PI) / 180);
texts.forEach((t, i) => {
ctx.fillText(t, 0, (i + 1) * (canvasSize / texts.length));
});
// 创建水印背景
const watermark = document.createElement("div");
watermark.className = "vue-watermark";
watermark.style.position = "absolute";
watermark.style.top = "0";
watermark.style.left = "0";
watermark.style.width = "100%";
watermark.style.height = "100%";
watermark.style.pointerEvents = "none";
watermark.style.backgroundImage = `url(${canvas.toDataURL()})`;
watermark.style.backgroundRepeat = "repeat";
el.style.position = "relative";
el.appendChild(watermark);
}
export default vWatermark;
快速引入外部页面指令 v-iframe
import type { Directive, DirectiveBinding } from "vue";
interface IframeOptions {
src: string;
width?: string;
height?: string;
loading?: "eager" | "lazy";
sandbox?: string[];
allow?: string[];
onLoad?: (iframe: HTMLIFrameElement) => void;
onError?: (error: Event) => void;
}
interface IframeElement extends HTMLElement {
_iframe?: HTMLIFrameElement;
_iframeResizeObserver?: ResizeObserver;
}
const vIframe: Directive<IframeElement, string | IframeOptions> = {
mounted(el, binding) {
createIframe(el, binding);
},
updated(el, binding) {
createIframe(el, binding);
},
unmounted(el) {
cleanupIframe(el);
},
};
function createIframe(
el: IframeElement,
binding: DirectiveBinding<string | IframeOptions>
) {
// 清理现有 iframe
cleanupIframe(el);
// 解析配置
const options: IframeOptions =
typeof binding.value === "string" ? { src: binding.value } : binding.value;
if (!options.src) {
console.error("v-iframe: src is required");
return;
}
// 创建 iframe 元素
const iframe = document.createElement("iframe");
iframe.src = options.src;
iframe.style.border = "none";
iframe.style.width = options.width || "100%";
iframe.style.height = options.height || "100%";
iframe.loading = options.loading || "lazy";
// 设置安全沙箱属性
if (options.sandbox) {
iframe.sandbox.add(...options.sandbox);
} else {
// 默认沙箱限制
iframe.sandbox.add("allow-same-origin", "allow-scripts", "allow-popups");
}
// 设置允许权限
if (options.allow) {
iframe.allow = options.allow.join("; ");
}
// 事件处理
iframe.onload = () => {
options.onLoad?.(iframe);
setupAutoHeight(el, iframe);
};
iframe.onerror = (error) => {
options.onError?.(error);
console.error("v-iframe: Failed to load", options.src, error);
};
el._iframe = iframe;
el.appendChild(iframe);
}
function setupAutoHeight(el: IframeElement, iframe: HTMLIFrameElement) {
try {
// 尝试自动调整高度
const resizeObserver = new ResizeObserver(() => {
if (iframe.contentWindow?.document?.body) {
iframe.style.height = `${iframe.contentWindow.document.body.scrollHeight}px`;
}
});
resizeObserver.observe(iframe);
el._iframeResizeObserver = resizeObserver;
} catch (error) {
console.warn(
"v-iframe: Auto height adjustment failed due to cross-origin restrictions"
);
}
}
function cleanupIframe(el: IframeElement) {
if (el._iframe) {
el.removeChild(el._iframe);
delete el._iframe;
}
if (el._iframeResizeObserver) {
el._iframeResizeObserver.disconnect();
delete el._iframeResizeObserver;
}
}
export default vIframe;
css 工程化👉 抽离 css 文件
上一篇
目录
- 1. 自定义指令
- 2. 案例
- 2.1. 点击外部指令 v-click-outside
- 2.2. 响应缩放指令 v-resize
- 2.3. 拖拽指令 (v-draggable)
- 2.4. 权限控制指令 v-permission
- 2.5. 复制文本指令 v-copy
- 2.6. 防抖指令 v-debounce
- 2.7. 聚焦指令 v-focus
- 2.8. 无限滚动指令 v-infinite-scroll
- 2.9. 工具提示指令 v-tooltip
- 2.10. 图片懒加载指令 v-lazy
- 2.11. 全屏指令 v-screenfull
- 2.12. 文字超出省略指令 v-ellipsis
- 2.13. 回到顶部指令 v-backtop
- 2.14. 水印指令 vue-waterMarker
- 3. 快速引入外部页面指令 v-iframe