分类: js

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

  • iOS Safari 播放音频的技巧分享

    iOS Safari 播放音频的技巧分享

    开发 Web App 一直是个蛮尴尬的事情。一方面,Google 不断在推;PWA 等技术也越来越好;另一方面,当我们真的像用 Web 开发 App 的时候,总会在不同地方踩到各种坑,猝不及防。尤其是 iOS Safari,各种 bug 层出不穷,坑死个人。今天分享一下在 iOS Safari 播放音频的一些技巧,希望节省大家将来的时间。

    基础:使用 new Audio() 创建播放器

    播放声音看起来并不难,在桌面上实现也比较简单,直接 const audio = new Audio(音频地址); audio.play() 就好了。只要你的音频文件可以正常访问,那你应该很快就能听到声音。

    这个过程实际上创建了一个 HTMLAudioElement 的实例。所以,你也可以在页面上直接插入一个 <audio>,然后通过某种方式拿到它的引用,再使用它播放音频。

    除了初始化的时候传入音频地址,在任何时候改变音频地址,然后再 .play() 都可以,很简单。

    在用户点击时提前创建播放器

    但是在 iOS Safari 上,就有问题。因为手机端的限制更严格,只有用户主动操作(点击、敲击)时,新建的 <audio> 才能正常播放。如果你在其它时间创建 <audio> 并播放,一切看起来都是正常的,没有任何报错,网络也会正常产生请求,但就是不会发出声音。

    解决方案就是在用户产生点击、敲击的时候,就把 <audio> 创建好,然后在需要播放的时候让它播放声音。

    双播放器切换实现连续播放

    有时候我们需要循环连续播放一首歌,比如游戏背景音乐,或者打坐的时候播一些背景音。这个时候我们也有两个选择:

    1. 使用 <audio>
      • 好处:流式播放,速度会更快;有 loop 属性,更方便。
      • 坏处:如果是长音频,由于流式播放的原因,用过的数据可能被随时丢弃,所以新的一遍开始时可能会卡 0.N 秒,无法避免
    2. 使用 Web Audio API 等更高级的 API
      • 好处:音频在内存里,想怎么播怎么播,还可以加入各种效果
      • 坏处:第一次播需要完成加载,或者手动实现音频流,很麻烦

    我建议用第二个方法,使用起来比较简单。只要在开始的时候创建两个 <aduio> 对象,然后在第一个播放结束前 250ms 启动第二个即可。至于为什么是 250ms,因为按照规范,onTimeUpdate 的触发周期是最长不超过 250ms,所以这样可以比较好的切换播放器。

    以下是我在 Nuxt3 里使用的 composable,供大家参考:

    import type { AudioOptions } from '~/types';
    
    export function useAudio({
      src,
      loop,
      loopOverlap,
      autoplay,
    }: AudioOptions = {}) {
      const audio = shallowRef<HTMLAudioElement>();
      const backup = shallowRef<HTMLAudioElement>();
      const isStarting = ref<boolean>(false);
      const isPlaying = ref<boolean>(false);
      const playhead = ref<number>(0);
      const duration = ref<number>(0);
    
      async function playAudio(src: string, overlap: number = 0.25): Promise<void> {
        if (!audio.value) {
          initAudio(src);
        }
        if (audio.value) {
          audio.value.src = src;
          if (loop && backup.value) {
            backup.value.src = src;
          }
        }
        if (overlap !== undefined) {
          loopOverlap = overlap;
        }
        isStarting.value = true;
        await audio.value?.play();
      }
      function pauseAudio(): void {
        audio.value?.pause();
        backup.value?.pause();
      }
      function initAudio(src: string): void {
        audio.value = new Audio(src);
        if (src) {
          audio.value.preload = 'auto';
        }
        audio.value.autoplay = autoplay || false;
        if (loop) {
          backup.value = new Audio(src);
          backup.value.preload = 'auto';
          backup.value.autoplay = false;
          backup.value.addEventListener('ended', onEnded);
          backup.value.addEventListener('timeupdate', onTimeUpdate);
          backup.value.addEventListener('play', onPlay);
          backup.value.addEventListener('playing', onPlaying);
          backup.value.addEventListener('pause', onPause);
          backup.value.addEventListener('loadedmetadata', onMetadata);
        }
        audio.value.addEventListener('timeupdate', onTimeUpdate);
        audio.value.addEventListener('loadedmetadata', onMetadata);
        audio.value.addEventListener('ended', onEnded);
        audio.value.addEventListener('pause', onPause);
        audio.value.addEventListener('play', onPlay);
        audio.value.addEventListener('playing', onPlaying);
      }
      function destroy(): void {
        if (audio.value) {
          audio.value.removeEventListener('timeupdate', onTimeUpdate);
          audio.value.removeEventListener('loadedmetadata', onMetadata);
          audio.value.removeEventListener('ended', onEnded);
          audio.value.removeEventListener('pause', onPause);
          audio.value.removeEventListener('play', onPlay);
          audio.value.removeEventListener('playing', onPlaying);
          audio.value = undefined;
        }
        if (!backup.value) return;
        backup.value.removeEventListener('ended', onEnded);
        backup.value.removeEventListener('loadedmetadata', onMetadata);
        backup.value.removeEventListener('timeupdate', onTimeUpdate);
        backup.value.removeEventListener('play', onPlay);
        backup.value.removeEventListener('playing', onPlaying);
        backup.value.removeEventListener('pause', onPause);
        backup.value = undefined;
      }
    
      function onPlay(): void {
        isPlaying.value = true;
      }
      function onPlaying(): void {
        isStarting.value = false;
      }
      function onPause(): void {
       isPlaying.value = false;
     }
      function onEnded(event: Event): void {
        if (loop) {
          (event.target as HTMLAudioElement).currentTime = 0;
        } else {
          isPlaying.value = false;
        }
      }
      function onTimeUpdate(): void {
        playhead.value = audio.value?.currentTime || 0;
        if (!loop) return;
        if (!playhead.value || !duration.value || playhead.value < duration.value - loopOverlap) return;
    
        backup.value?.play();
        // swap audio and backup
        const temp = audio.value;
        audio.value = backup.value;
        backup.value = temp;
      }
      function onMetadata(event: Event): void {
        duration.value = (event.target as HTMLAudioElement).duration || 0;
      }
    
      if (src) {
        initAudio(src);
      }
    
      return {
        audio,
        isStarting,
        isPlaying,
        playhead,
    
        playAudio,
        pauseAudio,
        destroy,
      };
    }
    

    使用 macOS Safari 调试 iOS Safari

    Safari 烂归烂,但是有一点:iOS Safari 的 bug 大多可以在 macOS 上复现。所以大多数时候,我们可以直接在 macOS 完成调试。但是有时候,我们必须在 iOS 上进行调试,比如涉及到输入框影响屏幕高度、或者上面提到的播放问题。

    于是,使用 macOS Safari 调试 iOS Safari 也是必备技能,需要的同学请看我的这个视频:

    总结

    我有时候会怀疑,苹果是不是故意劣化 Safari,以避免被 Web App 抢走原生 App 的市场份额,不然 iOS Safari 的 bug 不应该这么多。

    如果大家对 Web App 开发感兴趣,有相关的问题,欢迎留言讨论。

  • Vue3 中使用 h + render 渲染组件,实现自定义弹窗功能

    Vue3 中使用 h + render 渲染组件,实现自定义弹窗功能

    Vue3 里面使用模版添加组件非常常见,不过有时候,我们不方便直接把组件写入模版。比如,最常见的情况,在用户进行一些危险的、不可逆的操作前,我们常常需要要求用户确认。要求不高的时候,可以原生 JS confirm;但是如果要求高一些,我们就希望自定义弹窗样式。

    这个时候,如果必须事先把弹窗组件放入组件模版,就会很麻烦;如果我们能像使用原生 confirm 一样,使用 if (!myConfirm(message)) return,就会好很多。

    但是我没能在 Vue 官方文档找到合适的方案,还是 GPT-4 给我提供了思路,实验之后发现效果比较理想,所以写个笔记记录一下。

    import { h, render } from 'vue';
    import { ModalsConfirm } from '#components';

    export function showConfirmModal(message: string, title = 'Confirm') {
      // 因为要等待用户操作,所以必须返回 Promise
    return new Promise((resolve) => {
        // 创建一个新节点,用来容纳 modal
    const node = document.createElement('div');
        // 使用 `h` 创建虚拟节点,其中ModalsConfirm 是做好的 Vue SFC
    const vnode = h(ModalsConfirm, {
    message,
    title,
          // `on` + `事件名称` 即事件处理函数
    onConfirm() {
    node.remove();
    resolve(true);
    },
    onCancel() {
    node.remove();
    resolve(false);
    },
    });
    document.body.appendChild(node);
        // 使用 `render` 将虚拟节点添加到 DOM 树里
    render(vnode, node);
    });
    }

    使用的时候只需要调用即可:

    function onDelete(item) {
      if (!(await showConfirmModal('are u sure?')) return;

      // 真实的删除逻辑
    }

    GPT-4 之后,在辅助开发方面的提升非常大,理解更准确,幻觉更少。大家有机会的话多试试。

  • 使用 `postMessage` 跨域名迁移 `localStorage`

    使用 `postMessage` 跨域名迁移 `localStorage`

    朋友的网站有个需求:要从 A 域名迁移到 B 域名。所有内容不变,只是更改域名。这个需求不复杂,理论上改下配置然后 301 即可。但这个网站是纯静态网站,用户数据都存在 localStorage 里,所以他希望能够自动帮用户把数据也迁移到新域名。

    我们知道,localStorage 是按照域名存储的,B 网站无法访问 A 网站的 localStorage。所以我们就需要一些特殊的手段来实现需求。经过一些调研,我们准备使用 postMessage() 来完成这个需求。

    大体的方案如下:

    首先,增加 migrate.html 页面。这个页面不需要具体的功能,只要侦听 message 事件,并且把 localStorage 发出即可。

    <script>
      window.addEventListener('message', function (message) {
        const { origin, source } = message;
        // 验证来源,只接受我们自己的域名发来的请求
        if (origin !== 'https://wordleunlimited.me') return;
    
        const local = localStorage.getItem('wordle');
        // `source` 是浏览器自动填充的,即调用 `postMessage` 的来源。作为跨域 iframe 里的页面,拿不到外层 window,只能通过这种方式往回传递数据。
        source.postMessage({
          type: 'migrate',
          stored: local,
        }, 'https://wordleunlimited.me');
      });
    </script>

    然后,在应用里增加 <iframe>,因为我用 Vue3,所以这里也用 Vue 组件的方式处理。

    <template lang="pug">
    migrate-domain(v-if="needMigrate")
    </template>
    
    <script setup>
    // 我会把迁移的状态持久化,以免反复提示打扰用户
    const needMigrate = location.hostname === 'wordleunlimited.me' && !store.state.migrated19;
    </script>
    <script lang="ts" setup>
    import {ref} from "vue";
    // 项目启动得早,还在用 vuex
    import {useStore} from "vuex";
    import {key} from "@/store";
    
    const store = useStore(key);
    const migrateIFrame = ref<HTMLIFrameElement>();
    
    // migrate.html 接到请求后,验证来源,然后会把 localStorage 的数据发回。我们用这个函数接收。
    window.addEventListener('message', message => {
      const { origin, data } = message;
      // 同样验证来源
      if (origin !== 'https://mywordle.org' && origin !== 'https://mywordgame.com') return;
      const { type, stored } = data;
      if (type !== 'migrate') return;
      if (stored) {
        // 迁移数据时,需加入特殊标记,用来标记“已迁移”状态
        localStorage.setItem('wordle', stored.replace(/}$/, ', "migrated19": true}'));
        // 很奇怪,直接 reload 可能会迁移失败,所以这里稍微等一下
        setTimeout(() => {
          if (confirm('Data migrated, reload the page?')) {
            location.reload();
          }
        }, 100);
      }
    });
    
    // iframe 加载完即执行这段 JS,向 iframe 内的页面传递迁移请求
    function onLoad() {
      const contentWindow = migrateIFrame.value?.contentWindow;
      if (contentWindow) {
        contentWindow.postMessage('migrate', 'https://mywordle.org');
      } else {
        console.warn('no content window');
      }
    }
    </script>
    
    <template lang="pug">
    iframe(
      ref="migrateIFrame"
      src="https://mywordle.org/migrate.html"
      frameborder="no"
      width="0"
      height="0"
      @load="onLoad"
    )
    </template>
    
    <style scoped>
    iframe {
      width: 0;
      height: 0;
    }
    </style>

    至此,功能完成。

    如此一来,老用户打开网站后,会被跳转到新域名。然后应用 JS 会检查 localStorage 里存储的数据,如果没有迁移过,就会使用 <iframe> 加载老域名下的 migrate.html。等待目标页面加载完成之后,调用 postMessage() 发送迁移请求。接下里,migrate.html 接到请求后,返回之前存储的数据。新域名储存之后,提示刷新。

    主要的坑在于 <iframe> 里的页面无法直接跟跨域页面通信,所以需要父页面先找到子页面,发起请求;然后子页面再把数据回传给父页面。其它方面应该就是一般的 API 调用,以及体验性问题。

    希望本文对大家有帮助。如果各位对 postMessage() 或者其它相关技术有问题的话,欢迎留言交流。

    本文参与了「SegmentFault 思否写作挑战赛」,欢迎正在阅读的你也加入。

  • 【视频】Node.js 开发 RAR 解压缩命令行工具

    【视频】Node.js 开发 RAR 解压缩命令行工具

    拖来拖去,终于把 使用 node.js 开发命令行工具 workshop 的视频剪出来了,前几天上传到 B 站,访问量很一般,所以在自己的博客再捞一下。

    这次视频主要面向新手,主要呈现从 0 到 1 实现命令行工具的做法,希望观众无论基础如何,都能在看完视频之后,掌握封装仓库、实现命令行工具的做法。内容大约是:

    1. 不同系统下安装 node.js
    2. 创建命令行工具项目
    3. package.json 结构介绍
    4. 介绍 unrar-promise
    5. 介绍 yargs 实现命令行接口
    6. 开发功能
    7. 发布到 npm

    完成的项目放在 GitHub:meathill/unrar: a simple script to unarchive rar files (github.com) 非常简单,大家可以参考。

    有任何问题、建议均欢迎留言讨论。新的一年,我会努力多做视频、多做好视频,希望大家支持我。

  • MongoDB 里实现多表联查

    MongoDB 里实现多表联查

    前些天遇到一个需求,不复杂,用 SQL 表现的话,大约如此:

    SELECT *
    FROM db1 LEFT JOIN db2 ON db1.a = db2.b
    WHERE db1.userId='$me' AND db2.status=1

    没想到搜了半天,我厂的代码仓库里没有这种用法,各种教程也多半只针对合并查询(即只筛选 db1,没有 db2 的条件)。所以最后只好读文档+代码尝试,终于找到答案,记录一下。

    1. 我们用 mongoose 作为连接库
    2. 联查需要用 $lookup
    3. 如果声明外键的时候用 ObjectId,就很简单:
    // 假设下面两个表 db1 和 db2
    export const Db1Schema = new mongoose.Schema(
      {
        userId: { type: String, index: true },
        couponId: { type: ObjectId, ref: Db2Schema },
      },
      { versionKey: false, timestamps: true }
    );
    export const Db2Schema = new mongoose.Schema(
      {
        status: { type: Boolean, default: 0 },
      },
      { versionKey: false, timestamps: true }
    );
    
    // 那么只要
    db1Model.aggregate([
      {
        $lookup: {
          from: 'db2', // 目标表
          localField: 'couponId', // 本地字段
          foreignField: '_id', // 对应的目标字段
          as: 'source',
      },
      {
        $match: [ /* 各种条件 */ ],
      },
    ]);

    但是我们没有用 ObjectId,而是用 string 作为外键,所以无法直接用上面的联查。必须在 pipeline 里手动转换、联合。此时,当前表(db1)的字段不能直接使用,要配合 let,然后加上 $$ 前缀;连表(db2)直接加 $ 前缀即可。

    最终代码如下:

    // 每次必有的条件,当前表的字段用 `$$`,连表的字段用 `$`
    const filter = [{ $eq: ['$$userId', userId] }, { $eq: ['$isDeleted', false] }];
    if (status === Expired) {
      dateOp = '$lte';
    } else if (status === Normal) {
      dateOp = '$gte';
      filter.push({ $in: ['$$status', [Normal, Shared]] });
    } else {
      dateOp = '$gte';
      filter.push({ $eq: ['$$status', status] });
    }
    const results = await myModel.aggregate([
      {
        $lookup: {
          from: 'coupons',
          // 当前表字段必须 `let` 之后才能用
          let: { couponId: '$couponId', userId: '$userId', status: '$status' },
          // 在 pipeline 里完成筛选
          pipeline: [
            {
              $match: {
                $expr: {
                  // `$toString` 是内建方法,可以把 `ObjectId` 转换成 `string`
                  $and: [{ $eq: [{ $toString: '$_id' }, '$$couponId'] }, ...filter, { [dateOp]: ['$endAt', new Date()] }],
                },
              },
            },
            // 只要某些字段,在这里筛选
            {
              $project: couponFields,
            },
          ],
          as: 'source',
        },
      },
      {
        // 这种筛选相当 LEFT JOIN,所以需要去掉没有连表内容的结果
        $match: {
          source: { $ne: [] },
        },
      },
      {
        // 为了一次查表出结果,要转换一下输出格式
        $facet: {
          results: [{ $skip: size * (page - 1) }, { $limit: size }],
          count: [
            {
              $count: 'count',
            },
          ],
        },
      },
    ]);

    同事告诉我,这样做的效率不一定高。我觉得,考虑到实际场景,他说的可能没错,不过,早晚要迈出这样的一步。而且,未来我们也应该慢慢把外键改成 ObjectId 类型。

  • Vue 使用 Provide/Inject 向子组件内注入数据

    Vue 使用 Provide/Inject 向子组件内注入数据

    前阵子做厂里的需求,允许用户编辑算法生成的 CSS,以便将我厂的产品应用到生产环境。我们允许用户使用可视化编辑器编辑某条特定规则,大约如下图所示:

    我们知道,CSS 规则有优先级,优先级高的规则会覆盖优先级低的规则中的同名属性;也会继承低优先级规则里没有被覆盖的属性。反应到我们的可视化编辑器里,就是:

    1. 有些属性用户不能删除,因为那是从低优先级的样式里继承来的
    2. 有些属性用户修改了也不会生效,因为会有更高优先级的规则把它覆盖掉

    所以我们希望给用户一些提示,避免他们实际操作的时候感到疑惑。那么问题来了:怎么实现呢?

    从图上可以看出,CSS 可视化编辑器挺复杂的,有些属性可以直接编辑,比如 visibility,独立生效,那随便写个 <input type="checkbox"> 就行;有些则与其它属性一起发生作用,比如 align-items,那我们就需要比较复杂的组件,里面会嵌套别的组件。

    所以一般的 v-bind 方式就不适用:我计算出优先级之后,需要传递多次才能穿透组件间的嵌套关系,太复杂,很难用。我们需要更简单的传递方案。好在 Vue 提供了 provide/inject 方式。

    Vue 官方称这个功能为“依赖注入”,我们在父组件上使用 provide 暴露一些属性,然后在子组件里用 inject 把这些属性拿进来使用。不管子组件和父组件之间嵌套了几层,都可以获取到:

    provide() {
      return {
        foo: this.foo
      }
    }
    inject: ['foo']

    用法比较简单,就这么两步,但是有一些注意事项:

    1. 注入的变量默认没有响应式,Vue 就是这么设计的,这样可以避免一些问题。如果需要响应式,那就需要传入包含响应式的变量,比如 data 或者 computed
    2. 如果你用 vue-property-decorator,那么需要用 ProvideReactiveInjectReactive
    3. 这个依赖注入和设计模式里的 DI 不是一回事,面试时不要乱讲。
    4. 不要滥用这个设计,只有不特定层级子组件需要用到属性,才这么做。

    扩展阅读:

  • WebRTC 笔记

    WebRTC 笔记

    最近研究了一下 WebRTC,写篇笔记记录下。

    0. WebRTC 简介

    WebRTC 是一种 p2p 技术,它可以在两个不同的浏览器之间建立直连,让它们互相传输数据、视频和音频流。

    这个技术可以充分利用用户自己的上行带宽和网络环境,降低中心服务器的负载,既提升用户体验,又能降低服务提供者的成本。用在一些低成本的场景非常合适,比如视频会议、轻型联网游戏、内容共享网络等。

    由于其低成本的优势,我想用它给 mywordle.org 升级,支持用户 1v1 对战。将来的话,还可以搞一些 FC 模拟器玩玩。

    1. WebRTC 基本概念

    WebRTC 的概念不少,初次连接也很复杂,有些我现在还没完全搞清楚。所以这个部分以后再慢慢更新。另外这里的内容并不是按着规范走的,而是来自我的实践。

    1.1 基本概念

    1.1.1 信令服务器

    这可能是 WebRTC 里最重要的一个概念,每个浏览器 tab 页都是网络中的一个孤岛,必须通过信令服务器才能找到对方,建立连接。

    信令服务器是必须的,不能通过人工方式完成两个节点的互联(或者说很难)。但是信令服务器并不一定要自己建,有不少公共的可以蹭。

    1.1.2 Ice 服务器 RTCIceServer

    同一个局域网内连接很简单,但实际上并不常见。我们日常使用的网络,无论移动网络还是家里的宽带,其实都是位于 NAT 后面,相当于总机分机的概念。两个 NAT 后面的应用想连接就比较困难了,此时就需要 RTCIceServer 帮忙,协商在两个 NAT 上打洞。

    具体的实现逻辑我们不用关心,只要会用就行。目前有两类 RTCIceServer:STUN 和 TURN。前者只负责给双方牵线搭桥,本身不介入连接,有很多公共服务可以蹭;TURN 不光能把两个端连接起来,还能在两端中间网络不通的时候作为 fallback 方案。功能更强,真正生产级别的产品都需要;但是相应的,TURN 需要更高的资源支撑,免费资源也很少。

    1.2 发起连接

    当用户 A 想要跟用户 B 建立 WebRTC 连接时:

    1. A 创建一个 RTCPeerConnectioin 对象
    2. 创建一个 offer,其中包含着 A 的网络信息,其它人通过这个信息可能找到 A
    3. A 把 offer 发给信令服务器
    4. 信令服务器把 offer 发给用户 B
    5. 用户 B 记录下 offer,然后生成 answeransweroffer 其实是一样的,只是用来作为响应。这也是必须有信令服务器的原因。
    6. B 把 answer 发给信令服务器
    7. 信令服务器把 answer 发给 A
    8. A 尝试用 answer 建立连接,如果是局域网,双方可能已经连上了
    9. 如果连不上,则 A 尝试通过 ice server 连接。
    10. A 创建自己的 icecandidate 信息,然后发给信令服务器
    11. 信令服务器将 A 的 icecandidate 发给 B
    12. B 添加后,也创建自己的 icecandidate,发给信令服务器
    13. 信令服务器将 B 的 icecandidate 发给 A
    14. A 添加之,并尝试创建连接
    15. 如果一切正常,这个时候就连上了。

    2. 实操

    我建立了一个项目:meathill/webrtc-playground: learn webrtc (github.com),目前还在升级开发中。

    1. 实现一个 websocket 服务器(基于 socket.io),作为信令服务器,交换信令
    2. 用各种姿势尝试建立连接
    3. 同浏览器多 tab 连接成功
    4. 内网连接成功
    5. 手机开热点,尝试公网连接,也成功
    6. 尝试跟朋友连接,失败。遇到两个问题:
      1. 他家的网络是广州联通,我家是广州电信
      2. 他的手机网络是北京联通
      3. 目前的信令服务器会无条件广播各种信息,当我跟他都多开 tab 的时候,很难保证连接的两端是匹配的
    7. 于是接下来要重构。

    3. 总结

    这次学习过程一波三折。首先,WebRTC 的用户大部分关注音视频传输,毕竟这方面效果最明显;DataChannel 其实只算个添头,偏偏我最关注这个,所以找内容花了不少时间。

    接下来,大部分范例代码都只是抄来抄去,一个 tab 内部来回连,经常会看错。另外,一些概念也不清不楚,比如 ice server,无法主动触发请求。

    终于调通了内网和公网,又遇到联通电信问题……看来将来 TURN 服务器也必不可少。

  • node.js 里 ESM 与 CommonJS 的区别

    node.js 里 ESM 与 CommonJS 的区别

    可能大部分同学并不会直接用 node.js 开发 Web 后端程序,但是作为现代化前端,我们日常的各种开发都严重依赖开发脚手架,也即 node.js 环境下的各种工具链。目前已经有一些仓库逐步迁移到 ESM,所以了解一下 node.js 里 ESM 和 CommonJS 的区别也很有必要。

    我还是老习惯,后文列举出的差异并非文档记录,而是我在实操中遇到的大坑小坑,希望记下来能节省将来的时间和大家的时间。

    0. 设计原则

    我们可以把 CommonJS 理解成按需加载,需要什么就加载什么,加载进来就执行。所以可以动态加载、条件加载、循环加载。

    ESM 倾向于静态加载,方便解析依赖,优化运行效率。所以起初不能条件加载或者循环加载。不过后面考虑到实际需求,还是开放了 import() 做动态加载。

    这个设计原则可以导出后面的诸多不同。

    1. package.json

    package.json 里添加 type: 'module' 可以开启本项目的 ESM。不写或者 type: 'commonjs' 则继续使用 CommonJS。

    如果没有此配置,虽然我们代码中写的 好像是 ESM,但其实都会被 Babel 或其它什么工具转译成 CommonJS,最终运行的并不是 ESM。这点一定要注意。

    2. __dirname

    ESM 不再支持用 __dirname__filename 获取正在执行的 JS 文件在系统中的路径。作为替代方案,可以使用 import.meta.url 获取当前文件的 URL,不过返回结果是 file:// 协议,如果要继续使用 __dirname 可以这样:

    import {dirname} from 'path';
    import { fileURLToPath } from 'url';
    
    function getDirname(url) {
      const __filename = fileURLToPath(url);
      return dirname(__filename);
    }

    3. 解构

    使用 CommonJS 时,我们可以直接对导出的对象进行解构,比如:

    // lib.js
    module.exports = {
      foo: 'bar',
    };
    
    // index.js
    const {foo} = require('./lib');

    这样的用法在 ESM 中不可行。ESM 解构只能针对使用 export foo = 'bar' 这样主动暴露出的属性。对于一般对象的解构,我们只能写成:

    // lib.js
    export default {
      foo: 'bar',
    }
    
    // index.js
    import lib from './lib';
    
    const {foo} = lib;

    4. .mjs 文件与 .cjs 文件

    我们知道,node.js 加载模块时可以省去文件扩展名,比如 require('./foo'),不需要写最后的 .js.json,node.js 会自动去目录里查找对应的文件。

    node.js 会根据 type 的不同使用不同默认策略加载 js,我们也可以使用特定扩展名要求 node.js 在加载时使用特定模块类型。比如,我们使用 ESM 时,node.js 会把加载进来的 JS 都当 ESM 处理,如果这些 JS 还在使用 CommonJS 加载其它 JS,就会报错(前面说了,ESM 里不支持 require)。此时,我们可以把目标文件的扩展名写为 .cjs,node.js 就会当它是 CommonJS 来处理了。

    此功能在使用第三方库的时候很有用。比如 Postcss,它会加载项目里的配置文件,但它只支持 CommonJS,这时,如果执行时因没有 require 报错,就可以把配置文件的扩展名改成 .cjs

    5. 顶层 await

    开启 ESM 之后,可以使用顶层 await,省去一个异步函数。

    (这是促使我使用 ESM 的主要原因)

    6. importrequire

    ESM 中,我们可以使用 import 导入 CommonJS 模块和 ESM 模块;但是 CommonJS 的 require 只能用来导入 CommonJS 模块。如果要在 CommonJS 中导入 ESM 模块,需要使用 import() 然后异步处理。

    自然,ESM 里不能用 require

    7. 其它区别

    这些区别我在实际开发中没有遇到,大家自己阅读吧:Modules: ECMAScript modules | Node.js v17.8.0 Documentation (nodejs.org)

    (更多…)
  • 理解 Vue3 里的 defineProps 和 defineEmits

    理解 Vue3 里的 defineProps 和 defineEmits

    大家请先看这个问题:https://segmentfault.com/q/1010000041497872/a-1020000041498716,看看你们能不能给出答案。

    Vue3 增加了 Composition API,是一个很大的改进。一方面可以提升代码复用效率,另一方面通过更好的 tree-shaking,打包体积也会小很多:作为参考,前面博客中提到 mywordle.org,打包后 vendor.js 只有区区 96kB,里面可是用到了 vue3 全家桶。

    最初的 Composition API 是在 Options API 基础上改进的,不仅需要使用 setup() 函数,还要在 setup() 末尾返回所有模版需要用到的变量和函数,使用起来相当繁琐。于是后面就增加了 <script setup> 语法糖:

    1. 从生命周期来讲,相当于 created
    2. 支持顶层 await(因为实际上这还是个 setup() 函数)
    3. 所有 import 的内容、声明的变量和函数默认都返回
    4. 至少省了两层缩进

    但是由于少了 export,没法传参,也不方便暴露接口,所以作者就增加了三个工具方法:

    • defineProps
    • defineEmits
    • defineExpose

    注意,这三个工具方法只是帮助 Vue 编译器构建组件,它们不会出现在最终代码里,我们也不能预期它们会像普通函数那样工作。比如下面这段代码,就得不到常见的结果:

    const props = defineProps({
      userMenu: {
        type: Array,
        default() {
          return []
        }
      }
    })
    console.log(props) // 该对象中的 userName 总是有值
    console.log(props.userMenu) // 该对象始终是一个空数据

    因为 Vue 是 MVVM 框架,它的视图会在数据变化后自动渲染,于是通常情况下,props 里的值什么时候被填充并不重要,Vue 开发团队也不想追求 defineProps 工作的一般化。所以使用目前版本,上面这段代码,访问到的 props 是的 reactive 对象,数据被填充后就能看到 userName 里有值;而 props.userMenu 在访问时还没有被填充,所以得到的是 default() 返回的默认值,一直是空的。

    同时大家还要知道,console.log() 输出的是对象的指针,而非快照。所以里面的值只跟你展开时有关,跟运行时关系不大。

  • 浅尝 CodeMirror@6

    浅尝 CodeMirror@6

    厂里的项目需要在线代码编辑器,一开始我想试试 Monaco editor,VS Code 就用的这个,基本可以认为其功能、设计、性能都是上上之选。可惜它太重了,初始包就要 3M+,用在单机软件里问题不大,放在网页里就很不理想。于是用回 CodeMirror。不过毕竟是新项目,我不想继续使用 v5,便开始学习使用 CodeMirror@6。

    0. CodeMirror@6

    CodeMirror@6 其实发布很长时间了,我以前做 Showman 的时候就看过,不过当时我们已经在 CodeMirror@5 里投入很多,Showman 本身时间紧任务重,就没想迁移。

    CodeMirror@6 是作者完全重写的,整体架构发生了非常大的变化,使用方式与上一个版本完全不同。所以我要写篇博客记录分享一下。

    新版本着重提升了可用性与触屏支持,提供更好的内容解析功能,并且提供了现代化编程接口,比如模块管理、TypeScript 等。虽然整体还处于 beta 阶段,不敢保证日后没有破坏性变更,但还是新开项目的上乘之选。

    1. 旧版本迁移

    如果有上个版本的使用经验,那么最好先看这篇文档:Migration Guide。大部分使用问题都能迎刃而解。

    以前创建编辑器只需要 codemirror.fromTextarea(element) 即可,现在麻烦了很多,因为整个包被拆成数个组件:

    1. view 即网页中的视图
    2. state 即代码解析结果
    3. 插件,包括快捷键、语言 mode、丰富功能,用来处理所有非核心逻辑

    这样做的好处很明显,每个组件可以独立维护、独立使用,如果只需要其中一两项功能,就不用把所有代码都加载进来,性能会好很多。

    在新版本中初始化编辑器一般这样做:

    
    import { basicSetup, EditorState, EditorView } from '@codemirror/basic-setup';
    import { css } from '@codemirror/lang-css';
    import { ViewUpdate } from '@codemirror/view';
    
    const editor = new EditorView({
      state: EditorState.create({
        doc: code,
        extensions: [
          // basicSetup 是一套插件集合,包含了很多常用插件
          basicSetup,
          // 这里只使用 css 解析器
          css(),
          // 新版本一切皆插件,所以实时侦听数据变化也要通过写插件实现
          EditorView.updateListener.of((v: ViewUpdate) => {
            this.localValue = v.state.doc.toString();
            this.$emit('input', this.localValue);
          }),
        ],
      }),
      parent: this.$refs.editor as HTMLDivElement,
    });

    2. 更新代码

    新版本为了支持编辑代码的复杂需求,把所有变更都封装成了 transactions,通过 dispatch 告知 view 更新视图。所以修改代码就从 .setValue(code) 变成下面这种样子:

    this.editor.dispatch({
      changes: { from: 0, to: this.editor.state.doc.length, insert: code },
    });

    3. 高亮错误代码

    新版本高亮错误代码会比较麻烦。以前直接 .addLineClass() 就可以了,现在则要先生成标记,然后再把标记添加到视图中。不过也带来一个好处:以前标记了错误行,用户编辑后,错误还在那里,很难区分,一般都是直接清除。现在因为状态更新更全面了,所以标记也可以随之更新。

    我目前的实现方式如下,不是很理想,先分享出来吧:

    const errorMarkTheme = EditorView.baseTheme({
      '.cm-error-mark': {
        boxShadow: '-2px 0 red',
      },
    });
    const errorMark = Decoration.mark({
      class: 'cm-error-mark',
    });
    const addErrorMarks: StateEffectType<any> = StateEffect.define<{ from: number; to: number }>();
    const markField = StateField.define<DecorationSet>({
      create() {
        return Decoration.none;
      },
      update(marks, tr) {
        marks = marks.map(tr.changes);
        for (const effect of tr.effects) {
          if (effect.is(addErrorMarks)) {
            marks = marks.update({
              add: [errorMark.range(effect.value.from, effect.value.to)],
            });
          }
        }
        return marks;
      },
      provide: (field) => EditorView.decorations.from(field),
    });
    
    export default {
      highlightError(line: number, pos: number) {
        const code = this.localValue;
        // 必须用起始位置标记
        const lines = code.split('\n');
        const to = lines.slice(0, line - 1).reduce((total, line) => total + line.length + 1, 0) + pos;
        const effects = [addErrorMarks.of({ from: to - 1, to })];
        if (!this.editor.state.field(markField, false)) {
          effects.push(StateEffect.appendConfig.of([markField, errorMarkTheme]));
        }
        this.editor.dispatch({ effects });
      }
    }

    后记

    后来我得知,产品里其实已经集成了 Ace Editor,我一阵紧张,毕竟引入多个同类型的仓库基本上可以认为是错误操作。赶快跑去看了眼 Ace Editor,以及一些对比文章,应该说还好,无论是功能、架构,还是受欢迎程度,应该都比不上 codemirror,尤其是生态很单薄,所以以后会继续深耕 codemirror。