Rspack 插件架构原理:从 Tapable 到 Rust Hook

背景

在上一篇文章《Rspack 原理:webpack,我为什么不要你》中,我们了解到 Rspack 的核心编译流程是通过 JavaScript 调用 Rust 封装的 JsCompiler 方法完成的。尽管底层由高性能的 Rust 实现驱动,但 Rspack 却依然兼容传统的 JavaScript 插件生态,像 HtmlWebpackPluginAssetsWebpackPlugin 这样的常用插件也能正常运行。更多兼容插件可以前往 Plugin 兼容 页面进行查看。

好奇你的估计会疑问,Rspack 是如何让这些 JS 插件在 Rust 引擎中正常工作的?

细心的你估计发现在 JsCompiler 方法调用时,传参里有一个非常关键的参数,就是第四个参数 this.#registers,该参数是 this.#createHooksRegisters 方法的返回值。那么 this.#registers 的作用是啥?它在 Rspack 插件架构里扮演着什么样的角色?该参数传到 Rust 层又是如何被处理?

要回答这些问题,就要了解这背后的关键:Rspack 的插件架构设计。本篇文章将为你一一解答,另外你还能进一步了解:

  • Rspack 插件架构是如何实现,和 webpack 的 Tapable 在实现上有什么不一样?
  • Rust 是怎么定义和执行 hook 的?
  • Tapable 和 Rust Hook 的钩子都有哪些特点?
  • JavaScript 插件是如何注册进来的?
  • 当 Rust 层编译流程触发 compiler.hooks.make.call() 时,底层发生了什么?
  • 为什么能同时执行 JavaScript 和 Rust 的插件?顺序是怎么保证的?

本篇文章涉及的代码片段可能相对比较多,如果你在阅读中感觉压力比较大的话,可以跳过这些代码片段,直接看解释,我会针对每段代码都做通俗的解释。

什么是插件

我们先理解一下插件的基本概念,在构建工具中, 插件不仅是一种扩展模块 ,更是一种基于钩子机制或生命周期扩展点的架构模式,这种架构的核心思想是:在核心流程的关键节点上暴露一系列可注册的钩子(Hooks),允许外部插件注入自定义逻辑。这些钩子支持多种执行方式:同步/异步、串行/并行,从而实现灵活而强大的流程控制能力。

这本质上借鉴了发布/订阅模式 的思想,但它比单纯的事件系统更加可控:每次钩子触发时都会携带当前上下文(如 compilercompilation),插件可以通过操作这些上下文对象来影响后续编译行为。

以 webpack Tapable 的经典插件为例:

js 复制代码
class MyPlugin {
  apply(compiler) {
    // 注册 hooks
    compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
      // 可以访问 compilation 对象,添加资源、修改模块等
    })
  }
}

在这个例子中:

  • apply(compiler) 是插件入口,接收编译器实例;
  • compiler.hooks.compilation 是一个钩子;
  • .tap() 表示在此钩子上注册一个同步回调;
  • 回调函数接收 compilation 上下文,用于干预构建过程。

Rspack 并不是直接使用 webpack 的 Tapable 库,而是自己实现了一套轻量化的 LiteTapable,并与 Rust 层的 Hook 系统进行双向桥接。这套架构分为两层:

  • JS 层 :使用 TypeScript 实现的 LiteTapable ,处理 JS 插件注册,收集 Hooks 回调;
  • Rust 层:使用 Rust 实现的自研 Hook 系统,驱动编译流程,在关键节点触发 Hook。

这两者之间通过一个桥梁机制连接,而这正是开篇我们提到的 this.#createHooksRegisters 方法的使命。该方法执行赋值给 this.#registers 作为 JsCompiler 的第四个参数:

this.#createHooksRegisters 的作用是:将 JavaScript 层所有已注册的插件钩子封装成一种能被 Rust 识别的数据格式,然后传递给 Rust 底层编译器。

换句话说,this.#createHooksRegisters 是一个钩子注册表,记录了所有 JS 插件希望监听的生命周期事件。当 Rust 核心在编译过程中走到某个阶段(比如 make),它会查询这个注册表,并通知 JS 层执行对应的回调。

这样一来,即便核心流程运行在 Rust 中,也能准确地调用 JavaScript 编写的插件逻辑。那么 this.#createHooksRegisters 方法实现是怎样的呢?

#createHooksRegisters 实现

js 复制代码
#createHooksRegisters(): binding.RegisterJsTaps {
  const ref = new WeakRef(this);
  const getCompiler = () => ref.deref()!;
  const createTap = this.#createHookRegisterTaps.bind(this);
  const createMapTap = this.#createHookMapRegisterTaps.bind(this);
  
  return {
    ...createCompilerHooksRegisters(getCompiler, createTap, createMapTap),
    ...createCompilationHooksRegisters(getCompiler, createTap, createMapTap),
    // ... 更多注册器函数
  };
}

#createHooksRegisters 方法最终返回的是一个符合 binding.RegisterJsTaps 接口的对象,可以理解为一个专为 Rust 编译器准备的钩子注册表。

每个 create***HooksRegisters 负责某一类钩子的注册。例如:

  • createCompilerHooksRegisters:负责 Compiler 对象的钩子;
  • createCompilationHooksRegisters:负责 Compilation 对象的钩子;
  • 其他还包括 ModuleFactoryNormalModule 等不同组件的钩子,上面没有列出来。

我们看到 create***HooksRegisters 工厂函数接收三个通用参数:

  • getCompiler:获取当前 compiler 实例;
  • createTap:用于注册普通钩子上的插件回调;
  • createMapTap:用来注册那些按 key 动态分发的钩子(比如 ruleloader)上的回调。

createTapcreateMapTap 这两个参数可能有点不好理解,反正我们知道它们的作用是把 JS 插件的回调包装成 Rust 能调用的形式就可以了。

接下来,我们以 createCompilerHooksRegisters 为例,看看这些函数到底是怎么工作的:

js 复制代码
export const createCompilerHooksRegisters: CreatePartialRegisters<'Compiler'> = (
  getCompiler,
  createTap
) => {
  return {
    registerCompilerMakeTaps: createTap(
      binding.RegisterJsTapKind.CompilerMake,

      // 第二个参数: 获取 JS 层对应的 Hook 实例
      function () {
        return getCompiler().hooks.make;
      },

      // 第三个参数: 包装回调函数,支持异步执行
      function (queried) {
        return async function () {
          return await queried.promise(
            getCompiler().__internal__get_compilation()!
          );
        };
      }
    ),
  };
};

以上展示了 Compilermake 钩子的注册逻辑,这里的关键在于 createTap 接收了两个函数式参数(重点看第二和第三个参数):

  • 第二个参数:返回一个 Hook ,延迟获取 JS 层的 hooks.make 实例;
  • 第三个参数:将原始 tap 回调包装成一个可被 Rust 调用的异步函数,并注入当前 compilation 上下文。

这里的 queried.promise(...) 并非原生 Promise,而是 Rspack 自定义的一种待执行任务表示方式,用于桥接到 Rust 的异步运行时。

createTap 绑定的函数是 #createHookRegisterTaps

js 复制代码
#createHookRegisterTaps(registerKind, getHook, createTap) {
  return function getTaps(stages: number[]): JsTap[] {
    const hook = getHook();
    if (!hook.isUsed()) return [];

    // 1. 构建阶段断点 [min, stage1, stage2, ..., max]
    const breakpoints = [minStage, ...stages.sort(), maxStage];
    const jsTaps = [];

    // 2. 按区间遍历,查询每个 stage 范围内的 taps
    for (let i = 0; i < breakpoints.length - 1; i++) {
      const from = breakpoints[i];
      const to = breakpoints[i + 1];

      // 查询 [from, to) 区间内注册的所有插件
      const queried = hook.queryStageRange([from, to]);

      if (queried.taps.length === 0) continue;

      jsTaps.push({
        function: createTap(queried),  // 包装为可调用函数
        stage: from + 1                // 对齐执行优先级
      });
    }

    return jsTaps;
  };
}

看完上面的代码和注释是不是好累,没关系,我们主要记住它是做了几件关键的事情就行:

  • 支持 Stage 分段查询 :Rust 传入 [10, 20] 表示希望触发该阶段的插件;
  • 按序排列,精准控制执行流程 :所有返回的 JsTapstage 排序,确保插件严格按照注册时声明的优先级运行;
  • 懒加载 + 延迟求值getHook() 和回调包装都在实际触发时才执行,避免提前访问未初始化的上下文。

上述所有操作的前提,JS 层必须有一套钩子系统来支撑插件注册与查询。Rspack 使用的是自研的 @rspack/lite-tapable 库:

js 复制代码
import * as liteTapable from "@rspack/lite-tapable";

this.hooks = {
  initialize: new liteTapable.SyncHook([]),
  shouldEmit: new liteTapable.SyncBailHook(["compilation"]),
  make: new liteTapable.AsyncParallelHook(["compilation"]),
  // ...
};

可以看到,API 设计几乎完全兼容 webpack 的 Tapable,不过从名字我们可以知道,它是一个更加轻量的 Tapable。接下来我们深入 @rspack/lite-tapable 的源码,看看它相比 webpack 的 Tapable 到底做了哪些优化。

LiteTapable 架构

LiteTapable 和 webpack 的 Tapable 一样,都是基于订阅/发布模式的设计思想,这意味着你熟悉的 .tap().call().callAsync() 等写法都可以正常运行。不同的是 LiteTapable 针对 JS 与 Rust 跨语言协作做了特殊优化。LiteTapable 提供如下钩子类型:

同步钩子(Sync Hooks)

同步钩子有如下几种类型:

  • SyncHook:最基础的同步钩子,依次执行所有回调,不传返回值,也不中断;
  • SyncBailHook :熔断式钩子,一旦某个回调返回非 undefined 值,立即停止后续执行。常用于获取结果类场景;
  • SyncWaterfallHook:瀑布流模式,前一个回调的返回值作为下一个回调的第一个参数,形成链式传递。

我们来看个示例,SyncHook 的典型用法:

js 复制代码
const { SyncHook } = require('@rspack/lite-tapable');

const syncHook = new SyncHook();

syncHook.tap('first', () => console.log('第一个'));
syncHook.tap('second', () => console.log('第二个'));
syncHook.tap('third', () => console.log('第三个'));

syncHook.call();

输出:

js 复制代码
// 第一个
// 第二个
// 第三个

示例中,所有回调按注册顺序执行,互不影响,这种钩子适合做初始化或通知类操作。

异步钩子(Async Hooks)

异步钩子常常用于处理涉及 I/O、网络请求或延迟执行等复杂场景。有如下几种类型:

  • AsyncSeriesHook :异步串行执行,前一个完成后再执行下一个,支持 callbackPromise 风格;
  • AsyncParallelHook:所有回调并行执行,全部完成后触发结束回调,适合资源并行加载等场景;
  • AsyncSeriesBailHook :异步版熔断钩子,任意一个回调返回非 undefined 值即终止后续执行;
  • AsyncSeriesWaterfallHook :异步瀑布流,前一个 Promise 的返回值传递给下一个。

同样看个示例,AsyncSeriesHook 实现任务串行化:

js 复制代码
const { AsyncSeriesHook } = require('@rspack/lite-tapable');

const asyncHook = new AsyncSeriesHook();

asyncHook.tapAsync('step1', (callback) => {
  console.log('步骤1: 开始');
  setTimeout(() => {
    console.log('步骤1: 完成');
    callback(); 
  }, 100);
});

asyncHook.tapAsync('step2', (callback) => {
  console.log('步骤2: 开始');
  setTimeout(() => {
    console.log('步骤2: 完成');
    callback();
  }, 50);
});

asyncHook.callAsync((err) => {
  if (!err) console.log('所有步骤已完成!');
});

输出:

js 复制代码
// 步骤1: 开始
// 步骤1: 完成
// 步骤2: 开始
// 步骤2: 完成
// 所有步骤已完成!

可以看到,即使 step2 耗时更短,也必须等 step1 结束后才开始执行,实现了真正的串行控制。

指定阶段范围查询

针对 JavaScript 与 Rust 交互需求,LiteTapable 进行了特定优化,这其中关键的特性就是 queryStageRangeQueriedHook。为什么要这两个功能?我们回想一下 Rspack 的架构:

  • JS 层注册插件;
  • Rust 层驱动编译流程;
  • 当 Rust 触发某个 Hook 时,需要知道哪些 JS 插件应该被调用。

如果每次都把整个 Hook 上的所有 taps 全量传给 Rust,效率极低。于是 LiteTapable 引入了按需筛选机制:queryStageRange(stageRange),按区间提取 taps。

js 复制代码
// 查询 stage 在 [0, 10) 区间内的所有插件
const queried = hook.queryStageRange([0, 10]);

该方法返回一个 QueriedHook 实例,只包含落在指定阶段范围 [from, to) 内的插件(左闭右开)。QueriedHook 的结构如下:

js 复制代码
class QueriedHook {
  stageRange: StageRange;           // 查询区间
  hook: HookBase;                   // 原始 hook
  tapsInRange: FullTap[];           // 已筛选出的 tap 列表
}

QueriedHookcall() / callAsync() 方法只会执行 tapsInRange 中的回调,避免重复筛选。这里的设计是非常巧妙的,因为筛选是发生在 JS 层,所以执行信息会以精简结构传给 Rust,极大减少了跨语言调用的开销。

Rust Hook 架构

前面我们讲到,Rspack 通过 createCompilerHooksRegisters 等工厂函数,将 JS 插件的注册信息打包成一个对象(如 RegisterJsTaps),传递给 Rust 层:

js 复制代码
{
  registerCompilerMakeTaps: () => [...],
  registerThisCompilationTaps: () => [...],
  // 更多 hooks...
}

但这只是数据传递。真正的问题是:

  • Rust 是如何接收这些函数,并在合适时机调用它们的?
  • Rust 自己写的插件和 JS 插件,是怎么一起工作的?

这个涉及到了 Rspack Rust Hook 架构的核心设计:基于宏的 Hook 系统 + 拦截器(Interceptor)模式

define_hook! 宏

在 Rust 侧,所有钩子都通过 define_hook! 宏声明。例如:

宏(macro) 是 Rust 的一种编译时代码生成机制 ,可以在编译时自动生成重复或复杂的代码。这里的define_hook! 宏会在编译阶段生成完整的 Hook 类型系统,从而让 Hook 的定义更简洁高效。

js 复制代码
// 定义 CompilerMake hook
define_hook!(CompilerMake: Series(compilation: &mut Compilation));

这行代码的意思是:

  • 定义一个名为 CompilerMake 的 hook;
  • 执行模式为 Series(串行执行);
  • 接收参数:一个可变引用的 compilation 对象。

这行代码在编译期会自动生成三部分代码:

1、Trait:插件的行为契约

js 复制代码
pub trait CompilerMake {
  async fn run(&self, compilation: &mut Compilation) -> Result<()>;
  fn stage(&self) -> i32 { 0 } // 默认阶段
}

任何想监听 CompilerMake 的插件,都必须实现这个 trait。

trait 类似于 JavaScript 中的 interface。

2、Hook 结构体:统一管理两类插件

每个 Hook 都对应一个结构体,用来存储两类监听者:

js 复制代码
pub struct CompilerMakeHook {
  taps: Vec<Box<dyn CompilerMake + Send + Sync>>, // Rust 原生插件
  interceptors: Vec<Box<dyn Interceptor<Self> + Send + Sync>>, // JS 插件拦截器
}
  • taps:存放直接用 Rust 实现的插件;
  • interceptors:拦截器,用于动态获取 JavaScript 插件。

为什么需要拦截器?因为 JS 插件可能在任意时刻注册,Rust 无法在启动时预知所有回调。所以采用懒加载策略,只在真正触发 Hook 时,才去 JS 层查询当前有效的插件列表。

3、call 方法:统一执行入口

当某个阶段需要触发 Hook 时(如 make),Rspack 会调用:

js 复制代码
compiler.hooks.make.call(&mut compilation).await?;

这会进入宏生成的执行逻辑:

js 复制代码
impl CompilerMakeHook {
  pub async fn call(&self, compilation: &mut Compilation) -> Result<()> {
    let mut all_taps = Vec::new();

    // 1. 通过拦截器获取 JS 插件
    for interceptor in &self.interceptors {
      let js_taps = interceptor.call(self).await?;
      all_taps.extend(js_taps);
    }

    // 2. 加入 Rust 插件
    all_taps.extend(&self.taps);

    // 3. 按 stage 排序
    all_taps.sort_by_key(|tap| tap.stage());

    // 4. 串行执行(Series 模式)
    for tap in all_taps {
      tap.run(compilation).await?;
    }

    Ok(())
  }
}

看到这里你会发现,无论来源是 JS 还是 Rust,最终都被统一成相同的类型,按 stage 排序后依次执行。

如何编写一个 Rust 插件

在 Rspack 编写一个 Rust 插件是使用到了两个过程宏:

  • #[plugin]:标记插件结构体;
  • #[plugin_hook(HookName for Plugin)]:自动生成符合 Hook 约定的回调,还记得上面我们提到的插件的行为契约吗?#[plugin_hook] 宏会自动为你生成 run 方法(以及完整的 trait 实现)。

示例:监听 compilation 钩子:

js 复制代码
#[plugin]
#[derive(Debug)]
pub struct MyPlugin;

// 实现构造函数
impl MyPlugin {
  pub fn new() -> Self {
    Self::new_inner()
  }
}

#[plugin_hook(CompilerCompilation for MyPlugin)]
async fn compilation(
  &self,
  _compilation: &mut Compilation,
  _params: &mut CompilationParams,
) -> Result<()> {
  // 插件逻辑
  println!("compilation 阶段执行");
  Ok(())
}

impl Plugin for MyPlugin {
  fn name(&self) -> &'static str {
    "MyPlugin"
  }

  fn apply(&self, ctx: &mut ApplyContext<'_>) -> Result<()> {
    // 注册 hooks
    ctx.compiler_hooks.compilation.tap(compilation::new(self));
    Ok(())
  }
}

不同类型 Hook 的执行模式

define_hook! 支持多种执行模式,对应 Tapable 的经典模式:

执行模式 类比 Tapable 说明
Series AsyncSeriesHook 串行执行,全部完成后结束
SeriesBail AsyncSeriesBailHook 串行执行,遇到 Some(value) 即终止并返回
SeriesWaterfall AsyncSeriesWaterfallHook 串行执行,每个 tap 的返回值作为下一个 tap 的输入
Parallel AsyncParallelHook 并发执行,等待全部完成

关键桥梁:JsHooksAdapterPlugin

前面提到,每个 Hook 结构体都包含一个 interceptors 字段,用于在运行时动态获取 JavaScript 插件。那么,这些在 JS 中通过 .tap() 注册的回调,是如何被转换为 Rust 可调用的拦截器(interceptor)的呢?

关键正是 JsHooksAdapterPlugin ,它负责将 JavaScript 插件注册信息适配为 Rust 侧的 Interceptor 实例,并注入到对应 Hook 的 interceptors 列表中。

初始化:接收 JS 注册函数

Rust 在创建 JsCompiler 时,它会接收到一组来自 JavaScript 侧的插件注册信息,这些信息由 register_js_taps 对象承载:

js 复制代码
let js_hooks_plugin = JsHooksAdapterPlugin::from_js_hooks(env, register_js_taps)?;
plugins.push(js_hooks_plugin.clone().boxed());

这个 JsHooksAdapterPlugin 的核心职责是:为每一个支持的 Hook(如 makecompilation 等)生成一个对应的 Interceptor 实例:

register_js_taps.register_compiler_make_taps 被包装成 RegisterCompilerMakeTaps 结构体,该结构体实现了 Interceptor<CompilerMakeHook> trait。

注册为拦截器

在插件系统的 apply 阶段,JsHooksAdapterPlugin 会将这些拦截器注册到对应的 Hook 上:

js 复制代码
impl Plugin for JsHooksAdapterPlugin {
  fn apply(&self, ctx: &mut ApplyContext<'_>) -> Result<()> {
    // 将 JS 的 make 钩子注册为 CompilerMakeHook 的 interceptor
    ctx.compiler_hooks.make.intercept(self.register_compiler_make_taps.clone());
        
    // 同样处理 compilation、emit 等其他钩子
    ctx.compiler_hooks.compilation.intercept(self.register_compilation_taps.clone());
    // ...
    Ok(())
  }
}

这里的 .intercept(...) 方法会把拦截器存入 Hook 结构体的 interceptors 字段中:

js 复制代码
pub struct CompilerMakeHook {
  taps: Vec<Box<dyn CompilerMake + Send + Sync>>,
  interceptors: Vec<Box<dyn Interceptor<Self> + Send + Sync>>, // 注意这里
}

完整调用流程:以 compiler.hooks.make 为例

看完上面的逻辑估计你会觉得有点乱,我们以 compiler.hooks.make.call() 为例,走一遍完整的调用链。当 Rspack 执行到 Compiler::compile 中的 make 阶段时,会发生以下步骤:

1、触发 make hook

该方法会进入 CompilerMakeHook::call() 方法。前面我们已经介绍过 CompilerMakeHookcall 方法。

2、call 方法会遍历所有拦截器,查询 JS 插件

js 复制代码
for interceptor in &self.interceptors {
  let js_taps = interceptor.call(self).await?; // 关键看这里的 call 方法
  all_taps.extend(js_taps);
}

注意上面的 interceptor.call(),它实际上是调用了 RegisterCompilerMakeTaps::call()

3、调用 JavaScript 的函数

js 复制代码
async fn call(&self, _hook: &CompilerMakeHook) -> Result<Vec<CompilerMakeTap>> {
  let used_stages = hook.used_stages(); // 获取当前活跃的 stage 范围
  // 调用 JS 函数,拿到该 Hook 下的所有 taps
  let js_taps = self.register.call_with_sync(used_stages).await?;
  // 包装成 Rust 可执行的 Tap 对象
  let rust_taps = js_taps.into_iter()
    .map(|t| Box::new(CompilerMakeTap::from_js_tap(t)))
    .collect();

  Ok(rust_taps)
}

其中 self.register 就是你传进来的那个 registerCompilerMakeTaps 函数。

4、JavaScript 层查询 LiteTapable

还记得我们在 JS 层有一个轻量级的 LiteTapable 吗?它的作用就在这里:

js 复制代码
function registerCompilerMakeTaps(stages: number[]) {
  const hook = compiler.hooks.make;
  if (!hook.isUsed()) return [];
  const taps = hook.queryStageRange(stages); // 查询指定 stage 范围内的插件
  return taps.map(wrapTapAsFunction); // 包装成函数数组
}

这个设计很巧妙:只有在真正调用 Hook 时才去查询 JS 插件,避免了提前加载的开销,也支持动态注册。

5、合并与排序

收集完 JS 插件后,和本地的 Rust 插件合并:

js 复制代码
all_taps.extend(&self.taps); // 加入 Rust 插件
all_taps.sort_by_key(|tap| tap.stage()); // 按 stage 升序排列

6、统一执行

最后按照执行模式逐一调用:

js 复制代码
for tap in &all_taps {
  tap.run(compilation).await?;
}

所有插件,无论是 JS 还是 Rust,都被当作同一个抽象类型来处理。

总结

到目前为止,我们已经完整地介绍了 Rspack 的插件架构原理,恭喜你能坚持看到这里,Rspack 的插件架构比 webpack 的插件架构要复杂得多,从 Tapable 到 Rust Hook 的架构设计,Rspack 不仅能完全兼容 JavaScript 的插件体系,还构建了自身高性能的 Rust Hook 插件系统。值得强调的是,Rspack 并不是简单地用 Rust 重写 Tapable,而是一次非常用心的架构设计。

这实现过程中,Rspack 插件架构融合了多个优秀的设计思想。例如,通过统一执行模型来抹平语言差异;通过延迟查询实现性能与兼容兼顾;通过 interceptors 拦截器的引入,体现了对扩展开放,对修改封闭的设计原则等。

相关推荐
llq_3503 小时前
ReactDOM.createPortal 与 position: fixed 的本质区别
前端
Cache技术分享3 小时前
231. Java 集合 - 将集合元素转换为数组
前端·后端
神秘的猪头3 小时前
浏览器是如何渲染 HTML/CSS/JS 页面的?——从代码到像素的完整流程
前端·javascript
啷咯哩咯啷3 小时前
el-table-v2 实现自适应列宽
前端·javascript·vue.js
小牛马爱写博客3 小时前
Zabbix 6.0 基于 LNMP 架构完整部署教程(CentOS7)
架构·zabbix
jump6803 小时前
为什么typeof null = 'object'
前端
__不想说话__3 小时前
给网站做“体检”:Lighthouse如何平息产品经理的怒火
前端·google·架构
玉宇夕落3 小时前
🚀 从 HTML 到像素:浏览器渲染全流程揭秘(附性能优化实战)
前端·dom
西甲甲3 小时前
chromium UI 简要解析
前端