阅读视图

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

React无限级菜单:一个项目带你突破技术瓶颈

有很多前端同学工作了很多年,但是感觉技术进步不是很大 ,什么原因呢?因为在工作中基本上都是在用ui框架,每天都在实现上级或老板安排的业务需求。没有对技术进行深度的思考,所以很难有所进步,那么怎么办才能让自己的技术有所进步呢?发挥自己的大脑,去封装一些公用的东西,或者去思考ui 框架中的组件是如何实现的,自己动手亲自实现下,长期这样做相信你的技术水平必然会有所提升。今天我们就通过使用React封装一个无限级的菜单来提升下自己吧!

一个常规的菜单有哪些功能呢?

  • 一般都会有一个图标,一个是标题
  • 每级菜单下面都很有可能还有子菜单,有子菜单的菜单项需要有一个箭头让用户知道下面还有子菜单
  • 点击有子菜单的菜单项,如果当前项的子菜单是收起来的,点击应该展开,如果当前项的子菜单是展开的,点击应该应该收起来
  • 点击没有子菜单的菜单项,当前项应该选中,而其他的最后一层级的菜单项选中状态应该消失,即最后一层级的选中的在同一时刻只能有一个
  • 不管点击有没有子菜单的都应该响应一个回调给用户,让用户好处理跳转后的逻辑,比如跳转,权限验证等逻辑

代码实现

1. 创建一个react 项目

npx create-react-app react-menu

2. 布局分析

要想实现无限级菜单,那么肯定要用到递归,在react 中如何实现组件的递归呢?假设现在我们有两个react组件,一个是Menu组件,一个是MenuItem组件。这两个组件要形成递归关系怎么做呢,其实很简单,在Menu组件中使用MenuItem组件,又在MenuItem组件中使用Menu组件就会形成递归调用关系了。但是递归必须要有结束条件,否则就会出现无限循环,内存溢出的情况,而在我们的无限级菜单实现的过程中有没有子菜单就是递归结束的条件。

3.根据布局分析创建代码结构

image.png

4. 布局代码实现

App.js

import Menu from "./components/Menu/Menu";
import menuList from "./data/menuData";

function App() {
  return (
    <div className="App">
      <Menu menuList={menuList} />
    </div>
  );
}

export default App;

menuData.js

function getImageUrl(imageName) {
  return require(`../assets/images/menu/${imageName}`);
}
const menuList = [
  {
    title: "菜单一级",
    icon: getImageUrl("menu-1.svg"),
    activeIcon: getImageUrl("menu-1-on.svg"),
    children: [
      {
        title: "菜单二级1",
        icon: getImageUrl("menu-1-1.svg"),
        activeIcon: getImageUrl("menu-1-1-on.svg"),
      },
      {
        title: "菜单二级2",
        icon: getImageUrl("menu-1-2.svg"),
        activeIcon: getImageUrl("menu-1-2-on.svg"),
      },
      {
        title: "菜单二级3",
        icon: getImageUrl("menu-1-3.svg"),
        activeIcon: getImageUrl("menu-1-3-on.svg"),
        children: [
          {
            title: "菜单三级1",
            icon: getImageUrl("menu-1-3-1.svg"),
            activeIcon: getImageUrl("menu-1-3-1-on.svg"),
          },
          {
            title: "菜单三级2",
            icon: getImageUrl("menu-1-3-2.svg"),
            activeIcon: getImageUrl("menu-1-3-2-on.svg"),
          },
        ],
      },
    ],
  },
  {
    title: "菜单一级2",
    icon: getImageUrl("menu-2.svg"),
    activeIcon: getImageUrl("menu-2-on.svg"),
    children: [
      {
        title: "菜单二级2-1",
        icon: getImageUrl("menu-2-1.svg"),
        activeIcon: getImageUrl("menu-2-1-on.svg"),
      },
    ],
  },
  {
    title: "菜单一级3",
    icon: getImageUrl("menu-3.svg"),
    activeIcon: getImageUrl("menu-3-on.svg"),
  },
  {
    title: "菜单一级4",
    icon: getImageUrl("menu-4.svg"),
    activeIcon: getImageUrl("menu-4-on.svg"),
  },
];

export default menuList;

Menu.js

import React, { Component } from "react";
import MenuItem from "./MenuItem";
import "../../assets/css/menu.css";
export default class Menu extends Component {
  render() {
    let menuList = this.props.menuList;

    return (
      <ul className="menu">
        {menuList.map((item) => {
          return <MenuItem key={item.title} item={item}></MenuItem>;
        })}
      </ul>
    );
  }
}

MenuItem.js

import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    return (
      <li>
        <p className={showToggleIcon}>
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
          />
        )}
      </li>
    );
  }
}

关键点分析:

  • 在MenuItem中调用了Menu,而Menu中又调用了MenuItem,这就形成了递归
  • 通过item.children && item.children.length > 0 来判断递归的结束条件和是否显示收缩的箭头

menu.css

.menu,
p {
  padding:0;
  margin: 0;
}

.menu li {
  list-style: none;
  background: rgb(84, 92, 100);
  color: #fff;
  line-height: 50px;
  cursor: pointer;
  padding-left: 20px;
}
.menu li p{
  position: relative;
}

.menu li p::after {
  position: absolute;
  right: 20px;
  top: 50%;
  margin-top: -12px;
  content: '';
  width: 24px;
  height: 24px;
  background-image: url("../images/menu/menu-toggle.svg");
  background-size: contain;
}
.menu li p.hide::after {
  display: none;
}
.menu li p.show::after {
  display: block;
}

.menu-icon {
  display: inline-block;
  width: 24px;
  height: 24px;
  vertical-align: middle;
  background-size: contain;
  margin-right: 5px;
}
.menu.hide {
  display: none;
}
.menu.show{
  display: block;
}

效果展示:

image.png

5.将子菜单全收起来

一般的菜单默认都是只展示一级菜单的,二级及以下的都收起来

在MenuItem.js中 添加state, isShowSub 用来控制是否显示子菜单 在调用的Menu上添加className={this.state.isShowSub ? "show" : "hide"}

修改后的MenuItem.js如下

import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    return (
      <li>
        <p className={showToggleIcon}>
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

image.png react class不会自动传到子组件,需要手动去添加,所以还需要在Menu.js中添加 className={"menu " + (this.props.className || "")}

修改后代码如下:

import React, { Component } from "react";
import MenuItem from "./MenuItem";
import "../../assets/css/menu.css";
export default class Menu extends Component {
  render() {
    let menuList = this.props.menuList;

    return (
      <ul className={"menu " + (this.props.className || "")}>
        {menuList.map((item) => {
          return <MenuItem key={item.title} item={item}></MenuItem>;
        })}
      </ul>
    );
  }
}

关键点:

image.png

当然这样改了只是判断了类名,还得添加样式,样式修改如下:

.menu,
p {
  padding:0;
  margin: 0;
}

.menu li {
  list-style: none;
  background: rgb(84, 92, 100);
  color: #fff;
  line-height: 50px;
  cursor: pointer;
  padding-left: 20px;
}
.menu li p{
  position: relative;
}

.menu li p::after {
  position: absolute;
  right: 20px;
  top: 50%;
  margin-top: -12px;
  content: '';
  width: 24px;
  height: 24px;
  background-image: url("../images/menu/menu-toggle.svg");
  background-size: contain;
}
.menu li p.hide::after {
  display: none;
}
.menu li p.show::after {
  display: block;
}

.menu-icon {
  display: inline-block;
  width: 24px;
  height: 24px;
  vertical-align: middle;
  background-size: contain;
  margin-right: 5px;
}

.menu.hide {
  display: none;
}
.menu.show{
  display: block;
}

关键点:

image.png

效果展示:

image.png

可以看到现在就只展示一级的菜单了,二级及以下都收了起来。

6.点击展开收起功能

给MenuItem组件中的li下面的p添加点击事件,点击之后修改isShowSub 的状态,从而达到展开收起的功能。修改后的代码如下:

import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState({
      isShowSub: !this.state.isShowSub,
    });
  }
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    return (
      <li>
        <p className={showToggleIcon} onClick={this.itemClick.bind(this)}>
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

image.png

效果展示:

menu-toggle.gif 可以看到,菜单的展开收缩功能就实现了。

7.给有子菜单的,且展开的添加选中样式

有子菜单的项且子菜单是展开状态的菜单标题和菜单图标,右侧的箭头都应该高亮,且右侧的箭头应该向上。 MenuItem.js修改后如下:

import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState({
      isShowSub: !this.state.isShowSub,
    });
  }
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    return (
      <li>
        <p
          className={isExpand ? showToggleIcon + " active" : showToggleIcon}
          onClick={this.itemClick.bind(this)}
        >
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${isExpand ? item.activeIcon : item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

image.png

效果演示:

menu-toggle-on.gif

可以看到有子菜单的且子菜单是站看的就有选中的样式了。

8.解决选中样式没有占满一行问题

上面可以看到选中样式有了,但是有个问题,选中样式没有沾满一行,这样看上去非常不美观,且你点击左边没占满的那部分,展开收缩功能是失效的。这是什么原因呢?因为我们层级的缩进是给到li 上了,而点击事件和选中样式是给到p标签上的。所以就出现了这个问题,那么怎么解决呢?

  1. 你可以把点击事件和选中样式拿到li上面去,但是你拿过去之后会有很多问题,你点击子菜单的时候,也会触发展开收缩功能,需要给li下面的所有子菜单添加阻止冒泡事件,另外样式也需要做些调整,非常麻烦。
  2. 将层级缩进放到p标签上,将层级缩进放到p标签后,层级的缩进就不能写死了,需要根据层级来计算出缩进值。因为你直接写死的话你会发现所有的缩进都一样,分不清是多少级菜单了,就像下面这样了。

image.png

所以层级缩进放到p标签上后必须通过层级算出不同的值。这个时候我们就需要对用户传过来的数据进行格式化一下了,给每项都加上层级,level字段。在components/Menu下面新建一个utils.js

image.png

代码如下:

export function formatMenu(menuList, level = 1) {
  let currMemnList = menuList.map((item) => {
    const newItem = {
      ...item,
      level,
    };
    if (item.children && item.children.length > 0) {
      newItem.children = formatMenu(item.children, level + 1);
    }
    return newItem;
  });
  return currMemnList;
}

components/Menu 继续新增一个index.js, 为什么要新增index.js呢?因为Menu组件和MenuItem组件是递归调用的,这两个组件里面对数据进行数据格式化的执行都非常不合理,因为会被执行多次。而数据格式化只需要执行一次即可。而把数据格式化交给用户来格式化也不合理,每用一下这个组价还得自己格式化一下。所以我们新建一个index.js来包一层,这样就可以实现数据格式化只执行一次。且在我们后面实现最里面层的菜单选中的时候还会遇到这个初始化的特性。

index.js 代码如下

import { formatMenu } from "./utils";
import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuIndex extends Component {
  render() {
    const menuList = formatMenu(this.props.menuList);
    console.log("格式化后的菜单数据:", menuList);
    return <Menu menuList={menuList} />;
  }
}

修改App.js 导入的文件路径

import Menu from "./components/Menu/index";
import menuList from "./data/menuData";

function App() {
  return (
    <div className="App">
      <Menu menuList={menuList} />
    </div>
  );
}

export default App;

关键点:

image.png

查看浏览器控制台

image.png 可以看到格式化后的数据就具备了level层级字段。现在就可以根据层级字段来给p标签设置缩进了 在MenuItem组件的p标签添加 style={{ paddingLeft: item.level * 20 + "px" }}

修改后的代码如下:

import React, { Component } from "react";
import Menu from "./Menu";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState({
      isShowSub: !this.state.isShowSub,
    });
  }
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    return (
      <li>
        <p
          className={isExpand ? showToggleIcon + " active" : showToggleIcon}
          onClick={this.itemClick.bind(this)}
          style={{ paddingLeft: item.level * 20 + "px" }}
        >
          <i
            className={"menu-icon"}
            style={{
              backgroundImage: `url(${isExpand ? item.activeIcon : item.icon})`,
            }}
          ></i>
          {item.title}
        </p>
        {isChildMenu && (
          <Menu
            menuList={item.children}
            className={this.state.isShowSub ? "show" : "hide"}
          />
        )}
      </li>
    );
  }
}

关键点:

image.png

查看效果:

image.png

可以看到现在就可以实现整行选中了。且缩进正常。

9.处理没有子菜单的选中逻辑

前面针对有子菜单的选中逻辑已经实现了,现在我们来实现下没有子菜单的选中逻辑。没有子菜单的选中逻辑相对要复杂一些,因为它需要将之前的没子菜单的选中状态变成默认状态,怎么处理这个问题呢?在同一层级的还好处理,在不同层级的,甚至它所在的最顶层的菜单都不是同一个菜单的,处理起来就非常麻烦,因为Menu组件和MenuItem组件它们是递归调用的,即可能是父子关系,也有可能是子父关系。要是想通过父子传递的方式,很难行得通。那么到底该怎么做呢?

其实可以通过Context来处理,创建一个context, 在index.js中提供初始值来保存选中的状态的项,并且在index.js 提供一个更改context值的方法updateValue,在点击的时候不管是在哪个层级都可以获取到context, 调用调用context上的updateValue 方法,更改当前选中状态值的context值,即可实现同一时刻只选中一个没有子菜单的菜单选中项。

components/Menu 下面创建 Context.js,代码如下:

import { createContext } from "react";
export const ActiveMenuContext = createContext({});

修改components/Menu/index.js

import { formatMenu } from "./utils";
import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuIndex extends Component {
  state = {
    value: "",
  };

  updateValue(value) {
    console.log("dianji upldate", value);
    this.setState({ value });
  }

  render() {
    const menuList = formatMenu(this.props.menuList);
    console.log("格式化后的菜单数据:", menuList);
    return (
      <ActiveMenuContext.Provider
        value={{
          value: this.state.value,
          updateValue: this.updateValue.bind(this),
        }}
      >
        <Menu menuList={this.props.menuList} />
      </ActiveMenuContext.Provider>
    );
  }
}

关键点:

image.png

修改components/Menu/MenuItem.js

import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState(
      {
        isShowSub: !this.state.isShowSub,
      },
      () => {
        const { updateValue } = this.context;
        updateValue(this.props.item.title);
      }
    );
  }
  static contextType = ActiveMenuContext;
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    const isActiveItem = this.context.value === item.title && !isChildMenu;
    console.log(isActiveItem, item.level);
    return (
      <ActiveMenuContext.Consumer>
        {() => {
          return (
            <li>
              <p
                className={
                  isExpand || isActiveItem
                    ? showToggleIcon + " active"
                    : showToggleIcon
                }
                onClick={this.itemClick.bind(this)}
                style={{ paddingLeft: item.level * 20 + "px" }}
              >
                <i
                  className={"menu-icon"}
                  style={{
                    backgroundImage: `url(${
                      isExpand || isActiveItem ? item.activeIcon : item.icon
                    })`,
                  }}
                ></i>
                {item.title}
              </p>
              {isChildMenu && (
                <Menu
                  menuList={item.children}
                  className={this.state.isShowSub ? "show" : "hide"}
                />
              )}
            </li>
          );
        }}
      </ActiveMenuContext.Consumer>
    );
  }
}

关键点:

image.png

image.png

查看效果:

item.active.gif

10. 给用户添加事件处理函数

现在我们点击有子菜单的项能实现展开和收缩了,也实现了没有子菜单的在同一时刻只有一个的选择逻辑,但是菜单一般都是需要跳转的,或者还要处理其他逻辑,这怎么处理比较好呢!这其实提供一个回调函数比较好,让用户自己处理这部分逻辑,因为这部分逻辑是千差万别的,在菜单这种公共逻辑里面不好处理。来看下具体实现。

在App.js中添加一个clickMenu函数,传给Menu,具体代码修改如下:

import Menu from "./components/Menu/index";
import menuList from "./data/menuData";

function App() {
  function clickMenu(item) {
    // 这里处理用户点击后的逻辑
    console.log(item, "---item");
  }
  return (
    <div className="App">
      <Menu menuList={menuList} clickMenu={clickMenu} />
    </div>
  );
}

export default App;

关键点:

image.png

在components/Menu/index.js 添加clickMenu的传递

import { formatMenu } from "./utils";
import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuIndex extends Component {
  state = {
    value: "",
  };

  updateValue(value) {
    console.log("dianji upldate", value);
    this.setState({ value });
  }

  render() {
    const menuList = formatMenu(this.props.menuList);
    console.log("格式化后的菜单数据:", menuList);
    return (
      <ActiveMenuContext.Provider
        value={{
          value: this.state.value,
          updateValue: this.updateValue.bind(this),
          clickMenu: this.props.clickMenu,
        }}
      >
        <Menu menuList={menuList} />
      </ActiveMenuContext.Provider>
    );
  }
}

关键点:

image.png

在MenuItem中接收事件,并在点击的时候触发,将当前点击项的数据传回给用户

import React, { Component } from "react";
import Menu from "./Menu";
import { ActiveMenuContext } from "./Context";

export default class MenuItem extends Component {
  state = {
    isShowSub: false,
  };
  itemClick() {
    this.setState(
      {
        isShowSub: !this.state.isShowSub,
      },
      () => {
        const { updateValue, clickMenu } = this.context;
        updateValue(this.props.item.title);
        clickMenu(this.props.item);
      }
    );
  }
  static contextType = ActiveMenuContext;
  render() {
    const { item } = this.props;
    const isChildMenu = item.children && item.children.length > 0;
    const showToggleIcon = isChildMenu ? "show" : "hide";
    const isExpand = this.state.isShowSub && isChildMenu; // 子菜单是否是展开状态
    const isActiveItem = this.context.value === item.title && !isChildMenu;
    console.log(isActiveItem, item.level);
    return (
      <ActiveMenuContext.Consumer>
        {() => {
          return (
            <li>
              <p
                className={
                  isExpand || isActiveItem
                    ? showToggleIcon + " active"
                    : showToggleIcon
                }
                onClick={this.itemClick.bind(this)}
                style={{ paddingLeft: item.level * 20 + "px" }}
              >
                <i
                  className={"menu-icon"}
                  style={{
                    backgroundImage: `url(${
                      isExpand || isActiveItem ? item.activeIcon : item.icon
                    })`,
                  }}
                ></i>
                {item.title}
              </p>
              {isChildMenu && (
                <Menu
                  menuList={item.children}
                  className={this.state.isShowSub ? "show" : "hide"}
                />
              )}
            </li>
          );
        }}
      </ActiveMenuContext.Consumer>
    );
  }
}

关键点:

image.png

点击菜单查看效果:

image.png

可以看到在App.js中就获取到了用户点击的项,这样用户想做什么逻辑处理就可以自己处理了。到此,使用React开发一个无限级菜单就开发完成了。

总结

本篇分享了使用React 开发一个无限级菜单的过程

  • 从布局分析要实现无限级,我们就必须使用递归,并且把当前项有无子菜单了作为递归的结束条件
  • 在实现选中过程时,我们发现菜单没有选中整行,然后提供了两种解决方案,最终经过分析选择第二种方案
  • 选择第二种方案后层级缩进消失,然后我们添加了index.js进行包裹,在这里面我们进行了数据格式化,给数据添加level层级属性,用于计算当前项的层级缩进
  • 在处理点击没有子菜单项其他项需要去除选中状态的逻辑时,我们经过分析选择了Context作为解决方案,很好的解决了这个问题。
  • 为了组件的灵活性和扩展性,我们提供了点击事件处理逻辑给用户自己处理

今天的分享就到这里了,感谢收看,本篇已收录到 React 知识储备专栏, 欢迎关注后续更新

2025前端面试题

前端解决大规模并发问题的策略

前端处理大规模并发问题需要结合多种技术手段和架构优化,以下是一些关键解决方案:

1. 请求优化策略

  • 请求合并:将多个小请求合并为一个大请求
  • 节流(Throttle)与防抖(Debounce):控制高频事件的触发频率
  • 延迟加载:非关键资源延后加载
  • 分页/分批加载:大数据集分段获取

2. 缓存机制

// 使用内存缓存示例
const cache = new Map();

async function fetchWithCache(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }
  const response = await fetch(url);
  const data = await response.json();
  cache.set(url, data);
  return data;
}

3. Web Workers处理密集型任务

// 主线程
const worker = new Worker('worker.js');
worker.postMessage({data: largeData});
worker.onmessage = (e) => {
  console.log('Result:', e.data);
};

// worker.js
self.onmessage = (e) => {
  const result = processLargeData(e.data); // CPU密集型操作
  self.postMessage(result);
};

4. WebSocket长连接

const socket = new WebSocket('wss://example.com');

socket.onopen = () => {
  socket.send(JSON.stringify({type: 'subscribe'}));
};

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  // 处理实时数据更新...
};

5. Service Worker离线缓存

// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/styles/main.css',
        '/scripts/main.js',
        // ...其他关键资源
      ]);
    })
  );
});

self.addEventListener('fetch', (event) => {
 event.respondWith(
   caches.match(event.request).then((response) => {
     return response || fetch(event.request);
   })
 );
});

6. CDN与资源分发

  • 静态资源CDN分发
  • 按地域部署边缘节点
  • HTTP/2或HTTP/3多路复用

7. UI优化策略

  • 虚拟滚动(Virtual Scrolling):只渲染可视区域内容
  • 骨架屏(Skeleton Screen):提升用户感知体验
  • 渐进式渲染:优先显示关键内容

8. API设计配合

  • GraphQL:按需获取数据,减少冗余传输
  • BFF层(Backend For Frontend):为前端定制API聚合层

实际应用中,通常需要根据具体场景组合多种策略来应对高并发挑战。

跟🤡杰哥一起学Flutter (三十五、玩转Flutter滑动机制📱)

1. 引言

上节《三十四、玩转Flutter手势机制✋》扒完 Flutter 的 "手势机制",有点意犹未尽,那就趁热打铁,本节来把 "滑动机制" 也冲了 🔥

✨"滑动机制" 的出现是为了解决 "有限区域" 显示 "超量/无限内容" 的问题。

🤔 打个比方:

想象下你面前有一幅延绵百米的巨型壁画,你的眼睛一次只能看到笔画的一部分 (如正前方2米宽的区域),这个"眼睛能看到的区域" 可以称作你的 "视野窗口 (Viewport)",当你左右移动身体,"视口" 也会随之移动,让你看到壁画的其它部分。

😶 贴近现实,现代数字内容的丰富性 (如长文本、图片列表、网页、数据表格等) 远超 屏幕的物理显示范围,此时,用户也需要一种 "移动视口" 的方式,以访问被屏幕边缘遮挡的内容。💁‍♂️ "滑动机制" 应运而生,其本质是通过 用户交互 (如手指拖动、鼠标滚轮、触控板手势) 改变内容在屏幕上的显示位置 (视口偏移),从而让用户逐步 "扫过" 超量内容。技术实现通常分为这三个部分:

  • 内容容器与视口分离:将内容渲染在一个更大的 "虚拟画布" 中,屏幕仅显示其中一部分。
  • 输入事件驱动偏移:用户通过 滑动手势 触发系统计算位移量,调整内容容器的位置 (如向上偏移100像素),使得原本被遮挡的内容进入视口。
  • 边界反馈:当内容已完全显示 (到达顶部/底部) 时,通过阻力反馈或动画提示用户"无更多内容"。

😄 而在 Flutter 中,视口 (Viewport) 的概念不在局限于屏幕级别的显示区域,而是扩展到任意一个 "构建其可视范围内子项的可滚动区域"。为此,Flutter 设计了一套 "滑动处理机制",其中的重要参与者:

Scrollable-专注交互,监听手势并驱动滚动、Viewport-管理并提供可见视口,Slivers-提供内容,在Viewport按需渲染自己的一部分。

这种清晰的职责划分 (分层设计) 使得 Flutter 在实现无限列表、复杂联动特效时,仍能够保持出色的性能与强大的灵活性。

😏 本节就来系统学习下 Flutter 的这套 "滑动机制",依旧是 "概念名词+API详解+源码剖析" 的叙述套路~

2. 三大核心构件

💡 这部分涉及到比较多的 API,可以选择性阅读 (跳着看),大概知道是都是干嘛的,用到再查也可以。🤔对 "Sliver协议的工作流程" 建立一个基础认知,在后面分析各种具体滚动视图时会更加得心应手。

2.1. Scrollable - 滑动控制器

所有 可滚动组件 的 "基石",作用是将 "用户的输入 (手势) 转化为滚动的视觉变化",具体功能:

  • 手势识别:通过内置的 RawGestureDetector,识别垂直或水平方向的拖拽手势。
  • 滚动物理模拟:通过 ScrollPhysics,定义了滚动的 "感觉",比如:滚动到边缘时的效果 (Android-蓝色辉光-ClampingScrollPhysics、iOS-回弹-BouncingScrollPhysics)、滑动停止时的惯性动画 (Fling)。
  • 状态管理:管理滚动的核心状态,如:当前滚动位置 (pixel)、滚动范围 (min/max scroll extent)。这个状态由一个叫做 ScrollPosition 的对象维护。
  • 外部控制接口:通过 ScrollController,允许开发者从外部读取滚动位置、监听滚动事件或命令式地控制滚动 (如跳转到指定位置、执行动画)。
  • 构建视口Scrollable 本身不渲染任何可滚动的内容。它通过一个名为 viewportBuilder 的回调函数,将滚动的能力 (ViewportOffset) "嫁接" 给一个负责渲染部分内容的 Viewport 组件。

2.1.1. 属性/方法

构造方法:

class Scrollable extends StatefulWidget {
  const Scrollable({
    super.key,
    this.axisDirection = AxisDirection.down,        // 滚动方向
    this.controller,                                // 滚动控制器
    this.physics,                                   // 滚动物理效果
    required this.viewportBuilder,                  // 视口构建器(必需)
    this.incrementCalculator,                       // 增量计算器,
    this.excludeFromSemantics = false,             // 是否在语义树 (用于辅助功能,如屏幕阅读器) 中可见。
    this.semanticChildCount,                        // 向辅助功能提供一个提示,告知总共有多少个子项。
    this.dragStartBehavior = DragStartBehavior.start, // 拖拽开始行为
    this.restorationId,                            // 恢复ID
    this.scrollBehavior,                           // 滚动行为
    this.clipBehavior = Clip.hardEdge,             // 裁剪行为
  })
}

参数详解:

axisDirectionAxisDirection

定义了【滚动轴的方向】& ScrollPosition 的 pixel 为 0 时,内容所处的位置,可选值:

  • down:垂直方向,内容从上到下排列 (0.0在顶部)
  • up:垂直方向,内容从下到上排列 (0.0在底部)
  • right::水平方向,内容从左到右排列 (0.0在左侧)
  • left:水平方向,内容从右到左排列 (0.0在右侧)

physicsScrollPhysics?

定义了【组件滚动时的物理特性】,决定了滚动时的"手感",如果为null,Scrollable 会通过 ScrollConfiguration.of(context) 获取一个平台默认的 ScrollPhysics。Android 上是ClampingScrollPhysics (边界钳制,有辉光),在 iOS 上是 BouncingScrollPhysics (边界回弹)。常见的还有:NeverScrollableScrollPhysics (禁止滚动)、AlwaysScrollableScrollPhysics (内容不足一屏也可滚动)。

incrementCalculatorScrollIncrementCalculator?

用于计算非指针(如键盘箭头、鼠标滚轮)滚动事件的滚动增量。通常无需关注此参数,框架有默认实现,在需要自定义键盘/滚轮滚动行为时才使用。

dragStartBehaviorDragStartBehavior

定义了【拖拽开始行为】决定滚动操作何时开始被识别。

  • start:默认值,用户的手指按下并移动了一段微小的距离后才会被识别为滚动开始。
  • down:用户手指按下并开始移动的瞬间就被立即识别为滚动,除非有极致及时反馈的交互时才设置,否则建议还是保持默认,以保证最佳和最符合预期的用户体验。

clipBehaviorClip

定义了【如何对超出边界的内容进行裁剪】,可选值:

  • hardEdge:默认,以最快的方式裁剪掉超出边界的内容。裁剪的边缘是硬的,可能会有锯齿,但GPU负载最低,性能最好,特别适合滚动视图是矩形且没特殊视觉效果的场景。
  • antiAlias:裁剪内容,并对裁剪的边缘进行抗锯齿处理,使其看起来更平滑。视觉效果更好,特别是当滚动视图有圆角时,可以避免边缘的锯齿感。性能开销比 hardEdge 稍高。
  • antiAliasWithSaveLayer:使用最高质量的抗锯齿裁剪,但也是性能开销最大的。它会创建一个临时的离屏缓冲区 (save layer) 来执行裁剪操作。能处理复杂的裁剪场景,提供最平滑、最准确的视觉效果。但严重影响性能。只有当Clip.antiAlias 仍然出现视觉问题 (如复杂的透明度和变换组合下) 时,才作为最后的手段使用。
  • none: 完全不裁剪,内容可以绘制到滚动视图的边界之外。 性能最差,因为它可能需要绘制更多内容,而且,溢出的内容可以会覆盖页面上其它UI元素,导致混乱的视觉布局。

restorationIdString?

用于【为滚动视图提供一个唯一的ID】以便在应用被系统杀死并恢复后,能够自动恢复其滚动位置。属于 Flutter 状态恢复 (State Restoration) 框架的一部分。应用场景:包含长内容页面 (如文章),当应用被挂起时 RestorationManager 会找到所有带 restorationId 的 Widget,并向它们请求需要保存的数据,(对于滚动视图,就是当前的 scrollOffset)。这些数据被保存到系统中,当应用恢复时,RestorationManager 会找到具体相同 restorationId 的Widget,并将保存的数据交还给它,使其能够恢复到之前的状态 (即滚动到之前的位置)。

viewportBuilderViewportBuilder

这是 ViewportBuilder 的定义代码:

typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);

这个参数是【构建Viewport的回调】,Scrollable.build() 时会创建一个 ViewportOffset 对象 (实际上是 ScrollPosition 的实例),并将其作为 参数 传递到这个回调中,回调执行后返回 Viewport 或其它自定义组件,需要通过这个 position 参数来决定其它子组件 (通常是 Sliver) 的布局偏移。

controllerScrollController?

可选的【外部滚动控制器】继承 ChangeNotifier,可以通过它来监听滚动位置、驱动滚动。多个 Scrollable 还可以共享 同一个 controller 来实现同步滚动效果。不传这个参数 (null),Scrollable 会在内部自动创建一个 ScrollController,如果自己创建了 ScrollController,记得在 Statedispose() 中销毁它。它的构造函数:

class ScrollController extends ChangeNotifier {
    ScrollController({
      double initialScrollOffset = 0.0,// 初始滚动偏移量,默认为 0.0
      this.keepScrollOffset = true,
      this.debugLabel,
      this.onAttach,
      this.onDetach,
  })
}

typedef ScrollControllerCallback = void Function(ScrollPosition position);

参数详解

  • keepScrollOffset:是否使用 PageStorage 保存滚动位置,默认true, 当 ScrollController 被附加到多个滚动视图时非常有用,它决定了控制器是否应该在切换附加对象时,尝试保持当前的 scrollOffset
  • onAttachScrollControllerCallback? ,当一个 ScrollPosition 被附加到 ScrollController 时会触发这个回调,可在回调中可以获得刚附加的 ScrollPosition对象,进行一些初始化操作或记录。
  • onDetach:ScrollControllerCallback? ,当一个 ScrollPosition 从 ScrollController 中分离时会触发这个回调。可在回调中可以获得即将被分离的 ScrollPosition对象,进行一些清理或记录。

属性/方法

// 当前附加的所有 ScrollPosition 对象
Iterable<ScrollPosition> get positions => _positions;

// 是否有附加的滚动视图
bool get hasClients => _positions.isNotEmpty;

// 获取唯一的 ScrollPosition(仅在单个视图时使用)
ScrollPosition get position {return _positions.single}

// 当前滚动偏移量 (滚了多少像素)
double get offset => position.pixels;

// ✨ 滚动控制方法
// 动画滚动到指定位置,参数:偏移量、动画时长和曲线
// 顶部 (position为0),底部 (最大滚动距离-_scrollController.position.maxScrollExtent)
Future<void> animateTo(double offset, {
  required Duration duration,
  required Curve curve,
});

// 立即跳转到指定位置 (没有动画效果)
void jumpTo(double value);

// ✨ 添加滚动监听
_scrollController.addListener(() {
  print('offset: ${_scrollController.position.pixels}');
});

💡 注:要精确地判断滑动状态,推荐使用 NotificationListener ,它比 ScrollControlleraddListener 提供了更丰富、更具体的事件信息,如滚动停止。另外,上拉加载更多 (滑动到底部,用户还往上拉),常见的错误做法:在 addListener 里判断 position.pixels == position.maxScrollExtent,这只在到达底部时触发一次,而不是在到达底部后继续拉动时触发。正确的做法是:监听 OverscrollNotification,当用户试图滚动超过 maxScrollExtent 时,就会触发这个通知。

ScrollController 内部维护了一个 ScrollPosition 列表 _positionsScrollPosition 存储了 "单个滚动视图" 的 "状态信息 & 控制逻辑"。

abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
  required this.physics,         // 滚动的物理模拟效果
  required this.context,         // 滚动上下文
  this.keepScrollOffset = true,  // 是否通过 PageStorage 保存和恢复滚动位置
  ScrollPosition? oldPosition,   // 用于在 Widget 重建时迁移状态
  String? debugLabel,            // 调试标签
}

参数详解:

  • contextScrollContext,充当 ScrollableScrollPosition 间的桥梁,为滚动操作提供必要的 上下文信息。包括:分发ScrollNotification-notificationContext、搜索PageStorage-storageContext、动画支持-vsync、滚动方向-axisDirection、设备像素比-devicePixelRatio。除此之外,还提供了交互控制相关的方法:是否忽略指针事件-setIgnorePointer()、是否可以拖拽-setCanDrag()、setPixels(double offset)-当 ScrollPosition 内部的 pixels 值改变时,用它来通知 Viewport 更新其偏移量,从而真正移动视图。
  • oldPositionScrollPosition? ,当 Scrollable 的配置发生变化 (如ScrollController 或 ScrollPhysics 被替换) 时,ScrollableState 会创建一个新的 ScrollPosition,oldPosition 参数允许新 Position 从 旧 Position "吸收(absorb) " 状态,如当前的 pixels值,从而实现平滑过渡。

属性/方法:

// ================ 🔔 存储滚动核心数据 ================ 
double pixels: 最核心的属性。表示当前滚动位置距离滚动起点的偏移量(逻辑像素)。
double minScrollExtent: 最小滚动范围。通常是 0.0double maxScrollExtent: 最大滚动范围。它由 (内容总高度 - 视口高度) 计算得出。
double viewportDimension: 视口(可见区域)在滚动轴上的尺寸。例如,对于一个垂直滚动的 ListView,这就是它的高度。
double extentBefore: 在视口之前(上方或左方)的不可见区域的长度。等于 pixels - minScrollExtent。
double extentInside: 在视口内部的区域长度,就是 viewportDimension。
double extentAfter: 在视口之后(下方或右方)的不可见区域的长度。
bool outOfRange: 当前 pixels 是否超出了 minScrollExtent 和 maxScrollExtent 的范围。
bool atEdge: 当前 pixels 是否正好等于 minScrollExtent 或 maxScrollExtent。
  
Axis axis: 滚动的轴(Axis.vertical 或 Axis.horizontal)。
- ScrollDirection userScrollDirection: 用户最近一次的滚动方向。有四个值:
- ScrollDirection.idle: 静止。
- ScrollDirection.forward: 向前滚动(例如,在垂直列表中向上滚动,内容向下移动)。
- ScrollDirection.reverse: 向后滚动(例如,在垂直列表中向下滚动,内容向上移动)。
- 这个属性对于实现"滚动时隐藏/显示AppBar或FAB"等效果至关重要。

ScrollActivity activity: 内部状态机。表示当前滚动正在进行的"活动"。它是一个抽象类,常见的实现有:
- IdleScrollActivity: 静止状态。
- DragScrollActivity: 用户手指按住并拖动时的活动。
- BallisticScrollActivity: 用户手指抬起后,列表根据速度进行惯性滚动的活动。

// ================ 🎪 核心滚动操作 ================ 
jumpTo(double value): 瞬间将滚动位置改变到指定值,不带动画。
animateTo(double to, ...): 以动画方式将滚动位置平滑移动到指定值。
moveTo(double to, ...): jumpTo 和 animateTo 的内部实现,负责应用新的像素值。
correctBy(double correction): 当滚动位置不正确时(例如,子元素增删导致 maxScrollExtent 变化),用于修正 pixels。
applyNewDimensions(): 当视口或内容尺寸变化时,被调用来更新 min/maxScrollExtent 等维度信息,并可能修正 pixels。

// ================ 🎪 驱动滚动物理和动画 ================ 
它内部持有一个 ScrollPhysics 对象,用于决定滚动的物理行为(如在滚动到边缘时是阻尼效果还是回弹效果)。
它使用 Ticker 来驱动滚动动画,如用户手指离开屏幕后的惯性滚动(Fling/Ballistic)。
它定义了如 goIdle()、goBallistic(double velocity) 等方法来控制滚动的动态行为。

// ================ 🎪 通知机制 ================ 
// 它继承自 ChangeNotifier,当 pixels 发生变化时,
// 会调用 notifyListeners()。ScrollController 就是通过监听这个通知来得知滚动位置的变化。
// 它负责向上冒泡派发 ScrollNotification
// (如 ScrollStartNotification, ScrollUpdateNotification, ScrollEndNotification 等),
// 让父组件(如 NotificationListener)可以监听到滚动事件。
  
// 💡 可以调用 controller.positions 属性来访问这个ScrollPosition列表
// 当控制器只附加到一个滚动视图上时,你可以直接调用 controller.offset 来获取ScrollPosition对象
// 实际上返回的:controller.position.pixels。

Scrollable 里还有一个 scrollBehavior 属性,也顺带提一嘴。ScrollBehavior 用于【统一整个App或子树中所有滚动组件的默认行为】。当一个 可滚动的Widget 被创建时,它会调用 ScrollConfiguration.of() 向上查找离它最近的 ScrollConfiguration,并返回其 behavior 对象,Scrollable 根据它来配置自己的滚动物理效果、滚动条演示、越界指示器、指针设备交互等。构造方法:

// 构造方法,没有任何参数,创建的是一个不可变(immutable)的对象。
ScrollBehavior copyWith({
  bool? scrollbars,           // 是否显示滚动条
  bool? overscroll,          // 是否显示过度滚动效果
  Set<PointerDeviceKind>? dragDevices,        // 支持拖拽的设备类型
  MultitouchDragStrategy? multitouchDragStrategy,  // 多点触控策略
  Set<LogicalKeyboardKey>? pointerAxisModifiers,   // 滚动轴修饰键
  ScrollPhysics? physics,     // 滚动物理特性
  TargetPlatform? platform,   // 目标平台
})

2.1.2. ScrollableState

Scrollable 的核心逻辑都在 ScrollableState 中,内部维护一个 ScrollPosition 类型的 _position 属性,所有对滚动的操作,最终都作用于这个属性。ScrollPosition 是抽象类,在 ScrollableState 里的具体实现是 ScrollPositionWithSingleContext,核心实现:

  • 与 Scrollable 建立连接:"Single Context" 指的是它持有一个 "ScrollableState" 引用,通过它可以获取到:ScrollPhysics、BuildContext、TickerProvider 及 axisDirection。
  • 与 Viewport 通信:applyNewDimensions() 的具体实现就是通过 ScrollableState 找到对应的 Viewport,并从 Viewport 获取 minScrollExtent/maxScrollExtent,这是 ScrollPosition 获得滚动范围信息的关键。
  • 处理用户输入:实现了 beginActivity() 和 applyUserOffset() 等方法,当用户在 Scrollable 上拖动时,Scrollable 会将 拖动偏移量(delta) 传递给 applyUserOffset,然后 ScrollPositionWithSingleContext 会根据 ScrollPhysics 的规则更新 pixels。并且在用户开始拖动时会创建一个 DragScrollActivity 来响应用手操作。当用户松开手指时,调用 goBallistic() 来启动一个 BallisticScrollActivity,实现惯性滚动。
  • 管理生命周期:当一个 ScrollController 被附加到 Scrollable 上时,ScrollableState 会调用 attach() ,将自己作为参数传递给 ScrollPosition,从而完成绑定,解绑时则调用 death()。

ScrollPositionWithSingleContext 是实际工作的 滚动引擎,连接 ScrollController的意图Scrollable视图表现 的桥梁与执行者。ScrollableState 的核心方法如下:

class ScrollableState extends State<Scrollable> with , RestorationMixin
    implements ScrollContext {
  
      // 类似于 Theme.of(context)。它允许子组件沿着组件树向上查找到最近的 ScrollableState 实例
      static ScrollableState? of(BuildContext context):

      // 手势识别器 (DragGestureRecognizer) 的回调,当手势发生时,这些方法被调用,然后它们会调用
      // position.drag() 和 position.dragEnd(),将手势的细节传递给 ScrollPosition。
      _handleDragStart, _handleDragUpdate, _handleDragEnd

      // 用户如果没提供controller,创建一个备用的ScrollController实例
      initState()

      // 调 _updatePosition() → controller.createScrollPosition() 创建
      // ScrollPositionWithSingleContext 实例。controller.attach(position)。
      didChangeDependencies()

      // 构建Viewport
      Widget build(BuildContext context){
        // 套_ScrollableScope (继承InheritedWidget,便于实现 Scrollable.of() 查找 ScrollableState)
        Widget result = _ScrollableScope(          
          scrollable: this,
          position: position,
          // 套Listener-监听指针信号、套 RawGestureDetector-手势识别
          child: Listener(                         
            onPointerSignal: _receivedPointerSignal,
            child: RawGestureDetector(             
              gestures: _gestureRecognizers,
              child: Semantics(
                // 套IgnorePointer-忽略指针事件让其直接穿透到下层
                child: IgnorePointer(              
                  ignoring: _shouldIgnorePointer,
                  child: widget.viewportBuilder(context, position), // ✨ 实际内容视口
                ),
              ),
            ),
          ),
        );
        result = _buildChrome(context, result);  // 添加滚动条、过度滚动指示器等
        ...
      }
}

😄 在 ScrollableState.build() 中调用了 Scrollable 构造方法中传入的 viewportBuilder 回调,在创建 Viewport Widget 的同时传入了 position,使得 Viewport 可以根据 position.pixels 来计算子元素的偏移量,从而实现滚动效果。梳理下方法调用的流程,先是 初始化 (选择 ScrollController → 配置 Physics → 处理 ScrollPostion):

Widget 创建
    ↓
ScrollableState.initState()
    ↓
检查 widget.controller
    ├─ 如果为 null → 创建 _fallbackScrollController = ScrollController()  // 第629行
    └─ 如果不为 null → 使用提供的 controller
    ↓
super.initState()
    ↓
didChangeDependencies()
    ↓
获取 MediaQuery 设置和设备像素比
    ↓
_updatePosition()
    ↓
获取 ScrollConfiguration 和基础 ScrollPhysics
    ↓
检查自定义 physics
    ├─ 有 widget.physics → _physics = widget.physics!.applyTo(_physics)
    ├─ 有 widget.scrollBehavior → 应用 scrollBehavior 的 physics
    └─ 都没有 → 使用默认 physics
    ↓
检查旧的 _position
    ├─ 存在旧 position → detach 旧 position → scheduleMicrotask 销毁
    └─ 不存在 → 直接进入下一步
    ↓
_effectiveScrollController.createScrollPosition(_physics!, this, oldPosition)
    ↓
_effectiveScrollController.attach(position)
    ↓
初始化完成 ✅

用户手指拖动事件 (状态转换:空闲→Hold→Drag→结束,正常结束-可能惯性滚动、取消操作-立即停止)

用户手指按下屏幕
    ↓
GestureDetector 识别触摸事件
    ↓
_handleDragDown()
    ↓
状态检查 assert(_drag == null && _hold == null)
    ↓
_hold = position.hold(_disposeHold)  // 创建保持控制器
    ↓
停止当前滚动动画
    ↓
用户开始移动手指
    ↓
_handleDragStart()
    ↓
状态检查
    ├─ _hold 可能为 null(用户代码触发了其他活动)→ 直接返回
    └─ _hold 存在 → 继续处理
    ↓
_drag = position.drag(details, _disposeDrag)
    ↓
_hold 自动变为 null(转换为拖拽状态)
    ↓
用户继续拖拽移动
    ↓
_handleDragUpdate() (持续调用)
    ↓
状态检查
    ├─ _drag 为 null(拖拽已结束)→ 不处理
    └─ _drag 存在 → _drag.update(details)
    ↓
ScrollPosition.setPixels()
    ↓
应用 ScrollPhysics 约束
    ↓
更新滚动位置 + 发送 ScrollNotification
    ↓
触发 UI 重建
    ↓
检查用户操作
    ├─ 继续拖拽 → 回到 _handleDragUpdate()
    ├─ 松开手指 → _handleDragEnd()
    └─ 取消操作 → _handleDragCancel()

松开手指分支:
_handleDragEnd()
    ↓
状态检查
    ├─ _drag 为 null → 不处理
    └─ _drag 存在 → _drag.end(details)
    ↓
根据结束速度判断
    ├─ 速度足够大 → 开始惯性滚动 (BallisticScrollActivity)
    └─ 速度不够 → 停止滚动 (IdleScrollActivity)
    ↓
_drag 变为 null
    ↓
拖拽流程完成 ✅

取消操作分支:
_handleDragCancel()
    ↓
检查 _gestureDetectorKey.currentContext
    ├─ 为 null(组件被销毁)→ 直接返回
    └─ 存在 → 继续处理
    ↓
清理状态
    ├─ _hold?.cancel() → _hold = null
    └─ _drag?.cancel() → _drag = null
    ↓
取消流程完成 ✅

数据流向

用户手指拖动
    ↓
DragUpdateDetails.delta (比如: Offset(0, -10))
    ↓
ScrollDragController.update()
    ↓
ScrollPosition.setPixels(oldPixels + delta)
    ↓
position.pixels 变化 (100.090.0)
    ↓
didUpdateScrollPositionBy(-10.0)
    ↓
dispatchScrollStartedNotification 发送通知
    ↓
notifyListeners 通知监听器,ViewportOffset 继承自 ChangeNotifier
    ↓
┌─────────────────────┬─────────────────────┬─────────────────────┐
│   Viewport重新布局  │   Scrollbar更新位置 │   子组件可见性变化   │
└─────────────────────┴─────────────────────┴─────────────────────┘
    ↓                     ↓                     ↓
RenderSliver计算可见范围   滚动条thumb位置更新     Widget build/dispose
    ↓                     ↓                     ↓
子组件的renderObject更新   滚动条重绘             新的UI呈现给用户

2.2. Sliver - 滑动片段

"Sliver" 不是具体的类,而是一个协议/概念,它是 RenderSliver 和它的容器 (通常是 RenderViewport) 间沟通的方式。这个协议主要由 两个核心数据结构 + performLayout() 构成。

2.2.1. 输入-SliverConstraints

RenderViewport (滚动视口) 传递给 RenderSliver 的 "布局约束信息"。它告诉 Sliver:"这是你当前所处的环境,请根据这些信息结算你的布局"。SliverConstraints 的关键属性:

// ================ 🔄 滚动相关 ================

// 当前滚动偏移量,Sliver 根据它来判断自己哪一部分是可见的。
final double scrollOffset;

// 前面所有 Sliver 消耗的滚动距离总和。用于计算当前 Sliver 在整个可滚动区域中的起始位置。
final double precedingScrollExtent;  

// 剩余可绘制的像素数量。Sliver 应该根据这个值来决定绘制多少内容,不应该超过这个限制
final double remainingPaintExtent; 

// 前一个 Sliver 重叠的像素数量,当前一个 Sliver 的 paintExtent > layoutExtent 时
// 会产生重叠,通常用于固定头部等效果。如:SliverAppBar 收起时会与列表重叠
final double overlap;

// ================ 🎯 视口和缓存相关 ================

// 视口在主轴方向上的像素数量,对垂直列表来说,就是视口的高度。
final double viewportMainAxisExtent;

// 缓存区域的起始位置,相对于 scrollOffset。总是负数或零,
// 表示需要在当前可见区域之前预渲染多少内容。
final double cacheOrigin;

// 剩余缓存区域的大小。Sliver 应该从 cacheOrigin 开始,
// 尽可能提供 remainingCacheExtent 范围内的内容以优化滚动性能。
final double remainingCacheExtent;

// ================ 🧭 坐标系统信息 ================

// 滚动方向,决定了 scrollOffset 和 remainingPaintExtent 的增长方向
final AxisDirection axisDirection;  

// Sliver 内容的排列方向,相对于 axisDirection 而言,forward-相同,reverse反向
final GrowthDirection growthDirection;  

// 用户滚动的方向,用于判断用户是在向前滚动还是向后滚动,某些Sliver(如浮动头部)会根据此信息调整行为
final ScrollDirection userScrollDirection; 

// 交叉轴的可用空间。对于垂直列表来说就是宽度,对于水平列表来说就是高度。
final double crossAxisExtent;

// 交叉轴的方向。通常用于垂直列表中描述文字方向是从左到右还是从右到左。
final AxisDirection crossAxisDirection; 

2.2.2. 输出-SliverGeometry

RenderSliverperformLayout() 被调用后,它必须计算并设置自己的 geometry 属性,这是它返回给 RenderViewport 的 "布局结果"。SliverGeometry 的关键属性:

class SliverGeometry {
  // ================ 🎨 核心尺寸信息 ================

  // Sliver 总的可滚动范围,表示用户需要滚动多少距离才能从这个 Sliver 的开始滚动到结束。
  final double scrollExtent;

  // 当前实际绘制的像素范围,表示这个 Sliver 在当前滚动位置下实际占用的可见区域大小。
  final double paintExtent;

  // 布局占用的空间大小,决定下一个 Sliver 的布局位置,默认等于 paintExtent。
  // 当需要 "挤压"后续 Sliver 时会小于 paintExtent。
  final double layoutExtent;

  // 该 Sliver 能够绘制的最大范围。用于支持收缩包装的视口,
  表示如果有无限空间时这个 Sliver 最多能绘制多大。
  final double maxPaintExtent;

  // 当 Sliver 被固定在边缘时,能够阻挡内容滚动的最大范围,应用栏就是最典型的例子。
  final double maxScrollObstructionExtent;
  
  // ================ 📦 位置和交互信息 ================

  // 交叉轴占用的空间大小,如果为null,则使用约束中的 crossAxisExtent。
  // 用于某些需要自定义交叉轴大小的 Sliver。
  final double crossAxisExtent;
  
  // 绘制起始位置的偏移量,如果 Sliver 想要在其布局位置之前开始绘制 
  // (如阴影效果),这个值就是负数
  final double paintOrigin;

  // 可以响应点击事件的范围,默认等于 paintExtent,但某些情况下
  // 可能需要扩大或缩小点击区域。
  final double hitTestExtent;

  // 缓存区域消耗的大小。表示这个 Sliver 从剩余缓存区域中消耗了多少空间,用于优化滚动性能。
  final double cacheExtent;
  
  // ================ 🔧 状态信息 ================

  // 该 Sliver 是否应该被绘制。默认情况下,paintExtent > 0 时为 true,否则为 false。
  final bool visible;

  // 是否有视觉溢出。如果为 true,视口需要对子组件进行裁剪以避免内容溢出到视口边界之外。
  final bool hasVisualOverflow;

  // 滚动偏移修正值。如果不为 null,父组件会调整滚动位置并重新布局。
  // 用于处理滚动位置需要修正的特殊情况。
  final double? scrollOffsetCorrection;
}

2.2.3. 协议流程总结

  • RenderViewport子RenderSliver.layout() ,并传入 SliverConstraints (输入)。
  • RenderSliver.performLayout() 被触发,RenderSliver 的具体子类 (如RenderSliverList) 会根据 SliverConstraints 计算出自己需要展示哪些子元素、它们的位置,并最终计算出一个 SliverGeometry (输出)。
  • RenderSliver 将计算好的 SliverGeometry 赋值给自己的 geometry 属性。
  • RenderViewport 读取 geometry,从而知道这个 Sliver 占了多少空间、下一个 Sliver 应该从哪里开始布局等信息,然后继续布局下一个 Sliver。

RenderSliver子类.performLayout() 中进行 布局计算 的示例代码:

void performLayout() {
  // 1. 接收约束
  final SliverConstraints constraints = this.constraints;
  
  // 2. 分析滚动状态
  final double scrollOffset = constraints.scrollOffset;
  final double remainingExtent = constraints.remainingPaintExtent;
  
  // 3. 计算内容布局
  // ... 具体的布局逻辑 ...
  
  // 4. 生成几何信息
  geometry = SliverGeometry(
    scrollExtent: totalContentHeight,      // 总内容高度
    paintExtent: visibleContentHeight,     // 可见内容高度
    layoutExtent: layoutContentHeight,     // 布局影响高度
    maxPaintExtent: maxContentHeight,      // 最大绘制高度
    hasVisualOverflow: hasOverflow,        // 是否溢出
  );
}

梳理下 RenderSliver 的子类们:

💡 Tips:Sliver 组件们:ListView → SliverList、GridView → SliverGridSliverToBoxAdapter (用于将一个普通Box组件进行Sliver适配)、SliverAppBarSliverPersistentHeader(吸顶头部)、SliverFillRemaining (填充视口剩余空间)、SliverPadding (为Sliver添加内边距)、SliverLayoutBuilder (可以根据 Sliver 的几何信息来构建其子组件) 等。

2.3. Viewport - 视口管理器

继承 MultiChildRenderObjectWidget (多子Widget),实现 "懒加载/按需渲染" 滚动视图的基石,主要职责:

  • 管理可见区域显示:根据自身尺寸和给定的偏移量显示子组件的子集,只渲染在视口范围内可见的 Sliver子组件,而不是全部内容。
  • 协调滚动偏移:接收并处理 ViewportOffset 传递的滚动偏移量信息,随着偏移量的变化,动态调整哪些子组件在视口中可见。
  • 实现高效的布局算法:采用视口感知的布局协议,向 Sliver子组件 传递约束信息,包含可见空间剩余量等视口相关信息,使子组件能够智能地决定渲染内容。
  • 支持无限滚动机制:通过按需构建机制,只创建当前可见的 Widget,在布局阶段与构建阶段交错进行,实现高性能的无限列表。
  • 处理不同类型的 Sliver 组合:统一管理线性列表、网格、可折叠头部等不同类型的 Sliver,通过 Sliver布局协议 协调各种滚动效果,如视差滚动、折叠头部等。
  • 维护渲染边界和裁剪:定义内容的可视边界,对超出视口的内容进行裁剪,管理重绘边界,优化渲染性能。

构造函数

Viewport({
  super.key,
  this.axisDirection = AxisDirection.down,// 主轴方向,决定了滚动的方向和布局的起点。
  this.crossAxisDirection,// 交叉轴方向,它会影响子项在交叉轴上的布局顺序。
  this.anchor = 0.0,// 锚点
  required this.offset,// 滚动偏移控制器,通常由ScrollPosition实现。
  this.center,// 中心子项的Key
  this.cacheExtent,// 缓存区域大小
  this.cacheExtentStyle = CacheExtentStyle.pixel,// 缓存区域计算方式
  this.clipBehavior = Clip.hardEdge,// 超出Viewport边界内容的裁剪行为
  List<Widget> slivers = const <Widget>[],// 子组件列表
})

属性详解

  • anchor: double,表示视口中的"零点" (scrollOffset为0.0的点) 在视口自身中的位置比例。0.0-视口的顶部(或左侧) 是滚动偏移的零点,当 scrollOffset 为 0 时,内容的开头对齐视口的开头。1.0-视口的底部 (或右侧) 是滚动偏移的零点。0.5-视口的中心是滚动偏移的零点。anchor 的改变会影响内容如何从 center key 开始向两侧填充。对于反向列表(reverse:true),ListView会将其设置为1.0。
  • center:Key?, 中心子项的Key,这是一个优化参数。当 Viewport 首次布局时,它会尝试找到这个 Key 对应的 Sliver,并假定它位于 scrollOffset 为 0.0 的位置。这主要有两个用途:快速定位-在拥有大量数据时,可以快速定位到初始显示位置,而无需从头开始构建。布局稳定性-当 Viewport 的尺寸变化时 (如屏幕旋转) 通过 center key 可以保持同一个子项在视口中的相对位置,防止列表"跳动"。
  • cacheExtent:doube?,指定在视口可见区域之外,上下(或左右) 应该预先构建和布局的区域大小。如:如果视口高度为 600px,cacheExtent 为 200px,那么系统会渲染从 -200px 到 800px 这个范围内的列表项。
  • cacheExtentStyleCacheExtentStyle,缓存单位,pixel-默认,逻辑像素,viewport-视口大小的倍数,如:cacheExtent 为 1.0 意味着在视口上方和下方各缓存一个视口高度的区域。
  • slivers:List,只能放 Sliver 类型的 Widget, 如:SliverList、SliverGrid、SliverAppBar, SliverToBoxAdapter 等。

核心方法

// Widget 层和 RenderObject 层的连接点。Viewport Widget 
// 调用此方法来创建一个 RenderViewport 实例,并将构造函数中的所有参数传递给它。
createRenderObject(BuildContext context)

// 当 Viewport Widget 的配置发生变化时(例如 axisDirection 改变),此方法会被调用,
// 用新的配置去更新已存在的 RenderViewport 对象。
updateRenderObject(BuildContext context, RenderViewport renderObject)

// 创建 MultiChildRenderObjectElement,这是 Widget 在元素树中的表示。
createElement()

Viewport渲染树 中有两个主要实现:

  • RenderViewport:标准视口 RenderObject,会扩展填充整个主轴空间。
  • RenderShrinkWrappingViewport:收缩包装视口 RenderObject,会根据其子组件在主轴上的大小来调整自身大小。
// 主轴上占据所有空间
RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData>{}

abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMixin<RenderSliver>>
    extends RenderBox with ContainerRenderObjectMixin<RenderSliver, ParentDataClass>
    implements RenderAbstractViewport { ... }

// 主轴上所有以包裹其内容 (shrinkWrap: true)
class RenderShrinkWrappingViewport extends RenderViewportBase<SliverLogicalContainerParentData> { ... }

父类 RenderViewportBase 继承了 RenderBox,这表明 RenderViewport 自身遵循 盒子模型 (父节点会给他一个 BoxConstraints,它必须在此约束下确定自己的size),而管理的子节点列表则必须是 RenderSliver 类型,😄 可以把 RenderViewport 看作是 盒子协议 & Sliver协议 间的桥梁。具体的方法调用链路:

// ================ 🚀 初始化 ================

RenderViewport 构造函数
    ↓
调用父类 RenderViewportBase 构造
    ├─ 设置 axisDirection 和 crossAxisDirection // 设置主轴和交叉轴方向
    ├─ 绑定 ViewportOffset // 绑定滚动偏移控制器
    └─ 初始化 cacheExtent 和 clipBehavior // 设置缓存范围和裁剪行为
    ↓
设置 RenderViewport 特有属性
    ├─ _anchor = anchor // 设置锚点位置(0.0-1.0)
    └─ _center = center // 设置中心sliver
    ↓
addAll(children) // 批量添加子元素
    └─ adoptChild() // 为每个子元素建立父子关系
        ├─ setupParentData() // 设置 SliverPhysicalContainerParentData
        ├─ attach() // 将子元素附加到渲染管道
        └─ markNeedsLayout() // 标记需要重新布局
    ↓
设置默认center
    ├─ center == null && firstChild != null → _center = firstChild
    └─ 否则 → 保持原有center设置

attach() // 将视口附加到渲染管道
├─ super.attach(owner) // 调用父类attach方法
├─ _offset.addListener(markNeedsLayout) // 监听滚动偏移变化
└─ 递归调用子元素attach() // 确保所有子元素都正确附加

// ================ 📦 布局 ================

performLayout() // 布局入口
    ↓
应用视口尺寸到偏移控制器
    ├─ axis == Axis.vertical → offset.applyViewportDimension(size.height)
    └─ axis == Axis.horizontal → offset.applyViewportDimension(size.width)
    ↓
检查是否有子元素
    ├─ center == null → 设置滚动范围为0并返回
    │   ├─ _minScrollExtent = 0.0
    │   ├─ _maxScrollExtent = 0.0
    │   ├─ _hasVisualOverflow = false
    │   └─ offset.applyContentDimensions(0.0, 0.0)
    └─ 有子元素 → 继续布局流程
    ↓
计算布局参数
    ├─ (mainAxisExtent, crossAxisExtent) = switch (axis) // 计算主轴和交叉轴尺寸
    ├─ centerOffsetAdjustment = center!.centerOffsetAdjustment // 获取中心偏移调整
    └─ maxLayoutCycles = _maxLayoutCyclesPerChild * childCount // 设置最大布局循环次数
    ↓
执行布局循环
    ├─ _attemptLayout() // 尝试布局所有子元素
    │   ├─ 重置布局数据
    │   │   ├─ _minScrollExtent = 0.0
    │   │   ├─ _maxScrollExtent = 0.0
    │   │   └─ _hasVisualOverflow = false
    │   ├─ 计算中心偏移和绘制范围
    │   │   ├─ centerOffset = mainAxisExtent * anchor - correctedOffset
    │   │   ├─ reverseDirectionRemainingPaintExtent = clampDouble(centerOffset, 0.0, mainAxisExtent)
    │   │   └─ forwardDirectionRemainingPaintExtent = clampDouble(mainAxisExtent - centerOffset, 0.0, mainAxisExtent)
    │   ├─ 计算缓存范围
    │   │   ├─ _calculatedCacheExtent = switch (cacheExtentStyle) // 根据缓存样式计算实际缓存大小
    │   │   └─ 计算各方向的缓存范围
    │   ├─ layoutChildSequence(反向子元素) // 布局center之前的子元素
    │   │   ├─ child.layout(SliverConstraints) // 为每个子sliver执行布局
    │   │   ├─ updateChildLayoutOffset() // 更新子元素的布局偏移
    │   │   │   └─ childParentData.paintOffset = computeAbsolutePaintOffset()
    │   │   └─ updateOutOfBandData() // 更新滚动范围等额外数据
    │   │       ├─ GrowthDirection.reverse → _minScrollExtent -= scrollExtent
    │   │       └─ hasVisualOverflow → _hasVisualOverflow = true
    │   └─ layoutChildSequence (正向子元素) // 布局center及之后的子元素
    │       ├─ child.layout(SliverConstraints)// ✨ 给每个子 Sliver下发约束,执行布局
    │       ├─ SliverGeometry childLayoutGeometry = child.geometry! // ✨ 获得Sliver 返回的几何信息
    │       ├─ updateChildLayoutOffset() // 记录每个 Sliver 的绘制位置
    │       └─ updateOutOfBandData() // 基于每个 Sliver 的反馈更新全局信息
    │           ├─ GrowthDirection.forward → _maxScrollExtent += scrollExtent// 累计最大滚动范围
    │           └─ hasVisualOverflow → _hasVisualOverflow = true// 判断是否有溢出
    ├─ correction != 0.0 → offset.correctBy(correction) // 修正滚动偏移量
    └─ correction == 0.0 → offset.applyContentDimensions() // 应用最终的内容尺寸范围

// ================ 🎨 绘制 ================

paint() // 绘制入口
    ↓
检查是否有子元素
    ├─ firstChild == null → 直接返回
    └─ 有子元素 → 继续绘制流程
    ↓
检查是否需要内容裁剪
    ├─ hasVisualOverflow && clipBehavior != Clip.none → 创建裁剪区域
    │   └─ _clipRectLayer.layer = context.pushClipRect() // 创建裁剪图层
    │       ├─ needsCompositing // 检查是否需要合成
    │       ├─ Offset.zero & size // 设置裁剪矩形
    │       ├─ _paintContents // 绘制内容回调
    │       └─ oldLayer: _clipRectLayer.layer // 复用旧图层
    └─ 无溢出或不裁剪 → 直接绘制内容
        ├─ _clipRectLayer.layer = null // 清理裁剪图层
        └─ _paintContents(context, offset) // 直接绘制内容
    ↓
_paintContents() // 绘制所有可见内容
    └─ 遍历 childrenInPaintOrder // 按绘制顺序处理子元素
        ├─ child.geometry!.visible → 检查子元素是否可见
        └─ context.paintChild(child, offset + paintOffsetOf(child)) // 绘制每个可见的子元素
            ├─ paintOffsetOf(child) // 获取子元素绘制偏移
            │   └─ return childParentData.paintOffset
            └─ 应用变换矩阵并绘制

😶 老规矩画个图帮助理解:

😏 原理学完,动手缝合下三个构件,实现一个 最简单 的滑动效果【--->c35/simple_scroll_demo.dart<---】:

运行效果

😄 是的,就是这么简单,关于Flutter滑动机制 "三个核心构件" 就了解到这,接着开始学习具体的滑动组件。

3. 常用滑动组件

3.1. SingleChildScrollView

简介

一个用于解决 "内容溢出" 问题的简单 滚动容器Widget,可以让 单个子Widget 在空间不足时进行滚动。

3.1.1. API 详解

继承 StatelessWidget,大部分属性在 Scrollable 那里已经详细讲了,不再赘述,挑几个没讲到的:

  • paddingEdgeInsetsGeometry? ,在滚动区域内部添加内边距 (child外边),边距会随着内容一起滚动。
  • keyboardDismissBehaviorScrollViewKeyboardDismissBehavior,用户与滚动区域交互时,如何以及何时自动收起弹出的键盘。【manual-默认】滚动视图本身不会做任何事情来收起键盘,键盘的收起完全依赖于其他方式,如:回退键、FocusScope.of(context).unfocus() 等。【onDrag】当键盘弹出时,用户在滚动视图上开始拖动 (滚动) 的那一刻,键盘就会自动收起 ✨。
  • primarybool? ,是否使用主滚动控制器,默认null,由系统自动根据上下文自动选择最合适的控制器。为 true 时,使用 主滚动控制器-PrimaryScrollController (不能同时设置自定义controller),🤔 用于页面级别的滚动,需要与其它组件共享滚动状态,在移动平台上它会自动处理一些系统级的交互。如:Android 从屏幕边缘拖动可以触发返回操作,主滚动视图会优先响应滚动。在 iOS 上,点击状态栏可以快速滚动到顶部。在Scaffold中,如果body是一个可滚动组件,当键盘弹出时,会调整滚动区域以保证焦点输入框可见。为 false 时,当一个页面有多个滚动视图时,只能有一个可以是primary,其它都应该显示设置为false。一般用于独立的滚动区域:如对话框、侧边栏。

简单使用示例【--->c35/single_child_scroll_view_demo.dart<---】运行效果:

😄 非常简单,就切滚动物理效果、设置键盘随列表滚动消失、以及快速滑动到底部、中部和顶部。接着提下使用 SingleChildScrollView两个注意事项

与Column 配合使用的冲突

Column 试图占用尽可能多的空间,而 SingleChildScrollView 提供无限空间,这会导致冲突,需要对Column 进行高度约束。可以使用 LayoutBuilder + ConstrainedBox 设置最小高度,或者使用IntrinsicHeight 强制 Column 适应内容大小。

加载机制

SingleChildScrollView 会一次性将它的 child 全部渲染到内存中,而不管这个 child 有多大,它只是在 "视口" 中移动显示。它更适合处理 内容相对固定且不太多 的场景,对于 大量动态内容,还是得选择具有 "懒加载" 特性的滚动组件 (如 Listview),混合布局可以考虑用 CustomScrollView

3.1.2. 源码剖析

关于第一个注意事项 "SingleChildScrollView 提供无限空间",在源码中的体现 (移除了滚动方向的尺寸约束):

接着是第二个 "一次性将child全部渲染到内存中",跟下代码调用:SingleChildScrollViewbuild()

SingleChildScrollViewViewport 具体实现 Widget_SingleChildViewport,对应的 RenderObject_RenderSingleChildViewport

绘制方法

上面通过 pushClipRect() 来显示显示区域 (视觉裁剪) 实现 "窗口效果":

🤔 那 "滚动效果" 呢?通过改变 paintOffset 来移动 子组件的绘制位置

😁 视觉效果 (滑动) 与实际移动反向是 "相反" 的 ❗️ 向下滚动时,子组件是向上移动的,Y轴负值表示向上移动。

初始状态 (position = 0):
┌─────────────────┐ ← 视口顶部
│  子组件内容A     │
│  子组件内容B     │
│  子组件内容C     │
└─────────────────┘ ← 视口底部
│  子组件内容D     │ ← 不可见
│  子组件内容E     │ ← 不可见

向下滚动 (position = 100):
                   ← 子组件内容A (不可见)
┌─────────────────┐ ← 视口顶部
│  子组件内容B     │
│  子组件内容C     │
│  子组件内容D     │
└─────────────────┘ ← 视口底部
│  子组件内容E     │ ← 不可见

😊 可以将 clipBehavior 属性 Clip.none 来验证是否 child 是否真的是 全部渲染【--->c35/single_child_scroll_none_clip_demo.dart<---】运行效果:

3.2. ScrollView

抽象类,Flutter中绝大部分 "可滚动视图" 的 顶层父类,核心思想是 "滚动机制 & 内容布局" 的解耦,定义了一个可滚动区域的通用配置框架,具体如何排列子元素 (列表、网格或是其它形式) 则交由其子类实现。它的使命:

将用户的 滚动意图 (由Scrollable捕获) 转化为 视口内内容的平移 (由Viewport 和 Slivers 实现)。

核心方法

// ❗️ 子类都必须实现,用于返回 Widget列表 (必须是Sliver类型,
// 如:SliverList, SliverGrid, SliverToBoxAdapter),
// ScrollView 会把这个 Sliver 列表交给 Viewport 去渲染.
@protected
List<Widget> buildSlivers(BuildContext context);

// 构建Viewport,根据 shrinkWrap 决定使用哪种 Viewport
@protected  
Widget buildViewport(
  BuildContext context,
  ViewportOffset offset, 
  AxisDirection axisDirection,
  List<Widget> slivers,
){
  if (shrinkWrap) {
      return ShrinkWrappingViewport(...);
  }
  return Viewport();
}

// 结合 scrollDirection 和 reverse,用于获取滚动方向。
@protected
AxisDirection getDirection(BuildContext context) {
  return getAxisDirectionFromAxisReverseAndDirectionality(context, scrollDirection, reverse);
}

// 实现了 StatelessWidget.build()
@override
Widget build(BuildContext context) {
  // 第1步:构建 slivers 列表
  final List<Widget> slivers = buildSlivers(context);
  
  // 第2步:确定滚动方向
  final AxisDirection axisDirection = getDirection(context);

  // 第3步:确定有效的 primary 属性
  final bool effectivePrimary = primary
      ?? controller == null && PrimaryScrollController.shouldInherit(context, scrollDirection);

  // 第4步:获取滚动控制器
  final ScrollController? scrollController = effectivePrimary
      ? PrimaryScrollController.maybeOf(context)
      : controller;

  // 第5步:创建 Scrollable 组件
  final Scrollable scrollable = Scrollable(
    dragStartBehavior: dragStartBehavior,
    axisDirection: axisDirection,
    controller: scrollController,
    physics: physics,
    scrollBehavior: scrollBehavior,
    semanticChildCount: semanticChildCount,
    restorationId: restorationId,
    // 💡 将 Slivers 喂给 Viewport
    viewportBuilder: (BuildContext context, ViewportOffset offset) {
      return buildViewport(context, offset, axisDirection, slivers);
    },
    clipBehavior: clipBehavior,
  );

  // 第6步:处理 PrimaryScrollController 继承
  final Widget scrollableResult = effectivePrimary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;

  // 第7步:处理键盘消失行为
  if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
    return NotificationListener<ScrollUpdateNotification>(
      child: scrollableResult,
      onNotification: (ScrollUpdateNotification notification) {
        final FocusScopeNode focusScope = FocusScope.of(context);
        if (notification.dragDetails != null && focusScope.hasFocus) {
          focusScope.unfocus();
        }
        return false;
      },
    );
  } else {
    return scrollableResult;
  }
}

😄 将通用的滚动逻辑 (视口、控制器、物理效果) 都进行了封装,子类只需实现 buildSlivers()Slivers

3.3. ListView

简介

用于创建 可滚动的线性列表布局 的高级滑动组件。

3.3.1. API 详解

继承 BoxScrollView,同样挑几个属性讲讲:

  • shrinkWrapbool,默认false,尽可能占据父组件在滚动方向上提供的所有空间,如果父组件没限制 (如Column),就会导致无限高度/宽度错误。为true时,尺寸会收缩以包裹其内容的总高度/宽度,但这样会牺牲性能,因为它需要计算所有子项的尺寸 (即便不可见) 来确定自身的总尺寸。故仅在必要时使用,如:在Column 中嵌套一个 ListView 时。
  • itemExtentdouble?子项固定固定高度,如果所有子项都有相同的高度/宽度,设置此属性能极大地提高性能。因为 Listview不再需要动态计算每个子项的尺寸,可以直接算出滚动偏移,从而简化布局过程。
  • itemExtentBuilderItemExtentBuilder?子项高度构建器,其实就一方法回调,有两个参数:index-当前子项索引 和 dimensions-当前滚动视口的尺寸信息,返回值double-子项高度。适用于:当你的列表项目高度是可预知的、有规律的,但又不完全相同的场景,如:奇数index,高度50,偶数index,高度100。
  • prototypeItemWidget? ,列表项高度/宽度基本一致但又不想写死 itemExtent,可以提供一个 原型Widget,ListView 会测量这个原型一次,然后假设所有其他项都具有相同的尺寸。
  • cacheExtentdouble?缓存范围,Viewport 的预加载区域大小 (默认250.0) 增加此值可以减少快速滚动时的空白,但因为会提前构建更多项,所以会增加内存消耗。
  • addAutomaticKeepAlivesbool,默认true,当列表项滚动出视口时,是否自动使用 AutomaticKeepAlive 来保存它们的状态。列表项 的 State 也需要混入 AutomaticKeepAliveClientMixin 重写 wantKeepAlive 返回 true 才会有效,对列表项包含复杂状态 (如输入框内容、动画状态) 时很有用。更复杂的和状态,应使用外部状态管理方案 (如Provider、BLoC、Riverpod等),将状态与 UI 分离。
  • addRepaintBoundaries:bool,默认true,是否为每个列表项自动包裹一个 RepaintBoundary,用于隔离每个列表项的重绘,防止一个项的动画或变化导致整个列表重绘,从而优化性能。

提供了 四种构造方式

// ✨ 默认构造函数,接收一个 List<Widget> 作为 children。
// 内部使用 SliverChildListDelegate,它会一次性构建所有的子 Widget,
// 所以仅适用于【少量、固定的子项】的场景
ListView(children: <Widget>[ Container(), ...])

// ✨ ListView.builder(),最常用、最高性能
// 内部使用 SliverChildBuilderDelegate,它不会立即创建所有列表项,而是通过
// itemBuilder 回调函数,在列表项即将进入视口时才进行构建。适用于【大量或无限子项】的场景
final List<String> entries = List<String>.generate(1000, (i) => 'Item $i');
ListView.builder(
  itemCount: entries.length,// 列表项总数,如果为 null,则表示一个无限列表。
  itemBuilder: (BuildContext context, int index) {
    // 根据索引 index 返回对应的 Widget
    return ListTile(
      title: Text(entries[index]),
    );
  },
)

// ✨ ListView.separated(),builder() 变种,可以方便地在每个列表项之间插入一个分割线 Widget
ListView.separated(
  itemCount: 100,
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text('Item $index'));
  },
  // 根据索引 index 构建位于 item[index] 和 item[index + 1] 间的分割线。
  separatorBuilder: (BuildContext context, int index) {
    return const Divider(color: Colors.grey);
  },
)

// ✨ ListView.custom() 完全自定义,允许你提供一个自定义的 SliverChildDelegate
// 通过「childrenDelegate」参数传入,前三种构造方法其实都是这个构造方法的语法糖。
// 这种构造方式很少直接使用,适用场景:需对子项的创建、销毁、保活等行为进行更精细控制

3.3.2. 源码剖析

默认构造函数,内部使用 SliverChildListDelegate

SliverChildListDelegate extends SliverChildDelegate { 
  SliverChildListDelegate(
    this.children, // 这里是 List<Widget>
    {
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  }) : _keyToIndex = <Key?, int>{null: 0};
}

看下 build() 方法:

"盐值(Salt) " 这个概念来自密码学,指的是在 加密过程中添加的额外随机数据。在这里是给原有Key 添加额外信息,如:原始Key("item_1") 包装成 _SaltedValueKey(Key('item_1')),目的是让这个Key在delegate内部是唯一的,以避免Flutter混乱这些Key,导致状态错乱。

"KeyedSubtree" 是一个特殊的 Widget,它的作用是:为整个子树提供一个稳定的身份标识,帮助Flutter的渲染系统正确追踪Widget,确保当Widget位置变化时,状态能正确保持。"Element复用机制":当Widget第一次显示时,Flutter创建对应的 ElementRenderObject,当Widget重新构建时,Flutter会尝试 复用已有的Element,复用条件是:Widget的runtimeType和key都相同

😶 用的 children,传入时就是已经创建的Widget,所以是一次性构建所有的子 Widget,接着看下 builder() 构建的方式,用的 SliverChildBuilderDelegate

class SliverChildBuilderDelegate extends SliverChildDelegate {
  const SliverChildBuilderDelegate(
    this.builder, // 这里是 NullableIndexedWidgetBuilder
    {
    this.findChildIndexCallback,
    this.childCount,
    this.addAutomaticKeepAlives = true,
    this.addRepaintBoundaries = true,
    this.addSemanticIndexes = true,
    this.semanticIndexCallback = _kDefaultSemanticIndexCallback,
    this.semanticIndexOffset = 0,
  });
}

typedef NullableIndexedWidgetBuilder = Widget? Function(BuildContext context, int index);

看下 build() 方法:

💁‍♂️ separated() 也是用的 SliverChildBuilderDelegatecustom() 则需要通过 childrenDelegate 参数传入自定义的 SliverChildDelegate。继承关系:ListViewBoxScrollViewScrollViewBoxScrollViewScrollView 的基础上增加了 "自动填充padding" (避免状态栏遮挡-垂直滚动自动添加顶部填充、避免底部导航栏-自动添加底部填充、刘海屏适配-自动处理异形屏幕的安全区域)。另外,还对布局构建进行了抽象,子类只需实现 buildChildLayout(BuildContext context) 方法。构建调用链路:

ScrollView.build()
  ↓
BoxScrollView.buildSlivers()
  ↓
ListView.buildChildLayout()// 生成 SliverMultiBoxAdaptorWidget 对象
  ↓ 
BoxScrollView.buildSlivers() // 包装ListView生成的Sliver (如SliverPadding),返回[sliver]
  ↓
ScrollView.build() // 总装配@override
Widget build(BuildContext context) {
  final List<Widget> slivers = buildSlivers(context);  // ② 获取slivers
  final Scrollable scrollable = Scrollable(  // ① 创建Scrollable
    viewportBuilder: (context, offset) => buildViewport(context, offset, axisDirection, slivers),
  );
}

清楚明了,看下 ListView.buildChildLayout() ,根据不同情况,创建了四种类型的 Sliver Widget

👀 跟下这四个 Sliver 到类的具体实现~

3.3.3. SliverList - 动态列表

每个子项都要调用 layout() 方法,需要缓存已测量子项的信息,核心源码:

class SliverList extends SliverMultiBoxAdaptorWidget {
  const SliverList({
    super.key,
    required super.delegate,
  });

  @override
  RenderSliverList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverList(childManager: element);
  }
}

// 底层 RenderObject 核心逻辑
class RenderSliverList extends RenderSliverMultiBoxAdaptor {
  // 🔥 关键:每个子项都需要单独进行布局计算
  @override
  void performLayout() {
    // 1. 遍历每个可见的子项
    // 2. 调用 child.layout() 计算每个子项的实际尺寸
    // 3. 累积计算总的滚动范围
    // 4. 确定每个子项的位置
    
    double scrollOffset = constraints.scrollOffset;
    double remainingExtent = constraints.remainingPaintExtent;
    
    // 💡 关键性能瓶颈:需要逐个测量每个子项的高度
    while (remainingExtent > 0) {
      RenderBox child = getChildAtIndex(index);
      child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
      
      // 📏 每次都要获取子项的实际高度
      double childExtent = getMainAxisExtent(child);
      // ... 位置计算和缓存逻辑
    }
  }
}

3.3.4. SliverFixedExtentList - 固定高度高性能列表

直接通过数学计算确定位置,跳过子项的layout过程,可以精确预测滚动范围,核心源码:

class SliverFixedExtentList extends SliverMultiBoxAdaptorWidget {
  const SliverFixedExtentList({
    super.key,
    required super.delegate,
    required this.itemExtent, // 👈 关键:固定高度
  });

  final double itemExtent; // 🔥 核心:所有子项的固定高度

  @override
  RenderSliverFixedExtentList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverFixedExtentList(
      childManager: element,
      itemExtent: itemExtent, // 传递固定高度
    );
  }
}

// 底层 RenderObject 核心逻辑
class RenderSliverFixedExtentList extends RenderSliverMultiBoxAdaptor {
  final double itemExtent;

  @override
  void performLayout() {
    // 🚀 性能优势:可以直接计算而不需要测量
    
    // 1. 🔥 直接计算可见区域内的项目数量
    double scrollOffset = constraints.scrollOffset;
    int firstVisibleIndex = (scrollOffset / itemExtent).floor();
    int lastVisibleIndex = ((scrollOffset + constraints.viewportMainAxisExtent) / itemExtent).ceil();
    
    // 2. 🚀 直接计算每个子项的位置(无需layout测量)
    for (int index = firstVisibleIndex; index <= lastVisibleIndex; index++) {
      RenderBox child = getChildAtIndex(index);
      
      // 💎 关键优化:强制设置子项高度,跳过子项自己的layout计算
      child.layout(
        constraints.asBoxConstraints(
          minHeight: itemExtent,
          maxHeight: itemExtent, // 强制固定高度
        ),
        parentUsesSize: false, // 💡 不需要获取子项尺寸
      );
      
      // 🎯 直接计算位置:索引 * 固定高度
      double childMainAxisPosition = index * itemExtent - scrollOffset;
      // 设置子项位置...
    }
    
    // 3. 🚀 直接计算总的滚动范围
    geometry = SliverGeometry(
      scrollExtent: childCount * itemExtent, // 直接计算总高度
      paintExtent: math.min(constraints.remainingPaintExtent, maxPaintExtent),
      maxPaintExtent: childCount * itemExtent,
    );
  }
}

3.3.5. SliverPrototypeExtentList - 原型高度列表

支持复杂的Widget作为原型,只需测量原型一次,后续使用固定高度算法,核心源码:

class SliverPrototypeExtentList extends SliverMultiBoxAdaptorWidget {
  const SliverPrototypeExtentList({
    super.key,
    required super.delegate,
    required this.prototypeItem, // 👈 关键:原型Widget
  });

  final Widget prototypeItem; // 🔥 核心:用于测量的原型Widget

  @override
  RenderSliverPrototypeExtentList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverPrototypeExtentList(
      childManager: element,
      prototypeItem: prototypeItem,
    );
  }
}

// 底层 RenderObject 核心逻辑:
class RenderSliverPrototypeExtentList extends RenderSliverMultiBoxAdaptor {
  Widget _prototypeItem;
  double? _prototypeExtent; // 缓存原型的高度
  
  @override
  void performLayout() {
    // 🎨 首次计算:测量原型Widget的高度
    if (_prototypeExtent == null) {
      _prototypeExtent = _measurePrototype();
    }
    
    // 💎 后续逻辑与SliverFixedExtentList类似
    double itemExtent = _prototypeExtent!;
    
    // 🚀 使用固定高度的高性能算法
    int firstVisibleIndex = (constraints.scrollOffset / itemExtent).floor();
    int lastVisibleIndex = ((constraints.scrollOffset + constraints.viewportMainAxisExtent) / itemExtent).ceil();
    
    for (int index = firstVisibleIndex; index <= lastVisibleIndex; index++) {
      RenderBox child = getChildAtIndex(index);
      
      // 🔥 强制使用原型高度
      child.layout(
        constraints.asBoxConstraints(
          minHeight: itemExtent,
          maxHeight: itemExtent,
        ),
        parentUsesSize: false,
      );
    }
  }
  
  // 🎨 原型测量方法
  double _measurePrototype() {
    // 1. 创建原型Widget的RenderObject
    RenderBox prototypeRenderBox = _createPrototypeRenderBox();
    
    // 2. 对原型进行layout测量
    prototypeRenderBox.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    
    // 3. 获取原型的主轴高度
    double extent = getMainAxisExtent(prototypeRenderBox);
    
    // 4. 清理原型RenderObject
    prototypeRenderBox.dispose();
    
    return extent;
  }
}

3.3.6. SliverVariedExtentList - 预定义不同高度列表

适用于高度已知,无需子项自己测量,itemExtentBuilder会被多次调用,计算过的高度会被缓存,核心源码:

class SliverVariedExtentList extends SliverMultiBoxAdaptorWidget {
  const SliverVariedExtentList({
    super.key,
    required super.delegate,
    required this.itemExtentBuilder, // 👈 关键:高度构建器
  });

  // 🔥 核心:根据索引和维度信息返回高度的回调
  final ItemExtentBuilder itemExtentBuilder;

  @override
  RenderSliverVariedExtentList createRenderObject(BuildContext context) {
    final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
    return RenderSliverVariedExtentList(
      childManager: element,
      itemExtentBuilder: itemExtentBuilder,
    );
  }
}

// 底层 RenderObject 核心逻辑:
class RenderSliverVariedExtentList extends RenderSliverMultiBoxAdaptor {
  final ItemExtentBuilder itemExtentBuilder;
  
  // 🔧 高度缓存机制
  final Map<int, double> _cachedExtents = <int, double>{};

  @override
  void performLayout() {
    double scrollOffset = constraints.scrollOffset;
    double remainingExtent = constraints.remainingPaintExtent;
    
    // 📊 混合策略:预计算 + 按需计算
    int currentIndex = 0;
    double currentOffset = 0;
    
    // 1. 🔍 找到第一个可见的子项(通过累积高度)
    while (currentOffset < scrollOffset) {
      double itemHeight = _getItemExtent(currentIndex);
      currentOffset += itemHeight;
      currentIndex++;
    }
    
    // 2. 🎯 渲染可见区域内的子项
    while (remainingExtent > 0 && currentIndex < childCount) {
      double itemHeight = _getItemExtent(currentIndex);
      
      RenderBox child = getChildAtIndex(currentIndex);
      // 💡 性能优化:使用预定义的高度约束子项
      child.layout(
        constraints.asBoxConstraints(
          minHeight: itemHeight,
          maxHeight: itemHeight,
        ),
        parentUsesSize: false,
      );
      
      remainingExtent -= itemHeight;
      currentIndex++;
    }
  }
  
  // 🔥 核心方法:获取指定索引的高度
  double _getItemExtent(int index) {
    // 缓存机制避免重复计算
    return _cachedExtents[index] ??= itemExtentBuilder(index, constraints);
  }
}

3.4. GridView

简介

用于创建 二维可滚动网格布局容器,能够将一系列子Widget 排列成 多行多列的网格形式,支持横向和纵向滚动。

3.4.1. API 详解

继承 BoxScrollView,属性和Listview差不多,核心是 "gridDelegate",它负责定义网格的几何结构。

// 控制网格布局的代理
final SliverGridDelegate gridDelegate;

// Flutter 提供了两个实现
SliverGridDelegateWithFixedCrossAxisCount
- crossAxisCount: 固定列数。
- mainAxisSpacing: 主轴间距。
- crossAxisSpacing: 交叉轴间距。
- childAspectRatio: 子项的宽高比。默认为 1.0。非常重要,用于计算子项在主轴上的高度。

SliverGridDelegateWithMaxCrossAxisExtent
- maxCrossAxisExtent: 子项在交叉轴上的最大尺寸
- 其它参数同上

提供了 五种构造方式

// 模拟一些数据
final List<int> data = List.generate(50, (index) => index);

// ✨ ① GridView() - 默认构造函数
// 需要手动提供 gridDelegate 和 children 列表,适合少量、固定的子项。
GridView(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,// 交叉轴子项数量
    crossAxisSpacing: 10,// 交叉轴方向相邻子项间的间距
    mainAxisSpacing: 10,// 主轴方向相邻子项间的间距
    childAspectRatio: 1.0, // 子项的宽高比
  ),
  children: data.map((i) => GridItem(index: i)).toList()
);


// ✨ ① GridView.count() - 最常用,用于创建固定列数的网格
GridView.count(
  // 核心参数:固定列数
  crossAxisCount: 4,
  crossAxisSpacing: 10,
  mainAxisSpacing: 10,
  children: data.map((i) => GridItem(index: i)).toList(),
);

// ✨ ③ GridView.extent() - 响应式布局,根据子项的最大宽度自动计算列数。
GridView.extent(
  // 核心参数:子项在交叉轴上的最大尺寸
  maxCrossAxisExtent: 120.0,
  crossAxisSpacing: 10,
  mainAxisSpacing: 10,
  children: data.map((i) => GridItem(index: i)).toList(),
);

// ✨ ④ GridView.builder() - 高性能懒加载,用于大量或无限数据的场景,按需构建子项。
GridView.builder(
  // 布局代理,与默认构造函数中的一样
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    crossAxisSpacing: 10,
    mainAxisSpacing: 10,
  ),
  // 核心参数:子项总数
  itemCount: data.length,
  // 核心参数:子项构建器
  itemBuilder: (context, index) {
    // 只有当 item 将要显示时,此方法才会被调用
    print('Building item for builder: $index');
    return GridItem(index: data[index]);
  },
);

// ✨ ⑤ GridView.custom() - 完全自定义,提供了最大的灵活性,可以自定义子项的构建和管理策略。
GridView.custom(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 150,
    mainAxisSpacing: 10,// 主轴间距
    crossAxisSpacing: 10,// 交叉轴间距
  ),
  // 核心参数:子项代理
  // SliverChildBuilderDelegate 行为与 GridView.builder 相同
  childrenDelegate: SliverChildBuilderDelegate(
    (context, index) {
      return GridItem(index: data[index]);
    },
    childCount: data.length,
  ),
);

简单使用示例【--->c35/gridview_demo.dart<---】运行效果:

3.4.2. 源码剖析

有了前面 ListView 的经验,看起源码来可谓是轻车熟路了,先是 构造方法

// 😄 前三个的 Sliver 都是【SliverChildListDelegate】,全是一次性加载所有子控件
// 区别在于【网格布局代理类】的不同:
// 
//「SliverGridDelegateWithFixedCrossAxisCount」
// 固定交叉轴上的子项数量 (列数/行数),无论屏幕多大,始终显示固定的列数
// 子项宽度:childWidth = (totalWidth - spacing) / crossAxisCount 
//
//「SliverGridDelegateWithMaxCrossAxisExtent」
// 固定子项在交叉轴上的最大尺寸,根据屏幕大小自动计算合适的列数
// 开发者指定子项最大宽度固定,如:maxCrossAxisExtent = 150.0 
// 计算列数:crossAxisCount = ceil(totalWidth / (maxCrossAxisExtent + spacing))
// 计算子项宽度:childWidth = (totalWidth - spacing) / crossAxisCount

GridView()  → 需要自定义 SliverGridDelegateWithFixedCrossAxisCount 通过 gridDelegate 传入

GridView.count()  → 内部自动创建 SliverGridDelegateWithFixedCrossAxisCount

GridView.extend() → 内部自动创建 SliverGridDelegateWithMaxCrossAxisExtent
  
// 💡 懒加载,Sliver 是【SliverChildBuilderDelegate】,需传入自定义的 gridDelegate 参数。
GridView.builder()

// 既需要自定义 gridDelegate 参数,也需要自定义 childrenDelegate 参数。
GridView.custom()

GridView 也是继承 BoxScrollView,直接搜 buildChildLayout()

3.4.3. SliverGrid - 网格布局

用于在 二维网格中放置多个 box 子组件Sliver 组件,专门为滚动视图设计的 网格布局组件

RenderSliverGrid 的核心源码:

class RenderSliverGrid extends RenderSliverMultiBoxAdaptor {
  @override
  void performLayout() {
    // 🎯【准备阶段】获取滚动约束条件
    final SliverConstraints constraints = this.constraints;
    childManager.didStartLayout();
    childManager.setDidUnderflow(false);
  
    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    final double remainingExtent = constraints.remainingCacheExtent;
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    // 🎯【计算可见范围】获取布局策略、计算第一个/最后一个可见子项索引
    final SliverGridLayout layout = _gridDelegate.getLayout(constraints);
    final int firstIndex = layout.getMinChildIndexForScrollOffset(scrollOffset);
    final int? targetLastIndex = targetEndScrollOffset.isFinite ?
    layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset) : null;

    // 🎯【垃圾回收】移除不在可见范围内的子组件,释放内存
    if (firstChild != null) {
      final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
      final int trailingGarbage = targetLastIndex != null ? calculateTrailingGarbage(lastIndex: targetLastIndex) : 0;
      collectGarbage(leadingGarbage, trailingGarbage);
    } else {
      collectGarbage(0, 0);
    }

    // 🎯【布局子组件】每个子组件都通过 SliverGridGeometry 获得精确的位置和尺寸
    // 双向构建-既向前也向后添加子组件,懒加载-只构建可见区域的子组件
    
    // 处理第一个子组件
    final SliverGridGeometry firstChildGridGeometry = layout.getGeometryForChildIndex(firstIndex);
    
    // 向前添加子组件
    for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
      final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index);
      final RenderBox child = insertAndLayoutLeadingChild(
        gridGeometry.getBoxConstraints(constraints),
      )!;
      final SliverGridParentData childParentData = child.parentData! as SliverGridParentData;
      childParentData.layoutOffset = gridGeometry.scrollOffset;
      childParentData.crossAxisOffset = gridGeometry.crossAxisOffset;
    }
    
    // 向后添加子组件
    for (int index = indexOf(trailingChildWithLayout!) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) {
      // ... 类似的布局逻辑
    }

    // 🎯【计算几何信息】估算总滚动范围、计算绘制区域、计算缓存区域
    final double estimatedTotalExtent = reachedEnd
      ? trailingScrollOffset
      : childManager.estimateMaxScrollOffset(
          constraints,
          firstIndex: firstIndex,
          lastIndex: lastIndex,
          leadingScrollOffset: leadingScrollOffset,
          trailingScrollOffset: trailingScrollOffset,
        );
    final double paintExtent = calculatePaintOffset(
      constraints,
      from: math.min(constraints.scrollOffset, leadingScrollOffset),
      to: trailingScrollOffset,
    );
    geometry = SliverGeometry(
      scrollExtent: estimatedTotalExtent,
      paintExtent: paintExtent,
      maxPaintExtent: estimatedTotalExtent,
      cacheExtent: cacheExtent,
      hasVisualOverflow: estimatedTotalExtent > paintExtent || constraints.scrollOffset > 0.0 || constraints.overlap != 0.0,
    );
  }
}

代码有点多,梳理下执行链条:

RenderSliverGrid.performLayout()  // 开始网格布局
  ↓
childManager.didStartLayout()  // 初始化子组件管理器
  ↓
_gridDelegate.getLayout(constraints)  // 获取网格布局策略
  ↓
layout.getMinChildIndexForScrollOffset(scrollOffset)  // 计算第一个可见子项索引
  ↓
layout.getMaxChildIndexForScrollOffset(targetEndScrollOffset)  // 计算最后一个可见子项索引
  ↓
collectGarbage(leadingGarbage, trailingGarbage)  // 回收不可见的子组件
  ↓
layout.getGeometryForChildIndex(firstIndex)  // 获取第一个子项的几何信息for (index >= firstIndex; --index)  // 【循环1】向前布局缺失的子组件
  → insertAndLayoutLeadingChild()
  → 设置 layoutOffset 和 crossAxisOffset
  ↓
for (index <= targetLastIndex; ++index)  // 【循环2】向后布局新的子组件
  → insertAndLayoutChild()
  → 设置 layoutOffset 和 crossAxisOffset
  ↓
childManager.estimateMaxScrollOffset()  // 估算总滚动范围
  ↓
calculatePaintOffset() / calculateCacheOffset()  // 计算绘制和缓存区域
  ↓
geometry = SliverGeometry(...)  // 创建最终几何信息
  ↓
childManager.didFinishLayout()  // 完成布局,清理资源

😄 套下数据写个简单的例子 (垂直方向、一行4列,子元素高度为50,Viewport 为180,有24个子项):

// ① 计算可见范围
scrollOffset = 0 (初始位置)
targetEndScrollOffset = 0 + 180 = 180
layout.getMinChildIndexForScrollOffset(0) = 0     // 第一个可见项:索引0
layout.getMaxChildIndexForScrollOffset(180) = 15  // 最后一个可见项:索引15
// 计算详解
mainAxisCount = (180 / 50).ceil() = 3.6.ceil() = 4 行
maxChildIndex = max(0, 4 * 4 - 1) = 15

// ② 分析子项分布 (24个):1 (y=0-50):    [0] [1] [2] [3]     
行2 (y=50-100):  [4] [5] [6] [7]     
行3 (y=100-150): [8] [9] [10][11]    
行4 (y=150-200): [12][13][14][15]    
行5 (y=200-250): [16][17][18][19]    ← 不可见
行6 (y=250-300): [20][21][22][23]    ← 不可见

实际需要布局的子项: 索引 0-15 (只有前16个)
不会创建的子项: 索引 16-23 (后8个)

// ③ 布局执行
collectGarbage(): 清理超出范围的子项

第一个循环 (向前布局):
无需执行 (从索引0开始)

第二个循环 (向后布局):
for (index = 1; index <= 15; ++index) {  // 注意:只到15,不到23
  insertAndLayoutChild() // 只创建索引1到15的子项
  设置 layoutOffset 和 crossAxisOffset
}

索引16-23的子项: 完全不会被创建!

// ④ 最终几何信息
estimatedTotalExtent = childManager.estimateMaxScrollOffset()
// 会根据已知的16个子项来估算24个子项的总高度
// 估算结果: 6行 × 50px = 300px

paintExtent = 180px (viewport高度)
scrollExtent = 300px (估算的总滚动范围)

// 渲染结果
┌─────────────────────────┐ ← viewport top (y=0)
│ [0]  [1]  [2]  [3]     │ ← 行1: 已创建,完全可见
│ [4]  [5]  [6]  [7]     │ ← 行2: 已创建,完全可见
│ [8]  [9]  [10] [11]    │ ← 行3: 已创建,完全可见
│ [12] [13] [14] [15]    │ ← 行4: 已创建,部分可见
└─────────────────────────┘ ← viewport bottom (y=180)
未显示区域:
│ [16] [17] [18] [19]    │ ← 行5: 未创建,不可见
│ [20] [21] [22] [23]    │ ← 行6: 未创建,不可见

3.5. PageView

简介

可滚动的列表,特殊之处在于它的每个子组件 (称为"页面") 在滚动时都会强制占据整个视口 (Viewport)。常用于引导页、轮播图。TabBarView 也是基于它实现的,用来配置TabBar展示不同标签下的内容。

3.5.1. API 详解

继承 StatefulWidget,同样挑几个属性讲讲:

  • pageSnappingbool,是否启用页面吸附,默认true,滚动停止时会自动吸附到最近的页面边界,设置 false,则可以停在任何位置。
  • padEndsbool,是否在列表的两端添加填充,默认true,会在第一页和最后一页添加额外的填充空间。使得第一页和最后一页能够居中显示在视口中。这个参数只有 viewportFraction < 1.0 时才生效。
  • onPageChangedValueChanged? ,当一个新页面完全显示时 (pageSnapping完成后) 调用,可以获取新页面的索引。
  • childrenDelegatePageView 内部使用它来生成子组件,不同构造方法最终会创建不同类型的 SliverChildDelegate

提供了 三种构造方式

// ✨ ① PageView() - 默认构造函数,适合页面数量较少且固定的情况
PageView(
  children: <Widget>[
    _buildPage(1, Colors.pink),
    _buildPage(2, Colors.cyan),
    _buildPage(3, Colors.deepPurple),
  ],
)

// ✨ ② PageView.builder() - 构造器构造函数,最常用,懒加载,适合页面数量多或不确定的情况。
// 假设有100个页面
const int pageCount = 100;
PageView.builder(
  itemCount: pageCount,
  itemBuilder: (BuildContext context, int index) {
    // itemBuilder 会在页面即将进入视口时被调用
    // 这意味着只有当用户滑动到某个页面时,它才会被构建
  }
)

// ✨ ③ PageView.custom() - 自定义构造函数,对子项的构建、布局和回收逻辑进行高度自定义时。
PageView.custom(
  // childrenDelegate 是核心,它决定了如何提供子页面
  childrenDelegate: SliverChildBuilderDelegate(
    (BuildContext context, int index) {
      // 如:奇数页和偶数页显示不同的内容
      if (index.isEven) {
        return Container(
          color: Colors.green,
          child: Center(
            child: Text(
              'Even Page ${index + 1}\n(From PageView.custom())',
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 22, color: Colors.white),
            ),
          ),
        );
      } else {
        return Container(
          color: Colors.orange,
          child: Center(
            child: Text(
              'Odd Page ${index + 1}\n(From PageView.custom())',
              textAlign: TextAlign.center,
              style: const TextStyle(fontSize: 22, color: Colors.white),
            ),
          ),
        );
      }
    },
    // 同样可以指定子项数量,也可以为 null 表示无限列表
    childCount: 20, 
  ),
);

页面的切换控制,用到的 → PageController,它继承自 ScrollController,在原有像素级别的基础上,新增了 "页面" 级别的滚动控制,构造方法 & 类成员:

PageController({
  this.initialPage = 0,           // 初始页面索引
  this.keepPage = true,           // 是否保存页面状态
  this.viewportFraction = 1.0,    // 视口占比,< 1.0 每页只占部分视口,可以看到相邻页面的一部分
                                  // > 1.0: 每页超出视口,会有padding效果
  super.onAttach,                 // 附加回调
  super.onDetach,                 // 分离回调
}) : assert(viewportFraction > 0.0);

double? get page// 当前页面的精确位置,可能包含小数部分,如 1.5 表示在第1页和第2页之间
                  // 必须在 PageView 构建完成后才能访问

// 核心方法
  
// 动画地切换到指定页面
animateToPage(int page, {required Duration duration, required Curve curve})
  
// 无动画地直接跳转到指定页面
jumpToPage(int page)

// 动画地切换到下一页
nextPage({required Duration duration, required Curve curve})

// 动画地切换到上一页
previousPage({required Duration duration, required Curve curve})

简单使用示例【--->c35/pageview_demo.dart<---】运行效果:

💡 Tips:滑动几页后,切换Tab,再切回来,发现会从第一页开始,即重新创建。如果想 保存页面状态,子页面需混入 AutomaticKeepAliveClientMixin 并重写 wantKeepAlive 返回 true ❗️

3.5.2. 源码剖析

😄 没啥新意,默认构造是 SliverChildListDelegatebuilder() 则是 SliverChildBuilderDelegate,前者一次性构建所有的子Widget,后者动态懒加载。PageView 继承 StatefulWidget,直接看 _PageViewState.build()

SliverFillViewport 继承 StatelessWidget,直接看 build()

  • _SliverFractionalPadding:负责根据视口大小动态计算并添加内边距,让SliverFillViewport的首尾子项能够居中显示。
  • _SliverFillViewportRenderObjectWidget:Widget 和 RenderObject间的桥梁,负责创建和管理RenderSliverFillViewport 渲染对象。

RenderSliverFillViewport 的父类 RenderSliverFixedExtentBoxAdaptor 实现了 performLayout() ,关键代码:

abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
  @override
  void performLayout() {
    // ① 初始化阶段
    
    // 获取约束信息
    final SliverConstraints constraints = this.constraints;
    childManager.didStartLayout();
    
    // 计算关键偏移量
    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    // ② 索引计算阶段
    
    // 获取约束信息
    final SliverConstraints constraints = this.constraints;
    childManager.didStartLayout();
    
    // 计算关键偏移量
    final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin;
    final double targetEndScrollOffset = scrollOffset + remainingExtent;

    // ③ 垃圾回收阶段
    // 计算需要布局的子组件索引范围
    final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, -1);
    final int? targetLastIndex = getMaxChildIndexForScrollOffset(targetEndScrollOffset, -1);

    // ④ 子组件布局阶段
    // 向前布局:清理不再需要的子组件
    final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex);
    final int trailingGarbage = calculateTrailingGarbage(lastIndex: targetLastIndex);
    collectGarbage(leadingGarbage, trailingGarbage);
    // 向后布局:从 firstChild 向前插入和布局子组件
    for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) {
      final RenderBox? child = insertAndLayoutLeadingChild(_getChildConstraints(index));
      childParentData.layoutOffset = indexToLayoutOffset(-1, index);
    }

    // ⑤ 几何信息结算阶段:从 trailingChildWithLayout 向后插入和布局子组件
    for (int index = indexOf(trailingChildWithLayout!) + 1; index <= targetLastIndex; ++index) {
      RenderBox? child = insertAndLayoutChild(_getChildConstraints(index), after: trailingChildWithLayout);
      childParentData.layoutOffset = indexToLayoutOffset(-1, index);
    }

    // ⑥ 完成阶段
    // 计算各种范围信息
    final double paintExtent = calculatePaintOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
    final double cacheExtent = calculateCacheOffset(constraints, from: leadingScrollOffset, to: trailingScrollOffset);
    
    // 设置最终的几何信息
    geometry = SliverGeometry(
      scrollExtent: estimatedMaxScrollOffset,
      paintExtent: paintExtent,
      cacheExtent: cacheExtent,
      maxPaintExtent: estimatedMaxScrollOffset,
      hasVisualOverflow: ...,
    );
  }
}

4. 高级组件 & 自定义滚动

SliverFlutter 为了解决 大量内容滚动时的性能问题 而设计的 视口驱动渲染机制,它 只构建和渲染用户当前能看到的部分,相比 普通Widget 会一次性构建所有内容导致内存爆炸和性能问题要高效得多。

4.1. CustomScrollView

用于 构建自定义、复杂的滚动视图,它本身不直接决定其子项的布局,而是创建了一个可以容纳 Sliver 系列组件的滚动视口 (Viewport)。继承自 ScrollView,只是重写了 buildSlivers() ,返回构造参数传入的 slivers - Sliver组件列表。

4.2. SliverPersistentHeader

Persistent-"持久化",该组件可以 根据滚动位置改变自身大小,并且可以选择性地 "" 在视口(Viewport) 顶部Sliver。当它滚动到屏幕边缘时,它不会像普通列表项那样完全滚出屏幕,而是可以收缩到一个最小高度并"固定在那里",直到被下一个 SliverPersistentHeader 或者滚动会顶部所代替。😄 其实就很常说的"吸顶"组件。

class SliverPersistentHeader extends StatelessWidget {
  const SliverPersistentHeader({
    Key? key,
    required this.delegate, // 头部布局的委托对象,包括:最大/最小高度和构建逻辑
    this.pinned = false,// 是否固定在视口顶部 (滚动也不消失)
    this.floating = false,// 是否有浮动效果 (用户反向滚动会立即重新出现)
  });
}

SliverPersistentHeaderDelegate 是一个抽象类,需继承并实现下述方法:

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

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

// 返回头部的 UI 组件,参数:
// 「shrinkOffset」-头部从最大高度 maxExtent 收缩的距离,可能范围[0.0, maxExtent-minExtent]
// 当 shrinkOffset 为 0 时,头部处于完全展开状态,当达到最大值时,头部处于完全收缩状态
// 可以利用这个值来实现各种动画效果,如 (透明度、位移、大小锁房)
//
//「overlapsContent」该 Header 当前是否与滚动视图中的主要内容重写。
// 通常在 pinned 为 true 时,Header 下方的内容开始滚动到其后面时,此值为 true。
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
  return Container(/* 你的头部内容 */);
}

@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
  // 返回是否需要重建
  return true;
}

写个简单使用示例【--->c35/sliver_persistent_header_demo.dart<---】运行效果:

😶 简单传几个参数就实现了吸顶和浮动效果,跟下源码,看下是怎么做到的,build() 根据不同的值情况返回不同的 渲染对象,跟下对应的 performLayout() 的核心代码:

从下往上看,先是 _SliverScrollingPersistentHeaderRenderSliverScrollingPersistentHeader

接着是 _SliverFloatingPersistentHeaderRenderSliverFloatingPersistentHeader

再接着 _SliverPinnedPersistentHeaderRenderSliverPinnedPersistentHeader

最后是 _SliverFloatingPinnedPersistentHeaderRenderSliverFloatingPinnedPersistentHeader,浮动效果来自于父类 RenderSliverFloatingPersistentHeader,吸顶效果则是重写 updateGeometry() 实现:

4.3. SliverAppBar

基于 SliverPersistentHeader 实现的具体 应用栏组件 (内置应用的常见功能,如标题、操作按钮、背景等),开箱即用,相比起手写一坨 _SliverHeaderDelegate,直接配置几个属性即可轻松实现相同的效果:floatingpinned 就不用说了,还有这些:

  • snap:是否启用快速收缩/展开动画。
  • expandedHeight:完全展开时的高度,通常和 flexibleSpace 来显示背景内容,null 时使用默认工具栏高度。
  • collapsedHeight:收缩时的最小高度,不写默认为 toolbarHeight + bottom?.preferredSize.height。
  • stretch:bool,是否启用拉伸效果,默认false, 禁用拉伸效果,应用栏保持固定大小。设为true,当用户向下过度滚动时,应用栏会被拉伸放大。开启后的视觉效果:下拉时应用栏的 flexibleSpace 区域会被拉伸,拉伸后背景内容会按比例放大。
  • stretchTriggerOffset:double,设置触发 onStretchTrigger 回调 (拉伸回弹) 需要的过度滚动距离,默认100像素,计算方式: 从应用栏的自然位置开始计算向下的拖拽距离。
  • onStretchTriggerFuture ,拉伸触发的异步回调,用户拖拽超过阈值且松手时 (拉伸距离达到stretchTriggerOffset),注意,只在 stretch = true 时有效。

写个简单使用示例【--->c35/sliver_app_bar_demo.dart<---】运行效果:

跟下源码,内部使用 _SliverAppBarDelegate 来实现 SliverPersistentHeaderDelegate

4.4. NestedScrollView

当你在一个可滚动组件中放入一个"同方向"的可滚动组件,通常会遇到两大类问题:"手势冲突" & "布局约束问题"。比如最经典的例子:两个垂直滚动的ListView嵌套,会怎么样?

先是会报错:xxx has an unbounded height,尝试添加 固定高度 约束后。只有内部的列表在滑动,当它滚动尽头时,滚动事件并不会自动传递给外部的ListView。(🐶一种不太好的解法,内部ListView设置:shrinkWrap:true + physics: NeverScrollableScrollPhysics() ,即收缩+禁止滚动,仅适用于列表项少且固定的情况)。

NestedScrollView 就是为了解决这两个问题而设计出来的:

  • 通过 滚动协调器 ( _NestedScrollCoordinator) 统一管理和分配滚动事件,消除竞争.
  • body 提供了有界约束,使其内部的可滚动组件可以正常布局。

4.4.1. API 详解

构造方法:

class NestedScrollView extends StatefulWidget {
  const NestedScrollView({
    super.key, // Widget的唯一标识符,用于性能优化和状态保持
    this.controller, // 外层滚动控制器,用于程序化控制滚动位置和监听滚动事件
    this.scrollDirection = Axis.vertical, // 滚动方向,默认垂直滚动(仅影响外层滚动)
    this.reverse = false, // 是否反向滚动,true时从底部开始滚动(仅影响外层滚动)
    this.physics, // 滚动物理效果,控制弹性、边界行为等滚动特性(仅影响外层滚动)
    required this.headerSliverBuilder, // 头部构建器,返回头部Sliver组件列表(如SliverAppBar)
    required this.body, // 主体内容Widget,通常是TabBarView或其他滚动组件
    this.dragStartBehavior = DragStartBehavior.start, // 拖拽开始行为,控制手势识别的起始时机
    this.floatHeaderSlivers = false, // 是否优先浮动头部Sliver,true时向下滚动优先展开头部
    this.clipBehavior = Clip.hardEdge, // 内容裁剪行为,控制超出边界内容的显示方式
    this.restorationId, // 状态恢复ID,用于应用重启时恢复滚动位置
    this.scrollBehavior, // 滚动行为配置,定义滚动样式、物理效果等平台相关行为
  });

核心属性

  • headerSliverBuilderNestedScrollViewHeaderSliversBuilder → List Function(BuildContext context, bool innerBoxIsScrolled),用于构建 头部Sliver 组件列表。第二个参数的值代表"嵌套的滑动内容是否已经达到顶部,开始滑动"。
  • bodyWidget,主体部分,通常是一个包含可滚动内容的组件,最常见的是 TabBarView,其每个子页面都是一个 ListView 或 CustomScrollView,这部分内容构成了 "内部滚动视图"。
  • controllerScrollController,控制外层滚动,内层滚动由 PrimaryScrollController 自动管理。
  • floatHeaderSlivers:是否优先浮动头部Slivers,默认 false,设为 true,向下滚动会优先展开头部。
  • :scrollDirection、reverse、physics 只影响外层滚动视图,内层滚动视图需要在body中单独配置。

最简单的使用代码示例 (注意headerSliverBuilder列表的元素必须为Sliver组件,如:SliverList):

 NestedScrollView(
  // 头部
  headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
    return <Widget>[
      SliverToBoxAdapter(
        child: Container(height: 200, color: Colors.orange),
      ),
    ];
  },
  // 主体
  body: Container(
    color: Colors.orange.shade50,
    child: ListView.builder(
      padding: const EdgeInsets.all(16),
      itemCount: 30,
      itemBuilder: (context, index) => Card(
        margin: const EdgeInsets.only(bottom: 8),
        child: ListTile(
          leading: CircleAvatar(
            backgroundColor: Colors.orange,
            child: Text('${index + 1}'),
          ),
          title: const Text('这是主体内容 (body)'),
          subtitle: Text('列表项 ${index + 1}'),
        ),
      ),
    ),
  ),
),

运行效果:

尝试 SliverAppBar

 headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
  return <Widget>[
    // 🎨 可折叠的 SliverAppBar
    SliverAppBar(
      title: const Text('📱 SliverAppBar 示例'),
      backgroundColor: Colors.purple,
      foregroundColor: Colors.white,
      floating: true,
      pinned: true,
      snap: true,
      expandedHeight: 200.0,
      // 🎭 弹性空间 - 展开时显示的内容
      flexibleSpace: FlexibleSpaceBar(
        title: const Text('可折叠标题'),
        background: Container(
          color: Colors.purple,
          child: Center(
            child: Icon(Icons.star, size: 80, color: Colors.white.withOpacity(0.3)),
          ),
        ),
      ),
      // 根据内容滚动状态决定是否显示阴影
      forceElevated: innerBoxIsScrolled,
    ),
  ];
},

运行效果:

NestedScrollView 中,使用 floating、pinned、snap 等属性的 SliverAppBar 时,会产生 "重叠" 问题:

AppBar 在展开/收起过程中可能遮挡内容、内层滚动视图的内容可能显示在 AppBar 下方、滚动对齐不正确。

为了避免 body 的初始内容被 Header 遮挡,你需要使用这两对组合:

  • SliverOverlapInjector:将它作为 headerSliverBuilder 返回的 Sliver 列表中的一个父组件,包裹住其他 Sliver。它会捕获其子 Sliver (如 SliverAppBar) 所占据的重叠量。
  • SliverOverlapInjector:在 body 内部的可滚动视图,将它作为第一个Sliver,它会将SliverOverlapAbsorber 捕获到的重叠量作为内边距,应用到其内部,从而将 body内容往下挪,避免被遮挡。

用法示例 (注:两者必须使用同一个 SliverOverlapAbsorberHandle):

// 在 headerSliverBuilder 中,套Sliver组件
SliverOverlapAbsorber(
  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
  sliver: SliverAppBar(...), // 包装会产生重叠的组件
)

// 在 body 的每个滚动视图中,在 TabBarView 的每个 Tab 中都要使用 SliverOverlapInjector
SliverOverlapInjector(
  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
)

另外,你可能发现滑动后,且Tab到别的页面,回来后,第一页的 滑动距离没有保持,给 CustomScrollViewkey 属性设置一个 PageStorageKey 的对象 (唯一标识) 即可解决。原理是 ScrollPosition 类中通过 PageStorage 组件进行滑动进度的读写。

💡 Tips: 在 body 的 TabBarView 中为每个 ListView 都提供了自己的 ScrollController,会导致NestedScrollView 的协调机制 失效!

4.4.2. 源码剖析

核心大脑 _NestedScrollCoordinator (协调器):

class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { ... }

它管理两个独立的 ScrollController: _outerController_innerController

协调内外滚动位置的同步、处理手势分发和滚动事件。滚动分发的核心算法 (用户开始拖拽-drag() → 拖拽更新-applyUserOffset() ):

  • 向上拖拽 (delta < 0):优先消除内层 overscroll → 然后滚动外层 → 最后滚动内层。
  • 向下拖拽 (delta > 0):根据 floatHeaderSlivers 决定是否优先滚动外层。

然后是 惯性滚动处理,当手指离开屏幕:

然后是关键的 _getMetrics() ,它创建统一的滚动指标,让内外两个独立的滚动视图在物理模拟时表现得像一个整体。最后是为了解决 SliverAppBar 重叠问题的 SliverOverlapAbsorber/Injector 机制:

RenderSliverOverlapInjector.performLayout() 中:

画个调用时序图帮助理解:

用户手势 → NestedScrollCoordinator → 智能分析 → 分发给合适的滚动组件

4.5. Notification通知机制

ScrollNotification 是一个抽象类,用于 在滚动相关事件发生时发出通知,它是整个滚动通知系统的核心基类。

abstract class ScrollNotification extends LayoutChangedNotification with ViewportNotificationMixin {
  ScrollNotification({
    required this.metrics,
    required this.context,
  });
  
  final ScrollMetrics metrics;  // 滚动指标信息
  final BuildContext? context;  // 触发通知的组件上下文
}

核心属性:

  • metrics:ScrollMetrics,描述滚动视图内容的详细信息,包含:pixels(当前位置)、minScrollExtent(最小滚动范围)、maxScrollExtent(最大滚动范围)、viewportDimension(视口尺寸)等。
  • context:触发此通知的 Widget 的构建上下文,可用于查找滚动组件的渲染对象,确定视口大小等。
  • depth:继承自 ViewportNotificationMixin,表示通知冒泡经过的视口数量,通常监听器只响应 depth 为 0 的通知 (本地通知)。

它的几个字类:

// 开始滚动
class ScrollStartNotification extends ScrollNotification {
  final DragStartDetails? dragDetails; // 拖拽开始详情
}

// 滚动更新
class ScrollUpdateNotification extends ScrollNotification {
  final DragUpdateDetails? dragDetails; // 拖拽更新详情
  final double? scrollDelta; // 滚动距离增量
}

// 过渡滚动
class OverscrollNotification extends ScrollNotification {
  final double overscroll; // 过度滚动的像素数
  final double velocity;   // 滚动速度
}

// 滚动结束
class ScrollEndNotification extends ScrollNotification {
  final DragEndDetails? dragDetails; // 拖拽结束详情
}

// 用户滚动方向改变
class UserScrollNotification extends ScrollNotification {
  final ScrollDirection direction; // 滚动方向
}

// 用户停止交互
class UserScrollNotification extends ScrollNotification {
  final ScrollDirection direction;// 处于 idle
}

使用示例:

// 使用 NotificationListener<ScrollNotification> 包裹滑动视图进行监听
NotificationListener<ScrollNotification>(
  onNotification: (ScrollNotification notification) {
    if (notification is ScrollUpdateNotification) {
      print('滚动位置: ${notification.metrics.pixels}');
    }
    return false; // 不消费通知,继续向上传播
  },
  child: ListView(...),
)

相比 ScrollControllerScrollNotification 的优势在于 "解耦" 和 "事件驱动",具体表现在:

  • 任何父组件都可以通过 NotificationListener 监听到其子孙树中任何滚动组件的事件,无需获取该滚动组件的 ScrollController,这使得父组件与子滚动组件间没有强依赖关系,代码更清晰、更易维护。
  • NotificationListener 提供了丰富且详细的事件类型,让你能精确地知道滚动的具体阶段。而ScrollController.addListener() 只有一个通用的"滚动变动"通知,你无法直接区分是用户拖动、惯性滑动还是代码驱动的滚动。
  • ScrollNotification 是一种标准的、自下而上 的"事件冒泡机制",而 ScrollController 则主要用于自上而下的 控制,监听只是其附加功能。

😁 ScrollNotification 的事件来源可以分为两种:

// 用户手势交互

1. 用户拖拽屏幕 -> GestureDetector 识别
2. 手势生成 DragStartDetails, DragUpdateDetails, DragEndDetails
3. 这些手势信息传递给 ScrollActivity

// ② 程序化滚动

// ScrollController 主动调用
controller.animateTo(100.0); // 程序控制滚动
controller.jumpTo(200.0);    // 立即跳转

然后 Scrollable 是所有滚动组件的基础,其内部事件生成流程:

  • 手势识别: RawGestureDetector 识别用户手势
  • 活动创建: 根据手势类型创建不同的 ScrollActivity (DragScrollActivity-用户拖拽、DrivenScrollActivity-程序滚动、BallisticScrollActivity-惯性滚动)。
  • 位置更新: ScrollPosition 根据活动更新滚动位置。
  • 通知分发: ScrollPosition 创建并分发 ScrollNotification

ScrollPosition 是事件创建的核心,关键源码:

abstract class ScrollPosition {

  /// 开始滚动时调用
  void didStartScroll() {
    // 实际上是通过 activity 来分发通知的!
    activity!.dispatchScrollStartNotification(copyWith(), context.notificationContext);
  }

  /// 滚动更新时调用
  void didUpdateScrollPositionBy(double delta) {
    // 通过 activity 分发滚动更新通知
    activity!.dispatchScrollUpdateNotification(copyWith(), context.notificationContext!, delta);
  }

  /// 滚动结束时调用
  void didEndScroll() {
    // 通过 activity 分发滚动结束通知,并保存偏移量
    activity!.dispatchScrollEndNotification(copyWith(), context.notificationContext!);
    saveOffset();
    if (keepScrollOffset) {
      saveScrollOffset();
    }
  }

  /// 过度滚动时调用
  void didOverscrollBy(double value) {
    assert(activity!.isScrolling);
    // 通过 activity 分发过度滚动通知
    activity!.dispatchOverscrollNotification(copyWith(), context.notificationContext!, value);
  }

  /// 用户滚动方向改变时调用
  void didUpdateScrollDirection(ScrollDirection direction) {
    // 直接创建 UserScrollNotification 并分发
    UserScrollNotification(
      metrics: copyWith(), 
      context: context.notificationContext!, 
      direction: direction
    ).dispatch(context.notificationContext);
  }

  /// 滚动指标更新时调用
  void didUpdateScrollMetrics() {
    assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
    assert(_haveScheduledUpdateNotification);
    _haveScheduledUpdateNotification = false;
    if (context.notificationContext != null) {
      // 分发滚动指标变化通知
      ScrollMetricsNotification(
        metrics: copyWith(), 
        context: context.notificationContext!
      ).dispatch(context.notificationContext);
    }
  }
}

接着是 事件传播的链路

ScrollPosition.didUpdateScrollPositionBy()
   ↓
ScrollPosition._dispatch(ScrollUpdateNotification(...))
   ↓
BuildContext.dispatchNotification(notification)
   ↓
Element._notificationTree.dispatch(notification)
   ↓
向上遍历 Element 树
   ↓
查找 NotificationListener<ScrollNotification>
   ↓
执行 onNotification 回调
   ↓
根据返回值决定是否继续传播

JavaScript函数参数完全指南:从基础到高级技巧,一网打尽!

一、函数参数的基本使用

1.1 基本参数传递

// 基本参数
function greet(name, message) {
    return `${name}, ${message}!`;
}

console.log(greet("Alice", "Good morning"));
// "Alice, Good morning!"

1.2 默认参数(ES6+)

// 默认参数值
function createUser(name, role = "user", isActive = true) {
    return { name, role, isActive };
}

console.log(createUser("Bob"));
// { name: 'Bob', role: 'user', isActive: true }
console.log(createUser("Carol", "admin"));
// { name: 'Carol', role: 'admin', isActive: true }
console.log(createUser("Eve", "user", false));
// { name: 'Eve', role: 'user', isActive: false }

1.3 剩余参数(Rest Parameters)

// 收集所有剩余参数
function sum(prefix, ...numbers) {
    const total = numbers.reduce((acc, val) => acc + val, 0);
    return `${prefix}: ${total}`;
}

console.log(sum("结果", 1, 2, 3, 4)); // "结果: 10"

1.4 函数自带的arguments关键字

function sum() {
    let total = 0;
    for (let i = 0; i < arguments.length; i++) {
        total += arguments[i];
    }
    console.log("结果:", total);  // "结果: 10"
}

sum(1, 2, 3, 4);

二、参数处理技巧

2.1 使用对象解构处理多个参数:提高可读性和可维护性

// 对象参数解构
function displayUser({ name, age, email = "N/A" }) {
    console.log(`名称: ${name}`);
    console.log(`年龄: ${age}`);
    console.log(`邮箱: ${email}`);
}

const user = { name: "Dave", age: 30 };
displayUser(user);
// 名称: Dave
// 年龄: 30
// 邮箱: N/A

// 数组参数解构
function getFirstAndLast([first, , , last]) {
    return { first, last };
}

const colors = ["red", "green", "blue", "yellow"];
console.log(getFirstAndLast(colors));
// { first: 'red', last: 'yellow' }

2.2 优先使用默认参数:代替逻辑或(||)操作符

// 推荐
function greet(name = '访客') {}

// 不推荐
function greet(name) {
  name = name || '访客';
}

2.3 合理使用rest参数:代替arguments对象

// 推荐 (ES6+)
function sum(...numbers) {}

// 不推荐 (ES5)
function sum() {
  var numbers = Array.prototype.slice.call(arguments);
}

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

从零实现浏览器摄像头控制与视频录制:基于原生 JavaScript 的完整指南

本文我将带领大家深入探索如何使用原生 JavaScript 实现浏览器摄像头的控制与视频录制功能,打造一个专业级别的网页应用。

呈现的效果如下:

初始界面

拍照和拍摄视频

无论你是前端开发新手,还是有一定经验的工程师,通过本文的学习,你都将掌握以下技能:

  • 使用 MediaDevices API 获取和控制摄像头设备
  • 实现高质量视频录制和拍照功能
  • 设计直观友好的用户界面和交互体验
  • 处理常见的浏览器兼容性问题

一、核心技术概述

在开始编码之前,让我们先了解一下实现摄像头控制和视频录制所需的核心技术:

1. MediaDevices API

MediaDevices API 是现代浏览器提供的一组强大接口,用于访问和控制设备的媒体输入,如摄像头、麦克风等。主要方法包括:

  • getUserMedia():获取摄像头和麦克风的媒体流
  • enumerateDevices():枚举可用的媒体设备
  • getDisplayMedia():获取屏幕共享流(本文暂不涉及)

2. MediaRecorder API

MediaRecorder API 用于将媒体流录制为音频或视频文件。主要功能包括:

  • 开始和停止录制
  • 分段处理录制数据
  • 支持多种输出格式(如 WebM、MP4 等)

3. Canvas API

Canvas API 用于在网页上绘制图形和处理图像。我们将使用它来实现拍照功能:

  • 捕获视频帧
  • 图像处理和滤镜效果
  • 导出为图片格式

二、项目初始化与基础结构

首先,让我们创建项目的基础结构。新建一个文件夹,命名为 "camera-recorder",并在其中创建以下文件:

camera-recorder/
├── index.html
├── style.css
└── script.js

接下来,我们将使用现代化的前端技术栈构建这个应用,包括 Tailwind CSS 进行样式设计和 Font Awesome 提供图标支持。

三、构建用户界面

1. 基础 HTML 结构

打开 index.html 文件,添加以下内容:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>摄像头控制器</title>
  <!-- 引入 Tailwind CSS -->
  <script src="https://cdn.tailwindcss.com"></script>
  <!-- 引入 Font Awesome -->
  <link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
  
  <!-- Tailwind配置 -->
  <script>
    tailwind.config = {
      theme: {
        extend: {
          colors: {
            primary: '#3B82F6',
            secondary: '#10B981',
            danger: '#EF4444',
            dark: '#1F2937',
          },
          fontFamily: {
            sans: ['Inter', 'system-ui', 'sans-serif'],
          },
        },
      }
    }
  </script>
  
  <style type="text/tailwindcss">
    @layer utilities {
      .content-auto {
        content-visibility: auto;
      }
      .shadow-camera {
        box-shadow: 0 0 25px rgba(59, 130, 246, 0.4);
      }
      .btn-hover {
        @apply transform transition-all duration-300 hover:scale-105 hover:shadow-lg;
      }
    }
  </style>
</head>
<body class="bg-gray-50 min-h-screen font-sans text-dark">
  <!-- 页面内容将在这里 -->
</body>
</html>

2. 页面布局设计

我们的应用将包含以下主要部分:

  • 顶部导航栏
  • 状态提示区
  • 视频预览区
  • 控制面板
  • 媒体结果展示区
  • 页脚

下面是完整的 HTML 结构:

<body class="bg-gray-50 min-h-screen font-sans text-dark">
  <!-- 头部 -->
  <header class="bg-gradient-to-r from-primary to-blue-400 text-white shadow-md">
    <div class="container mx-auto px-4 py-6">
      <h1 class="text-[clamp(1.8rem,5vw,2.5rem)] font-bold flex items-center">
        <i class="fa fa-video-camera mr-3"></i>
        智能摄像头控制器
      </h1>
      <p class="text-blue-100 mt-2">使用现代浏览器API控制您的摄像头并录制视频</p>
    </div>
  </header>

  <main class="container mx-auto px-4 py-8 max-w-5xl">
    <!-- 状态提示区 -->
    <div id="status" class="mb-6 p-4 rounded-lg bg-yellow-100 border-l-4 border-yellow-500 transition-all duration-500">
      <div class="flex items-center">
        <i class="fa fa-info-circle text-yellow-500 mr-3 text-xl"></i>
        <p>请点击"开启摄像头"按钮开始使用</p>
      </div>
    </div>

    <!-- 视频预览区 -->
    <div class="relative bg-gray-100 rounded-xl overflow-hidden shadow-lg mb-6">
      <div id="camera-container" class="aspect-video bg-gray-800 flex items-center justify-center">
        <video id="preview" class="w-full h-full object-cover" autoplay muted playsinline></video>
        <div id="no-camera" class="absolute inset-0 flex flex-col items-center justify-center bg-gray-800/80">
          <i class="fa fa-video-camera text-gray-400 text-6xl mb-4"></i>
          <p class="text-gray-300 text-lg">摄像头未开启</p>
        </div>
      </div>
      
      <!-- 设备选择下拉框 -->
      <div class="absolute top-3 right-3 z-10">
        <select id="camera-select" class="bg-white/90 backdrop-blur-sm text-dark px-3 py-1.5 rounded-lg border border-gray-300 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-sm">
          <option value="">选择摄像头设备...</option>
        </select>
      </div>
    </div>

    <!-- 控制面板 -->
    <div class="bg-white rounded-xl shadow-md p-6 mb-8">
      <div class="flex flex-wrap gap-4 justify-center">
        <button id="start-camera" class="bg-primary hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium flex items-center btn-hover">
          <i class="fa fa-video-camera mr-2"></i> 开启摄像头
        </button>
        <button id="close-camera" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-3 rounded-lg font-medium flex items-center btn-hover" disabled>
          <i class="fa fa-power-off mr-2"></i> 关闭摄像头
        </button>
        <button id="start-recording" class="bg-secondary hover:bg-green-600 text-white px-6 py-3 rounded-lg font-medium flex items-center btn-hover" disabled>
          <i class="fa fa-circle mr-2"></i> 开始录制
        </button>
        <button id="stop-recording" class="bg-danger hover:bg-red-600 text-white px-6 py-3 rounded-lg font-medium flex items-center btn-hover" disabled>
          <i class="fa fa-stop mr-2"></i> 停止录制
        </button>
        <button id="take-photo" class="bg-dark hover:bg-gray-800 text-white px-6 py-3 rounded-lg font-medium flex items-center btn-hover" disabled>
          <i class="fa fa-camera mr-2"></i> 拍照
        </button>
      </div>
    </div>

    <!-- 拍摄结果展示 -->
    <div class="mt-8">
      <h2 class="text-xl font-bold mb-4 flex items-center">
        <i class="fa fa-film mr-2 text-primary"></i>
        拍摄结果
      </h2>
      <div id="results" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <div class="col-span-full text-center text-gray-500 py-8">
          <i class="fa fa-film text-4xl mb-3 opacity-30"></i>
          <p>您的视频和照片将显示在这里</p>
        </div>
      </div>
    </div>
  </main>

  <footer class="bg-gray-800 text-white mt-12 py-8">
    <div class="container mx-auto px-4 text-center">
      <p>© 2025 摄像头控制器 | 使用现代浏览器API构建</p>
      <p class="text-gray-400 text-sm mt-2">支持Chrome、Firefox、Safari和Edge等主流浏览器</p>
    </div>
  </footer>

  <script src="script.js"></script>
</body>
</html>

3. 样式设计说明

我们使用 Tailwind CSS 实现了响应式设计和现代化的 UI 效果:

  • 使用 bg-gradient-to-r 创建渐变色背景
  • 利用 clamp() 函数实现自适应字体大小
  • 添加 btn-hover 自定义工具类实现按钮悬停效果
  • 使用 gridflex 布局实现响应式设计
  • 通过 transition-allduration-300 添加平滑过渡效果

四、实现核心功能

现在让我们实现应用的核心功能,包括摄像头控制、视频录制和拍照功能。

1. 初始化变量和DOM元素

打开 script.js 文件,添加以下代码:

// 全局变量
let mediaStream = null;
let mediaRecorder = null;
let recordedChunks = [];
let isRecording = false;
const preview = document.getElementById('preview');
const startCameraBtn = document.getElementById('start-camera');
const closeCameraBtn = document.getElementById('close-camera');
const startRecordingBtn = document.getElementById('start-recording');
const stopRecordingBtn = document.getElementById('stop-recording');
const takePhotoBtn = document.getElementById('take-photo');
const cameraSelect = document.getElementById('camera-select');
const resultsContainer = document.getElementById('results');
const status = document.getElementById('status');
const noCamera = document.getElementById('no-camera');

2. 实现状态提示系统

为了提供良好的用户体验,我们需要实现一个状态提示系统:

// 更新状态提示
function updateStatus(message, type = 'info') {
  const colors = {
    info: { bg: 'bg-blue-100', border: 'border-blue-500', icon: 'fa-info-circle text-blue-500' },
    success: { bg: 'bg-green-100', border: 'border-green-500', icon: 'fa-check-circle text-green-500' },
    warning: { bg: 'bg-yellow-100', border: 'border-yellow-500', icon: 'fa-exclamation-triangle text-yellow-500' },
    error: { bg: 'bg-red-100', border: 'border-red-500', icon: 'fa-exclamation-circle text-red-500' }
  };
  
  status.className = `mb-6 p-4 rounded-lg ${colors[type].bg} border-l-4 ${colors[type].border} transition-all duration-500`;
  status.innerHTML = `
    <div class="flex items-center">
      <i class="fa ${colors[type].icon} mr-3 text-xl"></i>
      <p>${message}</p>
    </div>
  `;
}

3. 获取摄像头设备列表

使用 MediaDevices.enumerateDevices() 方法获取可用的摄像头设备:

// 获取摄像头设备列表
async function getCameraDevices() {
  try {
    const devices = await navigator.mediaDevices.enumerateDevices();
    const videoDevices = devices.filter(device => device.kind === 'videoinput');
    
    cameraSelect.innerHTML = '<option value="">选择摄像头设备...</option>';
    videoDevices.forEach(device => {
      const option = document.createElement('option');
      option.value = device.deviceId;
      option.text = device.label || `摄像头 ${cameraSelect.length}`;
      cameraSelect.appendChild(option);
    });
    
    return videoDevices;
  } catch (err) {
    updateStatus(`获取设备列表失败: ${err.message}`, 'error');
    console.error('获取设备列表失败:', err);
    return [];
  }
}

4. 开启和关闭摄像头

使用 getUserMedia() 方法获取摄像头流:

// 开启摄像头
async function startCamera(deviceId = null) {
  try {
    // 如果已经有流,先停止
    if (mediaStream) {
      mediaStream.getTracks().forEach(track => track.stop());
    }
    
    const constraints = {
      video: deviceId ? { deviceId: { exact: deviceId } } : true,
      audio: false
    };
    
    mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
    preview.srcObject = mediaStream;
    noCamera.classList.add('hidden');
    
    // 启用控制按钮
    startRecordingBtn.disabled = false;
    takePhotoBtn.disabled = false;
    closeCameraBtn.disabled = false;
    startCameraBtn.textContent = '切换摄像头';
    startCameraBtn.classList.remove('bg-primary', 'hover:bg-blue-600');
    startCameraBtn.classList.add('bg-gray-600', 'hover:bg-gray-700');
    
    updateStatus('摄像头已开启,可以开始录制或拍照', 'success');
  } catch (err) {
    updateStatus(`无法访问摄像头: ${err.message}`, 'error');
    console.error('访问摄像头失败:', err);
    noCamera.classList.remove('hidden');
  }
}

// 关闭摄像头
function closeCamera() {
  if (!mediaStream) return;
  
  // 停止所有流轨道
  mediaStream.getTracks().forEach(track => track.stop());
  mediaStream = null;
  
  // 更新UI
  preview.srcObject = null;
  noCamera.classList.remove('hidden');
  
  startRecordingBtn.disabled = true;
  stopRecordingBtn.disabled = true;
  takePhotoBtn.disabled = true;
  closeCameraBtn.disabled = true;
  startCameraBtn.textContent = '开启摄像头';
  startCameraBtn.classList.remove('bg-gray-600', 'hover:bg-gray-700');
  startCameraBtn.classList.add('bg-primary', 'hover:bg-blue-600');
  
  updateStatus('摄像头已关闭', 'info');
}

5. 实现视频录制功能

使用 MediaRecorder API 实现视频录制:

// 开始录制
function startRecording() {
  if (!mediaStream) return;
  
  try {
    // 创建录制器
    mediaRecorder = new MediaRecorder(mediaStream);
    recordedChunks = [];
    
    // 监听数据可用事件
    mediaRecorder.ondataavailable = (event) => {
      if (event.data.size > 0) {
        recordedChunks.push(event.data);
      }
    };
    
    // 监听录制停止事件
    mediaRecorder.onstop = () => {
      const blob = new Blob(recordedChunks, { type: 'video/webm' });
      recordedChunks = [];
      saveRecording(blob);
    };
    
    // 开始录制
    mediaRecorder.start();
    isRecording = true;
    
    // 更新UI
    startRecordingBtn.disabled = true;
    stopRecordingBtn.disabled = false;
    takePhotoBtn.disabled = true;
    closeCameraBtn.disabled = true;
    
    updateStatus('正在录制视频...', 'warning');
    
    // 添加录制指示器动画
    const indicator = document.createElement('div');
    indicator.className = 'absolute top-3 left-3 z-10 bg-red-500 rounded-full w-3 h-3 animate-pulse';
    document.getElementById('camera-container').appendChild(indicator);
  } catch (err) {
    updateStatus(`录制失败: ${err.message}`, 'error');
    console.error('录制失败:', err);
  }
}

// 停止录制
function stopRecording() {
  if (!mediaRecorder || !isRecording) return;
  
  // 停止录制
  mediaRecorder.stop();
  isRecording = false;
  
  // 更新UI
  startRecordingBtn.disabled = false;
  stopRecordingBtn.disabled = true;
  takePhotoBtn.disabled = false;
  closeCameraBtn.disabled = mediaStream ? false : true;
  
  // 移除录制指示器
  const indicators = document.querySelectorAll('#camera-container > div.animate-pulse');
  indicators.forEach(indicator => indicator.remove());
  
  updateStatus('视频录制已完成', 'success');
}

6. 实现拍照功能

使用 Canvas API 实现拍照功能:

// 拍照
function takePhoto() {
  if (!mediaStream) return;
  
  // 创建Canvas并绘制当前帧
  const canvas = document.createElement('canvas');
  canvas.width = preview.videoWidth;
  canvas.height = preview.videoHeight;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(preview, 0, 0, canvas.width, canvas.height);
  
  // 转换为图片URL
  const photoUrl = canvas.toDataURL('image/jpeg');
  savePhoto(photoUrl);
  
  updateStatus('照片拍摄成功', 'success');
  
  // 添加拍照效果
  const flash = document.createElement('div');
  flash.className = 'absolute inset-0 bg-white opacity-0 transition-opacity duration-300';
  document.getElementById('camera-container').appendChild(flash);
  flash.style.opacity = '1';
  setTimeout(() => {
    flash.style.opacity = '0';
    setTimeout(() => flash.remove(), 300);
  }, 100);
}

7. 保存和展示媒体文件

// 保存录制的视频
function saveRecording(blob) {
  const videoUrl = URL.createObjectURL(blob);
  
  // 创建视频元素
  const videoElement = document.createElement('video');
  videoElement.className = 'w-full h-auto rounded-lg shadow-md hover:shadow-lg transition-all duration-300';
  videoElement.controls = true;
  videoElement.src = videoUrl;
  
  // 创建卡片
  const card = createMediaCard(videoElement, 'video');
  
  // 添加下载按钮
  const downloadBtn = document.createElement('a');
  downloadBtn.href = videoUrl;
  downloadBtn.download = `recording-${new Date().toISOString().replace(/:/g, '-')}.webm`;
  downloadBtn.className = 'mt-2 inline-block bg-primary hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium flex items-center justify-center w-full';
  downloadBtn.innerHTML = '<i class="fa fa-download mr-1"></i> 下载视频';
  card.appendChild(downloadBtn);
  
  // 添加到结果区域
  addToResults(card);
}

// 保存拍摄的照片
function savePhoto(photoUrl) {
  // 创建图片元素
  const img = document.createElement('img');
  img.className = 'w-full h-auto rounded-lg shadow-md hover:shadow-lg transition-all duration-300';
  img.src = photoUrl;
  img.alt = '拍摄的照片';
  
  // 创建卡片
  const card = createMediaCard(img, 'photo');
  
  // 添加下载按钮
  const downloadBtn = document.createElement('a');
  downloadBtn.href = photoUrl;
  downloadBtn.download = `photo-${new Date().toISOString().replace(/:/g, '-')}.jpg`;
  downloadBtn.className = 'mt-2 inline-block bg-primary hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium flex items-center justify-center w-full';
  downloadBtn.innerHTML = '<i class="fa fa-download mr-1"></i> 下载照片';
  card.appendChild(downloadBtn);
  
  // 添加到结果区域
  addToResults(card);
}

// 创建媒体卡片
function createMediaCard(element, type) {
  const card = document.createElement('div');
  card.className = 'bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 transform hover:-translate-y-1';
  
  const cardHeader = document.createElement('div');
  cardHeader.className = 'p-3 bg-gray-50 flex justify-between items-center';
  
  const typeBadge = document.createElement('span');
  typeBadge.className = `px-2 py-0.5 rounded-full text-xs font-medium ${
    type === 'video' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
  }`;
  typeBadge.textContent = type === 'video' ? '视频' : '照片';
  
  const timeStamp = document.createElement('span');
  timeStamp.className = 'text-gray-500 text-xs';
  timeStamp.textContent = new Date().toLocaleString();
  
  cardHeader.appendChild(typeBadge);
  cardHeader.appendChild(timeStamp);
  
  const cardBody = document.createElement('div');
  cardBody.className = 'p-3';
  cardBody.appendChild(element);
  
  card.appendChild(cardHeader);
  card.appendChild(cardBody);
  
  return card;
}

// 添加到结果区域
function addToResults(element) {
  // 清空空状态提示
  if (resultsContainer.querySelector('.col-span-full')) {
    resultsContainer.innerHTML = '';
  }
  
  // 添加新内容
  resultsContainer.prepend(element);
  
  // 添加动画效果
  element.style.opacity = '0';
  element.style.transform = 'translateY(20px)';
  setTimeout(() => {
    element.style.opacity = '1';
    element.style.transform = 'translateY(0)';
  }, 50);
}

8. 事件监听和初始化

最后,添加事件监听器和页面初始化代码:

// 事件监听
startCameraBtn.addEventListener('click', async () => {
  if (!mediaStream) {
    await getCameraDevices();
    await startCamera();
  } else {
    await getCameraDevices();
    if (cameraSelect.options.length > 1) {
      // 切换到下一个摄像头
      const currentIndex = Array.from(cameraSelect.options).findIndex(option => option.selected);
      const nextIndex = currentIndex < cameraSelect.options.length - 1 ? currentIndex + 1 : 1;
      cameraSelect.selectedIndex = nextIndex;
      await startCamera(cameraSelect.value);
    } else {
      updateStatus('没有可切换的摄像头设备', 'warning');
    }
  }
});

closeCameraBtn.addEventListener('click', closeCamera);
startRecordingBtn.addEventListener('click', startRecording);
stopRecordingBtn.addEventListener('click', stopRecording);
takePhotoBtn.addEventListener('click', takePhoto);

cameraSelect.addEventListener('change', async () => {
  if (cameraSelect.value) {
    await startCamera(cameraSelect.value);
  }
});

// 页面加载时检查权限
document.addEventListener('DOMContentLoaded', async () => {
  try {
    // 检查媒体设备权限
    const permissionStatus = await navigator.permissions.query({ name: 'camera' });
    
    if (permissionStatus.state === 'granted') {
      updateStatus('已授予摄像头访问权限,可以随时开启摄像头', 'info');
      await getCameraDevices();
    } else if (permissionStatus.state === 'prompt') {
      updateStatus('点击"开启摄像头"按钮并授予访问权限', 'info');
    } else {
      updateStatus('请在浏览器设置中授予摄像头访问权限', 'warning');
    }
    
    // 监听权限状态变化
    permissionStatus.onchange = () => {
      updateStatus(`摄像头权限状态已更新: ${permissionStatus.state}`, 'info');
    };
  } catch (err) {
    updateStatus('无法检查摄像头权限', 'warning');
    console.error('检查摄像头权限失败:', err);
  }
});

五、应用优化与进阶功能

1. 浏览器兼容性处理

尽管大多数现代浏览器都支持 MediaDevices API 和 MediaRecorder API,但为了确保在各种浏览器中都能正常工作,建议添加适当的兼容性处理:

// 兼容性处理
navigator.mediaDevices = navigator.mediaDevices || 
  ((navigator.mozGetUserMedia || navigator.webkitGetUserMedia) ? {
    getUserMedia: function(c) {
      return new Promise(function(y, n) {
        (navigator.mozGetUserMedia || 
         navigator.webkitGetUserMedia).call(navigator, c, y, n);
      });
    }
  } : null);

// 检查浏览器是否支持必要的API
if (!navigator.mediaDevices) {
  updateStatus('您的浏览器不支持摄像头API', 'error');
  startCameraBtn.disabled = true;
}

2. 资源管理与性能优化

在应用中,合理管理资源和优化性能非常重要:

  • 在组件卸载或页面关闭时停止所有媒体流
  • 使用 requestAnimationFrame 优化视频渲染
  • 限制录制视频的分辨率以降低性能消耗
  • 实现录制缓冲区管理,避免内存溢出

3. 进阶功能扩展

基于现有的代码基础,你可以进一步扩展以下功能:

  • 添加视频滤镜和图像处理
  • 实现多摄像头同时录制
  • 添加实时音频录制功能
  • 实现视频剪辑和编辑功能
  • 集成云端存储和分享功能

六、总结

通过本文的学习,你已经掌握了如何使用原生 JavaScript 实现浏览器摄像头控制和视频录制功能。我们使用了 MediaDevices API 获取摄像头流,MediaRecorder API 录制视频,以及 Canvas API 实现拍照功能。

现在,你可以将这些知识应用到实际项目中,开发出更加复杂和专业的网页应用。

七、常见问题解答

  1. 为什么我的摄像头无法正常工作?

    • 确保你的浏览器有访问摄像头的权限
    • 检查是否有其他应用正在使用摄像头
    • 尝试在不同的浏览器中测试
  2. 录制的视频文件很大,如何优化?

    • 可以通过设置 MediaRecordervideoBitsPerSecond 参数降低视频质量
    • 考虑使用更高效的视频编码格式
    • 实现分段录制和压缩处理
  3. 如何在移动设备上优化体验?

    • 使用响应式设计适应不同屏幕尺寸
    • 考虑添加触摸友好的控制界面
    • 测试不同移动浏览器的兼容性

7000块帮朋友做了2个小程序加一个后台管理系统,值不值?

5、6、7月份副业共收入近15000块呢,这个数字我很满意,其中主要包括3个活:

    1. 帮助朋友亲戚做24小时自助洗车软件(用户端小程序 + 商家端小程序 + PC后台管理系统),报价7000块,到今日已经做完全部功能,也改完了几轮bugfix。首付款拿到70%(4900块),预计本月底结清。难度:高
    1. 在一个群里面看到有老板发布说参加AI大赛,主要是使用AI做一些应用,收集好的AI应用想法,符合要求一个作品(要求没那么高,可以是demo小单页面)给200块。 跟老板商量一个作品预付款50块, 我总共投稿了38个应用(哈哈哈我老肝了,平均每天搞6个作品,预付款就能拿到300块),整体预付款拿到近2000块钱。历时2个月左右,本以为尾款没希望了。 没想到在7月4日早上正在地铁上班途中收到了老板的尾款转账5260块。38个作品符合要求的有37个,因为投稿要手机号,让老板找人待投了17个(一个扣15块),其他的自己找家里面人手机号弄的。 难度:低
    1. 6月30日收到掘金4月份金石计划的赏金201.62块。难度:中

24小时自助洗车软件7000块

我在之前文章中也更新过,之前领导同事找我做私活,兼职每个月给1万,我没干(就TM懒)介绍给了同事18000全职的事情(听他说总共做了4-5个月)。相比这那个兼职这个洗车软件活的性价比很低

image.png

做这个的洗车软件,付出的精力真挺多的,基本每天晚上下班后都要写功能到很晚,以及周六、周天都在写功能,一坐就是一天。历时2个多月,5月7日正式开始,到今日偶尔还在改bug

image.png

除了一些写代码逻辑相关,晚上还有好多时间要在开会:

    1. 开会对需求
    1. 开会对实现方案
    1. 开会UI评审
    1. 自测改bug

这里提一下如果后端的水品不行, 或者因为是私活写的代码逻辑不那么完善,联调起来真的很慢,有的时候一坐一下午还不能联联完一个模块,一调一个报错,真TM心累。

下面说一下我们的洗车应用总共有以下几个模块

PC端

    1. 登录模块 (若依)
    1. 用户角色模块、字典、部门(若依)
    1. 订单管理
    • 3.1 订单列表
    • 3.2 美团核销
    • 3.3 抖音核销
    1. 会员管理
    • 4.1 会员列表
    1. 营销管理
    • 5.1 充值规则
    1. 门店管理
    • 6.1 门店列表
    • 6.2 股东提现记录
    • 6.3 门店未消费余额
    1. 分润管理
    • 7.1 分润配置
    • 7.2 分润记录
    • 7.3 银行卡信息
    1. 设备管理 (门店的洗车24小时设备)

说一下我们使用的若依应用,本身刚看到这个后台管理样式的时候我还看不上,说样式好难看。可能是好几年前使用的element UI,并且也没有对其优化,看起来就像个老系统。但是用起来还真不错,它的代码不说写的有多好,但是该有的功能都有了。比如字典、用户中心、角色菜单鉴权、登录等等都可以不用再次开发,也节省了很多时间。

再本次使用若依中,我总共封装了4个小业务组件:

  • car-date-time-range: 时间带快捷选择范围选择器(今日、昨日、本月、上月),客户要求所有的时间控件都要有快捷选择。
  • car-search: 配置式的列表查询组件, 支持查询、重置
export const SEARCH_OPTIONS = [
  {
    label: "网点名称",
    prop: "name",
    component: CarSelect,
    placeholder: "请输入网点名称",
    componentOptions: {
      placeholder: "请选择网点名称",
      options: [
        {
          value: "选项1",
          label: "黄金糕",
        },
        {
          value: "选项2",
          label: "双皮奶",
        },
        {
          value: "选项3",
          label: "蚵仔煎",
        },
        {
          value: "选项4",
        },
      ],
    },
  },
  {
    label: "手机号",
    prop: "name",
    component: "el-input",
  },
  {
    label: "注册时间",
    prop: "name",
    component: "el-input",
  },
  {
    label: "渠道",
    prop: "name",
    component: CarSelect,
    componentOptions: {
      options: [],
    },
  },
];
/**
 * 使用方式
 *  <CarSearch
      :searchConfig="SEARCH_OPTIONS"
      :searchParams="searchParams"
      v-show="searchParams.visible"
      @search="onSearch"
    />
 */
  • car-select: Elemnet Select 组件不支持配置式,简单封装了一下,为了给CarSearch配置使用
  • car-module: 类似于Element Card 组件,每个模块都套了一层展现:标题、内容、操作。

总结:若依真的挺好用的,什么请求封装等都有了,基础功能都不用你再次开发了。下次有私活了,还要用它哈哈。

用户端小程序

用户端小程序功能说明如下:

  • 首页

    • 总金额(展示)
    • 扫码启动应用(功能)
    • 优惠券对换框 (入口跳转)
    • 附近网点(入口跳转)
    • 充值购卡(入口跳转)
    • 联系客服 (弹窗)
    • 启动页面(弹窗)
    • 余额不足提示(弹窗)
  • 附件网点

    • 门店列表展示(支持排序:最近、空位,支持城市查询)
    • 门店详情展示
  • 我的

    • 充值金额(展示)

    • 赠送金额(展示)

    • 优惠券数量(展示)

    • 积分(展示)

    • 我的订单(模块)

      • 订单列表展示(消费、充值)
    • 优惠券

      • 优惠券列表展示(未消费、已消费、已过期TAB切换)
    • 我要充值

      • 充值规则列表展示(点击充值)
      • 充值成功页面(就是个倒计时要不要洗车、以及充值信息展示)
    • 团购核销

      • 美团、抖音券核销
      • 兑换成功页面(就是个倒计时要不要洗车、以及兑换成功信息展示)
    • 收费标准

      • 一个表格展示洗车收费计价规则
    • 积分商城

      • 里面就提示还在建设中
    • 合作加盟

    • 里面是手机号 + 微信二维码

微信支付

我们支付对接的是易宝支付,有相关需求的可以去找他们客服人员支持,由后端调用他们的API,返回的是直接调用wx.requestPaymentapi的参数,就可以直接进行微信支付了。 钱会在第二天12点前打到商家账户中,才可以提现。

扫码洗车

我们的PC端在新增设备的时候会生成一个小程序二维码,用户扫码之后就可以进行余额、优惠券启动设备了。

微信小程序二维码再生成的时候会让填写打开的Path路径,比如/pages/index/index?deviceId=12222222,那么用户使用微信扫码之后就会打开这个小程序进入这个页面。可以通过page的onLoad方法获取到deviceId参数。

Page({
  /**
   * 生命周期函数--监听页面加载
   */
  async onLoad(options) {
    await this.getUserInfo()
    // options.deviceId 获取扫码的设备ID 
    if(this.data.isLogin && options.deviceId) { // 已登陆 + 有设备ID 出现启动设备弹窗
      setDeviceId(options.deviceId)
      this.setData({
        deviceId: options.deviceId,
        isShowStart: true,
      })
    }
    if(!this.data.isLogin && options.deviceId) { // 未登录 + 有设备ID  去登录
        await isToLogin(options.deviceId)
    }
    if(this.data.branchePhone && !options.deviceId && this.data.isLogin) { // (已登陆 + 绑定过网点 + 没有设备ID参数时) 校验最低钱 提示充值弹窗
      this.getMinMoney()
    }
  },
})

我们小程序里面还有个扫一扫,可以让用户在进入小程序内直接操作,直接调用wx.scanCode的APi:

// 定义一个工具函数,用于解析 URL 参数
export function getQueryData(url) {
    const query = {};
    const parts = url.split('?');
    if (parts.length === 2) {
      const queryPart = parts[1];
      const params = queryPart.split('&');
      for (let i = 0; i < params.length; i++) {
        const param = params[i].split('=');
        const key = decodeURIComponent(param[0]);
        const value = decodeURIComponent(param[1]);
        query[key] = value;
      }
    }
    return query;
  }

page({
  onScan() {
    const vm = this
    wx.scanCode({
       onlyFromCamera: false,
       async success(event: any) {
            //path: "/pages/index/index?deviceId=111111111111"
            if(event.path) {
               const queryData = getQueryData(event.path) as any
               if(queryData.deviceId) {
                await isToLogin(queryData.deviceId)
                vm.readyStart(queryData.deviceId as any)
                setDeviceId(queryData.deviceId)
               }
            }
        },
        fail(error) {
          console.log(error,'微信扫码错误')
        }
    })
  },
})

手机号一键登录

这个是微信的button组件,直接定义类型就会弹出需要用户手机号登录的授权了。这个组件免费额度有1000次,后续就要充值续费才能使用了。因为我们登录页面有用户协议以及隐私政策,我写了个普通按钮以及手机号组件按钮切换,来实现提示勾选功能。

TIPS: 我们按钮原本叫《微信一键登录》,后续在小程序提交审核中,被拒绝了。说:你好,你的小程序登录页面或弹窗(调用手机号快速验证组件的前置页面),存在混淆腾讯官方的元素,包括但不限于"微信"字样、微信官方logo等,请去除相关元素,如:将手机号授权登录提示修改为"手机号快捷登录“

<button class="login-button" wx:if="{{!isCheck}}" bind:tap="onLogin">手机号快捷登录</button>
<button class="login-button" wx:else open-type="getPhoneNumber" bindgetphonenumber="onLogin">手机号快捷登录</button>

原本我们的小程序,需要用户手机号登录后,才可以使用,但是在审核中一直被卡。说明如下:

你好,小程序一进入(首页]页面未浏览体验功能服务,即要求授权手机号码、头像、呢称进行授权登录请在用户体验浏览功能服务后,再自行选择授权登录。请整改后再提交审核。

后面我们申诉:说我们的洗车工具是强服务类型的,我们的用户大多来自于线下门店扫码使用。然后又被驳回了,让我们在首页增加特殊说明:

你好,小程序一进入页面即要求授权手机号码登录,属于特定服务人群使用,请在首页页面补充说明账号仅限特定人群登录并进行登录账号鉴权。请整改后再提交审核。

添加说明如下:

<view>- 尊敬的客户,欢迎使用XXXX自助洗车小程序!</view>
<view>- 使用前请先登录,以便记录您的洗车订单和优惠权益。</view>
<view>- 未登录用户无法启动设备,请谅解。</view>
<view>- 登录后,您可享受充值金额赠送等专属服务。</view>

这么操作下来,通过是能通过了,但是由于每次的审核人员都不一样,有的时候就是在审核说明中提说我们已经按照之前的审核增加强服务工具类的特殊说明,但是还会被卡审核。

我们为此还开了个会议,因为本身是客户的需求是登录后才能使用,1、为了我们最小成本不用做改动。2、满足客户的需求。我们项目经理还编辑了一段文本(不愧是领导,做的总结就是有水平,我要是审核人员看到后可能都给通过了):

审核小哥哥/小姐姐您辛苦了,在提交版本前跟您提前反馈一个易打回问题,这个问题已经整改过了,开头的话在单独做一下背景说明。

关于首页就是登陆页的场景说明

我们是做自助洗车的,小程序是强工具类型,我们的用户都来自于线下的门店扫码,需要登录才后才能洗车。之前的审核同学已告知需要在首页添加说明即可通过,所以小程序对此问题已经做过处理啦。辛苦您再次审核了。

本次小程序上线的内容主要是xxxx

感谢您抽出宝贵的时间审核,感谢感谢!

但是但是通过率还是不高,最后跟客户协商更改下逻辑,让先浏览网点内容,在选择要不要进行登录。在每个需要登录访问的入口增加公共的判断如下(利用promise reject 阻止后续代码执行):

export const isToLogin = (deviceId) => {
  return new Promise((reslove, reject) => {
    const isLogin = getIsLogin()
    if(isLogin) {
      reslove('已登陆!!!,程序继续执行!')
    }else {
      const pages = getCurrentPages();
      const currentPage = pages[pages.length - 1];
      const currentRoute = currentPage.route;
      const currentOptions = currentPage.options;


      wx.navigateTo({
        url: `/pages/login/login?redirectUrl=${currentRoute}&deviceId=${currentOptions?.deviceId || deviceId || ''}`,
      })
      reject('未登录,不可继续执行程序,跳转登录中')
    }
  })
}

小程序更新页面缓存

统计信息我们放在一个userinfo接口中,使用wx.setStorageSync存储本地,在各个page中获取使用。

金额、消费券等统计信息都会在用户洗车消费完成、以及充值、兑换优惠券完成后需要刷新值。而且大家也都知道自助洗车是在洗车完成后才结算账单,那么我们就需要一个实时获取账单状态的定时任务,并且如果用户在页面停留(首页)在完成后更新页面上的值。

但是小程序没有一个Vuex或者Redux,那么更新界面的值就是个问题了。我们的统计信息在【首页、我的】有展示,我可以在首页Page、以及用户Page中我可以在有订单在洗车中,生命周期中Show启动定时任务、hide中关闭定时任务。

// order-status.js
import apiService from '../api/index'

import { reloadUserInfo } from './user-info'

const ORDER_ACCOUNT_UUID_KEY = 'ORDER_ACCOUNT_UUID_KEY'

export const setOrderAccountUUID = (data) => {
  wx.setStorageSync(ORDER_ACCOUNT_UUID_KEY, data)
}

export const getOrderAccountUUID = () => {
   return wx.getStorageSync(ORDER_ACCOUNT_UUID_KEY)
}

export const removeOrderAccountUUID = () => {
    wx.removeStorageSync(ORDER_ACCOUNT_UUID_KEY)
}

const paddingUpdateRegister = [] // { key: 'index', callback() {}}

export const registerUpdataCallback = (data) => {
    removeUpdataCallback(data.key)
    paddingUpdateRegister.push(data)
}

export const removeUpdataCallback = (key) =>{
  const findIndex = paddingUpdateRegister.find((item) => item.key === key);
  if(findIndex !== -1) {
    paddingUpdateRegister.splice(findIndex, 1)
  }
}


// 订单状态  1:已付款  3:已结算
let timer = null
export const startGetOrderResultTask = async () => {
    if(timer) {
       clearTimeout(timer)
    }
    const orderUUID = getOrderAccountUUID()

    if(orderUUID) { // 有订单id 刷新状态
       const response = await apiService.getOrderStatusApi({
         uuid: orderUUID,
         unShowLoading: true
       })
       if(response?.data?.orderStatus === '3') {
        removeOrderAccountUUID()
          // 订单结算完成,更新userinfo 接口中的金额相关字段
          await reloadUserInfo()
          // 更新界面展示的值
          paddingUpdateRegister.forEach((item) => {
            item?.callback()
          })
       }else {
        timer = setTimeout(() => {
             startGetOrderResultTask()
           }, 10000)
       }
    }
}

我会在启动设备完成后调用setOrderAccountUUID存储一个订单ID,然后分别在首页、我的页面注册回调函数、以及启动定时任务检测(洗车中,其实可以在小程序端继续使用的)。

使用如下:

const UPDATE_KEY = 'index' // 首页模块
Page({
 /**
   * 生命周期函数--监听页面显示
   */
  onShow() {
    const vm = this;
    registerUpdataCallback({
      key: UPDATE_KEY,
      callback() {
        const storgeUserInfo = getUserInfo();
        vm.setData({
            totalAmount: storgeUserInfo?.totalAmount || 0,
        })
      }
    })
  },

  /**
   * 生命周期函数--监听页面隐藏
   */
  onHide() {
    removeUpdataCallback(UPDATE_KEY)
  },
})

小程序端API代理

我之前跟别的同学协作开发过小程序,但是我是中途加入开发的,并没太在意API代理的问题,我查了好多文档,里面都没有提到代理的方案。问Gpt,他告诉我的是网络代理,直接给我的小程序开发者工具打不开了。

我还给我们的服务的同学说,微信小程序没办法代理,你们需要把API都改成GET请求,要POST的话就需要把CROS允许我跨域。

最后最后才知道微信小程序运行在微信客户端内,不基于浏览器引擎,因此不受传统浏览器同源策略(协议/域名/端口完全一致)的约束.

使用第三方npm插件构建

我是想在小程序中使用vant相关组件,但是再首次构建后(下载依赖后,点击开发者工具-> 构建NPM)再页面引入发生错误,原因是根目录中的project.config.json配置错误,可是我使用的是官方TS + LESS模版额,还需要配置:

{
 "packNpmManually": true,
 "packNpmRelationList": [
    {
        "packageJsonPath": "./package.json",
        "miniprogramNpmDistDir": "./miniprogram/"
    }
  ],
}

默认给我编译到根目录了,正常需要再/miniprogram/下面,然后删除构建的miniprogram_npm,重新构建NPM就行了。

Vant Picker组件样式更改

我们附近网点有个城市筛选,展示的是省市区筛选,那默认白色的肯定不能再,我们整体红色的小程序中使用了,然后我就更改了下他的样式如下:

.van-picker {
    background: #681414 !important;
}

.van-picker-column__item--selected  {
    color: #F7E2D8 !important;
}
.van-picker-column, .van-picker__cancel,.van-picker__title, .van-picker__confirm     {
    color: #F7E2D8 !important;
}

/* 主要是这里 hsla 就是你的北京主题色0.9 到 0.4  hsla(0,68%,24%,.9) -> hsla(0,68%,24%,.4)) */
.van-picker__mask {
    background-image: linear-gradient(180deg,hsla(0,68%,24%,.9),hsla(0,68%,24%,.4)),linear-gradient(0deg,hsla(0,68%,24%,.9),hsla(0,68%,24%,.4)) !important;
}

.van-hairline--bottom:after, .van-hairline--left:after, .van-hairline--right:after, .van-hairline--surround:after, .van-hairline--top-bottom:after, .van-hairline--top:after, .van-hairline:after {
  border-color: rgba(247,226,216,.2) !important;
}

绑定对象数据偶尔获取不到

在微信小程序中我们循环列表,然后这个列表有事件需要获取item数据,你使用data-item="{{item}}"后,再事件中使用 bind:tap="onItemClick"绑定事件

Page({
  onItemClick(event) {
       const {item = {}} = event.target.dataset; // 获取数据 

       // 以上这种方式再获取绑定对象数据时会纯在,获取不到数据

       // 需要变更为:
        const {item = {}} = event.currentTarget.dataset

  }
})

总结绑定数据时,字符串、数字等简单类型可以使用event.target.dataset,绑定数组、对象等就需要使用event.currentTarget.dataset

长按扫描图片二维码

再image上增加show-menu-by-longpress="{{true}}

 <image src="{{imgUrl}}" mode="" show-menu-by-longpress="{{true}}"/>
  <view>长按识别微信</view>

小程序阻止事件冒泡

以为阻止事件冒泡也是使用点击事件上的e.stopPropagation(); // 阻止事件继续向上冒泡,是的Kimi也是这么告诉我的,让我尝试了好大一会,最后是更改绑定事件实现的catch:tap

<view bind:tap="onParentClick">
  父元素
  <view catch:tap="onChildClick">子元素</view>
</view>

小程序打开地图APP功能

使用微信内置地图查看位置

wx.openLocation({
    atitude: item.latitude, // 目标地点纬度
    longitude: item.longitude, // 目标地点经度
    scale: 15,
    name: item.branchName, // 地点名称
    address: `${item.province || ''}${item.city || ''}${item.district || ''}` // 地点详细地址
});

这么就能打开手机APP导航了,地图展示如下:

小程序中唤起拨打电话功能

Page({
 onCall() {
     const item = this.data.item;
       wx.makePhoneCall({
        phoneNumber: `${item.contactPhone}`, // 要拨打的电话号码
        success: function() {
          console.log("拨打电话成功!");
        },
        fail: function(error) {
            console.log(error,'error111')
          console.log("拨打电话失败!");
        }
      });  
    },
})

列表下拉刷新、滚动加载组件

我记得我在大约6年前,为了使用下拉刷新、滚动加载功能还引入了一个组件才能使用。那个时候不知道有没有scroll-view这个官方组件,也有可能之前就有,是我没仔细查看文档。

    <view class="content" style="height: calc(100% - 90rpx);">
    <scroll-view
        class="scrollarea"
        scroll-y="{{scrollY}}"
        type="list"
        style="height: 100%;"
        refresher-enabled
        refresher-background="#681414"
        bindrefresherrefresh="onPullDownRefresh"
        refresher-triggered="{{isRefreshing}}"
        bindscrolltolower="onReachBottom"
    >
        <view class="shop-list" wx:if="{{list.length}}">
            <shop-item
                wx:for="{{list}}"
                wx:key="index"
                class="shop-item"
                item="{{item}}"
                data-item="{{item}}"
                bind:click="onItemClick"
            />
        </view>
        <!-- 空数据组件,允许下拉刷新 -->
        <car-empty wx:else/> 

        <!-- 列表加载完成后展示 -->
        <car-list-complate wx:if="{{isLoadAll}}"/>
    </scroll-view>
</view>
Page({
  /**
     * 页面相关事件处理函数--监听用户下拉动作
     */
  async onPullDownRefresh() {
        this.setData({ isRefreshing: true,pageNum: 1, isLoadAll: false }); // 开始刷新

        await this.getData(true)
            // 停止下拉刷新
        this.setData({ isRefreshing: false }); // 开始刷新   
    },

    /**
     * 页面上拉触底事件的处理函数
     */
    onReachBottom() {
        if(this.data.total > this.data.list.length) {
        this.getData()
        }else {
         this.setData({
            isLoadAll: true
         })
        }
    },
    async getData(reload = false) {
    const params = {}
    const response = await apiService.getOrderListApi(params)
    this.setData({
      total: response.total || 0,
      list: (reload ? []: this.data.list).concat(response?.rows || []), // reload 标识是重新加载还是 滚动加载拼接
      pageNum: this.data.pageNum + 1,
    })
  },
})

小程序发布体验版本上线审核流程

    1. 点击开发者工具的【上传】按钮,然后填写对应的版本号,以及版本描述。
    1. 登录小程序管理后台,点击版本管理,然后把开发版本中的可以置为【体验版本】,可以下载体验版本二维码给开发者、运营者等查看。
    1. 把本次开发版本提交审核,如果你使用某些获取用户权限的API,需要填写具体用途。
    1. 审核通过后,需要在审核版本中点击上线,才是正式上线。
    1. 上线后,未提交备案的小程序,在微信搜索功能查不到小程序。只能通过分享打开小程序。

可信域名配置

上传完代码,访问体验版本以及上线需要访问API地址必须是域名,并且需要在微信后台管理中配置可信域名才能访问。本地开发是IP地址也都无所谓。

总结: 之前虽然也搞过微信小程序,但是并没有这么完整,本次也算是积累不少小程序开发的经验

小程序商家端

商家端小程序功能说明如下:

  • 登录 账号密码登录

  • 首页

    • 下拉切换网点(以下功能是都跟网点挂钩的, 网点相当于门店)

    • 当日洗车、收益、消费、会员统计概览 (展示、切换网点更新数据)

    • 收益管理(菜单跳转)

      • 当日收益、当月收益、累计收益统计概览

        • 【支持】跳转查看详情,月、年支持切换查看详情(表格展示)
      • 当日消费、当月消费、累计消费统计概览

        • 【支持】跳转查看详情,月、年支持切换查看详情(表格展示)
      • 当日会员、当月会员、累计会员统计概览

        • 【支持】跳转查看详情,月、年支持切换查看详情(表格展示)
      • 当日洗车、当月洗车、累计洗车概览

      • 跨店支出、跨店消费、综合收入概览

      • 平均洗车单价、平均洗车时长、平均洗车频次统计概览

    • 提现管理(菜单跳转)

    • 提现管理概览统计(保证金、已提现、可提现、待提现)

    • 提现记录(表格展示提现时间、手续费、提现时间)

    • 提现功能(弹窗)

    • 数据金额提现(提现成功刷新提现记录)

    • 分润记录(菜单跳转)

    • 展示分润总额

    • 展示分润记录表格,下拉刷新,滚动加载(分润金额、分润比例、分润时间)

    • 支出切换时间范围筛选

    • 续费管理 (菜单跳转)

    • 门店续费记录(列表:短信续费记录 + 主板设备续费)下拉刷新、滚动加载

    • 门店已有设备列表展示

    • 短信续费,选择A、B、C、D不同条数方案充值续费

    • 主板设备续费,选择设备列表进行续费

    • 我的订单 (菜单跳转)

    • 列表展示我的订单,支持时间范围查询、支持订单类型查询、支持跨店消费类型查询、支持手机号查询。下拉刷新、滚动加载

    • 设备状态(菜单跳转)

    • 折叠面板展示网点下面的各个设备状态,空闲、洗车中、停用

    • 退出登录

商家端好像没有什么总结的,都是一些统计类的,主题色跟用户端一样,就还原设计就行。

洗车软件总结

我只能说如果我不换工作,不是待遇下降了30%,7000块就是帮朋友3个应用,我应该不会接。有孩子,有房贷,老婆不上班,我换工作后降薪。

性价比应该算是相当低了,我之前文档中还说接私活你就按照你的正常日薪 * 工时评就行。

现在的我很感谢朋友来找我做这个私活,利用业余时间,可以有个不错的外快,累是累了点。但是买手机真香啊,618刚给老婆买了个苹果16 8 + 256 5200. 她老说苹果12内存不够用了,天天在清理内存

之前我一直做TOB的业务,很少做小程序,之前也写过感觉比较简单,看看文档就行帮朋友写过几个小模块,这次也算完整做了2个微信小程序,积累的不少开发经验。

并且听朋友的意思还有二期功能,好像要做会员功能,不知道再能挣多少。

AI大赛投稿

这个活动其实我本身也是质疑活动的真实性,而且给老板说了好久他才答应给我预付款50,我也告诉我朋友让他搞,他也就搞了2个。他说太费劲了,最后钱还不一定能拿到。

我本来计划搞50个呢,谁知道搞到38个时候老板告诉我名额满了,后悔礼拜六礼拜天没有多搞点(其实我也没想着能拿到尾款,就想着每天搞5~6个,一天300块也挺不错)

后面也跟老板多聊了下,让他以后有私活了优先找我。

掘金金石计划

我在4月份利用空闲时间学习了下qiankun微应用源码,总结更新了几篇系列文章:

以及俩个组件源码的经验总结分享:

总结

以上就是我在5、6、7月份利用业余时间来挣到的一些钱,希望能多多来单子,有老板资源多的,可以拉我一把。活好、责任心强

React Router Dom 初步:从传统路由到现代前端导航

写在前面

最近在学习 react-router-dom,深感前端路由的重要性。从最初的后端路由到现在的前端路由,这个演进过程值得我们深思。今天就来聊聊 React Router Dom 7.6.3 的简单实战应用,顺便梳理一下前端路由的发展历程。

路由的前世今生

后端路由时代的回忆

还记得早期的 Web 开发吗?那时候的路由完全由后端控制:

用户访问 /about → 服务器处理 → 返回完整的 HTML 页面

这种传统的 MVC 模式虽然简单,但存在明显的问题:

  • 前后端高度耦合
  • 每次页面跳转都需要重新加载
  • 后端工程师既要写逻辑又要管页面

前后端分离的新时代

随着 MVVM 架构的普及,前后端分离成为主流:

  • Model层:通过 fetch API 获取数据
  • View层:JSX 负责视图渲染
  • ViewModel层:useState、useEffect 等实现数据绑定

这种架构让前端彻底当家作主,后端专注于提供 API 接口,前端则通过路由来管理页面导航。

React Router Dom 实战

实际开发中的结构

1. 路由结构设计

/                    # 首页
/about              # 关于页面
/user/:id           # 用户详情
/products           # 产品列表
/products/new       # 新增产品
/products/:id       # 产品详情

2. 组件文件组织

根据实际项目结构,(部分)文件组织如下:

src/
  main.jsx                    # 应用入口
  App.jsx                     # 主应用组件和路由配置
  App.css                     # 应用样式
  index.css                   # 全局样式
  pages/
    Home/
      index.jsx               # 首页组件
    About/
      index.jsx               # 关于页面组件
    UserProfile/
      index.jsx               # 用户详情页组件
    Products/
      index.jsx               # 产品列表父组件
      ProductDetails.jsx      # 产品详情子组件
      NewProduct.jsx          # 新增产品子组件

3. 性能优化考虑

  • 使用 React.lazy 实现路由懒加载
  • 合理使用 useEffect 避免不必要的重渲染
  • 利用 useParams 获取路由参数而非 props 传递

基础配置

首先看看如何在 React 19 + React Router Dom 7.6.3 中配置基础路由:

// src/App.jsx
import{
  BrowserRouter as Router, // 前端路由
  Routes, // 路由设置
  Route,  // 单条
}from 'react-router-dom'
import { useState } from 'react' 
import './App.css'
import Home from './pages/Home'
import About from './pages/About'
import UserProfile from './pages/UserProfile'
import Products from './pages/Products'
import ProductDetails from './pages/Products/ProductDetails.jsx'
import NewProduct from './pages/Products/NewProduct'

function App() {

  return (
    <>
      {/* 前端路由接管一切,配置 */}
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/user/:id" element={<UserProfile />} />
          <Route path="/products" element={<Products />} >
          {/* 二级路由 */}
          <Route path=":productId" element={<ProductDetails />} />
          <Route path="new" element={<NewProduct />} />
          </Route>
        </Routes>
      </Router>
    </>
  )
}

export default App

这里有几个关键点:

  • BrowserRouter 使用 HTML5 History API 实现前端路由
  • Routes 作为路由容器,管理所有路由配置
  • Route 定义具体的路径和对应组件

动态路由的魅力

动态路由是现代前端应用的核心特性。看看如何处理用户个人页面:

// src/pages/UserProfile/index.jsx
import { 
  useEffect
 } from 'react'

 import {
  useParams
 } from 'react-router-dom'

const UserProfile = () => {
  const {id} = useParams();
  useEffect(()=>{
    
    console.log(window.location);
  },[])
  return (
    <>
      UserProfile{id}
    </>
  )
}

export default UserProfile

这种设计完全符合 RESTful 规范:

  • GET /users/:id - 获取用户信息
  • POST /users - 创建用户
  • PATCH /users/:id - 更新用户
  • DELETE /users/:id - 删除用户

嵌套路由的实现

实际项目中,嵌套路由必不可少。以产品管理为例:

import {Outlet} from 'react-router-dom';

const Products = () => {
  return (
    <>
      <h1>产品列表</h1>
      {/* 二级路由的占位符 */}
      <Outlet />
    </>
  )
}

export default Products


// src/App.jsx 中的路由配置
<Route path="/products" element={<Products />}>
  <Route path=":productId" element={<ProductDetails />} />
  <Route path="new" element={<NewProduct />} />
</Route>

Outlet 组件是嵌套路由的核心,它充当子路由的占位符。这样设计的好处是:

  • 父组件可以提供通用的布局和状态
  • 子组件专注于具体的业务逻辑
  • 路由层级清晰,便于维护

页面级组件的设计

每个路由对应的组件都应该是页面级组件,保持简洁明了:

// src/pages/Home/index.jsx
const Home = () => {
  return (
    <>
      首页
    </>
  )
}

export default Home

// src/pages/About/index.jsx
const About = () => {
  return (
    <>
      About
    </>
  )
}

export default About

// src/pages/Products/ProductDetails.jsx
const ProductDetails = () => {
  return (
    <>
      <h1>产品详情</h1>
    </>
  )
}

export default ProductDetails

// src/pages/Products/NewProduct.jsx
const NewProduct = () => {
  return (
    <>
      <h1>NewProduct</h1>
    </>
  )
}

export default NewProduct

React 生态系统的思考

为什么选择 React Router Dom?

React 生态系统的一个显著特点是"少就是多"。React 核心库专注于组件和状态管理,路由功能由专门的库来实现。这种设计哲学的好处是:

  • 模块化:每个库专注于特定功能
  • 可选择性:可以根据项目需求选择合适的方案
  • 可维护性:问题定位更加精准

函数式编程的体现

相比 Vue 的模板化,React 更偏向函数式编程:

// src/pages/UserProfile/index.jsx
const UserProfile = () => {
  const { id } = useParams()
  // 纯函数逻辑
  return <>UserProfile{id}</>
}

这种设计让组件更加predictable,便于测试和维护。

展示

image.png


image.png


image.png


image.png


image.png


image.png


image.png

总结

React Router Dom 7.6.3 为我们提供了强大而灵活的前端路由解决方案。通过合理的路由设计,我们可以构建出用户体验优秀的单页应用。

前端路由的发展反映了整个前端技术的演进:从简单的页面跳转到复杂的状态管理,从后端驱动到前端主导。这种变化不仅提升了用户体验,也让前端开发变得更加专业和独立。

Vue 开发者的外挂工具:配置一个 JSON,自动造出一整套页面!

🧰 引言:你是不是也这样?

作为一名前端开发者,你有没有经历过下面这些场景?

“又来一个新模块?好,先去 copy 个模板,改改名字……再写点基本结构……”

“这个组件结构每次都差不多,能不能别让我手敲了?”

“团队里每个人写的 Vue 文件格式都不一样,review 起来头大……”

如果你点头了,那这篇博客就是为你准备的!

今天我要分享的是我自己开发的一个 CLI 工具 —— catalog_file_generator,它能让你:

  • ✅ 通过一个配置文件,一键生成 Vue 页面和组件;
  • ✅ 支持多种模板类型(Vue2、Vue3、script setup);
  • ✅ 自动创建多层级目录结构;
  • ✅ 统一项目风格,提升团队协作效率;

一句话总结:它是一个“造房子”的工具,你只管画设计图,它帮你盖楼。


🔨 它到底能干啥?

场景一:批量创建模块?一行命令搞定!

你想创建 abcd 这几个模块,每个模块下都有 index.vueedit.vue,甚至还有子目录 components

传统做法是:新建目录 ➜ 拷贝模板 ➜ 改名 ➜ 修改内容 ➜ 循环重复……

catalog_file_generator 的话,只需要一个配置文件:

json
深色版本
{
  "a": {
    "index.vue": { "content": "a列表", "template": "v2" },
    "edit.vue": { "content": "a编辑", "template": "v2" }
  },
  "b": {
    "index.vue": { "content": "b列表", "template": "v2" },
    "edit.vue": { "content": "b编辑", "template": "v2" }
  },
  "c": {
    "index.vue": { "content": "c列表", "template": "v2" },
    "edit.vue": { "content": "c编辑", "template": "v2" },
    "info.vue": { "content": "c详情", "template": "v2" }
  },
  "d": {
    "components": {
      "tool.vue": { "content": "d工具组件", "template": "v2" }
    },
    "index.vue": { "content": "d列表", "template": "v2" }
  }
}

然后执行命令:

cf-cli generate -c config.json -o src/views

Boom!目录结构瞬间就建好了!


场景二:统一代码风格?模板说了算!

团队协作中,最怕的就是风格不统一。有人喜欢用 <script setup>,有人偏爱 Vue2 的 Options API。

怎么办?用 catalog_file_generator,直接在配置里指定模板路径:

json
深色版本
{
  "user": {
    "index.vue": {
      "content": "用户列表",
      "template": "/templates/vue3sTemp.vue"
    }
  }
}

所有生成的文件都使用同一个模板,风格一致,review 不头疼。


场景三:快速搭建 MVP?几分钟搞定几十个页面!

创业、内部孵化、临时需求……时间紧任务重?

用这个工具,几分钟就能搭出几十个页面结构,把精力留给真正重要的功能逻辑。


🚀 怎么安装和使用?

安装方式:

npm install -g catalog_file_generator

安装完成后,输入:

cf-cli --help

你会看到如下命令:

Usage: cf-cli [options] [command]

Commands:
  module     交互式生成模块(支持不同文件选择不同模板 + 输入中文内容)
  generate   根据配置文件生成模块结构(支持 .js/.json)

Options:
  -V, --version  输出版本号
  -h, --help     显示帮助信息

🛠️ 命令详解

1. 交互式创建模块:cf-cli module

适用于临时新增模块,比如用户管理、订单页等。

示例命令:

cf-cli module -n user,order --files index,edit,detail -o src/views

参数说明:

参数 含义
-n--name 模块名,多个用逗号分隔
--files 要生成的文件名列表,默认是 index
-o--output 输出目录,默认是 ./dist

执行后会进入交互流程:

  1. 选择模板类型:Vue2 / Vue3 / script setup / 自定义路径;
  2. 输入中文描述:自动替换模板中的占位符(如 #name#content);

2. 配置文件生成结构:cf-cli generate

适合一次性批量生成多个模块。

示例命令:

cf-cli generate -c config.json -o src/modules

参数说明:

参数 含义
-c--config 配置文件路径(支持 .json 或 .js
-o--output 输出目录,默认是 ./dist

📦 支持的模板类型一览

类型 示例 说明
内置模板 "v2" 使用工具自带的 Vue2 模板
绝对路径 "/templates/vue3sTemp.vue" 相对于项目根目录查找
相对路径 "../custom-templates/form.vue" 相对于当前模块目录查找

🧪 小试牛刀:试试看!

示例输出结构:

运行完命令后,会在 src/modules/ 下生成如下结构:

深色版本
src/modules/
├── user/
│   ├── index.vue
│   ├── edit.vue
│   └── detail.vue
└── order/
    ├── index.vue
    ├── edit.vue
    └── detail.vue

每个 .vue 文件都会根据你选择的模板和内容自动填充内容,无需手动编写。


⚙️ 如何封装到自己的项目脚本中?

你可以封装一个 Node.js 脚本来调用这个 CLI,方便集成到你的项目中。

创建 scripts/cf-page.js

#!/usr/bin/env node

const { spawn } = require('child_process');
const chalk = require('chalk');
const path = require('path');
const [name] = process.argv.slice(2);

if (!name) {
  console.error(chalk.red('❌ 请提供模块名称,例如:npm run cf:page UserPage'));
  process.exit(1);
}

const cliEntry = path.resolve(__dirname, '../node_modules/catalog_file_generator/cli.js');

const args = [
  'module',
  '-n', name,
  '--files', 'index',
  '-o', 'src/components'
];

const child = spawn('node', [cliEntry, ...args], { stdio: 'inherit' });

child.on('error', (err) => {
  console.error(chalk.red(`❌ 子进程启动失败:${err.message}`));
  process.exit(1);
});

child.on('close', (code) => {
  if (code === 0) {
    console.log(chalk.green(`✅ 模块【${name}】创建成功!`));
  } else {
    console.error(chalk.red(`❌ 创建失败,退出码:${code}`));
  }
});

在 package.json 中添加脚本:

"scripts": {
  "cf:page": "node scripts/cf-page.js"
}

使用方式:

npm run cf:page UserPage

🌟 总结:为什么你应该试试它?

功能 亮点
🧩 支持交互式创建模块 每个文件都能选模板、填内容
📄 支持配置文件驱动 一次生成多个模块,结构清晰可复用
🎨 多种模板类型可选 支持 Vue2/Vue3,也可自定义路径
📂 支持嵌套结构生成 灵活控制目录层级
🌈 带颜色的日志提示 提升用户体验,便于排查问题

🎉 结语:别让工具牵着你走,要让它为你服务!

catalog_file_generator 不只是一个脚手架工具,它更像是一位“代码建筑师”,你只需要告诉它你要什么结构,剩下的交给它就行。

下次当你又要手写第 10 个 Vue 文件的时候,不妨试试这个工具,让你从重复劳动中解放出来,去做更有价值的事!


📌 GitHub 仓库链接?

👉 我已经打包发布到了 npm,你可以直接使用 npm install -g catalog_file_generator 安装。

📌 想扩展功能?

👉 欢迎 fork、PR、提 issue,我们一起打造更强大的前端代码生成器!


🎯 最后送大家一句话:

“程序员的价值不是写多少行代码,而是让代码尽可能少地写。”

用工具解放双手,才是真正的“高效编程”。


觉得有帮助的话,记得点个赞、收藏、转发哦~
💬 欢迎留言交流你日常开发中遇到的重复性工作,我们可以一起想办法自动化解决!

Flutter帧定义与60-120FPS机制

解释Flutter如何定义一帧以及1秒生成60-120个值的机制。

Flutter中"帧"的定义

  1. 系统层面的帧定义 Flutter中的"帧"是由 系统显示器的VSync信号 定义的,而不是Flutter自己决定的:
  • VSync信号 :垂直同步信号,由显示器硬件产生
  • 刷新率决定帧率 :60Hz显示器 = 60FPS,120Hz显示器 = 120FPS
  • 一帧 = 一个VSync周期 :从一个VSync信号到下一个VSync信号的时间间隔
  1. Flutter的帧处理流程

SchedulerBinding 展示了完整的帧处理机制:

// 1. 系统VSync信号触发
void handleBeginFrame(Duration timeStamp) {
  _currentFrameTimeStamp = timeStamp;  // 记录帧时间戳
  _schedulerPhase = SchedulerPhase.transientCallbacks;
  
  // 2. 执行所有注册的帧回调
  final Map<int, FrameCallback> callbacks = _transientCallbacks;
  for (final FrameCallback callback in callbacks.values) {
    callback(timeStamp);  // 调用Ticker._tick
  }
}

60-120FPS的生成机制

  1. 硬件决定基础帧率
  • 60Hz显示器 :每16.67ms产生一个VSync信号 → 60FPS
  • 120Hz显示器 :每8.33ms产生一个VSync信号 → 120FPS
  • 可变刷新率 :现代设备支持动态调整(如ProMotion技术)
  1. Flutter的自适应机制 animation_controller.dart 中明确说明:

"当动画正在活跃地动画时,动画控制器在运行您应用的设备准备显示新帧时生成新值(通常,此速率约为每秒 60-120 个值)。"

关键特点 :

  • Flutter 不控制 帧率,而是 适应 系统帧率
  • 每个VSync信号触发一次动画值更新
  • 帧率完全由硬件显示器决定
  1. 具体的值生成过程

60FPS设备示例 (1秒动画):

帧序列:Frame 1 → Frame 2 → ... → Frame 60
时间点:16.67ms → 33.33ms → ... → 1000ms
动画值:0.0167 → 0.0333 → ... → 1.0
总计:60个不同的动画值

120FPS设备示例 (1秒动画):

帧序列:Frame 1 → Frame 2 → ... → Frame 120
时间点:8.33ms → 16.67ms → ... → 1000ms
动画值:0.00833 → 0.01667 → ... → 1.0
总计:120个不同的动画值

核心技术实现

  1. VSync同步机制
// SchedulerBinding.scheduleFrame()
void scheduleFrame() {
  if (!framesEnabled) return;
  // 调用系统API请求下一个VSync信号
  PlatformDispatcher.instance.scheduleFrame();
}
  1. 时间戳精确计算

ticker.dart 中的 _tick 方法:

void _tick(Duration timeStamp) {
  _startTime ??= timeStamp;  // 记录动画开始时间
  _onTick(timeStamp - _startTime!);  // 计算elapsed时间
}
  1. 帧率自适应优势
    1. 精确时间控制 :基于实际elapsed时间而非帧数计算
    1. 设备兼容性 :自动适配不同刷新率的设备
    1. 性能优化 :高刷新率设备提供更流畅的动画
    1. 向后兼容 :在60Hz设备上正常工作

实际应用场景

不同设备的表现

  • iPhone 13 Pro (120Hz) :1秒动画生成120个值,超级流畅
  • 普通手机 (60Hz) :1秒动画生成60个值,标准流畅
  • 低端设备 :可能因性能限制跳帧,但时间计算仍然准确

开发者无需关心帧率 Flutter的设计哲学是让开发者专注于动画逻辑,而不用担心具体的帧率实现:

// 开发者只需要指定动画时长
AnimationController(
  durationDuration(seconds1),
  vsync: this,
)

系统会自动根据设备能力提供最佳的帧率体验。

总结

Flutter中的"帧"是由 系统VSync信号定义 的时间单位,1秒生成60-120个值是因为现代设备的显示器刷新率在60-120Hz之间。Flutter通过精密的时间同步机制,确保动画在任何刷新率的设备上都能保持准确的时长和流畅的体验。

【适合小白篇】什么是 SPA?前端路由到底在路由个啥?我来给你聊透!

还在分不清什么是单页应用(SPA)?
前端路由、pushStatehashchange 这些词把你绕晕了?
今天,我就用小白能听懂的大白话,带你一口气搞定 SPA + 前端路由这套祖传组合拳!


一、先把话挑明:SPA 是啥?

SPA,全称 Single Page Application,中文直译就是:单页应用

单页?顾名思义:
整个网站其实就一个页面,一个 index.html,剩下所有的“页面切换”,全靠 JavaScript 在里面翻魔术,局部更新。

这可不是嘴上说说,真的是只有一个 HTML 文件:

  • 没有 home.htmlabout.htmlprofile.html 这种文件存在。
  • 有的只是一个大大的 <div id="root"></div>
  • 页面级别内容都靠 React/Vue 这种框架,在这一个 div 里动态塞来塞去。

听起来离谱?可就是因为这样,SPA 才能:

  • 首屏加载后不卡:只要首屏资源加载完,后面切换页面不需要重新向服务器请求 HTML。
  • 体验顺滑:点菜单 URL 变了,但页面没刷新,也不会白屏。
  • 写起来省心:前端掌控一切页面逻辑,服务器只需要丢个 index.html 给你就行。

二、那多个页面是怎么“假装”出来的?

只靠一个 HTML,怎么假装有多个页面?

核心秘诀:前端路由 + 组件化

  1. 你会写很多页面级别的 React 组件,比如:

    function Home() { return <h1>这是首页</h1> }
    function About() { return <h1>关于我们</h1> }
    function Dashboard() { return <h1>控制台</h1> }
    

    这些就是你假装的“页面”。

  2. 然后你用 <Routes> + <Route> 做个路由表:

    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/dashboard" element={<Dashboard />} />
    </Routes>
    

    React Router 就帮你看着当前 URL,把匹配到的页面组件挂到 <Routes> 这个“占位符”里。

  3. 当你点击菜单(用 <Link>),或者在代码里手动跳转(useNavigate),React Router 会用 history.pushState 把地址栏 URL 改掉,但页面不刷新,只会把对应组件挂到占位符上。

看起来好像真的在多个页面之间跳来跳去,实际上只是:

  • URL 变了
  • 浏览器没刷
  • React 把 <div id="root"> 里的内容换了

三、URL 怎么能变而页面不刷新?魔法在这里!

先说大白话:

浏览器的地址栏一旦变了,按理说要向服务器重新请求资源,页面就会刷新。

但 SPA 偏偏就能做到:URL 变了,页面照样稳如老狗。

秘诀有俩:

  1. #(hash)
  2. HTML5 的 history.pushState

1)祖传做法:hash + hashchange

还记得很老的网站里有“回到顶部”吗?点一下 URL 变成 http://xxx.com/#top,页面只滚动,没有刷新。

这个 # 后面叫 hash(或 fragment),浏览器遇到 hash:

  • 不会向服务器重新发请求
  • 只是前端自己拿到 location.hash 去解析

聪明的前端就想了:

既然 hash 不会刷新页面,那干脆把路由信息塞 hash 里得了!

于是你会看到:

http://example.com/#/
http://example.com/#/about

前端再配个监听器:

window.addEventListener('hashchange', () => {
  console.log('哈,hash 变了!赶紧切换组件');
});

这样,点击 <a href="#/about">

  • 浏览器地址栏 hash 变了
  • 页面没刷新
  • hashchange 事件触发,前端拿到新的 hash,切换对应的组件

就这样,最早的 SPA 前端路由就有了雏形。


2)现代做法:pushState + popstate

后来 HTML5 出了 history.pushState,前端又找到了新玩具。

用它可以:

history.pushState({}, '', '/about');
  • 地址栏直接从 / 改成 /about
  • 没有 #,URL 干净利索,看起来跟真实多页站点一模一样。
  • 浏览器照样不刷新页面。

不过有个小坑:

  • pushState 只负责改地址栏,不会自动通知前端“喂,你该换组件了”。

  • 所以框架自己要做监听,或者点后退/前进时靠 popstate 事件感知:

    window.addEventListener('popstate', () => {
      console.log('用户点了返回,赶紧切换组件');
    });
    

3)pushState vs hash

hash pushState
URL 样式 有 #,丑 没 #,干净
会不会刷新 不会 不会
需要服务器配置 不需要 需要后端把所有路径都返回 index.html,否则直接访问 /about 会 404
SEO 友好 不好 好(可以和 SSR 搭配)

所以:

  • 小项目、纯静态托管:hash 路由省心省力。
  • 正式网站、想搞 SEO:pushState 路由香。

四、React Router 里这套是怎么拼起来的?

来,快速回顾一遍你真正在写的东西:

import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom';

function App() {
  return (
    <Router>
      <nav>
        <Link to="/">首页</Link> | <Link to="/about">关于</Link>
      </nav>

      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Router>
  );
}

function Home() {
  const navigate = useNavigate();
  return (
    <>
      <h1>这是首页</h1>
      <button onClick={() => navigate('/about')}>点我去关于</button>
    </>
  );
}

function About() {
  return <h1>这是关于页</h1>;
}

这里:


1️⃣ <Router> — 开启前端路由的开关

它是啥?

<Router>(最常见的是 <BrowserRouter>)是 React Router 的根组件。

可以理解成:

“从这里往下,我要开始用前端路由了,麻烦你把 URL、路由表、页面渲染全帮我管理起来!”

<Router>,React Router 整个就不工作:

  • <Link> 不会拦截 <a> 的点击行为
  • <Routes> 也不会匹配 URL
  • useNavigate 也会失效(因为找不到上下文)

内部到底干了啥?

<BrowserRouter> 为例:

  • 它内部会用到浏览器原生的 HTML5 History API

    • history.pushState:JS 改 URL 不刷新
    • window.onpopstate:监听浏览器的前进/后退按钮
  • 它会把 当前 URL 和一个上下文对象(用 React Context)传给子组件用:

    • <Routes><Link>useNavigate 全都从这个上下文里拿数据。

你需要记住一句话:

<BrowserRouter> 是你前端路由的大脑和中枢,没它啥都跑不起来。


2️⃣ <Link> — 代替 <a>,劫持点击

它是啥?

在 React Router 里,<Link> 就是用来替代 <a> 的。

写法长得像 <a>

<Link to="/about">关于我们</Link>

但内部不是 <a> 原生跳转,而是:

  • 阻止 <a> 的默认刷新行为(event.preventDefault()
  • history.pushState 改 URL
  • 告诉路由系统:“嘿,URL 变了,麻烦重新匹配 <Route>。”

为什么要这么做?

因为如果你写:

<a href="/about">关于我们</a>

浏览器会真跳到 /about,这会向服务器重新发请求。

而 SPA 不需要新请求,只需要前端自己换内容就行,所以 <Link> 的核心目标就是:

点一下,只改 URL,不刷新页面!


内部原理:

  • <Link> 本质就是一个 <a> 元素,只是点击时 JS 劫持了点击。
  • 调用的就是 history.pushState(或者 history.replaceState,如果用 <Link replace>)。

3️⃣ <Routes> & <Route> — 路由表 + 占位符

<Routes> 是啥?

它相当于:

“我在页面里留个位置,这里会根据当前 URL 渲染对应的页面组件。”

没有 <Routes>,React Router 就不知道要把你 <Route> 定义的页面挂到哪。


<Route> 是啥?

<Route> 就是一条路由规则:

  • path:匹配的 URL
  • element:要挂哪个组件

例如:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
</Routes>
  • 如果当前 URL 是 /,就渲染 <Home />
  • 如果是 /about,就渲染 <About />

内部原理:

  • <Routes> 会读取当前 URL(来自 <Router> 提供的上下文)。
  • 遍历所有 <Route>,找 path 匹配的那一个。
  • 把对应的 element(页面组件)渲染出来,挂到 <Routes> 所在的位置。

所以你可以把 <Routes> 理解成:

“我在页面里占个位,谁匹配就谁上!”


4️⃣ useNavigate — JS 里手动跳转的万能钥匙

它是啥?

useNavigate 是一个 React Router 提供的 Hook,用来在 JS 逻辑里做路由跳转。

如果 <Link> 是 HTML 标签里的跳转,useNavigate 就是 JS 里条件跳转:

const navigate = useNavigate();

function handleLoginSuccess() {
  // 登录成功后,跳转到首页
  navigate('/');
}

内部干了啥?

navigate('/about') 就等于:

  • 调用 history.pushState 改 URL
  • 触发路由系统重新匹配 <Route>

还能干嘛?

navigate 还能:

  • navigate(-1) — 返回上一页
  • navigate('/about', { replace: true }) — 用 replaceState,替换当前历史记录,不新增一条
  • navigate('/about', { state: { foo: 'bar' } }) — 可以带点状态

这四个是怎么串起来的?

我来给你梳理一下整个流程:

1. 你写 <BrowserRouter> 把前端路由系统包起来,负责提供路由上下文。
2. 你用 <Link to="/about"> 拦截点击,内部用 pushState 改 URL。
3. URL 改了后,<Routes> 会检测当前 URL,找匹配的 <Route>,渲染对应组件。
4. 如果你需要 JS 里跳转(比如登录成功后自动跳转),就用 useNavigate 调用 pushState,一样走路由匹配。

✅ 核心记忆口诀

名字 干啥用 背后本质
<Router> 开启路由大脑 提供 URL、上下文、监听 popstate
<Link> 点一下改 URL,不刷新 pushState
<Routes> 根据 URL 渲染页面 路由表匹配
useNavigate JS 里手动跳转 pushState or replaceState

这四个搞懂,你就能明白:

React Router = 前端路由的交通枢纽,URL 变了,页面只改局部,刷新?不存在的!


所以这套:

  • 不刷新页面
  • URL 在变
  • 哪个页面显示由路由表决定

五、回到最初的问题:为什么这套能做到“看起来是多页,实际上是单页”?

总结核心点:

  1. 物理上只有一个 HTML。
  2. 逻辑上有很多页面级组件,挂到 <Routes> 里切换。
  3. URL 是状态来源,hash 或 pushState 改变它。
  4. 路由系统匹配 URL,决定挂哪个页面组件。
  5. 页面没刷新,只是 DOM 局部替换了内容。
  6. 对用户来说:没啥区别,就像真在多个页面跳转。

六、最后,这里可能有几个常见小白误区

  • <a><Link> 有啥区别?
    <a> 真跳转,浏览器会整页刷新;<Link> 拦截点击,用 pushState 改 URL,不刷新。
  • pushState 改 URL 为啥不自动换页面?
    因为浏览器没义务管你页面内部渲染啥,路由框架自己得监听。
  • 为什么要服务器配置 fallback?
    因为你访问 example.com/about,如果没有 /about.html,服务器就得返回 index.html,让前端自己解析 /about 路由。

七、总结

单页应用(SPA)的本质就是:

一个页面假装成多个页面,靠前端路由 + URL 状态,做到“看起来多,实际只有一个”。

这套方案看着简单,背后就是:

  • hash 路由的老把式
  • HTML5 的新武器 history.pushState
  • 框架里用 <Routes><Route><Link>useNavigate 串起来

吃透了这套,你就能随时在面试里从容说出:
“其实我们前端路由,就是用 pushState 或 hashchange,拦截点击,动态匹配路由表,局部更新组件,页面不刷新。”

面试官一听,这小子,行。


如果对你有用,别忘了点个赞!有疑问评论区见。

我是小阳,大白话聊前端,我们下次见!

CSS 的 position 你真的理解了吗?

CSS 的 position 你真的理解了吗?

想象一下,你在玩一个拼图游戏。每个拼图块都有自己的位置,有些块需要紧紧挨着其他块,有些块可以自由地放在任何地方,还有些块需要"浮"在所有块的上方。CSS 的 position 属性就像是决定拼图块摆放规则的"游戏规则书"。

常见的误解

你可能会说:子元素设置 position: absolute 后,它的定位就是根据父元素来的。

稍微有经验的前端开发者会纠正说:不对!是根据最近的定位为 relative 的祖先元素,而不是直接的父元素。

但是,为什么是这样?背后的原理是什么?很多人答不上来。

今天我们就来彻底搞懂这个看似简单却暗藏玄机的 position 属性。

关键是理解包含块(Containing Block)

包含块就像是元素的"定位参考系"。就好比你在地图上找位置时,需要一个参考点一样。

什么是包含块?

包含块并不是元素的父元素,而是一个抽象的矩形区域,用来:

  1. 确定元素的定位基准点
  2. 计算百分比宽度和高度
  3. 决定绝对定位元素的坐标原点

一个生动的比喻

想象你在一个多层停车场里停车:

🏢 停车场大楼 (html)
├── 🚗 B1层 (body) 
│   ├── 🚗 区域A (div.container)
│   │   └── 🚗 你的车 (div.car)
│   └── 🚗 区域B (div.sidebar)

当你设置车的位置为 position: absolute; top: 10px; left: 20px 时:

  • 如果区域A设置了 position: relative,那么你的车会以区域A的左上角为原点定位
  • 如果区域A没有设置定位,车会一直向上找,直到找到B1层或停车场大楼作为参考点

包含块的工作原理

.container {
  position: relative;  /* 成为包含块 */
  width: 500px;
  height: 300px;
}

.child {
  position: absolute;
  top: 50px;    /* 相对于 .container 的顶部 */
  left: 100px;  /* 相对于 .container 的左边 */
  width: 50%;   /* .container 宽度的 50% = 250px */
}

在这个例子中,.container 就是 .child 的包含块。

CSS 浏览器内核对包含块的定义规则

浏览器内核(如 Webkit、Blink、Gecko)严格按照 W3C 规范来确定包含块。让我们用大白话来理解这些"官方规则":

规则一:static 和 relative 定位

.element {
  position: static;  /* 或者 relative */
}

包含块 = 最近的块级祖先元素的内容区域

就像俄罗斯套娃,元素总是被包在最近的那个"盒子"里。

规则二:absolute 定位

.element {
  position: absolute;
}

包含块 = 最近的非 static 定位祖先元素的 padding 区域

这就是我们常说的"向上查找定位祖先"!浏览器会一层层往上找,直到找到:

  • position: relative
  • position: absolute
  • position: fixed
  • position: sticky

如果找不到,就用根元素(html)。

规则三:fixed 定位

.element {
  position: fixed;
}

包含块 = 视口(viewport)

永远以浏览器窗口为参考系,这就是为什么 fixed 元素会"钉"在屏幕上。

规则四:特殊情况

有些 CSS 属性会创建新的包含块上下文:

  • transform 不为 none
  • perspective 不为 none
  • filter 不为 none
  • contain: layout/paint
.container {
  transform: translateZ(0); /* 创建新的包含块! */
}

.child {
  position: fixed; /* 不再相对于视口,而是相对于 .container */
}

这个规则经常让开发者踩坑!

CSS 浏览器默认样式的影响

很多初学者不知道,浏览器其实给每个 HTML 元素都设置了默认样式。这些默认样式直接影响包含块的确定。

浏览器默认给了什么?

/* 浏览器内置样式(简化版)*/
html, body {
  display: block;
  position: static; /* 默认值 */
}

div, p, h1, h2, h3, h4, h5, h6 {
  display: block;
  position: static; /* 默认值 */
}

span, a, em, strong {
  display: inline;
  position: static; /* 默认值 */
}

为什么 absolute 元素最终会以 html 为包含块?

当你写这样的代码时:

<div class="parent">
  <div class="child">我是绝对定位</div>
</div>
.child {
  position: absolute;
  top: 0;
  left: 0;
}

浏览器的"查找路径":

  1. 查找 .parentposition: static(默认值)→ 不符合条件
  2. 查找 bodyposition: static(默认值)→ 不符合条件
  3. 查找 html → 这是根元素 → 成为包含块

所以 .child 最终会定位到浏览器窗口的左上角,而不是 .parent 的左上角!

常见的"第一次踩坑"

/* 新手常犯的错误 */
.modal {
  position: absolute;
  top: 50%;
  left: 50%;
  /* 期望:相对于父元素居中 */
  /* 实际:相对于整个页面居中 */
}

解决方案就是给父元素设置定位:

.modal-container {
  position: relative; /* 创建包含块 */
}

.modal {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%); /* 真正居中 */
}

实战代码示例

理论说得再多,不如动手试试。下面是一些常见场景的完整示例。

示例 1:模态框居中

<!DOCTYPE html>
<html>
<head>
  <style>
    .modal-overlay {
      position: fixed;        /* 相对于视口 */
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    .modal {
      position: relative;     /* 建立定位上下文 */
      background: white;
      padding: 20px;
      border-radius: 8px;
      max-width: 500px;
    }
    
    .close-btn {
      position: absolute;     /* 相对于 .modal */
      top: 10px;
      right: 10px;
      background: none;
      border: none;
      font-size: 20px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="modal-overlay">
    <div class="modal">
      <button class="close-btn">&times;</button>
      <h2>我是模态框</h2>
      <p>这是一个完美居中的模态框</p>
    </div>
  </div>
</body>
</html>

示例 2:卡片悬浮效果

<style>
.card-container {
  position: relative;    /* 为徽章创建包含块 */
  background: white;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  margin: 20px;
}

.card-badge {
  position: absolute;    /* 相对于 .card-container */
  top: -10px;
  right: -10px;
  background: #ff4757;
  color: white;
  border-radius: 50%;
  width: 30px;
  height: 30px;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 12px;
  font-weight: bold;
}

.card-title {
  margin: 0 0 10px 0;
  font-size: 18px;
}
</style>

<div class="card-container">
  <div class="card-badge">NEW</div>
  <h3 class="card-title">产品标题</h3>
  <p>这是产品描述...</p>
</div>

示例 3:导航栏下拉菜单

<style>
.nav-item {
  position: relative;    /* 为下拉菜单创建包含块 */
  display: inline-block;
}

.dropdown-menu {
  position: absolute;    /* 相对于 .nav-item */
  top: 100%;            /* 紧贴父元素底部 */
  left: 0;
  background: white;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  min-width: 200px;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-10px);
  transition: all 0.3s ease;
}

.nav-item:hover .dropdown-menu {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.dropdown-item {
  display: block;
  padding: 10px 15px;
  color: #333;
  text-decoration: none;
  border-bottom: 1px solid #eee;
}

.dropdown-item:hover {
  background-color: #f5f5f5;
}
</style>

<nav>
  <div class="nav-item">
    <a href="#">产品</a>
    <div class="dropdown-menu">
      <a href="#" class="dropdown-item">Web 应用</a>
      <a href="#" class="dropdown-item">移动应用</a>
      <a href="#" class="dropdown-item">桌面应用</a>
    </div>
  </div>
</nav>

示例 4:常见的踩坑场景

<!-- 🚫 错误示例:transform 影响 fixed 定位 -->
<style>
.container {
  transform: translateZ(0); /* 这会影响 fixed 元素! */
}

.fixed-element {
  position: fixed;
  top: 0;
  right: 0;
  /* 预期:相对于视口定位 */
  /* 实际:相对于 .container 定位 */
}
</style>

<!-- ✅ 正确示例:避免 transform 干扰 -->
<style>
.container {
  /* 如果需要 transform,确保 fixed 元素不在其内部 */
}

.fixed-element {
  position: fixed;
  top: 0;
  right: 0;
  /* 现在真的相对于视口了 */
}
</style>

最佳工程实践

经过前面的学习,让我们总结一些在实际项目中的最佳实践。

1. 建立明确的定位上下文

/* ✅ 好习惯:明确创建定位上下文 */
.component-root {
  position: relative; /* 为内部绝对定位元素提供参考 */
}

.component-overlay {
  position: absolute;
  /* 明确知道这是相对于 .component-root */
}

2. 避免意外的包含块

/* 🚫 容易踩坑 */
.card {
  transform: scale(1.05); /* 意外创建了包含块 */
}

.card .tooltip {
  position: fixed; /* 不再相对于视口! */
}

/* ✅ 更好的方式 */
.card {
  transition: transform 0.3s;
}

.card:hover {
  transform: scale(1.05); /* 只在需要时应用 transform */
}

3. 使用 CSS 自定义属性增强可维护性

:root {
  --header-height: 60px;
  --sidebar-width: 250px;
}

.main-content {
  position: fixed;
  top: var(--header-height);
  left: var(--sidebar-width);
  width: calc(100% - var(--sidebar-width));
  height: calc(100% - var(--header-height));
}

4. 响应式设计中的定位

.mobile-menu {
  position: fixed;
  top: 0;
  left: -100%; /* 隐藏在屏幕外 */
  width: 80%;
  height: 100%;
  transition: left 0.3s ease;
}

.mobile-menu.active {
  left: 0; /* 滑入屏幕 */
}

/* 桌面端改为相对定位 */
@media (min-width: 768px) {
  .mobile-menu {
    position: static;
    left: auto;
    width: auto;
    height: auto;
  }
}

5. 调试定位问题的技巧

/* 开发时的调试样式 */
.debug * {
  outline: 1px solid red !important;
  position: relative !important;
}

.debug *::before {
  content: attr(class);
  position: absolute;
  top: 0;
  left: 0;
  background: rgba(255, 0, 0, 0.8);
  color: white;
  font-size: 10px;
  padding: 2px;
  z-index: 1000;
}

6. 性能优化建议

/* ✅ 使用 transform 而不是改变 top/left */
.animate-position {
  transform: translateX(100px); /* 不触发重排 */
  /* 而不是 left: 100px; */
}

/* ✅ 为动画元素创建合成层 */
.will-animate {
  will-change: transform; /* 提前告知浏览器 */
}

总结

现在你应该彻底理解了 CSS position 的工作原理:

  1. 包含块是理解定位的关键概念
  2. 不同的 position 值有不同的包含块查找规则
  3. 浏览器默认样式影响包含块的确定
  4. 一些 CSS 属性会意外地创建新的包含块上下文

下次再遇到定位问题时,问问自己:

  • 这个元素的包含块是谁?
  • 是否有意外的 transform/filter 等属性干扰?
  • 父元素的定位设置是否正确?

掌握了这些,你就真正理解了 CSS position!


记住一句话:定位不是看父子关系,而是看包含块关系。 🎯

《长安的荔枝·事件流版》——一颗荔枝引发的“冒泡惨案”

近期,随着电视剧《长安的荔枝》大火,本篇就让我们以《长安的荔枝》为模板,详细讲解事件流、事件捕获、事件冒泡及阻止冒泡。

事件流事件完整执行过程中的流动路径 8c72f7167a06df2896ae3fcccd35139.jpg

1.捕获阶段——“从长安到八百里加急直奔岭南采摘荔枝”’

当杨贵妃在长安突然想吃荔枝了,唐玄宗即刻下令朝廷八百里加急“捕获”荔枝。

  • 皇帝唐玄宗下令(Document)下令 ->
  • 宰相杨国忠(Element html)批红 ->
  • 刺史何履光(Element body)点兵 ->
  • 荔枝使李善德(Element div)采摘 -> e8c2453c942974a5e44756ca1d093f7.jpg

2.冒泡阶段——“荔枝传回长安”

  • 荔枝使李善德(Element div)采摘完毕,荔枝已装箱->

  • 刺史何履光(Element body)岭南道损耗+1,明年预算再砍三成->

  • 宰相杨国忠(Element html)上奏:“陛下,路途遥远,荔枝可能坏了->

  • 皇上唐玄宗(Document)大怒:一群饭桶!!

1db891a63721d4e7d63a5758fd6ebb3.jpg

  • 捕获阶段便是自上而下,从父到子。
  • 冒泡阶段则是从下到上,从子到父。

事件捕获

目标:简单了解事件捕获执行过程—-->唐玄宗下令采摘荔枝,官员层层准备直达岭南

  • 事件捕获概念: 从DOM的根元素开始去执行对应的事件(从外到里)

  • 事件捕获需要写对应的代码才能看到效果

  • 代码:

    DOM.addEventListener(事件类型,事件处理函数,是否使用捕获机制)

               document,addEventListener('click',function(e){
                  alert('点击事件')
               }) 
    
  • 说明:

    1. addEventListener第三个参数传入true代表是捕获阶段

    2. 若传入false代表冒泡阶段触发,默认就是false

    3. 若是用L0事件监听,则只有冒泡阶段,没有捕获

         <!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>
             .father{
                 width: 200px;
                 height: 200px;
                 background-color:green;
             }
             .son{
                 width: 100px;
                 height: 100px;
                 background-color: red;
             }
         </style>
         </head>
         <body>
             <div class="father">
                 <div class="son">
      
                 </div>
             </div>
             <script>
                 const fa=document.querySelectorAll('.father')
                 const son=document.querySelectorAll('.son')
         document,addEventListener('click',function(e){
            alert('我是爷爷')
         }) // false 即为冒泡,默认为false
         fa.document,addEventListener('click',function(e){
            alert('我是爸爸')
         })
         son.document,addEventListener('click',function(e){
            alert('我是儿子')
         })
      
             </script>
         </body>
         </html>
      

事件冒泡

目标:能够说出事件冒泡的执行过程-->李善德将荔枝采回往上运往长安

事件冒泡概念: 当一个元素被触发时,同样的事件将会在该元素的所有祖先元素中依次被触发。这一工程被称为事件冒泡

  • 简单理解:当一个元素触发事件后,会依次向上调用所有父级元素的同名事件
  • 事件冒泡是默认存在的
  • L2事件监听第三个参数是false,或者默认都是冒泡

image.png

阻止冒泡

  • 目标:能够阻止冒泡的代码-->将已经坏了的荔枝及时扔出
  • 需求:若想把事件就限制在当前元素内,就要阻止事件冒泡
  • 前提:阻止事件冒泡需要拿到事件对象
  • 语法: 事件对象.stopPropagation // Propagation 传播
  • 注意:此方法可以阻断事件流动传播,不光在冒泡阶段有效,捕获阶段也有效

【0编码】我使用Trae AI开发了一个【随手记账单格式化工具】

使用Trae AI开发随手记账单格式化工具的历程

引言

作为一个热衷于个人财务管理的程序员,我一直使用随手记APP来记录日常收支。然而,将微信和支付宝的账单导入随手记时,总是需要手动调整格式,这个过程既繁琐又容易出错。为了解决这个问题,我决定开发一个账单格式化工具,而Trae AI成为了我的得力助手,大大加速了开发过程。

需求分析

在开始开发之前,我明确了以下需求:

  1. 自动处理微信和支付宝账单 :将CSV格式的账单文件转换为随手记可导入的Excel格式
  2. 智能分类 :根据交易描述自动匹配支出/收入分类和账户信息
  3. 账单合并 :支持将微信和支付宝的账单合并为一个文件
  4. 配置管理 :允许用户自定义分类规则和关键词匹配
  5. 跨平台支持 :至少支持Windows系统

技术选型

在Trae AI的帮助下,我选择了以下技术栈:

  • 前端 :HTML, CSS, JavaScript(原生)
  • 后端 :Node.js, Express.js
  • 桌面应用框架 :Electron
  • 数据处理 :ExcelJS, csv-parser, fast-csv, xlsx
  • 其他工具 :iconv-lite(编码转换), jschardet(编码检测) 这个技术栈的优势在于:
  1. 统一的JavaScript生态系统 :前后端使用同一种语言,减少上下文切换
  2. Electron的跨平台能力 :可以轻松打包为Windows、macOS和Linux应用
  3. 丰富的数据处理库 :Node.js生态系统提供了强大的Excel和CSV处理能力

开发过程

1. 项目结构设计

在Trae AI的指导下,我设计了清晰的项目结构:

├── main.js              # Electron主进
程
├── preload.js           # 预加载脚本
├── renderer.js          # 渲染进程脚本
├── server.js            # Express后端服
务
├── index.html           # 主界面
├── config.html          # 配置管理界面
├── css/                 # 样式文件
├── js/                  # 前端
JavaScript
├── configs/             # 配置文件存储
├── uploads/             # 上传文件临时存
储
├── processed/           # 处理后文件存储
└── assets/              # 图标等资源

2. 后端服务开发

Trae AI帮助我快速实现了Express后端服务,主要功能包括:

  • 文件上传处理 :使用multer中间件处理文件上传
  • 编码检测与转换 :使用jschardet和iconv-lite处理不同编码的CSV文件
  • 数据解析与转换 :解析CSV内容,提取交易信息
  • 智能分类匹配 :根据关键词匹配交易分类和账户
  • Excel文件生成 :使用ExcelJS生成标准格式的Excel文件 其中,微信账单处理的核心代码如下:
// 处理微信账单
app.post("/api/process-wechat", upload.
single("wechatFile"), (req, res) => {
  // 加载依赖
  loadDependencies();
  
  // 处理函数 - 根据文件类型选择不同的处理方法
  const processWechatFile = () => {
    return new Promise((resolve, 
    reject) => {
      // 判断文件类型
      if (fileExt === '.xlsx') {
        // 处理Excel文件
        const tempWorkbook = new 
        ExcelJS.Workbook();
        tempWorkbook.xlsx.readFile
        (filePath)
          .then(() => {
            // 获取第一个工作表
            const worksheet = 
            tempWorkbook.getWorksheet
            (1);
            // 查找表头行
            // 处理数据行
            // ...
          })
          .catch(reject);
      } else {
        // 处理CSV文件
        // 读取CSV文件内容
        // 检测文件编码
        // 转换编码为UTF-8
        // 解析CSV行
        // ...
      }
    });
  };
});

3. 前端界面开发

Trae AI帮助我设计了简洁直观的用户界面:

  • 主界面 :包含微信账单处理、支付宝账单处理、处理结果和文件管理四个卡片
  • 配置界面 :支持管理支出分类、收入分类和账户分类的树形结构 前端使用原生JavaScript实现了与后端API的交互:
async function processWechatBill(e) {
  if (e) {
    logger.info("微信账单处理开始", { 
    fileName: e.namefileSize: e.
    size });
    var t = new FormData();
    t.append("wechatFile", e);
    try {
      showStatus("wechatStatus""处理
      中...""");
      var a = await fetch(API_BASE_URL 
      + "/api/process-wechat", { 
      method"POST"body: t }),
          s = await a.json();
      // 处理响应...
    } catch (e) {
      // 处理错误...
    }
  } else {
    // 未选择文件...
  }
}

4. Electron应用打包

Trae AI指导我使用electron-builder配置应用打包:

"build": {
  "asar": true,
  "compression": "maximum",
  "appId": "com.formatsuishouji.app",
  "productName": "随手记格式化工具",
  "extraResources": [
    {
      "from": "server.js",
      "to": "."
    },
    // 其他资源...
  ],
  "win": {
    "target": [
      "nsis"
    ],
    "icon": "assets/formatter.ico"
  }
}

功能实现

1. 微信和支付宝账单处理

工具可以处理两种格式的微信账单:

  • CSV格式 :传统的微信账单导出格式
  • XLSX格式 :新版微信账单导出格式 处理流程包括:
  1. 上传文件并检测编码
  2. 解析文件内容,提取交易信息
  3. 根据交易描述匹配分类和账户
  4. 格式化备注信息
  5. 生成Excel文件,包含收入和支出两个工作表

2. 智能分类系统

工具使用关键词匹配算法实现智能分类:

// 根据关键字匹配分类
function matchCategory(text, 
configData, defaultCategory) {
  if (!text || !configData || !Array.
  isArray(configData)) return 
  defaultCategory;

  for (const level1 of configData) {
    if (level1.children && Array.isArray
    (level1.children)) {
      for (const level2 of level1.
      children) {
        if (level2.keywords) {
          const keywords = level2.
          keywords.replace(/,/g",").
          split(",");
          for (const keyword of 
          keywords) {
            if (keyword && text.includes
            (keyword.trim())) {
              return { category: level1.
              name, subcategory: level2.
              name };
            }
          }
        }
      }
    }
  }

  return defaultCategory;
}

3. 账单合并功能

工具支持将处理后的微信和支付宝账单合并为一个文件:

// 合并账单
app.post("/api/merge-bills"(req, res) 
=> {
  // ...
  
  // 处理并合并文件
  Promise.all([
    wechatFile ? processFile
    (wechatFile) : Promise.resolve({ 
    income: [], expense: [] }),
    alipayFile ? processFile
    (alipayFile) : Promise.resolve({ 
    income: [], expense: [] })
  ])
    .then(([wechatResults, 
    alipayResults]) => {
      // 合并收入结果
      const mergedIncome = [...
      wechatResults.income, ...
      alipayResults.income];

      // 合并支出结果
      const mergedExpense = [...
      wechatResults.expense, ...
      alipayResults.expense];

      // 按日期排序
      mergedIncome.sort((a, b) => new 
      Date(a[1]) - new Date(b[1]));
      mergedExpense.sort((a, b) => new 
      Date(a[1]) - new Date(b[1]));

      // 添加到工作表并保存
      // ...
    })
});

4. 配置管理系统

工具提供了灵活的配置管理系统,支持自定义:

  • 支出分类 :如餐饮、交通、购物等
  • 收入分类 :如工资、兼职、投资等
  • 账户分类 :如储蓄卡、信用卡、虚拟账户等 每个分类都支持设置关键词,用于智能匹配交易记录。

使用Trae AI的优势

在开发过程中,Trae AI提供了以下帮助:

  1. 代码生成 :快速生成符合最佳实践的代码,减少了手动编码时间
  2. 问题解决 :遇到技术难题时,Trae AI提供了准确的解决方案
  3. 代码优化 :帮助识别性能瓶颈并提供优化建议
  4. 文档生成 :自动生成用户手册和技术文档

结论

通过使用Trae AI,我成功开发了一个功能完善的随手记账单格式化工具,大大提高了个人财务管理的效率。这个项目不仅解决了我自己的实际需求,也让我体验到了AI辅助编程的强大能力。

希望这篇博客能够帮助更多对AI辅助编程感兴趣的开发者,也欢迎大家使用和改进这个工具!

工具已开源:

参考资源

  • Electron官方文档
  • ExcelJS文档
  • Express.js文档
  • Trae AI官方网站

一个酷炫星球旋转动画的前端实现

最终实现效果如视频所示。

需求描述

若干个星球如图定时公转,支持手势左右滑动(本次demo懒得做)

应用于手机H5、微信公众号、小程序端

前端动效技术分析

  • css3 transition
    • 性能优秀,只关心关键帧,有且只有两个关键帧
  • css3 animation
    • 性能一般,只关心关键帧,存在多个关键帧
  • js - canvas
    • 性能优秀,一切都可控制,但是需要代码逻辑控制所有帧
  • media gif | video
    • 性能优秀,需要预加载,基本上不支持交互

需求分析

  • 存在交互需求,淘汰media方案
  • 有比较明确的动画路线,而且并不复杂,淘汰js - canvas方案
  • 遵循如果transition能做,就不用animation的原则,采用transition来实现

实现方案分析

  • 需求动效为一个3D效果(近大远小,透视),transition可以使用transition-3d来模拟实现,也可使用scale只模拟做出近大远小的效果。DEMO中采用transition-3d实现所有动效。
  • 动效为星球旋转,首先考虑的就是rotate属性来实现,但是实际上实现过程中存在太多问题,不在本次DEMO之中,有兴趣的可自行尝试。既然决定使用transition-3d实现,完全可以直接在立体坐标系中通过三轴定位每个星球,然后使用transition动画切换位置,即可实现需求。有一个弊端:星球公转走的是点到点的直线,而不是围绕公转轨道弧线移动。
  • 涉及到交互效果,所以我们使用className来控制每个星球的位置,通过切换className来触发旋转动效。

具体实现方案

  • 先在XY平面上平均分布若干个球,组成环形。
  • step1 将XY平面沿Z轴做一个初始旋转@rotateZ
  • step2 在上一步基础上,将YZ平面沿X轴做一个旋转@rotateX
  • step3 在上一步基础上,再次将XY平面沿Z轴做一个初始旋转@rotateZ2

变量定义

// 星星个数
@starLength: 6;
// 景深
@perspective: 1000px;
// 初始角
@rotateZ: 12;
@rotateX: 65;
@rotateZ2: -10;

// 圆半径
@rSize: 200;
// 星球图大小
@imgSize: 250px;
// 选中放大倍率
@scaleSize: 1.2;
// 动画速度
@duration: 0.5s;

  • 将所有的球,绝对定位到同一个点,我们定位到视窗正中心
  • 这里最外层使用flex布局,每个球使用绝对定位,即可保证每个球重叠并且中心点在视窗正中心
.star-box{
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.star{
  height: @imgSize;
  width: @imgSize;
  position: absolute;
}
  • step1 将所有的球,在视窗平面,以中心点为正圆圆心,使用transition3D分散到圆边上排列,并将所有的球分散到XY平面,并将XY平面沿Z轴做一个初始旋转@rotateZ,分别计算出每个球的XYZ(Z坐标为0)。

image.png

@rotateZItem: (@rotateZ + ((@i - 1) * 360 / @starLength));
@transformX: sin(@rotateZItem * 1deg) * @rSize;
@transformY: cos(@rotateZItem * 1deg) * @rSize;
@transformZ: 0;
  • step2 在上一步基础上,将YZ平面沿X轴做一个旋转@rotateX,分别计算出所有球的YZ(X坐标不会变)。

image.png

@step1Y: cos(@rotateZItem * 1deg) * @rSize;
@transformY: cos(@rotateX * 1deg) * @step1Y;
@transformZ: sin(@rotateX * 1deg) * @step1Y;
  • step3 在上一步基础上,再次将XY平面沿Z轴做一个初始旋转@rotateZ2,我们取任意一个点在XY平面做计算:

image.png

x = sin(m + n) * r 
  = (sin(m) * cos(n) + sin(n) * cos(m)) * r
  = ((a/r) * cos(n) + (b/r) * sin(n)) * r
  = a * con(n) + b * sin(n)

y = cos(m + n) * r
  = (cos(m) * cos(n) - sin(n) * sin(m)) * r
  = ((b/r) * cos(n) - (a/r) * sin(n)) * r
  = b * con(n) - a * sin(n)
@step1X: sin(@rotateZItem * 1deg) * @rSize;
@step2Y: cos(@rotateX * 1deg) * @step1Y;
@transformY: @step2Y * cos(@rotateZ2 * 1deg) + @step1X * sin(@rotateZ2 * 1deg);
@transformX: @step1X * cos(@rotateZ2 * 1deg) - @step2Y * sin(@rotateZ2 * 1deg);
  • 至此所有球点位都计算出来了,剩下的就是分配classname,通过state动态修改classname,使用transition补全关键帧。就做完了,具体可看demo代码。
transform: translate3D( @transformX * 1px, @transformY * 1px, @transformZ * 1px );

核心less代码:

.star-box {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.star {
  height: @imgSize;
  width: @imgSize;
  position: absolute;

  &-content {
    height: 100%;
    width: 100%;
    transition: all @duration;
    .bgContain();
    background-size: 0;
    background-image: url('./assets/img/star-shadow.png');
  }

  &-position-01 {
    scale: @scaleSize;

    &.star-content {
      background-size: 100%;

      &::before {
        opacity: 1;
        transition: all @duration;
        transition-delay: @duration;
      }
    }
  }

  .loopStar(@i) when (@i <=@starLength) {
    // 初始角度 加上Z轴初始角度 rotateZ
    @rotateZItem: (@rotateZ + ((@i - 1) * 360 / @starLength));
    @transformX: sin(@rotateZItem * 1deg) * @rSize;
    @transformY: cos(@rotateZItem * 1deg) * @rSize;
    @transformZ: 0;
    // X轴旋转 rotateX transformX不会变
    @step1Y: cos(@rotateZItem * 1deg) * @rSize;
    @transformZ: sin(@rotateX * 1deg) * @step1Y;
    @transformY: cos(@rotateX * 1deg) * @step1Y;
    // Z轴旋转 rotateZ2
    @step1X: sin(@rotateZItem * 1deg) * @rSize;
    @step2Y: cos(@rotateX * 1deg) * @step1Y;
    @transformY: @step2Y * cos(@rotateZ2 * 1deg)+@step1X * sin(@rotateZ2 * 1deg);
    @transformX: @step1X * cos(@rotateZ2 * 1deg) - @step2Y * sin(@rotateZ2 * 1deg);

    &-0@{i} {
      &::after {
        content: "";
        display: block;
        width: 100%;
        height: 100%;
        position: absolute;
        background-image: url('./assets/img/star0@{i}.png');
        .bgContain();
      }

      &::before {
        content: "";
        display: block;
        width: 100%;
        height: 100%;
        position: absolute;
        bottom: 50%;
        left: -5%;
        background-image: url('./assets/img/light.png');
        background-size: 100% 100%;
        transform: rotate(4deg);
        opacity: 0;
      }
    }

    &-position-0@{i} {
      transform: translate3D(@transformX * 1px, @transformY * 1px, @transformZ * 1px);
    }

    .loopStar(@i+1);
  }

  .loopStar(1);
}
function Index() {
  // 选中 第一个
  const [selectIndex, setSelectIndex] = useState(0)
  // 定时滚动
  const clearInterval = useInterval(() => { 
    setSelectIndex(flatIndex(selectIndex + 1))
  }, intervalTime)
  useEffect(() => { 
    return () => { 
      clearInterval()
    }
  }, [])

  return (
    <div className={styles.page}>
      <div className='star-box'>
        {Array.from(new Array(starLength)).map((_, index) => {
          const imgClass = `star-0${index + 1}`
          const positionIndex = flatIndex(selectIndex - index + starLength)
          const positionClass = `star-position-0${positionIndex + 1}`
          return (
            <div key={index} className={'star'}>
              <div className={ `star-content ${imgClass} ${positionClass}`}></div>
            </div>
          )
        })}
      </div>
    </div>
  )
}

手把手搭建Vue轮子从0到1:2. 搭建框架雏形

上一篇:# 手把手搭建Vue轮子从0到1:1. 前期准备

1. 下载Vue源码(版本:3.5.17)

基本结构:

vue-next-3.5.17 (Vue.js 核心仓库)
├── tsconfig.json          // TypeScript 配置文件
├── rollup.config.js        // rollup 的配置文件
├── packages                // 核心代码包
│   ├── vue-compat          // 用于兼容 vue2 的代码
│   ├── vue                 // 重要: 浏览器实例,打包之后的 dist 都会放在这里
│   ├── template-explorer   // 提供了一个线上的测试 (https://template-explorer.vuejs.org/),用于把 template 转化为 render
│   ├── size-check          // 测试运行时包大小
│   ├── shared              // 重要: 共享的工具类
│   ├── sfc-playground      // sfc 工具,比如: https://sfc.vuejs.org/
│   ├── server-renderer     // 服务器渲染
│   ├── runtime-test        // runtime 测试相关
│   ├── runtime-dom         // 重要: 基于浏览器平台的运行时
│   ├── runtime-core        // 重要: 运行时的核心内容,内部针对不同平台进行了实现
│   ├── reactivity-transform // 已过期,无需关注
│   ├── reactivity          // 重要: 响应式的核心模块
│   ├── global.d.ts         // 全局的 ts 声明
│   ├── compiler-ssr        // 服务端渲染的编译模块
│   ├── compiler-sfc        // 单文件组件 (.vue) 的编译模块
│   ├── compiler-dom        // 重要: 浏览器相关的编译模块
│   └── compiler-core       // 重要: 编译器核心代码
├── package.json            // npm 包管理工具
├── netlify.toml            // 自动化部署相关
├── jest.config.js          // 测试相关
├── api-extractor.json      // TypeScript 的 API 分析工具
├── SECURITY.md             // 报告漏洞,维护安全的说明文件
├── README.md               // 项目声明文件
├── LICENSE                 // 开源协议
├── CHANGELOG.md            // 更新日志
├── BACKERS.md              // 赞助声明
├── test-dts                // 测试相关,不需要关注
├── scripts                 // 配置文件相关,不需要关注
├── pnpm-workspace.yaml     // pnpm 相关配置
└── pnpm-lock.yaml         // 使用 pnpm 下载的依赖包本

核心包结构详解:

  • 🔥 核心模块 (最重要)

    • vue - 主入口包,整合所有功能模块
    • compiler-core - 编译器核心,处理模板编译逻辑
    • compiler-dom - DOM相关的编译器,处理浏览器特定的编译
    • runtime-core - 运行时核心,平台无关的运行时逻辑
    • runtime-dom - DOM运行时,浏览器平台的运行时实现
    • reactivity - 响应式系统,Vue的响应式核心
    • shared - 共享工具函数和常量
  • 🛠️ 构建与工具

    • compiler-sfc - 单文件组件编译器
    • compiler-ssr - 服务端渲染编译器
    • server-renderer - 服务端渲染器
    • runtime-test - 测试运行时环境
  • 🔄 兼容与扩展

    • vue-compat - Vue 2.x 兼容层
  • 📁 私有包 (packages-private/)

    • sfc-playground - 单文件组件在线演练场
    • template-explorer - 模板编译探索工具
    • dts-test - TypeScript 类型定义测试
  • ⚙️ 配置与脚本

    • scripts/ - 构建脚本和开发工具
    • 各种配置文件 (TypeScript, ESLint, Rollup 等)

这个项目采用 monorepo 架构,使用 pnpm 作为包管理器,支持多种构建格式 (ESM, CJS, UMD等),为不同的使用场景提供了完整的解决方案。

2. 运行Vue源码

  1. 安装 pnpm 包管理工具
npm install -g pnpm

pnpm 会通过一个 集中管理 的方式来管理 电脑中所有项目 的依赖包,以达到 节约电脑磁盘 的目的。

项目初衷 | pnpm

  1. 项目根目录下安装依赖
pnpm i

  1. 项目打包
npm run build

4. 查看生成的文件 packages/vue/dist

vue.runtime.esm-browser.prod.js      // 浏览器用 ESM 运行时生产环境版
vue.esm-browser.prod.js              // 浏览器用 ESM 完整版生产环境
vue.runtime.global.prod.js           // 浏览器用全局运行时生产环境版
vue.global.prod.js                   // 浏览器用全局完整版生产环境
vue.cjs.prod.js                      // CommonJS 生产环境版
vue.runtime.esm-browser.js           // 浏览器用 ESM 运行时开发版
vue.esm-browser.js                   // 浏览器用 ESM 完整开发版
vue.runtime.global.js                // 浏览器用全局运行时开发版
vue.global.js                        // 浏览器用全局完整版开发版
vue.cjs.js                           // CommonJS 开发版
vue.runtime.esm-bundler.js           // ESM Bundler 运行时(供打包工具用)
vue.esm-bundler.js                   // ESM Bundler 完整版(供打包工具用)

3. 构建运行测试实例

  1. 在源码中 \core\packages\vue\examples(vue自己的测试实例) 下新建 my-examples 文件夹,用于放我们的测试实例。

  2. VSCode中安装 Live Server 插件,帮我们直接启动一个 本地服务。

  1. 创建实例文件:reactive.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>
    <script src="../../dist/vue.global.js"></script>
</head>
<body>
    <div id="app"></div>
    <script>
        // 1. 从 Vue 中解构出 reactive、effect 函数
        const { reactive, effect } = Vue;

        // 2. 创建响应式对象
        const state = reactive({
            count: 0
        });

        // 3. 创建副作用函数(effect)
        effect(() => {
            document.querySelector('#app').innerHTML = `count is: ${state.count}`;
        });

        // 4. 定时修改响应式对象的属性,触发副作用函数,更新 DOM(视图发生变化)
        setTimeout(() => {
            state.count++;
        }, 1000);

    </script>
</body>
</html>
  1. 鼠标右键 -> Open with Live Server

  1. 等待 1s,count 由 0 变成了 1,说明已经成功运行了一个测试实例。

4. 对 Vue 进行debugger

  1. 开启 SourceMap(源代码映射)

未开启SourceMap时的开发者工具(F12 Sources模块):

开启SourceMap之后:

在执行 npm run build 时,实际执行的是 scripts/build.js 文件

(scripts/build.js)可以找到这么一行代码: sourceMap ? `SOURCE_MAP:true` : ``

在 rollup.config.js 文件中 output.sourcemap = !!process.env.SOURCE_MAP赋值给 output.sourcemap。

sourceMap 是通过 parseArgs(nodejs.org/api/util.ht…) 解析命令行参数得到的。parseArgs 解析后,values 对象中会包含 sourceMap 字段,然后通过解构赋值。

  • --sourceMap 是完整参数名
  • -s是它的短写

所以 sourceMap 的值最终取决于 运行 scripts/build.js 时是否传递了 --sourceMap 或 -s 参数。

  1. 修改 package.json,修改build脚本命令
"build": "node scripts/build.js -s"

重新执行 npm run build

  1. 查看源代码

ctrl+p 搜索文件,可以看到有对应的 effect.ts 和 reactive.ts 文件

  1. 添加断点

点击行序号 94,断点调试 reactive 的代码执行逻辑。

刷新页面,进入调试

5. 开始搭建

  1. 创建 package.json 模块
npm init -y

  1. 新建文件夹结构 packages (核心代码区域)
vue-mini (Vue.js MVP)
├── packages                // 核心代码包
│   ├── vue                 // 打包、测试实例、项目整体入口
│   ├── shared              // 共享的工具类
│   ├── runtime-dom         // 基于浏览器平台的运行时(浏览器部分运行时内容)
│   ├── runtime-core        // 运行时的核心内容,内部针对不同平台进行了实现
│   ├── reactivity          // 响应式的核心模块
│   ├── compiler-dom        // 重要: 浏览器相关的编译模块
│   └── compiler-core       // 重要: 编译器核心代码
├── package.json            // npm 包管理工具

  1. 导入 ts 配置
  • 项目根目录下创建 tsconfig.json 配置文件(What is a tsconfig.json),用于指定编译项目所需的入口文件和编译器配置。(也可以通过 tsc -init 生成默认配置,这个需先 npm i -g typescript)
  • 指定如下配置(www.typescriptlang.org/tsconfig/
{
  // 编译器配置
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig to read more about this file */

    /* Projects */
    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */

    /* Language and Environment */
    // 指定JS语言版本
    "target": "es5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    // https://www.typescriptlang.org/tsconfig#lib
    "lib": [
      "esnext",
      "dom"
    ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
    // "libReplacement": true,                           /* Enable lib replacement. */
    // "experimentalDecorators": true,                   /* Enable experimental support for legacy experimental decorators. */
    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */

    /* Modules */
    // 模块化
    "module": "esnext" /* Specify what module code is generated. */,
    // 指定根目录
    "rootDir": "." /* Specify the root folder within your source files. */,
    // 指定模块解析策略(指定类型脚本如何从给定的模块说明符查找文件)
    "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
    // "allowImportingTsExtensions": true,               /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
    // "rewriteRelativeImportExtensions": true,          /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
    // "resolvePackageJsonExports": true,                /* Use the package.json 'exports' field when resolving package imports. */
    // "resolvePackageJsonImports": true,                /* Use the package.json 'imports' field when resolving imports. */
    // "customConditions": [],                           /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
    // "noUncheckedSideEffectImports": true,             /* Check side effect imports. */
    // 允许解析 .json 文件
    "resolveJsonModule": true /* Enable importing .json files. */,
    // "allowArbitraryExtensions": true,                 /* Enable importing files with any extension, provided a declaration file is present. */
    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */

    /* JavaScript Support */
    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */

    /* Emit */
    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
    // 禁用 sourceMap
    "sourceMap": false /* Create source map files for emitted JavaScript files. */,
    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
    // "outDir": "./",                                   /* Specify an output folder for all emitted files. */
    // 转换为 JavaScript 时, 从 TypeScript 文件中删除所有注释
    "removeComments": false /* Disable emitting comments. */,
    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
    // 向下兼容迭代器(支持语法迭代) https://www.typescriptlang.org/tsconfig#downlevelIteration
    "downlevelIteration": true /* Emit more compliant, but verbose and less performant JavaScript for iteration. */,
    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */

    /* Interop Constraints */
    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
    // "verbatimModuleSyntax": true,                     /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
    // "isolatedDeclarations": true,                     /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
    // "erasableSyntaxOnly": true,                       /* Do not allow runtime constructs that are not part of ECMAScript. */
    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
    // 启用 ES 模块互操作性 https://www.typescriptlang.org/tsconfig#esModuleInterop
    "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
    "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,

    /* Type Checking */
    // 启用所有严格类型检查选项
    "strict": true /* Enable all strict type-checking options. */,
    // 启用隐式 any 类型检查(有助于简化 ts 复杂度,从而更加专注于逻辑本身)
    "noImplicitAny": false /* Enable error reporting for expressions and declarations with an implied 'any' type. */,
    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
    // "strictBuiltinIteratorReturn": true,              /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
    // 允许未使用的局部变量
    "noUnusedLocals": false /* Enable error reporting when local variables aren't read. */,
    // 允许未使用的参数
    "noUnusedParameters": false /* Raise an error when a function parameter isn't read. */
    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */

    /* Completeness */
    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
    // "skipLibCheck": true,                             /* Skip type checking all .d.ts files. */
  },
  // 入口
  "include": ["packages/*/src"]
}
  1. 配置 prettier 格式化代码工具
  • VSCode中安装 prettier 辅助插件

  • 项目根目录下创建 .prettierrc 文件
{
  // 结尾无分号
  "semi": false,
  // 全部使用单引号
  "singleQuote": true,
  // 每行最大长度
  "printWidth": 80,
  // 末尾不添加逗号
  "trailingComma": "none",
  // 省略箭头函数括号
  "arrowParens": "avoid"
}

6. 模块打包器 rollup(www.rollupjs.com/

与 webpack(webpack.docschina.org/concepts/)一样,可以将 JS 打包为指定模块。区别在于,对于 webpack 而言,打包时会产生许多冗余的代码,在开发大型项目的时候没有什么影响,但是在开发一个库的时候,冗余代码会大大增加库体积,导致不够美好。

rollup 可以将小块代码编译成大块复杂的代码,例如 libray 或应用程序。

Rollup 是一个用于 JavaScript 的模块打包工具,它将小的代码片段编译成更大、更复杂的代码,例如库或应用程序。

6.1. 如何理解更大,更复杂?

这其实是指 Rollup 的主要用途和工作方式:

  1. 将多个小模块合并成一个文件

在开发时,我们通常会把代码拆分成很多小的模块(每个功能一个文件),这样方便维护和复用。但在发布时,如果直接把这些小文件交给浏览器加载,会导致很多网络请求,影响性能。

  1. 打包过程

Rollup 会把这些分散的小模块“打包”成一个或几个大的文件。这个过程叫做“bundling”(打包),其实就是把很多小的代码片段合并成一个更大的整体。

  1. 更复杂的代码

合并后,Rollup 还会做一些优化,比如去除没用的代码(tree-shaking)、转换语法等。最终生成的文件,虽然体积可能更小,但结构上比单个小模块要复杂,因为它包含了所有模块的内容和模块之间的依赖关系处理。

举个例子:

假设你有三个模块:a.js、b.js、c.js。每个文件都很简单,但打包后,Rollup 会把它们合成一个 bundle.js,这个文件里包含了所有模块的代码和它们的依赖关系。这样,浏览器只需要加载一个文件。

总结

这里的“更大、更复杂”不是说代码变得难以理解,而是说:

  • 文件体积变大(因为合并了多个模块)
  • 代码结构更复杂(因为要处理模块之间的依赖和作用域)

但这样做的好处是:加载更快、部署更方便、可以做更多优化。

可以理解为 rollup 为一个打包 库 的模块打包器,而应用程序打包时则选择 webpack。

6.2. 引入 rollup

  1. 项目根目录下创建 rollup.config.js 文件
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';

/**
 * 默认导出一个数组,数组的每一个对象都是一个单独的导出文件配置。https://www.rollupjs.com/guide/big-list-of-options
 */
export default {
  // 入口文件
  input: 'packages/vue/src/index.ts',
  // 打包出口
  output: [
    // 导出 iife 模式的包
    {
      // 开启 sourcemap
      sourcemap: true,
      // 导出 iife 模式的包
      format: 'iife',
      // 导出的文件地址
      file: './packages/vue/dist/vue.js',
      // 变量名
      name: 'Vue',
    }
  ],
  // 插件
  plugins: [
    // ts 支持
    typescript({
      sourceMap: true,
    }),
    // 将 CommonJS 转换为 ES2015 模块
    commonjs(),
    // 模块导入的路径补全
    resolve(),
  ],
}
  1. 修改 package.json 文件
{
  "name": "vue-mini",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "rollup -c"
  },
  "repository": {
    "type": "git",
    "url": "https://gitee.com/carrierxia/vue-mini.git"
  },
  "devDependencies": {
    "rollup": "^3.12.0",
    "@rollup/plugin-commonjs": "^26.0.2",
    "@rollup/plugin-node-resolve": "^15.0.0",
    "@rollup/plugin-typescript": "^10.0.0",
    "typescript": "^5.7.3",
    "tslib": "^2.8.1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module"
}

  1. 执行 npm i 安装依赖
  2. 在新增 \vue-mini\packages\vue\src\index.ts 文件,输入
console.log("Vue")
  1. 执行 npm build 打包,可以看到生成了对应 dist 文件夹

7. 配置路径映射

{
  // 编译器配置
  "compilerOptions": {
    ...
    // 指定基础目录
    "baseUrl": "./" ,
    // 指定模块路径别名
    "paths": {
      "@vue/*": ["packages/*/src"]
    }
  }
}

因 GitHub 这个 31k Star 的宝藏仓库,我的开发效率 ×10

大家好,我是程序员凌览

写代码一分钟,翻工具两小时?别折腾了。

立刻收藏这个仓库 it-tools,它已经在 GitHub 上斩获了 31k Star 的。

image.png 仓库链接:https://github.com/CorentinTh/it-tools

it-tools是什么

it-tools 汇集了 70 多种对开发人员和 IT 工作人员有用的工具。这个令人惊叹的工具的酷炫之处在于它不需要设置,不需要持久卷,您可以立即开始使用它。它包含大量工具,可生成密码、编辑 CSS 和 HTML 代码、文件格式转换等等。

链接:https://it-tools.tech/

安装

公司内网断外网?不要怕。it-tools支持自建,一行命令,整站搬回家。

docker run -d --name it-tools -p 8080:80 corentinth/it-tools:latest

在浏览器中输入 http://IP:8080 就能看到主界面

image.png

工具介绍

从左侧菜单看,大致分为 加密转换器网页图像和视频开发网络数学测量文本 和 数据 这几类。

加密 –> Token 生成器 可以用来生成随机的字符串,比如密码

image.png

Web –> HTTP 状态码 可以用来查询 Web 上返回的状态码错误

image.png

图片与视频 –> 二维码生成器 可以用来生产二维码,方便分享

image.png

开发 –> Docker Run 到 docker-compose 转换器 可以用于将 Docker cli 转为 Docker compose 的 yaml 文件,而且可以直接下载

image.png 工具可收藏 image.pngiT-tools 目前已拥有 70 多种工具,并且还在不断增加,值得一用。

最后

给大家推荐一款超实用的工具:

  • 密码管家 是一款 utools 插件,能帮你轻松管理各种繁杂的账号和密码,再也不用担心忘记密码啦!

对了,我还会不定时分享更多好玩、有趣的 GitHub 开源项目,欢迎持续关注哦!

小效果--多行文本溢出出现省略号

实现多行文本溢出溢出省略号

最近遇到一个小效果,多行文本溢出时候出现'...'省略号。 实现的方法有两种:

  • webkit内核的浏览器本身支持的css指令。
  • 兼容性更好的辅助元素实现。

法一:webkit内核css实现

我们只需要加上以下几行css即可:

display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;

这个方法简单高效,你要非说兼容性多差也不至于,顶多在不是webkit内核的浏览器不出现...,但依旧会隐藏超出的内容。放心用!

追求完美的请看下一种方法。

法二:伪元素辅助实现

我们先看思路:

  • 需要利用两个辅助元素: 1.一个专门放...的div 2.一个元素将文本顶下去。
  • 将...元素右浮动,此时元素会被文字环绕,这个时候就想办法将...给放置到最下方如果单独使用margin-top,那么文字会避开...的maigin,所以设置给'...'设置margin-top无效。
  • 这个时候就需要做出来一个伪元素,将我们的整个文本顶下去,然后再设置文本的margin-top为负值,调到正确的位置即可。

html结构

<!DOCTYPE html>
<html>
    <head>
      <meta charset="utf-8">
      <title></title>
      <style>
        .box{
                        width:200px;
                        height: 104px;
background-color: skyblue;
margin: 200px auto;
padding: 10px;
overflow: hidden;  /* 记得溢出隐藏*/
}
      </style>
    </head>
    
<body>
<div class="box">
    <div class="txt">来一点中文的多行文本溢出处理手机号介绍的就哈市换手机号加时间到货时间黄金时代回到家撒活动结合实际电话实践活动久啊圣诞节函数换手机号圣诞节啊</span>
</div>
</body>
        
<html>       

看下效果:

Snipaste_2025-07-14_16-52-26.png

接下来我们一点点实现:

  • 我们需要加一个元素专门放'...',然后给它浮动到右侧。
<!DOCTYPE html>
<html>
    <head>
      <meta charset="utf-8">
      <title></title>
      <style>
        .box{
                  width:200px;
                  height: 104px;
background-color: skyblue;
margin: 200px auto;
padding: 10px;
overflow: hidden;  /* 记得溢出隐藏*/
}
                
          /* 省略元素 */
.more{
                    float: right;/* 主要是使用浮动,让文字环绕 */
                    margin-right: 5px;
}
      </style>
    </head>
    
<body>
<div class="box">
                    <div class="more">...</div>
    <div class="txt">来一点中文的多行文本溢出处理手机号介绍的就哈市换手机号加时间到货时间黄金时代回到家撒活动结合实际电话实践活动久啊圣诞节函数换手机号圣诞节啊</span>
</div>
</body>
        
<html>       

现在效果是这样的:

Snipaste_2025-07-14_16-56-49.png

我们只需要想办法将...给去往下方即可,可以使用一个伪元素,将内容顶下去,然后再调整文本margin-top为负数

最终代码

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>

<style>
.box{
width:200px;
height: 104px;
background-color: skyblue;
margin: 200px auto;
padding: 10px;
overflow: hidden;  /* 记得溢出隐藏*/
}

.box .txt{
margin-top: -20px;
} 



/* 辅助伪元素 */
.box::before{
content: "";
display: block;
height: 90px;
}

/* 省略元素 */
.more{
float: right;/* 主要是使用浮动,让文字环绕 */
margin-right: 5px;
}
</style>
</head>
<body>
<div class="box">
<div class="more">...</div>
<div class="txt">来一点中文的多行文本溢出处理手机号介绍的就哈市换手机号加时间到货时间黄金时代回到家撒活动结合实际电话实践活动久啊圣诞节函数换手机号圣诞节啊</span>
</div>
</body>
</html>

再看下效果:

Snipaste_2025-07-14_16-59-03.png


朋友,我是喝西瓜汁的兔叽,感谢您的阅读,衷心祝福您和家人身体健康,事事顺心。

手摸手带你彻底搞懂Vue的响应式原理

你是否好奇 Vue 为什么能在你修改变量时自动更新页面?

本文将手把手带你实现一个最简版的 Vue 响应式系统,从原理到代码,彻底讲透它的核心机制。

响应式系统的核心概念

什么是响应式

一句话概括就是:系统自动追踪数据的变化,并实时同步更新依赖该数据的视图或逻辑

大白话说就是:当你修改了变量,vue就会自动帮你更新页面,不用你手动改DOM

核心概念

在Vue中,它的本质是通过追踪数据和副作用函数之间的依赖关系,实现自动更新

那么它是怎么实现的呢?

依赖收集

当某个响应式变量被读取时,vue会记录当前是谁在用这个值,这个‘谁’通常是一个副作用函数,也就是effect

// 想象这个 effect 会订阅 count
effect(() => {
  console.log(count.value)
})

这段代码中effect传入的参数是一个函数,而这个函数中读取了count,那么,vue就会把这个函数,也就是副作用函数保存起来

当响应式变量的值发生变化时(如count.value++),Vue 会自动找到依赖它的所有副作用函数,并重新执行它们,从而更新视图或逻辑

这种机制就是发布订阅模式

  • 依赖收集 = 订阅
  • 触发更新 = 发布

触发更新

当响应式对象的某个属性被修改时,Vue会找到依赖它的所有effect,并依次调用他们。比如:

count.value++    // 会触发上面订阅了 count 的 effect

响应式对象

Vue3中的reactive使用了es6的proxy来实现响应式代理

每当你对对象进行访问(get)或者修改(set)时,Vue 都能感知到这一行为,并自动进行依赖收集或触发更新

接下来我们来实现一个简单的响应式系统

最简实现

案例:

const count = ref(0)

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

setTimeout(() => {
  count.value++
}, 1000)

接下来我们依次实现refeffect,来成功让count变化时自动调用effect中的函数

effect实现

// 定义一个全局对象,用来存储正在执行的副作用函数
let activeSub = null

class ReactiveEffect {
  constructor(public fn: Function) {}

  run() {
    // 将当前实例赋值给activeSub
    activeSub = this
    try {
      // 执行副作用函数
      return this.fn()
    } finally {
      // 将全局对象存储的副作用函数清空
      activeSub = null
    }
  }
}

function effect(fn) {
  const e = new ReactiveEffect(fn)

  e.run()
}

ref实现

这块代码比较复杂,我先把整体放出来,然后在分步骤解析

class RefImpl {
  _value;   // 当前的值
  subs;     // 链表头,用来存副作用函数
  subsTail; // 链表尾

  constructor(value) {
    this._value = value
  }

  get value() {
    if (activeSub) {
      trackRef(this)
    }
    return this._value
  }  // 当访问 .value 时,Vue就会收集这个依赖,记录哪个副作用函数用到了
  set value(newValue) {
    this._value = newValue
    triggerRef(this)
  }  // 当 .value 改变时,系统就会调用 triggerRef 通知所有依赖它的副作用函数去重新执行
}

function ref(value) {
  return new RefImpl(value)
}

// 收集依赖
function trackRef(dep) {
  if (activeSub) {  // 如果当前有正在运行的 effect 就与这个 ref 建立关联
    link(dep, activeSub)
  }
}

// 触发ref关联的依赖
function triggerRef(dep) {
  if (dep.subs) {  // 如果有关联的副作用函数便执行
    propagate(dep.subs)
  }
}

function link(dep, sub) {
  // 定义一个依赖关系的双向链表节点的结构
  let newLink = {
    sub,                // 订阅者,即副作用函数(表示谁依赖了这个ref)
    nextSub: undefined, // 链表中的下一个订阅者(副作用函数)
    prevSub: undefined, // 链表中的上一个订阅者
  }

  // 判断当前的 dep 是不是第一次被订阅(即 subsTail 为 null)
  if (dep.subsTail) {
    dep.subsTail.nextSub = newLink  // 将尾节点的nextSub指向新的节点(插入到尾部)
    newLink.prevSub = dep.subsTail  // 将这个新节点的prevSub指向原来的尾节点
    dep.subsTail = newLink          // 将原来的尾节点指向新节点
  } else {
    // 第一次被订阅,将头和尾都指向这个新节点
    dep.subs = newLink
    dep.subsTail = newLink
  }
}

function propagate(subs) {
  let link = subs          // 拿到链表头
  const queuedEffect = []  // 定义一个数组用来存储需要重新执行的副作用函数
  while (link) {           // 如果有值就去将副作用函数取出来,存入到数组
    const sub = link.sub
    queuedEffect.push(link.sub) // 将副作用函数追加到数组中
    link = link.nextSub         // 赋值为下一个链表
  }
  // 依次调用
  queuedEffect.forEach(effect => effect.run())
}

首先说一下什么是链表?

链表是一种基础的数据结构,用于按顺序存储元素,通过节点之间的指针连接来组织数据

我们为什么用链表而不是数组?来看下这两者的简单对比:

特性 数组(Array) 链表(当前用的结构)
添加依赖(effect) O(1) or O(n) ✅ O(1)(直接接到尾部)
删除依赖(effect) ❌ O(n) ✅ O(1)(通过指针)
遍历执行所有依赖 O(n) O(n)
实现复杂度 简单 略高

链表的设计可以让我们更高效的添加和移除副作用函数依赖

接下来我们看看在我们的代码中链表结构是怎么设计的:

let newLink = {
  sub,                // 订阅者,即副作用函数 effect 实例(表示谁依赖了这个ref)
  nextSub: undefined, // 下一个
  prevSub: undefined, // 上一个
}

它用来存储与当前响应式数据关联的副作用函数effect实例,sub是我们调用link函数时传递过来的全局定义的一个变量,用来存储正在执行的副作用函数effect实例,这一块的代码在effect实现那里

接下来看这段代码,我将每行都写上了注释,想必应该很清楚了

// 判断当前的 dep 是否已有订阅者(即是否已经建立过依赖)
if (dep.subsTail) {
  // 已经有订阅者,追加到链表尾部

  // 1. 让当前尾节点的 nextSub 指向新节点(连接尾部)
  dep.subsTail.nextSub = newLink

  // 2. 让新节点的 prevSub 指向旧尾部(形成双向链表)
  newLink.prevSub = dep.subsTail

  // 3. 更新 dep.subsTail,标记新的尾节点
  dep.subsTail = newLink
} else {
  // 没有任何订阅者,这是第一次收集依赖

  // 1. 让 subs(链表头)指向新节点
  dep.subs = newLink

  // 2. 同时 subsTail(链表尾)也指向它(链表中只有一个节点)
  dep.subsTail = newLink
}

dep也就是调用link函数时传递过来的ref实例,在RefImpl构造函数中,我们定义了subssubsTail,分别用于存储链表的头部和尾部

这样的话我们在修改ref响应式数据时就可以从自己身上拿到subs然后一路nextSub下去,就可以拿到所有关联的副作用函数effect实例啦

然后我们把这些函数全部执行一遍就可以了

也就是这一段代码

function propagate(subs) {
  let link = subs                 // 1. 从链表头开始遍历
  const queuedEffect = []         // 2. 用一个数组缓存所有需要触发的副作用函数

  while (link) {                  // 3. 遍历链表,直到 null
    const sub = link.sub           // 当前节点中的副作用函数
    queuedEffect.push(sub)         // 加入执行队列
    link = link.nextSub            // 移动到下一个节点
  }

  // 4. 依次执行所有副作用函数(即 effect.run())
  queuedEffect.forEach(effect => effect.run())
}

这样一个简易的响应式系统就完成啦

const count = ref(0)

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

setTimeout(() => {
  count.value++
}, 1000)
  1. () => {console.log(count.value)}执行
  2. 触发countget
  3. 收集依赖执行link函数
  4. 一秒后执行count.value++
  5. 触发countset
  6. count的值++
  7. 执行propagate
  8. 拿到count实例中的subs,遍历执行收集的副作用函数
  9. 控制台再次打印count.value

总结

虽然这个响应式系统已经能自动追踪数据变化并更新副作用函数,但它仍然是一个极简版本,存在不少限制,比如:

  • 不支持嵌套 effect
  • 不支持复用 effect
  • 没有调度器(scheduler)

这些问题将在后续文章中继续优化和完善,欢迎关注 🌟

Echart饼图自动轮播效果封装

Echart饼图效果:

1752481355951.png

未封装轮播效果

饼图组件chart-pie-stats-list.vue

<template>
  <div class="chart-wrap flex">
    <div class="item one">
      <div class="chart" ref="chartRef"></div>
    </div>
    <div class="item one flex chart-text">
      <div class="flex flex-wrap w-100 column">
        <div
          class="item one flex column pt15 pb15 chart-text-item"
          v-for="(item, index) in dataObj.data"
          :key="index"
        >
          <div class="title flex col-center pb10">
            <div
              class="icon-circle mr10"
              :style="'background:' + color[index]"
            ></div>
            <div class="text-cont">
              <el-tooltip
                :content="item.name.toString()"
                effect="dark"
                placement="top"
              >
                <span class="span-text-over">{{ item.name }}</span>
              </el-tooltip>
            </div>
          </div>
          <div class="value-percent flex-column">
            <div
              class="value flex flex-1 pb10"
              v-if="fields.count"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.value.toString()"
                  effect="dark"
                  placement="top"
                >
                  <span class="span-text-over"
                    >{{ parseFloat(item.value || 0).toLocaleString()
                    }}<span v-if="fields.unit && unit">{{ unit }}</span></span
                  >
                </el-tooltip>
              </div>
            </div>
            <div
              :style="[{ color: color[index % color.length] }]"
              class="percent flex flex-1 row-left"
              v-if="fields.percent"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.percent.toString() + '%'"
                  effect="dark"
                  placement="top"
                >
                  <span
                    class="span-text-over"
                    v-if="
                      Number(item.percent) === 100 || Number(item.percent) === 0
                    "
                    :style="'color:#686868'"
                  >
                    {{ item.percent }}%
                  </span>
                  <span class="span-text-over" :style="'color:#686868'" v-else
                    >{{ Number(item.percent) }}%</span
                  >
                </el-tooltip>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import * as echarts from "echarts";
export default {
  props: {
    dataObj: {
      type: Object,
      default() {
        return {};
      },
    },
    title: {
      type: String,
      default: "",
    },
    color: {
      type: Array,
      default: () => ["#409EFF", "#67c23a", "#ebb563"],
    },
    // 字段显示
    fields: {
      type: Object,
      default: () => ({
        count: true,
        unit: true,
        percent: true,
      }),
    },
    // 数据单位
    unit: {
      type: String,
      default: "人次",
    },
    // 自动播放时间间隔,默认2s
    autoTime: {
      type: Number,
      default: 2,
    },
  },
  data() {
    return {
      option: {
        tooltip: {
          trigger: "item",
          formatter: (item) => {
            if (item.data.name) {
              return (
                item.marker +
                item.name +
                " " +
                (this.fields.count && item.value >= 0
                  ? `${item.value}${
                      this.fields.unit && this.unit ? this.unit : ""
                    }`
                  : "") +
                " " +
                (this.fields.percent && item.percent ? item.percent + "%" : "")
              );
            }
          },
        },
        legend: {
          orient: "vertical",
          left: "left",
          show: false,
        },
        series: [
          {
            name: "",
            type: "pie",
            radius: ["78%", "70%"],
            label: {
              show: false,
              position: "center",
            },
            emphasis: {
              label: {
                show: true,
                fontSize: 28,
                fontWeight: "bold",
              },
            },
            labelLine: {
              show: false,
            },
            itemStyle: {
              borderRadius: 10,
            },
            data: [
              // { value: 1048, name: 'Search Engine' },
              // { value: 735, name: 'Direct' },
            ],
          },
        ],
        color: this.color,
      },
      currentIndex: -1,
      myChart: null,
      timer: null,
    };
  },
  watch: {
    dataObj: {
      handler() {
        this.initData();
      },
      deep: true,
    },
  },
  mounted() {
    this.initData();
  },
  methods: {
    initData() {
      this.$set(this.option.series[0], "data", this.dataObj.data);
      if (this.title) {
        this.option.title.text = this.title;
      }
      let chartDom = this.$refs.chartRef;
      this.myChart = echarts.init(chartDom);
      this.option && this.myChart.setOption(this.option);
      let chartTarget = this.$refs.chartRef;
      this.autoPlay();
      chartTarget.addEventListener("mouseenter", () => {
        this.stopLightChart();
      });
      chartTarget.addEventListener("mouseleave", () => {
        this.autoPlay();
      });
    },
    autoPlay() {
      this.autoLightChart();
      this.timer = setInterval(() => {
        this.autoLightChart();
      }, this.autoTime * 1000);
    },
    autoLightChart() {
      let dataLen = this.option.series[0].data.length;
      const textItems = document.querySelectorAll(".chart-text-item");
      // 取消之前高亮的图形
      this.myChart.dispatchAction({
        type: "downplay",
        seriesIndex: 0,
        dataIndex: this.currentIndex,
      });
      this.currentIndex = (this.currentIndex + 1) % dataLen;
      // 高亮当前图形
      this.myChart.dispatchAction({
        type: "highlight",
        seriesIndex: 0,
        dataIndex: this.currentIndex,
      });
      // 显示 tooltip
      this.myChart.dispatchAction({
        type: "showTip",
        seriesIndex: 0,
        dataIndex: this.currentIndex,
      });
      textItems.forEach((item, index) => {
        if (index === this.currentIndex) {
          item.classList.add("active");
        } else {
          item.classList.remove("active");
        }
      });
    },
    stopLightChart() {
      clearInterval(this.timer);
      let dataLen = this.option.series[0].data.length;
      const textItems = document.querySelectorAll(".chart-text-item");
      dataLen.forEach((item, index) => {
        // 取消之前高亮的图形
        this.myChart.dispatchAction({
          type: "downplay",
          seriesIndex: 0,
          dataIndex: index,
        });
      });
      textItems.forEach((item, index) => {
        item.classList.contains("active") && item.classList.remove("active");
      });
    },
  },
  destroyed() {
    this.stopLightChart();
  },
};
</script>
<style lang="scss" scoped>
.chart-wrap {
  width: 100%;

  .chart {
    height: 200px;
    width: 100%;
  }

  .chart-text {
    overflow: hidden;

    .title {
      .icon-circle {
        width: 8px;
        height: 8px;
      }
    }
  }
}

.chart-text-item {
  padding: 20px;
  opacity: 0.4;
  transition: all 0.4s;
  border-radius: 10px;
  transform: scale(0.99);

  &.active {
    opacity: 1;
    transform: scale(1.01);
  }
}
</style>

封装轮播效果后

将自动轮播的过程封装为一个js方法调用

tool-pie.js

/**
 *  echarts tooltip 自动轮播
 *  @param myChart  //初始化echarts的实例
 *  @param num      //类目数量(原因:循环时达到最大值后,使其从头开始循环)
 *  @param time     //轮播间隔时长
 */
export function autoHover(myChart, num = 12, time = 2000, extra = { enable: true, normal: ".chart-text-item", active: 'active' }) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // echarts实例
      let echartsInstance = myChart.node;
      // 开启自动轮播
      let gInterVal = startAuto(myChart.chart, num, time, extra)

      // /* 鼠标位于触发点关闭自动轮播,离开触发点开启自动轮播 */
      echartsInstance.addEventListener("mouseenter", function () {
        stopAuto(gInterVal, myChart.chart, num, extra)
      })
      echartsInstance.addEventListener("mouseleave", function () {
        gInterVal = startAuto(myChart.chart, num, time, extra)
      })

      let dispose = () => {
        return stopAuto(gInterVal, myChart.chart, num, extra)
      }
      resolve(dispose)
    })
  })
}

// 开启自动轮播
function startAuto(myChart, num, time, extra) {
  let currentIndex = 0
  activeChart(myChart, currentIndex, num, extra)
  let timeTicket = null
  timeTicket && clearInterval(timeTicket)
  timeTicket = setInterval(() => {
    currentIndex = currentIndex + 1
    if (currentIndex >= num) {
      currentIndex = 0
    }
    activeChart(myChart, currentIndex, num, extra)
  }, time)
  return timeTicket
}

function activeChart(myChart, currentIndex, num, extra) {
  // 取消之前高亮的图形
  for (let i = 0; i < num; i++) {
    myChart.dispatchAction({
      type: 'downplay',
      seriesIndex: 0,
      dataIndex: i
    })
  }
  // 高亮当前图形
  myChart.dispatchAction({
    type: 'highlight',
    seriesIndex: 0,
    dataIndex: currentIndex
  })

  // 显示 tooltip
  myChart.dispatchAction({
    type: "showTip",
    seriesIndex: 0,
    dataIndex: currentIndex
  });

  // 建议同一个项目统一风格,方便处理
  if(extra?.enable){
    const textItems = document.querySelectorAll(extra.normal);
    textItems.forEach((item, index) => {
      if (index === currentIndex) {
        item.classList.add(extra.active);
      } else {
        item.classList.remove(extra.active);
      }
    });
  }

}

// 销毁自动轮播
function stopAuto(timeTicket, myChart, num, extra) {
  return new Promise((resolve, reject) => {
    for (let i = 0; i < num; i++) {
      myChart.dispatchAction({
        type: 'downplay',
        seriesIndex: 0,
        dataIndex: i
      })
    }

    // 建议同一个项目统一风格,方便处理
    if(extra?.enable){
      const textItems = document.querySelectorAll(extra.normal);
      textItems.forEach((item, index) => {
        item.classList.contains(extra.active) && item.classList.remove(extra.active);
      });
    }

    timeTicket && clearInterval(timeTicket)
    resolve()
  })

}

export default {
  autoHover
}

使用 tool-pie.js 的 chart-pie-stats-list.vue

<!--
 * @Descripttion: 会员饼图
 * @Author: wang pingqi
 * @Date: 2023-11-15 14:46:52
 * @LastEditors: wang ping qi
 * @LastEditTime: 2025-07-14 16:06:02
-->
<template>
  <div class="chart-wrap flex">
    <div class="item one">
      <div class="chart" ref="chartRef"></div>
    </div>
    <div class="item one flex chart-text">
      <div class="flex flex-wrap w-100 column">
        <div
          class="item one flex column pt15 pb15 chart-text-item"
          v-for="(item, index) in dataObj.data"
          :key="index"
        >
          <div class="title flex col-center pb10">
            <div
              class="icon-circle mr10"
              :style="'background:' + color[index]"
            ></div>
            <div class="text-cont">
              <el-tooltip
                :content="item.name.toString()"
                effect="dark"
                placement="top"
              >
                <span class="span-text-over">{{ item.name }}</span>
              </el-tooltip>
            </div>
          </div>
          <div class="value-percent flex-column">
            <div
              class="value flex flex-1 pb10"
              v-if="fields.count"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.value.toString()"
                  effect="dark"
                  placement="top"
                >
                  <span class="span-text-over"
                    >{{ parseFloat(item.value || 0).toLocaleString()
                    }}<span v-if="fields.unit && unit">{{ unit }}</span></span
                  >
                </el-tooltip>
              </div>
            </div>
            <div
              :style="[{ color: color[index % color.length] }]"
              class="percent flex flex-1 row-left"
              v-if="fields.percent"
            >
              <div class="text-cont flex row-right">
                <el-tooltip
                  :content="item.percent.toString() + '%'"
                  effect="dark"
                  placement="top"
                >
                  <span
                    class="span-text-over"
                    v-if="
                      Number(item.percent) === 100 || Number(item.percent) === 0
                    "
                    :style="'color:#686868'"
                  >
                    {{ item.percent }}%
                  </span>
                  <span class="span-text-over" :style="'color:#686868'" v-else
                    >{{ Number(item.percent) }}%</span
                  >
                </el-tooltip>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import tools from "@/utils/tool-pie";
import * as echarts from "echarts";
export default {
  props: {
    dataObj: {
      type: Object,
      default() {
        return {};
      },
    },
    title: {
      type: String,
      default: "",
    },
    color: {
      type: Array,
      default: () => ["#409EFF", "#67c23a", "#ebb563"],
    },
    // 字段显示
    fields: {
      type: Object,
      default: () => ({
        count: true,
        unit: true,
        percent: true,
      }),
    },
    // 数据单位
    unit: {
      type: String,
      default: "人次",
    },
    // 自动播放时间间隔,默认2s
    autoTime: {
      type: Number,
      default: 2,
    },
  },
  data() {
    return {
      option: {
        tooltip: {
          trigger: "item",
          formatter: (item) => {
            if (item.data.name) {
              return (
                item.marker +
                item.name +
                " " +
                (this.fields.count && item.value >= 0
                  ? `${item.value}${
                      this.fields.unit && this.unit ? this.unit : ""
                    }`
                  : "") +
                " " +
                (this.fields.percent && item.percent ? item.percent + "%" : "")
              );
            }
          },
        },
        legend: {
          orient: "vertical",
          left: "left",
          show: false,
        },
        series: [
          {
            name: "",
            type: "pie",
            radius: ["78%", "70%"],
            label: {
              show: false,
              position: "center",
            },
            emphasis: {
              label: {
                show: true,
                fontSize: 28,
                fontWeight: "bold",
              },
            },
            labelLine: {
              show: false,
            },
            itemStyle: {
              borderRadius: 10,
            },
            data: [
              // { value: 1048, name: 'Search Engine' },
              // { value: 735, name: 'Direct' },
            ],
          },
        ],
        color: this.color,
      },
      myChart: null,
      tools: null,
    };
  },
  watch: {
    dataObj: {
      handler() {
        this.initData();
      },
      deep: true,
    },
  },
  mounted() {
    this.initData();
  },
  methods: {
    initData() {
      this.$set(this.option.series[0], "data", this.dataObj.data);
      if (this.title) {
        this.option.title.text = this.title;
      }
      let chartDom = this.$refs.chartRef;
      this.myChart = echarts.init(chartDom);
      this.option && this.myChart.setOption(this.option);

      if(this.$refs.chartRef && !this.tools ){
        let dataLength = this.option.series[0].data.length || 0
        tools.autoHover(
          {
            node : this.$refs.chartRef,
            chart : this.myChart,
          },
          dataLength,
          2000
        ).then((tools) => {
          this.tools = tools
        });
      }
    }
  },
  destroyed() {
    if (this.tools) this.tools()
  },
};
</script>

<style lang="scss" scoped>
.chart-wrap {
  width: 100%;

  .chart {
    height: 200px;
    width: 100%;
  }

  .chart-text {
    overflow: hidden;

    .title {
      .icon-circle {
        width: 8px;
        height: 8px;
      }
    }
  }
}

.chart-text-item {
  padding: 20px;
  opacity: 0.4;
  transition: all 0.4s;
  border-radius: 10px;
  transform: scale(0.99);

  &.active {
    opacity: 1;
    transform: scale(1.01);
  }
}
</style>

调用 chart-pie-stats-list.vue

<template>
    <chartPieStatsList :dataObj="chartData2" />
</template>

<script>
import chartPieStatsList from "./components/chart-pie-stats-list";
export default {
  data() {
    return {
      chartData2: {
        loading: true,
        title: "性别比例",
        type: "pie",
        radius: ["50%", "50%"],
        data: [
          {
            value: 0,
            percent: 0,
            name: "男",
          },
          {
            value: 0,
            percent: 0,
            name: "女",
          },
        ],
      },
    }
  }
}
</script>
❌