我的技术和生活

  • 搬家记:从 Vercel 到 Cloudflare(Nuxt 项目x2)

    搬家记:从 Vercel 到 Cloudflare(Nuxt 项目x2)

    月底的时候,收到 Vercel 的邮件,提示我账号内有一些额度用量已经消耗 50%。我没在意,心想反正月底,马上不就归零了么?第二天起来越想越不对,我的 Vercel 是付费的 Pro 账号,计费周期是上月 22 日到本月 22 日,这才月底,才一周多,怎么会就用掉 50%?

    于是我回想起之前收到 Vercel 的邮件,说以后我每个月的费用会上升 30+美金,不由得背后一凛。赶紧上 Vercel 后台查看,发现 Vercel 开始用新的计费单位:Function Invocations。且 4 月 25 日起,新开通 Pro 的用户会使用这个方式计费;而 5 月 25 日(即上月底),我们这些老 Pro 用户也会采用这个方式计价。

    很明显,新计价单位比老的方式严苛很多,以至于我之前一半都用不掉的套餐费用,现在连半个月都撑不住。公平一点来说,我的项目都是 Nuxt,要走 Serverless Function,所以消耗可能比 Next.js 走 Edge Function 要高。但是,无论如何,这个价位都让我们动起了搬家的念头。目标毫无疑问,就是 Cloudflare。

    具体额度可能随时有变化,我就不在这里贴数字了,简单给大家划个标准:我在 Vercel 每月 $20 的额度,大约对齐 Cloudflare 的免费版;Cloudflare 每月 $5 的套餐额度,足够我 $50 Vercel + $5 Upstash,还有一大堆我没用起来的服务。

    单纯算经济帐,肯定是 Cloudflare 划算得多。但是 Vercel 行走江湖,靠的就是一手优秀的 DX,尤其在我搬家折腾完之后,更是这么觉得。整体过还比较顺利,但是坑也真没躲过去。接下来分享一些踩坑经历,希望对大家有帮助。

    第一步,创建项目。在 Cloudflare Pages 导入 Git 仓库即可,操作跟 Vercel 基本一致,无需详述。接下来,我们就有了部署在 xxx.pages.dev 子域名的网站,建议测试充分之后再切换域名。

    第二步,解决各种 [unenv] 问题。这个问题是由于 Cloudflare Worker 运行时精简(删掉)了一些 Node.js API 导致的。可能发生在各个环节,从部署到执行都有可能。我遇到的问题主要是:

    1. ioredis。因为我使用 Upstash Redis,所以直接换 @upstash/redis/cloudflare 然后使用 http endpoint 即可解决。两者的 API 有些许区别,不过功能性差异不大,替换起来不麻烦。
    2. Nitro Stroage。其实跟上面是一个问题,只不过表现不太一样。如果通过 nuxt.config.js 配置 Storage 使用 Redis,那么部署那一关都过不去……
    3. crypto.createHash。这个暂时没找到好办法,主要是 TiDB Serverless 最初使用 Digest 认证,所以我才必须使用。现在他们支持 Basic Auth 认证,所以我直接去掉对这个 API 的依赖就好了。

    第三步,安排 Cron job。这一步目前我也还没做好。Cloudflare Pages 不支持 Cron job,只能在 Workers 里安排 Cron Trigger。所以比较合理的做法是,建立一个统一的 cron worker,定时运行,粒度控制在比较合理的范围内,然后通过这个 worker 去调用分散在各个项目中的 cron job。

    于是,由于我们暂时无法离开 Vercel 的 Cron job,所以我们就还不能关掉 Vercel 上的项目。那么我们就必须开一个分支出来进行开发。好在 Cloudflare 可以支持指定特定分支作为 Production 的做法,所以这方面也没有问题。

    哦,对了,Pages 不支持在 wrangler.toml 里定义环境变量,以及我们在 Pages 里修改环境变量也不会触发重新部署,这点和 Worker 不一样,需要注意。

    最后,就是改变域名指向。我们必须在 Workers & Pages > 刚才的项目 > Custom Domains 配置自定义域名,仅在在 Websites > CDN 里配置是不够的,请大家注意。另外,由于 Nuxt 是 SSR 框架,所以我们必须关闭位于 Speed > Optimization > Content Optimization 的 “Auto Minify”,否则,可能会遇到重复的页面内容。

    还有一点,与 Vercel 不同,Cloudflare Pages 只包含 Edge 网络,并没有前面的 CDN 层。所以在 Cloudflare Pages 里增加缓存必须通过 Cloudflare CDN 来进行,同时 CDN 必须开启 Proxied,否则,原本在 Vercel 有效的缓存头就只能在浏览器里工作了。

    以及,Cloudflare Pages 没有提供页面重定向等功能,所以原本放在 Vercel.json 里的一些东西可能就要我们自己实现了。

    总结

    按惯例来总结一下。Cloudflare 的套餐性价比非常高,同样用量,价格可能 Vercel 的 1/10,还能覆盖其它服务。所以值得一试。但是问题也不少,比如运行时被进一步缩减、不支持 Cron job、没有 CDN 层、没有更高层级的 Headers 控制,等等,所以在使用时,体验可能还是略逊于 Vercel。尤其是迁移搬家,更要注意。

    好了,如果你对全栈开发,网站基础设施感兴趣,有问题有疑问,欢迎留言讨论。

  • 【实用教程】在网页里集成语音输入:1. 在浏览器里完成录音

    【实用教程】在网页里集成语音输入:1. 在浏览器里完成录音

    继续写实用教程。接下来准备讲解一下如何在网页里集成语音识别,语音输入的功能。也就是俗称的 STT(Speech to Text)。这个功能可以很好的鼓励用户输入更多的内容,而且考虑到现在大家用移动设备、一体机的很多,麦克风也早已普及,所以我建议大家都了解一下相关操作。不知道要写多长,长的话会分成几篇,慢慢连载。

    计划内容:

    1. 基础概念
    2. 使用 MediaRecorder 完成录音
    3. STT 的几个方案:
      • OpenAI Whisper API
      • CloudFlare Workers AI
      • 腾讯云
      • WASM
    4. 细节处理
    5. 总结

    基础概念

    好的,闲言少叙,我们来看语音输入需要用到哪些技术。

    1. 首先,我们要想办法把用户的声音录下来。
    2. 接下来,就是把录音文件发给某个模型来解析。这一步的选择很多,我会介绍几个我用过的服务。其实浏览器自己也有 SpeechRecognition API,但是我测试的结果并不可用,也不可控,所以就放弃了。
    3. 然后接收识别结果,显示出来,就可以了。

    有些系统自带语音输入,不过质量可能不好;而且录音文件中所携带的用户情绪等信息在将来大模型发展后,也有更多的可探索空间。所以我觉得这个功能还是值得学习使用的。

    使用 MediaRecorder 完成录音

    浏览器提供 MediaRecorder 帮我们完成录音工作。它的用法很简单,相信大家看一眼文档就能学会。我觉得值得注意的点只有几个:

    1. iOS Safari 和基于 Chromium 的 Edge / Chrome 支持的文件格式不同,需要处理
    2. 使用时需要用户许可,所以我们要处理用户拒绝的状态
    3. Safari 需要指定 timeslice

    接下来我们看代码(基于 Nuxt3,即 Vue3 + TS)。首先,我们要使用 navigator.mediaDevices 捕获音视频流,然后才能进行录制。

    // 因为这里我们只需要录音,所以只要 audio 就可以
    const constraints = { audio: true };
    // 这里的 stream 最好是全局或者组件内可访问的状态。因为录音结束我们要及时释放它,否则可能会一直都有在录音的提示
    let stream: MediaStream | null = null;
    // 需要根据浏览器类型确定音频文件 mimeType
    const supportedMimeType = import.meta.client && isSafari() ? 'audio/mp4' : 'audio/webm;codecs=opus';
    let mediaRecorder: MediaRecorder | null = null;
    
    // 各种初始化,比如声音、画波形图等
    isStarting.value = true;
    
    try {    
      stream = await navigator.mediaDevices.getUserMedia(constraints);
      mediaRecorder = new MediaRecorder(stream, {
        mimeType: supportedMimeType,
      });
    } catch (e) {
      isStarting.value = false;
      // 判断错误类型,如果是用户主动的 Permission denied,就请用户自己去开开关;否则可能无法处理,就显示不支持好了
      startingError.value = (e as Error).message === 'Permission denied'
        ? 'You have denied access to your microphone. To use this feature, please enable it in your browser settings and try again. Thank you!'
        : 'Sorry, your browser doesn\'t support voice input.';
      return;
    }

    接下来,如果用户许可我们使用录音设备,就可以开始录音了。这里其实我省掉了选择不同输入设备的选项,大多数时候不重要,用默认即可。感兴趣有需求的同学可以自己摸索。

    // 录音正式开始时,我们可以在这里处理一些状态,以更新显示状态
    mediaRecorder.addEventListener('start', async function () {
      isRecordingStarted.value = true;
      isRecording.value = true;
      isStarting.value = false;
      startTime = Date.now();
      startCountingTime();
      emit('update:isWorking', true);
    });
    // 录音数据有变化,记录到 recordedChunks
    mediaRecorder.addEventListener('dataavailable', function (event: BlobEvent) {
      if (event.data.size > 0) {
        recordedChunks.push(event.data);
      }
    });
    // 录音停止,即调用 `mediaRecorder.stop()` 之后,处理语音文字转换,或者用户主动放弃(丢掉录音)等
    mediaRecorder.addEventListener('stop', async function () {
      isRecording.value = false;
      // 把录音转换成文件,可以下载
      audioBlob.value = new Blob(recordedChunks, {
        type: supportedMimeType,
      });
      // 释放 stream,这一步很重要,录音提示不消除,很影响用户体验
      stream?.getTracks().forEach(track => track.stop());
      stream = null;
      audioDuration += Date.now() - startTime;
    
      // 这是我的一个标记,用来标记用户点击的是“取消”还是“转换”
      if (isBreak) {
        isBreak = false;
        return;
      }
    
      // 考虑到用户可能使用移动设备,移动网络情况不稳定,所以不能只请求一次,要隔一段时间多尝试几次
      retryCount = 0;
      let error = 'init';
      while (retryCount < autoRetry && error) {
        error = (await speechToText(audioDuration)) || '';
        if (error) {
          retryCount++;
          if (retryCount < autoRetry) {
            await sleep(1000 + (retryCount - 1) * 5000); // 1s, 6s, 11s
          }
        }
      }
      if (error) {
        convertingError.value = error;
      }
    });
    
    // 启动录音。在 Safari 里我们必须写明 `timeslice` 参数,否则可能在 stop 的时候还没记录数据。我觉得这是个 bug
    mediaRecorder.start(1000);

    小结

    基本上,这样就可以完成录音部分的功能,配合文件下载做一个录音机习作也是可以的。算算字数也不少了,本文就先到这里,下次介绍使用大模型 API 进行文字转换的功能。

    如果你对网页开发、AI 产品开发、录音播放等有兴趣或问题,欢迎留言与我讨论。

  • 移动网页高度自适应最佳实践

    移动网页高度自适应最佳实践

    移动 Web 开发就要在“螺蛳壳里做道场”。移动设备限于屏幕尺寸,不得已左支右绌,既要多呈现内容,又要保证功能不要缺失。普通内容类网页还好,自然往下滚就行了;开发 Web App 的时候,当我们因为某种原因,需要限制滚动区域的时候,就很难处理。

    这篇文章会分享我的一些经验,希望能节省大家摸索的时间。

    无用单位:vh, svh, dvh, lvh

    很早以前,我们就有了 vwvh 单位,分别指代视窗宽高的 1%。需要注意的是,移动浏览器的视口包含被各种组件占据的部分,所以 vw 就比较好用,因为没有干扰;但是高度上,被通知栏、地址栏等占用的“临时”空间就会成为我们的麻烦。

    为了妥善利用屏幕空间,在我们上下滚屏的时候,大多数手机浏览器都会把地址栏、工具栏、或者通知栏隐藏起来,这就导致浏览器的可视面积其实会不断变化。原本就没用的 vh 便更没用了……于是后面新增了 svhdvhlvh 三种 长度单位,但其实帮助不大,因为当我们需要限制容器高度的时候,通常来说就不能让页面自由滚动。

    因为这几个长度单位过于没用,所以我就不详细介绍了。感兴趣的同学可以看下 TailwindCSS 里的演示:https://tailwindcss.com/docs/height#viewport-height

    虚拟键盘则让这个问题雪上加霜,因为虚拟键盘的显示和隐藏都不会影响这几个长度单位,所以当我们需要手动控制容器高度、位置的时候,就会很难做。

    最佳实践:常规页面,交给浏览器

    首先我们要信任浏览器,能够留给浏览器处理的,尽量交给浏览器原生处理。

    比如,常规页面,长一点,留给浏览器自然滚动。文本框输入的时候,浏览器会自动聚焦和滚动,通常情况下没什么问题,基本体验有保证。

    最佳实践:输入框文字不小于 16px

    如果文本框 font-size 小于 16px,iOS Safari 下,当文本框获得焦点,Safari 会自动放大整个页面;而失去焦点的时候,页面并不会自动缩小到 100%,所以就很蛋痛。

    解决方案有几个:

    1. 取消缩放。会使得可用性评价恶化,不推荐。
    2. blur 时自动恢复 100%。增加特性就是增加 bug 的可能,我觉得能不用就不用。
    3. 保持字体大小。应该大部分时候都更简单有效。

    最佳实践:使用 dvh 并解决兼容问题

    虽然但是,当我们需要固定高度的时候,表示视窗净高度的 dvh 仍然是我们最佳选择。

    不过,在我写文章的现在,dvh 的兼容性不是很好,所以必须做好兼容性配置。我建议用 JS 结合 CSS 变量来做。在 <head> 里插入这段 JS:

    // 首先,判断是否支持 dvh 单位
    if (!CSS.supports('height', '100dvh')) {
      // 如果不支持,就定义 --app-height 为视口高度,即 window.innerHeight
      document.body.style.setProperty('--app-height', window.innerHeight + 'px');
      // 当屏幕缩放时,改变内容高度。因为 resize 事件触发很频繁,所以使用节流减少性能损耗
      let timeout;
      function onResize() {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
          clearTimeout(timeout);
          document.body.style.setProperty('--app-height', window.innerHeight + 'px');
        }, 500);
      }
      window.addEventListener('resize', onResize);
    }
    

    然后定义 CSS 样式:

    :root {
      --app-height: 100dvh;
    }
    
    .h-dvh-app {
      height: var(--app-height);
    }
    

    如果使用 TailwindCSS,那么在配置文件里增加配置即可:

    export default {
      theme: {
        extend: {
          spacing: {
            'dvh-app': 'var(--app-height)',
          },
        },
      },
    }        
    

    最佳实践:使用 CSS 变量解决虚拟键盘

    只是限制高度为 100dvh,当虚拟键盘弹出之后,因为视口缩小,很可能会出现问题。此时 window.resize 事件也不会触发,所以我们应该侦听文本框的 focus 事件,动态改变容器高度;并在文本框 blur 之后,恢复高度。

    此时,我们可以借助 CSS 变量的“默认值”功能,即 var(--custom-value, --default-value) 来处理。当我们需要暂时的高度以应对虚拟键盘时,设置 --custom-value;之后,移除 —custom-value,恢复到预定义的 --app-height

    首先,修改 css,定义 --input-height: initial,这个值会被认为是空值而忽略。

    :root {
      --input-height: initial; 
      --app-height: 100dvh;
    }
    
    .h-dvh-app {
      height: var(--input-height, var(--app-height));
    }
    

    然后侦听输入框的 focusblur 事件:

    async function onTextareaFocus(): Promise<void> {
      // 桌面端忽略这个需求
      if (window.innerWidth > 640) return;
    
      // 给虚拟键盘弹出一些时间
      await sleep(300);
      const { innerHeight } = window;
      document.body.style.setProperty('--input-height', `${innerHeight}px`);
      // 需要的话,可以在这里插入一个滚动
    }
    async function onTextareaBlur(event: FocusEvent): Promise<void> {
      // 同样,也给虚拟键盘收起留一些时间
      await sleep(250);
      document.body.style.removeProperty('--input-height');
    }
    

    总结

    至此,遵守以上最佳实践之后,基本上我们可以妥善处理移动网页里的浏览器高度。当然,并不完美,比如,iOS Safari 在输入 position: sticky 里的文本框时,会凭空多出一大块空白,很烦,但是没办法解决。可以绕开,但是我觉得绕开的方案更难用。

    希望这篇文章对大家有用。如果你对移动网页开发有什么问题,欢迎留言讨论。

  • 【视频教程】技术栈大升级:Vue3 到 Nuxt3(4)深入理解 SSR 和 `useAsyncData`

    【视频教程】技术栈大升级:Vue3 到 Nuxt3(4)深入理解 SSR 和 `useAsyncData`

    2023 年,我个人最大的变化,是从 Vue3 SPA 应用向 Nuxt3 SSR 应用过渡,在预期可能存在 SSR 需求的项目中,都尽量使用 SSR。包括 React 应用,也尽量使用 Next.js,而不是 React SPA。

    这个过程中,面临很多问题,很多思路需要转换,很多以前没关注的点需要关注。本系列视频试图快速教会大家这些要点,帮助大家顺利从 SPA 切换到 SSR。

    这次的视频更偏理论,重点讲解 Nuxt3 如何处理 useAsyncData,以及为了兼顾 SSR 和前端开发所做的渲染策略设计。这部分知识我其实很晚才掌握,因为文档里说的也不太详细;所以既是好消息也是坏消息。好消息是,哪怕你没有掌握,也不太耽误使用 Nuxt3 开发项目;坏消息是,保不齐就会遇到一些奇怪的问题,难以复现和排错。

    视频要点:

    1. 现代化 SSR 的优势
    2. 深入理解 useAsyncData
    3. 使用 Pinia 传递数据
    4. 理解生命周期钩子变化

    如果你对 Vue3 开发、Nuxt3 开发、SSR 感兴趣,欢迎关注我的本系列。如果你对这些话题有疑问,欢迎留言讨论。

  • 【视频教程】技术栈大升级:Vue3 到 Nuxt3(2-3)升级实战 – 基础知识,适配 SSR,页面路由

    【视频教程】技术栈大升级:Vue3 到 Nuxt3(2-3)升级实战 – 基础知识,适配 SSR,页面路由

    2023 年,我个人最大的变化,是从 Vue3 SPA 应用向 Nuxt3 SSR 应用过渡,在预期可能存在 SSR 需求的项目中,都尽量使用 SSR。包括 React 应用,也尽量使用 Next.js,而不是 React SPA。

    这个过程中,面临到很多问题,很多思路需要转换,很多以前没关注的点需要关注。本系列视频试图快速教会大家这些要点,帮助大家顺利从 SPA 切换到 SSR。

    继上次的知识科普视频之后,我们开始实战。连续三次直播都比较失败,所以更新比较慢。第二期视频包含以下升级要点:

    1. 处理项目开发脚手架
    2. 迁移静态资源
    3. 修改引用地址
    4. 利用 process.client<client-only> 组件处理不能 SSR 的功能
    5. 处理 TailwindCSS
    6. 使用 useCookie 替代 localStorage

    第三期视频包含以下要点:

    1. 处理页面路由,理解页面嵌套
    2. 全局使用 css
    3. 附赠内容(拖时长):关于读源码

    后面应该还会再录制两期,分别是:服务器 API 开发;深入理解 SSR 与 useAsyncData+Pinia。

    如果你对 Vue3 开发、Nuxt3 开发、SSR 感兴趣,欢迎关注我的本系列。如果你对这些话题有疑问,欢迎留言讨论。

  • 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 开发感兴趣,有相关的问题,欢迎留言讨论。

  • Solidity/Rust 实战 — Web3 开发共学活动

    Solidity/Rust 实战 — Web3 开发共学活动

    感谢赞助商 HackQuest.io 委托我发布本条消息。

    Solidity/Rust 共学营信息清单

    • 🕙 每周一开始,滚动录取
    • 💰 免费 (成功结营的小伙伴还将获得专属周边)
    • 🌎 全程线上  (会议具体时间入营后通知)
    • 🎟️ 头部公链官方签发的学习证书

    关于 HackQuest 🚀

    HackQuest 是一个充满活力的 Web3 开发者教育社区,我们的目标是培养下一代 Web3 开发者。

    目前我们的产品仍处于内测阶段,我们计划招募小伙伴们一起学习 Solidity/Rust 开发。无论你是在校学生还是已经工作的程序员,Web3 小白或从业人士,我们都欢迎你加入我们的 Web3 开发者社区 🫶

    关于 Solidity 🧑‍💻

    Solidity 是一种专为编写区块链智能合约而设计的编程语言,主要用于以太坊和几乎所有 Layer2 公链。它受到 JavaScript 和 C++ 的影响,语法简洁,使开发者能够创建和管理数字货币及其他去中心化应用。

    随着区块链技术的发展,Solidity 成为了开发去中心化应用(DApps)的重要工具之一,特别是在 DeFi、GameFi、DID 等赛道。

    关于 Rust 🦀

    许多头部公链的编程语言和开发框架都是基于 Rust 语言为核心。Arbitrum、Solana、NEAR 等众多优质区块链项目都使用 Rust 语言开发,或用 Rust 语言开发其上的智能合约。同时,Aptos 和 Sui 使用的 Move 语言以及 Starknet 使用的 Cairo 语言都是基于 Rust 衍生的编程语言。

    在共学营期间你会学到 ✍️

    • Web3 基础知识,Solidity/Rust 语法
    • 头部公链 Solana, Arbitrum, Mantle 等背景介绍
    • 代币发行、NFT、区块链游戏 Crypto kitties 等项目的学习和部署

    你可以期待 🥳

    • 结识志同道合的小伙伴,一起学习 Web3,也可以一起组队参加黑客松
    • 获得 Mantle/Solana/Arbitrum 等公链官方签发的学习证书(可以上链,也可以挂到 LinkedIn 主页哦)
    • 获得 Web3/区块链 实战开发经验
    • 加入 HackQuest Web3 开发者社区,共学,共创,共治。

    🌟 所有成功结营的小伙伴将获得 HackQuest Colearning 专属周边,成为 HackQuest 社区的创始伙伴!快来加入我们吧!

    报名链接:添加微信共学小助手并备注“共学营”,微信号:moonshot_rick

  • 【教程】浏览器扩展中实现一键登录 Google(2)

    【教程】浏览器扩展中实现一键登录 Google(2)

    本文接续前一篇 【教程】浏览器扩展中实现一键登录 Google(1),重点介绍代码相关的部分。

    SSO 简介

    为防止有些同学不了解 SSO,这里简单介绍一下 SSO 的基本原理:

    1. 在自己的网站/工具上启动登录流程
    2. 跳转到第三方网站(Single Site)进行登录
    3. 登录完成之后,被重定向回到自己的网站/工具,回来的地址会包含一些 token
    4. 拿 token 去第三方网站校验,如果成功,将用户信息写入本地系统
    5. 之后就可以用本地的用户信息进行交互

    使用 SSO 的好处是:

    1. 减少用户心智负担,用户只需要在一个网站管理自己的用户信息即可
    2. 应用开发者不再需要费劲开发关于账号安全的功能

    使用 chrome.identity API 整合登录功能

    Chrome Extension API 提供两个登录方式:

    1. getAuthToken() 获得基于 Google 账号的 auth token,可以用来访问特定的 Google 服务,比如 Google Drive,在第三方浏览器比如 Edge 里不可用。
    2. launchWebAuthFlow() 使用上述 SSO 流程,在任何支持 SSO 的网站里完成登录。

    因为我并不打算和 Chrome 深度绑定,所以自然,我们要用第二种。

    由于我们要在浏览器扩展中 SSO,所以无法接受登录之后的重定向,所以上述步骤的(3)会变成在扩展里拿到 access_token,然后去 Supabase 换取用户身份信息,并存储在本地。

    manifest.json

    首先,我们要修改 manifest.json,将需要的 oauth2 信息添加进去。因为我用 CRXJS Vite Plugin,所以这里直接用 TS 来写就好:

    {
      permissions: [
        'identity',
      ],
      oauth2: {
        'client_id': 'XXXX-XXXXXX.apps.googleusercontent.com',
        // 我们只需要 openid 和 email 就够用了
        'scopes': [
          'openid',
          'email',
        ],
      },
    }
    

    Login 方法

    接下来,完成登录函数,代码写出来大约是这样:

    async function doLogin(): Promise<void> {
      // 获取 manifest.json 内容,方便重用代码
      const manifest = chrome.runtime.getManifest();
      let url = new URL('<https://accounts.google.com/o/oauth2/auth>');
      if (!manifest?.oauth2?.scopes) return;
    
      url.searchParams.set('client_id', manifest.oauth2.client_id);
      url.searchParams.set('response_type', 'id_token');
      url.searchParams.set('access_type', 'offline');
      url.searchParams.set('redirect_uri', `https://${chrome.runtime.id}.chromiumapp.org`);
      url.searchParams.set('scope', manifest.oauth2.scopes.join(' '));
    
      let redirectedTo;
      try {
        // 开启 SSO 弹窗,获取重定向地址
        redirectedTo = await chrome.identity.launchWebAuthFlow({
          url: url.href,
          interactive: true,
        });
      } catch (e) {
        return;
      }
    
      url = new URL(redirectedTo);
      const params = new URLSearchParams(url.hash.replace(/^#/, ''));
      // 用 token 去 supabase 进行验证
      const { data, error } = await supabase.auth.signInWithIdToken({
        provider: 'google',
        token: params.get('id_token') as string,
      });
      if (error) {
        return;
      }
    
      userStore.setUser(data.user);
    }
    

    登录一定需要后端验证,但是在 Supabase SDK 的帮助下,我们可以完全跳过这部分开发。Supabase SDK 甚至贴心的提供了 signInWithIdToken() ,所以我强烈推荐各位独立开发者使用 Supabase 作为用户体系的基础。

    要避开几个坑

    这里有几个坑必须小心。

    1. 前面在 Google Cloud 里创建凭据时必须创建“Web应用”,“Chrome 扩展”针对的是 getAuthToken()
    2. Google Cloud 创建应用之后,要等很久才能用,我也不知道为什么。Google Cloud 给出的提示是 10 分钟到数小时,我建议一次配置好之后,至少等待 4 个小时再去开发,可以避免很多意外
    3. 不要使用 getRedirectURL() ,它会在末尾多带一个 / ,导致登录失败。我也不知道为啥会有这么蠢的问题,但是调试几个小时之后,我不得不认为的确是这个问题……

    给没有 Google 账户的用户提供兜底

    总有一些用户没有 Google 账号,所以一般来说我们会给他们提供常规的邮箱+密码登录的方式作为兜底。

    因为使用 Supabase,所以这里的功能实现也比较简单。Supabase 默认开启 Email provider,直接在代码增加一个登录函数即可。因为我们暂时不验证用户邮箱,所以在一个函数里帮用户完成注册/登录。表单我就不贴了,两个文本框一个按钮,很简单。

    
    async function doLoginViaForm(): Promise<void> {
      try {
        const user = await userStore.register(email.value, password.value);
        userStore.setUser(user);
      } catch (e) {
        const msg = (e as Error).message || Object.toString.call(e);
        // if the user is already registered, we will try login
        if (msg === 'User already registered') {
          try {
            const user = await userStore.login(email.value, password.value);
            userStore.setUser(user);
          } catch (e) {
            message.value = (e as Error).message || Object.toString.call(e);
          }
        } else {
          message.value = msg;
        }
      }
    }
    

    总结

    至此,在浏览器扩展里集成 Google SSO,实现一键登录的功能就完成了。这个过程本身不复杂,但是有几个坑,还有不少易混淆的地方,我也花了不少时间在上面。希望我的这篇分享对大家有帮助。如果大家对浏览器扩展开发、Serverless 数据库使用、Vue 开发有什么问题,欢迎留言评论讨论。


    感谢几位赞助商支持我创作技术分享,也请大家多多使用我的分享链接注册使用他们的服务。在顶部导航的“各种代理”里就能找到链接。谢谢大家。

  • 感谢 Zeabur 成为本站第一位赞助商

    感谢 Zeabur 成为本站第一位赞助商

    2023 年年初,我在刷推的时候受 @xiqingongzi 启发,决定为本站进行招商。想法很简单:钱不钱的不重要(因为肯定挣不到钱……),关键是想看看有没有老板愿意支持我。换句话说,主要在于认可。

    果不其然,2023 年过去了,没人来。没关系,2024 继续努力。终于在 部署网站该选 Vercel 还是 Cloudflare Pages 一文之后,有老板主动联系我做投放了!!

    这位老板来自 Zeabur,他们的产品类似 Vercel + Fly.io,既支持使用 Edge Function 的基于 SSR 框架的产品部署,比如 Next.js,Nuxt 应用;也支持基于 Docker 的容器部署,比如 MySQL、WordPress。可以说是一站式满足独立开发者、新产品验证阶段的需求,还提供大量的免费额度,非常值得一试。他们还提供中文文档,对各位英语苦手的国内开发者来说,相信也会有很大帮助。

    推荐各位开发者尝试这款产品,也请大家在注册的时候,务必使用我的推广链接:

    后面我还会基于他们的产品做一些试用和介绍,敬请期待。

    感谢大家的支持,每一位读者都是支持我继续写下去的动力。2024 年已经过去 1/3 了,希望后面 2/3 我能带给大家更多有价值的内容。

  • 【视频教程】开发AI求职助手,一起走上全职远程之路(一)

    【视频教程】开发AI求职助手,一起走上全职远程之路(一)

    新系列简介

    开个新坑。其实类似的想法我去年就有了,但是一直没有做,除了懒之外,很大的问题就是我不会爬虫。而且在我的认识里,爬虫是一个很依赖后续维护的工作,不符合我写完能用很久的预期。不过我最近在思否看到 亮数据,似乎可以很好的弥补我的不足。于是我决定先把坑挖起来。

    本文是系列的第一集,会先介绍我的动机(找到全职远程工作);我设想的做法;介绍亮数据;分析我的代码(踩坑经验);最终初步抓取到 Vuejobs 的远程数据。

    创造新工具帮我们找到远程工作

    如何找到全职远程工作

    全职远程工作有很多好处,比如可以去泰国曼谷耍泼水节,只要安排好时间,工作娱乐两不误。很多朋友问我,怎么找到一份全职远程的工作,我有几个建议分享给大家:

    1. 不断提升自己,扩展技术栈。因为全职远程跟大公司做螺丝钉不同,更倾向于独当一面,所以你的技术栈越全面,能做的工作越多,找到全职远程工作的可能性也越大。
    2. 利用现在的工作机会,给自己打造可靠的个人品牌,形成良好的合作团队,利用好自己的工作副产品,给独立工作或者远程工作做好准备。
    3. 培养自己的自控力。远程工作比较重视结果,你越能控制自己,稳定输出,找到远程的机会也就越多。

    除去以上三点,今天要重点分享的,是如何找到尽可能多的远程工作机会;或者,要找到你需要弥补、增强的能力。这个过程,我们要学会利用好各种工具服务和提升自己。如果没有现成的工具,我们就自己开发需要的工具。

    关于亮数据(Bright data)

    我前几天在思否上看到一个小广告,叫 亮数据。看介绍,我发现它能很好的帮助我补强网络爬虫、内容抓取的能力。尤其是看其功能设计,能解决我前面说的“重维护”问题,我觉得值得一试。

    至于做什么,我觉得以前设想的“ 应用创意:AI 求职助手”很合适。只不过,我早先设想时,把简历上传、AI 分析放在第一步;现在我觉得,可以把工作机会获取、AI 分析与提示,放在第一步。即:

    1. 有一个爬虫,帮我四处收集招聘信息,尤其是全职远程
    2. AI 帮我分析 JD,并根据我的基本简历,生成求职信+针对性简历,投递
    3. AI 帮我准备面试,直至入职

    编写爬虫脚本

    想好就动手。今天的目标是做完第一步,也就是数据抓取,后面再继续做 AI 分析JD 和处理简历。我起初想用他们家的在线 IDE,尝试之后发现不太符合要求,调试起来也比较费力,遂放弃,改用亮数据浏览器(Scraping browser)。

    亮数据浏览器是他们部署在全球的服务,我们可以用 puppeteer-core 连接,然后发起请求,抓取目标网页。他们会帮我们解决一般的访问限制,甚至宣称可以通过验证码(我没试)。我觉得这样设计最大的好处是,我们可以在本地简单的开发爬虫脚本,然后直接上线使用,可以与既有的开发习惯轻松融合。

    这一步的脚本很简单,我就不详细介绍了,大家可以直接在 我的 GitHub 仓库 里查看;我的视频里也有详细讲解。这里只列举一下我踩过的坑:

    1. 连接亮数据浏览器需要使用 puppeteer-core,不能用 puppeteer,否则会超时,不知道为什么。
    2. 使用前必须付费,或者,请大家用我的 分享链接 完成注册,这样你就有 $10 的试用额度
    3. 因为 puppeteer-core 要使用 WebSocket 连接,之后每步操作也都要走 WS,所以网络就非常重要。我建议大家用云服务器来跑,我用的是博客服务器,美国 DO。
    4. 每次请求 打开一个网页,抓取一些信息。如果需要打开多个网页,就多次连接亮数据浏览器、打开页面

    配置亮数据

    调试好脚本之后,我们需要把它连接到亮数据浏览器。请大家使用我的 分享链接 完成注册,这样你就有 $10 的额度可以使用。

    登录之后,在 代理&爬虫基础设施 里找到“亮数据浏览器”,点击“开始使用”按钮,创建可用实例。

    如果参考我的脚本,可以先复制 .env.example.env,然后把用户名密码放在 BRIGHT_DATA_AUTH,把主机放在 BRIGHT_DATA_SBR_WS_ENDPOINT 即可完成配置。如果你自己编写脚本,也请注意让配置生效。

    至此,再找一台合适的服务器,就能完成抓取了。可能 Vue jobs 平日的访问量也不大,所以没有什么防护策略,至少我的简单脚本用起来没问题。如果未来遇到难抓的网站,我再尝试进阶用法。

    视频教程

    小结

    时间关系,今天先介绍第一部分,也是我最不熟悉的爬虫部分。后面会集成 AI 分析和记入数据库,那个我就比较熟了。

    对远程工作、爬虫开发、全栈开发等有兴趣、有问题的同学,欢迎留言讨论。也请大家多多支持我的文章和视频,给我动力尽快更新下一期。


    请大家使用我的分享链接注册 亮数据,这样你我都能获得 $10 的使用额度,我也会尽快更新下一篇。