LeetCode 的 30 天 JavaScript 课程实际上填补了空白

大多数编码挑战都教你如何解决难题。LeetCode 的 30 天 JavaScript 学习计划则有所不同:它向你展示了如何将拼图碎片变成砖块,用于构建现实世界的项目。

这种区别很重要。当你解决一个典型的算法问题时,你正在训练你的大脑进行抽象思考。但是当你实现一个去抖动函数或构建一个事件发射器时,你正在学习真正的软件是如何工作的。

我在自己完成挑战的过程中发现了这一点。这种体验不像是解决脑筋急转弯,更像是考古——揭示具体的现代 JavaScript 概念。每个部分都侧重于 JS 的另一个现代功能。

这个学习计划的奇特之处在于它不会教你 JavaScript。事实上,我相信你需要已经相当了解 JavaScript 才能从中受益。相反,它教的是如何使用 JavaScript 来解决实际的工程问题。

考虑一下 Memoize 挑战。从表面上看,它是关于缓存函数结果的。但你真正要学习的是为什么像 React 这样的库需要记忆化来有效地处理组件渲染。或者以 Debounce 问题为例——它不仅仅是实现延迟;它可以帮助你直接理解为什么每个现代前端框架、电梯以及基本上任何具有交互式 UI 的系统都需要这种模式。

这种对实用模式而不是语言基础的关注产生了一个有趣的限制;你需要处于以下两个位置之一才能受益:

  • 你了解计算机科学基础知识(尤其是数据结构和算法),并且熟悉 JavaScript
  • 你的计算机理论知识很强,并且有一定的 JavaScript 经验
  • 连接计算机科学与软件工程

    在学习计算机科学和从事软件工程实践之间会发生一些奇怪的事情。这种转变就像学习了多年的国际象棋理论,却发现自己在玩一种完全不同的游戏——规则不断变化,大多数动作都不在任何书中。

    在计算机科学中,你会学习二叉树的工作原理。在软件工程中,你会花费数小时调试 API,试图了解响应缓存不起作用的原因。从远处看,这两个世界之间的重叠似乎比实际情况要大得多。这之间存在差距,这通常会让刚开始职业生涯的计算机科学毕业生感到震惊。不幸的是,大多数教育资源都无法弥合这一差距。它们要么纯粹是理论性的(“快速排序的工作原理如下”),要么纯粹是实践性的(“部署 React 应用的方法如下”)。

    这个 JavaScript 学习计划之所以有趣,并不是因为它设计得特别好,而是因为它在这些世界之间建立了联系。以记忆化问题为例:**2623. 记忆化**。用计算机科学术语来说,它是关于缓存计算值。但实施它迫使你努力应对 JavaScript 在对象引用、函数上下文和内存管理方面的特殊性。突然间,

    你不只是在学习一种算法——你开始理解为什么存在像 Redis 这样的东西。

    这种风格贯穿了整个挑战。Event Emitter 实现不仅仅是教科书上的观察者模式——你可以把它看作是将 V8 引擎从浏览器中移除并围绕它构建 Node.js 的原因。Promise Pool 解决了并行执行问题,也就是数据库需要连接限制的原因。

    隐性课程

    本学习计划中的问题顺序并不是随机的。它是一层一层地构建现代 JavaScript 的思维模型。

    首先从闭包开始。这并不是因为闭包是最简单的概念(众所周知,闭包非常容易让人混淆),而是因为它们是 JavaScript 管理状态的基础。

    function createCounter(init) {
        let count = init;
        return function() {
            return count++;
        }
    }
    
    const counter1 = createCounter(10);
    console.log(counter1()); // 10
    console.log(counter1()); // 11
    console.log(counter1()); // 12
    
    // const counter1 = createCounter(10);
    // when this^ line executes:
    // - createCounter(10) creates a new execution context
    // - local variable count is initialized to 10
    // - a new function is created and returned
    // - this returned function maintains access 
    // to the count variable in its outer scope
    // - this entire bundle 
    // (function (the inner one) + its access to count) 
    // is what we call a closure

    这种模式是 JavaScript 中所有状态管理的种子。一旦您理解了此计数器的工作原理,您就会理解 React 的 useState 的底层工作原理。您就会明白为什么模块模式会出现在 ES6 之前的 JavaScript 中。

    然后计划转向函数转换。这些教你函数修饰——函数包装其他函数来修改它们的行为。这不仅仅是一个技术技巧;它是 Express 中间件的工作原理,React 高阶组件的运行方式,

    以及 TypeScript 装饰器的工作原理。

    当你遇到异步挑战时,你不仅仅是在学习 Promises - 你还在发现为什么 JavaScript 首先需要它们。Promise Pool 问题并没有教你一个创新、古怪的 JS 概念;它向你展示了为什么每个数据库引擎都存在连接池。

    以下是学习计划各部分与实际软件工程概念的粗略映射:

  • 闭包 → 状态管理
  • 基本数组变换 → 基本技能(辅助);实例:数据操作
  • 函数转换 → 中间件模式
  • 承诺与时间 -> 异步控制流
  • JSON -> 基础技能(辅助);实例:数据序列化、API 通信
  • 类(特别是在事件发射器的上下文中)→消息传递系统
  • 奖励(高级锁定)-> 混合更难的挑战,这些挑战本可以包含在上述部分中;Promise Pool4 是我最喜欢的一个
  • 模式识别,而不是问题解决

    让我们分析一些问题来展示这个学习计划的真正价值。

  • 记忆(#2623)
  • 考虑一下 Memoize 挑战。我喜欢它的原因在于,它能给出最好的解决方案(我能想到的)

    非常简单明了,就好像代码本身正在轻轻地告诉您它的作用(不过,我还是添加了一些注释)。

    无论如何,这不会使 #2623 成为一个简单的问题。我需要 2 次迭代才能使它变得如此干净:

    /**
     * @param {Function} fn
     * @return {Function}
     */
    function memoize(fn) {
        // Create a Map to store our results
        const cache = new Map();
    
        return function(...args) {
            // Create a key from the arguments
            const key = JSON.stringify(args);
    
            // If we've seen these arguments before, return cached result
            if (cache.has(key)) {
                return cache.get(key);
            }
    
            // Otherwise, calculate result and store it
            const result = fn.apply(this, args);
            cache.set(key, result);
            return result;
        }
    }
    
    const memoizedFn = memoize((a, b) => {
        console.log("computing...");
        return a + b;
    });
    
    console.log(memoizedFn(2, 3)); // logs "computing..." and returns 5
    console.log(memoizedFn(2, 3)); // just returns 5, no calculation
    console.log(memoizedFn(3, 4)); // logs "computing..." and returns 7
    
    
    // Explanantion:
    // It's as if our code had access to an external database
    
    // Cache creation
    // const cache = new Map();
    // - this^ uses a closure to maintain the cache between function calls
    // - Map is perfect for key-value storage
    
    // Key creation
    // const key = JSON.stringify(args);
    // - this^ converts arguments array into a string
    // - [1,2] becomes "[1,2]"
    // - we are now able to use the arguments as a Map key
    
    // Cache check
    // if (cache.has(key)) {
    //     return cache.get(key);
    // }
    // - if we've seen these arguments before, return cached result;
    // no need to recalculate
  • 防抖动 (#2627)
  • 想象一下,你在电梯里,有一个人疯狂地反复按着“关门”按钮。

    如果没有防抖功能:电梯每次按门时都会试图关闭门,这会导致门机构工作效率低下,甚至可能损坏。

    具有防抖功能:电梯会等待乘客停止按压一段时间(比如说 0.5 秒)后才真正尝试关门。这样效率更高。

    以下是另一种情况:

    想象一下,您正在实现一个搜索功能,当用户输入时获取结果:

    未使用去抖动功能:

    // typing "javascript"
    'j' -> API call
    'ja' -> API call
    'jav' -> API call
    'java' -> API call
    'javas' -> API call
    'javasc' -> API call
    'javascr' -> API call
    'javascri' -> API call
    'javascrip' -> API call
    'javascript' -> API call

    这将产生 10 次 API 调用。由于用户仍在输入,因此大多数调用都是无用的。

    具有去抖动功能(300ms 延迟):

    // typing "javascript"
    'j'
    'ja'
    'jav'
    'java'
    'javas'
    'javasc'
    'javascr'
    'javascri'
    'javascrip'
    'javascript' -> API call (only one call, 300ms after user stops typing)

    防抖就像告诉你的代码:“等到用户停止执行某项操作 X 毫秒后再实际运行此功能。”

    以下是 LeetCode #2627 的答案:

    /**
     * @param {Function} fn
     * @param {number} t milliseconds
     * @return {Function}
     */
    var debounce = function (fn, t) {
      let timeoutID = null;
      return function (...args) {
        clearTimeout(timeoutID); // MDN reference: https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout
        timeoutID = setTimeout(() => {
          fn.apply(this, args);
        }, t);
      };
    };
    
    // tests
    const log = debounce(console.log, 100);
    
    console.log("Starting tests...");
    
    setTimeout(() => log("Test 1"), 50); // should be cancelled
    setTimeout(() => log("Test 2"), 75); // should be cancelled
    setTimeout(() => log("Test 3"), 200); // should be logged at t=300ms
    
    setTimeout(() => {
      console.log("Tests completed.");
    }, 400);
    
    // Explanantion:
    // - we create a closure where timeoutID persists between calls
    // - timeoutID starts out as null
    // - we return the debounced version of the initial function
    
    // const log = debounce(console.log, 100);
    // - this^ creates a new closure with its own timeoutID
    // - log is now the inner function
    
    // setTimeout(() => log("Test 1"), 50);  // at t=50ms
    // setTimeout(() => log("Test 2"), 75);  // at t=75ms
    // setTimeout(() => log("Test 3"), 200); // at t=200ms
    
    // t=50ms: 
    // - log("Test 1") called
    // - clears timeoutId (null, so nothing happens)
    // - sets new timeout to run at t=150ms
    
    // t=75ms: 
    // - log("Test 2") called
    // - clears previous timeout (cancels "Test 1")
    // - sets new timeout to run at t=175ms
    
    // t=200ms:
    // - log("Test 3") called
    // - clears previous timeout (cancels "Test 2")
    // - sets new timeout to run at t=300ms
    
    // t=300ms:
    // - finally executes fn("Test 3")
    
    
    // why this works:
    // - the closure keeps timeoutId alive between calls
    // - every new call cancels the previous timeout
    // - only the last scheduled timeout actually runs

    其他常见的现实世界去抖动用例(搜索栏除外):

  • 保存草稿(等到用户停止编辑)
  • 提交按钮(防止重复提交)
  • 问题出在哪里

    我希望,从这篇文章整体积极的基调来看,我对**30 Days of JS** 的看法现在已经变得清晰了。

    但是没有一种教育资源是完美的,当谈到局限性时,诚实是有价值的。这个学习计划有几个盲点值得检查。

    首先,学习计划假设一定水平的先前知识。

    如果你还不熟悉 JavaScript,那么有些挑战可能会让你不知所措。这可能会让对学习计划有其他期望的初学者感到沮丧。

    其次,挑战是以孤立的方式呈现的。

    一开始这样做是有道理的,但随着计划的进展,你可能会感到失望。现实世界的问题通常需要结合多种模式和技术。学习计划可以从需要结合使用多个概念的更综合的挑战中受益(例外:我们确实在整个计划中使用了闭包)。这些可以很好地放在奖励部分(已经为高级用户保留)。

    最后,这组挑战的主要弱点在于其概念解释。从竞技编程的角度来看,

    我习惯在问题陈述中清晰地定义新术语和概念。然而,LeetCode 的描述往往不必要地复杂——理解他们对去抖动函数的解释比实现实际解​​决方案更难。

    尽管存在缺点,该学习计划对于理解现代 JavaScript 来说仍然是宝贵的资源。

    30 天之后

    理解这些模式仅仅只是开始。

    真正的挑战是认识到何时以及如何在生产代码中应用它们。以下是我在实际遇到这些模式后发现的。

    首先,这些模式很少单独出现。真正的代码库以挑战无法探索的方式将它们结合在一起。考虑从头开始实现的搜索功能。您可能会发现自己使用:

  • 输入处理去抖动
  • 用于结果缓存的记忆化
  • 承诺 API 调用超时
  • 用于搜索状态管理的事件发射器
  • 所有这些模式相互作用,产生了任何单一挑战都无法解决的复杂性。但是,亲自实现每个部分后,您就会大致了解整个实现应该如何运作。

    与直觉相反,您将获得的最有价值的技能不是实现这些模式,而是在其他人的代码中识别它们。

    最后的想法

    完成本学习计划后,编码面试并不是你唯一能识别这些模式的地方。

    您会在开源代码、同事的拉取请求中发现它们,并可能开始在您过去的项目中注意到它们。您可能之前已经实现过它们,甚至没有意识到。最重要的是,您会明白它们为什么在那里。

    最初是为了解决难题,后来为了更深入地了解现代 JavaScript 生态系统。

    本学习计划填补的正是这一空白:将理论知识与实际工程智慧相结合。

  • 2627. 防抖动 (承诺与时间) ↩
  • 2694. 事件发射器(类)↩
  • 2623. 记忆法 (函数转换) ↩
  • 2636. 承诺池(奖金)↩