LeetCode 的 30 天 JavaScript 课程实际上填补了空白
大多数编码挑战都教你如何解决难题。LeetCode 的 30 天 JavaScript 学习计划则有所不同:它向你展示了如何将拼图碎片变成砖块,用于构建现实世界的项目。
这种区别很重要。当你解决一个典型的算法问题时,你正在训练你的大脑进行抽象思考。但是当你实现一个去抖动函数或构建一个事件发射器时,你正在学习真正的软件是如何工作的。
我在自己完成挑战的过程中发现了这一点。这种体验不像是解决脑筋急转弯,更像是考古——揭示具体的现代 JavaScript 概念。每个部分都侧重于 JS 的另一个现代功能。
这个学习计划的奇特之处在于它不会教你 JavaScript。事实上,我相信你需要已经相当了解 JavaScript 才能从中受益。相反,它教的是如何使用 JavaScript 来解决实际的工程问题。
考虑一下 Memoize 挑战。从表面上看,它是关于缓存函数结果的。但你真正要学习的是为什么像 React 这样的库需要记忆化来有效地处理组件渲染。或者以 Debounce 问题为例——它不仅仅是实现延迟;它可以帮助你直接理解为什么每个现代前端框架、电梯以及基本上任何具有交互式 UI 的系统都需要这种模式。
这种对实用模式而不是语言基础的关注产生了一个有趣的限制;你需要处于以下两个位置之一才能受益:
连接计算机科学与软件工程
在学习计算机科学和从事软件工程实践之间会发生一些奇怪的事情。这种转变就像学习了多年的国际象棋理论,却发现自己在玩一种完全不同的游戏——规则不断变化,大多数动作都不在任何书中。
在计算机科学中,你会学习二叉树的工作原理。在软件工程中,你会花费数小时调试 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 概念;它向你展示了为什么每个数据库引擎都存在连接池。
以下是学习计划各部分与实际软件工程概念的粗略映射:
模式识别,而不是问题解决
让我们分析一些问题来展示这个学习计划的真正价值。
考虑一下 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
想象一下,你在电梯里,有一个人疯狂地反复按着“关门”按钮。
如果没有防抖功能:电梯每次按门时都会试图关闭门,这会导致门机构工作效率低下,甚至可能损坏。
具有防抖功能:电梯会等待乘客停止按压一段时间(比如说 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 天之后
理解这些模式仅仅只是开始。
真正的挑战是认识到何时以及如何在生产代码中应用它们。以下是我在实际遇到这些模式后发现的。
首先,这些模式很少单独出现。真正的代码库以挑战无法探索的方式将它们结合在一起。考虑从头开始实现的搜索功能。您可能会发现自己使用:
所有这些模式相互作用,产生了任何单一挑战都无法解决的复杂性。但是,亲自实现每个部分后,您就会大致了解整个实现应该如何运作。
与直觉相反,您将获得的最有价值的技能不是实现这些模式,而是在其他人的代码中识别它们。
最后的想法
完成本学习计划后,编码面试并不是你唯一能识别这些模式的地方。
您会在开源代码、同事的拉取请求中发现它们,并可能开始在您过去的项目中注意到它们。您可能之前已经实现过它们,甚至没有意识到。最重要的是,您会明白它们为什么在那里。
最初是为了解决难题,后来为了更深入地了解现代 JavaScript 生态系统。
本学习计划填补的正是这一空白:将理论知识与实际工程智慧相结合。