Vditor 实现混合模式评论,使用 Zustand 本地存储

Vditor 的 Markdown 格式的渲染见博客:
https://blog.csdn.net/qq_70172010/article/details/157431883?spm=1001.2014.3001.5501

实现效果

点击标题或者段落的评论按钮实现 Drawer 组件内容的渲染实现本地的评论。

创建评论数据的 Zustand store

javascript 复制代码
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

/**
 * 评论数据结构
 * @typedef {Object} Comment
 * @property {string} id - 评论唯一标识
 * @property {'heading' | 'paragraph'} targetType - 评论目标类型
 * @property {string} targetId - 目标元素 ID
 * @property {string} content - 评论内容
 * @property {string} author - 评论作者
 * @property {number} timestamp - 评论时间戳
 * @property {Comment[]} replies - 回复列表
 */

/**
 * 评论 Store - 使用 Zustand + localStorage 持久化
 */
export const useCommentStore = create(
    persist(
        (set, get) => ({
            // 状态
            comments: [],

            // Actions

            /**
             * 添加评论
             * @param {Object} commentData - 评论数据
             * @param {'heading' | 'paragraph'} commentData.targetType - 目标类型
             * @param {string} commentData.targetId - 目标 ID
             * @param {string} commentData.content - 评论内容
             * @param {string} commentData.author - 评论作者
             */
            addComment: ({ targetType, targetId, content, author }) => {
                const newComment = {
                    id: `comment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
                    targetType,
                    targetId,
                    content,
                    author,
                    timestamp: Date.now(),
                    replies: []
                };

                set((state) => ({
                    comments: [...state.comments, newComment]
                }));

                return newComment;
            },

            /**
             * 回复评论
             * @param {string} commentId - 父评论 ID
             * @param {string} content - 回复内容
             * @param {string} author - 回复作者
             */
            replyToComment: (commentId, content, author) => {
                const newReply = {
                    id: `reply-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
                    content,
                    author,
                    timestamp: Date.now()
                };

                set((state) => ({
                    comments: state.comments.map((comment) =>
                        comment.id === commentId
                            ? {
                                  ...comment,
                                  replies: [...comment.replies, newReply]
                              }
                            : comment
                    )
                }));

                return newReply;
            },

            /**
             * 删除评论
             * @param {string} commentId - 评论 ID
             */
            deleteComment: (commentId) => {
                set((state) => ({
                    comments: state.comments.filter((comment) => comment.id !== commentId)
                }));
            },

            /**
             * 删除回复
             * @param {string} commentId - 父评论 ID
             * @param {string} replyId - 回复 ID
             */
            deleteReply: (commentId, replyId) => {
                set((state) => ({
                    comments: state.comments.map((comment) =>
                        comment.id === commentId
                            ? {
                                  ...comment,
                                  replies: comment.replies.filter((reply) => reply.id !== replyId)
                              }
                            : comment
                    )
                }));
            },

            // Selectors

            /**
             * 获取指定目标的所有评论
             * @param {'heading' | 'paragraph'} targetType - 目标类型
             * @param {string} targetId - 目标 ID
             * @returns {Comment[]} 评论列表
             */
            getCommentsByTarget: (targetType, targetId) => {
                return get().comments.filter(
                    (comment) => comment.targetType === targetType && comment.targetId === targetId
                );
            },

            /**
             * 获取指定目标的所有评论数量
             * @param {'heading' | 'paragraph'} targetType - 目标类型
             * @param {string} targetId - 目标 ID
             * @returns {number} 评论数量
             */
            getCommentCount: (targetType, targetId) => {
                const comments = get().comments.filter(
                    (comment) => comment.targetType === targetType && comment.targetId === targetId
                );
                // 主评论数 + 所有回复数
                return comments.reduce((total, comment) => total + 1 + comment.replies.length, 0);
            },

            /**
             * 获取所有评论总数
             * @returns {number} 评论总数
             */
            getTotalCommentCount: () => {
                const comments = get().comments;
                return comments.reduce((total, comment) => total + 1 + comment.replies.length, 0);
            },

            /**
             * 清空所有评论
             */
            clearAllComments: () => {
                set({ comments: [] });
            }
        }),
        {
            name: 'markdown-comments-storage', // localStorage key
            getStorage: () => localStorage
        }
    )
);

创建评论浮动面板组件

javascript 复制代码
import { useState, useEffect } from 'react';
import { Avatar, Button, Input, Drawer, Badge, Empty, message } from 'antd';
import { MessageOutlined, CloseOutlined, SendOutlined, DeleteOutlined } from '@ant-design/icons';
import { useCommentStore } from './commentStore';
import styles from './CommentPanel.module.scss';

const { TextArea } = Input;

/**
 * CommentPanel - 评论浮动面板组件
 *
 * @param {Object} props
 * @param {boolean} props.open - 面板是否打开
 * @param {Function} props.onClose - 关闭面板回调
 * @param {string} props.targetType - 当前目标类型:'heading' | 'paragraph'
 * @param {string} props.targetId - 当前目标 ID
 * @param {string} props.targetTitle - 当前目标标题(用于显示)
 * @param {string} props.currentUser - 当前用户名
 */
const CommentPanel = ({
    open = false,
    onClose,
    targetType = null,
    targetId = null,
    targetTitle = '',
    currentUser = '匿名用户'
}) => {
    const [newComment, setNewComment] = useState('');
    const [replyingTo, setReplyingTo] = useState(null); // { commentId, author }
    const [replyContent, setReplyContent] = useState('');

    const {
        getCommentsByTarget,
        getCommentCount,
        addComment,
        replyToComment,
        deleteComment,
        deleteReply
    } = useCommentStore();

    // 获取当前目标的评论
    const comments = targetType && targetId ? getCommentsByTarget(targetType, targetId) : [];
    const commentCount = targetType && targetId ? getCommentCount(targetType, targetId) : 0;

    // 切换目标时清空输入
    useEffect(() => {
        setNewComment('');
        setReplyingTo(null);
        setReplyContent('');
    }, [targetId, targetType]);

    /**
     * 提交新评论
     */
    const handleSubmitComment = () => {
        if (!newComment.trim()) {
            message.warning('请输入评论内容');
            return;
        }

        if (!targetType || !targetId) {
            message.error('未选择评论目标');
            return;
        }

        addComment({
            targetType,
            targetId,
            content: newComment.trim(),
            author: currentUser
        });

        setNewComment('');
        message.success('评论发布成功');
    };

    /**
     * 提交回复
     */
    const handleSubmitReply = () => {
        if (!replyContent.trim()) {
            message.warning('请输入回复内容');
            return;
        }

        replyToComment(replyingTo.commentId, replyContent.trim(), currentUser);

        setReplyContent('');
        setReplyingTo(null);
        message.success('回复成功');
    };

    /**
     * 删除评论
     */
    const handleDeleteComment = (commentId) => {
        deleteComment(commentId);
        message.success('评论已删除');
    };

    /**
     * 删除回复
     */
    const handleDeleteReply = (commentId, replyId) => {
        deleteReply(commentId, replyId);
        message.success('回复已删除');
    };

    /**
     * 格式化时间戳
     */
    const formatTimestamp = (timestamp) => {
        const date = new Date(timestamp);
        const now = new Date();
        const diff = now - date;

        // 小于 1 分钟
        if (diff < 60000) {
            return '刚刚';
        }

        // 小于 1 小时
        if (diff < 3600000) {
            const minutes = Math.floor(diff / 60000);
            return `${minutes} 分钟前`;
        }

        // 小于 1 天
        if (diff < 86400000) {
            const hours = Math.floor(diff / 3600000);
            return `${hours} 小时前`;
        }

        // 小于 7 天
        if (diff < 604800000) {
            const days = Math.floor(diff / 86400000);
            return `${days} 天前`;
        }

        // 显示完整日期
        return date.toLocaleString('zh-CN', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute: '2-digit'
        });
    };

    /**
     * 生成头像颜色
     */
    const getAvatarColor = (name) => {
        const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#1890ff'];
        const index = name.charCodeAt(0) % colors.length;
        return colors[index];
    };

    return (
        <Drawer
            title={
                <div className={styles.drawerTitle}>
                    <span>
                        {targetType === 'heading' ? '章节' : '段落'}评论
                        {targetTitle && ` - ${targetTitle}`}
                    </span>
                </div>
            }
            placement="right"
            width={400}
            open={open}
            onClose={onClose}
            closeIcon={<CloseOutlined />}
            className={styles.commentDrawer}
            extra={
                <Badge count={commentCount} showZero>
                    <MessageOutlined style={{ fontSize: '18px' }} />
                </Badge>
            }
        >
            <div className={styles.commentPanel}>
                {/* 未选择目标时的提示 */}
                {!targetType || !targetId ? (
                    <Empty
                        description="请点击文档中的评论图标"
                        image={Empty.PRESENTED_IMAGE_SIMPLE}
                        className={styles.emptyState}
                    />
                ) : (
                    <>
                        {/* 评论列表 */}
                        <div className={styles.commentList}>
                            {comments.length === 0 ? (
                                <Empty
                                    description="暂无评论,快来发表第一条评论吧"
                                    image={Empty.PRESENTED_IMAGE_SIMPLE}
                                />
                            ) : (
                                comments.map((comment) => (
                                    <div key={comment.id} className={styles.commentItem}>
                                        {/* 主评论 */}
                                        <div className={styles.commentWrapper}>
                                            <Avatar
                                                style={{
                                                    backgroundColor: getAvatarColor(comment.author)
                                                }}
                                            >
                                                {comment.author.charAt(0).toUpperCase()}
                                            </Avatar>
                                            <div className={styles.commentBody}>
                                                <div className={styles.commentAuthor}>
                                                    <span>{comment.author}</span>
                                                    <span className={styles.commentTime}>
                                                        {formatTimestamp(comment.timestamp)}
                                                    </span>
                                                </div>
                                                <div className={styles.commentContent}>
                                                    <p>{comment.content}</p>
                                                </div>
                                                <div className={styles.commentActions}>
                                                    <Button
                                                        type="link"
                                                        size="small"
                                                        icon={<MessageOutlined />}
                                                        onClick={() =>
                                                            setReplyingTo({
                                                                commentId: comment.id,
                                                                author: comment.author
                                                            })
                                                        }
                                                    >
                                                        回复
                                                    </Button>
                                                    <Button
                                                        type="link"
                                                        size="small"
                                                        danger
                                                        icon={<DeleteOutlined />}
                                                        onClick={() =>
                                                            handleDeleteComment(comment.id)
                                                        }
                                                    >
                                                        删除
                                                    </Button>
                                                </div>
                                            </div>
                                        </div>

                                        {/* 回复列表 */}
                                        {comment.replies && comment.replies.length > 0 && (
                                            <div className={styles.replyList}>
                                                {comment.replies.map((reply) => (
                                                    <div
                                                        key={reply.id}
                                                        className={styles.replyItem}
                                                    >
                                                        <Avatar
                                                            size="small"
                                                            style={{
                                                                backgroundColor: getAvatarColor(
                                                                    reply.author
                                                                )
                                                            }}
                                                        >
                                                            {reply.author.charAt(0).toUpperCase()}
                                                        </Avatar>
                                                        <div className={styles.replyBody}>
                                                            <div className={styles.commentAuthor}>
                                                                <span>{reply.author}</span>
                                                                <span className={styles.commentTime}>
                                                                    {formatTimestamp(reply.timestamp)}
                                                                </span>
                                                            </div>
                                                            <div className={styles.commentContent}>
                                                                <p>{reply.content}</p>
                                                            </div>
                                                            <div className={styles.commentActions}>
                                                                <Button
                                                                    type="link"
                                                                    size="small"
                                                                    danger
                                                                    icon={<DeleteOutlined />}
                                                                    onClick={() =>
                                                                        handleDeleteReply(
                                                                            comment.id,
                                                                            reply.id
                                                                        )
                                                                    }
                                                                >
                                                                    删除
                                                                </Button>
                                                            </div>
                                                        </div>
                                                    </div>
                                                ))}
                                            </div>
                                        )}

                                        {/* 回复输入框 */}
                                        {replyingTo &&
                                            replyingTo.commentId === comment.id && (
                                                <div className={styles.replyInput}>
                                                    <TextArea
                                                        placeholder={`回复 @${replyingTo.author}...`}
                                                        autoSize={{ minRows: 2, maxRows: 4 }}
                                                        value={replyContent}
                                                        onChange={(e) =>
                                                            setReplyContent(e.target.value)
                                                        }
                                                        className={styles.textarea}
                                                    />
                                                    <div className={styles.replyActions}>
                                                        <Button
                                                            size="small"
                                                            onClick={() => {
                                                                setReplyingTo(null);
                                                                setReplyContent('');
                                                            }}
                                                        >
                                                            取消
                                                        </Button>
                                                        <Button
                                                            type="primary"
                                                            size="small"
                                                            icon={<SendOutlined />}
                                                            onClick={handleSubmitReply}
                                                        >
                                                            发送
                                                        </Button>
                                                    </div>
                                                </div>
                                            )}
                                    </div>
                                ))
                            )}
                        </div>

                        {/* 新评论输入框 */}
                        <div className={styles.newCommentSection}>
                            <TextArea
                                placeholder="写下你的评论..."
                                autoSize={{ minRows: 3, maxRows: 6 }}
                                value={newComment}
                                onChange={(e) => setNewComment(e.target.value)}
                                className={styles.textarea}
                            />
                            <div className={styles.newCommentActions}>
                                <span className={styles.hint}>
                                    当前以 {currentUser} 身份评论
                                </span>
                                <Button
                                    type="primary"
                                    icon={<SendOutlined />}
                                    onClick={handleSubmitComment}
                                    disabled={!newComment.trim()}
                                >
                                    发表评论
                                </Button>
                            </div>
                        </div>
                    </>
                )}
            </div>
        </Drawer>
    );
};

export default CommentPanel;

创建评论面板样式文件

css 复制代码
// 评论面板组件样式

.commentDrawer {
  :global {
    .ant-drawer-header {
      border-bottom: 1px solid #f0f0f0;
      padding: 16px 24px;
    }

    .ant-drawer-body {
      padding: 0;
      display: flex;
      flex-direction: column;
      height: calc(100vh - 56px);
    }
  }
}

.drawerTitle {
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 16px;
  font-weight: 600;
  color: #262626;
}

.commentPanel {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 24px;
  background: #fafafa;
}

.emptyState {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  min-height: 400px;
}

.commentList {
  flex: 1;
  overflow-y: auto;
  margin-bottom: 16px;
  padding-right: 8px;

  // 自定义滚动条
  &::-webkit-scrollbar {
    width: 6px;
  }

  &::-webkit-scrollbar-thumb {
    background: #d9d9d9;
    border-radius: 3px;

    &:hover {
      background: #bfbfbf;
    }
  }
}

.commentItem {
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 12px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
  transition: all 0.2s;

  &:hover {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  }
}

.commentWrapper {
  display: flex;
  gap: 12px;
}

.commentBody {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.commentAuthor {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  font-weight: 500;
  color: #262626;
}

.commentTime {
  font-size: 12px;
  font-weight: 400;
  color: #8c8c8c;
}

.commentContent {
  p {
    margin: 0;
    color: #595959;
    line-height: 1.6;
    word-break: break-word;
  }
}

.commentActions {
  display: flex;
  gap: 8px;
  margin-top: 8px;

  :global {
    .ant-btn-link {
      padding: 0 4px;
      height: auto;
      font-size: 13px;
      color: #8c8c8c;

      &:hover {
        color: #1890ff;
      }

      &.ant-btn-dangerous:hover {
        color: #ff4d4f;
      }
    }
  }
}

.replyList {
  margin-top: 12px;
  padding-left: 44px;
  border-left: 2px solid #f0f0f0;
}

.replyItem {
  display: flex;
  gap: 8px;
  padding: 8px 0;
  border-bottom: 1px solid #f0f0f0;

  &:last-child {
    border-bottom: none;
  }
}

.replyBody {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.replyInput {
  margin-top: 12px;
  padding: 12px;
  background: #f5f5f5;
  border-radius: 6px;
}

.replyActions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 8px;
}

.newCommentSection {
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}

.textarea {
  margin-bottom: 12px;

  :global {
    .ant-input {
      border-radius: 6px;
      transition: all 0.2s;

      &:focus {
        box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
      }
    }
  }
}

.newCommentActions {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.hint {
  font-size: 12px;
  color: #8c8c8c;
}

修改核心组件添加评论锚点和集成

导入相关文件

javascript 复制代码
import CommentPanel from './CommentPanel';
import { useCommentStore } from './commentStore';

初始化

javascript 复制代码
 * @param {boolean} props.enableComment - 是否启用评论功能,默认 true
 * @param {string} props.currentUser - 当前用户名,默认 '匿名用户'
javascript 复制代码
const MarkdownRenderer = ({
    content = '',
    theme = 'light',
    lang = 'zh_CN',
    className = '',
    onAfterRender,
    enableImagePreview = true,
    enableOutline = true,
    enableComment = true,
    currentUser = '匿名用户'
}) => {
javascript 复制代码
// 评论相关状态
    const [commentPanelOpen, setCommentPanelOpen] = useState(false);
    const [selectedTarget, setSelectedTarget] = useState({
        type: null, // 'heading' | 'paragraph'
        id: null,
        title: ''
    });

    const { getCommentCount } = useCommentStore();

添加评论按钮功能

javascript 复制代码
// 为标题添加 ID 和评论按钮
    const addHeadingsCommentButtons = () => {
        if (!containerRef.current || !enableComment) return;

        const headings = containerRef.current.querySelectorAll('h1, h2, h3, h4, h5, h6');

        headings.forEach((heading, index) => {
            const text = heading.textContent || '';
            const id = `heading-${index}`;

            // 为标题添加 ID(如果还没有)
            if (!heading.id) {
                heading.id = id;
            }

            // 为标题添加评论按钮
            addCommentButton(heading, 'heading', heading.id, text);
        });
    };

// 为段落添加评论按钮
    const addParagraphCommentButtons = () => {
        if (!containerRef.current || !enableComment) return;

        const paragraphs = containerRef.current.querySelectorAll('p');
        paragraphs.forEach((p, index) => {
            const id = `paragraph-${index}`;
            p.id = id;
            addCommentButton(p, 'paragraph', id, p.textContent?.substring(0, 30) + '...');
        });
    };

    // 添加评论按钮到元素
    const addCommentButton = (element, targetType, targetId, targetTitle) => {
        // 避免重复添加
        if (element.querySelector('.comment-trigger-btn')) return;

        const commentCount = getCommentCount(targetType, targetId);

        const button = document.createElement('div');
        button.className = 'comment-trigger-btn';
        button.innerHTML = `
            <span class="comment-icon">💬</span>
            ${commentCount > 0 ? `<span class="comment-count">${commentCount}</span>` : ''}
        `;

        button.style.cssText = `
            display: inline-flex;
            align-items: center;
            gap: 4px;
            margin-left: 8px;
            cursor: pointer;
            opacity: 0.6;
            transition: opacity 0.2s;
            vertical-align: middle;
        `;

        button.addEventListener('mouseenter', () => {
            button.style.opacity = '1';
        });

        button.addEventListener('mouseleave', () => {
            button.style.opacity = '0.6';
        });

        button.addEventListener('click', () => {
            setSelectedTarget({
                type: targetType,
                id: targetId,
                title: targetTitle
            });
            setCommentPanelOpen(true);
        });

        // 对于标题,追加到标题文本后面
        // 对于段落,添加到段落后面
        if (element.tagName.match(/^H[1-6]$/)) {
            const textNode = element.lastChild;
            if (textNode && textNode.nodeType === Node.TEXT_NODE) {
                const span = document.createElement('span');
                span.appendChild(textNode);
                element.appendChild(span);
            }
            element.appendChild(button);
        } else {
            element.appendChild(button);
        }
    };

渲染完成后

javascript 复制代码
// 为标题和段落添加评论按钮
                    if (enableComment) {
                        addHeadingsCommentButtons();
                        addParagraphCommentButtons();
                    }
javascript 复制代码
  添加enableComment依赖
 }, [content, theme, lang, enableImagePreview, enableOutline, enableComment]);

DOM中使用评论组件

javascript 复制代码
 {/* 评论面板 */}
            {enableComment && (
                <CommentPanel
                    open={commentPanelOpen}
                    onClose={() => setCommentPanelOpen(false)}
                    targetType={selectedTarget.type}
                    targetId={selectedTarget.id}
                    targetTitle={selectedTarget.title}
                    currentUser={currentUser}
                />
            )}
css 复制代码
// 评论触发按钮样式(全局)
    :global(.comment-trigger-btn) {
        display: inline-flex !important;
        align-items: center;
        gap: 4px;
        margin-left: 8px;
        cursor: pointer;
        opacity: 0.6;
        transition: all 0.2s;
        vertical-align: middle;
        user-select: none;

        &:hover {
            opacity: 1 !important;
            transform: scale(1.1);
        }

        .comment-icon {
            font-size: 16px;
            line-height: 1;
        }

        .comment-count {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 18px;
            height: 18px;
            padding: 0 6px;
            background: #1890ff;
            color: #fff;
            font-size: 12px;
            font-weight: 500;
            border-radius: 9px;
            line-height: 1;
        }
    }

    // 段落评论按钮特殊样式
    :global(p .comment-trigger-btn) {
        margin-left: 0;
        margin-top: 4px;
        font-size: 12px;

        .comment-icon {
            font-size: 14px;
        }
    }

如遇到?

css 复制代码
MarkdownRenderer.jsx:279 Error rendering Markdown: Event {isTrusted: true, type: 'error', target: script, currentTarget: script, eventPhase: 2, ...}

因项目中已经安装了 Vditor,但代码中使用了 CDN 加载,导致资源加载失败。直接干掉:

css 复制代码
                cdn: 'https://unpkg.com/vditor@3.11.2',

功能实现

  • 混合模式:支持标题和段落两种评论粒度
  • 浮动面板:右侧固定面板,体验流畅(类似于博客评论面板)
  • 评论回复:支持多级回复
  • 实时统计:显示评论数量
  • 持久化存储:数据自动保存到 localStorage
  • 零配置使用 :只需添加 enableComment={true}

评论功能与后端对接?

  1. 修改 `commentStore.js`,移除 persist 中间件

  2. 在 actions 中添加 API 调用

  3. 实现 fetch/axios 请求与后端同步数据

比如:

css 复制代码
addComment: async ({ targetType, targetId, content, author }) => {
    const newComment = {
        id: `comment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
        targetType,
        targetId,
        content,
        author,
        timestamp: Date.now(),
        replies: []
    };

    // 调用后端 API
    await fetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newComment)
    });

    set((state) => ({
        comments: [...state.comments, newComment]
    }));
}

当然如果是有专门的 service 层(如api.js),直接封装接口按需引入使用即可。

注意:当前文章的内容可参考官网: https://b3log.org/vditor/ 进行按需修改添加,本文只做示例的内容分享!!!

相关推荐
小二·10 小时前
Python Web 开发进阶实战 :AI 原生数字孪生 —— 在 Flask + Three.js 中构建物理世界实时仿真与优化平台
前端·人工智能·python
Amumu1213811 小时前
Vue组件化编程
前端·javascript·vue.js
We་ct11 小时前
LeetCode 6. Z 字形变换:两种解法深度解析与优化
前端·算法·leetcode·typescript
小二·12 小时前
Python Web 开发进阶实战(终章):从单体应用到 AI 原生生态 —— 45 篇技术演进全景与未来开发者生存指南
前端·人工智能·python
m0_6372565812 小时前
vue-baidu-map添加了类型组件导致非常卡顿的问题
前端·javascript·vue.js
挂机且五杀12 小时前
为什么在React地图组件里,memo 不是优化,而是生存?
前端·react.js·前端框架
RFCEO12 小时前
HTML编程 课程七、:HTML5 新增表单标签与属性
前端·html·html5·搜索框·手机号·邮箱验证·日期选择
刘一说12 小时前
Vue开发中的“v-model陷阱”:为什么它不能用于非表单元素?
前端·javascript·vue.js
利刃大大13 小时前
【Vue】组件生命周期 && 组件生命周期钩子
前端·javascript·vue.js·前端框架