Dynamically Evaluate JavaScript on Single-Page-Application

Notice

This is an old article and only available in Chinese. If you need a translation, please leave a comment and I will do my best to provide it as soon as possible.

之前在写自己的博客框架的时候遇到了一个问题:文章中的 script 标签没有任何作用,以 Vue 为 MVVM 框架举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<body>
  <div id="app">
    <p v-html="html"></p>
  </div>
  <script>
    var vm = new Vue({
      el: "#app",
      data: {
        html: '<script> console.log("JavaScript is awesome"); <\/script>',
      },
    });
  </script>
</body>

这段代码的意思很简单,创建一个 ID 为 app 的 DIV,其中包含一个 p 标签,vm 实例化之后,p 的内容就是 data 字段的 html,渲染结束后效果应该是这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<body>
  <div id="app">
    <p>
      <script>
        console.log("JavaScript is awesome");
      </script>
    </p>
  </div>
  <script>
    // emmmm
  </script>
</body>

好吧……看起来这代码很不规矩,但是浏览器应该是接受在任意位置出现的 script 标签的,也就是按照预期,我们会在控制台里看到 JavaScript is awesome 这句话,没毛病。

然而实际结果是……并没有。原因很简单,v-html 是通过 innerHTML 来实现的,而 HTML5 的标准规定了通过 innerHTML 得到的 script 标签不会被执行。此举的目的是为了防止利用这个特性来进行 XSS 攻击,但是依然有不少场景需要使用这些脚本,所以我们需要一些 trick 来让浏览器加载这些脚本。与此同时,如果 script 是通过 appendChild 添加到 DOM,这段脚本是能被正常执行的。

所以解决方案也就很简单了:找到需要执行的 script 标签,然后通过 createElement 创建空 script,并将待执行标签的数据复制到空 script 中(比如 src,或者 innerHTML),最后通过 appendChild 将它添加到 DOM 中去。

其中,找到 script 标签的方法比较多,比如可以通过正则对字符串进行匹配。不过考虑到通过 innerHTML 设置的 script 标签仅仅只是没有执行,他们仍然在 DOM 树中,所以 querySelector 在鲁棒性(划掉)上就比正则高出一截,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<body>
  <div id="app">
    <p v-html="html"></p>
  </div>
  <script>
    var vm = new Vue({
      el: "#app",
      data: {
        html: '<script> console.log("JavaScript is awesome"); <\/script>',
      },
      created: function () {
        // 等待 DOM 刷新
        this.$nextTick(function () {
          let app = document.querySelector("#app");
          let scripts = Array.from(app.querySelectorAll("script"));
          scripts.forEach(function (script) {
            let node = document.createElement("SCRIPT");
            let src = script.getAttribute("src");
            if (src) {
              node.setAttribute("src", src);
            } else {
              node.innerHTML = script.innerHTML;
            }
            document.querySelector("body").appendChild(node);
          });
        });
      },
    });
  </script>
</body>

这样,我们就能顺利地在控制台里看到输出的字符串啦(

当然这样做也有他的缺点:

  1. 使用了全局的 JavaScript 环境。如果代码中出现了一些声明(比如用 let 定义了变量),或者是通过 new Audio() 之类的方式播放了音频,即使你在适当的时候将他们从 DOM 树中移除,他们产生的影响仍然将保留在当前的上下文中。我个人的解决方案是,使用一个 flag 来记录是否添加过 script,若添加过,则后续的路由操作将在 beforeEach 的钩子处被劫持,并将 fullPath 直接应用到 window.location.href 上,强制刷新当前页面,来获得一个全新的 JavaScript 上下文。
  2. 没有充分模拟 script 标签的特性。按照正常的网页加载模式,浏览器在加载和执行 script 标签时是阻塞的,顺序执行的。通过这种方式添加的 script 执行顺序并没有任何明确的规定,可能导致出现依赖上的问题。
comments powered by Disqus
Except where otherwise noted, content on this blog is licensed under CC-BY 2.0.
Built with Hugo
Theme Stack designed by Jimmy