阅读视图

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

既然有了 defer,我们还需要像以前那样把 <script>标签放到 <body>的最底部吗?

既然有了 defer,我们还需要像以前那样把 <script> 标签放到 <body> 的最底部吗?如果我把带 defer 的脚本放在 <head> 里,会有性能问题吗?

核心答案

不需要了。 使用 defer 属性后,把 <script> 放在 <head> 里不仅没有性能问题,反而是更优的做法

原因:

  1. defer 脚本会并行下载,不阻塞 HTML 解析
  2. 脚本执行会延迟到 DOM 解析完成后,但在 DOMContentLoaded 事件之前
  3. 放在 <head> 里可以让浏览器更早发现并开始下载脚本

深入解析

浏览器解析机制

传统 <script>(无 defer/async):
HTML 解析 ──▶ 遇到 script ──▶ 暂停解析 ──▶ 下载脚本 ──▶ 执行脚本 ──▶ 继续解析

defer 脚本:
HTML 解析 ────────────────────────────────────────────▶ DOM 解析完成 ──▶ 执行脚本
     └──▶ 并行下载脚本 ──────────────────────────────────────────────────┘

为什么 <head> 里的 defer 更好?

位置 发现脚本时机 开始下载时机
<head> 解析开始时 立即
<body> 底部 解析接近完成时 较晚

放在 <head> 里,浏览器可以在解析 HTML 的同时下载脚本,充分利用网络带宽

常见误区

误区 1: "defer 脚本放 <head> 会阻塞渲染"

  • 错误。defer 脚本的下载和 HTML 解析是并行的

误区 2: "放 <body> 底部更保险"

  • 这是 defer 出现之前的最佳实践,现在已过时
  • 放底部反而会延迟脚本的发现和下载

误区 3: "defer 和放底部效果一样"

  • 不一样。放底部时,脚本下载要等到 HTML 解析到那里才开始
  • defer 在 <head> 里可以更早开始下载

defer vs async vs 传统方式

                    下载时机        执行时机              执行顺序
传统 script         阻塞解析        下载完立即执行         按文档顺序
async              并行下载        下载完立即执行         不保证顺序
defer              并行下载        DOM 解析完成后        按文档顺序

代码示例

<!-- ✅ 推荐:defer 脚本放在 <head> -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>页面标题</title>
  <!-- 浏览器立即发现并开始下载,但不阻塞解析 -->
  <script defer src="vendor.js"></script>
  <script defer src="app.js"></script>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <!-- HTML 内容 -->
</body>
</html>

<!-- ❌ 过时做法:放在 body 底部 -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>页面标题</title>
</head>
<body>
  <!-- HTML 内容 -->

  <!-- 要等 HTML 解析到这里才开始下载 -->
  <script src="vendor.js"></script>
  <script src="app.js"></script>
</body>
</html>

验证下载时机的方法

打开 Chrome DevTools → Network 面板,观察脚本的下载开始时间:

  • <head> 里的 defer 脚本:在 HTML 下载初期就开始
  • <body> 底部的脚本:在 HTML 解析接近完成时才开始

面试技巧

可能的追问方向

  1. "defer 和 async 有什么区别?"

    • async 下载完立即执行,不保证顺序
    • defer 等 DOM 解析完才执行,保证顺序
  2. "多个 defer 脚本的执行顺序是怎样的?"

    • 按照在文档中出现的顺序执行
    • 即使后面的脚本先下载完,也会等前面的
  3. "defer 脚本和 DOMContentLoaded 的关系?"

    • defer 脚本在 DOM 解析完成后、DOMContentLoaded 触发前执行
  4. "什么情况下还是要放 body 底部?"

    • 需要兼容不支持 defer 的古老浏览器(IE9 以下)
    • 现代开发中基本不需要考虑

展示深度的回答方式

"defer 放 <head> 不仅没有性能问题,反而是更优的选择。因为浏览器的预加载扫描器(Preload Scanner)可以在解析 HTML 的早期就发现这些脚本并开始下载,充分利用网络带宽。而放在 <body> 底部的话,脚本的发现时机会延后,相当于浪费了并行下载的机会。"

一句话总结

defer 脚本放 <head> 是现代最佳实践:更早发现、并行下载、不阻塞解析、按序执行。

如果一个脚本既有 async 又有 defer 属性,会发生什么情况?

如果一个脚本既有 async 又有 defer 属性,会发生什么情况?

核心答案

async 优先级更高,defer 会被忽略。 当一个 <script> 标签同时具有 asyncdefer 属性时,浏览器会按照 async 的行为执行——脚本并行下载,下载完成后立即执行,不保证执行顺序。

这是 HTML 规范明确定义的行为,defer 在这种情况下作为降级回退存在,用于兼容不支持 async 的老旧浏览器。

深入解析

HTML 规范中的优先级

根据 HTML Living Standard,浏览器处理 <script> 标签的逻辑如下:

if (脚本有 src 属性) {
    if (async 属性存在) {
         使用 async 模式
    } else if (defer 属性存在) {
         使用 defer 模式
    } else {
         使用传统阻塞模式
    }
}

关键点:async 的判断在 defer 之前,所以 async 优先。

为什么要这样设计?

这是一个优雅降级的设计:

浏览器支持情况 行为
支持 async 使用 async(忽略 defer)
不支持 async,支持 defer 使用 defer
都不支持 传统阻塞加载

在 async 刚推出时(约 2010 年),老版本 IE(IE9 及以下)不支持 async 但支持 defer。同时写两个属性可以让:

  • 现代浏览器使用 async
  • 老浏览器回退到 defer

三种模式对比

                    下载        执行时机              顺序保证    阻塞解析
无属性              阻塞        下载完立即执行                  
async              并行        下载完立即执行                  
defer              并行        DOM 解析完成后                 
async + defer      并行        下载完立即执行                  

常见误区

误区 1: "两个属性会产生某种组合效果"

  • 错误。不存在 "async-defer" 混合模式,只会选择其中一个

误区 2: "defer 会覆盖 async"

  • 错误。恰恰相反,async 优先级更高

误区 3: "现代开发中同时写两个属性有意义"

  • 基本没有意义了。async 的浏览器支持率已经非常高(IE10+),不需要 defer 作为回退

内联脚本的特殊情况

<!-- async 和 defer 对内联脚本无效 -->
<script async defer>
  console.log('我是内联脚本,async 和 defer 都被忽略');
</script>

asyncdefer 只对外部脚本(有 src 属性)有效。

代码示例

<!-- 同时有 async 和 defer -->
<script async defer src="script.js"></script>

<!-- 等价于(在现代浏览器中) -->
<script async src="script.js"></script>

验证行为的测试代码

<!DOCTYPE html>
<html>
<head>
  <script async defer src="a.js"></script> <!-- 输出 A -->
  <script async defer src="b.js"></script> <!-- 输出 B -->
  <script async defer src="c.js"></script> <!-- 输出 C -->
</head>
<body>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      console.log('DOMContentLoaded');
    });
  </script>
</body>
</html>

<!--
可能的输出顺序(取决于下载速度):
B, A, C, DOMContentLoaded
或
A, C, B, DOMContentLoaded
或其他任意顺序

如果是纯 defer,输出一定是:
A, B, C, DOMContentLoaded
-->

实际应用场景

<!-- 2010-2015 年的兼容性写法 -->
<script async defer src="analytics.js"></script>

<!-- 现代写法:直接用 async 或 defer -->
<!-- 独立脚本(如统计、广告)用 async -->
<script async src="analytics.js"></script>

<!-- 有依赖关系的脚本用 defer -->
<script defer src="vendor.js"></script>
<script defer src="app.js"></script>

面试技巧

可能的追问方向

  1. "为什么 async 优先级更高?"

    • 这是 HTML 规范的设计,目的是让 defer 作为 async 的降级回退
    • 体现了渐进增强/优雅降级的设计思想
  2. "现在还需要同时写两个属性吗?"

    • 基本不需要。async 支持率已经很高
    • 如果要兼容 IE9,应该用其他方案(如 polyfill 或条件注释)
  3. "module 类型的脚本呢?"

    • <script type="module"> 默认就是 defer 行为
    • 可以加 async 变成 async 行为
    • 不需要显式写 defer
  4. "动态创建的脚本呢?"

    • 动态创建的 <script> 默认是 async 行为
    • 可以设置 script.async = false 来改变

展示深度的回答方式

"当 async 和 defer 同时存在时,async 优先,defer 被忽略。这是 HTML 规范明确定义的行为,设计初衷是让 defer 作为 async 的降级回退——在 async 刚推出时,老版本 IE 不支持 async 但支持 defer,同时写两个属性可以实现优雅降级。不过在现代开发中,这种写法已经没有必要了。"

一句话总结

async + defer = async;defer 只是 async 的降级回退,现代开发中无需同时使用。

简单有效地提升 Shopify 站点性能:自定义加载脚本

简单有效地提升 Shopify 站点性能:自定义加载脚本

影响性能的一个常见因素是脚本的加载、编译、执行,这点在 Shopify 站点体现得更明显,因为平台默认集成一些支付、平台监控、分析追踪等工具,加上普遍需要安装插件、引入营销类工具脚本(比如GTM)等。

third-party.png

一个合理的解决思路是延迟一些"次要"脚本的加载和执行,那么在 Shopify 中我们可以检视代码,看到一些第三方脚本是这么被加载进网页的:

<script>(function() {
  var isLoaded = false;
  function asyncLoad() {
    if (isLoaded) return;
    isLoaded = true;
    var urls = ["https:\/\/api-na2.hubapi.com\/scriptloader\/v1\/243343319.js?shop=xxx.myshopify.com","\/\/cdn.shopify.com\/proxy\/873ef8dfc1568c840b7856dcd2d82f2cfd17e5417d1f1f835d2563af741ac47e\/d1639lhkj5l89m.cloudfront.net\/js\/storefront\/uppromote.js?shop=xxx.myshopify.com\u0026sp-cache-control=cHVibGljLCBtYXgtYWdlPTkwMA","\/\/cdn.shopify.com\/proxy\/20ab8c4c7e87500ef9d7bb98203fee1481a2e7a2e2153af4e3ddbb1e8446f26f\/api.goaffpro.com\/loader.js?shop=xxx.myshopify.com\u0026sp-cache-control=cHVibGljLCBtYXgtYWdlPTkwMA"];
    for (var i = 0; i < urls.length; i++) {
      var s = document.createElement('script');
      s.type = 'text/javascript';
      s.async = true;
      s.src = urls[i];
      var x = document.getElementsByTagName('script')[0];
      x.parentNode.insertBefore(s, x);
    }
  };
  if(window.attachEvent) {
    window.attachEvent('onload', asyncLoad);
  } else {
    window.addEventListener('load', asyncLoad, false);
  }
})();</script>

(attachEvent 是 IE 时代的 API,现已废弃)大致逻辑是在页面监听 load 事件,load 触发后再通过 asyncLoad 函数逐一创建以 async 异步加载的外部脚本标签插入网页。

注意这里加载的脚本带 async,意味着加载时机可以灵活一些,因此触发 asyncLoad 的时机也可以灵活一些,我们就可以这么修改以实现自定义加载脚本了:

修改 content_for_header(theme.liquid)

我习惯不处理购物车页,所以判断 template.name != 'cart'。替换 load 为 CustomLoad:

{% if template.name != 'cart'  %}
  {{ content_for_header | replace: "addEventListener('load'", "addEventListener('CustomLoad'" }}
{% else %}
  {{ content_for_header }}
{% endif %}

custom-load.png

触发自定义事件

在 theme.liquid 底部,body 闭合前,加入以下代码:

{% if template.name != 'cart' %}
  <script>
    const events = ['scroll', 'mousemove', 'keydown', 'click', 'touchstart'];
    let flag = false;

    function actionEvent() {
      if (flag) return;
      flag = true;
      
      window.dispatchEvent(new CustomEvent("CustomLoad"));
    }
    
    document.addEventListener('DOMContentLoaded', () => {
      events.forEach(function (eventType) {
        window.addEventListener(eventType, actionEvent, {
          passive: true,
          once: true
        });
      });
    });
  </script>
{% endif %}

这样就能实现一个简单有效的延迟加载脚本控制器了,当网站用户产生交互时再加载这些脚本。

以上代码仅供参考。

我的状态管理哲学

背景

简单讲讲 react 状态管理的演进过程

React状态管理的演进始终围绕“组件通信”与“状态复用”两大核心需求展开。

早期类组件时代,开发者依赖props实现组件间传值,通过state管理组件内部状态,但跨层级组件通信需借助“props drilling”(属性透传),代码冗余且维护成本高。

为解决这一问题,Redux、MobX 等第三方状态管理库应运而生,通过集中式存储、统一状态更新机制,实现了全局状态共享,但其繁琐的配置与概念(如reducer、action、中间件)也增加了开发门槛。

随着React 16.8推出Hook特性,函数组件得以拥有状态管理能力,useState、useContext等原生Hook的出现,为轻量型状态管理提供了可能,也推动开发者探索更简洁、无依赖的状态管理思路,逐步打破对第三方库的依赖。

React生态核心路由工具react-router(常用v6版本)虽不直接管理状态,但与状态管理深度关联。其路由参数、查询参数及导航状态需与全局/局部状态联动(如通过路由参数获取详情ID、同步登录状态控制跳转权限)。React Router v6适配Hook,提供useParams、useSearchParams等方法,可便捷操作路由状态,后续自定义的原生Hook状态管理方案可与其兼容,实现路由与业务状态的协同管控。

第三方状态管理库中,react-query(现更名TanStack Query)极具代表性,它跳出传统全局集中存储思路,专注服务端状态管理,补齐了传统库与原生Hook在异步数据处理上的短板。不同于Redux等通用库,它专为接口请求、数据缓存等服务端状态场景设计,无需手动维护加载、错误等冗余状态,大幅简化异步逻辑。但它不擅长客户端状态(如主题、弹窗),需搭配客户端状态管理方案使用,这也印证了状态管理无万能方案,需结合场景选型。

Zustand是一款轻量的第三方状态管理库,基于Hook设计,兼顾简洁性与实用性。它无需Context嵌套,通过自定义Hook即可便捷获取和修改全局状态,规避了Context重渲染的问题,同时简化了Redux等库的繁琐配置。

取舍

只要经过长年多个项目的开发经历,就会发现,没有哪个方案非常适用于所有的场景。

  1. Redux:优点是状态集中可追溯、生态完善、适合大型项目团队协作;缺点是配置繁琐、概念多(reducer/action等)、上手成本高,小型项目使用显冗余。

  2. Mobx:优点是响应式更新、编码灵活、无需手动编写大量模板代码;缺点是依赖装饰器语法(存在兼容问题)、状态变化隐性化,复杂项目易失控。

  3. Zustand:优点是轻量简洁、基于Hook设计、无Context嵌套、规避重渲染问题;缺点是生态不如Redux完善,大型项目复杂状态管控能力稍弱。

  4. react-query(TanStack Query) :优点是专注服务端状态、自动处理缓存/重试/加载状态、大幅简化异步逻辑;缺点是不擅长客户端状态管理,需搭配其他方案使用。

  5. rxjs:优点是擅长处理复杂异步流、状态联动能力强、可复用性高;缺点是学习曲线陡峭、概念抽象,简单场景使用成本过高。虽然 rxjs 本身不是状态管理,但其处理异步流的能力可以轻松构造出灵活的状态管理方案。

  6. react-use、ahooks:优点是封装大量通用Hook(含状态管理相关)、复用性强,简化重复开发,贴合React Hook生态;缺点是侧重通用Hook合集,无专属全局状态管理体系,复杂状态联动需基于其二次封装。

实际使用会将方案组合使用,这里我们会发现存在两种矛盾:

  1. 如果你倾向于使用 react hook 去开发逻辑,那么共享状态采用 context,会出现 context 套 context,逻辑混合在UI 组件树上,极难看懂,复杂应用中容易陷入性能优化又劣化的循环中。

  2. 如果不想使用 react context 作为状态共享的方案,通常是希望应用的业务状态能与 UI 框架解耦,选择 redux 和 zustand。这时候又会发现,这些方案并没有提供与 react hook 类似的逻辑组合复用能力,进入堆叠面条代码 “大力出奇迹” 的陷阱。

本文并不打算完全解决这种矛盾,这是我经验上判断 react 状态管理存在的问题,或许有些大佬有这方面的解决方案也说不一定。

沉思与创造

相信一些对状态管理或者应用架构设计感兴趣的人,必然设计过自己趁手的状态管理库。

我理想中的状态管理库应该能够做到以下的事情:

  1. 响应式状态:存储状态、读取状态、更新状态、订阅状态变更。

  2. 状态类:一组状态可以形成一个模版类型,并创建实例。

  3. 副作用管理:一个状态实例会管理自己的副作用(如定时器、监听器等),实例销毁(destroy方法)会清除其所有的副作用。

  4. 层次管理:一个状态实例A可能被另一个状态实例B持有,这是一种附属关系(如某种插件机制),实例B销毁的时候,实例A也会销毁(连带副作用清除)。

  5. 聚合事件:是对 “多个分散状态变更 / 副作用触发” 的统一收口与联动管理

  6. 组合复用机制:就像 react 自定义 Hook 一样,一些纯工具能力应该能轻易的复用并组合。

我曾自己尝试过很多种方案组合并用在自己维护的项目上,上面可以说就是无数次错误尝试与痛苦挣扎的总结。

一切不优雅的 hack 方案和 shit 代码都是源于某些能力没有提供,而你没有办法让库的提供者提供你想要的能力,可能得到的回答就是 “我们需要保持简洁、纯净”,你可以通过某某方式间接实现。

为什么要委屈自己,去接受这草台般的世界?最终我决定了放下一切信仰,自己开宗立派。

让我们一步步推演出这个方案的摸样(仅考虑 API 的设计,因为实现不复杂且一直在变化)。

状态类

一组状态可以形成一个模版类型,并创建实例。

虽然这是第二点,但还是先说说这个,存在 API 的依赖。

基于理想中“状态类”的诉求,我们先定义状态模版的创建方式——通过 createModel 方法封装状态的初始化逻辑,支持传入 id 和自定义参数 param,让同一种状态模版可以生成多个独立实例,兼顾复用性与灵活性。

const StateModel = createModel({
    initState: (id, params)=>({
        count: params.count
    }),
});

响应式状态

存储状态、读取状态、更新状态、订阅状态变更。

有了状态类的模版定义,接下来就要落地响应式核心能力——毕竟光有模版不能读写更新,跟空架子没区别。响应式API要足够简洁,还得兼顾灵活性,不用搞一堆冗余配置,直接在状态实例上挂载核心方法就行,具体用法如下:

// 创建状态实例,调用 create 方法 (id,  param) 传入 initState
const stateIns = StateModel.create('default', { count: 1 });
stateIns.getState();
stateIns.setState({ count: 2 }); 
// 或者 
setState(s => ({count: s.count + 1}));
stateIns.subscribe((state, prevState)=>{ /* 状态变更回调 */ });

这就够了吗?到这里看起来就是跟 Zustand 的 store 一样,没什么特别的。

不够!只有信奉极简主义者才会觉得这是够的。

subscribe 是一种极其简陋的实现,它存在以下问题:

  1. 订阅粒度太粗,没法精准订阅某个字段,哪怕只改了状态里的一个字段,所有订阅者都会被触发,跟Context的重渲染坑一模一样,大型应用里纯属性能灾难。
  2. 只是订阅了状态的变化,应该有场景需要对初值进行回调,因此需要分开它们。

可以使用 rxjs 提供 Subject 来暴露订阅接口。

stateIns.states // 分离字段的 BehaviorSubjects
stateIns.fullState // 整体状态 BehaviorSubject
stateIns.updates  // 分离字段的更新事件 Subject
stateIns.fullUpdate // 整体状态的更新事件 Subject

副作用管理

一个状态实例会管理自己的副作用(如定时器、监听器等),实例销毁(destroy方法)会清除其所有的副作用。

先说说计算属性吧,计算属性作为 state 的衍生。需要追踪 state 变更并重新计算,为了减少重复计算,如果没有像 vue proxy 响应式机制,那么就只能自己手动给到了。

const StateModel = createModel({
    initState: (id, params) => ({
        count: params.count,
        others1: 123,
        others2: 456,
    }),
    // 添加 computed 
    computed: {
        double: {
            dep: s => [s.count],
            run: s => s.count * 2,
        }
    },
});

const stateIns = StateModel.create('default', { count: 1 });
stateIns.getState().double // 2

这个设计,我只能说,很丑陋,实际也没想象中那么实用,后面再说。

再说说副作用。

const StateModel = createModel({
    initState: (id, params)=>({
        count: params.count,
        others1: 123,
        others2: 456,
    }),
    // 添加 effects
    effects: {
        effect1: {
            dep: s => [s.count],
            run: (state, setState)=>{
                const t = setTimeout(() => {
                    console.log('count is ', state.count)
                }, 3000);
                return () => clearTimeout(t);
            }
        }
    }
});

const stateIns = StateModel.create('default', { count: 1 });
stateIns.setState({count: 2});
stateIns.setState({count: 3}); // 3s 后打印 "count is 3"

表达了当 count 更新并稳定 3s 后打印它。

到这里,其实我也不知道自己在写什么了,effects 的存在究竟为了什么?为了模拟 react 的 useEffect 吗?不管怎么样,存在即合理,如果要把代码从 react 屎山迁过来,结果发现没有 effect 能力该是多头疼。

可以对比一下 Zustand,它根本就没有这两个东西。Zustand 直接返回状态和封装过的方法,不直接让使用方调用 setState,而是把状态更新逻辑封装在自定义方法里,更侧重“状态操作收口”。

但是代码写起来就是另一种意义上的丑陋了。

// Zustand 典型用法
import { create } from 'zustand';
import { debounce } from 'lodash-es';

// 直接创建store,封装状态和更新方法,不暴露setState
const useCountStore = create((set, get) => {
  const logCount = debounce(() => {
    console.log('count is ', get().count);
  }, 3000);

  return ({
    count: 1,
    double: 2,
    // 封装更新逻辑,使用方直接调用方法,无需手动setState
    increment: () => {
      set((state) => ({ count: state.count + 1, double: (state.count + 1) * 2 }))
      logCount(); // 手动调用
    },
    decrement: () => {
      set((state) => ({ count: state.count - 1, double: (state.count - 1) * 2 }))
      logCount(); // 手动调用
    },
    setCount: (val) => {
      set({ count: val, double: val * 2 })
      logCount(); // 手动调用
    },
  })
});

这大概就是为什么我始终没有大规模使用 Zustand(或许是用法不对吧)。

再回到自己的设计上来,我意识到直接让状态实例订阅自己的状态再执行计算属性变更和副作用,也能达到一样的效果。同时受到 Zustand 的启发,setState 就不应该暴露给外部使用,应该直接封死在内部的 actions 里面。

import { debounceTime } from 'rxjs';

const StateModel = createModel({
  initState: (id, params) => ({
      count: params.count,
      double: params.count * 2,
   }),
  actions: (get, set) => ({
    inc: () => set({ count: get().count + 1 }),
    dec: () => set({ count: get().count - 1 }),
    setCount: (val) => set({ count: val }),
  }),
  // 处理 computed 和 effect 的地方
  setup(self) {
    return [
      // 1. 直接通过同步监听 count 来设置 double
      self.states.count
        .subscribe(c => thisInstance.setState({ double: c * 2 })),
      // 2. 监听 count 再 pipe 一个 rxjs 防抖操作符
      self.states.count
        .pipe(debounceTime(3000))
        .subscribe(c => console.log('count is ', c)),
    ]
  }
});

const stateIns = StateModel.create('default', { count: 1 });
stateIns.actions.inc();
stateIns.actions.inc(); // 3s 后打印 "count is 3"

这个方案已经足够好用了,曾经我也这么认为,并在一个项目里面大规模使用,直到有人接手了我的代码并开始了吐槽。

我并不觉得 rxjs 是多么高深的技术,但事实如此 …… 不是谁都能接受的。

这个问题先按下不管,接着看。

createModel 内部通过返回 subscription 数组并在 destroy 的时候取消即可实现副作用的清理

ins._subscriptions = setup(ins);

ins.destroy = ()=>{
    ins._subscriptions.forEach((sup)=>{
        sup.unsubscribe();
    })
}

上面的例子只在 setup 中实现了静态的副作用管理,当然还需要考虑动态添加副作用的情况。

比如在调用 action 方法的时候,开启一个定时器 interval 执行一些操作,同时还要考虑多次调用时对上一次副作用的清理。

这就需要引入一个动态副作用管理的工具了。

import { Subscription, Observable } from 'rxjs';
type FunctionClean = () => void;
type Cleanup = Subscription | FunctionClean;

type EffectHandle = 
    Observable 
    | (() => FunctionClean); 

class EffectManager {
    addEffect(cleanup: Cleanup): FunctionClean;
    runEffect(handle: EffectHandle): Cleanup;
    cycleEffect(name: string, cb: () => EffectHandle | void): void;
    cleanAll(): void;
}

结合 EffectManager 使用示例,这时候静态副作用和动态副作用都可以用 EffectManager 管理,setup 里面也就可以显式添加副作用。

// 核心使用示例:结合状态模型的action动态管理副作用
const StateModel = createModel({
  initState: (id, params) => ({
    count: params.count,
    double: params.count * 2,
  }),
  actions: (get, set, self) => {
    return {
      inc: () => set({ count: get().count + 1 }),
      dec: () => set({ count: get().count - 1 }),
      // 动态副作用场景:调用action时开启定时器,多次调用自动清理上一次
      startCountLog: () => {
        self.effectManager.cycleEffect('countLog', () => {
          return self.states.count.pipe(
              debounceTime(3000), 
              tap((c)=>console.log('log', c))
          );
        });
      },
      stopCountLog: ()=>{
          // 传空即可清除
          self.effectManager.cycleEffect('countLog', ()=>{});
      }
    };
  },
  setup(self) {
    // 静态副作用:初始化时监听count,同步更新double
    const sub = self.states.count.subscribe(c => {
      self.setState({ double: c * 2 });
    });
    // 将静态副作用交给EffectManager管理
    self.effectManager.addEffect(sub);
  },
});

// 组件/业务中使用
const stateIns = StateModel.create('default', { count: 1 });
// 调用动态副作用
stateIns.actions.startCountLog();
stateIns.actions.inc();  // 3s 后 log 2

createModel 内部的 destroy 也就变成了

ins.effectManager = new EffectManager;
setup(ins);

ins.destroy = () => {
    ins.effectManager.cleanAll();
}

当然上面这个 destroy 是被简化过了,实际上还需要阻止 ins 上所有 rxjs Subject 继续被订阅。

层次管理

一个状态实例A可能被另一个状态实例B持有,这是一种附属关系(如某种插件机制),实例B销毁的时候,实例A也会销毁(连带副作用清除)。

类似组件树,状态实例也可以是一个树状的组合关系,父节点调用 destroy,子节点递归调用 destroy,完成副作用的全面清理。

const a = StateModel.create('a', { count: 1 });
//             通过调用 create 时第三个参数传入父节点
const b = StateModel2.create('b', { other: 0 }, a);

a.destroy() // 连带触发 b.destroy() 

上面的例子已经说明了层次管理的含义,在插件化的设计中,围绕核心实体去挂载其他插件实体,可以确保核心实体销毁时插件实体也被销毁。

不过我想这一节可以顺便聊聊依赖注入。

基于层次管理实现依赖注入

// ContextModel 很重要,可以作为内部的一个导出
const ContextModel = createModel({...})

const AppModel = createModel({...})

const FeatureModel1 = = createModel({...})

比如 FeatureModel1 依赖某个存储方法,通过一个 createDep 创建这个依赖。

export const StoreDepToken = Symbol('StoreDepToken');
export type StoreDepType = {
   get(name: string): string;
   set(name: string, content: string): void;
}

export const FeatureStoreDep = createDependency<StoreDepType>(StoreDepToken);

export const FeatureModel1 = = createModel({...})

然后通过 ContextModel 上的 setDep 来提供它。

const context = ContextModel.create('', {});

context.actions.setDependency(
    FeatureStoreDep.provide({
        get(name){
           localstorage.getItem(name);
        },
        set(name, content){
            localStorage.setItem(name, content);
        }
    })
);

FeatureModel 可以在 setup 中获取到,进而实现了依赖注入。

export const FeatureModel1 = = createModel({
    ...,
    setup(self){
       // 获取父节点中类型为 ContextModel 的 实例
       const context = self.getParent(ContextModel);
       // 取出设置的依赖
       const storage = context.actions.getDependency(FeatureStoreDep)
                           || {...} ; // 注意兜底
       // 使用它们
       storage.get
       storage.set
    }
})

聚合事件

对 “多个分散状态变更 / 副作用触发” 的统一收口与联动管理

因为我构思的状态管理是多实例的,不同状态实例有其独有的事件流,实际开发中是有聚合事件的使用场景的。

  1. 日志统一打印
  2. 错误事件统一处理
StateModel.aggregateEvent.updates
    .subscribe(({key, value, preValue, instance})=>{

    });

StateModel.aggregateEvent.events
    .subscribe(({eventName, data, instance})=>{

    })

写到这里,其实我还意识到,actions 也应该提供聚合事件,actions 其实是一种事件输入,其处理逻辑应该放在 setup 内部同样使用事件流订阅处理。actions 被实现为一个 proxy 并对 key 创建出一个触发输入事件的函数。

//                            定义泛型 状态、输入事件、输出事件
const StateModel = createModel<{count,double}, {inc}, {event}>({
  initState: (id, params) => ({
    count: params.count,
    double: params.count * 2,
  }),
  // 这块就不要了
  // actions: (get, set, self) => {
  //   return {
  //    inc: () => set({ count: get().count + 1 }),
  //  },
  setup(self) {
    // 改为一个 actions.inc 事件订阅
    self.effectManager.addEffect(
      self.actions.inc.subscribe(() => {
        self.setState({
            count: self.state.count + 1,
        })
      })
    );
    
    // 这个是 memo 
    self.effectManager.addEffect(
      self.states.count.subscribe(c => {
        self.setState({ double: c * 2 });
      }));
  },
});

// 组件/业务中使用
const stateIns = StateModel.create('default', { count: 1 });
stateIns.actions.inc();

这样实现有什么作用呢?当你需要对输入事件做流处理如防抖的时候,就可以直接复用到 rxjs 操作符了。

组合复用机制

就像 react 自定义 Hook 一样,一些纯工具能力应该能轻易的复用并组合。

如果你觉得前面那些还看得过去,并且用起来还不错。

对不起,到了这个地方,不破不立,我要推翻一些东西了。

本质原因是,我想用完全不同的思路去实现它,创造另外一个东西。

我开始思考成本问题

  1. 迁移成本,已有代码使用 react hook 实现状态管理,迁移到任何一种外部状态管理库方案时,如何保证实现的逻辑是一样的?尤其是一个大量使用了社区 hook 的自定义 hook 实现?

  2. 维护成本,redux 和 zustand,很难找到类似 react hook 一样的组合逻辑的能力,这大大增加了维护成本。

如果你们用过 vue 的 pinia 状态管理方案,大概就知道了,pinia store 的 setup 方法是可以在里面使用 vue composition api 的。

虽说实现框架无关是状态管理的共识,但是实现上总是以某种方式实现的,只要实现方式不影响最终的 UI 层,那么以什么方式实现,就没那么重要了。

这里我脑子里蹦出一个惊人的想法,外部状态管理,就不能使用 react hook 吗?react 的铁律告诉我们, hook 只能在组件里面使用!

先抛开固有限制,以前面实现 computed 和 effect 的例子来讲,为什么不能是这样的?使用一个 hook 方法,每次 state 变更重新跑 useMemo 和 useEffect ,并将结果合并到 hookState 中给外部使用。

const StateModel = createModel({
  initState: (id, params) => ({
      count: params.count,
   }),
  hook({state, setState, self}) {
      const double = useMemo(() => state.count * 2, [state.count]);

      const inc = useCallback(() => {
          setState(s=>({count: s.count+1}));
      }, []);
      
      useEffect(()=>{
          const t = setTimeout(()=>{
              console.log('log count', state.count);
          }, 3000);
          
          return () => clearTimeout(t);
      }, [state.count]);
      
      // hook 返回,对象合并到 hookState 里面
      return {
          double,
          inc,
      }
  }
});

const stateIns = StateModel.create('default', { count: 1 });

await stateIns.hookState.inc();
await stateIns.hookState.inc(); // 3s 后 log count 3
stateIns.hookState.double // 6

有人会说:“这不能吧,hook 只能在组件树上使用,例子上这样做会不会破坏 react 的规则?”

我的想法是,react 组件树并不一定要产生 UI 输出,也可以单纯维护状态实例树。

有什么好处?好处可太大了!

你可以使用 react-query 发起请求,它帮你维护了请求状态(data, loading, fetching, error, time),但是这是 hook 的用法,你把请求放在 Zustand store 里面,你将失去一切!

但是,在一个底层以 react hook 实现的外部状态管理库中,你得到了这一切。

image.png

外部状态库可以直接使用 react hook 是一种巨大的吸引力,逻辑复用直接就是 react hook 的思路。react-use、ahooks,这些都能复用上了,像呼吸一样简单。

总结:复杂 or 简洁

做稍微复杂的设计,是为了在结构上承载复杂的逻辑,让转移后的复杂度变得可控、可维护。前文设计的状态实例层次管理、聚合事件、动态副作用管控等特性,看似增加了方案本身的设计复杂度,实则是为了承接业务中多实例联动、多状态协同、异步流处理等复杂场景的需求——如果为了追求方案表面的简洁,省略这些设计,复杂度并不会消失,反而会转移到业务代码中,变成分散的冗余逻辑、难以维护的硬编码,最终形成“表面简洁、内在混乱”的代码困境。这种有目的的复杂设计,核心是通过结构化的方案设计,将业务复杂度收纳在合理的框架内,兼顾扩展性与可维护性,避免复杂度无序扩散。

从N倍人力到1次修改:Vite Plugin Modular 如何拯救多产品前端维护困境

0. 引言:一个真实的故事

产品经理:"我们需要在所有产品中添加一个新的用户反馈功能,下周上线!"

前端开发:"😰 我们有8个产品,每个都需要单独修改,这得加班到什么时候啊..."

技术总监:"🤔 这样下去不是办法,维护成本太高了。我们需要一个更好的解决方案!"

隔壁老王:"我来!用 Vite Plugin Modular,一次修改,所有产品自动同步更新!"

前端开发:"真的假的?这么神奇?"

技术总监:"哇哦!(兴奋)好厉害!(星星眼🌟)这就是我们需要的!(一脸崇拜😍)"


1. 项目背景与痛点

在我们公司的实际业务中,随着业务的快速发展,我们面临着一个具体的挑战:公司拥有许多不同的产品,但各产品都有类似的功能模块。传统的实现方案是为每个产品创建独立的前端项目,各自维护对应的功能。这种方式在初期可能运行良好,但随着业务的不断扩展,问题逐渐凸显:

  • 维护成本高:功能变更需要在多个项目中重复实现,耗费N倍的人力。例如,当需要修改一个通用的登录功能时,需要在所有产品的前端项目中逐一修改,不仅耗时耗力,还容易出现遗漏。
  • 代码冗余:相似功能在不同项目中重复编写,导致代码库臃肿,增加了存储和维护成本。
  • 一致性难以保证:不同项目的相同功能可能出现实现差异,导致用户在使用不同产品时体验不一致,影响品牌形象。
  • 部署和配置复杂:每个项目都需要独立的部署流程和配置管理,增加了DevOps团队的工作负担。
  • 团队协作效率低:开发者需要在多个项目间切换,增加了上下文切换成本,降低了开发效率。
  • 技术债务累积:随着时间推移,各项目可能采用不同的技术栈和实现方式,导致技术债务不断累积,难以统一升级和维护。

为了解决这些实际业务问题,我们开发了 Vite Plugin Modular,一个专为多模块、多环境前端项目设计的 Vite 插件。它通过将多个相关产品的功能模块整合到单个项目中,实现了代码的复用和统一管理,大幅降低了维护成本,提高了开发效率,为公司的业务发展提供了更灵活、更高效的前端技术支持。

2. 核心功能介绍

2.1 多模块管理

Vite Plugin Modular 允许在单个项目中管理多个独立的功能模块,每个模块都有自己的源码目录、入口文件和配置。通过命令行工具,开发者可以轻松添加、删除和管理模块:

  • 模块化目录结构:自动生成标准化的模块目录结构,保持代码组织清晰
  • 独立的模块配置:每个模块可以有自己的标题、入口文件、输出目录等配置
  • 模块间隔离:模块间相互独立,避免命名冲突和代码耦合

2.2 多环境配置

针对每个模块,Vite Plugin Modular 支持配置多个环境(如 development、production、test 等),实现环境的精细化管理:

  • 环境变量注入:自动将配置的环境变量注入到代码中,可通过 import.meta.env 访问
  • 环境特定配置:为不同环境提供不同的配置,满足各种部署场景需求
  • 统一的环境管理:通过命令行工具方便地添加和删除环境配置

2.3 命令行工具

提供了功能强大的命令行工具(vmod),简化模块和环境的管理:

  • 模块管理命令adddeletelist 等命令用于模块的生命周期管理
  • 环境管理命令addEnvdeleteEnv 等命令用于环境配置的管理
  • 配置管理命令config 命令用于修改模块配置
  • 智能命令生成:自动为每个模块和环境生成对应的 npm 脚本命令

2.4 智能构建系统

Vite Plugin Modular 集成了智能的构建系统,为每个模块和环境提供定制化的构建配置:

  • 动态入口解析:根据当前模式自动解析模块入口路径
  • 自定义输出目录:每个模块可以配置独立的输出目录
  • HTML 自动转换:替换 HTML 页面标题和入口脚本为模块配置的值
  • 构建优化:继承 Vite 的优秀构建性能,同时提供模块级别的优化

2.5 环境变量处理

提供了灵活的环境变量处理机制,简化配置管理:

  • 自动环境变量注入:将配置的环境变量转换为 VITE_ 前缀的环境变量
  • 命名规范转换:自动将驼峰命名转换为蛇形命名,保持环境变量命名一致性
  • 模块特定环境变量:每个模块可以有自己的环境变量配置

3. 技术选型理由

3.1 方案选型对比

在设计 Vite Plugin Modular 之前,我们评估了多种前端多模块开发方案,包括:

方案 优势 劣势
npm 组件库 • 代码复用性高 • 版本管理清晰 • 可跨项目使用 • 发布流程繁琐 • 调试不便 • 依赖管理复杂 • 无法共享完整页面级功能 • 不适用于经常变更的业务需求
Monorepo • 代码集中管理 • 版本统一管理 • 跨包依赖便捷 • 初始设置复杂 • 构建时间长 • 学习成本高 • 配置繁琐
Vite 多页 • 配置简单 • 共享依赖 • 构建性能好 • 页面级隔离,无法实现模块级隔离 • 环境配置管理复杂 • 缺乏统一的模块管理工具 • 开发环境 URL 需要指定到具体的 HTML 文件
Vite Plugin Modular • 模块级隔离 • 多环境配置 • 命令行工具支持 • 快速开发构建 • 统一管理与代码复用 • 依赖 Vite 生态 • 对单模块项目优势不明显

3.2 基于 Vite

选择 Vite 作为基础构建工具,主要考虑以下因素:

  • 快速的开发服务器:Vite 的开发服务器采用原生 ESM,启动速度极快,适合多模块开发场景
  • 优化的构建性能:使用 Rollup 进行生产构建,提供优秀的代码分割和 tree-shaking
  • 丰富的插件生态:Vite 拥有活跃的插件生态系统,便于扩展功能
  • 现代前端特性支持:内置对 TypeScript、JSX、CSS 预处理器等的支持
  • 环境变量处理:Vite 内置了环境变量处理机制,与我们的需求高度契合

3.3 TypeScript 开发

采用 TypeScript 进行开发,带来以下优势:

  • 类型安全:提供静态类型检查,减少运行时错误
  • 更好的 IDE 支持:TypeScript 提供了更强大的代码补全和类型提示
  • 可维护性:类型定义使代码更易于理解和维护
  • 更好的重构支持:类型系统使重构更加安全和高效

3.4 命令行工具选型

命令行工具采用以下技术栈:

  • Commander.js:用于解析命令行参数和选项
  • Inquirer.js:提供交互式命令行界面,提升用户体验
  • Chalk:用于终端彩色输出,提高日志可读性
  • Node.js 文件系统 API:用于文件和目录的操作

命令行工具效果展示

4. 实现原理与流程

4.1 核心工作流程

Vite Plugin Modular 的核心工作流程如下所示,通过 Vite 插件机制,在构建过程中动态解析模块和环境信息,实现模块化的配置管理和构建流程。

4.1.1 模块解析机制

  1. 模式解析:通过 Vite 的 mode 参数,解析模块和环境信息。例如,当运行 vite --mode module1-dev 时,插件会自动解析出模块名称为 module1,环境为 dev
  2. 配置加载:根据解析出的模块名称,加载对应的模块配置。配置文件采用 JSONC 格式,支持注释,提高了可读性和可维护性。
  3. 路径转换:根据模块配置,动态转换入口文件路径和输出目录路径。例如,将 src/main.ts 转换为 src/modules/module1/main.ts,将输出目录设置为 dist/module1
  4. HTML 处理:通过 Vite 的 HTML 转换钩子,替换页面标题和入口脚本为模块配置的值,确保每个模块都有正确的页面标题和入口点。

4.1.2 环境变量处理

  1. 变量注入:将模块配置中的 define 字段转换为环境变量,通过 Vite 的 define 选项注入到代码中。例如,将 { "apiUrl": "https://api.example.com" } 转换为 import.meta.env.VITE_API_URL
  2. 命名规范:自动将驼峰命名转换为蛇形命名,保持环境变量命名的一致性。例如,将 apiUrl 转换为 VITE_API_URL
  3. 环境覆盖:支持环境特定的变量覆盖,确保不同环境可以使用不同的变量值。

4.2 命令行工具实现

命令行工具(vmod)的实现基于以下核心流程:

  1. 命令注册:使用 Commander.js 注册各种模块管理命令,如 adddeletelistaddEnvdeleteEnv 等。
  2. 交互式界面:使用 Inquirer.js 实现交互式命令行界面,在用户执行命令时提供智能提示和选择。
  3. 文件操作:使用 Node.js 文件系统 API 进行文件和目录的操作,如创建模块目录、生成配置文件、复制模板文件等。
  4. 配置管理:实现配置文件的读取、修改和写入,确保模块配置的一致性和完整性。
  5. 命令生成:在添加模块或环境时,自动生成对应的 npm 脚本命令,方便用户运行和构建模块。

4.3 与 Vite 的集成

Vite Plugin Modular 与 Vite 的集成主要通过以下钩子实现:

  1. config:在 Vite 配置阶段,修改配置对象,设置正确的入口文件路径、输出目录路径和环境变量。
  2. transformIndexHtml:在 HTML 转换阶段,替换页面标题和入口脚本为模块配置的值,确保每个模块都有正确的页面标题和入口点。

这两个钩子是实际实现中使用的核心钩子,通过它们实现了模块解析、配置加载、路径转换和 HTML 处理等核心功能。

4.4 模块隔离机制

Vite Plugin Modular 实现了模块间的隔离,确保各模块之间相互独立,避免代码冲突和依赖混乱:

  1. 目录隔离:每个模块都有自己的目录,独立存放源码和资源文件。
  2. 配置隔离:每个模块都有自己的配置,支持不同的入口文件、输出目录和环境变量。
  3. 依赖隔离:各模块共享项目级的依赖,但可以通过条件导入实现模块特定的依赖。
  4. 构建隔离:每个模块的构建过程相互独立,避免构建过程中的相互影响。

5. 与传统多项目方案的对比

针对公司多产品、功能重复的场景,Vite Plugin Modular 与传统的多项目方案相比具有显著优势:

特性 传统多项目方案 Vite Plugin Modular
项目结构 多个独立项目,各自维护 单个项目多模块结构,集中管理
功能变更 需要在多个项目中重复实现,耗费N倍人力 集中修改,所有模块自动同步更新
代码复用 复制粘贴或通过 npm 包共享,复用成本高 项目内直接共享代码,复用成本低
一致性保证 不同项目可能出现实现差异,用户体验不一致 统一实现,确保所有产品功能一致性
开发流程 多项目切换,上下文切换成本高 单项目内开发,流程简化
部署管理 每个项目独立部署,配置复杂 统一部署配置,模块化部署
环境配置 每个项目独立管理环境变量 统一环境管理,模块化配置
构建性能 每个项目独立构建,构建时间长 共享构建配置,优化构建性能
学习成本 新成员需要熟悉多个项目结构 只需熟悉一个项目结构和模块配置
扩展性 新增产品需要创建新项目,周期长 新增模块即可,快速响应业务需求

5. 快速开始指南

5.1 安装

# 使用 npm
npm install @ad-feiben/vite-plugin-modular --save-dev

# 使用 yarn
yarn add @ad-feiben/vite-plugin-modular -D

# 使用 pnpm
pnpm add @ad-feiben/vite-plugin-modular -D

5.2 配置

  1. 初始化配置
# 使用 CLI 命令初始化
npx vmod init

# 或使用简写
npx vm init

2. 在 vite.config.ts 中注册插件

import { defineConfig } from 'vite'
import VitePluginModular from '@ad-feiben/vite-plugin-modular'

export default defineConfig({
  plugins: [
    VitePluginModular()
  ]
})

5.3 创建模块

以下是创建模块的流程图,展示了从执行命令到模块创建完成的完整过程:

以下是创建模块的实际效果展示:

5.4 开发和构建

创建模块后,Vite Plugin Modular 会自动生成对应的 npm 脚本命令:

# 运行特定模块的开发服务器
npm run dev:module1-dev

# 构建特定模块的生产版本
npm run build:module1-prod

5.5 目录结构

创建模块后,会自动生成以下目录结构,保持代码组织清晰:

src/modules/
├── module1/          # 模块目录
└── module2/
└── moduleN/

6. 适用场景

Vite Plugin Modular 特别适合以下场景:

6.1 多产品公司

对于拥有多个相关产品的公司,Vite Plugin Modular 可以将这些产品的前端代码整合到单个项目中,实现代码复用和统一管理。

6.2 微前端架构

在微前端架构中,Vite Plugin Modular 可以作为微前端模块的开发和构建工具,简化模块的管理和部署。

6.3 企业内部系统

企业内部通常有多个功能相关的系统(如 CRM、ERP、OA 等),Vite Plugin Modular 可以将这些系统的前端代码整合到单个项目中,提高开发和维护效率。

6.4 SaaS 产品

对于 SaaS 产品,不同客户可能有不同的定制需求,Vite Plugin Modular 可以通过模块和环境的配置,轻松实现不同客户的定制版本。

6.5 快速原型开发

在需要快速开发多个相关原型的场景中,Vite Plugin Modular 可以帮助开发者快速创建和管理多个原型模块,提高原型开发效率。

7. 未来规划

Vite Plugin Modular 是一个持续发展的项目,我们计划在未来的版本中添加以下功能:

7.1 国际化支持

  • 实现模块级别的国际化配置,支持不同模块使用不同的语言设置
  • 提供多语言资源管理系统,方便管理和维护多语言内容
  • 支持自动语言切换,根据用户环境或配置自动选择合适的语言

7.2 UI 界面

  • 开发可视化的模块管理界面,提供直观的模块创建、编辑、删除功能
  • 实现配置编辑器,通过图形界面编辑模块配置,减少手动编辑配置文件的错误
  • 提供实时预览功能,在修改配置后立即查看效果
  • 集成项目状态监控,显示模块构建状态、依赖关系等信息
  • 支持拖放操作,通过拖放方式管理模块间的依赖关系

7.3 完善文档

  • 编写详细的 API 文档,覆盖所有插件配置选项和命令行参数
  • 提供全面的使用指南,包括快速开始、高级配置、最佳实践等
  • 建立社区支持渠道,收集用户反馈和建议,持续改进插件功能

结语

Vite Plugin Modular 为前端多模块开发提供了一种全新的思路,通过将多个相关产品的功能模块整合到单个项目中,实现了代码的复用和统一管理,大幅降低了维护成本,提高了开发效率。它不仅是一个技术工具,更是一种前端工程化的最佳实践。

无论您是在开发多个相关产品,还是在构建微前端架构,Vite Plugin Modular 都能为您的项目带来显著的价值。我们相信,随着它的不断发展和完善,它将成为前端多模块开发的标准解决方案之一。

立即尝试 Vite Plugin Modular,体验前端模块化开发的新境界!

我的项目实战(九)—— 实现页面状态缓存?手写KeepAlive ,首页优化组件

今天,我们的项目要继续深入一个“看起来简单、实则暗流涌动”的功能场景:页面状态缓存 —— KeepAlive

你可能已经见过这样的需求:

“用户从首页点进详情页,再返回时,首页又要重新加载?能不能记住我之前滑到哪了?”

这不只是用户体验的问题,更是对前端架构的一次考验。


一、问题起点:为什么首页总在“重复劳动”?

在 React 单页应用中,路由切换并不会刷新页面,但组件会经历完整的挂载与卸载过程。

以常见的首页为例:

<Route path="/home" element={<Home />} />

当用户从 /home 切换到 /detail 时,React 会执行 Home.unmount()
再次返回时,则重新执行 Home.mount() —— 所有 useState 清零,useEffect 重跑,接口重发,列表重渲染。

结果就是:

  • 用户每次回来都要等数据加载;
  • 滚动位置回到顶部;
  • 已填写的搜索条件丢失;
  • 动画闪烁明显。

这不是 SPA 应该有的样子。我们需要的是:视觉上离开,逻辑上留下

于是,KeepAlive 出现了。


二、目标拆解:一个合格的 KeepAlive 要解决什么问题?

别急着引入第三方库,先明确我们的核心诉求:

  1. 组件状态保留:包括 state、ref、DOM 结构、滚动位置;
  2. 按需缓存:不是所有页面都需要缓存,要可配置;
  3. 内存可控:不能无限制缓存,避免内存泄漏;
  4. 与路由系统良好集成:支持 React Router 等主流方案;
  5. 组件卸载时自动清理资源:防止事件监听、定时器残留。

这些要求听起来像 Vue 的 <keep-alive>?没错,但在 React 中,它需要我们更主动地去构建这套机制。


三、方案选型:自研 vs 第三方库

方案一:手写简易版 KeepAlive

我们可以用最朴素的方式模拟缓存行为:

const [cache, setCache] = useState({});
const [activeKey, setActiveKey] = useState(null);

// 缓存当前组件
useEffect(() => {
  if (children && activeId) {
    setCache(prev => ({ ...prev, [activeId]: children }));
  }
}, [activeId, children]);

return (
  <>
    {Object.entries(cache).map(([key, comp]) => (
      <div key={key} style={{ display: key === activeKey ? 'block' : 'none' }}>
        {comp}
      </div>
    ))}
  </>
);

✅ 优点:

  • 原理清晰,适合教学理解;
  • 不依赖额外包,轻量;
  • 可完全掌控缓存策略。

❌ 缺点:

  • 无法真正保留组件实例(如 ref、内部 state 生命周期);
  • 子组件更新可能导致缓存失效;
  • 难以处理复杂嵌套结构;
  • 没有统一的缓存管理机制。

这种方式更适合静态内容或演示用途,不适合生产环境。


方案二:使用 react-activation

这是一个专门为 React 实现类似 Vue keep-alive 行为的成熟库。

它提供了三个核心能力:

import { AliveScope, KeepAlive } from 'react-activation';

function App() {
  return (
    <AliveScope>
      <Router>
        <Routes>
          <Route
            path="/home"
            element={
              <KeepAlive name="home" saveScrollPosition="screen">
                <Home />
              </KeepAlive>
            }
          />
        </Routes>
      </Router>
    </AliveScope>
  );
}

核心组件说明:

组件 作用
<AliveScope> 全局缓存容器,必须作为根节点包裹整个应用或需要缓存的部分
<KeepAlive> 包裹需要缓存的组件,通过 name 做唯一标识
useActivate/useUnactivate 替代 useEffect,监听组件激活/失活状态

✅ 真正做到了什么?

  • 组件卸载时不销毁实例,而是移入缓存池;
  • 再次激活时直接复用原有实例,state 完全保留;
  • 支持滚动位置记忆(saveScrollPosition);
  • 提供钩子函数控制数据刷新时机。

这才是我们想要的“活”的组件。


四、实践细节:如何安全高效地使用 KeepAlive?

1. 合理设置缓存粒度

不是所有页面都值得被缓存。比如:

  • 登录页、支付成功页这类一次性页面,不应缓存;
  • 数据强实时性页面(如股票行情),缓存反而会造成信息滞后。

✅ 建议只对以下类型启用:

  • 首页、推荐流、商品列表等高频访问页;
  • Tab 类布局中的子页面(可用 name 动态生成);
  • 用户常往返跳转的路径。
<KeepAlive name={`list_${category}`}>...</KeepAlive>

2. 控制数据更新节奏:useActivate 是关键

由于组件不会重新 mount,useEffect(() => {}, []) 只会在首次进入时触发一次。

这意味着:后续返回不会拉取最新数据

解决方案是使用专属钩子:

import { useActivate } from 'react-activation';

function Home() {
  const [data, setData] = useState([]);

  // 每次激活时执行
  useActivate(() => {
    console.log('Home 被唤醒');
    fetchLatestData().then(setData);
  });

  return <div>{/* 渲染内容 */}</div>;
}

这样既保留了状态,又能保证内容不过期。


3. 内存与性能的平衡

虽然 react-activation 做了很多优化,但我们仍需警惕:

  • 长期缓存大量组件会导致内存占用上升;
  • 特别是在移动端,内存资源有限。

📌 建议措施

  • 设置最大缓存数量(可通过封装中间层控制);
  • 对非活跃页面手动清除缓存(调用 dropByCacheKey);
  • 在开发工具中监控内存变化,及时发现问题。

4. 清理副作用:别忘了事件监听和定时器

即使组件被缓存,也不能放任副作用不管。

错误示例:

useEffect(() => {
  const timer = setInterval(polling, 5000);
  return () => clearInterval(timer); // ❌ 只在 unmount 时清理
});

如果组件一直被缓存,这个定时器将永远运行!

✅ 正确做法是结合 useUnactivate

useEffect(() => {
  const timer = setInterval(polling, 5000);
  return () => clearInterval(timer);
}, []);

// 或者使用专用钩子
useUnactivate(() => {
  console.log('Home 暂时休眠');
  // 可在此暂停轮询、断开 WebSocket 等
});

让组件在“休眠”前主动释放资源,醒来后再恢复。


五、总结:KeepAlive 是一种思维转变

KeepAlive 不只是一个技术组件,它代表了一种新的开发范式:

我们不再假设组件每次出现都是“全新”的,而要开始考虑它的“生命周期状态”

就像人离开房间又回来,不应该忘记自己刚才在做什么。

能力 在本组件中的体现
状态持久化 保留 scrollY、form 输入、局部状态
性能优化 避免重复渲染、减少网络请求
用户体验 返回即原样,无闪烁无等待
工程化思维 合理缓存、资源清理、可维护性

六、结语

前端开发的魅力就在于:
那些最容易被忽略的小功能,往往藏着最深的设计哲学。

从“回到顶部”到“页面缓存”,我们在一次次打磨中学会思考:

“用户真正需要的是什么?”
“我们是在做功能,还是在解决问题?”

KeepAlive 不是为了炫技,而是为了让用户感受到:这个页面记得我

下次当你接到“首页老是重新加载”的反馈时,不妨试试给它加一层 KeepAlive —— 让页面变得更有“记忆”。

欢迎点赞收藏,也期待你在评论区分享你的缓存策略或踩坑经历。

CSS3动画使用教程

你想要一份更详细、更系统的CSS3动画(animation)使用教程,我会从基础概念到实战案例,一步步拆解CSS3动画的核心用法,让你能轻松上手并灵活运用。

CSS3 动画(animation)全教程

CSS3动画是通过@keyframes定义关键帧,再通过animation属性将动画应用到元素上,能实现比transition更复杂、更灵活的动态效果,且无需JavaScript参与。

一、核心概念理解

在开始写代码前,先理清两个核心部分:

  1. @keyframes(关键帧) :定义动画的"关键状态",比如起始、中间、结束时元素的样式。
  2. animation 属性:将定义好的关键帧动画应用到元素上,并设置动画的时长、循环、速度等规则。

二、基础语法与步骤

步骤1:定义关键帧(@keyframes)
/* 格式:@keyframes 动画名称 { 关键帧规则 } */
@keyframes 动画名称 {
  /* 0% 表示动画开始(也可以用 from 替代) */
  0% {
    /* 起始样式 */
    transform: translateX(0);
    opacity: 0;
  }
  /* 50% 表示动画进行到一半 */
  50% {
    /* 中间样式 */
    transform: translateX(100px);
    opacity: 1;
  }
  /* 100% 表示动画结束(也可以用 to 替代) */
  100% {
    /* 结束样式 */
    transform: translateX(200px);
    opacity: 0;
  }
}
步骤2:应用动画(animation 属性)
/* 给元素添加动画 */
.animated-box {
  width: 100px;
  height: 100px;
  background: #007bff;
  
  /* 核心:animation 复合属性(推荐) */
  /* 格式:动画名称 时长 速度曲线 延迟 循环次数 方向 填充模式 播放状态 */
  animation: 动画名称 2s ease 0.5s infinite alternate forwards running;
  
  /* 也可以拆分为单个属性(便于理解和调试) */
  /* animation-name: 动画名称;          // 必选:指定关键帧名称 */
  /* animation-duration: 2s;            // 必选:动画时长(默认0,无效果) */
  /* animation-timing-function: ease;  // 可选:速度曲线(默认ease) */
  /* animation-delay: 0.5s;             // 可选:延迟播放(默认0) */
  /* animation-iteration-count: infinite; // 可选:循环次数(默认1,infinite无限) */
  /* animation-direction: alternate;    // 可选:播放方向(默认normal) */
  /* animation-fill-mode: forwards;     // 可选:动画结束后样式(默认none) */
  /* animation-play-state: running;     // 可选:播放状态(默认running,paused暂停) */
}

三、关键属性详解(必掌握)

1. 速度曲线(animation-timing-function)

控制动画的播放速度,常用值:

/* 常用值示例 */
.animated-box {
  /* linear:匀速(最常用) */
  animation-timing-function: linear;
  /* ease:慢→快→慢(默认) */
  /* ease-in:慢→快 */
  /* ease-out:快→慢 */
  /* ease-in-out:慢→快→慢(比ease更平缓) */
  /* 自定义贝塞尔曲线(精准控制) */
  /* animation-timing-function: cubic-bezier(0.1, 0.7, 1.0, 0.1); */
}
2. 播放方向(animation-direction)

控制动画是否反向播放:

.animated-box {
  /* normal:正常播放(默认),从0%→100% */
  /* alternate:交替播放,奇数次正向(0%→100%),偶数次反向(100%→0%) */
  /* reverse:反向播放(100%→0%) */
  /* alternate-reverse:反向交替播放 */
  animation-direction: alternate;
}
3. 填充模式(animation-fill-mode)

控制动画开始前/结束后的元素样式:

.animated-box {
  /* none:默认,动画结束后回到初始样式 */
  /* forwards:动画结束后,保持最后一帧样式 */
  /* backwards:动画延迟期间,保持第一帧样式 */
  /* both:同时应用forwards和backwards */
  animation-fill-mode: forwards;
}
4. 播放状态(animation-play-state)

常用于通过:hover、JS控制动画暂停/播放:

.animated-box {
  animation: move 2s infinite;
}
/* 鼠标悬停时暂停动画 */
.animated-box:hover {
  animation-play-state: paused;
}

四、实战案例(直接复用)

案例1:呼吸灯效果(透明度变化)
/* 定义关键帧 */
@keyframes breathe {
  0% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.5;
    transform: scale(1.1);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

/* 应用动画 */
.breathe-box {
  width: 80px;
  height: 80px;
  background: #ff6700;
  border-radius: 50%;
  /* 匀速、无限循环 */
  animation: breathe 2s linear infinite;
}
案例2:加载动画(旋转+多元素)
<!-- HTML结构 -->
<div class="loader">
  <div class="loader-item"></div>
  <div class="loader-item"></div>
  <div class="loader-item"></div>
</div>
/* 定义旋转关键帧 */
@keyframes load {
  0% {
    transform: translateY(0);
    opacity: 0.8;
  }
  50% {
    transform: translateY(-20px);
    opacity: 0.2;
  }
  100% {
    transform: translateY(0);
    opacity: 0.8;
  }
}

.loader {
  display: flex;
  gap: 8px;
  justify-content: center;
  align-items: center;
  height: 100px;
}

.loader-item {
  width: 12px;
  height: 30px;
  background: #007bff;
  border-radius: 6px;
  animation: load 1.2s ease infinite;
}
/* 给每个小球设置不同延迟,实现错落效果 */
.loader-item:nth-child(2) {
  animation-delay: 0.2s;
}
.loader-item:nth-child(3) {
  animation-delay: 0.4s;
}
案例3:文字渐入动画
@keyframes text-fade {
  0% {
    transform: translateY(20px);
    opacity: 0;
  }
  100% {
    transform: translateY(0);
    opacity: 1;
  }
}

.fade-text {
  font-size: 24px;
  animation: text-fade 0.8s ease forwards;
  /* 初始状态隐藏 */
  opacity: 0;
}

五、使用注意事项

  1. 性能优化:优先使用transformopacity属性做动画(浏览器硬件加速,无重绘),避免用widthheighttopleft(会触发频繁重排,卡顿)。
  2. 兼容性:现代浏览器(Chrome/Firefox/Safari/Edge)均支持CSS3动画,无需加前缀;如需兼容老旧浏览器(如IE10-),可加前缀-webkit-(如@-webkit-keyframes-webkit-animation)。
  3. 动画暂停:通过animation-play-state: paused暂停动画,比重新设置时长为0更优雅。

总结

  1. 核心结构:CSS3动画由@keyframes(定义关键帧)和animation(应用动画)两部分组成,animation-duration是必选属性(否则无动画效果)。
  2. 常用属性animation-iteration-count: infinite(无限循环)、animation-direction: alternate(交替播放)、animation-fill-mode: forwards(保持结束样式)是高频组合。
  3. 性能原则:动画优先操作transformopacity,避免触发页面重排,保证动画流畅。

你可以把这些案例代码复制到HTML文件中运行,修改关键帧的样式、动画时长、速度曲线等参数,直观感受不同设置的效果,很快就能熟练掌握。如果想实现某个特定的动画效果(比如弹跳、滑动、闪烁),可以告诉我,我会针对性给出代码。

three.js | 初识3D世界

概念理解 在3D世界,或者说三维世界,每个物体(three中对应的“名词”是Mesh)都有自己的形状(Geometry),以及材质(Material)。 偏一下题,我突然想到了这可以关联现实世界的摄影

你的 sideEffects 真的配对了吗?—— 深度拆解构建工具的 Tree-shaking 潜规则

🚀 省流助手(速通结论):

  1. sideEffects 是给宿主(用你包的项目)看的声明,不是给你自己构建减重用的。
  2. 只要包里包含 CSS/样式全局监听process.on)或修改全局变量绝不能简单设为 false
  3. /* @__PURE__ */ 的意思是  “这行没用到请删掉” ,而不是 “不能删”。
  4. Bundle 并不安全:即便你打包成了单文件,一旦声明了 false,宿主打包工具依然能从内部“抠掉”你的副作用代码。

一、 线上“失踪”案:谁偷走了我的初始化逻辑?

很多开发者都遇到过这种诡异场景:本地开发时一切正常的全局监听(如 process.on('exit'))或样式文件,发布成 npm 包被别人使用后,在生产环境竟然“失效”了。

检查代码,逻辑都在;检查产物,文件也引了。最后发现,根源竟然是你在 package.json 中随手写下的那行:

json

"sideEffects": false

请谨慎使用此类代码。

你以为是在帮宿主做性能优化,实际上你是在给自己的代码下“逐客令”。

二、 生效时刻:它是谁的“紧箍咒”?

误区:  认为在库里写了 sideEffects: false,自己执行 vite build 时包体积就会变小。

真相:它的真正战场是「宿主编译时刻」。

  1. 自身构建时:当你运行构建指令时,工具遵循作者意图。只要你在入口写了 import './effect.ts',这段代码就会物理存在于你的 dist 产物中。
  2. 宿主打包时:当其他项目安装了你的包,宿主工具(Vite/Webpack)会读取你的声明。如果你承诺了“无副作用”,一旦宿主没引用你该模块导出的变量,工具就会开启“外科手术”:即使你的单文件 Bundle 物理上包含了这段代码,工具也会在最终输出时将其精准剔除。

三、 穿透 Bundle 的“外科手术”

这是最隐蔽的陷阱。很多开发者认为:“我打包时已经把副作用合并进 index.js 了,宿主引用了 index.js 就安全了。”

错了。  现代打包工具具备 Module Concatenation(模块提升)  能力。它们能“看穿” Bundle 内部的结构。只要你声明了 false,它们有能力从一个大的文件块中只“抠”出用到的函数,而把剩下的(包括那段 import './effect.ts' 产生的内容)当作垃圾直接丢弃。

四、 微观博弈:/* @__PURE__ */ 到底在帮谁?

如果说 sideEffects 是文件级的“粗调”,那么 /* @__PURE__ */ 就是语句级的“微操”。

纠正一个常见误区:  它是标记“可以删”,而不是“不能删”。

假设你的工具库有一个文件导出了 100 个函数,宿主只用了其中 1 个。

  • 如果没有标记:剩下的 99 个导出中,如果包含 export const config = init() 这种函数执行,打包工具会因为不敢确定 init() 是否修改了全局变量而保守地保留这一行。
  • 如果加上标记:你是在给工具发“免责声明”。工具看到 /* @__PURE__ */,发现没人用 config,就会放心地把这一行代码从产物中抹除。

五、 避坑总结:白名单管理

为了不让代码被“误杀”,你不能在包含副作用的文件里写 false。最专业的做法是使用数组进行精准保护

哪些文件必须进 sideEffects 数组?

  1. 样式文件*.css*.scss
  2. 环境初始化:修改 global 或 window 的脚本。
  3. 进程监控:包含 process.on 或 interval 的逻辑。

推荐配置:

json

{
  "sideEffects": [
    "**/*.css",
    "./dist/_init/*.mjs"
  ]
}

请谨慎使用此类代码。

结语

Tree-shaking 是一场开发者与构建工具之间的博弈。工具的本质是“保守”的,而 sideEffects: false 是你交给工具的一把“激进”的剪刀。

在下一篇中,我们将深入探讨:如何通过工程架构设计,强制开发者在编写副作用代码时进行“决策”,从而构建一套永远不会被意外误删的“契约式”架构。

Vue<前端页面版本检测>

为什么需要版本检测

1. 解决浏览器缓存问题

  • 静态资源缓存:浏览器会缓存 JS、CSS 等静态资源,用户可能继续使用旧版本
  • 用户体验影响:用户无法及时获取新功能,导致功能缺失或操作异常

2. 保障功能一致性

  • 功能同步:确保所有用户都能使用最新的功能和修复
  • 数据一致性:避免因版本差异导致的数据不一致问题

3. 提升用户体验

  • 主动提醒:在新版本发布后主动通知用户更新
  • 无缝升级:减少用户手动刷新页面的需求

版本检测核心思路

version1.gif

整体架构

构建阶段 → 版本文件生成 → 运行时检测 → 版本对比 → 用户提醒

技术实现要点

1. 版本标识生成

  • 构建时生成:每次打包时生成唯一的版本标识
  • 时间戳方案:使用时间戳确保每次构建版本号唯一

2. 版本文件部署

  • JSON 格式:将版本信息保存为 version.json 文件
  • 静态访问:通过 HTTP 请求可直接访问版本文件

3. 客户端检测机制

  • 定时轮询:定期检查服务器版本文件
  • 版本对比:比较本地缓存版本与服务器版本
  • 智能提醒:仅在版本不一致时提醒用户

版本检测实现步骤

步骤一:构建版本文件生成脚本

创建 build-version.js 文件:

// build-version.js (自动生成版本文件脚本)
const fs = require('fs')
const path = require('path')

// 方案A:使用时间戳作为版本标识(最简单,确保每次打包唯一)
const version = new Date().getTime().toString()

// 版本文件内容
const versionJson = {
  version: version,
  updateTime: new Date().toLocaleString() // 可选:添加更新时间,便于排查
}

// 写入version.json文件(项目根目录)
const versionPath = path.resolve(__dirname, 'public', 'version.json')
fs.writeFileSync(versionPath, JSON.stringify(versionJson, null, 2), 'utf-8')

console.log(`✅ 自动生成版本文件成功,版本号:${version}`)

步骤二:修改构建命令

在 package.json 中修改构建命令:

{
  "scripts": {
    "build:prod": "node build-version.js && vue-cli-service build"
  }
}

步骤三:配置 Vue 构建过程

在 vue.config.js 中添加版本文件复制配置:

chainWebpack(config) {
  // ... 其他配置
  
  // 复制 version.json 到 dist 目录
  config.plugin('copy')
    .tap(args => {
      const hasVersionJson = args[0].some(item => item.from === 'version.json')
      if (!hasVersionJson) {
        args[0].push({
          from: path.resolve(__dirname, 'public/version.json'),
          to: path.resolve(__dirname, 'dist/version.json')
        })
      }
      return args
    })
}

步骤四:实现版本检测工具类

创建 src/utils/versionUpdate.js

// src/utils/versionUpdate.js
import { Notification } from 'element-ui'
/**
 * 版本更新检测工具类(仅生产环境启用轮询,内置环境判断)
 */
class VersionUpdate {
  constructor(options = {}) {
    this.config = {
      versionFileUrl: '/version.json', // 版本文件地址
      localVersionKey: 'cmpVersion', // 本地存储的版本号key
      disableFetchCache: true, // 禁用Fetch缓存
      pollInterval: 5 * 60 * 1000, // 5分钟轮询一次
      hasNotified: false // 是否已提醒过用户有新版本
    }
    Object.assign(this.config, options)
    // 定时轮询定时器
    this.pollTimer = null
    // 识别当前环境(Vue CLI 4 自动注入的环境变量)
    this.isProduction = process.env.NODE_ENV === 'production'
  }

  /**
   * 核心方法:执行版本检测
   */
  async checkVersion(isInit = false) {
    try {
      if (this.config.hasNotified) return false

      const localVersion = localStorage.getItem(this.config.localVersionKey) || ''
      const fetchOptions = {}
      if (this.config.disableFetchCache) {
        fetchOptions.cache = 'no-cache'
      }

      const response = await fetch(this.config.versionFileUrl, fetchOptions)
      if (!response.ok) {
        throw new Error(`版本文件请求失败,状态码:${response.status}`)
      }
      const latestVersionInfo = await response.json()
      const serverVersion = latestVersionInfo.version

      if (isInit) {
        this.cacheLatestVersion(serverVersion)
        return true
      }

      if (serverVersion && serverVersion !== localVersion) {
        this.config.hasNotified = true
        console.log('有新版本可用', latestVersionInfo)
        Notification({
          title: '🎉 有新版本可用',
          dangerouslyUseHTMLString: true,
          message: `<p style="font-size:12px;">建议点击刷新页面,以获取最新功能和修复</p> <p style="color:#cccccc;font-size:12px;">更新时间:${latestVersionInfo.updateTime}</p>`,
          duration: 0,
          customClass: 'check-version-notify',
          onClick: () => {
            this.forceRefreshPage()
          },
          onClose: () => {
            this.resetNotifyFlag()
          }
        })
        return true
      } else {
        // 版本一致时,重置提醒标记,便于后续轮询检测新版本
        this.config.hasNotified = false
        // console.log('当前已是最新版本,已缓存最新版本号')
        return false
      }
    } catch (error) {
      console.warn('版本检测异常,不影响应用运行:', error.message)
      return false
    }
  }
  /**
   * 启动定时轮询检测(内置环境判断:仅生产环境生效)
   */
  async startPolling() {
    // 核心:非生产环境,直接返回,不启动轮询
    if (!this.isProduction) {
      console.log('当前为非生产环境,不启动版本检测轮询')
      return
    }

    // 生产环境:正常启动轮询
    this.stopPolling() // 先停止已有轮询,避免重复启动
    this.checkVersion(true) // 立即执行一次检测

    this.pollTimer = setInterval(() => {
      this.checkVersion()
    }, this.config.pollInterval)

    console.log(`生产环境版本轮询检测已启动,每隔${this.config.pollInterval / 1000 / 60}分钟检测一次`)
  }

  /**
   * 停止定时轮询检测
   */
  stopPolling() {
    if (this.pollTimer) {
      clearInterval(this.pollTimer)
      this.pollTimer = null
      console.log('版本轮询检测已停止')
    }
  }

  /**
   * 重置提醒标记
   */
  resetNotifyFlag() {
    this.config.hasNotified = false
  }

  // 缓存最新版本号
  cacheLatestVersion(version) {
    localStorage.setItem(this.config.localVersionKey, version)
    this.resetNotifyFlag()
  }

  // 强制刷新页面
  forceRefreshPage() {
    window.location.reload(true)
  }
}

const versionUpdateInstance = new VersionUpdate()
export { VersionUpdate, versionUpdateInstance }
export default versionUpdateInstance

创建自定义.check-version-notify的版本检测全局样式:

image.png

// 版本检测通知样式
.check-version-notify{
  border: 3px solid transparent !important;
  cursor: pointer;
  background-color: rgba(255, 255, 255, 0.6) !important;
  backdrop-filter: blur(5px);
  &:hover{
    border: 3px solid $--color-primary !important;
  }
  .el-notification__icon{
    font-size: 18px;
    height: 18px;
  }
  .el-notification__title{
    font-size: 14px;
    line-height: 18px;
  }
  .el-notification__group{
    margin-left: 8px;
  }
}

步骤五:在应用入口启动版本检测

在 App.vue 或合适的入口文件中启动版本检测:

import versionUpdate from '@/utils/versionUpdate'
...
mounted() {
  versionUpdate.startPolling()
},
beforeDestroy() {
  versionUpdate.stopPolling()
}

将数组分成最小总代价的子数组 I

方法一:排序

思路与算法

根据题意可知一个数组的代价是它的第一个元素。需要将给定数组 $\textit{nums}$ 分成 $3$ 个连续且没有交集的子数组,题目要求返回这 $3$ 子数组的最小代价和。
根据题意可知,第一个子数组的代价已确定为 $\textit{nums}[0]$。如果确定了第二个子数组的第一个数的位置和第三个子数组的第一个数的位置,此时子数组的划分方案也就确定。我们可以任意选择两个索引 $(i,j)$ 作为第二个子数组的起始位置和第三个子数组的起始位置,且满足 $1 \le i < j \le n -1$,其中 $n$ 表示给定数组 $\textit{nums}$ 的长度。此时,第二个子数组的代价为 $\textit{nums}[i]$,第三个子数组的代价为 $\textit{nums}[j]$。为保证代价和最小,此时可以在 $[1,n−1]$ 中的选择值最小的两个下标即可,可将子数组 $\textit{nums}[1 \cdots n-1]$ 按照从小到大排序,取最小的两个元素即可。

代码

###C++

class Solution {
public:
    int minimumCost(vector<int>& nums) {
        sort(nums.begin() + 1, nums.end());
        return reduce(nums.begin(), nums.begin() + 3, 0);
    }
};

###Java

class Solution {
    public int minimumCost(int[] nums) {
        Arrays.sort(nums, 1, nums.length);
        return nums[0] + nums[1] + nums[2];
    }
}

###C#

public class Solution {
    public int MinimumCost(int[] nums) {
        Array.Sort(nums, 1, nums.Length - 1);
        return nums.Take(3).Sum();
    }
}

###Go

func minimumCost(nums []int) int {
    sort.Ints(nums[1:])
    return nums[0] + nums[1] + nums[2]
}

###Python

class Solution:
    def minimumCost(self, nums: List[int]) -> int:
        nums[1:] = sorted(nums[1:])
        return sum(nums[:3])

###C

int cmp(const void *a, const void *b) {
    return (*(int *)a) - (*(int *)b);
}

int minimumCost(int *nums, int numsSize) {
    qsort(nums + 1, numsSize - 1, sizeof(int), cmp);
    return nums[0] + nums[1] + nums[2];
}

###JavaScript

var minimumCost = function(nums) {
    nums = [nums[0], ...nums.slice(1).sort((a, b) => a - b)];
    return nums.slice(0, 3).reduce((sum, num) => sum + num, 0);
};

###TypeScript

function minimumCost(nums: number[]): number {
    nums = [nums[0], ...nums.slice(1).sort((a, b) => a - b)];
    return nums.slice(0, 3).reduce((sum, num) => sum + num, 0);
};

###Rust

impl Solution {
    pub fn minimum_cost(mut nums: Vec<i32>) -> i32 {
        if nums.len() > 1 {
            let (first, rest) = nums.split_at_mut(1);
            rest.sort();
        }
        nums.iter().take(3).sum()
    }
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,其中 $n$ 表示给定数组 $\textit{nums}$ 的长度。排序需要 $O(n \log n)$ 的时间。

  • 空间复杂度:$O(\log n)$。排序需要 $O(\log n)$ 的栈空间。

方法二:维护最小值和次小值

思路与算法

根据方法一可知,我们需要找到下标在 $[1,n−1]$ 中的两个最小元素,此时可以在遍历数组的过程中维护最小值 $\textit{first}$ 和次小值 $\textit{second}$,最终答案即为 $\textit{nums}[0] + \textit{first} + \textit{second}$。

代码

###C++

class Solution {
public:
    int minimumCost(vector<int> &nums) {
        int first = INT_MAX, second = INT_MAX;
        for (int i = 1; i < nums.size(); i++) {
            int x = nums[i];
            if (x < first) {
                second = first;
                first = x;
            } else if (x < second) {
                second = x;
            }
        }
        return nums[0] + first + second;
    }
};

###Java

class Solution {
    public int minimumCost(int[] nums) {
        int first = Integer.MAX_VALUE;
        int second = Integer.MAX_VALUE;

        for (int i = 1; i < nums.length; i++) {
            int x = nums[i];
            if (x < first) {
                second = first;
                first = x;
            } else if (x < second) {
                second = x;
            }
        }
        return nums[0] + first + second;
    }
}

###C#

public class Solution {
    public int MinimumCost(int[] nums) {
        int first = int.MaxValue;
        int second = int.MaxValue;
        
        for (int i = 1; i < nums.Length; i++) {
            int x = nums[i];
            if (x < first) {
                second = first;
                first = x;
            } else if (x < second) {
                second = x;
            }
        }
        return nums[0] + first + second;
    }
}

###Go

func minimumCost(nums []int) int {
    first := int(^uint(0) >> 1)
    second := int(^uint(0) >> 1)
    
    for i := 1; i < len(nums); i++ {
        x := nums[i]
        if x < first {
            second = first
            first = x
        } else if x < second {
            second = x
        }
    }
    return nums[0] + first + second
}

###Python

class Solution:
    def minimumCost(self, nums: List[int]) -> int:
        return nums[0] + sum(nsmallest(2, nums[1:]))

###C

int minimumCost(int* nums, int numsSize) {
    int first = INT_MAX;
    int second = INT_MAX;
    
    for (int i = 1; i < numsSize; i++) {
        int x = nums[i];
        if (x < first) {
            second = first;
            first = x;
        } else if (x < second) {
            second = x;
        }
    }
    return nums[0] + first + second;
}

###JavaScript

var minimumCost = function(nums) {
    let first = Number.MAX_SAFE_INTEGER;
    let second = Number.MAX_SAFE_INTEGER;
    
    for (let i = 1; i < nums.length; i++) {
        const x = nums[i];
        if (x < first) {
            second = first;
            first = x;
        } else if (x < second) {
            second = x;
        }
    }
    return nums[0] + first + second;
};

###TypeScript

function minimumCost(nums: number[]): number {
    let first: number = Number.MAX_SAFE_INTEGER;
    let second: number = Number.MAX_SAFE_INTEGER;
    
    for (let i = 1; i < nums.length; i++) {
        const x: number = nums[i];
        if (x < first) {
            second = first;
            first = x;
        } else if (x < second) {
            second = x;
        }
    }
    return nums[0] + first + second;
};

###Rust

impl Solution {
    pub fn minimum_cost(nums: Vec<i32>) -> i32 {
        let mut first = i32::MAX;
        let mut second = i32::MAX;
        
        for i in 1..nums.len() {
            let x = nums[i];
            if x < first {
                second = first;
                first = x;
            } else if x < second {
                second = x;
            }
        }
        nums[0] + first + second
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。

  • 空间复杂度:$O(1)$。

【节点】[ViewDirection节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity的Shader Graph中,View Direction节点是一个功能强大且常用的工具,它允许开发者访问网格顶点或片元的视图方向矢量。这个矢量表示从顶点或片元指向摄像机的方向,在光照计算、反射效果、边缘光等众多视觉效果中扮演着关键角色。

View Direction节点的基本概念

View Direction节点输出的矢量本质上是从当前处理的顶点或片元位置指向摄像机位置的矢量。这个矢量在不同的渲染计算中有着广泛的应用,特别是在需要基于观察角度变化效果的场景中。

视图方向在计算机图形学中是一个基础概念,它描述了表面点相对于观察者的方向关系。在Shader Graph中,View Direction节点封装了这一计算,让开发者能够轻松获取和使用这一重要数据。

从Unity 11.0版本开始,View Direction节点在URP和HDRP中的行为已经统一,都会对所有坐标空间下的视图方向进行标准化处理。这一变化简化了跨渲染管线的着色器开发,确保了行为的一致性。

节点参数详解

坐标空间选择

View Direction节点提供了一个重要的控件参数——Space下拉选单,允许开发者选择输出视图方向矢量的坐标空间。理解不同坐标空间的特性对于正确使用该节点至关重要。

  • Object空间:在此空间下,视图方向是相对于物体自身坐标系表达的。这意味着无论物体如何旋转、移动或缩放,视图方向都会相对于物体的本地坐标系进行计算。在需要基于物体自身方向的效果时特别有用,如某些类型的卡通渲染或物体特定的光照效果。
  • View空间:也称为摄像机空间,在此空间中,摄像机位于原点,视图方向是相对于摄像机坐标系的。这个空间下的计算通常更高效,因为许多与视图相关的变换已经完成。适用于屏幕空间效果、与摄像机直接相关的特效。
  • World空间:在此空间下,视图方向是基于世界坐标系表达的。这是最直观的空间之一,因为所有场景中的物体都共享同一世界坐标系。适用于需要与世界坐标交互的效果,如全局光照、环境遮挡等。
  • Tangent空间:也称为切线空间,这是一个相对于表面法线的局部坐标系。在此空间下,视图方向是相对于每个顶点或片元的法线方向表达的。特别适用于法线贴图、视差映射等需要基于表面方向的效果。

输出端口

View Direction节点只有一个输出端口,标记为"Out",输出类型为Vector 3。这个三维矢量包含了在当前选择的坐标空间下的视图方向。

输出的矢量始终是标准化的,即其长度为1。这一特性使得开发者可以直接使用该矢量进行点积计算等需要单位矢量的操作,而无需额外的标准化步骤。

在不同渲染管线中的行为差异

理解View Direction节点在不同渲染管线中的历史行为差异对于维护和迁移现有项目非常重要。

在Unity 11.0版本之前,View Direction节点在URP和HDRP中的工作方式存在显著差异:

  • 在URP中,该节点仅在Object空间下输出标准化的视图方向,在其他坐标空间下则保持原始长度
  • 在HDRP中,该节点在所有坐标空间下都会标准化视图方向

这种不一致性可能导致相同的着色器在不同渲染管线中产生不同的视觉效果。从11.0版本开始,Unity统一了这一行为,View Direction节点在所有渲染管线和所有坐标空间下都会输出标准化的视图方向。

对于需要在URP中使用旧行为(在Object空间外使用未标准化视图方向)的开发者,Unity提供了View Vector节点作为替代方案。这个节点保持了旧版本View Direction节点的行为,确保了向后兼容性。

实际应用场景

View Direction节点在着色器开发中有着广泛的应用,以下是一些常见的应用场景:

光照计算

在光照模型中,视图方向是计算高光反射的关键要素。结合表面法线和光照方向,视图方向用于确定观察者看到的高光强度。

  • 在Blinn-Phong光照模型中,使用法线、光照方向和视图方向的半角矢量来计算高光
  • 在基于物理的渲染中,视图方向是双向反射分布函数的重要输入

边缘光效果

视图方向可用于创建边缘光效果,当表面几乎与视图方向平行时增强其亮度。

  • 通过计算表面法线与视图方向的点积,可以确定表面的边缘程度
  • 结合菲涅耳效应,可以创建逼真的边缘发光效果

反射效果

视图方向在反射计算中至关重要,无论是平面反射、环境映射还是屏幕空间反射。

  • 在立方体环境映射中,使用视图方向计算反射矢量
  • 在屏幕空间反射中,视图方向用于确定反射射线的方向

视差映射

在视差映射技术中,视图方向用于模拟表面的深度和凹凸感。

  • 在切线空间中使用视图方向偏移纹理坐标
  • 创建更真实的表面凹凸效果,增强场景的立体感

使用示例与步骤

基础视图方向可视化

创建一个简单的着色器,直接显示视图方向:

  • 在Shader Graph中创建新图
  • 添加View Direction节点,选择World空间
  • 将View Direction节点连接到主节点的Base Color端口
  • 由于视图方向可能包含负值,需要将其映射到0-1范围
  • 可以使用Remap节点或简单的数学运算完成这一映射

这个简单的示例可以帮助开发者直观理解视图方向在不同表面区域的变化。

创建菲涅耳效果

菲涅耳效果模拟了物体表面在掠射角(表面几乎与视图平行)反射率增加的现象:

  • 添加View Direction节点和Normal节点,确保使用相同的坐标空间
  • 使用Dot Product节点计算法线和视图方向的点积
  • 使用One Minus节点反转结果,使掠射角的值接近1
  • 使用Power节点控制效果的衰减程度
  • 将结果与颜色或纹理相乘,连接到发射或基础颜色

实现简单的边缘光

创建一个基础的边缘光效果:

  • 按照菲涅耳效果的步骤计算边缘因子
  • 使用Smoothstep或Color节点控制边缘光的范围和颜色
  • 将结果添加到现有的光照计算中
  • 可以结合深度或屏幕空间信息增强效果的真实性

高级反射效果

创建一个基于视图方向的反射效果:

  • 使用View Direction节点和Normal节点计算反射方向
  • 将反射方向用于采样环境贴图或反射探针
  • 结合粗糙度贴图控制反射的模糊程度
  • 使用菲涅耳效应混合反射颜色和表面颜色

性能考虑与最佳实践

虽然View Direction节点本身计算开销不大,但在大规模使用时应考虑性能影响:

  • 在片元着色器中计算视图方向比在顶点着色器中计算更精确但更昂贵
  • 对于不需要高精度的效果,考虑在顶点着色器中计算视图方向并插值
  • 避免在着色器中重复计算视图方向,尽可能重用计算结果
  • 根据具体需求选择合适的坐标空间,减少不必要的空间转换

在移动平台或性能受限的环境中,应特别关注视图方向计算的开销:

  • 尽可能使用计算量较小的坐标空间
  • 考虑使用近似计算替代精确的视图方向
  • 对于远处或小物体,可以使用简化的视图方向计算

常见问题与解决方案

视图方向显示异常

当视图方向显示不正确时,通常是由于坐标空间不匹配造成的:

  • 确保View Direction节点和与之交互的其他节点使用相同的坐标空间
  • 检查物体的变换矩阵是否包含非常规的缩放或旋转
  • 验证摄像机的设置,特别是正交投影与透视投影的区别

性能问题

如果着色器因视图方向计算导致性能下降:

  • 分析是否真的需要在片元级别计算视图方向
  • 考虑使用更简化的计算模型
  • 检查是否有重复的视图方向计算可以合并

跨平台兼容性

确保视图方向相关效果在不同平台上表现一致:

  • 测试在不同图形API下的表现
  • 验证在移动设备上的精度和性能
  • 考虑为不同平台提供不同的精度或实现

进阶技巧与创意应用

结合时间变化的动态效果

通过将视图方向与时间参数结合,可以创建动态变化的视觉效果:

  • 使用视图方向驱动动画或纹理偏移
  • 创建随着观察角度变化而动态调整的效果
  • 实现类似全息图或科幻界面元素的视觉效果

非真实感渲染

在卡通渲染或其他非真实感渲染风格中,视图方向可以用于:

  • 控制轮廓线的粗细和强度
  • 实现基于角度的色彩简化
  • 创建手绘风格的笔触效果

特殊材质模拟

视图方向在模拟特殊材质时非常有用:

  • 模拟丝绸、缎子等具有角度相关反射的织物
  • 创建各向异性材料如拉丝金属的效果
  • 实现液晶显示屏的角度相关颜色变化

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

❌