标签: vite

  • 使用 CRXJS Vite 插件开发 ChatGPT SidePanel 插件(一)

    使用 CRXJS Vite 插件开发 ChatGPT SidePanel 插件(一)

    OpenAI DevDay 简单回顾

    OpenAI DevDay 上发布了一大堆新特性新功能,提升上下文容量、降低 token 价格,再次震撼整个行业。相信大家已经通过各种渠道了解到这次更新的细节,所以我就不再赘述。这里简单分享三个观点:

    1. 这次更新最值得关注是 Assistant API,因为这项功能大大降低了 AI 工具的开发门槛,让很多开发者不需要学习了解新技术,就能上手开发较复杂的 AI 应用。
    2. TTS、DALL-E 3 API 开放后,OpenAI 开发生态基本完整。ChatGPT 可以开口说话,也可以动手画画;再加上前面说的 Assistant API 所带来的 Retrieval 和 Code Interpreter,ChatGPT 可以拥有训练集以外的知识,也可以拥有 LLM 以外的逻辑思维能力。产品实现上的卡点基本打通,剩下就是应用层开发扩展了。
    3. $20/月的 ChatGPT Plus 价值进一步提升,俨然已经是性价比之选。如何好好利用,将其价值压榨出来,值得我们思考。我的想法是通过浏览器扩展加强自动化与可编程性。

    Chrome Extension SidePanel API (Chrome 114+)

    Chrome 浏览器从 v114 之后,开始支持 SidePanel,从此我们可以把扩展放在浏览器侧边栏里,提供新的可能性。

    之前我们使用扩展时,有三种方案,它们都有一些影响使用的问题:

    1. Popup:非常容易被关闭,基本上只要 popup 窗体失焦,就会被关闭,里面执行中的程序也会停下来。
    2. Content Script 插入 DOM:新插入的 DOM 可能跟原本的页面有冲突,尤其是样式,会增加开发成本。
    3. 独立打开:需要成为 activeTab,无法与目标页面共存。

    这些问题都可以被 SidePanel 很好地解决。于是,我们可以利用 SidePanel API 开发一个浏览器扩展,它可以大幅加强某个网站的功能、提升在这个网站里执行自动化操作的能力。我们不用担心它会被以外关闭,导致自动化失效;也不用担心它会和目标网页产生冲突。

    假如,我们针对 ChatGPT 网站开发一个扩展,加强它的功能,把 ChatGPT Plus 的功能和额度用好用满,应该可以实现一些相当不错的功能。

    CRXJS Vite 插件改进浏览器扩展开发

    产生上述想法之后,我就一直想找机会试试。不过开发浏览器扩展还有个痛点:扩展拥有加强版 API,在普通页面里无法使用;但是如果使用开发者模式加载扩展,又会丧失 HMR,开发不便。

    经过调研,发现 CRXJS Vite 插件 可以解决这个问题。它可以给插件开发环境添加 自动更新的功能,我们就不需要每次更改代码之后再手动刷新,也可以确保我们的开发环境支持全套 chrome.* API,与实际运行环境一致,大大提升我们的开发效率。

    使用该插件的方式非常简单。首先,创建一个 vite 项目。对我来说,效率最高的框架还是 Vue3。本着每次尝试的新技术不要超过 1/4 的比例,那就 vue-ts 吧:

    pnpm create vite my-vue-app --template vue-ts

    接下来,安装并配置 crxjs vite 插件:

    pnpm i @crxjs/vite-plugin@beta -D

    然后配置 vite.config.ts

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import { crx } from '@crxjs/vite-plugin'
    import manifest from './manifest.config';
    
    export default defineConfig({
      plugins: [
        vue(),
        crx({ manifest }),
      ],
      // 注意,这段配置很关键,请保证开发端口与 hmr 端口一致。不知道为何,插件生成的扩展里缺少 5173 默认值。
      server: {
        strictPort: true,
        port: 5173,
        hmr: {
          clientPort: 5173
        },
      },
    })

    我的 manifest.json 也是使用 TypeScript 生成的,所以上面我 import 本地的 manifest.config.ts 文件。

    export default defineManifest(async function (env) {
      return {
        "manifest_version": 3,
        "name": "my ChatGPT tools",
        permissions: [
          'activeTab', // 要往目标页注入脚本
          'scripting',  // 同上
          'sidePanel',  // 启用 sidePanel
          'tabs', // 为了与 content script 通信
        ],
        content_scripts: [
          {
            matches: ['https://chat.openai.com/*'],
            // crxjs 会帮我们把目标文件编译后注入目标页面
            js: ['./content/src/index.ts'],
          },
        ],
        // 针对 ChatGPT 而做
        host_permissions: [
          'https://chat.openai.com/*',
        ],
        // 启动 sidePanel 时,加载当前项目的页面
        side_panel: {
          default_path: 'index.html',
        },
        // 这里主要为了点击图标能打开或关闭 sidePanel,background script 同样交给 crxjs 处理
        background: {
          'service_worker': 'src/sw.ts',
          'type': 'module'
        },
      };
    });

    配置完成之后,照常启动项目 pnpm run dev

    然后在浏览器的扩展管理器里启动开发者模式,加载已解压的扩展目录即可。

    CRXJS 插件原理

    启动开发环境之后,CRXJS 会帮我们生成一个开发版的浏览器扩展,里面除了必备文件之外,还有各大组件所需的加载器,帮我们分别加载 service worker、content script,和页面内 js。它还会建立一个 WebSocket 连接到 vite 开发服务器,当侦听到目标文件出现变化时,就通过各种方式重新加载。比如,页面文件可以直接 HMR,service worker 可能要刷新组件,而 content script 甚至要刷新目标页。

    于是,便实现了浏览器扩展在开发环境下的 HMR。

    使用 CRXJS 开发浏览器扩展的注意事项

    首先,前面代码里有写,需要注意配置 HMR 端口。不知道为何,CRXJS 不使用默认的 5173 端口。

    其次,content script 需要在目标页面执行,所以 content script 修改后,常常需要刷新目标页。但是不知道什么原因,有时候 CRXJS 自动刷新目标页之后,content script 并没有更新,我猜测与这几步操作的执行顺序有关。我建议用开发者工具打开 content script 确认一眼。

    比如我的 content script 是 content/src/index.ts,那么就确认 content/src/index.ts.js 即可。

    以及,由于 HMR 可能会更新运行环境,如果此时恰逢我们在使用 chrome.tabs.sendMessage() 传递消息,可能导致 SidePanel 页和目标页连接断开,消息传送失败。解决方案嘛,就是多重启。修改完消息两端的代码之后,连目标页带侧边栏一起重启一次,即可。

    总结

    目前我的扩展还在开发中,将来做好了可能会上线 CWS,暂时就先不公开仓库了。

    新技术总能带来新的可能性,希望大家都能抓住这一波机会,无论是 OpenAI、LLM 还是浏览器 SidePanel 扩展,做出有价值的产品。

    有任何问题、建议、想法,欢迎留言讨论,共同进步。

  • Turbopack 发布后的各方反应:Vite/Webpack/围观群众

    Turbopack 发布后的各方反应:Vite/Webpack/围观群众

    上周整理了一下 Turbopack 发布后的各方反应,以便让自己和各位同学下一步做决策的时候能有所参考。接着忙碌的一周过去,我发现博客这周还没更,于是赶紧来补一下。

    0. TL;DR

    1. Turbopack 的宣传语更多还是“宣传”,实际效果提升并没有那么巨大。
    2. Turbopack 目前只支持 Next.js + React,且没有建立健全插件机制,所以没有生态可言,如果我们的主要开发环境不一致,可能暂时用不到。
    3. Turbopack 基于 Rust 开发,性能提升主要来自 SWC,所以有时间、有兴趣的话,直接使用 SWC 替换编译工具就好。
    4. 非目标开发者群体建议先不考虑迁移。
    5. Turbopack 对于打包过程有不小的改进,值得持续关注。
    6. 如果你日常使用 React,且想在新技术出现时斩获先机,早点动手也挺好。

    2022-10-26

    首先,10月26日,Vercel 发布了 Turbopack,自命 Webpack 继任者,号称速度比 Wepback 快 700 倍,比 Vite 快 10 倍。

    这些是官推亮点:

    • ~700x faster than Webpack -> 比 Webpack 快 700 倍
    • 10x faster than Vite -> 比 Vite 快 10 倍
    • Native incremental architecture built with Rust -> 基于 Rust,具备原生可增长架构
      ◆ Support for React Server Components -> 支持 React 服务器组件
      ◆ Support for TS, JSX, CSS & more -> 支持 TS、JSX、CSS,以及更多

    尤大观点

    很快,Vite 作者 @youyuxi 就跟进表达反驳:

    “10x faster than Vite” isn’t entirely accurate

    “‘比 Vite 快 10 倍’并不完全准确”,他说。

    然后他进一步介绍了自己的理由:

    1. Vite’s default React HMR is still babel-based, while Next/turbopack use swc/rust-based transform, so the HMR performance difference is a bit of apples to oranges.

    因为 Vite 默认的 HMR 仍然基于 Babel,而Next/Turbopack 使用的是基于 Rust 平台的 SWC,所以这里 HMR 的性能差异是关公战秦琼(这个 thread 后面还有若干理由,我就不一一翻译了):

    Vite can switch to the swc/rust transform if necessary, we currently chose not to do that because adding swc to the deps list is extra weight, and even without it HMR is fast enough.

    Vite 当然可以在必要时切换到 swc/rust,但 Vite 开发团队并不想这样做,因为即使只用 Babel(不那样做),HMR 也足够快。使用 SWC 替换 Babel 只是平添开发负担。

    1. turbopack does address Vite’s request congestion issue on large apps. On Vite side we are also exploring how we can solve this via upcoming browser features like web bundles.
    1. Turbopack 确实解决了 Vite 在大型应用上的请求拥塞问题。我们 Vite 团队这边,也在探索如何解决这个问题,比如,是否可以借助即将推出的浏览器功能(Web Bundles)。

    In the long run we may also consider using turbopack under the hood to replace esbuild/Rollup (where suitable), due to its strong caching capabilities.

    1. 从长远来看,由于 Turbopack 强大的缓存能力,我们也会考虑在底层使用 Turbopack 来替换 esbuild/Rollup。如果合适的话。
    1. I’m glad vercel is investing its resources into a proper native-speed successor to webpack (as it should), and that our work on Vite has pushed this to happen. It’ll be interesting to see how it evolves – my hope is it can be made truly framework agnostic, not Next-first.
    1. 我很高兴 Vercel 愿意投入资源,打造 Webpack 的继任者,并着力提升它们的速度表现(应该如此),我们在 Vite 上的努力也达到了这样的目的。我对它们未来的演变很感兴趣——我希望它不要和框架捆绑在一起,不要 Next-first。

    Sean Larkin 观点

    (Sean Larkin 是 Webpack 的核心团队成员。目前好像在 LinkedIn 工作,好像。)

    Sean Larkin 也表达了自己的观点:

    Few thoughts: 几点想法:

    1. Love the innovation but it looks moderately SWC powers and wish there was some more callout there.
    2. Disappointed how entangled this is with next/turborepo. But Vercel needs to make.
    3. The path of migration from webpack for the avg user will be hard.
    4. I like that the dev server is still bundling modules because ESM slower than raw ESM. I need to peel back more layers to try and get a standalone implementation working.
    5. It’s all in alpha so we need to still look at the broader view but I hope we’re selling less going fwd
    1. 我喜欢创新。但看起来,Turbopack 更多是借力于 SWC,希望对方能表达得清楚一些。
    2. 我对 Turborepo 与 Next 的绑定程度感到失望。但是 Vercel 需要挣到更多的 (天使资金),没办法。
    3. 普通用户想 Webpack 迁移过去,会很艰难。
    4. 我倾向于开发服务器上的模块仍然是打包后的,因为 ESM 比原始 ESM 慢。我需要剥离更多层,才能让独立的实现工作。
    5. 目前它还处于 alpha 阶段,所以我们要看开一点,但我仍然希望,销售手腕少一些,好好干开发。

    2022-10-27 ~ 2022-10-28

    这段时间,@youyuxi 觉得 Turbopack 的数据有问题,因为 Vercel benchmark 的设计有问题,于是他尝试调整测试方式,给出了一些数据。不过后来他删除了这些推文,因为数字不太对。

    2022-10-27

    Vite 核心成员之一,@antfu7 在知乎上表达了自己的观点:

    他认为 Vite 更吸引人的是其插件系统,和构建在上面的生态系统。这样才能将改进快速带给其他领域。Turbopack 方面,静观其变吧。

    https://zhihu.com/question/562349205/answer/2733040669
    如何评价Vercel开源的使用Rust实现的Turbopack? – Anthony Fu的回答 – 知乎

    2022-10-29

    经过几天实验,Vite 作者 @youyuxi 发表了新的推文,新推文介绍了他进一步比较 Turbopack 和 Vite 之间性能之后,得到的新结论。

    I updated the vite vs next/turbo benchmark by using swc to transform React refresh for vite. To avoid confusion, I have deleted the previous two tweets with outdated numbers.

    Latest numbers:

    • root: vite 338.2ms / next 334.6ms
    • leaf: vite 141.8ms / next 84.4ms

    我通过搭配 Vite+SWC,重新评测 React 转换之后,更新 Vite vs Next/Turbopack 的基准测试。为避免混淆,我删除了前两条带有过时数字的推文。

    最新的对比数字:

    • 根:Vite 338.2ms / Next 334.6ms
    • 叶:Vite 141.8ms / Next 84.4ms

    The swc transform helps a lot in the root component case because the file is big, and previously cost 300ms in babel transforms alone. This makes vite almost exactly the same speed with turbopack.

    SWC 转换在根组件时候提供了很大帮助。因为文件很大,以前仅在 Babel 中转换就要花费 300 毫秒。替换之后,Vite 的速度几乎与 Turbopack 完全相同。

    Interestingly, in leaf scenarios turbopack is still 69% faster – this entails that Vite HMR actually caught up with turbopack as the file gets bigger, potentially scaling better in larger apps.

    有趣的是,在叶子场景中,Turbopack 的速度仍然更快,提升约有 69%。——这意味着随着文件变大,Vite HMR 实际上赶上了 Turbopack,在较大的应用程序中可能会有更好的扩展性。

    测试用仓库:https://github.com/yyx990803/vite-vs-next-turbo-hmr

    Because we are now testing the HMR of the same framework with the same set of transforms, the result is more relevant for other frameworks compared to previously posted numbers.

    现在,因为我们使用相同的转换集测试同一框架的 HMR,与之前发布的数字相比,结果的相关度更高。

    Just to add a bit of context – in this benchmark, even before using swc for React transforms, turbo is only 2x compared to vite (compared to marketed 10x). So the difference wasn’t that drastic to begin with.

    顺便讲点相关前提:这套基准测试,甚至在替换使用 SWC 转换 React 之前,与 Vite 相比,Turbo 也会仅 2 倍(他们市场宣称要快 10 倍)。所以其实一直以来,差异都没有那么大。

    2022-10-31 及之后

    本文主体整理于 10 月 31 日,之后 @youyuxi 还在孜孜不倦的跑 benchmark,与各路支持、反对、吃瓜者或讨论或争论。但是他的观点变化不大,所以我也就偷懒不继续翻译了,相信大家看完都能做出自己的判断。

    N 神有一些观点,摘录于此:

    vite 为了提升速度,利用了浏览器的特性,在 dev 阶段不打包而用 esbuild 预编译 esm 小包投送给浏览器, 在 build时候用 rollup 再打包。这样 dev 就会非常快(因为无需打包),但写插件就很分裂,要考虑 dev 和 build 两种情况。并且理论上如果依赖小包过多会肯定会遇到浏览器并发瓶颈减慢速度。

    按 Turbopack 的说法,他 dev 和 build 同样走打包流程,还能比 vite 快,那么铁定是更好的。只是太早期了,现在还没有开放插件生态。而且自命 webpack 继任者没毛病,看 github 上就是 webpack 作者主力搞的。

    但另一方面如果未来 non-bundler 成为主流,前端不再需要打包。turbopack就没用了。vite 抛弃 rollup,build 也走 dev 流程就更美了。

    https://twitter.com/nshen121/status/1587333382362763264

    SWC 作者和 Webpack 作者

    这二位目前都在 Vercel 工作,可能受公司公关限制,都只转发了官推,并没有发表更多意见。


    总结

    Turbopack 可能不如官方所说的那么好。它的确带来了 HMR 的提升,但代价是不健全的插件机制和生态环境,以及难以被前端团队掌握的 Rust 平台。

    未来一段时间,我们还要继续坚持在 Vite 平台上。

  • 复盘 mywordle.org

    复盘 mywordle.org

    去年,有位开发者设计了一个填字游戏 wordle,取得了巨大的成功,最后被纽约时报斥资百万收购。就像众多成功产品一样,wordle 也有很多追随者和模仿者,其中就包括我们做的 mywordle.org

    刚上线时,因为优化得当,排名不错;如今,随着 wordle 游戏的关注度消退,这个产品已经趋于平静,访问量跌入谷底。于是写篇文章总结下技术、产品、运营方面的经验得失。

    技术向

    技术栈

    之前发过笔记:使用 Vite+Vue3+TypeScript+Tailwind CSS 开发 Wordle

    • Vue3 + Vue-router + Vuex
    • Vite
    • TypeScript
    • TailwindCSS
    • 骨架屏
    • nginx
    • i18n (编辑本地 json)
    • PWA

    纯静态页面,通过构建脚本一次性发布,后面就不需要服务器运算,只需要 CDN,运维成本很低,容易扩展。

    前端通过适当的分包实现按需加载,加快打开速度,提升用户体验。实际效果不错,搜索排名和留存都相当好。Lighthouse 一度基本满分。

    挑战0:全新技术栈

    项目启动时,Vite、Vue3、TypeScript 的内容不算很多,技术生态也没有完全适配,花了不少时间去学习。不过好在当时有时间,慢慢也捋顺了,虽然有一些问题到今天也没能妥善解决,但并没有影响整体进度。

    挑战1:多语言多模式共存

    当时存在两个模式:

    • hourly,每小时一个词
    • unlimited,随便玩

    以及十几种语言。因为我们是静态网站,想实现 /:lang/:type 和 /:type/:lang 共存,就要同时打包这么多组目录组合。如果将来又增加其它类型,就还要成倍增加。但是几个目录里的内容又是完全一样的,很浪费。

    现在想想,应该通过 nginx 来解决这个问题,不要放在前端构建脚本端。

    挑战2:WebRTC

    我们准备尝试用 WebRTC 实现多人对战,如果能成的话,将来还有很多应用场景。可惜 WebRTC 比我想象中复杂很多,不是抽点时间看看文档就能搞定的。当时我已经开始在 code.fun 的全职工作,时间不如启动时充足。

    于是此功能最终停留在 demo,未能整合进产品,更别提上线。

    未解决问题

    • import 类型的时候必须 import type { xxx } from '@/some/types',经常出错
    • TailwindCSS 添加新样式时无法即时生效,需要等下一次更新,或者手动刷新页面
    • ESLint 有很多误报,主要是 <script setup> 导致的未使用变量问题

    产品/运营向

    原始版 wordle 一天只能猜一次,一个词。很多玩家感觉不过瘾,所以搜索 wordle unlimited 就很多,我们也是那会儿做好,然后优化得当,排名很靠前,Google 前4(最好前3),吃了不少流量。

    但是很快,wordle unlimited 的搜索量就下降了,到现在跌了90%,只有一成。

    这是流量统计,外国人也是上班摸鱼,周末不玩页游 [Facepalm]

    这部分流量流去了 quodle 关键词,还是这个猜词游戏,但是一次猜 4 个词,更考验技巧和统筹能力。

    有趣的是,quodle 主流分两个模式:daily 和 practice。daily 还是一天一次,用的是高频词,比较好猜;practice 相当于我们做的 unlimited,不限次数,但是,用的是全部词库,几乎猜不到。

    本来有两个方向,quodle 和 pvp,我说服朋友搞 pvp:我说 quodle 这种玩法太硬核了,没人爱玩;pvp 用 WebRTC 搞联机,成本低效果好,用户粘性大。结果还是我的锅,以前没搞过 WebRTC,搞了两周没搞出来,工作一忙就扔掉了……

    总结,wordle 是个比较轻量的游戏,玩法简单,可玩性有限。daily 模式可能更合适;unlimited 有些饮鸩止渴,快速消耗掉了玩家的热情。quodle 的开发者注意到这一点,一方面提供新的玩法刺激用户,另一方面利用全量词库难以游戏的特点尽量将玩家留在每天一次的游戏里。

    总结

    从这个项目中,我学到很多技术之外的知识,产品和运营方面都刷新了我的知识边界、扩展了我的视野,很有意思。

    比如,mywordle.org 是纯静态网站,运维只需要 CDN,加上优化得当,费用很低。凭借早发优势和一些 SEO,流量和广告收入都不错。即使以我的工资标准和开发习惯(动不动重构、选择新技术栈,等)来支付开发费用,也能达到不错的收益结果(目前折算 1/3 吧)。后期收入虽低,但很稳定,也不需要继续投入研发成本,而且还能作为高质量搜索导入来源。以后我应该多做几个类似的网站。

    项目启动时,我刚被金山开除,于是可以投入大量的时间去学习使用没接触过的技术栈;后面开始继续全职工作了,就只有一点时间可以支配,以至于 WebRTC 都没能用上。不知道将来还有没有类似的机会——我的心情很矛盾,既希望有,又希望没有,哈哈。

  • 使用 Vite 建立灵活的外部仓库

    使用 Vite 建立灵活的外部仓库

    0. Vite 与 ESM

    与 Webpack 不同,Vite 以 ESM 为其唯一的模块管理规范。首先,在开发环境,它会把每个文件编译成独立的 ESM 模块,实现非常快速的热加载。其次,编译打包时,它默认的目标环境是 ES2019,支持 ESM,所以模块打包后,也会使用 ESM 加载。

    这给我们带来一个好处:如果用 Vite 开发项目,并且对其进行分包,构建之后放到线上(比如发布到 NPM);接下来我们就可以在其它项目中,使用 ESM 方式加载这个项目的代码。

    举个简单的例子。lodash 有一个同步发布的 lodash-es 包,功能完全一致,只是使用 ESM 构建,我们可以直接在代码中 import forEach from 'https://unpkg.com/lodash-es@4.17.21/forEach.js' 引用。一方面可以节省我们自己的带宽;另一方面如果用户在其它应用里使用过同一个库,就可以提高速度。

    1. Vite 分包

    Vite 把构建过程委托给 Rollup,所以构建时分包需要传参给 rollupOptions

    在某个项目中,我需要整合一批 codepen 上的绘图效果,这些效果都放在 /src/effects/ 目录下,所以我就要检查这个目录,并且生成对应的分包配置。

    export default defineConfig(async () => {
      // 从 v10.10 开始,node.js 的 `fs.readdir()` 函数支持 `withFileTypes` 参数,使用这个参数可以直接返回 `fs.Dirent` 对象,类似使用 `fs.stat()` 得到的 `fs.Stats` 对象。方便我们判断对象类型
      const files = await readdir(effectsDirectory, {withFileTypes: true});
      // 把目录下的内容分为两类,一个是基础类库,一个是不同特效
      const [effects, baseFiles] = files.reduce(([effects, base], file) => {
        const {name} = file;
        if (file.isDirectory()) {
          effects.push(name);
        } else if (file.isFile()) {
          base.push(name);
        }
        return [effects, base];
      }, [[], []]);
    
      return {
        build: {
          rollupOptions: {
            manualChunks(id) {
              // effects/some-effect 下的文件按目录分别打包
              const effect = effects.find(effect => id.includes(`/${effect}/`));
              if (effect) {
                return effect;
              }
              // 效果基类打包成一个文件,因为效果只需要基类,所以从主体剥离
              const baseFile = baseFiles.find(base => id.endsWith(base) && !/p5/i.test(id));
              if (baseFile) {
                return 'BaseEffect';
              }
              // p5 是个很大的效果库,官方不提供 esm 包,只能单独打一个
              if (/p5/i.test(id)) {
                return 'p5';
              }
              // 其它依赖正常打包,只在本项目中使用,不会被引用
              return id.includes('node_modules') ? 'vendor' : 'chuck';
            },
          },
          // 这个第3节会解释
          target: 'es2020',
        },
      },
    }
    

    2. 去掉文件名中的 hash

    Vite 很贴心的帮我们给生成的文件都加上了 hash。在独立项目中,给文件名加 hash 可以有效避免缓存问题;但是作为外部仓库的话,无法确定的 hash 会增加业务项目的开发难度,所以我希望构建时输出到特定版本号的目录里,然后去掉文件名中的 hash。

    这个操作同样需要修改 rollupOptions。rollup 有三个不同的选项分别处理不同的命名,这里我们可以忽略入口文件(entryFileNames),只改剩下两个。

    export default defineConfig(() => {
      return {
        build: {
          rollupOptions: {
            output: {
              // 资源文件,包括 css
              assetFileNames: 'assets/[name].[ext]',
              // 分包文件
              chunkFileNames: '[name].js',
            },
          },
        },
      };
    });

    3. 动态加载 CSS

    使用 Vite 开发时,我们同样可以在代码里 import 样式等非 JS 素材。构建时,Vite 会把它们处理后放在合适的地方。

    可惜的是,Vite 并不会帮我们自动加载分包后的素材。需要我们手动处理。这时就要利用 import.meta.url,它会返回当前模块的 URL,配合前面的的文件名策略,我们就可以完成动态加载,而不需要业务项目的开发者手动处理。

    但是 Vite 默认的版本基线是 ES2019,并不支持 import.meta.url,所以我们需要把 build.target 设置成 ES2020 或以上。

    let isCssLoaded = false;
    
    // 只有未加载且处于生产环境才加载 css。
    if (!isCssLoaded && __IS_PROD__) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      // 这一步非常重要,因为 vite/rollup 有 bug,会把 `import.meta.url` 翻译成 `self.location`,导致出错
      const baseUrl = import.meta.url;
      link.href = new URL('./assets/particle-orb.css', baseUrl).toString();
      document.head.appendChild(link);
      link.onload = () => {
        isCssLoaded = true;
      }
    }

    4. 总结

    新的技术选型总能给我们带来新的可能,ESM 之后,我们在项目之间复用代码也有了新的选择,赶紧用起来吧。

    如果你在使用 Vite 或者 ESM 时遇到什么问题,欢迎提问。如果有什么经验,也欢迎分享。

  • Vite 项目里启动 PWA

    Vite 项目里启动 PWA

    很简单,使用 vite-plugin-pwa 插件,Antfu 出品,品质保证。零配置,简单易用。

    0. 安装插件

    pnpm i vite-plugin-pwa -D

    1. 启动插件

    修改 vite.config.ts

    import { VitePWA} from 'vite-plugin-pwa';
    import { definePlugin } from 'vite';
    import vue from '@vitejs/plugin-vue';
    
    export default definePlugin(({ command }) => {
      const isDev = command === 'serve';
      return {
        plugins: [
          vue(),
          new VitePWA({
            disable: isDev, // 开发环境不启动 pwa
            includeAssets: [
              // 非直接加载,但是需要预缓存的内容
            ],
          }),
        ],
      };
    })

    2. 可脱机提示及可更新提示

    原则上来说,Vite、Vite 插件都是开发脚手架,不限定框架。不过我用的最多的还是 Vue。这里以 Vue3 为例示范一下如何使用插件快速实现 PWA 组件:

    1. pwa 完成缓存后,提示可脱机使用
    2. 线上版本更新后,提示有新版本可用
    3. 更新时,给出视觉反馈
    <script setup lang="ts">
    import { useRegisterSW } from 'virtual:pwa-register/vue'
    import {ref} from "vue";
    const {
      offlineReady,
      needRefresh,
      updateServiceWorker,
    } = useRegisterSW({
      immediate: true,
      onRegistered(r) {
        // 每小时自动检查一次,是否有新版本
        r && setInterval(async() => {
          await r.update()
        }, 60 * 60 * 1000)
      },
    });
    const isRefreshing = ref<boolean>(false);
    function doRefresh() {
      isRefreshing.value = true;
      updateServiceWorker();
    }
    const close = () => {
      offlineReady.value = false
      needRefresh.value = false
    }
    </script>
    
    <template lang="pug">
    .pwa-toast.fixed.right-4.bottom-4.p-3.border.border-gray-200.rounded.bg-white.z-index-10.shadow-md(
      v-if="offlineReady || needRefresh"
      role="alert"
    )
      p.message.mb-2
        span(v-if="offlineReady") App ready to work offline
        span(v-else-if="isRefreshing") Refreshing...
        span(v-else) New content available, click on reload button to update.
      button.border.border-gray-200.rounded.py-1.px-2(
        v-if="needRefresh",
        type="button",
        :disabled="isRefreshing",
        @click="doRefresh",
      )
        .spinner(v-if="isRefreshing")
        template(v-else) Reload
      button.border.border-gray-200.rounded.py-1.px-2.ml-2(type="button" @click="close") Close
    </template>

    3. 一些坑

    1. PWA 会拦截所有请求,以便缓存到本地。所以,打开网站,注册完 service worker,再请求其它文件,比如 ads.txt,也可能会看不到。这并不影响广告,因为广告商服务器不会受 PWA 影响;但是广告商运营人员可能只会操作浏览器,她们可能会认为你的广告文件没准备好。此时,请告诉她们使用匿名窗口。
    2. PWA 会自动预缓存 dist 目录内的东西,所以一定要注意 build.emptyOutDir,不要让目录过分膨胀,影响新用户体验。
    3. 有新版本后,上面的组件会提示用户刷新,但是刷新过程可能很慢(清理缓存,下载新内容等),所以点完之后可能没有反应。所以最好加上 spinner。

    4. 总结&扩展阅读

    整体来说,这个插件很好用,没什么特别需求的话,几乎可以零配置。

    建议感兴趣的同学好好阅读下 官方文档,尤其是 examples 目录里的内容,会有很大帮助。

  • 使用 Vite JavaScript API 构建多语言静态网站

    使用 Vite JavaScript API 构建多语言静态网站

    静态化真是爽,不仅操作简单,还有很多羊毛可以薅,比如 Vercel、Digital Ocean、Cloudflare,除去开发成本,运维支持成本几乎为零。我用 Vite 搭建了一个静态网站,然后需要多语言,最简单的做法就是多编译几次,输出不同语言到不同目录。我实操了一下,大体上还算顺利,略有小坑,分享一下。

    Vite JavaScript API

    Vite 除了命令行工具,还提供了插件 API、HMR(模块热加载)API、JS API,方便开发者从各种角度去丰富 Vite 的生态和使用场景。

    这里我们要使用的就是 Vite JavaScript API。从 官网 来看,build,即我们要用到的构建功能,也是开放的,很好。

    这个 API 支持一个参数,即 vite config,然后就能完成构建。不过我实测这里并不能使用 defineConfig() 方法,不知道是否与我的使用方式有关,相关的范例代码也不多、文档也不详细,就先这么着吧。

    Demo code

    经过调试后,完成的代码如下:

    for (const language of languages) {
      // 一些 SEO 相关属性
      const {
        title = '',
        description = '',
        content = '',
      } = data[language];
      if (!language) {
        continue;
      }
      const config = {
        // 注意,`configFile` 属性非常关键,如果不设为 false,vite 还会加载默认配置文件 vite.config.js
        configFile: false,
        root,
        base: language === 'en' ? '/' : `/${language}/`,
        build: {
          // 这个属性也比较关键,不设置的话,vite 会自动清理掉其它语言
          empty: false,
          outDir: resolve(root, language === 'en' ? './dist' : `./dist/${language}`)
        },
        define: {
          // 放一些根据语言自定义的变量
        },
        plugins: [pugPlugin.default({}, {
          title,
          description,
          lang: language,
          version,
          content: marked.parse(content),
        })],
      };
      await build(config);
    }

    注意事项写在上面的代码里了,大家留意一下。

    TailwindCSS 及其它工具

    TailwindCSS 会在当前工作目录(即 cwd)里查找配置文件。如果你像我一样,把构建文件放在 build 目录里,执行的时候可能就会报错,说 TailwindCSS 找不到配置文件。

    此时,只能通过 NPM script 比如 npm run build 执行 ./build/build.js

    总结

    慢慢适应 Vite 之后,我开始逐步把个人小项目向 Vite 迁移。新技术的体验提升很大,不过文档和范例的确有所欠缺。

    下一步要尝试 vitest。

  • Vite 里使用动态加载

    Vite 里使用动态加载

    有时候,我们希望根据用户当前的使用状态决定加载哪些模块。比如一个网页 IDE,用户在写 JS,我们就加载 JS 模块;用户在写 PHP 我们就加载 PHP 模块。这个功能有点类似路由懒加载,但又不完全相同。

    以前在 wepback 里,我们可以通过动态 import 加注释的方式来做:

    const _ = await import(/* webpackChunkName: "lodash" */ 'lodash');
    
    // 通过组合不同注释,还可以实现不同的分包和加载策略
    // Single target
    import(
      /* webpackChunkName: "my-chunk-name" */
      /* webpackMode: "lazy" */
      /* webpackExports: ["default", "named"] */
      'module'
    );
    
    // Multiple possible targets
    import(
      /* webpackInclude: /\.json$/ */
      /* webpackExclude: /\.noimport\.json$/ */
      /* webpackChunkName: "my-chunk-name" */
      /* webpackMode: "lazy" */
      /* webpackPrefetch: true */
      /* webpackPreload: true */
      `./locale/${language}`
    );

    上面的内容可以在 Webpack 官方文档 找到。

    Vite 里提供了类似的功能,不过使用方式不太一样。现在的 Vite 2 已经抛弃了以前的插件式实现,即 vite-plugin-dynamic-import 和 @rollup/plugin-dynamic-import-vars 都不会用到。

    首先,我们要使用 import.meta.glob('./*.js`) 声明哪些文件可能要用到;接下来,我们就可以根据实际需求加载具体文件。大概方式如下:

    <script lang="ts" setup>
    // <script setup> 的代码,相当于 `created`,所以可以使用动态加载
    
    // 未来要使用的变量
    let shareTexts:ShareTexts;
    
    // 声明符合 `/src/data/share*.ts` 的文件都可能要用到
    const modules = import.meta.glob('../data/share*.ts');
    
    // 如果 store 里有 `langName`,就加载 i18n 版,否则加载普通版
    modules[`../data/${store.state.langName ? 'share-i18n' : 'share'}.ts`]()
      .then(data => {
        // 因为用到 esm,所以要加 `default`
        shareTexts = data.default;
      });
    </script>

    接下来,还可以配合 vite.config.js,将相关装入特定分包:

    export default defineConfig(({command, mode}) => {
      return {
        plugins: [
          vue(),
        ],
        build: {
          rollupOptions: {
            output: {
              manualChunks(id) {
                let result;
                if (result = /(shares\/)?share\d\.txt\?raw$/.exec(id)) {
                  return result[1] ? 'share-i18n' : 'share';
                } else if (id.includes('node_modules')) {
                  return 'vendor';
                }
              },
            },
          },
        },
      };
    });

  • 笔记:使用 Vite+Vue3+TypeScript+Tailwind CSS 开发 Wordle

    笔记:使用 Vite+Vue3+TypeScript+Tailwind CSS 开发 Wordle

    最近有个小游戏很火,叫 Wordle,是个填字游戏,推上随处可见相关分享。各种衍生版也层出不穷。有位朋友让我帮他复刻,反正过年,闲着也是闲着,我就尝试用标题里的新技术栈帮他写了一个。现在已经上线了,对填字游戏感兴趣的同学可以试试:

    My Wordle(特色:1. 支持自定义单词;2. 5~9字母;3. 无限模式,可以一直玩。)

    这篇文章简单记录一些开发过程和经验。

    0. 创建 vite 项目

    npm init vite@latest

    接下来按照提示选择喜欢的配置即可。

    小配置下 vite.config.js,添加 @ 别名:

    // 为了方便后面开发,改为函数式,这样获取状态更容易
    export default defineConfig(({command, mode}) => {
      const isDev = command === 'serve'; // 这里先留个例子,后面骨架屏有用
      return {
        server: {
          host: true, // 允许外部访问,方便手机调试
        },
        plugins: [
          vue(),
        ],
        resolve: {
          alias: {
            '@': resolve(__dirname, './src'),
          },
        },
      };
    });

    注意,上面的配置文件虽然是 ESM,但实际上仍然在 CommonJS 环境里执行,所以有 __dirname

    然后安装依赖:pnpm i

    最后 npm run dev 即可启动开发服务器进行开发。Vite 真是快,空项目 100ms 就启动了。

    1. 安装+配置 Tailwind CSS

    pnpm i -D tailwindcss postcss autoprefixer
    npx tailwindcss init -p

    配置 tailwind.config.js

    module.exports = {
      content: [
        "./index.html",
        // 注意:因为我喜欢用 pug 开发模版,所以一定要在这里添加 `pug`
        "./src/**/*.{vue,js,ts,jsx,tsx,pug}",
      ],
      theme: {
        extend: {},
      },
      plugins: [],
      // 这部分 css 需要通过比较复杂的计算得出,所以要用 `safelist` 保证它们出现在最终 css 里
      safelist: [
        {
          pattern: /w-\d+\/\d+/,
        },
        {
          pattern: /grid-cols-[5-9]/,
        },
      ],
    }

    接下来创建 CSS 入口文件:./src/style/index.css

    @tailwind base;
    @tailwind components;
    @tailwind utilities;

    最后在入口文件里引用它即可:

    import { createApp } from 'vue'
    import App from './App.vue'
    import './index.css'
    
    createApp(App).mount('#app')

    2. 配置 TypeScript

    默认的 TypeScript 就挺好使,这里简单配一下 @ 别名即可:

    {
      "compilerOptions": {
        "paths": {
          "@/*": [
            "./src/*"
          ]
        }
      },
    }

    为开发方便,还需要装一些类型定义:

    pnpm i @types/gtag.js @types/node -D

    3. 配置 eslint

    安装所需依赖:

    pnpm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser @vue/eslint-config-typescript eslint eslint-plugin-babel eslint-plugin-vue

    配置 .eslintrc.js

    module.exports = {
      "env": {
        "browser": true,
        "es2021": true,
        "node": true
      },
      parser: "vue-eslint-parser",
      parserOptions: {
        parser: "@typescript-eslint/parser",
        sourceType: 'module',
        ecmaVersion: 'latest',
      },
      extends: [
        'eslint:recommended',
        'plugin:vue/vue3-recommended',
        '@vue/typescript/recommended',
      ],
      plugins: [
        'babel',
        'vue',
      ],
      globals: {
        // vue3 <script setup> 工具函数
        withDefaults: true,
        defineProps: true,
        defineEmits: true,
        defineExpose: true,
    
        // 我自己定义的常用类型
        Nullable: true,
        Timeout: true,
    
        $root: true,
      },
      "rules": {
        // vue3 <script setup> 暂时必须关掉这几项
        '@typescript-eslint/no-unused-vars': 0,
        '@typescript-eslint/ban-ts-comment': 0,
        '@typescript-eslint/no-non-null-assertion': 0,
      },
    }

    4. 拆包与懒加载

    Wordle 游戏需要用到词库,5个字母的词库 200+KB,6789的词库都是 300KB,全部放到一起加载太浪费,所以计划按需加载。另外,词库几乎是不变的,但业务逻辑是多变的,如果不拆包的话,每次业务逻辑变更后,用户都要重复下载好几百 KB 的词库,所以要做拆包与懒加载。

    拆包方面,vite build 是基于 rollup 实现的,所以要按照 rollup 的要求,用 rollupOptions 来配置:

    export default defineConfig(({command, mode}) => {
      return {
        ....
        build: {
          rollupOptions: {
            output: {
              manualChunks(id) {
                // fN 是高频词,出题时只用高频词,校验单词时才用全量词库。高频词独立打包。
                if (/[sf]\d\.txt\?raw$/.test(id)) {
                  return 'dict';
                // 分享文案,为方便朋友修改,独立打包
                } else if (/share\d\.txt\?raw$/.test(id)) {
                  return 'share';
                // 其它依赖,打包成 vendor。不知道为什么,必须有这一配置,前面两项才能生效
                } else if (id.includes('node_modules')) {
                  return 'vendor';
                }
              },
            },
          },
        },
      };
    });

    默认只加载高频词,全量词库会在页面准备就绪后,通过 fetch API 异步加载。

    5. 添加骨架屏

    网页里有个静态 footer,因为 CSS 文件加载需要一些时间,所以会先渲染出来,然后再挪到正确的位置,造成抖动。这里有两个选择:1. 隐藏 footer,或挪到 vue 里;2. 添加加载中样式。我选择在(2)上再发展一点,做骨架屏。

    我的思路是:

    1. <div id="app"></div> 里填充只有标题的 header 和填字格
    2. 拆分 tailwind.css,分成
      1. 只包含 reset 和 index.html 所需的 inline css
      2. 包含全部业务样式的外部 css 文件
    3. 编写脚本,build 时往 index.html 里塞入填字格,并把 inline css 写入网页 <head>

    实现方面,首先编辑 vite.config.js

    export default defineConfig(({command, mode}) => {
      // 根据开发、构建环境,使用不同的 tailwind 入口文件
      const tailwind = command === 'serve' ? 'tailwind-sketch.css' : 'tailwind.css';
      return {
        resolve: {
          alias: {
            '@tailwindcss': resolve(__dirname, './src/style/' + tailwind),
          },
        },
      };
    });

    我从 tailwind.css 里移除了 @tailwind base,即 reset 部分。这部分会被 inline 到 index.html 里。然后修改 tailwind.config.js,加入根据环境判断是否需要分析 index.html 的逻辑:

    const isDev = process.env.NODE_ENV ==='development';
    module.exports = {
      content: [
        './src/App.{pug,vue,ts,tsx}',
        './src/**/*.{vue,js,jsx,ts,tsx,pug}',
      ].concat(isDev ? './index.html' : []),
      theme: {
        extend: {},
      },
      plugins: [],
    }
    

    接着修改 build 脚本,增加编译 inline css 和注入功能:

    {
      "name": "aw-woodle-game",
      "version": "0.4.0",
      "scripts": {
        "build": "vue-tsc --noEmit && vite build && npm run build:sketch && npm run build:sketch2 && build/build.js",
        "build:sketch": "tailwind -i src/style/tailwind-sketch.css -o tmp/sketch.css --content index.html --config build/tailwind.config.sketch.js --minify",
        "build:sketch2": "stylus --include-css --compress < src/style/sketch.styl > tmp/sketch2.css",
      },
    }

    6. 总结

    最终我使用这套工具链完成了整个产品的开发。不过仍然有一些问题未解:

    1. Vite 开发环境与构建环境其实是两套工具链,所以能过 dev,未必能过 build,尤其是 TypeScript,会有很多差异。不过只要耐心调试一番,也不难搞。
    2. 不知道是否跟我用 pug 有关,Tailwind 总是慢一拍。即我改了模版,html 变了,但对应的样式并没有出来;过一会儿,就出来了。于是我要么继续改,期待后面它会加上来;要么只有手动刷新。
    3. build 时,rollup 会把 css 放在 <head> 里面,导致浏览器认为这段 CSS 是关键内容,加载完之后才进行第一次渲染(FCP),使得我的骨架屏失效,所以要手动把它挪到 <html> 的最后。(当然在 GA 等代码的前面)

    最终的 Lighthouse 得分比较喜人,贴一下:

    (更多…)