:)

没记错的话,这是第5次重写blog,没有其他的点子,只好拿博客开刀了呀 差不多花了一周时间,大部分时间用在实践typescript和组织代码逻辑。相比于vue2SFC一把梭,vue3提供更灵活的代码书写方式,我也是偶然从一个视频里了解到vue3的理念:

vue3解读

一些想法

前段日子我忽然想:热衷于写博客网站,但却没有值得写的博客文章,那还有何意义呢?以后学习知识,可以不妨考虑一下——学到何种程度,值得记下来吗。无论是有用亦或是有趣,如果不值得,那是否还有必要去学? 本文就作为我第一篇值得写的文章。

学习笔记

也可以是typescript + vue3 + nuxt3学习笔记,因为之前我没有系统地学习并写typescript。

typescript

javascript本身是无类型检查的,代码量多了,写起来会忘记数据结构,函数参数。很明显ts可以解决这个问题,还有另一个很好的地方,ts只是js的超集,使用它,只会有优点不会有缺点。 我没有学习ts的最佳实践,自己慢慢摸索出自己的风格。例如,本站有三个tab:文章记录文化,它们的数据都有共有点,于是我如下定义:

export type NeedsItem = {
  id: number;
  time: number;
  _show: boolean;
};

export type ArticleItem = NeedsItem & {
  title: string;
  len: number;
  tags: string[];
};

export type RecordItem = NeedsItem & {
  images: { src: string; alt: string, id?: number }[];
};

export type KnowledgeItem = NeedsItem & {
  title: string;
  type: "book" | "film" | "game";
};

export type CommonItem = ArticleItem | RecordItem | KnowledgeItem;

CommonItem便是通用的item对象,表示任何页面item。

Composition API

结合vue3提供的Composition API,我定义如下方法,用来封装列表页的基础逻辑:

export function useListPage<T extends CommonItem> () {
  const githubToken = useGithubToken();
  // 获取当前列表,当前路由,当前页面名称
  const { targetList, activeRoute, activeName } = getTargetList();

  useHead({
    title: activeName
  });
  const resultList = reactive(cloneDeep(targetList).map((item) => {
      return {
        ...item,
        _show: true
      };
    })
  ) as T[];

  // 根据github token状态控制item列表,因为没有token的话就没必要显示加密的item
  watch(githubToken,() => {
    resultList.forEach((item) => {
      item._show = !item.encrypt || !!githubToken.value;
    });
  }, { immediate: true });

  return {
    list: resultList
  };
}

于是,我可以这样使用useListPage,其他页面也是类似:

<script setup lang="ts">
const { list: articlesList } = useListPage<ArticleItem>();
</script>
<template>
  <div class="article-list">
    <ul>
      <li v-for="item in articlesList" v-show="item._show" :key="item.id">
          <nuxt-link :to="'/articles/' + String(item.id)">
            {{ item.title }}
          </nuxt-link>
      </li>
    </ul>
  </div>
</template>

我当时思考这样的代码时,头脑是很兴奋的——vue不再死板,我可以封装任何我想要的逻辑,并把它们组合到.vue文件里。而拥有ts的加持,所有的外部封装都有了类型检查,这是多么好的事啊!

use in anywhere!

初学者可能有一个疑惑:为什么useHead,onMounted,watch可以随意地使用,而不限于在vue组件里呢?我同样有此疑惑,猜测是,vue在调用组件setup时,有一个context存储当前的组件对象,并把它传给setup,setup内部的函数调用均可以读取这个context,类似于在window上的对象。当然这仅是我的猜测,还需要仔细学习源码。

在nuxt3里,同样"继承"了vue3的思想,诸如useHeaddefinePageMetauseFetch,均可以随处使用,只需在setup里调用即可。这给我们提供了很高的自由度,代码逻辑可以四散封装,最终都进入setup。基于此的还有vueuse,提供一系列功能函数,可以理解为lodash。

使用nuxt3的plugin,可以做到在任何界面预先检查token,并影响其他vue组件内部的useGithubToken()

export default defineNuxtPlugin(() => {
  const localToken = localStorage.getItem("GithubTokenKey");
  if (localToken) {
    // 进入界面时,检查token
    checkIsAuthor(localToken)
      .then((res) => {
        if (res) {
            useGithubToken().value = localToken;
            notify({
                title: "Token验证成功!"
            });
        }
      });
  }
});

打包优化

vercel的速度很慢,目前nuxt3不支持纯静态站点,这意味着如果不优化bundle的话,访客将看到很长一段时间的白屏。下图是优化前,网络差的情况下,首屏可能需要数10秒加载:Gzip后依旧近500K 我对rollup知之甚少,但打包文件超过500K时有提示:请使用rollup的manualChunks或者动态引入import()。可能是nuxt&vite的打包机制有问题,manualChunks没有太大作用。我试过把showdown加入manualChunks(上图便是),showdown只在详情页有用到,但是列表页居然也需要引入它,我排查发现列表页完全没有用showdown,很奇怪。于是我决定换import(),然后配合useFetch(),把loading状态一并做了。

  • 优化前,articles.json通过import引入,这会使articles.json被打包进entry.js,结果就是:访问/articles时,由白屏直接变为加载完成。我们改成如下,entry的体积会减小,在articles.json加载前,展示一个loading状态:

    <script lang="ts" setup>
    // import articlesList from "~/public/rebuild/json/articles.json"
    const {pending, data: articlesList} = useFetch('/rebuild/json/articles.json');
    </script>
    
    <template>
      <loading v-if="pending" />
      <ul v-else>
        <li v-for="item in articlesList">
          ... ...
        </li>
      </ul>
    </template>
    
  • 优化前,showdown和highlight.js的体积占很多,而这两个库只会在详情页用到,本不必被列表页引入,但nuxt还是引了。以highlight.js为例,我们改成如下,hljs在调用时才被加载:

    // import hljs from "highlight.js";
    mdEl.querySelectorAll("pre>code:not(.hljs)").forEach(async (el: HTMLElement) => {
      const hljs = (await import("highlight.js")).default as any;
      hljs.highlightElement(el);
    });
    

    通过以上操作,entry.js成功减小到66K(Gzip后):Gzip后66.8K

本地后端服务

实在想不到用什么词来描述这个概念,简单地说,就是在运行npm run dev时,把原本使用github graphql的请求,换成“直接修改本地文件”。这样本站就既支持在线更新,又支持本地更新了。 我最先想到的是找找vite的热更新接口,查了下文档,看起来挺简单。我们先写一个本地版deleteList函数,它的函数签名和utils/manage/github.tsdeleteList一模一样:

/** utils/manage/__github.ts */

export function deleteList (json, deletions) {
  // ... ...
  // 这里通过websocket发送update请求
  import.meta.hot.send("rebuild:update", {
    additions: [{
      path: `public/rebuild/json/articles.json`,
      content: JSON.stringify(json, null, 2)
    }],
    deletions,
  });
  // ... ...
}

以articles管理页为例,加上dev判断:

/** pages/manage/articles/index.vue */

import { deleteList } from "~/utils/manage/github";
import { deleteList as deleteListDev } from "~/utils/manage/__github";

function deleteItems() {
  // ... ...
  // 即 process.env.NODE_ENV === 'development'
  if (useRuntimeConfig().public.dev) {
    deleteListDev(json, selectedList);
  } else {
    deleteList(json, selectedList);
  }
}

可以正常使用,但有一个问题:编译时,deleteListDev()属于dev下才会使用的代码,也会被打包进dist,显然是不够优雅的。换一种思路,我们的目标是:在不改变函数签名的情况下,“偷梁换柱”把函数调个包。也许在import时动动手脚就行了?在查询rollup的文档后,服务端插件这样写:

import { Plugin } from "vite";

const LOCAL_SERVER = "ls:";

export default {
  name: "local-server-plugin",
  resolveId (source, importer, options) {
    if (source.startsWith(LOCAL_SERVER)) {
      const realPath = source.slice(LOCAL_SERVER.length);
      // 如果是dev环境,则在文件名前面加两个下划线:__
      const id = process.env.NODE_ENV === "development" ? realPath.replace(/([^/]*)$/, "__$1") : realPath;
      return this.resolve(id, importer, options).then(resolved => resolved || { id });
    }
    return null;
  },
  configureServer (server) {
    // 响应import.meta.hot.send("rebuild:update")
    server.ws.on("rebuild:update", (data, client) => {
      try {
        // 这里省略writeFile和removeFile函数
        data.additions.forEach(writeFile);
        data.deletions.forEach(removeFile);
        client.send("rebuild:result", true);
      } catch (e) {
        client.send("rebuild:result", e.toString());
      }
    });
  }
} as Plugin;

articles管理页改成这样:

/** pages/manage/articles/index.vue */

// * dev时:     import { deleteList } from "~/utils/manage/__github";
// * build时:   import { deleteList } from "~/utils/manage/github";
import { deleteList } from "ls:~/utils/manage/github";

function deleteItems() {
  // ... ...
  deleteList(json, selectedList);
}

不需要判断dev,只需在import时加上前缀ls:即可,插件会进行判断,若当前是dev,则把文件改个名字。 typescript无法识别ls:前缀,我们需要shim一下,我对此不熟悉,目前没找到更好的办法:

// Need a better way
declare module "ls:*github" {
  export const deleteList: typeof import("./utils/manage/github")["deleteList"];
}

简论

对于vue3,我现在的见解和笔记尚显浅薄,至于值不值得写下来,就交给时间去判断吧