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

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

本章导览

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

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

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

scss 复制代码
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

js 复制代码
// 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)

js 复制代码
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 步是这段代码的精髓:

js 复制代码
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)

js 复制代码
// 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:

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

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

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

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

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

js 复制代码
parts[ i ] || parts[ i - 2 ] || parts[ 0 ]

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

i cssExpand[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" undefined parts[1]="20px" --- "20px" ✓

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

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

设 parts = ["10px"]:

i 结果
0 "10px"
1 `undefined
2 `undefined
3 `undefined

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?

js 复制代码
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 分量:

js 复制代码
$.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:尺寸的六条路径

js 复制代码
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):

js 复制代码
offsetProp = "offset" + dimension[ 0 ].toUpperCase() + dimension.slice( 1 );

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

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

js 复制代码
// 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 唯一的提前返回 ,而且它返回的是字符串,不是数字。所以:

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

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

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

js 复制代码
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 && isBorderBox IE 的 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):

js 复制代码
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 参数...

等等,看最后调用:

js 复制代码
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)

js 复制代码
// 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 处理:

js 复制代码
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。

# extra val 初值 阶段二 阶段三 val 终值 box valueIsBorderBox boxModelAdjustment 返回
1 undefined "100px" rnumnonpx 不匹配 不触发 100 "content" false 0(早退) "100px"
2 "" "100px" 同上 不触发 100 "content" false 0 "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"
6 true "100px" 不匹配 不触发 100 true false box=true 不等于 content/border → 走 else... 见下

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

js 复制代码
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。

# extra box valueIsBorderBox 调整 返回
1 undefined "border"(isBorderBox 为 true) true box === "border" → 早退 0 "100px"
2 "content" "content" true -20 -4 = -24 "76px"
3 "padding" "padding" true -4(只减 border) "96px"
4 "border" "border" true 0(早退) "100px"
5 "margin" "margin" true 0 + 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)

js 复制代码
// 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(取整)。所以:

css 复制代码
offsetWidth = contentWidth + padding*2 + scrollGutter + border*2

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

公式推导:

ini 复制代码
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 的判据差异

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

位置 判据 用途
6103 elem.getClientRects().length 决定是否可以用 offset 回退
6077 之前的 curCSS isAttached( 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:四盒模型的加减法

js 复制代码
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

js 复制代码
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)

js 复制代码
// 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 时)。

循环体的两条大分支

js 复制代码
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):

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

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

box padding 处理 border 处理 margin 处理 delta(双侧) marginDelta 总调整
"border" --- --- --- --- --- 0(早退)
"padding" 不减 -B --- -2B 0 -2B
"content" -P -B --- -2P-2B 0 -2P-2B
"margin" 不减 不减(box !== "margin" 为 false) +M 0 +2M +2M
true 不减 -B --- -2B 0 -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 的计算公式:

ini 复制代码
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:

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

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

css 复制代码
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 与负边距

js 复制代码
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:

js 复制代码
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):

js 复制代码
}, 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 ):

js 复制代码
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 的第四态

js 复制代码
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 展示了它的完整四态:

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

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

js 复制代码
// 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 在对象分支里是:

js 复制代码
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:

js 复制代码
}, 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 的结构是:

js 复制代码
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。所以链式返回,但没效果。

数组读分支

js 复制代码
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 里:

js 复制代码
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 ) 对空集合

js 复制代码
$( "#nonexistent" ).css( "color" );

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

js 复制代码
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

js 复制代码
// 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 }

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

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

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

chainable 的计算

js 复制代码
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 只在写时用。实际用法:

js 复制代码
$( 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 的计算

js 复制代码
extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" );
方法 defaultExtra extra 结果
.height() / .height( 100 ) "content" "content"
.innerHeight() "padding" "padding"
.outerHeight() "" `margin === true
.outerHeight( true ) "" "margin"

access 的调用

js 复制代码
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

js 复制代码
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)

js 复制代码
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.scrollHeight body 的内容高度(含溢出) 内容溢出时
documentElement.scrollHeight html 的内容高度 通常
body.offsetHeight body 的 border-box 高度 body 有 border/margin 时
documentElement.offsetHeight html 的 border-box 高度 ---
documentElement.clientHeight html 的视口高度(不含滚动条) 内容不足一屏时

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

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

分支 3:普通元素

js 复制代码
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 里:

js 复制代码
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,不是百分比对应的像素)?

不对。回到阶段二:

js 复制代码
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" 100 0 100
.innerWidth() "padding" 100 +20 120
.outerWidth() "border" 100 +24 124
.outerWidth( true ) "margin" 100 +24+10 134
.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 -24 76
.innerWidth() "padding" 100 -4 96
.outerWidth() "border" 100 0 100
.outerWidth( true ) "margin" 100 +10 110

10.7 显隐:isHiddenWithinTree / getDefaultDisplay / showHide

10.7.1 isHiddenWithinTree 与 :hidden 的分野

js 复制代码
// 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 附近)对比:

js 复制代码
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":

js 复制代码
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:问浏览器"这个标签默认怎么显示"

js 复制代码
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:两遍循环的设计

js 复制代码
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 分支

js 复制代码
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 );
	}
}

三种情况:

情况 内联 display dataPriv 里有记录? 处理
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 分支

js 复制代码
} else {
	if ( display !== "none" ) {
		values[ index ] = "none";

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

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

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

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

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

第二遍

js 复制代码
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

js 复制代码
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 章会讲动画版本)在无动画时大致是:

js 复制代码
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 相对文档原点

js 复制代码
// 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 正确性。

写路径:

js 复制代码
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

js 复制代码
// 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:
js 复制代码
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:
js 复制代码
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. 最终计算:
js 复制代码
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():三段注释的坦白

js 复制代码
// 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

js 复制代码
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 是"相对于包含块的偏移"。两者不是同一个数。

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

css 复制代码
新 top = 目标 top - 当前 offset.top + 当前 CSS top

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

步骤 1:static 元素先变 relative

js 复制代码
if ( position === "static" ) {
	elem.style.position = "relative";
}

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

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

js 复制代码
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)

js 复制代码
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 回调

js 复制代码
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

js 复制代码
// 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)

映射表的作用

js 复制代码
jQuery.each( { scrollLeft: "pageXOffset", scrollTop: "pageYOffset" }, ... )
jQuery 方法 window 上的对应属性
scrollLeft pageXOffset
scrollTop pageYOffset

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

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

js 复制代码
var win;
if ( isWindow( elem ) ) {
	win = elem;
} else if ( elem.nodeType === 9 ) {
	win = elem.defaultView;
}

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

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

读路径

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

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

写路径

js 复制代码
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() 的协同

js 复制代码
// 滚动到某元素
$( "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() 返回值不一致

现象:

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

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

js 复制代码
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 会关闭写模式

现象:

js 复制代码
$( 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() 的返回值变了。

css 复制代码
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):

js 复制代码
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。

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

js 复制代码
// 慢
$( ".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 回退

现象:

html 复制代码
<span id="s">hello</span>
js 复制代码
$( "#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):

js 复制代码
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() 只处理集合的第一个元素

现象:

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

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

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

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

js 复制代码
var positions = $( "div" ).map( function() { return $( this ).offset(); } ).get();

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

现象:

js 复制代码
$( "#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 而不是读

现象:

js 复制代码
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:

js 复制代码
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

现象:

js 复制代码
$( "#nonScrollable" ).scrollTop( 100 );
$( "#nonScrollable" ).scrollTop();   // 0

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

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

js 复制代码
$( "#box" ).css( "overflow", "auto" );
$( "#box" ).scrollTop( 100 );

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

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

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

js 复制代码
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() 含

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

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

根因:9506--9508:

js 复制代码
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() 不一定恢复原状

现象:

html 复制代码
<div id="a" style="display: inline">x</div>
<div id="b">x</div>   <!-- CSS: #b { display: inline-block } -->
js 复制代码
$( "#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() 会用旧值覆盖新规则。以及:

js 复制代码
$( 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 )

现象:

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

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

js 复制代码
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.cssHooks 6138 样式钩子的公开扩展点 空对象起步,鸭子类型协议
cssHooks.width / cssHooks.height 6257--6300 尺寸读写钩子 get 在隐藏时用 swap;set 做单位统一化
cssHooks.margin/padding/borderWidth 6303--6328 简写展开 + 非负钳制 expand 用 `parts[i]
getWidthOrHeight 6061--6132 尺寸主入口 六条路径、四个 offset 回退条件
boxModelAdjustment 5990--6059 盒模型加减 + 滚动条 i 起始值 + 步长 2 表达方向对;margin 单独累计
setPositiveNumber 5978--5988 写通道值钳制 Math.max( 0, ... ),第一个参数未使用
jQuery.fn.css 6330--6353 公开 API access 四态 + 数组读复用 styles
尺寸方法族(6 个) 9487--9534 width/innerWidth/outerWidth 及高变体 二重 jQuery.each 代码生成
isHiddenWithinTree 4088--4105 display 判据(不传染) 与 :hidden 分野
getDefaultDisplay 4217--4240 标签默认 display 探测 建同标签临时元素插 body 读值;defaultDisplayMap 缓存
showHide 4242--4288 显隐 两遍循环避免布局抖动;dataPriv 存原值
jQuery.fn.offset 9349--9383 border box → 文档原点 gBCR + pageX/YOffset
jQuery.fn.position 9387--9430 margin box → 包含块 padding box fixed 特判;向上跳过 static
jQuery.fn.offsetParent 9442--9452 定位父 坦白式注释列出 3 个已知不准场景
jQuery.offset.setOffset 9295--9345 位置写入 增量翻译;using 回调
scrollLeft / scrollTop 9455--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 ]
if ( prefix !== "margin" ) { ...set = setPositiveNumber } 6325--6327 margin 可为负,不能钳制
`boxSizingNeeded = isIE extra`
"Fake content-box until we know it's needed" 6067 注释 用便宜的假值让后续走"不调整"分支
val = "auto" 6081 强制进入 offset 回退(非 px 值无法做盒模型加减)
valueIsBorderBox = offsetProp in elem 6110 SVG 没有 offsetWidth;有的话它恒是 border box
i = dimension === "width" ? 1 : 0 + i += 2 5991 / 6001 用索引奇偶性选方向对
Math.ceil( x - 0.5 ) 6046--6051 等价于 Math.round,配合 Math.max( 0, ... ) 防负
`) ) 0;`
extra += border(不进 delta) 6022 参与滚动条公式但不参与结果
marginDelta 分开累计 6006 gh-3982:负 margin 不能被 Math.max(0,...) 钳掉
_elem 参数未使用 5978 钩子签名兼容(elem, value, extra)
"outer" + name 且 defaultExtra: "" 9492 空串键是 falsy,让 `defaultExtra
funcName.indexOf( "outer" ) === 0 9506 区分 outerWidth(含滚动条)与其他
Math.max( 5 个值 ) 9517 文档高度没有标准答案,取保守上界
chainable ? margin : undefined 9531 防止 outerWidth( true ) 的 true 被当 CSS 值写入
`elem = el elem`
elem.style.display === "" 4103 内联为空才看级联(内联优先)
`values[ index ] = ... null`
doc.body.appendChild( doc.createElement( nodeName ) ) 4229 必须插入文档才能获得 UA 默认样式
offset.top - parentOffset.top - marginTop 9427 border box → margin box 要减 margin
parentOffset.top += borderTopWidth 9420 border box → padding box 要加 border
( curCSSTop + curCSSLeft ).indexOf( "auto" ) > -1 9311 字符串拼接查两个值里有没有 auto
!top ? val : win.pageXOffset 9476 scrollTo 需要双参数,只改一个时另一个保持
`offsetParent documentElement$1`

附录 A:本章源码索引

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

附录 B:速查表

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

方法 extra 读结果 写 200 时实际写入
.width() "content" 100 200px
.innerWidth() "padding" 120 180px
.outerWidth() "border" 124 176px
.outerWidth( true ) "margin" 134 166px

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

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

boxModelAdjustment 的四盒换算

起点 \ 目标 content padding border margin
content-box 0 +2P +2P+2B +2P+2B+2M
border-box -2P-2B -2B 0 +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 四个钩子如何在注册期介入
相关推荐
Mia_墨黎1 小时前
2026 Windows 11 终端美化完全指南
前端·后端
用户1880687493521 小时前
戴着 Rokid 接成语:用 AIUI 做一局有提示、有复盘的“接着说”
前端
Sakura_j_h_2 小时前
Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面
前端·机械臂·webgl·three.js·1024程序员节·esbuild
计算机魔术师3 小时前
管谷歌的不是Pichai一个人,是一整层AI智能体
前端
米米商聊3 小时前
图标能点,按钮就可用了吗?键盘、名称与收藏状态
前端·javascript
Younglina4 小时前
三个晚上,我和 WorkBuddy 做了个生图网站
前端·后端·ai编程
xy34534 小时前
Axure 9.0 内联框架加载方式
前端·ui·html·axure·原型·产品设计
技术小甜甜5 小时前
【Cursor】Skill 被标黄色警告,怎么改成合格的 SKILL.md
开发语言·javascript·ai编程·cursor·1024程序员节·ai agent·skills
陈随易5 小时前
副业搞钱点子之收入查看器
前端·后端