Vue3 优雅解决单引号注入问题:自定义指令 + 全局插件双方案

做业务系统时,用户输入单引号(')会直接导致 SQL 报错甚至注入风险。后端当然要做转义,但前端拦截能让体验更好、更安全。本文分享一套 Vue3 的完整解决方案:自定义指令 精准控制 + 全局插件一键覆盖,两种方式按需选用。


方案一:自定义指令 v-input-no-single-quote

适合需要精准控制某些输入框的场景。

创建指令文件

src/directives/inputNoSingleQuote.js

js 复制代码
const globalNoSingleQuoteConfig = {
  defaultEnabled: true,
  processedInputs: new WeakSet(),
  disabledInputs: new WeakSet()
};

export default {
  mounted(el, binding) {
    const inputElement = el.querySelector('.el-input__inner') 
      || el.querySelector('input') 
      || el.querySelector('textarea');
    if (!inputElement) return;

    const config = binding.value || {};
    const enabled = config.enabled !== false;

    if (!enabled) {
      globalNoSingleQuoteConfig.disabledInputs.add(el);
      return;
    }
    if (globalNoSingleQuoteConfig.processedInputs.has(el)) return;
    globalNoSingleQuoteConfig.processedInputs.add(el);

    let isComposing = false;

    const removeSingleQuote = () => {
      if (isComposing || !inputElement.value) return;
      const oldValue = inputElement.value;
      const newValue = oldValue.replace(/'/g, '');
      if (oldValue !== newValue) {
        const start = inputElement.selectionStart;
        inputElement.value = newValue;
        const newPos = start - (oldValue.substring(0, start).match(/'/g) || []).length;
        inputElement.setSelectionRange(newPos, newPos);
        inputElement.dispatchEvent(new Event('input', { bubbles: true }));
      }
    };

    inputElement.addEventListener('input', removeSingleQuote);
    inputElement.addEventListener('blur', removeSingleQuote);
    inputElement.addEventListener('paste', () => setTimeout(removeSingleQuote, 10));
    inputElement.addEventListener('compositionstart', () => { isComposing = true; });
    inputElement.addEventListener('compositionend', () => {
      isComposing = false;
      setTimeout(removeSingleQuote, 10);
    });

    el._removeSingleQuote = removeSingleQuote;
    el._inputElement = inputElement;
  },

  beforeUnmount(el) {
    if (el._inputElement && el._removeSingleQuote) {
      el._inputElement.removeEventListener('input', el._removeSingleQuote);
      el._inputElement.removeEventListener('blur', el._removeSingleQuote);
    }
    globalNoSingleQuoteConfig.processedInputs.delete(el);
  }
};

export { globalNoSingleQuoteConfig };

注册指令

src/main.js

js 复制代码
import noSingleQuote from './directives/inputNoSingleQuote.js'

app.directive('input-no-single-quote', noSingleQuote)

使用

vue 复制代码
<!-- 启用(默认) -->
<el-input v-model="name" v-input-no-single-quote />

<!-- 明确禁用(某些特殊输入框不需要限制) -->
<el-input v-model="sql" v-input-no-single-quote="{ enabled: false }" />

就这么简单,加一个指令,单引号自动消失。


方案二:全局插件(推荐)

不想每个输入框都加指令?用插件一次配置,全局生效

创建插件文件

src/plugins/globalNoSingleQuote.js

js 复制代码
import { globalNoSingleQuoteConfig } from '../directives/inputNoSingleQuote.js';

// 需要排除的页面路径(这些页面允许输入单引号)
const globalNoSingleQuotePluginConfig = {
  excludedPaths: ['/qp/zjjc', '/qp/tableModuSet']
};

export const GlobalNoSingleQuotePlugin = {
  install(app) {
    app.mixin({
      mounted() {
        this.$nextTick(() => {
          if (!this.$el || typeof this.$el.querySelectorAll !== 'function') return;

          // 排除指定页面
          const currentPath = window.location.hash || window.location.pathname;
          if (globalNoSingleQuotePluginConfig.excludedPaths.some(p => currentPath.includes(p))) return;

          const inputs = this.$el.querySelectorAll('.el-input, input, textarea');

          inputs.forEach(inputEl => {
            if (globalNoSingleQuoteConfig.processedInputs.has(inputEl) ||
                globalNoSingleQuoteConfig.disabledInputs.has(inputEl)) return;

            const actualInput = inputEl.querySelector('.el-input__inner')
              || inputEl.querySelector('input')
              || inputEl.querySelector('textarea')
              || (['INPUT', 'TEXTAREA'].includes(inputEl.tagName) ? inputEl : null);

            if (!actualInput) return;

            let isComposing = false;

            const removeSingleQuote = () => {
              if (isComposing || !actualInput.value) return;
              const oldValue = actualInput.value;
              const newValue = oldValue.replace(/'/g, '');
              if (oldValue !== newValue) {
                const start = actualInput.selectionStart;
                actualInput.value = newValue;
                const newPos = start - (oldValue.substring(0, start).match(/'/g) || []).length;
                actualInput.setSelectionRange(newPos, newPos);
                actualInput.dispatchEvent(new Event('input', { bubbles: true }));
              }
            };

            actualInput.addEventListener('input', removeSingleQuote);
            actualInput.addEventListener('blur', removeSingleQuote);
            actualInput.addEventListener('paste', () => setTimeout(removeSingleQuote, 10));
            actualInput.addEventListener('compositionstart', () => { isComposing = true; });
            actualInput.addEventListener('compositionend', () => {
              isComposing = false;
              setTimeout(removeSingleQuote, 10);
            });

            globalNoSingleQuoteConfig.processedInputs.add(inputEl);
            inputEl._removeSingleQuote = removeSingleQuote;
            inputEl._inputElement = actualInput;
          });
        });
      },

      beforeUnmount() {
        if (!this.$el || typeof this.$el.querySelectorAll !== 'function') return;
        const inputs = this.$el.querySelectorAll('.el-input, input, textarea');
        inputs.forEach(inputEl => {
          if (inputEl._inputElement && inputEl._removeSingleQuote) {
            inputEl._inputElement.removeEventListener('input', inputEl._removeSingleQuote);
            inputEl._inputElement.removeEventListener('blur', inputEl._removeSingleQuote);
          }
          globalNoSingleQuoteConfig.processedInputs.delete(inputEl);
        });
      }
    });
  }
};

export { globalNoSingleQuotePluginConfig };

注册插件

src/main.js

js 复制代码
import { GlobalNoSingleQuotePlugin } from './plugins/globalNoSingleQuote.js'

app.use(GlobalNoSingleQuotePlugin)

完成! 全站所有输入框自动禁止单引号,无需改任何业务代码。


两种方案对比

自定义指令 全局插件
使用方式 每个输入框加 v-input-no-single-quote 注册一次,全局生效
控制粒度 精准,逐个控制 全局,按页面路径排除
适用场景 部分页面需要限制 整个项目都需要限制
排除方式 { enabled: false } 配置 excludedPaths

几个细节值得关注

1. 中文输入法兼容

compositionstart / compositionend 事件判断是否在拼音输入中,避免打字过程中误删拼音里的 ' 符号(比如 don't 的输入过程)。

2. 光标位置修正

删除单引号后,光标位置会偏移。代码里计算了光标前被删除的字符数,精准还原光标位置,用户无感知。

3. 粘贴处理

粘贴事件用 setTimeout(fn, 10) 延迟处理,确保粘贴内容已写入 DOM 再进行清理。

4. WeakSet 防重复

WeakSet 记录已处理的元素,避免 mixin 在组件更新时重复绑定事件,也不会造成内存泄漏。

5. 触发 Vue 响应式更新

直接修改 inputElement.value 不会触发 Vue 的响应式,需要手动 dispatchEvent(new Event('input', { bubbles: true })) 通知 Vue 更新绑定的数据。


总结

  • 需要精准控制:用自定义指令,哪里需要加哪里
  • 需要全局覆盖:用插件,注册一次搞定全站
  • 两者可以同时使用:插件全局兜底,指令局部排除
相关推荐
忆琳2 小时前
Vue3 全局自动大写转换:一个配置,全站生效
javascript·element
Ruihong3 小时前
放弃 Vue3 传统 <script>!我的 VuReact 编译器做了一次清醒取舍
前端·vue.js
蜡台3 小时前
IDEA LiveTemplates Vue ElementUI
前端·vue.js·elementui·idea·livetemplates
神の愛3 小时前
Vite的proxy和Nginx的location 请求转发区别
vue.js
之歆3 小时前
Vue Router 深度解析 — 从浏览器导航模型到 SPA 路由工程
前端·javascript·vue.js
guojb8243 小时前
Vue3 高阶技巧:使用 AST 将 HTML 字符串优雅渲染为自定义组件
前端·javascript·vue.js
之歆3 小时前
API 层架构设计 — 从 RESTful 到 GraphQL 的范式演进
vue.js·后端·restful·graphql
蜡台3 小时前
Vue3 props ref router 数据通讯传输等使用记录
前端·javascript·vue.js·vue3·router·ref
mfxcyh4 小时前
实现签名画板
前端·javascript·vue.js