作者: admin

  • RESTful API接口设计规范与最佳实践:从入门到精通

    RESTful API接口设计规范与最佳实践:从入门到精通

    引言

    上周帮一个朋友Code Review代码,他写了一个用户管理接口:

    plaintext

    GET /getUserById?id=1
    POST /createUser
    POST /updateUser
    GET /deleteUser?id=1
    

    我看完差点没背过气去。这接口设计…怎么说呢,能用,但总觉得哪里不对劲。

    后来我问他为什么这么设计,他说:”我看很多老项目都是这么写的啊!”

    好吧,这篇文章就是来解决这个问题的。我会把我这些年设计API踩过的坑、总结的经验全部写下来,希望能帮你设计出更规范的接口。

    RESTful API设计示例配图 - 接口规范与状态码使用图示

    REST是什么

    在说RESTful API之前,先得搞清楚REST是什么。

    REST(Representational State Transfer)是Roy Fielding在2000年提出的架构风格,不是标准,也不是协议。它描述了一种设计Web服务的思路。

    简单说,REST的核心思想是:用URL定位资源,用HTTP动词描述操作。

    比如:

    操作RESTful写法非RESTful写法
    获取用户列表GET /usersGET /getUsers
    获取单个用户GET /users/1GET /getUserById?id=1
    创建用户POST /usersPOST /createUser
    更新用户PUT /users/1POST /updateUser
    删除用户DELETE /users/1GET /deleteUser?id=1

    看到了吗?RESTful的核心就是把所有的”动作”都用HTTP方法来表示,而不是在URL里写动词

    URL设计规范

    URL(Uniform Resource Locator)是API的门面,设计得好不好直接影响开发者体验。

    基本原则

    1. 用名词表示资源,不用动词

    bash

    # 好
    GET /users
    GET /users/123
    GET /articles/456/comments
    
    # 不好
    GET /getUsers
    GET /getUser
    GET /queryUsersById
    
    1. 用复数名词表示集合

    bash

    # 好
    GET /users
    POST /users
    
    # 不好
    GET /user
    POST /userList
    
    1. 用小写字母,用连字符分隔单词

    bash

    # 好
    GET /user-profiles
    GET /blog-posts
    
    # 不推荐(虽然也能工作)
    GET /userProfiles
    GET /blog_posts  # 下划线在某些字体里容易看不清
    
    1. 层级关系用斜杠表示

    bash

    # 获取用户123的所有文章
    GET /users/123/articles
    
    # 获取用户123的文章456的评论
    GET /users/123/articles/456/comments
    

    查询参数的使用

    当需要对资源进行过滤、排序、分页时,使用查询参数:

    bash

    # 过滤
    GET /users?status=active&role=admin
    
    # 排序
    GET /users?sort=created_at&order=desc
    
    # 分页
    GET /users?page=2&page_size=20
    
    # 搜索
    GET /users?search=张三
    

    避免过深的层级

    虽然REST支持多层嵌套,但不要太深:

    bash

    # 不推荐(嵌套太深)
    GET /organizations/123/departments/456/teams/789/members/101
    
    # 推荐(扁平化设计)
    GET /members/101?organization=123&department=456&team=789
    

    HTTP方法:正确使用动词

    HTTP定义了一组方法(也叫动词),RESTful API要用这些方法来表示操作。

    常用方法

    方法用途幂等性安全性
    GET获取资源
    POST创建资源
    PUT完整更新资源
    PATCH部分更新资源
    DELETE删除资源

    幂等性:同样的请求执行一次和执行多次,效果是一样的。
    安全性:不会改变服务器状态。

    GET:获取资源

    bash

    # 获取所有用户(列表)
    GET /users
    
    # 获取单个用户
    GET /users/123
    
    # 获取用户的好友列表
    GET /users/123/friends
    
    # 获取多个特定用户
    GET /users?id=1&id=2&id=3
    

    POST:创建资源

    bash

    # 创建用户
    POST /users
    Content-Type: application/json
    
    {
        "name": "张三",
        "email": "zhangsan@example.com",
        "password": "123456"
    }
    

    响应:

    http

    HTTP/1.1 201 Created
    Location: /users/456
    Content-Type: application/json
    
    {
        "id": 456,
        "name": "张三",
        "email": "zhangsan@example.com",
        "created_at": "2026-04-15T10:30:00Z"
    }
    

    注意返回状态码201和Location头。

    PUT:完整更新资源

    PUT要求提交完整的资源数据,缺少的字段会被设为默认值或清空:

    bash

    PUT /users/456
    Content-Type: application/json
    
    {
        "name": "张三改",
        "email": "zhangsan_changed@example.com",
        "password": "654321",
        "status": "active"
    }
    

    PATCH:部分更新资源

    PATCH只更新提供的字段,其他字段保持不变:

    bash

    PATCH /users/456
    Content-Type: application/json
    
    {
        "email": "zhangsan_new@example.com"
    }
    

    DELETE:删除资源

    bash

    DELETE /users/456
    

    成功删除返回204 No Content:

    http

    HTTP/1.1 204 No Content
    

    状态码:让客户端知道发生了什么

    状态码是HTTP响应的核心部分,它告诉客户端请求的结果是什么。

    常用状态码

    状态码含义使用场景
    200 OK成功GET、PUT、PATCH成功
    201 Created创建成功POST创建新资源成功
    204 No Content无内容DELETE成功,响应无body
    400 Bad Request请求错误参数校验失败、格式错误
    401 Unauthorized未认证需要登录
    403 Forbidden无权限已登录但无权限
    404 Not Found资源不存在找不到指定资源
    409 Conflict冲突资源冲突,如重复创建
    422 Unprocessable Entity验证失败数据验证失败
    500 Internal Server Error服务器错误程序异常
    503 Service Unavailable服务不可用维护或过载

    分层状态码

    不要只用200和500,要根据情况返回合适的状态码:

    javascript

    // Express.js 示例
    app.get('/users/:id', async (req, res) => {
        try {
            const userId = parseInt(req.params.id);
            
            if (isNaN(userId)) {
                return res.status(400).json({
                    error: 'Invalid user ID',
                    message: 'User ID must be a number'
                });
            }
            
            const user = await db.getUser(userId);
            
            if (!user) {
                return res.status(404).json({
                    error: 'User not found',
                    message: `No user with ID ${userId}`
                });
            }
            
            res.json(user);
        } catch (error) {
            console.error('Database error:', error);
            res.status(500).json({
                error: 'Internal server error',
                message: 'Please try again later'
            });
        }
    });
    

    请求和响应格式

    JSON是你的好朋友

    现代API基本都用JSON作为数据格式:

    http

    POST /users
    Content-Type: application/json
    Accept: application/json
    
    {
        "name": "张三",
        "email": "zhangsan@example.com"
    }
    

    响应:

    http

    HTTP/1.1 201 Created
    Content-Type: application/json
    
    {
        "id": 123,
        "name": "张三",
        "email": "zhangsan@example.com",
        "created_at": "2026-04-15T10:30:00Z"
    }
    

    统一的响应结构

    建议所有API都用统一的响应格式:

    javascript

    // 成功响应
    {
        "success": true,
        "data": { ... },
        "message": "操作成功"
    }
    
    // 分页响应
    {
        "success": true,
        "data": {
            "items": [...],
            "pagination": {
                "page": 1,
                "page_size": 20,
                "total": 100,
                "total_pages": 5
            }
        }
    }
    
    // 错误响应
    {
        "success": false,
        "error": {
            "code": "VALIDATION_ERROR",
            "message": "参数校验失败",
            "details": [
                { "field": "email", "message": "邮箱格式不正确" },
                { "field": "password", "message": "密码长度不能少于6位" }
            ]
        }
    }
    

    时间格式

    统一使用ISO 8601格式:

    json

    {
        "created_at": "2026-04-15T10:30:00Z",
        "updated_at": "2026-04-15T12:45:30+08:00"
    }
    

    字段命名

    统一用驼峰或蛇形,两者选其一:

    json

    // 驼峰(推荐,JavaScript风格)
    {
        "userId": 123,
        "userName": "张三",
        "createdAt": "2026-04-15T10:30:00Z"
    }
    
    // 蛇形(Python风格)
    {
        "user_id": 123,
        "user_name": "张三",
        "created_at": "2026-04-15T10:30:00Z"
    }
    

    认证与授权

    认证(Authentication)

    确认”你是谁”,常用方式:

    1. API Key

    bash

    GET /users?api_key=your_api_key_here
    

    适合服务端之间的调用,不适合用户认证。

    2. Bearer Token(JWT)

    bash

    GET /users
    Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    

    最常用的方式,适合移动端和前端。

    3. OAuth 2.0

    适合第三方登录,如微信登录、Google登录等。

    授权(Authorization)

    确认”你能做什么”,常用方式:

    javascript

    // 简单角色检查
    function authorize(roles = []) {
        return (req, res, next) => {
            if (!req.user) {
                return res.status(401).json({ error: 'Unauthorized' });
            }
            
            if (!roles.includes(req.user.role)) {
                return res.status(403).json({ error: 'Forbidden' });
            }
            
            next();
        };
    }
    
    // 使用中间件
    app.delete('/users/:id', 
        authenticate,  // 确认登录
        authorize(['admin']),  // 确认是管理员
        async (req, res) => {
            // 删除用户逻辑
        }
    );
    

    分页、排序和过滤

    分页

    bash

    # 页码分页
    GET /users?page=2&page_size=20
    
    # 偏移分页(适合大数据量)
    GET /users?offset=40&limit=20
    
    # 光标分页(适合实时数据)
    GET /users?cursor=abc123&limit=20
    

    响应示例:

    json

    {
        "data": [...],
        "pagination": {
            "page": 2,
            "page_size": 20,
            "total": 100,
            "total_pages": 5,
            "has_next": true,
            "has_prev": true
        }
    }
    

    排序

    bash

    # 按创建时间降序
    GET /users?sort=created_at&order=desc
    
    # 多字段排序
    GET /users?sort=role,created_at&order=asc,desc
    

    过滤

    bash

    # 单个条件
    GET /users?status=active
    
    # 多个条件
    GET /users?status=active&role=admin
    
    # 范围查询
    GET /users?age_gte=18&age_lte=60
    
    # 模糊搜索
    GET /users?search=张三
    

    错误处理

    错误响应格式

    json

    {
        "success": false,
        "error": {
            "code": "RESOURCE_NOT_FOUND",
            "message": "请求的资源不存在",
            "details": {
                "resource": "user",
                "id": "12345"
            }
        },
        "request_id": "req_abc123"
    }
    

    错误码设计

    建议使用枚举定义错误码:

    javascript

    const ErrorCodes = {
        // 通用错误(1000-1999)
        INTERNAL_ERROR: { code: 1000, status: 500, message: '服务器内部错误' },
        INVALID_PARAMETER: { code: 1001, status: 400, message: '参数错误' },
        
        // 认证错误(2000-2999)
        UNAUTHORIZED: { code: 2000, status: 401, message: '未认证' },
        TOKEN_EXPIRED: { code: 2001, status: 401, message: 'Token已过期' },
        
        // 权限错误(3000-3999)
        FORBIDDEN: { code: 3000, status: 403, message: '无权限访问' },
        
        // 资源错误(4000-4999)
        NOT_FOUND: { code: 4000, status: 404, message: '资源不存在' },
        ALREADY_EXISTS: { code: 4001, status: 409, message: '资源已存在' },
        
        // 业务错误(5000-5999)
        INSUFFICIENT_BALANCE: { code: 5000, status: 400, message: '余额不足' }
    };
    
    // 统一错误处理函数
    function handleError(errorCode, details = {}) {
        return {
            success: false,
            error: {
                code: errorCode.code,
                message: errorCode.message,
                ...details
            }
        };
    }
    
    // 使用
    app.get('/users/:id', async (req, res) => {
        const user = await getUser(req.params.id);
        if (!user) {
            return res.status(404).json(handleError(ErrorCodes.NOT_FOUND, { resource: 'user' }));
        }
        res.json(user);
    });
    

    API版本管理

    当API需要升级但不兼容旧版本时,需要版本管理。

    URL路径版本(推荐)

    bash

    GET /v1/users
    GET /v2/users
    

    优点:直观,易于调试
    缺点:代码需要维护多个版本

    Header版本

    bash

    GET /users
    Accept: application/vnd.example.v2+json
    

    优点:URL保持干净
    缺点:调试不方便

    版本共存策略

    javascript

    // Express.js 示例
    const v1Routes = require('./routes/v1');
    const v2Routes = require('./routes/v2');
    
    app.use('/v1', v1Routes);
    app.use('/v2', v2Routes);
    

    废弃旧版本

    http

    GET /v1/users
    
    # 响应头提示
    Deprecation: true
    Sunset: Thu, 31 Dec 2026 23:59:59 GMT
    Link: <https://api.example.com/v2/users>; rel="successor-version"
    

    实战:完整的用户管理API

    项目结构

    plaintext

    user-api/
    ├── src/
    │   ├── routes/
    │   │   └── users.js
    │   ├── middleware/
    │   │   ├── auth.js
    │   │   └── errorHandler.js
    │   ├── models/
    │   │   └── User.js
    │   ├── controllers/
    │   │   └── userController.js
    │   ├── utils/
    │   │   └── errors.js
    │   └── app.js
    ├── package.json
    └── README.md
    

    Express.js实现

    src/utils/errors.js:错误定义

    javascript

    class AppError extends Error {
        constructor(code, statusCode, message) {
            super(message);
            this.code = code;
            this.statusCode = statusCode;
            this.isOperational = true;
            
            Error.captureStackTrace(this, this.constructor);
        }
    }
    
    class ValidationError extends AppError {
        constructor(message, details = []) {
            super('VALIDATION_ERROR', 400, message);
            this.details = details;
        }
    }
    
    class NotFoundError extends AppError {
        constructor(resource, id) {
            super('NOT_FOUND', 404, `${resource} with id ${id} not found`);
            this.resource = resource;
            this.resourceId = id;
        }
    }
    
    class UnauthorizedError extends AppError {
        constructor(message = 'Authentication required') {
            super('UNAUTHORIZED', 401, message);
        }
    }
    
    class ForbiddenError extends AppError {
        constructor(message = 'Access denied') {
            super('FORBIDDEN', 403, message);
        }
    }
    
    module.exports = {
        AppError,
        ValidationError,
        NotFoundError,
        UnauthorizedError,
        ForbiddenError
    };
    

    src/middleware/errorHandler.js:错误处理中间件

    javascript

    const { AppError } = require('../utils/errors');
    
    function errorHandler(err, req, res, next) {
        console.error('Error:', err);
        
        if (err instanceof AppError) {
            return res.status(err.statusCode).json({
                success: false,
                error: {
                    code: err.code,
                    message: err.message,
                    details: err.details || undefined
                },
                request_id: req.id
            });
        }
        
        // 开发环境返回详细错误
        if (process.env.NODE_ENV === 'development') {
            return res.status(500).json({
                success: false,
                error: {
                    code: 'INTERNAL_ERROR',
                    message: err.message,
                    stack: err.stack
                }
            });
        }
        
        // 生产环境返回通用错误
        res.status(500).json({
            success: false,
            error: {
                code: 'INTERNAL_ERROR',
                message: 'An unexpected error occurred'
            }
        });
    }
    
    module.exports = errorHandler;
    

    src/middleware/auth.js:认证中间件

    javascript

    const jwt = require('jsonwebtoken');
    const { UnauthorizedError } = require('../utils/errors');
    
    function authenticate(req, res, next) {
        const authHeader = req.headers.authorization;
        
        if (!authHeader || !authHeader.startsWith('Bearer ')) {
            throw new UnauthorizedError('No token provided');
        }
        
        const token = authHeader.substring(7);
        
        try {
            const decoded = jwt.verify(token, process.env.JWT_SECRET);
            req.user = decoded;
            next();
        } catch (error) {
            if (error.name === 'TokenExpiredError') {
                throw new UnauthorizedError('Token expired');
            }
            throw new UnauthorizedError('Invalid token');
        }
    }
    
    function authorize(...roles) {
        return (req, res, next) => {
            if (!req.user) {
                throw new UnauthorizedError();
            }
            
            if (roles.length > 0 && !roles.includes(req.user.role)) {
                throw new ForbiddenError();
            }
            
            next();
        };
    }
    
    module.exports = { authenticate, authorize };
    

    src/models/User.js:数据模型(模拟)

    javascript

    // 模拟数据库
    const users = new Map();
    let nextId = 1;
    
    class User {
        static findAll(filters = {}) {
            let result = Array.from(users.values());
            
            // 应用过滤条件
            if (filters.status) {
                result = result.filter(u => u.status === filters.status);
            }
            if (filters.role) {
                result = result.filter(u => u.role === filters.role);
            }
            if (filters.search) {
                const search = filters.search.toLowerCase();
                result = result.filter(u => 
                    u.name.toLowerCase().includes(search) ||
                    u.email.toLowerCase().includes(search)
                );
            }
            
            // 排序
            if (filters.sort) {
                const order = filters.order === 'desc' ? -1 : 1;
                result.sort((a, b) => {
                    if (a[filters.sort] < b[filters.sort]) return -1 * order;
                    if (a[filters.sort] > b[filters.sort]) return 1 * order;
                    return 0;
                });
            }
            
            return result;
        }
        
        static findById(id) {
            return users.get(id);
        }
        
        static create(data) {
            const id = nextId++;
            const user = {
                id,
                ...data,
                status: 'active',
                created_at: new Date().toISOString(),
                updated_at: new Date().toISOString()
            };
            users.set(id, user);
            return user;
        }
        
        static update(id, data) {
            const user = users.get(id);
            if (!user) return null;
            
            const updated = {
                ...user,
                ...data,
                id,  // 确保id不可修改
                updated_at: new Date().toISOString()
            };
            users.set(id, updated);
            return updated;
        }
        
        static delete(id) {
            return users.delete(id);
        }
    }
    
    module.exports = User;
    

    src/controllers/userController.js:控制器

    javascript

    const User = require('../models/User');
    const { ValidationError, NotFoundError } = require('../utils/errors');
    
    // 获取用户列表
    async function getUsers(req, res) {
        const filters = {
            status: req.query.status,
            role: req.query.role,
            search: req.query.search,
            sort: req.query.sort || 'created_at',
            order: req.query.order || 'desc'
        };
        
        const page = parseInt(req.query.page) || 1;
        const pageSize = Math.min(parseInt(req.query.page_size) || 20, 100);
        
        const allUsers = User.findAll(filters);
        const total = allUsers.length;
        const totalPages = Math.ceil(total / pageSize);
        
        const start = (page - 1) * pageSize;
        const users = allUsers.slice(start, start + pageSize);
        
        res.json({
            success: true,
            data: {
                items: users,
                pagination: {
                    page,
                    page_size: pageSize,
                    total,
                    total_pages: totalPages,
                    has_next: page < totalPages,
                    has_prev: page > 1
                }
            }
        });
    }
    
    // 获取单个用户
    async function getUser(req, res) {
        const user = User.findById(parseInt(req.params.id));
        
        if (!user) {
            throw new NotFoundError('user', req.params.id);
        }
        
        res.json({
            success: true,
            data: user
        });
    }
    
    // 创建用户
    async function createUser(req, res) {
        const { name, email, password, role } = req.body;
        
        // 验证
        if (!name || !email || !password) {
            throw new ValidationError('Missing required fields', [
                { field: 'name', message: 'Name is required' },
                { field: 'email', message: 'Email is required' },
                { field: 'password', message: 'Password is required' }
            ]);
        }
        
        // 简单邮箱格式验证
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(email)) {
            throw new ValidationError('Invalid email format');
        }
        
        // 密码长度验证
        if (password.length < 6) {
            throw new ValidationError('Password too short');
        }
        
        const user = User.create({ name, email, password, role });
        
        res.status(201).json({
            success: true,
            data: user
        });
    }
    
    // 更新用户
    async function updateUser(req, res) {
        const id = parseInt(req.params.id);
        
        // 检查用户是否存在
        const existingUser = User.findById(id);
        if (!existingUser) {
            throw new NotFoundError('user', id);
        }
        
        // 邮箱格式验证(如果提供了email)
        if (req.body.email) {
            const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
            if (!emailRegex.test(req.body.email)) {
                throw new ValidationError('Invalid email format');
            }
        }
        
        const user = User.update(id, req.body);
        
        res.json({
            success: true,
            data: user
        });
    }
    
    // 删除用户
    async function deleteUser(req, res) {
        const id = parseInt(req.params.id);
        
        const deleted = User.delete(id);
        if (!deleted) {
            throw new NotFoundError('user', id);
        }
        
        res.status(204).send();
    }
    
    module.exports = {
        getUsers,
        getUser,
        createUser,
        updateUser,
        deleteUser
    };
    

    src/routes/users.js:路由

    javascript

    const express = require('express');
    const router = express.Router();
    const { authenticate, authorize } = require('../middleware/auth');
    const userController = require('../controllers/userController');
    
    // 公开路由
    router.get('/', userController.getUsers);
    router.get('/:id', userController.getUser);
    
    // 需要认证的路由
    router.post('/', authenticate, authorize('admin'), userController.createUser);
    router.put('/:id', authenticate, authorize('admin'), userController.updateUser);
    router.delete('/:id', authenticate, authorize('admin'), userController.deleteUser);
    
    module.exports = router;
    

    src/app.js:主应用

    javascript

    const express = require('express');
    const errorHandler = require('./middleware/errorHandler');
    const usersRouter = require('./routes/users');
    
    const app = express();
    
    // 中间件
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));
    
    // 请求日志(简单版)
    app.use((req, res, next) => {
        console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
        next();
    });
    
    // 路由
    app.use('/api/users', usersRouter);
    
    // 健康检查
    app.get('/health', (req, res) => {
        res.json({ status: 'ok', timestamp: new Date().toISOString() });
    });
    
    // 404处理
    app.use((req, res) => {
        res.status(404).json({
            success: false,
            error: {
                code: 'NOT_FOUND',
                message: `Route ${req.method} ${req.url} not found`
            }
        });
    });
    
    // 错误处理
    app.use(errorHandler);
    
    const PORT = process.env.PORT || 3000;
    app.listen(PORT, () => {
        console.log(`Server running on port ${PORT}`);
    });
    
    module.exports = app;
    

    运行测试

    bash

    # 安装依赖
    npm install express jsonwebtoken
    
    # 启动服务
    node src/app.js
    

    测试接口:

    bash

    # 健康检查
    curl http://localhost:3000/health
    
    # 获取用户列表
    curl http://localhost:3000/api/users
    
    # 创建用户(需要token,这里简化处理)
    curl -X POST http://localhost:3000/api/users \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer your_token_here" \
      -d '{"name":"张三","email":"zhangsan@example.com","password":"123456"}'
    
    # 获取单个用户
    curl http://localhost:3000/api/users/1
    
    # 更新用户
    curl -X PUT http://localhost:3000/api/users/1 \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer your_token_here" \
      -d '{"name":"张三改"}'
    
    # 删除用户
    curl -X DELETE http://localhost:3000/api/users/1 \
      -H "Authorization: Bearer your_token_here"
    

    文档与调试

    API文档

    好的文档比代码还重要。推荐使用OpenAPI(Swagger)规范:

    yaml

    # openapi.yaml
    openapi: 3.0.0
    info:
      title: User Management API
      version: 1.0.0
      description: 用户管理API接口文档
    
    paths:
      /users:
        get:
          summary: 获取用户列表
          parameters:
            - name: page
              in: query
              schema:
                type: integer
                default: 1
            - name: page_size
              in: query
              schema:
                type: integer
                default: 20
          responses:
            '200':
              description: 成功
              content:
                application/json:
                  schema:
                    $ref: '#/components/schemas/UserList'
    
    components:
      schemas:
        User:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            status:
              type: string
              enum: [active, inactive]
    

    常用调试工具

    • Postman:功能强大的API测试工具
    • Insomnia:轻量级API客户端
    • curl:命令行工具,适合快速测试
    • 浏览器开发者工具:Network面板

    总结

    好了,RESTful API设计规范就讲到这里。回顾一下今天学的内容:

    1. REST核心思想:URL表示资源,HTTP方法表示操作
    2. URL设计:用名词不用动词,复数形式,层级清晰
    3. HTTP方法:GET/POST/PUT/PATCH/DELETE的正确使用
    4. 状态码:合理使用2xx/4xx/5xx状态码
    5. 响应格式:统一的JSON结构
    6. 认证授权:Bearer Token+JWT
    7. 分页排序:多种分页方式,清晰的参数设计
    8. 错误处理:规范化错误码和错误格式
    9. 版本管理:URL路径版本控制
    10. 文档:使用OpenAPI规范

    说真的,设计好的API不是一蹴而就的事,需要在实际项目中不断打磨。我的建议是:

    1. 先设计再写代码,别边写边改
    2. 多看看优秀API的设计(GitHub、Stripe都是好例子)
    3. 站在调用方的角度思考用户体验
    4. 文档和代码同样重要
    5. 保持一致性

    关于内链方面,你可以继续学习TypeScript入门完全指南,用TypeScript能写出更健壮的API后端代码。或者学习Vue3 Composition API实战教程,了解前端如何调用这些API。

    常见问题

    Q:GET请求能不能带body?

    A:技术上可以,但主流做法是不这么做。很多HTTP客户端和代理会忽略GET请求的body。建议GET请求的参数都用query string传递。

    Q:PUT和PATCH有什么区别?

    A:PUT要求提供完整资源数据,缺失字段会被清空或设为默认值。PATCH只需要提供要修改的字段,其他字段保持不变。

    Q:POST和PUT都能创建资源,选哪个?

    A:POST用于创建资源,服务器自动生成ID。PUT用于创建或替换资源,通常客户端指定ID。在实际项目中,POST用于创建,PUT用于更新。

    Q:如何设计批量操作的API?

    A:可以用数组参数:

    bash

    POST /users/batch
    {
        "operations": [
            {"action": "create", "data": {...}},
            {"action": "update", "id": 123, "data": {...}},
            {"action": "delete", "id": 456}
        ]
    }
    

    Q:API应该返回多少数据?字段是否需要过滤?

    A:建议支持字段选择:

    bash

    GET /users?fields=id,name,email
    

    只返回需要的字段,减少网络传输量。

    Q:什么时候用状态码,什么时候用错误码?

    A:HTTP状态码表示请求级别的结果(成功、认证失败、资源不存在等),应用错误码表示业务逻辑的错误(余额不足、库存不足等)。两者配合使用。

    希望这篇教程对你有帮助。如果有问题或建议,欢迎在评论区交流!

  • Vue3 Composition API实战教程:像搭积木一样构建组件

    Vue3 Composition API实战教程:像搭积木一样构建组件

    引言

    我认识一个朋友,之前写了两年Vue2,最近开始用Vue3重构项目。第一次看到Composition API的时候,他发了条朋友圈:”这不就是把options API拆开写吗?”

    说实话,我一开始也是这么想的。但当我用它写了两个实际项目之后,发现这玩意儿确实香。

    为什么?因为以前写组件,所有逻辑都堆在data、methods、computed、watch这四个选项里。同一个功能相关的代码被拆得七零八落,找起来头疼,改起来更头疼。Composition API把同一个功能的代码聚在一起,逻辑清晰多了。

    这篇文章就是把我从”看不懂Composition API”到”真香”的过程记录下来,帮你少走弯路。

    Vue3响应式系统配图 - setup函数与组合式函数演示

    环境准备:搭建Vue3项目

    使用Vite创建项目

    Vite是Vue官方推荐的构建工具,比Vue CLI快很多。

    bash

    # 创建项目
    npm create vite@latest my-vue-app -- --template vue
    
    # 进入项目目录
    cd my-vue-app
    
    # 安装依赖
    npm install
    
    # 启动开发服务器
    npm run dev
    

    项目结构大概是这样的:

    plaintext

    my-vue-app/
    ├── src/
    │   ├── assets/
    │   ├── components/
    │   ├── App.vue
    │   └── main.js
    ├── index.html
    ├── package.json
    └── vite.config.js
    

    Vue3组件的基本结构

    打开App.vue,你会看到这样的代码:

    vue

    <script setup>
    import { ref, computed, onMounted } from 'vue'
    
    // 响应式数据
    const count = ref(0)
    
    // 计算属性
    const doubleCount = computed(() => count.value * 2)
    
    // 方法
    function increment() {
      count.value++
    }
    
    // 生命周期钩子
    onMounted(() => {
      console.log('组件挂载完成')
    })
    </script>
    
    <template>
      <div>
        <p>计数:{{ count }}</p>
        <p>双倍:{{ doubleCount }}</p>
        <button @click="increment">+1</button>
      </div>
    </template>
    
    <style scoped>
    button {
      padding: 8px 16px;
      background: #42b883;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    </style>
    

    这就是Vue3 Composition API的基本写法。注意<script setup>语法,这是官方推荐的写法,比普通<script>更简洁。

    响应式系统:Vue3的核心

    响应式是Vue的灵魂。数据变化,界面自动更新,不需要手动操作DOM。

    ref和reactive

    Vue3有两种创建响应式数据的方式:

    ref:基础类型的响应式

    vue

    <script setup>
    import { ref } from 'vue'
    
    // ref用于基础类型(string、number、boolean等)
    const count = ref(0)
    const name = ref('小明')
    const isActive = ref(false)
    
    // 访问值用.value
    function increment() {
      count.value++
    }
    
    function changeName() {
      name.value = '小红'
    }
    </script>
    
    <template>
      <div>
        <p>{{ name }} 点击了 {{ count }} 次</p>
        <button @click="increment">点我</button>
        <p v-if="isActive">显示中</p>
      </div>
    </template>
    

    reactive:对象的响应式

    vue

    <script setup>
    import { reactive } from 'vue'
    
    // reactive用于对象和数组
    const user = reactive({
      name: '张三',
      age: 25,
      email: 'zhangsan@example.com'
    })
    
    function birthday() {
      user.age++
    }
    
    function updateEmail() {
      user.email = 'new_' + user.email
    }
    </script>
    
    <template>
      <div>
        <p>姓名:{{ user.name }}</p>
        <p>年龄:{{ user.age }}</p>
        <p>邮箱:{{ user.email }}</p>
        <button @click="birthday">过生日</button>
      </div>
    </template>
    

    什么时候用ref,什么时候用reactive?

    我的经验是:

    • 基础类型用ref
    • 对象、数组用reactive
    • 或者统一用ref,因为ref也可以包装对象:const user = ref({...})

    深度响应式

    默认情况下,ref和reactive都是深度响应式的:

    vue

    <script setup>
    import { ref, reactive } from 'vue'
    
    const obj = reactive({
      nested: { count: 0 },
      arr: ['foo', 'bar']
    })
    
    function mutateDeeply() {
      // 这些修改都会触发响应式更新
      obj.nested.count++
      obj.arr.push('baz')
    }
    
    const nestedRef = ref({
      value: { count: 0 }
    })
    
    function mutateNestedRef() {
      // ref包装的对象,修改时需要用.value
      nestedRef.value.count++
    }
    </script>
    

    computed计算属性

    计算属性根据其他响应式数据计算得出,有缓存特性:

    vue

    <script setup>
    import { ref, computed } from 'vue'
    
    const firstName = ref('张')
    const lastName = ref('三')
    
    // 只读计算属性
    const fullName = computed(() => {
      return `${lastName.value}${firstName.value}`
    })
    
    // 可写计算属性
    const fullNameWritable = computed({
      get: () => `${lastName.value}${firstName.value}`,
      set: (value) => {
        // "李四" -> lastName="李", firstName="四"
        if (value.length >= 2) {
          lastName.value = value[0]
          firstName.value = value.slice(1)
        }
      }
    })
    
    function updateName() {
      fullNameWritable.value = '王五'
    }
    </script>
    
    <template>
      <div>
        <p>姓名:{{ fullName }}</p>
        <button @click="updateName">改为王五</button>
      </div>
    </template>
    

    watch监听器

    当响应式数据变化时执行某些操作:

    vue

    <script setup>
    import { ref, watch } from 'vue'
    
    const question = ref('')
    const answer = ref('')
    
    // 监听单个响应式数据
    watch(question, async (newQuestion, oldQuestion) => {
      if (newQuestion.includes('?')) {
        answer.value = '思考中...'
        // 模拟异步请求
        setTimeout(() => {
          answer.value = '这是我的答案'
        }, 1000)
      }
    })
    
    // 监听多个数据
    const first = ref('')
    const second = ref('')
    
    watch([first, second], ([newFirst, newSecond], [oldFirst, oldSecond]) => {
      console.log(`从 ${oldFirst},${oldSecond} 变为 ${newFirst},${newSecond}`)
    })
    </script>
    
    <template>
      <div>
        <input v-model="question" placeholder="输入问题..." />
        <p>{{ answer }}</p>
      </div>
    </template>
    

    watchEffect是另一个选择,它会自动追踪依赖:

    vue

    <script setup>
    import { ref, watchEffect } from 'vue'
    
    const url = ref('https://api.example.com/data')
    
    watchEffect(async () => {
      // 这里的代码会自动追踪url.value的变化
      const response = await fetch(url.value)
      const data = await response.json()
      console.log('获取到数据:', data)
    })
    
    // 改变url会自动触发上面的代码重新执行
    </script>
    

    生命周期钩子

    每个组件从创建到销毁会经历多个阶段,生命周期钩子让你在各个阶段执行代码。

    vue

    <script setup>
    import { 
      onMounted, 
      onUpdated, 
      onUnmounted,
      onBeforeMount,
      onBeforeUpdate,
      onBeforeUnmount,
      onActivated,
      onDeactivated,
      onErrorCaptured
    } from 'vue'
    
    // 组件挂载前
    onBeforeMount(() => {
      console.log('组件即将挂载')
    })
    
    // 组件挂载后(DOM已创建)
    onMounted(() => {
      console.log('组件已挂载')
      // 适合:获取DOM、绑定事件、发送请求
    })
    
    // 组件更新前
    onBeforeUpdate(() => {
      console.log('组件即将更新')
    })
    
    // 组件更新后(DOM已更新)
    onUpdated(() => {
      console.log('组件已更新')
      // 适合:获取更新后的DOM
    })
    
    // 组件卸载前
    onBeforeUnmount(() => {
      console.log('组件即将卸载')
    })
    
    // 组件卸载后
    onUnmounted(() => {
      console.log('组件已卸载')
      // 适合:清理定时器、取消订阅、解绑事件
    })
    
    // 错误捕获(Vue3新增)
    onErrorCaptured((err, instance, info) => {
      console.error('捕获到错误:', err)
      console.error('错误信息:', info)
      return false // 阻止错误继续传播
    })
    </script>
    

    对比Vue2的选项和Vue3的Composition API写法:

    Vue2选项Vue3钩子函数说明
    beforeCreatesetup()替代
    createdsetup()替代
    beforeMountonBeforeMount挂载前
    mountedonMounted挂载后
    beforeUpdateonBeforeUpdate更新前
    updatedonUpdated更新后
    beforeDestroyonBeforeUnmount卸载前
    destroyedonUnmounted卸载后

    组合式函数:Composition API的精髓

    组合式函数(Composables)是Composition API最强大的特性。它让你把相关逻辑抽离成可复用的函数。

    什么是组合式函数

    简单说,组合式函数就是一个用Composition API写的有复用价值的函数:

    javascript

    // useCounter.js
    import { ref, computed } from 'vue'
    
    export function useCounter(initialValue = 0) {
      const count = ref(initialValue)
      
      const doubleCount = computed(() => count.value * 2)
      
      function increment() {
        count.value++
      }
      
      function decrement() {
        count.value--
      }
      
      function reset() {
        count.value = initialValue
      }
      
      return {
        count,
        doubleCount,
        increment,
        decrement,
        reset
      }
    }
    

    在组件中使用组合式函数

    vue

    <!-- Counter.vue -->
    <script setup>
    import { useCounter } from './useCounter'
    
    // 在组件中使用
    const { count, doubleCount, increment, decrement, reset } = useCounter(10)
    </script>
    
    <template>
      <div>
        <p>计数:{{ count }}</p>
        <p>双倍:{{ doubleCount }}</p>
        <button @click="increment">+1</button>
        <button @click="decrement">-1</button>
        <button @click="reset">重置</button>
      </div>
    </template>
    

    现在任何组件想用计数器功能,只需调用这个函数,不需要继承、不需要mixin,代码清晰多了。

    实战:封装一个请求数据的组合式函数

    javascript

    // useFetch.js
    import { ref, watchEffect, isRef } from 'vue'
    
    export function useFetch(url) {
      const data = ref(null)
      const error = ref(null)
      const loading = ref(false)
      
      async function doFetch() {
        loading.value = true
        error.value = null
        
        try {
          // 支持传入ref或普通字符串
          const urlToFetch = isRef(url) ? url.value : url
          const response = await fetch(urlToFetch)
          
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`)
          }
          
          data.value = await response.json()
        } catch (e) {
          error.value = e
        } finally {
          loading.value = false
        }
      }
      
      // 如果url是ref,自动追踪变化并重新请求
      if (isRef(url)) {
        watchEffect(doFetch)
      } else {
        doFetch()
      }
      
      return { data, error, loading, refresh: doFetch }
    }
    

    使用这个函数:

    vue

    <script setup>
    import { ref } from 'vue'
    import { useFetch } from './useFetch'
    
    // 直接传字符串
    const { data: users, loading: usersLoading, error: usersError } = useFetch('/api/users')
    
    // 传ref,当id变化时自动重新请求
    const userId = ref('1')
    const { data: userDetail, loading, error } = useFetch(
      computed(() => `/api/users/${userId.value}`)
    )
    
    function changeUser() {
      userId.value = String(parseInt(userId.value) + 1)
    }
    </script>
    
    <template>
      <div>
        <button @click="changeUser">切换用户</button>
        
        <div v-if="loading">加载中...</div>
        <div v-else-if="error">出错了:{{ error.message }}</div>
        <div v-else>
          <p>用户详情:{{ userDetail }}</p>
        </div>
      </div>
    </template>
    

    实战:封装一个本地存储的组合式函数

    javascript

    // useStorage.js
    import { ref, watch } from 'vue'
    
    export function useStorage(key, defaultValue) {
      // 从localStorage读取初始值
      const storedValue = localStorage.getItem(key)
      const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
      
      // 监听变化,自动保存到localStorage
      watch(data, (newValue) => {
        if (newValue === null || newValue === undefined) {
          localStorage.removeItem(key)
        } else {
          localStorage.setItem(key, JSON.stringify(newValue))
        }
      }, { deep: true })
      
      return data
    }
    

    使用:

    vue

    <script setup>
    import { useStorage } from './useStorage'
    
    // 第一个参数是localStorage的key
    // 第二个参数是默认值
    const username = useStorage('username', '')
    const theme = useStorage('theme', 'light')
    const preferences = useStorage('preferences', { fontSize: 14, language: 'zh' })
    
    function toggleTheme() {
      theme.value = theme.value === 'light' ? 'dark' : 'light'
    }
    </script>
    
    <template>
      <div>
        <input v-model="username" placeholder="输入用户名" />
        <p>当前主题:{{ theme }}</p>
        <button @click="toggleTheme">切换主题</button>
        <p>首选项:{{ preferences }}</p>
      </div>
    </template>
    

    依赖注入:跨层级传递数据

    props层层传递很麻烦?依赖注入帮你解决。

    provide和inject

    父组件提供数据,后代组件直接获取:

    vue

    <!-- Parent.vue -->
    <script setup>
    import { provide, ref } from 'vue'
    
    const theme = ref('light')
    const user = ref({ name: '张三', role: 'admin' })
    
    // 提供给所有后代组件
    provide('theme', theme)
    provide('user', user)
    
    // 也可以提供函数
    provide('toggleTheme', () => {
      theme.value = theme.value === 'light' ? 'dark' : 'light'
    })
    </script>
    
    <template>
      <div :class="theme">
        <ChildComponent />
      </div>
    </template>
    

    vue

    <!-- GrandChild.vue(不需要父传子的props) -->
    <script setup>
    import { inject } from 'vue'
    
    // 注入数据
    const theme = inject('theme')
    const user = inject('user')
    const toggleTheme = inject('toggleTheme')
    </script>
    
    <template>
      <div>
        <p>当前主题:{{ theme }}</p>
        <p>用户:{{ user.name }} ({{ user.role }})</p>
        <button @click="toggleTheme">切换主题</button>
      </div>
    </template>
    

    带默认值的注入

    vue

    <script setup>
    import { inject } from 'vue'
    
    // 第二个参数是默认值
    const config = inject('config', { apiUrl: '/api', timeout: 5000 })
    
    // 或者用函数提供默认值(适合引用类型)
    const theme = inject('theme', () => ref('light'), true)
    </script>
    

    模板引用:操作DOM

    虽然Vue推崇数据驱动DOM,但有些时候还是需要直接操作DOM元素。

    ref绑定元素

    vue

    <script setup>
    import { ref, onMounted } from 'vue'
    
    // 创建ref
    const inputRef = ref(null)
    const canvasRef = ref(null)
    
    onMounted(() => {
      // 访问DOM元素
      inputRef.value.focus()
      
      // 操作Canvas
      const ctx = canvasRef.value.getContext('2d')
      ctx.fillStyle = 'green'
      ctx.fillRect(0, 0, 100, 100)
    })
    </script>
    
    <template>
      <div>
        <!-- 绑定ref -->
        <input ref="inputRef" type="text" />
        <canvas ref="canvasRef" width="200" height="200"></canvas>
      </div>
    </template>
    

    ref绑定组件实例

    vue

    <!-- ChildComponent.vue -->
    <script setup>
    import { ref } from 'vue'
    
    const count = ref(0)
    
    function increment() {
      count.value++
    }
    
    function getCount() {
      return count.value
    }
    
    // 暴露方法给父组件
    defineExpose({
      count,
      increment,
      getCount
    })
    </script>
    

    vue

    <!-- ParentComponent.vue -->
    <script setup>
    import { ref } from 'vue'
    import ChildComponent from './ChildComponent.vue'
    
    const childRef = ref(null)
    
    function handleClick() {
      console.log('子组件count:', childRef.value.count)
      childRef.value.increment()
      console.log('调用后count:', childRef.value.getCount())
    }
    </script>
    
    <template>
      <div>
        <ChildComponent ref="childRef" />
        <button @click="handleClick">操作子组件</button>
      </div>
    </template>
    

    实战:Todo应用

    学完上面的知识,来写个完整的Todo应用练练手。

    项目结构

    plaintext

    src/
    ├── components/
    │   ├── TodoList.vue
    │   ├── TodoItem.vue
    │   ├── TodoInput.vue
    │   └── TodoFilters.vue
    ├── composables/
    │   └── useTodos.js
    ├── App.vue
    └── main.js
    

    useTodos.js:封装Todo逻辑

    javascript

    // composables/useTodos.js
    import { ref, computed, watch } from 'vue'
    
    export function useTodos() {
      // 状态
      const todos = ref([])
      const filter = ref('all') // all, active, completed
      
      // 本地存储
      const STORAGE_KEY = 'vue3-todos'
      
      // 初始化时从localStorage读取
      const storedTodos = localStorage.getItem(STORAGE_KEY)
      if (storedTodos) {
        try {
          todos.value = JSON.parse(storedTodos)
        } catch (e) {
          console.error('读取本地存储失败:', e)
        }
      }
      
      // 保存到localStorage
      watch(todos, (newTodos) => {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(newTodos))
      }, { deep: true })
      
      // 计算属性
      const filteredTodos = computed(() => {
        if (filter.value === 'active') {
          return todos.value.filter(t => !t.completed)
        } else if (filter.value === 'completed') {
          return todos.value.filter(t => t.completed)
        }
        return todos.value
      })
      
      const activeCount = computed(() => 
        todos.value.filter(t => !t.completed).length
      )
      
      const completedCount = computed(() => 
        todos.value.filter(t => t.completed).length
      )
      
      // 方法
      function addTodo(title) {
        if (!title.trim()) return
        
        todos.value.push({
          id: Date.now(),
          title: title.trim(),
          completed: false,
          createdAt: new Date().toISOString()
        })
      }
      
      function toggleTodo(id) {
        const todo = todos.value.find(t => t.id === id)
        if (todo) {
          todo.completed = !todo.completed
        }
      }
      
      function deleteTodo(id) {
        const index = todos.value.findIndex(t => t.id === id)
        if (index !== -1) {
          todos.value.splice(index, 1)
        }
      }
      
      function editTodo(id, newTitle) {
        const todo = todos.value.find(t => t.id === id)
        if (todo && newTitle.trim()) {
          todo.title = newTitle.trim()
        }
      }
      
      function clearCompleted() {
        todos.value = todos.value.filter(t => !t.completed)
      }
      
      function setFilter(newFilter) {
        filter.value = newFilter
      }
      
      return {
        todos,
        filter,
        filteredTodos,
        activeCount,
        completedCount,
        addTodo,
        toggleTodo,
        deleteTodo,
        editTodo,
        clearCompleted,
        setFilter
      }
    }
    

    TodoInput.vue:输入组件

    vue

    <script setup>
    import { ref } from 'vue'
    
    const emit = defineEmits(['add'])
    
    const inputValue = ref('')
    
    function handleSubmit() {
      if (inputValue.value.trim()) {
        emit('add', inputValue.value)
        inputValue.value = ''
      }
    }
    </script>
    
    <template>
      <form @submit.prevent="handleSubmit" class="todo-input">
        <input 
          v-model="inputValue"
          type="text"
          placeholder="输入新任务..."
          class="input-field"
        />
        <button type="submit" class="add-btn">添加</button>
      </form>
    </template>
    
    <style scoped>
    .todo-input {
      display: flex;
      gap: 8px;
      margin-bottom: 16px;
    }
    
    .input-field {
      flex: 1;
      padding: 12px;
      border: 1px solid #ddd;
      border-radius: 6px;
      font-size: 16px;
    }
    
    .input-field:focus {
      outline: none;
      border-color: #42b883;
    }
    
    .add-btn {
      padding: 12px 24px;
      background: #42b883;
      color: white;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      font-size: 16px;
    }
    
    .add-btn:hover {
      background: #3aa876;
    }
    </style>
    

    TodoItem.vue:单个任务组件

    vue

    <script setup>
    import { ref } from 'vue'
    
    const props = defineProps({
      todo: {
        type: Object,
        required: true
      }
    })
    
    const emit = defineEmits(['toggle', 'delete', 'edit'])
    
    const isEditing = ref(false)
    const editValue = ref('')
    
    function startEdit() {
      editValue.value = props.todo.title
      isEditing.value = true
    }
    
    function saveEdit() {
      if (editValue.value.trim()) {
        emit('edit', props.todo.id, editValue.value)
      }
      isEditing.value = false
    }
    
    function cancelEdit() {
      isEditing.value = false
    }
    </script>
    
    <template>
      <li class="todo-item" :class="{ completed: todo.completed }">
        <div v-if="isEditing" class="edit-mode">
          <input 
            v-model="editValue"
            @keyup.enter="saveEdit"
            @keyup.escape="cancelEdit"
            class="edit-input"
          />
          <button @click="saveEdit" class="save-btn">保存</button>
          <button @click="cancelEdit" class="cancel-btn">取消</button>
        </div>
        <div v-else class="view-mode">
          <input 
            type="checkbox"
            :checked="todo.completed"
            @change="$emit('toggle', todo.id)"
            class="checkbox"
          />
          <span class="title">{{ todo.title }}</span>
          <button @click="startEdit" class="edit-btn">编辑</button>
          <button @click="$emit('delete', todo.id)" class="delete-btn">删除</button>
        </div>
      </li>
    </template>
    
    <style scoped>
    .todo-item {
      display: flex;
      align-items: center;
      padding: 12px;
      background: #f9f9f9;
      border-radius: 6px;
      margin-bottom: 8px;
    }
    
    .todo-item.completed .title {
      text-decoration: line-through;
      color: #999;
    }
    
    .checkbox {
      width: 20px;
      height: 20px;
      margin-right: 12px;
      cursor: pointer;
    }
    
    .title {
      flex: 1;
      font-size: 16px;
    }
    
    button {
      padding: 6px 12px;
      margin-left: 8px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    
    .edit-btn {
      background: #e0e0e0;
      color: #333;
    }
    
    .delete-btn {
      background: #ff4444;
      color: white;
    }
    
    .edit-mode {
      display: flex;
      width: 100%;
    }
    
    .edit-input {
      flex: 1;
      padding: 8px;
      border: 1px solid #42b883;
      border-radius: 4px;
      font-size: 16px;
    }
    
    .save-btn {
      background: #42b883;
      color: white;
      margin-left: 8px;
    }
    
    .cancel-btn {
      background: #e0e0e0;
      color: #333;
    }
    </style>
    

    TodoFilters.vue:筛选组件

    vue

    <script setup>
    defineProps({
      filter: {
        type: String,
        required: true
      },
      activeCount: {
        type: Number,
        required: true
      },
      completedCount: {
        type: Number,
        required: true
      }
    })
    
    const emit = defineEmits(['setFilter', 'clearCompleted'])
    
    const filters = [
      { key: 'all', label: '全部' },
      { key: 'active', label: '进行中' },
      { key: 'completed', label: '已完成' }
    ]
    </script>
    
    <template>
      <div class="filters">
        <div class="filter-buttons">
          <button
            v-for="f in filters"
            :key="f.key"
            @click="emit('setFilter', f.key)"
            :class="{ active: filter === f.key }"
            class="filter-btn"
          >
            {{ f.label }}
          </button>
        </div>
        
        <div class="stats">
          <span>进行中:{{ activeCount }}</span>
          <span>已完成:{{ completedCount }}</span>
          <button 
            v-if="completedCount > 0"
            @click="emit('clearCompleted')"
            class="clear-btn"
          >
            清除已完成
          </button>
        </div>
      </div>
    </template>
    
    <style scoped>
    .filters {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 12px 0;
      border-top: 1px solid #eee;
    }
    
    .filter-buttons {
      display: flex;
      gap: 8px;
    }
    
    .filter-btn {
      padding: 6px 12px;
      border: 1px solid #ddd;
      background: white;
      border-radius: 4px;
      cursor: pointer;
    }
    
    .filter-btn.active {
      background: #42b883;
      color: white;
      border-color: #42b883;
    }
    
    .stats {
      display: flex;
      gap: 16px;
      align-items: center;
      font-size: 14px;
      color: #666;
    }
    
    .clear-btn {
      padding: 4px 8px;
      background: #ff4444;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 12px;
    }
    </style>
    

    TodoList.vue:整合所有组件

    vue

    <script setup>
    import { useTodos } from '../composables/useTodos'
    import TodoInput from './TodoInput.vue'
    import TodoItem from './TodoItem.vue'
    import TodoFilters from './TodoFilters.vue'
    
    const {
      filter,
      filteredTodos,
      activeCount,
      completedCount,
      addTodo,
      toggleTodo,
      deleteTodo,
      editTodo,
      clearCompleted,
      setFilter
    } = useTodos()
    </script>
    
    <template>
      <div class="todo-list">
        <h1 class="title">Vue3 Todo应用</h1>
        
        <TodoInput @add="addTodo" />
        
        <ul class="todos">
          <TodoItem
            v-for="todo in filteredTodos"
            :key="todo.id"
            :todo="todo"
            @toggle="toggleTodo"
            @delete="deleteTodo"
            @edit="editTodo"
          />
        </ul>
        
        <div v-if="filteredTodos.length === 0" class="empty">
          暂无任务
        </div>
        
        <TodoFilters
          :filter="filter"
          :active-count="activeCount"
          :completed-count="completedCount"
          @set-filter="setFilter"
          @clear-completed="clearCompleted"
        />
      </div>
    </template>
    
    <style scoped>
    .todo-list {
      max-width: 600px;
      margin: 40px auto;
      padding: 24px;
      background: white;
      border-radius: 12px;
      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    }
    
    .title {
      text-align: center;
      color: #42b883;
      margin-bottom: 24px;
    }
    
    .todos {
      list-style: none;
      padding: 0;
      margin: 0;
    }
    
    .empty {
      text-align: center;
      padding: 40px;
      color: #999;
    }
    </style>
    

    App.vue:主组件

    vue

    <script setup>
    import TodoList from './components/TodoList.vue'
    </script>
    
    <template>
      <div class="app">
        <TodoList />
      </div>
    </template>
    
    <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    body {
      background: #f5f5f5;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    }
    
    .app {
      min-height: 100vh;
      padding: 20px;
    }
    </style>
    

    运行效果:

    bash

    npm run dev
    

    打开浏览器访问http://localhost:5173,一个完整的Todo应用就完成了。

    总结

    好了,这篇Vue3 Composition API教程就到这里。让我回顾一下今天学的内容:

    1. 环境搭建:Vite创建Vue3项目,基本组件结构
    2. 响应式系统:ref和reactive的区别,深度响应式
    3. 计算属性和监听器:computed、watch、watchEffect
    4. 生命周期钩子:从创建到销毁的各个阶段
    5. 组合式函数:封装可复用的逻辑
    6. 依赖注入:provide和inject跨层级传递数据
    7. 模板引用:ref绑定DOM和组件
    8. 实战项目:完整的Todo应用

    说实话,Composition API确实比Options API更灵活、更强大。它让你能够:

    • 把相关逻辑放在一起,不用在data、methods、computed之间跳来跳去
    • 抽离出可复用的组合式函数,比mixin清晰多了
    • 更好地组织TypeScript代码(虽然本教程没用TypeScript,但Vue3对TS的支持很好)

    如果你之前用Vue2,可以渐进式迁移。新项目建议直接上Composition API,老项目可以逐步把新组件改成新写法。

    关于内链方面,你可以继续学习TypeScript入门完全指南,TypeScript+Vue3是绝配。或者学习RESTful API接口设计规范,了解如何设计后端API配合Vue3前端使用。

    常见问题

    Q:Composition API和Options API哪个好?

    A:Composition API更好。它让代码逻辑更集中,更容易复用,也更适合TypeScript。新项目建议用Composition API。

    Q:需要完全放弃Vue2吗?

    A:不需要。Vue3完全兼容Options API语法,老项目可以慢慢迁移。可以先在新组件使用Composition API,老组件保持Options API写法。

    Q:什么时候用ref,什么时候用reactive?

    A:建议统一用ref。reactive有一些坑(比如解构后失去响应式),ref没有这个问题,而且语法更一致。

    Q:组合式函数为什么要用”use”开头?

    A:这是一个约定俗成的命名方式,类似于React的”hooks”命名。它让代码更易读,一看就知道这是个组合式函数,可以自动追踪依赖。

    Q:Vue3适合做大型项目吗?

    A:非常适合。Composition API让代码更容易组织和复用,TypeScript支持也很好。我见过不少几百个组件的大型Vue3项目,代码依然保持很好的可维护性。

    如果你有任何问题或建议,欢迎在评论区交流!

  • TypeScript入门完全指南:从JavaScript到类型安全

    TypeScript入门完全指南:从JavaScript到类型安全

    引言

    说实话,我第一次听说TypeScript的时候是拒绝的。”JavaScript不是挺好的吗?为什么还要学一个新的语言?”这是我当时的真实想法。

    但当我写过一个万行级别的JavaScript项目,被那些奇怪的类型隐式转换折磨得夜不能寐之后,我终于决定给TypeScript一个机会。结果嘛——真香。

    这篇文章就是想把我从”TypeScript是什么”到”能写简单项目”的过程完整记录下来,希望你少走一些弯路。

    TypeScript类型系统配图 - 基础类型与接口实战示例

    环境搭建:5分钟启动TypeScript

    安装Node.js

    TypeScript运行在Node.js环境中,所以第一步是安装Node.js。

    Windows/macOS用户:

    直接去Node.js官网下载LTS版本,安装过程没什么坑,一路下一步就行。装完之后打开终端验证:

    bash

    node --version
    npm --version
    

    Linux用户:

    bash

    # Ubuntu/Debian
    sudo apt update
    sudo apt install nodejs npm
    
    # 验证安装
    node --version
    npm --version
    

    安装TypeScript编译器

    有了npm之后,安装TypeScript就是一行命令的事:

    bash

    npm install -g typescript
    

    验证安装:

    bash

    tsc --version
    # 应该输出类似:Version 5.4.3
    

    第一个TypeScript程序

    创建一个文件夹,比如叫ts-hello,然后在里面新建一个文件hello.ts

    typescript

    // hello.ts
    function greet(name: string): string {
        return `你好,${name}!欢迎学习TypeScript。`;
    }
    
    const message = greet("小明");
    console.log(message);
    

    注意这个.ts后缀,这就是TypeScript文件的标志。

    然后在终端里编译它:

    bash

    tsc hello.ts
    

    执行完你会发现多了一个hello.js文件。没错,TypeScript代码需要编译成JavaScript才能运行。运行编译结果:

    bash

    node hello.js
    # 输出:你好,小明!欢迎学习TypeScript。
    

    使用配置文件

    项目里文件多了,每次手动tsc 文件名就太麻烦了。这时候需要一个tsconfig.json配置文件。

    bash

    tsc --init
    

    这会在当前目录生成一个默认配置文件。打开它,你会看到很多选项,这里先关注几个常用的:

    json

    {
        "compilerOptions": {
            "target": "ES2020",          // 编译目标JavaScript版本
            "module": "commonjs",         // 模块系统
            "outDir": "./dist",          // 编译输出目录
            "rootDir": "./src",          // 源码目录
            "strict": true,              // 开启严格模式(建议!)
            "esModuleInterop": true,      // 允许默认导入
            "skipLibCheck": true          // 跳过库文件检查
        },
        "include": ["src/**/*"],         // 要编译的文件
        "exclude": ["node_modules"]     // 排除的文件
    }
    

    配置好之后,把你的.ts文件放到src目录,执行tsc就会自动编译所有文件到dist目录。

    基础类型:TypeScript的核心

    这是TypeScript最重要的地方。类型系统让代码在写的时候就能发现错误,而不是等到运行时才崩溃。

    基础类型有哪些

    JavaScript的那些基础类型TypeScript都支持,只是在写法上有点区别:

    typescript

    // 字符串
    let name: string = "张三";
    let greeting: string = `你好,${name}`;
    
    // 数字
    let age: number = 25;
    let price: number = 99.9;
    
    // 布尔值
    let isStudent: boolean = true;
    
    // 数组
    let numbers: number[] = [1, 2, 3, 4, 5];
    let names: Array<string> = ["小明", "小红", "小刚"];  // 另一种写法
    
    // 元组:固定长度和类型的数组
    let person: [string, number] = ["张三", 25];
    
    // 枚举:一组有名字的常量
    enum Status {
        Pending,    // 0
        Active,     // 1
        Completed   // 2
    }
    let currentStatus: Status = Status.Active;
    
    // 任意类型:当你真的不确定是什么类型时
    let anything: any = 4;
    anything = "hello";
    anything = true;
    
    // void:表示没有返回值
    function logMessage(): void {
        console.log("这是一条日志");
    }
    
    // null 和 undefined
    let n: null = null;
    let u: undefined = undefined;
    

    类型注解和类型推断

    TypeScript有两种方式确定类型:

    类型注解:你手动告诉编译器这是什么类型

    typescript

    let count: number = 10;
    let username: string = "admin";
    

    类型推断:TypeScript根据上下文自动推断类型

    typescript

    let count = 10;        // TypeScript推断这是number类型
    let username = "admin"; // TypeScript推断这是string类型
    
    // 后续再赋值其他类型会报错
    count = "hello";  // 错误!count只能是number
    

    实际开发中,没必要每个变量都写类型注解,TypeScript够聪明的时候会自己推断。但函数参数、返回值、复杂对象这些地方,类型注解是必要的。

    接口:定义对象的形状

    接口是TypeScript最强大的特性之一,它让你定义一个对象的结构:

    typescript

    // 定义一个用户接口
    interface User {
        id: number;
        username: string;
        email: string;
        age?: number;           // 可选属性
        readonly createdAt: Date;  // 只读属性
    }
    
    // 创建符合接口的用户对象
    const user: User = {
        id: 1,
        username: "zhangsan",
        email: "zhangsan@example.com",
        age: 25,
        createdAt: new Date()
    };
    
    // 缺少必填属性会报错
    const badUser: User = {
        id: 2,
        username: "lisi"
        // 错误!缺少email和createdAt
    };
    
    // 尝试修改只读属性会报错
    user.createdAt = new Date();  // 错误!createdAt是只读的
    

    接口还能定义方法:

    typescript

    interface Calculator {
        (a: number, b: number): number;  // 函数的形状
        description: string;
    }
    
    const add: Calculator = (a, b) => a + b;
    add.description = "加法计算器";
    

    联合类型和交叉类型

    有时候一个变量可能有多种类型,这时候用联合类型:

    typescript

    // 联合类型:可以是字符串或数字
    let id: string | number;
    id = "123";    // OK
    id = 123;      // OK
    id = true;     // 错误!布尔值不行
    
    // 用联合类型限制函数参数
    function printId(id: string | number): void {
        if (typeof id === "string") {
            console.log(`字符串ID: ${id.toUpperCase()}`);
        } else {
            console.log(`数字ID: ${id.toFixed(2)}`);
        }
    }
    

    交叉类型:把多个类型合并成一个:

    typescript

    interface Person {
        name: string;
        age: number;
    }
    
    interface Employee {
        company: string;
        salary: number;
    }
    
    // 交叉类型:既是Person又是Employee
    type Worker = Person & Employee;
    
    const worker: Worker = {
        name: "张三",
        age: 30,
        company: "某科技公司",
        salary: 15000
    };
    

    函数:参数和返回值的类型

    TypeScript对函数的类型检查非常严格,这是好事。

    函数类型表达式

    typescript

    // 声明一个函数类型
    type AddFunction = (a: number, b: number) => number;
    
    // 实现这个函数
    const add: AddFunction = (x, y) => x + y;
    
    // 箭头函数写法
    const multiply: AddFunction = (x, y) => x * y;
    

    可选参数和默认参数

    typescript

    // 可选参数:参数名后面加?
    function buildName(firstName: string, lastName?: string): string {
        if (lastName) {
            return `${firstName} ${lastName}`;
        }
        return firstName;
    }
    
    console.log(buildName("张"));        // "张"
    console.log(buildName("张", "三"));   // "张三"
    
    // 默认参数:直接在参数列表赋值
    function greet(name: string, greeting: string = "你好"): string {
        return `${greeting},${name}!`;
    }
    
    console.log(greet("小明"));          // "你好,小明!"
    console.log(greet("小明", "早上好")); // "早上好,小明!"
    

    剩余参数

    typescript

    // 收集剩余参数为数组
    function sum(...numbers: number[]): number {
        return numbers.reduce((total, num) => total + num, 0);
    }
    
    console.log(sum(1, 2, 3));        // 6
    console.log(sum(1, 2, 3, 4, 5)); // 15
    

    函数重载

    同一个函数名,根据不同的参数类型返回不同类型:

    typescript

    // 函数重载签名
    function reverse(x: string): string;  // 输入字符串返回字符串
    function reverse(x: number): number;   // 输入数字返回数字
    function reverse(x: string | number): string | number {
        if (typeof x === "string") {
            return x.split("").reverse().join("");
        }
        return Number(x.toString().split("").reverse().join(""));
    }
    
    console.log(reverse("hello"));  // "olleh"
    console.log(reverse(12345));    // 54321
    

    泛型:让类型灵活起来

    泛型是TypeScript最难但最有用的部分。简单说,泛型就是”类型的变量”。

    为什么需要泛型

    先看一个没有泛型的例子:

    typescript

    // 这个函数返回数组的第一个元素
    function getFirst(arr: any[]): any {
        return arr[0];
    }
    
    const num = getFirst([1, 2, 3]);    // 返回any类型
    const str = getFirst(["a", "b"]);   // 返回any类型
    
    // 调用时丢失了具体类型信息
    num.toFixed(2);  // 编译器不知道num是number,可能报错
    

    用泛型重写:

    typescript

    // T是类型参数,在调用时自动推断
    function getFirst<T>(arr: T[]): T | undefined {
        return arr[0];
    }
    
    const num = getFirst([1, 2, 3]);        // 推断T为number
    const str = getFirst(["a", "b"]);       // 推断T为string
    
    num.toFixed(2);   // OK!TypeScript知道num是number
    str.toUpperCase(); // OK!TypeScript知道str是string
    

    泛型约束

    有时候需要对泛型类型进行限制:

    typescript

    // 约束T必须有length属性
    interface HasLength {
        length: number;
    }
    
    function logLength<T extends HasLength>(arg: T): void {
        console.log(`长度是:${arg.length}`);
    }
    
    logLength("hello");      // OK,字符串有length
    logLength([1, 2, 3]);    // OK,数组有length
    logLength({ length: 10 }); // OK,对象有length
    logLength(123);          // 错误!数字没有length
    

    多类型参数

    typescript

    // 交换两个变量的值
    function swap<T, U>(tuple: [T, U]): [U, T] {
        return [tuple[1], tuple[0]];
    }
    
    const result = swap([1, "hello"]);
    console.log(result);  // ["hello", 1]
    

    实用技巧:工程实践

    类型守卫

    类型守卫让你在条件判断中缩小变量类型范围:

    typescript

    interface Cat {
        meow(): void;
    }
    
    interface Dog {
        bark(): void;
    }
    
    function isCat(animal: Cat | Dog): animal is Cat {
        return (animal as Cat).meow !== undefined;
    }
    
    function speak(animal: Cat | Dog) {
        if (isCat(animal)) {
            animal.meow();  // TypeScript知道这是Cat
        } else {
            animal.bark();  // TypeScript知道这是Dog
        }
    }
    

    keyof和索引类型

    typescript

    interface User {
        id: number;
        name: string;
        email: string;
    }
    
    // keyof User 等于 "id" | "name" | "email"
    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
        return obj[key];
    }
    
    const user: User = {
        id: 1,
        name: "张三",
        email: "zhangsan@example.com"
    };
    
    const userName = getProperty(user, "name");    // string
    const userId = getProperty(user, "id");        // number
    const userAge = getProperty(user, "age");      // 错误!"age"不在User中
    

    工具类型

    TypeScript内置了很多有用的工具类型:

    typescript

    interface User {
        id: number;
        name: string;
        email: string;
    }
    
    // Partial:所有属性变为可选
    type PartialUser = Partial<User>;
    // 等于 { id?: number; name?: string; email?: string; }
    
    // Required:所有属性变为必填
    type RequiredUser = Required<User>;
    
    // Pick:挑选部分属性
    type UserPreview = Pick<User, "id" | "name">;
    // 等于 { id: number; name: string; }
    
    // Omit:排除部分属性
    type UserWithoutEmail = Omit<User, "email">;
    // 等于 { id: number; name: string; }
    
    // Record:创建指定键值对类型
    type UserMap = Record<string, User>;
    const users: UserMap = {
        "zhangsan": { id: 1, name: "张三", email: "zhangsan@example.com" },
        "lisi": { id: 2, name: "李四", email: "lisi@example.com" }
    };
    
    // Exclude:排除联合类型中的某些类型
    type Status = "pending" | "active" | "completed" | "failed";
    type ActiveStatus = Exclude<Status, "pending" | "failed">;
    // 等于 "active" | "completed"
    

    实战:一个简单的任务列表

    学完上面的知识,来动手写个小项目吧。

    项目结构

    plaintext

    task-app/
    ├── src/
    │   ├── types.ts      # 类型定义
    │   ├── task.ts        # 任务类
    │   ├── taskList.ts    # 任务列表
    │   └── index.ts       # 入口文件
    ├── dist/              # 编译输出
    ├── tsconfig.json
    └── package.json
    

    types.ts:类型定义

    typescript

    // 任务状态枚举
    export enum TaskStatus {
        Todo = "todo",
        InProgress = "in_progress",
        Done = "done"
    }
    
    // 任务接口
    export interface Task {
        id: string;
        title: string;
        description?: string;
        status: TaskStatus;
        createdAt: Date;
        completedAt?: Date;
    }
    
    // 创建任务的输入类型
    export interface CreateTaskInput {
        title: string;
        description?: string;
    }
    
    // 更新任务的输入类型
    export interface UpdateTaskInput {
        title?: string;
        description?: string;
        status?: TaskStatus;
    }
    

    task.ts:任务类

    typescript

    import { Task, TaskStatus, CreateTaskInput, UpdateTaskInput } from "./types";
    
    export class TaskItem implements Task {
        id: string;
        title: string;
        description?: string;
        status: TaskStatus;
        createdAt: Date;
        completedAt?: Date;
    
        constructor(input: CreateTaskInput) {
            this.id = this.generateId();
            this.title = input.title;
            this.description = input.description;
            this.status = TaskStatus.Todo;
            this.createdAt = new Date();
        }
    
        private generateId(): string {
            return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
        }
    
        update(input: UpdateTaskInput): void {
            if (input.title !== undefined) {
                this.title = input.title;
            }
            if (input.description !== undefined) {
                this.description = input.description;
            }
            if (input.status !== undefined) {
                this.status = input.status;
                if (input.status === TaskStatus.Done) {
                    this.completedAt = new Date();
                } else {
                    this.completedAt = undefined;
                }
            }
        }
    
        toString(): string {
            const statusText = {
                [TaskStatus.Todo]: "待办",
                [TaskStatus.InProgress]: "进行中",
                [TaskStatus.Done]: "已完成"
            };
            return `[${statusText[this.status]}] ${this.title}`;
        }
    }
    

    taskList.ts:任务列表管理

    typescript

    import { Task, TaskStatus, CreateTaskInput, UpdateTaskInput } from "./types";
    import { TaskItem } from "./task";
    
    export class TaskList {
        private tasks: Map<string, TaskItem> = new Map();
    
        create(input: CreateTaskInput): Task {
            const task = new TaskItem(input);
            this.tasks.set(task.id, task);
            return task;
        }
    
        findById(id: string): Task | undefined {
            return this.tasks.get(id);
        }
    
        findAll(): Task[] {
            return Array.from(this.tasks.values());
        }
    
        findByStatus(status: TaskStatus): Task[] {
            return this.findAll().filter(task => task.status === status);
        }
    
        update(id: string, input: UpdateTaskInput): Task | undefined {
            const task = this.tasks.get(id);
            if (!task) {
                return undefined;
            }
            task.update(input);
            return task;
        }
    
        delete(id: string): boolean {
            return this.tasks.delete(id);
        }
    
        clear(): void {
            this.tasks.clear();
        }
    
        getStats(): { total: number; todo: number; inProgress: number; done: number } {
            const all = this.findAll();
            return {
                total: all.length,
                todo: all.filter(t => t.status === TaskStatus.Todo).length,
                inProgress: all.filter(t => t.status === TaskStatus.InProgress).length,
                done: all.filter(t => t.status === TaskStatus.Done).length
            };
        }
    }
    

    index.ts:入口文件

    typescript

    import { TaskStatus } from "./types";
    import { TaskList } from "./taskList";
    
    // 创建任务列表实例
    const taskList = new TaskList();
    
    // 添加一些任务
    const task1 = taskList.create({
        title: "学习TypeScript",
        description: "完成TypeScript入门教程"
    });
    
    const task2 = taskList.create({
        title: "搭建项目框架",
        description: "使用React + TypeScript"
    });
    
    const task3 = taskList.create({
        title: "写单元测试"
    });
    
    // 更新任务状态
    taskList.update(task1.id, { status: TaskStatus.InProgress });
    
    // 打印所有任务
    console.log("=== 所有任务 ===");
    taskList.findAll().forEach(task => {
        const taskItem = taskList.findById(task.id)!;
        console.log(taskItem.toString());
    });
    
    // 打印统计
    console.log("\n=== 任务统计 ===");
    const stats = taskList.getStats();
    console.log(`总计:${stats.total}`);
    console.log(`待办:${stats.todo}`);
    console.log(`进行中:${stats.inProgress}`);
    console.log(`已完成:${stats.done}`);
    
    // 删除一个任务
    taskList.delete(task3.id);
    console.log("\n删除任务后:", taskList.findAll().length, "个任务");
    

    编译运行:

    bash

    tsc
    node dist/index.js
    

    输出:

    plaintext

    === 所有任务 ===
    [进行中] 学习TypeScript
    [待办] 搭建项目框架
    [待办] 写单元测试
    
    === 任务统计 ===
    总计:3
    待办:2
    进行中:1
    已完成:0
    
    删除任务后: 2 个任务
    

    总结

    好,写到这里你应该对TypeScript有了比较完整的认识。让我简单总结一下今天学的内容:

    1. 环境搭建:Node.js + TypeScript编译器,5分钟搞定
    2. 基础类型:string、number、boolean、数组、枚举、接口
    3. 函数类型:参数类型、返回值类型、可选参数、默认参数、泛型
    4. 泛型:让类型像变量一样灵活使用
    5. 实用技巧:类型守卫、keyof、工具类型
    6. 实战项目:一个完整的任务列表应用

    坦白说,TypeScript的入门门槛确实比JavaScript高一些,但一旦你习惯了写类型注解,你会发现代码质量提升了一大截——Bug少了,代码更好维护了,IDE的智能提示也更准确了。

    如果你之前一直在写JavaScript,强烈建议你新项目尝试用TypeScript,或者把旧项目一点点迁移过去。不需要一步到位,可以先从简单的地方开始加类型注解,慢慢来。

    关于内链方面,你可以继续学习Vue3 Composition API实战教程或者RESTful API接口设计规范,这些都是前端开发者的必备技能。

    常见问题

    Q:TypeScript和JavaScript有什么区别?

    A:TypeScript是JavaScript的超集,添加了类型系统和面向对象的特性。TypeScript代码需要编译成JavaScript才能在浏览器或Node.js中运行。简单说,TypeScript = JavaScript + 类型系统 + 编译。

    Q:TypeScript难学吗?

    A:对于有JavaScript基础的开发者来说,上手TypeScript并不难。最基本的类型注解几乎不需要额外学习。最难的部分是泛型和高级类型,但这些可以在实际项目中慢慢深入。

    Q:所有项目都需要用TypeScript吗?

    A:不一定。小型项目、原型项目用JavaScript更灵活。但中大型项目、团队协作项目、需要长期维护的项目,用TypeScript能显著提升代码质量和可维护性。

    Q:如何从JavaScript迁移到TypeScript?

    A:可以把tsconfig.json中的strict设置为false,然后逐个文件把.js改成.ts。TypeScript会兼容大部分JavaScript语法,然后慢慢加上类型注解。

    希望这篇教程对你有帮助。如果有问题或建议,欢迎在评论区交流!

  • RAG知识库问答系统实战案例:从需求分析到上线部署全流程

    RAG知识库问答系统实战案例:从需求分析到上线部署全流程

    正文

    一、项目背景与需求

    先说说这个项目怎么来的。

    我们公司是做SaaS产品的,客服团队每天要处理大量重复问题,什么”怎么重置密码””退款政策是什么””如何升级套餐”之类的,占了工单总量的60%以上。客服同事累得要命,用户等待时间也长。

    老板提了个需求:能不能做个智能问答机器人,能自动回答这些常见问题?

    技术选型的时候,考虑到几点:

    1. 准确率要够高:答错了比不答更糟糕,必须有据可查
    2. 能对接私有知识库:产品文档、FAQ都是内部资料,通用大模型不知道
    3. 响应要快:用户等太久体验差
    4. 后期好维护:知识库会更新,不能每次都改代码

    综合考虑,选了RAG架构。

    RAG项目技术架构图,Milvus向量库Docker部署方案示意

    二、技术方案设计

    2.1 整体架构

    plaintext

    ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
    │   用户问题   │────▶│   API服务    │────▶│   大模型    │
    └─────────────┘     └──────┬──────┘     └─────────────┘
                               │
                         ┌─────▼─────┐     ┌─────────────┐
                         │  检索模块  │◀───▶│  向量数据库  │
                         └───────────┘     └─────────────┘
                               │
                         ┌─────▼─────┐
                         │  文档处理  │◀───▶│  文件存储   │
                         └───────────┘
    

    核心流程:

    1. 用户提问 → 转成向量
    2. 向量检索 → 找到最相关的知识块
    3. 拼成prompt → 调用大模型
    4. 返回答案 → 附带参考来源

    2.2 技术栈选择

    组件技术选型选型理由
    后端框架FastAPI高性能、支持异步、文档自动生成
    向量数据库Milvus支持分布式、亿级向量毫秒级检索
    Embeddingtext2vec-base-chinese中文效果好,开源免费
    大模型通义千问中文能力强,国内合规
    文档处理LangChain生态完善,社区活跃
    前端Vue3 + Element Plus内部使用,简单够用

    2.3 性能指标目标

    • 问答响应时间:P95 < 3秒
    • 检索准确率:> 85%(人工评测)
    • 支持并发:100 QPS
    • 知识库规模:10万条文档

    三、环境准备与项目结构

    3.1 目录结构

    bash

    rag-knowledge-base/
    ├── app/
    │   ├── __init__.py
    │   ├── main.py              # FastAPI入口
    │   ├── api/
    │   │   ├── __init__.py
    │   │   ├── chat.py          # 问答接口
    │   │   └── knowledge.py     # 知识库管理接口
    │   ├── core/
    │   │   ├── __init__.py
    │   │   ├── config.py        # 配置管理
    │   │   └── security.py      # 安全相关
    │   ├── services/
    │   │   ├── __init__.py
    │   │   ├── embedding.py     # Embedding服务
    │   │   ├── retrieval.py     # 检索服务
    │   │   └── llm.py            # 大模型服务
    │   └── models/
    │       ├── __init__.py
    │       └── schemas.py       # Pydantic模型
    ├── scripts/
    │   ├── ingest.py            # 文档入库脚本
    │   └── test_query.py        # 测试脚本
    ├── knowledge_base/          # 知识库源文件
    │   ├── faq/
    │   ├── product_docs/
    │   └── policy/
    ├── vector_db/               # 向量数据库数据
    ├── Dockerfile
    ├── docker-compose.yml
    └── requirements.txt
    

    3.2 依赖安装

    bash

    # requirements.txt
    fastapi==0.109.0
    uvicorn[standard]==0.27.0
    pydantic==2.5.3
    langchain==0.1.4
    langchain-community==0.0.16
    pymilvus==2.3.4
    sentence-transformers==2.3.1
    tiktoken==0.5.2
    python-multipart==0.0.6
    python-dotenv==1.0.0
    

    bash

    pip install -r requirements.txt
    

    四、核心代码实现

    4.1 配置管理

    python

    # app/core/config.py
    from pydantic_settings import BaseSettings
    from functools import lru_cache
    
    class Settings(BaseSettings):
        """应用配置"""
        
        # 服务配置
        APP_NAME: str = "RAG知识库问答系统"
        DEBUG: bool = False
        
        # Milvus配置
        MILVUS_HOST: str = "localhost"
        MILVUS_PORT: int = 19530
        COLLECTION_NAME: str = "knowledge_base"
        VECTOR_DIM: int = 768  # text2vec-base-chinese输出维度
        
        # Embedding配置
        EMBEDDING_MODEL: str = "shibing624/text2vec-base-chinese"
        BATCH_SIZE: int = 32
        
        # LLM配置
        LLM_API_KEY: str = ""
        LLM_BASE_URL: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
        LLM_MODEL: str = "qwen-turbo"
        LLM_TEMPERATURE: float = 0.3
        MAX_TOKENS: int = 1000
        
        # 检索配置
        TOP_K: int = 3
        SCORE_THRESHOLD: float = 0.5
        
        class Config:
            env_file = ".env"
            case_sensitive = True
    
    @lru_cache()
    def get_settings():
        return Settings()
    

    4.2 Embedding服务

    python

    # app/services/embedding.py
    from sentence_transformers import SentenceTransformer
    from app.core.config import get_settings
    from typing import List
    import numpy as np
    
    class EmbeddingService:
        def __init__(self):
            settings = get_settings()
            self.model = SentenceTransformer(settings.EMBEDDING_MODEL)
            self.batch_size = settings.BATCH_SIZE
        
        def encode(self, texts: List[str]) -> List[List[float]]:
            """批量获取文本向量"""
            embeddings = self.model.encode(
                texts,
                batch_size=self.batch_size,
                show_progress_bar=False
            )
            return embeddings.tolist()
        
        def encode_single(self, text: str) -> List[float]:
            """获取单条文本向量"""
            embedding = self.model.encode([text])[0]
            return embedding.tolist()
    
    # 全局单例
    embedding_service = EmbeddingService()
    

    4.3 向量检索服务

    python

    # app/services/retrieval.py
    from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
    from app.core.config import get_settings
    from app.services.embedding import embedding_service
    from typing import List, Dict
    
    class RetrievalService:
        def __init__(self):
            self.settings = get_settings()
            self.collection = None
            self._connect()
        
        def _connect(self):
            """连接Milvus"""
            connections.connect(
                host=self.settings.MILVUS_HOST,
                port=self.settings.MILVUS_PORT,
                alias="default"
            )
            self._init_collection()
        
        def _init_collection(self):
            """初始化集合"""
            collection_name = self.settings.COLLECTION_NAME
            
            if utility.has_collection(collection_name):
                self.collection = Collection(collection_name)
                self.collection.load()
            else:
                # 创建新集合
                fields = [
                    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
                    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=4096),
                    FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=256),
                    FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=self.settings.VECTOR_DIM)
                ]
                schema = CollectionSchema(fields=fields, description="知识库向量集合")
                self.collection = Collection(name=collection_name, schema=schema)
                
                # 创建索引
                index_params = {
                    "index_type": "IVF_FLAT",
                    "metric_type": "L2",
                    "params": {"nlist": 128}
                }
                self.collection.create_index(field_name="vector", index_params=index_params)
                self.collection.load()
        
        def search(self, query: str, top_k: int = None) -> List[Dict]:
            """检索最相关的文档"""
            if top_k is None:
                top_k = self.settings.TOP_K
            
            # 1. 将问题向量化
            query_vector = embedding_service.encode_single(query)
            
            # 2. 执行搜索
            search_params = {"metric_type": "L2", "params": {"nprobe": 10}}
            
            results = self.collection.search(
                data=[query_vector],
                anns_field="vector",
                param=search_params,
                limit=top_k,
                output_fields=["text", "source"]
            )
            
            # 3. 整理结果
            retrieved_docs = []
            for hits in results:
                for hit in hits:
                    # 过滤低分结果
                    if hit.distance < self.settings.SCORE_THRESHOLD:
                        continue
                    retrieved_docs.append({
                        "text": hit.entity.get("text"),
                        "source": hit.entity.get("source"),
                        "score": float(hit.distance)
                    })
            
            return retrieved_docs
    
    # 全局单例
    retrieval_service = RetrievalService()
    

    4.4 大模型服务

    python

    # app/services/llm.py
    import openai
    from app.core.config import get_settings
    from typing import List, Dict
    
    class LLMService:
        def __init__(self):
            settings = get_settings()
            self.client = openai.OpenAI(
                api_key=settings.LLM_API_KEY,
                base_url=settings.LLM_BASE_URL
            )
            self.model = settings.LLM_MODEL
            self.temperature = settings.LLM_TEMPERATURE
            self.max_tokens = settings.MAX_TOKENS
        
        def generate(self, query: str, context: List[Dict]) -> Dict:
            """生成回答"""
            # 构建prompt
            system_prompt = """你是一个智能客服助手,擅长回答用户问题。
    请基于以下参考信息回答用户问题。
    要求:
    1. 只根据参考信息回答,不要编造
    2. 如果参考信息不足以回答,明确告知用户
    3. 回答要简洁清晰
    4. 如果涉及政策说明,要标注信息来源"""
    
            # 组装参考信息
            context_text = "\n\n".join([
                f"[来源{i+1}] {doc['text']}\n(文件:{doc['source']})"
                for i, doc in enumerate(context)
            ])
            
            user_prompt = f"""## 参考信息
    {context_text}
    
    ## 用户问题
    {query}
    """
            
            # 调用API
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_prompt}
                ],
                temperature=self.temperature,
                max_tokens=self.max_tokens
            )
            
            answer = response.choices[0].message.content
            
            return {
                "answer": answer,
                "sources": [doc['source'] for doc in context]
            }
    
    # 全局单例
    llm_service = LLMService()
    

    4.5 API接口

    python

    # app/api/chat.py
    from fastapi import APIRouter, HTTPException
    from app.models.schemas import ChatRequest, ChatResponse
    from app.services.retrieval import retrieval_service
    from app.services.llm import llm_service
    
    router = APIRouter(prefix="/api/v1", tags=["问答"])
    
    @router.post("/chat", response_model=ChatResponse)
    async def chat(request: ChatRequest):
        """问答接口"""
        try:
            # 1. 检索相关文档
            retrieved_docs = retrieval_service.search(
                query=request.question,
                top_k=request.top_k
            )
            
            if not retrieved_docs:
                return ChatResponse(
                    answer="抱歉,暂时没有找到相关信息,建议您联系人工客服获取帮助。",
                    sources=[]
                )
            
            # 2. 生成回答
            result = llm_service.generate(
                query=request.question,
                context=retrieved_docs
            )
            
            return ChatResponse(
                answer=result["answer"],
                sources=result["sources"]
            )
        
        except Exception as e:
            raise HTTPException(status_code=500, detail=f"服务异常:{str(e)}")
    

    python

    # app/models/schemas.py
    from pydantic import BaseModel, Field
    from typing import List, Optional
    
    class ChatRequest(BaseModel):
        question: str = Field(..., min_length=1, max_length=500, description="用户问题")
        top_k: Optional[int] = Field(3, ge=1, le=10, description="返回结果数量")
    
    class ChatResponse(BaseModel):
        answer: str = Field(..., description="回答内容")
        sources: List[str] = Field(default_factory=list, description="参考来源")
    

    4.6 主入口

    python

    # app/main.py
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    from app.api import chat, knowledge
    from app.core.config import get_settings
    
    settings = get_settings()
    
    app = FastAPI(
        title=settings.APP_NAME,
        version="1.0.0",
        description="基于RAG的企业知识库问答系统"
    )
    
    # CORS配置
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    
    # 注册路由
    app.include_router(chat.router)
    app.include_router(knowledge.router)
    
    @app.get("/health")
    async def health_check():
        return {"status": "ok"}
    
    if __name__ == "__main__":
        import uvicorn
        uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
    

    五、文档入库脚本

    python

    # scripts/ingest.py
    import os
    import json
    from pathlib import Path
    from langchain_community.document_loaders import (
        TextLoader, 
        UnstructuredMarkdownLoader,
        PyPDFLoader
    )
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    from app.services.embedding import embedding_service
    from app.services.retrieval import retrieval_service
    from app.core.config import get_settings
    
    def load_documents(directory: str):
        """加载目录下的所有文档"""
        docs = []
        path = Path(directory)
        
        for file_path in path.rglob("*"):
            if file_path.is_file() and file_path.suffix in ['.txt', '.md', '.pdf']:
                try:
                    if file_path.suffix == '.pdf':
                        loader = PyPDFLoader(str(file_path))
                    elif file_path.suffix == '.md':
                        loader = UnstructuredMarkdownLoader(str(file_path))
                    else:
                        loader = TextLoader(str(file_path), encoding='utf-8')
                    
                    docs.extend(loader.load())
                    print(f"加载成功: {file_path.name}")
                except Exception as e:
                    print(f"加载失败: {file_path.name}, 错误: {e}")
        
        return docs
    
    def split_documents(docs):
        """切分文档"""
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=300,
            chunk_overlap=50,
            length_function=len
        )
        return text_splitter.split_documents(docs)
    
    def ingest_documents():
        """文档入库"""
        print("开始文档入库...")
        
        # 1. 加载文档
        print("\n步骤1: 加载文档")
        docs = load_documents("knowledge_base")
        print(f"共加载 {len(docs)} 个文档")
        
        # 2. 切分文档
        print("\n步骤2: 切分文档")
        chunks = split_documents(docs)
        print(f"切分后共 {len(chunks)} 个文本块")
        
        # 3. 向量化并入库
        print("\n步骤3: 向量化并入库")
        for i, chunk in enumerate(chunks):
            if i % 100 == 0:
                print(f"处理进度: {i}/{len(chunks)}")
            
            # 获取向量
            vector = embedding_service.encode_single(chunk.page_content)
            
            # 存入Milvus(此处省略具体代码,假设retrieval_service有add方法)
            retrieval_service.add(
                text=chunk.page_content,
                source=chunk.metadata.get('source', 'unknown'),
                vector=vector
            )
        
        print("\n文档入库完成!")
    
    if __name__ == "__main__":
        ingest_documents()
    

    六、Docker部署

    6.1 docker-compose.yml

    yaml

    version: '3.8'
    
    services:
      # FastAPI服务
      api:
        build: .
        ports:
          - "8000:8000"
        environment:
          - MILVUS_HOST=milvus
          - MILVUS_PORT=19530
          - LLM_API_KEY=${LLM_API_KEY}
        depends_on:
          - milvus
        restart: unless-stopped
    
      # Milvus向量数据库
      milvus:
        image: milvusdb/milvus:v2.3.3
        ports:
          - "19530:19530"
          - "9091:9091"
        volumes:
          - ./volumes/milvus:/var/lib/milvus
        environment:
          - ETCD_ENDPOINTS=etcd:2379
          - MINIO_ADDRESS=minio:9000
        depends_on:
          - etcd
          - minio
        restart: unless-stopped
    
      etcd:
        image: quay.io/coreos/etcd:v3.5.5
        environment:
          - ETCD_AUTO_COMPACTION_MODE=revision
          - ETCD_AUTO_COMPACTION_RETENTION=1000
          - ETCD_QUOTA_BACKEND_BYTES=4294967296
        volumes:
          - ./volumes/etcd:/etcd
        command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    
      minio:
        image: minio/minio:RELEASE.2023-03-20T20-16-18Z
        environment:
          - MINIO_ACCESS_KEY=minioadmin
          - MINIO_SECRET_KEY=minioadmin
        volumes:
          - ./volumes/minio:/minio_data
        command: minio server /minio_data
        restart: unless-stopped
    

    6.2 Dockerfile

    dockerfile

    FROM python:3.10-slim
    
    WORKDIR /app
    
    # 安装系统依赖
    RUN apt-get update && apt-get install -y \
        build-essential \
        && rm -rf /var/lib/apt/lists/*
    
    # 复制依赖文件
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    
    # 复制代码
    COPY . .
    
    # 下载Embedding模型
    RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('shibing624/text2vec-base-chinese')"
    
    EXPOSE 8000
    
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
    

    6.3 启动服务

    bash

    # 复制环境变量模板
    cp .env.example .env
    # 编辑.env,填入API Key
    
    # 启动所有服务
    docker-compose up -d
    
    # 查看服务状态
    docker-compose ps
    
    # 查看日志
    docker-compose logs -f api
    

    服务启动后,访问 http://localhost:8000/docs 查看API文档。

    七、上线后的调优

    系统上线后,监控发现几个问题,做了相应调优:

    7.1 响应时间优化

    问题:P95响应时间超过5秒,不满足要求。

    原因分析

    1. Milvus检索不稳定,部分查询耗时>2秒
    2. Embedding模型加载慢,每次请求都重新加载
    3. 网络IO阻塞

    解决方案

    python

    # 1. 模型预加载
    embedding_service = EmbeddingService()  # 服务启动时加载
    
    # 2. Milvus连接池
    connections.connect(host="milvus", port="19530", pool_size=10)
    
    # 3. 异步处理
    @router.post("/chat", response_model=ChatResponse)
    async def chat(request: ChatRequest):
        # 并行执行检索和Embedding
        retrieved_docs = await asyncio.to_thread(
            retrieval_service.search, 
            query=request.question
        )
        ...
    

    7.2 准确率提升

    问题:部分问题检索不到相关内容。

    解决方案

    1. 增加Query改写层
    2. 混合检索(向量+关键词)
    3. 调整chunk_size,找到最优值(我们测试后确定为300)

    八、总结与经验

    这个项目做完,有几点体会:

    1. RAG不是万能的:它解决的是”知识库+大模型”的问题,如果知识库本身质量差,RAG效果也好不了
    2. 数据准备比技术实现更重要:我们花在清洗文档、规范FAQ上的时间,占了整个项目的40%
    3. 监控很重要:上线前一定要埋点,记录检索结果、大模型输出、用户反馈,才能持续优化
    4. 渐进式迭代:第一版不用追求完美,先跑通流程,再根据实际反馈优化

    以上就是完整的RAG项目实战经验,希望对你有帮助。

    相关推荐:

  • 2026程序员AI编程笔记精华:踩坑总结与实战经验

    2026程序员AI编程笔记精华:踩坑总结与实战经验

    正文

    从去年开始用AI编程工具,到现在快一年了。踩过不少坑,也积累了一些心得。之前在本地记了些笔记,现在整理出来,分享给有需要的同学。

    内容会比较杂,是我这一年的实操记录,不成体系,但都是真实经验。废话不多说,直接上干货。

    AI编程工具对比表格,Cursor Copilot JetBrains功能差异分析

    一、AI编程工具:我用了一圈下来,结论是这样的

    主流工具我用过的有:

    • Cursor(主力工具,用了大半年)
    • GitHub Copilot(用了小半年)
    • JetBrains AI Assistant(偶尔用)
    • Trae(最近在试)

    用下来的感受:

    Cursor对我来说是效率提升最明显的。它的Composer功能太强了,新项目直接用它生成基础代码,我再在上面改,比自己从空文件写快多了。尤其是写CRUD代码,Cursor生成得又快又规范,省去很多重复劳动。

    Copilot的强项是代码补全。它对Python、JavaScript的支持非常成熟,补全准确率高,适合边写边用。但让它帮我理解陌生代码或者重构,就没Cursor好使了。

    JetBrains家的AI Assistant我主要在PyCharm里用。对Java项目支持更好,Python方面稍弱。如果你主要写Java/Kotlin,可以考虑。

    Trae是字节出的,优点是对中文支持好,而且完全免费。最近在用它处理一些国内项目,配合Cursor用挺顺手。

    我的建议: 如果只能选一个,选Cursor。它功能最全面,Composer和Chat配合使用,基本能覆盖大部分开发场景。

    二、RAG项目开发:这几个坑踩得我印象深刻

    今年做了一个企业知识库问答系统,用的是RAG架构。开发过程中踩了几个坑,记录一下。

    坑一:文档切分策略直接影响检索质量

    一开始我用的固定长度切分,每块512字符。结果发现,切到一半的技术文档,检索出来的内容经常是断的,用户体验很差。

    后来改成按语义切分,用langchain的RecursiveCharacterTextSplitter,同时把chunk_overlap调大到50左右。这样相邻块有重叠,检索出来的内容上下文更完整。

    python

    from langchain.text_splitter import RecursiveCharacterTextSplitter
    
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=300,      # 之前用512,改小了
        chunk_overlap=50,     # 加了重叠,保证上下文连贯
        length_function=len,
        separators=["\n\n", "\n", "。", "!", "?", ""]  # 按语义断句
    )
    

    坑二:向量检索和关键词检索要混合使用

    单纯用向量检索,有个问题:用户问”密码忘了”,检索到的可能是”忘记密码”,这没问题。但用户问”pwd reset”,向量检索可能匹配不到中文”重置密码”的内容。

    后来加了BM25关键词检索,两种检索结果按权重合并。向量检索负责语义理解,关键词检索负责精确匹配,互相补充。

    python

    from langchain_community.retrievers import EnsembleRetriever
    
    # 混合检索,权重可调
    ensemble_retriever = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[0.3, 0.7]  # 关键词30%,向量70%
    )
    

    坑三:生产环境别用Chroma

    本地开发用Chroma很方便,零配置就能跑。但上线后发现两个问题:

    1. 内存占用高,几万条数据就卡
    2. 不支持分布式,多实例部署会数据不一致

    后来换了Milvus,用Docker部署,稳定多了。数据量大的话,建议直接上Milvus或者Pinecone。

    三、大模型API调用:这些细节不注意,分分钟超预算

    调用大模型API是按token计费的,以下是我踩过的预算坑。

    教训一:prompt要精简,能不说的废话就不说

    我最早写prompt特别啰嗦,喜欢加很多背景描述。输出质量确实好了,但token消耗也上去了。一算账单,发现60%的token都浪费在那些”尊敬的AI助手”之类的客套话上了。

    后来学乖了,prompt能简则简,只保留核心信息。输出质量没降多少,token消耗降了40%。

    python

    # 优化前(浪费token)
    """
    请你作为一个专业的Python后端开发工程师,
    帮我审查以下代码。
    这段代码是一个用户认证模块,
    主要功能是验证用户登录信息...
    """
    
    # 优化后(精简版)
    """
    审查以下Python代码,找出潜在问题:
    [代码内容]
    """
    
    # 注意,我只说"找出潜在问题",没说什么类型的。
    # 模型会自动识别常见问题类型,反而更准。
    

    教训二:temperature不是越高越好

    temperature控制输出的随机性。很多人以为temperature=0.8输出更有创意,其实不是。

    我的经验:

    • 问答题、代码审查等需要准确答案的:temperature=0.1~0.3
    • 头脑风暴、创意文案:temperature=0.5~0.7
    • 精确匹配(比如JSON输出):temperature=0

    教训三:结果缓存要善用

    如果你的业务里,有大量重复或相似的query(比如FAQ),一定要加缓存。

    我的做法:

    python

    from functools import lru_cache
    
    @lru_cache(maxsize=1000)
    def get_cached_response(question: str, user_id: str = None):
        """带缓存的问答函数"""
        # 先查缓存
        cache_key = f"{user_id}:{question}"
        
        # 命中缓存直接返回
        if cache := redis.get(cache_key):
            return json.loads(cache)
        
        # 没命中,调API
        response = call_llm_api(question)
        
        # 写入缓存,过期时间1小时
        redis.setex(cache_key, 3600, json.dumps(response))
        
        return response
    

    实测加缓存后,API调用量降了70%,一个月省了大几百块的API费用。

    四、提示词工程:我总结了三个万能模板

    提示词写得好不好,直接决定AI输出质量。我总结了三个常用模板,基本能覆盖日常开发场景。

    模板一:代码审查模板

    plaintext

    角色:你是一个资深后端开发工程师,擅长代码审查
    任务:审查以下{语言}代码
    要求:
    1. 找出安全漏洞
    2. 指出性能问题
    3. 评估代码可维护性
    4. 提出改进建议
    
    代码:
    {代码内容}
    
    输出格式:
    ## 安全问题
    {列出发现的问题及修复建议}
    
    ## 性能问题
    {列出发现的问题及修复建议}
    
    ## 可维护性
    {评估结果}
    
    ## 综合评分
    {1-10分}
    

    模板二:代码生成模板

    plaintext

    角色:{语言}后端开发专家
    任务:编写{功能描述}
    技术栈:{技术栈版本}
    要求:
    1. 符合PEP8/{语言}代码规范
    2. 添加中文注释
    3. 处理异常情况
    4. 返回完整可运行的代码
    
    功能需求:{具体需求描述}
    
    额外约束:{如果有特殊要求,如性能要求、安全要求等}
    

    模板三:问题排查模板

    plaintext

    现象:{描述遇到的问题}
    环境:
    - 系统:{操作系统}
    - {语言}版本:{版本号}
    - 依赖库:{主要依赖版本}
    
    相关代码:
    {粘贴关键代码片段}
    
    已尝试的解决方法:
    {列出你试过的方法及结果}
    
    请分析可能的原因,并给出解决步骤。
    

    这三个模板我用了大半年,AI输出质量稳定,比每次都临时组织语言效果好很多。

    五、AI编程的正确姿势:不是替代,是协作

    很多人担心AI会取代程序员。我觉得这个担心有点早。

    AI确实能写代码,但它不懂业务。你告诉它”实现一个订单模块”,它能写出CRUD代码,但它不知道怎么设计订单状态机、怎么处理分布式事务、要不要考虑幂等性。这些业务逻辑和架构设计,必须人来定。

    我用AI编程的思路是:AI负责执行,人负责决策。

    具体来说:

    • 需求分析、架构设计:人来
    • 简单代码、补全、格式化:AI来
    • 代码审查:AI初筛,人工复核
    • bug定位:AI分析,人确认
    • 性能优化:人分析热点,AI生成优化代码

    这样分工下来,效率确实高了不少。以前一天写200行代码,现在能写500行,而且bug率还低了。

    六、实用工具清单:我的开发环境配置

    最后分享下我现在的开发环境:

    编辑器:

    • Cursor(主力)+ VS Code(备选)
    • 插件:Prettier、ESLint、GitLens、Error Lens

    AI工具:

    • Cursor Composer:多文件编辑
    • Cursor Chat:代码问答
    • Notion AI:文档处理

    API调用:

    • OpenAI API(主力):gpt-3.5-turbo-1106,性价比最高
    • Claude API(备选):代码和长文本处理更强
    • 智谱GLM(国内项目):中文场景表现好

    调试工具:

    • Postman:API测试
    • Redis:缓存和向量存储
    • LangSmith:LLM应用调试(贵,但好用)

    写在最后

    AI编程还在快速发展,每个月都有新工具冒出来。我的策略是保持关注,但不追新。稳定好用的工具先研究透,等新工具经过市场验证了再考虑切换。

    以上就是我这一年AI编程的实战笔记,比较碎,但都是真金白银踩出来的坑。如果对你有帮助,欢迎收藏。

    有问题欢迎评论区交流,一起进步。

    相关推荐:

  • Cursor AI编程工具使用指南:2026年开发者效率翻倍的秘密武器

    Cursor AI编程工具使用指南:2026年开发者效率翻倍的秘密武器

    正文

    一、Cursor是什么,为什么2026年几乎所有开发者都在用

    上周五下午,我旁边的同事突然问我:”你用过Cursor没?”

    我说用过,顺嘴问了句怎么了。他说他们组六个人,现在五个都在用Cursor。”还有一个是macOS系统,Cursor对M系列芯片支持还不太完善,暂时还在用VS Code。”

    这个场景挺能说明问题的。Cursor作为AI编程工具,已经从”尝鲜玩具”变成了”主力工具”。根据2026年开发者调研数据,Cursor的日活用户突破800万,其中超过60%的用户把它设为默认开发环境。

    Cursor界面功能示意图,AI代码补全Composer多文件编辑

    Cursor火起来的原因很简单:它真的能让你少写很多代码。 不是我瞎吹,是有数据支撑的。用户反馈普遍提到,用Cursor后每天能节省1-2小时的重复编码时间,代码补全准确率比传统IDE高出40%以上。

    但Cursor也不是万能的。它有自己的适用场景和局限性。今天这篇文章,咱们就把Cursor掰开了揉碎了讲,讲清楚它能做什么、适合什么场景、以及怎么用才能效率最大化。

    二、安装与初始配置

    2.1 下载安装

    Cursor支持Windows、macOS和Linux系统。先去官网下载:https://cursor.sh/

    macOS用户直接下载.dmg文件安装。Windows用户下载.exe安装包,一路下一步就行。

    第一次打开Cursor,会提示你导入VS Code的设置。如果你之前用VS Code,所有的插件、快捷键配置、主题都会自动同步过来。这点挺贴心的,不用重新配置一遍。

    2.2 关联AI账号

    Cursor需要关联AI服务才能使用核心功能。免费版本每月有100次高级模型使用额度,对于日常学习和小项目够用了。

    打开设置(快捷键 Cmd/Ctrl + ,),找到Account选项:

    • 如果用Google账号,直接点击”Continue with Google”
    • 如果用GitHub账号,点击”Continue with GitHub”
    • 如果用邮箱注册,点击”Sign up with email”

    关联完成后,Cursor会自动配置好AI服务。免费版默认用Cursor自家模型,付费版可以切换到Claude或GPT-4。

    2.3 界面初识

    Cursor的界面和VS Code几乎一模一样,左侧是文件资源管理器,底部是终端,中间是代码编辑器。如果你是VS Code老用户,上手Cursor几乎零成本。

    和VS Code不同的是,Cursor在编辑器里多了几个AI相关的面板:

    • ComposerCmd/Ctrl + L):打开Composer面板,可以进行多文件编辑
    • Cursor ChatCmd/Ctrl + K):编辑器内的AI对话,可以引用当前代码
    • Cursor Tab:智能代码补全,比传统Tab更强

    三、核心功能详解

    3.1 Cursor Tab:新一代代码补全

    Cursor Tab是Cursor最基础也是最常用的功能。开启方式:设置里搜索”Cursor Tab”,勾选启用。

    它和VS Code的IntelliSense有什么不同?最大的区别是能补全整个函数,不只是一行

    看个实际例子。假设我正在写一个Python函数,用来计算斐波那契数列:

    python

    def fibonacci
    

    输入到这里,按下Tab键,Cursor会自动补全整个函数:

    python

    def fibonacci(n):
        """
        计算斐波那契数列第n项
        
        Args:
            n: 第n项位置
        
        Returns:
            斐波那契数列第n项的值
        """
        if n <= 0:
            return 0
        elif n == 1:
            return 1
        
        a, b = 0, 1
        for _ in range(n - 1):
            a, b = b, a + b
        return b
    

    不只是补全,还自动加了文档注释。这就是Cursor Tab的聪明之处——它理解代码意图,能补全完整的功能单元。

    常用快捷键:

    • Tab:接受补全
    • Esc:拒绝补全
    • Ctrl + →:只接受单词补全
    • Ctrl + ←:只接受到上一个空格

    3.2 Cursor Chat:代码级AI对话

    Cursor Chat让你在编辑器里直接和AI对话,而且AI能看到你当前的代码文件,这是它比普通ChatGPT强的地方。

    快捷键:Cmd/Ctrl + L

    打开Chat面板后,你可以:

    场景一:解释代码

    选中一段看不懂的代码,问:”这段代码在做什么?”

    比如我选中这个排序函数:

    python

    def quicksort(arr):
        if len(arr) <= 1:
            return arr
        pivot = arr[len(arr) // 2]
        left = [x for x in arr if x < pivot]
        middle = [x for x in arr if x == pivot]
        right = [x for x in arr if x > pivot]
        return quicksort(left) + middle + quicksort(right)
    

    AI回答:

    这是一个快速排序的实现。核心思想是分治法:

    1. 选择中间元素作为基准值(pivot)
    2. 将数组分为三部分:小于基准、等于基准、大于基准
    3. 递归排序左右两部分
    4. 时间复杂度平均O(n log n),最坏O(n²)

    场景二:优化代码

    选中代码后问:”这个函数怎么优化?”

    AI会指出性能问题,并给出优化建议。比如它发现上面这个快排用列表推导式创建新数组,内存开销大,会建议用原地交换的写法。

    场景三:生成测试用例

    问:”帮我写几个测试用例”

    AI会自动生成pytest格式的测试代码,覆盖正常情况、边界情况和异常情况。

    3.3 Composer:多文件AI编辑

    Composer是Cursor最强大的功能,可以同时编辑多个文件、处理复杂任务。快捷键:Cmd/Ctrl + I

    场景一:从零搭建项目

    我想创建一个Todo应用,告诉Cursor:

    plaintext

    用Python和Streamlit创建一个待办事项应用。
    功能:
    1. 添加待办事项
    2. 标记完成
    3. 删除待办事项
    4. 本地持久化存储(JSON文件)
    

    Cursor会自动创建项目结构:

    plaintext

    todo-app/
    ├── app.py          # 主应用
    ├── storage.py      # 数据存储模块
    ├── models.py       # 数据模型
    └── requirements.txt
    

    点击”Accept”接受全部变更,项目就搭建好了。

    场景二:重构代码

    选中一个文件,告诉Cursor:”把这个类改造成单例模式”

    Cursor会分析代码结构,给出重构方案。你可以逐个文件查看变更,也可以一键接受。

    场景三:添加新功能

    在已有项目里,告诉Cursor:”在用户管理模块里加一个导出CSV的功能”

    Cursor会理解现有代码结构,在正确的位置插入新功能,代码风格也会保持一致。

    3.4 Bug修复:Code Review的好帮手

    Cursor的Bug修复功能很实用。快捷键:Cmd/Ctrl + Shift + B

    你可以:

    自动修复:选中报错代码,Cursor分析错误原因,自动生成修复方案。

    python

    # 原始代码(有bug)
    def calculate_average(numbers):
        total = sum(numbers)
        average = total / len(numbers)
        return average
    
    # 当numbers为空列表时会抛出ZeroDivisionError
    

    选中后按Cmd/Ctrl + Shift + B,Cursor会提示修复方案:

    python

    def calculate_average(numbers):
        if not numbers:
            return 0  # 处理空列表情况
        total = sum(numbers)
        average = total / len(numbers)
        return average
    

    四、实战技巧:Cursor+项目的最佳实践

    光知道功能不够,关键是怎么用才能效率最大化。下面分享几个我日常使用中总结的技巧。

    4.1 新项目快速启动

    新项目先用Cursor的Composer生成基础框架,然后自己在上面改。比自己从空文件开始写快多了。

    我的常用Prompt模板:

    plaintext

    创建一个{项目类型},技术栈是{技术栈}。
    要求:
    1. {功能1}
    2. {功能2}
    3. {技术要求,如API接口规范、数据库设计等}
    

    比如:

    plaintext

    创建一个用户认证系统,技术栈是Python + FastAPI + SQLAlchemy。
    要求:
    1. 用户注册(邮箱、密码)
    2. 用户登录(返回JWT token)
    3. 密码加密存储(bcrypt)
    4. 使用SQLite数据库
    

    4.2 复杂逻辑分步实现

    不要让Cursor一次性写完整个复杂模块,容易出错。分步骤来:

    1. 先写数据模型
    2. 再写业务逻辑
    3. 最后写接口层

    每一步完成后,自己跑一遍测试,确保没问题再继续下一步。这样能及时发现错误,不至于最后debug找不到问题在哪。

    4.3 代码审查流程

    我现在的代码审查流程:

    1. 写完一段代码后,用Cursor Chat解释一遍,确保自己理解正确
    2. 用Bug修复功能检查有没有明显问题
    3. 让人工review前两步的结果

    这个流程比纯人工review效率高很多。Cursor能发现80%的常见问题,人工只需要关注业务逻辑层面。

    4.4 善用快捷键

    Cursor的快捷键和VS Code基本一致,但有几个新增的:

    功能Windows/LinuxmacOS
    打开Cursor ChatCtrl + LCmd + L
    打开ComposerCtrl + ICmd + I
    Bug修复Ctrl + Shift + BCmd + Shift + B
    接受AI补全TabTab
    拒绝AI补全EscEsc

    建议把这些快捷键练成本能反应,用起来才顺手。

    五、Cursor的局限性

    说了这么多Cursor的好处,也得聊聊它的局限。理性看待,才能用好工具。

    局限一:对中文指令的理解还不够精准

    Cursor对英文指令的理解比中文好很多。写Prompt的时候,用英文描述需求,生成结果更准确。实测同样的需求,英文Prompt比中文Prompt的代码质量平均高15%。

    局限二:复杂项目的上下文管理有上限

    Cursor能理解当前打开的文件,但当项目文件很多、依赖关系复杂时,它可能”迷路”。这时候需要你明确告诉它当前在处理哪个模块、引用了哪些文件。

    局限三:前端UI代码生成质量不稳定

    Vue、React等框架的组件代码,Cursor生成的质量参差不齐。简单的组件还行,涉及到状态管理、生命周期的地方容易出bug。建议生成后仔细检查。

    局限四:需要人工审核,不能盲信

    Cursor生成的代码有概率包含逻辑错误、安全漏洞。重要项目上线前,代码审查是必须的,不能因为用了AI就跳过这一步。

    六、Cursor vs 其他工具:怎么选

    现在AI编程工具挺多的,主流的有Cursor、GitHub Copilot、JetBrains AI Assistant、Trae。简单对比一下:

    Cursor

    • 优点:界面友好,功能全面,免费版额度够用
    • 缺点:中文支持一般,对大项目支持有待提升
    • 适合人群:前端/全栈开发者、个人开发者、小团队

    GitHub Copilot

    • 优点:生态完善、IDE集成度高、代码补全精准
    • 缺点:需要订阅、功能相对单一
    • 适合人群:企业团队、深度VS Code用户

    JetBrains AI Assistant

    • 优点:深度集成IDEA/PyCharm、企业级支持好
    • 缺点:只能在JetBrains全家桶里用
    • 适合人群:Java/Kotlin开发者、企业用户

    Trae(字节跳动)

    • 优点:中文支持好、完全免费、国产优化
    • 缺点:相对年轻,功能还在完善
    • 适合人群:国内开发者、中文项目

    我的建议是:都试试,每个工具用一周,选最适合自己工作流的。如果你在国内做项目,Trae和Cursor可以组合使用,扬长避短。

    七、写在最后

    工具永远只是工具,真正决定效率的还是用工具的人。Cursor能帮你写代码,但不能替你思考需求;能帮你找bug,但不能替你做架构决策。

    学会用Cursor不难,用好Cursor才是关键。要做到这一点,关键是搞清楚自己的需求边界:什么场景用AI辅助、什么场景必须自己写、什么时候该质疑AI的输出。

    把这些想清楚了,Cursor才能真正成为你的效率倍增器,而不是一个高级复制粘贴工具。

    相关推荐:

  • RAG检索增强生成实战教程:让大模型拥有企业知识库

    RAG检索增强生成实战教程:让大模型拥有企业知识库

    正文

    一、为什么RAG成为2026年最火AI技能

    去年这个时候,如果你问一家企业怎么用大模型,答案多半是”直接调API”。但今年,画风变了。

    我接触的十几家企业里,有八成以上都在搞私有知识库。他们发现一个问题:直接用通用大模型回答客户咨询,准确率只有60%出头,但加上RAG技术后,准确率能拉到90%以上。这背后的逻辑很简单——大模型再强,也不可能知道你公司内部的产品文档、客服话术、FAQ库。RAG就是解决这个问题的。

    RAG技术架构流程图,文档切分向量化检索生成全流程

    根据2026年最新招聘数据,掌握RAG技术的开发者薪资普遍比同资历传统开发岗高出30%到50%。有些猎头甚至直接说:”现在招AI应用开发,RAG是必问项。”

    所以,不管你是做后端开发、数据分析,还是纯AI方向,学RAG都是稳赚不赔的选择。

    二、RAG到底是怎么工作的

    在说具体实现之前,先聊清楚RAG的工作原理。很多教程一上来就贴代码,看完还是懵的。咱们把RAG拆成三个环节来讲。

    第一环节:知识库准备

    这个环节做的事情是把原始文档切分成小块,然后转成向量存入向量数据库。为什么切成小块?因为大模型每次能处理的上下文有限,把长文档切成若干小块,每次只检索最相关的几块喂给模型,效率和准确率都会更高。

    第二环节:用户问题检索

    用户提问后,系统先把这个问题转成向量,然后在向量数据库里找语义最接近的知识块。这里有个关键点:不是关键词匹配,是语义匹配。比如问”怎么重置密码”,系统能匹配到”找回账户访问权限”相关内容,靠的就是向量相似度。

    第三环节:生成回答

    把检索到的知识块和用户问题一起拼成提示词,扔给大模型生成回答。这样做的好处是回答有据可查,不是大模型凭空编的。

    整个流程大概就是这样。核心价值一句话概括:让大模型在回答问题前先”查资料”,然后基于资料组织答案。

    三、环境准备与依赖安装

    先说下我的测试环境:Python 3.10,16G内存。实际生产环境建议32G以上。

    先创建个项目目录,然后安装依赖:

    bash

    mkdir rag-project && cd rag-project
    python -m venv venv
    source venv/bin/activate  # Windows用 venv\Scripts\activate
    
    pip install langchain langchain-community langchain-chroma \
        chromadb openai tiktoken pypdf python-docx \
        streamlit python-dotenv
    

    如果网络慢,可以用国内镜像:

    bash

    pip install -i https://mirrors.aliyun.com/pypi/simple/ langchain langchain-community
    

    接下来需要准备OpenAI的API Key。如果用国内模型,可以替换成智谱GLM或者百度千帆的接口,这里先以OpenAI为例演示。

    创建.env文件:

    bash

    OPENAI_API_KEY=sk-your-api-key-here
    

    四、知识库文档处理全流程

    4.1 文档加载与清洗

    先准备一份测试文档。我创建了一个简单的Markdown文件:

    markdown

    # 产品FAQ
    
    ## 如何重置密码
    访问登录页面,点击"忘记密码",输入注册邮箱,系统会发送重置链接。
    
    ## 如何联系客服
    工作时间:周一至周五 9:00-18:00
    邮箱:support@example.com
    电话:400-123-4567
    
    ## 退款政策
    支持7天内无理由退款,超过7天需提供充分理由。
    虚拟商品一经购买不支持退款。
    

    保存为docs/faq.md

    现在写文档加载的代码:

    python

    from langchain_community.document_loaders import TextLoader
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    
    def load_and_split_documents(file_path: str):
        """加载并切分文档"""
        loader = TextLoader(file_path, encoding='utf-8')
        documents = loader.load()
        
        # 文本切分器,chunk_size是每段最大字符数
        # chunk_overlap是相邻两段的重复字符数,保证上下文连贯
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=200,
            chunk_overlap=50,
            length_function=len,
        )
        
        chunks = text_splitter.split_documents(documents)
        
        print(f"原始文档数:{len(documents)}")
        print(f"切分后块数:{len(chunks)}")
        
        return chunks
    
    if __name__ == "__main__":
        chunks = load_and_split_documents("docs/faq.md")
        for i, chunk in enumerate(chunks):
            print(f"\n--- 块 {i+1} ---")
            print(chunk.page_content[:100] + "...")
    

    运行后输出:

    plaintext

    原始文档数:1
    切分后块数:5
    
    --- 块 1 ---
    # 产品FAQ
    
    ## 如何重置密码
    访问登录页面,点击"忘记密码",输入注册邮箱...
    
    --- 块 2 ---
    ...忘记密码",输入注册邮箱,系统会发送重置链接。
    
    ## 如何联系客服...
    
    --- 块 3 ---
    ...邮箱:support@example.com
    电话:400-123-4567
    
    ## 退款政策
    ...
    

    可以看到,切分器保留了markdown的结构,同时让相邻块之间有50个字符的重叠,这样检索时不会丢失上下文。

    4.2 向量化与向量数据库存储

    接下来是核心步骤——把文本块转成向量,存入Chroma向量数据库。

    python

    from langchain_community.embeddings import OpenAIEmbeddings
    from langchain_chroma import Chroma
    import os
    from dotenv import load_dotenv
    
    load_dotenv()
    
    def create_vector_store(chunks, persist_directory="vector_db"):
        """创建向量数据库"""
        embeddings = OpenAIEmbeddings()
        
        # 如果数据库已存在,先删除
        if os.path.exists(persist_directory):
            import shutil
            shutil.rmtree(persist_directory)
        
        # 创建向量数据库
        vectorstore = Chroma.from_documents(
            documents=chunks,
            embedding=embeddings,
            persist_directory=persist_directory
        )
        
        print(f"向量数据库已创建,共存储 {vectorstore._collection.count()} 个向量")
        
        return vectorstore
    
    if __name__ == "__main__":
        chunks = load_and_split_documents("docs/faq.md")
        vectorstore = create_vector_store(chunks)
    

    Chroma是轻量级向量数据库,安装简单,适合本地开发测试。生产环境可以考虑Milvus、Pinecone或者阿里云向量检索服务。

    五、检索与问答实现

    5.1 相似度检索

    现在实现检索功能:

    python

    def retrieve_relevant_chunks(vectorstore, query, top_k=3):
        """检索最相关的文档块"""
        results = vectorstore.similarity_search_with_score(query, k=top_k)
        
        print(f"\n查询:{query}")
        print(f"检索到 {len(results)} 个相关块:\n")
        
        for i, (doc, score) in enumerate(results):
            print(f"【结果 {i+1}】相似度分数:{score:.4f}")
            print(f"内容:{doc.page_content[:150]}...")
            print()
        
        return results
    
    if __name__ == "__main__":
        chunks = load_and_split_documents("docs/faq.md")
        vectorstore = create_vector_store(chunks)
        
        # 测试几个不同类型的问题
        queries = [
            "密码忘了怎么办",
            "退款有什么要求",
            "上班时间客服电话多少"
        ]
        
        for query in queries:
            retrieve_relevant_chunks(vectorstore, query)
    

    运行结果:

    plaintext

    查询:密码忘了怎么办
    检索到 2 个相关块:
    【结果 1】相似度分数:0.3523
    内容:访问登录页面,点击"忘记密码",输入注册邮箱...
    
    【结果 2】相似度分数:0.4821
    内容:如何重置密码...
    

    注意看,检索到的是”如何重置密码”相关内容,而不是”密码忘了”这个关键词。这说明向量检索确实能理解语义。

    5.2 RAG问答链

    检索只是第一步,接下来要把检索结果喂给大模型生成回答:

    python

    from langchain_openai import ChatOpenAI
    from langchain.chains import RetrievalQA
    
    def create_rag_chain(vectorstore):
        """创建RAG问答链"""
        llm = ChatOpenAI(
            model_name="gpt-3.5-turbo",
            temperature=0.3  # 温度低一点,回答更准确
        )
        
        chain = RetrievalQA.from_chain_type(
            llm=llm,
            chain_type="stuff",  # 把检索结果塞到一个上下文里
            retriever=vectorstore.as_retriever(search_kwargs={"k": 2}),
            return_source_documents=True  # 返回引用的源文档
        )
        
        return chain
    
    def ask_question(chain, question):
        """向RAG系统提问"""
        print(f"\n{'='*50}")
        print(f"用户问题:{question}")
        print('='*50)
        
        result = chain.invoke({"query": question})
        
        print(f"\n【回答】\n{result['result']}")
        print(f"\n【参考来源】")
        for doc in result['source_documents']:
            print(f"- {doc.page_content}")
        
        return result
    
    if __name__ == "__main__":
        chunks = load_and_split_documents("docs/faq.md")
        vectorstore = create_vector_store(chunks)
        chain = create_rag_chain(vectorstore)
        
        ask_question(chain, "密码忘了怎么处理?")
    

    输出结果:

    plaintext

    【回答】
    根据知识库内容,如果忘记了密码,请按照以下步骤操作:
    
    1. 访问登录页面
    2. 点击"忘记密码"链接
    3. 输入您注册时使用的邮箱地址
    4. 系统会发送一封包含重置链接的邮件
    5. 点击邮件中的链接,按照提示设置新密码
    
    【参考来源】
    - 访问登录页面,点击"忘记密码",输入注册邮箱,系统会发送重置链接。
    - 如何重置密码
    

    可以看到,RAG系统不仅给出了准确的答案,还标注了信息来源。这是纯大模型做不到的。

    六、进阶优化技巧

    上面是一个最基础的RAG流程,实际项目里还有很多可以优化的地方。

    6.1 混合检索

    单纯用向量检索有时候会漏掉精确关键词匹配的情况。可以用混合检索——向量检索加关键词检索并行:

    python

    from langchain_community.retrievers import EnsembleRetriever
    
    def create_hybrid_retriever(vectorstore, texts):
        """创建混合检索器"""
        from langchain_community.retrievers import BM25Retriever
        
        # BM25是基于关键词的检索
        bm25_retriever = BM25Retriever.from_texts(texts)
        bm25_retriever.k = 2
        
        # 向量检索器
        vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
        
        # 混合检索,按权重合并结果
        ensemble_retriever = EnsembleRetriever(
            retrievers=[bm25_retriever, vector_retriever],
            weights=[0.3, 0.7]  # 关键词权重30%,向量权重70%
        )
        
        return ensemble_retriever
    

    6.2 Query改写优化

    用户的问题有时候表达不清,直接检索可能匹配不到正确内容。可以先让大模型改写问题:

    python

    def rewrite_query(query):
        """让LLM改写查询,使其更清晰"""
        llm = ChatOpenAI(temperature=0)
        
        prompt = f"""将以下用户问题改写为一个清晰、完整的检索查询。
        要求:使用正式的技术语言,包含可能的关键词。
        
        原问题:{query}
        
        改写后:"""
        
        rewritten = llm.invoke(prompt)
        return rewritten.content.strip()
    
    # 测试
    original = "密码忘了咋整"
    rewritten = rewrite_query(original)
    print(f"原问题:{original}")
    print(f"改写后:{rewritten}")
    

    七、构建完整可用的问答界面

    最后,用Streamlit快速搭一个可交互的问答界面:

    python

    import streamlit as st
    
    st.set_page_config(page_title="企业知识库问答", page_icon="💬")
    st.title("💬 企业知识库问答助手")
    
    if "chain" not in st.session_state:
        from main import create_rag_chain, create_vector_store, load_and_split_documents
        chunks = load_and_split_documents("docs/faq.md")
        vectorstore = create_vector_store(chunks)
        st.session_state.chain = create_rag_chain(vectorstore)
    
    question = st.text_input("请输入您的问题:", placeholder="例如:密码忘了怎么处理?")
    
    if question:
        with st.spinner("正在思考中..."):
            result = st.session_state.chain.invoke({"query": question})
            
        st.success("回答:")
        st.write(result['result'])
        
        with st.expander("查看参考来源"):
            for doc in result['source_documents']:
                st.write(f"- {doc.page_content}")
    

    运行命令:

    bash

    streamlit run app.py
    

    浏览器打开 http://localhost:8501 就能看到一个完整的问答界面了。

    八、写在最后

    整个RAG流程走下来,核心就这么几步:文档加载、文本切分、向量化存储、相似度检索、生成回答。看起来不难,但要做好其实有很多细节需要注意。

    比如文档切分策略,块太大容易引入噪音,块太小又可能丢失上下文。再比如检索阈值设置,低了会召回一堆无关内容,高了可能漏掉正确答案。这些都需要在项目里慢慢调优。

    我的建议是:先跑通整个流程,理解每个环节在做什么,然后再根据自己的业务场景调整参数和策略。纸上得来终觉浅,亲手实践一次比看十篇教程都有用。

    如果你在实践过程中遇到问题,欢迎在评论区留言,咱们一起讨论。

    相关推荐:

  • React Hooks实战:告别Class组件的现代开发

    React Hooks实战:告别Class组件的现代开发

    前言

    我是从React 15开始学的,那时候全是Class组件。写一个简单的计数器要这样:

    jsx

    class Counter extends React.Component {
        constructor(props) {
            super(props);
            this.state = { count: 0 };
            this.handleClick = this.handleClick.bind(this);
        }
        
        handleClick() {
            this.setState({ count: this.state.count + 1 });
        }
        
        componentDidMount() {
            console.log('组件挂载了');
        }
        
        componentDidUpdate() {
            console.log('组件更新了');
        }
        
        componentWillUnmount() {
            console.log('组件要卸载了');
        }
        
        render() {
            return (
                <button onClick={this.handleClick}>
                    点击次数: {this.state.count}
                </button>
            );
        }
    }
    

    第一次写的时候我都懵了——怎么这么多重复代码?this是什么东西?为什么点击事件要.bind(this)?生命周期方法怎么这么乱?

    后来React 16.8出了Hooks,一切都变了。同样的计数器,用Hooks写:

    jsx

    function Counter() {
        const [count, setCount] = useState(0);
        
        return (
            <button onClick={() => setCount(count + 1)}>
                点击次数: {count}
            </button>
        );
    }
    

    就这几行,代码少了三分之二,逻辑清晰多了。这就是Hooks的魅力——让React组件变得更简单、更容易理解和维护。

    为什么需要Hooks?

    Class组件的痛点

    在说Hooks之前,先聊聊为什么Facebook要发明Hooks:

    1. 逻辑复用困难

    Class组件复用逻辑只有两种方式:HOC(高阶组件)和Render Props。这两种方式都会让代码嵌套很深,调试困难。

    jsx

    // HOC示例
    const withUser = (Component) => {
        return class WithUser extends React.Component {
            constructor(props) {
                super(props);
                this.state = { user: null };
            }
            
            componentDidMount() {
                fetchUser().then(user => this.setState({ user }));
            }
            
            render() {
                return <Component {...this.props} user={this.state.user} />;
            }
        };
    };
    
    // 使用高阶组件
    const UserPage = withUser(ProfilePage);
    
    // 问题:层层嵌套
    const EnhancedComponent = withLogging(withTheme(withUser(OriginalComponent)));
    

    2. 生命周期逻辑混乱

    相关逻辑被分散到不同生命周期里,比如数据获取,可能在componentDidMount里发请求,在componentDidUpdate里处理更新,在componentWillUnmount里清理。但相关的逻辑其实应该放在一起。

    jsx

    class UserProfile extends React.Component {
        componentDidMount() {
            this.fetchUser();
            this.startPolling();
            this.setupEventListeners();
            this.updateDocumentTitle();
        }
        
        componentDidUpdate(prevProps) {
            if (prevProps.userId !== this.props.userId) {
                this.fetchUser();
                this.updateDocumentTitle();
            }
        }
        
        componentWillUnmount() {
            this.stopPolling();
            this.removeEventListeners();
        }
        
        // 问题:相关的逻辑分散在不同地方,很难维护
    }
    

    3. Class的this问题

    jsx

    class MyComponent extends React.Component {
        handleClick() {
            // 这里的this是undefined!必须bind或者用箭头函数
            this.setState({ clicked: true });
        }
        
        render() {
            return (
                <div>
                    {/* 方法1:bind */}
                    <button onClick={this.handleClick.bind(this)}>点击1</button>
                    
                    {/* 方法2:构造函数里bind */}
                    <button onClick={this.handleClick}>点击2</button>
                    
                    {/* 方法3:箭头函数 */}
                    <button onClick={() => this.handleClick()}>点击3</button>
                </div>
            );
        }
    }
    

    Hooks带来的改变

    Hooks是React 16.8引入的新特性,它让你在不写Class的情况下使用state和其他React特性:

    • 逻辑复用更简单:自定义Hook让逻辑复用变得直观
    • 代码更简洁:不用写Class,少打很多字
    • 更容易理解:相关的逻辑放在一起
    • 告别this:函数组件没有this问题
    • 更容易测试:Hook是普通函数,可以单独测试

    核心Hooks详解

    useState:状态管理

    useState是最基础的Hook,用来在函数组件中添加状态。

    基础用法

    jsx

    import { useState } from 'react';
    
    function Counter() {
        // count是状态值,setCount是用来更新状态的函数
        const [count, setCount] = useState(0);
        
        return (
            <div>
                <p>计数: {count}</p>
                <button onClick={() => setCount(count + 1)}>增加</button>
                <button onClick={() => setCount(count - 1)}>减少</button>
                <button onClick={() => setCount(0)}>重置</button>
            </div>
        );
    }
    

    函数式更新

    当新的状态依赖于旧状态时,用函数式更新更安全:

    jsx

    // 普通方式:快速点击时可能出问题
    setCount(count + 1);
    
    // 函数式更新(推荐,尤其在快速点击时)
    setCount(prevCount => prevCount + 1);
    
    // 更复杂的情况
    setCount(prev => {
        if (prev >= 10) return 0;
        return prev + 1;
    });
    

    对象类型状态

    jsx

    // 对象状态需要整体更新
    const [user, setUser] = useState({ name: '', age: 0, email: '' });
    
    // 正确:用展开运算符保留其他属性
    setUser(prev => ({ ...prev, name: '小明' }));
    setUser(prev => ({ ...prev, age: prev.age + 1 }));
    
    // 错误:会丢失其他属性
    setUser({ name: '小明' }); // age和email都没了!
    

    数组类型状态

    jsx

    const [items, setItems] = useState([]);
    
    // 添加元素
    setItems(prev => [...prev, newItem]);
    
    // 删除元素
    setItems(prev => prev.filter(item => item.id !== id));
    
    // 更新元素
    setItems(prev => prev.map(item => 
        item.id === id 
            ? { ...item, ...updates }  // 合并更新
            : item
    ));
    
    // 清空数组
    setItems([]);
    

    多状态管理

    jsx

    function UserForm() {
        const [name, setName] = useState('');
        const [email, setEmail] = useState('');
        const [password, setPassword] = useState('');
        const [isSubmitting, setIsSubmitting] = useState(false);
        const [errors, setErrors] = useState({});
        
        // ...
    }
    

    useEffect:副作用处理

    useEffect用来处理副作用,比如数据获取、订阅、手动修改DOM、定时器等。

    基础用法

    jsx

    import { useState, useEffect } from 'react';
    
    function UserProfile({ userId }) {
        const [user, setUser] = useState(null);
        const [loading, setLoading] = useState(true);
        const [error, setError] = useState(null);
        
        useEffect(() => {
            // 这个函数会在组件挂载后执行
            console.log('副作用执行了');
            
            // 可选:返回一个清理函数
            return () => {
                console.log('清理副作用');
            };
        }, []); // 空依赖数组表示只在挂载时执行
        
        return <div>...</div>;
    }
    

    数据获取实战

    jsx

    function UserProfile({ userId }) {
        const [user, setUser] = useState(null);
        const [loading, setLoading] = useState(true);
        const [error, setError] = useState(null);
        
        useEffect(() => {
            // 创建AbortController用于取消请求
            const controller = new AbortController();
            
            async function fetchUser() {
                setLoading(true);
                setError(null);
                
                try {
                    const response = await fetch(`/api/users/${userId}`, {
                        signal: controller.signal
                    });
                    
                    if (!response.ok) {
                        throw new Error('获取用户信息失败');
                    }
                    
                    const data = await response.json();
                    setUser(data);
                } catch (err) {
                    // 忽略取消请求的错误
                    if (err.name !== 'AbortError') {
                        setError(err.message);
                    }
                } finally {
                    // 只有请求没被取消时才更新loading
                    if (!controller.signal.aborted) {
                        setLoading(false);
                    }
                }
            }
            
            fetchUser();
            
            // 清理:组件卸载或userId变化时取消请求
            return () => controller.abort();
        }, [userId]); // userId变化时重新获取
        
        if (loading) return <div>加载中...</div>;
        if (error) return <div>出错了: {error}</div>;
        if (!user) return <div>用户不存在</div>;
        
        return (
            <div>
                <h1>{user.name}</h1>
                <p>{user.email}</p>
            </div>
        );
    }
    

    依赖数组的三种用法

    jsx

    useEffect(() => {
        // 1. 不传依赖数组:每次渲染都执行(容易造成无限循环)
        // 慎用!通常需要配合useRef
    });
    
    useEffect(() => {
        // 2. 空数组:只在挂载时执行一次(常用于初始化)
        // 类似componentDidMount
    }, []);
    
    useEffect(() => {
        // 3. 指定依赖:依赖变化时执行(最常用)
        // 类似componentDidUpdate
    }, [dependency1, dependency2]);
    

    清理副作用

    jsx

    function Timer() {
        const [seconds, setSeconds] = useState(0);
        
        useEffect(() => {
            const interval = setInterval(() => {
                setSeconds(s => s + 1);
            }, 1000);
            
            // 清理:组件卸载时清除定时器
            return () => {
                clearInterval(interval);
            };
        }, []);
        
        return <div>已过去 {seconds} 秒</div>;
    }
    
    function WindowSize() {
        const [width, setWidth] = useState(window.innerWidth);
        
        useEffect(() => {
            function handleResize() {
                setWidth(window.innerWidth);
            }
            
            window.addEventListener('resize', handleResize);
            
            // 清理:移除事件监听
            return () => {
                window.removeEventListener('resize', handleResize);
            };
        }, []);
        
        return <div>窗口宽度: {width}</div>;
    }
    

    useRef:操作DOM和保存可变值

    useRef有两个主要用途:操作DOM和保存不会触发渲染的可变值。

    操作DOM

    jsx

    import { useRef, useEffect } from 'react';
    
    function AutoFocusInput() {
        const inputRef = useRef(null);
        
        useEffect(() => {
            // 自动聚焦到输入框
            inputRef.current.focus();
        }, []);
        
        return (
            <input 
                ref={inputRef} 
                type="text" 
                placeholder="自动聚焦在这里"
            />
        );
    }
    
    function ScrollToTop() {
        const topRef = useRef(null);
        
        const scrollToTop = () => {
            topRef.current?.scrollIntoView({ behavior: 'smooth' });
        };
        
        return (
            <div>
                <div ref={topRef}>页面顶部</div>
                <button onClick={scrollToTop}>回到顶部</button>
            </div>
        );
    }
    

    保存上一次的props或state

    jsx

    function usePrevious(value) {
        const ref = useRef();
        
        useEffect(() => {
            // 每次value变化时,更新ref
            ref.current = value;
        });
        
        // 返回的是上一次的值
        return ref.current;
    }
    
    function Counter() {
        const [count, setCount] = useState(0);
        const previousCount = usePrevious(count);
        
        return (
            <div>
                <p>现在: {count}, 上次: {previousCount}</p>
                <button onClick={() => setCount(c => c + 1)}>增加</button>
            </div>
        );
    }
    

    保存定时器ID和其他可变值

    jsx

    function Timer() {
        const [seconds, setSeconds] = useState(0);
        const intervalRef = useRef(null);
        
        const startTimer = () => {
            if (intervalRef.current) return;
            
            intervalRef.current = setInterval(() => {
                setSeconds(s => s + 1);
            }, 1000);
        };
        
        const stopTimer = () => {
            if (intervalRef.current) {
                clearInterval(intervalRef.current);
                intervalRef.current = null;
            }
        };
        
        return (
            <div>
                <p>已过去 {seconds} 秒</p>
                <button onClick={startTimer}>开始</button>
                <button onClick={stopTimer}>停止</button>
            </div>
        );
    }
    

    useMemo和useCallback:性能优化

    这两个Hook用来避免不必要的计算和渲染。

    useMemo:缓存计算结果

    jsx

    import { useMemo } from 'react';
    
    function ExpensiveList({ items, filter, sortBy }) {
        // 只有items、filter或sortBy变化时才重新计算
        const filteredAndSorted = useMemo(() => {
            console.log('开始过滤和排序...');
            
            return items
                .filter(item => item.name.includes(filter))
                .sort((a, b) => {
                    if (sortBy === 'name') {
                        return a.name.localeCompare(b.name);
                    }
                    return a[sortBy] - b[sortBy];
                });
        }, [items, filter, sortBy]);
        
        return (
            <ul>
                {filteredAndSorted.map(item => (
                    <li key={item.id}>{item.name}</li>
                ))}
            </ul>
        );
    }
    
    // 另一个例子:缓存计算结果
    function App() {
        const [count, setCount] = useState(0);
        const [multiplier, setMultiplier] = useState(1);
        
        // expensiveCalculation是耗时计算
        const result = useMemo(() => {
            return expensiveCalculation(count);
        }, [count]);
        
        return (
            <div>
                <p>{count} * {multiplier} = {result}</p>
                <button onClick={() => setCount(c => c + 1)}>增加计数</button>
                <button onClick={() => setMultiplier(m => m + 1)}>增加倍数</button>
            </div>
        );
    }
    

    useCallback:缓存函数

    jsx

    import { useCallback } from 'react';
    
    function Parent() {
        const [count, setCount] = useState(0);
        const [name, setName] = useState('');
        
        // 用useCallback缓存函数
        const handleClick = useCallback((id) => {
            console.log('点击了', id);
            setCount(c => c + 1);
        }, []); // 空依赖,函数永远不变
        
        const handleSubmit = useCallback((data) => {
            console.log('提交数据:', data);
            setName(data.name);
        }, []); // 空依赖
        
        return (
            <div>
                <Child onClick={handleClick} />
                <Form onSubmit={handleSubmit} />
                <p>点击次数: {count}</p>
            </div>
        );
    }
    
    // 子组件使用React.memo避免不必要的重渲染
    const Child = React.memo(function Child({ onClick }) {
        console.log('Child渲染了');
        return <button onClick={() => onClick(1)}>点击</button>;
    });
    

    注意:不要过度使用useMemo和useCallback,只有在确实有性能问题时才用。

    自定义Hooks:逻辑复用

    自定义Hook是React Hooks最强大的特性——它让你可以把组件逻辑提取成可复用的函数。

    基础自定义Hook

    jsx

    // 自定义localStorage Hook
    function useLocalStorage(key, initialValue) {
        // 从localStorage读取初始值
        const [storedValue, setStoredValue] = useState(() => {
            try {
                const item = window.localStorage.getItem(key);
                return item ? JSON.parse(item) : initialValue;
            } catch (error) {
                console.error(error);
                return initialValue;
            }
        });
        
        // 更新localStorage
        const setValue = (value) => {
            try {
                // 支持函数更新
                const valueToStore = value instanceof Function 
                    ? value(storedValue) 
                    : value;
                
                setStoredValue(valueToStore);
                window.localStorage.setItem(key, JSON.stringify(valueToStore));
            } catch (error) {
                console.error(error);
            }
        };
        
        return [storedValue, setValue];
    }
    
    // 使用
    function App() {
        const [name, setName] = useLocalStorage('name', '');
        const [theme, setTheme] = useLocalStorage('theme', 'light');
        
        return (
            <div>
                <input 
                    value={name} 
                    onChange={e => setName(e.target.value)} 
                />
                <select value={theme} onChange={e => setTheme(e.target.value)}>
                    <option value="light">浅色</option>
                    <option value="dark">深色</option>
                </select>
            </div>
        );
    }
    

    监听窗口大小

    jsx

    function useWindowSize() {
        const [size, setSize] = useState({
            width: window.innerWidth,
            height: window.innerHeight
        });
        
        useEffect(() => {
            function handleResize() {
                setSize({
                    width: window.innerWidth,
                    height: window.innerHeight
                });
            }
            
            window.addEventListener('resize', handleResize);
            handleResize(); // 初始化
            
            return () => window.removeEventListener('resize', handleResize);
        }, []);
        
        return size;
    }
    
    function ResponsiveComponent() {
        const { width, height } = useWindowSize();
        
        return (
            <div>
                <p>窗口大小: {width} x {height}</p>
                {width < 768 && <MobileMenu />}
                {width >= 768 && <DesktopMenu />}
            </div>
        );
    }
    

    异步数据请求

    jsx

    function useAsync(asyncFunction, immediate = true) {
        const [data, setData] = useState(null);
        const [loading, setLoading] = useState(immediate);
        const [error, setError] = useState(null);
        
        const execute = useCallback(async (...args) => {
            setLoading(true);
            setError(null);
            
            try {
                const response = await asyncFunction(...args);
                setData(response);
            } catch (err) {
                setError(err);
            } finally {
                setLoading(false);
            }
        }, [asyncFunction]);
        
        useEffect(() => {
            if (immediate) {
                execute();
            }
        }, [execute, immediate]);
        
        return { data, loading, error, execute };
    }
    
    // 使用
    function UserList() {
        const { data: users, loading, error, execute: refetch } = useAsync(
            async () => {
                const response = await fetch('/api/users');
                if (!response.ok) throw new Error('获取失败');
                return response.json();
            }
        );
        
        if (loading) return <div>加载中...</div>;
        if (error) return <div>错误: {error.message}</div>;
        
        return (
            <div>
                <button onClick={refetch}>刷新</button>
                <ul>
                    {users?.map(user => (
                        <li key={user.id}>{user.name}</li>
                    ))}
                </ul>
            </div>
        );
    }
    

    防抖Hook

    jsx

    function useDebounce(value, delay) {
        const [debouncedValue, setDebouncedValue] = useState(value);
        
        useEffect(() => {
            const handler = setTimeout(() => {
                setDebouncedValue(value);
            }, delay);
            
            return () => clearTimeout(handler);
        }, [value, delay]);
        
        return debouncedValue;
    }
    
    function SearchBox() {
        const [searchTerm, setSearchTerm] = useState('');
        const [results, setResults] = useState([]);
        const [isSearching, setIsSearching] = useState(false);
        
        // 防抖500毫秒
        const debouncedSearch = useDebounce(searchTerm, 500);
        
        useEffect(() => {
            if (!debouncedSearch) {
                setResults([]);
                return;
            }
            
            setIsSearching(true);
            
            // 执行搜索
            fetch(`/api/search?q=${debouncedSearch}`)
                .then(res => res.json())
                .then(data => setResults(data))
                .finally(() => setIsSearching(false));
                
        }, [debouncedSearch]);
        
        return (
            <div>
                <input
                    value={searchTerm}
                    onChange={e => setSearchTerm(e.target.value)}
                    placeholder="输入搜索内容..."
                />
                {isSearching && <p>搜索中...</p>}
                {results.map(r => (
                    <div key={r.id}>{r.title}</div>
                ))}
            </div>
        );
    }
    

    表单处理Hook

    jsx

    function useForm(initialValues) {
        const [values, setValues] = useState(initialValues);
        const [errors, setErrors] = useState({});
        const [touched, setTouched] = useState({});
        const [isSubmitting, setIsSubmitting] = useState(false);
        
        const handleChange = (e) => {
            const { name, value } = e.target;
            setValues(prev => ({ ...prev, [name]: value }));
        };
        
        const handleBlur = (e) => {
            const { name } = e.target;
            setTouched(prev => ({ ...prev, [name]: true }));
        };
        
        const reset = () => {
            setValues(initialValues);
            setErrors({});
            setTouched({});
            setIsSubmitting(false);
        };
        
        const validate = (validator) => {
            const newErrors = validator(values);
            setErrors(newErrors);
            return Object.keys(newErrors).length === 0;
        };
        
        return {
            values,
            errors,
            touched,
            isSubmitting,
            setIsSubmitting,
            handleChange,
            handleBlur,
            reset,
            validate
        };
    }
    
    // 使用
    function ContactForm({ onSubmit }) {
        const {
            values,
            errors,
            touched,
            isSubmitting,
            handleChange,
            handleBlur,
            reset,
            validate
        } = useForm({ 
            name: '', 
            email: '', 
            message: '' 
        });
        
        const handleSubmit = async (e) => {
            e.preventDefault();
            
            // 标记所有字段已被访问
            setTouched({ name: true, email: true, message: true });
            
            // 验证
            if (!validate(v => {
                const errors = {};
                if (!v.name) errors.name = '姓名不能为空';
                if (!v.email.includes('@')) errors.email = '邮箱格式不正确';
                if (v.message.length < 10) errors.message = '留言至少10个字符';
                return errors;
            })) return;
            
            setIsSubmitting(true);
            await onSubmit(values);
            setIsSubmitting(false);
            reset();
        };
        
        return (
            <form onSubmit={handleSubmit}>
                <div>
                    <input
                        name="name"
                        value={values.name}
                        onChange={handleChange}
                        onBlur={handleBlur}
                        placeholder="姓名"
                    />
                    {touched.name && errors.name && <span>{errors.name}</span>}
                </div>
                
                <div>
                    <input
                        name="email"
                        value={values.email}
                        onChange={handleChange}
                        onBlur={handleBlur}
                        placeholder="邮箱"
                    />
                    {touched.email && errors.email && <span>{errors.email}</span>}
                </div>
                
                <div>
                    <textarea
                        name="message"
                        value={values.message}
                        onChange={handleChange}
                        onBlur={handleBlur}
                        placeholder="留言"
                    />
                    {touched.message && errors.message && <span>{errors.message}</span>}
                </div>
                
                <button type="submit" disabled={isSubmitting}>
                    {isSubmitting ? '提交中...' : '提交'}
                </button>
            </form>
        );
    }
    

    Hooks使用规则

    React对Hook的调用有两条强制的规则:

    规则1:只在顶层调用Hooks

    不要在循环、条件语句或嵌套函数中调用Hook:

    jsx

    // 错误 ❌ - 在条件语句中调用Hook
    function Component({ isLoggedIn }) {
        if (isLoggedIn) {
            const [name, setName] = useState(''); // 错误!
        }
        
        const [count, setCount] = useState(0);
    }
    
    // 正确 ✅ - 所有Hook在顶层调用
    function Component({ isLoggedIn }) {
        const [name, setName] = useState('');
        const [count, setCount] = useState(0);
        
        // 在条件块内部使用状态
        if (isLoggedIn) {
            console.log(name);
        }
    }
    

    规则2:只在React函数中调用

    只能在React函数组件或自定义Hook中调用:

    jsx

    // 错误 ❌
    function ordinaryFunction() {
        const [count, setCount] = useState(0); // 错误!
    }
    
    // 正确 ✅
    function MyComponent() {
        const [count, setCount] = useState(0);
    }
    
    // 正确 ✅ - 自定义Hook
    function useCustomHook() {
        const [data, setData] = useState(null);
        // ...
    }
    

    从Class组件迁移到Hooks

    很多老项目还在用Class组件,以下是常见迁移方案:

    生命周期方法迁移

    Class组件Hooks版本
    constructoruseState初始化
    componentDidMountuseEffect(() => {}, [])
    componentDidUpdateuseEffect(() => {}, [deps])
    componentWillUnmountuseEffect return cleanup
    shouldComponentUpdateReact.memo 或 useMemo

    完整迁移示例

    Class组件

    jsx

    class UserProfile extends React.Component {
        state = { user: null, loading: true, error: null };
        
        componentDidMount() {
            this.fetchUser();
            document.title = '加载中...';
        }
        
        componentDidUpdate(prevProps) {
            if (prevProps.userId !== this.props.userId) {
                this.fetchUser();
            }
            document.title = this.state.user?.name || '加载中';
        }
        
        componentWillUnmount() {
            this.cancelFetch();
        }
        
        async fetchUser() {
            try {
                this.setState({ loading: true, error: null });
                const user = await api.getUser(this.props.userId);
                this.setState({ user, loading: false });
            } catch (error) {
                this.setState({ error: error.message, loading: false });
            }
        }
        
        render() {
            const { user, loading, error } = this.state;
            
            if (loading) return <div>加载中...</div>;
            if (error) return <div>出错了: {error}</div>;
            
            return <div>{user.name}</div>;
        }
    }
    

    Hooks版本

    jsx

    function UserProfile({ userId }) {
        const [user, setUser] = useState(null);
        const [loading, setLoading] = useState(true);
        const [error, setError] = useState(null);
        
        useEffect(() => {
            let cancelled = false;
            
            async function fetchUser() {
                try {
                    setLoading(true);
                    setError(null);
                    const user = await api.getUser(userId);
                    if (!cancelled) {
                        setUser(user);
                    }
                } catch (error) {
                    if (!cancelled) {
                        setError(error.message);
                    }
                } finally {
                    if (!cancelled) {
                        setLoading(false);
                    }
                }
            }
            
            fetchUser();
            
            return () => {
                cancelled = true;
            };
        }, [userId]);
        
        useEffect(() => {
            document.title = user?.name || '加载中';
        }, [user]);
        
        if (loading) return <div>加载中...</div>;
        if (error) return <div>出错了: {error}</div>;
        
        return <div>{user.name}</div>;
    }
    

    常见问题和解决方案

    1. 闭包陷阱

    jsx

    // 问题:定时器会一直输出0
    function Counter() {
        const [count, setCount] = useState(0);
        
        useEffect(() => {
            const timer = setInterval(() => {
                console.log(count); // 永远是0(闭包问题)
                setCount(count + 1); // 永远执行 setCount(0 + 1)
            }, 1000);
            
            return () => clearInterval(timer);
        }, []); // 空依赖,只执行一次
        
        return <div>{count}</div>;
    }
    
    // 解决方案1:使用函数式更新
    useEffect(() => {
        const timer = setInterval(() => {
            setCount(c => c + 1); // 用函数获取最新值
        }, 1000);
        
        return () => clearInterval(timer);
    }, []);
    
    // 解决方案2:使用useRef保存最新值
    function Counter() {
        const [count, setCount] = useState(0);
        const countRef = useRef(count);
        
        useEffect(() => {
            countRef.current = count;
        }, [count]);
        
        useEffect(() => {
            const timer = setInterval(() => {
                setCount(countRef.current + 1);
            }, 1000);
            
            return () => clearInterval(timer);
        }, []);
        
        return <div>{count}</div>;
    }
    

    2. 无限循环

    jsx

    // 问题:effect里更新状态,状态变化触发effect
    useEffect(() => {
        setData(newData); // 触发重新渲染
    }, [data]); // data变化又触发effect → 无限循环!
    
    // 解决方案1:检查值是否真的变化了
    useEffect(() => {
        if (data !== newData) {
            setData(newData);
        }
    }, [newData]); // 只依赖newData
    
    // 解决方案2:使用ref存储中间值
    const dataRef = useRef(data);
    useEffect(() => {
        if (dataRef.current !== newData) {
            dataRef.current = newData;
            setData(newData);
        }
    }, [newData]);
    

    3. 清理函数很重要

    jsx

    // 问题:订阅不清理会造成内存泄漏
    useEffect(() => {
        const subscription = api.subscribe(data => setData(data));
        // 没写清理函数!组件卸载后订阅还在
    }, []);
    
    // 正确做法:返回清理函数
    useEffect(() => {
        const subscription = api.subscribe(data => setData(data));
        
        return () => {
            subscription.unsubscribe(); // 清理
        };
    }, []);
    
    // WebSocket示例
    useEffect(() => {
        const ws = new WebSocket('wss://example.com');
        
        ws.onmessage = (event) => {
            setMessages(prev => [...prev, event.data]);
        };
        
        return () => {
            ws.close(); // 关闭连接
        };
    }, []);
    

    4. useEffect里的async函数

    jsx

    // 错误:useEffect不能直接接收async函数
    useEffect(async () => {
        const data = await fetchData();
        setData(data);
    }, []);
    
    // 正确:在effect内部定义async函数
    useEffect(() => {
        async function fetchData() {
            const data = await fetch('/api/data');
            setData(data);
        }
        
        fetchData();
    }, []);
    
    // 或者用立即执行函数
    useEffect(() => {
        (async () => {
            const data = await fetch('/api/data');
            setData(data);
        })();
    }, []);
    

    常用Hooks库推荐

    react-use

    最流行的React Hooks库,提供了大量实用的Hook:

    bash

    npm install react-use
    

    jsx

    import { useMouse, useDebounce, useLocalStorage } from 'react-use';
    
    function Component() {
        const [mouseX, mouseY] = useMouse();
        const [value, setValue] = useLocalStorage('key', 'default');
        const debouncedValue = useDebounce(value, 300);
        
        return <div>鼠标位置: {mouseX}, {mouseY}</div>;
    }
    

    ahooks(阿里出品)

    专为React开发的Hooks库,有中文文档:

    bash

    npm install ahooks
    

    react-hook-form

    高性能表单处理Hook:

    bash

    npm install react-hook-form
    

    jsx

    import { useForm } from 'react-hook-form';
    
    function Form() {
        const { register, handleSubmit, formState: { errors } } = useForm();
        
        const onSubmit = (data) => console.log(data);
        
        return (
            <form onSubmit={handleSubmit(onSubmit)}>
                <input {...register("name", { required: true })} />
                {errors.name && <span>必填</span>}
                
                <input {...register("email", { pattern: /@/ })} />
                {errors.email && <span>邮箱格式不对</span>}
                
                <button type="submit">提交</button>
            </form>
        );
    }
    

    总结

    React Hooks彻底改变了React开发的方式:

    特性Class组件Hooks
    代码量
    逻辑复用HOC/Render Props自定义Hook
    this问题需要处理没有
    状态逻辑分散集中
    测试需要渲染组件可单独测试Hook
    学习曲线陡(生命周期复杂)缓(概念简单)

    建议的学习路径:

    1. 熟练使用useState和useEffect
    2. 理解useRef的两种用法
    3. 学习useMemo和useCallback做性能优化
    4. 尝试编写自定义Hook
    5. 在项目中实践,逐渐迁移旧代码

    Hooks不是要完全替代Class,而是给了我们更多选择。对于简单的组件用Hooks更方便,对于复杂的、有生命周期特殊需求的组件,Class仍然有用武之地。

    相关推荐

    Hooks,让React开发更简单!

  • Markdown写作指南:程序员的文档利器

    Markdown写作指南:程序员的文档利器

    前言

    记得刚工作那会儿,每次要写文档我就头疼。用Word吧,格式调半天;用Pages吧,mac专属传给别人还打不开;直接写txt吧,又太丑了……

    直到有一天,项目组的学长丢给我一个.md后缀的文件,说”这个你看一下”。我一脸懵地点开,发现排版还挺好看,心想这人真厉害,用什么软件排的版这么好。

    后来才知道,这玩意叫Markdown,压根不是什么专业排版软件,就是纯文本!用几个简单的符号就能控制格式,特别适合程序员写文档。

    现在我写博客、写项目文档、写笔记,80%都是用Markdown。它简单、通用、专注内容,而且GitHub、掘金、CSDN这些平台都原生支持Markdown格式。

    这篇文章就是用Markdown写的,带你从零学会Markdown语法。看完你就能写出漂亮的文档了。

    Markdown 常用语法图解 - 标题列表代码块引用表格使用示例

    什么是Markdown?

    一句话解释

    Markdown是一种轻量级标记语言,由约翰·格鲁伯(John Gruber)在2004年创建。它的设计理念是”用易读易写的纯文本格式编写文档,然后转换成结构化的HTML”。

    通俗点说就是:

    • 易读:就算不转换成HTML,原始文本也好看
    • 易写:不用复杂的菜单和快捷键,用符号标记格式
    • 通用:任何文本编辑器都能打开

    为什么程序员都在用?

    作为一个写了十几年代码的程序员,我总结了几个原因:

    1. 专注内容而非格式

    写Word的时候,你是不是经常花半小时调格式,结果正文就几句话?Markdown让你专注于写作本身,格式只是顺手的事。

    2. 版本控制友好

    Markdown本质是纯文本,可以用Git管理。每次修改都能看到改了哪里,再也不会有”文档覆盖了怎么办”的烦恼。

    3. 到处都能用

    GitHub的README是Markdown,博客平台支持Markdown,甚至很多笔记软件也支持Markdown。一套语法,走遍天下。

    4. 转换方便

    一份Markdown可以轻松转换成HTML、PDF、Word、EPUB等多种格式,满足各种场景需求。

    基础语法

    标题

    #符号表示标题,几个#就是几级标题:

    markdown

    # 一级标题
    ## 二级标题
    ### 三级标题
    #### 四级标题
    ##### 五级标题
    ###### 六级标题
    

    渲染效果:

    一级标题

    二级标题

    三级标题

    四级标题

    小技巧:建议最多用三级标题就够了,太多层级会让文档变得难读。

    段落和换行

    段落的分隔用空行:

    markdown

    这是第一段文字。
    
    这是第二段文字,它们之间隔了一个空行。
    

    行内换行(不隔段)有两种方式:

    markdown

    方法1:在行尾加两个空格  
    这是一行,
    这是另一行(行尾加了两个空格)。
    
    方法2:用<br>标签
    这是第一行<br>这是第二行
    

    字体样式

    markdown

    *斜体文本*   或   _斜体文本_
    **粗体文本** 或   __粗体文本__
    ***粗斜体*** 或   ___粗斜体___
    ~~删除线~~
    

    渲染效果:

    • 斜体文本
    • 粗体文本
    • 粗斜体文本
    • 删除线

    分隔线

    三个或更多的-*

    markdown

    ---
    或者
    ***
    或者
    ___
    

    渲染效果:

    列表

    无序列表-*+(建议统一使用一种):

    markdown

    - 苹果
    - 香蕉
      - 苹果蕉(缩进一个Tab或两个空格)
      - 普通香蕉
        - 国产香蕉
        - 进口香蕉
    - 橙子
    

    渲染效果:

    • 苹果
    • 香蕉
      • 苹果蕉
      • 普通香蕉
        • 国产香蕉
        • 进口香蕉


    • 橙子

    有序列表用数字加点:

    markdown

    1. 第一步
    2. 第二步
    3. 第三步
    

    渲染效果:

    1. 第一步
    2. 第二步
    3. 第三步

    引用

    >符号表示引用:

    markdown

    > 这是一段引用文字。
    > 可以有多行。
    >
    > 空一行后继续引用。
    

    渲染效果:

    这是一段引用文字。
    可以有多行。

    空一行后继续引用。

    引用可以嵌套:

    markdown

    > 外层引用
    >> 内层引用
    >>> 再嵌套一层
    

    实用的引用写法

    markdown

    > [!NOTE]
    > 这是一个提示信息框
    
    > [!WARNING]
    > 这是一个警告信息框
    
    > [!TIP]
    > 这是一个技巧提示框
    

    链接

    markdown

    [链接文字](https://example.com)
    [链接文字](https://example.com "鼠标悬停显示的标题")
    

    渲染效果:
    链接文字

    自动链接(尖括号包起来):

    markdown

    <https://example.com>
    <email@example.com>
    

    参考式链接(链接多的时候用):

    markdown

    我经常访问[Google][google]和[百度][baidu]。
    
    [google]: https://www.google.com "Google搜索"
    [baidu]: https://www.baidu.com "百度搜索"
    

    图片

    markdown

    ![图片描述](图片地址)
    ![图片描述](图片地址 "鼠标悬停标题")
    

    本地图片用相对路径:

    markdown

    ![截图](./images/demo.png)
    

    代码

    行内代码用反引号:

    markdown

    这是 `console.log()` 方法,用于输出日志。
    这是 `const PI = 3.14;` 常量定义。
    

    渲染效果:
    这是 console.log() 方法,用于输出日志。

    代码块用三个反引号,并可指定语言:

    markdown

    ```javascript
    function hello() {
        console.log("Hello, World!");
    }
    ```
    

    渲染效果:

    javascript

    function hello() {
        console.log("Hello, World!");
    }
    

    常用语言标记:

    • javascriptjs
    • pythonpy
    • html
    • css
    • java
    • sql
    • bashshell
    • json
    • yaml

    进阶语法

    表格

    markdown

    | 表头1 | 表头2 | 表头3 |
    |-------|-------|-------|
    | 单元格1 | 单元格2 | 单元格3 |
    | 单元格4 | 单元格5 | 单元格6 |
    

    渲染效果:

    表头1表头2表头3
    单元格1单元格2单元格3
    单元格4单元格5单元格6

    对齐方式

    markdown

    | 左对齐 | 居中对齐 | 右对齐 |
    |:------|:-------:|------:|
    | 文字 | 文字 | 文字 |
    

    渲染效果:

    左对齐居中对齐右对齐
    文字文字文字

    任务列表

    markdown

    - [x] 已完成的任务
    - [ ] 未完成的任务
    - [ ] 还有一个任务
    
    下面是打钩的任务:
    - [x] 学习Markdown语法
    - [x] 写第一篇文档
    - [ ] 分享给朋友
    

    渲染效果:

    • 已完成的任务
    • 未完成的任务
    • 还有一个任务

    脚注

    markdown

    这是一段文字[^1]。
    
    这是另一段文字[^2]。
    
    [^1]: 这是脚注1的说明。
    [^2]: 这是脚注2的说明,可以写很长。
    

    目录

    有些编辑器支持自动生成目录:

    markdown

    [TOC]
    
    # 第一章
    ## 第一节
    ## 第二节
    # 第二章
    

    注释

    Markdown本身不支持注释,但可以用HTML的注释语法:

    markdown

    <!-- 这是被注释的内容,在最终输出中不可见 -->
    

    常见应用场景

    1. GitHub项目README

    markdown

    # 项目名称
    
    简洁的项目介绍,一句话说清楚是做什么的。
    
    ## 特性
    
    - ✅ 特性一
    - ✅ 特性二
    - 🚧 正在开发
    
    ## 安装
    
    ```bash
    npm install my-project
    

    使用

    javascript

    import { foo } from 'my-project';
    
    foo({
        option1: 'value1',
        option2: 'value2'
    });
    

    API

    foo(options)

    参数类型说明
    option1string选项1
    option2string选项2

    贡献

    欢迎提交Pull Request!

    许可证

    MIT © 2024

    plaintext

    
    ### 2. 写技术博客
    
    ```markdown
    ---
    title: JavaScript入门教程
    date: 2024-01-15
    tags: [JavaScript, 入门教程]
    ---
    
    # JavaScript入门教程:前端开发第一步
    
    ## 前言
    
    开门见山介绍文章要讲什么。
    
    ## 正文
    
    ### 小标题
    
    内容……
    
    ### 代码示例
    
    ```javascript
    const hello = () => console.log('Hello!');
    

    总结

    回顾今天学的内容。

    参考资料

    plaintext

    
    ### 3. 课堂笔记
    
    ```markdown
    # JavaScript学习笔记
    
    ## 2024-01-10 变量和数据类型
    
    ### 知识点
    
    - let和const的区别
    - 6种基本数据类型
    - 类型转换
    
    ### 练习题
    
    - [x] 完成P23页练习1-3
    - [ ] 完成P25页练习4-6
    - [ ] 完成P30页综合练习
    
    ### 问题记录
    
    - 为什么要用const而不是let?
      - 答:const声明的变量不能重新赋值,更安全
      
    - 什么时候用null?
      - 答:表示空对象,通常用于初始化
    
    ### 代码练习
    
    ```javascript
    // 练习1:变量声明
    const name = '张三';
    let age = 18;
    age = 19; // let可以重新赋值
    

    今日收获

    今天学习了JavaScript的基础知识,理解了变量声明的区别。

    plaintext

    
    ### 4. 个人简历
    
    ```markdown
    # 张三
    
    资深前端开发工程师 | 5年经验
    
    ## 联系方式
    
    - 📧 zhangsan@example.com
    - 📱 138-xxxx-xxxx
    - 🌐 www.example.com
    
    ## 技能
    
    | 技能 | 熟练度 |
    |------|--------|
    | HTML/CSS | ⭐⭐⭐⭐⭐ |
    | JavaScript | ⭐⭐⭐⭐⭐ |
    | React | ⭐⭐⭐⭐ |
    | Vue | ⭐⭐⭐⭐⭐ |
    | Node.js | ⭐⭐⭐ |
    
    ## 项目经验
    
    ### 项目一:电商网站
    
    2022.03 - 2023.06
    
    - 技术栈:React + Node.js + MongoDB
    - 项目描述:实现了商品展示、购物车、订单管理等功能
    - 核心贡献:
      - 优化首屏加载速度,提升40%
      - 设计并实现用户积分系统
    - GitHub:https://github.com/xxx/project
    
    ### 项目二:企业内部管理系统
    
    2021.01 - 2022.02
    
    - 技术栈:Vue3 + Element Plus
    - 项目描述:用于企业内部资源管理和审批流程
    
    ## 工作经历
    
    ### ABC科技有限公司 | 前端开发工程师 | 2020.06 - 至今
    
    - 负责前端技术选型和架构设计
    - 带领3人小组完成项目交付
    
    ## 教育背景
    
    - 📚 北京大学 | 计算机科学 | 本科 | 2016-2020
    

    常用编辑器推荐

    1. Typora(强烈推荐)

    我最常用的Markdown编辑器,界面简洁,实时预览,完全免费。

    特点

    • 所见即所得,编辑和预览合一
    • 支持所有标准Markdown语法
    • 支持LaTeX数学公式
    • 支持拖拽插入图片

    官网:https://typora.io/

    2. VS Code + Markdown插件

    如果你是程序员,用VS Code装个Markdown插件也很方便:

    必装插件

    • Markdown All in One:自动补全、目录生成、快捷键支持
    • Markdown Preview Enhanced:增强的预览功能
    • Markdown PDF:导出为PDF
    • Markdown Emoji:Emoji自动补全

    安装插件后,按Ctrl+Shift+P,输入Markdown,选择”打开预览”即可。

    3. 作业部落

    在线Markdown编辑器,有客户端,适合不想安装软件的人。

    特点

    • 在线编辑,无需安装
    • 支持实时同步
    • 有分享功能

    官网:https://www.zybuluo.com/

    4. MarkText

    免费开源的Markdown编辑器,界面好看,支持多种主题。

    特点

    • 开源免费
    • 界面美观
    • 跨平台支持

    官网:https://www.marktext.cn/

    5. Notion

    不只是Markdown编辑器,是一个强大的笔记和知识管理工具。

    特点

    • 块编辑器的概念
    • 数据库功能强大
    • 团队协作友好

    官网:https://www.notion.so/

    Markdown vs 富文本编辑器

    特性MarkdownWord/Pages
    格式兼容性纯文本,到处都能打开依赖特定软件
    版本控制可以用Git管理二进制文件,冲突多
    协作GitHub/GitLab原生支持需要专门协作工具
    学习成本5分钟入门需要熟悉各种菜单
    排版控制基础够用更精细(但一般用不上)
    表格支持但不太方便更方便
    导出格式HTML/PDF/Word原生支持

    实战技巧

    1. 插入Emoji

    Markdown支持Emoji,有两种方式:

    使用Emoji符号

    markdown

    常见的Emoji:
    ✅ 完成 - :white_check_mark:
    ❌ 错误 - :x:
    ⚠️ 警告 - :warning:
    📝 笔记 - :pencil:
    🔗 链接 - :link:
    💡 提示 - :bulb:
    🎉 庆祝 - :tada:
    🔥 热门 - :fire:
    

    直接输入Emoji
    🍎 🍌 🍊

    2. 插入LaTeX数学公式

    很多Markdown编辑器支持LaTeX公式:

    markdown

    行内公式:$E=mc^2$
    
    独立公式:
    $$
    \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
    $$
    
    矩阵:
    $$
    \begin{pmatrix}
    a & b \\
    c & d
    \end{pmatrix}
    $$
    

    渲染效果:

    • 行内公式:
    • 独立公式:

    3. 流程图和时序图

    有些编辑器支持Mermaid语法:

    markdown

    ```mermaid
    flowchart TD
        A[开始] --> B{判断}
        B -->|是| C[执行1]
        B -->|否| D[执行2]
        C --> E[结束]
        D --> E
    ```
    

    渲染效果:

    mermaid

    flowchart TD
        A[开始] --> B{判断}
        B -->|是| C[执行1]
        B -->|否| D[执行2]
        C --> E[结束]
        D --> E
    

    时序图示例

    markdown

    ```mermaid
    sequenceDiagram
        客户端->>服务器: 请求
        服务器-->>客户端: 响应
        Note over 客户端,服务器: 这个注释跨越两者
    ```
    

    4. 高亮提示框

    虽然Markdown原生不支持,但可以用HTML:

    markdown

    > [!NOTE]
    > 这是一个提示框,提示用户注意某些信息
    
    > [!WARNING]
    > 这是一个警告框,提醒用户注意潜在问题
    
    > [!TIP]
    > 这是一个技巧框,分享一些实用技巧
    

    5. 键盘按键表示

    markdown

    按 `Ctrl` + `C` 复制
    按 `Ctrl` + `V` 粘贴
    按 `Ctrl` + `Z` 撤销
    

    渲染效果:
    Ctrl + C 复制

    我的Markdown工作流

    作为一个经常写文档的人,我现在的流程是:

    1. 速记:想到什么,用Typora快速写成Markdown
    2. 整理:结构化内容,补充细节
    3. 发布:转换成HTML/PDF,或直接发到博客平台
    4. 同步:用iCloud/Dropbox同步,多设备通用

    工作场景

    写项目文档

    • 用Typora写 → 导出HTML或PDF
    • 或者直接在GitHub上编辑.md文件

    写博客

    • 用Typora写
    • 复制内容到掘金/CSDN
    • 或者用Hexo/Hugo静态博客生成器

    做笔记

    • 用Notion或Typora
    • 配合云同步,多端访问

    常见问题

    Q: Markdown能完全替代Word吗?

    A: 不能。Word的协作修订、精确排版等功能Markdown做不到。但对于技术文档、博客、笔记,Markdown完全够用。

    Q: 表格太难写了怎么办?

    A: 可以用在线工具生成:

    Q: Markdown语法记不住怎么办?

    A: 用多了就记住了。刚开始可以打印一份语法速查表放旁边。

    Q: 为什么图片显示不出来?

    A: 检查图片路径是否正确。本地图片用相对路径或绝对路径,网络图片确保URL可访问。

    Q: 如何让代码块高亮显示?

    A: 在代码块开头指定语言,如 ```javascript

    速查表

    plaintext

    =============== 标题 ===============
    # 一级标题
    ## 二级标题
    ### 三级标题
    
    =============== 字体 ===============
    *斜体* 或 _斜体_
    **粗体** 或 __粗体__
    ***粗斜体***
    ~~删除线~~
    
    =============== 列表 ===============
    - 无序列表项
    1. 有序列表项
    
    =============== 链接和图片 ===============
    [文字](URL)
    ![alt](图片URL)
    
    =============== 代码 ===============
    `行内代码`
    
    ​```语言
    代码块
    ​```
    
    =============== 引用 ===============
    > 引用内容
    
    =============== 表格 ===============
    | 列1 | 列2 |
    |------|------|
    | 内容 | 内容 |
    
    =============== 其他 ===============
    ---
    分隔线
    - [ ] 任务列表
    

    总结

    Markdown是程序员的必备技能,简单易学,通用性强。一旦熟练了,你会发现写文档变得轻松很多——不用再和格式斗争,只需要专注于内容本身。

    建议现在就打开Typora或VS Code,试着写一篇你自己的Markdown文档。可以从写一份个人简历或者项目README开始。

    相关推荐

    Markdown,让写作回归内容本身。

  • JavaScript入门教程:前端开发第一步

    JavaScript入门教程:前端开发第一步

    前言

    记得我第一次想学编程的时候,在网上搜”学编程从哪开始”,结果出来一堆Python、Java、C++,越看越懵。后来有个前辈跟我说:”想写网页?先学JavaScript吧。”

    我当时还纳闷,JavaScript和Java是什么关系?是Java的弟弟吗?

    后来才知道,它俩除了名字里都有”Java”之外,完全是两回事。JavaScript是专门给网页用的编程语言,运行在浏览器里,能让网页”动”起来——点击按钮弹出提示、输入内容实时验证、滚动加载更多内容,这些都是JavaScript的功劳。

    最棒的是,JavaScript现在不只能写网页了,还能写服务器(Node.js)、手机App(React Native)、桌面程序(Electron)……学会了JavaScript,感觉整个编程世界都向你敞开了大门。

    JavaScript 基础知识结构图 - 变量函数 DOM 事件与异步编程图解

    JavaScript是什么?

    一句话解释JavaScript

    JavaScript诞生于1995年,最初是为了让网页有点”交互感”——比如表单验证、弹窗提示之类的。那时候网页就是静态的HTML文档,点个按钮要么跳转到新页面,要么啥反应没有。JavaScript的出现,让网页终于能”活”起来了。

    打个比方,HTML就是网页的”骨架”,CSS是网页的”皮肤”,而JavaScript就是网页的”肌肉”——让网页能动起来。

    JavaScript能做什么

    经过二十多年的发展,JavaScript已经成为:

    • Web开发必备技能:任何现代网站都离不开JavaScript
    • 全栈开发语言:前端用React/Vue,后端用Node.js
    • 跨平台开发:手机App用React Native,桌面程序用Electron
    • 最流行的编程语言之一:常年霸占GitHub使用量榜首

    JavaScript的特点

    1. 简单易学:语法相对简洁,入门门槛低
    2. 运行即见:浏览器里按F12就能写代码看效果
    3. 应用广泛:从网页到服务器,从手机到桌面
    4. 生态丰富:npm上有上百万个第三方库

    环境准备

    浏览器内置开发工具

    学JavaScript最简单的入门方式就是浏览器。按F12打开开发者工具,切换到”Console”标签,你就可以直接写JavaScript代码了。

    试试在控制台输入:

    javascript

    console.log("Hello, JavaScript!");
    

    按回车,你会看到控制台输出了”Hello, JavaScript!”。这就是你的第一行JavaScript代码!

    VS Code编辑器

    写更长的代码需要编辑器。推荐VS Code,它是微软出的免费编辑器,对JavaScript支持非常好。

    下载地址:https://code.visualstudio.com/

    安装好之后,创建一个index.html文件:

    html

    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>我的第一个JavaScript网页</title>
    </head>
    <body>
        <h1>Hello JavaScript!</h1>
        <div id="app"></div>
        
        <script>
            // 在这里写JavaScript代码
            document.getElementById("app").innerHTML = "<p>JavaScript已加载!</p>";
            console.log("脚本执行了!");
        </script>
    </body>
    </html>
    

    用浏览器打开这个HTML文件,你会看到页面内容被JavaScript修改了。

    JavaScript基础语法

    变量声明

    JavaScript里有三种声明变量的方式:

    javascript

    // let:声明可重新赋值的变量
    let name = "小明";
    name = "小红"; // 可以重新赋值
    
    // const:声明常量,不能重新赋值
    const PI = 3.14159;
    // PI = 3.14; // 错误!常量不能重新赋值
    
    // var:旧式的声明方式,不推荐使用(有很多坑)
    var age = 18;
    

    为什么推荐用let和const?

    javascript

    // var的问题:可以重复声明,容易出错
    var count = 1;
    var count = 2; // 不报错,容易造成bug
    
    // let解决:不能重复声明
    let count = 1;
    let count = 2; // 报错!
    
    // const解决:声明时必须赋值,且不能改
    const PI = 3.14159;
    PI = 3.14; // 报错!
    
    // const用于对象:引用不能改,但属性可以改
    const user = { name: "小明" };
    user.name = "小红"; // 可以!只改属性,不改引用
    user = {}; // 报错!不能改引用
    

    数据类型

    JavaScript有几种基本数据类型:

    javascript

    // 字符串(String)
    let greeting = "你好,世界!";
    let name = '小明';
    let template = `Hello, ${name}!`; // 模板字符串
    
    // 数字(Number)
    let count = 42;
    let price = 19.99;
    let scientific = 1.5e 6; // 科学计数法:1500000
    
    // 布尔值(Boolean)
    let isActive = true;
    let isFinished = false;
    
    // 空值和未定义
    let empty = null; // 空对象
    let notDefined = undefined; // 未定义
    
    // 对象(Object)
    let user = {
        name: "小明",
        age: 18,
        isStudent: true,
        sayHello: function() {
            return "你好!";
        }
    };
    
    // 数组(Array)
    let fruits = ["苹果", "香蕉", "橙子"];
    let mixed = [1, "hello", true, null];
    
    // 函数(Function)
    let add = function(a, b) {
        return a + b;
    };
    

    运算符

    javascript

    // 算术运算符
    let a = 10, b = 3;
    console.log(a + b);   // 13,加法
    console.log(a - b);   // 7,减法
    console.log(a * b);   // 30,乘法
    console.log(a / b);   // 3.333...,除法
    console.log(a % b);   // 1,取余
    console.log(a ** b);  // 1000,幂运算(10的3次方)
    console.log(++a);     // 11,自增
    console.log(--b);     // 2,自减
    
    // 比较运算符
    console.log(5 == "5");   // true,宽松相等(类型转换后比较)
    console.log(5 === "5");  // false,严格相等(类型不同直接不等)
    console.log(5 != "5");   // false
    console.log(5 !== "5");  // true
    console.log(3 > 2);      // true
    console.log(3 >= 3);     // true
    
    // 逻辑运算符
    console.log(true && false); // false,与运算
    console.log(true || false); // true,或运算
    console.log(!true);         // false,非运算
    
    // 短路运算
    console.log(true || console.log("不会执行")); // true
    console.log(false && console.log("不会执行")); // false
    

    条件判断

    javascript

    let score = 85;
    
    // if-else语句
    if (score >= 90) {
        console.log("优秀");
    } else if (score >= 60) {
        console.log("及格");
    } else {
        console.log("不及格");
    }
    
    // 三元运算符(简单条件时用)
    let result = score >= 60 ? "及格" : "不及格";
    console.log(result);
    
    // switch语句(多条件分支)
    let day = 3;
    switch (day) {
        case 1:
            console.log("星期一");
            break;
        case 2:
            console.log("星期二");
            break;
        case 3:
            console.log("星期三");
            break;
        default:
            console.log("其他日子");
    }
    
    // 多条件判断(现代写法)
    let grade = score >= 90 ? "A" : 
                score >= 80 ? "B" : 
                score >= 60 ? "C" : "D";
    

    循环

    javascript

    // for循环(最基本)
    for (let i = 0; i < 5; i++) {
        console.log(i); // 输出 0, 1, 2, 3, 4
    }
    
    // while循环
    let count = 0;
    while (count < 3) {
        console.log(count);
        count++;
    }
    
    // do-while循环(至少执行一次)
    let num = 0;
    do {
        console.log(num);
        num++;
    } while (num < 0); // 条件为false,但会执行一次
    
    // for...of循环(遍历数组)
    let fruits = ["苹果", "香蕉", "橙子"];
    for (let fruit of fruits) {
        console.log(fruit);
    }
    
    // for...in循环(遍历对象属性)
    let user = { name: "小明", age: 18, city: "北京" };
    for (let key in user) {
        console.log(key + ": " + user[key]);
    }
    
    // 数组的forEach方法
    fruits.forEach((fruit, index) => {
        console.log(`${index + 1}. ${fruit}`);
    });
    

    函数

    函数是JavaScript的核心,它是组织代码的基本单元。

    javascript

    // 函数声明
    function greet(name) {
        return "你好," + name + "!";
    }
    
    // 函数表达式
    const sayHi = function(name) {
        return "Hi, " + name;
    };
    
    // 箭头函数(ES6新增,推荐)
    const add = (a, b) => a + b;
    const greetArrow = (name) => "你好," + name + "!";
    
    // 带默认参数的函数
    function greetWithDefault(name = "陌生人") {
        return "你好," + name;
    }
    
    // 函数调用
    console.log(greet("小明"));     // 你好,小明!
    console.log(add(1, 2));        // 3
    console.log(greetWithDefault()); // 你好,陌生人!
    
    // 立即执行函数(IIFE)
    (function() {
        console.log("立即执行!");
    })();
    
    // 回调函数
    function doSomething(callback) {
        console.log("做点什么...");
        callback();
    }
    
    doSomething(() => console.log("回调执行了!"));
    

    DOM操作:让网页动起来

    DOM(Document Object Model)是JavaScript操作网页的接口。通过DOM,你可以获取、修改、添加、删除HTML元素。

    获取元素

    javascript

    // 通过ID获取(最常用)
    let title = document.getElementById("title");
    
    // 通过标签名获取
    let paragraphs = document.getElementsByTagName("p");
    
    // 通过类名获取
    let cards = document.getElementsByClassName("card");
    
    // 通过选择器获取(CSS选择器语法,推荐)
    let firstCard = document.querySelector(".card");  // 第一个匹配
    let allCards = document.querySelectorAll(".card"); // 所有匹配的
    
    // 获取body
    let body = document.body;
    
    // 获取表单元素
    let form = document.querySelector("form");
    let inputs = form.elements; // 获取所有表单控件
    

    修改元素内容

    javascript

    // 修改文本内容
    let heading = document.querySelector("h1");
    heading.textContent = "新的标题";   // 纯文本(安全,防止XSS)
    heading.innerHTML = "<strong>加粗的标题</strong>"; // 支持HTML
    
    // 修改样式
    heading.style.color = "red";
    heading.style.fontSize = "24px";
    heading.style.fontWeight = "bold";
    
    // 添加/删除类
    heading.classList.add("highlight");
    heading.classList.remove("old-style");
    heading.classList.toggle("active"); // 切换类
    
    // 修改属性
    let link = document.querySelector("a");
    link.href = "https://example.com";
    link.target = "_blank";
    link.setAttribute("data-id", "123"); // 设置自定义属性
    
    // 获取属性
    let href = link.getAttribute("href");
    let id = link.dataset.id; // 获取data-*属性
    

    事件处理

    事件是用户和网页交互的动作,比如点击、鼠标悬停、键盘输入等。

    javascript

    // 获取按钮
    let button = document.querySelector("#myButton");
    
    // 添加点击事件
    button.addEventListener("click", function(event) {
        console.log("按钮被点击了!");
        console.log("事件对象:", event);
    });
    
    // 使用箭头函数(更简洁)
    button.addEventListener("click", (e) => {
        console.log("点击了:", e.target);
    });
    
    // 带参数的事件处理
    button.addEventListener("click", () => {
        handleClick(123);
    });
    
    function handleClick(id) {
        console.log("处理的ID:", id);
    }
    
    // 常见的其他事件
    // mouseover/mouseout - 鼠标悬停
    // mousedown/mouseup - 鼠标按下/抬起
    // keydown/keyup - 键盘按键
    // submit - 表单提交
    // change - 输入框内容变化且失去焦点
    // input - 输入框内容实时变化
    // focus/blur - 获得/失去焦点
    
    // 表单提交事件
    let form = document.querySelector("form");
    form.addEventListener("submit", (e) => {
        e.preventDefault(); // 阻止默认提交行为
        console.log("表单提交了!");
        // 在这里发送数据
    });
    
    // 键盘事件
    document.addEventListener("keydown", (e) => {
        if (e.key === "Enter") {
            console.log("按下了回车键!");
        }
        if (e.ctrlKey && e.key === "s") {
            e.preventDefault();
            console.log("Ctrl+S 快捷键");
        }
    });
    

    实战:做一个简单的计数器

    完整的HTML+CSS+JavaScript示例:

    html

    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>计数器</title>
        <style>
            body {
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                display: flex;
                justify-content: center;
                align-items: center;
                min-height: 100vh;
                margin: 0;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            }
            .counter {
                background: white;
                padding: 40px;
                border-radius: 20px;
                box-shadow: 0 10px 40px rgba(0,0,0,0.2);
                text-align: center;
                min-width: 300px;
            }
            .counter h2 {
                color: #333;
                margin-bottom: 10px;
            }
            .count {
                font-size: 80px;
                color: #333;
                margin: 20px 0;
                font-weight: bold;
            }
            button {
                padding: 15px 35px;
                font-size: 18px;
                border: none;
                border-radius: 8px;
                cursor: pointer;
                margin: 0 8px;
                transition: all 0.3s;
                color: white;
                font-weight: bold;
            }
            .decrease { background: #ff6b6b; }
            .reset { background: #95a5a6; }
            .increase { background: #4ecdc4; }
            button:hover {
                transform: translateY(-2px);
                box-shadow: 0 5px 15px rgba(0,0,0,0.2);
            }
            button:active {
                transform: translateY(0);
            }
        </style>
    </head>
    <body>
        <div class="counter">
            <h2>计数器</h2>
            <div class="count" id="count">0</div>
            <button class="decrease" onclick="decrease()">减少</button>
            <button class="reset" onclick="reset()">重置</button>
            <button class="increase" onclick="increase()">增加</button>
        </div>
    
        <script>
            // 初始化计数器
            let count = 0;
            const countDisplay = document.getElementById("count");
    
            // 更新显示
            function updateDisplay() {
                countDisplay.textContent = count;
                
                // 根据数字大小改变颜色
                if (count > 0) {
                    countDisplay.style.color = "#4ecdc4";
                } else if (count < 0) {
                    countDisplay.style.color = "#ff6b6b";
                } else {
                    countDisplay.style.color = "#333";
                }
            }
    
            // 增加
            function increase() {
                count++;
                updateDisplay();
                // 添加点击动画效果
                countDisplay.style.transform = "scale(1.2)";
                setTimeout(() => {
                    countDisplay.style.transform = "scale(1)";
                }, 100);
            }
    
            // 减少
            function decrease() {
                count--;
                updateDisplay();
                countDisplay.style.transform = "scale(0.8)";
                setTimeout(() => {
                    countDisplay.style.transform = "scale(1)";
                }, 100);
            }
    
            // 重置
            function reset() {
                count = 0;
                updateDisplay();
            }
    
            // 键盘控制
            document.addEventListener("keydown", (e) => {
                if (e.key === "ArrowUp" || e.key === "+") {
                    increase();
                } else if (e.key === "ArrowDown" || e.key === "-") {
                    decrease();
                } else if (e.key === "r" || e.key === "R") {
                    reset();
                }
            });
        </script>
    </body>
    </html>
    

    异步编程:Promise和async/await

    传统网页要获取新数据需要刷新整个页面,AJAX让网页可以在不刷新的情况下获取服务器数据。

    Promise基础

    Promise是ES6引入的异步编程解决方案,比回调函数更清晰。

    javascript

    // 创建Promise
    const myPromise = new Promise((resolve, reject) => {
        // 模拟异步操作
        setTimeout(() => {
            const success = true;
            if (success) {
                resolve("操作成功!");
            } else {
                reject("操作失败!");
            }
        }, 1000);
    });
    
    // 使用Promise
    myPromise
        .then(result => console.log(result))
        .catch(error => console.error(error))
        .finally(() => console.log("无论成功失败都会执行"));
    

    async/await

    async/await是Promise的语法糖,让异步代码看起来像同步代码。

    javascript

    // 定义async函数
    async function fetchUserData() {
        try {
            // await等待Promise完成
            const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
            const user = await response.json();
            console.log(user);
            return user;
        } catch (error) {
            console.error("获取数据失败:", error);
        }
    }
    
    // 调用async函数
    fetchUserData().then(user => {
        console.log("获取到的用户:", user.name);
    });
    

    fetch API获取数据

    javascript

    // 简单的GET请求
    fetch("https://jsonplaceholder.typicode.com/users")
        .then(response => {
            if (!response.ok) {
                throw new Error("网络响应失败");
            }
            return response.json();
        })
        .then(users => {
            users.forEach(user => {
                console.log(`${user.name} - ${user.email}`);
            });
        })
        .catch(error => console.error("请求失败:", error));
    
    // 更现代的async/await写法
    async function getUsers() {
        try {
            const response = await fetch("https://jsonplaceholder.typicode.com/users");
            if (!response.ok) throw new Error("获取失败");
            
            const users = await response.json();
            users.forEach(user => {
                console.log(`${user.name} - ${user.email}`);
            });
        } catch (error) {
            console.error("请求失败:", error);
        }
    }
    
    getUsers();
    
    // 发送POST请求
    async function createUser(userData) {
        try {
            const response = await fetch("https://jsonplaceholder.typicode.com/users", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json"
                },
                body: JSON.stringify(userData)
            });
            
            const result = await response.json();
            console.log("创建成功:", result);
            return result;
        } catch (error) {
            console.error("创建失败:", error);
        }
    }
    
    createUser({
        name: "张三",
        email: "zhangsan@example.com"
    });
    

    常用JavaScript库

    Lodash

    实用的工具函数库,提供数组、对象、字符串等操作的辅助函数。

    javascript

    // 安装:npm install lodash
    // 或CDN引入:<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
    
    import _ from 'lodash';
    
    // 防抖函数
    const debouncedSearch = _.debounce(searchFunction, 300);
    
    // 节流函数
    const throttledScroll = _.throttle(handleScroll, 100);
    
    // 深拷贝对象
    const copied = _.cloneDeep(originalObject);
    
    // 数组去重
    const unique = _.uniq([1, 2, 2, 3, 3, 3]); // [1, 2, 3]
    
    // 数组扁平化
    const flat = _.flattenDeep([1, [2, [3, [4]]]]); // [1, 2, 3, 4]
    
    // 查找满足条件的第一个元素
    const user = _.find(users, { active: true });
    
    // 对象合并
    const merged = _.assign({}, obj1, obj2);
    

    Axios

    比fetch更好用的HTTP请求库。

    javascript

    // 安装:npm install axios
    import axios from 'axios';
    
    // GET请求
    axios.get('https://api.example.com/users')
        .then(response => console.log(response.data));
    
    // POST请求
    axios.post('https://api.example.com/users', {
        name: '小明',
        age: 18
    })
        .then(response => console.log(response.data))
        .catch(error => console.error('请求失败:', error));
    
    // 同时发送多个请求
    async function loadData() {
        const [users, posts] = await Promise.all([
            axios.get('/api/users'),
            axios.get('/api/posts')
        ]);
        console.log(users.data, posts.data);
    }
    
    // 设置全局配置
    axios.defaults.baseURL = 'https://api.example.com';
    axios.defaults.timeout = 10000;
    axios.defaults.headers.common['Authorization'] = 'Bearer token';
    

    常见错误和调试

    语法错误

    javascript

    // 常见错误1:忘记引号配对
    let str = '这是一个字符串"; // 语法错误
    
    // 常见错误2:变量未声明
    console.log(nmae); // ReferenceError: name is not defined
    
    // 常见错误3:数组越界
    let arr = [1, 2, 3];
    console.log(arr[10]); // undefined
    
    // 常见错误4:在异步回调中访问this
    class Timer {
        constructor() {
            this.time = 0;
            setTimeout(function() {
                this.time++; // 这里的this不是Timer实例!
            }, 1000);
        }
    }
    
    // 解决:使用箭头函数或bind
    setTimeout(() => {
        this.time++;
    }, 1000);
    

    使用console调试

    javascript

    // 基本输出
    console.log("普通信息");
    
    // 警告和错误
    console.warn("警告信息");
    console.error("错误信息");
    
    // 查看对象(更详细)
    let obj = { name: "小明", age: 18 };
    console.log(obj); // 普通日志
    console.dir(obj); // 交互式属性列表
    console.table(obj); // 表格形式显示
    
    // 分组显示
    console.group("用户信息");
    console.log("姓名:", obj.name);
    console.log("年龄:", obj.age);
    console.groupEnd();
    
    // 格式化输出
    console.log("用户%s今年%d岁", obj.name, obj.age);
    
    // 计时
    console.time("循环");
    for (let i = 0; i < 10000; i++) {}
    console.timeEnd("循环");
    

    使用断点调试

    在浏览器开发者工具的Sources面板中:

    1. 点击行号添加断点
    2. 刷新页面执行到断点处暂停
    3. 在右侧面板查看变量值
    4. 使用调试工具栏单步执行

    JavaScript学习路线

    作为一个过来人,我的建议是:

    1. HTML/CSS基础:先学会写静态网页
    2. JavaScript基础:变量、函数、DOM操作
    3. ES6+新特性:箭头函数、模块化、async/await
    4. 框架学习:Vue、React、Angular选一个
    5. 工程化:Webpack、Vite、npm/yarn

    推荐的学习资源

    • MDN Web Docs:最权威的JavaScript文档
    • 《JavaScript高级程序设计》:经典书籍
    • 阮一峰的博客:中文教程写得很好
    • LeetCode:刷算法题巩固基础

    总结

    JavaScript是一门很”宽容”的语言,语法相对简单,入门门槛低,但要想精通却需要不断实践。

    今天这篇文章涵盖了JavaScript入门的大部分核心内容:

    • JavaScript简介和应用场景
    • 变量声明和数据类型
    • 运算符和表达式
    • 条件判断和循环
    • 函数定义和调用
    • DOM操作和事件处理
    • 异步编程基础
    • 常用JavaScript库
    • 调试技巧

    建议的学习方法是:多动手敲代码。不要只看教程,看完一个知识点就自己写几个例子试试。遇到问题就Google,很多你遇到的问题别人早就遇到过了。

    相关推荐

    动手实践是最好的学习方式,打开你的编辑器,开始写代码吧!