分类: js

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

  • JavaScript 中使用正则 `u` 标记匹配多语言

    JavaScript 中使用正则 `u` 标记匹配多语言

    JavaScript 里使用 Unicode 编码字符串。Unicode 是一种可变长度的编码类型,大部分时候,它用两个字节表示一个字符,大部分常见字符都在这 65536 的范围内。一些少见字符,比如各种语言文字、emoji,则会用到 4 个字节。

    以前我们用正则校验字符串的时候,可以用 /[a-zA-Z0-9]/ 检查字符,这样对英文和数字没问题,但不能匹配中文。如果要匹配中文和中文标点,可以用:

    /[\u4E00-\u9FCC\u3002\uff1b\uff0c\uff1a\u201c\u201d\uff08\uff09\u3001\uff1f\u300a\u300b]+/g

    ES2018 之后,我们可以使用 Unicode 属性 \p{...} 来匹配某一个类型的字符串,配合 /u 标记,就可以方便地匹配多字节字符串了。

    在匹配多语言文字时,可以传入 Script 参数,达到非常高效且简便的写法。比如中文,就是 \p{sc=Han},上面那么长的穷举(其实还没举完)正则只需要这么几个简单的字符就能替换,简单多了,对吧?借用下别人的例子:

    let regexp = /\p{sc=Han}/gu; // returns Chinese hieroglyphs
    
    let str = `Hello Привет 你好 123_456`;
    
    alert( str.match(regexp) ); // 你,好

    我们还可以用这个属性来匹配俄文:\p{sc=Cyrillic}。不过有趣的是,欧洲诸国文字多少有些区别,除了我们最熟悉的英文 26 个字母,德文就有 üöä,法文也有 ù,但它们都是拉丁文, \p{sc=Latin},甚至土耳其文也是,只有俄文不一样。

    Babel 也包含了对应的插件:@babel/plugin-proposal-unicode-property-regex · Babel (babeljs.io),在古早浏览器里可以转换成非 Unicode 形态,所以基本上可以放心使用。

    还是要不断更新自己的知识才行呀。

    (更多…)
  • Vue3 < script setup > + TypeScript 笔记

    Vue3 < script setup > + TypeScript 笔记

    近期把一个老项目(Vue 组件库)从 Options API 风格重构成了 <script setup> 风格 + TypeScript,写篇博客记录下坑和心得。

    注:这篇博客只记录超出我踩过坑和不熟悉的内容,符合直觉、看了文档写完一次过的部分,我就不记录了。

    0. Composition API 与 <script setup>

    Vue 3 给我们带来了 Composition API,改变了之前 Options API 使用 mixins 继承和扩展组件的开发模式。这种模式让前端在开发时复用组件、改进组件变得更容易。具体改进我在本文里不再详述,大家可以看下 Vue master 的视频教程:Why the Composition API

    之后尤大又提出了 script-setup rfc,进一步改进了使用 Composition API 的体验。简单总结下新提案:

    1. 使用 <script setup> 取代 setup(),里面定义的所有变量,包括函数,都可以直接在模版中使用,不需要手动 return
    2. 可以使用顶级 await
    3. <script setup> 可以与其它 <script> 在同一个 SFC 里共存

    实际体验之后,我觉得这个提案的确能节省不少代码,不过也会增加一些复杂度。

    1. 导出 name 等非模版属性

    <script setup>setup() 的语法糖,主要目的是降低 composition API 的代码复杂度。它直接向模板暴露整个 <script setup> 的上下文,所有定义过的变量自动导出供模版使用,这样我们就不需要手动一个一个 return 了。

    所以它就不适合导出其它非模版属性,比如组件名称 name。如有需要,可添加一个普通 <script> 节点,用常见的 export default 导出初始化对象。如下:

    <script>
    export default {
      name: 'SomeVue3Component',
    };
    </script>
    
    <script setup>
    import {ref} from 'vue';
    
    const foo = ref('bar');
    </script>

    2. defineProps/defineEmits

    要在 <script setup> 里使用 props(传入参数)和 emit(广播事件),需要使用 defineProps()defineEmits() 定义。但要注意,这俩东西其实是编译宏,并非真实函数,不能把它们当作函数来使用。所以也不需要 import,当它们是全局函数直接用就好。同时记得修改 .eslintrc.js 把它们添加到 global 里。

    最简单的使用方法如下,以前的 props 定义规则可以沿用。

    const props = defineProps({
      foo : {
        type: String,
        default: 'bar',
      },
    });
    const emit = defineEmits(['change']);

    要配合 TypeScript,通常需要使用 withDefaults()props 生成默认值。这也是个宏,不能当函数用,也不能使用当前环境里的变量。改造后的代码如下:

    interface Props = {
      foo: string;
    };
    const props = withDefaults(defineProps<Props>(), {
      foo: 'bar', // 'bar' 不能是变量
    });
    const {
      foo,
    } = toRefs(props);
    
    const emit = defineEmits<{
      (e:'change', value:string),
    }>();

    这个地方的设计相当不完善,我不知道 Vue 团队会如何改进这里。比如,我们不能使继承 interface,然后再初始化 props,因为继承是常规 ts 语句,而 defineProps 是编译宏,两者的工作环境不一样。而因为无法使用变量,我们也无法将父级组件的 props 混入本地 props。于是复用组件又变得麻烦起来。

    耐心等待吧。

    3. 使用 undefined 初始化对象

    定义 props 问题真不少。有一些参数是可选参数,不一定要定义,也不一定要用到;但是使用 ts 定义时,即使如此,也要初始化他们,可以传值为 undefined,不然 tsc 可能会报告错误:变量可能为空。之所以用 undefined,而不是 null,则是因为 TypeScript 认为 null 是独立类型。

    即:

    interface Props {
      foo?: string;
    }
    const props = withDefaults(defineProps<Props>(), {
      foo: undefined,
    });
    const {
      foo,
    } = toRefs(props);
    
    // 接下来才能正常使用
    const bar = computed(() => {
      return foo.value || 'bar';
    });

    4. 使用的变量未初始化错误

    有些函数,可以传入为定义变量做参数,但是函数自身的签名没有很好的体现这一点,就会报错。比如 setTimeout,以下这段代码就会报告:

    type timer = ReturnType<typeof setTimeout>;
    let timeout:timer;
    function doAction() {
      clearTimeout(timeout);
    }

    我确定这段代码没问题,但是 tsc 不给过,只好给 timeout 加上 ! 修饰符,即:let timeout!:timer。这样就可以了。

    5. Vue SFC 文件类型定义

    为让 tsc 能够正确理解 Vue SFC 格式,需要创建这个描述文件,并且告诉 tsc 加载这个描述文件。

    declare module "*.vue" {
      import { defineComponent } from "vue";
      const component: ReturnType<typeof defineComponent>;
      export default component;
    }
    {
      "compilerOptions": {
        "target": "esnext",
        "module": "esnext",
        "strict": true,
        "jsx": "preserve",
        "moduleResolution": "node",
        "suppressImplicitAnyIndexErrors": true,
        "allowSyntheticDefaultImports": true,
        "allowJs": true,
        "resolveJsonModule": true,
        "esModuleInterop": true,
    
        "paths": {
          "@/*": [
            "./src/*"
          ]
        },
        "lib": ["DOM", "ESNext"]
      },
      "include": [
        "src/**/*.ts",
        "src/**/*.vue",
        "test/**/*.ts"
      ],
      "exclude": [
        "node_modules"
      ],
      "files": ["src/types/vue-shims.d.ts"]
    }

    6. 待解决问题

    • 因为第一次使用 TypeScript,很多不熟悉的地方,tsc 大量报错。但是由于使用了 vue-loader,所以 tsc 报告的错误行号基本都不对,很难查找问题所在,浪费了大量时间。
    • 导入了 moment locale 文件,但是缺少定义,不知道该怎么声明某个对象属于某个接口。可能要通过前面类似定义 Vue SFC 的方式。

    7. 项目地址

    对项目感兴趣,或者寻求范例的同学可以在 GitHub 上找到这个项目:meathill/muimui-ui: A simple vue 3 ui components suit. (github.com)

  • 正确使用 @babel/preset-env 与 @babel/plugin-transform-runtime

    正确使用 @babel/preset-env 与 @babel/plugin-transform-runtime

    这几天又在折腾项目脚手架,看到之前的配置里用到 transform-runtime,于是就想研究下。找了半天,发现现有的文章、讨论都没有准确、合理的说明,所以写篇博客介绍一下。

    定位

    @babel/preset-env

    @babel/preset-env 是一大堆插件的集合,包含了当前浏览器环境下,所有语言特性的插件,可以根据 browserslist 的结果,选择合适的插件将新语言特性转译成旧浏览器可以支持的表达方式。

    官方推荐使用预制件(preset),可以大大节省配置时间,提高开发效率。

    @babel/plugin-transform-runtime

    babel 转译时,往往需要用到一些工具函数,比如 _extend。转译以文件为单位,意味着每个文件里都可能有重复的 babel 工具函数。现代化项目常有大量的文件,就可能产生重复和浪费。

    @babel/plugin-transform-runtime 可以帮我们把直接写入文件里工具函数变成对 core-js 的引用,可以减少转译后的文件大小。

    使用场景

    从上面的定位可以看出,@babel/plugin-transform-runtime@babel/preset-env 并不重合,两者的功能没有交集。

    对于任何需要考虑兼容性的项目,我们都应该使用 babel 进行转译,并使用 @babel/preset-env 降低配置的复杂度,大部分通用的配置如下:

    module.exports = {
      'presets': [
        [
          '@babel/preset-env',
          {
            'corejs': 3, // 使用 core-js@3 版本,core-js@2 从 ES2017 之后就没再更新了,不推荐使用
            'useBuiltIns': 'usage', // 只转译用到的新语言元素
            'bugfix': true, // v7.9 之后引入的新选项,尽量减小转译后的代码体积,v8 之后会变成默认选项
          },
        ],
      ],
    };

    小型项目可以到此为止,中大型项目、文件数量比较多的,可以引入 @babel/plugin-transform-transform。此时我们需要先安装两个依赖——注意,这俩依赖都要装:

    # 用来开发、debug 和编译
    npm i @babel/plugin-transform-runtime -D
    
    # 用来在生产环境中使用
    npm i @babel/runtime
    # 如果要使用特定的 core-js 版本,也可以安装特定的 runtime
    npm install --save @babel/runtime-corejs2
    npm install --save @babel/runtime-corejs3

    接下来,修改上面的 babel.config.js,加入插件配置:

    module.exports = {
      // 重复上面的配置
      "plugins": [
        // 其它插件
        [
          "@babel/plugin-transform-runtime",
          // 一般情况下我们使用默认配置即可
        ],
      ],
    };

    完成。

    库项目

    库类项目也推荐使用 @babel/plugin-transform-runtime,因为库项目通常会面临另一个问题。如果我们直接导入 core-js 作 polyfill 的话,像 PromiseSetMap 这样的全局对象就会被覆盖。对于一般的应用而言,问题不大;但如果是库,你无法预期其它开发者会在什么情况下使用你的库,很可能他的目标平台都支持这些新语法元素,不希望转译污染。

    此时,使用 @babel/plugin-transform-runtime 可以让 babel 在转译时使用沙箱包装 polyfill,避免污染全局。

    参考文档

  • SonarQube + SonarJS 体验笔记

    试用了一下 SonarSource 开发的代码质量静态分析工具,记录一些过程和体验。

    SonarQube

    SonarQube 是 SonarSource 开发的代码质量静态分析工具,有四个不同的收费级别,分别是:

    1. 社区版(免费)
    2. 开发者版(个人版)
    3. 企业版
    4. 数据中心版

    很自然我会选择社区版来体验。社区版支持 15 种语言,包括我们前端要用到的 CSS、HTML、JS、TS,以及衍生出来的 React、Vue。基本上就够我们用了。社区版支持自主部署,后面三个付费版基本上对主要功能(代码质量静态分析)没有变化,只是增加语言类型、代码行数和并发,属于企业级需求,可以说对商业相当友好,起步完全免费。

    安装和使用

    官方提供安装文档:Install the Server | SonarQube Docs

    安装后,可以使用 SonarScanner 对代码仓库进行扫描,并将结果上传到 SonarCube 实例上,以供查看。

    分析结果很全面,包含可靠性、安全性、可维护性、覆盖率、重复性、复杂度等等。用他们自己的话说,SonarJS “可能”是最好的静态分析工具,此言非虚。

    SonarJS

    从 SonarQube 的安装文档可以看出,一套完整 Qube 产品由三个部分组成:

    1. 数据库,用来存储数据
    2. Web 服务器,用来提供我们查阅的界面
    3. 各种 scanner,用来扫描项目

    SonarJS 就是其中之一,可以单独使用。不过我没仔细研究。

    eslint-plugin-sonarjs

    我们还可以把 sonarjs 整合到现有开发流程当中,通过添加 eslint-plugin-sonarjs 插件。

    (小小吐个槽,作为质量管理工具,eslint-plugin-sonarjs 上来就往我的项目中添加了 33 个威胁,其中 11 个中等威胁, 22 个高等威胁……)

    使用方式比较简单:

    1. npm i eslint-plugin-sonarjs -D 安装插件
    2. 修改配置文件,添加 { plugins: ['sonarjs'] },以及规则集 { extends: ['plugin:sonarjs/recommended'] } 即可
    3. 如果需要启动全部规则,则需要使用 @typescript-eslint/parser。具体的还是看官方文档吧。

    不过这个 eslint 插件里的规则似乎并不够多,粗粗一数也就 30 左右,远远少于 SonarJS 号称 240+的规则,可能还是受限于 ESLint 本身吧。可能的话还是要通过 scanner 来做检查。

    总结

    作为一款免费工具,SonarQube 很是给了我一些惊喜。我准备自己搭一个服务器端,然后把 eslint-plugin-sonarjs 加到我所有个人项目中。

  • 捕获 promisify  `child_process.exec` 的错误

    捕获 promisify `child_process.exec` 的错误

    这个东西文档里没写清楚,所以写篇博客记一下。

    在 Node.js 里,我们可以使用 child_process 下的命令分裂出一个进程,执行其他的命令。这些命令包括 execexecFilespawn

    我比较常使用 execspawn。前者用起来比较方便,只要传入完整的命令和参数,很接近日常命令行体验;后者传参要麻烦一些,不过可以实时获取输出,包括 stdoutstderr,比较方便给用户及时反馈。

    下面贴一下文档里的例子,spawn 的使用将来有机会再说。

    const { exec } = require('child_process');
    exec('cat *.js missing_file | wc -l', (error, stdout, stderr) => {
      if (error) {
        console.error(`exec error: ${error}`);
        return;
      }
      console.log(`stdout: ${stdout}`);
      console.error(`stderr: ${stderr}`);
    });

    Node.js 8 之后,我们可以用 util.promisify() 命令将 exec 转换为 Promise 风格的方法,即不再需要 callback 函数,而是返回 Promise 实例,方便我们链式调用或者使用 Async function。

    此时,它的样子是这样的:

    const util = require('util');
    const exec = util.promisify(require('child_process').exec);
    
    async function lsExample() {
      const { stdout, stderr } = await exec('ls');
      console.log('stdout:', stdout);
      console.error('stderr:', stderr);
    }
    lsExample();

    官方文档没解释清楚错误处理,经过我的尝试,是这样的:

    1. 命令发生错误,或者被意外中断都会引发错误
    2. 如果不出错,就会正确返回 stdoutstderr
    3. 否则,错误实例里会包含 stdoutstderrcode
    4. 1~127 是各种错误编码,128+ 则是 signal + 127 的结果,通常来说是受控于我们的操作。比如使用 FFmpeg 录屏的时候,结束录制就要 Ctrl+C,此时就会得到值为 128 的 code。所以此时很可能不是真的失败。
    const util = require('util');
    const exec = util.promisify(require('child_process').exec);
    
    (async () => {
      let code, stdout, stderr;
      try {
        ({stdout, stderr} = await exec('ls'));
      } catch (e) {
        ({code, stdout, stderr} = e);
      }
    
      if (code && code > 127) {
        // 确实失败了
      }
      console.log('stdout:', stdout);
      console.log('stderr:', stderr);
    })();
  • node.js 里使用 fifo

    node.js 里使用 fifo

    0. 需求

    前两天 Showman 遇到一个需求:

    1. 我们需要在服务器端录制视频
    2. 录制视频的过程主要由 node.js 控制,借助 puppeteer 操作浏览器
    3. 但是也会需要执行一些 shell 命令,此时为安全考虑,我们会启动一个封闭的临时环境给用户执行
    4. 这些封闭环境是用户进程间共用的,不会随时启动随时销毁
    5. 所以 node.js 就需要在其它环境里执行一些操作,返回内容,等待执行完毕后再继续下面的

    于是我的同事就让我用 fifo。

    1. 什么是 fifo

    我以前没有用过 fifo,所以搜索了一下。

    FIFO 特殊文件(同具名管道)与管道类似,只是可以用访问文件系统的方式来访问它。它可以被多个进程同时打开和读写。当进程通过 FIFO 交换数据时,内核将直接在内部交换数据,而不会写入到文件系统中。因此,FIFO 特殊文件在文件系统中没有内容;文件系统的入口(即文件)只是作为引用方式,让各进程能够使用文件名来访问管道。

    原文:https://man7.org/linux/man-pages/man7/fifo.7.html

    管道大家应该都知道,把 A 进程的输出直接输入到 B 进程里,加快处理速度。fifo 与管道的差别就是 fifo 可以通过文件路径直接访问,用起来更简单。

    2. 在命令行里使用 fifo

    创建 fifo,使用 mkfifo 命令:

    mkfifo xxx.fifo

    写入内容到 fifo:

    echo "something" > xxx.fifo

    读取 fifo:

    cat xxx.fifo

    因为 fifo 是管道,内容直接走内核,所以实际上硬盘上不会存储任何内容。如果我们在写入之后再 cat fifo,就不会得到任何内容。

    3. 在 node.js 里使用 fifo

    在 node.js 里使用 fifo 需要用 fs.opennet.Socket。因为我需要在执行完毕后继续下一步,所以进行了 Promise 封装:

    try {
      // 为避免执行时间过长导致进程超时,不断输出些内容
      const interval = setInterval(() => {
        log('termlang is processing...');
      }, 3E4);
      await new Promise((resolve, reject) => {
        // 打开名为 $basename-$lineno.sp.fifo 的管道
        open('./$basename-$lineno.sp.fifo', constants.O_RDONLY | constants.O_NONBLOCK, (err, fd) => {
          if (err) {
            clearInterval(interval);
            reject(err);
          }
          const pipe = new Socket({fd});
          pipe.on('data', data => {
            data = data.toString();
            // 以输出内容包括 finished 或 errored 为结束标记
            if (/(finished|errored)/.test(data)) {
              resolve(data);
            }
          });
        });
      });
      clearInterval(interval);
    } catch (err) {
      log(err);
    }

    4. 总结

    作为半路出家的前端,我对系统、对 Linux 一直缺乏了解。所以类似管道这种东西,我一直也不太熟悉,这次算学会了一个新技能,记录分享一下。

  • 聊聊 NPM 里的版本号和依赖

    聊聊 NPM 里的版本号和依赖

    好像一直没有写过版本号和依赖相关的内容,偶尔会有同学问,所以写一篇总结一下。

    0. Semver

    我们目前使用的版本规范通常基于 Semver,语义化版本。官方网站:语义化版本 2.0.0 | Semantic Versioning (semver.org)。按照其规则,版本号的结构应该是:

    主(大)版本号.次(小)版本号.修正(补丁版本)号

    其中,

    • 主版本号一般包含架构和 API 的变化。如果 API 出现重大变化,使得依赖它的软件要重构,那么就要体现在大版本号里。不过现在的代码仓库很少有破坏式重构,API 一般能够在至少 2、3 个大版本里保持稳定。所以主版本号变化一般出现在大型重构时,仓库内部的代码架构和组织形式出现重大变化,或者基于不同系统,就需要升级主版本了。
    • 次版本号一般表达功能变化。架构没有变化,原有 API 也基本维持不变,只是新增了功能。这个时候就会上调次版本号。
    • 修订号一般表示修复 bug。

    0.1 年代版本号

    另一种流行的版本号规范是年代版本号,比如 Ubuntu,每年会发布两个版本,目前是 21.04,10月份会发布 21.10。偶数年的 .04 版本会最终成为 LTS,长期维护版本;奇数年的版本和 .10 版本则只维护半年,里面包含各种最新的软件组件,方便喜欢尝新的用户。

    前端常见的软件和库,包括 node.js、Angular、Electron 也用这种方式确定版本号。

    1. 依赖中指定版本号

    我们在项目中可能会使用大量开源代码,这些开源代码通常都会使用包管理工具(比如 NPM,node package manager)安装和管理。

    在 package.json 里,我们可以使用几个运算符告诉 NPM 我们希望怎么使用这些依赖:

    • 不写,repo: '1.0.0'。要求使用 1.0.0 版本的 repo,必须完全一致。
    • ~repo: ~1.0.0。要求大版本为 1,小版本为 0,修订版本不限制,比 0 高就可以。
    • ^repo: ^1.0.0。要求大版本为 1,小版本高于 0 就可以。

    一般来说,使用 npm i repo 安装的依赖,默认规则是 ^;使用 npm i repo@version 安装的依赖,默认规则是写死。

    2. 升级依赖

    这个世界上不存在没有 Bug 的代码,也没有功能完善的代码。使用开源仓库,我们就要考虑升级依赖。一方面可以使用新功能,一方面可以解决 bug。

    通常来说,直接使用 npm update 就能升级项目依赖,NPM 会按照(1)里设定的规则更新依赖。

    3. 升级依赖的大版本

    只使用 npm update 无法升级大版本。原因如上文所述,大版本可能包含破坏性的 API 更新,很容易导致 dev/build 失败,作为工具无法妥善处理,必须交给开发者手动完成。

    很多同学因此不愿意升级大版本。但我建议大家还是要找机会做升级,尤其工具链,比如 webpack、babel。这些工具很多时候有时效性,长期不升级会导致各种问题。而且,升级工具链本身这也是我们偿还技术债务的好机会。

    其实升级工具链的大版本并不复杂,大多数半天就搞定了;如果没有用到偏门功能,甚至可能直接升级就能跑。——通常来说,开源软件的作者面对使用频率高的功能,会比较保守;没人用的功能改动就会大刀阔斧一些。

    升级依赖需要指定大版本,比如从 webpack 4 升级到 webpack 5,可以使用 npm i webpack@5,这样会安装大版本是 v5 的最新版本。这里有个小建议,公司的生产级别的项目,最好不要着急升级,等到 X.2 X.3 这样基本稳定的次版本发布后再升级,可以避免踩很多坑。

    一般来说,开源仓库的官方也会提供迁移指南,比如 webpack v4 to v5。只要你有 v4 的配置经验,照着指南操作,大多数时候都能顺利完成。

    4. 解决 npm audit 问题

    开源仓库的安全问题日趋严重,GitHub 和 NPM 都会帮我们检查依赖,并且根据已知的安全问题列表发出警告。所以在安装依赖时,我们经常能看到类似下面这种警告信息:

    found 5 moderate severity vulnerabilities
      run `npm audit fix` to fix them, or `npm audit` for details

    这个时候,我们应该执行 npm audit 查看所有的审计结果,可能得到如下的报告:

    ┌───────────────┬──────────────────────────────────────────────────────────────┐
    │ Moderate      │ Regular expression denial of service                         │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Package       │ glob-parent                                                  │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Patched in    │ >=5.1.2                                                      │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Dependency of │ @vue/cli-service [dev]                                       │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Path          │ @vue/cli-service > webpack-dev-server > chokidar >           │
    │               │ glob-parent                                                  │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ More info     │ https://npmjs.com/advisories/1751                            │
    └───────────────┴──────────────────────────────────────────────────────────────┘

    这个报告说明有问题的包是 glob-parent,它由 webpack-dev-server 引入,又因为 @vue/cli-service 而最终成为项目的依赖。很明显,它是 @vue/cli 的组成部分,是前端工具链的一环。那么通常来说,它的危害层级很低,多半不会影响到项目整体安全性。

    如果是 node.js 项目,就要小心了,服务器和后端都是安全重灾区,后果可能会很严重。这个时候,你可以往上翻,找到这些报告的最前面,如果可以修复,NPM 会告诉你应该运行什么命令更新问题依赖。如果不能修复,就要自己想办法了。

    比如,你可以移除出问题的依赖,不过多半不可行。或者,我比较常用的方案是,在 GitHub 上找到依赖仓库,自己 fork 一份,然后升级其中的依赖,然后发布一个我个人的版本,接下来就用我自己的版本。等到官方修复后,再用回官方版本。

    5. 总结

    最后按惯例总结一下。依赖是我们软件产品的重要组成部分,它们的版本事关重大,必须给予足够的关注。至少,每个月要检查一次依赖的安全审计问题,如有需要,就升级依赖。

    我们自己写代码的时候,也要遵守 Semver 的规定,适时上调版本号,让版本号能表示代码的发展情况。

  • 用 express.js 实现流式输出 HTTP 响应

    用 express.js 实现流式输出 HTTP 响应

    0. 前言

    我们先来总结一下客户端与服务器端的数据交互方式:

    1. ajax,即 XMLHttpRequest,最常见的数据交互方式,适用于较轻量级的一次性请求,比如用户登录、CRUD 等。
    2. SSE,服务器端事件,适用于有大量服务器端推送、且不需要从客户端发送数据的需求,比如盯股票行情
    3. WebSocket,适用于上下行数据都很多、都很频繁的情况,比如在线聊天、网络游戏等

    单从技术角度来看,大概是这几个。根据需求变化,我也可以组合出更多方案。比如,类似 Showman 生成视频这种需求,耗时很长,对服务器资源占用很高,就比较适合:

    1. 发起请求,产生一条任务记录,进入队列
    2. 服务器上运行独立的进程,从队列中取任务出来执行,并记录日志
    3. 客户端不断检查任务进度

    1. 需求

    今天的需求,介于轻量一次性,与需要服务器长时间执行之间,即 FastTest 中的发布功能。这里,我们有以下需求:

    1. 管理员点击 Publish 按钮,开始发布静态网站
    2. 服务器需要先保存数据,然后调用 webpack 发布所有语言版本
    3. 只有少数管理员会使用此功能,服务器压力不大
    4. 管理员希望能了解发布进度,及每一步的状态

    所以,提交任务后等待服务器慢慢跑就不合适;等待请求完成一次性看到所有结果也不合适;最合适的,就是基于长链接,不断获取返回信息,并在前端实时看到进度。也即是:流式输出 HTTP 响应。

    2. 实现

    2.1 后端

    后端实现我选择 node.js + express.js,在后端领域,这两个我比较熟悉。express.js 是在 node.js 网络模块上进行封装得来,使用起来很简单,也支持原生 node.js 方法。

    首先我们创建一个服务器:

    const express = require('express');
    const app = express();
    const port = 3100;
    
    app.listen(port, () => {
      console.log('FastTest Admin API at: ', port);
    });

    然后,我们在需要流式返回响应的接口里设置相应头 Content-type: application/octet-stream。接下来,只要我们不断向输出流写入内容就可以了。哦,对了,结束的时候,我们还要关闭输出流。

    app.post('/data', async(req, res, next) => {
      res.setHeader('Content-type', 'application/octet-stream');
      
      // 第一次返回
      res.write('Local data saved. Start to build dist。files.\n');
    
      // 数次返回
      for (const item of items) {
        await doSomething(item);
        res.write(`${item} done successfully.\n`);
      }
    
      // 最后,全部完成
      res.write('All done.');
      // 关闭输出流
      res.end();
    });

    2.2 axios

    axios 是很流行的 ajax 库,它进行了 Promise 封装,用起来很方便。这里我们要用 onDownloadProgress(即 axios 对 XMLHttpRequest.progress 的封装)获取下载进度,它会在每次服务器返回响应字符串的时候更新,我们只需要截取上次响应之后,这次响应新增的内容,即可。

    function publish(data, onDownloadProgress) {
      return axios.post('/data', data, {
        onDownloadProgress,
      });
    }
    
    async function doPublish() {
      if (isLoading.value) {
        return;
      }
    
      isPublishing.value = true;
      message.value = status.value = null;
    
      try {
        const { cases, lang } = store.state;
        let offset = 0;
        await publish({ cases, lang }, ({ target: xhr }) => {
          // responseText 包含了从一开始到此刻的全部响应内容,所以我们需要从上次结束的位置截取,获得新增的内容
          const { responseText } = xhr;
          const chunk = responseText.substring(offset);
          // 记录这一次的结束位置
          offset = responseText.length;
          currentStatus.value = chunk;
        });
        status.value = true;
        currentStatus.value = '';
        message.value = 'Published successfully.';
      } catch (e) {
        message.value = 'Failed to publish. ' + e.message;
      }
      isPublishing.value = false;
    }

    2.3 Vue3

    相对来说,Vue3 的部分最容易。这里我用了 animate.css 的 flash 效果,让信息更显眼。除此之外,就是简单的赋值。

    <template lang="pug">
    .alert.alert-info.mb-0.me-2.py-1.px-3.animated.flash.infinite.slower(
      v-if="currentStatus",
    ) {{currentStatus}}
    </template>

    3. 效果演示

    流式输出效果演示

    4. 部署

    一般来说,我们很少会直接用 node.js 当服务器,多半会启动 node.js 服务,然后用 nginx 反向代理给用户访问。这里需要注意,nginx 默认会将响应内容存入缓冲区,然后批量返回给客户端。这会导致流式输出无效,变成常规的执行完毕后一次性输出。

    所以我们必须修改 nginx 的配置:

    # 仅展示有关配置
    location /data {
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header Host $http_host;
            proxy_pass http://localhost:3100;
            # 关闭代理缓冲区,所有响应实时输出
            proxy_buffering off;
    }

    5. 总结 & 扩展阅读

    前些天我帮朋友做了个小项目,叫 FastTest。项目很简单,不过我主动扩展了技术选型,使用了非常多的技术,都是浅显入门的程度,非常适合用来做基础入门学习。本文也是从这个项目中提炼出来的。建议大家有空的时候看看,拉到本地,跑起来试试看效果。

    文中的几段代码,分别位于:

    项目的详细介绍请见:超全面全栈入门项目:fastest

    扩展阅读

  • 检查超宽元素的脚本

    检查超宽元素的脚本

    有时候我们制作页面,搞着搞着发现超宽,出现横向滚动条。于是我们就要想办法调整样式,但是往往超宽的只有那么一两个元素,并不是很好找,所以我就写了下面一个脚本,在页面里跑一下就能找到超宽的元素,然后针对性调整一下样式就可以了:

    // 这里的 375 主要针对基于 iPhone 6 开发移动端页面时
    function traverse(parent) {
      let target;
      for (const elem of parent.children) {
        const rect = elem.getBoundingClientRect();
        const {left, width} = rect;
        if (left + width > 375) {
          target = elem;
          break;
        }
        target = traverse(elem);
        if (target) {
          return target;
        }
      }
      if (target) {
        return target;
      }
    }

    使用的时候,打开页面的开发者工具,将这段代码复制到 console 里面,然后执行 traverse(document.body) 就可以找到超宽的元素,然后想办法调整它即可。

    当然我们也可以继续用这个函数探索可疑元素,找到更具体的超宽元素;或者找到其它超宽元素。这些就留给大家自行探索吧。

  • Webpack 不支持 `import.meta`,利用 ESM 在浏览器里使用 yargs

    Webpack 不支持 `import.meta`,利用 ESM 在浏览器里使用 yargs

    前些天遇到一个需求:解析 curl 请求,并转换成 ajax 请求由浏览器发出去。

    我觉得这个需求听起来不算稀罕,理论上应该有现成的库。于是在 npm 找了一下,发现 curlconverter 似乎可以满足需求。但是使用的时候报错:Module parse failed: Unexpected token

    这个错误很奇怪,看起来像是 loader 没配好。打开报错的文件位置,怎么看语法都没问题。尝试修改 webpack 配置,也未果。因为项目是 vue-cli 创建的,在如何查看最终配置上也浪费很多时间。

    最后继续诉诸 Google,关键词换来换去,终于在搜索 mjs Module parse failed: Unexpected token 时找到这个 issue:https://github.com/arnog/mathlive/issues/525,继而找到 https://github.com/webpack/webpack/issues/6719,终于确定,这是 webpack 的问题。

    因为 webpack 不支持 import.meta,所以会把 import.meta 当成语法错报告。我觉得这个行为很扯,因为 loader 配错也会报这个,所以对于第一次接触到这个问题的开发者(比如我)而言,会浪费大量时间在那些初级错误的搜索结果里。

    接下来解决问题。

    curlconverter 虽然不能直接使用,但仔细阅读它的代码,其中 https://github.com/NickCarneiro/curlconverter/blob/master/util.js 解析 curl 命令的功能实现应该问题不大,我只要想办法把 yargs 加载进来即可。而 yargs 支持浏览器 ESM 加载,所以我在页面里添加了 <script type="module" src="./yargs.js">,使用如下代码:

    // 虽然 yargs 已经到 16.2,但是 16.0.4 之后的版本都有问题
    import Yargs from 'https://unpkg.com/yargs@16.0.3/browser.mjs';
    
    // 加载完成 yargs 之后,把它挂载到 window 上
    window.Yargs = Yargs;

    将 yargs 挂到 window 上,成为外部库。然后在 vue.config.js 里配置 externals:

    module.exports = {
      chainWebpack: config => {
        config.externals.yargs = 'commonjs Yargs';
      }
    }

    接下来,将前面说的 utils.js 复制到本地并修改其中 parseCurlCommand 的实现,最终完成了需求。


    总结一下:

    1. 使用 yargs 解析命令行请求比较方便,远比自己写方便
    2. yargs 无法配合 webpack,据说可以配合 rollup 或者 snowpack,在我的 vue-cli 项目中,需要使用一些特殊的手段加载
    3. curlconverter 也很好用,可惜不能直接用