Cloudflare Turnstile 在设计上已经把无感作为默认目标,但把它放进 Vue 3 这样的单页应用里,最容易出问题的不是算法本身,而是脚本加载时机与组件卸载时机不匹配。Turnstile 官方推荐两种使用方式:自动渲染和显式渲染。在 SPA 中自动渲染会随路由反复扫描 DOM,容易产生重复挂载;而显式渲染虽然可控,却需要自行处理脚本异步加载、widget 实例销毁和 token 刷新。下面这篇内容会围绕这些问题,给出一个可以直接复用的工程化方案。

SPA 场景下为什么需要重新审视 Turnstile 的加载方式
传统多页应用中,每次刷新页面都会重新加载 Cloudflare 的 Turnstile 脚本,验证组件渲染一次后随页面销毁。但 Vue 3 应用大多采用客户端路由,页面切换不会触发完整的 document 刷新。如果仍然把 <script> 标签直接挂在 index.html 中并依赖自动渲染,就会出现第一个页面验证框正常,切到第二个页面后验证框不出现、或者同时存在多个验证框的情况。根本原因在于 Turnstile 的自动渲染脚本只会在页面加载后扫描一次带有 cf-turnstile class 的元素,之后的 DOM 新增节点并不会自动处理。
显式渲染可以解决这个问题,但需要额外封装脚本加载器。脚本加载器要保证全局只执行一次脚本注入,并且在脚本加载完成前,所有等待渲染的组件都能拿到同一个 window.turnstile 实例。如果不做单例化处理,多个组件同时触发加载就会向 document.head 插入多个相同 script 标签,虽然浏览器会缓存脚本文件,但 onload 回调会多次执行,进而导致重复初始化逻辑。因此,工程化的第一步就是把脚本加载过程收敛到一个可复用的函数中。
另外,Turnstile 的 render() 方法返回一个 widgetId,后续的 reset、remove 都必须依赖这个 ID。如果组件卸载时没有调用 remove(),DOM 节点虽然被 Vue 移除了,但 Turnstile 内部仍保留着对容器的引用,可能造成内存泄漏,甚至在路由回退时出现 token 残留。这些细节在简单 demo 中不易察觉,但放到长期运行的表单页面里就会成为偶发问题。
封装一个与 Vue 生命周期绑定的 Turnstile 组件
最直接的工程化做法是创建一个 TurnstileWidget.vue 组件,内部只负责三件事:等待脚本就绪、渲染 widget、在卸载前移除 widget。组件通过 props 接收 sitekey、theme、size 等配置,通过 emit 对外抛出验证成功、失败、过期等事件。这样业务组件就不需要关心脚本加载细节,只需要在模板中放置一个 <TurnstileWidget /> 并监听事件即可。
下面是一个完整的组件封装示例。脚本加载器使用 Promise 缓存,确保全局只加载一次。容器元素通过 ref 获取,在 onMounted 中异步渲染。回调函数统一使用 emit 转发,避免在组件内部写死业务逻辑。卸载阶段调用 window.turnstile.remove(widgetId) 释放资源。
// composables/use-turnstile-script.js
let scriptPromise = null;
export function loadTurnstileScript() {
if (window.turnstile) {
return Promise.resolve(window.turnstile);
}
if (scriptPromise) {
return scriptPromise;
}
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.onload = () => resolve(window.turnstile);
script.onerror = () => reject(new Error('Turnstile script failed to load'));
document.head.appendChild(script);
});
return scriptPromise;
}
这里没有使用动态 import 或 npm 包,原因是 Turnstile 的浏览器端脚本需要依赖全局上下文,并且 Cloudflare 会根据请求频率调整脚本内容。直接加载官方地址能保证 API 兼容性。当然,如果环境要求代理静态资源,也可以把脚本下载后部署到自己的 CDN,但要同步更新域名白名单,否则验证请求可能被拦截。
接下来是 Vue 组件。组件模板中只保留一个容器 <div>,因为 Turnstile 会向该容器内部注入 iframe。不要给容器添加额外的子节点,否则 render() 可能覆盖已有内容。容器的 ref 名称最好固定,避免与组件外部冲突。
<template>
<div ref="containerRef"></div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { loadTurnstileScript } from '../composables/use-turnstile-script';
const props = defineProps({
siteKey: { type: String, required: true },
theme: { type: String, default: 'auto' },
size: { type: String, default: 'normal' },
action: { type: String, default: '' },
cData: { type: String, default: '' }
});
const emit = defineEmits(['success', 'error', 'expired', 'timeout']);
const containerRef = ref(null);
let widgetId = null;
async function renderWidget() {
if (!containerRef.value) return;
const turnstile = await loadTurnstileScript();
widgetId = turnstile.render(containerRef.value, {
sitekey: props.siteKey,
theme: props.theme,
size: props.size,
action: props.action,
cData: props.cData,
callback: (token) => emit('success', token),
'error-callback': () => emit('error'),
'expired-callback': () => emit('expired'),
'timeout-callback': () => emit('timeout')
});
}
onMounted(renderWidget);
onBeforeUnmount(() => {
if (widgetId !== null && window.turnstile) {
window.turnstile.remove(widgetId);
}
});
</script>
这个组件本身没有引入额外的状态管理,token 通过 success 事件抛给父组件。父组件拿到 token 后可以随表单一起提交到后端。注意 size 属性虽然官方支持 compact 和 normal,但在某些布局中 compact 会挤压高度,建议在响应式表单中通过 CSS 控制容器尺寸,而不是频繁切换 size。
用组合式函数管理 token 状态与重置逻辑
组件封装已经能满足大多数表单场景,但当多个页面需要复用验证逻辑时,组合式函数会提供更好的抽象能力。通过 useTurnstile 这类函数,业务组件可以拿到 token、isReady、error 等响应式状态,以及 reset 方法,在需要刷新验证时直接调用即可。这样 token 状态的生命周期与组件实例保持一致,不会出现跨组件的状态泄漏。
组合式函数的核心思路与组件封装类似,但把状态暴露出去,方便在提交表单前做校验。例如提交按钮可以监听 isReady 和 token,只有 token 非空时才允许提交。下面给出一个实现,代码中通过 onMounted 自动启动渲染,也可以让调用方手动调用 mount。
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { loadTurnstileScript } from './use-turnstile-script';
export function useTurnstile(containerRef, options = {}) {
const token = ref('');
const widgetId = ref(null);
const isReady = ref(false);
const error = ref(null);
async function mount() {
try {
const turnstile = await loadTurnstileScript();
if (!containerRef.value || widgetId.value !== null) return;
widgetId.value = turnstile.render(containerRef.value, {
sitekey: options.siteKey,
theme: options.theme || 'auto',
size: options.size || 'normal',
action: options.action || '',
cData: options.cData || '',
callback: (value) => {
token.value = value;
options.onSuccess && options.onSuccess(value);
},
'error-callback': (err) => {
error.value = err;
options.onError && options.onError(err);
},
'expired-callback': () => {
token.value = '';
options.onExpired && options.onExpired();
}
});
isReady.value = true;
} catch (e) {
error.value = e;
}
}
function reset() {
if (widgetId.value !== null && window.turnstile) {
window.turnstile.reset(widgetId.value);
token.value = '';
}
}
onMounted(mount);
onBeforeUnmount(() => {
if (widgetId.value !== null && window.turnstile) {
window.turnstile.remove(widgetId.value);
}
});
return { token, widgetId, isReady, error, reset, mount };
}
这里有一个容易忽略的点:如果父组件因为 v-if 切换导致容器频繁创建和销毁,onMounted 会重复执行,但 widgetId.value 的保护可以避免同一容器被重复渲染。然而由于组件卸载时已经调用了 remove(),下一次挂载时容器是新的 DOM 节点,因此 reset 之前应确保容器已经存在。调用方如果需要在 token 过期后重新验证,可以在 expired-callback 中自动调用 reset,但要注意避免死循环,因为 reset 会再次触发验证流程,如果后端配置了严格的 siteverify 策略,可能需要用户重新交互。
路由切换、token 过期与多实例的工程化细节
在实际项目中,Turnstile 往往会出现在登录、注册、支付等高价值操作页面。这些页面通常不是应用入口,用户可能从任意路由进入。如果脚本加载器没有做全局单例,每次进入页面都会重新插入 script 标签,虽然 Turnstile 内部有防重复机制,但控制台会出现网络请求重复或 console 警告。将脚本加载收敛为单例后,还需要考虑脚本加载失败后的重试策略。一个简单做法是在 Promise 被 reject 时把缓存的 Promise 置空,允许下一次进入页面重新加载。
Token 过期是另一个高频问题。Turnstile 生成的 token 默认有效期大约是 300 秒,超过这个时间提交到后端会被 Cloudflare 拒绝。前端不能只依赖用户的提交动作,最好在表单提交前再检查一次 token 的获取时间。可以用组合式函数返回的 token 之外再维护一个 tokenIssuedAt 参数,在提交时判断是否超过 240 秒,如果快过期就主动调用 turnstile.reset() 并提示用户稍等片刻。不要在提交回调中直接调用 reset 并立即发送请求,因为 reset 是异步的,此时 token 还未更新,会导致后端验证失败。
多实例场景同样值得注意。一个页面理论上可以放置多个 Turnstile widget,但每个 widget 必须对应不同的容器和不同的 widgetId。如果多个 widget 使用相同的 sitekey 但不同的 action,Cloudflare 会根据 action 区分验证场景。封装组件时要确保每个实例的 widgetId 是独立的,不能放在模块级变量中共享。上面的组合式函数每次调用都会创建独立的 widgetId 和 token,因此可以安全地在同一页面使用多个实例。
测试方面,Turnstile 提供了一个测试用的 sitekey,例如官方文档中的 1x00000000000000000000AA 和对应的 secret,可以在开发环境中始终通过验证。但要注意测试 sitekey 不能用于生产,否则任何人都可以绕过验证。工程化方案中可以将 sitekey 配置进环境变量,在构建时根据环境注入。后端验证必须使用 secret key 调用 https://challenges.cloudflare.com/turnstile/v0/siteverify,并校验返回的 success 字段和 hostname,不能只依赖前端 token 的存在。
最后,如果项目使用 TypeScript,建议为 window.turnstile 声明全局类型。由于官方没有提供完整的浏览器端类型定义,可以在项目根目录添加 turnstile.d.ts,声明 render、reset、remove 等方法,这样在组合式函数中调用时可以获得类型提示。类型声明文件不属于可点击链接,直接展示即可。
interface TurnstileOptions {
sitekey: string;
action?: string;
cData?: string;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact';
callback?: (token: string) => void;
'error-callback'?: (error: unknown) => void;
'expired-callback'?: () => void;
'timeout-callback'?: () => void;
}
interface Turnstile {
render(container: HTMLElement, options: TurnstileOptions): string;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
interface Window {
turnstile?: Turnstile;
}
总结来说,Vue 3 中工程化 Turnstile 的核心不是引入一个 npm 包,而是把脚本加载、组件生命周期和 token 状态三者解耦并统一管理。组件封装适合快速接入,组合式函数适合需要灵活控制状态的复杂场景,两者可以同时存在。只要把卸载和重置逻辑写清楚,Cloudflare Turnstile 的无感验证在 SPA 中也能保持稳定和可维护。
Vue 3Cloudflare Turnstile无感验证修改时间:2026-10-02 21:02:45