我们在项目开发过程中经常会遇到用户发送验证码后唤起图片滑块验证,验证成功后触发发送验证码事件(不处理发送验证码逻辑父组件去监听)进而读秒等流程,特此封装了该组件。
1、html部分
<template>
<div>
<div v-if="countDownNum < 1" @click="_sendCode" slot="suffix" class="modifyColor"
:class=" _disabled ? 'disabled' : 'hover7'">
发送验证码
</div>
<div v-else class="mt6 modifyColor">
{
{ countDownNum }}S
</div>
<div :id="cacheKey"></div>
</div>
</template>
2、css部分
.modifyColor {
color: #4C70DF;
padding-right:15px;
}
.mt6{
margin-top: 6px;
}
3、js部分
(1)将以下内容放在public文件夹中取名为yidun.js
(function (global, factory) {
'use strict'
if (typeof module === 'object' && typeof module.exports === 'object') {
module.exports = global.document ? factory(global) : function (w) {
if (!w.document) {
throw new Error('initNECaptchaWithFallback requires a window with a document')
}
return factory(w)
}
} else if (typeof define === 'function' && define.amd) {
define('initNECaptchaWithFallback', [], function () {
return factory(global)
})
} else {
global.initNECaptchaWithFallback = factory(global)
}
}(typeof window !== 'undefined' ? window : this, function (window) {
'use strict'
var errorCallbackCount = 0
// 常量
var DEFAULT_VALIDATE = 'QjGAuvoHrcpuxlbw7cp4WnIbbjzG4rtSlpc7EDovNHQS._ujzPZpeCInSxIT4WunuDDh8dRZYF2GbBGWyHlC6q5uEi9x-TXT9j7J705vSsBXyTar7aqFYyUltKYJ7f4Y2TXm_1Mn6HFkb4M7URQ_rWtpxQ5D6hCgNJYC0HpRE7.2sttqYKLoi7yP1KHzK-PptdHHkVwb77cwS2EJW7Mj_PsOtnPBubTmTZLpnRECJR99dWTVC11xYG0sx8dJNLUxUFxEyzTfX4nSmQz_T5sXATRKHtVAz7nmV0De5unmflfAlUwMGKlCT1khBtewlgN5nHvyxeD8Z1_fPVzi9oznl-sbegj6lKfCWezmLcwft8.4yaVh6SlzXJq-FnSK.euq9OBd5jYc82ge2_hEca1fGU--SkPRzgwkzew4O4qjdS2utdPwFONnhKAIMJRPUmCV4lPHG1OeRDvyNV8sCnuFMw7leasxIhPoycl4pm5bNy70Z1laozEGJgItVNr3' // 默认validate
var FALLBACK_LANG = {
'zh-CN': '前方拥堵,已自动跳过验证',
'en': 'captcha error,Verified automatically'
}
var CACHE_MIN = 1000 * 60 // 缓存时长单位,1分钟
var REQUEST_SCRIPT_ERROR = 502
var RESOURCE_CACHE = {}
// 工具函数
function loadScript(src, cb) {
var head = document.head || document.getElementsByTagName('head')[0]
var script = document.createElement('script')
cb = cb || function () {}
script.type = 'text/javascript'
script.charset = 'utf8'
script.async = true
script.src = src
if (!('onload' in script)) {
script.onreadystatechange = function () {
if (this.readyState !== 'complete' && this.readyState !== 'loaded') return
this.onreadystatechange = null
cb(null, script) // there is no way to catch loading errors in IE8
}
}
script.onload = function () {
this.onerror = this.onload = null
cb(null, script)
}
script.onerror = function () {
// because even IE9 works not like others
this.onerror = this.onload = null
cb(new Error('Failed to load ' + this.src), script)
}
head.appendChild(script)
}
function joinUrl(protocol, host, path) {
protocol = protocol || ''
host = host || ''
path = path || ''
if (protocol) {
protocol = protocol.replace(/:?\/{0,2}$/, '://')
}
if (host) {
var matched = host.match(/^([-0-9a-zA-Z.:]*)(\/.*)?/)
host = matched[1]
path = (matched[2] || '') + '/' + path
}!host && (protocol = '')
return protocol + host + path
}
function setDomText(el, value) {
if (value === undefined) return
var nodeType = el.nodeType
if (nodeType === 1 || nodeType === 11 || nodeType === 9) {
if (typeof el.textContent === 'string') {
el.textContent = value
} else {
el.innerText = value
}
}
}
function queryAllByClassName(selector, node) {
node = node || document
if (node.querySelectorAll) {
return node.querySelectorAll(selector)
}
if (!/^\.[^.]+$/.test(selector)) return []
if (node.getElementsByClassName) {
return node.getElementsByClassName(selector)
}
var children = node.getElementsByTagName('*')
var current
var result = []
var className = selector.slice(1)
for (var i = 0, l = children.length; i < l; i++) {
current = children[i]
if (~(' ' + current.className + ' ').indexOf(' ' + className + ' ')) {
result.push(current)
}
}
return result
}
function assert(condition, msg) {
if (!condition) throw new Error('[NECaptcha] ' + msg)
}
function isInteger(val) {
if (Number.isInteger) {
return Number.isInteger(val)
}
return typeof val === 'number' && isFinite(val) && Math.floor(val) === val
}
function isArray(val) {
if (Array.isArray) return Array.isArray(val)
return Object.prototype.toString.call(val) === '[object Array]'
}
function ObjectAssign() {
if (Object.assign) {
return Object.assign.apply(null, arguments)
}
var target = {}
for (var index = 1; index < arguments.length; index++) {
var source = arguments[index]
if (source != null) {
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key]
}
}
}
}
return target
}
function getTimestamp(msec) {
msec = !msec && msec !== 0 ? msec : 1
return parseInt((new Date()).valueOf() / msec, 10)
}
// 降级方案
function normalizeFallbackConfig(customConfig) {
var siteProtocol = window.location.protocol.replace(':', '')
var defaultConf = {
protocol: siteProtocol === 'http' ? 'http' : 'https',
lang: 'zh-CN',
errorFallbackCount: 3
}
var config = ObjectAssign({}, defaultConf, customConfig)
var errorFallbackCount = config.errorFallbackCount
assert(
errorFallbackCount === undefined || (isInteger(errorFallbackCount) && errorFallbackCount >= 1),
'errorFallbackCount must be an integer, and it\'s value greater than or equal one')
return config
}
function loadResource(config, cb) {
if (window.initNECaptcha) return cb(null)
function genUrl(server) {
var path = 'load.min.js'
var urls = []
if (isArray(server)) {
for (var i = 0, len = server.length; i < len; i++) {
urls.push(joinUrl(config.protocol, server[i], path))
}
} else {
var url = joinUrl(config.protocol, server, path)
urls = [url, url]
}
return urls
}
var urls = genUrl(config.staticServer || ['cstaticdun.126.net', 'cstaticdun1.126.net', 'cstatic.dun.163yun.com'])
function step(i) {
var url = urls[i] + '?v=' + getTimestamp(CACHE_MIN)
loadScript(url, function (err) {
if (err || !window.initNECaptcha) { // loadjs的全局变量
i = i + 1
if (i === urls.length) {
return cb(new Error('Failed to load script(' + url + ').' + (err ? err.message : 'unreliable script')))
}
return step(i)
}
return cb(null)
})
}
step(0)
}
/*
* entry: initNECaptchaWithFallback
* options:
* errorFallbackCount: 触发降级的错误次数,默认第三次错误降级
* defaultFallback: 是否开启默认降级
* onFallback: 自定义降级方案,参数为默认validate
*/
function initNECaptchaWithFallback(options, onload, onerror) {
var captchaIns = null
var config = normalizeFallbackConfig(options)
var defaultFallback = config.defaultFallback !== false
var langPkg = FALLBACK_LANG[config.lang === 'zh-CN' ? config.lang : 'en']
var storeKey = window.location.pathname + '_' + config.captchaId + '_NECAPTCHA_ERROR_COUNTS'
try {
errorCallbackCount = parseInt(localStorage.getItem(storeKey) || 0, 10)
} catch (error) {}
var fallbackFn = !defaultFallback ? config.onFallback || function () {} : function (validate) {
function setFallbackTip(instance) {
if (!instance) return
setFallbackTip(instance._captchaIns)
if (!instance.$el) return
var tipEles = queryAllByClassName('.yidun-fallback__tip', instance.$el)
if (!tipEles.length) return
// 确保在队列的最后
setTimeout(function () {
for (var i = 0, l = tipEles.length; i < l; i++) {
setDomText(tipEles[i], langPkg)
}
}, 0)
}
setFallbackTip(captchaIns)
config.onVerify && config.onVerify(null, {
validate: validate
})
}
var noFallback = !defaultFallback && !config.onFallback
var proxyOnError = function (error) {
errorCallbackCount++
if (errorCallbackCount < config.errorFallbackCount) {
try {
localStorage.setItem(storeKey, errorCallbackCount)
} catch (err) {}
onerror(error)
} else {
fallbackFn(DEFAULT_VALIDATE)
proxyRefresh()
noFallback && onerror(error)
}
}
var proxyRefresh = function () {
errorCallbackCount = 0
try {
localStorage.setItem(storeKey, 0)
} catch (err) {}
}
var triggerInitError = function (error) {
if (initialTimer && initialTimer.isError()) {
initialTimer.resetError()
return
}
initialTimer && initialTimer.resetTimer()
noFallback ? onerror(error) : proxyOnError(error)
}
config.onError = function (error) {
if (initialTimer && initialTimer.isError()) {
initialTimer.resetError()
}
proxyOnError(error)
}
config.onDidRefresh = function () {
if (initialTimer && initialTimer.isError()) {
initialTimer.resetError()
}
proxyRefresh()
}
var initialTimer = options.initTimeoutError ? options.initTimeoutError(proxyOnError) : null // initialTimer is only for mobile.html
var loadResolve = function () {
window.initNECaptcha(config, function (instance) {
if (initialTimer && initialTimer.isError()) return
initialTimer && initialTimer.resetTimer()
captchaIns = instance
onload && onload(instance)
}, triggerInitError)
}
var cacheId = 'load-queue'
if (!RESOURCE_CACHE[cacheId]) {
RESOURCE_CACHE[cacheId] = {
rejects: [],
resolves: [],
status: 'error'
}
}
if (RESOURCE_CACHE[cacheId].status === 'error') {
RESOURCE_CACHE[cacheId].status = 'pending'
loadResource(config, function (error) {
if (error) {
var err = new Error()
err.code = REQUEST_SCRIPT_ERROR
err.message = config.staticServer + '/load.min.js error'
var rejects = RESOURCE_CACHE[cacheId].rejects
for (var i = 0, iLen = rejects.length; i < iLen; i++) {
rejects.pop()(err)
}
RESOURCE_CACHE[cacheId].status = 'error'
} else {
RESOURCE_CACHE[cacheId].status = 'done'
var resolves = RESOURCE_CACHE[cacheId].resolves
for (var j = 0, jLen = resolves.length; j < jLen; j++) {
resolves.pop()()
}
}
})
} else if (RESOURCE_CACHE[cacheId].status === 'done') {
loadResolve()
}
if (RESOURCE_CACHE[cacheId].status === 'pending') {
RESOURCE_CACHE[cacheId].rejects.push(function loadReject(err) {
triggerInitError(err)
})
RESOURCE_CACHE[cacheId].resolves.push(loadResolve)
}
}
return initNECaptchaWithFallback
}))
(2)在index.html入口文件中引入yidun.js
<script src="<%= BASE_URL %>js/yidun.js"></script>
(3)组件js部分
<script lang="ts">
import {Component, Prop, Vue, Watch} from "vue-property-decorator";
import {getStorage, setStorage} from "@/modules/st-core/js_sdk/util/local-storage";
import apiConfig from "@/serve/api.config";
declare const initNECaptchaWithFallback:any
@Component({
components: {}
})
export default class GetVerificationCode extends Vue {
//是否禁用
@Prop({type : Boolean ,default:true}) disabled;
//缓存的key
@Prop({type : String ,default:"sendTime"}) cacheKey;
@Prop({type : String ,default:"mt6"}) marginTop;
@Prop({type : String ,default:"fz14"}) fontSize;
private countDownNum: number = 0 //倒计时
private timer: any = null //计时器
private captchaIns:any = null
private locale: any = getStorage("locale","en") ;
created(){
this._startTimer()
}
async mounted(){
await this.$nextTick()
await this._initNECaptcha()
}
@Watch('$i18n.locale')
localeData(value){
this.locale = value
}
//是否禁用
get _disabled(){
return this.disabled || this.countDownNum > 0 || !this.captchaIns ;
}
//读秒
_countdown() {
let total = 60;
let sendTime = getStorage(this.cacheKey, 0);
let now: any = Date.now();
let mixSeconds: any = Math.ceil((now - sendTime) / 1000); //已过去了多少s
this.countDownNum = total - mixSeconds;
if (this.countDownNum <= 0) {
clearInterval(this.timer);
}
return this._countdown;
}
//验证码发送成功后调用该方法
onSendSuccess() {
setStorage(this.cacheKey, Date.now());
this._startTimer();
}
_startTimer() {
clearInterval(this.timer);
this.timer = setInterval(this._countdown(), 1000);
}
//发送验证码
_sendCode() {
if (this._disabled) return;
this.captchaIns.popUp();
}
_initNECaptcha(){
return new Promise((resolve,reject)=>{
// 若使用降级方案引用初始化js
// initNECaptcha替换成initNECaptchaWithFallback
initNECaptchaWithFallback({
element: `#${this.cacheKey}`,
captchaId: apiConfig.domainList.codes,
mode:'popup',
lang:this.locale == 'en' ? 'en' : 'zh-CN',
width: '320px',
onVerify: (err: any, ret: any) => {
// 用户验证码验证成功后,进行实际的提交行为
if (err) return console.log(err)
let validate = ret.validate;
this.$emit("send" , {validate});
this._initNECaptcha()
}
}, (instance) => {
this.captchaIns = instance ;
resolve(instance);
}, (err) => {
// 初始化失败后触发该函数,err对象描述当前错误信息
console.log(err)
reject(err);
})
})
}
}
</script>
总结:
1、图片校验全局函数initNECaptchaWithFallback()接收三个函数:配置项、初始化成功、初始失败,其中配置项中的captchaId要与服务端协商一致。图片校验onVerify失败后组件内部会重新唤起校验,校验成功后拿到一个字符串,触发send事件将这个字符串传给服务端校验,这个send事件在父级组件用来监听发送验证码。
2、概括为组件外部的其他不可发送验证码的情况,比如手机号格式错误等,可以传入disabled属性来控制,或者正在读秒,或者图片校验初始化失败。
//是否禁用
get _disabled(){
return this.disabled || this.countDownNum > 0 || !this.captchaIns ;
}
3、组件内部提供onSendSuccess()方法方法,组件外部在验证码发送成功时调用该方法,该方法真正的作用是读秒。
4、读秒逻辑:
(1)验证码发送成功后将当前的时间存在缓存中
onSendSuccess() {
setStorage(this.cacheKey, Date.now());
this._startTimer();
}
(2)读秒逻辑:总时长为60秒,现在的时间减去从缓存中读取出来的发送时间就是过去了多少秒,总时长减去过去了多少秒就是剩余的时间,如果剩余的时间小于等于0的时候我们要把定时器清理掉。
_countdown() {
let total = 60;
let sendTime = getStorage(this.cacheKey, 0);
let now: any = Date.now();
let mixSeconds: any = Math.ceil((now - sendTime) / 1000); //已过去了多少s
this.countDownNum = total - mixSeconds;
if (this.countDownNum <= 0) {
clearInterval(this.timer);
}
return this._countdown;
}
_startTimer() {
clearInterval(this.timer);
this.timer = setInterval(this._countdown(), 1000);
}
(3)用户刷新页面时继续读秒处理
created(){
this._startTimer()
}