HTML 上事件句柄的作用域

前段时间翻看 HTML Reference,发现了没怎么见过的 output 标签,其中给出的例子让我比较吃惊:

<form oninput="sum.value = parseInt(a.value) + parseInt(b.value)">
  <input type="number" name="a" value="4">
  +
  <input type="number" name="b" value="7">
  =
  <output name="sum">11</output>
</form>

oninput 中怎么就能拿到 sumab 这些变量呢?

在前辈们从小教育我们不要这么写的 onclick="clickHandler();" 写法中,clickHandler 是一个全局变量,我们理所当然地认为能调用到,没什么问题。那 sum 是不是全局变量呢?window.sum === undefined,并不是。如果是的话 HTML 也太危险了,随便一个 name 属性就影响到全局了。

总之先 oninput="console.dir(sum);" 看下,指向 output 这个元素,也对,改 sum.value 才能改变 output 中显示的值。再看下 this,发现指向 form,不意外。然后作为 form 的属性,看下 form.oninput,发现它是这个函数:

function oninput(event) {
  console.dir(this);
}

也就是说,HTML 上 on 开头的属性,会使用如上的函数包裹一下设置到元素的 attribute 中。这个包裹是纯粹的字符串直接拼接,我们可以尝试一下 oninput="console." 这样语法错误的字符串,在控制台查看这个 form.oninput 时报了语法错误 Uncaught SyntaxError: Unexpected token }。另外,这个包裹的函数传入了参数 event,就是那个事件对象,在 HTML 中也能直接使用 event 这个变量。好了,我要是把 sum 改名为 event 会怎样?君孰与参数屌? 发现 event 依然是事件对象,也就说明 sum 是在这个函数外的作用域上。

不知道哪个版本开始 DevTools 可以查看函数的 [[Scopes]] 了,运行如下代码后,可以观察到 inner[[Scopes]] 有两个,分别是 Closure (outer)Global,前者包含了常量 a,后者就是 window

function outer() {
  const a = 1;
  function inner() {
    return a;
  }
  console.dir(inner);
}
outer();

同理我们可以看到 form.oninput 有四个 [[Scopes]],分别是 form 元素、空对象 {}documentwindow。前三者叫做 With Block,是用 with 语句设置的;第二个是个空对象,不知道为什么,找了下规范,第十条描述了它的 scope,也没提到。不过,这下 sum 的来源基本清楚了,空对象肯定没有,documentwindow 也没有,它是来自 form 元素。

可是,可是,console.dir(form) 查看时,并没有找到 sum 属性,只有 form[2] 表示这个 output 元素;然而直接输出 form.sum 时,是有值的。难道 sum 是在 form 构造函数 HTMLFormElement 的原型上面?form.constructor.prototype.sum === undefined,并没有;难道它是一个 HTMLCollection,像 form.children 那样,可以直接用名称取值?可是它不由 HTMLCollection 构造,而且 HTMLCollection 也是会显示对应名称的属性的。我用 form.hasOwnProperty('sum') 试了下,发现 sum 是 form 自身的属性,但怎么在控制台就不显示呢?于是我去翻看 form 的规范,它的 DOM 接口定义中有行 getter (RadioNodeList or Element) (DOMString name);后面也有描述其实现过程,原来 form 中有 name 属性的那些元素,是通过一个叫 past names map 的东西维护的,它可以保证元素 name 改变了也能让 form 找到这个元素。

继续测试,把 sum 改名为 oninput,form 的 input 事件依然能够触发,然而输出 form.oninput 却是 output 元素。WTF?更进一步,设置 form.oninput = null; 之后,input 事件不再触发了,但它的值依然是 output 元素。我趴在地上想了想,应该是,通过 oninput 写的句柄,在初始化之后,类似与 addEventListener,注册到某个地方去维护了,事件触发时,并不是直接调用 form.oninput 本身;之后浏览器发现 output 元素也叫 oninput,通过 past names map 的方式,取其值时(用 getter)拦截掉了原来的句柄,返回了 map 中的值;再给它赋值时,past names map 并不让重新赋值(没有 setter),被句柄接到,从注册的地方删除,不在响应该事件。

至此,HTML 上事件句柄的作用域基本都清楚了,若有纰漏,还望指出。