User Snippets实战技巧提升开发效率

百里丽君 工具 阅读 1,461
赞 16 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接手了一个后台管理工具的优化需求,核心目标是让运营人员能快速配置一些动态行为,比如在特定页面插入一段 JS 脚本、修改某个按钮的行为,或者临时打个补丁修复线上 bug。原本是靠我们开发手动改代码发版,结果每次都要排期,运营早就烦了。

User Snippets实战技巧提升开发效率

最开始想的是搞个可视化规则引擎,后来发现太重了,而且大多数场景其实就是“执行一段 JS”。于是就想到了 User Snippets —— 就是允许用户上传或输入一小段 JavaScript 代码,在前端运行时动态注入执行。听起来简单,但真做起来,坑一个接一个。

第一版方案:直接 eval

刚开始图省事,直接用了 eval 去执行用户输入的代码。代码就几行:

function runUserSnippet(code) {
  try {
    eval(code)
  } catch (e) {
    console.error('Snippet 执行出错', e)
  }
}

本地测试没问题,扔到测试环境也跑通了,我还在心里暗爽:“这不就完事了?” 结果第二天 QA 找上门:页面卡死,CPU 直接拉满。

查了一下日志才发现,有个测试人员手滑写了个无限循环:

while (true) {
  console.log('嘿嘿')
}

更离谱的是,eval 还能访问所有全局变量,有人顺手写了句 localStorage.clear(),差点把整个环境清了。这哪是 snippet,这是炸弹啊。

最大的坑:性能和安全问题

从那之后我就明白,不能让用户代码拥有完全自由的执行权。核心问题有两个:

  • 没有执行超时机制,死循环直接拖垮页面
  • 可以直接访问 window、document 等全局对象,存在 XSS 和数据破坏风险

第一点还好说,可以用 setTimeout 拆解任务或者 Web Worker 隔离执行。但第二点才是真头疼 —— 如何限制作用域?

我试过用 with 包一层空对象,结果 ES5 严格模式下直接报错。又试过字符串替换关键词,比如把 window 替换成 null,结果遇到 wind' + 'ow 这种拼接就绕过去了,防不住。

折腾了半天发现这条路走不通。最后决定改方案:用 iframe + postMessage 隔离执行环境。

最终的解决方案

思路是这样的:把用户代码塞进一个隐藏的 iframe 里运行,这个 iframe 的 origin 是独立的(实际用了 same-origin 的空白页),并且不暴露任何全局变量。主页面通过 postMessage 和它通信。

iframe 内部的代码模板长这样:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>snippet runner</title>
</head>
<body>
  <script>
    // 只暴露有限 API
    const api = {
      log: (...args) => parent.postMessage({ type: 'log', data: args }, '*'),
      fetch: async (url, options) => {
        const res = await fetch(url, options)
        const json = await res.json().catch(() => null)
        parent.postMessage({ type: 'fetch_result', data: json }, '*')
      },
      done: (result) => {
        parent.postMessage({ type: 'done', data: result }, '*')
      }
    }

    // 用户代码在这里
    {{USER_CODE}}

  </script>
</body>
</html>

主页面这边监听消息,并控制生命周期:

class UserSnippetRunner {
  constructor() {
    this.iframe = document.createElement('iframe')
    this.iframe.style.display = 'none'
    document.body.appendChild(this.iframe)
  }

  async run(code, timeout = 5000) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        reject(new Error('Snippet 执行超时'))
      }, timeout)

      const handleMessage = (event) => {
        if (event.source !== this.iframe.contentWindow) return

        switch (event.data.type) {
          case 'done':
            clearTimeout(timer)
            resolve(event.data.data)
            break
          case 'log':
            console.log('[Snippet]', ...event.data.data)
            break
          case 'fetch_result':
            console.log('[Fetch Result]', event.data.data)
            break
        }
      }

      window.addEventListener('message', handleMessage)

      // 注入代码
      const html = 
        &lt;!DOCTYPE html&gt;
        &lt;script&gt;
          ${code.replace(/&lt;/script&gt;/g, &#039;&lt;\/script&gt;&#039;)}
        &lt;/script&gt;
      
      this.iframe.srcdoc = html

      // 清理函数
      const cleanup = () => {
        window.removeEventListener('message', handleMessage)
      }

      // 超时后清理
      setTimeout(cleanup, timeout + 100)
    })
  }
}

这样用户代码虽然还是能用 fetch,但必须通过我们封装的 api.fetch,不能随意乱来。也不能访问 localStoragewindow 上的敏感属性 —— 因为 iframe 里根本没挂载这些。

还有些小问题没彻底解决

这个方案上线两周了,目前没再出现卡死的情况,日志也能收集到。但有几个小毛病一直没完美处理:

  • iframe 加载有延迟,频繁执行时会有性能开销
  • 如果用户代码抛出异常,不会自动被捕获传回来,得靠他们手动包 try-catch
  • 无法调试,出了错只能看 log,不能断点

我也想过用 AST 解析 + 沙箱模拟执行,比如 acorn 或 vm2,但那是 Node 环境的,浏览器里不好使。也有现成库像 realms-shim,但太新了,兼容性差,公司不许用 experimental 特性。

所以现在的做法是在前端加了个提示:“请确保代码中包含错误处理”,然后靠 code review 来拦一部分明显有问题的脚本。不是最优解,但够用。

回顾与反思

回过头看,一开始低估了“运行任意代码”的危险性。以为只是个小功能,结果牵扯出一堆安全和稳定性问题。最大的教训就是:别图快,eval 这种东西在生产环境真的碰都不要碰。

另一个经验是,沙箱隔离不一定非得搞多复杂,有时候用 iframe 这种“笨办法”反而更稳定。毕竟它本身就是浏览器原生的隔离机制,比你自己在 JS 层面搞作用域控制靠谱多了。

现在这个系统每天大概跑几十个 snippets,大多是做一些临时数据抓取、埋点打标、AB 测试逻辑切换。虽然还有点小瑕疵,但至少没人来找我修服务器了。

以上是我的项目经验,希望对你有帮助

这个方案不是最优的,也谈不上优雅,但它解决了实际问题。如果你也在做类似的功能,建议早点考虑隔离和超时机制,别像我一样等线上炸了才回头。

有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如加上语法校验、支持异步返回、甚至集成 Monaco 编辑器做提示,后续可能会继续分享这类实战内容。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论