我的技术和生活

  • 理解 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() 输出的是对象的指针,而非快照。所以里面的值只跟你展开时有关,跟运行时关系不大。

  • 代码分享:翻转小动画

    代码分享:翻转小动画

    朋友对我图省事用的 animate.css flipInX 效果不满意,软磨硬泡非要我改成 wordle 原版那种整个翻过来的。于是我就想办法实现了一把,比想象的稍微复杂一些,难点在于我不知道 transform-style: preserve-3d 这个属性。没有这个属性,正反两个图层就被压在一个平面里,怎么翻转都是上面那个图层显示出来。

    我用来调试的代码实现请点 这里,为了方便调试,我这次没用 codepen,用的 Vue SFC Playground,效果挺好,尤其是适合不熟悉属性时一边试一边写。不知道 codepen 是否支持这种玩法。

    实现的代码与之类似:

    <template lang="pug">
    .game-item
      .face G
      .face G
    </template>
    
    <style lang="stylus">
    .game-item
      aspect-ratio 1
      position relative
      // 这个样式很重要,没有它就没有背面一说
      transform-style: preserve-3d;
      user-select none
    
    .face
      position absolute
      top 0
      left 0
      width 100%
      height 100%
      display flex
      justify-content center
      align-items center
    
      &:first-child
        // 把第一层稍微提高一点点,不能太多,不然旋转效果不好看
        transform translateZ(0.1px)
    
      &:last-child
        // 背面提前翻转 180 度,这样转过来才是对的
        transform rotateX(180deg)
    
    @keyframes flip
      from
        transform: perspective(400px) rotate3d(1, 0, 0, 0)
    
      to
        transform: perspective(400px) rotate3d(1, 0, 0, 180deg)
    
    .flip
      animation-name: flip
    </style>

    这种效果平时还是找时间写上一两次,CSS 属性是知识性内容,没办法凭经验推出来,平时得注意积累。

  • 浅尝 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。

  • 浅尝 Monorepo

    浅尝 Monorepo

    最初听说 Monorepo,是群里的同学问我是否了解 lerna,我还真没听说过,于是去学习了一下。简单来说,就是把好多个软件放在一个大型仓库里一起管理。

    0. Monorepo 简介

    为方便灵活使用,我们一般会把软件包拆散,每个小包只负责某个特定功能,通过组合完成复杂功能。这个时候我们有两个选择:

    1. 每个包放在独立仓库,独立管理、独立发布,然后通过 NPM 等包管理工具安装互为依赖。
    2. 所有包放在一起,统一管理,直接内部引用。即 Monorepo。

    我之前在 OpenResty 一直使用前者,而很多开源项目都选择后者,比如 vue-cli。它的仓库里包含了 vue-cli 主体和大量插件,还有测试套件等。

    这样做有几个显而易见的好处:

    1. 这些软件包通常高度耦合,彼此之间功能关联紧密。A 软件 X 版本依赖 B 软件的 Y 版本,B 软件的 Y 版本又依赖 C 软件的 Z 版本。统一管理切换环境更轻松。
    2. 统一管理依赖,既能减少磁盘占用,也可以保证开发环境统一。
    3. 没有编译、没有黑盒,所有源代码对项目成员公开,遇到问题分析调试都容易很多。

    我觉得在 Google 这样的技术流公司这么搞没问题;在崇尚奉献和高参与质量的开源项目里这么搞也很好,但是对很多技术水平一般,自我要求很低的软件公司这么搞就是乱来了。

    1. 问题

    以我曾经短暂工作过的XX办公(为保护当事人隐私我隐去了“金山”二字)为例,使用 monorepo 带来了几个未曾预料的问题:

    1.1 版本管理混乱

    所有产品代码都放在一个仓库里,数个不同的团队同时进行开发,每天会产生大量的 commit。负责合并分支的人既不懂业务也不懂版本管理,基本就是点一下合并按钮,于是大量未经 rebase squash 的 commit 被用 merged 方式合并入主干。整个提交历史混乱不堪毫无价值。

    同时由于采取 merge 方式,仓库里存在大量分支,git 无法判断版本之间的先后关系,也无法自动解决冲突,于是几乎每两三天就遇到冲突无法合并,需要开发人员解决冲突。

    1.2 代码质量参差不齐,且互相影响

    monorepo 好的一方面是代码对大家公开,所有人都可以学习其他人的写法,互相帮助解决问题;坏的一方面是坏的代码影响的也是整个项目仓库。

    有些同学用 VS Code,这本身不是问题,但 ta 们不研究配置、不研究插件,于是代码中很多低级错误——甚至因为低级错误太多,反而不会报错。比如有个事件侦听函数,参数传进来是 e,但是函数体里用的是 event。结果竟然没有报错,也能通过测试(人肉)。我找到开发人员让他改,他发现竟然有一个全局 event 变量,不知道是谁在哪里带进去的……

    【2022-09-20 更新】这里还真触及到我的知识盲区。这是早期 JS 的兼容性设计,即事件触发时 window 上也会有个全局变量 event,主要方便大家乱来。没想到救了 XX 办公。

    1.3 技术栈升级困难

    架构升级我就不谈了,这东西见仁见智,也不是能经常干的事情。这里说的主要是工具链。

    如,eslint、错字检查、安全性检查。好比说,我发现部分代码存在风格问题,希望引入 eslint 检查,并且把 eslint 加入到集成环境里。按说这个想法并不复杂,这些工具都不影响整体架构,只需要外挂到 pre-commit 之类的钩子上即可。但此时必须征得整个开发团队几百号人的同意,那就不是一句话能解决的。

    于是打开 WebStorm,海量的臭气(smell)扑面而来,难以直视。

    1.4 代码互相耦合

    原本 monorepo 只是方便大家阅读代码、维护代码。但是难免会有人滥用,直接引用、导入别人的代码,或者把自己的代码写入别人的目录里。

    比如我想优化构建过程,希望能拆成 ES6 和 ES5 两个版本(因为厂里明确要求支持 ES5 的只有文档、表格、幻灯片)。然后我发现,虽然名义上大家各自维护自己的目录,但实际上跨目录引用比比皆是。简单按照目录区分基本不可能。

    2. 总结

    当然,我数落这些问题,不是想说 monorepo 不好。分散项目独立管理也会有别的问题,比如技术栈不统一、集成困难、人员变更后难以交接继承等。每种技术都会有适合的场景,各位技术决策者也应该从自己的团队实际出发,选择最适合自己的技术选型。

    现在技术领域各种声音分外吵杂,很多公司会把技术方案作为一种宣传方式,名为推广技术实践,实则宣传自己的团队和品牌。如果不加区分盲目追随,就很容易掉进坑里。

    (更多…)
  • 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 形态,所以基本上可以放心使用。

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

    (更多…)
  • 加入 Code.fun

    其实入职已经两周了,突然发现还没写博客,所以水一篇。

    被金山优化后,我开始找工作,过程还算顺利,并没有因为年龄因素遭遇直接的否决,大家都是头脑清醒的职业人士,不至于被几篇公众号带走脑子。

    但是结果并不理想,毕竟年纪和资历摆在这里,需求找到足够大的坑,比如技术负责人。但是大部分经营顺利愿意招聘的企业,岗位上大多已有合适的人选,招我进去多半是开发组技术经理这样没那么高阶的岗位,待遇就很难谈拢。

    这才是大龄程序员的真正难题:有些能力不够强,与年轻人相比竞争力不足;或者能力够强,但是没有合适的岗位安排。

    Anyway,最后我决定加入光速软件,我们的产品是 Code.fun,一款将设计稿转化成代码的软件,可以对接 Sketch、figma、Photosthop 等设计软件,直接导出 React、Vue、小程序 等代码,帮助前端开发者快速完成任务。

    光速也是技术型创业公司,很能满足我的技术追求,也能够给我足够大的发挥空间,希望我的加入能给双方带来一段美好的合作经历。

    对我厂产品感兴趣的同学可以联系我。我厂也在招人,有志于打造一款效率产品的同学也欢迎。

  • 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';
                }
              },
            },
          },
        },
      };
    });

  • 在 macOS 上安装配置 Flutter SDK

    在 macOS 上安装配置 Flutter SDK

    前天 Flutter 官宣开始支持 Windows App,所以我想新的一年,也学一学 Flutter 开发,填补大前端的空白。这两天抽空把 Flutter SDK 装好了,过程还有点费劲,所以记一下。

    我的系统是 macOS 12.1。

    0. Flutter SDK

    直接在官网下载压缩包:Install | Flutter

    解压后放到一个不太深的文件夹里,我的是 ~/flutter

    添加路径到 .zshrc

    export PATH="`pwd`/flutter/bin:$PATH"

    最后运行 flutter -v 能正常返回就算成功。

    1. Android Studio

    接下来运行 flutter doctor 检查环境,发现不少问题。先安装 Android Studio 解决 Android 开发问题吧。

    下载并安装:Download Android Studio and SDK tools  |  Android Developers

    我对 Android 生态不熟悉,用 Android Studio 的目的就是用 GUI 工具配置环境。所以在里面找到 SDK Manager,安装

    • Android API 32
    • Android SDK Command-line Tools
    • Android Emulator
    • Android SDK Platform-Tools

    1.1 安装 OpenSDK

    Android 平台需要安装 JDK。按说 macOS 系统本身就集成在内,不过我希望用 OpenSDK 替换之。

    # 安装 opensdk
    brew install openjdk
    
    # 链接库
    sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk
    
    # 添加 PATH
    export PATH="/opt/homebrew/opt/openjdk/bin:`pwd`/flutter/bin:`pwd`/Library/Android/sdk/tools/bin:$PATH"
    
    # 验证
    java -version

    1.2 验证

    最后运行 flutter doctor --android-licenses,接受所有 Android 协议,这篇儿也就通过了。

    2. Xcode

    直接使用 App Store 安装 Xcode。装好后启动,安装其余组件。然后执行下面命令,完成安装:

    sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
    sudo xcodebuild -runFirstLaunch

    接下来还要装 CocoaPods,以便将来使用插件。我也不知道插件是干嘛的,不过让装就装吧。

    brew install cocoapods

    3. Chrome

    Flutter 需要 Chrome 作为 web 开发平台,所以我们也要安装。不过它并不严格要求 Chrome,Edge 也可以。我现在主要用 Edge,配置一下就可以:

    export CHROME_EXECUTABLE="/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"

    4. 配置开源镜像源

    Flutter 也要使用类似 NPM 的包管理工具,这些工具都放在 Google 服务器上,所以难免受到牵连。所以要配置一下国内的镜像源,方便使用。

    同样编辑 .zshrc

    export PUB_HOSTED_URL=https://pub.flutter-io.cn
    export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

    还有一些其它源可用,参考:在中国网络环境下使用 Flutter | Flutter 中文文档 | Flutter 中文开发者网站

    (更多…)
  • 笔记:使用 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 得分比较喜人,贴一下:

    (更多…)
  • Chrome Extension MV3 在 background script 里扫描二维码并传出结果

    Chrome Extension MV3 在 background script 里扫描二维码并传出结果

    升级浏览器扩展到 MV3 时,因为 MV3 不再允许使用 background 页面,必须使用 service worker 类型的 background script。这样一来,就没有 window 对象,会遇到很多问题。

    今天分享下如何在 service worker 类型的 background script 里完成扫描二维码并传出结果。比如我帮朋友升级 二维码生成器 (Quick QR) 这个扩展,它有个功能是通过右键菜单扫描页面中的二维码,并返回扫描结果。这个过程只能在 background script 里完成,于是升级时就给我带来了不小的麻烦。

    首先,右键菜单的响应事件只能得到图片 URL。其次,扫描功能需要用到 jsqr,它接受 imageData 作为参数,可以返回扫描结果。所以我们就需要把 URL 转换成 imageData,过程如下:

    // service worker 里可以使用 `fetch()` 请求服务器
    const response = await fetch(src);
    const blob = await response.blob();
    // 使用 `createImageBitmap` 可以将支持的图片格式转换成位图
    const bitmap = await createImageBitmap(blob);
    const {width, height} = bitmap;
    // 在 service worker 里,需要使用 OffscreenCanvas
    const canvas = new OffscreenCanvas(width, height);
    // 注意,这里只能是 `2d`,`bitmaprenderer` 无法使用 `getImageData` 方法
    const context = canvas.getContext('2d');
    context.drawImage(bitmap, 0, 0);
    const imageData = context.getImageData(0, 0, width, height);
    // 接下来识别就好
    const data = jsQR(imageData.data, width, height);
    const [tab] = await getActiveTab();
    // 因为没有 `window`,`alert()`、`prompt()` 都不能用,只能通过在目标页面执行脚本的方式输出结果。注意,`tab.executeScript` 也没有了,必须用下面这个新 API,并且要提前申请权限
    await chrome.scripting.executeScript({
      args: [i18n(SCAN_RESULT), data.data],
      target: {
        tabId: tab.id,
      },
      func: (message, result) => {
        result = prompt(message, result);
        if (result) {
          navigator.clipboard.writeText(result)
            .catch(() => void 0);
        }
      },
    });

    大概过程如上面代码所示,需要注意的东西我都放在注释里了。

    关于 MV3 的其它升级经验,我会更新在 Chrome Extension Manifest V3 升级笔记,欢迎常来看看。

    (更多…)