阅读视图

发现新文章,点击刷新页面。

Node.js + vue3 大文件-切片上传全流程(视频文件)

Node.js + vue3 大文件-切片上传全流程(视频文件)

这个业务场景是在参与一个AI智能混剪视频切片的项目中碰到的,当时的第一版需求是视频文件直接上传,当时是考虑到视频切片不会很大,就默认用户直接上传,但后续需求调整,切片时长扩大且画质也许会有所提高,导致文件会很大。解决方案考虑过是否可以通过压缩来解决,但混剪视频需求,用户是极其在意画质的,因此就放弃这种方案,只能选择市面通用的方案,切片上传。

功能简述

  1. 支持手动上传、拖动上传。

  2. 支持切片上传,且上传时带有进度条。

    切片格式限制:Mp4,大小限制: 20M

  3. 支持断点续传(后续再添加...)

服务端(node.js)

Install

pnpm install express multer fluent-ffmpeg body-parser cors fs-extra

环境配置:由于多个切片需要合并成一个视频,因此本地机器需要配置 ffmpeg

# 验证是否安装了 ffmpeg
ffmpeg -v

文件目录结构

your-project-name
├─ index.js
├─ cache
├─ output
├─ utils
│  ├─ multer.js
├─ public
├─ dist

multer 配置

const multer = require('multer')
const fse = require('fs-extra')

/**
 * multer 配置
 * @param { string } path 上传文件的目录
 * @param { function } fileFilter 文件过滤
 * @returns { multer } multer 实例
 */
module.exports = (path, fileFilter) => {
  /**
   * 上传文件的目录
   */
  const storage = (path) => {
    return multer.diskStorage({
      // 上传文件的目录
      destination: (req, file, cb) => {
        cb(null, path)
      },
      // 上传文件的名称
      filename: (req, file, cb) => {
        const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8')
        cb(null, fileName)
      }
    })
  }
  const config = {
    storage: storage(path)
  }
  /**
   * 文件过滤
   */
  if (fileFilter) {
    config.fileFilter = fileFilter
  }
  /**
   * 上传配置
   */
  return multer(config)
}

创建服务

const express = require('express')
const fse = require('fs-extra')
const fs = require('fs')
const multer = require('./utils/multer.js')
const { sep, resolve } = require('path')
const app = express()
const router = express.Router()
// multer 配置
const multerOption = multer(resolve(__dirname, `.${sep}cache`))

/**
 * 处理静态文件
 * 静态资源 token 校验
 */
express.static(resolve(__dirname,`.${sep}public`)))
app.use(express.static(resolve(__dirname, `.${sep}public`)))
app.use(express.static(resolve(__dirname, `.${sep}dist`)))
/**
 * 跨域
 */
app.use(cors())
/**
 * 请求参数
 */
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())

/**
 * 上传切片
 */
router.post('/upload/chunk', multerOption.single('file'), (req, res) => {
try {
    const { file } = req
    const { chunkIndex, name: fileName } = req.body
    const cachePath = resolve(__dirname, `.${sep}cache`)
    const filePath = resolve(cachePath, `.${sep}${fileName}`)
    // 创建hash目录
    createFolder(filePath)
    // 移动chunk到指定文件目录
    fs.renameSync(resolve(cachePath, `.${sep}${file.originalname}`), resolve(filePath, `.${sep}${file.originalname}`))
  } catch (e) {
    console.log('e', e)
    throw new Error(e.message)
  }
})

/**
 * 合并切片
 */
router.post('/upload/chunk', (req, res) => {
  try {
    const { name: fileName, tagIds } = req.bodyd
    const filePath = resolve(__dirname, `.${sep}cache${sep}${fileName}`)
    const outputPath = resolve(__dirname, `.${sep}output`)
    // 获取 分片 文件
    const chunks = fs.readdirSync(hashPath)
    // 排序分片
    chunks.sort((a, b) => {
      const numA = parseInt(a)
      const numB = parseInt(b)
      return numA - numB
    })
    // 合并分片
    chunks.map(chunkPath => {
      fs.appendFileSync(
        resolve(filePath, `.${sep}${fileName}.mp4`),
        fs.readFileSync(resolve(filePath, `.${sep}${chunkPath}`))
      )
    })
    // 移动视频到指定目录
    fs.renameSync(resolve(filePath, `.${sep}${fileName}.mp4`), resolve(outputPath, `.{sep}${fileName}.mp4`))
    // 删除分片
    chunks.map(chunkPath => {
      fs.unlinkSync(resolve(filePath, `.${sep}${chunkPath}`))
    })
    // 删除hash目录
    fs.rmdirSync(filePath)
  } catch (e) {
    throw new Error(e.message)
  }
})

/**
 * 创建文件夹
 * @param {String} path 文件夹路径
 */
createFolder(path) {
  try {
    if (fse.existsSync(path)) {
      return
    }
    fse.ensureDirSync(path)
  } catch (error) {
    throw new Error('[Create Folder]创建文件夹失败', error)
  }
}

/**
 * 启动服务
 */
try {
  const port = process.env.PORT || 8081 // 端口号
  const host = process.env.IP || '0.0.0.0' // 主机地址
  app.listen(port, host, () => {
    console.log(`服务已启动,访问地址:http://${host}:${port}`)
  })
} catch (error) {
  console.error('启动服务失败:', error)
}

客户端 (vue3 + element-plus)

<template>
  <div class="upload-video round-8 pd-16 border-box scroll-y">
    <div class="container" style="overflow: hidden;">
      <input ref="uploadRef" type="file" :multiple="uploadOptions.multiple" :accept="uploadOptions.accept" @change="handleSelectFile" />
      <!-- 等待上传 -->
      <div v-if="uploadStatus === 'waiting'" class="upload-box flex-center text-center pointer hover"
        @dragover="handlePreventDefault"
        @dragenter="handlePreventDefault"
        @drop="handleFileDrop"
        @click="handleClickUpload">
        <img src="@/assets/upload.png" alt="上传" class="upload-icon" />
        <div class="mg-l-8" style="line-height: 22px;">
          <p class="color-info font-12 ellipsis">拖拽到此区域上传或点击上传</p>
          <p class="color-info font-12 ellipsis">仅支持 .mp4 格式</p>
        </div>
      </div>
      <!-- 上传 -->
      <div v-else class="upload-box flex-center-column pd-16 border-box">
        <!-- 正在上传 -->
        <div v-if="uploadStatus === 'uploading'" class="flex-column jc-c" style="width: 100%; height: 100%;">
          <el-progress :percentage="progress" />
          <div class="font-12 color-info flex ai-c jc-sb">
            <el-button text type="info" size="small" loading style="margin-left: -8px;">
              <span v-if="chunkInfo.total" class="mg-l-4">
                {{ chunkInfo.uploaded !== chunkInfo.total ? `(${chunkInfo.uploaded}/${chunkInfo.total}) 正在上传...` : '上传成功,正在读取文件...' }}
              </span>
            </el-button>
            <el-button text type="danger" size="small" class="mg-r-16">
              取消
            </el-button>
          </div>
        </div>
        <!-- 上传完成 -->
        <div v-if="uploadStatus === 'success'" class="flex-center-column">
          <div class="preview-video mg-b-12 relative pointer" @click="handleClickPreview">
            <div v-if="isPreview" class="preview-video-mask" />
            <video ref="previewVideoRef" :src="previewUrl" preload="metadata" class="round-4" width="100%" height="100%" style="aspect-ratio: 16/9;" />
          </div>
          <span class="font-12 color-info flex ai-c" style="max-width: 326px;">
            <el-icon class="mg-r-4 font-14 color-success"><CircleCheckFilled /></el-icon>
            <span class="ellipsis">已选择文件【{{ fileInfo?.name }}】</span>
          </span>
          <el-button size="small" class="mg-t-8" type="primary" @click="handleClickUpload">重新上传文件</el-button>
        </div>
      </div>
    </div>
    <div class="form-box mg-t-16">
      <el-form :model="form" ref="formRef" label-position="top" :rules="formRules">
        <el-form-item label="视频名称" style="margin-bottom: 8px;" prop="name">
          <el-input v-model="form.name" type="textarea" :rows="5" resize="none" placeholder="请输入视频名称" clearable />
        </el-form-item>
        <el-form-item label="视频标签" prop="tags">
          <el-select v-model="form.tags" placeholder="请选择视频标签" clearable filterable multiple :disabled="!tags.length">
            <el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleConfirm">确定</el-button>
          <el-button type="info" @click="handleClickBack">返回</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getTagList, checkVideoChunkApi, uploadChunkApi, mergeChunkApi } from '@/api'

const router = useRouter()
const fileInfo = ref(null)

/**
 * 表单
 */
const form = reactive({
  name: '',
  tags: []
})

/**
 * 表单验证
 */
const formRules = {
  name: [{ required: true, message: '请输入视频名称', trigger: 'blur' }],
  tags: [{ required: true, message: '请选择视频标签', trigger: 'blur' }]
}

/**
 * 视频标签
 */
const tags = ref([])

/**
 * 上传视频的配置
 * @type {Object} { accept: 'video/mp4', multiple: true }
 */
const uploadOptions = {
  accept: ['video/mp4'],
  multiple: false
}

/**
 * 上传进度
 * @type {Number}
 */
const progress = ref(10)

/**
 * 上传状态
 * waiting | uploading | success | fail
 */
const uploadStatus = ref('waiting')

/**
 * 阻止浏览器拖拽打开文件的默认行为
 * @param {Object} e
 */
const handlePreventDefault = (e) => {
  e.stopPropagation()
  e.preventDefault()
}

/**
 * 放开鼠标,拖拽结束时回调
 * @param {Object} e
 */
 const handleFileDrop = async (e) => {
  try {
    handlePreventDefault(e)
    const filesList = []
    const target = []
    const types = e.dataTransfer.types
    if (!types.includes('Files')) {
      ElMessage.warning('仅支持MP4文件!')
      return
    }
    // 特殊处理,不然直接看e的files始终为空
    target.forEach.call(e.dataTransfer.files, (file) => { filesList.push(file) }, false)
    if (!filesList.length) {
      return
    }
    const file = filesList[0]
    const fileEvent = {
      target: {
        files: [file]
      }
    }
    handleSelectFile(fileEvent)
  } catch (error) {
    console.error(error)
    uploadStatus.value = 'waiting'
  } finally {
    uploadRef.value.value = null
  }
}

const previewUrl = ref('')
/**
 * 手动选择本地文件
 * @param {Object} fileEvent
 */
const handleSelectFile = async (fileEvent) => {
  try {
    const { target } = fileEvent
    if (!target.files.length) {
      return
    }
    const file = target.files[0]
    console.log('🔅 ~ handleSelectFile ~ file:', file)
    // 校验文件
    if (file.type !== 'video/mp4') {
      ElMessage.warning('仅支持MP4文件!')
      return
    }
    uploadStatus.value = 'success'
    fileInfo.value = file
    // 设置视频名称 -- 去除文件后缀
    form.name = file.name.replace(/.mp4$/, '')
    previewUrl.value = URL.createObjectURL(file)
  } catch (error) {
    console.error(error)
    uploadStatus.value = 'waiting'
  } finally {
    uploadRef.value.value = null
  }
}

const uploadRef = ref(null)
/**
 * 点击上传按钮
 */
const handleClickUpload = () => {
  uploadRef.value.click()
}

const previewVideoRef = ref(null)
const isPreview = ref(true)
/**
 * 点击预览
 */
const handleClickPreview = () => {
  // 如果正在预览,则暂停
  if (!isPreview.value) {
    previewVideoRef.value.pause()
    isPreview.value = true
    return
  }
  // 如果未正在预览,则播放
  isPreview.value = false
  previewVideoRef.value.play()
}

/**
 * 点击返回
 */
const handleClickBack = () => {
  router.back()
}

/**
 * 分片信息
 */
const chunkInfo = reactive({
  total: 0,
  uploaded: 0
})

const formRef = ref(null)
/**
 * 点击确定
 */
const handleConfirm = async () => {
  // console.log('handleConfirm', fileInfo.value)
  try {
    await formRef.value.validate()
    // 检测视频-已上传了多少分片
    const chunkCheckInfo = await checkVideoChunkApi({ name: form.name })
    if (chunkCheckInfo.code === 1) {
      return
    }
    // 已上传分片数量
    const isUploadedChunkArr = chunkCheckInfo.data
    // 分片大小
    const chunkSize = 1024 * 1024 * 20 // 20MB
    // 切片总数量
    chunkInfo.total = Math.ceil(fileInfo.value.size / chunkSize)
    // 切片列表
    const chunkList = []
    for (let i = 0; i < chunkInfo.total; i++) {
      const start = i * chunkSize
      const end = Math.min(fileInfo.value.size, start + chunkSize)
      const chunk = fileInfo.value.slice(start, end)
      chunkList.push(chunk)
    }
    uploadStatus.value = 'uploading'
    //  上传切片
    for (let i = 0; i < chunkList.length; i++) {
      let chunkIndex = i + 1
      if (isUploadedChunkArr.includes(`${chunkIndex}`)) {
        chunkInfo.uploaded++
        continue
      }
      let blobFile = new File([chunkList[i]], `${chunkIndex}.mp4`)
      const formData = new FormData()
      formData.append('file', blobFile)
      formData.append('name', form.name)
      formData.append('chunkIndex', chunkIndex)
      const flag = await uploadChunkApi(formData, (evt) => {
        progress.value = 0
        progress.value = evt?.progress ? Math.floor(evt.progress * 100) : 0
      })
      if (flag.code === 1) {
        break
      }
      chunkInfo.uploaded++
    }
    // 合并切片
    await mergeChunkApi({
      name: form.name,
      tagIds: form.tags
    })
    uploadStatus.value = 'success'
    ElMessage.success('上传成功')
    router.push({
      path: '/list',
      query: {
        tagId: form.tags[0]
      }
    })
  } catch (error) {
    console.log(error)
  }
}

const getTagListData = async () => {
  try {
    const res = await getTagList()
    if (res.code === 0) {
      tags.value = res.data
    }
  } catch (error) {
    console.log(error)
  }
}


onMounted(() => {
  getTagListData()
})

</script>
<script>
export default {
  name: 'UploadVideo'
}
</script>
<style lang="scss" scoped>
.upload-video {
  width: 100%;
  height: 100%;
  background-color: var(--el-bg-color);
}

.upload-box {
  width: 100%;
  height: 220px;
  font-size: 16px;
  border-radius: 8px;
  background-color: var(--el-fill-color-light);
  .upload-icon {
    width: 160px;
  }
  &.hover {
    &:hover {
      border-color: #409EFF;
    }
  }
}

.preview-video {
  width: 220px;
  position: relative;
  object-fit: cover;
  aspect-ratio: 16/9;
  border-radius: 4px;
  background-color: var(--el-color-primary-light-9);
  .preview-video-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: url('@/assets/play.png') no-repeat center center;
    background-size: 22% 30%;
    background-color: rgba(0, 0, 0, 0.5);
    border-radius: 4px;
  }
}

input[type="file"] {
  display: none;
}

:deep(){
  .el-form-item__label {
    margin-bottom: 4px;
  }
}
</style>

预览

未上传

image-20251015165314385.png

待上传

image-20251015174233866.png

上传中

image-20251015174351510.png

一个专业的前端如何在国内安装 `pnpm`

the-nutcracker-ballet.jpeg

本文以 macOS 为例,但思路也适用于 Windows 系统。

对于 pnpm 我们有多种安装方式,可以使用现有的包管理器比如 npm npm i -g pnpm

但是 npm 一般是通过 nvm 安装的,如果 nvm 切换到其他 node.js 版本,则无法使用 pnpm(command not found: pnpm),还得继续安装一遍,颇为麻烦。

所以 pnpm 官方一般推荐通过 shell 脚本的方式安装,以下安装命令来自 pnpm 官网:pnpm.io/installatio…

curl -fsSL https://get.pnpm.io/install.sh | sh -

但是如果直接运行我们会发现超时以及报错。通过下载安装脚本 get.pnpm.io/install.sh 和搜索关键词 github 我们在 94 行发现:

archive_url="https://github.com/pnpm/pnpm/releases/download/v${version}/pnpm-${platform}-${arch}"

原因很清楚了国内无法访问 github,修复也很简单找一个 proxy 即可,这里我用的是 gh-proxy.com/ (2025-09-27 可用):

第一步:移除无用包

可选。主要是为了删除无用包,减少磁盘浪费,以及避免冲突。切换到曾经安装过 pnpm 的 node.js 版本。

nvm use 20
npm uninstall -g pnpm

第二步:替换成可用 proxy

将下载到本地的 install.sh 修改成如下:

archive_url="https://gh-proxy.com/https://github.com/pnpm/pnpm/releases/download/v${version}/pnpm-${platform}-${arch}"

然后执行:

sh install.sh

等待 10s 即可安装成功,并且 .zshrc 文件末尾将自动增加:

# pnpm
export PNPM_HOME="/Users/legend80s/Library/pnpm"
case ":$PATH:" in
  *":$PNPM_HOME:"*) ;;
  *) export PATH="$PNPM_HOME:$PATH" ;;
esac
# pnpm end

重新开一个 terminal 让更新后的 .zshrc 生效或者直接 source .zshrc 然后,

试试 pnpm -v 输出 10.17.1(2025-09-27)。

再试试 pnpx pnpx ydd -e -s -c=a hefty

一样成功 🎉。

全栈视角:从零构建一个现代化的 Todo 应用

Todo 应用是学习全栈开发的“Hello World”,但它能完美地串联起现代 Web 开发的所有核心概念。我们将构建一个具备增删改查、实时更新等功能的单页面应用。

tRPC-×-Drizzle-×-Next-js-Todo-10-09-2025_05_55_PM

技术选型

  • Next.js 15: 用于构建服务器渲染的 React 应用。
  • TypeScript: 增加类型检查,提升代码质量。
  • Tailwind CSS: 用于快速构建响应式界面。
  • Drizzle ORM: 作为 ORM 管理数据库。
  • PostgreSQL: 作为数据库存储 Todo 项。
  • tRPC: 用于构建类型安全的 API。
  • Zod: 用于验证和解析 API 请求参数。
  • @tanstack/react-query: 用于管理客户端数据缓存和状态。

开发环境

  • Node.js 18+: 确保安装最新版本的 Node.js。
  • npm: 或使用 Yarn/PNPM 作为包管理器。
  • PostgreSQL: 安装并运行 PostgreSQL 数据库。
  • Drizzle ORM: 全局安装 Drizzle ORM 命令行工具。

用 Docker 起本地 PG(可选):

docker run --name pg13 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=app -p 5432:5432 -d postgres:13

连接串(示例):postgres://postgres:postgres@localhost:5432/app

项目结构

.
├── drizzle
│   ├── meta
│   │   ├── 0000_snapshot.json
│   │   └── _journal.json
│   └── 0000_lively_gravity.sql
├── src
│   ├── app
│   │   ├── api/trpc/[trpc]
│   │   │   └── route.ts
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── trpc-provider.tsx
│   ├── server
│   │   ├── db
│   │   │   ├── index.ts
│   │   │   └── schema.ts
│   │   ├── routers
│   │   │   └── todo.ts
│   │   ├── root.ts
│   │   └── trpc.ts
│   └── trpc
│       └── react.ts
├── README.md
├── biome.json
├── drizzle.config.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
└── tsconfig.json

项目初始化

npx create-next-app@latest todo --ts
cd todo

选择 App Router、ESLint、Tailwind。

安装与配置 PostgreSQL、Drizzle 及相关依赖

pnpm add drizzle-orm drizzle-kit pg zod @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson

新增开发脚本(package.json)

{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build --turbopack",
    "start": "next start",
    "lint": "biome check",
    "format": "biome format --write",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

环境变量(.env):

DATABASE_URL=postgres://postgres:postgres@localhost:5432/app
NODE_ENV=development

Drizzle 配置(drizzle.config.ts):

import type { Config } from "drizzle-kit";

export default {
  schema: "./src/server/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

数据库连接(src/server/db/index.ts):

import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool);

数据表

Schema(src/server/db/schema.ts):

import { pgTable, serial, varchar, boolean, timestamp } from "drizzle-orm/pg-core";

export const todos = pgTable("todos", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 200 }).notNull(),
  completed: boolean("completed").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

生成并执行迁移:

pnpm db:generate
pnpm db:migrate

执行后会在 drizzle/ 目录看到迁移 SQL,并把表建好。 (可选)打开可视化:

pnpm db:studio

初始化 tRPC(服务器与客户端)

  1. 服务器端基础(src/server/trpc.ts):

    import { initTRPC } from "@trpc/server";
    import { ZodError } from "zod";
    
    const t = initTRPC.context<{}>().create({
      errorFormatter({ shape, error }) {
        return {
          code: -1,
          message:
            error.cause instanceof ZodError
              ? error.cause.issues.map((issue) => issue.message).join("")
              : shape.message,
          data: null,
        };
      },
    });
    
    export const publicProcedure = t.procedure;
    export const router = t.router;
    
  2. tRPC 适配 Next.js App Router(src/app/api/trpc/[trpc]/route.ts):

    import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
    import { appRouter } from "@/server/root";
    
    const handler = (req: Request) => {
      return fetchRequestHandler({
        endpoint: "/api/trpc",
        req,
        router: appRouter,
      });
    };
    
    export { handler as GET, handler as POST };
    
  3. tRPC todo 路由(src/server/routers/todo.ts):

    import { desc, eq } from "drizzle-orm";
    import { z } from "zod";
    import { db } from "@/server/db";
    import { todos } from "@/server/db/schema";
    import { publicProcedure, router } from "@/server/trpc";
    
    export const todoRouter = router({
      list: publicProcedure.query(async () => {
        return db.select().from(todos).orderBy(desc(todos.createdAt));
      }),
    
      create: publicProcedure
        .input(z.object({ title: z.string().min(1).max(200) }))
        .mutation(async ({ input }) => {
          const [row] = await db
            .insert(todos)
            .values({ title: input.title })
            .returning();
          return row;
        }),
    
      toggle: publicProcedure
        .input(z.object({ id: z.number(), completed: z.boolean() }))
        .mutation(async ({ input }) => {
          const [row] = await db
            .update(todos)
            .set({ completed: input.completed })
            .where(eq(todos.id, input.id))
            .returning();
          return row;
        }),
    
      remove: publicProcedure
        .input(z.object({ id: z.number() }))
        .mutation(async ({ input }) => {
          await db.delete(todos).where(eq(todos.id, input.id));
          return { ok: true };
        }),
    });
    
  4. tRPC 根接口(src/server/root.ts):

    import { todoRouter } from "./routers/todo";
    import { router } from "./trpc";
    
    export const appRouter = router({
      todo: todoRouter,
    });
    
    export type AppRouter = typeof appRouter;
    
  5. 客户端(src/trpc/react.ts):

    import { createTRPCReact } from "@trpc/react-query";
    import type { AppRouter } from "@/server/root";
    
    export const trpc = createTRPCReact<AppRouter>();
    
  6. 创建 tRPC Provider src/app/trpc-provider.tsx

    "use client";
    
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { httpBatchLink } from "@trpc/client";
    import { type ReactNode, useState } from "react";
    import { trpc } from "@/trpc/react";
    
    export function TRPCProviders({ children }: { children: ReactNode }) {
      const [queryClient] = useState(() => new QueryClient());
      const [trpcClient] = useState(() =>
        trpc.createClient({
          links: [
            httpBatchLink({
              url: "/api/trpc",
            }),
          ],
        }),
      );
    
      return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
          <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
        </trpc.Provider>
      );
    }
    
  7. 在 app/layout.tsx 中挂载 Provider:

    import type { Metadata } from "next";
    import { Geist, Geist_Mono } from "next/font/google";
    import "./globals.css";
    import { TRPCProviders } from "./trpc-provider";
    
    const geistSans = Geist({
      variable: "--font-geist-sans",
      subsets: ["latin"],
    });
    
    const geistMono = Geist_Mono({
      variable: "--font-geist-mono",
      subsets: ["latin"],
    });
    
    export const metadata: Metadata = {
      title: "Create Next App",
      description: "Generated by create next app",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="en">
          <body
            className={`${geistSans.variable} ${geistMono.variable} antialiased`}
          >
            <TRPCProviders>{children}</TRPCProviders>
          </body>
        </html>
      );
    }
    

Todo CRUD 端到端打通

  1. 前端页面(src/app/page.tsx):
    "use client";
    
    import { useState } from "react";
    import { trpc } from "@/trpc/react";
    
    export default function HomePage() {
      const utils = trpc.useUtils();
      const [title, setTitle] = useState("");
    
      const { data: todos = [], isLoading } = trpc.todo.list.useQuery();
    
      const createTodo = trpc.todo.create.useMutation({
        onSuccess: () => utils.todo.list.invalidate(),
      });
    
      const toggleTodo = trpc.todo.toggle.useMutation({
        onSuccess: () => utils.todo.list.invalidate(),
      });
    
      const removeTodo = trpc.todo.remove.useMutation({
        onSuccess: () => utils.todo.list.invalidate(),
      });
    
      const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (!title.trim()) return;
        createTodo.mutate({ title });
        setTitle("");
      };
    
      return (
        <main className="mx-auto max-w-xl p-6 space-y-4">
          <h1 className="text-2xl font-bold">tRPC × Drizzle × Next.js Todo</h1>
    
          <form className="flex gap-2" onSubmit={handleSubmit}>
            <input
              className="flex-1 border rounded px-3 py-2"
              placeholder="添加待办..."
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
            <button
              className="px-4 py-2 rounded bg-black text-white disabled:opacity-50"
              type="submit"
              disabled={createTodo.isPending}
            >
              {createTodo.isPending ? "添加中..." : "添加"}
            </button>
          </form>
    
          {isLoading ? (
            <p>加载中...</p>
          ) : (
            <ul className="space-y-2">
              {todos.map((t) => (
                <li
                  key={t.id}
                  className="flex items-center gap-3 border p-2 rounded"
                >
                  <input
                    type="checkbox"
                    checked={t.completed}
                    onChange={(e) =>
                      toggleTodo.mutate({ id: t.id, completed: e.target.checked })
                    }
                  />
                  <span className={t.completed ? "line-through text-gray-500" : ""}>
                    {t.title}
                  </span>
                  <button
                    className="ml-auto text-red-600"
                    onClick={() => removeTodo.mutate({ id: t.id })}
                  >
                    删除
                  </button>
                </li>
              ))}
            </ul>
          )}
        </main>
      );
    }
    

完整项目地址

github.com/letconstvar…

深入理解 JavaScript 函数:从基础到高阶应用

函数是 JavaScript 的核心概念,掌握函数意味着真正理解 JavaScript 编程的精髓

1. 函数基础:减少重复代码的利器

1.1 函数的定义与调用

// 函数声明
function sayHello() {
    console.log("Hello, World!");
}

// 函数调用
sayHello(); // 输出: Hello, World!

函数提升现象值得注意:

// 此处可以正常调用,因为函数声明会提升
test(); // 输出: "函数已调用"

function test() {
    console.log("函数已调用");
}

1.2 参数与返回值

function calculateSum(a, b) {
    return a + b;
}

const result = calculateSum(5, 3);
console.log(result); // 输出: 8

// 未传递参数的情况
function greet(name) {
    if (name === undefined) {
        return "Hello, stranger!";
    }
    return `Hello, ${name}!`;
}

console.log(greet()); // 输出: Hello, stranger!

2. 作用域与闭包:JavaScript 的"结界"

2.1 作用域链理解

var globalVar = "我是全局变量";

function outer() {
    var outerVar = "我是外部函数变量";
    
    function inner() {
        var innerVar = "我是内部函数变量";
        console.log(globalVar);    // 可以访问
        console.log(outerVar);     // 可以访问
        console.log(innerVar);     // 可以访问
    }
    
    inner();
    // console.log(innerVar); // 错误:无法访问内部函数变量
}

outer();

2.2 立即执行函数(IIFE)

// 传统写法 - 会污染全局作用域
var count = 0;
function increment() {
    return ++count;
}

// IIFE写法 - 不会污染全局作用域
const counter = (function() {
    let count = 0;
    
    return {
        increment: function() {
            return ++count;
        },
        decrement: function() {
            return --count;
        },
        getCount: function() {
            return count;
        }
    };
})();

console.log(counter.getCount()); // 0
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2

3. 函数的本质:一等公民的对象

3.1 函数作为值传递

// 函数赋值给变量
const multiply = function(a, b) {
    return a * b;
};

// 函数作为参数传递
function operate(a, b, operation) {
    return operation(a, b);
}

console.log(operate(5, 3, multiply)); // 输出: 15

// 函数作为返回值
function createMultiplier(factor) {
    return function(number) {
        return number * factor;
    };
}

const double = createMultiplier(2);
console.log(double(5)); // 输出: 10

3.2 this 关键字的奥秘

const person = {
    name: "张三",
    sayName: function() {
        console.log(this.name);
    }
};

person.sayName(); // 输出: "张三"

// this 绑定丢失的情况
const sayName = person.sayName;
sayName(); // 输出: undefined (在严格模式下会报错)

4. 构造函数:创建对象的工厂

4.1 构造函数的使用

function Person(name, age) {
    this.name = name;
    this.age = age;
    
    this.introduce = function() {
        return `大家好,我是${this.name},今年${this.age}岁`;
    };
}

// 使用 new 关键字创建实例
const person1 = new Person("李四", 25);
const person2 = new Person("王五", 30);

console.log(person1.introduce()); // 大家好,我是李四,今年25岁
console.log(person2.introduce()); // 大家好,我是王五,今年30岁

4.2 new.target 的应用

function Vehicle(type) {
    if (!new.target) {
        throw new Error("必须使用 new 关键字调用构造函数");
    }
    this.type = type;
}

// 正确使用
const car = new Vehicle("汽车");

// 错误使用
// const bike = Vehicle("自行车"); // 抛出错误

5. 递归:函数自我调用的艺术

5.1 经典递归案例:斐波那契数列

function fibonacci(n) {
    if (n <= 0) return 0;
    if (n === 1) return 1;
    
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// 优化版本:使用缓存
function fibonacciMemo(n, memo = {}) {
    if (n in memo) return memo[n];
    if (n <= 0) return 0;
    if (n === 1) return 1;
    
    memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
    return memo[n];
}

console.log(fibonacci(7)); // 13
console.log(fibonacciMemo(50)); // 12586269025 (优化后可以快速计算)

5.2 汉诺塔问题的递归解法

function hanoiTower(source, auxiliary, target, disks) {
    if (disks === 1) {
        console.log(`将盘子从 ${source} 移动到 ${target}`);
        return;
    }
    
    // 将 n-1 个盘子从源柱移动到辅助柱
    hanoiTower(source, target, auxiliary, disks - 1);
    
    // 将最大的盘子从源柱移动到目标柱
    console.log(`将盘子从 ${source} 移动到 ${target}`);
    
    // 将 n-1 个盘子从辅助柱移动到目标柱
    hanoiTower(auxiliary, source, target, disks - 1);
}

// 测试 3 个盘子的汉诺塔
hanoiTower('A', 'B', 'C', 3);

执行过程可视化:

A->C
A->B
C->B
A->C
B->A
B->C
A->C

6. 执行栈:理解函数调用的底层机制

通过下面的例子理解执行栈的工作原理:

<!DOCTYPE html>
<html>
<head>
    <title>执行栈演示</title>
</head>
<body>
    <script>
        function A() {
            console.log("A开始执行");
            B();
            console.log("A执行结束");
        }

        function B() {
            console.log("B开始执行");
            C();
            console.log("B执行结束");
        }

        function C() {
            console.log("C开始执行");
            console.log("C执行结束");
        }

        console.log("全局开始");
        A();
        console.log("全局结束");
    </script>
</body>
</html>

控制台输出顺序:

全局开始
A开始执行
B开始执行
C开始执行
C执行结束
B执行结束
A执行结束
全局结束

7. 高级技巧与最佳实践

7.1 尾递归优化

// 普通递归 - 可能导致栈溢出
function factorial(n) {
    if (n === 1) return 1;
    return n * factorial(n - 1);
}

// 尾递归优化版本
function factorialTail(n, accumulator = 1) {
    if (n === 1) return accumulator;
    return factorialTail(n - 1, n * accumulator);
}

console.log(factorial(5)); // 120
console.log(factorialTail(5)); // 120

7.2 函数柯里化

function curry(fn) {
    return function curried(...args) {
        if (args.length >= fn.length) {
            return fn.apply(this, args);
        } else {
            return function(...args2) {
                return curried.apply(this, args.concat(args2));
            };
        }
    };
}

// 使用柯里化
function add(a, b, c) {
    return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

总结

JavaScript 函数是语言的灵魂,从基础的参数传递到高级的闭包、递归等概念,理解函数意味着掌握了 JavaScript 编程的精髓。通过本文的讲解和代码示例,希望你能:

  1. 深入理解函数的作用域和闭包机制
  2. 掌握构造函数和原型链的关系
  3. 学会使用递归解决复杂问题
  4. 理解 JavaScript 的执行机制

函数的学习是一个循序渐进的过程,多实践、多思考,你会发现 JavaScript 函数的世界既深邃又美妙!

NestJS入门(1)——TODO项目创建及概念初步了解

通过实现一个TODO需求来了解NestJS,需要实现的功能包括:

  1. 用户注册、登录
  2. 备忘录创建、修改、查询、删除

创建项目

❗注意:Node版本需≥20

全局安装 Nest CLI 脚手架,并初始化项目,项目名称为 DoraemonNotebook (哆啦A梦记事本)

$ npm i -g @nestjs/cli 
$ nest new DoraemonNotebook

了解项目结构及基础概念

创建完成后项目的目录结构如图:

image.png

其中核心文件为 src 目录下的几个文件:

文件 说明
app.controller.spec.ts 控制器的单元测试
app.controller.ts 一个具有单个路由的基本控制器
app.module.ts 应用程序的根模块
app.service.ts 一个具有单个方法的基本服务
main.ts 应用程序的入口文件,它使用核心函数 NestFactory 来创建 Nest 应用程序实例

根据这几个核心文件及数据流向我们来了解一下NestJS中的几个核心概念,在入口文件中我们首先会接触到根模块 AppModule

模块

image.png

Nest项目中至少需要一个根模块,即目前的 app.module.ts 文件,它是 Nest 构建应用程序的起点。我们在组织程序功能架构时推荐使用模块作为组织组件的方式,将一组密切相关的功能作为一个模块,方便管理。

在模块中我们可以看到引入了 AppControllerAppService,接下来我们先了解控制器(AppController)的概念。

控制器

image.png

控制器类似于前端的路由,它告诉程序请求将用哪个控制器处理,一个控制器可以具有多个路由,每个路由执行不同的操作。 具体的操作实现也就是业务逻辑我们一般放在提供者(AppService)中实现。

提供者

提供者(Provider)负责封装特定功能(如业务逻辑、数据访问、工具函数等),并可通过依赖注入(@Injectable())的方式在控制器或其他服务中使用。

默认情况下提供者是单例模式,全局共享实例状态,也可以通过声明作用域将生命周期改为请求作用域(@Injectable({scope: Scope.REQUEST}))、临时作用域(@Injectable({scope: Scope.TRANSIENT}))。

其他

本章只是简单了解NestJS的目录及架构,其实还有很多概念没介绍到,比如装饰器、依赖注入、中间件、异常过滤器、拦截器等,后续的文章中我们将通过在完成实际TODO需求中一一使用并详细讲解。

项目启动

项目开发过程中我们可以通过以下命令启动程序:

$ npm run start:dev

此命令将监视您的文件,自动重新编译并重新加载服务器。通过浏览器访问 http://localhost:3000,我们可以看到返回的 Hello World! 消息。

Electron IPC 自动化注册方案:模块化与热重载的完美结合

背景

在 Electron 应用开发中,我们经常需要在主进程和渲染进程之间进行通信。随着应用功能不断增加,IPC处理器的数量也会急剧增长。传统的手动注册方式会导致代码臃肿、难以维护。本文将介绍一种自动化的 IPC 处理器注册机制。

核心实现

1. 自动化扫描与注册

首先,我们来看核心的注册机制实现:

// project/electron/ipc/index.js
const path = require("path");
const { readdirSync } = require("fs");
const importSync = require("import-sync");

const getIcpMainHandler = () => {
    let allHandler = {};
    // 扫描 handlers 目录下的所有文件
    const dirs = readdirSync(path.join(__dirname, "handlers"), "utf8");
    
    for (const file of dirs) {
        const filePath = path.join(__dirname, "handlers", file);
        const handlersTemp = importSync(filePath);
        let handlers = {}
        
        // 分析每个导出的处理器
        for (const key in handlersTemp) {
            const handler = handlersTemp[key];
            let handlerType = Object.prototype.toString.call(handler);
            const match = handlerType.match(/^\[object (\w+)\]$/);
            handlerType = match[1];
            
            handlers[key] = {
                key,
                type: handlerType,
                val: handler,
            };
            
            allHandler = {
                ...allHandler,
                ...handlers,
            };
        }
    }
    return allHandler;    
}

module.exports.registerHandlerForIcpMain = () => {
    const ipcMainHandlers = getIcpMainHandler();
    // 只执行函数类型的处理器
    for (const key in ipcMainHandlers) {
        const handler = ipcMainHandlers[key];
        if (handler.type.indexOf("Function") > -1) {
            handler.val()
        }
    }
};

2. 模块化的处理器定义

将不同功能的 IPC 处理器分类到不同的文件中:

// project/electron/ipc/handlers/file.js
const { ipcMain } = require("electron");

module.exports.fileHander = () => {
    // 处理文件夹清理请求
    ipcMain.handle("clear-folder", async (event, path) => {
        // 具体的文件操作逻辑
        console.log("Clearing folder:", path);
    });
    
    // 可以注册更多的文件相关处理器
    ipcMain.handle("read-file", async (event, filePath) => {
        // 文件读取逻辑
    });
}
// project/electron/ipc/handlers/win.js
const { ipcMain, BrowserWindow } = require("electron");

module.exports.winHander = () => {
    // 处理窗口打开请求
    ipcMain.on('open-window', async (event) => {
        // 创建新窗口的逻辑
        const win = new BrowserWindow({ width: 800, height: 600 });
    });
    
    // 窗口管理相关处理器
    ipcMain.on('close-window', async (event) => {
        // 窗口关闭逻辑
    });
}

3. 主进程集成

在主进程启动时注册所有 IPC 处理器:

// project/electron/main.js
const { registerHandlerForIcpMain } = require("./ipc/index.js")

app.whenReady().then(() => {
    // 自动注册所有 IPC 处理器
    registerHandlerForIcpMain();
    
    // ... 其他初始化逻辑
});

4. 构建配置优化

为了支持开发时的热重载,我们在 Vite 配置中动态扫描 IPC 文件:

// vite.config.js
import fs from "fs"
import path from "path"

// 递归扫描目录(支持子目录)
const scanDeep = (dir) => {
    let results = []
    const list = fs.readdirSync(dir, { withFileTypes: true })

    for (const item of list) {
        const fullPath = path.join(dir, item.name)
        if (item.isDirectory()) {
            results = results.concat(scanDeep(fullPath))
        } else if (item.isFile() && [".js", ".cjs", ".mjs"].includes(path.extname(item.name))) {
            results.push(fullPath)
        }
    }
    return results
}

// 生成 Electron 配置
export const getElectronConfig = () => {
    const electronDir = path.join(__dirname, "../electron")
    const ipcEntries = scanDeep(path.join(electronDir, "ipc")).map((file) => ({
        // 从项目根目录计算相对路径
        entry: path.relative(process.cwd(), file)
    }))
    
    return [
        // 主进程
        {
            entry: path.join(process.cwd(), "electron/main.js")
        },
        // 预加载脚本
        {
            entry: path.join(process.cwd(), "electron/preload/index.js"),
            onstart(args) {
                args.reload()
            }
        },
        // 动态 IPC 入口
        ...ipcEntries
    ]
}

技术优势

1. 模块化与可维护性

  • 将相关功能的 IPC 处理器分组到不同的文件中
  • 新功能的添加不会影响现有代码结构
  • 便于团队协作开发

2. 自动化管理

  • 自动扫描并注册所有处理器,无需手动导入
  • 减少遗漏注册的风险
  • 统一的处理器管理入口

3. 开发体验优化

  • 支持开发时的热重载
  • 清晰的目录结构便于定位问题
  • 类型检查确保处理器格式正确

总结

这种自动化的 IPC 处理器注册机制为 Electron 应用开发带来了显著的好处:

  • 可扩展性:轻松添加新的 IPC 处理器
  • 可维护性:清晰的代码组织结构
  • 开发效率:自动化注册减少手动配置
  • 团队协作:统一的代码规范

这种模式特别适合中大型 Electron 项目,能够有效管理复杂的进程间通信需求。

超长定时器 long-timeout

在 JavaScript 开发中,定时器是常用的异步编程工具。然而,原生的 setTimeoutsetInterval 存在一个鲜为人知的限制:它们无法处理超过 24.8 天的定时任务。

对于前端开发来说,该限制不太会出现问题,但是需要设置超长定时的后端应用场景,如长期提醒、周期性数据备份、订阅服务到期提醒等,这个限制可能会导致严重的功能缺陷。

JavaScript 定时器的限制

原理

JavaScript 中 setTimeoutsetInterval 的延时参数存在一个最大值限制,这源于底层实现的整数类型限制。具体来说:

// JavaScript 定时器的最大延时值(单位:毫秒)
const TIMEOUT_MAX = 2 ** 31 - 1; // 2147483647 毫秒

// 转换为天数
const MAX_DAYS = TIMEOUT_MAX / 1000 / 60 / 60 / 24; // 约 24.855 天

console.log(TIMEOUT_MAX); // 输出: 2147483647
console.log(MAX_DAYS);   // 输出: 24.855134814814818

这一限制的根本原因在于 JavaScript 引擎内部使用 32 位有符号整数来存储延时值。当提供的延时值超过这个范围时,JavaScript 会将其视为 0 处理,导致定时器立即执行。

问题示例

以下代码演示了超出限制时的问题:

// 尝试设置 30 天的延时(超出 24.8 天的限制)
setTimeout(() => {
  console.log("应该在 30 天后执行");
}, 1000 * 60 * 60 * 24 * 30); // 2592000000 毫秒

// 实际结果:回调函数会立即执行,而不是在 30 天后

在控制台中执行上述代码,会发现回调函数立即执行,而不是像预期那样在 30 天后执行。这是因为 2592000000 毫秒超过了 2147483647 毫秒的最大值限制。

long-timeout 库

long-timeout 是一个专门解决 JavaScript 定时器时间限制问题的轻量级库。它提供了与原生 API 兼容的接口,同时支持处理超过 24.8 天的延时任务。

主要特性

  • 完全兼容原生 setTimeoutsetInterval API
  • 支持任意时长的延时,不受 24.8 天限制
  • 轻量级实现,无外部依赖
  • 同时支持 Node.js 和浏览器环境
  • 提供与原生方法对应的清除定时器函数

安装与基本使用

安装

可以通过 npm 或 yarn 安装 long-timeout 库:

# 使用 npm
npm install long-timeout

# 使用 yarn
yarn add long-timeout

pnpm add long-timeout

基本用法

long-timeout 库提供了与原生 API 几乎相同的接口,使用非常简单:

// 引入 long-timeout 库
import lt from 'long-timeout';

// 设置一个 30 天的超时定时器
// 返回一个定时器引用,用于清除定时器
const timeoutRef = lt.setTimeout(() => {
  console.log('30 天后执行的代码');
}, 1000 * 60 * 60 * 24 * 30); // 2592000000 毫秒

// 清除超时定时器
// lt.clearTimeout(timeoutRef);

// 设置一个每 30 天执行一次的间隔定时器
const intervalRef = lt.setInterval(() => {
  console.log('每 30 天执行一次的代码');
}, 1000 * 60 * 60 * 24 * 30);

// 清除间隔定时器
// 同上
// lt.clearInterval(intervalRef);

实现原理

long-timeout 库的核心实现原理是将超长延时分解为多个不超过 24.8 天的小延时,通过递归调用 setTimeout 来实现对超长延时的支持。同时 node-cron 库也是基于该原理实现的。

核心实现代码

以下是 long-timeout 库的核心实现逻辑:

// 定义 32 位有符号整数的最大值
const TIMEOUT_MAX = 2147483647;

// 定时器构造函数
function Timeout(after, listener) {
  this.after = after;
  this.listener = listener;
  this.timeout = null;
}

// 启动定时器的方法
Timeout.prototype.start = function() {
  // 如果延时小于最大值,直接使用 setTimeout
  if (this.after <= TIMEOUT_MAX) {
    this.timeout = setTimeout(this.listener, this.after);
  } else {
    const self = this;
    // 否则,先设置一个最大值的延时,然后递归调用
    this.timeout = setTimeout(function() {
      // 减去已经等待的时间
      self.after -= TIMEOUT_MAX;
      // 继续启动定时器
      self.start();
    }, TIMEOUT_MAX);
  }
};

// 清除定时器的方法
Timeout.prototype.clear = function() {
  if (this.timeout !== null) {
    clearTimeout(this.timeout);
    this.timeout = null;
  }
};

工作流程图解

long-timeout 库的工作流程可以概括为以下几个步骤:

  1. 接收用户设置的延时时间和回调函数
  2. 检查延时是否超过 2147483647 毫秒(约 24.8 天)
  3. 如果未超过最大值,直接使用原生 setTimeout
  4. 如果超过最大值,将延时分解为多个最大值的段,通过递归调用实现
  5. 每完成一个时间段,更新剩余延时并继续设置下一个定时器
  6. 当所有时间段完成后,执行用户提供的回调函数
[用户设置超长延时][检查是否超过 TIMEOUT_MAX] ── 否 ─→ [直接使用 setTimeout]
                       └── 是 ─→ [分解为多个 TIMEOUT_MAX 段][递归调用 setTimeout][所有段完成后执行回调]

注意事项与最佳实践

内存管理

对于长时间运行的应用,应当注意及时清除不再需要的定时器,以避免内存泄漏:

import lt from 'long-timeout';

let timeoutRef = lt.setTimeout(() => {
  console.log('任务执行');
}, 1000 * 60 * 60 * 24 * 30); // 30 天

// 当不再需要该定时器时,及时清除
function cancelTask() {
  if (timeoutRef) {
    lt.clearTimeout(timeoutRef);
    timeoutRef = null; // 释放引用
    console.log('定时器已清除');
  }
}

应用重启的处理

需要注意的是,long-timeout 仅在应用运行期间有效。如果应用重启或进程终止,所有未执行的定时器都会丢失。对于需要持久化的定时任务,建议结合数据库存储:

// 引入 long-timeout 库
import lt from 'long-timeout';
// 假设的数据库模块
import db from './database'; 

// 从数据库加载未完成的定时任务
async function loadPendingTasks() {
  const tasks = await db.getPendingTasks();
  
  tasks.forEach(task => {
    const now = Date.now();
    const delay = task.executeTime - now;
    
    if (delay > 0) {
      // 重新设置定时器
      const timeoutId = lt.setTimeout(async () => {
        await executeTask(task.id);
        await db.markTaskAsCompleted(task.id);
      }, delay);
      
      // 保存 timeoutId 以便后续可能的取消操作
      db.updateTaskTimeoutId(task.id, timeoutId);
    } else {
      // 任务已过期,基于业务和当前时刻来决定是否执行或取消
      // 如电商大促发送短信提醒用户
      
      // 这里简单假设任务已过期,直接执行
      await executeTask(task.id);
      await db.markTaskAsCompleted(task.id);
    }
  });
}

精确性考虑

虽然 long-timeout 成功解决了定时器时间范围的限制问题,但定时器的执行精度仍受 JavaScript 事件循环机制和系统调度的影响。在实际运行中,任务可能无法按照预设时间精准执行。

为了减少系统调度带来的误差,可在每次定时器触发时记录当前时间戳,并在回调函数中计算实际执行时间,以此对时间误差进行补偿。不过这种方法仅能缓解部分精度问题,无法完全消除误差。

对于对计时精度要求高的场景,long-timeout 可能无法满足需求。开发者可以通过以下方案来解决:

  • Web Workers:可在后台线程执行任务,不阻塞主线程,一定程度上能提升计时精度。不过存在通信开销大及实现复杂的问题。
  • Node.js 的 process.hrtime():提供高精度的时间测量,可用于需要精确计时的场景,结合适当的逻辑可实现较精确的定时任务。
  • 操作系统级定时任务:如 Linux 的 cron 或 Windows 的任务计划程序,借助系统层面的调度能力,能保证较高的计时精度,不过需要与应用程序进行交互集成。

替代方案与技术对比

除了 long-timeout 库外,还有其他几种处理超长定时任务的方法:

表格

方案 优点 缺点
long-timeout 库 API 友好,使用简单,轻量级 仅在应用运行期间有效,不支持持久化
自定义递归 setTimeout 不需要额外依赖 实现复杂,管理困难
Web Workers 不阻塞主线程 通信开销大,实现复杂
服务端定时任务 持久化,不受客户端限制 需要服务器资源,网络依赖
浏览器闹钟 API 系统级支持,应用关闭后仍可工作 浏览器兼容性问题,用户权限要求

Node 版本管理还在手动重装全局包?这个方案让你效率翻倍

前言

作为前端开发,你一定遇到过这样的场景:

  • 测试说:"能在 Node 16 下跑一下吗?"
  • 你切过去,发现常用的 CLI 工具全没了
  • 然后开始漫长的 npm install -g xxx 之旅

我统计了一下,自己平时全局装了 18 个包。手动重装一遍,保守估计要 5 分钟。如果一个月切换 10 次版本,就是 50 分钟

这篇文章分享一个更优雅的解决方案,让这个过程缩短到 30 秒

问题本质:全局包为什么会丢?

先理解一下底层原理。

当你用 nvmn 管理 Node 版本时,每个版本都有独立的安装目录:

~/.nvm/versions/node/v14.17.0/lib/node_modules/
~/.nvm/versions/node/v18.17.0/lib/node_modules/

切换版本 = 切换可执行文件路径 = 原来的全局包找不到了。

这是合理的设计,因为不同 Node 版本可能需要不同版本的包。但对开发者来说,确实增加了使用成本。

解决思路:自动化迁移

核心思路分两步:

第一步:保存全局包列表

npm list -g --depth=0 --json

这个命令返回 JSON 格式的全局包信息:

{
  "dependencies": {
    "@vue/cli": {
      "version": "5.0.8"
    },
    "typescript": {
      "version": "5.2.2"
    }
  }
}

把这个信息存到配置文件,就完成了备份。

第二步:批量安装

切换版本后,读取配置文件,循环安装:

for package in packages:
    npm install -g $package

工具实现:global-pack-sync

基于上面的思路,我实现了一个 CLI 工具。

快速开始

# 安装
npm install -g global-pack-sync

# 三步走
gps save      # 保存
nvm use 18    # 切换
gps restore   # 恢复

进阶用法

1. 多配置管理

可以为不同场景保存不同配置:

gps save work-env     # 工作环境
gps save side-project # 个人项目

# 按需恢复
gps restore work-env

2. 选择性安装

不想全部恢复?交互式选择:

gps select

> [x] typescript       # 要
  [ ] create-react-app # 不要
  [x] nodemon          # 要

3. 版本控制

默认安装最新版(推荐),也可以锁定版本:

gps restore --exact-version

性能优化

工具做了几个优化:

1. 并行安装

默认并发度为 3,可自定义:

gps restore --concurrency 5

测试数据(15 个包):

  • 串行:180 秒
  • 并发 3:65 秒
  • 并发 5:48 秒

2. 智能去重

已安装的包会自动跳过,避免重复安装。

3. 失败重试

网络抖动导致失败?自动生成重试脚本:

cat ~/.global-pack-sync/retry-failed.sh

#!/bin/bash
npm install -g package-that-failed

配置文件解析

配置存储在 ~/.global-pack-sync/packages.json

{
  "default": {
    "nodeVersion": "v18.17.0",
    "npmVersion": "9.6.7",
    "packageManager": "npm",
    "packages": {
      "@vue/cli": "5.0.8",
      "typescript": "5.2.2",
      "nodemon": "3.0.1"
    },
    "savedAt": "2025-01-15T10:30:00Z",
    "packagesCount": 15
  }
}

这个文件可以加入 Git,团队共享配置。

最佳实践

基于几个月的使用,总结几个经验:

1. 定期更新配置

装了新的全局包后,记得重新保存:

npm install -g new-package
gps save

2. 区分环境配置

工作和个人项目分开管理:

gps save company-tools
gps save personal-tools

3. 配置备份

定期备份配置文件:

cp ~/.global-pack-sync/packages.json ~/Dropbox/

4. 团队协作

团队统一工具链:

# Leader 保存
gps save team-standard

# 成员恢复
gps restore team-standard

兼容性说明

  • ✅ 支持 npm、yarn、pnpm
  • ✅ 支持 macOS、Linux、Windows
  • ✅ 支持 Node 14+
  • ✅ 自动检测包管理器类型

对比其他方案

方案 优点 缺点
手动记录 简单 易遗漏,费时
自定义脚本 灵活 维护成本高
Docker 环境隔离 本地开发较重
global-pack-sync 自动化、快速 需要安装工具

总结

切换 Node 版本是常见操作,但手动处理全局包迁移确实繁琐。使用自动化工具可以:

  • ⏱️ 节省时间:从 5 分钟到 30 秒
  • 🎯 减少遗漏:不会忘记某个包
  • 👥 团队协作:统一工具链配置
  • 🔄 提升效率:专注业务开发

如果你也有类似痛点,不妨试试这个工具。

项目地址: global-pack-sync


你平时怎么管理全局包的?欢迎评论区分享~

原文移步个人博客

❌