普通视图

发现新文章,点击刷新页面。
今天 — 2025年3月30日首页

🚀🚀🚀Zod 深度解析:TypeScript 运行时类型安全的终极实践指南

2025年3月30日 22:20

前言

在现代 TypeScript 开发中,我们经常面临一个关键挑战:编译时类型安全 ≠ 运行时数据安全

即使你的代码通过了 TypeScript 类型检查,来自 API 响应、用户输入或配置文件的数据仍可能在运行时导致意外错误。

Zod 应运而生,它填补了 TypeScript 类型系统与运行时验证之间的关键空白。

往期精彩推荐

核心概念解析

类型安全的三层架构

  1. 静态类型TypeScript 编译时检查
  2. 运行时验证Zod 的数据校验
  3. 类型生成z.infer 自动推导

Schema 即真理来源(Single Source of Truth)

Zod 的核心理念是:

// 定义一次,多处使用
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email()
});

type User = z.infer<typeof UserSchema>; // 自动生成类型
function saveUser(user: User) { ... }  // 复用类型

完整的示例

我们以提交表单数据并发送数据请求为例:

// RegisterForm.tsx
import { useRequest } from "ahooks";
import { Button, Form, Input, message } from "antd";
import React from "react";
import { z } from "zod";

// 定义表单数据的 zod schema
const registerSchema = z.object({
  username: z
    .string()
    .min(3, "用户名至少3个字符")
    .max(20, "用户名最多20个字符"),
  email: z.string().email("请输入有效的邮箱地址"),
  password: z.string().min(6, "密码至少6个字符"),
}); 

// 转换为 TypeScript 类型
type RegisterFormData = z.infer<typeof registerSchema>;

// 模拟 API 请求
const mockRegisterApi = (
  data: RegisterFormData
): Promise<{ success: boolean }> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ success: true });
    }, 1000);
  });
};

const RegisterForm: React.FC = () => {
  const [form] = Form.useForm();
  // 使用 useRequest 处理数据请求
  const { loading, run } = useRequest(mockRegisterApi, {
    manual: true, // 手动触发
    onSuccess: (result) => {
      if (result.success) {
        message.success("注册成功");
        form.resetFields();
      }
    },
    onError: (error) => {
      message.error("注册失败: " + error.message);
    },
  });
  // 表单提交处理
  const onFinish = async (values: RegisterFormData) => {
    try {
      // 使用 zod 验证表单数据
      const validatedData = registerSchema.parse(values);
      // 触发 API 请求
      await run(validatedData);
    } catch (error) {
      if (error instanceof z.ZodError) {
        // 处理 zod 验证错误
        const errors = error.errors.reduce((acc, curr) => {
          acc[curr.path[0]] = { validateStatus: "error", help: curr.message };
          return acc;
        }, {} as any);
        form.setFields(
          Object.entries(errors).map(([name, value]) => ({ name, ...value }))
        );
      }
    }
  };

  return (
    <div style={{ maxWidth: 400, margin: "0 auto", padding: "20px" }}>
      <h2>用户注册</h2>
      <Form
        form={form}
        name="register"
        onFinish={onFinish}
        layout="vertical"
        initialValues={{ username: "", email: "", password: "" }}
      >
        <Form.Item
          name="username"
          label="用户名"
          rules={[{ required: true, message: "请输入用户名" }]}
        >
          <Input placeholder="请输入用户名" />
        </Form.Item>
        <Form.Item
          name="email"
          label="邮箱"
          rules={[{ required: true, message: "请输入邮箱" }]}
        >
          <Input placeholder="请输入邮箱" />
        </Form.Item>
        <Form.Item
          name="password"
          label="密码"
          rules={[{ required: true, message: "请输入密码" }]}
        >
          <Input.Password placeholder="请输入密码" />
        </Form.Item>
        <Form.Item>
          <Button type="primary" htmlType="submit" loading={loading} block>
            注册
          </Button>
        </Form.Item>
      </Form>
    </div>
  );
};

export default RegisterForm;

这个示例展示了如何将 zod 的类型安全验证与 antd 表单和 useRequest 这类的数据请求结合起来,创建一个更加安全的表单提交场景。

最后

Zod 正在成为 TypeScript 生态中数据验证的事实标准,其设计哲学完美契合现代 TypeScript 应用的开发需求。

往期精彩推荐

JavaScript yield与异步编程

作者 烛阴
2025年3月30日 22:08

什么是 yield

yield 关键字只能在生成器函数中使用。生成器函数是一种特殊的函数,使用 function* 声明。当生成器函数执行到 yield 表达式时,它会暂停执行,并将 yield 后面的值返回给调用者。重要的是,函数的状态会被保存,以便稍后可以从暂停的地方继续执行。

生成器函数的基本语法:

function* myGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = myGenerator();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // { value: 3, done: false }
console.log(generator.next()); // { value: undefined, done: true }
  • function* myGenerator(): 声明一个生成器函数。
  • yield 1;: 暂停函数执行,并返回 1
  • generator.next(): 调用 next() 方法恢复函数的执行,并返回一个包含 valuedone 属性的对象。value 属性是 yield 表达式的值,done 属性表示生成器函数是否已经执行完毕。

双向通信
yield不仅能向外传递值,还能通过next(value)接收外部输入:


function* calculator() {  
  const a = yield 'Enter a:'; 
  console.log(a)
  const b = yield 'Enter b:'; 
  console.log(b)
  return a + b;  
}  
const cal = calculator();  
cal.next();    // {value: 'Enter a:', done: false}  
cal.next(2);   // {value: 'Enter b:', done: false}  
cal.next(3);   // {value: 5, done: true}  

这种特性使得生成器可以动态响应外部逻辑

yield 的强大之处:

  1. 控制迭代过程: yield 允许你自定义迭代过程,按需生成值。这对于处理大型数据集或无限序列非常有用,可以避免一次性加载所有数据到内存中。

    function* infiniteSequence() {
      let i = 0;
      while (true) {
        yield i++;
      }
    }
    
    const sequence = infiniteSequence();
    
    console.log(sequence.next().value); // 0
    console.log(sequence.next().value); // 1
    console.log(sequence.next().value); // 2
    // ... 无限循环
    
  2. 实现状态机: yield 可以用于实现状态机,控制程序在不同状态之间的转换。

    function* stateMachine() {
      let state = 'start';
    
      while (true) {
        switch (state) {
          case 'start':
            console.log('Starting...');
            state = yield 'waiting';
            break;
          case 'waiting':
            console.log('Waiting for input...');
            state = yield 'processing';
            break;
          case 'processing':
            console.log('Processing data...');
            state = yield 'done';
            break;
          case 'done':
            console.log('Done!');
            return;
          default:
            console.log('Invalid state!');
            return;
        }
      }
    }
    
    const machine = stateMachine();
    console.log(machine.next()); // Starting... { value: 'waiting', done: false }
    console.log(machine.next('processing')); // Waiting for input... { value: 'processing', done: false }
    console.log(machine.next('done')); // Processing data... { value: 'done', done: false }
    console.log(machine.next()); // Done! { value: undefined, done: true }
    

从 Webpack 源码来深入学习 Tree Shaking 实现原理 🤗🤗🤗

作者 Moment
2025年3月30日 21:33

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777

20250310220634

Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,通过静态分析模块间的导入导出关系,精确识别并移除未被引用的代码,从而显著减小最终打包体积并提升应用性能。这一技术最初由 Rich Harris 在 Rollup 中创新性地实现,随后 Webpack 在 2.0 版本开始支持,如今已成为现代前端工程中不可或缺的优化手段。Tree-Shaking 的成功依赖于 ES Module 的静态结构特性,使构建工具能够在编译阶段确定代码的使用情况,这也是它区别于传统 CommonJS 模块优化的关键所在。

在 Webpack 中启动 Tree Shaking

在 Webpack 中启用 Tree Shaking 需要满足以下条件:

  1. 使用 ES Module 语法(import/export),而非 CommonJS(require)

  2. 将 Webpack 模式设置为 production:

    module.exports = {
      mode: "production",
    };
    
  3. 在 optimization 选项中启用 usedExports:

    module.exports = {
      mode: "production",
      optimization: {
        usedExports: true,
      },
    };
    
  4. 在 package.json 中添加 "sideEffects" 属性,标记哪些文件有副作用:

    {
      "name": "moment",
      "sideEffects": false
    }
    

    或指定有副作用的文件:

    {
      "name": "moment",
      "sideEffects": ["*.css", "*.scss"]
    }
    
  5. 确保你的 Babel 配置不会将 ES Modules 转换为 CommonJS 模块

编写代码时,应该避免导入整个库,而是只导入需要的部分:

// 不推荐(无法 tree shake)
import _ from "lodash";

// 推荐(可以 tree shake)
import { debounce } from "lodash";

为什么 Commonjs 不能实现 tree shaking

CommonJS 无法实现 Tree Shaking 的深层原因在于其动态特性与运行时行为:

CommonJS 模块系统允许高度动态的导入模式。开发者可以在条件语句中使用 require(),支持变量路径导入,甚至允许在任何作用域中导入模块。例如:

if (process.env.NODE_ENV === "development") {
  require("./debug-tools");
}

const moduleId = getModuleId();
const module = require(`./modules/${moduleId}`);

同时,CommonJS 采用值拷贝的导出方式,导出的是完整模块对象。即使只使用一个属性,也必须导入整个模块对象,无法在编译时确定使用了哪些具体导出项。依赖解析发生在执行阶段,模块关系图只有在代码实际运行时才能完全确定。静态分析工具无法可靠地预测所有可能的模块加载路径,动态 require 调用的结果依赖于运行时环境和条件。

相比之下,ES Modules 的设计从根本上解决了这些问题:

ES Modules 严格限制导入导出语句的位置和形式,所有 import/export 必须位于模块顶层。下面的代码在 ESM 中是非法的:

if (condition) {
  import { foo } from "./module"; // 语法错误!
  export const bar = "bar"; // 语法错误!
}

这种限制使得模块依赖图在编译时完全确定。ES Modules 支持精确的导入导出关系,可以明确指定需要的导出项,构建工具能够创建精确的依赖关系图,确定哪些导出项实际被使用。

此外,ES Modules 的编译时可分析性也很关键。模块说明符必须是字符串字面量,编译器可以在不执行代码的情况下构建完整的模块依赖图,准确识别哪些导出项从未被任何模块引用。

因此,ES Modules 的这些特性为 Tree Shaking 提供了必要的静态分析基础,使构建工具能够准确识别并移除未使用的代码,从而大幅减小最终打包体积。

实现原理

在 Webpack 中,Tree-shaking 通过"标记-清除"两阶段实现未使用代码的消除。整个过程精确且高效:

首先,在编译阶段 Webpack 会构建完整的模块关系图并标记未使用的导出:

  1. 构建阶段(Make):分析模块代码,提取所有导出变量并记录到模块依赖图(ModuleGraph)结构中,建立模块间的依赖关系网络

  2. 封装阶段(Seal):遍历模块依赖图,确定每个模块的导出变量是否被其他模块引用,对未被引用的导出变量做标记

  3. 生成阶段:输出最终代码时,根据标记状态生成有效的导出语句,已被标记为"未使用"的导出在产物代码中会以注释形式存在

随后,在优化阶段,Terser 等压缩工具会识别这些标记并物理移除未使用的代码,最终生成精简的产物文件。

这种设计将"分析依赖"与"清除死代码"解耦,使 Webpack 能在不同环境中灵活应用 Tree-shaking 技术。

首先,标记功能需要配置 optimization.usedExports = true 开启,如下代码所示:

const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
  },

  optimization: {
    usedExports: true,
  },
};

然后我们编写如下代码:

20250325221218

我们再 moment.js 文件导出了一个 moment 变量,并且在入口文件中使用了。

接下来我们将执行打包的操作,最终输出的结果如下所示:

/*
 * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
/******/ (() => {
  // webpackBootstrap
  /******/ "use strict";
  /******/ var __webpack_modules__ = {
    /***/ "./src/index.js":
      /*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
      /***/ (
        __unused_webpack_module,
        __webpack_exports__,
        __webpack_require__
      ) => {
        eval(
          '/* unused harmony exports bar, foo */\n/* harmony import */ var _moment__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moment */ "./src/moment.js");\nconst bar = "bar";\nconst foo = "foo";\n\n/* unused harmony default export */ var __WEBPACK_DEFAULT_EXPORT__ = ("foo-bar");\n\n\n\nconsole.log(_moment__WEBPACK_IMPORTED_MODULE_0__.moment);\n\n\n//# sourceURL=webpack://debug-example/./src/index.js?'
        );

        /***/
      },

    /***/ "./src/moment.js":
      /*!***********************!*\
  !*** ./src/moment.js ***!
  \***********************/
      /***/ (
        __unused_webpack_module,
        __webpack_exports__,
        __webpack_require__
      ) => {
        eval(
          '/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   moment: () => (/* binding */ moment)\n/* harmony export */ });\nconst moment = "moment";\n\n\n//# sourceURL=webpack://debug-example/./src/moment.js?'
        );

        /***/
      },

    /******/
  };
  /************************************************************************/
  /******/ // The module cache
  /******/ var __webpack_module_cache__ = {};
  /******/
  /******/ // The require function
  /******/ function __webpack_require__(moduleId) {
    /******/ // Check if module is in cache
    /******/ var cachedModule = __webpack_module_cache__[moduleId];
    /******/ if (cachedModule !== undefined) {
      /******/ return cachedModule.exports;
      /******/
    }
    /******/ // Create a new module (and put it into the cache)
    /******/ var module = (__webpack_module_cache__[moduleId] = {
      /******/ // no module.id needed
      /******/ // no module.loaded needed
      /******/ exports: {},
      /******/
    });
    /******/
    /******/ // Execute the module function
    /******/ __webpack_modules__[moduleId](
      module,
      module.exports,
      __webpack_require__
    );
    /******/
    /******/ // Return the exports of the module
    /******/ return module.exports;
    /******/
  }
  /******/
  /************************************************************************/
  /******/ /* webpack/runtime/define property getters */
  /******/ (() => {
    /******/ // define getter functions for harmony exports
    /******/ __webpack_require__.d = (exports, definition) => {
      /******/ for (var key in definition) {
        /******/ if (
          __webpack_require__.o(definition, key) &&
          !__webpack_require__.o(exports, key)
        ) {
          /******/ Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key],
          });
          /******/
        }
        /******/
      }
      /******/
    };
    /******/
  })();
  /******/
  /******/ /* webpack/runtime/hasOwnProperty shorthand */
  /******/ (() => {
    /******/ __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop);
    /******/
  })();
  /******/
  /************************************************************************/
  /******/
  /******/ // startup
  /******/ // Load entry module and return exports
  /******/ // This entry module can't be inlined because the eval devtool is used.
  /******/ var __webpack_exports__ = __webpack_require__("./src/index.js");
  /******/
  /******/
})();

这段代码是 webpack 开发模式下的打包输出,清晰展示了 Tree Shaking 的"标记"阶段实现:

webpack 通过静态分析精确识别出模块中的未使用导出,并用特殊注释标记它们。在这个例子中:

  • index.js 文件中的三个导出被标记为未使用:

    • 变量barfoo通过/* unused harmony exports bar, foo */标记
    • 默认导出"foo-bar"通过/* unused harmony default export */标记
  • 而 moment.js 模块中的moment导出被正确识别为已使用,因它在 index.js 中被引用: console.log(_moment__WEBPACK_IMPORTED_MODULE_0__.moment)

这种标记机制让 webpack 在不影响代码功能的情况下,为后续的压缩阶段提供明确的"删除指南"——在开发模式下保留全部代码以便调试,而在生产模式下由 Terser 等工具移除这些已标记的未使用代码,从而减小最终包体积。

整个过程依赖于 webpack 精心设计的模块系统,其中两个关键组件发挥着核心作用:

__webpack_module_cache__作为模块缓存仓库,存储已执行模块的结果,确保每个模块只被执行一次,无论它被引用多少次。这不仅提高了运行效率,也保证了模块状态的一致性,尤其对含有副作用的模块至关重要。

__webpack_require__函数则模拟了模块加载器,它智能管理模块的加载、执行与缓存流程:首先检查模块是否已缓存,若已缓存则直接返回;否则创建新模块实例、执行模块代码并缓存结果。这个精密的加载机制使 webpack 能够在运行时准确还原静态分析所建立的模块依赖关系。

通过这套机制,webpack 不仅实现了代码模块化,更让 Tree Shaking 成为可能——它可以精确追踪哪些导出真正被使用,哪些仅仅是"死代码"。这个过程完美展示了 Tree Shaking 的工作原理:先标记,后清除,最终生成高效精简的代码包。

1. 收集模块导出

首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程就是将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:

  1. 具名导出转换为 HarmonyExportSpecifierDependency 对象

  2. default 导出转换为 HarmonyExportExpressionDependency 对象

如下代码所示:

20250325221218

对应的 dependencies 值为:

20250325224918

所有模块都编译完毕之后,触发 compilation.hooks.finishModules 钩子,开始执行 FlagDependencyExportsPlugin 插件回调。

FlagDependencyExportsPlugin 插件执行流程详解

FlagDependencyExportsPlugin 是 Webpack 中负责标记模块导出信息的核心插件,它在 Tree Shaking 的第一阶段(标记阶段)发挥关键作用。下面我会详细解释这个插件的执行流程,特别是关于缓存内容和导出规范的部分。

1. 插件注册和初始化
apply(compiler) {
  compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
    // 插件逻辑
  });
}

插件通过 compiler.hooks.compilation.tap 注册到编译器的 compilation 钩子上,当 Webpack 创建新的 compilation 对象时会触发这个钩子。

2. 主要执行阶段

插件主要在 finishModules 钩子阶段执行,这个阶段是所有模块构建完成后、开始优化之前:

compilation.hooks.finishModules.tapAsync(PLUGIN_NAME, (modules, callback) => {
  // 主要逻辑
});
3. 初始化阶段
const logger = compilation.getLogger(PLUGIN_LOGGER_NAME);
let statRestoredFromMemCache = 0;
// 其他统计变量...
const { moduleMemCaches } = compilation;
const queue = new Queue();

在初始化阶段,插件首先设置日志记录器并创建统计变量以跟踪缓存恢复情况,然后获取编译过程的内存缓存(moduleMemCaches),最后建立处理队列用于存放待分析的模块,为后续的导出信息收集和标记做好准备。

4. 尝试从缓存恢复导出信息
缓存中存储了什么?

缓存中存储的是模块的导出信息,具体包括:

  1. 模块的导出变量列表:模块中导出的所有变量名称

  2. 每个导出变量的提供状态(provided):表示该导出是否确实被提供

    • true - 确定提供

    • false - 确定不提供

    • null - 不确定

  3. 导出变量的混淆可能性(canMangle):表示该导出名称是否可以在压缩时被混淆

  4. 导出变量的使用状态(used):表示该导出是否被其他模块使用

  5. 导出变量的重定向信息:如果导出是重定向到其他模块的导出,则包含目标信息

// 恢复缓存的示例代码
asyncLib.each(
  modules,
  (module, callback) => {
    const exportsInfo = moduleGraph.getExportsInfo(module);

    // 处理没有声明导出类型的模块
    if (
      (!module.buildMeta || !module.buildMeta.exportsType) &&
      exportsInfo.otherExportsInfo.provided !== null
    ) {
      // 对于没有明确声明导出类型的模块(如CommonJS模块),
      // 假定它可能导出任何内容
      statNoExports++;
      exportsInfo.setHasProvideInfo();
      exportsInfo.setUnknownExportsProvided();
      return callback();
    }

    // 处理不可缓存的模块(没有hash值的模块)
    if (typeof module.buildInfo.hash !== "string") {
      statFlaggedUncached++;
      queue.enqueue(module);
      exportsInfo.setHasProvideInfo();
      return callback();
    }

    // 首先尝试从内存缓存恢复(更快)
    const memCache = moduleMemCaches && moduleMemCaches.get(module);
    const memCacheValue = memCache && memCache.get(this);
    if (memCacheValue !== undefined) {
      statRestoredFromMemCache++;
      // 从缓存恢复导出信息
      exportsInfo.restoreProvided(memCacheValue);
      return callback();
    }

    // 其次尝试从持久化缓存恢复
    cache.get(module.identifier(), module.buildInfo.hash, (err, result) => {
      if (err) return callback(err);

      if (result !== undefined) {
        statRestoredFromCache++;
        // 从持久化缓存恢复导出信息
        exportsInfo.restoreProvided(result);
      } else {
        // 没有缓存,需要重新分析
        statNotCached++;
        queue.enqueue(module);
        exportsInfo.setHasProvideInfo();
      }
      callback();
    });
  }
  // 继续下一阶段...
);

这段代码是 Webpack 中 Tree Shaking 功能的基础部分,它通过 FlagDependencyExportsPlugin 插件来分析和收集每个模块的导出信息。具体来说,它会遍历项目中的所有模块,对每个模块进行分析,确定该模块导出了哪些变量(如函数、类、常量等),以及这些导出是通过什么方式实现的(如直接导出、重命名导出、星号导出等)。

在处理每个模块时,代码采用了一个三层的处理策略:首先,对于那些使用 CommonJS(如 module.exports)这样的模块,由于无法静态分析其导出内容,会直接将其标记为"可能导出任何内容";其次,对于之前处理过的模块,会尝试从内存缓存或磁盘缓存中恢复其导出信息,这样可以显著提升构建性能;最后,对于那些没有缓存信息的模块,则将其放入队列中,等待后续进行完整的代码分析。

这些收集到的导出信息会被存储在 Webpack 的模块图(ModuleGraph)中,成为后续 Tree Shaking 过程的重要依据。通过这些信息,Webpack 可以准确地知道每个模块提供了哪些导出,这些导出是否被其他模块使用,从而在最终打包时可以安全地移除那些未被使用的代码。

ModuleGraph 是 Webpack 内部的核心数据结构,负责追踪和管理所有模块间的依赖关系与导出信息。它通过 exportsInfo 系统精确记录每个模块"导出了什么"以及"这些导出如何被使用",为 Tree Shaking 优化提供关键决策依据。作为 Webpack 构建流程的中枢神经系统,ModuleGraph 使编译器能够理解代码间的内在联系,从而实现智能代码生成和高效打包优化。

如下图所示:

20250326101318

这种设计不仅确保了 Tree Shaking 的准确性,还通过多级缓存机制显著提升了构建性能,特别是在开发模式下的反复构建场景中。同时,它还能安全地处理不同类型的模块系统(ES Modules 和 CommonJS),保证了打包结果的正确性。

5. 分析导出信息 - 何为导出规范(ExportsSpec)?

**导出规范(ExportsSpec)**是描述模块如何导出变量的对象,它包含以下关键信息:

  1. exports:可以是以下三种情况之一

    • true - 表示模块导出所有内容(如 export * from './other'

    • 数组 - 包含具体导出变量的名称和规范

    • false/undefined - 不导出任何内容

  2. canMangle:表示导出名称是否可以被混淆(重命名为短名称)

    • 对于库来说,通常设为 false,因为外部代码可能依赖这些名称

    • 对于应用内部模块,通常设为 true,允许压缩工具重命名以减小体积

  3. from:如果导出是重定向,指向源模块

  4. priority:优先级,在多个模块提供相同导出名称时使用

  5. terminalBinding:表示这是最终绑定,不应该被其他导出覆盖

  6. hideExports:需要隐藏的导出列表

  7. excludeExports:要排除的导出列表(当 exports 为 true 时使用)

// 导出规范的例子
{
  exports: [
    "default",
    { name: "helper", canMangle: true },
    { name: "utils", exports: ["format", "parse"] }
  ],
  canMangle: true,
  priority: 0
}

// 或者未知的所有导出
{
  exports: true,
  excludeExports: ["private"],
  canMangle: false
}
canMangle 详解

canMangle(可混淆)字面意思是指"是否可以改变/混淆该变量的名称":

  1. 为什么需要混淆名称

    • 在代码压缩过程中,长变量名会被重命名为短变量名(如 longVariableNamea

    • 这样可以显著减小最终打包文件的大小

    • 对于模块内部使用的导出,混淆是安全的

  2. 何时不能混淆名称

    • 当导出可能被外部代码通过动态方式访问时(如使用字符串形式的属性名)

    • 当导出是公共 API 的一部分时(如发布为库)

    • 当导出使用了 Object.defineProperty 等特殊方式定义时

可以混淆的情况:

// module.js
export const calculateTotal = (a, b) => a + b;

// 导入时使用静态导入
import { calculateTotal } from "./module";
calculateTotal(1, 2);

这种情况下,calculateTotalcanMangletrue,因为导入使用了静态命名导入,Webpack 可以安全地将 calculateTotal 重命名为更短的名称(如 a):

// 压缩后可能变成这样
const a = (t, n) => t + n;
a(1, 2);

不能混淆的情况:

// library.js
export const VERSION = "1.0.0";
export const helper = { format: () => {} };

// 用户可能这样使用
import * as lib from "./library";
console.log(lib["VERSION"]); // 使用字符串访问

这种情况下,VERSIONcanMangle 应为 false,因为它可能被动态访问,如果重命名会破坏外部代码的正常工作。

canMangle 在 Tree Shaking 中的作用:

  1. Webpack 中的 canMangle 标记用于控制代码压缩过程中的变量名重命名,被标记为 canMangle: true 的导出可以被压缩工具更激进地重命名,这不仅能减小代码体积,还能带来更多优化机会。

  2. 对于 canMangle: false 的导出,压缩工具会保留其原始名称,这主要是为了保证那些通过动态方式引用的代码能够正常工作。

  3. Webpack 通过导出声明分析来确定 canMangle 状态,默认情况下,普通的导出都会被标记为 canMangle: true

  4. 不同类型的模块有不同的 canMangle 处理策略:ES 模块的导出通常可以被混淆,而 CommonJS 模块由于其动态特性,会更保守地设置为 canMangle: false

  5. 在库模式下(output.library),Webpack 会将暴露的导出标记为 canMangle: false,因为这些导出可能会被外部代码以各种方式引用。

  6. 对于包含副作用的模块,其导出通常会被标记为 canMangle: false,这是为了避免重命名导致的潜在问题。

  7. 这种标记机制是 Tree Shaking 优化的重要组成部分,通过精确控制哪些变量名可以被重命名,在保证代码正确性的同时实现最大程度的代码压缩。

canMangle 状态是 Webpack 中决定导出变量名是否可以被压缩工具重命名的标志。它是 Tree Shaking 优化的重要组成部分,通过允许安全的名称重命名,帮助减小最终打包文件的大小。

当 Webpack 分析模块时,会为每个导出确定适当的 canMangle 状态,并将这些信息传递给后续的压缩工具,以实现安全且高效的代码优化。

6. 处理依赖块和依赖 - 收集导出规范
const processDependenciesBlock = (depBlock) => {
  for (const dep of depBlock.dependencies) {
    processDependency(dep);
  }
  for (const block of depBlock.blocks) {
    processDependenciesBlock(block);
  }
};

const processDependency = (dep) => {
  // 从每个依赖中获取导出规范
  const exportDesc = dep.getExports(moduleGraph);
  if (!exportDesc) return;
  exportsSpecsFromDependencies.set(dep, exportDesc);
};

这两个函数的目的是递归地遍历模块的所有依赖,并收集每个依赖提供的导出规范:

  1. **依赖块(DependenciesBlock)**是包含依赖的容器,可以是:

    • 模块本身

    • 代码分割点(如动态 import())

    • 条件加载(如 if 语句中的 require())

  2. **依赖(Dependency)**表示模块间的依赖关系,例如:

    • ImportDependency:对应 import { foo } from './bar'

    • CommonJsRequireDependency:对应 require('./foo')

    • HarmonyExportSpecifierDependency:对应 export const foo = 5

20250326094247

  1. dep.getExports(moduleGraph):每种依赖类型都有自己的逻辑来提供导出规范

    • 对于导入语句,它描述导入了哪些变量

    • 对于导出语句,它描述导出了哪些变量

如下图所示:

20250326093837

7. 处理导出规范 - 核心逻辑详解
const processExportsSpec = (dep, exportDesc) => {
  // 获取导出描述信息
  const exports = exportDesc.exports;
  const globalCanMangle = exportDesc.canMangle;
  const globalFrom = exportDesc.from;
  const globalPriority = exportDesc.priority;
  const globalTerminalBinding = exportDesc.terminalBinding || false;
  const exportDeps = exportDesc.dependencies;

  // 处理隐藏的导出
  if (exportDesc.hideExports) {
    for (const name of exportDesc.hideExports) {
      const exportInfo = exportsInfo.getExportInfo(name);
      exportInfo.unsetTarget(dep);
    }
  }

  // 处理未知导出情况 - 如 export * from './module'
  if (exports === true) {
    if (
      exportsInfo.setUnknownExportsProvided(
        globalCanMangle,
        exportDesc.excludeExports,
        globalFrom && dep,
        globalFrom,
        globalPriority
      )
    ) {
      changed = true;
    }
  }
  // 处理具名导出列表
  else if (Array.isArray(exports)) {
    const mergeExports = (exportsInfo, exports) => {
      for (const exportNameOrSpec of exports) {
        // 解析导出规范详情
        let name;
        let canMangle = globalCanMangle;
        let terminalBinding = globalTerminalBinding;
        let exports;
        let from = globalFrom;
        let fromExport;
        let priority = globalPriority;
        let hidden = false;

        // 处理字符串形式的导出名称
        if (typeof exportNameOrSpec === "string") {
          name = exportNameOrSpec;
        }
        // 处理对象形式的导出规范
        else {
          name = exportNameOrSpec.name;
          if (exportNameOrSpec.canMangle !== undefined)
            canMangle = exportNameOrSpec.canMangle;
          if (exportNameOrSpec.export !== undefined)
            fromExport = exportNameOrSpec.export;
          if (exportNameOrSpec.exports !== undefined)
            exports = exportNameOrSpec.exports;
          if (exportNameOrSpec.from !== undefined) from = exportNameOrSpec.from;
          if (exportNameOrSpec.priority !== undefined)
            priority = exportNameOrSpec.priority;
          if (exportNameOrSpec.terminalBinding !== undefined)
            terminalBinding = exportNameOrSpec.terminalBinding;
          if (exportNameOrSpec.hidden !== undefined)
            hidden = exportNameOrSpec.hidden;
        }

        // 获取或创建导出信息对象
        const exportInfo = exportsInfo.getExportInfo(name);

        // 更新导出的提供状态
        if (exportInfo.provided === false || exportInfo.provided === null) {
          exportInfo.provided = true;
          changed = true;
        }

        // 更新是否可以被混淆
        if (exportInfo.canMangleProvide !== false && canMangle === false) {
          exportInfo.canMangleProvide = false;
          changed = true;
        }

        // 更新终端绑定状态
        if (terminalBinding && !exportInfo.terminalBinding) {
          exportInfo.terminalBinding = true;
          changed = true;
        }

        // 处理嵌套导出(如对象中的属性)
        if (exports) {
          const nestedExportsInfo = exportInfo.createNestedExportsInfo();
          // 递归处理嵌套导出
          mergeExports(nestedExportsInfo, exports);
        }

        // 设置导出目标(对于re-export情况)
        if (
          from &&
          (hidden
            ? exportInfo.unsetTarget(dep)
            : exportInfo.setTarget(
                dep,
                from,
                fromExport === undefined ? [name] : fromExport,
                priority
              ))
        ) {
          changed = true;
        }

        // 重新计算目标导出信息
        const target = exportInfo.getTarget(moduleGraph);
        let targetExportsInfo;
        if (target) {
          // 如果导出是重定向,获取目标导出信息
          const targetModuleExportsInfo = moduleGraph.getExportsInfo(
            target.module
          );
          targetExportsInfo = targetModuleExportsInfo.getNestedExportsInfo(
            target.export
          );
          // 添加模块依赖关系
          const set = dependencies.get(target.module);
          if (set === undefined) {
            dependencies.set(target.module, new Set([module]));
          } else {
            set.add(module);
          }
        }

        // 更新导出信息中的重定向
        if (exportInfo.exportsInfoOwned) {
          if (exportInfo.exportsInfo.setRedirectNamedTo(targetExportsInfo)) {
            changed = true;
          }
        } else if (exportInfo.exportsInfo !== targetExportsInfo) {
          exportInfo.exportsInfo = targetExportsInfo;
          changed = true;
        }
      }
    };
    // 开始处理导出列表
    mergeExports(exportsInfo, exports);
  }

  // 处理依赖关系
  if (exportDeps) {
    cacheable = false;
    for (const exportDependency of exportDeps) {
      // 添加模块依赖关系
      const set = dependencies.get(exportDependency);
      if (set === undefined) {
        dependencies.set(exportDependency, new Set([module]));
      } else {
        set.add(module);
      }
    }
  }
};

这个函数的核心作用是根据收集到的导出规范,更新模块的导出信息:

  1. 处理不同类型的导出

    • 未知导出exports === true):如 export * from './module'

    • 具名导出列表(数组):明确列出的导出变量

  2. 为每个导出设置状态

    • provided:标记导出是否被提供

    • canMangle:标记导出名称是否可以被混淆

    • terminalBinding:标记是否为终端绑定

  3. 处理导出重定向

    • 设置导出目标(对于 re-export 情况)

    • 建立导出信息与目标导出信息的连接

    • 维护模块间的依赖关系图

  4. 处理嵌套导出:对于如 export const utils = { format, parse } 这样的嵌套导出,递归处理其内部结构

8. 通知依赖 - 级联更新
const notifyDependencies = () => {
  const deps = dependencies.get(module);
  if (deps !== undefined) {
    for (const dep of deps) {
      queue.enqueue(dep);
    }
  }
};

当一个模块的导出信息发生变化时,所有依赖于它的模块都需要重新分析,这个函数实现了级联更新:

  1. 从依赖图中查找依赖于当前模块的所有模块

  2. 将这些模块加入处理队列,以便在后续迭代中重新分析它们

  3. 这确保了导出信息的变化能正确地传播到整个依赖图

9. 缓存导出信息 - 为下次构建做准备
asyncLib.each(
  modulesToStore,
  (module, callback) => {
    // 跳过不可缓存的模块
    if (typeof module.buildInfo.hash !== "string") {
      return callback();
    }

    // 获取要缓存的导出信息
    const cachedData = moduleGraph
      .getExportsInfo(module)
      .getRestoreProvidedData();

    // 更新内存缓存
    const memCache = moduleMemCaches && moduleMemCaches.get(module);
    if (memCache) {
      memCache.set(this, cachedData);
    }

    // 存储到持久化缓存
    cache.store(
      module.identifier(),
      module.buildInfo.hash,
      cachedData,
      callback
    );
  },
  (err) => {
    logger.timeEnd("store provided exports into cache");
    callback(err);
  }
);

这段代码实现了 Webpack 构建过程中的关键优化机制:将模块导出信息存储到缓存系统。通过这种方式,Webpack 能在后续构建中快速恢复模块的导出状态,避免重复分析,显著提升构建性能。

它有如下实现细节

  1. 缓存资格判断:首先检查模块是否具备缓存条件,通过验证 module.buildInfo.hash 是否为字符串。这个哈希值代表模块内容的指纹,只有带有有效哈希的模块才会进入缓存流程。

  2. 导出数据提取:对符合条件的模块,调用 moduleGraph.getExportsInfo(module).getRestoreProvidedData() 获取其导出信息。这些数据包含了模块所有导出的详细状态,是 Tree Shaking 优化的基础。

  3. 分层缓存策略

    • 内存缓存:通过 memCache.set(this, cachedData) 将数据存入内存,优化同一构建周期内的重复访问

    • 持久化缓存:通过 cache.store() 将数据持久保存,支持跨构建会话的信息复用

  4. 精确缓存标识:持久化缓存采用 module.identifier()module.buildInfo.hash 作为键,确保缓存的精确匹配和失效控制。只有模块内容完全相同时才能命中缓存,保证了优化的安全性。

  5. 非阻塞执行模式:整个缓存过程通过 asyncLib.each 异步执行,防止在处理大型项目时阻塞主线程,提高了构建的响应性和效率。

这个缓存机制是 Webpack 增量构建和 Tree Shaking 优化的关键支撑,通过智能复用分析结果,大幅降低了重复构建的计算开销。

10. 模块重建支持 - 热更新的基础
const providedExportsCache = new WeakMap();

// 当模块开始重建时,保存其当前的导出信息
compilation.hooks.rebuildModule.tap(PLUGIN_NAME, (module) => {
  providedExportsCache.set(
    module,
    moduleGraph.getExportsInfo(module).getRestoreProvidedData()
  );
});

// 重建完成后,恢复之前的导出信息
compilation.hooks.finishRebuildingModule.tap(PLUGIN_NAME, (module) => {
  moduleGraph
    .getExportsInfo(module)
    .restoreProvided(providedExportsCache.get(module));
});

这部分代码是为增量构建和热模块替换(HMR)设计的:

  1. 当模块需要重新构建时(如文件变化),先保存其当前的导出信息
  2. 模块重建完成后,恢复这些导出信息
  3. 这确保了即使模块重建,其导出信息也能保持一致,避免不必要的级联更新
导出规范的实际例子

为了更直观地理解导出规范,下面是几个具体的例子:

例子 1:具名导出

// module.js
export const foo = 123;
export function bar() {}

对应的导出规范:

{
  exports: [
    { name: "foo", canMangle: true },
    { name: "bar", canMangle: true }
  ],
  canMangle: true
}

例子 2:默认导出

// module.js
export default class MyClass {}

对应的导出规范:

{
  exports: [
    { name: "default", canMangle: true }
  ],
  canMangle: true
}

例子 3:重导出

// module.js
export { foo, bar as baz } from "./other-module";

对应的导出规范:

{
  exports: [
    { name: "foo", from: "./other-module", export: ["foo"] },
    { name: "baz", from: "./other-module", export: ["bar"] }
  ],
  canMangle: true
}

例子 4:星号导出

// module.js
export * from "./utils";

对应的导出规范:

{
  exports: true,
  from: "./utils",
  canMangle: true
}
小结

FlagDependencyExportsPlugin 的执行流程可以归纳为以下步骤:

  1. 缓存恢复阶段:插件首先尝试从内存和持久化缓存中恢复模块的导出状态信息,避免不必要的重复分析,提高构建速度。

  2. 导出信息收集:对于缓存未命中的模块,插件深入分析其依赖图谱和导出结构,收集包含变量名称、混淆选项、优先级和重定向路径等关键数据的导出规范。

  3. 状态处理与更新:基于收集到的导出规范,插件更新模块的导出信息表,标记每个导出的提供状态、混淆可能性和目标引用,构建精确的导出映射。

  4. 依赖传播机制:当某个模块的导出信息变更时,插件自动将依赖于该模块的所有模块加入处理队列,确保导出状态变化能在整个依赖网络中正确传递。

  5. 智能缓存更新:分析完成后,插件将最新的导出状态信息同时写入内存和持久化缓存系统,优化后续构建过程。

该插件通过精确标记和追踪模块导出信息,为 Webpack 的 Tree Shaking 机制提供了关键的决策依据。它构建了一个详尽的"导出-使用"关系图,使 Webpack 能够准确识别并保留仅被实际引用的代码,从而显著减小最终打包体积。

经过 FlagDependencyExportsPlugin 插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。

最终 FlagDependencyExportsPlugin 插件的执行流程如下流程图所示:

┌──────────────────────────────────────────────────────────────────────┐
│                        注册到编译系统                                 │
│                compiler.hooks.compilation.tap                        │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                       模块构建完成触发点                              │
│               compilation.hooks.finishModules.tapAsync               │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                          初始化阶段                                  │
├──────────────────────────────────────────────────────────────────────┤
│ ▸ 创建模块处理队列                                                   │
│ ▸ 设置统计变量                                                       │
│ ▸ 准备缓存访问                                                       │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                       导出信息缓存恢复                                │
├──────────────────────────────────────────────────────────────────────┤
│ ① 对每个模块执行缓存检查:                                             │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │无导出类型├───→│标记为提供未知导出      │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │不可缓存  ├───→│加入分析队列            │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │内存缓存  ├───→│恢复导出信息            │                         │
│    │命中     │    │跳过后续分析            │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │持久缓存  ├───→│恢复导出信息            │                         │
│    │命中     │    │跳过后续分析            │                         │
│    └─────────┘    └────────────────────────┘                         │
│                                                                      │
│    ┌─────────┐    ┌────────────────────────┐                         │
│    │缓存未命中├───→│加入分析队列            │                         │
│    └─────────┘    └────────────────────────┘                         │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                         模块分析循环                                 │
├──────────────────────────────────────────────────────────────────────┤
│ ① 从队列取出一个模块                                                 │
│                        ↓                                             │
│ ② 遍历模块依赖,收集导出规范 ──→ processDependenciesBlock            │
│                        ↓                                             │
│ ③ 处理每个依赖的导出规范    ──→ processExportsSpec                   │
│     • 处理隐藏导出                                                   │
│     • 处理未知导出 (exports === true)                                │
│     • 处理具名导出列表                                               │
│                        ↓                                             │
│ ④ 检测导出信息变化        ┌──────┐                                   │
│                        │ 有变化 ├──→ 通知依赖此模块的模块重新分析    │
│                        └──────┘                                      │
│                        ↓                                             │
│ ⑤ 标记可缓存模块                                                     │
│                        ↓                                             │
│ ⑥ 重复执行直到队列为空                                               │
└──────────────────────────────────┬───────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────────────┐
│                       导出信息缓存存储                                │
├──────────────────────────────────────────────────────────────────────┤
│ ① 遍历所有可缓存模块                                                 │
│                 ↓                                                    │
│ ② 提取模块导出信息 → getRestoreProvidedData()                        │
│                 ↓                                                    │
│ ③ 同时更新双层缓存:                                                  │
│    ┌────────────────┐    ┌────────────────┐                          │
│    │ 内存缓存       │    │ 持久化缓存     │                          │
│    │ memCache.set   │    │ cache.store    │                          │
│    └────────────────┘    └────────────────┘                          │
└──────────────────────────────────────────────────────────────────────┘

2. 标记模块导出

FlagDependencyUsagePlugin 是 webpack 中 Tree Shaking 机制的"使用端"标记器,它与 FlagDependencyExportsPlugin 形成完整的标记系统:前者负责标记"模块提供了什么",而本插件精确追踪"这些提供的内容被如何使用"。通过这种双向标记,webpack 能够精确识别未使用的代码并将其移除。

该插件接收一个 global 参数决定是执行全局分析还是按运行时环境分别分析,影响最终的代码分割和优化策略。

2.1 初始化与准备阶段

constructor(global) {
  this.global = global;
}

apply(compiler) {
  compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
    const moduleGraph = compilation.moduleGraph;
    compilation.hooks.optimizeDependencies.tap(
      { name: PLUGIN_NAME, stage: STAGE_DEFAULT },
      modules => {
        // 缓存检查
        if (compilation.moduleMemCaches) {
          throw new Error("optimization.usedExports can't be used with cacheUnaffected...");
        }

        const logger = compilation.getLogger(PLUGIN_LOGGER_NAME);
        const exportInfoToModuleMap = new Map();
        const queue = new TupleQueue();

插件在 optimizeDependencies 钩子的默认阶段执行,确保在所有模块解析完成但优化尚未开始时运行。初始化阶段建立了两个关键数据结构:

  • exportInfoToModuleMap: exportInfoToModuleMap 是一个映射表,将导出信息对象关联回其所属的模块。它解决了嵌套导出场景下的模块定位问题 - 当处理深层嵌套导出路径(如obj.nested.property)时,需要找到嵌套导出信息所属的模块,以便在导出状态变化时将正确的模块加入处理队列。

如下图所:

20250327004616

这些都是我们前面讲过的。

  • queue: 用于存储待处理的模块和其运行时环境信息

2.2 模块导出使用处理器 (processReferencedModule)

这个核心函数精确标记每个导出的使用状态:

const processReferencedModule = (module, usedExports, runtime, forceSideEffects) => {
  const exportsInfo = moduleGraph.getExportsInfo(module);

  if (usedExports.length > 0) {
    // 处理模块有被使用的导出

这部分逻辑根据不同情况进行处理:

  1. 无导出类型模块处理:
if (!module.buildMeta || !module.buildMeta.exportsType) {
  if (exportsInfo.setUsedWithoutInfo(runtime)) {
    queue.enqueue(module, runtime);
  }
  return;
}

对于 CommonJS 或没有明确导出类型的模块,由于无法静态分析导出,标记为"完全使用"。

  1. 精确导出路径处理:
   for (const usedExportInfo of usedExports) {
     // 提取导出路径和混淆选项
     let usedExport;
     let canMangle = true;
     if (Array.isArray(usedExportInfo)) {
       usedExport = usedExportInfo;
     } else {
       usedExport = usedExportInfo.name;
       canMangle = usedExportInfo.canMangle !== false;
     }

这段代码处理两种导出引用格式:简单数组形式和带混淆标志的对象形式。如下代码所示:

import { moment } from "./moment";

moment.click();

最终输出结果如下:

20250327094809

  1. 整体导出对象使用:
if (usedExport.length === 0) {
  if (exportsInfo.setUsedInUnknownWay(runtime)) {
    queue.enqueue(module, runtime);
  }
}

空数组表示使用了整个导出对象(如import * as mod from './mod'),将导出标记为"未知方式使用"。

如下代码所示:

// moment.js
export const a = 1;

export const b = 2;

export const c = 3;

export const d = 4;

export const e = 5;

export const f = 6;

// index.js
import * as moment from "./moment";

console.log(moment);

最终输出结果如下图所示:

20250330171226

  1. 副作用处理:

    } else {
      // 没有使用导出,但可能有副作用
      if (
        !forceSideEffects &&
        module.factoryMeta !== undefined &&
        module.factoryMeta.sideEffectFree
      ) {
        return; // 跳过无副作用的未使用模块
      }
    
      if (exportsInfo.setUsedForSideEffectsOnly(runtime)) {
        queue.enqueue(module, runtime);
      }
    }
    

    当模块没有被使用的导出时,检查是否有副作用:

    • 如果标记为无副作用且未强制保留副作用,则完全跳过

    • 否则标记为"仅用于副作用"

2.3 小结

FlagDependencyUsagePlugin 是 webpack 树摇机制的核心实现,负责精确标记哪些导出被使用,哪些可以安全移除。在 Seal 阶段,它通过全面分析依赖图来优化最终产物体积。

主要流程如下:

  1. 初始化阶段:通过 compilation.hooks.optimizeDependencies 钩子触发插件执行,为每个模块创建导出信息 (exportInfo) 并存入 exportInfoToModuleMap,调用 setHasUseInfo() 初始化导出使用状态追踪。

  2. 入口分析:从所有入口依赖开始,调用 processEntryDependency 函数,处理全局入口、命名入口及其包含的依赖,初始入口模块默认标记为"副作用使用",确保入口代码保留。

  3. 依赖遍历与标记:使用队列和广度优先搜索遍历整个依赖图,对每个模块调用 processModule 收集其依赖引用的导出,通过 getDependencyReferencedExports 确定每个依赖使用了哪些导出,使用 setUsedConditionally 将被引用的导出标记为已使用。

  4. 特殊情况处理:对整个导出对象引用 (EXPORTS_OBJECT_REFERENCED) 特殊处理,对无导出引用但有副作用的模块标记为仅副作用使用,对无副作用 (sideEffectFree) 模块在无导出使用时可能完全跳过。

  5. 结果存储:所有标记结果记录在 exportInfo._usedInRuntime 属性中,这些信息将直接影响代码生成阶段保留或移除哪些代码。

这个精确的导出使用分析是 webpack 实现高效树摇的关键环节,确保最终包中只包含实际使用的代码,显著优化应用性能和加载时间。无论是基础类型导出还是复杂对象,插件都能准确追踪其使用情况,为后续优化提供可靠依据。

3. 生成代码

4. 删除 Dead Code

在执行了 FlagDependencyExportsPlugin 和 FlagDependencyUsagePlugin 插件的导出标记和使用标记过程后,Webpack 将会准确地知道哪些模块和导出被实际使用,哪些是未使用的。接下来,Webpack 会进行 删除未使用代码(Dead Code Elimination)的优化,这个过程在 Tree Shaking 的最后阶段,确保未被使用的代码从最终的打包结果中移除。

首先我们需要编写这样的 webpack 配置:

const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  mode: "production",
  optimization: {
    usedExports: true,
    sideEffects: true,
    minimize: true,
    minimizer: [
      new TerserPlugin({
        extractComments: false,
      }),
    ],
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
  },
};

最终我们要编写这样的实际代码:

20250330212340

在这里,我们在 moment.js 文件中定义了三个变量,导出了两个,有一个没有导出,我们再 index.js 文件中只使用了一个,当我们执行构建的时候,最终输出的结果如下图所示:

(() => {
  "use strict";
  console.log(1);
})();

完美 🎉🎉🎉🎉🎉🎉

总结

Tree Shaking 是一种基于 ES Module 的静态代码分析技术,通过精确识别并移除未被引用的代码来减小最终打包体积。它通过"标记-清除"两阶段流程实现:先由 FlagDependencyExportsPlugin 识别模块提供了什么,再由 FlagDependencyUsagePlugin 标记这些导出如何被使用,最后在生产环境中通过压缩工具物理移除未使用代码。Tree Shaking 的成功依赖于 ES Module 的静态结构特性,这也是它无法在 CommonJS 等动态模块系统中有效工作的根本原因。

Mac 命令行及 Linux 使用指南与示例

2025年3月30日 21:30

通用基础命令(Mac 和 Linux 均适用)

1. 文件与目录操作

命令 功能说明 示例
ls 列出目录内容 ls -al(显示所有文件,包括隐藏文件)
cd 切换目录 cd ~/Documents(进入用户文档目录)
pwd 显示当前目录路径 pwd
cp 复制文件或目录 cp file.txt backup/
mv 移动或重命名文件 mv old.txt new.txt
rm 删除文件或目录 rm -rf dir/(强制递归删除目录)
mkdir 创建目录 mkdir project
touch 创建空文件 touch newfile.txt

2. 文本操作与处理

命令 功能说明 示例
cat 查看文件内容 cat log.txt
grep 文本搜索 grep "error" /var/log/syslog
echo 输出内容或写入文件 echo "Hello" > hello.txt
nano / vim 文本编辑器 vim notes.md
head / tail 查看文件头/尾部内容 tail -f log.txt(实时追踪日志)

3. 系统信息与进程管理

命令 功能说明 示例
ps 查看进程信息 ps aux | grep chrome
top / htop 实时监控系统资源 htop(需安装)
kill 终止进程 kill -9 1234(强制终止 PID 1234 的进程)
df / du 查看磁盘使用情况 df -h(以易读格式显示磁盘空间)
free 查看内存使用(Linux) free -m(显示内存以 MB 为单位)

Mac 特有命令与工具

1. 系统信息与管理

命令/工具 功能说明 示例
sw_vers 查看 macOS 版本 sw_vers -productVersion
system_profiler 查看硬件和系统信息 system_profiler SPHardwareDataType
open 用默认程序打开文件或目录 open .(在 Finder 中打开当前目录)
pbcopy / pbpaste 剪贴板操作 cat file.txt | pbcopy(复制文件内容到剪贴板)

2. 包管理工具:Homebrew

# 安装 Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 常用操作
brew install wget     # 安装软件
brew update           # 更新包列表
brew upgrade          # 升级所有已安装包
brew remove node      # 卸载软件

Linux 特有命令与工具

1. 包管理(Debian/Ubuntu)

# 更新与安装
sudo apt update        # 更新软件源
sudo apt install nginx # 安装软件
sudo apt remove nginx  # 卸载软件
sudo apt autoremove    # 清理无用依赖

# 查看已安装软件
apt list --installed

2. 系统服务管理(Systemd)

sudo systemctl start nginx    # 启动服务
sudo systemctl stop nginx     # 停止服务
sudo systemctl restart nginx  # 重启服务
sudo systemctl status nginx   # 查看服务状态

3. 网络工具

# 查看 IP 地址(Linux)
ip addr show

# 测试网络连通性
ping google.com

# 查看开放端口
netstat -tuln

Mac 与 Linux 的差异点

1. 命令参数差异

功能 Mac(BSD 风格) Linux(GNU 风格)
查看文件修改时间 ls -lT ls -l --time-style=full-iso
文本替换(sed) sed -i '' 's/old/new/g' file sed -i 's/old/new/g' file
计算 MD5 校验和 md5 file.txt md5sum file.txt

2. 文件系统路径差异

类型 Mac Linux
用户主目录 /Users/username /home/username
临时目录 /private/tmp /tmp
系统日志 /var/log/system.log /var/log/syslog

实用场景示例

1. 批量重命名文件

# Mac(需安装 rename)
brew install rename
rename 's/old/new/' *.txt

# Linux(使用 rename 或 mmv)
sudo apt install rename
rename 'old' 'new' *.txt

2. 查找文件

# 按名称查找
find ~/ -name "*.log"

# 按内容查找
grep -rn "error" /var/log/

3. 压缩与解压

# 压缩目录为 tar.gz
tar -czvf archive.tar.gz /path/to/dir

# 解压 tar.gz
tar -xzvf archive.tar.gz

# 压缩为 zip(Mac/Linux 通用)
zip -r archive.zip /path/to/dir

安全与权限管理

1. 修改文件权限

chmod 755 script.sh     # 设置权限为 rwxr-xr-x
chown user:group file   # 修改文件所有者和组

2. SSH 密钥管理

# 生成密钥对
ssh-keygen -t ed25519

# 将公钥复制到服务器
ssh-copy-id user@remote-server

开发环境配置

1. Python 虚拟环境

# 创建虚拟环境
python -m venv myenv

# 激活环境(Mac/Linux)
source myenv/bin/activate

2. Node.js 版本管理(nvm)

# 安装 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash

# 安装 Node.js
nvm install 18
nvm use 18

注意事项

  1. 权限警告

    • 慎用 sudorm -rf,避免误删系统文件。
    • Mac 启用 SIP(系统完整性保护),部分系统目录不可修改。
  2. 脚本兼容性

    • 在 Mac 上使用 GNU 工具(如 gsedgrep)可提高与 Linux 的兼容性:
      brew install coreutils findutils gnu-sed
      
  3. 日志与调试

    • 使用 journalctl(Linux)或 log show(Mac)查看系统日志。

通过掌握这些命令和示例,您可以高效操作 Mac 和 Linux 系统!遇到问题时,记得善用 man <命令> 查看手册(如 man ls)。

SvelteKit 最新中文文档教程(15)—— 链接选项

作者 冴羽
2025年3月30日 21:25

前言

Svelte,一个语法简洁、入门容易,面向未来的前端框架。

从 Svelte 诞生之初,就备受开发者的喜爱,根据统计,从 2019 年到 2024 年,连续 6 年一直是开发者最感兴趣的前端框架 No.1

image.png

Svelte 以其独特的编译时优化机制著称,具有轻量级高性能易上手等特性,非常适合构建轻量级 Web 项目

为了帮助大家学习 Svelte,我同时搭建了 Svelte 最新的中文文档站点。

如果需要进阶学习,也可以入手我的小册《Svelte 开发指南》,语法篇、实战篇、原理篇三大篇章带你系统掌握 Svelte!

欢迎围观我的“网页版朋友圈”、加入“冴羽·成长陪伴社群”,踏上“前端大佬成长之路”

链接选项

在 SvelteKit 中,使用 <a> 元素(而不是框架特定的 <Link> 组件)在应用程序的路由之间导航。如果用户点击一个由应用程序"拥有"的链接(而不是指向外部网站的链接),SvelteKit 将通过导入其代码并调用任何所需的 load 函数来获取数据,从而导航到新页面。

您可以使用 data-sveltekit-* 属性来自定义链接的行为。这些属性可以应用于 <a> 本身,或者应用于父元素。

这些选项也适用于具有 method="GET"<form> 元素。

data-sveltekit-preload-data

在浏览器注册到用户点击链接之前,我们可以检测到他们已经将鼠标悬停在链接上(在桌面端),或者触发了 touchstartmousedown 事件。在这两种情况下,我们可以做出明智的猜测,即 click 事件即将到来。

SvelteKit 可以利用这些信息来提前开始导入代码和获取页面数据,这可以为我们多争取几百毫秒的时间 —— 这就是用户界面感觉迟缓和感觉流畅之间的差异。

我们可以通过 data-sveltekit-preload-data 属性来控制这种行为,该属性可以是以下两个值之一:

  • "hover" 表示当鼠标在链接上停留时将开始预加载。在移动设备上,预加载在 touchstart 时开始
  • "tap" 表示一旦注册到 touchstartmousedown 事件就会开始预加载

默认项目模板在 src/app.html 中的 <body> 元素上应用了 data-sveltekit-preload-data="hover" 属性,这意味着默认情况下每个链接都会在悬停时预加载:

<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>

有时候,当用户悬停在链接上时调用 load 可能不太理想,要么是因为这可能会导致误判(悬停后不一定会点击),要么是因为数据更新非常快,延迟可能意味着数据已经过时。

在这些情况下,您可以指定 "tap" 值,这会导致 SvelteKit 仅在用户点击链接时调用 load

<a data-sveltekit-preload-data="tap" href="/stonks"> 获取当前股票价值 </a>

[!NOTE] 您也可以从 $app/navigation 导入 preloadData,以编程的方式调用。

如果用户设置了减少数据使用(即 navigator.connection.saveDatatrue),数据将永远不会被预加载。

data-sveltekit-preload-code

即使在您不想为链接预加载数据的情况下,预加载代码也可能是有益的。data-sveltekit-preload-code 属性的工作方式类似于 data-sveltekit-preload-data,但它可以接受四个值之一,按"急切"递减排序:

  • "eager" 表示链接将立即预加载
  • "viewport" 表示链接一旦进入视口就会预加载
  • "hover" - 如上所述,但只预加载代码
  • "tap" - 如上所述,但只预加载代码

注意,viewporteager 仅适用于导航后立即存在于 DOM 中的链接 —— 如果链接是稍后添加的(例如在 {#if ...} 块中),则在触发 hovertap 之前不会预加载。这是为了避免因过度观察 DOM 变化而导致的性能问题。

[!NOTE] 由于预加载代码是预加载数据的先决条件,因此只有当此属性指定的值比存在的任何 data-sveltekit-preload-data 属性更急切时,此属性才会生效。

data-sveltekit-preload-data 一样,如果用户选择了减少数据使用,此属性将被忽略。

data-sveltekit-reload

偶尔,我们需要告诉 SvelteKit 不要处理链接,而是让浏览器来处理。给链接添加 data-sveltekit-reload 属性...

<a data-sveltekit-reload href="/path">路径</a>

...将会在点击链接时导致完整的页面导航。

具有 rel="external" 属性的链接将受到相同的处理。此外,它们在预渲染期间会被忽略。

data-sveltekit-replacestate

有时您不希望导航在浏览器的会话历史记录中创建新条目。给链接添加 data-sveltekit-replacestate 属性...

<a data-sveltekit-replacestate href="/path">路径</a>

...在点击链接时将替换当前的 history 条目,而不是用 pushState 创建新的条目。

data-sveltekit-keepfocus

有时您不希望在导航后重置焦点。例如,也许您有一个在用户输入时就提交的搜索表单,您想保持文本输入框的焦点。给它添加 data-sveltekit-keepfocus 属性...

<form data-sveltekit-keepfocus>
<input type="text" name="query" />
</form>

...将导致当前获得焦点的元素在导航后保持焦点。通常,应该避免在链接上使用此属性,因为获得焦点的元素将是 <a> 标签(而不是之前获得焦点的元素),并且屏幕阅读器和其他辅助技术的用户通常期望在导航后移动焦点。您还应该只在导航后仍然存在的元素上使用此属性。如果元素不再存在,用户的焦点将会丢失,这会让使用辅助技术的用户感到困惑。

data-sveltekit-noscroll

在导航到内部链接时,SvelteKit 会模仿浏览器的默认导航行为:它会将滚动位置改变到 0,0,使用户处于页面的最左上角(除非链接包含 #hash,在这种情况下,它会滚动到具有匹配 ID 的元素)。

在某些情况下,您可能希望禁用这种行为。给链接添加 data-sveltekit-noscroll 属性...

<a href="path" data-sveltekit-noscroll>路径</a>

...将在点击链接后阻止滚动。

禁用选项

要在启用了这些选项的元素内禁用任何选项,请使用 "false" 值:

<div data-sveltekit-preload-data>
<!-- 这些链接将被预加载 -->
<a href="/a">a</a>
<a href="/b">b</a>
<a href="/c">c</a>

<div data-sveltekit-preload-data="false">
<!-- 这些链接将不会被预加载 -->
<a href="/d">d</a>
<a href="/e">e</a>
<a href="/f">f</a>
</div>
</div>

要有条件地将属性应用于元素,请这样做:

<div data-sveltekit-preload-data={condition ? 'hover' : false}>

Svelte 中文文档

点击查看中文文档:SvelteKit 链接选项

系统学习 Svelte,欢迎入手小册《Svelte 开发指南》。语法篇、实战篇、原理篇三大篇章带你系统掌握 Svelte!

此外我还写过 JavaScript 系列TypeScript 系列React 系列Next.js 系列冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…

欢迎围观我的“网页版朋友圈”、加入“冴羽·成长陪伴社群”,踏上“前端大佬成长之路”

Flutter Linux应用初探

作者 Karl_wei
2025年3月30日 21:13

距离我上一篇文章,足足过去一年!!!
断更是艰难的过程,日常斥责自己没有作品。除了工作的忙碌、技术栈重心的变化外,AI的崛起带来技术交流平台的低迷,也是让我疲于更新的原因之一
近期重新投入Flutter技术,适配了Linux平台,才让我重新燃起奋笔疾书的欲望。Flutter for Linux在社区中的文章是非常之少的,期待这篇文章能给大家带来一些思考~

原理浅层分析

此次我是对旧项目进行Linux平台的适配,这个项目在Android和Windows平台已经顺利发布运行两年。因此这里省去创建运行项目的说明。
两年前创建的项目,期间跟随Flutter版本升级到3.22。在Linux平台的首次运行,竟然一次就顺利跑起来了。这让我十分的欣喜,而后不断思考:为何Flutter在Linux能如此的顺利运行?

1. 应用载体

Flutter Linux的载体是一个典型的GtkApplication。在main主入口,创建了MyApplication实例并运行应用程序。

#include "my_application.h"

int main(int argc, char** argv) {
  g_autoptr(MyApplication) app = my_application_new();
  return g_application_run(G_APPLICATION(app), argc, argv);
}

在my_application.h中,使用G_DECLARE_FINAL_TYPE宏定义了MyApplication的类型继承自GtkApplication

G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, GtkApplication)

创建了在my_application后,自然就会按顺序的执行GtkApplication的生命周期。
应用程序的主要生命周期包含以下几个关键阶段:

  • 启动(Startup)
  • 激活(Activate)这是最重要的阶段。
    主要完成:创建GTK窗口、设置窗口属性(大小、透明度等)、创建Flutter视图、注册Flutter插件...
  • 关闭(Shutdown)

image.png 总的来说,Flutter在Linux下的运行完全是依赖于GTK框架,通过以下步骤实现:

  • 创建GTK应用程序
  • 设置窗口和显示属性
  • 初始化Flutter引擎
  • 创建Flutter视图
  • 处理生命周期事件和消息

2. engine挂载

Flutter的engine和view是怎么跟GtkApplication关联上的呢?核心代码都在GApplication::activate的钩子中。

  • 创建一个FlDartProject
g_autoptr(FlDartProject) project = fl_dart_project_new();
  • 通过fl_dart_project_set_dart_entrypoint_arguments把启动参数,设置到Flutter层
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
  • 创建FlView,并且作为GTK_WIDGET添加到容器GTK_CONTAINER中
  FlView* view = fl_view_new(project);
  gtk_widget_show(GTK_WIDGET(view));
  gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
  • 注册Flutter插件
  fl_register_plugins(FL_PLUGIN_REGISTRY(view));

整个过程与GTKWinodw是比较脱离的,跟Android FlutterActivity、Windows FlutterWindow的实现思路一模一样。
这也证明Flutter是个很纯粹的跨平台UI框架,脱离原生框架的束缚。所以3年前的项目,Linux端一次运行成功也就不足为奇了~

重点Tips

1. Flutter版本

Flutter的更新迭代是非常快的,并且桌面的支持也力不从心,所以对于一个新的平台来说,在开始适配的时候,一定要升级到最新版本,一定要用最新!!!

2. 设置窗口属性

Flutter是跨平台的UI,那么窗口的属性自然就无法快速去操作,比如:设置无标题栏、设置大小、居中等。
这里我们也不推荐在Flutter层面使用window_manager去操作,从性能和显示的实时效果出发,就应该在c++层处理完成
以下代码,为Flutter应用设置了依据分辨率适配大小、居中、隐藏标题栏、设置透明底等。

// 获取屏幕分辨率
gboolean GetScreenRect(gint *width, gint *height) {
  GdkDisplay *display = gdk_display_get_default();
  if (display) {
    GdkMonitor *monitor = gdk_display_get_primary_monitor(display);
    if (monitor) {
      GdkRectangle geometry;
      gdk_monitor_get_geometry(monitor, &geometry);
      *width = geometry.width;
      *height = geometry.height;
      return TRUE;
    }
  }
  return FALSE;
}

// 获取DPI
gint GetDpi() {
  GdkScreen *screen = gdk_screen_get_default();
  if (screen) {
    return gdk_screen_get_resolution(screen);
  }
  return 96; // 默认DPI
}

static gboolean on_draw_event(GtkWidget *widget, cairo_t *cr,
                  gpointer user_data)
{
  cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
  cairo_paint(cr);
  return FALSE;
}

static void transparent_setup(GtkWidget *win)
{
  GdkScreen *screen;
  GdkVisual *visual;

  gtk_widget_set_app_paintable(win, TRUE);
  screen = gdk_screen_get_default();
  visual = gdk_screen_get_rgba_visual(screen);

  if (visual != NULL && gdk_screen_is_composited(screen)) {
    gtk_widget_set_visual(win, visual);
    g_signal_connect(G_OBJECT(win), "draw", G_CALLBACK(on_draw_event), NULL);
  }
}

// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
  MyApplication* self = MY_APPLICATION(application);
  GtkWindow* window =
      GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));

  // Use a header bar when running in GNOME as this is the common style used
  // by applications and is the setup most users will be using (e.g. Ubuntu
  // desktop).
  // If running on X and not using GNOME then just use a traditional title bar
  // in case the window manager does more exotic layout, e.g. tiling.
  // If running on Wayland assume the header bar will work (may need changing
  // if future cases occur).
  gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
  GdkScreen* screen = gtk_window_get_screen(window);
  if (GDK_IS_X11_SCREEN(screen)) {
    const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
    if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
      use_header_bar = FALSE;
    }
  }
#endif
  if (use_header_bar) {
    GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
    gtk_widget_show(GTK_WIDGET(header_bar));
    gtk_header_bar_set_title(header_bar, "SystemUpgradeMain");
    gtk_header_bar_set_show_close_button(header_bar, TRUE);
    gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
  } else {
    gtk_window_set_title(window, "SystemUpgradeMain");
  }

  // 设置窗口透明
  transparent_setup(GTK_WIDGET(window));
  // 隐藏标题栏
  gtk_window_set_decorated(GTK_WINDOW(window), FALSE);
  // 设置窗口居中
  gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER);

  // 获取缩放因子
  double scale_factor;
  gint screenWidth, screenHeight;
  auto default_resolution = 1.0 * 1920 / 1080;
  if (GetScreenRect(&screenWidth, &screenHeight)) {
    auto current_resolution = 1.0 * screenWidth / screenHeight;
    if (current_resolution > default_resolution) {
      scale_factor = 1.0 * screenHeight / 1080;
    } else {
      scale_factor = 1.0 * screenWidth / 1920;
    }
  } else {
    gint dpi = GetDpi();
    scale_factor = dpi / 96.0;
  }
  std::cout << "scale_factor: " << scale_factor << std::endl;
  // 设置窗口大小
  gtk_window_set_default_size(window, 1172*scale_factor, 731*scale_factor);

  gtk_widget_show(GTK_WIDGET(window));
  gtk_widget_set_visible(GTK_WIDGET(window), FALSE);

  g_autoptr(FlDartProject) project = fl_dart_project_new();
  fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);

  FlView* view = fl_view_new(project);
  gtk_widget_show(GTK_WIDGET(view));
  gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));

  GdkRGBA background_color;
  gdk_rgba_parse(&background_color, "#ffffff");
  fl_view_set_background_color(view, &background_color);

  fl_register_plugins(FL_PLUGIN_REGISTRY(view));

  gtk_widget_grab_focus(GTK_WIDGET(view));
}

3. 查看Flutter for Linux源码

Flutter Linux的相关文章,全网都非常少见,其原因跟Flutter在Linux的投入,Linux系统下Flutter的应用生态都有所关系。好在官方的源代码文档,还是比较完整的:Flutter Linux源码
在给Linux窗口设置透明背景时,我们就遇到了不少坑。

  • 在Flutter 3.27之前,Flutter官方是没有提供透明窗口的方法的,FlutterView默认是黑色的。因此即便我们通过cairo_paint把GTKWindow绘制成透明的,上层的FlutterView依然不透明。
static gboolean on_draw_event(GtkWidget *widget, cairo_t *cr,
                  gpointer user_data)
{
  cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
  cairo_paint(cr);
  return FALSE;
}

static void transparent_setup(GtkWidget *win)
{
  GdkScreen *screen;
  GdkVisual *visual;

  gtk_widget_set_app_paintable(win, TRUE);
  screen = gdk_screen_get_default();
  visual = gdk_screen_get_rgba_visual(screen);

  if (visual != NULL && gdk_screen_is_composited(screen)) {
    gtk_widget_set_visual(win, visual);
    g_signal_connect(G_OBJECT(win), "draw", G_CALLBACK(on_draw_event), NULL);
  }
}
  • 于是我们通过搜索源码文档,很快定位到了相关的api,再到github上溯源其提交版本,很快的解决了这个问题。

image.png

写在后面

Linux App在国内的应用场景是比较少的,但随着接下来设备国产化的战略继续推进,我相信Flutter Linux会有进一步的需求。但是从生态上来看,不会C++的团队,在Flutter For Linux的道路上,是会遇到比较多的困难的。
Anyway,在国内鸿蒙化、国产化;世界范围AI编程、AOSP停止维护的大背景下,衷心希望Flutter桌面端越来越好吧~

注意Tailwind CSS 4.0 自定义颜色方式变更了

2025年3月30日 20:21

今天在使用tailwindcss时,发现配置在tailwind.config.js中的颜色无论如何都不生效,一查才知道4.0 配置方式改变了。

在 Tailwind CSS 4.0 中,自定义颜色的方式与之前的版本有所不同,因为 Tailwind CSS 4.0 引入了基于 CSS 的配置方式,移除了传统的 tailwind.config.js 文件的强制依赖,转而更倾向于使用 CSS 文件中的 @theme 指令来定义自定义主题,包括颜色。这种新方法利用了 CSS 自定义属性(Custom Properties),更加现代化且灵活。

以下是如何在 Tailwind CSS 4.0 中自定义颜色的步骤和示例:


1. 使用 CSS 文件中的 @theme 指令

在 Tailwind CSS 4.0 中,你可以通过在 CSS 文件中使用 @theme 指令来定义自定义颜色。这些颜色会自动生成对应的工具类(如 bg-text- 等),并保留 Tailwind 的默认颜色。

示例代码:

/* styles.css 或其他 CSS 文件 */
@import "tailwindcss";

@theme {
  --color-myblue-50: #eef2ff;
  --color-myblue-100: #e0e7ff;
  --color-myblue-200: #c7d2fe;
  --color-myblue-300: #a5b4fc;
  --color-myblue-400: #818cf8;
  --color-myblue-500: #6366f1;
  --color-myblue-600: #4f46e5;
  --color-myblue-700: #4338ca;
  --color-myblue-800: #3730a3;
  --color-myblue-900: #312e81;
}

说明:

  • --color- 是 Tailwind CSS 4.0 中用于定义颜色的命名空间。
  • myblue 是你自定义的颜色名称,可以随意命名(建议语义化)。
  • 50900 是颜色的不同深浅度(从浅到深),你可以根据需要定义部分或全部。
  • 定义完成后,你可以直接在 HTML 中使用这些颜色,例如:
    <div class="bg-myblue-500 text-myblue-900">自定义颜色</div>
    

结果:

  • Tailwind 会根据你定义的 --color-myblue-* 生成对应的工具类,如 bg-myblue-500text-myblue-900 等。
  • 默认颜色(如 bluered 等)依然保留,除非你显式覆盖它们。

2. 覆盖默认颜色

如果你想完全替换 Tailwind 的默认颜色(而不是扩展),可以在 @theme 中重新定义它们。例如,替换默认的 blue 颜色:

示例代码:

@import "tailwindcss";

@theme {
  --color-blue-50: #f0f9ff;
  --color-blue-100: #e0f2fe;
  --color-blue-200: #bae6fd;
  --color-blue-300: #7dd3fc;
  --color-blue-400: #38bdf8;
  --color-blue-500: #0ea5e9;
  --color-blue-600: #0284c7;
  --color-blue-700: #0369a1;
  --color-blue-800: #075985;
  --color-blue-900: #0c4a6e;
}

说明:

  • 这里重新定义了 --color-blue-*,覆盖了 Tailwind 默认的 blue 颜色。
  • 使用时,仍然是 bg-blue-500text-blue-900 等,但颜色值会变成你自定义的。

3. 使用 OKLCH 或其他颜色格式

Tailwind CSS 4.0 默认使用 oklch 颜色格式(比 RGB 更现代,支持更广的色域),但你也可以使用 HEX、RGB 或 HSL 等格式。

示例代码(使用 OKLCH):

@import "tailwindcss";

@theme {
  --color-mygreen-500: oklch(0.65 0.15 150);
  --color-mygreen-600: oklch(0.55 0.15 150);
}

说明:

  • oklch 的参数分别是 lightness(亮度)、chroma(色度)、hue(色调)。
  • 你也可以使用 HEX:
    --color-mygreen-500: #10b981;
    

4. 动态主题支持(使用 CSS 变量)

如果你需要支持动态主题(例如暗黑模式),可以在 @theme 中结合 CSS 变量使用:

示例代码:

@import "tailwindcss";

@theme {
  --color-primary: var(--primary-color);
}

:root {
  --primary-color: #6366f1; /* 默认主题 */
}

@media (prefers-color-scheme: dark) {
  :root {
    --primary-color: #a5b4fc; /* 暗黑模式 */
  }
}

说明:

  • --color-primary 定义了一个动态颜色,它的值来自 --primary-color
  • :root 中通过 CSS 变量动态调整颜色。
  • 使用时:<div class="bg-primary">动态颜色</div>

注意事项

  • 构建工具:确保你的项目使用支持 Tailwind CSS 4.0 的构建工具(如 Vite),并正确引入 @tailwindcss
  • 颜色命名:避免与默认颜色冲突,除非你明确想覆盖它们。
  • 性能:只定义你需要的颜色,避免生成过多未使用的工具类。

总结

在 Tailwind CSS 4.0 中,自定义颜色的首选方式是通过 CSS 文件中的 @theme 指令定义 --color-* 变量。这种方法简单、直观且与现代 CSS 特性深度集成。

前端必懂优化策略——浏览器缓存

作者 竺梓君
2025年3月30日 19:22

前言

浏览器缓存是优化网页加载速度的关键技术,通过存储网页资源(如HTML、CSS、JavaScript和图片等)的副本,减少对服务器的请求次数。

当用户再次访问同一网站时,对于静态的数据,浏览器可以优先使用本地缓存的数据,从而加快页面加载时间并降低服务器负载。缓存机制包括强缓存(通过Cache-ControlExpires头控制)和协商缓存(利用ETagLast-Modified进行验证),两者共同作用以提高用户体验和网站性能。

请求头和响应头

想弄懂浏览器的缓存,我们就要先弄懂什么是请求头和响应头,在以前的http协议中本来是不需要请求头和响应头的,但是随着浏览器的发展,前端和后端需要开始进行沟通了,而请求头和响应头就是为了实现前后端能够进行内容协商,简单来说就是在请求头和响应头中告诉对方,我需要你怎么去处理这份数据。

const http = require('http');
const url = require('url');

const responseData = {
  id: 1,
  name: '张三',
  age: 18,
  sex: '男'
}

function toHTML(data) {
  return `
    <ul>
      <li><span>id:</span> <span>${data.id}</span></li>
      <li><span>昵称:</span> <span>${data.name}</span></li>
      <li><span>年龄:</span> <span>${data.age}</span></li>
      <li><span>性别:</span> <span>${data.sex}</span></li>
    </ul>
  `
}


const server = http.createServer((req, res) => {
  const { pathname } = url.parse(req.url)
  if (pathname === '/') {
    const accept = req.headers.accept // 前端想要的类型
 
    if (accept.includes('application/json')) {
       res.writeHead(200, {'content-type': 'application/json'})
      res.end(JSON.stringify(responseData)) 
      
    } else {
      res.writeHead(200, {'content-type': 'text/html; charset=utf-8'})
      res.end(toHTML(responseData))
    }

  } else {
    res.writeHead(404, {'content-type': 'text/html'})
    res.end('<h1>Not Found</h1>')
  }

})

server.listen(3000, () => {
  console.log('server is running on port 3000');
})

这里我们使用原生的node.js开启一个HTTP服务,用来监听3000端口,然后我们使用url并解构出pathname从而判断根路径,req.headers就是请求头,属性accept就是前端想要的数据类型描述对象,我在后端输出给大家看一下

86.png 第一个就是前端想要的数据类型,通过这份数据,我们就知道前端想要什么数据了,于是我们返回对应的数据,并且在响应头中告诉浏览器将返回的数据处理为什么类型,前端想要的数据是text/html,于是就走进了这份代码

res.writeHead(200, {'content-type': 'text/html; charset=utf-8'})
res.end(toHTML(responseData)

前端就会将它当html代码处理

87.png

88.png 如果我们得到前端想要的是text类型但是我们在响应头中告诉浏览器要处理为application/json类型会怎么样

89.png

91.png 可以看到,浏览器就会根据后端响应头里面描述进行处理,这就是基于请求头和响应头进行的前后端内容协商

强缓存

一听名字就知道很霸道,事实也的确如此,之前我们讲了那么多有关请求头和响应头和这个有什么关系呢,有的兄弟有的,我们就是通过响应头来告诉前端什么需要缓存。

优点:效率高

而强缓存就是直接告诉浏览器这个响应的数据全部缓存,而且缓存多久我说了算,并且哪怕返回文件的静态资源(图片,CSS和JS)发生了变化,但是文件名没有改变的话,也还是按我之前缓存的内容来,而且缓存时间之内不要再来烦我,直接到缓存数据中拿。

          const timeStamp =req.headers['if-modified-since']  
          let status = 200
            if(timeStamp && Number(timeStamp) === stats.mtimeMs) { //文件没有发生过更改
                status = 304
            }
            res.writeHead(status,{
                'content-type':mime.getType(ext),
                'cache-control':'max-age=86400',    // 强缓存一天
                'last-modified': stats.mtimeMs  //最后修改时间
            })
            if(status === 200){
                const readStream = fs.createReadStream(filePath) //创建可读流
                readStream.pipe(res)   //将可读流的数据,通过管道,输入前端
            }
            else{
                return res.end()
            }

98.png

缺点:某些资源还是会向后端发请求

在强缓存之后,数据的文件名如果没有更改是不会再向服务器发请求的,但是在通过url访问某些资源的时候,请求头中会带有Cache-Control; max-age=0

97.png

这种资源哪怕已经被缓存了,也会向后端发送请求,我们这里判断文件是否更改,未更改的话就按道理不会向后端发请求,其实这里就不要写了

但是文件未更改,通过某些url请求的数据还是想向后端请求数据,没有再向缓存中拿,我们来验证一下

93.png 这是前端第一次向后端请求资源,这时的请求都是耗时的

94.png 这是发的第二次请求,可以看到,图片数据是直接从浏览器缓存中拿的,所以耗时为0ms,而localhost不仅·耗时,而且状态码为304,这些都说明这份文件是向后端发送过请求的。

那有小伙伴会说,有没有可能这份数据根本就没有被缓存,我开始也是这样认为的,但是我在判断状态码为304后什么资源都没返回,但是这份数据依旧加载正常。所以请求时间减少是因为我没有返回内容,所以只耗时请求的时间,而它什么都没请求但是加载正常说明数据是被缓存了的,只是它优先会向后端发送请求。

缺点:文件内容更改不能及时更新

强缓存通过设置 Cache-ControlExpires 实现高效缓存,但当文件内容更新时,可能会因浏览器直接使用本地缓存而导致新版本无法及时生效。我们可以使用修改文件名的方式去解决该问题

修改文件名
  • 原理:通过更改文件名或路径,使浏览器认为这是一个全新的资源,从而绕过缓存。

  • 实现方式

    • 在构建工具中为文件添加哈希值(如 [filename].[hash].js),例如 main.a1b2c3.js
    • 每次文件内容更新时,哈希值随之变化,确保浏览器重新下载。
  • 优点:简单高效,避免了复杂的缓存策略配置。

协商缓存

协商缓存也是和它的名字一样,前后端商量着来,不会直接向浏览器的缓存中拿数据,而是通过发请求拿响应再决定使用之前的缓存数据还是拿新的数据并将新数据进行缓存。

通过修改时间来判断是否使用缓存数据

 const stats = fs.statSync(filePath); 
 const timeStamp =req.headers['if-modified-since']

            let status = 200
            if(timeStamp && Number(timeStamp) === stats.mtimeMs) { //文件没有发生过更改
                status = 304
            }
            res.writeHead(status,{
                'content-type':mime.getType(ext),
                'last-modified': stats.mtimeMs  //最后修改时间
            })

拿到文件信息,stats就是实际的最后修改时间,req.headers['if-modified-since'],上次获取资源中stats的最后修改时间。 如果二者相等,那么就说明文件内容未更改,返回状态码304,就相当于告诉浏览器,这次和上次的内容相同,你用上次的文件也是一样的,修改了文件则可以立马更新。

优点:仅判断实际修改时间和上次获取资源的文件最后修改时间是否相同,开销性能少

缺点:如果使用文件更改了任何东西,哪怕最后复原了,内容相同,修改时间也会发生变化,需要重新缓存

通过文件的内容来判断是否使用缓存数据

const ifNoneMatch = req.headers['if-none-match']
      checksum.file(filePath, (err, sum) => {
        sum = `"${sum}"`
        if (ifNoneMatch === sum) {  // 文件没有变化
          res.writeHead(304, {
            'Content-Type': mime.getType(ext),
            'etag': sum,
          })
          res.end()
        } else {
          res.writeHead(200, {
            'Content-Type': mime.getType(ext),
            'etag': sum,
          })

根据文件的实际内容计算文件的md5值,使用实际的md5值和上次请求资源的实际md5值进行比较,如果相同则证明文章内容相同。

修改内容前

100.png修改内容后

101.png

两次计算出来的文件md5值完全不一样,这样响应体就了清楚的告诉浏览器到底从缓存中拿还是重新加载文件。

优点:精准的判断文件内容是否发生变化。 缺点:计算文件的md5值开销性能相对大不少

总结

强缓存:

  1. 缓存效率高,缓存后不需要再向后端发请求,直接在本地存储拿
  2. 但是url访问的部分内容还是会向后端发请求,且在缓存时间内,文件内容发生更改无法及时更新,可以使用文件名内添加哈希值

协商缓存:

1.每次都需要向后端发请求,缓存效率相对较低 2.根据时间或内容都可以在文件内容发生变化时及时更新文件数据

建议:根据不同的场合合理利用强缓存和协商缓存

WXT浏览器插件开发中文教程(27)----发布插件

2025年3月30日 19:22

前言

大家好,我是倔强青铜三。是一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新,欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

发布

WXT 可以将您的扩展程序打包为 ZIP 文件,并提交到各个商店进行审核或自行托管。

首次发布

如果您是首次将扩展程序发布到商店,您需要手动完成整个流程。WXT 不会帮您创建列表,每个商店都有其独特的步骤和要求,您需要熟悉这些内容。

有关每个商店的具体信息,请参阅下面的商店部分。

自动化

WXT 提供了两个命令,帮助自动化提交新版本进行审核和发布:

  • wxt submit init:设置 wxt submit 命令所需的所有密钥和选项。
  • wxt submit:提交扩展程序的新版本进行审核(审核通过后自动发布)。

开始时,请运行 wxt submit init 并按照提示操作。完成后,您应该会有一个 .env.submit 文件!WXT 将使用此文件提交更新。

在持续集成 (CI) 中,请确保将所有环境变量添加到提交步骤中。

要提交新版本进行发布,请构建您计划发布的所有 ZIP 文件:

wxt zip
wxt zip -b firefox

然后运行 wxt submit 命令,并传入您要发布的所有 ZIP 文件。在此示例中,我们将为 Chrome Web Store、Edge Addons 和 Firefox Addons Store 这三个主要商店发布版本。

如果您是首次运行该命令,或者您最近对发布流程进行了更改,建议您通过添加 --dry-run 标志来测试密钥。

wxt submit --dry-run \
  --chrome-zip .output/{your-extension}-{version}-chrome.zip \
  --firefox-zip .output/{your-extension}-{version}-firefox.zip --firefox-sources-zip .output/{your-extension}-{version}-sources.zip \
  --edge-zip .output/{your-extension}-{version}-chrome.zip

如果试运行通过,则移除该标志并执行实际发布:

wxt submit \
  --chrome-zip .output/{your-extension}-{version}-chrome.zip \
  --firefox-zip .output/{your-extension}-{version}-firefox.zip --firefox-sources-zip .output/{your-extension}-{version}-sources.zip \
  --edge-zip .output/{your-extension}-{version}-chrome.zip

警告

有关 --firefox-sources-zip 选项的更多详细信息,请参阅 Firefox Addon Store 部分。

GitHub Action

以下是一个 GitHub Action 的示例,用于提交扩展程序的新版本进行审核。请确保您已在仓库的设置中添加了工作流中使用的所有必需的密钥。

name: Release
on:
  workflow_dispatch:
jobs:
  submit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - name: Install dependencies
        run: pnpm install
      - name: Zip extensions
        run: |
          pnpm zip
          pnpm zip:firefox
      - name: Submit to stores
        run: |
          pnpm wxt submit \
            --chrome-zip .output/*-chrome.zip \
            --firefox-zip .output/*-firefox.zip --firefox-sources-zip .output/*-sources.zip
        env:
          CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
          CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
          CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
          CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
          FIREFOX_EXTENSION_ID: ${{ secrets.FIREFOX_EXTENSION_ID }}
          FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }}
          FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }}

上述操作为基本工作流奠定了基础,包括 zipsubmit 步骤。要进一步完善您的 GitHub Action,并探索更复杂的场景,请考虑参考以下真实项目中的示例。它们引入了诸如版本管理、更新日志生成和 GitHub 发布等高级功能,以满足不同的需求:

  • aklinker1/github-better-line-counts - 使用传统提交方式,自动提升版本并生成更新日志,手动触发,可选的试运行用于测试
  • GuiEpi/plex-skipper - 当 package.json 版本发生变化时自动触发,创建并上传 GitHub 发布的工件。

这些示例旨在提供清晰的见解,并是定制您自己的工作流的良好起点。您可以自由地探索并根据您的项目需求进行调整。

商店

Chrome Web Store

✅ 支持 • 开发者仪表板发布文档

要为 Chrome 创建 ZIP 文件:

wxt zip

Firefox Addon Store

✅ 支持 • 开发者仪表板发布文档

Firefox 要求您上传扩展程序源代码的 ZIP 文件。这使得他们能够重新构建您的扩展程序,并以可读的方式审查代码。更多详细信息可以在 Firefox 的文档中找到。

运行 wxt zip -b firefox 时,WXT 将同时打包您的扩展程序和源代码。某些文件(例如配置文件、隐藏文件、测试文件和排除的入口点)将自动从您的源代码中排除。但是,重要的是要手动检查 ZIP 文件,以确保它仅包含重新构建扩展程序所需的文件。

要自定义要打包的文件,请在配置文件中添加 zip 选项。

wxt.config.ts

import { defineConfig } from 'wxt';
export default defineConfig({
  zip: {
    // ...
  },
});

如果这是您首次向 Firefox Addon Store 提交,或者您已更新了项目布局,请始终测试您的源代码 ZIP 文件!以下命令应允许您从解压后的 ZIP 文件中重新构建扩展程序。

pnpm 打包方式

pnpm i
pnpm zip:firefox

npm 打包方式

npm i
npm run zip:firefox

yarn 打包方式

yarn
yarn zip:firefox

bun 打包方式

bun i
bun zip:firefox

请确保您的项目中有一个 README.mdSOURCE_CODE_REVIEW.md 文件,并在其中包含上述命令,以便 Firefox 团队知道如何构建您的扩展程序。

确保在主项目中运行 wxt build -b firefox 以及在打包的源代码中运行时,构建输出完全相同。

警告

如果您使用 .env 文件,它们可能会影响输出目录中的块哈希值。在运行 wxt zip -b firefox 之前,要么删除 .env 文件,要么使用 zip.includeSources 选项将其包含在源代码 ZIP 中。小心不要将任何机密信息包含在 .env 文件中。

有关更多详细信息,请参阅问题 #377

私有包

如果您使用了私有包,并且不想在审核过程中向 Firefox 团队提供您的身份验证令牌,您可以使用 zip.downloadPackages 下载任何私有包,并将其包含在 ZIP 文件中。

wxt.config.ts

export default defineConfig({
  zip: {
    downloadPackages: [
      '@mycompany/some-package',
      //...
    ],
  },
});

根据您的包管理器,源代码 ZIP 中的 package.json 将被修改,以便通过 overridesresolutions 字段使用下载的依赖项。

警告

WXT 使用命令 npm pack <package-name> 来下载包。这意味着无论您使用哪种包管理器,您都需要正确设置 .npmrc 文件。NPM 和 PNPM 都尊重 .npmrc 文件,但 Yarn 和 Bun 有它们自己的方式来授权私有注册表,因此您需要添加一个 .npmrc 文件。

Safari

🚧 尚不支持

WXT 目前不支持 Safari 的自动化发布。Safari 扩展程序需要一个原生的 MacOS 或 iOS 应用程序包装器,而 WXT 尚未创建。目前,如果您想发布到 Safari,请按照以下指南操作:

运行 safari-web-extension-converter 命令行工具时,请传递 .output/safari-mv2.output/safari-mv3 目录,而不是您的源代码目录。

pnpm wxt build -b safari
xcrun safari-web-extension-converter .output/safari-mv2

Edge Addons

✅ 支持 • 开发者仪表板发布文档

无需为 Edge 创建特定的 ZIP 文件。如果您已经在发布到 Chrome Web Store,您可以重复使用 Chrome ZIP 文件。

但是,如果您有针对 Edge 的特定功能,请创建一个单独的 ZIP 文件:

wxt zip -b edge

最后感谢阅读!欢迎关注我,微信公众号倔强青铜三。欢迎点赞收藏关注,一键三连!!!

docker安装jenkins与docker 容器时区和时间同步问题

2025年3月30日 19:21

使用docker compose安装

services:
  jenkins:
    image: jenkins/jenkins:lts-jdk11 # 使用 LTS 长期支持版
    container_name: jenkins
    restart: unless-stopped
    ports:
      - "7000:8080" # Web 访问端口
      - "7001:50000" # Agent 通信端口
    volumes:
      # 使用宿主机的localtime和timezone,后面的localtime:ro和timezone:ro表示只读模式
      - /etc/localtime:/etc/localtime:ro
      - /etc/timezone:/etc/timezone:ro
      - ./jenkins_data:/var/jenkins_home # 持久化数据卷
      - /var/run/docker.sock:/var/run/docker.sock # 允许容器内使用宿主机 Docker

    environment:
      - TZ=Asia/Shanghai # 设置时区
      - JAVA_OPTS=-Dhudson.model.UpdateCenter.updateCenterUrl=https://mirrors.huaweicloud.com/jenkins/updates/dynamic-stable-2.462.3/ -Duser.timezone=Asia/Shanghai -Dorg.apache.commons.jelly.tags.fmt.timeZone=Asia/Shanghai -Dhudson.model.DownloadService.noSignatureCheck=true

容器启动之后,容器内部的/etc/localtimedate -R可能不一致,此时应该以date -R的为准

docker容器时区问题

查看所有容器的时区

docker ps --format '{{.Names}}' | xargs -I {} sh -c 'echo -n "{}: " && docker exec {} date'

查看单个容器时区(如果与宿主机: date -R的结果不一致,就说明要调整)

docker exec jenkins date -R

查看容器中运行中的java时区

docker exec jenkins bash -c "java -XshowSettings:properties -version"

进入容器中查看java运行状态

docker docker exec -it jenkins /bin/bash
ps -ef | grep java
jps -lv

第12章 掌握CSS层叠样式表——Flex布局

作者 客白村
2025年3月30日 18:26

1 伸缩盒模型简介

  • 2009 年, W3C 提出了一种新的盒子模型 —— Flexible Box (伸缩盒模型,又称:弹性盒 子)。
  • 它可以轻松的控制:元素分布方式、元素对齐方式、元素视觉顺序 .......
  • 截止目前,除了在部分 IE 浏览器不支持,其他浏览器均已全部支持。
  • 伸缩盒模型的出现,逐渐演变出了一套新的布局方案 —— flex 布局。

小贴士

  1. 传统布局是指:基于传统盒状模型,主要靠: display 属性 + position 属性 + float 属性。
  2. flex 布局目前在移动端应用比较广泛,因为传统布局不能很好的呈现在移动设备上。

2 伸缩盒子模型组成

  • 设置方式:给元素设置 display: flex,子元素可以自动挤压或拉伸

  • 组成部分:

    • 伸缩容器
    • 伸缩项目
    • 主轴:默认在水平方向
    • 侧轴 / 交叉轴:默认在垂直方向

伸缩盒子模型组成

3 伸缩容器、伸缩项目

  • 伸缩容器: 开启了 flex 的元素,就是 伸缩容器。
    1. 给元素设置: display:flexdisplay:inline-flex ,该元素就变为了伸缩容器。
    2. display:inline-flex 很少使用,因为可以给多个伸缩容器的父容器,也设置为伸缩 容器。
    3. 一个元素可以同时是:伸缩容器、伸缩项目。
  • 伸缩项目:伸缩容器所有子元素自动成为了 伸缩项目。
    1. 仅伸缩容器的子元素成为了伸缩项目,孙子元素、重孙子元素等后代,不是 伸缩项目。
    2. 无论原来是哪种元素(块、行内块、行内),一旦成为了伸缩项目,全都会 “块状化”

4 主轴与侧轴

  • 主轴: 伸缩项目沿着主轴排列,主轴默认是水平的,默认方向是:从左到右(左边是起点,右边 是终点)。
  • 侧轴: 与主轴垂直的就是侧轴,侧轴默认是垂直的,默认方向是:从上到下(上边是起点,下边 是终点)。

5 主轴方向

  • 属性名: flex-direction
  • 常用值如下:
    1. row :主轴方向水平从左到右 —— 默认值

    2. row-reverse :主轴方向水平从右到左。

    3. column :主轴方向垂直从上到下。

    4. column-reverse :主轴方向垂直从下到上。

主轴方向

注意:改变了主轴的方向,侧轴方向也随之改变。

6 主轴换行方式

  • 属性名: flex-wrap

  • 常用值如下:

    1. nowrap :默认值,不换行。 不换行
    2. wrap :自动换行,伸缩容器空间不够,将自动换行。 自动换行
    3. wrap-reverse :反向换行。 反向换行
  • 注意:

    • 默认不换行,会自动伸缩元素大小。 默认不换行
    • 子元素如果数量比较少,换行后的元素会均匀占据剩余的空间。 换行

7. flex-flow

flex-flow 是一个复合属性,复合了 flex-directionflex-wrap 两个属性。 值没有顺序要求。实例代码,如下所示:

flex-flow: row wrap;

8. 主轴对齐方式

  • 属性名: justify-content
  • 常用值如下:
    1. flex-start :主轴起点对齐。—— 默认值
    2. flex-end :主轴终点对齐。
    3. center :居中对齐。
    4. space-between :均匀分布,两端对齐(最常用)。
    5. space-around :均匀分布,两端距离是中间距离的一半。
    6. space-evenly :均匀分布,两端距离与中间距离一致。

主轴对齐方式

9 侧轴对齐方式

9.1 一行的情况

  • 所需属性: align-items
  • 常用值如下:
    1. flex-start :侧轴的起点对齐。 flex-start

    2. flex-end:侧轴的终点对齐。 flex-end

    3. center:侧轴的中点对齐。 center

    4. baseline: 伸缩项目的第一行文字的基线对齐。 baseline

    5. stretch:如果伸缩项目未设置高度,将占满整个容器的高度。(stretch为默认值,要使它生效,必须使所有伸缩项目均没有高度,才能把每个伸缩项目拖拽得合适距离。)

stretch

9.2 多行的情况

  • 所需属性: align-content
  • 常用值如下:
    1. flex-start:与侧轴的起点对齐。 flex-start

    2. flex-end:与侧轴的终点对齐。 flex-end

    3. center:与侧轴的中点对齐。 center

    4. space-between:与侧轴两端对齐,中间平均分布。 space-between

    5. space-around:伸缩项目间的距离相等,比距边缘大一倍。 space-around

    6. space-evenly: 在侧轴上完全平分。 space-evenly

    7. stretch:占满整个侧轴。( stretch为默认值,要使它生效,必须使所有伸缩项目均没有高度,才能把每个伸缩项目拖拽得合适距离。) stretch

注意:该属性对单行弹性盒子模型无效

10 flex 实现水平垂直居中

方法一:父容器开启 flex 布局,随后使用 justify-contentalign-items 实现水平垂直居中

.outer { 
   width: 400px; 
   height: 400px; 
   background-color: #888;
   display: flex; 
   justify-content: center; 
   align-items: center; 
 } 
.inner { 
   width: 100px; 
   height: 100px; 
   background-color: orange; 
}

方法二:父容器开启 flex 布局,随后子元素 margin: auto

.outer { 
   width: 400px; 
   height: 400px; 
   background-color: #888; 
   display: flex; 
 } 
.inner { 
   width: 100px; 
   height: 100px; 
   background-color: orange; 
   margin: auto; 
 }

11 伸缩性

11.1 flex-basis(基)

  • 概念: flex-basis 设置的是主轴方向的基准长度,若主轴为水平方向,就会覆盖宽度,让宽度失效;若主轴为垂直方向,就会覆盖高度,让高度失效。
  • 备注:主轴横向:宽度失效;主轴纵向:高度失效 。
  • 作用:浏览器根据这个属性设置的值,计算主轴上是否有多余空间,默认值 auto ,即:伸缩项目的宽或高。

11.2 flex-grow(伸)

  • 概念: flex-grow 定义伸缩项目的放大比例,默认为 0 ,即:纵使主轴存在剩余空间,也不拉伸 (放大)。
  • 规则:
    1. 若所有伸缩项目的 flex-grow 值都为 1 ,则:它们将等分剩余空间(如果有空间的话)。
    2. 若三个伸缩项目的 flex-grow 值分别为: 1 、 2 、 3 ,则:分别瓜分到: 1/6 、 2/6 、 3/6 的空间。

11.3 flex-shrink(缩)

  • 概念: flex-shrink 定义了项目的压缩比例,默认为 1 ,即:如果空间不足,该项目将会缩小。(要实现拉伸,得将flex-wrap设置nowrap;浏览区以实际内容区分,且缩的极限是内容实际大小)
  • 注意: 收缩项目的计算,略微复杂一点,我们拿一个场景举例:

例如: 三个收缩项目,宽度分别为: 200px300px200px ,它们的 flex-shrink 值分别 为: 1 、 2 、 3

若想刚好容纳下三个项目,需要总宽度为 700px ,但目前容器只有 400px ,还差 300px 所以每个人都要收缩一下才可以放下,具体收缩的值,这样计算:

  1. 计算分母: (200×1) + (300×2) + (200×3) = 1400
  2. 计算比例:
    • 项目一: (200×1) / 1400 = 比例值1
    • 项目二: (300×2) / 1400 = 比例值2
    • 项目三: (200×3) / 1400 = 比例值3
  3. 计算最终收缩大小:
    • 项目一需要收缩: 比例值1 × 300
    • 项目二需要收缩: 比例值2 × 300
    • 项目三需要收缩: 比例值3 × 300

12 flex复合属性

flex 是复合属性,复合了: flex-growflex-shrinkflex-basis 三个属性,默认值为 0 1 auto

  • 如果写 flex:1 1 auto ,则可简写为: flex:auto
  • 如果写 flex:1 1 0 ,则可简写为: flex:1
  • 如果写 flex:0 0 auto ,则可简写为: flex:none
  • 如果写 flex:0 1 auto ,则可简写为: flex:0 auto —— 即 flex 初始值。
  • 如何实现伸缩项目的宽度比例不同? 给每个伸缩项目分别设置flex属性,即可。 设置绳索项目宽度
  • 如何实现伸缩项目的高度比例不同? 将伸缩容器的主轴方向改为column,再给每个伸缩项目分别设置flex属性,即可。 设置绳索项目高度

13 项目排序

order 属性定义项目的排列顺序。数值越小,排列越靠前,默认为 0

14 单独对齐

通过 align-self 属性,可以单独调整某个伸缩项目的对齐方式 默认值为 auto ,表示继承父元素的 align-items 属性。

15 案例: 抖音解决方案

15.1 视觉效果

抖音解决方案视觉效果

15.2 准备素材

抖音解决方案素材

15.3 制作思路

抖音解决方案制作思路

15.4 编码实现

1.整体布局


<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

li {
  list-style: none;
}

.box {
  margin: 50px auto;
  width: 1200px;
  height: 418px;
  border: 1px solid #ddd;
  border-radius: 10px;
}
</style>

<div class="box"></div>
  1. 列表布局

<style>
.box ul {
  display: flex;
  /* 弹性盒子换行 */
  flex-wrap: wrap;
  /* 调整主轴对齐方式 */
  justify-content: space-between;

  /* 调整 行对齐方式 */
  align-content: space-between;

  padding: 90px 40px 90px 60px;
  height: 418px;
}

.box li {
  display: flex;
  width: 500px;
  height: 88px;
  /* background-color: pink; */
}
</style>

<div class="box">
    <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
  </ul>
</div>
  1. 内容样式

<style>
.box .pic {
  margin-right: 15px;
}

.box .text h4 {
  line-height: 40px;
  font-size: 20px;
  font-weight: 400;
  color: #333;
}

.box .text p {
  font-size: 14px;
  color: #666;
}
</style>

<ul>
  <li>
    <div class="pic">
      <img src="./images/1.svg" alt="">
    </div>
    <div class="text">
      <h4>一键发布多端</h4>
      <p>发布视频到抖音短视频、西瓜视频及今日头条</p>
    </div>
  </li>
  <li>
    <div class="pic">
      <img src="./images/2.svg" alt="">
    </div>
    <div class="text">
      <h4>管理视频内容</h4>
      <p>支持修改已发布稿件状态和实时查询视频审核状态</p>
    </div>
  </li>
  <li>
    <div class="pic">
      <img src="./images/3.svg" alt="">
    </div>
    <div class="text">
      <h4>发布携带组件</h4>
      <p>支持分享内容携带小程序、地理位置信息等组件,扩展内容及突出地域性</p>
    </div>
  </li>
  <li>
    <div class="pic">
      <img src="./images/4.svg" alt="">
    </div>
    <div class="text">
      <h4>数据评估分析</h4>
      <p>获取视频在对应产品内的数据表现、获取抖音热点,及时进行表现评估</p>
    </div>
  </li>
</ul>

为什么选择 tsup?

作者 EricXJ
2025年3月30日 18:03

前端构建工具技术选型:为什么选择 tsup?

目录

  1. 什么是 tsup?
  2. 核心优势
  3. 与其他工具对比
  4. 适用场景
  5. 快速配置示例
  6. 何时不推荐使用 tsup?
  7. 总结

1. 什么是 tsup?

tsup 是一个零配置的 TypeScript 构建工具,基于 esbuild 实现。它能快速打包 TypeScript/JavaScript 代码,自动生成类型声明文件(.d.ts),并支持输出多种模块格式(ESM、CJS、IIFE)。


2. 核心优势

特性 说明
极速构建 基于 esbuild,比传统工具快 10~100 倍
🚀 零配置 开箱即用 TypeScript,无需配置 Loader/Plugin
📦 类型声明生成 通过 --dts 参数一键生成 .d.ts 文件
🔄 多格式支持 同时输出 ESM、CJS 等格式,适配不同环境
🛠 开发友好 内置监听模式(--watch)、代码压缩(--minify

3. 与其他工具对比

3.1 功能对比表

工具 构建速度 配置复杂度 类型生成 多格式输出 适用场景
tsup ⚡️ 极快 ✅ 自动 ✅ 支持 库、CLI 工具
esbuild ⚡️ 极快 ❌ 需插件 ✅ 支持 底层工具链
rollup 🏃 较快 ✅ 需插件 ✅ 支持 复杂库打包
webpack 🐢 较慢 ❌ 需配置 ✅ 支持 企业级应用

3.2 配置对比示例

tsup 配置
// tsup.config.ts
export default {
  entry: ["src/index.ts"], // 入口文件
  format: ["esm", "cjs"],  // 输出格式
  dts: true,               // 生成类型声明
  clean: true,             // 清理旧文件
};

运行命令

tsup
等效的 Rollup 配置
// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';

export default [
  {
    input: 'src/index.ts',
    output: [
      { file: 'dist/index.esm.js', format: 'esm' },
      { file: 'dist/index.cjs.js', format: 'cjs' }
    ],
    plugins: [typescript()]
  },
  {
    input: 'src/index.ts',
    output: [{ file: 'dist/index.d.ts', format: 'esm' }],
    plugins: [dts()]
  }
];

运行命令

rollup -c

4. 适用场景

✅ 推荐使用

  • 开发 TypeScript 库:快速生成类型声明和多格式模块。
  • CLI 工具打包:轻量级输出,无需复杂优化。
  • 原型验证:零配置快速验证想法。

❌ 不推荐使用

  • 复杂前端应用:需要代码分割、静态资源处理时,选择 Vite/Webpack。
  • 深度定制构建流程:需精细控制打包步骤时,选择 Rollup/esbuild。

5. 快速配置示例

5.1 基础场景:打包 TypeScript 库

# 安装
npm install tsup --save-dev

# 配置
npx tsup src/index.ts --format esm,cjs --dts

5.2 进阶场景:监听模式 + 代码压缩

npx tsup src/index.ts --watch --minify

6. 何时不推荐使用 tsup?

场景 替代方案 理由
需要处理 CSS/图片 Vite、Webpack tsup 不擅长静态资源处理
需要代码分割和懒加载 Rollup、Webpack tsup 不支持应用级优化
需要自定义插件扩展 esbuild、Rollup tsup 插件生态较新,功能有限

7. 总结

选择 tsup 如果

  • 你正在开发一个 TypeScript 库或 CLI 工具。
  • 你希望零配置、极速完成打包。
  • 你需要自动生成类型声明文件。

避免 tsup 如果

  • 项目需要处理复杂资源(CSS、图片)。
  • 需要深度定制构建流程或使用特定插件。

👉 终极建议:对于 90% 的库和工具场景,tsup 是更高效的选择!

90%前端不知道的BFC核心原理与实战技巧

作者 bug_kada
2025年3月30日 17:52

BFC到底是什么东西

BFC(Block Formatting Context,块级格式化上下文)是Web页面中一个独立的渲染区域,它规定了内部块级元素的布局方式,并且这个区域的布局不会影响到外部元素。

简单来说,BFC就像页面中的一个隔离的容器,容器内的元素布局不会影响到容器外的元素,反之亦然。这种隔离特性使得BFC成为解决多种CSS布局问题的有力工具。

BFC的核心特点:

  • 内部的Box会在垂直方向一个接一个地放置
  • Box垂直方向的距离由margin决定,属于同一个BFC的两个相邻Box的margin会发生重叠
  • BFC的区域不会与float box重叠
  • BFC就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素

怎样触发BFC

不是所有的元素都能形成BFC,只有当元素满足以下至少一个条件时,才会创建一个新的BFC:

/* 1. 根元素(html)本身就是BFC */
html {
  /* 隐式创建BFC */
}

/* 2. 浮动元素(float不为none) */
.float-element {
  float: left; /* 或 right */
}

/* 3. 绝对定位元素(position为absolute或fixed) */
.absolute-element {
  position: absolute;
}

/* 4. display为inline-block、table-cell、table-caption、flex、inline-flex等 */
.inline-block-element {
  display: inline-block;
}

/* 5. overflow不为visible的块级元素 */
.overflow-element {
  overflow: hidden; /* 或 auto, scroll */
}

/* 6. 表格单元格(display: table-cell) */
.table-cell {
  display: table-cell;
}

/* 7. 弹性盒子(display: flex或inline-flex) */
.flex-container {
  display: flex;
}

/* 8. 网格布局(display: grid或inline-grid) */
.grid-container {
  display: grid;
}

BFC的规则

理解BFC的工作原理需要掌握它的核心布局规则:

  1. 内部的Box垂直排列:在BFC中,块级元素会按照垂直方向一个接一个地放置。
<div class="bfc-container">
  <div class="box">Box 1</div>
  <div class="box">Box 2</div>
  <div class="box">Box 3</div>
</div>

<style>
.bfc-container {
  overflow: hidden; /* 触发BFC */
  background: #f0f0f0;
}
.box {
  width: 100px;
  height: 100px;
  margin: 10px;
  background: lightblue;
}
</style>

image.png 2. 垂直方向上的距离由margin决定:属于同一个BFC的两个相邻Box的垂直margin会发生重叠。

<div class="box1">Box 1</div>
<div class="box2">Box 2</div>

<style>
.box1, .box2 {
  width: 100px;
  height: 100px;
  margin: 20px;
  background: lightblue;
}
</style>

屏幕截图 2025-03-30 173525.png 它们之间的垂直间距是20px(不是40px),说明margin发生了重叠。

  1. BFC区域不会与float box重叠:BFC会避开浮动元素。
<div class="float-left">浮动元素</div>
<div class="bfc-content">BFC内容</div>

<style>
.float-left {
  float: left;
  width: 100px;
  height: 100px;
  background: lightcoral;
}
.bfc-content {
  overflow: hidden; /* 触发BFC */
  height: 200px;
  background: lightblue;
}
</style>

左侧红色浮动方块,右侧蓝色BFC内容区域避开浮动元素,不会重叠。

image.png

  1. 计算BFC高度时,浮动元素也参与计算:这是清除浮动的原理。
<div class="bfc-container">
  <div class="float-child">浮动子元素</div>
</div>

<style>
.bfc-container {
  overflow: hidden; /* 触发BFC */
  background: #f0f0f0;
  border: 1px solid #ccc;
}
.float-child {
  float: left;
  width: 100px;
  height: 100px;
  background: lightblue;
}
</style>

容器包裹住了浮动元素,没有出现高度塌陷。

image.png

BFC解决了什么问题

BFC在CSS布局中主要解决了三类核心问题,这些问题的本质都是由于常规文档流布局的局限性导致的。下面我们深入分析每种问题场景及BFC的解决方案。

1. 浮动导致的高度塌陷问题

问题本质
当父元素包含浮动子元素时,父元素的高度计算会忽略这些浮动元素,导致"高度塌陷"现象。这是因为浮动元素脱离了常规文档流,父元素无法感知其存在。

传统解决方案的局限
常用的clearfix技巧需要添加额外的空元素或使用伪元素清除浮动,这种方式虽然有效但不够优雅,增加了无意义的DOM节点或CSS规则。

BFC解决方案
触发父元素的BFC可以强制其包含浮动子元素。这是因为BFC在计算自身高度时,会将浮动子元素的高度纳入计算范围。

.container {
  overflow: hidden; /* 触发BFC */
  border: 1px solid #ccc;
}
.float-child {
  float: left;
  width: 100px;
  height: 150px;
}

优势分析

  1. 无需额外DOM节点
  2. 代码更简洁直观
  3. 符合CSS的设计哲学(通过自身属性解决问题)

2. Margin边距重叠问题

问题本质
在常规文档流中,相邻块级元素的垂直margin会发生合并现象(margin collapsing),这经常导致实际间距与预期不符。

典型场景

  • 相邻兄弟元素之间
  • 父元素与第一个/最后一个子元素之间
  • 空块级元素的上下margin

BFC解决方案原理
将元素放入不同的BFC中可以阻止margin合并。因为BFC创建了独立的布局环境,其内部元素与外部元素的margin不再属于同一个上下文。

<div class="box" style="margin-bottom: 20px;">Box A</div>
<div style="overflow: hidden;"> <!-- 创建新BFC -->
  <div class="box" style="margin-top: 30px;">Box B</div>
</div>

实际效果
此时两个box之间的垂直间距将是50px(20+30),而不是合并后的30px。

3. 自适应多栏布局

问题本质
在实现类似"左侧固定+右侧自适应"的布局时,常规文档流会导致右侧内容与左侧浮动元素重叠。

传统方案缺陷
使用margin-left或calc计算宽度需要明确知道左侧栏宽度,缺乏灵活性。

BFC解决方案机制
BFC区域不会与浮动元素重叠的特性,使得右侧内容能自动填满剩余空间。

.left {
  float: left;
  width: 200px;
}
.right {
  overflow: hidden; /* 触发BFC */
}

技术细节

  1. 浮动元素占据物理空间但不影响BFC布局
  2. BFC元素会自动计算避开浮动区域后的可用宽度
  3. 不需要预先知道浮动元素的具体尺寸

总结

BFC是CSS布局中一个强大但常被忽视的概念。通过理解BFC的触发条件和布局规则,我们可以:

  1. 解决浮动导致的高度塌陷问题
  2. 控制margin重叠行为
  3. 创建自适应的多栏布局
  4. 隔离元素,防止外部布局影响内部

现代CSS布局技术如Flexbox和Grid也在内部创建了BFC,但理解BFC的基本原理仍然对解决布局问题非常有帮助。

❌
❌