虚拟列表从入门到出门

虚拟列表

目标:让页面只渲染可见的少量节点,其余都是空白高度

固定高度

🌰来喽

每一项高度固定(假设50px)

若滚动区域高度500px,则可显示 (500 / 50 = 10项)

此时快速滚动到了2000项

真实渲染的列表内容:

只渲染 10~15 个 DOM 节点(pool)

把它们整体 transform 下移 offset 像素

在它们里面显示第 2000~2015 条数据

  • html版

    html 复制代码
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
        <style>
          .virtual-list {
            height: 300px;
            overflow-y: auto;
            border: 1px solid #ccc;
            position: relative;
          }
        </style>
      </head>
      <body>
        <!-- 页面滚动容器 -->
        <div id="app" class="virtual-list"></div>
    
        <script>
          // 列表数据总数
          const total = 10000;
          // 列表项高度
          const itemHeight = 30;
          // 可视区域渲染数量 +2 作为缓冲 避免闪烁
          const visibleCount = Math.ceil(300 / itemHeight) + 2;
          const data = Array.from({ length: total }, (_, i) => `Item ${i}`);
    
          // 容器
          const container = document.getElementById('app');
    
          // 列表容器 需要撑开总高度
          const wrap = document.createElement('div');
          // 计算容器总高度
          wrap.style.height = total * itemHeight + 'px';
          // 列表项使用absolute定位
          wrap.style.position = 'relative';
    
          // 列表项
          const list = document.createElement('div');
          // 使用absolute定位 使listItem 在list内偏移而不改变list高度
          list.style.position = 'absolute';
          list.style.top = 0;
          list.style.left = 0;
          list.style.right = 0;
    
          wrap.appendChild(list);
          container.appendChild(wrap);
    
          function render() {
            // 读取容器当前垂直滚动偏移
            const scrollTop = container.scrollTop;
            // 计算起始位置
            const start = Math.floor(scrollTop / itemHeight);
            // 计算结束为止
            const end = Math.min(start + visibleCount, total);
    
            /**
             * 设置偏移 使内容位置正确
             * 使用transform 将list 整体下移 start * itemHeight
             * transform性能较直接设置top更优
             * */
            list.style.transform = `translateY(${start * itemHeight}px)`;
    
            // 渲染可视区域数据,重置innerHTML 清空之前渲染的内容,优化见下文
            list.innerHTML = '';
    
            // 渲染数据
            for (let i = start; i < end; i++) {
              const div = document.createElement('div');
              div.style.height = `${itemHeight}px`;
              div.style.lineHeight = `${itemHeight}px`;
              div.style.borderBottom = '1px solid #eee';
              div.textContent = data[i];
              list.appendChild(div);
            }
          }
    
          render();
          // 滚动监听
          container.addEventListener('scroll', render);
        </script>
      </body>
    </html>
  • React版

    tsx 复制代码
    import VirtualList from './pages/virtual-list/fixed-height-virtual-list';
    
    function App() {
      const data = Array.from({ length: 100000 }, (_, i) => `Item ${i}`);
    
      return (
        <div style={{ padding: 20 }}>
          <h2>React 虚拟列表示例</h2>
          <VirtualList itemHeight={30} height={400} data={data} />
        </div>
      );
    }
    
    export default App;
    tsx 复制代码
    import { useRef, useState } from 'react';
    
    interface VirtualListProps {
      itemHeight: number;
      height: number;
      data: string[];
    }
    const FixHeightVirtualList = ({
      itemHeight = 30,
      height = 300,
      data = [],
    }: VirtualListProps) => {
      const containerRef = useRef<HTMLDivElement>(null);
    
      const [scrollTop, setScrollTop] = useState(0);
    
      const total = data.length;
      const visible = Math.ceil(height / itemHeight) + 2;
      const start = Math.floor(scrollTop / itemHeight);
      const end = Math.min(start + visible, total);
    
      const onScroll = () => {
        if (containerRef.current) {
          const top = containerRef.current.scrollTop;
          setScrollTop(top);
        }
      };
    
      return (
        <div
          ref={containerRef}
          onScroll={onScroll}
          style={{
            height,
            overflowY: 'auto',
            border: '1px solid #ccc',
            position: 'relative',
          }}
        >
          <div style={{ height: total * itemHeight, position: 'relative' }}>
            <div
              style={{
                position: 'absolute',
                top: '0',
                left: '0',
                right: '0',
                transform: `translateY(${start * itemHeight}px)`,
              }}
            >
              {data.slice(start, end).map((item, index) => (
                <div
                  key={start + index}
                  style={{
                    height: itemHeight,
                    lineHeight: `${itemHeight}px`,
                    borderBottom: '1px solid #eee',
                    paddingLeft: '10px',
                  }}
                >
                  {item}
                </div>
              ))}
            </div>
          </div>
        </div>
      );
    };
    
    export default FixHeightVirtualList;
  • React优化版

    • 方向

      python 复制代码
        data.slice(start, end).map()
      • 每次滚动都会生成新数组,会导致对象频繁创建
      • 数组改变后,React会diff整个可视区域
      • key每次新增/删除导致卸载挂载
    • 优化后

      • DOM数量保持不变
      • DOM完全复用
      • transform: translateY(offset)不触发回流
    • 去掉不必要的diff 去掉不必要的DOM创建/删除 去掉不必要的布局 去掉不必要的渲染

    tsx 复制代码
    import React, { useCallback, useMemo, useRef, useState } from 'react';
    
    interface VirtualListProps {
      itemHeight: number;
      height: number;
      data: string[];
      buffer: number;
      renderItem?: (item: string, index: number) => React.ReactNode;
    }
    const FixHeightVirtualListV2 = ({
      itemHeight = 50,
      height = 400,
      data = [],
      buffer = 2,
      renderItem,
    }: VirtualListProps) => {
      // 存DOM引用,读取scrollTop
      const containerRef = useRef<HTMLDivElement>(null);
    
      // 保存滚动实时值,不触发渲染,避免频繁setState
      const scrollTopRef = useRef(0);
    
      //标识是否已经有rAF回调
      const tickingRef = useRef(false);
    
      const [scrollTop, setScrollTop] = useState(0);
    
      const total = data.length;
    
      // 容器可见行数
      const visible = Math.ceil(height / itemHeight);
      // 真正创建的DOM节点数 = 可视区域 + 缓冲区 缓冲越多滚动越平滑但DOM更多
      const poolCount = visible + buffer;
      // 起始位置
      const start = Math.max(0, Math.floor(scrollTop / itemHeight));
    
      const onScroll = useCallback(() => {
        // 每次滚动把最新的scrollTop存入ref
        const top = containerRef.current?.scrollTop || 0;
        scrollTopRef.current = top;
    
        // 如果没有排队的rAF,就排一个
        if (!tickingRef.current) {
          tickingRef.current = true;
          requestAnimationFrame(() => {
            setScrollTop(scrollTopRef.current);
            tickingRef.current = false;
          });
        }
      }, []);
    
      const containerStyle = useMemo<React.CSSProperties>(
        () => ({
          overflowY: 'auto',
          height: height,
          border: '1px solid #ddd',
          position: 'relative',
          WebkitOverflowScrolling: 'touch',
        }),
        [height]
      );
    
      const spacerStyle = useMemo<React.CSSProperties>(
        () => ({
          height: total * itemHeight,
          position: 'relative',
        }),
        [total, itemHeight]
      );
    
      const innerStyle = useMemo<React.CSSProperties>(
        () => ({
          position: 'absolute',
          top: 0,
          left: 0,
          right: 0,
          transform: `translateY(${start * itemHeight}px)`,
          willChange: 'transform',
        }),
        [start, itemHeight]
      );
    
      const itemBaseStyle = useMemo<React.CSSProperties>(
        () => ({
          height: itemHeight,
          lineHeight: `${itemHeight}px`,
          borderBottom: '1px solid #eee',
          padding: '0 12px',
          boxSizing: 'border-box',
          overflow: 'hidden',
          whiteSpace: 'nowrap',
          textOverflow: 'ellipsis',
        }),
        [itemHeight]
      );
    
      return (
        <div ref={containerRef} style={containerStyle} onScroll={onScroll}>
          <div style={spacerStyle}>
            <div style={innerStyle}>
              {/* key使用index做索引而非dataIndex  在diff的时候不会将节点当作新节点创建或删除,而是复用DOM只替换文本*/}
              {Array.from({ length: poolCount }).map((_, index) => {
                const dataIndex = start + index;
                const item = dataIndex < total ? data[dataIndex] : null;
    
                const content =
                  item === null
                    ? null
                    : renderItem
                    ? renderItem(item, dataIndex)
                    : item ?? String(item);
    
                return (
                  <div
                    key={index}
                    style={{ ...itemBaseStyle }}
                    data-index={dataIndex}
                  >
                    {content}
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      );
    };
    
    export default FixHeightVirtualListV2;

不定高度

  • React版

    • 相比于定长列表,我们首先需要获取每个item的高度并将其存起来

    tsx 复制代码
    import { useState } from 'react';
    import VariableHeightVirtualList from './pages/virtual-list/variable-height-virtual-list';
    
    
    function App() {
    
      const [dataVariable] = useState(() =>
        new Array(1000).fill(0).map((_, i) => ({
          id: i,
          text: `Row ${i}`,
          height: 20 + Math.round(Math.random() * 80),
        }))
      );
    
      return (
        <VariableHeightVirtualList
          data={dataVariable}
          poolCount={15}
          estimatedItemHeight={50}
          containerHeight={500}
          renderItem={(item: any) => (
            <div
              style={{
                padding: '10px',
                borderBottom: '1px solid #eee',
                background: '#fafafa',
                height: item.height, // 不定高
              }}
            >
              {item.text} --- height: {item.height}
            </div>
          )}
        />
      );
    }
    
    export default App;
    tsx 复制代码
    import {
      useCallback,
      useEffect,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
    } from 'react';
    
    interface VirtualListProps {
      data: string[];
      renderItem: (item: string, index: number) => React.ReactNode;
      poolCount?: number;
      estimatedItemHeight?: number;
      containerHeight?: number;
    }
    const VariableHeightVirtualList = ({
      data,
      renderItem,
      poolCount = 15,
      estimatedItemHeight = 40,
      containerHeight = 400,
    }: VirtualListProps) => {
      const total = data.length;
    
      // heightMap存储单项高度,prefixHeight存储累计高度
      // 用来存储已测量的真实高度
      const heightMap = useRef<Record<number, number>>({});
    
      // 前缀和数组 prefixHeight[i] 表示 0~i 项的总高度 用于快速用二分查找scrollTop对应的startIndex
      const prefixHeight = useRef<number[]>([]);
    
      // 驱动视图更新
      const [scrollTop, setScrollTop] = useState(0);
    
      // 保持池内每个DOM节点的引用
      const itemRefs = useRef<Array<React.RefObject<HTMLDivElement>>>([]);
    
      // 计算前缀和 在不定高情况下,无法直接计算startIndex,需要通过前缀和与二分查找来定位scrollTop对应的item
      const calcPrefix = useCallback(() => {
        const arr = new Array(total);
        let sum = 0;
        for (let i = 0; i < total; i++) {
          const h = heightMap.current[i] || estimatedItemHeight;
          sum += h;
          arr[i] = sum;
        }
        prefixHeight.current = arr;
      }, [total, estimatedItemHeight]);
    
      // 首次挂载时执行
      useEffect(() => {
        calcPrefix();
      }, [calcPrefix]);
    
      // 二分查找
      // 给定当前scrollTOp,在prefixHeight中通过二分查找找到最小的k,使prefixHeight[k] >= scrollTop,也就是scrollTop所在Item的索引
      const findStartIndex = useCallback(() => {
        const arr = prefixHeight.current;
        const target = scrollTop;
    
        let left = 0;
        let right = arr.length - 1;
    
        while (left < right) {
          const mid = (left + right) >> 1;
          if (arr[mid] < target) left = mid + 1;
          else right = mid;
        }
    
        return left;
      }, [scrollTop]);
    
      // 得到起始索引
      const [startIndex, setStartIndex] = useState(0);
    
      useEffect(() => {
        setStartIndex(findStartIndex());
      }, [scrollTop, findStartIndex]);
    
      // 把DOM池视觉定位到startIndex位置
      const offset = useMemo(
        () => (startIndex === 0 ? 0 : prefixHeight.current[startIndex - 1]),
        [startIndex]
      );
    
      const scrollLock = useRef(false);
      const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
        const nextTop = (e.target as HTMLDivElement)?.scrollTop;
    
        if (!scrollLock.current) {
          scrollLock.current = true;
          requestAnimationFrame(() => {
            setScrollTop(nextTop);
            scrollLock.current = false;
          });
        }
      };
    
      // 在DOM更新并在浏览器绘制前,测量池中每个已渲染节点的真实offsetHeight,把测量结果写入heightMap
      // useLayoutEffect比useEffect执行更早,
      useLayoutEffect(() => {
        let changed = false;
    
        for (let i = 0; i < poolCount; i++) {
          const realIndex = startIndex + i;
          if (realIndex >= total) break;
    
          const dom = itemRefs.current[i];
          if (dom) {
            const h = dom.current?.offsetHeight || 0;
            if (heightMap.current[realIndex] !== h) {
              heightMap.current[realIndex] = h;
              changed = true;
            }
          }
        }
    
        if (changed) calcPrefix();
      });
    
      return (
        // 最外层滚动容器
        <div
          style={{
            height: containerHeight,
            overflow: 'auto',
            position: 'relative',
            border: '1px solid #ddd',
          }}
          onScroll={onScroll}
        >
          <div
            style={{
              height: prefixHeight.current[total - 1] || 0,
              position: 'relative',
            }}
          >
            <div
              style={{
                transform: `translateY(${offset}px`,
                position: 'absolute',
                left: 0,
                right: 0,
                color: 'black',
              }}
            >
              {/* 构建DOM池,固定长度为poolCount的数组并map出池内一个个槽位 */}
              {Array.from({ length: poolCount }).map((_, i) => {
                const dataIndex = startIndex + i;
                if (dataIndex >= total) return null;
    
                return (
                  <div
                    key={i}
                    ref={el => (itemRefs.current[i] = el)}
                    style={{ boxSizing: 'border-box', width: '100%' }}
                    data-index={dataIndex}
                  >
                    {renderItem(data[dataIndex], dataIndex)}
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      );
    };
    
    export default VariableHeightVirtualList;
    • 整体经历以下几个阶段

      • 用户滚动
      • 更新scrollTop
      • 通过前缀和prefixHeight 二分查找 startIndex
      • 计算offset
      • 渲染DOM池
      • useLayoutEffect测量真实高度
      • 写入heightMap
      • 重新计算prefixHeight
      • 视图稳定,等待下一次更新
  • Vue版

    html 复制代码
    <template>
      <div style="padding: 20px">
        <h3>Variable Height Virtual List</h3>
        <VariableHeightVirtualList
          :items="data"
          :containerHeight="600"
          :estimatedItemHeight="72"
          v-slot="{ item, index }"
          ref="vlist"
        >
          <div @click="toggleExpand(index)" :style="itemStyle(item, index)">
            <strong>#{{ index }}</strong> - {{ item.text }}
            <div v-if="expanded[index]" style="margin-top: 8px">
              额外内容:{{ item.largeText }}
            </div>
          </div>
        </VariableHeightVirtualList>
      </div>
    </template>
    
    <script lang="ts" setup>
    import { ref } from 'vue';
    import VariableHeightVirtualList from '../component/VariableHeightVirtualList.vue';
    const vlist = ref<any>(null);
    
    const data = new Array(2000).fill(0).map((_, i) => ({
      id: i,
      text: 'Item ' + i,
      largeText: '详细内容 '.repeat((i % 6) + 1),
      hasImage: i % 10 === 0,
    }));
    
    const expanded = ref<Record<number, boolean>>({});
    
    function toggleExpand(idx: number) {
      expanded.value = { ...expanded.value, [idx]: !expanded.value[idx] };
    }
    
    function itemStyle(item: any, idx: number) {
      return {
        padding: '12px',
        background: idx % 2 === 0 ? '#fff' : '#fafafa',
        borderBottom: '1px solid #eee',
        cursor: 'pointer',
      };
    }
    </script>
    html 复制代码
    <script lang="ts" setup>
    import {
      ref,
      computed,
      watch,
      nextTick,
      onMounted,
      onBeforeUnmount,
    } from 'vue';
    
    /**
     * Props
     * - items: 数据数组
     * - containerHeight: 可视区高度 (px)
     * - estimatedItemHeight: 估算高度(用于初始 prefix)
     * - poolCount: 可选,覆盖计算得到的池大小
     * - render-slot: 默认插槽 renderItem(item, index)
     */
    interface Props<T = any> {
      items: T[];
      containerHeight: number;
      estimatedItemHeight?: number;
      poolCount?: number;
      itemKey?: (item: any, index: number) => string | number;
    }
    
    const props = withDefaults(defineProps<Props>(), {
      estimatedItemHeight: 56,
      poolCount: undefined,
      itemKey: undefined,
    });
    
    const emit = defineEmits<{
      (e: 'measure', info: { index: number; height: number }): void;
    }>();
    
    // 容器元素引用
    const containerRef = ref<HTMLElement | null>(null);
    // 池子
    const poolRefs = ref<Array<HTMLElement | null>>([]);
    const ro = ref<ResizeObserver | null>(null);
    
    const items = computed(() => props.items);
    const total = computed(() => items.value.length);
    
    const estimatedItemHeight = computed(() => props.estimatedItemHeight!);
    
    // 存储item高度 <索引,高度>
    const heightMap = ref<Record<number, number>>({});
    
    // 前缀和数组
    const prefix = ref<number[]>([]);
    
    const pendingScroll = ref<number | null>(null);
    // 当前滚动位置
    const scrollTop = ref(0);
    
    // 计算可视数量 & 池大小
    const visibleEstimate = computed(() =>
      Math.max(1, Math.ceil(props.containerHeight / estimatedItemHeight.value))
    );
    const pool = computed(() => props.poolCount ?? visibleEstimate.value + 3);
    
    // 起始索引与偏移
    const startIndex = ref(0);
    const offset = ref(0);
    
    // 构建前缀和数组
    function calcPrefix() {
      const n = total.value;
      const arr: number[] = new Array(n);
      let s = 0;
      for (let i = 0; i < n; i++) {
        s += heightMap.value[i] ?? estimatedItemHeight.value;
        arr[i] = s;
      }
      prefix.value = arr;
    }
    
    // 二分法查找起始item
    function binaryFindStart(target: number) {
      const arr = prefix.value;
      if (!arr.length) return 0;
      let l = 0,
        r = arr.length - 1;
      while (l < r) {
        const m = (l + r) >> 1;
        if (arr[m] < target) l = m + 1;
        else r = m;
      }
      return l;
    }
    
    // 整个列表总高度
    const totalHeight = computed(() => {
      const last = prefix.value[prefix.value.length - 1];
      if (last != null) return last;
      return total.value * estimatedItemHeight.value;
    });
    
    // 滚动时处理
    function onScroll(e: Event) {
      const el = e.target as HTMLElement;
      const top = el.scrollTop;
      if (pendingScroll.value === null) {
        pendingScroll.value = top;
        requestAnimationFrame(() => {
          scrollTop.value = pendingScroll.value as number;
          pendingScroll.value = null;
        });
      } else {
        pendingScroll.value = top;
      }
    }
    
    // 监听滚动位置变化,更新起始索引与偏移
    watch(scrollTop, top => {
      if (!prefix.value.length) {
        startIndex.value = 0;
        offset.value = 0;
        return;
      }
      const s = binaryFindStart(top);
      startIndex.value = s;
      offset.value = s === 0 ? 0 : prefix.value[s - 1] ?? 0;
    });
    
    // 观察元素高度变化
    function observeEl(el: HTMLElement | null) {
      if (!el || !ro.value) return;
      ro.value.observe(el);
    }
    
    // 测量池中所有项目实际高度,并更新
    async function measurePool() {
      await nextTick();
      let changed = false;
      for (let i = 0; i < pool.value; i++) {
        const realIndex = startIndex.value + i;
        if (realIndex >= total.value) break;
        const el = poolRefs.value[i];
        if (!el) continue;
        const h = Math.round(el.offsetHeight);
        if (heightMap.value[realIndex] !== h) {
          heightMap.value = { ...heightMap.value, [realIndex]: h };
          emit('measure', { index: realIndex, height: h });
          changed = true;
        }
        observeEl(el);
      }
      if (changed) {
        requestAnimationFrame(() => calcPrefix());
      }
    }
    
    // 组件挂载时创建 监听元素尺寸变化并更新
    onMounted(() => {
      ro.value = new ResizeObserver(entries => {
        let changed = false;
        for (const ent of entries) {
          const el = ent.target as HTMLElement;
          const idxAttr = el.dataset.vIndex;
          if (!idxAttr) continue;
          const idx = Number(idxAttr);
          const newH = Math.round(ent.contentRect.height);
          if (heightMap.value[idx] !== newH) {
            heightMap.value = { ...heightMap.value, [idx]: newH };
            emit('measure', { index: idx, height: newH });
            changed = true;
          }
        }
        if (changed) requestAnimationFrame(() => calcPrefix());
      });
      calcPrefix();
    });
    
    // 组件卸载前断开观察
    onBeforeUnmount(() => {
      ro.value?.disconnect();
      ro.value = null;
    });
    
    // 监听 items 变化,重建 prefix 并测量池
    watch([startIndex, () => items.value.length], () => {
      measurePool();
    });
    
    // 组件挂载后初始化
    onMounted(() => {
      nextTick(() => {
        calcPrefix();
        measurePool();
      });
    });
    </script>
    
    <template>
      <!-- 滚动容器 -->
      <div
        :style="{
          height: props.containerHeight + 'px',
          overflowY: 'auto',
          position: 'relative',
        }"
        ref="containerRef"
        @scroll="onScroll"
      >
        <!-- 实际元素容器 -->
        <div :style="{ height: totalHeight + 'px', position: 'relative' }">
          <div
            :style="{
              transform: `translateY(${offset}px)`,
              position: 'absolute',
              left: 0,
              right: 0,
            }"
          >
            <template v-for="i in pool">
              <div
                v-if="startIndex + (i - 1) < total"
                :key="i - 1"
                :ref="el => (poolRefs[i - 1] = el)"
                :data-v-index="startIndex + (i - 1)"
                class="vhvl-item"
                style="width: 100%; box-sizing: border-box"
              >
                <slot
                  :item="items[startIndex + (i - 1)]"
                  :index="startIndex + (i - 1)"
                >
                  <div style="padding: 8px; border-bottom: 1px solid #eee">
                    {{ items[startIndex + (i - 1)] }}
                  </div>
                </slot>
              </div>
            </template>
          </div>
        </div>
      </div>
    </template>
    
    <style scoped></style>

思路

虚拟列表本质就是:用极少数DOM,模拟海量的内容渲染,并保持页面流畅

复制代码
JavaScript → Style → Layout → Paint → Composite
  • 减少DOM数量

    • DOM越少,Layout和Paint成本越低
  • DOM池复用

    • 不创建/销毁DOM,减少渲染

    • 在React升级版中,我们会看到如下写法:

      • 无论ItemIndex怎么变化,DOM是不变的,React只会将其内容改变,而不是删除/创建
    css 复制代码
      <div key={i}>{text}</div>
  • transform位移

  • 使用useLayoutEffect

    • height测量在绘制前完成

    复制代码
        render
        ↓
        useLayoutEffect(DOM 已存在,但尚未绘制)
        ↓
        浏览器绘制
        ↓
        useEffect

前缀和相关

一维前缀和

leetcode.cn/problems/ra...

当计算数组区间和时,可以通过前缀和的方式,本文在计算不定高度的item和时使用

二维前缀和

leetcode.cn/problems/ra...

相关推荐
程序猿小蒜1 小时前
基于springboot的人口老龄化社区服务与管理平台
java·前端·spring boot·后端·spring
用户21411832636022 小时前
Google Nano Banana Pro图像生成王者归来
前端
文心快码BaiduComate2 小时前
下周感恩节!文心快码助力感恩节抽奖页快速开发
前端·后端·程序员
_小九2 小时前
【开源】耗时数月、我开发了一款功能全面的AI图床
前端·后端·图片资源
恋猫de小郭2 小时前
聊一聊 Gemini3、 AntiGravity 和 Nano Banana Pro 的体验和问题
前端·aigc·gemini
一 乐2 小时前
英语学习激励|基于java+vue的英语学习交流平台系统小程序(源码+数据库+文档)
java·前端·数据库·vue.js·学习·小程序
淡淡蓝蓝2 小时前
uni.uploadFile使用PUT方法上传图片
开发语言·前端·javascript
晴殇i3 小时前
用户登录后,Token 到底该存哪里?从懵圈到精通的全方位解析
前端·面试
零一科技3 小时前
Vue3学习第七课:(Vuex 替代方案)Pinia 状态管理 5 分钟上手
前端·vue.js