阅读视图

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

如何在Windows上使用SANE扫描文档

SANE是用于连接文档扫描仪的一套API,主要用于UNIX系统。与TWAIN(另一种主要用于Windows的文档扫描API)相比,它有几个优点:

  1. TWAIN不会将用户界面与设备的驱动程序分开。这样很难通过网络访问图像捕获设备。而SANE则可以轻松地在纯命令行环境中使用。
  2. SANE内置了对各种扫描仪的支持,而使用TWAIN必须下载并安装单独的驱动程序。

有一些在Windows上运行SANE的尝试。但大多数做法仍然需要Linux环境。借助Windows的Linux子系统(WSL),这一过程变得更加容易。

继续阅读以了解如何做到这一点。

将USB设备连接到WSL

  1. 安装Linux WSL发行版后,将其版本设置为2以使用WSL 2。

    PS C:\Users\admin> wsl -l  -v
      NAME            STATE           VERSION
      Debian          Running         1
    PS C:\Users\admin> wsl --set-version Debian 2
    
  2. 在Windows上安装USBIPD。可以在GitHub上找到它的安装程序。

  3. 使用USBIPD将USB设备连接到WSL。

    PS C:\Users\admin> usbipd list # list USB devices connected to the host
    PS C:\Users\admin> usbipd bind --busid <busid> # share the device. You can find the bus id in the previous step
    PS C:\Users\admin> usbipd attach --wsl --busid <busid> # attach the device to WSL
    
  4. 在Linux中运行lsusb,可以在列表中找到USB设备。

    $ lsusb
    Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
    Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
    Bus 002 Device 003: ID 04c5:132e Fujitsu, Ltd fi-7160
    

使用SANE扫描文档

  1. 安装SANE。这里,我们的系统是Debian。

    $ sudo apt install sane-utils
    
  2. 使用scanimage命令行工具列出扫描仪和扫描文档。

    $ scanimage -L
    device 'fujitsu:fi-7160:151477' is a FUJITSU fi-7160 scanner
    $ scanimage -o scanned.png # save the document to an image
    

在浏览器中扫描文档

接下来,我们将使用Dynamic Web TWAIN SDK创建一个网页,在Windows上的浏览器中扫描文档,并使用WSL中运行的SANE后端。

  1. 在WSL上,安装Dynamic Web TWAIN服务。可以在它的npm包上找到安装程序。

    sudo dpkg -i DynamicWebTWAINServiceSetup.deb
    

    该服务将作为HTTP服务器在网页和扫描仪之间进行通信。您可以通过访问http://127.0.0.1:18625来检查它是否已安装。

    每次系统启动时,还需要启动两个进程。以下是启动它们的命令:

    nohup "/opt/dynamsoft/Dynamic Web TWAIN Service 19/DynamsoftScanning" gtkproxy &
    nohup "/opt/dynamsoft/Dynamic Web TWAIN Service 19/DynamsoftScanningMgr" &
    

    可以自己创建一个服务来启动它们。

  2. 使用以下代码编写一个网页来扫描文档并另存为PDF:

    <!DOCTYPE html>
    <html>
    <head>
      <title>Scan via SANE on Windows</title>
      <script src="https://cdn.jsdelivr.net/npm/dwt@latest/dist/dynamsoft.webtwain.min.js"></script>
    </head>
    <body>
      <button onclick="AcquireImage();">Scan</button>
      <button onclick="SaveAsPDF();">Save as PDF</button>
      <div id="dwtcontrolContainer"></div>
      <script type="text/javascript">
        Dynamsoft.DWT.Host = "local.dynamsoft.com";
        Dynamsoft.DWT.ResourcesPath = "https://cdn.jsdelivr.net/npm/dwt@latest/dist";
        //You need to set the service installer location here since the installer's size exceeds jsdelivr's limit.
        //You'd better host the installers in your own environment.
        Dynamsoft.DWT.ServiceInstallerLocation = 'https://unpkg.com/dwt/dist/dist/';
        Dynamsoft.DWT.ProductKey = 'LICENSE-KEY';
        Dynamsoft.DWT.Containers = [{ ContainerId: 'dwtcontrolContainer', Width: 270, Height: 350 }];
        window.onload = function () {
          Dynamsoft.DWT.Load();
        };
        var DWTObject;
        Dynamsoft.DWT.RegisterEvent("OnWebTwainReady", function() {
          // dwtcontrolContainer is the id of the DIV to create the WebTwain instance in.
          DWTObject = Dynamsoft.DWT.GetWebTwain('dwtcontrolContainer');
        });
        function AcquireImage() {
          if (DWTObject) {
            DWTObject.SelectSourceAsync().then(function(){
              return DWTObject.AcquireImageAsync({
                PixelType: Dynamsoft.DWT.EnumDWT_PixelType.TWPT_RGB,
                Resolution: 200,
                IfCloseSourceAfterAcquire: true
              });
            }).catch(function (exp) {
              alert(exp.message);
            });
          }
        }
    
        function SaveAsPDF(){
          if (DWTObject) {
            DWTObject.ConvertToBlob(
              DWTObject.SelectAllImages(),
              Dynamsoft.DWT.EnumDWT_ImageType.IT_PDF,
              function (result, indices, type) {
                console.log(result.size);
                DownloadBlobAsFile(result, "scanned_document.pdf");
              },
              function (errorCode, errorString) {
                console.log(errorString);
              },
            );
          }
        }
    
        function DownloadBlobAsFile(blob, fileName) {
          var link = document.createElement('a');
          link.href = window.URL.createObjectURL(blob);
          link.download = fileName;
          link.click();
        }
      </script>
    </body>
    </html>
    

sane-demo-page.jpg

现在,我们可以使用SANE在Windows上扫描文档了。

源代码

github.com/tony-xlh/Dy…

面试官:JWT、Cookie、Session、Token有什么区别?

JWT、Cookie、Session、Token 是 Web 开发中常用的身份认证和状态管理技术,它们之间既有区别,也有联系

一、JWT(JSON Web Token)

JWT 是一种开放标准(RFC 7519),用于在网络应用之间安全地传输信息(通常是身份认证信息)。它是一个自包含的、可验证的、不可篡改的字符串,格式如下:

Header.Payload.Signature

三部分组成:

  1. Header(头部):声明类型和签名算法(如 HS256)。
  2. Payload(载荷):包含用户信息(如用户 ID、角色等)和元数据(如过期时间)。
  3. Signature(签名):用密钥对 Header 和 Payload 签名,防止篡改。

特点:

  • 无需服务器存储(无状态)。
  • 可跨域使用(常用于分布式系统、微服务)。
  • 一旦签发,在过期前无法撤销(除非引入黑名单机制)。

二、Cookie

Cookie 是浏览器存储的一小段文本信息,由服务器通过 HTTP 响应头 Set-Cookie 设置,浏览器在后续请求中自动携带。

特点:

  • 自动携带(浏览器行为)。
  • 可设置过期时间、作用域、HttpOnly、Secure 等属性。
  • 容量小(约 4KB)。
  • 可用于存储 Session ID 或 JWT。

三、Session(会话)

Session 是服务器端维护的用户会话状态。通常流程如下:

  1. 用户登录后,服务器创建一个 Session,生成一个唯一的 Session ID
  2. Session ID 通过 Cookie 返回给浏览器。
  3. 浏览器后续请求自动携带该 Cookie,服务器通过 Session ID 查找对应的用户状态。

特点:

  • 状态存储在服务器端(通常是内存、Redis、数据库)。
  • 安全性较高(用户无法直接篡改)。
  • 不适合分布式系统(需要共享 Session 存储)。

四、Token(令牌)

Token 是一个广义概念,指用于身份验证的凭证。JWT 就是一种 Token。

常见 Token 类型:

  • Access Token(访问令牌):用于访问资源。
  • Refresh Token(刷新令牌):用于获取新的 Access Token。
  • JWT:一种结构化的 Token。

五、它们之间的关系与区别

名称 存储位置 状态管理 安全性 适用场景
JWT 客户端 无状态 分布式系统、移动端、API 认证
Cookie 客户端 无状态 存储小量数据、自动携带
Session 服务器端 有状态 传统 Web 应用
Token 客户端 无状态 通用身份凭证(JWT 是其一)

六、常见组合方式

方式一:Session + Cookie(传统 Web)

  • 登录后服务器创建 Session,Session ID 存 Cookie。
  • 每次请求带 Cookie,服务器查 Session 验证身份。

方式二:JWT + Header(前后端分离)

  • 登录后服务器返回 JWT,前端存 localStorage 或 Cookie。
  • 每次请求手动在 Header 中加 Authorization: Bearer <JWT>

方式三:JWT + Cookie(安全增强)

  • JWT 存 Cookie,设置 HttpOnly + Secure,防止 XSS。
  • 浏览器自动携带,服务器解析 JWT 验证身份。

七、总结

  • JWT 是一种自包含的 Token不依赖服务器存储
  • Cookie浏览器存储机制,可存 Session ID 或 JWT。
  • Session服务器存储的用户状态,依赖 Cookie 传递 ID。
  • Token身份凭证,JWT 是其中一种实现。

由于vite版本不一致,导致vue组件引入报错

最近开发了一个vue3的瀑布流插件,但是发现插件在部分项目无法正常展示。

报错信息: Uncaught (in promise) TypeError: Cannot read properties of null (reading 'ce')

image.png

这个错误信息“Uncaught (in promise) TypeError: Cannot read properties of null (reading 'ce') ” 是 Vue 3 中一个常见但信息模糊的报错,通常与组件未正确挂载、异步组件加载失败、或虚拟 DOM 渲染异常有关。

'ce' 是 Vue 内部压缩后的属性名,通常指向 组件的虚拟节点(vnode)或渲染上下文,当 Vue 试图访问一个已卸载或未挂载的组件实例时,就会抛出这个错误。

✅ 常见原因与排查方向

1. 组件未正确挂载就访问其 DOM 或实例

比如你在 onMounted 之前就访问了 refthis.$el,或者组件被条件渲染(v-if)控制,导致挂载失败。

2. 异步组件加载失败或返回 null

如果你使用了 defineAsyncComponentimport(),但组件加载失败或返回了 null,Vue 会尝试渲染一个无效的 vnode。

3. 组件在 v-if 或 v-show 中频繁切换,导致卸载时访问旧实例

比如你在 onUnmountedwatchEffect 中访问了已销毁的 DOM 或组件实例。

4. 使用了不兼容的库或插件

某些第三方库(如旧版本的 vue-router, pinia, element-plus)在 Vue 3.3+ 中可能存在兼容性问题,导致内部访问失败。

5. 组件依赖的devDependencies库和项目devDependencies版本不一致

由于组件依赖的运行时库在打包的时候不会编译进入dist包,项目本地运行时双方依赖版本不一致就会导致报错。

经过排查后发现我组件的vite版本和项目的vite版本差距太大。

//项目依赖库版本
"devDependencies": {
    "@vitejs/plugin-vue": "^3.0.3",
    "vite": "^3.0.7"
 }

//组件库依赖版本
"devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "vite": "^7.2.2"
 }

解决方案:升级项目的依赖库版本即可正常展示;

image.png

vue3+vite实现瀑布流效果 vue3-waterfall-x

HTML&CSS&JS:赛博木鱼

用 HTML + CSS + JS 打造一个禅意十足的互动小应用——赛博木鱼, 本文将拆解其核心实现逻辑,带你从 0 到 1 理解 “敲木鱼” 背后的技术细节。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS


<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>赛博木鱼</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            background-color: #121212;
            color: #FFFFFF;
            font-family: 'Kaiti SC', 'PingFang SC', sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            overflow: hidden;
        }

        .title-container {
            text-align: center;
            margin-bottom: 20px;
        }

        .main-title {
            color: #FFFFFF;
            font-size: 6vw;
            font-family: 'Kaiti SC', sans-serif;
            background: linear-gradient(180deg,
                rgba(255, 176, 103, 0.05) 0%,
                rgba(115, 55, 0, 0.02) 100%);
            background-clip: text;
            margin: 0;
            line-height: 1;
        }

        .score-container {
            text-align: center;
            margin: 20px 0;
        }

        .score {
            font-size: 10vh;
            font-family: 'PingFang SC', sans-serif;
            font-weight: 900;
            margin: 0;
        }

        .description {
            font-size: 5vh;
            font-family: 'PingFang SC', sans-serif;
            margin-top: 10px;
        }

        .wooden-fish-container {
            position: relative;
            width: 300px;
            height: 250px;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 30px 0;
        }

        .wooden-fish {
            width: 284px;
            height: 236px;
            transition: transform 0.1s ease;
        }

        .mallet {
            position: absolute;
            top: 20px;
            right: 20px;
            width: 119px;
            height: 69px;
            transition: transform 0.1s ease;
            z-index: 10;
        }

        .floating-text {
            position: absolute;
            top: -50px;
            left: 50%;
            transform: translateX(-50%);
            font-family: 'PingFang SC', sans-serif;
            font-size: 1.5em;
            color: #FFFFFF;
            opacity: 0;
            pointer-events: none;
            animation: floatUp 0.4s ease-out forwards;
        }

        @keyframes floatUp {
            0% {
                opacity: 1;
                transform: translateX(-50%) translateY(0);
            }
            100% {
                opacity: 0;
                transform: translateX(-50%) translateY(-60px);
            }
        }

        .bottom-text {
            font-family: 'Kaiti SC', sans-serif;
            font-size: 1.2em;
            text-align: center;
            margin-top: 30px;
        }
    </style>
</head>
<body>
    <div class="title-container">
        <div class="main-title">赛博木鱼</div>
    </div>

    <div class="score-container">
        <div class="score" id="score">0</div>
        <div class="description">功德</div>
    </div>

    <div class="wooden-fish-container" id="fishContainer">
        <img src="https://img.alicdn.com/imgextra/i3/O1CN012CO0YU1VSfNs506ZS_!!6000000002652-2-tps-284-236.png"
             alt="木鱼" class="wooden-fish" id="woodenFish">
        <img src="https://img.alicdn.com/imgextra/i1/O1CN01tKb5Et1aSjWRjCHK3_!!6000000003329-2-tps-119-69.png"
             alt="锤子" class="mallet" id="mallet">
    </div>

    <div class="bottom-text">轻敲木鱼,细悟赛博真经。</div>

    <audio id="clickSound" preload="auto">
        <source src="https://qianwen.alicdn.com/resource/qiaomuyu.mp3" type="audio/mpeg">
    </audio>

    <script>
        let score = 0;
        const scoreElement = document.getElementById('score');
        const woodenFish = document.getElementById('woodenFish');
        const mallet = document.getElementById('mallet');
        const fishContainer = document.getElementById('fishContainer');
        const clickSound = document.getElementById('clickSound');

        fishContainer.addEventListener('click', function(e) {
            // 播放音效
            clickSound.currentTime = 0;
            clickSound.play();

            // 锤子旋转动画
            mallet.style.transform = 'rotate(-25deg)';
            setTimeout(() => {
                mallet.style.transform = 'rotate(0deg)';
            }, 100);

            // 木鱼放大动画
            woodenFish.style.transform = 'scale(1.1)';
            setTimeout(() => {
                woodenFish.style.transform = 'scale(1)';
            }, 100);

            // 增加分数
            score++;
            scoreElement.textContent = score;

            // 创建漂浮文字
            const floatingText = document.createElement('div');
            floatingText.className = 'floating-text';
            floatingText.textContent = '功德+1';
            fishContainer.appendChild(floatingText);

            // 移除漂浮文字
            setTimeout(() => {
                floatingText.remove();
            }, 400);
        });
    </script>
</body>
</html>




HTML

  • title-container:标题容器:承载 “赛博木鱼” 主标题,通过居中布局突出视觉核心
  • score-container:分数展示区:包含 “功德数” 和 “功德” 描述,是用户交互的核心反馈载体
  • wooden-fish-container:交互核心容器
  • wooden-fish:木鱼图片:通过 CDN 引入资源,无需本地存储,是点击交互的视觉核心
  • mallet:锤子图片:绝对定位在木鱼右上方,点击时通过旋转动画模拟 “敲击” 动作
  • clickSound:音效载体:预加载木鱼敲击音效(preload="auto"),点击时触发播放,增强沉浸感
  • floating-text:动态生成元素:点击时创建 “功德 + 1” 漂浮文字,完成交互反馈闭环

CSS

  • .mallet:点击时锤子绕右上角旋转 25 度,模拟 “敲下去” 的动作,100 毫秒快速回弹
  • .wooden-fish:点击时木鱼轻微放大 1.1 倍,模拟 “被敲击后的震动”,与锤子动作同步:
  • .floating-text:自动向上淡出,营造“功德升天”的视觉效果。

JavaScript

  1. 初始化变量:获取 DOM 元素与状态
let score = 0; // 功德数初始值
const scoreElement = document.getElementById('score'); // 分数显示元素
const woodenFish = document.getElementById('woodenFish'); // 木鱼元素
const mallet = document.getElementById('mallet'); // 锤子元素
const fishContainer = document.getElementById('fishContainer'); // 交互容器
const clickSound = document.getElementById('clickSound'); // 音效元素

通过 getElementById 获取核心交互元素,提前定义功德数状态。

  1. 核心交互:点击事件处理
fishContainer.addEventListener('click', function(e) {
    // 1. 播放敲击音效(每次点击从头播放,支持连续敲击)
    clickSound.currentTime = 0;
    clickSound.play();

    // 2. 锤子旋转动画:敲下→回弹
    mallet.style.transform = 'rotate(-25deg)';
    setTimeout(() => {
        mallet.style.transform = 'rotate(0deg)';
    }, 100);

    // 3. 木鱼缩放动画:放大→还原
    woodenFish.style.transform = 'scale(1.1)';
    setTimeout(() => {
        woodenFish.style.transform = 'scale(1)';
    }, 100);

    // 4. 功德数累加与更新
    score++;
    scoreElement.textContent = score;

    // 5. 生成“功德+1”漂浮文字
    const floatingText = document.createElement('div');
    floatingText.className = 'floating-text';
    floatingText.textContent = '功德+1';
    fishContainer.appendChild(floatingText);

    // 6. 动画结束后移除文字(避免DOM堆积)
    setTimeout(() => {
        floatingText.remove();
    }, 400);
});
  1. 关键技术点拆解

音效重置:clickSound.currentTime = 0 确保连续点击时音效不叠加,每次都是完整的 “敲击声”;

动画同步:锤子旋转和木鱼缩放的过渡时长(0.1s)与定时器延迟(100ms)一致,动作协调;

DOM 优化:漂浮文字动画结束后通过 remove()移除,避免页面 DOM 元素过多导致性能问题;

无依赖设计:不使用任何框架,仅原生 API,兼容性覆盖所有现代浏览器。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

React组件命名为什么用小写开头会无法运行?

在React项目实际开发中,我们经常用到一些约定俗成的语法,今天我们来聊一聊为什么组件命名时以小写字母开头的组件无法运行的这个现象,这个现象是由什么原因导致的。这个背后有重要的设计原理。

这就不得不谈 JSX,JSX是一种语法扩展,它允许我们在JavaScript中编写类似HTML的代码。

React项目中遇到JSX中的元素时,函数组件首字母大小写决定了React编译这个元素 是原生DOM元素还是自定义组件。 具体来说:

  • 当JSX标签以小写字母开头时,React会将其视为原生DOM元素(如divspan等),并尝试在DOM中创建对应的标签。
  • 当JSX标签以大写字母开头时,React会将其视为自定义组件,并去查找当前作用域中对应的函数或类组件。

那么,这个问题产生的根本原因:JSX 的编译机制

// 当 Babel 编译 JSX 时,它会根据标签的首字母大小写来决定如何转换:
<MyComponent />
<div />

// 编译后的 JavaScript
React.createElement(MyComponent, null);  // 大写 - 作为变量/组件
React.createElement("div", null);        // 小写 - 作为字符串(HTML 标签)
// ❌ 错误:小写组件名
function avatar({ src, alt }) {
  return <img src={src} alt={alt} />;
}

function UserProfile() {
  return (
    <div>
      {/* 这会导致错误 */}
      <avatar src="user.jpg" alt="User" />
      {/* 编译为:React.createElement("avatar", { src: "user.jpg", alt: "User" }) */}
      {/* React 会寻找 <avatar> HTML 标签,但不存在 */}
    </div>
  );
}

Babel有一个插件(通常是@babel/plugin-syntax-jsx或@babel/preset-react)来处理JSX语法。这个插件会将JSX转换为React.createElement调用。

实现这一转换的Babel插件内部,会有一个Visitor来处理JSXElement节点。在Visitor中,它会检查JSXOpeningElement的name属性。如果name是一个JSXIdentifier,并且首字母是小写,则将其作为字符串;如果是大写,则保留为标识符。 那么我们来模拟插件内部是怎么解析的呢?看下方代码

<MyComponent prop="value" />
<div className="container" />

// Babel 解析为 AST(抽象语法树)
{
  type: 'JSXElement',
  openingElement: {
    type: 'JSXOpeningElement',
    name: {
      type: 'JSXIdentifier',
      name: 'MyComponent'  // 或 'div'
    }
    // ...
  }
}

转换阶段核心代码

export default function (babel) {
  const { types: t } = babel;
  
  return {
    name: "transform-jsx",
    visitor: {
      JSXElement(path) {
        const openingElement = path.node.openingElement;
        const tagName = openingElement.name.name;
        
        // 关键判断逻辑
        let elementType;
        if (/^[a-z]/.test(tagName)) {
          // 小写开头 -> HTML 标签 -> 字符串
          elementType = t.stringLiteral(tagName);
        } else {
          // 大写开头 -> 组件 -> 标识符
          elementType = t.identifier(tagName);
        }
        
        // 转换为 React.createElement 调用
        const createElementCall = t.callExpression(
          t.identifier('React.createElement'),
          [elementType, ...processAttributes(openingElement.attributes)]
        );
        
        path.replaceWith(createElementCall);
      }
    }
  };
}

实际 Babel 插件源码分析

在 @babel/plugin-transform-react-jsx 中:

function transformJSX() {
  return {
    visitor: {
      JSXElement(path) {
        const { node } = path;
        const tag = node.openingElement.name;
        
        let tagExpr;
        if (tag.type === 'JSXIdentifier') {
          const tagName = tag.name;
          
          // 关键判断:首字母是否小写
          if (
            /^[a-z][a-z0-9]*$/.test(tagName) || 
            // 或者是已知的 SVG 标签等
            knownHTMLTags.has(tagName) ||
            knownSVGTags.has(tagName)
          ) {
            // HTML/SVG 标签 -> 字符串字面量
            tagExpr = types.stringLiteral(tagName);
          } else {
            // 组件 -> 标识符
            tagExpr = types.identifier(tagName);
          }
        } else if (tag.type === 'JSXMemberExpression') {
          // 处理 <MyComponent.SubComponent /> 这种情况
          tagExpr = transformJSXMemberExpression(tag);
        }
        
        const createElementCall = types.callExpression(
          types.identifier('React.createElement'),
          [tagExpr, ...createAttributes(node.openingElement.attributes)]
        );
        
        path.replaceWith(createElementCall);
      }
    }
  };
}

完整的编译示例如下

JSX

  return (
    <div className="app">
      <Header title="Welcome" />
      <main className="content">
        <UserList users={users} />
        <footer className="site-footer">
          <Copyright year={2024} />
        </footer>
      </main>
    </div>
  );
}

Babel 编译后的 JavaScript

  return React.createElement(
    "div", 
    { className: "app" },
    React.createElement(Header, { title: "Welcome" }),
    React.createElement(
      "main", 
      { className: "content" },
      React.createElement(UserList, { users: users }),
      React.createElement(
        "footer", 
        { className: "site-footer" },
        React.createElement(Copyright, { year: 2024 })
      )
    )
  );
}

以上就是组件命名大小写在react插件中的运行示例演示,解释了为什么组件用小写开头无法运行。

看似简单的首字母大小写判断,实际上是整个 React 开发设计和生态的重要一环。

我是大布布将军,一个AICodeing时代下的前端开发思考者。

Flutter 从入门到精通:状态管理入门 - setState 的局限性与 Provider 的优雅之道

如果你已经写过一些 Flutter 代码,你一定对 StatefulWidgetsetState 不陌生。当你需要更新屏幕上的某些内容时——比如用户点击按钮后,一个数字增加了——setState 是你最先学到的工具。

然而,随着应用逻辑变得复杂,你可能会发现仅仅依靠 setState 会让代码变得混乱、难以维护,甚至引发性能问题。这时,你就需要一个更专业的状态管理方案。

这篇文章将带你:

  1. 深入理解 setState 的工作机制。
  2. 剖析 setState 在复杂应用中的三大局限性。
  3. 入门 Flutter 官方推荐的、最简单直观的状态管理库 Provider
  4. 亲手将一个 setState 案例重构为 Provider 实现,感受其魅力。

1. 一切的开始:setState

Flutter 中,“状态 (State)” 是什么?简单来说,状态就是可以随时间变化的、会影响 UI 呈现的数据。一个计数器的当前数值、一个加载指示器是否显示、一个用户的登录信息,这些都是状态。

StatefulWidget 与其关联的 State 对象就是 Flutter 管理局部状态的基础。当我们调用 setState 方法时,发生了三件事:

  1. 更新数据:你在 setState 的回调函数中改变了某个状态变量的值。
  2. 标记为“脏” (Dirty)Flutter 框架会将当前 Widget 标记为“需要重建”。
  3. 触发重建:在下一帧绘制时,Flutter 会重新调用这个 Widgetbuild 方法,使用新的状态数据来构建 UI,从而更新屏幕。

让我们来看一个最经典的计数器例子:

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      homeCounterPage(),
    );
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage{
  int _counter = 0;

  void _incrementCounter() {
    // 调用 setState 来更新 UI
    setState(() {
      // 在这个回调中改变状态变量
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('CounterPage build method called!'); // 添加打印以便观察
    return Scaffold(
      appBarAppBar(
        titleconst Text('setState Demo'),
      ),
      bodyCenter(
        childColumn(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('You have pushed the button this many times:'),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButtonFloatingActionButton(
        onPressed: _incrementCounter,
        tooltip'Increment',
        childconst Icon(Icons.add),
      ),
    );
  }
}

这个例子完美地展示了 setState 的用法,对于单一 Widget 内部的状态管理,setState 简单有效。

2. setState 的三大局限性

当我们的应用只有一个页面时,一切都很美好。但现实是复杂的,setState 的问题很快就会暴露出来。

局限一:状态提升困难 (State Lifting)

假设你想在 AppBar 的标题中也显示这个计数器的值。_counter 状态现在位于 _CounterPageState 中,AppBar 却在它的 build 方法里。更糟糕的是,如果另一个完全不同的页面也需要这个 _counter 的值呢?

setState 管理的状态是与特定 Widget 实例绑定的。要跨 Widget 共享状态,你唯一的办法就是“状态提升”——将状态移动到需要它的所有 Widget 的共同父 Widget 中,然后通过构造函数层层传递下来。如果状态需要被子 Widget 修改,你还需要将修改状态的回调函数也一并层层传递下去。

这个过程很快就会变成一场噩梦,我们称之为“回调地狱”或“属性钻取 (Prop Drilling)”。

局限二:不必要的 Widget 重建

观察上面代码中的 print 语句。每次你点击按钮,控制台都会打印 "CounterPage build method called!"。

这意味着整个 CounterPagebuild 方法都被重新执行了。在这个简单的例子里,这没什么大不了。但在一个复杂的页面中,Scaffold 下可能有一个包含成百上千个 Widget 的复杂树。setState 会把它们全部重建,即使大部分 Widget 根本不依赖 _counter 这个变量。

这种粗粒度的重建是 Flutter 性能问题的主要来源之一。我们理想的更新应该是精确的,只重建那些真正依赖数据的 Widget

局限三:业务逻辑与 UI 耦合

_CounterPageState 中, _incrementCounter 这个业务逻辑(如何改变数据)和 build 方法这个UI 逻辑(如何展示数据)被紧紧地捆绑在同一个类里。

随着业务逻辑越来越复杂(例如,_incrementCounter 需要发起网络请求,并处理成功或失败的情况),这个 State 类会变得越来越臃肿,难以阅读、测试和维护。一个良好的架构应该让 UI 和业务逻辑分离。

3. 救星登场:Provider

为了解决上述问题,社区涌现了许多状态管理方案,如 Provider, Riverpod, BLoC, Redux, MobX 等。

Provider 是官方推荐的入门首选,它由社区开发者 Remi Rousselet 创建(他也是 Riverpod 的作者),后来被 Flutter 团队收编为 "Flutter Favorite" 包。

Provider 的核心思想很简单:

  1. 状态(数据和业务逻辑)Widget 中抽离到一个独立的类中。
  2. 通过一个“提供者” Widget (ChangeNotifierProvider) 将这个类的实例放入 Widget 树的顶层。
  3. 在树下的任何子 Widget 中,都可以轻松地“获取”这个实例,来读取状态或调用方法。
  4. 当状态改变时,只有那些“正在监听”这个状态的 Widget 会被重建。

4. 使用 Provider 重构计数器应用

让我们用 Provider 的思想来重构上面的例子。

第一步:添加 provider 依赖

pubspec.yaml 文件中添加依赖:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.2 # 推荐使用最新版本

然后运行 flutter pub get

第二步:创建状态模型 (Model)

我们将状态和业务逻辑抽离出来,创建一个 CounterModel。它需要混入 (mixin) ChangeNotifier,这是 Flutter SDK 内置的一个简单的类,它能让我们在状态改变时通知监听者。

创建一个新文件 lib/counter_model.dart

import 'package:flutter/foundation.dart';

class CounterModel with ChangeNotifier {
  int _count = 0;

  // 读取数据的 getter
  int get count => _count;

  // 修改数据并通知监听者的方法
  void increment() {
    _count++;
    notifyListeners(); // 关键!通知所有监听者数据已改变。
  }
}
  • ChangeNotifier:提供了 notifyListeners() 方法。
  • notifyListeners():当我们的数据 (_count) 改变后,调用此方法,所有监听这个 CounterModelWidget 都会被通知,并触发重建。
第三步:提供模型实例

我们需要在 Widget 树的顶端提供 CounterModel 的实例,以便下面的 Widget 可以访问它。通常,我们把它放在 MaterialApp 的上一层。

修改 main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_model.dart'; // 导入我们创建的模型

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

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

  @override
  Widget build(BuildContext context) {
    // 3. 使用 ChangeNotifierProvider 包裹 MaterialApp
    return ChangeNotifierProvider(
      create: (context) => CounterModel(), // 创建 CounterModel 实例
      child: const MaterialApp(
        homeCounterPage(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    print('CounterPage build method called!');
    return Scaffold(
      appBarAppBar(
        // 5. 在 AppBar 中也可以轻松访问状态
        titleText('Provider Demo - Count: ${context.watch<CounterModel>().count}'),
      ),
      bodyCenter(
        childColumn(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('You have pushed the button this many times:'),
            // 4. 使用 Consumer Widget 来监听状态变化
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                print('Text widget rebuilds!');
                return Text(
                  '${counter.count}',
                  style: Theme.of(context).textTheme.headlineMedium,
                );
              },
            ),
          ],
        ),
      ),
      floatingActionButtonFloatingActionButton(
        onPressed: () {
          // 6. 调用模型的方法来改变状态
          // 使用 read 方法,因为它只触发一次动作,不需要监听后续变化
          context.read<CounterModel>().increment();
        },
        tooltip'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
代码解析:
  • ChangeNotifierProvider: 它创建了 CounterModel 的实例,并将其提供给它的所有子 Widget
  • Consumer<CounterModel>: 这是 Provider 的核心。它的 builder 会在 CounterModel 调用 notifyListeners() 时被精确地重新执行。注意,现在 CounterPage 自身变成了 StatelessWidget,它的 build 方法不再因为数据改变而重复调用!只有 Consumer 包裹的 Text 在重建。
  • context.watch<T>(): 这是另一种监听数据的方式,它会让整个 build 方法都依赖这个数据。我们在 AppBar 中使用它,所以当计数改变时,AppBar 也会重建以更新标题。
  • context.read<T>(): 这个方法只会读取一次数据,并且不会在数据变化时引起 Widget 重建。它最适合用在 onPressed 这样的回调函数中,我们只想调用一个方法,而不需要因为数据变化而重建按钮本身。

总结

让我们回顾一下,Provider 是如何解决 setState 的三大局限性的:

  1. 状态共享:通过 ChangeNotifierProvider,任何深层的子 Widget 都能轻松访问到状态,无需层层传递。
  2. 性能优化:通过 Consumercontext.watch,我们可以实现“精确重建”,只有依赖数据的 Widget 才会被更新,避免了不必要的 UI 开销。
  3. 逻辑分离:业务逻辑被清晰地分离到了 CounterModel 中,UI (CounterPage) 只负责展示和触发动作,代码结构更清晰,更易于测试和维护。

当然,setState 并非一无是处。对于那些纯粹的、局部的、不需要与其他 Widget 共享的 UI 状态(例如一个动画的控制器状态,或一个输入框的焦点状态),使用 setState 依然是最简单直接的选择。

掌握 Provider,是你踏上 Flutter 专业开发之路的关键一步。从这里开始,你将能够构建更复杂、更健壮、性能更优的应用程序。

在接下来的文章中,我们将探讨更高级的状态管理方案,如 RiverpodBLoC,它们在 Provider 的基础上提供了更强大的功能。

对 changelogen 和 changelogithub 使用的思考

摘要

对 bumpp+changelogithub 和 changelogen 这两款发版方案的实践探索与对比。

我试着使用这两款工具,实现版本升级更新日志生成、和依赖包发布。不过用起来有点卡手,对我来说仅仅只使用部分的功能。

我打算在一个普通的管理后台项目内,使用这些工具,实现版本升级日志生成。不考虑依赖包发布的事情,因为不可能发布一个管理后台到 npm 镜像内。

试着直接使用工作流的 changelogithub 和 bumpp 工具实现上述要求

在 node 项目安装 bumpp ,这是一个用来实现依赖包版本号升级的库,用于手动升级项目内根包的版本号。

在 package.json 内编写命令:

{
"release:bumpp": "bumpp"
}

bumpp 的默认行为不适合同步上传本地修改的 CHANGELOG.md 文件

bumpp 是一个很单纯的,对版本号做升级的工具。阅读文档,其默认开启了一下三款参数:

  1. --commit 默认生成 git commit 提交信息。
  2. --tag 默认生成 git tag 版本。
  3. --push 默认同时推送 tag 和 commit 信息。

这些默认行为不好去拆分处理,彼此相互耦合,形成了一套固定的,仅仅对外更新 package.json 版本号和推送 git tag 的工作流程。

如果我想拆分掉其中的内容,重新有机组合自己的工作流程,就很坐牢

试着在本次 git commit 生成的时候,不默认提交到本地仓库

这事实上做不到,只要你写配置,就一定会生成 git commit,并且默认推送到本地 git 仓库。而且你的 git commit 仅仅只有一个文件修改和一个 tag 提交,你不能添加额外的东西。

比如说我想在更新版本号后,同时更新 CHANGELOG.md 文件,然后再提交。让一次 git commit 同时包含三个内容:

  • 被更新的 CHANGELOG.md 文件。
  • 被更新的根目录 package.json 文件。
  • 新增的 git tag 标签。

这是做不到的,bumpp 工具没办法让你多上传一个被更改的本地文件。

基于 bumpp 工具的版本升级逻辑,就不允许,也不考虑你在本地写入、更新、并推送CHANGELOG.md 文件到 git 仓库。你的每次 git commit 只能包含以下两个内容:

  • 被更新的根目录 package.json 文件。
  • 新增的 git tag 标签。

这种逻辑你必须接受,否则你就别用 bumpp 来升级版本号。这让我非常难受,因为我之前习惯用 changset 来更新版本号,并且每次提交的时候,都是可以一次性提交:

  1. 被更新的 CHANGELOG.md 文件。
  2. 被更新的根目录 package.json 文件。
  3. 新增的 git tag 标签。

这些都可以自由掌控,而 bumpp 发版工具卡死了,限定死了。不允许你有多余的日志更新与上传行为。

试着在 bump.config.ts 内不提供 --tag 参数

这事实上是不可理喻的,反而让自己坐牢。不提供 tag 参数,bumpp 甚至没办法给 package.json 更新有意义的版本号,直接留空,无法写入。

试着在 bump.config.ts 内不提供 --push 参数

这反而自己坐牢。不提供 --push 参数,虽然 package.json 的修改没有被默认推送到本地 git 仓库,可是 tag 标签却没办法推送,要自己手动不全 git 的推送参数才行。

git push --follow-tags

你要自己找机会,确保生成的 tag 被推送云端仓库,才能不会漏东西。这反而加重了心智负担。

结论: bumpp 就不给你机会生成什么日志文件

bumpp 本身的默认行为就不适合你在任何渠道内同时上传本地的 CHANGELOG.md 文件、package.json 的版本号和 git tag 标签。

bumpp 事实上和 changelogithub 高度耦合的

我上面折腾那么久,不就是为了实现在本地生成 CHANGELOG.md 文件并且一同上传么?我就是喜欢 changeset 这种提交方式啊。

所以更新日志的生成能力,只能仰赖其他的工具。经过调研,bumpp 经常是用 changelogithub 来生成更新日志的。

但是很不巧的是,changelogithub 本身就仅仅只考虑生成最好的 github release 更新日志,不考虑生成本地的 CHANGELOG.md 更新日志。

正常使用 changelogithub 的工作流

一般来说,changelogithub 是在 github workflow 工作流配置文件内写的,而且往往是通过 git tag 来触发工作流的。

# .github\workflows\release.yaml
name: Release

on:
  push:
    tags:
      - "v*"

permissions: write-all

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: 设置javascript环境
        uses: sxzz/workflows/setup-js@v1
        with:
          fetch-all: false
          package-manager: pnpm
          auto-install: true

- name:  changelogithub 生成 github release 发行版日志
        run: pnpm dlx changelogithub
        continue-on-error: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

在工作流内写起来很简单,很优雅。但是只负责,只考虑在 github release 内发布更新日志。

尝试在 github workflow 内让 changelogithub 生成日志文件

实在不行我试着让 changelogithub 在云端工作流内,也写入 CHANGELOG.md 更新日志,再实现提交。

  1. 在 github workflow 内额外增加一个步骤 pnpm dlx changelogithub --output "CHANGELOG.md" 。在云端内写入文件。
  2. 在云端的 git 内设置用户名、邮箱。
  3. 确定云端 git 的分支名称。
  4. 推送到 origin 远程仓库。
  5. 本机仓库再 git fetch 拉取更新。

这套流程很麻烦,要实现云端生成文件、修改文件、生成 git commit 提交并拉取更新,太麻烦了。我都套模板了,至于搞那么复杂么?

这套流程不好走通,而且 changelogithub 本身提供的 --output 参数在仓库文档内是不写清楚的,要你自己亲自看源码才知道有这个参数的。可想而知这个方案太离谱,太弯弯绕绕了。太卡手了,不能去落实下去。

尝试在本地仓库用 changelogithub 生成日志文件,然后额外提交一个 git commit 专门说明更新了日志文件

这肯定很不优雅嘛。哪有一个 git commit 提交专门为了说明一个版本更新的日志的。那肯定会问,为什么更新日志不能和更新版本后的 package.json 一起发布呢?

这问题又绕回来了,bumpp 没办法让我做到这一点。这就意味着,只要我使用这一套 bumpp + changelogithub 的发版工作流,就算能生成本地日志文件,git commit 提交也被迫变乱。

changelogithub 本地生成的 CHANGELOG.md 更新日志太难看

在本地运行命令:

pnpm dlx changelogithub --output "CHANGELOG.md"

控制台内看起来很好看:

2025-11-13-22-47-30

但是实际在 CHANGELOG.md 文件内,看的难看的要死:

2025-11-13-22-48-16

满屏幕都是 &nbsp; 空格,这个 CHANGELOG.md 文件都不是给人看的。这不能接受吧,不可能去生成这种全都是 &nbsp; 空格的文件给人去阅读的,太离谱了。

结论: changelogithub 就不给你在本地生成能看的日志文件

所以很遗憾,只能放弃掉用 changelogithub 在云端和本地生成 CHANGELOG.md 文件的方案。

changelogithub 的用途就卡死了,自己就仅仅限定在 github workflow 工作流内,根据 tag 标签触发更新,生成 github release 更新日志。

完全不能去生成任何实体的,具体的 CHANGELOG.md 文件。

在生成 github release 日志这件事上,这个工具很优雅。但也就只能做这一种事情。

尝试用 bumpp + changelogen 的搭配来生成本地的更新日志文件

那既然本地用 changelogithub 生成 CHANGELOG.md 文件是一坨答辩,那我退而求其次用更加根本的 changelogen 来生成更新日志,行不行呢?

changelogithub 本质上就是对 changelogen 的二次封装,在 bumpp 升级版本后,紧接着用 changelogen 来生成:

运行命令:

pnpm dlx changelogen --output "CHANGELOG.md"

实际出现一个致命缺陷,changelogen 没办法在本地内判断上一个 tag 标签和最新的 tag 标签,导致生成的更新日志是空的。两个相同版本号之间是没有任何差异的,所以日志是空的。

尝试单独使用 changelogen 完成一整个版本升级和日志生成工作

我就很纳闷了,changelogen 有那么废物么?在 bumpp 升级版本后,紧接着用 changelogen 来生成本地日志,结果无法判断版本号差异?生成空日志?

我很怀疑 changelogen 本身是不能去依赖别人来更新版本号的,如果让 changelogen 自己去实现版本号升级,并且自己去生成日志,会怎么样?

这是一套完全独立的新方案了。和 bumpp + changelogithub 方案完全不同了。

编写命令并生成版本号

阅读 changelogen 的 README 文档,得知要想让 changelogen 独立完成上述工作,需要以下这几个参数:

  1. --bump 根据 git 语义化提交来确定版本号,写入 package.json 文件并升级版本号。
  2. --release 生成更新日志。并生成 git tag。
  3. --push 根据预设的 git commit 模板,同时推送:
    • 升级版本号,更改后的 package.json 文件。
    • 更新日志文件。
    • git tag 标签。
{
"release:changelogen": "changelogen --bump --release --push"
}

如下图所示:

2025-11-13-23-13-24

查看 git 记录,同时有 git tag、本地的修改日志文件、被修改的 package.json。

2025-11-13-23-14-45

而且本地的 CHANGELOG.md 文件很工整美观:

2025-11-13-23-15-42

这很完美哦,完美满足了我上面的全部需求。不过单独的 changelogen 方案还是有一些问题的。

容易误触

你一点击,你就发布更新了。你要手动撤回 git 修改内容才行,包括手动删除掉推送的 tag 标签。

误触撤回比较麻烦。很容易不小心就触发版本更新了。

没办法自己确定版本号

changelogen 是根据语义化的 git commit 信息,来自主判断确定版本号的。你没得选版本号。

总是会自己打开 github release 页面

目前 changelogen 是没有办法自己关闭掉默认打开 github release 页面的行为的,这一点有点恼人。

2025-11-13-23-17-10

总结: 顾此失彼,失去对版本号的手动控制了

changelogen 能独立完成我的核心需求,但是我却没办法手动精准选择版本号了。只能按照语义化的发版规则,自主更新版本号。

方案对比总结表

经过上述的折腾,可以总结出这两款方案的使用细节差异:

方案 bumpp + changelogithub 结合方案 单独 changelogen 方案
能否手动控制版本号? 可以用 bumpp 手动选择版本号 不能,根据 conventionalcommits 约定式提交来生成版本号
本地自动生成的提交
包含 git tag 标签?
包含 包含
本地自动生成的提交
包含 package.json
包含 包含
本地自动生成的提交
包含 CHANGELOG.md
不包含 包含
本地生成的 CHANGELOG.md 是否美观? 不美观。滥用 &nbsp; 空格 正常。没有冗余的 &nbsp; 空格
是否方便生成本地的 CHANGELOG.md 文件? 不方便 正常生成
是否方便生成云端的 github release 日志? 正常生成 不方便,默认手动上传

发版方案对比总结表

就仅仅针对 更新版本号维护 CHANGELOG.md 更新日志文件推送 github release 更新日志 这三件事而言,不同的发版方案有不同的处理细节。总结如下:

发版方案 bumpp + changelogithub changelogen changeset
更新 package.json 版本号 由 bumpp 可独立完成 --bump 参数实现 changeset version 命令消耗变更集并按配置更新
如何控制 semver 语义化版本 由 bumpp 手动选择 由 conventionalcommits 自动映射 changeset add 命令内手动选择
或在变更集文件手动编写
更新日志内容 由 conventionalcommits 自动生成 由 conventionalcommits 自动生成 手动编写
对 monorepo 的支持 全部包共用唯一一个版本号 全部包共用唯一一个版本号 每个包有独立的版本号
也可以通过配置实现共用版本号
git 提交允许 CHANGELOG.md 不包含本地的 CHANGELOG.md 文件 允许上传 允许上传,消耗变更集会默认包含本地的 CHANGELOG.md 文件
生成 github release 日志 由 changelogithub 独立生成 在 github 工作流内自动生成 不能独立生成。须依赖额外的 changesets/action 工作流完成
github workflow 触发方式 由 git tag 触发工作流 由 git tag 触发工作流 分两步:changesets/action 识别变更集生成 pr;合并 pr 更新版本
发版流程的心智负担 轻量级,无负担 还行 繁杂。手动写更新日志,并围绕变更集,合并 pr 完成发版
发版速度 最快 最快 最慢,很多步骤需要人工确认
是否容易误触 容易误触 容易误触 全人工编写变更集 + 手动合合并 pr ,几乎没有误触风险
方案落地难度 非常简单 略有重复代码,也很简单 稍微比较复杂,有多个常用命令要声明
适用代码与团队规模 个人、单仓库、小团队 个人、单仓库、小团队 多人协作、多包维护、严格流程的大团队
对比其他方案的综合评价 轻量级,无负担 还行。保持平衡 最通用,最繁杂,生态最完整

该总结表也是作者在 2025 年内,折腾发包发版和日志生成时,最有价值的表格了。

使用建议

  1. 如果是公司级别的正式的,规范的项目。那我选择 changeset 方案,因为可以手动编写更新日志,多人审核复核,人工控制发版版本号。
  2. 如果是单人维护的,放到 github 的小项目。那我选择 changelogen 方案,有一个差不多能看的更新日志,本地能看,github release 也能看日志,语义化的版本号,就够用了。
  3. 如果是追求快速落地的 npm 包发布。那我选择 bumpp + changelogithub 方案,该方案落地最快,实现最快,配置最少,发版最迅速。

回到最初的需求,我打算在一个普通的管理后台项目内,实现版本升级日志生成。我应该选择 changelogen 方案。

从值拷贝到深拷贝:彻底弄懂 JavaScript 的堆与栈

彻底搞懂 JS 的堆内存与栈内存:从值拷贝到深拷贝

在学习 JavaScript 的过程中,经常会遇到一些令人困惑的问题:

  • 为什么我改了一个对象,另一个变量也变了?
  • 为什么有的赋值是“独立”的,有的却会互相影响?
  • 深拷贝和浅拷贝,到底区别在哪?

这些现象的根源,其实都来自 —— 堆内存(Heap)与栈内存(Stack) 的不同存储机制。
本文将带你从内存模型出发,搞懂数据的“居住位置”和“传递方式”,彻底弄清值拷贝与引用拷贝的差异。


一、栈内存与堆内存:存储机制的区别

JavaScript 会根据变量类型,选择不同的存储方式:

数据类型 存储位置 特点
基本类型(Number、String、Boolean、null、undefined、Symbol、BigInt) 栈内存(Stack) 连续存储、读写高效、空间固定
引用类型(Object、Array、Function) 堆内存(Heap) 动态分配、可扩展、访问间接

栈内存:简单变量的“快递柜”

let a = 1;
let b = 2;
let c = 3;
let d = a; // 值拷贝

栈内存中存储的是值本身,且空间连续。
d = a 实际上是对 1 这个值的复制。
因此,无论之后如何修改 a,都不会影响到 d

关键特征:
每个变量都有自己的小空间,互不干扰、读取极快。


堆内存:对象与数组的“仓库区”

const users = [
  { id: 1, name: "oumasyu", hometown: "赣州" },
  { id: 2, name: "inx177", hometown: "南昌" },
  { id: 3, name: "gustt_", hometown: "赣州" }
];

数组和对象属于引用类型
它们的实际数据存放在堆内存中,而变量 users 只是保存了一个引用地址

当你执行:

const data = users;
data[0].hobbies = ["篮球", "看烟花"];
console.log(data, users);

结果会发现 —— users 也被改动了!

这是因为:

  • usersdata 在栈中存放的地址相同
  • 它们同时指向堆内存中同一块数据区域
  • 改变任何一方,其实都在修改那块共享的堆空间。

二、引用式拷贝:看似复制,实则共用

可以把这种情况理解为:

users ──► [ { id:1, name:"oumasyu" } ]
   ▲
   │(共用同一地址)
data ┘

datausers 并没有创建两份数据,只是共用一个引用。
所以,修改 data[0] 的属性,等同于修改 users[0]


三、想要真正“分家”?你需要深拷贝

如果希望两个对象互不影响,就必须让它们在堆内存中拥有各自的空间

方法一:JSON.parse(JSON.stringify())

const data = JSON.parse(JSON.stringify(users));
data[0].hobbies = ["篮球", "看烟花"];
console.log(data, users);

执行后你会发现:
修改 data 不会再影响 users —— 它们终于“分家”了。

原理
通过序列化和反序列化,将对象转成字符串再重新生成,从而创建一份全新的数据结构。

优点:简单直接、常用于深拷贝。
缺点:无法拷贝函数、undefinedSymbol、循环引用等。


方法二:structuredClone()(更现代)

const data = structuredClone(users);

structuredClone() 是浏览器原生的深拷贝 API。
相比 JSON 方法,它支持更多数据类型(如 DateRegExpMapSet、循环引用等),
是未来更推荐的写法。


四、图解内存变化:从共享到独立

# 引用式拷贝
users ──► [ { id:1, name:"oumasyu" } ]
   ▲
   │
data ┘  (共用同一堆空间)

# 深拷贝后
users ──► [ { id:1, name:"oumasyu" } ]
data  ──► [ { id:1, name:"oumasyu", hobbies:["篮球"] } ]
(独立的两份堆内存数据)

五、核心对比总结

拷贝类型 是否新建堆内存 是否共享数据 常见实现方式
值拷贝(基本类型) =
引用拷贝(对象/数组) =
深拷贝 JSON.parse(JSON.stringify()) / structuredClone()

总结

理解堆内存与栈内存的本质,是写好 JS 的关键一步。
当你清楚变量“指的是什么”,你就能轻松判断:

  • 哪些修改会相互影响;
  • 何时该用深拷贝;
  • 如何优化内存和性能。

一句话总结:

基本类型复制的是值,引用类型复制的是地址。
想要真正“断开关系”,就得创建新的堆内存。

从零掌握 Ajax:一次请求带你读懂异步数据加载原理

一、精炼理解:Ajax 到底是啥?

Ajax = 在不刷新页面的情况下,和服务器交换数据并动态更新页面
它让 Web 页面拥有“应用级”的体验——局部更新、响应式交互、更少等待。核心实现长期基于浏览器内建对象 XMLHttpRequest(XHR) ,现代开发则多用 fetch / Axios,但底层思想相同:异步请求 + 数据驱动视图


二、核心流程

  1. 创建 XHR 实例

    const xhr = new XMLHttpRequest();
    
  2. 打开连接

    xhr.open('GET', '/api/members', true); // true => 异步
    
  3. 可选:设置请求头 / 超时 / 响应类型

    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.responseType = 'json'; // 直接得到对象(现代浏览器)
    xhr.timeout = 5000; // 毫秒
    
  4. 发送请求

    xhr.send();
    
  5. 监听状态 / 处理响应

    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          const data = xhr.responseType === 'json' ? xhr.response : JSON.parse(xhr.responseText);
          // 更新 DOM
        } else {
          // 处理非 200 的情况
        }
      }
    };
    
  6. 清理/取消

    xhr.abort(); // 主动取消请求
    

三、readyState 一眼看懂

  • 0 UNSENT:未调用 open()
  • 1 OPENED:调用了 open(),可 setRequestHeadersend
  • 2 HEADERS_RECEIVED:已接收响应头(可读取 status)
  • 3 LOADING:接收响应体中(可处理流式数据)
  • 4 DONE:响应完成,数据就绪

调试建议:打印 readyStatestatus,快速定位在哪个阶段失败。


四、常见坑与解决方法

1. 同步请求会卡死 UI

xhr.open(..., false) 是同步,会阻塞主线程。不要在浏览器中使用同步请求,除非你非常清楚场景(几乎没有)。

2. 跨域(CORS)错误

浏览器会阻止不满足 CORS 策略的请求。后端需设置 Access-Control-Allow-Origin,或通过代理绕过开发时限制。

3. JSON 解析报错

JSON.parse 报错说明服务器没返回合法 JSON。排查响应 Content-Type、响应体首尾空白和服务器异常栈。

4. 超时与重试

设置合理 xhr.timeout,并在 ontimeout 中进行用户提示或重试逻辑(避免无限重试)。

5. 状态码不是 200

处理 4xx/5xx、304 等不同类型响应,给用户明确提示而非只在控制台打印错误。

6. 网络抖动与幂等

POST 请求若可重试需保证幂等(或在服务端做去重),避免重复提交造成的数据问题。


五、XHR 与 fetch/Axios 对比(实用速览)

特性 XHR fetch Axios
语法风格 回调/事件 Promise(更现代) Promise + 自动 JSON 处理
支持流 有(onprogress) 有(ReadableStream) 基于 fetch/XHR,封装更好
自动 JSON 解析 .json() 是(响应自动转换)
超时设置 有(timeout 需 AbortController 内置超时配置
更灵活的进度 是(上传/下载 progress) 比较复杂 封装上传/下载进度支持

建议:新项目优先用 fetch 或 Axios;但遇到上传进度/低层控制时,XHR 的事件模型仍然非常有用。


六、实用示例:可靠的异步请求

<ul id="members"></ul>
<script>
  const ul = document.getElementById('members');

  function fetchMembers() {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', true);
    xhr.responseType = 'json'; // 现代浏览器直接得到对象
    xhr.timeout = 7000;

    xhr.onreadystatechange = () => {
      if (xhr.readyState !== 4) return;
      if (xhr.status === 200) {
        const data = xhr.response;
        ul.innerHTML = data.map(u => `<li>${u.login}</li>`).join('');
      } else {
        ul.innerHTML = `<li>请求失败:${xhr.status} ${xhr.statusText}</li>`;
      }
    };

    xhr.ontimeout = () => {
      ul.innerHTML = '<li>请求超时,请重试</li>';
    };

    xhr.onerror = () => {
      ul.innerHTML = '<li>网络错误,请检查连接</li>';
    };

    xhr.send();
    return xhr; // 若需要外部取消请求,可保留此引用并调用 xhr.abort()
  }

  fetchMembers();
</script>

七、总结

掌握 Ajax,不只是知道如何发请求,而是理解请求生命周期、错误处理与用户体验折衷——把网络的不确定性,优雅地交给代码去处理。

JS防抖:别再让按钮“手抖”连点了!

你有没有遇到过这种情况:点击“提交订单”按钮,因为网络卡了点了两下,结果生成了两个订单?或者搜索框输入时,每输一个字就发一次请求,服务器直接“累到罢工”?这时候,JS防抖(Debounce)就能派上大用场了!

什么是防抖?

简单来说,防抖就是让函数“冷静”一下再执行。如果在短时间内频繁触发同一个函数,防抖会忽略前面的触发,只执行最后一次。就像你在电梯里按关门键,只要有人不断按,电梯就会一直等,直到没人按了才关门。

为什么需要防抖?

  • 减少请求次数:搜索框输入时,防抖可以等用户输入完成后再发请求,而不是每输一个字就发一次,大大减轻服务器压力。
  • 避免重复操作:按钮点击时,防抖可以防止用户快速连点导致的重复提交(比如下单、支付)。
  • 提升性能:对于一些复杂的DOM操作(比如窗口 resize 时计算元素位置),防抖可以避免频繁计算,让页面更流畅。

防抖函数怎么写?(超简单版)

下面是一个基础版的防抖函数,代码不多,注释也写得很清楚:

 // 防抖函数:func是要执行的函数,delay是延迟时间(毫秒)
function debounce(func, delay) {
  let timer = null; // 定时器ID

  // 返回一个新函数
  return function(...args) {
    // 如果定时器存在,清除它(取消上一次的延迟执行)
    if (timer) clearTimeout(timer);

    // 重新设置定时器,delay毫秒后执行func
    timer = setTimeout(() => {
      func.apply(this, args); // 执行原函数,并传递参数
    }, delay);
  };
}

 

实际例子:按钮防抖

假设我们有一个“提交订单”按钮,点击后会调用  submitOrder()  函数。如果用户快速点击多次,就会重复提交。我们用防抖来解决这个问题:

<button id="submitBtn">提交订单</button>
<script>
// 模拟提交订单的函数
function submitOrder() {
  console.log("订单提交成功!");
  // 这里可以写真实的接口请求代码
}

// 使用防抖包装submitOrder,延迟500毫秒执行
const debouncedSubmit = debounce(submitOrder, 500);

// 给按钮绑定点击事件,触发防抖后的函数
document.getElementById("submitBtn").addEventListener("click", debouncedSubmit);
</script>

 

现在,即使你在500毫秒内点击10次按钮,也只会执行一次  submitOrder() ,完美解决了重复提交的问题!

实际例子:搜索框防抖

再来看一个搜索框的例子。用户输入时,我们希望等用户停止输入1秒后,再发送搜索请求:

<input type="text" id="searchInput" placeholder="请输入关键词搜索">

<script>
// 模拟搜索接口请求
function search(keyword) {
  console.log(`正在搜索:${keyword}`);
  // 这里可以写真实的搜索接口请求代码
}

// 使用防抖包装search,延迟1000毫秒执行
const debouncedSearch = debounce(search, 1000);

// 给搜索框绑定输入事件
document.getElementById("searchInput").addEventListener("input", function(e) {
  debouncedSearch(e.target.value); // 传递输入框的值给搜索函数
});
</script>

 

现在,用户输入“手机”时,不会每输一个字就搜索一次,而是等用户停止输入1秒后,才会执行一次搜索,大大减少了请求次数。

防抖的小细节

  • 延迟时间的选择:延迟时间(delay)要根据实际场景调整。按钮防抖一般用300-500毫秒,搜索框防抖一般用500-1000毫秒。
  • 立即执行:有时候我们希望第一次触发时立即执行,之后才防抖(比如按钮点击后立即禁用,防止重复点击)。这种情况可以给防抖函数加一个  immediate  参数,稍微修改一下代码即可实现(进阶需求,基础版暂时用不到)。

总结

记住这个简单的防抖函数,下次遇到“手抖”问题时,直接拿来用就可以啦!需要我帮你写一个带“立即执行”功能的进阶版防抖函数吗?

Flutter 每日库:轻松监听网络变化,就靠 connectivity_plus!

在移动应用开发中,网络状态监听是一个高频需求——比如视频应用在非WiFi环境下限制自动播放,或是在网络断开时提示用户检查连接。今天介绍的 connectivity_plus 插件,正是 Flutter 开发者实现这一功能的利器!


一、为什么需要 connectivity_plus?

1. 核心功能

  • 实时监听网络类型:WiFi、蜂窝数据、蓝牙、VPN 等。
  • 跨平台支持:Android、iOS、Web、Windows 等全平台兼容。
  • 简单易用:几行代码即可实现网络状态订阅。

2. 注意事项

  • 不保证网络可用性:仅检测连接类型,不验证网络是否能访问互联网。
  • iOS 权限提示:首次安装需用户授权网络权限,否则监听可能失效。
  • 真机调试更准确:模拟器可能返回异常结果。

二、快速集成步骤

1. 添加依赖

pubspec.yaml 中引入插件:

dependencies:
  connectivity_plus: ^6.1.3

2. Android 配置

修改 android/app/build.gradle,确保最低 SDK 版本 ≥ 19:

defaultConfig {
    minSdkVersion 19
}

并在 AndroidManifest.xml 中添加权限:

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

3. 核心代码实现

class _ConnectivityPlusPageState extends State<ConnectivityPlusPage> {
  StreamSubscription<List<ConnectivityResult>>? _subscription;
  final Connectivity _connectivity = Connectivity();

  @override
  void initState() {
    super.initState();
    // 订阅网络变化
    _subscription = _connectivity.onConnectivityChanged.listen((result) {
      print('当前网络类型: $result');
      // 根据结果更新UI或业务逻辑
    });
  }

  @override
  void dispose() {
    _subscription?.cancel(); // 务必释放资源
    super.dispose();
  }
  
  // 其他代码...
}

三、高级技巧与场景应用

1. 获取当前网络类型

Future<String> getNetworkType() async {
  final result = await Connectivity().checkConnectivity();
  if (result.contains(ConnectivityResult.wifi)) return "WiFi";
  if (result.contains(ConnectivityResult.mobile)) return "蜂窝网络";
  return "无网络";
}

2. 处理 iOS 网络权限弹窗

建议在开屏页检测网络状态,若未授权则引导用户设置:

Future<void> checkInitialNetwork() async {
  final result = await Connectivity().checkConnectivity();
  if (result == ConnectivityResult.none) {
    showDialog(...); // 提示用户开启网络
  }
}

3. 结合网络请求

搭配 diohttp 库,在网络恢复时自动重试:

_subscription = Connectivity().onConnectivityChanged.listen((result) {
  if (result != ConnectivityResult.none) {
    retryFailedRequests(); // 重新发送失败请求
  }
});

JavaScript日期操作与DOM节点管理:构建动态网页的核心技术

在现代前端开发中,JavaScript的日期对象和DOM节点操作是构建交互式网页的两大核心技术。本文将通过多个实际案例,深入探讨这些技术的应用场景和实现方法。

日期对象:网页时间的掌控者

日期对象基础

JavaScript中的Date对象用于处理日期和时间。通过实例化Date对象,我们可以获取当前时间或指定时间:

javascript

复制下载

// 获取当前时间
const date = new Date();
console.log(date);

// 获取指定时间
const date1 = new Date('2022-5-1 08:30:00');
console.log(date1);

Date对象提供了丰富的方法来获取时间的各个组成部分:

  • getFullYear(): 获取年份
  • getMonth(): 获取月份(0-11)
  • getDate(): 获取日期(1-31)
  • getDay(): 获取星期(0-6)
  • getHours(): 获取小时(0-23)
  • getMinutes(): 获取分钟(0-59)
  • getSeconds(): 获取秒数(0-59)

实时时钟的实现

利用Date对象和定时器,我们可以轻松实现一个实时更新的时钟:

html

复制下载运行

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    div {
      width: 300px;
      height: 40px;
      border: 1px solid pink;
      text-align: center;
      line-height: 40px;
    }
  </style>
</head>
<body>
  <div></div>
  <script>
    const div = document.querySelector('div')
    function getMyDate() {
      const date = new Date()
      let h = date.getHours()
      let m = date.getMinutes()
      let s = date.getSeconds()
      h = h < 10 ? '0' + h : h
      m = m < 10 ? '0' + m : m
      s = s < 10 ? '0' + s : s
      return `今天是: ${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${h}:${m}:${s}`
    }

    div.innerHTML = getMyDate()
    setInterval(function () {
      div.innerHTML = getMyDate()
    }, 1000)
  </script>
</body>
</html>

这段代码创建了一个实时更新的时钟,通过setInterval函数每秒更新一次显示内容。注意在处理小时、分钟和秒数时,我们使用了三元运算符确保单位数时间前面补零,使显示更加美观。

倒计时功能的实现

倒计时是网站中常见的功能,如促销活动、限时抢购等场景。以下是一个下班倒计时的实现:

html

复制下载运行

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Document</title>
  <style>
    /* 样式代码 */
  </style>
</head>
<body>
  <div class="countdown">
    <p class="next">今天是2222年2月22日</p>
    <p class="title">下班倒计时</p>
    <p class="clock">
      <span id="hour">00</span>
      <i>:</i>
      <span id="minutes">25</span>
      <i>:</i>
      <span id="scond">20</span>
    </p>
    <p class="tips">18:30:00下课</p>
  </div>
  <script>
    // 目标时间
    const last = +new Date('2025-11-12 16:30:00');

    fn(); // 先调用一次,填补空白期
    setInterval(fn, 1000);

    const next = document.querySelector('.next');
    next.innerHTML = `今天是${new Date().getFullYear()}${new Date().getMonth() + 1}${new Date().getDate()}日`;

    // 倒计时函数
    function fn() {
      // 现在时间
      const now = +new Date();
      // 倒计时时间差
      const time = last - now;

      // 计算离目标时间有多少小时、分、秒
      let h = parseInt(time / 1000 / 60 / 60 % 24);
      let m = parseInt(time / 1000 / 60 % 60);
      let s = parseInt(time / 1000 % 60);
      h = h < 10 ? '0' + h : h;
      m = m < 10 ? '0' + m : m;
      s = s < 10 ? '0' + s : s;

      document.querySelector('#hour').innerHTML = h;
      document.querySelector('#minutes').innerHTML = m;
      document.querySelector('#scond').innerHTML = s;
    }
  </script>
</body>
</html>

这个倒计时功能的关键点在于:

  1. 使用+new Date()获取时间戳,便于计算时间差
  2. 通过数学运算将毫秒差转换为小时、分钟和秒
  3. 使用setInterval实现每秒更新
  4. 初始调用一次函数,避免等待第一秒时的空白期

DOM节点操作:动态网页的基石

DOM节点基础

文档对象模型(DOM)将HTML文档表示为树形结构,允许JavaScript动态访问和更新文档内容、结构和样式。

节点查找

DOM提供了多种查找节点的方法:

  • 父节点:parentNode

  • 子节点:

    • childNodes - 获取所有子节点,包括文本节点、注释节点
    • children - 仅获得所有元素节点,返回一个伪数组
  • 兄弟节点:

    • nextSibling / previousSibling - 获取上下兄弟节点,包括文本节点
    • nextElementSibling / previousElementSibling - 获取上下兄弟元素节点

点击关闭广告案例

html

复制下载运行

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    /* 样式代码 */
  </style>
</head>
<body>
  <div class="box">
    我是广告1
    <div class="box1">X</div>
  </div>
  <div class="box">
    我是广告2
    <div class="box1">X</div>
  </div>
  <div class="box">
    我是广告3
    <div class="box1">X</div>
  </div>
  <script>
    // 方法1: 通过父节点删除
    const closeBtn = document.querySelectorAll('.box1')
    for (let i = 0; i < closeBtn.length; i++) {
      closeBtn[i].addEventListener('click', function () {
        // 关闭我的爸爸 所以只关闭当前的父元素
        this.parentNode.style.display = 'none'
      })
    }

    // 方法2: 事件委托
    // const parents = document.querySelectorAll('.box');
    // for(let i = 0;i<parents.length;i++){
    //   parents[i].addEventListener('click',function(){
    //     this.style.display = 'none';
    //   })
    // }

    // 方法3: 普通方法
    // const closeBtn = document.querySelectorAll('.box1');
    // const parents = document.querySelectorAll('.box');
    // for(let i = 0;i<closeBtn.length;i++){
    //   closeBtn[i].addEventListener('click',function(){
    //     parents[i].style.display = 'none';
    //   })
    // }
  </script>
</body>
</html>

这个案例展示了三种实现关闭功能的方法:

  1. 父节点方法:通过this.parentNode直接获取关闭按钮的父元素,简单直接
  2. 事件委托:将事件绑定到父元素,利用事件冒泡机制,适合动态添加的元素
  3. 普通方法:通过索引关联关闭按钮和对应的广告容器

节点创建与添加

动态创建课程列表

html

复制下载运行

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>学车在线首页</title>
    <link rel="stylesheet" href="./css/style.css">
</head>
<body>
    <div class="box w">
        <div class="box-hd">
            <h3>精品推荐</h3>
            <a href="#">查看全部</a>
        </div>
        <div class="box-bd">
            <ul class="clearfix">
                <!-- 动态生成的内容 -->
            </ul>
        </div>
    </div>
    <script>
        let data = [
            {
                src: 'images/course01.png',
                title: 'Think PHP 5.0 博客系统实战项目演练',
                num: 1125
            },
            // ... 更多数据
        ];
       
        //根据数据的个数,创建对应的小li
        const ul = document.querySelector('.box-bd ul');
        for(let i = 0 ;i<data.length;i++){
            const li = document.createElement('li');
            //追加小li
            ul.appendChild(li);
            //渲染内容
            li.innerHTML = `
                <a href="#">
                    <img src=${data[i].src} alt="">
                    <h4>${data[i].title}</h4>
                    <div class="info">
                        <span>高级</span> • <span>${data[i].num}</span>人在学习
                    </div>
                </a>
            `
        }
    </script>
</body>
</html>

这个案例展示了如何根据数据动态生成页面内容:

  1. 使用document.createElement创建新的li元素
  2. 使用appendChild将新元素添加到DOM树中
  3. 使用模板字符串动态生成HTML内容
  4. 通过循环遍历数据数组,为每个数据项创建对应的页面元素

克隆节点

克隆节点是创建相似元素的便捷方法:

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>
</head>
<body>
  <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
  </ul>
  <script>
    const ul = document.querySelector('ul');
    //克隆节点 元素.cloneNode(true)
    const li1 = ul.children[0].cloneNode(true);
    //追加
    li1.innerHTML = '4';
    ul.appendChild(li1);
  </script>
</body>
</html>

克隆节点有两种方式:

  • 元素.cloneNode(true): 深克隆,包括所有子节点
  • 元素.cloneNode(false): 浅克隆,仅克隆元素本身

综合案例:学生信息管理系统

学生信息管理系统综合运用了DOM操作和事件处理:

html

复制下载运行

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta http-equiv="X-UA-Compatible" content="ie=edge" />
  <title>学生信息管理</title>
  <link rel="stylesheet" href="css/index.css" />
</head>
<body>
  <h1>新增学员</h1>
  <form class="info" autocomplete="off">
    姓名:<input type="text" class="uname" name="uname" />
    年龄:<input type="text" class="age" name="age" />
    性别:
    <select name="gender" class="gender">
      <option value="男"></option>
      <option value="女"></option>
    </select>
    薪资:<input type="text" class="salary" name="salary" />
    就业城市:<select name="city" class="city">
      <option value="北京">北京</option>
      <option value="上海">上海</option>
      <option value="广州">广州</option>
      <option value="深圳">深圳</option>
      <option value="曹县">曹县</option>
    </select>
    <button class="add">录入</button>
  </form>

  <h1>就业榜</h1>
  <table>
    <thead>
      <tr>
        <th>学号</th>
        <th>姓名</th>
        <th>年龄</th>
        <th>性别</th>
        <th>薪资</th>
        <th>就业城市</th>
        <th>操作</th>
      </tr>
    </thead>
    <tbody>
      <!-- 动态内容 -->
    </tbody>
  </table>
  <script>
    //获取元素
    const uname = document.querySelector('.uname');
    const age = document.querySelector('.age');
    const gender = document.querySelector('.gender');
    const salary = document.querySelector('.salary');
    const city = document.querySelector('.city');
    const tbody = document.querySelector('tbody');
    //定义一个数组
    const arr = [];
    
    // 1.录入模块
    const info = document.querySelector('.info');
    info.addEventListener('submit', function (e) {
      //阻止默认行为
      e.preventDefault();
      //进行表单验证,获取所有带name属性的元素
      const names = document.querySelectorAll('[name]');
      for(let i = 0;i<names.length;i++){
        if(names[i].value === ''){
          alert('请填写完整信息');
          return;
        }
      }
      //创建对象
      const obj = {
        stuId: arr.length + 1,
        uname: uname.value,
        age: age.value,
        gender: gender.value,
        salary: salary.value,
        city: city.value,
      }
      arr.push(obj);

      //清空表单 reset重置
      this.reset();

      //清空tbody
      tbody.innerHTML = '';
      //调用渲染函数
      render();
    })

    //删除 事件委托给tbody
    tbody.addEventListener('click', function (e) {
      if (e.target.tagName === 'A') {
        arr.splice(e.target.dataset.id, 1);
        //重新渲染
        tbody.innerHTML = '';
        render();
      }
    })

    //渲染函数
    function render() {
      for (let i = 0; i < arr.length; i++) {
        const tr = document.createElement('tr');
        tr.innerHTML = `
          <td>${arr[i].stuId}</td>
          <td>${arr[i].uname}</td>
          <td>${arr[i].age}</td>
          <td>${arr[i].gender}</td>
          <td>${arr[i].salary}</td>
          <td>${arr[i].city}</td>
          <td><a href="javascript:" data-id=${i}>删除</a></td>
        `
        tbody.appendChild(tr);
      }
    }
  </script>
</body>
</html>

这个综合案例包含了以下关键技术点:

  1. 表单提交处理:使用submit事件和preventDefault()阻止默认提交行为
  2. 表单验证:检查所有必填字段是否已填写
  3. 数据管理:使用数组存储学生信息
  4. 动态渲染:根据数组数据动态生成表格行
  5. 事件委托:将删除事件委托给tbody,处理动态添加的元素
  6. 数据属性:使用data-id存储数据索引,便于删除操作

总结

JavaScript的日期对象和DOM操作是前端开发中不可或缺的核心技术。通过本文的多个案例,我们可以看到:

  1. 日期对象使我们能够处理各种时间相关需求,从简单的时钟显示到复杂的倒计时功能
  2. DOM节点操作允许我们动态创建、修改和删除页面内容,实现丰富的交互体验
  3. 事件处理让我们能够响应用户操作,创建响应式的用户界面
  4. 数据驱动的页面生成方式提高了代码的可维护性和可扩展性

Canvas入门指南:从零开始绘制你的第一个图形

在现代网页开发中,Canvas(画布)是一项强大的技术,它允许我们通过JavaScript在网页上绘制各种图形、动画和交互式内容。无论是数据可视化、游戏开发还是创意艺术展示,Canvas都能提供无限可能。今天,我们将从零开始学习如何在网页中创建画布并绘制简单的图形。

Canvas

Canvas是HTML5引入的一个元素,它提供了一个可以通过JavaScript绘制图形的区域。你可以把它想象成一块真实的画布,而JavaScript就是你的画笔,你可以使用它来绘制线条、形状、文本和图像。

创建画布基本语法

创建一个Canvas元素

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas绘图示例</title>
    <style>
        canvas {
            border: 1px solid #ccc;
            margin: 20px;
        }
    </style>
</head>
<body>
    <canvas id="myCanvas" width="800" height="600">
        您的浏览器不支持Canvas,请升级到现代浏览器。
    </canvas>
    
    <script>
        // 获取Canvas元素
        const canvas = document.getElementById('myCanvas');
        
        // 获取2D绘图上下文
        const ctx = canvas.getContext('2d');
        
        // 现在可以使用ctx来绘制图形了
    </script>
</body>
</html>

绘制简单图形

1. 绘制矩形
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>绘制矩形</title>
    <style>
        canvas {
            border: 1px solid #ccc;
        }
    </style>
</head>
<body>
    <canvas id="myCanvas" width="800" height="400"></canvas>
    
    <script>
        const canvas = document.getElementById('myCanvas');
        const ctx = canvas.getContext('2d');
        
        // 设置填充颜色
        ctx.fillStyle = '#3498db';
        // 绘制填充矩形 (x坐标, y坐标, 宽度, 高度)
        ctx.fillRect(50, 50, 200, 150);
        
        // 设置边框颜色
        ctx.strokeStyle = '#e74c3c';
        // 设置边框宽度
        ctx.lineWidth = 5;
        // 绘制描边矩形
        ctx.strokeRect(300, 50, 200, 150);
        
        // 清除矩形区域 (x坐标, y坐标, 宽度, 高度)
        ctx.clearRect(100, 100, 100, 50);
    </script>
</body>
</html>
2. 绘制线条和路径
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>绘制线条和路径</title>
    <style>
        canvas {
            border: 1px solid #ccc;
        }
    </style>
</head>
<body>
    <canvas id="myCanvas" width="800" height="400"></canvas>
    
    <script>
        const canvas = document.getElementById('myCanvas');
        const ctx = canvas.getContext('2d');
        
        // 绘制一条直线
        ctx.beginPath();
        ctx.moveTo(50, 50); // 移动到起点
        ctx.lineTo(200, 150); // 绘制到终点
        ctx.strokeStyle = '#e74c3c';
        ctx.lineWidth = 3;
        ctx.stroke();
        
        // 绘制三角形
        ctx.beginPath();
        ctx.moveTo(300, 50);
        ctx.lineTo(250, 150);
        ctx.lineTo(350, 150);
        ctx.closePath(); // 闭合路径,连接最后一个点与第一个点
        ctx.fillStyle = '#3498db';
        ctx.fill();
        
        // 绘制复杂路径
        ctx.beginPath();
        ctx.moveTo(450, 100);
        ctx.lineTo(500, 50);
        ctx.lineTo(550, 100);
        ctx.lineTo(600, 80);
        ctx.lineTo(580, 150);
        ctx.lineTo(520, 170);
        ctx.lineTo(470, 140);
        ctx.closePath();
        ctx.strokeStyle = '#2ecc71';
        ctx.lineWidth = 2;
        ctx.stroke();
        ctx.fillStyle = 'rgba(46, 204, 113, 0.3)'; // 使用半透明颜色
        ctx.fill();
    </script>
</body>
</html>

总结:

注意事项(新手必看)

  1. 先拿“画笔”,再画画:所有绘制操作前,必须先获取 getContext('2d') 这个绘图上下文,它就是你的一切。
  2. 定位要清楚:画布的左上角是坐标原点 (0, 0),X轴向右,Y轴向下,这和数学里的坐标系不一样。
  3. 画路径前要“重新开始” :每次画新路径前,一定要调用 beginPath()。如果不这样做,之前画的路径样式会影响到新的路径。
  4. 提前设置样式:在调用 fill() 或 stroke() 之前,就要把颜色、线宽等样式设置好。顺序是:设置样式 -> 绘制图形。
  5. 尺寸是属性,不是样式:画布的大小要用 HTML 的 width 和 height 属性来设置,用 CSS 设置可能会被拉伸变形。

CSS3选项卡:纯CSS实现优雅的内容切换

在网页设计中,选项卡(Tabs)是一种常见且高效的界面模式,它能在有限空间内组织大量内容。传统实现依赖JavaScript,但现在通过CSS3的强大选择器和伪类,我们可以用纯CSS创建流畅的选项卡效果!本文将带你探索这种简洁而强大的实现方式。

css3选项卡

CSS3选项卡是指仅使用HTML和CSS(特别是CSS3特性)实现的内容切换界面。用户点击不同标签时,对应的内容区域会显示,其他内容隐藏。

核心技术

1. :checked 伪类

input:checked ~ .content {
    display: block;
}

2. 通用兄弟选择器 (~)

input:checked ~ .tab-content {
    opacity: 1;
}

3. 过渡动画 (transition)

.tab-content {
    transition: opacity 0.3s ease;
}
代码示例:带图标和动画的选项卡
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS3动画选项卡</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: linear-gradient(135deg, #667eea, #764ba2);
            font-family: 'Arial', sans-serif;
            padding: 20px;
        }
        
        .tabs-wrapper {
            width: 100%;
            max-width: 700px;
            background: white;
            border-radius: 15px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
            overflow: hidden;
        }
        
        .tab-input {
            display: none;
        }
        
        .tab-labels {
            display: flex;
            background: #f8f9fa;
            border-bottom: 2px solid #e9ecef;
        }
        
        .tab-label {
            flex: 1;
            padding: 20px;
            text-align: center;
            cursor: pointer;
            transition: all 0.3s ease;
            color: #6c757d;
            font-weight: 500;
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }
        
        .tab-label:hover {
            color: #495057;
            background: #e9ecef;
        }
        
        .tab-label i {
            font-size: 18px;
        }
        
        /* 激活状态指示器 */
        .tab-label::after {
            content: '';
            position: absolute;
            bottom: -2px;
            left: 50%;
            width: 0;
            height: 3px;
            background: #3498db;
            transition: all 0.3s ease;
            transform: translateX(-50%);
        }
        
        .tab-input:checked + .tab-label {
            color: #3498db;
            background: white;
        }
        
        .tab-input:checked + .tab-label::after {
            width: 80%;
        }
        
        .tab-content {
            display: none;
            padding: 40px;
            animation: slideUp 0.4s ease;
        }
        
        #tab1:checked ~ #content1,
        #tab2:checked ~ #content2,
        #tab3:checked ~ #content3,
        #tab4:checked ~ #content4 {
            display: block;
        }
        
        @keyframes slideUp {
            from {
                opacity: 0;
                transform: translateY(20px);
            }
            to {
                opacity: 1;
                transform: translateY(0);
            }
        }
        
        .content-header {
            display: flex;
            align-items: center;
            margin-bottom: 20px;
            gap: 15px;
        }
        
        .content-icon {
            width: 50px;
            height: 50px;
            background: #3498db;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-size: 20px;
        }
        
        h2 {
            color: #2c3e50;
            margin: 0;
        }
        
        p {
            color: #7f8c8d;
            line-height: 1.7;
            margin-bottom: 15px;
        }
        
        .feature-list {
            list-style: none;
            margin-top: 20px;
        }
        
        .feature-list li {
            padding: 8px 0;
            color: #5a6c7d;
            position: relative;
            padding-left: 25px;
        }
        
        .feature-list li::before {
            content: '✓';
            position: absolute;
            left: 0;
            color: #27ae60;
            font-weight: bold;
        }
    </style>
    <!-- 使用Font Awesome图标 -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
    <div class="tabs-wrapper">
        <input type="radio" name="tabs" id="tab1" class="tab-input" checked>
        <label for="tab1" class="tab-label">
            <i class="fas fa-home"></i>
            首页
        </label>
        
        <input type="radio" name="tabs" id="tab2" class="tab-input">
        <label for="tab2" class="tab-label">
            <i class="fas fa-box"></i>
            产品
        </label>
        
        <input type="radio" name="tabs" id="tab3" class="tab-input">
        <label for="tab3" class="tab-label">
            <i class="fas fa-info-circle"></i>
            关于
        </label>
        
        <input type="radio" name="tabs" id="tab4" class="tab-input">
        <label for="tab4" class="tab-label">
            <i class="fas fa-envelope"></i>
            联系
        </label>
        
        <div id="content1" class="tab-content">
            <div class="content-header">
                <div class="content-icon">
                    <i class="fas fa-home"></i>
                </div>
                <h2>欢迎页面</h2>
            </div>
            <p>欢迎使用我们的CSS3选项卡组件。这个实现完全基于纯CSS,无需任何JavaScript代码。</p>
            <p>通过利用CSS3的:checked伪类和兄弟选择器,我们创建了流畅的内容切换效果。</p>
            <ul class="feature-list">
                <li>纯CSS实现,无JavaScript依赖</li>
                <li>流畅的切换动画效果</li>
                <li>响应式设计,适配各种屏幕</li>
                <li>易于自定义样式</li>
            </ul>
        </div>
        
        <div id="content2" class="tab-content">
            <div class="content-header">
                <div class="content-icon">
                    <i class="fas fa-box"></i>
                </div>
                <h2>产品特性</h2>
            </div>
            <p>我们的产品提供了多种强大功能,旨在提升用户体验和开发效率。</p>
            <ul class="feature-list">
                <li>高性能内容切换</li>
                <li>优雅的交互动画</li>
                <li>可访问性优化</li>
                <li>跨浏览器兼容</li>
                <li>移动端友好</li>
            </ul>
        </div>
        
        <div id="content3" class="tab-content">
            <div class="content-header">
                <div class="content-icon">
                    <i class="fas fa-info-circle"></i>
                </div>
                <h2>关于我们</h2>
            </div>
            <p>我们是一支专注于前端技术的团队,致力于创建优雅且高效的Web解决方案。</p>
            <p>通过利用现代CSS特性,我们能够实现以前需要JavaScript才能完成的功能。</p>
        </div>
        
        <div id="content4" class="tab-content">
            <div class="content-header">
                <div class="content-icon">
                    <i class="fas fa-envelope"></i>
                </div>
                <h2>联系我们</h2>
            </div>
            <p>如果您对我们的实现有任何疑问或建议,欢迎通过以下方式联系我们:</p>
            <ul class="feature-list">
                <li>邮箱: contact@example.com</li>
                <li>电话: +86 123 4567 8900</li>
                <li>地址: 北京市朝阳区某某街道123号</li>
            </ul>
        </div>
    </div>
</body>
</html>

总结:

CSS核心技术:

  1. :checked伪类 - 检测哪个选项卡被选中
  2. 通用兄弟选择器(~) - 选择被选中选项卡之后的所有兄弟元素
  3. 相邻兄弟选择器(+) - 精确选择紧邻的元素
  4. CSS动画 - 为内容切换添加过渡效果

!!注意:

  1. HTML结构顺序 - radio按钮必须位于内容区域之前,兄弟选择器才能正常工作
  2. 可访问性考虑 - 确保选项卡可以通过键盘导航,并为屏幕阅读器提供适当标签
  3. 内容高度处理 - 不同内容区域高度不一致时,考虑使用固定高度或动画优化

ES6 模块导出 export default 与 export 的区别?

在 JavaScript(包括 React 组件)中,export default function Profile() {} 和 export function Profile() {} 是两种不同的导出方式,核心区别在于导出类型导入方式,具体如下:

1. 导出类型不同

  • export default function Profile() {} :这是「默认导出(default export)」。一个模块(文件)中只能有一个默认导出,可以理解为 “模块的主要导出内容”。默认导出时,函数名(Profile)是可选的(可以写成匿名函数 export default function() {}),因为导入时可以自定义名称。
  • export function Profile() {} :这是「命名导出(named export)」。一个模块中可以有多个命名导出,每个导出都有明确的名称(这里是 Profile),必须通过名称来导入。

2. 导入方式不同

这是两者最直观的区别,导入时的语法完全不同:

(1)默认导出的导入方式:

可以自定义导入名称(无需与导出时的名称一致),且不需要用大括号 {} 包裹。

// 导出(默认导出)
export default function Profile() {}

// 导入(可自定义名称,比如 MyProfile)
import MyProfile from './Profile'; 

(2)命名导出的导入方式:

必须使用与导出时完全一致的名称,且必须用大括号 {} 包裹(称为 “解构导入”)。

// 导出(命名导出)
export function Profile() {}

// 导入(必须用 { Profile },名称必须一致)
import { Profile } from './Profile'; 

如果想自定义名称,需要用 as 关键字重命名:

import { Profile as MyProfile } from './Profile'; 

3. 其他区别

  • 多个导出的支持

    • 默认导出:一个文件只能有 1 个 默认导出。

    • 命名导出:一个文件可以有 多个 命名导出,例如:

      // 多个命名导出
      export function Profile() {}
      export function Settings() {}
      export const version = '1.0';
      

      导入时可以按需导入:

      import { Profile, Settings } from './components';
      
  • 混合使用:一个模块可以同时包含「一个默认导出」和「多个命名导出」,例如:

    // 默认导出(1个)
    export default function Profile() {}
    
    // 命名导出(多个)
    export function Settings() {}
    export const version = '1.0';
    

    导入时需分别处理:

    import Profile, { Settings, version } from './components';
    

总结表格

特性 export default function Profile() {}(默认导出) export function Profile() {}(命名导出)
模块中数量限制 只能有 1 个 可以有多个
导出时名称 可选(可匿名) 必须有明确名称
导入时语法 import 自定义名称 from '路径' import { 原名称 } from '路径'
导入时名称灵活性 可自定义名称 必须与导出名称一致(可通过 as 重命名)

实际使用场景

  • 默认导出:通常用于模块的 “主内容”,比如一个组件文件的核心组件(如 Profile.js 中默认导出 Profile 组件),导入时更简洁。
  • 命名导出:通常用于导出多个相关的工具函数、组件或常量(如一个 utils.js 文件导出多个工具函数),导入时可以按需选择,避免引入不必要的内容。

在 Excel 中要把数字补齐到固定长度

在 Excel 中要把数字补齐到固定长度(例如补齐到 8 位,在前面补 0),有三种最常用的方法,任选其一即可:


方法 1:使用 TEXT 函数(最推荐)

如果你原来的数字在 A1 单元格中:

=TEXT(A1,"00000000")

作用:

  • 自动将 8805 转成 "00008805"
  • 始终保持 8 位,不足位前补 0

方法 2:使用 RIGHT + REPT

如果原值是数字,也适用:

=RIGHT(REPT("0",8) & A1, 8)

解释:

  • REPT("0",8) → 生成 8 个 0
  • 拼上原数字,最后取右 8 位

方法 3:设置单元格自定义格式(只改变显示,不改变值)

选择单元格 → 右键 → 设置单元格格式 → 自定义
输入:

00000000

这样单元格显示为 8 位,但底层仍是数字(适合排序计算不受影响)。


📌 你需要的是“在前面补 4 个 0,使其变 8 位”

如果你只是说“固定 8 位”,用上面 TEXT 函数即可:

=TEXT(A1,"00000000")

如果你想严格“只补 4 个 0”,无论原数字多少位,则:

="0000"&A1

前端好搭档:table 和 position sticky

前言

前端开发中,总是会接到各式各样的交互需求。最近产品想在移动端中实现一个报表功能,在页面中以表格的形式展示各个统计数据。

这本来是个普普通通的需求,使用 html 的 table 标签就可以实现,但是要求在页面滑动过程中表头要固定在顶部,同时在表格滑出页面时,表头取消固定在顶部,大致如下:

image-20251011154234246

表头固定现在有 CSS position: sticky 可以直接实现,但是滑出屏幕后不再固定,这个交互第一直觉要用 JS 去实现了,因为 position: sticky 应该会让元素一直保持在顶部,就像下面这个“固定在顶部的内容”元素一样。

table_base1

代码示例:

当时的想法是先写个示例看看,用 position: sticky 加 JS 实现。

方案实现

页面的基础内容如下,一个页面标题加内容,内容区域使用 table 标签实现表格功能。

<h1>HTML表格基础示例</h1>
<div class="content">
  <p>这是第一个表格,包含10条数据。</p>
  <table>
    <colgroup>
      <col style="width: 200px;">
      <col style="width: 150px;">
      <col style="width: 300px;">
      <col style="width: 200px;">
    </colgroup>
    <thead>
      <tr>
        <th>姓名</th>
        <th>年龄</th>
        <th>职位</th>
        <th>部门</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>张三</td>
        <td>28</td>
        <td>软件工程师</td>
        <td>研发部</td>
      </tr>
      <!-- ...更多数据行 -->
    </tbody>
  </table>
  <!-- ...更多其他表格 -->
</div>

CSS 部分就是设置内容区域(类名为 content 的元素)可以滑动和表头固定,关键代码如下:

body {
    display: flex;
    flex-direction: column;
}
.content {
    flex: 1;
    overflow-y: auto;
}
table {
    border-collapse: collapse;
}
th {
    position: sticky;
    top: 0;
    z-index: 10;
}

代码中我们在表头设置了 position: sticky 属性,让其在滑动出屏幕时可以固定在顶部,让我们看下效果。

table_base2

示例代码:

可以看到我们直接实现了最终的效果,在表格离开视图后,表头也不再固定在顶部。平时在使用 position: sticky 时倒是没发现这点,查了 MDN 文档解释如下:

一个 sticky 元素会“固定”在离它最近的一个拥有“滚动机制”的祖先上(当该祖先的 overflowhiddenscrollautooverlay 时),即便这个祖先不是最近的真实可滚动祖先。

但是示例代码中只在内容区域(类名为 content 的元素)设置了 overflow 属性,按理说表头会一直固定在顶部才对,只能暂时理解 table 有特殊的渲染机制。

总结

实践结果来说,在 table 表头设置 position: sticky 会有一些比较特殊的行为,这个行为又比较符合平时认知的习惯(表格的表头顶部固定,表格滑出屏幕后不再固定表头)。

上面的 position: sticky 仅仅是初步的方案,如果你的表格宽度超过一屏幕,此时的左右滑动会是基于内容区域(类名为 content 的元素),你可能想通过在 table 外层加一个 div 来实现仅仅 table 区域左右滑动,但是外层 div 设置了 overflow 属性,表头就不会再基于内容区域(类名为 content 的元素)固定了,所以可能需要通过 JS 实现左右滑动。

在此基础上要是有首列固定的需求,也需要通过 JS 去实现。

左右滑动是基于内容区域效果如下:

table_base3

最后,文中若有不对的地方,欢迎讨论指正。

Flutter CI/CD 完整指南:从 Bitbucket Pipelines 到 Play Store 自动化部署

手动构建、签名和上传 Flutter 应用的过程既繁琐又容易出错。你得找到正确的签名密钥,记住密码,运行构建命令,然后小心翼翼地将生成的 .aab.apk 文件上传到 Google Play Console。这个过程不仅耗时,而且严重拖慢了版本更新的速度。

持续集成与持续部署(CI/CD) 就是解决之道。通过建立一条自动化流水线,我们可以确保每一次代码推送到仓库后,都能自动完成分析、测试、构建,甚至部署。

本指南将带领你使用 Bitbucket PipelinesFastlane,为你的 Flutter 应用搭建一条完整的 CI/CD 流水线。我们将从基础的测试和签名应用包构建开始,逐步实现完全自动部署到 Google Play 商店的内部测试轨道。


准备工作

  • 一个已在 Bitbucket 仓库中的 Flutter 项目。
  • 一个 Android 签名密钥.jks 文件)及其凭证(密钥库密码、密钥别名、密钥密码)。
  • 你的 Bitbucket 仓库的管理权限

第一部分:奠定基础 — 每次推送都进行构建和测试

首先,让我们创建一个能够在每次代码推送时,验证代码并构建已签名的 Android App Bundle 的流水线。

步骤 1:启用 Bitbucket Pipelines

这是最简单的一步:

  1. 在 Bitbucket 上导航到你的仓库
  2. 在左侧边栏,点击 仓库设置(Repository settings)
  3. 在“Pipelines”部分下,点击 设置(Settings)
  4. 切换开关以启用 Pipelines。Bitbucket 会提供一些模板,目前你可以先忽略。

步骤 2:创建 bitbucket-pipelines.yml 文件

在你的 Flutter 项目的根目录(与 pubspec.yaml 同级),创建一个名为 bitbucket-pipelines.yml 的新文件。这个文件是自动化流程的核心,包含流水线的所有指令。

步骤 3:定义 Docker 镜像和缓存

YAML 文件的第一部分是定义执行环境。我们将使用一个社区维护的Flutter Docker 镜像的特定版本(这里以 Flutter 3.32.7 为例)。同时,缓存也至关重要,它可以避免每次构建时都重新下载 Flutter SDK 和 pub 包,从而加快构建速度。

image: ghcr.io/cirruslabs/flutter:3.32.7

definitions:
  caches:
    # Cache the Flutter SDK between builds
    flutter: /flutter
    # Cache the pub packages
    pub: $HOME/.pub-cache

clone:
  depth: 1 # Perform a shallow clone for faster checkout

步骤 4:创建第一个流水线(分析与测试)🧪

现在,我们来配置一个简单的流水线,让它在推送到任意分支时自动运行。这条流水线将负责拉取依赖项、分析代码并运行测试。

请将以下内容添加到你的 bitbucket-pipelines.yml 文件中:

pipelines:
  default:
    - step:
        name: Analyze and Test
        caches:
          - pub
        script:
          # Get Flutter packages
          - flutter pub get
          # Run the linter/analyzer
          - flutter analyze
          # Run all widget tests (you can comment this out if you don't have tests)
          - flutter test

步骤 4:创建第一个流水线(分析与测试)🧪

现在,我们来配置一条简单流水线,让它在代码推送到任意分支时自动运行。这条流水线将负责获取依赖项、分析代码,并运行测试。

请将以下内容添加到你的 bitbucket-pipelines.yml 文件中:

pipelines:
  default:
    - step:
        name: Analyze and Test
        caches:
          - pub
        script:
          # Get Flutter packages
          - flutter pub get
          # Run the linter/analyzer
          - flutter analyze
          # Run all widget tests (you can comment this out if you don't have tests)
          - flutter test

配置详解 ⚙️

让我们来分解一下这段配置的含义:

  • pipelines > default: 定义了一个流水线,它会在每一次提交(commit) 被推送时运行。
  • step: 一个流水线由一个或多个步骤组成,每个步骤都在一个全新的 Docker 容器中运行。
  • name: 一个描述性的名称,它将显示在 Bitbucket 的用户界面(UI)中。
  • caches: 指定此步骤应该使用我们在前面定义过的 pub 缓存
  • script: 需要按顺序执行的 Shell 命令列表

提交并推送这个文件。你应该就能在仓库的 “Pipelines” (流水线)部分看到你的第一个流水线开始运行了。


步骤 5:处理 Android 构建所需的密钥信息 🔑

绝不应该将你的签名密钥或密码直接提交到仓库中。Bitbucket 提供了一种安全的方式来处理这些敏感信息,那就是**“仓库变量”(Repository variables)**。

5a. 编码你的 Keystore 文件和凭证

你的 .jks 文件是一个二进制文件。为了将其作为变量存储,我们必须使用 Base64 将其编码成文本格式。打开终端并运行相应的命令:

# On macOS/Linux  
base64 -i my-upload-key.jks -o key.txt  
  
# On Windows (using PowerShell)  
[Convert]::ToBase64String([IO.File]::ReadAllBytes("my-upload-key.jks")) | Out-File -FilePath "key.txt"

运行上述命令会生成一个名为 key.txt 的文件,其中包含一个很长的字符串。请复制整个字符串。

现在,我们用同样的方法来处理 key.properties 文件,以便编码访问 .jks 文件所需的凭证

# On macOS/Linux  
base64 -i key.properties -o properties.txt  
  
# On Windows (using PowerShell)  
[Convert]::ToBase64String([IO.File]::ReadAllBytes("key.properties")) | Out-File -FilePath "properties.txt"

5b. 将变量添加到 Bitbucket 🔐

前往 仓库设置(Repository settings) -> 流水线(Pipelines) -> 仓库变量(Repository variables)

添加以下变量。至关重要的一点是,请为每个变量都勾选“已保护”(Secured)框

| Variable Name             | Value                                              |
| ------------------------- | -------------------------------------------------- |
| `ANDROID_KEYSTORE_BASE64` | Paste the entire base64 string from `key.txt`.     |
| `ANDROID_KEYPROPERTIES_BASE64` | Paste the entire base64 string from `properties.txt`.                   |

步骤 6:构建已签名的 Android 应用包(AAB)✅

现在,让我们创建一个新的流水线,让它在每次推送到 development 分支时,自动构建一个已签名的 .aab 文件

请将以下代码块添加到你的 .yml 文件的 pipelines 键下:

# ... (image, definitions, and default pipeline from above) ...
pipelines:
  # ... (default pipeline here) ...
  branches:
    development:
      - step:
          name: Analyze and Build App Bundle
          size: 2x # Use a larger build container for performance
          caches:
            - pub
          script:
            - rm -f pubspec.lock
            - flutter pub get
            - flutter analyze
            - flutter test

            # Decode and write the keystore file from the repository variable
            - echo $ANDROID_KEYSTORE_BASE64 | base64 -d > android/app/keystore.jks

            # Create the key.properties file with the keystore credentials
            - echo $ANDROID_KEYPROPERTIES_BASE64 | base64 -d > android/key.properties

            # Build the app bundle
            - flutter build appbundle --obfuscate --split-debug-info=./debug_info -t lib/main_dev.dart --release
          artifacts:
            # Save the built app bundle so it can be downloaded
            - build/app/outputs/bundle/release/app-release.aab

关键新增内容解析 💡

  • branches > development: 这条流水线只会在代码推送到 development 分支时运行。
  • size: 2x: 构建 Flutter 应用是资源密集型的。设置 2x 会分配双倍内存,这能防止构建错误并加快速度。
  • echo … | base64 -d …: 这是至关重要的命令,它反转了我们之前的步骤。它取出安全的变量,将其从 Base64 解码,并重新写入一个二进制的 .jks.properties 文件
  • flutter build appbundle …: 我们使用标准的构建命令,带有混淆(obfuscation)和一个特定的入口点(-t lib/main_dev.dart)。
  • artifacts: 这告诉 Bitbucket 保存指定的文件(app-release.aab),你可以在流水线结果页面下载它。

第二部分:最后冲刺 — 使用 Fastlane 自动化部署到 Play Store 🚀

构建应用固然很好,但真正的魔力在于自动化部署。为此,我们将集成 Fastlane——一个专为自动化应用发布而设计的开源平台。

新工作流程概览

  1. 本地设置: 我们将先在本地设置 Fastlane,确保它可以正确地与 Google Play 商店通信。
  2. Google 凭证: 我们将创建一个 Google Cloud 服务账号,Fastlane 将使用它进行身份验证。
  3. 安全变量: 我们将对秘密的服务账号 JSON 密钥进行编码,并将其安全地存储在 Bitbucket 中。
  4. 流水线更新: 我们将修改 .yml 文件,使其安装并运行 Fastlane,由 Fastlane 处理构建和部署工作。

步骤 1:获取 Google Play API 凭证 🔑

Fastlane 需要一个服务账号才能代表你执行操作。

创建服务账号

  1. Google Cloud Console 中,导航到 IAM 与管理(IAM & Admin) -> 服务账号(Service Accounts)
  2. 点击 + 创建服务账号(+ CREATE SERVICE ACCOUNT) ,给它命名(例如:bitbucket-ci-cd-deploys),然后点击 创建并继续(CREATE AND CONTINUE)
  3. 在“授予此服务账号对项目的访问权限”下,添加角色 “服务账号用户”(Service Account User) 。点击 继续(CONTINUE) ,然后 完成(DONE)

创建并下载 JSON 密钥

  1. 找到你刚创建的服务账号。点击旁边的三个点菜单,选择 管理密钥(Manage keys)
  2. 点击 添加密钥(ADD KEY) -> 创建新密钥(Create new key) 。选择 JSON 并点击 创建(CREATE)
  3. 一个 JSON 文件(例如:your-service-account-key.json)将被下载。这个文件就是你的密码,务必保持安全,不要提交到仓库中。

在 Play Console 中授予权限

  1. 前往 Google Play Console用户与权限(Users & Permissions) 页面,点击 邀请新用户(Invite new users)
  2. 粘贴服务账号的电子邮件地址,并授予所需的权限(例如:管理员(Admin) 或特定的 发布经理(Release Manager) 角色)。点击 邀请用户(Invite user)

步骤 2:在本地安装和配置 Fastlane 🛠️

我们将在你的 Flutter 项目的 android 目录中设置 Fastlane。

安装 Fastlane

请遵循 Fastlane 官方设置指南。强烈建议使用 Bundler

初始化 Fastlane

  1. 在终端中进入你的项目 android 目录cd android
  2. 运行 fastlane init
  3. 它会要求输入你的应用的包名秘密 JSON 文件的路径(例如:./your-service-account-key.json)。

配置 fastlane/Appfile

确保其中的路径设置是正确的。

# fastlane/Appfile  
json_key_file("./your-service-account-key.json") # Path to your JSON key  
package_name("com.your.app.package")

4. 安装 flutter_version 插件 🛠️

为了能自动pubspec.yaml 文件中获取应用的版本名称(version name)版本代码(version code) ,请在 android 目录下运行此命令:

  bundle exec fastlane add_plugin flutter_version

5. 配置 fastlane/Fastfile 🛠️

这里就是你定义 “快车道”(lanes) 的地方。请用以下内容替换掉文件中的原有内容:


# fastlane/Fastfile
default_platform(:android)

platform :android do
  desc "Builds and deploys to the Google Play internal testing track"
  lane :deploy_internal do
    # Go up to the root to run Flutter commands
    Dir.chdir("..") do
      sh("flutter", "build", "appbundle", "--obfuscate", "--split-debug-info=./debug_info", "-t", "lib/main_dev.dart", "--release")
    end
    
    version_info = flutter_version()
    version_name = version_info["version_name"] # e.g., "1.2.3"
    build_number = version_info["version_code"] # e.g., "45"

    # Upload to the Play Store
    upload_to_play_store(
      track: 'internal', # Deploy to the internal testing track
      aab: '../build/app/outputs/bundle/release/app-release.aab',
      version_name: "#{version_name}(#{build_number})"
    )
  end
end

6. 在本地进行测试(强烈推荐)✅

在你的终端(仍需位于 android 目录中)运行命令:fastlane deploy_internal。如果这条命令成功运行,那么你就准备好进行自动化部署了。

更新 .gitignore 文件 🔒

确保你的根目录下的 .gitignore 文件包含了那个秘密的 JSON 密钥文件:

/android/your-service-account-key.json  
/android/gemfile.lock

步骤 4:更新 Bitbucket Pipelines 以进行部署 🚀

最后一步,让我们把 Fastlane 集成到 bitbucket-pipelines.yml 文件中。

将 JSON 密钥添加为安全变量 🔒

像处理 Keystore 文件一样,你需要对 JSON 密钥文件进行编码:

 base64 -i your-service-account-key.json -o play-key.txt

将字符串从 play-key.txt 文件中复制出来。

前往 仓库设置(Repository settings) -> 仓库变量(Repository variables) ,添加一个新的受保护变量,命名为 GPLAY_SERVICE_ACCOUNT_KEY_BASE64,并将复制的字符串粘贴为它的值。

修改 bitbucket-pipelines.yml 文件 ⚙️

用这个新版本的流水线替换掉你原有的 development 分支流水线。它会简化很多,因为 Fastlane 替我们处理了大部分逻辑。

branches:
  development:
    - step:
        name: Build and Deploy to Internal Testing
        size: 2x
        caches:
          - pub
        script:
          # Basic setup and analysis
          - rm -f pubspec.lock
          - flutter pub get
          - flutter analyze
          
          # Restore Android Keystore for the build
          - echo $ANDROID_KEYSTORE_BASE64 | base64 -d > android/app/keystore.jks
          
          # Create the key.properties file with the keystore credentials
          - echo $ANDROID_KEYPROPERTIES_BASE64 | base64 -d > android/key.properties

          # Restore Google Play API key for Fastlane
          - echo $GPLAY_SERVICE_ACCOUNT_KEY_BASE64 | base64 -d > android/your-service-account-key.json

          # --- Deployment via Fastlane ---
          - cd android
          - bundle install
          - bundle exec fastlane deploy_internal
        artifacts:
          # Save the built artifact just in case
          - build/app/outputs/bundle/release/app-release.aab

结论 🎉

恭喜! 你现在拥有了一条强大且完全自动化的 CI/CD 流水线。

今后,每一次推送到你的 development 分支的代码,都将无需任何手动干预,自动完成分析、构建、签名,并部署到 Google Play 商店的内部测试人员手中。

这套设置不仅为你节省了无数时间,还确保了发布流程的一致性、可靠性和安全性,让你能够专注于你最擅长的事情:构建出色的应用!

Flutter3.38 带来了什么

Flutter 3.38 最近发布了,带来了很多新功能和改进。这次发布包含了来自 145 位 独立贡献者的 825 次 总提交,其中有 37 位 是首次贡献者。让我们深入了解一下这次发布的内容。

Dot shorthands

在 Dart 3.10 + Flutter 3.38 中开始默认支持 Dot shorthands ,通过 Dot shorthands 可以使可以通过允许您省略 Dart 能够推断出来的类型,从而减少样板代码,例如使用 .start 而不是 MainAxisAlignment.start :

// With shorthands
Column(
  mainAxisAlignment: .start,
  crossAxisAlignment: .center,
  children: [ /* ... */ ],
),

// Without shorthands
Column(
  mainAxisAlignment: MainAxisAlignment.start,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: [ /* … */ ],
),

这对于命名构造函数也同样适用,如.all而不是EdgeInsets.all

Padding(
  padding: .all(8.0),
  child: Text('Hello world'),
),

💻 Web 开发配置与功能增强


Web 开发配置文件

flutter run 命令现在支持一个用于 Web 设置的配置文件。您可以在项目根目录下的 web_dev_config.yaml 文件中指定 主机 (host)、端口 (port)、证书 (certificate)请求头信息 (header)。将此文件提交到版本控制,确保团队中的所有成员都使用相同的设置进行调试。

server:
  host: "0.0.0.0" # Defines the binding address <string>
  port: 8080 # Specifies the port <int> for the development server
  https:
    cert-path: "/path/to/cert.pem" # Path <string> to your TLS certificate
    cert-key-path: "/path/to/key.pem" # Path <string> to TLS certificate key

Web 开发代理设置

除了现有的命令行标志外,Web 开发配置文件现在还支持新的代理设置 (proxy settings)

  • 代理设置允许将特定路径的请求转发到另一个服务器。这使得开发连接到同一主机上动态端点的 Web 客户端更加容易。
server:
  proxy:
    - target: "http://localhost:5000/" # Base URL <string> of your backend
      prefix: "/users/" # Path <string>
    - target: "http://localhost:3000/"
      prefix: "/data/"
      replace: "/report/" # Replacement <string> of path in redirected URL (optional)
    - target: "http://localhost:4000/"
      prefix: "/products/"
      replace: ""

Web 上的热重载扩展支持

  • 当使用 -d web-server 运行并在浏览器中打开 Flutter 应用链接时,有状态热重载 (Stateful hot reload) 现在默认启用。这甚至可以同时连接多个浏览器。
  • -d chrome 一样,此功能可以使用 --no-web-experimental-hot-reload 标志临时禁用。但此禁用功能将在未来版本中移除,因此,如果您在开发工作流程中遇到问题,请使用 Dart 的 web 热重载问题模板 提交 Bug。
  • 更多信息,请参阅Web 上的热重载文档

🏗️ Framework更新

本次发布包含了Framework中的许多强大的新功能和改进,使开发者能够对高级 UI、导航和平台交互有更精细的控制。

强大的 UI 浮层控制

开发者在使用 OverlayPortal 创建弹出窗口、对话框和其他浮动 UI 元素时,现在拥有更大的能力。

  • 通过 OverlayPortal.overlayChildLayoutBuilder (#174239),现在可以在 Widget 树中任何向上的 Overlay 中渲染子 Widget,这使得显示应用范围的通知或需要跳出其父 Widget 布局限制的 UI 变得更容易。这可以更灵活地显示弹出、对话框、通知等 UI ,例如:
class _OverlayPortalExampleState extends State<OverlayPortalExample> {
  final OverlayPortalController _controller = OverlayPortalController();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('OverlayPortal')),
      body: Center(
        child: OverlayPortal.overlayChildLayoutBuilder(
          controller: _controller,
          /// ****可以配置 root****
          overlayLocation: OverlayChildLocation.rootOverlay,
          child: ElevatedButton(
            onPressed: () => _controller.toggle(),
            child: const Text('点我显示浮层'),
          ),
          overlayChildBuilder: (context, info) {
            return Material(
              elevation: 6,
              color: Colors.white,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(8),
              ),
              child: Container(
                padding: const EdgeInsets.all(16),
                child: const Text('这是一个浮层'),
              ),
            );
          },
        ),
      ),
    );
  }
}
  • 底层方法 Overlay.of 也得到了增强,使其更加健壮和高效 (#174315)。

现代化 Android 导航体验

  • 为了提供更现代的 Android 导航体验,预测性返回手势路由过渡 (predictive back route transitions) 现在在 MaterialApp默认启用 (#173860)。当用户执行返回手势时,他们会看到主屏幕的预览,当前路由则随之动画移出。
  • 此外,默认的页面过渡效果已从 ZoomPageTransitionsBuilder 更新为 FadeForwardsPageTransitionsBuilder,以反映原生的行为。

桌面集成深化

  • Windows 上,开发者现在可以访问已连接显示器的列表,并查询每个显示器的详细属性,例如分辨率、刷新率和物理尺寸 (#164460)。这使得创建具有复杂窗口管理功能的应用程序成为可能。

框架稳定性与 Web UI 优化

  • 框架本身现在更具弹性。Widget 生命周期回调(例如 didUpdateWidget)中发生的错误现在得到更优雅的处理,防止它们在元素树中引发连锁故障 (#173148)。
  • ResizeImage 现在正确地实现了相等性 (equality),确保相同的 ResizeImage 提供者被视为相同,从而使图像缓存和比较更具可预测性 (#172643)。
  • 在 Web 上,UI 优化仍在继续:RSuperellipse 的一个 Bug 已修复,防止角半径大于 Widget 本身时出现渲染错误;在这种情况下,它将按预期被处理以生成药丸形状 (pill shape) (#172254)。

国际化支持增强

  • 检测浏览器首选区域设置 (locale) 的功能现在更可靠。引擎现在使用标准的 Intl.Locale Web API 来解析浏览器语言,取代了先前手动且更脆弱的实现 (#172964)。这一变化为全球用户带来了更可靠的区域设置检测和更好的体验。

Android 特定 Bug 修复

  • 一个主要影响带有硬件键盘的 Samsung 设备的 Android 特定 Bug (#171973) 已解决。
    • 此前,用户与 TextField 交互后,Android 输入法编辑器 (IME) 可能会陷入陈旧状态。这导致 IME 错误地拦截 “Enter” 或 “Space” 键,阻止非文本 Widget(如 CheckboxRadio 按钮)接收事件。
    • 修复确保在文本连接关闭时,InputMethodManager 正确重置,清除 IME 的陈旧状态,并恢复用户可预测的硬件键盘交互。

🎨 Material 和 Cupertino 更新

Material 和 Cupertino 库持续发展,侧重于 API 一致性和优化的用户体验。本次发布带来了重大的 API 迁移、新的 Widget 功能和众多改进,使构建美观、实用的 UI 更加简单。

WidgetState 迁移

  • 在弃用 MaterialState 的基础上,本次发布继续向更统一的 WidgetState 进行内部迁移。
  • 这提供了一种一致、富有表现力的方式来定义 Widget 在不同交互状态(例如:按下、悬停或禁用)下的外观,对现有应用程序无需更改
  • 此迁移已应用于各种 Widget 及其主题,包括 IconButtonElevatedButtonCheckboxSwitch (#173893)。
  • 新的 API 还增加了功能和灵活性;例如,IconButton 现在包含一个 statesController 属性 (#169821),允许通过编程方式控制其视觉状态,为更自定义和交互式的设计打开了大门。

新功能和便捷 API

  • Badge.count 构造函数现在包含一个 maxCount 参数 (#171054),可以轻松限制显示计数(例如,显示 “99+” 而不是 “100”)。

image.png

👆 手势控制与平台优化


Material 和 Cupertino 组件改进

  • 为了更精细的手势控制InkWell Widget 现在新增了一个 onLongPressUp 回调 (#173221),这对于触发仅在用户抬起手指时才完成的动作非常有用。
  • Cupertino 库继续致力于提高 iOS 的保真度:
    • CupertinoSlidingSegmentedControl 增加了一个 isMomentary 属性 (#164262),允许控件触发动作而无需持久保持选择状态。
    • 为了更好地匹配原生 iOS 行为,CupertinoSheet 在完全展开时向上拖动时,现在具有一个微妙的 “拉伸”效果 (subtle “stretch” effect) (#168547)。
  • 核心组件的行为也得到了完善:
    • 修复了 DropdownMenuFormField,使其在表单重置时能正确清空其文本字段 (#174937)。
    • SegmentedButton 进行了更新,以改进焦点处理 (#173953) 并确保其边框正确反映 Widget 的状态 (#172754)。

🔗 Material 与 Cupertino 解耦(Decoupling)规划

我们一直在规划将 Material 和 Cupertino 库从框架中解耦。以下是关于最近发布的设计文档的一些讨论:

主题 状态 详情
改进 flutter/packages 的发布流程 (包含解耦后的 Material 和 Cupertino) 已决定 First-Party Package Release Strategy
Batch Release One Pager (PUBLICLY SHARED)
颜色和点速记 (Colors and dot shorthands) 已决定 A Basic Color Set for Flutter (PUBLICLY SHARED)
解耦测试 (Decoupling tests) 进行中 Decoupling Framework Tests (PUBLICLY SHARED)
Issue #177028
文本 (Text) 讨论中 Flutter Decoupling Design From Text (PUBLICLY SHARED)

📜 滚动 (Scrolling): 更健壮和可预测的 Slivers

本次发布带来了多项修复,使得构建复杂的滚动布局(尤其是使用 SliverMainAxisGroupSliverCrossAxisGroup 的布局)更加健壮和可预测

  • 开发者在使用这些 Widget 对多个 Slivers 进行分组时,会发现手势处理现在更可靠。对这些组内 Slivers 上的点击和其他指针事件的命中测试现在计算正确,确保用户交互按预期进行 (#174265)。
  • 其他几项修复有助于 SliverMainAxisGroup 内更精确的滚动行为:
    • 解决了使用固定头部 (pinned header) 时的过度滚动问题 (#173349)。
    • 调用 showOnScreen 以显示一个 Sliver 现在可以正常工作 (#171339)。
    • 内部滚动偏移量的计算更精确 (#174369)。
  • 对于构建自定义滚动视图的开发者,新的 SliverGrid.list 构造函数 (#173925) 提供了一种从简单子 Widget 列表创建网格的更清晰方法。
  • 本次发布还改进了复杂布局中键盘和 D-pad 用户的焦点导航。在具有不同滚动轴的嵌套滚动视图中(例如垂直列表中的水平轮播图),定向焦点导航现在更可预测,防止焦点意外地在不同部分之间跳转 (#172875)。

♿ Accessibility: 为所有用户提供更包容的体验

使应用程序对所有用户都可访问是 Flutter 框架的基石。本次发布通过提供更多的编程控制改善国际用户的体验以及优化核心 Widget 的辅助功能,继续履行这一承诺。

  • 对于构建复杂应用的开发者,本次发布引入了通过使用 WidgetsFlutterBinding.instance.ensureSemantics在 iOS 上默认开启辅助功能的能力 (#174163)。
  • 调试辅助功能问题现在更容易了,因为 debugDumpSemanticsTree 包含了额外的文本输入验证结果信息,有助于更快地诊断问题 (#174677)。
  • 对于基于 Sliver 的滚动视图中的高级辅助功能,现在可以使用新的 SliverSemantics Widget (#167300)。
    • 就像现有的 Semantics Widget 一样,开发者可以在 CustomScrollView 中使用 SliverSemantics 来用特定的语义信息注解 (annotate) Sliver 树的一部分。
    • 这对于为屏幕阅读器注解头部、分配语义角色和添加描述性标签特别有用,为用户提供更易理解和可访问的体验。
  • 核心 Widget 的辅助功能也在继续完善:
    • CupertinoExpansionTile 现在默认可访问 (#174480)。
    • AutoComplete Widget 现在会向用户宣布搜索结果的状态 (#173480)。
    • 其他改进,例如 TimePicker 中更大的触摸目标 (#170060),有助于提供更易于访问的开箱即用体验。

🍏 iOS 平台支持与迁移

平台支持

我们很高兴地确认 Flutter 完全支持最新的平台版本iOS 26、Xcode 26 和 macOS 26(均于 9 月发布)。这确保您可以立即在 Apple 最新的操作系统和工具链上开始开发和测试您的应用。

部署流程改进

您可能已经注意到,Flutter 在上一个版本中为 iOS 开发者带来了重大的生活质量改进 (quality-of-life improvement),解决了长期困扰用户的一个问题:使用 flutter run 在真机上运行 Flutter 应用时,要求 Xcode 应用程序自动启动

  • 我们引入了一种新的部署方法,使用 Xcode 26 命令行工具 devicectl 进行应用程序安装、启动和调试。
  • 这一转变消除了在部署过程中调用 Xcode 应用程序的需要,在大多数情况下仅依赖于命令行 Xcode 构建工具。
  • 此前,此功能依赖于 Xcode 自动化,但在 Xcode 26 上变得不稳定和不可靠,尤其是在连续执行命令时。如果您现在正在为最新的 Apple 版本进行开发,我们强烈建议您将 Flutter 更新到 3.38 或更高版本
  • 如果您遇到问题,可以使用 flutter config --no-enable-lldb-debugging 禁用此部署方法,并提交问题让我们知道!

UIScene 生命周期迁移

Flutter 3.38 包含了对 Apple 强制要求的 UIScene 生命周期的关键支持。

  • 这是继 Apple 在 WWDC25 上宣布**“在 iOS 26 之后的版本中,任何使用最新 SDK 构建的 UIKit 应用都将被要求使用 UIScene 生命周期,否则将无法启动”**之后,一项关键的、前瞻性的更新
  • 为了确保您的 iOS Flutter 应用程序在未来的 iOS 版本上保持兼容并成功启动,需要进行迁移

📱 迁移 Flutter 应用

所有现有的 iOS Flutter 应用都必须迁移到新的生命周期。您有两种途径完成此迁移:

  1. 手动迁移: 遵循 Flutter 网站上提供的手动迁移说明
  2. 自动迁移(实验性): 启用一个实验性功能来自动处理迁移。此功能将在未来版本中默认启用。运行以下命令: flutter config --enable-ios-scene-lifecycle-migration

迁移 Flutter 插件

依赖于应用生命周期事件的 Flutter 插件必须更新以使用 UIScene 生命周期事件。插件开发者应参考迁移指南。尚未迁移的插件将在未来版本中显示警告。

迁移嵌入式 Flutter (可选)

对于将 Flutter 嵌入到原生宿主应用程序的项目,迁移是可选但强烈推荐的。采用 Flutter 新的 UIScene API(使用添加到应用迁移指南)可为您的插件启用场景生命周期事件,确保与 Flutter 生态系统的兼容性。


🤖 Android 平台更新


16KB 页面大小兼容性

升级到 Flutter 3.38 是为 Google Play 16 KB 页面大小兼容性要求做准备的关键步骤。

  • 2025 年 11 月 1 日起,面向 Android 15 及更高版本的应用必须支持 16 KB 页面。
  • 这一变化确保您的应用在高内存设备上正确运行,并提供高达 30% 更快的启动速度等性能优势。
  • Flutter 3.38 将默认的 Android ndkVersion 更新为 NDK r28,这是原生代码实现 16 KB 支持所需正确对齐的最低要求

内存泄漏修复

Flutter 3.38 修复了一个影响所有 Android 上的 Flutter 应用的重要内存泄漏问题。此问题(在 3.29.0 中引入)发生在 Activities 因开发者设置或系统低内存而被销毁时

Android 依赖项更新

确定适用于您的应用的 Android 依赖项(包括 Gradle、Android Gradle Plugin (AGP)、Kotlin Gradle Plugin (KGP)、Java 等)的正确版本组合通常是一个挑战。对于 Flutter 3.38 版本,我们在持续集成 (CI) 环境中测试并确认了与以下 Android 依赖项的兼容性:

  • Java 17: Flutter 3.38 中进行 Android 开发所需的最低版本
  • KGP 2.2.20: 工具链已知和支持的最大 Kotlin Gradle Plugin 版本。
  • AGP 8.11.1: 与 KGP 2.2.20 兼容的最新 Android Gradle Plugin 版本。
  • Gradle 8.14: 此版本适用于所选的 Java、KGP 和 AGP 版本。请注意,Gradle 8.13 是 AGP 8.11.1 所需的最低版本

为确保您的应用在 Flutter 版本之间无缝过渡,我们强烈建议您在构建文件中使用 Flutter SDK 提供的 API 级别变量。此版本的配置值为:

  • flutter.compileSdkVersion (API 36)
  • flutter.targetSdkVersion (API 36)
  • flutter.minSdkVersion (API 24) 或更高版本

⚙️ 引擎(Engine)更新


性能叠加层 (Performance overlay)

性能叠加层已重构以提高效率,减少了其在 Skia 和 Impeller 后端上的渲染时间 (#176364)。这意味着您可以获得更准确的性能数据,且开销更小。

Vulkan 和 OpenGL ES

VulkanOpenGL ES 后端进行了大量修复和改进,提高了在更广泛设备上的稳定性和性能。这包括更好地处理管线缓存 (#176322)、围栏等待器 (#173085) 和图像布局转换 (#173884)。

渲染器统一

继续努力统一 CanvasKitSkwasm 渲染器。本次发布包括大量的重构,以在这两者之间共享更多代码,这将有助于未来带来更一致的体验和更快的开发速度 (#174588)。

线程合并

注意⚠️注意⚠️注意⚠️ 从 iOS 和 Android 中移除了退出线程合并的能力**(即不再支持可选退出)。


🛠️ DevTools 和 IDEs


实验性 Widget 预览 - 更新

Flutter 3.35 引入了 Widget 预览,这是一项准备好接受社区早期反馈的实验性功能。Flutter 3.38 版本带来了对 Widget 预览的重大改进,包括:

  • IDE 集成: VSCodeIntelliJ / Android Studio 插件都已更新,初步支持 Widget 预览。您现在可以直接在 IDE 内查看预览,以获得更无缝的开发体验。

image.png 当在 IDE 中使用时,Widget 预览环境默认配置为根据当前选定的源文件来过滤显示的预览:

image.png

  • Widget 预览环境主题和控制改进: Widget 预览环境现在支持浅色和深色模式,以及自定义 IDE 配色方案,以匹配您的开发环境。Widget 预览环境中的控件也已调整为占用更少的空间,为渲染预览留出更多可用空间。 image.png

  • 预览可扩展性: Preview 标注类不再被标记为 final,现在可以被扩展以创建自定义 Preview 标注,从而减少常见预览类型的样板代码。

image.png

  • 多重预览支持 (MultiPreview support): 一个新的 MultiPreview 基类允许您从单个自定义标注中创建多个预览变体

image.png

  • 预览分组(Preview groups): Preview 类中新增了一个 group 参数,允许将相关的预览进行分组

image.png

  • 减少 @Preview 标注参数限制:

    • 私有常量现在被支持作为 @Preview 标注的参数。
    • 函数参数(如 wrappertheme)仍然要求具有公共的、静态可访问的名称。

🚧 Widget 预览(实验性)- 未来计划与反馈

Widget 预览仍然是一个实验性功能,您的反馈对于塑造其未来至关重要。API 和用户体验尚未稳定,将根据我们从您那里获得的学习进行更改。

基于早期的反馈,计划进行更多增强,以改进 Widget 预览体验,包括:

  • Flutter DevTools Widget Inspector 支持: Widget Inspector 正在更新,以支持在 Widget 预览环境中检查预览。我们计划将 Inspector 直接嵌入到 Widget 预览器中,使其无论在何种开发环境中都易于访问。
  • IDE 中的多项目支持: Widget 预览器目前仅支持显示包含在单个项目或 Pub 工作区中的预览。我们正在积极研究支持具有多个 Flutter 项目的 IDE 会话的选项(问题 [#173550])。
  • 启动性能改进: 正在调查性能改进的机会,以减少初始启动时间,包括:
    • 在首次运行后启动预编译的 Widget 预览环境
    • 并行化预览检测逻辑,以更好地处理大型项目。

开始使用:

重要提示: 存在一个已知问题,即在执行 flutter pub get 后,Widget 预览器可能会崩溃或停止更新。如果您遇到此问题,请在您的项目中运行 flutter pub get重启您的 IDE。详见 #178317


💻 DevTools 更新

Flutter 3.38 包含了对用户在 2025 年 DevTools 用户调查中提出的一些主要痛点的修复,包括:

  • 网络面板 (Network Panel) 改进:
    • 使人更容易理解面板何时正在记录网络流量。(#9495)
    • 修复了复制粘贴网络请求的问题。(#9472, #9482, #9485, #8588)
  • Flutter Inspector 修复:
    • 修复了一个错误,即选择一个 Widget 有时会打开底层的框架源代码而不是用户的源代码。(#176530)
    • 修复了一个偶尔阻止与 Inspector 面板顶部按钮交互的错误。(#9327)

🚫 弃用和破坏性更改

本次发布包括几项重要的弃用破坏性更改,作为持续现代化和改进 Flutter 框架努力的一部分。

  • 构建和工具链关键更改: 可能会影响自定义构建脚本。
    • Flutter SDK 根目录下的 version 文件已被移除,取而代之的是位于 bin/cache 中的新文件 flutter.version.json (#172793)。
    • 此外,AssetManifest.json 文件不再默认生成 (#172594)。
  • 其他值得注意的更改包括:
    • 为了更可预测的行为,包含 Action(操作)的 SnackBar 将不再自动关闭 (#173084)。
    • OverlayPortal.targetsRootOverlay 构造函数已弃用,取而代之的是更灵活的 OverlayPortal(overlayLocation: OverlayChildLocation.rootOverlay)
    • CupertinoDynamicColor 上的几个属性(例如 withAlphawithOpacity)现已弃用,推荐使用标准的 Color 方法 (#171160)。
    • Flutter 3.38 要求 Java 17 作为 Android 的最低版本,与 Gradle 8.14(2025 年 7 月发布)的最低要求相匹配。

有关这些和其他更改的更多详细信息和迁移指南,请查阅破坏性更改页面

结束语

Flutter 3.38 专注于让您的日常开发更快、更愉快。这些增强功能旨在简化您的构建方式。我们非常感谢为本次发布做出贡献的每一位社区成员的辛勤工作和反馈。

要获取所有更改的完整列表,请务必查看详细的破坏性更改和发布说明。要免费提升您的生产力,只需运行 flutter upgrade

您已经使用上了flutter 3.38了呢还是打算继续等等?反正我先用为敬。

❌