普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月28日掘金 前端

不止是代码堆放:带你全面掌握 Monorepo 核心技术与选型

作者 明月_清风
2025年12月28日 11:43

在前端工程化日益复杂的今天,很多开发者对 Monorepo 的第一印象往往是:“不就是把好几个项目的代码塞进一个 Git 仓库吗?”

如果仅仅是“物理堆放”,那不仅不能提效,反而会带来权限混乱和构建缓慢的灾难。真正的 Monorepo 是一套工程化管理方案,它通过精妙的工具链,解决了多项目协作中代码复用、依赖同步和版本管理的痛点。


一、 为什么我们需要 Monorepo?

在传统的 Multirepo(多仓库)模式下,如果你维护着一个组件库 UI-Lib 和三个业务项目,流程通常是这样的:

  1. 修改 UI-Lib 的代码。
  2. 更新版本号,发布到 npm。
  3. 到三个业务项目中分别执行 npm update
  4. 如果发现 Bug,重复上述步骤……

这种“发布-拉取”的循环极大地浪费了开发时间。而 Monorepo 将它们置于同一工作区:

  • 实时反馈:修改组件库,业务项目立即生效,无需发布 npm。
  • 原子重构:当你需要改动一个核心接口时,可以在同一个 Commit 中修改所有受影响的子项目,确保系统始终处于可用状态。

二、 核心技术:支撑 Monorepo 的三根支柱

要玩转 Monorepo,你必须掌握以下三个层面的技术选型:

1. 依赖管理层(Workspaces)

这是 Monorepo 的心脏。它负责将仓库内的各个 Package 互相“软链接”,并统一管理第三方依赖。

  • pnpm Workspaces (首选) :通过内容寻址存储,极大节省磁盘空间,并能严格禁止未声明的依赖访问(解决幻影依赖问题)。
  • Yarn Workspaces:老牌方案,生态完善。

2. 任务编排层(Build Pipeline)

当仓库里有几十个项目时,运行 npm run build 如果要等半小时,那是不可接受的。我们需要“聪明”的工具:

  • Turborepo:由 Vercel 出品。核心能力是 指纹缓存(Hashing) ——如果代码没变,直接从缓存读取结果;以及 并行执行——自动分析依赖图谱,让互不影响的项目同时构建。
  • Nx:功能更全面的“重型武器”,支持可视化依赖图谱分析,适合对构建流程有极致定制化需求的大厂。

3. 发布与版本层(Versioning)

如何决定哪个包需要发版?如何自动生成 Changelog?

  • Changesets:非常推荐。它通过在 PR 中添加描述文件,自动化处理版本更新和发布流程,尤其适合开源项目或多人协作。

三、 选型指南:你真的需要它吗?

Monorepo 不是银弹,它也有自己的“适用边界”:

维度 建议使用 Monorepo 建议保持 Multirepo
项目关联度 存在大量共享组件、工具类、类型定义。 各项目业务独立,几乎没有代码交集。
技术栈 统一使用 React 或 Vue,规范一致。 混合了多种框架或不同年代的老旧项目。
团队规模 中小型团队,追求快速迭代与低沟通成本。 跨部门超大型团队,对代码权限有极其严格限制。
构建压力 具备配置 Turborepo 等工具的能力。 没有专人维护配置,不愿增加构建工具复杂度。

四、 落地建议:从 0 到 1 的最优路径

如果你准备在团队落地 Monorepo,建议采用这套**“黄金组合”**:

  1. 包管理:使用 pnpm。它的速度和依赖处理能力是目前社区的共识。
  2. 脚手架:使用 Turborepo。它的学习曲线最平缓,只需一个 turbo.json 就能让构建速度起飞。
  3. 语言:全量开启 TypeScript。Monorepo 的最大优势之一就是跨项目的类型安全——你修改了 A 包的接口定义,B 包在编译阶段就会直接报错。

结语

Monorepo 的本质是将管理复杂度转化为了工具链复杂度。虽然初期配置需要一定成本,但它带来的协同效率和代码复用率是无可比拟的。

你想了解如何用 pnpm + Turborepo 搭建一个最小可运行的 Monorepo 模板吗?我可以为你提供具体的配置步骤。

Single-SPA 学习总结

作者 鹏北海
2025年12月28日 10:22

一、什么是 Single-SPA

Single-SPA 是微前端领域的"鼻祖"框架,它是一个用于前端微服务化的 JavaScript 框架,允许你在同一个页面中使用多个框架(React、Vue、Angular 等),而不需要刷新页面。

核心定位

  • JS Entry 方案:通过加载子应用的 JS 入口文件来集成
  • 路由驱动:基于 URL 变化自动激活/卸载子应用
  • 框架无关:支持任意前端框架,只要导出生命周期函数

二、核心概念

1. 三种模块类型

类型 说明 生命周期 使用场景
Application 有 UI 的微前端应用 bootstrap → mount → unmount 独立页面/功能模块
Parcel 可复用的 UI 组件 手动挂载/卸载 跨应用共享组件
Utility 无 UI 的共享模块 工具函数、API 封装、状态管理

2. 生命周期函数

每个 Application 必须导出三个生命周期函数:

// 应用首次加载时调用(只执行一次)
export async function bootstrap(props) {
  console.log("应用初始化");
}

// 路由匹配时调用(每次激活都执行)
export async function mount(props) {
  // 渲染 DOM
  ReactDOM.createRoot(props.container).render(<App />);
}

// 路由离开时调用
export async function unmount(props) {
  // 清理 DOM 和副作用
  root.unmount();
}

3. 生命周期流程

首次加载:  load → bootstrap → mount
路由切换:  unmount → mount (另一个应用)
完全卸载:  unmount → unload (可选)

三、核心原理

1. 路由劫持

Single-SPA 通过劫持浏览器的路由事件来实现应用切换:

// 劫持 history API
const originalPushState = window.history.pushState;
window.history.pushState = function (...args) {
  originalPushState.apply(this, args);
  // 触发应用切换逻辑
  reroute();
};

// 监听 popstate 事件
window.addEventListener("popstate", reroute);

2. 应用状态机

每个应用都有状态流转:

NOT_LOADED → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED
    → BOOTSTRAPPING → NOT_MOUNTED → MOUNTING → MOUNTED
    → UNMOUNTING → NOT_MOUNTED

3. 应用加载与执行

// 注册应用
registerApplication({
  name: "app1",
  app: () => import("./app1/main.js"), // 动态导入
  activeWhen: "/app1", // 激活条件
  customProps: { authToken: "xxx" }, // 传递给子应用的 props
});

// 启动
start();

4. Import Map 模块解析

Single-SPA 推荐使用 Import Map 来管理模块 URL:

<script type="importmap">
  {
    "imports": {
      "react": "https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js",
      "@myorg/app1": "https://mycdn.com/app1/main.js"
    }
  }
</script>

浏览器会根据 Import Map 解析 import '@myorg/app1' 到实际 URL。


四、Single-SPA 生态

1. 核心包

包名 作用
single-spa 核心库,提供注册、启动、生命周期管理
single-spa-layout 声明式布局引擎,用 HTML 定义路由和布局

2. 框架适配器

适配器 框架
single-spa-react React
single-spa-vue Vue 2/3
single-spa-angular Angular
single-spa-svelte Svelte

3. 开发工具

工具 作用
import-map-overrides 运行时覆盖 Import Map,方便本地调试
import-map-injector 支持从远程 URL 加载 Import Map
create-single-spa 官方脚手架 CLI

五、项目结构

推荐的目录结构

micro-frontends/
├── root-config/              # 基座应用
│   ├── src/
│   │   ├── index.ejs         # HTML 模板 + 布局配置
│   │   └── root-config.js    # 应用注册和启动
│   └── webpack.config.js
│
├── navbar/                   # 导航栏应用(始终显示)
├── app1/                     # 业务应用 1
├── app2/                     # 业务应用 2
│
├── shared/                   # 共享模块
│   ├── api/                  # API 封装
│   └── styleguide/           # 公共样式/组件
│
└── shared-dependencies/      # Import Map 配置
    └── importmap.json

六、创建 Single-SPA 项目

方式一:使用官方脚手架 create-single-spa(推荐)

# 全局安装
npm install -g create-single-spa

# 或使用 npx
npx create-single-spa

脚手架会引导你选择:

  1. 项目类型

    • single-spa root config - 基座应用
    • single-spa application / parcel - 子应用
    • in-browser utility module - 工具模块
  2. 框架:React / Vue / Angular / Svelte / None

  3. 包管理器:npm / yarn / pnpm

创建基座应用

npx create-single-spa --moduleType root-config
# 选择组织名称,如 @myorg

创建 React 子应用

npx create-single-spa --moduleType app-parcel --framework react

创建 Vue 子应用

npx create-single-spa --moduleType app-parcel --framework vue

方式二:手动配置

如果需要更多控制,可以手动配置:

1. 基座应用 (root-config)

mkdir root-config && cd root-config
npm init -y
npm install single-spa single-spa-layout
npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin
// src/root-config.js
import { registerApplication, start } from "single-spa";
import {
  constructApplications,
  constructRoutes,
  constructLayoutEngine,
} from "single-spa-layout";

const routes = constructRoutes(document.querySelector("#single-spa-layout"));
const applications = constructApplications({
  routes,
  loadApp: ({ name }) => import(/* webpackIgnore: true */ name),
});

applications.forEach(registerApplication);
constructLayoutEngine({ routes, applications }).activate();
start();

2. React 子应用

npm install single-spa-react react react-dom
// src/main.js
import React from "react";
import ReactDOMClient from "react-dom/client";
import singleSpaReact from "single-spa-react";
import App from "./App";

const lifecycles = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: App,
  errorBoundary(err, info, props) {
    return <div>Error</div>;
  },
});

export const { bootstrap, mount, unmount } = lifecycles;

3. Vue 子应用

npm install single-spa-vue vue
// src/main.js
import { createApp, h } from "vue";
import singleSpaVue from "single-spa-vue";
import App from "./App.vue";

const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render() {
      return h(App);
    },
  },
});

export const { bootstrap, mount, unmount } = vueLifecycles;

方式三:参考官方示例仓库

示例 地址 说明
React 示例 github.com/react-micro… 完整的 React 微前端示例
Vue 示例 github.com/vue-microfr… 完整的 Vue 微前端示例
混合框架示例 github.com/polyglot-mi… React + Vue + Angular 混合

在线演示:


七、开发调试技巧

1. 使用 import-map-overrides

// 在浏览器控制台启用开发工具
localStorage.setItem("devtools", true);

// 刷新页面后,右下角会出现开发工具面板
// 可以将任意模块指向本地开发服务器

2. 本地开发流程

# 1. 启动本地子应用
cd my-app && npm start --port 9001

# 2. 访问线上/本地基座
# 3. 使用 import-map-overrides 将 @myorg/my-app 指向 localhost:9001

3. 独立运行子应用

子应用应该能够独立运行,方便开发调试:

// 判断是否在 single-spa 环境中
if (!window.singleSpaNavigate) {
  // 独立运行
  ReactDOM.createRoot(document.getElementById("root")).render(<App />);
}

八、Single-SPA vs 其他方案

特性 single-spa qiankun micro-app
Entry 类型 JS Entry HTML Entry HTML Entry
沙箱隔离 ❌ 无内置 ✅ Proxy 沙箱 ✅ 沙箱隔离
样式隔离 ❌ 无内置 ✅ Shadow DOM / Scoped ✅ 样式隔离
老项目改造 较高 极低 极低
灵活性 ★★★★★ ★★★★ ★★★
学习曲线 较陡 中等 较平缓

选择建议

  • 选 single-spa:需要最大灵活性、深入理解微前端原理、技术能力强的团队
  • 选 qiankun:企业级项目、多历史系统接入、需要开箱即用的沙箱和隔离
  • 选 micro-app:快速上手、组件化思维、Vue 技术栈为主

九、最佳实践

1. 共享依赖

通过 Import Map 共享公共依赖,避免重复加载:

{
  "imports": {
    "react": "https://cdn.jsdelivr.net/npm/react@18/...",
    "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18/..."
  }
}

2. 样式隔离

Single-SPA 不提供内置样式隔离,需要自行处理:

  • CSS Modules
  • CSS-in-JS (styled-components, emotion)
  • BEM 命名规范
  • 添加应用前缀

3. 全局状态管理

  • 使用 Utility 模块共享状态
  • 发布订阅模式 (EventBus)
  • 通过 customProps 传递

4. 错误边界

singleSpaReact({
  // ...
  errorBoundary(err, info, props) {
    return <ErrorPage error={err} />;
  },
});

十、学习资源

官方资源

视频教程

相关规范

Vuex 详解:现代 Vue.js 应用的状态管理方案

2025年12月28日 00:09

引言

在现代前端应用开发中,随着应用复杂度的不断提升,组件间的数据共享和状态管理变得越来越重要。Vuex 作为 Vue.js 官方推荐的状态管理库,为 Vue 应用提供了一种集中式、可预测的状态管理模式。本文将结合提供的代码实例,深入探讨 Vuex 的核心概念、工作原理以及实际应用。

一、Vuex 的基本概念

Vuex 是一个专门为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

1.1 为什么需要 Vuex?

从提供的代码中可以看到,我们有两个组件 MyCount 和 MyPersons,它们都需要访问和修改共享的状态数据:

  • MyCount 组件需要访问和修改 sum(求和值)
  • MyPersons 组件需要访问和修改 persons(人员列表)
  • 两个组件都需要相互访问对方的状态数据

如果没有 Vuex,这种跨组件的数据共享需要通过复杂的父子组件传值或事件总线来实现,随着应用规模扩大,代码将变得难以维护。Vuex 通过提供一个全局的单例状态树,优雅地解决了这个问题。

1.2 Vuex 的核心概念

从 index.js 文件中可以看到,Vuex 包含以下几个核心部分:

javascript

复制下载

export default new Vuex.Store({
  actions,
  mutations,
  state,
  getters
})

State(状态) :应用的单一状态树,包含所有需要共享的数据。

Mutations(变更) :唯一更改状态的方法,必须是同步函数。

Actions(动作) :提交 mutations,可以包含任意异步操作。

Getters(获取器) :从 state 中派生出一些状态,类似于计算属性。

二、Vuex 的基本使用

2.1 初始化与配置

首先需要在项目中安装和配置 Vuex:

javascript

复制下载

import Vuex from 'vuex'
import Vue from 'vue'

// 使用插件
Vue.use(Vuex)

const store = new Vuex.Store({
  // 配置项
})

2.2 组件中访问状态

在组件中,可以通过 $store 访问 Vuex 的状态:

javascript

复制下载

// 在计算属性中直接访问
computed: {
  persons(){
    return this.$store.state.persons
  },
  sum(){
    return this.$store.state.sum
  }
}

2.3 组件中修改状态

修改状态有两种方式:

方式一:通过 actions(可包含异步操作)

javascript

复制下载

methods: {
  incrementOdd(){
    this.$store.dispatch('jiaOdd', this.n)
  }
}

方式二:直接提交 mutations(同步操作)

javascript

复制下载

methods: {
  add(){
    const personObj = {id:nanoid(), name:this.name};
    this.$store.commit('ADD_PERSON', personObj)
  }
}

三、四个 Map 辅助函数的使用

为了简化代码,Vuex 提供了四个辅助函数:

3.1 mapState

将 state 映射为组件的计算属性:

javascript

复制下载

// 数组写法(简写)
...mapState(['sum', 'persons'])

// 对象写法(可重命名)
...mapState({currentSum: 'sum', personList: 'persons'})

3.2 mapGetters

将 getters 映射为组件的计算属性:

javascript

复制下载

...mapGetters(['bigSum'])

3.3 mapMutations

将 mutations 映射为组件的方法:

javascript

复制下载

// 对象写法
...mapMutations({increment: 'JIA', decrement: 'JIAN'}),

// 数组写法
...mapMutations(['JIA', 'JIAN'])

3.4 mapActions

将 actions 映射为组件的方法:

javascript

复制下载

...mapActions({incrementOdd: 'jiaOdd', incrementWait: 'jiaWait'})

四、Vuex 模块化与命名空间

随着应用规模扩大,将所有状态集中在一个文件中会变得难以维护。Vuex 允许我们将 store 分割成模块,每个模块拥有自己的 state、mutations、actions、getters。

4.1 模块化配置

从重构后的代码可以看到,我们将 store 拆分成了两个模块:

count.js - 处理计数相关的状态
person.js - 处理人员相关的状态

javascript

复制下载

// index.js
import countAbout from './count'
import personAbout from './person'

export default new Vuex.Store({
  modules: {
    countAbout,
    personAbout
  }
})

4.2 命名空间

通过设置 namespaced: true 开启命名空间,可以避免不同模块之间的命名冲突:

javascript

复制下载

// count.js
export default {
  namespaced: true,
  // ... 其他配置
}

4.3 模块化后的访问方式

访问 state:

javascript

复制下载

// 直接访问
persons(){
  return this.$store.state.personAbout.persons
}

// 使用 mapState(需要指定命名空间)
...mapState('personAbout', ['persons'])

访问 getters:

javascript

复制下载

// 直接访问
firstPersonName(){
  return this.$store.getters['personAbout/firstPersonName'];
}

// 使用 mapGetters
...mapGetters('personAbout', ['firstPersonName'])

提交 mutations:

javascript

复制下载

// 直接提交
this.$store.commit('personAbout/ADD_PERSON', personObj);

// 使用 mapMutations
...mapMutations('countAbout', {increment: 'JIA', decrement: 'JIAN'})

分发 actions:

javascript

复制下载

// 直接分发
this.$store.dispatch('personAbout/addWangPerson', personObj);

// 使用 mapActions
...mapActions('countAbout', {incrementOdd: 'jiaOdd', incrementWait: 'jiaWait'})

五、实际应用案例分析

5.1 计数器模块(count.js)

这个模块展示了如何处理同步和异步的状态更新:

javascript

复制下载

// 同步操作
JIA(state, value) {
  state.sum += value;
},

// 异步操作(通过 action)
jiaWait(context, value) {
  setTimeout(() => {
    context.commit('JIAWAIT', value);
  }, 500);
}

5.2 人员管理模块(person.js)

这个模块展示了更复杂的业务逻辑:

javascript

复制下载

// 条件性提交 mutation
addWangPerson(context, value) {
  if (value.name.indexOf('王') === 0) {
    context.commit('ADD_PERSON', value);
  } else {
    alert('添加的人必须姓王!');
  }
},

// 异步 API 调用
addServer(context) {
  axios.get('https://api.uixsj.cn/hitokoto/get?type=social').then(
    response => {
      const word = {id: nanoid(), name: response.data};
      context.commit('ADD_PERSON', word);
    },
    error => {
      alert(error.message);
    }
  )
}

六、Vuex 的最佳实践

6.1 严格遵循数据流

Vuex 强制实施一种单向数据流:

  1. 组件派发 Action
  2. Action 提交 Mutation
  3. Mutation 修改 State
  4. State 变化触发组件更新

6.2 合理使用模块化

  • 按功能或业务逻辑划分模块
  • 为所有模块启用命名空间
  • 保持模块的独立性

6.3 异步操作的处理

  • 所有异步逻辑放在 Actions 中
  • 保持 Mutations 的纯粹性(只做状态变更)
  • 合理处理异步错误

6.4 表单处理策略

在 MyPersons.vue 中,我们看到了典型的表单处理模式:

javascript

复制下载

add(){
  const personObj = {id: nanoid(), name: this.name};
  this.$store.commit('personAbout/ADD_PERSON', personObj);
  this.name = ''; // 清空表单
}

七、Vuex 的优缺点分析

7.1 优点

  1. 集中式状态管理:所有状态变化都可以追踪和调试
  2. 组件通信简化:跨组件数据共享变得简单
  3. 可预测的状态变化:通过严格的规则保证状态变化的可预测性
  4. 插件生态丰富:支持时间旅行、状态快照等高级功能
  5. TypeScript 支持:提供完整的类型定义

7.2 缺点

  1. 学习曲线:需要理解 Flux 架构思想
  2. 代码冗余:简单的应用可能不需要 Vuex
  3. 样板代码:需要编写一定量的模板代码
  4. 性能考虑:大型状态树可能影响性能

八、替代方案与未来趋势

8.1 Vuex 的替代方案

  1. Pinia:Vue.js 的下一代状态管理库,更加轻量且对 TypeScript 友好
  2. Composition API:使用 reactive 和 provide/inject 实现简单的状态共享
  3. 事件总线:适合小型应用的简单通信

8.2 Vuex 4 和 Vuex 5

  • Vuex 4 支持 Vue 3,API 基本保持不变
  • Vuex 5(开发中)将提供更好的 TypeScript 支持和更简洁的 API

结论

Vuex 作为 Vue.js 生态中成熟的状态管理方案,为构建中大型 Vue 应用提供了可靠的架构基础。通过本文的分析,我们可以看到 Vuex 如何:

  1. 提供集中式的状态管理
  2. 通过严格的规则保证状态变化的可预测性
  3. 通过模块化支持大型应用的状态管理
  4. 提供丰富的辅助函数简化开发

在实际项目中,是否使用 Vuex 应该根据应用规模和复杂度来决定。对于小型应用,简单的组件通信可能就足够了;但对于中大型应用,Vuex 提供的结构化状态管理方案将大大提升代码的可维护性和可扩展性。

随着 Vue 3 的普及,开发者也可以考虑使用 Composition API 或 Pinia 等更现代的解决方案,但 Vuex 的核心思想和设计模式仍然是值得学习和借鉴的宝贵经验。

告别重复传参!用柯里化提升代码优雅度

作者 鱼鱼块
2025年12月27日 23:54

柯里化:让函数“慢慢来”,一次只吃一口

在编程世界里,我们常常会遇到这样一种场景:一个函数需要多个参数才能完成任务。但有时候,这些参数并不会一下子全部准备好——可能今天知道第一个,明天拿到第二个,后天才凑齐全部。

这时候,柯里化(Currying) 就派上用场了。它就像一位耐心的厨师,不急着把整道菜做完,而是先记住你已经给的食材,等你把剩下的材料陆续送来,再一锅炒好。


从最简单的加法说起

假设我们有一个普通的加法函数:

function add(a, b) {
  return a + b;
}
console.log(add(1, 2)); // 输出 3

这很直接:两个数一起传进去,立刻出结果。但如果我只能先给你 a,过一会儿再告诉你 b 呢?普通函数就无能为力了。

于是我们可以手动“柯里化”一下:

function add(a) {
  return function(b) {
    return a + b;
  };
}
console.log(add(1)(2)); // 输出 3

你看,现在 add(1) 返回的是一个新函数,这个函数“记得”了 a = 1,等你再调用它传入 b,就能算出结果。这背后靠的是 闭包——内部函数可以“记住”外部函数的变量,即使外部函数已经执行完了。

这种写法虽然可行,但只适用于固定两个参数的情况。如果函数有三个、四个甚至更多参数,手动嵌套写起来会非常繁琐,而且难以复用


自动柯里化:通用解决方案

手动为每个函数写柯里化版本太麻烦了。有没有办法写一个“万能工具”,自动把任意多参函数变成可逐步传参的形式?

当然有!来看这个通用的 curry 函数:

function add(a, b, c, d) {
  return a + b + c + d;
}

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args); // 参数够了,直接执行
    }
    return (...rest) => curried(...args, ...rest); // 不够?继续收
  };
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)(4)); // 10
console.log(curriedAdd(1, 2)(3, 4)); // 10,也可以一次传多个

它是怎么工作的?

  • fn.length 是 JavaScript 中函数的一个属性,表示该函数声明时定义的参数个数(不包括剩余参数)。
  • 每次调用 curried,都会收集当前传入的参数(通过 ...args)。
  • 如果当前参数数量 ≥ 原函数所需参数数量,就立即执行 fn(...args)
  • 否则,返回一个新函数,这个新函数会把已有的 args 和后续传入的 rest 合并,再次调用 curried —— 这就是递归的思想。

只要收集到足够数量的参数,就立刻执行;否则,返回一个新函数继续等待。

🔍 注意:这个实现利用了 闭包 + 递归 的思想。每次调用都把已有的参数“存起来”,直到攒够为止。而闭包保证了这些中间参数不会丢失。


实战:日志函数的柯里化妙用

柯里化不只是炫技,它在实际开发中非常有用。比如处理日志:

const log = type => message => {
  console.log(`${type}: ${message}`);
};

const errorLog = log('ERROR');
const infoLog = log('info');

errorLog('接口异常');        // 输出: ERROR: 接口异常
infoLog('页面加载完成');     // 输出: info: 页面加载完成

这里,log 是一个柯里化函数。我们先固定日志类型(如 'ERROR'),得到一个专门打错误日志的函数 errorLog。以后只要传消息内容就行,不用每次都写类型。

优势在哪里?

  1. 减少重复代码:不需要每次写 log('error', 'xxx')
  2. 提高可读性errorLog('xxx')log('error', 'xxx') 更直观。
  3. 便于组合与复用:可以轻松创建不同级别的日志器,并在多个模块中共享。

总结:柯里化的三大核心

  1. 闭包:保存已传入的参数,不会被垃圾回收。
  2. 递归/链式调用:每次返回新函数,继续接收剩余参数。
  3. 退出条件:当参数数量达到原函数要求(fn.length),就执行并返回结果。

柯里化不是必须用的技术,但它能让你的函数更灵活、更具组合性。就像乐高积木——你可以先拼好一部分,等需要时再接上其他模块,最终搭出完整作品。

下次当你发现某个函数总是在不同地方传相同的前几个参数时,不妨试试柯里化——让函数学会“等一等”,说不定代码会变得更优雅!

static 和 lib/assets资源区别

作者 Robet
2025年12月27日 22:50

这是一个非常关键的问题!在 SvelteKit 项目中,static/ 目录和 src/lib/assets/(或任何 src/ 下的资源)处理方式完全不同,直接影响到:

  • 资源是否被 构建工具处理
  • 是否支持 哈希文件名(缓存优化)
  • 如何 引用资源路径
  • 是否能使用 ESM 导入语法

✅ 核心区别总结

特性 static/ 目录 src/lib/assets/(或其他 src/ 下)
是否经过 Vite 构建 ❌ 不处理,原样复制 ✅ 会经过 Vite 处理(压缩、哈希、优化)
输出位置 直接复制到 build/ 根目录 打包进 build/_app/assets/...
引用方式 绝对路径 /image.png 必须通过 ESM 导入import img from '$lib/assets/image.png'
文件名哈希 ❌ 无(不利于长期缓存) ✅ 有(如 image.abc123.png,利于 CDN 缓存)
适用场景 robots.txt、favicon.ico、sitemap.xml 等静态 Web 资源 应用内使用的图片、图标、字体等模块化资源

🔍 详细解释

1. static/ 目录 —— “纯静态 Web 根目录”

  • SvelteKit 在构建时,会原封不动地将 static/ 内容复制到输出目录(build/)的根部

  • 这些文件不会被 Vite 处理(不压缩、不重命名、不哈希)。

  • 浏览器通过 绝对路径 访问:

    <!-- 在 +layout.svelte 或 app.html 中 -->
    <link rel="icon" href="/favicon.ico" />
    <img src="/images/logo.png" />
    
  • 典型用途

    • favicon.ico
    • robots.txt
    • sitemap.xml
    • 第三方验证文件(如 google-site-verification=xxx.html
    • 需要固定 URL 的公开资源

⚠️ 如果你把应用内的图片放这里(如 /static/product.jpg),会导致:

  • 无法利用内容哈希 → 用户可能看到旧图(缓存问题)
  • 无法 tree-shaking 未使用的图片
  • 无法使用现代图像格式自动转换(如 WebP)

2. src/lib/assets/ —— “模块化资源”

  • 这些文件被视为 JavaScript 模块依赖

  • 必须通过 ESM import 语句 引用:

    <!-- 在 .svelte 或 .js 文件中 -->
    <script>
      import logo from '$lib/assets/logo.png';
    </script>
    
    <img src={logo} alt="Logo" />
    
  • Vite 会:

    • 为文件生成 唯一哈希名(如 logo.a1b2c3.png
    • 自动优化(压缩、转 WebP 等,需配置)
    • 只打包实际被引用的资源
    • 支持动态 import()(按需加载)
  • 输出路径示例:

    build/
    └── _app/
        └── assets/
            └── logo.a1b2c3.png
    

这是 SvelteKit 推荐的应用内资源管理方式!


🧪 对比示例

场景:在页面中显示一张 logo

❌ 错误做法(把应用图片放 static)

<!-- src/routes/+page.svelte -->
<img src="/logo.png" />  <!-- 来自 static/logo.png -->
  • 问题:URL 固定为 /logo.png,浏览器缓存后更新困难。

✅ 正确做法(用 $lib/assets/

<!-- src/routes/+page.svelte -->
<script>
  import logo from '$lib/assets/logo.png';
</script>
<img src={logo} />
  • 渲染结果:<img src="/_app/assets/logo.a1b2c3.png" />
  • 优势:文件名含哈希,更新即失效缓存,CDN 友好。

📁 推荐目录结构

project/
├── static/
│   ├── favicon.ico        ← 必须放这里(浏览器固定请求 /favicon.ico)
│   ├── robots.txt         ← 必须放这里
│   └── vercel.svg         ← 第三方验证文件
└── src/
    └── lib/
        └── assets/
            ├── images/
            │   ├── logo.png
            │   └── hero.jpg
            └── icons/
                └── star.svg

💡 特殊情况:什么时候用 static/ 存图片?

仅当你需要固定 URL 且不希望被构建工具处理时,例如:

  • 一个公开的分享图,URL 必须是 https://yoursite.com/share-banner.jpg
  • 第三方平台要求特定路径的图片(如微信公众号素材)
  • 非常大的背景图,不想被打包(但通常仍建议用 ESM + 动态导入)

否则,一律使用 $lib/assets/ + import


✅ 总结一句话

static/ 是给“Web 服务器”看的静态文件;
src/lib/assets/ 是给“你的应用代码”用的模块化资源。

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(九)

2025年12月27日 22:45

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(九)

Flutter: 3.35.7

今天的功能完成那基本功能就完成了,所以给出github链接:

https://github.com/yhtqw/FrontEndDemo/tree/main/flutter_demo/lib/pages/transform_use/widgets/multiple_transform

前面我们简单实现了图片元素和文本元素的新增,优先实现的功能,很多地方还可以优化。今天我来讨论一下文本元素的拖动缩放。

对于文本元素,文本元素的高度是由文本自身的样式来决定的,所以我们限制文本元素只能缩放宽度,高度自动计算。这样就会对缩放区域进行元素类型判断,如果是文本元素,那么只缩放宽度,高度自适应;如果是其他元素,则同上对宽度高度进行缩放。既然功能又区别,那么icon也要有区别,那么之前的元素操作区域得做出更改,新增这种特殊功能区分元素类型对应图标不一样的配置:

class ResponseAreaModel {
  // 其他省略...

  /// 元素类型不同展示不同的操作icon
  final Map<String, String>? iconConfig;

  // 其他省略...
}

class ConstantsConfig {
  // 其他省略...

  static final List<ResponseAreaModel> baseAreaList = [
    // 其他省略...

    // 缩放
    ResponseAreaModel(
      areaWidth: 20,
      areaHeight: 20,
      xRatio: 1,
      yRatio: 1,
      status: ElementStatus.scale.value,
      icon: 'assets/images/icon_scale.png',
      trigger: TriggerMethod.move,
      iconConfig: {
        ElementType.textType.type: 'assets/images/icon_scale_text.png',
      },
    ),

    // 其他省略...
  ],

  // 其他省略...
}

// 其他省略...

// 如果选中,则展示操作区域
if (selected) ...areaList.map((item) => Positioned(
  top: elementItem.elementHeight * item.yRatio - item.areaHeight / 2,
  left: elementItem.elementWidth * item.xRatio - item.areaWidth / 2,
  child: Container(
    width: item.areaWidth,
    height: item.areaHeight,
    alignment: Alignment.center,
    decoration: BoxDecoration(
      color: Colors.blueAccent,
      borderRadius: BorderRadius.circular(item.areaWidth / 2),
    ),
    child: Image.asset(
      // 判断对应类型的图片是否单独存在
      item.iconConfig?[elementItem.type] ?? item.icon,
      width: item.areaWidth - ConstantsConfig.areaIconMargin,
      height: item.areaHeight - ConstantsConfig.areaIconMargin,
      fit: BoxFit.scaleDown,
      color: Colors.white,
    ),
  ),
)),

运行效果:

image01.gif

这样我们就通过元素状态区分了特殊功能的图标展示,现在就是功能的实现了:

/// 处理元素缩放
///
/// 通过移动点坐标[x]和[y]与按下的初始坐标,
void _onScale({required double x, required double y}) {
  if (_currentElement?.type == ElementType.textType.type) {
    _onScaleText(x: x, y: y);
  } else {
    _onScaleBase(x: x, y: y);
  }
}

/// 抽取获取缩放需要的基础参数
(double, double, double, double, double) _getScaleParams({required double x, required double y}) {
  final double oWidth = _temporary!.width;
  final double oHeight = _temporary!.height;
  final double oX = _temporary!.x;
  final double oY = _temporary!.y;
  final double resizeRatio = _calcResizeRatio(x: x, y: y);

  return (oWidth, oHeight, oX, oY, resizeRatio);
}

/// 处理非文本元素的缩放
void _onScaleBase({required double x, required double y}) {
  if (_currentElement == null || _temporary == null) return;
  
  final (oWidth, oHeight, oX, oY, resizeRatio) = _getScaleParams(x: x, y: y);
  double newW = oWidth * resizeRatio;
  double newH = oHeight * resizeRatio;
  final double minSize = ConstantsConfig.minSize;

  // 以短边为基准来计算最小宽高
  if (oWidth <= oHeight && newW < minSize) {
    newW = minSize;
    newH = minSize * oHeight / oWidth;
  } else if (oHeight < oWidth && newH < minSize) {
    newH = minSize;
    newW = minSize * oWidth / oHeight;
  }

  // 以长边为基准来计算最大宽高
  if (oWidth >= oHeight && newW >= _transformWidth) {
    newW = _transformWidth;
    newH = _transformWidth * oHeight / oWidth;
  } else if (oHeight > oWidth && newH >= _transformHeight) {
    newH = _transformHeight;
    newW = _transformHeight * oWidth / oHeight;
  }

  if (
    newW == _currentElement?.elementWidth &&
      newH == _currentElement?.elementHeight
  ) {
    return;
  }

  _currentElement = _currentElement?.copyWith(
    elementWidth: newW,
    elementHeight: newH,
    x: oX - (newW - oWidth) / 2,
    y: oY - (newH - oHeight) / 2,
  );
  _onChange();
}

/// 文本元素的缩放
void _onScaleText({required double x, required double y}) {
  if (_currentElement == null || _temporary == null) return;
  
  final (oWidth, oHeight, oX, oY, resizeRatio) = _getScaleParams(x: x, y: y);
  double newW = oWidth * resizeRatio;
  final double minSize = ConstantsConfig.minSize;

  // 以短边为基准来计算最小宽高
  if (oWidth <= oHeight && newW < minSize) {
    newW = minSize;
  }

  // 以长边为基准来计算最大宽高
  if (oWidth >= oHeight && newW >= _transformWidth) {
    newW = _transformWidth;
  }

  final TextStyle style = _getTextStyle(_currentElement!.textOptions!);
  final (tempWidth, tempHeight) = TransformUtils.calculateTextSize(
    text: _currentElement!.textOptions!.text,
    style: style,
    maxWidth: newW,
  );

  _currentElement = _currentElement?.copyWith(
    elementWidth: newW,
    elementHeight: tempHeight,
    x: oX - (newW - oWidth) / 2,
    y: oY - (tempHeight - oHeight) / 2,
  );
  _onChange();
}

运行效果:

image02.gif

这样我们就实现了文本元素的拉伸效果。可以看到,随着功能的增加,可能就需要对之前的逻辑做更改,不过因为抽取了配置,很多地方就只需要加入少量的代码即可实现部分功能。

基础的功能差不多就完成了,接下来主功能就还有一个,那就是保存,我们不知道这个功能是否还存在编辑,如果不存在,直接将该结构转成图片返回即可,所以我们保存就直接将数据和图片都传递过去即可。一般这种大量数据存后端都是JSON字符串,所以我们直接转成json字符串存储:

Future<void> _onSave() async {
  if (_isLoading) return;
  _isLoading = true;

  if (_currentElement != null) {
    setState(() {
      _currentElement = null;
    });
  }

  if (_currentElement == null) {
    final String imagePath = await _getImagePath();
    final List<Map<String, dynamic>> tempStringList = _elementList.map((item) => ElementModel.toJson(item)).toList();

    widget.onSave(imgSrc: imagePath, data: jsonEncode(tempStringList));

    _isLoading = false;
  } else {
    _isLoading = false;
    _onSave();
  }
}

运行效果:

image03.gif

这样我们就简单实现了图片的生成,并且将数据转换成JSON字符串传递给后端保存,后续后端返回这个字符串再还原就可以进行编辑。这样就简单实现了基础的功能,后续就实现辅助的功能。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

好了,今天的分享到此结束,感谢阅读~拜拜~

原子化 CSS 与 Fragment:现代前端开发的高效实践

作者 ohyeah
2025年12月27日 22:33

在现代前端开发中,提升开发效率、优化性能以及保持代码可维护性是开发者持续追求的目标。近年来,原子化 CSS(Atomic CSS)理念与 React 中的 Fragment 模式逐渐成为主流实践,它们分别从样式管理和 DOM 结构两个维度,为开发者提供了更优雅、高效的解决方案。本文将围绕这两个核心概念展开,结合 Tailwind CSS 的使用方式和原生 JavaScript 与 React 中的 Fragment 应用,探讨如何构建高性能、高复用性的前端项目。


一、传统 CSS 的局限与原子化 CSS 的兴起

传统的 CSS 编写方式通常以“语义化类名”为核心,例如 .article-card.user-profile 等。这类类名虽然具有良好的业务含义,但往往导致样式高度耦合于具体组件,难以跨项目或跨组件复用。一个典型的痛点是:即使两个组件需要相同的内边距或文字颜色,开发者仍需重复编写相似的样式规则,或者通过复杂的继承结构来共享样式,这不仅增加了维护成本,也降低了开发效率。

为了解决这一问题,原子化 CSS(Atomic CSS)应运而生。其核心思想是将 CSS 规则拆解为最小、不可再分的“原子类”,每个类只负责单一的样式属性。例如:

  • p-4 表示 padding: 1rem(默认 16px)
  • flex 表示 display: flex
  • text-gray-500 表示特定的灰色文字颜色

这种写法看似“冗长”,实则带来了极高的复用性一致性。开发者不再需要为每个新组件创建新的 CSS 文件或类名,而是直接组合已有的原子类即可快速构建 UI。

面向对象的 CSS 思维

原子化 CSS 并非完全抛弃面向对象的思想,而是将其重新诠释:

  • 封装:原子类本身就是对单一样式规则的封装;
  • 多态:通过不同类的组合,同一原子类可在不同上下文中呈现不同效果;
  • 组合优于继承:避免深层嵌套和复杂选择器,通过类名组合实现样式叠加。

这种模式尤其适合快速原型开发和团队协作,因为所有开发者都基于同一套“设计系统”进行构建,减少了风格不一致的问题。


二、Tailwind CSS:原子化 CSS 的最佳实践

Tailwind CSS 是目前最流行的原子化 CSS 框架。它提供了一套高度可配置的实用类(utility classes),几乎无需手写自定义 CSS 即可完成复杂界面的搭建。

快速上手 Tailwind + Vite

在现代前端工程中,Tailwind 通常与构建工具如 Vite 配合使用。安装步骤如下:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

然后在 vite.config.js 中引入插件(若使用官方模板,通常已自动集成):

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss'

export default defineConfig({
  plugins: [react(), tailwindcss()]
})

接着在入口 CSS 文件中引入 Tailwind 的基础样式:

@tailwind base;
@tailwind components;
@tailwind utilities;

至此,即可在 JSX 或 HTML 中直接使用 Tailwind 类名。

示例:构建一个卡片组件

以下是一个使用 Tailwind 构建的简单文章卡片组件:

const ArticleCard = () => {
  return (
    <div className="p-4 bg-white rounded-xl shadow hover:shadow-lg transition">
      <h2 className="text-lg font-bold">Tailwindcss</h2>
      <p className="text-gray-500 mt-2">
        用 utility class 快速构建 UI
      </p>
    </div>
  )
}

可以看到,所有样式均通过类名内联声明,无需额外 CSS 文件。p-4 控制内边距,rounded-xl 设置圆角,shadowhover:shadow-lg 实现悬停阴影效果,transition 添加过渡动画。这种写法直观、高效,且天然支持响应式。

响应式布局:移动端优先

Tailwind 默认采用 Mobile First(移动端优先)策略。例如:

<div className="flex flex-col md:flex-row gap-4">
  <main className="bg-blue-100 p-4 md:w-2/3">主内容</main>
  <aside className="bg-green-100 p-4 md:w-1/3">侧边栏</aside>
</div>
  • 在小屏设备上,容器为垂直布局(flex-col);
  • 当屏幕宽度 ≥ 768px(md 断点)时,切换为水平布局(md:flex-row),并设置主内容占 2/3,侧边栏占 1/3。

这种声明式响应式语法极大简化了媒体查询的编写,使布局逻辑一目了然。


三、Fragment:优雅解决 DOM 结构冗余

在 React 开发中,组件必须返回单个根元素。早期开发者常被迫包裹一层无意义的 <div>,这不仅污染 DOM 结构,还可能影响 CSS 布局(如 Flex 容器的子项数量变化)。

React 提供了 Fragment 来解决这一问题:

export default function App() {
  return (
    <>
      <h1>标题1</h1>
      <h2>标题2</h2>
      <button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
        提交
      </button>
      <ArticleCard />
    </>
  )
}

这里的 <>...</><React.Fragment> 的简写语法。它在渲染时不会生成任何实际 DOM 节点,仅作为逻辑容器存在,从而保持 DOM 树的简洁性。

原生 JavaScript 中的 DocumentFragment

Fragment 的思想不仅存在于 React,在原生 DOM 操作中同样重要。当需要批量插入多个节点时,频繁调用 appendChild 会触发多次重排(reflow)重绘(repaint) ,严重影响性能。

此时可使用 DocumentFragment

<script>
  const container = document.querySelector('.container')
  const p1 = document.createElement('p')
  p1.textContent = '111'
  const p2 = document.createElement('p')
  p2.textContent = '222'

  const fragment = document.createDocumentFragment()
  fragment.appendChild(p1)
  fragment.appendChild(p2)

  container.appendChild(fragment) // 仅一次 DOM 操作
</script>

DocumentFragment 是一个脱离文档流的内存节点,在其上操作不会触发浏览器渲染。只有最终将其插入真实 DOM 时,才会一次性将所有子节点挂载,从而将 N 次 DOM 操作优化为 1 次。

关键优势:减少重排/重绘次数,显著提升性能,尤其在处理大量动态内容(如列表渲染、表格生成)时效果明显。


四、协同增效:原子化 CSS + Fragment 的现代开发范式

将原子化 CSS 与 Fragment 结合,构成了现代前端开发的高效工作流:

  • 样式层面:通过 Tailwind 的原子类,开发者在 JSX 中直接声明样式,无需切换文件上下文,提升开发连贯性;
  • 结构层面:使用 Fragment 避免无意义的包装元素,保持语义清晰、DOM 精简;
  • 性能层面:DocumentFragment 优化原生 DOM 操作,React Fragment 优化虚拟 DOM 渲染,两者共同保障应用流畅性。

更重要的是,这种组合天然契合 AI 辅助编程 的趋势。当使用自然语言描述 UI(如“一个带阴影的白色卡片,包含标题和灰色描述文本”),LLM 更容易生成语义明确、结构规范的 Tailwind 代码,而无需理解复杂的 CSS 选择器逻辑。


结语

原子化 CSS 与 Fragment 并非炫技,而是对“关注点分离”和“性能优先”原则的务实体现。前者让样式回归组合与复用,后者让结构回归语义与效率。在 Tailwind CSS 和 React 等现代工具链的支持下,开发者可以更专注于业务逻辑本身,而非被繁琐的样式管理和 DOM 冗余所困扰。

无论是个人项目还是大型团队协作,拥抱这些实践都将带来更清爽的代码、更快的迭代速度和更佳的用户体验。正如前端生态不断演进,我们的开发方式也应随之精进——少写 CSS,少造 div,多组合,多优化。

Python 基础再升级!5 个超实用小技巧,写代码快人一步

2025年12月27日 22:16

刚入门 Python 能实现功能,但想让代码更简洁、更高效?这 5 个进阶偏基础的小技巧,看似简单却能大幅提升编码体验,每个都附可直接运行的示例,新手也能轻松掌握~

1. 巧用 enumerate:遍历列表同时获取索引

遍历列表时想知道元素的位置,不用手动加计数器,enumerate() 能直接返回索引和元素,还能自定义起始索引。

# 待遍历的列表
fruits = ["苹果", "香蕉", "橙子", "葡萄"]

# 基础用法:默认从0开始
for idx, fruit in enumerate(fruits):
    print(f"索引{idx}{fruit}")

# 进阶用法:指定起始索引(比如从1开始)
print("\n指定起始索引:")
for idx, fruit in enumerate(fruits, start=1):
    print(f"第{idx}个水果:{fruit}")

# 输出:
# 索引0:苹果
# 索引1:香蕉
# 索引2:橙子
# 索引3:葡萄
# 
# 指定起始索引:
# 第1个水果:苹果
# 第2个水果:香蕉
# 第3个水果:橙子
# 第4个水果:葡萄

2. zip () 函数:多列表同步遍历,超省心

需要同时遍历多个列表的对应元素时,zip() 能把多个可迭代对象打包成元组,避免手动处理索引。

# 多个关联列表
names = ["张三", "李四", "王五"]
scores = [85, 92, 78]
subjects = ["数学", "语文", "英语"]

# 同步遍历三个列表
for name, score, subject in zip(names, scores, subjects):
    print(f"{name}{subject}成绩:{score}分")

# 输出:
# 张三的数学成绩:85分
# 李四的语文成绩:92分
# 王五的英语成绩:78分

3. 用 set 快速去重:比列表遍历高效 N 倍

列表去重不用写循环判断,转成集合(set)再转回列表,一行就能搞定,且时间复杂度更低。

# 含重复元素的列表
nums = [1, 2, 2, 3, 3, 3, 4, 4, 5]

# 快速去重
unique_nums = list(set(nums))
print("去重后:", unique_nums)  # 输出:去重后:[1, 2, 3, 4, 5]

# 拓展:保持原列表顺序(Python 3.7+)
unique_nums_ordered = list(dict.fromkeys(nums))
print("去重且保序:", unique_nums_ordered)  # 输出:去重且保序:[1, 2, 3, 4, 5]

4. 三元表达式:简化简单的条件判断

简单的 if-else 赋值逻辑,用三元表达式能精简代码,保持可读性的同时减少行数。

# 需求:判断成绩是否及格,返回对应提示
score = 88

# 传统if-else写法
if score >= 60:
    result = "及格"
else:
    result = "不及格"

# 三元表达式写法
result = "及格" if score >= 60 else "不及格"
print(f"成绩{score}{result}")  # 输出:成绩88:及格

# 进阶:嵌套三元表达式(适用于简单场景)
score = 95
result = "优秀" if score >= 90 else "良好" if score >= 80 else "及格" if score >= 60 else "不及格"
print(f"成绩{score}{result}")  # 输出:成绩95:优秀

5. with 语句:自动管理文件 / 资源,告别忘记 close ()

操作文件时用with语句,无需手动调用close(),代码块执行完会自动关闭文件,避免资源泄露。

# 传统写法(需手动close)
f = open("test.txt", "w", encoding="utf-8")
f.write("Python基础进阶技巧")
f.close()

# with语句写法(推荐)
with open("test.txt", "r", encoding="utf-8") as f:
    content = f.read()
    print("文件内容:", content)  # 输出:文件内容:Python基础进阶技巧

# 注:with语句块结束后,文件会自动关闭,即使报错也不影响

总结

  1. enumerate() 遍历带索引、zip() 同步遍历多列表,解决遍历场景的常见痛点;
  2. 集合(set)快速去重效率高,三元表达式简化简单条件赋值;
  3. with 语句自动管理资源,操作文件 / 网络连接时更安全。

eslint匹配规则速通

作者 sujiu
2025年12月27日 22:15

eslint匹配规则速通

匹配规则

eslint.config.js 里,“当前文件” 到底会被哪一个(或哪几个)配置对象命中,是按下述流程逐条计算的。 只要记住一句话:

ESLint 会把文件路径依次跟每个配置对象的 files/ignores 做 glob 匹配,命中且未被忽略的就生效;多个对象都命中就层层合并。

下面把“匹配”这件事拆成 5 个具体问题,一次性讲透。


  1. 拿谁来匹配? 文件在磁盘里的绝对路径(Windows 带盘符,Unix 以 / 开头)。 例: /home/me/proj/src/utils/is-empty.js D:\repo\src\utils\is-empty.js

  2. 跟谁匹配? 每个配置对象里可能出现的两组 glob 模式:

    • files 数组 —— 白名单
    • ignores 数组 —— 黑名单(支持 ! 否定)
  3. 匹配顺序(级联) 对每一个配置对象:

    1. 若对象没有 files,视为 files:["**/*"](全选)。
    2. 拿文件路径去跟 files 里每一项做 minimatch(类 .gitignore 语法)。
      • 只要有一项命中就认为该对象“适用于”此文件。
    3. 若对象还有 ignores,再跟这些模式比对:
      • 命中任何 ignores立即把该对象排除,不再参与后续合并。
      • 支持否定写法 "!xxx",可再把文件“捞回来”。
    4. 全局忽略规则(对象里只有 ignores,没有 files 等其他键)会在所有对象之前先算一次,原理相同。
  4. 合并结果 经过上面筛选后,可能 0 ~ N 个对象对当前文件生效。

    • 0 个 → ESLint 报错 “No configuration provided for …”
    • ≥1 个 → 按数组顺序浅合并(靠后的覆盖靠前的): languageOptions/linterOptions/plugins/settings 递归合并; rules 直接覆盖同名键。
  5. 快速验证 命令行看一眼到底哪条配置命中:

    npx eslint --inspect-config src/utils/is-empty.js
    

    会打印出最终合并后的完整配置以及“来自哪几个对象”,调试神器。

export default [
  {                           // 对象 A
    ignores: ["**/*.config.js"]   // 全局忽略
  },
  {                           // 对象 B
    files: ["src/**/*.js"],
    ignores: ["**/*.test.js"],
    rules: { semi: "warn" }
  },
  {                           // 对象 C
    files: ["**/*.test.js"],
    rules: { semi: "off" }
  }
]

现在 lint 文件 /proj/src/utils/is-empty.js

  1. 全局忽略 A 先算 —— 文件名不是 *.config.js未被忽略,继续。
  2. 对象 B:files 命中 src/**/*.js,且不在 ignores 里 → 生效
  3. 对象 C:files 命中 **/*.test.js,但当前文件不是测试 → 跳过

最终只有 B 生效,semi: "warn"

再把 /proj/src/utils/is-empty.test.js 走一遍:

  1. 全局忽略 A 同上,未忽略。
  2. 对象 B:虽然命中 src/**/*.js,但紧接着被 **/*.test.js 忽略 → 对象 B 被排除
  3. 对象 C:命中 **/*.test.js生效semi: "off"

合并规则

flat Config 的“合并”并不是深度合并(deep merge),而是按数组顺序、一级一级地“覆盖 + 累加”,规则非常干脆:

  1. 数组顺序 = 优先级 后出现的 config 对象如果和前面的同字段同名,就整值覆盖;不同名就累加。
  2. 命中范围(files / ignores)独立判断 对单个文件来说,ESLint 会把所有路径命中的 config 对象收集起来,再按顺序合并; 只要某一段 config 的 ignores 匹配了,这段及后面同路径的 config 就被整段跳过。
  3. 合并粒度只到“字段”一级
    • languageOptionspluginsrules 都是整对象替换,不会递归合并内部子键。
    • 想“增量”就必须把前面那段再抄一遍,或者利用展开运算符自己拼装。

看一段代码就懂

export default [
  {                                                    // ①
    files: ['**/*.ts'],
    languageOptions: { parser: ts.parser, ecmaVersion: 2022 },
    rules: { '@typescript-eslint/no-explicit-any': 'warn', 'no-console': 'off' }
  },
  {                                                    // ②
    files: ['**/*.ts'],
    rules: { 'no-console': 'error' }                   // 整对象覆盖,结果只剩 no-console:error
  }
];

对任意 .ts 文件:

  • 先收集 ① → 再收集 ②
  • ② 的 rules 是全新对象,直接把 ① 的 rules 整份替换掉;languageOptions 保留 ① 的,因为 ② 没写。

最终生效的只有:

languageOptions: { parser: ts.parser, ecmaVersion: 2022 }
rules: { 'no-console': 'error' }

@typescript-eslint/no-explicit-any 这条规则被“覆盖没”了——不会自动保留

wx微信小程序部分逻辑

2025年12月27日 21:53
<view class="w-main">
  <text class="w-title">{{title}}</text>
  <text class="w-desc">{{desc}}</text>
  <block>
    <!-- 头像选择按钮,根据需要决定是否显示 -->
    <view class="nickname-wrapper">
      <text class="nickname-label">头像</text>
      <button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
        <image class="avatar" src="{{userInfo.avatarUrl}}"></image>
      </button>
    </view>

    <view class="nickname-wrapper">
      <text class="nickname-label">昵称</text>
      <input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
    </view>
  </block>

  <block>
    <button bindtap="onAuthLogin" open-type="getUserInfo" class="w-login-btn" theme='primary'>
      确认授权登录
    </button>
  </block>
</view>
const app = getApp(); //通过getApp()方法,能够获取App的实例,从而获取app.js文件中定义的公共数据globalData
// const defaultAvatarUrl = '../../images/no-login.png' // 先将avatarUrl设置为一个常量
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'

Page({
  /**
   * 页面的初始数据
   */
  data: {
    title: '登录确认',
    desc: '为保障您的账户安全,请确认是本人登录',
    openId: '',
    sessionKey: '',
    scene: '',
    userInfo: {
      avatarUrl: defaultAvatarUrl,
      nickName: '',
      avatarUrlBase64: ''
    },
    hasUserInfo: false,
    canIUseNicknameComp: wx.canIUse('input.type.nickname'),
  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad(options) {
    this.setData({
      scene: decodeURIComponent(options.scene)
    })
    this.getOpenId();
  },

  getOpenId() {
    wx.login({
      success: res => {
        console.log("用户登录的临时凭证是:" + res.code);
        // 保存this到变量that中
        const that = this;
        wx.request({
          url: 'https://juejin.plus/prod-api/wx/public/getOpenId',
          method: 'POST',
          data: {
            code: res.code,
            scene: that.data.scene ? that.data.scene : '-1' // 使用that代替this
          },
          success: function (res) {
            if (res.data.code == 200) {
              console.log("用户openid:", res);
              // 使用变量that来引用页面实例
              that.setData({
                openId: res.data.data.openId,
                sessionKey: res.data.data.sessionKey
              });
            } else {
              wx.showToast({
                title: "用户openid获取失败",
                duration: 1000,
                icon: 'none',
                mask: true
              })
            }
          },
          fail: function (err) {
            console.log("失败")
          }
        });
      }
    });
  },

  onAuthLogin(e) {
    if (this.data.userInfo.avatarUrlBase64 === '') {
      wx.showToast({
        title: "请选择头像",
        duration: 1000,
        icon: 'none',
        mask: true
      })
      return
    }
    if (this.data.userInfo.nickName === '') {
      wx.showToast({
        title: "请输入昵称",
        duration: 1000,
        icon: 'none',
        mask: true
      })
      return
    }
    wx.getUserProfile({
      desc: '用于完善用户资料',
      success: (res) => {
        const that = this;
        that.setData({
          isLogin: true
        });
        console.log("用户信息:", res)
        var params = {
          'openId': that.data.openId,
          'sessionKey': that.data.sessionKey,
          'encryptedData': res.encryptedData,
          'rawData': res.rawData,
          'signature': res.signature,
          'iv': res.iv,
          'scene': that.data.scene ? that.data.scene : '-1',
          'nickName': that.data.userInfo.nickName,
          'avatarUrlBase64': that.data.userInfo.avatarUrlBase64,
        }
        console.log("param:", params)
        wx.request({
          url: 'https://juejin.plus/prod-api/wx/public/getWxUserInfo',
          method: 'POST',
          data: params,
          success: function (res) {
            if (res.data.code == 200) {
              that.setData({
                isLogin: true
              });
              app.globalData.loginState = true;
              console.log("登录状态修改成功")
              wx.showToast({
                title: '登录成功!',
                icon: 'success',
                duration: 3000,
                success: res => {
                  setTimeout(() => {
                    wx.reLaunch({
                      url: '/pages/home/home',
                    })
                  }, 2000)
                },
              })
            } else {
              wx.showToast({
                title: "登录失败",
                duration: 1000,
                icon: 'none',
                mask: true
              })
            }
          }
        })
      }
    })
  },

  onChooseAvatar(e) {
    const {
      avatarUrl
    } = e.detail

    //对临时图片链接进行base64编码
    var avatarUrl_base64 = 'data:image/jpeg;base64,' + wx.getFileSystemManager().readFileSync(avatarUrl, 'base64')
    const {
      nickName
    } = this.data.userInfo
    this.setData({
      "userInfo.avatarUrl": avatarUrl,
      "userInfo.avatarUrlBase64": avatarUrl_base64,
      hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
    })
  },
  onInputChange(e) {
    const nickName = e.detail.value
    const {
      avatarUrl
    } = this.data.userInfo
    this.setData({
      "userInfo.nickName": nickName,
      hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
    })
  },
  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady() {

  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow() {

  },

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

  },

  /**
   * 生命周期函数--监听页面卸载
   */
  onUnload() {

  },

  /**
   * 页面相关事件处理函数--监听用户下拉动作
   */
  onPullDownRefresh() {

  },

  /**
   * 页面上拉触底事件的处理函数
   */
  onReachBottom() {

  },

  /**
   * 用户点击右上角分享
   */
  onShareAppMessage() {

  },
})

用 Vue 3 构建任务清单:响应式编程的优雅实践

作者 Zyx2007
2025年12月27日 21:14

在前端开发中,任务清单(Todos)是一个经典练习项目。它虽功能简单,却能完整体现数据驱动、状态管理与用户交互的核心思想。若用传统 DOM 操作实现,代码往往冗长且难以维护;而借助 Vue 3 的组合式 API 与响应式系统,我们只需关注“数据如何变化”,界面便会自动同步更新。这种声明式开发范式,不仅提升了开发效率,也让代码逻辑更清晰、更易测试。

从命令式到声明式:思维的转变

传统 JavaScript 开发通常采用命令式编程:先获取 DOM 元素,再监听事件,最后手动修改内容。

const app = document.getElementById('app');
const input = document.getElementById('todo-input');
input.addEventListener('change', (e) => {
  app.innerHTML = e.target.value.trim();
});

这种方式直接操作渲染引擎,每一步都需精确控制。一旦逻辑复杂(如增删改查、筛选、全选),代码会迅速膨胀,且容易因状态不一致导致 bug。

而 Vue 的核心理念是响应式数据驱动:你不再操作 DOM,而是定义数据;当数据变化时,框架自动更新视图。开发者只需思考“业务状态是什么”,而非“页面该怎么改”。

响应式状态:用 ref 管理数据

在 Vue 3 的 <script setup> 中,我们使用 ref 创建响应式变量:

<script setup>
import { ref } from 'vue'
const title = ref('Todos 任务清单')
const todos = ref([
  { id: 1, title: '打王者', done: false },
  { id: 2, title: '吃饭', done: true }
])
</script>

titletodos 都是响应式引用对象。通过 title.value = '新标题' 修改值后,模板中所有使用 {{ title }} 的地方都会自动刷新。这种机制将 UI 与状态紧密绑定,消除了手动 DOM 操作的繁琐。

模板指令:简洁表达逻辑

Vue 提供一系列指令,以声明方式描述 UI 行为:

<input v-model="title" @keyup.enter="addTodo" />
<ul v-if="todos.length">
  <li v-for="todo in todos" :key="todo.id">
    <input type="checkbox" v-model="todo.done" />
    <span :class="{ 'done': todo.done }">{{ todo.title }}</span>
  </li>
</ul>
  • v-model 实现双向绑定,输入框内容与 title 同步;
  • @keyup.enter 监听回车键,触发添加任务;
  • v-for 循环渲染列表,无需手动拼接 HTML;
  • :class 动态绑定样式,完成项显示删除线;
  • v-if 控制“暂无任务”提示的显示时机。

这些指令将常见交互模式封装成语义化语法,大幅降低样板代码量。

计算属性:高效派生状态

任务清单需要实时显示“未完成任务数”和“全选”状态。若在模板中直接写 todos.filter(...).length,每次渲染都会重新计算,影响性能。Vue 的 computed 提供了缓存机制:

const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

active 是一个计算属性,只有当 todos 发生变化时才会重新求值。模板中使用 {{ active }} 即可获得最新结果,且性能最优。

更巧妙的是,computed 支持 getter/setter,可用于实现“全选”功能:

const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done)
  },
  set(value) {
    todos.value.forEach(todo => todo.done = value)
  }
})

当用户勾选“全选”复选框时,set 被调用,批量更新所有任务状态;反之,若部分任务被取消,get 返回 false,复选框自动取消。这种双向联动仅需几行代码,却覆盖了复杂的交互逻辑。

添加任务:聚焦数据变更

新增任务的本质是向 todos 数组追加一项:

const addTodo = () => {
  if (!title.value.trim()) return
  todos.value.push({
    id: Date.now(),
    title: title.value,
    done: false
  })
  title.value = ''
}

这里没有 createElementappendChildinnerHTML,只有对数据的操作。Vue 会自动检测数组变更,并高效更新 DOM。这种“数据即 UI”的思想,让业务逻辑与视图完全解耦。

样式与体验优化

通过动态类名,轻松实现视觉反馈:

.done {
  color: #999;
  text-decoration: line-through;
}

配合 :class="{ 'done': todo.done }",完成的任务自动变灰并加删除线,提升用户体验。

总结

这个 Todos 应用虽小,却完整展示了 Vue 3 的核心优势:

  • 响应式系统让状态与视图自动同步;
  • 组合式 API 提供灵活、可复用的逻辑组织方式;
  • 模板指令 将常见交互抽象为简洁语法;
  • 计算属性 在保证性能的同时简化派生状态管理。

相比传统 DOM 操作,Vue 让开发者从“如何改页面”转向“数据该是什么样”,极大降低了心智负担。对于初学者,这是快速上手现代前端开发的捷径;对于资深工程师,这是构建可维护、可扩展应用的坚实基础。正如那句老话:“当你专注于数据,UI 自会跟随。”

基于 Microsoft Graph API 与 React Email 构建现代化邮件发送系统

作者 大明二代
2025年12月27日 20:43

背景

在传统的 Node.js 服务端开发中,使用 Nodemailer 配合 SMTP 协议是发送邮件的标准做法。然而,随着安全标准的提升,微软已宣布将在 2026 年彻底停止 Outlook/Microsoft 365 对 SMTP 基础身份验证(Basic Auth)的支持。

为了应对这一变化,我们需要转向 Microsoft Graph API。同时,为了解决邮件模板开发中样式难以调试、兼容性差的痛点,我们可以引入 React Email。本文将介绍如何结合这两者,构建一套符合 Shadcn UI 审美风格且具备高度可靠性的邮件发送系统。


技术栈

  • React Email: 使用 React 组件编写邮件模板,支持 Tailwind CSS。
  • Nodemailer: 成熟的 Node.js 邮件发送库。
  • Microsoft Graph API: 微软提供的现代化 API 接口,用于替代传统 SMTP。
  • MSAL Node: 微软官方提供的身份验证库。

1. 架构设计

系统分为三层:模板层、逻辑层和传输层。

  • 模板层:利用 React Email 定义 UI,确保样式在各邮件客户端(如 Gmail、Outlook、iOS Mail)中表现一致。
  • 传输层:通过自定义 Nodemailer 的 Transport 接口,将底层的邮件发送请求导向 Microsoft Graph API。
  • 认证层:使用 Azure 应用注册(App Registration)获取 OAuth2 令牌。

2. 实现 Microsoft Graph 传输器

我们需要实现一个自定义的 AzureTransport 类。它的核心逻辑是:在发送邮件前检查令牌是否过期,若过期则通过客户端凭据流(Client Credentials Flow)获取新令牌,随后调用 Graph API 的 /sendMail 接口。

import * as msal from '@azure/msal-node';
import { SentMessageInfo, Transport } from 'nodemailer';
import MailMessage from 'nodemailer/lib/mailer/mail-message';

export class AzureTransport implements Transport<SentMessageInfo> {
  name = 'Azure';
  version = '0.1';
  private msalClient: msal.ConfidentialClientApplication;
  private tokenInfo: msal.AuthenticationResult | null = null;

  constructor(private config: any) {
    this.msalClient = new msal.ConfidentialClientApplication({
      auth: {
        clientId: config.clientId,
        clientSecret: config.clientSecret,
        authority: `https://login.microsoftonline.com/${config.tenantId}`
      }
    });
  }

  private async getAccessToken() {
    if (!this.tokenInfo || (this.tokenInfo.expiresOn && Date.now() > this.tokenInfo.expiresOn.getTime())) {
      this.tokenInfo = await this.msalClient.acquireTokenByClientCredential({
        scopes: ['https://graph.microsoft.com/.default']
      });
    }
    return this.tokenInfo!.accessToken;
  }

  async send(mail: MailMessage, callback: any) {
    try {
      const { subject, from, to, html, text } = mail.data;
      const accessToken = await this.getAccessToken();

      const mailMessage = {
        message: {
          subject,
          body: { content: html || text, contentType: html ? 'HTML' : 'Text' },
          toRecipients: (Array.isArray(to) ? to : [to]).map((addr: any) => ({
            emailAddress: { address: typeof addr === 'string' ? addr : addr.address }
          })),
        }
      };

      const response = await fetch(`https://graph.microsoft.com/v1.0/users/${from}/sendMail`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
        body: JSON.stringify(mailMessage)
      });

      if (!response.ok) throw new Error(await response.text());
      callback(null, { messageId: response.headers.get('request-id') });
    } catch (error) {
      callback(error, null);
    }
  }
}


3. 使用 React Email 编写 Shadcn 风格模板

Shadcn UI 的核心在于简洁的 Zinc 色调和良好的间距。以下是为用户确认设计的通用模板:

import { Body, Container, Head, Heading, Html, Preview, Text, Tailwind, Button } from "@react-email/components";

export const RequestReceivedEmail = () => (
  <Html>
    <Head />
    <Preview>Confirmation of your request</Preview>
    <Tailwind config={{ theme: { extend: { colors: { brand: "#09090b" } } } }}>
      <Body className="bg-white font-sans text-zinc-950">
        <Container className="border border-zinc-200 rounded-lg my-10 mx-auto p-8 max-w-[465px]">
          <Heading className="text-2xl font-semibold tracking-tight">Request Received</Heading>
          <Text className="text-sm leading-6 text-zinc-600 mt-4">
            We have successfully received your information. Our team will review the details and respond accordingly.
          </Text>
          <Button className="bg-zinc-950 rounded-md text-white text-sm font-medium px-6 py-3 mt-4 no-underline inline-block" href="https://app.aands.ai">
            Access Portal
          </Button>
        </Container>
      </Body>
    </Tailwind>
  </Html>
);


4. 业务逻辑整合

最后,我们将上述模块整合到一个服务函数中。该函数会同时向用户发送回执,并向运营团队发送内部通知。

import { render } from "@react-email/components";
import nodemailer from "nodemailer";

const transporter = nodemailer.createTransport(new AzureTransport({
  clientId: process.env.AZURE_CLIENT_ID,
  clientSecret: process.env.AZURE_CLIENT_SECRET,
  tenantId: process.env.AZURE_TENANT_ID
}));

export const sendNotification = async (userData: any) => {
  const userHtml = await render(<RequestReceivedEmail />);
  
  await transporter.sendMail({
    from: "noreply@yourdomain.com",
    to: userData.email,
    subject: "Request Confirmation",
    html: userHtml
  });
};


总结

通过这种方式,我们不仅解决了 SMTP 认证即将过期的合规性问题,还提升了邮件的视觉质量。Microsoft Graph API 提供了比传统 SMTP 更强的可监控性和安全性,而 React Email 则极大地改善了邮件开发的开发者体验。

在实际生产环境中,请确保在 Azure 门户中为你的应用授予了 Mail.Send 权限,并根据业务需求配置环境变量。

参考资料

  1. 构建和发送电子邮件的React组件的实例教程
  2. Sending Emails via Outlook with Nodemailer and Microsoft Graph

那些让你 debug 到凌晨的陷阱,我帮你踩平了:React Hooks 避坑指南

2025年12月27日 20:03

前言

React 函数组件的世界里,Hooks 无疑是 “效率神器”—— 它用极简的 API 封装了状态管理与生命周期逻辑,让我们告别了类组件的繁琐。但就像童话里藏在糖果屋后的陷阱,这些看似友好的 API 背后,藏着不少因 “闭包特性”“依赖逻辑”“异步处理” 引发的。很多开发者刚上手时,总在 “写得通” 和 “写得对” 之间反复踩坑,debug 到怀疑人生。

我在刚入手时踩了不少 “坑”,所以我将用我的亲身经历帮你们填平。本文先快速梳理 Hooks 核心基础,再聚焦那些最容易中招的 “陷阱”,用代码案例拆解坑因、给出避坑方案,帮你避开 Hooks 路上的 “连环雷”!

一、先搭个 Hooks 小舞台

React Hooks 就像给函数组件开了挂 —— 不用写类就能拥有状态和生命周期,先来快速回顾下 “基础三件套”

1. useState:状态管理的 “入门钥匙”

它能接收同步函数(注意:异步代码会翻车!),返回 “状态 + 修改状态的方法”。修改状态时,setXxx 既可以直接传值,也能传一个 “接收旧状态、返回新状态” 的函数(这个细节是避坑关键!)。

比如State.jsx里的例子:

import { useState } from "react";
function getDate() {
    return new Promise((resolve) => {
        setTimeout(() => { resolve(100) }, 1000)
    })
}
export default function State() {
    // ❌ 错误示范:useState不支持异步函数
    // const [num, setNum] = useState(async () => {
    //   const res = await getDate();
    //   return res;
    // });

    // ✅ 正确:同步函数初始化状态
    const [num, setNum] = useState(() => {
        return 1;
    });

    function add() {
        // ✅ 用函数形式获取“修改前的旧状态”
        setNum((prev) => {
            console.log(prev); // 点击时打印当前num(修改前)
            return prev + 1;
        })
    }
    return (
        <div onClick={add}>{num}</div>
    )
}

2. useEffect:生命周期的 “伪装者”

它像个 “多面手”,能模拟组件的 “挂载、更新、卸载”,但用法不对就会踩坑:

  • useEffect(() => {}):组件初次加载 + 每次重渲染都触发
  • useEffect(() => {}, []):只在初次加载时触发
  • useEffect(() => {}, [x]):初次加载 + x 变化时触发
  • 返回的函数:组件卸载前执行(用来做清理,比如清定时器)

Effect.jsx里的定时器例子:

import { useState, useEffect } from "react";
async function getData() {
    const data = new Promise((resolve) => {
        setTimeout(() => { resolve(100) }, 1000)
    })
    return data;
}
export default function Effect() {
    const [num, setNum] = useState(() => { return 1; });
    const [age, setAge] = useState(18);

    // 🌰 依赖项的坑(后面细说)
    // useEffect(() => {
    //   getData().then((data) => {
    //     console.log(data);
    //     setNum(data);
    //   })
    // }, [age]) // 依赖age,但实际修改的是num…

    useEffect(() => {
        // 启动定时器
        const timer = setInterval(() => {
            // setNum(num + 1); // ❌ 这里有坑!后面讲
        }, 1000)
        // 组件卸载前清理定时器(避免内存泄漏)
        return () => {
            clearInterval(timer);
        }
    })

    function add() {
        setNum((prev) => {
            return prev + 1;
        })
    }
    return (
        <div onClick={add}>{num}---{age}</div>
    )
}

3. useReducer:复杂状态的 “调度员”

当状态逻辑比较复杂时,useReduceruseState更清晰 —— 它把 “状态修改逻辑” 抽成reducer函数,通过dispatch触发:

import { useReducer } from "react"
// 状态修改的“规则函数”
function reducer(state, action) {
    switch(action.type) {
        case 'add':
            return state + action.num;
        case 'minus':
            return state - action.num;
        default:
            return state;
    }
}
export default function Trap() {
    // 初始化状态为0,dispatch用来触发reducer
    const [count, dispatch] = useReducer(reducer, 0);
    
    // 触发“add”操作,传参num=1
    dispatch({type: 'add', num: 1});
    
    return (
        <div>{count}</div>
    )
}

以上我们就大致回顾了hooks的一些基础知识,如果想看更具体的请看我的文章:

一场组件的进化脱口秀——React从 “类” 到 “hooks” 的 “改头换面”

二、重点来了:Hooks 的 “陷阱”

前面都是铺垫,这些看似简单的 Hooks,藏着能让你 debug 到天亮的坑—— 接下来逐个拆解:

陷阱 1: useState 的 “异步更新”+“闭包陷阱”

Effect.jsx里的定时器:

useEffect(() => {
    const timer = setInterval(() => {
        setNum(num + 1); // ❌ 这里有问题!
    }, 1000)
    return () => clearInterval(timer);
})

坑在哪?

useEffect默认没有依赖项,会在组件每次重渲染时重新执行 —— 但定时器里的num是 “闭包捕获的旧值”,导致num + 1永远只在初始值1的基础上加,页面不会更新。

怎么填坑?

setNum的 “函数形式”,它能拿到最新的旧状态:

setNum((prev) => prev + 1); // ✅ 不管闭包,直接拿最新prev

陷阱 2:useEffect 的 “依赖项迷路”

再看Effect.jsx里被注释的代码:

useEffect(() => {
    getData().then((data) => {
        setNum(data); // 修改num
    })
}, [age]) // ❌ 依赖项写了age,但实际没用到age

坑在哪?

useEffect的依赖项数组必须包含 “回调里用到的所有外部变量”—— 这里回调里没用到age,却把age当依赖,会导致age变化时重复请求;反过来,如果用到了某个变量却没写进依赖,就会拿到旧值 (闭包陷阱)

怎么填坑?

依赖项要 “诚实”:用到啥就写啥,没用到就别写。比如上面的代码,要么把age从依赖里删掉,要么在回调里真正用到age

陷阱 3:useReducer 的 “dispatch 不是万能药”

Trap.jsx里的定时器注释:

useEffect(() => {
    setInterval(() => {
        // console.log(count); // ❌ 永远打印初始值0
        // setCount(count + 1); // 同样的闭包坑
        // setCount((prev) => prev + 1) // ✅ 用函数形式才对
    }, 1000)
}, [])

坑在哪?

哪怕用了useReducer,如果在useEffect(依赖为空)里用count,还是会因为闭包捕获旧值,导致拿到的count永远是初始值。

怎么填坑?

useState一样,修改状态时用 “函数形式”;或者把count加入useEffect的依赖项(但要注意重复触发的问题)。

陷阱 4:useState 的 “异步初始化”

State.jsx里的注释:

// ❌ useState不支持异步函数初始化
// const [num, setNum] = useState(async () => {
//     const res = await getDate();
//     return res;
// });

坑在哪?

useState的初始化函数必须是同步的 —— 异步代码会直接返回一个Promise,而不是你想要的结果。

怎么填坑?

把异步初始化逻辑放到useEffect里:

const [num, setNum] = useState(0);
useEffect(() => {
    getDate().then(res => setNum(res));
}, [])

三、避坑总结: Hooks 的 “生存法则”

  1. useState 修改状态,优先用函数形式setXxx(prev => prev + 1),避免闭包旧值。
  2. useEffect 依赖项要 “完整且诚实” :回调里用到的变量,必须写进依赖数组;没用到的,别瞎写。
  3. 异步逻辑别往 useState 初始化里塞:交给useEffect(依赖为空)来做。
  4. 定时器 / 订阅要记得清理:在useEffect的返回函数里做卸载前的清理(比如清定时器)。

结语

Hooks 的陷阱,本质上大多是对 “闭包” “React 渲染机制” 和 “Hooks 设计规则” 理解不透彻的结果。没有绝对 “万能” 的 API,只有 “用对场景” 的用法 —— 比如 useState 的函数式更新、useEffect 的依赖项规范,这些看似细节的点,恰恰是避开陷阱的关键。希望本文的案例能帮你跳出 “踩坑 - debug - 再踩坑” 的循环,在使用 Hooks 时更从容、更精准。

记住:

好的代码不是 “写出来的”,而是 “避坑避出来的”,多理解底层逻辑,少依赖 “经验主义”,才能真正掌握 Hooks 的精髓

nextjs中,关于Layout组件和Page组件的认知

作者 RedHeartWWW
2025年12月27日 19:55

一、Layout——页面的布局结构

当用户访问next项目中的一个页面,如果这个页面的路由对应文件夹下面有layout.tsx,那么next就会基于这个文件里面的dom结构,渲染当前页面结构。Layout的通常传参非常简单,因为它负责的仅仅只有负责当前页面的布局这一项工作。

// 一般路由文件夹下的layout.tsx只需要渲染children即可
interface LayoutProps {
    children: React.ReactNode 
}

export default function Layout({ children }: LayoutProps) {
  return <div className="min-h-screen w-full">{children}</div>
}

// app顶层文件夹的layout除了children,还需要渲染html和body
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

通常当用户访问页面路由,next会先匹配对应文件夹下面的page.tsx,然后再“自下而上”寻找Layout.tsx。

例如用户访问/blog这个页面的时候,会先寻找静态路由对应的文件夹blog,匹配到app/blog/page.tsx之后,如果当前文件夹中没有layout,那就逐级往上寻找layout,直到匹配到app/layout.tsx

app/
 ├── (marketing)/  --> 带括号,是路由组,继续往下找
 │    └── page.tsx --> 对应路径 "/"
 │
 ├── blog/  --> 对应路径 "/blog"
 │    └── page.tsx --> 找到目标,但是当前文件夹里面没有layout.tsx
 │
 └── Layout.tsx  --> 如果blog上级文件夹中有layout.tsx,那就用这个layout.tsx来渲染blog/page.tsx

二、Page——页面的业务内容

Layout.tsx负责当前页面的结构,通常里面的代码都是没有复杂业务逻辑的静态纯组件,承担业务逻辑的都是Page.tsx。

Page有params和searchParams两个默认参数。params用于获取 动态路由(Dynamic Routes)中的参数。例如,如果你的文件路径是 app/blog/[slug]/page.tsx,那么 params 就会包含 slugsearchParams用于获取 URL 中 问号后面的查询参数(Query Parameters)。例如 ?query=nextjs&page=1

const Page = async({
    params,
    searchParams
}:{
    params:Promise<{slug:string}>, 
    searchParams:Promise<{[key: string]:string | string[] | undefined}>
}) =>{
    const {slug} = await params;
    const urlParams = await searchParams;
    console.log(slug, urlParams);
    return <>
        <Nav />
        <p>Hello, I'm the blog page</p>
    </>
}
export default Page

当访问/blog/a?query=11111&query=222222&b=5时,console会输出: image.png 其中['a']是只有访问动态路由才会有输出,如果访问一个静态路由地址,params输出就是undefined,searchParams则会以键值对的形式展示出来

三、路由组划分多根模板

nextjs通过(folder)的方式划分路由组,路由组本身并不会被next识别为路由,比如文件结构如果是/app/(marketing)/blog/page.tsx,那么只需要访问/blog就能访问到这个page.tsx。
路由组虽然不会被识别为路由,但由于上面提到的page“从内而外”匹配layout渲染的机制,不同的模块都可以有自己的rootlayout:

app/
 ├── (marketing)/  --> marketing模块对应路由组
 │    └── page.tsx --> 对应路径 "/"
 |    └── layout.tsx --> marketing模块下所有page的根layout 
 |
 ├── (dashboard)/  --> 
 │    └── dashboard/
 │         └── page.tsx --> 对应路径 "/dashboard"
 │         └── layout.tsx --> dashboard模块下所有page的根layout

面试官: “ 说一下你对 Cookie 的理解 ? ”

作者 千寻girling
2025年12月27日 19:32

1. 什么是 Cookie

Cookie(小甜饼)是 服务器发送给浏览器并保存在客户端的一小段数据,用于:

  • 记录用户状态(如登录信息、购物车内容)
  • 跟踪用户行为(如浏览历史、广告推送)
  • 存储少量配置信息(如主题偏好、语言设置)

特点:

  • 大小限制:通常每个 Cookie 最大 4KB
  • 数量限制:每个域名一般最多 50 个 Cookie
  • 自动携带:浏览器在访问同一域名时会自动将 Cookie 附加在请求头中发送给服务器

2. Cookie 的结构

一个 Cookie 通常包含以下字段:

字段名 说明
Name Cookie 的名称
Value Cookie 的值(通常经过 URL 编码)
Domain 可以访问该 Cookie 的域名
Path 可以访问该 Cookie 的路径
Expires / Max-Age Cookie 的过期时间(Expires 是具体日期,Max-Age 是秒数)
HttpOnly 如果设置,Cookie 不能通过 JavaScript 访问(防止 XSS 攻击)
Secure 如果设置,Cookie 只能通过 HTTPS 传输
SameSite 控制跨站请求时是否发送 Cookie(防止 CSRF 攻击)

3. Cookie 的工作流程

  1. 服务器发送 Cookie : 当浏览器第一次访问服务器时,服务器在响应头中添加:

    Set-Cookie: username=Tom; Path=/; HttpOnly; Secure
    

    浏览器收到后会将该 Cookie 保存到本地。

  2. 浏览器存储 Cookie: Cookie 会被保存在浏览器的某个文件或内存中,根据 Domain 和 Path 来区分。

  3. 浏览器发送请求时携带 Cookie : 之后每次访问同一域名和路径时,浏览器会自动在请求头中添加:

    Cookie: username=Tom
    

    服务器通过读取这个 Cookie 来识别用户。


4. Cookie 的分类

(1)会话 Cookie(Session Cookie)

  • 没有设置 Expires 或 Max-Age
  • 浏览器关闭后自动删除
  • 常用于保存短期会话信息(如登录状态)

(2)持久 Cookie(Persistent Cookie)

  • 设置了 Expires 或 Max-Age
  • 在过期时间前一直有效,即使浏览器关闭
  • 常用于保存长期信息(如记住登录、用户偏好)

5. Cookie 的优缺点

优点

  • 简单易用:服务器和浏览器都原生支持
  • 自动携带:无需手动处理
  • 轻量级:适合存储少量数据

缺点

  • 容量小:每个 Cookie 最大 4KB

  • 安全性差

    • 容易被窃取(XSS 攻击)
    • 容易被伪造(CSRF 攻击)
  • 性能影响

    • 每次请求都会携带,增加带宽消耗
    • 过多 Cookie 会影响页面加载速度

6. Cookie 的安全设置

为了提高安全性,建议设置以下属性:

  • HttpOnly:防止 JavaScript 访问(减少 XSS 风险)

  • Secure:只在 HTTPS 连接中传输

  • SameSite

    • Strict:仅在同站请求时发送
    • Lax:允许部分跨站请求(如 GET 表单提交)
  • 合理的过期时间:短期 Cookie 减少被盗风险


7. 示例:使用 Cookie

(1)服务器设置 Cookie(Node.js + Express)

const express = require('express');
const app = express();

app.get('/login', (req, res) => {
  // 设置一个会话 Cookie
  res.cookie('username', 'Tom', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'Strict',
    maxAge: 3600000 // 1 小时
  });
  res.send('登录成功');
});

app.get('/profile', (req, res) => {
  // 读取 Cookie
  const username = req.cookies.username;
  if (username) {
    res.send(`欢迎你,${username}`);
  } else {
    res.send('请先登录');
  }
});

app.listen(3000);

(2)浏览器查看 Cookie

  • Chrome:F12 → Application → Cookies
  • Firefox:F12 → Storage → Cookies

8. Cookie 与 Token 的区别

特性 Cookie Token
存储位置 浏览器 浏览器(LocalStorage/SessionStorage)或 App
传输方式 自动在 HTTP 请求头中发送 需要手动在请求头中添加(如 Authorization: Bearer <token>
容量限制 每个 Cookie 最大 4KB 无固定限制(但过大影响性能)
安全性 较低(易受 XSS/CSRF 攻击) 较高(可结合 HttpOnly、Secure、SameSite)
适用场景 简单会话管理、短期状态存储 复杂认证授权、跨域请求、移动应用

✅ 总结

  • Cookie 是服务器保存在浏览器的一小段数据,用于记录状态和跟踪用户
  • 自动携带在请求头中,方便但有容量和安全限制
  • 常用于简单会话管理,现代 Web 开发中更多与 Token 结合使用
  • 安全使用需设置 HttpOnlySecureSameSite 等属性

SPA首屏加载速度优化!

作者 大知闲闲i
2025年12月28日 09:29

⚡ 首屏加载速度为何如此重要?

首屏加载时间(First Contentful Paint)是决定用户留存率的关键指标。据统计:

  • 53%的用户 会在3秒内放弃加载缓慢的移动网站

  • 每提高1秒 的加载速度,转化率提升7%

  • Google已将页面速度纳入搜索排名因素

🔍 精准诊断:你的应用到底"慢"在哪里?

首屏时间计算方法

// 现代浏览器API
const getFirstContentfulPaint = () => {
  const [entry] = performance.getEntriesByName('first-contentful-paint');
  return entry.startTime;
};

// 传统计算方法
const firstScreenTime = () => {
  return performance.timing.domContentLoadedEventEnd - 
         performance.timing.navigationStart;
};

🚀 七大核心理念:从根源解决加载问题

一、代码分割:智能加载的艺术

动态导入 + 路由懒加载

// 传统方式(不推荐)
import Home from '@/views/Home.vue';

// 现代化懒加载(强烈推荐)
const Home = () => import(/* webpackChunkName: "home" */ '@/views/Home.vue');
const About = () => import(/* webpackChunkName: "about" */ '@/views/About.vue');

// Vue Router配置
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue'),
    meta: { preload: true } // 关键路由预加载标记
  }
];

// 预加载策略
router.beforeEach((to, from, next) => {
  const preloadList = to.matched.filter(item => item.meta.preload);
  preloadList.forEach(route => {
    route.components.default().catch(() => {});
  });
  next();
});

二、Tree Shaking:剔除无用代码

Webpack配置优化

// webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    usedExports: true, // 标记未使用代码
    minimize: true,
    splitChunks: {
      chunks: 'all',
      minSize: 20000,
      maxSize: 244000,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
          reuseExistingChunk: true
        },
        common: {
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
};

三、UI框架按需加载:告别"全量"包袱

Element-UI优化示例

// .babelrc 配置
{
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

// 按需引入组件
import Vue from 'vue';
import { Button, Select, Pagination } from 'element-ui';

Vue.use(Button);
Vue.use(Select);
Vue.use(Pagination);

四、图片资源革命级优化

现代图片处理方案

// Vue图片懒加载指令
Vue.directive('lazy', {
  inserted: (el, binding) => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = new Image();
          img.src = binding.value;
          img.onload = () => {
            el.src = binding.value;
            el.classList.add('loaded');
          };
          observer.unobserve(el);
        }
      });
    });
    observer.observe(el);
  }
});

// WebP自动降级方案
<picture>
  <source srcset="image.webp" type="image/webp">
  <source srcset="image.jpg" type="image/jpeg">
  <img src="image.jpg" alt="Description">
</picture>

五、缓存策略:极致利用浏览器能力

多级缓存体系

// Service Worker 缓存策略
const CACHE_NAME = 'v1';
const urlsToCache = ['/', '/styles/main.css', '/scripts/main.js'];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) return response;
        
        return fetch(event.request).then(response => {
          // 动态缓存重要资源
          if (event.request.url.includes('/api/critical')) {
            const responseClone = response.clone();
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, responseClone);
            });
          }
          return response;
        });
      })
  );
});

六、构建优化:发布前的最后冲刺

Webpack高级配置

// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  chainWebpack: config => {
    // 打包分析
    config.plugin('analyzer').use(BundleAnalyzerPlugin);
    
    // 移除 prefetch/preload
    config.plugins.delete('prefetch');
    config.plugins.delete('preload');
    
    // 压缩配置
    config.optimization.minimize(true);
    
    // Gzip压缩
    config.plugin('compression').use(CompressionPlugin, [{
      algorithm: 'gzip',
      test: /\.(js|css)$/,
      threshold: 10240,
      minRatio: 0.8
    }]);
    
    // Brotli压缩(更高效)
    config.plugin('brotli').use(CompressionPlugin, [{
      algorithm: 'brotliCompress',
      filename: '[path].br[query]',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 },
      threshold: 10240,
      minRatio: 0.8
    }]);
  }
};

七、服务端优化:全链路性能提升

Nginx配置优化

# Gzip压缩配置
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types 
    text/plain
    text/css
    text/xml
    text/javascript
    application/javascript
    application/xml+rss
    application/json
    image/svg+xml;

# 缓存策略
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# Brotli压缩(需要额外模块)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript;

Node.js Express优化

const express = require('express');
const compression = require('compression');
const helmet = require('helmet');

const app = express();

// 安全头部
app.use(helmet({
  contentSecurityPolicy: false,
}));

// 压缩中间件
app.use(compression({
  level: 6,
  threshold: 0,
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));

// 静态资源缓存
app.use(express.static('dist', {
  maxAge: '1y',
  setHeaders: (res, path) => {
    if (path.endsWith('.html')) {
      res.setHeader('Cache-Control', 'public, max-age=0');
    }
  }
}));

🎯 进阶方案:突破性能瓶颈

1. SSR(服务端渲染)

// Nuxt.js配置示例
export default {
  target: 'server',
  render: {
    // 开启HTTP/2推送
    http2: {
      push: true
    },
    // 资源提示
    resourceHints: true
  },
  build: {
    // 提取CSS
    extractCSS: true,
    // 优化配置
    optimization: {
      splitChunks: {
        chunks: 'all',
        automaticNameDelimiter: '.',
        name: true,
        maxSize: 256000
      }
    }
  }
};

2. 预渲染关键路径

// 使用prerender-spa-plugin
const PrerenderSPAPlugin = require('prerender-spa-plugin');

module.exports = {
  plugins: [
    new PrerenderSPAPlugin({
      staticDir: path.join(__dirname, 'dist'),
      routes: ['/', '/about', '/contact'],
      renderer: new PrerenderSPAPlugin.PuppeteerRenderer({
        inject: {},
        renderAfterDocumentEvent: 'render-event'
      })
    })
  ]
};

3. CDN动态加速

<!-- 智能CDN选择 -->
<script>
  const cdnList = [
    'https://cdn1.example.com',
    'https://cdn2.example.com',
    'https://cdn3.example.com'
  ];
  
  // 测速选择最快的CDN
  async function getFastestCDN() {
    const promises = cdnList.map(url => 
      fetch(`${url}/ping.txt`).then(res => ({
        url,
        time: performance.now()
      })).catch(() => null)
    );
    
    const results = await Promise.all(promises);
    const valid = results.filter(r => r !== null);
    valid.sort((a, b) => a.time - b.time);
    return valid[0].url;
  }
</script>

📊 监控与持续优化

性能监控仪表板

// 实时性能监控
const metrics = {};

// 收集性能数据
const collectMetrics = () => {
  const paintMetrics = performance.getEntriesByType('paint');
  const navigationMetrics = performance.getEntriesByType('navigation')[0];
  
  metrics.FCP = paintMetrics.find(m => m.name === 'first-contentful-paint');
  metrics.LCP = performance.getEntriesByName('largest-contentful-paint')[0];
  metrics.FID = performance.getEntriesByName('first-input')[0];
  
  // 发送到监控系统
  sendToAnalytics(metrics);
};

// 用户行为关联分析
const correlateWithUserActions = () => {
  // 关联加载性能与用户转化率
};

🎁 总结:构建你的优化清单

立即执行(今天就能做)

  • 启用路由懒加载
  • 配置Webpack代码分割
  • 开启Gzip/Brotli压缩
  • 优化图片格式和尺寸
  • 设置合适的HTTP缓存头

短期规划(1-2周)

  • 实现Service Worker离线缓存
  • UI框架按需加载优化
  • 关键资源预加载
  • 部署CDN加速
  • 添加性能监控

长期战略(1-3个月)

  • 实施SSR/SSG方案
  • 建立性能预算机制
  • 开发渐进式Web应用特性
  • 建立自动化的性能测试流水线

💡 终极思考

首屏优化不是一次性任务,而是持续的过程。随着应用发展,需要:

  1. 建立性能文化 - 让每个开发者都有性能意识

  2. 设置性能预算 - 为每个关键指标设置上限

  3. 自动化监控 - 实时发现问题,快速响应

  4. A/B测试 - 用数据驱动优化决策

记住:每一毫秒都值得争取,因为对于用户来说,速度不仅是一种功能,更是一种体验的保证。

JavaScript 函数柯里化:从入门到实战,一文搞定(面试可用)

作者 南山安
2025年12月27日 23:32

前言

最近在复习 JavaScript 高阶函数的时候,又把函数柯里化(Currying)翻出来好好捋了一遍。很多人一听到“柯里化”就觉得高大上,其实它没那么神秘,用通俗的话说,就是把一个接受多个参数的函数,变成一个个只接受一个参数的函数链条。

这篇文章就把我自己的学习笔记整理了一下,从最基础的对比开始,慢慢讲到原理、实现、实际用法,希望能帮你把这个知识点彻底吃透。

1. 先看一个最直观的对比

普通写法:

function add(a, b) {
  return a + b;
}

console.log(add(1, 2)); // 3

柯里化写法:

function add(a) {
  return function (b) {
    return a + b;
  };
}

console.log(add(1)(2)); // 3

看到区别了吗?

  • 普通版:一次把所有参数传完。
  • 柯里化版:参数一个一个传,每次调用返回一个新函数,直到所有参数收集齐了才真正计算。

这种“一个一个传”的方式,就是函数柯里化的核心。

2. 柯里化的本质:闭包 + 参数收集

为什么能一个一个传?靠的是闭包。

在外层函数里,参数 a 被保存了下来(成了闭包里的自由变量),内层函数可以随时访问它。当我们再传进来 b 的时候,就可以用之前保存的 a 去计算。

所以说,柯里化本质上就是利用闭包把参数“攒”起来,等参数够了再执行真正的逻辑。

3. 怎么判断参数“够了”?

JavaScript 函数有一个隐藏属性 length,它表示函数定义时参数的个数(不包括剩余参数和默认参数)。

function add(a, b) {
  return a + b;
}

console.log(add.length); // 2

我们可以利用这个属性来做一个相对严谨的柯里化判断:只有当收集到的参数数量 ≥ 原函数的 length 时,才真正执行。

4. 手写一个通用柯里化函数

下面这个是我自己最常用的一版:

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args); // 直接展开,更清晰
    }
    return (...more) => curried(...args, ...more); // 这里也用展开合并
  };
}

// 测试
function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);

console.log(curriedAdd(1)(2)(3));     // 6
console.log(curriedAdd(1, 2)(3));     // 6
console.log(curriedAdd(1)(2, 3));     // 6
console.log(curriedAdd(1, 2, 3));     // 6

这个版本的好处:

  • 支持任意数量的参数逐步传递
  • 也支持一次传多个(只要总数够了就执行)
  • 实现只有十来行,容易理解和记忆

注意:这里用了递归 + 闭包,外层每次调用都会产生新的 curried 函数,args 会被不断累加,直到满足条件。

5. 柯里化的经典实战场景

说了那么多,这么麻烦的编写柯里化函数,它到底能做什么呢?

场景一:固定部分参数,制造专用的工具函数

// 通用日志函数
const log = (type) => (message) => {
  console.log(`[${type.toUpperCase()}]: ${message}`);
};

// 通过柯里化“固定”日志类型,得到专用函数
const errorLog = log('error'); // 第一个参数
const infoLog = log('info');
const warnLog = log('warn');
// 第二个参数
errorLog('接口 404 了!');         // [ERROR]: 接口 404 了!
infoLog('页面加载完成');           // [INFO]: 页面加载完成
warnLog('即将弃用旧 API');        // [WARN]: 即将弃用旧 API

这种写法在实际项目里特别常见,尤其是做日志、埋点、事件绑定的时候,能让代码语义更清晰。

场景二:延迟执行 / 参数复用

比如我们有一个通用的 Ajax 请求函数:

function ajax(method, url, data) {
  // ...真正的请求逻辑
}

// 柯里化后
const get = curry(ajax)('GET');
const post = curry(ajax)('POST');

const fetchUserList = get('/api/users');
const fetchUserDetail = get('/api/users/');

const submitForm = post('/api/submit');

这样每次调用时就不用反复写 method,代码更简洁,也更不容易写错。

场景三:配合函数式编程库(如 lodash、ramda)

lodash 的 _.curry 功能更强大,支持占位符 __ 来跳过某些参数:

const _ = require('lodash');

const join = (sep, ...arr) => arr.join(sep);
const curryJoin = _.curry(join);

const dotJoin = curryJoin('.');
dotJoin('a', 'b', 'c'); // "a.b.c"

不过日常项目里,自己手写一个简单版往往就够用了。

6. 柯里化的优缺点总结

优点:

  1. 参数复用:固定前几个参数,快速生成新函数
  2. 延迟执行:参数没收集齐之前不会真正运行
  3. 让代码更函数式、更声明式,阅读性更好(尤其配合管道操作)

缺点:

  1. 产生大量闭包和中间函数,性能略有损耗(现代引擎优化后影响很小)
  2. 调试时调用栈会变深一点
  3. 如果滥用,会让代码看起来“太巧妙”,反而降低可读性

所以我的建议是:合适的地方用,别为了柯里化而柯里化。

最后

函数柯里化其实就是一个很小的技巧,但用好了能让你的代码更优雅、更灵活。尤其是当你开始接触函数式编程、React 高阶组件、Redux 中间件这些场景时,会发现柯里化的影子到处都是。

希望这篇从零开始的整理,能帮你把柯里化彻底搞明白。欢迎在评论区分享你用柯里化写过的有趣代码,或者你踩过的坑~

你会先找行还是直接拍平?两种二分策略你Pick哪个?

作者 栀秋666
2025年12月27日 23:16

引言

你有没有遇到过这种情况:

面试官轻描淡写地扔过来一道题:“给你一个m×n的矩阵,每行递增,而且每一行的第一个数都比上一行最后一个大……问你能不能快速找到某个目标值?”

你心里一咯噔:
👉 这不是普通的二维数组啊,这简直是升序界的卷王之王

然后你灵光一闪:
💡 “等等!这不就是个‘假装是二维’的一维数组吗?”

没错,今天我们要聊的,就是一个能让二分查找从平面直角坐标系直接穿越到数轴上跳舞的经典问题。我们不讲晦涩公式,只用最接地气的语言,带你把这道题“吃干抹净”。


🧩 问题长什么样?先别慌,它是纸老虎!

题目大概是这样:

74. 搜索二维矩阵 - 力扣(LeetCode)

image.png

看到这个结构,聪明的你已经发现了关键点:

整个矩阵展开后,其实是一个严格升序的一维数组!

也就是说,上面那个矩阵等价于:

[1, 3, 5, 7, 10, 11, 16, 20, 23, 30, 34, 60] // 完全有序!

所以——
🎉 我们可以用二分查找来解决!

但问题是:怎么在“二维空间”里搞“一维操作”?

这就引出了两种神仙思路👇


🚀 方法一:两次二分 —— 先找小区再敲门

想象一下你在一栋高档公寓楼里找朋友:

  • 大楼有 m 层(对应行)
  • 每层有 n 户(对应列)
  • 所有房间号按顺序排列,且下一层第一个房间号 > 上一层最后一个

你要找住在“房间号=11”的老张。

你会怎么做?

🧠 当然是:

  1. 先看每层的第一户门牌号,确定他在哪一层;
  2. 然后再去那层挨家挨户敲门找他。

这就是两次二分法的核心思想!

✅ 第一步:二分找“可能住的那一行”

我们对第一列进行二分查找,找的是:

“最后一个首元素 ≤ target”的那一行

为什么是“最后一个”?因为后面的行开头就太大了,不可能有目标。

举个例子,target = 11:

首元素
0 1
1 10
2 23

显然,第2行开始首元素23 > 11,所以只能在第0或第1行。而我们要找的是“最后一个小于等于11的首元素”,那就是第1行(10 ≤ 11)。

于是我们锁定:目标最多只能出现在第1行!

✅ 第二步:在这行内部再二分查找

第1行是 [10, 11, 16, 20],标准升序数组,直接套模板二分即可。

找到了11 → 返回 true

🎯 成功定位,就像快递小哥精准投递!

💡 关键细节:避免死循环的小技巧

在找行的时候,为了避免 lowhigh 卡住不动,我们需要向上取整:

const mid = Math.floor((high - low + 1) / 2) + low;

否则当 low=0, high=1 时,mid 永远是0,就会陷入无限循环——相当于你一直在一楼徘徊,不敢上二楼 😵‍💫


🚀 方法二:一次二分 —— 把二维压成一维,“降维打击”!

如果说方法一是“一步步推理”,那方法二就是“开挂模式”!

它的哲学是:

“我不管你是几维,只要整体有序,我就当你是一条直线!”

🌀 思路精髓:虚拟一维数组 + 下标映射

假设矩阵是 m × n,我们可以把它看作一个长度为 m * n 的一维数组。

如何将一维下标 k 映射回二维坐标?

✨ 答案非常优雅:

row = Math.floor(k / n);   // 第几行
col = k % n;               // 第几列

是不是像极了小时候学的“排座位”?

比如一共4列:

  • 第0个同学 → 第0行第0列
  • 第5个同学 → 第1行第1列(5 ÷ 4 = 1余1)

于是我们可以直接在整个“虚拟数组”上做二分查找!

🔍 实战演示:target = 11

总长度 = 3×4 = 12,初始范围 [0, 11]

中间下标 mid = 5 → 对应位置是:

row = Math.floor(5 / 4) = 1
col = 5 % 4 = 1
→ matrix[1][1] = 11 ✅ 找到了!

boom!一击必中!


🆚 两种方法对比:谁更适合你?

维度 两次二分法 一次二分法
时间复杂度 O(log m + log n) = O(log mn) O(log mn)
空间复杂度 O(1) O(1)
代码长度 稍长(两个函数) 超短(10行搞定)
逻辑清晰度 分步思考,适合教学 数学美感强,适合装X
边界处理难度 第一列二分需小心 相对简单
推荐指数 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐⭐

🤔 如何选择?

  • 如果你是初学者,建议先掌握两次二分法——它更符合直觉,像是走路:先迈左脚,再迈右脚。
  • 如果你是老司机,直接上一次二分法,代码简洁得像诗一样,面试官看了都想鼓掌👏

🎯 我的独特理解:这不是“两种方法”,而是“两种思维方式”

很多人说:“哦,一个是分治,一个是映射。”
我说:错!这是两种人生哲学!

  • 两次二分法像是“稳扎稳打型选手”:

    “我不急,先缩小范围,再精确打击。”
    类似于生活中那些做事有计划、步步为营的人。

  • 一次二分法则是“全局视角型天才”:

    “你们还在纠结维度?我已经把它拍平了!”
    就像马斯克说的:“不要被表象迷惑,抓住本质。”

而这道题的本质是什么?

🔑 只要数据整体有序,维度只是障眼法!

你可以把它当成一张表格,也可以当成一条线,甚至可以当成一部电影的时间轴——只要你能找到“顺序”的锚点。


🛠️ 实际代码实现(可直接复制粘贴)

✅ 方法一:两次二分(推荐学习用)

var searchMatrix = function(matrix, target) {
    // 找到可能包含 target 的最后一行(基于首列)
    let low = -1;
    let high = matrix.length - 1;
    
    while (low < high) {
        const mid = Math.floor((high - low + 1) / 2) + low;
        if (matrix[mid][0] <= target) {
            low = mid;
        } else {
            high = mid - 1;
        }
    }

    if (low === -1) return false; // 比最小的还小

    // 在该行内进行二分查找
    const row = matrix[low];
    let left = 0, right = row.length - 1;
    while (left <= right) {
        const mid = Math.floor((right - left) / 2) + left;
        if (row[mid] === target) {
            return true;
        } else if (row[mid] > target) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return false;
};

✅ 方法二:一次二分(推荐实战用)

var searchMatrix = function(matrix, target) {
    const m = matrix.length;
    const n = matrix[0].length;
    let low = 0;
    let high = m * n - 1;

    while (low <= high) {
        const mid = Math.floor((high - low) / 2) + low;
        const val = matrix[Math.floor(mid / n)][mid % n];

        if (val < target) {
            low = mid + 1;
        } else if (val > target) {
            high = mid - 1;
        } else {
            return true;
        }
    }
    return false;
};

⚡ 提示:背下这个下标映射公式,关键时刻能救你一命!


🌟 写在最后:学会“降维”,才能跳出题海

很多刷题人陷入困境的原因是:

“我做了100道二分题,怎么换了个马甲就不认识了?”

因为你记得的是“形式”,而不是“灵魂”。

真正的高手,看到这种矩阵不会想“二维怎么搞”,而是问自己:

❓ “这个结构有没有全局有序性?”
❓ “能不能把它变成我能处理的形式?”

一旦你掌握了这种化归思维,你会发现:

  • 旋转排序数组?→ 找断点 → 降维处理
  • 山脉数组找峰值?→ 利用单调性 → 二分收缩
  • 二维搜索?→ 扁平化 → 当作一维

🚀 所谓算法能力,不是记忆套路,而是不断把新问题翻译成旧知识的能力


❤️ 结语:愿你也能在代码世界里“以无厚入有间”

庄子说:“彼节者有间,而刀刃者无厚,以无厚入有间,恢恢乎其于游刃必有余地矣。”

翻译成程序员语言就是:

只要你找对了突破口(有序性),哪怕问题看起来坚不可摧(二维+嵌套),你的算法也能像一把薄刃,轻松滑进去,咔嚓一声——搞定!


PostCSS完全指南:功能/配置/插件/SourceMap/AST/插件开发/自定义语法

作者 漂流瓶jz
2025年12月27日 23:02

PostCSS是什么

PostCSS是一个转义CSS代码的工具,它的输入为CSS文件,输出也是CSS文件。它首先把CSS转换为抽象语法树AST,再使用插件对语法树进行修改,最后生成新的CSS代码。它的作用非常像JavaScript中的Babel。

在CSS领域,存在感更强的是SCSS和Less,它们是CSS的预处理器,扩充了CSS的语法和功能,可以编写复用性更强的代码。预处理器经过编译后,是CSS代码。而PostCSS正如它的名字,最常被用做CSS的后处理器,做一些兼容性功能。例如添加浏览器引擎的前缀,转换CSS代码以兼容不支持的浏览器等。预处理器和后处理器的关系类似于这样:

postcss-1.png

对比JavaScript的生态位,SCSS和Less像TypeScript扩充语法,PostCSS像Babel转义兼容语法。但PostCSS允许我们自定义语法规则,因此用作预处理器,甚至只用PostCSS也是可以的。

PostCSS使用

这里我们以最常用的插件Autoprefixer举例,这是一个根据兼容性设置添加浏览器引擎前缀的插件。首先创建css/index.css,作为我们要转义的CSS代码。

::placeholder {
  color: gray;
}

.image {
  width: stretch;
}

API方式

首先试一下JavaScriptAPI的方式使用PostCSS。执行下面的代码:

const fs = require("fs");
const autoprefixer = require("autoprefixer");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");

postcss([autoprefixer])
  .process(originData, { from: "css/index.css", to: "out.css" })
  .then((res) => {
    console.log(res.css);
    fs.writeFileSync('out.css', res.css);
  });

我们读取CSS文件为字符串,放入PostCSS中进行转义,最后手动写入输出文件。虽然PostCSS要求指定from和to表示输入输出的文件路径,但实际上它们是给SourceMap用的,并不会真正帮我们读取写入(但还是必填)。最后生成的结果如下:

::-moz-placeholder {
  color: gray;
}

::placeholder {
  color: gray;
}

.image {
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
}

可以看到生成的CSS代码中的部分属性添加了浏览器前缀了。具体哪些前缀被添加,要根据Browserslist浏览器兼容范围确定。(在后面介绍插件的部分会提到)

命令行方式

使用PostCSS CLI可以支持以命令行方式转义CSS文件。首先需要安装postcss-cli依赖,然后命令行执行:

# 单个文件
postcss css/index.css -u autoprefixer -o out.css --no-map
# 目录
postcss css -u autoprefixer -d output --no-map

PostCSS CLI支持转义单个文件或者目录,目录会转义其中的每个文件。其中-u表示传入的插件名,-o表示输出的文件名,-d表示输出目录,--no-map表示不输出SourceMap。经过转义后,输出结果与上面API方式一致。如果更多配置,则需要使用PostCSS配置文件,我们在后面单独介绍。

Webpack中使用PostCSS

在Webpack中使用PostCSS,主要依靠postcss-loader。

创建Webpack项目

我们先创建一个Webpack项目,可以打包CSS,但不包含PostCSS。

# 创建项目
npm init -y
# 安装依赖
npm add webpack webpack-cli style-loader css-loader

创建src/index.css,内容为即为前面的CSS代码。再创建src/index.js,引入CSS文件:

import "./index.css";
console.log("你好,jzplp");

然后再创建Webapck配置文件webpack.config.js:

const path = require("path");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

其中最关键的是style-loader和css-loader,这是引入CSS文件的必要loader。loader是从后向前链式调用,先css-loader,再style-loader。然后在package.json中增加命令:

"scripts": {
  "build": "webpack"
},

最后执行npm run build,结果输出到dist/main.js中。结果较长,这里只截取包含CSS的部分。可以看到,CSS被打包进JavaScript代码中,其内容未变。

postcss-2.png

引入postcss-loader

安装三个相关依赖:postcss postcss-loader autoprefixer。然后修改webpack.config.js,引入PostCSS:

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: [
          "style-loader",
          "css-loader",
          {
            loader: "postcss-loader",
            options: {
              postcssOptions: {
                plugins: ['autoprefixer'],
              },
            },
          },
        ],
      },
    ],
  },
};

注意其中重点是新增加了postcss-loader,它的位置在数组的最后。意味着CSS文件先经过它处理,然后再给css-loader和style-loader。postcssOptions选项中可以配置插件。postcss-loader也支持使用配置文件postcss.config.js。

重新执行npm run build后,查看结果发现,除了原有代码外,还增加了浏览器前缀,说明代码成功被PostCSS转义了。(下图为了方便用两行展示CSS字符串,实际为一行)

postcss-3.png

配置文件postcss.config.js

在命令行或Webapck方式使用PostCSS时,都支持postcss.config.js作为配置文件。但是这两种配置文件的居然是不一样的,部分场景互相不兼容。因此这里分别介绍两种方式的配置文件。

命令行方式配置文件

引入插件

命令行仅支持直接引入插件对象的方式。例如前面我们列举的例子,使用配置文件内容如下:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
};

然后执行的命令进行修改,输出效果一致。

# 原命令
postcss css/index.css -u autoprefixer -o out.css --no-map
# 新命令
postcss css/index.css -o out.css

插件参数

为了描述插件参数,我们换一个postcss-color-gray插件。配置文件内容如下,先不使用插件参数:

const postcssColorGray = require("postcss-color-gray");
module.exports = {
  plugins: [postcssColorGray],
};

然后使用插件语法,重新写一段CSS代码,转义后的代码也在下面列出:

/* 源CSS代码 */
.jzplp {
  color: gray(0 / 90%);
}

/* 转义后CSS代码 */
.jzplp {
  color: rgba(0,0,0,0.9);
}

可以看到插件生效了。然后我们修改配置文件,增加插件参数:

const postcssColorGray = require("postcss-color-gray");
module.exports = {
  plugins: [postcssColorGray({ preserve: true })],
};

重新执行命令行,转义后的代码发生了变化,保留了gray函数,参数生效了。

/* 转义后CSS代码 */
.jzplp {
  color: rgba(0,0,0,0.9);
  color: gray(0 / 90%);
}

配置文件名称和格式

虽然PostCSS CLI中仅仅提到了配置文件名称为postcss.config.js,但我参考隔壁postcss-loader的文件名对PostCSS CLI进行了尝试,发现居然是支持的!这里我们描述一下。

首先是JavaScript类的配置文件,包括.postcssrc.js, .postcssrc.cjs, postcss.config.cjs等,PostCSS CLI是支持的,文件内容和执行效果都与postcss.config.js一致。

然后是JSON格式的配置文件,例如.postcssrc, .postcssrc.json等,PostCSS CLI也是支持的,但是由于插件必须直接引入插件对象,因此JSON格式实际上并不能用。它的报错和在postcss.config.js中直接写插件名称字符串的报错是一致的,因此判定文件本身被读取了,但不支持插件。这里举例下配置文件内容,以.postcssrc.json为例:

{
  "plugins": [["postcss-color-gray", { "preserve": true }]]
}

然后报错内容如下,可以看到是在读插件过程中的错误。使用yaml类的文件格式,报错也是一致的。

postcss-4.png

读取上下文

配置文件还可以导出一个函数,函数可以接收上下文入参,最终返回配置对象。这里我们给出配置文件的示例:

const postcssColorGray = require("postcss-color-gray");
module.exports = (ctx) => {
  console.log(ctx);
  return {
    plugins: [
      postcssColorGray(ctx.env === "development" ? { preserve: true } : {}),
    ],
  };
};

/* 输出结果
{
  cwd: 'E:\\testProj\\postcss-test\\apitest',
  env: undefined,
  options: {
    map: { inline: true },
    parser: undefined,
    syntax: undefined,
    stringifier: undefined
  },
  file: {
    dirname: 'E:\\testProj\\postcss-test\\apitest\\css',
    basename: 'index.css',
    extname: '.css'
  }
}
*/

通过上面代码可以看到,可以根据上下文入参调整配置对象的内容。这里简单说明上下文入参含义:

  • env: 为process.env.NODE_ENV的值
  • file: 文件名相关参数
    • dirname: 文件路径
    • basename: 文件名
    • extname: 文件扩展名
  • options: 命令行中输入的选项

Webpack方式配置文件

引入插件

Webpack方式不仅支持直接引入插件对象的方式,还支持直接写插件名称字符串。两种方式这里都列举下:

// 直接引入插件对象
const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
};

// 直接写插件名称字符串
module.exports = {
  plugins: ['autoprefixer'],
};

然后删除Webapck配置中的插件配置,效果一致。Webapack中配置可以是这样(仅展示相关片段):

module: {
  rules: [
    {
      test: /\.css$/i,
      use: ["style-loader", "css-loader", "postcss-loader"],
    },
  ],
},

插件参数

这里还使用前面命令行中插件参数一节里面的postcss-color-gray插件和CSS代码。这里尝试用两种插件配置方式:

// 直接引入插件对象
const autoprefixer = require("autoprefixer");
const postcssColorGray = require("postcss-color-gray");
module.exports = {
  plugins: [autoprefixer, postcssColorGray({ preserve: true })],
};

// 直接写插件名称字符串
module.exports = {
  plugins: ['autoprefixer', ['postcss-color-gray', { preserve: true }]],
};

查看结果,两种方式都能正常接收参数,结果与命令行方式一致。这里还举例了传入多个插件的方式。

配置文件名称和格式

Webpack方式的配置文件,支持多种配置文件格式和文件名,包括.postcssrc, .postcssrc.json, .postcssrc.yaml, .postcssrc.js, postcss.config.cjs等等,具体可以看postcss-loader的文档。其中JSON和YAML类的格式仅支持直接写插件名称字符串,这里示例下配置文件的格式,首先是JSON格式:

{
  "plugins": [
    "autoprefixer",
    ["postcss-color-gray", { "preserve": true }]
  ]
}

然后是YAML格式,实际内容一致:

plugins:
  - autoprefixer
  - - postcss-color-gray
    - preserve: true

读取上下文

Webpack方式的配置文件也可以导出一个函数,函数可以接收上下文入参,最终返回配置对象。这里我们给出配置文件的示例:

module.exports = (ctx) => {
  console.log(ctx);
  return {
    plugins: [["postcss-color-gray", ctx.mode === "development" ? { preserve: true } : {}]],
  };
};

/* 输出结果
{
  mode: 'production',
  file: 'E:\\testProj\\postcss-test\\webpacktest\\src\\index.css',
  webpackLoaderContext: {} // 内容很多,这里省略
  env: 'production',
  options: { plugins: [ 'autoprefixer' ] }
*/

这里的输出与命令行方式类似,但不完全一样,这里简单说明上下文入参含义:

  • env和mode: 为process.env.NODE_ENV的值
  • file: 文件路径
  • options: Webpack配置中的postcssOptions选项
  • webpackLoaderContext: Webpack loader的上下文,内容很多

其余参数

这些配置文件都可以接收PostCSS的API中ProcessOptions的参数,除了from和to。这里列举几个:

  • parser 传入解析AST的方法
  • stringifier 传入从AST生成字符串的方法
  • syntax 传入AST解析和生成的方法,相当于parser + stringifier
  • map SourceMap选项

这些参数的具体用法会在后面介绍到。

不同点总结

从上面命令行方式与Webpack方式配置文件的描述,我们可以明确两种方式配置文件的不同点:

  • 最主要的不同是引入插件方式的不同:命令行仅支持直接引入插件对象的方式,Webpack方式还支持直接写插件名称字符串。这导致了插件传参方式和配置文件格式的限制。
  • 其次是读取上下文参数的不同。这是由于两者运行方式不同,命令行与Webpack方式可以接收到的配置数据不同,因此上下文参数不一样。

各类插件简介

PostCSS中提供了非常多的插件,作用各不相同,这里列举几个进行介绍:

Autoprefixer

Autoprefixer是PostCSS中最知名的插件,它的作用是根据浏览器兼容性,添加浏览器引擎前缀。浏览器引擎前缀是浏览器为了给实验性或者浏览器引擎独有的非标准CSS属性添加的前缀,这样这个实验属性就不会影响到其它浏览器,开发者也能识别这是针对某种浏览器做的优化。常见的前缀有:

  • -webkit-: 基于WebKit内核的浏览器,例如Chrome、Safari等
  • -moz-: 火狐浏览器
  • -o-: 旧版(WebKit之前的)Opera浏览器
  • -ms-: IE 浏览器

这里举一个例子,transition属性用上面的浏览器引擎前缀可以写为:

div {
  /* 原属性 */
  transition: all 4s ease;
  /* 增加浏览器引擎前缀 */
  -webkit-transition: all 4s ease;
  -moz-transition: all 4s ease;
  -ms-transition: all 4s ease;
  -o-transition: all 4s ease;
}

现在添加浏览器引擎前缀经常是为了兼容性,为了在旧版本浏览器也可以使用较新的CSS特性。而Autoprefixer插件就可以帮我们做到这件事。这个插件没有浏览器兼容配置,而是读取工程的Browserslist配置。这里举个例子。首先给出我们要转义的CSS代码:

.jzplp {
  display: flex;
  width: stretch;
}

通过在package.json中设置不同的Browserslist配置,我们能得到不同的代码生成结果,这对应的是不同浏览器版本的兼容性。兼容的版本越多,那么需要处理的就越多。

/* 配置 "browserslist": "> 1%" 的生成结果 */ 
.jzplp {
  display: flex;
  width: -webkit-fill-available;
  width: stretch;
}

/* 配置 "browserslist": "> 0.01%" 的生成结果 */
.jzplp {
  display: -moz-box;
  display: flex;
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
}

postcss-custom-properties

postcss-custom-properties是一个增加CSS变量兼容性的插件,对于不支持的CSS var的浏览器提供后备值。这里来举例试一下:

/* 源CSS代码 */
.jzplp {
  background: var(--jza);
  color: var(--jzb, red);
}

/* 生成CSS代码 */
.jzplp {
  background: var(--jza);
  color: red;
  color: var(--jzb, red);
}

通过上面例子可以看到,如果提供了CSS var的后备值,那么插件将会生成一个不带CSS var的版本。如果不支持的浏览器读取到不带CSS var的版本,可以正常展示;支持的浏览器则使用var的版本覆盖前一个不支持的属性值。这样实现了CSS var的浏览器兼容性处理。如果我们没有提供后备值,则不会生成兼容性代码。

但是如果在同一个文件中提供了CSS变量的值,那么即使var函数中没提供后备值,也可以生成兼容性代码。我们看一下例子:

/* 源CSS代码 */
:root {
  --jza: blue;
}
.jzplp {
  background: var(--jza);
  color: var(--jzb);
}

/* 生成CSS代码 */
:root {
  --jza: blue;
}
.jzplp {
  background: blue;
  background: var(--jza);
  color: var(--jzb);
}

可以看到例子中--jza提供了变量值,因此生成了兼容性代码;jzb没有则不会生成。变量值必须与使用变量的代码在同一个文件中才有效。

postcss-global-data

在前面postcss-custom-properties插件中我们看到,只有在同一个文件中提供了CSS变量值,才能生成兼容性代码。但是工程中的全局变量与使用位置一般不会在一个文件内,这会导致postcss-custom-properties插件无法识别。而postcss-global-data就可以解决这个问题。

postcss-global-data插件允许我们提供一些全局CSS文件,作为每个被编译文件的附加数据使用。但这些全局文件的编译结果会在输出前被移除,因此不会使每个编译后文件的代码体积增加。我们来看一下例子,首先是PostCSS配置文件, 注意postcss-global-data插件必须在前:

const postcssCustomProperties = require("postcss-custom-properties");
const postcssGlobalData = require("@csstools/postcss-global-data");

module.exports = (ctx) => {
  return {
    plugins: [
      postcssGlobalData({
        files: ["./global.css"],
      }),
      postcssCustomProperties,
    ],
  };
};

然后是要编译的文件index.css和全局CSS文件global.css:

/* global.css */
:root {
  --jza: blue;
}

/* index.css */
.jzplp {
  background: var(--jza);
}

最后我们看下编译的结果:

/* 未使用postcss-global-data插件 */
.jzplp {
  background: var(--jza);
}

/* 已使用postcss-global-data插件 */
.jzplp {
  background: blue;
  background: var(--jza);
}

可以看到在使用postcss-global-data插件的情况下,生成代码中增加了兼容性代码,读取了全局CSS文件中的变量值。但是全局CSS文件global.css中的CSS代码却没有包含进来。

cssnano

cssnano是一个代码压缩工具,将CSS代码进行语义化压缩。与gzip等纯压缩工具的不一样的是,它会根据语义对代码本身进行改动,例如去掉注释,去掉重复属性,合并选择器等等。这里我们举例试试:

/* 源CSS代码 */
.jzplp {
  color: red;
  /* 我是注释 */
  color: red;
}
.jzplp {
  width: 20px;
}

/* 生成CSS代码 */
.jzplp{color:red;width:20px}

可以看到,同样的选择器被合并,同样的属性值被合并了,注释和中间的换行符空格都去掉了。cssnano还支持预设或者插件,这里就不描述了。

PostCSS兼容性插件

使用postcss-preset-env

前面介绍了几个PostCSS的插件,但有一个插件却留到了这一节介绍:postcss-preset-env。类似与Babel中的@babel/preset-env预设,postcss-preset-env中包含了很多PostCSS浏览器兼容性的插件。它会读取我们配置的浏览器兼容版本,根据CSSDB上面的特性列表以及我们代码使用了哪些新特性,选择应用哪些插件,从而在浏览器不支持某些CSS新特性的情况下,允许使用新特性。

首先接入插件,由于postcss-preset-env是做兼容性处理的,因此需要放在其它插件的后面,让兼容性插件最后处理:

const postcssPresetEnv = require("postcss-preset-env");
module.exports = {
  plugins: [
    // 其他插件
    postcssPresetEnv({ /* 插件参数 */ }),
  ],
};

postcss-preset-env插件也是读取工程中的Browserslist配置。这里我们提供一段CSS源代码,让postcss-preset-env插件在不同浏览器配置下编译试试。

/* 源CSS代码 */
.jzplp {
  width: stretch;
  color: #01020380;
}

/* 转义后CSS代码 "browserslist": "> 1%" */
.jzplp {
  width: -webkit-fill-available;
  width: stretch;
  color: #01020380;
}

/* 转义后CSS代码 "browserslist": "> 0.1%" */
.jzplp {
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
  color: rgba(1,2,3,0.50196);
}

通过上面的结果,我们可以看到不同的浏览器兼容性配置,会生成不同的代码。postcss-preset-env中包含了Autoprefixer,因此会自动添加浏览器引擎前缀。此外,还会根据支持程度添加其它兼容性代码。

例如#rrggbbaa,是分别以两个16进制数字表示红R绿G蓝B透明度A表示颜色的方式,只在相对较新的浏览器中支持。当browserslist>1%时不进行转义,当>0.1%时就转义为rgba函数的形式。

是否任何语法都能转义

postcss-preset-env插件可以根据源CSS代码使用的特性来转义代码,增加兼容性。那么是不是什么特性都能转义,转义后的效果是不是与转义前一致?这很显然是否定的。即使是JavaScript代码,转义和Polyfill也做不到将所有新特性的兼容模式运行和新特性完全一致。例如Vue3之所以不兼容IE的一个原因,就是无法兼容JavaScript语法中Proxy的所有特性。至于CSS兼容性的限制就更大了。这里我们依然使用之前的CSS变量代码来举例:

/* 源CSS代码 */
.jzplp {
  background: var(--jza, red);
  color: var(--jzb);
}

/* 生成CSS代码 "browserslist": "> 0.001%" */
.jzplp {
  background: red;
  background: var(--jza, red);
  color: var(--jzb);
}

即使浏览器兼容性配置的要求很高,生成的代码也是这样。当我们提供了后备值时,插件会为我们生成兼容性的固定值background: red。如果没提供,那插件则无能为例。不管有没有生成固定值,这段代码在不支持CSS变量的浏览器运行时,效果与支持的浏览器不一样:因为变量的运行时变更功能无法被兼容。因此这明显可以得出:转义插件并不是任何属性都能转义,相反它不能做到的事情特别多,只能够尽量。

PostCSS与SCSS和Less

这一部分我们以Webapck作为环境,从直接引入SCSS与Less开始,再到用PostCSS做后处理器,再直接用PostCSS解析SCSS与Less。

Webapck引入SCSS

首先尝试在Webpack中引入SCSS。还是前面创建的Webpack工程,安装依赖sass和sass-loader,然后修改webpack.config.js:

const path = require("path");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.scss$/i,
        use: ["style-loader", "css-loader", "sass-loader"],
      },
    ],
  },
};

可以看到,增加了一条规则匹配SCSS文件。先使用sass-loader把SCSS解析成CSS,然后再用常规的CSS处理。我们要打包的代码如下。首先是入口文件,其中引入了CSS和SCSS文件。

import "./index.scss";
import "./index.css";
console.log("你好,jzplp");

然后是CSS和SCSS文件内容:

/* index.css */
.jzplp {
  color: blue;
}

/* index.scss */
$jzabc: red;
div {
  color: $jzabc;
}

SCSS文件中使用了变量的特性,SCSS在编译后会变成它的实际值,我们看看打包结果。文件较长,仅展示相关部分:

i.push([e.id, ".jzplp {\n  color: blue;\n}", ""]);
i.push([e.id, "div{color:red}", ""]);
console.log("你好,jzplp");

可以看到,SCSS文件被编译成功,也打包进了最终成果中。

Webapck引入Less

再尝试在Webpack中引入Less。安装依赖less和less-loader,然后修改webpack.config.js,这里只列出module部分,其它和Webapck引入SCSS一致:

const path = require("path");

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.less$/i,
        use: ["style-loader", "css-loader", "less-loader"],
      },
    ],
  },
};

然后创建index.less,内容如下:

@jzabc: red;
div {
  color: @jzabc;
}

在index.js中引入index.less:import "./index.less";。其它内容和上面一致,然后进行打包,生成结果如下(仅展示相关部分)。可以看到,Less文件也被编译成功,也打包进了最终成果中。

i.push([e.id, "div {\n  color: red;\n}\n", ""]);
i.push([e.id, ".jzplp {\n  color: blue;\n}", ""]);
console.log("你好,jzplp");

用PostCSS做后处理器

在文章的一开始,我们介绍了PostCSS最常用作后处理器,这一节我们就在前面引入SCSS与Less的基础上,集成PostCSS用作后处理器。

const path = require("path");

const postcssConfig = {
  loader: "postcss-loader",
  options: {
    postcssOptions: {
      plugins: ["autoprefixer"],
    },
  },
};

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader", postcssConfig],
      },
      {
        test: /\.less$/i,
        use: ["style-loader", "css-loader", postcssConfig, "less-loader"],
      },
      {
        test: /\.scss$/i,
        use: ["style-loader", "css-loader", postcssConfig, "sass-loader"],
      },
    ],
  },
};

上面同时支持CSS/SCSS/Less文件引入,都配置了PostCSS。看PostCSS的位置是在SCSS和Less之后,这就是后处理器的含义。然后是要打包的各个文件代码:

/* index.js */
import "./index.scss";
import "./index.less";
import "./index.css";
console.log("你好,jzplp");

/* index.scss */
$jzabc: red;
.jzplp-scss {
  color: $jzabc;
  width: stretch;
}

/* index.less */
@jzabc: red;
.jzplp-less {
  color: @jzabc;
  width: stretch;
}

/* index.css */
.jzplp-css {
  color: blue;
  width: stretch;
}

最后生成的结果如下:

".jzplp-scss{color:red;width:-webkit-fill-available;width:-moz-available;width:stretch}",
".jzplp-css {\n  color: blue;\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}",
".jzplp-less {\n  color: red;\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}\n",
console.log("你好,jzplp");

可以看到,不仅成功编译了SCSS和Less,而且代码经过了PostCSS处理,加上了浏览器引擎前缀。

PostCSS直接解析SCSS与Less

在前面PostCSS用作后处理器的方式中,修改一下编译的代码:

/* index.js */
import "./index.scss";
import "./index.less";
import "./index.css";
console.log("你好,jzplp");

/* index.scss */
$jzabc: stretch;
.jzplp-scss {
  width: $jzabc;
}

/* index.less */
@jzabc: stretch;
.jzplp-less {
  width: @jzabc;
}

重新编译,结果如下:

".jzplp-less {\n  width: -webkit-fill-available;\n  width: -moz-available;\n  width: stretch;\n}\n"
".jzplp-scss{width:-webkit-fill-available;width:-moz-available;width:stretch}"
console.log("你好,jzplp");

如果我们将postcss-loader与less-loader和sass-loader换一下位置,先处理PostCSS,再编译SCSS和Less,结果会如何呢?参考配置如下,仅展示改动部分:

{
  test: /\.less$/i,
  use: ["style-loader", "css-loader", "less-loader", postcssConfig],
},
{
  test: /\.scss$/i,
  use: ["style-loader", "css-loader", "sass-loader", postcssConfig],
},

重新编译,结果如下:

".jzplp-less {\n  width: stretch;\n}\n"
".jzplp-scss{width:stretch}"
console.log("你好,jzplp");

可以看到生成结果并不同,stretch没有增加浏览器引擎前缀。原因是先经过PostCSS处理时,并不能识别SCSS和Less语法,因此无法处理相关的内容。而其中的正常CSS语法,PostCSS依然可以处理。

PostCSS还提供了SCSS和Less语法的解析器postcss-scss和postcss-less,我们引入试一下。首先修改Webapck配置:

{
  test: /\.less$/i,
  use: [
    "style-loader",
    "css-loader",
    "less-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          syntax: 'postcss-less',
          plugins: ["autoprefixer"],
        },
      },
    },
  ],
},
{
  test: /\.scss$/i,
  use: [
    "style-loader",
    "css-loader",
    "sass-loader",
    {
      loader: "postcss-loader",
      options: {
        postcssOptions: {
          syntax: 'postcss-scss',
          plugins: ["autoprefixer"],
        },
      },
    },
  ],
},

重新编译,结果如下:

".jzplp-less {\n  width: stretch;\n}\n"
".jzplp-scss{width:stretch}"
console.log("你好,jzplp");

可以看到效果与上面PostCSS先处理的情况一致,stretch没有增加浏览器引擎前缀。这是因为postcss-scss和postcss-less都是将SCSS和Less代码转换为AST,然后再将AST转换回来,并不编译为CSS代码。因此SCSS和Less变量并未被PostCSS插件转义。它们的作用主要是提供SCSS和Less的AST结点,方便对应的SCSS和Less的PostCSS插件做处理。虽然相关文档的使用场景说了可以直接使用普通PostCSS插件处理,但上面的测试结果说明,可以处理但并不完美。

PostCSS的SourceMap

命令行生成SourceMap

首先来看一下命令行方式如何生成SourceMap。先给出一个简单的源文件:

.jzplp {
  width: stretch;
}
div {
  color: var(--abc, red);
}

然后执行命令:postcss index.css -u autoprefixer -o out.css。这种情况下PostCSS不会去读postcss.config.js配置文件,而是仅使用命令行传参来转义。,默认情况下,PostCSS会生成inline,即附加在生成文件中作为注释的SourceMap。生成结果如下:

.jzplp {
  width: -webkit-fill-available;
  width: -moz-available;
  width: stretch;
}
div {
  color: var(--abc, red);
}

/*# sourceMappingURL* 防止报错 *=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImNzcy9pbmRleC5jc3MiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7RUFDRSw2QkFBYztFQUFkLHFCQUFjO0VBQWQsY0FBYztBQUNoQjtBQUNBO0VBQ0Usc0JBQXNCO0FBQ3hCIiwiZmlsZSI6Im91dC5jc3MiLCJzb3VyY2VzQ29udGVudCI6WyIuanpwbHAge1xyXG4gIHdpZHRoOiBzdHJldGNoO1xyXG59XHJcbmRpdiB7XHJcbiAgY29sb3I6IHZhcigtLWFiYywgcmVkKTtcclxufVxyXG4iXX0= */

如果不希望生成SourceMap,那么命令行中附加--no-map即可:postcss css/index.css -u autoprefixer -o out.css --no-map。但如果希望生成独立的SourceMap文件,就必须使用PostCSS配置文件了。我们先把命令行修改为:postcss css/index.css -o out.css。然后配置postcss.config.js:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
};

执行命令行,发现生成文件与--no-map效果一致,不生成SourceMap。如果生成inline的SourceMap,配置文件修改为:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
  map: true,
};

如果希望生成的独立的配置文件,则配置文件修改为:

const autoprefixer = require("autoprefixer");
module.exports = {
  plugins: [autoprefixer],
  map: { inline: false },
};

这时候会生成一个文件out.css.map,其内容如下:

{
  "version": 3,
  "sources": [
    "css/index.css"
  ],
  "names": [],
  "mappings": "AAAA;EACE,6BAAc;EAAd,qBAAc;EAAd,cAAc;AAChB;AACA;EACE,sBAAsB;AACxB",
  "file": "out.css",
  "sourcesContent": [
    ".jzplp {\r\n  width: stretch;\r\n}\r\ndiv {\r\n  color: var(--abc, red);\r\n}\r\n"
  ]
}

可以看到,这个SourceMap文件的格式规范与之前JavaScript的SourceMap规范是一致的。

API方式生成SourceMap

API方式与命令行的配置方式类似,首先是生成inline的SourceMap:

const fs = require("fs");
const autoprefixer = require("autoprefixer");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");

postcss([autoprefixer])
  .process(originData, { from: "css/index.css", to: "out.css", map: true })
  .then((res) => {
    console.log(res.css);
    fs.writeFileSync('out.css', res.css);
  });

生成的SourceMap会直接在res.css中作为注释存在,输出到文件中和命令行输出一致。如果不希望生成inline的SourceMap,则是这样配置:

postcss([autoprefixer])
  .process(originData, {
    from: "css/index.css",
    to: "out.css",
    map: { inline: false },
  })
  .then((res) => {
    console.log(res.map);
    const str = res.map.toString();
    fs.writeFileSync("out.css", res.css);
    fs.writeFileSync("out.css.map", str);
  });

/* 输出结果
SourceMapGenerator{...省略 }
*/

这时候SourceMap在res.map中,而且不是以字符串的形式存在,而是以SourceMapGenerator对象的形式存在。我们在快速定位源码问题:SourceMap的生成/使用/文件格式与历史文章中描述过,这是source-map包中的对象类型,可以直接输出SourceMap字符串,也可以做进一步处理。这里将它输出成字符串,并打印到文件中,内容和命令行输出一致。

SourceMap内容解析

我们使用前面文章Webpack中各种devtool配置的含义与SourceMap生成逻辑中介绍过的解析SourceMap工具代码,来解析上面生成的SourceMap文件。解析结果如下:

生成代码行10  源代码行10  源名称-            源文件:css/index.css
生成代码行22  源代码行22  源名称-            源文件:css/index.css
生成代码行231 源代码行216 源名称-            源文件:css/index.css
生成代码行32  源代码行22  源名称-            源文件:css/index.css
生成代码行323 源代码行216 源名称-            源文件:css/index.css
生成代码行42  源代码行22  源名称-            源文件:css/index.css
生成代码行416 源代码行216 源名称-            源文件:css/index.css
生成代码行50  源代码行30  源名称-            源文件:css/index.css
生成代码行60  源代码行40  源名称-            源文件:css/index.css
生成代码行72  源代码行52  源名称-            源文件:css/index.css
生成代码行724 源代码行524 源名称-            源文件:css/index.css
生成代码行80  源代码行60  源名称-            源文件:css/index.css

可以看到,全是生成代码和源码的对应关系,一个标识符都没有。这也正常,毕竟CSS代码中并没有真正意义上JavaScript的那种标识。这里我们表格列一下对应关系指向的具体内容:

源代码标识符 源代码行号 源代码列号 生成代码标识符 生成代码行号 生成代码列号
.jzplp 1 0 .jzplp 1 0
width 2 2 width 2 2
; 2 16 ; 2 31
width 2 2 width 3 2
; 2 16 ; 3 23
width 2 2 width 4 2
; 2 16 ; 4 16
} 3 0 } 5 0
div 4 0 div 6 0
color 5 2 color 7 2
; 5 24 ; 7 24
} 6 0 } 8 0

通过上面的表格,可以看到PostCSS记录SourceMap的规律:标记规则选择器的开头,规则大括号的结尾}。标记CSS声明的开头,声明值的结尾;。

PostCSS的AST

生成AST数据

我们来试一下如何使用PostCSS生成AST数据。首先还是先构造一个CSS文件,里面涵盖了CSS的一些常见场景:

.jzplp {
  width: 10px;
  color: var(--abc, red);
}
div .jzplp2 {
  color: blue !important;
}
/* this is comment jzplp */
.jz1 {
  margin: 5px;
  .jz2 {
    padding: 10px 20px;
  }
}
@media (max-width: 768px) {
  #jz3 {
    border: 1px solid red;
  }
}

然后使用前面API的方式处理CSS文件,在res.root中就能拿到AST数据:

const fs = require("fs");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");
postcss()
  .process(originData, {
    from: "css/index.css",
    to: "out.css",
    map: { inline: false },
  })
  .then((res) => {
    console.log(res.root);
  });

PostCSS还提供了方法,可以直接解析CSS拿到AST数据:

const fs = require("fs");
const postcss = require("postcss");

const originData = fs.readFileSync("./css/index.css", "utf-8");
const data = postcss.parse(originData);
const dataJson = JSON.stringify(data.toJSON());
console.log(dataJson);

然后得到我们最终拿到的AST数据的JSON格式,后面的分析都基于这份数据:

{
  "raws": { "semicolon": false, "after": "" },
  "type": "root",
  "nodes": [
    {
      "raws": { "before": "", "between": " ", "semicolon": true, "after": "\r\n" },
      "type": "rule",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 14, "line": 2, "offset": 24 },
            "inputId": 0,
            "start": { "column": 3, "line": 2, "offset": 12 }
          },
          "prop": "width",
          "value": "10px"
        },
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 25, "line": 3, "offset": 51 },
            "inputId": 0,
            "start": { "column": 3, "line": 3, "offset": 28 }
          },
          "prop": "color",
          "value": "var(--abc, red)"
        }
      ],
      "source": {
        "end": { "column": 1, "line": 4, "offset": 54 },
        "inputId": 0,
        "start": { "column": 1, "line": 1, "offset": 0 }
      },
      "selector": ".jzplp"
    },
    {
      "raws": { "before": "\r\n", "between": " ", "semicolon": true, "after": "\r\n" },
      "type": "rule",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 25, "line": 6, "offset": 96 },
            "inputId": 0,
            "start": { "column": 3, "line": 6, "offset": 73 }
          },
          "prop": "color",
          "important": true,
          "value": "blue"
        }
      ],
      "source": {
        "end": { "column": 1, "line": 7, "offset": 99 },
        "inputId": 0,
        "start": { "column": 1, "line": 5, "offset": 56 }
      },
      "selector": "div .jzplp2"
    },
    {
      "raws": { "before": "\r\n", "left": " ", "right": " " },
      "type": "comment",
      "source": {
        "end": { "column": 27, "line": 8, "offset": 128 },
        "inputId": 0,
        "start": { "column": 1, "line": 8, "offset": 101 }
      },
      "text": "this is comment jzplp"
    },
    {
      "raws": { "before": "\r\n", "between": " ", "semicolon": false, "after": "\r\n" },
      "type": "rule",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": ": " },
          "type": "decl",
          "source": {
            "end": { "column": 14, "line": 10, "offset": 152 },
            "inputId": 0,
            "start": { "column": 3, "line": 10, "offset": 140 }
          },
          "prop": "margin",
          "value": "5px"
        },
        {
          "raws": { "before": "\r\n  ", "between": " ", "semicolon": true, "after": "\r\n  " },
          "type": "rule",
          "nodes": [
            {
              "raws": { "before": "\r\n    ", "between": ": " },
              "type": "decl",
              "source": {
                "end": { "column": 23, "line": 12, "offset": 187 },
                "inputId": 0,
                "start": { "column": 5, "line": 12, "offset": 168 }
              },
              "prop": "padding",
              "value": "10px 20px"
            }
          ],
          "source": {
            "end": { "column": 3, "line": 13, "offset": 192 },
            "inputId": 0,
            "start": { "column": 3, "line": 11, "offset": 156 }
          },
          "selector": ".jz2"
        }
      ],
      "source": {
        "end": { "column": 1, "line": 14, "offset": 195 },
        "inputId": 0,
        "start": { "column": 1, "line": 9, "offset": 130 }
      },
      "selector": ".jz1"
    },
    {
      "raws": { "before": "\r\n", "between": " ", "afterName": " ", "semicolon": false, "after": "\r\n" },
      "type": "atrule",
      "name": "media",
      "source": {
        "end": { "column": 1, "line": 19, "offset": 270 },
        "inputId": 0,
        "start": { "column": 1, "line": 15, "offset": 197 }
      },
      "params": "(max-width: 768px)",
      "nodes": [
        {
          "raws": { "before": "\r\n  ", "between": " ", "semicolon": true, "after": "\r\n  " },
          "type": "rule",
          "nodes": [
            {
              "raws": { "before": "\r\n    ", "between": ": " },
              "type": "decl",
              "source": {
                "end": { "column": 26, "line": 17, "offset": 262 },
                "inputId": 0,
                "start": { "column": 5, "line": 17, "offset": 240 }
              },
              "prop": "border",
              "value": "1px solid red"
            }
          ],
          "source": {
            "end": { "column": 3, "line": 18, "offset": 267 },
            "inputId": 0,
            "start": { "column": 3, "line": 16, "offset": 228 }
          },
          "selector": "#jz3"
        }
      ]
    }
  ],
  "source": {
    "end": { "column": 2, "line": 19, "offset": 270 },
    "inputId": 0,
    "start": { "column": 1, "line": 1, "offset": 0 }
  },
  "inputs": [
    {
      "hasBOM": false,
      "css": ".jzplp {\r\n  width: 10px;\r\n  color: var(--abc, red);\r\n}\r\ndiv .jzplp2 {\r\n  color: blue !important;\r\n}\r\n/* this is comment jzplp */\r\n.jz1 {\r\n  margin: 5px;\r\n  .jz2 {\r\n    padding: 10px 20px;\r\n  }\r\n}\r\n@media (max-width: 768px) {\r\n  #jz3 {\r\n    border: 1px solid red;\r\n  }\r\n}",
      "id": "<input css abVh40>"
    }
  ]
}

公共属性

PostCSS的AST结点类型不多,且不同的结点有一些公共属性,这里列举一下:

属性名 属性含义 数据举例 数据举例说明
type 结点类型 decl CSS声明类型
nodes 子结点列表 [...] 子结点数组
raws 结点中的空白字符 { "semicolon": false, "after": "\r\n" } 下面单独说明
source 结点的源码位置 {...} 下面单独说明

其中最重要的是type和nodes属性,一个表示结点的类型,一个是包含的子结点列表。raws中分类整理了结点中的空白字符,这里列举一下属性含义:

  • raws.before 结点前的空白符
  • raws.after 结点后的空白符
  • raws.between 对于decl结点是属性和值中间的空白符,例如key: value中间的,对于其它结点类型是其它位置的空白。
  • raws.semicolon 如果最后一个子结点有分号则为true
  • raws.afterName 在at规则和它的条件之间的空白。例如@media (中间
  • raws.left 注释中/*之后,注释内容之前。
  • raws.right 注释内容之后,注释*/之前。

source是生成SourceMap使用的数据,通过文件标识+起点终点的行号列号,可以精确找到结点在源文件的位置。这里也列举一下属性含义:

  • source.inputId 源文件标识
  • source.start 起点位置信息
  • source.end 终点位置信息
  • source.start.line或source.end.line 行号
  • source.start.column或source.end.column 列号
  • source.start.offset或source.end.offset 距离文件起始的偏移量

CSS结点类型和私有属性

这里列举一下PostCSS的AST结点类型,以及它们私有的属性:

root结点类型

标识一个CSS文件,其中包含所有解析的结点。

rule结点类型

rule结点是一个CSS规则,包含选择器,后面跟着一个声明块。举例如下:

/* 一个rule结点 */
.jzplp {
  width: 10px;
  color: var(--abc, red);
}
/* 一个rule结点 */
.jz1 {
  margin: 5px;
  /* 嵌套一个rule结点 */
  .jz2 {
    padding: 10px 20px;
  }
}

rule结点有一个私有属性selector,表示这个结点的选择器,值例如.jzplp,或者组合选择器div .jzplp2

comment结点类型

comment结点表示一个注释。它有个私有属性text,表示注释内容。

decl结点类型

decl结点表示一个CSS声明,即key: value;的结构。例如width: 10px;或者border: 1px solid red;。即使值包含函数或者多个值集合,decl结点也不再细分,不包含子结点。decl结点的私有属性如下:

  • prop: CSS声明属性名,例如width
  • value: CSS声明属性值,例如10px
  • important: 如果声明设置了!important,这里的值为true

atrule结点类型

atrule结点表示一个at规则,即以@符号开头的规则。举例如下:

/* 一个atrule结点 */
@media (max-width: 768px) {
  /* 嵌套一个rule结点 */
  #jz3 {
    border: 1px solid red;
  }
}

atrule结点有私有属性name,表示@后面跟的标识符,例如上面的media。还有一个私有属性params,表示at规则内的参数,例如上面的(max-width: 768px)

插件开发

新建插件

首先我们创建pluginJzplp.js文件存放插件代码,然后在PostCSS配置文件中引入:

// pluginJzplp.js
function pluginJzplp() {
  console.log('init');
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log('Root');
    },
    Declaration() {},
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

// postcss.config.js
const pluginJzplp = require('./pluginJzplp');
module.exports = {
  plugins: [pluginJzplp],
};

可以看到,插件就是一个函数,函数的返回值是一个对象,其中postcssPlugin属性表示函数名称,其它属性大部分都和遍历AST有关。这个函数本身还需要设置postcss属性为true,这样才能被PostCSS认为是插件。我们看下执行后的输出:

// 转义单文件
init
init
Root
// 转义两个文件
init
init
Root
init
init
Root

可以看到,每转义一个文件,插件函数会被执行两次,但实际遍历(输出Root)只有一次。我们再来看下函数入参:

// pluginJzplp.js
function pluginJzplp(p1, p2, p3) {
  console.log(p1, p2, p3);
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log('Root');
    },
    Declaration() {},
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

// postcss.config.js
const pluginJzplp = require('./pluginJzplp');
module.exports = {
  plugins: [pluginJzplp('jz1', 'jz2', 3)],
};

/* 单文件输出结果
jz1 jz2 3
Root
Root
*/

可以看到,插件函数的入参全部是插件引入时传的参数。

遍历AST

PostCSS插件遍历AST的方法与Babel类似,都是以结点类型名作为函数属性。当遍历到对应类型的结点时,函数被触发,入参为对应的结点数据。我们来看一个插件例子:

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    Rule(data) {
      console.log("Rule", data.selector);
    },
    Declaration: {
      color(data) {
        console.log("Declaration color", data.value);
      },
      width(data) {
        console.log("Declaration width", data.value);
      },
    },
    AtRule: {
      media(data) {
        console.log("AtRule media", data.params);
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

/* 输出结果
Root
Rule .jzplp
Declaration width 10px
Declaration color var(--abc, red)
Rule div .jzplp2
Declaration color blue
Rule .jz1
Rule .jz2
AtRule media (max-width: 768px)
Rule #jz3
*/

对应节点类型函数的入参,就是这个类型的结点数据本身,上面的例子中输出了一些节点属性。对于AtRule和Declaration类型的结点可以接受更细分的类型函数,例如Declaration可以把属性名作为key分别定义遍历函数。

每个类型的结点可以定义两种类型的函数,一种是进入结点时,一种是退出结点时。区别在于是否已经遍历过子结点。上面的介绍的函数都是进入结点时的函数,退出函数名则需要在后面加Exit,例如RootExit, RuleExit。只有拥有子结点的类型有退出函数,Declaration这种没有子结点的结点就只有一个函数。

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Once() {
      console.log("Once");
    },
    OnceExit() {
      console.log("OnceExit");
    },
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Rule(data) {
      console.log("Rule", data.selector);
    },
    RuleExit(data) {
      console.log("RuleExit", data.selector);
    },
    Declaration(data) {
      console.log("Declaration", data.prop, data.value);
    },
    AtRule: {
      media(data) {
        console.log("AtRule media", data.params);
      },
    },
    AtRuleExit: {
      media(data) {
        console.log("AtRuleExit media", data.params);
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

/* 输出结果
Once
Root
Rule .jzplp
Declaration width 10px
Declaration color var(--abc, red)
RuleExit .jzplp
Rule div .jzplp2
Declaration color blue
RuleExit div .jzplp2
Rule .jz1
Declaration margin 5px
Rule .jz2
Declaration padding 10px 20px
RuleExit .jz2
RuleExit .jz1
AtRule media (max-width: 768px)
Rule #jz3
Declaration border 1px solid red
RuleExit #jz3
AtRuleExit media (max-width: 768px)
RootExit
OnceExit
*/

通过上面的例子可以看到,先触发进入结点的函数,再访问内部结点,然后再触发退出结点的函数。上面例子中还有Once和OnceExit函数,这两个是最早和最后触发的两个函数,入参是root数据。

修改AST结点属性

首先从修改AST结点的属性开始。下面的插件修改了Declaration和AtRule的属性:

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Rule(data) {
      console.log("Rule", data.selector);
    },
    RuleExit(data) {
      console.log("RuleExit", data.selector);
    },
    Declaration: {
      color(data) {
        data.value = "yellow";
        console.log("Declaration color");
      },
    },
    AtRule: {
      media(data) {
        data.params = "(max-width: 1000px)";
        console.log("AtRule media");
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

我们简化一下转义的CSS代码,运行PostCSS后查看输出结果,发现生成的代码中确实被改掉了。

/* 源CSS代码 */
div .jzplp2 {
  color: blue !important;
}
.jz2 {
  width: 10px;
}
@media (max-width: 768px) {}

/* 生成CSS代码 */
div .jzplp2 {
  color: yellow !important;
}
.jz2 {
  width: 10px;
}
@media (max-width: 1000px) {}

/* 命令行输出
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
Rule .jz2
RuleExit .jz2
AtRule media
RootExit
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
AtRule media
RootExit
*/

/* 插件中删掉修改AtRule的代码 命令行输出
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
Rule .jz2
RuleExit .jz2
AtRule media
RootExit
Root
Rule div .jzplp2
Declaration color
RuleExit div .jzplp2
RootExit
*/

从命令行中输出中,我们还发现,Root被遍历了两次。第一次遍历了全部属性,第二次仅仅遍历了修改过的属性以及他们的父结点。如果把插件中修改AtRule的代码删除再执行,发现Root还是被遍历了两次。因此父结点的再上级结点也会被遍历,一直到根结点。因此,假设我们一直将属性修改为不同的值,就会触发无限循环遍历。开发插件的时候要注意这件事。

let a = 1;
function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Declaration: {
      width(data) {
        data.value = ++a;
        console.log("Declaration width");
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

/* 命令行输出
Root
Declaration width
RootExit
Root
Declaration width
RootExit
Root
...无限循环
*/

增删AST结点

PostCSS提供了每个结点的创建函数,可以创建对应类型的结点。每个结点上还挂载了父节点,子结点,各种位置添加结点的函数,可以让我们方便增删和处理结点。例如下面这个插件例子:

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Root() {
      console.log("Root");
    },
    RootExit() {
      console.log("RootExit");
    },
    Declaration: {
      width(data, { Declaration }) {
        const decl = new Declaration({ prop: "color", value: "black" });
        data?.parent?.append(decl);
      },
    },
    Rule(data, { Rule, Declaration }) {
      if (data.selector === "div .jzplp2") {
        const decl = new Declaration({ prop: "color", value: "yellow" });
        const rule = new Rule({ selector: ".jz3", nodes: [decl] });
        data.before(rule);
      }
    },
    AtRule: {
      media(data) {
        if (!data.flag) {
          data.flag = 1;
          const cloned = data.clone(data);
          data.push(cloned);
        }
      },
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

注意部分可能造成无限循环的场景需要进行限制。输出结果如下:

/* 源CSS代码 */
div .jzplp2 {
  color: blue !important;
}
.jz2 {
  width: 10px;
}
@media (max-width: 768px) {}


/* 生成CSS代码 */
.jz3 {
  color: yellow;
}
div .jzplp2 {
  color: blue !important;
}
.jz2 {
  width: 10px;
  color: black;
}
@media (max-width: 768px) {
@media (max-width: 768px) {}}

/* 命令行输出
Root
RootExit
Root
RootExit
*/

AST辅助工具

在前面介绍过PostCSS的AST结点类型一共就只有几种,例如规则rule规则结点和decl声明结点。但CSS实际的复杂度要更高,例如结点选择器还可以分为属性选择器,类选择器,标签选择器,还有组合选择器,伪类伪元素选择器等等,有些甚至是函数的形态。CSS声明的值也是多种多样,有字符串,数字,函数,组合值等等。像这类数据PostCSS并没有直接提供解析方式,但有一些开源工具可以帮助解析。下面我们列举几个工具简单介绍:

postcss-selector-parser

postcss-selector-parser是一个解析CSS选择器的工具。虽然名字中带postcss,但实际上不依赖PostCSS运行。postcss-selector-parser可以将选择器字符串解析为AST数据。其中包含一些结点类型,用type字段表示:

  • attribute 属性选择器结点
  • class 类选择器结点
  • combinator 组合选择器结点
  • id id选择器结点
  • pseudo 伪类和伪元素选择器结点
  • tag 标签选择器结点
  • selector 选择器容器结点
  • 等等...

这里我们构造一个复杂一点的选择器字符串,遍历结点,看能解析出哪些类型:

const parser = require('postcss-selector-parser');
const selectorStr = '.jz1, div+jz2:not(#jzplp::first-line)';

const transform = selectors => {
    selectors.walk(data => {
      console.log(data.type.padEnd(12), data.toString().padEnd(35), data?.nodes?.map?.(item => item.toString()));
    });
};
const transformed = parser(transform).processSync(selectorStr);

/*
selector     .jz1                                [ '.jz1' ]
class        .jz1                                undefined
selector      div+jz2:not(#jzplp::first-line)    [ ' div', '+', 'jz2', ':not(#jzplp::first-line)' ]
tag           div                                undefined
combinator   +                                   undefined
tag          jz2                                 undefined
pseudo       :not(#jzplp::first-line)            [ '#jzplp::first-line' ]
selector     #jzplp::first-line                  [ '#jzplp', '::first-line' ]
id           #jzplp                              undefined
pseudo       ::first-line                        []
*/

可以看到,组合选择器里面并不包含其它选择器,而是仅有连接符号本身。这个工具本身没有直接包含将代码转为JSON形式的AST数据的方法。我试了下ast方法,输出的内容也不是JSON数据,因此这里就不列出了。

const parser = require('postcss-selector-parser');
parser(() => {})
  .ast(selectorStr)
  .then((data) => console.log(data));
// 输出非JSON数据

工具内提供了各种创建AST结点的方法,这里我们尝试修改和添加AST结点试试。

const parser = require("postcss-selector-parser");
const selectorStr = ".jz1, div+jz2:not(#jzplp::first-line)";
const transform = (selectors) => {
  selectors.walk((data) => {
    // 修改结点属性
    if (data.type === "tag" && data.value === "jz2") data.value = "h1";
    // 添加结点
    if (data.type === "selector" && data.toString() === '.jz1') {
      const attr = parser.attribute({ attribute: "href" });
      data.nodes.push(attr);
    }
  });
};
const transformed = parser(transform).processSync(selectorStr);
console.log(transformed);

/* 输出结果
.jz1[href], div+h1:not(#jzplp::first-line)
*/

postcss-value-parser

postcss-selector-parser是一个解析CSS值的工具,也可以脱离PostCSS运行。它将CSS声明中的值字符串解析为AST数据。首先我们构造一个复杂的值例子,输出对应的AST数据:

const valueParser = require("postcss-value-parser");
const valueStr = "calc(50vh + 10px) solid var(--jzplp, rgba(255,255,255, 0.5))";
var parsedValue = valueParser(valueStr);
console.log(JSON.stringify(parsedValue));

/* 输出结果
{
  "nodes": [
    {
      "type": "function",
      "sourceIndex": 0,
      "value": "calc",
      "before": "",
      "after": "",
      "sourceEndIndex": 17,
      "nodes": [
        { "type": "word", "sourceIndex": 5, "sourceEndIndex": 9, "value": "50vh" },
        { "type": "space", "sourceIndex": 9, "sourceEndIndex": 10, "value": " " },
        { "type": "word", "sourceIndex": 10, "sourceEndIndex": 11, "value": "+" },
        { "type": "space", "sourceIndex": 11, "sourceEndIndex": 12, "value": " " },
        { "type": "word", "sourceIndex": 12, "sourceEndIndex": 16, "value": "10px" }
      ]
    },
    { "type": "space", "sourceIndex": 17, "sourceEndIndex": 18, "value": " " },
    { "type": "word", "sourceIndex": 18, "sourceEndIndex": 23, "value": "solid" },
    { "type": "space", "sourceIndex": 23, "sourceEndIndex": 24, "value": " " },
    {
      "type": "function",
      "sourceIndex": 24,
      "value": "var",
      "before": "",
      "after": "",
      "sourceEndIndex": 60,
      "nodes": [
        { "type": "word", "sourceIndex": 28, "sourceEndIndex": 35, "value": "--jzplp" },
        { "type": "div", "sourceIndex": 35, "sourceEndIndex": 37, "value": ",", "before": "", "after": " " },
        {
          "type": "function",
          "sourceIndex": 37,
          "value": "rgba",
          "before": "",
          "after": "",
          "sourceEndIndex": 59,
          "nodes": [
            { "type": "word", "sourceIndex": 42, "sourceEndIndex": 45, "value": "255" },
            { "type": "div", "sourceIndex": 45, "sourceEndIndex": 46, "value": ",", "before": "", "after": "" },
            { "type": "word", "sourceIndex": 46, "sourceEndIndex": 49, "value": "255" },
            { "type": "div", "sourceIndex": 49, "sourceEndIndex": 50, "value": ",", "before": "", "after": "" },
            { "type": "word", "sourceIndex": 50, "sourceEndIndex": 53, "value": "255" },
            { "type": "div", "sourceIndex": 53, "sourceEndIndex": 55, "value": ",", "before": "", "after": " " },
            { "type": "word", "sourceIndex": 55, "sourceEndIndex": 58, "value": "0.5" }
          ]
        }
      ]
    }
  ]
}
*/

通过上面的例子,CSS声明值被解析成了JSON形式的AST数据。其中每个结点以type属性来区分类型。我们列举下其中的常用AST结点:

  • word 普通值结点,例如 10px, 255等
  • string 字符串节点,例如 "/jz/123"
  • div 分隔节点,例如 逗号,斜杠等
  • space 空格结点
  • comment 注释结点
  • function 函数结点,里面可以包含其它结点,例如 rgba(), var()等

然后我们再试着遍历AST,以及修改结点内容。这里在前面代码的基础上继续写:

parsedValue.walk(function (node) {
  console.log(node.type.padEnd(10), "[", node.value.padEnd(10), "]");
  if (node.type === "word" && node.value === "10px")  node.value = "10em";
});
console.log(parsedValue.toString());

/* 输出结果
function   [ calc       ]
word       [ 50vh       ]
space      [            ]
word       [ +          ]
space      [            ]
word       [ 10px       ]
space      [            ]
word       [ solid      ]
space      [            ]
function   [ var        ]
word       [ --jzplp    ]
div        [ ,          ]
function   [ rgba       ]
word       [ 255        ]
div        [ ,          ]
word       [ 255        ]
div        [ ,          ]
word       [ 255        ]
div        [ ,          ]
word       [ 0.5        ]
calc(50vh + 10em) solid var(--jzplp, rgba(255,255,255, 0.5))
*/

自定义语法规则

PostCSS不仅可以编译CSS语法,还可以自定义语法规则实现功能扩展。

编写自定义语法规则

PostCSS编写自定义语法的方法,就是实现parser/stringifier/syntax方法,然后在PostCSS参数中传入对应方法即可。

  • parser方法 将字符串转为抽象语法树
  • stringifier方法 将抽象语法树转为字符串
  • syntax 相当于parser + stringifier

编写自定义语法规则是一件很复杂的事情,需要经过词法分析句法分析等步骤,很显然超出了这篇文章的范畴。因此这里我们只给出一个非常简单的demo,示意一下自定义语法的开发接口。首先来看下自定义语法的方式。这里我们设置文件内容的每行为xxx=xxx,尝试用自定义语法解析和生成这样的结构。

/* 需要解析的文件 index.jzcss
jzplp=12345
jz2=98765
*/

const postcss = require("postcss");

function parseLine(strLine) {
  const strList = strLine.split("=");
  const customKey = { type: "customKey", value: strList[0] };
  const customValue = { type: "customValue", value: strList[1] };
  const equal = { type: "equal", value: "=", nodes: [customKey, customValue] };
  return equal;
}

function parse(cssStr) {
  console.log("parser!");
  const strList = cssStr.split("\n");
  const nodes = strList.map(line => parseLine(line));
  const root = postcss.root();
  root.nodes = nodes;
  return root;
}

function recursion(node) {
  if (node.type === "customKey" || node.type === "customValue") return node.value;
  if (node.type === "equal") return recursion(node.nodes[0]) + node.value + recursion(node.nodes[1]);
  return node.nodes.map((item) => recursion(item)).join("\n");
}

function stringify(root, builder) {
  console.log("stringify!");
  builder(recursion(root), root);
}

module.exports = {
  parse,
  stringify,
};

首先是parse方法,外面包裹一个PostCSS的root结点。里面切分出每行,对每行切分出equal等号结点,子结点为customKey和customValue两个。解析成AST数据后返回。stringify方法使用了递归,针对不同的结点类型输出不同的字符串,进行拼合。最后将字符串和root结点传回builder回调。然后我们尝试使用自定义规则来解析文件,输出AST数据:

const fs = require("fs");
const postcss = require("postcss");
const customParser = require("./parser");

const originData = fs.readFileSync("./index.jzcss", "utf-8");
postcss()
  .process(originData, {
    from: "index.jzcss",
    to: "out.css",
    syntax: customParser,
  })
  .then((res) => {
    console.log(res.css);
    console.log(JSON.stringify(res.root.toJSON()));
  });

/* 输出结果
parser!
stringify!
jzplp=12345
jz2=98765
{
  "raws": {},
  "type": "root",
  "nodes": [
    {
      "type": "equal",
      "value": "=",
      "nodes": [
        { "type": "customKey", "value": "jzplp" },
        { "type": "customValue", "value": "12345\r" }
      ]
    },
    {
      "type": "equal",
      "value": "=",
      "nodes": [
        { "type": "customKey", "value": "jz2" },
        { "type": "customValue", "value": "98765" }
      ]
    }
  ],
  "lastEach": 1,
  "indexes": {},
  "inputs": []
}
*/

为自定义规则编写插件

创建了自定义规则结点之后,我们再为这种规则结点编写一个插件,尝试修改AST结点再生成代码。

function pluginJzplp() {
  return {
    postcssPlugin: "postcss-plugin-jzplp",
    Once(root) {
      root.walk((node) => {
        if (node.type === "equal") {
          node.value = "==";
          if (node.nodes[0].value === "jz2")
            node.nodes[0].value = "jzplp2";
        }
      });
    },
  };
}
pluginJzplp.postcss = true;
module.exports = pluginJzplp;

我们自己创建的结点类型太简陋了,不能被PostCSS识别为结点,不能使用结点类型名称开头的函数;walk函数也无法遍历更深层的结点。因此我们手动修改子结点的属性。最后引入插件并执行,从输出结果看改动成功了。

const fs = require("fs");
const postcss = require("postcss");
const customParser = require("./parser");
const pluginJzplp = require("./pluginJzplp");
const originData = fs.readFileSync("./index.jzcss", "utf-8");

postcss([pluginJzplp])
  .process(originData, {
    from: "index.jzcss",
    to: "out.css",
    syntax: customParser,
  })
  .then((res) => {
    console.log(res.css);
  });

/* 输出结果
jzplp==12345
jzplp2==98765
*/

总结

这篇文章介绍了PostCSS的作用:转换CSS的代码成AST,经过插件处理再生成新CSS代码,经常被用作后处理和添加兼容性;还介绍了各种使用方法:命令行方式,API方式,Webapck);然后介绍了几个插件的作用,以及与SCSS和Less组合使用。后面又介绍了PostCSS的SourceMap与AST结构,如何开发插件与自定义语法。

从作用上来,虽然CSS本身不是完整的编程语言,但PostCSS对CSS却像编程语言一样处理。只不过AST结点类型只有几种,看起来有点简陋,还需要额外的辅助工具,比如解析选择器和声明值。这些辅助工具也是用AST来实现的。

除了PostCSS之外,还有CSSTree,CSSOM等都可以将CSS转换为AST语法树,但都没有PostCSS知名度高,有的甚至已经不维护了。

参考

❌
❌