Underscore.js 整体设计思路与架构分析
源码分析: bgithub.xyz/lessfish/un…
官网中所带注释的源码:
核心架构模式
模块结构
Underscore.js 采用了 立即执行函数表达式 (IIFE) 作为核心模块结构,创建了一个封闭的作用域,避免了全局变量污染:
这种设计方式能够让 Underscore.js :
- 支持多种模块系统(CommonJS、AMD、全局变量)
- 提供 noConflict 方法,避免命名冲突
- 在不同环境中(浏览器、Node.js)正常工作
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ?
// 场景1:CommonJS 环境(Node.js)
module.exports = factory() :
typeof define === 'function' && define.amd ?
// 场景2:AMD 环境(如 RequireJS)
define('underscore', factory) :
// 场景3:无模块化的浏览器全局环境(兜底)
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, (function () {
var current = global._; // 保存当前全局的 _ 变量
var exports = global._ = factory(); // 把 underscore 挂载到全局 _
// 解决命名冲突的 noConflict 方法
exports.noConflict = function () { global._ = current; return exports; };
}()));
}(this, (function () {
// 核心实现...
})));
双模式 API 设计
Underscore.js 同时支持两种调用方式:
函数式调用
_.map([1, 2, 3], function(num) { return
num * 2; });
面向对象调用(链式)
_([1, 2, 3]).map(function(num) { return
num * 2; }).value();
这种设计通过以下核心构造函数实现:
function _$1(obj) {
if (obj instanceof _$1) return obj;
if (!(this instanceof _$1)) return new
_$1(obj);
this._wrapped = obj;
}
方法挂载机制
函数定义与收集
Underscore.js 首先将所有功能实现为独立函数,然后通过 allExports 对象统一收集:
var allExports = {
__proto__: null,
VERSION: VERSION,
restArguments: restArguments,
isObject: isObject,
// ... 其他函数
};
方法挂载
通过 mixin 方法,将所有函数同时挂载到构造函数和原型链上:
function mixin(obj) {
each(functions(obj), function(name) {
var func = _$1[name] = obj[name];
_$1.prototype[name] = function() {
var args = [this._wrapped];
push.apply(args, arguments);
return chainResult(this, func.apply
(_$1, args));
};
});
return _$1;
}
// 执行挂载
var _ = mixin(allExports);
这种设计使得:
- 所有函数既可以通过
_.func()方式调用 - 也可以通过
_().func()链式调用
数组方法集成
Underscore.js 还集成了原生数组的方法,分为两类:
变更方法(Mutator)
pop/push/reverse/shift/sort/splice/unshift 这些方法的核心是修改原数组(比如 push 往原数组加元素,shift 从原数组删第一个元素),执行后原数组本身变了,方法返回值只是 “操作结果”(比如 pop 返回删除的元素),而非新数组。
// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);
// 调用mutator方法push
arrWrapper.push(4);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组被修改)
each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
// 从数组原型(ArrayProto是Array.prototype的简写)获取对应的原生方法
var method = ArrayProto[name];
// 为包装类原型添加当前遍历的方法(如pop/push等)
_$1.prototype[name] = function() {
// 获取包装类实例中包裹的原始数组(_wrapped是包装类存储原始数据的核心属性)
var obj = this._wrapped;
// 仅当原始数组不为null/undefined时执行(避免空指针错误)
if (obj != null) {
// 调用原生数组方法,将当前方法的参数透传给原生方法,直接修改原数组
// apply作用:绑定方法执行上下文为原始数组obj,参数以数组形式传递
method.apply(obj, arguments);
// 特殊边界处理:修复shift/splice清空数组后可能残留的索引0问题
// 原因:部分场景下shift/splice把数组清空(length=0)后,obj[0]仍可能残留undefined
// 删除索引0可保证空数组的结构完全干净,符合原生数组的预期行为
if ((name === 'shift' || name === 'splice') && obj.length === 0) {
delete obj[0];
}
}
// 返回链式调用结果:保证调用该方法后仍能继续调用包装类的其他方法
// chainResult会根据是否开启链式调用,返回包装类实例(this)或修改后的原数组(obj)
return chainResult(this, obj);
};
});
function chainResult(instance, obj) {
return instance._chain ? _$1(obj).chain() : obj;
}
function chain(obj) {
var instance = _$1(obj);
instance._chain = true;
return instance;
}
访问方法(Accessor)
concat/join/slice 这些方法的核心是返回新结果,原数组完全不变(比如 concat 拼接后返回新数组,原数组还是原样;slice 截取后返回新子数组)。
// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);
// 2. 调用accessor方法concat
const newWrapper = arrWrapper.concat([5,6]);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组仍不变)
console.log(newWrapper._wrapped); // [1,2,3,4,5,6](新结果)
// 批量为自定义数组包装类的原型挂载数组非可变方法(不会修改原数组,返回新值)
// 目标:让自定义包装类实例调用这些方法时,获取原生方法的返回结果,并保持链式调用特性
each(['concat', 'join', 'slice'], function(name) {
// 从数组原型(ArrayProto)中获取对应的原生方法(如Array.prototype.concat)
// ArrayProto是Array.prototype的简写,常见于Underscore/Lodash等工具库
var method = ArrayProto[name];
// 为自定义数组包装类(_$1)的原型挂载当前遍历的方法
_$1.prototype[name] = function() {
// 获取包装类实例中包裹的原始数组/值(_wrapped是包装类存储原始数据的核心属性)
var obj = this._wrapped;
// 仅当原始数据不为null/undefined时执行原生方法(避免空指针错误)
if (obj != null) {
// 调用原生方法,透传参数并接收返回值
// 核心差异:这类方法不修改原数组,而是返回新值,因此需要用新值覆盖obj
obj = method.apply(obj, arguments);
}
// 返回链式调用结果:将新的返回值(obj)传入chainResult,保证链式调用的正确性
// 若开启链式则返回包装类实例,未开启则返回新的数组/值
return chainResult(this, obj);
};
});
链式调用实现
Underscore.js 的链式调用是其一大特色,通过以下机制实现:
调用 _.chain() 后,所有方法执行完都会通过 chainResult 返回「新的包装对象」(而非原始数据),因此可以继续调用原型上的方法;直到调用 value() 方法(需补充实现),取出 _wrapped 里的原始数据,结束链式。
举个栗子
看下链式调用如何工作:
// 链式调用示例:过滤出大于2的数,再乘以2,最后获取结果
var finalResult = _.chain([1, 2, 3, 4])
.filter(function(x) { return x > 2; })
.map(function(x) { return x * 2; })
.value();
console.log(finalResult); // 输出:[6, 8]
代码实现
下面看下核心代码是怎么实现的吧 ~
// 核心包装类 _$1(对应 Underscore 的 _ 函数)
function _$1(obj) {
// 如果是 _$1 实例,直接返回
if (obj instanceof _$1) return obj;
// 如果不是实例,创建实例并存储原始数据
if (!(this instanceof _$1)) return new _$1(obj);
this._wrapped = obj; // 存储被包装的原始数据(数组/对象)
this._chain = false; // 链式标记,默认关闭
}
// 结束链式:获取最终结果
_$1.prototype.value = function() {
return this._wrapped; // 取出包装对象里的原始数据
};
function chain(obj) {
var instance = _$1(obj);
instance._chain = true; // 开启链式标记
return instance;
}
function chainResult(instance, obj) {
// 关键判断:如果开启链式,返回新的包装对象(继续链式);否则返回原始数据
return instance._chain ? _$1(obj).chain() : obj;
}
// 模拟 Underscore 的 each/functions 工具函数(简化版)
function each(arr, callback) {
for (var i = 0; i < arr.length; i++) callback(arr[i], i);
}
function functions(obj) {
return Object.keys(obj).filter(key => typeof obj[key] === 'function');
}
function mixin(obj) {
each(functions(obj), function(name) {
var func = _$1[name] = obj[name]; // 挂载到 _$1 静态方法
_$1.prototype[name] = function() {
// 1. 构造参数:第一个参数是包装的原始数据 this._wrapped,后续是方法入参
var args = [this._wrapped];
push.apply(args, arguments);
// 2. 执行工具函数,得到结果
var result = func.apply(_$1, args);
// 3. 调用 chainResult,决定返回包装对象(链式)还是原始数据
return chainResult(this, result);
};
});
return _$1;
}
// 挂载常用工具方法(模拟 Underscore 的 filter/map)
mixin({
filter: function(arr, fn) {
return arr.filter(fn);
},
map: function(arr, fn) {
return arr.map(fn);
}
});
// 给 _$1 原型挂载 chain 方法(对应用户代码里的 instance.chain())
_$1.prototype.chain = function() {
return chain(this._wrapped);
};
核心设计特点
函数式编程风格
Underscore.js 采用函数式编程范式,提供了大量高阶函数:
-
纯函数 :如 map 、 filter 等,不修改原数据,避免污染原数据
-
函数工厂 :如 tagTester 、 createPredicateIndexFinder 等。会返回一个新函数的函数,用于复用函数逻辑,减少重复代码
// 函数工厂:生成检测特定类型的函数 const tagTester = function(tag) { // 返回新函数(检测类型) return function(obj) { return Object.prototype.toString.call(obj) === `[object ${tag}]`; }; }; // 生产具体的检测函数 _.isArray = tagTester('Array'); _.isObject = tagTester('Object'); _.isFunction = tagTester('Function'); // 使用 console.log(_.isArray([1,2])); // true console.log(_.isObject({a:1})); // true -
函数组合 :如 compose 函数,将多个函数组合成一个新函数,执行顺序为 “从右到左”,前一个函数的输出作为后一个函数的输入
// 函数组合核心实现 _.compose = function(...funcs) { return function(...args) { // 从右到左执行函数 return funcs.reduceRight((result, func) => [func.apply(this, result)], args)[0]; }; }; // 示例:先过滤大于2的数,再乘以2,最后求和 const filterBig = arr => _.filter(arr, x => x > 2); const double = arr => _.map(arr, x => x * 2); const sum = arr => _.reduce(arr, (a, b) => a + b, 0); // 组合函数:sum(double(filterBig(arr))) const process = _.compose(sum, double, filterBig); console.log(process([1,2,3,4])); // (3,4)→[6,8]→14 -
函数柯里化 :如 partial 函数,将多参数函数拆解为单参数函数链,可分步传参,延迟执行。
// 柯里化核心实现(简化版) _.partial = function(func, ...fixedArgs) { return function(...remainingArgs) { // 合并固定参数和剩余参数,执行原函数 return func.apply(this, fixedArgs.concat(remainingArgs)); }; }; // 示例:固定乘法的第一个参数为2(创建“乘以2”的函数) const multiply = (a, b) => a * b; const double = _.partial(multiply, 2); // 分步传参:先传2,后传3/4 console.log(double(3)); // 6 console.log(double(4)); // 8
跨环境兼容性
Underscore.js 设计了完善的跨环境兼容机制,核心是 先检测、后适配、再降级 的策略:
- 环境检测 :自动检测运行环境(浏览器、Node.js)
- 特性检测 :检测原生方法是否存在
- 优雅降级 :当原生方法不可用时,使用自定义实现
- IE 兼容性 :特别处理了 IE < 9 的兼容性问题
性能优化
Underscore.js 在设计中融入了多种性能优化策略:
-
缓存 :如 memoize 函数,缓存计算结果
// memoize 核心实现(简化版) _.memoize = function(func, hashFunction) { const cache = {}; // 缓存容器 hashFunction = hashFunction || function(args) { return args[0]; // 默认用第一个参数作为缓存key }; return function(...args) { const key = hashFunction.apply(this, args); // 缓存存在则直接返回,否则执行函数并缓存 if (!cache.hasOwnProperty(key)) { cache[key] = func.apply(this, args); } return cache[key]; }; }; // 示例:缓存斐波那契计算结果(避免重复递归) const fib = _.memoize(function(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }); console.log(fib(10)); // 55(首次计算,缓存结果) console.log(fib(10)); // 55(直接取缓存,无需计算) -
延迟执行 :如 debounce 、 throttle 函数
- debounce(防抖) :延迟执行函数,若短时间内重复触发,重置延迟(如搜索框输入、窗口 resize);
- throttle(节流) :限制函数在指定时间内仅执行一次(如滚动事件、按钮点击)。
// 防抖核心实现(简化版) _.debounce = function(func, wait) { let timeoutId; return function(...args) { clearTimeout(timeoutId); // 重置延迟 timeoutId = setTimeout(() => { func.apply(this, args); }, wait); }; }; // 示例:搜索框输入后500ms执行搜索 const search = _.debounce(function(keyword) { console.log('搜索:', keyword); }, 500); // 快速输入时,仅最后一次输入后500ms执行 search('a'); search('ab'); search('abc'); // 仅执行这一次 -
惰性求值 :通过链式调用实现 链式调用时,并非每一步都立即计算,而是延迟到最后一步
value ()才执行最终计算,减少中间临时数据的生成:// 惰性求值示例:链式调用仅在value()时执行最终逻辑 const result = _.chain([1,2,3,4]) .filter(x => x > 2) // 暂存逻辑,不立即执行 .map(x => x * 2) // 暂存逻辑,不立即执行 .value(); // 执行所有逻辑,返回结果 [6,8] -
原生方法优先 :当原生方法可用时,优先使用原生方法,JavaScript 原生方法(如
Array.prototype.map、Object.keys)由引擎底层实现(C++),比纯 JS 实现快得多。
可扩展性
Underscore.js 设计了良好的扩展机制:
-
mixin 方法 :允许用户添加自定义函数,可将自定义函数挂载到 Underscore 原型上,支持链式调用:
// 示例:自定义一个“求平方和”的方法,通过mixin挂载 _.mixin({ sumOfSquares: function(arr) { return _.reduce(arr, (sum, x) => sum + x * x, 0); } }); // 直接调用 + 链式调用都支持 console.log(_.sumOfSquares([1,2,3])); // 1+4+9=14 const result = _.chain([1,2,3]) .filter(x => x > 1) // [2,3] .sumOfSquares() // 4+9=13 .value(); console.log(result); // 13 -
自定义 iteratee :允许用户自定义迭代器行为
// 示例:自定义迭代器,处理对象数组的特定属性 const users = [ { name: '张三', age: 20 }, { name: '李四', age: 30 } ]; // 自定义迭代器:提取age属性并判断是否大于25 const ageIterator = user => user.age > 25; const result = _.filter(users, ageIterator); console.log(result); // [{ name: '李四', age: 30 }] -
模板系统 :支持自定义分隔符、变量插值规则,适配不同场景
// 示例:自定义模板分隔符(默认是<% %>,改为{{ }}) _.templateSettings = { evaluate: /{{(.+?)}}/g, // 执行代码:{{ code }} interpolate: /{{=(.+?)}}/g // 插值:{{= value }} }; // 使用自定义模板 const template = _.template('Hello {{= name }}! {{ if (age > 18) { }}成年{{ } else { }}未成年{{ } }}'); const html = template({ name: '张三', age: 20 }); console.log(html); // Hello 张三! 成年
缺点
性能层面的损耗
链式调用的额外开销
Underscore 链式调用依赖每次方法调用创建新的 _$1 包装对象,且需通过 value() 触发最终计算:
-
对象创建成本:每一步链式操作(如
map()/filter())都会实例化新的包装对象,频繁操作大型数据集时,内存分配和垃圾回收开销显著; -
原型链查找损耗:包装对象的方法挂载在
_$1.prototype上,每次调用需遍历原型链,效率低于原生方法的直接调用; -
对比示例:
_.chain([1,2,3]).map(x=>x*2).value()比原生[1,2,3].map(x=>x*2)多了「包装对象创建→原型链查找→结果重新包装」三层开销。
具体函数实现的效率瓶颈
-
类型检测冗余:早期版本未优先使用原生
Array.isArray(),而是通过Object.prototype.toString.call()做类型判断,效率比原生 API 低 30% 以上; -
遍历策略不优:统一用通用遍历逻辑处理数组 / 对象,未针对数组使用更高效的
for循环(而非for...in),对象遍历未优先用Object.keys()过滤原型属性; -
高阶函数调用栈开销:
map/filter等方法的迭代器需通过optimizeCb封装闭包,每次迭代都会产生函数调用栈损耗,而原生方法由引擎内联优化,无此开销。
闭包与内存占用
Underscore 基于 IIFE 封装核心逻辑,闭包会长期持有内部变量(如 _ 构造函数、mixin 缓存、工具函数):
- 即使仅使用
_.isArray()一个方法,整个闭包内的所有变量也无法被垃圾回收,造成内存冗余; - 非模块化环境下,全局挂载的
_变量常驻内存,进一步增加无意义的内存占用。
API 设计层面:一致性与易用性缺陷
双模式调用的混淆性
同时支持「函数式调用(_.map())」和「对象链式调用(_().map())」,带来双重问题:
-
学习成本高:新手需理解两种模式的底层差异(如链式模式依赖
_wrapped包装数据,函数式模式直接传参); -
行为不一致风险:部分方法在两种模式下参数传递有细微差异(如
_.reduce()链式调用时this指向包装对象,函数式调用时需手动传context)。
参数与行为的不一致性
-
参数顺序混乱:
_.reduce(collection, iteratee, [accumulator])与原生Array.prototype.reduce(callback, [initialValue])参数顺序相反,用户切换使用时易出错; -
边界处理不统一:对
null/undefined/ 空对象的处理逻辑混乱(如_.map(null)返回[],_.keys(null)抛出错误); -
可选参数模糊:
_.defaults()/_.extend()对默认值、浅拷贝的规则未明确标注,导致相同输入可能产生不同预期结果。
功能覆盖的冗余与缺失
-
冗余覆盖:部分方法(如
_.each())仅对原生方法做简单封装,无额外价值却增加调用层级; -
核心功能缺失:早期版本无原生的深拷贝方法(
_.cloneDeep()为后期补充),需手动嵌套_.extend()实现,易用性差。
生态兼容层面:与现代开发体系脱节
模块化适配严重不足
-
无原生 ES 模块支持:仅通过 IIFE 兼容 CommonJS/AMD,无法直接使用
import { map } from 'underscore'按需导入; -
树摇(Tree-shaking)失效:模块结构设计导致现代打包工具(Webpack/Rollup)无法移除未使用的函数,即使仅用
_.isArray(),也会打包整个库(约 5KB),而原生Array.isArray()无体积成本; -
对比 Lodash-es:
lodash-es/map可按需导入,体积仅几百字节,Underscore 无此能力。
现代语法与工具链适配差
-
旧语法的陈旧性:依赖构造函数 + 原型链实现包装对象(
_$1.prototype[name] = ...),与 ES6class语法脱节,现代开发者可读性差; -
箭头函数冲突:链式调用依赖
this指向包装对象,而箭头函数的词法this会导致this._wrapped报错,增加使用复杂度; -
框架集成不契合:在 React/Vue 等现代框架中,其函数式风格与框架响应式系统(如 Vue 的
ref/reactive)适配性差,不如 Lodash/Ramda 灵活。
类型系统支持缺失
-
无内置 TypeScript 类型:完全依赖第三方
@types/underscore,存在类型覆盖不全(如链式调用返回类型推断错误)、版本不匹配(库更新后类型定义滞后)等问题; - 类型安全不足:方法参数 / 返回值无类型约束,运行时易因类型错误导致 bug,而现代库(如 Lodash)原生支持 TS 类型。
扩展性与维护层面:原型链设计的硬伤
扩展机制的局限性
-
原型污染风险:
mixin方法直接挂载函数到_$1.prototype,若自定义方法名与内置方法冲突(如自定义map),会覆盖原生逻辑,导致意外行为; -
无结构化插件系统:扩展方式仅依赖
mixin,无法像 Vue/React 那样通过插件注册、生命周期管理复杂扩展,生态扩展性差。
代码结构与维护成本
-
闭包嵌套复杂:核心逻辑通过多层闭包封装,早期版本包含大量 “魔法逻辑”(如
optimizeCb优化迭代器),代码可读性极低; - 测试覆盖不全面:虽有基础测试用例,但跨环境(如旧版 IE)、边界场景(如空值 / 超大数组)的测试覆盖不足,修复 bug 易引入新问题;
- 兼容负担重:为适配 IE6+ 等老旧环境,保留大量冗余的兼容代码,无法精简核心逻辑。
功能设计层面:能力不足与场景覆盖不全
异步操作支持缺失
- 无原生支持
Promise/async/await,处理异步数据流(如接口请求→数据处理)时,需手动封装_.map+Promise.all,代码冗余; - 防抖 / 节流函数(
debounce/throttle)仅支持同步逻辑,无法处理异步回调的时序问题。
对象操作能力有限
-
浅拷贝局限:
_.extend()/_.defaults()仅支持浅拷贝,深度拷贝需手动实现或依赖第三方扩展,而原生structuredClone()或 Lodash_.cloneDeep()已原生支持; -
对象遍历低效:无针对嵌套对象的遍历方法(如
_.deepKeys),处理复杂对象需多层嵌套调用。
函数式特性不完整
-
柯里化 / 组合能力弱:仅通过
_.partial()模拟柯里化(无法自动柯里化多参数函数),_.compose()仅支持同步函数组合,无异步组合能力; -
惰性求值不彻底:链式调用虽有惰性特征,但仅在
value()时执行,无法像 Ramda 那样实现 “按需计算”,处理超大数据集时效率低。
安全性层面:潜在的风险隐患
原型污染风险
早期版本的 _.extend()/_.defaults() 未过滤 __proto__ 属性,若传入包含 __proto__: { evil: true } 的用户输入,会修改 Object.prototype,导致全局原型污染:
// 原型污染示例(旧版本 Underscore)
const obj = {};
_.extend(obj, { __proto__: { test: 123 } });
console.log({}.test); // 123(全局原型被污染)
模板注入隐患
_.template() 方法默认使用 eval 执行模板中的代码,若未过滤用户输入的模板字符串,易引发代码注入攻击:
// 模板注入风险
const userInput = "{{= alert('XSS') }}";
const template = _.template(userInput);
template(); // 执行恶意代码
时代适配层面:原生 ES6+ 的全面替代
核心功能被原生方法覆盖
ES6+ 引入的原生 API 完全覆盖 Underscore 核心能力,且性能更优(引擎级优化):
| Underscore 方法 | 原生替代方案 | 优势 |
|---|---|---|
_.map() |
Array.prototype.map() |
无包装对象开销,引擎内联优化 |
_.keys() |
Object.keys() |
原生实现,效率更高 |
_.extend() |
Object.assign() |
原生支持,无需额外依赖 |
_.debounce() |
浏览器原生 requestIdleCallback(或框架内置) |
更贴合现代浏览器调度机制 |
旧语法与现代开发习惯脱节
- 依赖
arguments对象处理参数(如_.partial()),而现代 JS 已支持剩余参数(...args),代码更简洁; - 构造函数 + 原型链的实现方式,与现代开发者熟悉的
class语法相悖,学习和维护成本高。
核心总结
Underscore.js 的所有缺点本质是 “早期设计无法适配现代 JavaScript 生态”:
- 性能层面:链式调用、闭包、低效实现带来多维度损耗,无法与原生引擎优化的 API 竞争;
- 生态层面:模块化、类型系统、现代工具链适配不足,无法满足现代工程化开发需求;
- 功能层面:异步、深拷贝、函数式特性的缺失,无法覆盖复杂业务场景;
- 安全 / 维护层面:原型污染、代码复杂、测试不足,增加生产环境风险。