阅读视图

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

掌握 Stylus:让 CSS 编写效率倍增的预处理器

掌握 Stylus:让 CSS 编写效率倍增的预处理器

在前端开发中,CSS 作为样式定义的基础语言,其编写效率和可维护性直接影响项目开发效率。而 Stylus 作为一款强大的 CSS 预处理器,凭借其简洁的语法和丰富的特性,成为提升 CSS 开发体验的利器。本文将带你深入了解 Stylus 的优势与用法,助你快速掌握这一高效工具。

什么是 Stylus?

Stylus 是一门富有表现力的 CSS 预处理器,它在传统 CSS 的基础上扩展了诸多实用功能,如变量、函数、混合(mixins)、嵌套等。这些特性不仅能大幅减少重复代码,还能让样式结构更加清晰,显著提升 CSS 的编写效率和可维护性。

需要注意的是,浏览器无法直接解析 Stylus 代码,因此必须将其编译为标准 CSS 后才能在网页中使用。

快速开始:安装与编译

安装 Stylus

通过 npm 可以轻松全局安装 Stylus:

bash

npm i -g stylus

编译 Stylus 文件

将 Stylus 文件编译为 CSS 文件的基本命令:

bash

stylus style.styl -o style.css

若需要实时编译(边写边编译),可使用 watch 模式:

bash

stylus style.styl -o style.css -w

Stylus 的核心优势

简洁的语法

相比传统 CSS,Stylus 语法更为简洁,可省略大括号、冒号和分号:

stylus

// Stylus 写法
.card
    width 45px
    height 45px

编译后生成的 CSS:

css

.card {
    width: 45px;
    height: 45px;
}

强大的嵌套功能

Stylus 支持选择器嵌套,使样式结构与 HTML 结构保持一致,增强代码的可读性和维护性:

stylus

.panel
    background #fff
    padding 10px
    
    &.active  // 表示与上级选择器同一级的 .panel.active
        border 1px solid #000
        
    .title
        font-size 16px

提升 CSS 的编程能力

Stylus 为 CSS 增添了编程特性,包括变量定义、函数、混合等,同时提供了模块化能力和作用域控制,还能自动为 CSS 属性添加浏览器前缀,解决兼容性问题。

实用布局与动画技巧

弹性布局(Flexbox)

弹性布局是移动端布局的主流方案,通过以下属性可实现灵活的父子元素布局:

  • display: flex:创建弹性布局上下文
  • 子元素会失去块级元素默认的换行特性,适合多列布局
  • 主轴对齐:justify-content
  • 侧轴对齐:align-items
  • 主轴方向:flex-direction(默认 row 水平方向,可选 column 垂直方向)
  • 子元素设置 flex: 1 可实现等比例分配空间

过渡动画(Transition)

相比 animation,transition 更简单,无需定义 keyframes,可直接为属性变化添加过渡效果:

stylus

.element
    transition all 700ms ease-in  // 所有属性变化,700ms 过渡时间,ease-in 缓动函数
    
    // 也可指定单个属性及延迟时间
    transition opacity 300ms ease-in 400ms  // 延迟 400ms 后开始,300ms 完成透明度过渡

响应式布局(媒体查询)

通过 @media 可实现不同设备的适配:

stylus

// 针对宽度小于等于 480px 的设备(如 iPhone 等移动设备)
@media (max-width: 480px)
    .container
        width 100%
        padding 5px

总结

Stylus 作为一款优秀的 CSS 预处理器,通过简化语法、增加编程特性和提供丰富功能,极大地提升了 CSS 开发效率。无论是小型项目还是大型应用,使用 Stylus 都能让样式代码更加简洁、可维护性更强。掌握 Stylus,将为你的前端开发工作带来显著的效率提升。

app里video层级最高导致全屏视频上的操作的东西显示不出来的问题

为什么出现这个问题?

UniApp 在 App 端渲染页面时,用的是一个 原生 WebView(当做浏览器看待,手机系统来展示html界面) + 原生组件混合层

  • 页面(HTML、Vue 组件)在 WebView 层
  • <video><map><canvas> 等是 原生控件层
  • 原生层始终盖在 WebView 上面;
  • CSS 的 z-indexposition: fixedoverflow 对原生控件层 完全无效
  • 不管你怎么用 z-index 调整,只要视频是原生控件,它永远在最上面。

需求效果

3df355ab-db26-4d83-97b2-523965e34cf1.jpg

6a774578-fb93-47e9-89f4-5617ac726035.jpg

解决方法

使用subNVue原子窗体开发
uni-app subNVue 原生子窗体开发指南 - DCloud问答
pages.json 页面路由 | uni-app官网(ask.dcloud.net.cn/article/359…)
subNVue是 vue 页面的子窗体,它不是全屏页面,就是用于解决 vue 页面中的层级覆盖和原生界面自定义用的。它也不是组件,就是一个原生子窗体

1. 创建主页面和操作栏页面

项目结构

pages/index/
├── index.vue          # 主页面(视频全屏播放层)
└── subNVue/
    ├── overlay.nvue   # 悬浮UI层(顶部导航 + 底部操作栏 + 用户信息)
    └── comment.nvue   # 评论弹出层(评论列表和交互)

2. pages.json 配置

 {
  "path" : "pages/index/index",
    "style": {
      "navigationStyle": "custom", 
      "app-plus": {
        "subNVues": [
          {
            "id": "overlay",
            "path": "pages/index/subnvue/overlay",
            "style": {
              "position": "absolute",
              "top": 0,
              "left": 0,
              "width": "100%",
              "height": "100%",
              "background": "transparent"
            }
          },
          {
            "id": "commentPopup",
            "path": "pages/index/subnvue/comment",
            "style": {
              "position": "dock",
              "dock": "bottom",
              "width": "100%",
              "height": "900px",
              "background": "transparent"
            }
          }
        ]
      }
  }
  }

3. 主页面 index.vue

只保留视频和顶部导航

  <template>
<view class="video-page">
<!-- 视频全屏播放层 -->
<video 
class="video-bg" 
src="/static/video.mp4"
autoplay 
loop 
:show-fullscreen-btn="false"
:show-center-play-btn="false"
:controls="false"
enable-play-gesture
objectFit="cover"
></video>
</view>
</template>

<script setup>
import { onReady } from '@dcloudio/uni-app'

let overlaySubNVue = null
let commentSubNVue = null

onReady(() => {
// #ifdef APP-PLUS
// 获取并显示overlay层
overlaySubNVue = uni.getSubNVueById('overlay')
if (overlaySubNVue) {
overlaySubNVue.show('none')
console.log('✅ overlay层已显示')
}

// 获取comment层
commentSubNVue = uni.getSubNVueById('commentPopup')
if (commentSubNVue) {
// 先显示再立即隐藏,确保层初始化
commentSubNVue.show('none')
setTimeout(() => {
commentSubNVue.hide('none')
console.log('✅ comment层已初始化并隐藏')
}, 50)
}
// #endif
})
</script>

<style scoped>
.video-page {
width: 100%;
height: 100vh;
background:  rgba(0, 0, 0, 0.9);
}

.video-bg {
width: 100%;
height: 90%;
}
</style>
  

4. 子窗体

4.1 导航栏和底部内容 overlay.nvue

<template>
  <view class="overlay-container">
    <!-- 顶部导航栏 -->
    <view class="top-nav">
      <view class="back-btn" @click="goBack">
        <image src="/static/video/Frame@2x.png" class="back-icon"></image>
      </view>
    </view>

    <!-- 底部内容容器 -->
    <view class="bottom-wrapper">
      <!-- 用户和描述区域 -->
      <view class="content-area">
        <!-- 用户信息 -->
        <view class="user-row">
          <image :src="postData.userAvatar" class="user-avatar" @click="goToUserProfile"></image>
          <text class="user-name" @click="goToUserProfile">{{ postData.username }}</text>
          <view class="btn-follow" :class="{ 'followed': postData.isFollowed }" @click="handleFollow">
            <image 
              v-if="!postData.isFollowed"
              src="/static/video/Frame 2033196032@2x.png" 
              class="btn-follow-img"
            ></image>
            <text v-else class="btn-follow-text">已关注</text>
          </view>
        </view>

        <!-- 描述文字 -->
        <text class="desc-text">{{ postData.content }}</text>
      </view>
    </view>

    <!-- 底部操作栏(移到最外层) -->
    <view class="bottom-action">
      <view class="input" @click="handleComment">
        <text class="input-placeholder">说点什么吧~</text>
      </view>
      
      <view class="actions">
        <view class="action" @click="handleLike">
          <image 
            :src="postData.isLiked ? '/static/video/喜欢 red@2x.png' : '/static/video/喜欢 (4) 1@2x.png'" 
            class="action-icon"
          ></image>
          <text class="action-num">{{ postData.likeCount }}</text>
        </view>

        <view class="action" @click="handleCollect">
          <image 
            :src="postData.isCollected ? '/static/home/收藏 (5) 1@2x.png' : '/static/home/收藏.png'" 
            class="action-icon"
          ></image>
          <text class="action-num">{{ postData.collectCount }}</text>
        </view>
        
        <view class="action" @click="handleComment">
          <image src="/static/video/评论 (1) 1@2x.png" class="action-icon"></image>
          <text class="action-num">{{ postData.commentCount }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

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

// 容器高度
const containerHeight = ref(500)

// 文章数据
const postData = reactive({
  userAvatar: '/static/video/Ellipse 216@2x.png',
  username: '栗子',
  isFollowed: false,
  content: '早上出来散步,湖边那排柳树都发芽了!看着真清爽,我家小区的树还没冒绿呢。',
  likeCount: 145,
  collectCount: 86,
  commentCount: 76,
  isLiked: false,
  isCollected: false
})

// 返回
const goBack = () => {
  uni.navigateBack()
}

// 跳转个人主页
const goToUserProfile = () => {
  uni.navigateTo({
    url: '/pages/home/home'
  })
}

// 关注/取消关注
const handleFollow = () => {
  postData.isFollowed = !postData.isFollowed
  const title = postData.isFollowed ? '关注成功' : '已取消关注'
  uni.showToast({ title, icon: 'none' })
}

// 点赞
const handleLike = () => {
  postData.isLiked = !postData.isLiked
  postData.likeCount += postData.isLiked ? 1 : -1
}

// 收藏
const handleCollect = () => {
  postData.isCollected = !postData.isCollected
  postData.collectCount += postData.isCollected ? 1 : -1
  
  if (postData.isCollected) {
    // 收藏成功,显示短时间提示
    uni.showToast({ 
      title: '收藏成功', 
      icon: 'none',
      duration: 1500
    })
  } else {
    // 取消收藏,显示长时间提示(不自动消失需要手动关闭)
    uni.showToast({ 
      title: '取消收藏', 
      icon: 'none',
      duration: 1000 // 1秒后自动消失
    })
  }
}

// 打开评论弹窗
const handleComment = () => {
  // #ifdef APP-PLUS
  // 1. 隐藏overlay层
  const overlaySubNVue = uni.getSubNVueById('overlay')
  if (overlaySubNVue) {
    overlaySubNVue.hide()
    console.log('✅ overlay层已隐藏')
  }
  
  // 2. 显示评论弹窗
  const commentSubNVue = uni.getSubNVueById('commentPopup')
  if (commentSubNVue) {
    commentSubNVue.show('slide-in-bottom', 300)
    console.log('✅ 评论弹窗已打开')
  } else {
    console.error('❌ 未找到评论弹窗')
  }
  // #endif
}
</script>

<style>
.overlay-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 750rpx;
  height: 1624rpx;
  flex: 1;
}

/* 顶部导航 */
.top-nav {
  position: absolute;
  top: 88rpx;
  left: 32rpx;
  z-index: 100;
}

.back-btn {
  width: 60rpx;
  height: 60rpx;
  justify-content: center;
  align-items: center;
  flex-direction: row;
}

.back-icon {
  width: 64rpx;
  height: 64rpx;
}

/* ==================== 底部容器 ==================== */
.bottom-wrapper {
  position: absolute;
  bottom: 130rpx;
  left: 0;
  width: 750rpx;
  background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.6) 30%, rgba(0,0,0,0.85) 100%);
}

/* ==================== 内容区域 ==================== */
.content-area {
  padding: 40rpx 32rpx 30rpx 32rpx;
}

/* 用户信息行 */
.user-row {
  flex-direction: row;
  align-items: center;
  margin-bottom: 20rpx;
}

.user-avatar {
  width: 80rpx;
  height: 80rpx;
  border-radius: 40rpx;
  border-width: 4rpx;
  border-color: #FFFFFF;
}

.user-name {
  margin-left: 20rpx;
  font-size: 36rpx;
  color: #FFFFFF;
  font-weight: 500;
}

.btn-follow {
  margin-left: 24rpx;
  width: 120rpx;
  height: 60rpx;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  border-radius: 30rpx;
}

.btn-follow.followed {
  background-color: rgba(255, 255, 255, 0.3);
  border-width: 2rpx;
  border-color: #FFFFFF;
}

.btn-follow-img {
  width: 120rpx;
  height: 60rpx;
}

.btn-follow-text {
  font-size: 26rpx;
  color: #FFFFFF;
  font-weight: 400;
}

/* 描述文字 */
.desc-text {
  font-size: 32rpx;
  line-height: 48rpx;
  color: #FFFFFF;
}

/* ==================== 底部操作栏 ==================== */
.bottom-action {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 750rpx;
  flex-direction: row;
  align-items: center;
  padding: 20rpx 32rpx 30rpx 32rpx;
  background-color:transparent;
  z-index: 10;
}

/* 输入框 */
.input {
  flex: 1;
  height: 70rpx;
  background-color: rgba(26, 26, 26, 0.8);
  border-radius: 35rpx;
  padding-left: 28rpx;
  padding-right: 28rpx;
  margin-right: 20rpx;
  justify-content: center;
}

.input-placeholder {
  font-size: 28rpx;
  color: #999999;
}

/* 操作按钮组 */
.actions {
  flex-direction: row;
  align-items: center;
}

/* 单个操作 */
.action {
  flex-direction: row;
  align-items: center;
  margin-left: 20rpx;
}

.action-icon {
  width: 50rpx;
  height: 50rpx;
}

.action-num {
  margin-left: 10rpx;
  font-size: 26rpx;
  color: #FFFFFF;
}
</style>

4.2 评论弹窗内容 `comment.nvue

<template>
  <view class="comment-popup">
    <!-- 遮罩层 -->
    <view class="mask" @click="closePopup"></view>

    <!-- 评论内容区 -->
    <view class="comment-content">
      <!-- 评论头部 -->
      <view class="comment-header">
        <text class="comment-title">全部评论 {{ commentList.length }}</text>
        <view class="close-btn" @click="closePopup">
          <text class="close-icon">×</text>
        </view>
      </view>

      <!-- 评论列表 -->
      <list class="comment-list" @loadmore="loadMoreComments">
        <cell v-for="(comment, index) in commentList" :key="comment.id">
          <view class="comment-item">
            <!-- 主评论 -->
            <view class="comment-main" @click="handleReply(comment)">
              <image :src="comment.avatar" class="comment-avatar"></image>
              <view class="comment-right">
                <view class="comment-user-info">
                  <view class="comment-user-left">
                    <text class="comment-username" :class="{ 'vip-username': comment.isVip }">{{ comment.username }}</text>
                    <image v-if="comment.isVip" src="/static/video/Group 2033195212@2x.png" class="vip-icon"></image>
                  </view>
                  <view class="comment-like" @click.stop="handleCommentLike(comment)">
                    <text class="comment-like-count">{{ comment.likeCount }}</text>
                    <image 
                      :src="comment.isLiked ? '/static/video/点赞 (3) 2@2x.png' : '/static/video/点赞 (3) 1@2x.png'" 
                      class="like-extra-icon"
                    ></image>
                  </view>
                </view>
                <text class="comment-text">{{ comment.content }}</text>
                <view class="comment-footer">
                  <text class="comment-time">{{ comment.time }}</text>
                  <text class="reply-btn">回复</text>
                </view>

                <!-- 回复列表 -->
                <view class="reply-wrapper" v-if="comment.replies && comment.replies.length > 0" @click.stop>
                  <view class="reply-list" :class="{ 'reply-list-expanded': comment.showAll }">
                    <view class="reply-item" v-for="(reply, rIndex) in comment.showReplies" :key="reply.id" @click="handleReply(reply)">
                      <image :src="reply.avatar" class="reply-avatar"></image>
                      <view class="reply-right">
                        <view class="reply-user-info">
                          <view class="reply-user-left">
                            <text class="reply-username" :class="{ 'vip-username': reply.isVip }">{{ reply.username }}</text>
                            <text class="reply-arrow" v-if="reply.replyToUsername">回复</text>
                            <text class="reply-to-username" v-if="reply.replyToUsername" :class="{ 'vip-username': reply.replyToIsVip }">{{ reply.replyToUsername }}</text>
                            <image v-if="reply.isVip" src="/static/images/huangguan.png" class="vip-icon-small"></image>
                          </view>
                          <view class="reply-like" @click.stop="handleReplyLike(reply)">
                            <text class="like-count">{{ reply.likeCount }}</text>
                            <image 
                              :src="reply.isLiked ? '/static/video/点赞 (3) 2@2x.png' : '/static/video/点赞 (3) 1@2x.png'" 
                              class="like-extra-icon-small"
                            ></image>
                          </view>
                        </view>
                        <text class="reply-text">{{ reply.content }}</text>
                        <view class="reply-footer">
                          <text class="reply-time">{{ reply.time }}</text>
                          <text class="reply-btn">回复</text>
                        </view>
                      </view>
                    </view>
                  </view>

                  <!-- 展开/收起更多回复 -->
                  <view class="expand-replies" v-if="comment.replies.length > 1" @click.stop="toggleReplies(comment)">
                    <text class="expand-line">——————</text>
                    <text class="expand-text">{{ comment.showAll ? '收起回复' : '展开' + (comment.replies.length - 1) + '条回复' }}</text>
                    <image src="/static/video/下  拉 1@2x.png" class="expand-arrow" :class="{ 'arrow-up': comment.showAll }"></image>
                  </view>
                </view>
              </view>
            </view>
          </view>
        </cell>

        <!-- 加载状态 -->
        <cell>
          <view class="load-more">
            <text class="load-more-text" v-if="loadingMore">加载中...</text>
            <text class="load-more-text" v-else-if="noMoreData">没有更多了</text>
          </view>
        </cell>
      </list>

      <!-- 底部输入区域 -->
      <view class="bottom-input-area">

        <!-- 评论输入框 -->
        <view class="comment-input-wrapper">
          <input 
            class="comment-input" 
            :value="inputComment"
            :placeholder="replyPlaceholder"
            placeholder-style="color: #999999"
            @input="onInput"
            @confirm="submitComment"
          />
          <view class="send-btn" :class="{ 'active': inputComment.trim() }" @click="submitComment">
            <text class="send-text">发送</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

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

// 加载状态
const loadingMore = ref(false)
const noMoreData = ref(false)
const currentPage = ref(1)

// 评论输入
const inputComment = ref('')
const replyingTo = ref(null)
const replyPlaceholder = ref('说点什么...')

// 评论列表
const commentList = reactive([
  {
    id: 1,
    avatar: '/static/video/Ellipse 2206@2x.png',
    username: '星子落满襟',
    isVip: true,
    content: '岁月静好',
    time: '50分钟前',
    likeCount: 16,
    isLiked: true,
    showAll: false,
    replies: [
      {
        id: 11,
        avatar: '/static/video/Ellipse 2207@2x.png',
        username: '风卷云舒时',
        isVip: false,
        content: '+1',
        time: '5小时前',
        likeCount: 2,
        isLiked: false,
        replyToUsername: '星子落满襟',
        replyToIsVip: true
      },
      {
        id: 12,
        avatar: '/static/images/myInsterest/Ellipse 217.png',
        username: '回复用户2',
        isVip: false,
        content: '同意',
        time: '4小时前',
        likeCount: 1,
        isLiked: false,
        replyToUsername: '风卷云舒时',
        replyToIsVip: false
      }
    ],
    showReplies: []
  },
  {
    id: 2,
    avatar: '/static/video/Ellipse 2208@2x.png',
    username: '巷口路灯下',
    isVip: false,
    content: '这天气太适合出门了',
    time: '1天前',
    likeCount: 15,
    isLiked: false,
    showAll: false,
    replies: [],
    showReplies: []
  },
  {
    id: 3,
    avatar: '/static/video/Ellipse 2207@2x.png',
    username: '风卷云舒时',
    isVip: true,
    content: '很棒的分享',
    time: '2天前',
    likeCount: 3,
    isLiked: false,
    showAll: false,
    replies: [],
    showReplies: []
  }
])

// 输入框输入
const onInput = (e) => {
  inputComment.value = e.detail.value
}

// 取消回复
const cancelReply = () => {
  replyingTo.value = null
  replyPlaceholder.value = '说点什么...'
  inputComment.value = ''
}

// 关闭弹窗
const closePopup = () => {
  // 清空输入和回复状态
  inputComment.value = ''
  replyingTo.value = null
  replyPlaceholder.value = '说点什么...'
  
  // #ifdef APP-PLUS
  // 1. 隐藏评论弹窗
  const wv = plus.webview.currentWebview()
  if (wv) {
    wv.hide('slide-out-bottom', 300)
    console.log('✅ 评论弹窗已关闭')
  }
  
  // 2. 延迟显示overlay层
  setTimeout(() => {
    const overlaySubNVue = uni.getSubNVueById('overlay')
    if (overlaySubNVue) {
      overlaySubNVue.show('none')
      console.log('✅ overlay层已显示')
    }
  }, 100)
  // #endif
}

// 提交评论或回复
const submitComment = () => {
  if (!inputComment.value.trim()) {
    uni.showToast({ title: '请输入内容', icon: 'none' })
    return
  }

  if (replyingTo.value) {
    // 提交回复
    const comment = commentList.find(c =>
      c.id === replyingTo.value.id ||
      (c.replies && c.replies.some(r => r.id === replyingTo.value.id))
    )

    if (comment) {
      const newReply = {
        id: Date.now(),
        avatar: '/static/video/Ellipse 216@2x.png',
        username: '我',
        isVip: false,
        content: inputComment.value,
        time: '刚刚',
        likeCount: 0,
        isLiked: false,
        replyToUsername: replyingTo.value.username,
        replyToIsVip: replyingTo.value.isVip || false
      }

      if (!comment.replies) {
        comment.replies = []
      }
      comment.replies.push(newReply)

      // 更新显示的回复
      if (!comment.showAll) {
        comment.showReplies = [comment.replies[0]]
      } else {
        comment.showReplies = comment.replies
      }

      uni.showToast({ title: '回复成功', icon: 'success' })
    }
  } else {
    // 提交评论
    const newComment = {
      id: Date.now(),
      avatar: '/static/video/Ellipse 216@2x.png',
      username: '我',
      isVip: false,
      content: inputComment.value,
      time: '刚刚',
      likeCount: 0,
      isLiked: false,
      showAll: false,
      replies: [],
      showReplies: []
    }

    commentList.unshift(newComment)
    uni.showToast({ title: '评论成功', icon: 'success' })
  }

  // 清空输入框和回复状态
  inputComment.value = ''
  replyingTo.value = null
  replyPlaceholder.value = '说点什么...'
}

// 展开/收起回复
const toggleReplies = (comment) => {
  if (comment.showAll) {
    comment.showAll = false
    comment.showReplies = [comment.replies[0]]
  } else {
    comment.showAll = true
    comment.showReplies = comment.replies
  }
}

// 评论点赞
const handleCommentLike = (comment) => {
  comment.isLiked = !comment.isLiked
  comment.likeCount += comment.isLiked ? 1 : -1
}

// 回复点赞
const handleReplyLike = (reply) => {
  reply.isLiked = !reply.isLiked
  reply.likeCount += reply.isLiked ? 1 : -1
}

// 回复评论
const handleReply = (item) => {
  replyingTo.value = item
  replyPlaceholder.value = `回复 ${item.username}:`
  
  uni.showToast({
    title: `回复 ${item.username}`,
    icon: 'none',
    duration: 1000
  })
}

// 加载更多评论
const loadMoreComments = () => {
  if (loadingMore.value || noMoreData.value) {
    return
  }

  loadingMore.value = true

  setTimeout(() => {
    const newComment = {
      id: commentList.length + 1,
      avatar: '/static/video/Ellipse 2207@2x.png',
      username: '新用户' + (commentList.length + 1),
      isVip: false,
      content: '这是新加载的评论内容',
      time: '刚刚',
      likeCount: Math.floor(Math.random() * 20),
      isLiked: false,
      showAll: false,
      replies: [],
      showReplies: []
    }

    commentList.push(newComment)
    currentPage.value++

    if (currentPage.value >= 5) {
      noMoreData.value = true
    }

    loadingMore.value = false
  }, 1000)
}

// 组件挂载
onMounted(() => {
  // 初始化评论显示的回复
  commentList.forEach(comment => {
    if (comment.replies && comment.replies.length > 0) {
      comment.showReplies = [comment.replies[0]]
    }
  })
})
</script>

<style>
.comment-popup {
  position: absolute;
  top: 0;
  left: 0;
  width: 750rpx;
  height: 1624rpx;
  justify-content: flex-end;
}

.mask {
  position: absolute;
  top: 0;
  left: 0;
  width: 750rpx;
  height: 1624rpx;
  background-color: rgba(0, 0, 0, 0.5);
}

.comment-content {
  width: 750rpx;
  height: 900rpx;
  background-color: #FFFFFF;
  border-top-left-radius: 32rpx;
  border-top-right-radius: 32rpx;
  overflow: hidden;
  flex-direction: column;
}

/* 评论头部 */
.comment-header {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  padding: 32rpx 32rpx 16rpx;
  height: 88rpx;
}


/* 评论输入框 */
.comment-input-wrapper {
  flex-direction: row;
  align-items: center;
  padding: 16rpx 32rpx 20rpx 32rpx;
  background-color: #FFFFFF;
}

.comment-input {
  flex: 1;
  height: 70rpx;
  background-color: #F5F5F5;
  border-radius: 35rpx;
  padding-left: 28rpx;
  padding-right: 28rpx;
  font-size: 28rpx;
  color: #333333;
  margin-right: 16rpx;
}

.send-btn {
  width: 120rpx;
  height: 60rpx;
  background-color: #E5E5E5;
  border-radius: 30rpx;
  justify-content: center;
  align-items: center;
}

.send-btn.active {
  background-color: #7E51FF;
}

.send-text {
  font-size: 28rpx;
  color: #fff;
}

.send-btn.active .send-text {
  color: #FFFFFF;
}

.comment-title {
  font-size: 32rpx;
  font-weight: 500;
  color: #333333;
}

.close-btn {
  width: 56rpx;
  height: 56rpx;
  justify-content: center;
  align-items: center;
  flex-direction: row;
}

.close-icon {
  font-size: 56rpx;
  color: #999999;
  line-height: 56rpx;
}

/* 评论列表 */
.comment-list {
  flex: 1;
  height: 0;
  padding-left: 32rpx;
  padding-right: 32rpx;
  padding-bottom: 0rpx;
}

/* 底部输入区域 */
.bottom-input-area {
  width: 750rpx;
  background-color: #FFFFFF;
  border-top-width: 1rpx;
  border-top-color: #F0F0F0;
}

.comment-item {
  padding-top: 24rpx;
  padding-bottom: 24rpx;
}

.comment-main {
  flex-direction: row;
}

.comment-avatar {
  width: 72rpx;
  height: 72rpx;
  border-radius: 36rpx;
}

.comment-right {
  flex: 1;
  margin-left: 20rpx;
}

.comment-user-info {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12rpx;
}

.comment-user-left {
  flex-direction: row;
  align-items: center;
}

.comment-username {
  font-size: 28rpx;
  font-weight: 500;
  color: #888888;
}

.vip-username {
  color: #7E51FF;
}

.vip-icon {
  width: 32rpx;
  height: 32rpx;
  margin-left: 8rpx;
}

.comment-text {
  font-size: 30rpx;
  line-height: 44rpx;
  color: #333333;
  margin-bottom: 12rpx;
}

.comment-footer {
  flex-direction: row;
  align-items: center;
}

.comment-time {
  font-size: 24rpx;
  color: #888888;
}

.reply-btn {
  font-size: 28rpx;
  color: #333333;
  margin-left: 12rpx;
}

.comment-like {
  flex-direction: row;
  align-items: center;
  padding-right: 37rpx;
}

.comment-like-count {
  font-size: 24rpx;
  color: #999999;
  margin-right: 2rpx;
}

.like-extra-icon {
  width: 45rpx;
  height: 45rpx;
  margin-left: 8rpx;
  margin-right: 10rpx;
}

/* 回复列表容器 */
.reply-wrapper {
  margin-top: 24rpx;
  margin-bottom: 0rpx;
}

.reply-list {
  height: 200rpx;
  overflow: hidden;
}

.reply-list-expanded {
  height: 600rpx;
}

.reply-item {
  flex-direction: row;
  margin-bottom: 24rpx;
}

.reply-avatar {
  width: 56rpx;
  height: 56rpx;
  border-radius: 28rpx;
}

.reply-right {
  flex: 1;
  margin-left: 16rpx;
}

.reply-user-info {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8rpx;
}

.reply-user-left {
  flex-direction: row;
  align-items: center;
}

.reply-username {
  font-size: 26rpx;
  font-weight: 500;
  color: #888888;
}

.reply-arrow {
  font-size: 24rpx;
  color: #CCCCCC;
  margin-left: 8rpx;
  margin-right: 8rpx;
}

.reply-to-username {
  font-size: 26rpx;
  font-weight: 500;
  color: #888888;
}

.vip-icon-small {
  width: 28rpx;
  height: 28rpx;
  margin-left: 6rpx;
}

.reply-text {
  font-size: 28rpx;
  line-height: 40rpx;
  color: #333333;
  margin-bottom: 8rpx;
}

.reply-footer {
  flex-direction: row;
  align-items: center;
}

.reply-time {
  font-size: 22rpx;
  color: #999999;
}

.reply-like {
  flex-direction: row;
  align-items: center;
  padding-right: 40rpx;
}

.like-count {
  font-size: 24rpx;
  color: #999999;
  margin-right: 6rpx;
}

.like-extra-icon-small {
  width: 45rpx;
  height: 45rpx;
  margin-left: 8rpx;
  margin-right: 10rpx;
}

/* 展开更多回复 */
.expand-replies {
  flex-direction: row;
  align-items: center;
  padding-top: 0rpx;
  padding-bottom: 16rpx;
  margin-top: 0rpx;
}

.expand-line {
  font-size: 20rpx;
  color: #E5E5E5;
  margin-right: 12rpx;
}

.expand-text {
  font-size: 26rpx;
  color: #4A6481;
  margin-right: 8rpx;
}

.expand-arrow {
  width: 24rpx;
  height: 24rpx;
}

.arrow-up {
  transform: rotate(180deg);
}

/* 加载更多 */
.load-more {
  padding-top: 32rpx;
  padding-bottom: 32rpx;
  flex-direction: row;
  align-items: center;
  justify-content: center;
}

.load-more-text {
  font-size: 24rpx;
  color: #999999;
}
</style>

5.总结

  1. 使用subNVue 将页面分为 video 以及 遮罩区域等
  2. 配置pages.json
  3. 代码拆解
  4. 注意nvue的css兼容问题 nvue默认flex布局且默认竖直排列

大模型也栽跟头的 Promise 题!来挑战一下?

🌟 开场白:Promise,你以为的它,真的是它吗?

各位掘友,大家好!

在前端的武林中,Promise 绝对是内功心法级别的存在。我们每天都在用 .then().catch(),用它来处理异步,但你有没有遇到过那种让你直呼“卧槽,这顺序不对啊!”的 Promise 题?

今天,我们就来挑战一道看似简单,实则暗藏玄机的 Promise 经典面试题。它能完美地考察你对 JavaScript 事件循环微任务队列,尤其是 Promise 状态吸收(Promise Resolution Procedure) 的理解。

如果你能准确说出下面这段代码的输出顺序,恭喜你,你的 Promise 功力至少是“内功小成”!


⚠️ 灵魂拷问面试题:输出结果是多少?

请看这段代码,并思考一下,1, 2, 3, 4, 5, 6, 7 这几个数字的打印顺序会是怎样的?

const p1 = Promise.resolve();

const p2 = new Promise((resolve) => {
  // 关键点:用 p1 来 resolve p2
  resolve(p1);
})

console.log(p1)
console.log(p2)

// p2 链
p2.then(()=>{
  console.log(1);
})
  .then(()=>{
    console.log(2);
  })
  .then(()=>{
    console.log(3);
  });

// p1 链
p1.then(()=>{
  console.log(4);
})
  .then(()=>{
    console.log(5);
  })
  .then(()=>{
    console.log(6);
  })
  .then(()=>{
    console.log(7);
  });

如果你心中已经有了答案,不妨先记下来,我们马上揭晓谜底!


✨ 核心知识点:Promise 状态吸收(State Absorption)

为什么这道题容易错?因为它引入了一个“套娃”操作:用一个 Promise (p1) 去解决另一个 Promise (p2)

这就是 Promise 规范中的一个核心机制——Promise Resolution Procedure,俗称状态吸收

🔄 状态吸收:Promise 界的“移魂大法”

想象一下,p2 是一个年轻的学徒,p1 是一个已功成名就的大侠。

当我们在 p2 的构造函数中调用 resolve(p1) 时,就相当于:

学徒 p2 对大侠 p1 说:“我决定,我的命运就由您来决定了!”

根据 Promises/A+ 规范

  1. 当一个 Promise (p2) 被另一个 Promise (p1) 解决时,p2 不会立即进入 fulfilled 状态。
  2. 相反,p2吸收(Adopt) p1 的状态。
  3. 这意味着,p2 上的所有 .then() 回调,都会被转移p1 上去执行。p2 成了一个代理(Proxy)

在本题中:

  • p1 = Promise.resolve(),它已经是 fulfilled 状态。
  • p2 吸收 p1 的状态,所以 p2 的回调 (1, 2, 3) 实际上是挂在了 p1 的回调队列中,和 p1 自己的回调 (4, 5, 6, 7) 一起排队。

总结: 所有的 .then() 回调,无论是来自 p1 链还是 p2 链,现在都在 同一个微任务队列 中,等待 p1 解决后执行。


🔧 调度分析:微任务队列的“插队”艺术

既然所有的回调都在一个队列里,那么它们的执行顺序就取决于两个因素:

  1. .then() 的调用顺序:决定了初始回调的入队顺序。
  2. Promise 链的连续性:决定了后续回调的入队和执行顺序。

第一步:初始入队

同步代码执行时,由于p2在准备阶段,所以p1.then(4) 先被调用。

  • 微任务队列初始状态:[p2准备阶段,p1.then(4),p2吸收阶段,p1.then(5)]

第二步:实际执行与链式调度

在 V8 引擎(Node.js/Chrome)中,Promise 链的调度有一个特性:当一个 Promise 链中的回调执行完毕后,它所返回的新 Promise 的下一个 .then() 回调,会被优先安排到当前微任务队列的末尾。

首先是最直观的过程:

状态吸收:准备、吸收

微队列:

  1. p2准备阶段
  2. p1推入4
  3. p2吸收阶段
  4. p1推入5
  5. p2推入1
  6. p1推入6
  7. p2推入2
  8. p1推入7
  9. p2推入3

现在来模拟执行过程:

序号 执行任务 输出 解释
1 p1.then(4) 4 注意: 尽管 p2.then(1) 先入队,但实际运行时,p1 链的第一个回调被优先执行。输出 4p1 链的下一个回调 p1.then(5) 立即入队。
2 p1.then(5) 5 p1 链的连续性得到体现,p1.then(5) 紧接着执行。输出 5p1.then(6) 立即入队。
3 p2.then(1) 1 此时,轮到 p2 链的第一个回调执行。输出 1p2 链的下一个回调 p2.then(2) 立即入队。
4 p1.then(6) 6 再次回到 p1 链。输出 6p1.then(7) 立即入队。
5 p2.then(2) 2 回到 p2 链。输出 2p2.then(3) 立即入队。
6 p1.then(7) 7 p1 链的最后一个回调。输出 7
7 p2.then(3) 3 p2 链的最后一个回调。输出 3

运行结果图

image.png

最终的正确输出顺序是:

4, 5, 1, 6, 2, 7, 3


💡 总结:面试官想考察你什么?

通过这道题,面试官想考察你的知识点清单:

  1. 同步代码优先console.log(p1)console.log(p2) 总是最先执行。
  2. Promise 状态吸收:当 resolve(Promise) 时,被解决的 Promise (p2) 会将自己的回调转嫁给传入的 Promise (p1),导致所有回调在同一个微任务队列中竞争。
  3. 微任务调度:在 V8 引擎中,Promise 链的执行具有连续性。一旦开始执行某个 Promise 链的回调,它会倾向于执行完该链中所有已准备好的后续回调,直到遇到一个尚未解决的 Promise 或队列中没有该链的后续任务为止。

记住这个机制,下次遇到 Promise 套娃题,你就能轻松应对了!希望这篇博客对你有所帮助,我们下次见!

检测题

了解状态吸收后,来看看下面的输出结果是多少呢?

async function async1() {
  console.log(1);
  await async2();
  console.log('AAA');
}

async function async2() {
  return Promise.resolve(2);
}

async1();

Promise.resolve()
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(4);
  })
  .then(() => {
    console.log(5);
  });

【uniapp】小程序体积优化,分包异步化

前言

在小程序端,分包异步化 是一个重要的减小体积的手段,下面会介绍如何在 uniapp分包异步化

跨分包自定义组件引用

在页面中正常使用: import CustomButton from "@/packageB/components/component1/index.vue";

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "Index",
        "componentPlaceholder": {
          "custom-button": "view"
        }
      }
    }
  ],
  "subPackages": [
    {
      "root": "packageA",
      "pages": [
        {
          "path": "index/index",
          "style": {
            "navigationBarTitleText": "分包页面",
            // 添加配置
            "componentPlaceholder": {
              "custom-button": "view"
            }
          }
        }
      ]
    },
    {
      "root": "packageB",
      "pages": [
        {
          "path": "index/index",
        }
      ],
    }
  ]
}

此特性依赖配置 componentPlaceholder,目前 uniapp 仅支持在 pages.json 中添加页面级别的配置,如果需要在某个组件或者页面中配置,可以使用 插件,支持 vue2vue3

跨分包 JS 代码引用

小程序端默认支持跨分包 JS 代码引用,需要写小程序原生支持的语法,不能使用静态引入或者动态引入。示例如下:

sub分包 定义 utils.js 文件

// sub/utils.js
export function add(a, b) {
    return a + b
}

sub分包 正常使用 utils.js 文件

// sub/index.vue
<template>
    <view>
        {{ count }}
        <button @tap="handleClick">add one</button>
    </view>
</template>

<script>
    import {
        add
    } from "./utils.js";

    export default {
        data() {
            return {
                count: 1
            }
        },
        methods: {
            handleClick() {
                this.count = add(this.count, 1)
            }
        }
    }
</script>

其他分包使用 sub分包utils.js 文件

// sub2/index.vue
<template>
    <view>
       {{ count }}
        <button @tap="handleClick">add two</button>
    </view>
</template>

<script>
    export default {
        data() {
            return {
                count: 1
            }
        },
        methods: {
            handleClick() {
                require('../sub/utils.js', sub_utils => {
                    this.count = sub_utils.add(this.count, 2);
                }, ({
                    mod,
                    errMsg
                }) => {
                    console.error(`path: ${mod}, ${errMsg}`)
                })
            }
        }
    }
</script>

注意:

  • 引用的文件必须存在
  • 使用小程序支持的原生语法

结语

如果这个库的插件帮助到了你,可以点个 star✨ 鼓励一下。

如果你有什么好的想法或者建议,欢迎在 github.com/uni-toolkit… 提 issue 或者 pr

深入理解 CSS 弹性布局:从传统布局到 Flex 的优雅演进

深入理解 CSS Flexbox 布局:从传统方案到现代实践

在 Web 开发中,页面布局是构建用户界面的基础。传统的布局方法,比如 floatinline-block,长期以来被广泛使用,但这些方式存在不少局限。随着 CSS3 的发展,Flexbox 成为了现代一维布局的标准工具,它简化了很多开发者的工作,尤其是在响应式设计中。

本文将带你了解 Flexbox 布局的核心概念,并与传统布局方式做对比,帮助你更好地理解并应用 Flexbox。


一、传统布局方法的痛点

在 Flexbox 出现之前,开发者常常使用 inline-blockfloat 来实现多列布局。然而,这些方法存在诸多缺陷。

1. 使用 inline-block 实现并排布局

.item {
  display: inline-block;
  width: 33.33%;
}
优点:
  • 可设置宽度和高度;
  • 支持文本对齐属性。
缺点:
  • 元素间会产生大约 4px 的空白间隙;
  • 元素宽度超出容器宽度,可能导致布局错位;
  • 必须额外处理间隙(如 HTML 注释合并、字体 hack 或设置父元素 font-size: 0);
  • 居中对齐较为复杂,响应式设计难度较大。

💡 即使设置了 width: 33.33%,由于默认间隙的存在,最终的布局宽度可能会超过 100%。

2. 使用 float 实现浮动布局

.item {
  float: left;
  width: 33.33%;
}
缺点:
  • 元素脱离文档流,可能导致父容器高度塌陷;
  • 需要手动清除浮动(clearfixoverflow: hidden);
  • 不利于响应式设计;
  • 维护成本较高,且现如今已被其他现代布局方法所取代。

二、Flexbox:现代一维布局的解决方案

Flexbox 是一种专为沿着单一方向(无论是行或列)排列子元素而设计的布局模型。它可以高效地分配空间、控制对齐方式,并极大简化了复杂布局的实现。

启用 Flexbox 布局非常简单:

.container {
  display: flex;
}

只需这一行代码,子元素就会进入弹性布局环境,成为“弹性项目”。


三、案例解析:使用 Flexbox 实现等分布局

下面是一个简单的 Flexbox 示例,展示如何使用 Flexbox 实现等分布局。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Flex Layout Example</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }

    .box {
      display: flex;
      height: 100px;
      width: 50%;
      background-color: red;
      margin-bottom: 10px;
    }

    .box:nth-child(2) {
      background-color: blue;
    }

    .item {
      flex: 1;
      font-size: 20px;
      color: black;
      text-align: center;
      line-height: 100px;
    }

    .item:nth-child(odd) {
      background-color: yellow;
    }
  </style>
</head>
<body>
  <div class="box">
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item">3</div>
  </div>
  <div class="box"></div>
</body>
</html>

关键点解析:

代码片段 作用说明
* { margin: 0; padding: 0; } 清除浏览器默认样式,避免干扰布局
box-sizing: border-box 确保盒模型计算一致,避免宽高计算问题
.box { display: flex; } 启用弹性布局容器,默认主轴为水平方向(flex-direction: row
flex: 1 子元素均分剩余空间,等效于 flex: 1 1 0%,无需手动计算百分比
.item:nth-child(odd) 奇数项背景为黄色,便于区分不同的元素
.box:nth-child(2) 第二个容器背景为蓝色,便于观察

🔍 注意:.box 宽度为 50%,三个 .item 元素通过 flex: 1 自动均分该区域,不会因屏幕尺寸变化而失调。


四、Flexbox 核心属性

属性 说明
display: flex 将容器变为弹性布局容器
flex-direction 主轴方向:row(水平)或 column(垂直)等
justify-content 主轴对齐方式:centerspace-betweenflex-start
align-items 交叉轴对齐方式:centerstretchflex-end
flex-wrap 是否允许换行:nowrap / wrap
flex 子项的缩放属性,常用值 flex: 1 表示等分空间

推荐常用组合(居中场景)

.container {
  display: flex;
  justify-content: center; /* 水平居中 */
  align-items: center;     /* 垂直居中 */
  min-height: 100vh;       /* 配合视口高度使用效果更佳 */
}

✅ 相比传统的 margin: autotransform(-50%) 方法,Flexbox 的居中更直观且兼容性好(支持 IE10+)。


五、Flexbox 与传统布局方式对比

特性 inline-block float flex
空白问题 存在间隙
宽高控制 支持 支持 更灵活
居中对齐 复杂 复杂 简单(一行代码)
等分布局 需要精确计算 不适用 flex: 1 自动均分
响应式设计 较弱 较弱
学习成本

📌 结论:对于大多数一维布局需求(如导航栏、按钮组、卡片列表),Flexbox 是最佳选择。


六、Flexbox 适用场景与注意事项

✅ 推荐使用 Flex 的场景:

  • 水平/垂直居中
  • 导航菜单、页眉页脚布局
  • 表单控件对齐
  • 移动端自适应组件
  • 动态数量的等分布局(如评分星星、标签组)

⚠️ 不推荐使用 Flex 的情况:

  • 复杂的二维网格布局 → 推荐使用 CSS Grid
  • 需要兼容 IE8/9 的项目 → Flexbox 支持 IE10+,但不支持旧版 IE

七、总结

Flexbox 是现代 CSS 布局的核心工具,解决了传统布局中的许多痛点。通过本案例可以看到:

  • 使用 display: flexflex: 1 可以轻松实现等分布局;
  • 不再需要关注空白间隙或浮动塌陷的问题;
  • 布局更具弹性,易于维护和扩展。

建议你在实际项目中实践并调整属性,进一步理解 Flexbox 的强大功能,掌握现代布局的核心技能。


如果本文对你理解 Flexbox 有帮助,欢迎点赞并分享。关注我,获取更多前端技术分享。


ECharts 全局触发click点击事件(柱状图、折线图增大点击范围)

需求

image.png

image.png

如图所示,由于图表联动需求,选中图表中某一列数据,可联动其它图表数据进行渲染。

对于柱状图 需要支持点击其背景区域也可触发点击事件;

对于折线图,需要支持点击其坐标点左右范围的区间也可触发点击事件,并且点击后要保留竖线。

Echarts 点击处理事件,通过chartInstance.on可实现,但只能点击柱状图才能触发,点击label(即:坐标文本)、点击柱状图阴影区域无法触发;通过chartInstance.getZr().on可全局监听Echarts事件,但无法准确的获取当前点击柱状的index;解决办法如下:

解决方案

处理图表全局点击事件

  chartInstance?.getZr().on("click", (params: any) => {
    const pointInPixel = [params.offsetX, params.offsetY];
    const pointInGrid = chartInstance.convertFromPixel({ seriesIndex: 0 }, pointInPixel);
    // 柱状图-竖向(数据的索引值)
    const index = pointInGrid[0];

    console.log(index)
  });

🔍 一步步解析代码

第 1 行
chartInstance?.getZr().on("click", (params) => { ... })
  • chartInstance 是你通过 echarts.init(...) 得到的图表实例。
  • .getZr() 返回的是 ECharts 内部用的 ZRender 实例,它相当于一个底层的“画布层”,能监听点击、鼠标移动等低级事件。
  • .on("click", handler) 表示注册一个画布点击事件,params 是点击事件对象。

👉 所以这一行的作用是:
在整个 ECharts 图的画布上注册一个点击事件监听器。


第 2 行
const pointInPixel = [params.offsetX, params.offsetY];
  • params.offsetX / offsetY 是鼠标点击在画布中的像素坐标。
  • 即:你点击的位置在图表的画布上的绝对坐标点。

👉 这一步是把点击点的屏幕坐标保存成数组,例如 [x, y] = [320, 180]


第 3 行
const pointInGrid = chartInstance.convertFromPixel({ seriesIndex: 0 }, pointInPixel);
  • convertFromPixel 是 ECharts 提供的一个有用方法:

    可以反向计算:“像素坐标 → 对应到数据坐标(数据索引或坐标轴数值)”

  • 第二个参数是刚才的 [x, y] 像素坐标。

  • 第一个参数 { seriesIndex: 0 } 表示使用第 0 个系列(在图表的第一个数据集)来定义转换规则。

👉 如果这是一个柱状图或折线图,调用后得到的 pointInGrid 就是:

[x轴的索引, y轴的数值]

例如点击了第 3 根柱子:

pointInGrid = [3, 200]

第 4-5 行
const index = pointInGrid[0];
console.log(index);
  • 取得反算结果中的第一个值——即 x轴索引值(数据索引)
  • 打印到控制台。

处理图表点击选中态

以上方案只是解决了图表的点击问题,有时候,比如折线图,点击后,需要留下一个选中态的竖线。解决方案如下

let permanentSelectedLine: echarts.graphic.Line | null = null; // 点击列后,选中的竖线
let lastSelectedIndex = -1; // 上次选中的列索引
const SELECTED_LINE_Z = 1000; // 选中竖线的层级

// 点击列并显示虚线
const handleColumnClick = (index: number) => {
  const chartInstance = chartRef.value?.getInstance();
  if (!chartInstance || !data.value?.dayList) return;

  const dayList = data.value.dayList;

  // 检查索引是否有效
  if (index < 0 || index >= dayList.length) return;

  // 获取选中列的日期和数据
  const selectedDate = dayList[index].statDay;
  const selectedData = {
    index,
    date: selectedDate,
    data: dayList[index]
  };

  // 触发事件,用于联动其它图表
  assetDashboardStore.setSelectedOrderIncomeTrendDate(selectedDate);

  // 删除旧的永久选中竖线
  if (permanentSelectedLine) {
    chartInstance.getZr().remove(permanentSelectedLine);
    permanentSelectedLine = null;
  }

  // 如果点击的是同一列,取消选中
  if (lastSelectedIndex === index) {
    lastSelectedIndex = -1;
    return;
  }

  lastSelectedIndex = index;

  // 计算竖线位置
  const xPixel = chartInstance.convertToPixel({ xAxisIndex: 0 }, selectedDate);

  // 获取图表高度,用于绘制竖线
  const chartHeight = chartRef.value?.$el?.clientHeight || 300;

  // 确定竖线的起始和结束位置
  const yStart = 60; // 顶部边距
  const yEnd = chartHeight - 90; // 底部边距

  // 画新竖线(永久选中线,样式对齐 trigger: "axis")
  permanentSelectedLine = new echarts.graphic.Line({
    z: SELECTED_LINE_Z, // 最高层级,确保始终显示在最上层
    shape: { x1: xPixel, y1: yStart, x2: xPixel, y2: yEnd },
    style: {
      stroke: "#666666",
      lineWidth: 1,
      lineDash: [5, 5]
    }
  });

  chartInstance.getZr().add(permanentSelectedLine);
};

🔍 解析代码

这段代码的函数叫 handleColumnClick,它允许在点击某个“列”(比如折线图或柱状图的指定 x 轴点)时执行额外操作:

  1. 在图表上绘制一条永久竖线(虚线)以表示选中项;
  2. 触发一个联动事件(更新状态给其他图表用);
  3. 支持再次点击同一列取消选中。
删除旧的竖线:
if (permanentSelectedLine) {
  chartInstance.getZr().remove(permanentSelectedLine);
  permanentSelectedLine = null;
}
  • 图上可能已经有一条虚线(上次点击生成)。
  • 这一步从画布(ZRender 层)移除旧竖线。
  • 清空变量,以便下一次重新画新的竖线。
计算竖线位置(像素坐标):
const xPixel = chartInstance.convertToPixel({ xAxisIndex: 0 }, selectedDate);
  • selectedDate 是 x 轴的数据值,比如 '2024-10-02'
  • convertToPixel() 把 “坐标点” 转成 “画布像素位置”
  • 返回的是竖线绘制的 x 坐标
获取图表高度范围,确定线的起止点:
const chartHeight = chartRef.value?.$el?.clientHeight || 300;
const yStart = 60;
const yEnd = chartHeight - 90;
  • 计算竖线上下端在画布中的像素位置。
  • 顶部留 60px,底部留 90px,让线看起来不贴边。

创建一条虚线对象:
permanentSelectedLine = new echarts.graphic.Line({
  z: SELECTED_LINE_Z,
  shape: { x1: xPixel, y1: yStart, x2: xPixel, y2: yEnd },
  style: {
    stroke: "#666666",
    lineWidth: 1,
    lineDash: [5, 5]
  }
});

📌 这块非常关键:

  • 调用了 echarts.graphic.Line ——直接使用 ECharts 底层的图形类;
  • 生成一条线段;
  • shape 决定起点终点;style 决定样式;
  • lineDash: [5,5] 表示虚线;
  • z 控制层级(保证线上浮)。

这条线就是真正的「永久选中虚线」。


将虚线添加到图表画布内:
chartInstance.getZr().add(permanentSelectedLine);
  • 调用了 ECharts 的底层绘图引擎(ZRender)的 add() 方法;
  • 这条线直接绘制在图上,不受 tooltip 控制、不会消失;

Vue3 的“批量渲染”机制

两种机制分别指:

  1. 在 1次 回调里触发 多次 computed/effect,其只会执行 1次
  2. 在 1次 回调里 多次 更新与视图相关的 ref视图更新 也只执行 1次

举两个例子:

// effectA
effect(() => {
  console.log('A:', a.value)
  if (a.value > 5) {
    // 2、再触发第2次 effectB
    b.value = a.value * 2
  }
})

// effectB
effect(() => {
  console.log('B:', b.value)
})

// 1、更新dep, 触发1次 effectA 和 1次 effectB
a.value = 6
// 实际 effectB 只会执行 1 次,而不是 2 次
btn.onclick = () => {
  count.value++
  count.value++
  count.value++
  count.value++
}

return () => {
  console.log('render call')
  return h('div', `count: ${count.value}`)
}
// 点击按钮,实际 'render call' 只会被打印1次

如何实现的?简写源码 来说明: PS: 不熟悉响应式基础的,请移步往期文章

effect 的批处理

修改响应式变量的值后,会触发 setter 里的函数 trigger,之后会沿着链表通知所有 effect

export function traggerRef(dep: RefImpl) {
  ......
    propagate(dep.subs)
  ......
}

/** 传播更新 */
export function propagate(subs: Link) {
  // 链表节点
  let link = subs
  // 收集 effect
  const queuedEffect = []
  while (link) {
    const sub = link.sub
    // 标记 dirty,防止重复触发 effect
    if (!sub.tracking && !sub.dirty) {
      sub.dirty = true
      // ......
      queuedEffect.push(sub)
      // ......
    }
    // 遍历链表
    link = link.nextSub
  }

  // 通知 effect 执行
  queuedEffect.forEach(effect => effect.notify())
}

可以看到 sub 里有一个 dirty 属性,如果同一次回调函数中,多次触发 sub,它只会被放入待执行列表 1 次,也就是不会多次执行。

注意,dirty 标志位会等 Effect 真正执行完成后才重置。

异步渲染 render

mount组件的流程是:使用VNode创建组件实例instance -> 挂载到DOM -> 更新,组件实际上就是创建了一个 Effect 来订阅更新:

const mountComponent = (vnode, container, anchor) => {
    /**
     * 1、创建组件实例
     * 2、初始化状态
     * 3、挂载到DOM
     */
    // 1 实例化
    const instance = createComponentInstance(vnode)
    // 2 初始化
    setupComponent(instance)

    const componentUpdateFn = () => {
      // 首次挂载
      if (!instance.isMounted) {
        // 得到 Virtual DOM
        const subTree = instance.render()
        // 3 挂载
        patch(null, subTree, container, anchor)
        // 保存当前 V-DOM
        instance.subTree = subTree
        // 修改标志位
        instance.isMounted = true
      } else {
        // 更新
        const preSubTree = instance.subTree
        // 获取新的 V-DOM
        const subTree = instance.render()
        // 对比新旧 VNode,更新
        patch(preSubTree, subTree, container, anchor)
        instance.subTree = subTree
      }
    }

    const effect = new ReactiveEffect(componentUpdateFn)
    const update = effect.run.bind(effect)

    instance.update = update

    effect.scheduler = () => {
      queueJob(update)
    }

    effect.run()
  }

但假如有如下例子,假如点击 1次 按钮,将打印 4次 effect execute 和 1次 render call

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body>
    <div id="app"></div>
    <button id="btn">+++</button>
    <script type="module">
      import { createApp, h, ref, effect, computed } from 'vue'

      const rootComp = {
        setup() {
          const count = ref(0)

          btn.onclick = () => {
            count.value++
            count.value++
            count.value++
            count.value++
          }

          effect(() => {
            console.log('effect execute', count.value)
          })

          return () => {
            console.log('render call')
            return h('div', `count1: ${count.value}`)
          }
        },
      }

      createApp(rootComp).mount('#app')
    </script>
  </body>
</html>

按理说 render call 也应该打印 4次,Why? 因为代码里利用 Effect.scheduler 做了 异步更新,即重写了scheduler:

const componentUpdateFn = () => {
  //......
  const update = effect.run.bind(effect)

  instance.update = update

  effect.scheduler = () => {
    queueJob(update)
  }
  //......
}

function queueJob(job) {
  Promise.resolve().then(() => {
    job()
  })
}

此时,每次 ref 更新后,不立即重置 dirty,而是等所有同步任务执行完后,再执行渲染,BINGO

用 Stylus 写 CSS 有多爽?从响应式面板案例看透它的优雅

作为前端开发者,你是否也曾被 CSS 的冗余语法、重复代码折磨过?写一个嵌套结构要反复敲选择器,改一个样式要全局搜半天?今天要聊的 Stylus,可能会让你重新爱上写样式 —— 它用极简的语法、强大的编程特性,把 CSS 变成了一门 "可编写" 的语言。

本文不会空谈理论,而是通过一个「响应式图片面板」案例,带你沉浸式体验 Stylus 的优雅:从安装到实战,从布局到交互,看完就能上手。

一、初识 Stylus:比 CSS 更懂开发者的预处理器

Stylus 是三大 CSS 预处理器(Sass、Less、Stylus)中最 "叛逆" 的一个 —— 它彻底抛弃了 CSS 的冗余语法,括号、分号、冒号都成了 "可选品"。这种极简主义,让写样式变得像写伪代码一样流畅。

1. 先装起来,跑通流程

全局安装 Stylus(需要 Node 环境):

bash

npm i -g stylus

新建style.styl文件,写完后编译成浏览器能识别的 CSS:

bash

# 单次编译:将style.styl编译为style.css
stylus style.styl -o style.css

# 实时编译:边写边更,开发必备
stylus style.styl -o style.css -w

2. 语法对比:Stylus vs 原生 CSS

同样一段样式,感受下差异:

原生 CSS

css

.card {
  width: 50px;
  height: 45px;
  background: #fff;
}
.card .title {
  font-size: 16px;
  color: #333;
}
.card.active {
  border: 1px solid #f00;
}

Stylus

stylus

.card
  width 50px
  height 45px
  background #fff
  .title
    font-size 16px
    color #333
  &.active
    border 1px solid #f00

是不是瞬间清爽了?没有括号、分号,嵌套直接用缩进(像 Python 一样),&还能轻松表示父元素自身的状态 —— 这只是 Stylus 的冰山一角。

二、实战:用 Stylus 实现响应式图片面板

我们要做的效果是:一个横向排列的图片面板,点击任意面板会展开放大,其他面板收缩;在手机端自动隐藏部分面板,适配小屏幕。

先看最终效果框架:

  • 桌面端:5 个面板横向排列,点击展开
  • 移动端(≤480px):只显示前 3 个面板,占满屏幕宽度

1. HTML 结构:极简骨架

html

预览

<div class="container">
  <div class="panel active" style="background-image: url('图片1')">
    <h3>Explore The World</h3>
  </div>
  <div class="panel" style="background-image: url('图片2')">
    <h3>Wild Forest</h3>
  </div>
  <!-- 共5个面板,省略后3个 -->
</div>

2. JavaScript 交互:点击切换激活状态

用排他思想实现 "点击谁,谁放大":

javascript

运行

const panels = document.querySelectorAll('.panel');

panels.forEach(panel => {
  panel.addEventListener('click', () => {
    // 移除其他面板的active类
    document.querySelector('.active')?.classList.remove('active');
    // 给当前面板添加active类
    panel.classList.add('active');
  });
});

3. Stylus 样式:核心代码拆解

这部分是重点,我们一步步解析 Stylus 如何用更少的代码实现复杂布局和交互。

(1)基础重置与全局布局

stylus

*
  margin 0
  padding 0

body
  display flex
  flex-direction row
  justify-content center
  align-items center
  height 100vh
  overflow hidden
  • * 选择器重置默认边距,Stylus 中直接写*+ 缩进即可
  • display flex 替代display: flex;,少写冒号和分号
  • 100vh让 body 占满全屏,overflow hidden隐藏滚动条

(2)容器与面板布局:嵌套语法的妙用

stylus

.container
  display flex
  width 90vw  // 桌面端占视口90%宽度

  .panel
    height 80vh
    border-radius 50px
    color #fff
    cursor pointer
    flex 0.5  // 未激活时占比0.5
    margin 10px
    position relative
    background-size cover
    background-position center
    background-repeat no-repeat
    transition all 700ms ease-in  // 过渡动画:所有属性变化用700ms完成

    h3
      font-size 24px
      position absolute
      left 20px
      bottom 20px
      margin 0
      opacity 0  // 初始隐藏标题
      transition opacity 700ms ease-in 400ms  // 标题延迟400ms显示

    &.active  // 激活状态(&代表父元素.panel)
      flex 5  // 激活时占比5(挤压其他面板)
      h3
        opacity 1  // 显示标题

这段代码的优雅之处:

  • 嵌套层级清晰.container包含.panel.panel包含h3,结构和 HTML 一一对应,不用反复写父选择器
  • & 符号的灵活使用&.active直接表示.panel.active,比 CSS 中重复写.panel简洁太多
  • 过渡动画简写transition all 700ms ease-in 替代冗长的transition: all 700ms ease-in;

(3)响应式适配:媒体查询的极简写法

stylus

@media (max-width 480px)
  .container
    width 100vw  // 移动端占满屏幕

  .panel:nth-of-type(4),
  .panel:nth-of-type(5)
    display none  // 隐藏第4、5个面板

对比原生 CSS 的媒体查询:

css

@media (max-width: 480px) {
  .container {
    width: 100vw;
  }
  .panel:nth-of-type(4), .panel:nth-of-type(5) {
    display: none;
  }
}

Stylus 直接省略了括号和分号,甚至连媒体查询的大括号都省了,缩进即层级 —— 写响应式布局像写普通样式一样自然。

三、Stylus 的其他 "爽点":不止于简洁

案例中用到的只是 Stylus 的基础功能,它真正强大的地方在于 "编程特性":

1. 变量:一次定义,多处复用

stylus

// 定义主题变量
primary-color = #3498db
panel-height = 80vh
border-radius = 50px

// 使用变量
.panel
  height panel-height
  border-radius border-radius
  &.active
    border 2px solid primary-color

改样式时只需改变量,不用全局搜索替换,适合大型项目。

2. 混合(Mixins):复用代码块

stylus

// 定义一个"弹性居中"的混合
flex-center()
  display flex
  justify-content center
  align-items center

// 复用混合
.container
  flex-center()  // 直接调用

.btn-group
  flex-center()  // 再次复用

减少重复代码,尤其适合封装常用布局模式(如清除浮动、响应式断点)。

3. 自动前缀:无需手动写 - webkit-

配合stylus-mixin等工具,Stylus 能自动为 CSS3 属性添加浏览器前缀:

stylus

// 写一次
transform scale(1.1)

// 编译后自动生成
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
transform: scale(1.1);

四、为什么推荐用 Stylus?

从案例和特性来看,Stylus 的核心优势在于:

  1. 极简语法:少写 80% 的冗余符号,专注逻辑而非格式
  2. 嵌套清晰:HTML 结构和 CSS 样式一一对应,降低维护成本
  3. 编程能力:变量、混合、函数等特性,让 CSS 具备 "可复用、可扩展" 能力
  4. 无缝过渡:完全兼容 CSS 语法,不会 CSS 也能快速上手

五、最后:从案例到生产

本文的案例代码可以直接运行,你可以:

  1. 替换图片 URL 为自己的资源
  2. 调整flex值和transition时间改变动画效果
  3. 新增媒体查询断点适配更多设备

如果要在生产环境使用,建议结合构建工具(如 Webpack、Vite)配置 Stylus-loader,实现自动编译和压缩。

Stylus 不是银弹,但它绝对是提升 CSS 开发效率的 "利器"。如果你受够了写冗余的 CSS,不妨从这个响应式面板案例开始,感受 Stylus 带来的优雅 —— 毕竟,谁不想用更少的代码,做更多的事呢?

Javascript常见面试题

目录

  1. ES6+ 核心特性
  2. 原型链与继承
  3. 闭包与作用域
  4. 异步编程
  5. 手写实现
  6. 进阶技巧

ES6+ 核心特性

1. let 和 const

// var 存在变量提升和函数作用域问题
console.log(a); // undefined
var a = 1;

// let 和 const 是块级作用域,不存在变量提升
console.log(b); // ReferenceError
let b = 2;

// const 声明的常量不能重新赋值
const PI = 3.14;
PI = 3.15; // TypeError

// 但可以修改对象的属性
const obj = { name: 'Tom' };
obj.name = 'Jerry'; // 可以
obj = {}; // TypeError

学习要点:

  • var、let、const 的区别
  • 暂时性死区(TDZ)
  • 块级作用域的应用场景

2. 解构赋值

数组解构

// 基础用法
const [a, b, c] = [1, 2, 3];

// 跳过某些值
const [first, , third] = [1, 2, 3];

// 默认值
const [x = 1, y = 2] = [10];
console.log(x, y); // 10, 2

// 剩余参数
const [head, ...tail] = [1, 2, 3, 4];
console.log(head); // 1
console.log(tail); // [2, 3, 4]

// 交换变量
let a = 1, b = 2;
[a, b] = [b, a];

对象解构

// 基础用法
const { name, age } = { name: 'Tom', age: 18 };

// 重命名
const { name: userName, age: userAge } = { name: 'Tom', age: 18 };

// 默认值
const { x = 10, y = 20 } = { x: 30 };
console.log(x, y); // 30, 20

// 嵌套解构
const user = {
  name: 'Tom',
  address: {
    city: 'Beijing',
    district: 'Chaoyang'
  }
};
const { address: { city } } = user;

// 函数参数解构
function getUserInfo({ name, age = 18 }) {
  console.log(name, age);
}
getUserInfo({ name: 'Tom' });

3. 箭头函数

// 基础语法
const add = (a, b) => a + b;
const square = x => x * x; // 单参数可省略括号
const greet = () => 'Hello'; // 无参数

// 返回对象需要加括号
const getUser = id => ({ id, name: 'Tom' });

// this 指向特性
const obj = {
  name: 'Tom',
  sayHi: function() {
    setTimeout(() => {
      console.log(this.name); // 'Tom',箭头函数继承外层 this
    }, 1000);
  },
  sayHello: function() {
    setTimeout(function() {
      console.log(this.name); // undefined,普通函数 this 指向 window
    }, 1000);
  }
};

箭头函数特点:

  • 没有自己的 this,继承外层作用域的 this
  • 不能作为构造函数
  • 没有 arguments 对象
  • 不能使用 yield 命令

4. 模板字符串

// 基础用法
const name = 'Tom';
const age = 18;
const message = `My name is ${name}, I'm ${age} years old.`;

// 多行字符串
const html = `
  <div>
    <h1>${name}</h1>
    <p>Age: ${age}</p>
  </div>
`;

// 表达式计算
const price = 100;
const count = 3;
console.log(`Total: ${price * count}`);

// 标签模板
function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<strong>${values[i]}</strong>` : '');
  }, '');
}

const result = highlight`Name: ${name}, Age: ${age}`;
console.log(result); // Name: <strong>Tom</strong>, Age: <strong>18</strong>

5. 扩展运算符与剩余参数

// 数组扩展运算符
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const combined = [...arr1, ...arr2]; // [1, 2, 3, 4, 5, 6]

// 数组复制
const original = [1, 2, 3];
const copy = [...original];

// 数组去重
const arr = [1, 2, 2, 3, 3, 4];
const unique = [...new Set(arr)]; // [1, 2, 3, 4]

// 对象扩展运算符
const obj1 = { a: 1, b: 2 };
const obj2 = { c: 3, d: 4 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 2, c: 3, d: 4 }

// 对象浅拷贝
const user = { name: 'Tom', age: 18 };
const userCopy = { ...user };

// 剩余参数
function sum(...numbers) {
  return numbers.reduce((total, num) => total + num, 0);
}
console.log(sum(1, 2, 3, 4)); // 10

// 结合解构
const [first, ...rest] = [1, 2, 3, 4];
const { name, ...others } = { name: 'Tom', age: 18, city: 'Beijing' };

6. 默认参数

// 基础用法
function greet(name = 'Guest') {
  return `Hello, ${name}`;
}

// 默认参数可以是表达式
function calculate(a, b = a * 2) {
  return a + b;
}

// 默认参数与解构结合
function createUser({ name = 'Anonymous', age = 0 } = {}) {
  return { name, age };
}

// 函数默认值是惰性求值的
let x = 1;
function foo(y = x) {
  console.log(y);
}
foo(); // 1
x = 2;
foo(); // 2

7. Promise

// 基础用法
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve('Success!');
    } else {
      reject('Error!');
    }
  }, 1000);
});

promise
  .then(result => console.log(result))
  .catch(error => console.error(error))
  .finally(() => console.log('Done'));

// Promise 链式调用
fetch('/api/user')
  .then(response => response.json())
  .then(data => {
    console.log(data);
    return fetch(`/api/posts/${data.id}`);
  })
  .then(response => response.json())
  .then(posts => console.log(posts))
  .catch(error => console.error(error));

// Promise.all - 并发执行,全部成功才成功
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);

Promise.all([p1, p2, p3])
  .then(results => console.log(results)); // [1, 2, 3]

// Promise.race - 返回最快的结果
Promise.race([p1, p2, p3])
  .then(result => console.log(result)); // 最快返回的那个

// Promise.allSettled - 等待全部完成,不管成功失败
Promise.allSettled([p1, p2, p3])
  .then(results => console.log(results));

// Promise.any - 只要有一个成功就成功
Promise.any([p1, p2, p3])
  .then(result => console.log(result));

8. async/await

// 基础用法
async function fetchUser() {
  try {
    const response = await fetch('/api/user');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(error);
  }
}

// 并发请求
async function fetchAll() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('/api/users').then(r => r.json()),
      fetch('/api/posts').then(r => r.json()),
      fetch('/api/comments').then(r => r.json())
    ]);
    return { users, posts, comments };
  } catch (error) {
    console.error(error);
  }
}

// 顺序执行
async function processInSequence(urls) {
  const results = [];
  for (const url of urls) {
    const response = await fetch(url);
    const data = await response.json();
    results.push(data);
  }
  return results;
}

// 错误处理
async function getUserData(id) {
  try {
    const user = await fetchUser(id);
    const posts = await fetchUserPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error('Error:', error);
    throw error; // 可以继续向上抛出
  }
}

9. Class 类

// 基础类
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }

  // 静态方法
  static create(name, age) {
    return new Person(name, age);
  }

  // getter
  get info() {
    return `${this.name}, ${this.age}`;
  }

  // setter
  set info(value) {
    const [name, age] = value.split(',');
    this.name = name;
    this.age = parseInt(age);
  }
}

// 继承
class Student extends Person {
  constructor(name, age, grade) {
    super(name, age); // 调用父类构造函数
    this.grade = grade;
  }

  sayHi() {
    super.sayHi(); // 调用父类方法
    console.log(`I'm in grade ${this.grade}`);
  }

  study() {
    console.log(`${this.name} is studying`);
  }
}

// 使用
const student = new Student('Tom', 18, 12);
student.sayHi();
student.study();

// 私有属性(ES2022)
class BankAccount {
  #balance = 0; // 私有属性

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

10. 模块化

// 导出 - module.js
export const PI = 3.14;
export function add(a, b) {
  return a + b;
}
export class Calculator {
  // ...
}

// 默认导出
export default function multiply(a, b) {
  return a * b;
}

// 导入
import multiply, { PI, add, Calculator } from './module.js';

// 全部导入
import * as math from './module.js';

// 重命名导入
import { add as sum } from './module.js';

// 仅执行模块
import './init.js';

// 动态导入
async function loadModule() {
  const module = await import('./module.js');
  module.default();
}

原型链与继承

1. 原型基础

// 构造函数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

// 在原型上添加方法
Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person = new Person('Tom', 18);
person.sayHi(); // Hi, I'm Tom

// 原型链关系
console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(person.__proto__.__proto__ === Object.prototype); // true

// 检查原型
console.log(person instanceof Person); // true
console.log(Person.prototype.isPrototypeOf(person)); // true

原型链图解:

person 
  ↓ __proto__
Person.prototype 
  ↓ __proto__
Object.prototype 
  ↓ __proto__
null

2. 继承的多种方式

1) 原型链继承

function Parent() {
  this.name = 'parent';
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child() {
  this.age = 18;
}

Child.prototype = new Parent();

const child1 = new Child();
const child2 = new Child();

// 缺点:所有实例共享引用类型属性
child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue', 'green']

2) 构造函数继承

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name); // 调用父类构造函数
  this.age = age;
}

const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);

child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue']

// 缺点:无法继承父类原型上的方法
console.log(child1.getName); // undefined

3) 组合继承(推荐)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name); // 第二次调用 Parent
  this.age = age;
}

Child.prototype = new Parent(); // 第一次调用 Parent
Child.prototype.constructor = Child;

const child1 = new Child('Tom', 18);
const child2 = new Child('Jerry', 20);

child1.colors.push('green');
console.log(child2.colors); // ['red', 'blue']
console.log(child1.getName()); // 'Tom'

// 缺点:调用了两次父类构造函数

4) 寄生组合继承(最佳)

function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue'];
}

Parent.prototype.getName = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

// 关键:使用 Object.create
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const child = new Child('Tom', 18);
console.log(child.getName()); // 'Tom'

// 封装继承函数
function inherit(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype);
  Child.prototype.constructor = Child;
}

3. Object.create 深入

// Object.create 创建一个新对象,使用现有对象作为原型
const parent = {
  name: 'parent',
  sayHi() {
    console.log(`Hi, I'm ${this.name}`);
  }
};

const child = Object.create(parent);
child.name = 'child';
child.sayHi(); // Hi, I'm child

// Object.create 的实现
function create(proto) {
  function F() {}
  F.prototype = proto;
  return new F();
}

// 创建一个没有原型的对象
const obj = Object.create(null);
console.log(obj.__proto__); // undefined

闭包与作用域

1. 作用域链

// 全局作用域
const globalVar = 'global';

function outer() {
  // 外层函数作用域
  const outerVar = 'outer';
  
  function inner() {
    // 内层函数作用域
    const innerVar = 'inner';
    console.log(globalVar); // 'global'
    console.log(outerVar);  // 'outer'
    console.log(innerVar);  // 'inner'
  }
  
  inner();
  // console.log(innerVar); // ReferenceError
}

outer();

2. 闭包原理

// 基础闭包
function createCounter() {
  let count = 0;
  return function() {
    return ++count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

// 闭包应用:私有变量
function createPerson(name) {
  let _age = 0; // 私有变量
  
  return {
    getName() {
      return name;
    },
    getAge() {
      return _age;
    },
    setAge(age) {
      if (age > 0 && age < 150) {
        _age = age;
      }
    }
  };
}

const person = createPerson('Tom');
person.setAge(18);
console.log(person.getAge()); // 18
console.log(person._age); // undefined

// 闭包应用:模块化
const calculator = (function() {
  let result = 0;
  
  return {
    add(num) {
      result += num;
      return this;
    },
    subtract(num) {
      result -= num;
      return this;
    },
    multiply(num) {
      result *= num;
      return this;
    },
    getResult() {
      return result;
    }
  };
})();

calculator.add(10).multiply(2).subtract(5).getResult(); // 15

3. 闭包常见问题

问题1:循环中的闭包

// 错误示例
for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 输出 5 个 5
  }, 1000);
}

// 解决方案1:使用 IIFE
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 0, 1, 2, 3, 4
    }, 1000);
  })(i);
}

// 解决方案2:使用 let
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2, 3, 4
  }, 1000);
}

// 解决方案3:使用 bind
for (var i = 0; i < 5; i++) {
  setTimeout(function(j) {
    console.log(j);
  }.bind(null, i), 1000);
}

问题2:内存泄漏

// 可能造成内存泄漏
function outer() {
  const largeData = new Array(1000000).fill('data');
  
  return function inner() {
    console.log('Hello');
    // inner 引用了 outer 的作用域
    // 即使不使用 largeData,它也不会被回收
  };
}

// 改进:只保留需要的数据
function outer() {
  const largeData = new Array(1000000).fill('data');
  const needed = largeData.slice(0, 10);
  
  return function inner() {
    console.log(needed); // 只保留需要的部分
  };
}

4. this 指向详解

// 1. 默认绑定
function foo() {
  console.log(this); // 严格模式: undefined, 非严格模式: window
}

// 2. 隐式绑定
const obj = {
  name: 'Tom',
  sayName() {
    console.log(this.name);
  }
};
obj.sayName(); // 'Tom'

const sayName = obj.sayName;
sayName(); // undefined,this 丢失

// 3. 显式绑定
function greet() {
  console.log(`Hello, ${this.name}`);
}

const user = { name: 'Tom' };
greet.call(user); // Hello, Tom
greet.apply(user); // Hello, Tom
const boundGreet = greet.bind(user);
boundGreet(); // Hello, Tom

// 4. new 绑定
function Person(name) {
  this.name = name;
}
const person = new Person('Tom');
console.log(person.name); // 'Tom'

// 5. 箭头函数
const obj2 = {
  name: 'Tom',
  sayName: () => {
    console.log(this.name); // 继承外层 this
  }
};

// 优先级:new > 显式绑定 > 隐式绑定 > 默认绑定

异步编程

1. 事件循环(Event Loop)

console.log('1'); // 同步任务

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步任务

// 输出顺序:1, 4, 3, 2

// 复杂示例
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
}

console.log('script start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

async1();

new Promise(resolve => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
});

console.log('script end');

/* 输出顺序:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

宏任务 vs 微任务

  • 宏任务:setTimeout, setInterval, setImmediate, I/O, UI渲染
  • 微任务:Promise.then, MutationObserver, process.nextTick

执行顺序

  1. 执行同步代码
  2. 执行所有微任务
  3. 执行一个宏任务
  4. 执行所有微任务
  5. 重复 3-4

2. Promise 进阶

实现 Promise.all

Promise.myAll = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('参数必须是数组'));
    }
    
    const results = [];
    let completedCount = 0;
    
    if (promises.length === 0) {
      resolve(results);
      return;
    }
    
    promises.forEach((promise, index) => {
      Promise.resolve(promise).then(
        value => {
          results[index] = value;
          completedCount++;
          
          if (completedCount === promises.length) {
            resolve(results);
          }
        },
        reason => {
          reject(reason);
        }
      );
    });
  });
};

实现 Promise.race

Promise.myRace = function(promises) {
  return new Promise((resolve, reject) => {
    if (!Array.isArray(promises)) {
      return reject(new TypeError('参数必须是数组'));
    }
    
    promises.forEach(promise => {
      Promise.resolve(promise).then(resolve, reject);
    });
  });
};

Promise 链式调用

// 串行执行多个异步任务
function runInSequence(tasks) {
  return tasks.reduce((promise, task) => {
    return promise.then(result => {
      return task().then(Array.prototype.concat.bind(result));
    });
  }, Promise.resolve([]));
}

// 使用示例
const tasks = [
  () => Promise.resolve(1),
  () => Promise.resolve(2),
  () => Promise.resolve(3)
];

runInSequence(tasks).then(results => {
  console.log(results); // [1, 2, 3]
});

3. async/await 进阶

并发控制

// 控制并发数量
async function asyncPool(poolLimit, array, iteratorFn) {
  const results = [];
  const executing = [];
  
  for (const [index, item] of array.entries()) {
    const promise = Promise.resolve().then(() => iteratorFn(item, array));
    results.push(promise);
    
    if (poolLimit <= array.length) {
      const e = promise.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      
      if (executing.length >= poolLimit) {
        await Promise.race(executing);
      }
    }
  }
  
  return Promise.all(results);
}

// 使用示例
const timeout = i => new Promise(resolve => setTimeout(() => resolve(i), i));
asyncPool(2, [1000, 5000, 3000, 2000], timeout).then(results => {
  console.log(results);
});

错误重试

async function retry(fn, times = 3, delay = 1000) {
  for (let i = 0; i < times; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === times - 1) {
        throw error;
      }
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// 使用示例
retry(() => fetch('/api/data'), 3, 2000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('重试失败:', error));

超时控制

function timeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      setTimeout(() => reject(new Error('Timeout')), ms);
    })
  ]);
}

// 使用示例
timeout(fetch('/api/data'), 5000)
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

手写实现

1. 防抖(Debounce)

/**
 * 防抖:在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时
 * 应用场景:搜索框输入、窗口 resize
 */
function debounce(func, wait, immediate = false) {
  let timeout;
  
  return function(...args) {
    const context = this;
    
    const later = function() {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    
    if (callNow) func.apply(context, args);
  };
}

// 使用示例
const input = document.querySelector('input');
input.addEventListener('input', debounce(function(e) {
  console.log('搜索:', e.target.value);
}, 500));

2. 节流(Throttle)

/**
 * 节流:规定时间内只触发一次
 * 应用场景:滚动事件、按钮点击
 */
function throttle(func, wait) {
  let timeout;
  let previous = 0;
  
  return function(...args) {
    const context = this;
    const now = Date.now();
    const remaining = wait - (now - previous);
    
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func.apply(context, args);
    } else if (!timeout) {
      timeout = setTimeout(() => {
        previous = Date.now();
        timeout = null;
        func.apply(context, args);
      }, remaining);
    }
  };
}

// 使用示例
window.addEventListener('scroll', throttle(function() {
  console.log('滚动位置:', window.scrollY);
}, 1000));

3. 深拷贝

/**
 * 深拷贝:完整复制对象,包括嵌套对象
 */
function deepClone(obj, hash = new WeakMap()) {
  // null 或非对象类型直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理 Date
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 处理 RegExp
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  
  // 处理循环引用
  if (hash.has(obj)) {
    return hash.get(obj);
  }
  
  // 创建新对象或数组
  const cloneObj = Array.isArray(obj) ? [] : {};
  hash.set(obj, cloneObj);
  
  // 递归拷贝
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  
  return cloneObj;
}

// 使用示例
const original = {
  name: 'Tom',
  hobbies: ['reading', 'coding'],
  address: {
    city: 'Beijing',
    district: 'Chaoyang'
  }
};

const copy = deepClone(original);
copy.address.city = 'Shanghai';
console.log(original.address.city); // 'Beijing'

4. 实现 call、apply、bind

call 实现

Function.prototype.myCall = function(context, ...args) {
  // context 为 null 或 undefined 时,指向 window
  context = context || window;
  
  // 创建唯一的 key 避免覆盖原有属性
  const fn = Symbol('fn');
  context[fn] = this;
  
  // 执行函数
  const result = context[fn](...args);
  
  // 删除添加的属性
  delete context[fn];
  
  return result;
};

// 测试
function greet(greeting, punctuation) {
  console.log(`${greeting}, I'm ${this.name}${punctuation}`);
}

const user = { name: 'Tom' };
greet.myCall(user, 'Hello', '!'); // Hello, I'm Tom!

apply 实现

Function.prototype.myApply = function(context, args = []) {
  context = context || window;
  
  const fn = Symbol('fn');
  context[fn] = this;
  
  const result = context[fn](...args);
  
  delete context[fn];
  
  return result;
};

// 测试
greet.myApply(user, ['Hi', '.']); // Hi, I'm Tom.

bind 实现

Function.prototype.myBind = function(context, ...args1) {
  const fn = this;
  
  return function(...args2) {
    // 如果是通过 new 调用,this 指向实例
    if (this instanceof fn) {
      return new fn(...args1, ...args2);
    }
    
    return fn.apply(context, [...args1, ...args2]);
  };
};

// 测试
const boundGreet = greet.myBind(user, 'Hey');
boundGreet('~'); // Hey, I'm Tom~

5. 实现 new 操作符

function myNew(Constructor, ...args) {
  // 创建一个新对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);
  
  // 执行构造函数,绑定 this
  const result = Constructor.apply(obj, args);
  
  // 如果构造函数返回对象,则返回该对象,否则返回创建的对象
  return result instanceof Object ? result : obj;
}

// 测试
function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.sayHi = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const person = myNew(Person, 'Tom', 18);
person.sayHi(); // Hi, I'm Tom

6. 实现 instanceof

function myInstanceof(obj, constructor) {
  // 获取对象的原型
  let proto = Object.getPrototypeOf(obj);
  
  // 获取构造函数的 prototype
  const prototype = constructor.prototype;
  
  // 沿着原型链查找
  while (proto) {
    if (proto === prototype) {
      return true;
    }
    proto = Object.getPrototypeOf(proto);
  }
  
  return false;
}

// 测试
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof([], Object)); // true
console.log(myInstanceof({}, Array)); // false

7. 柯里化(Curry)

/**
 * 柯里化:将多参数函数转换为单参数函数序列
 */
function curry(fn) {
  return function curried(...args) {
    // 参数够了就执行
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    
    // 参数不够继续返回函数
    return function(...args2) {
      return curried.apply(this, [...args, ...args2]);
    };
  };
}

// 使用示例
function add(a, b, c) {
  return a + b + c;
}

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

8. 发布订阅模式

class EventEmitter {
  constructor() {
    this.events = {};
  }
  
  // 订阅事件
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  
  // 发布事件
  emit(event, ...args) {
    if (this.events[event]) {
      this.events[event].forEach(callback => {
        callback(...args);
      });
    }
  }
  
  // 取消订阅
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
  
  // 只订阅一次
  once(event, callback) {
    const fn = (...args) => {
      callback(...args);
      this.off(event, fn);
    };
    this.on(event, fn);
  }
}

// 使用示例
const emitter = new EventEmitter();

function handleLogin(user) {
  console.log('用户登录:', user);
}

emitter.on('login', handleLogin);
emitter.emit('login', { name: 'Tom' }); // 用户登录: { name: 'Tom' }

emitter.off('login', handleLogin);
emitter.emit('login', { name: 'Jerry' }); // 无输出

9. 数组扁平化

// 方法1:递归
function flatten1(arr) {
  const result = [];
  
  arr.forEach(item => {
    if (Array.isArray(item)) {
      result.push(...flatten1(item));
    } else {
      result.push(item);
    }
  });
  
  return result;
}

// 方法2:reduce
function flatten2(arr) {
  return arr.reduce((acc, item) => {
    return acc.concat(Array.isArray(item) ? flatten2(item) : item);
  }, []);
}

// 方法3:使用栈
function flatten3(arr) {
  const stack = [...arr];
  const result = [];
  
  while (stack.length) {
    const item = stack.pop();
    if (Array.isArray(item)) {
      stack.push(...item);
    } else {
      result.unshift(item);
    }
  }
  
  return result;
}

// 方法4:指定深度
function flatten4(arr, depth = 1) {
  if (depth === 0) return arr;
  
  return arr.reduce((acc, item) => {
    return acc.concat(
      Array.isArray(item) ? flatten4(item, depth - 1) : item
    );
  }, []);
}

// 测试
const nested = [1, [2, [3, [4, 5]]]];
console.log(flatten1(nested)); // [1, 2, 3, 4, 5]
console.log(flatten4(nested, 2)); // [1, 2, 3, [4, 5]]

// 原生方法
console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5]

10. Promise 实现

class MyPromise {
  constructor(executor) {
    this.status = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.status === 'pending') {
        this.status = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };
    
    const reject = (reason) => {
      if (this.status === 'pending') {
        this.status = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }
  
  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err };
    
    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === 'fulfilled') {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolve(x);
          } catch (error) {
            reject(error);
          }
        });
      }
      
      if (this.status === 'rejected') {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolve(x);
          } catch (error) {
            reject(error);
          }
        });
      }
      
      if (this.status === 'pending') {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              resolve(x);
            } catch (error) {
              reject(error);
            }
          });
        });
        
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              resolve(x);
            } catch (error) {
              reject(error);
            }
          });
        });
      }
    });
    
    return promise2;
  }
  
  catch(onRejected) {
    return this.then(null, onRejected);
  }
  
  finally(callback) {
    return this.then(
      value => MyPromise.resolve(callback()).then(() => value),
      reason => MyPromise.resolve(callback()).then(() => { throw reason })
    );
  }
  
  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }
  
  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

// 测试
const promise = new MyPromise((resolve, reject) => {
  setTimeout(() => resolve('Success'), 1000);
});

promise.then(value => {
  console.log(value); // Success
});

进阶技巧

1. 数组高级操作

const users = [
  { id: 1, name: 'Tom', age: 18, city: 'Beijing' },
  { id: 2, name: 'Jerry', age: 20, city: 'Shanghai' },
  { id: 3, name: 'Mike', age: 18, city: 'Beijing' },
  { id: 4, name: 'Lucy', age: 22, city: 'Guangzhou' }
];

// 1. 分组
const groupBy = (arr, key) => {
  return arr.reduce((acc, item) => {
    (acc[item[key]] = acc[item[key]] || []).push(item);
    return acc;
  }, {});
};

const groupedByAge = groupBy(users, 'age');
// { 18: [...], 20: [...], 22: [...] }

// 2. 去重
const unique = arr => [...new Set(arr)];

// 对象数组去重
const uniqueBy = (arr, key) => {
  const seen = new Set();
  return arr.filter(item => {
    const k = item[key];
    return seen.has(k) ? false : seen.add(k);
  });
};

// 3. 排序
users.sort((a, b) => a.age - b.age); // 按年龄升序
users.sort((a, b) => b.age - a.age); // 按年龄降序

// 多条件排序
users.sort((a, b) => {
  return a.age - b.age || a.name.localeCompare(b.name);
});

// 4. 查找
const user = users.find(u => u.id === 2);
const userIndex = users.findIndex(u => u.id === 2);

// 5. 筛选
const adults = users.filter(u => u.age >= 18);

// 6. 映射
const names = users.map(u => u.name);

// 7. 累加
const totalAge = users.reduce((sum, u) => sum + u.age, 0);

// 8. 链式操作
const result = users
  .filter(u => u.age >= 18)
  .map(u => ({ ...u, isAdult: true }))
  .sort((a, b) => a.age - b.age);

2. 对象高级操作

// 1. 合并对象
const obj1 = { a: 1, b: 2 };
const obj2 = { b: 3, c: 4 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 3, c: 4 }

// 深度合并
function deepMerge(target, source) {
  for (let key in source) {
    if (source[key] instanceof Object && key in target) {
      Object.assign(source[key], deepMerge(target[key], source[key]));
    }
  }
  return Object.assign(target || {}, source);
}

// 2. 对象转数组
const obj = { a: 1, b: 2, c: 3 };
const entries = Object.entries(obj); // [['a', 1], ['b', 2], ['c', 3]]
const keys = Object.keys(obj); // ['a', 'b', 'c']
const values = Object.values(obj); // [1, 2, 3]

// 3. 数组转对象
const arr = [['a', 1], ['b', 2]];
const objFromArr = Object.fromEntries(arr); // { a: 1, b: 2 }

// 4. 对象属性筛选
function pick(obj, keys) {
  return keys.reduce((acc, key) => {
    if (obj.hasOwnProperty(key)) {
      acc[key] = obj[key];
    }
    return acc;
  }, {});
}

const user = { name: 'Tom', age: 18, email: 'tom@example.com' };
const picked = pick(user, ['name', 'age']); // { name: 'Tom', age: 18 }

// 5. 对象属性排除
function omit(obj, keys) {
  return Object.keys(obj)
    .filter(key => !keys.includes(key))
    .reduce((acc, key) => {
      acc[key] = obj[key];
      return acc;
    }, {});
}

const omitted = omit(user, ['email']); // { name: 'Tom', age: 18 }

// 6. 冻结对象
const frozen = Object.freeze({ a: 1 });
frozen.a = 2; // 无效
frozen.b = 3; // 无效

// 7. 密封对象
const sealed = Object.seal({ a: 1 });
sealed.a = 2; // 有效
sealed.b = 3; // 无效

3. 字符串技巧

// 1. 模板字符串高级用法
const tag = (strings, ...values) => {
  return strings.reduce((result, str, i) => {
    return result + str + (values[i] ? `<strong>${values[i]}</strong>` : '');
  }, '');
};

const name = 'Tom';
const age = 18;
const result = tag`Name: ${name}, Age: ${age}`;

// 2. 字符串填充
'5'.padStart(3, '0'); // '005'
'5'.padEnd(3, '0'); // '500'

// 3. 重复
'ha'.repeat(3); // 'hahaha'

// 4. 首字母大写
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);

// 5. 驼峰转换
const camelCase = str => {
  return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
};
camelCase('hello-world'); // 'helloWorld'

// 6. 蛇形转换
const snakeCase = str => {
  return str.replace(/([A-Z])/g, '_$1').toLowerCase();
};
snakeCase('helloWorld'); // 'hello_world'

// 7. 截断字符串
const truncate = (str, length) => {
  return str.length > length ? str.slice(0, length) + '...' : str;
};

4. 正则表达式

// 1. 邮箱验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
emailRegex.test('user@example.com'); // true

// 2. 手机号验证(中国)
const phoneRegex = /^1[3-9]\d{9}$/;
phoneRegex.test('13800138000'); // true

// 3. URL 验证
const urlRegex = /^https?:\/\/.+/;
urlRegex.test('https://example.com'); // true

// 4. 密码强度(至少8位,包含大小写字母和数字)
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;

// 5. 提取所有数字
const extractNumbers = str => str.match(/\d+/g);
extractNumbers('abc123def456'); // ['123', '456']

// 6. 替换
const text = 'Hello World';
text.replace(/World/, 'JavaScript'); // 'Hello JavaScript'
text.replace(/o/g, '0'); // 'Hell0 W0rld'

// 7. 命名捕获组
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = '2024-01-15'.match(dateRegex);
console.log(match.groups); // { year: '2024', month: '01', day: '15' }

5. 性能优化技巧

// 1. 使用对象查找代替 switch
// 不推荐
function getDiscount(type) {
  switch(type) {
    case 'VIP':
      return 0.8;
    case 'SVIP':
      return 0.5;
    default:
      return 1;
  }
}

// 推荐
const discountMap = {
  VIP: 0.8,
  SVIP: 0.5,
  default: 1
};

const getDiscount = type => discountMap[type] || discountMap.default;

// 2. 避免不必要的计算
// 不推荐
for (let i = 0; i < arr.length; i++) {
  // arr.length 每次都计算
}

// 推荐
const len = arr.length;
for (let i = 0; i < len; i++) {
  // 只计算一次
}

// 3. 使用 requestAnimationFrame
function animate() {
  // 动画逻辑
  requestAnimationFrame(animate);
}

// 4. 使用 Web Workers 处理复杂计算
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });
worker.onmessage = function(e) {
  console.log('结果:', e.data);
};

// 5. 使用 DocumentFragment 批量操作 DOM
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
document.querySelector('ul').appendChild(fragment);

学习资源推荐

在线文档

书籍

  • 《JavaScript 高级程序设计(第4版)》
  • 《你不知道的 JavaScript》(上、中、下)
  • 《JavaScript 设计模式与开发实践》
  • 《深入理解 ES6》

练习平台

视频课程

  • 慕课网 JavaScript 进阶课程
  • B站前端技术分享
  • 极客时间《重学前端》

学习建议

  1. 循序渐进:从基础开始,不要跳跃式学习
  2. 多写代码:理论结合实践,每个知识点都要写 Demo
  3. 阅读源码:学习优秀开源项目的代码风格和设计思想
  4. 总结归纳:定期整理笔记,构建知识体系
  5. 持续学习:JavaScript 在不断发展,保持学习热情

练习题目

基础题

  1. 实现一个函数,判断两个对象是否相等
  2. 实现数组的 map、filter、reduce 方法
  3. 实现一个简单的模板引擎
  4. 实现 Promise.all 和 Promise.race
  5. 实现一个 LRU 缓存

进阶题

  1. 实现一个完整的 Promise(符合 Promises/A+ 规范)
  2. 实现一个虚拟 DOM 和 diff 算法
  3. 实现一个简单的响应式系统(类似 Vue)
  4. 实现一个发布订阅模式的事件总线
  5. 实现一个带并发控制的请求调度器

实战项目

  1. 实现一个 TodoList 应用(包含增删改查)
  2. 实现一个图片懒加载库
  3. 实现一个无限滚动列表
  4. 实现一个拖拽排序功能
  5. 实现一个简单的打包工具

持续更新中... 🚀

祝学习顺利!如有问题欢迎交流探讨。

老板问我:AI真能一键画广州旅游路线图?我用 MCP 现场开图

老板那天凑过来:“听说AI能一键画旅游地图?真的假的?”

我笑了:“来,给您现场演示一下。”

打开电脑,三下五除二配置好环境。在对话框里输入:“生成广州旅游路线地图,要能交互的。”

3秒后,一个精美地图跃然屏上——广州塔、沙面、陈家祠清晰标注。

老板瞪大眼睛:“这就完了?太神奇了吧!”

“这就是MCP的魔力,让AI从聊天工具变成生产力神器。”

他摸着下巴:“看来我们得抓紧用起来了。”

想知道怎么实现?本文将从零开始,教你实现这个"魔法"

效果预览:

生成可交互地图的效果图.gif

MCP简要介绍

MCP(Model Context Protocol)就是能够让 AI 大模型更好地使用各类工具的一个协议。

AI 模型只是一个大脑,而 MCP 协议就是让模型加上手脚,可以帮你干更多的事情,比如:

  • 读取、修改你的本地文件
  • 使用浏览器上网查询信息
  • 查询实时交通路况
  • 生成各种图表、地图路线

总之,MCP 能干的事情可太多了!要知道,大模型本身其实只会问答,它自己并不会使用外部工具,而 MCP 的出现就等于是让大模型拥有了使用各种外部工具的能力。

不过要想使用 MCP,你还得用到一个东西叫做 MCP Host 。

MCP Host

MCP Host 就是一个支持 MCP 协议的软件环境,能"跑"AI 模型,并且让 AI 模型能用各种外部工具。

常见 MCP Host:

  • Claude Desktop
  • Cursor
  • Trae
  • Cline (我们今天要使用的例子)

我们今天使用 Cline 为例来讲讲 MCP 的使用方法。

安装Mcp host (Client)

cline是vscode的一个插件,首先我们要下载vscode编辑器,然后在插件商店搜索cline并安装,安装好了之后侧边栏就会出现一个cline图标,点一下就进入使用cline的地方。

配置Cline用的API key

紧接着,我们需要配置ai模型,cline支持接入不同模型的api,如cluade、gpt、deepseek等模型,我们这里演示使用deepseek模型进行演示。

deepseek的官方网站就有提供api,我们可以到deepseek官方网站注册登录,并充值获取api。

创建好key之后,将key给cline,填好之后,cline会引导我们来到聊天页面,我们随便问它一个问题,给他打个招呼,看他能不能够正常回复,能正常回复就说明接入deepseek模型成功了

概念解释:MCP Server 和 Tool

MCP Server

MCP Server 即 MCP 服务器,跟我们传统意义上的 Server 并没有什么太大的关系,它就是一个程序而已,只不过这个程序的执行是符合 MCP 协议的。

大部分的 MCP 服务器都是本地通过 Node 或者是 Python 启动的,只不过在使用的过程中可能会联网,当然它也可能不联网,纯本地使用也是可以的。不管是联不联网,它都可以叫做 MCP Server,本质就是给 MCP 客户端即 AI 模型提供服务的。

Tool

所以 MCP Server 本质就是一个程序,就像手机上的应用,这些应用都内置了一些功能模块,而 Tool 就是 MCP Server 程序中的一些工具函数。

可以把 MCP Server 理解为一个计算器应用,这个计算器有计算和换算两个功能,作为用户可以根据自己需求选择计算还是换算功能,而这两个功能就是 Tool。

比如我们要让 DeepSeek 生成一个可交互的广州旅游路线地图,DeepSeek 是没办法完成的,但是我们可以安装一个处理生成图表的 MCP Server,它内部包含一个函数 generate_path_mapTool,这个功能是传入地点、图的大小就可以返回路线地图。

所以我们要一个广州旅游路线地图的话,就得让cline安装处理生成图表的MCP Server,然后deepseek把地点、图的大小这些参数传给MCP Server的generate_path_map就可以拿到一个可交互的广州旅游路线地图了。

配置MCP Server

前面解释了MCP Server和Tool的概念,我们再回到cline这里继续实操。

首先我们打开进入cline,进入MCP Server 设置页面,点击“已安装”,再点击“配置MCP服务器”,之后cline就会跟我们打开一个cline_mcp_settings.json文件。如果我们想新增一个MCPServer的话,我们只需要在里面填入对应的启动命令就行了。

如下操作MCPServer配置.gif

使用他人制作的MCP Server

接下来我们来安装一个别人写好的MCP Server,我们打开mcpmarket.cn/ ,这是一个MCP Server的市场,就跟我们手机的应用市场有点像,这里面有很多别人写好的MCP Server,我们去找生成图表的MCP ,复制配置就可以生效了

生成图表的MCP Serve工具链接: mcpmarket.cn/server/680e…

跟着以下的操作图进行操作使用他人制作的MCP Server.gif

按照网站上的说明,将配置添加到 cline_mcp_settings.json 文件中

window用户

{
  "mcpServers": {
    "mcp-server-chart": {
      "command": "cmd",
      "args": ["/c", "npx", "-y", "@antv/mcp-server-chart"]
    }
  }
}

mac用户

{
  "mcpServers": {
    "mcp-server-chart": {
      "command": "npx",
      "args": ["-y", "@antv/mcp-server-chart"]
    }
  }
}

注意:电脑要装Node.js环境

没有的话要自行安装哦: nodejs.org/en/download…

实战演示

配置完成后,你就可以在 Cline 中输入:

"生成一个广州的旅游路线地图"

DeepSeek 会自动调用 MCP Server 的相关工具,为你生成一个精美的交互式地图!

生成可交互地图的效果图.gif

MCP交互流程详解

sequenceDiagram
    participant 用户
    participant Cline
    participant deepseek
    participant MCP as MCP Server
    
    用户->>Cline: 生成广州旅游路线地图
    Cline->>deepseek: 用户请求 + 可用工具列表
    deepseek->>Cline: 调用generate_interactive_map工具
    Cline->>MCP: generate_interactive_map(广州, 景点列表)
    MCP->>MCP: 生成交互式地图
    MCP->>Cline: 返回地图
    Cline->>deepseek: 工具执行结果
    deepseek->>Cline: 整理回复和说明
    Cline->>用户: 显示地图和旅游建议
  1. 用户 -> Cline: “帮我生成一个广州的旅游路线地图,要可交互的哦!”
  2. Cline -> deepseek模型::cline把用户的请求和可用的mcp-server-chart工具信息一起交给模型deepseek来想办法
  3. deepseek模型 -> Cline:deepseek模型看到请求和工具后,就想:这个任务可以用mcp-server-chart工具的generate_interactive_map功能,需要指定地点、景点和样式。于是它告诉Cline:“你去调用generate_interactive_map工具,参数是广州、这些景点和旅游路线样式。”
  4. Cline -> MCP Server : Cline就拿着这些参数去调用地图工具(mcp-server-chart)的generate_interactive_map函数。
  5. MCP Server -> Cline: 地图工具mcp-server-chart接到命令后,就忙活起来,生成一个可交互的广州旅游路线地图,然后把生成的结果返回给Cline。
  6. Cline -> deepseek模型: Cline拿到地图后,就把这个结果交给deepseek模型
  7. deepseek模型 -> Cline:模型再组织一下语言,比如解释一下地图怎么用,再传给cline
  8. Cline -> 用户: 然后Cline就把最终的回答和地图一起展示给用户。

总结

上述内容我们主要讲了4点,分别是:

  1. MCP 协议的核心概念 :让 AI 模型拥有使用外部工具的能力
  2. 完整的环境搭建 :从 VSCode 插件安装到 API 配置
  3. 实战操作流程 :配置 MCP Server 并生成交互式地图
  4. 技术原理理解 :MCP Host、MCP Server 和 Tool 的关系

🚀🚀🚀Vue3相对Vue2的全面改进与优化

1. 核心架构改进

1.1 组合式API (Composition API)

  • 设计理念:从选项式API(Options API)转变为基于函数的组合式API
  • 优势
    • 更好的逻辑复用和代码组织
    • TypeScript类型推断更友好
    • 减少模板中的this依赖
    • 更灵活的代码结构,适合复杂组件
  • 使用方式
    // Vue2 Options API
    export default {
      data() {
        return { count: 0 }
      },
      methods: {
        increment() { this.count++ }
      }
    }
    
    // Vue3 Composition API
    import { ref } from 'vue'
    export default {
      setup() {
        const count = ref(0)
        const increment = () => { count.value++ }
        return { count, increment }
      }
    }
    

1.2 TypeScript支持

  • 完全使用TypeScript重写
  • 更好的类型推断和IDE支持
  • 内置类型定义,无需额外安装
  • 泛型组件支持

1.3 模块化架构

  • 核心功能拆分为独立的包:
    • @vue/reactivity: 响应式系统
    • @vue/runtime-core: 核心运行时
    • @vue/runtime-dom: DOM平台特定实现
    • @vue/compiler-sfc: 单文件组件编译器
  • 按需引入,减小打包体积

2. 响应式系统优化

2.1 从Object.defineProperty到Proxy

  • Vue2限制
    • 无法检测对象属性的添加或删除
    • 无法检测数组索引和长度的变化
    • 必须遍历对象的所有属性进行响应式转换
  • Vue3改进
    • 基于Proxy实现,原生支持对象属性增删
    • 支持数组索引和长度变化的监听
    • 自动处理Map、Set等复杂数据类型
    • 懒响应式转换,提高初始化性能

2.2 响应式API增强

  • 新增多个响应式API:
    • reactive: 创建响应式对象
    • ref: 创建响应式引用
    • computed: 创建计算属性
    • watchEffect: 自动追踪依赖的副作用
    • watch: 精确监听特定值的变化
  • 提供细粒度控制:
    • shallowReactive/shallowRef: 浅响应式
    • readonly/shallowReadonly: 只读代理
    • toRaw: 获取原始对象

3. 性能优化

3.1 编译时优化

  • 静态提升:将静态节点和属性提升到渲染函数外部
  • PatchFlag:精确标记变化的内容,避免全量对比
  • 缓存事件处理函数:避免每次渲染都创建新函数
  • 预字符串化:将静态节点序列编译为HTML字符串

3.2 运行时优化

  • 虚拟DOM优化
    • 更小的虚拟DOM树
    • 静态标记跳过diff
    • 最长递增子序列算法优化列表更新
  • 内存优化
    • 使用WeakMap/WeakSet避免内存泄漏
    • 组件卸载时更彻底的清理
  • 体积优化
    • 模块化设计,Tree-shaking友好
    • 核心库体积减少约41%

4. 开发体验提升

4.1 模板语法增强

  • 多根节点组件(Fragments)
  • 模板中的动态指令参数
  • 内置的Teleport组件用于跨组件渲染
  • Suspense组件用于异步组件加载状态管理

4.2 更好的错误处理

  • 更友好的错误提示
  • 全局错误处理API
  • 组件生命周期钩子错误捕获

4.3 开发者工具

  • 更好的响应式数据可视化
  • 性能分析工具
  • 组件结构和依赖关系展示

5. 其他重要改进

5.1 异步组件改进

  • 更简单的异步组件API
  • 原生支持Suspense
  • 更好的TypeScript支持

5.2 自定义渲染器API

  • 更灵活的自定义渲染器创建方式
  • 便于跨平台开发
  • 简化了渲染器的实现难度

5.3 插件系统

  • 更标准化的插件安装方式
  • 支持向应用实例添加全局属性和方法

6. 代码对比示例

6.1 组件创建对比

// Vue2
new Vue({
  el: '#app',
  data: {
    message: 'Hello'
  },
  methods: {
    updateMessage() {
      this.message = 'Updated'
    }
  }
})

// Vue3
import { createApp } from 'vue'

const app = createApp({
  data() {
    return { message: 'Hello' }
  },
  methods: {
    updateMessage() {
      this.message = 'Updated'
    }
  }
})

app.mount('#app')

6.2 生命周期对比

// Vue2
export default {
  beforeCreate() { /* ... */ },
  created() { /* ... */ },
  beforeMount() { /* ... */ },
  mounted() { /* ... */ },
  beforeUpdate() { /* ... */ },
  updated() { /* ... */ },
  beforeDestroy() { /* ... */ },
  destroyed() { /* ... */ }
}

// Vue3
import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'

export default {
  setup() {
    onBeforeMount(() => { /* ... */ })
    onMounted(() => { /* ... */ })
    onBeforeUpdate(() => { /* ... */ })
    onUpdated(() => { /* ... */ })
    onBeforeUnmount(() => { /* ... */ })
    onUnmounted(() => { /* ... */ })
    
    return {}
  }
}

CSS Flex 布局比 float 更值得学

在现代 Web 开发中,布局是构建用户界面的核心环节。长期以来,CSS 的 float 属性曾是实现多列布局的主要手段,但随着 CSS 技术的发展,Flexbox(弹性盒子布局)已成为更强大、更直观、更可靠的布局方案。本文将从多个维度对比 float 与 Flex 布局,并阐明为何 Flex 布局更值得投入时间学习和使用。

1. 历史背景:从 float 到 Flexbox

float 最初设计用于实现文本环绕图片的效果(如杂志排版),并非为页面整体布局而生。然而在 CSS Grid 和 Flexbox 出现之前,开发者不得不“滥用” float 来构建复杂的多列布局,导致代码冗长、语义不清,且需大量“清除浮动”(clearfix)技巧来修复布局塌陷问题。

Flexbox 则是 W3C 专门为一维布局(行或列)设计的现代 CSS 布局模块,于 2012 年左右开始被主流浏览器支持。它从语义、功能和开发体验上彻底解决了传统布局的痛点。

2. 语义与意图更清晰

使用 float 实现布局时,代码往往与视觉效果脱节。例如:

.sidebar {
  float: left;
  width: 200px;
}
.content {
  margin-left: 220px;
}

这段代码意图是“侧边栏在左,内容区在右”,但 float 本身表达的是“元素向左浮动”,而非“创建两列布局”。而 Flex 布局则直接表达布局意图:

.container {
  display: flex;
}
.sidebar {
  width: 200px;
}
.content {
  flex: 1; /* 自动填充剩余空间 */
}

display: flex 明确告诉开发者:这是一个弹性容器,子元素将按弹性规则排列。

3. 对齐与分布更强大

float 几乎无法实现垂直居中、等高列、动态间距等常见需求,而 Flexbox 原生支持:

  • 水平/垂直居中

    .center {
      display: flex;
      justify-content: center; /* 水平居中 */
      align-items: center;     /* 垂直居中 */
    }
    
  • 等高列:Flex 容器中的子项默认拉伸至相同高度,无需 JavaScript 或 hack 技巧。

  • 动态分配空间:通过 flex-growflex-shrinkflex-basis 精细控制子项如何伸缩。

  • 响应式对齐:结合 flex-direction 可轻松实现移动端堆叠、桌面端并排的响应式布局。

4. 无需“清除浮动”

使用 float 时,父容器高度会因子元素脱离文档流而“塌陷”,必须通过 clearfix(如 overflow: hidden 或伪元素)修复:

.clearfix::after {
  content: "";
  display: table;
  clear: both;
}

而 Flex 容器天然包含其子元素,不存在高度塌陷问题,代码更简洁、更可靠。

5. 更好的可维护性与可读性

Flex 布局的代码结构清晰、逻辑直观,便于团队协作和后期维护。相比之下,基于 float 的布局常需嵌套多层 div、添加额外类名,且难以调试。

例如,实现一个三栏布局:

  • float 方案:需为每栏设置宽度、浮动方向,并为父容器清除浮动。
  • Flex 方案
    .container {
      display: flex;
    }
    .left, .right {
      width: 200px;
    }
    .main {
      flex: 1;
    }
    

仅需几行代码,语义明确,无需额外 hack。

6. 浏览器支持已无后顾之忧

虽然 Flexbox 在早期存在浏览器兼容性问题,但如今(截至 2024 年),所有主流浏览器(包括 IE11,部分支持)均良好支持 Flexbox。对于仍需支持老旧浏览器的项目,可结合 Autoprefixer 或渐进增强策略处理。

相比之下,继续使用 float 布局不仅技术落后,还会增加开发成本和出错概率。

7. 学习成本其实更低

许多初学者误以为 Flexbox 复杂,但实际上:

  • 核心属性仅需掌握 5 个:display: flexflex-directionjustify-contentalign-itemsflex
  • 一旦理解“主轴/交叉轴”概念,布局逻辑一通百通。
  • 调试工具(如 Chrome DevTools 的 Flexbox 可视化)极大提升开发效率。

float 虽看似简单,但其副作用(如脱离文档流、高度塌陷、margin 折叠等)反而让初学者陷入更多陷阱。

结语

CSS Flex 布局不是“替代” float 的权宜之计,而是现代 Web 布局的标准方案。它语义清晰、功能强大、易于维护,且已被广泛支持。对于新项目,应毫不犹豫地采用 Flexbox(或更复杂的场景使用 CSS Grid)。而 float 应仅用于其原始用途:实现文本环绕图像等内联浮动效果。

ElementUI分页器page-size切换策略:从保持当前页到智能计算的优化实践

ElementUI分页器page-size切换策略:从保持当前页到智能计算的优化实践

在数据密集型的后台管理系统中,分页器是必不可少的组件。本文将分享在使用ElementUI分页器时,page-size切换策略从"保持当前页"到更优方案的演进过程。

引言

在开发Vue后台管理系统时,ElementUI的分页器组件el-pagination是我们经常使用的工具。然而,在实际业务中,我们发现了一个看似简单却影响用户体验的问题:当用户切换每页显示条数(page-size)时,当前页码(current-page)会被重置为1

这种默认行为在某些场景下会给用户带来困扰,特别是当用户已经浏览到较深页码时。本文将分享我们在项目中对这个问题的思考、实践和优化过程。

问题背景

在我们的用户管理页面中,用户经常需要:

  1. 浏览用户列表(默认每页20条)
  2. 跳转到第5页查看特定用户
  3. 切换到每页50条以查看更多数据

按照ElementUI的默认行为,第三步操作会导致页面跳回第一页,用户需要重新导航到第5页,这种体验很不友好。

第一阶段:保持当前页策略

实现方案

我们最初的解决方案很简单:在@size-change事件中保持当前页码不变。

javascript

复制下载

handleSizeChange(newSize) {
  this.pageSize = newSize;
  // 保持当前页码不变,不执行 this.currentPage = 1
  this.fetchData();
}

存在的问题

在实际使用中,我们发现这种策略存在几个严重问题:

1. 数据重复或缺失

假设有101条数据,每页显示10条:

  • 用户在第11页(显示第101条数据)
  • 切换到每页50条后,系统仍显示第11页
  • 但第11页实际上已经超出了数据范围(101条数据,每页50条,实际只有3页)

javascript

复制下载

// 问题示例
原始数据:101条,pageSize=10,currentPage=11(显示第101条数据)
切换后:pageSize=50,currentPage=11(但实际只有3页,第11页无数据)
2. 用户体验困惑

用户看到空白页或错误提示,无法理解为什么切换显示条数会导致数据消失。

3. 业务逻辑混乱

在某些需要精确定位数据的场景(如审核、审批),这种数据错位可能导致严重的工作失误。

适用场景分析

实际上,"保持当前页"策略只在极少数场景下适用:

  • 数据量极大且连续性强
  • 用户对数据定位有精确记忆
  • 业务对数据连续性要求高于准确性

在我们的用户管理系统中,这些条件都不满足。

第二阶段:解决方案的演进

方案一:智能重新计算策略

这是我们最终采用的主要方案,核心思想是根据当前显示的数据位置重新计算合理的页码。

实现原理

javascript

复制下载

handleSizeChange(newSize) {
  const oldSize = this.pageSize;
  const oldPage = this.currentPage;
  
  // 计算当前页第一条数据的全局索引
  const firstItemIndex = (oldPage - 1) * oldSize;
  
  // 根据新pageSize重新计算页码
  this.currentPage = Math.floor(firstItemIndex / newSize) + 1;
  this.pageSize = newSize;
  
  this.fetchData();
}
业务场景示例

假设用户正在审核用户提交的申请:

javascript

复制下载

// 用户操作流程:
1. 每页10条,浏览到第5页(查看第41-50条申请)
2. 觉得翻页太麻烦,切换到每页50// 智能重新计算:
原始:pageSize=10, currentPage=5, 第一条数据索引=(5-1)*10=40
重新计算:newSize=50, newPage=floor(40/50)+1=1
结果:切换到第1页,显示第1-50条申请(包含用户之前查看的数据)
优势
  • 保持用户浏览的连续性
  • 避免数据缺失或重复
  • 逻辑合理,符合用户直觉
适用场景
  • 数据浏览和查阅
  • 需要保持上下文的操作
  • 大多数常规的分页场景

方案二:重置到第一页策略

这是ElementUI的默认行为,但在理解其价值后,我们在特定场景中主动采用这种策略。

实现原理

javascript

复制下载

handleSizeChange(newSize) {
  this.pageSize = newSize;
  this.currentPage = 1; // 明确重置到第一页
  this.fetchData();
}
业务场景示例

在数据统计和分析页面:

javascript

复制下载

// 用户操作流程:
1. 每页显示20条统计数据,在第3页查看特定数据段
2. 切换到每页100条以查看更长时间跨度的趋势

// 重置到第一页的优势:
- 统计数据的完整性(从起始点开始查看)
- 避免跨页数据对比造成的误解
- 符合分析类操作的思维模式
优势
  • 保证数据展示的完整性
  • 避免复杂的边界情况处理
  • 行为可预测,不会让用户困惑
适用场景
  • 数据分析和统计页面
  • 搜索结果展示
  • 需要从头开始浏览的场景

完整实现代码

下面是我们项目中最终的完整实现:

vue

复制下载

<template>
  <div class="pagination-demo">
    <!-- 策略选择器 -->
    <div class="strategy-selector">
      <el-radio-group v-model="strategy" @change="handleStrategyChange">
        <el-radio label="smart">智能重新计算</el-radio>
        <el-radio label="reset">重置到第一页</el-radio>
      </el-radio-group>
    </div>

    <!-- 数据表格 -->
    <el-table :data="currentPageData" border>
      <el-table-column prop="id" label="ID" width="80"></el-table-column>
      <el-table-column prop="name" label="姓名"></el-table-column>
      <el-table-column prop="email" label="邮箱"></el-table-column>
      <el-table-column prop="status" label="状态"></el-table-column>
    </el-table>

    <!-- 分页器 -->
    <el-pagination
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
      :current-page="currentPage"
      :page-sizes="[10, 20, 50, 100]"
      :page-size="pageSize"
      layout="total, sizes, prev, pager, next, jumper"
      :total="total">
    </el-pagination>

    <!-- 状态信息 -->
    <div class="page-info">
      当前页: {{ currentPage }} | 
      每页: {{ pageSize }}条 | 
      总数据: {{ total }}条 |
      显示: {{ startIndex }}-{{ endIndex }}条
    </div>
  </div>
</template>

<script>
export default {
  name: 'SmartPagination',
  data() {
    return {
      strategy: 'smart', // 'smart' 或 'reset'
      currentPage: 1,
      pageSize: 20,
      total: 0,
      allData: [],
      currentPageData: []
    }
  },
  computed: {
    startIndex() {
      return (this.currentPage - 1) * this.pageSize + 1;
    },
    endIndex() {
      return Math.min(this.currentPage * this.pageSize, this.total);
    }
  },
  methods: {
    // 处理每页条数变化
    handleSizeChange(newSize) {
      const oldSize = this.pageSize;
      
      if (this.strategy === 'smart') {
        // 智能重新计算策略
        const firstItemIndex = (this.currentPage - 1) * oldSize;
        this.currentPage = Math.floor(firstItemIndex / newSize) + 1;
      } else {
        // 重置到第一页策略
        this.currentPage = 1;
      }
      
      this.pageSize = newSize;
      this.fetchData();
    },
    
    // 处理页码变化
    handleCurrentChange(newPage) {
      this.currentPage = newPage;
      this.fetchData();
    },
    
    // 切换策略
    handleStrategyChange() {
      // 策略切换时重置到第一页
      this.currentPage = 1;
      this.fetchData();
    },
    
    // 模拟数据请求
    async fetchData() {
      try {
        // 模拟API请求
        const mockData = await this.mockApiRequest();
        this.currentPageData = mockData.data;
        this.total = mockData.total;
      } catch (error) {
        console.error('数据加载失败:', error);
      }
    },
    
    // 模拟API
    mockApiRequest() {
      return new Promise(resolve => {
        setTimeout(() => {
          const start = (this.currentPage - 1) * this.pageSize;
          const end = start + this.pageSize;
          const data = this.generateMockData().slice(start, end);
          
          resolve({
            data,
            total: 235 // 模拟总条数
          });
        }, 300);
      });
    },
    
    // 生成模拟数据
    generateMockData() {
      const data = [];
      for (let i = 1; i <= 235; i++) {
        data.push({
          id: i,
          name: `用户${i}`,
          email: `user${i}@example.com`,
          status: i % 3 === 0 ? '激活' : '禁用'
        });
      }
      return data;
    }
  },
  
  mounted() {
    this.fetchData();
  }
}
</script>

<style scoped>
.pagination-demo {
  padding: 20px;
}

.strategy-selector {
  margin-bottom: 20px;
  padding: 10px;
  background: #f5f7fa;
  border-radius: 4px;
}

.page-info {
  margin-top: 15px;
  padding: 10px;
  background: #e6f7ff;
  border-radius: 4px;
  font-size: 14px;
  color: #1890ff;
}
</style>

策略选择指南

在实际项目中,我们根据页面类型制定了选择标准:

选择智能重新计算策略的场景

  1. 数据管理页面:用户管理、订单管理、商品管理等
  2. 审核审批流程:需要保持审核进度的场景
  3. 内容浏览:文章列表、消息中心等

选择重置到第一页策略的场景

  1. 数据分析页面:统计报表、数据图表
  2. 搜索结果:搜索后切换每页显示数量
  3. 筛选过滤:应用筛选条件后查看结果

总结与思考

通过这次优化实践,我们得到了几个重要启示:

1. 不要盲目改变默认行为

ElementUI将重置到第一页作为默认行为是有其考虑的,我们在修改前应该充分理解这个设计的初衷。

2. 用户体验需要具体场景具体分析

同一个交互行为,在不同业务场景下可能需要不同的处理策略。

3. 技术方案要服务于业务需求

从"保持当前页"到"智能重新计算"的演进,体现了我们对业务需求理解的深化。

4. 提供可配置的解决方案

在我们的组件库中,最终将分页策略做成了可配置选项,让不同业务场景可以选择最适合的方案。

这次优化不仅解决了具体的技术问题,更重要的是让我们重新思考了组件设计背后的用户体验哲学。在技术选型和方案设计中,平衡默认行为与特殊需求、通用性与个性化,是我们不断追求的目目标。

思考题:在你的项目中,还有哪些看似简单的交互细节,其实蕴含着深刻的用户体验思考?欢迎在评论区分享讨论!

react-konva实战指南:Canvas高性能+易维护的组件化图形开发实现教程

图形绘制与交互是许多复杂应用(如数据可视化、设计工具、画板,游戏等)的核心需求。而react-konva作为Konva.js的React封装库,将React的声明式编程理念与Konva.js强大的图形处理能力完美结合,让开发者能够以更直观、高效的方式构建交互式图形应用。我将从react-konva的核心特性出发,详细讲解其使用方法、性能优化技巧及实际应用场景,帮助读者快速上手并落地项目。

1. 介绍

react-konva并非一个独立的图形库,而是Konva.js与React的桥梁。Konva.js是一款基于Canvas的2D图形库,支持分层渲染、事件检测、动画过渡等核心能力,而react-konva则通过React组件的形式封装了Konva.js的API,让开发者可以用React的思维(如组件化、状态管理、Props传递)来操作图形元素,无需直接编写原生Canvas代码。

核心优势

  1. 声明式API:通过React组件(如<Stage><Layer><Rect><Circle>)描述图形结构,替代Konva.js的命令式调用,代码更易读、维护;
  2. React生态兼容:无缝集成React的状态管理(如useStateuseReducer)、生命周期(如useEffect),支持Redux、MobX等状态库;
  3. 高性能渲染:基于Konva.js的分层渲染机制,仅更新变化的图形元素,避免全量重绘;
  4. 完善的事件系统:支持鼠标(onClickonDrag)、触摸(onTouchStart)、键盘(onKeyPress)等事件,且事件检测精度不受Canvas像素限制;
  5. 丰富的图形与动画:内置矩形、圆形、文本、路径等基础图形,支持缩放、旋转、平移等变换,以及帧动画、过渡动画。

2.快速上手

从安装到第一个图形,步骤如下:

2.1. 安装依赖

react-konva依赖于konva核心库,需同时安装两个包:

# npm
npm install react-konva konva --save

# yarn
yarn add react-konva konva

2.2. 基础示例

react-konva的核心组件结构为:Stage(画布容器)→ Layer(渲染层)→ 图形元素(RectCircle等)。其中,Stage是顶层容器,一个应用可包含多个StageLayer是渲染层,每个Layer对应一个Canvas元素,建议将“频繁更新的元素”与“静态元素”分属不同Layer以优化性能。

以下是一个完整的示例,实现“点击按钮添加一个可拖拽的矩形”的功能:

import React, { useState } from 'react';
import { Stage, Layer, Rect, Text } from 'react-konva';

const App = () => {
  // 状态:存储所有矩形的信息(位置、大小、颜色)
  const [rectangles, setRectangles] = useState([
    { x: 50, y: 50, width: 100, height: 60, color: '#ff6347' }
  ]);
  // 状态:记录当前是否在拖拽矩形
  const [isDragging, setIsDragging] = useState(false);

  // 新增矩形:在随机位置添加一个蓝色矩形
  const addRectangle = () => {
    setRectangles([
      ...rectangles,
      {
        x: Math.random() * 400, // 随机X坐标(Stage宽度为500)
        y: Math.random() * 300, // 随机Y坐标(Stage高度为400)
        width: 80 + Math.random() * 60, // 随机宽度
        height: 50 + Math.random() * 40, // 随机高度
        color: '#4169e1'
      }
    ]);
  };

  // 拖拽事件:开始拖拽时更新状态
  const handleDragStart = () => {
    setIsDragging(true);
  };

  // 拖拽事件:结束拖拽时更新状态
  const handleDragEnd = (e) => {
    setIsDragging(false);
    // 更新被拖拽矩形的最终位置
    const updatedRects = rectangles.map((rect, index) => {
      if (index === e.target.index) { // e.target.index 是当前图形在父组件中的索引
        return { ...rect, x: e.target.x(), y: e.target.y() };
      }
      return rect;
    });
    setRectangles(updatedRects);
  };

  return (
    <div style={{ margin: '20px' }}>
      {/* 按钮:触发新增矩形 */}
      <button 
        onClick={addRectangle}
        style={{ marginBottom: '10px', padding: '8px 16px' }}
      >
        添加矩形
      </button>
      {/* 拖拽状态提示 */}
      {isDragging && <Text text="拖拽中..." x={200} y={10} fontSize={16} />}

      {/* Stage:画布容器,width/height 定义画布大小 */}
      <Stage width={500} height={400} style={{ border: '1px solid #eee' }}>
        {/* Layer:渲染层,所有图形元素必须放在Layer内 */}
        <Layer>
          {/* 遍历渲染所有矩形 */}
          {rectangles.map((rect, index) => (
            <Rect
              key={index} // 建议使用唯一ID此处为简化用index
              x={rect.x}
              y={rect.y}
              width={rect.width}
              height={rect.height}
              fill={rect.color}
              stroke="#333" // 边框颜色
              strokeWidth={2} // 边框宽度
              draggable // 允许拖拽
              onDragStart={handleDragStart}
              onDragEnd={handleDragEnd}
              // 鼠标悬停时显示指针
              onMouseOver={(e) => {
                e.target.setAttrs({ stroke: '#ff0' }); // 悬停时边框变黄
              }}
              onMouseOut={(e) => {
                e.target.setAttrs({ stroke: '#333' }); // 离开时恢复边框颜色
              }}
            />
          ))}
        </Layer>
      </Stage>
    </div>
  );
};

export default App;

2.3. 核心组件解析

组件 作用说明
<Stage> 顶层画布容器,对应Konva.js的Konva.Stage,需指定widthheight属性
<Layer> 渲染层,对应Konva.Layer,每个Layer包含一个Canvas元素,支持分层渲染
<Rect> 矩形图形,支持x(横坐标)、y(纵坐标)、widthheightfill(填充色)等属性
<Circle> 圆形图形,核心属性为xy(圆心坐标)、radius(半径)、fill
<Text> 文本元素,支持text(内容)、fontSizefontFamilyfill等属性
<Image> 图片元素,需通过image属性传入Image对象(需先加载完成)

3. 进阶功能

下面是一些进阶的功能,包括动画、变换与事件:

3.1. 实现图形动画

react-konva支持两种动画方式:基于状态的动画(通过React状态更新触发重绘)和Konva原生动画(通过Konva.Animation API)。

方式1:基于状态的简单动画(基础)

通过useState+useEffect实现矩形的“呼吸效果”(缩放动画),适合基础过渡:

import React, { useState, useEffect } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const AnimatedRect = () => {
  const [scale, setScale] = useState(1); // 缩放比例,初始为1
  const [growing, setGrowing] = useState(true); // 是否正在放大

  // 每30ms更新一次缩放比例,实现动画效果
  useEffect(() => {
    const timer = setInterval(() => {
      setScale(prev => {
        // 放大到1.2后开始缩小,缩小到0.8后开始放大
        if (prev >= 1.2) setGrowing(false);
        if (prev <= 0.8) setGrowing(true);
        return growing ? prev + 0.01 : prev - 0.01;
      });
    }, 30);

    // 组件卸载时清除定时器,避免内存泄漏
    return () => clearInterval(timer);
  }, [growing]);

  return (
    <Stage width={300} height={200}>
      <Layer>
        <Rect
          x={100}
          y={50}
          width={100}
          height={60}
          fill="#20b2aa"
          scaleX={scale} // X轴缩放比例
          scaleY={scale} // Y轴缩放比例
          offsetX={50} // 缩放中心点X矩形宽度的一半offsetY={30} // 缩放中心点Y矩形高度的一半)
        />
      </Layer>
    </Stage>
  );
};

export default AnimatedRect;

方式2:Konva原生动画(复杂)

对于需要精细控制的复杂帧动画(如多属性同步变化、物理运动),建议使用Konva的Animate组件或Konva.Animation API:

import React from 'react';
import { Stage, Layer, Rect, Animate } from 'react-konva';

const ComplexAnimation = () => {
  // 定义动画关键帧:x从50→400,y从50→250,同时旋转360度
  const animationConfig = {
    x: [50, 400],
    y: [50, 250],
    rotation: [0, 360], // 旋转角度(单位:度)
    duration: 2000, // 动画时长(ms)
    easing: Konva.Easings.EaseInOut // 缓动函数
  };

  return (
    <Stage width={500} height={300}>
      <Layer>
        <Rect
          width={80}
          height={50}
          fill="#ff4500"
          offsetX={40} // 旋转中心点矩形中心offsetY={25}
        >
          {/* Animate组件:绑定动画配置 */}
          <Animate
            config={animationConfig}
            repeat={Infinity} // 无限循环
            yoyo={true} // 动画结束后反向播放类似往返效果)
          />
        </Rect>
      </Layer>
    </Stage>
  );
};

export default ComplexAnimation;

3.2. 图形变换(缩放、旋转、平移)

react-konva的图形元素支持通过属性直接控制变换,核心属性包括:

  • x/y:元素的左上角坐标(默认基准点为左上角);
  • scaleX/scaleY:X/Y轴缩放比例(1为原始大小);
  • rotation:旋转角度(单位:度,顺时针为正);
  • offsetX/offsetY:变换基准点(如设置为元素中心,旋转/缩放将围绕中心进行)。

示例:通过滑块控制矩形的旋转角度:

import React, { useState } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const RotatableRect = () => {
  const [rotation, setRotation] = useState(0); // 初始旋转角度为0

  return (
    <div style={{ margin: '20px' }}>
      {/* 滑块:控制旋转角度(0~360度) */}
      <label>旋转角度:{rotation}°</label>
      <input
        type="range"
        min="0"
        max="360"
        value={rotation}
        onChange={(e) => setRotation(Number(e.target.value))}
        style={{ width: '300px', marginLeft: '10px' }}
      />

      <Stage width={300} height={200}>
        <Layer>
          <Rect
            x={150}
            y={100}
            width={100}
            height={60}
            fill="#9370db"
            rotation={rotation}
            offsetX={50} // 旋转基准点为矩形中心
            offsetY={30}
            stroke="#333"
            strokeWidth={2}
          />
        </Layer>
      </Stage>
    </div>
  );
};

export default RotatableRect;

3.3. 事件类型和处理

react-konva的事件系统基于Konva.js,能精准捕获与交互,支持像素级别的事件检测(即使两个图形重叠,也能精准识别鼠标 hover 的是哪个图形),且事件名称与React保持一致(如onClickonMouseMove)。

常见事件类型:

  • 鼠标事件:onClickonDoubleClickonMouseDownonMouseUponMouseOveronMouseOut
  • 拖拽事件:onDragStartonDragonDragEnd
  • 触摸事件:onTouchStartonTouchMoveonTouchEnd
  • 键盘事件:需先通过stage.on('keydown', handler)绑定,或在元素上使用onKeyPress(需元素处于焦点状态)。

示例:实现“点击矩形改变颜色”和“键盘删除选中矩形”:

import React, { useState, useRef } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const InteractiveRects = () => {
  const [rectangles, setRectangles] = useState([
    { id: 1, x: 50, y: 50, width: 80, height: 50, color: '#ff6b6b' },
    { id: 2, x: 200, y: 100, width: 80, height: 50, color: '#4ecdc4' }
  ]);
  const [selectedId, setSelectedId] = useState(null);
  const stageRef = useRef(null); // 用于获取Stage实例

  // 点击矩形:选中并改变颜色
  const handleRectClick = (e, id) => {
    setSelectedId(id);
    // 随机改变颜色
    const randomColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`;
    const updatedRects = rectangles.map(rect => 
      rect.id === id ? { ...rect, color: randomColor } : rect
    );
    setRectangles(updatedRects);
  };

  // 键盘事件:按Delete删除选中的矩形
  React.useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === 'Delete' && selectedId) {
        setRectangles(rectangles.filter(rect => rect.id !== selectedId));
        setSelectedId(null);
      }
    };

    // 绑定键盘事件
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [selectedId, rectangles]);

  return (
    <div>
      <p>点击矩形选中并改变颜色,按Delete删除选中矩形</p>
      <Stage 
        width={400} 
        height={200} 
        ref={stageRef}
        style={{ border: '1px solid #eee' }}
      >
        <Layer>
          {rectangles.map(rect => (
            <Rect
              key={rect.id}
              x={rect.x}
              y={rect.y}
              width={rect.width}
              height={rect.height}
              fill={rect.color}
              stroke={selectedId === rect.id ? '#ff0' : '#333'} // 选中时边框变黄
              strokeWidth={selectedId === rect.id ? 3 : 2} // 选中时边框变粗
              onClick={(e) => handleRectClick(e, rect.id)}
              onMouseOut={(e) => {
                e.target.setAttrs({
                  stroke: selectedId === rect.id ? '#ff0' : '#333',
                });
              }}
            />
          ))}
        </Layer>
      </Stage>
    </div>
  );
};

export default InteractiveRects;

4. 性能优化

应对大规模图形场景,当应用中需要渲染成百上千个图形元素(如数据可视化中的海量节点、设计工具中的复杂图层)时,单纯的基础用法可能会出现卡顿。react-konva虽基于 Konva.js 做了底层优化,但仍需结合 React 特性进行针对性优化,核心思路是减少不必要的重渲染降低绘制压力

4.1. 避免不必要的组件重渲染

React 组件的重渲染触发条件(如父组件重渲染、Props 变化、State 变化)会直接影响react-konva的性能,可通过以下方式优化:

方式1:使用 memo 缓存图形组件

对于纯展示型的图形组件(如静态矩形、文本),可通过 React.memo 缓存组件,避免父组件重渲染时被连带重渲染。

示例:封装一个缓存的矩形组件:

import React, { memo } from 'react';
import { Rect } from 'react-konva';

// 自定义比较函数:仅当Props中的关键属性变化时才重渲染
const RectMemoized = memo(
  ({ x, y, width, height, color, onMouseOver, onMouseOut }) => (
    <Rect
      x={x}
      y={y}
      width={width}
      height={height}
      fill={color}
      stroke="#333"
      strokeWidth={2}
      onMouseOver={onMouseOver}
      onMouseOut={onMouseOut}
    />
  ),
  (prevProps, nextProps) => {
    // 仅当关键属性(位置、大小、颜色)不变时,返回true(不重渲染)
    return (
      prevProps.x === nextProps.x &&
      prevProps.y === nextProps.y &&
      prevProps.width === nextProps.width &&
      prevProps.height === nextProps.height &&
      prevProps.color === nextProps.color
    );
  }
);

export default RectMemoized;

方式2:拆分状态与分层渲染

将“频繁变化的元素”(如拖拽中的图形、实时更新的数据标签)与“静态元素”(如背景、固定参考线)拆分到不同的 <Layer> 中。Konva.js 会仅重绘变化的 Layer,而非全量重绘整个 Stage。

示例:分层管理静态背景与动态图形:

<Stage width={800} height={600}>
  {/* 静态Layer:仅渲染一次,后续不重绘 */}
  <Layer>
    <Rect x={0} y={0} width={800} height={600} fill="#f5f5f5" /> {/* 背景 */}
    <Line points={[0, 300, 800, 300]} stroke="#ddd" strokeWidth={1} /> {/* 参考线 */}
  </Layer>

  {/* 动态Layer:仅当图形变化时重绘 */}
  <Layer>
    {dynamicRectangles.map(rect => (
      <RectMemoized key={rect.id} {...rect} />
    ))}
  </Layer>
</Stage>

方式3:使用 useCallback 缓存事件处理函数

若图形组件的事件处理函数(如 onClickonDrag)是在父组件中定义的,每次父组件重渲染时会生成新的函数实例,导致子组件 Props 变化而重渲染。可通过 useCallback 缓存函数。

示例:缓存拖拽事件处理函数:

const handleDragEnd = useCallback((e, id) => {
  setRectangles(prev => 
    prev.map(rect => 
      rect.id === id ? { ...rect, x: e.target.x(), y: e.target.y() } : rect
    )
  );
}, []); // 依赖为空,函数仅创建一次

4.2. 降低绘制压力

当图形数量超过 1000 个时,即使避免了重渲染,Canvas 的绘制操作仍可能成为瓶颈,可通过以下方式优化:

方式1:图形合并

对于大量重复且无交互的图形(如数据可视化中的网格点、背景纹理),可通过 Konva.js 的 Group 组件合并,批量绘制,减少绘制调用次数。

示例:合并多个静态小圆点:

import { Group, Circle } from 'react-konva';

const DotGroup = () => {
  // 生成1000个静态小圆点
  const dots = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    x: Math.random() * 800,
    y: Math.random() * 600,
    radius: 2,
    color: '#ccc'
  }));

  return (
    <Group> {/* 合并为一个Group,减少绘制调用 */}
      {dots.map(dot => (
        <Circle
          key={dot.id}
          x={dot.x}
          y={dot.y}
          radius={dot.radius}
          fill={dot.color}
        />
      ))}
    </Group>
  );
};

方式2:可视区域裁剪

仅渲染当前视图内的图形(Viewport Culling),隐藏视图外的图形(如滚动或缩放时)。可通过监听 Stage 的 zoomdrag 事件,计算可视区域范围,过滤掉不在范围内的图形。

示例:实现可视区域裁剪:

import React, { useState, useEffect } from 'react';
import { Stage, Layer, RectMemoized } from 'react-konva';

const ViewportCulling = () => {
  const [allRectangles, setAllRectangles] = useState([]);
  const [visibleRectangles, setVisibleRectangles] = useState([]);
  const stageRef = useRef(null);

  // 初始化10000个矩形(模拟大规模数据)
  useEffect(() => {
    const rects = Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      x: Math.random() * 2000,
      y: Math.random() * 1500,
      width: 20,
      height: 20,
      color: `#${Math.floor(Math.random() * 16777215).toString(16)}`
    }));
    setAllRectangles(rects);
  }, []);

  // 监听Stage的缩放和拖拽事件,更新可视区域内的图形
  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;

    const updateVisibleRects = () => {
      // 获取Stage的可视区域范围(考虑缩放和偏移)
      const stageRect = stage.getClientRect();
      const visibleLeft = stageRect.x;
      const visibleTop = stageRect.y;
      const visibleRight = visibleLeft + stageRect.width;
      const visibleBottom = visibleTop + stageRect.height;

      // 过滤出在可视区域内的矩形
      const visible = allRectangles.filter(rect => 
        rect.x + rect.width > visibleLeft &&
        rect.x < visibleRight &&
        rect.y + rect.height > visibleTop &&
        rect.y < visibleBottom
      );

      setVisibleRectangles(visible);
    };

    // 初始计算一次
    updateVisibleRects();
    // 监听缩放和拖拽事件
    stage.on('zoom drag end', updateVisibleRects);

    // 清理事件监听
    return () => stage.off('zoom drag end', updateVisibleRects);
  }, [allRectangles]);

  return (
    <Stage
      ref={stageRef}
      width={800}
      height={600}
      draggable // 允许拖拽Stage查看大范围图形
      scaleX={1}
      scaleY={1}
      onWheel={(e) => {
        // 实现滚轮缩放
        e.evt.preventDefault();
        const scale = stageRef.current.scaleX();
        const newScale = e.evt.deltaY > 0 ? scale - 0.1 : scale + 0.1;
        stageRef.current.scale({ x: newScale, y: newScale });
      }}
    >
      <Layer>
        {visibleRectangles.map(rect => (
          <RectMemoized key={rect.id} {...rect} />
        ))}
      </Layer>
    </Stage>
  );
};

export default ViewportCulling;

5. 实际应用场景与案例

react-konva 凭借其灵活性和高性能,广泛应用于各类图形交互场景,以下是几个典型案例:

5.1. 交互式图表数据可视化

结合 d3.js 等数据处理库,可构建支持拖拽、缩放、hover 提示的交互式图表(如散点图、热力图)。

示例:基于 react-konva + d3 的散点图:

import React, { useEffect, useState } from 'react';
import { Stage, Layer, Circle, Text } from 'react-konva';
import * as d3 from 'd3';

const ScatterPlot = ({ data }) => {
  const [scaledData, setScaledData] = useState([]);
  const [hoveredPoint, setHoveredPoint] = useState(null);

  // 使用d3.scale处理数据映射(将原始数据映射到Stage坐标)
  useEffect(() => {
    const xScale = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.x)])
      .range([50, 750]); // X轴范围:50~750(留出边距)

    const yScale = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.y)])
      .range([550, 50]); // Y轴范围:550~50(倒序,符合视觉习惯)

    const scaled = data.map(d => ({
      id: d.id,
      x: xScale(d.x),
      y: yScale(d.y),
      value: d.value,
      color: d3.interpolateViridis(d.value / 100) // 基于value生成颜色
    }));

    setScaledData(scaled);
  }, [data]);

  return (
    <Stage width={800} height={600}>
      <Layer>
        {/* 坐标轴 */}
        <Line points={[50, 50, 50, 550]} stroke="#333" strokeWidth={2} /> {/* Y轴 */}
        <Line points={[50, 550, 750, 550]} stroke="#333" strokeWidth={2} /> {/* X轴 */}
        {/* 轴标签 */}
        <Text text="X轴(数值)" x={400} y={580} fontSize={14} align="center" />
        <Text text="Y轴(数值)" x={20} y={300} fontSize={14} rotation={-90} align="center" />

        {/* 散点 */}
        {scaledData.map(point => (
          <Circle
            key={point.id}
            x={point.x}
            y={point.y}
            radius={hoveredPoint === point.id ? 8 : 5} // hover时放大
            fill={point.color}
            stroke={hoveredPoint === point.id ? "#fff" : "none"}
            strokeWidth={2}
            onMouseOver={() => setHoveredPoint(point.id)}
            onMouseOut={() => setHoveredPoint(null)}
          />
        ))}

        {/* Hover提示框 */}
        {hoveredPoint && (
          const point = scaledData.find(d => d.id === hoveredPoint);
          <Group x={point.x + 10} y={point.y - 10}>
            <Rect width={120} height={40} fill="#fff" stroke="#333" strokeWidth={1} />
            <Text text={`Value: ${point.value}`} x={10} y={10} fontSize={12} />
            <Text text={`X: ${point.x.toFixed(0)}`} x={10} y={25} fontSize={12} />
          </Group>
        )}
      </Layer>
    </Stage>
  );
};

// 使用示例:
// <ScatterPlot data={[{ id: 1, x: 20, y: 80, value: 50 }, ...]} />
export default ScatterPlot;

5.2. 简易图形编辑器

构建支持图形添加、拖拽、旋转、删除的轻量级设计工具(如流程图编辑器、海报制作工具)。

可以实现如下核心功能:

  • 图形库:提供矩形、圆形、文本等基础图形选择;
  • 画布操作:支持画布拖拽、缩放;
  • 图层管理:显示/隐藏、锁定/解锁图层;
  • 导出功能:将画布内容导出为图片(通过 stage.toDataURL())。

5.3. 简单2D游戏开发

实现支持碰撞检测、角色动画的 2D 游戏(如贪吃蛇、拼图游戏)。

示例:贪吃蛇游戏的核心逻辑(简化):

import React, { useEffect, useRef, useState } from 'react';
import { Stage, Layer, Rect } from 'react-konva';

const SnakeGame = () => {
  const [snake, setSnake] = useState([{ x: 200, y: 200 }, { x: 190, y: 200 }, { x: 180, y: 200 }]);
  const [food, setFood] = useState({ x: 300, y: 300 });
  const [direction, setDirection] = useState({ x: 10, y: 0 }); // 初始方向:右
  const gameLoopRef = useRef(null);

  // 生成随机食物位置
  const generateFood = () => {
    const x = Math.floor(Math.random() * 40) * 10; // 10的倍数,与蛇身对齐
    const y = Math.floor(Math.random() * 30) * 10;
    setFood({ x, y });
  };

  // 游戏循环:每100ms更新一次蛇的位置
  useEffect(() => {
    gameLoopRef.current = setInterval(() => {
      setSnake(prev => {
        // 计算新蛇头位置
        const head = { x: prev[0].x + direction.x, y: prev[0].y + direction.y };
        // 检查是否吃到食物
        const ateFood = head.x === food.x && head.y === food.y;
        if (ateFood) generateFood();

        // 更新蛇身:吃到食物则增加一节,否则删除尾部
        const newSnake = [head, ...prev];
        if (!ateFood) newSnake.pop();
        return newSnake;
      });
    }, 100);

    // 清理定时器
    return () => clearInterval(gameLoopRef.current);
  }, [direction, food]);

  // 监听键盘事件控制方向
  useEffect(() => {
    const handleKeyDown = (e) => {
      switch (e.key) {
        case 'ArrowUp':
          if (direction.y !== 10) setDirection({ x: 0, y: -10 }); // 避免反向
          break;
        case 'ArrowDown':
          if (direction.y !== -10) setDirection({ x: 0, y: 10 });
          break;
        case 'ArrowLeft':
          if (direction.x !== 10) setDirection({ x: -10, y: 0 });
          break;
        case 'ArrowRight':
          if (direction.x !== -10) setDirection({ x: 10, y: 0 });
          break;
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [direction]);

  return (
    <Stage width={400} height={300}>
      <Layer>
        {/* 蛇身 */}
        {snake.map((segment, index) => (
          <Rect
            key={index}
            x={segment.x}
            y={segment.y}
            width={10}
            height={10}
            fill={index === 0 ? '#2ecc71' : '#27ae60'} // 蛇头绿色更深
          />
        ))}

        {/* 食物 */}
        <Rect
          x={food.x}
          y={food.y}
          width={10}
          height={10}
          fill="#e74c3c"
        />
      </Layer>
    </Stage>
  );
};

export default SnakeGame;

6. 常见问题与解决方案

在使用 react-konva 开发过程中,开发者常会遇到一些共性问题,以下是高频问题及对应的解决方案:

图形元素不显示

可能原因与解决方法:

  • 未放在 <Layer>:所有图形元素(RectCircle 等)必须嵌套在 <Layer> 内,否则无法渲染。
    解决方案:确保组件结构为 Stage → Layer → 图形元素

  • 坐标或尺寸设置错误:若图形的 x/y 坐标超出 <Stage> 范围,或 width/height 设为 0,会导致图形不可见。
    解决方案:检查坐标是否在 Stagewidth/height 范围内,确认尺寸属性大于 0。

  • 图片加载顺序问题:使用 <Image> 组件时,若图片未加载完成就传入 image 属性,会导致图片不显示。
    解决方案:通过 useEffect 监听图片加载完成后再渲染 <Image>

    import React, { useState, useEffect } from 'react';
    import { Stage, Layer, Image } from 'react-konva';
    
    const KonvaImage = ({ src }) => {
      const [image, setImage] = useState(null);
    
      useEffect(() => {
        const img = new Image();
        img.src = src;
        img.onload = () => setImage(img); // 加载完成后更新状态
      }, [src]);
    
      return image ? <Image image={image} width={200} height={150} /> : null;
    };
    

拖拽事件不生效

可能原因与解决方法:

  • 未设置 draggable={true}:图形元素默认不支持拖拽,需显式添加 draggable 属性。
    解决方案:在图形组件上添加 draggable,如 <Rect draggable />

  • 事件被上层元素遮挡:若图形上方有其他元素(如透明的 Rect),会导致拖拽事件被拦截。
    解决方案:通过 zIndex 属性调整图形层级(zIndex 越大,层级越高),或确保上层元素不拦截事件(设置 pointerEvents="none")。

  • 拖拽范围限制问题:若通过 dragBoundFunc 限制拖拽范围时逻辑错误,可能导致拖拽失效。
    解决方案:检查 dragBoundFunc 函数返回值是否正确(需返回 { x, y } 对象):

    <Rect
      draggable
      dragBoundFunc={(pos) => {
        // 限制拖拽范围在 Stage 内
        return {
          x: Math.max(0, Math.min(pos.x, 800 - 100)), // 800 是 Stage 宽度,100 是矩形宽度
          y: Math.max(0, Math.min(pos.y, 600 - 60))   // 600 是 Stage 高度,60 是矩形高度
        };
      }}
    />
    

大规模图形场景下性能卡顿

可能原因与解决方法:

  • 未做重渲染优化:父组件频繁重渲染导致所有图形组件连带重渲染。
    解决方案:参考第四章内容,使用 React.memo 缓存图形组件、useCallback 缓存事件函数。

  • Layer 数量过多或不合理:若每个图形都单独放在一个 Layer 中,会增加 Canvas 绘制开销。
    解决方案:合理拆分 Layer,将静态元素归为一个 Layer,动态元素归为一个或少数几个 Layer

  • 未启用可视区域裁剪:渲染了视图外的大量图形,浪费性能。
    解决方案:实现第四章提到的“可视区域裁剪”逻辑,仅渲染当前视图内的图形。

与 React状态同步延迟

可能原因与解决方法:

  • 直接操作 Konva 实例属性:若通过 e.target.setAttrs({ x: 100 }) 直接修改图形属性,未同步到 React 状态,会导致状态与视图不一致。
    解决方案:修改属性后,需同步更新 React 状态(如 onDragEnd 事件中更新 x/y 状态),确保状态是唯一数据源。

  • 动画导致的状态滞后:Konva 原生动画(如 Animate 组件)修改属性时,不会自动同步到 React 状态,导致状态滞后。
    解决方案:在动画结束后,通过 onFinish 事件同步状态:

    <Animate
      config={animationConfig}
      onFinish={() => {
        // 动画结束后同步状态到 React
        setRectX(400);
        setRectY(250);
      }}
    />
    

7. 版本兼容与升级要点

react-konva 与 React、Konva.js 的版本存在一定依赖关系,升级时需注意兼容性,避免出现 API 不兼容问题。

7.1. 版本依赖关系

react-konva 版本 支持 React 版本 依赖 Konva.js 版本
2.x 16.8+(支持 Hooks) 7.x
1.x 15.x - 16.x 6.x

注意react-konva@2.x 是目前的稳定版本,推荐使用,且需确保 konva 版本与 react-konva 兼容(通常安装时会自动匹配)。

7.2. 升级核心注意事项

  • 从 1.x 升级到 2.x

    1. react-konva@2.x 移除了 ReactKonvaCore 等旧 API,统一使用顶层导出组件(如 import { Stage } from 'react-konva');
    2. 不再支持 React 16.8 以下版本,需先升级 React 到 16.8+;
    3. Konva 实例获取方式变化:从 ref 获取时,需通过 ref.current 访问(如 stageRef.current),而非旧版的 ref 直接访问。
  • Konva.js 升级注意事项

    1. Konva.js 7.x 对事件系统做了优化,部分事件名称调整(如 dragmove 改为 drag),需同步修改事件处理函数;
    2. 图形属性 offset 不再支持数组形式(如 offset={[50, 30]}),需拆分为 offsetX={50}offsetY={30}

8. 总结

react-konva 作为 React 生态中成熟的 2D 图形库,其核心价值在于:

  • 低学习成本:使用 React 组件化思维操作图形,无需从零学习 Canvas 或 Konva.js 原生 API;
  • 高性能:基于 Konva.js 的分层渲染和事件优化,支持大规模图形场景;
  • 强扩展性:可与 React 生态工具(如 Redux、React Router)无缝集成,也可结合 d3.jschart.js 等库实现复杂功能;
  • 完善的生态:官方文档详细,社区活跃,问题解决资源丰富。

在选择之前,请了解它的能力边界,适用场景与不适用场景如下:

  • 适用场景

    • 交互式数据可视化(如散点图、流程图);
    • 轻量级设计工具(如简易海报编辑器、思维导图);
    • 2D 小游戏(如贪吃蛇、拼图);
    • 自定义图形组件(如仪表盘、进度条)。
  • 不适用场景

    • 3D 图形渲染(需使用 Three.js 等 3D 库);
    • 超大规模图形渲染(如百万级节点的地图,需使用 WebGL 优化的库);
    • 复杂的矢量图形编辑(需使用 SVG 或专业矢量库)。

通过本文的讲解,相信开发者已掌握 react-konva 的核心用法、性能优化技巧和实际应用场景。在实际项目中,建议结合具体需求选择合适的功能模块,灵活运用优化策略,构建高效、流畅的图形交互应用。

参考来源:


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

从 TypeScript 视角读懂 Java 和 TS 类中 new 自己的区别

Java class 里 new 自身 和 TypeScript 里 new 自己的区别分析

在日常开发中,我们经常会在类的内部通过 new 关键字实例化自身对象。Java 和 TypeScript 都支持面向对象编程,但它们在“类内 new 自身”这一行为上有着本质的区别。本文将从语法、运行时行为、类型系统等角度,详细分析 Java 和 TypeScript 在这方面的异同。


一、Java 中 class 里 new 自身

Java 是强类型、编译型语言。类内部 new 自身是非常常见的写法,比如:

public class Person {
    public Person() {
        System.out.println("Person 构造方法被调用");
    }

    public void createAnother() {
        Person p = new Person();
        System.out.println("又创建了一个 Person");
    }
}

特点与机制:

  1. 类型确定
    在类内部,new Person() 总是创建当前类的实例,类型就是 Person。如果该类被继承,子类内部 new Person() 依然是父类对象,不是子类。
  2. 构造方法调用
    会调用当前类的构造方法,构造链严格按照声明顺序执行。
  3. 用途
    常用于工厂方法、原型模式、递归结构等。
  4. 限制
    不能直接通过 this 关键字 new 自身(new this() 不合法)。只能通过类名 new。

二、TypeScript 中 class 里 new 自己

TypeScript 作为 JavaScript 的超集,支持类的语法糖。来看一个例子:

class Person {
    constructor() {
        console.log("Person 构造函数被调用");
    }

    createAnother() {
        const p = new Person();
        console.log("又创建了一个 Person");
    }
}

特点与机制:

  1. 类型推断
    new Person() 创建的是当前类的实例,类型为 Person。但 TypeScript 支持更灵活的 this 类型(多态 this),可以实现“new this()”的效果。

  2. 多态 this
    TypeScript 支持 new this.constructor()new (this.constructor as any)(),可以在父类方法中创建子类实例。还可以用 this 类型返回当前实例的实际类型。

    例如:

    class Base {
        clone(): this {
            return new (this.constructor as any)();
        }
    }
    
    class Sub extends Base {}
    
    const s = new Sub();
    const s2 = s.clone(); // s2 的类型是 Sub
    
  3. 用途
    常用于工厂方法、链式调用、流式 API 等场景。

  4. 灵活性
    TypeScript 允许通过 this 类型实现更灵活的“new 自己”,支持继承链上的多态。

三、核心区别对比

维度 Java TypeScript
语法 只能 new 类名() 可以 new 类名(),也可以 new (this.constructor as any)()
类型 new 的总是当前类类型 支持 this 类型,多态更强
继承下行为 子类 new 父类名,得到父类对象 子类可通过 this.constructor 得到子类对象
运行时机制 编译期类型固定,运行时无多态 new 运行时可动态 new 当前实例的构造函数
典型应用 工厂方法、原型模式 工厂方法、链式调用、流式 API

四、TS 中 constructor 的访问修饰符

TypeScript 中,constructor 默认是 public,所以在类的外部和内部都可以通过 new 关键字来实例化对象。

class Person {
  constructor() {
    console.log("Person 构造函数被调用");
  }
}

const p = new Person(); // 合法

如果你将 constructor 显式声明为 private 或 protected,则只能在类的内部(或子类内部,protected 情况下)通过 new 创建实例,外部无法 new。例如:

class Person {
  private constructor() {}

  static getInstance() {
    return new Person();
  }
}

const p = new Person(); // 报错:构造函数为私有
const p2 = Person.getInstance(); // 合法

protected 构造函数允许子类继承和实例化,但禁止外部直接 new 父类:

class Base {
  protected constructor() {}
}

class Sub extends Base {
  constructor() {
    super();
  }
}

const b = new Base(); // 报错
const s = new Sub();  // 合法

五、Java 中的构造器访问控制

Java 也有类似的访问修饰符(public、protected、private),但和 TypeScript 有细微差别:

  • public:任何地方都能 new
  • protected:同包或子类能 new
  • private:只能在类内部 new

例如:

public class Singleton {
    private Singleton() {}
    public static Singleton getInstance() {
        return new Singleton();
    }
}

和 TypeScript 的单例写法如出一辙。

六、常见面试题与陷阱

1. Java 子类能否在父类方法中 new this?

不能。Java 的 this 代表当前实例,不能直接用 new this()。如果想在父类方法中创建“当前实际类型”的对象,通常需要借助反射:

public class Base {
    public Base create() throws Exception {
        return this.getClass().getDeclaredConstructor().newInstance();
    }
}

但这种写法有一定的复杂性和性能开销。

2. TypeScript 的 this 类型陷阱

虽然 TS 支持 this 类型,但如果构造函数有参数,this.constructor as any 的类型检查就会失效,容易出错:

class A {
  constructor(public name: string) {}
  clone(): this {
    // 这里需要传递参数,否则报错
    return new (this.constructor as any)(this.name);
  }
}

七、总结与最佳实践

  • Java:类内 new 自身只能 new 明确的类名,继承时不会多态。需要多态时可用反射或工厂模式。
  • TypeScript:类内 new 自身可以用 this.constructor 实现多态,配合 this 类型更灵活。constructor 默认 public,访问控制可实现单例等模式。

建议:

  • 需要多态工厂时,TypeScript 推荐用 this 类型+静态工厂方法。
  • Java 推荐用工厂方法或反射,避免直接在父类中 new 子类。

Langchain4j Rag 知识库教程

 Langchain4j Rag 知识库教程

Rag 原理

RAG,Retrieval Augmented Generation,检索增强生成。通过检索外部知识库的方式增强大模型的生成能力。

基础大模型训练完成后,随着时间的推移,产生的新数据大模型是无法感知的;而且训练大模型的都是通用数据,有关专业领域的数据大模型也是不知道的。此时就需要外挂一个知识库。

其中,2.3 组织Prompt、3.1 发送Prompt、3.2 生成结果、3.3 返回响应、4 返回响应的流程由 Langchain4j 来完成。

向量知识库

向量数据库: Milvus、Chroma、Pinecone、RedisSearch(Redis)、pgvector(PostgreSQL) 向量是表示具有大小和方向的量。

向量余弦相似度,用于表示坐标系中两个点之间的距离远近

多维向量余弦相似度

向量知识库索引和检索

索引(存储)

向量存储步骤:

  1. 把最新或者专业的数据存储到文档(Document)中
  2. 文本分割器把一个大的文档切割成一个一个小的文本片段(Segments)
  3. 这些小的文本片段需要用一种专门擅长文本向量化的向量大模型转换成向量(Embeddings)
  4. 把文本片段对应的向量存储到向量数据库(Embedding Store)中

检索

检索阶段通常在线进行,当用户提交一个应该使用索引文档回答的问题时。

这个过程可能因使用的信息检索方法而异。 对于向量搜索,这通常涉及嵌入用户的查询(问题) 并在嵌入存储中执行相似度搜索。 然后将相关片段(原始文档的片段)注入到提示中并发送给 LLM。

如果余弦相似度 > 0.5的数据会被检索出来,然后再把检索结果和用户输入发送给大模型,大模型响应后返回给用户。

Rag 快速入门

存储:构建向量数据库操作对象

引入依赖

<!-- 提供向量数据库和向量模型 -->
<dependency>
    <groupld>dev.langchain4j</groupld>
    <artifactld>langchain4j-easy-rag</artifactld>
    <version>1.0.1-beta6</version>
</dependency>

 加载知识数据文档

List<Document> documents = ClassPathDocumentLoader.loadDocuments("文档路径");

构建向量数据库操作对象

InMemoryEmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>();

把文档切割、向量化并存储到向量数据库中

EmbeddingStorelngestor ingestor = EmbeddingStorelngestor.builder()
        .embeddingStore(store)
        .build();
ingestor.ingest(documents);

检索:构建向量数据库检索对象

构建向量数据库检索对象

ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(store) // 指定向量数据库
        .maxResults(3) // 最高、最多检索结果的数量
        .minScore(0.6) // 最小余弦相似度
        .build();

配置向量数据库检索对象

@AiService(
        wiringMode = AiServiceWiringMode.EXPLICIT,
        contentRetriever = "retriever"
)

Rag 核心 API

Document Loader 文档加载器

用于把磁盘或者网络中的数据加载进程序,常用的文档加载器:

  • FileSystemDocumentLoader,根据本地磁盘绝对路径加载
  • ClassPathDocumentLoader,相对于类路径加载
  • UrlDocumentLoader,根据url路径加载

Document Parser 文档解析器

用于解析使用文档加载器加载进内存的内容,把非纯文本数据转化成纯文本,常用的文档解析器:

  • TextDocumentParser,解析纯文本格式的文件
  • ApachePdfBoxDocumentParser,解析pdf格式文件
  • ApachePoiDocumentParser,解析微软的office文件,例如DoC、PPT、XLS
  • ApacheTikaDocumentParser(默认),几乎可以解析所有格式的文件

Document Splitter 文档分割器

用于把一个大的文档,切割成一个一个的小片段,常用的文档分割器:

  • DocumentByParagraphSplitter,按照段落分割文本
  • DocumentByLineSplitter,按照行分割文本
  • DocumentBySentenceSplitter,按照句子分割文本
  • DocumentByWordSplitter,按照词分割文本
  • DocumentByCharacterSplitter,按照固定数量的字符分割文本
  • DocumentByRegexSplitter,按照正则表达式分割文本
  • DocumentSplitters.recursive(...)(默认),递归分割器,优先段落分割, 再按照行分割,再按照句子分割,再按照词分割

Embedding Model 向量模型

用于把文档分割后的片段向量化或者查询时把用户输入的内容向量化

Langchain4j 内置的向量模型

内置的向量模型可能不是那么强大,需要在application.yml中配置第三方更强大的向量模型 配置完成后 Langchain4j 会根据配置信息向容器中注入一个向量模型对象,我们只需要把该向量模型对象设置给EmbeddingStoreIngestorEmbeddingStoreContentRetriever即可。

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
          .embeddingStore(store)
          .documentSplitter(ds)
          .embeddingModel(embeddingModel)
          .build();
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
                .embeddingStore(store)
                .embeddingModel(embeddingModel)
                .minScore(0.5)
                .maxResults(3)
                .build();

EmbeddingStore 向量数据库操作对象

配置 RedisSearch 向量数据库

参考链接:

RAG 工作机制详解——一个高质量知识库背后的技术全流程

黑马程序员LangChain4j从入门到实战项目全套视频课程,涵盖LangChain4j+ollama+RAG

Vue3 响应式来!

什么是响应式 js 本身没有响应式,思考这个结果 即使 count 有变化 第二次打印还是 2 Vue 响应式 流程 Vue 2 defineProperty 定义个一个对象 obj,使用define
❌