阅读视图

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

Vue 转盘抽奖 transform

Vue 转盘抽奖 transform

简介:电商食用级转盘抽奖

讲在前面

在我们日常生活,电子购物已是必不可少的环节了。营销手段更是层出不穷,要数经典的还是转盘抽奖了,紧张又刺激(其实概率还不都是咱们程序猿弄的,刺激个der~)

虽说如此...

但 还是决定自己搞一个试试!

核心 transform

transform 属性向元素应用 2D 或 3D 转换。该属性允许我们对元素进行旋转、缩放、移动或倾斜。

但是既然我们说转盘,当然用到的是旋转啦:rotate

简单示例 顺时针旋转10deg

transform:rotate(10deg);

什么?你已经会这个css属性了? 那恭喜你,你已经能自己独立制作转盘抽奖啦~

核心代码

1. 转盘UI

<template>

    <view class="">

      
            <!-- 转盘包裹 -->
            <view class="rotate">
              <!-- 绘制圆点 -->
              <view :class="'circle circle_' + index" v-for="(item, index) in circleList" :key="index"
                :style="{ background: index % 2 == 0 ? colorCircleFirst : colorCircleSecond }"></view>
              <!-- 转盘图片 -->
              <image class="dish" src="/static/demo/pan.png" :style="{ transform: rotate_deg, transition: rotate_transition }" ></image>
              <!-- 指针图片 -->
              <image class="pointer" src="/static/demo/zhen.png" @click="start" ></image>
              
            
            </view>



      
    </view>

</template>


<style lang="scss" scoped>
.rotate {
  width: 600rpx;
  height: 600rpx;
  background: #ffbe04;
  border-radius: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: 48%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.rotate .dish {
  width: 550rpx;
  height: 550rpx;
}

.pointer {
  width: 142rpx;
  height: 200rpx;
  position: absolute;
  top: 46%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* 圆点 */
.rotate .circle {
  position: absolute;
  display: block;
  border-radius: 50%;
  height: 20rpx;
  width: 20rpx;
  background: black;
}

/*这里只写了一个点的位置,其他的自己补充一下 调调位置就好啦*/
.rotate .circle_0 {
  top: 2rpx;
  left: 284rpx;
}
    
</style>

2.让转盘转动起来

var light_timer; //灯光定时器

data() {
        return {
            circleList: [], //圆点列表
            colorCircleFirst: "#FF0000", //圆点闪烁颜色一
            colorCircleSecond: "#fff", //圆点闪烁颜色二
            cat: 45, //总共8个扇形区域,每个区域45度,这就取决去奖品池的UI图了
            isAllowClick: true, //是否能够点击
            rotate_deg: 0, //指针旋转的角度
            rotate_transition: "transform 3s ease-in-out" //过渡属性,渐入渐出

        };
    },
        
onLoad() {
    this.showcircleList();
},
    
    methods: {
        // 设置边缘一圈16个圆点,可以根据需要修改
        showcircleList() {
            let circleList = [];
            for (var i = 0; i < 16; i++) {
                circleList.push(i);
            }
            this.circleList = circleList;
            this.light();   
        },

        //设置边缘灯光闪动效果
        light: function() {
            var that = this;
            clearInterval(light_timer);
            light_timer = setInterval(function() {
                if (that.colorCircleFirst == "#FF0000") {
                    that.colorCircleFirst = "#fff";
                    that.colorCircleSecond = "#FF0000";
                } else {
                    that.colorCircleFirst = "#FF0000";
                    that.colorCircleSecond = "#fff";
                }
            }, 300); //设置圆点闪烁的间隔时间效果
        },
        //点击开始抽奖
        start() {
            this.rotating();
        },
        //旋转
        rotating() {
            if (!this.isAllowClick) return;
            this.isAllowClick = false;
            this.rotate_transition = "transform 3s ease-in-out";
            this.LuckyClick--;
            var rand_circle = 5; //默认多旋转5圈
            var winningIndex = this.set(); //设置概率
            console.log(winningIndex);
            var randomDeg = 360 - winningIndex * 45; //8个区域。一圈是360度,对应区域旋转度数就是顺时针的 360 - winningIndex*45°
            var deg = rand_circle * 360 + randomDeg; //把本来定义多转的圈数度数也加上
            this.rotate_deg = "rotate(" + deg + "deg)";

            var that = this;
            setTimeout(function() {
                that.isAllowClick = true;
                that.rotate_deg = "rotate(" + randomDeg + "deg)"; //定时器关闭的时候角度调回五圈之前相同位置,依照产品需求可以自己更改
            that.rotate_transition = "";

                if (winningIndex == 0) {
                    console.log("恭喜您,IphoneX");
                } else if (winningIndex == 1) {
                    console.log("恭喜您,获得10元现金");
                } else if (winningIndex == 2) {
                    console.log("很遗憾,重在参与");
                    uni.showToast({
                        title:"很遗憾,重在参与",
                        icon:"none"
                    })
                } else if (winningIndex == 3) {
                    console.log("恭喜您,获得30元现金");
                } else if (winningIndex == 4) {
                    console.log("恭喜您,获得20元现金");
                } else if (winningIndex == 5) {
                    console.log("恭喜您,获得50元现金");
                } else if (winningIndex == 6) {
                    console.log("恭喜您,获得5元现金");
                } else if (winningIndex == 7) {
                    console.log("恭喜您,获得100元现金");
                }
            }, 3500);
        },

        //设置概率
        set() {
            var winIndex;
            var __rand__ = Math.random();
            // 随机数 设置抽奖概率 winIndex 记得参考奖品池的UI图
            if (__rand__ < 0.30) winIndex = 2;
            else if (__rand__ < 0.55) winIndex = 6;
            else if (__rand__ < 0.75) winIndex = 1;
            else if (__rand__ < 0.85) winIndex = 4;
            else if (__rand__ < 0.92) winIndex = 3;
            else if (__rand__ < 0.97) winIndex = 5;
            else if (__rand__ < 0.99) winIndex = 7;
            else if (__rand__ == 0.99) winIndex = 0;
            return winIndex;
        },



}

最终效果展示

zhuanpan.png

总结

其实就是利用背景图进行旋转,设置好旋转角度!如果有兴趣的话就快速行动吧,冲冲冲!!!

vue中hash模式和history模式的区别

一句话总结

  • Hash 模式:利用 URL 中 # 后的内容变化实现前端路由,不触发页面刷新,兼容性好。
  • History 模式:基于 HTML5 的 history.pushState()popstate 事件,URL 更干净,但需要服务端配合。

一、Hash 模式(默认模式)

1. 基本形式

https://example.com/#/user/profilehash 部分

2. 工作原理

  • 核心机制:监听 window.onhashchange 事件。
  • 当用户点击链接或调用 router.push() 时,Vue Router 修改 location.hash(如 #/home#/about)。
  • 浏览器不会向服务器发起请求,因为 # 及其后的内容不会发送给服务器
  • 页面 URL 改变但不刷新,前端根据新的 hash 值匹配路由并渲染对应组件。

3. 特点

优点 缺点
✅ 兼容性极好(IE8+ 支持) ❌ URL 中带有 #,不够美观
✅ 无需服务端配置 ❌ SEO 友好性略差(部分爬虫可能忽略 hash)
✅ 天然避免 404 问题 ❌ 不符合传统 URL 语义

4. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'hash', // 默认值,可省略
  routes: [...]
});

二、History 模式(推荐用于现代项目)

1. 基本形式

https://example.com/user/profile

URL 看起来和传统多页应用一致,无 # 符号。

2. 工作原理

  • 核心技术
    • history.pushState(state, title, url):在不刷新页面的情况下修改浏览器历史记录和 URL。
    • history.replaceState(...):替换当前历史记录。
    • window.onpopstate:监听浏览器前进/后退操作(如点击 ← → 按钮)。
  • 流程示例
    1. 用户访问 /home → 前端加载,Vue Router 渲染 Home 组件。
    2. 点击“关于”链接 → 调用 router.push('/about') → 执行 history.pushState(null, '', '/about')
    3. URL 变为 https://example.com/about,页面不刷新,About 组件被渲染。
    4. 用户刷新页面 → 浏览器向服务器请求 /about 资源。

3. 关键问题:刷新 404

  • 原因:服务器收到 /about 请求时,若未配置,会尝试查找物理路径下的 about.html 或目录,找不到则返回 404。
  • 解决方案服务端需配置“兜底路由” ,将所有前端路由请求重定向到 index.html

Nginx 配置示例:

location / {
  try_files $uri $uri/ /index.html;
}

4. 特点

优点 缺点
✅ URL 简洁美观,符合 REST 风格 ❌ 需要服务端支持(部署配置)
✅ 更好的 SEO(主流爬虫已支持) ❌ 在纯静态托管(如 GitHub Pages)中需额外处理
✅ 用户体验更接近原生 Web ❌ 旧浏览器(IE9 以下)不支持

5. 示例代码(Vue Router 配置)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
});

🔁 三、对比总结

特性 Hash 模式 History 模式
URL 样式 example.com/#/path example.com/path
刷新是否 404 ❌ 不会(# 后内容不发给服务器) ✅ 会(需服务端配置兜底)
浏览器兼容性 IE8+ IE10+(HTML5 History API)
服务端要求 必须配置 fallback 到 index.html
SEO 友好性 一般 较好(现代爬虫支持)
使用场景 快速原型、老旧环境、无服务端控制权 正式项目、追求用户体验、有运维支持

💡 最佳实践建议

  • 开发阶段:两种模式均可,推荐 history 提前暴露部署问题。
  • 生产部署
    • 若使用 Nginx/Apache/Caddy → 优先选 history + 配置 fallback。
  • 无法控制服务端? → 用 hash 模式最稳妥。

补充知识

  • 为什么 hash 不发给服务器?
    根据 HTTP 规范,URL 中 #fragment 部分仅用于客户端定位(如锚点),不会包含在 HTTP 请求中
  • History API 安全限制
    pushState 只能修改同源下的路径,不能跨域篡改 URL,保障了安全性。

顶层元素问题:popover vs. dialog

原文:Top layer troubles: popover vs. dialog 作者:Stephanie Eckles 日期:2025年12月1日 翻译:田八

来源:前端周刊

你是否曾尝试通过设置 z-index: 9999 解决元素层级问题?如果是,那你其实是在与一个基础的CSS概念 ——层叠上下文—— 斗争。

层叠上下文定义了元素在第三维度(即“z轴”)上的排列顺序。你可以把z轴想象成视口中层叠上下文根节点与用户(即通过浏览器视口观察的你)之间的DOM元素的层级。

image.png

一个元素只能在同一层叠上下文中重新调整层级。虽然 z-index 是实现这一点的工具,但失败往往源于层叠上下文的变化。这种变化可能通过多种方式发生,例如使用固定定位(fixed)、粘性定位(sticky)元素,或是将绝对定位(absolute)/相对定位(relative)与 z-index 结合使用等,MDN 上列出了更多原因

现代网页设计有一个“顶层”特性,它保证使其位于所有其他层叠上下文的最顶层。它覆盖整个视口,不过顶层中的元素实际可见尺寸可能更小。

将元素提升到顶层,可使其摆脱原本所在的任何层叠上下文。

虽然顶层直接解决了一个与CSS相关的问题,但目前还没有属性可用于将元素提升到顶层。取而代之的是,某些元素和特定条件可以访问顶层,例如通过 <div> 标签显示的原生对话框 showModal() 和被指定为 Popover 的元素。

Popover API是一项新推出的 HTML 功能,它允许你声明式的创建非模态覆盖元素。通过使用 Popover API 用来摆脱任何层叠上下文,这是它的一个理想特性。然而,在急于选择这种原生能力之前,需要注意一个潜在的问题。

场景设定

想象一下,在2025年的网络世界:你的网页应用包含一个通过“Toast”消息显示通知的服务。你知道的,就是那些通常出现在角落或其他不太可能遮挡其他用户界面(UI)位置的弹出消息。

通常,这些Toast通知通常用于实时提醒,比如保存成功,或者表单提交失败等错误提示。它们有时有时间限制,或者包含如关闭按钮这样的关闭机制。有时它们还包含额外操作,例如“重试”选项,用于重新提交失败的工作流。

既然您的应用紧跟时代潮流,你最近决定将Toast升级为使用Popover API。这样你就可以将Toast组件放置在应用的任何结构中,而无需为了解决层叠上下文问题而采用一些变通方法。毕竟,Toast必须显示在所有其他元素之上,因此通过 Popover 实现顶层访问是明智之举!

你发布了改进版本,并为自己的工作感到自豪。

发布的当周晚些时候,你收到了一份紧急错误报告。不是普通的错误报告,而是一个可访问性违规报告。

Dialog vs. popover

你的应用很新潮,你之前也升级使用了原生HTML对话框。那是一次很棒的升级,因为你用原生 Web 功能取代了对 JavaScript 的依赖。这也是你兴奋地将Toast也升级为使用Popover的另一个原因。

那么,错误是什么呢?一位键盘用户正在使用一个包含对话框的工作流程,对话框打开期间,后台进程触发了一个弹出式通知。该通知提示存在错误,需要用户进行交互。

当这位键盘用户试图将焦点切换到Toast上时,出现了错误。他们虽然在视觉上能看到Toast显示在对话框背景之上,但焦点无法成功进入Toast,而是意外地跳到了浏览器UI上。

你可以在这个CodePen示例中亲自体验这个错误,使用Tab键,你会发现你永远无法访问到Toast。你也可以尝试使用屏幕阅读器,会发现虚拟光标也无法进入Toast。

CodePen

如果你能够点击弹出框,可能会觉得至少点击操作是可行的。但很快我们就会发现,事情并非如此。

为什么Toast弹出框无法访问

虽然顶层可以超越标准的层叠上下文,但顶层中的元素仍然受分层顺序的影响。最近添加到顶层的元素会显示在之前添加的顶层元素之上。这就是为什么Toast在视觉上会显示在对话框背景之上。

如果弹出框在视觉上可用,那为什么通过键盘或屏幕阅读器的虚拟光标却无法访问呢?

原因在于弹出框与 模态 对话框之间存在竞争关系。当通过 showModal()方法启动原生HTML对话框时,对话框外部的页面会变为 惰性状态惰性状态 是一种必要的可访问性行为,它会隔离对话框内容,并阻止通过Tab键和虚拟光标访问背景页面。

这个错误是由于Toast弹出框是背景页面DOM的一部分。这意味着由于它位于对话框DOM边界之外,所以也变成了惰性状态。

但是,由于顶层顺序的原因,因为它是在对话框打开后创建的,所以在视觉上,它被放置在对话框的顶部,这一点可能会让你感到困惑。

如果你以为点击弹出框就能关闭它,实际上并非如此,尽管弹出框确实会消失。真正发生的情况是,你触发了弹出框的 轻触关闭 行为。这意味着它关闭是因为你实际上点击了它的边界之外,因为对话框捕获了点击操作。

所以,虽然弹出框被关闭了,但“重试”按钮实际上并没有被点击,这意味着任何关联的事件监听器都不会被触发。

即使你创建了一个自动化测试来专门检查当对话框打开时Toast的提醒功能,该自动化测试仍可能出现误报,因为它触发了对Toast按钮的编程式点击。这种伪点击错误地绕过了由于对话框导致页面变为惰性状态所引发的问题。

重新获得弹出框访问权限

解决方案有两个方面:

  1. 将弹出框(popover)在DOM中实际放置在对话框(dialog)内部。
  2. 确保使用 popover="manual",以防止对话框内的点击操作过早触发弹出框的轻触关闭。

完成这两步后,弹出框现在既在视觉上可用,又可以通过任何方式完全交互。

Codepan

经验教训与额外考虑

我们了解到,如果你的网站或应用有可能同时显示弹出框和对话框,并且它们有独立的时间线,那么你需要想出一种在对话框内启动弹出框的机制。

或者,您可以选择在对话框关闭之前禁用后台页面弹出窗口。但如果通知需要及时交互,或者对话框内容有可能触发 Toast 提示,则此方法可能并不理想。

除了可见性和交互性之外,您可能还需要考虑另一个问题:弹出窗口是否需要在对话框关闭后继续保持打开状态。也就是说,即使对话框关闭,弹出窗口也需要保持打开状态,例如继续等待用户执行操作。

虽然我非常支持使用原生平台功能,而且我认为弹出框(popover)尤其出色,但有时冲突是无法完全避免的。事实上,您可能已经遇到过类似的问题,即模态对话框的惰性行为。因此,本文的主要目的是提醒您,如果同时显示背景弹出框和模态对话框,可能会出现问题,因此不要完全放弃之前自定义的弹出框架构。

如果这个问题目前或将来会影响到你的工作,请关注这个HTML问题,其中正在讨论解决方案。

关于斯蒂芬妮·埃克尔斯

Stephanie Eckles 是 Adobe Spectrum CSS 的高级设计工程师,也是 CSSWG 的成员,同时还是 ModernCSS.dev 的作者。Steph 拥有超过 15 年的 Web 开发经验,她乐于以作家、研讨会讲师和会议演讲者的身份分享这些经验。她致力于倡导无障碍设计、可扩展 CSS 和 Web 标准。业余时间,她是两个女儿的妈妈,喜欢烘焙和水彩画。

博客:ModernCSS.dev Mastodon:@5t3ph

译者注:

  1. popover:弹出框指的是轻提示的弹出式框,没有过多的交互逻辑
  2. dialog:对话框指的是带有交互逻辑的弹出框,例如存在确认和取消按钮,输入框等

这两个都是新特性,具体内容可参考MDN

React 的新时代已经到来:你需要知道的一切

原文: The next era of React has arrived: Here's what you need to know

翻译: 嘿嘿

来源:前端周刊

构建异步 UI 向来都是一件非常困难的事情。导航操作将内容隐藏在加载指示器之后,搜索框在响应无序到达时会产生竞态条件,表单提交则需要手动管理每一个加载状态标志和错误信息。每个异步操作都迫使你手动进行协调。

image.png

这不是一个性能问题,而是一个协调问题。现在,React 的原语声明式地解决了它。

对于开发团队而言,这标志着我们构建方式的一次根本性转变。React 不再需要每位开发者在每个组件中重新发明异步处理逻辑,而是提供了标准化的原语来自动处理协调。这意味着更少的 Bug、更一致的用户体验,以及更少的调试竞态条件的时间。

React 的异步协调原语

image.png

在 React Conf 2025 上,来自 React 团队的 Ricky Hanlon 演示的 Async React 示例,展示了未来的可能性:一个包含搜索、标签页和状态变更的课程浏览应用,在快速网络下感觉即时,在慢速网络下也能保持流畅。UI 更新自动协调,不会闪烁。

这不是一个新库,而是 React 19 的协调 API 与 React 18 的并发特性 的结合。它们共同构成了 React 团队称之为  “异步 React(Async React)”  的完整系统,通过可组合的原语来构建响应式的异步应用程序:

  • useTransition:跟踪待处理的异步工作。
  • useOptimistic:在状态变更期间提供即时反馈(乐观更新)。
  • Suspense:声明式地处理加载边界。
  • useDeferredValue:在快速更新期间保持稳定的用户体验。
  • use() :使数据获取(和上下文读取)变得声明式。

理解这些部分如何协同工作是关键,它使我们能从命令式的异步代码转向声明式的协调。

问题:手动的异步协调

在这些原语出现之前,开发者必须手动编排每一个异步操作。表单提交需要显式的加载和错误状态:

function SubmitButton() {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);

    async function handleSubmit() {
        setIsLoading(true);
        setError(null);
        try {
            await submitToServer();
            setIsLoading(false);
        } catch (e) {
            setError(e.message);
            setIsLoading(false);
        }
    }

    return (
        <div>
            <button onClick={handleSubmit} disabled={isLoading}>
                {isLoading ? '提交中...' : '提交'}
            </button>
            {error && <div>错误:{error}</div>}
        </div>
    );
}

数据获取也遵循类似的命令式模式,使用 useEffect

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        setIsLoading(true);
        setError(null);
        fetchUser(userId)
            .then(data => {
                setUser(data);
                setIsLoading(false);
            })
            .catch(e => {
                setError(e.message);
                setIsLoading(false);
            });
    }, [userId]);

    if (isLoading) return <div>加载中...</div>;
    if (error) return <div>错误:{error}</div>;

    return <div>{user.name}</div>;
}

每个异步操作都重复这个模式:跟踪加载状态、处理错误、协调状态更新。当这种模式扩展到几十个组件时,就会导致不一致的加载状态、被遗忘的错误处理,以及难以调试的微妙竞态条件。

原语详解

Actions 自动跟踪异步工作

React 19 引入了 Actions 来声明式地处理异步协调。将一个异步函数包装在 startTransition 中,可以让 React 跟踪整个操作:

const [isPending, startTransition] = useTransition();

function submitAction() {
    startTransition(async () => {
        await submitToServer();
    });
}

isPending 标志在 Promise 解决之前一直为 true。React 会自动处理此状态,并且在 Transition 中抛出的错误会冒泡到错误边界(Error Boundary),而不是在分散的 try/catch 块中处理(你仍需自己处理预期的错误,如验证失败)。

React 将在 Transition 中调用的任何函数被称为 “Action”。命名约定很重要:为函数添加 “Action” 后缀表示它们运行在 Transition 中(例如,submitActiondeleteAction)。

以下是使用 Actions 重写的相同按钮:

function SubmitButton() {
    const [isPending, startTransition] = useTransition();

    function submitAction() {
        startTransition(async () => {
            await submitToServer();
        });
    }

    return (
        <button onClick={submitAction} disabled={isPending}>
            {isPending ? '提交中...' : '提交'}
        </button>
    );
}

另一种选择是使用 React 19 的 <form> 组件,它可以通过接受一个 action 属性并将其自动包装在 Transition 中来为你处理:

async function submitAction(formData) {
    await submitToServer(formData);
}

<form action={submitAction}>
    <input name='username' />
    <button>提交</button>
</form>;

与手动 Action 一样,错误仍会冒泡到错误边界。当你希望在 UI 中反映表单状态时,React 19 提供了表单实用程序:useFormStatus 让子组件可以访问表单的待处理状态,而 useActionState 则允许你根据 Action 的结果更新组件状态(例如显示验证错误或“点赞”计数)。

相同的模式也适用于按钮、输入框和标签页等可复用组件。你的设计组件可以暴露 actionsubmitAction 或 changeAction 等 Action 属性,并在内部使用 Transitions 来管理待处理状态和其他异步行为。我们稍后将回到这个模式。

乐观更新提供即时反馈

Actions 提供了待处理状态,但“待处理”并不总是正确的反馈。当你点击复选框来标记任务完成时,它应该立即切换。等待服务器的响应很可能会破坏流程导致竟态问题。

useOptimistic() 在 Transitions 内部工作,用于在异步 Action 在后台运行时显示即时更新:

function CompleteButton({ complete }) {
    const [optimisticComplete, setOptimisticComplete] = useOptimistic(complete);
    const [isPending, startTransition] = useTransition();

    function completeAction() {
        startTransition(async () => {
            setOptimisticComplete(!optimisticComplete);
            await updateCompletion(!optimisticComplete);
        });
    }

    return (
        <button onClick={completeAction} className={isPending ? 'opacity-50' : ''}>
            {optimisticComplete ? <CheckIcon /> : <div></div>}
        </button>
    );
}

复选框会立即切换。如果请求成功,服务器状态将与乐观更新匹配。如果失败,服务器状态保持旧值,因此复选框会自动恢复其原始状态。

与 useState(它会延迟 Transition 内部的更新)不同,useOptimistic 会立即更新。Transition 边界定义了乐观状态的生命周期:它仅在异步 Action 处于待处理状态时持续存在,一旦 Transition 完成,就会自动“落定”到事实来源(props 或服务器状态)。(注:简单说就是当 transition 为 pending 时 optimisticComplete 为 startTransition 中设定的值,而一旦 transition 完成即 pending 为 false 时,optimisticComplete 会放弃 startTransition 的状态而使用传入的值及为例子中的 complete)

Suspense 声明式地协调加载状态

乐观更新处理了状态变更,但初始数据加载呢?useEffect 模式迫使我们手动管理 isLoading 状态。Suspense 通过允许我们声明式地定义加载边界来解决这个问题。我们需要控制显示什么后备 UI 以及如何分割加载,因此应用的独立部分可以并行加载。

Suspense 与“支持 Suspense”的数据源协同工作:异步服务器组件、使用 use() API 读取的 Promise(我们接下来会介绍),以及像 TanStack Query 这样的库(它提供了用于缓存和去重的 useSuspenseQuery)。

以下是 Suspense 如何协调多个独立数据流:

function App() {
    return (
        <div>
            <h1>仪表板</h1>
            <Suspense fallback={<ProfileSkeleton />}>
                <UserProfile />
            </Suspense>
            <Suspense fallback={<PostsSkeleton />}>
                <UserPosts />
            </Suspense>
        </div>
    );
}

每个组件都可以通过自己的后备方案独立挂起。父组件通过 Suspense 边界处理加载状态,而不是协调多个 useEffect 调用。但有个问题:当你触发导致组件重新获取数据的更新时(如切换标签页或导航),加载后备方案会再次显示,隐藏你已经看到的内容,并产生突兀的加载状态。

结合 Transition 与 Suspense

将 Transition 与 Suspense 结合可以解决这个问题,它告诉 React 保持现有内容可见,而不是立即再次显示后备方案。以下是一个针对标签页切换的适配示例:

function App() {
    const [tab, setTab] = useState('profile');
    const [isPending, startTransition] = useTransition();

    function handleTabChange(newTab) {
        startTransition(() => setTab(newTab));
    }

    return (
        <div>
            <nav>
                <button onClick={() => handleTabChange('profile')}>个人资料</button>
                <button onClick={() => handleTabChange('posts')}>帖子</button>
            </nav>
            <Suspense fallback={<LoadingSkeleton />}>
                <div style={{ opacity: isPending ? 0.7 : 1 }}>{tab === 'profile' ? <UserProfile /> : <UserPosts />}</div>
            </Suspense>
        </div>
    );
}

现在,加载后备方案仅在初始加载时显示。当你切换标签页时,Transition 会在新数据在后台加载时保持当前内容可见。不透明度样式使其变暗,以表示更新正在进行。一旦就绪,React 会自动无缝地换入新内容。没有突兀的加载状态,没有卡顿。

关键在于:Transitions 会“暂缓”UI 更新,直到异步工作完成,从而防止 Suspense 边界在导航期间回退到后备状态。像 Next.js 这样的框架使用此功能在新路由加载时保持页面可见。

use() 直接读取异步数据

早些时候,我们看到了 Suspense 如何与“支持 Suspense”的数据源协同工作。use() API 就是这样的数据源之一:它为数据获取提供了 useEffect 的替代方案,允许你在渲染期间读取 Promise。

以下是用 Suspense 和 use() 重写的最初的 useEffect 示例:

function UserProfile({ userId }) {
    const user = use(fetchUser(userId));
    return <div>{user.name}</div>;
}

function App({ userId }) {
    return (
        <ErrorBoundary fallback={<div>加载用户时出错</div>}>
            <Suspense fallback={<div>加载中...</div>}>
                <UserProfile userId={userId} />
            </Suspense>
        </ErrorBoundary>
    );
}

组件在读取 Promise 时挂起,触发最近的 Suspense 边界,然后在 Promise 解决时带着数据重新渲染。错误被错误边界捕获。与 Hooks 不同,use() 可以条件调用。

一个注意事项:Promise 需要被缓存。否则,每次渲染都会重新创建它。在实践中,你可以使用像 Next.js 这样处理缓存和去重的框架。

延迟值防止 UI 过载

Actions 和 Suspense 处理离散的操作:点击、提交、导航。但快速输入(如搜索)需要不同的方法,因为你希望输入框即使在结果加载时也能保持响应。

一种方法可以是设计一个 SearchInput 组件,通过内部乐观状态保持输入响应,并在 Transition 中调用 changeAction,这样父组件只需传递 value 和 changeAction

当你没有设计组件时,useDeferredValue() 提供了类似的拆分效果。虽然你可以用它来延迟昂贵的 CPU 计算(性能),但此处的目标是稳定的用户体验。

结合 Suspense、use() 和ErrorBoundary,我们可以获得完整的搜索体验:

function SearchApp() {
    const [query, setQuery] = useState('');
    const deferredQuery = useDeferredValue(query);
    const isStale = query !== deferredQuery;

    return (
        <div>
            <input value={query} onChange={e => setQuery(e.target.value)} />
            <ErrorBoundary fallback={<div>加载结果时出错</div>}>
                <Suspense fallback={<div>搜索中...</div>}>
                    <div style={{ opacity: isStale ? 0.5 : 1 }}>
                        <SearchResults query={deferredQuery} />
                    </div>
                </Suspense>
            </ErrorBoundary>
        </div>
    );
}

function SearchResults({ query }) {
    if (!query) return <div>开始输入以搜索</div>;
    const results = use(fetchSearchResults(query));
    return (
        <div>
            {results.map(r => (
                <div key={r.id}>{r.name}</div>
            ))}
        </div>
    );
}

Suspense 后备方案仅在初始加载时显示。在后续搜索期间,useDeferredValue 会在新结果于后台加载时保持旧结果可见(通过 isStale 降低不透明度)。错误边界隔离了失败,即使数据请求失败,搜索输入也能保持功能正常。

综合应用:Async React 示例

到目前为止,我们分别了解了每个原语。Async React 示例 展示了当一个框架将它们整合到路由、数据获取和设计系统中时会发生什么:

gif of async react demo

尝试切换网络速度以查看 UI 如何适应:在快速连接下即时,在慢速连接下流畅。

路由器将导航包装在 Transitions 中:

function searchAction(value) {
    router.setParams('q', value);
}

更新搜索参数是异步的,会更改 URL 并触发数据重新获取,同时 Transition 会跟踪这一切。

数据层将 use() 与缓存的 Promise 结合使用:

function LessonList({ tab, search, completeAction }) {
    const lessons = use(data.getLessons(tab, search));
    return (
        <Design.List>
            {lessons.map(item => (
                <Lesson item={item} completeAction={completeAction} />
            ))}
        </Design.List>
    );
}

当数据加载时,组件会挂起,Suspense 在初始加载时显示后备方案,但在切换标签页和搜索期间,Transitions 会保持旧内容可见。

Design 组件暴露 Action 属性:

<Design.SearchInput value={search} changeAction={searchAction} />

SearchInput 在内部使用 useOptimistic,以便在新的 URL 的 Transition 处于待处理状态时立即更新输入值。TabList 同样乐观地更新选中的标签页。

命名约定(“changeAction”)表示传递的函数将在 Transition 中运行。

状态变更以相同方式工作:

async function completeAction(id) {
    await data.mutateToggle(id);
    router.refresh();
}

这个 completeAction 通过 LessonList 传递给 Design.CompleteButton,该按钮也暴露了一个 action 属性。该按钮在 Action 运行时乐观地更新完成状态。


这是一个简化版的课程应用示例:

export default function Home() {
    const router = useRouter();
    const search = router.search.q || '';
    const tab = router.search.tab || 'all';

    function searchAction(value) {
        router.setParams('q', value);
    }

    function tabAction(value) {
        router.setParams('tab', value);
    }

    async function completeAction(id) {
        await data.mutateToggle(id);
        router.refresh();
    }

    return (
        <>
            <Design.SearchInput value={search} changeAction={searchAction} />
            <Design.TabList activeTab={tab} changeAction={tabAction}>
                <Suspense fallback={<Design.FallbackList />}>
                    <LessonList tab={tab} search={search} completeAction={completeAction} />
                </Suspense>
            </Design.TabList>
        </>
    );
}

协调发生在每个层面:

  • 路由:导航被包装在 Transitions 中。
  • 数据获取:数据层使用 Suspense 和缓存的 Promise。
  • 设计组件:组件暴露“Action”属性以在内部处理乐观更新。

在快速网络上,更新是即时的。在慢速网络上,乐观 UI 和 Transitions 在没有手动逻辑的情况下保持响应性。原语的复杂性由路由器、数据获取设置和设计系统处理。应用代码只需将它们连接起来。


构建自定义异步组件

大多数应用可能会使用已经实现了这些模式的库中的组件。但你也可以自己实现它们来构建自定义异步组件。

这是一个针对 Next.js 的实用示例:一个与 URL 参数同步的可复用选择组件。

这对于过滤器、排序或任何你希望持久化在 URL 中的 UI 状态很有用:

import { useRouter, useSearchParams } from 'next/navigation';

export function RouterSelect({ name, value, options, selectAction }) {
    const [optimisticValue, setOptimisticValue] = useOptimistic(value);
    const [isPending, startTransition] = useTransition();
    const router = useRouter();
    const searchParams = useSearchParams();

    function changeAction(e) {
        const newValue = e.target.value;
        startTransition(async () => {
            setOptimisticValue(newValue);
            await selectAction?.(newValue);

            const params = new URLSearchParams(searchParams);
            params.set(name, newValue);
            router.push(`?${params.toString()}`);
        });
    }

    return (
        <select name={name} value={optimisticValue} onChange={changeAction} style={{ opacity: isPending ? 0.7 : 1 }}>
            {options.map(opt => (
                <option key={opt.value} value={opt.value}>
                    {opt.label}
                </option>
            ))}
        </select>
    );
}

该组件在内部处理协调。父组件可以通过 selectAction 注入副作用:

function Filters() {
    const [progress, setProgress] = useState(0);
    const [optimisticProgress, incrementProgress] = useOptimistic(progress, (prev, increment) => prev + increment);

    return (
        <>
            <LoadingBar progress={optimisticProgress} />
            <RouterSelect
                name='category'
                selected={selectedCategory}
                options={categoryOptions}
                selectAction={items => {
                    incrementProgress(30);
                    setProgress(100);
                }}
            />
        </>
    );
}

在这个例子中,进度条的乐观更新和路由器导航被协调在一起。传递给 selectAction 的任何内容都受益于相同的异步协调。命名约定(“Action”)表示它在 Transition 中运行,并且我们可以在内部调用乐观更新。

这就是 Async React 示例中设计组件使用的模式。SearchInputTabList 和 CompleteButton 都暴露了 Action 属性,在内部处理 Transitions、乐观更新和待处理状态。

使用 ViewTransition(Canary)实现平滑动画

原语解决了更新 何时 发生的问题,而 ViewTransition 则解决了它们 看起来如何 的问题。它包装了浏览器的 View Transition API,并专门在 React Transition(由 useTransitionuseDeferredValue 或 Suspense 触发)内部更新组件时激活。

默认情况下,它在状态之间进行交叉淡入淡出,你也可以使用 CSS 自定义动画。

以下是 Async React 示例如何使用它为课程列表添加动画:

return (
    <ViewTransition key='results' default='none' enter='auto' exit='auto'>
        <Design.List>
            {lessons.map(item => (
                <ViewTransition key={item.id}>
                    <Lesson item={item} completeAction={completeAction} />
                </ViewTransition>
            ))}
        </Design.List>
    </ViewTransition>
);

外层的 ViewTransition 在 Suspense 解析或在状态之间切换时(如显示“无结果”)为整个列表添加动画。每个项目上的内层 ViewTransition 为单个课程添加动画:搜索时,现有项目滑动到新位置,而新项目淡入,移除的项目淡出。

注意:  ViewTransition 目前仅在 React 的 canary 版本中可用。

实际权衡

采用这些模式通常比它们所替代掉的手动逻辑更简单。你并没有增加复杂性;而是将协调工作丢给了 React。话虽如此,以 Transitions、乐观更新和 Suspense 边界的方式思考确实需要思维转变。

何时适用

这些原语在具有丰富交互性的应用中表现出色:仪表板、管理面板和搜索界面。它们消除了整类的 Bug。竞态条件消失了。导航感觉无缝。你可以用更少的样板代码获得“原生应用”的感觉。

不要修复未损坏的东西

如果 useState 和 useEffect 对你来说工作可靠,就没有必要拆除它们。如果你没有在处理竞态条件、突兀的加载状态或输入延迟,你就不需要解决不存在的问题。

迁移路径

你可以选择渐进式的采用。下次构建具有复杂异步状态的功能时,可以尝试用 Transition 代替另一个 isLoading 标识。在即时反馈重要的地方添加乐观 UI。这些工具与现有代码共存,因此你可以逐个功能地采用它们。

结论:向声明式异步的转变

异步 React(Async React)是并发渲染和协调原语的结合,形成了一个用于处理异步工作的完整系统,而这在过去需要手动编排。

随着这些原语在整个生态系统中被采用,这种转变变得切实可行。在 React Conf 2025 上宣布的 Async React 工作组 正在积极致力于在路由器、数据获取库和设计组件中标准化这些模式。

我们已经看到它的实际应用:

  • 路由器(如 Next.js)默认将导航包装在 Transitions 中。
  • 数据库(如 TanStack Query 和 SWR)深度集成了对 Suspense 的支持。
  • 设计系统预计将跟进,暴露 Action 属性以在内部处理待处理状态和乐观更新。

最终,这将异步处理的复杂性从应用代码转移到了框架。你描述 应该发生什么(Action、状态变更、导航),而 React 协调 它如何发生(待处理状态、乐观更新、加载边界)。React 的下一个时代不仅是关于新功能;更是关于让无缝的异步协调成为应用功能的默认方式。

TypeScript 严格性是非单调的:strict-null-checks 和 no-implicit-any 的相互影响

原文: TypeScript strictness is non-monotonic: strict-null-checks and no-implicit-any interact

翻译: 嘿嘿

来源:前端周刊

TypeScript 编译器选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:仅启用 strictNullChecks 会导致类型错误,而在同时启用 noImplicitAny 后这些错误却消失了。这意味着更严格的设置反而导致更少的错误!

这虽然是一个影响不大的奇闻异事,但我在实际工作中确实遇到了它,当时我正在将一些模块更新为更严格的设置。

背景

TypeScript 是驯服 JavaScript 代码库的强大工具,但要获得最大的保障,需要在“严格”模式下使用它。

在现有的 JavaScript 代码库中采用 TypeScript 可以逐步完成:逐个打开每个严格的子设置,并逐一处理出现的错误。这种渐进式方法使得采用变得可行:不要在一次大爆炸中修复整个世界,而是进行多次较小的更改,直到最终世界被修复。

在工作中,我们最近一直在以这种方式逐步提高代码的严格性,然后我遇到了这种相互作用。

示例

下面这段代码中,array 的类型是什么?

const array = [];
array.push(123);

作为一个独立的代码片段,它看起来奇怪且毫无意义(“为什么不直接用 const array = [123];?”),但它是真实代码的最小化版本。

const featureFlags = [];

if (enableRocket()) {
  featureFlags.push("rocket");
}
if (enableParachute()) {
  featureFlags.push("parachute");
}

prepareForLandSpeedRecord(featureFlags);

这里没有显式的类型注解,所以 TypeScript 需要推断它。这种推断有点巧妙,因为它需要“时间旅行”(指需要运行后续语句后回头去修改推断的类型,类似正则回溯):const array = [] 这个声明并没有说明数组中可能包含什么,这个信息只来自代码后面出现的 push

考虑到所有这些,推断出的确切类型依赖于两个 TypeScript 语言选项也就不足为奇了:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
never[]
最严格 number[]

选项说明

这里影响推断类型的两个选项是:

  • strictNullChecks:正确强制处理可选/可为空的值。例如,启用后,一个可为空的字符串变量(类型为 string | null)不能直接用在期望普通 string 值的地方。
  • noImplicitAny:避免在一些模棱两可的情况下推断出“全能”的 any 类型。

最好同时启用它们:strictNullChecks 解决了“十亿美元的错误”,而 noImplicitAny 减少了感染代码库的容易出错的 any 的数量。

问题所在

我们上表中第三种配置,即启用 strictNullChecks 但禁用 noImplicitAny 时,推断出 array: never[]。因此,代码片段无效并被报错(在线示例):

array.push(123);
//         ^^^ 错误:类型“123”的参数不能赋给类型“never”的参数。

没有任何东西(既不是字面量 123,也不是任何其他 number,也不是任何其他东西)是 never 的“子类型”,所以,是的,这段代码无效是合理的。

奇怪之处

“启用一些更严格的要求,然后得到一个错误”并不令人惊讶,也不值得注意……但让我们再仔细看看表格:

strictNullChecks noImplicitAny 推断类型
最不严格 any[]
number[]
报错! never[]
最严格 number[]

所以,如果我们从一个宽松的代码库开始,并希望使其变得严格,我们可能会:

  1. 启用 strictNullChecks,然后遇到一个新错误(不奇怪),然后
  2. 解决这个错误,无需更改代码,只需启用 noImplicitAny(奇怪!)。

当我们朝着完全严格的方向前进时,逐个启用严格选项可能会导致一些“虚假的”错误短暂出现,仅仅出现在中间的半严格状态。随着我们打开设置,错误数量会先上升后下降!

我个人期望启用严格选项是单调的:启用的选项越多 = 报错越多。但这一对选项违反了这种期望。

解决方案

在尝试使 TypeScript 代码库变得严格时,有几种方法可以“解决”这种奇怪现象:

  1. 直接用显式注解修复错误,例如 const array: number[] = []
  2. 使用不同的逐个启用顺序:先启用 noImplicitAny,然后再启用 strictNullChecks。如上表所示,按照这个顺序,两个步骤的推断结果都是 array: number[],因此没有错误。
  3. 同时启用它们:不要试图完全渐进,而是将这两个选项作为一步启用。

解释

为什么启用 strictNullChecks 并禁用 noImplicitAny 会导致一个在其他地方不出现的错误?jcalz 在 StackOverflow 上解释得很好,其核心是:

  • 这种有问题的组合是一个为了向后兼容而留下的边缘情况,其中 array 的类型在其声明处被推断为 never[],并在后续代码中被锁定。
  • 启用 noImplicitAny 会使编译器在模棱两可的位置(在没有 noImplicitAny 时会推断为 any 的地方)使用“演化”类型(evolving types,可理解为先推断为 any/never 然后后续追加推断的类型):因此,array 的类型不会在其声明行被确定,并且可以结合来自 push 的信息进行推断。

评论

这感觉像是一个有趣的脑筋急转弯,而不是一个重大问题:

  • 修复这些虚假错误并不是一个重大的负担或显著的浪费时间,而且可以说,添加注解可能使这类代码更清晰。
  • 半严格状态可能有奇怪的行为是可以理解的:我想 TypeScript 开发者更关心完全严格模式下的良好体验,希望中间状态只是垫脚石,而不是长期状态。

总结

TypeScript 选项 strictNullChecksnoImplicitAny 以一种奇怪的方式相互作用:以“错误”的顺序逐个启用它们会导致错误出现然后又消失,违反了单调性的期望(启用的严格选项越多 = 错误越多)。这可能发生在真实代码中,但影响极小,因为很容易解决和/或规避。

告别杂乱数字:用 Intl.NumberFormat 打造全球友好的前端体验

大家好,我是CC,在这里欢迎大家的到来~

开场

书接上文,Intl 下的 Segmenter 对象可以实现对文本的分割,Collator 对象可以处理字符串的比较,除此之外,还有数字格式化、日期格式化等其他功能。

这篇文章先来看看数字格式化,现在来理论加实践一下。

数字格式化

Intl.NumberFormat使数字在特定语言环境下格式化。

配置项

为了方便阅读,属性列表根据用途划分为多个部分,包括区域选项、样式选项、数字选项和其他选项。

区域选项

  • localeMatcher
    • 使用的区域匹配算法,可能的值包括:
    • 默认值为 best fit,还有 lookup
  • numberingSystem
    • 数字格式化的数字系统,像阿拉伯数字 arab、简体中文数字 hans、无衬线数字 mathsans
    • 默认值取决于区域
    • 同 locales 的 Unicode 扩展键 nu 设置,但优先级高于他

样式选项

  • style
    • 使用的格式化样式,可选的值包括:
    • decimal: 普通数字格式化
    • currency: 货币格式化
    • percent: 百分比格式化
    • unit: 单位格式化
    • 默认值是 decimal
  • currency
    • 货币格式化中使用的货币,像美元 USD、欧元 EUR 和人民币 CNY。
    • 没有默认值,style 为 currency 时必须提供,内容会被转换为大写。
  • currencyDisplay
    • 货币格式化中如何显示货币,可选的值包括:
    • code: 使用 ISO 货币代码
    • symbol: 使用本地化货币符号
    • narrowSymbol: 使用窄格式符号,像 100而不是US100 而不是 US100
    • name: 使用本地化货币名称,像 dollar
  • currencySign
    • 使用括号将数字括起来,而不是添加负号,可选的值包括:
    • standard: 默认值
    • accounting: 会计
  • unit
    • 格式化的单位
    • style 为 unit 时必填
  • unitDisplay
    • unit 格式化时使用的格式化风格,可选的值包括:
    • short: 默认值,例如 16 l
    • narrow: 例如 16l
    • long: 例如 16 litres

数字选项,由 Intl.PluralRules 支持

  • minimumIntegerDigits
    • 最小整数位数,默认值为 1,范围是 1~21
    • 若实际整数位数不足会在左侧用 0 补足,比如对于数字 5 该值设置为 3 则显示为“005”
  • minimumFractionDigits
    • 小数部分的最小位数,范围是 0~100
    • 若小数位数不足时会在右侧补 0,超过时会按四舍五入截断
    • 默认值对于普通数字和百分比是 0,对于 currency 是 2(ISO 4217 标准小数位数)
  • maximumFractionDigits
    • 小数部分的最大位数,范围是 0~100
    • 若小数位数不足时会在右侧补 0,超过时会按四舍五入截断
    • 默认值对于普通数字和百分比是 3,对于 currency 是 2(ISO 4217 标准小数位数)
  • minimumSignificanntDigits
    • 最小有效数字,默认值为 1。范围是 1~21。
    • 优先级高于 minimumFractionDigits
  • maximumSignificanntDigits
    • 最大有效数字,默认值为 21。范围是 1~21。
    • 优先级高于 maximumFractionDigits
  • roundingPriority
    • 当同时使用 FractionDigits 和 SignificantDigits 时指定如何解决四舍五入冲突,可选的值包括:
    • auto: 默认值,使用有效数字属性
    • morePrecision: 使用精度更高的属性
    • lessPrecision: 使用精度更低的属性
    • auto 属性会在 natation 为 compact 时且未设置任何四个 FractionDigits/SignificantDigits 时会被设置为 morePrecision
    • 除 auto 属性以外的值会根据 maximumSignificanntDigits 和 maximumFractionDigits 计算出更高精度,忽略最小小数位和有效数字位
  • roundingIncrement
    • 相对于计算出的舍入单位的舍入增量
    • 默认值为 1,其他值包括 1、2、5、10、20、25、50、100、200、250、500、1000、2000、5000
    • 不能与有效数字位舍入或任何 roundingPriority(除了 auto) 混合使用
  • roundingMode
    • 对小数进行舍入,可选的值包括:
    • ceil: 向正无穷舍入,正数向上,负数“向正”
    • floor: 向负无穷舍入,正数向下,负数“向负”
    • expand: 四舍五入远离 0,绝对值增大
    • trunc: 四舍五入朝向 0,绝对值减小
    • halfCeil: 趋向于正无穷舍入,包括半值
    • halfFloor: 趋向于负无穷舍入,包括半值
    • halfExpand: 默认值,半值远离 0 舍入
    • halfTrunc: 向 0 取整,包括半值
    • halfEven: 半值向最接近的偶数整数舍入,常用于统计,减少片差
  • trailingZeroDisplay
    • 整数末尾 0 的显示策略,可选的值包括:
    • auto: 默认值,根据 minimumFractionDigits 和 minimumSignificanntDigits 保持末尾 0
    • stripIfInteger: 如果小数部分全为 0 则删除小数部分,如果小数部分有任何非零数则与 auto 相同

其他选项

  • notation
    • 数字的显示格式,可选的值包括:
    • standard: 默认值,纯数字格式
    • scientific: 返回格式化数字的数量级
    • engineering: 返回能被 3 整除的 10 的指数
    • compact: 表示指数的字符串,默认使用 short 形式
  • compactDisplay
    • 仅当 notation 为 compact 时使用,可选的值包括:
    • short: 默认值
    • long
  • useGrouping
    • 是否使用分组分隔符,像千位分隔符或者千/十万/千万分隔符,可选的值包括:
    • always: 即使 locale 偏好不同也展示分组分隔符
    • auto: 根据 locale 偏好显示分组分隔符,也取决于货币
    • min2: 当一组数字至少有 2 位数字时显示分组分隔符
    • true: 同 always
    • false: 不展示分组分隔符
    • 当 notation 为 compact 时默认值为 min2,否则默认值为 auto
    • 字符串 true 和 false 会被转化为默认值
  • signDisplay
    • 何时显示数字符号,可选的值包括:
    • auto: 默认值
    • always: 总是显示
    • exceptZero: 正数和负数显示符号,但 0 不显示
    • negative: 仅显示负数的符号,不包括负零
    • never: 从不展示

格式化

format()方法会基于区域和格式化选项进行数字格式化。支持数字、大数和字符串。

数字可能因为太大或太小而丢失精度

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format(987654321987654321));
// 987,654,321,987,654,300

但是使用大数就不会有问题

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format(987654321987654321n));
// 987,654,321,987,654,321

字符串也不会丢失精度

const numberFormat = new Intl.NumberFormat("en-US");
console.log(numberFormat.format("987654321987654321"));
// 987,654,321,987,654,321

使用指数表示

const numberFormat = new Intl.NumberFormat("en-US");
const bigNum = 987654321987654321n;
console.log(numberFormat.format(`${bigNum}E-6`));
// 987,654,321,987.654

格式化分割成多部分

formatToParts()将会返回一个对象数组,包含格式化后的每一部分,适合用来自定义字符串格式化。

const number = 3500;

const formatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
});

console.log(formatter.format(number));
// "3.500,00 €"

console.log(formatter.formatToParts(number));
// [
//   { type: "integer", value: "3" },
//   { type: "group", value: "." },
//   { type: "integer", value: "500" },
//   { type: "decimal", value: "," },
//   { type: "fraction", value: "00" },
//   { type: "literal", value: " " },
//   { type: "currency", value: "€" },
// ];

格式化数字范围

formatRange()返回一个字符串表示数字范围格式化后的内容。

const nf = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

console.log(nf.formatRange(3, 5));
// "$3 – $5"

如果开始值和结束值四舍五入值相同或者完全相同时则会添加近似等于符号。

console.log(nf.formatRange(2.9, 3.1));
// "~$3"

格式化数字范围分割成多部分

formatRangeToParts()返回一个对象数组,包含格式化后的每一部分,适合用来自定义数字字符串的格式化范围。

const startRange = 3500;
const endRange = 9500;

const formatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
});

console.log(formatter.formatRange(startRange, endRange));
// "3.500,00–9.500,00 €"

console.log(formatter.formatRangeToParts(startRange, endRange));
// [
//   { type: "integer", value: "3", source: "startRange" },
//   { type: "group", value: ".", source: "startRange" },
//   { type: "integer", value: "500", source: "startRange" },
//   { type: "decimal", value: ",", source: "startRange" },
//   { type: "fraction", value: "00", source: "startRange" },
//   { type: "literal", value: "–", source: "shared" },
//   { type: "integer", value: "9", source: "endRange" },
//   { type: "group", value: ".", source: "endRange" },
//   { type: "integer", value: "500", source: "endRange" },
//   { type: "decimal", value: ",", source: "endRange" },
//   { type: "fraction", value: "00", source: "endRange" },
//   { type: "literal", value: " ", source: "shared" },
//   { type: "currency", value: "€", source: "shared" },
// ]

获取配置项

const de = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 2,
  roundingIncrement: 5,
  roundingMode: "halfCeil",
});

const usedOptions = de.resolvedOptions();
console.log(usedOptions.locale); // "de-DE"
console.log(usedOptions.numberingSystem); // "latn"
console.log(usedOptions.compactDisplay); // undefined ("notation" not set to "compact")
console.log(usedOptions.currency); // "USD"
console.log(usedOptions.currencyDisplay); // "symbol"
console.log(usedOptions.currencySign); // "standard"
console.log(usedOptions.minimumIntegerDigits); // 1
console.log(usedOptions.minimumFractionDigits); // 2
console.log(usedOptions.maximumFractionDigits); // 2
console.log(usedOptions.minimumSignificantDigits); // undefined (maximumFractionDigits is set)
console.log(usedOptions.maximumSignificantDigits); // undefined (maximumFractionDigits is set)
console.log(usedOptions.notation); // "standard"
console.log(usedOptions.roundingIncrement); // 5
console.log(usedOptions.roundingMode); // halfCeil
console.log(usedOptions.roundingPriority); // auto
console.log(usedOptions.signDisplay); // "auto"
console.log(usedOptions.style); // "currency"
console.log(usedOptions.trailingZeroDisplay); // auto
console.log(usedOptions.useGrouping); // auto

判断返回支持的 locale

在给定的 locales 数组中判断出 NumberFormat 支持的 locales。但是可能每个浏览器支持的不大一样。

const locales = ["ban", "id-u-co-pinyin", "de-ID"];
const options = { localeMatcher: "lookup" };

console.log(Intl.NumberFormat.supportedLocalesOf(locales, options));
// ["id-u-co-pinyin", "de-ID"]

总结

Intl.NumberFormat用于根据语言和地区格式化数字内容,像把数字格式化为货币、百分比或带单位的本地化字符串,精确控制数字的小数位数、有效数字和整数部分的最小位数,设置丰富的舍入模式像四舍五入、向零舍入或银行家舍入法这些场景下都十分适用。

HTTP一些问题的解答(接上篇)

一、在弱网环境下HTTP1会比HTTP2更快的原因是啥?

在弱网环境(高延迟、高丢包率)下,HTTP/1.x 有时比 HTTP/2 表现更好,核心原因是 HTTP/2 的多路复用机制与 TCP 协议的固有缺陷在弱网下产生了 “负协同效应” ,而 HTTP/1.x 的多连接策略反而规避了这种风险。具体可从以下几个角度拆解:

1. 多路复用放大了 TCP 队头阻塞的影响

HTTP/2 的核心优势是 “多路复用”—— 所有请求通过单个 TCP 连接传输,不同请求的帧(Frame)在该连接中交错发送。但这也意味着:单个 TCP 数据包的丢失会阻塞所有请求

  • 弱网下的问题:弱网环境丢包率高(比如 5% 以上),TCP 层一旦丢失一个数据包(可能包含某个请求的帧),会触发重传机制。由于 TCP 是 “按序交付” 的,重传期间,该连接上所有后续数据包(无论属于哪个请求)都会被暂存在接收端的 TCP 缓冲区,无法提交给 HTTP/2 应用层。例如:一个 TCP 连接上有 10 个并发请求的帧在传输,若第 3 个请求的某个帧丢失,TCP 重传期间,后面 7 个请求的帧即使已到达,也会被阻塞,导致所有 10 个请求都变慢。
  • HTTP/1.x 的规避方式:HTTP/1.x 依赖多个并行 TCP 连接(浏览器通常限制为 6-8 个),每个连接处理一个串行请求。若某个连接发生丢包,仅影响该连接上的请求,其他连接的请求仍可正常传输。例如:8 个连接中 1 个丢包,仅 1 个请求受影响,其余 7 个可继续,整体效率反而更高。

2. HTTP/2 的复杂机制在弱网下 “水土不服”

HTTP/2 为优化性能引入的机制(如头部压缩、流优先级),在弱网环境下可能变成负担:

  • HPACK 头部压缩的脆弱性:HTTP/2 用 HPACK 算法压缩请求头,依赖客户端和服务器维护 “共享压缩上下文”(记录已传输的头部字段)。若传输过程中某个头部帧丢失,可能导致双方压缩上下文不一致,需要重新同步,反而增加额外的传输开销和延迟。而 HTTP/1.x 的头部虽未压缩(冗余字节多),但结构简单,单个请求的头部丢失仅影响该请求,无需复杂同步。
  • 流优先级调度的失效:HTTP/2 允许标记请求的优先级(如 CSS/JS 优先于图片),但弱网下,TCP 层的丢包和重传会打乱优先级 —— 高优先级请求的帧可能因低优先级帧的丢包而被阻塞,导致优先级机制失效。而 HTTP/1.x 虽无优先级调度,但多连接天然隔离了不同请求,重要资源可通过独立连接传输,受其他请求影响更小。

3. TCP 拥塞控制对单连接的 “惩罚” 更重

TCP 有拥塞控制机制(如慢启动、拥塞避免),当检测到丢包时,会剧烈降低发送窗口(减少数据发送速率),直到网络恢复。

  • HTTP/2 的单连接劣势:单个 TCP 连接发生丢包后,拥塞窗口会被大幅缩减,导致所有请求的传输速率同时下降。弱网下丢包频繁,单连接可能长期处于低速率状态,整体吞吐量骤降。
  • HTTP/1.x 的多连接优势:多个 TCP 连接独立进行拥塞控制,某个连接因丢包被降速时,其他连接可保持正常速率。即使部分连接受影响,整体吞吐量仍能维持在较高水平。

总结:弱网下 “简单” 比 “高效” 更可靠

HTTP/2 的设计是为了在良好网络环境下通过多路复用提升效率(减少连接开销、优化资源调度),但依赖 TCP 作为底层传输协议。而弱网环境的高丢包、高延迟会放大 TCP 队头阻塞的影响,让多路复用从 “优势” 变成 “劣势”。

HTTP/1.x 虽然存在连接开销大、串行请求的问题,但多连接的 “分散风险” 特性在弱网下更稳健 —— 丢包的影响被限制在单个连接内,整体表现反而更优。这也是为什么在 3G 网络、高丢包的偏远地区,部分场景下 HTTP/1.x 反而比 HTTP/2 更快的核心原因。

二、HTTP2只能有一个连接吗?

HTTP/2 不是 “只能有一个连接” ,而是推荐使用单个 TCP 连接实现多路复用,但协议本身并不禁止建立多个连接。是否使用单连接,更多是基于性能优化的 “最佳实践”,而非强制限制。

1. 为什么 HTTP/2 通常用 “单个连接”?

HTTP/2 的核心设计目标之一是解决 HTTP/1.x “多连接开销大” 的问题,因此推荐通过单个 TCP 连接承载所有请求,利用 “多路复用”(多个 Stream 在同一连接中交错传输)提升效率:

  • 减少连接建立成本:TCP 三次握手、TLS 握手都有延迟(尤其是首次连接),单个连接可避免多次握手的开销;
  • 优化拥塞控制:单个连接的 TCP 拥塞窗口(发送速率)可集中利用带宽,多个连接可能因各自的拥塞控制相互竞争带宽,反而降低整体效率;
  • 简化多路复用逻辑:单个连接中,所有 Stream 的帧(Frame)通过Stream ID区分,接收端更容易管理和调度,多个连接会增加状态同步的复杂度。

2. 什么情况下 HTTP/2 会用 “多个连接”?

虽然不推荐,但 HTTP/2 协议允许同一客户端与服务器建立多个 TCP 连接,常见场景包括:

  • 兼容性兜底:部分老旧服务器或中间代理(如 CDN 节点)对 HTTP/2 的多路复用支持不完善(比如不识别 Stream ID),客户端可能 fallback 到多个连接以确保通信正常;
  • 域名分片残留:HTTP/1.x 常用 “域名分片”(将资源分散到多个子域名,突破浏览器单域名连接数限制),迁移到 HTTP/2 后,若未完全改造,可能仍保留多个子域名的连接(每个子域名一个 HTTP/2 连接);
  • 故障隔离:若单个连接因网络问题(如长时间卡顿、丢包)不可用,客户端可新建一个 HTTP/2 连接继续传输,避免整体中断;
  • 带宽限制突破:某些场景下(如超大文件传输),单个 TCP 连接的拥塞控制可能无法充分利用带宽,通过多个连接 “并行” 传输(类似 HTTP/1.x)可提升吞吐量(但这是对 HTTP/2 设计的 “反用”,较少见)。

3. 浏览器的实际行为:单连接为主,多连接为辅

现代浏览器(如 Chrome、Firefox)对 HTTP/2 的实现遵循 “单连接优先” 原则:

  • 同一域名,默认只建立1 个 HTTP/2 连接,所有请求通过该连接的多路复用传输;
  • 若该连接出现异常(如 TCP 断连),浏览器会自动新建一个 HTTP/2 连接替代;
  • 不同域名,仍会建立独立的 HTTP/2 连接(与 HTTP/1.x 一致,因跨域连接无法共享)。

总结

HTTP/2 的 “单个连接” 是推荐的最佳实践(为了最大化多路复用的优势),但协议本身不限制连接数量。实际应用中,绝大多数场景会用单连接,仅在兼容性、故障恢复等特殊情况下使用多个连接。这种设计既保留了灵活性,又通过单连接默认策略解决了 HTTP/1.x 的核心性能问题。

三、HTTP2中,多路复用的原理是什么?

HTTP/2 的多路复用(Multiplexing)是其核心特性之一,本质是在单一 TCP 连接上同时处理多个请求 - 响应事务,解决了 HTTP/1.x 中 “队头阻塞”(Head-of-Line Blocking)和连接效率低下的问题。其实现原理可拆解为三个关键机制:

1. 帧(Frame):数据传输的最小单位

HTTP/2 将所有传输的数据(请求头、响应体等)拆分为二进制帧,每个帧大小固定(默认最大 16KB),并包含以下关键信息:

  • 流标识符(Stream ID) :标记该帧属于哪个 “流”(对应一个请求 - 响应);
  • 类型(Type) :区分帧的用途(如HEADERS帧承载请求头,DATA帧承载正文,SETTINGS帧配置参数等);
  • 长度(Length) :帧的实际数据大小;
  • 标志位(Flags) :附加控制信息(如END_STREAM标记流结束)。

二进制帧的设计相比 HTTP/1.x 的文本格式,不仅解析效率更高,更重要的是为 “交错传输” 奠定了基础。

2. 流(Stream):请求 - 响应的逻辑通道

每个请求 - 响应事务对应一个,流是 TCP 连接内的 “虚拟通道”,具有以下特性:

  • 双向性:一个流中可同时传输客户端到服务器(请求)和服务器到客户端(响应)的帧;
  • 唯一标识:每个流有唯一的Stream ID(客户端发起的流 ID 为奇数,服务器发起的为偶数);
  • 优先级:可通过PRIORITY帧指定流的优先级(如 CSS/JS 资源优先于图片),服务器据此调整帧的发送顺序;
  • 可中断与复用:流可被暂停、恢复或终止,释放的 ID 可被新流复用。

通过流的隔离,多个请求 - 响应的帧可以在同一 TCP 连接上 “交错传输”(如请求 A 的DATA帧和请求 B 的HEADERS帧交替发送),接收方再根据Stream ID将帧重新组装成完整的请求 / 响应。

3. 单一 TCP 连接的复用

HTTP/2 通过上述 “帧 + 流” 机制,实现了单一 TCP 连接上的多路复用

  • 所有请求 / 响应共享一个 TCP 连接,无需为每个请求建立新连接(减少三次握手 / 慢启动的开销);
  • 多个流的帧可并行传输,避免了 HTTP/1.x 中 “一个请求阻塞导致后续请求排队” 的队头阻塞问题;
  • 即使某个流因网络问题阻塞,其他流的帧仍可正常传输(仅影响单个流,不阻塞整个连接)。

对比 HTTP/1.x 的核心优势

HTTP/1.x HTTP/2 多路复用
多个请求需建立多个 TCP 连接(或串行复用同一连接) 所有请求共享单一 TCP 连接
文本格式传输,解析效率低 二进制帧传输,解析更快
一个请求阻塞会导致后续请求排队(队头阻塞) 流隔离,单个流阻塞不影响其他流

简言之,HTTP/2 的多路复用通过 “帧拆分 + 流标识 + 单连接复用”,彻底解决了 HTTP/1.x 的连接效率问题,大幅提升了高并发场景下的性能(如网页加载大量资源时)。

四、HTTP/1为啥会一个请求阻塞会导致后续请求排队(队头阻塞)?

HTTP/1.x 出现 “队头阻塞”(Head-of-Line Blocking)的核心原理,是由TCP 协议的 “按序交付” 特性HTTP/1.x 协议的 “串行请求 - 响应” 设计共同决定的,两者叠加导致了 “前一个请求阻塞后续所有请求” 的现象。

原理拆解:两层机制的叠加限制

1. 底层 TCP 协议的 “按序交付” 特性(根本原因)

TCP 是面向连接的 “可靠字节流协议”,其核心特性之一是 “按序交付”

  • 发送方会给每个数据包分配一个唯一的 “序号”,接收方必须按序号从小到大的顺序接收并组装数据;
  • 若中间某个数据包丢失(如网络波动),接收方会触发 “超时重传” 机制,等待发送方重新发送丢失的数据包;
  • 在丢失的数据包被重传并接收前,后续所有已到达的数据包即使完整,也会被暂存队列中,无法提交给应用层处理(因为顺序被打乱,无法保证数据完整性)。

这就是 “TCP 层的队头阻塞”—— 单个数据包的问题会阻塞后续所有数据的处理。

2. HTTP/1.x 协议的 “串行请求 - 响应” 设计(放大问题)

HTTP/1.x 运行在 TCP 之上,但其协议设计进一步放大了 TCP 的队头阻塞问题:

  • 无 “流标识” 机制:HTTP/1.x 没有像 HTTP/2 那样的 “流 ID” 来区分不同请求的数据包。接收方(如浏览器)只能通过 “请求发送顺序” 来匹配对应的响应。
  • 严格串行处理:在同一个 TCP 连接上,HTTP/1.x 要求:
    • 必须等前一个请求的完整响应被接收后,才能发送下一个请求;
    • 响应也必须按请求发送的顺序返回(否则接收方无法判断哪个响应对应哪个请求)。

最终导致队头阻塞的过程

假设在一个 TCP 连接上,浏览器按顺序发送 3 个请求:请求A → 请求B → 请求C,过程如下:

  1. 服务器正常返回响应A的部分数据,但中途某个数据包丢失;
  2. 由于 TCP 按序交付特性,接收方会等待丢失的数据包重传,此时响应A的后续数据和已到达的响应B响应C的完整数据,都会被暂存在 TCP 缓冲区中,无法提交给浏览器处理;
  3. 同时,由于 HTTP/1.x 的串行规则,浏览器必须等响应A完全接收后,才能处理响应B响应C—— 即使响应B响应C的数据早已到达,也只能排队等待。

最终,单个请求(A)的阻塞会像 “多米诺骨牌” 一样,导致后续所有请求(B、C)被卡住,这就是 HTTP/1.x 队头阻塞的完整原理。

总结

TCP 的 “按序交付” 导致单个数据包问题阻塞后续数据,而 HTTP/1.x 缺乏 “流标识” 和 “并行处理” 能力,只能通过 “串行请求 - 响应” 来保证数据匹配,两者叠加使得一个请求的延迟会阻塞同一连接上所有后续请求,这就是队头阻塞的本质。

JavaScript 列表转树(List to Tree)详解:前端面试中如何从递归 O(n²) 优化到一次遍历 O(n)

前言:Offer 是怎么没的?

在前端面试的江湖里,「列表转树(List to Tree)」 是一道妥妥的高频题。

很多同学一看到这道题,内心 OS 都是:

😎「简单啊,递归!」

代码写完,自信抬头。
面试官却慢悠悠地问了一句:

🤨「如果是 10 万条数据 呢?
👉 时间复杂度多少?
👉 会不会栈溢出?」

空气突然安静。

今天这篇文章,我们就把这道题彻底拆开:
从「能写」到「写得对」,再到「写得漂亮」。


一、为什么面试官总盯着这棵“树”?

因为在真实业务中,后端给你的几乎永远是扁平数据

例如:

const list = [
  { id: 1, parentId: 0, name: '北京市' },
  { id: 2, parentId: 1, name: '顺义区' },
  { id: 3, parentId: 1, name: '朝阳区' },
  { id: 4, parentId: 2, name: '后沙峪' },
  { id: 121, parentId: 0, name: '江西省' },
  { id: 155, parentId: 121, name: '抚州市' }
];

而前端组件(Menu、Tree、Cascader)要的却是👇

省
 └─ 市
     └─ 区

🎯 面试官的真实考点

  • 数据结构理解:是否真正理解 parentId
  • 递归意识 & 代价:不只会写,还要知道坑在哪
  • 性能优化能力:能否从 O(n²) 优化到 O(n)
  • JS 引用理解:是否理解对象在内存中的表现

二、第一重境界:递归法(能写,但不稳)

1️⃣ 最基础的递归写法

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: list2tree(list, item.id)
    }));
}

逻辑非常直观:

  • 找当前 parentId 的所有子节点
  • 对每个子节点继续递归
  • 没有子节点时自然退出

三、进阶:ES6 优雅写法(看起来很高级)

如果你在面试中写出下面这段代码👇
面试官大概率会先点头。

const list2tree = (list, parentId = 0) =>
  list
    .filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,              // 解构赋值,保持原对象纯净
      children: list2tree(list, item.id)
    }));

这一版代码:

  • ✅ 箭头函数
  • filter + map 链式调用
  • ✅ 解构赋值,不污染原数据
  • ✅ 可读性很好,看起来很“ES6”

👉 很多同学到这一步就觉得稳了。


🤔 面试官的经典追问

「这个方案,有什么问题?」


🎯 标准回答(一定要说出来)

「这个方案的本质是 嵌套循环
每一层递归,都会遍历一次完整的 list

👉 时间复杂度是 O(n²)
👉 如果层级过深,还可能导致 栈溢出(Stack Overflow) 。」

📌 一句话总结

ES6 写法只是“看起来优雅”,
性能问题不会因为代码好看就自动消失。


四、第二重境界:Map 优化(面试及格线)

既然慢,是因为反复遍历找父节点
那就用 Map 建立索引

👉 典型的:空间换时间


核心思路

  1. 第一遍:把所有节点放进 Map
  2. 第二遍:通过 parentId 直接挂载
  3. 利用 JS 对象引用,自动同步树结构

代码实现

function listToTreeWithMap(list) {
  const map = new Map();
  const tree = [];

  // 初始化
  for (const item of list) {
    map.set(item.id, { ...item, children: [] });
  }

  // 构建树
  for (const item of list) {
    const node = map.get(item.id);
    if (item.parentId === 0) {
      tree.push(node);
    } else {
      const parent = map.get(item.parentId);
      parent && parent.children.push(node);
    }
  }

  return tree;
}

⏱ 复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

📌 到这一步,已经可以应付大多数面试了。


五、终极奥义:一次遍历 + 引用魔法(Top Tier)

面试官:
「能不能只遍历一次?」

答案是:能,而且这才是天花板解法。


核心精髓:占位 + 引用同步

  • 子节点可能先于父节点出现
  • 先在 Map 里给父节点 占位
  • 后续再补全数据
  • 引用地址始终不变,树会“自己长好”

代码实现(一次遍历)

function listToTreePerfect(list) {
  const map = new Map();
  const tree = [];

  for (const item of list) {
    const { id, parentId } = item;

    if (!map.has(id)) {
      map.set(id, { children: [] });
    }

    const node = map.get(id);
    Object.assign(node, item);

    if (parentId === 0) {
      tree.push(node);
    } else {
      if (!map.has(parentId)) {
        map.set(parentId, { children: [] });
      }
      map.get(parentId).children.push(node);
    }
  }

  return tree;
}

🏆 为什么这是王者解法?

  • ✅ 一次遍历,O(n)
  • ✅ 支持乱序数据
  • ✅ 深度理解 JS 引用机制
  • ✅ 面试官一眼就懂你是“真会”

六、真实开发中的应用场景

  • 🔹 权限 / 菜单树(Ant Design / Element)
  • 🔹 省市区 / Cascader
  • 🔹 文件目录结构(云盘、编辑器)

七、面试总结 & 避坑指南

方案 时间复杂度 评价
递归 O(n²) 能写,但危险
Map 两次遍历 O(n) 面试合格
一次遍历 O(n) 面试加分

面试加分表达

  • 主动提 空间换时间
  • 点出 JS 对象是引用类型
  • 询问 parentId 是否可能为 null
  • 说明是否会修改原数据(必要时深拷贝)

结语

算法不是为了为难人,
而是为了在复杂业务中,
选出那条最稳、最优雅的路。

如果这篇文章对你有帮助👇
👍 点个赞
💬 评论区聊聊你在项目里遇到过的奇葩数据结构

工程化工具类:模块化系统全解析与实践

引言

在前端开发的演进历程中,模块化一直是工程化实践的核心。从早期的脚本标签堆砌到现代的ES Modules,模块化技术极大地提升了代码的可维护性、复用性和协作效率。本文将深入探讨模块化的各个方面,包括模块加载器实现、规范演化、Polyfill技术,并补充构建工具、性能优化等工程化实践,全面解析模块化在现代前端开发中的应用。

一、实现简单的模块加载器

在理解复杂模块系统之前,我们先实现一个简单的模块加载器,了解其核心原理。

1.1 基础模块加载器实现
// 简单的模块注册表
const moduleRegistry = {};
const moduleCache = {};

// 模块定义函数
function define(name, dependencies, factory) {
  if (!moduleRegistry[name]) {
    moduleRegistry[name] = {
      dependencies,
      factory,
      resolved: false,
      exports: null
    };
  }
}

// 模块加载函数
function require(name) {
  // 检查缓存
  if (moduleCache[name]) {
    return moduleCache[name];
  }
  
  const module = moduleRegistry[name];
  if (!module) {
    throw new Error(`Module ${name} not found`);
  }
  
  // 解析依赖
  const resolvedDeps = module.dependencies.map(dep => {
    if (dep === 'exports' || dep === 'module') {
      return null; // 特殊处理
    }
    return require(dep);
  });
  
  // 执行工厂函数获取模块导出
  const factoryResult = module.factory.apply(null, resolvedDeps);
  
  // 缓存模块导出
  moduleCache[name] = factoryResult || {};
  module.resolved = true;
  
  return moduleCache[name];
}

// 使用示例
define('math', [], function() {
  return {
    add: (a, b) => a + b,
    multiply: (a, b) => a * b
  };
});

define('calculator', ['math'], function(math) {
  return {
    calculate: (x, y) => math.multiply(math.add(x, y), 2)
  };
});

// 使用模块
const calculator = require('calculator');
console.log(calculator.calculate(2, 3)); // 10
1.2 异步模块加载器
class AsyncModuleLoader {
  constructor() {
    this.modules = new Map();
    this.loading = new Map();
  }
  
  // 定义模块
  define(name, deps, factory) {
    this.modules.set(name, {
      deps,
      factory,
      exports: null,
      resolved: false
    });
  }
  
  // 异步加载模块
  async require(name) {
    if (this.modules.get(name)?.resolved) {
      return this.modules.get(name).exports;
    }
    
    // 防止重复加载
    if (this.loading.has(name)) {
      return this.loading.get(name);
    }
    
    // 创建加载Promise
    const loadPromise = this._loadModule(name);
    this.loading.set(name, loadPromise);
    
    return loadPromise;
  }
  
  async _loadModule(name) {
    const module = this.modules.get(name);
    if (!module) {
      throw new Error(`Module ${name} not found`);
    }
    
    // 加载所有依赖
    const depPromises = module.deps.map(dep => this.require(dep));
    const deps = await Promise.all(depPromises);
    
    // 执行工厂函数
    const exports = module.factory.apply(null, deps);
    
    // 更新模块状态
    module.exports = exports || {};
    module.resolved = true;
    this.loading.delete(name);
    
    return module.exports;
  }
}

// 使用示例
const loader = new AsyncModuleLoader();

loader.define('utils', [], () => ({
  format: str => str.toUpperCase()
}));

loader.define('app', ['utils'], (utils) => {
  return {
    run: () => console.log(utils.format('hello'))
  };
});

loader.require('app').then(app => app.run()); // 输出: HELLO

二、AMD规范实现

AMD(Asynchronous Module Definition)规范是RequireJS推广的异步模块定义标准。

2.1 简化的AMD实现
(function(global) {
  // 模块缓存
  const modules = {};
  const inProgress = {};
  
  // 定义函数
  function define(id, dependencies, factory) {
    if (arguments.length === 2) {
      factory = dependencies;
      dependencies = [];
    }
    
    modules[id] = {
      id: id,
      dependencies: dependencies,
      factory: factory,
      exports: null,
      resolved: false
    };
    
    // 尝试解析模块
    resolveModule(id);
  }
  
  // 依赖解析
  function resolveModule(id) {
    const module = modules[id];
    if (!module || module.resolved) return;
    
    // 检查依赖是否都可用
    const deps = module.dependencies;
    const missingDeps = deps.filter(dep => 
      !modules[dep] || !modules[dep].resolved
    );
    
    if (missingDeps.length === 0) {
      // 所有依赖已就绪,执行工厂函数
      executeModule(id);
    } else {
      // 等待依赖
      missingDeps.forEach(dep => {
        if (!inProgress[dep]) {
          inProgress[dep] = [];
        }
        inProgress[dep].push(id);
      });
    }
  }
  
  // 执行模块
  function executeModule(id) {
    const module = modules[id];
    if (module.resolved) return;
    
    // 获取依赖的exports
    const depExports = module.dependencies.map(dep => {
      if (dep === 'exports') return {};
      if (dep === 'require') return createRequire();
      if (dep === 'module') return { id: module.id, exports: {} };
      return modules[dep].exports;
    });
    
    // 执行工厂函数
    const exports = module.factory.apply(null, depExports);
    
    // 设置exports
    module.exports = exports || 
      (depExports[module.dependencies.indexOf('exports')] || {});
    module.resolved = true;
    
    // 通知等待此模块的其他模块
    if (inProgress[id]) {
      inProgress[id].forEach(dependentId => resolveModule(dependentId));
      delete inProgress[id];
    }
  }
  
  // 创建require函数
  function createRequire() {
    return function(ids, callback) {
      if (typeof ids === 'string') ids = [ids];
      
      Promise.all(ids.map(loadModule))
        .then(modules => {
          if (callback) callback.apply(null, modules);
        });
    };
  }
  
  // 异步加载模块
  function loadModule(id) {
    return new Promise((resolve, reject) => {
      if (modules[id] && modules[id].resolved) {
        resolve(modules[id].exports);
        return;
      }
      
      // 动态加载脚本
      const script = document.createElement('script');
      script.src = id + '.js';
      script.onload = () => {
        // 等待模块解析
        const checkInterval = setInterval(() => {
          if (modules[id] && modules[id].resolved) {
            clearInterval(checkInterval);
            resolve(modules[id].exports);
          }
        }, 10);
      };
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  
  // 暴露到全局
  global.define = define;
  global.require = createRequire();
  
})(typeof window !== 'undefined' ? window : global);

// 使用示例
define('math', [], function() {
  return {
    add: function(a, b) { return a + b; }
  };
});

define('app', ['math', 'require'], function(math, require) {
  return {
    calculate: function() {
      return math.add(1, 2);
    },
    loadExtra: function() {
      require(['utils'], function(utils) {
        console.log('Utils loaded');
      });
    }
  };
});

require(['app'], function(app) {
  console.log(app.calculate()); // 3
});

三、CMD规范实现

CMD(Common Module Definition)规范由Sea.js推广,强调就近依赖。

3.1 简化的CMD实现
(function(global) {
  const modules = {};
  const factories = {};
  const cache = {};
  
  // 模块状态
  const STATUS = {
    PENDING: 0,
    LOADING: 1,
    LOADED: 2,
    EXECUTING: 3,
    EXECUTED: 4
  };
  
  // 定义函数
  function define(factory) {
    // 获取当前脚本
    const scripts = document.getElementsByTagName('script');
    const currentScript = scripts[scripts.length - 1];
    const id = currentScript.src.replace(/\.js$/, '');
    
    factories[id] = factory;
    modules[id] = {
      id: id,
      factory: factory,
      deps: [],
      exports: null,
      status: STATUS.PENDING,
      callbacks: []
    };
    
    // 解析依赖
    parseDependencies(id);
  }
  
  // 解析依赖
  function parseDependencies(id) {
    const factory = factories[id];
    if (!factory) return;
    
    const source = factory.toString();
    const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
    const deps = [];
    let match;
    
    while ((match = requireRegex.exec(source)) !== null) {
      deps.push(match[1]);
    }
    
    modules[id].deps = deps;
  }
  
  // 异步加载模块
  function require(id, callback) {
    const module = modules[id];
    
    if (module && module.status === STATUS.EXECUTED) {
      // 模块已执行,直接返回
      if (callback) {
        callback(module.exports);
      }
      return module.exports;
    }
    
    // 模块未加载,开始加载
    if (!module || module.status === STATUS.PENDING) {
      return loadModule(id, callback);
    }
    
    // 模块加载中,添加回调
    if (module.status < STATUS.EXECUTED) {
      module.callbacks.push(callback);
    }
  }
  
  // 加载模块
  function loadModule(id, callback) {
    const module = modules[id] || (modules[id] = {
      id: id,
      deps: [],
      exports: null,
      status: STATUS.LOADING,
      callbacks: callback ? [callback] : []
    });
    
    // 创建script标签加载
    const script = document.createElement('script');
    script.src = id + '.js';
    script.async = true;
    
    script.onload = function() {
      module.status = STATUS.LOADED;
      executeModule(id);
    };
    
    script.onerror = function() {
      console.error(`Failed to load module: ${id}`);
    };
    
    document.head.appendChild(script);
    
    return null;
  }
  
  // 执行模块
  function executeModule(id) {
    const module = modules[id];
    if (!module || module.status >= STATUS.EXECUTING) return;
    
    module.status = STATUS.EXECUTING;
    
    // 收集依赖
    const deps = module.deps;
    const depValues = deps.map(depId => {
      const depModule = modules[depId];
      if (depModule && depModule.status === STATUS.EXECUTED) {
        return depModule.exports;
      }
      // 同步加载依赖(简化实现)
      return require(depId);
    });
    
    // 执行工厂函数
    const factory = factories[id];
    if (!factory) {
      throw new Error(`Factory not found for module: ${id}`);
    }
    
    // 提供require、exports、module参数
    const localRequire = function(depId) {
      return require(depId);
    };
    
    const localExports = {};
    const localModule = { exports: localExports };
    
    // 执行
    const result = factory.call(null, localRequire, localExports, localModule);
    
    // 设置exports
    module.exports = localModule.exports || result || localExports;
    module.status = STATUS.EXECUTED;
    
    // 执行回调
    module.callbacks.forEach(cb => cb(module.exports));
    module.callbacks = [];
  }
  
  // 暴露全局
  global.define = define;
  global.require = require;
  
})(typeof window !== 'undefined' ? window : global);

// 使用示例
// 文件: math.js
define(function(require, exports, module) {
  module.exports = {
    add: function(a, b) {
      return a + b;
    }
  };
});

// 文件: app.js
define(function(require, exports, module) {
  var math = require('math');
  
  exports.calculate = function() {
    return math.add(1, 2);
  };
});

// 主文件
require('app', function(app) {
  console.log(app.calculate()); // 3
});

四、ES Module的简单Polyfill

虽然现代浏览器支持ES Modules,但在某些场景下,我们仍需要Polyfill支持。

4.1 基础ESM Polyfill实现
// ES Module Polyfill
(function() {
  const moduleMap = new Map();
  const moduleCache = new Map();
  
  // 拦截import语句(通过动态import实现)
  window.importModule = async function(modulePath) {
    // 检查缓存
    if (moduleCache.has(modulePath)) {
      return moduleCache.get(modulePath);
    }
    
    // 加载模块代码
    const code = await fetchModule(modulePath);
    
    // 解析依赖
    const deps = extractDependencies(code);
    
    // 加载依赖
    const depPromises = deps.map(dep => 
      importModule(resolvePath(modulePath, dep))
    );
    const dependencies = await Promise.all(depPromises);
    
    // 执行模块
    const moduleExports = {};
    const module = {
      exports: moduleExports
    };
    
    // 创建包装函数
    const wrapper = createWrapper(code, dependencies);
    wrapper(
      moduleExports, // exports
      module,        // module
      modulePath     // __filename(模拟)
    );
    
    // 缓存结果
    const exports = module.exports === moduleExports ? 
      moduleExports : module.exports;
    moduleCache.set(modulePath, exports);
    
    return exports;
  };
  
  // 提取依赖
  function extractDependencies(code) {
    const importRegex = /import\s+.*?\s+from\s+['"](.*?)['"]/g;
    const dynamicImportRegex = /import\s*\(['"](.*?)['"]\)/g;
    const deps = new Set();
    
    let match;
    while ((match = importRegex.exec(code)) !== null) {
      deps.add(match[1]);
    }
    
    // 重置正则
    importRegex.lastIndex = 0;
    
    while ((match = dynamicImportRegex.exec(code)) !== null) {
      deps.add(match[1]);
    }
    
    return Array.from(deps);
  }
  
  // 创建包装函数
  function createWrapper(code, dependencies) {
    const wrapperCode = `
      (function(exports, module, __filename, __dirname) {
        // 注入依赖
        const [
          ${dependencies.map((_, i) => `__dep${i}`).join(', ')}
        ] = arguments[4];
        
        ${code}
        
        // 返回默认导出
        return module.exports && module.exports.default ?
          module.exports.default : module.exports;
      })
    `;
    
    return eval(wrapperCode);
  }
  
  // 解析路径
  function resolvePath(basePath, targetPath) {
    if (targetPath.startsWith('./') || targetPath.startsWith('../')) {
      const baseDir = basePath.substring(0, basePath.lastIndexOf('/'));
      return new URL(targetPath, baseDir + '/').pathname;
    }
    return targetPath;
  }
  
  // 获取模块代码
  async function fetchModule(path) {
    const response = await fetch(path);
    if (!response.ok) {
      throw new Error(`Failed to load module: ${path}`);
    }
    return response.text();
  }
  
  // 拦截script type="module"
  interceptModuleScripts();
  
  function interceptModuleScripts() {
    const originalCreateElement = document.createElement;
    
    document.createElement = function(tagName) {
      const element = originalCreateElement.call(document, tagName);
      
      if (tagName === 'script') {
        const originalSetAttribute = element.setAttribute.bind(element);
        
        element.setAttribute = function(name, value) {
          originalSetAttribute(name, value);
          
          if (name === 'type' && value === 'module') {
            // 拦截模块脚本
            const src = element.getAttribute('src');
            if (src) {
              element.type = 'text/javascript';
              importModule(src).then(() => {
                if (element.onload) element.onload();
              }).catch(err => {
                if (element.onerror) element.onerror(err);
              });
            }
          }
        };
      }
      
      return element;
    };
  }
})();

// 使用示例
// 模块文件: utils.js
export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export default function greet(name) {
  return `Hello, ${capitalize(name)}!`;
}

// 主文件
importModule('./utils.js').then(utils => {
  console.log(utils.default('world')); // Hello, World!
  console.log(utils.capitalize('test')); // Test
});
4.2 支持Tree Shaking的ESM Polyfill
class ESMCompat {
  constructor() {
    this.modules = new Map();
    this.usedExports = new Set();
  }
  
  // 注册模块
  register(name, code) {
    const ast = this.parse(code);
    const exports = this.extractExports(ast);
    
    this.modules.set(name, {
      code,
      ast,
      exports,
      used: new Set()
    });
  }
  
  // 解析代码为AST(简化版)
  parse(code) {
    // 简化实现:实际应使用Babel等解析器
    const exportMatches = code.match(/export\s+(const|let|var|function|class|default)\s+(\w+)/g) || [];
    const imports = code.match(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g) || [];
    
    return {
      exports: exportMatches.map(match => ({
        type: match.split(' ')[1],
        name: match.split(' ')[2]
      })),
      imports: imports.map(match => {
        const parts = match.match(/import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/);
        return {
          specifiers: parts[1].split(',').map(s => s.trim()),
          source: parts[2]
        };
      })
    };
  }
  
  // 提取导出
  extractExports(ast) {
    return ast.exports.map(exp => exp.name);
  }
  
  // 使用模块(标记使用的导出)
  use(name, ...exports) {
    const module = this.modules.get(name);
    if (module) {
      exports.forEach(exp => {
        if (module.exports.includes(exp)) {
          module.used.add(exp);
        }
      });
    }
  }
  
  // 生成优化后的代码
  generateOptimized(name) {
    const module = this.modules.get(name);
    if (!module) return '';
    
    let code = module.code;
    
    // 移除未使用的导出(简化实现)
    module.exports.forEach(exp => {
      if (!module.used.has(exp)) {
        const regex = new RegExp(`export\\s+.*?\\b${exp}\\b[^;]*;`, 'g');
        code = code.replace(regex, '');
      }
    });
    
    return code;
  }
}

// 使用示例
const compat = new ESMCompat();

compat.register('math', `
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export function unusedFunction() { return 'unused'; }
`);

// 标记使用的导出
compat.use('math', 'PI', 'add');

// 生成优化代码
console.log(compat.generateOptimized('math'));
// 输出将只包含PI和add的导出

五、模块化构建工具集成

现代开发中,我们使用构建工具处理模块化。以下展示如何集成Webpack-like的简单打包器。

5.1 简易模块打包器
const fs = require('fs');
const path = require('path');
const { parse } = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

class SimpleBundler {
  constructor(entry) {
    this.entry = entry;
    this.modules = new Map();
    this.moduleId = 0;
  }
  
  // 构建
  build(outputPath) {
    const entryModule = this.collectDependencies(this.entry);
    const bundleCode = this.generateBundle(entryModule);
    
    fs.writeFileSync(outputPath, bundleCode);
    console.log(`Bundle generated: ${outputPath}`);
  }
  
  // 收集依赖
  collectDependencies(filePath) {
    const fileContent = fs.readFileSync(filePath, 'utf-8');
    const ast = parse(fileContent, {
      sourceType: 'module',
      plugins: ['jsx']
    });
    
    const dependencies = [];
    const dirname = path.dirname(filePath);
    
    // 遍历AST收集import语句
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        const importPath = node.source.value;
        const absolutePath = this.resolvePath(importPath, dirname);
        dependencies.push(absolutePath);
      },
      CallExpression: ({ node }) => {
        if (node.callee.type === 'Import') {
          const importPath = node.arguments[0].value;
          const absolutePath = this.resolvePath(importPath, dirname);
          dependencies.push(absolutePath);
        }
      }
    });
    
    const moduleId = this.moduleId++;
    const module = {
      id: moduleId,
      filePath,
      code: fileContent,
      dependencies,
      mapping: {}
    };
    
    this.modules.set(filePath, module);
    
    // 递归收集依赖
    dependencies.forEach(dep => {
      if (!this.modules.has(dep)) {
        this.collectDependencies(dep);
      }
    });
    
    return module;
  }
  
  // 解析路径
  resolvePath(importPath, baseDir) {
    if (importPath.startsWith('.')) {
      return path.resolve(baseDir, importPath);
    }
    // 处理node_modules(简化)
    const nodeModulePath = path.resolve(process.cwd(), 'node_modules', importPath);
    if (fs.existsSync(nodeModulePath)) {
      return nodeModulePath;
    }
    return importPath;
  }
  
  // 生成打包代码
  generateBundle(entryModule) {
    const modules = [];
    
    // 创建模块映射
    this.modules.forEach(module => {
      const transformedCode = this.transformModule(module);
      modules.push(`
        ${module.id}: {
          factory: function(require, module, exports) {
            ${transformedCode}
          },
          mapping: ${JSON.stringify(module.mapping)}
        }
      `);
    });
    
    // 生成运行时
    return `
      (function(modules) {
        const moduleCache = {};
        
        function require(id) {
          if (moduleCache[id]) {
            return moduleCache[id].exports;
          }
          
          const mod = modules[id];
          const localRequire = function(modulePath) {
            return require(mod.mapping[modulePath]);
          };
          
          const module = { exports: {} };
          mod.factory(localRequire, module, module.exports);
          
          moduleCache[id] = module;
          return module.exports;
        }
        
        // 启动入口模块
        require(0);
      })({
        ${modules.join(',\n')}
      });
    `;
  }
  
  // 转换模块代码
  transformModule(module) {
    const ast = parse(module.code, {
      sourceType: 'module'
    });
    
    // 构建路径映射
    let importIndex = 0;
    
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        const importPath = node.source.value;
        const depModule = this.modules.get(
          this.resolvePath(importPath, path.dirname(module.filePath))
        );
        
        if (depModule) {
          const importName = `__import_${importIndex++}`;
          module.mapping[importPath] = depModule.id;
          
          // 替换import语句
          const specifiers = node.specifiers.map(spec => {
            if (t.isImportDefaultSpecifier(spec)) {
              return t.variableDeclarator(
                spec.local,
                t.memberExpression(
                  t.identifier(importName),
                  t.identifier('default')
                )
              );
            } else {
              return t.variableDeclarator(
                spec.local,
                t.memberExpression(
                  t.identifier(importName),
                  spec.imported || spec.local
                )
              );
            }
          });
          
          return t.variableDeclaration('const', specifiers);
        }
      }
    });
    
    // 移除export语句
    traverse(ast, {
      ExportNamedDeclaration: ({ node, remove }) => {
        if (node.declaration) {
          return node.declaration;
        }
        remove();
      },
      ExportDefaultDeclaration: ({ node }) => {
        return t.expressionStatement(
          t.assignmentExpression(
            '=',
            t.memberExpression(
              t.identifier('module'),
              t.identifier('exports')
            ),
            t.objectExpression([
              t.objectProperty(
                t.identifier('default'),
                node.declaration
              )
            ])
          )
        );
      }
    });
    
    const { code } = generate(ast);
    return code;
  }
}

// 使用示例
const bundler = new SimpleBundler('./src/index.js');
bundler.build('./dist/bundle.js');

六、模块联邦与微前端架构

模块联邦(Module Federation)是Webpack 5引入的重要特性,支持跨应用共享模块。

6.1 简易模块联邦实现
// 模块联邦管理器
class ModuleFederation {
  constructor(config) {
    this.config = config;
    this.remotes = new Map();
    this.exposes = new Map();
    this.shared = new Map();
    this.init();
  }
  
  init() {
    // 初始化共享模块
    if (this.config.shared) {
      Object.entries(this.config.shared).forEach(([name, config]) => {
        this.shared.set(name, {
          module: require(name),
          version: config.version,
          singleton: config.singleton || false
        });
      });
    }
    
    // 初始化暴露模块
    if (this.config.exposes) {
      Object.entries(this.config.exposes).forEach(([name, modulePath]) => {
        this.exposes.set(name, require(modulePath));
      });
    }
  }
  
  // 注册远程应用
  async registerRemote(name, url) {
    try {
      const remoteManifest = await this.fetchRemoteManifest(url);
      this.remotes.set(name, {
        url,
        manifest: remoteManifest
      });
      console.log(`Remote ${name} registered`);
    } catch (error) {
      console.error(`Failed to register remote ${name}:`, error);
    }
  }
  
  // 获取远程清单
  async fetchRemoteManifest(url) {
    const response = await fetch(`${url}/federation-manifest.json`);
    return response.json();
  }
  
  // 获取模块
  async getModule(remoteName, moduleName) {
    // 检查共享模块
    if (this.shared.has(moduleName)) {
      return this.shared.get(moduleName).module;
    }
    
    // 检查本地暴露
    if (this.exposes.has(moduleName)) {
      return this.exposes.get(moduleName);
    }
    
    // 检查远程模块
    const remote = this.remotes.get(remoteName);
    if (remote) {
      return this.loadRemoteModule(remote, moduleName);
    }
    
    throw new Error(`Module ${moduleName} not found`);
  }
  
  // 加载远程模块
  async loadRemoteModule(remote, moduleName) {
    const moduleUrl = `${remote.url}/${moduleName}.js`;
    
    // 动态加载脚本
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = moduleUrl;
      
      script.onload = () => {
        // 假设远程模块会暴露到全局
        const module = window[`${remote.name}_${moduleName}`];
        if (module) {
          resolve(module);
        } else {
          reject(new Error(`Module ${moduleName} not found in remote`));
        }
      };
      
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }
  
  // 暴露模块
  expose(name, module) {
    this.exposes.set(name, module);
    // 暴露到全局(供远程访问)
    window[`${this.config.name}_${name}`] = module;
  }
}

// 使用示例
// App 1 配置
const federation1 = new ModuleFederation({
  name: 'app1',
  exposes: {
    './Button': './src/components/Button.js'
  },
  shared: {
    react: { singleton: true, version: '17.0.0' },
    'react-dom': { singleton: true, version: '17.0.0' }
  }
});

// App 2 配置
const federation2 = new ModuleFederation({
  name: 'app2',
  remotes: {
    app1: 'http://localhost:3001'
  },
  shared: {
    react: { singleton: true, version: '17.0.0' }
  }
});

// App2中使用App1的模块
federation2.getModule('app1', 'Button').then(Button => {
  // 使用远程Button组件
  console.log('Remote Button loaded:', Button);
});
七、模块化性能优化
7.1 代码分割与懒加载
class CodeSplitter {
  constructor() {
    this.chunks = new Map();
    this.loadedChunks = new Set();
  }
  
  // 定义代码分割点
  defineChunk(name, getChunk) {
    this.chunks.set(name, getChunk);
  }
  
  // 懒加载代码块
  async loadChunk(name) {
    if (this.loadedChunks.has(name)) {
      return;
    }
    
    const getChunk = this.chunks.get(name);
    if (!getChunk) {
      throw new Error(`Chunk ${name} not found`);
    }
    
    // 标记为加载中
    this.loadedChunks.add(name);
    
    try {
      await getChunk();
      console.log(`Chunk ${name} loaded`);
    } catch (error) {
      this.loadedChunks.delete(name);
      throw error;
    }
  }
  
  // 预加载代码块
  preloadChunk(name) {
    if (this.loadedChunks.has(name)) return;
    
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'script';
    
    const getChunk = this.chunks.get(name);
    if (getChunk && getChunk.chunkPath) {
      link.href = getChunk.chunkPath;
      document.head.appendChild(link);
    }
  }
}

// Webpack动态导入兼容
function dynamicImport(modulePath) {
  if (typeof __webpack_require__ !== 'undefined') {
    // Webpack环境
    return import(/* webpackChunkName: "[request]" */ modulePath);
  } else {
    // 原生环境
    return import(modulePath);
  }
}

// 使用示例
const splitter = new CodeSplitter();

// 定义代码块
splitter.defineChunk('dashboard', () => 
  dynamicImport('./Dashboard.js')
);

splitter.defineChunk('analytics', () => 
  dynamicImport('./Analytics.js')
);

// 路由懒加载
async function loadRoute(routeName) {
  switch (routeName) {
    case 'dashboard':
      await splitter.loadChunk('dashboard');
      break;
    case 'analytics':
      await splitter.loadChunk('analytics');
      break;
  }
}

// 预加载
window.addEventListener('mouseover', (e) => {
  if (e.target.href && e.target.href.includes('dashboard')) {
    splitter.preloadChunk('dashboard');
  }
});
7.2 模块缓存策略
class ModuleCache {
  constructor() {
    this.cache = new Map();
    this.ttl = 5 * 60 * 1000; // 5分钟
    this.maxSize = 100; // 最大缓存模块数
  }
  
  // 获取模块
  async get(key, fetchModule) {
    const cached = this.cache.get(key);
    
    // 检查缓存是否有效
    if (cached && Date.now() - cached.timestamp < this.ttl) {
      console.log(`Cache hit: ${key}`);
      return cached.module;
    }
    
    // 缓存失效或不存在,重新获取
    console.log(`Cache miss: ${key}`);
    const module = await fetchModule();
    
    // 更新缓存
    this.set(key, module);
    
    return module;
  }
  
  // 设置缓存
  set(key, module) {
    // 清理过期缓存
    this.cleanup();
    
    this.cache.set(key, {
      module,
      timestamp: Date.now()
    });
  }
  
  // 清理缓存
  cleanup() {
    const now = Date.now();
    
    // 清理过期
    for (const [key, value] of this.cache) {
      if (now - value.timestamp > this.ttl) {
        this.cache.delete(key);
      }
    }
    
    // 清理超出大小限制的(LRU策略)
    if (this.cache.size > this.maxSize) {
      const entries = Array.from(this.cache.entries());
      entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
      
      for (let i = 0; i < entries.length - this.maxSize; i++) {
        this.cache.delete(entries[i][0]);
      }
    }
  }
  
  // 清空缓存
  clear() {
    this.cache.clear();
  }
}

// 使用示例
const moduleCache = new ModuleCache();

async function loadModuleWithCache(modulePath) {
  return moduleCache.get(modulePath, async () => {
    const response = await fetch(modulePath);
    return response.text();
  });
}

八、模块化最佳实践与工程化

8.1 模块设计原则
// 1. 单一职责原则
// 不好的例子
class UserManager {
  // 混合了用户管理、验证、通知等多个职责
}

// 好的例子
class UserRepository {
  // 只负责数据访问
}

class UserValidator {
  // 只负责验证
}

class UserNotifier {
  // 只负责通知
}

// 2. 依赖注入
class UserService {
  constructor(userRepository, validator, notifier) {
    this.userRepository = userRepository;
    this.validator = validator;
    this.notifier = notifier;
  }
  
  async register(user) {
    if (!this.validator.validate(user)) {
      throw new Error('Invalid user');
    }
    
    await this.userRepository.save(user);
    await this.notifier.sendWelcome(user.email);
  }
}

// 3. 接口抽象
// 定义接口
class IStorage {
  async save(key, value) {}
  async get(key) {}
  async delete(key) {}
}

// 具体实现
class LocalStorage extends IStorage {
  async save(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  }
  
  async get(key) {
    return JSON.parse(localStorage.getItem(key));
  }
  
  async delete(key) {
    localStorage.removeItem(key);
  }
}

class APIService {
  constructor(storage) {
    if (!(storage instanceof IStorage)) {
      throw new Error('Invalid storage implementation');
    }
    this.storage = storage;
  }
}
8.2 模块版本管理与升级
class ModuleVersionManager {
  constructor() {
    this.versions = new Map();
    this.deprecations = new Map();
  }
  
  // 注册模块版本
  register(moduleName, version, module) {
    if (!this.versions.has(moduleName)) {
      this.versions.set(moduleName, new Map());
    }
    
    this.versions.get(moduleName).set(version, module);
  }
  
  // 获取模块(支持语义化版本)
  get(moduleName, versionRange = 'latest') {
    const moduleVersions = this.versions.get(moduleName);
    if (!moduleVersions) {
      throw new Error(`Module ${moduleName} not found`);
    }
    
    if (versionRange === 'latest') {
      const latestVersion = Array.from(moduleVersions.keys())
        .sort(this.compareVersions)
        .pop();
      return moduleVersions.get(latestVersion);
    }
    
    // 简化的版本范围解析
    const availableVersions = Array.from(moduleVersions.keys())
      .filter(v => this.satisfiesVersion(v, versionRange))
      .sort(this.compareVersions);
    
    if (availableVersions.length === 0) {
      throw new Error(`No version of ${moduleName} satisfies ${versionRange}`);
    }
    
    return moduleVersions.get(availableVersions.pop());
  }
  
  // 比较版本
  compareVersions(v1, v2) {
    const parts1 = v1.split('.').map(Number);
    const parts2 = v2.split('.').map(Number);
    
    for (let i = 0; i < 3; i++) {
      if (parts1[i] !== parts2[i]) {
        return parts1[i] - parts2[i];
      }
    }
    
    return 0;
  }
  
  // 检查版本是否满足范围
  satisfiesVersion(version, range) {
    // 简化实现,实际应使用semver库
    if (range === '*') return true;
    
    const [op, versionRange] = range.match(/^([>=<~^]*)(\d+\.\d+\.\d+)$/).slice(1);
    const vParts = version.split('.').map(Number);
    const rParts = versionRange.split('.').map(Number);
    
    switch (op) {
      case '^': // 兼容版本
        return vParts[0] === rParts[0] && vParts[1] >= rParts[1];
      case '~': // 近似版本
        return vParts[0] === rParts[0] && 
               vParts[1] === rParts[1] && 
               vParts[2] >= rParts[2];
      case '>=':
        return this.compareVersions(version, versionRange) >= 0;
      case '>':
        return this.compareVersions(version, versionRange) > 0;
      case '<=':
        return this.compareVersions(version, versionRange) <= 0;
      case '<':
        return this.compareVersions(version, versionRange) < 0;
      default:
        return version === versionRange;
    }
  }
  
  // 弃用通知
  deprecate(moduleName, version, message) {
    if (!this.deprecations.has(moduleName)) {
      this.deprecations.set(moduleName, new Map());
    }
    
    this.deprecations.get(moduleName).set(version, {
      message,
      deprecatedAt: new Date()
    });
    
    // 添加控制台警告
    console.warn(`Module ${moduleName}@${version} is deprecated: ${message}`);
  }
}

// 使用示例
const versionManager = new ModuleVersionManager();

// 注册不同版本
versionManager.register('utils', '1.0.0', {
  oldMethod: () => 'old'
});

versionManager.register('utils', '1.1.0', {
  oldMethod: () => 'old',
  newMethod: () => 'new'
});

versionManager.register('utils', '2.0.0', {
  newMethod: () => 'new',
  betterMethod: () => 'better'
});

// 标记弃用
versionManager.deprecate('utils', '1.0.0', '请升级到1.1.0+版本');

// 获取模块
const utilsV1 = versionManager.get('utils', '^1.0.0');
console.log(utilsV1); // 1.1.0版本

const utilsLatest = versionManager.get('utils');
console.log(utilsLatest); // 2.0.0版本

总结

模块化是现代前端工程化的基石,从前端的脚本标签到ES Modules,再到模块联邦等高级模式,模块化技术不断演进。本文从简单模块加载器实现开始,逐步深入AMD、CMD规范,探讨ES Module的Polyfill技术,并补充了构建工具集成、模块联邦、性能优化等工程化实践。

关键要点总结:

  1. 模块加载器核心原理: 依赖管理、缓存、异步加载
  2. 规范演进: 从AMD/CMD到ES Modules的统一
  3. 工程化实践: 代码分割、懒加载、版本管理、依赖注入
  4. 未来趋势: 模块联邦、微前端架构、Web Assembly模块化

模块化不仅仅是技术选择,更是一种设计哲学。良好的模块化设计能够提升代码的可维护性、可测试性和团队协作效率。在实际项目中,应根据团队规模、项目复杂度和技术栈选择合适的模块化方案,并不断优化模块边界和依赖关系。

随着前端技术的不断发展,模块化将继续演进,但核心原则——关注点分离、接口抽象、依赖管理——将始终保持不变。掌握模块化的核心原理和实践,能够帮助开发者构建更健壮、可维护的前端应用。

从零搭一个 Vue 小家:用 Vite + 路由轻松入门现代前端开发

从零开始,轻松走进 Vue 的世界:一个“全家桶”小项目的搭建之旅

如果你刚刚接触前端开发,听到“Vue”、“Vite”、“路由”这些词时是不是有点懵?别担心!我们可以把写代码想象成搭积木、装修房子、甚至安排一场家庭旅行。今天,我们就通过一个名为 all-vue 的小项目,带你一步步理解现代 Vue 应用是怎么“搭起来”的。


🏠 第一步:选好地基——用 Vite 快速建项目

什么是vite?

Vite(法语,意为“快”)是一个由 Vue.js 作者 尤雨溪(Evan You) 主导开发的现代化前端构建工具。它旨在解决传统打包工具(如 Webpack)在开发阶段启动慢、热更新(HMR)延迟高等问题,提供极速的开发体验。

想象你要盖一栋房子。传统方式可能要先打地基、砌砖、铺电线……繁琐又耗时。而 Vite 就像一位超级高效的建筑承包商,你只要说一句:“我要一个 Vue 房子”,它立刻给你搭好框架,连水电都通好了!

在终端里运行:

npm init vite@latest all-vue -- --template vue

几秒钟后,你就得到了一个结构清晰的项目目录。其中最关键的是:

  • index.html:这是你房子的“大门”,浏览器一打开就看到它。
  • src/main.js:这是整栋房子的“总开关”,负责启动整个应用。
  • src/App.vue:这是“客厅”,所有房间(页面)都要从这里进出。

Vite 的优势在于——修改代码后,浏览器几乎瞬间刷新,就像你换了个沙发,家人马上就能坐上去试舒服不舒服。


🏗️ 第二步:认识整栋楼——项目结构概览

运行 npm init vite@latest all-vue -- --template vue 后,你会得到这样一栋“数字公寓”:

项目结构简略预览:

/all-vue
├── public/            # 公共资源(如 logo.png)
├── src/
│   ├── assets/        # 图片、字体等静态资源
│   ├── components/    # 可复用的小部件(按钮、卡片等)
│   ├── views/         # 独立页面(首页、关于页等)
|   |     |—— About.vue # 关于页面的Vue组件
|   |     |—— Home.vue # 主页的vue组件
│   ├── router/        # 室内导航系统
|   |     |—— index.js # 路由总控
│   ├── App.vue        # 中央控制台(客厅)
│   └── main.js        # 智能钥匙
├── index.html         # 入户大门
├── package.json       # 公寓的“住户手册 + 装修清单”
└── vite.config.js     # 建筑规范说明书

其中,package.json 就像这栋楼的住户手册 + 装修材料清单。打开它,你会看到:

{
  "name": "all-vue",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "vite": "^5.0.0"
  }
}
  • dependencies:这是“入住必需品”,比如 Vue 框架本身、路由系统——没有它们,房子没法正常运转;
  • devDependencies:这是“装修工具包”,只在开发时用(比如 Vite 构建工具),住户入住后就不需要了;
  • scripts:这是“快捷指令”,比如 npm run dev 就是“启动预览模式”,npm run build 是“打包交付”。

有了这份清单,任何开发者都能一键还原你的整套环境——就像照着宜家说明书组装家具一样可靠。


🚪 第三步:认识“大门”——index.html 的两个秘密

虽然现代 Vue 应用的逻辑几乎全在 JavaScript 和 .vue 文件里,但一切的起点,其实是这个看似简单的 index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>all-vue</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

别小看这十几行代码,它藏着两个关键设计:

🔌 1. <div id="app"></div>:Vue 的“插座”

你可以把它想象成墙上预留的一个智能插座面板。它本身空无一物,但一旦通电(Vue 应用启动),就会自动“投影”出整个用户界面。

main.js 中,我们这样写:

createApp(App).mount('#app')

这句话的意思就是:“请把 App.vue 这个‘客厅’的内容,投射到 id 为 app 的那个插座上。”
没有这个插座,Vue 再厉害也无处施展;有了它,动态内容才能在静态 HTML 中生根发芽。

⚡ 2. <script type="module" src="/src/main.js"></script>:原生 ES 模块的魔法

注意这里的 type="module"。这是现代浏览器支持的一种原生模块加载方式。传统脚本是“一股脑全塞进来”,而模块化脚本则像快递包裹——每个文件独立打包,按需引用,互不干扰。

Vite 正是利用了这一特性,无需打包即可直接在浏览器中运行模块化的代码。这意味着:

  • 开发时启动飞快(冷启动快);
  • 修改文件后热更新极快(HMR 精准替换);
  • 代码结构清晰,符合现代工程规范。

所以,index.html 不仅是入口,更是连接静态 HTML 世界动态 Vue 世界的桥梁。


🔑 第四步:打造“钥匙”——main.js 如何启动应用

有了大门,就得有钥匙。main.js 就是这把精密的电子钥匙,负责激活整套智能家居系统:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'

createApp(App).use(router).mount('#app')

这段代码做了三件事,环环相扣:

  1. 引入核心模块:从 Vue 拿到“造房子”的工具(createApp),从本地拿到“客厅设计图”(App.vue)和“导航系统”(router);
  2. 组装系统:用 .use(router) 把导航插件装进主程序;
  3. 插入插座.mount('#app') 表示:“请把这套系统通电安装在 index.html 中 id 为 app 的插座上。”

没有这把钥匙,再漂亮的客厅也只是一堆图纸;有了它,整个房子才真正“活”起来。


💡 第五步:点亮客厅——根组件 App.vue

钥匙转动,门开了,我们走进 App.vue —— 这是所有功能的总控中心:

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view />
  </div>
</template>

多人一开始会直接写 <div>Home | About</div>,但这只是静态文字。要让它们变成可点击的导航,就得用 Vue Router 提供的 <router-link> 组件。

这里有两个核心元素:

  • <router-link> :智能门把手,点击不刷新页面,只切换内容;
  • <router-view /> :魔法地板,当前该展示哪个房间,它就实时投影出来。

虽然原始文件只写了 HomeAbout,但正确的写法应如上所示——让文字变成可交互的导航。


🗺️ 第六步:装上导航系统——配置 Vue Router

路由,就像是你家里的智能导航系统。没有它,你只能待在客厅;有了它,你才能自由穿梭于各个房间。

我们在 src/router/index.js 中这样配置:

import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/about', name: 'About', component: About }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router

这段代码的意思是:

  • 当用户访问 /(也就是主页),就显示 Home.vue 这个房间;
  • 当用户访问 /about,就带他去 About.vue 那个房间。

注意这里用了 createWebHashHistory(),这意味着网址会变成 http://localhost:5173/#/about。那个 # 就像门牌号里的“分隔符”,告诉系统:“后面的部分是内部房间号,不是新地址”。


🛋️ 第七步:布置房间——编写页面组件

现在,我们来装修两个房间。

首页(Home.vue)

<template>
  <div>
    <h1>Home</h1>
  </div>
</template>

关于页(About.vue)

<template>
  <div>
    <h1>About</h1>
  </div>
</template>

每个 .vue 文件都是一个自包含的“功能单元”:有自己的结构(template)、逻辑(script)和样式(style)。它们彼此隔离,却能通过路由无缝切换。


🎨 第八步:美化家园——全局样式 style.css

虽然功能齐备,但房子还是灰扑扑的。这时候,style.css 就派上用场了。你可以在这里写:

body {
  font-family: 'Arial', sans-serif;
  background-color: #f5f5f5;
}

nav {
  padding: 1rem;
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

就像给墙壁刷漆、给地板打蜡,让整个家更温馨舒适。


▶️ 最后一步:启动你的 Vue 家园!

现在,所有“装修材料”都已就位——地基打好了(Vite 项目)、大门装上了(index.html)、钥匙配好了(main.js)、客厅布置妥当(App.vue),连房间(Home.vueAbout.vue)和导航系统(Vue Router)也都调试完毕。是时候打开电闸,点亮整栋房子了!

请在终端(命令行)中依次执行以下两条命令(确保你已在 all-vue 项目目录下):

# 第一步:安装“住户手册”里列出的所有依赖(比如 Vue 和路由)
npm install

# 第二步:启动开发服务器——相当于按下“智能家居总开关”
npm run dev

运行成功后,你会看到类似这样的提示:

  VITE v5.0.0  ready in 320 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

这时,只需打开浏览器,访问 http://localhost:5173/ (端口号可能略有不同),就能看到你的 Vue 小家啦!

image.png

  • 点击 Home,客厅中央显示 “Home”;
  • 点击 About,瞬间切换到 “About” 页面——全程无需刷新,就像在家自由走动一样丝滑。

🎉 恭喜你!你不仅看懂了代码,还亲手让它跑起来了!

这不再是一堆抽象的文件,而是一个真正能交互的 Web 应用。你已经完成了从“零”到“一”的飞跃——而这,正是所有伟大项目的起点。


🧩 总结:Vue 项目的“生活化”逻辑链

让我们用一次智能家居入住体验来串起全过程:

  1. Vite 是开发商:提供标准化精装修样板间;
  2. index.html 是入户门:设有智能插座(#app)和模块化接线口(type="module");
  3. main.js 是电子钥匙:插入后激活整套系统;
  4. App.vue 是中央控制台:集成导航与内容展示区;
  5. Vue Router 是室内导航图:定义各房间路径;
  6. Home.vue / About.vue 是功能房间:各自独立,按需进入;
  7. style.css 是全屋软装方案:统一视觉风格。

✨ 写在最后:你已经站在 Vue 的门口

这个 all-vue 项目虽小,却包含了现代 Vue 应用的核心骨架:组件化 + 路由 + 响应式 + 工程化构建。你不需要一开始就懂所有细节,就像学骑自行车,先扶稳车把,再慢慢蹬脚踏。

当你运行 npm run dev,看到浏览器里出现“Home”和“About”两个链接,并能自由切换时——恭喜你,你已经成功迈出了 Vue 开发的第一步!

接下来,你可以:

  • 在 Home 里加一张图片;
  • 在 About 里写一段自我介绍;
  • 用 CSS 让导航栏变彩色;
  • 甚至添加第三个页面……

编程不是魔法,而是一步步搭建的过程。而你,已经搭好了第一块积木。

爬楼梯?不,你在攀登算法的珠穆朗玛峰!

爬楼梯?不,你在攀登算法的珠穆朗玛峰!

一道看似“幼儿园难度”的面试题:
“每次能爬1阶或2阶,问爬到第n阶有几种方法?”
却暗藏递归、动态规划、记忆化、空间优化四大内功心法——
它不是考你会不会算数,而是看你有没有系统性思维


🧗‍♂️ 初见:天真递归 —— “我能行!”(然后爆栈了)

最直觉的解法?当然是递归!

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

逻辑完美

  • 要到第 n 阶,要么从 n-1 上来,要么从 n-2 跳上来
  • 所以 f(n) = f(n-1) + f(n-2) —— 这不就是斐波那契?

但问题来了:
当你调用 climbStairs(45),电脑会疯狂重复计算:

  • f(43) 被算两次
  • f(42) 被算三次
  • ……
    时间复杂度 O(2ⁿ) —— 指数爆炸!

就像你让一个人背完整本字典来查一个词——可行,但荒谬。


🧠 进阶:记忆化递归 —— “我记住了!”

既然重复计算是罪魁祸首,那就把算过的答案存起来

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (memo[n]) return memo[n]; // ← 关键:查缓存!
  memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
  return memo[n];
}

效果:每个 f(k) 只算一次 → 时间复杂度 O(n)
思想空间换时间,典型的自顶向下动态规划(Top-down DP)

但有个小瑕疵:memo 是全局变量,容易被污染。


🔒 优雅封装:闭包 + 记忆化 —— “我的缓存,外人别碰!”

闭包memo 私有化,打造一个“智能函数”:

const climbStairs = (function() {
  const memo = {}; // ← 外部无法访问!
  return function climb(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n]) return memo[n];
    memo[n] = climb(n - 1) + climb(n - 2);
    return memo[n];
  };
})();

优势

  • 多次调用共享缓存(越用越快)
  • 状态私有,安全可靠
  • 接口干净:用户只需 climbStairs(n)

这不是函数,这是一个会学习、有记忆、懂封装的智能体


🚀 终极优化:自底向上 + 滚动变量 —— “我不需要递归!”

其实,我们根本不需要递归,也不需要存所有中间值!

观察规律:

f(1) = 1
f(2) = 2
f(3) = f(2) + f(1) = 3
f(4) = f(3) + f(2) = 5
...

只需要两个变量,就能滚动计算出结果:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  
  let prevPrev = 1; // f(i-2)
  let prev = 2;     // f(i-1)
  
  for (let i = 3; i <= n; i++) {
    const current = prev + prevPrev; // f(i)
    prevPrev = prev;   // 滚动窗口
    prev = current;
  }
  
  return prev;
}

时间复杂度:O(n)
空间复杂度:O(1) —— 极致优化!
无递归:避免调用栈溢出(n 很大时更安全)

这就是自底向上的动态规划(Bottom-up DP) —— 从已知出发,一步步推导未知。


📊 四种解法对比

方法 时间复杂度 空间复杂度 是否递归 适用场景
暴力递归 O(2ⁿ) O(n) 教学演示
记忆化递归 O(n) O(n) 中等规模,逻辑清晰
闭包记忆化 O(n) O(n) 需要缓存复用
滚动变量 O(n) O(1) 生产环境首选

💡 面试加分回答

当面试官问这道题,你可以这样说:

“我会根据场景选择方案:

  • 如果是教学或快速原型,用记忆化递归,逻辑直观;
  • 如果是高性能生产环境,用滚动变量的迭代法,O(1) 空间且无栈溢出风险。
    此外,我还会考虑边界情况(如 n ≤ 0)、类型校验,以及是否需要支持‘每次可爬1~k阶’的扩展。”

——瞬间从“会写代码”升级到“有工程思维”。


🌟 结语:小题大智慧

“爬楼梯”从来不是一道数学题,而是一面镜子:

  • 它照出你是否理解递归的本质
  • 它检验你是否掌握动态规划的思想
  • 它考验你能否在简洁、性能、可维护性之间做权衡

下次再有人说“这题太简单”,你可以微笑回应:

“是啊,简单到能写出四种境界。”

而这,正是优秀工程师和普通 coder 的分水岭。

🚀别再卷 Redux 了!Zustand 才是 React 状态管理的躺平神器

Zustand VS Redux

在文章开始前咱们先唠嗑一下,各位平时用哪个更多点呢?大数据不会骗人:

首先GitHub上的 Star 数量比较: image.png

image.png

其次每周的下载数量比较:

image.png

image.png

显然,想必用Zustand的可能大概也许应该会居多(单纯看数据来讲)。那么明明Redux才是大哥,为啥被Zustand这个小弟后来居上了?

给大家一个表:

对比项 Redux(老牌流程派) Zustand(新晋清爽党)
上手门槛 高:得记 action type、reducer、Provider 等一堆概念 低:会用 React Hook 就能写,几行代码起手
代码量 多:改个 count 得写 action、reducer 一堆模板代码 少:创建 store + 组件调用,加起来不到 20 行
组件里怎么用 得用 useSelector 取数据 + useDispatch 发动作 直接 useStore( state => state.xxx ) 一步到位
要不要包 Provider 必须包:得用 <Provider store={store}> 裹整个 App 不用包:组件直接调用 store,省一层嵌套
适合场景 大型复杂项目(多人协作、状态逻辑多) 中小型项目 / 快速开发(想少写代码、快速落地)

相信看完表大家已经很明了了,那么如果还想深入了解的可以自行去搜搜,我们唠嗑就到这,开始今天的学习。

具体资料大家去官网看:

www.npmjs.com/package/zus…

www.npmjs.com/package/rea…

前言

想象一下:你正在开发一个 React 项目,Home 组件要改个数字,About 组件得同步显示,List 组件还要从接口拉数据 —— 要是每个组件都自己存状态,代码早乱成一锅粥了!今天咱们就用 Zustand 这个躺平神器,把这些组件串成丝滑的整体,顺便解锁 React 全局状态的 “极简玩法”

一、先搭个 “状态仓库”:Zustand 初体验

Zustand 是啥?你可以把它理解成一个 “共享储物柜”:组件们不用再互相传 props,直接从这个柜子里拿数据、调方法就行。

首先你需要下载Zustand(在开篇的资料里也可以找到~):

image.png

先看我们的第一个 “储物格”——count.js(负责管理计数状态):

// src/store/count.js
import { create } from "zustand";

// 用 create 造一个“状态仓库”
const useCountStore = create((set) => ({
    // 存数据:初始计数是0,还有个默认年龄19
    count: 0,
    age: 19,
    // 存方法:点一下计数+1(set会自动更新视图)
    increase: () => set((state) => ({ count: state.count + 1 })),
    // 传个参数,计数直接减val
    decrease: (val) => set((state) => ({ count: state.count - val }))
}))

export default useCountStore;

就这么几行,一个能 “存数据 + 改数据” 的全局状态就搞定了 —— 比 Redux 轻量到没朋友!

二、组件 “抢着用”:状态共享原来这么丝滑

有了仓库,组件们就能 “按需取货” 了。先看 Home 组件(负责操作计数):

// src/components/Home.jsx
import useCountStore from '../store/count.js'

export default function Home() {
    // 从仓库里“拿”count数据
    let count = useCountStore((state) => state.count);
    // 从仓库里“拿”increase、decrease方法
    const increase = useCountStore((state) => state.increase);
    const decrease = useCountStore((state) => state.decrease);
    return (
        <div>
            {/* 点按钮直接调仓库里的方法,不用传参! */}
            <button onClick={increase}>发送-{count}</button>
            <button onClick={() => decrease(10)}>减少-{count}</button>
        </div>
    )
}

再看 About 组件(负责显示计数):

// src/components/About.jsx
import useCountStore from "../store/count"

export default function About() {
    // 同样从仓库拿count,Home改了这里自动更!
    let count = useCountStore((state) => state.count);
    return (
        <div>
            <h2>title-{count}</h2>
        </div>
    )
}

点击前:

image.png

点击10次发送后:

image.png

刷新然后点击10次减少后:

image.png

你看你看你看看看,Home 点按钮改了 count,About 里的标题直接同步更新 —— 连 props 都不用传,这丝滑感谁用谁知道!

三、进阶玩法:状态里塞接口请求

光存数字才哪到哪,还不够炫!咱们给仓库加个 “拉接口” 的功能。先写 list.js(负责管理列表数据):

// src/store/list.js
import { create } from "zustand";

// 先写个请求接口的函数
const fetchApi = async () => {
    const response = await fetch('https://mock.mengxuegu.com/mock/66585c4db462b81cb3916d3e/songer/songer');
    const res = await response.json();
    return res.data; // async函数的return会变成Promise的resolve值
}

// 造个存列表的仓库
const useListStore = create((set) => ({
    list: [], // 初始列表是空数组
    // 存个“拉列表”的方法,里面调用接口
    fetchList: async () => {
        const res = await fetchApi();
        set({ list: res }) // 拿到数据后更新list
    }
}))

export default useListStore;

然后让 List 组件 用这个仓库:

// src/components/List.jsx
import { useEffect } from "react";
import useListStore from "../store/list"

export default function List() {
    // 从仓库拿list数据和fetchList方法
    const list = useListStore((state) => state.list);
    const fetchList = useListStore((state) => state.fetchList);

    // 组件一加载就调用接口拉数据
    useEffect(() => {
        fetchList()
    }, [])

    return (
        <div>
            {/* 拿到数据直接map渲染 */}
            {list.map((item) => {
                return <div key={item.name}>{item.name}</div>
            })}
        </div>
    )
}

接口数据就出现在浏览器上啦:

image.png

打开页面,List 组件会自动拉接口、存数据、渲染列表 —— 状态管理 + 接口请求,一套流程直接在仓库里包圆了!

四、最后一步:把组件都塞进 App

最后在 App.jsx 里把这些组件拼起来:

import Home from "./components/Home"
import About from "./components/About"
import List from "./components/List"

export default function App() {
    return (
        <div>
            <Home></Home>
            <About></About>
            <List></List>
        </div>
    )
}

image.png

启动项目,你会看到:About 显示着计数,List 自动渲染接口数据 —— 这就是 Zustand 给 React 带来的 “状态自由”

总结

Zustand 堪称 React 状态管理的 “轻骑兵”:无需写冗余的 reducer、不用嵌套 Provider 包裹组件树,几行代码就能搭建全局状态仓库。它剥离了传统状态管理的繁琐仪式感,让我们彻底摆脱模板代码的束缚,聚焦业务本身。

结语

相比 Redux 的 “厚重” 和 Context API 在高频更新下的性能短板,Zustand 就像一把恰到好处的 “瑞士军刀”,轻巧却锋利,用最简单的方式解决了 React 组件间的状态共享难题,让开发者能把更多精力放在业务逻辑本身,而不是状态管理的 “套路” 里。

好的工具从来不是炫技的枷锁,而是让开发者回归创造本身的桥梁。

从零到一:彻底搞定面试高频算法——“列表转树”与“爬楼梯”全解析

在前端面试中,算法往往是决定能否拿高薪的关键。很多同学一听到“算法”就头大,觉得那是天才玩的游戏。其实,大多数面试算法题考察的不是你的数学造诣,而是你对递归(Recursion)和逻辑处理的理解。

今天,我们就通过两个非常经典的面试真题—— “列表转树(List to Tree)”和“爬楼梯(Climbing Stairs)” ,带你从小白视角拆解算法的奥秘。

第一部分:列表转树 —— 业务中的“常青树”

1. 为什么要学这个?

在实际开发中,后端返回给我们的数据往往是“扁平化”的。比如一个省市区选择器,或者一个后台管理系统的左侧菜单导航。为了存储方便,数据库通常会存储为如下结构:

id parentId name
1 0 中国
2 1 北京
3 1 上海
4 2 东城区

但前端 UI 组件(如 Element UI 的 Tree 组件)需要的是一个嵌套的树形对象。如何把上面的表格数据转换成包含 children 的树?这就是面试官考察你的数据结构处理能力。

2. 解法一:暴力递归(最符合人类直觉)

核心逻辑:

  1. 遍历列表,找到根节点(parentId === 0)。
  2. 对于每一个节点,再去列表里找谁的 parentId 等于我的 id
  3. 递归下去,直到找不到子节点。
// 代码参考
function list2tree(list, parentId = 0) {
  const result = []; 
  list.forEach(item => {
    if (item.parentId === parentId) {
      // 这里的递归就像是在问:谁是我的孩子?
      const children = list2tree(list, item.id);
      if (children.length) {
        item.children = children;
      }
      result.push(item);
    }
  });
  return result;
}

小白避坑指南:

这种方法的复杂度是 O(n2)O(n^2)。如果列表有 1000 条数据,最坏情况下要跑 100 万次循环。面试官此时会问:“有没有更优的方法?”

3. 解法二:优雅的 ES6 函数式写法

如果你想让代码看起来更“高级”,可以利用 filtermap

function list2tree(list, parentId = 0) {
  return list
    .filter(item => item.parentId === parentId) // 过滤出当前的子节点
    .map(item => ({
      ...item, // 展开原有属性
      children: list2tree(list, item.id) // 递归寻找后代
    }));
}

4. 解法三:空间换时间(面试官最爱)

为了把时间复杂度降到 O(n)O(n),我们可以利用 Map 对象。Map 的查询速度极快,像是一个“瞬移器”。

思路:

  1. 先遍历一遍列表,把所有节点存入 Map 中,以 id 为 Key。
  2. 再遍历一遍,根据 parentId 直接从 Map 里把父节点“揪”出来,把当前节点塞进父节点的 children 里。
// 代码参考
function listToTree(list) {
    const nodeMap = new Map();
    const tree = [];

    // 第一遍:建立映射表
    list.forEach(item => {
        nodeMap.set(item.id, { ...item, children: [] });
    });

    // 第二遍:建立父子关系
    list.forEach(item => {
        const node = nodeMap.get(item.id);
        if (item.parentId === 0) {
            tree.push(node); // 根节点入队
        } else {
            // 直接通过 parentId 找到父亲,把儿子塞进去
            nodeMap.get(item.parentId)?.children.push(node);
        }
    });
    return tree;
}

优点: 只遍历了两遍列表。无论数据有多少,速度依然飞快。

第二部分:爬楼梯 —— 掌握算法的“分水岭”

如果说“列表转树”考察的是业务能力,那“爬楼梯”考察的就是编程思维

题目描述: 假设你正在爬楼梯。需要 nn 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

1. 自顶向下:递归的艺术

我们站在第 nn 阶往回看:

  • 要到达第 10 阶,你只能从第 9 阶跨 1 步上来,或者从第 8 阶跨 2 步上来。
  • 所以:f(10)=f(9)+f(8)f(10) = f(9) + f(8)

这就是著名的斐波那契数列公式

// 基础版
function climbStairs(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    return climbStairs(n - 1) + climbStairs(n - 2);
}

致命缺陷: 这个代码会跑死电脑。算 f(10)f(10) 时要算 f(9)f(9)f(8)f(8);算 f(9)f(9) 时又要算一遍 f(8)f(8)。大量的重复计算导致“爆栈”。

2. 优化:带备忘录的递归(记忆化)

我们可以准备一个“笔记本”(memo),算过的值就记下来,下次直接拿。

const memo = {};
function climbStairs(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n]) return memo[n]; // 翻翻笔记,有就直接给
    memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
    return memo[n];
}

3. 自底向上:动态规划(DP)

动态规划(Dynamic Programming)听起来很高大上,其实就是倒过来想。

我们不从 f(n)f(n) 往回找,而是从 f(1)f(1) 开始往后推:

  • f(1)=1f(1) = 1
  • f(2)=2f(2) = 2
  • f(3)=1+2=3f(3) = 1 + 2 = 3
  • f(4)=2+3=5f(4) = 2 + 3 = 5
function climbStairs(n) {
  if (n <= 2) return n;
  const dp = new Array(n + 1);
  dp[1] = 1;
  dp[2] = 2;
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]; // 每一个结果都是前两个的和
  }
  return dp[n];
}

4. 极致优化:滚动变量

既然 f(n)f(n) 只依赖前两个数,那我们连数组都不需要了,只需要三个变量在手里“滚”起来。

function climbStairs(n) {
    if(n <= 2) return n;
    let prePrev = 1; // f(n-2)
    let prev = 2;    // f(n-1)
    let current;
    for(let i = 3; i <= n; i++){
        current = prev + prePrev;
        prePrev = prev;
        prev = current;
    }
    return current;
}

这时的空间复杂度降到了 O(1)O(1),几乎不占用额外内存。

总结:小白如何精进算法?

通过这两道题,你应该能发现算法学习的规律:

  1. 先画图,后写码: 不管是树状结构还是楼梯台阶,画出逻辑图比直接写代码重要得多。
  2. 寻找重复子问题: 递归和 DP 的核心都在于把大问题拆解成一样的小问题。
  3. 从暴力到优化: 别指望一步写出最优解。先用最笨的方法写出来,再去思考如何减少重复计算。

一个定时器,理清 JavaScript 里的 this

本文将从最基础的对象方法中this的指向说起,深入剖析定时器中this“不听话” 的原因,再逐一讲解几种常见的 “救回this” 的方法,包括经典的var that = this、灵活的call/apply、实用的bind,以及 ES6 中更优雅的箭头函数。通过清晰的案例对比和原理分析,帮你彻底理清this的绑定规律,从此不再被this的指向问题困扰。

一、从最普通的对象方法说起:this 指向当前对象

先从最正常的场景看起:一个对象,里面有个方法,方法里打印 

this 和 this.name

var obj = {
  name: 'Cherry',
  func1: function () {
    console.log(this);
    console.log(this.name);
  }
};
obj.func1();

image.png

在这种通过“对象.方法()”调用的场景下:

  • this 指向的是 obj 本身
  • this.name 就是 "Cherry"

也就是说,只要是“谁点出来的函数,

this 一般就指向谁”。

这一点很多人都懂,真正乱的是下面这种情况。

二、一进定时器,this 就不听话了

把上面的对象稍微改一下:再加一个方法,里面开个定时器。

var obj = {
  name: 'Cherry',
  func1: function () {
    console.log(this.name);
  },
  func2: function () {
    console.log(this);   // 这里的 this 还是 obj
    setTimeout(function () {
      console.log(this); // 这里的 this 是谁?
      this.func1();      // 这里很多人第一反应是“调用不到”
    }, 3000);
  }
};
obj.func2();

运行之后你会发现:

image.png

  • func2 里面第一行 console.log(this) 打印的是 obj
  • 但是定时器回调里的 console.log(this),却不再是 obj,而是全局对象(浏览器里是 window,严格模式下甚至可能是 undefined

原因是:谁调用这个函数,

this 就指向谁

  • obj.func2() 是“对象.方法调用”,所以 this === obj
  • setTimeout 回调是“普通函数调用”,真正执行时类似 window.callback(),所以 this 又回到了全局

于是 

this.func1() 就出现了典型错误:
你以为是调用 obj.func1,实际上是在全局环境下找 func1。

三、三种常见的“救回 this”姿势

为了在定时器里还能拿到“外层的那个对象”,常见有三种写法。

1. 老派写法:var that = this

最早接触到的方案一般是这个:

var obj = {
  name: 'Cherry',
  func1: function () {
    console.log(this.name);
  },
  func2: function () {
    var that = this;   // 先把外层的 this 存起来
    setTimeout(function () {
      console.log(this);      // 这里还是全局对象
      that.func1();           // 用 that 调用
    }, 3000);
  }
};
obj.func2();

image.png

思路很直白:

  • 外层 this 是我们想要的对象
  • 回调内部再用一个变量 that 把它“闭包”住
  • 不再依赖回调里的 this,而是用 that 去调用

优点:

  • 所有环境都支持,ES5 就可以用
    缺点:
  • 可读性一般,多层回调时会出现 that = this / self = this 满天飞

2. call / apply:立即执行并指定 this

第二种方案是利用 Function.prototype.call / apply,它们有两个关键点:

  • 都是立即调用函数
  • 第一个参数是要绑定的 this

例如:

function show() {
  console.log(this.name);
}
var obj = { name: 'Cherry' };
show();             // this => window / undefined
show.call(obj);     // this => obj
show.apply(obj);    // this => obj

call 和 apply 的区别只在于传参方式

  • call(fnThis, arg1, arg2, ...)
  • apply(fnThis, [arg1, arg2, ...])

在和定时器结合时,有一种稍微“绕”一点的写法,会先用 call 执行一次,然后把返回的函数交给定时器:

setTimeout(function () {
  console.log(this);  // 这里的 this 被 call 成 obj
  this.func1();
  
  return function () {
    console.log(this);  // 这个函数真正被 setTimeout 调用时,this 又回到全局
  };
}.call(obj), 2000);

分析一下这个写法的流程:

  • .call(obj) 先立刻执行这段函数,里面的 this 是 obj
  • 这个函数里 this.func1() 能正常调用到 obj.func1
  • 它 return 的那个内部函数才是真正交给 setTimeout 的
  • 这个内部函数在将来执行时,又是一次“普通函数调用”,于是 this 再次回到全局

这种写法属于“利用 call 硬拉一次 

this 过来”,但在实际项目里,更常见的做法不是这样用 call,而是第三种:bind

3. bind:先订婚,后结婚

bind 和 call/apply 很容易混:

  • call/apply马上执行,并临时指定一次 this
  • bind不执行,而是返回一个“this 永远被绑死”的新函数

用一个简单对比看差异:

var obj = {
  name: 'Cherry',
  func1: function () {
    console.log(this.name);
  }
};
console.log(obj.func1.bind(obj)); // 打印的是一个新函数
console.log(obj.func1.call(obj)); // 打印的是 func1 的返回值(这里是 undefined)
const f = obj.func1.bind(obj);
f(); // 始终以 obj 作为 this 调用

image.png

套用一个比较形象的说法:

  • call/apply闪婚,当场拍板,函数当场执行完事
  • bind先订婚,先约定好将来的 this,真正结婚(执行)是以后

因此在定时器这种“将来才会执行”的场景,bind 非常自然:

var obj = {
  name: 'Cherry',
  func1: function () {
    console.log(this.name);
  },
  func2: function () {
    setTimeout(this.func1.bind(this), 3000);
  }
};
obj.func2();
  • this.func1.bind(this) 立即返回了一个新函数
  • 这个新函数里 this 被固定成当前对象
  • setTimeout 三秒后再执行它时,this 依然是那个对象

相较于 that = this 和“花里胡哨的 call 写法”,bind 在这种场景下是最容易读懂的一种。

四、箭头函数:不再创建自己的 this

还有一种办法,是直接 **放弃回调自己的 **

this,而是用外层的。

这就是箭头函数的做法:箭头函数不会创建自己的执行上下文,它的 this 完全继承自外层作用域

箭头函数的核心是没有自己的 this,它的 this 是词法绑定(定义时继承外层作用域的 this),而非动态绑定。

把前面的定时器改成箭头函数版本:

var obj = {
  name: 'Cherry',
  func1: function () {
    console.log(this);       // obj
    console.log(this.name);  // Cherry
  },
  func2: function () {
    console.log(this);       // obj
    setTimeout(() => {
      console.log(this);     // 依然是 obj
      this.func1();          // 也能正常调用
    }, 3000);
  }
};
obj.func2();
obj.func1();

image.png

这里的关键点是:

  • func2 里的 this 是对象本身
  • 箭头函数的 this 直接沿用 func2 的 this
  • 所以在箭头函数里,this 没有发生“跳变”,始终是那个对象

再结合一个简单的对比例子,看得更清楚。

1. 普通函数和箭头函数的 this 对比

// 普通函数
function normal() {
  console.log(this);
}
// 箭头函数
const arrow = () => {
  console.log(this);
};
normal(); // 非严格模式下 this => window
arrow();  // this 继承自定义它时所在的作用域(全局里一般也是 window / undefined)

image.png

再注意一个常被问到的问题:

  • 箭头函数不能作为构造函数使用,也就是说不能 new 一个箭头函数
    在实际代码里,如果你尝试 new func()(func 是箭头函数),会直接报错

2. 顶层箭头函数的 this

在普通脚本里,如果写一个顶层箭头函数:

const func = () => {
  console.log(this);
};
func();

image.png

这里的 

this 继承自顶层作用域:

  • 浏览器非模块脚本中,一般是 window
  • 严格模式 / ES 模块中,顶层 this 往往是 undefined

这也再次说明:箭头函数的 

this 完全取决于它被定义时所处的环境,而不是被谁调用。

3. 继承外层作用域的 this

const obj = {
  name: 'Cherry',
  func: function () {          // 普通函数,this === obj
    console.log('外层 this:', this);
    setTimeout(() => {         // 箭头函数,this 继承外层
      console.log('箭头函数 this:', this);
      console.log('name:', this.name);
    }, 1000);
  }
};
obj.func();

image.png

把 setTimeout 里的箭头函数换成普通函数,this 会丢失

setTimeout 中的回调函数是被 JavaScript 引擎 “独立调用” 的,而非作为某个对象的方法调用

五、小结 & 使用建议

把上面的内容串一下,可以得到这样一份“速记表”:


  • this 的基础规律

    • 谁调用,指向谁obj.method() 里 this === obj
    • 普通函数直接调用:fn() 里 this 是全局对象 / undefined(严格模式)
  • setTimeout / 回调里的

    this

    • 回调是普通函数调用,this 默认指向全局
    • 所以在对象方法里直接写 setTimeout(function () { this.xxx }),往往拿不到我们想要的对象
  • 三种修复方式的对比

    • var that = this

      • 利用闭包保存外层 this
      • 兼容最好,但代码略显“老派”
    • call/apply

      • 立即执行函数
      • 第一个参数用来指定 this
      • 适合“当场就要执行一次”的场景
    • bind

      • 返回“this 被锁死”的新函数,而不是立即执行
      • 非常适合“定时器、事件监听、回调”这些“稍后再执行”的情形
  • 箭头函数

    • 不创建自己的 this,只继承外层
    • 在对象方法中配合回调使用,能有效避免 this 跳来跳去
    • 不适合作为构造函数(不能 new
❌