普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月8日技术

从一道前端面试题,谈 JS 对象存储特点和运算符执行顺序

2026年1月8日 20:44

本文大纲

今天来看一道前端面试的代码输出题。

面试官提供了一段 Javascript 代码,要求给出这段代码运行后的输出结果。

const obj = {
  a: 0,
};

obj['1'] = 0;
obj[++obj.a] = obj.a++;
const values = Object.values(obj);
obj[values[1]] = obj.a;
console.log(obj);

先分析这道题前,先补充两个个前置知识点。

知识点一:对象的常规属性 (properties) 和排序属性 (elements)

先来看下面一段代码:

const obj = {};
obj['b'] = 'b'
obj[100] = '100';
obj[1] = '1';
obj['a'] = 'a';
obj[50] = '50';
obj['c'] = 'c';

for (const key in obj){ 
  console.log(`key: ${key} value: ${obj[key]}`)
}
/**
 * 打印结果如下:
  key: 1 value: 1
  key: 50 value: 50
  key: 100 value: 100
  key: b value: b
  key: a value: a
  key: c value: c
 */

观察下打印的数据,很明显属性的设置顺序并不是打印的顺序,比如 b 属性是第一个设置的,打印结果却排在 100 后面,仔细分析他们的打印规律,可以得到如下特点:

  1. 数字类的属性不管设置的顺序先后,都会被优先打印,而且是按照从小到大的升序进行打印的。
  2. 字符类属性会按照设置的顺序进行打印,上面我们是按照 bac 的顺序进行设置的,打印顺序也是如此。

为什么会出现这样的结果呢?

这是因为 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。在 V8 中数字属性被称为 elements,字符串属性被称为 properties。之所以这样设置,主要是为了提升访问性能。elements 对象中会按照顺序存放数字属性,类似于数组的存储,这样查询的效率当然就很高了。

知识点二:JavaScript 运算符的执行顺序

JavaScript 中最常见运算符如下(运算顺序从高到低排列):

  • 对象属性访问,比如 obj.aobj['a']
  • 递增/递减,比如 a++(后置递增) 或 ++a(前置递增)。
  • 算术,比如 a + 1
  • 比较,包括 <(小于)、>(大于)、<=(小于等于)、>=(大于等于),比如 a > 3
  • 相等,包括 == 粗略相等, ===严格相等,!== 粗略不等,!== 严格不等,比如 a == 1
  • 逻辑,也就是与或非,&(与)、|(或)、!(非),比如 a && b
  • 三元,比如 flag ? '1' : '0'
  • 赋值,比如 a = 1

对于单个赋值语句 LHS = RHS 的求值,根据 ECMAScript 规范(ECMA-262, AssignmentExpression evaluation),对于 LeftHandSideExpression = AssignmentExpression,其计算顺序如下:

  • 第一步,先得到一个 引用(Reference),即“要写入的位置”,也就是 Evaluate LeftHandSideExpression
  • 第二步,再计算右侧的值,Evaluate AssignmentExpression
  • 最后将第二步得到的值写入第一步的引用当中。

也就是说,对于LHS = RHS 这样的赋值表达式,其计算顺序是左侧先求值(为了知道写到哪),右侧后求值(为了知道写什么),最后将右侧的值写入左侧。

📌 注意:这里说的“左侧求值”不是求它的值,而是求它的“位置”(比如属性名、变量名等)。例如对于 obj[++obj.a],要确定属性名,就必须先执行 ++obj.a

写个简单例子:

const obj = {
  get a() {
    console.log('获取 a 的值');
    return 1;
  },
  get b() {
    console.log('获取 b 的值');
    return 2;
  }
}
obj[++obj.a] = obj.b++;
console.log('obj: ', obj);

我们前面说过,运算符顺序是对象属性访问 > 递增/递减 > 赋值,对于赋值运算符 LHS = RHS,会先求出左侧 LHS引用(Reference),再计算右侧 RHS 的值,最后将右侧的值赋值给左侧的引用。所以用这个逻辑来分析下这段代码的执行顺序:

  • 第一步,先取 obj.a 的值, 取到的值为 1, 然后执行前置递增 ++obj.a,结果为 2,然后程序就知道要往 obj2 属性上赋值了。
  • 第二步,然后再取 obj.b 的值,执行后置递增 obj.b++,右侧计算的值为 2
  • 最后把右侧计算的结果值 2 赋值给 obj[2] 属性。

逐行分析代码执行过程

了解了这两个知识点后,让我们来逐行解析下这段代码。


const obj = {
  a: 0,
};

obj['1'] = 0;
obj[++obj.a] = obj.a++;
const values = Object.values(obj);
obj[values[1]] = obj.a;
console.log(obj);

  1. 首先,我们定义了一个对象 obj,它有一个属性 a,值为 0
const obj = {
  a: 0,
};
  1. 接下来,我们给 obj 添加了一个属性 1,值为 0,此时对象中有两个属性,a1
obj['1'] = 0;

由于数字属性会排在前面,此时 obj 的值为:

{
  "1": 0,
  "a": 0,
}
  1. 然后会执行 obj[++obj.a] = obj.a++,前面我们分析过运算符的优先级,先执行左侧 ++obj.a 得到 1,然后执行右侧 obj.a++, 由于是后置递增,所以右侧的值为 1,执行赋值后,obj 的值为:
{
  "1": 1,
  "a": 2,
}
  1. 经过 Object.values(obj) 后,values 的值为 [1, 2]
  2. 执行 obj[values[1]] = obj.a ,转换后就是 obj[2] = 2obj 的值变为:
{
  "1": 1,
  "2": 2,
  "a": 2,
}

这就是最终的输出结果了。

小结

该题主要考察两个知识点:

  1. JavaScript 对象中,属性的设置顺序并不一定是循环打印顺序,在 V8中,数字类的属性在被称为 elements(按从小到大排列存储),字符类属性被称为 properties(按添加顺序存储)。
  2. JavaScript 运算符的运算顺序是对象属性访问 > 递增/递减 > 赋值,对于赋值运算符 LHS = RHS,会先求出左侧 LHS引用(Reference),再计算右侧 RHS 的值,最后将右侧的值赋值给左侧的引用。

GDAL 空间关系解析

作者 GIS之路
2026年1月8日 20:08

^ 关注我,带你一起学GIS ^

前言

空间关系用于判断几何对象之间位置关系,GDAL支持相交、相离、包含等多种空间关系。在GIS开发中,空间分析离不开准确的空间关系判断,选择何种空间关系,对数据查询和分析速度的影响也会有差异,所以,熟练掌握各种空间关系是很有必要的。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL 空间关系解析

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 判断几何关系

定义一个方法SpatialRelations用于判断要素间空间关系,该方法接收两个参数,其中polygonPath指向面文件路径,pointPath指向点文件路径。

"""
说明:判断空间关系
参数:
    -polygonPath:面图层数据路径
    -pointPath:点图层数据路径
"""
def SpatialRelations(polygonPath,pointPath):

对于数据的基础操作步骤为获取数据驱动,获取数据源,再到获取目标图层。

# 检查文件是否存在
checkFilePath(polygonPath)
checkFilePath(pointPath)

# 添加数据驱动
shpDriver = ogr.GetDriverByName("ESRI Shapefile")

# 获取数据源
polygonDs = shpDriver.Open(polygonPath)
pointDs = shpDriver.Open(pointPath)

# 获取图层
polygonLayer = polygonDs.GetLayer(0)
pointLayer = pointDs.GetLayer(0)

方法checkFilePath用于检查文件路径是否正常,接受一个路径参数。

"""
说明:检查文件路径是否正常
参数:
    -filePath:文件数据路径
"""
def checkFilePath(filePath):
    if os.path.exists(filePath):
        print("文件数据路径存在")
    else:
        print("文件数据路径不存在,请检查!")

本文基于以下数据进行空间关系判断。

3. 相交关系

通过GetNextFeature或者GetFeature方法获取要素,其中GetFeature方法需要传入指定FId参数。

    # 遍历面图层要素
    for polyFeature in polygonLayer:
        # 获取几对象
        polyGeom = polyFeature.GetGeometryRef()
        # 获取要素Id
        featId = polyFeature.GetField("Id")
        print(f"n面状要素~~~~~Id:{featId}n")

        # 获取几何要素
        polyGeom = polyFeature.GetGeometryRef()
        # print(f"面状几何:{polyGeom}")

        # 遍历点图层要素
        for ptFeature in pointLayer:
            # 获取几何对象
            ptGeom = ptFeature.GetGeometryRef()
            # print(f"点状几何:{ptGeom1}")

            # 获取要素名称
            featName = ptFeature.GetField("name")   

            # 相交
            result = polyGeom.Intersect(ptGeom)

            description"不相交" if result == False else "^^😊相交😊^^"

            print(f"面要素【{featId}】与点要素【{featName}】 是否相交:{result} ({description})") 

其中相交关系输出结果显示如下,与图形显示结果一致。

其中相离关系输出结果显示如下,与图形显示结果一致。

其中相接关系输出结果显示如下,与图形显示结果一致。

还有包含(Contains)、重叠(Overlaps)、在...之内(Within) 等空间关系,留给感兴趣的读者自行实现。

4. 注意事项

windows开发环境中同时安装GDALPostGIS,其中投影库PROJ的环境变量指向PostGIS的安装路径,在运行GDAL程序时,涉及到要素、几何与投影操作时会导致异常。具体意思为GDAL不支持PostGIS插件中的投影库版本,需要更换投影库或者升级版本。

RuntimeError: PROJ: proj_identify: D:Program FilesPostgreSQL13sharecontribpostgis-3.5projproj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected. It comes from another PROJ installation.

解决办法为修改PROJ的环境变量到GDAL支持的版本或者在GDAL程序开头添加以下代码:

os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Libsite-packages\osgeo\data\proj'

图片效果

OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现数据空间查询

GDAL 实现数据属性查询

GDAL 实现创建几何对象

GDAL 实现自定义数据坐标系

GDAL 实现矢量数据读写

GDAL 数据类型大全

GDAL 实现 GIS 数据读取转换(全)

WebAssembly入门(一)——Emscripten

2026年1月8日 19:30

WebAssembly介绍

WebAssembly,简单来说,是一种浏览器可执行的代码格式,诸如 C、C++ 和 Rust 等源语言能够编译为WebAssembly代码,并暴露可供js调用的函数,使得js能够利用接近原生的速度完成一些逻辑。

关于WebAssembly的介绍,MDN已经足够详细了,developer.mozilla.org/zh-CN/docs/… ,不再赘述。

Emscripten 介绍和安装

Emscripten 是一个基于 LLVM/Clang 的开源编译器工具链,核心是将 C/C++ 等原生代码编译为 WebAssembly(Wasm)和 JavaScript “胶水代码”,让高性能原生程序与库能在浏览器、Node.js 及其他 Wasm 运行时高效执行,性能接近原生,是原生代码向 Web 迁移的核心工具。更多信息可访问官网:emscripten.org/docs/index.…

安装

# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git

# Enter that directory
cd emsdk
# Fetch the latest version of the emsdk (not needed the first time you clone)
git pull

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
source ./emsdk_env.sh

注:windows系统用emsdk.bat替换上述命令中的emsdk,用emsdk_env.bat替换上述命令中的source ./emsdk_env.sh

安装验证

在 Emscripten 的安装目录下打开终端,执行以下命令检查是否安装成功

./emcc --check

注:

  • windows系统,通过在文件中找到并打开emcmdprompt.bat来启动该提示符,在其他命令行中是找不到emcc的。
  • 在 Windows 系统中,使用emcc而不是 ./emcc来调用该工具。

安装成功后check的输出如下: image.png

入门实践

hello world

准备基础测试文件:

// test.c
#include <stdio.h>
int main() {
    printf("Hello from Emscripten!\n");
    return 0;
}

执行编译

emcc test.c -o test.html # 生成test.html + test.js + test.wasm

启动本地服务器,打开test.html

image.png

生成文件介绍

test.html

test.html 是 Emscripten 自动生成的完整 HTML 页面,本质是一个开箱即用的前端载体,核心作用是:

  • 作为 Wasm/JS 代码的运行容器:内置了加载 test.js(胶水代码)的逻辑,无需你手动写 HTML 引入脚本;
  • 提供基础的交互界面:默认包含一个简单的页面结构,以及控制台输出区域(用于显示 C 代码中 printf/puts 等函数的输出);
  • 简化测试流程:直接在本地服务器打开就可以运行,无需额外编写 HTML 代码。
test.js

test.js 是整个编译产物的核心,也是 Emscripten 最关键的输出文件之一,被称为 “胶水代码”,作用是连接 JavaScript 环境和 Wasm 模块,填补两者的差异。

它包含以下核心功能(按执行顺序):

(1)Wasm 模块的加载与实例化

自动处理 test.wasm(编译时同步生成的 Wasm 文件)的加载、解析和实例化,无需你手动调用 WebAssembly.instantiate

(2)Emscripten 运行时初始化

初始化 Wasm 运行所需的环境:

  • 线性内存(Memory)管理;
  • 虚拟文件系统(MEMFS/IDBFS)(模拟 C 的文件操作,如 fopen/fwrite);
  • 系统调用模拟(如 exit/time);
  • 错误处理和日志输出。

(3)C/JS 互操作桥梁

  • 封装 C 函数的调用逻辑:把 Wasm 中的函数(如下划线前缀的 _main)映射为更友好的 JS 调用方式;
  • 处理 C 标准库输出:把 C 代码中 printf/puts 的输出重定向到 HTML 页面的控制台区域;
  • 暴露运行时 API:如 ccall/cwrap(用于 JS 调用 C 函数)、FS(文件系统操作)等。

(4)自动执行 C 的 main 函数

默认情况下,test.js 初始化完成后会自动调用 C 代码中的 main 函数,这也是为什么你编译的 C 程序能直接在浏览器中运行。

在实践中,我们不一定需要使用编译生成的胶水代码,很多时候自己手动管理wasm更方便。

test.wasm

执行 C 代码编译后的指令(如 main 函数、sum 函数)

js调用wasm函数

这个demo中我们不生成胶水代码,使用原生api调用wasm。

准备c文件:

// calc.c
int sum(int a, int b)
{
    return a + b;
}
int minus(int a, int b)
{
    return a - b;
}

执行编译

emcc calc.c -o calc.wasm  -s EXPORTED_FUNCTIONS=_sum,_minus --no-entry

编写html和js加载wasm

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>WASM Calc Demo</title>
  </head>
  <body>
    <h1>WASM Calculator</h1>
    <div id="output"></div>
    <script>
      (async () => {
        // 加载 wasm 文件
        const response = await fetch("calc.wasm");
        const buffer = await response.arrayBuffer();
        const wasmModule = await WebAssembly.instantiate(buffer);

        // 导出的方法都在 instance.exports 里
        const { sum, minus } = wasmModule.instance.exports;
        console.log(wasmModule);
        // 调用并显示结果
        const a = 7,
          b = 3;
        document.getElementById("output").innerHTML = `
        sum(${a}, ${b}) = ${sum(a, b)}<br>
        minus(${a}, ${b}) = ${minus(a, b)}
      `;
      })();
    </script>
  </body>
</html>

效果如下: image.png

注:

  • 在 C 语言的 ABI(应用二进制接口)规范里,大多数平台的链接器会为 C 全局符号加上一个下划线 _ 前缀
    Emscripten 也遵循了这个传统。你写的 int sum(int a, int b),编译后实际导出的符号是 _sum
  • 一般情况下编译时会找main函数,如果不需要main,可以加参数--no-entry,否则编译会报错
  • EXPORTED_FUNCTIONS参数不是必须,编译时可以根据文件内容生成导出的函数。但如果函数既没有被 C 代码调用,也没有被正确导出,Emscripten 可能会认为它没用,直接去掉它。因此可以修改代码,添加EMSCRIPTEN_KEEPALIVE
// calc.c
#include <emscripten/emscripten.h>

EMSCRIPTEN_KEEPALIVE
int sum(int a, int b)
{
    return a + b;
}
EMSCRIPTEN_KEEPALIVE
int minus(int a, int b)
{
    return a - b;
}

执行emcc calc.c -o calc.wasm -s --no-entry一样能得到两个函数

工作原理概览和LLVM/Clang介绍

Emscripten 的编译流程如下:

  1. 前端编译:Clang 将 C/C++ 源码编译为 LLVM IR。
  2. 中间优化:LLVM 与 Binaryen 对 IR 做代码精简、循环优化、死代码消除等。
  3. 后端生成:输出 Wasm 二进制模块(.wasm)与 JavaScript 胶水代码(处理 Wasm 加载、内存管理、API 绑定),可选直接生成可运行的 HTML。
C/C++ 源码
   ↓
ClangLLVM 前端) → 生成 LLVM IRLLVM 优化器 → 优化 LLVM IRLLVM 后端 → 生成 原始 WebAssembly 模块
   ↓
Binaryen(wasm-opt) → 优化 Wasm 
   ↓
最终输出:优化后的 .wasm 

这里出现了一些名词:前端/后端/Clang/LLVM/IR,下面简单介绍下这些概念。

1. IR(Intermediate Representation,中间表示)

IR 是编译器在前端(源码解析)后端(目标代码生成) 之间的 “中间语言”,是连接不同源码语言和不同目标平台的桥梁。核心特点如下:

  • 与源码语言无关:不管是 C/C++、Rust、Go 还是 Swift,只要能被 LLVM 前端编译,最终都会转换成统一的 IR。
  • 与目标平台无关:IR 不包含任何 CPU 架构、操作系统的特有指令,只描述 “计算逻辑”。
  • 分层设计:LLVM IR 分为 LLVM IR(文本 / 二进制形式) 和更底层的 Machine IR,前者用于跨平台优化,后者用于针对具体架构生成机器码。

2. LLVM(Low Level Virtual Machine)

LLVM(Low Level Virtual Machine)是一个开源的编译器基础设施项目,
它本质上是一套编译器前端和后端的集合,支持多种编程语言和目标平台。

  • 前端:如 Clang,把 C/C++ 转成 LLVM IR(中间表示)
  • 后端:把 LLVM IR 转成目标机器码(如 x86、ARM、WebAssembly)

Clang

Clang 是 LLVM 项目的一个核心子组件,本质是 LLVM 生态的C/C++/Objective-C 前端编译器,二者是框架与组件的关系 ——LLVM 提供通用的编译基础设施,Clang 负责将 C 系源码转换成 LLVM IR,再由 LLVM 的优化器和后端完成后续流程。

3. Binaryen

Binaryen 是一个专注于 WebAssembly(WASM)的工具链库和优化器
由 WebAssembly 核心团队成员开发和维护(主要用 C++ 实现,带有 JS/Python 接口)。
它的目标是让 WebAssembly 代码更小、更快、更高效

分享一下思考过程 C++

作者 Time-Limit
2020年5月24日 14:23
  • 知识点:动态规划
  • 时间复杂度:O(n*m);n,m 分别为两个序列的长度。

动态规划题目一般要先想好两个问题:

  • 状态如何定义。
  • 状态之间如何转移。

对于该题,最终目标是在分别两个序列中选取等长且非空的子序列,使得两个子序列的点积最大。
即,在 nums1 的前 n 个数中,在 nums2 的前 m 个数字中分别选取等长且非空的子序列, 使其点积最大。
推而广之,我们可以将问题表示为,在nums1的前 x (x <= n)个数字中,在nums2的前y(y <= m)个数字中,分别选取等长且非空的子序列, 使其点积最大。
为了方便,我们用 f(x, y) 表示子问题的最优方案的点积。
当(x, y) = (n,m) 时,f(x,y) 就是最终答案。

状态转移主要是分析状态之间的关联或差异,利用小问题的解高效的构造大问题的解。
来思考下该题状态如何转移,该题的特点是小问题总是为大问题的前缀,总是可以向小问题中追加数字得到一个大问题。
设 nx = nums1[x],ny = nums2[y]。
f(x,y) 可能由以下几个状态转移得到:

  • 向 f(x-1, y-1) 追加 nx,ny 获得 f(x, y)。
  • 向 f(x, y-1) 追加 ny 获得 f(x, y)。
  • 向 f(x-1, y) 追加 nx 获得 f(x,y)。

当然,也可以同时追加多个数字,由更小的问题获得 f(x, y),但这本质上还是通过上述三种子问题间接转移过来的。
那么,为何f(x-1,y-1) 不能用 f(x-1, y) 或者 f(x, y-1) 间接转移过来呢?因为在求解过程中要考虑nx 和 ny 在对应位置的情况。

总结一下,该题的状态方程如下:
$$
f(x,y) = max \left{ \begin{array}{c}
nxny&, 有且只有 nx,ny\
nx
ny + f(x-1, y-1)&, 包含 nx,ny \
f(x, y-1)&, 不包含 nx \
f(x-1, y)&, 不包含 ny \
f(x-1, y-1)&, 不包含 nx,ny\
\end{array}\right.
$$

###cpp

class Solution {
public:
    int maxDotProduct(vector<int>& nums1, vector<int>& nums2) {
        int dp[501][501];
        for(int i = 0; i <= nums1.size(); i++) {
            for(int j = 0; j <= nums2.size(); j++) {
                dp[i][j] = -1000*1000*500;
            }
        }
        for(int i = 1; i <= nums1.size(); i++) {
            for(int j = 1; j <= nums2.size(); j++) {
                int a = nums1[i-1];
                int b = nums2[j-1];
                dp[i][j] = max(dp[i][j], a*b);
                dp[i][j] = max(dp[i][j], dp[i-1][j-1] + a*b);
                dp[i][j] = max(dp[i][j], dp[i-1][j-1]);
                dp[i][j] = max(dp[i][j], dp[i-1][j]);
                dp[i][j] = max(dp[i][j], dp[i][j-1]);
            }
        }
        return dp[nums1.size()][nums2.size()];
    }
};

如果感觉有点意思,可以关注👏HelloNebula👏

  • 分享周赛题解
  • 分享计算机专业课知识
  • 分享C++相关岗位面试题
  • 分享专业书籍PDF

[wllama]纯前端实现大语言模型调用:在浏览器里跑 AI 是什么体验。以调用腾讯 HY-MT1.5 混元翻译模型为例

作者 Electrolux
2026年1月8日 18:49

前言

🌐 在线演示: mvp-ai-wllama.vercel.app/

🔗 GitHub仓库: mvp-ai-wllama

效果展示

所有操作均在浏览器进行,先来看看最终效果:

纯前端.jpg

说实话,第一次听说要在浏览器里跑大语言模型的时候,我的第一反应是:这怎么可能?不是需要 GPU 服务器吗?不是需要后端 API 吗?

但事实证明,wllama 的出现,真的让这一切变成了可能。于是就有了这个项目——一个完全在浏览器里运行的 AI 推理方案,不需要服务器,不需要后端,打开网页就能用。

腾讯混元翻译模型示例

作为实际应用示例,本项目支持加载和运行 腾讯混元翻译模型(HY-MT1.5-1.8B-GGUF),这是一个专为多语言翻译任务设计的轻量级模型

模型特点

  • 🌍 多语言支持:支持 36 种语言的翻译任务
  • 💬 对话式翻译:采用对话式交互,提供更自然的翻译体验
  • 📦 多种量化版本:提供从 Q2_K(777MB)到 f16(3.59GB)的多种量化版本,满足不同性能和精度需求
  • 轻量高效:1.8B 参数量,在保证翻译质量的同时,大幅降低了计算和存储需求

量化版本选择建议

  • Q2_K(777MB):适合快速测试和资源受限环境
  • Q4_K_M(1.13GB):平衡质量和性能的推荐选择
  • Q5_K_M(1.3GB):更高精度的翻译质量
  • Q8_0(1.91GB):接近原始精度的最佳选择

step1: 下载模型

step2: 打开 mvp-ai-wllama.vercel.app/wllama/mana… 导入模型, 在 mvp-ai-wllama.vercel.app/wllama/load… 中就可以直接使用了(无视框架,只要能执行js就能够调用)

为什么要做这个

传统的 AI 模型推理,你懂的:

  • 得搞个 GPU 服务器,成本不低
  • 后端服务部署,运维头疼
  • 数据要传到服务器,隐私总让人担心
  • 持续的服务成本,小项目根本玩不起

而浏览器端推理就不一样了:

  • 用户的电脑就是"服务器",零成本
  • 数据完全本地处理,隐私安全
  • 离线也能用,体验更好
  • 部署简单,一个静态页面就能搞定

所以,为什么不试试呢?

核心功能演示

  • 本地模型加载:支持从本地文件直接加载 GGUF 模型
  • 远程模型下载:从 URL 下载模型并自动缓存到 IndexedDB
  • 缓存管理:完整的模型缓存管理系统,支持导入、导出、删除
  • 流式生成:实时流式输出 AI 生成内容
  • 多线程支持:自动检测并使用多线程模式提升性能
  • 多实例支持:支持同时运行多个独立的模型实例,每个实例可加载不同模型
  • 参数持久化:推理参数自动保存到 localStorage
  • 事件驱动:完整的事件系统,支持监听模型加载、生成等事件
  • 类型安全:完整的 TypeScript 类型定义
  • PWA 支持:完整的渐进式 Web 应用支持,可安装到设备,支持离线使用

技术架构

核心技术栈

  • React 19 + Next.js 15:现代化前端框架
  • @wllama/wllama:基于 WebAssembly 的 Llama 模型运行时
  • WebAssembly (WASM):高性能模型推理引擎
  • TypeScript:类型安全的开发体验
  • IndexedDB:模型文件缓存系统
  • EventEmitter:事件驱动的架构设计
  • localStorage:推理参数持久化存储

tip: 事实上核心库 wllama-core 不依赖于 React,你可以拿到项目中的 src/wllama-core,然后接入到任何系统中去,接入层可以参考 src/app/wllama/load-from-file/page.tsx 等应用层文件

架构流程图

用户选择模型
    ↓
React组件层
    ↓
WllamaCore (核心封装层)
    ↓
@wllama/wllama (WASM运行时)
    ↓
WebAssembly引擎
    ↓
GGUF模型文件
    ↓
IndexedDB缓存
    ↓
流式生成输出

WASM模型推理核心流程

模型加载流程图解

用户选择模型文件/URL
    ↓
检查缓存(如从URL加载)
    ↓
缓存命中 → 从IndexedDB读取
缓存未命中 → 下载/读取文件
    ↓
加载到WASM内存
    ↓
初始化模型参数
    ↓
模型就绪,可开始推理

核心代码实现

// src/wllama-core/wllama-core.ts

/**
 * WllamaCore - 核心封装类,提供简洁的API
 */
export class WllamaCore {
  private wllama: Wllama;
  private isModelLoaded: boolean = false;
  private inferenceParams: InferenceParams;

  /**
   * 从文件加载模型
   */
  async loadModelFromFiles(
    files: File[],
    options?: LoadModelOptions
  ): Promise<void> {
    if (this.isModelLoaded || this.isGenerating) {
      throw new Error('Another model is already loaded or generation is in progress');
    }

    this.emit(WllamaCoreEvent.MODEL_LOADING);
    
    try {
      const loadOptions = {
        n_ctx: options?.n_ctx ?? this.inferenceParams.nContext,
        n_batch: options?.n_batch ?? this.inferenceParams.nBatch,
        n_threads: options?.n_threads ?? (this.inferenceParams.nThreads > 0 
          ? this.inferenceParams.nThreads 
          : undefined),
      };

      await this.wllama.loadModel(files, loadOptions);

      // 获取模型元数据
      const metadata = this.wllama.getModelMetadata();
      this.modelMetadata = {
        name: metadata.meta['general.name'] || 
              metadata.meta['llama.context_length'] || 
              files[0].name.replace('.gguf', ''),
        ...metadata.meta,
      };

      this.isModelLoaded = true;
      this.emit(WllamaCoreEvent.MODEL_LOADED, {
        metadata: this.modelMetadata,
        runtimeInfo: this.runtimeInfo,
      });
    } catch (error) {
      this.resetInstance();
      const errorMsg = (error as Error)?.message ?? 'Unknown error';
      this.emit(WllamaCoreEvent.ERROR, errorMsg);
      throw new Error(errorMsg);
    }
  }

  /**
   * 从URL加载模型(支持自动缓存)
   */
  async loadModelFromUrl(
    url: string,
    options?: LoadModelOptions & { 
      useCache?: boolean;
      downloadOptions?: DownloadOptions;
    }
  ): Promise<void> {
    const useCache = options?.useCache !== false; // 默认启用缓存

    try {
      let file: File;

      // 检查缓存
      if (useCache) {
        const cachedFile = await cacheManager.open(url);
        if (cachedFile) {
          this.logger?.log('Loading model from cache:', url);
          file = cachedFile;
        } else {
          // 下载并缓存
          this.logger?.log('Downloading and caching model:', url);
          await cacheManager.download(url, options?.downloadOptions);
          const downloadedFile = await cacheManager.open(url);
          if (!downloadedFile) {
            throw new Error('Failed to open cached file after download');
          }
          file = downloadedFile;
        }
      } else {
        // 直接下载,不使用缓存
        const response = await fetch(url, {
          headers: options?.downloadOptions?.headers,
          signal: options?.downloadOptions?.signal,
        });
        const blob = await response.blob();
        const fileName = url.split('/').pop() || 'model.gguf';
        file = new File([blob], fileName, { type: 'application/octet-stream' });
      }

      await this.loadModelFromFiles([file], options);
    } catch (error) {
      // 错误处理...
    }
  }
}

缓存管理系统:IndexedDB实现

项目采用 IndexedDB 实现模型文件的持久化缓存,支持大文件存储和快速检索:

// src/wllama-core/cache-manager.ts

/**
 * CacheManager - 基于 IndexedDB 的缓存管理器
 */
export class CacheManager {
  /**
   * 从URL下载并缓存模型文件
   */
  async download(url: string, options: DownloadOptions = {}): Promise<void> {
    const filename = await urlToFileName(url);

    const response = await fetch(url, {
      headers: options.headers,
      signal: options.signal,
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch: ${response.statusText}`);
    }

    // 流式读取并显示进度
    const reader = response.body.getReader();
    const chunks: Uint8Array[] = [];
    let loaded = 0;
    const contentLength = response.headers.get('content-length');
    const total = contentLength ? parseInt(contentLength, 10) : 0;

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      loaded += value.length;
      if (options.progressCallback && total > 0) {
        options.progressCallback({ loaded, total });
      }
    }

    const blob = new Blob(chunks as BlobPart[]);
    const db = await getDB();

    // 存储到 IndexedDB
    const cachedFile: CachedFile = {
      blob,
      originalURL: url,
      createdAt: Date.now(),
      etag: response.headers.get('etag') || undefined,
      contentType: response.headers.get('content-type') || undefined,
    };

    return new Promise((resolve, reject) => {
      const transaction = db.transaction([STORE_FILES], 'readwrite');
      const fileStore = transaction.objectStore(STORE_FILES);
      fileStore.put(cachedFile, filename);
      transaction.oncomplete = () => resolve();
      transaction.onerror = () => reject(transaction.error);
    });
  }

  /**
   * 从缓存打开文件
   */
  async open(nameOrURL: string): Promise<File | null> {
    const db = await getDB();
    let fileName = nameOrURL;

    // 尝试直接使用名称
    try {
      const file = await this.getFileFromDB(db, fileName);
      if (file) return file;
    } catch {
      // 尝试将URL转换为文件名
      try {
        fileName = await urlToFileName(nameOrURL);
        const file = await this.getFileFromDB(db, fileName);
        if (file) return file;
      } catch {
        return null;
      }
    }

    return null;
  }

  /**
   * 列出所有缓存文件
   */
  async list(): Promise<CacheEntry[]> {
    const db = await getDB();
    const allFiles = await this.getAllFiles(db);
    const result: CacheEntry[] = [];

    for (const [fileName, cachedFile] of Object.entries(allFiles)) {
      const metadata: CacheEntryMetadata = {
        originalURL: cachedFile.originalURL || fileName,
      };
      
      // 复制其他元数据字段
      Object.keys(cachedFile).forEach(key => {
        if (key !== 'blob' && key !== 'originalURL') {
          metadata[key] = (cachedFile as any)[key];
        }
      });
      
      result.push({
        name: fileName,
        size: cachedFile.blob.size,
        metadata,
      });
    }

    return result;
  }
}

关键特性

  • URL哈希映射:使用 SHA-1 哈希将 URL 转换为唯一文件名
  • 进度回调:支持下载进度实时反馈
  • 元数据扩展:可扩展的元数据结构,支持 ETag、创建时间等
  • 浏览器兼容:支持所有现代浏览器,包括较旧版本

事件驱动架构:EventEmitter设计

项目采用事件系统,实现组件间的松耦合通信:

// src/wllama-core/wllama-core.ts

export enum WllamaCoreEvent {
  MODEL_LOADING = 'model_loading',      // 模型加载中
  MODEL_LOADED = 'model_loaded',        // 模型加载完成
  MODEL_UNLOADED = 'model_unloaded',    // 模型已卸载
  GENERATION_START = 'generation_start', // 生成开始
  GENERATION_UPDATE = 'generation_update', // 生成更新
  GENERATION_END = 'generation_end',     // 生成结束
  ERROR = 'error',                       // 错误
}

export class WllamaCore {
  private eventListeners: Map<WllamaCoreEvent, Set<EventListener>> = new Map();

  /**
   * 注册事件监听器
   */
  on(event: WllamaCoreEvent, listener: EventListener) {
    if (!this.eventListeners.has(event)) {
      this.eventListeners.set(event, new Set());
    }
    this.eventListeners.get(event)!.add(listener);
  }

  /**
   * 移除事件监听器
   */
  off(event: WllamaCoreEvent, listener: EventListener) {
    this.eventListeners.get(event)?.delete(listener);
  }

  /**
   * 触发事件
   */
  private emit(event: WllamaCoreEvent, data?: unknown) {
    const listeners = this.eventListeners.get(event);
    if (listeners) {
      listeners.forEach((listener) => listener(data));
    }
  }
}

支持的事件类型

  • model_loading - 模型加载中
  • model_loaded - 模型加载完成
  • model_unloaded - 模型已卸载
  • generation_start - 生成开始
  • generation_update - 生成更新(流式输出)
  • generation_end - 生成结束
  • error - 错误事件

多实例事件系统

在多实例模式下,所有事件数据都包含 instanceId 字段,用于区分不同实例的事件:

// 监听特定实例的事件
instance1.on(WllamaCoreEvent.MODEL_LOADED, (data: any) => {
  console.log('实例1模型已加载:', data.instanceId);
  console.log('模型元数据:', data.metadata);
});

// 监听所有实例的事件,通过 instanceId 区分
const handleUpdate = (data: { data: string; instanceId: string }) => {
  if (data.instanceId === 'chat-1') {
    console.log('聊天1更新:', data.data);
  } else if (data.instanceId === 'chat-2') {
    console.log('聊天2更新:', data.data);
  }
};

instance1.on(WllamaCoreEvent.GENERATION_UPDATE, handleUpdate);
instance2.on(WllamaCoreEvent.GENERATION_UPDATE, handleUpdate);

事件数据结构

// 所有事件数据都包含 instanceId
interface BaseEventData {
  instanceId: string;
}

// 模型加载事件
interface ModelLoadedEventData extends BaseEventData {
  metadata: ModelMetadata;
  runtimeInfo: RuntimeInfo;
}

// 生成更新事件
interface GenerationUpdateEventData extends BaseEventData {
  data: string;
}

核心功能特性

1. 多种模型加载方式

支持三种模型加载方式,满足不同使用场景:

import { WllamaCore, WLLAMA_CONFIG_PATHS } from '@/wllama-core';

const wllamaCore = new WllamaCore({ paths: WLLAMA_CONFIG_PATHS });

// 方式1: 从本地文件加载
const files = [/* File 对象 */];
await wllamaCore.loadModelFromFiles(files, {
  n_ctx: 4096,
  n_batch: 128,
});

// 方式2: 从URL加载(自动缓存)
await wllamaCore.loadModelFromUrl('https://example.com/model.gguf', {
  n_ctx: 4096,
  useCache: true, // 默认启用
  downloadOptions: {
    progressCallback: (progress) => {
      console.log(`下载进度: ${progress.loaded}/${progress.total}`);
    },
  },
});

// 方式3: 从缓存加载
import { cacheManager } from '@/wllama-core';
const cachedFile = await cacheManager.open('https://example.com/model.gguf');
if (cachedFile) {
  await wllamaCore.loadModelFromFiles([cachedFile], { n_ctx: 4096 });
}

2. 流式生成支持

支持实时流式输出,提供流畅的用户体验:

const result = await wllamaCore.createChatCompletion(messages, {
  nPredict: 4096,
  useCache: true,
  sampling: { temp: 0.2 },
  onNewToken(token, piece, currentText, opts) {
    // 实时更新UI
    setMessages(prev => {
      const updated = [...prev];
      updated[updated.length - 1].content = currentText;
      return updated;
    });
    
    // 可以随时停止生成
    // opts.abortSignal();
  },
});

3. 参数持久化

推理参数自动保存到 localStorage,下次使用时自动恢复:

// 设置参数(自动保存)
wllamaCore.setInferenceParams({
  nContext: 8192,
  temperature: 0.7,
  nPredict: 2048,
});

// 获取参数
const params = wllamaCore.getInferenceParams();
console.log(params);
// {
//   nThreads: -1,
//   nContext: 8192,
//   nBatch: 128,
//   temperature: 0.7,
//   nPredict: 2048
// }

4. 多线程支持

自动检测并使用多线程模式,大幅提升推理性能:

// src/middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // 启用 SharedArrayBuffer 支持(多线程所需)
  response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
  response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
  
  return response;
}

注意事项

  • 必须在 HTTPS 环境下运行(或 localhost)
  • 需要浏览器支持 SharedArrayBuffer
  • 设置响应头后需要重启开发服务器

5. 多实例支持

支持创建和管理多个独立的 WllamaCore 实例,每个实例可以加载不同的模型,独立进行推理:

import { wllamaCoreFactory, WLLAMA_CONFIG_PATHS, Message, WllamaCoreEvent } from '@/wllama-core';

// 创建多个实例
const instance1 = wllamaCoreFactory.getOrCreate('chat-1', { paths: WLLAMA_CONFIG_PATHS });
const instance2 = wllamaCoreFactory.getOrCreate('chat-2', { paths: WLLAMA_CONFIG_PATHS });

// 每个实例可以加载不同的模型
await instance1.loadModelFromUrl('https://example.com/model1.gguf');
await instance2.loadModelFromUrl('https://example.com/model2.gguf');

// 独立进行推理
const messages1: Message[] = [{ role: 'user', content: '你好' }];
const messages2: Message[] = [{ role: 'user', content: 'Hello' }];

const [result1, result2] = await Promise.all([
  instance1.createChatCompletion(messages1),
  instance2.createChatCompletion(messages2),
]);

// 监听不同实例的事件(事件数据包含 instanceId)
instance1.on(WllamaCoreEvent.MODEL_LOADED, (data: any) => {
  console.log('实例1模型已加载:', data.instanceId);
});

instance2.on(WllamaCoreEvent.GENERATION_UPDATE, (data: any) => {
  console.log('实例2生成更新:', data.data, '实例ID:', data.instanceId);
});

// 获取所有实例
const allInstances = wllamaCoreFactory.getAll();
console.log(`当前有 ${allInstances.size} 个实例`);

// 销毁指定实例
await wllamaCoreFactory.destroy('chat-1');

// 销毁所有实例
await wllamaCoreFactory.destroyAll();

关键特性

  • 实例隔离:每个实例的推理参数存储在独立的 localStorage 键中(格式:params-{instanceId}
  • 事件隔离:每个实例的事件监听器独立,事件数据包含 instanceId 用于区分
  • 资源管理:通过工厂类统一管理所有实例,支持获取、创建、销毁等操作
  • 向后兼容:原有的直接创建 WllamaCore 实例的方式仍然支持

6. PWA 支持

项目完整支持渐进式 Web 应用(PWA),用户可以像原生应用一样安装和使用:

核心特性

  • 可安装性:支持添加到主屏幕,提供原生应用体验
  • 离线支持:通过 Service Worker 实现离线访问
  • 智能缓存:自动缓存应用资源,提升加载速度
  • 自动更新:Service Worker 自动检测并更新应用

manifest.json 配置

{
  "name": "MVP AI Wllama",
  "short_name": "Wllama",
  "description": "AI Wllama Application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ]
}

Service Worker 实现

项目实现了智能的 Service Worker,支持:

  • 资源缓存:自动缓存应用页面和静态资源
  • 离线回退:网络不可用时使用缓存内容
  • 后台更新:后台自动更新缓存,不阻塞用户操作
  • 快速失败:网络请求超时快速失败,避免长时间等待
// public/sw.js

const CACHE_NAME = 'wllama-cache-v1';

// 安装时立即激活
self.addEventListener('install', () => self.skipWaiting());

// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
    ).then(() => self.clients.claim())
  );
});

// 拦截网络请求,实现缓存策略
self.addEventListener('fetch', (event) => {
  // 缓存优先策略:优先使用缓存,后台更新
  event.respondWith(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      const cached = await cache.match(event.request);
      
      if (cached) {
        // 有缓存:立即返回,后台更新
        event.waitUntil(
          fetch(event.request).then((res) => {
            if (res?.status === 200) {
              return cache.put(event.request, res.clone());
            }
          }).catch(() => {})
        );
        return cached;
      }
      
      // 无缓存:网络请求
      try {
        const res = await fetch(event.request);
        if (res?.status === 200) {
          event.waitUntil(cache.put(event.request, res.clone()));
        }
        return res;
      } catch {
        // 网络失败:返回缓存或空响应
        return cached || new Response('', { status: 503 });
      }
    })()
  );
});

Service Worker 管理

项目提供了智能的 Service Worker 管理组件,只在 PWA 环境下注册:

// src/components/ServiceWorkerManager.tsx

export default function ServiceWorkerManager({ swPath = '/sw.js' }) {
  useEffect(() => {
    if (!('serviceWorker' in navigator)) return;

    const isPWA = () => {
      return window.matchMedia('(display-mode: standalone)').matches ||
             window.matchMedia('(display-mode: minimal-ui)').matches ||
             (window.navigator as any).standalone === true;
    };

    const checkAndManageSW = async () => {
      const existingReg = await navigator.serviceWorker.getRegistration();
      const currentIsPWA = isPWA();

      // 只在 PWA 环境注册 Service Worker
      if (currentIsPWA && !existingReg) {
        const reg = await navigator.serviceWorker.register(swPath);
        console.log('Service Worker 注册成功(PWA 环境)');
      } else if (!currentIsPWA && existingReg) {
        // 不在 PWA 环境时卸载
        await existingReg.unregister();
        const cacheNames = await caches.keys();
        await Promise.all(cacheNames.map(name => caches.delete(name)));
      }
    };

    checkAndManageSW();
  }, [swPath]);
}

使用方式

  1. 安装应用

    • 在支持的浏览器中访问应用
    • 浏览器会显示"添加到主屏幕"提示
    • 点击安装后,应用会像原生应用一样运行
  2. 离线使用

    • 安装后,应用的核心功能可以在离线状态下使用
    • Service Worker 会自动缓存访问过的页面
    • 模型文件存储在 IndexedDB 中,离线时仍可使用
  3. 自动更新

    • Service Worker 会自动检测新版本
    • 后台更新缓存,不影响当前使用
    • 下次打开应用时会使用新版本

注意事项

  • PWA 功能需要在 HTTPS 环境下运行(或 localhost)
  • Service Worker 只在 PWA 模式下注册,避免在普通浏览器中占用资源
  • 模型文件缓存使用 IndexedDB,与 Service Worker 缓存分离
  • 支持手动卸载 Service Worker(通过 ServiceWorkerUninstall 组件)

7. 缓存管理功能

完整的缓存管理系统,支持导入、导出、删除等操作:

import { cacheManager, toHumanReadableSize } from '@/wllama-core';

// 列出所有缓存文件
const entries = await cacheManager.list();
console.log(`缓存文件数: ${entries.length}`);
entries.forEach(entry => {
  console.log(`${entry.metadata.originalURL || entry.name}: ${toHumanReadableSize(entry.size)}`);
});

// 获取缓存总大小
const totalSize = entries.reduce((sum, entry) => sum + entry.size, 0);
console.log(`总大小: ${toHumanReadableSize(totalSize)}`);

// 删除特定文件
await cacheManager.delete('https://example.com/model.gguf');

// 清空所有缓存
await cacheManager.clear();

// 从文件导入到缓存
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = fileInput.files?.[0];
if (file) {
  await cacheManager.write(`/${file.name}`, file, {
    etag: '',
    originalSize: file.size,
    originalURL: `/${file.name}`,
  });
}

使用示例

基本使用(React组件)

// src/app/wllama/load-from-file/page.tsx
"use client"
import { useState, useRef, useEffect } from 'react';
import { WllamaCore, Message, WLLAMA_CONFIG_PATHS } from '@/wllama-core';

export default function MinimalExample() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isModelLoaded, setIsModelLoaded] = useState(false);
  const wllamaCoreRef = useRef<WllamaCore | null>(null);

  useEffect(() => {
    wllamaCoreRef.current = new WllamaCore({ paths: WLLAMA_CONFIG_PATHS });
    return () => {
      wllamaCoreRef.current?.unloadModel().catch(() => {});
    };
  }, []);

  const loadModel = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    if (!files.length || !wllamaCoreRef.current) return;
    
    try {
      await wllamaCoreRef.current.loadModelFromFiles(files, { 
        n_ctx: 4096, 
        n_batch: 128 
      });
      setIsModelLoaded(true);
    } catch (err) {
      console.error('加载失败:', err);
    }
  };

  const send = async () => {
    if (!input.trim() || !wllamaCoreRef.current || !isModelLoaded) return;

    const userMsg: Message = { role: 'user', content: input.trim() };
    const assistantMsg: Message = { role: 'assistant', content: '' };
    setMessages((prev) => [...prev, userMsg, assistantMsg]);
    setInput('');

    try {
      const result = await wllamaCoreRef.current.createChatCompletion(
        [...messages, userMsg], 
        {
          nPredict: 4096,
          useCache: true,
          sampling: { temp: 0.2 },
          onNewToken(_token, _piece, text) {
            setMessages((prev) => {
              const updated = [...prev];
              if (updated.length > 0 && updated[updated.length - 1].role === 'assistant') {
                updated[updated.length - 1].content = text;
              }
              return updated;
            });
          },
        }
      );
    } catch (err) {
      console.error('生成失败:', err);
    }
  };

  return (
    <div>
      <input type="file" accept=".gguf" onChange={loadModel} />
      {/* UI组件... */}
    </div>
  );
}

从URL加载(自动缓存)

// 从URL加载模型,自动缓存到IndexedDB
await wllamaCore.loadModelFromUrl('https://example.com/model.gguf', {
  n_ctx: 4096,
  useCache: true, // 默认启用
  downloadOptions: {
    progressCallback: (progress) => {
      const percent = progress.total > 0 
        ? (progress.loaded / progress.total) * 100 
        : 0;
      console.log(`下载进度: ${percent.toFixed(1)}%`);
    },
  },
});

// 下次加载时,会自动从缓存读取,无需重新下载
await wllamaCore.loadModelFromUrl('https://example.com/model.gguf', {
  n_ctx: 4096,
  // useCache: true 是默认值
});

事件监听

wllamaCore.on(WllamaCoreEvent.MODEL_LOADING, () => {
  console.log('模型加载中...');
});

wllamaCore.on(WllamaCoreEvent.MODEL_LOADED, (data) => {
  const { metadata, runtimeInfo } = data as {
    metadata?: ModelMetadata;
    runtimeInfo?: RuntimeInfo;
  };
  console.log('模型已加载:', metadata?.name);
  console.log('多线程模式:', runtimeInfo?.isMultithread);
});

wllamaCore.on(WllamaCoreEvent.GENERATION_UPDATE, (text) => {
  console.log('生成中:', text as string);
});

wllamaCore.on(WllamaCoreEvent.ERROR, (error) => {
  console.error('错误:', error as string);
});

多实例使用

使用工厂类创建和管理多个实例:

import { wllamaCoreFactory, WLLAMA_CONFIG_PATHS, Message, WllamaCoreEvent } from '@/wllama-core';

// 方式1: 使用 getOrCreate(推荐,如果实例已存在则返回现有实例)
const instance1 = wllamaCoreFactory.getOrCreate('chat-1', { paths: WLLAMA_CONFIG_PATHS });
const instance2 = wllamaCoreFactory.getOrCreate('chat-2', { paths: WLLAMA_CONFIG_PATHS });

// 方式2: 使用 create(如果实例已存在会抛出错误)
// const instance1 = wllamaCoreFactory.create({ paths: WLLAMA_CONFIG_PATHS }, 'chat-1');

// 方式3: 使用 getDefault(获取或创建默认实例,向后兼容)
// const defaultInstance = wllamaCoreFactory.getDefault({ paths: WLLAMA_CONFIG_PATHS });

// 加载不同的模型
await instance1.loadModelFromUrl('https://example.com/model1.gguf');
await instance2.loadModelFromUrl('https://example.com/model2.gguf');

// 监听事件(事件数据包含 instanceId)
instance1.on(WllamaCoreEvent.GENERATION_UPDATE, (data: any) => {
  if (data.instanceId === 'chat-1') {
    console.log('聊天1更新:', data.data);
  }
});

instance2.on(WllamaCoreEvent.GENERATION_UPDATE, (data: any) => {
  if (data.instanceId === 'chat-2') {
    console.log('聊天2更新:', data.data);
  }
});

// 同时进行多个对话
const messages1: Message[] = [{ role: 'user', content: '你好' }];
const messages2: Message[] = [{ role: 'user', content: 'Hello' }];

await Promise.all([
  instance1.createChatCompletion(messages1),
  instance2.createChatCompletion(messages2),
]);

// 获取实例信息
console.log('实例1 ID:', instance1.getInstanceId());
console.log('当前实例数:', wllamaCoreFactory.getInstanceCount());

// 清理
await wllamaCoreFactory.destroy('chat-1');
await wllamaCoreFactory.destroy('chat-2');
// 或清理所有实例
// await wllamaCoreFactory.destroyAll();

PWA 安装和使用

项目支持完整的 PWA 功能,用户可以像安装原生应用一样安装:

安装步骤

  1. 在桌面浏览器

    • 访问应用后,浏览器地址栏会显示安装图标
    • 点击安装图标,选择"安装"
    • 应用会添加到桌面,可以独立窗口运行
  2. 在移动设备

    • iOS Safari:点击分享按钮 → "添加到主屏幕"
    • Android Chrome:浏览器会自动显示"添加到主屏幕"横幅
    • 安装后,应用会出现在主屏幕上

离线使用

  • 安装后,应用的核心功能可以在离线状态下使用
  • 已加载的模型文件存储在 IndexedDB 中,离线时仍可使用
  • Service Worker 会缓存访问过的页面,离线时也能浏览

Service Worker 管理

项目提供了 Service Worker 管理功能,可以通过组件控制:

// Service Worker 只在 PWA 环境下自动注册
// 可以通过全局方法管理
(window as any).swManager.status(); // 查看状态
(window as any).swManager.unregister(); // 卸载 Service Worker

PWA 配置要点

  • manifest.json 配置了应用的基本信息、图标和显示模式
  • Service Worker 实现了智能缓存策略
  • 支持自动更新,后台检测新版本
  • 只在 PWA 环境下注册,避免在普通浏览器中占用资源

非React环境使用

核心库 wllama-core 不依赖 React,可以在任何 JavaScript/TypeScript 环境中使用:

// 纯JavaScript/TypeScript环境
import { WllamaCore, WLLAMA_CONFIG_PATHS } from './wllama-core';

const wllamaCore = new WllamaCore({ paths: WLLAMA_CONFIG_PATHS });

// 加载模型
await wllamaCore.loadModelFromFiles(files, { n_ctx: 4096 });

// 生成文本
const result = await wllamaCore.createChatCompletion([
  { role: 'user', content: '你好!' }
], {
  nPredict: 4096,
  sampling: { temp: 0.2 },
});

console.log(result);

项目结构

mvp-ai-wllama/
├── src/
│   ├── app/                    # Next.js 应用页面
│   │   ├── wllama/
│   │   │   ├── load-from-file/    # 从文件加载页面
│   │   │   ├── load-from-url/     # 从URL加载页面
│   │   │   ├── load-from-cache/   # 从缓存加载页面
│   │   │   ├── manager-cache/     # 缓存管理页面
│   │   │   └── multi-instance/     # 多实例演示页面
│   │   └── layout.tsx             # 布局组件(包含 PWA manifest 配置)
│   ├── wllama-core/            # 核心库(无React依赖)
│   │   ├── wllama-core.ts      # 核心封装类
│   │   ├── wllama-core-factory.ts # 工厂类(多实例管理)
│   │   ├── cache-manager.ts    # 缓存管理器
│   │   ├── storage.ts          # localStorage工具
│   │   ├── utils.ts            # 工具函数
│   │   ├── types.ts            # 类型定义
│   │   └── config.ts           # 配置
│   └── components/             # React组件
│       ├── StudioLayout/       # 布局组件
│       ├── Loading.tsx         # 加载组件
│       ├── ServiceWorkerManager.tsx # Service Worker 管理组件
│       └── ServiceWorkerUninstall.tsx # Service Worker 卸载组件
├── public/
│   ├── manifest.json          # PWA 清单文件
│   ├── sw.js                  # Service Worker 文件
│   ├── icon-192.png           # PWA 图标(192x192)
│   ├── icon-512.png           # PWA 图标(512x512)
│   └── wasm/
│       └── wllama/
│           ├── multi-thread/   # 多线程WASM
│           └── single-thread/  # 单线程WASM
└── src/middleware.ts          # Next.js中间件(多线程支持)

部署方案

Vercel一键部署

项目已配置,可直接部署到Vercel:

# 安装依赖
npm install

# 构建项目
npm run build

# Vercel 会自动检测并部署

🌐 在线演示: mvp-ai-wllama.vercel.app/

静态文件部署

项目支持静态导出,构建后的文件可部署到任何静态托管服务:

# 构建静态文件
npm run build

# 输出目录: out/
# 可直接部署到 GitHub Pages、Netlify、Nginx 等

多线程支持配置

如需启用多线程支持,需要配置正确的 HTTP 响应头:

// src/middleware.ts
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // 启用 SharedArrayBuffer 支持
  response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
  response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
  
  return response;
}

注意事项

  • 必须在 HTTPS 环境下运行(或 localhost)
  • 需要浏览器支持 SharedArrayBuffer
  • 某些CDN可能不支持这些响应头,需要配置

技术优势总结

特性 传统方案 本方案
数据安全 ❌ 需要上传服务器 ✅ 完全本地处理
部署成本 ❌ 需要后端服务 ✅ 纯静态部署
模型格式 ⚠️ 需要转换 ✅ 直接支持GGUF格式
离线使用 ❌ 需要网络 ✅ 完全离线
性能优化 ⚠️ 依赖网络 ✅ IndexedDB缓存 + 多线程
隐私保护 ⚠️ 数据上传 ✅ 数据不出浏览器
参数控制 ⚠️ 复杂配置 ✅ 简单API + 自动持久化
流式输出 ⚠️ 需要WebSocket ✅ 原生支持流式生成

技术原理

使用WebAssembly运行Llama模型

传统AI模型推理需要:

  1. 搭建GPU服务器
  2. 配置CUDA环境
  3. 处理模型加载和推理
  4. 管理服务器资源

本方案通过WebAssembly技术:

  1. 在浏览器中直接运行Llama模型推理
  2. 使用WASM实现高性能计算
  3. 完全客户端化,无需服务器
  4. 支持多线程加速(SharedArrayBuffer)

GGUF模型格式

GGUF(GPT-Generated Unified Format)是专门为Llama模型设计的格式:

  • 量化支持:支持多种量化级别(Q4_K_M, Q8_0等)
  • 快速加载:优化的文件结构,加载速度快
  • 内存效率:量化后模型体积大幅减小
  • 跨平台:统一的格式,跨平台兼容

IndexedDB缓存机制

  • 持久化存储:模型文件存储在浏览器IndexedDB中,关闭浏览器后仍保留
  • URL映射:使用SHA-1哈希将URL映射为唯一文件名
  • 进度追踪:支持下载进度实时反馈
  • 元数据扩展:可扩展的元数据结构,支持ETag、创建时间等

多线程加速原理

  • SharedArrayBuffer:允许多个Web Worker共享内存
  • 自动检测:自动检测浏览器是否支持多线程
  • 性能提升:多线程模式下推理速度可提升2-4倍
  • 安全限制:需要设置COOP/COEP响应头

参考项目

开源地址

🔗 GitHub仓库: mvp-ai-wllama

总结

本项目提供了一个完整的纯前端Llama模型推理方案,通过WebAssembly技术实现了模型推理的本地化,结合React和现代化的缓存系统,打造了一个功能完善、性能优秀的AI对话应用。

核心亮点

  • 🚀 纯前端架构,无需后端服务
  • 🔒 数据完全本地化,保护隐私安全
  • ⚡ 基于WebAssembly的高性能推理
  • 💾 IndexedDB缓存系统,支持大文件存储
  • 🔄 流式生成支持,实时输出
  • 🧵 多线程加速,性能提升显著
  • 🔀 多实例支持,可同时运行多个模型实例
  • 📱 PWA 支持,可安装到设备,支持离线使用
  • 📦 零React依赖的核心库,可接入任何系统
  • 🎯 完整的类型定义,开发体验优秀

欢迎Star和Fork,一起推动前端AI技术的发展!


相关阅读

vue 表格 vxe-table 加载数据的几种方式,更新数据的用法

2026年1月8日 18:02

vue 表格 vxe-table 加载数据的几种方式,更新数据的用法,vue 最强大的表格组件,支持 vue2 和 vue3,功能太多,这里介绍基础用法

vxetable.cn

双向绑定 data 方式

数据是双向同步的,修改 data 就可以自动刷新

11111111111111111-ezgif.com-video-to-gif-converter

<template>
  <div>
    <vxe-button status="primary" @click="loadList1Event">修改1</vxe-button>
    <vxe-button status="primary" @click="loadList2Event">修改2</vxe-button>
    <vxe-button status="primary" @click="loadList3Event">修改3</vxe-button>

    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const gridOptions = reactive({
  border: true,
  height: 300,
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'address', title: 'Address', showOverflow: true }
  ],
  data: []
})

const loadList1Event = () => {
  gridOptions.data = [
    { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
    { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 26, address: 'test abc' },
    { id: 10010, name: 'Test10', role: 'Develop', sex: 'Man', age: 38, address: 'test abc' },
    { id: 10011, name: 'Test11', role: 'Test', sex: 'Women', age: 29, address: 'test abc' },
    { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 27, address: 'test abc' }
  ]
}

const loadList2Event = () => {
  gridOptions.data = [
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
    { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
    { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
    { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
    { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 26, address: 'test abc' },
    { id: 10010, name: 'Test10', role: 'Develop', sex: 'Man', age: 38, address: 'test abc' },
    { id: 10011, name: 'Test11', role: 'Test', sex: 'Women', age: 29, address: 'test abc' },
    { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 27, address: 'test abc' },
    { id: 10013, name: 'Test13', role: 'Test', sex: 'Women', age: 24, address: 'test abc' },
    { id: 10014, name: 'Test14', role: 'Develop', sex: 'Man', age: 34, address: 'test abc' }
  ]
}
const loadList3Event = () => {
  gridOptions.data = [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
    { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
    { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
    { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
    { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 26, address: 'test abc' },
    { id: 10010, name: 'Test10', role: 'Develop', sex: 'Man', age: 38, address: 'test abc' },
    { id: 10011, name: 'Test11', role: 'Test', sex: 'Women', age: 29, address: 'test abc' },
    { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 27, address: 'test abc' },
    { id: 10013, name: 'Test13', role: 'Test', sex: 'Women', age: 24, address: 'test abc' },
    { id: 10014, name: 'Test14', role: 'Develop', sex: 'Man', age: 34, address: 'test abc' },
    { id: 10015, name: 'Test15', role: 'Test', sex: 'Man', age: 21, address: 'test abc' },
    { id: 10016, name: 'Test16', role: 'Develop', sex: 'Women', age: 20, address: 'test abc' },
    { id: 10017, name: 'Test17', role: 'Test', sex: 'Man', age: 31, address: 'test abc' },
    { id: 10018, name: 'Test18', role: 'Develop', sex: 'Women', age: 32, address: 'test abc' },
    { id: 10019, name: 'Test19', role: 'Test', sex: 'Man', age: 37, address: 'test abc' },
    { id: 10020, name: 'Test20', role: 'Develop', sex: 'Man', age: 41, address: 'test abc' }
  ]
}
</script>

调用方法刷新数据

除了修改 data 之外,还可以通过调用 loadData、reloadData 方法刷新数据

22222222222222222-ezgif.com-video-to-gif-converter

<template>
  <div>
    <vxe-button status="primary" @click="loadList1Event">修改1</vxe-button>
    <vxe-button status="primary" @click="loadList2Event">修改2</vxe-button>
    <vxe-button status="primary" @click="loadList3Event">修改3</vxe-button>

    <vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const gridRef = ref()

const gridOptions = reactive({
  border: true,
  height: 300,
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'address', title: 'Address', showOverflow: true }
  ],
  data: []
})

const loadList1Event = () => {
  const $grid = gridRef.value
  if ($grid) {
    $grid.loadData([
      { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
      { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 26, address: 'test abc' },
      { id: 10010, name: 'Test10', role: 'Develop', sex: 'Man', age: 38, address: 'test abc' },
      { id: 10011, name: 'Test11', role: 'Test', sex: 'Women', age: 29, address: 'test abc' },
      { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 27, address: 'test abc' }
    ])
  }
}

const loadList2Event = () => {
  const $grid = gridRef.value
  if ($grid) {
    $grid.loadData([
      { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
      { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
      { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
      { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
      { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
      { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
      { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 26, address: 'test abc' },
      { id: 10010, name: 'Test10', role: 'Develop', sex: 'Man', age: 38, address: 'test abc' },
      { id: 10011, name: 'Test11', role: 'Test', sex: 'Women', age: 29, address: 'test abc' },
      { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 27, address: 'test abc' },
      { id: 10013, name: 'Test13', role: 'Test', sex: 'Women', age: 24, address: 'test abc' },
      { id: 10014, name: 'Test14', role: 'Develop', sex: 'Man', age: 34, address: 'test abc' }
    ])
  }
}

const loadList3Event = () => {
  const $grid = gridRef.value
  if ($grid) {
    $grid.loadData([
      { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
      { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
      { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
      { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
      { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
      { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
      { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
      { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
      { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 26, address: 'test abc' },
      { id: 10010, name: 'Test10', role: 'Develop', sex: 'Man', age: 38, address: 'test abc' },
      { id: 10011, name: 'Test11', role: 'Test', sex: 'Women', age: 29, address: 'test abc' },
      { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 27, address: 'test abc' },
      { id: 10013, name: 'Test13', role: 'Test', sex: 'Women', age: 24, address: 'test abc' },
      { id: 10014, name: 'Test14', role: 'Develop', sex: 'Man', age: 34, address: 'test abc' },
      { id: 10015, name: 'Test15', role: 'Test', sex: 'Man', age: 21, address: 'test abc' },
      { id: 10016, name: 'Test16', role: 'Develop', sex: 'Women', age: 20, address: 'test abc' },
      { id: 10017, name: 'Test17', role: 'Test', sex: 'Man', age: 31, address: 'test abc' },
      { id: 10018, name: 'Test18', role: 'Develop', sex: 'Women', age: 32, address: 'test abc' },
      { id: 10019, name: 'Test19', role: 'Test', sex: 'Man', age: 37, address: 'test abc' },
      { id: 10020, name: 'Test20', role: 'Develop', sex: 'Man', age: 41, address: 'test abc' }
    ])
  }
}
</script>

在手机端做个滚动效果

作者 snow来了
2026年1月8日 18:00

用react开发一个antd mobile组件,页面上有多个高度不等的div元素,每个div底部都有一个「核对」按钮。当div的高度大于屏幕高度的时候,就要将这个div底部的「核对」按钮锁定在页面底部,方便用户操作。当用户滚动页面,这个div的底部进入屏幕的时候,就要取消「核对」按钮的锁定,要它回归原处,跟随这个div进行滚动。当第二个div进入屏幕50%的时候,在此将它的「核对」按钮锁定在页面上。你帮我用监听滚动的方式实现这个功能,所有的代码都写在一个文件里面,组件的名字叫:MaterialVerificationSon

如果出现按钮取消锁定有抖动,是样式问题,用 transform: isFixed ? 'translateX(-2px)' : 'none',可以解决

import { Button } from 'antd-mobile';
import React, { useEffect, useRef, useState } from 'react';

// import { useLocation } from '@tanstack/react-location';

export const MaterialVerificationSon: React.FC = () => {
  // 示例数据:包含不同高度的div内容
  const [items] = useState([
    { id: 1, content: '这是第一个材料项,高度适中', height: 780 },
    { id: 2, content: '这是第二个材料项,高度较高,内容较多,需要滚动查看', height: 800 },
    { id: 3, content: '这是第三个材料项,高度非常高,内容非常多,需要大量滚动', height: 1200 },
    { id: 4, content: '这是第四个材料项,高度适中', height: 400 },
    { id: 5, content: '这是第五个材料项,高度较高', height: 900 },
  ]);
  const contentRef = useRef<HTMLDivElement>(null);

  // 存储每个元素的ref
  const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
  // 存储每个按钮的锁定状态
  const [lockedButtons, setLockedButtons] = useState<Record<number, boolean>>({});

  // 获取屏幕高度
  const getScreenHeight = () => window.innerHeight;

  // 检查元素是否在视口中
  const isElementInViewport = (element: HTMLElement) => {
    const rect = element.getBoundingClientRect();
    return rect.top < getScreenHeight() && rect.bottom > 0;
  };

  // 检查元素底部是否在视口内
  const isElementBottomInViewport = (element: HTMLElement) => {
    const rect = element.getBoundingClientRect();
    const elementBottom = rect.bottom;
    return elementBottom <= getScreenHeight() && elementBottom > 0;
  };

  // 检查元素是否至少有50%在视口内
  const isElementHalfVisible = (element: HTMLElement) => {
    const rect = element.getBoundingClientRect();
    const elementHeight = rect.height;
    const visibleHeight = Math.min(Math.max(getScreenHeight() - rect.top, 0), elementHeight);
    return visibleHeight / elementHeight >= 0.5;
  };

  // 检查元素是否过高(超过屏幕高度)
  const isElementTooTall = (element: HTMLElement) => {
    return element.scrollHeight > getScreenHeight();
  };

  // 处理滚动事件
  const handleScroll = () => {
    const newLockedButtons: Record<number, boolean> = {};

    items.forEach((item, index) => {
      const element = itemRefs.current[index];
      if (!element) return;

      const elementTooTall = isElementTooTall(element); //是否大于屏幕高度
      const elementInViewport = isElementInViewport(element); //是不是在视口里面
      const elementBottomInViewport = isElementBottomInViewport(element); //检查元素底部是否在视口内
      const elementHalfVisible = isElementHalfVisible(element); //视口50%

      console.log(elementInViewport, index);
      if (elementInViewport && elementTooTall && elementHalfVisible) {
        newLockedButtons[item.id] = true;
      }

      if (elementBottomInViewport) {
        newLockedButtons[item.id] = false;
      }
    });

    setLockedButtons(newLockedButtons);
  };
  console.log(lockedButtons, 9999);
  // 添加滚动事件监听器
  useEffect(() => {
    if (contentRef.current) {
      contentRef.current.addEventListener('scroll', handleScroll);
      // 初始执行一次
      handleScroll();
    }

    return () => {
      if (contentRef.current) {
        contentRef.current.removeEventListener('scroll', handleScroll);
      }
    };
  }, []);

  // 核对按钮点击处理
  const handleVerify = (id: number) => {
    console.log(`核对按钮被点击,ID: ${id}`);
    // 这里可以添加具体的核对逻辑
  };

  return (
    <div style={{ padding: '16px', height: '100vh', overflowY: 'auto' }} ref={contentRef}>
      <h2>材料核对页面</h2>

      {items.map((item, index) => (
        <div
          key={item.id}
          ref={el => (itemRefs.current[index] = el)}
          style={{
            border: '1px solid #ddd',
            borderRadius: '8px',
            padding: '16px',
            marginBottom: '16px',
            overflowY: 'auto',
          }}
        >
          <h3>材料项 {item.id}</h3>
          <p>{item.content}</p>
          <p>高度: {item.height}px</p>
          <p style={{ height: `${item.height}px` }}>
            这是一个示例内容,实际应用中这里会有具体的材料信息。
          </p>

          {/* 核对按钮 */}
          <div
            style={{
              position: lockedButtons[item.id] ? 'fixed' : 'static',
              bottom: lockedButtons[item.id] ? '20px' : 'auto',
              left: lockedButtons[item.id] ? '50%' : 'auto',
              transform: lockedButtons[item.id] ? 'translateX(-50%)' : 'none',
              zIndex: 1000,
              width: '100%',
              display: 'flex',
              justifyContent: 'center',
              marginTop: '16px',
            }}
          >
            <Button
              color="primary"
              size="large"
              onClick={() => handleVerify(item.id)}
              style={{
                width: '80%',
                maxWidth: '300px',
              }}
            >
              核对1111{index}
            </Button>
          </div>
        </div>
      ))}
    </div>
  );
};

JavaScript 中 ==、===、Object.is 以及 null、undefined、undeclared 的区别

作者 码途潇潇
2026年1月8日 17:57

1.在JavaScript 当中,===== 与Object.is()的区别

在JavaScript 当中有许多可以比较相等与否的方法。其中最常见的三个分别是**===(严格比较)==(松散比较),以及Object.is(同值比较)**。

1.1 ==松散比较(loose equality)

==在比较两个值之前,会先强制转换数据类型,变成两者可以比较的形式。

如下示例:

console.log(1 == "1"); // true
console.log(0 == false); // true
console.log(undefined == null); // true

因为会强制转换型别,==会带给开发者一些困扰。因此多数的情况,不建议使用==

1.2 严格比较(strict equality)

===不会强制转换型别与值,所以如果是不同型别,比较两者会回传false。不同值的话一样会回传false,===的 JS 设计初衷是判断数值是否相同。

不过有两个情况例外:

  • 当我们比较+0-0时,严格比较会回传true
  • 以及比较NaNNaN会是false。而这两个状况则是同值比较Object.is派上用场的时候。
+0 === -0; // true
NaN === NaN; // false

1.3 Object.is同值比较(same-value equality)

同值比较顾名思义是在比较两个值是不是相等。虽然它是Object 开头,但比较的可以是任意的两个值。例如:

console.log(Object.is(1, 1)); // true
console.log(Object.is(1, "1")); // false

上面提到的两种在===时遇到的问题,可以透过Object.is有效分辨

console.log(Object.is(+0, -0)); // false
console.log(Object.is(NaN, NaN)); // true

=== +0 和-0 是 true,是因为比较的是值,就是 0 这个值,而不关心他的方向,Object.is 关心了他了方向,因为方向是不同的,所以 object.is 是false。

===中的两个NaN 的比较,NaN 是一种失败状态,Not a Number,但是失败的原因是可能是不同的,所以= ==中是 false。但是在 object.is语义中,规定了所有的 NaN 都表示一种值状态,所以是 true。

不过如果要有效分辨NaN,在JavaScript 有一个方法叫isNaN是可以使用的。假如对于Object.is感到陌生,可以选择用Number.isNaN

2.JavaScript null、undefined 与undeclared 的区别?

2.1 undefined 与null 的区别 ?

undefined表示还未定义值,所以当一个变数被宣告但还未被赋予任何值之前,这个变数就会是undefined,可以理解为「尚未」。

null代表的是一个变数的空值,可以理解为「没有」

undefinednull在JavaScript 中,都是属于原始数据类型(primitive data types) 之一,也就像任何其他数据类型(data types),例如:stringnumber一样,可以被赋予在变数上。两者在使用上,两者会有不同的意义。

举例来说,当前端要向后端索取数据时,因为需要等待数据回传,所以某个变数一开始可能是undefined,当数据回来时,就会变成该数据类型。以下面的例子来说,我们有个变量users,定义它的类型为

UserDTO[] | undefined

而不是

UserDTO[] | null

正是因为在拿到资料前,users是「尚未」。

type UserDTO = {
  id: string,
  firstName: string,
  lastName: string,
  profilePicture: string | null,
};

const users: UserDTO[] | undefined = await fetchUsers();

反之亦然,上面的例子中,当拿到了users,有些使用者没有照片,因为是「没有」,所以profilePicture的类型是

profilePicture: string | null;

而不是

profilePicture: string | undefined;

2.2 undefined 与undeclared 的区别 ?

undefined:已经声明了,但还没值

undeclared:压根没声明过,JS 根本不知道它是谁

let a;
console.log(a); // undefined
typeof a; // "undefined" 合法,不报错
console.log(b);
ReferenceError: b is not defined //undeclared 是一种代码错误状态

Node.js 模块加载 - 4 - CJS 和 ESM 互操作避坑清单

作者 之恒君
2026年1月8日 17:47

这份清单覆盖项目配置、模块导入导出、路径解析、第三方包兼容四大核心场景,为避开跨模块规范的常见问题。

一、 项目基础配置避坑

  1. 明确模块类型,避免自动切换混乱
    • Node.js 默认 .js 文件为 CJS,若要全局启用 ESM,必须在项目根目录 package.json 中配置 "type": "module"
    • 若只想单个文件用 ESM,可将后缀改为 .mjs;单个文件用 CJS,后缀改为 .cjs(不受 package.jsontype 影响)。
    • ❌ 错误:项目根目录没配 "type": "module",却在 .js 文件里写 import/export → 直接报错。
  1. package.json 字段优先级要分清
{
  "main": "./dist/index.cjs",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.mjs"
    }
  }
}
    • ESM 优先读取 exports 字段,CJS 优先读取 main 字段。
    • 若要同时兼容 CJS 和 ESM,需在 exports 中分别声明:

二、 模块导入导出避坑

  1. CJS 不能直接 require() ESM 模块
// CJS 文件中加载 ESM 模块
import('./esm-file.js').then((module) => {
  console.log(module.default); // 拿到 ESM 的默认导出
});
    • ❌ 错误:const esmModule = require('./esm-file.js')(该文件是 ESM 格式)→ 报 ERR_REQUIRE_ESM
    • ✅ 解决:用 import() 动态导入(返回 Promise):
  1. ESM 加载 CJS 模块的注意事项
// ESM 文件中加载 CJS 模块
import cjsModule from './cjs-file.js';
// 等价于 CJS 的 const cjsModule = require('./cjs-file.js')
// 方案1:解构默认导出
import cjsModule from './cjs-file.js';
const { foo } = cjsModule;

// 方案2:CJS 模块兼容具名导出写法
module.exports = { foo: 'bar' }; // ESM 可解构
    • ✅ ESM 可以直接用 import 加载 CJS 模块,CJS 的 module.exports 会被视为 ESM 的默认导出。
    • ❌ 坑:CJS 模块没有 ESM 的具名导出,不能写 import { foo } from './cjs-file.js' → 导入的 fooundefined
    • ✅ 解决:要么解构默认导出,要么让 CJS 模块兼容导出:
  1. __dirname / __filename 在 ESM 中不可用
// ESM 中替代 __dirname/__filename
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
    • CJS 中的 __dirname(当前文件目录路径)、__filename(当前文件路径)在 ESM 中被移除。
    • ❌ 错误:在 ESM 文件中直接用 __dirname → 报 ReferenceError
    • ✅ 解决:用 import.meta.url 手动实现:

三、 路径解析避坑

  1. ESM 必须写全文件扩展名
    • 这是最容易踩的坑!CJS 支持省略 .js/.json,ESM 不行。
    • ❌ 错误:import helper from './utils/helper' → 报 ERR_MODULE_NOT_FOUND
    • ✅ 正确:import helper from './utils/helper.js'
  1. ESM 加载目录必须配置 exports
{
  "exports": { ".": "./index.js" } // 映射目录根到 index.js
}
    • CJS 加载目录时,会自动找 index.js;ESM 不会,必须在目录的 package.json 中配置 exports
    • 示例:utils 目录下加 package.json
    • 之后才能在 ESM 中这样写:import utils from './utils'
  1. 第三方包的路径映射优先看 exports
    • 若第三方包的 package.jsonexports 字段,ESM 会严格按其规则加载,不能“越级”导入子文件。
    • ❌ 错误:包 my-pkgexports 只暴露了根,却写 import foo from 'my-pkg/src/foo.js' → 报错。
    • ✅ 解决:要么让包在 exports 中添加该路径,要么用 CJS 加载(CJS 不受 exports 限制)。

四、 命令行运行与工具链避坑

  1. 运行 ESM 文件的参数注意
    • 若没配置 package.json"type": "module",运行 .mjs 文件无需额外参数;运行 .js 文件需加 --experimental-modules(低版本 Node),高版本 Node(v14.3+)无需。
    • 命令示例:node esm-file.js(已配 type: module)、node esm-file.mjs(无需配置)。
  1. TypeScript/打包工具的兼容设置
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "NodeNext", // 匹配 Node.js 的 ESM 解析规则
    "outDir": "./dist",
    "esModuleInterop": true // 兼容 CJS 模块的默认导出
  }
}
    • tsc 编译时,若目标是 ESM,需在 tsconfig.json 中设置:
    • webpack/rollup 时,需明确配置 output.formatcjsesm,避免打包后模块类型混乱。

五、CJS & ESM 混合项目常见错误排查对照表

这份对照表覆盖 Demo 运行中最容易出现的 8 类报错,包含 错误现象、根本原因、解决方案,帮你快速定位和修复问题。

错误类型 错误提示示例 根本原因 解决方案
ESM 扩展名缺失 Error [ERR_MODULE_NOT_FOUND]: Cannot find module './esm-helper' imported from ... ESM 要求必须写全文件扩展名(.js/.mjs),不能像 CJS 那样省略 import 语句中补全扩展名,例如: import helper from './esm-helper.js'
CJS 直接加载 ESM Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported. CJS 的 require() 不支持直接加载 ESM 模块,只能加载 CJS 模块 改用 ESM 的动态 import() 函数(返回 Promise): import('./esm-module.js').then(mod => { ... })
ESM 加载目录无 exports Error [ERR_MODULE_NOT_FOUND]: Cannot find module './utils' imported from ... ESM 加载目录时,不会默认找 index.js,必须配置目录的 package.json exports 字段 utils 目录下新建 package.json,添加: { "exports": { ".": "./index.js" } }
ESM 加载项目目录文件,非node_modules中包 Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/src/utils' is not supported resolving ES modules imported from /src/esm-module.js ESM 加载本项目中的非node_modlues目录时,必须写全文件路径;加载时不会默认找 index.js,即使配置了目录的 package.json exports 字段,也不成; 需使用具体文件路径 如:import utils from './utils/index.js'
__dirname 未定义 ReferenceError: __dirname is not defined in ES module scope ESM 中移除了 __dirname/__filename 全局变量 import.meta.url 手动实现,参考 esm-dirname.js 的代码
type: module 冲突 SyntaxError: Cannot use import statement outside a module 根目录 package.json 没配置 "type": "module".js 文件被当作 CJS 解析 在根 package.json 中添加: { "type": "module" },或把 ESM 文件后缀改为 .mjs
第三方包 exports 限制 Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/xxx' is not defined by "exports" 第三方包的 package.json exports 字段未暴露该子路径,ESM 严格遵循映射规则 1. 改用包暴露的合法路径; 2. 若为自己开发的包,在 exports 中添加该路径映射
CJS 解构 ESM 具名导出失败 const { foo } = require('./esm-module.js')fooundefined ESM 的具名导出不能被 CJS 直接解构,require() 只能拿到 ESM 的默认导出 先获取默认导出再解构: const mod = require('./esm-module.js'); const { foo } = mod.default;
文件路径大小写问题 Error [ERR_MODULE_NOT_FOUND]: Cannot find module './Esm-Helper.js' Node.js 路径解析区分大小写(尤其是 Linux/macOS 系统) 保证 import 路径的大小写和实际文件名完全一致

六、额外通用排查技巧

  1. 检查 Node.js 版本:确保 Node.js ≥ v14.3.0,低版本对 ESM 支持不完善。
  2. 清理缓存:运行 node --clear-module-cache src/xxx.js 清除模块缓存,解决缓存导致的解析异常。
  3. 绝对路径测试:若相对路径报错,改用绝对路径(结合 __dirname)测试,排除相对路径层级错误。

VitePress文件构建失败:Element is missing end tag?

作者 Jaxon1216
2026年1月8日 17:45

VitePress 大型 Markdown 文件构建失败问题:Element is missing end tag

昨晚在我 push 完上一次的 Vue 学习笔记后,习惯性地打开 notes.jiangxu.net 查看更新。然而页面并没有如期更新。心里一沉,打开 GitHub 仓库,果然看到了那个刺眼的红色 ❌ —— 部署失败。

切换到 Vercel 的部署日志,映入眼帘的是这样的错误:

✖ building client + server bundles...

build error:!
[vite:vue] [plugin vite:vue] Frontend/Vue/day2-night.md (273:1): 
Element is missing end tag.

SyntaxError: [plugin vite:vue] Frontend/Vue/day2-night.md (273:1): 
Element is missing end tag.
    at createCompilerError (compiler-core.cjs.prod.js:1360:17)
    at emitError (compiler-core.cjs.prod.js:2963:5)
    at Object.onclosetag (compiler-core.cjs.prod.js:2360:13)

"Element is missing end tag"?我仔细检查了第 273 行,是一个普通的 Markdown 表格,没有任何 HTML 标签。这让我陷入了困惑。

🔍 漫长的调试之旅

接下来的一个个小时,我与 Claude Sonnet 进行了多轮对话,尝试了各种可能的解决方案:

  1. 验证 HTML 标签配对 - 写了脚本检查所有 <details><summary> 标签,结果全部配对正确 ✅
  2. 检查代码块闭合 - 验证了所有 44 个代码块都正确闭合 ✅
  3. 转义 HTML 标签 - 将所有 <script setup> 改为 &lt;script setup&gt;
  4. 修复表格格式 - 将双竖线 || 开头的表格改为单竖线 |
  5. 更换代码块类型 - 将 vue 改为 html

但问题依然存在。 在这里插入图片描述

更诡异的是:

  • 删除"问题行"后,错误会移动到更前面的行(273→242→191)
  • 前 272 行单独提取出来可以成功构建
  • 同目录下其他较小的文件都能正常构建

💡 真相大白

经过 200 多次工具调用后,问题依然没有得到解决。最终我请教了老大,他一眼就看出了问题所在。原来,这是 VitePress/Vue 编译器在处理复杂 Markdown 文件时的边界情况 Bug

环境信息

{
  "vitepress": "^1.6.4",
  "@vitejs/plugin-vue": "^5.x",
  "vue": "^3.x"
}

文件对比分析

文件 行数 <details> 表格 代码块 构建结果
day2-morning.md 418 0 0 9 ✅ 成功
day2-afternoon.md 304 5 0 2 ✅ 成功
day2-night.md 1580 22 55行 32 ❌ 失败

通过对比发现,失败的文件具有三个特征:

  1. 文件最大(1580 行)
  2. HTML 标签最多(22 个 <details>
  3. 复杂度最高(表格 + 代码块 + HTML 标签混合)

二分法测试

# 测试前 800 行
✅ 构建成功

# 测试前 1200 行  
❌ 构建失败

# 测试前 1000 行
✅ 构建成功

结论:存在一个隐藏的阈值(约 1000-1200 行),超过后触发 Bug。

🎯 根本原因

这不是某一行的语法错误,而是多个边界情况的累积效应

原因 1:表格中的花括号被误识别

在 Markdown 表格中使用 return {},Vue 编译器可能将其误认为模板插值语法 {{ }}。在大文件中,这种误识别会累积,导致编译器状态机错乱。

问题代码

| **暴露** | 自动暴露 | 需要 `return {}` |

原因 2:<summary> 标签内的转义 HTML

<summary> 标签内直接使用 &lt;script setup&gt; 这样的转义字符,在某些情况下会让编译器混淆,认为还有未闭合的标签。

原因 3:<details> 内部格式不规范

VitePress 的 Markdown 解析器期望 <summary> 标签后有空行,否则在大文件中可能导致解析边界不清。

触发条件

当以下因素同时出现时触发 Bug:

文件大小 > 1000行 
  + HTML标签数量 > 20
  + Markdown表格 > 10
  + 花括号{} 在表格中
  = 编译器进入不稳定状态

这是典型的编译器边界情况 Bug,不是代码错误,而是工具链的缺陷。

✅ 解决方案-workout

修复 1:表格中的花括号加空格

修改前

| **暴露** | 自动暴露 | 需要 `return {}` |

修改后

| **暴露** | 自动暴露 | 需要 `return { }` |

{} 之间添加空格,避免被误识别为 Vue 插值。

修复 2:规范 <details> 格式

在以下位置添加空行:

  • <summary> 标签后
  • 代码块前后
  • </details> 标签前

让 Markdown 解析器更清晰地识别边界。

修复 3:优化 <summary> 内的 HTML

使用 <code> 标签包裹转义字符:

修改前

<summary>为什么 &lt;script setup&gt; 不能用?</summary>

修改后

<summary>为什么 <code>&lt;script setup&gt;</code> 不能用?</summary>

修复 4:拆分大文件(治本方案)

如果文件超过 800 行,建议拆分为多个子文件:

day2-night.md (1580行)
  ↓ 拆分为
day2-night-pinia.md (800行)
day2-night-communication.md (780行)

🔧 预防措施:自动化检查

为避免类似问题,添加预构建检查脚本:

// scripts/check-html-tags.cjs
const fs = require('fs');

function checkFile(filePath) {
  const content = fs.readFileSync(filePath, 'utf-8');
  const lines = content.split('\n');
  
  let inCodeBlock = false;
  const tagStack = [];
  
  lines.forEach((line, index) => {
    // 跳过代码块
    if (line.trim().startsWith('```')) {
      inCodeBlock = !inCodeBlock;
      return;
    }
    if (inCodeBlock) return;
    
    // 移除反引号内容
    const processedLine = line.replace(/`[^`]*`/g, '');
    
    // 检查标签配对...
  });
  
  return tagStack.length === 0;
}

package.json 中配置:

{
  "scripts": {
    "check:html": "node scripts/check-html-tags.cjs",
    "prebuild": "npm run check:html"
  }
}

📊 修复效果

指标 修复前 修复后
本地构建 ❌ 失败 ✅ 45s
Vercel 部署 ❌ 失败 ✅ ~60s
文件大小 1580行 1580行(内容不变)

修复前后文件行数不变,只是调整了格式。

💡 最佳实践建议

文件大小控制

  • 推荐:单个 Markdown 文件不超过 800 行
  • ⚠️ 警告:超过 1000 行需要特别注意格式规范
  • 避免:超过 1500 行,强烈建议拆分

格式规范

✅ 推荐写法

  1. 表格中的花括号return { value } (有空格)
  2. details 标签:summary 后、内容前后、代码块前后都要有空行
  3. summary 中的 HTML:用 <code> 标签包裹,如 <code>&lt;script&gt;</code>

❌ 避免写法

  1. 表格中紧密的花括号return {} (无空格)
  2. details 无空行:summary 后直接写内容
  3. summary 中直接转义:直接使用 &lt; &gt; 不用 code 包裹

混合内容注意事项

当文件中同时包含以下元素时,需要格外小心:

  • 大量 Markdown 表格(> 10 行)
  • 多个 HTML 标签(> 15 个)
  • 多个代码块(> 20 个)
  • 反引号内的特殊字符({}<>

🎯 经验总结

关键要点

  1. 工具链不是完美的

    • VitePress/Vue 编译器在处理边界情况时可能出现 Bug
    • 不要假设工具会正确处理所有合法的 Markdown 语法
  2. 累积效应很重要

    • 单个"问题"可能不会触发 Bug
    • 多个边界情况组合可能导致编译器崩溃
  3. 调试需要耐心

    • 有时问题不在错误提示的那一行
    • 需要系统性地分析和测试
  4. 预防胜于治疗

    • 添加自动化检查脚本
    • 建立格式规范并严格遵守
    • 定期审查大文件,及时拆分

写在最后

这个 Bug 的发现和解决历经了 200+ 次的调试和测试,充分体现了大型项目中工具链的复杂性。虽然最终的修复很简单(几个空格和标签调整),但问题的诊断过程却需要对编译原理、Markdown 解析、Vue 模板编译等多方面的理解。

更重要的是,这次经历让我意识到:

  • 不要盲目相信工具:即使是成熟的工具链也可能有 Bug
  • 保持代码整洁:良好的格式规范不仅提高可读性,还能避免触发工具的边界情况
  • 建立反馈机制:遇到问题要及时记录和分享,帮助社区改进

如果你也遇到了类似的问题,希望这篇文章能帮你快速定位和解决。如果你有更好的解决方案或见解,欢迎讨论交流!


作者jiangxu.net
日期:2026-01-08
标签VitePress Vue Markdown 编译器Bug 调试经验

写作不易,如果这篇文章对你有帮助,欢迎分享给更多的人 ⭐

package.json 中的 dependencies 与 devDependencies:深度解析

作者 大知闲闲i
2026年1月8日 17:42

核心区别概述

在 Node.js 和前端项目中,package.json 文件是项目的"身份证"和"配置清单",其中的 dependencies(生产依赖)和 devDependencies(开发依赖)是管理项目依赖的两大核心字段。它们虽然都用来声明项目所需的软件包,但在使用场景、安装方式和部署策略上有着本质区别。

📦 dependencies:生产环境依赖

定义与作用

dependencies 包含了项目在生产环境中运行所必需的依赖包。这些是项目代码运行时真正需要的库和框架。

典型包含内容

  • 运行时库:如 React、Vue、Angular 等前端框架

  • 工具库:如 lodash、axios、moment 等通用工具

  • 服务端框架:如 Express、Koa、NestJS

  • 数据库驱动:如 mongoose、sequelize、mysql2

  • 其他核心依赖:任何项目运行不可或缺的第三方包

安装与使用

# 安装到 dependencies
npm install <package-name> --save
# 或简写
npm install <package-name>
# yarn 等价命令
yarn add <package-name>

部署影响

这些依赖会被打包并部署到生产环境,用户最终下载的代码包中包含这些依赖。

🔧 devDependencies:开发环境依赖

定义与作用

devDependencies 包含了仅在开发、构建、测试等阶段需要的工具和库。项目运行时并不需要这些依赖。

典型包含内容

  • 构建工具:Webpack、Vite、Rollup

  • 代码质量工具:ESLint、Prettier、Stylelint

  • 测试框架:Jest、Mocha、Cypress

  • 类型检查:TypeScript、Flow

  • 开发服务器:nodemon、webpack-dev-server

  • 文档工具:JSDoc、VuePress

  • 其他开发工具:如代码生成器、构建脚本等

安装与使用

# 安装到 devDependencies
npm install <package-name> --save-dev
# 简写
npm install <package-name> -D
# yarn 等价命令
yarn add <package-name> --dev

部署影响

这些依赖不会被打包到生产环境,最终用户不会下载这些代码,减少了生产包体积。

🔍 详细对比表格

🛠️ 使用场景示例

package.json 示例

{
  "name": "my-project",
  "version": "1.0.0",
  "dependencies": {
    "vue": "^3.2.0",          // 运行时必需
    "axios": "^1.3.0",        // HTTP 请求库
    "vue-router": "^4.1.0",   // 路由管理
    "pinia": "^2.0.0"         // 状态管理
  },
  "devDependencies": {
    "vite": "^4.0.0",         // 构建工具
    "eslint": "^8.0.0",       // 代码检查
    "jest": "^29.0.0",        // 测试框架
    "prettier": "^2.0.0",     // 代码格式化
    "@types/node": "^18.0.0"  // TypeScript 类型定义
  }
}

不同环境的安装策略

开发环境安装所有依赖:

# 安装所有依赖(包括 devDependencies)
npm install
# 或
yarn install

生产环境仅安装 dependencies:

# 跳过 devDependencies
npm install --production
# 或设置环境变量
NODE_ENV=production npm install

# Dockerfile 中的典型用法
COPY package*.json ./
RUN npm ci --only=production

CI/CD 流水线优化:

# GitHub Actions 示例
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci              # 安装所有依赖用于测试
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci --production # 仅安装生产依赖
      - run: npm run build

⚠️ 常见误区与最佳实践

误区 1:随意放置依赖

// ❌ 错误:将构建工具放在 dependencies
{
  "dependencies": {
    "webpack": "^5.0.0"  // 错误!这应该是 devDependency
  }
}

// ✅ 正确
{
  "devDependencies": {
    "webpack": "^5.0.0"
  }
}

误区 2:类型定义包放置错误

// 对于 TypeScript 项目:
{
  "dependencies": {
    "express": "^4.18.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.0"  // 类型定义永远是 devDependency
  }
}

最佳实践

  1. 严格区分:运行时必需的放 dependencies,开发工具放 devDependencies

  2. 定期审计:使用 npm audit 检查安全漏洞

  3. 版本控制

    {
      "dependencies": {
        "vue": "^3.2.0",   // 兼容性更新
        "axios": "~1.3.0"  // 小版本更新
      }
    }
    
  4. 依赖清理:定期删除未使用的依赖

    npm uninstall <unused-package>
    

🔬 高级技巧

查看依赖树

# 查看 dependencies 依赖树
npm list --depth=0

# 仅查看 devDependencies
npm list --dev --depth=0

# 图形化显示
npm list --depth=0 --json | npx dependency-tree

依赖分析工具

# 分析包大小
npx source-map-explorer dist/*.js

# 查看重复依赖
npx depcheck

# 可视化依赖
npx npm-gui

package-lock.json 与 yarn.lock

# 确保依赖一致性
npm ci  # 使用 package-lock.json 精确安装
yarn install --frozen-lockfile

📊 实际案例分析

案例 1:前后端分离项目

// frontend/package.json
{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "antd": "^5.0.0",
    "react-router-dom": "^6.8.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^3.0.0",
    "typescript": "^5.0.0",
    "@types/react": "^18.0.0",
    "jest": "^29.0.0"
  }
}

// backend/package.json  
{
  "dependencies": {
    "express": "^4.18.0",
    "mongoose": "^7.0.0",
    "jsonwebtoken": "^9.0.0"
  },
  "devDependencies": {
    "nodemon": "^2.0.0",
    "@types/express": "^4.17.0",
    "jest": "^29.0.0"
  }
}

案例 2:Monorepo 项目

// package.json (根目录)
{
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": {
    "lerna": "^6.0.0",
    "typescript": "^5.0.0",
    "jest": "^29.0.0"
  }
}

// packages/web-app/package.json
{
  "dependencies": {
    "shared-utils": "*"  // 工作区内引用
  },
  "devDependencies": {
    "vite": "^4.0.0"
  }
}

 总结要点

  1. 核心区别dependencies 用于生产运行,devDependencies 用于开发构建

  2. 部署策略:生产环境跳过 devDependencies 安装以优化部署

  3. 性能影响:正确分类可以减少生产包体积,加快安装速度

  4. 团队协作:明确的依赖分类有助于团队理解和维护项目

  5. 安全性:定期审计和更新依赖,特别是生产依赖

记住一个简单的原则:如果用户运行你的应用时需要这个包,它就是 dependency;如果只有开发者在编写、测试、构建时需要,它就是 devDependency。 正确使用这两个字段,能让你的项目更加专业、高效和可维护。

2025 年终回顾:25 岁,从“混吃等死”到别人眼中的“技术专家”

作者 何贤
2026年1月8日 17:33

2025 年终总结:25 岁,从“混吃等死”到别人眼中的“技术专家”

两年前的春节假期,某天。在一所面积不大、紧邻国道的廉租房里,住着三个人。

那时的我,还是个凭运气混进大公司、天天写 CRUD 混吃等死的前端“小卡拉米”。趁着春节假期,我戴着耳机,沉浸在游戏世界里。突然,客厅传来一声闷响。

我疑惑地摘下耳机:“什么声音?”

回头望去,我看到奶奶仰面躺在沙发上——她晕倒了。

那是我第一次见到我最亲爱的亲人(我是单亲+留守儿童)病倒在面前。慌乱中,我给在外打牌的父亲拨去了电话。最后所幸并无大碍,但在那一刻,我知道:我不能再这样混下去了,我需要努力。

两年后的今天,再回头看:

  • 我成为了团队中不可或缺的技术核心
  • 我成为了稀土掘金 2025 年度优秀创作者
  • 我开源的项目累计获得 1K+ GitHub Star
  • 我开始频繁出现在 Three.js 官方推特的转发列表中
  • 也第一次,被别人称为「技术专家」

111.gif

前言:前端版“萧炎”?不,是鸽子王

我无意想去将过去两年到底是如何度过的写成文章,把这篇年终总结写成“前端版萧炎”的自传。老实说我也想不起来是怎么过的。上面那段沉重的开场白,就当是我为自己小小的骄傲一下吧。

好了!STOP!沉重的话题到此为止。让我们一起来看看,“鸽子王”老何今年到底干了些什么事吧!

1.所在之平台:数据与感谢

首先,让我们来看看今年在平台上的具体“战绩”。今年一共写了多少篇文章呢?

更新频率.png

哇!居然有足足 9 篇之多! 这个数量真是闻者伤心、听者落泪,运营看了想打人(右边狐尼克真的是运营催更我时的表情 be like...)。不过好在数据还算过得去,收获了 1217 名粉丝。真的特别特别感谢你们!不多说了,就我这“随缘更新”的频率还能有粉丝,真的得给“义父们”磕一个。

感谢 给你磕头 GIF 动图_爱给网_aigei_com.gif

在此期间,我也收获了非常不错的流量,感谢各大网友、群友和平台运营老师的大力扶持。

222.png

最终,我获得了 「稀土掘金 2025 年度优秀创作者」 的荣誉。当时运营老师通知我的时候,我的第一反应是:

11111111111111111.png

泰裤辣!兄弟们也是好起来了!

说真的,能拿这个奖完完全全归功于万能的群友们和运营老师满满的 Push,是你们的监督让我得以将写文章的习惯(勉强)坚持下去!

微信圖片_20260108111207_10_110.jpg

2.所做之项目:从“夯”到“拉”的锐评

来到项目环节,让我以极其客观(自我检讨)的视角,锐评一下今年开源的项目吧!

🏝️ Island —— 2.5D 卡通风个人简历\

自我评价:人上人

island.gif

island 对现在的我来说,确实存在不少问题:

  • 画面风格:三渲二的效果还需优化,仔细看距离小时候在 PSP 上玩的游戏风格还有差距,后续计划加入自定义后处理通道来调节。
  • UI 设计:当时用 DALL·E 3 生成的 UI 比较简陋,后续会用 Nano-banana-pro 全面改进 UI 风格。
  • 兼容性:移动端适配?不存在的,手机和平板用户只能干瞪眼 = =。
  • 交互性:可互动内容单调,靠近物体没有视觉反馈。
  • 展示方式:玩家需要到场景上方点击告示牌展示新项目,方式太单一,和网页没啥区别!

但话又说回来,这确实是我第一个有点“出圈”的项目。也许每个人回看以前的代码都会觉得稚嫩,左看右看能挑出一堆毛病,但不可否认它在我心中的地位。综合下来,给个**“人上人”**的评价!后面的改动还能在掘金多水两篇文章,美滋滋。

🏙️ CubeCity —— 卡通城市放置系统

自我评价:项目顶尖,作者“拉完了”

游玩时动图.gif

CubeCity 是我 GitHub 上 Star 最多的项目,单个项目贡献了 877 Star。玩法参考了《卡牌城镇》,支持随意建造、拆除、升级、搬迁建筑。UI 贴合 Low Poly 风格,在国外社区也很讨喜。

但 Star 多不代表没问题:

  • 性能:渲染帧率堪忧,比如 GTX 1660 Ti 这种显卡都跑不满 60 FPS。
  • 生气:道路上没有汽车和小人跑动,城市显得空荡荡。
  • 兼容性:移动设备又双叒没做兼容?!GitHub 上提的 Issue 也不回?可恶的鸽子王!
  • 功能缺失:说好的成就系统呢?经济系统呢?社交排行榜呢?

何贤你在干嘛?总而言之,这个项目简直是鸽到没朋友,最鸽的一集!X 上评论不回,GitHub 上 Issue 装死。要不是项目底子还行,我真的要骂人了!

综合下来项目给到顶尖,但是开发者给到 拉完了 啊!

Third-Person-MC——第三人称我的世界

自我评价:夯

03.gif

这个项目掘友们可能没怎么听过,但在群里应该多多少少见识过。这是目前对我来说最复杂的一个项目!

该项目具备多种生态地貌、无限地形生成与自适应相机等核心特性,不久的将来,即将正式登陆掘金平台与大家见面。至于是否会进一步扩展联机系统,目前尚无定论。相关内容,我会在后续发布的专题文章中为大家详细解读。

总体来说还不错!实机测试在 GTX 1660 Ti 的笔记本上也能稳定 30 帧!算是一个非常有意思的探索。综合评价:


好了好了打住!今年说实话还是开源了不少项目!但是不能在这占用篇幅!在此我直接就是一个项目大合影

Snipaste_2026-01-08_14-36-36.png

以及对于我来说所有项目从夯到拉的排名如下:

我的从夯到拉.png

3.所遇之好友:良师益友

近年最幸运的事,就是遇到了一个很好的领导,以及一群志同道合、相互勉励的朋友。

关于“冷爷”

在工作上,我遇到了一位好领导,但我更愿称他为好朋友——冷爷。 平时群友或合作伙伴可能觉得我是个温和的人,可一旦切入工作模式,我就会变成大家口中的“压力怪”。因此曾有一段时间,我和办公环境有些格格不入。冷爷作为 Leader,真的起到了至关重要的润滑作用。 生活中,冷爷也经常带我出去玩。那段时间我真是“两耳不闻窗外事,一心只想学技术”,彻彻底底的宅男一枚。要不是冷爷拉着我游山玩水,我可能真就成了那种“代码敲得飞起、话却说不清楚”的刻板极客。 他是一个好领导,更是一个好朋友。在这里想对冷爷说一声:谢谢!

关于 Web3D 圈子

随着深入学习 Web3D,我微信里多了很多耕耘于此的朋友。虽然大家细分领域不同——有做可视化大屏的,有做 3D 看车/看房的,有研究 NVIDIA Isaac Sim 的,也有做数字工厂/机械臂的。甚至有些曾是我在视频网站上仰望的偶像,现在也成了列表好友。

大家聚在一起分享技术,扯皮打趣,大佬们时不时冒泡答疑。这个圈子很小,抬头不见低头见,但真的很少出现拉踩或诋毁。我是在群友们的“夸夸”中一步步走到这里的。 这种正反馈非常奇妙:动力来自群友的鼓励和大佬的认可,而这些又促使我创造出更好的项目!

4. 所想:运气表面积

最近我了解到一个非常有趣的观点,叫 Luck Surface Area(运气表面积),最早来自 Jason Roberts:

你生活中会有多少‘无心插柳柳成荫’的意外之喜?这取决于你的‘运气表面积’。 LSA(运气) = P(热爱/做事的深度) × C(传播/连接的广度)

这个乘法关系很神奇,意味着如果其中一项为零,总结果就为零:

  • 只有热爱 (P),没有传播 (C) = 孤独的耕耘者 如果你对某事极度热爱,技艺精湛,但把自己关在地下室里,从不向外界展示,那么你的“运气表面积”几乎为零。外界的机会无法穿透墙壁找到你,“酒香也怕巷子深”。
  • 只有传播 (C),没有热爱 (P) = 空洞的喧哗者 如果你擅长营销,但传播的内容缺乏内核,不是你真正热爱或擅长的东西,你可能短期获得关注,但无法建立深度的信任,真正的“好运”依然很难降临。

Gemini_Generated_Image_bxxtb0bxxtb0bxxt.png 我觉得我是非常幸运的。优秀的 Web3D 作品天然具有视觉冲击力和社交属性,而稀土掘金平台很好地承担了“传播”的职责!

所以,并不是我选择了这个平台,而是我遇到的人、事以及平台给予的正反馈激励着我!非常感谢能看到这里的你!

5. 所规划之未来

2026 年会是什么样?我不知道。它会是我的“三年之约”,我希望自己能变成更好的人。

但我确定我一定会:

  • 🛠️ 填坑:优化那些我没有完善好的项目(别骂了别骂了)。
  • 创造:产出更多有趣的项目和技术文章。
  • 🤝 连接:认识更多志同道合的朋友。
  • 🌐 布道:将 Web3D 的魅力分享给更多的人。

6.三年之约,你会如约而至吗?

最后,如果你愿意,也在这篇文章的评论区留下属于你的「三年之约」吧!

无论是技术的精进、生活的改变,还是一个简单的愿望。让我们约定在未来的某一天回头看,一起见证彼此的蜕变!🚀

🚀 从重复 CRUD 到工程化封装:我是如何设计 useTableList 统一列表逻辑的

作者 basestone
2026年1月8日 17:29

在中后台项目中,最常见的页面不是表单,而是列表页。
真正消耗时间的,往往不是业务复杂度,而是分页、排序、loading、查询参数同步这些重复劳动。

这篇文章分享我在项目中封装的一个 Hook —— useTableList,用于统一管理 Ant Design Table 的列表行为。


一、为什么要封装表格 Hook?

在没有封装之前,一个列表页通常要处理:

  • loading 状态
  • 分页 / 页码同步
  • 查询参数合并
  • 表格排序映射
  • rowSelection 管理
  • search / refresh / reset 行为区分

结果就是:

👉 每个列表页几乎都在复制粘贴,而且 bug 特别容易集中在这些地方。

所以我给自己定了一个目标:

列表页只关心:表单 + columns + 业务操作
其余全部交给 Hook。


二、设计目标

  • ✅ 数据请求收敛到一个入口
  • ✅ 表格行为(分页 / 排序)内聚
  • ✅ 对外暴露语义清晰的 API
  • ✅ 最大限度贴合 antd Table
  • ✅ 可作为项目级基础设施

三、对外 API 设计

const {
  queryParams,
  search,
  refresh,
  reset,
  selectedRowKeys,
  tableProps
} = useTableList(getListApi, { rowSelection: true })

search —— 查询(回到第一页)

用于表单搜索 / 条件变化。

refresh —— 刷新当前页

用于新增 / 删除 / 修改之后。

reset —— 重置条件

用于重置按钮。

selectedRowKeys —— 批量操作能力基础

tableProps —— 直接传给 antd Table

<Table rowKey="id" columns={columns} {...tableProps} />

四、全局配置能力

解决不同后端字段不统一的问题:

configureTableOption({
  pageSize: 20,
  sortField: ['orderType', 'orderField'],
  sortOrder: ['ASC', 'DESC']
})

五、核心实现思路

1️⃣ 单一数据入口

所有行为最终都会走:

  • search
  • refresh
  • reset
  • 表格分页 / 排序
const fetchList = async (params) => { ... }

这是整个 Hook 稳定性的核心。


2️⃣ 查询参数是唯一真相

分页、排序、条件都收敛在 queryParams 中,避免状态割裂。


3️⃣ 表格行为完全内聚

onTableChange => fetchList()

页面层不再处理分页 / 排序细节。


4️⃣ rowSelection 统一托管

让批量操作天然可扩展。


六、完整源码(useTableList.ts)

import {  type Key, type ReactNode, useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { Empty, type TableProps as AntdTableProps } from 'antd'
import type {
  TablePaginationConfig,
  TableCurrentDataSource
} from 'antd/es/table/interface'

export interface QueryParamsData {
  pageNo: number
  pageSize: number
  orderType?: string
  orderField?: string
  [key: string]: any
}

interface TableResponse<T> {
  status: string
  data: {
    list: T[]
    totalCount: number
  }
}

interface TableState<T> {
  pagination: TablePaginationConfig
  list: T[]
  queryParams: QueryParamsData
}

export interface TableProps<T> {
  bordered: boolean
  size: 'middle'
  sticky: boolean
  rowSelection: AntdTableProps<T>['rowSelection'] | undefined
  pagination: TablePaginationConfig
  loading: boolean
  dataSource: T[]
  onChange: AntdTableProps<T>['onChange']
  locale: {
    emptyText: string | ReactNode
  }
}

interface TableListResult<T> {
  /** 查询参数 */
  queryParams: QueryParamsData
  /** 执行查询方法 */
  search: (params?: Record<string, any>) => void
  refresh: (params?: Record<string, any>) => void
  reset: (params?: Record<string, any>) => void
  /** 选中的行 keys */
  selectedRowKeys: Key[]
  /** 表格属性 */
  tableProps: TableProps<T>
}

interface GlobalTableConfig {
  sortField: string[]
  sortOrder: string[]
  pageSize: number
}

// 默认配置
const globalTableConfig: GlobalTableConfig = {
  sortField: ['orderType', 'orderField'],
  sortOrder: ['ASC', 'DESC'],
  pageSize: 10
}

/**
 * 配置全局表格参数
 * @param config
 */
export function configureTableOption(config: GlobalTableConfig): void {
  Object.keys(config).forEach((key: string) => {
    globalTableConfig[key] = config[key]
  })
}

export function useTableList<T extends Record<string, any> = Record<string, any>>(
  getRequestFn: (data: QueryParamsData) => Promise<TableResponse<T>>,
  initParams = {} as Record<string, any>
): TableListResult<T> {
  const { rowSelection, ...restInitParams } = initParams
  const PAGE_SIZE = globalTableConfig.pageSize

  const [state, setState] = useState<TableState<T>>({
    pagination: {
      showSizeChanger: true,
      showQuickJumper: true,
      total: 0,
      pageSize: PAGE_SIZE,
      current: 1
    },
    list: [],
    queryParams: {
      pageNo: 1,
      pageSize: PAGE_SIZE,
      ...restInitParams
    }
  })

  const { pagination, list, queryParams } = state
  const { pageNo: currentPageNo, pageSize: currentPageSize } = queryParams

  const [isLoading, setIsLoading] = useState(true)
  const initialQuery = useRef(queryParams)
  const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])


  const rowSelectionData: AntdTableProps<T>['rowSelection'] | undefined = useMemo(() => {
    if (!rowSelection) return void 0
    return {
      selectedRowKeys,
      onChange: (keys: Key[]) => setSelectedRowKeys(keys)
    }
  }, [rowSelection, selectedRowKeys])

  const showTotal = useCallback(
    (total: number): string => {
      return `共 ${total} 条记录 第 ${currentPageNo}/${Math.ceil(total / currentPageSize)} 页 `
    },
    [currentPageNo, currentPageSize]
  )

  const fetchList = useCallback(
    async (params: QueryParamsData): Promise<void> => {
      const { pageNo } = params
      setIsLoading(true)
      const queryParamsData = { ...restInitParams, pageSize: currentPageSize, ...params }
      if (params.pageNo === void 0) {
        queryParamsData.pageNo = 1
      }
      if (params.pageSize === void 0) {
        queryParamsData.pageSize = currentPageSize
      }
      const { data } = await getRequestFn(queryParamsData)
      const { list = [], totalCount = 0 } = data || {}
      rowSelection && setSelectedRowKeys([])
      setState({
        list,
        queryParams: queryParamsData as T & QueryParamsData,
        pagination: {
          ...pagination,
          current: pageNo,
          pageSize: queryParamsData.pageSize,
          total: totalCount
        }
      })

      setIsLoading(false)
    },
    [queryParams, restInitParams, rowSelection, currentPageSize, getRequestFn, pagination]
  )

  const search = useCallback(
    (params?: Record<string, any>) => {
      fetchList({ ...queryParams, ...params, pageNo: 1 })
    },
    [fetchList, queryParams]
  )

  const refresh = useCallback(
    (params?: Record<string, any>) => {
      fetchList({ ...queryParams, ...params})
    },
    [fetchList, queryParams]
  )

  const reset = useCallback(
    (params?: Record<string, any>) => {
      fetchList({ ...params, pageSize: currentPageSize, pageNo: 1 })
    },
    [fetchList, currentPageSize]
  )

  const onTableChange = useCallback(
    ( pagination: TablePaginationConfig,
      _filters: Record<string, any>,
      sorter: Record<string, any>,
      extra: TableCurrentDataSource<any>) => {
      const { action } = extra
      if (['paginate', 'sort'].includes(action)) {
        const { current, pageSize } = pagination
        const { field, order } = sorter
        const [orderTypeField, orderFieldName] = globalTableConfig.sortField
        const [ascValue, descValue] = globalTableConfig.sortOrder
        const params = {
          ...queryParams,
          [orderTypeField]: order ? (order === 'ascend' ? ascValue : descValue) : void 0,
          [orderFieldName]: field,
          pageNo: current,
          pageSize: pageSize
        }
        fetchList(params)
      }
    },
    [queryParams, fetchList]
  )

  const tableProps: TableProps<T> = useMemo(() => {
    return {
      bordered: true,
      size: 'middle',
      sticky: true,
      rowSelection: rowSelectionData,
      pagination: { ...pagination, showTotal },
      loading: isLoading,
      dataSource: list,
      onChange: onTableChange,
      locale: {
        emptyText: isLoading ? '' : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
      }
    }
  }, [rowSelectionData, pagination, showTotal, isLoading, list, onTableChange])

  useEffect(() => {
    search(initialQuery.current)
  }, [])

  return {
    queryParams,
    search,
    refresh,
    reset,
    selectedRowKeys,
    tableProps
  }
}


七、页面使用示例

const { tableProps, search, reset, selectedRowKeys } = useTableList(getUserList, {
  rowSelection: true
})
<Form onFinish={search}>
  <Button htmlType="submit">搜索</Button>
  <Button onClick={reset}>重置</Button>
</Form>

<Table rowKey="id" columns={columns} {...tableProps} />

八、总结

useTableList 带来的不是“少写几行代码”,而是:

  • 列表页工程化
  • 行为模型统一
  • Bug 集中收口
  • 可持续扩展

非常适合作为中后台项目的基础设施。


九、后续可进阶方向

  • URL 同步查询参数
  • 导出 / 批量操作能力
  • 自动轮询 / 缓存
  • 向 ProTable 形态演进

如果这篇文章对你有帮助,欢迎点赞 / 收藏 / 评论交流 👏 也欢迎分享你们项目中是如何封装列表页的。

TypeScript 在项目中的实际解决的问题

作者 戴维南
2026年1月8日 17:29

核心理念

  • TypeScript 类型推断:把运行时错误变成编译时错误,同时为编辑器提供智能提示(自动补全、悬浮类型信息)
  • 泛型的核心价值:让类型信息在函数调用过程中"流动",而不是被抹掉

一、为什么有了 TypeScript 还需要 Zod?

本质区别

TypeScript Zod
作用时机 编译时 运行时
编译后 类型信息完全擦除 验证逻辑保留
保护范围 你写的代码 外部进来的数据

TypeScript 的局限

interface User {
  name: string;
  age: number;
}

// 编译后类型信息完全消失
const user: User = JSON.parse(apiResponse); // 运行时没有任何验证!

如果 API 返回 { name: 123, age: "abc" },TypeScript 不会报错,程序会带着错误数据继续运行。

Zod 的价值

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

const user = UserSchema.parse(JSON.parse(apiResponse)); 
// 数据不符合 schema 会直接抛错

使用场景判断

数据来源 用什么
外部进来的(API、用户输入、env、localStorage) Zod
内部流转的(props、state、函数签名) TS 类型

二、Zod 的核心优势:Schema 即类型

z.infer 自动导出类型

// 只写一次 schema
const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0),
});

// 自动导出 TypeScript 类型,不用手写 interface
type User = z.infer<typeof UserSchema>;
// 等价于:type User = { name: string; email: string; age: number }

对比传统方式

// ❌ 传统方式:写两遍,容易不同步
interface User {
  name: string;
  email: string;
  age: number;
}
function validateUser(data: unknown): User {
  // 手写验证逻辑...
}

// ✅ Zod:Single Source of Truth
const UserSchema = z.object({...});
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(data); // 验证 + 类型推导一步到位

好处

  • 改一处,处处生效 — 修改 schema,类型自动更新
  • IDE 全程有提示 — parse 后的数据有完整类型支持
  • 复杂类型也能推导 — union、optional、transform 等都能正确推导

三、Zod 验证失败的处理

方式一:parse - 直接抛错

try {
  const user = UserSchema.parse(apiResponse);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.errors);
    // [{ path: ['email'], message: 'Invalid email' }]
  }
}

方式二:safeParse - 不抛错(推荐)

const result = UserSchema.safeParse(apiResponse);

if (result.success) {
  console.log(result.data.name); // result.data 类型安全
} else {
  console.log(result.error.flatten()); // 详细错误信息
}

理解 safeParse 返回值

resultZod 生成的结果对象,不是后端数据:

// 验证成功时
{ success: true, data: { name: "张三", age: 25 } }

// 验证失败时
{ success: false, error: ZodError }

实际项目处理模式

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  const json = await res.json();
  
  const result = UserSchema.safeParse(json);
  
  if (!result.success) {
    reportError('API schema mismatch', result.error); // 上报监控
    return null; // 或返回默认值、抛业务异常
  }
  
  return result.data;
}

四、TypeScript 高级类型系统

TypeScript 的类型系统本质上是一门图灵完备的函数式编程语言

核心价值:通过类型推断,把运行时错误变成编译时错误

泛型:类型层面的"函数"

泛型的核心价值:让类型信息在函数调用过程中"流动",而不是被抹掉

type Wrapper<T> = { value: T; timestamp: number };

type WrappedString = Wrapper<string>;  // { value: string; timestamp: number }

条件类型:类型层面的 if-else

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;  // true
type B = IsString<123>;      // false

infer:类型层面的模式匹配

infer 的作用:"这个位置的类型我不知道,但帮我捕获出来,后面我要用"

类比正则表达式的捕获组:

正则:/Hello (\w+)/  →  捕获括号里匹配到的内容
类型:T extends Promise<infer U>  →  捕获 Promise 里面的类型

为什么 infer 只能和 extends 一起用?

infer 需要一个匹配上下文才能知道要捕获什么,extends 提供了这个上下文:

T extends Promise<infer U> ? U : never
//        ↑ 这是一个"模式"
//                  ↑ 在这个模式里,U 是要捕获的部分

TS 的执行逻辑:

  1. T 去匹配 Promise<?> 这个模式
  2. 如果匹配成功,把 ? 位置的类型赋值给 U
  3. 然后 U 就可以在 ? 后面使用了
// ❌ 不能单独使用 infer
type Bad<T> = infer U;  // 错误:infer 只能在条件类型的 extends 子句中使用
// 因为 TS 不知道 U 应该从哪里推断,没有模式匹配就没有"捕获"的来源

就像正则表达式的捕获组必须在匹配模式里,不能单独写 (\w+)

示例:提取 Promise 内部类型

type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>;  // string
type B = Unwrap<number>;           // number

执行过程:

  1. T extends Promise<infer U> — T 是不是 Promise<某个类型>
  2. infer U — 如果是,把那个类型捕获出来叫 U
  3. ? U : T — 条件成立返回 U,否则返回 T

示例:提取函数返回值类型

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Fn = (x: number) => { name: string; age: number };
type Result = ReturnType<Fn>;  // { name: string; age: number }

示例:infer 配合模板字面量提取字符串

infer 不仅能提取泛型参数,还能从字符串字面量类型中提取部分内容:

// 从 'GET /api/user/:id' 中提取方法
type ExtractMethod<T> = T extends `${infer M} ${string}` ? M : never;

type Method = ExtractMethod<'GET /api/user/:id'>;  // 'GET'
type Method2 = ExtractMethod<'POST /api/login'>;   // 'POST'

// 从 'GET /api/user/:id' 中提取路径
type ExtractPath<T> = T extends `${string} ${infer P}` ? P : never;

type Path = ExtractPath<'GET /api/user/:id'>;  // '/api/user/:id'

执行过程(以 ExtractMethod 为例):

  1. T = 'GET /api/user/:id'
  2. 模式 ${infer M} ${string} 表示:M + 空格 + 任意字符串
  3. TS 尝试匹配,发现 M = 'GET',后面是 ' /api/user/:id'
  4. 匹配成功,返回 M,即 'GET'

这在构建类型安全的路由、API 客户端时非常有用。

映射类型:批量变换对象类型

type Partial<T> = { [K in keyof T]?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };

模板字面量类型

type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>;  // "onClick"

五、实战案例:类型安全的表单 Hook

本案例完整展示两个核心理念:

  • 类型推断把运行时错误变编译时错误:字段名拼错、值类型不对,编译时就报错
  • 泛型让类型"流动":初始值的类型信息一路流动到 setField,不会丢失

需求

  • 传入初始值,自动推导表单字段类型
  • valueserrorsonChange 都要类型安全
  • 字段名写错要报错,值类型不对要报错

没有类型推导的痛苦

// ❌ 手动指定类型,写两遍,容易不同步
interface FormValues {
  username: string;
  age: number;
  email: string;
}

const { values, setField } = useForm<FormValues>({
  username: '',
  age: 0,
  email: ''
});

setField('usernmae', 'test');  // 拼写错误,运行时才发现
setField('age', '18');         // 类型错了,string 给了 number 字段

实现

function useForm<T extends Record<string, unknown>>(initialValues: T) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});

  // K extends keyof T:字段名必须是 T 的 key
  // T[K]:值类型自动跟着字段走
  function setField<K extends keyof T>(field: K, value: T[K]) {
    setValues(prev => ({ ...prev, [field]: value }));
  }

  function setError<K extends keyof T>(field: K, message: string) {
    setErrors(prev => ({ ...prev, [field]: message }));
  }

  return { values, errors, setField, setError };
}

使用效果

const { values, setField } = useForm({
  username: '',
  age: 0,
  isAdmin: false,
});

setField('username', 'john');     // ✅ 
setField('age', 25);              // ✅
setField('usernmae', 'john');     // ❌ 编译错误:字段名不存在
setField('age', '25');            // ❌ 编译错误:期望 number,给了 string

编辑器智能提示

类型推断不仅能报错,还能提供智能提示:

setField('|')  // 光标在这里时,编辑器自动弹出补全列表:
               // - username
               // - age
               // - isAdmin

values.|       // 光标在这里时,编辑器自动提示所有可用字段和类型:
               // - username: string
               // - age: number
               // - isAdmin: boolean

这是因为 TS 推断出了具体类型,编辑器(通过 TypeScript Language Server)就能提供精确的补全和类型信息。如果类型信息丢失(比如变成 Record<string, unknown>),这些提示就没了。

类型推断的完整执行过程

第一步:调用 useForm,推断泛型 T

const { values, setField } = useForm({
  username: '',
  age: 0,
  isAdmin: false,
});

TS 看到你传入的对象字面量,自动推断出泛型 T 的具体类型:

T = {
  username: string;
  age: number;
  isAdmin: boolean;
}

此时函数签名相当于:

function useForm(initialValues: { username: string; age: number; isAdmin: boolean })

第二步:keyof T 生成字段名联合类型

keyof T = 'username' | 'age' | 'isAdmin'

这意味着所有用到 keyof T 的地方,只能是这三个字符串之一。

第三步:setField 调用时的推断

function setField<K extends keyof T>(field: K, value: T[K])

当你调用 setField('age', 25) 时:

  1. TS 看到第一个参数是 'age'
  2. 推断出 K = 'age'
  3. 计算 T[K] = T['age'] = number
  4. 所以第二个参数 value 必须是 number
setField('age', 25);      // ✅ K='age', T[K]=number, 25 是 number
setField('age', '25');    // ❌ K='age', T[K]=number, '25' 是 string,类型不匹配
setField('username', 'john'); // ✅ K='username', T[K]=string
setField('isAdmin', true);    // ✅ K='isAdmin', T[K]=boolean

第四步:错误字段名的拦截

setField('usernmae', 'john');  // ❌ 编译错误

TS 检查 'usernmae' 是否属于 keyof T(即 'username' | 'age' | 'isAdmin'),发现不属于,直接报错。

完整推断流程图

useForm({ username: '', age: 0, isAdmin: false })
    │
    ▼
T 被推断为 { username: string; age: number; isAdmin: boolean }
    │
    ▼
keyof T = 'username' | 'age' | 'isAdmin'
    │
    ▼
setField('age', 25) 调用时:
    │
    ├─ K 被推断为 'age'(因为第一个参数是 'age')
    │
    ├─ T[K] = T['age'] = number
    │
    └─ 检查 25 是否符合 number ✅

为什么这样设计有效

关键在于 泛型 K 是在调用时才确定的

function setField<K extends keyof T>(field: K, value: T[K])
//              ↑ 每次调用都会推断出具体的 K
  • 调用 setField('age', ...) 时,K = 'age'
  • 调用 setField('username', ...) 时,K = 'username'

然后 T[K] 会根据 K 的值动态计算出对应的类型,实现了"字段名和值类型的绑定"。

深入理解:泛型变量与 extends 约束

泛型变量的本质

泛型变量 T 是一个类型占位符,它的具体值由调用时传入的参数反推确定

function useForm<T extends Record<string, unknown>>(initialValues: T)
//              ↑ T 是占位符,调用时才知道具体是什么类型
// 调用时,TS 根据参数反推 T 的值
useForm({ username: '', age: 0 })
//      └─ 参数类型是 { username: string; age: number }
//         所以 T = { username: string; age: number }

extends 约束的作用

T extends Record<string, unknown> 的意思是:T 可以是任何类型,但必须满足 Record<string, unknown> 的约束

Record<string, unknown> 表示"key 是 string,value 是任意类型的对象",本质上就是普通对象。

如果传入不符合约束的类型会怎样?

// ✅ 符合约束:普通对象
useForm({ username: '', age: 0 });

// ❌ 不符合约束:数组不是 Record<string, unknown>
useForm([1, 2, 3]);
// 编译错误:Argument of type 'number[]' is not assignable to parameter of type 'Record<string, unknown>'

// ❌ 不符合约束:原始类型
useForm('hello');
// 编译错误:Argument of type 'string' is not assignable to parameter of type 'Record<string, unknown>'

// ❌ 不符合约束:null
useForm(null);
// 编译错误

extends 约束 vs 直接指定类型

// 方式一:直接指定参数类型(不灵活)
function useForm(initialValues: Record<string, unknown>)

// 方式二:泛型 + extends 约束(灵活且安全)
function useForm<T extends Record<string, unknown>>(initialValues: T)

方式一的问题:类型信息丢失

// 直接指定类型的实现
function useFormBad(initialValues: Record<string, unknown>) {
  const [values, setValues] = useState(initialValues);
  return { values };
}

const { values } = useFormBad({ username: '', age: 0 });

// values 的类型是 Record<string, unknown>
// TS 只知道它是"某个对象",不知道具体有哪些字段

values.username;  // 类型是 unknown,不是 string
values.age;       // 类型是 unknown,不是 number
values.foo;       // ✅ 不报错!TS 认为任何 string key 都可能存在

// 使用时必须手动断言,很麻烦且不安全
const name = values.username as string;

方式二的优势:保留具体类型

// 泛型 + extends 约束的实现
function useFormGood<T extends Record<string, unknown>>(initialValues: T) {
  const [values, setValues] = useState<T>(initialValues);
  return { values };
}

const { values } = useFormGood({ username: '', age: 0 });

// values 的类型是 { username: string; age: number }
// TS 知道具体有哪些字段,每个字段是什么类型

values.username;  // 类型是 string ✅
values.age;       // 类型是 number ✅
values.foo;       // ❌ 编译错误:Property 'foo' does not exist

对比总结

直接指定类型 泛型 + extends
参数类型 Record<string, unknown> T(具体类型)
返回值类型 Record<string, unknown> T(具体类型)
字段访问 任意字段都不报错 只能访问存在的字段
字段类型 全是 unknown 保留原始类型
IDE 提示 有完整的字段补全

泛型的核心价值就是:让类型信息在函数调用过程中"流动",而不是被抹掉

深入理解"类型流动"

什么是类型被"抹掉"?

当你用宽泛的类型(如 Record<string, unknown>anyobject)作为参数类型时,TS 只能记住这个宽泛的类型,具体的类型信息就丢失了:

function identity(value: object): object {
  return value;
}

const user = identity({ name: 'john', age: 25 });
// user 的类型是 object
// TS 已经"忘记"了它原本是 { name: string; age: number }

类型信息在进入函数时被"抹掉"了,出来后就只剩下 object

什么是类型"流动"?

泛型让 TS 把具体类型"带着走",从输入流动到输出:

function identity<T>(value: T): T {
  return value;
}

const user = identity({ name: 'john', age: 25 });
// user 的类型是 { name: string; age: number }
// TS "记住"了具体类型,并让它流动到返回值

流动过程可视化

没有泛型(类型被抹掉):
{ name: string; age: number } ──进入函数──► object ──返回──► object
                                    ↑
                              类型信息在这里丢失

有泛型(类型流动):
{ name: string; age: number } ──进入函数──► T ──返回──► { name: string; age: number }
                                    ↑              ↑
                              T 记住了具体类型    原样流出

在 useForm 中的体现

// 类型流动的完整链路:
useForm({ username: '', age: 0 })
    │
    ▼ 输入类型 { username: string; age: number }
    │
    ▼ T 捕获这个类型
    │
    ▼ useState<T> 使用这个类型
    │
    ▼ setField<K extends keyof T> 使用这个类型
    │
    ▼ 返回的 values 保持这个类型
    │
    ▼ 调用方拿到的 values 类型是 { username: string; age: number }

整个过程中,{ username: string; age: number } 这个具体类型被 T 捕获后,一路流动到最终的返回值,没有任何地方丢失。

为什么"流动"很重要?

因为类型的价值在于使用时。如果类型在函数调用后丢失了,后续代码就无法获得类型检查和 IDE 提示:

// 类型丢失后,后续代码"裸奔"
const { values } = useFormBad({ username: '', age: 0 });
values.usernam = 'test';  // 拼写错误,不报错
values.age = '25';        // 类型错误,不报错

// 类型流动后,后续代码受保护
const { values } = useFormGood({ username: '', age: 0 });
values.usernam = 'test';  // ❌ 编译错误:属性不存在
values.age = '25';        // ❌ 编译错误:类型不匹配

一句话总结:泛型就像一个"类型变量",它在函数入口捕获具体类型,然后把这个类型带到函数内部和出口,让整个调用链路都能享受到类型安全。

约束的意义

extends 约束有两个作用:

  1. 限制调用者:只能传入符合约束的类型,传错了编译报错
  2. 保障实现者:函数内部可以安全地假设 T 是对象类型,使用 keyof T 等操作
function useForm<T extends Record<string, unknown>>(initialValues: T) {
  // 因为 T extends Record<string, unknown>
  // 所以 keyof T 一定是 string(对象的 key)
  // 这里的操作是类型安全的
  const keys: (keyof T)[] = Object.keys(initialValues) as (keyof T)[];
}

总结

概念 说明
泛型变量 T 类型占位符,调用时由参数反推确定
extends 约束 限制 T 必须满足某种结构,否则编译报错
反推机制 TS 根据实际传入的参数,自动推断出 T 的具体类型
约束 + 反推 既保证类型安全(约束),又保留具体类型信息(反推)

六、VSCode 如何获得类型提示

核心:TypeScript Language Server

你写代码 → VSCode → TypeScript Language Server → 类型推导引擎
                              ↓
                    返回类型信息、错误、补全建议
                              ↓
                    VSCode 显示提示/报错/补全

工作机制

  1. VSCode 内置 TS 扩展,启动时自动运行 tsserver
  2. 每次敲键盘,VSCode 通过 LSP 协议把代码发给 tsserver
  3. tsserver 实时分析:解析 AST → 运行类型推导 → 返回结果
  4. VSCode 渲染:红色波浪线、悬浮提示、自动补全列表

功能对应

功能 背后在做什么
悬浮显示类型 tsserver 推导出类型返回给 VSCode
自动补全 tsserver 返回当前上下文可用的属性/方法
红色波浪线 tsserver 检测到类型错误
F12 跳转定义 tsserver 找到符号定义位置

类型推导的"智能"全在 TypeScript 编译器里,VSCode 只是显示层。


总结

概念 作用
TypeScript 类型 编译时检查你写的代码
Zod 运行时验证外部数据 + Schema 自动导出类型
z.infer Schema 和类型统一,Single Source of Truth
泛型 让类型可复用,让类型信息"流动"
条件类型 根据条件返回不同类型
infer 从复杂类型中提取部分
LSP 编辑器获得类型提示的桥梁

核心理念

TypeScript 类型推断:把运行时错误变成编译时错误。通过泛型、条件类型等机制,让类型信息在代码中"流动",在编译阶段就能发现字段拼写错误、类型不匹配等问题。

setField 为例:

// ❌ 没有类型推断的写法
function setFieldBad(field: string, value: unknown) {
  // ...
}

setFieldBad('usernmae', 'john');  // 拼写错误,编译不报错,运行时字段没更新
setFieldBad('age', '25');         // 类型错误,编译不报错,运行时逻辑出问题

// ✅ 有类型推断的写法
function setField<K extends keyof T>(field: K, value: T[K]) {
  // ...
}

const { setField } = useForm({ username: '', age: 0 });

setField('usernmae', 'john');  // ❌ 编译错误:'usernmae' 不在 'username' | 'age' 中
setField('age', '25');         // ❌ 编译错误:类型 'string' 不能赋值给类型 'number'
setField('age', 25);           // ✅ 编译通过

关键在于 K extends keyof TT[K] 的配合:

  • TS 根据你传入的初始值,推断出 T = { username: string; age: number }
  • 调用 setField('age', ...) 时,推断出 K = 'age',进而推断出 value 必须是 number
  • 字段名和值类型的对应关系,在编译时就被锁死了

这就是类型推断的威力:不是简单的类型标注,而是让 TS 根据上下文自动推导出约束关系,把本该运行时才暴露的 bug 提前到编译时拦截

Zod 的核心价值:Schema 即类型(Single Source of Truth)。

  • 传统方式:手写 interface + 手写验证逻辑,两份代码容易不同步
  • Zod 方式:只写一次 Schema,通过 z.infer 自动导出 TypeScript 类型
// 一处定义,两处受益
const UserSchema = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof UserSchema>;  // 编译时类型
UserSchema.parse(data);                   // 运行时验证

两者的关系

  • TypeScript 保证你写的代码类型正确(编译时)
  • Zod 保证外部数据符合预期(运行时)
  • z.infer 让 Zod Schema 和 TS 类型统一,避免重复定义

两者互补,覆盖了编译时和运行时的类型安全。

CSS 新特性!瀑布流布局的终极解决方案

作者 冴羽
2026年1月8日 17:11

前言

前端开发一直有一个老大难的问题,那就是——瀑布流布局。

效果需求并不复杂:卡片错落,参差有致,看起来高级,滚动起来流畅。

就是这样一个看似简单的效果,其实已经困扰了前端开发者好多年。

要引入 JavaScript 库,要让内容智能填充,要实现响应式布局,写无数个媒体查询,要实现无限滚动加载,要用 JavaScript 处理复杂的布局逻辑……

现在,经过 Mozilla、苹果 WebKit 团队、CSS 工作组和所有浏览器的多轮讨论,它终于有了终极解决方案!

这就是 CSS Grid Lanes

且让我们先翻译它为“CSS 网格车道”吧。

之所以叫车道,想象一下高速公路:有好几条车道,车辆会自动选择最短的那条车道排队。

CSS Grid Lanes 就是这个原理——你先定义好有几条“车道”(列),网页内容会自动填充到最短的那一列,就像车辆自动选择最不拥堵的车道一样。

具体使用起来也很简单,三行代码就能实现:

.container {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 16px;
}

实现原理

现在,让我们来细致讲解下如何实现开头图中的瀑布流效果。

首先是 HTML 代码:

<main class="container">
  <figure><img src="photo-1.jpg" /></figure>
  <figure><img src="photo-2.jpg" /></figure>
  <figure><img src="photo-3.jpg" /></figure>
  <!-- etc -->
</main>

然后是 CSS 代码:

.container {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 16px;
}

代码一共 3 行。

display: grid-lanes 创建网格容器,使用瀑布流布局。

grid-template-columns 创建车道,我们将值设为 repeat(auto-fill, minmax(250px, 1fr))意思是至少 250 像素宽的灵活列。浏览器决定创建多少列,并填充所有可用空间。

gap: 16px表示车道之间有 16px 的间歇。

就是这么简单。

3 行 CSS 代码,无需任何媒体查询或容器查询,我们就创建了一个适用于所有屏幕尺寸的灵活布局。


更绝的是,这种布局能让用户通过 Tab 键在各个栏目之间切换,访问所有当前可见的内容(而不是像以前那样,先滚动到第一列底部,然后再返回第二列顶部)。

它也支持你实现无限循环加载,随着用户滚动页面,内容无限加载,而无需使用 JavaScript 来处理布局。

功能强大

不同车道尺寸

Grid Lanes 充分利用了 CSS Grid 的强大功能 grid-template-*来定义车道,所以很容易创建出富有创意的布局。

例如,我们可以创建一个布局,其中窄列和宽列交替出现——即使列数随视口大小而变化,第一列和最后一列也始终是窄列。

实现也很简单:

grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr) minmax(16rem, 2fr)) minmax(8rem, 1fr);

效果如下:

跨车道

由于我们拥有网格布局的全部功能,我们当然也可以跨越车道。

效果如下:

实现代码:

main {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(20ch, 1fr));
  gap: 2lh;
}
article {
  grid-column: span 1;
}
@media (1250px < width) {
  article:nth-child(1) {
    grid-column: span 4;
  }
  article:nth-child(2),
  article:nth-child(3),
  article:nth-child(4),
  article:nth-child(5),
  article:nth-child(6),
  article:nth-child(7),
  article:nth-child(8) {
    grid-column: span 2;
  }
}

放置项目

我们也可以在使用网格车道时显式地放置项目。这时,无论有多少列,标题始终位于最后一列。

实现代码为:

main {
  display: grid-lanes;
  grid-template-columns: repeat(auto-fill, minmax(24ch, 1fr));
}
header {
  grid-column: -3 / -1;
}

改变方向

网格车道也可以双向排列!

上面的所有示例创建的是“瀑布式”布局,内容以列的形式排列。

网格车道也可以用于创建另一种方向的布局,即“砖块式”布局。

当使用 grid-template-columns定义列时,浏览器会自动创建瀑布式布局,如下所示:

.container {
  display: grid-lanes;
  grid-template-columns: 1fr 1fr 1fr 1fr;
}

如果你想要反方向的砖块布局,使用 grid-template-rows

.container {
  display: grid-lanes;
  grid-template-rows: 1fr 1fr 1fr;
}

容差

“容差”是为 Grid Lanes 创建的一个新概念。它允许你调整布局算法在决定放置项目位置时的精确度。

回到高速公路的比喻:

假设 1 号车道前面的车比 2 号车道长了 1 厘米,下一辆车要排到哪条车道?

如果严格按“哪条短选哪条”,它会选 2 号车道。但 1 厘米的差距根本不重要!这样来回切换车道反而让人困惑。

“容差”就是告诉系统:“差距小于这个值,就当作一样长”。

容差默认值是 1em(大约一个字的高度)。

为什么容差很重要呢?

因为用键盘 Tab 键浏览网页的人(比如视障用户)会按内容顺序跳转。

如果布局乱跳,他们会很迷惑。合适的容差能让浏览体验更流畅。

现在能用吗?

目前可以在 Safari 技术预览版 234 中体验,其他浏览器还在开发中。

苹果 WebKit 团队从 2022 年中就开始实现这个功能,现在基本语法已经稳定了。虽然还有些细节在讨论(比如属性命名),但核心用法不会变。

你可以访问 webkit.org/demos/grid3 看各种实际例子。

最后

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

em,rem,px,rpx单位换算,你弄懂了吗?

2026年1月8日 17:01

一、在网页上面的换算

em与rem与px的换算

em公式

em × 父元素字体大小 = px
<!-- 父元素:字体 20px -->
<div style="font-size: 20px">
  <!-- 子元素:padding 1.25em -->
  <p style="padding: 1.25em">
    内容
  </p>
</div>

计算:

1.25em × 20px = 25px
所以 padding = 25px
嵌套情况(多层父元素)
<div style="font-size: 20px">        <!-- 最外层父元素 -->
  <div style="font-size: 18px">      <!-- 中间父元素 -->
    <p style="padding: 1.25em">      <!-- 子元素 -->
      内容
    </p>
  </div>
</div>
  • 子元素的 1.25em 相对于直接父元素(中间 div)
  • 1.25em × 18px = 22.5px

rem基础公式

rem值*根元素字体大小=px值
默认情况

浏览器根元素(<html>)的默认字体大小的16px。

所以

1rem=16px
常见换算

image.png

快速换算方法
rem 值 × 16 = px 值

或者

px 值 ÷ 16 = rem 值
实际应用
/* UnoCSS 类名 */
leading-5        → line-height: 1.25rem → 20px
p-4padding: 1rem → 16px
p-5padding: 1.25rem → 20px
w-10width: 2.5rem → 40px
如果根元素字体大小被改了
html {
  font-size: 20px;  /* 不是默认的 16px */
}

那么

1rem = 20px
1.25rem = 1.25 × 20 = 25px  (不是 20px 了)

三种单位的区别:

  1. px(像素)-绝对单位
px = 固定像素值,不会变化
  1. em(相对父元素)
em × 父元素字体大小 = px
  1. rem(Root Em)- 相对根元素
rem = 相对于根元素(<html>)的字体大小

二、在小程序中换算

rpx(responsive pixel)是微信小程序的响应式单位,不是标准css单位。

规则:
屏幕宽度 = 750rpx
换算公式:
1rpx = 屏幕宽度 ÷ 750

反过来

1px = 750 ÷ 屏幕宽度 rpx

不同设备的换算

iphone6(基础设备)
屏幕宽度:375px
1rpx = 375px ÷ 750 = 0.5px
1px = 2rpx
iPhone6 Plus
屏幕宽度:414px
1rpx = 414px ÷ 750 = 0.552px
1px ≈ 1.81rpx
iPhone X
屏幕宽度:375px
1rpx = 375px ÷ 750 = 0.5px
1px = 2rpx
快速换算(以iPhone6为基准)

常用换算

1px = 2rpx
10px = 20rpx
20px = 40rpx
50px = 100rpx
100px = 200rpx

注意事项

1. rpx 只在微信小程序中使用

  • 标准 CSS/HTML 不支持 rpx

  • UnoCSS 不支持 rpx

  • 只在微信小程序、uni-app 等框架中使用

2. 不同设备换算不同

  • iPhone6:1px = 2rpx

  • iPhone6 Plus:1px ≈ 1.81rpx

  • 安卓:1px ≈ 2.08rpx

  • 设计稿宽度用 750px

  • 设计稿中的 px 值直接作为 rpx 值使用

  • 例如:设计稿 100px → 代码 100rpx

实际开发中的话,小程序以iphone6/7/8为基准设备。
iPhone6/7/8
屏幕宽度:375px
屏幕高度:667px

设计稿标准

设计稿宽度:750px(2倍图)
设计稿高度:1334px(2倍图)

Vue 响应式原理深度解析

作者 满天星辰
2026年1月8日 16:57

🏗️ 核心架构概览

1. 响应式系统三层架构

┌─────────────────────────────────────────┐
│           组件渲染系统 (Renderer)        │
├─────────────────────────────────────────┤
│         响应式系统 (Reactivity)          │
│  ┌─────────────┐  ┌──────────────────┐  │
│  │  依赖收集    │  │  触发更新         │  │
│  │  (Track)    │  │  (Trigger)       │  │
│  └─────────────┘  └──────────────────┘  │
├─────────────────────────────────────────┤
│       原始响应式对象 (Raw Objects)       │
└─────────────────────────────────────────┘

🔄 Vue 2 vs Vue 3 响应式原理对比

2. Vue 2: Object.defineProperty

// Vue 2 响应式实现简化版
function defineReactive(obj, key, val) {
  // 为每个属性创建 Dep(依赖收集器)
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 依赖收集
      if (Dep.target) {
        dep.depend()  // 收集当前 watcher
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      // 触发更新
      dep.notify()
    }
  })
}

// 递归对象实现深度响应式
function observe(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return
  }
  
  // 递归处理对象属性
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

// Vue 2 的局限性:
// 1. 无法检测对象属性的添加/删除
// 2. 数组变异方法需要特殊处理
// 3. 性能问题:递归遍历所有属性

3. Vue 3: Proxy + Reflect

// Vue 3 响应式实现简化版
function reactive(target) {
  return createReactiveObject(target)
}

function createReactiveObject(target) {
  // 创建响应式处理器
  const handler = {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      // 依赖收集
      track(target, key)
      // 深度响应式:如果值是对象,递归处理
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    
    set(target, key, value, receiver) {
      const oldValue = target[key]
      const result = Reflect.set(target, key, value, receiver)
      
      // 只有值变化时才触发更新
      if (oldValue !== value) {
        // 触发更新
        trigger(target, key)
      }
      return result
    },
    
    deleteProperty(target, key) {
      const hasKey = hasOwn(target, key)
      const result = Reflect.deleteProperty(target, key)
      
      if (hasKey && result) {
        // 触发更新
        trigger(target, key)
      }
      return result
    },
    
    has(target, key) {
      const result = Reflect.has(target, key)
      track(target, key)
      return result
    },
    
    ownKeys(target) {
      track(target, ITERATE_KEY)  // 追踪迭代操作
      return Reflect.ownKeys(target)
    }
  }
  
  return new Proxy(target, handler)
}

// Vue 3 的优势:
// 1. 支持对象属性的添加/删除
// 2. 更好的性能(惰性代理)
// 3. 支持 Map、Set 等集合类型
// 4. 更精细的依赖追踪

🎯 核心概念详解

4. 依赖收集 (Track)

// 全局的依赖收集栈
let activeEffect = null
const targetMap = new WeakMap()  // 目标对象 → 键 → 依赖集合

function track(target, key) {
  if (!activeEffect) return
  
  // 获取目标对象的依赖映射
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  
  // 获取属性的依赖集合
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  
  // 收集当前 effect
  dep.add(activeEffect)
  activeEffect.deps.push(dep)  // 反向记录,用于清理
}

// Effect 类(相当于 Vue 2 的 Watcher)
class ReactiveEffect {
  constructor(fn, scheduler) {
    this.fn = fn
    this.scheduler = scheduler
    this.deps = []  // 依赖此 effect 的所有 dep
    this.active = true
  }
  
  run() {
    if (!this.active) return this.fn()
    
    // 保存当前 effect,开始依赖收集
    const lastEffect = activeEffect
    activeEffect = this
    try {
      return this.fn()
    } finally {
      // 恢复之前的 effect
      activeEffect = lastEffect
    }
  }
  
  stop() {
    if (this.active) {
      // 清理所有依赖
      cleanupEffect(this)
      this.active = false
    }
  }
}

function cleanupEffect(effect) {
  const { deps } = effect
  deps.forEach(dep => {
    dep.delete(effect)
  })
  deps.length = 0
}

5. 触发更新 (Trigger)

function trigger(target, key, type = 'SET') {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  
  // 获取该属性的依赖 effects
  const effects = depsMap.get(key)
  
  // 获取迭代操作的依赖(用于 for...in, Object.keys 等)
  const iterateEffects = depsMap.get(ITERATE_KEY)
  
  const effectsToRun = new Set()
  
  // 收集需要执行的 effects
  if (effects) {
    effects.forEach(effect => {
      if (effect !== activeEffect) {
        effectsToRun.add(effect)
      }
    })
  }
  
  // 对于添加/删除属性,需要触发迭代依赖
  if (type === 'ADD' || type === 'DELETE') {
    if (iterateEffects) {
      iterateEffects.forEach(effect => {
        if (effect !== activeEffect) {
          effectsToRun.add(effect)
        }
      })
    }
  }
  
  // 执行 effects
  effectsToRun.forEach(effect => {
    if (effect.scheduler) {
      effect.scheduler()  // 调度执行(用于 computed、watch)
    } else {
      effect.run()  // 立即执行
    }
  })
}

⚡ 响应式 API 实现

6. ref 实现原理

class RefImpl {
  constructor(value, shallow = false) {
    this._value = shallow ? value : toReactive(value)
    this._rawValue = value
    this.__v_isRef = true
    this._shallow = shallow
    
    // 为 ref 创建依赖集合
    this.dep = new Set()
  }
  
  get value() {
    // 依赖收集
    trackRefValue(this)
    return this._value
  }
  
  set value(newVal) {
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal
      this._value = this._shallow ? newVal : toReactive(newVal)
      // 触发更新
      triggerRefValue(this)
    }
  }
}

function trackRefValue(ref) {
  if (activeEffect) {
    ref.dep.add(activeEffect)
    activeEffect.deps.push(ref.dep)
  }
}

function triggerRefValue(ref) {
  const effects = ref.dep
  effects.forEach(effect => {
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  })
}

function toReactive(value) {
  return isObject(value) ? reactive(value) : value
}

7. computed 实现原理

class ComputedRefImpl {
  constructor(getter, setter) {
    this._getter = getter
    this._setter = setter
    this._value = undefined
    this._dirty = true  // 脏检查标志
    this.dep = new Set()
    this.effect = new ReactiveEffect(
      getter,
      () => {
        // 当依赖变化时,标记为脏
        if (!this._dirty) {
          this._dirty = true
          // 触发依赖 computed 的 effects
          triggerRefValue(this)
        }
      }
    )
  }
  
  get value() {
    // 依赖收集
    trackRefValue(this)
    
    // 如果脏了,重新计算
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
  
  set value(newVal) {
    if (this._setter) {
      this._setter(newVal)
    } else {
      console.warn('Write operation failed: computed value is readonly')
    }
  }
}

🔧 编译时优化

8. Patch Flags 优化

// 模板编译优化示例
// 编译前模板
<template>
  <div>
    <span>静态内容</span>
    <span>{{ dynamic }}</span>
    <div :class="className"></div>
  </div>
</template>

// 编译后代码
import { createElementVNode as _createElementVNode } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode(
  "span", 
  null, 
  "静态内容",
  -1 /* HOISTED */
)

export function render(_ctx, _cache) {
  return _createElementVNode(
    "div", 
    null, [
      _hoisted_1,  // 静态节点提升
      _createElementVNode(
        "span", 
        null, 
        _toDisplayString(_ctx.dynamic),
        1 /* TEXT */
      ),
      _createElementVNode(
        "div", 
        {
          class: normalizeClass(_ctx.className)
        },
        null,
        2 /* CLASS */
      )
    ],
    0 /* 无标志 */
  )
}

// Patch Flags 类型
const PatchFlags = {
  TEXT: 1,        // 动态文本
  CLASS: 2,       // 动态 class
  STYLE: 4,       // 动态 style
  PROPS: 8,       // 动态 props
  FULL_PROPS: 16, // 动态 key,需要全量 diff
  HYDRATE_EVENTS: 32,
  STABLE_FRAGMENT: 64,
  KEYED_FRAGMENT: 128,
  UNKEYED_FRAGMENT: 256,
  NEED_PATCH: 512,
  DYNAMIC_SLOTS: 1024,
  HOISTED: -1,    // 静态提升
  BAIL: -2        // 特殊情况
}

9. Tree Flattening(树结构打平)

// 优化前的 VNode 结构
{
  type: 'div',
  children: [
    { type: 'p', children: '静态内容' },
    { type: 'span', children: [/* 动态内容 */] },
    { type: 'div', children: [/* 更多嵌套 */] }
  ]
}

// 优化后的 VNode 结构
{
  type: 'div',
  children: [
    { type: 'p', children: '静态内容' },
    // 动态节点被提取到单独的数组中
  ],
  // 动态子节点被收集到这里
  dynamicChildren: [
    { type: 'span', patchFlag: 1 },
    { type: 'div', patchFlag: 2 }
  ]
}

// 更新时只 diff dynamicChildren,跳过静态节点

📊 响应式性能优化

10. 依赖收集优化

// 1. 惰性依赖收集
let shouldTrack = true

function pauseTracking() {
  shouldTrack = false
}

function enableTracking() {
  shouldTrack = true
}

function track(target, key) {
  if (!shouldTrack || !activeEffect) return
  // ... 原有的 track 逻辑
}

// 2. 批量更新
let isFlushing = false
let queue = []

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job)
  }
  
  // 下一个 tick 执行所有更新
  if (!isFlushing) {
    isFlushing = true
    Promise.resolve().then(() => {
      try {
        queue.forEach(job => job())
      } finally {
        queue.length = 0
        isFlushing = false
      }
    })
  }
}

// 3. 响应式层级的优化
const RAW = '__v_raw'  // 原始对象标记

function toRaw(observed) {
  const raw = observed && observed[RAW]
  return raw ? toRaw(raw) : observed
}

// 避免深层代理的重复创建
const proxyMap = new WeakMap()

function createReactiveObject(target) {
  // 检查是否已经有代理
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }
  
  const proxy = new Proxy(target, handlers)
  proxyMap.set(target, proxy)
  return proxy
}

🔄 响应式系统的调度机制

11. 更新队列与调度器

// 响应式任务调度器
const queue = []
let isFlushing = false
const resolvedPromise = Promise.resolve()

function queueFlush() {
  if (!isFlushing && !queue.length) {
    isFlushing = true
    resolvedPromise.then(flushJobs)
  }
}

function flushJobs() {
  try {
    // 先执行所有前置任务
    for (let i = 0; i < queue.length; i++) {
      const job = queue[i]
      if (job && job.active !== false) {
        if (job.pre) {
          job()
        }
      }
    }
    
    // 执行组件更新
    for (let i = 0; i < queue.length; i++) {
      const job = queue[i]
      if (job && job.active !== false) {
        if (!job.pre) {
          job()
        }
      }
    }
  } finally {
    // 清空队列
    queue.length = 0
    isFlushing = false
    
    // 执行后置任务
    // flushPostFlushCbs()
  }
}

// watch 的调度实现
function watch(source, cb, options = {}) {
  const { flush = 'sync' } = options
  
  let scheduler
  if (flush === 'sync') {
    scheduler = job => job()  // 同步执行
  } else if (flush === 'pre') {
    scheduler = job => queueJob(job)  // 组件更新前
  } else { // 'post'
    scheduler = job => queuePostFlushCb(job)  // 组件更新后
  }
  
  const effect = new ReactiveEffect(getter, scheduler)
  // ...
}

🎯 响应式系统的边界情况

12. 循环引用处理

// 使用 WeakMap 避免循环引用
const reactiveMap = new WeakMap()

function reactive(obj) {
  // 如果已经代理过,直接返回
  const existing = reactiveMap.get(obj)
  if (existing) return existing
  
  // 如果对象有循环引用标记,跳过
  if (obj && obj.__v_skip) {
    return obj
  }
  
  const proxy = createReactiveObject(obj)
  reactiveMap.set(obj, proxy)
  return proxy
}

// 标记跳过响应式的对象
function markRaw(value) {
  Object.defineProperty(value, '__v_skip', {
    value: true,
    enumerable: false,
    configurable: true
  })
  return value
}

13. 大对象的性能优化

// 浅层响应式
function shallowReactive(target) {
  return createReactiveObject(target, true)
}

function createReactiveObject(target, shallow = false) {
  const handlers = shallow ? shallowHandlers : baseHandlers
  // ...
}

// 浅层 handlers
const shallowHandlers = {
  get(target, key, receiver) {
    const result = Reflect.get(target, key, receiver)
    track(target, key)
    
    // 关键区别:不递归处理嵌套对象
    return result  // 直接返回,不再包装
  },
  // ... 其他 handlers
}

// 惰性响应式:只在实际访问时进行代理
function lazyReactive(target) {
  let proxyCache = null
  
  return new Proxy(target, {
    get(target, key, receiver) {
      if (key === '__v_raw') return target
      
      // 第一次访问时才创建真正的响应式代理
      if (!proxyCache) {
        proxyCache = reactive(target)
      }
      
      return Reflect.get(proxyCache, key, receiver)
    },
    // ... 其他操作
  })
}

📈 响应式系统的监控与调试

14. 开发工具集成

// 响应式调试信息
function setupReactivityDebug() {
  // 1. 跟踪依赖关系
  const dependencyGraph = new Map()
  
  // 2. 性能监控
  const performance = {
    trackTime: 0,
    triggerTime: 0,
    effectCount: 0
  }
  
  // 3. 内存泄漏检测
  const effectRegistry = new WeakSet()
  
  return {
    // 暴露给 Vue DevTools 的接口
    inspect(target) {
      const depsMap = targetMap.get(target)
      return {
        deps: depsMap ? Array.from(depsMap.entries()) : [],
        rawValue: toRaw(target),
        isReactive: isReactive(target)
      }
    },
    
    // 性能分析
    getPerformance() {
      return { ...performance }
    },
    
    // 清理所有 effect
    disposeAll() {
      // 清理逻辑
    }
  }
}

// Vue DevTools 中的响应式面板可以看到:
// 1. 响应式对象的依赖图
// 2. 每个属性的依赖列表
// 3. Effect 的执行历史
// 4. 性能分析数据

🎓 总结:Vue 响应式系统的演进

Vue 2 到 Vue 3 的关键改进

  1. 底层实现Object.defineProperty → Proxy
  2. 性能:递归初始化 → 惰性代理
  3. 功能:不支持新增属性 → 完全支持
  4. 类型支持:有限的类型推断 → 完整的 TypeScript 支持
  5. 集合类型:不支持 Map/Set → 完整支持

核心创新点

  • 编译时优化:Patch Flags、Tree Flattening
  • 依赖收集粒度:组件级 → 属性级
  • 更新调度:同步更新 → 异步批量更新
  • 内存管理:WeakMap 自动垃圾回收

设计哲学

// Vue 响应式系统的设计目标:
1. 透明性:开发者无需手动管理依赖
2. 高效性:最小化的更新范围
3. 一致性:同步的编程模型,异步的更新执行
4. 渐进性:从简单到复杂的平滑过渡
5. 可调试性:完整的开发工具支持

// 这就是为什么 Vue 的响应式系统既强大又易用

Vue 的响应式系统是其框架的核心,它巧妙地将声明式编程高效的更新机制结合起来,为开发者提供了极佳的开发体验,同时保证了优秀的运行时性能。

Nice-modal-react的使用

2026年1月8日 16:53

创建modal

import { Modal } from 'antd';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import WarningSvg from '@/assets/warning.svg?react';

export const BrowserNotSatisfiedModal = NiceModal.create(() => {
  const modal = useModal();
  const modalProps: any = NiceModal.antdModal(modal);
  modalProps.open = modalProps.visible;
  delete modalProps.visible;

  const handleOK = () => {
    modal.hide();
  };

  return (
    <Modal
      {...modalProps}
      title={
        <div>
          <WarningSvg
            style={{
              fontSize: '22px',
              marginRight: '12px',
              verticalAlign: 'top',
            }}
          />
          提示
        </div>
      }
      centered
      onOk={handleOK}
      okText="确定"
      cancelText="取消"
    >
      <p style={{ margin: '20px 0 40px' }}>
        浏览器版本过低,可能存在部分内容不兼容,建议升级浏览器后使用
      </p>
    </Modal>
  );
});


使用弹框

NiceModal.show(AppealDialog, props);

注意:这里NiceModal返回的是一个Promise 可以这样使用

 NiceModal.show(ConsumerFormModal, {
    fields: (headerFields?.fields as unknown as ConsumerFormField[]) ?? [],
    editRecod: record,
    isEdit: true,
    })
    .then(()=>{})
    .catch(()=>{})
    .finally(() => {
    setIsEditConsumerTableFlag?.(false);
    });

JavaScript有哪些数据类型?如何判断一个变量的数据类型?

作者 码途潇潇
2026年1月8日 16:33

JavaScript有哪些数据类型?如何判断一个变量的数据类型?

JavaScript 有其内建的数据类型。其中又分为原生值(primitive values)和对象(objects)。

1.JavaScript 的原生值 (primitive values)

截至目前,JavaScript 的资料型别中,有七个原生值。这七个原生值以外的,全都是属于对象。

原生值是不可变的(immutable),意思是我们不能改变那个值本身。比如当我们“修改”原始值时,实际上是让变量重新绑定到一个新的值,而不是在原值上进行修改。

let greeting = "Hi";
greeting = "Hello"; // 此时虽然打印 greeting 是 Hello,但是 Hi 这个值本身是没变的,只是 greeting 指向了Hello 这个新值

JavaScript 的型别中的七个原生值包含:

1.1 字符串 String

String (字符串)

字串是最常见的原生值之一。如前面提到,在JavaScript 当中字串本身是不可变的。当我们用substring()来撷取字串,或用concat()来把两个字串合为一,这些都是会回传另一个字串,而非改变原本的字串。

1.2 布尔值 Boolean

Boolean (布尔值)

truefalse两个值的布林值,也是JavaScript 的原生值。

1.3 Number

JavaScript 与一些语言不同,没有分整数与浮点数,而是都用number这个原生值。不论整数或浮点数,都是number这个型别。在JavaScript 当中,+Infinity, -Infinity, 与 NaN 都是number这个型别,所以我们用typeof来检查的话,会得到number

console.log(typeof NaN); // number

number在JavaScript 是双精度浮点数,所以精确度是介于-(2^53 − 1)2^53 − 1之间。在这个范围之外,就会有精准度的问题,这时候要用另一个原生值BigInt

1.4 BigInt

上面提到在JavaScript 的整数与浮点数,都是用number这个型别,这其实只说了一半。因为JavaScript 的number精准度有其限制,虽然多数情况很够用(2^53 - 1会是9007199254740991,我们很少用到比这大的数)。但有些时候会需要更往下的精准度。这时就可以用BigInt数值的型别。

BigInt可以让我们任意选择其精准度,就可以避免一些number会遇到的问题。它跟number一样可以用+, *, -, **, 与 %等运算子,不过要注意不可以拿BigIntnumber型别的值交互使用,这会出现TypeError

1.5 Undefined

undefined是一个类型,它本身也是一个值。

  • 假如某个变量未声明就是用,会出现索引错误ReferenceError

    console.log(a);
    
    ReferenceError: a is not defined
    
  • 用 let / const 声明了,但在声明前使用

    console.log(a);
    let a = 1;
    
    ReferenceError: Cannot access 'a' before initialization
    
  • 某个变量声明但是没有赋值

    let greeting;
    console.log(greeting); // undefined
    

1.6 Null

null是很容易跟undefined搞混的原生值。undefined是因为某变数还没有赋值,所以对JavaScript 来说,它不知道该变数的值是什么,所以要读取该变数时,会是undefined。不过 则null是我们赋予某个变数null这一个值。

1.7 Symbol

最后一个JavaScript 原生值是Symbol,它是一个独特(unique) 值,多半会搭配物件一起使用,作为物件的键(key)。

const sym = Symbol("ExplainYYDS");
const obj = { [sym]: "Interview Preps for Software Engineers" };
obj[sym]; // Interview Preps for Software Engineers

2.JavaScript中的对象(objects)

除了上述的七个原生值以外的存在,在JavaScript 当中都是对象。

JavaScript 圈有一个梗,数组是对象,函数是对象,对象是对象。

3. 如何辨别一个变量的数据类型?

3.1 使用typeof判断变量的数据类型

要辨别一个变数的数据类型,最常见的方式是透过typeof这个方法。举例来说typeof判断字符串。

let greeting = "hi";
console.log(typeof greeting); // 'string'

typeof 的两个注意点

  • typeof 原生值,返回的是该原生值的类型标识字符串,但是 typeof null 结果不是 null,而是object,这个历史遗留的 bug,修复成本太高了
  • typeof 数组的结果是 object ,tpyeof 函数的结果是 function
console.log(typeof null); // object
console.log(typeof []); // object
console.log(typeof function () {}); // function

补充typeof结果的表格

Type of val Result
Undefined "undefined"
Null "object"
Boolean "boolean"
Number "number"
String "string"
Object (native and does not implement [[Call]]) "object"
Object (native or host and does implement [[Call]]) "function"
Object (host and does not implement [[Call]]) Implementation-defined except may not be "undefined", "boolean", "number", or "string".

因为 typeof 无法判断数组和对象,所以需要新的方法去判断某个变量是对象还是数组。

3.2 判断变量是对象还是数组?

Array.isArray()是可以协助我们的方法。如果是数组,会回传true;但若是一般对象,则会回传false。举例来说:

Array.isArray([1, 2, 3]); // true
Array.isArray({ foo: 123 }); // false

我们也可以透过Object.prototype.toString()的方法帮助我们辨别数组、函数与一般对象。

const arr = [1, 2, 3];
const fn = () => {
  return 123;
};
const obj = { foo: 123 };

console.log(Object.prototype.toString.call(arr)); // [object Array]
console.log(Object.prototype.toString.call(fn)); // [object Function]
console.log(Object.prototype.toString.call(obj)); // [object Object]
❌
❌