恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

C++模板参数包与void_t:彻底解放参数列表的复用革命

  • 首页
  • 资讯中心
  • /
  • C++模板参数包与void_t:彻底解放参数列表的复用革命

相关资讯

从排课冲突到状态流转:微信小程序私教预约系统开发记录 2026/10/10 4:20:04
用Python打造本地Markdown编辑器:实时预览与文件保存 2026/10/10 4:20:04
Claude Code+Codex+Grok组合实战:AI编码工具王炸工作流 2026/10/10 4:20:04

最新资讯

PaddleX 3D多模态融合检测产线(3D BEV Detection)实战:BEVFusion 推理、部署与二次开发指南
Harbor 项目贡献指南:在 AI 辅助编码时代写出能被合入的 PR
Node.js内存溢出?深入解析V8堆与FATAL ERROR的根治方案
基于FlexLM日志与Grafana的开源SolidWorks授权监控看板搭建指南
功能测试实战方法:用例设计、缺陷管理与工程实践
计算机单片机毕设实战-基于单片机的新房甲醛检测与手动自动双模式通风控制系统设计 基于单片机的室内三项环境参数阈值配置声光告警装置设计(030113)

今日推荐

Codex 总用英文回答?从 AGENTS.md 到 config.toml 的中文输出调优指南
OpenClaw 自定义插件开发完整指南(2026最新版):从 TypeScript 到 npm 发布
基于Spark的电影推荐系统全链路实战:从爬虫到Web展示

本周热门

MR25H40CDF + PIC18F65K40:工业记录仪高可靠存储实战
基于STM32的数控恒压恒流电源设计:从硬件到PID调参全解析
LT9211 MIPI重定时器原理与双路扇出实战指南

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

C++模板参数包与void_t:彻底解放参数列表的复用革命

发布时间:2026/10/10 4:25:05
C++模板参数包与void_t:彻底解放参数列表的复用革命 前阵子我在重构一个跨平台设备接入库越写越觉得这不是在写业务是在跟函数签名搏斗。同一个采集接口因为设备型号不同上报的参数可能是两个温度值、一组坐标、三段字符串再加上类型可有可无的顺序组合我最后数了一下光是参数解析相关的重载函数就堆了十多个每个长得都差不多改一个字段要翻好几处。正想着是不是该用模板参数包重构一把我顺手写了一个 C 头文件把这个库里所有参数列表问题一次性解决了。这里说的不是一个花架子工具而是把参数列表从函数签名硬编码中彻底解放出来的模板设施基于void_t做类型探测、基于模板参数包做编译期收集、基于std::index_sequence做运行期分派最后向外暴露统一入口。这份头文件不绑定任何业务逻辑任何需要处理可变参数列表的模块都能直接拷走复用。这篇文章就把这份头文件的设计思路、完整实现、改造前后对比、实测场景和踩过的坑一次说透。1. 传统参数列表的死穴函数签名把业务焊死在了编译器里1.1 重载组合爆炸是如何发生的传统 C 处理多类型参数最直接的办法就是函数重载。假设你的设备接入层需要接收三类数据设备 A 上报温度、湿度设备 B 上报x、y、时间戳设备 C 上报事件名、事件等级、数据块。按照习惯你大概率会写三个函数void HandleSensorData(double temp, double humidity); void HandleSensorData(double x, double y, int64_t timestamp); void HandleSensorData(const std::string eventName, int level, const std::vectoruint8_t payload);写三个还好但真实业务远没有这么温和。传感器型号一多你会发现参数组合的排列方式像野草一样疯长同样是坐标点有的设备传float有的传double有的还带一个confidence同样是事件上报有的带payload有的不带有的带两个payload。你被迫为每一种组合写一个重载函数而函数体里的逻辑往往只有一两处不同剩下全是复制粘贴。我数过那个设备接入库里最夸张的一组接口仅仅是为了适配 6 种不同设备的采集上报就存在 14 个重载函数。它们的名字一模一样只是参数类型和数量不同而函数体里的解析逻辑至少重复了 9 份。更麻烦的是如果某天公共数据结构里增加了一个字段这 14 个函数里凡是用到该字段的地方都要同步修改漏改一处就是线上偶发问题的温床。1.2 签名即束缚调用方、实现方、扩展方的三角债传统参数列表真正的问题不在于参数多而在于函数签名在编译期就被“焊死”了。焊死的意思是函数的可用输入范围完全由声明决定你无法在这个签名之上做任何组合、过滤、扩展只能再加一个新签名。这种设计会在三个角色之间制造三角债调用方必须知道每个设备对应哪一个精确签名一个参数顺序写错编译期报错或者更糟匹配到错误的重载运行期行为完全不符合预期。实现方为了让调用方有合适的入口必须把同一个逻辑按类型组合反复复制增加一个支持类型就等于增加一批重载。扩展方想在已有系统中加入一种全新参数类型几乎不可能跳过修改公共头文件这一步因为所有已有函数的签名都已经固定新类型根本挤不进去。打个比方传统参数列表就像固定插座孔位所有设备都必须按照插座孔位设计插头孔位不够就只能再加一块插线板。而模板参数包方案相当于一条通用数据总线设备只需要知道自己要传什么数据总线本身不关心孔位数量只要类型可支持接上就能跑。1.3 本篇文章要解决的问题边界既然要解决“参数列表宿命”那我的目标很明确让同一个接入接口可以接收任意类型、任意数量的参数同时在编译期完成类型校验把出错时机尽量提前并且不能让调用方感知到模板的存在。注意这里不是要做“运行时什么都能传”的动态类型系统恰恰相反我要的是参数列表的形态可组合、可扩展但类型安全依然被编译器牢牢守住。文章后半段给出的头文件解决的边界是“入参类型已知、组合方式事前不可枚举”的场景。如果你的项目里参数组合只有一两种那完全没必要上这套方案但如果你已经感到重载函数在不受控地膨胀那这套思路会是很合适的解药。2. 这个头文件的底层武器三个模板工具的组合逻辑2.1 void_t 与 SFINAE让类型自己“举手报名”void_t是 C17 标准库里一个不起眼但威力巨大的工具它的定义简单到让人怀疑人生templatetypename... using void_t void;它本身什么也不做唯一的用途是配合 SFINAE替换失败不是错误做“类型能力探测”。你可以给某个类型定义一组特化如果该类型支持某个操作则特化成立返回true_type如果不支持则替换失败编译期自动落到 fallback 特化返回false_type。例如我想知道某个类型是否有Serialize()方法templatetypename T, typename void struct is_serializable : std::false_type {}; templatetypename T struct is_serializableT, std::void_tdecltype(std::declvalT().Serialize()) : std::true_type {};不要小看这十几行。它把“类型是否支持某能力”从人工阅读文档、靠纪律约束变成了编译器自动检查的硬约束。在我那份头文件里void_t就是入参类型的“报名入口”所有允许进入参数列表的类型都必须能通过这类探测特化证明自己的身份。2.2 模板参数包与折叠表达式编译期的清单处理模板参数包templatetypename... Args允许函数接收任意数量的类型参数但它真正的价值在于可以在编译期对这个“参数清单”做整体处理。C17 引入的折叠表达式让这个过程变得非常优雅。假设你想在编译期检查所有参数类型是否都支持某个能力传统做法是递归解包代码冗长折叠表达式一行就能搞定(has_feature_vstd::decay_tArgs ...);这里 ...会把包里的每一个类型依次代入has_feature_v并且用逻辑与连接起来。只要有一个类型不合格整个表达式在编译期就是false配合static_assert就能给出清晰的报错。我在头文件里大量使用这种技巧它的核心逻辑等同于“全身扫描一遍确认每个零件都在白名单里才允许启机”。2.3 std::index_sequence 与 tuple把编译期参数变成运行期可遍历的数据模板参数包最大的特点是它在编译期依然是一堆离散的类型你无法像普通容器一样“遍历”它们。要让参数真正进入函数体内部参与运行期逻辑必须把参数包“打包”成一个可索引的数据结构再在需要时“解开”。std::tuple是天然的打包容器std::index_sequence是编译期生成的一串整数序列两者组合可以实现教科书式的解包操作templatetypename Fn, typename Tuple, std::size_t... Is auto call_with_tuple_impl(Fn fn, Tuple args, std::index_sequenceIs...) { return std::invoke(std::forwardFn(fn), std::getIs(std::forwardTuple(args))...); } templatetypename Fn, typename Tuple auto call_with_tuple(Fn fn, Tuple args) { return call_with_tuple_impl(std::forwardFn(fn), std::forwardTuple(args), std::make_index_sequence std::tuple_size_vstd::decay_tTuple{}); }核心逻辑是先把外部传入的args...装进 tuple然后生成和 tuple 元素数量一致的下标序列0,1,2,...再用std::getIs逐个把元素取出来重新展开成独立的参数传给目标函数。整个过程形象一点说就像把一盒散装零件装进货架再按货架编号取出零件组装。2.4 为什么选择全编译期方案而不是运行时变参看到这里有些读者会问C 风格的va_list不也能接收任意参数吗std::initializer_list不也能批量传参吗为什么非要模板元编程这一套原因很简单运行时变参方案丢类型。va_list拿到的是经过默认提升之后的int、double你根本不知道原始类型是什么initializer_list要求所有实参必须是同一种类型处理异构参数完全无能为力。而模板参数包方案保留了每个实参的精确类型校验、转换、分发全部发生在编译期类型的确定性反而是这套方案最大的优点。3. 头文件实现拆解一个通用参数分发器的诞生过程3.1 入参类型白名单supported_type 特化表头文件的第一个组成部分是“类型白名单”。我把它命名为supported_type它的职责是明确声明哪些类型是这个参数分发器允许接收的。// param_list_utils.h #pragma once #include functional #include tuple #include type_traits #include utility templatetypename T struct supported_type : std::false_type {}; template struct supported_typeint : std::true_type {}; template struct supported_typedouble : std::true_type {}; template struct supported_typefloat : std::true_type {}; template struct supported_typestd::string : std::true_type {}; template struct supported_typestd::vectoruint8_t : std::true_type {}; template struct supported_typeint64_t : std::true_type {}; templatetypename T constexpr bool supported_type_v supported_typeT::value;有人会觉得维护一张白名单很麻烦但这张表恰恰是整个方案可复用的基础。每个项目只需要按自己的领域模型扩充这份特化表比如添加结构体类型DevicePoint就让supported_typeDevicePoint变成true_type之后这个类型就可以毫无障碍地进出所有参数分发接口。相比在调用方一层层加重载这已经是非常轻量级的扩展动作了。3.2 幂等的打包器把 Args... 收集到 tuple要让参数进入后续的统一分发流程第一步是打包。我用一个auto pack_args(Args...)函数把任意参数转成 tuple同时保留其值类型语义templatetypename... Args auto pack_args(Args... args) { return std::make_tuple(std::forwardArgs(args)...); }别小看这个“打包器”它是参数列表从“签名分散”走向“结构统一”的转折点。传统思路中参数只能作为函数形参存在到了函数体内部还需要逐个命名而打包之后参数集合变成了一个整体对象可以继续被传递、存储、重新分发。这里有一个值得注意的细节std::make_tuple对左值和右值的处理是自动的所以传入一个临时对象、一个字符串字面量、一个自定义结构体都不会出现意外的拷贝或悬垂引用。实测中这是整个方案里语义最干净的一步。3.3 展开与分发index_sequence 驱动的调用循环打包不是目的把打包后的参数重新展开并转发给实际业务函数才是目的。上面已经给出过call_with_tuple的核心代码工程化时我会把两种形态都封装好一种是接收 tuple 再展开另一种是直接接收Args...再内部打包展开。接收 tuple 形态适合已经从别处拿到参数集合的场景比如某个缓存模块把请求参数存成了 tuple后续需要用它调用处理函数。直接接收Args...形态适合调用方最自然的写法看上去就是一个普通函数调用内部再自动完成打包、展开。两种形态都遵循同一原则目标业务函数叫什么名字、有几个参数、参数类型是什么头文件完全不管。头文件只负责把“参数集合”安全无损地传达到目标函数的签名上。3.4 对外统一入口safe_invoke 与 static_assert 防线既然要做编译期校验就不能把问题留给链接器或运行期。我用safe_invoke作为对外统一入口内部做两层检查templatetypename Fn, typename... Args auto safe_invoke(Fn fn, Args... args) - std::enable_if_t (supported_type_vstd::decay_tArgs ...), decltype(std::invoke(std::forwardFn(fn), std::forwardArgs(args)...)) { static_assert((supported_type_vstd::decay_tArgs ...), param_list_utils: unsupported parameter type, please extend supported_typeT first.); return std::invoke(std::forwardFn(fn), std::forwardArgs(args)...); }第一层是 SFINAE 约束一旦某个参数类型不在白名单里函数模板直接替换失败不会参与重载解析调用方立刻看到编译错误。第二层是static_assert如果前一层因为某些极端情况没有完全阻止折叠表达式会在断言处提供一段比较友好的诊断文字提示开发者去扩展白名单。这里要特别说明的是两级检查并不是多此一举。SFINAE 负责的是“让编译器知道不该匹配”static_assert负责的是“当错误确实发生时提供可读的解释”。没有后者的项目报错信息往往是一大坨no matching function for call to有后者之后报错第一行就会明确告诉你该去哪里改。3.5 业务桥接层示例注册任意签名函数光有safe_invoke还不够实际项目往往希望把一组函数统一注册到某个分发器里。头文件的最后一个部分是注册器的“概念骨架”templatetypename Fn class param_dispatcher { public: explicit param_dispatcher(Fn fn) : fn_(std::move(fn)) {} templatetypename... Args auto operator()(Args... args) const { return safe_invoke(fn_, std::forwardArgs(args)...); } private: Fn fn_; }; templatetypename Fn auto make_dispatcher(Fn fn) { return param_dispatcherstd::decay_tFn(std::forwardFn(fn)); }这个骨架的含义是你只需要提供一个可调用对象绑定任意签名剩下的参数接收、校验、转发全部交给模板设施。业务代码不再需要关心自己到底被哪个上层模块调用上层模块也无需知道业务函数的精确签名两侧通过参数分发器完成解耦。4. 调用方视角的“复用革命”改造前后对比4.1 从重载三兄弟到一行注册传统写法里处理三类设备上报数据至少要写三个重载函数加上重复的解析逻辑几十行起步。改造之后接入代码变成这样// 设备 A/B/C 共用一个接入入口 auto dispatcher make_dispatcher([](auto... args) { // 这里直接按收到的参数做业务逻辑 return ProcessCollectedData(args...); }); // 不同设备的不同参数组合统一走同一入口 dispatcher(36.5, 72.0); // 设备 A温度、湿度 dispatcher(120.3, 89.1, 1712345678LL); // 设备 Bx、y、时间戳 dispatcher(std::string(door_open), 2, std::vectoruint8_t{0xAA, 0xBB}); // 设备 C由于dispatcher的operator()本身就是模板它天然接受任意参数组合业务内部通过args...拿到原始数据。原本需要三个重载函数才能表达的组合现在一个make_dispatcher就接住了。4.2 新类型接入不再改动旧代码传统方案里新增一种设备类型往往意味着新增一个重载函数然后回到调用方处再加分支。而采用这套头文件方案之后新增类型只涉及两处修改让新类型通过supported_type白名单校验。在设备侧数据进入系统的入口把参数传给同一个dispatcher。已有代码完全不需要动也不会因为新增类型产生重载匹配风险。这一点在长期维护的项目里节省的时间非常明显我重构完这个接入库之后后续接入三款新设备都没有改动过公共处理逻辑。4.3 实际项目中的参数组合变化场景参数列表的复用革命不只是“少写几个重载”还体现在面对组合变化时的优雅程度。真实业务里参数组合的变化通常有三种模式数量变化同一业务今天接收两个参数明天接收三个参数。类型变化需求从long long时间戳升级为自定义Timestamp结构体。顺序变化不同设备可能先传时间戳再传坐标也可能先传坐标再传时间戳。前两种在这个方案里非常容易处理因为参数包天然支持任意数量和类型第三种需要额外设计命名参数或重排逻辑我建议的做法是在设备接入层做一次小规模适配把设备侧顺序归一化成内部协议顺序再交给 dispatcher这部分不需要动核心头文件。4.4 改动后的团队协作与代码审查变化功能之外团队协作层面的变化是我没有预料到的收益。以前审查代码时最头疼的就是看到一群重载函数只为了适配不同参数组合改动一个字段要花大量时间确认哪些重载需要同步修改。使用参数分发器后公共处理逻辑只有一份审查时可以只关注新接入的设备参数是否合理、白名单是否该新增类型、业务处理里有没有对参数做正确的类型判断。代码的可读性和可维护性提升了一大截新同事也能更快理解“所有设备数据最终都汇到同一个处理中心”这个架构。5. 实测中的三个落地场景与边界判断5.1 场景一串口指令帧的任意参数组装第一个真实落地场景是一个串口指令下发模块。指令帧的内容由多个字段组合而成字段类型千差万别有单字节命令码、有双字节长度、有字符串设备名、也有固定长度的数据块。过去每条指令都要写一个单独的函数函数内部做字段拼接字段一多命令码和长度之间的对应关系非常容易错。接入头文件之后我写了一个通用的BuildFrameWithArgs它接收任意字段参数内部统一调用 dispatcher 完成类型校验再按字段顺序序列化BuildFrameWithArgs(0x01, std::string(relay), 0x00A1, std::vectoruint8_t{...});实测下来最惊喜的一点是字段类型错误会在编译期直接暴露。以前如果长度字段传成一个std::string运行期才会发现序列化后字节不对现在白名单校验直接把错误挡在编译期排查成本大幅降低。5.2 场景二日志网关的异构键值上报第二个场景是日志网关模块。日志系统需要支持键值对上报但值类型是异构的可能是一个整数计数、一个浮点耗时、一个字符串消息甚至是一个二进制采样块。过去最省事的做法是把所有值转成字符串存到 map 里代价是丢失原始类型后续做数值统计时还得再转一次。用头文件方案后上报入口变成了一个变参模板Report(request_count, 100); Report(latency_ms, 12.5); Report(device_id, std::string(SN-2024));内部通过pack_args把键和值打包成 tuple再借助index_sequence展开成键值对类型信息全程保留。统计模块可以直接对数值类型做聚合不需要解析字符串。这个场景让我切实感受到了类型保真的价值它不是简单地把某一个函数参数列表“变顺手”而是让数据在传参过程中不再降级。5.3 场景三插件注册入口的参数版本兼容第三个场景与插件系统相关不同版本的插件在注册时需要提供的参数列表不一样。早期版本只传插件名中期版本要追加版本号最新版本还要带上作者信息。如果对所有版本分别维护注册函数新增版本时旧版本注册函数也不能删注册区会被各种签名碎片堆满。我把注册入口统一成一个变参模板插件注册时不管传几个参数注册中心都可以通过std::tuple_size感知参数数量、通过std::getN感知具体参数类型再做版本判断RegisterPlugin(plugin_a); RegisterPlugin(plugin_b, 1.2.0); RegisterPlugin(plugin_c, 1.3.0, alice, std::string(key));注册中心接收的都是同一套参数包内部再根据参数数量走对应逻辑代码整洁度提高了一个量级。5.4 什么时候不该用这套方案再好的工具也有边界我得诚实指出这套方案的适用前提。如果你的业务只有一种固定的参数列表或者参数组合不超过两三种直接用传统函数签名反而是最直观的。模板参数包方案会引入实例化开销、编译时间增长和更复杂的报错这些成本在简单场景下完全划不来。另外如果你的参数列表里包含大量运行时才能确定的数据类型比如需要从配置字符串动态解析出“任意字段”那更适合的方案还是std::variant或std::any这类运行时异构容器而不是编译期参数包。编译期方案的根基是“类型在编译期必须确定”这一点在任何时候都不能违背。6. 踩坑实录模板报错、引用悬挂与性能取舍6.1 坑一void_t 探测特化永远匹配到 false_type第一个坑出现在我写is_serializable的时候。刚开始我没想清楚void_t的匹配机制写了这样的特化templatetypename T struct is_serializableT, void_tdecltype(T().Serialize()) : std::true_type {};然后发现任何类型都报false_type。原因在于decltype(T().Serialize())中的T()要求 T 必须可默认构造而我的自定义结构体根本没有默认构造函数导致替换失败。这里的教训是探测任何能力时不要假设类型具有某种构造方式应该用std::declvalT()构造“伪右值”来进行探测templatetypename T struct is_serializableT, std::void_tdecltype(std::declvalT().Serialize()) : std::true_type {};排查这类问题的最快方法是写一个独立的static_assert验证例如static_assert(is_serializable_vMyStruct)如果失败就单独注释掉MyStruct上的其他模板逻辑问题范围会迅速缩小。6.2 坑二折叠表达式的隐蔽类型转换使用折叠表达式时我还遇到过一种非常隐蔽的错误某个参数类型支持隐式转换到另一种类型导致折叠表达式“成功”了但语义完全错误。举个例子const char*可以隐式转换为std::string如果我在校验条件里写的是“参数类型是否为std::string”那么传入const char*也会通过折叠表达式。这是危险的因为后续业务逻辑可能确实按std::string处理了数据但调用方以为传的是原始字符串字面量一旦涉及所有权或生命周期管理就会出现隐秘bug。解决方式是在白名单校验里使用严格类型不依赖隐式转换static_assert((std::is_same_vstd::decay_tArgs, std::string || ...), strictly expect std::string, not implicit conversion);并且把const char*、char[]等常见字面量类型单独列入白名单再在打包时显式转成std::string。经过这次踩坑我把“显式类型意图”作为写模板约束的第一原则。6.3 坑三std::get 展开时的引用转发问题在call_with_tuple_impl里std::getIs(std::forwardTuple(args))这段代码如果写得不小心容易出现引用悬挂。最初我的实现是这样的auto element std::getIs(args); return std::invoke(fn, element...);当args是一个临时 tuple 且函数返回后保留了对 tuple 内部元素的引用时就会出现经典悬垂引用问题。排查过程有点折磨因为问题不是必现的只有调用方在函数里保存了auto才会暴露。修正方式是严格按照“转发风格”传递 tuple让元素按原值类型继续传递return std::invoke(std::forwardFn(fn), std::getIs(std::forwardTuple(args))...);这个坑提醒我涉及模板展开时值类别左值/右值的保留要比普通代码敏感得多任何偷懒的“先取引用再转发”写法都值得重新审视。6.4 坑四enable_if 条件不互斥导致重载歧义当我给某个业务函数同时提供“变参版本”和“固定参数版本”时还踩过重载歧义的坑。固定参数版本接收(double, double)变参版本接收任意Args...编译器在匹配dispatcher(1.0, 2.0)时两个版本都能匹配直接报ambiguous call。解决办法是给变参版本的enable_if增加条件明确禁止它与固定参数重载竞争templatetypename... Args auto operator()(Args... args) - std::enable_if_t(supported_type_vstd::decay_tArgs ...) (sizeof...(Args) ! 2) // 与固定双参版本互斥 { ... };本质上是让 SFINAE 条件覆盖“排除掉所有固定版本能处理的情况”保证任意一次调用都只有一个版本可以胜出。这里建议在写完头文件后专门写一组参数组合的编译期测试把固定参数、变参参数、空参数、单参数等边界全部覆盖一遍。6.5 排查链路与性能观察这一系列坑让我形成了一套模板排查链路先把问题缩小到一个最小的可编译文件再拆出void_t、参数包、index_sequence三个独立单元分别验证最后拼装回原场景。对不明来源的模板实例化错误我还会在函数体内临时加一句std::cout __PRETTY_FUNCTION__输出当前模板参数确认实例化时实际代入的类型和预期是否一致。性能方面这套头文件的所有检查都发生在编译期运行时最终调用本质上就是一次普通函数调用没有虚拟分派没有运行时容器分配。代价集中在编译期每多一种新的参数组合编译器就会多实例化一份safe_invoke和call_with_tuple_impl项目整体编译时间会有可感知的增长。我在那个接入库里统计过全量编译时间大约增加了 15%还在可接受范围内如果编译时间特别敏感的项目可以只对高频参数组合做显式实例化把低频变参路径留给模板。从这半年的使用体验来看最深的一点感受是参数列表根本不是宿命它只是编码习惯的投影。传统重载写多了人会下意识觉得“参数组合多了就该复制函数”模板参数包提供了一个更陡峭的学习曲线但一旦跨过去返回值是实打实的维护成本下降。这个头文件之后又被我抽到另外两个项目里都是直接拷走、改一改白名单就上手那种“同一套武器反复可用”的感觉确实当得起复用革命这四个字。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号