jQuery 4.0 源码架构与工程实践-第 10 章 尺寸、几何与显隐

0 阅读43分钟

jQuery 4.0 源码架构与工程实践-第 10 章 尺寸、几何与显隐

本章导览

小节主题关键源码难度
10.1cssHooks 协议:get / set / expand6138 / 6257–6328★★★★
10.2getWidthOrHeight:尺寸的六条路径6061–6132★★★★★
10.3boxModelAdjustment:四盒模型的加减法5990–6059★★★★★
10.4setPositiveNumber 与负边距5978–5988★★
10.5jQuery.fn.css 与 access 的第四态6330–6353★★
10.6尺寸方法族:height / innerHeight / outerHeight9488–9534★★★
10.7显隐:isHiddenWithinTree / getDefaultDisplay / showHide4088–4288★★★★
10.8几何:offset / position / offsetParent / setOffset9289–9453★★★★
10.9滚动:scrollLeft / scrollTop9455–9485★★
10.10陷阱与边界(18 条)—★★★★
10.11全章总结 + 附录 A/B/C—★★

本章是第 09 章的姊妹篇。第 09 章讲样式读写的双通道(getStyles / curCSS / jQuery.style / adjustCSS / jQuery.css),本章讲物理量的计算:尺寸怎么量、盒模型怎么加减、元素在哪里、怎么显示和隐藏。

本章的四个核心函数构成一条严密的链条:

jQuery.fn.width(...)                        ← 用户 API
      ↓  access + extra
jQuery.css( elem, "width", "border" )       ← 读通道(第 09 章 9.10)
      ↓  cssHooks.width.get
getWidthOrHeight( elem, "width", "border" ) ← 尺寸主入口(10.2)
      ↓
boxModelAdjustment( ... )                   ← 盒模型算术(10.3)
      ↓
setPositiveNumber( ... )                    ← 写通道的值钳制(10.4)

以及三条独立的支线:showHide(显隐,10.7)、offset/position(几何,10.8)、scrollLeft/scrollTop(滚动,10.9)。


10.1 cssHooks 协议:get / set / expand

// Add in style property hooks for overriding the default
// behavior of getting and setting a style property
cssHooks: {},

(6136–6138)

jQuery.cssHooks 是一个空对象起步的扩展点。jQuery 内建往里塞三个系列的钩子,第三方插件可以继续加。它是 jQuery 样式模块唯一公开的扩展机制。

钩子的三个可选方法

方法签名返回 undefined 的含义调用方
get( elem, computed, extra )我不处理,请走默认路径jQuery.css 6234 / jQuery.style 6207
set( elem, value, extra )我已写入,请不要再写jQuery.style 6194
expand( value ) → { prop: value, ... }—动画模块的 propFilter(第 14 章,6852 附近)

注意:没有任何校验。jQuery 不检查钩子的形状,只在需要时用 "get" in hooks / "set" in hooks 判断。这是一个宽松的鸭子类型协议。

内建钩子系列一:width / height(6257–6300)

jQuery.each( [ "height", "width" ], function( _i, dimension ) {
	jQuery.cssHooks[ dimension ] = {
		get: function( elem, computed, extra ) {
			if ( computed ) {

				// Elements with `display: none` can have dimension info if
				// we invisibly show them.
				return jQuery.css( elem, "display" ) === "none" ?
					swap( elem, cssShow, function() {
						return getWidthOrHeight( elem, dimension, extra );
					} ) :
					getWidthOrHeight( elem, dimension, extra );
			}
		},

		set: function( elem, value, extra ) {
			var matches,
				styles = getStyles( elem ),

				// To avoid forcing a reflow, only fetch boxSizing if we need it (gh-3991)
				isBorderBox = extra &&
					jQuery.css( elem, "boxSizing", false, styles ) === "border-box",
				subtract = extra ?
					boxModelAdjustment(
						elem,
						dimension,
						extra,
						isBorderBox,
						styles
					) :
					0;

			// Convert to pixels if value adjustment is needed
			if ( subtract && ( matches = rcssNum.exec( value ) ) &&
				( matches[ 3 ] || "px" ) !== "px" ) {

				elem.style[ dimension ] = value;
				value = jQuery.css( elem, dimension );
			}

			return setPositiveNumber( elem, value, subtract );
		}
	};
} );

get 的逻辑:

  1. computed 为 false → 返回 undefined(让 jQuery.style 读 elem.style.width)
  2. computed 为 true → 检查 display 是否为 none
    • 是 → 用 swap 临时显示(cssShow),读尺寸,再恢复
    • 否 → 直接 getWidthOrHeight

这是 .width() 能对隐藏元素工作的全部秘密。一个 display: none 的 div,.width() 仍然返回有意义的数字——因为 jQuery 会临时把它变成 position:absolute; visibility:hidden; display:block,量完再变回去。

代价:一次 swap 意味着一次强制同步布局(读 offsetWidth 会 flush 样式队列)。所以对隐藏元素读尺寸是昂贵操作,在循环里要避免。

set 的逻辑:

  1. extra 为空时不查 boxSizing(gh-3991 注释:避免强制 reflow)——因为 extra 为空意味着用户只是 .css( "width", 100 ),不需要盒模型换算
  2. subtract = 需要从目标值里减掉的"额外层"像素数
  3. 单位统一化:如果 subtract 非零且传入值不是 px(比如 50%),则先把值写进去,再用 jQuery.css 读回 px 值。因为盒模型加减只能在 px 下进行——你不能从 50% 里减去 20px
  4. setPositiveNumber( elem, value, subtract ) 做减法并保证非负

第 3 步是这段代码的精髓:

if ( subtract && ( matches = rcssNum.exec( value ) ) &&
	( matches[ 3 ] || "px" ) !== "px" ) {

	elem.style[ dimension ] = value;
	value = jQuery.css( elem, dimension );
}

matches[3] || "px":rcssNum.exec( "50" ) 的 matches[3] 是 ""(空串,因为 ([a-z%]*) 可以匹配空),"" || "px" → "px"。所以裸数字 50 被当作 px 处理,跳过换算。

而 "50%" 的 matches[3] 是 "%",不等于 "px" → 先写 style.width = "50%",再读 jQuery.css( elem, "width" ) 得到 "120px"(计算值),后续减法在 120 上进行。

这会造成一次额外的 reflow,但只在"非 px 值 + 需要盒模型换算"时才发生。

内建钩子系列二:margin / padding / borderWidth(6303–6328)

// These hooks are used by animate to expand properties
jQuery.each( {
	margin: "",
	padding: "",
	border: "Width"
}, function( prefix, suffix ) {
	jQuery.cssHooks[ prefix + suffix ] = {
		expand: function( value ) {
			var i = 0,
				expanded = {},

				// Assumes a single number if not a string
				parts = typeof value === "string" ? value.split( " " ) : [ value ];

			for ( ; i < 4; i++ ) {
				expanded[ prefix + cssExpand[ i ] + suffix ] =
					parts[ i ] || parts[ i - 2 ] || parts[ 0 ];
			}

			return expanded;
		}
	};

	if ( prefix !== "margin" ) {
		jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber;
	}
} );

这个 jQuery.each 的键是 prefix,值是 suffix:

prefixsuffix生成的钩子名
"margin"""cssHooks.margin
"padding"""cssHooks.padding
"border""Width"cssHooks.borderWidth

expand 的语义:把 CSS 简写值展开成四个方向的具体属性。

CSS 的 margin/padding 简写遵循"上右下左"顺时针规则,且有省略规则:

简写展开结果
10pxtop=10, right=10, bottom=10, left=10
10px 20pxtop=10, right=20, bottom=10, left=20
10px 20px 30pxtop=10, right=20, bottom=30, left=20
10px 20px 30px 40pxtop=10, right=20, bottom=30, left=40

expand 用一行实现了全部四条规则:

parts[ i ] || parts[ i - 2 ] || parts[ 0 ]

逐位分析(设 parts = ["10px","20px","30px"]):

icssExpand[i]parts[i]parts[i-2]parts[0]结果
0"Top""10px"——"10px" ✓
1"Right""20px"——"20px" ✓
2"Bottom""30px"parts[0]="10px"—"30px" ✓
3"Left"undefinedparts[1]="20px"—"20px" ✓

完美复现"三位简写时 left 取 right"的规则。设 parts = ["10px","20px"]:

iparts[i]parts[i-2]parts[0]结果
0"10px"——"10px" ✓
1"20px"——"20px" ✓
2undefinedparts[0]="10px"—"10px" ✓
3undefinedparts[1]="20px"—"20px" ✓

设 parts = ["10px"]:

i结果
0"10px"
1undefined || parts[-1] (undefined) || parts[0]="10px" → "10px" ✓
2undefined || parts[0]="10px" → "10px" ✓
3undefined || parts[1]=undefined || parts[0]="10px" → "10px" ✓

parts[-1] 在 JS 数组里是 undefined(数组没有负索引),所以安全。

为什么 border 的 suffix 是 "Width"? 因为 border: 1px solid red 这种简写包含宽度、样式、颜色三部分,expand 无法拆。jQuery 只支持 borderWidth: 5 展开成 borderTopWidth/Right/Bottom/Left。所以用 border + "Width" 拼出 borderWidth,而展开出的属性名是 border + Top + Width = borderTopWidth。✓

为什么 margin 不设 set?

if ( prefix !== "margin" ) {
	jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber;
}

因为 margin 可以是负值(margin-top: -10px),而 setPositiveNumber 会 Math.max( 0, ... ) 把负数钳到 0。padding 和 borderWidth 不能为负,所以可以用 setPositiveNumber 做保护。

这是一个非常精确的设计决策:同一个钩子协议,按属性的语义约束选择是否安装 set。

那 margin 走什么路径?没有 set 钩子 → jQuery.style 直接写 style.margin = value。所以 .css( "margin", -10 ) 会写 "-10"(注意:isAutoPx("margin") 为 true,所以补 px 得到 "-10px")✓。

内建钩子系列三:无

只有以上两类。opacity、zIndex 等都没有钩子——它们走 curCSS / style[name] 的通用路径。

编写一个自定义 cssHook:完整示例

假设我们要支持读取 CSS transform 的 translateX 分量:

$.cssHooks.translateX = {
	get: function( elem, computed, extra ) {
		// 只在计算模式下工作
		if ( !computed ) {
			return undefined;   // 让 jQuery.style 读 elem.style.transform(其实读不到,见下)
		}
		var t = $.css( elem, "transform" );
		if ( t && t !== "none" ) {
			var m = new DOMMatrixReadOnly( t );
			return extra === true ? m.m41 : m.m41 + "px";
		}
		return extra === true ? 0 : "0px";
	},
	set: function( elem, value ) {
		// 自己写完,返回 undefined 阻止再写
		var t = $.css( elem, "transform" );
		var m = t && t !== "none" ? new DOMMatrixReadOnly( t ) : new DOMMatrixReadOnly();
		m.m41 = parseFloat( value ) || 0;
		elem.style.transform = m.toString();
		return undefined;
	}
};

几个要点:

  1. get 里判断 computed,非计算模式返回 undefined——遵循协议。
  2. set 返回 undefined——我们已经自己写了 elem.style.transform,如果返回值,jQuery 会再去写 elem.style[ "translateX" ](不存在的属性,无效)。
  3. 注意 finalPropName 的干扰:我们的键是 translateX,finalPropName( "translateX" ) 会检查 "translateX" in emptyStyle → false;再 vendorPropName → "msTranslateX" / "MozTranslateX" / "WebkitTranslateX" 都不在 → 返回 "translateX"。所以 name 保持 translateX,cssHooks[ "translateX" ] 命中 ✓。
  4. 读的时候:jQuery.css( elem, "translateX" ) → hooks.get( elem, true, undefined ) → 返回 "100px"。

一个坑:如果同时注册了 get 和 set,但用户调用 $( el ).css( "translateX", "50px" ),走 jQuery.style,hooks.set 被调用并自己写 transform,返回 undefined → jQuery.style 不再写 ✓。

钩子与 access 的交互

jQuery.fn.css(10.5)用 access 处理参数。当传入对象时,access 会遍历对象的键,对每个键调用 jQuery.style( elem, key, value )。所以批量设置时每个属性都会过一遍钩子。这是正确的行为。


10.2 getWidthOrHeight:尺寸的六条路径

function getWidthOrHeight( elem, dimension, extra ) {

	// Start with computed style
	var styles = getStyles( elem ),

		// To avoid forcing a reflow, only fetch boxSizing if we need it (gh-4322).
		// Fake content-box until we know it's needed to know the true value.
		boxSizingNeeded = isIE || extra,
		isBorderBox = boxSizingNeeded &&
			jQuery.css( elem, "boxSizing", false, styles ) === "border-box",
		valueIsBorderBox = isBorderBox,

		val = curCSS( elem, dimension, styles ),
		offsetProp = "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 );

	// Return a confounding non-pixel value or feign ignorance, as appropriate.
	if ( rnumnonpx.test( val ) ) {
		if ( !extra ) {
			return val;
		}
		val = "auto";
	}


	if (
		(

			// Fall back to offsetWidth/offsetHeight when value is "auto"
			// This happens for inline elements with no explicit setting (gh-3571)
			val === "auto" ||

			// Support: IE 9 - 11+
			// Use offsetWidth/offsetHeight for when box sizing is unreliable.
			// In those cases, the computed value can be trusted to be border-box.
			( isIE && isBorderBox ) ||

			( !support.reliableColDimensions() && nodeName( elem, "col" ) ) ||

			( !support.reliableTrDimensions() && nodeName( elem, "tr" ) )
		) &&

		// Make sure the element is visible & connected
		elem.getClientRects().length ) {

		isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box";

		// Where available, offsetWidth/offsetHeight approximate border box dimensions.
		// Where not available (e.g., SVG), assume unreliable box-sizing and interpret the
		// retrieved value as a content box dimension.
		valueIsBorderBox = offsetProp in elem;
		if ( valueIsBorderBox ) {
			val = elem[ offsetProp ];
		}
	}

	// Normalize "" and auto
	val = parseFloat( val ) || 0;

	// Adjust for the element's box model
	return ( val +
		boxModelAdjustment(
			elem,
			dimension,
			extra || ( isBorderBox ? "border" : "content" ),
			valueIsBorderBox,
			styles,

			// Provide the current computed size to request scroll gutter calculation (gh-3589)
			val
		)
	) + "px";
}

(6061–6132)

这是本章最核心的函数。.width()、.innerWidth()、.outerWidth()、.outerWidth( true ) 以及它们的高/宽变体,全部收敛到这里。我们把它的执行路径完整枚举。

阶段一:初始化(6064–6074)

boxSizingNeeded = isIE || extra(gh-4322)

这是一个性能优化:jQuery.css( elem, "boxSizing", false, styles ) 会读计算样式。虽然我们传了 styles(复用 getComputedStyle 结果),但读取 boxSizing 属性本身可能触发 style recalc。

什么时候真的需要知道 box-sizing?只有两种情形:

  1. IE:IE 的计算值在某些 box-sizing 情况下不可靠,必须知道
  2. extra 非空:需要做盒模型加减,必须知道起点是 content-box 还是 border-box

如果 extra 为空(用户只是 .css( "width" ) 要原始计算值),不需要知道 box-sizing,跳过读取。

"Fake content-box until we know it's needed"——isBorderBox 在这种情况下是 false,即"假装是 content-box"。这个假值只在最后一行 extra || ( isBorderBox ? "border" : "content" ) 里用到,而那时 extra 为空,所以传给 boxModelAdjustment 的 box 是 "content",isBorderBox 是 false → box === ( isBorderBox ? "border" : "content" ) → "content" === "content" → 返回 0,不做任何调整 ✓。

这就是"假装"的全部意义:用一个便宜的假值,让后续代码走"不调整"的分支,从而完全避免读取 boxSizing。

offsetProp 的构造(6074):

offsetProp = "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 );

dimension 是 "width" 或 "height"。"width"[0].toUpperCase() = "W",.slice(1) = "idth" → "offsetWidth" ✓。

阶段二:非 px 值的处理(6076–6082)

// Return a confounding non-pixel value or feign ignorance, as appropriate.
if ( rnumnonpx.test( val ) ) {
	if ( !extra ) {
		return val;
	}
	val = "auto";
}

rnumnonpx = /^(pnum)(?!px)[a-z%]+$/i(9.1 讲过)。它匹配"数字 + 非 px 单位"。

当计算出的 val 是 "50%"、"2em"、"10vh" 时:

  • !extra(用户要原始值) → 直接返回 "50%"。这是为什么要这个分支:$( el ).css( "width" ) 应该返回 "50%" 而不是换算后的 px。注释说 "Return a confounding non-pixel value"(返回一个恼人的非像素值)——jQuery 承认这是"令人困惑的",但这是 CSS 的真相。
  • 有 extra(要数字或盒模型换算) → 设为 "auto",强制进入下一阶段的 offset 回退。因为非 px 值无法参与盒模型加减(你不知道 50% 是多少 px)。

这个 return val 是 getWidthOrHeight 唯一的提前返回,而且它返回的是字符串,不是数字。所以:

$( el ).css( "width" );      // "50%"  ← 字符串,因为 extra 为 undefined
$( el ).width();             // 数字,因为 extra 为 "border",走 offset 回退

这两个值不一致! .css("width") 给 "50%",.width() 给实际像素数。这是 jQuery 的一个已知行为,也是很多人的困惑源。我们在 10.10 会列为陷阱。

阶段三:offset 回退的四条件(6085–6114)

if (
	(
		val === "auto" ||
		( isIE && isBorderBox ) ||
		( !support.reliableColDimensions() && nodeName( elem, "col" ) ) ||
		( !support.reliableTrDimensions() && nodeName( elem, "tr" ) )
	) &&
	elem.getClientRects().length ) {
	...
}

四个"或"条件 + 一个"与"条件:

条件触发场景为什么
val === "auto"内联元素没有显式宽度(gh-3571)getComputedStyle 对 display:inline 且无显式 width 的元素报 "auto"
isIE && isBorderBoxIE 的 border-box 计算值不可靠IE 9–11 在 border-box 下计算值有误,offset* 才对
!reliableColDimensions() && nodeName(elem,"col")Firefox 135+ / Safari 18.3+ 的 <col>9.5 讲过,计算宽度恒为 0 或按 span=1 算
!reliableTrDimensions() && nodeName(elem,"tr")Firefox 70–135 / IE 10–11 的 <tr>9.5 讲过,计算高度含边框与否不一致

elem.getClientRects().length:确保元素可见且已连接。这是 9.3 里出现过的同一个技巧——getClientRects() 对隐藏/游离元素返回空数组,比 offsetWidth === 0 更可靠(因为 offsetWidth 为 0 也可能是真的 0 宽度元素)。

回退内部(6105–6113):

isBorderBox = jQuery.css( elem, "boxSizing", false, styles ) === "border-box";

// Where available, offsetWidth/offsetHeight approximate border box dimensions.
// Where not available (e.g., SVG), assume unreliable box-sizing and interpret the
// retrieved value as a content box dimension.
valueIsBorderBox = offsetProp in elem;
if ( valueIsBorderBox ) {
	val = elem[ offsetProp ];
}
  1. 重新读 boxSizing(无条件)。因为进入回退说明我们要用 offsetWidth,必须确切知道它的盒模型语义。
  2. valueIsBorderBox = offsetProp in elem:检查 offsetWidth 是否存在。SVG 元素没有 offsetWidth(SVGElement 不继承 HTMLElement 的布局属性)!所以对 SVG 元素,valueIsBorderBox 为 false,val 保持原计算值,且被当作 content-box 解释。
  3. val = elem[ offsetProp ]:offsetWidth 是 border-box 的整数(含 content + padding + border,不含 margin,含滚动条)。

注意 isBorderBox 和 valueIsBorderBox 是两个不同的变量:

变量含义用于
isBorderBox元素的 box-sizing 计算值传给 boxModelAdjustment 作为 isBorderBox 参数
valueIsBorderBox当前 val 这个数字代表的是哪种盒传给 boxModelAdjustment 作为 isBorderBox 参数...

等等,看最后调用:

boxModelAdjustment(
	elem,
	dimension,
	extra || ( isBorderBox ? "border" : "content" ),   // box
	valueIsBorderBox,                                   // isBorderBox 参数
	styles,
	val                                                 // computedVal
)

传给 boxModelAdjustment 的是 valueIsBorderBox,不是 isBorderBox。

  • box = extra || ( isBorderBox ? "border" : "content" ):目标盒。若无 extra,目标是"元素当前 box-sizing 对应的默认盒"
  • isBorderBox(第 4 参)= valueIsBorderBox:val 这个数字是按哪种盒解释的

这个区分是 getWidthOrHeight 最容易看错的地方。isBorderBox 变量其实只用于构造 box 参数(当 extra 为空时),而真正决定加减方向的是 valueIsBorderBox。

举例:box-sizing: border-box 的元素,val 来自 curCSS(未走 offset 回退),则 isBorderBox = true,valueIsBorderBox = true。box = extra || "border"。若 extra = "content",boxModelAdjustment 的 box === "content",isBorderBox === true → 走 else 分支,减去 padding 和 border ✓。

若走了 offset 回退,且元素有 offsetWidth(HTML 元素),则 valueIsBorderBox = true(offsetWidth 恒是 border-box),无论 boxSizing 是什么。这是正确的:offsetWidth 按定义就是 border box。

阶段四:归一化与盒模型调整(6116–6131)

// Normalize "" and auto
val = parseFloat( val ) || 0;

// Adjust for the element's box model
return ( val +
	boxModelAdjustment( elem, dimension, extra || ( isBorderBox ? "border" : "content" ),
		valueIsBorderBox, styles, val )
) + "px";

parseFloat( val ) || 0:把 "100px" 变 100,把 ""/"auto"/NaN 变 0。

最后返回 ( val + 调整量 ) + "px"——注意末尾拼了 "px"。所以 getWidthOrHeight 总是返回字符串,即使调用方要的是数字。

那 .width() 怎么返回数字?因为 cssHooks.width.get 的返回值被 jQuery.css 处理:

if ( extra === "" || extra ) {
	num = parseFloat( val );
	return extra === true || isFinite( num ) ? num || 0 : val;
}

jQuery.css 会 parseFloat 一次,把 "142px" 变成 142 ✓。

所以字符串 → 数字 的转换发生在 jQuery.css 层,不是 getWidthOrHeight 层。这是一个清晰的分工:getWidthOrHeight 负责"算出正确的 px 字符串",jQuery.css 负责"按 extra 决定返回类型"。

六条完整路径推演

设一个 box-sizing: content-box 的 div,CSS 为 width: 100px; padding: 10px; border: 2px solid; margin: 5px。计算值 width = "100px",offsetWidth = 100 + 20 + 4 = 124。

#extraval 初值阶段二阶段三val 终值boxvalueIsBorderBoxboxModelAdjustment返回
1undefined"100px"rnumnonpx 不匹配不触发100"content"false0(早退)"100px"
2"""100px"同上不触发100"content"false0"100px"
3"padding""100px"不匹配不触发100"padding"false+20(上下/左右 padding)"120px"
4"border""100px"不匹配不触发100"border"false+20+4 = +24"124px"
5"margin""100px"不匹配不触发100"margin"false+24 + 10 = +34"134px"
6true"100px"不匹配不触发100truefalsebox=true 不等于 content/border → 走 else...见下

第 6 行需要单独说明:extra === true 时,box 参数是 true(因为 extra || 短路)。在 boxModelAdjustment 里:

if ( box === ( isBorderBox ? "border" : "content" ) ) {
	return 0;
}

isBorderBox 为 false → 右边是 "content";box 是 true → true === "content" 为 false → 不早退。

然后 for 循环里:

  • box === "margin"?true === "margin" → false
  • !isBorderBox → true → delta += padding;box !== "padding"(true !== "padding")→ true → delta += border

所以 extra === true 等价于 extra === "border"。这就是为什么 .width() 和 .outerWidth() 的区别靠 extra 是 "border" 还是 true(见 10.6)。

再看一个 border-box 的例子。设 box-sizing: border-box,width: 100px; padding: 10px; border: 2px。

此时计算值 width 是 "100px"(border-box 下 width 指定的是 border box 总宽),offsetWidth = 100。

#extraboxvalueIsBorderBox调整返回
1undefined"border"(isBorderBox 为 true)truebox === "border" → 早退 0"100px"
2"content""content"true-20 -4 = -24"76px"
3"padding""padding"true-4(只减 border)"96px"
4"border""border"true0(早退)"100px"
5"margin""margin"true0 + marginDelta(10) = +10"110px"

第 3 行验证:box === "padding",isBorderBox === true → 走 else 分支:

  • box === "content"?否 → 不减 padding
  • box !== "margin"?"padding" !== "margin" → 是 → delta -= border(-4)

结果 100 - 4 = 96 ✓(padding box = border box - border)。

第 5 行验证:box === "margin",走 else:

  • box === "content"?否
  • box !== "margin"?否 → 不减 border
  • 循环外 marginDelta += margin(+10)
  • 但循环内的 if ( box === "margin" ) marginDelta += ... 在每次迭代都执行,i 取 1 和 3(width)→ marginRight + marginLeft = 10
  • delta 为 0(因为都没执行)
  • !isBorderBox && computedVal >= 0?isBorderBox 为 true → 跳过滚动条计算
  • 返回 0 + 10 = 10
  • 100 + 10 = 110 ✓

滚动条 gutter 的处理(gh-3589)

// Account for positive content-box scroll gutter when requested by providing computedVal
if ( !isBorderBox && computedVal >= 0 ) {

	// offsetWidth/offsetHeight is a rounded sum of content, padding, scroll gutter, and border
	// Assuming integer scroll gutter, subtract the rest and round down
	delta += Math.max( 0, Math.ceil(
		elem[ "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 ) ] -
		computedVal -
		delta -
		extra -
		0.5

	// If offsetWidth/offsetHeight is unknown, then we can't determine content-box scroll gutter
	// Use an explicit zero to avoid NaN (gh-3964)
	) ) || 0;
}

(6041–6056)

问题:当一个内容溢出产生滚动条的元素,我们要算它的 content-box 宽度时,getComputedStyle().width 报的是不含滚动条的内容宽度,而 CSS 意义上的 content box 也不含滚动条(滚动条在 padding box 内、content box 外)。

但 offsetWidth 是 content + padding + scrollbar + border(取整)。所以:

offsetWidth = contentWidth + padding*2 + scrollGutter + border*2

我们要的 delta(从 content 到目标盒的增量)里,漏掉了 scrollGutter。

公式推导:

scrollGutter = offsetWidth - computedVal - delta - extra - 0.5

其中:

  • computedVal = 传入的 val(content 宽度的浮点值)
  • delta = 已累加的 padding/border(本轮)
  • extra = 在 box === "padding" 分支里单独累计的 border 宽度(见 6022)
  • -0.5 配合 Math.ceil 实现"四舍五入到最近的整数"

等等,Math.ceil( x - 0.5 ) 是 Math.round( x ) 的等价形式(对于正数)。jQuery 用这个形式可能只是为了明确"取整"意图。

Math.max( 0, ... ) 保证不会出现负的 gutter(浮点误差导致)。

|| 0 的作用(gh-3964):如果 elem.offsetWidth 是 undefined(SVG 元素),整个算式是 NaN,Math.ceil( NaN ) 是 NaN,Math.max( 0, NaN ) 是 NaN,NaN || 0 → 0 ✓。

computedVal >= 0 条件:只有当传入的 val 非负时才算。如果 val 是 0(隐藏元素),跳过。注意这里 computedVal 参数在 boxModelAdjustment 的签名里是第 6 个参数,可选——cssHooks.width.set(6280)调用时只传了 5 个参数,所以 computedVal 是 undefined,undefined >= 0 是 false → 跳过滚动条计算 ✓。

这个设计很巧妙:boxModelAdjustment 有两种调用模式:

  • 读模式(getWidthOrHeight):传 6 个参数,计算滚动条
  • 写模式(cssHooks.width.set):传 5 个参数,不算滚动条(因为设置宽度时不应该考虑当前滚动条)

getClientRects() vs offsetWidth 的判据差异

源码里两处用了不同的可见性判据:

位置判据用途
6103elem.getClientRects().length决定是否可以用 offset 回退
6077 之前的 curCSSisAttached( elem )决定是否回退到内联样式
jQuery.fn.offset(9372)elem.getClientRects().length决定是否返回 {0,0}

getClientRects() 返回一个 DOMRectList,包含元素的所有边界矩形(内联元素换行会产生多个矩形)。空数组意味着元素没有渲染(display:none、游离、或本身是空的内联元素)。

相比 offsetWidth === 0,getClientRects().length 的好处:

  1. 对 display: none 一定返回空(而 offsetWidth 也是 0,两者一致)
  2. 对 SVG 元素有效(offsetWidth 不存在)
  3. 能区分"元素不可见"和"元素宽度为 0"——一个 width: 0 的可见元素,getClientRects() 返回一个 width 为 0 的矩形(长度 1),而 offsetWidth 是 0

第 3 点是关键。jQuery 需要的是"这个元素有没有被渲染",而不是"它有多宽"。


10.3 boxModelAdjustment:四盒模型的加减法

function boxModelAdjustment( elem, dimension, box, isBorderBox, styles, computedVal ) {
	var i = dimension === "width" ? 1 : 0,
		extra = 0,
		delta = 0,
		marginDelta = 0;

	// Adjustment may not be necessary
	if ( box === ( isBorderBox ? "border" : "content" ) ) {
		return 0;
	}

	for ( ; i < 4; i += 2 ) {
		...
	}

	// Account for positive content-box scroll gutter ...
	if ( !isBorderBox && computedVal >= 0 ) { ... }

	return delta + marginDelta;
}

(5990–6059)

var i = dimension === "width" ? 1 : 0 与 i += 2

var cssExpand = [ "Top", "Right", "Bottom", "Left" ];

对 width,我们只需要 Right + Left → i 从 1 开始,步长 2 → i = 1, 3 → cssExpand[1] = "Right"、cssExpand[3] = "Left" ✓

对 height,我们只需要 Top + Bottom → i 从 0 开始,步长 2 → i = 0, 2 → "Top"、"Bottom" ✓

这是一个**用数组索引 + 步长表达"两个方向"**的技巧,避免了两个独立的循环或方向数组。代价是可读性,收益是代码量减半。

早退条件(5997–5999)

// Adjustment may not be necessary
if ( box === ( isBorderBox ? "border" : "content" ) ) {
	return 0;
}

语义:如果目标盒就是"当前 val 所代表的盒",不需要调整。

  • isBorderBox === true → val 已经是 border box → 目标是 "border" 时不调
  • isBorderBox === false → val 是 content box → 目标是 "content" 时不调

这是整个函数最快的路径,也是最常见的路径(.css("width") 无 extra 时)。

循环体的两条大分支

for ( ; i < 4; i += 2 ) {

	// Both box models exclude margin
	// Count margin delta separately to only add it after scroll gutter adjustment.
	// This is needed to make negative margins work with `outerHeight( true )` (gh-3982).
	if ( box === "margin" ) {
		marginDelta += jQuery.css( elem, box + cssExpand[ i ], true, styles );
	}

	// If we get here with a content-box, we're seeking "padding" or "border" or "margin"
	if ( !isBorderBox ) {

		// Add padding
		delta += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles );

		// For "border" or "margin", add border
		if ( box !== "padding" ) {
			delta += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles );

		// But still keep track of it otherwise
		} else {
			extra += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles );
		}

	// If we get here with a border-box (content + padding + border), we're seeking "content" or
	// "padding" or "margin"
	} else {

		// For "content", subtract padding
		if ( box === "content" ) {
			delta -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles );
		}

		// For "content" or "padding", subtract border
		if ( box !== "margin" ) {
			delta -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles );
		}
	}
}

我们把它整理成一张完整的决策表。设 P = padding(单侧),B = border(单侧),M = margin(单侧)。

content-box 起点(isBorderBox === false):

boxpadding 处理border 处理margin 处理delta(双侧)marginDelta总调整
"content"—————0(早退)
"padding"+P记入 extra(不进 delta)—+2P0+2P + scrollGutter
"border"+P+B—+2P+2B0+2P+2B + gutter
"margin"+P+B+M 进 marginDelta+2P+2B+2M+2P+2B+2M
true+P+B—+2P+2B0同 "border"

border-box 起点(isBorderBox === true):

boxpadding 处理border 处理margin 处理delta(双侧)marginDelta总调整
"border"—————0(早退)
"padding"不减-B—-2B0-2B
"content"-P-B—-2P-2B0-2P-2B
"margin"不减不减(box !== "margin" 为 false)+M0+2M+2M
true不减-B—-2B0-2B

验证 "margin" 在 border-box 下的行为:目标是 margin box,起点是 border box,差值就是 2M ✓。代码里 box !== "margin" 为 false 所以不减 border ✓,box === "content" 为 false 所以不减 padding ✓。正确。

为什么 margin 要单独累计(gh-3982)

注释解释得很清楚:

Count margin delta separately to only add it after scroll gutter adjustment. This is needed to make negative margins work with outerHeight( true ).

滚动条 gutter 的计算公式:

gutter = offsetWidth - computedVal - delta - extra - 0.5

offsetWidth 是 border box(不含 margin)。所以公式里不能包含 margin。如果 delta 里混入了 margin,gutter 就会算错(变成负数)。

而 marginDelta 在 return delta + marginDelta 时才加上,在 gutter 计算之后。

负 margin 的问题(gh-3982):如果 margin 是 -10px,而 gutter 计算出的 Math.max( 0, ... ) 把它钳到 0,就会导致 outerWidth( true ) 在负 margin 时不准。分开累计后,delta 里只有非负的 padding/border,Math.max( 0, ... ) 不会误伤负 margin ✓。

extra 变量的作用

在 box === "padding" 且 !isBorderBox 的分支里,border 宽度不进 delta,而是进 extra:

} else {
	extra += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles );
}

为什么?因为目标是 padding box,不应该加 border。但 gutter 公式需要知道 border 宽度:

offsetWidth = content + padding + gutter + border
gutter = offsetWidth - content - padding - border = offsetWidth - computedVal - delta - extra

此时 delta = 2P,extra = 2B → gutter = offsetWidth - computedVal - 2P - 2B ✓

所以 extra 是一个"参与公式但不参与结果"的中间量。这是为了复用同一个公式处理不同的目标盒。


10.4 setPositiveNumber 与负边距

function setPositiveNumber( _elem, value, subtract ) {

	// Any relative (+/-) values have already been
	// normalized at this point
	var matches = rcssNum.exec( value );
	return matches ?

		// Guard against undefined "subtract", e.g., when used as in cssHooks
		Math.max( 0, matches[ 2 ] - ( subtract || 0 ) ) + ( matches[ 3 ] || "px" ) :
		value;
}

(5978–5988)

两种身份

setPositiveNumber 有两个调用身份:

  1. 作为 cssHooks.padding.set / cssHooks.borderWidth.set(6326):jQuery.style 调用 hooks.set( elem, value, extra ),返回值被写回 style。
  2. 作为 cssHooks.width.set 的最后一步(6297):return setPositiveNumber( elem, value, subtract )。

注意身份 1 的 extra 参数在这里变成了 subtract?不对——签名是 ( _elem, value, subtract ),jQuery.style 调用 hooks.set( elem, value, extra )。所以第 3 个参数既可能是"盒模型 extra 字符串",也可能是"要减去的像素数"。

在身份 1 里,extra 通常是 undefined(用户直接 .css( "padding", 10 )),所以 subtract || 0 → 0。

在身份 2 里,subtract 是 boxModelAdjustment 的结果(数字或 0)。

第一个参数为什么叫 _elem

下划线前缀表示"未使用"(jQuery 代码规范里用 _ 前缀标记刻意忽略的参数)。setPositiveNumber 不需要 elem——它只做数值运算。

但为什么要保留这个参数?因为作为钩子方法,它必须匹配 ( elem, value, extra ) 的协议签名。这是一个签名兼容导致的空参数。

Math.max( 0, ... ) 的语义

"Positive" 体现在这里:结果永不为负。

场景:元素 box-sizing: border-box,padding: 20px,用户执行 .width( 10 )。

  • extra = "border"(默认)
  • boxModelAdjustment( ..., "border", isBorderBox=true, ... ) → box === "border" → 早退 0?

等等,这不对。让我重新看 cssHooks.width.set:

subtract = extra ?
	boxModelAdjustment( elem, dimension, extra, isBorderBox, styles ) : 0;

这里 extra 是从 jQuery.style 传下来的。.width( 10 ) 时,jQuery.fn.width 调用 jQuery.style( elem, type, value, extra ),extra 是 "border"(见 10.6)。

所以 boxModelAdjustment( elem, "width", "border", isBorderBox, styles ):

  • box === "border",isBorderBox === true → box === ( true ? "border" : "content" ) → "border" === "border" → 早退 0

subtract = 0。所以 .width( 10 ) 在 border-box 下就是写 10px ✓(因为 border-box 的 width 就是 border box 宽度,用户要的就是这个)。

再看 content-box 的情况:box-sizing: content-box,padding: 20px,.width( 100 ):

  • extra = "border"(jQuery 的 .width() 默认按 border?不对)

让我重新看 10.6 的代码(9531):

}, type, chainable ? margin : undefined, chainable );

access( this, fn, type, value, chainable )。access 的第 4 个参数是 value,第 5 个是 chainable。所以传给 fn 的 value 参数是 margin(用户传入的第一个参数)。

而 jQuery.fn.width 的签名是 function( margin, value ):

jQuery.fn[ funcName ] = function( margin, value ) {
	var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ),
		extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" );

	return access( this, function( elem, type, value ) {
		...
		return value === undefined ?
			jQuery.css( elem, type, extra ) :
			jQuery.style( elem, type, value, extra );
	}, type, chainable ? margin : undefined, chainable );
};

所以:

  • extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" )
    • 对 width: defaultExtra = "content"(type),所以 extra = "content"
    • 对 innerWidth:defaultExtra = "padding"
    • 对 outerWidth:defaultExtra = ""(空串!)→ "" || (...) → margin === true ? "margin" : "border"

注意 defaultExtra 为 "" 是 falsy,所以 outerWidth 走后半段:margin === true ? "margin" : "border" ✓

那么对 content-box 元素 .width( 100 ):

  • extra = "content"
  • boxModelAdjustment( elem, "width", "content", isBorderBox=false, styles ) → box === "content" → 早退 0
  • subtract = 0
  • setPositiveNumber( elem, 100, 0 ) → Math.max( 0, 100 - 0 ) + "px" → "100px" ✓

对 content-box 元素 .outerWidth( 100 ):

  • extra = "border"
  • boxModelAdjustment( elem, "width", "border", false, styles ) → delta = 2P + 2B
  • subtract = 2P + 2B(比如 48)
  • setPositiveNumber( elem, 100, 48 ) → "52px" ✓

完美:用户要 outerWidth 100,jQuery 写 width: 52px(content),加上 48 的 padding+border 正好 100 ✓

负值的情形:如果 subtract = 120 而用户要 100,Math.max( 0, 100 - 120 ) → 0。所以 .outerWidth( 100 ) 在一个 padding+border 超过 100 的元素上,会把 content width 设为 0,而不是负数。这是 CSS 的约束(width 不能为负)与 jQuery 的防护。

matches[ 3 ] || "px"

如果 value 是 "50%":matches = [ "50%", undefined, "50", "%" ] → Math.max( 0, 50 - subtract ) + "%"。

但前面 cssHooks.width.set 已经把非 px 值换算成了 px(6290–6295),所以到这里 value 通常是 px 字符串或数字。

如果 value 是数字 100:rcssNum.exec( 100 ) —— 正则的 exec 会把参数转成字符串 "100" → matches[2] = "100",matches[3] = "" → "" || "px" → "px" ✓

如果 value 是 "auto" 或 "inherit":rcssNum 不匹配 → matches 为 null → 返回 value 原样。所以 .width( "auto" ) 会写 style.width = "auto" ✓(这是有效的 CSS)。


10.5 jQuery.fn.css 与 access 的第四态

jQuery.fn.extend( {
	css: function( name, value ) {
		return access( this, function( elem, name, value ) {
			var styles, len,
				map = {},
				i = 0;

			if ( Array.isArray( name ) ) {
				styles = getStyles( elem );
				len = name.length;

				for ( ; i < len; i++ ) {
					map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles );
				}

				return map;
			}

			return value !== undefined ?
				jQuery.style( elem, name, value ) :
				jQuery.css( elem, name );
		}, name, value, arguments.length > 1 );
	}
} );

(6330–6353)

access 的四态回顾

access 是 jQuery 的通用读写访问器(第 03 章讲过)。jQuery.fn.css 展示了它的完整四态:

access( this, fn, name, value, chainable )
调用形式namevaluearguments.length > 1access 行为
.css( "color" )"color"undefinedfalse读模式:只处理 this[0],返回 fn 的返回值
.css( "color", "red" )"color""red"true写模式:遍历所有元素,返回 this(链式)
.css( { color: "red" } ){...}undefinedfalse对象读? 不——access 检测 typeof name === "object" → 遍历键,逐个写,返回 this
.css( [ "a", "b" ] )[...]undefinedfalseArray.isArray 分支在 fn 内部处理

关键在 access 内部对 typeof name === "object" 的判断:

// access 源码(第 03 章)
if ( typeof name === "object" ) {
	for ( ; i < length; i++ ) {
		fn( elems[ i ], name );   // 注意:只传 2 个参数,value 是 undefined
	}
	return chainable ? elems : ...;
}

但我们的 fn 里检查的是 value !== undefined,而对象模式下 value 是 undefined... 这看起来会走读分支?

不对。让我重新看 access。实际上 jQuery 的 access 在对象分支里是:

if ( typeof name === "object" ) {
	for ( ; i < length; i++ ) {
		// 对每个元素,遍历对象的键
		for ( key in name ) {
			fn( elems[ i ], key, name[ key ] );
		}
	}
}

即把对象的每个键值对拆开传给 fn。这才是 access 的完整逻辑(第 03 章有详细讲解)。所以 .css( { color: "red" } ) 会让 fn 被调用两次:fn( el, "color", "red" )。

于是 value !== undefined → jQuery.style( elem, "color", "red" ) ✓

arguments.length > 1 作为 chainable:

}, name, value, arguments.length > 1 );
  • .css( "color" ) → arguments.length === 1 → chainable = false → 读模式 ✓
  • .css( "color", "red" ) → arguments.length === 2 → chainable = true → 写模式 ✓
  • .css( { color: "red" } ) → arguments.length === 1 → chainable = false??

这不对——对象模式应该返回 this 以支持链式。

实际上 access 在 typeof name === "object" 分支里忽略 chainable,总是返回 elems。让我回忆第 03 章的内容:access 的结构是:

var access = function( elems, fn, key, value, chainable, emptyGet, raw ) {
	var i = 0,
		len = elems.length,
		bulk = key == null;

	// Sets many values
	if ( typeof key === "object" ) {
		chainable = true;
		for ( i in key ) {
			access( elems, fn, i, key[ i ], true, emptyGet, raw );
		}

	// Sets one value
	} else if ( value !== undefined ) {
		chainable = true;
		...
	}

	return chainable ? elems : ...;
};

是的!access 在对象分支里强制 chainable = true,然后递归调用自己处理每个键值对。所以 .css( { color: "red" } ) 返回 this ✓

而且这也是为什么 .css( "color", null )(value 是 null,不是 undefined)会走"设置"分支并 chainable = true——但 jQuery.style 里 value == null 会 no-op。所以链式返回,但没效果。

数组读分支

if ( Array.isArray( name ) ) {
	styles = getStyles( elem );
	len = name.length;

	for ( ; i < len; i++ ) {
		map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles );
	}

	return map;
}

.css( [ "width", "height" ] ) 返回 { width: "100px", height: "50px" }。

注意 jQuery.css( elem, name[i], false, styles ) 的第三个参数是 false:

在 jQuery.css 里:

if ( extra === "" || extra ) { ... }

false 既不是 "" 也不是 truthy → 跳过数值化,返回原始字符串。

所以数组读返回的是字符串值,不是数字。这和普通读 .css( "width" )(extra 为 undefined)一致 ✓

但 styles 只取一次:getStyles( elem ) 在循环外,循环中复用。这就是 9.2 讲的复用策略。

为什么 jQuery.fn.css 不支持 extra

.css() 没有 extra 参数,所以无法直接读"带盒模型的尺寸"。这就是为什么需要 .width() / .innerWidth() / .outerWidth() 三个独立方法——它们各自固定了 extra。

对比:

API底层调用extra
.css( "width" )jQuery.css( elem, "width" )undefined → 字符串
.width()jQuery.css( elem, "width", "content" )"content" → 数字
.innerWidth()jQuery.css( elem, "width", "padding" )"padding" → 数字
.outerWidth()jQuery.css( elem, "width", "border" )"border" → 数字
.outerWidth( true )jQuery.css( elem, "width", "margin" )"margin" → 数字

一个容易忽略的陷阱:.css( name ) 对空集合

$( "#nonexistent" ).css( "color" );

access 在 bulk || !fn 之外的情形下,如果 elems.length === 0,会走 emptyGet 分支:

return chainable ? elems :
	bulk ? fn.call( elems ) :
	length ? fn( elems[ 0 ], key ) : emptyGet;

emptyGet 默认 undefined。所以 $( "#nope" ).css( "color" ) 返回 undefined(不是 "",也不是抛错)✓

而 $( "#nope" ).css( [ "a", "b" ] ) 同样返回 undefined(因为 access 在 length === 0 时根本不调 fn)。

10.6 尺寸方法族:height / innerHeight / outerHeight

// Create innerHeight, innerWidth, height, width, outerHeight and outerWidth methods
jQuery.each( { Height: "height", Width: "width" }, function( name, type ) {
	jQuery.each( {
		padding: "inner" + name,
		content: type,
		"": "outer" + name
	}, function( defaultExtra, funcName ) {

		// Margin is only for outerHeight, outerWidth
		jQuery.fn[ funcName ] = function( margin, value ) {
			var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ),
				extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" );

			return access( this, function( elem, type, value ) {
				var doc;

				if ( isWindow( elem ) ) {

					// $( window ).outerWidth/Height return w/h including scrollbars (gh-1729)
					return funcName.indexOf( "outer" ) === 0 ?
						elem[ "inner" + name ] :
						elem.document.documentElement[ "client" + name ];
				}

				// Get document width or height
				if ( elem.nodeType === 9 ) {
					doc = elem.documentElement;

					// Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height],
					// whichever is greatest
					return Math.max(
						elem.body[ "scroll" + name ], doc[ "scroll" + name ],
						elem.body[ "offset" + name ], doc[ "offset" + name ],
						doc[ "client" + name ]
					);
				}

				return value === undefined ?

					// Get width or height on the element, requesting but not forcing parseFloat
					jQuery.css( elem, type, extra ) :

					// Set width or height on the element
					jQuery.style( elem, type, value, extra );
			}, type, chainable ? margin : undefined, chainable );
		};
	} );
} );

(9487–9534)

二重 jQuery.each 生成六个方法

外层:{ Height: "height", Width: "width" }

  • name = "Height",type = "height"
  • name = "Width",type = "width"

内层:{ padding: "inner" + name, content: type, "": "outer" + name }

defaultExtrafuncName(Height 轮)funcName(Width 轮)
"padding""innerHeight""innerWidth"
"content""height""width"
"""outerHeight""outerWidth"

注意 "" 作为对象键是合法的(空字符串键)。这是 jQuery 故意的——因为 defaultExtra || 需要它 falsy。

这个二重循环的模式在第 08 章的 addClass/removeClass、第 07 章的 DOM 插入方法族里都出现过,是 jQuery 的标准代码生成手法:用数据驱动避免复制粘贴六份几乎相同的实现。

chainable 的计算

var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ),

chainable 决定 access 的第 5 个参数(是否链式返回)。

逐项分析:

  • arguments.length:$( el ).width() 是 0 → falsy → chainable = 0(falsy)→ 读模式;$( el ).width( 100 ) 是 1 → truthy → 继续判断
  • defaultExtra:对 height/innerHeight 是非空字符串 → truthy → chainable = true → 写模式
  • typeof margin !== "boolean":对 outerHeight,defaultExtra 是 ""(falsy),所以看这里

关键场景:$( el ).outerHeight( true ) —— margin 是 true(boolean)!此时:

  • arguments.length = 1 → truthy
  • defaultExtra = "" → falsy
  • typeof true !== "boolean" → false
  • 所以 chainable = false → 读模式!

这是对的:outerHeight( true ) 是"带 margin 地读",不是写。✓

而 $( el ).outerHeight( 100 ):

  • margin 是 100,typeof 100 !== "boolean" → true
  • chainable = true → 写模式 ✓

$( el ).outerHeight( true, 100 )? 这不是合法用法。签名是 ( margin, value ),margin === true 表示"读时包含 margin",value 只在写时用。实际用法:

$( el ).outerHeight( true );        // 读,含 margin
$( el ).outerHeight( 100 );         // 写,按 border 盒换算
$( el ).outerHeight( 100, true );   // 写,按 margin 盒换算

第三个用法:margin = 100,value = true → extra = "" || ( 100 === true || true === true ? "margin" : "border" ) → true === true → "margin" ✓ 而 chainable = 1 && ( "" || typeof 100 !== "boolean" ) = 1 && ( "" || true ) = true ✓

extra 的计算

extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" );
方法defaultExtraextra 结果
.height() / .height( 100 )"content""content"
.innerHeight()"padding""padding"
.outerHeight()""margin === true || value === true ? "margin" : "border" → "border"
.outerHeight( true )"""margin"

access 的调用

return access( this, function( elem, type, value ) { ... },
	type, chainable ? margin : undefined, chainable );
  • 第 3 参 key = type("width" / "height")
  • 第 4 参 value = chainable ? margin : undefined

这里有一个精妙之处:写模式下 access 把 margin 当作 value 传给 fn。但 margin 可能是 true(boolean)!

  • .outerHeight( true ) → chainable = false → 第 4 参是 undefined → 读模式 ✓(不会把 true 当值写进去)
  • .outerHeight( 100 ) → chainable = true → 第 4 参是 100 ✓
  • .outerHeight( 100, true ) → chainable = true → 第 4 参是 100 ✓(true 在 value 里,用于算 extra)

所以 chainable ? margin : undefined 是防止 true 被误当作 CSS 值的守卫。

fn 内的三个分支

分支 1:window

if ( isWindow( elem ) ) {

	// $( window ).outerWidth/Height return w/h including scrollbars (gh-1729)
	return funcName.indexOf( "outer" ) === 0 ?
		elem[ "inner" + name ] :
		elem.document.documentElement[ "client" + name ];
}

$( window ).width() → window.document.documentElement.clientWidth(视口宽度,不含滚动条) $( window ).outerWidth() → window.innerWidth(含滚动条,gh-1729)

funcName.indexOf( "outer" ) === 0 判断方法名是否以 "outer" 开头。对 outerWidth/outerHeight 成立。

elem[ "inner" + name ] → window.innerHeight / window.innerWidth ✓ elem.document.documentElement[ "client" + name ] → documentElement.clientHeight / clientWidth ✓

注意:$( window ).innerWidth() 走的是 else 分支(不以 outer 开头)→ 返回 clientWidth,即和 .width() 一样。这是 jQuery 的行为:innerWidth 对 window 没有独立语义。

分支 2:document(nodeType 9)

if ( elem.nodeType === 9 ) {
	doc = elem.documentElement;

	// Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height],
	// whichever is greatest
	return Math.max(
		elem.body[ "scroll" + name ], doc[ "scroll" + name ],
		elem.body[ "offset" + name ], doc[ "offset" + name ],
		doc[ "client" + name ]
	);
}

$( document ).height() 返回文档的总高度。为什么取 5 个值的最大值?

因为不同浏览器/不同情况下,这几个值哪个最准是不确定的:

属性含义何时最大
body.scrollHeightbody 的内容高度(含溢出)内容溢出时
documentElement.scrollHeighthtml 的内容高度通常
body.offsetHeightbody 的 border-box 高度body 有 border/margin 时
documentElement.offsetHeighthtml 的 border-box 高度—
documentElement.clientHeighthtml 的视口高度(不含滚动条)内容不足一屏时

取 Math.max 是一种保守的启发式:保证返回值不小于真实内容高度。代价是可能偏大(比如 body 有 margin 时)。

这是 jQuery 里典型的"没有标准答案时的经验公式"。

分支 3:普通元素

return value === undefined ?

	// Get width or height on the element, requesting but not forcing parseFloat
	jQuery.css( elem, type, extra ) :

	// Set width or height on the element
	jQuery.style( elem, type, value, extra );

读:jQuery.css( elem, "width", "content" ) → 经 cssHooks.width.get → getWidthOrHeight → 数字 写:jQuery.style( elem, "width", 100, "content" ) → 经 cssHooks.width.set → setPositiveNumber

注意注释 "requesting but not forcing parseFloat"——extra 是字符串("content"),不是 true。所以 jQuery.css 里:

return extra === true || isFinite( num ) ? num || 0 : val;

extra === true 为 false,isFinite( num ) 决定。如果计算值是 "auto",num 是 NaN → 返回 val(字符串 "auto")!

等等,但 getWidthOrHeight 返回的是 "142px"(因为末尾拼了 "px"),parseFloat( "142px" ) 是 142,isFinite 为 true → 返回 142 ✓

如果 getWidthOrHeight 提前返回了非 px 值(阶段二的 return val),比如 "50%":parseFloat( "50%" ) 是 50,isFinite 为 true → 返回 50(数字!)。

所以 .width() 对一个 width: 50% 的元素返回 50(数字 50,不是百分比对应的像素)?

不对。回到阶段二:

if ( rnumnonpx.test( val ) ) {
	if ( !extra ) {
		return val;
	}
	val = "auto";
}

.width() 的 extra 是 "content"(truthy),所以不提前返回,而是 val = "auto" → 进入阶段三的 offset 回退 → val = elem.offsetWidth → 返回像素值 ✓

所以 .width() 对百分比宽度元素返回的是 offsetWidth(border-box 像素),再经 boxModelAdjustment 转成 content-box ✓

而 .css( "width" )(extra 为 undefined)→ !extra 为 true → 提前返回 "50%" ✓

这就解释了 10.2 提到的不一致。两种 API 的行为差异根源就在 !extra 这一个判断上。

六个方法的完整行为表(content-box 元素,width:100, padding:10, border:2, margin:5)

调用extra底层 val调整结果
.width()"content"1000100
.innerWidth()"padding"100+20120
.outerWidth()"border"100+24124
.outerWidth( true )"margin"100+24+10134
.width( 200 )"content"—subtract=0写 200px
.outerWidth( 200 )"border"—subtract=24写 176px
.outerWidth( 200, true )"margin"—subtract=34写 166px

border-box 元素(width:100 表示 border box 总宽 100):

调用extra底层 val调整结果
.width()"content"100-2476
.innerWidth()"padding"100-496
.outerWidth()"border"1000100
.outerWidth( true )"margin"100+10110

10.7 显隐:isHiddenWithinTree / getDefaultDisplay / showHide

10.7.1 isHiddenWithinTree 与 :hidden 的分野

// isHiddenWithinTree reports if an element has a non-"none" display style (inline and/or
// through the CSS cascade), which is useful in deciding whether or not to make it visible.
// It differs from the :hidden selector (jQuery.expr.pseudos.hidden) in two important ways:
// * A hidden ancestor does not force an element to be classified as hidden.
// * Being disconnected from the document does not force an element to be classified as hidden.
// These differences improve the behavior of .toggle() et al. when applied to elements that are
// detached or contained within hidden ancestors (gh-2404, gh-2863).
function isHiddenWithinTree( elem, el ) {

	// isHiddenWithinTree might be called from jQuery#filter function;
	// in that case, element will be second argument
	elem = el || elem;

	// Inline style trumps all
	return elem.style.display === "none" ||
		elem.style.display === "" &&
		jQuery.css( elem, "display" ) === "none";
}

(4088–4105)

两个参数的由来:jQuery#filter 调用伪类/函数时,签名是 fn( elem, index, match )。但 isHiddenWithinTree 也被 showHide 直接调用(isHiddenWithinTree( elem ))。为了兼容两种调用,写了 elem = el || elem。

看 :hidden 的实现(8884 附近)对比:

jQuery.expr.pseudos.hidden = function( elem ) {
	return !jQuery.expr.pseudos.visible( elem );
};
jQuery.expr.pseudos.visible = function( elem ) {
	return !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length );
};
判据isHiddenWithinTree:hidden
只关心 display✓✗(visibility: hidden 的元素 offsetWidth 非 0 → :visible)
祖先隐藏会传染✗✓(祖先 display:none → 子孙 offsetWidth 为 0 → :hidden)
游离元素算隐藏✗✓(游离元素 getClientRects() 为空 → :hidden)
visibility: hidden不算隐藏算 :visible(!)

为什么需要两套判据? 因为用途不同:

  • :hidden 用于选择器——"找出页面上真正看不见的元素"。祖先隐藏、游离、零尺寸都算。
  • isHiddenWithinTree 用于显隐操作——"我该不该把这个元素的 display 改掉"。如果一个元素的祖先是 display: none,那它自己的 display 可能还是 block,此时"显示它"的正确做法是什么都不做(改它的 display 没用,祖先还藏着)。

注释里引用的 gh-2404、gh-2863 就是这两个场景的 bug:早期 jQuery 的 .show() 会错误地给隐藏祖先下的元素设置 display: block,导致后续祖先显示时元素的内联 display 覆盖了原本的 CSS。

"Inline style trumps all":

return elem.style.display === "none" ||
	elem.style.display === "" &&
	jQuery.css( elem, "display" ) === "none";
  • 如果内联 display: none → 隐藏(无需再看级联)
  • 如果内联 display 为空 → 看计算值(级联/样式表)
  • 如果内联 display 是其他值(如 block)→ 不隐藏(内联覆盖了级联的 none)

第三条隐含在逻辑里:第一个条件 === "none" 为 false,第二个条件 === "" 为 false → 返回 false。

这是"内联样式优先级最高"这一 CSS 规则的代码表达。

10.7.2 getDefaultDisplay:问浏览器"这个标签默认怎么显示"

var defaultDisplayMap = {};

function getDefaultDisplay( elem ) {
	var temp,
		doc = elem.ownerDocument,
		nodeName = elem.nodeName,
		display = defaultDisplayMap[ nodeName ];

	if ( display ) {
		return display;
	}

	temp = doc.body.appendChild( doc.createElement( nodeName ) );
	display = jQuery.css( temp, "display" );

	temp.parentNode.removeChild( temp );

	if ( display === "none" ) {
		display = "block";
	}
	defaultDisplayMap[ nodeName ] = display;

	return display;
}

(4217–4240)

为什么要问浏览器? 因为"某个标签的默认 display"是浏览器内置样式表决定的,而不是 JS 能查到的常量。HTML 规范里有建议的默认样式(<div> 是 block、<span> 是 inline、<li> 是 list-item、<table> 是 table、<tr> 是 table-row…),但实际值由 UA 样式表给出,且自定义元素(<my-widget>)默认是 inline。

jQuery 的做法:创建一个同标签名的临时元素,插到 body 里,读它的 display,再删掉。

为什么必须插到 body? 因为游离元素的 getComputedStyle 在部分浏览器里返回空(9.3 讲过)。插入文档才能获得正确的 UA 样式。

为什么用 elem.ownerDocument 而不是 document? 因为元素可能属于 iframe 的文档。用 ownerDocument 保证在正确的文档里探测。

缓存 defaultDisplayMap[ nodeName ]:nodeName 是大写的标签名(DIV、SPAN、LI)。缓存避免了同一个标签反复创建/插入/删除。

if ( display === "none" ) display = "block":某些元素(如 <head>、<style>、<script>、<template>、<base>)的 UA 默认 display 是 none。如果 show() 把这些设成 none 就没意义了,所以兜底为 block。

代价分析:第一次对某个标签调用 getDefaultDisplay 会:

  1. createElement
  2. appendChild(触发样式计算)
  3. getComputedStyle(可能触发 reflow)
  4. removeChild(触发 reflow)

这是一次昂贵的同步布局。之后同类标签走缓存。所以在循环里第一次 show() 一堆 <div> 是慢的,之后快。

10.7.3 showHide:两遍循环的设计

function showHide( elements, show ) {
	var display, elem,
		values = [],
		index = 0,
		length = elements.length;

	// Determine new display value for elements that need to change
	for ( ; index < length; index++ ) {
		elem = elements[ index ];
		if ( !elem.style ) {
			continue;
		}

		display = elem.style.display;
		if ( show ) {

			// Since we force visibility upon cascade-hidden elements, an immediate (and slow)
			// check is required in this first loop unless we have a nonempty display value (either
			// inline or about-to-be-restored)
			if ( display === "none" ) {
				values[ index ] = dataPriv.get( elem, "display" ) || null;
				if ( !values[ index ] ) {
					elem.style.display = "";
				}
			}
			if ( elem.style.display === "" && isHiddenWithinTree( elem ) ) {
				values[ index ] = getDefaultDisplay( elem );
			}
		} else {
			if ( display !== "none" ) {
				values[ index ] = "none";

				// Remember what we're overwriting
				dataPriv.set( elem, "display", display );
			}
		}
	}

	// Set the display of the elements in a second loop to avoid constant reflow
	for ( index = 0; index < length; index++ ) {
		if ( values[ index ] != null ) {
			elements[ index ].style.display = values[ index ];
		}
	}

	return elements;
}

(4242–4288)

这是本章架构上最讲究的一个函数。核心设计是两遍循环。

为什么是两遍

注释说得很清楚:"Set the display of the elements in a second loop to avoid constant reflow"。

如果只用一个循环,每处理一个元素就改一次 style.display,每次都使之前的布局失效。浏览器在下次读布局信息时要把所有失效重算一遍——N 个元素 = N 次布局失效,可能 1 次重算(如果后续不读)或 N 次重算(如果循环里读了)。

两遍循环:第一遍只决定要设什么值(存在 values[ index ] 里),第二遍只写。虽然写 N 次仍然会失效 N 次,但第一遍的读取是批量的——所有 getComputedStyle 都在元素还处于"原始状态"时完成,不会读到"刚被前一个元素改动影响"的中间态。

更准确地说:第一遍里的 isHiddenWithinTree( elem ) 会调 jQuery.css( elem, "display" )(读计算样式 → 强制 style recalc)。如果读写交替,就是经典的 layout thrashing(布局抖动)。分离读写是标准优化手法。

第一遍的 show 分支

display = elem.style.display;
if ( show ) {

	if ( display === "none" ) {
		values[ index ] = dataPriv.get( elem, "display" ) || null;
		if ( !values[ index ] ) {
			elem.style.display = "";
		}
	}
	if ( elem.style.display === "" && isHiddenWithinTree( elem ) ) {
		values[ index ] = getDefaultDisplay( elem );
	}
}

三种情况:

情况内联 displaydataPriv 里有记录?处理
A"none"有(之前 hide 过)values[i] = 记录的原值;不清空内联
B"none"无values[i] = null;清空内联 display(=""),进入下一个 if
C""—检查 isHiddenWithinTree → 若隐藏,values[i] = getDefaultDisplay

情况 A:.hide() 时存了原 display,.show() 时取出来。注意此时不清空内联,因为下一步 values[i] 非 null,第二遍会直接写成原值。

情况 B:内联是 none 但没有记录(用户直接写了 style="display:none")。清空内联,然后看级联:如果级联仍隐藏 → getDefaultDisplay;如果级联给出了 display → values[i] 保持 null,第二遍不写 ✓

values[ index ] = ... || null 的作用:dataPriv.get 返回 undefined 时变成 null,这样 values[index] != null(第二遍的判断)才正确区分"要写"和"不写"。稀疏数组 values 里未赋值的槽是 undefined,undefined != null 是 false(宽松比较)→ 不写 ✓

注意 values 是稀疏数组:只有需要改变的元素才有值。这是空间和语义的双重优化。

第一遍的 hide 分支

} else {
	if ( display !== "none" ) {
		values[ index ] = "none";

		// Remember what we're overwriting
		dataPriv.set( elem, "display", display );
	}
}

简短得多:只要当前不是 none,就记录原值到 dataPriv 并计划设为 none。

注意记录的是 elem.style.display(内联值),可能是 ""(表示"没有内联 display")。所以:

$( "<div style='display:inline'>" ).hide().show();  // 恢复成 inline
$( "<div>" ).hide().show();                          // 恢复成 ""(由级联决定)

dataPriv 是 jQuery 的私有数据存储器(第 16 章详解)。用它存 display 而不是 data-* 属性,是为了不污染 DOM 属性。

第二遍

for ( index = 0; index < length; index++ ) {
	if ( values[ index ] != null ) {
		elements[ index ].style.display = values[ index ];
	}
}

values[ index ] != null 用宽松比较,同时排除 undefined(未赋值)和 null(情况 B 且级联可见)。

注意:elements[ index ].style 在第一遍已经用 if ( !elem.style ) continue; 检查过了,所以这里不会再遇到没有 style 的元素?

不对——第一遍 continue 时 values[ index ] 保持 undefined,第二遍 undefined != null 为 false → 跳过 ✓ 安全。

show / hide / toggle

jQuery.fn.extend( {
	show: function() {
		return showHide( this, true );
	},
	hide: function() {
		return showHide( this );
	},
	toggle: function( state ) {
		...
	}
} );

(4290 起)

.show() / .hide() 都是薄封装。注意 hide 不传第二个参数——源码就是 showHide( this ),靠 show 形参取到 undefined(falsy)走 else 分支。写成 showHide( this, false ) 语义等价但不是原文。

toggle 的实现(第 14 章会讲动画版本)在无动画时大致是:

toggle: function( state ) {
	if ( typeof state === "boolean" ) {
		return state ? this.show() : this.hide();
	}
	return this.each( function() {
		if ( isHiddenWithinTree( this ) ) {
			jQuery( this ).show();
		} else {
			jQuery( this ).hide();
		}
	} );
}

注意 toggle 用的是 isHiddenWithinTree 而不是 :hidden——这就是 10.7.1 讲的分野:toggle 关心的是"元素自己的 display",不是"它在页面上是否可见"。

显隐与动画的关系

.show( 400 ) / .hide( "slow" ) / .toggle( true ) 会走动画路径(第 14 章)。动画结束后会调用 showHide( elements, show ) 做最终的状态设置。所以 showHide 是动画的终点,也是无动画 show/hide 的全部。

dataPriv 的使用与内存

dataPriv.set( elem, "display", value ) 会在元素上挂一个 expando 属性,值是 Data 实例。当元素被移除时,jQuery.cleanData(第 07/16 章)会清理。

一个陷阱:如果你用原生 elem.remove() 移除元素(不经过 jQuery),dataPriv 的数据不会被清理,造成内存泄漏。这是 jQuery 数据系统的通用问题(第 16 章详述)。


10.8 几何:offset / position / offsetParent

10.8.1 offset():border box 相对文档原点

// offset() relates an element's border box to the document origin
offset: function( options ) {

	// Preserve chaining for setter
	if ( arguments.length ) {
		return options === undefined ?
			this :
			this.each( function( i ) {
				jQuery.offset.setOffset( this, options, i );
			} );
	}

	var rect, win,
		elem = this[ 0 ];

	if ( !elem ) {
		return;
	}

	// Return zeros for disconnected and hidden (display: none) elements (gh-2310)
	// Support: IE <=11+
	// Running getBoundingClientRect on a
	// disconnected node in IE throws an error
	if ( !elem.getClientRects().length ) {
		return { top: 0, left: 0 };
	}

	// Get document-relative position by adding viewport scroll to viewport-relative gBCR
	rect = elem.getBoundingClientRect();
	win = elem.ownerDocument.defaultView;
	return {
		top: rect.top + win.pageYOffset,
		left: rect.left + win.pageXOffset
	};
},

(9349–9383)

读路径只有四步:

  1. elem = this[ 0 ]——只读集合的第一个元素。.offset() 不是 map,返回单个对象。
  2. getClientRects().length 为 0 → 返回 { top: 0, left: 0 }(gh-2310,且避免 IE 抛错)
  3. getBoundingClientRect() 得到视口相对坐标
  4. 加上 win.pageYOffset / win.pageXOffset(滚动量)得到文档相对坐标

为什么用 pageYOffset 而不是 scrollTop?

window.pageYOffset 是标准的滚动偏移量。document.documentElement.scrollTop 在标准模式下有效,在怪异模式(quirks mode)下要用 document.body.scrollTop。pageYOffset 屏蔽了这个差异 ✓

为什么用 elem.ownerDocument.defaultView? 9.2 讲过——IE popup 问题 + iframe 正确性。

写路径:

if ( arguments.length ) {
	return options === undefined ?
		this :
		this.each( function( i ) {
			jQuery.offset.setOffset( this, options, i );
		} );
}

$( el ).offset( undefined ) → arguments.length === 1 → 进入 setter 分支 → options === undefined → 返回 this(no-op,保持链式)。

这是一个精心的设计:如果某个代码路径算出 offset( someMaybeUndefined ),不会因为传了 undefined 而变成 getter。注释说 "Preserve chaining for setter"。

getBoundingClientRect 的语义:返回的是 border box 相对于视口的位置(含 border,不含 margin)。所以 offset() 注释说 "relates an element's border box to the document origin" ✓

10.8.2 position():margin box 相对 offsetParent 的 padding box

// position() relates an element's margin box to its offset parent's padding box
// This corresponds to the behavior of CSS absolute positioning
position: function() {
	if ( !this[ 0 ] ) {
		return;
	}

	var offsetParent, offset, doc,
		elem = this[ 0 ],
		parentOffset = { top: 0, left: 0 };

	// position:fixed elements are offset from the viewport, which itself always has zero offset
	if ( jQuery.css( elem, "position" ) === "fixed" ) {

		// Assume position:fixed implies availability of getBoundingClientRect
		offset = elem.getBoundingClientRect();

	} else {
		offset = this.offset();

		// Account for the *real* offset parent, which can be the document or its root element
		// when a statically positioned element is identified
		doc = elem.ownerDocument;
		offsetParent = elem.offsetParent || doc.documentElement;
		while ( offsetParent &&
			offsetParent !== doc.documentElement &&
			jQuery.css( offsetParent, "position" ) === "static" ) {

			offsetParent = offsetParent.offsetParent || doc.documentElement;
		}
		if ( offsetParent && offsetParent !== elem && offsetParent.nodeType === 1 &&
			jQuery.css( offsetParent, "position" ) !== "static" ) {

			// Incorporate borders into its offset, since they are outside its content origin
			parentOffset = jQuery( offsetParent ).offset();
			parentOffset.top += jQuery.css( offsetParent, "borderTopWidth", true );
			parentOffset.left += jQuery.css( offsetParent, "borderLeftWidth", true );
		}
	}

	// Subtract parent offsets and element margins
	return {
		top: offset.top - parentOffset.top - jQuery.css( elem, "marginTop", true ),
		left: offset.left - parentOffset.left - jQuery.css( elem, "marginLeft", true )
	};
},

(9387–9430)

注释里的关键句:"This corresponds to the behavior of CSS absolute positioning"。

CSS 中,position: absolute; top: 10px 的 10px 是元素的 margin box 边缘到包含块的 padding box 边缘的距离。jQuery 的 position() 复现的正是这个语义。

分支 A:position: fixed

fixed 元素的参照系是视口,而视口的偏移恒为 0。所以直接用 getBoundingClientRect()(视口相对坐标),parentOffset 保持 {0,0} ✓

分支 B:其他 position

  1. offset = this.offset() —— 元素的文档坐标(border box)
  2. 向上找真正的 offsetParent:
offsetParent = elem.offsetParent || doc.documentElement;
while ( offsetParent &&
	offsetParent !== doc.documentElement &&
	jQuery.css( offsetParent, "position" ) === "static" ) {

	offsetParent = offsetParent.offsetParent || doc.documentElement;
}

elem.offsetParent 是 DOM 原生属性,返回最近的定位祖先(position 非 static)或 body/table/td 等。但 jQuery 还要跳过多层:因为 offsetParent 可能是 static 的(比如 body),而 CSS 定位的包含块必须是非 static 的。

循环条件三个:

  • offsetParent 存在
  • 还不是 documentElement(到顶了)
  • 它的 position 是 static(不合格,继续往上)

|| doc.documentElement 兜底:offsetParent 为 null 时(fixed 元素、游离元素)用 documentElement。

  1. 计算 parentOffset:
if ( offsetParent && offsetParent !== elem && offsetParent.nodeType === 1 &&
	jQuery.css( offsetParent, "position" ) !== "static" ) {

	// Incorporate borders into its offset, since they are outside its content origin
	parentOffset = jQuery( offsetParent ).offset();
	parentOffset.top += jQuery.css( offsetParent, "borderTopWidth", true );
	parentOffset.left += jQuery.css( offsetParent, "borderLeftWidth", true );
}

四个守卫条件(都必要):

  • offsetParent 存在
  • offsetParent !== elem(元素自己不是自己的父)
  • nodeType === 1(是元素,不是 document)
  • position 非 static(找到了合格的包含块)

为什么要加 border 宽度? 注释说 "Incorporate borders into its offset, since they are outside its content origin"。

jQuery( offsetParent ).offset() 返回的是 offsetParent 的 border box 位置(含 border)。而 CSS 定位的参照是 padding box(border box 减去 border)。所以要加上 border 宽度,从 border box 位置移到 padding box 位置 ✓

  1. 最终计算:
return {
	top: offset.top - parentOffset.top - jQuery.css( elem, "marginTop", true ),
	left: offset.left - parentOffset.left - jQuery.css( elem, "marginLeft", true )
};

offset 是元素的 border box 位置。要得到 margin box 位置,需要减去 margin(margin 在 border 外面)✓

10.8.3 offsetParent():三段注释的坦白

// This method will return documentElement in the following cases:
// 1) For the element inside the iframe without offsetParent, this method will return
//    documentElement of the parent window
// 2) For the hidden or detached element
// 3) For body or html element, i.e. in case of the html node - it will return itself
//
// but those exceptions were never presented as a real life use-cases
// and might be considered as more preferable results.
//
// This logic, however, is not guaranteed and can change at any point in the future
offsetParent: function() {
	return this.map( function() {
		var offsetParent = this.offsetParent;

		while ( offsetParent && jQuery.css( offsetParent, "position" ) === "static" ) {
			offsetParent = offsetParent.offsetParent;
		}

		return offsetParent || documentElement$1;
	} );
}

(9432–9452)

这是 jQuery 源码里少见的"坦白式注释":它直接列出了三个已知的"不准确"场景,说这些场景"现实中没出现过,而且当前结果可能更可取",最后加一句"这个逻辑不保证,未来随时可能改变"。

这种注释的价值在于:它告诉维护者这些行为是偶然的,不是契约。如果你要依赖 offsetParent() 的某个边界行为,注释明确警告了不要这么做。

实现上:用 this.map(...) 而不是 this.each(...),所以返回一个新的 jQuery 集合(offsetParent 的集合)。这是 offsetParent() 与 offset()/position() 的重大区别——后者只处理 this[0]。

while 循环跳过多层 static 祖先,与 position() 里的逻辑一致,但没有 !== doc.documentElement 的终止条件。所以:

  • 如果 offsetParent 是 body 且 body 是 static → 继续找 body.offsetParent(通常是 null)→ 循环结束 → offsetParent || documentElement$1 → 返回 documentElement

documentElement$1 而不是 elem.ownerDocument.documentElement:这是一个真实的 bug 源——对 iframe 内的元素,会返回主文档的 documentElement(注释里的第 1 条)。jQuery 承认了这一点。

10.8.4 jQuery.offset.setOffset:把"目标位置"翻译成 CSS

jQuery.offset = {
	setOffset: function( elem, options, i ) {
		var curPosition, curLeft, curCSSTop, curTop, curOffset, curCSSLeft, calculatePosition,
			position = jQuery.css( elem, "position" ),
			curElem = jQuery( elem ),
			props = {};

		// Set position first, in-case top/left are set even on static elem
		if ( position === "static" ) {
			elem.style.position = "relative";
		}

		curOffset = curElem.offset();
		curCSSTop = jQuery.css( elem, "top" );
		curCSSLeft = jQuery.css( elem, "left" );
		calculatePosition = ( position === "absolute" || position === "fixed" ) &&
			( curCSSTop + curCSSLeft ).indexOf( "auto" ) > -1;

		// Need to be able to calculate position if either
		// top or left is auto and position is either absolute or fixed
		if ( calculatePosition ) {
			curPosition = curElem.position();
			curTop = curPosition.top;
			curLeft = curPosition.left;

		} else {
			curTop = parseFloat( curCSSTop ) || 0;
			curLeft = parseFloat( curCSSLeft ) || 0;
		}

		if ( typeof options === "function" ) {

			// Use jQuery.extend here to allow modification of coordinates argument (gh-1848)
			options = options.call( elem, i, jQuery.extend( {}, curOffset ) );
		}

		if ( options.top != null ) {
			props.top = ( options.top - curOffset.top ) + curTop;
		}
		if ( options.left != null ) {
			props.left = ( options.left - curOffset.left ) + curLeft;
		}

		if ( "using" in options ) {
			options.using.call( elem, props );

		} else {
			curElem.css( props );
		}
	}
};

(9295–9345)

这是 .offset( { top, left } ) 的实现,也是 jQuery 里最需要几何直觉的一段。

问题:用户给的是"目标文档坐标",但 CSS 的 top/left 是"相对于包含块的偏移"。两者不是同一个数。

解法:用增量而不是绝对值。

新 top = 目标 top - 当前 offset.top + 当前 CSS top

即:算出"要移动多少",加到现有的 CSS top 上。这样无论包含块是谁,移动量都是对的 ✓

步骤 1:static 元素先变 relative

if ( position === "static" ) {
	elem.style.position = "relative";
}

top/left 对 static 元素无效。所以必须先改成 relative(relative 保持元素在正常流中的位置,但允许 top/left 偏移)✓

步骤 2:确定"当前 CSS top/left"

calculatePosition = ( position === "absolute" || position === "fixed" ) &&
	( curCSSTop + curCSSLeft ).indexOf( "auto" ) > -1;

如果元素是 absolute/fixed 且 top 或 left 是 auto,那么CSS 的 top 值不代表实际位置——实际位置由"静态位置"(static position,即元素在正常流中应该在的位置)决定。此时要用 curElem.position()(几何计算出的实际偏移)作为基准。

curCSSTop + curCSSLeft 是字符串拼接(如 "auto" + "10px" = "auto10px"),indexOf( "auto" ) > -1 检查任一个是 auto ✓ 这是一个简洁的"两个值里有没有 auto"的判断。

否则(元素非 absolute/fixed,或 top/left 都有具体值),直接用 CSS 值:parseFloat( curCSSTop ) || 0。

步骤 3:函数式 options(gh-1848)

if ( typeof options === "function" ) {
	options = options.call( elem, i, jQuery.extend( {}, curOffset ) );
}

.offset( function( index, coords ) { return { top: coords.top + 10 }; } )。

jQuery.extend( {}, curOffset ) 是拷贝——注释说 "to allow modification of coordinates argument"。如果不拷贝,用户在回调里改 coords 会污染 curOffset,而后面还要用 curOffset.top 算增量 ✓

步骤 4:using 回调

if ( "using" in options ) {
	options.using.call( elem, props );
} else {
	curElem.css( props );
}

.offset( { top: 100, using: function( props ) { $( this ).animate( props ); } } )。

using 让用户接管"如何应用这些 props"——最常见的用法是配合 .animate()。这是 jQuery 的经典扩展点设计(与 jQuery.fn.animate 的 step/complete 同类)。

注意 "using" in options 用的是 in,不是 options.using——因为 using 可能是 undefined(显式设为 undefined 也算"提供了这个键")。实际上两者在这里差别不大,用 in 更严格。


10.9 滚动:scrollLeft / scrollTop

// Create scrollLeft and scrollTop methods
jQuery.each( { scrollLeft: "pageXOffset", scrollTop: "pageYOffset" }, function( method, prop ) {
	var top = "pageYOffset" === prop;

	jQuery.fn[ method ] = function( val ) {
		return access( this, function( elem, method, val ) {

			// Coalesce documents and windows
			var win;
			if ( isWindow( elem ) ) {
				win = elem;
			} else if ( elem.nodeType === 9 ) {
				win = elem.defaultView;
			}

			if ( val === undefined ) {
				return win ? win[ prop ] : elem[ method ];
			}

			if ( win ) {
				win.scrollTo(
					!top ? val : win.pageXOffset,
					top ? val : win.pageYOffset
				);

			} else {
				elem[ method ] = val;
			}
		}, method, val, arguments.length );
	};
} );

(9455–9485)

映射表的作用

jQuery.each( { scrollLeft: "pageXOffset", scrollTop: "pageYOffset" }, ... )
jQuery 方法window 上的对应属性
scrollLeftpageXOffset
scrollToppageYOffset

var top = "pageYOffset" === prop; —— 标记当前处理的是"垂直"还是"水平"。用于后面的 scrollTo 参数选择。

"Coalesce documents and windows"(合并文档与窗口)

var win;
if ( isWindow( elem ) ) {
	win = elem;
} else if ( elem.nodeType === 9 ) {
	win = elem.defaultView;
}

$( window ).scrollTop() 和 $( document ).scrollTop() 等价——因为后者被转换为 document.defaultView(即 window)。

这是一个很人性化的设计:用户不需要记住"滚动是 window 的还是 document 的"。

读路径

if ( val === undefined ) {
	return win ? win[ prop ] : elem[ method ];
}
  • window/document → win.pageYOffset
  • 普通元素 → elem.scrollTop

为什么 window 用 pageYOffset 而元素用 scrollTop? 因为 window 没有 scrollTop 属性(Window 接口没有),只有 pageYOffset(以及别名 scrollY)。而元素没有 pageYOffset。两者语义相同(滚动偏移),但属性名不同。

写路径

if ( win ) {
	win.scrollTo(
		!top ? val : win.pageXOffset,
		top ? val : win.pageYOffset
	);

} else {
	elem[ method ] = val;
}

scrollTo( x, y ) 需要两个参数。但我们只想改一个:

  • 对 scrollTop(top === true):scrollTo( win.pageXOffset, val ) —— 保持水平不变 ✓
  • 对 scrollLeft(top === false):scrollTo( val, win.pageYOffset ) —— 保持垂直不变 ✓

!top ? val : win.pageXOffset 是第一个参数(x):top 为 false(即 scrollLeft)时取 val,否则取当前 x ✓ top ? val : win.pageYOffset 是第二个参数(y):top 为 true(即 scrollTop)时取 val,否则取当前 y ✓

为什么用 scrollTo 而不是 win.scrollY = val? 因为 pageYOffset / scrollY 是只读的。设置滚动必须调 scrollTo / scroll / scrollBy。

arguments.length 作为 chainable:$( window ).scrollTop( 0 ) → arguments.length === 1 → truthy → 写模式 ✓;$( window ).scrollTop() → 0 → 读模式 ✓

一个已知的不对称

$( el ).scrollTop() 对元素返回 elem.scrollTop(数字)。但元素必须是可滚动的(有 overflow: auto/scroll 且内容溢出),否则恒为 0。

而 $( window ).scrollTop() 返回的是文档滚动量。jQuery 没有为 document.scrollingElement 做特殊处理——因为在标准模式下 document.documentElement.scrollTop 就是文档滚动,而 jQuery 把它 coalesce 成了 window,绕过了这个问题 ✓

与 offset() 的协同

// 滚动到某元素
$( "html, body" ).animate( { scrollTop: $( "#target" ).offset().top }, 500 );

这是最常见的用法。offset().top 给出文档坐标,scrollTop 设为该值即可滚到元素顶部。

注意 $( "html, body" ) 而不是 $( "body" )——因为不同浏览器的滚动容器不同(标准模式是 documentElement,怪异模式是 body)。jQuery 的 scrollTop 对 html 和 body 元素都走 elem[ method ] = val 分支,所以两个都设能覆盖所有情况。

10.10 陷阱与边界(18 条)

陷阱 1:.css( "width" ) 与 .width() 返回值不一致

现象:

<div id="box" style="width: 50%"></div>
$( "#box" ).css( "width" );   // "50%"    字符串
$( "#box" ).width();          // 480      数字(父元素 960 的一半)

根因:getWidthOrHeight 的阶段二(6077–6082):

if ( rnumnonpx.test( val ) ) {
	if ( !extra ) {
		return val;      // .css() 走这里,返回 "50%"
	}
	val = "auto";        // .width() 走这里,强制 offset 回退
}

.css( "width" ) 的 extra 是 undefined → !extra 为 true → 提前返回字符串。 .width() 的 extra 是 "content" → 走 offset 回退 → 返回 offsetWidth(border box)再转 content box。

后果:如果你写 $( el ).width() === $( el ).css( "width" ) 判断,永远不相等(类型都不同)。

正确心智模型:.css( prop ) 问的是"CSS 声明/计算值是什么",.width() 问的是"渲染出来多宽"。

陷阱 2:outerWidth( true ) 的 boolean 会关闭写模式

现象:

$( el ).outerWidth( true );       // 读,含 margin
$( el ).outerWidth( 100 );        // 写,按 border 盒换算
$( el ).outerWidth( 100, true );  // 写,按 margin 盒换算

根因:chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" )(9497)。对 outerWidth,defaultExtra 是 ""(falsy),所以看 typeof margin !== "boolean":

  • margin === true → typeof 是 "boolean" → 表达式 false → chainable = false → 读模式
  • margin === 100 → typeof 是 "number" → true → chainable = true → 写模式

后果:$( el ).outerWidth( false ) 也是读模式(false 也是 boolean),等价于 outerWidth()。而 $( el ).outerWidth( 0 ) 是写模式(设置宽度为 0)。

这是一个靠参数类型区分语义的设计,容易误用。

陷阱 3:box-sizing 会改变 .width() 的含义

现象:同一个元素,box-sizing 从 content-box 改成 border-box 后,.width() 的返回值变了。

width: 100px; padding: 10px; border: 2px;
content-box: .width() = 100,  .outerWidth() = 124
border-box:  .width() = 76,   .outerWidth() = 100

根因:.width() 的 extra 是 "content",永远返回 content box 宽度。CSS 里 width 属性在 content-box 下指的是 content 宽,在 border-box 下指的是 border box 宽。所以换算方向相反。

后果:Bootstrap 等框架会设 * { box-sizing: border-box },此时 .width() 返回的数字小于 CSS 里写的 width。很多人以为 .width() 应该等于 CSS 值,这是错的。

规避:如果你要"CSS 里写的那个值",用 .css( "width" );如果你要"内容区实际多宽",用 .width();如果你要"占多大地方",用 .outerWidth()。

陷阱 4:对隐藏元素读尺寸是昂贵操作

现象:循环里对 display: none 的元素调用 .width(),性能骤降。

根因:cssHooks.width.get(6264–6268):

return jQuery.css( elem, "display" ) === "none" ?
	swap( elem, cssShow, function() {
		return getWidthOrHeight( elem, dimension, extra );
	} ) :
	getWidthOrHeight( elem, dimension, extra );

每次都要:读 display → 写三个内联样式 → 读 offsetWidth(强制 reflow)→ 恢复三个内联样式(又一次 reflow)。

量化:一次 swap + getWidthOrHeight 约 0.1–1 ms。100 个隐藏元素 = 10–100 ms。

规避:批量处理时先显示容器(或移到视口外),读完全部尺寸后再隐藏:

// 慢
$( ".hidden-item" ).each( function() { sizes.push( $( this ).width() ); } );

// 快
var $p = $( ".hidden-item" ).parent();
$p.show();
$( ".hidden-item" ).each( function() { sizes.push( $( this ).width() ); } );
$p.hide();

陷阱 5:inline 元素的 .width() 走 offset 回退

现象:

<span id="s">hello</span>
$( "#s" ).css( "width" );   // "auto"
$( "#s" ).width();          // 37(offsetWidth)

根因:getComputedStyle 对未显式设宽的 inline 元素返回 "auto"(gh-3571)。getWidthOrHeight 的 val === "auto" 条件成立 → 走 offset 回退 → val = elem.offsetWidth。

注意:offsetWidth 是 border box,所以回退后 valueIsBorderBox = true,后续 boxModelAdjustment 会按 border box 解释。对 .width()(extra = "content")会减去 padding + border。inline 元素的 padding 在垂直方向不参与布局,但水平方向参与,所以结果仍有意义。

陷阱 6:.width( "50%" ) 会触发一次"先写后读"

现象:.width( "50%" ) 比 .width( 500 ) 慢。

根因:cssHooks.width.set(6290–6295):

if ( subtract && ( matches = rcssNum.exec( value ) ) &&
	( matches[ 3 ] || "px" ) !== "px" ) {

	elem.style[ dimension ] = value;
	value = jQuery.css( elem, dimension );
}

当 subtract 非零(需要盒模型换算)且值不是 px 时,jQuery 必须先把 50% 写进去让浏览器算出实际 px,才能做减法。这是一次额外的写入 + 读取。

注意条件:subtract 必须非零。对 .width()(extra = "content")在 content-box 元素上,boxModelAdjustment 早退返回 0 → subtract = 0 → 跳过换算。所以 .width( "50%" ) 是快的,.outerWidth( "50%" ) 是慢的。

陷阱 7:SVG 元素没有 offsetWidth

现象:$( svgRect ).width() 返回的值与预期不符。

根因:valueIsBorderBox = offsetProp in elem(6110)。SVG 元素(SVGElement)不继承 HTMLElement 的 offsetWidth/offsetHeight,所以 offsetProp in elem 为 false,回退不生效,val 保持 curCSS 的计算值,且被当作 content box 解释。

源码注释(6108–6109)明说:"Where not available (e.g., SVG), assume unreliable box-sizing and interpret the retrieved value as a content box dimension."

后果:对 SVG 元素,.width() 基本等价于 parseFloat( getComputedStyle( el ).width )。

陷阱 8:.offset() 只处理集合的第一个元素

现象:

$( "div" ).offset();   // 只返回第一个 div 的位置,不是数组

根因:var elem = this[ 0 ];(9362),且读分支直接 return { top, left }。

对比:.offsetParent() 用 this.map(...),返回集合;.position() 也只用 this[0]。

规避:要批量取位置用 .map():

var positions = $( "div" ).map( function() { return $( this ).offset(); } ).get();

陷阱 9:.offset() 对隐藏/游离元素返回 { top: 0, left: 0 }

现象:

$( "#hidden" ).offset();   // { top: 0, left: 0 }

根因:9372–9374 的 if ( !elem.getClientRects().length ) { return { top: 0, left: 0 }; }(gh-2310)。注释还提到:在 IE 上对游离节点调 getBoundingClientRect 会抛错,所以这个守卫同时是防崩溃。

后果:{ top: 0, left: 0 } 与"元素真的在左上角"无法区分。需要区分时必须先判断可见性。

陷阱 10:.offset( undefined ) 返回 this 而不是读

现象:

var opts = maybeUndefined;
$( el ).offset( opts );    // 若 opts 是 undefined,返回 jQuery 对象,no-op

根因:9353–9359 的 if ( arguments.length ) { return options === undefined ? this : ... }。只要传了参数(哪怕 undefined),就进 setter 分支;options === undefined 时直接返回 this 保持链式。

设计意图(注释 "Preserve chaining for setter"):避免"根据运行时值决定读/写"的代码意外变成读操作,破坏链式调用。

陷阱 11:position() 对 fixed 元素用 getBoundingClientRect

现象:position: fixed 元素的 .position() 返回的是视口相对坐标,而普通元素是包含块相对坐标。

根因:9397–9400:

if ( jQuery.css( elem, "position" ) === "fixed" ) {
	offset = elem.getBoundingClientRect();
}

parentOffset 保持 { top: 0, left: 0 }(视口偏移恒为 0)。

语义一致性:这是正确的——fixed 元素的包含块就是视口,所以"相对包含块的坐标"就是"相对视口的坐标" ✓

后果:页面滚动时,fixed 元素的 .position() 不变,而普通元素的 .position() 也不变(因为都是相对包含块)。但 fixed 元素的 .offset() 会随滚动变化(因为 offset 是文档坐标)。

陷阱 12:offsetParent() 对 iframe 内元素返回主文档的 documentElement

现象:iframe 内元素(且无合格 offsetParent)的 .offsetParent() 返回主页面的 <html>。

根因:9450 用 documentElement$1(模块级常量,指向主文档的 document.documentElement)而不是 elem.ownerDocument.documentElement。

jQuery 的态度:源码注释(9432–9441)坦白列出了三个已知不准确的场景,并说"这些异常从未在现实中出现过,当前结果可能更可取;但此逻辑不保证,未来可能改变"。

规避:需要精确结果时用原生 elem.offsetParent。

陷阱 13:scrollTop 对不可滚动元素恒为 0

现象:

$( "#nonScrollable" ).scrollTop( 100 );
$( "#nonScrollable" ).scrollTop();   // 0

根因:设置 elem.scrollTop = 100 时,如果元素不可滚动(overflow: visible 或内容未溢出),浏览器会把值钳到 0。这是 DOM 行为,不是 jQuery 的问题。

规避:先把元素设为可滚动:

$( "#box" ).css( "overflow", "auto" );
$( "#box" ).scrollTop( 100 );

陷阱 14:$( document ).height() 可能出现的偏差

现象:$( document ).height() 返回值可能大于真实内容高度。

根因:9517–9521 取 5 个值的 Math.max:

Math.max(
	elem.body[ "scroll" + name ], doc[ "scroll" + name ],
	elem.body[ "offset" + name ], doc[ "offset" + name ],
	doc[ "client" + name ]
)

如果 body 有 margin(浏览器默认 8px),body.offsetHeight 会比内容高度小,但 body.scrollHeight 可能受 margin 折叠影响而偏大。取 max 是保守策略——宁可偏大也不偏小(因为常见用途是"判断内容是否超出一屏")。

规避:要精确值用 document.documentElement.scrollHeight。

陷阱 15:$( window ).width() 不含滚动条,outerWidth() 含

现象:页面有垂直滚动条时:

$( window ).width();         // 1920 - 15 = 1905(视口宽,不含滚动条)
$( window ).outerWidth();    // 1920(window.innerWidth,含滚动条)
$( window ).innerWidth();    // 1905(与 .width() 相同!)

根因:9506–9508:

return funcName.indexOf( "outer" ) === 0 ?
	elem[ "inner" + name ] :
	elem.document.documentElement[ "client" + name ];

只有以 "outer" 开头的方法走 window.innerWidth。gh-1729 是这个行为的 bug 编号。

后果:$( window ).innerWidth() 不等于 window.innerWidth。这是一个命名冲突导致的反直觉行为。

陷阱 16:.hide() 之后再 .show() 不一定恢复原状

现象:

<div id="a" style="display: inline">x</div>
<div id="b">x</div>   <!-- CSS: #b { display: inline-block } -->
$( "#a" ).hide().show();   // 恢复成 inline ✓(dataPriv 记的是 "inline")
$( "#b" ).hide().show();   // dataPriv 记的是 "",show 时清空内联 → 回落到 CSS 的 inline-block ✓
$( "#b" ).css( "display", "block" ).hide().show();   // 记的是 "block" ✓

根因:showHide 的 hide 分支记 dataPriv.set( elem, "display", display ),其中 display 是 elem.style.display(内联值)。show 时取出该值写回。如果原内联值是 "",写回 "" 等于删除内联声明,回落到级联 ✓

真正的陷阱:如果 .hide() 之后修改了 CSS 规则,.show() 会用旧值覆盖新规则。以及:

$( el ).hide();
el.style.display = "flex";    // 绕过 jQuery 直接改
$( el ).show();               // 会写成 dataPriv 里的旧值,覆盖 flex!

陷阱 17:getDefaultDisplay 首次调用昂贵,且对 <script> 返回 block

现象:第一次 .show() 一个 <div> 会创建 + 插入 + 读取 + 删除一个临时元素。

根因:getDefaultDisplay(4219–4240)没有缓存时的完整流程。defaultDisplayMap[ nodeName ] 缓存后不再重复。

另一个后果:<script>、<style>、<template>、<head> 的 UA 默认 display 是 none,getDefaultDisplay 会兜底返回 "block"(4234–4236)。所以 $( "script" ).show() 会让它变成 display: block——虽然 script 仍然不可见(因为它没有渲染内容),但会占据布局空间。

陷阱 18:负 margin 与 outerWidth( true )

现象:

<div style="width:100px; margin:-20px"></div>
$( el ).outerWidth( true );   // 100 + (-20*2) = 60,而不是 100

根因:boxModelAdjustment 的 marginDelta 分支(6006–6008):

if ( box === "margin" ) {
	marginDelta += jQuery.css( elem, box + cssExpand[ i ], true, styles );
}

jQuery.css( ..., true ) 返回带符号的数字,所以 margin: -20px 得到 -20,marginDelta = -40。

delta + marginDelta = 0 + (-40) = -40,100 - 40 = 60 ✓

这就是 gh-3982 的意义:如果 margin 混在 delta 里,会被滚动条公式的 Math.max( 0, ... ) 钳掉。分开累计后负值得以保留 ✓

注意:getWidthOrHeight 的最后是 ( val + 调整量 ) + "px",如果调整量绝对值大于 val,会得到负数(如 -40px)。只有 setPositiveNumber(写通道)才做非负钳制,读通道不钳制。


10.11 全章总结

核心组件表

组件行号职责关键设计
jQuery.cssHooks6138样式钩子的公开扩展点空对象起步,鸭子类型协议
cssHooks.width / cssHooks.height6257–6300尺寸读写钩子get 在隐藏时用 swap;set 做单位统一化
cssHooks.margin/padding/borderWidth6303–6328简写展开 + 非负钳制expand 用 parts[i] || parts[i-2] || parts[0] 实现 CSS 省略规则;margin 不装 set(允许负值)
getWidthOrHeight6061–6132尺寸主入口六条路径、四个 offset 回退条件
boxModelAdjustment5990–6059盒模型加减 + 滚动条i 起始值 + 步长 2 表达方向对;margin 单独累计
setPositiveNumber5978–5988写通道值钳制Math.max( 0, ... ),第一个参数未使用
jQuery.fn.css6330–6353公开 APIaccess 四态 + 数组读复用 styles
尺寸方法族(6 个)9487–9534width/innerWidth/outerWidth 及高变体二重 jQuery.each 代码生成
isHiddenWithinTree4088–4105display 判据(不传染)与 :hidden 分野
getDefaultDisplay4217–4240标签默认 display 探测建同标签临时元素插 body 读值;defaultDisplayMap 缓存
showHide4242–4288显隐两遍循环避免布局抖动;dataPriv 存原值
jQuery.fn.offset9349–9383border box → 文档原点gBCR + pageX/YOffset
jQuery.fn.position9387–9430margin box → 包含块 padding boxfixed 特判;向上跳过 static
jQuery.fn.offsetParent9442–9452定位父坦白式注释列出 3 个已知不准场景
jQuery.offset.setOffset9295–9345位置写入增量翻译;using 回调
scrollLeft / scrollTop9455–9485滚动window/document 归一;scrollTo 双参数

工程经验清单

  1. 用数据驱动生成 API。六个尺寸方法由二重 jQuery.each 生成,键是 defaultExtra、值是方法名。改动只需改那张映射表。这是 jQuery 的标准代码生成手法,在第 07、08 章也反复出现。

  2. 方向对用"起始索引 + 步长"表达。var i = dimension === "width" ? 1 : 0; for ( ; i < 4; i += 2 ) —— 用 cssExpand 的索引奇偶性一次性覆盖两个方向。代价是可读性,收益是零分支。

  3. 中间量与结果量要分开。boxModelAdjustment 里的 extra(border 宽度,参与滚动条公式但不进结果)与 marginDelta(在滚动条计算之后才加)都是为了让同一个公式能服务于不同目标盒。

  4. 读通道不钳制,写通道钳制。getWidthOrHeight 可以返回 "-40px",setPositiveNumber 保证非负。语义不同:读是"报告事实",写是"构造合法 CSS"。

  5. 两遍循环分离读写。这是避免 layout thrashing 的标准手法,在 showHide(4249 决定 / 4281 写入)里体现得最清楚。任何"读一批 → 写一批"的 DOM 批处理都应该照此组织。

  6. 哨兵变量兼作状态标记。values[ index ] = ... || null 把 undefined(未赋值)和 null(不需改变)区分开,稀疏数组同时表达了"要不要写"和"写什么"。

  7. 不确定的行为要写下来。offsetParent() 的三段注释是范例:列出已知不准的场景、说明为什么当前结果可接受、警告未来可能改变。这比沉默的"正确"更有价值。

  8. 无法解决的缺陷要承认。support.reliableColDimensions 对 IE 直接返回 true 并注释 "we accept this limitation"。诚实标注边界,比写一堆无效 workaround 更负责。

  9. 扩展点要留给用户接管。setOffset 的 using 回调、cssHooks 的 get/set——jQuery 在"我算好参数,你怎么用随你"的位置留接口,这是它生态繁荣的原因之一。

  10. 同一个概念在不同模块可能有不同判据。:hidden(几何)vs isHiddenWithinTree(display)不是重复,而是用途驱动的分化。理解"为什么有两套"比记住"有两套"重要。

"怪代码"解释表

代码位置为什么这么写
parts[ i ] || parts[ i - 2 ] || parts[ 0 ]6318一条表达式实现 CSS 简写的 1/2/3/4 值省略规则
if ( prefix !== "margin" ) { ...set = setPositiveNumber }6325–6327margin 可为负,不能钳制
boxSizingNeeded = isIE || extra6068gh-4322:能不读 boxSizing 就不读
"Fake content-box until we know it's needed"6067 注释用便宜的假值让后续走"不调整"分支
val = "auto"6081强制进入 offset 回退(非 px 值无法做盒模型加减)
valueIsBorderBox = offsetProp in elem6110SVG 没有 offsetWidth;有的话它恒是 border box
i = dimension === "width" ? 1 : 0 + i += 25991 / 6001用索引奇偶性选方向对
Math.ceil( x - 0.5 )6046–6051等价于 Math.round,配合 Math.max( 0, ... ) 防负
) ) || 0;6055gh-3964:offsetWidth 未知时避免 NaN
extra += border(不进 delta)6022参与滚动条公式但不参与结果
marginDelta 分开累计6006gh-3982:负 margin 不能被 Math.max(0,...) 钳掉
_elem 参数未使用5978钩子签名兼容(elem, value, extra)
"outer" + name 且 defaultExtra: ""9492空串键是 falsy,让 defaultExtra || 能走后半段
funcName.indexOf( "outer" ) === 09506区分 outerWidth(含滚动条)与其他
Math.max( 5 个值 )9517文档高度没有标准答案,取保守上界
chainable ? margin : undefined9531防止 outerWidth( true ) 的 true 被当 CSS 值写入
elem = el || elem4099兼容 jQuery#filter 的 ( elem, index, match ) 签名
elem.style.display === ""4103内联为空才看级联(内联优先)
values[ index ] = ... || null4262区分"未赋值"与"不需改变"
doc.body.appendChild( doc.createElement( nodeName ) )4229必须插入文档才能获得 UA 默认样式
offset.top - parentOffset.top - marginTop9427border box → margin box 要减 margin
parentOffset.top += borderTopWidth9420border box → padding box 要加 border
( curCSSTop + curCSSLeft ).indexOf( "auto" ) > -19311字符串拼接查两个值里有没有 auto
!top ? val : win.pageXOffset9476scrollTo 需要双参数,只改一个时另一个保持
offsetParent || documentElement$19450已知对 iframe 不准,注释坦白了

附录 A:本章源码索引

行号内容
4088–4105isHiddenWithinTree
4217–4240defaultDisplayMap / getDefaultDisplay
4242–4288showHide
4290+jQuery.fn.extend( { show, hide, toggle } )
5978–5988setPositiveNumber
5990–6059boxModelAdjustment
6061–6132getWidthOrHeight
6134–6138jQuery.cssHooks = {}
6257–6300cssHooks.height / cssHooks.width
6303–6328cssHooks.margin / padding / borderWidth
6330–6353jQuery.fn.css
8881 / 8884:visible / :hidden 伪类
9289–9293jQuery.expr.pseudos.animated
9295–9345jQuery.offset.setOffset
9347–9453offset / position / offsetParent
9455–9485scrollLeft / scrollTop
9487–9534六个尺寸方法

附录 B:速查表

六个尺寸方法对照(content-box 元素:width 100 / padding 10 / border 2 / margin 5)

方法extra读结果写 200 时实际写入
.width()"content"100200px
.innerWidth()"padding"120180px
.outerWidth()"border"124176px
.outerWidth( true )"margin"134166px

border-box 元素(width 100 表示 border box 总宽)

方法读结果
.width()76
.innerWidth()96
.outerWidth()100
.outerWidth( true )110

boxModelAdjustment 的四盒换算

起点 \ 目标contentpaddingbordermargin
content-box0+2P+2P+2B+2P+2B+2M
border-box-2P-2B-2B0+2M

(P/B/M 为单侧值;content-box 目标为 padding/border/margin 时还要加上滚动条 gutter)

三个几何 API 的参照系

API参照系测量对象
.offset()文档原点元素 border box
.position()包含块 padding box元素 margin box
.offsetParent()—定位父元素(集合)
.scrollTop()—滚动偏移

附录 C:思考题

  1. .css( "width" ) 返回 "50%",.width() 返回 480。 这两个值在源码里由哪一行分支决定?如果要让 .css( "width" ) 也返回像素值,需要改哪里?会带来什么副作用?

  2. boxModelAdjustment 里 extra 变量(记录 border 宽度但不进 delta)只在 box === "padding" 分支被赋值。 为什么其他分支不需要它?请从滚动条公式推导。

  3. 为什么 marginDelta 必须在 return delta + marginDelta 时才加,而不能直接累加到 delta? 构造一个具体的反例(含负 margin 和滚动条)说明。

  4. getWidthOrHeight 的四个 offset 回退条件里,isIE && isBorderBox 这一条。 IE 的什么行为导致必须回退?如果去掉这一条,在 IE 上会出现什么现象?

  5. valueIsBorderBox = offsetProp in elem。 除了 SVG,还有哪些元素没有 offsetWidth?jQuery 对这些元素的尺寸处理是否符合 CSS 语义?

  6. showHide 的两遍循环真的减少 reflow 吗? 第一遍里有 isHiddenWithinTree(读 getComputedStyle)和 elem.style.display = ""(写),这本身不就是读写交替吗?请分析 jQuery 的做法是否真的达到了目的。

  7. getDefaultDisplay 把 <script> 的默认 display 兜底成 block。 这是好设计吗?如果改成返回 "none" 会怎样?

  8. offsetParent() 用 documentElement$1(主文档)而非 elem.ownerDocument.documentElement。 请给出一个复现步骤,证明这个 bug 在 iframe 场景下确实存在。为什么 jQuery 选择不修?

  9. setOffset 用增量而不是绝对值计算新的 top/left。 如果改用绝对值(直接 props.top = options.top),在什么情况下会出错?

  10. scrollLeft/scrollTop 用 win.scrollTo( x, y ) 而不是 win.scrollX = val。 除了"属性只读"这个理由,还有别的考虑吗?(提示:scrollTo 支持 behavior: "smooth")

  11. chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" )。 这个表达式让 outerHeight( false ) 变成读操作。这是有意的还是意外?请分析 false 与 0 的行为差异并评价。

  12. 假设你要给 jQuery 加一个 .contentBoxWidth() 方法(永远返回 content box 宽度,无论 box-sizing 如何)。请写出实现,并说明它应该插入 jQuery.each 的哪个位置。


下一章预告

第 09、10 章完成了"元素长什么样、在哪里、有多大"这一层。接下来进入 jQuery 最庞大、最精妙的模块——事件系统。

第 11 章 事件系统(上):注册与分发 将覆盖:

  • jQuery.event.add:on 的参数洗牌(on( types, selector, data, fn ) 的四种重载如何归一化)
  • handleObj 结构:一次 on 调用在元素上留下的数据结构,以及 guid 的作用
  • delegateCount:为什么委托处理器必须排在数组前面,以及这个设计如何用一次排序换取分发时的零判断
  • dispatch:事件分发的主循环,从 native event 到 jQuery.Event 到处理器链
  • handlers:委托路径的冒泡遍历,jQuery.find.matchesSelector 在每层祖先上的调用
  • 命名空间:rtypenamespace 正则与 .off( "click.myNS" ) 的匹配语义
  • off 的重载:五种调用形态如何映射到同一套删除逻辑
  • special 协议:setup / teardown / add / remove 四个钩子如何在注册期介入