Flutter速来系列23、关于Sliver的故事:列表,自定义Header,铺满屏幕,折叠

一、常见滚定组件

Flutter提供了多种滚动组件,可以用于处理各种滚动效果。

Sliver的子组件都能滚动,但并不是所有能滚动的组件都是Sliver子组件。比如,ListView和Grid就不是Sliver子组件。

重要说3遍

ListView和Grid就 不是 Sliver子组件。

ListView和Grid就 不是 Sliver子组件。

ListView和Grid就 不是 Sliver子组件。

在 Flutter 中,可滚动组件通常由三个角色组成

  • Scrollable:一个可滚动组件的基类,它定义了一个可滚动组件所需要的基本行为和接口。比如ListView和GridView

  • Viewport:表示一个视口,它用来显示可滚动组件中的内容。Viewport 可以是一个矩形,也可以是任意形状,它负责将滚动区域中的内容渲染到屏幕上。

  • Sliver:一个 Scrollable 的子组件,它是用来描述可滚动区域中的一段可滚动内容的。Sliver 可以是一个矩形,也可以是任意形状,它可以包含多个子组件,例如 SliverList、SliverGrid 等。


Sliver的子组件及其作用

Sliver的子组件都能滚动

Sliver子组件 描述
SliverAppBar 可折叠的应用栏,随着滚动进行展开和收起
SliverList 垂直的线性列表,用于显示动态数量的列表项
SliverGrid 二维网格布局,可在水平和垂直方向上滚动
SliverToBoxAdapter 将普通的非Sliver组件包装为Sliver组件,用于在Sliver布局中使用
SliverFixedExtentList 与SliverList类似,但所有列表项的高度都是固定的,可提高性能
SliverPersistentHeader 创建一个持久化的Header,始终可见,并可包含其他子组件
SliverPadding 为子组件提供填充,控制子组件与边界之间的间距
SliverOpacity 设置子组件的透明度,可根据滚动位置或其他条件调整子组件的显示效果
SliverAnimatedList 动态、带动画效果的列表,用于在滚动视图中显示数据的变化
SliverAnimatedOpacity 根据滚动位置或其他条件,以动画的方式调整子组件的透明度

二、Flutter 中的 Sliver

在 Flutter 中,Sliver 是一种特殊的 Widget,它可以用于创建可滚动的、高性能的列表或网格。相比于普通的列表或网格,使用 Sliver 可以提高滑动性能,减少内存占用,并且可以支持更多的交互效果。

Sliver 的基本概念

在 Flutter 中,Sliver 是指一种可以滚动的可视区域,它可以有多个子节点,每个子节点可以是一个 Widget 或者一个 LayoutBuilder。根据子节点的类型和滚动方向的不同,可以将 Sliver 分为以下几种类型:

  • SliverAppBar:一个可以随着滚动渐变、折叠、固定在顶部或底部的 AppBar。
  • SliverList:一个垂直方向的可滚动列表。
  • SliverGrid:一个网格布局的可滚动列表。
  • SliverToBoxAdapter:一个包含单个子节点的 Sliver,可以用于将一个普通的 Widget 包装成一个可滚动的 Widget。
  • SliverFillRemaining:一个占满剩余空间的 Sliver,通常用于在 CustomScrollView 中填充屏幕剩余的空间。

使用 Sliver 创建可滚动列表

在 Flutter 中,创建一个可滚动的列表通常需要使用 ListViewGridView。但是这些 Widget 的性能并不总是最优,尤其是在列表项较多时。相比之下,使用 Sliver 可以更好地控制列表项的渲染和排布,从而提高性能。

创建 SliverList

SliverList 是一个用于显示可滚动列表的 Sliver 组件,它可以高效地渲染大量的列表项,并且可以和其他 Sliver 组件一起使用,构建复杂的可滚动布局。与普通的列表组件不同,SliverList 不会提前将所有列表项都渲染出来,而是在滚动时动态地渲染当前可见的部分,从而节省内存和渲染时间。

下面是一个简单的例子,演示如何使用 SliverList 显示一个包含 50 个列表项的可滚动列表:

dart 复制代码
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverList Demo',
      home: Scaffold(
        body: CustomScrollView(
          slivers: <Widget>[
            SliverList(
              delegate: SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  print('Building item $index'); // 打印日志
                  return ListTile(
                    title: Text('Item $index'),
                    leading: CircleAvatar(child: Text('$index')),
                  );
                },
                childCount: 50,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

在这个例子中,我们创建了一个 CustomScrollView,其中包含了一个 SliverListSliverList 使用 SliverChildBuilderDelegate 来构建列表项,它会根据 childCount 属性的值来确定列表项的数量。在 SliverChildBuilderDelegate 中,我们可以使用 BuildContextindex 参数来构建每个列表项。在这个例子中,我们为每个列表项添加了一个圆形图标,以及一个显示编号的文本。当我们向上或向下滚动列表时,SliverList 会动态地渲染当前可见的列表项,从而保证了滚动的流畅性和性能。

注意看日志的打印

创建 SliverGrid

SliverGrid 是一个用于显示网格布局的 Sliver 组件,它可以高效地渲染大量的网格项,并且可以和其他 Sliver 组件一起使用,构建复杂的可滚动布局。与普通的网格布局组件不同,SliverGrid 不会提前将所有网格项都渲染出来,而是在滚动时动态地渲染当前可见的部分,从而节省内存和渲染时间。

下面是一个例子,演示如何使用 SliverGrid 显示一个包含 50 个格子的网格布局:

dart 复制代码
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverGrid Demo',
      home: Scaffold(
        body: CustomScrollView(
          slivers: <Widget>[
            // 创建一个包含 50 个格子的 SliverGrid
            SliverGrid(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                // 指定每行包含 3 个格子
                crossAxisCount: 3,
              ),
              delegate: SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  // 构建格子
                  return Container(
                    color: Colors.blue[100 * (index % 9 + 1)],
                    alignment: Alignment.center,
                    child: Text('Grid $index'),
                  );
                },
                childCount: 50, // 格子数量
              ),
            ),
          ],
        ),
      ),
    );
  }
}

三、Sliver 的高级用法

除了基本的 Sliver 类型外,Flutter 还提供了一些高级的 Sliver 类型,例如 SliverPersistentHeaderSliverFillViewportSliverOverlapInjector

三.1、 使用 SliverPersistentHeader 创建自定义 Header

SliverPersistentHeader 是一个可以自定义的 Header,它可以随着滚动渐变、折叠、固定在顶部或底部,并且可以包含任意的子节点。

与普通的头部或底部组件不同,SliverPersistentHeader 可以随着滚动而动态地改变自身的高度,从而实现更加灵活的布局效果。

下面是一个例子,演示如何使用 SliverPersistentHeader 实现一个固定在页面顶部的头部组件:

less 复制代码
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverPersistentHeader Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('SliverPersistentHeader Demo'),
        ),
        body: CustomScrollView(
          slivers: <Widget>[
            // 创建一个固定在页面顶部的 SliverPersistentHeader
            SliverPersistentHeader(
              pinned: true, // 固定在页面顶部
              delegate: _MyHeaderDelegate(),
            ),
            // 添加一个普通的 SliverList
            SliverList(
              delegate: SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  return ListTile(
                    title: Text('Item $index'),
                    leading: CircleAvatar(child: Text('$index')),
                  );
                },
                childCount: 50,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// 定义一个 SliverPersistentHeaderDelegate
class _MyHeaderDelegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    // 获取状态栏高度
    final double statusBarHeight = MediaQuery.of(context).padding.top;

    // 构建头部组件
    return Container(
      padding: EdgeInsets.only(top: statusBarHeight),
      color: Colors.blue,
      alignment: Alignment.center,
      child: Text('Header'),
    );
  }

  @override
  double get maxExtent => 100.0; // 最大高度

  @override
  double get minExtent => 50.0; // 最小高度

  @override
  bool shouldRebuild(_MyHeaderDelegate oldDelegate) {
    return false; // 不需要重新构建
  }
}

在这个例子中,我们创建了一个 CustomScrollView,其中包含了一个固定在页面顶部的 SliverPersistentHeader。我们使用 pinned 属性将头部组件固定在页面顶部,并使用 _MyHeaderDelegate 类来构建头部组件。在 _MyHeaderDelegate 中,我们实现了 buildmaxExtentminExtentshouldRebuild 四个方法,分别用于构建头部组件、指定最大和最小高度以及控制是否需要重新构建头部组件。在这个例子中,我们只是简单地为头部组件添加了一个背景颜色和一个文本。当我们向上或向下滚动列表时,头部组件会随着滚动而动态地改变自身的高度,从而实现了更加灵活的布局效果

不够吗?再来一个例子

dart 复制代码
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverPersistentHeader Demo',
      home: Scaffold(
        body: CustomScrollView(
          slivers: <Widget>[
            // 创建一个固定在页面顶部的 SliverPersistentHeader
            SliverPersistentHeader(
              pinned: true, // 固定在页面顶部
              delegate: _MyHeaderDelegate(),
            ),
            // 添加一个普通的 SliverList
            SliverList(
              delegate: SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  return ListTile(
                    title: Text('Item $index'),
                    leading: CircleAvatar(child: Text('$index')),
                  );
                },
                childCount: 50,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// 定义一个 SliverPersistentHeaderDelegate
class _MyHeaderDelegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    // 计算当前 Header 的高度
    double height = maxExtent - shrinkOffset;
    if (height < minExtent) {
      height = minExtent;
    }

    // 计算当前 Header 的背景颜色
    final double alpha = (maxExtent - height) / (maxExtent - minExtent);
    final Color backgroundColor = Colors.blue.withOpacity(alpha);

    // 构建 Header
    return Stack(
      fit: StackFit.expand,
      children: [
        // 背景图片
        Image.network(
          'https://picsum.photos/id/1/800/600',
          fit: BoxFit.cover,
        ),
        // 渐变遮罩层
        Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                backgroundColor.withOpacity(0.5),
                backgroundColor,
              ],
            ),
          ),
        ),
        // 折叠的内容
        Positioned(
          top: 100 - shrinkOffset,
          left: 16.0,
          right: 16.0,
          child: Opacity(
            opacity: 1 - alpha,
            child: Text(
              'Header',
              style: TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold, color: Colors.white),
            ),
          ),
        ),
      ],
    );
  }

  @override
  double get maxExtent => 200.0; // 最大高度

  @override
  double get minExtent => 50.0; // 最小高度

  @override
  bool shouldRebuild(_MyHeaderDelegate oldDelegate) {
    return false; // 不需要重新构建
  }
}
SliverPersistentHeader 对比 NestedScrollView

其实,NestedScrollView 也能实现类似的功能。

选择使用 SliverPersistentHeader 还是 NestedScrollView 取决于你的实际需求。下面是一些参考因素:

  • 如果你只需要一个简单的固定在顶部的 Header,那么使用 SliverPersistentHeader 可能更加简单明了。
  • 如果你需要在 Header 和内容之间添加一些复杂的交互逻辑,比如下拉刷新、上拉加载、折叠、渐变等效果,那么使用 NestedScrollView 可能更加灵活方便。
  • 如果你需要在页面中嵌套多个滚动组件,并且需要让它们进行联动,那么使用 NestedScrollView 是必要的选择。

总的来说,SliverPersistentHeaderNestedScrollView 都是非常强大和灵活的 Flutter 组件,可以帮助开发者实现各种复杂的布局效果。你可以根据自己的实际需求来选择使用哪一个。

三.2、 使用 SliverFillViewport 创建全屏的可滚动区域

SliverFillViewport 是 Flutter 中一个重要的 Widget,它的作用是使其子元素填充视口(也就是屏幕可见的部分)。

这个 Widget 最常见的用途是在 PageView 或者 CustomScrollView 里使用,用来创建用户可以左右滑动查看的各种 "pages" 或者 "cards"。

SliverFillViewport 的主要特点是它可以让其子元素以特定的方式来填充滚动视图。举例来说,如果你希望在用户滚动视图时,每个元素都可以占据滚动视图的整个视口,那么 SliverFillViewport 就是一个很好的选择。

简单的例子的例子
less 复制代码
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverFillViewport 示例',
      home: Scaffold(
        appBar: AppBar(title: Text('SliverFillViewport 示例')),
        body: Container(
          width: 300,
          height: 400,
          color: Colors.grey,
          child: CustomScrollView(
            slivers: <Widget>[
              SliverFillViewport(
                viewportFraction: 0.2, // 子组件高度占满视口的比例
                delegate: SliverChildListDelegate(
                  [
                    Container(color: Colors.blue),
                    Container(color: Colors.green),
                    Container(color: Colors.yellow),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

在这个示例中,我们将 viewportFraction 属性设置为 0.2,表示子组件的高度占视口高度的 20%。

运行该代码,你将看到在 300x400 的容器内,子组件的高度只占据了视口高度的 20%,而其余空间则留白。

这个示例演示了将 viewportFraction 属性设置为不同值时,子组件占用视口高度的比例发生变化的效果。


再来一个例子
dart 复制代码
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

// 定义旅游景点类
class TouristAttraction {
  final String name; // 名称
  final String description; // 描述
  final Color color; // 背景色

  TouristAttraction({required this.name, required this.description, required this.color});
}

class MyApp extends StatelessWidget {
  // 旅游景点列表
  final List<TouristAttraction> attractions = [
    TouristAttraction(
      name: '埃菲尔铁塔',
      description:
      '埃菲尔铁塔是法国巴黎的一座铁塔,位于马斯菲尔德公园(Champ de Mars)内,是巴黎地标之一。铁塔的设计者是古斯塔夫·埃菲尔,铁塔的建造是为了纪念法国大革命一百周年。',
      color: Colors.lightBlue, // 设置该景点的颜色
    ),
    TouristAttraction(
      name: '自由女神像',
      description:
      '自由女神像是位于美国纽约港的一座巨型铜像,是纽约市的象征之一,也是美国和法国友谊的象征。这座雕像是由法国雕塑家弗雷德里克·奥古斯特·巴托尔迪设计并制作,1886年10月28日揭幕。',
      color: Colors.pink, // 设置该景点的颜色
    ),
    TouristAttraction(
      name: '泰姬陵',
      description:
      '泰姬陵是位于印度北部城市阿格拉的一座白色大理石陵墓,于17世纪由莫卧儿帝国皇帝沙贾汗为其逝去的爱妃慕塔芝·马哈尔而建造。泰姬陵被认为是世界上最美的建筑之一。',
      color: Colors.orange, // 设置该景点的颜色
    ),
    TouristAttraction(
      name: '长城',
      description:
      '长城是一道蜿蜒于中国北部的防御工事,由石头、砖头、土坯等材料砌成,是中国古代的一项伟大工程。长城的修建始于公元前7世纪,历经2000多年的修建和扩建,成为了世界上最长的城墙。',
      color: Colors.yellow, // 设置该景点的颜色
    ),
    TouristAttraction(
      name: '比萨斜塔',
      description:
      '比萨斜塔是意大利比萨市的一座独立的钟楼,以其明显的倾斜而闻名于世。斜塔的建造始于12世纪,由于斜塔的基础建设不够坚固,导致了斜塔的倾斜。',
      color: Colors.green, // 设置该景点的颜色
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverFillViewport 演示',
      home: Scaffold(
        body: CustomScrollView(
          slivers: <Widget>[
            SliverAppBar(
              title: Text('旅游景点'),
              floating: true,
            ),
            SliverFillViewport(
              delegate: SliverChildBuilderDelegate(
                // 构建子元素
                    (BuildContext context, int index) {
                  final attraction = attractions[index];
                  return Container(
                    padding: EdgeInsets.all(16.0),
                    color: attraction.color, // 设置背景色
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text(
                          attraction.name,
                          style: TextStyle(
                            fontSize: 24,
                            fontWeight: FontWeight.bold,
                            color: Colors.white,
                          ),
                        ),
                        SizedBox(height: 8),
                        Text(
                          attraction.description,
                          style: TextStyle(
                            fontSize: 16,
                            color: Colors.white,
                          ),
                        ),
                      ],
                    ),
                  );
                },
                childCount: attractions.length,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

在这个例子中,我们创建了一个 TouristAttraction 类,用于表示旅游景点的名称和描述。我们将多个景点添加到 attractions 列表中,并在 CustomScrollView 中使用 SliverFillViewport 来展示它们的介绍。在 SliverFillViewport 中,我们使用 SliverChildBuilderDelegate 来构建子元素,并通过 childCount 属性指定子元素的数量。在 SliverChildBuilderDelegate 的回调函数中,我们遍历 attractions 列表,并根据每个景点的名称和描述创建一个子元素。由于 SliverFillViewport 会铺满整个 Viewport,因此所有的子元素都会占用整个屏幕。最后,我们将 CustomScrollView 放到 Scaffoldbody 中,并在 SliverAppBar 中设置标题和浮动属性。

三.3、 使用 SliverOverlapInjector 实现重叠效果

SliverOverlapInjector 是一个特殊的 Sliver Widget,用于在两个 Sliver 之间插入一个非滚动的 Widget,这个 Widget 可以覆盖在上一个 Sliver 的底部,同时也可以被下一个 Sliver 的内容覆盖。这个 Widget 主要用于解决两个 Sliver 之间的重叠问题。

最常见的使用场景是在 Flutter 的自定义滚动视图(CustomScrollView)中,通常在具有弹性头部(SliverAppBar)的列表上方添加额外的内容,这些内容将在列表滚动时滑入视图并在列表内容滚动到顶部时停止。

以下是一个简单的示例,用于演示如何使用 SliverOverlapInjector,我已添加详细的中文注释以帮助理解:

dart 复制代码
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SliverOverlapInjector 示例',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: "SliverOverlapInjector 示例"),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey<NestedScrollViewState> _key = GlobalKey<NestedScrollViewState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        key: _key,
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              title: Text(widget.title),
              expandedHeight: 200.0,
              floating: false,
              pinned: true,
              flexibleSpace: FlexibleSpaceBar(
                background: Image.network('https://picsum.photos/400/200', fit: BoxFit.cover),
              ),
            ),
          ];
        },
        body: Builder(
          builder: (BuildContext context) {
            return CustomScrollView(
              slivers: <Widget>[
                SliverOverlapAbsorber(
                  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                  sliver: SliverList(
                    delegate: SliverChildBuilderDelegate(
                          (BuildContext context, int index) {
                        return ListTile(
                          title: Text('列表项 #$index'),
                        );
                      },
                      childCount: 50,
                    ),
                  ),
                ),
                SliverOverlapInjector(
                  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

在这个示例中,SliverAppBar 提供了一个可以伸展的 AppBar,SliverOverlapAbsorber 吸收了其中的重叠部分,并通过一个 NestedScrollView.sliverOverlapAbsorberHandleFor 共享给 SliverOverlapInjector。这样,当我们在 CustomScrollView 中滚动时,SliverAppBar 下方的列表会根据滚动的情况逐渐滑入视图,形成重叠效果。

总结

在 Flutter 中,`Sliver以上是关于使用 Sliver 构建可滚动区域的基础介绍和示例代码,希望能够帮助你更好地理解和使用 Sliver 相关的 Widget。使用 Sliver 可以方便地构建出各种复杂的可滚动效果,同时也可以通过 SliverOverlapInjector 实现重叠效果,为用户提供更加丰富的交互体验。如果你有任何问题或疑问,欢迎继续提问。




可滚动布局模型和 Sliver布局模型 是两个东西吗

可滚动布局模型Sliver布局模型都是Flutter中用于实现可滚动性布局的布局模型,但它们的实现方式和使用方法略有不同。

可滚动布局模型(例如ListView、GridView、SingleChildScrollView等)是基于Scrollable类实现的,它们使用Viewport来显示子组件,并支持滚动、滑动和惯性等手势操作。这些组件通常使用较少的代码来实现常见的可滚动性布局模式。

Sliver布局模型则更为灵活,它的核心是使用Sliver来构建可滚动的子组件,可以自由组合和嵌套多种滚动组件以实现复杂的滚动效果。Sliver布局模型通常需要更多的布局代码和布局知识,但可以实现更高级和自定义的滚动效果。

可以将Sliver布局模型看作是可滚动布局模型的扩展,它提供了更多的自定义选项和更高级的滚动效果,例如可扩展的应用栏、流畅的滚动列表、复杂的网格布局等等。

总之,可滚动布局模型和Sliver布局模型都是Flutter中用于实现可滚动性布局的布局模型,开发者可以根据需要选择合适的布局模型来实现所需的滚动效果。

相关推荐
珹洺2 分钟前
从 HTML 到 CSS:开启网页样式之旅(二)—— 深入探索 CSS 选择器的奥秘
前端·javascript·css·网络·html
冰冻果冻25 分钟前
vue--制作随意滑动的相册
前端·javascript·vue.js
GISer_Jing1 小时前
前端测试工具(Jest与Mock)
前端·测试工具
licy__1 小时前
HTML 元素类型介绍
前端·css·html
一雨方知深秋1 小时前
WEB APIS(DOM对象,操作元素内容,属性,表单属性,自定义属性,定时器)
开发语言·前端·javascript
三金121381 小时前
整站使用Vue(工程化)
前端·javascript·vue.js
爱喝羊奶1 小时前
0 基础 新手应该看的AI编程导论
前端
爪哇学长2 小时前
打造极致网页体验:HTML与CSS高级实战秘籍
前端·css·html
程序猴老王2 小时前
el-select 和el-tree二次封装
前端·vue.js·elementui
blzlh3 小时前
手把手教你做网易云H5页面,进大厂后干的第一件事
前端·javascript·css