shenxianbao_branch
sxb 9 months ago
parent 6fbd846848
commit 9993c827ed

@ -13,7 +13,872 @@
* jQuery的立即执行函数表达式IIFE用于创建一个封闭的作用域避免全局变量污染
* 它接受两个参数global全局对象通常为window和factory一个函数用于定义jQuery
*/
(function( global, factory ) {
// 如果在CommonJS环境中如Node.js并且module.exports存在
if ( typeof module === "object" && typeof module.exports === "object" ) {
// 如果全局对象有document属性则直接导出jQuery
// 否则导出一个函数该函数在被调用时会检查是否存在document
module.exports = global.document ?
factory( global, true ) :
function( w ) {
// 如果没有document则抛出错误
if ( !w.document ) {
throw new Error( "jQuery需要一个包含document的window对象" );
}
// 否则调用factory函数创建jQuery
return factory( w );
};
} else {
// 在非CommonJS环境中直接调用factory函数
factory( global );
}
}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
// 用于存储已删除的ID的数组
var deletedIds = [];
// 获取全局的document对象
var document = window.document;
// 从deletedIds数组借用一些数组方法用于后续操作
var slice = deletedIds.slice;
var concat = deletedIds.concat;
var push = deletedIds.push;
var indexOf = deletedIds.indexOf;
// 一个空对象,用于存储类名到类型的映射
var class2type = {};
// 获取class2type对象的toString方法
var toString = class2type.toString;
// 获取class2type对象的hasOwnProperty方法
var hasOwn = class2type.hasOwnProperty;
// 一个空对象,用于存储浏览器支持的特性
var support = {};
// jQuery的版本号
var version = "1.12.4";
// 定义jQuery对象它实际上是init构造函数的增强版
var jQuery = function( selector, context ) {
// 如果直接调用jQuery而没有new这里会返回一个新的jQuery.fn.init实例
return new jQuery.fn.init( selector, context );
};
// 匹配并去除字符串开头和结尾的空白字符包括BOM和NBSP
var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;
// 匹配以"-ms-"开头的字符串
var rmsPrefix = /^-ms-/;
// 匹配并替换字符串中的"-"后跟一个字母或数字的字符
var rdashAlpha = /-([\da-z])/gi;
// 用于将"-"后跟字母的字符串转换为驼峰命名法的回调函数
var fcamelCase = function( all, letter ) {
return letter.toUpperCase();
};
// jQuery的原型对象包含所有实例方法
jQuery.fn = jQuery.prototype = {
// 当前jQuery的版本号
jquery: version,
// 构造函数指向jQuery本身
constructor: jQuery,
// 初始选择器字符串
selector: "",
// jQuery对象的默认长度为0
length: 0,
// 将jQuery对象转换为一个真正的数组
toArray: function() {
return slice.call( this );
},
// 获取jQuery对象中的第N个元素或者获取所有元素组成的数组
get: function( num ) {
return num != null ?
// 返回指定位置的元素
( num < 0 ? this[ num + this.length ] : this[ num ] ) :
// 返回所有元素组成的数组
slice.call( this );
},
// 将一个元素数组推入到当前jQuery对象的栈中并返回新的jQuery对象
pushStack: function( elems ) {
// 创建一个新的jQuery对象
var ret = jQuery.merge( this.constructor(), elems );
// 将旧的对象引用添加到新对象的prevObject属性上
ret.prevObject = this;
// 保持上下文的一致性
ret.context = this.context;
// 返回新的jQuery对象
return ret;
},
// 对jQuery对象中的每个元素执行一次提供的回调函数
each: function( callback ) {
return jQuery.each( this, callback );
},
// 将jQuery对象中的每个元素通过提供的回调函数映射到一个新数组中并返回一个新的jQuery对象
map: function( callback ) {
return this.pushStack( jQuery.map( this, function( elem, i ) {
return callback.call( elem, i, elem );
}) );
},
// 返回一个从当前位置开始包含指定数量元素的新jQuery对象如果参数是负数则从末尾开始计数
slice: function() {
return this.pushStack( slice.apply( this, arguments ) );
},
// 获取jQuery对象中的第一个元素
first: function() {
return this.eq( 0 );
},
// 获取jQuery对象中的最后一个元素
last: function() {
return this.eq( -1 );
},
// 获取jQuery对象中指定位置的元素如果索引是负数则从末尾开始计数
eq: function( i ) {
var len = this.length,
j = +i + ( i < 0 ? len : 0 );
return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] );
},
// 结束当前操作返回到上一个jQuery对象如果有的话
end: function() {
return this.prevObject || this.constructor();
},
// 以下方法是从数组对象中借用来的,用于内部使用
push: push,
sort: deletedIds.sort,
splice: deletedIds.splice
};
// jQuery.extend方法用于扩展jQuery对象本身或其原型对象
jQuery.extend = jQuery.fn.extend = function() {
var src, copyIsArray, copy, name, options, clone,
target = arguments[ 0 ] || {},
i = 1,
length = arguments.length,
deep = false;
// 处理深度复制的情况
if ( typeof target === "boolean" ) {
deep = target;
// 跳过布尔值和目标对象
target = arguments[ i ] || {};
i++;
}
// 如果目标不是对象或函数,则将其转换为对象
if ( typeof target !== "object" && !jQuery.isFunction( target ) ) {
target = {};
}
// 如果只有一个参数则扩展jQuery本身
if ( i === length ) {
target = this;
i--;
}
// 遍历每一个要扩展的对象
for ( ; i < length; i++ ) {
// 只处理非null/undefined的值
if ( ( options = arguments[ i ] ) != null ) {
// 扩展基础对象
for ( name in options ) {
src = target[ name ];
copy = options[ name ];
// 防止无限循环
if ( target === copy ) {
continue;
}
// 深度复制的逻辑(略)
// Recurse if we're merging plain objects or arrays
// 定义一个函数,用于扩展对象或合并对象
// deep 参数指示是否进行深度拷贝
// copy 是要合并到第一个对象中的对象或数组
// target 是被扩展的对象
var someFunction = function( deep, copy, target ) {
// 检查是否进行深度拷贝且copy是一个纯对象或数组
if ( deep && copy && ( jQuery.isPlainObject( copy ) ||
( copyIsArray = jQuery.isArray( copy ) ) ) ) {
// 如果copy是数组
if ( copyIsArray ) {
copyIsArray = false; // 重置标志位,因为已经处理过是数组的情况
clone = src && jQuery.isArray( src ) ? src : []; // 根据src是否为数组来决定clone是src的引用还是新数组
} else {
// 如果copy是纯对象
clone = src && jQuery.isPlainObject( src ) ? src : {}; // 根据src是否为纯对象来决定clone是src的引用还是新对象
}
// 从不直接移动原始对象,而是克隆它们
// 使用jQuery.extend进行合并可能涉及深度拷贝
target[ name ] = jQuery.extend( deep, clone, copy );
// 如果copy不是undefined则直接赋值给target[name]
} else if ( copy !== undefined ) {
target[ name ] = copy;
}
}
// jQuery的静态方法集合
jQuery.extend( {
// 每个jQuery实例在页面上的唯一标识符
expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ),
// 假设没有ready模块时jQuery已准备好
isReady: true,
// 错误处理函数
error: function( msg ) {
throw new Error( msg );
},
// 空函数,常用于回调占位
noop: function() {},
// 判断对象是否为函数
isFunction: function( obj ) {
return jQuery.type( obj ) === "function";
},
// 判断对象是否为数组
isArray: Array.isArray || function( obj ) {
return jQuery.type( obj ) === "array";
},
// 判断对象是否为窗口对象
isWindow: function( obj ) {
return obj != null && obj == obj.window;
},
// 判断对象是否为数字
isNumeric: function( obj ) {
var realStringObj = obj && obj.toString();
return !jQuery.isArray( obj ) && ( realStringObj - parseFloat( realStringObj ) + 1 ) >= 0;
},
// 判断对象是否为空对象
isEmptyObject: function( obj ) {
var name;
for ( name in obj ) {
return false;
}
return true;
},
// 判断对象是否为纯对象(即直接通过{}或new Object()创建的对象)
isPlainObject: function( obj ) {
var key;
if ( !obj || jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) {
return false;
}
try {
if ( obj.constructor &&
!hasOwn.call( obj, "constructor" ) &&
!hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) {
return false;
}
} catch ( e ) {
return false;
}
if ( !support.ownFirst ) {
for ( key in obj ) {
return hasOwn.call( obj, key );
}
}
for ( key in obj ) {}
return key === undefined || hasOwn.call( obj, key );
},
// 获取对象的类型
type: function( obj ) {
if ( obj == null ) {
return obj + "";
}
return typeof obj === "object" || typeof obj === "function" ?
class2type[ toString.call( obj ) ] || "object" :
typeof obj;
},
// 在全局上下文中执行JavaScript代码
globalEval: function( data ) {
if ( data && jQuery.trim( data ) ) {
( window.execScript || function( data ) {
window[ "eval" ].call( window, data );
} )( data );
}
},
// 将字符串从dashed转换为camelCase
camelCase: function( string ) {
return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase );
},
// 检查元素是否具有指定的节点名称
nodeName: function( elem, name ) {
return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase();
},
// 遍历对象或数组
each: function( obj, callback ) {
var length, i = 0;
if ( isArrayLike( obj ) ) {
length = obj.length;
for ( ; i < length; i++ ) {
if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {
break;
}
}
} else {
for ( i in obj ) {
if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {
break;
}
}
}
return obj;
},
});
// Support: Android<4.1, IE<9
// 去除字符串两端的空白字符
trim: function(text) {
// 如果text为null或undefined则返回空字符串
return text == null ?
"" :
// 将text转换为字符串如果text不是字符串的话然后使用正则表达式去除两端的空白字符
(text + "").replace(rtrim, "");
},
// 将类数组对象或可迭代对象转换为真正的数组results是内部使用参数用于存储转换结果
makeArray: function(arr, results) {
var ret = results || []; // 如果没有传入results则初始化为空数组
if (arr != null) { // 如果arr不为null或undefined
// 判断arr是否类似数组
if (isArrayLike(Object(arr))) {
// 如果是字符串则将其放入数组中否则直接使用arr
jQuery.merge(ret,
typeof arr === "string" ?
[arr] : arr
);
} else {
// 使用push方法将arr添加到ret数组中
push.call(ret, arr);
}
}
return ret; // 返回转换后的数组
},
// 判断元素是否在数组中,返回元素的索引,如果不在则返回-1
inArray: function(elem, arr, i) {
var len;
if (arr) { // 如果数组不为空
if (indexOf) { // 如果indexOf方法存在现代浏览器
return indexOf.call(arr, elem, i); // 使用indexOf查找元素索引
}
len = arr.length; // 获取数组长度
i = i ? i < 0 ? Math.max(0, len + i) : i : 0; // 处理负数索引
// 遍历数组,查找元素
for (; i < len; i++) {
// 跳过稀疏数组中的空位
if (i in arr && arr[i] === elem) {
return i; // 找到元素,返回索引
}
}
}
return -1; // 未找到元素,返回-1
},
// 合并两个数组,将第二个数组的元素添加到第一个数组中
merge: function(first, second) {
var len = +second.length, // 获取second数组的长度转换为数字
j = 0,
i = first.length; // 获取first数组的长度
// 使用while循环将second数组的元素添加到first数组中
while (j < len) {
first[i++] = second[j++];
}
// 支持IE<9处理类数组对象如NodeLists的长度属性可能不是数字的情况
if (len !== len) { // 如果len转换为数字后与自身不相等NaN的情况
while (second[j] !== undefined) { // 继续添加元素直到second[j]为undefined
first[i++] = second[j++];
}
}
first.length = i; // 更新first数组的长度
return first; // 返回合并后的数组
},
// 使用回调函数过滤数组,返回满足条件的元素组成的新数组
grep: function(elems, callback, invert) {
var callbackInverse,
matches = [],
i = 0,
length = elems.length,
callbackExpect = !invert; // 根据invert的值确定callback的期望返回值true或false
// 遍历数组将满足条件的元素添加到matches数组中
for (; i < length; i++) {
callbackInverse = !callback(elems[i], i); // 调用回调函数,并取反
if (callbackInverse !== callbackExpect) { // 如果回调函数的返回值与期望不符
matches.push(elems[i]); // 将元素添加到matches数组中
}
}
return matches; // 返回过滤后的数组
},
// 对数组或对象进行映射,返回一个新数组,数组中的每个元素都是回调函数处理后的结果
map: function(elems, callback, arg) {
var length, value,
i = 0,
ret = []; // 初始化返回数组
// 判断elems是否是类数组对象
if (isArrayLike(elems)) {
length = elems.length; // 获取elems的长度
// 遍历elems数组
for (; i < length; i++) {
value = callback(elems[i], i, arg); // 调用回调函数处理元素
if (value != null) { // 如果回调函数返回值不为null或undefined
ret.push(value); // 将返回值添加到ret数组中
}
}
} else {
// 如果elems不是类数组对象则遍历其所有属性
for (i in elems) {
value = callback(elems[i], i, arg); // 调用回调函数处理元素
if (value != null) { // 如果回调函数返回值不为null或undefined
ret.push(value); // 将返回值添加到ret数组中
}
}
}
// 使用concat方法将ret数组中的嵌套数组展平
return concat.apply([], ret);
},
// 全局GUID计数器用于生成唯一标识符
guid: 1,
// 绑定函数到指定上下文,并可选地预设一些参数
proxy: function(fn, context) {
var args, proxy, tmp;
// 如果context是字符串则假设是要绑定到fn的某个方法上
if (typeof context === "string") {
tmp = fn[context];
context = fn;
fn = tmp;
}
// 检查fn是否为函数如果不是则返回undefined
if (!jQuery.isFunction(fn)) {
return undefined;
}
// 保存额外参数
args = slice.call(arguments, 2);
// 创建代理函数
proxy = function() {
// 将预设参数和当前调用的参数合并,然后调用原函数
return fn.apply(context || this, args.concat(slice.call(arguments)));
};
// 设置代理函数的guid以便可以移除事件监听器等
proxy.guid = fn.guid = fn.guid || jQuery.guid++;
return proxy; // 返回代理函数
},
// 获取当前时间的毫秒数
now: function() {
return +(new Date()); // 返回Date对象的时间戳毫秒数
},
// jQuery.support已不在核心中使用但其他项目可能会附加属性到它上面因此需要保留
support: support
});
// 以下代码块由于使用了ES6的Symbol可能会导致JSHint报错因此使用注释忽略JSHint的检查
/* jshint ignore: start */
if (typeof Symbol === "function") {
jQuery.fn[Symbol.iterator] = deletedIds[Symbol.iterator]; // 为jQuery对象设置Symbol.iterator属性以便支持迭代
}
/* jshint ignore: end */
// 填充class2type映射表
jQuery.each("Boolean Number String Function Array Date RegExp Object Error Symbol".split(" "),
function(i, name) {
class2type["[object " + name + "]"] = name.toLowerCase(); // 将类名字符串映射为小写形式并存储在class2type对象中
});
// 判断一个对象是否类似数组
function isArrayLike(obj) {
var length = !!obj && "length" in obj && obj.length, // 获取对象的length属性如果存在的话
type = jQuery.type(obj); // 获取对象的类型
// 如果对象是函数或window对象则不是类似数组
if (type === "function" || jQuery.isWindow(obj)) {
return false;
}
// 返回对象是否为数组或长度大于0且length-1索引存在的对象处理稀疏数组
return type === "array" || length === 0 ||
typeof length === "number" && length > 0 && (length - 1) in obj;
}
var Sizzle =
/*!
* Sizzle CSS Selector Engine v2.2.1
* http://sizzlejs.com/
*
* Copyright jQuery Foundation and other contributors
* Released under the MIT license
* http://jquery.org/license
*
* Date: 2015-10-17
*/
(function(window) {
// 省略Sizzle选择器引擎的代码...
});
// 创建一个独特的expando属性名用于存储数据结合当前时间戳
expando = "sizzle" + 1 * new Date(),
// 获取全局的document对象
preferredDoc = window.document,
// 运行方向计数器(可能用于跟踪查询的执行顺序)
dirruns = 0,
// 完成标志(可能用于跟踪查询是否已完成)
done = 0,
// 创建缓存对象,用于存储类名、标记和编译器的缓存
classCache = createCache(),
tokenCache = createCache(),
compilerCache = createCache(),
// 排序函数,用于排序,如果两个元素相同,则标记有重复
sortOrder = function( a, b ) {
if ( a === b ) {
hasDuplicate = true;
}
return 0;
},
// 常量定义
MAX_NEGATIVE = 1 << 31,
// 实例方法
hasOwn = ({}).hasOwnProperty,
arr = [],
pop = arr.pop,
push_native = arr.push,
push = arr.push, // 注意这里push被重新赋值下面会解释
slice = arr.slice,
// 自定义的indexOf方法用于查找元素在数组中的位置
indexOf = function( list, elem ) {
var i = 0,
len = list.length;
for ( ; i < len; i++ ) {
if ( list[i] === elem ) {
return i;
}
}
return -1;
},
// 布尔属性字符串,用于快速检查元素是否具有这些属性
booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",
// 正则表达式定义,用于匹配不同类型的选择器
// ...(省略了具体的正则表达式定义,因为它们很长且专注于选择器解析)
// matchExpr对象用于快速匹配不同类型的选择器
matchExpr = {
// ...(省略了具体的匹配规则)
},
// 用于检查元素类型的正则表达式
rinputs = /^(?:input|select|textarea|button)$/i,
rheader = /^h\d$/i,
// 用于检测原生方法的正则表达式
rnative = /^[^{]+\{\s*\[native \w/,
// 快速表达式匹配用于解析ID、标签或类选择器
rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,
// 用于匹配兄弟选择器的正则表达式
rsibling = /[+~]/,
rescape = /'|\\/g,
// CSS转义字符的正则表达式和函数
runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ),
funescape = function( _, escaped, escapedWhitespace ) {
// ...(省略了具体的转义逻辑)
},
// 卸载处理器,用于在页面卸载时重置文档
unloadHandler = function() {
setDocument();
};
// 优化push.apply的使用尝试直接应用NodeList到数组
try {
push.apply(
(arr = slice.call( preferredDoc.childNodes )),
preferredDoc.childNodes
);
// 尝试访问最后一个元素的nodeType来检测是否成功
arr[ preferredDoc.childNodes.length ].nodeType;
} catch ( e ) {
// 如果失败则使用替代的push方法
push = { apply: arr.length ?
// 如果原生slice可用则使用它
function( target, els ) {
push_native.apply( target, slice.call(els) );
} :
// 否则,逐个添加元素到目标数组
function( target, els ) {
var j = target.length,
i = 0;
while ( (target[j++] = els[i++]) ) {}
target.length = j - 1;
}
};
}
// Sizzle函数是选择器引擎的核心
function Sizzle( selector, context, results, seed ) {
var m, i, elem, nid, nidselect, match, groups, newSelector,
newContext = context && context.ownerDocument,
nodeType = context ? context.nodeType : 9; // 9代表document节点
results = results || [];
// 如果选择器不是字符串、为空、或者上下文节点类型不正确,则直接返回空结果
if ( typeof selector !== "string" || !selector ||
nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) {
return results;
}
// ...省略了Sizzle函数的具体实现因为这部分非常长且复杂
}
// 如果seed参数为假即没有提供初始的匹配元素集合
if ( !seed ) {
// 如果提供的上下文context不是当前文档则设置文档为上下文
// 如果没有提供上下文则使用preferredDoc优先文档作为上下文
// 如果上下文是一个元素则使用它的ownerDocument拥有它的文档作为上下文
if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) {
setDocument( context );
}
// 如果没有提供上下文则默认使用document作为上下文
context = context || document;
// 如果文档是HTML文档
if ( documentIsHTML ) {
// 如果选择器足够简单,尝试使用"get*By*" DOM方法除了DocumentFragment上下文因为DocumentFragment上没有这些方法
if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) {
// ID选择器
if ( (m = match[1]) ) {
// 文档上下文
if ( nodeType === 9 ) {
if ( (elem = context.getElementById( m )) ) {
// 支持IE, Opera, Webkit
// TODO: 识别版本
// getElementById可能会根据名称而不是ID匹配元素
if ( elem.id === m ) {
results.push( elem );
return results;
}
} else {
return results;
}
// 元素上下文
} else {
// 支持IE, Opera, Webkit
// TODO: 识别版本
// getElementById可能会根据名称而不是ID匹配元素
if ( newContext && (elem = newContext.getElementById( m )) &&
contains( context, elem ) &&
elem.id === m ) {
results.push( elem );
return results;
}
}
// 类型选择器
} else if ( match[2] ) {
push.apply( results, context.getElementsByTagName( selector ) );
return results;
// 类选择器
} else if ( (m = match[3]) && support.getElementsByClassName &&
context.getElementsByClassName ) {
push.apply( results, context.getElementsByClassName( m ) );
return results;
}
}
// 使用querySelectorAll
if ( support.qsa &&
!compilerCache[ selector + " " ] &&
(!rbuggyQSA || !rbuggyQSA.test( selector )) ) {
if ( nodeType !== 1 ) {
newContext = context;
newSelector = selector;
// qSA会在元素上下文之外查找这不是我们想要的
// 感谢Andrew Dupont提供的这种解决方法
// 支持IE <=8
// 排除对象元素
} else if ( context.nodeName.toLowerCase() !== "object" ) {
// 捕获上下文的ID如果必要则先设置它
if ( (nid = context.getAttribute( "id" )) ) {
nid = nid.replace( rescape, "\\$&" );
} else {
context.setAttribute( "id", (nid = expando) );
}
// 在列表中的每个选择器前加上前缀
groups = tokenize( selector );
i = groups.length;
nidselect = ridentifier.test( nid ) ? "#" + nid : "[id='" + nid + "']";
while ( i-- ) {
groups[i] = nidselect + " " + toSelector( groups[i] );
}
newSelector = groups.join( "," );
// 为兄弟选择器扩展上下文
newContext = rsibling.test( selector ) && testContext( context.parentNode ) ||
context;
}
if ( newSelector ) {
try {
push.apply( results,
newContext.querySelectorAll( newSelector )
);
return results;
} catch ( qsaError ) {
} finally {
if ( nid === expando ) {
context.removeAttribute( "id" );
}
}
}
}
}
// 其他情况
return select( selector.replace( rtrim, "$1" ), context, results, seed );
}
/**
* 创建一个有限大小的键值缓存
* @returns {function(string, object)} 返回一个在存储后将数据对象存储在自身上的函数属性名为空格后缀字符串
* 如果缓存大于Expr.cacheLength则删除最旧的条目
*/
function createCache() {
var keys = [];
function cache( key, value ) {
// 使用key + " "以避免与原生原型属性冲突见Issue #157
if ( keys.push( key + " " ) > Expr.cacheLength ) {
// 仅保留最近的条目
delete cache[ keys.shift() ];
}
return (cache[ key + " " ] = value);
}
return cache;
}
/**
* 为Sizzle特殊使用标记一个函数
* @param {Function} fn 要标记的函数
*/
function markFunction( fn ) {
fn[ expando ] = true;
return fn;
}
/**
* 使用一个元素支持测试
* @param {Function} fn 传入创建的div并期望一个布尔结果
*/
function assert( fn ) {
var div = document.createElement("div");
try {
return !!fn( div );
} catch (e) {
return false;
} finally {
// 默认情况下从其父节点中移除
if ( div.parentNode ) {
div.parentNode.removeChild( div );
}
// 在IE中释放内存
div = null;
}
}
/**
* 为指定的attrs添加相同的处理器

Loading…
Cancel
Save