分类: js

有关 JavaScript 的技术文章和行业分析文章。

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

    在任意上下文执行代码,`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() 的确存在一些问题,不能轻易使用,但是当需求摆在面前,经过充分的分析确认之后,我们该用还是要用。

  • 使用 Node.js 驱动 FFMPEG  录屏

    使用 Node.js 驱动 FFMPEG 录屏

    FFMPEG 功能非常强大,不仅能转换视频格式、压缩视频、添加字幕等,还能录制屏幕内容。使用 FFMPEG 录屏的方法很简单:

    # Linux
    ffmpeg -video_size 1024x768 -framerate 25 -f x11grab -i :0.0+100,200 output.mp4 
    
    # macOS
    ffmpeg -f avfoundation -list_devices true -i "" 
    
    # Windows
    ffmpeg -f gdigrab -framerate 30 -offset_x 10 -offset_y 20 -video_size 640x480 -show_region 1 -i desktop output.mkv 

    更详细的介绍可以参考官方文档,这里不做太多摘抄。

    使用 Node.js child_process 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:

    1. exec/execFile 调用程序,当执行完成,一次性获得结果
    2. spawn 调用程序,过程中流式获得输出

    我的录屏对时间点有非常高的要求,力求准确。所以我只能选择 spawn,然后通过检查日志输出得到准确的录制开始时间。

    所以,我的代码大概如此:

    class FfmpegRecorder {
      // 其它函数略去
      startRecording(args) {
        this.isFileReady = new Promise((resolve, reject) => {
          this._isFileReadyResolve = resolve;
          this._isFileReadyReject = reject;
        });
        this.recording = spawn(this.ffmpeg, args);
        
        this.recording.stdout.on('data', this.onStdData);
        this.recording.stderr.on('data', this.onStdErr);
      }
      stopRecording() {
        this.recording.kill('SIGINT');
        const path = resolve(process.cwd(), 'a.mp4');
        return path;
      }
    
      onStdData(data) {
        data = data.toString();
        this.stdout += data;
        if (this.stdout.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
      onStdErr(data) {
        data = data.toString();
        this.stderr += data;
        if (this.stderr.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
    }

    根据我在命令行里直接跑 ffmpeg 的结果,它会先初始化,然后开始录屏。录屏开始时,会输出 Output #0, mp4, to xxx.mp4 这样的日志,所以我就会反复检查 stdoutstderr,直到关键日志出现,然后告诉外面的程序开始录制了。

    这里比较奇怪的是,日志输出应该是正常的,走 stdout 通道,结果只能从 stderr 通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。

    上面的代码忽略了 onErroronExit 部分,有兴趣的同学请等我开源(公司代码)。

    在 Linux,stopRecording(),即 kill('SIGINT')(相当于按下 Ctrl+C)之后,FFMPEG 会终止录屏,并且生成可以播放的视频文件。但是在 Windows,只会留下一个无法播放的文件。

    通过观察命令行直接运行 ffmpeg 的结果和 node.js 保存的结果,我发现缺失了结束录制后处理视频文件的部分。实际上 FFMPEG 录屏时只会记录视频内容,录制结束后才会生成影片的meta 信息,而播放器必须读取后者才可以正常播放。所以我猜测在 Windows 下 kill('SIGINT') 会直接彻底杀死进程,而不仅仅是发送一个信号给 FFMPEG,并让它完成后续的工作。

    做出判断后,我尝试用按下 q 的方式通知 FFMPEG 停止工作,并等待 5s。果然成功,于是我构建了新类,继承前面的 FfmpegRecorder,大概代码如下:

    const endTag = /kb\/s:\d+.\d{2}/m;
    
    class WindowsRecorder extend FfmpegRecorder {
      stopRecording() {
        // 向 child process 输入 `q`
        this.recording.stdin.setEncoding('utf8');
        this.recording.stdin.write('q');
        const p = new Promise((resolve, reject) => {
          this._stopResolve = resolve;
          this._stopReject = reject;
        });
        // 设置一个超时保护 15s
        setTimeout(() => {
          this._stopReject();
        }, 15E3);
        return p;
      }
      onStdData(data) {
        super.onStdData(data);
        if (this._stopResolve && endTag.test(this.stdout)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
      onStdErr(data) {
        super.onStdErr(data);
        if (this._stopResolve && endTag.test(this.stderr)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
    }

    为了功能完善,我没有选择等待固定的时间,而是继续检查日志,直到发现 endTag 标记。另外,我也留下了 15s 超时保护,避免某些暂时没遇到的问题破坏功能。

    至此,功能基本稳定完成。

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

    函数,栈,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 里所有的错误,即使函数位于不同调用栈,也可以正确捕获并且显示在一起,方便调试。

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

    记一个 `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 在当前栈捕获到错误。

  • 使用 File System Access API 在浏览器里操作本地文件

    使用 File System Access API 在浏览器里操作本地文件

    如《Webpack 5 发布,Chrome 86 开始支持本地文件系统》一文所述,Chrome 86 开始,浏览器正式支持操作本地文件。接下来结合最近的使用,分享下用法。

    0. 准备工作:理清概念

    首先,我们要先搞清楚一些概念。实际上,让浏览器操作本地文件是开发者一直在努力并且不停在探索的方向,所以历史上有很多方案,存在很多名称接近但其实并不一样的 API,大家在学习的时候一定要搞清楚,不要弄混。

    最早登场的是 File API,代表功能是 FileReader(参考:《使用 Promise 封装 FileReader》)。这个 API 最大的进步在于,我们可以在浏览器里读取文件,然后操作二进制数据;最后还可以构建内存 URL,并通过 <a download="file.{ext}"> 下载到本地。如此,浏览器可以操作几乎所有二进制数据,作为工具平台的价值大大提高。

    接下来,激进的 Google Chrome 提出并实现了 File System API。这个 API 试图在浏览器里创建一个独立的文件环境,让开发者可以在里面任意操作文件和目录。如果能做好,这会是一个非常好的实现,可以大大提升浏览器作为系统核心的地位。可惜步子不仅大、而且偏,最终失败。我总结原因有二:

    • 一方面,“独立的文件环境”,即无法操作系统本地文件,其实没什么价值,反而增加了用户理解和使用时的心智负担。
    • 另外,当时浏览器的其它限制没有突破——没有包管理、没有 babel、甚至没有 Promise,IE 仍然大量存在,开发难度极大。

    所以最终这套方案死得悄无声息。我曾经的博客《HTML5的File API应用》,可能是为数不多的中文分享。

    接下来是 Chrome Extension、Chrome App、Chrome OS 里的 File (System) API。这几个产品都是 Google 私有,不用考虑其他浏览器厂商,所以可以放开手脚随便搞。这里大家需要注意的是,因为 Google 的产品策略一向是说关就关,所以大家要留心常看文档,别学了一半 API 没了,比如:Extension 的 chrome.fileSystem 就已经弃用了

    最后,也就是今天的主角,File System Access API。这套方案应该是未来的主角。它提供了比较稳妥的本地文件交互模式,即保证了实用价值,又保障了用户的数据安全,明显是前辈 File System API 的继任者。

    它的设计思路也不复杂:

    1. 要求用户手动选择文件或者目录,以获取文件或目录的控制权限
    2. 选择文件或目录后,获取到 FileHandle,后续的操作经由它来进行
    3. FileHandleserializable 对象,所以可以通过序列化和反序列化实现跨 session 的存储(即刷新页面后还能用)

    好,下面看代码。

    1. 读取本地文件

    这段代码可以比较完整的演示 window.showOpenFilePicker API 的用法:

    // 使用 `try...catch` 可以捕获用户取消选择时抛出的错误,如果你对错误不在意,不捕获也行
    try {
      const [handle] = await showOpenFilePicker({
        multiple: false, // 只选择一个文件
        types: [
          {
            description: 'Navlang Files',
            accept: {
              'text/x-navlang': '.nav',
            },
          },
        ],
        excludeAcceptAllOption: true,
      });
    } catch (e) {
      if (e.message.indexOf('The user aborted a request') === -1) {
        console.error(e);
        return;
      }
    }
    
    // 如果没有选择文件,就不需要继续执行了
    if (!handle) {
      return;
    }
    
    // 这里的 options 用来声明对文件的权限,能否写入
    const options = {
      writable: true,
      mode: 'readwrite',
    };
    // 然后向用户要求权限
    if ((await handle.queryPermission(options)) !== 'granted'
      && (await handle.requestPermission(options)) !== 'granted') {
      alert('Please grant permissions to read & write this file.');
      return;
    }
    
    // 前面获取的是 FileHandle,需要转换 File 才能用
    const file = await handle.getFile();
    // 接下来,`file` 就是普通 File 实例,你想怎么处理都可以,比如,获取文本内容
    const code = await file.text();

    2. 保存本地文件

    前面说过,FileHandle 可以序列化,也即可以进行持久化存储。所以我们只需要把对应的 FileHandle 存下来,然后保存即可。

    if (data.file) {
      const writable = await data.file.createWritable();
      await writable.write(data.code);
      await writable.close();
    }

    如果之前没有获取过 FileHandle,则可以通过 window.showSaveFilePicker 来获取:

    try {
      const file = await showSaveFilePicker(filePickerOptions);
    } catch (e) {
      if (e.message.indexOf('The user aborted a request.') === -1) {
        console.error(e);
      }
      return;
    }
    // 然后接前面的代码
    const writable = await file.createWritable();
    await writable.write(data.code);
    await writable.close();

    这个功能现在有一点小问题,不知道是不是 Chrome 实现不太稳定,如果你打开开发者工具,然后钩上“Pause on caught exceptions”,那么保存时会暂停数次,并提示错误。不用理会,直接继续执行即可。我猜测这个过程本来应该由浏览器自动捕获并重试,直到超时保护或者写入成功,但是现在会错误地抛出来。

    3. 总结

    File System Access API 不仅可以操作文件,还可以操作目录,操作目录的方式和文件相仿,我就不详细举例了,大家可以看下后面的参考链接,或者等我用到目录、踩了坑再来分享。

    这个 API 对前端来说意义不小。有了这个功能,Web 可以提供更完整的功能链路,从打开、到编辑、到保存,一套到底。虽然目前只有 Chrome 支持,但还是建议大家尽快把它用起来。


    参考链接:

  • JavaScript 获取正则表达式中子表达式的个数

    JavaScript 获取正则表达式中子表达式的个数

    正如标题所示,我厂有这么一个需求。我不会,老板鄙视我后丢过来一个链接:stackoverflow: Count the capture groups in a qr regex?

    看不太懂 Perl,但是这个思路很棒。所以改写成 JS 版,并记录如下:

    function countCapturingGroups(r){
      r = new RegExp(`|${r.source}`);
      const result = ''.match(r);
      return result.length - 1;
    }
    
    const result = countCapturingGroups(/fo(.)b(..)/);
    console.log(result); // 2
    

    它的原理是这样的。构建一个新正则,包含两部分:空字符和目标正则。空字符正则会完成与目标字符串的匹配,保证有结果(不然的话就会返回 null。接下来 | 会保证后面的正则也是有效的,可以生成包含子表达式结果的数组。

    我们知道,结果是个类数组,结构大约是:

    1. 全部匹配字符串
    2. 0~N 子表达式结果
    3. 其它一些属性

    所以用其长度 – 1 就能获得子表达式的个数。从功耗上来说,这个应该是很节省了。

  • 升级 Vue@2 项目到 Vue@3

    升级 Vue@2 项目到 Vue@3

    这篇主要是笔记。(我估计会是第一篇,因为只迁移了一个项目)

    1. 安装新包

    只记录必须重装的:

    npm i vue@3 vue-loader@16.0.0-beta.8 vue-router@4.0.0-beta.13 @vue/compiler-sfc

    2. 修改 Webpack 配置

    // v2
    const VueLoaderPlugin = require('vue-loader/lib/plugin');
    // v3
    const {VueLoaderPlugin} = require('vue-loader');
    
    // for DefinePlugin
    {
      plugins: [
        new DefinePlugin({
          __VUE_OPTIONS_API__: true,
          __VUE_PROD_DEVTOOLS__: false,
        }),
      ],
    }

    3. 修改入口文件

    没有 new Vue({}) 了,取而代之的是 Vue.createApp({}),后者还支持 tree-shaking。

    也不需要注册 Vue-router 了,直接 app.use(router) 就好。所以传统的入口文件就要修改为:

    // v2
    import Vue from 'vue';
    import VueRouter from 'vue-router';
    import App from './app';
    import 'bootstrap/dist/css/bootstrap.min.css';
    import '@/styl/index.styl';
    import router from './router';
    
    Vue.use(VueRouter);
    
    Vue.config.productionTip = false;
    
    new Vue({
      router,
      ...App,
    }).$mount('#app');
    
    // v3
    import {createApp} from 'vue';
    import App from './app';
    import 'bootstrap/dist/css/bootstrap.min.css';
    import '@/styl/index.styl';
    import router from './router';
    
    const app = createApp({
      ...App,
    });
    app.use(router);
    app.mount('#app');

    4. 修改 router

    Vue-router 的变化很大,建议大家好好看看 迁移手册。就我厂这个项目而言,主要是三个变化:

    1. 使用支持 tree-shaking 的函数 createRouter
    2. 修改 history: createWebHistory()
    3. 使用渲染函数 h 替换之前渲染方式
    // 加载方式
    import {h} from 'vue';
    import {
      createRouter,
      createWebHistory,
      createWebHashHistory,
      RouterView,
    } from 'vue-router';
    
    const routes = [
      {
        path: '/',
        name: 'home',
        component: {
          // vue-router v3
          render(createElement) {
            return createElement('router-view');
          }
    
          // vue-router v4
          render() {
            return h(RouterView);
          },
        },
        children: components,
      },
      // ....
    ];
    
    const router = createRouter({
      // vue-router v3
      mode: process.env.NODE_ENV === 'production' ? 'history' : 'hash',
      // vue-router v4
      history: process.env.NODE_ENV === 'production'
        ? createWebHistory()
        : createWebHashHistory(),
      scrollBehavior: (to) => {
        if (to.hash && !/^#/.test(to.hash)) {
          return {selector: to.hash};
        }
        // 这里有个小改动,x => left, y => top,简单提一下
        return {top: 0};
      },
      routes,
    });

    5. 自定义组件 v-model 修改

    • prop: value => modelValue
    • event: input => `update:modelValue`

    6. 一些小修改

    • beforeDestroy => beforeUnmount

    7. createApp 与 Application,与 Component

    v2 时,我们可以通过 new Vue({}) 初始化 Vue 实例。这个阶段,Vue 默认有一个全局对象 + 若干个实例,除了 local 的,就是全局的。

    v3 时,引入了 Application(应用)的概念,在全局和组件之间,增加了一个新的层级。这样一来,我们就可以在同一个 Web 产品中,使用 Application 来划分命令、组件、mixins 的范围。应该会增加代码的强壮程度(虽然我暂时还没用到)。

    不过,迁移代码的时候,也要注意。以前我们可能 new 一个实例,调用它的 methods;现在不行了,要这样做:

    // v2
    const ins = new Vue({});
    ins.doSomething();
    
    // v3
    const app = createApp({});
    const vm = app.mount('$el');
    vm.doSomething();

    8. 新的响应式 API

    v3 最大的变化就是重构了响应式实现,所以新增了不少响应式 API。同时,也会检查开发者的代码,如果发现不需要响应式的地方用到响应式对象,就会提示开发者,因为响应式会增加系统开销。

    这个时候可以用 markRawtoRaw 方法来修改对象,撤销之前附加在上面的响应式属性,提高访问效率。

    其它 API 还很多,后面慢慢更新吧。

    9. Devtool 和 SourceMap

    遗憾的是,目前 Vue Devtool 无法检测到 Vue。老项目的 SourceMap 也完全不生效,无法正常对 SFC 进行 debug。

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

    使用 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。先推荐给大家吧。

  • 使用 GitHub Registry 托管私有 NPM 源

    使用 GitHub Registry 托管私有 NPM 源

    我厂有不少私有仓库,都是日常开发中提炼出来的,在几个项目中共享,比如 UI、Vuex、网络请求(axios 封装)等。以前的主要形式是使用 npm 安装 GitHub 仓库,即

    npm i openresty/some-repo

    这样 npm 会自动去 GitHub 查找对应的仓库,然后 clone 下来完成安装。项目中的 .npmignore 文件也会正常生效,安装后的目录会忽略不需要的文件。这样做的好处就是简单,只要仓库存在,安装依赖的系统有足够的权限(比如 ssh key),就能顺利安装。在过去的三年时间里,我们一直都这样做。

    但是这样会有一些问题:

    1. package-lock.json 里记录的是 commit hash,只看这个值无法判断版本,npm 也没法使用版本号解决依赖冲突的问题。换言之,比如 A 仓库需要 C 仓库的 1 版本,B 仓库需要 C 仓库的 2 版本,那么就只能同时安装两个版本,因为 npm 无法判断两个版本是否兼容。
    2. 安装的时候要走 git clone,下载量很大,速度很慢,经常被同事吐槽。也会影响 CI 的效率。
    3. git 仓库里要提交编译后的代码,一方面浪费时间和空间,另一方面大量编译后的代码也影响 code review

    所以,考虑之后决定改用 GitHub Registry。GitHub Registry 是 GitHub 提供的私有源,感兴趣的同学可以访问这个页面了解详情。这个服务需要收费,不过我厂本来就是 Team 用户,额度比较富裕,所以并没有阻力。

    经过一段时间的摸索,终于搞成了,下面简单说一下做法。

    对依赖项目

    修改 package.json,把项目名称改成 @openresty/some-repo,即“@公司名/仓库名”。然后增加 publishConfig 字段,设置源为 GitHub registry:

    {
      "publishConfig": {
        "registry": "https://npm.pkg.github.com/"
      },
    }

    接下来,正常使用 npm publish 发布依赖即可。不过在发布之前,最好使用 .npmignore 忽略掉开发时的源文件和配置文件,一方面这些文件对于其它项目的开发没有价值,另一方面无论是存储还是下载消耗流量,都要收费,能省则省嘛。

    对业务项目

    因为这些依赖仓库都是私有项目,所以我们先得解决身份认证的问题。GitHub 提供了 Personal access tokens 方式,生成 token 后,添加到 ~/.npmrc 文件即可:

    //npm.pkg.github.com/:_authToken=<TOKEN>

    接下来,在业务项目中,添加 .npmrc 文件,指名依赖源:

    registry=https://registry.npmjs.org/
    @openresty:registry=https://npm.pkg.github.com

    这段配置分两部分:其它域的依赖,直接从 npm 的源下载;@openresty 域下的依赖,从 GitHub Registry 下载。

    最后,正常使用 npm i @openresty/some-repo 安装依赖即可。


    最后的最后附上修改前后的效果对比,可以看出,改造后效果明显:

    国内修改前real 0m39.543s
    user 0m17.768s
    sys 0m3.944s
    阿里云
    修改后real 0m16.450s
    user 0m15.204s
    sys 0m3.380s
    阿里云
    国外修改前real 0m49.350s
    user 0m22.352s
    sys 0m4.144s
    阿里云
    修改后real 0m30.807s
    user 0m15.780s
    sys 0m3.936s
    阿里云
  • webpack 入口是 vue 文件时,无法合并 CSS

    webpack 入口是 vue 文件时,无法合并 CSS

    在多页网站中合并 CSS 是很常见的优化手法。一般来说,CSS 体积不会太大,使用同一份 CSS,改进用户点击链接后的加载速度,大部分收益都大于多加载几 K CSS 带来的损耗。

    对于 UI 库来说也是如此,用统一的样式库,减少 import 时的心智负担,也是性价比很高的做法。

    最开始我在 UI 库的入口文件里 export 所有组件,然后在其它仓库里用 import {component} from 'my-components' 使用组件。后来通过分析得知,这样做无法 tree-shaking,而且会导致组件库的重复引用,即 A import B,B import C,那么无论 A 里有没有用到 C(比如用到 B 的一个小功能 b1,它不依赖 C),都会把 C 的代码打包进去。

    通过研读 Webpack 的 tree-shaking 文档,我得知也没有什么好办法可以规避这个问题,毕竟 JS 很灵活,你也不知道哪个开发者会搞个 eval('const myRequire = require'),如果要识别解析分辨所有情况太复杂,所以选择最保守的策略:认不出来具体功能的代码都给你带上。(早年我写 NerveNet 的时候,就是想不通这里怎么搞,最后坑掉了。早知道大家都选择绕开,说不定我的 NerveNet 也能如愿写出来了……)

    总之,目前 lodash 的做法比较常见且能解决问题,即增加多入口,为每个可能用到的函数都打包独立的函数。这样需要引用那个就引用哪个,不用担心把整个 lodash 都打包进去。

    于是我就立项开始重构我厂的几个前端库。然后很快就卡在 UI 库上面:无法生成合并过的 CSS 文件。Google 许久没有结果,吃饭前我灵机一动:vue-loader 必须搭配 VueLoaderPlugin 才能正确打包,会不会这个过程有 bug,导致如果我的入口都是 Vue 单文件组件,就会没法正确合并 CSS 文件。

    然后我就测试了一下,代码大约如下:

    // a.js
    import './a.styl'
    
    // b.js
    import './b.styl'

    可以生成合并过的 CSS 文件。与之相较,这样的 Vue 单文件组件就不行:

    // a.vue
    <style lang="stylus">
    body
      font-size 16px
    </style>
    
    // b.vue
    <style lang="stylus">
    body
      color red
    </style>

    但是,同样是 vue 单文件组件,样式使用 import 的方式导入,就没问题:

    // a.vue
    <script>
    import './a.styl';
    </script>
    
    // b.vue
    <script>
    import './b.styl';
    </script>

    现在可以确定是 vue-loader 或者 VueLoaderPlugin 有问题。不过最近身体欠佳,每天要花很多时间锻炼,另外还欠下不少坑要填,所以暂时没空去翻 issue 或者提 issue。哪位同学看见了,愿意帮忙的,可以搞一下,也算参与 开源社区建设了,功德无量。