写在前面
在ECMAScript
中我们常说的拷贝分两种:深拷贝和浅拷贝,也有分为值拷贝和引用拷贝。深拷贝、浅拷贝的区分点就是对引用类型的对象进行不同的操作,前者拷贝引用类型执行的实际值(值拷贝),后者只拷贝引用(引用拷贝)。浅拷贝基本上也无需多复杂的实现,语言本身提供的 Object.assign
也基本上可以满足日常所需,在 Lodash
中,深浅拷贝都在一个 baseClone
的方法中得以实现,函数内部根据 isDeep
的值做区分。
本文主要探究并适当拓展一下较为复杂的深拷贝的实现方式,浅拷贝暂不讨论
拷贝,说到底就是拷贝数据。数据类型一般也就分为两种值类型和引用类型,我们先来看一下值类型的拷贝。
值类型
值类型,在ECMAScript
中也叫做原始数据类型。ECMAScript
中目前有以下几种基本数据类型。
const undefinedTag = '[object Undefined]'
const nullTag = '[object Null]'
const boolTag = '[object Boolean]'
const numberTag = '[object Number]'
const stringTag = '[object String]'
// es2015
const symbolTag = '[object Symbol]'
// es2019?
const bigIntTag = '[object BigInt]'
基本数据类型都是值传递,所以只要值基本数据类型的数据,直接返回自身即可
const pVal = [
undefinedTag, nullTag,
boolTag, numberTag, stringTag,
symbolTag, bigIntTag
]
function clone (target) {
let type = Object.prototype.toString.call(target)
if (pVal.includes(type)) {
return target
}
}
常见的引用类型
除了原始数据类型,剩下的都是引用数据类型,我们先看一下最常见的 Array
和 Object
。
实现一个ForEach
在 clone
实现对Array
和 Object
的 clone
之前,我们需要先实现一个 ForEach
方法。
为什么要重新实现?
出于两个原因,需要在 clone
的时候一个 foreach
方法。
- 性能。
Array.prototype.forEach
性能上表现一般。 Object.prototype
没有类似forEach
可以遍历对象值的方法,需要配合Object.prototype.keys
和for...in
才能实现类似的效果,但是后者性能很差。
/** * 类似Array.prototype.forEach的forEach方法 * * @param {Array} [array] 源数组 * @param {Function} 遍历方法 * @returns {Array} 返回原数组 */
function forEach(array, iteratee) {
let index = -1
const length = array.length
while (++index < length) {
// 中断遍历
if (iteratee(array[index], index, array) === false) {
break
}
}
return array
}
可以中断循环
Array.prototype.forEach
是不支持中断循环的,但是我们实现的 forEach
是可以的。
var arr = [1,2,3]
arr.forEach(i => {
if (i === 2) {
return false
}
console.log(i)
})
// 1
// 3
// 只能跳过当前遍历
forEach(arr, i => {
if (i === 2) {
return false
}
console.log(i)
})
// 1
// 只要在某次遍历中返回false,即可跳出整个循环
遍历对象/数组
因为对象跟数组的机构基本类似,数组可以看做一种特殊的 key-value
形式,即 key
为数组项下标, value
为数组项的对象。 如果我们要统一遍历处理数组和对象,我们可以这么写:
const unknownObj = {} || []
const props = Array.isArray(unknownObj) ? undefined : Object.keys(unknownObj)
forEach(props || unknownObj, (subValue, key) => {
if (props) {
key = subValue
subValue = unknownObj[key]
}
})
WeakMap的妙用
遇到循环引用怎么办?
在 clone
的时候,遇到循环引用的对象,在递归的时候,如果不终止,会造成栈溢出。我们实现简单的 cloen
对象的例子:
var cloneObj = function (obj) {
var target = new obj.constructor()
forEach(Object.keys(obj), (val, key) => {
key = val
val = obj[key]
if (Object.prototype.toString.call(val) === "[object Object]") {
target[key] = cloneObj(val)
} else {
target[key] = val
}
})
return target
}
下面示例,证明此函数可用:
var a = {
x: {
y: 2
}
}
var b = cloneObj(a)
b // { x: { y: 2 } }
b === a // false
b.x === a.x // false
下面示例,可以看到栈溢出:
var a = {
x: 1
}
a.x = a
cloneObj(a) // Uncaught RangeError: Maximum call stack size exceeded
怎么解决这个问题呢?我们可以看到下面这点:
a.x === a // true
所以,只要把 a
的值存起来,下次递归之前,如果要递归的值 a.x
跟存储的值相等,那么就可以直接返回,不需要进行递归了。我们可以这么实现:
var cache = []
var cloneObj = function (obj) {
var target = new obj.constructor()
if (cache.includes(obj)) {
return obj
}
cache.push(obj)
forEach(Object.keys(obj), (val, key) => {
key = val
val = obj[key]
if (Object.prototype.toString.call(val) === "[object Object]") {
target[key] = cloneObj(val)
} else {
target[key] = val
}
})
return target
}
var b = cloneObj(a)
a === b // false
虽然我们最后阻止了递归,但是这种写法也有缺陷。我们还需要声明额外的外部变量 cache
,如果要封装成模块,①必须使用闭包, cache
存储了 a
的值,如果这个引用一直存在,②那么 a
将一直存在内存里,不会被垃圾回收(garbage collection)。并且,③ includes
方法每次都要遍历数组,非常消耗性能。
// 必须引入闭包
var markClone = function () {
var cache = []
return function (obj) {
var target = new obj.constructor()
if (cache.includes(obj)) {
return obj
}
cache.push(obj)
forEach(Object.keys(obj), (val, key) => {
key = val
val = obj[key]
if (Object.prototype.toString.call(val) === "[object Object]") {
target[key] = cloneObj(val)
} else {
target[key] = val
}
})
return target
}
}
var cloneObj = makeClone()
cloneObj({x: 1})
关于①,我们可以这么解决:
var cloneObj = function (obj, cache = []) {
var target = new obj.constructor()
if (cache.includes(obj)) {
return obj
}
cache.push(obj)
forEach(Object.keys(obj), (val, key) => {
key = val
val = obj[key]
if (Object.prototype.toString.call(val) === "[object Object]") {
target[key] = cloneObj(val, cache)
} else {
target[key] = val
}
})
return target
}
cloneObj({x: 1})
剩下的两个问题,让我们交给 WeakMap
。
弱引用:WeakMap
普通的对象只支持字符串作为 key
,即使你使用了其他的数据类型,也会调用其自身的 toString()
方法:
var a = {}
a[{x:2}] = 3
a[234] = 'hello'
Object.keys(a) // ["234", "[object Object]"]
为了让其他对象也可以作为 key
, ECMAScript 6
新增了 Map
数据类型,支持任何数据类型作为 key
:
var m = new Map()
m.set(function(){}, 1)
m.set([1,3,5], 2)
m.set({x: 'abc'}, 3)
m.forEach((val, key) => console.log(val, key))
// 1 ƒ (){}
// 2 [1, 3, 5]
// 3 {x: "abc"}
WeakMap
类型则略微有些不同,它只支持除原始数据类型之外的类型作为 key
,且这些 key
不可遍历,因为存储的是弱引用。
弱引用不计入引用计数,如果某个引用对象的引用计数变为0,那么它会在垃圾回收时,会被回收。同时,弱引用也失去关联。
我们使用 WeakMap
替代 cache
:
var cloneObj = function (obj, cache = new WeakMap()) {
var target = new obj.constructor()
if (cache.has(obj)) {
return cache.get(obj)
}
cache.set(obj, target)
forEach(Object.keys(obj), (val, key) => {
key = val
val = obj[key]
if (Object.prototype.toString.call(val) === "[object Object]") {
// 如果是循环引用,这行类似于 a.x = a,因为此时cloneObj方法返回的是target
target[key] = cloneObj(val, cache)
} else {
target[key] = val
}
})
return target
}
cloneObj({x: 1})
get
和 has
方法执行效率(O(1))绝对比 include
高多了(O(n)),我们解决了问题③。我们现在测试一下,我们是否解决了问题②。
测试垃圾回收
首先,打开命令行。
node --expose-gc
--expose-gc
参数表示允许手动执行垃圾回收机制
然后执行:
// 手动执行一次垃圾回收,保证获取的内存使用状态准确
> global.gc();
undefined
// 定义getUsage方法,可以快速获取当前堆内存使用情况,单位M
> var getUsage = () => process.memoryUsage().heapUsed / 1024 / 1024 + 'M'
// 查看内存占用的初始状态,heapUsed 为 5M 左右
> getUsage();
'5.1407012939453125M'
> let wm = new WeakMap();
undefined
// 新建一个变量 key,指向一个 5*1024*1024 的数组
> let key = new Array(5 * 1024 * 1024);
undefined
// 设置 WeakMap 实例的键名,也指向 key 数组
// 这时,key 数组实际被引用了两次,
// 变量 key 引用一次,WeakMap 的键名引用了第二次
// 但是,WeakMap 是弱引用,对于引擎来说,引用计数还是1
> wm.set(key, 1);
WeakMap {}
> global.gc();
undefined
// 这时内存占用 heapUsed 增加到 45M 了
> getUsage();
'45.260292053222656M'
// 清除变量 key 对数组的引用,
// 但没有手动清除 WeakMap 实例的键名对数组的引用
> key = null;
null
// 再次执行垃圾回收
> global.gc();
undefined
// 内存占用 heapUsed 变回 5M 左右,
// 可以看到 WeakMap 的键名引用没有阻止 gc 对内存的回收
> getUsage();
'5.110954284667969M'
简洁版本
基于以上的内容,我们可以总结出一版简洁的版本,支持值类型、 Array
、 Object
类型的拷贝:
const sampleClone = function (target, cache = new WeakMap()) {
// 值类型
const undefinedTag = '[object Undefined]'
const nullTag = '[object Null]'
const boolTag = '[object Boolean]'
const numberTag = '[object Number]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const bigIntTag = '[object BigInt]'
// 引用类型
const arrayTag = '[object Array]'
const objectTag = '[object Object]'
// 传入对象的类型
const type = Object.prototype.toString.call(target)
// 所有支持的类型
const allTypes = [
undefinedTag, nullTag,boolTag, numberTag, stringTag,symbolTag, bigIntTag, arrayTag, objectTag
]
// 如果是不支持的类型
if (!allTypes.includes(type)) {
console.warn(`不支持${type}类型的拷贝,返回{}。`)
return {}
}
// 值类型数组
const valTypes = [
undefinedTag, nullTag,boolTag, numberTag, stringTag,symbolTag, bigIntTag
]
// 值类型直接返回
if (valTypes.includes(type)) {
return target
}
// forEach
function forEach(array, iteratee) {
let index = -1
const length = array.length
while (++index < length) {
// 中断遍历
if (iteratee(array[index], index, array) === false) {
break
}
}
return array
}
// 初始化clone值
let cloneTarget = new target.constructor()
// 阻止循环引用
if (cache.has(target)) {
return cache.get(target)
}
cache.set(target, cloneTarget)
// 克隆Array 和Object
const keys = type === arrayTag ? undefined : Object.keys(target)
forEach(keys || target, (value, key) => {
if (keys) {
key = value
}
cloneTarget[key] = sampleClone(target[key], cache)
})
return cloneTarget
}
以上实现的对原始数据类型和Array
和 Object
的 clone
,基本上已经可以满足日常的使用,因为这是前端大多数情况下要处理的数据格式。
特殊的Array类型对象
在 Lodash
的 baseClone.js
中有这么几行代码:
function initCloneArray(array) {
const { length } = array
const result = new array.constructor(length)
// Add properties assigned by `RegExp#exec`.
if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
result.index = array.index
result.input = array.input
}
return result
}
initCloneArray
方法用于初始化一个数组对象,但是如果满足一些特殊条件,会给它初始化两个属性 index
和 input
。注释说的也很明白,这是为了初始化 RegExp.prototype.exec
方法执行后返回的特殊的Array数组。 我们可以看一下:
我们可以看到,这个数组对象与常见的数组的不同。对这种类型的数组对象进行克隆,参照 Lodash
的处理方法即可。我们现在把它加入 sampleClone
中。
var sampleClone = function (target, cache = new WeakMap()) {
...
let cloneTarget
if (Array.isArray(target)) {
cloneTarget = initCloneArray(target)
} else {
cloneTarget = new target.constructor()
}
...
}
疑问 groups属性是什么?
特殊的Object key
在上面的 sampleClone
中,我们使用了 Object.keys
遍历出「所有」对象上的 key
。但这个所有是存疑的,因为这个方法无法取到原型对象的 key
,也无法取到 Symbol
类型的 key
,也无法遍历出不可枚举的值。
在我之前的文章中 列出了好几种获取属性的方法,使用这些方法配合着可以取到所有从原对象可以使用的值。 其实说到底,就是在 Object.keys
的基础上,多使用几种方法,取到这些值。在 Lodash
的 baseClone
方法中,通过 isFlat
标识是否拷贝原型对象上的属性,通过 isFull
标识是否拷贝类型为 Symbol
的 key
。需要注意的是,Lodash
只拷贝可枚举的值。
我们通过传递参数实现一下:
// 来自lodash,使用for...in,返回对象上可枚举属性key+原型key的数组
function keysIn(object) {
const result = []
for (const key in object) {
result.push(key)
}
return result
}
// 来自lodash,返回对象上可枚举Symbol key的数组
function getSymbols(object) {
if (object == null) {
return []
}
object = Object(object)
return Object
.getOwnPropertySymbols(object)
.filter((symbol) => Object.prototype.propertyIsEnumerable.call(object, symbol))
}
// 来自lodash,返回对象上可枚举属性key + Symbol key的数组
function getAllKeys(object) {
const result = keys(object)
if (!Array.isArray(object)) {
result.push(...getSymbols(object))
}
return result
}
// 来自lodash,返回对象原型链上可枚举(属性key + Symbol key)的数组
function getSymbolsIn(object) {
const result = []
while (object) {
result.push(...getSymbols(object))
object = Object.getPrototypeOf(Object(object))
}
return result
}
// 来自lodash,返回对象上可枚举属性key + Symbol key + 原型链上可枚举(属性key + Symbol key)的数组
function getAllKeysIn(object) {
const result = []
for (const key in object) {
result.push(key)
}
if (!Array.isArray(object)) {
result.push(...getSymbolsIn(object))
}
return result
}
var sampleClone = function ( target, cache = new WeakMap(), includePrototypeKey, includeSymbolKey ) {
...
// 最终获取对象keys数组使用的方法
const keysFunc = isFull
? (isFlat ? getAllKeysIn : getAllKeys)
: (isFlat ? keysIn : keys)
...
const keys = type === arrayTag ? undefined : keysFunc(target)
...
}
类数组(Array Like)
关于类数组的概念,可在这篇文章中了解。类数组其实算是一种特殊的对象,最后也是通过我们自定义的 forEach
进行拷贝, Lodash
在取对象键数组的时候进行的区分。在我们刚才说到的 getAllKeys
方法中,引用了一个 keys
方法,这个方法里会根据是否是类数组使用不同的取值方法:
function keys(object) {
return isArrayLike(object)
? arrayLikeKeys(object)
: Object.keys(Object(object))
}
isArrayLike
这个方法用来判断是否是类数组,可以在这篇文章中看到详细说明。 我们主要看一下,arrayLikeKeys
这个方法如何取出类数组中的 key
。
// 将类数组value的所有key取出,放在一个新的数组中返回
// 如 ['a','b', 'c']
function arrayLikeKeys(value, inherited) {
const isArr = Array.isArray(value)
const isArg = !isArr && isArguments(value)
const isBuff = !isArr && !isArg && isBuffer(value)
const isType = !isArr && !isArg && !isBuff && isTypedArray(value)
const skipIndexes = isArr || isArg || isBuff || isType
const length = value.length
const result = new Array(skipIndexes ? length : 0)
let index = skipIndexes ? -1 : length
while (++index < length) {
result[index] = `${index}`
}
for (const key in value) {
if ((inherited || Object.prototype.hasOwnProperty.call(value, key)) &&
!(skipIndexes && (
// Safari 9 has enumerable `arguments.length` in strict mode.
(key === 'length' ||
// Skip index properties.
isIndex(key, length))
))) {
result.push(key)
}
}
return result
}
我们可以看到, arrayLikeKeys
用了两步取出 key
。
第一步
判断是否拥有 IndexKey
(即形如0,1,2,3,4…)的 key
。
// 数组、参数数组、Buff、Typed数组,都被视为有IndexKey的对象
const skipIndexes = isArr || isArg || isBuff || isType
// 将IndexKey都取出,放到数组里,其他类型的直接跳过
const length = value.length
const result = new Array(skipIndexes ? length : 0)
let index = skipIndexes ? -1 : length
while (++index < length) {
result[index] = `${index}`
}
第二步
将除了 IndexKey
之外的所有 key
取出
// 参数inherited用来标识是否取继承自原型对象的key。
function arrayLikeKeys(value, inherited) {
...
(inherited || Object.prototype.hasOwnProperty.call(value, key))
...
}
inherited
= true表示继承原型对象的key
,因为最外层是for...in
,可以取到继承自原型对象的key
;
为false
或者Undefined
,则使用Object.prototype.hasOwnProperty
只取对象自身的key
。
function arrayLikeKeys(value, inherited) {
...
!(skipIndexes && (key === 'length' || isIndex(key, length)))
...
{
result.push(key)
}
}
skipIndexes
用来标识数组是否有 IndexKey
,如果没有,说明当前的 key
是「其他key」,直接进入下一步,将 key
插入要返回的数组;如果有,继续往进行判断。
如果是有 IndexKey
的数组,则判断当前的 key
名是否是 length
,因为在 safari 9
中, arguments.length
属性是可枚举的,但是 Lodash
是不会拷贝 length
这个key
的,因为 Lodash
只会拷贝可枚举的属性。 然后我们继续看 isIndex
方法:
function isIndex(value, length) {
const type = typeof value
length = length == null ? MAX_SAFE_INTEGER : length
return !!length &&
(type === 'number' ||
(type !== 'symbol' && reIsUint.test(value))) &&
(value > -1 && value % 1 == 0 && value < length)
}
这个方法用来判断当前 key
是否是数组的 IndexKey
。如果是,则跳过插入,因为之前已经在 while
循环中插入过了,如果没有,插入。
assignValue?
处理完对象,在最后赋值的时候,我们看到 Lodash
是这么写的:
assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
没有直接用 =
赋值,而是用了一个 assignValue
方法,我们看一下这个方法:
function assignValue(object, key, value) {
const objValue = object[key]
if (!(hasOwnProperty.call(object, key) && eq(objValue, value))) {
if (value !== 0 || (1 / value) === (1 / objValue)) {
baseAssignValue(object, key, value)
}
} else if (value === undefined && !(key in object)) {
baseAssignValue(object, key, value)
}
}
function baseAssignValue(object, key, value) {
if (key == '__proto__') {
Object.defineProperty(object, key, {
'configurable': true,
'enumerable': true,
'value': value,
'writable': true
})
} else {
object[key] = value
}
}
baseAssignValue
其实就是赋值操作,但是要进入到这一步,还需要满足两个条件。我们看第一个:
!(hasOwnProperty.call(object, key) && eq(objValue, value))
只有在 key
在原型链上才能满足,但是我们可能不需要处理原型对象属性,后面会有说到。
我们继续看第二个条件:
value === undefined && !(key in object)
需要属性不在对象上才能满足,这个条件应该是给 Lodash
是中其他的函数调用的,我们也可以略过。
综上,我们在最后赋值的时候不需要,这个 assign
方法,直接使用 =
即可。
开始改造
我们现在根据以上内容涉及到的特殊对象,对我们的简单版本进行改良。
特殊的数组对象
正则生成的特殊数组对象是需要兼容的,如前文所示,直接在初始化数组的时候,将特殊的属性进行拷贝。
特殊的Key
Lodash
在 baseClone
方法中,支持这么2个参数:是否拷贝原型对象上的属性,是否拷贝 Symbol
类型的值。我们挨个分析。
原型对象上的属性
我们先抛开如何实现「拷贝原型对象的属性」,直接去思考「我们是否需要拷贝原型对象的属性」呢? 我觉得不需要。原因有二。
- 每个对象都是类的实例,类的实例属性其实就是对象的原型对象属性。我们在实际的场景中,如果需要这么一个实例,直接使用类生成一个新的、一样的实例即可,并且,拷贝出一个一模一样的实例的场景也似乎没有。
Lodash
虽然提供了这么一个参数,但是从来没有使用过。我已经给开发者提了Issue。
Symbol
类型
Symbol
算是一种基本的数据类型,自然是要支持的。可以对外暴露出一个参数,让用户决定是否拷贝。
所以,在我们接下来的增强版本中,将不会加入这个参数,也不会对对象的原型对象属性进行拷贝。
类数组
对类数组也是需要兼容的,如前文所示,在获取对象的 key
的时候,使用对应的方法即可。另外,二进制数组我们暂时不处理,后面会单独加入。
增强版本
const hasOwnProperty = Object.prototype.hasOwnProperty
const getType = Object.prototype.toString
// 初始化一个数组对象,包括正则返回的特殊数组
function initCloneArray(array) {
const { length } = array
const result = new array.constructor(length)
// Add properties assigned by `RegExp#exec`.
if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
result.index = array.index
result.input = array.input
}
return result
}
// 获取key的方法
function getKeysFunc(isFull) {
// 返回对象上可枚举Symbol key的数组
function getSymbols(object) {
if (object == null) {
return []
}
object = Object(object)
return Object
.getOwnPropertySymbols(object)
.filter((symbol) => Object.prototype.propertyIsEnumerable.call(object, symbol))
}
// 判断是否是合法的类数组的length属性
function isLength(value) {
return typeof value === 'number' &&
value > -1 && value % 1 === 0 && value <= Number.MAX_SAFE_INTEGER
}
// 判断是否是类数组
function isArrayLike(value) {
return value != null && typeof value !== 'function' && isLength(value.length)
}
// 判断是否是合法的类数组的index
function isIndex(value, length) {
const reIsUint = /^(?:0|[1-9]\d*)$/
const type = typeof value
length = length == null ? Number.MAX_SAFE_INTEGER : length
return !!length &&
(type === 'number' ||
(type !== 'symbol' && reIsUint.test(value))) &&
(value > -1 && value % 1 === 0 && value < length)
}
// 是否是arguments
function isArguments(value) {
return typeof value === 'object' && value !== null && getType.call(value) === '[object Arguments]'
}
// 返回类数组上key组成的数组
function arrayLikeKeys(value, inherited) {
const isArr = Array.isArray(value)
const isArg = !isArr && isArguments(value)
const skipIndexes = isArr || isArg
const length = value.length
const result = new Array(skipIndexes ? length : 0)
let index = skipIndexes ? -1 : length
while (++index < length) {
result[index] = `${index}`
}
for (const key in value) {
if ((inherited || hasOwnProperty.call(value, key)) &&
!(skipIndexes && (
// Safari 9 has enumerable `arguments.length` in strict mode.
(key === 'length' ||
// Skip index properties.
isIndex(key, length))
))) {
result.push(key)
}
}
return result
}
// 返回对象上可枚举属性key
function keys(object) {
return isArrayLike(object)
? arrayLikeKeys(object)
: Object.keys(Object(object))
}
// 返回对象上可枚举属性key + Symbol key的数组
function getAllKeys(object) {
const result = keys(object)
if (!Array.isArray(object)) {
result.push(...getSymbols(object))
}
return result
}
return isFull
? getAllKeys
: keys
}
const enhanceClone = function (target, cache = new WeakMap(), isFull = true) {
// 值类型
const undefinedTag = '[object Undefined]'
const nullTag = '[object Null]'
const boolTag = '[object Boolean]'
const numberTag = '[object Number]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const bigIntTag = '[object BigInt]'
// 引用类型
const arrayTag = '[object Array]'
const objectTag = '[object Object]'
// 传入对象的类型
const type = getType.call(target)
// 所有支持的类型
const allTypes = [
undefinedTag, nullTag,boolTag, numberTag, stringTag, symbolTag, bigIntTag, arrayTag, objectTag
]
// 如果是不支持的类型
if (!allTypes.includes(type)) {
console.warn(`不支持${type}类型的拷贝,返回{}。`)
return {}
}
// 值类型数组
const valTypes = [
undefinedTag, nullTag,boolTag, numberTag, stringTag,symbolTag, bigIntTag
]
// 值类型直接返回
if (valTypes.includes(type)) {
return target
}
// forEach
function forEach(array, iteratee) {
let index = -1
const length = array.length
while (++index < length) {
// 中断遍历
if (iteratee(array[index], index, array) === false) {
break
}
}
return array
}
// 初始化clone值
let cloneTarget
if (Array.isArray(target)) {
cloneTarget = initCloneArray(target)
} else {
cloneTarget = new target.constructor()
}
// 阻止循环引用
if (cache.has(target)) {
return cache.get(target)
}
cache.set(target, cloneTarget)
// 确定获取key的方法
const keysFunc = getKeysFunc(isFull)
// 克隆Array 和Object
const keys = type === arrayTag ? undefined : keysFunc(target)
forEach(keys || target, (value, key) => {
if (keys) {
key = value
}
cloneTarget[key] = enhanceClone(target[key], cache, isFull)
})
return cloneTarget
}
至此,我们加入了几个特殊类型对象和数组的判断,我们暂且称之为增强版本。
Set 和 Map
Set
和 Map
都提供了 forEach
方法可以遍历自身,处理循环也使用 WeakMap
即可。 我们在 sampleCloen
的基础上继续添加:
var cloneDeep = function (target, cache = new WeakMap()) {
...
const setTag = '[object Set]'
const mapTag = '[object Map]'
...
// 引用类型数组
const refTypes = [
arrayTag, objectTag, setTag, mapTag, argTag
]
// 如果不是指定的引用类型,直接返回空对象,提示无法拷贝
if (!refTypes.includes(type)) {
console.warn(`不支持${type}类型的拷贝,返回{}。`)
return {}
}
// 克隆set
if (type === setTag) {
target.forEach(value => {
cloneTarget.add(cloneDeep(value, cache))
})
return cloneTarget
}
// 克隆map
if (type === mapTag) {
target.forEach((value, key) => {
cloneTarget.set(key, cloneDeep(value, cache))
})
return cloneTarget
}
...
return target
}
WeakMap 和 WeakSet
WeakMap
和 WeakSet
里面存储的都是一些「临时」的值,只要引用次数为0,会被垃圾回收机制自动回收,这个时机是不可预测的,所以一个WeakMap
和 WeakSet
里面目前有多少个成员也是不可预测的,ECMAScript
也规定WeakMap
和 WeakSet
不可遍历。
所以,WeakMap
和 WeakSet
是无法拷贝的。
在 Lodash
中遇到 WeakMap
会返回原对象或者 {}
。
...
const weakMapTag = '[object WeakMap]'
...
const cloneableTags = {}
...
cloneableTags[errorTag] = cloneableTags[weakMapTag] = false
...
// 如果传了原对象的父对象则返回原对象,否则返回{}
if (isFunc || !cloneableTags[tag]) {
return object ? value : {}
}
也就是说,如果你直接拷贝一个WeakMap
对象,会返回 {}
;但是,如果你只是拷贝对象内存的指针,还是可以的,而判断是指针还是对象的依据就是是否传入了父对象。所以,如果要正确的处理这些不可拷贝的对象,我们还要在函数的参数列表中加入父对象的参数。修改后如下:
const cloneDeep = function (target, cache = new WeakMap(), isFull = true, parent) {
...
// 引用类型数组
const refTypes = [
arrayTag, objectTag, setTag, mapTag, argTag
]
// 无法拷贝的数组
const unableTypes = [
weakMapTag, weakSetTag
]
// 如果不是指定的引用类型,且不属于无法拷贝的对象,返回空对象
if (!refTypes.includes(type)) {
// 属于无法拷贝类型,如果传入了父对象,返回引用;反之,直接返回空对象
if (unableTypes.includes(type)) {
return parent ? target : {}
} else {
console.warn(`不支持${type}类型的拷贝,返回{}。`)
return {}
}
}
...
forEach(keys || target, (value, key) => {
if (keys) {
key = value
}
cloneTarget[key] = cloneDeep(target[key], cache, isFull, target)
})
...
}
疑问 为什么 Lodash
只对 WeakMap
做了处理,而没有考虑 WeakSet
呢?
Arguments
Arguments
也是特殊的类数组对象。它没有数组实例的原型方法,它的原型对象指向 Object
的原型。我们看下它的属性:
let tryArgu = function (a, b) {
console.log(Object.prototype.toString.call(arguments))
console.log(Object.getPrototypeOf(arguments) === Object.prototype)
console.log(arguments)
}
tryArgu(1,2)
// [object Arguments]
// true
我们看到,相比于普通的数组对象,多了 callee
属性。这个属性是一个不可枚举的属性,值为当前函数。
arguments.callee === tryArgu // true
Object.getOwnPropertyDescriptor(arguments, 'callee')
知道了以上内容,克隆这种类型的对象的方法就十分简单了。 我们先看下 Lodash
是如何处理的:
...
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
result = (isFlat || isFunc) ? {} : initCloneObject(value)
...
}
...
function initCloneObject(object) {
return (typeof object.constructor === 'function' && !isPrototype(object))
? Object.create(Object.getPrototypeOf(object))
: {}
}
...
可以看到,它把 Arguments
作为一个对象初始化了。我们试一下:
var tryArgu = function (a, b) {
return _.cloneDeep(arguments)
}
tryArgu(1,2)
// {0: 1, 1: 2}
这么处理就是保证基本使用的时候不出错,保证 argumens[n]
与 cloneTarget[n]
相等。如果要真实还原我们应该怎么做呢?
因为我们没有 arguments
的构造函数,所以我们初始化克隆对象的时候,只能通过一个函数返回一个真实的 arguments
对象,然后把它的属性给修改掉。
const cloneDeep = function (target, cache = new WeakMap(), isFull = true, parent) {
...
let cloneTarget
if (Array.isArray(target)) {
cloneTarget = initCloneArray(target)
} else {
if (type === argTag) {
cloneTarget = (function(){return arguments})()
cloneTarget.callee = target.callee
cloneTarget.length = target.length
} else {
cloneTarget = new target.constructor()
}
}
...
}
需要注意的是, length
属性指的是实参的个数,也可以被修改,我们保持跟原对象一致即可。
RegExp
我们平时可能习惯了使用字面量的形式去声明一个正则表达式,其实,每一个正则表达式都是一个 RegExp
的实例,都拥有相应的属性和方法。我们也可以使用 new
关键字实例化一个RegExp
对象。
// 字面量
var reg = /abc/gi
// new
var reg2 = new RegExp('abc', 'gi')
一个正则表达式由两部分组成:**source **和 flags 。
reg.source // 'abc'
reg.flags // 'gi'
**source **中的字符又可以大致分为6类。详见
类名 | 类别英文名 | 举例 |
---|---|---|
字符类别 | Character Classes | \d |
匹配任意阿拉伯数字。等价于[0-9] | ||
字符集合 | Character Sets | [xyz] |
匹配集合中的任意一个字符。如匹配’sex’ 中的’x’ | ||
边界 | Boundaries | $ |
匹配结尾。如 /t$/ 不匹配’tea’中的’t’,但是匹配’eat’中的’t’ |
||
分组和反向引用 | Grouping & Back References | (x) |
匹配’x’并且捕获匹配项。如匹配’xyzxyz’中的两个’x’。 | ||
数量词 | Quantifiers | x* |
匹配前面的模式’x’0次或者多次。 | ||
断言 | Assertions | x(?=y) |
仅匹配被y跟随的x,如’xy abcx ayx’只匹配第一个x |
flags 包含6个字符,可以组合使用。详见
字符 | 对应属性 | 用途 |
---|---|---|
g | global | 全局匹配;找到所有匹配,而不是在第一个匹配后停止 |
i | ignoreCase | 忽略大小写 |
m | multiline | 多行; 将开始和结束字符(^和$)视为在多行上工作(也就是,分别匹配每一行的开始和结束(由 \n 或 \r 分割),而不只是只匹配整个输入字符串的最开始和最末尾处。 |
u | unicode | Unicode; 将模式视为Unicode序列点的序列 |
y | sticky | 粘性匹配; 仅匹配目标字符串中此正则表达式的lastIndex属性指示的索引(并且不尝试从任何后续的索引匹配)。 |
s | dotAll | dotAll模式,.可以匹配任何字符(包括终止符 ‘\n’)。 |
想要知道当前正则表达式是否含有某个flags,可以直接通过属性获取。值得注意的是,这些属性只能获取,不能设置,因为正则表达式实例一旦被构建,就不能再改动了,改动后,就是另一个正则表达式了。
let reg = /abc/uys
reg.global // false
reg.ignoreCase // false
reg.multiline // false
reg.unicode // true
reg.sticky // true
reg.dotAll // true
除了这些,正则表达式还有一个值得注意的属性: lastIndex
。这个属性的值是正则表达式开始匹配的位置,使用正则对象的 test
和 exec
方法,而且当修饰符为 g
或 y
时, 对 lastIndex
是有可能变化的,当然,你也可以设置它的值。
var reg = /ab/g
var str = 'abababababab'
reg.lastIndex // 0
reg.test(str) // true
reg.lastIndex // 2
reg.test(str) // true
reg.lastIndex // 4
好了,正则表达式我们基本上都已经搞清楚了,我们看下 Lodash
是如何对正则对象进行拷贝的。
const reFlags = /\w*$/
function cloneRegExp(regexp) {
const result = new regexp.constructor(regexp.source, reFlags.exec(regexp))
result.lastIndex = regexp.lastIndex
return result
}
/\w*$/
中的 \w
等同于 [A-Za-z0-9_]
, *
表示匹配0次或者多次, $
表示从结尾开始匹配。所以,这个正则的意思就是从结尾开始匹配,找到不是[A-Za-z0-9_]
的字符为止,返回中间这些匹配到的。我们试一下:
/\w*$/.exec('abc/def')
// [0: 'def', groups: undefined, index: 4, input: 'abc/def', length: 1]
/\w*$/.exec('abcdef')
// [0: 'abcdef', groups: undefined, index: 0, input: 'abcdef', length: 1]
RegExp
的第二个参数应该传递的类型是 String
,如果不是,则会调用对象的 toString
方法。所以,上面的返回结果在构建实例的时候会被隐式转换成 String
。
/\w*$/.exec('abc/def').toString() // 'def'
我不知道为啥 Lodash
兜了这么大一圈子,直接用 regexp.flags
不就行了吗?如果说它是为了向后兼容,可能会有新的 flags
那个 [A-Za-z]
应该也够了,没必要用 \w
吧? 我暂时找不到答案,网上的其他一些实现方法也并没有用到这个正则匹配,所以,我们也对这个进行改良。
function cloneRegExp(regexp) {
const result = new regexp.constructor(regexp.source, regexp.flags)
result.lastIndex = regexp.lastIndex
return result
}
加入到我们的深拷贝中
const cloneDeep = function (target, cache = new WeakMap(), isFull = true, parent) {
...
const regexpTag = '[object RegExp]'
...
if (Array.isArray(target)) {
cloneTarget = initCloneArray(target)
} else {
if (type === argTag) {
cloneTarget = (function(){return arguments})()
cloneTarget.callee = target.callee
cloneTarget.length = target.length
} else if(type === regexpTag) {
cloneTarget = cloneRegExp(target)
} else {
cloneTarget = new target.constructor()
}
}
...
}
Date
时间的实例对象,其实是由某个时间值+一些时间处理函数构成的。所以拷贝起来也简单,直接用这个时间值作为参数,生成一个新的实例即可。
// 构建实例时,不传入参数,值即为实例创建的时间
var now = new Date()
Object.prototype.toString.call(now) // "[object Date]"
now + '' // "Thu Dec 26 2019 15:08:50 GMT+0800 (中国标准时间)"
+now // 1577344130208
我们看一下, Lodash
是如何取到时间值的:
function initCloneByTag(object, tag, isDeep) {
const Ctor = object.constructor
...
case dateTag:
return new Ctor(+object)
...
}
跟我们设想的一样。
const cloneDeep = function (target, cache = new WeakMap(), isFull = true, parent) {
... else if(type === dateTag) {
cloneTarget = new target.constructor(+target)
} else {
cloneTarget = new target.constructor()
}
}
...
}
Function 和 Error
这两个对象, Function
没有拷贝的必要, Error
的拷贝则是毫无意义的。 函数,存储的是抽象的逻辑,本身不跟外部的状态有关。比如你可以拷贝 1+2
中的 1
或者 2
,它们是占据内存的具体值,但是函数就是 function add(x, y) { return x + y }
,拷贝这种逻辑的意义不大。 我们看下 Lodash
怎么做的:
const isFunc = typeof value === 'function'
...
if (tag == objectTag || tag == argsTag || (isFunc && !object)) {
result = (isFlat || isFunc) ? {} : initCloneObject(value)
...
} else {
if (isFunc || !cloneableTags[tag]) {
return object ? value : {}
}
...
}
我们可以看到,如果传了父对象,则返回原来的函数,反之,返回空对象。它并没有拷贝函数,网上也有一些文章真的对函数进行了拷贝,用到了 eval
和 new Function
这种标准不提倡使用的命令和语法,我觉得意义不大,感兴趣的可以看看。
我们沿用 Lodash
中的做法即可。
const cloneDeep = function (target, cache = new WeakMap(), isFull = true, parent) {
... else if(type === functionTag) {
cloneTarget = parent ? target : {}
} else {
cloneTarget = new target.constructor()
}
}
...
}
继续说 Error
,为什么会说它的拷贝毫无意义呢?因为压根儿不可能有这样的使用场景…. 但是,错误对象能不能被拷贝呢?我们可以试一下。先看下面的例子:
var err1 = new Error('错误1')
Object.keys(err1) // []
for(let x in err1) { console.log(x) } // undefined
Object.getOwnPropertyNames(err1) // ['stack','message']
err1.message // '错误1'
err1.stack
// 'Error: 错误1
at <anonymous>:1:12'
我们看到 err1
对象有2个不可枚举的属性, message
是创建时传入的参数, stack
是记录的错误创建的堆的位置。 at
后面的内容是抛出错误的**<文件名>:行号:列号**。
那么,如何拷贝呢? Error
构造函数其实是可以传递三个参数的,第一个是 message
,第二个是文件名,第三个是行号。但是后两个参数是非标准的,现在好像没什么浏览器支持,但是即使支持,没有列号,信息也是不完全的。
我们也可以新建一个错误对象,然后将 message
的值作为参数传入,将原对象的 stack
覆盖掉新建对象的 stack
属性,这个其实是可行的。也就是说,我们抛出错误,两个对象都可以跳转到第一个对象报错的地方。但是,它们本身其实是不同的:它们有着不同的调用链。
在它们调用链的最顶端,保存的都是对象被创建的那个位置,这个是无法改变的。所以,这种方法看起来拷贝了常用的属性和方法,但是因为它们创建的位置不同(也不可能相同)。
此处把例子列出来比较麻烦,感兴趣的可以自己实验。
我们看下 Lodash
如何处理错误对象的:
...
cloneableTags[errorTag] = cloneableTags[weakMapTag] = false
...
if (isFunc || !cloneableTags[tag]) {
return object ? value : {}
}
...
跟处理函数和 WeakMap
的方式一样。我们只需把它加入我们之前定义的不可拷贝数组类型即可。
const unableTypes = [
weakMapTag, weakSetTag, errorTag
]
Promise
Promise
实例的拷贝比较简单,因为它存储的事当前的状态,如果在 then
方法中不对当前状态做任何处理,那么它会返回一个保存当前状态的新的实例对象。所以拷贝 Promise
,调用它的 then
方法,然后什么也不做就行了。
const cloneDeep = function (target, cache = new WeakMap(), isFull = true, parent) {
... else if(type === promiseTag) {
cloneTarget = target.then()
} else {
cloneTarget = new target.constructor()
}
}
...
}
ArrayBuffer
TODO
完整版本
基于上述的各种类型,我们可以整合出一个比较全面的版本,来处理 ECMAScript
中所有数据类型的克隆。
一些我们常见的对象如
window
(‘[object Window]’) 、document
(‘[object HTMLDocument]’),它们是浏览器的内置对象,属于BOM和DOM,并不属于ECMAScript
语言中内置对象,不在本文研究的范围之内。
const hasOwnProperty = Object.prototype.hasOwnProperty
const getType = Object.prototype.toString
// 初始化一个数组对象,包括正则返回的特殊数组
function initCloneArray(array) {
const { length } = array
const result = new array.constructor(length)
// Add properties assigned by `RegExp#exec`.
if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
result.index = array.index
result.input = array.input
}
return result
}
// 获取key的方法
function getKeysFunc(isFull) {
// 返回对象上可枚举Symbol key的数组
function getSymbols(object) {
if (object == null) {
return []
}
object = Object(object)
return Object
.getOwnPropertySymbols(object)
.filter((symbol) => Object.prototype.propertyIsEnumerable.call(object, symbol))
}
// 判断是否是合法的类数组的length属性
function isLength(value) {
return typeof value === 'number' &&
value > -1 && value % 1 === 0 && value <= Number.MAX_SAFE_INTEGER
}
// 判断是否是类数组
function isArrayLike(value) {
return value != null && typeof value !== 'function' && isLength(value.length)
}
// 判断是否是合法的类数组的index
function isIndex(value, length) {
const reIsUint = /^(?:0|[1-9]\d*)$/
const type = typeof value
length = length == null ? Number.MAX_SAFE_INTEGER : length
return !!length &&
(type === 'number' ||
(type !== 'symbol' && reIsUint.test(value))) &&
(value > -1 && value % 1 === 0 && value < length)
}
// 是否是arguments
function isArguments(value) {
return typeof value === 'object' && value !== null && getType.call(value) === '[object Arguments]'
}
// 返回类数组上key组成的数组
function arrayLikeKeys(value, inherited) {
const isArr = Array.isArray(value)
const isArg = !isArr && isArguments(value)
const skipIndexes = isArr || isArg
const length = value.length
const result = new Array(skipIndexes ? length : 0)
let index = skipIndexes ? -1 : length
while (++index < length) {
result[index] = `${index}`
}
for (const key in value) {
if ((inherited || hasOwnProperty.call(value, key)) &&
!(skipIndexes && (
// Safari 9 has enumerable `arguments.length` in strict mode.
(key === 'length' ||
// Skip index properties.
isIndex(key, length))
))) {
result.push(key)
}
}
return result
}
// 返回对象上可枚举属性key
function keys(object) {
return isArrayLike(object)
? arrayLikeKeys(object)
: Object.keys(Object(object))
}
// 返回对象上可枚举属性key + Symbol key的数组
function getAllKeys(object) {
const result = keys(object)
if (!Array.isArray(object)) {
result.push(...getSymbols(object))
}
return result
}
return isFull
? getAllKeys
: keys
}
// 拷贝正则对象
function cloneRegExp(regexp) {
const result = new regexp.constructor(regexp.source, regexp.flags)
result.lastIndex = regexp.lastIndex
return result
}
// 拷贝arguments对象
function cloneArguments(args) {
const result = (function(){return arguments})()
result.callee = args.callee
result.length = args.length
return result
}
const cloneDeep = function (target, isFull = true, cache = new WeakMap(), parent) {
// 值类型
const undefinedTag = '[object Undefined]'
const nullTag = '[object Null]'
const boolTag = '[object Boolean]'
const numberTag = '[object Number]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const bigIntTag = '[object BigInt]'
// 引用类型
const arrayTag = '[object Array]'
const objectTag = '[object Object]'
const setTag = '[object Set]'
const mapTag = '[object Map]'
const argTag = '[object Arguments]'
const regexpTag = '[object RegExp]'
const dateTag = '[object Date]'
const funcTag = '[object Function]'
const promiseTag = '[object Promise]'
// 无法拷贝的引用类型
const weakMapTag = '[object WeakMap]'
const weakSetTag = '[object WeakSet]'
const errorTag = '[object Error]'
// 传入对象的类型
const type = getType.call(target)
// 所有支持的类型
const allTypes = [
undefinedTag, nullTag,boolTag, numberTag, stringTag, symbolTag, bigIntTag, arrayTag, objectTag,
setTag, mapTag, argTag, regexpTag, dateTag, funcTag, promiseTag,
weakMapTag, weakSetTag, errorTag
]
// 如果是不支持的类型
if (!allTypes.includes(type)) {
console.warn(`不支持${type}类型的拷贝,返回{}。`)
return {}
}
// 值类型数组
const valTypes = [
undefinedTag, nullTag,boolTag, numberTag, stringTag,symbolTag, bigIntTag
]
// 值类型直接返回
if (valTypes.includes(type)) {
return target
}
// forEach
function forEach(array, iteratee) {
let index = -1
const length = array.length
while (++index < length) {
// 中断遍历
if (iteratee(array[index], index, array) === false) {
break
}
}
return array
}
// 初始化clone值
let cloneTarget
if (Array.isArray(target)) {
cloneTarget = initCloneArray(target)
} else {
switch (type) {
case argTag:
cloneTarget = cloneArguments(target)
break
case regexpTag:
cloneTarget = cloneRegExp(target)
break
case dateTag:
cloneTarget = new target.constructor(+target)
break
case funcTag:
cloneTarget = parent ? target : {}
break
case promiseTag:
cloneTarget = target.then()
break
case weakMapTag:
case weakSetTag:
case errorTag:
!parent && console.warn(`${type}类型无法拷贝,返回{}。`)
cloneTarget = parent ? target : {}
break
default:
cloneTarget = new target.constructor()
}
}
// 阻止循环引用
if (cache.has(target)) {
return cache.get(target)
}
cache.set(target, cloneTarget)
// 克隆set
if (type === setTag) {
target.forEach(value => {
cloneTarget.add(cloneDeep(value, cache))
})
return cloneTarget
}
// 克隆map
if (type === mapTag) {
target.forEach((value, key) => {
cloneTarget.set(key, cloneDeep(value, cache))
})
return cloneTarget
}
// 确定获取key的方法
const keysFunc = getKeysFunc(isFull)
// 克隆Array 和Object
const keys = type === arrayTag ? undefined : keysFunc(target)
forEach(keys || target, (value, key) => {
if (keys) {
key = value
}
cloneTarget[key] = cloneDeep(target[key], isFull, cache, target)
})
return cloneTarget
}
以上代码上可能还有些性能或者写法的问题需要优化,到作为演示 clone
的实现过程已经够用。后续如果 ECMAScript
中又新加了数据类型,继续拓展这个方法就行。
位掩码(bitmasks)的妙用
位运算在日常的开发工作中很少会有涉及,也**非常不推荐使用,**因为它的易读性很差。但是在很底层的框架中却常有用到,因为相比于普通计算,它的效率高多了。除了计算,也有一些别的用法,比如在 lodash
中,就有多处使用了位掩码。 设想你要设计一个权限系统,某个用户的权限分布,可以用以个简单的 json
表示:
{
"id": 1,
"RightA": true,
"RightB": false,
"RightC": false,
"RightD": true
}
随着系统的扩大,权限越来越多,这个对象也会越来越大,无论是在网络传输、还是内存占用上,都会导致效率下降。我们可以试着用位掩码去优化这个问题。
我们看下面这个表格,一些十进制数字的二进制表示:
十进制 | 二进制 |
---|---|
0 | 00000000 |
1 | 00000001 |
2 | 00000010 |
3 | 00000011 |
4 | 00000100 |
5 | 000000101 |
6 | 00000110 |
7 | 00000111 |
8 | 00001000 |
9 | 00001001 |
如果我们用 1
表示 true
, 0
表示 false
,二进制的位置表示权限,那么之前的 JSON
对象可以简化为:
{
"id": 1,
"right": 9
}
这相当于把信息都存储到 一个数字中了,我们现在试着从这个数字中取出信息:
const Right = 9
const RightA = 1
const RightB = 2
const RightC = 4
const RightD = 8
hasRightA = !!(Right & RightA) // true
hasRightB = !!(Right & RightB) // false
hasRightC = !!(Right & RightC) // false
hasRightD = !!(Right & RightD) // true
如果我们又重新设置了权限,需要把信息拼凑好返回:
// 修改后的信息
var change = {
"id": 1,
"RightA": true,
"RightB": false,
"RightC": true,
"RightD": false
}
right = parseInt(1010, 2) // 10
在 lodash
中,位掩码被用于多个参数的传递:
/** Used to compose bitmasks for cloning. */
const CLONE_DEEP_FLAG = 1
const CLONE_FLAT_FLAG = 2
const CLONE_SYMBOLS_FLAG = 4
function baseClone(value, bitmask, customizer, key, object, stack) {
let result
const isDeep = bitmask & CLONE_DEEP_FLAG
const isFlat = bitmask & CLONE_FLAT_FLAG
const isFull = bitmask & CLONE_SYMBOLS_FLAG
...
}
参考
今天的文章Lodash中的cloneDeep分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/14388.html