阅读视图

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

🌐 《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的坑?欢迎在评论区分享你的经历,我们一起交流进步!

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 辅助生成,并由作者整理审核。

看了下昨日泄露的苹果 App Store 源码……

新闻

昨日苹果 App Store 前端源码泄露,因其生产环境忘记关闭 Sourcemap,被用户下载了源码,上传到 Github。

仓库地址:github.com/rxliuli/app…

目前已经 Fork 和 Star 超 5k:

如果你想要第一时间知道前端资讯,欢迎关注公众号:冴羽

用户如何抓取的源码?

用户 rxliuli 使用 Chrome 插件 Save All Resources 将代码下载了下来。

插件地址为:chromewebstore.google.com/detail/save…

下次你也可以打包下载源码了~

如何看待源码泄露?

其实前端源码泄露对业务本身并没有什么影响,因为前端代码无论是否压缩还是混淆,最终都需传输到浏览器才能运行,本身就具有 “暴露” 属性,SourceMap 只是让代码更易读,更容易调试。

尽管如此,依然不建议在生产环境开启 SourceMap,对普通用户无益,且存在轻微性能开销和源代码暴露的安全风险。

我大致看了下代码,并没有什么密钥之类的信息,所以干点坏事之类的就不用想了。真正有价值的核心代码比如推荐逻辑还是在服务端。

代码使用 Svelte?

我万万没想到,项目使用的是 Svelte。

Svelte 我自然是很熟的,毕竟我翻译过 Svelte 官网:svelte.yayujs.com/

还写了一本掘金小册《Svelte 开发指南》:s.juejin.cn/ds/QNzfZ4eq…

想一想,使用 Svelte 也在情理之中。

因为 Svelte 就非常适合处理这种页面相对简单、业务逻辑并不复杂的页面。

在实现上 ,与其说 Svelte 是框架,不如说 Svelte 是一个编译器。 它会在构建时就会将代码编译为高效的 JavaScript 代码,因此能够实现高性能的 Web 应用。

Svelte 的核心优势在于:

  • 轻量级:核心库只有 3 KB,非常适合开发轻量级项目
  • 高性能:构建时优化,而且不使用虚拟 DOM,减少了内存占用和开销,性能更高
  • 易上手:学习曲线小,入门门槛低,语法简洁易懂

简而言之,Svelte 非常适合构建轻量级 Web 项目,也是本人做个人项目的首选技术栈。

以后大家如果要做相对简单的项目,又有性能上的追求(比如 KPI),那就可以考虑使用 Svelte。

用它作为示例学 Svelte ?

我看了下代码,项目代码还是 Svelte 4,而 Svelte 已经到 5 了,Svelte 4 和 5 不论是底层架构还是基础语法都发生了很大的变化,其变化的剧烈程度类似于 Next.js 12 升 Next.js 13,所以想通过这个项目学习 Svelte 就不用想了,都是些过时的语法了,不如直接学 Svelte 5。

深入解析 Vue 3 编译器中的 transformOn:事件指令的编译机制

在 Vue 的编译阶段,v-on 指令(即 @click@keydown 等事件绑定)并不是简单地原样输出,而是经过编译器的语法树(AST)转换,生成高效的运行时代码。本文将深入解析 Vue 3 源码中的 transformOn 模块,了解它如何处理事件修饰符与动态事件绑定。


一、概念

在 Vue 中,v-on 指令不仅用于绑定事件,还支持一系列修饰符,例如:

<button @click.stop.prevent="onClick">Click Me</button>

这些修饰符会改变事件监听行为,比如:

  • .stop → 调用 event.stopPropagation()
  • .prevent → 调用 event.preventDefault()
  • .once → 只触发一次
  • .capture → 捕获阶段触发

编译器必须将这些声明式修饰符转化为等价的 JavaScript 调用逻辑。
而这正是 transformOn 的职责所在。


二、原理

transformOn 是一个 指令转换器(DirectiveTransform) ,作用于 v-on 相关指令。它主要分为三个阶段:

  1. 基础转换:调用 baseTransform(基础事件转换函数),生成初步的 key(事件名)与 handlerExp(事件处理函数表达式)。

  2. 修饰符分类与处理:通过 resolveModifiers 将所有修饰符分类为:

    • eventOptionModifiers → 事件选项(once, passive, capture)
    • nonKeyModifiers → 非键盘类运行时修饰符(stop, prevent, self, ctrl...)
    • keyModifiers → 键盘事件修饰符(enter, esc, left, right...)
  3. 包装与修正:根据分类结果生成最终的 createObjectProperty(key, handlerExp) 对象。


三、代码拆解与注释

下面逐段分析 transformOn.ts 中的关键实现。


1️⃣ 修饰符类型定义

const isEventOptionModifier = makeMap(`passive,once,capture`)
const isNonKeyModifier = makeMap(
  `stop,prevent,self,ctrl,shift,alt,meta,exact,middle`,
)
const maybeKeyModifier = makeMap('left,right')
const isKeyboardEvent = makeMap(`onkeyup,onkeydown,onkeypress`)

解释:

  • makeMap 用于创建一个哈希表映射,提高修饰符查找效率。

  • Vue 将修饰符分为三类:

    • 事件选项修饰符:直接影响 addEventListener
    • 非键盘修饰符:用于通用事件过滤。
    • 键盘相关修饰符:仅在键盘事件中起作用。

2️⃣ 修饰符分类函数 resolveModifiers

const resolveModifiers = (key, modifiers, context, loc) => {
  const keyModifiers = []
  const nonKeyModifiers = []
  const eventOptionModifiers = []

  for (let i = 0; i < modifiers.length; i++) {
    const modifier = modifiers[i].content

    if (isEventOptionModifier(modifier)) {
      eventOptionModifiers.push(modifier)
    } else if (maybeKeyModifier(modifier)) {
      if (isStaticExp(key)) {
        if (isKeyboardEvent(key.content.toLowerCase())) {
          keyModifiers.push(modifier)
        } else {
          nonKeyModifiers.push(modifier)
        }
      } else {
        keyModifiers.push(modifier)
        nonKeyModifiers.push(modifier)
      }
    } else {
      if (isNonKeyModifier(modifier)) {
        nonKeyModifiers.push(modifier)
      } else {
        keyModifiers.push(modifier)
      }
    }
  }

  return { keyModifiers, nonKeyModifiers, eventOptionModifiers }
}

解释与逻辑注释:

  1. 遍历每个修饰符;

  2. 判断其所属类别:

    • 若是 passive/once/capture → 加入 eventOptionModifiers
    • 若可能是键或鼠标方向(如 left/right),则进一步判断事件名;
    • 其他修饰符通过 isNonKeyModifier 判断是否属于通用行为。
  3. 返回三类结果,供后续调用阶段使用。

这一函数的作用相当于为“修饰符分流”,为后续包装提供信息。


3️⃣ 事件名标准化函数 transformClick

const transformClick = (key: ExpressionNode, event: string) => {
  const isStaticClick =
    isStaticExp(key) && key.content.toLowerCase() === 'onclick'
  return isStaticClick
    ? createSimpleExpression(event, true)
    : key.type !== NodeTypes.SIMPLE_EXPRESSION
      ? createCompoundExpression([
          `(`,
          key,
          `) === "onClick" ? "${event}" : (`,
          key,
          `)`,
        ])
      : key
}

功能:

  • .right.middle 点击事件转换为等价事件:

    • @click.rightonContextmenu
    • @click.middleonMouseup
  • 若事件是动态绑定,则构造条件表达式以在运行时判断。


4️⃣ 主体函数 transformOn

export const transformOn: DirectiveTransform = (dir, node, context) => {
  return baseTransform(dir, node, context, baseResult => {
    const { modifiers } = dir
    if (!modifiers.length) return baseResult

    let { key, value: handlerExp } = baseResult.props[0]
    const { keyModifiers, nonKeyModifiers, eventOptionModifiers } =
      resolveModifiers(key, modifiers, context, dir.loc)

    if (nonKeyModifiers.includes('right')) {
      key = transformClick(key, `onContextmenu`)
    }
    if (nonKeyModifiers.includes('middle')) {
      key = transformClick(key, `onMouseup`)
    }

    if (nonKeyModifiers.length) {
      handlerExp = createCallExpression(context.helper(V_ON_WITH_MODIFIERS), [        handlerExp,        JSON.stringify(nonKeyModifiers),      ])
    }

    if (
      keyModifiers.length &&
      (!isStaticExp(key) || isKeyboardEvent(key.content.toLowerCase()))
    ) {
      handlerExp = createCallExpression(context.helper(V_ON_WITH_KEYS), [        handlerExp,        JSON.stringify(keyModifiers),      ])
    }

    if (eventOptionModifiers.length) {
      const modifierPostfix = eventOptionModifiers.map(capitalize).join('')
      key = isStaticExp(key)
        ? createSimpleExpression(`${key.content}${modifierPostfix}`, true)
        : createCompoundExpression([`(`, key, `) + "${modifierPostfix}"`])
    }

    return { props: [createObjectProperty(key, handlerExp)] }
  })
}

🔍 逐步解读:

  1. 基础转换调用
    通过 baseTransform 获取事件名与处理函数表达式。

  2. 分类解析修饰符
    调用 resolveModifiers 返回三类修饰符集合。

  3. 修饰符应用顺序

    • .right.middle → 改写事件名;
    • 非键盘修饰符 → 包装 V_ON_WITH_MODIFIERS
    • 键盘修饰符 → 包装 V_ON_WITH_KEYS
    • 事件选项修饰符 → 改写事件名后缀(如 onClickOnce)。
  4. 最终返回结构

    return {
      props: [createObjectProperty(key, handlerExp)]
    }
    

    生成 AST 节点形式的属性键值对,用于后续代码生成阶段(Codegen)。


四、实践示例

Vue 模板

<button @click.stop.once="submitForm">Submit</button>

编译后伪代码(简化)

{
  onClickOnce: _withModifiers(submitForm, ["stop"])
}

此处 _withModifiers_withKeys 均由运行时辅助函数实现。


五、拓展:运行时辅助函数

在运行时阶段:

  • V_ON_WITH_MODIFIERS_withModifiers(fn, ["stop", "prevent"])
  • V_ON_WITH_KEYS_withKeys(fn, ["enter", "esc"])

它们会返回一个新函数,在事件触发时根据修饰符自动调用 event.stopPropagation() 等操作。
这实现了 “声明式语法 → 运行时行为” 的无缝衔接。


六、潜在问题与优化方向

  1. 修饰符冲突

    • 某些修饰符组合(如 .exact.ctrl)在动态事件下的行为可能难以预测。
  2. 动态事件名

    • 当事件名不是静态字符串(例如 @[eventName]="fn")时,编译时难以推断事件类型,需要运行时判断。
  3. 性能考虑

    • 每个 _withModifiers 包装都会创建新的函数对象;在大规模动态列表中可能增加内存消耗。
  4. 代码生成阶段的优化

    • 可通过静态分析提前合并部分修饰符逻辑,减少运行时代码体积。

七、总结

transformOn 是 Vue 编译器中极具代表性的模块之一:

  • 它展现了 Vue 编译期指令重写 的设计哲学;
  • 将模板语法中的声明式修饰符,转化为最小化的运行时代码;
  • 通过多层函数封装,实现灵活而一致的事件行为。

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

Vue 模板编译器中的 transformModel:v-model 指令的编译秘密

v-model 是 Vue 中最具代表性的双向绑定语法糖,它在运行时能自动管理表单输入与数据之间的同步。而在编译阶段,Vue 的模板编译器(@vue/compiler-dom)通过 transformModel 函数将 v-model 转换为运行时可识别的指令表达式。

本文我们将深入剖析源码:

import {
  type DirectiveTransform,
  ElementTypes,
  NodeTypes,
  transformModel as baseTransform,
  findDir,
  findProp,
  hasDynamicKeyVBind,
  isStaticArgOf,
} from '@vue/compiler-core'
import { DOMErrorCodes, createDOMCompilerError } from '../errors'
import {
  V_MODEL_CHECKBOX,
  V_MODEL_DYNAMIC,
  V_MODEL_RADIO,
  V_MODEL_SELECT,
  V_MODEL_TEXT,
} from '../runtimeHelpers'

一、概念:transformModel 的角色定位

在 Vue 编译过程中,指令(如 v-modelv-htmlv-bind)都会被编译器的 transform 阶段 转换成适合运行时的结构。
transformModel 是 DOM 编译器中专门处理 v-model 的指令转换函数,目标是:

  1. 判断绑定的元素类型(如 <input><select>)。
  2. 检查错误用法(如 v-model 绑定到文件输入)。
  3. 注入运行时辅助函数(如 V_MODEL_TEXTV_MODEL_CHECKBOX 等)。

二、原理:编译时如何决定不同的绑定逻辑

1️⃣ 调用基础转换逻辑

const baseResult = baseTransform(dir, node, context)
  • 这里调用了核心模块 @vue/compiler-core 的通用版本 transformModel
  • 它会生成一个基础结果对象 { props, needRuntime },为后续 DOM 特有的逻辑扩展打底。

2️⃣ 组件与普通元素的区分

if (!baseResult.props.length || node.tagType === ElementTypes.COMPONENT) {
  return baseResult
}

解释:

  • 如果 v-model 是在组件上(例如 <MyInput v-model="x" />),编译器不会做额外 DOM 层级转换,只保留基础属性。
  • 普通元素则继续深入检查类型。

3️⃣ 检查非法参数使用

if (dir.arg) {
  context.onError(
    createDOMCompilerError(
      DOMErrorCodes.X_V_MODEL_ARG_ON_ELEMENT,
      dir.arg.loc,
    ),
  )
}

v-model:foo="x" 这种语法仅对组件有效,对原生元素无意义。


4️⃣ 检查重复绑定 value

function checkDuplicatedValue() {
  const value = findDir(node, 'bind')
  if (value && isStaticArgOf(value.arg, 'value')) {
    context.onError(
      createDOMCompilerError(
        DOMErrorCodes.X_V_MODEL_UNNECESSARY_VALUE,
        value.loc,
      ),
    )
  }
}

当用户在使用 v-model 的同时又写了 :value 时,可能造成冲突或冗余,因此编译器在开发模式下会发出警告。


5️⃣ 识别元素类型并选择合适的运行时助手

const { tag } = node
const isCustomElement = context.isCustomElement(tag)

Vue 会为不同类型的元素绑定不同的指令运行时函数:

元素类型 对应运行时辅助符号 功能说明
<input type="text"> V_MODEL_TEXT 文本输入双向绑定
<input type="checkbox"> V_MODEL_CHECKBOX 多选框绑定
<input type="radio"> V_MODEL_RADIO 单选绑定
<select> V_MODEL_SELECT 下拉选择绑定
自定义组件或动态 :type V_MODEL_DYNAMIC 动态类型运行时绑定

源码逻辑如下:

let directiveToUse = V_MODEL_TEXT
let isInvalidType = false

if (tag === 'input' || isCustomElement) {
  const type = findProp(node, `type`)
  if (type) {
    if (type.type === NodeTypes.DIRECTIVE) {
      directiveToUse = V_MODEL_DYNAMIC
    } else if (type.value) {
      switch (type.value.content) {
        case 'radio':
          directiveToUse = V_MODEL_RADIO
          break
        case 'checkbox':
          directiveToUse = V_MODEL_CHECKBOX
          break
        case 'file':
          isInvalidType = true
          context.onError(
            createDOMCompilerError(
              DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
              dir.loc,
            ),
          )
          break
        default:
          __DEV__ && checkDuplicatedValue()
      }
    }
  } else if (hasDynamicKeyVBind(node)) {
    directiveToUse = V_MODEL_DYNAMIC
  } else {
    __DEV__ && checkDuplicatedValue()
  }
} else if (tag === 'select') {
  directiveToUse = V_MODEL_SELECT
} else {
  __DEV__ && checkDuplicatedValue()
}

6️⃣ 注入运行时指令引用

if (!isInvalidType) {
  baseResult.needRuntime = context.helper(directiveToUse)
}

此时,baseResult.needRuntime 会携带一个对运行时 resolveDirective() 的引用,使生成的渲染函数能在运行时调用正确的指令处理逻辑。


7️⃣ 移除编译期无用的 modelValue 属性

baseResult.props = baseResult.props.filter(
  p => !(p.key.type === NodeTypes.SIMPLE_EXPRESSION && p.key.content === 'modelValue')
)

原因:原生元素的 v-model 不需要显式的 modelValue 传入,它会在运行时通过 binding.value 自动管理,因此删除以减少代码体积。


三、对比:@vue/compiler-core@vue/compiler-dom

  • @vue/compiler-core:负责通用的 AST 构建与基础转换(组件/指令通用逻辑)。

  • @vue/compiler-dom:在此基础上为浏览器平台添加 DOM 特有的行为,例如:

    • 检查 <input type="file"> 这种非法绑定。
    • 区分 checkboxradio 的运行时指令。
    • 针对开发模式(__DEV__)进行额外校验。

四、实践:transformModel 实际输出示例

当编译如下模板:

<input v-model="msg" type="text">

编译结果(简化版)大致为:

{
  props: [],
  needRuntime: helper(V_MODEL_TEXT)
}

渲染函数中会生成:

withDirectives(
  createElementVNode("input", null, null, 512 /* NEED_PATCH */),
  [[vModelText, msg]]
)

最终由 vModelText 在运行时处理 input 的输入/输出同步。


五、拓展:动态输入类型的特殊处理

例如:

<input :type="inputType" v-model="value">

此时编译器会检测到 type 是一个动态绑定(NodeTypes.DIRECTIVE),
自动切换到:

directiveToUse = V_MODEL_DYNAMIC

运行时则会在输入类型变化时动态切换不同的监听逻辑。


六、潜在问题与边界

  1. 文件输入限制
    v-model 不支持 <input type="file">,必须使用事件监听手动处理上传。
  2. 重复绑定冲突
    若同时使用 :valuev-model,可能导致值不一致问题。
  3. 自定义元素兼容性
    对于 Web Components,需自定义 isCustomElement 逻辑,保证 v-model 的行为一致。

七、总结

transformModel 是 Vue 模板编译器中将 “语法糖” 翻译为 “运行时逻辑” 的关键节点。
它体现了 Vue 的一个核心设计哲学——在编译阶段智能决策,在运行时高效执行

在理解了这段代码后,你不仅能掌握 v-model 的编译机制,还能更好地理解 Vue 模板编译的抽象层次:
从语法到 AST,从 AST 到渲染函数,再从渲染函数到最终 DOM 更新。


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

深入理解 Vue 编译阶段的 v-html 指令转换逻辑

在 Vue 的模板编译过程中,v-html 是一个特殊的 DOM 指令,它允许开发者直接将一段字符串内容设置为元素的 innerHTML。这篇文章将从源码角度解析 transformVHtml 的实现逻辑,理解其背后的安全约束与编译策略。


一、背景与概念

v-html 在 Vue 中的用途是让开发者能够动态插入一段 HTML 内容,例如:

<div v-html="rawHtml"></div>

这段代码在运行时会把 rawHtml 的字符串直接作为 innerHTML 写入 <div> 元素中。
在编译器阶段,Vue 会将该指令转换为渲染函数可识别的属性设置表达式。
例如:

{ innerHTML: rawHtml }

而整个转换的逻辑就集中在 transformVHtml 这个指令转换函数中。


二、源码结构与实现

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

export const transformVHtml: DirectiveTransform = (dir, node, context) => {
  const { exp, loc } = dir
  if (!exp) {
    context.onError(
      createDOMCompilerError(DOMErrorCodes.X_V_HTML_NO_EXPRESSION, loc),
    )
  }
  if (node.children.length) {
    context.onError(
      createDOMCompilerError(DOMErrorCodes.X_V_HTML_WITH_CHILDREN, loc),
    )
    node.children.length = 0
  }
  return {
    props: [
      createObjectProperty(
        createSimpleExpression(`innerHTML`, true, loc),
        exp || createSimpleExpression('', true),
      ),
    ],
  }
}

三、源码逐行解析与注释

1. 导入依赖

import {
  type DirectiveTransform,
  createObjectProperty,
  createSimpleExpression,
} from '@vue/compiler-core'
import { DOMErrorCodes, createDOMCompilerError } from '../errors'
  • DirectiveTransform:定义了一个指令转换函数的类型签名。Vue 在编译模板时,会为每个指令(如 v-if, v-for, v-html)注册对应的转换逻辑。
  • createObjectProperty:用于生成对象属性的 AST 节点。
  • createSimpleExpression:生成简单表达式节点(如字符串字面量或变量引用)。
  • createDOMCompilerError:在 DOM 转换阶段生成编译错误信息。

2. 定义主函数

export const transformVHtml: DirectiveTransform = (dir, node, context) => {

此处声明了一个指令转换函数 transformVHtml,其签名固定为 (dir, node, context)

  • dir:当前指令节点信息(包含表达式、参数、修饰符等)。
  • node:所在元素节点。
  • context:编译上下文,提供错误报告与代码生成工具。

3. 检查表达式有效性

const { exp, loc } = dir
if (!exp) {
  context.onError(
    createDOMCompilerError(DOMErrorCodes.X_V_HTML_NO_EXPRESSION, loc),
  )
}

逻辑说明:

  • v-html 必须绑定一个表达式(例如 v-html="htmlContent")。
  • 若表达式缺失(如 v-html 单独存在),则调用 context.onError 抛出错误。
  • 这里的错误类型为 X_V_HTML_NO_EXPRESSION

设计思路:
Vue 编译器会严格要求 v-html 提供动态值,否则模板含义不明确,无法生成有效的渲染代码。


4. 检查子节点冲突

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

逻辑说明:

  • 如果一个元素已经使用了 v-html,它的子节点将被完全替换,因此原始子节点在模板中是无效的。

  • 这段代码检测 node.children 是否非空;若存在,则:

    1. 报错提示开发者(X_V_HTML_WITH_CHILDREN)。
    2. 清空所有子节点,防止生成冲突的渲染逻辑。

示例错误:

<div v-html="rawHtml">
  <p>这段内容将被覆盖</p>
</div>

5. 生成最终的 AST 转换结果

return {
  props: [
    createObjectProperty(
      createSimpleExpression(`innerHTML`, true, loc),
      exp || createSimpleExpression('', true),
    ),
  ],
}

关键逻辑:

  • 返回的对象告诉编译器:
    该指令应转换为一个 props(即元素属性)数组。
  • createObjectProperty() 的作用是生成 { innerHTML: exp } 的 AST 表达形式。
  • exp 不存在,则使用空字符串表达式占位,防止后续阶段崩溃。

结果示例:

输入模板:

<div v-html="content"></div>

编译输出(简化):

{
  props: [{ key: 'innerHTML', value: content }]
}

这在渲染函数中最终转化为:

el.innerHTML = content

四、设计原理与对比

特性 v-html {{ }} 插值表达式
内容类型 原始 HTML 字符串 纯文本(HTML 转义)
安全性 潜在 XSS 风险 自动转义,安全
编译输出 innerHTML = exp textContent = exp
子节点 被清空 可混合使用

对比总结:

  • v-html 是“危险操作”,适用于可信内容(例如 CMS 返回的安全 HTML)。
  • 插值表达式自动防止注入攻击,推荐默认使用。

五、实践建议

  1. 仅在必要时使用 v-html:若只是输出文本,应使用 {{ }}
  2. 对内容进行清洗:例如使用 DOMPurify 过滤 HTML。
  3. 避免动态注入用户输入:防止跨站脚本(XSS)攻击。
  4. 注意 SSR 一致性innerHTML 可能导致服务端与客户端不一致。

六、拓展思考

1. 在编译管线中的位置

transformVHtml 属于 DOM 级别指令转换,它在模板编译第二阶段(node transform 阶段)执行,属于 结构性重写 类型的变换逻辑。

2. 可扩展性

开发者可参考其实现方式,创建自定义指令的编译时转换逻辑,通过 DirectiveTransform 接口将指令映射为目标属性或指令调用。


七、潜在问题与改进方向

  1. 安全风险:直接操作 innerHTML 无法防止恶意脚本注入。
  2. 性能问题:频繁更改 innerHTML 会导致 DOM 重绘。
  3. 无法绑定事件:通过 v-html 注入的内容不会被 Vue 模板编译处理。

Vue 团队在设计上有意将 v-html 视为“逃逸阀门”,仅用于特定、可信的场景。


八、总结

transformVHtml 是 Vue 编译器中处理 v-html 指令的核心函数,它的职责不仅是生成 innerHTML 属性绑定,同时还负责在编译阶段进行安全校验和错误提示。通过它,我们能直观地看到 Vue 如何在编译期约束开发者行为,保证运行时的正确性与安全性。


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

❌