阅读视图

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

🌐 《GraphQL in Next.js 初体验》中文笔记

🧩 一、概览:Next.js + GraphQL 是怎么“对话”的?

Next.js 是前后端一体框架,而 GraphQL 是一种 API 查询语言。结合起来后:
🤝 Next.js 提供运行环境
🧠 GraphQL 提供数据语义接口

简单理解:

  • Next.js = 舞台和播放器(API 路径、页面渲染、Serverless 运行)
  • GraphQL = 剧本(Schema定义)、演员台词逻辑(Resolver)、导演策略(Context)

结构大致如下:

Next.js API Route ─┬─► ApolloServer / Yoga Server
                    │
                    └─► GraphQL Schema (定义数据结构)
                        └─► Resolver (具体处理逻辑)
                            └─► Context (跨请求共享上下文)

🧱 二、Schema:GraphQL 的“数据契约”

Schema 是 定义数据形状的语言模型
可以把它理解为数据库表结构 + API 文档的结合体。

示例 ⚙️

# ./graphql/schema.graphql
type User {
  id: ID!
  name: String!
  age: Int
}

type Query {
  users: [User!]!
  user(id: ID!): User
}

type Mutation {
  addUser(name: String!, age: Int): User
}

📘 要点笔记:

  • type:定义类型(用户、文章、评论等)
  • Query:读取数据的入口
  • Mutation:修改、创建、删除数据的入口

Schema 就像一份菜单,它定义了服务员能做的所有菜,但不做菜。
做菜的是 Resolver 👉


🧠 三、Resolver:GraphQL 的“大脑中枢”

Resolver 是 Schema 的执行器,它负责将查询请求映射到实际数据源
在 Next.js 中,一般写成 JS/TS 文件与 Schema 匹配。

示例 👇

// ./graphql/resolvers.js
const users = [
  { id: "1", name: "Neo", age: 29 },
  { id: "2", name: "Trinity", age: 27 },
];

export const resolvers = {
  Query: {
    users: () => users,
    user: (_, { id }) => users.find(u => u.id === id),
  },
  Mutation: {
    addUser: (_, { name, age }) => {
      const newUser = { id: String(users.length + 1), name, age };
      users.push(newUser);
      return newUser;
    },
  },
};

📘 要点笔记:

  • 每个字段对应一个函数。
  • 第一个参数 _ 通常是父级字段(这里未使用,可省略)。
  • 第二个参数 { id } 是客户端传入的变量。
  • Resolver 内不管 Schema 长啥样,它只需返回对应数据。

🌍 四、Context:连接“每个请求”的神经系统

Context 是 GraphQL 在请求周期中共享的环境对象
它的作用类似于:

  • 注入依赖(数据库实例、token 验证、用户状态)
  • 跨 Resolver 共享状态

示例:

import jwt from "jsonwebtoken";
import db from "./db.js";

export const createContext = ({ req }) => {
  const token = req.headers.authorization || "";
  let user = null;

  try {
    user = jwt.verify(token, process.env.JWT_SECRET);
  } catch (e) {
    console.log("token 无效或未提供");
  }

  return { db, user };
};

在 Resolver 中,就能这样访问:

Mutation: {
  addUser: (_, { name, age }, { db, user }) => {
    if (!user) throw new Error("未授权");
    return db.insertUser({ name, age });
  }
}

📘 要点笔记:

  • Context 在每次请求开始时创建(一次请求一次 Context)。
  • 经 Context 可安全访问外部资源且隔离状态。
  • 在 SSR 和 Serverless 模式的 Next.js 中非常实用。

⚙️ 五、在 Next.js 中整合 Apollo Server

在 Next.js 中最常见的方式是用 /api/graphql 作为后端入口。

// ./pages/api/graphql.js
import { ApolloServer } from "apollo-server-micro";
import { typeDefs } from "../../graphql/schema.js";
import { resolvers } from "../../graphql/resolvers.js";
import { createContext } from "../../graphql/context.js";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: createContext,
});

export const config = {
  api: { bodyParser: false },
};

export default server.createHandler({ path: "/api/graphql" });

💡 运行逻辑:

  1. 浏览器访问 /api/graphql
  2. Next.js 调用 ApolloServer 处理请求
  3. ApolloServer 根据 Schema 调用对应 Resolver
  4. Resolver 通过 Context 访问数据源
  5. 返回 JSON 响应

🖥️ 六、Next.js 前端调用示例

import { gql, useQuery } from "@apollo/client";

const ALL_USERS = gql`
  query {
    users {
      id
      name
      age
    }
  }
`;

export default function UsersList() {
  const { loading, error, data } = useQuery(ALL_USERS);
  if (loading) return <p>⏳ 加载中...</p>;
  if (error) return <p>❌ 出错啦: {error.message}</p>;
  return (
    <ul>
      {data.users.map(u => (
        <li key={u.id}>
          👤 {u.name}(年龄:{u.age ?? "未知"})
        </li>
      ))}
    </ul>
  );
}

在这里,前端只需声明「我想要什么」,
后端就帮你搞定「怎么算出来」。


💬 七、小结:三大组件一图流

<div style="max-width:720px;margin:auto;text-align:center;">
<svg width="100%" height="300" viewBox="0 0 720 300" xmlns="http://www.w3.org/2000/svg">
  <rect x="60" y="60" width="170" height="60" rx="10" fill="#A1C4FD" stroke="#333"/>
  <text x="145" y="95" text-anchor="middle" font-size="13">Schema (定义契约)</text>

  <rect x="280" y="60" width="170" height="60" rx="10" fill="#FDD692" stroke="#333"/>
  <text x="365" y="95" text-anchor="middle" font-size="13">Resolver (处理逻辑)</text>

  <rect x="500" y="60" width="170" height="60" rx="10" fill="#C2E9FB" stroke="#333"/>
  <text x="585" y="95" text-anchor="middle" font-size="13">Context (执行环境)</text>

  <line x1="230" y1="90" x2="280" y2="90" stroke="#000" stroke-width="2" marker-end="url(#arrow)"/>
  <line x1="450" y1="90" x2="500" y2="90" stroke="#000" stroke-width="2" marker-end="url(#arrow)"/>

  <defs>
    <marker id="arrow" markerWidth="10" markerHeight="10" refX="6" refY="3" orient="auto">
      <path d="M0,0 L0,6 L9,3 z" fill="#333"/>
    </marker>
  </defs>
</svg>
<p style="font-size:13px;color:#666;">▲ Next.js GraphQL 三件套结构关系图</p>
</div>

🚀 八、为什么 Next.js 是 GraphQL 的理想宿主?

  1. Serverless Ready:API 路由天然适合部署 Apollo 或 Yoga Server。
  2. SSR + CSR 混合渲染:可直连 GraphQL 数据,从后端直出页面。
  3. Edge Runtime:未来的 Web AGI 场景(边缘智能体)可直接调用 GraphQL 层。
  4. TypeScript 一体化支持:Schema + Resolver 均能生成类型定义,开发丝滑。

TypeScript泛型:让类型也"通用"的魔法

前言

大家好,我是小杨。还记得我刚学习TypeScript时,最让我头疼的就是泛型这个概念。什么TUK,看起来像密码一样神秘。但当我真正理解并开始使用泛型后,才发现它就像是TypeScript中的"瑞士军刀",能让我们的代码既灵活又类型安全。今天,我想和大家分享我对于TypeScript泛型的理解和实战经验。

什么是泛型?从函数参数到类型参数

想象一下,如果你要写一个函数,它既能处理数字,又能处理字符串,还能处理任何其他类型,你会怎么做?

在JavaScript中,我们可能会这样写:

// JavaScript方式 - 缺乏类型安全
function identity(value) {
    return value;
}

const num = identity(42);        // 返回42,但类型信息丢失了
const str = identity("hello");   // 返回"hello",类型信息丢失了

而在TypeScript中,泛型给了我们更好的解决方案:

typescript

// TypeScript泛型 - 保持类型安全
function identity<T>(value: T): T {
    return value;
}

const num = identity(42);        // 类型为 number
const str = identity("hello");   // 类型为 string
const bool = identity(true);     // 类型为 boolean

这里的<T>就是泛型参数,它像一个"类型变量",在函数被调用时确定具体的类型。

泛型基础:从简单到复杂

1. 泛型函数

让我们从一个实际的例子开始:

// 一个简单的栈实现
class Stack<T> {
    private items: T[] = [];
    
    push(item: T): void {
        this.items.push(item);
    }
    
    pop(): T | undefined {
        return this.items.pop();
    }
    
    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }
    
    size(): number {
        return this.items.length;
    }
}

// 使用示例 - 类型安全!
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
// numberStack.push("hello"); // ❌ 编译错误:不能将字符串压入数字栈

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");

2. 多个泛型参数

// 处理键值对的函数
function pair<K, V>(key: K, value: V): [K, V] {
    return [key, value];
}

// 使用示例
const stringNumberPair = pair("age", 25);      // [string, number]
const numberBooleanPair = pair(1, true);       // [number, boolean]
const complexPair = pair("config", { debug: true }); // [string, { debug: boolean }]

3. 泛型约束

有时候,我们需要对泛型参数做一些限制:

// 要求泛型参数必须有length属性
interface HasLength {
    length: number;
}

function getLength<T extends HasLength>(item: T): number {
    return item.length;
}

// 使用示例
getLength("hello");        // ✅ 字符串有length
getLength([1, 2, 3]);      // ✅ 数组有length  
getLength({ length: 5 });  // ✅ 对象有length属性
// getLength(42);          // ❌ 数字没有length属性

实战场景:泛型在项目中的应用

场景1:API响应处理

在我的实际项目中,泛型在API层发挥了巨大作用:

// 定义通用的API响应类型
interface ApiResponse<T> {
    success: boolean;
    data: T;
    message?: string;
    timestamp: number;
}

// 通用的API请求函数
async function apiRequest<T>(
    endpoint: string, 
    options?: RequestInit
): Promise<ApiResponse<T>> {
    const response = await fetch(`/api/${endpoint}`, options);
    const result: ApiResponse<T> = await response.json();
    return result;
}

// 定义具体的数据类型
interface User {
    id: number;
    name: string;
    email: string;
}

interface Product {
    id: number;
    title: string;
    price: number;
    category: string;
}

// 使用示例 - 完美的类型安全!
const userResponse = await apiRequest<User>("users/1");
console.log(userResponse.data.name);    // ✅ 正确的属性访问
// console.log(userResponse.data.invalid); // ❌ 编译错误

const productResponse = await apiRequest<Product>("products/123");
console.log(productResponse.data.price); // ✅ 正确的属性访问

场景2:工具函数库

泛型让工具函数变得更加通用和类型安全:

// 数组工具函数
function filterArray<T>(
    array: T[], 
    predicate: (item: T, index: number) => boolean
): T[] {
    return array.filter(predicate);
}

function mapArray<T, U>(
    array: T[], 
    mapper: (item: T, index: number) => U
): U[] {
    return array.map(mapper);
}

// 对象工具函数
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

function mergeObjects<T extends object, U extends object>(
    obj1: T, 
    obj2: U
): T & U {
    return { ...obj1, ...obj2 };
}

// 使用示例
const numbers = [1, 2, 3, 4, 5];
const evenNumbers = filterArray(numbers, n => n % 2 === 0); // number[]

const users = [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" }
];
const userNames = mapArray(users, user => user.name); // string[]

const person = { name: "Alice", age: 30 };
const name = getProperty(person, "name"); // string
// const invalid = getProperty(person, "email"); // ❌ 编译错误

场景3:状态管理

在React项目中,泛型可以帮助我们创建类型安全的Hook:

import { useState, useCallback } from 'react';

// 通用的表单Hook
function useForm<T extends Record<string, any>>(initialValues: T) {
    const [values, setValues] = useState<T>(initialValues);
    
    const setValue = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
        setValues(prev => ({ ...prev, [key]: value }));
    }, []);
    
    const reset = useCallback(() => {
        setValues(initialValues);
    }, [initialValues]);
    
    return {
        values,
        setValue,
        reset,
        setValues
    };
}

// 使用示例
interface LoginForm {
    email: string;
    password: string;
    rememberMe: boolean;
}

function LoginComponent() {
    const { values, setValue } = useForm<LoginForm>({
        email: "",
        password: "", 
        rememberMe: false
    });
    
    // 完全类型安全!
    const handleEmailChange = (email: string) => {
        setValue("email", email); // ✅ 正确
    };
    
    const handleRememberChange = (remember: boolean) => {
        setValue("rememberMe", remember); // ✅ 正确
    };
    
    // setValue("invalidKey", "value"); // ❌ 编译错误
    // setValue("email", 123);          // ❌ 编译错误
}

场景4:高阶组件和渲染Props

// 带加载状态的高阶组件
function withLoading<TProps extends object>(
    Component: React.ComponentType<TProps>
) {
    return function WithLoadingComponent(props: TProps & { isLoading?: boolean }) {
        const { isLoading, ...componentProps } = props;
        
        if (isLoading) {
            return <div>Loading...</div>;
        }
        
        return <Component {...componentProps as TProps} />;
    };
}

// 数据获取组件
interface DataRendererProps<T> {
    url: string;
    children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}

function DataRenderer<T>({ url, children }: DataRendererProps<T>) {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);
    
    useEffect(() => {
        fetch(url)
            .then(response => response.json())
            .then((data: T) => {
                setData(data);
                setLoading(false);
            })
            .catch((err: Error) => {
                setError(err);
                setLoading(false);
            });
    }, [url]);
    
    return <>{children(data, loading, error)}</>;
}

// 使用示例
interface UserData {
    id: number;
    name: string;
    email: string;
}

function UserProfile() {
    return (
        <DataRenderer<UserData> url="/api/user/1">
            {(user, loading, error) => {
                if (loading) return <div>Loading user...</div>;
                if (error) return <div>Error: {error.message}</div>;
                if (user) return <div>Hello, {user.name}!</div>;
                return null;
            }}
        </DataRenderer>
    );
}

高级泛型技巧

1. 条件类型

// 根据条件选择类型
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;    // "yes"
type B = IsString<number>;    // "no"

// 提取数组元素类型
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type Numbers = ArrayElement<number[]>;      // number
type Strings = ArrayElement<string[]>;      // string
type Mixed = ArrayElement<(number | string)[]>; // number | string

2. 映射类型

// 让所有属性变为可选
type Partial<T> = {
    [P in keyof T]?: T[P];
};

// 让所有属性变为只读
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

// 实际应用
interface User {
    id: number;
    name: string;
    email: string;
}

type PartialUser = Partial<User>;
// 等价于 { id?: number; name?: string; email?: string; }

type ReadonlyUser = Readonly<User>;
// 等价于 { readonly id: number; readonly name: string; readonly email: string; }

3. 泛型工具类型实战

// 创建请求参数类型
interface ApiEndpoints {
    users: {
        GET: { id: number };
        POST: { name: string; email: string };
    };
    products: {
        GET: { category?: string };
        POST: { title: string; price: number };
    };
}

// 自动生成请求参数类型
type RequestParams<TEndpoint extends keyof ApiEndpoints, TMethod extends keyof ApiEndpoints[TEndpoint]> 
    = ApiEndpoints[TEndpoint][TMethod];

// 使用示例
type GetUserParams = RequestParams<"users", "GET">;     // { id: number }
type CreateUserParams = RequestParams<"users", "POST">; // { name: string; email: string }
type GetProductParams = RequestParams<"products", "GET">; // { category?: string }

常见陷阱和最佳实践

1. 不要过度使用泛型

// 不推荐:过度复杂的泛型
function overlyComplex<T extends Record<string, any>, K extends keyof T, U extends T[K]>(
    obj: T, 
    key: K, 
    transformer: (value: T[K]) => U
): U {
    return transformer(obj[key]);
}

// 推荐:保持简单
function getAndTransform<T, U>(
    obj: Record<string, T>,
    key: string,
    transformer: (value: T) => U
): U {
    return transformer(obj[key]);
}

2. 提供合理的默认值

// 为泛型参数提供默认值
interface PaginationOptions<T = any> {
    page: number;
    pageSize: number;
    filter?: (item: T) => boolean;
    sort?: (a: T, b: T) => number;
}

// 使用默认值
const defaultOptions: PaginationOptions = {
    page: 1,
    pageSize: 10
};

// 指定具体类型
const userOptions: PaginationOptions<User> = {
    page: 1,
    pageSize: 20,
    filter: user => user.active,
    sort: (a, b) => a.name.localeCompare(b.name)
};

3. 合理使用类型推断

// 让TypeScript自动推断类型
function createArray<T>(...items: T[]): T[] {
    return items;
}

// 自动推断为number[]
const numbers = createArray(1, 2, 3);
// 自动推断为string[] 
const strings = createArray("a", "b", "c");
// 自动推断为(string | number)[]
const mixed = createArray(1, "two", 3);

结语

泛型是TypeScript中最强大的特性之一,它让我们的代码在保持类型安全的同时,获得了极大的灵活性。从简单的工具函数到复杂的系统架构,泛型都能发挥重要作用。

记住学习泛型的关键:

  • 从简单的用例开始,逐步深入
  • 多实践,在真实项目中应用
  • 不要害怕犯错,TypeScript编译器会指导你

泛型就像编程中的"魔法",一旦掌握,你就会发现它能解决很多之前觉得棘手的问题。希望今天的分享能帮助你在TypeScript的道路上更进一步!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

TypeScript函数:给JavaScript函数加上"类型安全带"

前言

大家好,我是小杨。记得我刚从JavaScript转向TypeScript时,最让我惊喜的就是函数的类型安全特性。以前在JavaScript中,我经常遇到"undefined is not a function"这样的运行时错误,而TypeScript的函数特性就像给代码系上了安全带,让很多错误在编写阶段就能被发现。今天就来聊聊TypeScript中的函数,以及它和JavaScript函数的那些区别。

基础篇:TypeScript函数的基本用法

函数声明的类型化

在JavaScript中,我们这样写函数:

// JavaScript风格
function greet(name) {
    return `Hello, ${name}!`;
}

而在TypeScript中,我们可以为参数和返回值添加类型:

// TypeScript风格 - 为函数系上安全带
function greet(name: string): string {
    return `Hello, ${name}!`;
}

// 使用示例
const message = greet("Alice"); // ✅ 正确
// const errorMessage = greet(123); // ❌ 编译错误:参数类型不匹配

函数表达式与箭头函数

// 函数表达式
const add = function(x: number, y: number): number {
    return x + y;
};

// 箭头函数 - 我的最爱
const multiply = (x: number, y: number): number => x * y;

// 使用类型别名
type MathOperation = (a: number, b: number) => number;

const divide: MathOperation = (a, b) => a / b;

进阶特性:TypeScript函数的超能力

1. 可选参数和默认参数

// 可选参数
function createUser(
    name: string, 
    email: string, 
    age?: number  // 这个问号让参数变成可选的
): User {
    return {
        name,
        email,
        age: age || 0 // 处理可选参数
    };
}

// 默认参数
function sendMessage(
    content: string,
    priority: "low" | "medium" | "high" = "medium", // 默认值
    timeout: number = 5000
): void {
    console.log(`Sending: ${content}, Priority: ${priority}, Timeout: ${timeout}ms`);
}

// 使用示例
createUser("Alice", "alice@example.com"); // ✅ age是可选的
createUser("Bob", "bob@example.com", 25); // ✅ 也可以提供age

sendMessage("Hello"); // ✅ 使用默认参数
sendMessage("Urgent!", "high", 1000); // ✅ 自定义所有参数

2. 剩余参数

// 收集所有参数到一个数组中
function buildPath(...segments: string[]): string {
    return segments.join('/');
}

// 混合使用
function configureApp(
    name: string,
    ...settings: [string, any][]
): AppConfig {
    console.log(`Configuring app: ${name}`);
    settings.forEach(([key, value]) => {
        console.log(`Setting ${key} to ${value}`);
    });
    return { name, settings };
}

// 使用示例
const path = buildPath("usr", "local", "bin", "app"); // "usr/local/bin/app"
configureApp("MyApp", ["theme", "dark"], ["lang", "zh-CN"]);

3. 函数重载

这是TypeScript独有的强大特性:

// 重载签名
function processInput(input: string): string[];
function processInput(input: number): number[];
function processInput(input: boolean): boolean[];

// 实现签名
function processInput(input: any): any[] {
    if (typeof input === 'string') {
        return input.split('');
    } else if (typeof input === 'number') {
        return [input, input * 2, input * 3];
    } else {
        return [input, !input];
    }
}

// 使用示例 - 自动获得正确的类型提示!
const chars = processInput("hello"); // string[] 类型
const numbers = processInput(5);     // number[] 类型
const booleans = processInput(true); // boolean[] 类型

实战对比:TypeScript vs JavaScript函数

场景1:API请求函数

JavaScript版本:

// JavaScript - 容易出错
async function fetchUserData(userId) {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    return data; // 返回什么?我们不知道!
}

// 使用时可能会遇到问题
const userData = await fetchUserData(123);
console.log(userData.nonExistentProperty); // 运行时才报错!

TypeScript版本:

// TypeScript - 安全明确
interface User {
    id: number;
    name: string;
    email: string;
    avatar?: string;
}

async function fetchUserData(userId: number): Promise<User> {
    const response = await fetch(`/api/users/${userId}`);
    const data: User = await response.json();
    return data; // 明确的返回类型
}

// 使用时获得完整的类型安全
const userData = await fetchUserData(123);
console.log(userData.name); // ✅ 正确的属性
// console.log(userData.nonExistentProperty); // ❌ 编译时就报错!

场景2:回调函数处理

JavaScript版本:

// JavaScript - 容易出错
function processArray(arr, callback) {
    const result = [];
    for (let i = 0; i < arr.length; i++) {
        result.push(callback(arr[i]));
    }
    return result;
}

// 可能出现的错误
const numbers = [1, 2, 3];
const doubled = processArray(numbers, (num) => num * 2); // 正常工作
const problematic = processArray(numbers, "not a function"); // 运行时崩溃!

TypeScript版本:

// TypeScript - 类型安全
function processArray<T, U>(
    arr: T[],
    callback: (item: T, index: number) => U
): U[] {
    const result: U[] = [];
    for (let i = 0; i < arr.length; i++) {
        result.push(callback(arr[i], i));
    }
    return result;
}

// 使用示例 - 完全类型安全
const numbers = [1, 2, 3];
const doubled = processArray(numbers, (num) => num * 2); // number[] 类型
const strings = processArray(numbers, (num) => num.toString()); // string[] 类型

// const error = processArray(numbers, "not a function"); // ❌ 编译错误

场景3:配置对象处理

JavaScript版本:

// JavaScript - 配置容易出错
function createButton(config) {
    const defaultConfig = {
        text: "Button",
        color: "blue",
        size: "medium",
        disabled: false
    };
    
    return { ...defaultConfig, ...config };
}

// 可能的问题
const button1 = createButton({ text: "Click me", colour: "red" }); // 拼写错误,静默失败
const button2 = createButton({ size: "extra-large" }); // 无效的尺寸,运行时才可能发现

TypeScript版本:

// TypeScript - 配置安全
interface ButtonConfig {
    text?: string;
    color?: "blue" | "red" | "green" | "yellow";
    size?: "small" | "medium" | "large";
    disabled?: boolean;
}

function createButton(config: ButtonConfig) {
    const defaultConfig: Required<ButtonConfig> = {
        text: "Button",
        color: "blue",
        size: "medium",
        disabled: false
    };
    
    return { ...defaultConfig, ...config };
}

// 使用示例 - 即时错误检测
const button1 = createButton({ 
    text: "Click me", 
    color: "red",  // ✅ 有效颜色
    size: "large"  // ✅ 有效尺寸
});

// const button2 = createButton({ colour: "red" }); // ❌ 拼写错误,编译时报错
// const button3 = createButton({ size: "extra-large" }); // ❌ 无效尺寸,编译时报错

高级特性:TypeScript函数的独特能力

1. 泛型函数

// 泛型让函数更加灵活
function identity<T>(value: T): T {
    return value;
}

// 自动类型推断
const num = identity(42);        // number 类型
const str = identity("hello");   // string 类型
const bool = identity(true);     // boolean 类型

// 泛型约束
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "Alice", age: 30 };
const userName = getProperty(user, "name"); // string 类型
const userAge = getProperty(user, "age");   // number 类型
// const invalid = getProperty(user, "email"); // ❌ 编译错误

2. 条件类型与函数

// 基于输入类型的条件返回
type ApiResponse<T> = T extends number 
    ? { data: number; type: "number" }
    : T extends string
    ? { data: string; type: "string" }
    : { data: T; type: "object" };

function createResponse<T>(data: T): ApiResponse<T> {
    if (typeof data === "number") {
        return { data, type: "number" } as ApiResponse<T>;
    } else if (typeof data === "string") {
        return { data, type: "string" } as ApiResponse<T>;
    } else {
        return { data, type: "object" } as ApiResponse<T>;
    }
}

// 自动推断正确的返回类型
const numResponse = createResponse(42);    // { data: number; type: "number" }
const strResponse = createResponse("hello"); // { data: string; type: "string" }

3. 函数类型的高级用法

// 函数组合
type FunctionType<T, R> = (arg: T) => R;

function compose<T, U, R>(
    f: FunctionType<U, R>,
    g: FunctionType<T, U>
): FunctionType<T, R> {
    return (x: T) => f(g(x));
}

// 使用组合
const addFive = (x: number) => x + 5;
const multiplyByTwo = (x: number) => x * 2;
const toString = (x: number) => x.toString();

const processNumber = compose(toString, compose(addFive, multiplyByTwo));
const result = processNumber(10); // "25"

最佳实践和注意事项

1. 合理使用any和unknown

// 不推荐:过度使用any
function dangerousFunction(data: any): any {
    // 这里可能发生任何事!
    return data.someProperty.someMethod();
}

// 推荐:使用unknown进行类型安全处理
function safeFunction(data: unknown): string {
    if (typeof data === 'string') {
        return data.toUpperCase();
    } else if (data && typeof data === 'object' && 'message' in data) {
        return String(data.message);
    }
    return "Unknown data";
}

2. 充分利用类型推断

// TypeScript可以推断返回类型,不需要总是显式声明
function calculateTotal(price: number, quantity: number) {
    return price * quantity; // 自动推断返回number类型
}

// 但对于复杂逻辑,显式声明更好
function processOrder(order: Order): ProcessResult {
    // 复杂的处理逻辑...
    return result;
}

3. 错误处理的最佳实践

// 使用Result模式而不是抛出错误
type Result<T, E = Error> = 
    | { success: true; data: T }
    | { success: false; error: E };

function safeDivide(a: number, b: number): Result<number> {
    if (b === 0) {
        return { success: false, error: new Error("Division by zero") };
    }
    return { success: true, data: a / b };
}

// 使用示例
const result = safeDivide(10, 2);
if (result.success) {
    console.log(result.data); // 5
} else {
    console.error(result.error.message);
}

总结:TypeScript函数的优势

通过上面的对比和实践,我们可以看到TypeScript函数相比JavaScript函数的主要优势:

  1. 类型安全:在编译时捕获类型错误
  2. 更好的智能提示:IDE可以提供准确的参数和返回类型提示
  3. 自文档化:函数签名本身就是很好的文档
  4. 重构友好:类型系统帮助安全地进行代码重构
  5. 团队协作:明确的接口约定减少沟通成本

结语

从JavaScript的"自由奔放"到TypeScript的"规范有序",函数的类型化可能是最有价值的改进之一。它就像给我们的代码加上了安全带,虽然一开始可能觉得有些束缚,但一旦习惯,就会发现它能避免很多潜在的事故。

记住,好的TypeScript代码不是一味地添加类型,而是找到类型安全和开发效率的最佳平衡点。希望今天的分享能帮助你在实际项目中更好地使用TypeScript函数!

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Node.js + Python 爬虫界的黄金搭档

写了一个工具叫去水印下载鸭,它的功能是解析某音、某红书等平台视频、图片,支持无水印下载。

image.png

在后端开发中,我选择了自己最为熟悉的 Node.js 技术。然而,在开发过程中,我遇到了一些棘手的问题,其中最令人头疼的就是某音的加密处理。 幸运的是,我在 GitHub 社区找到了一个开源仓库,它提供了相关的解决方案,但这个仓库是用 Python 实现的。我本想借助 AI 将其转换为 Node.js 代码,但转换后的代码运行报错。为了避免进一步的麻烦,我决定直接在 Node.js 项目中引入这个 Python 仓库,并且删减了其中我不需要的文件。

Node.js 集成 Python

在 Node.js 中使用 Python 简单又高效。我们先让 Python 程序输出结果,然后对结果稍作处理,用 /RESULT_START:结果:RESULT_END/ 这样的格式将其包裹起来。

async def fetch_one_video(url):
    """
    Fetches a single video by aweme_id.
    """
    hybird_crawler = HybridCrawler()
    data = await hybird_crawler.hybrid_parsing_single_video(url,False)
    # 只输出结果数据,使用特定标记包裹
    print(f"RESULT_START:{json.dumps(data)}:RESULT_END")

随后,在 Node.js 端,我们借助 exec 方法来运行一条 Python 命令,具体如下所示:


new Promise((resolve, reject) => {
const src = path.join(__dirname, '../script/start.py');
exec(`python ${src} ${url}`, (error, stdout, stderr) => {
    if (error) {
        console.error(`Error executing Python script: ${error}`);
        reject(null)
        return;
    }
    if (stderr) {
        reject(null)
        return;
    }
    // 使用正则表达式捕获 RESULT_START 和 RESULT_END 之间的内容
    const regex = /RESULT_START:(.*?):RESULT_END/;
    const match = stdout.match(regex);

    if (match && match[1]) {
        try {
            // 解析捕获到的 JSON 数据
            const jsonData = JSON.parse(match[1]);
            resolve(jsonData);
        } catch (parseError) {
            reject(null)
        }
    } else {
        reject(null)
    }
})
})

当调用 exec 方法时,Node.js 会调用操作系统的相关功能来创建一个新的子进程。这个子进程会独立于父进程运行。

这样,Node.js环境中使用Python就搞定了。

Docker 部署 Node.js+Python

若想在 Docker 容器中运行 Node.js 与 Python,打包时需确保容器内同时具备 Node.js 和 Python 的运行环境。

最初,Dockerfile 仅包含 Node.js 的配置,配置如下所示:

# 构建阶段
FROM node:24-alpine

WORKDIR /app

# 复制 package 文件
COPY package*.json ./

# 复制源代码
COPY . .

RUN npm i -g pnpm && pnpm i 

# 暴露端口
EXPOSE 3000

# 启动应用
CMD [ "npm", "start" ]

为了在Docker镜像中引入Python,我在Dockerfile中加入了对Python3的支持,以确保容器内同时具备Node.js和Python的运行环境。以下是Dockerfile的修改内容:

FROM node:24-alpine

RUN apt-get update && \
    apt-get install -y --no-install-recommends python3 python3-pip && \
    rm -rf /var/lib/apt/lists/*
 
 #省略...

然而,在构建镜像的过程中,我发现下载python3python3-pip等包的速度非常慢,并且在安装过程中还涉及到编译,这使得整个构建过程变得异常耗时。

经过一番思考,我决定调整策略:先基于Python镜像构建,再在其上添加Node.js环境。于是,我对Dockerfile进行了如下调整:

FROM python:3.11-slim-bookworm

RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g' \
        /etc/apt/sources.list.d/debian.sources && \
    sed -i 's|http://security.debian.org|https://mirrors.aliyun.com|g' \
        /etc/apt/sources.list.d/debian.sources

RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
    && rm -rf /var/lib/apt/lists/* \
    && npm config set registry https://registry.npmmirror.com/
 
#省略...

这样完成解决Docker部署问题。

最后

如果你也遇到了需要在项目中同时使用 Node.js 和 Python 的情况,不妨参考本文的操作方法,或许能为你提供一些思路和帮助。

去水印下载鸭仅限于学习,请勿用于其他用途,否则后果自负,且软件没有进行任何店铺售卖,谨防受骗!

微信小程序插件从发布到使用的完整实战指南

一、概念篇:插件是什么

微信小程序的**插件(plugin)**是一种模块化复用机制。开发者可以将一个功能封装成插件,供其他小程序调用。例如常见的有「视频播放器插件」「地图定位插件」「支付工具插件」等。

📌 特点:

  • 插件 不能独立运行
  • 插件必须通过 宿主小程序引用后 才能使用;
  • 插件可以暴露组件、接口、页面供调用;
  • 插件更新后可发布新版本供他人升级。

二、原理篇:插件与宿主小程序的关系

插件的运行机制是「宿主小程序 → 调用插件接口/组件」。
宿主在 app.json 中声明依赖,微信框架会在编译阶段将插件资源合并加载。

调用链如下:

宿主小程序 page.wxml → 插件组件 → 插件逻辑层(plugin/index.js) → 微信宿主环境

因此,插件和宿主的小程序在逻辑上隔离,但在运行时通过接口通信。


三、实践篇(上):发布插件步骤

1️⃣ 创建插件项目

在开发者工具中新建项目,选择:

项目类型:插件

配置文件 project.config.json

{
  "appid": "wx05dfcd468442088e",
  "compileType": "plugin",
  "pluginRoot": "plugin"
}

2️⃣ 编写插件结构

项目结构示例:

plugin/
 ├─ components/
 │   └─ video-player/
 │       ├─ video-player.wxml
 │       ├─ video-player.wxss
 │       ├─ video-player.js
 │       └─ video-player.json
 ├─ index.js
 └─ plugin.json

plugin.json

{
  "publicComponents": {
    "video-player": "components/video-player/video-player"
  },
  "publicMethods": {
    "play": "index.play"
  }
}

index.js

function play() {
  console.log("播放视频中……");
}
module.exports = {
  play
};

3️⃣ 上传并发布插件

  1. 登录 [微信公众平台 → 小程序 → 开发 → 插件管理]
  2. 点击「上传插件版本」
  3. 填写版本号(如 1.0.0)与描述
  4. 提交审核
  5. 审核通过后即可发布插件。

四、实践篇(下):在其他小程序中使用插件

下面重点讲解——如何在其他小程序使用你发布的插件

(1)添加插件依赖

在宿主小程序的后台(公众平台 → 开发 → 插件管理)中添加插件 AppID。

例如你要使用的插件:

插件AppID:wx05dfcd468442088e
插件版本:1.0.0

(2)配置 app.json

{
  "plugins": {
    "videoProxy": {
      "version": "1.0.0",
      "provider": "wx05dfcd468442088e"
    }
  }
}

解释:

  • videoProxy 是插件引用名称;
  • provider 是插件的 AppID;
  • version 是要使用的插件版本号。

(3)在页面中引入插件组件

index.json

{
  "usingComponents": {
    "plugin-video-player": "plugin://videoProxy/video-player"
  }
}

index.wxml

<view class="container">
  <plugin-video-player src="https://example.com/video.mp4"></plugin-video-player>
</view>

(4)在 JS 文件中调用插件方法

// 引用插件
const videoProxy = requirePlugin('videoProxy')

Page({
  onReady() {
    // 调用插件暴露的方法
    videoProxy.play()
  }
})

解释:

  • requirePlugin('videoProxy') 获取插件对象;
  • 通过插件中定义的 publicMethods 调用其方法。

(5)插件的页面调用方式

插件如果暴露了页面(如 publicPages),可以通过 plugin:// 打开:

wx.navigateTo({
  url: 'plugin://videoProxy/video-page'
})

五、调试与常见问题

问题 原因 解决方案
Component is not found in path 路径错误或插件未正确注册 检查 plugin.json 与 usingComponents
插件无法调用方法 宿主小程序未 requirePlugin 确保已在 JS 文件正确调用
模拟器启动失败 缺少 provider 或 version app.json 插件配置必须完整
审核不通过 使用了禁止 API 或未备案资源 按审核意见修改后重新提交

六、拓展篇:插件版本与安全

  • 插件可维护多个版本,宿主可指定版本或自动升级;
  • 插件中不能使用用户隐私相关 API;
  • 可在后台限制哪些小程序可使用;
  • 插件更新后宿主需要重新上传审核以同步。

七、总结

微信小程序插件的使用流程可概括为:

  1. 插件开发并配置暴露接口;
  2. 在公众平台上传并发布;
  3. 宿主小程序后台添加插件;
  4. app.json 声明插件;
  5. 页面引入并调用插件组件或方法。

这样,你就可以在多个小程序中共用同一功能模块,大大提升开发效率与一致性。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

前端开发者必看!JavaScript这些坑我替你踩过了

你是不是经常遇到这样的场景:代码明明看起来没问题,运行起来却各种报错?或者某个功能在测试环境好好的,一到线上就出问题?

说实话,这些坑我也都踩过。从刚开始写JS时的一头雾水,到现在能够游刃有余地避开各种陷阱,我花了太多时间在调试和填坑上。

今天这篇文章,就是要把我这些年积累的避坑经验全部分享给你。看完之后,你不仅能避开常见的JS陷阱,还能深入理解背后的原理,写出更健壮的代码。

变量声明那些事儿

先来说说最基础的变量声明。很多新手觉得var、let、const不都差不多吗?结果写着写着就出问题了。

看看这个例子:

// 问题代码
for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i); // 猜猜会输出什么?
    }, 100);
}

// 实际输出:5, 5, 5, 5, 5
// 是不是跟你想的不一样?

为什么会这样?因为var是函数作用域,而不是块级作用域。循环结束后,i的值已经变成5了,所有定时器回调函数访问的都是同一个i。

怎么解决?用let就行:

// 正确写法
for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i); // 输出:0, 1, 2, 3, 4
    }, 100);
}

let是块级作用域,每次循环都会创建一个新的i绑定,所以每个定时器访问的都是自己那个循环里的i值。

再来看const,很多人以为const声明的变量完全不能改,其实不然:

const user = { name: '小明' };
user.name = '小红'; // 这个是可以的!
console.log(user.name); // 输出:小红

// 但是这样不行:
// user = { name: '小刚' }; // 报错!

const保证的是变量引用的不变性,而不是对象内容的不变性。如果想完全冻结对象,可以用Object.freeze()。

类型转换的坑

JS的类型转换可以说是最让人头疼的部分之一了。来看看这些让人迷惑的例子:

console.log([] + []); // 输出:"" 
console.log([] + {}); // 输出:"[object Object]"
console.log({} + []); // 输出:0
console.log({} + {}); // 输出:"[object Object][object Object]"

console.log('5' + 3); // 输出:"53"
console.log('5' - 3); // 输出:2

为什么会这样?这涉及到JS的类型转换规则。+运算符在遇到字符串时会优先进行字符串拼接,而-运算符则始终进行数字运算。

再看这个经典的面试题:

console.log(0.1 + 0.2 === 0.3); // 输出:false

这不是JS的bug,而是浮点数精度问题。几乎所有编程语言都有这个问题。解决方案是使用小数位数精度处理:

function floatingPointEqual(a, b, epsilon = 1e-10) {
    return Math.abs(a - b) < epsilon;
}

console.log(floatingPointEqual(0.1 + 0.2, 0.3)); // 输出:true

箭头函数的误解

箭头函数用起来很爽,但很多人没真正理解它的特性:

const obj = {
    name: '小明',
    regularFunc: function() {
        console.log(this.name);
    },
    arrowFunc: () => {
        console.log(this.name);
    }
};

obj.regularFunc(); // 输出:"小明"
obj.arrowFunc();   // 输出:undefined

箭头函数没有自己的this,它继承自外层作用域。在这个例子里,箭头函数的外层是全局作用域,所以this指向全局对象(浏览器中是window)。

再看一个更隐蔽的坑:

const button = document.querySelector('button');

const obj = {
    message: '点击了!',
    handleClick: function() {
        // 这个能正常工作
        button.addEventListener('click', function() {
            console.log(this.message); // 输出:undefined
        });
        
        // 这个也能"正常"工作,但原因可能跟你想的不一样
        button.addEventListener('click', () => {
            console.log(this.message); // 输出:"点击了!"
        });
    }
};

obj.handleClick();

第一个回调函数中的this指向button元素,第二个箭头函数中的this指向obj,因为箭头函数继承了handleClick方法的this。

异步处理的陷阱

异步编程是JS的核心,但也有很多坑:

// 你以为的顺序执行
console.log('开始');
setTimeout(() => console.log('定时器'), 0);
Promise.resolve().then(() => console.log('Promise'));
console.log('结束');

// 实际输出顺序:
// 开始
// 结束  
// Promise
// 定时器

这是因为JS的事件循环机制。微任务(Promise)比宏任务(setTimeout)有更高的优先级。

再看这个常见的错误:

// 错误的异步循环
for (var i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i); // 输出:5, 5, 5, 5, 5
    }, 100);
}

// 解决方法1:使用let
for (let i = 0; i < 5; i++) {
    setTimeout(() => {
        console.log(i); // 输出:0, 1, 2, 3, 4
    }, 100);
}

// 解决方法2:使用闭包
for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(() => {
            console.log(j); // 输出:0, 1, 2, 3, 4
        }, 100);
    })(i);
}

数组操作的误区

数组方法用起来很方便,但理解不深就容易出问题:

const arr = [1, 2, 3, 4, 5];

// 你以为的filter
const result = arr.filter(item => {
    if (item > 2) {
        return true;
    }
    // 忘记写else return false
});

console.log(result); // 输出:[1, 2, 3, 4, 5]

filter方法期待回调函数返回truthy或falsy值。没有明确返回值的函数默认返回undefined,也就是falsy值,所以所有元素都被过滤掉了。

再看这个reduce的常见错误:

const arr = [1, 2, 3, 4];

// 求和的错误写法
const sum = arr.reduce((acc, curr) => {
    acc + curr; // 忘记return!
});

console.log(sum); // 输出:NaN

// 正确写法
const correctSum = arr.reduce((acc, curr) => acc + curr, 0);
console.log(correctSum); // 输出:10

对象拷贝的深坑

对象拷贝是日常开发中经常遇到的问题:

const original = { 
    name: '小明',
    hobbies: ['篮球', '游泳'],
    info: { age: 20 }
};

// 浅拷贝
const shallowCopy = {...original};
shallowCopy.name = '小红'; // 不影响原对象
shallowCopy.hobbies.push('跑步'); // 会影响原对象!

console.log(original.hobbies); // 输出:['篮球', '游泳', '跑步']

// 深拷贝的简单方法(有局限性)
const deepCopy = JSON.parse(JSON.stringify(original));
deepCopy.hobbies.push('读书');
console.log(original.hobbies); // 输出:['篮球', '游泳', '跑步'] 不受影响

JSON方法虽然简单,但会丢失函数、undefined等特殊值,而且不能处理循环引用。

现代JS提供了更专业的深拷贝方法:

// 使用structuredClone(较新的API)
const modernDeepCopy = structuredClone(original);

// 或者自己实现简单的深拷贝
function deepClone(obj) {
    if (obj === null || typeof obj !== 'object') return obj;
    if (obj instanceof Date) return new Date(obj);
    if (obj instanceof Array) return obj.map(item => deepClone(item));
    
    const cloned = {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            cloned[key] = deepClone(obj[key]);
        }
    }
    return cloned;
}

模块化的问题

ES6模块用起来很顺手,但也有一些需要注意的地方:

// 错误的理解
export default const name = '小明'; // 语法错误!

// 正确写法
const name = '小明';
export default name;

// 或者
export default '小明';

还有这个常见的循环引用问题:

// a.js
import { b } from './b.js';
export const a = 'a' + b;

// b.js  
import { a } from './a.js';
export const b = 'b' + a; // 这里a是undefined!

模块加载器会检测循环引用并尝试解决,但结果可能不是你想要的那样。最好的做法是避免循环引用,或者把共享逻辑提取到第三个模块中。

现代JS的最佳实践

说了这么多坑,最后分享一些现代JS开发的最佳实践:

  1. 尽量使用const,除非确实需要重新赋值
  2. 使用===而不是==,避免隐式类型转换
  3. 使用模板字符串代替字符串拼接
  4. 善用解构赋值
  5. 使用async/await处理异步,让代码更清晰
// 不好的写法
function getUserInfo(user) {
    const name = user.name;
    const age = user.age;
    const email = user.email;
    
    return name + '今年' + age + '岁,邮箱是' + email;
}

// 好的写法
function getUserInfo(user) {
    const { name, age, email } = user;
    return `${name}今年${age}岁,邮箱是${email}`;
}

// 更好的异步处理
async function fetchData() {
    try {
        const response = await fetch('/api/data');
        const data = await response.json();
        return data;
    } catch (error) {
        console.error('获取数据失败:', error);
        throw error;
    }
}

总结

JavaScript确实有很多看似奇怪的行为,但一旦理解了背后的原理,这些"坑"就不再是坑了。记住,好的代码不是一蹴而就的,而是在不断踩坑和总结中慢慢积累的。

你现在可能还会遇到各种JS的奇怪问题,这很正常。重要的是保持学习的心态,理解原理而不仅仅是记住用法。

你在开发中还遇到过哪些JS的坑?欢迎在评论区分享你的经历,我们一起交流进步!

每日一题-电网维护🟡

给你一个整数 c,表示 c 个电站,每个电站有一个唯一标识符 id,从 1 到 c 编号。

这些电站通过 n 条 双向 电缆互相连接,表示为一个二维数组 connections,其中每个元素 connections[i] = [ui, vi] 表示电站 ui 和电站 vi 之间的连接。直接或间接连接的电站组成了一个 电网 

最初,所有 电站均处于在线(正常运行)状态。

另给你一个二维数组 queries,其中每个查询属于以下 两种类型之一 

  • [1, x]:请求对电站 x 进行维护检查。如果电站 x 在线,则它自行解决检查。如果电站 x 已离线,则检查由与 x 同一 电网 中 编号最小 的在线电站解决。如果该电网中 不存在 任何 在线 电站,则返回 -1。

  • [2, x]:电站 x 离线(即变为非运行状态)。

返回一个整数数组,表示按照查询中出现的顺序,所有类型为 [1, x] 的查询结果。

注意:电网的结构是固定的;离线(非运行)的节点仍然属于其所在的电网,且离线操作不会改变电网的连接性。

 

示例 1:

输入: c = 5, connections = [[1,2],[2,3],[3,4],[4,5]], queries = [[1,3],[2,1],[1,1],[2,2],[1,2]]

输出: [3,2,3]

解释:

  • 最初,所有电站 {1, 2, 3, 4, 5} 都在线,并组成一个电网。
  • 查询 [1,3]:电站 3 在线,因此维护检查由电站 3 自行解决。
  • 查询 [2,1]:电站 1 离线。剩余在线电站为 {2, 3, 4, 5}
  • 查询 [1,1]:电站 1 离线,因此检查由电网中编号最小的在线电站解决,即电站 2。
  • 查询 [2,2]:电站 2 离线。剩余在线电站为 {3, 4, 5}
  • 查询 [1,2]:电站 2 离线,因此检查由电网中编号最小的在线电站解决,即电站 3。

示例 2:

输入: c = 3, connections = [], queries = [[1,1],[2,1],[1,1]]

输出: [1,-1]

解释:

  • 没有连接,因此每个电站是一个独立的电网。
  • 查询 [1,1]:电站 1 在线,且属于其独立电网,因此维护检查由电站 1 自行解决。
  • 查询 [2,1]:电站 1 离线。
  • 查询 [1,1]:电站 1 离线,且其电网中没有其他电站,因此结果为 -1。

 

提示:

  • 1 <= c <= 105
  • 0 <= n == connections.length <= min(105, c * (c - 1) / 2)
  • connections[i].length == 2
  • 1 <= ui, vi <= c
  • ui != vi
  • 1 <= queries.length <= 2 * 105
  • queries[i].length == 2
  • queries[i][0] 为 1 或 2。
  • 1 <= queries[i][1] <= c

3607. 电网维护

前言

这道题给定 $c$ 个电站之间的双向电缆连接情况,对于每个检查查询需要计算每个电站自身或其所在电网中的编号最小的在线电站解决检查。每个电网都是一个连通分量,连通性问题可以使用广度优先搜索、深度优先搜索或并查集实现。

这篇题解使用并查集实现,并查集的优点在于不需要显性将边数组转换成邻接结点表示。读者可以自行完成广度优先搜索和深度优先搜索的实现。

解法一

思路和算法

对于每个检查查询,需要实现如下功能。

  • 判断一个电网中特定电站是否在线。

  • 寻找一个电网中的编号最小的在线电站。

可以使用有序集合实现。

首先遍历二维数组 $\textit{connections}$ 得到所有电站组成的电网,使用哈希表记录每个电网对应的在线电站有序集合。由于初始时所有电站都在线,因此将所有电站都存入哈希表中的有序集合。

然后遍历二维数组 $\textit{queries}$ 执行查询。对于每个查询 $\textit{query}$,根据电站 $\textit{query}[1]$ 得到其所属连通分量的在线电站有序集合,执行如下操作。

  • 当 $\textit{query}[0] = 1$ 时,判断有序集合中是否存在 $\textit{query}[1]$,执行相应的检查操作。

    • 如果有序集合中存在 $\textit{query}[1]$,则电站 $\textit{query}[1]$ 自行解决检查,当前查询结果是 $\textit{query}[1]$。

    • 如果有序集合中不存在 $\textit{query}[1]$,则当有序集合不为空时将其中的最小元素作为当前查询结果,表示由同一电网中编号最小的在线电站解决检查,当有序集合为空时当前查询结果是 $-1$。

  • 当 $\textit{query}[0] = 2$ 时,将电站 $\textit{query}[1]$ 从有序集合中移除。

遍历结束之后,即可得到查询结果数组。

代码

###Java

class Solution {
    static final int CHECK = 1, OFFLINE = 2;

    public int[] processQueries(int c, int[][] connections, int[][] queries) {
        int checkCount = 0;
        for (int[] query : queries) {
            if (query[0] == CHECK) {
                checkCount++;
            }
        }
        UnionFind uf = new UnionFind(c + 1);
        for (int[] connection : connections) {
            uf.union(connection[0], connection[1]);
        }
        Map<Integer, TreeSet<Integer>> components = new HashMap<Integer, TreeSet<Integer>>();
        for (int i = 1; i <= c; i++) {
            int root = uf.find(i);
            components.putIfAbsent(root, new TreeSet<Integer>());
            components.get(root).add(i);
        }
        int[] queryResults = new int[checkCount];
        int checkIndex = 0;
        for (int[] query : queries) {
            TreeSet<Integer> component = components.get(uf.find(query[1]));
            if (query[0] == CHECK) {
                if (component.contains(query[1])) {
                    queryResults[checkIndex] = query[1];
                } else {
                    queryResults[checkIndex] = !component.isEmpty() ? component.first() : -1;
                }
                checkIndex++;
            } else if (query[0] == OFFLINE) {
                component.remove(query[1]);
            }
        }
        return queryResults;
    }
}

class UnionFind {
    private int[] parent;
    private int[] rank;

    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        rank = new int[n];
    }

    public void union(int x, int y) {
        int rootx = find(x);
        int rooty = find(y);
        if (rootx != rooty) {
            if (rank[rootx] > rank[rooty]) {
                parent[rooty] = rootx;
            } else if (rank[rootx] < rank[rooty]) {
                parent[rootx] = rooty;
            } else {
                parent[rooty] = rootx;
                rank[rootx]++;
            }
        }
    }

    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]);
        }
        return parent[x];
    }
}

###C#

public class Solution {
    const int CHECK = 1, OFFLINE = 2;

    public int[] ProcessQueries(int c, int[][] connections, int[][] queries) {
        int checkCount = 0;
        foreach (int[] query in queries) {
            if (query[0] == CHECK) {
                checkCount++;
            }
        }
        UnionFind uf = new UnionFind(c + 1);
        foreach (int[] connection in connections) {
            uf.Union(connection[0], connection[1]);
        }
        IDictionary<int, SortedSet<int>> components = new Dictionary<int, SortedSet<int>>();
        for (int i = 1; i <= c; i++) {
            int root = uf.Find(i);
            components.TryAdd(root, new SortedSet<int>());
            components[root].Add(i);
        }
        int[] queryResults = new int[checkCount];
        int checkIndex = 0;
        foreach (int[] query in queries) {
            SortedSet<int> component = components[uf.Find(query[1])];
            if (query[0] == CHECK) {
                if (component.Contains(query[1])) {
                    queryResults[checkIndex] = query[1];
                } else {
                    queryResults[checkIndex] = component.Count > 0 ? component.Min : -1;
                }
                checkIndex++;
            } else if (query[0] == OFFLINE) {
                component.Remove(query[1]);
            }
        }
        return queryResults;
    }
}

class UnionFind {
    private int[] parent;
    private int[] rank;

    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        rank = new int[n];
    }

    public void Union(int x, int y) {
        int rootx = Find(x);
        int rooty = Find(y);
        if (rootx != rooty) {
            if (rank[rootx] > rank[rooty]) {
                parent[rooty] = rootx;
            } else if (rank[rootx] < rank[rooty]) {
                parent[rootx] = rooty;
            } else {
                parent[rooty] = rootx;
                rank[rootx]++;
            }
        }
    }

    public int Find(int x) {
        if (parent[x] != x) {
            parent[x] = Find(parent[x]);
        }
        return parent[x];
    }
}

复杂度分析

  • 时间复杂度:$O((c + q) \log c + (c + n) \times \alpha(c))$,其中 $c$ 是电站数量,$n$ 是数组 $\textit{connections}$ 的长度,$q$ 是数组 $\textit{queries}$ 的长度,$\alpha$ 是反阿克曼函数。并查集的初始化时间是 $O(c)$,遍历数组 $\textit{connections}$ 执行合并操作的时间是 $O(n \times \alpha(c))$,计算每个电网的在线电站有序集合的时间是 $O(c \times \alpha(c) + c \log c)$,对于每个查询的操作时间都是 $O(\log c)$,因此时间复杂度是 $O((c + q) \log c + (c + n) \times \alpha(c))$。

  • 空间复杂度:$O(c)$,其中 $c$ 是电站数量。并查集与记录每个电网的在线电站有序集合的空间是 $O(c)$。注意返回值不计入空间复杂度。

解法二

思路和算法

可以将有序集合换成基于小根堆的优先队列,使用延迟删除的方式实现。需要额外记录每个电站是否在线,初始时所有电站都在线。

首先遍历二维数组 $\textit{connections}$ 得到所有电站组成的电网,使用哈希表记录每个电网对应的在线电站优先队列。由于初始时所有电站都在线,因此将所有电站都存入哈希表中的优先队列。

然后遍历二维数组 $\textit{queries}$ 执行查询。对于每个查询 $\textit{query}$,根据电站 $\textit{query}[1]$ 得到其所属连通分量的在线电站优先队列,如果优先队列的队首元素编号的电站不在线则移除,直到优先队列为空或优先队列的队首元素编号的电站在线,然后执行如下操作。

  • 当 $\textit{query}[0] = 1$ 时,判断优先队列中是否存在 $\textit{query}[1]$,执行相应的检查操作。

    • 如果优先队列中存在 $\textit{query}[1]$,则电站 $\textit{query}[1]$ 自行解决检查,当前查询结果是 $\textit{query}[1]$。

    • 如果优先队列中不存在 $\textit{query}[1]$,则当优先队列不为空时将队首元素作为当前查询结果,表示由同一电网中编号最小的在线电站解决检查,当优先队列为空时当前查询结果是 $-1$。

  • 当 $\textit{query}[0] = 2$ 时,将电站 $\textit{query}[1]$ 的在线状态改为不在线。

遍历结束之后,即可得到查询结果数组。

代码

###Java

class Solution {
    static final int CHECK = 1, OFFLINE = 2;

    public int[] processQueries(int c, int[][] connections, int[][] queries) {
        int checkCount = 0;
        for (int[] query : queries) {
            if (query[0] == CHECK) {
                checkCount++;
            }
        }
        UnionFind uf = new UnionFind(c + 1);
        for (int[] connection : connections) {
            uf.union(connection[0], connection[1]);
        }
        boolean[] online = new boolean[c + 1];
        for (int i = 1; i <= c; i++) {
            online[i] = true;
        }
        Map<Integer, PriorityQueue<Integer>> components = new HashMap<Integer, PriorityQueue<Integer>>();
        for (int i = 1; i <= c; i++) {
            int root = uf.find(i);
            components.putIfAbsent(root, new PriorityQueue<Integer>());
            components.get(root).offer(i);
        }
        int[] queryResults = new int[checkCount];
        int checkIndex = 0;
        for (int[] query : queries) {
            PriorityQueue<Integer> pq = components.get(uf.find(query[1]));
            while (!pq.isEmpty() && !online[pq.peek()]) {
                pq.poll();
            }
            if (query[0] == CHECK) {
                if (online[query[1]]) {
                    queryResults[checkIndex] = query[1];
                } else {
                    queryResults[checkIndex] = !pq.isEmpty() ? pq.peek() : -1;
                }
                checkIndex++;
            } else if (query[0] == OFFLINE) {
                online[query[1]] = false;
            }
        }
        return queryResults;
    }
}

class UnionFind {
    private int[] parent;
    private int[] rank;

    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        rank = new int[n];
    }

    public void union(int x, int y) {
        int rootx = find(x);
        int rooty = find(y);
        if (rootx != rooty) {
            if (rank[rootx] > rank[rooty]) {
                parent[rooty] = rootx;
            } else if (rank[rootx] < rank[rooty]) {
                parent[rootx] = rooty;
            } else {
                parent[rooty] = rootx;
                rank[rootx]++;
            }
        }
    }

    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]);
        }
        return parent[x];
    }
}

###C#

public class Solution {
    const int CHECK = 1, OFFLINE = 2;

    public int[] ProcessQueries(int c, int[][] connections, int[][] queries) {
        int checkCount = 0;
        foreach (int[] query in queries) {
            if (query[0] == CHECK) {
                checkCount++;
            }
        }
        UnionFind uf = new UnionFind(c + 1);
        foreach (int[] connection in connections) {
            uf.Union(connection[0], connection[1]);
        }
        bool[] online = new bool[c + 1];
        for (int i = 1; i <= c; i++) {
            online[i] = true;
        }
        IDictionary<int, PriorityQueue<int, int>> components = new Dictionary<int, PriorityQueue<int, int>>();
        for (int i = 1; i <= c; i++) {
            int root = uf.Find(i);
            components.TryAdd(root, new PriorityQueue<int, int>());
            components[root].Enqueue(i, i);
        }
        int[] queryResults = new int[checkCount];
        int checkIndex = 0;
        foreach (int[] query in queries) {
            PriorityQueue<int, int> pq = components[uf.Find(query[1])];
            while (pq.Count > 0 && !online[pq.Peek()]) {
                pq.Dequeue();
            }
            if (query[0] == CHECK) {
                if (online[query[1]]) {
                    queryResults[checkIndex] = query[1];
                } else {
                    queryResults[checkIndex] = pq.Count > 0 ? pq.Peek() : -1;
                }
                checkIndex++;
            } else if (query[0] == OFFLINE) {
                online[query[1]] = false;
            }
        }
        return queryResults;
    }
}

class UnionFind {
    private int[] parent;
    private int[] rank;

    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        rank = new int[n];
    }

    public void Union(int x, int y) {
        int rootx = Find(x);
        int rooty = Find(y);
        if (rootx != rooty) {
            if (rank[rootx] > rank[rooty]) {
                parent[rooty] = rootx;
            } else if (rank[rootx] < rank[rooty]) {
                parent[rootx] = rooty;
            } else {
                parent[rooty] = rootx;
                rank[rootx]++;
            }
        }
    }

    public int Find(int x) {
        if (parent[x] != x) {
            parent[x] = Find(parent[x]);
        }
        return parent[x];
    }
}

复杂度分析

  • 时间复杂度:$O((c + q) \log c + (c + n) \times \alpha(c))$,其中 $c$ 是电站数量,$n$ 是数组 $\textit{connections}$ 的长度,$q$ 是数组 $\textit{queries}$ 的长度,$\alpha$ 是反阿克曼函数。并查集的初始化时间是 $O(c)$,遍历数组 $\textit{connections}$ 执行合并操作的时间是 $O(n \times \alpha(c))$,计算每个电网的在线电站优先队列的时间是 $O(c \times \alpha(c) + c \log c)$,对于每个查询的平均操作时间都是 $O(\log c)$,因此时间复杂度是 $O((c + q) \log c + (c + n) \times \alpha(c))$。

  • 空间复杂度:$O(c)$,其中 $c$ 是电站数量。并查集与记录每个电网的在线电站优先队列的空间是 $O(c)$。注意返回值不计入空间复杂度。

两种方法:懒删除堆 / 倒序处理(Python/Java/C++/Go)

方法一:懒删除堆

首先,建图 + DFS,把每个连通块中的节点加到各自的最小堆中。每个最小堆维护对应连通块的节点编号。

然后处理询问。

对于类型二,用一个 $\textit{offline}$ 布尔数组表示离线的电站。这一步不修改堆。

对于类型一:

  • 如果电站 $x$ 在线,那么答案为 $x$。
  • 否则检查 $x$ 所处堆的堆顶是否在线。若离线,则弹出堆顶,重复该过程。如果堆为不空,那么答案为堆顶,否则为 $-1$。

为了找到 $x$ 所属的堆,还需要一个数组 $\textit{belong}$ 记录每个节点在哪个堆中。

具体请看 视频讲解,欢迎点赞关注~

###py

class Solution:
    def processQueries(self, c: int, connections: List[List[int]], queries: List[List[int]]) -> List[int]:
        g = [[] for _ in range(c + 1)]
        for x, y in connections:
            g[x].append(y)
            g[y].append(x)

        belong = [-1] * (c + 1)
        heaps = []

        def dfs(x: int) -> None:
            belong[x] = len(heaps)  # 记录节点 x 在哪个堆
            h.append(x)
            for y in g[x]:
                if belong[y] < 0:
                    dfs(y)

        for i in range(1, c + 1):
            if belong[i] >= 0:
                continue
            h = []
            dfs(i)
            heapify(h)
            heaps.append(h)

        ans = []
        offline = [False] * (c + 1)
        for op, x in queries:
            if op == 2:
                offline[x] = True
                continue
            if not offline[x]:
                ans.append(x)
                continue
            h = heaps[belong[x]]
            # 懒删除:取堆顶的时候,如果离线,才删除
            while h and offline[h[0]]:
                heappop(h)
            ans.append(h[0] if h else -1)
        return ans

###java

class Solution {
    public int[] processQueries(int c, int[][] connections, int[][] queries) {
        List<Integer>[] g = new ArrayList[c + 1];
        Arrays.setAll(g, i -> new ArrayList<>());
        for (int[] e : connections) {
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x);
        }

        int[] belong = new int[c + 1];
        Arrays.fill(belong, -1);
        List<PriorityQueue<Integer>> heaps = new ArrayList<>();
        PriorityQueue<Integer> pq;
        for (int i = 1; i <= c; i++) {
            if (belong[i] >= 0) {
                continue;
            }
            pq = new PriorityQueue<>();
            dfs(i, g, belong, heaps.size(), pq);
            heaps.add(pq);
        }

        int ansSize = 0;
        for (int[] q : queries) {
            if (q[0] == 1) {
                ansSize++;
            }
        }

        int[] ans = new int[ansSize];
        int idx = 0;
        boolean[] offline = new boolean[c + 1];
        for (int[] q : queries) {
            int x = q[1];
            if (q[0] == 2) {
                offline[x] = true;
                continue;
            }
            if (!offline[x]) {
                ans[idx++] = x;
                continue;
            }
            pq = heaps.get(belong[x]);
            // 懒删除:取堆顶的时候,如果离线,才删除
            while (!pq.isEmpty() && offline[pq.peek()]) {
                pq.poll();
            }
            ans[idx++] = pq.isEmpty() ? -1 : pq.peek();
        }
        return ans;
    }

    private void dfs(int x, List<Integer>[] g, int[] belong, int compId, PriorityQueue<Integer> pq) {
        belong[x] = compId; // 记录节点 x 在哪个堆
        pq.offer(x);
        for (int y : g[x]) {
            if (belong[y] < 0) {
                dfs(y, g, belong, compId, pq);
            }
        }
    }
}

###cpp

class Solution {
public:
    vector<int> processQueries(int c, vector<vector<int>>& connections, vector<vector<int>>& queries) {
        vector<vector<int>> g(c + 1);
        for (auto& e : connections) {
            int x = e[0], y = e[1];
            g[x].push_back(y);
            g[y].push_back(x);
        }

        vector<int> belong(c + 1, -1);
        vector<priority_queue<int, vector<int>, greater<>>> heaps;
        priority_queue<int, vector<int>, greater<>> pq;

        auto dfs = [&](this auto&& dfs, int x) -> void {
            belong[x] = heaps.size(); // 记录节点 x 在哪个堆
            pq.push(x);
            for (int y : g[x]) {
                if (belong[y] < 0) {
                    dfs(y);
                }
            }
        };

        for (int i = 1; i <= c; i++) {
            if (belong[i] < 0) {
                dfs(i);
                heaps.emplace_back(move(pq));
            }
        }

        vector<int> ans;
        vector<int8_t> offline(c + 1);
        for (auto& q : queries) {
            int x = q[1];
            if (q[0] == 2) {
                offline[x] = true;
                continue;
            }
            if (!offline[x]) {
                ans.push_back(x);
                continue;
            }
            auto& h = heaps[belong[x]];
            // 懒删除:取堆顶的时候,如果离线,才删除
            while (!h.empty() && offline[h.top()]) {
                h.pop();
            }
            ans.push_back(h.empty() ? -1 : h.top());
        }
        return ans;
    }
};

###go

func processQueries(c int, connections [][]int, queries [][]int) (ans []int) {
g := make([][]int, c+1)
for _, e := range connections {
x, y := e[0], e[1]
g[x] = append(g[x], y)
g[y] = append(g[y], x)
}

belong := make([]int, c+1)
for i := range belong {
belong[i] = -1
}
heaps := []hp{}
var h hp

var dfs func(int)
dfs = func(x int) {
belong[x] = len(heaps) // 记录节点 x 在哪个堆
h.IntSlice = append(h.IntSlice, x)
for _, y := range g[x] {
if belong[y] < 0 {
dfs(y)
}
}
}
for i := 1; i <= c; i++ {
if belong[i] >= 0 {
continue
}
h = hp{}
dfs(i)
heap.Init(&h)
heaps = append(heaps, h)
}

offline := make([]bool, c+1)
for _, q := range queries {
x := q[1]
if q[0] == 2 {
offline[x] = true
continue
}
if !offline[x] {
ans = append(ans, x)
continue
}
// 懒删除:取堆顶的时候,如果离线,才删除
h := &heaps[belong[x]]
for h.Len() > 0 && offline[h.IntSlice[0]] {
heap.Pop(h)
}
if h.Len() > 0 {
ans = append(ans, h.IntSlice[0])
} else {
ans = append(ans, -1)
}
}
return
}

type hp struct{ sort.IntSlice }
func (h *hp) Push(v any) { h.IntSlice = append(h.IntSlice, v.(int)) }
func (h *hp) Pop() any   { a := h.IntSlice; v := a[len(a)-1]; h.IntSlice = a[:len(a)-1]; return v }

复杂度分析

  • 时间复杂度:$\mathcal{O}(c\log c+n + q\log c)$ 或者 $\mathcal{O}(c+n + q\log c)$,取决于实现,其中 $n$ 是 $\textit{connections}$ 的长度,$q$ 是 $\textit{queries}$ 的长度。
  • 空间复杂度:$\mathcal{O}(c+n)$。返回值不计入。

方法二:倒序处理 + 维护最小值

倒序处理询问,离线变成在线,删除变成添加,每个连通块只需要一个 $\texttt{int}$ 变量就可以维护最小值。

注意可能存在同一个节点多次离线的情况,我们需要记录节点离线的最早时间(询问的下标)。对于倒序处理来说,离线的最早时间才是真正的在线时间。

###py

class Solution:
    def processQueries(self, c: int, connections: List[List[int]], queries: List[List[int]]) -> List[int]:
        g = [[] for _ in range(c + 1)]
        for x, y in connections:
            g[x].append(y)
            g[y].append(x)

        belong = [-1] * (c + 1)
        cc = 0  # 连通块编号

        def dfs(x: int) -> None:
            belong[x] = cc  # 记录节点 x 在哪个连通块
            for y in g[x]:
                if belong[y] < 0:
                    dfs(y)

        for i in range(1, c + 1):
            if belong[i] < 0:
                dfs(i)
                cc += 1

        # 记录每个节点的离线时间,初始为无穷大(始终在线)
        offline_time = [inf] * (c + 1)
        for i in range(len(queries) - 1, -1, -1):
            t, x = queries[i]
            if t == 2:
                offline_time[x] = i  # 记录离线时间

        # 每个连通块中仍在线的电站的最小编号
        mn = [inf] * cc
        for i in range(1, c + 1):
            if offline_time[i] == inf:  # 最终仍在线
                j = belong[i]
                mn[j] = min(mn[j], i)

        ans = []
        for i in range(len(queries) - 1, -1, -1):
            t, x = queries[i]
            j = belong[x]
            if t == 2:
                if offline_time[x] == i:
                    mn[j] = min(mn[j], x)  # 变回在线
            elif i < offline_time[x]:  # 已经在线(写 < 或者 <= 都可以)
                ans.append(x)
            elif mn[j] != inf:
                ans.append(mn[j])
            else:
                ans.append(-1)
        ans.reverse()
        return ans

###java

class Solution {
    public int[] processQueries(int c, int[][] connections, int[][] queries) {
        List<Integer>[] g = new ArrayList[c + 1];
        Arrays.setAll(g, i -> new ArrayList<>());
        for (int[] e : connections) {
            int x = e[0], y = e[1];
            g[x].add(y);
            g[y].add(x);
        }

        int[] belong = new int[c + 1];
        Arrays.fill(belong, -1);
        int cc = 0; // 连通块编号
        for (int i = 1; i <= c; i++) {
            if (belong[i] < 0) {
                dfs(i, g, belong, cc);
                cc++;
            }
        }

        int[] offlineTime = new int[c + 1];
        Arrays.fill(offlineTime, Integer.MAX_VALUE);
        int q1 = 0;
        for (int i = queries.length - 1; i >= 0; i--) {
            int[] q = queries[i];
            if (q[0] == 2) {
                offlineTime[q[1]] = i; // 记录最早离线时间
            } else {
                q1++;
            }
        }

        // 维护每个连通块的在线电站的最小编号
        int[] mn = new int[cc];
        Arrays.fill(mn, Integer.MAX_VALUE);
        for (int i = 1; i <= c; i++) {
            if (offlineTime[i] == Integer.MAX_VALUE) { // 最终仍然在线
                int j = belong[i];
                mn[j] = Math.min(mn[j], i);
            }
        }

        int[] ans = new int[q1];
        for (int i = queries.length - 1; i >= 0; i--) {
            int[] q = queries[i];
            int x = q[1];
            int j = belong[x];
            if (q[0] == 2) {
                if (offlineTime[x] == i) { // 变回在线
                    mn[j] = Math.min(mn[j], x);
                }
            } else {
                q1--;
                if (i < offlineTime[x]) { // 已经在线(写 < 或者 <= 都可以)
                    ans[q1] = x;
                } else if (mn[j] != Integer.MAX_VALUE) {
                    ans[q1] = mn[j];
                } else {
                    ans[q1] = -1;
                }
            }
        }
        return ans;
    }

    private void dfs(int x, List<Integer>[] g, int[] belong, int compId) {
        belong[x] = compId;
        for (int y : g[x]) {
            if (belong[y] < 0) {
                dfs(y, g, belong, compId);
            }
        }
    }
}

###cpp

class Solution {
public:
    vector<int> processQueries(int c, vector<vector<int>>& connections, vector<vector<int>>& queries) {
        vector<vector<int>> g(c + 1);
        for (auto& e : connections) {
            int x = e[0], y = e[1];
            g[x].push_back(y);
            g[y].push_back(x);
        }

        vector<int> belong(c + 1, -1);
        int cc = 0; // 连通块编号
        auto dfs = [&](this auto&& dfs, int x) -> void {
            belong[x] = cc; // 记录节点 x 在哪个连通块
            for (int y : g[x]) {
                if (belong[y] < 0) {
                    dfs(y);
                }
            }
        };

        for (int i = 1; i <= c; i++) {
            if (belong[i] < 0) {
                dfs(i);
                cc++;
            }
        }

        vector<int> offline_time(c + 1, INT_MAX);
        for (int i = queries.size() - 1; i >= 0; i--) {
            auto& q = queries[i];
            if (q[0] == 2) {
                offline_time[q[1]] = i; // 记录最早离线时间
            }
        }

        // 维护每个连通块的在线电站的最小编号
        vector<int> mn(cc, INT_MAX);
        for (int i = 1; i <= c; i++) {
            if (offline_time[i] == INT_MAX) { // 最终仍然在线
                int j = belong[i];
                mn[j] = min(mn[j], i);
            }
        }

        vector<int> ans;
        for (int i = queries.size() - 1; i >= 0; i--) {
            auto& q = queries[i];
            int x = q[1];
            int j = belong[x];
            if (q[0] == 2) {
                if (offline_time[x] == i) { // 变回在线
                    mn[j] = min(mn[j], x);
                }
            } else if (i < offline_time[x]) { // 已经在线(写 < 或者 <= 都可以)
                ans.push_back(x);
            } else if (mn[j] != INT_MAX) {
                ans.push_back(mn[j]);
            } else {
                ans.push_back(-1);
            }
        }
        ranges::reverse(ans);
        return ans;
    }
};

###go

func processQueries(c int, connections [][]int, queries [][]int) []int {
g := make([][]int, c+1)
for _, e := range connections {
x, y := e[0], e[1]
g[x] = append(g[x], y)
g[y] = append(g[y], x)
}

belong := make([]int, c+1)
for i := range belong {
belong[i] = -1
}
cc := 0 // 连通块编号

var dfs func(int)
dfs = func(x int) {
belong[x] = cc // 记录节点 x 在哪个连通块
for _, y := range g[x] {
if belong[y] < 0 {
dfs(y)
}
}
}
for i := 1; i <= c; i++ {
if belong[i] < 0 {
dfs(i)
cc++
}
}

offlineTime := make([]int, c+1)
for i := range offlineTime {
offlineTime[i] = math.MaxInt
}
q1 := 0
for i, q := range slices.Backward(queries) {
if q[0] == 2 {
offlineTime[q[1]] = i // 记录最早离线时间
} else {
q1++
}
}

// 维护每个连通块的在线电站的最小编号
mn := make([]int, cc)
for i := range mn {
mn[i] = math.MaxInt
}
for i := 1; i <= c; i++ {
if offlineTime[i] == math.MaxInt { // 最终仍然在线
j := belong[i]
mn[j] = min(mn[j], i)
}
}

ans := make([]int, q1)
for i, q := range slices.Backward(queries) {
x := q[1]
j := belong[x]
if q[0] == 2 {
if offlineTime[x] == i { // 变回在线
mn[j] = min(mn[j], x)
}
} else {
q1--
if i < offlineTime[x] { // 已经在线(写 < 或者 <= 都可以)
ans[q1] = x
} else if mn[j] != math.MaxInt {
ans[q1] = mn[j]
} else {
ans[q1] = -1
}
}
}
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(c+n + q)$,其中 $n$ 是 $\textit{connections}$ 的长度,$q$ 是 $\textit{queries}$ 的长度。
  • 空间复杂度:$\mathcal{O}(c+n)$。返回值不计入。

相似题目

3108. 带权图里旅途的最小代价

专题训练

  1. 图论题单的「§1.1 DFS 基础」。
  2. 数据结构题单的「§5.6 懒删除堆」。
  3. 数据结构题单的「专题:离线算法」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

并查集 & 数据结构

解法:并查集 & 数据结构

首先用并查集计算每个电站属于哪些电网,然后对每个电网用一个 set 维护当前在线的电站,这样即可在 $\mathcal{O}(\log n)$ 的复杂度内删除电站,并在 $\mathcal{O}(1)$ 的复杂度内查询编号最小的在线电站。

整体复杂度 $\mathcal{O}((n + q)\log n)$。

参考代码(c++)

class Solution {
public:
    vector<int> processQueries(int n, vector<vector<int>>& connections, vector<vector<int>>& queries) {
        int root[n + 1];
        // 求并查集的根
        auto findroot = [&](this auto &&findroot, int x) -> int {
            if (root[x] != x) root[x] = findroot(root[x]);
            return root[x];
        };

        // 构建电网
        for (int i = 1; i <= n; i++) root[i] = i;
        for (auto &edge : connections) {
            int x = findroot(edge[0]), y = findroot(edge[1]);
            if (x != y) root[x] = y;
        }

        // 对每个电网用一个 set 维护当前在线的电站
        set<int> st[n + 1];
        for (int i = 1; i <= n; i++) st[findroot(i)].insert(i);

        vector<int> ans;
        for (auto &qry : queries) {
            int r = findroot(qry[1]);
            if (qry[0] == 1) {
                // 该电站未离线
                if (st[r].count(qry[1])) ans.push_back(qry[1]);
                // 该电站已离线,但电网里还有未离线的电站,取最小值
                else if (st[r].size() > 0) ans.push_back(*st[r].begin());
                // 电网里的电站都离线了
                else ans.push_back(-1);
            } else {
                // 将电站离线
                st[r].erase(qry[1]);
            }
        }
        return ans;
    }
};

Webpack中各种devtool配置的含义与SourceMap生成逻辑

简述

在之前的文章中,我们对SourceMap进行简单的了解:快速定位源码问题:SourceMap的生成/使用/文件格式与历史。SourceMap的出现,是为了应对前端工程化工具在转义,打包,压缩等操作后,代码变化非常大,出错后排查报错位置困难的问题,原理是记录源和生成代码中标识符的位置关系。

Webpack是目前流行的前端打包工具,在修改源代码的同时,也会生成SourceMap文件。Webpack提供了几十种生成的SourceMap的生成方式,生成的文件内容和性能各不相同,这次我们就来了解下Webpack中的SourceMap配置。

Webpack中的devtool配置不仅涉及SourceMap,还与代码生成,开发/生产模式有关系。本文更多使用生产模式,更在意SourceMap数据本身,而不是Webpack构建过程。

创建Webpack示例

创建一个使用Webpack打包的基础示例,后面各种配置都基于这个示例修改。首先命令行执行:

# 创建工程
npm init -y
# 安装Webpack相关依赖
npm install webpack webpack-cli html-webpack-plugin --save-dev

然后创建文件src/index.js,这就是我们要打包的文件。内容如下(执行到第二行会出现找不到变量的报错):

const a = 1;
console.log(a + b);

然后在package.json文件的scripts中增加命令:"build": "webpack"。最后是Webpack配置文件webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'production', // 生产模式
  entry: './src/index.js', // 源码入口
  plugins: [
    new HtmlWebpackPlugin({ // 生成HTML页面入口
      title: 'jzplp的SourceMap实验', // 页面标题
    }),
  ],
  output: {
    filename: 'main.js', // 生成文件名
    path: path.resolve(__dirname, 'dist'),  // 生成文件目录
    clean: true, // 生成前删除dist目录内容
  },
  devtool: 'source-map'
};

devtool表示SourceMap的生成配置,后面主要修改的就是它。它为什么叫做devtool而不直接而叫做sourcemap,是因为它除了控制SourceMap生成之外,也控制代码如何生成,后面我们会看到例子。

命令行运行npm run build,即可使用Webpack打包,同时生成SourceMap文件。生成后目录结构如下:

|-- webpack1
    |-- package-lock.json
    |-- package.json
    |-- webpack.config.js
    |-- dist
    |   |-- index.html
    |   |-- main.js
    |   |-- main.js.map
    |-- src
        |-- index.js

使用浏览器打开index.html,即可看到代码执行效果,查看错误信息。生成的HTML文件内容如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>jzplp的SourceMap实验</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <script defer="defer" src="main.js"></script>
  </head>
  <body></body>
</html>

解析SourceMap工具

这里还需要一段解析SourceMap文件的代码,方便后续拿到map文件后分析数据。这里使用source-map包,详细描述可以看快速定位源码问题:SourceMap的生成/使用/文件格式与历史。创建一个mapAnalysis.js文件,内容如下:

const sourceMap = require("source-map");
const fs = require("fs");
// 打开SourceMap文件
const data = fs.readFileSync("./dist/main.js.map", "utf-8");

function outputData(data) {
  if (data || data === 0) return String(data);
  return "-";
}

async function jzplpfun() {
  const consumer = await new sourceMap.SourceMapConsumer(data);
  // 遍历内容
  consumer.eachMapping((item) => {
    // 美化输出
    console.log(
      `生成代码行${outputData(item.generatedLine).padEnd(2)}${outputData(
        item.generatedColumn
      ).padEnd(2)} 源代码行${outputData(item.originalLine).padEnd(
        2
      )}${outputData(item.originalColumn).padEnd(2)} 源名称${outputData(
        item.name
      ).padEnd(12)} 源文件:${outputData(item.source)}`
    );
  });
}
jzplpfun();

代码的内容是读取SourceMap文件,解析并输出其中的位置对应关系。执行node mapAnalysis.js即可。解析后的结果示例如下。后面会直接利用这段代码解析生成的SourceMap。

生成代码行10  源代码行20  源名称console      源文件:webpack://webpack1/src/index.js
生成代码行18  源代码行28  源名称log          源文件:webpack://webpack1/src/index.js
生成代码行112 源代码行110 源名称-            源文件:webpack://webpack1/src/index.js
生成代码行114 源代码行216 源名称b            源文件:webpack://webpack1/src/index.js

值(none)

(none)表示不设置devtool,也就是不生成SourceMap数据。(注意devtool: 'none'是错误值)。我们生成试一下,作为对比:

// main.js
console.log(1+b);

可以看到只生成了代码,没有SourceMap。在浏览器中打开页面,看到Console报错中指示的文件为生成文件main.js。点击文件名查看也是生成文件的代码,如下图:

devtool-1.png

值source-map

devtool: 'source-map'这个配置会生成打包后的代码和独立的SourceMap文件。生成内容如下:

// main.js
console.log(1+b);
//# sourceMappingURL/* 防止报错 */=main.js.map

// main.js.map
{
  "version": 3,
  "file": "main.js",
  "mappings": "AACAA,QAAQC,IADE,EACMC",
  "sources": ["webpack://webpack1/./src/index.js"],
  "sourcesContent": ["const a = 1;\r\nconsole.log(a + b);"],
  "names": ["console", "log", "b"],
  "sourceRoot": ""
}

使用工具解析,SourceMap中的位置关系如下:

生成代码行10  源代码行20  源名称console      源文件:webpack://webpack1/src/index.js
生成代码行18  源代码行28  源名称log          源文件:webpack://webpack1/src/index.js
生成代码行112 源代码行110 源名称-            源文件:webpack://webpack1/src/index.js
生成代码行114 源代码行216 源名称b            源文件:webpack://webpack1/src/index.js

在浏览器中打开页面,看到Console报错中指示的文件为源代码文件index.js,第二行。点击文件名查看也是源代码文件的代码,标出了错误的位置,如下图:

devtool-2.png

值inline-前缀

配置中可以增加inline-前缀,表示SourceMap数据附加在生成的文件中,而不是作为一个独立的文件存在。这里以devtool: 'inline-source-map为例生成试试。

// main.js
console.log(1+b);
//# sourceMappingURL/* 防止报错 */=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWFpbi5qcyIsIm1hcHBpbmdzIjoiQUFDQUEsUUFBUUMsSUFERSxFQUNNQyIsInNvdXJjZXMiOlsid2VicGFjazovL3dlYnBhY2sxLy4vc3JjL2luZGV4LmpzIl0sInNvdXJjZXNDb250ZW50IjpbImNvbnN0IGEgPSAxO1xyXG5jb25zb2xlLmxvZyhhICsgYik7Il0sIm5hbWVzIjpbImNvbnNvbGUiLCJsb2ciLCJiIl0sInNvdXJjZVJvb3QiOiIifQ==

可以看到没由生成main.js.map,但是最后多了一行注释,sourceMappingURL的值为Data URL格式的SourceMap数据。复制到浏览器地址栏中,得到结果如下。这个JSON数据和前面devtool: 'source-map'中生成的完全一致。

{
  "version": 3,
  "file": "main.js",
  "mappings": "AACAA,QAAQC,IADE,EACMC",
  "sources": ["webpack://webpack1/./src/index.js"],
  "sourcesContent": ["const a = 1;\r\nconsole.log(a + b);"],
  "names": ["console", "log", "b"],
  "sourceRoot": ""
}

SourceMap数据附加在生成代码文件中会使得文件体积大幅增加,进而造成页面文件下载速度变慢。这里浏览器效果和devtool: 'source-map'一致,就不展示了。

值nosources-前缀

配置中可以增加nosources-前缀,表示源代码不包含在SourceMap数据中。这里以devtool: 'nosources-source-map为例生成试试。

// main.js
console.log(1+b);
//# sourceMappingURL/* 防止报错 */=main.js.map

// main.js.map
{
  "version": 3,
  "file": "main.js",
  "mappings": "AACAA,QAAQC,IADE,EACMC",
  "sources": ["webpack://webpack1/./src/index.js"],
  "names": ["console", "log", "b"],
  "sourceRoot": ""
}

生成的SourceMap数据与前面devtool: 'source-map'生成的相比,缺少了sourcesContent属性,这个属性包含的就是源代码内容。

在浏览器中打开页面,看到Console报错中指示的文件为源代码文件index.js,第二行,也就是说SourceMap数据是生效的。但点击文件名查看,却找不到源代码文件,这是因为我们没提供文件,webpack生成的文件路径webpack://浏览器不能使用它来找到文件。

devtool-3.png

值hidden-前缀

配置中可以增加hidden-前缀,表示生成SourceMap,但是在源码中并不生成引用注释。这里以devtool: 'hidden-source-map为例生成试试。

// main.js
console.log(1+b);

// main.js.map
{
  "version": 3,
  "file": "main.js",
  "mappings": "AACAA,QAAQC,IADE,EACMC",
  "sources": ["webpack://webpack1/./src/index.js"],
  "sourcesContent": ["const a = 1;\r\nconsole.log(a + b);"],
  "names": ["console", "log", "b"],
  "sourceRoot": ""
}

通过结果可以看到,生成的SourceMap数据与前面devtool: 'source-map'生成的相比一致。但是生成代码最后一行表示SourceMap文件地址的注释却没有了。我们使用浏览器打开,发现错误定位依然到的是生成文件,SourceMap未生效。

devtool-4.png

这种配置一般用于生成SourceMap文件,但并不提供给用户下载的场景。可以使用浏览器主动附加SourceMap,上报收集报错栈数据,或者利用其它工具解析SourceMap并处理报错数据。

这里我们试一下浏览器主动附加SourceMap:右键点击生成代码文件内容,出现Add source map选项,把我们刚才生成的SourceMap文件添加进去。结果与在源码中指定了SourceMap文件地址的现象一致,错误信息被SourceMap处理了。

devtool-5.png

值eval

devtool可以直接取值为eval,此时不生成SourceMap,而是直接控制代码生成。这也是为什么devtool不叫sourcemap的原因,因为它不只控制SourceMap的生成。我们来看一下配置为devtool: 'eval'时的生成结果:

// main.js
(() => {
  var __webpack_modules__ = {
      44: () => {
        eval(
          "{const a = 1;\r\nconsole.log(a + b);\n\n//# sourceURL=webpack://webpack1/./src/index.js?\n}"
        );
      },
    },
    __webpack_exports__ = {};
  __webpack_modules__[44]();
})();

可以看到,源代码被包裹在eval中执行。为什么要这么做?因为这样生成代码的速度很快,而且当源代码被修改后,增量构建的速度也很快,因此开发模式下经常使用值eval以及后面要介绍的eval前缀。但是由于代码包裹在eval中执行,执行效率比较低,因此不适合作为生产模式使用。

我们注意到eval包裹的代码中,最后还有一句注释,指向了一个sourceURL地址。通过这个地址,浏览器会把eval中的代码识别为这个文件。我们用浏览器看一下:

devtool-6.png

可以看到,我们执行代码的的错误并没有被提示为生成的文件名main.js,而是源文件名index.js。点击文件名,到右侧文件内容,发现是把eval中的代码作为源文件index.js的内容了。

这样使用eval虽然没有SourceMap数据,但是错误内容的指示依然很清晰,我们很容易找到源码并修改。注意eval中并不是真的源代码,内容与真正的源码有一定的区别,例如最前面和最后面的括号。

值eval-前缀

eval除了可以作为值,还可以作为前缀,例如devtool: 'eval-source-map'。此时不仅有eval的特性,还会生成SourceMap数据。我们试一下:

// main.js
(() => {
  var __webpack_modules__ = {
      44: () => {
        eval(
          "{const a = 1;\r\nconsole.log(a + b);//# sourceURL=[module]\n//# sourceMappingURL/* 防止报错 */=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiNDQuanMiLCJtYXBwaW5ncyI6IkFBQUE7QUFDQSIsInNvdXJjZXMiOlsid2VicGFjazovL3dlYnBhY2sxLy4vc3JjL2luZGV4LmpzP2I2MzUiXSwic291cmNlc0NvbnRlbnQiOlsiY29uc3QgYSA9IDE7XHJcbmNvbnNvbGUubG9nKGEgKyBiKTsiXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///44\n\n}"
        );
      },
    },
    __webpack_exports__ = {};
  __webpack_modules__[44]();
})();

看生成代码中源码也是被eval包裹的,但在后面出现了三条注释,其中一条是sourceMappingURL,也就是SourceMap数据。两条是sourceURL,其中第一条sourceURL=[module]是没有用处的,我尝试过是否删除这条对现象没有影响,应该是被第二条覆盖了。我们先来解析一下里面的SourceMap数据,内容如下:

{
  "version": 3,
  "file": "44.js",
  "mappings": "AAAA;AACA",
  "sources": ["webpack://webpack1/./src/index.js?b635"],
  "sourcesContent": ["const a = 1;\r\nconsole.log(a + b);"],
  "names": [],
  "sourceRoot": ""
}

/* 解析后位置关系数据
生成代码行1  列0  源代码行1  列0  源名称-            源文件:webpack://webpack1/src/index.js?b635
生成代码行2  列0  源代码行2  列0  源名称-            源文件:webpack://webpack1/src/index.js?b635
*/

devtool-7.png

devtool-8.png

打开浏览器,发现此时错误信息是经过转换的,定位到了源码文件,但是仅定位到了行,没有具体到错误的列位置。而且右侧除了出现源码和生成代码外,还出现了另一个叫做44的文件。这里我们结合生成代码和浏览器现象,一起分析一下:

devtool-9.png

index.js是源码,经过WebPack打包生成了mian.js。其中包含了eval内代码和SourceMap数据。这部分代码由于包含注释sourceURL,因此被浏览器展示为独立的文件44。由于sourceMappingURL在eval内代码中,因此这个SourceMap被认为是源码index.js和eval内代码的转换关系,并不是index.js与mian.js的转换关系。

至于为什么但是仅定位到了行,我们看SourceMap解析后的数据,发现它仅仅是将每行关联起来,没有详细的记录每个标识符的转换关系。因此才只定位到行号。至于为什么这么做,这是因为性能考虑,毕竟eval内代码也是将源码直接拿过来用,因此也就不费力生成高质量的SourceMap了。

值cheap-前缀

配置中可以增加cheap-前缀,表示生成简略版的SourceMap,只有行号没有列号。这里以devtool: 'cheap-source-map为例生成试试。

// main.js
console.log(1+b);
//# sourceMappingURL/* 防止报错 */=main.js.map

// main.js.map
{
  "version": 3,
  "file": "main.js",
  "mappings": "AACA",
  "sources": ["webpack://webpack1/./src/index.js"],
  "sourcesContent": ["const a = 1;\r\nconsole.log(a + b);"],
  "names": [],
  "sourceRoot": ""
}

/* 解析后位置关系数据
生成代码行1  列0  源代码行2  列0  源名称-            源文件:webpack://webpack1/src/index.js
*/

可以看到,正常生成了代码与SourceMap文件,但是SourceMap中却只有一条行对行的转换关系,没有列信息,更没有标识符。我们在浏览器中看一下效果:

devtool-10.png

可以看到,与devtool: 'source-map的效果不同,它的错误指向的是源码中的一整行,并不精确。为什么明明有更精确的选项,却存在这种模糊的SourceMap数据呢?这是因为它虽然信息模糊,但生成速度更快,可以适用于开发模式或者追求速度的场景。

值module-前缀

配置中可以增加module-前缀,可以实现SourceMap映射生成的功能。与这个场景非常相似的例子,我们在source-map包的SourceMapGenerator对象中的applySourceMap方法中描述过。这个场景是将已生成的代码作为源代码,继续生成代码,同时生成SourceMap,实现最终生成代码与最开始的源代码的位置关系映射。这个场景经常用于希望关联npm包中的SourceMap,进行错误排查或调试使用。Webpack限制module-前缀必须与cheap-前缀一起使用,因此我们以devtool: 'cheap-module-source-map生成试试。

模拟npm包

这里有两步,第一步我们模拟一个npm包的打包并生成SourceMap。这里我们使用前面【创建Webpack示例】中的方法创建新一个项目,项目名称为project1。源码文件改名为index2.js(不和主示例项目用同一个文件名),Webpack配置文件webpack.config.js有改动:

// index2.js
const a = 1;
console.log(a, b);

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'production', // 生产模式
  entry: './src/index2.js', // 源码入口
  output: {
    filename: 'project1.js', // 生成文件名
    path: path.resolve(__dirname, 'dist'),  // 生成文件目录
    clean: true, // 生成前删除dist目录内容
  },
  devtool: 'source-map'
};

我们只需要它生成的Javascript代码,并不需要HTML,因此就不生成了。这里并不限制SourceMap数据类型,我们生成一个最简单的devtool: 'source-map。生成的结果如下:

// project1.js
console.log(1,b);
//# sourceMappingURL/* 防止报错 */=project1.js.map

// project1.js.map
{
  "version": 3,
  "file": "project1.js",
  "mappings": "AACAA,QAAQC,IADE,EACKC",
  "sources": ["webpack://webpack1/./src/index.js"],
  "sourcesContent": ["const a = 1;\r\nconsole.log(a, b);"],
  "names": ["console", "log", "b"],
  "sourceRoot": ""
}

/* 解析后位置关系数据
生成代码行1  列0  源代码行2  列0  源名称console      源文件:webpack://webpack1/src/index.js
生成代码行1  列8  源代码行2  列8  源名称log          源文件:webpack://webpack1/src/index.js
生成代码行1  列12 源代码行1  列10 源名称-            源文件:webpack://webpack1/src/index.js
生成代码行1  列14 源代码行2  列15 源名称b            源文件:webpack://webpack1/src/index.js
*/

这里我们把package.json里面的main属性改成project1.js,它即是这个包的入口文件;增加"type": "module",表示是一个ESModule的包。这里不污染npm仓库,就不发包了。我们在主示例项目的根目录中新建project1文件夹,然后将package.json, 以及dist目录里面的文件都放进去。最后主示例项目的目录结构如下:

|-- webpack1
    |-- mapAnalysis.js
    |-- package-lock.json
    |-- package.json
    |-- webpack.config.js
    |-- dist
    |   |-- index.html
    |   |-- main.js
    |   |-- main.js.map
    |-- project1
    |   |-- package.json
    |   |-- project1.js
    |   |-- project1.js.map
    |-- src
        |-- index.js

主示例不使用module-前缀

修改主示例中的index.js,引入project1包中的代码,否则project1包的代码不会被打包进来。

Webpack解析已有的SourceMap文件需要loader。首先命令行执行npm install source-map-loader --save-dev安装依赖,然后修改Webpack配置文件webpack.config.js。使用Rule.extractSourceMap选项也能解析已有的SourceMap文件,可以看注释。

注意这里我们首先使用devtool: "cheap-source-map"试一下效果。这里关闭了代码压缩,实测打开压的时候使用cheap-前缀不会生成SourceMap数据。

// index.js
import "../project1";

const c = 3;
console.log(c, d);

// webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "production",
  entry: "./src/index.js",
  optimization: {
    minimize: false, // 关闭代码压缩
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "jzplp的SourceMap实验",
    }),
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        use: "source-map-loader",
      },
      /*
      {
        test: /\.m?js$/,
        extractSourceMap: true,
      },
      */
    ],
  },
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  devtool: "cheap-source-map",
};

这里我们生成的代码和SourceMap数据如下:

// mian.js
/******/ (() => { // webpackBootstrap
/******/ "use strict";

;// ./project1/project1.js
console.log(1,b);

;// ./src/index.js


const c = 3;
console.log(c, d);

/******/ })()
;
//# sourceMappingURL/* 防止报错 */=main.js.map

// main.js.map
{
  "version": 3,
  "file": "main.js",
  "mappings": ";;;;AAAA;;;ACAA;AACA;AACA;AACA",
  "sources": [
    "webpack://webpack1/./project1/project1.js",
    "webpack://webpack1/./src/index.js"
  ],
  "sourcesContent": [
    "console.log(1,b);\n",
    "import \"../project1\";\r\n\r\nconst c = 3;\r\nconsole.log(c, d);\r\n"
  ],
  "names": [],
  "sourceRoot": ""
}

/* 解析后位置关系数据
生成代码行5  列0  源代码行1  列0  源名称-            源文件:webpack://webpack1/project1/project1.js
生成代码行8  列0  源代码行1  列0  源名称-            源文件:webpack://webpack1/src/index.js
生成代码行9  列0  源代码行2  列0  源名称-            源文件:webpack://webpack1/src/index.js
生成代码行10 列0  源代码行3  列0  源名称-            源文件:webpack://webpack1/src/index.js
生成代码行11 列0  源代码行4  列0  源名称-            源文件:webpack://webpack1/src/index.js
*/

通过SourceMap数据可以看到,使用cheap-source-map,报错信息是关联到npm包中的生成文件project1.js中的,并没有使用project1.js.map数据。我们在浏览器看下效果。

devtool-11.png

可以看到错误被识别到了project1.js文件中,我们主项目SourceMap数据起作用了,但是没有关联到project1中的源码。

主示例使用module-前缀

修改Webpack配置为devtool: 'cheap-module-source-map,然后重新生成代码。

// mian.js
/******/ (() => { // webpackBootstrap
/******/ "use strict";

;// ./project1/project1.js
console.log(1,b);

;// ./src/index.js


const c = 3;
console.log(c, d);

/******/ })()
;
//# sourceMappingURL/* 防止报错 */=main.js.map

// main.js.map
{
  "version": 3,
  "file": "main.js",
  "mappings": ";;;;AACA;;;ACDA;AACA;AACA;AACA",
  "sources": [
    "webpack://webpack1/webpack1/./src/index2.js",
    "webpack://webpack1/./src/index.js"
  ],
  "sourcesContent": [
    "const a = 1;\r\nconsole.log(a, b);",
    "import \"../project1\";\r\n\r\nconst c = 3;\r\nconsole.log(c, d);\r\n"
  ],
  "names": [],
  "sourceRoot": ""
}

/* 解析后位置关系数据
生成代码行5  列0  源代码行2  列0  源名称-            源文件:webpack://webpack1/webpack1/src/index2.js
生成代码行8  列0  源代码行1  列0  源名称-            源文件:webpack://webpack1/src/index.js
生成代码行9  列0  源代码行2  列0  源名称-            源文件:webpack://webpack1/src/index.js
生成代码行10 列0  源代码行3  列0  源名称-            源文件:webpack://webpack1/src/index.js
生成代码行11 列0  源代码行4  列0  源名称-            源文件:webpack://webpack1/src/index.js
*/

生成的mian.js依然是一致的,可以忽略。但是main.js.map却不一样了。通过解析可以看到,它直接与project1中的源码文件index2.js产生了关系,因此Webpack内部将project1.js.map利用上了,因此可以直接定位到npm包中的源码。我们看一下浏览器效果:

devtool-12.png

可以看到,错误直接定位到了源文件index2.js。右侧浏览器目录中的project1.js消失了,取代的是index2.js的源码和错误位置信息。通过这种方式,可以排查和调试npm包中的错误。最后用一张图表示它们之间的关系:

devtool-13.png

混合前缀值

前面我们介绍了devtool中的各种前缀值,这些前缀值可以互相组合成几十种选项。选项需要符合这个规则:[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map。例如eval-cheap-module-source-map, hidden-nosources-cheap-source-map等等,这里就不完整列举了。

这些值分别满足前面这些前缀值的相关特性。在实际开发中,会根据不同的场景选择不同的模式,这里我们简单列举一下不同前缀符合的特点,详细的可以参考Webpack文档。

前缀值 构建速度 是否适合生产模式 SourceMap质量
eval
cheap
inline - -
cheap
nosources - -
hidden - -
inline - -
module -

sourceURL注释

在前面eval相关配置中,我们看到了sourceURL注释,指向一个地址。浏览器会解析这个注释,把这个地址作为这个代码的源文件。但与SourceMap不同的是,sourceMappingURL会真的请求文件,sourceURL并不会请求,而是把代码本身当作文件内容。这里我们尝试script标签和eval两种场景。

script标签

首先我们构造一段代码,里面包含三个script标签的例子a,b和c。首先是index.html:

<html>
  <script src="./a.js"></script>
  <script>
    try {
      console.log("jzplp", b);
    } catch (e) {
      console.log(e);
    }
  </script>
  <script>
    //# sourceURL=./c.js
    try {
      console.log("jzplp", c);
    } catch (e) {
      console.log(e);
    }
  </script>
</html>

因为需要同时输出三个错误,因此我们将错误捕获之后输出,这样依然可以关联到源文件。具体可以看快速定位源码问题:SourceMap的生成/使用/文件格式与历史文章中的浏览器使用SourceMap部分。然后是两个独立的js文件,a.js和c.js。其中a是被HTML直接引用的,c并没有被引用,只是用来尝试有没有被请求。

// a.js
try {
  console.log("jzplp", a);
} catch (e) {
  console.log(e);
}

// c.js
try {
  console.log("jzplp", c);
} catch (e) {
  // is c
  console.log(e);
}

然后我们在浏览器中打开index.html文件,在Console中查看输出结果,以及点击文件名称查看文件:

devtool-14.png

  • 例子a:标签直接引用文件,浏览器加载的也是文件,因此报错栈信息和浏览器文件中都能展示正确的文件。
  • 例子b:标签中直接写代码,浏览器无法与独立文件相关联,因此认为是index.html中的一部分。
  • 例子c:标签中直接写代码,但是增加了sourceURL注释。浏览器认为它来源于独立的文件,因此把标签中的内容作为独立的c.js文件展示。

注意此时查看Developer resources,发现其中没有c.js的文件请求,文件内容也与独立的c.js不一致。因此,浏览器读取sourceURL注释后,并不会真的请求源文件,而只是把当前代码(在这里是标签内代码)作为独立文件展示。而sourceURL值作为文件路径。

eval

我们最开始是在Webpack的eval中发现sourceURL的,因此eval肯定也如同script标签一样支持sourceURL。这里我们再举d,e,f三个例子:

<html>
  <script>
    eval(`
    try {
      console.log("jzplp", d);
    } catch (e) {
      console.log(e);
    }
    `);
  </script>
  <script>
    eval(`
    //# sourceURL=./e.js
    try {
      console.log("jzplp", e);
    } catch (e) {
      console.log(e);
    }
    `);
  </script>
  <script>
    //# sourceURL=./f1.js
    eval(`
    //# sourceURL=./f2.js
    try {
      console.log("jzplp", f);
    } catch (e) {
      console.log(e);
    }
    `);
  </script>
</html>

devtool-15.png

  • 例子d:直接写eval,浏览器无法关联文件,认为是index2.html中的一部分。
  • 例子b:eval中增加了sourceURL注释,浏览器认为它来源于独立的文件,因此把eval中的内容作为独立的e.js文件展示。(图中左下)
  • 例子c:标签和eval都有sourceURL注释。浏览器认为它们都是来源于独立的文件,因此文件相当于是嵌套引用的,f1内部引用了f2:index2.html -> f1.js -> f2.js。(图中右边)

SourceMapDevToolPlugin插件

SourceMapDevToolPlugin是一个Webpack插件,对比devtool,它可以更精细的控制SourceMap生成行为。详细说明可以看参考中的SourceMapDevToolPlugin文档,这里我们列举几个简单场景。由于生成的SourceMap内容和上面相似,这里就不重复写了,只描述配置项和效果。

module.exports = {
  // ...
  devtool: false,
  plugins: [new webpack.SourceMapDevToolPlugin({})],
};

这是默认场景,由于没有指定SourceMap的filename,因此不生成独立文件,生成效果和devtool: inline-source-map一致。

module.exports = {
  // ...
  devtool: false,
  plugins: [new webpack.SourceMapDevToolPlugin({
    filename: '[file].map',
  })],
};

指定了filename,生成独立的SourceMap文件,生成效果和devtool: source-map一致。

module.exports = {
  // ...
  devtool: false,
  plugins: [new webpack.SourceMapDevToolPlugin({
    filename: 'mapDir/[file].map',
  })],
};

将所有生成的SourceMap文件放到独立的mapDir目录中。这是devtool选项无法做到的。

module.exports = {
  // ...
  devtool: false,
  plugins: [new webpack.SourceMapDevToolPlugin({
    filename: '[file].map',
    append: '\n//# sourceMappingURL=https://jzplp.com/sourcemap/[url]',
  })],
};

修改生成代码中记录的SourceMap文件地址,适用于SourceMap的url与生成代码有区别的场景。

module.exports = {
  // ...
  devtool: false,
  plugins: [new webpack.SourceMapDevToolPlugin({
    filename: '[file].map',
    columns : 'false',
  })],
};

生成SourceMap的时候,不记录SourceMap的列信息。类似于devtool: 'cheap-source-map的效果。

总结

这篇文章总结了Webpack中生成SourceMap数据的配置与具体效果,尤其详细描述了各种devtool配置项的逻辑。devtool虽然有几十个配置选项,但都是由几个前缀组合而成的,拥有对应前缀的特性。还介绍了SourceMapDevToolPlugin插件,相比于devtool可以更灵活的生成SourceMap。

通过上面的各种例子,也可以看到生成的SourceMap数据并不是完全符合SourceMap规范,而是有一些变化,比如没有列信息,没有标识符名称等等。而浏览器也能适应这些变化,例如没有列信息就表示为整行错误。

参考

Vue 编译核心中的运行时辅助函数注册机制详解

一、概念说明

在 Vue 3 的编译流程中,**runtimeHelpers(运行时辅助函数)**是一组编译器与运行时之间的“桥梁”。
编译器在将模板编译为渲染函数(render function)时,会将某些指令(如 v-modelv-onv-show)转换为运行时调用的辅助函数。而这些辅助函数的引用与注册关系,就由 registerRuntimeHelpers() 维护。

换句话说:编译器不会直接在代码中写 withModifiers(...),而是写一个内部符号(Symbol),再通过映射表告诉运行时该符号对应哪个实际函数。


二、源码与原理解析

import { registerRuntimeHelpers } from '@vue/compiler-core'

// 定义编译时使用的唯一符号(用于标识运行时 helper 函数)
export const V_MODEL_RADIO: unique symbol = Symbol(__DEV__ ? `vModelRadio` : ``)
export const V_MODEL_CHECKBOX: unique symbol = Symbol(
  __DEV__ ? `vModelCheckbox` : ``,
)
export const V_MODEL_TEXT: unique symbol = Symbol(__DEV__ ? `vModelText` : ``)
export const V_MODEL_SELECT: unique symbol = Symbol(
  __DEV__ ? `vModelSelect` : ``,
)
export const V_MODEL_DYNAMIC: unique symbol = Symbol(
  __DEV__ ? `vModelDynamic` : ``,
)

export const V_ON_WITH_MODIFIERS: unique symbol = Symbol(
  __DEV__ ? `vOnModifiersGuard` : ``,
)
export const V_ON_WITH_KEYS: unique symbol = Symbol(
  __DEV__ ? `vOnKeysGuard` : ``,
)

export const V_SHOW: unique symbol = Symbol(__DEV__ ? `vShow` : ``)

export const TRANSITION: unique symbol = Symbol(__DEV__ ? `Transition` : ``)
export const TRANSITION_GROUP: unique symbol = Symbol(
  __DEV__ ? `TransitionGroup` : ``,
)

// 注册这些符号与其对应的运行时函数名称的映射关系
registerRuntimeHelpers({
  [V_MODEL_RADIO]: `vModelRadio`,
  [V_MODEL_CHECKBOX]: `vModelCheckbox`,
  [V_MODEL_TEXT]: `vModelText`,
  [V_MODEL_SELECT]: `vModelSelect`,
  [V_MODEL_DYNAMIC]: `vModelDynamic`,
  [V_ON_WITH_MODIFIERS]: `withModifiers`,
  [V_ON_WITH_KEYS]: `withKeys`,
  [V_SHOW]: `vShow`,
  [TRANSITION]: `Transition`,
  [TRANSITION_GROUP]: `TransitionGroup`,
})

逐行注释说明:

  • Symbol(__DEV__ ? 'vModelRadio' : '')
    → 创建一个独立的唯一符号。
    在开发模式下(__DEV__true)使用可读字符串方便调试;
    在生产模式下为空字符串以减小体积。
  • unique symbol
    → TypeScript 类型系统中的特殊标识符,确保该常量唯一、不可重名。
  • registerRuntimeHelpers()
    → 将这些符号与对应的运行时函数名建立映射。
    编译器后续生成代码时,就可以通过符号查找到对应的 Helper。

三、机制对比分析

特性 Vue 2.x 实现 Vue 3 实现
辅助函数声明 直接字符串引用(如 _vModel 使用 Symbol 唯一标识
编译与运行时绑定 模糊绑定(通过命名约定) 显式映射(registerRuntimeHelpers
Tree-shaking 较弱 可按需引入、极强
类型安全 通过 unique symbol 强类型保证

👉 结论:Vue 3 通过 Symbol 注册机制,使得运行时函数调用更加安全、可追踪且利于优化。


四、实践示例:编译阶段的 Helper 替换

模板示例:

<input v-model="checked" type="checkbox" />

编译后伪代码:

// 编译器在生成 AST → 渲染函数的过程中
// 发现 v-model + type="checkbox" => 对应 helper 为 V_MODEL_CHECKBOX

import { V_MODEL_CHECKBOX } from './runtimeHelpers'

function render(_ctx) {
  return _createElementVNode("input", {
    type: "checkbox",
    "onUpdate:modelValue": _cache[0] || (_cache[0] = _withDirectives(...))
  }, null, 512 /* NEED_PATCH */)
}

在最终构建输出阶段,Vue 会根据注册的映射表:

[V_MODEL_CHECKBOX]: 'vModelCheckbox'

将辅助函数替换为真实的运行时代码:

import { vModelCheckbox } from 'vue'

五、拓展思考:为何使用 Symbol?

  1. 唯一性保证
    即使不同模块导入相同 helper,也不会冲突。
  2. 调试友好性
    在开发环境下,Symbol 描述字符串会在控制台中显示,方便分析。
  3. 可扩展性
    未来若新增指令(如自定义指令 helper),可直接定义新 Symbol 注册即可,不破坏原有逻辑。

六、潜在问题与注意事项

潜在问题 说明 解决建议
Symbol 在生产环境中无描述 可能导致调试信息缺失 保留 DEV 构建版本以调试
registerRuntimeHelpers 顺序不当 若多次注册重复 key,会覆盖前者 遵守统一注册顺序并集中管理
运行时未导出对应函数 导致渲染阶段报错 “helper not found” 确保 runtime-dom 中对应函数存在
Tree-shaking 失效 若错误导入全部 runtime 应仅按需引用 helper 模块

七、总结

Vue 编译核心中的 runtimeHelpers模板编译到运行时执行的关键枢纽
它通过:

  • 使用 Symbol 实现唯一标识;
  • 通过 registerRuntimeHelpers() 建立映射;
  • 将编译器生成的抽象指令转译为运行时真实函数。

这一机制实现了编译与运行的解耦、类型安全、可维护与高效优化


本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue 模板解析器 parserOptions 深度解析

一、概念概述

在 Vue 的编译流程中,模板解析(template parsing) 是编译器的第一步。
其任务是将用户编写的 HTML 模板字符串转换为抽象语法树(AST,Abstract Syntax Tree)。
这一过程由 @vue/compiler-core 提供核心逻辑,而各平台(浏览器、SSR、小程序等)可以通过自定义 parserOptions 来决定如何识别标签、命名空间和内建组件。

本文所展示的 parserOptions 即是 Vue 浏览器端编译器的解析配置


二、源码原理分析

import { Namespaces, NodeTypes, type ParserOptions } from '@vue/compiler-core'
import { isHTMLTag, isMathMLTag, isSVGTag, isVoidTag } from '@vue/shared'
import { TRANSITION, TRANSITION_GROUP } from './runtimeHelpers'
import { decodeHtmlBrowser } from './decodeHtmlBrowser'

export const parserOptions: ParserOptions = {
  parseMode: 'html',
  isVoidTag,
  isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag) || isMathMLTag(tag),
  isPreTag: tag => tag === 'pre',
  isIgnoreNewlineTag: tag => tag === 'pre' || tag === 'textarea',
  decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined,
  ...
}

(1) 模式与基础判断函数

  • parseMode: 'html'
    说明当前解析器的输入是 HTML 模板,而非 JSX 或其他 DSL。
  • isVoidTag
    判断一个标签是否是空标签(void element),如 <img>, <br>, <input> 等,这些标签不允许子节点。
  • isNativeTag
    通过 isHTMLTag / isSVGTag / isMathMLTag 判断标签是否为原生标签,防止自定义组件被误识别为 HTML。
  • isPreTagisIgnoreNewlineTag
    控制是否保留换行符。例如 <pre><textarea> 的内容应原样保留。
  • decodeEntities
    在浏览器环境中使用 decodeHtmlBrowser 进行 HTML 实体解码(如 &amp;&)。

(2) 内建组件识别逻辑

isBuiltInComponent: tag => {
  if (tag === 'Transition' || tag === 'transition') {
    return TRANSITION
  } else if (tag === 'TransitionGroup' || tag === 'transition-group') {
    return TRANSITION_GROUP
  }
},

说明:

Vue 中有两个特殊的内建组件:

  • <Transition>:单元素/组件的过渡动画;
  • <TransitionGroup>:多个元素的列表动画。

这里的函数返回 运行时标识符(runtime helper) ,由编译器注入至渲染函数中,用以连接模板编译结果与运行时逻辑。

💡 关键点
在编译阶段,Vue 会将 <Transition> 转换为一个特殊的 AST 节点,并通过 TRANSITION 常量关联到运行时的过渡逻辑。


(3) 命名空间解析逻辑 getNamespace

getNamespace(tag, parent, rootNamespace) {
  let ns = parent ? parent.ns : rootNamespace
  if (parent && ns === Namespaces.MATH_ML) {
    ...
  } else if (parent && ns === Namespaces.SVG) {
    ...
  }

  if (ns === Namespaces.HTML) {
    if (tag === 'svg') {
      return Namespaces.SVG
    }
    if (tag === 'math') {
      return Namespaces.MATH_ML
    }
  }
  return ns
},

功能说明:

Vue 解析模板时,会为每个节点维护一个命名空间:

  • Namespaces.HTML
  • Namespaces.SVG
  • Namespaces.MATH_ML

这些命名空间控制编译器如何处理节点与属性,例如:

  • SVG 元素的属性名区分大小写;
  • MathML 中的结构与 HTML 不同。

详细逻辑:

  1. 从父节点继承命名空间
    默认继承父节点的 ns

  2. 特殊处理 MathML

    • 如果父节点是 <annotation-xml> 且包含 encoding="text/html"encoding="application/xhtml+xml",则切换到 HTML 命名空间;
    • 若父节点为 mtextmimo 等数学标签,且当前标签非 mglyphmalignmark,也切换到 HTML 命名空间(因为这类内容可含普通 HTML)。
  3. 特殊处理 SVG

    • 当父节点是 <foreignObject><desc><title> 时,其内部内容属于 HTML 语义。
  4. HTML → 子节点切换

    • <svg> → 切入 SVG 命名空间;
    • <math> → 切入 MathML 命名空间。

三、机制对比:HTML / SVG / MathML 解析差异

特性 HTML SVG MathML
命名空间 默认 http://www.w3.org/2000/svg http://www.w3.org/1998/Math/MathML
属性区分大小写
标签嵌套规则 自由 严格 严格
空标签规则 存在 void 元素 无 void 概念 无 void 概念

Vue 的 getNamespace 正是为了解决这些语法差异,使模板在不同命名空间中被正确解析。


四、实践:在自定义编译器中使用

你可以基于这个配置创建一个 自定义 HTML 编译器

import { baseCompile } from '@vue/compiler-core'
import { parserOptions } from './parserOptions'

const template = `<svg><foreignObject><div>Hello</div></foreignObject></svg>`
const ast = baseCompile(template, { parserOptions }).ast

console.log(ast)

输出结果(简化版):

{
  type: 1,
  tag: 'svg',
  ns: Namespaces.SVG,
  children: [
    {
      tag: 'foreignObject',
      ns: Namespaces.SVG,
      children: [
        { tag: 'div', ns: Namespaces.HTML }
      ]
    }
  ]
}

说明:

  • <svg> → SVG 命名空间;
  • <foreignObject> → 仍属于 SVG;
  • <div> → 切换回 HTML 命名空间(由 getNamespace 控制)。

五、拓展与衍生思考

  1. 在 SSR 场景下

    • decodeEntities 可能需要服务端版本,如 decodeHtml(非 decodeHtmlBrowser)。
  2. 在小程序编译中

    • 可替换 isNativeTag 判断逻辑,以识别小程序原生组件(如 viewbutton 等)。
  3. 在 JSX 模式中

    • parseMode 可设为 'jsx',并采用不同的节点构建逻辑。

六、潜在问题与优化方向

  • 问题 1:性能开销
    getNamespace 在嵌套结构复杂时频繁调用,理论上可缓存部分计算结果。
  • 问题 2:跨平台一致性
    不同运行时环境(Web、Weex、Custom Renderer)需保证 parserOptions 的一致性,否则 AST 结构不兼容。
  • 问题 3:命名空间边界模糊
    部分浏览器行为(如 <math><svg> 混用)存在兼容性差异,Vue 的命名空间策略是权衡后的实现。

七、结语

本文深入解析了 Vue 编译器中 parserOptions 的源码设计与实现逻辑,从标签判断到命名空间规则,再到内建组件映射,展示了 Vue 编译系统在“语法一致性”与“平台兼容性”之间的平衡思路。

本文部分内容借助 AI 辅助生成,并由作者整理审核。

🌿 深度解析 Vue DOM 编译器模块源码:compile 与 parse 的构建逻辑

一、概念层:Vue DOM 编译器的职责

Vue 的编译器(@vue/compiler-dom)是模板到渲染函数的桥梁。
其核心职责是:

  • 解析模板字符串 → 生成抽象语法树(AST)。
  • 执行节点和指令的转换 → 将模板语法映射为虚拟节点创建代码。
  • 代码生成 → 输出可执行的渲染函数。

本篇代码展示了 Vue DOM 编译器的入口实现逻辑,即 compileparse 的封装。


二、原理层:核心结构概览

import {
  baseCompile,
  baseParse,
  type CodegenResult,
  type CompilerOptions,
  type RootNode,
  type DirectiveTransform,
  type NodeTransform,
  noopDirectiveTransform,
} from '@vue/compiler-core'

🔍 解析说明

  • baseCompile / baseParse:来自 @vue/compiler-core,是核心的模板编译与解析引擎。
  • CompilerOptions:编译时配置项(自定义解析器、指令、节点转换等)。
  • noopDirectiveTransform:空指令转换器,用于如 v-cloak 这种无需编译逻辑的指令。

核心思想@vue/compiler-dom 是在 compiler-core 的基础上扩展 DOM 特有语法的一个“包装层”。


三、对比层:DOM 专属的增强点

compiler-dom 相较于 compiler-core,在下列方面有所增强:

模块 功能增强 说明
parserOptions 增加 HTML 语义解析规则 适应浏览器 DOM 特性
transformStyle 处理内联样式 将 style 属性转为动态绑定表达式
transformVHtml / transformVText 处理 v-htmlv-text 注入对应渲染逻辑
transformModel 重写 v-model 实现 DOM 层双向绑定
transformOn 重写 v-on 添加事件代理与修饰符逻辑
transformShow 实现 v-show 指令 控制元素显示隐藏
stringifyStatic 静态节点字符串化 提升 SSR 渲染性能

四、实践层:源码逐步拆解

1️⃣ 组合节点与指令的转换器

export const DOMNodeTransforms: NodeTransform[] = [
  transformStyle,
  ...(__DEV__ ? [transformTransition, validateHtmlNesting] : []),
]

🧩 注释与解释:

  • transformStyle:基础样式节点转换。
  • transformTransition:开发环境下检测 <transition> 组件。
  • validateHtmlNesting:在开发环境中校验 HTML 标签嵌套合法性。

⚙️ 在生产环境中,这两个校验逻辑将被剔除,以优化编译性能。


2️⃣ 注册 DOM 指令转换器

export const DOMDirectiveTransforms: Record<string, DirectiveTransform> = {
  cloak: noopDirectiveTransform,
  html: transformVHtml,
  text: transformVText,
  model: transformModel,
  on: transformOn,
  show: transformShow,
}

💡 注释与解释:

  • v-cloak → 无需生成代码,直接忽略。
  • v-html / v-text → 分别生成 innerHTMLtextContent 赋值逻辑。
  • v-model / v-on → 重写核心指令,兼容浏览器事件系统。
  • v-show → 转换为动态显示控制代码。

📌 此处通过覆盖同名指令实现“DOM 定制版”的行为。


3️⃣ 编译器入口:compile

export function compile(
  src: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  return baseCompile(
    src,
    extend({}, parserOptions, options, {
      nodeTransforms: [
        ignoreSideEffectTags,
        ...DOMNodeTransforms,
        ...(options.nodeTransforms || []),
      ],
      directiveTransforms: extend(
        {},
        DOMDirectiveTransforms,
        options.directiveTransforms || {},
      ),
      transformHoist: __BROWSER__ ? null : stringifyStatic,
    }),
  )
}

🧱 拆解讲解:

步骤 功能 说明
extend({}, parserOptions, options, …) 合并配置 保留用户自定义 transform
ignoreSideEffectTags 忽略副作用标签 跳过 <script> / <style>
nodeTransforms 节点转换序列 统一挂载 DOM 相关 transform
directiveTransforms 指令转换序列 扩展 DOM 特有指令
transformHoist 提升优化控制 在 SSR 模式下启用静态节点字符串化

✳️ 实践逻辑:

compile 的本质是对 baseCompile 的再包装。它利用 extendcompiler-core 的能力“注入” DOM 规则,实现:

模板 → AST → DOM 转换规则注入 → 渲染函数代码。


4️⃣ 模板解析:parse

export function parse(template: string, options: ParserOptions = {}): RootNode {
  return baseParse(template, extend({}, parserOptions, options))
}

🧩 说明:

  • baseParse 执行基础的模板词法与语法分析。
  • parserOptions 负责 DOM 标签规则与属性判断。

📜 返回值:AST 根节点 RootNode,包含所有模板结构信息。


五、拓展层:编译生态与复用机制

Vue 编译器实际上由多个包协同完成:

模块 作用 依赖关系
@vue/compiler-core 通用 AST & 渲染生成逻辑 被所有编译器复用
@vue/compiler-dom 针对浏览器 DOM 的实现 依赖 core
@vue/compiler-ssr 服务端渲染优化版 共享 transform 列表
@vue/compiler-sfc 单文件组件(.vue)处理 调用 DOM 编译器

因此,compiler-dom 是整个 Vue 编译体系的“前端模板层”。


六、潜在问题与优化思考

问题点 说明 潜在优化方向
环境分支(__DEV__ / __BROWSER__ 不同构建目标逻辑差异大 可考虑使用动态注入插件简化
transform 体系耦合度高 各 transform 需严格顺序执行 未来可通过 pipeline 化机制改进
静态提升与字符串化策略复杂 SSR 与 CSR 差异明显 可引入统一的中间层优化策略

七、总结

这份源码是 Vue 编译器 DOM 层的桥梁实现,核心目标是将 通用编译框架DOM 特定逻辑 解耦。
通过灵活的 transform 注册机制,它为浏览器端渲染提供了高扩展性的编译管线。


📘 结语
本文详细解析了 Vue compiler-dom 模块的设计逻辑与源码结构,从概念到实现层层剖析其构建思想。
掌握这一部分,可以更深入理解 Vue 编译器的工作机制及其生态模块间的分工。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深度解析:isValidHTMLNesting —— HTML 嵌套合法性验证的设计与实现

一、概念层:功能与设计目标

在前端编译器或模板编译阶段,我们经常需要判断一个 HTML 标签是否可以合法地嵌套在另一个标签内
例如:

  • <ul><li></li></ul> 是合法的。
  • <p><div></div></p> 是不合法的。

Vue 编译器的 DOM 转换阶段就需要这类判断,以在模板编译时抛出清晰的语法错误。
本模块正是为了解决这个问题——在不依赖外部库的前提下,提供 isValidHTMLNesting(parent, child) 方法,判断一对标签的父子关系是否合法。


二、原理层:逻辑流程与判断优先级

核心函数 isValidHTMLNesting(parent, child) 的逻辑可以概括为如下优先级顺序:

export function isValidHTMLNesting(parent: string, child: string): boolean {
  if (parent === 'template') return true; // 特例1:<template> 可包任何元素

  if (parent in onlyValidChildren)
    return onlyValidChildren[parent].has(child); // 特例2:父节点有明确允许子节点集合

  if (child in onlyValidParents)
    return onlyValidParents[child].has(parent); // 特例3:子节点有明确唯一父节点集合

  if (parent in knownInvalidChildren)
    if (knownInvalidChildren[parent].has(child)) return false; // 否定1:父节点禁止特定子节点

  if (child in knownInvalidParents)
    if (knownInvalidParents[child].has(parent)) return false; // 否定2:子节点禁止特定父节点

  return true; // 其他情况默认合法
}

逻辑顺序解析:

  1. 特例优先

    • <template> 这种结构性标签可包含任意元素。
    • <table><thead> 等标签对结构有严格约束,因此优先匹配 “onlyValidChildren”。
  2. 否定规则覆盖

    • 若某父节点明确定义了“不允许”的子节点(例如 <p> 内禁止 <div>),立即判定非法。
    • 同理,如果某子节点不能出现在某父节点中(例如 <a> 不能嵌套 <a>),也直接否决。
  3. 默认放行

    • 若不在规则集合中,则认为合法,以保持宽容性与未来兼容。

三、对比层:与 W3C / React / Vue 规则的异同

框架 嵌套验证策略 特点
W3C HTML Spec 规范性最强,定义复杂且细粒度 精确但实现困难
React DOM Validator 仅警告级别,不中断编译 偏向开发提示
Vue Compiler DOM 编译时校验并抛出错误 更严格、更前置
本实现 (validate-html-nesting) 静态映射规则 + 特例处理 性能高、零依赖、适合编译期使用

Vue 官方在 @vue/compiler-dom 中采用的就是这种轻量策略。
它不追求 100% 的 HTML 规范覆盖,而是确保绝大多数错误嵌套能在编译期被捕获


四、实践层:主要数据结构与源码解构

1. onlyValidChildren —— “父节点白名单”

const onlyValidChildren: Record<string, Set<string>> = {
  head: new Set(['base','link','meta','title','style','script','template']),
  select: new Set(['optgroup','option','hr']),
  table: new Set(['caption','colgroup','tbody','tfoot','thead']),
  tr: new Set(['td','th']),
  ...
  script: new Set([]), // script不可包含子元素
}

设计目的:
有些 HTML 标签有明确结构规则(如 <table> 只能含 <tr>)。
这些父节点拥有唯一合法子节点集合。

解析:

  • head → 仅允许 <meta><title><script> 等。
  • script / style 等则设置为空集合 emptySet,禁止任何子元素。

2. onlyValidParents —— “子节点白名单”

const onlyValidParents: Record<string, Set<string>> = {
  td: new Set(['tr']),
  tr: new Set(['tbody', 'thead', 'tfoot']),
  th: new Set(['tr']),
  figcaption: new Set(['figure']),
  summary: new Set(['details']),
  ...
}

作用:
部分元素只能出现在指定父节点中,如:

  • <td> 必须位于 <tr>
  • <tr> 只能位于 <tbody><thead>
  • <figcaption> 只能在 <figure> 中。

3. knownInvalidChildren —— “父节点黑名单”

const knownInvalidChildren: Record<string, Set<string>> = {
  p: new Set(['div','section','table','ul', ...]),
  svg: new Set(['div','span','p','table', ...]),
}

语义说明:

  • <p> 是行内块级元素,不能直接包含块级元素;
  • <svg> 是独立命名空间,不应包含普通 HTML 标签。

4. knownInvalidParents —— “子节点黑名单”

const knownInvalidParents: Record<string, Set<string>> = {
  a: new Set(['a']),
  button: new Set(['button']),
  form: new Set(['form']),
  li: new Set(['li']),
  h1: headings,
  ...
}

意义:

  • <a> 不能嵌套 <a>
  • <button> 不能嵌套 <button>
  • 标题标签 <h1>~<h6> 不应互相嵌套。

五、拓展层:改进方向与应用场景

1. 改进方向

  • 命名空间支持:目前未处理 SVG/MathML 的复杂子层级。
  • 动态规则加载:可从外部 JSON 自动同步更新。
  • 编译器集成:在 Vue 模板 AST 分析阶段可直接调用,辅助报错。

2. 实际应用场景

  • Vue 编译器插件:在 transformElement 阶段校验嵌套。
  • HTML 静态分析工具:用于 CI 语法检查。
  • 模板语言解析器(如 Pug/Handlebars) :转换前验证嵌套结构。

六、潜在问题与注意事项

问题 说明
规则更新延迟 原始仓库 validate-html-nesting 更新时需手动同步
非标准标签支持有限 自定义组件或 Web Components 默认视为合法
错误上下文缺失 函数仅返回 true/false,不提供错误原因或修复建议

七、结语

isValidHTMLNesting 是一个轻量但关键的 HTML 校验模块,
它的设计哲学是——在不引入运行时依赖的前提下,静态定义最主要的合法性规则
这使它非常适合前端编译阶段或静态分析工具使用。

本文部分内容借助 AI 辅助生成,并由作者整理审核。

Vue DOM 编译错误系统解析:DOMErrorCodes 与 createDOMCompilerError

一、概念

在 Vue 3 的模板编译过程中,错误系统(Error System) 用于在编译模板为渲染函数时检测和报告各种潜在问题。
本文中的代码片段来自 @vue/compiler-dom 模块,主要定义了 DOM 层级特有的错误码错误信息映射、以及用于创建错误对象的 createDOMCompilerError 方法。
这些错误是针对浏览器环境(如 v-htmlv-textv-model)等指令使用不当时产生的专属错误。


二、原理解析

1. 错误对象结构

export interface DOMCompilerError extends CompilerError {
  code: DOMErrorCodes
}

解释:

  • DOMCompilerError 继承自核心编译器的 CompilerError
  • 其核心属性是 code,标识具体错误类型(枚举值)。

这保证了 DOM 模块的错误能与核心编译器保持统一结构,同时又能拓展自定义错误。


2. 错误创建函数

export function createDOMCompilerError(
  code: DOMErrorCodes,
  loc?: SourceLocation,
) {
  return createCompilerError(
    code,
    loc,
    __DEV__ || !__BROWSER__ ? DOMErrorMessages : undefined,
  ) as DOMCompilerError
}

逐步说明:

  1. 参数

    • code: 来自 DOMErrorCodes 的错误编号。
    • loc: 可选的源码位置信息,用于定位错误在模板中的位置。
  2. 逻辑

    • 调用核心函数 createCompilerError() 生成错误对象。
    • 在开发模式(__DEV__)或非浏览器环境中,传入 DOMErrorMessages 以附带可读信息。
  3. 返回类型

    • 返回强制转换为 DOMCompilerError,保证类型安全。

设计思想:
编译器错误系统通过环境变量动态切换错误描述:

  • 生产环境 → 只保留错误码(节省体积);
  • 开发环境 → 附带人类可读的错误提示信息。

3. DOMErrorCodes 枚举定义

export enum DOMErrorCodes {
  X_V_HTML_NO_EXPRESSION = 53,
  X_V_HTML_WITH_CHILDREN,
  X_V_TEXT_NO_EXPRESSION,
  X_V_TEXT_WITH_CHILDREN,
  X_V_MODEL_ON_INVALID_ELEMENT,
  X_V_MODEL_ARG_ON_ELEMENT,
  X_V_MODEL_ON_FILE_INPUT_ELEMENT,
  X_V_MODEL_UNNECESSARY_VALUE,
  X_V_SHOW_NO_EXPRESSION,
  X_TRANSITION_INVALID_CHILDREN,
  X_IGNORED_SIDE_EFFECT_TAG,
  __EXTEND_POINT__,
}

逐项解读:

  • X_V_HTML_NO_EXPRESSIONv-html 缺少表达式。
  • X_V_HTML_WITH_CHILDRENv-html 使用时仍存在子节点(将被覆盖)。
  • X_V_TEXT_NO_EXPRESSIONv-text 缺少表达式。
  • X_V_MODEL_ON_INVALID_ELEMENTv-model 用在非表单元素上。
  • X_V_MODEL_ON_FILE_INPUT_ELEMENTv-model 不能绑定文件输入框。
  • X_TRANSITION_INVALID_CHILDREN<Transition> 内子节点数不正确。
  • X_IGNORED_SIDE_EFFECT_TAG<script> / <style> 等副作用标签被忽略。

关键点:
枚举起始值 53 来自 ErrorCodes.__EXTEND_POINT__,保证与 @vue/compiler-core 不冲突。
每个错误码都是自增生成的唯一整数值。


4. 枚举同步检查机制

if (__TEST__) {
  if (DOMErrorCodes.X_V_HTML_NO_EXPRESSION < ErrorCodes.__EXTEND_POINT__) {
    throw new Error(
      `DOMErrorCodes need to be updated to ${ErrorCodes.__EXTEND_POINT__}...`
    )
  }
}

功能:

  • 在单元测试模式下(__TEST__),确保 DOM 错误码起始位置不与核心错误码冲突。
  • 若版本不同步,则自动抛出异常提醒开发者更新常量。

⚙️ 设计亮点:
此校验机制确保了编译器多模块协作时的错误码空间隔离,防止编号重叠导致错误信息错乱。


5. 错误信息字典

export const DOMErrorMessages: { [code: number]: string } = {
  [DOMErrorCodes.X_V_HTML_NO_EXPRESSION]: `v-html is missing expression.`,
  [DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT]: 
    `v-model cannot be used on file inputs since they are read-only...`,
  ...
}

说明:
这是一个从错误码到提示语的映射表。
编译器在抛错时可以通过错误码查表,获得直观的提示文字。

典型输出(开发模式):

[Vue compiler error] v-model cannot be used on file inputs since they are read-only.

三、对比分析:与核心 ErrorCodes 的关系

模块 错误码前缀 作用域 典型错误
@vue/compiler-core ErrorCodes 通用模板语法 缺少表达式、无效指令
@vue/compiler-dom DOMErrorCodes 浏览器专用 v-html / v-model 错误

总结:
DOMErrorCodes 是对核心错误系统的扩展层,负责浏览器特定的语义验证。
它通过 __EXTEND_POINT__ 与核心模块形成一种“版本对齐机制”。


四、实践示例

假设我们在模板中误用了 v-model

<div v-model="data"></div>

在编译阶段将触发:

createDOMCompilerError(DOMErrorCodes.X_V_MODEL_ON_INVALID_ELEMENT, loc)

输出(开发环境):

[v-model can only be used on <input>, <textarea> and <select> elements.]

过程说明:

  1. 编译器检测到 div 上绑定 v-model
  2. 触发相应错误码;
  3. createDOMCompilerError 构造错误;
  4. 编译器捕获并输出至控制台。

五、拓展与思考

  • 扩展性设计
    __EXTEND_POINT__ 机制使未来可以安全添加新错误类型而不冲突。
  • 环境感知机制
    借助 __DEV____BROWSER__,Vue 能在不同构建目标下动态切换错误输出粒度。
  • 可测试性
    单元测试下自动检测错误码重叠,强化工程一致性。
  • 国际化潜力
    未来可在 DOMErrorMessages 上层封装语言包系统以支持多语言错误提示。

六、潜在问题与优化空间

  1. 手动同步风险
    若核心库 ErrorCodes.__EXTEND_POINT__ 更新但 DOM 模块未同步,测试才会检测到,属于事后发现型问题
  2. 错误信息冗余
    大量硬编码的错误字符串可能在不同语言版本中造成维护负担。
  3. 缺乏上下文恢复机制
    仅报告错误而不提供“修复建议”或 AST 位置恢复,可能影响 IDE 集成体验。

七、总结

Vue 的 DOMErrorCodescreateDOMCompilerError 模块展示了其编译系统的模块化设计哲学
通过清晰的错误码空间划分、环境自适应输出、以及开发测试保护机制,实现了高可维护性与可扩展性。
这套机制为 Vue 的模板编译器在不同运行时环境下提供了稳定、清晰的错误反馈体系。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深度解析:decodeHtmlBrowser —— 浏览器端 HTML 解码函数设计

一、背景与概念

在前端开发中,我们经常需要处理HTML 实体(HTML Entities) 。例如服务器返回的内容可能包含:

&lt;div&gt;Hello&lt;/div&gt;

这时前端需要将这些转义符还原成真实字符 <div>Hello</div>,以便正确展示或处理。
为此,浏览器内置的 DOMParser 或元素解析能力就能帮助我们实现HTML 解码

decodeHtmlBrowser 就是一个利用浏览器 DOM 的小型解码工具函数,它可以在浏览器端安全、高效地将被转义的 HTML 还原。


二、源码与逐行解析

/* eslint-disable no-restricted-globals */

let decoder: HTMLDivElement

export function decodeHtmlBrowser(raw: string, asAttr = false): string {
  if (!decoder) {
    decoder = document.createElement('div')
  }
  if (asAttr) {
    decoder.innerHTML = `<div foo="${raw.replace(/"/g, '&quot;')}">`
    return decoder.children[0].getAttribute('foo')!
  } else {
    decoder.innerHTML = raw
    return decoder.textContent!
  }
}

🔹 第 1 行:/* eslint-disable no-restricted-globals */

关闭 ESLint 规则 no-restricted-globals
该规则通常用于防止全局变量污染(如 eventnameself 等),此处禁用是为了确保使用 document 不被警告。


🔹 第 2 行:let decoder: HTMLDivElement

定义一个全局缓存变量,用于保存一个 <div> 元素。
作用:避免每次调用函数都重新创建 DOM 节点,提高性能。


🔹 第 4 行:函数定义

export function decodeHtmlBrowser(raw: string, asAttr = false): string
  • raw: 原始字符串(可能包含 HTML 实体)
  • asAttr: 是否以属性上下文解析,默认为 false

🔹 第 5–7 行:DOM 缓存初始化

if (!decoder) {
  decoder = document.createElement('div')
}

首次调用时创建一个 <div> 节点,之后多次复用。


🔹 第 8–11 行:属性模式(asAttr = true)

if (asAttr) {
  decoder.innerHTML = `<div foo="${raw.replace(/"/g, '&quot;')}">`
  return decoder.children[0].getAttribute('foo')!
}

解析逻辑:

  1. 先替换掉 ",防止破坏 HTML 结构。

    raw.replace(/"/g, '&quot;')
    
  2. 通过设置 innerHTML,让浏览器解析 HTML 实体。

  3. 再读取子节点第一个元素的 foo 属性,得到浏览器自动解码后的结果。

示例:

decodeHtmlBrowser('&lt;div&gt;x&lt;/div&gt;', true)
// => "<div>x</div>"

🔹 第 12–14 行:文本模式(asAttr = false)

decoder.innerHTML = raw
return decoder.textContent!

此模式下直接使用 <div>textContent 读取解码结果。

示例:

decodeHtmlBrowser('&amp;lt;Hello&amp;gt;')
// => "&lt;Hello&gt;"
decodeHtmlBrowser('&lt;Hello&gt;')
// => "<Hello>"

三、原理分析

模式 使用 DOM 属性 解码范围 典型场景
文本模式 textContent 通用 HTML 文本 用户输入、HTML 内容
属性模式 getAttribute 属性上下文转义 HTML 属性内的转义内容

本质上,浏览器的 DOM 解析器在解析 innerHTML 时会自动将实体符号转回字符,因此这段代码就是巧妙地利用浏览器的解析行为完成解码。


四、与其他方案对比

方法 原理 优点 缺点
decodeHtmlBrowser 利用 DOM 自动解析 兼容性好、无需外部库 需在浏览器环境
DOMParser 创建解析文档 更安全(不污染现有 DOM) 代码稍繁琐
he(npm 包) JS 实现 HTML 实体表 支持全实体 文件体积较大

✅ 实际开发中,如果只在浏览器端运行,该函数足够轻量且性能良好。


五、实践示例

示例 1:解码普通文本

decodeHtmlBrowser('&lt;span&gt;Hi&lt;/span&gt;')
// 输出:"<span>Hi</span>"

示例 2:解码属性值

decodeHtmlBrowser('Tom &amp; Jerry', true)
// 输出:"Tom & Jerry"

示例 3:性能优化

由于 decoder 是全局复用的,连续调用不会重复创建 DOM 节点,非常适合在循环中解码大量字符串。


六、拓展思考

可以进一步封装为通用 HTML 解码模块,例如:

export function decode(raw: string, mode: 'text' | 'attr' = 'text') {
  return decodeHtmlBrowser(raw, mode === 'attr')
}

或添加 SSR 支持(Node 环境下使用第三方库 he)。


七、潜在问题与安全性

  1. XSS 风险
    raw 来自用户输入,直接注入到 innerHTML 可能带来风险(尤其在属性模式下)。
  2. Node 环境不可用
    函数依赖 document,只能在浏览器执行。
  3. 多线程冲突
    在并发场景(如 Web Worker)中,全局 decoder 不安全。

✅ 建议在浏览器端、受控输入场景中使用。


八、总结

decodeHtmlBrowser 是一个利用浏览器解析器进行 HTML 实体解码的小巧函数。
它通过创建一次性 DOM 节点,实现了兼顾性能与简洁的解码逻辑,在前端框架源码(如 Vue、React DOM 工具层)中也可见类似实现。

本质思想:让浏览器帮我们做浏览器最擅长的事——解析 HTML。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深度解析:Vue 模板编译器中的 transformVText 实现原理

一、背景概念

在 Vue 模板编译阶段,指令(如 v-ifv-forv-text 等)会被转换成相应的 JavaScript 渲染代码。
v-text 是一个较为简单的指令,它的作用是在渲染时设置元素的 textContent 属性

举个例子:

<span v-text="message"></span>

最终会被编译为类似:

_textContent: _toDisplayString(message)

而这一编译行为,正是由编译器内部的 指令转换器(DirectiveTransform) 来完成的,
transformVText 就是处理 v-text 指令的核心逻辑。


二、原理解析

Vue 在编译模板时,会为每种指令注册一个 DirectiveTransform
该函数接受三个参数:

  • dir: 当前指令对象(包含表达式、参数、修饰符等信息)
  • node: 指令所在的节点
  • context: 编译上下文(提供错误处理、工具方法、helper 引用等)

返回值通常是一个对象 { props }
代表该指令将会生成哪些渲染属性(例如 textContentinnerHTML 等)。


三、源码逐行拆解与注释

以下是完整源码及详细注释:

import {
  type DirectiveTransform,
  TO_DISPLAY_STRING,
  createCallExpression,
  createObjectProperty,
  createSimpleExpression,
  getConstantType,
} from '@vue/compiler-core'
import { DOMErrorCodes, createDOMCompilerError } from '../errors'

说明:

  • 导入 DirectiveTransform 类型用于定义函数签名;
  • TO_DISPLAY_STRING 是 Vue 的内部 helper,用于将任意值安全地转换为字符串(即 _toDisplayString());
  • create* 系列函数用于创建 AST 节点(编译阶段的抽象语法树节点);
  • DOMErrorCodescreateDOMCompilerError 用于在编译错误时抛出友好的错误提示。

核心函数定义

export const transformVText: DirectiveTransform = (dir, node, context) => {
  const { exp, loc } = dir
  • dir 是当前的指令描述对象;
  • exp 表示 v-text 的绑定表达式(例如 message);
  • loc 是位置信息,用于报错时定位。

错误检查一:缺少表达式

  if (!exp) {
    context.onError(
      createDOMCompilerError(DOMErrorCodes.X_V_TEXT_NO_EXPRESSION, loc),
    )
  }

v-text 必须有表达式,否则报错:

<div v-text></div> <!-- ❌ 错误:缺少表达式 -->

错误检查二:存在子节点

  if (node.children.length) {
    context.onError(
      createDOMCompilerError(DOMErrorCodes.X_V_TEXT_WITH_CHILDREN, loc),
    )
    node.children.length = 0
  }

v-text 会覆盖整个元素的文本内容,因此不允许与子节点共存
例如:

<div v-text="msg">Hello</div> <!-- ❌ 同时存在子节点 -->

编译器会清空子节点,以保证 textContent 是唯一内容。


构造编译结果

  return {
    props: [
      createObjectProperty(
        createSimpleExpression(`textContent`, true),

创建一个对象属性:

{ textContent: <表达式> }

动态或常量表达式判断

        exp
          ? getConstantType(exp, context) > 0
            ? exp
            : createCallExpression(
                context.helperString(TO_DISPLAY_STRING),
                [exp],
                loc,
              )
          : createSimpleExpression('', true),

这里是整个函数的逻辑核心

  • 如果有表达式 exp

    • 判断其是否为常量(getConstantType > 0):

      • ✅ 是常量 → 直接使用;
      • ❌ 否则 → 包装成 _toDisplayString(exp)
  • 如果没有表达式 → 使用空字符串。

例如:

<span v-text="'Hello'"></span>  // 常量 → textContent: 'Hello'
<span v-text="msg"></span>      // 动态 → textContent: _toDisplayString(msg)

尾部返回

      ),
    ],
  }
}

最终返回一个 { props: [...] } 对象,供上层编译逻辑整合到 codegenNode 中,
最终生成渲染函数中对 textContent 的赋值语句。


四、与其它指令的对比

指令 作用 编译生成属性 支持表达式类型
v-text 设置 textContent { textContent: exp } 表达式
v-html 设置 innerHTML { innerHTML: exp } 表达式
v-bind 绑定任意属性 { attr: exp } 表达式
v-on 绑定事件 { onXxx: handler } 函数或表达式

可见,v-text 的核心在于确保内容安全且简单替换,而不像 v-html 那样存在 XSS 风险。


五、实践意义

该转换器的存在使 Vue 模板编译具备以下优点:

  1. AST 层清晰职责分离:模板转换与渲染生成解耦;
  2. 运行时性能优化:常量表达式可直接内联;
  3. 错误捕获机制:在编译阶段即可发现模板误用;
  4. 统一字符串转义逻辑:通过 _toDisplayString() 确保渲染输出安全。

六、拓展与潜在问题

🔹 拓展方向

  • 你可以基于该模式自定义指令转换器,例如:

    • v-markdown → 自动解析 Markdown 内容;
    • v-textsafe → 输出前进行转义与敏感词过滤。

🔹 潜在问题

  • 若用户在模板中误用 v-text 并手动修改 DOM,可能引发内容覆盖;
  • 对性能要求高的场景,应尽量减少 _toDisplayString() 的调用次数;
  • 过度依赖编译时检查,可能忽略运行时动态表达式边界问题。

七、总结

transformVText 是 Vue 编译器中处理 v-text 指令的关键逻辑。
它体现了 Vue 编译体系的核心特征:静态分析、错误预防与安全渲染
通过这一机制,Vue 能在编译阶段就生成高效、安全的渲染函数。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

深度解析 Vue 编译器中的 transformShow:v-show 指令的编译原理

一、概念背景

v-show 是 Vue 模板系统中的一个常见指令,用于基于布尔条件控制元素的显示状态。与 v-if 不同,v-show 并不会销毁或重新创建 DOM 元素,而是通过动态修改元素的 display 样式属性来实现显隐切换。

在 Vue 的编译器阶段,每一个模板指令(如 v-ifv-forv-onv-bindv-show 等)都会被转换(transform)成对应的运行时代码。本文聚焦于 transformShow 这个编译阶段的指令转换函数。


二、源码解读

下面是 transformShow 的源码(来自 Vue 的 DOM 编译模块):

import type { DirectiveTransform } from '@vue/compiler-core'
import { DOMErrorCodes, createDOMCompilerError } from '../errors'
import { V_SHOW } from '../runtimeHelpers'

export const transformShow: DirectiveTransform = (dir, node, context) => {
  const { exp, loc } = dir
  if (!exp) {
    context.onError(
      createDOMCompilerError(DOMErrorCodes.X_V_SHOW_NO_EXPRESSION, loc),
    )
  }

  return {
    props: [],
    needRuntime: context.helper(V_SHOW),
  }
}

三、逐行解析与原理讲解

1. 引入依赖

import type { DirectiveTransform } from '@vue/compiler-core'
import { DOMErrorCodes, createDOMCompilerError } from '../errors'
import { V_SHOW } from '../runtimeHelpers'
  • DirectiveTransform:类型定义,用于声明一个“指令转换函数”的标准结构。
    它的签名通常是 (dir, node, context) => TransformResult
  • createDOMCompilerError:用于在编译阶段报告错误,比如指令缺少必要参数时。
  • V_SHOW:指向一个运行时帮助函数(runtime helper),即真正执行 v-show 逻辑的部分。

注释说明:
Vue 在编译模板时,会将指令编译为渲染函数调用。在运行时阶段,V_SHOW 对应的函数(位于 runtime-dom)负责实际地更新元素的显示状态。


2. 定义指令转换函数

export const transformShow: DirectiveTransform = (dir, node, context) => {

这段代码定义了一个指令转换器函数。它接收三个参数:

  • dir:当前指令节点对象,包含 nameexp(表达式)、modifiersloc(源码位置信息)等;
  • node:AST 节点(如一个 <div><button> 元素);
  • context:编译上下文,提供错误处理、运行时帮助注册等工具。

3. 取出指令表达式

const { exp, loc } = dir
  • expv-show 后面的表达式,如 v-show="isVisible"
  • loc:源代码位置,用于在错误提示中提供文件行号与列号。

4. 错误检查逻辑

if (!exp) {
  context.onError(
    createDOMCompilerError(DOMErrorCodes.X_V_SHOW_NO_EXPRESSION, loc),
  )
}

如果用户写了一个不完整的指令,比如:

<div v-show></div>

则没有提供表达式。此时编译器会调用 context.onError() 触发一个编译错误:

错误信息示例:
[Vue compiler]: v-show is missing expression at line 10:5

这保证了模板语法的正确性,防止运行时报错。


5. 返回转换结果

return {
  props: [],
  needRuntime: context.helper(V_SHOW),
}

这一步是关键。编译器最终需要返回一个结果对象,告诉生成器:

  • props: []
    说明 v-show 不会生成任何静态属性,而是完全交由运行时控制。
  • needRuntime: context.helper(V_SHOW)
    表示该指令在运行时需要 V_SHOW 这个辅助函数。

运行时对应逻辑(位于 runtime-dom):

export const vShow = {
  beforeMount(el, { value }) {
    el.style.display = value ? '' : 'none'
  },
  updated(el, { value, oldValue }) {
    if (value !== oldValue) {
      el.style.display = value ? '' : 'none'
    }
  }
}

编译器阶段只标记“需要此运行时函数”,而不参与实现显示逻辑。


四、与其他指令的对比

指令 是否生成 props 是否需要 runtime helper 行为特征
v-if ✅ 是 ❌ 否(直接编译成条件分支) 通过条件 AST 控制渲染结构
v-on ✅ 是 ✅ 是 绑定事件监听器
v-bind ✅ 是 ❌ 否 绑定动态属性
v-show ❌ 否 ✅ 是 (V_SHOW) 通过样式控制显隐

可以看出,v-showv-if 的根本区别在于运行时行为v-show 属于“渲染后控制”,而非“结构性编译控制”。


五、实践示例:编译结果分析

示例模板:

<div v-show="isVisible"></div>

编译后的伪代码(简化形式):

import { vShow as _vShow } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", {
    directives: [[_vShow, _ctx.isVisible]]
  }))
}

可以看到,_vShow 被注册为运行时指令,并应用于元素的指令数组中。
编译器只是告诉生成器“需要 _vShow”,而不关心具体实现。


六、拓展与思考

1. 为什么不在编译期直接处理?

v-show 的逻辑依赖于 运行时状态(如响应式数据) 。编译时无法确定 isVisible 的值,因此只能延迟到运行时由指令处理。

2. 为什么返回空 props

v-show 不直接修改节点属性,而是通过运行时访问 el.style。因此,编译器无需生成静态绑定。

3. 优化方向

在 SSR 场景下,v-show 可优化为在初始渲染时直接添加 display: none,避免首屏闪烁,这部分由 SSR 编译器自动完成。


七、潜在问题与注意事项

  1. 性能影响
    v-show 在 DOM 中保留元素,因此频繁切换时比 v-if 更高效,但首次渲染时会渲染所有元素。
  2. 样式干扰
    如果手动操作元素的 display 属性,可能与 v-show 的逻辑冲突。
  3. 过渡动画
    v-show 可与 transition 一起使用,但动画实现依赖于 CSS display 切换。

八、总结

transformShow 是 Vue 编译器中极简却关键的一环。
它的职责仅是:

  1. 校验语法合法性;
  2. 注册运行时指令依赖;
  3. 将逻辑委托给运行时的 vShow 实现。

这种编译器-运行时分层设计,体现了 Vue 体系中“轻编译、强运行”的设计哲学。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

❌