普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月7日掘金 前端

虚拟列表:从定高到动态高度的 Vue 3 & React 满分实现

2026年2月7日 11:49

前言

在处理海量数据渲染(如万级甚至十万级列表)时,直接操作 DOM 会导致严重的页面卡顿甚至崩溃。虚拟列表(Virtual List) 作为前端性能优化的“核武器”,通过“只渲染可视区”的策略,能将渲染性能提升数个量级。本文将带你从零实现一个支持动态高度的通用虚拟列表。

定高虚拟列表滚动.gif

一、 核心原理解析

虚拟列表本质上是一个“障眼法”,其结构通常分为三层:

  1. 外层容器(Container) :固定高度,设置 overflow: auto,负责监听滚动事件。
  2. 占位背景(Placeholder) :高度等于“总数据量 × 列表项高度”,用于撑开滚动条,模拟真实滚动的视觉效果。
  3. 渲染内容区(Content Area) :绝对定位,根据滚动距离动态计算起始索引,并通过 translateY 偏移到当前可视区域。

image.png


二、 定高虚拟列表

1. 设计思路

  • 可视项数计算Math.ceil(容器高度 / 固定高度) ± 缓冲区 (BUFFER)
  • 起始索引Math.floor(滚动距离 / 固定高度)
  • 偏移量起始索引 * 固定高度

2. Vue 3 + TailwindCSS实现

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:用于撑开滚动条,高度 = 总数据量 * 每项高度 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表:通过 transform 定位到滚动位置 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <div
            v-for="item in visibleList"
            :key="item.id"
            class="py-2 px-4 border-b border-gray-200"
            :class="{
              'bg-pink-200 h-[100px]': item.id % 2 !== 0,
              'bg-green-200 h-[100px]': item.id % 2 === 0,
            }"
          >
            {{ item.name }}
          </div>
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter();

const ITEM_HEIGHT = 100; // 列表项固定高度(与样式中的 h-[100px] 一致)
const BUFFER = 5; // 缓冲区数量,避免滚动时出现空白

const virtualListRef = ref<HTMLDivElement | null>(null);

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动容器的滚动距离

// 总列表高度(撑开滚动条用)
const totalHeight = computed(() => ListData.value.length * ITEM_HEIGHT);

// 可视区域高度(滚动容器的高度)
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || 0;
});

// 可视区域可显示的列表项数量(向上取整 + 缓冲区)
const visibleCount = computed(() => {
  return Math.ceil(viewportHeight.value / ITEM_HEIGHT) + BUFFER;
});

// 当前显示的起始索引
const startIndex = computed(() => {
  // 滚动距离 / 每项高度 = 跳过的项数(向下取整)
  const index = Math.floor(scrollTop.value / ITEM_HEIGHT);
  // 防止索引为负数
  return Math.max(0, index);
});

// 当前显示的结束索引
const endIndex = computed(() => {
  const end = startIndex.value + visibleCount.value;
  // 防止超出总数据长度
  return Math.min(end, ListData.value.length);
});

// 可视区域需要渲染的列表数据
const visibleList = computed(() => {
  return ListData.value.slice(startIndex.value, endIndex.value);
});

// 可视区域的偏移量(让列表项定位到正确位置)
const offsetY = computed(() => {
  return startIndex.value * ITEM_HEIGHT;
});

// 处理滚动事件
const handleScroll = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 返回首页
const goBack = () => {
  router.push('/home');
};

// 初始化
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
});
</script>

3. 实现效果图

定高虚拟列表滚动.gif


三、 进阶:不定高(动态高度)虚拟列表

在实际业务(如社交动态、聊天记录)中,每个 Item 的高度往往是不固定的。

1. 核心改进思路

  • 高度映射表(Map) :记录每一个 Item 渲染后的真实高度。
  • 累计高度数组(Cumulative Heights) :存储每一项相对于顶部的偏移位置。
  • ResizeObserver:利用该 API 监听子组件高度变化,实时更新映射表,解决图片加载或文本折行导致的位移。

2. Vue 3 + tailwindCSS 实现(子组件抽离)

子组件: 负责上报真实高度:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.name }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    name: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

父组件:核心逻辑

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div class="bg-white mt-20 h-[calc(100vh-200px)] rounded-xl">
      <!-- 滚动容器 -->
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative"
        @scroll="handleScroll"
      >
        <!-- 占位容器:撑开滚动条 -->
        <div :style="{ height: `${totalHeight}px` }"></div>

        <!-- 可视区域列表 -->
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <!-- 渲染子组件,监听高度更新事件 -->
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
    <div
      class="fixed top-2 left-24 -translate-x-1/2 px-8 py-3 bg-white text-indigo-600 rounded-full text-base font-semibold cursor-pointer shadow-lg transition-all duration-300 hover:-translate-x-1/2 hover:-translate-y-0.5 hover:shadow-2xl"
      @click="goBack"
    >
      ← 返回首页
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import VirtualListItem from './listItem.vue'; // 引入子组件

const router = useRouter();

const MIN_ITEM_HEIGHT = 100; // 子项预设的最小高度
const BUFFER = 5; //上下缓冲区数目
const virtualListRef = ref<HTMLDivElement | null>(null); // 滚动容器引用

const ListData = ref<any[]>([]); // 完整列表数据
const scrollTop = ref(0); // 滚动距离
const itemHeights = ref<Map<number, number>>(new Map()); // 子组件高度映射表
const cumulativeHeights = ref<number[]>([0]); // 累计高度数组
const scrollTimer = ref<number | null>(null); // 滚动节流定时器
const isUpdatingCumulative = ref(false); // 累计高度更新防抖

// 初始化位置数据
const initPositionData = () => {
  // 初始化高度映射表(默认最小高度)
  const heightMap = new Map<number, number>();
  ListData.value.forEach((item) => {
    heightMap.set(item.id, MIN_ITEM_HEIGHT);
  });
  // 初始化累计高度
  updateCumulativeHeights();
};

// 更新累计高度(核心)
const updateCumulativeHeights = () => {
  if (isUpdatingCumulative.value) return;
  isUpdatingCumulative.value = true;

  const itemCount = ListData.value.length;
  const cumulative = [0];
  let sum = 0;

  for (let i = 0; i < itemCount; i++) {
    const itemId = ListData.value[i].id;
    sum += itemHeights.value.get(itemId) || MIN_ITEM_HEIGHT;
    cumulative.push(sum);
  }

  cumulativeHeights.value = cumulative;
  isUpdatingCumulative.value = false;
};

// 处理子组件的高度更新事件
const handleItemHeightUpdate = (id: number, height: number) => {
  // 高度未变化则跳过
  if (itemHeights.value.get(id) === height) return;

  // 更新高度映射表
  itemHeights.value.set(id, height);

  // 异步更新累计高度(避免同步更新导致的性能问题)
  nextTick(() => {
    updateCumulativeHeights();
  });
};

// 总高度,根据统计高度数组最后一个值计算得出
const totalHeight = computed(() => {
  return cumulativeHeights.value[cumulativeHeights.value.length - 1] || 0;
});

// 列表可视区域高度
const viewportHeight = computed(() => {
  return virtualListRef.value?.clientHeight || MIN_ITEM_HEIGHT * 5;
});

// 计算起始索引
const startIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  if (totalItemCount === 0) return 0;
  if (scrollTop.value <= 0) return 0;

  let baseStartIndex = 0;
  // 反向遍历找起始索引
  for (let i = cumulativeHeights.value.length - 1; i >= 0; i--) {
    if (cumulativeHeights.value[i] <= scrollTop.value) {
      baseStartIndex = i;
      break;
    }
  }
  const finalIndex = Math.max(0, baseStartIndex - BUFFER); // 确保不小于0
  return Math.min(finalIndex, totalItemCount - 1);
});

// 计算结束索引
const endIndex = computed(() => {
  const totalItemCount = ListData.value.length;
  const viewportHeightVal = viewportHeight.value;
  if (totalItemCount === 0) return 0;

  const targetScrollBottom = scrollTop.value + viewportHeightVal; // 目标滚动到底部位置
  let baseEndIndex = totalItemCount - 1;
  for (let i = 0; i < cumulativeHeights.value.length; i++) {
    if (cumulativeHeights.value[i] > targetScrollBottom) {
      baseEndIndex = i - 1;
      break;
    }
  }
  const finalEndIndex = Math.min(baseEndIndex + BUFFER, totalItemCount - 1); // 确保不大于总项数-1
  return finalEndIndex;
});

// 可见列表
const visibleList = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return start <= end ? ListData.value.slice(start, end + 1) : [];
});

const offsetY = computed(() => {
  return cumulativeHeights.value[startIndex.value] || 0;
});

// 滚动节流处理
const handleScroll = () => {
  if (!virtualListRef.value) return;

  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  scrollTimer.value = window.setTimeout(() => {
    scrollTop.value = virtualListRef.value!.scrollTop;
  }, 20);
};

const handleResize = () => {
  if (virtualListRef.value) {
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

const goBack = () => {
  router.push('/home');
};

// 生命周期
onMounted(() => {
  // 生成模拟数据
  ListData.value = Array.from({ length: 1000 }, (_, index) => ({
    id: index,
    name: `Item ${index}`,
  }));
  initPositionData();
  window.addEventListener('resize', handleResize); // 监听窗口大小变化
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
  if (scrollTimer.value) clearTimeout(scrollTimer.value);
  isUpdatingCumulative.value = false;
  itemHeights.value.clear();
});
</script>

3. React + tailwindCSS 实现(子组件抽离)

子组件:

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface VirtualListItemProps {
  item: {
    id: number;
    name: string;
  };
  onUpdateHeight: (id: number, height: number) => void; // 替代 Vue 的 emit
}

const VirtualListItem: React.FC<VirtualListItemProps> = ({
  item,
  onUpdateHeight,
}) => {
  const itemRef = useRef<HTMLDivElement>(null);
  // 存储 ResizeObserver 实例(避免重复创建)
  const resizeObserverRef = useRef<ResizeObserver | null>(null);

  // 计算并上报高度
  const sendItemHeight = useCallback(() => {
    if (!itemRef.current) return;
    const realHeight = itemRef.current.offsetHeight;
    onUpdateHeight(item.id, realHeight);
  }, [item.id, onUpdateHeight]);

  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);

    // 初始化 ResizeObserver 监听高度变化
    if (window.ResizeObserver) {
      resizeObserverRef.current = new ResizeObserver(() => {
        sendItemHeight();
      });
      if (itemRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }
    }

    // 清理定时器(对应 Vue 的 onUnmounted 部分)
    return () => {
      clearTimeout(timer);
      if (resizeObserverRef.current) {
        resizeObserverRef.current.disconnect();
        resizeObserverRef.current = null;
      }
    };
  }, [sendItemHeight]); // 仅首次挂载执行

  //监听 item 变化重新计算高度
  useEffect(() => {
    const timer = setTimeout(() => {
      sendItemHeight();
    }, 0);
    return () => clearTimeout(timer);
  }, [item.id, sendItemHeight]); // item.id 变化时执行

  const itemClass = `py-2 px-4 border-b border-gray-200 ${
    item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'
  }`;

  const itemStyle: React.CSSProperties = {
    height: item.id % 2 === 0 ? '150px' : '100px',
  };

  return (
    <div ref={itemRef} className={itemClass} style={itemStyle}>
      {item.name}
    </div>
  );
};

export default VirtualListItem;


父组件:

import React, {
  useEffect,
  useRef,
  useState,
  useCallback,
  useMemo,
} from 'react';
import VirtualListItem from './listItem';

const VirtualList: React.FC = () => {
  const MIN_ITEM_HEIGHT = 100; // 最小项高度
  const BUFFER = 5; // 缓冲区项数

  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用

  const [listData, setListData] = useState<Array<{ id: number; name: string }>>(
    []
  ); // 列表数据
  const [scrollTop, setScrollTop] = useState(0); // 滚动位置
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 高度映射表(Map 结构)
  const [cumulativeHeights, setCumulativeHeights] = useState<number[]>([0]); // 累计高度数组
  const scrollTimerRef = useRef<number | null>(null); // 滚动节流定时器

  // 初始化模拟数据
  const initData = () => {
    const mockData = Array.from({ length: 1000 }, (_, index) => ({
      id: index,
      name: `Item ${index}`,
    }));
    setListData(mockData);
    // 初始化高度映射表(默认最小高度)
    const initHeightMap = new Map<number, number>();
    mockData.forEach((item) => {
      initHeightMap.set(item.id, MIN_ITEM_HEIGHT);
    });
    setItemHeights(initHeightMap);
    // 初始化累计高度
    updateCumulativeHeights(initHeightMap, mockData);
  };

  useEffect(() => {
    initData();
    // 监听窗口大小变化
    const handleResize = () => {
      if (virtualListRef.current) {
        setScrollTop(virtualListRef.current.scrollTop);
      }
    };
    window.addEventListener('resize', handleResize);

    // 清理监听
    return () => {
      window.removeEventListener('resize', handleResize);
      if (scrollTimerRef.current) {
        clearTimeout(scrollTimerRef.current);
      }
      itemHeights.clear(); // 清空 Map 释放内存
    };
  }, []);

  // 更新累计高度(核心函数)
  const updateCumulativeHeights = useCallback(
    (heightMap: Map<number, number>, data: typeof listData) => {
      const cumulative = [0];
      let sum = 0;
      for (let i = 0; i < data.length; i++) {
        const itemId = data[i].id;
        sum += heightMap.get(itemId) || MIN_ITEM_HEIGHT;
        cumulative.push(sum);
      }
      setCumulativeHeights(cumulative);
    },
    [MIN_ITEM_HEIGHT]
  );

  // 处理子组件的高度更新事件(对应 Vue 的 handleItemHeightUpdate)
  const handleItemHeightUpdate = useCallback(
    (id: number, height: number) => {
      // 高度未变化则跳过
      if (itemHeights.get(id) === height) return;

      // 更新高度映射表
      const newHeightMap = new Map(itemHeights);
      newHeightMap.set(id, height);
      setItemHeights(newHeightMap);

      // 异步更新累计高度
      setTimeout(() => {
        updateCumulativeHeights(newHeightMap, listData);
      }, 0);
    },
    [itemHeights, listData, updateCumulativeHeights]
  );

  // 滚动节流处理
  const handleScroll = useCallback(() => {
    if (!virtualListRef.current) return;

    // 节流:20ms 内只更新一次 scrollTop
    if (scrollTimerRef.current) {
      clearTimeout(scrollTimerRef.current);
    }
    scrollTimerRef.current = setTimeout(() => {
      setScrollTop(virtualListRef.current!.scrollTop);
    }, 20);
  }, []);

  // 可视区域高度
  const viewportHeight = useMemo(() => {
    return virtualListRef.current?.clientHeight || MIN_ITEM_HEIGHT * 5;
  }, []);

  //  总列表高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;
    if (scrollTop <= 0) return 0;

    // 反向遍历找起始索引
    let baseStartIndex = 0;
    for (let i = cumulativeHeights.length - 1; i >= 0; i--) {
      if (cumulativeHeights[i] <= scrollTop) {
        baseStartIndex = i;
        break;
      }
    }

    const finalIndex = Math.max(0, baseStartIndex - BUFFER);
    return Math.min(finalIndex, totalItemCount - 1);
  }, [
    scrollTop,
    viewportHeight,
    totalHeight,
    cumulativeHeights,
    listData.length,
  ]);

  // 结束索引
  const endIndex = useMemo(() => {
    const totalItemCount = listData.length;
    if (totalItemCount === 0) return 0;

    const targetScrollBottom = scrollTop + viewportHeight;
    let baseEndIndex = totalItemCount - 1;

    for (let i = 0; i < cumulativeHeights.length; i++) {
      if (cumulativeHeights[i] > targetScrollBottom) {
        baseEndIndex = i - 1;
        break;
      }
    }

    let finalEndIndex = baseEndIndex + BUFFER;
    finalEndIndex = Math.min(finalEndIndex, totalItemCount - 1);
    return finalEndIndex;
  }, [scrollTop, viewportHeight, cumulativeHeights, listData.length]);

  // 可视区列表
  const visibleList = useMemo(() => {
    return startIndex <= endIndex
      ? listData.slice(startIndex, endIndex + 1)
      : [];
  }, [startIndex, endIndex, listData]);

  // 偏移量
  const offsetY = useMemo(() => {
    return cumulativeHeights[startIndex] || 0;
  }, [startIndex, cumulativeHeights]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div className="bg-white mt-10 h-[calc(100vh-200px)] rounded-xl">
        {/* 滚动容器 */}
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
        >
          {/* 占位容器:撑开滚动条 */}
          <div style={{ height: `${totalHeight}px` }}></div>

          {/* 可视区域列表:transform 偏移 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{ transform: `translateY(${offsetY}px)` }}
          >
            {visibleList.map((item) => (
              <VirtualListItem
                key={item.id}
                item={item}
                onUpdateHeight={handleItemHeightUpdate}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

4. 实现效果图

动高虚拟列表滚动.gif


四、 总结与避坑指南

1. 为什么需要缓冲区(BUFFER)?

如果只渲染可见部分,用户快速滚动时,异步渲染可能会导致瞬间的“白屏”。设置上下缓冲区可以预加载部分 DOM,让滑动更顺滑。

2. 性能进一步优化

  • 滚动节流(Throttle) :虽然滚动监听很快,但在 handleScroll 中加入 requestAnimationFrame 或 20ms 的节流,能有效减轻主线程压力。
  • Key 的选择:在虚拟列表中,key 必须是唯一的 id,绝对不能使用 index,否则在滚动重用 DOM 时会出现状态错乱。

3. 注意事项

  • 定高:逻辑简单,性能极高。
  • 不定高:依赖 ResizeObserver,需注意频繁重排对性能的影响,建议对 updateCumulativeHeights 做异步批处理。

这 7 个免费 Lottie 动画网站,帮你省下一个设计师的工资

2026年2月7日 11:03

大家好,我是大华!

有时候写前端,会觉得:同样是写页面,为什么有些产品一看就很舒服,而自己写的界面,怎么看都觉得很笨重。

是他们用了什么高深的技术吗?

其实那些看起来很好看,很丝滑的动画,大多数项目只是用了 Lottie。

比如下面这种动画效果:

什么是Lottie动画?

Lottie 并不是某种前端框架,而是一种动画文件格式和播放方案。 设计师在 After Effects 里把动画做好,导出成 JSON 文件,前端或客户端只需要通过 Lottie 播放器加载,就能把动画渲染出来。

和 GIF 或视频相比,Lottie 的体积更小、更轻量。 它是矢量动画,体积小、放大不会失真,还可以通过代码控制播放、暂停、循环,甚至动态改颜色和速度。

怎么使用?

一、在 HTML / 原生页面中使用 Lottie

这是最简单、也是最通用的一种方式,适合官网、活动页、Demo 页面。

现在官方更推荐用 lottie-web + <lottie-player> 这种方式,基本零学习成本。

1️⃣ 引入 Lottie 播放器

直接在 HTML 里引入官方 CDN:

<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>

2️⃣ 在页面中使用

<lottie-player
  src="https://assets.lottiefiles.com/packages/lf20_x62chJ.json"
  background="transparent"
  speed="1"
  style="width: 300px; height: 300px;"
  loop
  autoplay>
</lottie-player>

这样,一个简单的动画就已经跑起来了,效果如下:

你可以简单理解为: Lottie = 一个自定义的 HTML 标签,src 指向 JSON 文件即可。

常用属性也很好记:

  • loop:是否循环
  • autoplay:是否自动播放
  • speed:播放速度
  • style:控制大小

如果你只是想加个加载动画、空状态动画,这种方式已经完全够用了。


二、在 Vue 项目中使用 Lottie

在 Vue 里,一般会直接使用 lottie-web,控制力更强,适合业务场景。

1️⃣ 安装依赖

npm install lottie-web

2️⃣ 在组件中使用

<template>
  <div ref="lottieContainer" style="width: 300px; height: 300px;"></div>
</template>

<script>
import lottie from 'lottie-web'

export default {
  mounted() {
    lottie.loadAnimation({
      container: this.$refs.lottieContainer,
      renderer: 'svg',
      loop: true,
      autoplay: true,
      path: '/lottie/loading.json' // 本地或远程 JSON
    })
  }
}
</script>

这样动画会在组件挂载完成后自动播放。

这里有几个关键点,理解了基本就不怕用了:

  • container:动画挂载的 DOM
  • renderer:一般用 svg
  • path:Lottie JSON 文件路径
  • loop / autoplay:控制播放行为

三、在 Vue 里怎么控制动画?

这也是 Lottie 相比 GIF 最大的优势之一。

你可以拿到动画实例,然后随意控制:

const animation = lottie.loadAnimation({
  container: this.$refs.lottieContainer,
  renderer: 'svg',
  loop: false,
  autoplay: false,
  path: '/lottie/success.json'
})

// 手动播放
animation.play()

// 暂停
animation.pause()

// 停止
animation.stop()

比如:

  • 提交成功后再播放动画
  • 请求完成才显示动效
  • 根据状态切换不同动画

简而言之:Lottie 可以让你在应用或网站中添加更流畅的动画效果,并且不会降低性能或占用所存储的空间。


免费 Lottie 动画网站

说实话,动画制作成本很高。没人会为了做一个弹跳的购物车图标就去组件整个特效团队。 幸运的是,网上有很多免费的 Lottie 动画,你可以直接把它们添加到你的应用或者网站里面。

下面这些网站,基本可以覆盖你 80% 的日常需求,而且不花钱。

1. LottieFiles(官方首选)

Lottie 的官方平台,也是大多数人找动画的第一站。 提供大量免费动画资源,支持在线预览和修改颜色,下载的就是标准 JSON 文件,用起来非常省心。

网站:lottiefiles.com/


2. IconScout

IconScout 本身就是一个大型设计素材站,其中的免费 Lottie 动画覆盖了大量 UI 场景,从加载动画到角色动效都有,风格也比较多样。

网站:iconscout.com/


3. Storyset(Freepik 出品)

Storyset 提供可在浏览器中直接编辑的插画和 Lottie 动画,你可以自己改颜色、搭配元素,然后导出。即使完全不懂设计,也能做出“像定制过”的效果。

网站:storyset.com/


4. LottieFlow

由 Webflow 社区维护的免费 Lottie 库,主打无水印、无会员限制。 动画以 Web 场景为主,但 JSON 文件在任何项目中都能用。

网站:finsweet.com/lottieflow


其他可选资源

如果你想多囤一些风格不同的动画,这些也可以顺手收藏:


Lottie 真正解决的,其实是页面体验的问题。 它让动画变得轻量、可控,也大幅降低了开发和设计之间的协作成本。

你不需要是 After Effects 高手,也不用写复杂的动画逻辑。 很多时候,只是下载一个 JSON 文件,放进项目里,页面立刻就会多一点质感。

本文首发于公众号:程序员大华,专注前端、Java开发,AI应用和工具的分享。关注我,少走弯路,一起进步!

为什么有的函数要用 call,有的却完全不用?

作者 DoraBigHead
2026年2月7日 10:32

——从 this 设计本质理解 JavaScript 方法分类

在学习 JavaScript 的过程中,很多人都会卡在一个问题上:

为什么 Object.getPrototypeOf(obj) 不需要 call
Object.prototype.toString 却必须用 call

更进一步的问题是:

我怎么提前知道一个函数到底是“参数型函数”还是“this 型函数”?

本文将从设计层面而不是“记规则”的角度,彻底解释这个问题。


一、困惑的根源:我们混淆了两种“函数设计方式”

在 JS 里,函数只有一种语法形式,但实际上有两种完全不同的设计思路

1️⃣ 参数型函数(Parameter-based)

Object.getPrototypeOf(obj)
Object.keys(obj)
Math.max(1, 2, 3)

特点:

  • 操作对象 通过参数传入
  • 函数内部 不依赖 this
  • this 是谁 无关紧要

2️⃣ this 型函数(This-based)

obj.toString()
arr.push(1)
Object.prototype.toString.call(value)

特点:

  • 操作对象 来自 this
  • 函数内部 强依赖 this
  • 必须明确 this 指向谁

👉 是否需要使用 call,只取决于这一点


二、为什么 Object.getPrototypeOf 不需要 call?

先看调用方式:

Object.getPrototypeOf(left)

它的“设计意图”非常明确:

  • 要操作的对象是 left
  • left 已经作为参数传入
  • 函数内部只关心参数,不关心 this

可以把它理解为伪代码:

function getPrototypeOf(obj) {
  return obj.__proto__
}

👉 这是一个纯工具函数(utility function)

所以:

  • 不需要 call
  • call 反而多余

三、为什么 Object.prototype.toString 必须用 call?

再看这个经典写法:

Object.prototype.toString.call(value)

为什么不能直接这样?

Object.prototype.toString(value) // ❌

因为这个方法的设计是:

  • 没有参数
  • 要检查的对象只能来自 this

伪代码理解:

Object.prototype.toString = function () {
  return "[object " + 内部类型(this) + "]"
}

👉 如果你不告诉它 this 是谁,它根本不知道要检查什么。

这就是 必须使用 call 的根本原因


四、一个极其重要的判断标准(80% 准确)

看方法“挂在哪里”

✅ 挂在构造函数本身上的(参数型)

Object.keys
Object.getPrototypeOf
Array.isArray
Math.max

特点:

  • Object.xxx
  • Array.xxx
  • Math.xxx

👉 几乎一定是参数型函数


✅ 挂在 prototype 上的(this 型)

Object.prototype.toString
Array.prototype.push
Array.prototype.slice
Function.prototype.call

特点:

  • xxx.prototype.xxx
  • 操作“当前对象”

👉 几乎一定依赖 this


口诀总结(非常重要)

静态方法用参数,原型方法靠 this


五、最可靠的方法:一行代码验证

如果你真的不确定,直接用这一招。

验证是否依赖 this

const fn = Object.prototype.toString
fn() // ❌ 报错或结果异常

👉 没有 this 就不能工作 → this 型函数


验证是否依赖参数

const fn = Object.getPrototypeOf
fn({}) // ✅ 正常执行

👉 this 不重要 → 参数型函数


六、为什么不能“所有函数都用 call”?

技术上可以,但语义上是错误的

Object.getPrototypeOf.call(null, obj)

问题在于:

  • this 被完全忽略
  • 代码可读性变差
  • 违背 JS API 的设计初衷

👉 call 的存在是为了解决 this,而不是统一写法


七、总结一句话(博客结尾版)

JS 中是否使用 call
不取决于“函数高级不高级”,
只取决于“这个函数是否依赖 this”。


八、你现在卡住,其实非常正常

你现在遇到的不是“语法问题”,而是:

从“会用 JS” → “理解 JS 设计” 的过渡阶段

这是一个所有中高级 JS 开发者都必经的坎。

Vue3中非响应式的全局状态管理

作者 EchoEcho
2026年2月7日 08:14

vue项目中,一般说到状态管理,我们会想到pinia,它可以帮助我们管理需要在多个页面、组件间共享的数据,并且根据数据的更新触发相关的渲染更新。但如果是数据变化不会引起页面刷新的全局数据呢?

 比如我当前开发的项目中,需要在项目初始化之后获取对应的引擎实例,该实例提供相关api用于处理页面逻辑,但该实例并不会触发页面的更新,此时就需要一个 非响应式的全局状态管理 -- globalState

适用场景:
  • 全局配置、缓存、临时数据
  • 跨页面/组件的事件通知
  • 不需要响应式的数据共享
不适用场景
  • 需要数据变化时自动刷新页面的场景【用PiniaVuex
逻辑梳理:
  1.  定义一个globalState类,全局只有一个实例
  2. 维护一份state数据,提供setgethasdeleteclear
  3. 提供事件总线功能,用于在不同组件间“广播消息”: on【监听事件】、emit【触发事件】、off【移除监听】

具体实现代码:

/stores/globalState.ts

// 全局状态管理(非响应式)
/**
 * 跨页面/组件共享数据,但又不需要响应式(不需要自动刷新UI)。
 * 存储全局配置、缓存、临时数据等。
 * 避免污染 window,比直接用 window.xxx 更安全、可控、易维护。
 * 比 Pinia/Vuex 更轻量,适合存储不需要响应式的数据。
 */

class GlobalState {
    private static instance: GlobalState
    private state: Map<string, any> = new Map()
    private eventListeners: Map<string, Function[]> = new Map()

    static getInstance(): GlobalState {
        if(!GlobalState.instance) {
            GlobalState.instance = new GlobalState()
        }
        return GlobalState.instance
    }
    
    set(key: string, value: any): void {
        this.state.set(key, value)
    }

    get(key: string): any {
        return this.state.get(key)
    }

    has(key: string): boolean {
        return this.state.has(key)
    }

    delete(key: string): boolean {
        return this.state.delete(key)
    }

    clear(): void {
        this.state.clear()
    }

    // 新增事件总线功能
    on(eventName: string, callback: Function): void {
        if(!this.eventListeners.has(eventName)) {
            this.eventListeners.set(eventName, [])
        }
        this.eventListeners.get(eventName)?.push(callback)
    }
    
    emit(eventName: string, data?: any): void {
        const listeners = this.eventListeners.get(eventName)
        if(listeners) {
            listeners.forEach(callback => {
                try {
                    callback(data)
                } catch (error) {
                    console.error('事件回调执行错误:', error)
                }
            })
        }
    }

    off(eventName: string, callback?: Function): void {
        if (!callback) {
            this.eventListeners.delete(eventName)
        } else {
            const listeners = this.eventListeners.get(eventName)
            if (listeners) {
                const index = listeners.indexOf(callback)
                if (index > -1) {
                    listeners.splice(index, 1)
                }
            }
        }
    }

}

export const globalState = GlobalState.getInstance()
使用
  1. 组件A -- a.vue  【当引擎实例化成功后,设置引擎数据,并触发广播,在适合的时机销毁相关数据】
// a.vue 当引擎实例化成功后,设置引擎数据,并触发广播
import { globalState } from "@/stores/globalState";
const InstanceHasInited = (data) => {
      if (engine) {
            globalState.set("engineInstance", data);
            // 触发事件,通知其他组件
            globalState.emit("engineInstance:created", data);
      }
});
// 销毁相关数据
onUnmounted(() => {
  globalState.delete("engineInstance");
  globalState.off("engineInstance:created");
});
  1. 组件B -- b.vue  【在需要使用引擎api获取数据的位置添加监听】

import { globalState } from "@/stores/globalState";
const handleInstanceCreated = (engine: Engine) => {
    if(engine) {
        // 调用相关api
    }
}
onMounted(() => {
  // 监听引擎实例创建事件
  globalState.on("engineInstance:created", handleInstanceCreated);
})

挑战全栈框架的极限:仅 7kb 的 Lupine.js 发布了

作者 uuware
2026年2月7日 06:55

Lupine.js:一款"极其"高效的 Web 框架

在一个被庞大的元框架 (Meta-frameworks) 和复杂构建链主导的世界里,Lupine.js 提出了一个简单的问题:如果我们能拥有现代全栈框架的威力,却不需要那些臃肿的负担,会怎样?

Lupine.js 是一个 轻量级 (7kb gzipped)全栈 Web 框架,它结合了类 React 的前端体验和类 Express 的后端架构。它是完全从零开始设计,旨在实现极致的速度、简洁和高效。

og-image.png

为什么选择 Lupine.js?

1. 🪶 极其轻量的前端

lupine.web 前端包极其小巧——仅 7kb gzipped。然而,它保留了你熟悉和喜爱的开发体验:TSX 语法 (React JSX)、组件和 Hooks。没有沉重的运行时需要下载,这意味着即使在慢速网络下,你的页面也能瞬间加载。

2. ⚡ 内置服务端渲染 (SSR)

大多数框架将 SSR 视为附加功能。在 Lupine 中,SSR 是 一等公民lupine.api 后端经过优化,能够自动在服务器上渲染你的前端页面。

  • 无样式闪烁 (No FOUC): 关键 CSS 由服务端注入。
  • 零配置 SEO: Meta 标签 (og:image, description) 在页面离开服务器前就已经计算完毕。
  • 社交分享就绪: 分享到 Twitter/微信/Facebook 的链接开箱即用,效果完美。

3. 🎨 原生 CSS-in-JS 引擎

告别配置 PostCSS、Tailwind 或 styled-components 的烦恼。Lupine 内置了一个强大的 CSS-in-JS 引擎。

  • 样式隔离: 样式自动隔离到你的组件。
  • 嵌套支持: 支持 .parent & 语法。
  • 高性能: 样式在 SSR 期间被高效提取和注入。
const Button = () => {
  const css = {
    backgroundColor: '#0ac92a',
    '&:hover': {
      backgroundColor: '#08a823',
    },
  };
  return <button css={css}>点击我</button>;
};

4. 🚀 全栈合一

Lupine 不仅仅是一个前端库;它是完整的应用解决方案。

  • 后端 (lupine.api): 一个高效、极简的 Node.js 框架,类似于 Express。
  • 前端 (lupine.web): 一个响应式的 UI 库。
  • 开发体验: 运行 npm run dev,即可在同一个 VS Code 会话中同时调试前端和后端。

快速开始

准备好尝试了吗?几秒钟就能搭建一个新的项目。

第一步:创建项目

使用我们的 CLI 工具创建一个新应用。

npx create-lupine@latest my-awesome-app

第二步:运行项目

进入目录并启动开发服务器。

cd my-awesome-app
npm install
npm run dev

访问 http://localhost:11080,你将看到你的第一个 Lupine 应用正在运行!

代码活跃度

Lupine 正在积极开发中。你可以直接在 GitHub 上查看我们的代码频率和贡献: 👉 github.com/uuware/lupi…

总结

Lupine.js 非常适合这样的开发者:

  • 掌控力: 想要了解技术栈的每一个部分。
  • 速度: 想为用户提供最快的体验。
  • 简洁: 没有隐藏的魔法,只有干净的代码。

Lupine.js 在 GitHub 上点个 Star,并在你的下一个项目中尝试一下吧!

TypeScript 泛型从轻松入门到看懂源码

作者 SuperEugene
2026年2月7日 02:02

从「完全不懂泛型」一路走到「看懂下面这段代码到底在干嘛」:

//此代为为VxeTable组件库Grid配置式表格数据分页示例代码部分片段
<script lang="ts" setup>
import { reactive } from 'vue'
import type { VxeGridProps, VxeGridListeners } from 'vxe-table'

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

const gridOptions = reactive<VxeGridProps<RowVO>>({  
  showOverflow: true,  
  border: true,  
  loading: false,  
  height: 500,  
  pagerConfig: pagerVO,  
  columns: [    
    { type: 'seq', width: 70, fixed: 'left' },    
    { field: 'name', title: 'Name', minWidth: 160 },    
    { field: 'email', title: 'Email', minWidth: 160 },    
    { field: 'nickname', title: 'Nickname', minWidth: 160 },    
    { field: 'age', title: 'Age', width: 100 },    
    { field: 'role', title: 'Role', minWidth: 160 },    
    { field: 'amount', title: 'Amount', width: 140 },    
    { field: 'updateDate', title: 'Update Date', visible: false },    
    { field: 'createDate', title: 'Create Date', visible: false },  
  ],  
  data: [],
})
</script>

VxeTable组件库简介:

  • 由于这篇文章引用到了VxeTable组件库的代码,所以在这里给没接触过的小伙伴做一个简单的介绍,老司机可自行跳过。
  • VxeTable是一个基于 Vue 的表格组件库,提供表格、表单、工具栏、分页等组件,适合中后台场景。性能与功能都较强,但学习成本和按需引入的配置需要投入时间。如果你的项目以表格为核心,且需要虚拟滚动、复杂交互等功能,VxeTable 是合适的选择。感兴趣的小伙伴可以通过下方贴上的官网链接学习了解。

(PS·即使没用过VxeTable也不影响你看懂这篇文章)

官网链接:VxeTable官网

一、什么是泛型?一句话版本

泛型 = 给 “类型” 加参数。

  • 函数可以有参数:function fn(x: number) {}
  • 类型也可以有 “参数”:Array<string>

这里 Array 就是一个「带类型参数」的类型,<string> 就是「传给它的类型参数」。

用人话说:

泛型就是:我写一份通用的类型 / 函数,真正用的时候再告诉它具体用什么类型

二、最普通的一层泛型

1. 最熟悉的例子:数组

// 这俩是完全等价的
const list1: string[] = []
const list2: Array<string> = []
  • Array<T> 是一个泛型类型
  • T 是它的类型参数
  • Array<string> 表示「元素类型是 string 的数组」

2. 自己写一个泛型函数

function identity<T>(value: T): T {  
  return value
}
identity<number>(1)      // T 被替换成 number
identity<string>('hi')   // T 被替换成 string

你可以理解为:

  • 定义:identity<T>T 是一个「占位的类型」
  • 使用:identity<number> → 这次调用里「把 T 换成 number

或许有同学不理解为什么要在函数名称后面写<T>。不用纠结,这是固定的写法,就像你要使用变量,就要先声明一样,如:

let data = []
data.push(123)

如果此处没有声明data,便用不了data。同理如果不在函数名称后面写<T>声明一下这是泛型参数,TypeScript 无法识别 T 是什么,如下:

//  错误:找不到名称 'T'
function identity(value: T): T {
  return value
}
// 报错:Cannot find name 'T'

TypeScript 会把 T 当作一个未声明的类型,因此报错。

三、类型也可以是泛型:接口 /type

1. 泛型接口

// 使用时传入不同的 T
interface ApiResponse<T> {  
  code: number  
  msg: string  
  data: T
}

interface User {  
  id: number  
  name: string
}

const res1: ApiResponse<User> = {  
  code: 0,  
  msg: 'ok',  
  data: { id: 1, name: '张三' },
}

const res2: ApiResponse<string[]> = {  
  code: 0,  
  msg: 'ok',  
  data: ['a', 'b'],
}

观察:

  • ApiResponse<T> 自己并不知道 T 是啥
  • 真正用的时候写 ApiResponse<User> / ApiResponse<string[]>
  • TypeScript 在这一刻才把 T 替换掉

四、嵌套泛型:泛型里面再套泛型

其实很简单,就是「类型参数本身也是一个泛型类型」。

// 一层:数组里放字符串
Array<string>

// 两层:Promise 里放数组,数组里放字符串
Promise<Array<string>>

// 换个写法更直观
type StringArray = Array<string>
type StringArrayPromise = Promise<StringArray>

你可以这么想:

  • 第一层:Array<T>
  • 第二层:Promise<第一层>

五、回到文章最开始的例子:VxeGridProps<RowVO>

先看定义的行数据类型:

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

然后:

const gridOptions = reactive<VxeGridProps<RowVO>>({...})

拆开理解:

  • VxeGridProps<D = any> 是 vxe-table 提供的泛型接口
  • 你写的是 VxeGridProps<RowVO>
  • 这一刻,D 就被替换成了 RowVO

也就是在这一整次使用里,可以把它脑补成:

// 伪代码,仅用于理解
interface VxeGridProps_RowVO extends VxeTableProps<RowVO> {  
  columns?: VxeGridPropTypes.Columns<RowVO>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<RowVO>  
  // ...
}

可能很多同学看到这里会感到些疑惑,怎么一会儿T一会儿D的。其实不管是T还是D都是类型变量的自定义名称,叫什么都无所谓,语法上没有任何固定含义,就像你写 JS 时给变量起名num/name/age一样,只是前端社区形成了「约定俗成的命名习惯」,用不同字母对应不同语义,让代码更易读。

字母 全称 含义/使用场景 例子
T Type 通用类型(最常用,无特殊语义时都用 T) first<T>(arr: T[])
D Default/Date 通常指 “默认类型” 或 “日期类型”(小众) 泛型接口里的默认类型:interface Config<D = string>
K KeyKey 表示对象的「键」类型 getKey<K extends string>(obj: { [k: K]: any }, key: K)
V Value 表示对象的「值」类型 Map<K, V>(TS 内置的 Map 泛型)
E Element 表示数组 / 集合的「元素」类型 Array<E>(TS 内置的数组泛型)
P Parameter 表示函数的「参数」类型 function wrap<P>(fn: (arg: P) => void, arg: P)

六、类型参数是怎么一层一层 “传下去” 的?

到这一步为了更好的理解泛型,我将带着同学们追溯源码。一起来追踪一下源码看看吧。

1. 第一层:VxeGridProps<D>

源码里(简化):

export interface VxeGridProps<D = any> extends VxeTableProps<D> {  
  columns?: VxeGridPropTypes.Columns<D>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<D>  
  // ...
}

当你用 VxeGridProps<RowVO>

  • extends VxeTableProps<D> → 变成 extends VxeTableProps<RowVO>
  • columns?: Columns<D> → 变成 columns?: Columns<RowVO>
  • proxyConfig?: ProxyConfig<D> → 变成 proxyConfig?: ProxyConfig<RowVO>

记忆:哪里写了 <D>,就会被替换成 <RowVO>

2. 第二层:Columns<D> = Column<D>[]

export namespace VxeGridPropTypes {  
  export type Column<D = any> = VxeTableDefines.ColumnOptions<D>  
  export type Columns<D = any> = Column<D>[]
}

当你用的是 Columns<RowVO> 时:

  • Columns<D>Columns<RowVO>
  • = Column<D>[] 这一行里的 D 同样被替换成 RowVO,变成:
  • Columns<RowVO> = Column<RowVO>[]

接着:

  • Column<D> = VxeTableDefines.ColumnOptions<D>
  • 也会变成:Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

所以:

columns 的每一项类型就是 ColumnOptions<RowVO>

七、第三层:ColumnOptions<D>D 真正用在哪里?

export interface ColumnOptions<D = any> extends VxeColumnProps<D> {  
  children?: ColumnOptions<D>[]  
  slots?: VxeColumnPropTypes.Slots<D>
}

继续替换:

  • ColumnOptions<D>ColumnOptions<RowVO>
  • extends VxeColumnProps<D> → 变成 extends VxeColumnProps<RowVO>
  • children?: ColumnOptions<D>[] → 变成 children?: ColumnOptions<RowVO>[]
  • slots?: Slots<D> → 变成 slots?: Slots<RowVO>

关键点:ColumnOptions<RowVO> 本身定义了「列配置」的结构它继承的 VxeColumnProps<RowVO> + Slots<RowVO> 等地方,会在「需要行数据的回调」里用到 RowVO,比如:

formatter(params: { row: RowVO; ... })
className(params: { row: RowVO; ... })

八、我在学习时候的疑惑?

我当时并不理解TypeScript 做的是统一替换

// 把 D 换成 RowVO:
type Columns<RowVO> = Column<RowVO>[]

// 再把 Column 展开:
type Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

// 合起来就是:
type Columns<RowVO> = VxeTableDefines.ColumnOptions<RowVO>[]

就拿文章示例的代码来看,TypeScript 会把函数体中所有的<T>都 替换成你制定的类型。

不理解的代码:

export type Column<D = any> = VxeTableDefines.ColumnOptions<D>
export type Columns<D = any> = Column<D>[]

我当特别不能理解 Column<D>[]<D>是怎么变成<RowVO>的。直到我明白了TypeScript会做统一替换,根本不是按数据传参的逻辑去做的。

九、把整个链路串起来(从外到内)

你写了:

reactive<VxeGridProps<RowVO>>({...})

于是:

VxeGridProps<D> → VxeGridProps<RowVO>
extends VxeTableProps<D> → extends VxeTableProps<RowVO>
columns?: Columns<D> → columns?: Columns<RowVO>

然后:

Columns<D> = Column<D>[] → Columns<RowVO> = Column<RowVO>[]
Column<D> = ColumnOptions<D> → Column<RowVO> = ColumnOptions<RowVO>

再往下:

ColumnOptions<D> extends VxeColumnProps<D> → ColumnOptions<RowVO> extends VxeColumnProps<RowVO>

最终效果:

  • data 的类型是:RowVO[]
  • 所有回调里涉及「行数据」的地方,类型参数是 RowVO

十、总结这次案例

  • RowVO:描述 “一行数据长什么样”
  • VxeGridProps<RowVO>:告诉表格「我的每一行数据都是 RowVO
  • 泛型参数 <RowVO> 会一层层往下传,凡是类型里写了 <D> 的地方,就会变成 <RowVO>

你现在已经不是 “不懂泛型的小白” 了,你已经能:

  • 看懂「类型参数是怎么一层一层传下去的」
  • 顺着 VxeGridProps<RowVO> → Columns<RowVO> → ColumnOptions<RowVO> 这一整条链路往下追

这就已经是非常扎实的泛型理解了。

总结

  1. 泛型的核心是「给类型加参数」,使用时再指定具体类型,如 Array<string>VxeGridProps<RowVO>
  2. 嵌套泛型的本质是「类型参数本身也是泛型」,参数会逐层传递替换(DRowVO);

以上便是对泛型的分享,欢迎大家指正讨论,与大家共勉。

从Vue到Bevy与Nestjs:我为 Vue 构建了一套“无头”业务引擎

作者 ArisuYuki
2026年2月6日 23:21

不知道大家是否见过那种动辄几千行、逻辑像乱麻一样缠绕的 .vue 文件?

笔者在许多开源项目和企业级项目里都见过类似的现象:各种 watch 互相套娃、生命周期里塞满异步逻辑、父子组件传值传到怀疑人生。当项目进入中后期,Vue 的响应式系统仿佛从‘利器’变成了‘诅咒’,每一行代码的改动都像是在玩扫雷。

这种“面条代码”的泛滥让我开始反思:当下的前端开发范式,真的能支撑起当今逻辑爆炸的复杂应用吗?


起初,我以为这种混乱只是人为因素——觉得只要通过规范的 Code Review、靠着开发者的自觉,就能压制住代码的腐烂。但随着项目规模的膨胀,我推翻了自己的想法。我发现 Vue 的 API 仿佛自带一种传染性

只要你的业务代码中还直接调用着 refwatchonMounted这些Vue最核心的功能,业务逻辑就不可避免地会向 UI 框架低头成为UI的附庸。今天为了省事顺手写下的每一个 watchcomputed,都是为未来的“谁改了我的变量”埋下伏笔。Vue的这种‘响应式链路’在项目初期极度丝滑,但在项目后期就是噩梦的开始。

直到最后,我发现一个几乎无法避开的实事:只要 UI 框架还掌握着状态的‘修改权’,业务代码就几乎注定会退化成面条。 于是我开始意识到,我必须从物理层面给 Vue 的权力‘断供’。这便是我设计 Virid 的初衷:我要的不是更优雅地写 Vue 代码的方法,而是一套根本不属于 Vue 的全新世界。


在这样的理念的推动下,我产生了一个极其激进的想法:**让逻辑彻底从 UI 中剥离,构建一套完全"无头"(Headless)的业务引擎。**当我将目光投向 Rust 的 Bevy ECS 架构NestJS 的 IoC 依赖注入时,我发现了我自己的答案。

Bevy 是 Rust 圈子里最硬核的开源项目之一,它的 ECS 系统美得像艺术品。但可惜它为游戏而生,天然自带高频 Tick,直接挪到前端开发中会显得格格不入。NestJS 是 JS 领域里依赖注入最成熟的实践。我一直在思考,如果能用 NestJS 的手感去写一套 Bevy 式的解耦逻辑,会发生什么?@Virid/core 就是这个思考的答案。它剔除了多余的资源损耗,保留了最核心的架构美感。


站在巨人的肩膀上,我为前端量身定制了一套“带帧双缓冲与优先级调度的消息中心”。

它绝非简单的 Event Bus 或 Pub/Sub 模式所能比拟。它本质上是一个融合了 NestJS 依赖注入Bevy调度核心的精密系统。通过帧双缓冲机制,它彻底消除了前端逻辑中常见的"竞态条件"与"状态踩踏";配合优先级调度,它确保了每一条业务指令都能在最合适的时间节拍里执行。

要使用@Virid/core,只需要简单的三步走。首先派生一个自己的消息。他可以携带任何你想要发送的数据。

// 初始化核心引擎
const app = createVirid();
// 派生一个自己的消息
class IncrementMessage extends SingleMessage {
  constructor(public amount: number) {
    super();
  }
}

接着,定义自己的Component并注册他,这是“数据中心”,他只负责存储数据,除此之外没有任何逻辑。

@Component()
class CounterComponent {
  public count = 0;
}
// 注册这个数据组件
app.bindComponent(CounterComponent);

最后,编写一个自己的system。他是纯静态的、不需要任何注册与调用,只需要编写他需要的参数。@Virid/core将会自己发现并在合适的时候调用它。

//定义系统
class CounterSystem {
  //默认优先级
  //无需任何操作,只要定义好后@Virid/core将会自动将system与对应的消息类型挂钩
  //当接收到对应的消息之后,@Virid/core将会注入所有需要的参数,自动执行整个system
  @System()
  static onIncrement(
    @Message(IncrementMessage) message: IncrementMessage,
    count: CounterComponent,
  ) {
    console.log("---------------------onIncrement----------------------");
    console.log("message :>> ", message);
    count.count += message.amount;
  }
   //设置一个很高的优先级
  @System(100)
  static onIncrementPriority(
    @Message(IncrementMessage) message: IncrementMessage,
    count: CounterComponent,
  ) {
    console.log(
      "---------------------onIncrementPriority----------------------",
    );
    console.log("message :>> ", message);
    count.count += message.amount;
  }
}

在任何地方,只要发送消息,onIncrement将会被自动调用。而且由于帧双缓冲机制,其天然自带防抖功能。

IncrementMessage.send(1);//这个消息将会被合并(如果使用EventMessage派生则不会被合并)
IncrementMessage.send(5);
//只需要发送上面的消息,CounterComponent将会被自动注入onIncrementPriority与onIncrement的调用中
//因为优先级的存在,控制台会先后显示onIncrementPriority与onIncrement的执行流程
//---------------------onIncrementPriority----------------------
//message :>>  IncrementMessage {
//  amount: 5
//}
//---------------------onIncrement----------------------
//message :>>  IncrementMessage {
//  amount: 5
//}

通过这种方式,业务逻辑、UI、数据三者能够彻底解耦,我们将不会再需要Vue做任何事情来介入业务,只要触发一个合适的信号,所有的系统将会自动合适的调用,并且调度系统将会严格保证执行的先后顺序。通过这样的设计,配合几个生命周期钩子。可以轻而易举的实现undo/redo与消息跟踪功能,这是在普通的Vue中难以做到的事。

由于 System 和 Component 都是纯粹的逻辑和数据,你可以在完全不启动浏览器、不渲染 Vue 组件的情况下,对业务逻辑进行 100% 的单元测试


解决了业务逻辑放和数据在哪儿的问题,剩下的就是解决与Vue之间的黏合问题。如何利用Vue的响应式和各种API,优雅的让我们的核心数据投影到UI上?在这个过程中,我创造了@virid/vue和大量的核心概念。

要控制Vue,我们需要一个“代理人”(Controller)来做这件事。让他负责充当ViridVue之间的沟通人。他将会全权接管Vue的所有操作,并统一转发给System。于是,Vue文件中将会只剩下一行script代码(以一个音乐列表的播放为例)。

<template>
  <div>
    <div>This is a playlist page with many songs</div>
    <div v-for="(item, index) in plct.playlist" :key="item.id">
      <Song :index="index"></Song>
    </div>
  </div>
</template>
<script setup lang="ts">
  import Song from "./Song.vue";
  import { useController } from "@virid/vue";
  import { PlaylistController } from "@/logic/controllers/playListController";
  const plct = useController(PlaylistController, { id: "playlist" });
</script>
<style lang="scss" scoped></style>

在普通的Vue中,业务逻辑与UI逻辑往往掺杂在一起,但是在Virid的核心调度之下我们拥有了一个全新的选择:让Vue永远只负责UI的显示与绘制,将业务逻辑转交给@Virid/core

为了兼容响应式,我引入了响应式装饰器@Responsive(),只要给任何变量打上这个装饰器,当我们访问的时候,其将会被Virid自动转换成Vue的响应式变量。这意味着我们可以直接告诉Virid,那些变量是需要响应式的。

@Component()
export class PlaylistComponent {
  // 当前正在播放的歌,第一次访问时将会被Virid转化为响应式变量
  @Responsive()
  public currentSong: Song = null!
  // 歌单列表,第一次访问时将会被Virid转化为响应式变量
  @Responsive()
  public playlist: Song[] = []
}

@Project()是一个非常强大的“桥梁”。使得Controller能够直接访问任何Component上的属性,同时将其转化为只读的。这意味着一个Controller能够任意观察Component中的数据,从而更新Vue组件,同时只读保证了Component数据的安全。

@Listener()装饰器用于“偷听”一个消息,但是与System不同的是,其只能偷听一种派生自ControllerMessage类型的消息,并且无法享受依赖注入的功能,这意味着一个Controller不能直接更改Component

@OnHook('onSetup')装饰器告诉Virid,需要在Vue的什么生命周期自动调用下面这个方法。Virid将会在合适的时机自动调用被修饰的方法。

@Watch()是一个在Vue原版上,融合了Virid特点的更强大的功能,其不仅能够检测Controller自身响应式变量的变化。还能够监测任意一个Component上的变量。但是,因为**@Watch()**中只能更改Controller自身的变量,因此其仍然无法修改任何Component

export class SongControllerMessage extends ControllerMessage {
  //到底是哪一首歌发来的消息?索引
  constructor(public readonly index: number) {
    super()
  }
}

@Controller()
export class PlaylistController {
   //告诉Virid自动将playlist变为响应式的
  @Responsive()
  public playlist!: Song[]
    
  //创建一个投影,从component中映射数据
  @Project(PlaylistComponent, (i) => i.currentSong)
  public currentSong!: Song

  @Listener(SongControllerMessage)
  onMessage(@Message(SongControllerMessage) message: SongControllerMessage) {
    console.log('song', this.playlist[message.index])
    //可以做一些操作统一拦截,或者直接调用播放器
    PlaySongMesage.send(this.playlist[message.index])
  }
    
  @OnHook('onSetup')
  async getPlaylist() {
    //在这里可以获取数据,例如从服务器获取数据,这里模拟一下
    await new Promise((resolve) => setTimeout(resolve, 1000))
    this.playlist = [
      new Song('歌曲1', '1'),
      new Song('歌曲2', '2'),
      new Song('歌曲3', '3'),
      new Song('歌曲4', '4'),
      new Song('歌曲5', '5'),
      new Song('歌曲6', '6'),
      new Song('歌曲7', '7')
    ]
  }
  //观测当前歌曲,如果变了就触发某些操作
  @Watch(PlaylistComponent, (i) => i.currentSong, {})
  watchCurrentSong() {
    console.log('监听到当前歌曲改变PlaylistComponent:', this.currentSong)
  }
}

对于每一首歌,我们同样需要创建一个对应的Controller来充当我们和Virid的代理人,但是与此同时,每一个Song组件也需要和父Playlist组件通讯。因此我创建了一些更强大工具。

在.Vue文件中,我们传递了这样的变量,但是!**我们只传递了Song组件的索引,并没有传递item本身。**因此,我们需要某种方式获得index,并且还要能够访问到父组件的属性。

<div v-for="(item, index) in plct.playlist" :key="item.id">
  <Song :index="index"></Song>
</div>

@Env()是一个用于标记的标记装饰器。当你在子组件的Controller中标记一个属性为 @Env()时,Virid将会负责将其安装到这个属性上,这意味着你不需要自己定义props,按需声明取用即可

@Inherit()是一个类似@Project()的工具,如果说@Project()ControllerComponent之间的只读桥梁。那么@Inherit()就是ControllerController之间的只读桥梁。@Inherit 彻底终结了前端组件通信中冗长的 Emit/Props 链路。它建立了一个虫洞,让子组件可以直接观察到远方父组件的状态的同时,无法对父组件产生任何副作用污染。

通过@Inherit()你可以从任意组件内“继承”任意Controller的状态,同时,他也是只读的,这保证了一个Controller永远无法偷偷修改另一个Controller中数据的权利,当另一个Controller因为组件卸载而销毁的时候,这样的连接将会自动断开,类似于一种WeakRef。

通过@Inherit()@Project(),我们可以实现非常强大的功能,不需要父组件给我们提供任何数据,Song将会自己知道哪个数据才是自己应该得到的。

@Controller()
export class SongController {
  @Env()
  public index!: number
  @OnHook('onSetup')
  public onSetup() {
    console.log('我的索引是:', this.index)
  }
  @Inherit(PlaylistController, 'playlist', (i) => i.playlist)
  public playlist!: Song[]

  @Project<SongController>((i) => i.playlist?.[i.index])
  public song!: Song

  playThisSong() {
    //其实直接播放也行,但是这里我们模拟一下需要发送给父组件让父组件处理的情况
    console.log('发送播放消息:', this.index)
    SongControllerMessage.send(this.index)
  }
}

最终,消息将在System中得到处理,从此整个Virid将得到完整的闭环。

//当Playlist调用 PlaySongMesage.send(this.playlist[message.index])时
//整个系统将被激活,从而更新正确的数据
@System()
  static playThisSong(
    @Message(PlaySongMesage) message: PlaySongMesage,
    playlist: PlaylistComponent,
    player: PlayerComponent
  ) {
    //把这首歌添加到playlist里,如果没有的话
    playlist.playlist.push(message.song)

    //开始播放这首歌
    playlist.currentSong = message.song
    player.player.play(message.song)
    //自动发送新消息,记录
    return new IncreasePlayNumMessage()
  }

Virid 不是为了消灭 Vue,而是为了解决业务逻辑被耦合在UI中的问题。它可能不适合所有的 Todo-list,但它一定适合那些让你夜不能寐的复杂系统。

项目地址:github.com/ArisuYuki/v…

Flutter ——流式布局(Wrap)

作者 Haha_bj
2026年2月6日 23:06

Flutter 中 Wrap组件是解决 Row/Column 溢出问题的另一种重要方案,下面从核心作用、基础用法、核心属性、实战场景和对比 Row 这几个方面,给你做全面且易懂的讲解。

一、Wrap 核心作用

Wrap 是流式布局组件,和 Row/Column 最大的区别是:

  • Row/Column 子组件总尺寸超过父容器时会溢出(出现警告);
  • Wrap 子组件总尺寸超过父容器时会自动换行 / 换列,不会溢出。

简单说:Wrap 就是 “可以自动换行的 Row” 或 “可以自动换列的 Column”。

二、基础用法

先看一个最基础的示例,直观感受 Wrap 的效果:

lass MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("哈哈"),
        ),
        body: Padding(padding: EdgeInsetsGeometry.all(10),
          child: Wrap(
            children: [
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('1')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('2')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('3')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('4')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('5')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('6')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('7')),
              ),
              Container(width: 80,height: 80,
                margin: const EdgeInsets.all(5),
                color: Colors.red,
                child: Center(child: Text('8')),
              )
            ],
          ),
        ),
      ),
    );
  }
}
图片.png

效果:8 个 80x80 的容器会先在第一行排列,当剩余宽度不够放下下一个容器时,自动换行到第二行,完全适配父容器宽度,无溢出。

三、核心属性详解

Wrap 的属性和 Row/Column 高度相似,但新增了换行相关的属性:

属性 作用 常用值
direction 排列方向(主轴) Axis.horizontal(默认,水平)/ Axis.vertical(垂直)
alignment 主轴方向的对齐方式(单行 / 列的对齐) WrapAlignment.start(默认)/ center/ end/ spaceBetween/ spaceAround/ spaceEvenly
crossAxisAlignment 交叉轴方向的对齐方式(行 / 列之间的对齐) WrapCrossAlignment.start(默认)/ center/ end
runAlignment 多行 / 多列整体的对齐方式 WrapAlignment.start(默认)/ center/ end/ 等
spacing 主轴方向子组件之间的间距 数值(如 8.0)
runSpacing 交叉轴方向(行 / 列之间)的间距 数值(如 8.0)
children 子组件列表 Widget 数组

关键属性实战示例

Wrap(
  direction: Axis.horizontal, // 水平排列
  alignment: WrapAlignment.spaceBetween, // 单行内两端对齐
  runAlignment: WrapAlignment.center, // 多行整体居中
  crossAxisAlignment: WrapCrossAlignment.center, // 行内垂直居中
  spacing: 10, // 水平子组件间距 10
  runSpacing: 15, // 行与行之间的间距 15
  children: [
    Container(width: 70, height: 70, color: Colors.red),
    Container(width: 70, height: 80, color: Colors.green),
    Container(width: 70, height: 70, color: Colors.blue),
    Container(width: 70, height: 70, color: Colors.yellow),
    Container(width: 70, height: 70, color: Colors.purple),
    Container(width: 70, height: 70, color: Colors.yellow),
    Container(width: 70, height: 70, color: Colors.purple),
  ],
)
图片.png

四、常见使用场景

Wrap 是 Flutter 中实现 “标签流、按钮流、网格标签” 的首选组件,以下是两个高频实战场景:

场景 1:标签列表(最经典用法)

dart

// 模拟动态标签列表
Wrap(
  spacing: 8, // 标签之间的水平间距
  runSpacing: 8, // 行之间的垂直间距
  children: [
    // 标签组件封装
    _buildTag('Flutter'),
    _buildTag('Dart'),
    _buildTag('Android'),
    _buildTag('iOS'),
    _buildTag('前端'),
    _buildTag('移动端'),
    _buildTag('跨平台'),
    _buildTag('布局'),
    _buildTag('组件'),
  ],
)

// 封装标签 Widget
Widget _buildTag(String text) {
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
    decoration: BoxDecoration(
      color: Colors.grey[200],
      borderRadius: BorderRadius.circular(20),
    ),
    child: Text(text),
  );
}

五、Wrap vs Row/Column 核心对比

特性 Wrap Row/Column
溢出处理 自动换行 / 换列,无溢出 直接溢出,出现警告
空间占用 仅包裹子组件(mainAxisSize 固定为 min) 可设置 max/min,默认 max
适用场景 动态数量的子组件(标签、按钮) 固定数量的子组件(导航栏、表单行)
性能 略优(无需计算溢出) 需计算主轴空间,溢出时性能无影响

六、常见问题与注意事项

  1. Wrap 中使用 Expanded 无效:Expanded 是配合 Flex(Row/Column)的弹性布局组件,Wrap 不支持弹性分配空间,因此在 Wrap 的 children 中用 Expanded 不会有任何效果。

  2. 控制 Wrap 整体的宽度 / 高度:如果想让 Wrap 占满父容器宽度(而非仅包裹子组件),可以给 Wrap 包裹一个 Container 并设置宽度:

    dart

    Container(
      width: double.infinity, // 占满父容器宽度
      child: Wrap(/* ... */),
    )
    

总结

  1. 核心定位:Wrap 是流式布局,解决 Row/Column 溢出问题,子组件超出父容器时自动换行 / 换列。

  2. 核心属性spacing(子组件间距)、runSpacing(行 / 列间距)、direction(排列方向)是最常用的三个属性。

  3. 使用技巧

    • 动态数量的标签、按钮优先用 Wrap;
    • Wrap 不支持 Expanded,无需尝试弹性分配空间;
    • 控制间距优先用 spacing/runSpacing,而非子组件的 margin(更统一)。

掌握 Wrap 布局,就能轻松实现 Flutter 中绝大多数 “流式排列” 的 UI 场景,是替代 Row/Column 解决溢出问题的最佳选择。

Flutter——弹性布局(Flex、Expanded)

作者 Haha_bj
2026年2月6日 22:27

弹性布局(Flex)允许子组件按照一定比例来分配父容器空间。

一、核心关系与基础概念

首先要明确一个关键关系:Row 和 Column 都是 继承自Flex 组件的 ——

  • Row = Flex(direction: Axis.horizontal)(水平弹性布局)
  • Column = Flex(direction: Axis.vertical)(垂直弹性布局)

Expanded 是配合 Flex(包括 Row/Column)使用的 “空间分配器”,用于控制子组件在弹性布局中占据的空间比例。

1. Flex 组件的基础用法

Flex 是弹性布局的基类,直接使用的场景较少(优先用 Row/Column),但理解它能帮你掌握弹性布局的本质:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Flex & Expanded 详解')),
        body: Padding(
          padding: const EdgeInsets.all(20),
          // 基础 Flex 布局(水平方向,等价于 Row)
          child: Flex(
            direction: Axis.horizontal, // 布局方向:horizontal(水平)/vertical(垂直)
            children: [
              Container(width: 80, height: 80, color: Colors.red),
              Container(width: 80, height: 80, color: Colors.green),
              Container(width: 80, height: 80, color: Colors.blue),
            ],
          ),
        ),
      ),
    );
  }
}

二、Flex 核心属性(Row/Column 通用)

Flex 的核心属性和 Row/Column 完全一致,这里重点强调弹性布局相关的关键属性:

属性 作用 常用值
direction 布局方向(Flex 独有,Row/Column 已固定) Axis.horizontal / Axis.vertical
mainAxisAlignment 主轴对齐方式 start/center/end/spaceBetween/spaceAround/spaceEvenly
crossAxisAlignment 交叉轴对齐方式 start/center/end/stretch
mainAxisSize 主轴占用空间 max(默认,占满父容器)/ min(仅包裹子组件)
children 子组件列表 Widget 数组

三、Expanded 组件详解

Expanded 是 Flex 布局的 “黄金搭档” ,用于解决布局溢出问题,并按比例分配剩余空间。

1. 核心作用

  • 强制子组件占据 Flex 布局中剩余的全部 / 部分空间
  • 自动适配父容器尺寸,避免子组件溢出(最核心的用途)
  • 支持通过 flex 属性设置空间分配比例

2. 基础属性

属性 作用 默认值
flex 空间分配权重(比例) 1
child 需要分配空间的子组件 必填

3. 实战示例

示例 1:基础比例分配(解决溢出)
// Flex(Row)+ Expanded 按比例分配空间
Row(
  children: [
    // 占1份空间
    Expanded(
      flex: 1,
      child: Container(color: Colors.red, height: 60),
    ),
    // 占2份空间
    Expanded(
      flex: 2,
      child: Container(color: Colors.green, height: 60),
    ),
    // 占1份空间
    Expanded(
      flex: 1,
      child: Container(color: Colors.blue, height: 60),
    ),
  ],
)

效果:父容器宽度被分成 1+2+1=4 份,红色占 1/4,绿色占 2/4,蓝色占 1/4,完全适配父容器,无溢出。

示例 2:混合固定尺寸 + 弹性尺寸
// 固定尺寸 + Expanded 弹性尺寸
Row(
  children: [
    // 固定宽度的按钮
    const SizedBox(width: 80, child: ElevatedButton(onPressed: () {}, child: Text('返回'))),
    // 占满剩余所有空间的文本(flex 默认1)
    Expanded(
      child: Container(
        color: Colors.grey[200],
        alignment: Alignment.center,
        child: const Text('这是标题,会占满剩余空间'),
      ),
    ),
    // 固定宽度的图标
    const SizedBox(width: 80, child: Icon(Icons.more_vert)),
  ],
)

效果:左右两个固定宽度的组件,中间文本区域自动占满剩余所有宽度,适配不同屏幕尺寸。

示例 3:垂直方向(Column + Expanded)
// Column + Expanded 垂直方向分配空间
Column(
  children: [
    // 顶部固定高度
    Container(height: 80, color: Colors.red, child: const Center(child: Text('顶部'))),
    // 中间占满剩余空间(核心内容区)
    Expanded(
      child: Container(color: Colors.green, child: const Center(child: Text('核心内容区'))),
    ),
    // 底部固定高度
    Container(height: 80, color: Colors.blue, child: const Center(child: Text('底部'))),
  ],
)

效果:顶部和底部固定高度,中间内容区自动占满屏幕剩余高度,是 App 页面的经典布局方式。

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("哈哈"),
        ),
        body: Column(
          children: [Flex(direction: .horizontal,
            children: [
              Expanded(
                  flex: 1,
                  // 此处设置的宽度 100无效,flex 比 width 优先级高
                  child: Container(width: 100, height: 40,color: Colors.red,)),
              Expanded(
                  flex: 2,
                  child: Container(width: 100,height: 40,color: Colors.yellow,))
            ],
          )],
        ),
      ),
    );
  }
}

Expandedflex 优先级远高于子组件的 widthExpanded 的作用是强制子组件占据 Flex 布局中分配给它的空间,因此你给 Container 设置的 width: 100 会被完全忽略;

Vitest 前端测试实战指南:从入门到精通

作者 kevinIcoding
2026年2月6日 21:46

Vitest 前端测试实战指南:从入门到精通

引言

在前端开发中,测试往往是被忽视的一环。很多开发者认为写测试会增加开发时间,或者觉得测试只是为了应付代码审查。但实际上,好的测试用例不仅能保证代码质量,减少生产环境的 bug,还能提高开发效率,让重构更加安全。

最近,我在项目中集成了 Vitest 测试框架,体验非常好。它速度快、配置简单,与 Vite 完美集成,非常适合现代前端项目。今天,我就来分享一下如何在 React + TypeScript 项目中使用 Vitest 进行测试。

为什么需要前端测试?

1. 保证代码质量

测试用例可以验证代码的功能是否符合预期,捕获潜在的 bug,特别是在复杂的业务逻辑中。

2. 提高开发效率

虽然写测试会花费一些时间,但在长期来看,它能减少调试时间,让开发者更自信地修改代码。

3. 便于重构

有了测试用例,重构代码时可以快速验证修改是否破坏了现有功能。

4. 文档作用

测试用例可以作为代码的活文档,帮助其他开发者理解代码的预期行为。

Vitest 优势

Vitest 是基于 Vite 的测试框架,相比 Jest 等传统测试框架,它有以下优势:

  • 速度快:利用 Vite 的快速启动和热更新能力
  • 配置简单:与 Vite 配置无缝集成
  • TypeScript 支持:原生支持 TypeScript
  • ES 模块:原生支持 ES 模块
  • 快照测试:支持组件快照测试
  • API 友好:API 设计与 Jest 类似,学习成本低

项目搭建

1. 初始化 React + TypeScript 项目

首先,使用 Vite 创建一个 React + TypeScript 项目:

npm create vite@latest vitest-demo -- --template react-ts
cd vitest-demo

2. 安装测试依赖

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

3. 配置 Vitest

在项目根目录创建 vitest.config.ts 文件:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

// Vitest 配置文件
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.ts',
    css: true,
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
});

4. 配置测试环境

src 目录创建 setupTests.ts 文件:

// 测试环境配置文件
import '@testing-library/jest-dom/vitest';

5. 更新 package.json

package.json 中添加测试脚本:

"scripts": {
  "dev": "vite",
  "build": "tsc -b && vite build",
  "lint": "eslint .",
  "preview": "vite preview",
  "test": "vitest",
  "test:run": "vitest run"
}

测试示例

1. 工具函数测试

首先,我们来测试一个简单的数学工具函数。

创建 src/utils/math.ts 文件:

/**
 * 数学工具函数
 */

/**
 * 加法函数
 * @param a 第一个加数
 * @param b 第二个加数
 * @returns 两个数的和
 */
export const add = (a: number, b: number): number => {
  return a + b;
};

/**
 * 减法函数
 * @param a 被减数
 * @param b 减数
 * @returns 两个数的差
 */
export const subtract = (a: number, b: number): number => {
  return a - b;
};

/**
 * 乘法函数
 * @param a 第一个乘数
 * @param b 第二个乘数
 * @returns 两个数的积
 */
export const multiply = (a: number, b: number): number => {
  return a * b;
};

/**
 * 除法函数
 * @param a 被除数
 * @param b 除数
 * @returns 两个数的商
 * @throws 当除数为0时抛出错误
 */
export const divide = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error('除数不能为0');
  }
  return a / b;
};

然后,创建 src/utils/math.test.ts 文件:

import { describe, it, expect } from 'vitest';
import { add, subtract, multiply, divide } from './math';

/**
 * 数学工具函数测试
 */
describe('数学工具函数测试', () => {
  /**
   * 测试加法函数
   */
describe('add函数测试', () => {
    it('应该正确计算两个正数的和', () => {
      expect(add(1, 2)).toBe(3);
      expect(add(10, 20)).toBe(30);
    });

    it('应该正确计算小数的和', () => {
      expect(add(0.1, 0.2)).toBeCloseTo(0.3);
      expect(add(1.5, 2.5)).toBe(4);
    });
  });

  /**
   * 测试除法函数
   */
describe('divide函数测试', () => {
    it('应该正确计算两个正数的商', () => {
      expect(divide(6, 2)).toBe(3);
    });

    it('应该在除数为零时抛出错误', () => {
      expect(() => divide(5, 0)).toThrow('除数不能为0');
    });
  });
});

2. 组件测试

接下来,我们来测试一个登录表单组件。

创建 src/components/LoginForm.tsx 文件:

import { useState } from 'react';

/**
 * 登录表单组件
 * @param onSubmit 表单提交回调函数
 */
interface LoginFormProps {
  onSubmit: (data: { username: string; password: string }) => void;
}

/**
 * 登录表单组件
 * 包含用户名和密码输入字段,以及表单验证逻辑
 */
export const LoginForm = ({ onSubmit }: LoginFormProps) => {
  // 表单状态
  const [formData, setFormData] = useState({
    username: '',
    password: '',
  });
  
  // 错误信息状态
  const [errors, setErrors] = useState({
    username: '',
    password: '',
  });

  /**
   * 处理输入变化
   * @param e 输入事件
   */
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value,
    }));
    
    // 清除对应字段的错误信息
    if (errors[name as keyof typeof errors]) {
      setErrors(prev => ({
        ...prev,
        [name]: '',
      }));
    }
  };

  /**
   * 验证表单
   * @returns 是否验证通过
   */
  const validateForm = (): boolean => {
    const newErrors = {
      username: '',
      password: '',
    };

    // 验证用户名
    if (!formData.username.trim()) {
      newErrors.username = '用户名不能为空';
    }

    // 验证密码
    if (!formData.password) {
      newErrors.password = '密码不能为空';
    } else if (formData.password.length < 6) {
      newErrors.password = '密码长度不能少于6位';
    }

    // 设置错误信息
    setErrors(newErrors);

    // 检查是否有错误
    return !newErrors.username && !newErrors.password;
  };

  /**
   * 处理表单提交
   * @param e 提交事件
   */
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    // 验证表单
    if (validateForm()) {
      // 调用提交回调
      onSubmit(formData);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>登录</h2>
      
      <div className="form-group">
        <label htmlFor="username">用户名</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          className={errors.username ? 'error' : ''}
        />
        {errors.username && <div className="error-message">{errors.username}</div>}
      </div>
      
      <div className="form-group">
        <label htmlFor="password">密码</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          className={errors.password ? 'error' : ''}
        />
        {errors.password && <div className="error-message">{errors.password}</div>}
      </div>
      
      <button type="submit">登录</button>
    </form>
  );
};

然后,创建 src/components/LoginForm.test.tsx 文件:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';

/**
 * LoginForm组件测试
 */
describe('LoginForm组件测试', () => {
  it('应该正常渲染登录表单', () => {
    const mockOnSubmit = vi.fn();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    expect(screen.getByRole('heading', { name: '登录' })).toBeInTheDocument();
    expect(screen.getByLabelText('用户名')).toBeInTheDocument();
    expect(screen.getByLabelText('密码')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
  });

  it('提交空表单应该显示错误信息', () => {
    const mockOnSubmit = vi.fn();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const submitButton = screen.getByRole('button', { name: '登录' });
    fireEvent.click(submitButton);
    
    expect(screen.getByText('用户名不能为空')).toBeInTheDocument();
    expect(screen.getByText('密码不能为空')).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it('有效输入应该成功提交表单', () => {
    const mockOnSubmit = vi.fn();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const usernameInput = screen.getByLabelText('用户名');
    const passwordInput = screen.getByLabelText('密码');
    const submitButton = screen.getByRole('button', { name: '登录' });
    
    fireEvent.change(usernameInput, { target: { value: 'testuser' } });
    fireEvent.change(passwordInput, { target: { value: 'password123' } });
    fireEvent.click(submitButton);
    
    expect(mockOnSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123',
    });
  });
});

3. 异步服务测试

最后,我们来测试一个模拟的 API 服务。

创建 src/services/userService.ts 文件:

/**
 * 用户服务
 * 模拟API调用,包含获取用户列表、获取单个用户、创建用户等方法
 */

/**
 * 用户类型定义
 */
export interface User {
  id: number;
  name: string;
  email: string;
}

/**
 * 模拟用户数据
 */
const mockUsers: User[] = [
  { id: 1, name: '张三', email: 'zhangsan@example.com' },
  { id: 2, name: '李四', email: 'lisi@example.com' },
  { id: 3, name: '王五', email: 'wangwu@example.com' },
];

/**
 * 模拟API延迟
 * @param ms 延迟时间(毫秒)
 */
const delay = (ms: number): Promise<void> => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

/**
 * 用户服务类
 */
export class UserService {
  /**
   * 获取用户列表
   * @param shouldFail 是否模拟失败场景
   * @returns 用户列表
   */
  async getUsers(shouldFail: boolean = false): Promise<User[]> {
    // 模拟API延迟
    await delay(300);
    
    // 模拟失败场景
    if (shouldFail) {
      throw new Error('获取用户列表失败');
    }
    
    return mockUsers;
  }

  /**
   * 创建新用户
   * @param user 用户信息
   * @param shouldFail 是否模拟失败场景
   * @returns 创建的用户信息
   */
  async createUser(user: Omit<User, 'id'>, shouldFail: boolean = false): Promise<User> {
    // 模拟API延迟
    await delay(400);
    
    // 模拟失败场景
    if (shouldFail) {
      throw new Error('创建用户失败');
    }
    
    // 生成新用户ID
    const newId = Math.max(...mockUsers.map(u => u.id)) + 1;
    // 创建新用户
    const newUser: User = {
      id: newId,
      ...user,
    };
    
    // 添加到模拟数据中
    mockUsers.push(newUser);
    
    return newUser;
  }
}

// 导出单例实例
export const userService = new UserService();

然后,创建 src/services/userService.test.ts 文件:

import { describe, it, expect } from 'vitest';
import { userService } from './userService';

/**
 * UserService测试
 */
describe('UserService测试', () => {
  it('应该成功获取用户列表', async () => {
    const users = await userService.getUsers();
    expect(users).toBeInstanceOf(Array);
    expect(users.length).toBeGreaterThan(0);
  });

  it('获取用户列表失败时应该抛出错误', async () => {
    await expect(userService.getUsers(true)).rejects.toThrow('获取用户列表失败');
  });

  it('应该成功创建新用户', async () => {
    const newUser = {
      name: '赵六',
      email: 'zhaoliu@example.com',
    };
    
    const createdUser = await userService.createUser(newUser);
    expect(createdUser).toBeInstanceOf(Object);
    expect(createdUser.id).toBeGreaterThan(0);
    expect(createdUser.name).toBe(newUser.name);
  });
});

测试技巧

1. 组件测试技巧

  • 使用语义化选择器:优先使用 getByRolegetByLabelText 等语义化选择器,而不是 getByClassNamegetByTestId
  • 模拟用户交互:使用 fireEvent 模拟真实的用户操作,如点击、输入等
  • 断言具体:断言应该具体,避免过于宽泛的断言

2. 异步测试技巧

  • 使用 async/await:处理异步操作时,使用 async/await 让代码更清晰
  • 测试错误场景:使用 expect(...).rejects.toThrow() 测试异步操作的错误场景
  • 模拟依赖:对于外部依赖,如 API 调用,使用 mock 或 stub 来隔离测试

3. 测试覆盖策略

  • 正常情况:测试功能的基本使用场景
  • 边界情况:测试输入的边界值,如空字符串、负数、最大值等
  • 异常情况:测试错误处理和异常状态
  • 边缘情况:测试特殊输入和场景,如网络错误、超时等

项目结构

最终,我们的项目结构如下:

vitest-demo/
├── src/
│   ├── assets/
│   ├── components/
│   │   ├── LoginForm.tsx
│   │   └── LoginForm.test.tsx
│   ├── services/
│   │   ├── userService.ts
│   │   └── userService.test.ts
│   ├── utils/
│   │   ├── math.ts
│   │   └── math.test.ts
│   ├── App.tsx
│   ├── App.test.tsx
│   ├── setupTests.ts
│   └── main.tsx
├── package.json
├── vitest.config.ts
└── README.md

运行测试

开发模式运行测试(监听文件变化)

npm test

单次运行测试(运行后退出)

npm run test:run

总结

通过本文的介绍,相信你已经对如何在 React + TypeScript 项目中使用 Vitest 进行测试有了清晰的了解。从工具函数测试到组件测试,再到异步服务测试,我们覆盖了前端测试的主要场景。

测试是一个长期的过程,不是一蹴而就的。建议从简单的工具函数和核心组件开始,逐步扩展测试覆盖范围。随着测试用例的积累,你会发现代码质量和开发效率都有显著提升。

最后,记住测试的目的不是为了追求 100% 的测试覆盖率,而是为了保证代码的核心功能稳定可靠。好的测试用例应该是有针对性的,能够捕获真正重要的 bug。

希望本文对你有所帮助,祝你测试愉快! 🎉

Webpack 与 Vite:我究竟该选哪个

作者 NEXT06
2026年2月6日 20:30

在前端工程化的演进历程中,工具链的发展始终围绕着两个核心命题:构建的灵活性开发的即时性。Webpack 作为构建工具的集大成者,确立了“一切皆模块”的工程标准;而 Vite 则利用浏览器原生能力,掀起了从“构建驱动”向“体验驱动”的范式转移。

本文将结合底层原理,从构建机制、配置哲学、兼容性策略及热更新效率四个维度,深度解构这两者的核心差异。


一、 构建机制与冷启动:Bundle vs No-Bundle

Webpack 与 Vite 最根本的区别在于开发环境的启动模式。这直接决定了项目的冷启动速度与规模扩展性。

Webpack:全量构建 (Bundle-Based)

Webpack 是一个基于依赖图谱(Dependency Graph)的静态模块打包器。

  • 原理:在开发服务器启动前,Webpack 必须从入口文件(Entry)开始,递归解析所有的依赖模块(AST 分析),通过 Loader 转译代码,最终将所有模块打包进内存中的 Bundle 文件。

  • 瓶颈:启动时间 

    O(n)O(n)
    

     与项目复杂度成正比。随着应用规模扩大,依赖解析和打包的过程呈指数级增长。

Vite:按需编译 (Native ESM)

Vite 采用了 No-Bundle 的设计理念,将构建过程移交给了浏览器。

  • 原理:Vite 利用现代浏览器原生支持 ES Module(

  • 优势:启动时间接近 

    O(1)O(1)
    

    ,与项目总模块数无关,仅取决于页面当前需要的模块。

代码对比

Webpack (隐式逻辑)
需等待所有模块打包完成,终端才会显示 Compiled successfully,浏览器才能访问。

Vite (浏览器请求)

codeHtml

<!-- index.html -->
<script type="module" src="/src/main.js"></script>

浏览器发起 HTTP 请求 -> Vite Server 拦截 -> 编译 main.js -> 返回。

屏幕录制 2026-02-06 201827.gif


二、 开发体验与配置哲学:显式装配 vs 开箱即用

在配置层面,Webpack 倾向于提供原子化的控制权,而 Vite 倾向于提供最佳实践的默认配置。

Webpack:职责单一与链式调用

Webpack 默认只理解 JavaScript。处理其他资源必须显式配置 Loader,且对配置顺序有严格要求。

  • 痛点:Loader 的执行顺序是从右向左(或从下到上) 。若顺序颠倒,会导致解析失败。
  • 模块化规范:配置文件采用 CommonJS 规范 (module.exports),在编写复杂配置时缺乏类型提示。

Webpack 配置示例

JavaScript

// webpack.config.js
const path = require('path');

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 必须严格遵守顺序:先 css-loader 解析 import,再 style-loader 挂载 DOM
        use: ['style-loader', 'css-loader'] 
      }
    ]
  }
};

Vite:约定优于配置与类型友好

Vite 针对高频场景(CSS、TypeScript、JSX)内置了支持,无需额外配置 Loader。

  • 优势:原生支持 ESM 配置文件,配合 defineConfig 辅助函数,能获得完整的 TypeScript 类型推断与智能提示。
  • CSS处理:直接 import CSS 文件即可生效,且原生支持 CSS Modules 和 Pre-processors(只需安装对应的 sass/less 依赖)。

Vite 配置示例

JavaScript

// vite.config.js
import { defineConfig } from 'vite';

// 获得代码提示与类型检查
export default defineConfig({
  // CSS 预处理器等配置已内置,无需手动编写 Loader 规则
});

屏幕录制 2026-02-06 202147.gif


三、 生产构建与兼容性策略:统一降级 vs 分流加载

生产环境的构建策略体现了两者对“兼容性”与“性能”权衡的差异。

Webpack:Babel 统一转译

Webpack 通常结合 babel-loader 和 @babel/preset-env,将所有 ES6+ 代码转换为 ES5,以兼容目标浏览器(如 IE11)。

  • 代价:即使是支持现代特性的浏览器,也必须加载体积冗余、执行效率较低的 ES5 代码及 Polyfills。

Webpack 配置片段

JavaScript

// rule 配置
{
  test: /.m?js$/,
  exclude: /node_modules/,
  use: {
    loader: 'babel-loader',
    options: { presets: ['@babel/preset-env'] }
  }
}

Vite:Modern Mode + Legacy 分层策略

Vite 默认构建目标为现代浏览器(支持 Native ESM)。为了兼容旧版浏览器,Vite 提供了 @vitejs/plugin-legacy。

  • 机制:构建会生成两套代码。

    1. Modern Bundle:使用 
    2. Legacy Bundle:使用 SystemJS 加载,包含必要的 Polyfills,仅在不支持 ESM 的浏览器中通过 
  • Rollup:Vite 生产环境使用 Rollup 打包,而非 esbuild。这是因为 Rollup 在代码分割(Code Splitting)和 CSS 处理上更为成熟稳定。

Vite Legacy 配置

JavaScript

// vite.config.js
import legacy from '@vitejs/plugin-legacy';

export default defineConfig({
  plugins: [
    legacy({
      targets: ['ie >= 11'], // 自动生成 polyfills-legacy.js chunks
      additionalLegacyPolyfills: ['regenerator-runtime/runtime']
    })
  ]
});

四、 热更新 (HMR) 效率:重建 vs 精准替换

热更新(HMR)的速度直接影响开发者的心流体验。

Webpack:增量构建

当文件修改时,Webpack 需要重新构建包含该模块的依赖子树,计算 Patch,并通过 WebSocket 推送更新。虽然有缓存机制,但在大型项目中,重建依赖图的过程仍可能导致秒级延迟。

Vite:精准链式更新

Vite 的 HMR 是基于 ESM 的。

  • 原理:当模块编辑后,Vite 只需要让浏览器重新请求该模块(加上时间戳 query 防止缓存)。
  • 304 缓存:未变更的模块,浏览器直接利用 HTTP 缓存(304 Not Modified),无需服务器再次处理。
  • 效率:HMR 速度与应用总规模几乎无关,始终保持毫秒级响应。

五、 总结与选型建议

Webpack 与 Vite 并非简单的替代关系,而是不同工程化理念的产物。

  • Webpack 是一个编译器。它拥有庞大的插件生态和极致的定制能力,适合对构建产物有极高要求、需要深度定制 Loader 链、或必须兼容极低版本浏览器的存量巨型项目。
  • Vite 是一个开发服务器 + 生产打包器的组合。它通过标准化开发流程和利用现代浏览器特性,解决了“慢”的痛点。对于绝大多数现代 Web 应用(Vue 3 / React 18+),Vite 是首选方案。

从配置繁琐的“作坊式组装”到开箱即用的“工业化引擎”,Vite 的出现标志着前端工程化进入了追求极致开发体验的新阶段。

React 与 Vue 的 CSS 模块化深度实战指南:从原理到实践,彻底告别样式“打架”

作者 AAA阿giao
2026年2月6日 19:35

引言

在前端开发的日常中,我们常常会遇到一个令人抓狂的问题:为什么我只改了一个组件的样式,结果整个页面都乱了?

这背后的根本原因,就是 CSS 的全局作用域特性。默认情况下,所有 .button.header.txt 这样的类名在整个 HTML 文档中都是共享的——你在一个地方定义了 .txt { color: red; },另一个组件用了同样的类名,也会被染红!

为了解决这个问题,现代前端框架如 ReactVue 都提供了强大的 CSS 模块化(Scoped Styling) 能力。它们虽然思路不同,但目标一致:让每个组件的样式只作用于自己,互不干扰

本文将带你深入剖析 React 与 Vue 是如何实现 CSS 模块化的,并逐行解读真实代码,确保你不仅“会用”,更“懂原理”。全文内容详尽、结构清晰,适合初学者入门,也适合进阶开发者查漏补缺。


一、问题的根源:CSS 为何“容易打架”?

CSS(层叠样式表)的设计初衷是全局生效。这意味着:

  • 类名没有作用域;
  • 后加载的样式可能覆盖前面的;
  • 相同类名在不同组件中会互相污染。

比如:

.txt {
  color: red;
}

如果你在两个不同的组件里都用了 <div class="txt">,那么它们都会变成红色——即使你只想让其中一个变红。

这就是我们需要“模块化”的根本原因


二、React 的 CSS 模块化方案

React 社区推崇“显式优于隐式”的哲学,因此它提供了多种模块化方案。我们重点讲解两种:styled-components(CSS-in-JS)CSS Modules(原生 CSS 模块化)

2.1 方案一:styled-components —— 样式即组件

以下正是使用 styled-components 的典型示例:

import {
  useState 
} from 'react';
import styled from 'styled-components';  // 样式组件 

// 样式组件
const  Button = styled.button`
background:${props => props.primary?'blue': 'white'};
color:${props => props.primary?'white': 'blue'};
border: 1px solid blue;
padding: 8px 16px;
border-radius: 4px;
`
console.log(Button);

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

export default App

它是如何工作的?

  • styled.button 创建了一个新的 React 组件,内部是一个 <button> 元素;
  • 所有写在反引号中的 CSS 会被注入到 <style> 标签中;
  • 关键点:每个 styled 组件都会生成一个唯一的类名(如 sc-abc123-def456 ,确保样式不会冲突;
  • 通过 props 实现动态样式(如 primary 控制颜色);
  • console.log(Button) 会输出一个 React 组件函数,说明它本质是 JS 对象。

浏览器实际渲染效果(简化版):

<style>
.sc-abc123-def456 {
  background: white;
  color: blue;
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
}
.sc-abc123-xyz789 {
  background: blue;
  color: white;
}
</style>

<button class="sc-abc123-def456">默认按钮</button>
<button class="sc-abc123-xyz789">主要按钮</button>

💡 优点:样式与逻辑紧密耦合,支持动态主题、媒体查询、嵌套等;
缺点:运行时注入样式,略微增加 bundle 体积;不适合大型静态样式库。


2.2 方案二:CSS Modules —— 原生 CSS 的模块化革命

以下内容详细描述了 CSS Modules 的机制:

  • 文件名后面添加 .module.css
  • 类名会被编译为 AnotherButton_button__12345
  • 通过 import styles from './Button.module.css' 导入
  • JSX 中使用 {styles.button} 引用

示例:创建一个模块化 CSS 文件

/* Button.module.css */
.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
  border-radius: 4px;
}

在 React 组件中使用

import styles from './Button.module.css';

function Button({ children }) {
  return <button className={styles.button}>{children}</button>;
}

构建时发生了什么?

假设你的文件路径是 src/components/Button.module.css,构建工具(如 Webpack 或 Vite)会在打包时:

  1. .button 重命名为类似 Button_button__abc123 的唯一字符串;

  2. 生成一个 JavaScript 对象:

    // 编译后的 styles 对象
    const styles = {
      button: "Button_button__abc123"
    };
    
  3. 注入对应的 CSS 到页面中。

优势总结:

  • 完全隔离:每个类名全局唯一,零冲突;
  • 类型安全:配合 TypeScript 可获得自动补全和错误检查;
  • 性能优秀:无运行时开销,纯静态 CSS;
  • 可组合:支持 composes 复用样式(见下文)。

进阶技巧:样式复用(composes

/* base.module.css */
.baseBtn {
  padding: 8px 16px;
  border-radius: 4px;
}

/* Button.module.css */
.primary {
  composes: baseBtn from './base.module.css';
  background: blue;
  color: white;
}

这样,.primary 自动继承了 .baseBtn 的所有样式。


三、Vue 的 CSS 模块化方案:scoped 属性

相比 React 的“显式导入”,Vue 的方案更加“隐形而优雅”——只需在 <style> 标签上加一个 scoped 属性。

以下 Vue 代码完美展示了这一点:

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
<div>
  <h1 class="txt">Hello txt</h1>
  <h1 class="txt2">Hello txt2</h1>
  <HelloWorld />
</div>
</template>

<style scoped>
.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}
.txt {
  color: red;
  background-color: orange;
}
.txt2 {
  color: pink;
}
</style>

以及子组件 HelloWorld.vue

<script setup>
</script>

<template>
  <div>
    <h1 class="txt">你好</h1>
    <h1 class="txt2">你好2</h1>
    该子组件中无样式内容,跟随父组件的样式
    如果子组件中需要自定义样式,需要使用scoped属性
    此时,子组件中的样式只作用于当前组件,不会影响到其他组件
    如果不加scoped属性,子组件中的样式会影响到其他组件
  </div>
</template>

<style scoped>
.txt {
  color: blue;
  background-color: green;
  font-size: 30px;
}
.txt2 {
  color: orange;
}
</style>

scoped 是如何实现隔离的?

Vue 在编译阶段会:

  1. 为当前组件生成一个唯一的 hash,例如 data-v-f3f3ec42
  2. 给组件内所有根元素(或指定元素)添加该属性;
  3. 重写 <style scoped> 中的选择器,加上属性限制。

编译后效果(简化):

父组件样式

.txt[data-v-f3f3ec42] { color: red; }
.txt2[data-v-f3f3ec42] { color: pink; }

子组件样式

.txt[data-v-7ba5bd90] { color: blue; }
.txt2[data-v-7ba5bd90] { color: orange; }

HTML 渲染结果

<div data-v-f3f3ec42>
  <h1 class="txt" data-v-f3f3ec42>Hello txt</h1>
  <h1 class="txt2" data-v-f3f3ec42>Hello txt2</h1>
  <div data-v-7ba5bd90>
    <h1 class="txt" data-v-7ba5bd90>你好</h1>
    <h1 class="txt2" data-v-7ba5bd90>你好2</h1>
  </div>
</div>

结果:尽管类名相同,但因为 data-v-xxx 不同,样式完全隔离!

注意事项:深度选择器

如果你希望父组件的样式能影响子组件(比如定制第三方 UI 库),可以使用 :deep()

<style scoped>
.parent :deep(.child) {
  color: purple;
}
</style>

📌 Vue 2 中使用 /deep/::v-deep,Vue 3 推荐使用 :deep()


四、React vs Vue:CSS 模块化对比全景图

维度 React (CSS Modules) React (styled-components) Vue (scoped)
实现方式 类名哈希化 动态生成唯一类名 + 注入 <style> 属性选择器 ([data-v-xxx])
样式位置 独立 .module.css 文件 写在 JS/TSX 中 写在 .vue 单文件组件内
类名可读性 开发时需 styles.xxx,运行时为哈希 开发时直观,运行时为哈希 开发和运行时均为原始类名
作用域强度 ⭐⭐⭐⭐⭐(绝对隔离) ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐(依赖属性,可被绕过)
动态样式 需结合 JS 条件拼接 原生支持 props 需绑定动态 class
TypeScript 支持 完美(自动类型推导) 良好 有限
学习成本 中等(需理解模块导入) 低(直观) 极低(加个 scoped 即可)
适用场景 大型项目、团队协作、静态样式多 快速原型、动态主题、UI 库开发 中小型项目、快速开发、Vue 生态

五、为什么需要 CSS 模块化?—— 真实痛点解析

场景 1:多人协作项目

想象一个 10 人团队同时开发一个后台系统。A 写了 .card { padding: 10px; },B 也写了 .card { margin: 20px; }。如果不模块化,最终 .card 会同时有 padding 和 margin,甚至可能因加载顺序导致样式错乱。

模块化后:A 的 .card 变成 PageA_card__abc,B 的变成 PageB_card__def,互不影响。

场景 2:开源组件库

如果你发布一个 React 组件库,使用普通 CSS,用户很容易因为类名冲突导致样式异常。而使用 CSS Modules 或 styled-components,就能保证“开箱即用,零污染”。

场景 3:微前端架构

在微前端中,多个子应用共存于同一页面。若都使用全局 CSS,冲突几乎是必然的。模块化是微前端样式的安全基石


六、最佳实践建议

React 项目推荐

  • 中小型项目:优先使用 styled-components,开发体验极佳;
  • 大型企业级应用:采用 CSS Modules + TypeScript,兼顾性能与可维护性;
  • 避免:直接使用全局 CSS(除非是 reset/normalize)。

Vue 项目推荐

  • 默认开启 scoped:所有组件样式都加上 scoped
  • 全局样式单独管理:如 assets/styles/global.css,用于 reset、变量、通用类;
  • 慎用深度选择器:仅在必要时(如覆盖 Element Plus 样式)使用 :deep()

七、结语:选择适合你的“样式盔甲”

  • React 的 CSS Modules 像一套精密的“锁链铠甲”——每一块甲片(类名)都有唯一编号,严丝合缝,坚不可摧;
  • styled-components 则像一件“魔法斗篷”——样式随组件而生,动态变幻,灵活自如;
  • Vue 的 scoped 更像一层“隐形护盾”——你看不见它,但它默默守护着你的样式不被污染。

🎯 记住:技术没有绝对优劣,只有是否适合当前项目。
但无论你选择哪一种,请坚持一致性——团队统一规范,才是长期可维护的关键。

现在,回看开头那个“按钮莫名变蓝”的问题,你已经有能力彻底解决它了!

which@5.0.0源码阅读

作者 米丘
2026年2月6日 19:25

发布日期 2024 年 10 月 1 日

whichNode.js 版的系统 which 命令,核心作用是跨平台查找可执行命令对应的完整文件路径(比如找到 node 对应的 node.exenpm 对应的 npm.cmd)。

package.json

node-which-5.0.0/package.json

{
  "author": "GitHub Inc.",
  "name": "which",
  "description": "Like which(1) unix command. Find the first instance of an executable in the PATH.",
  "version": "5.0.0",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/npm/node-which.git"
  },
  "main": "lib/index.js",
  "bin": {
    "node-which": "./bin/which.js"
  },
  "license": "ISC",
  "dependencies": {
    "isexe": "^3.1.1"
  },
  "devDependencies": {
    "@npmcli/eslint-config": "^5.0.0",
    "@npmcli/template-oss": "4.23.3",
    "tap": "^16.3.0"
  },
  "scripts": {
    "test": "tap",
    "lint": "npm run eslint",
    "postlint": "template-oss-check",
    "template-oss-apply": "template-oss-apply --force",
    "lintfix": "npm run eslint -- --fix",
    "snap": "tap",
    "posttest": "npm run lint",
    "eslint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\""
  },
  "files": [
    "bin/",
    "lib/"
  ],
  "tap": {
    "check-coverage": true,
    "nyc-arg": [
      "--exclude",
      "tap-snapshots/**"
    ]
  },
  "engines": {
    "node": "^18.17.0 || >=20.5.0"
  },
  "templateOSS": {
    "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
    "version": "4.23.3",
    "publish": "true"
  }
}

bin/which.js 文件

node-which-5.0.0/bin/which.js

#!/usr/bin/env node

const which = require('../lib')
const argv = process.argv.slice(2)

// 用法提示函数(usage)
const usage = (err) => {
  if (err) {
    console.error(`which: ${err}`)
  }
  console.error('usage: which [-as] program ...')
  process.exit(1) // 退出进程,错误码 1(表示参数错误)
}

if (!argv.length) {
  // 没有任何参数时,直接输出用法
  return usage()
}

let dashdash = false // 标记是否遇到 -- 分隔符

// 用 reduce 拆分「要查找的命令(commands)」和「标志位(flags)」
const [commands, flags] = argv.reduce((acc, arg) => {
  // 若遇到 -- 或已标记 dashdash,后续参数都当命令,不解析
  if (dashdash || arg === '--') {
    dashdash = true
    return acc
  }

  // 不以 - 开头的参数 → 是要查找的命令,加入 acc 数组
  if (!/^-/.test(arg)) {
    acc[0].push(arg)
    return acc
  }
  // 以 - 开头的参数 → 是标志位,解析每个字符
  for (const flag of arg.slice(1).split('')) {
    if (flag === 's') {
      // -s:静默模式(找到路径也不输出,仅通过退出码判断)
      acc[1].silent = true
    } else if (flag === 'a') {
      // -a:返回所有匹配的命令路径(如 PATH 中有多个 node 版本)
      acc[1].all = true
    } else {
      // 非法标志位,输出用法并退出
      usage(`illegal option -- ${flag}`)
    }
  }

  return acc
}, [[], {}])

for (const command of commands) {
  try {
    // 同步查找命令路径:all: flags.all → 为 true 时返回所有匹配路径,否则返回第一个
    const res = which.sync(command, { all: flags.all })

    // 非静默模式(!flags.silent):输出结果(数组转换行字符串,兼容多路径)
    if (!flags.silent) {
      console.log([].concat(res).join('\n'))
    }
  } catch (err) {
    // 查找失败(命令不存在):设置退出码 1,但不退出进程(继续处理下一个命令)
    process.exitCode = 1
  }
}

入口文件

node-which-5.0.0/lib/index.js

// 导入可执行文件检查工具
const { isexe, sync: isexeSync } = require('isexe')
// 导入路径处理工具
const { join, delimiter, sep, posix } = require('path')


module.exports = which
which.sync = whichSync

API 之 which

node-which-5.0.0/lib/index.js

const which = async (cmd, opt = {}) => {
  // 获取路径相关信息
  // pathEnv:PATH 分割后的目录数组(如 ['/usr/bin', '/bin'])。
  // pathExt:需要尝试的文件扩展名数组(如 ['.exe', '.cmd'] 或 ['', '.sh'])。
  // pathExtExe:用于 isexe 检查的完整扩展名列表。
  const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt)
  // 存储找到的所有可执行文件路径
  const found = []

  // 遍历 PATH 中的每个目录
  for (const envPart of pathEnv) {
    // 拼接目录与命令,生成基础路径
    const p = getPathPart(envPart, cmd)

    // 遍历每个可能的扩展名
    for (const ext of pathExt) {
      // 拼接基础路径与扩展名,生成完整路径
      const withExt = p + ext
       // 检查该路径是否为可执行文件
      const is = await isexe(withExt, { pathExt: pathExtExe, ignoreErrors: true })
      if (is) {
        // 若不需要返回所有结果,直接返回第一个匹配项
        if (!opt.all) {
          return withExt
        }
        // 否则加入结果数组
        found.push(withExt)
      }
    }
  }

  if (opt.all && found.length) {
    return found
  }

  // 若允许不抛出错误,返回 null
  if (opt.nothrow) {
    return null
  }

  // 否则抛出“未找到”错误
  throw getNotFoundError(cmd)
}

const { isexe } = require('isexe')

getPathInfo

const getPathInfo = (
  cmd, // 要查找的命令(如 npm、ls、./script.sh)
  {
    // 指定的搜索路径(默认使用环境变量 PATH)
    path: optPath = process.env.PATH,
    // 可执行文件的扩展名列表(默认使用环境变量 PATHEXT,Windows 特有)
    pathExt: optPathExt = process.env.PATHEXT,
    // 路径分隔符(默认根据系统自动确定,Windows 用 ;,类 Unix 用 :)
    delimiter: optDelimiter = delimiter,
  },
) => {
  
  // 第一步:生成「搜索路径列表(pathEnv)」
  // 判断命令是否包含「路径分隔符」(/ 或 \)
  const pathEnv = cmd.match(rSlash)
    // 命令包含路径分隔符
    ? [''] // [''],表示无需搜索系统路径
    : [
      // Windows:先加「当前工作目录(process.cwd ())」
      // Windows 原生规则:优先搜当前目录
      // Unix(Linux/Mac):不加当前目录
      ...(isWindows ? [process.cwd()] : []),
      // 把 optPath(默认系统 PATH)按分隔符(Windows ;、Unix :)拆分为目录列表
      ...(optPath || '').split(optDelimiter),
    ];

  // 第二步:Windows 系统的扩展名特殊处理
  if (isWindows) {
    // 1、获取可执行文件扩展名列表
    const pathExtExe =
      optPathExt || ['.EXE', '.CMD', '.BAT', '.COM'].join(optDelimiter);

    // 将 pathExtExe 拆分后,为每个扩展名添加小写版本(如 .EXE → .EXE 和 .exe)
    // 因为 Windows 文件名不区分大小写,确保大小写不同的扩展名都能被匹配
    const pathExt = pathExtExe
      .split(optDelimiter)
      .flatMap((item) => [item, item.toLowerCase()]);

    // 若命令包含 .(如 my.script),可能用户已指定部分扩展名
    // 此时在 pathExt 开头添加空字符串(表示不添加扩展名直接匹配)
    if (cmd.includes('.') && pathExt[0] !== '') {
      pathExt.unshift('');
    }

    return { pathEnv, pathExt, pathExtExe };
  }

  // 类 Unix 系统(macOS、Linux)中,无需复杂的扩展名处
  return { pathEnv, pathExt: [''] };
};
const {  delimiter } = require('path')

getPathPart

node-which-5.0.0/lib/index.js

const getPathPart = (
  raw, // 原始的路径片段(可能包含引号,如 "C:\Program Files")
  cmd // 要拼接的命令(如 node.exe、script.sh,可能包含相对路径前缀
) => {
  // 处理带引号的路径片段(pathPart 生成)
  const pathPart = /^".*"$/.test(raw) ? raw.slice(1, -1) : raw

  // 提取相对路径前缀(prefix 生成)
  const prefix = !pathPart && rRel.test(cmd) ? cmd.slice(0, 2) : ''

  // 拼接路径片段与命令
  return prefix + join(pathPart, cmd)
}
const rSlash = new RegExp(
  `[${posix.sep}${sep === posix.sep ? '' : sep}]`.replace(/(\\)/g, '\\$1'),
);

const rRel = new RegExp(`^\\.${rSlash.source}`);

const { join, sep, posix } = require('path')

API 之 which.sync

node-which-5.0.0/lib/index.js

const whichSync = (cmd, opt = {}) => {
  const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt)
  const found = []

  for (const pathEnvPart of pathEnv) {
    const p = getPathPart(pathEnvPart, cmd)

    for (const ext of pathExt) {
      const withExt = p + ext
      const is = 
        isexeSync(withExt, { pathExt: pathExtExe, ignoreErrors: true })
      
      if (is) {
        if (!opt.all) {
          return withExt
        }
        found.push(withExt)
      }
    }
  }

  if (opt.all && found.length) {
    return found
  }

  if (opt.nothrow) {
    return null
  }

  throw getNotFoundError(cmd)
}

const { sync: isexeSync } = require('isexe')

isexe@3.1.1

发布日期 2023 年 8 月 3 日

isexe跨平台检查文件是否为「可执行文件」的专用工具包,核心解决「Windows 和 Unix 系统判断可执行文件的规则完全不同」的问题。

package.json

{
  "name": "isexe",
  "version": "3.1.1",
  "description": "Minimal module to check if a file is executable.",
  "main": "./dist/cjs/index.js",
  "module": "./dist/mjs/index.js",
  "types": "./dist/cjs/index.js",
  "files": [
    "dist"
  ],
  "exports": {
    ".": {
      "import": {
        "types": "./dist/mjs/index.d.ts",
        "default": "./dist/mjs/index.js"
      },
      "require": {
        "types": "./dist/cjs/index.d.ts",
        "default": "./dist/cjs/index.js"
      }
    },
    "./posix": {
      "import": {
        "types": "./dist/mjs/posix.d.ts",
        "default": "./dist/mjs/posix.js"
      },
      "require": {
        "types": "./dist/cjs/posix.d.ts",
        "default": "./dist/cjs/posix.js"
      }
    },
    "./win32": {
      "import": {
        "types": "./dist/mjs/win32.d.ts",
        "default": "./dist/mjs/win32.js"
      },
      "require": {
        "types": "./dist/cjs/win32.d.ts",
        "default": "./dist/cjs/win32.js"
      }
    },
    "./package.json": "./package.json"
  },
  "devDependencies": {
    "@types/node": "^20.4.5",
    "@types/tap": "^15.0.8",
    "c8": "^8.0.1",
    "mkdirp": "^0.5.1",
    "prettier": "^2.8.8",
    "rimraf": "^2.5.0",
    "sync-content": "^1.0.2",
    "tap": "^16.3.8",
    "ts-node": "^10.9.1",
    "typedoc": "^0.24.8",
    "typescript": "^5.1.6"
  },
  "scripts": {
    "preversion": "npm test",
    "postversion": "npm publish",
    "prepublishOnly": "git push origin --follow-tags",
    "prepare": "tsc -p tsconfig/cjs.json && tsc -p tsconfig/esm.json && bash ./scripts/fixup.sh",
    "pretest": "npm run prepare",
    "presnap": "npm run prepare",
    "test": "c8 tap",
    "snap": "c8 tap",
    "format": "prettier --write . --loglevel warn --ignore-path ../../.prettierignore --cache",
    "typedoc": "typedoc --tsconfig tsconfig/esm.json ./src/*.ts"
  },
  "author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)",
  "license": "ISC",
  "tap": {
    "coverage": false,
    "node-arg": [
      "--enable-source-maps",
      "--no-warnings",
      "--loader",
      "ts-node/esm"
    ],
    "ts": false
  },
  "prettier": {
    "semi": false,
    "printWidth": 75,
    "tabWidth": 2,
    "useTabs": false,
    "singleQuote": true,
    "jsxSingleQuote": false,
    "bracketSameLine": true,
    "arrowParens": "avoid",
    "endOfLine": "lf"
  },
  "repository": "https://github.com/isaacs/isexe",
  "engines": {
    "node": ">=16"
  }
}

入口文件

isexe-3.1.1/src/index.ts

import * as posix from './posix.js' // 导入 POSIX 系统(Linux/macOS 等)的实现
import * as win32 from './win32.js' // 导入 Windows 系统的实现
export * from './options.js' // 导出配置选项类型(如 IsexeOptions)
export { win32, posix }  // 允许直接访问特定平台的实现

const platform = process.env._ISEXE_TEST_PLATFORM_ || process.platform
const impl = platform === 'win32' ? win32 : posix

/**
 * Determine whether a path is executable on the current platform.
 */
export const isexe = impl.isexe
/**
 * Synchronously determine whether a path is executable on the
 * current platform.
 */
export const sync = impl.sync

posix.isexe

isexe-3.1.1/src/posix.ts

const isexe = async (
  path: string,  // 要检查的文件路径(比如 "/usr/bin/node" 或 "C:\\node.exe")
  options: IsexeOptions = {}  // 配置项,默认空对象
): Promise<boolean> => {
  
  const { ignoreErrors = false } = options

  try {
    // await stat(path):获取文件状态
    // checkStat(statResult, options):判断是否可执行
    return checkStat(await stat(path), options)
  } catch (e) {
    // 把错误转为 Node.js 标准错误类型(带错误码)
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er // 非预期错误,向上抛出
  }
}
import { Stats, statSync } from 'fs'
import { stat } from 'fs/promises'

checkStat

isexe-3.1.1/src/posix.ts

const checkStat = (stat: Stats, options: IsexeOptions) =>
  stat.isFile() && checkMode(stat, options)

checkMode

isexe-3.1.1/src/posix.ts

const checkMode = (
  // 文件的 Stats 对象(通常由 fs.stat 或 fs.lstat 获取)
  // 包含文件的权限位(mode)、所有者 ID(uid)、所属组 ID(gid)等元数据。
  stat: Stats, 
  // 配置对象,允许自定义用户 ID(uid)、组 ID(gid)、用户所属组列表(groups),默认使用当前进程的用户信息。
  options: IsexeOptions
) => {
  // 1、获取用户与组信息
  // 当前用户的 ID(优先使用 options.uid,否则调用 process.getuid() 获取当前进程的用户 ID)。
  const myUid = options.uid ?? process.getuid?.()
  // 当前用户所属的组 ID 列表(优先使用 options.groups,否则调用 process.getgroups() 获取)。
  const myGroups = options.groups ?? process.getgroups?.() ?? []
  // 当前用户的主组 ID(优先使用 options.gid,否则调用 process.getgid(),或从 myGroups 取第一个组 ID)。
  const myGid = options.gid ?? process.getgid?.() ?? myGroups[0]
  // 若无法获取 myUid 或 myGid,抛出错误(权限判断依赖这些信息)
  if (myUid === undefined || myGid === undefined) {
    throw new Error('cannot get uid or gid')
  }

  // 2、构建用户所属组集合
  const groups = new Set([myGid, ...myGroups])

  // 3、解析文件权限位与归属信息
  const mod = stat.mode // 文件的权限位(整数,如 0o755 表示 rwxr-xr-x)
  const uid = stat.uid // 文件所有者的用户 ID
  const gid = stat.gid // 文件所属组的组 ID

  // 4、定义权限位掩码
  // 八进制 100 → 十进制 64 → 对应所有者的执行权限位(x)
  const u = parseInt('100', 8)
  // 八进制 010 → 十进制 8 → 对应所属组的执行权限位(x)
  const g = parseInt('010', 8)
  // 八进制 001 → 十进制 1 → 对应其他用户的执行权限位(x)
  const o = parseInt('001', 8)
  // 所有者和所属组的执行权限位掩码(64 | 8 = 72)
  const ug = u | g

  // 5、权限判断逻辑
  return !!(
    mod & o || // 1. 其他用户有执行权限
    (mod & g && groups.has(gid)) || // 2. 所属组有执行权限,且当前用户属于该组
    (mod & u && uid === myUid) || // 3. 所有者有执行权限,且当前用户是所有者
    (mod & ug && myUid === 0)  // 4. 所有者或组有执行权限,且当前用户是 root(UID=0)
  )
}

posix.sync

const sync = (
  path: string,
  options: IsexeOptions = {}
): boolean => {
  
  const { ignoreErrors = false } = options
  try {
    return checkStat(statSync(path), options)
    
  } catch (e) {
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er
  }
}

win32.isexe

isexe-3.1.1/src/win32.ts

const isexe = async (
  path: string,
  options: IsexeOptions = {}
): Promise<boolean> => {
  
  const { ignoreErrors = false } = options
  try {
    return checkStat(await stat(path), path, options)
  } catch (e) {
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er
  }
}

checkStat

isexe-3.1.1/src/win32.ts

const checkStat = (stat: Stats, path: string, options: IsexeOptions) =>
  stat.isFile() && checkPathExt(path, options)

checkPathExt

isexe-3.1.1/src/win32.ts

const checkPathExt = (path: string, options: IsexeOptions) => {

  // 获取可执行扩展名列表
  const { pathExt = process.env.PATHEXT || '' } = options

  const peSplit = pathExt.split(';')
  // 特殊情况处理:空扩展名
  // 空扩展名通常表示 “任何文件都视为可执行”,这是一种特殊配置
  if (peSplit.indexOf('') !== -1) {
    return true
  }

  // 检查文件扩展名是否匹配
  for (let i = 0; i < peSplit.length; i++) {
    // 转小写:避免大小写问题(比如.EXE和.exe视为同一个)
    const p = peSplit[i].toLowerCase()
    // 截取文件路径的最后N个字符(N是当前扩展名p的长度),也转小写
    const ext = path.substring(path.length - p.length).toLowerCase()

    // 匹配条件:扩展名非空 + 文件扩展名和列表中的扩展名完全一致
    if (p && ext === p) {
      return true
    }
  }
  return false
}

win32.sync

isexe-3.1.1/src/win32.ts

const sync = (
  path: string,
  options: IsexeOptions = {}
): boolean => {
  
  const { ignoreErrors = false } = options
  try {
    return checkStat(statSync(path), path, options)
    
  } catch (e) {
    const er = e as NodeJS.ErrnoException
    if (ignoreErrors || er.code === 'EACCES') return false
    throw er
  }
}

Vue.js核心知识总结01

2026年2月6日 19:14

一、Vue 实例与数据绑定详解

1.1 Vue 实例是什么?

Vue 实例是 Vue 应用的入口点,每个 Vue 应用都是通过创建 Vue 实例开始的。

// Vue 实例创建语法
const vm = new Vue({
  // 配置选项
  el: '#app',          // 挂载点
  data: {},           // 数据
  methods: {},        // 方法
  computed: {},       // 计算属性
  watch: {},         // 侦听器
  // ... 其他选项
})

1.2 el 选项的两种写法

写法一:创建时直接指定

// 创建实例时指定挂载点
const vm = new Vue({
  el: '#app',  // 可以是CSS选择器字符串
  data: { message: 'Hello' }
})

// 或者传入DOM元素
const appElement = document.getElementById('app')
const vm = new Vue({
  el: appElement,  // 也可以是DOM元素
  data: { message: 'Hello' }
})

写法二:创建后手动挂载

// 1. 先创建实例(不指定el)
const vm = new Vue({
  data: { message: 'Hello' }
})

// 2. 稍后手动挂载
setTimeout(() => {
  vm.$mount('#app')  // 方式一:使用选择器
  
  // 或
  const element = document.getElementById('app')
  vm.$mount(element)  // 方式二:使用DOM元素
}, 1000)

何时使用$mount?

  • 需要条件挂载:根据用户权限、设备类型等决定挂载点
  • 异步加载场景:等待某些条件满足后再挂载
  • 测试环境:更方便地控制挂载时机

1.3 data 的两种写法详解

写法一:对象式(Vue实例使用)

// 适用于根实例或简单场景
new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue',
    count: 0,
    user: {
      name: '张三',
      age: 25
    }
  }
})

写法二:函数式(组件必须使用)

// 适用于组件(Vue.component 或 .vue 文件)
Vue.component('my-component', {
  data() {
    return {
      message: 'Hello',
      count: 0
    }
  }
})

// 或单文件组件中
export default {
  data() {
    return {
      message: 'Hello',
      count: 0
    }
  }
}

为什么组件必须用函数式?

//  错误示例:对象式data会导致数据共享问题
const CounterComponent = {
  data: { count: 0 },  // 所有实例共享同一个对象
  template: '<button @click="count++">{{ count }}</button>'
}

// 使用组件
<counter-component></counter-component>
<counter-component></counter-component>
// 两个组件会显示相同的count值,点击一个另一个也会变

//  正确示例:函数式保证每个实例有自己的数据
const CounterComponent = {
  data() {
    return { count: 0 }  // 每次返回新对象
  },
  template: '<button @click="count++">{{ count }}</button>'
}
// 现在每个组件实例都有独立的count

二、模板语法深度解析

2.1 插值表达式 {{}} 的完整用法

基本语法:

<div id="app">
  <!-- 显示纯文本 -->
  <p>{{ message }}</p>
  
  <!-- 支持JavaScript表达式 -->
  <p>{{ message + '!' }}</p>
  <p>{{ count + 1 }}</p>
  <p>{{ price * quantity }}</p>
  <p>{{ score >= 60 ? '及格' : '不及格' }}</p>
  
  <!-- 调用方法 -->
  <p>{{ getFullName() }}</p>
  
  <!-- 访问对象属性 -->
  <p>{{ user.name }}</p>
  <p>{{ user.address.city }}</p>
  
  <!-- 数组操作 -->
  <p>{{ list[0] }}</p>
  <p>{{ list.slice(0, 3) }}</p>
  
  <!-- 字符串操作 -->
  <p>{{ text.toUpperCase() }}</p>
  <p>{{ text.substring(0, 10) + '...' }}</p>
</div>

不允许的用法:

<!--  不能是语句 -->
{{ var a = 1 }}
{{ if(true) { return 'yes' } }}
{{ for(let i=0; i<10; i++) { console.log(i) } }}

<!--  不能是函数/类定义 -->
{{ function() { return 1 } }}
{{ class MyClass {} }}

<!--  不能是赋值表达式 -->
{{ a = 1 }}
{{ a += 1 }}

<!-- 不能在标签属性中直接使用 -->
<div id="{{ id }}">错误</div>  <!-- 应该用 v-bind -->

2.2 指令语法:v-bind 详细讲解

基础绑定:

<!-- 绑定属性 -->
<img v-bind:src="imageSrc">
<a v-bind:href="url">链接</a>
<input v-bind:value="inputValue">

<!-- 简写形式 -->
<img :src="imageSrc">
<a :href="url">链接</a>
<input :value="inputValue">

绑定class的多种方式:

<!-- 1. 对象语法(最常用) -->
<div :class="{ active: isActive, 'text-danger': hasError }">
  对象语法
</div>
<!-- 渲染为:<div class="active text-danger"></div> -->

<!-- 2. 数组语法 -->
<div :class="[activeClass, errorClass]">
  数组语法
</div>
<!-- 相当于:<div class="active text-danger"></div> -->

<!-- 3. 混合语法 -->
<div :class="['base-class', { active: isActive, disabled: isDisabled }]">
  混合语法
</div>

<!-- 4. 计算属性返回class对象 -->
<div :class="classObject">
  计算属性
</div>

<script>
new Vue({
  data: {
    isActive: true,
    hasError: false,
    activeClass: 'active',
    errorClass: 'text-danger'
  },
  computed: {
    classObject() {
      return {
        active: this.isActive && !this.hasError,
        'text-danger': this.hasError && this.error.type === 'fatal'
      }
    }
  }
})
</script>

绑定style的详细用法:

<!-- 1. 内联对象语法 -->
<div :style="{ 
  color: activeColor, 
  fontSize: fontSize + 'px',
  'font-weight': isBold ? 'bold' : 'normal'
}">
  内联样式
</div>

<!-- 2. 绑定样式对象 -->
<div :style="styleObject">
  样式对象
</div>

<script>
new Vue({
  data: {
    styleObject: {
      color: 'red',
      fontSize: '13px',
      backgroundColor: '#f5f5f5'
    }
  }
})
</script>

<!-- 3. 数组语法(合并多个样式对象) -->
<div :style="[baseStyles, overridingStyles]">
  数组语法
</div>

<!-- 4. 自动添加浏览器前缀 -->
<div :style="{ transform: 'rotate(45deg)' }">
  自动加前缀
</div>
<!-- Vue会自动处理为:transform, -webkit-transform, -ms-transform等 -->

<!-- 5. 多重值(浏览器兼容) -->
<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }">
  多重值
</div>
<!-- 浏览器会依次检查支持哪个值,使用第一个支持的 -->

2.3 v-model 双向绑定深度解析

基础用法:

<!-- 文本输入 -->
<input v-model="message" placeholder="编辑我...">
<p>输入的内容是:{{ message }}</p>

<!-- 多行文本 -->
<textarea v-model="message" placeholder="多行文本..."></textarea>

<!-- 复选框(单个) -->
<input type="checkbox" id="checkbox" v-model="checked">
<label for="checkbox">{{ checked }}</label>

<!-- 复选框(多个,绑定到数组) -->
<input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
<label for="jack">Jack</label>
<input type="checkbox" id="john" value="John" v-model="checkedNames">
<label for="john">John</label>
<p>选中的名字:{{ checkedNames }}</p>

<!-- 单选按钮 -->
<input type="radio" id="one" value="One" v-model="picked">
<label for="one">One</label>
<input type="radio" id="two" value="Two" v-model="picked">
<label for="two">Two</label>
<p>选中的是:{{ picked }}</p>

<!-- 选择框(单选) -->
<select v-model="selected">
  <option disabled value="">请选择</option>
  <option value="A">选项A</option>
  <option value="B">选项B</option>
</select>
<p>选中的是:{{ selected }}</p>

<!-- 选择框(多选,绑定到数组) -->
<select v-model="multiSelected" multiple style="width: 50px;">
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>
<p>多选的结果:{{ multiSelected }}</p>

修饰符详解:

<!-- .lazy:输入完成后同步(失去焦点或按回车时) -->
<input v-model.lazy="msg">
<p>lazy值:{{ msg }}</p>
<!-- 普通v-model:每输入一个字符就同步一次 -->
<!-- lazy:输入完成后才同步 -->

<!-- .number:自动将输入转为数值类型 -->
<input v-model.number="age" type="number">
<p>age的类型:{{ typeof age }}</p>
<!-- 输入"25"会转为数字25,而不是字符串"25" -->

<!-- .trim:自动去除首尾空白字符 -->
<input v-model.trim="username">
<p>用户名:"{{ username }}"</p>
<!-- 输入"  admin "会自动转为"admin" -->

<!-- 修饰符可以串联使用 -->
<input v-model.lazy.trim="searchText">

v-model 原理(自定义组件中实现):

Vue.component('custom-input', {
  props: ['value'],  // 接收父组件传递的值
  template: `
    <input
      :value="value"
      @input="$emit('input', $event.target.value)"
    >
  `
})

// 使用
<custom-input v-model="message"></custom-input>
<!-- 相当于 -->
<custom-input 
  :value="message"
  @input="message = $event"
></custom-input>

三、事件处理完整指南

3.1 事件绑定语法详解

基本绑定:

<!-- 方法处理器 -->
<button v-on:click="greet">打招呼</button>

<!-- 简写 -->
<button @click="greet">打招呼</button>

<!-- 内联语句 -->
<button @click="count += 1">增加:{{ count }}</button>

<!-- 调用方法 -->
<button @click="say('Hello')">说Hello</button>

<!-- 传递事件对象 -->
<button @click="warn('Form cannot be submitted yet.', $event)">
  提交
</button>

<!-- 访问原始DOM事件 -->
<input @input="onInput">

methods 配置详解:

new Vue({
  data: {
    count: 0,
    name: 'Vue.js'
  },
  methods: {
    // 基本方法
    greet() {
      alert('Hello ' + this.name + '!')
    },
    
    // 带参数的方法
    say(message) {
      alert(message)
    },
    
    // 使用事件对象
    warn(message, event) {
      // 阻止默认行为
      if (event) {
        event.preventDefault()
      }
      alert(message)
    },
    
    // 访问事件目标
    onInput(event) {
      console.log('输入的值:', event.target.value)
      console.log('事件类型:', event.type)
      console.log('触发元素:', event.target.tagName)
    },
    
    // 在方法中使用数据
    increment() {
      this.count++  // 可以访问实例数据
      console.log('当前计数:', this.count)
    }
  }
})

3.2 事件修饰符完整用法

.prevent:阻止默认行为

<!-- 阻止链接跳转 -->
<a @click.prevent="doSomething">链接</a>

<!-- 阻止表单提交 -->
<form @submit.prevent="onSubmit">
  <button type="submit">提交</button>
</form>

<!-- 阻止右键菜单 -->
<div @contextmenu.prevent>禁用右键</div>

.stop:阻止事件冒泡

<!-- 父元素也有click事件 -->
<div @click="parentClick">
  <!-- 点击按钮不会触发父元素的click -->
  <button @click.stop="childClick">按钮</button>
</div>

<!-- 实际应用:模态框关闭 -->
<div class="modal" @click="closeModal">
  <div class="modal-content" @click.stop>
    <!-- 点击内容区域不会关闭模态框 -->
    模态框内容
  </div>
</div>

其他修饰符:

<!-- .capture:使用捕获模式 -->
<!-- 事件从外向内捕获,先执行父元素事件 -->
<div @click.capture="parentClick">
  <button @click="childClick">按钮</button>
</div>
<!-- 点击按钮:先执行parentClick,再执行childClick -->

<!-- .self:只有事件在元素自身触发时才调用 -->
<!-- 点击子元素不会触发 -->
<div @click.self="handleSelf">
  <p>点击这个p不会触发div的click事件</p>
</div>

<!-- .once:事件只触发一次 -->
<button @click.once="init">初始化(只执行一次)</button>

<!-- .passive:提升滚动性能 -->
<!-- 告诉浏览器你不想阻止事件的默认行为 -->
<div @scroll.passive="onScroll">
  滚动内容...
</div>
<!-- 特别适合移动端,提升滚动流畅度 -->

修饰符串联使用:

<!-- 顺序很重要:会按顺序执行 -->
<a @click.stop.prevent="doSomething">
  阻止跳转和冒泡
</a>
<!-- 先执行.stop,再执行.prevent -->

<!-- 多个修饰符 -->
<form @submit.prevent.stop.once="handleSubmit">
  只提交一次的表单
</form>

3.3 按键修饰符和系统修饰符

按键修饰符:

<!-- Vue提供的按键别名 -->
<input @keyup.enter="submit">      <!-- 回车 -->
<input @keyup.tab="nextField">     <!-- Tab -->
<input @keyup.delete="deleteItem"> <!-- 删除 -->
<input @keyup.esc="cancel">        <!-- ESC -->
<input @keyup.space="playPause">   <!-- 空格 -->
<input @keyup.up="moveUp">         <!-- 上箭头 -->
<input @keyup.down="moveDown">     <!-- 下箭头 -->
<input @keyup.left="moveLeft">     <!-- 左箭头 -->
<input @keyup.right="moveRight">   <!-- 右箭头 -->

<!-- 数字键 -->
<input @keyup.1="selectOption1">   <!-- 数字1 -->
<input @keyup.2="selectOption2">   <!-- 数字2 -->

<!-- 字母键 -->
<input @keyup.a="pressA">          <!-- A键 -->
<input @keyup.b="pressB">          <!-- B键 -->

<!-- 使用键码(不推荐,因为键码可能变动) -->
<input @keyup.13="submit">         <!-- 13是回车键码 -->

<!-- 自定义按键别名 -->
<script>
Vue.config.keyCodes = {
  f1: 112,
  f2: 113,
  custom: 86,      // v键
  pageUp: 33,
  pageDown: 34
}
</script>
<input @keyup.f1="showHelp">       <!-- F1键 -->
<input @keyup.custom="doCustom">   <!-- 自定义键 -->

系统修饰键:

<!-- Ctrl + 点击 -->
<button @click.ctrl="doSomething">Ctrl+点击</button>

<!-- Alt + 点击 -->
<button @click.alt="doSomething">Alt+点击</button>

<!-- Shift + 点击 -->
<button @click.shift="doSomething">Shift+点击</button>

<!-- Meta(Windows键或Command键) -->
<button @click.meta="doSomething">Meta+点击</button>

<!-- 精确修饰符 .exact -->
<!-- 只有Ctrl被按下时才触发 -->
<button @click.ctrl.exact="ctrlOnly">仅Ctrl</button>

<!-- 没有任何系统修饰符被按下时才触发 -->
<button @click.exact="noModifiers">无修饰键</button>

鼠标按钮修饰符:

<!-- 左键点击 -->
<button @click.left="leftClick">左键</button>

<!-- 右键点击 -->
<button @click.right="rightClick">右键</button>

<!-- 中键点击 -->
<button @click.middle="middleClick">中键</button>

四、计算属性与侦听器深度解析

4.1 计算属性(Computed)完整指南

计算属性的特点:

  1. 缓存机制:依赖不变时不重新计算
  2. 响应式:依赖的响应式数据变化时自动更新
  3. 声明式:像普通属性一样使用,不用关心如何计算

基本用法:

new Vue({
  data: {
    firstName: '张',
    lastName: '三',
    price: 100,
    quantity: 5,
    discount: 0.1
  },
  computed: {
    // 1. 简写形式(只有getter)
    fullName() {
      return this.firstName + this.lastName
    },
    
    // 2. 完整形式(包含getter和setter)
    fullNameWithSetter: {
      get() {
        return this.firstName + ' ' + this.lastName
      },
      set(newValue) {
        const names = newValue.split(' ')
        this.firstName = names[0]
        this.lastName = names[names.length - 1]
      }
    },
    
    // 3. 依赖多个数据
    totalPrice() {
      return this.price * this.quantity * (1 - this.discount)
    },
    
    // 4. 格式化数据
    formattedPrice() {
      return '¥' + this.totalPrice.toFixed(2)
    },
    
    // 5. 过滤列表
    activeUsers() {
      return this.users.filter(user => user.isActive)
    },
    
    // 6. 排序列表
    sortedItems() {
      return [...this.items].sort((a, b) => a.price - b.price)
    }
  }
})

在模板中使用计算属性:

<div id="app">
  <!-- 像普通属性一样使用 -->
  <p>全名:{{ fullName }}</p>
  <p>总价:{{ formattedPrice }}</p>
  
  <!-- 双向绑定计算属性(需要有setter) -->
  <input v-model="fullNameWithSetter">
  
  <!-- 在v-for中使用 -->
  <ul>
    <li v-for="user in activeUsers" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
  
  <!-- 在样式绑定中使用 -->
  <div :class="{ 'high-price': totalPrice > 1000 }">
    价格:{{ formattedPrice }}
  </div>
  
  <!-- 在条件渲染中使用 -->
  <div v-if="hasActiveUsers">
    有活跃用户
  </div>
</div>

计算属性的缓存机制:

computed: {
  // 这个计算属性会被缓存
  expensiveCalculation() {
    console.log('重新计算...')
    // 假设这是一个耗时的计算
    let result = 0
    for (let i = 0; i < 1000000; i++) {
      result += Math.sqrt(i)
    }
    return result
  }
}

// 多次访问expensiveCalculation,只计算一次
console.log(vm.expensiveCalculation) // 输出"重新计算...",然后返回结果
console.log(vm.expensiveCalculation) // 直接返回缓存结果,不输出
vm.someDependency = 'new value'      // 依赖变化
console.log(vm.expensiveCalculation) // 再次输出"重新计算..."

4.2 侦听器(Watch)完整指南

侦听器的特点:

  1. 观察数据变化:响应特定数据的变化
  2. 执行副作用:适合执行异步操作或复杂逻辑
  3. 无缓存:每次变化都会执行

基本语法:

new Vue({
  data: {
    message: 'Hello',
    user: {
      name: '张三',
      age: 25,
      address: {
        city: '北京',
        street: '朝阳路'
      }
    },
    searchText: '',
    formData: {
      title: '',
      content: ''
    }
  },
  watch: {
    // 1. 基本侦听(函数形式)
    message(newVal, oldVal) {
      console.log(`消息从 "${oldVal}" 变为 "${newVal}"`)
    },
    
    // 2. 深度侦听对象(对象形式)
    user: {
      deep: true,        // 深度侦听对象内部变化
      immediate: true,   // 立即执行一次handler
      handler(newVal, oldVal) {
        console.log('用户信息发生变化')
        // 保存到本地存储
        localStorage.setItem('user', JSON.stringify(newVal))
      }
    },
    
    // 3. 侦听对象属性(字符串形式)
    'user.name': function(newVal, oldVal) {
      console.log(`用户名从 ${oldVal} 改为 ${newVal}`)
    },
    
    // 4. 侦听嵌套属性
    'user.address.city': function(newCity) {
      console.log('城市改为:', newCity)
    },
    
    // 5. 侦听计算属性
    fullName(newVal) {
      console.log('全名更新为:', newVal)
    }
  }
})

实际应用场景:

场景一:搜索功能防抖

watch: {
  searchText: {
    handler(newVal) {
      // 清除之前的定时器
      if (this.searchTimer) {
        clearTimeout(this.searchTimer)
      }
      
      // 设置新的定时器
      this.searchTimer = setTimeout(() => {
        this.performSearch(newVal)
      }, 500) // 500毫秒防抖
    },
    immediate: true // 组件创建时立即执行
  }
},
methods: {
  async performSearch(keyword) {
    if (!keyword.trim()) {
      this.searchResults = []
      return
    }
    
    try {
      this.isLoading = true
      const response = await fetch(`/api/search?q=${keyword}`)
      this.searchResults = await response.json()
    } catch (error) {
      console.error('搜索失败:', error)
    } finally {
      this.isLoading = false
    }
  }
}

场景二:表单验证

watch: {
  email: {
    handler(newVal) {
      this.validateEmail(newVal)
    },
    immediate: true
  },
  
  password: {
    handler(newVal) {
      this.validatePassword(newVal)
    },
    immediate: true
  }
},
methods: {
  validateEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!email) {
      this.emailError = '邮箱不能为空'
    } else if (!emailRegex.test(email)) {
      this.emailError = '邮箱格式不正确'
    } else {
      this.emailError = ''
    }
  },
  
  validatePassword(password) {
    if (!password) {
      this.passwordError = '密码不能为空'
    } else if (password.length < 6) {
      this.passwordError = '密码至少6位'
    } else {
      this.passwordError = ''
    }
  }
}

场景三:路由参数变化

watch: {
  // 侦听路由参数变化
  '$route.params.id': {
    handler(newId) {
      this.loadProductDetails(newId)
    },
    immediate: true
  },
  
  // 侦听查询参数变化
  '$route.query': {
    handler(newQuery) {
      this.applyFilters(newQuery)
    },
    deep: true
  }
},
methods: {
  async loadProductDetails(productId) {
    try {
      this.isLoading = true
      const response = await fetch(`/api/products/${productId}`)
      this.product = await response.json()
    } catch (error) {
      console.error('加载失败:', error)
      this.error = '加载产品信息失败'
    } finally {
      this.isLoading = false
    }
  },
  
  applyFilters(query) {
    // 根据查询参数过滤数据
    this.filters = { ...this.filters, ...query }
    this.filterProducts()
  }
}

API方式使用侦听器:

const vm = new Vue({
  data: { count: 0 }
})

// 使用$watch API
const unwatch = vm.$watch('count', function(newVal, oldVal) {
  console.log(`count从${oldVal}变为${newVal}`)
}, {
  deep: false,
  immediate: false
})

// 修改数据,触发侦听器
vm.count = 1  // 输出:count从0变为1

// 停止侦听
unwatch()

vm.count = 2  // 不会触发侦听器

4.3 Computed vs Watch 选择指南

何时使用计算属性?

// 场景1:需要基于现有数据计算新值
computed: {
  // 全名 = 姓 + 名
  fullName() {
    return this.firstName + ' ' + this.lastName
  },
  
  // 过滤并排序列表
  sortedActiveUsers() {
    return this.users
      .filter(user => user.isActive)
      .sort((a, b) => a.name.localeCompare(b.name))
  },
  
  // 格式化显示
  formattedDate() {
    return new Date(this.timestamp).toLocaleDateString()
  }
}

何时使用侦听器?

// 场景1:需要执行异步操作
watch: {
  searchText(newVal) {
    // 发送API请求
    this.fetchSearchResults(newVal)
  }
},

// 场景2:数据变化需要执行多个操作
watch: {
  formData: {
    deep: true,
    handler(newVal) {
      // 1. 保存草稿
      this.saveDraft(newVal)
      // 2. 验证表单
      this.validateForm(newVal)
      // 3. 发送分析事件
      this.sendAnalytics('form_changed', newVal)
    }
  }
},

// 场景3:需要访问新旧值
watch: {
  price(newPrice, oldPrice) {
    const change = ((newPrice - oldPrice) / oldPrice * 100).toFixed(2)
    console.log(`价格变化:${change}%`)
  }
}

性能优化建议:

  1. 能用计算属性就不用侦听器:计算属性有缓存,性能更好
  2. 避免在计算属性中执行异步操作:计算属性应该是同步的
  3. 深度侦听要谨慎deep: true 会遍历对象所有属性,性能开销大
  4. 及时清理定时器:在beforeDestroy中清理侦听器中的定时器
beforeDestroy() {
  if (this.searchTimer) {
    clearTimeout(this.searchTimer)
  }
  if (this.unwatch) {
    this.unwatch()
  }
}

 5.条件渲染

5.1 v-if 指令

语法

<div v-if="表达式">内容</div>
<div v-else-if="表达式">内容</div>
<div v-else>内容</div>

特点

  • 基于条件判断是否创建或移除DOM元素
  • 切换频率较低的场景使用
  • v-if、v-else-if、v-else必须连续使用,中间不能被打断

示例

<div id="root">
    <div v-if="type === 'A'">显示A</div>
    <div v-else-if="type === 'B'">显示B</div>
    <div v-else>显示其他</div>
</div>

5.2 v-show 指令

语法

<div v-show="表达式">内容</div>

特点

  • 通过CSS的display: none控制显示/隐藏
  • DOM元素始终存在,只是视觉上隐藏
  • 切换频率高的场景使用
  • 初始渲染开销大,切换开销小

5.3 v-if vs v-show 对比

特性 v-if v-show
DOM操作 添加/删除DOM节点 切换CSS的display属性
初始渲染 条件为false时不渲染 无论条件都渲染,然后隐藏
切换开销 高(需要重建DOM) 低(只修改CSS)
适用场景 不频繁切换的场景 频繁切换的场景
性能影响 减少初始DOM节点数量 增加初始DOM节点数量

5.4 template 标签的使用

<template v-if="n === 1">
    <h1>标题1</h1>
    <p>段落1</p>
    <span>内容1</span>
</template>

特点

  • 作为不可见的包裹元素,不会在最终HTML中显示
  • 只能与v-ifv-else-ifv-elsev-for配合使用
  • 不能v-show一起使用

6、列表渲染

6.1 v-for 指令基础

语法

<li v-for="(item, index) in items" :key="item.id">
    {{ item.name }} - 索引: {{ index }}
</li>

可遍历的数据结构

遍历数组(最常用):

<ul>
    <li v-for="(person, index) in persons" :key="person.id">
        {{ index }} - {{ person.name }} ({{ person.age }})
    </li>
</ul>

遍历对象

<ul>
    <li v-for="(value, key, index) in car" :key="key">
        {{ index }} - {{ key }}: {{ value }}
    </li>
</ul>

遍历字符串(很少用):

<span v-for="(char, index) in 'hello'" :key="index">
    {{ char }}
</span>

遍历数字(很少用):

<span v-for="n in 5" :key="n">{{ n }}</span>
<!-- 输出:1 2 3 4 5 -->

6.2 :key 的重要性

为什么需要key?

key是Vue识别节点的标识,用于高效的DOM更新。

key的内部原理(虚拟DOM Diff算法)

数据更新流程

数据变化 → 响应式系统检测 → 重新生成虚拟DOM → 新旧虚拟DOM差异对比 → 最小化更新真实DOM

Diff算法对比规则

  1. 找到相同key

    • 内容未变 → 复用之前的真实DOM
    • 内容变化 → 生成新真实DOM,替换旧DOM
  2. 未找到相同key

    • 创建新真实DOM,添加到页面

key的选择策略

推荐使用

  • 数据的唯一标识:id、手机号、身份证号等
<li v-for="person in persons" :key="person.id">
    {{ person.name }}
</li>

谨慎使用index作为key

<!-- 可能引发问题的场景 -->
<li v-for="(person, index) in persons" :key="index">
    {{ person.name }}
</li>

index作为key的问题

  1. 效率问题:逆序添加/删除时,产生不必要的真实DOM更新
  2. 数据错乱:包含输入类DOM时,可能产生错误的DOM更新

何时可以使用index

  • 仅用于展示的静态列表
  • 没有逆序操作
  • 没有输入类表单元素

6.3 综合应用

列表过滤与排序

<div id="app">
    <!-- 搜索功能 -->
    <input type="text" v-model="keyword" placeholder="搜索姓名">
    
    <!-- 排序按钮 -->
    <button @click="sortType = 1">年龄升序 ↑</button>
    <button @click="sortType = 2">年龄降序 ↓</button>
    <button @click="sortType = 0">原顺序</button>
    
    <!-- 列表渲染 -->
    <ul>
        <li v-for="p in filteredPersons" :key="p.id">
            {{ p.id }} - {{ p.name }} ({{ p.age }}岁)
        </li>
    </ul>
</div>

<script>
new Vue({
    el: '#app',
    data: {
        keyword: '',
        sortType: 0, // 0:原顺序, 1:升序, 2:降序
        persons: [
            { id: '001', name: '张三', age: 25 },
            { id: '002', name: '李四', age: 22 },
            { id: '003', name: '王五', age: 28 },
            { id: '004', name: '赵六', age: 20 }
        ]
    },
    computed: {
        filteredPersons() {
            let arr = this.persons
            
            // 1. 过滤(根据关键字)
            if (this.keyword) {
                arr = arr.filter(person => 
                    person.name.includes(this.keyword)
                )
            }
            
            // 2. 排序
            if (this.sortType === 1) {
                arr = [...arr].sort((a, b) => a.age - b.age) // 升序
            } else if (this.sortType === 2) {
                arr = [...arr].sort((a, b) => b.age - a.age) // 降序
            }
            
            return arr
        }
    }
})
</script>

数组排序方法详解

sort()方法比较函数

arr.sort((a, b) => {
    // 返回值 < 0: a排在b前面
    // 返回值 > 0: b排在a前面  
    // 返回值 = 0: 保持原顺序
})

// 数字排序口诀:前减后得升序,后减前得降序
numbers.sort((a, b) => a - b)   // 升序
numbers.sort((a, b) => b - a)   // 降序

条件渲染与列表渲染结合

<div id="app">
    <!-- 根据数据是否为空显示不同内容 -->
    <div v-if="items.length === 0">
        <p>暂无数据</p>
        <button @click="loadData">加载数据</button>
    </div>
    
    <template v-else>
        <h3>数据列表 (共{{ items.length }}条)</h3>
        <ul>
            <li v-for="item in items" :key="item.id">
                <span v-if="item.isNew" class="new-badge">NEW</span>
                {{ item.title }}
                <span v-show="item.hot"></span>
            </li>
        </ul>
    </template>
</div>

6.4实践与注意事项

性能优化

  1. 合理使用v-if和v-show

    • 初始不需要显示 → 使用v-if
    • 需要频繁切换 → 使用v-show
  2. 避免v-if和v-for同时用在同一个元素

    <!-- 不推荐 -->
    <li v-for="user in users" v-if="user.isActive">
        {{ user.name }}
    </li>
    
    <!-- 推荐:使用计算属性过滤 -->
    <li v-for="user in activeUsers" :key="user.id">
        {{ user.name }}
    </li>
    
  3. 为v-for始终添加key

    • 使用唯一标识,避免使用index

常见问题解决

问题1:列表更新视图不刷新

  • 确保使用Vue的响应式方法修改数组
// 正确
this.items.push(newItem)
this.items.splice(index, 1, newItem)
Vue.set(this.items, index, newValue)

// 错误(不会触发视图更新)
this.items[index] = newValue
this.items.length = 0

问题2:嵌套循环的key

<div v-for="category in categories" :key="category.id">
    <h3>{{ category.name }}</h3>
    <div v-for="product in category.products" 
         :key="product.id">
        {{ product.name }}
    </div>
</div>

7. 响应式原理回顾

Vue的响应式系统工作流程

  1. 数据被Object.defineProperty()代理
  2. 数据变化触发setter
  3. 通知所有依赖的Watcher
  4. Watcher调用更新函数
  5. 重新生成虚拟DOM
  6. 执行Diff算法对比新旧虚拟DOM
  7. 最小化更新真实DOM

虚拟DOM的优势

  • 减少直接操作DOM的次数
  • 批量更新,提高性能
  • 跨平台能力(可渲染到不同平台)

总结

特性 条件渲染 列表渲染
主要指令 v-if, v-else-if, v-else, v-show v-for
核心概念 条件判断显示/隐藏 数据遍历
性能关键 合理选择v-if/v-show 正确使用:key
常用场景 模态框、选项卡、权限控制 数据列表、表格、菜单

key的正确使用:

<!-- 使用唯一标识 -->
<li v-for="user in users" :key="user.id">
  {{ user.name }}
</li>

<!-- 组合唯一键 -->
<li v-for="item in items" :key="`${item.category}-${item.id}`">
  {{ item.name }}
</li>

<!-- 特殊情况:使用index(要谨慎) -->
<li v-for="(item, index) in staticList" :key="index">
  {{ item }}
</li>
<!-- 仅在以下情况可以使用index:
     1. 列表是静态的(不会重新排序、添加、删除)
     2. 列表项没有表单元素
     3. 列表项没有自己的状态 -->

数组更新检测:

// Vue能够检测到的数组变更方法:
// 1. push()     - 末尾添加
// 2. pop()      - 末尾删除
// 3. shift()    - 开头删除
// 4. unshift()  - 开头添加
// 5. splice()   - 添加/删除
// 6. sort()     - 排序
// 7. reverse()  - 反转

// Vue能检测到变化:
vm.items.push({ id: 4, name: 'D' })
vm.items.splice(0, 1)  // 删除第一个
vm.items.sort((a, b) => a.price - b.price)

// Vue检测不到的变化:
vm.items[0] = { id: 1, name: '新的A' }  // 直接设置索引
vm.items.length = 0                     // 直接修改length

// 解决方案:
Vue.set(vm.items, 0, { id: 1, name: '新的A' })
vm.items.splice(0, 1, { id: 1, name: '新的A' })  // 替换
vm.items = []  // 重新赋值

嵌套循环:

<!-- 外层循环 -->
<div v-for="category in categories" :key="category.id">
  <h3>{{ category.name }}</h3>
  
  <!-- 内层循环 -->
  <ul>
    <li v-for="product in category.products" 
        :key="product.id"
        :class="{ 'new': product.isNew }">
      {{ product.name }}
      <span v-if="product.isHot"></span>
    </li>
  </ul>
</div>

8.Vue数据代理

数据代理 是 Vue 中一个重要的机制,它允许开发者通过 this.属性名 直接访问 data 中的数据,而不需要写 this.data.属性名

Vue 实例this代理了 data() 函数返回的对象中的所有属性

代理过程

第一步:原始数据存储

// 1. Vue 先执行你的 data() 函数,得到一个对象
const rawData = {
  message: 'Hello Vue',
  count: 0,
  user: { name: 'John' }
};

// 2. Vue 把这个对象保存到实例的 _data 属性
this._data = rawData;
// 现在可以通过 this._data.message 访问

第二步:创建代理连接

// 3. Vue 在自身(this)上创建同名属性
// 对 message 属性的代理
Object.defineProperty(this, 'message', {
  get() {
    // 当有人访问 this.message 时
    // 实际上返回的是 this._data.message
    return this._data.message;
  },
  set(newValue) {
    // 当有人设置 this.message = 'xxx' 时
    // 实际上设置的是 this._data.message = 'xxx'
    this._data.message = newValue;
  }
});

// 对 count 做同样的代理
Object.defineProperty(this, 'count', {
  get() { return this._data.count; },
  set(val) { this._data.count = val; }
});

// 对 user 做同样的代理  
Object.defineProperty(this, 'user', {
  get() { return this._data.user; },
  set(val) { this._data.user = val; }
});

第三步:使用时的效果

// 你写的代码
this.message = 'New Value';

// Vue 实际执行的操作
this._data.message = 'New Value';
// 这就是代理的核心:转发操作

实际工作流程:

  1. Vue 实例初始化时,将原始 data 保存到 _data 私有属性
  2. 遍历 _data 的所有属性名(如 'message', 'count')
  3. 为每个属性在 Vue 实例上定义同名属性
  4. 设置 getter:访问 this.message 时,实际返回 this._data.message
  5. 设置 setter:设置 this.message = 'new' 时,实际设置 this._data.message = 'new'
  • 谁代理谁?

  • 代理者:Vue 实例(this

  • 被代理者data() 返回的对象中的每个属性

  • 代理什么:属性的读取设置操作

  • 代理方式:通过 Object.defineProperty 创建 getter/setter

  • 代理目的:提供简洁统一的访问接口

9.Vue数据监测

Vue 的核心特性之一就是数据驱动视图。当数据变化时,视图会自动更新。

Vue 2.x 的实现原理(基于 Object.defineProperty)

数据劫持

当你在 Vue 的 data 选项中定义数据时,Vue 会遍历这些数据的所有属性,并使用 JavaScript 的 Object.defineProperty 方法对每个属性进行"劫持"。

每个属性都被 Vue 安装了一个"监听器"。这个监听器有两项主要功能:

  • getter(获取拦截器):当你读取这个属性时,Vue 会记录下来"谁读取了我"
  • setter(设置拦截器):当你修改这个属性时,Vue 会通知所有依赖这个属性的地方进行更新

依赖收集

当一个组件或计算属性使用某个数据时,Vue 会创建一个"观察者"(Watcher)。在数据被读取的过程中,这个观察者会把自己"注册"到该数据的依赖列表中。

就好比你订阅了一份杂志:

  • 当你第一次读取数据时,相当于你告诉杂志社:"我对这个数据感兴趣,请把我加入订阅名单"
  • 数据变化时,杂志社(Vue)会给所有订阅者(Watcher)发送通知

发布-订阅模式

当数据发生变化时,setter 会被触发。这时 Vue 会:

  1. 检查新值是否与旧值相同(避免不必要的更新)
  2. 如果值确实改变了,通知所有依赖这个数据的观察者
  3. 观察者收到通知后,执行更新操作(比如重新渲染组件)

递归观测

如果数据的属性值是对象或数组,Vue 会递归地对它们进行同样的观测处理。这就是为什么嵌套对象和数组也能实现响应式。

Vue 2 响应式的局限性

Vue 2 使用 Object.defineProperty 实现响应式,这带来了一个关键限制:

data() {
  return {
    user: {
      name: 'John',
      age: 30
    },
    list: ['a', 'b', 'c']
  };
},
created() {
  //  问题1:添加新属性不会触发更新
  this.user.email = 'john@example.com';  // 视图不会更新
  
  // 问题2:通过索引设置数组项不会触发更新
  this.list[0] = 'new value';  // 视图不会更新
  
  // 问题3:修改数组长度不会触发更新
  this.list.length = 0;  // 视图不会更新
}

根本原因

  • Vue 2 只能在初始化时对已有的属性进行响应式处理
  • 新增的属性没有经过 Object.defineProperty 处理
  • 数组的索引和长度修改也无法被劫持

Vue 2.x 的方法有几个局限性:

  1. 无法检测到对象新属性的添加或删除 (需要通过vue.set)
  2. 对数组的某些操作不敏感(比如通过索引直接修改)
  3. 需要对每个属性单独劫持,性能开销较大

数组的特殊处理

由于历史原因和浏览器兼容性,Vue 对数组进行了特殊处理:

在 Vue 2.x 中:

  • 重写了数组的 7 个变更方法(push、pop、shift、unshift、splice、sort、reverse)

  • 当你调用这些方法时,Vue 能够检测到变化

  • 但通过索引直接设置项(arr[index] = value)或修改长度(arr.length = 0)不会被检测到

数组索引不能用 Object.defineProperty

技术限制

const arr = ['a', 'b', 'c'];

// 理论上可以对索引使用 defineProperty
Object.defineProperty(arr, '0', {
  get() { /* ... */ },
  set() { /* ... */ }
});

// 但问题来了:
arr[100] = 'new';  // 创建了第100个元素
// 难道要对 0-100 每个索引都预先 defineProperty 吗

根本问题

  1. 数组长度动态变化:无法预知会有多少个索引

  2. 性能不可行:如果数组有10000个元素,难道要定义10000个 getter/setter?

  3. 内存浪费:大多数索引可能永远不会被访问

数组访问的实际情况

场景分析

export default {
  data() {
    return {
      list: ['a', 'b', 'c'],
      users: [
        { name: 'John', age: 30 },
        { name: 'Jane', age: 25 }
      ]
    };
  },
  created() {
    // 1. 访问数组本身 - 有 getter
    console.log(this.list);  // 触发 list 的 getter
    
    // 2. 访问数组索引 - 没有 getter
    console.log(this.list[0]);  // 直接访问数组索引,无 getter
    
    // 3. 访问数组中的对象属性 - 有 getter
    console.log(this.users[0].name);  
    // 流程:
    // - users[0]:无 getter(直接数组访问)
    // - .name:有 getter(对象属性有响应式)
    
    // 4. 通过索引修改 - 问题所在
    this.list[0] = 'x';  //  不会触发更新
    
    // 5. 使用方法修改 - 有效
    this.list.push('d');  // 触发更新(重写的方法)
  }
};

数组索引的特殊访问路径

虽然索引没有 getter/setter,但 Vue 通过另一种方式"感知"到数组元素的访问:

// 当你在模板中使用数组索引时:
<template>
  <div>{{ list[0] }}</div>
  <div>{{ users[0].name }}</div>
</template>

// Vue 的编译过程:
1. 编译模板时发现 list[0]
2. 创建对应的 Watcher(观察者)
3. 这个 Watcher 会:
   - 读取 list(触发 listgetter,收集依赖)
   - 然后读取 list[0](直接数组访问)
   
// 关键点:依赖是收集在「数组本身」上,而不是「数组索引」上

vue2能被检测到的数组操作

// 1. 使用重写的7个方法
this.list.push('new');      // 
this.list.pop();            // 
this.list.shift();          // 
this.list.unshift('new');   // 
this.list.splice(0, 1, 'x'); // 
this.list.sort();           // 
this.list.reverse();        // 

// 2. 替换整个数组
this.list = ['new', 'array'];  // 

// 3. 使用 Vue.set(内部用 splice)
Vue.set(this.list, 0, 'new');  // 

// 4. 修改 length(但有限制)
this.list.splice(newLength);  //  通过 splice 修改长度

数组操作规范

// 正确:使用变异方法或 $set
this.list.push('new');               // 添加元素
this.list.splice(index, 1);          // 删除元素
this.$set(this.list, index, 'new');  // 修改元素

// 避免:直接操作索引
this.list[index] = 'new';            // 不会触发更新
this.list.length = 0;                // 不会触发更新

解决方案:Vue.set / this.$set

Vue 提供了 Vue.set(全局API)和 this.$set(实例方法)来解决这个问题:

// 正确做法
this.$set(this.user, 'email', 'john@example.com');  // 视图会更新
this.$set(this.list, 0, 'new value');               // 视图会更新

Vue.set 和 this.$set 的关系

它们是同一个函数

// Vue 源码中的实现
Vue.set = function (obj, key, value) {
  // ... 实现逻辑
};

// 在组件实例上暴露为 $set
Vue.prototype.$set = Vue.set;

// 也就是说:
Vue.set === Vue.prototype.$set  // true

使用场景区别

// 1. 在 Vue 组件内部:使用 this.$set(推荐)
export default {
  methods: {
    addProperty() {
      this.$set(this.user, 'email', 'test@example.com');
    }
  }
};

// 2. 在 Vue 组件外部:使用 Vue.set
import Vue from 'vue';

const app = new Vue({ /* ... */ });
Vue.set(app.user, 'email', 'test@example.com');

// 3. 在非 Vue 上下文中:使用 Vue.set
const plainObject = { name: 'John' };
Vue.set(plainObject, 'age', 30);  // 也可以用于普通对象

什么时候必须用 $set?

// 必须用 $set 的情况:
// 1. 给对象添加新属性
// 2. 通过索引修改数组项(当索引超出原长度或修改已有项)

// 不需要 $set 的情况:
// 1. 修改已有属性:this.user.name = 'new'
// 2. 使用数组变异方法:push, pop, splice 等
// 3. 替换整个数组:this.list = newList

10.Vue 内置指令

10.1 v-text 指令

作用:向所在节点渲染文本内容。

<div v-text="name">你好</div>
<div v-text="str"></div>

特点

  • 替换节点中的所有内容

  • 不识别 HTML 结构(纯文本显示)

  • 与 {{ }} 插值语法的区别:

    • v-text:完全替换内容
    • {{ }}:只替换占位符,保留其他内容

10.2 v-html 指令

作用:向指定节点渲染包含 HTML 结构的内容。

<div v-html="str"></div>

安全性警告

  • XSS 攻击:跨站脚本攻击,恶意脚本注入可能盗取 Cookie 等用户信息

  • 使用原则

    1. 永远不要用在用户提交的内容上
    2. 只在完全可信的内容上使用

11.自定义指令

11.1 定义语法

// 局部指令
new Vue({
  directives: { 指令名: 配置对象 }
})

// 全局指令
Vue.directive('指令名', 配置对象)

11.2 三个核心钩子函数

Vue.directive('fbind', {
  // 1. 指令与元素成功绑定时
  bind(element, binding) {
    element.value = binding.value
  },
  // 2. 元素被插入页面时
  inserted(element, binding) {
    element.focus()
  },
  // 3. 模板被重新解析时
  update(element, binding) {
    element.value = binding.value
  }
})

11.3 参数详解

element:绑定的真实 DOM 元素。

binding 对象

{
  name: 'fbind',        // 指令名(不带 v-)
  value: 1,             // 绑定的值
  expression: 'n',      // 表达式字符串
  arg: 'value',         // 参数(v-fbind:value 中的 value)
  modifiers: {}         // 修饰符对象
}

11.4 命名规范

  • 指令定义时不加 v-,使用时要加 v-

  • 多单词指令使用 kebab-case(如:v-big-number

  • 指令名要加引号的情况:命名中间有-

    directives: {
      'big-number': function(element, binding) {
        // ...
      }
    }
    

12.Vue 生命周期

12.1 生命周期概念

  • 别名:生命周期回调函数、生命周期函数、生命周期钩子

  • 定义:Vue 在关键时刻自动调用的特殊函数

  • 特点

    • 函数名不可更改
    • 函数内容由程序员根据需求编写
    • this 指向 vm 或组件实例对象

12.2 完整的生命周期流程

创建阶段:
beforeCreate → created → beforeMount → mounted

更新阶段:
beforeUpdate → updated

销毁阶段:
beforeDestroy → destroyed

12.3 各阶段详解

(1) 创建阶段

  • beforeCreate

    • 实例刚创建,数据观测和事件配置还未开始
    • datamethods 等不可用
  • created

    • 实例创建完成,数据观测、属性和方法运算完成
    • datamethods 已可用,但 DOM 未生成
    • 适合:发送异步请求、初始化数据
  • beforeMount

    • 模板编译完成,但未挂载到页面
    • 虚拟 DOM 已创建
  • mounted

    • 实例挂载到 DOM 上,页面首次渲染完成
    • 真实 DOM 已生成,可通过 this.$el 访问

(2) 更新阶段

  • beforeUpdate

    • 数据更新时调用,DOM 未重新渲染
    • 可以获取更新前的 DOM 状态
  • updated

    • 数据更新导致 DOM 重新渲染后调用
    • 避免在此阶段修改数据,可能导致无限循环

(3) 销毁阶段

  • beforeDestroy

    • 实例销毁前调用,实例完全可用
    • 必须进行清理工作
  • destroyed

    • 实例销毁后调用,所有绑定和监听被移除
    • 子实例也被销毁

编辑

12.4 最常用的两个钩子详解

(1) mounted(挂载完成时)

调用时机:组件第一次显示在页面上之后。

应该做什么

mounted() {
  // 发送请求获取初始数据
  this.fetchData()
  
  // 启动定时器
  this.timer = setInterval(() => {
    // 轮询、倒计时等
  }, 1000)
  
  // 绑定自定义事件
  this.$bus.$on('event', this.handleEvent)
  
  // 操作 DOM(初始化第三方库)
  this.initChart()
  
  // 订阅消息
  this.$store.subscribe(mutation => {
    // 处理订阅
  })
}

不应该做什么

  • 修改大量数据(可能导致频繁重新渲染)
  • 执行耗时同步操作(会阻塞页面)

(2) beforeDestroy(销毁前)

调用时机:组件即将被销毁前。

必须做什么(防止内存泄漏):

beforeDestroy() {
  //  清除定时器
  clearInterval(this.timer)
  clearTimeout(this.timeout)
  
  // 解绑自定义事件
  this.$bus.$off('event', this.handleEvent)
  
  // 取消订阅
  this.unsubscribe()
  
  // 清理其他资源
  this.websocket.close()
}

重要性:如果不清理,即使组件销毁了,这些资源还在后台运行,会造成内存泄漏


13.自定义指令中的 this 指向问题

13.1 问题现象

在自定义指令的钩子函数中,this 指向的是 window(全局对象),而不是 Vue 实例。

directives: {
  big(element, binding) {
    console.log(this) // window,不是 Vue 实例
    console.log(this.message) // undefined,无法访问组件数据
  }
}

13.2 原因分析

  • 设计原因:保持指令的独立性和复用性

  • 指令钩子是独立函数,不是 Vue 实例的方法

  • Vue 内部调用方式:

    // 伪代码
    function callHook(hookFn, el, binding, vnode) {
      hookFn.call(window, el, binding, vnode)  // 用 window 作为 this
    }
    

13.3 解决方案

方案一:通过 binding.value 传递数据(推荐)

<div v-demo="dataValue"></div>
directives: {
  demo: {
    bind(el, binding) {
      // 所有数据通过 binding.value 传递
      el.innerText = binding.value
    }
  }
}

方案二:通过 vnode 访问 Vue 实例(不推荐,破坏封装性)

directives: {
  demo: {
    bind(el, binding, vnode) {
      const vm = vnode.context  // 获取 Vue 实例
      console.log(vm.message)   // 可以访问组件数据
    }
  }
}

13.4为什么这样设计?

  1. 保持指令独立:指令只依赖传入参数,不依赖外部状态
  2. 提高复用性:指令可在任何组件中使用
  3. 明确数据来源:所有数据通过 binding.value 显式传递,代码更清晰

对比其他地方的 this

位置 this 指向 示例
methods 中的方法 Vue 实例 this.message 可以访问
computed 计算属性 Vue 实例 this.count 可以访问
watch 监听器 Vue 实例 this.$emit() 可以用
生命周期钩子 Vue 实例 this.$el 可以访问
自定义指令钩子 window this 没用

13.5常见问题

Q1:为什么组件的 data 必须是函数?

A:如果 data 是对象,所有组件实例会共享同一个 data 对象,修改一个实例的数据会影响所有实例。使用函数返回新对象可保证每个实例有独立的数据。

Q2:什么时候使用 v-text 而不是 {{ }}?

A

  • 当需要完全替换元素内容时用 v-text
  • 当需要保留元素原有内容时用 {{ }}
  • 当内容安全可信且需要显示 HTML 时用 v-html

Q3:生命周期函数可以异步吗?

A:可以,但需要注意:

  • created 中可以发送异步请求
  • mounted 中可以进行异步 DOM 操作
  • 异步操作不会影响生命周期流程

Q4:为什么 beforeDestroy 中必须清理资源?

A:如果不清理:

  • 定时器继续运行,占用内存
  • 事件监听器未移除,可能触发错误
  • 订阅未取消,可能产生内存泄漏
  • 第三方库实例未销毁,可能冲突

Q5:如何选择使用局部指令还是全局指令?

A

  • 局部指令:只在当前组件或特定组件中使用
  • 全局指令:在多个不相关的组件中都需要使用

Q6:自定义指令中如何传递复杂参数?

A

<!-- 传递对象 -->
<div v-demo="{ color: 'red', size: 20 }"></div>

<!-- 传递多个参数 -->
<div v-demo:[arg].modifier="value"></div>
bind(el, binding) {
  console.log(binding.value)    // { color: 'red', size: 20 }
  console.log(binding.arg)      // 'arg'
  console.log(binding.modifiers) // { modifier: true }
}

Q7:mounted 和 created 的区别是什么?

A

  • created:实例创建完成,数据可用,但 DOM 未生成

  • mounted:实例已挂载到 DOM,可进行 DOM 操作

  • 选择原则

    • 需要 DOM 操作 → mounted
    • 不需要 DOM,只需数据 → created(更早获取数据)

Astro: 优化katex,mermaid和灯箱使用

作者 姓王者
2026年2月6日 19:06

在前文 Astro 5.17构建性能优化实践:从18s到13s中,我已经成功减少了构建时间,这次,通过继续优化 katexmermaid灯箱 的使用,我进一步提升了博客的 客户端构建时 性能。

核心问题

传统的做法通常是在 Layout 中直接 import 对应的 CSS 文件,或者在 astro.config.mjs 中添加全局集成。这会导致在普通的页面,即使没有使用 KaTeX,Mermaid,灯箱 的文章,也会加载这些资源,造成 不必要的性能开销

优化思路:构建时检测

我们利用 Astro 对 Markdown 处理的钩子(Remark 插件),在构建初期就 “预知” 文章的内容特征。

1. 增强内容检测插件

修改 src/utils/remark-post-body.ts,在遍历 Markdown 的 抽象语法树(AST) 时,通过 unist-util-visit 查找特定的节点:

src/utils/remark-post-body.ts

// 伪代码示例
visit(tree, (node: any) => {
  if (node.type === "image") hasImage = true;
  if (node.type === "math") hasKatex = true;
  if (node.type === "code" && node.lang === "mermaid") {
    hasMermaid = true;
    // 将代码块转换为特定格式以便前端渲染
    node.type = "html";
    node.value = `<pre class="mermaid">${node.value}</pre>`;
  }
});

这些标识位会被自动注入到文章的 remarkPluginFrontmatter 中。

2. 布局层的条件渲染

PostLayout.astro 中,我们可以根据这些标识位,动态引入 样式组件和脚本。由于 astro 文件的 frontmatter 部分是 构建时静态分析 import,为了实现动态注入,我们将样式进行了 组件化包裹,再通过组件引入放在 条件渲染 中:

src/components/stalux/posts/KatexStyle.astro

---
import "katex/dist/katex.min.css";
---
<!-- 无内容,只有frontmatter静态导入的css -->

src/components/stalux/posts/PhotoSwipeStyle.astro

---
import "photoswipe/dist/photoswipe.css";
---
<!-- 无内容,只有frontmatter静态导入的css -->

src/layouts/PostLayout.astro

---
// 仅在需要时引入组件
import KatexStyle from "@components/stalux/posts/KatexStyle.astro";
import PhotoSwipeStyle from "@components/stalux/posts/PhotoSwipeStyle.astro";
const { hasKatex, hasImage, hasMermaid } = Astro.props;
---
{hasKatex && <KatexStyle />}
{hasImage && <PhotoSwipeStyle />}
<!-- 页面内容 -->
<slot />
{hasMermaid && (
    <script>
        import mermaid from "mermaid";
        // 动态初始化逻辑...
    </script>
)}

带来的改变

维度 优化措施 最终效果
构建性能 自动化检测与 按需加载 katex,mermaid,灯箱 整体流水线运行效率显著提升,构建速度更快
客户端体验 样式与脚本按需加载,适配 astro:page-load 页面首屏体积更小,完美兼容视图转换动画

总结

之前还有很多优化,比如说 减少 render 的重复使用,在这里就不说了。总之最后通过优化,时间上从 13s 减少到 11s,客户端的性能也有了质的提升!

Astro: 优化katex,mermaid和灯箱使用

作者:xingwangzhe

本文链接: xingwangzhe.fun/posts/astro…

本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

前端架构治理演进规划

2026年2月6日 18:54

一、背景与目标

1.1 现状分析

经过 Phase 1~4 的微前端治理(详见 doc/wujie集成.md),已完成:

已完成项 成果
微前端框架 iframe → wujie,7 个子应用统一接入
通信协议 postMessage → wujie bus,类型化事件
子应用预加载 preloadChildApps 高/低优先级分级
CSS 统一 UnoCSS → Tailwind CSS 4
Monorepo pnpm workspace + Turborepo

但微前端只是架构治理的第一步。 当前 7 个子应用之间存在大量重复建设:

重复领域 现状 影响
UI 组件 每个子应用独立封装 Table/Form/Dialog 7 份重复代码,风格不统一
业务组件 客户选择器、产品选择器等各自实现 逻辑不一致,Bug 修一处漏六处
Utils 工具函数 日期格式化、金额计算、权限判断各写一套 维护成本 ×7
Hooks/Composables useTable、useForm、useDict 各子应用独立 无法共享最佳实践
API 层 接口定义、拦截器、错误处理各自维护 后端改一个字段,前端改 7 处
类型定义 业务实体 TS 类型各子应用独立定义 类型不同步,联调困难

1.2 治理目标

                    ┌─────────────────────────────────┐
                    │        业务应用层(7 个子应用)     │
                    │   mkt / doc / ibs-manage / ...   │
                    └──────────────┬──────────────────┘
                                   │ 消费
                    ┌──────────────┴──────────────────┐
                    │        公共资源层(packages)       │
                    │                                   │
                    │  ┌───────────┐  ┌─────────────┐  │
                    │  │ UI 组件库  │  │ 业务组件库   │  │
                    │  │@cmclink/ui│  │@cmclink/biz  │  │
                    │  └───────────┘  └─────────────┘  │
                    │  ┌───────────┐  ┌─────────────┐  │
                    │  │ Hooks 库  │  │  Utils 库    │  │
                    │  │@cmclink/  │  │@cmclink/     │  │
                    │  │ hooks     │  │ utils        │  │
                    │  └───────────┘  └─────────────┘  │
                    │  ┌───────────┐  ┌─────────────┐  │
                    │  │ API SDK   │  │  类型定义    │  │
                    │  │@cmclink/  │  │@cmclink/     │  │
                    │  │ api       │  │ types        │  │
                    │  └───────────┘  └─────────────┘  │
                    └──────────────┬──────────────────┘
                                   │ 支撑
                    ┌──────────────┴──────────────────┐
                    │        基础设施层                  │
                    │  micro-bridge / vite-config /     │
                    │  tsconfig / eslint-config         │
                    └──────────────────────────────────┘

核心原则

  1. 资源化 — 可复用的代码提取为独立 package
  2. 公共化 — 跨子应用共享,单点维护
  3. 文档化 — 每个公共包配套使用文档和示例
  4. AI 友好 — 沉淀为 AI Agent 可消费的 Skills/MCP 资源

二、公共资源沉淀规划

2.1 @cmclink/ui — 基础 UI 组件库

定位:基于 Element Plus 二次封装的业务通用 UI 组件。

组件 说明 来源
CmcTable 统一表格(分页、排序、列配置、导出) 各子应用 useTable + 模板代码
CmcForm 统一表单(校验、布局、动态字段) 各子应用表单封装
CmcDialog 统一弹窗(确认、表单弹窗、详情弹窗) 各子应用 Dialog 封装
CmcSearch 搜索栏(条件组合、折叠展开、快捷搜索) 各子应用搜索区域
CmcUpload 文件上传(拖拽、预览、进度、断点续传) 各子应用上传组件
CmcEditor 富文本编辑器(统一配置) 各子应用编辑器封装
CmcDescription 详情描述列表 各子应用详情页

实施策略

packages/
└── ui/
    ├── package.json          # @cmclink/ui
    ├── src/
    │   ├── components/       # 组件源码
    │   │   ├── CmcTable/
    │   │   ├── CmcForm/
    │   │   └── ...
    │   ├── composables/      # 组件内部 hooks
    │   └── index.ts          # 统一导出
    └── docs/                 # 组件文档(可选 VitePress)

2.2 @cmclink/biz — 业务组件库

定位:与业务强相关的可复用组件,跨产品线共享。

组件 说明 使用方
CustomerSelector 客户选择器(搜索、分页、多选) mkt / doc / commerce-finance
ProductSelector 产品选择器 mkt / operation
PortSelector 港口选择器 doc / operation
VesselSelector 船名航次选择器 doc / operation
DictSelect 字典下拉(统一字典管理) 全部子应用
UserSelector 用户/员工选择器 全部子应用
ApprovalFlow 审批流程组件 多个子应用

2.3 @cmclink/hooks — 通用 Composables

定位:跨子应用复用的 Vue 3 组合式函数。

Hook 说明 当前状态
useTable 表格数据管理(分页、排序、筛选、刷新) 各子应用独立实现
useForm 表单状态管理(校验、提交、重置) 各子应用独立实现
useDict 字典数据获取与缓存 各子应用独立实现
usePermission 权限判断(按钮级、菜单级) 各子应用独立实现
useExport 数据导出(Excel/CSV/PDF) 各子应用独立实现
useWebSocket WebSocket 连接管理 部分子应用实现
useI18n 国际化增强(业务术语统一翻译) 各子应用独立实现
useCrud CRUD 操作封装(增删改查一体) 各子应用独立实现

2.4 @cmclink/utils — 工具函数库

定位:纯函数工具集,零依赖或仅依赖 lodash-es

模块 函数示例 说明
date formatDate, diffDays, toUTC 日期处理(统一格式)
money formatMoney, toFixed, currencyConvert 金额计算(精度安全)
validator isPhone, isEmail, isTaxNo 业务校验规则
formatter formatFileSize, formatDuration 格式化工具
tree flatToTree, treeToFlat, findNode 树结构操作
auth getToken, setToken, removeToken 认证工具
storage getCache, setCache, removeCache 存储封装

2.5 @cmclink/api — API SDK

定位:统一的后端接口定义层,前后端类型对齐。

// packages/api/src/modules/customer.ts
import type { Customer, CustomerQuery } from '@cmclink/types'
import { request } from '../request'

/** 客户列表 */
export const getCustomerList = (params: CustomerQuery) =>
  request.get<PageResult<Customer>>('/admin-api/customer/page', { params })

/** 客户详情 */
export const getCustomerDetail = (id: number) =>
  request.get<Customer>(`/admin-api/customer/get?id=${id}`)

价值

  • 后端改接口 → 只改 @cmclink/api 一处 → 所有子应用自动同步
  • TypeScript 类型约束 → 编译期发现接口不匹配
  • 可自动生成 → 结合 Swagger/OpenAPI 自动生成 SDK

2.6 @cmclink/types — 共享类型定义

定位:业务实体的 TypeScript 类型定义,前后端对齐。

// packages/types/src/customer.ts
export interface Customer {
  id: number
  name: string
  code: string
  contactPerson: string
  phone: string
  email: string
  status: CustomerStatus
  createdAt: string
}

export type CustomerStatus = 'active' | 'inactive' | 'pending'

export interface CustomerQuery {
  name?: string
  code?: string
  status?: CustomerStatus
  pageNo: number
  pageSize: number
}

三、前后端职能对齐

3.1 基础架构团队职责矩阵

职责领域 前端基础架构 后端基础架构 协同点
框架治理 微前端(wujie)、Monorepo 微服务、网关 子应用 ↔ 微服务 1:1 映射
通信协议 wujie bus 事件定义 API 接口规范 事件名 / 接口路径统一命名
类型系统 @cmclink/types Swagger/OpenAPI 自动生成 TS 类型
API 层 @cmclink/api SDK RESTful API 实现 SDK 自动生成
权限体系 前端按钮/菜单权限 后端接口权限 权限码统一定义
国际化 前端翻译资源 后端错误码翻译 翻译 Key 统一管理
监控告警 前端性能/错误上报 后端 APM 全链路 TraceID 打通
CI/CD 前端构建部署 后端构建部署 统一流水线、环境管理

3.2 前后端类型自动同步方案

后端 Swagger/OpenAPI 定义
         │
         ▼
    openapi-typescript / swagger-typescript-api
         │
         ▼
  @cmclink/types(自动生成 TS 类型)
         │
         ▼
  @cmclink/api(自动生成 API SDK)
         │
         ▼
    各子应用直接消费

工具选型

  • openapi-typescript:从 OpenAPI 3.0 生成 TypeScript 类型
  • swagger-typescript-api:从 Swagger 生成完整的 API Client

四、AI 编程能力沉淀

4.1 为什么基础架构要考虑 AI

AI 编程(Copilot、Cursor、Windsurf 等)已成为开发者日常工具。公共资源的质量直接决定 AI 生成代码的质量

AI 编程痛点 根因 基础架构解法
AI 生成的代码风格不统一 缺乏项目级规范上下文 .windsurf/rules/ 规范文件
AI 不了解业务组件 API 组件文档缺失或分散 组件库 + JSDoc + 示例
AI 重复造轮子 不知道已有公共函数 @cmclink/utils + @cmclink/hooks
AI 生成的接口调用不对 不了解后端 API 结构 @cmclink/api 类型化 SDK
AI 无法理解项目架构 架构文档不完善 架构决策记录(ADR)

4.2 Agent Skills 沉淀

将项目规范和最佳实践沉淀为 AI Agent 可消费的 Skills:

.windsurf/
├── rules/                    # 已有:27 个专项规范
│   ├── core.mdc
│   ├── vue3-component-standards.mdc
│   ├── typescript-standards.mdc
│   └── ...
├── workflows/                # 工作流定义
│   ├── create-component.md   # 新建组件工作流
│   ├── create-api-module.md  # 新建 API 模块工作流
│   ├── create-page.md        # 新建页面工作流
│   └── migrate-child-app.md  # 子应用迁入工作流
└── skills/                   # AI Skills 定义(规划中)
    ├── cmclink-ui.md         # UI 组件库使用指南
    ├── cmclink-api.md        # API SDK 使用指南
    └── cmclink-patterns.md   # 业务模式最佳实践

Skills 示例 — 新建 CRUD 页面

---
description: 创建标准 CRUD 页面(列表 + 新增 + 编辑 + 删除)
---

1.`@cmclink/types` 中定义实体类型
2.`@cmclink/api` 中定义接口
3. 使用 `CmcTable` + `CmcSearch` + `CmcForm` 组合
4. 使用 `useCrud` hook 管理状态
5. 使用 `usePermission` 控制按钮权限

4.3 MCP Server 能力规划

MCP(Model Context Protocol) 让 AI Agent 能够直接访问项目资源:

MCP 能力 说明 价值
组件文档查询 AI 查询 @cmclink/ui 组件 Props/Slots/Events 生成代码直接使用正确的组件 API
API 接口查询 AI 查询后端接口定义和参数 生成的接口调用代码类型正确
字典数据查询 AI 查询业务字典(状态码、类型码) 生成代码使用正确的枚举值
权限码查询 AI 查询按钮/菜单权限码 生成的权限判断代码准确
代码模板生成 AI 基于模板生成标准化页面 新页面开发效率 ×3

MCP Server 架构

┌──────────────────────────────────────────────┐
│              AI Agent (Windsurf/Cursor)        │
│                                                │
│  "帮我创建一个客户管理的 CRUD 页面"              │
└──────────────────┬─────────────────────────────┘
                   │ MCP Protocol
┌──────────────────┴─────────────────────────────┐
│            @cmclink/mcp-server                  │
│                                                 │
│  ┌─────────────┐  ┌──────────────────────────┐ │
│  │ 组件文档资源 │  │ API 接口资源              │ │
│  │ (Resources) │  │ (Resources)              │ │
│  └─────────────┘  └──────────────────────────┘ │
│  ┌─────────────┐  ┌──────────────────────────┐ │
│  │ 代码生成工具 │  │ 字典/权限查询工具         │ │
│  │ (Tools)     │  │ (Tools)                  │ │
│  └─────────────┘  └──────────────────────────┘ │
└─────────────────────────────────────────────────┘

五、实施路线图

5.1 短期(1~2 个月)— 基础沉淀

优先级 任务 产出 负责
P0 提取 @cmclink/utils 工具函数包 基础架构
P0 提取 @cmclink/hooks 通用 Composables 包 基础架构
P0 提取 @cmclink/types 共享类型定义包 基础架构 + 后端
P1 完善 .windsurf/rules/ AI 规范文件 基础架构
P1 创建 .windsurf/workflows/ 标准工作流 基础架构

5.2 中期(3~4 个月)— 组件化

优先级 任务 产出 负责
P0 搭建 @cmclink/ui 组件库 CmcTable / CmcForm / CmcSearch 基础架构
P0 搭建 @cmclink/api SDK 统一 API 调用层 基础架构 + 后端
P1 搭建 @cmclink/biz 业务组件库 客户选择器等业务组件 基础架构 + 业务
P1 组件文档站(VitePress) 在线文档 + 示例 基础架构
P2 OpenAPI → TypeScript 自动生成 类型自动同步流水线 基础架构 + 后端

5.3 长期(5~6 个月)— AI 赋能

优先级 任务 产出 负责
P1 @cmclink/mcp-server AI Agent 资源服务 基础架构
P1 AI Skills 沉淀 组件/API/模式使用指南 基础架构
P2 代码模板生成器 标准化页面脚手架 基础架构
P2 全链路 TraceID 打通 前后端监控联动 基础架构 + 后端

六、预期收益

6.1 效率提升

场景 当前耗时 治理后耗时 提升
新建 CRUD 页面 4~8 小时 1~2 小时 4x
修复跨子应用 Bug 改 7 处 改 1 处 7x
新子应用接入 2~3 天 半天 5x
后端接口变更适配 改 7 个子应用 改 1 个 SDK 7x
AI 生成代码可用率 ~30% ~80% 2.7x

6.2 质量提升

  • 一致性:所有子应用使用相同的组件和交互模式
  • 可维护性:公共代码单点维护,变更自动传播
  • 类型安全:前后端类型自动同步,编译期发现问题
  • AI 友好:规范化的代码库让 AI 生成更准确的代码

6.3 团队赋能

  • 新人上手:标准化组件 + 文档 + AI Skills → 快速产出
  • 跨团队协作:公共组件库是团队间的共同语言
  • 技术影响力:沉淀的基础设施可对外输出

七、风险与缓解

风险 影响 缓解措施
公共包变更影响所有子应用 回归范围大 Changesets 版本管理 + 自动化测试
业务组件抽象不当 过度抽象或不够通用 先在 2 个子应用验证,再推广
AI Skills 维护成本 文档过时 与代码同仓库,CI 检查文档同步
团队推广阻力 业务团队不愿迁移 渐进式迁移,新页面优先使用

附录:packages 目录规划

packages/
├── micro-bridge/       # ✅ 已有 — 微前端通信 SDK
├── micro-bootstrap/    # ✅ 已有 — 子应用启动器
├── vite-config/        # ✅ 已有 — 统一 Vite 配置
├── tsconfig/           # ✅ 已有 — 统一 TS 配置
├── ui/                 # 📋 规划 — 基础 UI 组件库
├── biz/                # 📋 规划 — 业务组件库
├── hooks/              # 📋 规划 — 通用 Composables
├── utils/              # 📋 规划 — 工具函数库
├── api/                # 📋 规划 — API SDK
├── types/              # 📋 规划 — 共享类型定义
├── eslint-config/      # 📋 规划 — 统一 ESLint 配置
└── mcp-server/         # 📋 规划 — AI Agent MCP 服务

Flutter——线性布局(Row、Column)

作者 Haha_bj
2026年2月6日 18:51

Flutter 中最基础也最核心的 Row(行)和 Column(列)布局组件,我会从基础用法、核心属性、常见问题和实战示例几个方面,给你做一个通俗易懂的全面讲解。

一、核心概念与基础用法

Row 和 Column 是 Flutter 中用于线性布局的核心组件:

  • Row:在水平方向上排列子组件(从左到右,可通过属性调整)
  • Column:在垂直方向上排列子组件(从上到下,可通过属性调整)

它们都继承自 Flex 组件,核心逻辑一致,只是排列方向不同。

1. 最基础的使用示例

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Row & Column 详解')),
        body: Padding(
          padding: const EdgeInsets.all(20),
          // 外层 Column 垂直排列
          child: Column(
            // 子组件
            children: [
              // 第一个子组件:Row 水平排列
              Row(
                children: [
                  Container(width: 80, height: 80, color: Colors.red),
                  Container(width: 80, height: 80, color: Colors.green),
                  Container(width: 80, height: 80, color: Colors.blue),
                ],
              ),
              const SizedBox(height: 20), // 间距
              // 第二个子组件:Column 垂直排列
              Column(
                children: [
                  Container(width: 80, height: 80, color: Colors.yellow),
                  Container(width: 80, height: 80, color: Colors.purple),
                  Container(width: 80, height: 80, color: Colors.orange),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

二、核心属性详解(Row/Column 通用)

这是掌握布局的关键,重点属性如下:

属性 作用 常用值
mainAxisAlignment 主轴对齐方式(Row 主轴 = 水平,Column 主轴 = 垂直) start(默认)、centerendspaceBetweenspaceAroundspaceEvenly
crossAxisAlignment 交叉轴对齐方式(Row 交叉轴 = 垂直,Column 交叉轴 = 水平) startcenter(默认)、endstretch(拉伸填满)
mainAxisSize 主轴占用空间大小 max(默认,占满父组件)、min(仅包裹子组件)
children 子组件列表 任意 Widget 数组

关键属性实战示例

// 演示 mainAxisAlignment 和 crossAxisAlignment 的效果
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween, // 水平两端对齐,中间均匀间距
  crossAxisAlignment: CrossAxisAlignment.center,    // 垂直居中
  mainAxisSize: MainAxisSize.max,                   // 占满父组件宽度
  children: [
    Container(width: 50, height: 50, color: Colors.red),
    Container(width: 50, height: 80, color: Colors.green), // 高度不同,看交叉轴对齐
    Container(width: 50, height: 50, color: Colors.blue),
  ],
)

三、常见问题与解决方案

1. Row/Column 溢出(Overflow)

问题:子组件总宽度 / 高度超过父组件,出现黄色 / 黑色溢出警告。解决方案

  • 使用 Expanded:让子组件占满剩余空间(可设置 flex 比例)
  • 使用 Flexible:类似 Expanded,但不会强制填满(可设置 fit 属性)
  • 使用 SingleChildScrollView:可滚动的布局(横向 / 纵向)
// Expanded 解决溢出问题(按比例分配空间)
Row(
  children: [
    Expanded(flex: 1, child: Container(color: Colors.red, height: 50)), // 1份
    Expanded(flex: 2, child: Container(color: Colors.green, height: 50)), // 2份
    Expanded(flex: 1, child: Container(color: Colors.blue, height: 50)), // 1份
  ],
)

// SingleChildScrollView 解决溢出(可滚动)
SingleChildScrollView(
  scrollDirection: Axis.horizontal, // 横向滚动
  child: Row(
    children: [
      for (int i = 0; i < 10; i++) Container(width: 80, height: 80, color: Colors.primaries[i % 10]),
    ],
  ),
)

2. Column 嵌套 Column 高度异常

问题:内层 Column 占满外层 Column 高度,导致布局错乱。解决方案:给内层 Column 包裹 mainAxisSize: MainAxisSize.min

Column(
  children: [
    Text('标题'),
    // 内层 Column 仅包裹子组件,不占满高度
    Column(
      mainAxisSize: MainAxisSize.min,
      children: [Text('内容1'), Text('内容2'), Text('内容3')],
    ),
  ],
)

四、实战综合示例

// 模拟一个常见的页面布局
Scaffold(
  body: Column(
    children: [
      // 顶部导航栏区域(Row)
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          IconButton(icon: const Icon(Icons.arrow_back), onPressed: () {}),
          const Text('详情页', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
          IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
        ],
      ),
      // 分割线
      const Divider(height: 1),
      // 内容区域(占满剩余高度)
      Expanded(
        child: SingleChildScrollView(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // 图片
              Image.network('https://picsum.photos/400/200'),
              // 文本区域
              Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start, // 文本左对齐
                  children: const [
                    Text('商品名称', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
                    SizedBox(height: 8),
                    Text('价格:¥99.00', style: TextStyle(color: Colors.red, fontSize: 18)),
                    SizedBox(height: 8),
                    Text('商品描述:这是一个测试商品,用于演示 Row 和 Column 布局'),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
      // 底部按钮区域(Row)
      Row(
        children: [
          Expanded(child: ElevatedButton(onPressed: () {}, child: const Text('加入购物车'))),
          Expanded(child: ElevatedButton(onPressed: () {}, style: ElevatedButton.styleFrom(backgroundColor: Colors.red), child: const Text('立即购买'))),
        ],
      ),
    ],
  ),
)

总结

  1. 核心定位:Row 是水平线性布局,Column 是垂直线性布局,核心属性(mainAxisAlignment/crossAxisAlignment)控制对齐方式。
  2. 溢出解决:溢出是最常见问题,优先用 Expanded(按比例分配)或 SingleChildScrollView(可滚动)解决。
  3. 关键技巧:嵌套布局时,记得给内层 Column/Row 设置 mainAxisSize: MainAxisSize.min,避免不必要的空间占用。

微前端 — wujie(无界)集成设计文档

2026年2月6日 18:44

一、背景与选型

1.1 现状问题

主应用 cmclink-web-micro-main 采用 iframe 过渡方案 加载 7 个子应用,存在以下痛点:

问题 影响
7 个 <iframe> 硬编码在 App.vue 新增子应用需改 App.vue + router + tabs.ts 三处
每个 iframe 独立加载完整 Vue + Element Plus 内存和带宽 ×7,首屏慢
postMessage 通信无类型约束 调试困难,事件名拼写错误无感知
弹窗无法突破 iframe 边界 Element Plus 的 Dialog/MessageBox 被裁切
reloadIframe() 暴力刷新 子应用状态全部丢失
URL 不同步 刷新页面后子应用路由丢失

1.2 方案对比

维度 qiankun micro-app wujie iframe(当前)
Vue 3 + Vite 兼容 ⚠️ 需插件
JS 沙箱强度 Proxy(有逃逸风险) iframe 沙箱 iframe 沙箱(最强) 天然隔离
CSS 隔离 动态样式表 样式隔离 iframe 级别 天然隔离
keep-alive ❌ 不支持 原生 alive 模式
弹窗突破容器
子应用改造量 大(导出生命周期) 最小
从 iframe 迁移成本 最低
维护活跃度 ⚠️ 停滞 ✅ 京东 ✅ 腾讯

1.3 选型结论

选择 wujie(无界),核心理由:

  1. 从 iframe 迁移成本最低<iframe> 标签 1:1 替换为 <WujieVue>
  2. 隔离性最强 — iframe 沙箱是浏览器原生级别,零逃逸风险
  3. 原生 alive 模式 — 子应用切换时状态完整保留(当前 7 个 iframe 全挂载就是为了保活)
  4. 子应用几乎零改造 — 不要求导出 bootstrap/mount/unmount 生命周期函数

二、整体架构

2.1 系统架构图

┌─────────────────────────────────────────────────────────────┐
│                    主应用 @cmclink/main                      │
│                  (cmclink-web-micro-main)                     │
│                                                              │
│  ┌──────────────┐  ┌──────────────────────────────────────┐ │
│  │ LayoutHeader │  │         wujie bus (事件总线)           │ │
│  └──────────────┘  └──────────┬───────────────────────────┘ │
│  ┌──────────────┐             │                              │
│  │  SiderMenu   │             │ $on / $emit                  │
│  └──────────────┘             │                              │
│  ┌────────────────────────────┼──────────────────────────┐  │
│  │              App.vue 子应用容器                         │  │
│  │                            │                           │  │
│  │  ┌─────────┐ ┌─────────┐ ┌┴────────┐ ┌─────────┐    │  │
│  │  │WujieVue │ │WujieVue │ │WujieVue │ │  ...    │    │  │
│  │  │  mkt    │ │  doc    │ │commerce │ │ (x7)    │    │  │
│  │  │:alive   │ │:alive   │ │-finance │ │         │    │  │
│  │  └─────────┘ └─────────┘ └─────────┘ └─────────┘    │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

2.2 子应用列表

子应用 name 路由前缀 开发端口 说明
营销 mkt /mkt 3001 营销产品线
业财 commerce-finance /commerce-finance 3002 业财产品线
单证 doc /doc 3003 单证产品线
操作线 operation /operation 3004 操作产品线
通用 general /general 3005 公共产品线
公共 common /common 3006 基础数据
运营后台 ibs-manage /ibs-manage 3007 运营管理

子应用注册表统一维护在 packages/micro-bridge/src/registry.ts

2.3 Monorepo 目录结构

微前端/
├── apps/
│   ├── cmclink-web-micro-main/    # @cmclink/main — 真正的主应用(基座)
│   ├── doc/                        # @cmclink/doc — 单证子应用
│   ├── ibs-manage/                 # @cmclink/ibs-manage — 运营后台子应用
│   └── main/                       # ⚠️ 旧主应用(待手动删除,见第八章)
├── packages/
│   ├── micro-bridge/               # @cmclink/micro-bridge — 通信 SDK + 注册表
│   ├── micro-bootstrap/            # @cmclink/micro-bootstrap — 子应用启动器
│   ├── vite-config/                # @cmclink/vite-config — 统一 Vite 配置
│   └── tsconfig/                   # @cmclink/tsconfig — 统一 TS 配置
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

三、主应用集成详解

3.1 依赖安装

// apps/cmclink-web-micro-main/package.json
{
  "dependencies": {
    "wujie-vue3": "^1.0.22",
    "@cmclink/micro-bridge": "workspace:*"
  }
}

3.2 插件注册

// src/plugins/wujie.ts
import WujieVue from 'wujie-vue3'
import type { App } from 'vue'

export function setupWujie(app: App) {
  app.use(WujieVue)
}
// src/main.ts(关键行)
import { setupWujie } from "@/plugins/wujie"
// ...
setupWujie(app)
app.mount("#app")

3.3 App.vue — 子应用容器

改造前(7 个硬编码 iframe):

<iframe :src="'/mkt/?_t=' + now" name="mkt" v-show="route.path === '/mkt'" />
<iframe :src="'/doc/?_t=' + now" name="doc" v-show="route.path === '/doc'" />
<!-- ... 重复 7 次 -->

改造后(基于注册表动态渲染):

<WujieVue
  v-for="app in microAppRegistry"
  :key="app.name"
  v-show="route.path === app.activeRule"
  :name="app.name"
  :url="getAppUrl(app)"
  :alive="true"
  :props="{ token: userStore.token, userInfo: userStore.userInfo }"
  width="100%"
  height="100%"
/>

关键属性说明

属性 说明
:name app.name 子应用唯一标识,wujie 内部用于实例管理
:url getAppUrl(app) 子应用入口 URL,优先读环境变量
:alive true alive 模式:子应用切换时不销毁,保留完整状态
:props { token, userInfo } 直接传递数据给子应用(替代 postMessage)
v-show route.path === app.activeRule 控制显示/隐藏,配合 alive 实现 keep-alive

URL 解析逻辑

const getAppUrl = (app: MicroAppConfig): string => {
  // 环境变量命名规则:VITE_{APP_NAME}_APP_URL(大写,连字符转下划线)
  // 例如:VITE_MKT_APP_URL, VITE_COMMERCE_FINANCE_APP_URL
  const envKey = `VITE_${app.name.toUpperCase().replace(/-/g, '_')}_APP_URL`
  const envUrl = (import.meta.env as Record<string, string>)[envKey]
  return envUrl || app.entry  // 兜底使用注册表中的 entry
}

3.4 AuthenticatedLayout.vue — 事件监听

改造前(iframe postMessage):

import { listenFromSubApp, MESSAGE_TYPE } from "@/utils/iframe-bridge"
onMounted(() => {
  listenFromSubApp((data: any) => {
    if (data.type === MESSAGE_TYPE.TO_ROUTE) { ... }
    if (data.type === MESSAGE_TYPE.ROUTE_CHANGE) { ... }
  })
})

改造后(wujie bus):

import { bus } from "wujie"
onMounted(() => {
  bus.$on("TO_ROUTE", (data: any) => {
    tabsStore.menuClick({ appName: data.appName, path: data.path, ... })
  })
  bus.$on("ROUTE_CHANGE", (data: any) => {
    tabsStore.updateQuery(data)
  })
  bus.$on("ASSETS_404", (data: any) => {
    ElMessageBox.confirm(...)
  })
  bus.$on("CLOSE_ALL_TABS", (data: any) => {
    tabsStore.removeTab(data?.appName)
  })
})

3.5 tabs.ts — 主应用向子应用通信

改造前

import { sendToSubApp, reloadIframe } from "@/utils/iframe-bridge"
sendToSubApp(tab.appName, { type: NOTICE_TYPE.ROUTER_CHANGE, payload: {...} })
reloadIframe(tab.appName)

改造后

import { bus } from "wujie"
bus.$emit("ROUTER_CHANGE_TO_CHILD", { appName, route, query })
bus.$emit("REFRESH_CHILD", { appName: tab.appName })

四、通信协议设计

4.1 事件总线(wujie bus)

wujie 内置了一个全局事件总线 bus,主应用和子应用共享同一个 bus 实例。

主应用                          子应用
  │                               │
  │  bus.$emit("事件名", data)  ──→│  bus.$on("事件名", handler)
  │                               │
  │  bus.$on("事件名", handler) ←──│  bus.$emit("事件名", data)
  │                               │

4.2 事件清单

子应用 → 主应用

事件名 触发场景 payload 结构 主应用处理
TO_ROUTE 子应用请求跳转到某个路由 { appName, path, query, name } tabsStore.menuClick()
ROUTE_CHANGE 子应用内部路由变更 { appName, path } tabsStore.updateQuery() 同步 URL
ASSETS_404 子应用静态资源加载失败 { appName } 弹窗提示用户刷新
CLOSE_ALL_TABS 子应用请求关闭自己的 tab { appName } tabsStore.removeTab()

主应用 → 子应用

事件名 触发场景 payload 结构 子应用处理
ROUTER_CHANGE_TO_CHILD 主应用 tab 切换/菜单点击 { appName, route, query } 子应用内部路由跳转
CLOSE_ALL_TAB_TO_CHILD 主应用关闭子应用 tab { appName } 子应用清理状态
REFRESH_CHILD 用户点击刷新按钮 { appName } 子应用重新加载当前页

4.3 props 直传(补充通道)

除了 bus 事件,wujie 还支持通过 :props 直接向子应用传递数据:

<!-- 主应用 -->
<WujieVue :props="{ token: userStore.token, userInfo: userStore.userInfo }" />
// 子应用中获取
const props = (window as any).__WUJIE?.props
const token = props?.token

适用场景:token、用户信息等初始化数据,不需要事件驱动的静态数据。


五、子应用侧适配方案

5.1 改造范围

子应用需要将 iframe-bridge.ts 中的 postMessage 通信替换为 wujie bus.$emit

涉及文件(以 doc 子应用为例):

文件 当前用法 改造方案
src/utils/iframe-bridge.ts notifyMainApp()postMessage 新建 wujie-bridge.ts 替代
src/router/index.ts notifyMainApp(MESSAGE_TYPE.ROUTE_CHANGE, ...) 改 import 路径即可
src/App.vue getPathFromParent() 读父窗口 URL 改为 bus 监听 ROUTER_CHANGE_TO_CHILD
src/main.ts errorCheck()postMessage 报告 404 setupErrorCheck() 用 bus

5.2 改造步骤

Step 1:创建 wujie-bridge.ts(替代 iframe-bridge.ts)

// src/utils/wujie-bridge.ts
/**
 * @description wujie 子应用通信桥接器
 * @author yaowb
 * @date 2026-02-06
 */

// wujie 子应用环境下,bus 挂载在 window.__WUJIE 上
function getWujieBus() {
  return (window as any).__WUJIE?.bus
}

/** 是否在 wujie 子应用环境中 */
export function isInWujie(): boolean {
  return !!(window as any).__WUJIE
}

/** 获取主应用传递的 props */
export function getWujieProps(): Record<string, any> {
  return (window as any).__WUJIE?.props || {}
}

/** 向主应用发送事件(保持与 iframe-bridge 相同的函数签名) */
export function notifyMainApp(type: string, payload: any) {
  const bus = getWujieBus()
  if (bus) {
    bus.$emit(type, payload)
  } else {
    console.warn('[wujie-bridge] Not in wujie environment, skip emit:', type)
  }
}

/** 监听主应用发来的事件 */
export function onMainAppEvent(type: string, handler: (data: any) => void) {
  const bus = getWujieBus()
  if (bus) {
    bus.$on(type, handler)
  }
}

/** 移除事件监听 */
export function offMainAppEvent(type: string, handler: (data: any) => void) {
  const bus = getWujieBus()
  if (bus) {
    bus.$off(type, handler)
  }
}

/** 资源 404 错误检测 */
export function setupErrorCheck(appName: string) {
  if (!isInWujie()) return
  window.addEventListener('error', (event) => {
    if (event.target instanceof Element) {
      const tagName = event.target.tagName.toUpperCase()
      if (tagName === 'SCRIPT' || tagName === 'LINK') {
        notifyMainApp('ASSETS_404', { appName })
      }
    }
  }, true)
}

export const MESSAGE_TYPE = {
  TO_ROUTE: 'TO_ROUTE',
  ROUTE_CHANGE: 'ROUTE_CHANGE',
  ASSETS_404: 'ASSETS_404',
  CLOSE_ALL_TABS: 'CLOSE_ALL_TABS',
}

Step 2:改造 router/index.ts

// 改造前
import { notifyMainApp, MESSAGE_TYPE } from '@/utils/iframe-bridge'

// 改造后(函数签名不变,只换 import 路径)
import { notifyMainApp, MESSAGE_TYPE } from '@/utils/wujie-bridge'

// 业务代码完全不用改
router.afterEach((to) => {
  notifyMainApp(MESSAGE_TYPE.ROUTE_CHANGE, {
    appName: import.meta.env.VITE_APP_NAME,
    path: to.fullPath
  })
})

关键设计wujie-bridge.ts 保持与 iframe-bridge.ts 相同的 notifyMainApp() 函数签名和 MESSAGE_TYPE 常量,子应用只需替换 import 路径,业务代码零改动

Step 3:改造 App.vue(路由同步)

// 改造前:从父窗口 URL 读取 childPath
import { getPathFromParent } from '@/utils/iframe-bridge'
const childPath = getPathFromParent()

// 改造后:监听主应用的路由指令
import { onMainAppEvent } from '@/utils/wujie-bridge'
onMainAppEvent('ROUTER_CHANGE_TO_CHILD', (data) => {
  if (data.appName === import.meta.env.VITE_APP_NAME) {
    router.push({ path: data.route, query: data.query })
  }
})

Step 4:改造 main.ts(错误检测)

// 改造前
import { errorCheck } from '@/utils/iframe-bridge'
errorCheck()

// 改造后
import { setupErrorCheck } from '@/utils/wujie-bridge'
setupErrorCheck(import.meta.env.VITE_APP_NAME)

5.3 兼容性策略

子应用需要同时支持 wujie 模式独立运行模式(开发调试时直接访问子应用端口)。wujie-bridge.ts 已内置兼容:

export function notifyMainApp(type: string, payload: any) {
  const bus = getWujieBus()
  if (bus) {
    bus.$emit(type, payload)  // wujie 环境
  } else {
    console.warn('[wujie-bridge] Not in wujie, skip:', type)  // 独立运行,静默跳过
  }
}

5.4 改造检查清单(每个子应用)

  • 创建 src/utils/wujie-bridge.ts
  • src/router/index.tsimport 路径改为 wujie-bridge
  • src/App.vuegetPathFromParent()onMainAppEvent('ROUTER_CHANGE_TO_CHILD')
  • src/main.tserrorCheck()setupErrorCheck()
  • 搜索所有 iframe-bridge 引用,确认全部替换
  • 独立运行验证(直接访问子应用端口)
  • wujie 模式验证(通过主应用加载)
  • 删除旧的 src/utils/iframe-bridge.ts

六、子应用预加载策略

6.1 wujie preloadApp API

wujie 提供 preloadApp() 方法,可以在用户访问前预热子应用,减少首次加载白屏时间。

import { preloadApp } from 'wujie'

// 预加载指定子应用(只加载 HTML/JS/CSS,不渲染)
preloadApp({ name: 'doc', url: '/doc/' })

6.2 推荐策略

// src/plugins/wujie.ts(增强版)
import WujieVue from 'wujie-vue3'
import { preloadApp } from 'wujie'
import type { App } from 'vue'
import { microAppRegistry } from '@cmclink/micro-bridge'

export function setupWujie(app: App) {
  app.use(WujieVue)
}

/**
 * 预加载子应用(登录成功后调用)
 * 策略:
 *   - 高频子应用(doc、mkt):立即预加载
 *   - 其他子应用:延迟 3 秒后预加载,避免抢占主应用资源
 */
export function preloadChildApps() {
  const highPriority = ['doc', 'mkt']
  const lowPriority = microAppRegistry
    .filter(app => !highPriority.includes(app.name))

  // 高优先级:立即预加载
  highPriority.forEach(name => {
    const app = microAppRegistry.find(a => a.name === name)
    if (app) {
      preloadApp({ name: app.name, url: app.entry })
    }
  })

  // 低优先级:延迟预加载
  setTimeout(() => {
    lowPriority.forEach(app => {
      preloadApp({ name: app.name, url: app.entry })
    })
  }, 3000)
}

6.3 调用时机

// AuthenticatedLayout.vue — 用户登录成功后
import { preloadChildApps } from '@/plugins/wujie'

onMounted(() => {
  preloadChildApps()
  // ... 其他初始化
})

6.4 预加载效果预估

指标 无预加载 有预加载
子应用首次切换白屏 1-3 秒 < 500ms
主应用首屏影响 高优先级 +200ms,低优先级无感
内存占用 按需加载 预热后常驻(alive 模式本身就常驻)

七、路由同步设计

7.1 当前方案

主应用路由与子应用路由的映射关系:

主应用 URL: /micro-main/doc?childPath=/order/list
                          |
子应用内部路由: /order/list

7.2 路由同步流程

用户点击菜单
    │
    ▼
主应用 router.push('/doc')
    │
    ▼
App.vue v-show 切换显示 doc 子应用
    │
    ▼
tabs.ts bus.$emit('ROUTER_CHANGE_TO_CHILD', { appName: 'doc', route: '/order/list' })
    │
    ▼
子应用 bus.$on('ROUTER_CHANGE_TO_CHILD') → router.push('/order/list')
    │
    ▼
子应用 router.afterEach → bus.$emit('ROUTE_CHANGE', { appName: 'doc', path: '/order/list' })
    │
    ▼
主应用 bus.$on('ROUTE_CHANGE') → router.replace({ query: { childPath: '/order/list' } })

7.3 wujie sync 路由同步模式(深度优化方向)

wujie 内置了路由同步能力,可以通过 sync 属性开启:

<WujieVue
  :name="app.name"
  :url="getAppUrl(app)"
  :alive="true"
  :sync="true"   <!-- 开启路由同步 -->
/>

开启后,子应用的路由变更会自动同步到主应用 URL 的 query 参数中:

主应用 URL: /micro-main/doc?doc=/order/list&doc-query=xxx

注意sync 模式与当前手动 childPath 方案有冲突,建议在 Phase 3 中评估后再开启。当前阶段保持手动同步方案,确保平稳过渡。


八、清理旧 apps/main

8.1 背景

apps/main 是之前基于 @micro-zoe/micro-app 框架搭建的主应用原型,不是真正的生产主应用。真正的主应用是 apps/cmclink-web-micro-main

8.2 差异对比

维度 apps/main(旧) apps/cmclink-web-micro-main(真)
package name main-app @cmclink/main
微前端方案 @micro-zoe/micro-app iframe → wujie
子应用数量 3(marketing/doc/ibs-manage) 7(完整业务线)
业务代码 简化版 完整生产代码
状态 ⚠️ 待清理 ✅ 正式使用

8.3 清理步骤

# 1. 确认 cmclink-web-micro-main 正常运行
pnpm --filter @cmclink/main dev

# 2. 删除旧主应用
rm -rf apps/main

# 3. 更新 turbo.json(如有 filter 引用 main-app 的地方)

# 4. pnpm install 重新解析 workspace
pnpm install

⚠️ 注意:删除前请确认 apps/main 中的 MicroAppContainer.vue@cmclink/micro-bridge 集成代码等有价值的内容已迁移到 cmclink-web-micro-main


九、CSS 方案统一路线

9.1 现状

应用 CSS 方案 版本 问题
主应用 Tailwind CSS 4 ^4.1.14 ✅ 无问题
doc 子应用 UnoCSS 0.56.5 ❌ 不兼容 Vite 7
ibs-manage 子应用 UnoCSS 0.56.5 ❌ 不兼容 Vite 7

9.2 推荐方案

统一迁移到 Tailwind CSS 4,理由:

  • 主应用已使用 Tailwind CSS 4,统一后减少认知负担
  • Tailwind CSS 4 原生支持 Vite 7
  • UnoCSS 0.56.5 的 Vite 插件不兼容 Vite 7(peer dependency 冲突)

9.3 迁移步骤(每个子应用)

# 1. 卸载 UnoCSS
pnpm --filter @cmclink/doc remove unocss @unocss/vite @unocss/preset-uno

# 2. 安装 Tailwind CSS 4
pnpm --filter @cmclink/doc add tailwindcss @tailwindcss/vite

# 3. 替换 vite.config.ts 中的插件
#    UnoCSS() → tailwindcss()

# 4. 创建 CSS 入口文件
#    @import "tailwindcss";

# 5. 逐步替换 UnoCSS 专有语法(如 attributify 模式)

9.4 风险评估

风险 影响 缓解措施
UnoCSS attributify 语法无对应 需手动改为 class 写法 全局搜索 un- 前缀
UnoCSS 自定义 rules 需转为 Tailwind 插件 逐个评估,大部分有等价写法
迁移期间样式回归 页面样式可能错乱 逐页面验证,保留 UnoCSS 作为过渡

十、实施路线图

Phase 1 ✅ 已完成:主应用 wujie 集成
├── ✅ cmclink-web-micro-main 纳入 monorepo
├── ✅ App.vue iframe → WujieVue 动态渲染
├── ✅ AuthenticatedLayout.vue 通信改造
├── ✅ tabs.ts 通信改造
├── ✅ 子应用注册表更新(7 个子应用)
└── ✅ pnpm install + 启动验证

Phase 2 ✅ 已完成:子应用侧适配(2026-02-06)
├── ✅ 创建 wujie-bridge.ts 替代 iframe-bridge.ts(doc + ibs-manage)
├── ✅ 改造 router/index.ts — import 路径替换,业务代码零改动
├── ✅ 改造 App.vue — getPathFromParent 来源替换
├── ✅ 改造 main.ts / service.ts / linkCpf.vue — 所有引用替换
└── ✅ pnpm install 验证通过

Phase 3 ✅ 已完成:深度优化(2026-02-06)
├── ✅ 子应用预加载策略(preloadChildApps 高/低优先级分级)
├── ✅ wujie sync 路由同步评估(当前手动方案已满足,sync 留待后续)
├── 通信层类型安全增强(后续迭代)
└── 性能监控与错误上报(后续迭代)

Phase 4 ✅ 已完成:CSS 统一(2026-02-06)
├── ✅ doc 子应用 UnoCSS → Tailwind CSS 4
├── ✅ ibs-manage 子应用 UnoCSS → Tailwind CSS 4
├── ✅ package.json 依赖替换(+2 -60 packages)
├── ✅ stylelintrc.json 规则更新
└── 其他子应用迁入时直接使用 Tailwind CSS 4

Phase 5 📋 规划中:公共资源沉淀与 AI 编程能力
├── 详见 doc/前端架构治理演进规划.md

附录 A:wujie 核心概念速查

概念 说明
alive 模式 子应用实例常驻内存,切换时不销毁。适合多 tab 场景
bus 全局事件总线,主子应用共享。bus.$on / bus.$emit
props 主应用通过 :props 向子应用传递数据,子应用通过 window.__WUJIE.props 读取
preloadApp 预加载子应用资源(HTML/JS/CSS),不渲染 DOM
sync 路由同步模式,子应用路由自动映射到主应用 URL query
degrade 降级模式,当浏览器不支持 Proxy 时自动降级为 iframe

附录 B:常用命令

# 启动主应用
pnpm --filter @cmclink/main dev

# 启动主应用 + 单证子应用
pnpm --filter @cmclink/main --filter @cmclink/doc dev

# 全量启动
pnpm dev

# 构建
pnpm build

# 增量构建(只构建变更的应用)
pnpm build:affected

附录 C:新增子应用接入指南

新增一个子应用只需 3 步:

1. 注册表添加配置packages/micro-bridge/src/registry.ts):

{
  name: 'new-app',
  entry: '/new-app/',
  activeRule: '/new-app',
  port: 3008,
}

2. 主应用路由添加占位src/router/index.ts):

{
  path: '/new-app',
  name: 'NewApp',
  component: { render: () => null },
  meta: { name: '新应用', appName: 'new-app' },
}

3. tabs.ts 添加子应用路径和 tab 信息

// childPathList 添加
'/new-app'

// appList 添加
{ name: '新应用', nameEn: 'New App', appName: 'new-app', route: '/new-app', show: false }

App.vue 中的 <WujieVue> 会自动基于注册表渲染,无需修改

提示词工程入门-03

2026年2月6日 23:29

前言

"写个代码" "帮我写个快速排序函数,用 Python 实现,要求时间复杂度 O(n log n),添加详细注释"

同样是让 AI 写代码,为什么第一个指令得到的是模糊的回复,而第二个能得到精确满足需求的代码?

这就是提示词工程(Prompt Engineering)的魔力。

好的 Prompt = 好的输出。今天我们来学习如何写出让 AI "秒懂"的提示词。


1. 什么是提示词工程

提示词(Prompt):你给大模型的输入指令

提示词工程(Prompt Engineering):设计和优化 Prompt 的艺术和科学

Prompt 的黄金结构

┌─────────────────────────────────────────┐
│           Prompt 结构模板                 │
├─────────────────────────────────────────┤
│                                         │
│  1. 角色(Role)                         │
│     "你是一个经验丰富的程序员..."        │
│                                         │
│  2. 任务(Task)                         │
│     "请写一个快速排序函数..."           │
│                                         │
│  3. 上下文(Context)                    │
│     "用于处理整数数组..."               │
│                                         │
│  4. 约束(Constraints)                  │
│     "要求O(n log n)时间复杂度..."       │
│                                         │
│  5. 格式(Format)                       │
│     "返回JSON格式,包含code和说明..."    │
│                                         │
│  6. 示例(Examples)                     │
│     "输入:[3,1,2] 输出:[1,2,3]"         │
│                                         │
└─────────────────────────────────────────┘

2. 案例

案例 1:明确角色的力量

❌ 没有角色

"怎么提高编程能力?"

✅ 有角色

"你是一位有10年经验的编程导师,
曾经指导过数百名初学者成为资深工程师。
请给我提供提高编程能力的建议。"

效果差异:有角色的 Prompt 能得到更有针对性、更有深度的回答。

案例 2:具体明确的任务

❌ 模糊的任务

"帮我写个文章"

✅ 明确的任务

"请写一篇关于人工智能发展历史的文章,
要求:
1. 字数800-1000字
2. 包含三个主要发展阶段
3. 提到GPT、Llama等关键模型
4. 语言风格通俗易懂"

SMART 原则

  • Specific(具体明确):写 Python 代码而非"写代码"
  • Measurable(可衡量):100字以内
  • Achievable(可达成):不要求超出模型能力
  • Relevant(相关性):任务与上下文相关
  • Time-bound(有时限):30秒内能读完的介绍

案例 3:提供充足上下文

❌ 缺少上下文

"这个代码有什么问题?"
[粘贴一段代码]

✅ 充足上下文

"我正在开发一个电商网站的用户认证功能。
这段Python代码用于验证用户密码,
但总是返回False,帮我找出问题:

[粘贴代码]

预期行为:正确密码返回True,错误密码返回False
实际行为:所有密码都返回False"

上下文要素清单

  • 背景:这是什么项目/场景?
  • 目标:想要达到什么效果?
  • 现状:当前是什么情况?
  • 问题:遇到了什么具体问题?

案例 4:使用示例的魔力

少样本学习示例

例子1:
输入:苹果
分类:水果

例子2:
胡萝卜
分类:蔬菜

例子3:
香蕉
分类:?

思维链示例

问题:小明有5个苹果,吃了2个,又买了3个,现在有几个?

思考过程:
1. 初始:小明有5个苹果
2. 吃了2个:5 - 2 = 3个
3. 买了3个:3 + 3 = 6个
答案:6个

现在请解决:
小红有10颗糖,给了妹妹3颗,妈妈又给了她5颗,现在有几颗?

效果:示例能让 AI 快速理解你想要的输出模式。

案例 5:格式化输出

❌ 不好的 Prompt

"分析这段代码"

✅ 好的 Prompt

"请分析以下代码,并按以下格式输出:

## 代码功能
[简要说明代码的功能]

## 时间复杂度
[分析时间复杂度]

## 改进建议
[列出3条具体改进建议]

## 重构代码
\`\`\`python
[重构后的代码]
\`\`\`"

案例 6:常用 Prompt 模板

代码生成模板

你是一个{语言}专家。
请写一个{功能描述}的{语言}函数,
要求:
1. {要求1}
2. {要求2}
3. {要求3}

请包含:
- 详细的注释
- 错误处理
- 使用示例

Bug 调试模板

我在开发一个{项目类型}项目,
这段{语言}代码出现了{问题描述}:

\`\`\`{语言}
{代码}
\`\`\`

预期行为:{预期}
实际行为:{实际}

请帮我:
1. 找出问题所在
2. 解释问题原因
3. 提供修复方案

总结

核心技巧速记

技巧 说明 示例
明确角色 给 AI 分配身份 "你是一位经验丰富的程序员"
具体任务 清楚说明要做什么 "写一个快速排序,O(n log n)"
充足上下文 提供背景信息 "这是电商网站的推荐功能"
使用示例 展示期望格式 "输入A输出B,输入C输出?"
明确格式 说明输出形式 "以 JSON 格式返回"

Prompt 检查清单

发送 Prompt 前,问自己:

  • 角色清晰:是否告诉 AI 它的角色?
  • 任务明确:是否清楚说明了要做什么?
  • 上下文充足:是否提供了足够的背景信息?
  • 约束具体:是否说明了限制和要求?
  • 格式明确:是否说明了输出格式?
  • 示例充分:是否提供了参考示例?

常见陷阱

陷阱 问题 解决方案
过于简短 "帮我优化代码" 说明具体优化目标
矛盾要求 "性能最好但代码简洁" 明确优先级
一次太多 "写完整电商系统" 分解为小任务
缺少验证 "计算复杂数学" 要求给出计算步骤

高级技巧

1. 迭代优化

第一次尝试 → 评估结果 → 修改 Prompt → 再次尝试

2. 温度参数调整

  • 低温度(0.1-0.3):更确定、一致的输出
  • 中等温度(0.4-0.7):平衡创造性和准确性
  • 高温度(0.8-1.0):更创造性、多样化的输出

3. 分解复杂任务

"设计一个博客系统"
↓ 分解为
"1. 设计数据库结构"
"2. 设计用户认证 API"
"3. 设计文章发布 API"
  1. 不要期望一次完美:迭代优化是常态
  2. 从简单开始:先验证基本方向,再添加细节
  3. 保存好用的 Prompt:建立自己的模板库
  4. 对比实验:用不同版本测试效果差异
  5. 学习他人经验:参考优秀的 Prompt 示例
❌
❌