分类
js

在任意上下文执行代码,`new Function` vs `eval()`

0. 需求及方向

我厂的 Navigator 扩展遇到一个需求:

  1. 动态生成一段 JS 在浏览器里运行
  2. 能重复生成、重复运行
  3. 能生成一段新代码,继续在上一次的环境里运行

其中(3)是新需求,(1)(2)已经比较完善地实现了。主要方案是使用 <script type="module">,这样加载的 JS 会自动执行,并且与全局环境相隔离,不会污染全局环境——这样就可以重复执行。

(3)的难点在于,上一段 JS 已经执行过了,环境也自然释放了,我要么把上一阶段 JS 的执行结果保存下来,要么把执行环境保存下来。前者需要对 Navlang 编译器做大幅度的修改;后者则有很大的实施难度。

经过研究和思考,我选择 保存环境 为攻关方向。即在上次执行结束前,在 window 上注册一个钩子函数,然后在后续追加执行的时候调用它,把后面的代码以函数的形式传进去,以便在上一次留下的环境里继续执行。

1. 问题:访问环境里的变量

添加钩子函数并不难,这样就行:

// 在上一段代码最后添加钩子函数
window.doNext = function (func) {
  func();
}

// 在新一段代码执行函数
window.doNext(function () {
  // 新生成的函数
});

这样做虽然看起来是在上一次的上下文环境中执行新的函数,但由于 JS 的闭包和作用域链特性,实际执行时,新函数并不能访问到上一次上下文的数据。换言之,目的并没有实现。

2. new Function() vs eval()

然后回到标题。所以我们必须在上一次的环境里重新构建函数,这并不困难,其实开发浏览器扩展时,因为要协调四、五个运行环境,所以把函数序列化传输,再重新构建执行非常常见。于是我直接操起 new Function(),然后失败了。

查阅 MDN,原来 new Function() 构造的函数,它的上下文会绑定在全局对象上(相当于 .call(null)),所以自然无法访问到上一次环境里的变量。

再看 eval()文档,似乎可行,于是换用之,果然有效。至此,问题解决。

3. 代码范例

function navlangExecuteFunction(func) {
  // 使用模版字符串构建异步函数
  eval(`(async function doNext() {
    // 捕获中间可能出现的问题
    try {
      // 执行真正的函数
      await (${func})();
      // 为兼容 node.js,mock 一个 process 来处理执行结束
      process.exit();
    } catch (e) {
       if (!e.message.startsWith('Exit code:')) {
         console.error(e);
         process.exit(1);
       }
    }
  })()`);
}
window.navlangExecuteFunction = navlangExecuteFunction;
const serializeFunction = (f) => {
  const serialized = f.toString();
  // Safari serializes async arrow functions with an invalid function keyword.
  // This needs to be removed in order for the function to be interpretable.
  const safariPrefix = 'async function ';
  if (serialized.startsWith(safariPrefix)) {
    const arrowIndex = serialized.indexOf('=>');
    const bracketIndex = serialized.indexOf('{');
    if (arrowIndex > -1 && (bracketIndex === -1 || arrowIndex < bracketIndex)) {
      return async ${serialized.slice(safariPrefix.length)};
    }
  }
  return serialized;
};

let nextStep = async function () {
  // 要执行的部分
}
nextStep = serializeFunction(nextStep);
navlangExecuteFunction(nextStep);

4. 可能带来的问题

MDN 明确建议大家 不要用 eval。理由如下:

  1. 不安全。因为 eval() 会在当前环境执行代码,意味着攻击者可能窃取任何当前环境的数据。
  2. 性能差。现代 JS 引擎会对代码进行大量优化,包括转成机器码等。eval() 会破坏这个过程,使得运行性能大大降低。

不过在我的场景下,这两个问题并不严重。一方面,被执行的追加代码都是由 Navlang 编译器生成,而不是任意第三方,它的安全性不会比其它的代码安全性低。另一方面,这个功能是帮助用户开发调试 Navlang 的,我们可以认为它大概率不会跑在性能敏感的环境里。

另外,对我厂的 Navigator 产品而言,这样的方案还会让 GC 变得比较难执行。不过一样从 便利开发 的角度出发,我觉得性价比完全 OK。

5. 总结

这个需求比较特殊,涉及到 JS 函数的很多性质,包括运行时优化的知识,还是蛮值得大家琢磨的。

另外就是所谓“尽信书不如无书”。eval() 的确存在一些问题,不能轻易使用,但是当需求摆在面前,经过充分的分析确认之后,我们该用还是要用。

分类
js

函数,栈,try…catch,以及异步

前两天《记一个 `try…catch` 异步函数的坑》发出后,有同学表示对最后一句不解:

异步函数的调用可能并不在当前栈,也无法被 try…catch 在当前栈捕获到错误。

要解释清楚,一两行是不够的,于是写这篇文章展开解释一下。(这篇文章是基础向。)

函数与栈

首先来看这样一段代码:

function a() {
  // ...
}

function b() {
  // 代码1 
  a();
  // 代码2
}

function c() {
  // 代码3
  b()
  // 代码4
}

c();

它会怎么执行呢?简单来说是这样:

  1. 构建一个栈
  2. c 推入栈,开始执行 代码3
  3. b 推入栈,开始执行 代码1
  4. 将 a 推入栈,开始执行
  5. a 执行完,出栈
  6. 开始执行 代码 2
  7. b 执行完,出栈
  8. 开始执行 代码 4
  9. c 执行完,出栈

栈是一个先入后出的数据结构,你可以把它当成一个桶,先放进去的东西会被后放进去的东西压在下面,需要先把上层的东西,也就是后放进去的东西拿出来才能拿到先放进去的东西。

发生错误

如果代码执行中发生错误,就会在错误处中断,并逐个出栈。此时我们不仅能看到错误本身,还能看到错误发生处的完整栈信息,对 debug 很有帮助。

try...catch

异步函数出现之前,try...catch 只能捕获当前栈的错误。比如,同样是上面那段代码,稍微改动一下:

// 之前定义 a b c 的代码不变

function d() {
  throw new Error('oh my god');
}

try {
  c();
} catch (e) {
  console.error(e);
}

d();

这段代码里,c() 执行时如果有错误,可以被 try...catch 捕获到;但是 d() 位于另一个栈(或同一个栈的另一个堆叠),就无法捕获。

异步 callback & Promise

Promise 成为规范被纳入标准之前,我们处理异步操作时需要使用回调(callback)。比如侦听事件:

try {
  $('.btn').on('click', function (e) {
    console.log('hello');
  });
} catch (e) {
  console.error(e);
}

请注意:这里添加侦听函数的代码,也就是 $().on() 的部分,是在当前栈执行的;而用户点击后执行的操作,即 console.log('hello') 的部分,是在将来某个事件调用栈里执行的。所以,如果后面的函数里有问题,那么如上面所示的代码是无法捕获到错误的。

Promise 也一样。比如下面这段代码:

new Promise((resolve) => {
  // 执行完后回调
  func0(resolve);
})
  .then(() => {
    return func1();
  })
  .then(() => {
    return func2();
  });

这里的 func0func1func2 三个函数都是在不同调用栈里执行的,所以如果你在最外面 try...catch,无法捕获到错误。

这样会给我们编写代码带来一些困难。比如,有时候我们需要同时发起好几个网络请求,有些会成功有些会失败。我们并不知道每个失败请求对应的构造函数是怎么执行的,只能依靠请求内容进行判断,就比较麻烦。

这个问题,在异步函数中得到了解决。

异步函数 async function

这个变化主要是 await 带来的。

前篇文章所说,在不使用 await 的情况下,try...catch 只会捕获当前调用栈的错误。对于异步函数来说,它在当前栈会返回 Promise 实例,然后就顺利结束,不会抛出任何错误。所以 try...catch 也无法捕获任何错误。

但是添加 await 之后,情况就不同了。这个时候,try...catch 会捕获整个 Promise 里所有的错误,即使函数位于不同调用栈,也可以正确捕获并且显示在一起,方便调试。

分类
js

记一个 `try…catch` 异步函数的坑

前几天遇到一个问题:想捕获异步函数的错误,但是捕获不到。我的代码大概是这样:

try {
  (async () => {
    // 一大堆异步函数
  })();
} catch (e) {
  if (e.message.startsWith('my error:') {
    // 我的错误,提示一下即可
  } else {
    // 某种不知名错误,继续抛出
    throw new Error(e.message);
  }
}

不知道读者是否发现问题,我当时是左看右看没看出来。后来只好写最小用例缩小问题范围,终于找到问题所在:异步函数前面缺少 await。于是想通了,上面的代码其实被引擎解释成:

  1. try 一个函数
  2. 这个函数的返回值是一个 Promise
  3. Promise 是正常对象,所以 try 成功
  4. 至于 Promise 里的函数是否失败,不关 try 的事,所以自然捕获不到

所以正确的写法是:

// 外面先套一层,不然不能用 await
(async () => {
  try {
    // 再加上 `await`
    await (async () => {
      // 异步函数体
    })();
  } catch (e) {
    // ....
  }
})();

这个 await 至关重要。这是异步函数的一大特性,即 try...catch 可以捕获整个异步操作前后所有栈的异常,而不仅仅是当前函数的异常。同时我们也要注意,异步函数的调用可能并不在当前栈,也无法被 try...catch 在当前栈捕获到错误。

分类
js

使用 webpack-mock-server 给组件库添加测试服务

再过一周,我就在我厂待满三年了。其实我的职业生涯还算比较顺利,除了第一次跳槽不太好,后面每个公司都选的不错,虽然远不能满足财务自由的梦想,但是几乎都能让我在技术上有所精进,在职业上也取得一定成长。

三年期间,我们做了不少产品,为了方便在不同产品之间复用代码,我把一些公共部分抽出来做成组件,独立开发和维护,并且通过 npm + GitHub Registry 管理依赖(这个部分,前面曾写过一篇文章《使用 GitHub Registry 托管私有 NPM 源》介绍)。

有一些组件,比如登录,独立出来开发没问题,但是测试比较难搞,为了它单独开发服务器有点太兴师动众。所幸我很快就找到 webpack-mock-server,它可以很方便的定义 API 接口,只要把它加到项目中,就能很容易的完成测试了。

使用方法

1. 安装

使用 npm 安装,并且添加配置文件。安装 typescript 是因为它默认会在项目根目录里找 webpack.mock.ts,我暂时不知道怎么不用 ts 写配置。

npm install -D webpack-mock-server typescript
const webpackMockServer = require("webpack-mock-server");
 
module.exports = {
  devServer: {
    before: webpackMockServer.use
  }
}

2. 配置接口

目前这个工具只会在根目录里找 webpack.mock.ts(或者说我用的还不太熟,只会这么做),好在写 express 配置并不复杂,也不需要 ts 语法:

import webpackMockServer from "webpack-mock-server";
 
// app is expressjs application
export default webpackMockServer.add((app, helper) => {
  // you can find more about expressjs here: https://expressjs.com/
  app.get("/testGet", (_req, res) => {
    res.json("JS get-object can be here. Random int:" + helper.getRandomInt());
  });
  app.post("/testPost", (_req, res) => {
    res.json("JS post-object can be here");
  });
});

3. 检查接口

接下来,正常启动 dev-server 即可:webpack-dev-server --config=build/webpack.dev.js,然后留心控制台,会多输出一个服务网址,比如:

WebpackMockServer. Started at http://localhost:8079/

这个服务一般是 dev-server 端口 -1,比如我的 dev-server 跑在 8080,那么它就在 8079。打开之后是如下所示的接口列表:

从中可以看到所有提供服务的接口,支持什么方法,点击还能查看返回结果,非常方便。

总结

使用这个工具,可以大大提升组件库的开发效率。目前我用的也不是很熟,文档中介绍的方法还没用完,也不清楚怎么不用 ts。先推荐给大家吧。

分类
js 技术

使用 JS 模拟元素被 click

需求来自于我厂的 QA 产品。在这个产品中,我需要在浏览器插件里模拟用的各种行为,比如:点击。

click 事件前后发生了什么

最初我觉得点击嘛,能有啥问题,就直接广播 click 事件呗。结果发现并非如此。实际上,一次 click 背后,其实有一大套的逻辑:

  1. 移动到按钮上时,会依次触发 mouseovermouseenter 事件,前者冒泡,后者不冒泡
  2. 鼠标按下时,广播 mousedown 事件
  3. 如果此时其它元素有焦点,那么该元素会先失去焦点,并广播 blur 事件
  4. (下一节补充)
  5. 按钮获得焦点,广播 focus 事件
  6. 如果因此影响到 DOM,那么会等待 DOM 变更
  7. 如果鼠标没有离开按钮,按钮广播 mouseup 事件
  8. 最后广播 click 事件
  9. 在移动设备上,可能会有 300ms 延迟

其实(5)并不准确,每一次事件广播都是独立的 event loop ,所以上面每一步都可能产生 DOM 变化和其它次生变化,也可能导致一些操作或功能不符合预期。比如:

  1. 一个搜索框。输入后自动搜索,结果以 dropdown 形式展示在下面
  2. 点击 dropdown 里的条目可以跳转
  3. 搜索框 blur 时 dropdown 移除

此时,dropdown 里的条目可能无法点击。因为点击时,输入框先 blur,之后 dropdown 隐藏,接下来 mouseup 事件触发在别的元素上,于是不会有 click 事件。

最简单的解决方案,给 blur 事件加延迟,10ms 就够。

(关于上面的事件触发顺序,可以用这个的 codepen 尝试)

click 事件对 change 事件的影响

接下来,回到 QA 产品。

我真正踩的坑,是 change 事件没有按照预期触发。大家知道,Vue 组件通过 $emit('input') 可以更新绑定在 v-model 里的值。触发的时机一般通过侦听 DOM input 或者 change 来决定。如果 change 无法正确触发,那么 QA 产品自然而然就无法正常工作。

实际上,在上一节的 click 逻辑中,第(4)步可以展开为:

如果失去焦点的元素是 <input> 或者 <textarea>,则该元素会广播 change 事件。

模拟 click 动作的代码

最后,演示一下最终代码:

const options = {
  bubbles: true,
  cancelable: true,
  view: window,
};
let event = new MouseEvent('mousedown', options);
elem.dispatchEvent(event);
elem.focus();
event = new MouseEvent('mouseup', options);
elem.dispatchEvent(event);
elem.click();

相关链接

分类
js

利用 Web Speech API 实现语音阅读试题(TTS)

孩子上小学一年级,寒假作业里有一项:每天做20个20以内的加减法。这个作业老师不直接布置,而是让家长负责,方式任意。那么,显而易见,这个工作就由我负责。然后我就顺手抄起 Vue 做了一个 Web App:http://mui.evereditor.com,源代码在:https://github.com/meathill/mui-teacher

然后老婆说,别的教学应用都会把题目念出来,这样比较有上课的感觉,问我能不能也把题目念出来。我之前大略有些印象,可以直接调用浏览器的原生 API 实现 TTS(text to speech),于是就想试试看吧。

使用“Web speech API”作为关键词,很容易找到 MDN 上的这个页面,继而了解到 SpeechSynthesisUtterance 这个类,接下来就简单了,直接参考 Using the Web Speech API 里的 Demo,就完成了下面代码:

doRead(index) {
  const content = this.$refs.line[index].textContent;
  const msg = new SpeechSynthesisUtterance(content.replace('-', '减'));
  speechSynthesis.speak(msg);
},

这里有三个注意事项:

  1. 系统会把 - 读成“至”,但我这里是加减的“减”,所以我要手动把它替换一下
  2. 从某个版本开始,发音必须由用户主动触发,即放在交互性事件里,不能在页面打开时自动读
  3. 发音效果跟浏览器有关,目前 Chrome 和 Safari 效果比较好

这样的 TTS,对于整段文字来说,效果一般,但是读一般的算式足够了,小朋友也挺喜欢。这个 API 还可以用作语音输入,不过考虑到模型的效果,没有尝试,想真正可用的话,还是用那些高级的在线版本吧。


图文无关,想出去玩……

分类
js

三道前端编程面试题

面试题要有区分度。不能太容易,让对方有屈辱感(“看不起人么?让我做这个?”);也不能太难,把所有候选人都干掉,对自己的时间也是一种浪费。其实挺难选的。

很多大公司,因为买方市场,大把候选人排队等着挑,所以干脆把面试题弄得难一些,目的是筛选,只要好的,合适不合适另说。而一些小公司,比如我厂,得到优秀简历的机会本就不多,如果因为面试题设计不好,没法很好的考察候选人的水平,或者让候选人感到不舒服拒接 offer,都是损失。

这里分享我近期总结的三道编程题,对应初中高三档候选人,我觉得很有区分度,大家也可以试试。

分类
js

在 Node.js 12 中使用 ESM

Node.js 12 之后开始支持 ECMAScript Modules(简称ESM),不过并不是默认开启或者自动切换。坦率地说我也卡了一阵子才搞清楚怎么直接使用。简单记一下吧。

分类
技术 教程

第一场 GitChat 总结

开始之前,先做广告吧。

GitChat 分享 《JavaScript 异步开发全攻略》

为解决异步函数的回调陷阱,开发社区不断摸索,终于折腾出 Promise/A+。它不增加新的语法,可以适配几乎所有浏览器;以队列的形式组织代码,易读好改;捕获异常方案也基本可用。这套方案在迭代中逐步完善,最终被吸收进 ES2015。不仅如此,ES2017 中还增加了 Await/Async,可以用顺序的方式书写异步代码,甚至可以正常抛出捕获错误,维护同一个栈。可以说彻底解决了异步回调的问题。 现在大部分浏览器和 Node.js 都已原生支持 Promise,很多类库也开始返回 Promise 对象,更有各种降级适配策略。Node.js 7+ 则实装了 Await/Async。如果您现在还不会使用,那么我建议您尽快学习一下。

下次直播分享 前端面试攻略:JavaScript 排序与搜索

从事前端开发的同学很多从页面仔入门,比如说我,自学比例很大,有些时候会无意中忽视一些基础,比如算法、数据结构。这些欠缺在某些时候就会显得很致命,比如说面试,或者处理大量数据的场景。所以希望这样的一场分享能够帮助大家夯实原本不太扎实的基础,将来的开发之路更加顺畅。

目前早鸟票发送中,7月13日前门票5折,19日前75折,开播当日恢复全价。

分类
js

使用 Webpack 时需避免循环引用

开发日历控件的时候,对方变更了一下需求,基本上将最终产品分成两个:

  1. 选择连续时间段
  2. 选择多个不连续时间

那么我们知道,对于这种大部分功能一致,只有若干函数逻辑不同的产品,最合适的就是状态模式。于是很自然的,我就拿“2”作为标准模式,“1”作为新模式,将其重构成父类和子类,大概关系如下:

// 父类
// DatePicker.js

import RangeDatePicker from './RangeDatePicker';

class DatePicker {
  ....
  static getInstance(el, options) {
    if (options.scattered) {
      return new DatePicker(el, options);
    } else {
      return new RangeDatePicker(el, options);
    }
}


// 子类
// RangeDatePicker.js

import DatePicker from './DatePicker';

class RangeDatePicker extends DatePicker {

}

因为这个类只有两个成员,所以我把工厂方法 .getInstance() 放到了父类里面,通过判断参数确定应该返回哪一类实例。代码写完,测试的时候却报错:

Super expression must either be null or a function, not undefined

这个意思很明显,被继承的父类不能未定义。然则 DatePicker 明明是定义了的,只是验证两个类文件的话,均未出现任何语法错误。

遇事不决先 Google,还真找到很多结果,不过大多数都和 React.Component 有关,翻了半天一无所获,只好自力更生。打开 Chrome 开发者工具,勾上“Pause on Exceptions”,观察发生异常时的状况,一遍又一遍,我渐渐意识到,发生这个错误的时候,DatePicker 还未能在 webpack 的环境中完成注册。问题找到了!

与其它编译类语言不同,JS 是动态语言,所有 JS 代码都是放到统一的环境里跑的,类的代码如此,import 也是如此。所以对于其他语言,比如 ActionScript、Java,循环引用,即 A 引用 B,B 也引用 A,是没问题的,因为类的代码都会编译到执行文件,执行的时候,都已经在环境中;而 JS 是边执行边置入环境,具体到我这里,在将父类 DatePicker 放入环境时,会先 import 子类 RangeDatePicker 的代码,而子类又会要求 import 父类的代码,父类的代码正在引入中,于是便产生了问题。

想明白这点,后面就好办了,直接创建一个工厂类,把工厂方法放到里面执行,问题便解决了:

import DatePicker from './DatePicker';
import RangeDatePicker from './RangeDatePicker';

export default {
  createDatePicker(el, options) {
    if (options.scattered) {
      return new DatePicker(el, options);
    } else {
      return new RangeDatePicker(el, options);
    }
  }
}

PS:当年写依赖注入和包管理工具的时候,就卡在这个地方,怎么都想不通,于是一直也没写完。没想到这些个浓眉大眼有头发的,也都这么不负责任,这种问题都不解决就搞出来让全世界人用了。