普通视图

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

一文解决Babel 插件

2025年5月18日 17:27

Babel 是一个强大的 JavaScript 编译器,它允许开发者在项目中使用最新的 JavaScript 特性,并将其转换为向后兼容的版本,以便在当前和旧版浏览器或环境中运行。Babel 的核心功能之一就是其插件化的架构,开发者可以编写自定义插件来扩展 Babel 的功能,实现各种代码转换需求。

Babel 的工作流程

Babel 的转换过程主要分为三个阶段:

  1. 解析 (Parse) :

    • 输入: 源代码字符串。
    • 过程: Babel 使用解析器(默认为 @babel/parser,它是 babylon 的一个分支,而 babylon 又基于 acorn)将源代码字符串转换成一种叫做抽象语法树(Abstract Syntax Tree, AST)的中间表示形式。AST 是一种树形结构,它以结构化的方式表示代码的语法。树上的每个节点都代表代码中的一个构造,例如变量声明、函数调用、表达式等。
    • 输出: AST 对象。
  2. 转换 (Transform) :

    • 输入: 上一阶段生成的 AST。
    • 过程: 这是 Babel 插件工作的核心阶段。Babel 会遍历 AST,并在遍历过程中调用注册的插件。插件可以检查、修改、添加或删除 AST 节点,从而改变代码的结构和行为。多个插件会按照它们在 Babel 配置中声明的顺序依次执行。
    • 输出: 经过插件处理后的新的 AST。
  3. 生成 (Generate) :

    • 输入: 转换后的 AST。
    • 过程: Babel 使用代码生成器(默认为 @babel/generator)将修改后的 AST 转换回 JavaScript 代码字符串。在这个过程中,还可以生成 Source Map,用于在调试时将转换后的代码映射回原始代码。
    • 输出: 转换后的 JavaScript 代码字符串和可选的 Source Map。

你可以使用 AST Explorer 这个在线工具来查看不同代码片段对应的 AST 结构,这对于理解和编写 Babel 插件非常有帮助。

Babel 插件拿到的是什么,输出的是什么?

  • 拿到的是 (输入) :
    Babel 插件主要通过访问者模式 (Visitor Pattern) 来操作 AST。当 Babel 遍历 AST 时,如果遇到特定类型的节点,并且有插件注册了对该类型节点的访问者函数,那么这个函数就会被调用。
    访问者函数会接收两个主要的参数:

    1. path (类型为 NodePath): 这是一个非常重要的对象,它代表了 AST 中两个节点之间的链接。path 对象包含了当前节点 (path.node) 的信息,以及该节点在 AST 中的位置、作用域、父节点等上下文信息。更重要的是,path 对象提供了大量用于操作 AST 的方法,例如替换节点 (replaceWith)、删除节点 (remove)、在节点前后插入新节点 (insertBefore, insertAfter) 等。
    2. state: 这是一个可选的状态对象,它可以在遍历过程中传递数据。通常,插件的选项(通过 Babel 配置传入)会挂载到 state.opts 上。state 还可以用来在插件的不同访问者函数之间共享信息,或者在插件的 prepost 函数中使用。
  • 输出的是 (效果) :
    Babel 插件本身通常不直接“输出”一个值(除非是插件工厂函数返回插件对象)。它们通过修改传入的 path 对象所代表的 AST 结构来产生效果。这些修改会直接作用于 Babel 正在处理的 AST。当所有插件都执行完毕后,最终修改过的 AST 会被传递给代码生成阶段。

    所以,插件的“输出”是对 AST 的副作用修改

Babel 插件的核心 API 和概念

  1. 插件结构:
    一个 Babel 插件本质上是一个 JavaScript 函数,这个函数接收 Babel 的核心对象(通常命名为 babelapi)作为参数,并返回一个包含 visitor 对象的对象。

    export default function (babel) {
      // 可以通过 babel.types 或 api.types 访问 @babel/types
      const { types: t } = babel;
    
      return {
        // 可选的插件名称,用于调试和错误信息
        name: "my-custom-plugin",
    
        // 可选的 pre(state) 函数,在遍历 AST 之前执行
        pre(file) {
          // console.log("Plugin pre-execution for file:", file.opts.filename);
          // 可以在这里初始化一些插件级别的状态
          this.somePluginState = new Map();
        },
    
        // visitor 对象是插件的核心
        visitor: {
          // 键是 AST 节点的类型 (例如 'Identifier', 'BinaryExpression', 'FunctionDeclaration')
          // 值是处理该类型节点的函数或对象
          Identifier(path, state) {
            // 当 Babel 遍历到 Identifier 节点时,此函数会被调用
            // path: NodePath 对象,代表当前 Identifier 节点及其上下文
            // state: 插件的状态对象,包含插件选项 (state.opts)
            // console.log("Visiting Identifier:", path.node.name);
          },
    
          // 对于某些节点类型,可以提供 enter 和 exit 方法
          FunctionDeclaration: {
            enter(path, state) {
              // 在进入 FunctionDeclaration 节点时调用
              // console.log("Entering FunctionDeclaration:", path.node.id.name);
            },
            exit(path, state) {
              // 在退出 FunctionDeclaration 节点时调用 (所有子节点都已访问完毕)
              // console.log("Exiting FunctionDeclaration:", path.node.id.name);
            }
          },
    
          // 还可以使用 | 分隔多个节点类型,用同一个函数处理
          "BinaryExpression|LogicalExpression"(path, state) {
            // 处理二元表达式或逻辑表达式
          }
        },
    
        // 可选的 post(state) 函数,在遍历 AST 之后执行
        post(file) {
          // console.log("Plugin post-execution for file:", file.opts.filename);
          // 可以在这里进行一些清理工作
          // console.log("Plugin state:", this.somePluginState);
          // this.somePluginState.clear();
        },
    
        // 可选:如果插件需要处理继承的 visitor (不常用)
        // inherits: require("@babel/plugin-syntax-jsx"), // 例如
      };
    }
    
  2. visitor 对象:
    这是插件的核心。它的键是 AST 节点的类型名称(符合 ESTree 规范,Babel 在此基础上有所扩展,例如 JSX 相关的节点类型)。当 Babel 的 traverse 模块遍历 AST 时,遇到匹配类型的节点,就会调用相应的访问者函数。

  3. path (NodePath) 对象:
    NodePath 是理解 Babel 插件的关键。它不仅仅是对 AST 节点的简单包装,更提供了节点间的关系以及操作 AST 的丰富 API。

    • path.node: 当前访问的 AST 节点。

    • path.parent: 父 AST 节点。

    • path.parentPath: 父节点的 NodePath 对象。

    • path.scope: 当前节点所处的作用域信息,可以用来查找变量绑定、检查变量是否被引用等。

    • path.type: 当前节点的类型 (字符串,如 "Identifier")。

    • path.key: 当前节点在其父节点属性中的键名。

    • path.listKey: 如果当前节点是父节点某个数组属性的一员,此为该数组属性的键名。

    • path.inList: 布尔值,表示当前节点是否是列表的一部分。

    • path.get(key): 获取子路径。

    • path.isNodeType(type): 检查节点类型,如 path.isIdentifier()

    • path.findParent(callback): 向上查找符合条件的父路径。

    • 常用操作方法:

      • path.replaceWith(newNode): 用一个新节点替换当前节点。
      • path.replaceWithMultiple(nodesArray): 用多个新节点替换当前节点。
      • path.insertBefore(nodes): 在当前节点前插入一个或多个节点。
      • path.insertAfter(nodes): 在当前节点后插入一个或多个节点。
      • path.remove(): 删除当前节点。
      • path.skip(): 跳过当前节点的子节点的遍历。
      • path.stop(): 停止整个 AST 遍历。
  4. state 对象:

    • state.opts: 访问插件配置中传递的选项。例如,如果在 .babelrc.js 中配置插件:

      // .babelrc.js
      module.exports = {
        plugins: [
          ["./path/to/my-plugin.js", { option1: true, option2: "value" }]
        ]
      };
      

      在插件中可以通过 state.opts.option1state.opts.option2 来访问这些值。

    • state.file: 表示当前正在处理的文件,包含文件名等信息。

    • state.cwd: 当前工作目录。

    • 插件可以在 pre 函数中向 this (插件实例) 添加属性,这些属性在 visitorpost 函数中可以通过 this 访问,也可以通过 state 传递(但不推荐直接修改 state 对象本身,而是使用 this 来存储插件实例的状态)。

  5. @babel/types (通常别名为 ttypes) :
    这个模块提供了大量的工具函数,用于:

    • 创建 AST 节点: 例如 t.identifier("myVar") 创建一个标识符节点,t.binaryExpression("+", leftNode, rightNode) 创建一个二元表达式节点。
    • 检查节点类型: 例如 t.isIdentifier(node)t.isStringLiteral(node, { value: "hello" })
    • 断言节点类型: 例如 t.assertIdentifier(node),如果节点不是指定类型则抛出错误。
    • 其他辅助函数。

    在插件开头通常这样引入:

    export default function ({ types: t }) {
      // 现在可以使用 t.identifier, t.isBinaryExpression 等
      return {
        visitor: { /* ... */ }
      };
    }
    
  6. @babel/template:
    当需要创建复杂的 AST 节点结构时,手动使用 @babel/types 逐个创建节点会非常繁琐且容易出错。@babel/template 允许你用类似代码模板的字符串来生成 AST。

    • template(codeString, options): 返回一个函数,调用此函数并传入占位符的实际 AST 节点,即可生成对应的 AST 结构。
    import template from "@babel/template";
    import { types as t } from "@babel/core"; // 或者从插件参数中获取 types
    
    const buildTryCatch = template(`
      try {
        %%BODY%%
      } catch (%%ERROR_PARAM%%) {
        %%CATCH_HANDLER%%
      }
    `);
    
    // 在 visitor 中使用
    // const tryCatchAst = buildTryCatch({
    //   BODY: path.node.body, // 一个 BlockStatement
    //   ERROR_PARAM: t.identifier("e"),
    //   CATCH_HANDLER: t.blockStatement([
    //     t.expressionStatement(
    //       t.callExpression(
    //         t.memberExpression(t.identifier("console"), t.identifier("error")),
    //         [t.identifier("e")]
    //       )
    //     )
    //   ])
    // });
    // path.replaceWith(tryCatchAst);
    

    占位符可以是 %%NAME%% (大写,用于替换为 AST 节点) 或 $$NAME$$ (用于替换为字符串,生成标识符)。

详细代码讲解

下面我们将通过几个示例插件来详细讲解如何编写 Babel 插件。

准备工作

为了运行和测试这些插件,你需要安装 @babel/core。如果你想从命令行运行,还需要 @babel/cli

npm install --save-dev @babel/core
# 或者
yarn add --dev @babel/core

你可以创建一个 transform.js 文件来测试插件:

// transform.js
const babel = require('@babel/core');

function transformCode(code, plugins, presets = []) {
  const result = babel.transformSync(code, {
    plugins: plugins,
    presets: presets,
    configFile: false, // 忽略项目中的 babel 配置文件,以便独立测试
    babelrc: false,    // 同上
  });
  return result.code;
}

module.exports = { transformCode };

示例插件 1: 将 var 声明替换为 let

这个插件会遍历代码,找到所有的 var 变量声明,并将它们改为 let

// plugins/var-to-let-plugin.js

/**
 * Babel 插件:将 var 声明转换为 let 声明。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块,用于 AST节点的 TypeScript 定义和构建函数。
 * @returns {Object} Babel 插件对象,包含 visitor。
 */
export default function ({ types: t }) {
  // 打印插件加载信息,有助于调试
  // console.log("var-to-let-plugin loaded");

  return {
    name: "var-to-let", // 插件名称,可选,但推荐

    visitor: {
      /**
       * 访问 VariableDeclaration (变量声明) 节点。
       * 例如:var a = 1; const b = 2; let c = 3;
       *
       * @param {NodePath} path - 当前 VariableDeclaration 节点的路径对象。
       * @param {Object} state - 插件状态对象,包含插件选项 (state.opts) 等。
       */
      VariableDeclaration(path, state) {
        // path.node 是当前的 AST 节点
        // console.log("Visiting VariableDeclaration:", path.node.kind);

        // 检查当前变量声明的类型是否为 'var'
        if (path.node.kind === "var") {
          // console.log(`Found 'var' declaration at line ${path.node.loc.start.line}, column ${path.node.loc.start.column}`);
          // console.log("Original AST node:", JSON.stringify(path.node, null, 2));

          // 直接修改节点的 kind 属性
          // 这是一个简单的修改,对于更复杂的转换,可能需要创建新节点并替换
          path.node.kind = "let";

          // console.log(`Changed 'var' to 'let'`);
          // console.log("Modified AST node:", JSON.stringify(path.node, null, 2));

          // 注意:这种直接修改是有效的,因为 Babel 的遍历器允许在遍历过程中修改 AST。
          // 修改后,后续的插件或代码生成阶段将使用这个修改后的节点。

          // 如果需要更复杂的操作,例如基于某些条件决定是否转换,
          // 或者转换成完全不同的结构,就需要使用 path.replaceWith() 等方法。

          // 示例:如果插件有选项控制是否转换
          // if (state.opts && state.opts.enableVarToLet === false) {
          //   console.log("var-to-let conversion is disabled by plugin options.");
          //   path.node.kind = "var"; // 恢复,或者一开始就不修改
          //   return; // 提前退出当前节点的处理
          // }
        }

        // 演示如何访问插件选项 (如果配置了)
        // if (state.opts.myCustomOption) {
        //   console.log("Plugin option myCustomOption:", state.opts.myCustomOption);
        // }
      },

      // 你可以添加其他访问者来处理不同类型的节点
      // 例如,记录所有函数名称
      // FunctionDeclaration(path) {
      //   if (path.node.id) {
      //     console.log("Found function:", path.node.id.name);
      //   }
      // }
    },

    // pre 和 post 函数示例
    pre(file) {
      // console.log(`[var-to-let-plugin] Starting transformation for: ${file.opts.filename || 'unknown file'}`);
      // this.declarationsChanged = 0; // 初始化插件实例的状态
    },

    post(file) {
      // console.log(`[var-to-let-plugin] Finished transformation for: ${file.opts.filename || 'unknown file'}`);
      // if (this.declarationsChanged > 0) {
      //   console.log(`[var-to-let-plugin] Total var declarations changed to let: ${this.declarationsChanged}`);
      // }
      // delete this.declarationsChanged; // 清理状态
    }
  };
}

// 为了在 Node.js 环境中直接 require 这个文件 (如果使用 ES Modules 语法 export default)
// 如果你的测试环境或 Babel 配置期望 CommonJS 模块,可以这样做:
// module.exports = function(babel) { /* ... plugin code ... */ };
// 或者在 package.json 中设置 "type": "module"

测试 var-to-let-plugin.js:

// test-var-to-let.js
const { transformCode } = require('./transform'); // 假设 transform.js 在同级目录
const varToLetPlugin = require('./plugins/var-to-let-plugin').default; // 注意 .default

const code = `
  var x = 10;
  function foo() {
    var y = 20;
    if (true) {
      var z = 30; // var 会被提升
    }
    console.log(y, z);
  }
  var a = 1, b = 2;
  const c = 3; // const 不应被修改
  let d = 4;   // let 不应被修改
`;

console.log("Original Code:\n", code);

const transformedCode = transformCode(code, [
  [varToLetPlugin, { /* 插件选项,这里为空 */ }]
]);

console.log("\nTransformed Code:\n", transformedCode);

/*
预期输出:
Original Code:
... (原始代码) ...

Transformed Code:
let x = 10;
function foo() {
  let y = 20;
  if (true) {
    let z = 30; // var 会被提升
  }
  console.log(y, z);
}
let a = 1, b = 2;
const c = 3; // const 不应被修改
let d = 4;   // let 不应被修改
*/

运行 node test-var-to-let.js 查看结果。

示例插件 2: 移除所有的 console.log 语句

这个插件会查找所有 console.log(...) 这样的调用表达式,并将它们从代码中移除。

// plugins/remove-console-log-plugin.js

/**
 * Babel 插件:移除所有的 console.log 调用。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("remove-console-log-plugin loaded");

  return {
    name: "remove-console-log",

    visitor: {
      /**
       * 访问 CallExpression (函数调用表达式) 节点。
       * 例如:func(a, b), obj.method(), console.log("hello")
       *
       * @param {NodePath} path - 当前 CallExpression 节点的路径对象。
       * @param {Object} state - 插件状态对象。
       */
      CallExpression(path, state) {
        const node = path.node;

        // 检查调用者 (callee) 是否是 console.log
        // console.log 的 AST 结构通常是:
        // {
        //   type: "CallExpression",
        //   callee: {
        //     type: "MemberExpression",
        //     object: { type: "Identifier", name: "console" },
        //     property: { type: "Identifier", name: "log" },
        //     computed: false
        //   },
        //   arguments: [ ... ]
        // }

        const callee = node.callee;

        // 方式一:直接检查属性 (不够健壮,如果 console 被重命名或赋值给其他变量则无效)
        // if (
        //   t.isMemberExpression(callee) &&
        //   t.isIdentifier(callee.object, { name: "console" }) &&
        //   t.isIdentifier(callee.property, { name: "log" })
        // ) {
        //   console.log(`Found console.log call at line ${node.loc.start.line}. Removing it.`);
        //   path.remove(); // 从 AST 中移除当前 CallExpression 节点
        // }

        // 方式二:使用 path.matchesPattern (更简洁,但要注意模式的精确性)
        // `path.matchesPattern("console.log")` 可以匹配 console.log()
        // `path.matchesPattern("console.error")` 可以匹配 console.error()
        if (path.get("callee").matchesPattern("console.log")) {
          // console.log(`Found console.log call via matchesPattern at line ${node.loc.start.line}. Removing it.`);
          // console.log("Original AST node for CallExpression:", JSON.stringify(node, null, 2));

          // 检查插件选项,例如,只有在特定模式下才移除
          const removeIfOnlySpecificArgument = state.opts.removeIfArgumentIs;
          if (removeIfOnlySpecificArgument) {
            if (node.arguments.length === 1 && t.isStringLiteral(node.arguments[0], { value: removeIfOnlySpecificArgument })) {
              // console.log(`Removing console.log with specific argument: "${removeIfOnlySpecificArgument}"`);
              path.remove();
            } else {
              // console.log(`Skipping console.log removal, argument does not match "${removeIfOnlySpecificArgument}".`);
            }
          } else {
            // 默认移除所有 console.log
            path.remove();
          }
        }

        // 也可以移除其他 console 方法,例如 console.error, console.warn
        // else if (path.get("callee").matchesPattern("console.error")) {
        //   console.log(`Found console.error call at line ${node.loc.start.line}. Removing it.`);
        //   path.remove();
        // }

        // 进阶:处理 console 被解构或重命名的情况
        // const calleePath = path.get("callee");
        // if (calleePath.isMemberExpression()) {
        //   const objectPath = calleePath.get("object");
        //   const propertyName = calleePath.node.property.name;
        //
        //   if (propertyName === 'log' || propertyName === 'warn' || propertyName === 'error') {
        //     const binding = objectPath.scope.getBinding(objectPath.node.name);
        //     // 检查 objectPath.node.name (例如 'console') 是否确实指向全局的 console 对象
        //     // 这比较复杂,因为 console 可能被局部变量覆盖
        //     // 一个简单(但不完全可靠)的检查是看它是否是全局绑定
        //     if (objectPath.isIdentifier({ name: "console" }) && (!binding || binding.path.scope.isGlobal)) {
        //       console.log(`Found console.${propertyName} call. Removing it.`);
        //       path.remove();
        //     }
        //   }
        // }
      },
    },
  };
}

测试 remove-console-log-plugin.js:

// test-remove-console-log.js
const { transformCode } = require('./transform');
const removeConsoleLogPlugin = require('./plugins/remove-console-log-plugin').default;

const code = `
  function greet(name) {
    console.log("Entering greet function"); // 将被移除
    const message = "Hello, " + name;
    console.log("Message:", message);       // 将被移除
    if (name === "error") {
      console.error("Error case detected!"); // 如果插件也处理 error,则移除
    }
    return message;
  }

  console.log("Script started"); // 将被移除
  greet("World");
  console.log("Script finished"); // 将被移除

  const myLogger = console;
  myLogger.log("Logged with myLogger"); // 这个默认不会被简单模式移除

  console.log("debug"); // 用于测试选项
`;

console.log("Original Code:\n", code);

// 测试1: 移除所有 console.log
const transformedCode1 = transformCode(code, [
  [removeConsoleLogPlugin]
]);
console.log("\nTransformed Code (all console.log removed):\n", transformedCode1);

// 测试2: 只移除 console.log("debug")
const transformedCode2 = transformCode(code, [
  [removeConsoleLogPlugin, { removeIfArgumentIs: "debug" }]
]);
console.log("\nTransformed Code (only console.log('debug') removed):\n", transformedCode2);


/*
预期输出 (transformedCode1):
function greet(name) {
  const message = "Hello, " + name;
  if (name === "error") {
    console.error("Error case detected!"); // 假设插件只移除 console.log
  }
  return message;
}
greet("World");

const myLogger = console;
myLogger.log("Logged with myLogger");

预期输出 (transformedCode2):
function greet(name) {
  console.log("Entering greet function");
  const message = "Hello, " + name;
  console.log("Message:", message);
  if (name === "error") {
    console.error("Error case detected!");
  }
  return message;
}
console.log("Script started");
greet("World");
console.log("Script finished");

const myLogger = console;
myLogger.log("Logged with myLogger");
*/

示例插件 3: 自动为函数体包裹 try...catch

这个插件会找到所有的函数声明和函数表达式,并将它们的主体代码块包裹在一个 try...catch 语句中,用于统一的错误处理。这是一个更复杂的例子,涉及到创建新的 AST 节点。

// plugins/auto-try-catch-plugin.js
import template from "@babel/template"; // 需要 npm install --save-dev @babel/template

/**
 * Babel 插件:自动为函数体包裹 try...catch 语句。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("auto-try-catch-plugin loaded");

  // 使用 @babel/template 来构建 try...catch 结构
  // %%BODY%% 是一个占位符,将被实际的函数体替换
  // %%ERROR_IDENTIFIER%% 是错误对象的标识符
  // %%CATCH_HANDLER_BODY%% 是 catch 块内部的语句
  const buildTryCatch = template.statements(`
    try {
      %%BODY%%
    } catch (%%ERROR_IDENTIFIER%%) {
      %%CATCH_HANDLER_BODY%%
    }
  `);

  // 默认的 catch 处理逻辑:console.error(e)
  const defaultCatchHandler = template.statement(`
    console.error(%%ERROR_IDENTIFIER%%);
  `);

  // 如果希望 catch 块更复杂,可以定义更复杂的模板或手动构建
  // const complexCatchHandler = template.statements(`
  //   console.error("An error occurred in function:", %%FUNCTION_NAME%%);
  //   console.error(%%ERROR_IDENTIFIER%%.message);
  //   reportErrorToServer(%%ERROR_IDENTIFIER%%);
  // `);


  return {
    name: "auto-try-catch",

    visitor: {
      /**
       * 访问函数声明、函数表达式和箭头函数表达式。
       * 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'
       * 也可以分开写:
       * FunctionDeclaration(path, state) { /* ... */ },
       * FunctionExpression(path, state) { /* ... */ },
       * ArrowFunctionExpression(path, state) { /* ... */ },
       */
      'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression': function (path, state) {
        const node = path.node;

        // 0. 检查插件选项,例如是否启用,或者特定的函数名才处理
        if (state.opts.disable === true) {
          // console.log("Auto try-catch is disabled by plugin options.");
          return;
        }

        const onlyWrapNamedFunctions = state.opts.onlyWrapFunctionsNamed;
        if (onlyWrapNamedFunctions && Array.isArray(onlyWrapNamedFunctions)) {
          let functionName = "";
          if (t.isFunctionDeclaration(node) && node.id) {
            functionName = node.id.name;
          } else if (t.isFunctionExpression(node) && node.id) {
            functionName = node.id.name;
          } else if (t.isArrowFunctionExpression(node) && path.parentPath.isVariableDeclarator() && t.isIdentifier(path.parentPath.node.id)) {
            // const myFunction = () => { ... }
            functionName = path.parentPath.node.id.name;
          }
          // console.log("Checking function name:", functionName);
          if (!onlyWrapNamedFunctions.includes(functionName)) {
            // console.log(`Skipping function ${functionName || '(anonymous)'} as it's not in the whitelist.`);
            return;
          }
        }


        // 1. 获取函数体 (BlockStatement)
        let body = node.body;

        // 2. 如果是箭头函数且函数体不是块语句 (例如: const add = (a, b) => a + b;),
        //    需要先将其转换为块语句。
        if (!t.isBlockStatement(body)) {
          // 例如: () => expr  变成  () => { return expr; }
          // console.log("Arrow function with expression body found. Converting to block statement.");
          const expression = body;
          body = t.blockStatement([t.returnStatement(expression)]);
          // 更新节点,确保后续操作基于新的块语句体
          node.body = body;
        }

        // 3. 检查函数体是否为空或者已经是一个 try...catch 包裹的结构 (避免重复包裹)
        if (body.body && body.body.length === 0) {
          // console.log("Function body is empty. Skipping try-catch wrapping.");
          return; // 空函数体,无需包裹
        }
        if (body.body && body.body.length === 1 && t.isTryStatement(body.body[0])) {
          // console.log("Function body is already a try statement. Skipping.");
          return; // 已经有 try 语句了,不再包裹
        }

        // console.log(`Wrapping function ${node.id ? node.id.name : (path.parentPath.isVariableDeclarator() && path.parentPath.node.id ? path.parentPath.node.id.name : '(anonymous)')} with try...catch.`);
        // console.log("Original function body AST:", JSON.stringify(body, null, 2));

        // 4. 定义错误标识符和 catch 处理逻辑
        const errorIdentifier = path.scope.generateUidIdentifier("e"); // 生成一个唯一ID,避免冲突

        let catchHandlerBody;
        if (state.opts.customCatchHandler && typeof state.opts.customCatchHandler === 'string') {
          // 允许用户通过选项传入自定义的 catch 处理代码字符串
          // 注意:这种方式直接执行用户传入的字符串作为代码模板,需要谨慎处理安全问题。
          // 更安全的方式是让用户传入一个函数,该函数返回 AST 节点数组。
          try {
            const customHandlerTemplate = template.statements(state.opts.customCatchHandler);
            catchHandlerBody = customHandlerTemplate({
              ERROR_IDENTIFIER: errorIdentifier,
              // 可以传递更多上下文给模板,例如函数名
              // FUNCTION_NAME: t.stringLiteral(node.id ? node.id.name : 'anonymous')
            });
          } catch (err) {
            console.warn(`[auto-try-catch-plugin] Failed to parse customCatchHandler: ${err.message}. Falling back to default.`);
            catchHandlerBody = defaultCatchHandler({ ERROR_IDENTIFIER: errorIdentifier });
          }
        } else {
          catchHandlerBody = defaultCatchHandler({ ERROR_IDENTIFIER: errorIdentifier });
        }


        // 5. 使用 template 构建新的 try...catch 结构
        // 注意:template.statements 返回的是一个 AST 节点数组
        // 而函数体 node.body 需要的是一个 BlockStatement
        // 所以我们将 template 返回的数组作为新 BlockStatement 的 body
        const tryCatchAstNodes = buildTryCatch({
          BODY: body.body, // 直接传递原函数体的语句数组
          ERROR_IDENTIFIER: errorIdentifier,
          CATCH_HANDLER_BODY: catchHandlerBody,
        });

        // 6. 将原函数体替换为新的包含 try...catch 的块语句
        node.body = t.blockStatement(tryCatchAstNodes);

        // console.log("New function body AST with try-catch:", JSON.stringify(node.body, null, 2));

        // 如果函数是异步函数 (async function)
        // 它的返回值会被隐式包裹在 Promise.resolve() 中。
        // 如果 try 块中的代码抛出错误,且没有 return 语句,
        // catch 块执行后,函数会隐式返回 undefined (被 Promise.resolve(undefined) 包裹)。
        // 如果 catch 块中 rethrow 了错误,或者抛出了新错误,则 Promise 会 reject。
        // 这里的简单包裹对于异步函数的错误捕获是有效的。
        // 如果需要更复杂的异步错误处理(例如,确保 Promise总是 resolve 或 reject 特定值),
        // catch 块的逻辑会更复杂。

        // 标记路径已更改,有助于 Babel 进行优化 (某些情况下)
        // path.stop(); // 如果你确定这个节点处理完后不需要再访问其子节点或同级其他访问器
      }
    }
  };
}

测试 auto-try-catch-plugin.js:

// test-auto-try-catch.js
const { transformCode } = require('./transform');
const autoTryCatchPlugin = require('./plugins/auto-try-catch-plugin').default;

const code = `
  function syncFunction(a, b) {
    console.log("syncFunction called");
    if (a < 0) {
      throw new Error("Negative input for 'a'");
    }
    return a + b;
  }

  const arrowFunc = (x) => {
    if (x === 0) throw new Error("Zero division");
    return 100 / x;
  };

  const arrowExprFunc = (y) => y * y;

  async function asyncFunction(name) {
    console.log("asyncFunction called");
    if (!name) {
      throw new Error("Name is required");
    }
    await new Promise(resolve => setTimeout(resolve, 10));
    return `Hello, ${name}`;
  }

  // 已经有 try-catch 的函数,不应该被再次包裹
  function alreadyWrapped() {
    try {
      console.log("Already wrapped");
    } catch (e) {
      // ignore
    }
  }

  // 空函数
  function emptyFunction() {}

  // 测试插件选项
  function processData(data) {
    if (!data) throw new Error("No data");
    return data.toUpperCase();
  }
  function ignoreThis(data) {
    return data;
  }
`;

console.log("Original Code:\n", code);

// 测试1: 默认行为
const transformedCode1 = transformCode(code, [
  [autoTryCatchPlugin]
]);
console.log("\nTransformed Code (default catch handler):\n", transformedCode1);

// 测试2: 使用自定义 catch handler (字符串模板)
const customHandler = `
  console.warn("Custom error handler caught:", %%ERROR_IDENTIFIER%%.message);
  Sentry.captureException(%%ERROR_IDENTIFIER%%);
`;
const transformedCode2 = transformCode(code, [
  [autoTryCatchPlugin, { customCatchHandler: customHandler }]
]);
console.log("\nTransformed Code (custom catch handler):\n", transformedCode2);

// 测试3: 只包裹特定名称的函数
const transformedCode3 = transformCode(code, [
  [autoTryCatchPlugin, { onlyWrapFunctionsNamed: ["processData", "syncFunction"] }]
]);
console.log("\nTransformed Code (only 'processData' and 'syncFunction' wrapped):\n", transformedCode3);


/*
预期输出 (transformedCode1 会比较长,这里只展示 syncFunction 的大致结构):
function syncFunction(a, b) {
  try {
    console.log("syncFunction called");
    if (a < 0) {
      throw new Error("Negative input for 'a'");
    }
    return a + b;
  } catch (_e) { // _e 是生成的唯一ID
    console.error(_e);
  }
}

const arrowFunc = x => {
  try {
    if (x === 0) throw new Error("Zero division");
    return 100 / x;
  } catch (_e2) {
    console.error(_e2);
  }
};

const arrowExprFunc = y => {
  try {
    return y * y;
  } catch (_e3) {
    console.error(_e3);
  }
};

async function asyncFunction(name) {
  try {
    console.log("asyncFunction called");
    if (!name) {
      throw new Error("Name is required");
    }
    await new Promise(resolve => setTimeout(resolve, 10));
    return `Hello, ${name}`;
  } catch (_e4) {
    console.error(_e4);
  }
}

function alreadyWrapped() { // 不变
  try {
    console.log("Already wrapped");
  } catch (e) {
    // ignore
  }
}

function emptyFunction() {} // 不变

// processData 会被包裹, ignoreThis 不会 (在 transformedCode3 中)
*/

这个 auto-try-catch-plugin 示例展示了:

  • 使用 @babel/template 创建复杂的 AST 结构。
  • 处理不同类型的函数(声明、表达式、箭头函数)。
  • 转换箭头函数的表达式体为块语句体。
  • 使用 path.scope.generateUidIdentifier 生成唯一的变量名以避免冲突。
  • 通过插件选项 state.opts 自定义插件行为。
  • 避免重复处理(例如已经有 try...catch 的函数)。

示例插件 4: 简单的国际化 (i18n) 文本替换

这个插件演示如何读取插件选项(一个字典),并替换代码中特定的函数调用(例如 __('greeting'))为其在字典中对应的值。

// plugins/i18n-plugin.js

/**
 * Babel 插件:简单的国际化文本替换。
 * 替换形如 __('key') 或 i18n('key', 'default value') 的调用。
 *
 * @param {Object} babel - Babel 核心对象。
 * @param {Object} babel.types - @babel/types 模块。
 * @returns {Object} Babel 插件对象。
 */
export default function ({ types: t }) {
  // console.log("i18n-plugin loaded");

  let translations = {}; // 用于存储从选项加载的翻译
  let defaultLocale = 'en';
  let translationFunctionName = '__'; // 默认的翻译函数名

  return {
    name: "simple-i18n",

    // pre 函数在遍历前执行,适合用来处理插件选项
    pre(state) {
      // console.log("i18n-plugin: pre() hook");
      // console.log("Plugin options received:", JSON.stringify(this.opts, null, 2)); // this.opts 是 state.opts 的别名

      if (this.opts.translations) {
        translations = this.opts.translations;
      } else {
        console.warn("[i18n-plugin] 'translations' option not provided. Plugin may not work as expected.");
        translations = {};
      }

      if (this.opts.defaultLocale) {
        defaultLocale = this.opts.defaultLocale;
      }

      if (this.opts.functionName) {
        translationFunctionName = this.opts.functionName;
      }
      // console.log(`[i18n-plugin] Using function name: '${translationFunctionName}', default locale: '${defaultLocale}'`);
      // console.log("[i18n-plugin] Loaded translations:", JSON.stringify(translations, null, 2));
    },

    visitor: {
      /**
       * 访问 CallExpression (函数调用表达式) 节点。
       * @param {NodePath} path - 当前 CallExpression 节点的路径对象。
       * @param {Object} state - 插件状态对象 (这里我们主要用 pre 中设置好的 this.opts)。
       */
      CallExpression(path, state) {
        const node = path.node;
        const callee = node.callee;

        // 检查是否是我们定义的翻译函数调用,例如 __() 或 i18n()
        if (t.isIdentifier(callee, { name: translationFunctionName })) {
          // console.log(`Found translation function call: ${translationFunctionName}() at line ${node.loc.start.line}`);

          if (node.arguments.length === 0) {
            // console.warn(`[i18n-plugin] Call to ${translationFunctionName}() with no arguments. Skipping.`);
            return;
          }

          const firstArg = node.arguments[0];
          if (!t.isStringLiteral(firstArg)) {
            // console.warn(`[i18n-plugin] First argument to ${translationFunctionName}() must be a string literal (the key). Skipping.`);
            return;
          }

          const translationKey = firstArg.value;
          // console.log(`Translation key: "${translationKey}"`);

          let translatedString = null;

          // 尝试从当前语言的翻译中获取
          if (translations[defaultLocale] && translations[defaultLocale].hasOwnProperty(translationKey)) {
            translatedString = translations[defaultLocale][translationKey];
          } else if (translations.hasOwnProperty(translationKey)) {
            // 如果顶层直接是键值对 (没有按语言组织)
            // 或者作为一种回退机制,如果特定语言没有,尝试从通用翻译中找
            // 这种结构通常不推荐,最好按语言组织
            // console.log(`[i18n-plugin] Key "${translationKey}" not found in locale "${defaultLocale}", trying root.`);
            // translatedString = translations[translationKey];
          }


          // 如果找不到翻译,并且提供了默认值参数
          if (translatedString === null || typeof translatedString !== 'string') {
            if (node.arguments.length > 1 && t.isStringLiteral(node.arguments[1])) {
              const defaultValue = node.arguments[1].value;
              // console.log(`[i18n-plugin] Key "${translationKey}" not found for locale "${defaultLocale}". Using provided default value: "${defaultValue}"`);
              translatedString = defaultValue;
            } else {
              // console.warn(`[i18n-plugin] Key "${translationKey}" not found for locale "${defaultLocale}" and no default value provided. Replacing with key itself or an indicator.`);
              // 如果没有翻译也没有默认值,可以选择替换为 key 本身,或者一个标记,或者抛出错误
              // 这里我们替换为 "KEY_NOT_FOUND:key"
              translatedString = state.opts.missingKeyPrefix ? `${state.opts.missingKeyPrefix}${translationKey}` : `KEY_NOT_FOUND:${translationKey}`;
            }
          }

          if (typeof translatedString === 'string') {
            // console.log(`Replacing call with string literal: "${translatedString}"`);
            // 用翻译后的字符串字面量替换整个函数调用表达式
            path.replaceWith(t.stringLiteral(translatedString));
          } else {
            // console.warn(`[i18n-plugin] Could not resolve translation for key "${translationKey}". Original call remains.`);
          }
        }
      }
    },

    post(state) {
      // console.log("i18n-plugin: post() hook");
      // 清理,虽然在这个简单例子中非必需,但好习惯
      translations = {};
      defaultLocale = 'en';
      translationFunctionName = '__';
    }
  };
}

测试 i18n-plugin.js:

// test-i18n-plugin.js
const { transformCode } = require('./transform');
const i18nPlugin = require('./plugins/i18n-plugin').default;

const code = `
  const greeting = __("greeting.hello");
  const farewell = __("farewell", "Goodbye for now!");
  const missing = __("missing.key");
  const customName = i18n("custom.message");

  function showMessages() {
    console.log(greeting);
    console.log(farewell);
    console.log(missing);
    console.log(customName);
    console.log(__("inline.usage", "Inline default"));
  }
`;

const pluginOptions1 = {
  functionName: "__", // 明确指定函数名
  defaultLocale: "en_US",
  translations: {
    "en_US": {
      "greeting.hello": "Hello, World!",
      "farewell": "Farewell, Friend!",
      "inline.usage": "Used inline"
      // "missing.key" is intentionally missing
    },
    "es_ES": {
      "greeting.hello": "¡Hola, Mundo!"
    }
  },
  missingKeyPrefix: "[MISSING] " // 自定义未找到键的前缀
};

const pluginOptions2 = {
  functionName: "i18n", // 改变翻译函数名
  defaultLocale: "fr_FR",
  translations: {
    "fr_FR": {
      "custom.message": "Message personnalisé"
    }
  }
};


console.log("Original Code:\n", code);

// 测试1: 使用 __ 和 en_US 翻译
const transformedCode1 = transformCode(code, [
  [i18nPlugin, pluginOptions1]
]);
console.log("\nTransformed Code (en_US with __):\n", transformedCode1);

// 测试2: 使用 i18n 和 fr_FR 翻译 (只影响 i18n() 调用)
// 注意:由于 Babel 插件是按顺序应用的,如果想让两个不同配置的 i18n 插件都生效,
// 需要分别应用它们,或者让一个插件能够处理多种配置。
// 这里我们假设只应用一个,所以 __() 调用在这次转换中不会被 pluginOptions2 处理。
const transformedCode2 = transformCode(code, [
  [i18nPlugin, pluginOptions2] // 这个配置只会处理 i18n()
]);
console.log("\nTransformed Code (fr_FR with i18n, __ calls untouched by this specific plugin instance):\n", transformedCode2);

// 更实际的场景是,你可能只配置一次插件,或者链式调用:
const transformedCodeCombined = transformCode(code, [
  [i18nPlugin, pluginOptions1], // 先处理 __
  [i18nPlugin, pluginOptions2]  // 再处理 i18n
]);
console.log("\nTransformed Code (Combined - __ then i18n):\n", transformedCodeCombined);


/*
预期输出 (transformedCode1):
const greeting = "Hello, World!";
const farewell = "Farewell, Friend!";
const missing = "[MISSING] missing.key";
const customName = i18n("custom.message"); // 不会被 pluginOptions1 处理

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log("Used inline");
}

预期输出 (transformedCode2):
const greeting = __("greeting.hello"); // 不会被 pluginOptions2 处理
const farewell = __("farewell", "Goodbye for now!"); // 不会被 pluginOptions2 处理
const missing = __("missing.key"); // 不会被 pluginOptions2 处理
const customName = "Message personnalisé";

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log(__("inline.usage", "Inline default")); // 不会被 pluginOptions2 处理
}

预期输出 (transformedCodeCombined):
const greeting = "Hello, World!";
const farewell = "Farewell, Friend!";
const missing = "[MISSING] missing.key";
const customName = "Message personnalisé";

function showMessages() {
  console.log(greeting);
  console.log(farewell);
  console.log(missing);
  console.log(customName);
  console.log("Used inline");
}
*/

这个 i18n-plugin 示例展示了:

  • pre 钩子中处理和准备插件选项。
  • 根据插件选项动态改变插件的行为(如翻译函数名、区域设置)。
  • 从选项中加载数据(翻译字典)。
  • 替换函数调用节点为字符串字面量节点。
  • 处理参数和提供回退机制(默认值、未找到键的标记)。
  • post 钩子中进行清理(可选)。

AST Explorer 的使用

强烈推荐使用 AST Explorer (astexplorer.net)

  1. 选择 JavaScript 作为语言。
  2. 选择 @babel/parser 作为解析器。
  3. 选择 "Transform" -> "Babel Plugin" (或 v7/v8)。
  4. 在左上角面板输入你的源代码。
  5. 在右上角面板可以看到生成的 AST 结构。点击代码中的部分会高亮对应的 AST 节点,反之亦然。
  6. 在左下角面板可以编写你的 Babel 插件代码。
  7. 在右下角面板可以看到插件转换后的代码。

这对于理解特定代码结构对应的 AST 节点类型、属性以及试验插件逻辑非常有帮助。

Babel 插件的测试

虽然上面的例子中我们用了简单的 transformCode 函数来手动测试,但在实际项目中,建议使用更专业的测试框架,如 Jest。

你可以使用 @babel/coretransformtransformSync 方法在测试用例中运行你的插件,并断言输出是否符合预期。

一个简单的 Jest 测试用例可能如下:

// my-plugin.test.js
import pluginTester from 'babel-plugin-tester'; // 一个流行的测试工具 npm install --save-dev babel-plugin-tester
import myCustomPlugin from '../plugins/my-custom-plugin'; // 你的插件

pluginTester({
  plugin: myCustomPlugin,
  pluginName: 'my-custom-plugin', // 与插件中的 name 对应
  // fixture: path.join(__dirname, 'fixtures', 'my-test-case', 'code.js'), // 从文件加载测试用例
  // outputFixture: path.join(__dirname, 'fixtures', 'my-test-case', 'output.js'),
  tests: [
    {
      title: 'should replace var with let',
      code: 'var x = 1;',
      output: 'let x = 1;',
      pluginOptions: { /* 插件选项 */ }
    },
    {
      title: 'should remove console.log',
      code: 'console.log("hello"); var a = 1;',
      output: 'var a = 1;', // 假设这是另一个插件或组合
      // 如果是测试 remove-console-log-plugin
      // plugin: require('../plugins/remove-console-log-plugin').default,
      // output: 'var a = 1;',
    },
    // 更多测试用例
    {
      title: 'should not change const',
      code: 'const y = 2;',
      output: 'const y = 2;', // 或者 snapshot: true
    },
    {
      title: 'error case',
      code: 'var 123invalid = "test";', // 无效代码
      error: /SyntaxError/ // 或特定的错误信息/类型
    }
  ],
});

babel-plugin-tester 提供了很好的结构来组织测试用例,包括快照测试、错误测试等。

其他相关知识

  1. 插件顺序:

    • Babel 插件的执行顺序很重要。在 Babel 配置中,插件按数组顺序从前到后执行。
    • Preset(预设,插件的集合)中的插件会在自定义插件之前执行。Preset 的顺序是反向的(从后到前)。
    • 如果一个插件的转换依赖于另一个插件的结果,你需要确保它们的顺序正确。
  2. Preset (预设) :

    • Preset 是一组预先配置好的 Babel 插件和/或选项。例如 @babel/preset-env 可以根据你指定的目标环境自动确定需要的插件和 polyfill。
    • @babel/preset-react 包含了转换 JSX 的插件。
    • @babel/preset-typescript 包含了转换 TypeScript 的插件。
    • 你可以创建自己的 Preset。
  3. 宏 (Macros - babel-plugin-macros) :

    • 宏允许你在构建时执行代码生成,并且不需要用户在 Babel 配置中添加插件,只需导入宏即可。它们提供了更零配置的插件体验。例如 styled-components/macro
  4. 性能注意事项:

    • 避免在访问者函数中进行昂贵的操作,特别是那些会被频繁调用的节点类型(如 Identifier)。
    • 如果可能,在 pre 函数中进行一次性的计算或设置。
    • 合理使用 path.skip() 来跳过不需要处理的子树。
    • 缓存计算结果(如果适用)。
    • Babel 内部有一些优化,但插件的写法仍然对性能有影响。
  5. 作用域 (Scope) :

    • path.scope 对象非常有用,它提供了关于当前节点作用域内变量绑定、引用等信息。
    • scope.hasBinding("varName"): 检查变量是否在此作用域或父作用域中声明。
    • scope.getBinding("varName"): 获取变量的绑定信息 (Binding 对象),包含声明节点、引用等。
    • scope.generateUidIdentifier("prefix"): 生成一个在当前作用域中唯一的标识符,避免命名冲突。
    • 理解作用域对于进行安全的变量重命名、注入新变量等操作至关重要。
  6. 与打包工具 (Webpack, Rollup) 的集成:

    • Babel 通常作为这些打包工具的一个加载器 (loader) 或插件来使用(例如 babel-loader for Webpack)。打包工具负责读取文件,然后将文件内容传递给 Babel 进行转换。
  7. Source Maps:

    • Babel 可以生成 Source Map,将转换后的代码映射回原始代码,这对于调试非常重要。代码生成阶段 (@babel/generator) 负责此事,通常可以通过 Babel 的选项来控制 Source Map 的生成。
  8. Helper 函数:

    • 某些转换(例如类、异步函数)可能需要一些辅助函数(helpers)来模拟新特性。Babel 可以将这些 helpers 内联到每个需要它们的文件中,或者通过 @babel/plugin-transform-runtime@babel/runtime 将它们提取为共享模块,以减小代码体积。

总结

编写 Babel 插件是一个强大但复杂的过程。核心在于理解 AST 的结构、掌握 NodePath API 以及如何使用 @babel/types@babel/template 来操纵 AST。

  • 流程: Parse -> Transform (插件工作区) -> Generate。
  • 输入: 插件通过 visitor 模式接收 path (NodePath) 和 state
  • 输出: 插件通过修改 path 对应的 AST 节点来产生效果。
  • 关键工具: NodePath API, @babel/types, @babel/template
  • 实践: 多使用 AST Explorer,多写测试。

前端 SEO 优化

2025年5月18日 17:09

前端 SEO 优化的核心目标:

  1. 可抓取性 (Crawlability) : 确保搜索引擎的爬虫能够发现并访问你网站上的所有重要内容。
  2. 可索引性 (Indexability) : 确保爬虫能够理解并正确地将你的页面内容添加到搜索引擎的索引库中。
  3. 内容质量与相关性 (Content Quality & Relevance) : 提供高质量、与用户搜索意图相关的内容,并使用合适的关键词。
  4. 用户体验 (User Experience - UX) : 提供快速、易用、移动友好的体验,因为搜索引擎越来越重视用户满意度。
  5. 技术实现 (Technical Implementation) : 采用搜索引擎友好的技术和编码规范。

以下是详细的前端 SEO 优化点和相关代码示例:

一、基础 HTML 标签优化 (On-Page SEO Fundamentals)

这是 SEO 的基石,确保每个页面都有清晰的元信息。

1. <title> 标签

  • 作用: 页面标题,显示在浏览器标签页、书签以及搜索引擎结果页的标题位置。是影响排名的最重要因素之一。

  • 优化建议:

    • 每个页面都应有唯一且描述性的标题。
    • 长度通常建议在 50-60 个字符(中文大约 25-30 个汉字)以内,超出部分在 SERP 中可能被截断。
    • 包含核心关键词,自然地融入标题。
    • 重要关键词尽量靠前。
    • 品牌名可以放在标题末尾,用分隔符(如 -|)隔开。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF--8">
    <!-- 示例1: 首页标题 -->
    <title>XX公司官网 - 领先的AI解决方案提供商</title>

    <!-- 示例2: 产品页标题 -->
    <title>智能聊天机器人XYZ - 提升客户服务效率 | XX公司</title>

    <!-- 示例3: 文章页标题 -->
    <title>如何有效进行前端性能优化 - 实践指南 | XX公司博客</title>

    <!-- 其他 meta 标签... -->
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

2. <meta name="description"> 标签

  • 作用: 页面描述,显示在搜索引擎结果页的标题下方,作为页面的摘要。虽然不直接影响排名,但会极大地影响点击率 (CTR)。

  • 优化建议:

    • 每个页面都应有唯一且吸引人的描述。
    • 长度通常建议在 150-160 个字符(中文大约 75-80 个汉字)以内。
    • 准确概括页面内容,包含核心关键词。
    • 使用有号召性的语言,鼓励用户点击。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>智能聊天机器人XYZ - 提升客户服务效率 | XX公司</title>

    <!-- 示例: 产品页描述 -->
    <meta name="description" content="了解XX公司的智能聊天机器人XYZ如何通过自然语言处理和机器学习技术,7x24小时自动化客户咨询,显著提升服务效率并降低运营成本。立即获取演示!">

    <!-- 其他 meta 标签... -->
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

3. <meta name="keywords"> 标签 (基本已废弃)

  • 作用: 早期用于告诉搜索引擎页面关键词。
  • 现状: Google 等主流搜索引擎已基本忽略此标签,因为容易被滥用。可以不写,或者简单写几个核心词。
<!-- 示例 (可选,作用不大) -->
<meta name="keywords" content="智能聊天机器人, AI客服, 客户服务自动化">

4. <meta name="robots"><meta name="googlebot"> 标签

  • 作用: 指示搜索引擎爬虫如何处理当前页面。

  • 常用值:

    • index: 允许索引此页面。
    • noindex: 不允许索引此页面。
    • follow: 允许跟踪此页面上的链接。
    • nofollow: 不允许跟踪此页面上的链接。
    • all (默认): 等同于 index, follow
    • none: 等同于 noindex, nofollow
    • noarchive: 不在搜索结果中显示缓存链接。
    • nosnippet: 不在搜索结果中显示此页面的文本摘要或视频预览。
    • notranslate: 不提供此页面的翻译版本。
    • noimageindex: 不索引此页面上的图片。
    • unavailable_after: [RFC 850 date/time string]: 在指定日期后不再显示此页面。
<head>
    <!-- 示例1: 允许索引和跟踪 (默认行为,可不写) -->
    <meta name="robots" content="index, follow">

    <!-- 示例2: 禁止索引但允许跟踪链接 (例如,用于某些用户协议页面) -->
    <meta name="robots" content="noindex, follow">

    <!-- 示例3: 禁止索引和跟踪 (例如,用于后台管理页面或测试页面) -->
    <meta name="robots" content="noindex, nofollow">
    <!-- 或者 -->
    <meta name="robots" content="none">

    <!-- 示例4: 针对 Google 特定的指令 -->
    <meta name="googlebot" content="nosnippet, max-snippet:-1, max-image-preview:large, max-video-preview:-1">
    <!--
        max-snippet:-1: 不限制摘要长度
        max-image-preview:large: 允许显示大图预览
        max-video-preview:-1: 不限制视频预览时长
    -->
</head>

5. 语义化 HTML5 标签

  • 作用: 使用正确的 HTML5 语义化标签(如 <header>, <nav>, <main>, <article>, <section>, <aside>, <footer>, <figure>, <figcaption>)可以帮助搜索引擎更好地理解页面结构和内容层次。

  • 优化建议:

    • 合理组织页面结构,使用语义化标签包裹相应内容。
    • 确保内容的可读性和逻辑性。
<body>
    <header>
        <h1><a href="/">网站主标题 (通常是Logo和网站名称)</a></h1>
        <nav>
            <ul>
                <li><a href="/products">产品</a></li>
                <li><a href="/solutions">解决方案</a></li>
                <li><a href="/blog">博客</a></li>
                <li><a href="/about">关于我们</a></li>
            </ul>
        </nav>
    </header>

    <main>
        <article>
            <header>
                <h2>文章标题:前端SEO深度解析</h2>
                <p>发布日期: <time datetime="2025-05-17">2025年5月17日</time> 作者: SEO专家</p>
            </header>
            <section id="introduction">
                <h3>1. 引言</h3>
                <p>前端SEO对于现代网站至关重要...</p>
            </section>
            <section id="html-tags">
                <h3>2. HTML标签优化</h3>
                <p>详细介绍title, meta description等...</p>
                <figure>
                    <img src="images/html-structure.png" alt="良好HTML结构示例图">
                    <figcaption>图1:一个语义化的HTML页面结构示例。</figcaption>
                </figure>
            </section>
            <!-- 更多 section -->
            <footer>
                <p>标签: <a href="/tags/seo">SEO</a>, <a href="/tags/frontend">前端</a></p>
            </footer>
        </article>

        <aside>
            <h3>相关文章</h3>
            <ul>
                <li><a href="/blog/performance-optimization">前端性能优化技巧</a></li>
                <li><a href="/blog/structured-data-guide">结构化数据指南</a></li>
            </ul>
            <h3>广告位</h3>
            <div id="ad-slot-1"></div>
        </aside>
    </main>

    <footer>
        <p>&copy; 2025 XX公司. 保留所有权利.</p>
        <nav>
            <a href="/privacy-policy">隐私政策</a> | <a href="/terms-of-service">服务条款</a>
        </nav>
    </footer>
</body>

6. 标题标签 (H1-H6)

  • 作用: 定义内容的层级结构,<h1> 是最重要的标题,通常用于页面主标题,每个页面应只有一个 <h1><h2><h6> 表示子标题,层级递减。

  • 优化建议:

    • 逻辑清晰地使用 H 标签,反映内容的组织结构。
    • <h1> 包含核心关键词,与 <title> 呼应但可以不完全相同。
    • 不要跳级使用(例如 <h1> 直接到 <h3>)。
    • 不要滥用 H 标签来仅仅为了样式,样式应由 CSS 控制。
<!-- 在上面的语义化HTML示例中已包含 H 标签的正确使用 -->
<main>
    <article>
        <header>
            <h1>文章主标题 (H1)</h1> <!-- 页面唯一 H1 -->
        </header>
        <section>
            <h2>章节标题 (H2)</h2>
            <p>内容...</p>
            <h3>子章节标题 (H3)</h3>
            <p>内容...</p>
            <h4>更细分的子章节 (H4)</h4>
            <p>内容...</p>
        </section>
        <section>
            <h2>另一个章节标题 (H2)</h2>
            <p>内容...</p>
        </section>
    </article>
</main>

7. 图片优化 (<img> 标签)

  • 作用: 图片是内容的重要组成部分,优化图片有助于图片搜索排名,并提升页面加载速度。

  • 优化建议:

    • alt 属性: 必须为所有有意义的图片提供描述性的 alt 文本。这不仅帮助视障用户理解图片内容,也帮助搜索引擎理解图片。如果图片纯粹是装饰性的,alt 可以为空 (alt="")。
    • 文件名: 使用描述性的文件名(例如 ai-chatbot-dashboard.jpg 而不是 img123.jpg)。
    • 图片压缩: 使用工具(如 TinyPNG, ImageOptim)压缩图片大小而不显著降低质量。
    • 响应式图片: 使用 <picture> 元素或 <img>srcsetsizes 属性提供不同尺寸的图片以适应不同设备。
    • 图片格式: 选择合适的图片格式(JPEG 适合照片,PNG 适合透明背景或简单图形,WebP 提供更好的压缩和质量)。
    • 延迟加载 (Lazy Loading) : 对非首屏图片使用 loading="lazy" 属性,或通过 JavaScript 实现。
<!-- 基本图片优化 -->
<img src="images/smart-ai-assistant.jpg"
     alt="一位用户正在与智能AI助手进行交互的场景图"
     width="800"
     height="600">

<!-- 响应式图片与延迟加载 -->
<img src="images/product-feature-small.jpg"
     srcset="images/product-feature-small.jpg 480w,
             images/product-feature-medium.jpg 800w,
             images/product-feature-large.jpg 1200w"
     sizes="(max-width: 600px) 480px,
            (max-width: 900px) 800px,
            1200px"
     alt="产品核心功能展示图"
     loading="lazy">

<!-- 使用 <picture> 元素提供不同格式或裁剪的图片 -->
<picture>
   <source srcset="images/hero-banner.webp" type="image/webp">
   <source srcset="images/hero-banner.jpg" type="image/jpeg">
   <img src="images/hero-banner.jpg" alt="网站首页的英雄横幅广告" loading="lazy">
</picture>

8. 链接优化 (<a> 标签)

  • 作用: 内部链接和外部链接对于 SEO 都很重要。

  • 优化建议:

    • 锚文本 (Anchor Text) : 使用描述性的锚文本,包含目标页面的关键词,而不是通用的“点击这里”。
    • title 属性: (可选) 为链接提供额外的上下文信息。
    • rel="nofollow" : 如果不希望传递权重给某个链接(例如,用户评论中的链接、广告链接),可以使用此属性。
    • rel="noopener"rel="noreferrer" : 当链接使用 target="_blank" 打开新窗口时,出于安全和隐私考虑,建议添加。noopener 防止新页面通过 window.opener 访问原始页面对象,noreferrer 则不发送 Referer HTTP 头。
    • 内部链接策略: 合理规划内部链接,帮助搜索引擎发现深层内容,并传递权重。面包屑导航是好的实践。
<!-- 良好锚文本 -->
<p>了解更多关于我们的<a href="/services/ai-consulting">AI咨询服务</a></p>

<!-- 避免使用 -->
<p>了解更多<a href="/services/ai-consulting">点击这里</a></p>

<!-- Nofollow 示例 (例如,付费链接或不可信链接) -->
<a href="https://example.com/advertisement" rel="nofollow">赞助商链接</a>

<!-- 新窗口打开链接的安全实践 -->
<a href="https://external-site.com" target="_blank" rel="noopener noreferrer">访问外部资源</a>

<!-- 面包屑导航示例 -->
<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="/">首页</a></li>
    <li class="breadcrumb-item"><a href="/products">产品</a></li>
    <li class="breadcrumb-item active" aria-current="page">智能聊天机器人XYZ</li>
  </ol>
</nav>

二、可抓取性与可索引性优化

1. robots.txt 文件

  • 作用: 放在网站根目录的文本文件,用于告知搜索引擎爬虫哪些页面或目录可以抓取,哪些不可以。

  • 位置: https://yourdomain.com/robots.txt

  • 优化建议:

    • 确保其可访问。
    • 正确配置 User-agentDisallow / Allow 指令。
    • 可以指定 Sitemap 的位置。
# robots.txt for https://yourdomain.com

User-agent: * # 表示对所有爬虫生效
Disallow: /admin/          # 禁止抓取 /admin/ 目录下的所有内容
Disallow: /tmp/            # 禁止抓取 /tmp/ 目录
Disallow: /private-page.html # 禁止抓取特定私有页面
Allow: /public/           # 明确允许抓取 /public/ 目录 (如果父目录被Disallow)
Allow: /important-page.html # 明确允许抓取某个重要页面

User-agent: Googlebot     # 仅对 Googlebot 生效的规则
Disallow: /google-specific-ignore/
Crawl-delay: 1           # (可选,部分爬虫支持) 抓取间隔,单位秒,谨慎使用

User-agent: Bingbot
Disallow: /bing-specific-ignore/

# 指定Sitemap位置 (非常重要)
Sitemap: https://yourdomain.com/sitemap.xml
Sitemap: https://yourdomain.com/sitemap_images.xml # 如果有图片站点地图

2. XML 站点地图 (Sitemap)

  • 作用: 一个 XML 文件,列出网站上希望搜索引擎索引的所有重要 URL。帮助搜索引擎更快、更全面地发现网站内容,特别是对于新网站或内容层级较深的网站。

  • 位置: 通常在根目录,并通过 robots.txt 或搜索引擎站长平台提交。

  • 优化建议:

    • 包含所有规范的、可索引的 URL。
    • 保持更新,当有新内容或内容变更时及时更新站点地图。
    • 可以包含 <lastmod> (最后修改时间), <changefreq> (更新频率), <priority> (相对优先级) 等可选标签,但搜索引擎主要关注 URL 和 lastmod
    • 对于大型网站,可以使用站点地图索引文件 (Sitemap Index File) 来管理多个站点地图。
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> <!-- 图片站点地图命名空间 -->

  <url>
    <loc>https://yourdomain.com/</loc>
    <lastmod>2025-05-15T10:00:00+00:00</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://yourdomain.com/products</loc>
    <lastmod>2025-05-10T12:30:00+00:00</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.9</priority>
  </url>
  <url>
    <loc>https://yourdomain.com/products/smart-chatbot</loc>
    <lastmod>2025-05-12T08:45:15+00:00</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
    <!-- 图片站点地图示例 -->
    <image:image>
      <image:loc>https://yourdomain.com/images/smart-chatbot-promo.jpg</image:loc>
      <image:caption>智能聊天机器人XYZ产品宣传图</image:caption>
      <image:geo_location>北京, 中国</image:geo_location>
      <image:title>智能聊天机器人XYZ</image:title>
      <image:license>https://yourdomain.com/image-license</image:license>
    </image:image>
    <image:image>
      <image:loc>https://yourdomain.com/images/smart-chatbot-ui.png</image:loc>
    </image:image>
  </url>
  <url>
    <loc>https://yourdomain.com/blog/frontend-seo-guide</loc>
    <lastmod>2025-05-17T14:20:05+00:00</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>
  <!-- 更多 URL -->

</urlset>

3. 规范化 URL (<link rel="canonical">)

  • 作用: 当网站上存在内容相同或高度相似的多个 URL 时(例如,带 www 和不带 www,HTTP 和 HTTPS,带参数和不带参数的 URL),使用规范标签告诉搜索引擎哪个是“首选”或“官方”版本,以避免重复内容问题并集中权重。

  • 优化建议:

    • 每个可索引的页面都应该有一个指向自身的规范标签(自引用规范标签),或者指向其规范版本的标签。
    • 确保规范 URL 是绝对路径。
<head>
    <!-- 示例1: 页面自身的规范 URL -->
    <link rel="canonical" href="https://yourdomain.com/products/smart-chatbot">

    <!-- 示例2: 如果当前页面是 https://yourdomain.com/products/smart-chatbot?source=ads -->
    <!-- 并且你希望 https://yourdomain.com/products/smart-chatbot 是规范版本 -->
    <link rel="canonical" href="https://yourdomain.com/products/smart-chatbot">

    <!-- 示例3: 移动版页面指向对应的桌面版规范URL (如果内容相同) -->
    <!-- (假设当前是 m.yourdomain.com/product-a) -->
    <!-- <link rel="canonical" href="https://www.yourdomain.com/product-a"> -->
    <!-- 同时,桌面版页面应有: <link rel="alternate" media="only screen and (max-width: 640px)" href="https://m.yourdomain.com/product-a"> -->
    <!-- 对于响应式设计,通常是自引用规范标签 -->
</head>

4. 处理单页面应用 (SPA) 的 SEO

  • 挑战: 传统 SPA(如 React, Vue, Angular 构建的应用)通常在客户端渲染内容,初始 HTML 可能只有一个空的 <div>。这对于某些搜索引擎爬虫来说难以抓取和索引内容。

  • 解决方案:

    • 服务器端渲染 (SSR - Server-Side Rendering) : 在服务器上渲染完整的 HTML 页面并发送给浏览器(和爬虫)。Next.js (React), Nuxt.js (Vue), Angular Universal 是流行的 SSR 框架。
    • 预渲染 (Pre-rendering) : 在构建时为特定路由生成静态 HTML 文件。适用于内容不经常变化的页面。可以使用 react-snap, prerender-spa-plugin 等工具。
    • 动态渲染 (Dynamic Rendering) : 检测请求是否来自爬虫,如果是,则返回一个服务器端渲染或预渲染的版本;如果是普通用户,则返回客户端渲染的 SPA。需要配置服务器或使用第三方服务。
    • History API: 确保 SPA 使用 HTML5 History API (pushState, replaceState) 来管理路由,并为每个“页面”提供唯一的、可分享的 URL。服务器需要配置为将所有相关路由都指向应用的入口 HTML 文件。

SSR/预渲染概念 (不展示完整框架代码,仅示意)

  • Next.js (React) 页面文件 (pages/products/[id].js) :
// pages/products/[id].js (Next.js 示例)
import Head from 'next/head';

function ProductPage({ product }) {
  if (!product) {
    return <div>Loading... or Product not found</div>;
  }

  return (
    <div>
      <Head>
        <title>{product.name} - My E-commerce Site</title>
        <meta name="description" content={product.description} />
        <link rel="canonical" href={`https://yourdomain.com/products/${product.id}`} />
        {/* 其他 meta 标签, JSON-LD 结构化数据等 */}
        <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(product.jsonLdData) }} />
      </Head>
      <h1>{product.name}</h1>
      <img src={product.imageUrl} alt={product.name} />
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      {/* ...更多产品详情... */}
    </div>
  );
}

// getServerSideProps 用于 SSR: 在每次请求时在服务器端获取数据
export async function getServerSideProps(context) {
  const { id } = context.params;
  // 假设 fetchProductData 是一个从API或数据库获取产品数据的函数
  const productData = await fetchProductData(id);

  if (!productData) {
    return { notFound: true }; // 返回404页面
  }

  // 准备 JSON-LD 数据
  const jsonLdData = {
    "@context": "https://schema.org/",
    "@type": "Product",
    "name": productData.name,
    "image": productData.imageUrl,
    "description": productData.description,
    "sku": productData.sku,
    "mpn": productData.mpn,
    "brand": {
      "@type": "Brand",
      "name": productData.brandName
    },
    "offers": {
      "@type": "Offer",
      "url": `https://yourdomain.com/products/${productData.id}`,
      "priceCurrency": "USD",
      "price": productData.price,
      "availability": productData.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
      "seller": {
        "@type": "Organization",
        "name": "My E-commerce Site"
      }
    }
    // 可以添加评论 (aggregateRating, review) 等
  };

  return {
    props: {
      product: {
        ...productData,
        jsonLdData // 将 JSON-LD 数据传递给组件
      },
    },
  };
}

// 模拟数据获取函数
async function fetchProductData(id) {
  // 在实际应用中,这里会调用 API
  console.log(`Fetching product data for ID: ${id} on server...`);
  // 模拟 API 延迟
  await new Promise(resolve => setTimeout(resolve, 100));
  const sampleProducts = {
    "123": { id: "123", name: "Awesome Gadget", description: "The most awesome gadget ever.", price: "99.99", imageUrl: "/images/gadget.jpg", sku: "AG-001", mpn: "98765", brandName: "GadgetCorp", inStock: true },
    "456": { id: "456", name: "Super Widget", description: "A truly super widget for all your needs.", price: "49.50", imageUrl: "/images/widget.jpg", sku: "SW-002", mpn: "54321", brandName: "WidgetCo", inStock: false },
  };
  return sampleProducts[id] || null;
}

export default ProductPage;
  • Prerender SPA Plugin (webpack.config.js 示例) :
// webpack.config.js (示意,具体配置依项目而定)
const path = require('path');
const PrerenderSPAPlugin = require('prerender-spa-plugin');
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer; // 使用 Puppeteer 进行渲染

module.exports = {
  // ...其他 webpack 配置
  plugins: [
    // ...其他插件
    new PrerenderSPAPlugin({
      staticDir: path.join(__dirname, 'dist'), // 构建输出目录
      routes: [ '/', '/about', '/products/123', '/products/456' ], // 需要预渲染的路由列表
      renderer: new Renderer({
        // 可选配置:
        // injectProperty: '__PRERENDER_INJECTED',
        // inject: { foo: 'bar' },
        renderAfterDocumentEvent: 'render-event', // 等待自定义事件触发后再渲染
        // renderAfterTime: 5000, // 等待固定时间后渲染
        // headless: false, // (调试用) 显示浏览器窗口
      }),
      postProcess (renderedRoute) {
        // 可选: 对渲染后的 HTML 进行后处理
        // 例如,移除不必要的脚本标签
        renderedRoute.html = renderedRoute.html.replace(/<script.*?src=".*?bundle.js.*?></script>/i, '');
        return renderedRoute;
      }
    })
  ]
};

注意: 上述 Next.js 和 prerender-spa-plugin 的代码是概念性的,实际实现会更复杂。

三、结构化数据 (Structured Data / Schema Markup)

  • 作用: 使用 Schema.org 词汇表以 JSON-LD (推荐)、Microdata 或 RDFa 格式向搜索引擎提供关于页面内容的明确信息。这可以帮助搜索引擎更好地理解内容,并可能在搜索结果中显示丰富的摘要 (Rich Snippets),从而提高可见性和点击率。

  • 优化建议:

    • 为适用的内容类型添加结构化数据(例如文章、产品、食谱、事件、FAQ、本地商家、面包屑等)。
    • 使用 Google 的结构化数据测试工具进行验证。

JSON-LD 示例:

  1. 文章 (Article / BlogPosting) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "BlogPosting", // 或 "Article", "NewsArticle"
      "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "https://yourdomain.com/blog/frontend-seo-guide"
      },
      "headline": "前端SEO深度解析:原理与代码实践",
      "description": "本文详细介绍了前端SEO的各项优化策略,包括HTML标签、可抓取性、结构化数据、性能优化等,并提供了丰富的代码示例。",
      "image": [
        "https://yourdomain.com/images/seo-guide-banner.jpg",
        "https://yourdomain.com/images/seo-guide-social.png"
       ],
      "author": {
        "@type": "Person", // 或 "Organization"
        "name": "张三",
        "url": "https://yourdomain.com/authors/zhangsan"
      },
      "publisher": {
        "@type": "Organization",
        "name": "XX公司博客",
        "logo": {
          "@type": "ImageObject",
          "url": "https://yourdomain.com/images/logo.png",
          "width": 600,
          "height": 60
        }
      },
      "datePublished": "2025-05-17T09:00:00+08:00",
      "dateModified": "2025-05-18T10:30:00+08:00",
      "articleSection": "技术分享",
      "keywords": "SEO, 前端, 优化, 搜索引擎优化, JavaScript SEO"
    }
    </script>
</head>
  1. 产品 (Product) : (已在 Next.js 示例中包含)
  2. FAQ 页面 (FAQPage) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "FAQPage",
      "mainEntity": [{
        "@type": "Question",
        "name": "什么是前端SEO?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "前端SEO是指在网站的前端开发过程中,通过一系列技术和策略优化,使得网站内容更容易被搜索引擎发现、抓取、理解和索引,从而提升在搜索结果中的自然排名。"
        }
      }, {
        "@type": "Question",
        "name": "服务器端渲染 (SSR) 对SEO有什么好处?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "SSR可以确保搜索引擎爬虫在首次请求时就能获取到完整的HTML内容,避免了因客户端JavaScript执行问题导致的内容抓取不全,从而显著改善SPA(单页面应用)的SEO效果。"
        }
      }, {
        "@type": "Question",
        "name": "如何选择合适的图片格式进行SEO优化?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "选择图片格式时应考虑图片内容和压缩效率。JPEG适合照片类图像,PNG适合需要透明背景或颜色较少的图形,WebP则通常能在保证质量的前提下提供比JPEG和PNG更小的文件体积,有利于页面加载速度,对SEO有积极影响。确保为所有图片提供描述性的alt文本。"
        }
      }]
    }
    </script>
</head>
  1. 面包屑 (BreadcrumbList) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "BreadcrumbList",
      "itemListElement": [{
        "@type": "ListItem",
        "position": 1,
        "name": "首页",
        "item": "https://yourdomain.com/"
      },{
        "@type": "ListItem",
        "position": 2,
        "name": "产品中心",
        "item": "https://yourdomain.com/products"
      },{
        "@type": "ListItem",
        "position": 3,
        "name": "智能聊天机器人XYZ"
        // "item" is optional for the last item if it's the current page
      }]
    }
    </script>
</head>
  1. 本地商家 (LocalBusiness) :
<head>
    <!-- ...其他 meta ... -->
    <script type="application/ld+json">
    {
      "@context": "https://schema.org",
      "@type": "Restaurant", // 或 "Store", "Dentist", etc.
      "name": "美味中餐厅",
      "image": "https://yourdomain.com/images/restaurant-front.jpg",
      "@id": "https://yourdomain.com/restaurant-beijing", // 可选,页面的规范URL
      "url": "https://yourdomain.com/restaurant-beijing",
      "telephone": "+86-10-12345678",
      "priceRange": "$$", // 或 "¥¥", "€€"
      "menu": "https://yourdomain.com/menu",
      "servesCuisine": "中餐",
      "acceptsReservations": "True",
      "address": {
        "@type": "PostalAddress",
        "streetAddress": "朝阳区建国路123号",
        "addressLocality": "北京市",
        "addressRegion": "北京",
        "postalCode": "100022",
        "addressCountry": "CN"
      },
      "geo": {
        "@type": "GeoCoordinates",
        "latitude": 39.9042,
        "longitude": 116.4074
      },
      "openingHoursSpecification": [{
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": [
          "Monday",
          "Tuesday",
          "Wednesday",
          "Thursday",
          "Friday"
        ],
        "opens": "11:00",
        "closes": "22:00"
      },{
        "@type": "OpeningHoursSpecification",
        "dayOfWeek": [
          "Saturday",
          "Sunday"
        ],
        "opens": "10:00",
        "closes": "23:00"
      }],
      "aggregateRating": { // 如果有用户评分
        "@type": "AggregateRating",
        "ratingValue": "4.5",
        "reviewCount": "250"
      }
    }
    </script>
</head>

四、性能优化 (Core Web Vitals & Page Speed)

页面加载速度和用户体验是重要的排名因素。Google 的核心 Web 指标 (Core Web Vitals) 包括:

  • LCP (Largest Contentful Paint) : 最大内容绘制时间,衡量加载性能。目标:2.5 秒以内。
  • FID (First Input Delay) / INP (Interaction to Next Paint) : 首次输入延迟 / 下一次绘制交互,衡量交互性。FID 目标:100 毫秒以内。INP 是 FID 的演进,目标:200 毫秒以内。
  • CLS (Cumulative Layout Shift) : 累积布局偏移,衡量视觉稳定性。目标:0.1 以内。

前端优化措施:

  1. 优化图片: 已在前述提及(压缩、响应式、WebP、懒加载)。

  2. 代码压缩与精简 (Minification & Uglification) :

    • 使用 Webpack, Rollup, Parcel 等构建工具压缩 HTML, CSS, JavaScript 文件。
    • 移除未使用的 CSS 和 JavaScript (Tree Shaking)。
    // webpack.config.js (部分示例)
    const TerserPlugin = require('terser-webpack-plugin');
    const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
    
    module.exports = {
      // ...
      optimization: {
        minimize: true,
        minimizer: [
          new TerserPlugin({ // 压缩 JS
            terserOptions: {
              compress: {
                drop_console: true, // 生产环境移除 console
              },
            },
          }),
          new CssMinimizerPlugin(), // 压缩 CSS
        ],
        splitChunks: { // 代码分割
          chunks: 'all',
        },
      },
      // ...
    };
    
  3. 启用 Gzip 或 Brotli 压缩: 在服务器端配置,减小传输文件大小。

  4. 浏览器缓存 (Browser Caching) :

    • 通过 HTTP 头部(Cache-Control, Expires, ETag)指示浏览器缓存静态资源。
    • Service Worker 缓存:PWA 的核心技术,可以实现更精细的缓存控制和离线访问。
    // service-worker.js (非常简化的缓存优先策略示例)
    const CACHE_NAME = 'my-site-cache-v1';
    const urlsToCache = [
      '/',
      '/styles/main.css',
      '/scripts/main.js',
      '/images/logo.png'
      // ...其他核心资源
    ];
    
    self.addEventListener('install', event => {
      event.waitUntil(
        caches.open(CACHE_NAME)
          .then(cache => {
            console.log('Opened cache');
            return cache.addAll(urlsToCache);
          })
      );
    });
    
    self.addEventListener('fetch', event => {
      event.respondWith(
        caches.match(event.request)
          .then(response => {
            // 缓存命中 - 返回响应
            if (response) {
              return response;
            }
            // 未命中 - 从网络获取,并添加到缓存
            return fetch(event.request).then(
              networkResponse => {
                if(!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') {
                  return networkResponse;
                }
                const responseToCache = networkResponse.clone();
                caches.open(CACHE_NAME)
                  .then(cache => {
                    cache.put(event.request, responseToCache);
                  });
                return networkResponse;
              }
            );
          })
      );
    });
    
    self.addEventListener('activate', event => {
      const cacheWhitelist = [CACHE_NAME]; // 新版本缓存名
      event.waitUntil(
        caches.keys().then(cacheNames => {
          return Promise.all(
            cacheNames.map(cacheName => {
              if (cacheWhitelist.indexOf(cacheName) === -1) {
                return caches.delete(cacheName); // 删除旧缓存
              }
            })
          );
        })
      );
    });
    

    注册 Service Worker (在主 JS 文件中):

    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
        navigator.serviceWorker.register('/service-worker.js')
          .then(registration => {
            console.log('ServiceWorker registration successful with scope: ', registration.scope);
          })
          .catch(error => {
            console.log('ServiceWorker registration failed: ', error);
          });
      });
    }
    
  5. 使用 CDN (Content Delivery Network) : 将静态资源分发到离用户更近的服务器,加快加载速度。

  6. 减少 HTTP 请求: 合并 CSS 和 JS 文件(构建工具会自动处理),使用 CSS Sprites 或 SVG Sprites。

  7. 优化关键渲染路径 (Critical Rendering Path) :

    • 内联关键 CSS (Above-the-fold CSS) 到 HTML <head> 中,以快速渲染首屏内容。
    • 异步加载非关键 CSS 和 JavaScript (<link rel="preload" as="style">, <script async>, <script defer>)。
    <head>
        <style>
            /* critical-path.css - 内联的关键CSS */
            body { font-family: sans-serif; margin: 0; }
            .header { background-color: #333; color: white; padding: 1em; }
            /* ...更多首屏渲染必需的样式... */
        </style>
        <!-- 异步加载剩余CSS -->
        <link rel="preload" href="styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
        <noscript><link rel="stylesheet" href="styles/main.css"></noscript>
    </head>
    <body>
        <!-- ...内容... -->
        <script src="scripts/main-bundle.js" defer></script>
        <script src="scripts/analytics.js" async></script>
    </body>
    
  8. 字体优化:

    • 使用 Web 字体时,确保字体文件尽可能小。
    • 使用 font-display: swap;font-display: optional; 避免 FOIT (Flash of Invisible Text)。
    • 预加载关键字体文件 (<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>)。

五、移动友好性与可访问性 (Mobile-Friendliness & Accessibility - A11y)

  1. 响应式网页设计 (Responsive Web Design - RWD) : 确保网站在各种设备(桌面、平板、手机)上都有良好的显示和用户体验。使用媒体查询 (@media)。

    /* 基础样式 */
    .container { width: 90%; margin: 0 auto; }
    .column { float: left; width: 100%; margin-bottom: 1em; }
    
    /* 中等屏幕 (例如平板) */
    @media (min-width: 768px) {
      .container { width: 80%; }
      .column.half { width: 50%; padding: 0 1%; box-sizing: border-box; }
    }
    
    /* 大屏幕 (例如桌面) */
    @media (min-width: 1024px) {
      .container { width: 1200px; max-width: 90%; }
      .column.third { width: 33.33%; padding: 0 1%; box-sizing: border-box; }
      .column.two-thirds { width: 66.66%; padding: 0 1%; box-sizing: border-box; }
    }
    

    Viewport Meta 标签 (必须) :

    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    
  2. 可访问性 (A11y) :

    • 虽然不直接是排名因素,但良好的可访问性通常与良好的用户体验相关,并可能间接影响SEO。
    • 使用语义化 HTML。
    • 为图片提供 alt 文本。
    • 确保键盘可导航。
    • 足够的颜色对比度。
    • 使用 ARIA (Accessible Rich Internet Applications) 属性增强动态内容的可访问性(谨慎使用,优先使用原生 HTML 语义)。
    <!-- ARIA 示例: 一个可折叠区域 -->
    <button aria-expanded="false" aria-controls="collapsible-section" id="toggle-button">
      显示详情
    </button>
    <div id="collapsible-section" hidden>
      <p>这里是详细内容...</p>
    </div>
    
    <script>
      const toggleButton = document.getElementById('toggle-button');
      const collapsibleSection = document.getElementById('collapsible-section');
      toggleButton.addEventListener('click', () => {
        const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true' || false;
        toggleButton.setAttribute('aria-expanded', !isExpanded);
        collapsibleSection.hidden = isExpanded;
        toggleButton.textContent = isExpanded ? '显示详情' : '隐藏详情';
      });
    </script>
    

六、其他重要前端SEO注意事项

  1. HTTPS: 必须使用 HTTPS,Google 将其作为排名信号。

  2. 避免内容隐藏技巧 (Cloaking) : 不要向搜索引擎爬虫显示与用户看到的不同内容。

  3. 处理404错误: 创建一个友好的自定义404页面,并确保服务器对不存在的页面返回正确的404状态码。

    <!-- custom-404.html -->
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <title>页面未找到 (404) - XX公司</title>
        <meta name="robots" content="noindex"> <!-- 404页面不应被索引 -->
        <style>
            body { font-family: sans-serif; text-align: center; padding-top: 50px; }
            h1 { font-size: 3em; }
            p { font-size: 1.2em; }
            a { color: #007bff; text-decoration: none; }
        </style>
    </head>
    <body>
        <h1>:( 404 - 页面未找到</h1>
        <p>抱歉,您要查找的页面不存在或已被移动。</p>
        <p>您可以尝试:</p>
        <ul>
            <li><a href="/">返回首页</a></li>
            <li>使用站内搜索</li>
            <li>检查您输入的网址是否正确</li>
        </ul>
    </body>
    </html>
    

    服务器配置 (例如 Nginx):

    server {
        # ...
        error_page 404 /custom-404.html;
        location = /custom-404.html {
            root /path/to/your/html/files; # 指向你的404文件所在目录
            internal;
        }
        # ...
    }
    
  4. 301重定向: 对于永久移动的页面,使用301(永久重定向)将旧URL指向新URL,以传递权重。

  5. 国际化SEO (hreflang) : 如果网站有多种语言或地区版本,使用 hreflang 标签告知搜索引擎不同版本之间的关系。

    <head>
        <!-- ... -->
        <link rel="alternate" hreflang="en" href="https://yourdomain.com/en/page.html" />
        <link rel="alternate" hreflang="de" href="https://yourdomain.com/de/page.html" />
        <link rel="alternate" hreflang="zh-CN" href="https://yourdomain.com/zh/page.html" />
        <link rel="alternate" hreflang="x-default" href="https://yourdomain.com/en/page.html" /> <!-- 默认版本 -->
    </head>
    

    也可以在 XML 站点地图或 HTTP 头部中实现 hreflang

  6. 避免使用 Flash 或 Silverlight 等过时技术: 搜索引擎难以解析其内容。

  7. JavaScript 链接: 如果使用 JavaScript 生成链接 (例如 window.location.href 或框架的路由跳转),确保它们最终也呈现为带有 href 属性的标准 <a> 标签,以便爬虫能够发现和跟踪。

    // 不太好的方式 (爬虫可能不执行JS或不识别为链接)
    // <div onclick="navigateTo('/product/123')">产品123</div>
    
    // 更好的方式 (即使JS失效或爬虫不执行JS,仍是可访问链接)
    // <a href="/product/123" onclick="handleNavigation(event, '/product/123')">产品123</a>
    // function handleNavigation(event, url) {
    //   event.preventDefault(); // 阻止默认跳转
    //   // 使用JS进行路由跳转 (例如,React Router, Vue Router)
    //   router.push(url);
    // }
    

总结

前端 SEO 是一个持续优化的过程,涉及技术细节、内容策略和用户体验的方方面面。核心在于理解搜索引擎的工作原理,并为用户和爬虫都提供最佳的体验和最清晰的信息。建议结合使用 Google Search Console, Bing Webmaster Tools, Lighthouse, PageSpeed Insights 等工具来监控和改进你的 SEO 效果。

【开源软件推荐】 so-novel,一个超强的聚合式小说下载工具

作者 极客密码
2025年5月18日 16:34

📚 前言:网文阅读的困境与解法

你是否曾遇到这样的困扰:想看最新连载小说,要么被迫付费,要么忍受广告横飞的笔趣阁,要么被限制在特定平台的APP中?

虽然市面上已有不少阅读方案,但总有些难以满足的需求:有人嫌弃界面设计太丑,有人吐槽功能不够完善,还有人厌倦了被特定平台限制。在这种情况下,高度可定制化的阅读体验成为了许多书友的终极追求。

今天要介绍的"So Novel"正是为解决这些痛点而生的利器。它能够将全网流行小说(包括连载中和已完本的)一键下载为主流电子书格式,让你可以在任何喜爱的设备上实现真正的离线阅读体验。

🎯 谁适合使用So Novel?

  • 免费(白嫖)看正版网文 ,又追求 极致阅读体验 的资深书友
  • 习惯用手机阅读的iOS用户(由于无法使用"阅读"APP,可通过So Novel下载后导入Apple Books)
  • 喜欢在电脑或大屏设备上阅读的Windows、macOS、Linux用户
  • 钟爱专业电子书阅读器的发烧友
  • 讨厌在线阅读依赖性,习惯将书籍存储在本地的"囤书党"
  • 经常处于无网络环境需要离线阅读的通勤党
  • 追求"开箱即用",不想折腾复杂配置的普通用户

✨ So Novel 介绍

So Novel是一款交互式小说下载器,真正做到了跨平台、无门槛使用:

  • 🖥️ 跨系统支持:windows、maacos、linux皆可使用
  • 📦 开箱即用:下载解压后即可使用,无需任何额外配置
  • 🔍 多源聚合:内置多个书源,支持聚合搜索,无需手动切换
  • 🚀 极速下载:特别优化无限流书源的下载速度
  • 📑 多格式支持:可导出EPUB、TXT、HTML(支持翻页)、PDF等格式
  • 📚 批量下载:同时下载多部作品,效率翻倍
  • 🔄 簡繁转换:内置简繁互转功能,满足不同阅读习惯

如何下载

在 so-novel 官方 Github Releases 下载对应操作系统版本即可:

github.com/freeok/so-n…

注:如果访问不到Github或者下载很慢,这里同时也提供给百度网盘的下载链接:

链接: pan.baidu.com/s/1EfH5_nMT…
提取码: xfdm

我这里是 macos intel芯片,下载 sonovel-macos_x64.tar.gz 就可以,M系列芯片的下载 arm64 版本的

安装使用

下载后将压缩包解压

直接将当前目录拖到 终端 里去(windows 版本的解压后有个 exe 文件,直接双击就可以打开)

然后输入如下命令后,按回车

bash ./run-macos.sh

您可能会遇到这样一个无法验证开发者的警告信息,不用管它,点击取消:

点击 系统设置,找到 隐私与安全性

找到安全性,并改为 APP Store和被认可的开发者

点击仍然允许

再次回到终端,执行上次的命令回车即可打开。

接下来就是按提示输入序号,按回车就行了

需要聚合搜索,输入 q ,按回车,输入书名,按回车:

输入下载序号,按回车:

输入1 下载全本

下载完成后,会自动合并为 epub 格式

合并成功后的 epub 文件位于软件目录下的 downloads 文件夹

导入喜欢的阅读软件,开始享受阅读的乐趣吧!

闲着周末写了一个react vue3双版本的右键菜单插件

作者 codefishsss
2025年5月18日 16:17

前言

在Web应用开发中,右键菜单是一个常见的功能。今天,我要分享一个我开发的跨框架右键菜单插件 context-menu-plugin,它不仅支持Vue 3和React,还具备了丰富的功能特性和优秀的交互体验。

核心特性

1. 跨框架支持

这个插件最大的特点是同时支持Vue 3和React两大主流框架。通过抽象共享逻辑和样式,实现了框架无关的核心功能,同时又保持了框架特定的最佳实践。

// Vue 3 使用示例
<template>
<div v-context-menu="menuOptions"> 右键点击此区域 </div> </template> 

// React 使用示例
const [contextMenu] = useContextMenu(); 
<div onContextMenu={contextMenu}> 右键点击此区域 </div>

2. 丰富的功能支持

多级子菜单

支持无限层级的子菜单,每个子菜单都继承父菜单的主题和样式。

主题支持

内置四种主题:

  • Light(默认):清爽的白色主题

  • Dark:深色主题

  • Blue:蓝色主题

  • Green:绿色主题

通过CSS变量实现主题切换,方便用户自定义:

:root {
--cm-light-bg: #ffffff;
--cm-light-text: #333333;
--cm-light-hover: #f5f5f5;
--cm-light-border: #e8e8e8;
}

完整的键盘支持

  • ↑/↓:导航菜单项

  • →:打开子菜单

  • ←:返回上级菜单

  • Enter/Space:选择菜单项

  • Esc:关闭菜单

3. 优秀的交互体验

智能定位

自动检测视口边界,确保菜单始终在可见区域内显示:

// 位置计算逻辑
const position = { x: Math.min(event.clientX, window.innerWidth - menuWidth), y: Math.min(event.clientY, window.innerHeight - menuHeight) };

平滑动画

使用CSS动画提供流畅的过渡效果:

.context-menu { 
animation-duration: var(--cm-animation-duration); animation-name: cmFadeIn;
} 
@keyframes cmFadeIn { 
from { opacity: 0; transform: scale(0.95); } 
to { opacity: 1; transform: scale(1); } }

4. 可访问性支持

遵循WCAG准则,确保菜单对所有用户都可用:

<div role="menu" aria-orientation="vertical" aria-label="上下文菜单" > <div role="menuitem" aria-disabled={disabled} tabIndex={0} > 菜单项 </div>

5. 响应式设计

自动适配移动设备:

@media (max-width: 768px) { .context-menu { min-width: 160px; font-size: 16px; // 更大的字体 .cm-item { padding: 10px 16px; // 更大的点击区域 } } }

技术实现亮点

1. Portal/Teleport技术

为了避免菜单被父元素的overflowz-index影响,使用Portal/Teleport将菜单渲染到body层级:

// React版本
return ReactDOM.createPortal( <ContextMenu />, document.body ); 

// Vue 3版本
<Teleport to="body"> <ContextMenu /> </Teleport>

2. 共享核心逻辑

通过抽象共享代码,确保两个框架版本的行为一致:

src/ 
├── shared/ 
│   ├── contextAware.js  // 上下文感知逻辑 
│   ├── i18n.ts         // 国际化支持 
│   ├── types.ts       // TypeScript类型定义
│   └── utils.js      // 通用工具函数 
├── react/           // React实现 
└── vue3/           // Vue 3实现

3. 性能优化

  • 使用防抖处理窗口调整事件
  • 使用事件委托处理菜单项点击
  • 懒加载子菜单内容

使用方法

安装

Vue 3使用示例

<script setup>
import { useContextMenu } from 'context-menu-plugin/vue3'; 
const menuOptions = { 
items: [ 
{ label: '新建', icon: '📄', 
children: [ 
{ label: '文件', onClick: () => {} },
{ label: '文件夹', onClick: () => {} } ] }, 
{ type: 'separator' }, 
{ label: '刷新', shortcut: 'Ctrl+R', onClick: () => {} } ] }; 
</script> 
<template> 
<div v-context-menu="menuOptions"> 右键点击此区域 </div> 
</template>

React使用示例

import { useContextMenu } from 'context-menu-plugin/react'; 
function App() { 
const [contextMenu] = useContextMenu({ 
items: [ 
{ label: '新建', icon: '📄', children: [ { label: '文件', onClick: () => {} }, 
{ label: '文件夹', onClick: () => {} } ] }, 
{ type: 'separator' }, 
{ label: '刷新', shortcut: 'Ctrl+R', onClick: () => {} } ] }); 
return ( <div onContextMenu={contextMenu}> 右键点击此区域 </div> ); 
}

总结

这个右键菜单插件通过跨框架支持、丰富的功能特性、优秀的交互体验和完善的可访问性支持,为Web应用开发提供了一个强大而灵活的解决方案。无论是Vue 3还是React项目,都能轻松集成并提供一致的用户体验。

未来计划

  1. 添加更多主题预设
  2. 支持更多自定义选项
  3. 添加更多动画效果
  4. 优化移动端体验
  5. 添加更多辅助功能支持

JavaScript执行栈和执行上下文

2025年5月18日 16:16

在JavaScript中,执行栈和执行上下文是理解代码执行流程和作用域链的关键概念。它们决定了代码如何执行以及变量和函数如何被查找和访问。本文将详细介绍执行上下文的生命周期、执行栈的工作原理以及它们在实际编程中的应用。

一、执行上下文

(一)什么是执行上下文?

执行上下文(Execution Context)是JavaScript代码执行的环境。它是一个抽象的概念,用于描述代码在运行时的状态。每当JavaScript代码运行时,它都在某个执行上下文中运行。

(二)执行上下文的类型

JavaScript中有三种执行上下文类型:

  1. 全局执行上下文:这是默认的上下文,任何不在函数内部的代码都在全局上下文中运行。全局执行上下文在页面加载时创建,当页面关闭时销毁。
  2. 函数执行上下文:每当一个函数被调用时,都会为该函数创建一个新的执行上下文。函数执行上下文在函数调用时创建,函数执行完成后销毁。
  3. eval函数执行上下文eval函数内部的代码也有自己的执行上下文。不过,eval函数的使用并不推荐,因为它会带来安全问题和性能问题。

(三)执行上下文的生命周期

执行上下文的生命周期分为两个阶段:

  1. 创建阶段:当代码执行进入一个环境时,会创建一个执行上下文。在这个阶段,执行上下文会进行以下操作:

    • 创建变量对象(Variable Object,VO):包括函数的形参、arguments对象、函数声明和变量声明。
    • 确定this的指向。
    • 确定作用域链。
  2. 执行阶段:在执行阶段,代码开始执行,变量被赋值,函数被调用,其他代码按顺序执行。

二、执行栈

(一)什么是执行栈?

执行栈(Call Stack)是JavaScript运行时用来管理执行上下文的一种数据结构。它是一个后进先出(LIFO)的栈结构,用于跟踪函数调用的顺序。

(二)执行栈的工作原理

  1. 入栈:当代码执行进入一个新的环境时,对应的执行上下文会被推入执行栈中。
  2. 出栈:当函数执行完成时,对应的执行上下文会被从执行栈中弹出,控制权交由下一个执行上下文。

(三)执行栈的特点

  • 后进先出:最后进入执行栈的执行上下文最先被弹出。
  • 栈顶是当前执行的上下文:执行栈的栈顶总是当前正在执行的函数的执行上下文。

(四)执行栈的图解

以下是一个具体的代码示例及其对应的执行栈图解:

function foo() { 
    function bar() {        
        return 'I am bar';
    }
    return bar();
}
foo();

对应的执行栈图解如下:

执行栈图解

(五)执行栈的数量限制

虽然执行上下文的数量没有明确的限制,但如果超出栈分配的空间,会造成堆栈溢出。常见于递归调用,没有终止条件造成死循环的场景。

// 递归调用自身
function foo() {
    foo();
}
foo();
// 报错:Uncaught RangeError: Maximum call stack size exceeded

三、执行上下文的生命周期

(一)创建阶段

在创建阶段,执行上下文会进行以下操作:

  1. 创建变量对象(VO)

    • 确定函数的形参(并赋值)。
    • 初始化arguments对象(并赋值)。
    • 确定普通字面量形式的函数声明(并赋值)。
    • 变量声明,函数表达式声明(未赋值)。
  2. 确定this的指向this的值由调用者决定。

  3. 确定作用域:由词法环境决定,哪里声明定义,就在哪里确定。

(二)执行阶段

在执行阶段,执行上下文会进行以下操作:

  1. 变量对象赋值

    • 变量赋值。
    • 函数表达式赋值。
  2. 调用函数

  3. 顺序执行其他代码

四、变量对象

变量对象(Variable Object,VO)是执行上下文的一个重要组成部分,它是一个包含变量、函数声明和形参的对象。在创建阶段,变量对象会被初始化,包括以下内容:

  • arguments对象:包含函数调用时传入的参数。
  • 形参:函数的形参会被赋值。
  • 函数声明:函数声明会被提升并赋值。
  • 变量声明:变量声明会被提升,但未赋值。

(一)变量对象的示例

以下是一个具体的代码示例及其对应的变量对象:

const foo = function(i) {
    var a = "Hello";
    var b = function privateB() {};
    function c() {}
}
foo(10);

在创建阶段,变量对象如下:

fooExecutionContext = {
    variableObject: {
        arguments: {0: 10, length: 1}, // 确定Arguments对象
        i: 10, // 确定形参
        c: pointer to function c(), // 确定函数引用
        a: undefined, // 局部变量初始值为undefined
        b: undefined // 局部变量初始值为undefined
    },
    scopeChain: {},
    this: {}
}

在执行阶段,变量对象如下:

fooExecutionContext = {
    variableObject: {
        arguments: {0: 10, length: 1},
        i: 10,
        c: pointer to function c(),
        a: "Hello", // a变量被赋值为Hello
        b: pointer to function privateB() // b变量被赋值为privateB()函数
    },
    scopeChain: {},
    this: {}
}

五、总结

执行上下文和执行栈是JavaScript中非常重要的概念。理解它们的工作原理和生命周期,可以帮助你更好地理解代码的执行流程和作用域链。

变量声明需谨慎!!!💣这几种声明变量的方式(var、let、const)还有作用域,绝不能含糊!

2025年5月18日 15:56

引言

JavaScript作为一门动态脚本语言,其变量声明机制和作用域规则一直是我们需要深入理解的核心内容。从早期的var到ES6引入的letconst,JavaScript的变量管理方式经历了显著的变化。今天,我们从多个角度,全面解析varletconst的异同。

一、JS代码的执行机制

对于给定的JS代码文件,首先要做的是将其从硬盘读入内存,然后开始执行。JavaScript代码的执行依赖于引擎,如Chrome的V8引擎。V8负责将代码从硬盘读入内存后,进行解析、编译和优化。其核心流程分为两个阶段:

  • 编译阶段:引擎对代码进行词法分析、语法分析,并确定作用域规则。
  • 执行阶段:逐行执行代码,处理变量赋值、函数调用等操作。

二、作用域与作用域链

2.1 作用域的类型

作用域是变量和函数的可访问性规则,JavaScript中分为三类:

  1. 全局作用域:在函数或代码块外声明的变量,全局可访问。
  2. 函数作用域:在函数内部声明的变量,仅函数内可见。
  3. 块级作用域(ES6新增):由{}包裹的代码块(如iffor),使用letconst声明的变量仅块内有效。

2.2 作用域链的运作机制

当访问一个变量时,引擎会按照作用域链逐层查找:

当前作用域 → 父级作用域1 → 父级作用域2 → ... → 全局作用域
(ps:父作用域可嵌套)

这种链式结构确保了变量的层级隔离性。

function outer() {
    let a = 10;
    function inner() {
        console.log(a); // 通过作用域链找到outer的a
    }
    inner();
}
outer(); // 输出10

image.png

三、变量提升(Hoisting)

3.1 var的变量提升

在编译阶段,var声明的变量会被提升到作用域顶部,并初始化为undefined,而赋值操作保留在执行阶段。

示例

console.log(x); // undefined
var x = 5;
console.log(x); // 5

等效于:

var x; // 提升声明
console.log(x); // undefined
x = 5; // 赋值
console.log(x); // 5

3.2 let的变量提升

let声明的变量也会被提升到其所在的作用域顶部,但与var不同,let声明的变量在初始化之前会进入一个“暂时性死区”(Temporal Dead Zone, TDZ)。在这个区域内,变量是“提升”了,但尚未初始化,因此不能被访问,任何尝试访问这些变量的操作都会抛出[ReferenceError]

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;

3.3 函数声明提升

函数声明整体被提升,提升的是定义,而不是调用,因此可以在声明前调用:

showName() // 驼峰式命名
console.log(myName);

var myName = 'wym'
function showName() {
  let b = 2;
  console.log(myName)
  console.log('函数执行了')
}

运行结果:

image.png 解析:这段代码等效于

function showName() { 
  let b = 2; 
  console.log(myName) 
  console.log('函数执行了') 
}

showName()
console.log(myName)
var myName = 'wym'

注意:函数和变量之间,函数通常先于变量提升,即:优先提升函数,后提升变量。

四、var、let、const的全面对比

4.1 作用域差异

关键字 作用域 重复声明 变量提升 必须初始化
var 函数/全局 允许
let 块级 不允许 否(TDZ)
const 块级 不允许 否(TDZ)

4.2 使用场景分析

  • var:ES6之前的主要声明方式,因作用域和提升问题,现不推荐使用。
  • let:适用于需要重新赋值的块级变量(如循环计数器)。
  • const:声明常量或引用类型(对象、数组),确保变量指向不变。

五、建议

定义变量时如果后续不需要修改了,建议优先使用const,提高代码可读性和安全性;次选let,杜绝重复声明报错,作用域更安全,其独特的TDZ机制也能够有效防止在声明前访问变量;var能不用就不用,防止变量污染和意外覆盖。

React 中的 Immutable

2025年5月18日 15:41

React 中的 Immutable 概念

Immutable(不可变)是 React 开发中的一个重要概念,指的是数据一旦创建就不能被直接修改。在 React 中,正确处理不可变性对于性能优化和状态管理至关重要。

为什么需要 Immutable

  1. 性能优化:React 依赖浅比较(shallow comparison)来判断组件是否需要重新渲染
  2. 可预测性:不可变数据使状态变化更易于追踪和调试
  3. 时间旅行调试:可以轻松实现撤销/重做功能

在 React 中实践 Immutable

1. 状态更新

错误做法(直接修改状态):

javascript
复制
// ❌ 错误 - 直接修改状态
this.state.comments.push({id: 1, text: "Hello"});
this.setState({comments: this.state.comments});

正确做法(创建新对象/数组):

javascript
复制
// ✅ 正确 - 创建新数组
this.setState({
  comments: [...this.state.comments, {id: 1, text: "Hello"}]
});

2. 常见不可变操作

数组
javascript
复制
// 添加元素
const newArray = [...oldArray, newItem];

// 删除元素
const newArray = oldArray.filter(item => item.id !== idToRemove);

// 更新元素
const newArray = oldArray.map(item => 
  item.id === idToUpdate ? {...item, ...updatedProps} : item
);
对象
javascript
复制
// 更新属性
const newObj = {...oldObj, key: newValue};

// 嵌套对象更新
const newObj = {
  ...oldObj,
  nested: {
    ...oldObj.nested,
    key: newValue
  }
};

3. 使用 Immutable.js 库

Facebook 提供的 Immutable.js 提供了专门的不可变数据结构:

javascript
复制
import { List, Map } from 'immutable';

const list1 = List([1, 2, 3]);
const list2 = list1.push(4); // 返回新列表,不修改原列表

const map1 = Map({ a: 1, b: 2 });
const map2 = map1.set('a', 3); // 返回新映射

性能考虑

对于大型数据结构,使用扩展运算符(...)可能会产生性能问题,因为需要复制整个对象/数组。这时可以考虑:

  1. 使用 Immutable.js 或类似的库
  2. 使用 Immer 库(提供更方便的不可变更新语法)
javascript
复制
import produce from 'immer';

const nextState = produce(currentState, draft => {
  draft.todos.push({id: 1, text: "Learn Immutable"});
});

总结

在 React 中遵循不可变原则可以:

  • 避免意外的副作用
  • 优化组件渲染性能
  • 简化复杂的状态管理
  • 实现更可靠的调试功能

正确使用不可变更新是成为高效 React 开发者的关键技能之一。

redux中为什么要Immutable

2025年5月18日 15:37

1. 核心原因

(1) 可预测性(Predictability)

  • Redux 的核心原则:状态变更必须通过 纯函数(Reducer) 显式声明,禁止直接修改原状态。

  • 为什么?

    • 如果允许直接修改状态(Mutable),多个代码片段可能同时修改同一对象,导致难以追踪变化来源。
    • Immutable 确保每次状态变更都是显式的,只能通过 dispatch(action) 触发,使数据流更清晰。

(2) 性能优化(Shallow Comparison)

  • React-Redux 的 connect 或 useSelector 依赖浅比较(shallow equality check)

    javascript
    复制
    // React-Redux 内部逻辑(伪代码)
    function useSelector(selector) {
      const newState = selector(store.getState());
      if (newState === prevState) {
        return prevState; // 如果引用相同,跳过重新渲染
      }
      // 否则触发组件更新
    }
    
    • 如果直接修改原状态(Mutable)newState === prevState 始终为 true,组件不会更新。
    • Immutable 确保每次变更返回新对象,使浅比较能正确检测变化。

(3) 时间旅行调试(Time-Travel Debugging)

  • Redux DevTools 的核心功能:记录所有状态快照,允许回溯到任意历史状态。

    • 如果状态可变(Mutable)

      • 历史状态会被后续修改污染,无法正确还原。
    • Immutable 保证每次状态独立,快照机制才能正常工作。


2. 违反 Immutable 的后果

❌ 错误示例:直接修改状态

javascript
复制
const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      state.count++; // ❌ 直接修改原状态
      return state;  // 返回相同的引用
    default:
      return state;
  }
};

问题

  1. Redux DevTools 无法记录正确历史(所有快照指向同一对象)。
  2. React-Redux 不会触发重新渲染(浅比较发现 state === newState)。
  3. 代码难以维护:其他代码可能依赖未被克隆的旧状态。

✅ 正确做法:返回新对象

javascript
复制
const reducer = (state = { count: 0 }, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 }; // ✅ 新对象
    default:
      return state;
  }
};

3. 如何实现 Immutable?

(1) 原生 JavaScript(适合简单结构)

  • 对象{ ...oldObj, key: newValue }

  • 数组

    • 添加:[...arr, newItem]
    • 删除:arr.filter(item => item.id !== id)
    • 更新:arr.map(item => item.id === id ? newItem : item)

(2) 使用 Immer(推荐)

javascript
复制
import produce from 'immer';

const reducer = (state, action) => 
  produce(state, draft => {
    switch (action.type) {
      case 'UPDATE':
        draft.user.name = 'New Name'; // ✅ 看似直接修改,实际生成新对象
        break;
    }
  });

优点

  • 语法更直观,自动处理深层嵌套。
  • Redux Toolkit 已内置 Immer

(3) Immutable.js(较复杂)

  • 提供 MapList 等不可变数据结构,但需配合其 API 使用:

    javascript
    复制
    import { Map } from 'immutable';
    const state = Map({ count: 0 });
    const newState = state.set('count', 1); // 返回新对象
    

4. 总结

原因 解释
可预测性 确保状态变更只能通过 action → reducer 显式触发,避免隐蔽的副作用。
性能优化 浅比较(shallow comparison)依赖引用变化检测,Immutable 是必要条件。
时间旅行调试 Redux DevTools 需要完整的状态快照,Mutable 会导致历史记录污染。

最佳实践

  • 简单场景:用扩展运算符(...)或数组方法。
  • 复杂场景:用 Immer(Redux Toolkit 默认支持)。
  • 历史项目:可考虑 Immutable.js,但学习成本较高。

Immutable 是 Redux 架构的基石,理解它能帮助你写出更健壮、可维护的状态管理代码。

**

**

**

**

《JavaScript语言精粹》读书笔记之第3章:对象Object

作者 小飞悟
2025年5月18日 15:30

小飞悟申明:小编的笔记只针对强者!!!

一、对象字面量 Object Literals

属性名可以是包括空字符串在内的任何字符串。在对象字面量中,如果属性名是一个合法的JavaScript标识符且不是保留字,则并不强制要求用引号括住属性名。所以用引号括住"first-name"是必需的,但是否括住first_name则是可选的。逗号用来分隔多个“名/值”对。

  1. 合法标识符 :
  • 如果属性名是合法的JavaScript标识符且不是保留字,可以不加引号。
- 例如:
const person = {
    firstName: 'John',
    lastName: 'Doe'
};
  1. 非法标识符 :
  • 如果属性名包含特殊字符、空格或不是合法标识符,必须加引号。
- 例如
const person = {
    "first-name": 'John',
    "last name": 'Doe'
};

二、检索 Retrieval

要检索对象里包含的值,可以采用在[ ]后缀中括住一个字符串表达式的方式。如果字符串表达式是一个字符串字面量,而且它是一个合法的JavaScript标识符且不是保留字,那么也可以用.表示法代替。优先考虑使用.表示法,因为它更紧凑且可读性更好。

    stooge["first-name"]    // "Jerome"
    flight.departure.IATA   // "SYD"

运算符可以用来填充默认值:

    var middle = stooge["middle-name"] || "(none)";
    var status = flight.status || "unknown";

尝试从undefined的成员属性中取值将会导致TypeError异常。这时可以通过&&运算符来避免错误。

    flight.equipment                            // undefined
    flight.equipment.model                      // throw "TypeError"
    flight.equipment && flight.equipment.model  // undefined

三、引用 Reference

对象通过引用来传递。它们永远不会被复制:

理解对象引用

在JavaScript中,对象是通过引用来传递的,这意味着当将一个对象赋值给另一个变量时,两个变量实际上指向同一个对象。以下是对代码的详细解释:

  1. 对象引用 :
var x = stooge;
x.nickname = 'Curly';
var nick = stooge.nickname;
  • x 和 stooge 指向同一个对象,因此修改 x 的属性也会影响 stooge 。
  • nick 的值为 'Curly' ,因为 x 和 stooge 是同一个对象的引用。
  1. 多个对象引用 :
var a = {}, b = {}, c = {};
  • a 、 b 和 c 分别引用不同的空对象。
  1. 同一对象引用 :
a = b = c = {};
  • a 、 b 和 c 现在都引用同一个空对象。

四、反射 Refelection

用typeof 来确定属性类型很有帮助,同时也有两种方法处理不需要的属性。

未写完,待更新。。。。

前端实习踩过的坑之CSS系列

作者 秦盼儿
2025年5月18日 15:16

【前言】

时间过得真的很快,作为25届毕业生的我即将结束在南京接近6个月的实习。现在处于投简历背八股找工作状态,分享下我在这次充实的实习之旅中遇到的问题和解决方案,在梳理所学知识的同时也能帮助和我一样的前端新人。

1️⃣第一个坑不要太熟悉哦

没错,它就是外边距塌陷问题,我解决这个问题也很简单粗暴,

解决方法: 自己算一下外边距,尽量只设置一个边距

2️⃣渐变色边框导致边框圆角失效

很多朋友都遇到过使用渐变色边框的时候导致圆角失效,那么怎么解决呢?直接上代码截图 效果图:

image.png

这样写边框圆角是不生效的

image.png

解决方法: 使用 background-clip 实现 设置两层渐变

    border: 1px solid transparent;
    border-radius: 20px;
    background-image: linear-gradient(
        54deg,
        #fa7332 0%,
        #ed395f 34%,
        #ea2837 100%
      ),
      linear-gradient(rgba(249, 162, 156, 1) 0%, rgba(255, 129, 122, 0.43) 100%);
    background- origin: border-box;//使图片完整展示
    background- clip: content-box, border-box;
    //第一个表示裁剪至内容区值,第二个表示裁剪至边框取值

3️⃣图片叠加问题

效果图:

image.png

解决方法: 绝对定位和z-Index

.swiper_item_cover {
        position: relative;
        width: 160.6px;
        height: 170px;
        
        .swiper_item_cover_1 {
          position: absolute;
          z- index: 2;
          top: 0;
          left: 0;
          width: 106px;
          height: 170px;
        }
        .swiper_item_cover_2 {
          position: absolute;
          z- index: 1;
          top:25px;
          right: 0;
          width: 77px;
          height: 134px;
        }
      }

4️⃣子元素继承父元素的透明度

解决方法: 利用rgba 间接设置透明度

5️⃣兄弟元素的默认行为导致层级覆盖

原因: 如果兄弟元素没有设置position,默认情况下,它的position的值是static。 static元素不参与堆叠上下文 ,因此它们的z-index默认值是auto,这意味着它们会在所有absolute定位元素之上。

解决方法: 给另一个兄弟元素设置 position:relative

6️⃣文本溢出显示省略号

解决方法:

      text-overflow: ellipsis;
      overflow: hidden;
      word-break: break-all;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 1;/*修改这个数字可以变成多行省略*/
      line-clamp: 1;/*这个也要改,scss中可以用@mixin封装成一个方法*/

scss封装示例

@mixin ellicpsisN($lineCount) {
  text-overflow: ellipsis;
  overflow: hidden;
  word-break: break-all;
  display: -webkit-box;
  -webkit-box-orient: vertical;//弹性盒模型
  -webkit-line-clamp: $lineCount;//弹性盒模型方向垂直//弹性盒模型方向垂直
  -webkit-line-clamp: 1;//限制显示的行数
}
//用法
@include ellicpsisN(1);

7️⃣自定义滚动条的样式

UI给图滚动条样式要调整,别急,它来啦

解决方法:

//设置滚动条的宽度高度和背景颜色
    ::-webkit-scrollbar { 
      width: 6px;
      height: 8px;
      background- color: #ebeef5;
    }
//设置滚动条滑块的阴影和背景颜色
    ::-webkit-scrollbar-thumb {
      box- shadow: inset 0 0 6px rgba(0, 0, 0, .3);
      -webkit-box- shadow: inset 0 0 6px rgba(0, 0, 0, .3);
      background- color: #ccc;
    }
//设置滚动条轨道的阴影圆角和背景颜色
    ::-webkit-scrollbar-track{
      box- shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
      border- radius: 3px;
      background: rgba(255, 255, 255, 1);
}

8️⃣ 通知弹窗内部自定义html样式

效果图

image.png

解决方法:

const handleClickEmployee = (row) => {
      const h = this.$createElement;
      this.$notify({
        title: '提示',
        message: h(
          'i',
          { style: 'color: teal' },
          'This is a reminder'
        ),
      });
    };
//利用elment-plus 的组件
 ElNotification({
    title: 'Title',
    message: h('i', { style: 'color: teal' }, 'This is a reminder'),
  })

9️⃣ 利用i标签和elment-plus来快速渲染图标

官网上有更多图标可以选,这是几个使用示例哦

解决方法:

image.png

🔟移动端项目禁用点击高亮和文本选择

解决方法:

body {
  -webkit-tap-highlight- color: transparent;//禁用点击高亮
  outline: 0;//移除默认轮廓线
  user-select: none;//禁用文本选择
  -webkit-user-select: none;
  -webkit-text-size-adjust: 100%;//调整文字大小为100%
  width: 100%;
}

篇幅限制,本期前端新手容易遇到的CSS坑分享到这里!这几天会逐步更新完这个系列,欢迎大家留言和指导~

从权限管理到编译原理

作者 GaoNengwWw
2025年5月18日 14:40

77406616_p0_master1200.jpg

背景图来自: ああもんどCheytac M200

本文共预计阅读时间 5分钟+

大家好我是 GaoNeng-wWw. 前几天在用Nest.js写SSO后端的时候涉及到一些复杂的权限管理. 例如 如果有 A 和 B 权限 或 有 A 和 C 权限 那么则允许访问. 解决方案是写一个修饰器, 接受一个对象, 用于描述权限表达式, 类似于AST. 该修饰器作用于接口, 启动时为成员设置元信息, 接收到请求后 Guard 反射读取表达式对象, 而后调用 judge 函数判断用户是否满足该表达式。

绝望的表达式.png

考虑到后面可能会接入更多的权限, 为了保护开发人员的大脑和个人安全, 不得不含泪搓了一个普拉特解析器。

演示.png

这样一来, 开发人员的心智不仅得到了保护, 就连自己的个人安全也得到了保证。等写完后端后,便将该功能迁移到了前端。

阅读完本文, 你将能够有以下的收获

  1. v-permission 的封装
  2. 编译原理基础知识
  3. 如何写一个 Pratt 解析器
  4. 如何写一个简单的Vite插件

文中所有的代码(包括课后习题答案)都可以在 Permission-Compiler中找到.

封装一个完备的 v-permission

在这里, 当我们讨论 完备 的时候, 实际上我们在讨论能不能满足基本逻辑运算. 即: 与、或、非。

如果单单的使用数组来表示很难做到表达基本的逻辑运算. 所以我们可以使用一个或多个的对象组合而成. 下面的代码块则是一个完备的 PermissionExpression.

type And = {
  lhs: PermissionExpr;
  rhs: PermissionExpr;
  type: 'AND';
}
type Or = {
  lhs: PermissionExpr;
  rhs: PermissionExpr;
  type: 'OR';
}
type Not = {
  expr: PermissionExpr;
  type: 'NOT';
}
type Has = {
  val: string;
  type: 'HAS'
}
type PermissionExpr = Has | And | Or | Not;

这样我们就定义了一个完备的权限表达式对象. 其中 And, Or, Not 对应的是与或非. Has 类型表示的是, 某个用户是否拥有某个权限.

当且仅当用户存在权限 a 时候 {type: 'Has', val: 'a'} 会为true.

有了上述的类型,我们便可以封装一个较为完备的 v-permission 了

// directive/v-permission.ts
export default {
  created: (el: Element, binding)=>{
    if (!isValid(binding.value)){
      el.innerHTML = '<!-- -->'
    }
  },
} as Directive<Element, PermissionExpr>;

对于 isValid 函数, 实现起来也非常容易。因为 PermissionExpr 本质就是一颗树。所以我们可以很轻松的给出 isValid 的实现

const judge = (expr: PermissionExpr, userPermission: string[]):boolean => {
  if (expr.type === 'HAS') {
    return userPermission.includes(expr.val);
  }
  if (expr.type === 'AND'){
    return judge(expr.lhs, userPermission) && judge(expr.rhs, userPermission);
  }
  if (expr.type === 'OR') {
    return judge(expr.lhs, userPermission) || judge(expr.rhs, userPermission);
  }
  if (expr.type === 'NOT'){
    return !judge(expr.expr, userPermission);
  }
  return false;
}

const isValid = (node:PermissionExpr) => {
  const { permissions } = useAccount();
  return judge(node);
}

回到我们的 vue 文件中. 我们可以直接定义权限表达式看一下效果, 如果没问题,应该显示 完 全 勝 利 四个字

<!-- App.vue -->
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>
<template>
  <span v-permission="{type: 'HAS', val: 'a'}">完 全 勝 利</span>
</template>
// mock-store.ts
import { ref, type Ref } from "vue"

const permissions:Ref<string[]> = ref(['a']);

export const useAccount = () => {
  const add = (permission: string) => {
    permissions.value.push(permission);
  }
  const remove = (permission: string) => {
    permissions.value = permissions.value.filter(p => p === permission);
  }
  const has = (permission: string) => permissions.value.some(p => p === permission);
  return { add, remove, has, permissions };
}

完全胜利.png

完 全 勝 利

表达式解析

对于复杂的权限, 手写表达式对象显然不太现实. 所以我们可以传入一个字符串, 编译时将该字符串解析为一个表达式对象, 权限判断依然留在运行时. 流程图大概如下

flowchart TB
    S
    Scanner
    Parser
    AST
    S[开始] --> Scanner[扫描v-permission]
    Scanner --> Parser[解析字符串]
    Parser --> AST[生成AST]

编译流程

将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序. 某种意义上, 我们将字符串编译权限表达式对象这个过程也可以称作为编译。

我们都知道, 语言分为: 编译型语言、解释性语言。二者最本质的不同便是 编译型语言会将源代码转为机器语言.

stateDiagram-v2
    [*] --> 分词器: 字符串
    分词器 --> 解析器: Token流
    state 解释性语言 {
        [*] --> 执行
    }
    解析器 --> 解释性语言: AST
    state 编译型语言 {
        [*] --> 中间代码
        中间代码 --> 机器码
    }
    解析器 --> 编译型语言: AST

无论是解释性语言还是编译型语言, 整体都需要将用户输入的字符串信息分割为Token, 而后将Token组合为AST。

形式语言

在形式语言理论中,文法(formal grammar)是形式语言中字符串的一套产生式规则(production rule)。这些规则描述了如何用语言的字母表生成符合句法(syntax)的有效的字符串

形式语言语法

产生是的规则。正式定义为 G=(N,Σ,P,S)G=(N,\Sigma,P,S)

  • NN 是有限的非终结符集合,与GG生成的字符串无交, 换句话说 NG=N \cap G = \emptyset
  • Σ\Sigma 是有限的终结符集合, ΣN=\Sigma \cap N = \emptyset
  • PP 是产生式规则集合 (ΣN)N(ΣN)(ΣN)(\Sigma \cup N)^{*}N(\Sigma \cup N)^* \rightarrow (\Sigma \cup N)^{*}
  • SS 是开始符号 SNS \in N

其中 * 为 克莱尼星号

产生式

语法是由产生式规定。它规定了那些符号可以替换为哪些符号. 例如 XZX \rightarrow Z 表示, 符号 XX 可以被替换为 ZZ。读作 XX 可以重写/替换为 ZZ

终结符与非终结符

终结符是在产生式中不能被分解为更小单位的的基本符号。例如

xaabx \rightarrow a \\ a \rightarrow b

其中bb是一个终结符, 因为bb不会再推导出任何的符号. 但aa, xx 都是非终结符, 因为 aa 可以推导出 bbxx 可以推导出 aa

上下文无关文法

是一种形式化、精确描述语言语法的工具. 在应用一个产生式进行推到时,前后推导的结果就是上下文.

倘若有一个文法 GG, 文法中每一个产生式左侧的非终结符是单独出现的, 那么我们可以说这个文法是上下文无关文法.

例如

S1SVOS小明VO猪肉鸡肉羊肉\begin{matrix} S1 & \rightarrow S & V & O \\ S & \rightarrow 你 & 我 & 小明 \\ V & \rightarrow 吃 & 喝 & 玩 \\ O & \rightarrow 猪肉 & 鸡肉 & 羊肉\\ \end{matrix}

那么 S1S1 组成的集合就是 S1={你吃猪肉,你吃鸡肉,你吃羊肉,你喝猪肉,...小明玩鸡肉}S1=\{你吃猪肉,你吃鸡肉,你吃羊肉,你喝猪肉,...小明玩鸡肉\} [1]^{[^1]}

虽然推导出来的句子挺鬼畜的, 但至少意思到了

上下文有关文法

任何产生式规则的左手端和右手端都可以被终结符和非终结符构成的上下文所围绕。

例如

S1SVOS小明VO猪肉鸡肉羊肉O手机\begin{matrix} S1 & \rightarrow S & V & O \\ S & \rightarrow 你 & 我 & 小明 \\ V & \rightarrow 吃 & 喝 & 玩 \\ O & \rightarrow 猪肉 & 鸡肉 & 羊肉\\ 玩O & \rightarrow 手机 \\ \end{matrix}

这样一来推导过程就是 S1SVOVO你玩O你玩手机S1 \rightarrow SVO \rightarrow 你VO \rightarrow 你玩O \rightarrow 你玩手机

词法分析

分词器主要将用户输入的字符串转为一个个Token. 这个过程叫做 Tokenization. (说真的我不知道这个怎么翻译, 就理解为标记化吧). 该阶段被称之为词法分析阶段.

词法分析阶段并不关注Token之间的关系。 例如 (() 可以通过词法阶段, 但不一定能通过语法分析阶段.

例如对于下列 JavaScript 代码

const a = 1;

可以标记化为

Token TokenKind
const 常量关键词
a 标识符
= 等号
1 数字常量
; 分号

词法分析器

写一个词法分析器其实并不困难, 我们并不需要逐字符的去遍历然后拼接,而是可以使用更加高效的正则表达式完成. 因为我们的词法分析器目的是为了生成Token, 所以我们应该先设定Token的类型


enum TokenKind {
  EOF,
  IDENTIFIER,
  LEFT_PARENTHESIS,
  RIGHT_PARENTHESIS,
  AND,
  OR,
  NOT,
}

type Token = {
  // Token类型
  kind: TokenKind;
  // 人类可读的类型, TokenKind[kind]
  humanKind: string;
  // 实际字符
  char: string;
}

有了Token类型我们就可以来设计分词器了.

type RuleHandle = (
  args: {
    tokens: Token[];
    match: string;
    advanceN: (val: number)=>void
  }
) => void;

type Rule = [RegExp, RuleHandle]

export const tokenizer = (
  code: string.
  rule: Rule[]
): Token[] => {}

上述代码中, rule参数可能比较难理解. 这么做的原因主要是为了扩展性考虑, 用户可以通过传入rule参数来直接个性化的定制分词逻辑.

接下来我们就要开始写分词器了. 事实上整个分词器非常简单, 分词器一共三种情况

  1. 到达末尾了
    1. 为Token流插入 EOF Token
  2. 没有到达末尾, 没有通过任何一个正则表达式
    1. 行为: 抛出错误, Bad Input ${code}
  3. 没有达到末尾, 通过了至少一个正则表达式
    1. 调用指定规则的 handle
// directive/permission-lexer.ts
export const tokenizer = (
  code: string.
  rule: Rule[]
): Token[] => {
  const tokens: Token[] = [];
  let input = code;
  let pos = 0;
  let matched = false;
  const isEnd = () => pos < input.length;
  const advanceN = (val: number) => {
    pos += val;
  }
  // 消耗掉 [0,pos) 的字符串
  // 换句话说 丢弃 [0,pos) 这个区间
  const reminder = () => input.slice(pos);
  while (!isEnd()) {
    for (const [regExp, handle] of rules) {
      if (regExp.test(input)){
        matched = true;
      }
      const match = regExpr.exec(reminder());
      if (!match) {
        // 这个地方纯粹是为了避免as强转
        // 如果有需要可以抛出错误而不是continue
        continue;
      }
      handle({tokens, match: match[0] })
    }
    if (!matched) {
      throw new Error(`Bad Input ${input}`);
    }
  }
  tokens.push({kind: TokenKind.EOF, humanKind: 'EOF', char: '0'});
  return tokens;
}

const defaultHandle = (kind: TokenKind)=>{
  return (({tokens, advanceN, match}) => {
    tokens.push({kind, char: match, humanKind: TokenKind[kind]});
    advanceN(match.length)
  }) as RuleHandle;
}
const skip:RuleHandle = ({match,advanceN}) => {
  advanceN(match.length);
}
export const rules: Rule[] = [
  [/^\ /, skip],
  [/^And|^&&|^AND|^\&/, defaultHandle(TokenKind.AND)],
  [/^Or|^\|\||^OR|^\|/, defaultHandle(TokenKind.OR)],
  [/^Not|^!|^NOT/, defaultHandle(TokenKind.NOT)],
  [/^\(/, defaultHandle(TokenKind.LEFT_PARENTHESIS)],
  [/^\)/, defaultHandle(TokenKind.RIGHT_PARENTHESIS)],
  [/^,/, defaultHandle(TokenKind.COMMA)],
  [/^[a-zA-Z_][a-zA-Z0-9_]*/, defaultHandle(TokenKind.IDENTIFIER)]
]

注意, 这里的正则表达式全部都是 /^..../ 而不是 /.../. 我们修改下代码来看看效果

// directive/v-permission.ts
+ import {rules, tokenizer} from './permission-lexer.ts';
- const isValid = (node:PermissionExpr) => {
+ const isValid = (node:PermissionExpr | string) => {
     const { permissions } = useAccount();
-    return judge(node);
+    if (typeof node === 'string') {
+      const tokens = tokenizer(node, rules);
+      console.log(tokens);
+      return true;
+    } else {
+      return judge(node);
+    }
}

export default {
  created: (el: Element, binding)=>{
    if (!isValid(binding.value)){
      el.innerHTML = '<!-- -->'
    }
  },
-} as Directive<Element, PermissionExpr>;
+} as Directive<Element, string | PermissionExpr>
//App.vue
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>


<template>
-  <span v-permission="{type: 'HAS', val: 'a'}">完 全 勝 利</span>
+  <span v-permission="'a || b'">完 全 勝 利</span>
</template>

修改完成后, 在控制台中我们应该能够看到下图

2.png

语法分析

语法分析阶段的任务是接受来自词法分析阶段的Token流, 根据语法规则来建立语法树。本文采用的是普拉特解析法(Pratt Parser). Pratt Parser可以很有效的解析中缀表达式和优先级。不仅如此,Pratt Parser也更加灵活(后文中你会看到Pratt Parser到底有多灵活)

Nud, Led

Nud (The Null Denotation), 如果一个Token可以放在开头, 那么我们称这个Token叫做Nud (非严格定义的话可以叫做prefix). 例如一元运算 (-, !, ~)

Led (The Left Denotation), 如果一个Token必须知道左边的表达式,那么我们称它是一个Led (非严格定义的话可以叫infix). 例如二元运算符 (&&, ||, +-*/)

不过我们要注意, 有些Token可以是 Nud 也可以是 Led. 比如说当 「-」

基础结构

Pratt基础结构非常简单, 核心不会超出30行代码

// v-permission/permission-parser.ts
export const enum BP {
  DEFAULT_BP,
  COMMA,
  LOGICAL,
  UNARY,
  PRIMARY
}
type LedHandle = (lhs: Expr, bp:BP) => Node;
type NudHandle = () => Node;
export class Parser {
      constructor(
        private tokens: Token[]=[],
        private pos=0,
        private nudMap = new Map<TokenKind, NudHandle>(),
        private ledMap = new Map<TokenKind, LedHandle>(),
        private bpMap=new Map<TokenKind, BP>()
      ){
        this.setup();
      }
/*01*/parseExpr(bp: number){
/*02*/  const token = this.peek();
/*03*/  const tokenKind = token.kind;
/*  */  // 从nud开始
/*04*/  const nud = this.nudMap.get(tokenKind);
/*05*/  if (!nud) {
/*06*/    throw new Error(`Except token ${TokenKind[token.kind]}`);
/*07*/  }
/*08*/  let lhs = nud();
/*09*/  while (
/*10*/    this.bpMap.get(this.peek().kind) !== undefined && 
          // bp实际上是限制了led解析
          // 换句话说我们只解析比当前token绑定力更大的token
          // 比如 -1*2
          // 假设我们给 「-」 设定绑定力为3, 「*」的绑定力为2
          // 1就会和「-」绑定而不是「*」
/*11*/    this.bpMap.get(this.peek().kind)! > bp
/*12*/  ) {
/*13*/    const cur = this.peek();
/*14*/    const tk = cur.kind;
/*15*/    const led = this.ledMap.get(tk);
/*16*/    if(!led){
/*17*/      throw new Error(`Except for token ${cur.humanKind}`);
/*18*/    }
/*19*/    lhs = led(lhs, bp);
/*20*/  }
/*21*/  return lhs;
/*22*/}
/*23*/peek(){
/*24*/  return this.tokens[this.pos]
/*25*/}
      setup(){}
}

Identifier 与 二元表达式 的解析

在上文中我们说过 Pratt Parser 的扩展能力非常强. 接下来让我们完善Identifier的解析.

首先我们来定义一下Identifier的类型

export type Expr = BinaryExpr | Identifier;
export interface Identifier {
  type: 'Identifier',
  name: string;
}
export interface BinaryExpr {
  type: 'BinaryExpr',
  operator: Token;
  // 因为二元表达式允许符号左右两侧都是子表达式
  // [1,2,3].every(val => typeof val === 'number') && [1,2,3].some(val => val % 2 === 0)
  // 上述代码显然是合法的
  lhs: Expr; 
  rhs: Expr;
}

之后我们需要实现几个函数

  • next: 获取当前token, 向前步进一个token
    • 换句话说就是吃掉当前token, 然后返回吃掉的token
  • nud
    • 注册 Nud Token的处理函数和绑定力
  • led
    • 注册 Led Token的处理函数和绑定力
  • setup
    • 初始化一些处理函数, callback需要显式调用 bind 函数声明this指向.
next(){
  const token = this.peek();
  this.pos += 1;
  return token;
}
nud(kind: TokenKind, f: NudHandle){
  this.bpMap.set(kind, BP.PRIMARY);
  this.nudMap.set(kind, f);
}
led(bp: BP, kind: TokenKind, f: LedHandle) {
  this.bpMap.set(kind, bp);
  this.ledMap.set(kind, f);
}
setup(){
  // 待会实现
}

有了工具函数接下来我们先实现最简单的 Identifier 解析

/*01*/ parsePrimary(){
/*02*/   const name = this.next().char;
/*03*/   return {
/*04*/     type: 'Identifier',
/*05*/     name,
/*06*/   } as Identifier;
/*07*/ }
/*08*/ parseBinary(lhs: Expr){
/*09*/   const operator = this.next();
/*10*/   const rhs = this.parseExpr(BP.LOGICAL);
/*11*/   return {
/*12*/     type: 'BinaryExpr',
/*13*/     lhs,
/*14*/     rhs,
/*15*/     operator,
/*16*/   } as BinaryExpr;
/*17*/ }
/*18*/ setup(){
/*19*/   this.led(BP.LOGICAL,TokenKind.AND,this.parseBinary.bind(this) );
/*20*/   this.led(BP.LOGICAL,TokenKind.OR,this.parseBinary.bind(this) );
/*21*/   this.nud(TokenKind.IDENTIFIER,this.parsePrimary.bind(this) );
/*22*/ }

好了, 追加完上述22行代码后我们就完成了对Identifier和二元表达式的解析. 接下来我们稍微的修改一下代码

// v-permission.ts
+ import {rules, tokenizer} from './permission-lexer.ts';
+ import {Parser} from './permission-parser.ts';
- const isValid = (node:PermissionExpr) => {
+ const isValid = (node:PermissionExpr | string) => {
     const { permissions } = useAccount();
+    if (typeof node === 'string') {
+      const tokens = tokenizer(node, rules);
-      console.log(tokens);
+      const parser = new Parser(tokens);
+      const ast = parser.run();
+      console.log(ast);
+      return true;
+    } else {
+      return judge(node);
+    }
// App.vue

<template>
  <span v-permission="'a || b'">完 全 勝 利</span>
</template>

修改完成后刷新一下页面, 理论上控制台应该输出

二元表达式的解析.png

分组表达式

对于一元表达式和分组表达式, 实际上更加的简单. 因为我们不会(至少这篇文章不会)涉及到函数调用. 我们先来实现以下分组表达式, 分组表达式不需要定义类型.

解析器只需要吃掉左括号然后重新开始解析表达式就可以,解析完成后吃掉右括号,然后返回解析好的表达式。

// 获取当前 token 的 TokenKind. 如果不为我们预期, 则直接抛出错误
expect(kind: TokenKind) {
  const token = this.peek();
  if (token.kind !== kind) {
    throw new Error(`Expcetion ${TokenKind[kind]} but find ${TokenKind[token.kind]}`);
  }
  return this.next();
}
parseGroup(){
  // 吃掉左括号, 没有就报错
  this.expect(TokenKind.LEFT_PARENTHESIS);
  // 重新解析所有表达式
  const expr = this.parseExpr(BP.DEFAULT_BP);
  // 吃掉右括号, 没有就报错
  this.expect(TokenKind.RIGHT_PARENTHESIS);
  return expr;
}
setup(){
  // ...
  this.nud(TokenKind.LEFT_PARENTHESIS, this.parseGroup.bind(this));
}

接下来让我我们修改一下代码, 看一下分组表达式的结果

<span v-permission="'a || (b || c)'">完 全 勝 利</span>

分组表达式的胜利.png

如果不加括号的话, 默认是左结合. 当解析器遇到左括号后会重新开始解析子表达式.

前缀表达式的解析

前缀表达式是我们最终要解决的问题,当然他并不难,恰恰相反,反而是因为太简单了到最后随手就能实现. 先让我们来定义一下前缀表达式的类型

interface PrefixExpr {
  type: 'PrefixExpr';
  operator: Token;
  expr: Expr;
}
parsePrefix(){
  const operator = this.next();
  const expr = this.parseExpr(BP.UNARY);
  return {
    type:  'PrefixExpr',
    operator,
    expr
  } as PrefixExpr
}
setup(){
  // ...
  this.nud(TokenKind.NOT, this.parsePrefix.bind(this) );
}

现在让我们修改一下代码看一下效果

- <span v-permission="'a || (b || c)'">完 全 勝 利</span>
+ <span v-permission="'!a || (b || c)'">完 全 勝 利</span>

一元表达式.png

然后我们来完善一下类型

export type Node = Expr;
export type Expr = BinaryExpr | Identifier | PrefixExpr;

代码生成

本章节的目的在于将先前我们生成好的 Expr 转为我们 封装一个完备的v-permission中定义好的 PermissionExpr

const objectGenerate = (node:Node): PermissionExpr | null => {
  // ?
  return null;
}

IdentifierPrefix 都太简单了. 唯一有一点难度的只有 BinaryExpression. 所以我们这里只写 BinaryExpression. 对于其他 (包括课后习题) 都可以在 Permission-Compiler中找到

// directive/codegen.ts
const isPrefix = (node:Node) => node.type === 'PrefixExpr';
const isIdentifier = (node:Node) => node.type === 'Identifier';
const isBinaryExpr = (node:Node) => node.type === 'BinaryExpr';
export const binaryExprGen = (expr: BinaryExpr) => {
  const {lhs,rhs,operator} = expr;

  // 这里判断主要是为了收窄 operator.kind 只能是 AND 和 OR
  if (operator.kind !== TokenKind.AND && operator.kind !== TokenKind.OR) {
    throw new Error('Operator only support && or ||, or you can use keywords `and` or `or`.');
  }
  return {
    lhs: objectGenerate(lhs),
    rhs: objectGenerate(rhs),
    type: TokenKind[operator.kind]
  } as And | Or
}
export const objectGenerate = (node: Node):PermissionExpr|null => {
    if (isIdentifier(node)) {
    // ...
  }
  if (isBinaryExpr(node)){
    return binaryExprGen(node);
  }
  if (isPrefix(node)) {
    // ...
  }
  return null;
}

现在我们稍微的修改一下代码, 看看能不能生成 PermissionExpr

// v-permission.ts
+import { objectGenerate } from './codegen';
const isValid = (value: string | PermissionExpr) => {
  const { permissions } = useAccount();
  if (typeof value === 'string') {
    const parser = new Parser(tokens);
+   const ast = parser.run();
+   const permissionExpr = objectGenerate(ast);
+   if (!permissionExpr){
+     throw new Error('Unknown Error')
+   }
+   console.log(permissionExpr)
+   return judge(permissionExpr, permissions.value);

// App.vue
+ <span v-permission="'!a || (b || !c)'">完 全 勝 利</span>

刷新页面后我们应该在控制台看到

终于啊终于.png

页面上应该也可以看到

赢了但是没完全赢.png

这里之所以能看到是因为我们权限写的是 没有A 或者 (B 或者 没有C) 因为或运算符只要有一个是true, 则对应的表达式就是true. 我们在 mock-store.tspermissions 变量写的是 ['a']. 显然满足了 !c, 那么 b || !c 就是true, 那么 !a || (b||!c) 也是true, 那么就可以显示

插件

实际上整个文章到这里确实可以结束了, 因为我们完成了最终的目标 输入一段字符串, 解析为权限表达式对象.

但在这里我们可以稍作思考, 如果我们给 v-permission 传入一个静态的字符串, 那么其实没有必要在运行时进行解析, 而是可以直接在编译时进行解析, 使用解析产物替换掉传入的静态字符串.

所以我们的整体历程便是

stateDiagram-v2 
    direction LR    
    S1: 搜索v-permission指令
    [*] --> S1
    S1 --> [*]: 不存在
    S1 --> 提取指令值
    提取指令值 --> [*]: 不是一个常量
    提取指令值 --> transform
    state transform {
        direction LR
        [*] --> 分词
        分词 --> 解析
        解析 --> 代码生成
        代码生成 --> 替换指令值
    }
    transform --> [*]

写个简单的插件

// plugin/permission.ts
import type {Plugin} from 'vite';
import permissionTransform from './permission-transform';

const permission = ()=>{
  return {
    name: 'vite-plugin-vue-permission',
    transform(code, id, options) {
      if (!id.endsWith('vue')){
        return code;
      }
      return permissionTransform(code, id);
    },
    enforce: 'pre',
  } as Plugin;
}

export default permission;

// vite.config.ts
import permission from './plugin/permission';
import inspect from 'vite-plugin-inspect';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    inspect(),
    vue(),
    permission()
  ],
})

实现 permissionTransform

考虑到我们编译器的主体结构已经实现, 所以 permissionTransform 的难度主要在于

  1. 如何将vue代码转为ast
  2. 如何将ast转为代码

第一个问题相对来说比较好解决, vue 官方提供了 @vue/compiler-sfc 来负责解析。

第二个问题似乎没有什么标准答案, 掘金上也有不少人是自己根据ast写了代码生成. 但个人感觉其实没有必要, 我们完全可以对 loc.start.offset 进行降序排序, 而后切分重组字符串。

提取 v-permission

提取 v-permission 是最简单的一个部分, 本质就是遍历template下所有的dom元素, 提取后再提取存在 namepermission 的指令就可以。

// plugin/permission-transform.ts
export default (
  code: string, id:string
)=>{
  const sfcAST = useSFC({code,id});
  const elements:BaseElementNode[] = [];
  const template = sfcAST.template;
  if (!template?.ast) {
    return code;
  }
  walkSFC(template.ast, {
    enter: (node) => {
      if (node.type === NodeTypes.ELEMENT ) {
        const _node:BaseElementNode = node as BaseElementNode;
        elements.push(_node);
      }
    }
  })
  if (!elements.length) {
    return code;
  }
  const directives:DirectiveNode[] = [];
  for (const ele of elements) {
    const props = ele.props;
    const allDirectives = props.filter(p => p.type === NodeTypes.DIRECTIVE);
    if (!allDirectives.length){
      continue;
    }
    directives.push(...allDirectives.filter(d => d.name === 'permission'))
  }
  directives.sort((a,b) => b.loc.start.offset - a.loc.start.offset);
  return code;
}

解析静态字符串

// plugin/permission-transform.ts
const parseStaticPermission = (
  _ast: SimpleExpressionNode
) => {
  const vueAST = _ast.ast;
  if (!vueAST){
    return ;
  }
  let text = '';
  if(vueAST.type === 'StringLiteral'){
    text = vueAST.value;
  }
  if (vueAST.type === 'TemplateLiteral') {
    throw new Error('Not implment Template parse yet.');
  }
  const tokens = tokenizer(text, rules);
  const parser = new Parser(tokens);
  const ast = parser.run();
  const expr = objectGenerate(ast);
  if (!expr) {
    throw new Error('Unknown Error');
  }
  return expr;
}
export default (
  code: string, id:string
)=>{
  // ...
  for (const directive of directives) {
    if (!directive.exp || !directive.exp.ast){
      continue;
    }
    if (directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION){
      continue;
    }
    if (directive.exp.ast.type !== 'StringLiteral') {
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
  }
}

前三个if拆开写纯粹是为了不让自己心智负担太大. 这边我们对 objectGenerate 产出的代码叫做了 Intermediate language.

重组字符串

这里有一个小坑,我们需要对 directive.exp.loc.start.offset 进行倒序排序, 换句话说我们要倒着修改字符串. 如果不这么做, 就会陷入到 修改完第一个字符串结果第二个字符串的位置偏移了。

export default (
  code: string, id:string
)=>{
  // ...
  for (const directive of directives) {
    if (!directive.exp || !directive.exp.ast){
      continue;
    }
    if (directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION){
      continue;
    }
    if (directive.exp.ast.type !== 'StringLiteral') {
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
+   const l = directive.exp.loc.start.offset;
+   const r = directive.exp.loc.end.offset;
+   code = `${code.slice(0,l-1)}"${JSON.stringify(permissionExprAstIR).replaceAll('"',"'")}"${code.slice(r+1)}`
  }
  return code;
}

Try It

接下来, 我们就可以再 vite.config.ts 中使用我们的插件了.

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import permission from './plugin/permission';
import inspect from 'vite-plugin-inspect';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    inspect(),
    vue(),
    permission()
  ],
})

接下来我们修改下 App.vue 访问 http://localhost:5173/__inspect

<!-- App.vue -->
<!-- 还是沿用了先前的App.vue -->
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>

<template>
  <span v-permission="'!a || (b || !c)'">完 全 勝 利</span>
</template>

访问 inspect 之后, 我们点击 App.vue 后在弹出的 Drawer 中点击我们编写的插件, 可以看到确实已经替换成功了

diff.png

课后习题

  • 实现函数解析

Ref

  1. www.zhihu.com/question/21…

Trae 插件 Builder 模式:从 0 到 1 开发天气查询小程序,解锁 AI 编程新体验

2025年5月18日 14:37

我正在参加Trae「超级体验官」创意实践征文,本文所使用的 Trae 免费下载链接:www.trae.com.cn/?utm_source… 在这里插入图片描述

在软件开发领域,效率与创新始终是开发者追求的核心目标。Trae 插件(原 MarsCode 编程助手)Builder 模式的全面上线,无疑为开发者带来了全新的解决方案。它不仅同时支持 VS Code、JetBrains IDEs 等主流开发环境,还能让开发者通过全自然语言,轻松实现从 0 到 1 开发完整项目,真正实现了从 “编程助手” 到高度智能的 “AI 工程师” 的进化。本文将以开发一个简单的 “天气查询小程序” 为例,深入测评 Trae 插件 Builder 模式的功能亮点,分享使用技巧,并与国内外 AI 编程工具进行对比,展现其独特优势。

一、Trae 插件 Builder 功能测评及使用技巧

Trae插件获取:www.trae.com.cn/plugin

在这里插入图片描述

对于开发者而言,Trae 插件的 Builder 模式 堪称“效率加速器”。无论是搭建一个小型游戏还是工具类应用,用户只需用自然语言描述需求,插件便能自动生成可运行的基础代码。这不仅省去了手动创建文件、配置环境的麻烦,还大幅降低了初期开发的试错成本。开发者得以从“造轮子”中解脱,将精力投入到更具创造性的业务实现上——这正是 Builder 模式的真正价值。

1.1 功能亮点

在这里插入图片描述

  1. 全自然语言交互:Trae 插件 Builder 模式最大的亮点在于其强大的自然语言理解能力。开发者无需使用复杂的编程指令,只需用日常语言描述需求,如 “创建一个能查询天气的小程序,用户输入城市名,就能显示当前天气状况、温度和风力”,Trae 插件便能迅速解析需求,自动生成对应的代码框架与逻辑。这种交互方式极大降低了编程门槛,即使是非专业开发者,也能轻松表达开发意图。
  2. 多环境无缝支持:无论是使用 VS Code 进行轻量级开发,还是借助 JetBrains IDEs 进行大型项目构建,Trae 插件 Builder 模式都能完美适配。开发者可以在熟悉的开发环境中,享受统一的 AI 编程体验,无需在不同工具间频繁切换,有效提升开发效率。
  3. 智能代码生成与优化:除了基础代码生成,Trae 插件还具备智能优化功能。在生成天气查询小程序代码时,它会自动考虑代码的可读性、可维护性和性能。例如,合理封装天气查询的 API 调用逻辑,避免重复代码;根据不同平台特性,优化界面渲染效果,确保小程序在各种设备上都能流畅运行。
  4. 实时反馈与协作:在开发过程中,Trae 插件会实时反馈代码执行情况和潜在问题。当输入的自然语言需求存在歧义或代码运行出现错误时,它会以通俗易懂的方式给出提示,并提供修改建议。此外,插件还支持团队协作,不同成员可以在同一项目中使用 Builder 模式,共同推进开发进程,实现高效沟通与协作。

1.2 使用技巧

在这里插入图片描述

  1. 精准描述需求:为了获得更符合预期的代码,在使用自然语言描述需求时,尽量提供详细信息。比如在开发天气查询小程序时,明确说明 “使用 OpenWeatherMap 的 API 获取天气数据”“小程序界面采用简洁的卡片式设计” 等,让 Trae 插件更精准地理解开发意图。
  2. 灵活调整与迭代:开发过程并非一蹴而就,当生成的代码不符合预期时,不要急于推翻重来。可以通过逐步修改自然语言描述,让 Trae 插件迭代优化代码。例如,如果小程序的界面布局不理想,可以输入 “将天气信息显示区域调整为居中对齐,字体增大一号”,插件会快速响应并更新代码。
  3. 学习生成代码:Trae 插件生成的代码是学习编程的宝贵资源。开发者在使用过程中,可以仔细研读生成的代码,学习其中的编程逻辑和设计模式。通过这种方式,不仅能完成项目开发,还能提升自身的编程水平。

二、Trae插件安装与使用步骤

要在 VSCode 中安装 Trae 插件,请先打开扩展面板(Ctrl+Shift+X),在搜索栏输入“Trae”并回车,找到官方插件后点击安装按钮即可。安装完成后,你就能立即体验它强大的开发辅助功能。

在这里插入图片描述在这里插入图片描述

要使用Builder模式,请先将Trae插件更新至最新版本,然后在界面顶部导航栏点击"Builder"标签页,即可从Chat模式切换至Builder模式开始协作开发。最新版本确保您能使用完整的Builder功能。

三、天气查询小程序实战案例

在这里插入图片描述

3.1 项目需求分析

我们要开发的天气查询小程序,核心功能是让用户输入城市名称,小程序通过调用天气 API 获取该城市的实时天气信息,包括天气状况(如晴、多云、雨等)、温度(摄氏度)和风力,并将这些信息以友好的界面展示给用户。同时,为了提升用户体验,小程序需要具备简洁美观的界面设计和流畅的交互效果。

在这里插入图片描述

3.2 使用 Trae 插件 Builder 模式开发过程

  1. 启动 Trae 插件:在 VS Code 或 JetBrains IDEs 中打开项目,激活 Trae 插件 Builder 模式。
  2. 输入自然语言需求:在插件的交互界面输入 “创建一个天气查询小程序,用户可以在输入框输入城市名,点击查询按钮后,通过 OpenWeatherMap 的 API 获取该城市的天气状况、温度和风力,并在页面上以卡片形式展示。小程序使用 HTML、CSS 和 JavaScript 进行开发,界面风格简约现代”。
  3. 查看与调整代码:Trae 插件迅速生成包含 HTML 页面结构、CSS 样式表和 JavaScript 逻辑的代码。开发者可以查看代码,检查是否符合需求。如果发现问题,如 API 密钥未配置,可输入 “添加 OpenWeatherMap 的 API 密钥配置代码”,插件会自动补充相关代码。
  4. 测试与优化:运行生成的小程序,输入城市名称进行测试。若发现界面显示不完整或数据获取错误,通过修改自然语言描述,让 Trae 插件进一步优化代码。经过几次迭代,一个功能完善、界面美观的天气查询小程序便开发完成。

实操流程图片】:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.3 项目成果展示

最终完成的天气查询小程序,界面简洁清晰,用户输入城市名称并点击查询按钮后,能快速准确地显示天气信息。例如输入 “北京”,页面会展示 “天气状况:晴,温度:25℃,风力:微风” 等内容,完全满足最初的项目需求。

在这里插入图片描述

在这里插入图片描述

【可能出现问题】:

script.js 文件里,API_KEY 需要替换成你从 OpenWeatherMap 官网获取的真实 API 密钥。

// 请将此处替换为你自己的 OpenWeatherMap API 密钥
const API_KEY = 'your_openweathermap_api_key';

解决办法:前往 OpenWeatherMap 注册账号,获取 API 密钥,然后把 your_openweathermap_api_key 替换成真实的密钥

在这里插入图片描述

四、与竞品对比:Trae 插件 Builder 模式的优势

当前AI编程工具主要分为三类:1)代码补全型(如GitHub Copilot),2)对话辅助型(如Amazon CodeWhisperer),3)项目生成型(如本次分析的Trae插件Builder模式。随着自然语言处理技术的突破,开发者对工具的期待已从单纯的效率工具转向能理解复杂意图、完成系统工程的全流程解决方案。在此背景下,项目级代码生成能力正成为新一代AI编程助手的核心竞争点。

在这里插入图片描述

对比维度 GitHub Copilot Cursor 国内同类产品 Trae插件Builder模式
核心功能 代码补全/片段生成 自然语言编程 基础项目生成 全流程项目构建
项目级支持 需手动搭建框架 部分功能受限 需求理解易偏差 从0到1完整生成
代码质量 片段级优化 优化能力有限 需频繁调试 智能环境适配优化
开发效率 节省30%编码时间 中等效率提升 反复修改耗时 节省70%+初始开发时间
环境支持 多IDE兼容 依赖特定环境 功能受限 VS Code/JetBrains无缝支持
典型案例 需手动完成天气小程序80%基础代码 生成代码需二次优化 可能误解界面风格需求 一次输入生成完整天气小程序(含API/UI)

场景体验】:以开发"天气查询小程序"为例.

我实际体验了Trae插件的Builder模式开发"天气查询小程序",发现确实有四大优势:第一,它能准确理解我的复合需求,包括API调用和UI风格要求;第二,生成的核心代码框架直接就能运行,省去了很多搭建工作;第三,在不同IDE上都能流畅使用,完全适配我的开发环境;最惊喜的是,原本需要3-5小时的项目初始化工作,现在30分钟就能搞定,效率提升非常明显。

五、总结与使用体验

在这里插入图片描述

综上所述,Trae 插件 Builder 模式凭借其强大的全自然语言交互、多环境支持、智能代码生成与优化等功能,以及在与竞品对比中展现出的显著优势,成为开发者从 0 到 1 开发项目的得力助手。无论是开发简单的小程序,还是复杂的大型应用,Trae 插件都能为开发者带来全新的编程体验,助力提升开发效率与质量。如果你也想体验高效智能的编程方式,不妨尝试使用 Trae 插件 Builder 模式,开启属于你的 AI 编程之旅。

作用域链和闭包(clousre)拆解(3)

2025年5月18日 14:00

一、作用域链

1.1 代码分析:

看看输出啥。

function cat() {
  console.log(myName);
}
function dog() {
  var myName = "王中王";
  function dogking() {
    console.log(myName);
  }
  dogking();
  cat();
}
var myName = "旺中旺";
dog();

1.2 调用栈分析

在这里插入图片描述

全局执行上下文和dog函数执行上下文中都包含myname 变量,dogKing和cat函数中的myname变量会取哪个呢? 其实两个函数输出结果并不相同。 dogKing函数输出为“王中王”,cat函数输出为“旺中旺”。 为什么呢?

1.3 作用域链

首先,变量的查找是根据作用域链的规则来的。

那么作用域链是什么呢?作用域链是js引擎用来解析变量的机制。查找变量时,js引擎会先在当前作用域查找,如果没找到,继续向外层查找,直至全局作用域。这个从内向外的查找链条就是作用域链。

那按照这个概念理解,dogKing函数的输出是按照作用域链查找的,cat函数则不是。因为dog和dogKing函数组成了一个闭包,闭包比较特殊,dogKing的外级作用域就是dog函数。

js调用栈中,每个执行上下文中都包含全局执行上下文的引用,我们把这个引用称为outer。 在这里插入图片描述 cat和dog函数查找变量时,首先在当前的执行上下文中查找,没有找到,会继续查询outer指向的全局执行上下文中进行查找。

那为什么cat的外部引用时全局执行上下文,而不是dog函数执行上下文呢?这是因为在执行过程中,作用域链是根据词法作用域决定的。

1.4 词法作用域

词法作用域是js中作用域的静态结构。在代码编写时确定,与代码执行无关。是由函数的嵌套结构确定,与函数调用无关。 因此在函数定义时,根据词法作用域,dog和cat函数的上级作用域都是全局作用域。

1.5 练习

块级作用域变量查找同理。

function cat() {
  let age1 = 20
  console.log(age);
}
function dog() {
  var myName = "王中王";
  function dogking() {
    console.log(myName);
  }
  let age = 18
  dogking();
  cat();
}
var myName = "旺中旺";
let age = 10
dog();

试着根据调用栈分析下cat函数中输出的值。

在这里插入图片描述 这是这段程序的调用栈。var声明的变量和函数声明在变量环境中,let和const声明在词法环境中。 在这里插入图片描述 变量查找时, ①查找当前作用域的词法环境,从栈顶到栈底 ②查找当前作用域的变量环境 ③查找全局作用域的词法环境,从栈顶到栈底,找到age=10 ④找到,结束。

二、闭包

2.1 上代码

function func() {
    var myName = "李三岁"
    let num1 = 1
    const num2 = 2
    var innerFunc = {
        getName:function(){
            console.log(num1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerFunc
}
var tem = func()
tem.setName("李逍遥")
console.log(tem.getName())

首先,我们看当执行到func函数结尾时的调用栈情况: 在这里插入图片描述

上述代码中,innerFunc对象中包含getName和setName两个方法,定义在func函数内部。根据词法作用域,即声明时,getName和setName方法可以顺着作用域链访问到func函数中的变量。因此可以引用myName和num1两个变量。 接着innerFunc返回给tem变量后,虽然func函数执行完毕,但是此时依然引用func函数中的两个变量,因此并不会被回收。此时的调用栈情况为:

在这里插入图片描述 func函数执行完成以后,其执行上下文从栈顶弹出,但myName和num1变量还没getName和setName方法使用,因此还保存在内存中。无论在哪里调用这两个方法,都可以访问这两个变量,其他任何方法都访问不到这两个变量。因此这两个方法和变量就组成了闭包。

2.2 定义

mdn中闭包的定义为:闭包是由捆绑起来的(封闭的)函数和函数周围状态(词法环境)的引用组合而成。 因此,闭包可以让内部函数访问其外部作用域,即使外部函数执行结束,内部函数引用的外部函数的变量依然保存在内存中,内部函数及其引用的变量组成闭包。其实宽泛理解,在js中所有的函数都是一个闭包。

2.3 变量查找

闭包中的函数执行后,变量查找时,js引擎会沿着:setName函数执行上下文>func闭包>全局执行上下文的顺序查找。 在这里插入图片描述 在浏览器中打断点后,我们看开发者工具的信息: 在这里插入图片描述 当给myName赋值时,Scope项体现了作用链为:Local>Closure>Block>Global。因为我在vue3的项目中运行,如果在单独的js文件中运行,Block中的变量会在Global中,没有Block这一环。Local就是当前setName方法的作用域。Closure(func)就是func函数的闭包。Global为全局作用域。

2.4 闭包回收

  • 如果引用闭包的变量是个局部变量,等该作用域销毁后,下次gc执行垃圾回收时,进行是否还在使用的判断和内存回收。
  • 如果引用闭包的变量是个全局变量,那么该闭包会一直存在知道页面关闭。若以后闭包不再使用,会造成内存泄漏。(总的内存大小不变,可用的内存大小变小了)这也是闭包的一大缺点。

2.5 闭包的用途

2.5.1 数据封装和私有化

上代码

function createPerson(age) {
  const privateAge = age;   // 私有变量
  
  return {
    getAge: function() {
      return privateAge;
    },
    setAge: function(newAge) {
      if (typeof newAge === 'number' && newAge > 0) {
        privateAge = newAge;
      }
    }
  };
}

const person = createPerson(30);
console.log(person.getAge());  // 输出: 30
person.setAge(35);
console.log(person.getAge());  // 输出: 35

上述代码中,person变量可以通过闭包访问privateAge变量,但外部代码不能访问。 同时也可以作为缓存,保存在内存中。

因此闭包可以用来创建私有变量和方法,防止外部直接访问和修改。

2.5.2 防抖和节流

防抖:

function shake(){
let timer = null

function func(){
if(timer != null) clearTimeout(timer)

timer = setTimeout(()=>{
// todo want to do
},200)
}
}

节流

function throttle(){
let timer = null

function func(){
if(timer != null) return

timer = setTimeout(()=>{
// todo want to do
timer = null
},200)
}
}

防抖和节流都是通过闭包使变量存在于内存中,借助变量实现想要的功能。

三、做个题

var obj = {
    myName:"time",
    printName: function () {
        console.log(myName)
    }    
}
function func() {
    let myName = "李三岁"
    return obj.printName
}
let myName = "刘大哥"
let _printName = func()
_printName()
obj.printName()

分析一下,当执行到func()未return时的调用栈: 在这里插入图片描述 执行完成后弹出栈顶: 在这里插入图片描述 此时_printName被赋值,执行时: 在这里插入图片描述 查找myName变量:_printName函数执行上下文词法环境>_printName函数执行上下文变量环境>全局词法环境,找到,输出“刘大哥”。 执行至obj.printName()时,情况相同,obj.printName函数执行上下文中的词法环境和变量环境中均为空,所以查找到全局执行上下文中。 因此printName函数的myName变量是属于全局作用域下的,此作用域链由词法作用域决定。

总结:此段程序中并没有生成闭包。obj不是一个函数,其中的myName和printName是他的两个属性,彼此并没有联系。若想产生联系,需要加上this关键字。否则printName会通过词法作用域链查找myName

文章参考:time.geekbang.org/column/intr… zhuanlan.zhihu.com/p/683323392 juejin.cn/post/737617…

CSRF和跨域问题CORS

作者 屁__啦
2025年5月18日 13:27

原来只知道有CORS是因为浏览器同源策略引起的,所以如果前端调用后端Restful API的时候浏览器会报跨域的问题;

image.png 看完CSRF相关的内容,跨站点请求伪造,即便有同源策略,浏览器会拦截响应,但是该执行的API还是已经执行过了。

CORS 是浏览器为了解决跨域请求的问题而设计的一种机制,后端通过设置 HTTP 响应头(如 Access-Control-Allow-Origin)告诉浏览器:我允许这个域的请求访问我的资源。

“简单请求”:

符合以下条件的请求,浏览器直接发起请求,不会预检

  • 方法是 GET、POST 或 HEAD;
  • Content-Type 限于:application/x-www-form-urlencoded, multipart/form-data, text/plain
  • 无自定义请求头;

这些请求如果不被 CORS 允许,浏览器会拦截响应(但请求已经发送了)

🔍 示例:简单请求跨域时依旧执行 API

const formData = new URLSearchParams();
formData.append('email', email);
formData.append('name', name);
formData.append('password', password);

fetch(`${API_URL}/auth/register`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded', // ✔️ 属于简单请求
  },
  body: formData,
});

上面的请求是跨域的,而且会有跨域问题,但是仍旧会创建一个新的用户,用户会被保存到数据库; 这是一个典型的“简单请求”,即使跨域时 CORS 没设置允许,浏览器也会把请求发送出去!只是响应内容会被拦截,JavaScript 拿不到响应数据。

⚠️ “非简单请求”:

如使用 PUT、DELETE 方法或带自定义请求头的 POST 请求,会先发送一个预检请求(OPTIONS) ,询问服务器是否允许,再决定是否发送真正的请求。

🔥 和 CORS 的关系?

  • CSRF 攻击通常是“同源策略绕不过”的,因为攻击的页面和目标 API 是跨域的。
  • 但是跨域的 POST 请求(简单请求)是会直接发出的,哪怕响应被浏览器拦截,后端逻辑已经执行了

🔐 安全建议

same site cookie只允许顶级域名相同的情况下携带cookie,不适用于前后端分离且前后端域名不同的情况; 这时后端设置的sameSite是None;

//前端代码
fetch("https://api.backend.com/api/xxx", {
  method: "POST",
  credentials: "include", // 👈 关键点:允许浏览器带上 Cookie
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(data),
});
//后端代码
res.cookie("backendToken", token, {
  httpOnly: true,
  secure: true,         // 必须是 HTTPS 才能使用 SameSite=None
  sameSite: "None",     // 明确允许跨站携带 Cookie
});

由于你在跨站传递 Cookie,浏览器已不再默认信任行为,所以要注意:

  • 使用 https://,不要部署在 HTTP 上。
  • 后端验证 OriginReferer 头,配合 CSRF Token 做双重验证(提高安全性)。
  • 如果使用 JWT,尽量使用短有效期 + Refresh Token 机制。

🔐 前端安全防御方法汇总

1. 防止 XSS(跨站脚本攻击)

  • 输入过滤 + 输出转义

    • 所有用户输入进行严格校验(白名单优先)。
    • 对动态插入 DOM 的内容使用转义(如 innerText 替代 innerHTML)。
  • 使用安全框架

    • React、Vue 默认对绑定内容进行转义。
  • Content Security Policy(CSP)

  • 限制可执行的脚本来源,例如:

  • HTTP-ONLY cookie防止cookie泄漏

Content-Security-Policy: script-src 'self'

2. 防止 CSRF(跨站请求伪造)

  • 使用 SameSite Cookie 策略

    • 推荐设置:
    Set-Cookie: token=xxx; SameSite=Strict; HttpOnly; Secure
    
    • Strict: 拒绝第三方携带 Cookie,前后端不分离时使用。

    • Lax: 支持 GET 导航请求,适合大多数表单场景。

  • 配合 Token 校验

    • 前端每次请求带上 CSRF Token,在服务端验证。
  • 禁止第三方表单提交

    • 使用 X-Requested-With: XMLHttpRequest 检测是否来自前端代码。
  • 正确使用DOM操作

    • 使用 textContent / innerText,不要用 innerHTML 插入不可信内容。

从rsbuild的unplugin-element-plugin的问题来看plugin-入门

作者 在泡泡里
2025年5月18日 13:26

入门插件分享

问题背景

当我把 rsbuild 升级到 1.3.20 之后,发现 unplugin-element-plus 的样式自动导入失效了。我马上切换到了 1.1.1(这个版本是我乱猜的),结果真没错,这个插件生效了。至于是从哪个版本开始改的,我没有试。

升级工具

在升级 rsbuild 时可以用这个工具:

taze - 升级 Rsbuild

调试过程

配置调试环境

我开始调试插件,如果是 rsbuild,直接修改 npm_modules 的代码就行了。

我使用的是 VSCode,在项目根目录建一个 .vscode 文件夹,新建一个 launch.json 文件:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/node_modules/@rsbuild/core/bin/rsbuild.js",
      "args": ["dev"],
      "env": {
        "NODE_OPTIONS": "DEBUG=rsbuild"
      },
      "outFiles": ["${workspaceFolder}/**/*.js"]
    }
  ]
}

然后按 F5(此时要 .vscode 在工作的根目录)就可以开始调试。

调试界面

经过一番研究,我把目光转移到这里。

Unplugin 介绍

这里要提一句 Unplugin 这个东西。我的理解就是将各个打包工具的方法抽象成一个方法,统一调用然后适配成 webpack、vite、rollup、rspack、esbuild 各家插件。

问题定位

核心方法是 createUnplugin => transform => transformStyle

核心方法

你会发现一个断点没进。这里逻辑很简单,就是在 import { Elxxx } from 'element-plus' 下面去引入它的样式。把断点移上去:

可以通过左边点击红点打断点(我比较喜欢写 debugger):

设置断点

然后你就会发现我要加样式的地方:

代码转换

它居然长这样,不应该是我的源码吗?

源码示例

<template>
  <ElDialog v-model="visible" title="标题" width="80%" :destroy-on-close="true" append-to-body>
    <div class="flex flex-col w-500px h-500px bg-red"></div>
    <ElButton @click="hide">关闭</ElButton>
  </ElDialog>
</template>

<script setup lang="ts">
import { ElDialog, ElButton } from 'element-plus'

const visible = ref<boolean>(false)

const show = () => {
  visible.value = true
}

const hide = () => {
  visible.value = false
}

defineExpose({
  show,
  hide,
})
</script>

<style lang="scss" scoped></style>

这里就找到 unplugin-element-plus 没有生效的原因了,这似乎是 vue-loader 处理后的结果。 好像是为了编译速度,拆分后可以按需加载

解决方案

经过两小时的研究,我想到:在它转换之前处理不就行了吗?

这个插件有个 enforce 参数。我去查了官网,vite 的插件执行顺序是这样的:

插件执行顺序

实施方案

最后,我把 unplugin-element-plus 拉下来,跑起来,引入它打包结果的包:

源码调试

这样就能通过源码调试:

pnpm dev  # "dev": "tsdown --watch"

enforce: 'post' 改成 pre,再去调试 source 看看是什么东西:

成功解决

太好啦,我们有救啦!

这样就变成了玩字符串,应该比较简单。然后就是去兼容 unplugin-element-plus 的参数,剩下的交给 AI 写,哈哈哈哈哈哈哈哈!

走出变量提升的迷雾:10分钟彻底搞懂JavaScript执行机制与作用域

2025年5月18日 13:23

前言

JavaScript作为一门灵活的编程语言,其执行机制和变量声明规则有着诸多特性。理解这些特性对于编写高质量的JavaScript代码至关重要。本文将深入探讨JavaScript的执行机制、作用域、变量提升以及ES6引入的新特性,帮助开发者避免常见陷阱。

1. JavaScript代码的执行机制

JavaScript代码执行分为两个关键阶段:

1.1 编译阶段

当JavaScript引擎(如Chrome的V8)拿到代码后,首先进入编译阶段:

  • 代码从硬盘读入内存
  • 语法检测
  • 创建执行上下文环境
  • 处理变量声明和函数定义
// 编译阶段会创建类似这样的结构
currentVariable {
  showName: <function reference>,
  myName: undefined,
  // ...其他变量
}

1.2 执行阶段

编译完成后,代码按顺序执行,完成实际的赋值和函数调用操作。

2. 作用域:变量查找的规则

作用域决定了变量的可访问性和生命周期,JavaScript中包含三种主要作用域:

  • 全局作用域:在最外层定义的变量
  • 函数作用域:函数内部定义的变量
  • 块级作用域:ES6引入,在{}内使用letconst定义的变量

2.1 作用域链

当访问一个变量时,JavaScript会按照"冒泡查找"的规则:

  1. 先在当前作用域查找
  2. 找不到则向上层作用域查找
  3. 直到找到变量或到达全局作用域
  4. 全局作用域也没有则报错

这种查找路径构成了作用域链:当前作用域 → 父作用域 → ... → 全局作用域

2.2 词法作用域

JavaScript采用的是词法作用域(Lexical Scope),也称为静态作用域,这意味着函数的作用域在函数定义时就已确定,而非函数调用时:

let globalVar = 'global';

function outer() {
  let outerVar = 'outer';
  
  function inner() {
    console.log(outerVar); // 访问的是定义时的外部变量
    console.log(globalVar); // 同样可以访问更外层的全局变量
  }
  
  return inner;
}

const innerFn = outer();
innerFn(); // 输出: "outer" 和 "global"

词法作用域的特点:

  1. 静态确定:函数的作用域在编写代码时(词法分析阶段)就已确定
  2. 嵌套关系:内部函数可以访问外部函数中声明的变量
  3. 闭包基础:正是因为词法作用域,JavaScript才能实现强大的闭包功能

2.2.1 词法作用域与动态作用域的区别

为了理解词法作用域的特性,我们来看一个对比示例:

let value = 'global';

function foo() {
  console.log(value);
}

function bar() {
  let value = 'local';
  foo();
}

bar(); // 在词法作用域下输出: "global"
       // 如果是动态作用域则会输出: "local"

在上面的例子中,foo函数中的value引用的是全局变量,而非bar函数中的局部变量,这正是词法作用域的体现。

3. 变量提升(Hoisting)现象

3.1 var的变量提升

使用var声明的变量会在编译阶段被"提升"到当前作用域的顶部,但只提升声明,不提升赋值:

console.log(myName); // 输出:undefined
var myName = '曾小贤';

实际执行顺序相当于:

var myName; // 声明被提升
console.log(myName); // undefined
myName = '曾小贤'; // 赋值保留在原位置

3.2 函数声明的提升

函数声明会被完整提升到作用域顶部,包括函数体:

showName(); // "函数执行了"
function showName() {
    let b = 2;
    console.log('函数执行了');
}

这就是为什么在示例代码中,showName()可以在函数声明之前调用。

3.3 变量提升的问题

showName(); // 函数执行了
console.log(myName); // undefined
var myName = '曾小贤';
function showName() {
    let b = 2;
    console.log('函数执行了');
}

变量提升会导致代码执行结果与阅读顺序不一致,造成困惑,是JavaScript设计上的一个争议点。

4. let/const与暂时性死区(TDZ)

4.1 TDZ现象

ES6引入的letconst解决了变量提升的混乱,它们声明的变量不会被提升,相反会创建"暂时性死区":

console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 1;

从变量声明的块作用域开始,到该变量被赋值之前的区域,称为"暂时性死区"。在这个区域内,访问该变量会抛出错误。

4.2 块级作用域

{
  let blockVar = 'block scope';
  var functionVar = 'function scope';
}
console.log(functionVar); // "function scope"
console.log(blockVar); // ReferenceError: blockVar is not defined

letconst声明的变量严格遵循块级作用域,而var则遵循函数作用域。

5. var与let的关键区别

特性 var let
作用域 函数作用域 块级作用域
变量提升 会提升声明,初始值为undefined 不提升,有TDZ
重复声明 允许在同一作用域重复声明 禁止在同一块作用域重复声明
全局声明 成为window对象属性 不会成为window对象属性

6. 执行上下文示意图

从图中可以看到,执行上下文包含:

  • 全局上下文:最外层的执行环境
    • 变量环境:存储var声明的变量和函数声明
      a = undefined
      fn=function
      
    • 词法环境:存储let和const声明的变量

这种设计使得var声明的变量会出现提升现象,而letconst则不会。

7. 最佳实践建议

  1. 优先使用let和const:避免var的提升问题
  2. 默认使用const:如果变量不需要重新赋值,增加代码可靠性
  3. 理解TDZ:养成先声明后使用的习惯
  4. 合理划分作用域:减少作用域链查找,提高性能

总结

JavaScript的执行机制和变量声明规则看似复杂,实则有迹可循。理解编译和执行的双阶段过程、作用域链的查找规则、变量提升的工作原理以及ES6引入的新特性,不仅能帮助我们写出更可靠的代码,也能在面试中脱颖而出。

变量提升是JavaScript的一个历史包袱,但通过使用let和const,我们可以避开大多数陷阱。在现代JavaScript开发中,遵循"先声明后使用"的原则,合理利用块级作用域,才能充分发挥这门语言的优势。

纯血鸿蒙开发之广告服务(2)

作者 云_杰
2025年5月18日 12:34

前言

哈喽,小伙伴们大家好!我是青蓝逐码的云杰,上一篇文章我们学习鸿蒙广告服务中的banner广告和开屏广告,今天我们接着学习!

1.原生广告

原生广告是与应用内容融于一体的广告,通过“和谐”的内容呈现广告信息,在不破坏用户体验的前提下,为用户提供有价值的信息,展示形式包含图片和视频,支持您自由定制界面。

1.1 广告展示

1.2 开发步骤

1.2.1 获取OAID。

若需提升广告推送精准度,可以在请求参数AdRequestParams中添加oaid属性。

如何获取OAID参见获取OAID信息

identifier.getOAID().then((data: string) => {
  this.oaid = data;
}).catch((error: BusinessError) => {
  hilog.error(0x0000, 'testTag', 'Failed to get OAID');
});

1.2.2 请求广告。

请求单广告位广告,需要创建一个AdLoader对象,通过AdLoader的loadAd方法请求广告,最后通过AdLoadListener,来监听广告的加载状态。

若需提升广告推送精准度,可以在请求参数AdRequestParams中添加oaid属性。

请求广告参数名 类型 必填 说明
adType number 请求广告类型,原生广告类型为3。
adId string 广告位ID。如果仅调测广告,可使用测试广告位ID:testy63txaom86(原生视频),testu7m3hc4gvm(原生大图),testb65czjivt9(原生小图),testr6w14o0hqz(原生三图)。如果要接入正式广告,则需要申请正式的广告位ID。可在应用发布前进入流量变现官网,点击“开始变现”,登录鲸鸿动能媒体服务平台进行申请,具体操作详情请参见展示位创建
oaid string 开放匿名设备标识符,用于精准推送广告。不填无法获取到个性化广告。

1.2.3 广告监听

  • 通过 AdLoadListener 监听广告加载的回调:
    • onAdLoadFailure:广告加载失败。
    • onAdLoadSuccess:广告加载成功。
    // 广告请求回调监听
    const adLoaderListener: advertising.AdLoadListener = {
      // 广告请求失败回调
      onAdLoadFailure: (errorCode: number, errorMsg: string) => {
        hilog.error(0x0000, 'testTag', '%{public}s',
          `Failed to request ad, message: ${errorMsg}, error code: ${errorCode}`);
      },
      // 广告请求成功回调
      onAdLoadSuccess: (ads: Array<advertising.Advertisement>) => {
        hilog.info(0x0000, 'testTag', '%{public}s', `Succeeded in requesting ad`);
        // 调用原生广告展示页面
        promptAction.showToast({ message: '成功·' })
        emitter.emit('NativeAdPage',
          { data: { 'ads': ads, 'adDisplayOptions': adDisplayOptions } })
      }
    };

1.2.4 封装请求

import { advertising } from '@kit.AdsKit';
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { emitter } from '@kit.BasicServicesKit';

export class NativeAdUtil {
  private ads: Array<advertising.Advertisement> = [];
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;

  requestAd() {
    // 广告展示参数
    const adDisplayOptions: advertising.AdDisplayOptions = {
      // 是否静音,默认不静音
      mute: false
    }
    // 原生广告配置
    const adOptions: advertising.AdOptions = {
      // 设置是否请求非个性化广告
      nonPersonalizedAd: 1,
      // 是否允许流量下载0:不允许,1:允许,不设置以广告主设置为准
      allowMobileTraffic: 0,
      // 是否希望根据 COPPA 的规定将您的内容视为面向儿童的内容: -1默认值,不确定 0不希望 1希望
      tagForChildProtection: -1,
      // 是否希望按适合未达到法定承诺年龄的欧洲经济区 (EEA) 用户的方式处理该广告请求: -1默认值,不确定 0不希望 1希望
      tagForUnderAgeOfPromise: -1,
      // 设置广告内容分级上限: W: 3+,所有受众 PI: 7+,家长指导 J:12+,青少年 A: 16+/18+,成人受众
      adContentClassification: 'A'
    }
    // 原生广告请求参数
    const nativeVideoAdReqParams: advertising.AdRequestParams = {
      // 'testu7m3hc4gvm'为测试专用的广告位ID,应用正式发布时需要改为正式的广告位ID
      adId: 'testu7m3hc4gvm',
      adType: 3,
      adCount: 1,
      // 原生广告自定义扩展参数。等所有广告素材下载完后再回调
      enableDirectReturnVideoAd: true
    }
    // 广告请求回调监听
    const adLoaderListener: advertising.AdLoadListener = {
      // 广告请求失败回调
      onAdLoadFailure: (errorCode: number, errorMsg: string) => {
        hilog.error(0x0000, 'testTag', '%{public}s',
          `Failed to request ad, message: ${errorMsg}, error code: ${errorCode}`);
      },
      // 广告请求成功回调
      onAdLoadSuccess: (ads: Array<advertising.Advertisement>) => {
        hilog.info(0x0000, 'testTag', '%{public}s', `Succeeded in requesting ad`);
        // 调用原生广告展示页面
        promptAction.showToast({ message: '成功·' })
        emitter.emit('NativeAdPage',
          { data: { 'ads': ads, 'adDisplayOptions': adDisplayOptions } })
      }
    };
    // 创建AdLoader广告对象
    const load: advertising.AdLoader = new advertising.AdLoader(this.context);
    // 调用广告请求接口
    load.loadAd(nativeVideoAdReqParams, adOptions, adLoaderListener);
  }
}

export const nativeAdUtil = new NativeAdUtil()

1.2.5 封装原生广告组件

  1. 请求广告

     nativeAdUtil.requestAd()
    
  2. 获取广告参数信息

      aboutToAppear() {
        emitter.on('NativeAdPage', (eventData: emitter.EventData) => {
          const data = eventData.data as Record<string, object>
          this.ads = data['ads'] as Array<advertising.Advertisement>
          this.adDisplayOptions = data['adDisplayOptions'] as advertising.AdDisplayOptions
          this.isShow = true
        })
        nativeAdUtil.requestAd()
      }
    
  3. 广告封装组件

    import { AdComponent, advertising } from '@kit.AdsKit';
    import { emitter } from '@kit.BasicServicesKit';
    import { nativeAdUtil } from '../utils/NativeAdUtil';
    
    
    @Component
    export struct NativeAdView {
      // 广告内容
      private ads: Array<advertising.Advertisement> = [];
      private adDisplayOptions: advertising.AdDisplayOptions = {
        // 是否静音,默认不静音
        mute: false
      };
      @State isShow: boolean = false
      @State isShowClose: boolean = false
    
      aboutToAppear() {
        emitter.on('NativeAdPage', (eventData: emitter.EventData) => {
          const data = eventData.data as Record<string, object>
          this.ads = data['ads'] as Array<advertising.Advertisement>
          this.adDisplayOptions = data['adDisplayOptions'] as advertising.AdDisplayOptions
          this.isShow = true
        })
        nativeAdUtil.requestAd()
      }
    
      build() {
        if (this.isShow) {
          Column() {
            AdComponent({
              ads: this.ads,
              displayOptions: this.adDisplayOptions,
              interactionListener: {
                onStatusChanged: (status: string, ad: advertising.Advertisement, data: string) => {
                  switch (status) {
                    case AdStatus.AD_OPEN:
                      this.isShowClose = true
                      break;
                    case AdStatus.AD_CLICKED:
                      break;
                    case AdStatus.AD_CLOSED:
                      this.isShowClose = false
                      this.isShow = false
                      break;
                    default:
                  }
                }
              }
            })
              .width('100%')
            if (this.isShowClose) {
              Row() {
                Text('关闭')
                  .fontSize(12)
                  .onClick(() => {
                    this.isShow = false
                  })
              }
              .width('100%')
            }
          }
        }
    
      }
    }
    
    enum AdStatus {
      AD_OPEN = 'onAdOpen',
      AD_CLICKED = 'onAdClick',
      AD_CLOSED = 'onAdClose'
    }
    
  4. 效果展示

2. 插屏广告

插屏广告是一种在应用开启、暂停或退出时以全屏或半屏的形式弹出的广告形式,展示时机巧妙避开用户对应用的正常体验,尺寸大,曝光效果好。

2.1 广告展示

2.2 订阅广告监听

开发者需要在App中订阅com.huawei.hms.pps.action.PPS_INTERSTITIAL_STATUS_CHANGED事件来监听插屏广告页面变化并接收插屏信息。示例代码中的订阅方法registerPPSReceiver()需要在每次展示广告前调用 。 在订阅到公共事件后,可以从CommonEventData的parameters参数中使用"interstitial_ad_status"作为key值获取插屏广告页面变化状态。

import { BusinessError, commonEventManager } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const KEY_INTERSTITIAL_STATUS = 'interstitial_ad_status';

export class InterstitialAdUtil {
  // 用于保存创建成功的订阅者对象,后续使用其完成订阅及退订的动作
  private subscriber: commonEventManager.CommonEventSubscriber | null = null;

  // 订阅方法,需要在每次展示广告前调用
  public registerPPSReceiver(): void {
    if (this.subscriber) {
      this.unRegisterPPSReceiver();
    }
    // 订阅者信息
    const subscribeInfo: commonEventManager.CommonEventSubscribeInfo = {
      events: ['com.huawei.hms.pps.action.PPS_INTERSTITIAL_STATUS_CHANGED'],
      publisherBundleName: 'com.huawei.hms.adsservice'
    };
    // 创建订阅者回调
    commonEventManager.createSubscriber(subscribeInfo,
      (err: BusinessError, commonEventSubscriber: commonEventManager.CommonEventSubscriber) => {
        if (err) {
          hilog.error(0x0000, 'testTag', '%{public}s', `CreateSubscriber error, ${err.code}, message: ${err.message}}`);
          return;
        }
        hilog.info(0x0000, 'testTag', '%{public}s', 'Succeeded in creating subscriber');
        this.subscriber = commonEventSubscriber;
        // 订阅公共事件回调
        if (!this.subscriber) {
          hilog.warn(0x0000, 'testTag', '%{public}s', 'Need to create subscriber');
          return;
        }
        commonEventManager.subscribe(this.subscriber,
          (err: BusinessError, commonEventData: commonEventManager.CommonEventData) => {
            if (err) {
              hilog.error(0x0000, 'testTag', '%{public}s', `Subscribe error, ${err.code}, message: ${err.message}`);
            } else {
              // 订阅者成功接收到公共事件
              // 获取插屏广告页面变化状态
              const status: string = commonEventData?.parameters?.[KEY_INTERSTITIAL_STATUS];
              switch (status) {
                case AdStatus.AD_OPEN:
                  console.info('onAdOpen');
                  break;
                case AdStatus.AD_CLICKED:
                  console.info('onAdClick');
                  break;
                case AdStatus.AD_CLOSED:
                  console.info('onAdClose');
                  this.unRegisterPPSReceiver();
                  break;
                case AdStatus.AD_VIDEO_START:
                  console.info('onAdVideoStart');
                  break;
                case AdStatus.AD_COMPLETED:
                  console.info('onAdCompleted');
                  break;
                default:
                  break;
              }
            }
          });
      });
  }

  // 取消订阅
  public unRegisterPPSReceiver(): void {
    commonEventManager.unsubscribe(this.subscriber, (err: BusinessError) => {
      if (err) {
        hilog.error(0x0000, 'testTag', '%{public}s', `Unsubscribe error, ${err.code}, message: ${err.message}}`);
      } else {
        hilog.info(0x0000, 'testTag', '%{public}s', 'Succeeded in unsubscribing');
        this.subscriber = null;
      }
    });
  }
}

export const InterstitialAd = new InterstitialAdUtil()

enum AdStatus {
  AD_OPEN = 'onAdOpen',
  AD_CLICKED = 'onAdClick',
  AD_CLOSED = 'onAdClose',
  AD_VIDEO_START = 'onVideoPlayBegin',
  AD_COMPLETED = 'onVideoPlayEnd'
}

2.3 开发使用

2.3.1 请求广告

  private requestAd(adLoader: advertising.AdLoader): void {
    const adRequestParam: advertising.AdRequestParams = {
      // 广告类型:插屏广告
      adType: 12,
      // 'testb4znbuh3n2'为测试专用的广告位ID,App正式发布时需要改为正式的广告位ID
      adId: 'testb4znbuh3n2',
      // 开放匿名设备标识符
      oaid: this.oaid
    };
    const adOption: advertising.AdOptions = {
      // 设置是否请求非个性化广告
      nonPersonalizedAd: 0,
      // 是否允许流量下载0:不允许,1:允许,不设置以广告主设置为准
      allowMobileTraffic: 0,
      // 是否希望根据 COPPA 的规定将您的内容视为面向儿童的内容: -1默认值,不确定 0不希望 1希望
      tagForChildProtection: -1,
      // 是否希望按适合未达到法定承诺年龄的欧洲经济区 (EEA) 用户的方式处理该广告请求: -1默认值,不确定 0不希望 1希望
      tagForUnderAgeOfPromise: -1,
      // 设置广告内容分级上限: W: 3+,所有受众 PI: 7+,家长指导 J:12+,青少年 A: 16+/18+,成人受众
      adContentClassification: 'A'
    };
    const adLoaderListener: advertising.AdLoadListener = {
      onAdLoadFailure: (errorCode: number, errorMsg: string) => {
        hilog.error(0x0000, 'testTag', '%{public}s',
          `Failed to request ad, message: ${errorMsg}, error code: ${errorCode}`);
      },
      onAdLoadSuccess: (ads: Array<advertising.Advertisement>) => {
        hilog.info(0x0000, 'testTag', '%{public}s', 'Succeeded in requesting ad!');
        this.ads = [];
        this.ads.push(...ads);
      },
    };
    adLoader.loadAd(adRequestParam, adOption, adLoaderListener);
  }

2.3.2 展示广告

  1. 展示时期

由于我们不清楚广告参数什么时候请求成功,我们可以监听存储广告参数的数组ads,当ads的长度不为空时,就可以展示广告advertising.showAd

import { advertising, identifier } from '@kit.AdsKit';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct Index {
  private ads: Array<advertising.Advertisement> = [];
  private context = getContext(this) as common.UIAbilityContext;
  private oaid: string = '';
  private displayOptions: advertising.AdDisplayOptions = {
    // 插屏广告视频播放是否静音
    mute: true
  };
  @State index: number = 0

  aboutToAppear() {
    try {
      // 使用Promise回调方式获取OAID
      identifier.getOAID().then((data) => {
        this.oaid = data;
        hilog.info(0x0000, 'testTag', '%{public}s', 'Succeeded in getting adsIdentifierInfo by promise');
      }).catch((error: BusinessError) => {
        hilog.error(0x0000, 'testTag', '%{public}s', `Failed to get adsIdentifierInfo, message: ${error.message}`);
      })
    } catch (error) {
      hilog.error(0x0000, 'testTag', '%{public}s', `Catch err, code: ${error.code}, message: ${error.message}`);
    }
    let load: advertising.AdLoader = new advertising.AdLoader(this.context);
    this.requestAd(load);

    //监听广告数组长度来判断广告的出现时期
    this.showAd()

  }

  build() {
    Column() {

    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  private showAd() {
    // 请在此处自行增加步骤3中的,注册插屏广告状态监听器
    // ...
    // InterstitialAd.registerPPSReceiver()
    // 此处ads[0]表示请求到的第一个广告,用户根据实际情况选择
    advertising.showAd(this.ads[0], this.displayOptions, this.context);
  }

  private requestAd(adLoader: advertising.AdLoader): void {
    const adRequestParam: advertising.AdRequestParams = {
      // 广告类型:插屏广告
      adType: 12,
      // 'testb4znbuh3n2'为测试专用的广告位ID,App正式发布时需要改为正式的广告位ID
      adId: 'testb4znbuh3n2',
      // 开放匿名设备标识符
      oaid: this.oaid
    };
    const adOption: advertising.AdOptions = {
      // 设置是否请求非个性化广告
      nonPersonalizedAd: 0,
      // 是否允许流量下载0:不允许,1:允许,不设置以广告主设置为准
      allowMobileTraffic: 0,
      // 是否希望根据 COPPA 的规定将您的内容视为面向儿童的内容: -1默认值,不确定 0不希望 1希望
      tagForChildProtection: -1,
      // 是否希望按适合未达到法定承诺年龄的欧洲经济区 (EEA) 用户的方式处理该广告请求: -1默认值,不确定 0不希望 1希望
      tagForUnderAgeOfPromise: -1,
      // 设置广告内容分级上限: W: 3+,所有受众 PI: 7+,家长指导 J:12+,青少年 A: 16+/18+,成人受众
      adContentClassification: 'A'
    };
    const adLoaderListener: advertising.AdLoadListener = {
      onAdLoadFailure: (errorCode: number, errorMsg: string) => {
        hilog.error(0x0000, 'testTag', '%{public}s',
          `Failed to request ad, message: ${errorMsg}, error code: ${errorCode}`);
      },
      onAdLoadSuccess: (ads: Array<advertising.Advertisement>) => {
        hilog.info(0x0000, 'testTag', '%{public}s', 'Succeeded in requesting ad!');
        this.ads = [];
        this.ads.push(...ads);
      },
    };
    adLoader.loadAd(adRequestParam, adOption, adLoaderListener);
  }
}
  1. 效果展示

总结

今天介绍了广告服务中的两个,目前我们已经掌握了广告服务,如果对这方面还有疑惑,欢迎在评论区留言或者加入我们组织,组织里很多厉害的大佬!如果我的内容对您有帮助,可以点赞、关注+收藏,谢谢大家!如果小伙伴对鸿蒙的其他内容感兴趣,欢迎加入我们青蓝逐码!

青蓝逐码官网:www.qinglanzhuma.cn/

echarts按需引入,减小打包后体积

作者 Rosen
2025年5月18日 11:58

ECharts 提供的模块是可组合的(modular),通过 echarts/core 模块实现按需引入。

npm install echarts

封装echarts,新建一个文件echarts-setup.ts

// echarts-setup.ts
import * as echarts from 'echarts/core'
import {
  LineChart
} from 'echarts/charts'
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent
} from 'echarts/components'
import {
  CanvasRenderer
} from 'echarts/renderers'

// 注册所需组件
echarts.use([
  LineChart,
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent,
  CanvasRenderer
])

export default echarts

使用:

// MyChart.vue / .tsx
import echarts from './echarts-setup'

// 初始化图表
onMounted(() => {
  const chart = echarts.init(document.getElementById('main'))
  chart.setOption({
    title: { text: '折线图示例' },
    tooltip: {},
    xAxis: { data: ['A', 'B', 'C'] },
    yAxis: {},
    series: [{
      type: 'line',
      data: [5, 20, 36]
    }]
  })
})

正则表达式与文本处理的艺术

作者 BitCat
2025年5月18日 11:50

引言

在前端开发领域,文本处理是一项核心技能。正则表达式作为一种强大的模式匹配工具,能够帮助我们高效地处理各种复杂的文本操作任务。

正则表达式基础

什么是正则表达式?

正则表达式是一种用于匹配字符串中字符组合的模式。它由一系列字符和特殊符号组成,用于定义搜索模式。

// 基本示例:匹配所有数字
const numberPattern = /\d+/g;
const text = "我有23个苹果和45个橙子";
const numbers = text.match(numberPattern); // 结果: ["23", "45"]

基本语法元素

元素 描述 示例
. 匹配任意单个字符 /a.c/ 匹配 "abc", "axc" 等
[] 字符集,匹配方括号内的任意字符 /[abc]/ 匹配 "a", "b", 或 "c"
[^] 否定字符集,匹配任何不在方括号内的字符 /[^abc]/ 匹配除 "a", "b", "c" 之外的字符
\d 匹配任意数字,等价于 [0-9] /\d{3}/ 匹配三个连续数字
\w 匹配任意字母、数字或下划线,等价于 [A-Za-z0-9_] /\w+/ 匹配一个或多个字母数字字符
\s 匹配任意空白字符 /\s/ 匹配空格、制表符等

量词

量词决定了模式应该匹配多少次。

量词 描述 示例
* 匹配前一个元素零次或多次 /a*/ 匹配 "", "a", "aa", ...
+ 匹配前一个元素一次或多次 /a+/ 匹配 "a", "aa", ... 但不匹配 ""
? 匹配前一个元素零次或一次 /a?/ 匹配 "" 或 "a"
{n} 精确匹配前一个元素n次 /a{3}/ 匹配 "aaa"
{n,} 匹配前一个元素至少n次 /a{2,}/ 匹配 "aa", "aaa", ...
{n,m} 匹配前一个元素n至m次 /a{1,3}/ 匹配 "a", "aa", 或 "aaa"

锚点

锚点用于指定匹配的位置。

// 使用锚点匹配行首和行尾
const pattern = /^开始.*结束$/;
console.log(pattern.test("开始这是中间内容结束")); // true
console.log(pattern.test("这不是开始的内容结束")); // false

贪婪与惰性匹配

正则表达式的默认行为是贪婪匹配,它会尽可能多地匹配字符。相比之下,惰性匹配则尽可能少地匹配字符。

贪婪匹配

// 贪婪匹配示例
const htmlText = "<div>内容1</div><div>内容2</div>";
const greedyPattern = /<div>.*<\/div>/;
const greedyMatch = htmlText.match(greedyPattern);
console.log(greedyMatch[0]); // 结果: "<div>内容1</div><div>内容2</div>"

贪婪模式下,.* 会匹配尽可能多的字符,导致整个字符串都被匹配。

惰性匹配

// 惰性匹配示例
const htmlText = "<div>内容1</div><div>内容2</div>";
const lazyPattern = /<div>.*?<\/div>/g;
const lazyMatches = htmlText.match(lazyPattern);
console.log(lazyMatches); // 结果: ["<div>内容1</div>", "<div>内容2</div>"]

通过在量词后添加问号 ?,可以将贪婪匹配转为惰性匹配。惰性模式下,正则表达式引擎会尽可能少地匹配字符,在第一次找到完整匹配后就停止。

性能对比

// 贪婪匹配性能测试
const longText = "<div>".repeat(1000) + "</div>".repeat(1000);
console.time('greedy');
const greedyResult = /<div>.*<\/div>/.test(longText);
console.timeEnd('greedy'); // 可能需要很长时间甚至超时

// 惰性匹配性能测试
console.time('lazy');
const lazyResult = /<div>.*?<\/div>/.test(longText);
console.timeEnd('lazy'); // 通常比贪婪匹配快得多

在处理长文本时,惰性匹配通常比贪婪匹配有更好的性能,因为它避免了过度回溯。

捕获组

捕获组允许我们提取模式的特定部分,这在需要处理复杂文本时尤为有用。

基本捕获组

// 基本捕获组
const dateString = "今天是2023-05-15";
const datePattern = /(\d{4})-(\d{2})-(\d{2})/;
const match = dateString.match(datePattern);
console.log(match[0]); // "2023-05-15"(完整匹配)
console.log(match[1]); // "2023"(第一个捕获组)
console.log(match[2]); // "05"(第二个捕获组)
console.log(match[3]); // "15"(第三个捕获组)

命名捕获组

命名捕获组使代码更易理解,特别是在复杂模式中。

// 命名捕获组
const dateString = "今天是2023-05-15";
const datePattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = dateString.match(datePattern);
console.log(match.groups.year);  // "2023"
console.log(match.groups.month); // "05"
console.log(match.groups.day);   // "15"

非捕获组

当我们只需要分组但不需要捕获匹配内容时,可以使用非捕获组。

// 非捕获组
const text = "HTML和CSS都是前端必备技能";
const pattern = /(?:HTML|CSS)和(?:HTML|CSS)/;
console.log(pattern.test(text)); // true

反向引用

反向引用允许我们在模式中引用之前的捕获组。

// 反向引用
const htmlWithAttrs = '<div class="container">内容</div>';
const pattern = /<(\w+)([^>]*)>(.*?)<\/\1>/;
const match = htmlWithAttrs.match(pattern);
console.log(match[1]); // "div"(标签名)
console.log(match[2]); // ' class="container"'(属性)
console.log(match[3]); // "内容"(内容)

性能优化技巧

避免过度使用贪婪模式

贪婪模式可能导致大量回溯,降低性能。在适当的情况下,使用惰性匹配可以显著提高效率。

// 不推荐(在大文本中可能很慢)
const slowPattern = /<div>.*<\/div>/;

// 推荐
const fastPattern = /<div>.*?<\/div>/;

优先使用更具体的模式

// 不推荐(太宽泛)
const emailCheck1 = /.*@.*/;

// 推荐(更具体)
const emailCheck2 = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;

避免嵌套量词

嵌套量词如 (a+)+ 可能导致指数级的性能下降,被称为"灾难性回溯"。

// 危险模式,可能导致回溯爆炸
const badPattern = /^(a+)*$/;
const input = "aaaaaaaaaaaaaaa!"; // 以感叹号结尾
console.time('test');
badPattern.test(input); // 可能导致浏览器挂起
console.timeEnd('test');

使用原子组优化

在支持原子组的环境中,可以使用原子组 (?>...) 来控制回溯。

// 在某些正则实现中支持原子组(JavaScript标准还不支持)
// const atomicGroup = /(?>a+)b/;

实际应用案例

表单验证

// 邮箱验证
function validateEmail(email) {
  const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  return pattern.test(email);
}

// 密码复杂度验证(至少8位,包含大小写字母、数字和特殊字符)
function validatePassword(password) {
  const pattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+])[A-Za-z\d!@#$%^&*()_+]{8,}$/;
  return pattern.test(password);
}

// 手机号验证(中国大陆)
function validatePhone(phone) {
  const pattern = /^1[3-9]\d{9}$/;
  return pattern.test(phone);
}

高亮文本匹配

// 搜索关键词高亮
function highlightKeywords(text, keyword) {
  const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const pattern = new RegExp(`(${escapedKeyword})`, 'gi');
  return text.replace(pattern, '<span class="highlight">$1</span>');
}

// 使用示例
const searchResult = highlightKeywords(
  "JavaScript是一种用于网页交互的编程语言",
  "javascript"
);
console.log(searchResult); // "<span class="highlight">JavaScript</span>是一种用于网页交互的编程语言"

URL解析

// 提取URL参数
function getUrlParams(url) {
  const params = {};
  const pattern = /[?&]([^=&#]+)=([^&#]*)/g;
  let match;
  
  while ((match = pattern.exec(url)) !== null) {
    params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
  }
  
  return params;
}

// 使用示例
const url = "https://example.com/search?q=正则表达式&page=1&sort=desc";
const params = getUrlParams(url);
console.log(params); // {q: "正则表达式", page: "1", sort: "desc"}

代码格式化

// 格式化数字为千分位表示
function formatNumber(num) {
  return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

// 使用示例
console.log(formatNumber(1234567)); // "1,234,567"

边缘情况和限制

正则表达式的局限性

正则表达式不适合处理一些特定的文本结构,如HTML解析或嵌套结构。

// 错误的做法:使用正则表达式解析HTML
const htmlContent = '<div><p>文本1</p><p>文本2 <a href="#">链接</a></p></div>';
const badPattern = /<p>(.*?)<\/p>/g; // 不能正确处理嵌套标签

// 更好的做法:使用DOM解析
function extractParagraphText(html) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  const paragraphs = doc.querySelectorAll('p');
  return Array.from(paragraphs).map(p => p.textContent);
}

处理Unicode字符

JavaScript正则表达式对Unicode的支持有限,需要使用u标志。

// 没有u标志,无法正确处理Unicode
console.log(/^.$/.test('😊')); // false(表情符号被视为两个字符)

// 使用u标志正确处理Unicode
console.log(/^.$/u.test('😊')); // true

避免过度依赖正则表达式

有时候,使用字符串方法或专门的解析库可能是更好的选择。

// 对于简单的字符串操作,使用内置方法可能更清晰
// 不推荐
const csv = "a,b,c";
const values1 = csv.match(/([^,]+),([^,]+),([^,]+)/);

// 推荐
const values2 = csv.split(',');

对比分析

正则表达式 vs. 字符串方法

方法 优势 劣势
正则表达式 强大的模式匹配能力,简洁的代码 学习曲线陡峭,调试困难,性能问题
字符串方法 直观易懂,性能可预测 复杂模式匹配需要更多代码
// 提取域名 - 正则表达式方法
function getDomainRegex(url) {
  const match = url.match(/^https?:\/\/([^/]+)/);
  return match ? match[1] : null;
}

// 提取域名 - 字符串方法
function getDomainString(url) {
  if (!url.startsWith('http://') && !url.startsWith('https://')) {
    return null;
  }
  const withoutProtocol = url.replace(/^https?:\/\//, '');
  const firstSlash = withoutProtocol.indexOf('/');
  return firstSlash === -1 ? withoutProtocol : withoutProtocol.substring(0, firstSlash);
}

浏览器兼容性

大多数现代浏览器支持ES2018中引入的正则表达式功能(如命名捕获组),但在支持旧浏览器的项目中需要注意。

// 命名捕获组(在较旧的浏览器中不支持)
const datePattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;

// 向后兼容的替代方案
const oldDatePattern = /(\d{4})-(\d{2})-(\d{2})/;
const match = "2023-05-15".match(oldDatePattern);
const [_, year, month, day] = match;

结论

正则表达式是前端开发中强大而必不可少的工具。通过深入理解贪婪与惰性匹配、捕获组、性能优化等核心概念,我们可以编写出高效、可读的正则表达式,解决各种文本处理问题。虽然学习曲线较陡,但掌握这一技能将极大提升我们的开发效率和代码质量。

正则表达式的精髓在于找到复杂性和可读性之间的平衡。一个好的正则表达式应当既能解决问题,又便于其他人理解和维护。

学习资源


如果你觉得这篇文章有帮助,欢迎点赞收藏,也期待在评论区看到你的想法和建议!👇

终身学习,共同成长。

咱们下一期见

💻

❌
❌