普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月12日技术

FTP Cheatsheet

Connect and Login

Start an FTP session and authenticate.

Command Description
ftp hostname Connect to server
ftp 192.168.1.10 Connect by IP
ftp hostname 21 Connect to a custom port
user username Log in with username (prompts for password)
user username password Log in with username and password
quit Quit session
bye Quit session (alias for quit)

Local and Remote Paths

Navigate directories on both sides.

Command Description
pwd Show remote working directory
!pwd Show local working directory
cd /remote/path Change remote directory
lcd /local/path Change local directory
ls List remote files
!ls List local files

Download Files

Transfer files from remote server to local system.

Command Description
get file.txt Download one file
get remote.txt local.txt Download and rename locally
mget *.log Download multiple files
prompt Toggle interactive prompt for mget
reget large.iso Resume interrupted download

Upload Files

Transfer local files to remote server.

Command Description
put file.txt Upload one file
put local.txt remote.txt Upload with remote name
mput *.txt Upload multiple files
append file.txt Append local file to remote file

Transfer Modes

Select ASCII or binary mode as needed.

Command Description
binary Set binary transfer mode
ascii Set text transfer mode
type Show current transfer mode
hash Show transfer progress marks
status Display session and transfer status

Remote File Management

Manage files and directories on the FTP server.

Command Description
mkdir dirname Create remote directory
rmdir dirname Remove remote directory
delete file.txt Delete remote file
mdelete *.tmp Delete multiple remote files
rename old.txt new.txt Rename remote file
size file.iso Show remote file size

Connection and Safety

Useful options to avoid transfer issues.

Command Description
passive Toggle passive mode
debug Toggle protocol debug output
verbose Toggle verbose transfer output
close Close connection, keep FTP shell
open hostname Reconnect to another host
!command Run a local shell command
help List available FTP commands

Batch and Non-Interactive

Run FTP commands from scripts.

Command Description
ftp -n hostname Disable auto-login
ftp -inv hostname Script-friendly mode
ftp hostname < commands.txt Run commands from file

Related Tools

Safer and encrypted alternatives when possible.

Tool Description
sftp SSH-based secure file transfer
scp Secure copy over SSH
rsync Efficient sync and incremental transfer

How to Parse Command-Line Options in Bash with getopts

The getopts command is a Bash built-in that provides a clean, structured way to parse command-line options in your scripts. Instead of manually looping through arguments with case and shift, getopts handles option parsing, argument extraction, and error reporting for you.

This guide explains how to use getopts to process options and arguments in Bash scripts. If you are new to command-line arguments, start with our guide on positional parameters .

Syntax

The basic syntax for getopts is:

sh
while getopts "optstring" VARNAME; do
 case $VARNAME in
 # handle each option
 esac
done
  • optstring — A string that defines which options the script accepts
  • VARNAME — A variable that holds the current option letter on each iteration

Each time the while loop runs, getopts processes the next option from the command line and stores the option letter in VARNAME. The loop ends when there are no more options to process.

Here is a simple example:

~/flags.shsh
#!/bin/bash

while getopts "vh" opt; do
 case $opt in
 v) echo "Verbose mode enabled" ;;
 h) echo "Usage: $0 [-v] [-h]"; exit 0 ;;
 esac
done

Run the script:

Terminal
./flags.sh -v
./flags.sh -h
./flags.sh -vh
output
Verbose mode enabled
Usage: ./flags.sh [-v] [-h]
Verbose mode enabled
Usage: ./flags.sh [-v] [-h]

Notice that -vh works the same as -v -h. The getopts command automatically handles combined short options.

The Option String

The option string tells getopts which option letters are valid and which ones require an argument. There are three patterns:

  • f — A simple flag (no argument). Used for boolean switches like -v for verbose.
  • f: — An option that requires an argument. The colon after the letter means the user must provide a value, such as -f filename.
  • : (leading colon) — Enables silent error mode. When placed at the very beginning of the option string, getopts suppresses its default error messages so you can handle errors yourself.

For example, the option string ":vf:o:" means:

  • : — Silent error mode
  • v — A simple flag (-v)
  • f: — An option requiring an argument (-f filename)
  • o: — An option requiring an argument (-o output)

OPTARG and OPTIND

When working with getopts, two special variables track the parsing state:

OPTARG

The OPTARG variable holds the argument value for options that require one. When you define an option with a trailing colon (e.g., f:), the value the user passes after -f is stored in OPTARG:

~/optarg_example.shsh
#!/bin/bash

while getopts "f:o:" opt; do
 case $opt in
 f) echo "Input file: $OPTARG" ;;
 o) echo "Output file: $OPTARG" ;;
 esac
done
Terminal
./optarg_example.sh -f data.csv -o results.txt
output
Input file: data.csv
Output file: results.txt

OPTIND

The OPTIND variable holds the index of the next argument to be processed. It starts at 1 and increments as getopts processes each option. After the while loop finishes, OPTIND points to the first non-option argument.

Use shift $((OPTIND - 1)) after the loop to remove all processed options, leaving only the remaining positional arguments in $@:

~/optind_example.shsh
#!/bin/bash

while getopts "v" opt; do
 case $opt in
 v) echo "Verbose mode enabled" ;;
 esac
done

shift $((OPTIND - 1))

echo "Remaining arguments: $@"
Terminal
./optind_example.sh -v file1.txt file2.txt
output
Verbose mode enabled
Remaining arguments: file1.txt file2.txt

The shift $((OPTIND - 1)) line is a common pattern. Without it, the processed options would still be part of the positional parameters, making it difficult to access the non-option arguments.

Error Handling

The getopts command has two error handling modes: verbose (default) and silent.

Verbose Mode (Default)

In verbose mode, getopts prints its own error messages when it encounters an invalid option or a missing argument:

~/verbose_errors.shsh
#!/bin/bash

while getopts "f:" opt; do
 case $opt in
 f) echo "File: $OPTARG" ;;
 esac
done
Terminal
./verbose_errors.sh -x
./verbose_errors.sh -f
output
./verbose_errors.sh: illegal option -- x
./verbose_errors.sh: option requires an argument -- f

In this mode, getopts sets opt to ? for both invalid options and missing arguments.

Silent Mode

Silent mode is enabled by adding a colon at the beginning of the option string. In this mode, getopts suppresses its default error messages and gives you more control:

  • For an invalid option, opt is set to ? and OPTARG contains the invalid option character.
  • For a missing argument, opt is set to : and OPTARG contains the option that was missing its argument.
~/silent_errors.shsh
#!/bin/bash

while getopts ":f:vh" opt; do
 case $opt in
 f) echo "File: $OPTARG" ;;
 v) echo "Verbose mode" ;;
 h) echo "Usage: $0 [-v] [-f file] [-h]"; exit 0 ;;
 \?) echo "Error: Invalid option -$OPTARG" >&2; exit 1 ;;
 :) echo "Error: Option -$OPTARG requires an argument" >&2; exit 1 ;;
 esac
done
Terminal
./silent_errors.sh -x
./silent_errors.sh -f
output
Error: Invalid option -x
Error: Option -f requires an argument

Silent mode is the recommended approach for production scripts because it allows you to write custom error messages that are more helpful to the user.

Practical Examples

Example 1: Script with Flags and Arguments

This script demonstrates a common pattern — a usage function , boolean flags, options with arguments, and input validation:

~/process.shsh
#!/bin/bash

usage() {
 echo "Usage: $0 [-v] [-o output] [-n count] file..."
 echo ""
 echo "Options:"
 echo " -v Enable verbose output"
 echo " -o output Write results to output file"
 echo " -n count Number of lines to process"
 echo " -h Show this help message"
 exit 1
}

VERBOSE=false
OUTPUT=""
COUNT=0

while getopts ":vo:n:h" opt; do
 case $opt in
 v) VERBOSE=true ;;
 o) OUTPUT="$OPTARG" ;;
 n) COUNT="$OPTARG" ;;
 h) usage ;;
 \?) echo "Error: Invalid option -$OPTARG" >&2; usage ;;
 :) echo "Error: Option -$OPTARG requires an argument" >&2; usage ;;
 esac
done

shift $((OPTIND - 1))

if [ $# -eq 0 ]; then
 echo "Error: No input files specified" >&2
 usage
fi

if [ "$VERBOSE" = true ]; then
 echo "Verbose: ON"
 echo "Output: ${OUTPUT:-stdout}"
 echo "Count: ${COUNT:-all}"
 echo "Files: $@"
 echo ""
fi

for file in "$@"; do
 if [ ! -f "$file" ]; then
 echo "Warning: '$file' not found, skipping" >&2
 continue
 fi

 if [ -n "$OUTPUT" ]; then
 if [ "$COUNT" -gt 0 ] 2>/dev/null; then
 head -n "$COUNT" "$file" >> "$OUTPUT"
 else
 cat "$file" >> "$OUTPUT"
 fi
 else
 if [ "$COUNT" -gt 0 ] 2>/dev/null; then
 head -n "$COUNT" "$file"
 else
 cat "$file"
 fi
 fi
done
Terminal
echo -e "line 1\nline 2\nline 3\nline 4\nline 5" > testfile.txt
./process.sh -v -n 3 testfile.txt
output
Verbose: ON
Output: stdout
Count: 3
Files: testfile.txt
line 1
line 2
line 3

The script parses the options first, then uses shift to access the remaining file arguments.

Example 2: Configuration Wrapper

This example shows a script that wraps another command, passing different configurations based on the options provided:

~/deploy.shsh
#!/bin/bash

ENV="staging"
DRY_RUN=false
TAG="latest"

while getopts ":e:t:dh" opt; do
 case $opt in
 e) ENV="$OPTARG" ;;
 t) TAG="$OPTARG" ;;
 d) DRY_RUN=true ;;
 h)
 echo "Usage: $0 [-e environment] [-t tag] [-d] service"
 echo ""
 echo " -e env Target environment (default: staging)"
 echo " -t tag Image tag (default: latest)"
 echo " -d Dry run mode"
 exit 0
 ;;
 \?) echo "Error: Invalid option -$OPTARG" >&2; exit 1 ;;
 :) echo "Error: Option -$OPTARG requires an argument" >&2; exit 1 ;;
 esac
done

shift $((OPTIND - 1))

SERVICE="${1:?Error: Service name required}"

echo "Deploying '$SERVICE' to $ENV with tag '$TAG'"

if [ "$DRY_RUN" = true ]; then
 echo "[DRY RUN] Would execute: docker pull myregistry/$SERVICE:$TAG"
 echo "[DRY RUN] Would execute: kubectl set image deployment/$SERVICE $SERVICE=myregistry/$SERVICE:$TAG -n $ENV"
else
 echo "Pulling image and updating deployment..."
fi
Terminal
./deploy.sh -e production -t v2.1.0 -d webapp
output
Deploying 'webapp' to production with tag 'v2.1.0'
[DRY RUN] Would execute: docker pull myregistry/webapp:v2.1.0
[DRY RUN] Would execute: kubectl set image deployment/webapp webapp=myregistry/webapp:v2.1.0 -n production

getopts vs getopt

The getopts built-in is often confused with the external getopt command. Here are the key differences:

Feature getopts (built-in) getopt (external)
Type Bash/POSIX built-in External program (/usr/bin/getopt)
Long options Not supported Supported (--verbose)
Portability Works on all POSIX shells Varies by OS (GNU vs BSD)
Whitespace handling Handles correctly GNU version handles correctly
Error handling Built-in verbose/silent modes Manual
Speed Faster (no subprocess) Slower (spawns a process)

Use getopts when you only need short options and want maximum portability. Use getopt (GNU version) when you need long options like --verbose or --output.

Troubleshooting

Options are not being parsed
Make sure your options come before any non-option arguments. The getopts command stops parsing when it encounters the first non-option argument. For example, ./script.sh file.txt -v will not parse -v because file.txt comes first.

OPTIND is not resetting between function calls
If you use getopts inside a function and call that function multiple times, you need to reset OPTIND to 1 before each call. Otherwise, getopts will start from where it left off.

Missing argument not detected
If an option requires an argument but getopts does not report an error, check that you included a colon after the option letter in the option string. For example, use "f:" instead of "f" if -f needs an argument.

Unexpected ? in the variable
In verbose mode (no leading colon), getopts sets the variable to ? for both invalid options and missing arguments. Switch to silent mode (leading colon) to distinguish between the two cases and write custom error messages.

Quick Reference

Element Description
getopts "opts" var Parse options defined in opts, store current letter in var
f in option string Simple flag, no argument
f: in option string Option that requires an argument
: (leading) Enable silent error mode
$OPTARG Holds the argument for the current option
$OPTIND Index of the next argument to process
shift $((OPTIND - 1)) Remove parsed options, keep remaining arguments
\? in case Handles invalid options
: in case Handles missing arguments (silent mode only)

FAQ

Does getopts support long options like –verbose?
No. The getopts built-in only supports single-character options (e.g., -v). For long options, use the external getopt command (GNU version) or parse them manually with a case statement and shift.

Can I combine options like -vf file?
Yes. The getopts command automatically handles combined options. When it encounters -vf file, it processes -v first, then -f with file as its argument.

What happens if I forget shift $((OPTIND - 1))?
The processed options will remain in the positional parameters. Any code that accesses $1, $2, or $@ after the getopts loop will still see the option flags instead of just the remaining arguments.

Is getopts POSIX compliant?
Yes. The getopts command is defined by the POSIX standard and works in all POSIX-compliant shells, including bash, dash, ksh, and zsh. This makes it more portable than the external getopt command.

How do I make an option’s argument optional?
The getopts built-in does not support optional arguments for options. An option either always requires an argument (using :) or never takes one. If you need optional arguments, handle the logic manually after parsing.

Conclusion

The getopts built-in provides a reliable way to parse command-line options in Bash scripts. It handles option string definitions, argument extraction, combined flags, and error reporting with minimal code.

If you have any questions, feel free to leave a comment below.

昨天 — 2026年2月11日技术

HTTP常考状态码详解(附面试官考察点深扒)

作者 NEXT06
2026年2月11日 21:53

前言:那个让人尴尬的面试现场 😅

不管是校招萌新还是想跳槽的老鸟,面试时大概率都遇到过这样一个场景:
面试官推了推眼镜,轻描淡写地问了一句:“简单说一下 301 和 302 的区别?再讲讲 304 是怎么产生的?

这时候,很多人脑子里可能只有一行字:“完了,这题我看过,但我忘了……”
于是只能支支吾吾:“额,一个是永久,一个是临时...那个...304好像是缓存?”

面试官微微一笑,你的心里却凉了半截。

其实,HTTP 状态码(Status Code)  真的不是枯燥的数字。对于我们后端开发来说,它不仅是面试的“敲门砖”,更是线上排错(Troubleshooting)的“听诊器”。看到 502 和看到 504,排查方向可是完全不一样的!

今天这篇文章,咱们不搞死记硬背,我带大家从应用场景面试官视角,把这块硬骨头彻底嚼碎了!


🌏 状态码家族概览:先看大局

HTTP 状态码由 3 位数字组成,第一个数字定义了响应的类别。你可以把它们想象成 5 个性格迥异的家族:

  • 1xx:消息(Information)

    • 🐢 一句话总结:“服务收到了,你继续发。”(实际开发中很少直接处理)
  • 2xx:成功(Success)

    • ✅ 一句话总结:“操作成功,舒服了。”
  • 3xx:重定向(Redirection)

    • 👉 一句话总结:“资源搬家了,你去那边找它。”
  • 4xx:客户端错误(Client Error)

    • 🙅‍♂️ 一句话总结:“你(客户端)发的东西有毛病,服务器处理不了。”
  • 5xx:服务端错误(Server Error)

    • 💥 一句话总结:“我(服务端)炸了,不是你的锅。”

🔍 核心状态码详解:别只背定义,要懂场景

1. 2xx 系列:不仅仅只有 200

  • 200 OK

    • 含义:最常见的,请求成功。
    • 场景:网页正常打开,接口正常返回数据。
  • 201 Created

    • 含义:请求成功并且服务器创建了新的资源。
    • 场景:RESTful API 中,使用 POST 创建用户或订单成功后,应该返回 201 而不是 200。
  • 204 No Content

    • 含义:服务器处理成功,但不需要返回任何实体内容。
    • 场景:前端发送 DELETE 请求删除某条记录,后端删完了,没必要回传什么数据,给个 204 告诉前端“妥了”即可。
  • 206 Partial Content (💡划重点)

    • 含义:服务器已经成功处理了部分 GET 请求。
    • 场景大文件断点续传、视频流媒体播放。前端会在 Header 里带上 Range: bytes=0-100,后端就只返回这部分数据。面试问到“断点续传怎么做”,这个状态码是核心。

2. 3xx 系列:重定向与缓存的纠葛

  • 301 Moved Permanently (永久重定向)

    • 含义:资源已经被永久移动到了新位置。
    • 场景:网站更换域名(如 http 升级到 https),或者老旧的 URL 废弃。
    • 关键点:浏览器会缓存这个重定向,下次你再访问老地址,浏览器直接就去新地址了,根本不会去问服务器。
  • 302 Found (临时重定向)

    • 含义:资源暂时去别的地方了,但未来可能还会回来。
    • 场景:活动页面的临时跳转,未登录用户跳转到登录页。
  • 304 Not Modified (🔥 超高频考点)

    • 含义:资源没修改,你可以直接用你本地的缓存。

    • 原理

      1. 浏览器第一次请求资源,服务器返回 200,并在 Header 里带上 ETag (文件指纹) 或 Last-Modified (最后修改时间)。
      2. 浏览器第二次请求,Header 里带上 If-None-Match (对应 ETag) 或 If-Modified-Since。
      3. 服务器对比发现:“哎?这文件我没改过啊!”
      4. 服务器直接返回 304(响应体是空的,省带宽),告诉浏览器:“别下新的了,用你缓存里那个!”

3. 4xx 系列:客户端的锅

  • 400 Bad Request

    • 含义:请求参数有误,语义错误。
    • 场景:前端传的 JSON 格式不对,或者必填参数没传。
  • 401 Unauthorized vs 403 Forbidden (⚠️ 易混淆)

    • 401未认证。意思是“你是谁?我不认识你”。(通常没登录,或者 Token 过期)。
    • 403禁止。意思是“我知道你是谁,但你没权限进这个屋”。(比如普通用户想删管理员的数据)。
  • 404 Not Found

    • 含义:资源未找到。
    • 场景:URL 输错了,或者资源被删了。
  • 405 Method Not Allowed

    • 含义:方法不被允许。
    • 场景:接口只支持 POST,你非要用 GET 去调。

4. 5xx 系列:服务端的泪

  • 500 Internal Server Error

    • 含义:服务器内部错误。
    • 场景:后端代码抛了空指针异常(NPE)、数据库连不上了、代码逻辑炸了。
  • 502 Bad Gateway vs 504 Gateway Timeout (🔥 线上排错必问)

    • 这俩通常出现在 Nginx(网关)  和 后端服务(如 Java/Go/Python 应用)  之间。

    • 502 Bad Gateway上游服务挂了或返回了无效响应

      • 大白话:Nginx 给后端发请求,后端直接断开连接,或者后端进程直接崩了(端口通但不干活)。
    • 504 Gateway Timeout上游服务超时

      • 大白话:Nginx 给后端发请求,后端活着,但是处理太慢了(比如慢 SQL 查了 60 秒),超过了 Nginx 设置的等待时间。

🎯 面试官的“伏击圈”:最常考&最易混淆点

这里是整篇文章的精华,面试官问这些问题时,心里其实是有“小九九”的。

1. 问:301 和 302 到底有啥本质区别?我不都是跳过去了吗?

  • 🚫 易忘点:只记得“永久”和“临时”,忘了SEO(搜索引擎优化)缓存

  • 🕵️‍♂️ 面试官想考察什么:你是否了解 HTTP 协议对搜索引擎的影响,以及浏览器缓存策略。

  • 💯 完美回答范例

    “虽然用户体验一样,但核心区别在于缓存SEO
    301 会被浏览器强制缓存,下次根本不请求服务器;搜索引擎会把旧地址的权重转移到新地址。
    302 不会被缓存,每次都会去问服务器,搜索引擎也会保留旧地址。
    所以做网站迁移一定要用 301,否则旧域名的 SEO 权重就丢了。”

2. 问:304 状态码是怎么产生的?

  • 🚫 易忘点:只知道是缓存,说不出 ETag 和 Last-Modified 的协商过程。

  • 🕵️‍♂️ 面试官想考察什么Web 性能优化。你是否懂“协商缓存”机制,是否知道如何通过 HTTP 头节省带宽。

  • 💯 完美回答范例

    “304 是协商缓存的结果。
    客户端带着 If-None-Match (ETag) 或 If-Modified-Since 发起请求。
    服务端对比发现资源未变,就不传 Body,只回一个 304 头。
    这能极大减少带宽消耗,提升页面加载速度。”

3. 问:线上报 502 和 504,你怎么排查?

  • 🚫 易忘点:分不清谁是因谁是果,瞎查数据库。

  • 🕵️‍♂️ 面试官想考察什么Troubleshooting(故障排查)能力。这是区分“码农”和“工程师”的分水岭。

  • 💯 完美回答范例

    “看到 502,我首先怀疑后端服务没启动进程崩了,或者 Nginx 配置的 Upstream 地址配错了。
    看到 504,说明后端连接正常但处理太慢。我会去查后端日志看有没有慢 SQL,或者是不是死锁导致请求卡住超时了。”


📝 总结:一张图带你记忆

最后,给兄弟们整几个顺口溜,助你记忆:

  • 200:皆大欢喜。
  • 301:搬家了,不回来了;302:出差了,过几天回。
  • 304:没改过,用旧的。
  • 401:没身份证;403:有身份证但不让进。
  • 404:查无此人。
  • 500:代码写烂了。
  • 502:后端挂了;504:后端慢了。

希望这篇文章能帮你把 HTTP 状态码彻底搞懂!下次面试官再问,直接把原理拍他脸上!😎

WebGL 基础API详解:掌握3D图形编程的核心工具

2026年2月11日 17:59

WebGL API总览

WebGL提供了丰富的API来控制GPU进行3D图形渲染。这些API可以按功能分为几大类,每一类都承担着不同的职责,共同协作完成从数据输入到图像输出的整个渲染流程。

上下文和状态管理API

在开始绘制之前,我们首先需要获取WebGL的绘图上下文,并管理绘图状态。

获取WebGL上下文

// 从canvas元素获取WebGL绘图环境
const canvas = document.getElementById('myCanvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');

视口设置

// 设置渲染区域的大小和位置(通常是整个canvas)
gl.viewport(0, 0, canvas.width, canvas.height);

清空缓冲区

// 设置清空颜色(这里是黑色)
gl.clearColor(0.0, 0.0, 0.0, 1.0);

// 清空颜色缓冲区
gl.clear(gl.COLOR_BUFFER_BIT);

// 如果启用了深度测试,还需清空深度缓冲区
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

功能开关

// 启用深度测试
gl.enable(gl.DEPTH_TEST);

// 启用面剔除
gl.enable(gl.CULL_FACE);

// 启用混合(透明度处理)
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

// 禁用功能
gl.disable(gl.DEPTH_TEST);

着色器相关API

着色器是WebGL的灵魂,这些API负责创建、编译和管理着色器程序。

创建和编译顶点着色器

// 创建顶点着色器对象
const vertexShader = gl.createShader(gl.VERTEX_SHADER);

// 设置着色器源代码
const vertexShaderSource = `
attribute vec4 a_position;
void main() {
  gl_Position = a_position;
}
`;
gl.shaderSource(vertexShader, vertexShaderSource);

// 编译着色器
gl.compileShader(vertexShader);

// 检查编译是否成功
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
  console.error('顶点着色器编译失败:', gl.getShaderInfoLog(vertexShader));
}

创建和编译片元着色器

// 创建片元着色器对象
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

// 设置着色器源代码
const fragmentShaderSource = `
precision mediump float;
uniform vec4 u_color;
void main() {
  gl_FragColor = u_color;
}
`;
gl.shaderSource(fragmentShader, fragmentShaderSource);

// 编译着色器
gl.compileShader(fragmentShader);

// 检查编译是否成功
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
  console.error('片元着色器编译失败:', gl.getShaderInfoLog(fragmentShader));
}

程序相关API

程序对象是顶点着色器和片元着色器的组合,这些API负责管理和链接着色器。

创建和链接程序

// 创建程序对象
const program = gl.createProgram();

// 将着色器附加到程序
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);

// 链接程序
gl.linkProgram(program);

// 检查链接是否成功
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error('程序链接失败:', gl.getProgramInfoLog(program));
}

// 使用程序
gl.useProgram(program);

变量定位和赋值API

这些API用于在JavaScript和着色器之间传递数据,是实现动态渲染的关键。

获取变量位置

// 获取attribute变量位置(顶点着色器中的变量)
const positionAttributeLocation = gl.getAttribLocation(program, 'a_position');
const colorUniformLocation = gl.getUniformLocation(program, 'u_color');

给attribute变量赋值

// 给attribute变量赋值(1-4个浮点数)
gl.vertexAttrib1f(location, value);  // 1个浮点数
gl.vertexAttrib2f(location, x, y);  // 2个浮点数
gl.vertexAttrib3f(location, x, y, z);  // 3个浮点数
gl.vertexAttrib4f(location, x, y, z, w);  // 4个浮点数

给uniform变量赋值

// 给uniform变量赋值(浮点数)
gl.uniform1f(colorUniformLocation, value);  // 1个浮点数
gl.uniform2f(colorUniformLocation, x, y);  // 2个浮点数
gl.uniform3f(colorUniformLocation, r, g, b);  // 3个浮点数
gl.uniform4f(colorUniformLocation, r, g, b, a);  // 4个浮点数

// 给uniform变量赋值(整数)
gl.uniform1i(textureUniformLocation, textureUnit);  // 1个整数

// 给矩阵uniform变量赋值
const matrix = new Float32Array([
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, 1, 0,
  0, 0, 0, 1
]);
gl.uniformMatrix4fv(matrixUniformLocation, false, matrix);

缓冲区相关API

缓冲区用于高效地向GPU传输大量顶点数据,是高性能渲染的基础。

创建和绑定缓冲区

// 创建缓冲区对象
const buffer = gl.createBuffer();

// 绑定缓冲区(指定缓冲区类型)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);  // 顶点数据
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffer);  // 索引数据

向缓冲区写入数据

// 创建顶点数据
const vertices = new Float32Array([
  -0.5, -0.5,  // 第一个点
   0.5, -0.5,  // 第二个点
   0.0,  0.5   // 第三个点
]);

// 向当前绑定的缓冲区写入数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// STATIC_DRAW: 数据一次性写入,多次使用
// DYNAMIC_DRAW: 数据频繁更改
// STREAM_DRAW: 数据偶尔更改

配置顶点属性指针

// 启用顶点属性数组
gl.enableVertexAttribArray(positionAttributeLocation);

// 配置顶点属性
gl.vertexAttribPointer(
  positionAttributeLocation, // attribute变量位置
  2,                         // 每个顶点包含2个分量(x, y)
  gl.FLOAT,                  // 数据类型为浮点数
  false,                     // 不标准化
  0,                         // 步长(0表示紧密排列)
  0                          // 偏移量
);

绘制相关API

这些API触发GPU执行渲染操作。

绘制图元

// 使用顶点数组绘制
gl.drawArrays(
  gl.TRIANGLES,  // 图元类型
  0,             // 起始顶点索引
  3              // 顶点数量
);

// 使用索引数组绘制
const indices = new Uint16Array([0, 1, 2]);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
gl.drawElements(
  gl.TRIANGLES,  // 图元类型
  3,             // 要绘制的索引数量
  gl.UNSIGNED_SHORT, // 索引数据类型
  0               // 偏移量
);

纹理相关API

纹理用于为3D模型添加细节和真实感。

创建和配置纹理

// 创建纹理对象
const texture = gl.createTexture();

// 绑定纹理
gl.bindTexture(gl.TEXTURE_2D, texture);

// 设置纹理图像
gl.texImage2D(
  gl.TEXTURE_2D,    // 纹理目标
  0,                // 纹理级别
  gl.RGBA,          // 内部格式
  gl.RGBA,          // 源格式
  gl.UNSIGNED_BYTE, // 源数据类型
  imageData         // 图像数据
);

// 设置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

// 激活纹理单元
gl.activeTexture(gl.TEXTURE0);  // 激活纹理单元0

混合和深度测试API

这些API控制像素的混合方式和深度测试行为。

混合设置

// 启用混合
gl.enable(gl.BLEND);

// 设置混合函数
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);  // 标准alpha混合

深度测试设置

// 启用深度测试
gl.enable(gl.DEPTH_TEST);

// 设置深度测试函数
gl.depthFunc(gl.LEQUAL);  // 小于等于时通过测试

// 设置深度缓冲写入掩码
gl.depthMask(true);  // 允许写入深度缓冲

常用常量参考

着色器类型

  • gl.VERTEX_SHADER - 顶点着色器
  • gl.FRAGMENT_SHADER - 片元着色器

缓冲区类型

  • gl.ARRAY_BUFFER - 顶点数据缓冲区
  • gl.ELEMENT_ARRAY_BUFFER - 索引数据缓冲区

缓冲区使用方式

  • gl.STATIC_DRAW - 静态数据,一次性写入
  • gl.DYNAMIC_DRAW - 动态数据,频繁更改
  • gl.STREAM_DRAW - 流数据,偶尔更改

绘制模式

  • gl.POINTS - 点
  • gl.LINES - 线段
  • gl.TRIANGLES - 三角形

数据类型

  • gl.FLOAT - 浮点数
  • gl.UNSIGNED_BYTE - 无符号字节

纹理类型

  • gl.TEXTURE_2D - 2D纹理
  • gl.TEXTURE_CUBE_MAP - 立方体贴图

缓冲区位

  • gl.COLOR_BUFFER_BIT - 颜色缓冲区
  • gl.DEPTH_BUFFER_BIT - 深度缓冲区

实际应用示例

回顾我们在前一节中使用的API:

// 使用着色器程序
gl.useProgram(program);

// 获取变量位置
const positionLocation = gl.getAttribLocation(program, 'a_Position');
const colorLocation = gl.getUniformLocation(program, 'u_Color');

// 给attribute变量赋值
gl.vertexAttrib2f(sizeLocation, canvas.width, canvas.height);

// 给uniform变量赋值
gl.uniform4f(colorLocation, r, g, b, a);

// 执行绘制
gl.drawArrays(gl.POINTS, 0, 1);

// 设置清空颜色
gl.clearColor(r, g, b, a);

// 清空缓冲区
gl.clear(gl.COLOR_BUFFER_BIT);

掌握了这些基础API,你就能构建各种复杂的3D图形应用了。这些API看似繁多,但它们都有明确的职责分工,一旦熟悉了它们的作用,WebGL编程就会变得清晰明了。

🔥从"打补丁"到"换思路":一次企业级 AI Agent 的架构拐点

作者 Sailing
2026年2月11日 17:36

在做企业级 AI Agent 时,我踩过一个非常典型的坑。

一开始我以为只是个“小逻辑问题”。后来我发现,那是一次架构认知的分水岭

这篇文章,讲的不是“消息补全”。讲的是一个更重要的问题:

当规则开始打补丁时,你是不是已经选错了工具?

问题很简单:AI 听不懂 “...这个呢”

我们在做一个企业内部智能运维助手 LUI Agent。能力很清晰:

  • 查询域名状态
  • 查询 Pod 数
  • 搜索内部文档
  • ......

在实现多轮对话时,出现了一个极其常见的问题:

用户:查询域名 bbb.com 的状态
AI:该域名 QPS 为 120,P99 为 45ms...

用户:yyy.com 这个呢
AI:???

第二句话——

“yyy.com 这个呢”

从人类视角看,毫无歧义。
但从工具调用视角看,这是一个不完整的句子

下游网关服务根本不知道用户要干什么。

所以我们需要一个能力:

在调用工具前,把“不完整的问题”补全为完整问题。

第一反应:规则一定能搞定(踩坑之路)

作为一个开发者,我的第一反应非常自然:这不就是模式匹配吗?

于是写了第一版规则:

private isIncompleteMessage(message: string): boolean {
  const trimmed = message.trim();

  if (trimmed.length < 8) return true;
  if (/呢[??]?$/.test(trimmed)) return true;
  if (/吗[??]?$/.test(trimmed)) return true;
  if (/^(这个|那个|这|那)/.test(trimmed)) return true;

  return false;
}

看起来很优雅:

  • 短消息?拦截。
  • 追问句式?拦截。
  • 指代开头?拦截。

覆盖三大类问题。我当时甚至觉得设计得挺漂亮。

然后,规则开始失控

测试几轮后,问题很快暴露:

Case 1

yyy.com这个呢

长度 19,不符合 < 8

规则顺序导致被判定为“完整问题”。

我开始加补丁。

Case 2

b.com也查一下

好,加一个“也 + 动词”规则。

Case 3

yyy.com呢

好,再扩展域名 + 呢。

Case 4

这个应用有几个pod

以“这个”开头,但其实是完整问题。误判。

Case 5

这个功能很好用

被误判为“不完整问题”。假阳性。


那一刻我突然意识到:

我已经开始写“例外规则”了。

而当你开始写例外规则的时候,你已经失去了规则系统的简洁性。

规则不再是“设计”,它开始变成“修修补补”。(越来越不好维护!)

真正的问题:这不是字符串问题

我突然意识到一个更本质的问题:我在用规则解决一个语义问题。

  1. “这个呢” 不是句法问题,是指代问题。
  2. 不是字符串匹配问题,是上下文理解问题。

这本质上是一个语义理解任务。而我在用规则解决语义,这就像用 if/else 写一个自然语言理解系统,注定会崩。

相反,正是 LLM 天生擅长的领域。

意图识别

换思路:让 LLM 做意图补全

我加了一层“消息预处理”。在真正调用 agent 工具前,让 LLM 判断:

  • 当前问题是否完整?
  • 是否是追问?
  • 是否需要结合历史补全?

核心逻辑:

/**
 * 预处理消息:使用 LLM 判断并补全不完整的问题
 */
private async preprocessMessage(
  message: string, 
  history: BaseMessage[], 
  agentId: string, 
  requestId?: string
): Promise<string> {
  // 没有历史对话,无需补全
  if (history.length === 0) {
    return message;
  }
  
  const llm = getLLM();
  
  // 取最近的对话历史
  const recentHistory = history.slice(-6).map(msg => {
    const role = msg instanceof HumanMessage ? '用户' : '助手';
    return `${role}: ${String(msg.content).substring(0, 200)}`;
  }).join('\n');
  
  const prompt = `你是一个意图分析助手。判断用户当前输入是否需要根据对话历史补全。

## 对话历史
${recentHistory}

## 用户当前输入
${message}

## 任务
1. 判断当前输入是否是一个完整、独立的问题
2. 如果是完整问题,直接返回原文
3. 如果是追问、指代、省略句式(如"这个呢"、"xxx也查一下"、"状态如何"),结合历史补全为完整问题

## 输出
只返回最终的问题(补全后或原文),不要任何解释。`;

  const response = await llm.invoke(prompt);
  const completed = typeof response.content === 'string' 
    ? response.content.trim() 
    : message;
  
  // 记录是否进行了补全
  if (completed !== message) {
    this.logger.info('消息已补全', { original: message, completed });
  }
  
  return completed || message;
}

核心 Prompt 起了很大作用:

你是一个意图分析助手。判断用户当前输入是否需要根据对话历史补全。

## 任务
1. 判断当前输入是否是一个完整、独立的问题
2. 如果是完整问题,直接返回原文
3. 如果是追问、指代、省略句式(如"这个呢"、"xxx也查一下"、"状态如何"),结合历史补全为完整问题

效果对比:规则 vs 语义

对话历史:

用户: 查询域名 xxx.com 的状态
助手: 该域名 QPS 为 120,响应时间 P99 为 45ms...

用户输入: yyy.com这个呢
LLM 补全: 查询域名 yyy.com 的状态 ✅

对话历史:

用户: 这个应用有几个pod
助手: 当前应用 yyy.com 有 3 个 Pod...

用户输入: 这个应用呢(切换了应用)
LLM 补全: 这个应用有几个pod ✅

对话历史:

用户: 查询 xxx.com 的 QPS
助手: xxx.com 的 QPS 为 50...

用户输入: yyy.com 也查一下
LLM 补全: 查询 yyy.com 的 QPS ✅

完美!LLM 能够理解语义,自动处理各种追问句式。

  • 没有新增规则。
  • 没有顺序依赖。
  • 没有边界爆炸。

它理解了语义。

但 LLM 不是银弹

LLM 问题也随之而来:

  • 延迟增加 500ms ~ 1s。
  • Token 成本增加。
  • 输出不可 100% 可控。

所以,我没有“全盘 LLM 化”。而是做了一个分层架构。

混合架构:规则前置,LLM兜底

在实际项目中,采用了"规则快速拦截 + LLM 深度分析"的混合策略:

// 意图分析流程
async analyzeIntent(message: string, history: BaseMessage[]) {
  // 1. 规则快速拦截(< 1ms)
  const quickResult = this.quickIntercept(message);
  if (quickResult.confident) {
    return quickResult;
  }
  
  // 2. LLM 深度分析(500ms - 3s)
  const llmResult = await this.llmAnalyze(message, history);
  return llmResult;
}

// 规则快速拦截
private quickIntercept(message: string) {
  // 问候语
  if (/^(你好|hi|hello|嗨)/i.test(message)) {
    return { agentId: 'general', confident: true };
  }
  // 身份询问
  if (/你是谁|你叫什么/.test(message)) {
    return { agentId: 'general', confident: true };
  }
  // 导航意图(明确的跳转词)
  if (/^(跳转|打开|进入|去)(到)?/.test(message)) {
    return { agentId: 'navigation', confident: true };
  }
  // 不确定,交给 LLM
  return { confident: false };
}

规则适合:

  • 问候语(例如:你好、你是)
  • 明确跳转(例如:打开**、跳转**)
  • 格式校验
  • 固定关键词

LLM 适合:

  • 指代
  • 追问
  • 模糊表达
  • 语义补全

规则保证速度,LLM 保证理解。这才是企业级 Agent 的现实架构。

更隐蔽的一次教训:历史丢失

后来,我还踩了一个更隐蔽的坑。

日志显示:

API historyLength: 10
MasterAgent historyLength: 6

丢了 4 条。原因:

JSON.stringify(undefined) // -> undefined

某些结构化消息没有 content 字段,被我写的代码逻辑给过滤掉了。

修复方式: 直接 stringify 化

function getMessageContent(msg) {
  if (typeof msg.content === 'string') return msg.content;

  const { role, timestamp, ...rest } = msg;
  return JSON.stringify(rest);
}

让 LLM 自己理解结构化数据

这件事让我学到一个重要认知:

不要低估 LLM 对结构化信息的理解能力。
信息别丢,比格式完美更重要。

总结

这不是一个“补全功能优化”的故事,这是一个架构边界判断问题:

  • 规则系统适合确定性边界
  • 语义系统适合模糊边界

当你发现:

  • 规则在不断打补丁
  • 误判越来越多
  • 例外规则越来越复杂

那很可能 —— 你在用规则解决语义问题

很多人做 Agent,沉迷 Prompt。但真正重要的不是 Prompt 写多长。而是学会判断:

什么时候该用规则;
什么时候该交给语义(LLM 意图识别)。

如果你也在做 Agent,你现在的系统,是规则在膨胀?还是语义在进化?

WX20230928-110017@2x.png

note: 我最近一直在做 前端转全栈前端转 AI Agent 开发方向的工作,后续我会持续分享这两方面的文章。欢迎大家随时来交流~~

WebGL 从零开始:绘制你的第一个3D点

2026年2月11日 17:36

WebGL程序的两大核心组件

WebGL程序就像一台精密的机器,需要两个关键部件协同工作才能正常运行:

JavaScript程序 - 负责控制和数据处理

着色器程序 - 负责图形渲染

这两个部分缺一不可,就像汽车需要发动机和方向盘一样。

从最简单的红点开始

我们的第一个目标很明确:在屏幕中心绘制一个红色的点,大小为10像素。

ScreenShot_2026-02-11_173444_693.png

顶点着色器:告诉GPU在哪里画点

void main(){
    // 告诉GPU在裁剪坐标系原点画点
    gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
    // 设置点的大小为10像素
    gl_PointSize = 10.0;
}

这里用到了GLSL语言的几个重要概念:

  • gl_Position是内置变量,用来设置顶点位置
  • gl_PointSize专门控制点的大小
  • vec4是包含4个浮点数的向量容器

片元着色器:告诉GPU用什么颜色画

void main(){
    // 设置像素颜色为红色
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 
}

注意颜色值的表示方法:

  • WebGL中颜色分量范围是0-1,不是0-255
  • 红色表示为(1.0, 0.0, 0.0, 1.0)
  • 对应CSS中的rgb(255, 0, 0)

完整的HTML结构

<body>
    <!-- 顶点着色器源码 -->
    <script type="shader-source" id="vertexShader">
     void main(){
        gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
        gl_PointSize = 10.0;
    }
    </script>
    
    <!-- 片元着色器源码 -->
    <script type="shader-source" id="fragmentShader">
     void main(){
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); 
    }
    </script>
    
    <canvas id="canvas"></canvas>
</body>

JavaScript核心代码实现

第一步:获取WebGL绘图环境

// 获取canvas元素
var canvas = document.querySelector('#canvas');
// 获取WebGL上下文(兼容处理)
var gl = canvas.getContext('webgl') || canvas.getContext("experimental-webgl");

第二步:创建和编译着色器

// 获取顶点着色器源码
var vertexShaderSource = document.querySelector('#vertexShader').innerHTML;
// 创建顶点着色器对象
var vertexShader = gl.createShader(gl.VERTEX_SHADER);
// 将源码分配给着色器对象
gl.shaderSource(vertexShader, vertexShaderSource);
// 编译顶点着色器程序
gl.compileShader(vertexShader);

// 片元着色器创建过程类似
var fragmentShaderSource = document.querySelector('#fragmentShader').innerHTML;
var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);

第三步:创建和链接着色器程序

// 创建着色器程序
var program = gl.createProgram();
// 将顶点着色器挂载到程序上
gl.attachShader(program, vertexShader); 
// 将片元着色器挂载到程序上
gl.attachShader(program, fragmentShader);
// 链接着色器程序
gl.linkProgram(program);
// 启用着色器程序
gl.useProgram(program);

第四步:执行绘制操作

// 设置清空画布颜色为黑色
gl.clearColor(0.0, 0.0, 0.0, 1.0);
// 用设置的颜色清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制点图元
gl.drawArrays(gl.POINTS, 0, 1);

让点动起来:交互式绘制

静态点不够有趣,让我们实现点击画布就在点击位置绘制彩色点的功能。

改进的着色器程序

// 顶点着色器 - 接收外部数据
precision mediump float;
// 接收点在canvas坐标系上的坐标
attribute vec2 a_Position;
// 接收canvas的宽高尺寸
attribute vec2 a_Screen_Size;

void main(){
    // 将屏幕坐标转换为裁剪坐标
    vec2 position = (a_Position / a_Screen_Size) * 2.0 - 1.0; 
    position = position * vec2(1.0, -1.0);
    gl_Position = vec4(position, 0, 1);
    gl_PointSize = 10.0;
}

// 片元着色器 - 接收颜色数据
precision mediump float;
// 接收JavaScript传过来的颜色值
uniform vec4 u_Color;

void main(){
    // 将颜色值转换为WebGL格式
    vec4 color = u_Color / vec4(255, 255, 255, 1);
    gl_FragColor = color; 
}

交互式JavaScript实现

// 获取着色器变量位置
var a_Position = gl.getAttribLocation(program, 'a_Position');
var a_Screen_Size = gl.getAttribLocation(program, 'a_Screen_Size');
var u_Color = gl.getUniformLocation(program, 'u_Color');

// 传递canvas尺寸信息
gl.vertexAttrib2f(a_Screen_Size, canvas.width, canvas.height);

// 存储点击位置的数组
var points = [];

// 绑定点击事件
canvas.addEventListener('click', e => {
    var x = e.pageX;
    var y = e.pageY;
    var color = {r: Math.random()*255, g: Math.random()*255, b: Math.random()*255, a: 255};
    points.push({ x: x, y: y, color: color });
    
    // 清空画布并重新绘制所有点
    gl.clearColor(0, 0, 0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    
    for (let i = 0; i < points.length; i++) {
        // 传递颜色数据
        gl.uniform4f(u_Color, points[i].color.r, points[i].color.g, points[i].color.b, points[i].color.a);
        // 传递位置数据
        gl.vertexAttrib2f(a_Position, points[i].x, points[i].y);
        // 绘制点
        gl.drawArrays(gl.POINTS, 0, 1);
    }
});

核心知识点总结

GLSL语言基础

  • attribute变量:只能在顶点着色器中使用,接收顶点数据
  • uniform变量:可在顶点和片元着色器中使用,接收全局数据
  • varying变量:在顶点着色器和片元着色器间传递插值数据
  • 向量运算:vec2、vec3、vec4等容器类型的运算规则

WebGL API核心方法

  • createShader():创建着色器对象
  • shaderSource():提供着色器源码
  • compileShader():编译着色器
  • createProgram():创建着色器程序
  • attachShader():绑定着色器到程序
  • linkProgram():链接着色器程序
  • useProgram():启用着色器程序
  • drawArrays():执行绘制操作

坐标系转换

从canvas坐标系到裁剪坐标系的转换公式:

position = (canvas_position / canvas_size) * 2.0 - 1.0

这个转换将浏览器坐标映射到WebGL的标准化设备坐标系(NDC),其中x、y坐标范围都是[-1, 1]。

性能优化提示

直接使用gl.vertexAttrib2f逐个传递数据效率较低。后续章节我们会学习使用缓冲区(Buffer)来批量传递顶点数据,这能显著提升渲染性能。

现在你已经掌握了WebGL的基础绘制技能,准备好迎接更复杂的三角形绘制挑战了吗?

Flutter 与前端开发的对比:构建、调试与平台兼容性差异

2026年2月11日 17:28

作为一个传统前端开发者,最近接手了一个老的flutter项目,说实话感觉并不容易。Flutter 不仅仅是一个 UI 框架,它涉及到原生开发的诸多细节,平台兼容性、构建流程和调试方式都与传统前端开发有很大不同。

这篇文章将对比Flutter与传统前端框架(特别是 Vue 和 React)在构建、调试和平台兼容性方面的差异。让你了解从前端转向 Flutter 开发时,需要调整的思维和工具。

构建流程:Flutter 与前端框架的不同

Flutter 构建流程不仅要支持 Web,还支持原生平台(Android 和 iOS)。它的构建过程需要涉及原生代码的编译、资源打包等,以下是每一步 Flutter 构建流程与前端构建流程中相应步骤和命令的对比。

1. 构建命令

Flutter: flutter build 命令

Flutter 构建应用时,使用 flutter build 命令来生成不同平台的输出。以下是常用的构建命令:

  • flutter build apk:构建 Android 应用,生成 APK 文件。
  • flutter build ios:构建 iOS 应用,生成 IPA 文件。
  • flutter build web:构建 Web 应用,生成浏览器可以使用的静态文件(HTML、CSS、JS)。

对比前端构建: npm run build(以 Vue/React 为例)

前端开发(如 Vue 或 React)的构建流程通常依赖于 Webpack、Vite 等构建工具。这些工具会将代码打包、压缩并生成静态文件,适合在浏览器中运行。

  • npm run build:通过 Webpack 或 Vite 将前端应用编译成静态文件。通常会生成 dist/build/ 文件夹,里面包含 HTML、CSS 和 JavaScript 文件。

2. 原生代码编译与优化

Flutter: AOT 编译

Flutter 将 Dart 代码通过 AOT(Ahead-of-Time)编译为原生代码,这意味着 Flutter 会将应用的代码在构建时转换为可以直接在 Android 或 iOS 上执行的机器码。

  • flutter build apk:通过 AOT 编译生成 Android 平台的原生代码(APK)。
  • flutter build ios:通过 AOT 编译生成 iOS 平台的原生代码(IPA)。

对比前端编译: 编译和压缩

前端框架(如 Vue 或 React)将 JavaScript 代码进行编译和压缩,以优化加载速度和减少文件大小。这个过程通常包括代码分割、模块合并等,确保最终的静态文件可以高效运行。

  • Webpack/Vite 编译:通过 Webpack 或 Vite,将 JavaScript、CSS 和图片等资源进行优化,生成静态文件。

    • 代码分割:前端项目会根据需要将代码拆分成多个模块,按需加载。
    • 压缩和混淆:压缩 JavaScript、CSS 和图片,减少资源体积,提升加载速度。

3. 依赖管理

Flutter: pubspec.yaml

Flutter 使用 pubspec.yaml 文件来管理依赖,它与前端项目中的 package.json 文件类似。Flutter 项目的依赖不仅包括 Dart 包,还包括原生代码库的集成。

  • flutter pub get:安装 pubspec.yaml 中声明的依赖。
  • flutter pub upgrade:升级依赖到最新版本。
  • flutter pub outdated:查看过期的依赖,帮助进行版本管理。

对比前端: package.jsonnpm install

前端项目通过 package.json 文件来管理 JavaScript 库和工具,类似于 Flutter 中的 pubspec.yaml 文件。npmyarn 是前端项目的包管理工具,帮助安装和更新依赖。

  • npm install:安装 package.json 中列出的所有依赖。
  • npm update:升级所有依赖到最新版本。
  • npm outdated:查看过期的依赖和版本更新。

4. 输出文件:平台特定的构建结果

Flutter: 原生应用(APK、IPA)和 Web 输出

Flutter 会根据平台生成不同的输出文件:

  • Android: 生成 APK 文件,可以直接安装到 Android 设备上。
  • iOS: 生成 IPA 文件,可以通过 Xcode 部署到 iOS 设备上。
  • Web: 生成 HTML、CSS 和 JavaScript 文件,可以直接部署到 Web 服务器上。

对比前端构建产物: 静态文件(HTML、CSS、JS)

前端项目在构建后生成适用于浏览器的静态文件,包括:

  • HTML:基础页面结构。
  • CSS:样式表,定义页面样式。
  • JavaScript:行为脚本,控制页面交互。

这些文件最终通过 HTTP 或其他协议加载并渲染在浏览器中。

总结对比:Flutter 与前端构建的差异

构建步骤 Flutter 前端(Vue/React)
构建命令 flutter build apk/ios/web npm run build
原生代码编译 使用 AOT 编译 Dart 代码生成原生 APK/IPA 使用 Webpack/Vite 对 JS、CSS 进行编译和压缩
依赖管理 pubspec.yaml 管理依赖,使用 flutter pub get 等命令 package.json 管理依赖,使用 npm install 等命令
输出文件 输出 APK、IPA 或 Web 文件 输出静态文件(HTML、CSS、JS)
  • 构建工具:Flutter 的构建工具需要处理多个平台的构建(Android、iOS、Web),而前端框架的构建工具主要聚焦于 Web 构建(尽管有些框架支持静态网站生成等)。
  • 原生代码编译:Flutter 需要进行 AOT 编译,将 Dart 代码转化为原生代码,而前端框架主要依赖 JavaScript 打包和优化。
  • 依赖管理:Flutter 和前端项目都通过配置文件(pubspec.yamlpackage.json)管理依赖,但 Flutter 还涉及到原生库和插件的集成,增加了依赖管理的复杂度。

虽然 Flutter 和前端框架(如 Vue 和 React)在构建和依赖管理上有一些相似之处,但 Flutter 的构建流程涉及更多平台特定的操作,复杂度较高。

调试:Flutter 与前端框架的调试差异

前端框架调试(Vue/React)

在前端框架中,调试流程非常简便,主要依赖浏览器的开发者工具和热重载功能。

  • 浏览器调试:前端开发中的调试,绝大多数依赖浏览器的开发者工具(DevTools)。通过查看控制台日志、断点调试和网络请求分析,我们可以轻松地调试 Vue 和 React 应用。
  • 热重载:前端框架的热重载非常快捷,修改代码后,开发服务器会自动刷新页面,立即看到效果。

Flutter 调试

Flutter 的调试方式更为复杂,主要依赖于 Android Studio 或 Visual Studio Code 中的插件支持。

  • Flutter DevTools:Flutter 提供了专门的 DevTools,支持调试 Dart 代码、查看性能、内存使用情况、UI 渲染等。通过 Flutter DevTools,我们可以深入了解应用的行为,特别是在性能和资源管理方面。
  • 热重载:Flutter 也有热重载功能,当修改代码时,Flutter 会迅速重新加载应用的界面,保持应用状态,避免重新启动应用。
  • 原生调试:在调试 Android 和 iOS 应用时,我们还需要使用 Android Studio 或 Xcode,查看原生代码和日志,调试更为繁琐。
总结
  • 调试工具:前端框架的调试通常依赖于浏览器开发者工具,而 Flutter 调试则需要借助专门的 DevTools 和原生开发工具(Android Studio、Xcode)。
  • 调试体验:前端开发的调试过程相对简洁,而 Flutter 的调试不仅涉及到 Dart 代码,还涉及到原生平台的调试,因此调试过程更为复杂。

平台兼容性:Flutter 与前端框架的差异

前端框架的跨平台兼容性

前端框架(如 Vue 和 React)主要运行在 Web 浏览器中,其跨平台的核心在于浏览器的支持。浏览器已经高度标准化,现代浏览器对 HTML、CSS 和 JavaScript 的支持非常广泛,确保了 Web 应用能够在不同操作系统和设备上运行。

  • 兼容性:前端框架通过响应式设计、CSS 媒体查询和现代 JavaScript 的特性来适配不同的屏幕尺寸和设备。
  • 限制:尽管前端框架可以跨平台,但它们只能运行在支持现代浏览器的设备上,且性能可能受限于浏览器引擎。

Flutter 的跨平台兼容性

Flutter 的跨平台兼容性比前端框架复杂得多,因为它不仅支持 Web 端,还支持 Android 和 iOS 等原生平台。

  • 跨平台编译:Flutter 通过 Dart 代码与平台之间的桥接,提供一次开发,多平台运行的能力。Flutter 会将代码编译成每个平台的原生代码,因此可以获得原生应用的性能。
  • 平台适配:Flutter 提供了大量的 Material 和 Cupertino 组件来帮助适配不同平台的 UI 样式,但开发者仍然需要关注不同平台间的差异,尤其是在 Android 和 iOS 之间。
  • Web 支持:Flutter Web 虽然在不断发展,但相比原生 Android/iOS,Web 的性能和兼容性仍然存在差距,尤其是在处理大量动画和复杂 UI 时,性能可能不如原生应用。
总结
  • 兼容性:前端框架主要在浏览器中运行,平台兼容性问题较少;而 Flutter 需要在多个平台上进行编译和适配,尤其是 Android 和 iOS 的差异需要额外关注。
  • 跨平台实现:前端框架通过浏览器实现跨平台,而 Flutter 通过编译为原生代码实现跨平台,性能上可能优于纯 Web 应用,但开发成本和复杂度也较高。 是的,签名(尤其是在移动应用开发中)确实和传统前端开发有很大的不同,特别是在 Flutter 中,涉及到 AndroidiOS 应用的 签名 配置。

签名

在传统的 前端开发 中,通常并不需要考虑签名问题,前端项目的发布主要是生成静态文件(如 HTML、CSS、JavaScript)并部署到服务器上,这个过程通常不涉及到身份认证、签名等操作。签名在前端更多地出现在 API 调用的身份验证中,比如使用 JWT(Json Web Token)进行请求授权。

而在 Flutter 中,特别是涉及 AndroidiOS 原生应用的构建时,签名 是必须要处理的步骤之一。签名的主要作用是确保应用的安全性,防止应用被恶意篡改,并保证应用发布的身份可信。

1. Android 签名

对于 Android 应用,签名是发布应用到 Google Play Store 或直接安装到设备的必要步骤。签名确保了 APK 文件的完整性和安全性。

  • debug.keystore:Flutter 项目默认提供了一个 debug 签名(debug.keystore),用于开发过程中调试应用。
  • release.keystore:发布应用时,必须使用发布版的 release.keystore 进行签名。这是一个包含私钥的文件,只有正确的私钥才能签署 APK 文件,使其能够被安装和分发。

签名配置通常在 Android 项目的 build.gradle 文件中进行设置。例如:

android {
    signingConfigs {
        release {
            storeFile file('path_to_your_keystore_file/release.keystore')
            storePassword 'your_keystore_password'
            keyAlias 'your_key_alias'
            keyPassword 'your_key_password'
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

通过上述配置,Flutter 会在构建发布版 APK 时自动使用配置中的签名信息。

2. iOS 签名

iOS 平台上,签名过程更加严格,所有的 iOS 应用都必须通过 Apple 的签名机制进行签名,否则无法安装到设备上,且不能在 App Store 上发布。

iOS 签名流程

  • 开发者证书:需要通过 Apple Developer Program 注册并获取一个开发者证书。
  • Provisioning Profile:为应用创建一个有效的 Provisioning Profile,关联应用标识符、设备和证书。
  • Xcode 配置:在 Xcode 中配置签名信息,确保应用使用正确的证书和 Profile。

Flutter 中,构建 iOS 应用时,你需要配置好证书、签名文件(如 *.p12)和 Provisioning Profile。签名设置通常通过 Xcode 来完成。配置文件在 Flutter 项目中 ios/Runner.xcodeproj 下进行处理。

3. 签名与前端开发的不同

前端开发:

在前端项目中,签名 不涉及应用的发布和安装过程。前端应用的发布通常是将文件上传到服务器(如 NetlifyVercel 或传统服务器),并通过 HTTPS 安全传输。前端的“签名”更多是在 API 调用中使用 OAuthJWT 等令牌进行身份认证和授权。

Flutter 开发:

Flutter 中,尤其是发布 AndroidiOS 应用时,签名是必须处理的步骤。它涉及:

  • 生成签名文件(如 debug.keystorerelease.keystore,以及 iOS 的开发者证书)。
  • 配置签名文件,确保只有合法的密钥才能对应用进行签名和发布。
  • 签名的验证:签名文件确保应用的完整性,防止恶意篡改和伪造,尤其是对于 Android 和 iOS 平台,只有经过签名的 APK 或 IPA 文件才能在设备上安装。

签名 是 Flutter 开发中与传统前端开发最显著的差异之一。在 前端开发 中,签名通常出现在 API 认证 上,而在 Flutter 中,签名更重要的是涉及到 应用的发布与分发,特别是对于 AndroidiOS 应用。

理解这些签名机制及其配置方式,对从传统前端转向 Flutter 开发的开发者至关重要。

Flutter 开发中的核心术语

  • Dart:Flutter 使用的编程语言,面向对象,支持并发和异步操作,专为客户端开发设计。

  • Kotlin:用于 Android 开发的现代编程语言,Flutter 与原生 Android 交互时常用。

  • AOT 编译(Ahead-of-Time 编译) :在构建时将 Dart 代码编译为原生机器码,提升应用启动速度和运行性能。

  • JIT 编译(Just-in-Time 编译) :运行时动态编译代码,支持开发模式下的热重载。

  • Flutter Engine:Flutter 的底层渲染引擎,负责 UI 渲染、事件处理、平台交互等核心功能。

  • Flutter SDK:开发者使用的工具包,包含 Flutter Engine、Dart SDK 和构建工具,支持跨平台开发。

  • Flutter DevTools:Flutter 提供的调试工具,帮助开发者分析性能、内存使用、UI 渲染等。

  • Hot Reload:Flutter 的开发模式功能,修改代码后无需重启应用即可即时更新 UI,保持应用状态。

  • Hot Restart:完全重新启动应用并丢失当前状态,适用于需要清除应用状态的场景。

  • Platform Channels:Flutter 与原生平台(Android/iOS)交互的通信机制,支持双向数据传递。

  • pubspec.yaml:Flutter 项目的配置文件,类似于前端的 package.json,用于管理依赖、资源等。管理 Dart 包Flutter 插件,其中插件允许与 AndroidiOS 的原生代码进行集成。

  • Widget:Flutter 中的基本 UI 组件,所有的 UI 元素(如按钮、文本、布局等)都是通过 Widget 构建的。

  • Flutter Build System:Flutter 用于构建应用的工具系统,支持生成 APK、IPA 或 Web 应用。

  • Dart VM:Dart 的虚拟机,执行运行时的 Dart 代码,支持 JIT 编译和调试功能。

  • fastlane:一个自动化构建和发布工具,广泛用于 iOSAndroid 应用的构建、测试、发布和提交流程,简化了 CI/CD 流程。

  • Flutter Plugin:一种 Flutter 插件,用于扩展 Flutter 的功能,通过与原生平台的代码进行交互,提供相机、定位、传感器等设备功能。

  • Gradle:一个强大的构建自动化工具,Flutter 使用 Gradle 来构建 Android 应用,负责依赖管理、编译、打包等任务。

  • Xcode:苹果公司开发的集成开发环境(IDE),用于开发 iOSmacOS 应用,Flutter 用来构建和部署 iOS 应用。

  • Android Studio:Google 提供的官方 IDE,用于开发 Android 应用。

  • DartPad:一个在线 Dart 代码编辑器,允许开发者在浏览器中快速编写和测试 Dart 代码,适合初学者和快速原型开发。

  • App Bundle:一种 Android 应用的发布格式(.aab 文件),比 APK 文件更小,支持更高效的 APK 打包和分发。

  • Flutter Channels:用于选择不同的开发版本,如 stablebetadevmaster,以便根据不同渠道,选择稳定版或试验版。

  • FlutterTest:Flutter 提供的单元测试框架,用于编写和运行 Dart 代码和 Flutter 应用的单元测试,确保代码质量和功能正确。

  • Flutter Driver 允许开发者模拟用户与应用的交互,验证 UI 和用户体验方面的功能。

  • Dart DevTools:Flutter 的调试工具,帮助开发者监控应用性能,分析内存和 CPU 使用情况,查看 UI 渲染和调试信息。

  • CI/CD(Continuous Integration/Continuous Delivery):持续集成和持续交付,是开发流程的一部分,旨在自动化代码构建、测试和发布的过程,Flutter 可以与工具如 JenkinsGitLab CIBitrise 等集成实现自动化。

  • Widget Tree:Flutter 中的 UI 组件是通过 Widget 组成的树形结构,称为 Widget Tree,通过树形结构管理视图层次关系。

  • Skia:Flutter 使用的高性能渲染引擎,负责将 UI 绘制到屏幕上,确保 Flutter 应用能够在多个平台上渲染一致的 UI。

  • Dart FFI(Foreign Function Interface):Dart 提供的接口,使 Dart 代码能够与其他语言(如 CC++ )编写的库进行交互,通常用于性能优化或访问低级平台功能。

  • Hotfix:Flutter 支持在应用运行时热修复,即无需重新编译和发布,直接对应用进行快速修复,适用于修复小的 bug 或问题。

结论:从前端到 Flutter,最需要调整的是思维

  • 构建差异:Flutter 需要涉及原生平台的构建和编译,而前端框架只关注浏览器端的构建。
  • 调试差异:前端开发依赖浏览器的开发者工具,Flutter 调试涉及 Dart 代码和原生代码的调试工具。
  • 平台兼容性:前端框架通过 Web 浏览器实现跨平台,而 Flutter 则通过原生代码实现跨平台,支持更多的原生功能,但也带来了更高的开发复杂度。

对于从纯前端转向 Flutter 开发,最大的挑战是从浏览器端的简单构建转向涉及原生开发的构建流程,同时需要适应更多的原生平台差异。

WebGL 初探:让你的网页拥有3D魔法

2026年2月11日 17:10

WebGL的前世今生:从插件时代到开放标准

还记得那些年我们用过的Flash吗?在WebGL出现之前,如果想在网页上展示3D效果,我们不得不依赖Adobe Flash、微软SilverLight这些浏览器插件。就像过去我们想要听音乐必须安装专门的播放器一样,这些插件就像一道门槛,限制了3D网页的发展。

但是,聪明的程序员们不甘于此!他们联手打造了一个开放标准——WebGL,让我们的浏览器原生支持3D图形渲染,再也不需要额外安装任何插件了。

WebGL到底是什么?用最简单的话说清楚

想象一下,你正在玩一个3D游戏,那些栩栩如生的场景、流畅的角色动作,背后都是GPU(显卡)在默默工作。WebGL就是一座桥梁,让你可以用JavaScript直接和GPU对话,告诉它"嘿,帮我渲染一个红色的三角形"或者"给我一个旋转的立方体"。

简单来说:

  • WebGL是一套3D图形API(应用程序接口)
  • 它让你的JavaScript代码可以直接控制GPU
  • 你可以用它创建3D图表、网页游戏、3D地图、虚拟现实等精彩应用

WebGL的工作原理:就像工厂的流水线

想象一下汽车制造厂的流水线:

  1. 工人准备好零件(顶点数据)
  2. 每个工位对零件进行加工(顶点着色器)
  3. 零件被组装成车门(图元装配)
  4. 车门表面涂漆(光栅化)
  5. 最后贴上标志(片元着色器)

WebGL的渲染过程也是这样:

// 这是JavaScript部分,准备数据
const vertices = [
  -0.5, -0.5,  // 三角形左下角
   0.5, -0.5,  // 三角形右下角
   0.0,  0.5   // 三角形顶部
];

// 这是顶点着色器,告诉GPU如何放置顶点
const vertexShaderSource = `
attribute vec2 a_position;  // 接收顶点位置
void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);  // 设置顶点位置
}
`;

// 这是片元着色器,告诉GPU如何上色
const fragmentShaderSource = `
void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // 设置为红色
}
`;

渲染管线的四个关键步骤:

  1. 顶点着色器:处理每个顶点的位置
  2. 图元装配:把顶点连成三角形
  3. 光栅化:把三角形变成像素
  4. 片元着色器:给每个像素上色

wechat_2026-02-11_164451_306.png

WebGL开发需要掌握的技能

如果你已经会HTML、CSS、JavaScript,那么恭喜你,你已经完成了80%的准备工作!WebGL开发还需要:

1. HTML基础(你已经有了)

只需要知道怎么使用<canvas>标签就够了:

<canvas id="webgl-canvas" width="800" height="600"></canvas>

2. JavaScript(你已经精通了)

负责:

  • 获取WebGL上下文
  • 处理顶点数据(坐标、颜色、法向量等)
  • 将数据传递给GPU
  • 加载和解析模型文件

3. GLSL着色器语言(新的挑战)

这是运行在GPU上的小程序,语法类似C语言,但专门为图形处理设计:

// 顶点着色器示例
attribute vec4 position;
uniform mat4 u_matrix;
void main() {
  gl_Position = u_matrix * position;  // 矩阵变换
}

4. 3D数学知识(最重要的基础)

  • 向量:表示方向和距离
  • 矩阵:进行坐标变换(平移、旋转、缩放)
  • 这是WebGL的核心,理解了数学原理,你就掌握了3D世界的钥匙

着色器程序:让GPU听懂你的话

WebGL中有两种着色器:

顶点着色器(Vertex Shader)

  • 处理每个顶点的位置信息
  • 执行坐标变换(移动、旋转、缩放等)

片元着色器(Fragment Shader)

  • 决定每个像素的颜色
  • 处理光照、纹理等效果
// 完整的简单示例
function initWebGL() {
  const canvas = document.getElementById('webgl-canvas');
  const gl = canvas.getContext('webgl');
  
  if (!gl) {
    alert('你的浏览器不支持WebGL');
    return;
  }
  
  // 创建着色器程序...
  // 绘制图形...
}

WebGL的魅力在哪里?

想象一下你能做到的事情:

  • 创建震撼的3D数据可视化
  • 开发浏览器内的3D游戏
  • 构建虚拟现实体验
  • 实现复杂的图像处理效果
  • 优化传统2D应用性能

总结:开启3D世界的大门

WebGL不仅仅是技术,更是创造力的延伸。它让你有能力:

  • 用JavaScript控制GPU的强大计算能力
  • 将数学知识转化为视觉艺术
  • 为用户提供沉浸式的3D体验
  • 在浏览器中实现以前不可能的效果

记住,WebGL的学习曲线虽然陡峭,但一旦掌握,你就能创造出令人惊叹的视觉效果。让我们一起踏上这段精彩的3D之旅吧!

【2025】加入 uniapp 的一年

2026年2月11日 17:04

前言

本文主要分享过去一年自己入职 uniapp 的经历。

经历

未雨绸缪的离职

2025 年过完年之后,因为各种原因,我打定了离职的想法。经历了一段时间的折腾,拿到了一个 offer,当时感觉还可以,已经打算去了。

后面,耗子哥联系我说 uniapp 在招人,问我要不要试试,当时也没多想,反正试试又不要钱,走了朋友的内推,顺利通过了两轮面试,四月初就入职了。

压力山大的实习

情况并没有我想象的那么好,甚至可以说是相当糟糕。

公司要做的东西不是常规业务,加上自己对 vue 的编译时、运行时等等也不熟悉,想修复社区的问题也是相当难受,因为我压根不了解 uniapp 的使用,上家公司用 uniapp vue2 做了微信小程序,对它的印象糟糕透了,也就没花功夫去学习下。

不过,对我而言,还是有些好的消息。

  • 内推我的同事对我很好,不懂的内容都可以去请教他;前端组的同事实力很强,也必较耐心
  • 之前写过 rollup、vite、eslint 和 vscode 插件,维护过不少开源项目,不至于对于 node、编译相关的东西一窍不通

大概入职一周左右,我开始尝试提交第一个pr,这个修复成为了我那几天的噩梦,uniapp 还是挺复杂的,很多东西我根本就不明白,设计,实现,修复和验证的流程等等。

我花了不少时间,可是还没解决那个app端的问题,当时很沮丧,都有点打退堂鼓的意思了。

后面我开始尝试其他端问题的修复,领导也跟我说让我解决小程序相关的问题,我就开始了打怪升级之路。

试用期是三个月,大概六月底,就开始了转正答辩,中间也是折腾了很久,这里就不赘述了(说多了都是泪)。

我的答辩 md 文件我是存储在了 github 上,截一部分当时做的内容。

image.png

慢慢成长的转正

转正之后,随着工作的进行,对于 uniapp 也越来越熟悉,自己的能力也得到了不少提升,在掘金和uniapp的ask社区也写了一些小程序相关的文章。

上家公司当时写小程序被包体积超出的问题来回折磨,回头想想,自己目前会的应该能极大地缓解这个问题。

github 的 uniapp 仓库我刚刚查了一下,大概有 400 多次提交(包含 dev分支 (vue2)、next分支 (vue3)),不知道什么时候能超过前面的辛宝哥。

image.png

因为自己主要负责小程序方面,这段时间对于 uniapp 和 小程序有了更多的了解,还荣幸地加入了 uni-helper 社区。

除了技术,终于在年底拿到了驾照。

我大概在7-8月份报考了海淀驾校,那个时候顶着炎炎烈日去驾校练车,基本都是周末的时间,真的太遭罪了。

每学一次车,一天的时间都要花费在这上面了,不过好在结局不错,顺利一把过,拿到了驾照。

结语

新的一年,希望大家都能更好。

Flutter 多环境设计最佳实践:从混乱切换到工程化管理

2026年2月11日 16:48

Flutter 多环境设计最佳实践:从混乱切换到工程化管理

在实际 Flutter 项目中,几乎都会遇到多环境问题:

  • 开发环境(dev)
  • 测试环境(staging / test)
  • 生产环境(prod)

环境差异通常包括:

  • 接口地址不同
  • 日志等级不同
  • 功能开关不同
  • 第三方服务 key 不同

很多人一开始是这样做的:

const baseUrl = "http://test-api.xxx.com";

发版前手动改成:

const baseUrl = "https://api.xxx.com";

这种方式看似简单,但存在严重问题:

  • 容易误发测试接口到生产
  • 每次发版都要改代码
  • 无法自动化 CI/CD
  • 无法规范团队协作

那么 Flutter 项目中,正确的多环境设计方式是什么?

本文将给出一套工程化解决方案。


一、环境设计的核心思想

Flutter 多环境设计的本质不是“切换地址”,而是:

将环境控制权从代码中剥离,交给构建流程。

完整逻辑可以拆成三层:

构建层 → 决定环境
配置层 → 存储差异
访问层 → 统一管理

二、推荐方案:单入口 + dart-define

很多文章会推荐使用多个 main.dart 或 Android 原生 flavor。

但如果你只是需要:

  • 切换接口
  • 切换 debug 开关
  • 切换日志等级

完全没必要增加原生复杂度。

更推荐:

单入口 + dart-define + JSON 配置文件


三、完整实现步骤

1️⃣ 创建环境配置文件

assets/config/env/
  ├── dev.json
  ├── staging.json
  └── prod.json

示例:

dev.json

{
  "baseUrl": "http://localhost:3000",
  "timeout": 10000,
  "debug": true
}

prod.json

{
  "baseUrl": "https://api.xxx.com",
  "timeout": 8000,
  "debug": false
}

2️⃣ 注册 assets

pubspec.yaml 中:

flutter:
  assets:
    - assets/config/env/dev.json
    - assets/config/env/staging.json
    - assets/config/env/prod.json

3️⃣ 定义配置模型

class EnvConfig {
  final String baseUrl;
  final int timeout;
  final bool debug;

  EnvConfig({
    required this.baseUrl,
    required this.timeout,
    required this.debug,
  });

  factory EnvConfig.fromJson(Map<String, dynamic> json) {
    return EnvConfig(
      baseUrl: json['baseUrl'],
      timeout: json['timeout'],
      debug: json['debug'],
    );
  }
}

4️⃣ 创建环境管理类

import 'dart:convert';
import 'package:flutter/services.dart';
import 'env_config.dart';

class Env {
  static late EnvConfig _config;

  static Future<void> init() async {
    const String flavor =
        String.fromEnvironment('FLAVOR', defaultValue: 'dev');

    final path = 'assets/config/env/$flavor.json';

    final jsonStr = await rootBundle.loadString(path);
    final jsonMap = json.decode(jsonStr);

    _config = EnvConfig.fromJson(jsonMap);
  }

  static String get baseUrl => _config.baseUrl;
  static int get timeout => _config.timeout;
  static bool get debug => _config.debug;
}

5️⃣ 在 main.dart 初始化

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Env.init();
  runApp(const MyApp());
}

6️⃣ 构建时切换环境

开发环境:

flutter run --dart-define=FLAVOR=dev

测试环境:

flutter run --dart-define=FLAVOR=staging

生产环境:

flutter build apk --dart-define=FLAVOR=prod

四、为什么推荐 dart-define?

String.fromEnvironment() 是:

编译期常量

意味着:

  • 构建时写死
  • 运行时不能修改
  • 无额外性能损耗
  • 非运行时判断

这是一种工程化设计,而不是业务层 if 判断。


五、方案对比

方案 单入口 + dart-define 多 main + 原生 flavor
维护成本
原生配置 不需要 需要
CI/CD 简单 复杂
多包名支持 不支持 支持
多图标支持 不支持 支持

结论:

  • 只切接口 → 用 dart-define
  • 多包名多图标 → 用原生 flavor

六、工程化原则总结

  1. 环境 ≠ 业务逻辑
  2. 环境差异文件化
  3. 构建期决定环境
  4. 运行期只读取配置
  5. 统一 Env 管理访问

七、安全提醒

不要在 JSON 中存储:

  • 私钥
  • 支付密钥
  • 第三方 secret

Flutter 包是可反编译的。


八、进阶思考

当你理解了这套设计后,可以继续演进:

  • 与 CI/CD 集成自动构建
  • 支持灰度发布
  • 支持远程动态配置
  • 与 Android flavor 结合实现多包名

结语

Flutter 多环境设计的核心不是切换地址,

而是:

将环境控制权从代码转移到构建流程。

当你开始从“写代码”转向“设计工程结构”,
你的思维层级就已经发生变化。

前端将 HTML 转成 Word 文档的踩坑记录(从 html-docx-js到html-to-docx 到 docx)

作者 沈小闹
2026年2月11日 16:46

前端将 HTML 转成 Word 文档的踩坑记录(从 html-docx-js 到 docx)

在项目中,我需要实现一个功能:
👉 将页面渲染出来的 HTML 内容导出为 Word 文档(.docx)

看起来很简单,但真正落地时踩了不少坑。
这篇文章记录一下从插件选择到最终解决方案的全过程。


一、插件选型对比

1️⃣ html-docx-js

最早尝试的是 html-docx-js

优点:

  • 使用简单
  • 直接将 HTML 字符串转换成 Word

但是很快遇到了问题:

❗ 多层级有序列表在 WPS 中显示异常

当 HTML 中存在:

<ol>
  <li>一级</li>
  <li>
    二级
    <ol>
      <li>子级</li>
    </ol>
  </li>
</ol>

Microsoft Word 中显示正常,
但在 WPS 中会出现:

  • 序号错乱
  • 层级缩进异常
  • 列表结构被打乱

也就是说:

html-docx-js 在生成的 docx 结构中,列表兼容性并不稳定。

对于需要兼容 WPS 的场景来说,这是不可接受的。


2️⃣ html-to-docx / htmltodoc

后来尝试 html-to-docx 这一类库。

问题也很明显:

❗ 不支持 canvas 图片

如果页面中有:

  • canvas 图表
  • 图形绘制
  • Echarts
  • GPT 可视化图表

导出后:

图片为空白

原因是:

  • 这些库只识别 <img>
  • 不会处理 <canvas> 的内容
  • 不会主动把 canvas 转成图片

在图表场景下,这几乎无法使用。


3️⃣ 最终选择:docx

最后选择了 docx(dolanmiu/docx)。

原因:

  • 底层生成真实 docx 结构
  • 可控性强
  • 可自定义 ImageRun / Paragraph
  • 兼容性更好

但同时:

自己要负责 HTML → docx 的映射逻辑。

这也是后面踩坑的开始。


二、使用 docx 时踩到的坑


坑 1:canvas 图片第一次导出是空白

现象:

  • 页面中 canvas 渲染正常
  • 第一次导出 Word,图片是空白
  • 第二次导出却正常

原因

docx 需要的是:

Uint8Array(二进制图片数据)

而 canvas:

  • 是绘图上下文
  • 不是图片资源
  • 如果在 clone 之后再去读取,很可能上下文已经丢失

尤其是:

element.cloneNode(true)

克隆出来的 canvas:

不包含绘制内容

正确做法

必须在克隆 HTML 之前:

  1. 遍历所有 canvas
  2. 调用 canvas.toDataURL()
  3. 缓存结果
  4. 在 clone 后替换成 <img src="dataURL">

核心原则:

canvas 先转图片,再克隆 DOM。


坑 2:ImageRun 被嵌套在 Paragraph 中,图片直接消失

这是最隐蔽、最坑的一个问题。

现象:

  • 图片数据正确
  • 不跨域
  • 二进制正常
  • 但导出 Word 后图片消失
  • 有时 Office Word 还会提示文件有问题

打印结构后发现:

Paragraph
  └─ Paragraph
       └─ ImageRun

也就是说:

ImageRun 外面包了两层 Paragraph。

问题本质

在 docx 结构中:

  • Paragraph 是块级元素
  • Paragraph 不能嵌套 Paragraph
  • ImageRun 必须直接存在于 Paragraph.children 中

非法结构虽然可以被创建,但:

Word 会忽略或报结构错误。

正确结构

new Paragraph({
  children: [
    new ImageRun(...)
  ]
})

而不是:

new Paragraph({
  children: [
    new Paragraph({
      children: [
        new ImageRun(...)
      ]
    })
  ]
})

坑 3:HTML 的结构 ≠ docx 的结构

在 Markdown 渲染后,HTML 往往是这样:

<div>
  <p>
    文字
    <img />
  </p>
</div>

但 docx 并不是 DOM 树结构。

docx 的正确模型更像是:

Section
 ├─ Paragraph
 ├─ Paragraph
 ├─ Paragraph

是一个扁平结构。

因此正确做法是:

  • 文字 → 一个 Paragraph
  • 图片 → 一个 Paragraph
  • 保持顺序
  • 不强行还原 HTML 嵌套

例如:

<p>hello <img /> world</p>

应转换为:

Paragraph("hello")
Paragraph(Image)
Paragraph("world")

而不是试图在一个 Paragraph 里混排。


三、最终总结

在前端做 HTML → Word 导出时,需要注意:

插件层面

  • html-docx-js:WPS 兼容性问题
  • html-to-docx:不支持 canvas
  • docx:可控但需要自己处理结构

使用 docx 时必须注意

  1. canvas 必须提前转为图片
  2. 不要嵌套 Paragraph
  3. ImageRun 必须直接在 Paragraph.children 中
  4. 不要试图 1:1 还原 HTML 结构

四、核心经验

Word 文档不是浏览器。
HTML 的语义嵌套不能直接映射到 docx。

当你开始:

  • 把结构扁平化
  • 图片独立成段
  • 主动控制文档结构

问题就会变得清晰很多。

Vue3 组件通信全解析

2026年2月11日 16:35

组件通信是 Vue 开发中绕不开的核心知识点,尤其是 Vue3 组合式 API 普及后,通信方式相比 Vue2 有了不少变化和优化。本文将抛开 TypeScript,用最通俗易懂的方式,带你梳理 Vue3 中所有常用的组件通信方式,从基础的父子通信到复杂的跨层级通信,每一种都配实战示例,新手也能轻松上手。

一、父子组件通信(最基础也最常用)

父子组件通信是日常开发中使用频率最高的场景,Vue3 为这种场景提供了清晰且高效的解决方案。

1. 父传子:Props

Props 是父组件向子组件传递数据的官方标准方式,子组件通过定义 props 接收父组件传递的值。

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <!-- 向子组件传递数据 -->
    <Child 
      :msg="parentMsg" 
      :user-info="userInfo"
      :list="fruitList"
    />
  </div>
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 定义要传递给子组件的数据
const parentMsg = ref('来自父组件的问候')
const userInfo = reactive({
  name: '张三',
  age: 25
})
const fruitList = ref(['苹果', '香蕉', '橙子'])
</script>

子组件(Child.vue)

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <p>父组件传递的字符串:{{ msg }}</p>
    <p>父组件传递的对象:{{ userInfo.name }} - {{ userInfo.age }}岁</p>
    <p>父组件传递的数组:{{ list.join('、') }}</p>
  </div>
</template>

<script setup>
// 定义props接收父组件数据
const props = defineProps({
  // 字符串类型
  msg: {
    type: String,
    default: '默认值'
  },
  // 对象类型
  userInfo: {
    type: Object,
    default: () => ({}) // 对象/数组默认值必须用函数返回
  },
  // 数组类型
  list: {
    type: Array,
    default: () => []
  }
})

// 在脚本中使用props(组合式API中可直接用props.xxx)
console.log(props.msg)
</script>

2. 子传父:自定义事件(Emits)

子组件通过触发自定义事件,将数据传递给父组件,父组件通过监听事件接收数据。

子组件(Child.vue)

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <button @click="sendToParent">向父组件传递数据</button>
  </div>
</template>

<script setup>
// 声明要触发的自定义事件(可选,但推荐)
const emit = defineEmits(['childMsg', 'updateInfo'])

const sendToParent = () => {
  // 触发事件并传递数据(第一个参数是事件名,后续是要传递的数据)
  emit('childMsg', '来自子组件的消息')
  emit('updateInfo', {
    name: '李四',
    age: 30
  })
}
</script>

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <!-- 监听子组件的自定义事件 -->
    <Child 
      @childMsg="handleChildMsg"
      @updateInfo="handleUpdateInfo"
    />
    <p>子组件传递的消息:{{ childMsg }}</p>
    <p>子组件更新的信息:{{ newUserInfo.name }} - {{ newUserInfo.age }}岁</p>
  </div>
</template>

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

const childMsg = ref('')
const newUserInfo = reactive({
  name: '',
  age: 0
})

// 处理子组件的消息
const handleChildMsg = (msg) => {
  childMsg.value = msg
}

// 处理子组件的信息更新
const handleUpdateInfo = (info) => {
  newUserInfo.name = info.name
  newUserInfo.age = info.age
}
</script>

二、跨层级组件通信

当组件嵌套层级较深(比如爷孙组件、跨多级组件),使用 props + emits 会非常繁琐,这时需要更高效的跨层级通信方案。

1. provide /inject(依赖注入)

provide 用于父组件(或祖先组件)提供数据,inject 用于子孙组件注入数据,支持任意层级的组件通信。

祖先组件(GrandParent.vue)

<template>
  <div class="grand-parent">
    <h3>我是祖先组件</h3>
    <Parent />
  </div>
</template>

<script setup>
import Parent from './Parent.vue'
import { ref, reactive, provide } from 'vue'

// 提供基本类型数据
const theme = ref('dark')
provide('theme', theme)

// 提供对象类型数据
const globalConfig = reactive({
  fontSize: '16px',
  color: '#333'
})
provide('globalConfig', globalConfig)

// 提供方法(支持双向通信)
provide('changeTheme', (newTheme) => {
  theme.value = newTheme
})
</script>

孙组件(Child.vue)

<template>
  <div class="child">
    <h4>我是孙组件</h4>
    <p>祖先组件提供的主题:{{ theme }}</p>
    <p>全局配置:{{ globalConfig.fontSize }} / {{ globalConfig.color }}</p>
    <button @click="changeTheme('light')">切换为亮色主题</button>
  </div>
</template>

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

// 注入祖先组件提供的数据(第二个参数是默认值)
const theme = inject('theme', 'light')
const globalConfig = inject('globalConfig', {})
const changeTheme = inject('changeTheme', () => {})
</script>

2. Vuex/Pinia(全局状态管理)

当多个不相关的组件需要共享状态,或者项目规模较大时,推荐使用官方的状态管理库,Vue3 中更推荐 Pinia(比 Vuex 更简洁)。

示例:Pinia 实现全局通信

1. 安装 Pinia

npm install pinia

2. 创建 Pinia 实例(main.js)

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

3. 创建 Store(stores/user.js)

import { defineStore } from 'pinia'

// 定义并导出store
export const useUserStore = defineStore('user', {
  // 状态
  state: () => ({
    username: '默认用户名',
    token: ''
  }),
  // 计算属性
  getters: {
    // 处理用户名格式
    formatUsername: (state) => {
      return `【${state.username}】`
    }
  },
  // 方法(修改状态)
  actions: {
    // 更新用户信息
    updateUserInfo(newInfo) {
      this.username = newInfo.username
      this.token = newInfo.token
    },
    // 清空用户信息
    clearUserInfo() {
      this.username = ''
      this.token = ''
    }
  }
})

4. 组件中使用 Store

<template>
  <div>
    <h3>全局状态管理示例</h3>
    <p>用户名:{{ userStore.formatUsername }}</p>
    <p>Token:{{ userStore.token }}</p>
    <button @click="updateUser">更新用户信息</button>
    <button @click="clearUser">清空用户信息</button>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'

// 获取store实例
const userStore = useUserStore()

// 更新用户信息
const updateUser = () => {
  userStore.updateUserInfo({
    username: '掘金用户',
    token: '123456789'
  })
}

// 清空用户信息
const clearUser = () => {
  userStore.clearUserInfo()
}
</script>

三、其他常用通信方式

1. v-model 双向绑定

Vue3 中 v-model 支持自定义绑定属性,可实现父子组件的双向数据绑定,简化子传父的操作。

子组件(Child.vue)

<template>
  <div class="child">
    <input 
      type="text" 
      :value="modelValue" 
      @input="emit('update:modelValue', $event.target.value)"
    />
    <!-- 支持多个v-model -->
    <input 
      type="number" 
      :value="age" 
      @input="emit('update:age', $event.target.value)"
    />
  </div>
</template>

<script setup>
defineProps(['modelValue', 'age'])
const emit = defineEmits(['update:modelValue', 'update:age'])
</script>

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>父组件</h3>
    <Child 
      v-model="username"
      v-model:age="userAge"
    />
    <p>用户名:{{ username }}</p>
    <p>年龄:{{ userAge }}</p>
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { ref } from 'vue'

const username = ref('')
const userAge = ref(0)
</script>

2. 事件总线(mitt)

Vue3 移除了 Vue2 的 $on/$emit 事件总线,可使用第三方库 mitt 实现任意组件间的通信。

1. 安装 mitt

npm install mitt

2. 创建事件总线(utils/bus.js)

import mitt from 'mitt'
const bus = mitt()
export default bus

3. 组件 A 发送事件

<template>
  <div>
    <button @click="sendMsg">发送消息到组件B</button>
  </div>
</template>

<script setup>
import bus from '@/utils/bus'

const sendMsg = () => {
  // 触发自定义事件并传递数据
  bus.emit('msgEvent', '来自组件A的消息')
}
</script>

4. 组件 B 接收事件

<template>
  <div>
    <p>组件A传递的消息:{{ msg }}</p>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import bus from '@/utils/bus'

const msg = ref('')

// 挂载时监听事件
onMounted(() => {
  bus.on('msgEvent', (data) => {
    msg.value = data
  })
})

// 卸载时移除监听(避免内存泄漏)
onUnmounted(() => {
  bus.off('msgEvent')
})
</script>

四、通信方式选型建议

表格

通信场景 推荐方式
父传子 Props
子传父 自定义事件(Emits)/v-model
爷孙 / 跨层级 provide / inject
全局共享状态 Pinia
任意组件临时通信 mitt 事件总线

总结

  1. Vue3 中父子组件通信优先使用 Props + Emits,v-model 可简化双向绑定场景;
  2. 跨层级通信推荐 provide / inject,全局状态管理首选 Pinia
  3. 临时的任意组件通信可使用 mitt 事件总线,注意及时移除监听避免内存泄漏。

组件通信的核心是 “数据流向清晰”,无论选择哪种方式,都要保证数据的传递路径可追溯,避免滥用全局通信导致代码维护困难。希望本文能帮助你彻底掌握 Vue3 组件通信,少走弯路~

详解JS的?.(可选链操作符)和 ??(空值合并操作符)

作者 SuperEugene
2026年2月11日 16:31

引言

这是一段我看从别人的Axios代码里看到的代码片段

const msg = error.response?.data?.msg ?? error.response?.data?.message ?? error.message

起初这个?的用法,我只在定义接口的时候用过

export interface UserInfo {
  id: string 
  pwd: string  // 密码
  nickname: string // 用户名称
  wechatCode?: string  // 微信号
}

表示:wechatCode是可选的,有也行没有也行。其他的都是一定要有的。

error.response?.data 我还能理解,表示error中是否有response,如果有就再.data如果没有就返回嘛。但是??我就不能理解啦。怀揣着好奇心学习了一下,分享给同样不知道的同学们。

一、关键语法讲解

  1. 可选链操作符 ?. 作用:安全地访问嵌套对象的属性,如果访问路径上的某个属性是 nullundefined,不会报错,而是直接返回 undefined
  • 传统写法:如果直接写 error.response.data.msg,如果 error.responseundefined,会抛出 Cannot read properties of undefined (reading 'data') 错误。
  • 可选链写法:error.response?.data?.msg 会先检查 error.response 是否存在(非 null/undefined),存在才继续访问 data,再检查 data 存在才访问 msg,任何一步不存在就返回 undefined

2.空值合并操作符 ??

作用:只在左边的值是 nullundefined 时,才返回右边的值(区别于 |||| 会把 ''0false 等 “假值” 也判定为无效)。

  • 例子:
    • undefined ?? '默认值'返回 '默认值'
    • '' ?? '默认值'返回 ''(因为 '' 不是 null/undefined)。
  • 对比 ||'' || '默认值'返回 '默认值',这是两者的核心区别。

二、整行代码分析

const msg = error.response?.data?.msg ?? error.response?.data?.message ?? error.message

这行代码的核心目的是:按优先级从 error 对象中提取错误提示信息,确保最终拿到一个有效的、非空的错误文本,查找优先级如下:

1、第一优先级:error.response?.data?.msg 先尝试从 errorresponse.data 里找 msg 属性(很多后端接口返回的错误信息字段是 msg)。

2、第二优先级:error.response?.data?.message 如果第一步的结果是 null/undefined(比如接口返回的错误字段是 message 而不是 msg),就尝试找 response.data 里的 message 属性。

3、最后兜底:error.message 如果前两步都没找到(比如没有 response 层级,比如前端自身抛出的错误),就取 error 本身的 message 属性(JS 原生 Error 对象默认有 message 字段)。

三、举例子讲解加深印象

const msg = error.response?.data?.msg ?? error.response?.data?.message ?? error.message

还是这个代码,用三种不同的接口情况返回让你更好的理解。下面的三个例子要和这条代码对照起来看。

假设有三种不同的 error 结构,看 msg 的取值结果:

例子 1:接口返回 msg 字段

const error = {
  response: {
    data: { msg: '用户名已存在' }
  }
};
// 执行代码后,msg = '用户名已存在'(取第一优先级)

例子 2:接口返回 message 字段

const error = {
  response: {
    data: { message: '密码错误' }
  }
};
// 第一步 msg 是 undefined → 第二步取 message → msg = '密码错误'

例子 3:前端原生错误(无 response 层级)

const error = new Error('网络请求超时');
// 前两步都是 undefined → 取 error.message → msg = '网络请求超时'

四、总结

  1. ?. 是为了安全访问嵌套属性,避免因某个属性不存在导致代码报错;
  2. ?? 是为了精准判断 “无值”(仅 null/undefined),优先使用接口返回的错误信息,兜底用原生错误信息;
  3. 整行代码的核心逻辑是:按 msgmessage(接口层级)→ message(错误对象层级)的优先级,提取有效的错误提示文本。
const msg = error.response?.data?.msg ?? error.response?.data?.message ?? error.message

通过本次学习,再结合案例分析一下。这个写法似乎是前端处理异步请求(如 Axios)错误信息的常用最佳实践,能兼容不同格式的错误返回,保证代码的健壮性。

以上就是本次对JS的?.(可选链操作符)和 ??(空值合并操作符)的学习分享。欢迎大家指正讨论,与大家共勉。

设计模式-行为型

作者 牛奶
2026年2月11日 16:08

设计模式-行为型

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下行为型设计模式,包括策略模式模板方法模式观察者模式迭代器模式责任链模式命令模式备忘录模式状态模式访问者模式中介者模式解释器模式,提供前端场景和 ES6 代码的实现过程。

什么是行为型

行为型模式(Behavioral Patterns)主要关注对象之间的通信职责分配。这些模式描述了对象之间如何协作共同完成任务,以及如何分配职责。行为型模式不仅关注类和对象的结构,更关注它们之间的相互作用,通过定义清晰的通信机制,解决系统中复杂的控制流问题,使得代码更加清晰、灵活和易于维护。

行为型模式

策略模式(Strategy)

策略模式(Strategy Pattern)定义一系列的算法,把它们一个个封装起来,并使它们可以相互替换。策略模式让算法独立于使用它的客户而变化。

前端中的策略模式场景

  • 表单验证:将不同的验证规则(如非空、邮箱格式、手机号格式)封装成策略,根据需要选择验证策略。
  • 不同业务逻辑处理:例如,根据用户权限(普通用户、VIP、管理员)展示不同的 UI 或执行不同的逻辑。
  • 缓动动画算法:在动画库中,提供多种缓动函数(如 linearease-inease-out)供用户选择。

策略模式-JS实现

// 策略对象
const strategies = {
  "S": (salary) => salary * 4,
  "A": (salary) => salary * 3,
  "B": (salary) => salary * 2
};

// 环境类(Context)
class Bonus {
  constructor(salary, strategy) {
    this.salary = salary;
    this.strategy = strategy;
  }

  getBonus() {
    return strategies[this.strategy](this.salary);
  }
}

// 客户端调用
const bonusS = new Bonus(10000, "S");
console.log(bonusS.getBonus()); // 40000

const bonusA = new Bonus(10000, "A");
console.log(bonusA.getBonus()); // 30000

模板方法模式(Template Method)

模板方法模式(Template Method Pattern)定义一个操作中的算法的骨架,而将一些步骤延迟到子类中实现。

模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些步骤。

前端中的模板方法模式场景

  • UI组件生命周期VueReact 组件的生命周期钩子(如 componentDidMountcreated)就是模板方法模式的应用。框架定义了组件渲染的整体流程,开发者在特定的钩子中实现自定义逻辑。
  • HTTP请求封装:定义一个基础的请求类,处理通用的配置(如 URL、Headers),子类实现具体的请求逻辑(如 GET、POST 参数处理)。

模板方法模式-JS实现

// 抽象父类:饮料
class Beverage {
  // 模板方法,定义算法骨架
  makeBeverage() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
  }

  boilWater() {
    console.log("煮沸水");
  }

  pourInCup() {
    console.log("倒进杯子");
  }

  // 抽象方法,子类必须实现
  brew() {
    throw new Error("抽象方法不能调用");
  }

  addCondiments() {
    throw new Error("抽象方法不能调用");
  }
}

// 具体子类:咖啡
class Coffee extends Beverage {
  brew() {
    console.log("用沸水冲泡咖啡");
  }

  addCondiments() {
    console.log("加糖和牛奶");
  }
}

// 具体子类:茶
class Tea extends Beverage {
  brew() {
    console.log("用沸水浸泡茶叶");
  }

  addCondiments() {
    console.log("加柠檬");
  }
}

// 客户端调用
const coffee = new Coffee();
coffee.makeBeverage();
// 输出:
// 煮沸水
// 用沸水冲泡咖啡
// 倒进杯子
// 加糖和牛奶

const tea = new Tea();
tea.makeBeverage();
// 输出:
// 煮沸水
// 用沸水浸泡茶叶
// 倒进杯子
// 加柠檬

观察者模式(Observer)

观察者模式(Observer Pattern)定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

前端中的观察者模式场景

  • DOM事件监听document.addEventListener 就是最典型的观察者模式。
  • Promisethen 方法也是一种观察者模式,当 Promise 状态改变时,执行相应的回调。
  • Vue响应式系统Dep(目标)和 Watcher(观察者)实现了数据的响应式更新。
  • RxJS:基于观察者模式的响应式编程库。
  • Event Bus:事件总线。

观察者模式-JS实现

// 目标对象(Subject)
class Subject {
  constructor() {
    this.observers = [];
  }

  addObserver(observer) {
    this.observers.push(observer);
  }

  removeObserver(observer) {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }

  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data);
    });
  }
}

// 观察者对象(Observer)
class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} 收到通知: ${data}`);
  }
}

// 客户端调用
const subject = new Subject();
const observer1 = new Observer("观察者1");
const observer2 = new Observer("观察者2");

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notify("更新数据了!");
// 输出:
// 观察者1 收到通知: 更新数据了!
// 观察者2 收到通知: 更新数据了!

迭代器模式(Iterator)

迭代器模式(Iterator Pattern)提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

前端中的迭代器模式场景

  • 数组遍历forEachmap 等数组方法。
  • ES6 IteratorSymbol.iterator 接口,使得对象可以使用 for...of 循环遍历。
  • Generators:生成器函数可以生成迭代器。

迭代器模式-JS实现

// 自定义迭代器
class Iterator {
  constructor(items) {
    this.items = items;
    this.index = 0;
  }

  hasNext() {
    return this.index < this.items.length;
  }

  next() {
    return this.items[this.index++];
  }
}

// 客户端调用
const items = [1, 2, 3];
const iterator = new Iterator(items);

while (iterator.hasNext()) {
  console.log(iterator.next());
}
// 输出:1 2 3

// ES6 Iterator 示例
const iterableObj = {
  items: [10, 20, 30],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for (const item of iterableObj) {
  console.log(item);
}
// 输出:10 20 30

责任链模式(Chain of Responsibility)

责任链模式(Chain of Responsibility Pattern)使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

前端中的责任链模式场景

  • 事件冒泡:DOM 事件在 DOM 树中的冒泡机制就是责任链模式。
  • 中间件ExpressKoaRedux 中的中间件机制。
  • 拦截器Axios 的请求和响应拦截器。

责任链模式-JS实现

// 处理器基类
class Handler {
  setNext(handler) {
    this.nextHandler = handler;
    return handler; // 返回 handler 以支持链式调用
  }

  handleRequest(request) {
    if (this.nextHandler) {
      this.nextHandler.handleRequest(request);
    }
  }
}

// 具体处理器
class HandlerA extends Handler {
  handleRequest(request) {
    if (request === 'A') {
      console.log("HandlerA 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerB extends Handler {
  handleRequest(request) {
    if (request === 'B') {
      console.log("HandlerB 处理了请求");
    } else {
      super.handleRequest(request);
    }
  }
}

class HandlerC extends Handler {
  handleRequest(request) {
    if (request === 'C') {
      console.log("HandlerC 处理了请求");
    } else {
      console.log("没有处理器处理该请求");
    }
  }
}

// 客户端调用
const handlerA = new HandlerA();
const handlerB = new HandlerB();
const handlerC = new HandlerC();

handlerA.setNext(handlerB).setNext(handlerC);

handlerA.handleRequest('A'); // HandlerA 处理了请求
handlerA.handleRequest('B'); // HandlerB 处理了请求
handlerA.handleRequest('C'); // HandlerC 处理了请求
handlerA.handleRequest('D'); // 没有处理器处理该请求

命令模式(Command)

命令模式(Command Pattern)将一个请求封装为一个对象,从而使用户可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

前端中的命令模式场景

  • 富文本编辑器:执行加粗、斜体、下划线等操作,并支持撤销(Undo)和重做(Redo)。
  • 菜单和按钮:将菜单项或按钮的操作封装成命令对象。

命令模式-JS实现

// 接收者:执行实际命令
class Receiver {
  execute() {
    console.log("执行命令");
  }
}

// 命令对象
class Command {
  constructor(receiver) {
    this.receiver = receiver;
  }

  execute() {
    this.receiver.execute();
  }
}

// 调用者:发起命令
class Invoker {
  constructor(command) {
    this.command = command;
  }

  invoke() {
    console.log("调用者发起请求");
    this.command.execute();
  }
}

// 客户端调用
const receiver = new Receiver();
const command = new Command(receiver);
const invoker = new Invoker(command);

invoker.invoke();
// 输出:
// 调用者发起请求
// 执行命令

备忘录模式(Memento)

备忘录模式(Memento Pattern)在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

前端中的备忘录模式场景

  • 状态管理Redux 等状态管理库的时间旅行(Time Travel)功能。
  • 表单草稿:保存用户输入的表单内容,以便下次恢复。
  • 撤销/重做:编辑器中的撤销和重做功能。

备忘录模式-JS实现

// 备忘录:保存状态
class Memento {
  constructor(state) {
    this.state = state;
  }

  getState() {
    return this.state;
  }
}

// 发起人:需要保存状态的对象
class Originator {
  constructor() {
    this.state = "";
  }

  setState(state) {
    this.state = state;
    console.log(`当前状态: ${this.state}`);
  }

  saveStateToMemento() {
    return new Memento(this.state);
  }

  getStateFromMemento(memento) {
    this.state = memento.getState();
    console.log(`恢复状态: ${this.state}`);
  }
}

// 管理者:管理备忘录
class Caretaker {
  constructor() {
    this.mementos = [];
  }

  add(memento) {
    this.mementos.push(memento);
  }

  get(index) {
    return this.mementos[index];
  }
}

// 客户端调用
const originator = new Originator();
const caretaker = new Caretaker();

originator.setState("状态1");
originator.setState("状态2");
caretaker.add(originator.saveStateToMemento()); // 保存状态2

originator.setState("状态3");
caretaker.add(originator.saveStateToMemento()); // 保存状态3

originator.setState("状态4");

originator.getStateFromMemento(caretaker.get(0)); // 恢复到状态2
originator.getStateFromMemento(caretaker.get(1)); // 恢复到状态3

状态模式(State)

状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

前端中的状态模式场景

  • 有限状态机(FSM):例如,Promise 的状态(Pending, Fulfilled, Rejected)。
  • 组件状态管理:例如,一个按钮可能有 loadingdisableddefault 等状态,不同状态下点击行为不同。
  • 游戏开发:角色的不同状态(如站立、奔跑、跳跃、攻击)。

状态模式-JS实现

// 状态接口
class State {
  handle(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 具体状态A
class ConcreteStateA extends State {
  handle(context) {
    console.log("当前是状态A");
    context.setState(new ConcreteStateB());
  }
}

// 具体状态B
class ConcreteStateB extends State {
  handle(context) {
    console.log("当前是状态B");
    context.setState(new ConcreteStateA());
  }
}

// 上下文
class Context {
  constructor() {
    this.state = new ConcreteStateA();
  }

  setState(state) {
    this.state = state;
  }

  request() {
    this.state.handle(this);
  }
}

// 客户端调用
const context = new Context();
context.request(); // 当前是状态A
context.request(); // 当前是状态B
context.request(); // 当前是状态A

访问者模式(Visitor)

访问者模式(Visitor Pattern)表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

前端中的访问者模式场景

  • AST遍历Babel 插件开发中,通过访问者模式遍历和修改 AST(抽象语法树)节点。
  • 复杂数据结构处理:对树形结构或图形结构进行不同的操作(如渲染、序列化、验证)。

访问者模式-JS实现

// 元素类
class Element {
  accept(visitor) {
    throw new Error("抽象方法不能调用");
  }
}

class ConcreteElementA extends Element {
  accept(visitor) {
    visitor.visitConcreteElementA(this);
  }

  operationA() {
    return "ElementA";
  }
}

class ConcreteElementB extends Element {
  accept(visitor) {
    visitor.visitConcreteElementB(this);
  }

  operationB() {
    return "ElementB";
  }
}

// 访问者类
class Visitor {
  visitConcreteElementA(element) {
    console.log(`访问者访问 ${element.operationA()}`);
  }

  visitConcreteElementB(element) {
    console.log(`访问者访问 ${element.operationB()}`);
  }
}

// 客户端调用
const elementA = new ConcreteElementA();
const elementB = new ConcreteElementB();
const visitor = new Visitor();

elementA.accept(visitor); // 访问者访问 ElementA
elementB.accept(visitor); // 访问者访问 ElementB

中介者模式(Mediator)

中介者模式(Mediator Pattern)用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。

前端中的中介者模式场景

  • MVC/MVVM框架:Controller 或 ViewModel 充当中介者,协调 View 和 Model 之间的交互。
  • 复杂表单交互:例如,选择省份后,城市下拉框需要更新;选择城市后,区县下拉框需要更新。使用中介者统一管理这些联动逻辑。
  • 聊天室:用户之间不直接发送消息,而是通过服务器(中介者)转发。

中介者模式-JS实现

// 中介者
class ChatMediator {
  constructor() {
    this.users = [];
  }

  addUser(user) {
    this.users.push(user);
    user.setMediator(this);
  }

  sendMessage(message, user) {
    this.users.forEach(u => {
      if (u !== user) {
        u.receive(message);
      }
    });
  }
}

// 用户类
class User {
  constructor(name) {
    this.name = name;
    this.mediator = null;
  }

  setMediator(mediator) {
    this.mediator = mediator;
  }

  send(message) {
    console.log(`${this.name} 发送消息: ${message}`);
    this.mediator.sendMessage(message, this);
  }

  receive(message) {
    console.log(`${this.name} 收到消息: ${message}`);
  }
}

// 客户端调用
const mediator = new ChatMediator();
const user1 = new User("User1");
const user2 = new User("User2");
const user3 = new User("User3");

mediator.addUser(user1);
mediator.addUser(user2);
mediator.addUser(user3);

user1.send("大家好!");
// 输出:
// User1 发送消息: 大家好!
// User2 收到消息: 大家好!
// User3 收到消息: 大家好!

解释器模式(Interpreter)

解释器模式(Interpreter Pattern)给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器用来解释语言中的句子。

前端中的解释器模式场景

  • 模板引擎MustacheHandlebars 等模板引擎,解析模板字符串并生成 HTML。
  • 编译器前端:将代码解析为 AST。
  • 数学表达式计算:解析并计算字符串形式的数学表达式。

解释器模式-JS实现

// 抽象表达式
class Expression {
  interpret(context) {
    throw new Error("抽象方法不能调用");
  }
}

// 终结符表达式:数字
class NumberExpression extends Expression {
  constructor(number) {
    super();
    this.number = number;
  }

  interpret(context) {
    return this.number;
  }
}

// 非终结符表达式:加法
class AddExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) + this.right.interpret(context);
  }
}

// 非终结符表达式:减法
class SubtractExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }

  interpret(context) {
    return this.left.interpret(context) - this.right.interpret(context);
  }
}

// 客户端调用:计算 10 + 5 - 2
const expression = new SubtractExpression(
  new AddExpression(new NumberExpression(10), new NumberExpression(5)),
  new NumberExpression(2)
);

console.log(expression.interpret()); // 13

项目地址

设计模式-结构型

作者 牛奶
2026年2月11日 16:00

设计模式-结构型

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

引言

本文主要介绍下结构型设计模式,包括适配器模式装饰器模式代理模式外观模式桥接模式组合模式享元模式,提供前端场景和 ES6 代码的实现过程。

什么是结构型

结构型模式(Structural Patterns)主要关注对象组合。这些模式描述了如何将类或对象结合在一起形成更大的结构,同时保持结构的灵活高效。结构型模式通过继承组合的方式来简化系统的设计,解决对象之间的耦合问题,使得系统更加容易扩展和维护。

适配器模式(Adapter)

适配器模式(Adapter Pattern)将一个类的接口转换成客户希望的另一个接口,使原本因接口不兼容而不能一起工作的类可以一起工作。

适配器模式通常用于包装现有的类,以便与新的接口或系统进行交互。

前端中的适配器模式场景

  • 接口数据适配:后端返回的数据结构可能与前端组件需要的数据结构不一致,可以使用适配器模式进行转换。
  • 旧接口兼容:在系统重构或升级时,保持对旧接口的兼容性,使用适配器模式将新接口映射到旧接口。
  • Vue计算属性:Vue 中的 computed 属性可以看作是一种适配器,将原始数据转换为视图需要的数据格式。

适配器模式-JS实现

// 旧接口
class OldCalculator {
  constructor() {
    this.operations = function(term1, term2, operation) {
      switch (operation) {
        case 'add':
          return term1 + term2;
        case 'sub':
          return term1 - term2;
        default:
          return NaN;
      }
    };
  }
}

// 新接口
class NewCalculator {
  add(term1, term2) {
    return term1 + term2;
  }

  sub(term1, term2) {
    return term1 - term2;
  }
}

// 适配器类
class CalculatorAdapter {
  constructor() {
    this.calculator = new NewCalculator();
  }

  operations(term1, term2, operation) {
    switch (operation) {
      case 'add':
        return this.calculator.add(term1, term2);
      case 'sub':
        return this.calculator.sub(term1, term2);
      default:
        return NaN;
    }
  }
}

// 客户端调用
const oldCalc = new OldCalculator();
console.log(oldCalc.operations(10, 5, 'add')); // 15

const newCalc = new NewCalculator();
console.log(newCalc.add(10, 5)); // 15

const adapter = new CalculatorAdapter();
console.log(adapter.operations(10, 5, 'add')); // 15

装饰器模式(Decorator)

装饰器模式(Decorator Pattern动态地给一个对象添加一些额外的职责,而不影响该对象所属类的其他实例。

装饰器模式提供了一种灵活的替代继承方案,用于扩展对象的功能。

前端中的装饰器模式场景

  • 高阶组件(HOC):在 React 中,高阶组件本质上就是装饰器模式的应用,用于复用组件逻辑。
  • 类装饰器:在 ES7 装饰器语法或 TypeScript 中,可以使用装饰器来增强类或类的方法,例如用于日志记录、性能监控、权限控制等。

装饰器模式-JS实现

// 原始对象
class Circle {
  draw() {
    console.log("画一个圆形");
  }
}

// 装饰器基类
class Decorator {
  constructor(circle) {
    this.circle = circle;
  }

  draw() {
    this.circle.draw();
  }
}

// 具体装饰器:添加红色边框
class RedBorderDecorator extends Decorator {
  draw() {
    this.circle.draw();
    this.setRedBorder();
  }

  setRedBorder() {
    console.log("添加红色边框");
  }
}

// 客户端调用
const circle = new Circle();
circle.draw();
// 输出:
// 画一个圆形

const redCircle = new RedBorderDecorator(new Circle());
redCircle.draw();
// 输出:
// 画一个圆形
// 添加红色边框

代理模式(Proxy)

代理模式(Proxy Pattern)为其他对象提供一种代理以控制对这个对象的访问。

代理模式可以在访问对象之前或之后执行额外的操作,如权限验证、延迟加载、缓存等。

前端中的代理模式场景

  • 数据响应式Vue 3 使用 Proxy 对象来实现数据的响应式系统,拦截对象的读取和修改操作。
  • 网络请求代理:在开发环境中,配置代理服务器(如 webpack-dev-server 的 proxy)解决跨域问题。
  • 虚拟代理:例如图片懒加载,先显示占位图,等图片加载完成后再替换为真实图片。
  • 缓存代理:对于开销较大的计算结果或网络请求结果进行缓存,下次请求时直接返回缓存结果。

代理模式-JS实现

// 真实图片加载类
class RealImage {
  constructor(fileName) {
    this.fileName = fileName;
    this.loadFromDisk(fileName);
  }

  loadFromDisk(fileName) {
    console.log("正在从磁盘加载 " + fileName);
  }

  display() {
    console.log("显示 " + this.fileName);
  }
}

// 代理图片类
class ProxyImage {
  constructor(fileName) {
    this.fileName = fileName;
  }

  display() {
    if (!this.realImage) {
      this.realImage = new RealImage(this.fileName);
    }
    this.realImage.display();
  }
}

// 客户端调用
const image = new ProxyImage("test.jpg");

// 第一次调用,加载图片
image.display();
// 输出:
// 正在从磁盘加载 test.jpg
// 显示 test.jpg

// 第二次调用,直接显示
image.display();
// 输出:
// 显示 test.jpg

外观模式(Facade)

外观模式(Facade Pattern)提供一个统一的接口,用来访问子系统中的一群接口。外观模式定义了一个高层接口,让子系统更容易使用。

前端中的外观模式场景

  • 浏览器兼容性封装:封装不同浏览器的 API 差异,提供统一的接口。例如,封装事件监听函数,统一处理 addEventListenerattachEvent
  • 简化复杂库的使用:例如 jQueryAxios,它们为复杂的原生 DOM 操作或 XMLHttpRequest 提供了简单易用的接口。

外观模式-JS实现

// 子系统1:灯光
class Light {
  on() {
    console.log("开灯");
  }
  off() {
    console.log("关灯");
  }
}

// 子系统2:电视
class TV {
  on() {
    console.log("打开电视");
  }
  off() {
    console.log("关闭电视");
  }
}

// 子系统3:音响
class SoundSystem {
  on() {
    console.log("打开音响");
  }
  off() {
    console.log("关闭音响");
  }
}

// 外观类:家庭影院
class HomeTheaterFacade {
  constructor(light, tv, sound) {
    this.light = light;
    this.tv = tv;
    this.sound = sound;
  }

  watchMovie() {
    console.log("--- 准备看电影 ---");
    this.light.off();
    this.tv.on();
    this.sound.on();
  }

  endMovie() {
    console.log("--- 结束看电影 ---");
    this.light.on();
    this.tv.off();
    this.sound.off();
  }
}

// 客户端调用
const light = new Light();
const tv = new TV();
const sound = new SoundSystem();
const homeTheater = new HomeTheaterFacade(light, tv, sound);

homeTheater.watchMovie();
// 输出:
// --- 准备看电影 ---
// 关灯
// 打开电视
// 打开音响

homeTheater.endMovie();
// 输出:
// --- 结束看电影 ---
// 开灯
// 关闭电视
// 关闭音响

桥接模式(Bridge)

桥接模式(Bridge Pattern)将抽象部分与它的实现部分分离,使它们可以独立地变化。

前端中的桥接模式场景

  • UI组件与渲染引擎分离:例如,一个通用的图表库,可以将图表的逻辑(抽象部分)与具体的渲染方式(实现部分,如 Canvas、SVG、WebGL)分离。
  • 事件监听:在绑定事件时,将回调函数(实现部分)与事件绑定(抽象部分)分离,使得回调函数可以复用。

桥接模式-JS实现

// 实现部分接口:颜色
class Color {
  fill() {
    throw new Error("抽象方法不能调用");
  }
}

class Red extends Color {
  fill() {
    return "红色";
  }
}

class Green extends Color {
  fill() {
    return "绿色";
  }
}

// 抽象部分:形状
class Shape {
  constructor(color) {
    this.color = color;
  }

  draw() {
    throw new Error("抽象方法不能调用");
  }
}

class Circle extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的圆形`);
  }
}

class Square extends Shape {
  draw() {
    console.log(`画一个${this.color.fill()}的正方形`);
  }
}

// 客户端调用
const redCircle = new Circle(new Red());
redCircle.draw(); // 画一个红色的圆形

const greenSquare = new Square(new Green());
greenSquare.draw(); // 画一个绿色的正方形

组合模式(Composite)

组合模式(Composite Pattern)将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

前端中的组合模式场景

  • DOM树:DOM 树本身就是一个典型的组合模式结构,既包含具体的节点(如 divspan),也包含包含其他节点的容器。
  • 虚拟DOMVirtual DOM 也是树形结构,组件可以包含其他组件或原生元素。
  • 文件目录系统:文件夹可以包含文件或子文件夹。
  • 级联菜单:多级菜单的展示和操作。

组合模式-JS实现

// 组件基类
class Component {
  constructor(name) {
    this.name = name;
  }

  add(component) {
    throw new Error("不支持该操作");
  }

  remove(component) {
    throw new Error("不支持该操作");
  }

  print(indent = "") {
    throw new Error("不支持该操作");
  }
}

// 叶子节点:文件
class File extends Component {
  print(indent = "") {
    console.log(`${indent}- ${this.name}`);
  }
}

// 组合节点:文件夹
class Folder extends Component {
  constructor(name) {
    super(name);
    this.children = [];
  }

  add(component) {
    this.children.push(component);
  }

  remove(component) {
    const index = this.children.indexOf(component);
    if (index > -1) {
      this.children.splice(index, 1);
    }
  }

  print(indent = "") {
    console.log(`${indent}+ ${this.name}`);
    this.children.forEach(child => {
      child.print(indent + "  ");
    });
  }
}

// 客户端调用
const root = new Folder("根目录");
const folder1 = new Folder("文档");
const folder2 = new Folder("图片");

const file1 = new File("简历.doc");
const file2 = new File("照片.jpg");
const file3 = new File("logo.png");

root.add(folder1);
root.add(folder2);
folder1.add(file1);
folder2.add(file2);
folder2.add(file3);

root.print();
// 输出:
// + 根目录
//   + 文档
//     - 简历.doc
//   + 图片
//     - 照片.jpg
//     - logo.png

享元模式(Flyweight)

享元模式(Flyweight Pattern)通过共享来高效地支持大量细粒度的对象。

前端中的享元模式场景

  • 事件委托:在父元素上绑定事件监听器,通过事件冒泡处理子元素的事件,避免为每个子元素绑定监听器,节省内存。
  • 对象池:在游戏开发或复杂动画中,预先创建一组对象放入池中,使用时取出,用完归还,避免频繁创建和销毁对象。
  • DOM复用:在长列表滚动(虚拟滚动)中,只渲染可视区域的 DOM 节点,回收并复用移出可视区域的节点。

享元模式-JS实现

// 享元工厂
class ShapeFactory {
  constructor() {
    this.circleMap = {};
  }

  getCircle(color) {
    if (!this.circleMap[color]) {
      this.circleMap[color] = new Circle(color);
      console.log(`创建新的 ${color} 圆形`);
    }
    return this.circleMap[color];
  }
}

// 具体享元类
class Circle {
  constructor(color) {
    this.color = color;
  }

  draw(x, y) {
    console.log(`在 (${x}, ${y}) 画一个 ${this.color} 的圆形`);
  }
}

// 客户端调用
const factory = new ShapeFactory();

const redCircle1 = factory.getCircle("红色");
redCircle1.draw(10, 10);

const redCircle2 = factory.getCircle("红色");
redCircle2.draw(20, 20);

const blueCircle = factory.getCircle("蓝色");
blueCircle.draw(30, 30);

console.log(redCircle1 === redCircle2); // true
// 输出:
// 创建新的 红色 圆形
// 在 (10, 10) 画一个 红色 的圆形
// 在 (20, 20) 画一个 红色 的圆形
// 创建新的 蓝色 圆形
// 在 (30, 30) 画一个 蓝色 的圆形
// true

项目地址

AI Design-to-Code 的两个根本问题,和我的解法

作者 w934423231
2026年2月11日 15:52

AI 写业务逻辑已经很顺手,但设计稿还原?样式丢、布局乱、代码难维护。这不是模型不够强,是我们喂给它的输入不对。

落地过程中,我发现 AI D2C 的困难归结为两个根本问题。


问题一:AI 没有空间认知

LLM 是序列模型,处理的是 token 流,不是二维平面。当它看到:

{ "x": 285, "y": 725, "width": 700, "height": 440 }
{ "x": 1005, "y": 725, "width": 370, "height": 440 }
{ "x": 285, "y": 1165, "width": 340, "height": 400 }

它看到的是三组数字,不是「第一行两张卡片并排,第二行一张卡片靠左」。

人看设计稿是空间扫描 — 一眼看出对齐、等距、分栏。LLM 看坐标是数值推理 — 要算 1005 - 285 = 720,再跟 width: 700 比较,才能推断「这两个元素是水平排列的」。而数值推理恰恰是 LLM 最弱的能力之一。

这导致几类典型错误:

空间关系 人的判断 LLM 容易犯的错
水平对齐 y 值接近就是一行 把 y=725 和 y=730 判断成两行
等分布局 三个等宽元素占满容器 生成固定 px 而不是 flex:1
嵌套层级 小元素在大元素内部 坐标包含关系算错,层级打平
间距规律 所有模块间距 20px 部分写 20,部分写 16,不一致

本质原因:Transformer 的自注意力机制是在 token 维度上建立关联的,它没有内置的二维坐标系。它理解「猫坐在垫子上」比理解「x=100 的元素在 x=500 的元素左边」要容易得多——前者是语言语义,后者是空间计算。

问题二:上下文窗口造成注意力涣散

Transformer 的注意力是一个 softmax 分布——所有 token 共享一个概率空间。上下文从 2k 增长到 50k 时,每个 token 分到的平均注意力从 1/2000 降到 1/50000。

具体表现:

现象 示例
遗忘 前面强调"必须带 data-ir-id",后面生成的代码就忘了
偏移 样式值从精确的 rgba(51,51,51,1) 变成随意的 #333
简化 该生成 10 个模块,只生成了 3 个就停了
幻觉 编造设计稿里不存在的元素

这不是模型能力问题,是 Transformer 架构的固有特性——上下文越长,早期信息的影响力越弱。

两个问题互相放大

最要命的是,这两个问题是耦合的:

空间推理弱 → 需要更详细的布局描述来补偿
                 ↓
         上下文变大
                 ↓
         注意力涣散 → 连详细的描述也读不准了
                 ↓
         布局错误更多 → 需要更多修复指令
                 ↓
         上下文进一步膨胀 → 恶性循环

直接把 16MB 的设计稿丢给 AI,这两个问题同时爆发。


我的解法:在 LLM 之前把这两件事做掉

核心思路:别让 LLM 做空间计算,别让 LLM 处理长上下文。用工程手段在模型介入之前,把坐标关系翻译成语言描述,把大上下文拆成小片段。

[前置] 扫描项目代码风格
   ↓
设计文件 → DSL 压缩 → 语义理解 → 意图推断 → 代码生成

每个阶段都在对抗这两个根本问题:

阶段 对抗「无空间认知」 对抗「注意力涣散」
DSL 压缩 16MB → 2MB,减少无关 token
区域拆分 预计算空间分组,告诉 AI「这些在一行」 2MB → 0.34MB,进一步缩小上下文
语义理解 把坐标关系翻译成语言:「card + list」 用语义标签替代原始坐标
意图推断 把「三个 300px 元素」翻译成「等分布局」 意图确认后不再需要原始数据
代码生成 输入已经是「flex:1」而不是坐标 每个模块独立生成,上下文极小

下面展开每个阶段的具体做法。


前置阶段:扫描项目代码风格

在处理设计稿之前,先扫描目标项目,提取现有代码的风格和规范。生成的代码要和项目保持一致。

提取什么

维度 来源 示例
框架类型 package.json Vue3 + Element Plus + TypeScript
编码规范 eslint/prettier/editorconfig 单引号、无分号、缩进 2 空格
已有组件 src/components/*.vue ContentWrap、Pagination、Icon
样式 Token src/styles/*.scss --el-color-primary: #409eff

从现有代码学习

配置文件只能告诉你缩进是 2 空格还是 4 空格,真正的代码风格要从现有组件里学:

// 从项目现有 .vue 文件中提取的模式
interface LearnedPatterns {
  // 脚本风格
  scriptStyle: 'setup' | 'options';       // <script setup> vs <script>
  useDefineOptions: boolean;               // defineOptions({ name: '...' })
  propsStyle: 'type-based' | 'runtime';   // defineProps<T>() vs defineProps({})
  emitsStyle: 'type-based' | 'runtime';   // defineEmits<T>() vs defineEmits([])
  // 导入风格
  importOrder: string[];                   // ['vue', 'element-plus', '@/xxx']
  importGrouping: 'grouped' | 'flat';
  pathAlias: Record<string, string>;       // { '@': 'src', '#': 'types' }
  // 样式风格
  styleLang: 'scss' | 'less' | 'css';
  styleScoped: boolean;
  useDeepSelector: ':deep()' | '::v-deep'; // 深度选择器风格
  // 命名风格
  cssClassStyle: 'kebab-case' | 'BEM';    // .card-header vs .card__header
  refNaming: 'xxxRef' | 'refXxx';         // tableRef vs refTable
}

学习方法:扫描项目现有组件(至少 3 个样本),统计各模式出现频率,取多数。每条结论必须附带来源文件路径。

实测基线(OA 项目,195 个组件样本):

184/195 组件用 <script setup>      → 采用 setup 风格
✔ 169/195 组件用 defineOptions        → 加 defineOptions
✔ 120/195 组件用 scoped 样式          → 加 scoped
✔ 123/195 组件用 lang="scss"          → 采用 SCSS
✔ 引号统计 single:3577 行 / double:46 行 → 单引号

还会按业务目录二次扫描。比如首页组件目录 23 个文件,script setup + defineOptions + scoped scss 全量命中——这比全局统计更可信。

优先级

1. 现有代码学习   # 最高 — 实际代码说了算
2. 配置文件         # 其次 — ESLint/Prettier
3. 框架默认         # 最低 — Vue3 官方推荐

配置文件与代码样本冲突时,以样本为准。项目组件数 < 3 时才用框架默认。


拿到设计文件

输入通常是这几种:

  • 蓝湖导出的 JSON(3-10MB)
  • Figma 导出的 JSON
  • 设计图片(降级方案)

实际案例:我们的 OA 首页设计稿,蓝湖导出的 ui.json16.55MB,画板尺寸 1920×3210px,包含 27 个模块(审批、考勤、红黑榜、销售漏斗等)、107 个图片资源1387 个节点

原始 JSON 长这样:

{
  "transform": [[1, 0, 0], [0, 1, 0]],
  "combinedFrame": { ... },
  "paths": [ ... ],
  "masks": [ ... ]
}

这些字段是渲染引擎需要的,开发者不需要,LLM 更不需要。


压缩成 DSL

目标:把「能画 UI」的数据变成「能描述 UI」的数据。

保留这些字段:

  • id — 节点标识
  • name — 节点名称(常有语义提示)
  • type — 节点类型
  • frame — 位置和尺寸
  • style — 样式(fills / borders / shadows)
  • text — 文本内容和样式
  • image — 图片 URL

删掉这些:

  • transform 矩阵
  • paths 路径数据
  • 蒙版、裁切信息
  • 无意义的分组中间层

实测效果(OA 首页):

文件 大小 说明
ui.json(原始) 16.55 MB 蓝湖导出,含全部绘图指令
ui-dsl.json(压缩后) 2.02 MB 只保留结构+样式+文本
region-row1.json(区域拆分) 0.34 MB 单个区域的 DSL,可直接喂给 LLM

压缩率 87%。进一步按区域拆分后,单个文件只有 300KB 左右,LLM 能轻松处理。

图片资源处理ui.json 里的图片是 URL 链接,需要建立映射:

// image-manifest.json (生成的映射表)
{
  "images": [
    {
      "url": "https://lanhuapp.com/.../icon-approve.png",
      "local": "assets/icons/icon-approve.png",
      "type": "png",
      "size": "25×25"
    }
  ],
  "total": 107,
  "uniquePng": 64,
  "uniqueSvg": 97
}

生成代码时,LLM 直接使用 local 路径,不用处理外部 URL。


语义理解

回答「这是什么」。

给每个节点打标签:container、card、button、list、table、nav、header、footer...

实际案例 — OA 首页 DSL 解析出的模块结构:

顶部区域 (y=0~265, 30节点)
    ├── logo (228×68)
    ├── 按钮: 刷新缓存
    ├── 按钮: 进入后台
    └── 对话: "下午好,何冰玉"
区域 Row1 (y=285~725, 234节点)
    ├── 本月目标完成情况 (700×440) → card + chart
    ├── 常用功能 (370×440) → grid
    └── 审批+预计收益+考勤+报销 (770×440) → 2×2 grid
区域 Row2 (y=745~1145, 194节点)
    ├── 红黑榜单 (340×400) → card + list
    ├── 异常榜 (340×400) → card + list
    ├── 销售榜 (370×400) → card + list
    └── 企业公告 (770×400) → card + list
...共 7 个区域,27 个模块

怎么推断:

  1. 看名称 — 设计师命名的 name 字段常有提示:

    • name: "按钮" → button,name: "标题" → header
    • name: "内容" → content、name: "导航" → nav
  2. 看结构 — 子节点重复 → list;有文本+边框+背景 → card

  3. 看尺寸 — 25×25px 的图片节点 → icon;700×440 的容器 → card

每个推断带置信度分数。名称明确提示 → 0.9+,仅靠尺寸猜 → 0.5-0.7。低置信度的后面会让你确认。


意图推断

回答「设计师想要什么效果」。

这步最关键,也是传统方案最容易忽略的。

看这个例子:

设计稿上三张卡片,都是 300px 宽,间距 20px。问题:这是等分布局还是固定宽度?

视觉上一样,代码完全不同:

/* 等分 — 容器变宽,卡片跟着变宽 */
.card { flex: 1; }

/* 固定 — 容器变宽,卡片还是 300px */
.card { width: 300px; }

不搞清楚意图,生成的代码「看起来对,但行为错」。

需要推断的意图:

  • 等分还是固定宽度?
  • 允许换行吗?
  • 超出怎么处理?截断、滚动、换行?
  • 小屏幕怎么响应?堆叠、隐藏、缩小?

推断依据:

  • 三个元素宽度相等,加起来接近容器宽度 → 可能等分
  • 使用 8px 栅格 → 大概率响应式
  • 后台管理系统 → 大概率固定宽度

置信度低于 0.7 时,直接问你:

检测到三张卡片,宽度均为 300px。请确认布局意图:
[ ] 等分布局(卡片宽度随容器变化)
[ ] 固定宽度(卡片始终 300px

代码生成

输入:带语义标签的 DSL + 明确的意图 + 目标框架

语义映射到组件:

语义 Vue3 + Element Plus React + Ant Design
card el-card Card
button el-button Button
table el-table Table

意图映射到布局:

意图 CSS
等分 flex + flex:1
固定宽度 flex + width
允许换行 flex-wrap: wrap

只写差异样式(隐式样式分析):

这里有个关键概念:隐式样式层

每个组件库都有默认样式,比如 Element Plus 的 el-card:

el-card:
  background: "#fff"
  border-radius: "4px"
  border: "1px solid #ebeef5"

设计稿的背景也是 #fff?不用写。只写与默认值不同的部分:

// ❌ 冗余
.card {
  background: #fff;      // el-card 默认
  border-radius: 4px;    // el-card 默认
  padding: 20px;
}

// ✅ 最小化
.card {
  padding: 20px;
}

这样做的好处:

  • 代码更干净
  • 不会覆盖组件库的主题变量
  • 后续换主题时不会出问题

样式值从 DSL 精确提取:

OA 首页提取出的设计 Token:

用途 DSL 来源
主文字 rgba(51,51,51,1) 节点 3:793 text.style.color
模块标题 Source Han Sans CN Bold 16px 标题节点 text.style.font
页面边距 20px 从容器 frame.x 计算
模块间距 20px 从相邻模块 frame 差值计算

不让 LLM 凭记忆编颜色值——它会编错的。

Token 匹配:

设计值能匹配到项目的 CSS 变量?优先用变量:

// 设计值 20px 匹配到 --el-component-size
padding: var(--el-component-size);

代码更规范,也能响应主题切换。


这套方法比「直接丢给 AI」好在哪

以 OA 首页为例:

维度 直接丢给 AI 五阶段模型
输入大小 16.55MB 原始 JSON 0.34MB 区域 DSL
节点数 1387 个全部丢入 按模块拆分,每次 30-200 个
图片资源 无法处理 107 个图片自动建立 URL→本地文件映射
布局准确性 靠猜 意图明确后再推断
样式精确度 可能编造 从源数据提取
模块覆盖 可能漏掉模块 27 个模块全部识别

现有代码已实现 11 个模块,通过这套方案识别出了 13 个待开发模块、确定了 6 个可复用的排行榜组件。


回到根本问题:工程缓解策略

上面的流程是「预防」,但落地时注意力涣散仍然会发生。还需要额外的工程手段来弥补:

用工程手段弥补

规则强化 — 在关键约束上用强语气标记:

⚠️ **核心原则:必须从 DSL 节点提取精确值****禁止**:让 LLM 凭记忆生成颜色值
✅ **必须**:每个样式值标注来源节点 ID

⚠️❌ 禁止✅ 必须 这类标记能显著提升约束遵守率。

检查点机制 — 流程中设强制验证点,没过就不能继续:

checkpoint:
  name: "DSL 分析完成检查"
  required_outputs:
    - module_list_table  # 模块清单
    - colors             # 颜色值列表
    - coverage_status    # 覆盖率状态
  on_missing:
    action: "阻止继续,要求补全"

流水线上的质量门禁,错误不传递到下游。

Subagent 分治 — 大任务拆成小任务,每个 Subagent 只处理一个模块:

角色 上下文大小 职责
主 Agent ~2k tokens 调度、分配、合并
Subagent 1 ~5k tokens 生成模块 A 代码
Subagent 2 ~5k tokens 生成模块 B 代码

避免单个 Agent 处理 50k+ tokens,每个 Agent 保持聚焦。

显式行范围 — 精确指定该读哪段:

// 模糊(容易遗漏)
"读取 ui-dsl.json,找到模块信息"

// 精确(更可靠)
"读取 ui-dsl.json 的第 285-725 行,这是模块 A 的 DSL 数据"

来源标注 — 每个样式值标注 DSL 节点 ID:

// 来源:节点 3:793 的 text.style.color
color: rgba(51, 51, 51, 1);

要标注来源,就必须去读原始数据,而不是凭印象编造。

本质

这些策略的本质:用工程手段弥补模型的注意力缺陷

  • 规则强化 → 提升关键信息的权重
  • 检查点 → 阻断错误传播
  • 分治 → 缩小单次处理的上下文
  • 显式范围 → 减少无关信息干扰
  • 来源标注 → 强制回溯原始数据

演进方向:Teams Agents 架构

当前方案用「主 Agent + Subagent」,主 Agent 还是要理解全局,上下文逐步累积。更彻底的方案:多 Agent 协作

当前方案

主 Agent(上下文膨胀)
    ├── 读 DSL
    ├── 分析语义
    ├── 推断意图
    ├── 调度 Subagent
    └── 合并结果

Teams Agents 方案

共享状态(DSL + 分析结果)
    │
    ├── DSL Agent      → 只做压缩和结构化,输出写入共享状态
    ├── 语义 Agent     → 读共享状态,只做语义标签
    ├── 意图 Agent     → 读共享状态,只做意图推断
    ├── 代码 Agent 1   → 读共享状态,只生成模块 A
    ├── 代码 Agent 2   → 读共享状态,只生成模块 B
    └── Leader Agent   → 不处理细节,只做检查和调度

为什么更好

维度 单 Agent Teams Agents
上下文隔离 Subagent 有隔离,主 Agent 没有 每个 Agent 都隔离
Leader 负担 要理解全部细节 只看摘要和检查点
信息传递 prompt 传递,容易丢失 共享状态,精确读取
并行能力 受主 Agent 调度限制 完全并行
单点故障 主 Agent 出错全崩 单个 Agent 出错可重试

Leader Agent 的职责

不再做: 读 DSL、理解设计细节、记住样式值

只做:

  • 定义任务边界(哪个 Agent 负责哪个模块)
  • 检查产出完整性(模块数对不对、覆盖率够不够)
  • 处理冲突(两个 Agent 输出不一致时决策)
  • 最终合并

跑通需要什么

  1. 共享状态存储 — 文件系统 / 数据库 / 内存 KV
  2. Agent 通信协议 — 谁先跑、谁依赖谁、怎么通知完成
  3. 支持 Teams 的平台 — OpenAI Swarm、AutoGen、CrewAI、或自己搭

五阶段模型是基础,Teams 架构是优化。


边界

这套方案解决的是结构化设计稿到静态代码。这些不覆盖:

  1. 复杂动效 — 需要额外的动效描述层
  2. 交互逻辑 — 按钮点了做什么,得单独定义
  3. 数据绑定 — 哪些是静态文本、哪些是动态数据,得标注
  4. 设计稿本身有问题 — 方案假设设计稿是规范的

总结

AI D2C 的两个根本问题——无空间认知注意力涣散——不会因为模型变强而彻底消失。它们是 Transformer 架构的固有特性。

所以解法不是等更强的模型,而是用工程手段绕过它们:

  1. 扫描 — 读懂项目现有代码风格
  2. 压缩 — 去噪,缩小上下文
  3. 语义 — 把坐标翻译成语言
  4. 意图 — 把像素翻译成意图
  5. 生成 — 用项目风格写对代码

再加上规则强化、检查点、分治等工程手段对抗注意力涣散,用 Teams 架构进一步隔离上下文。


欢迎讨论

这套方案是我在 OA 项目中落地的实践,不是最优解。

我很想听到不同的思路:

  • 空间认知问题有没有更好的解法?比如给 LLM 加一个视觉编码器,或者用多模态模型直接看设计图?
  • 注意力涣散除了分治和规则强化,还有什么工程技巧?
  • 如果用 Teams Agents 架构,共享状态怎么设计最合理?
  • 有没有人在做 Figma 插件 + LLM 的方案,跟这套思路有什么交集?

欢迎 PR、Issue 或直接讨论。

为什么删除 node_modules 这么慢?原因和解决方案一次讲清

2026年2月10日 15:53

大家好,我是大华!

经常写前端的朋友一定很熟悉,每次删除 node_modules 文件夹的时候都特别慢,甚至有时候还会提示权限不足。

image.png


node_modules 是什么?

简单来说,node_modules 就是项目里存放所有依赖包的地方。每当你用 npm installyarn 安装依赖时,所有依赖和它们的依赖,都会被下载到这个文件夹里。

它是 Node.js 项目中非常核心的一个文件夹,没有它,项目无法运行。


node_modules 的作用

node_modules 的作用就是存放项目依赖。 举个例子,我的项目用到了 lodashaxiosvue,对应的依赖安装后,这些包都会放到 node_modules 文件夹里,在代码里有用 importrequire 引入的时候,Node 都会去 node_modules 里寻找对应的模块。

可以理解为,它是项目的一个依赖仓库,所有功能模块都靠它支撑。


为什么 node_modules 会这么大?

如果你打开一个前端项目,依赖安装后,你发现 node_modules 可能会有几百 MB 或者几 GB 的大小,这有几个原因:

1. 依赖嵌套

每个包都有自己的依赖,npm 或 yarn 会把它们都下载下来。例如 vue-router 依赖 vueaxios 依赖 follow-redirects,它们又有自己的依赖,一层层叠加,导致文件数量和体积剧增。

2. 大量小文件

Node 包里通常会包含源码、文档、测试文件等大量小文件。虽然每个文件不大,但数量非常多,文件总数动不动就几十万,操作系统处理这么多小文件非常耗时。

3. 版本兼容问题

有时候同一个依赖会出现多个版本共存,每个版本都会占用额外空间,进一步膨胀 node_modules

示例:一个Vue项目的依赖规模

├── vue
├── vue-router
├── vuex
├── axios
├── webpack (及其15+个直接依赖)
├── babel (及其20+个插件)
└── 其他工具链...

实际安装后可能包含300-1000+个目录,占用数百MB甚至GB级空间。


为什么删除 node_modules 文件夹这么耗时?

我自己在 Windows 或 macOS 上都有过这样的体验:右键删除或者用命令 rm -rf node_modules,都可能卡上几分钟甚至十几分钟。

原因主要有:

1. 文件数量巨大

node_modules通常包含成千上万个小文件,操作系统删除大量小文件比删除单个大文件慢得多

2. 目录结构深

深层嵌套的目录结构需要递归遍历,增加I/O操作

3. Windows的特殊性

NTFS文件系统处理大量小文件效率较低,防病毒软件实时扫描每个删除操作,路径长度限制(260字符)导致额外处理

4. 磁盘I/O瓶颈

机械硬盘(HDD)在随机读写小文件时性能较差,SSD稍好但仍受限制

删除node_modules文件夹的几种方案

方案1:原生命令行删除(跨平台)

Windows (PowerShell):

# 方法1:使用Remove-Item
Remove-Item -Path .\node_modules -Recurse -Force

# 方法2:使用rd命令(更快)
rd /s /q node_modules

也可以写个脚本,创建一个clean.bat文件

@echo off
echo 开始清理node_modules,可以去摸鱼了...
rd /s /q node_modules
echo 清理完成!可以继续干活了
pause

然后双击运行就可以。

macOS/Linux:

# 使用rm命令
rm -rf node_modules

方案2:使用专用删除工具

rimraf(Node.js工具):

# 全局安装
npm install -g rimraf

# 在项目目录使用
rimraf node_modules

# 或使用npx(无需安装)
npx rimraf node_modules

快速删除脚本(Windows批处理):

@echo off
echo 正在删除node_modules...
rmdir /s /q node_modules
echo 删除完成!
pause

方案3:使用包管理器的清理功能

使用npm:

# npm v6.5+ 新增的清理命令
npm clean-install

# 或先删除再安装
npm ci

使用yarn:

# yarn的自动清理
yarn clean

# 使用Plug'n'Play模式(避免node_modules)
yarn --pnp

使用pnpm:

# pnpm使用硬链接,删除更快
pnpm store prune  # 清理存储
pnpm install

方案4:操作系统级优化

Windows优化:

  1. 临时禁用防病毒软件
  2. 使用管理员权限运行命令行
  3. 使用快速删除工具(如FastDelete)

macOS/Linux优化:

# 使用rsync技巧(有时更快)
rsync -a --delete empty_dir/ node_modules/

方案5:避免频繁删除

  1. 使用Docker容器:在容器内开发,删除容器即可
  2. 使用虚拟机或WSL2:隔离环境
  3. 依赖缓存策略:合理配置npm/yarn缓存

方案6:现代包管理器的优势

pnpm解决方案:

# pnpm使用符号链接和内容寻址存储
# node_modules很小,主要文件在全局存储
pnpm install  # 安装快,删除也快

Yarn Plug'n'Play:

// package.json
{
  "installConfig": {
    "pnp": true
  }
}
// 无需node_modules目录

写在最后

node_modules 删除慢,并不是你电脑的问题,也不是你操作不对,而是 Node 生态本身的历史包袱。

如果你只是偶尔删一次,用命令行或者 rimraf 就够了; 如果你经常重装依赖、切分支、拉项目,那我会强烈建议你试试 pnpm 或 Yarn PnP,真的能明显改善体验。

本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!

2026年JavaScript重大更新:ES2026新特性详解及实战指南

作者 LeonGao
2026年2月10日 15:39

引言

作为现代Web开发的基石,JavaScript自1995年诞生以来,始终伴随前端工程化、全栈开发、跨端应用的演进持续迭代。2026年,ECMAScript 2026(简称ES2026)正式落地,带来了一系列针对性更新——从彻底解决日期处理混乱、浮点精度丢失等长期痛点,到优化异步编程、强化类型安全、提升资源管理可靠性,全方位适配AI协作、边缘计算等新兴场景。对于前端、全栈开发者而言,掌握这些新变化不仅能简化代码、减少bug,更能跟上技术趋势,提升工程效率。本文将从背景、技术实现、优缺点及应用建议等维度,全面拆解2026年JavaScript的核心变化,助力开发者快速上手实践。

一、背景:JavaScript长期痛点与ES2026的迭代初衷

多年来,JavaScript在广泛应用中逐渐暴露诸多原生缺陷,这些痛点往往迫使开发者依赖第三方库,增加代码体积与维护成本,同时限制了其在高可靠性、高精度场景的应用,主要集中在以下4个方面:

  • 日期时间处理:原生Date对象时区转换不精确、国际化支持有限,解析字符串时易出现异常行为,开发者需依赖Moment.js、Luxon等库规避问题;
  • 浮点精度问题:采用IEEE 754 64位浮点标准,简单累加、循环计算时易丢失精度(如0.1+0.2≠0.3),在财务、科学计算场景尤为棘手;
  • 资源管理繁琐:文件句柄、数据库连接等资源需依赖try-finally手动释放,易遗漏导致资源泄漏,垃圾回收的不可预测性无法满足确定性资源管理需求;
  • 异步与类型优化不足:异步代码嵌套冗余、并行处理不便,原生类型检查能力薄弱,需依赖TypeScript或第三方校验库保障代码健壮性。

基于此,TC39委员会结合开发者实践反馈,推动多项提案成熟落地,ES2026以“解决长期痛点、提升开发效率、适配新兴场景”为核心,实现了JavaScript语言能力的全方位升级,让原生JS更适配全栈、AI协作、边缘计算等复杂场景需求。

二、2026年JavaScript核心变化:技术实现与代码示例

ES2026的更新聚焦“实用性、可靠性、高效性”,核心变化集中在日期处理、浮点计算、资源管理、异步编程、类型安全5大方向,以下将详细阐述每项特性的技术实现,并提供可直接复用的代码示例。

2.1 核心特性1:Temporal API——日期时间处理的现代标准

Temporal API是ES2026最受关注的更新,作为原生Date对象的全面替代方案,它采用“显式性、精确性”设计原则,支持时区、日历、时长的精准处理,无缝适配国际化场景,从根本上解决了Date对象的诸多缺陷。

技术实现:Temporal API包含多个核心对象,分别对应不同的日期时间场景,核心设计是“区分本地时间与带时区时间”,避免隐式转换引发的错误,同时支持伊斯兰历、日本历等多种文化日历,内置格式化、计算方法。

核心对象说明:

  • Temporal.PlainDate:无时区的日期(如2026-02-10);
  • Temporal.PlainTime:无时区的时间(如15:30:00);
  • Temporal.ZonedDateTime:带时区的日期时间(如2026-02-10T15:30:00+08:00(Asia/Shanghai));
  • Temporal.Duration:时长(如30天、2小时);
  • Temporal.Calendar:日历系统(默认公历,可切换其他日历)。

代码示例:

// 1. 解析带时区的日期时间并计算时长
const start = Temporal.ZonedDateTime.from('2026-02-10T00:00:00+08:00(Asia/Shanghai)');
const end = Temporal.ZonedDateTime.from('2026-02-17T21:30:00+08:00(Asia/Shanghai)');
const duration = end.since(start);
console.log(`从${start.toLocaleString()}到${end.toLocaleString()},共过去${duration.days}天${duration.hours}小时`);
// 输出:从2026-02-10 00:00:00到2026-02-17 21:30:00,共过去7天21小时

// 2. 无时区日期计算(增加30天)
const plainDate = Temporal.PlainDate.from('2026-02-10');
const nextMonthDate = plainDate.add({ days: 30 });
console.log(nextMonthDate.toString()); // 输出:2026-03-12(自动处理2月天数)

// 3. 国际化格式化(适配中文场景)
const formatted = start.toLocaleString({
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit',
  timeZoneName: 'long'
});
console.log(formatted); // 输出:2026年2月10日 15:30 中国标准时间

// 4. 切换日历系统(伊斯兰历)
const islamicDate = plainDate.withCalendar('islamic-umalqura');
console.log(islamicDate.toString()); // 输出:1447-07-13

2.2 核心特性2:Math.sumPrecise——解决浮点求和精度问题

针对JavaScript长期存在的浮点精度丢失问题,ES2026新增Math.sumPrecise方法,采用Kahan变体精确求和算法,可精准累加数组中的浮点数,无需修改底层浮点表示,大幅简化财务、科学计算场景的开发。

技术实现:Math.sumPrecise接收一个可迭代对象(如数组),内部通过误差补偿机制,修正浮点累加过程中的精度偏差,相较于传统reduce求和,无需手动编写精度修正函数,且性能更优。

代码示例:

// 传统求和(精度丢失问题)
const transactions = [0.1, 0.2, 0.3, 0.4, 0.5];
const normalSum = transactions.reduce((a, b) => a + b, 0);
console.log(`传统求和结果:${normalSum}`); // 输出:1.4999999999999998(不符合预期)

// Math.sumPrecise精确求和
const preciseSum = Math.sumPrecise(transactions);
console.log(`精确求和结果:${preciseSum}`); // 输出:1.5(符合预期)

// 实际财务场景应用(计算订单总金额)
const orderAmounts = [19.99, 29.98, 9.99, 39.95];
const totalAmount = Math.sumPrecise(orderAmounts);
console.log(`订单总金额:${totalAmount.toFixed(2)}`); // 输出:99.91

2.3 核心特性3:显式资源管理——using声明与自动释放

ES2026引入using声明和Symbol.dispose/Symbol.asyncDispose接口,实现了资源的确定性释放,替代传统try-finally写法,大幅减少资源泄漏风险,尤其适用于文件操作、数据库连接、网络请求等场景,类似C#的using或Python的with语句。

技术实现:通过实现Symbol.dispose(同步资源)或Symbol.asyncDispose(异步资源)接口,标记需要释放的资源;using声明将资源绑定到代码块,当代码块执行完毕(无论正常结束还是抛出异常),自动调用dispose方法释放资源,无需手动干预。

代码示例:

// 1. 同步资源释放(模拟文件操作)
class FileHandler {
  constructor(filePath) {
    this.filePath = filePath;
    this.file = null;
  }

  // 实现同步释放接口
  [Symbol.dispose]() {
    if (this.file) {
      this.file.close();
      console.log(`文件${this.filePath}已自动关闭`);
    }
  }

  open() {
    this.file = { close: () => {} }; // 模拟文件打开
    console.log(`文件${this.filePath}已打开`);
    return this;
  }

  read() {
    return `文件${this.filePath}内容`;
  }
}

// using声明自动释放文件资源
using fileHandler = new FileHandler('data.txt').open();
const content = fileHandler.read();
console.log(content);
// 代码块结束后,自动调用Symbol.dispose,输出:文件data.txt已自动关闭

// 2. 异步资源释放(模拟数据库连接)
class DbConnection {
  constructor(connStr) {
    this.connStr = connStr;
    this.connection = null;
  }

  async connect() {
    this.connection = { close: async () => {} }; // 模拟数据库连接
    console.log(`数据库${this.connStr}已连接`);
    return this;
  }

  // 实现异步释放接口
  async [Symbol.asyncDispose]() {
    if (this.connection) {
      await this.connection.close();
      console.log(`数据库${this.connStr}已自动断开`);
    }
  }

  async query(sql) {
    return `执行SQL:${sql},返回结果`;
  }
}

// 异步using声明(配合await)
async function queryData() {
  using dbConn = await new DbConnection('mysql://localhost:3306/test').connect();
  const result = await dbConn.query('SELECT * FROM users');
  console.log(result);
}
queryData();
// 执行完毕后,自动调用Symbol.asyncDispose,输出:数据库mysql://localhost:3306/test已自动断开

2.4 核心特性4:异步编程优化——行内Await与并行简化

ES2026对async/await语法进行了升级,支持行内async表达式(行内Await代码块),打破传统异步代码的嵌套冗余,可将多个异步任务聚合为一个await操作,大幅提升异步代码的可读性与开发效率。

技术实现:行内Await允许在非async函数的表达式中使用await,通过“对象字面量+await”的方式,并行执行多个异步任务,无需手动使用Promise.all,简化异步并行处理逻辑。

代码示例:

// 传统异步写法(嵌套冗余,并行处理繁琐)
async function getUserDataOld() {
  const user = await fetch('/api/user/1');
  const posts = await fetch(`/api/user/${user.id}/posts`);
  const comments = await fetch(`/api/post/${posts[0].id}/comments`);
  return { user, posts, comments };
}

// ES2026行内Await写法(并行执行,简洁清晰)
async function getUserDataNew() {
  // 行内async表达式,并行执行3个异步任务,统一await结果
  const userData = await {
    user: fetch('/api/user/1').then(res => res.json()),
    posts: fetch('/api/user/1/posts').then(res => res.json()),
    comments: fetch('/api/post/1/comments').then(res => res.json())
  };
  return userData;
}

// 执行结果:3个请求并行发起,大幅缩短执行时间
getUserDataNew().then(data => console.log(data));

// 复杂场景:条件性异步任务并行
async function getCombinedData(isVip) {
  const data = await {
    user: fetch('/api/user/1').then(res => res.json()),
    ...(isVip ? { vipInfo: fetch('/api/user/1/vip').then(res => res.json()) } : {})
  };
  return data;
}

2.5 核心特性5:类型安全增强——原生Type Guard

为减少类型错误,ES2026引入原生Type Guard(类型守卫)语法,无需依赖TypeScript或第三方校验库,即可在原生JS中实现简洁的类型检查,支持对象结构、属性约束的精准校验,提升代码健壮性。

技术实现:Type Guard通过“value is Type”的语法,在条件判断中直接定义类型约束,支持基础类型、对象结构、属性范围的校验,校验通过后,代码块内自动推断变量类型,无需手动类型断言。

代码示例:

// 传统类型检查(繁琐,易遗漏)
function printUserOld(user) {
  if (user && typeof user.name === 'string' && typeof user.age === 'number' && user.age > 0) {
    console.log(`${user.name}${user.age}岁`);
  } else {
    throw new Error('用户信息格式错误');
  }
}

// ES2026原生Type Guard(简洁,精准)
function printUserNew(user) {
  // 行内Type Guard,校验user的结构与属性约束
  if (user is { name: string, age: number > 0 }) {
    console.log(`${user.name}${user.age}岁`); // 代码块内自动推断user类型
  } else {
    throw new Error('用户信息格式错误');
  }
}

// 实际应用:接口返回数据校验
async function fetchUser() {
  const res = await fetch('/api/user/1');
  const user = await res.json();
  // 校验接口返回数据类型
  if (!(user is { id: number, name: string, email?: string })) {
    throw new Error('接口返回数据格式异常');
  }
  return user;
}

2.6 其他实用更新(补充)

除上述核心特性外,ES2026还包含多项细节优化,进一步提升开发效率:

  • Uint8Array base64编解码:内置二进制与base64的转换方法,无需依赖第三方库,提升性能;
  • Error.isError:跨上下文可靠检查错误对象,解决不同执行环境下错误类型判断不准确的问题;
  • Array.fromAsync:从异步迭代器创建数组,适配现代异步迭代场景;
  • Map Upsert:简化Map对象的插入/更新操作,无需手动判断键是否存在。

三、ES2026新特性的优缺点分析及应用建议

ES2026的更新虽针对性解决了诸多长期痛点,但部分特性仍存在学习成本与兼容性问题,以下结合实际开发场景,分析核心特性的优缺点,并给出可落地的应用建议。

3.1 核心特性优缺点汇总

特性 优点 缺点
Temporal API 1. 日期时间处理精准,支持时区与国际化;2. 替代第三方日期库,减少代码体积;3. 语法清晰,易维护。 1. 学习成本较高,需熟悉多个核心对象;2. 与Date对象不兼容,需逐步迁移;3. 部分旧浏览器未完全支持。
Math.sumPrecise 1. 彻底解决浮点求和精度问题;2. 语法简洁,无需手动编写精度修正函数;3. 性能优于自定义实现。 1. 仅支持求和场景,无法解决所有浮点问题(如乘法、除法精度);2. 需注意与传统求和方法的区分。
显式资源管理(using) 1. 自动释放资源,减少泄漏风险;2. 简化代码,替代繁琐的try-finally;3. 支持同步/异步资源统一管理。 1. 需手动实现dispose接口,对现有代码有改造成本;2. 异步资源需注意asyncDispose的正确实现。
行内Await 1. 简化异步并行代码,提升可读性;2. 减少Promise.all的手动使用;3. 支持条件性异步任务聚合。 1. 需注意变量作用域,避免异步任务依赖问题;2. 部分旧版运行时(如旧Node.js版本)不支持。
原生Type Guard 1. 原生支持类型检查,无需依赖TS或第三方库;2. 语法简洁,提升代码健壮性;3. 支持复杂对象结构校验。 1. 不支持复杂schema校验(如嵌套对象深层校验);2. 类型约束能力弱于TypeScript。

3.2 实际应用建议

结合特性优缺点,针对不同开发场景,给出以下应用建议,帮助开发者高效落地ES2026新特性:

  1. 场景适配建议:

    1. 财务、科学计算场景:优先使用Math.sumPrecise,避免浮点精度问题;
    2. 日期时间处理场景(如考勤、日程、国际化应用):全面迁移至Temporal API,替代Date与第三方日期库;
    3. 文件、数据库、网络请求场景:强制使用using声明,确保资源确定性释放,减少泄漏风险;
    4. 多异步任务并行场景(如接口批量请求):使用行内Await,简化代码结构;
    5. 中小型项目类型校验:使用原生Type Guard;大型项目建议结合TypeScript,提升类型约束能力。
  2. 兼容性处理建议:

    1. 浏览器端:目前Chrome 144+、Firefox、WebKit等主流浏览器已逐步支持ES2026核心特性,旧浏览器需使用Polyfill(如@js-temporal/polyfill)做兼容;
    2. Node.js端:Node.js 20+版本支持大部分特性,建议升级至最新稳定版,或使用Babel转译;
    3. 项目迁移:采用“渐进式迁移”策略,先在新功能中使用新特性,逐步替代旧写法,避免一次性大规模改造带来的风险。
  3. 避坑提醒:

    1. Temporal API:避免混用Temporal对象与Date对象,如需兼容旧代码,可使用Temporal.from(Date)进行转换;
    2. using声明:异步资源必须实现Symbol.asyncDispose接口,且需配合await使用,否则会导致资源释放失败;
    3. 行内Await:多个异步任务若存在依赖关系(如A任务依赖B任务结果),不可使用行内并行写法,需保持顺序执行;
    4. Math.sumPrecise:仅适用于求和场景,乘法、除法等浮点运算仍需手动处理精度(如使用decimal.js库)。

四、结论:2026年JavaScript的价值与未来发展

2026年JavaScript的更新(ES2026),并非简单的语法新增,而是围绕“解决长期痛点、提升开发效率、适配新兴场景”的系统性升级——Temporal API终结了日期处理的混乱局面,Math.sumPrecise填补了原生浮点精度的空白,using声明解决了资源管理的顽疾,行内Await与Type Guard则进一步优化了异步编程与类型安全体验。这些变化的核心价值,在于让原生JavaScript摆脱对第三方库的过度依赖,变得更健壮、更高效、更适配全栈开发、AI协作、边缘计算等现代技术场景。

从未来发展来看,结合TC39的后续规划与行业趋势,JavaScript将继续朝着以下方向演进:一是类型系统持续深化,原生类型能力将进一步贴近TypeScript,实现“动态类型+静态类型”的灵活适配;二是性能持续优化,通过JIT编译、内存管理革新等技术,缩小与原生语言的性能差距;三是多范式融合,进一步强化异步编程、函数式编程能力,适配AI辅助开发、边缘计算等新兴场景;四是生态协同升级,与WebAssembly的协同将更加紧密,实现“JS负责业务编排、WASM负责高性能计算”的分层协作模式。

对于开发者而言,ES2026的落地意味着更高的开发效率、更可靠的代码质量,以及更低的维护成本。唯有主动学习并落地这些新特性,结合实际场景灵活运用,才能在快速迭代的技术浪潮中保持竞争力,充分发挥JavaScript在现代开发中的核心价值。

五、参考资料(可选)

为帮助读者进一步学习ES2026新特性,整理以下权威参考资料:

  1. ECMAScript 2026 官方提案:TC39 Finished Proposals(包含所有ES2026落地特性的详细说明);
  2. Temporal API 官方文档:Temporal API Specification(详细介绍Temporal API的设计理念与使用方法);
  3. MDN ES2026 新特性详解:JavaScript 2026 新特性(含代码示例与浏览器兼容性说明);
  4. ES2026 实战指南:2026年JavaScript趋势分析(结合实际项目的落地经验);
  5. 显式资源管理提案:Explicit Resource Management(using声明的详细设计与实现)。

🚀99% 前端都用错了 flatMap!把 “映射扁平” 用成高级 for 循环?20 个高阶用法一次性讲透

2026年2月10日 12:58

我在 3 个项目里用 flatMap 重构了 50+ 处代码,总结了这些经验。今天全部教给你。


开篇:为什么要专门学 flatMap?

去年我接手一个电商后台项目,代码里到处都是这样的写法:

// 性能杀手:两次遍历 + 中间数组
const result = orders
  .filter(order => order.status === 'paid')
  .map(order => order.items)
  .flat();

重构后用 flatMap:

// 一次遍历,代码更简洁
const result = orders.flatMap(order => 
  order.status === 'paid' ? order.items : []
);

性能提升 40%,代码量减少 30%。这就是 flatMap 的威力。


第一部分:基础技巧(1-5)

技巧 1:替代 filter + map

场景:提取所有已付款订单的商品

const orders = [
  { id: 1, status: 'paid', items: ['iPhone', 'AirPods'] },
  { id: 2, status: 'pending', items: ['iPad'] },
  { id: 3, status: 'paid', items: ['MacBook'] }
];

// ❌ 传统写法:两次遍历
const items1 = orders
  .filter(o => o.status === 'paid')
  .map(o => o.items)
  .flat();

// ✅ flatMap:一次遍历
const items2 = orders.flatMap(order =>
  order.status === 'paid' ? order.items : []
);
// ['iPhone', 'AirPods', 'MacBook']

为什么更好:减少一次遍历,大数据量时性能优势明显。


技巧 2:处理嵌套数组(树形结构扁平化)

场景:菜单树转平级列表

const menuTree = [
  {
    name: '系统管理',
    children: [
      { name: '用户管理', path: '/users' },
      { name: '角色管理', path: '/roles' }
    ]
  },
  {
    name: '业务管理',
    children: [
      { name: '订单管理', path: '/orders' }
    ]
  }
];

// 提取所有菜单项,保留父级信息
const flatMenu = menuTree.flatMap(parent =>
  parent.children.map(child => ({
    ...child,
    parentName: parent.name
  }))
);

/*
[
  { name: '用户管理', path: '/users', parentName: '系统管理' },
  { name: '角色管理', path: '/roles', parentName: '系统管理' },
  { name: '订单管理', path: '/orders', parentName: '业务管理' }
]
*/

技巧 3:字符串拆分与合并

场景:处理多行输入的关键词

const userInput = [
  'apple, banana, orange',
  'react, vue, angular',
  'frontend, backend'
];

// 拆分成所有关键词并去重
const keywords = [...new Set(
  userInput.flatMap(line => 
    line.split(',').map(s => s.trim())
  )
)];
// ['apple', 'banana', 'orange', 'react', 'vue', 'angular', 'frontend', 'backend']

技巧 4:根据数量展开元素

场景:购物车根据商品数量生成明细

const cart = [
  { name: 'iPhone', quantity: 2 },
  { name: 'AirPods', quantity: 1 }
];

// 展开成独立项(用于库存检查)
const items = cart.flatMap(product =>
  Array(product.quantity).fill(product.name)
);
// ['iPhone', 'iPhone', 'AirPods']

技巧 5:过滤 null/undefined(但要注意坑)

场景:清理接口返回的数据

const apiResponse = [1, null, 2, undefined, 3, null, 4];

// ✅ 正确写法
const clean = apiResponse.flatMap(x => 
  x != null ? [x] : []
);
// [1, 2, 3, 4]

// ❌ 错误写法(会误删 0 和空字符串)
// const wrong = apiResponse.flatMap(x => x || []);
// const wrong2 = apiResponse.flatMap(x => x ?? []);

坑点警示x || []x ?? [] 会把 0''false 也过滤掉!


第二部分:进阶技巧(6-12)

技巧 6:递归扁平化树结构

场景:无限极分类转平级

const categoryTree = [
  {
    id: 1,
    name: '电子产品',
    children: [
      {
        id: 2,
        name: '手机',
        children: [
          { id: 3, name: 'iPhone', children: [] }
        ]
      }
    ]
  }
];

function flattenTree(nodes, depth = 0) {
  return nodes.flatMap(node => [
    { ...node, depth, children: undefined },
    ...flattenTree(node.children || [], depth + 1)
  ]);
}

const flatList = flattenTree(categoryTree);
/*
[
  { id: 1, name: '电子产品', depth: 0 },
  { id: 2, name: '手机', depth: 1 },
  { id: 3, name: 'iPhone', depth: 2 }
]
*/

技巧 7:笛卡尔积生成(SKU 组合)

场景:电商 SKU 生成

const colors = ['深空灰', '银色'];
const sizes = ['128GB', '256GB'];
const editions = ['标准版', 'Pro版'];

// 生成所有 SKU 组合
const skus = colors.flatMap(color =>
  sizes.flatMap(size =>
    editions.map(edition => ({
      sku: `PHONE-${color}-${size}-${edition}`,
      color,
      size,
      edition,
      price: calculatePrice(color, size, edition)
    }))
  )
);

// 生成 2×2×2=8 个 SKU

技巧 8:分页数据合并

场景:合并多个分页接口返回的数据

const pages = [
  { items: [{ id: 1 }, { id: 2 }], hasMore: true },
  { items: [{ id: 3 }], hasMore: false }
];

// 合并所有 items,并标记来源页
const allItems = pages.flatMap((page, pageIndex) =>
  page.items.map(item => ({
    ...item,
    pageIndex,
    isLastPage: !page.hasMore
  }))
);

/*
[
  { id: 1, pageIndex: 0, isLastPage: false },
  { id: 2, pageIndex: 0, isLastPage: false },
  { id: 3, pageIndex: 1, isLastPage: true }
]
*/

技巧 9:表单验证错误收集

场景:收集所有表单字段的验证错误

const fields = [
  { name: 'email', value: 'invalid', validators: [isEmail, isRequired] },
  { name: 'age', value: 15, validators: [isAdult, isRequired] }
];

const errors = fields.flatMap(field => {
  const fieldErrors = field.validators
    .map(v => v(field.value))
    .filter(error => error !== null);
  
  return fieldErrors.map(msg => ({
    field: field.name,
    message: msg
  }));
});

/*
[
  { field: 'email', message: '邮箱格式不正确' },
  { field: 'age', message: '年龄必须大于18岁' }
]
*/

技巧 10:标签系统展开

场景:文章标签反向索引

const articles = [
  { title: 'React Hooks', tags: ['react', 'frontend'] },
  { title: 'Vue 3', tags: ['vue', 'frontend'] }
];

// 展开成 "标签-文章" 映射
const tagIndex = articles.flatMap(article =>
  article.tags.map(tag => ({ tag, title: article.title }))
);

// 统计每个标签的文章数
const tagCount = tagIndex.reduce((acc, { tag }) => {
  acc[tag] = (acc[tag] || 0) + 1;
  return acc;
}, {});
// { react: 1, frontend: 2, vue: 1 }

技巧 11:权限菜单过滤

场景:根据权限过滤可见菜单

const menuTree = [
  {
    name: '系统管理',
    children: [
      { name: '用户管理', permission: 'user:view', visible: true },
      { name: '配置管理', permission: 'config:view', visible: false }
    ]
  }
];

const userPermissions = ['user:view', 'order:view'];

// 过滤出用户有权限的菜单
const visibleMenus = menuTree.flatMap(menu =>
  menu.children.flatMap(child =>
    child.visible && userPermissions.includes(child.permission)
      ? [{ ...child, parent: menu.name }]
      : []
  )
);

技巧 12:矩阵转置

场景:表格行列转换

const matrix = [
  [1, 2, 3],
  [4, 5, 6],
  [7, 8, 9]
];

// 矩阵转置
const transposed = matrix[0].map((_, colIndex) =>
  matrix.flatMap(row => row[colIndex] !== undefined ? [row[colIndex]] : [])
);

/*
[
  [1, 4, 7],
  [2, 5, 8],
  [3, 6, 9]
]
*/

第三部分:高阶技巧(13-20)

技巧 13:动态条件展开

场景:根据数据类型决定展开策略

const data = [
  { type: 'batch', items: ['a', 'b', 'c'] },
  { type: 'single', value: 'd' }
];

const result = data.flatMap(item => {
  if (item.type === 'batch') {
    return item.items; // 展开数组
  }
  return [item.value]; // 包装成数组
});
// ['a', 'b', 'c', 'd']

技巧 14:深度路径提取

场景:提取嵌套对象的所有叶子节点

const nested = {
  a: { b: { c: 1 } },
  d: { e: 2 }
};

function getPaths(obj, prefix = '') {
  return Object.entries(obj).flatMap(([key, value]) => {
    const path = prefix ? `${prefix}.${key}` : key;
    
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      return getPaths(value, path);
    }
    
    return [{ path, value }];
  });
}

getPaths(nested);
/*
[
  { path: 'a.b.c', value: 1 },
  { path: 'd.e', value: 2 }
]
*/

技巧 15:分组报表生成

场景:销售数据透视表预处理

const sales = [
  { month: '1月', region: '东', amount: 100 },
  { month: '1月', region: '西', amount: 200 },
  { month: '2月', region: '东', amount: 150 }
];

const months = [...new Set(sales.map(s => s.month))];

const report = months.flatMap(month => {
  const monthData = sales.filter(s => s.month === month);
  const total = monthData.reduce((sum, s) => sum + s.amount, 0);
  
  return [{
    month,
    details: monthData,
    total,
    average: total / monthData.length
  }];
});

技巧 16:全选功能展开

场景:下拉框全选展开成子项

const options = [
  { label: '水果', children: ['苹果', '香蕉'] },
  { label: '蔬菜', children: ['白菜'] },
  { label: '全选', value: 'all' }
];

const flattened = options.flatMap(opt => {
  if (opt.value === 'all') {
    // 全选展开成所有子项
    return options
      .filter(o => o.children)
      .flatMap(o => o.children);
  }
  
  return opt.children
    ? opt.children.map(child => ({ label: child, group: opt.label }))
    : [opt];
});

技巧 17:自定义深度扁平化

场景:指定深度的数组扁平化

const deepArray = [1, [2, [3, [4]]], 5];

function flatMapDeep(arr, depth = 1) {
  if (depth === 0) return arr;
  
  return arr.flatMap(item =>
    Array.isArray(item)
      ? flatMapDeep(item, depth - 1)
      : [item]
  );
}

flatMapDeep(deepArray, 1); // [1, 2, [3, [4]], 5]
flatMapDeep(deepArray, 2); // [1, 2, 3, [4], 5]
flatMapDeep(deepArray, 3); // [1, 2, 3, 4, 5]

技巧 18:数据转换管道

场景:多阶段数据处理

const rawData = [
  { id: 1, items: [10, 20], active: true },
  { id: 2, items: [30], active: false },
  { id: 3, items: [40, 50], active: true }
];

// 管道:过滤 -> 展开 -> 转换
const processed = rawData
  .flatMap(item => item.active ? [item] : [])           // 过滤
  .flatMap(item => item.items.map(val => ({             // 展开
    id: item.id, 
    value: val 
  })))
  .map(({ id, value }) => ({                             // 转换
    id,
    value,
    doubled: value * 2
  }));

/*
[
  { id: 1, value: 10, doubled: 20 },
  { id: 1, value: 20, doubled: 40 },
  { id: 3, value: 40, doubled: 80 },
  { id: 3, value: 50, doubled: 100 }
]
*/

技巧 19:多重条件过滤与展开

场景:复杂条件的数据筛选

const products = [
  { name: 'iPhone', category: '手机', variants: ['128GB', '256GB'] },
  { name: 'T恤', category: '服装', variants: ['M', 'L', 'XL'] }
];

// 只展开指定类别的商品变体
const variants = products.flatMap(product => {
  if (product.category !== '手机') return [];
  
  return product.variants.map(variant => ({
    name: `${product.name} ${variant}`,
    basePrice: 5999,
    variant
  }));
});

技巧 20:实战综合案例(购物车系统)

场景:完整的购物车数据处理流程

const cart = [
  {
    id: 1,
    type: 'bundle',
    name: '苹果套装',
    items: [
      { sku: 'IPHONE', name: 'iPhone', price: 5999, stock: 10, category: '手机' },
      { sku: 'AIRPODS', name: 'AirPods', price: 1999, stock: 0, category: '配件' }
    ]
  },
  {
    id: 2,
    type: 'single',
    name: 'MacBook',
    sku: 'MACBOOK',
    price: 14999,
    stock: 5,
    category: '电脑',
    options: { colors: ['深空灰', '银色'], sizes: ['14寸', '16寸'] }
  },
  {
    id: 3,
    type: 'single',
    name: '鼠标',
    sku: 'MOUSE',
    price: 299,
    stock: 100,
    category: '配件'
  }
];

// 步骤1:展开所有商品(包括套装)
const expanded = cart.flatMap(item => {
  if (item.type === 'bundle') {
    return item.items.map(sub => ({ 
      ...sub, 
      bundleId: item.id,
      bundleName: item.name 
    }));
  }
  return [item];
});

// 步骤2:过滤库存为0的商品
const available = expanded.flatMap(product =>
  product.stock > 0 ? [product] : []
);

// 步骤3:生成 SKU 组合
const withSkus = available.flatMap(product => {
  if (product.options) {
    const { colors, sizes } = product.options;
    return colors.flatMap(color =>
      sizes.map(size => ({
        ...product,
        sku: `${product.sku}-${color}-${size}`,
        variant: `${color} ${size}`,
        unitPrice: product.price
      }))
    );
  }
  return [product];
});

// 步骤4:按分类分组
const byCategory = withSkus.reduce((acc, item) => {
  const cat = item.category;
  if (!acc[cat]) acc[cat] = [];
  acc[cat].push(item);
  return acc;
}, {});

// 步骤5:生成订单摘要
const orderSummary = Object.entries(byCategory).map(([category, items]) => ({
  category,
  count: items.length,
  items: items.map(i => ({
    name: i.name,
    sku: i.sku,
    price: i.unitPrice || i.price
  })),
  subtotal: items.reduce((sum, i) => sum + (i.unitPrice || i.price), 0)
}));

console.log('订单摘要:', JSON.stringify(orderSummary, null, 2));

输出结果

[
  {
    "category": "手机",
    "count": 1,
    "items": [{ "name": "iPhone", "sku": "IPHONE", "price": 5999 }],
    "subtotal": 5999
  },
  {
    "category": "电脑",
    "count": 4,
    "items": [
      { "name": "MacBook", "sku": "MACBOOK-深空灰-14寸", "price": 14999 },
      { "name": "MacBook", "sku": "MACBOOK-深空灰-16寸", "price": 14999 },
      { "name": "MacBook", "sku": "MACBOOK-银色-14寸", "price": 14999 },
      { "name": "MacBook", "sku": "MACBOOK-银色-16寸", "price": 14999 }
    ],
    "subtotal": 59996
  },
  {
    "category": "配件",
    "count": 1,
    "items": [{ "name": "鼠标", "sku": "MOUSE", "price": 299 }],
    "subtotal": 299
  }
]

第四部分:性能对比与最佳实践

性能测试

// 测试数据:10000 条订单
const orders = Array(10000).fill(null).map((_, i) => ({
  status: i % 2 === 0 ? 'paid' : 'pending',
  items: [`item-${i}-a`, `item-${i}-b`]
}));

// 方法1:filter + map + flat
console.time('filter-map-flat');
const r1 = orders
  .filter(o => o.status === 'paid')
  .map(o => o.items)
  .flat();
console.timeEnd('filter-map-flat'); // ~2.5ms

// 方法2:flatMap
console.time('flatMap');
const r2 = orders.flatMap(o =>
  o.status === 'paid' ? o.items : []
);
console.timeEnd('flatMap'); // ~1.2ms

// flatMap 快 2 倍!

使用口诀

  1. 能一次遍历做完,绝不用两次(filter + map → flatMap)
  2. 需要条件过滤 + 转换 → flatMap
  3. 需要展开嵌套数组 → flatMap
  4. flatMap 只扁平一层,深度扁平需要递归
  5. 大数据量优先用 flatMap 减少中间数组创建

第五部分:常见踩坑记录

坑 1:误用 ?? 或 || 导致数据丢失

const data = [0, '', false, null, undefined];

// ❌ 错误:会过滤掉 0、''、false
const wrong = data.flatMap(x => x || []); // []
const wrong2 = data.flatMap(x => (x ?? [])[0] ? [x] : []); 

// ✅ 正确:只过滤 null/undefined
const right = data.flatMap(x => x != null ? [x] : []); 
// [0, '', false]

坑 2:async 函数不能直接用于 flatMap

const ids = [1, 2, 3];

// ❌ 错误:flatMap 不会等待 Promise
const wrong = ids.flatMap(async id => {
  const res = await fetch(`/api/${id}`);
  return res.json();
});
// 返回的是 [Promise, Promise, Promise]

// ✅ 正确:先通过 Promise.all 等待所有异步操作完成,再用 flatMap 扁平数据
async function fetchAndProcess() {
  // 第一步:等待所有接口请求 + json 解析完成
  const dataList = await Promise.all(
    ids.map(async id => {
      try {
        const res = await fetch(`/api/${id}`);
        if (!res.ok) throw new Error(`请求失败:${res.status}`);
        return await res.json(); // 关键:等待 json 解析(原示例遗漏 await)
      } catch (err) {
        console.error(`处理 id=${id} 失败:`, err);
        return { items: [] }; // 异常兜底,避免 flatMap 出错
      }
    })
  );

  // 第二步:用 flatMap 扁平 items 数组
  const results = dataList.flatMap(item => item.items || []);
  return results;
}

// 调用示例
fetchAndProcess().then(results => {
  console.log("最终结果:", results);
});

坑 3:返回非数组会报错

const nums = [1, 2, 3];

// ❌ 错误:回调返回非数组(数字),flatMap 会抛出 TypeError
// const wrong = nums.flatMap(n => n * 2); 
// 报错信息:TypeError: Iterator value 2 is not an entry object

// ✅ 正确:回调必须返回数组,flatMap 会自动扁平单层数组
const right = nums.flatMap(n => [n * 2]); // [2, 4, 6]

// 拓展:如果需要多层扁平(比如返回嵌套数组),可用 flatMap + flat
const nestedRight = nums.flatMap(n => [[n * 2]]).flat(); // [2, 4, 6]

总结

经过 3 个项目的实战检验,flatMap 是我使用频率最高的数组方法之一。它的核心优势:

优势 说明
性能 减少遍历次数,大数据量时优势明显
简洁 filter + map 合并成一行代码
灵活 支持条件展开、动态返回 0/1/N 个元素
组合 可链式调用,构建数据处理管道

记住核心心法

  • 需要「条件过滤 + 转换」→ flatMap
  • 需要「展开嵌套」→ flatMap
  • 能一次遍历 → 绝不用两次
❌
❌