标签: nuxt

  • 解决跨时区 Nuxt SSR 导致的页面离奇 Bug

    解决跨时区 Nuxt SSR 导致的页面离奇 Bug

    打开后台一看,如果本周再不写博客,就三周没更了,还是抽点时间分享点东西吧。今天结合近期解决的问题,分享一下出海服务全球用户,并且使用 SSR 时可能遇到的问题。

    问题

    我厂的 AI 日记产品 DailyLift 遇到一个非常奇怪的问题。合伙人发现,在午夜过后,打开我们的网页,界面会渲染得非常奇怪。

    这里的问题很多,不管是文字样式还是背景图案,都很奇怪,只看错误截图和代码完全没有思路。给不了解我厂产品的读者介绍一下上图中的 bug:

    1. No data 表示当天无数据,正常情况时字体很小。
    2. 只有具体的数字才应该显示成这么大的字体。
    3. 配图也不对,no data 对应的应该是其它图片。
    4. No data 自然不应该有弧形进度条。

    难以复现的 bug

    我们是远程工作,我一般会把工作时间固定在早上 9:30~晚上 6:30。当我次日开始工作,打开开发环境开始 debug,这个 bug 完全无法重现。对合伙人来说,这个 bug 也是时有时无。所以,这个 bug 在我们的任务列表里开了关关了开,反反复复,非常难搞。

    但是随着时间发展,我们还是总结了一些问题特征:

    1. 此问题只在午夜零点之后出现
    2. 此问题只在刚发布过日志之后出现(这个很关键,可惜我解决 bug 那天才发现)
    3. 此问题只在新加载页面时出现
    4. 此问题只在线上(生产 / 测试)环境中出现

    至此,我还是没有什么思路,只是觉得,可能要午夜的时候来调试一下。

    捕获 bug

    上上周,儿子考试结束,我们带他去长沙武汉各玩两天,逛街三天,赶车三次,日均 1w 步之后,我的膝盖很累。导致周三的力量举 PR 测试状态奇差,离既定目标差了一半。(感兴趣的同学可以看下:年中测试新极限,逛街三天赶车两次,状态很差,只完成目标的一边。下次继续努力吧

    晚上睡觉前,我心情比较低落,毕竟努力了半年,之前的运动表现估算起来,肯定不止这个数字。于是我就随手打开 DailyLift,写了篇日志。然后,我鬼使神差地想起这个 bug,随手刷新了一下页面,正所谓失之东隅,收之桑榆,竟然复现了这个 bug!

    简单介绍一下我们的技术选型:

    1. Nuxt
    2. Supabase
    3. 部署在 Vercel,非企业用户,只能选择特定区域,于是我们选择美西服务器

    开发环境无法复现,线上环境稳定复现。虽然表象无法推测问题所在,但也是巨大进步。我思来想去,突然想到,会不会跟服务器端渲染(SSR)有关?于是我查看源代码——注意,是源代码,不是 DOM 树;因为 DOM 树可能会被 Vue(Nuxt)修改过,不是初始状态——一看,果然,服务器端渲染的结果跟我页面的效果不一样。而这样的结果,是由于时区导致的。

    我的博客发表在 12 点之前,刷新页面在 12 点之后。如果大家观察上图,可以发现,我们是按照日期来分割数据的。我身处 +8 时区,所以我的 N 日 0 点,是 UTC N-1 日 16 点;Vercel 服务器位于美西,差不多是 -7 时区。于是,Vercel 在服务器端渲染页面的时候,他认为我的这篇日志,应该是 N-1 日 9 点钟写的,N-1 天还没结束,所以是当天(Today)的日志。 但是当我在本地运行 Vue 的时候,我已经是 N+1 日了,于是我本地的代码认为日志发表在昨天,今天还没写过日志。

    于是,Nuxt 尝试对 DOM 进行修改,但不知为何,它复用了服务器端传回来的部分 HTML,修改了部分 HTML,所以就出现了前面的 bug。

    解决 Bug

    找到问题根源之后,解决问题就没那么难了。唯一的难点就是克服测极限带来的身体疲劳,不过找到问题的兴奋感冲淡了这份疲劳。

    查阅文档之后我发现,Vercel 倒是早就帮我们准备了解决方案,我们只需要用useRequestHeader('x-vercel-ip-timezone') 就可以获取到用户所在的时区,接下来,只要保证整个 SSR 在这个时区里进行就好。

    我使用的 dayjs 来处理时间,它自带 timezone 功能,所以给所有 dayjs() 后面加上 dayjs().tz(tzHeader) 就可以。

    总结

    最后,困扰我们一个月之久的 Bug 终于解决。我也意识到处理时区在全球化应用开发时的重要性。合伙人问我有没有办法确保类似的问题不再发生,我回他:我这辈子都不会忘了处理时区了……

    希望这篇博客对你的出海应用开发有帮助。如果你对 Nuxt、服务器端渲染、云服务器等有问题,欢迎留言讨论。

  • 搬家记:从 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 产品开发、录音播放等有兴趣或问题,欢迎留言与我讨论。

  • SSR,云平台,ChatGPT——我的 2023 技术关键词

    SSR,云平台,ChatGPT——我的 2023 技术关键词

    前言

    2023 年,因为换工作,启动新项目等原因,我对我的技术栈进行了比较大的更新,主要集中在这三个方向:

    1. SSR(Server Side Rendering,服务器端渲染)。之前我开发的项目基本上都是 SPA(Single Page Application),比如 Vue,但之后我会越来越多开始用 Nuxt。由于基础设施的发展,以后 SSR 会更方便更好用。
    2. 云平台。以前我大概买了 3、4 台云服务器用来做各种尝试,在上面各种折腾。去年使用 Vercel、Supabase、CloudFlare 平台之后,我已经不打算再在服务器上浪费时间了,云平台实在太好用了。未来我会努力把所有服务都迁移到云平台上,新增产品都直接云原生。
    3. ChatGPT。相信不只是我,很多人都会把 ChatGPT 作为去年技术的首选关键词。如今我不仅在上面完成产品开发,日常也会使用它替代大部分的搜索;甚至我家孩子写作业也会使用它来帮忙。我认为,未来 ChatGPT 就像是搜索引擎一样,决定了一个人的起点和成长速度。

    接下来逐个分享。

    服务器端渲染,SSR

    起初我不是很看重 SSR,我总觉得,我当年也写过 PHP,有什么“服务器端渲染”我没见过?实际用过之后,我承认:真香……

    首先,使用 SSR 可以提升用户体验,且有利于 SEO,这点相信大家都知道。如果对其原理不太清楚的话,欢迎观看我的视频:从浏览器渲染机制理解 Web 性能——“在浏览器地址栏输入 URL,按下回车后会发生什么?”

    其次,如今的 SSR 与当年 PHP 模版套页面的实现有很大区别:

    1. 语言同构化:开发难度大大降低,没有心智负担。
    2. 数据传递与状态管理:虽然数据不能完全通用,但是框架尽量会帮我们处理好,让我们在服务器端和客户端都能自由使用。
    3. 渲染由边缘计算负责:这一点有点依赖云平台,不过考虑到浏览器的渲染机制,SSR 并不会拖慢渲染速度,用户体验只会更好。
    4. 页面切换不需要重新加载。对于旧的编程语言来说,因为前后端环境割裂,所以页面切换的时候都是重新加载完整页面;但是新框架下,则只需要加载数据即可,此处跟 SPA 的体验无二。

    第三,如今的 SSR 框架都很好的整合了服务器,包括中间件等功能,还有各种官方第三方模块支持,能大大降低我们开发服务器软件的成本。所以已经是我启动新项目的不二之选。

    云平台

    以前我长期维护好几台服务器,一方面可以部署自己做的产品 demo,另一方面也可以部署一些开源项目方便日常使用。因为各种云都有面向新用户的优惠活动,所以成本不高,我觉得值得一搞。

    自己的服务器当然比较比较自由,坏处就是免不了产生运维成本,即使使用 docker 也一样。部署新代码至少要去跑一遍拉取脚本,对吧?我的一位老板朋友甚至请我帮忙写了一套服务器脚本,用来做 CI/CD。

    初期这么搞没问题,但后来就越来越觉得功能不够,性价比也太低,开始寻求替代方案。之前我参加 Hackathon 的时候了解到 Vercel 云平台。它与 GitHub Pages 不同,支持 SSR、支持云函数,配合一些云数据库,比如 Upstash,可以快速搭建起来一套可用的服务。去年年初,我的那位老板朋友想做一套打分系统,放在他的静态网站里,于是,我就尝试用 Nuxt.js + Upstash 开发了一套,并且部署在 Vercel 上,效果非常好,免运维,多环境,推到 GitHub 自动部署,实在太好用。

    我把这个过程制作成了系列课程:Nuxt3+Vercel+Serverless 数据库全栈开发。大家感兴趣不妨看一看。

    后面一发不可收拾,过去一年我不再采购新的单体服务器,旧的服务器用完也不再续费。新产品都部署在 Vercel 等云平台上面,帮我节省了大量的时间。

    Vercel 去年年中的时候开通了存储功能,实际上就是打包了几家云数据库服务来卖,我也很快获准开通。从此,云平台使用就更加顺利了。临近年底,我尝试 CloudFlare Pages,效果也非常好。他们家的优势是自带统计分析功能,远比 Vercel 大方,一站式解决更省心。

    云数据库方面,我使用 Upstash 的 Redis,KV 数据库足以满足大部分产品需求。数据库用 Supabase 和 TiDB 比较多。前者支持 PG Vector,方便我们进行 LLM Embedding & Search;后者则提供 5GB 免费额度,比较好用。云存储有 CF 的 R2,空间和流量也相当充足。如果不是 PHP 太老没人支持,我都想把博客这台机器退掉了。

    ChatGPT,以及其它

    ChatGPT 更是值得大书特书的一个技术关键词。不过考虑到大家去年一整年应该已经被类似的内容淹没了,所以我这里就少写一些,只说说我的情况。

    我目前订阅了 ChatGPT+,方法是借用国外亲戚的手机号注册,并且用他的手机号注册 PayPal,通过 Google Play 订阅。订阅的原因是 ChatGPT 4 + DALL-E 都可以随便用,比 API 便宜得多。

    在编程领域,GPT-4 比 GPT-3.5 好太多了,知识库更新到去年 4 月份之后,除了 next.js 14 的内容外,我日常的编程问题大多可以用 GPT-4 解决,比如:

    • 写正则
    • 写 SQL
    • 查函数、查第三方库
    • 纠正函数错误

    帮我节省了大量的 Google 时间,单凭这点,每月 $19.99 的订阅费用就很值得。

    除此之外,我还在继续使用 GitHub Copilot。Copilot 也很好用,除了生成工具函数、编写测试外,我发现翻译语言和框架方面也有很大的作用。去年我就完全靠它开发了一个 flutter 应用,方法就是把 TS+Vue 写好的代码丢给它让它翻译。

    所以,无论是学习新东西,保障日常开发,还是扩展新领域,AI 对我都帮助巨大。

    总结

    总而言之,如果再有同学问,前端想学后端,应用学什么语言框架以及是否需要搭自己的服务器?我都会建议他们:不要学 Express、Koa;习惯用 Vue 就学 Nuxt,习惯用 React 就学 Next.js;不需要搭建服务器,就用云存储就能解决绝大多数问题。

    我还建议大家,尽快想办法开通 ChatGPT,再不济国产大模型也要用起来,未来是 AI 的时代,学会用 AI,效率会大幅度提升。半年的初入门新人,善用 AI 可以赶上 3 年的老程序员;而老程序员学会用 AI 之后,可以快速把自己的能力扩展到其它领域。

    以上,就是我去年关键的技术栈总结,希望对大家有所帮助。如果大家有什么意见建议,想说的想聊的,欢迎留言。