复制对象(浅拷贝和深拷贝)

一、赋值语句复制

1.1 原始值与引用值(基本类型和引用类型)

ES 变量可以包含两种不同类型的数据:原始值和引用值。原始值就是最简单的数据,引用值则是由多个值构成的对象。

原始值:Undefined、Null、Boolean、Number、String 和 Symbol。

引用值:Object、Function、Array、Date、RegExp。

原始值和引用值在通过变量复制时是有所不同。请看下面的例子:

let num1 = 5; 
let num2 = num1; 
num1 = 10;
console.log(num1); // 10
console.log(num2); // 5

let obj1 = {name:'js'}; 
let obj2 = obj1; 
obj1.name = 'css';
console.log(obj1.name); // 'css'
console.log(obj2.name); // 'css'
console.log(obj1 === obj2); // true

let obj3 = {}; 
let obj4 = {};
console.log(obj3 === obj4); // false

js执行或者是函数执行,都会预编译,产生执行上下文,上下文中的代码在执行的时候,会创建变量对象的一个作用域链,作用域上的变量名会放到栈内存,变量值为undefined,在解释执行阶段,会通过赋值语句=,判断右侧值的类型,如果是原始值会把数据的值插到栈内存,如果是引用值会把数据的指针插到栈内存,引用值被放到堆内存。

栈(stack):是栈内存的简称,栈是自动分配相对固定大小的内存空间,并由系统自动释放,栈数据结构遵循FILO(first in last out)先进后出的原则。

堆(heap):是堆内存的简称,堆是动态分配内存,内存大小不固定,也不会自动释放,堆数据结构是一种无序的树状结构。

栈的特点:开口向上、速度快、容量小;堆的特点:速度稍慢、容量比较大;

小结

原始值大小固定,因此保存在栈内存上。
从一个变量到另一个变量复制原始值会创建该值的第二个副本。
引用值是对象,存储在堆内存上。
包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。

二、引用值复制

由于不能通过赋值操作符去复制引用值,我们就只能通过拷贝的方式去复制一个对象,由于使用场景不同我们区分成,深拷贝和浅拷贝。

2.1 浅拷贝

浅拷贝是按位拷贝对象,它会创建一个新对象,如果属性是基本类型,拷贝的就是基本类型的值;如果属性是引用类型,拷贝的就是内存地址。所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

浅拷贝实现方式

1.赋值语句

根据浅拷贝的定义,如果浅拷贝一个对象,我们只要把这个对象遍历一下,把对象的的值通过赋值语句(=),把值赋给目标对象。

let target = {
    a : 1,
    b : [1,2]
}
let cloneTarget = {};
for (key in target) {
    cloneTarget[key] = target[key];
}

2.Object.assign()

Object.assign是ES6的新函数。Object.assign() 方法可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。但是 Object.assign() 进行的是浅拷贝,拷贝的是对象的属性的引用,而不是对象本身。

let target = {};
Object.assign(target, ...data);

Object.assign 和 Array.prototype.concant() 他们有着一些相似的写法和功能,当然也有着一些区别。

两个方法都是按顺序进行和并,但是 Object.assgin 会把重复的属性进行保留最新值的操作,concat 不会。concat 会创建一个新的数组,不会影响参与合并的原数组。而 Object.assgin 则是把第一个参数对象当成操作对象,把其他参数对象的属性往它身上进行合并,不会创建新对象,是对第一个参数对象的直接操作。concat 创建出来的新数组,和Object.assgin 操作的对象,并不会继承有参与合并对象“本身”及“原型对象”上的方法和属性。

3.展开语法(拓展运算符)

展开语法(Spread syntax), 可以在函数调用/数组构造时, 将数组表达式或者string在语法层面展开;还可以在构造字面量对象时, 将对象表达式按key-value的方式展开。

var obj1 = { foo: 'bar', x: 42 };
var obj2 = { foo: 'baz', y: 13 };

var clonedObj = { ...obj1 };
// 克隆后的对象: { foo: "bar", x: 42 }

var mergedObj = { ...obj1, ...obj2 };
// 合并后的对象: { foo: "baz", x: 42, y: 13 }

4.通过lodash实现浅拷贝

创建一个 value 的浅拷贝。

var cloneValue = _.clone(value);

举个🌰,我们想用myObject复制出一个对象,它的属性a的值是2,其他的和myObject一样。

let myObject = {
    a: 1, 
    b: '1234',
    c: {
        d: true
    }
};

let copyObject1 = copyData(myObject);
copyObject1.a = 2;
console.log(myObject.a + '和' + copyObject1.a); // 1和2
console.log(myObject.c === copyObject1.c); // true

let copyObject2 = {};
Object.assign(copyObject2, myObject);
copyObject2.a = 2;
console.log(myObject.a + '和' + copyObject2.a); // 1和2
console.log(myObject.c === copyObject2.c); // true

let copyObject3 = {...myObject, a:2}; 
copyObject3.a = 2;
console.log(myObject.a + '和' + copyObject3.a); // 1和2
console.log(myObject.c === copyObject3.c); // true

function copyData (target){
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        for (const key in target) {
            cloneTarget[key] = target[key];
        }
        return cloneTarget;
    } else {
        return target;
    }
}

很明显第二和第三种方法实现浅拷贝更加的简便,但是无论是 Object.assign 还是 ... 的展开操作,断掉的引用也只是一层,如果对象嵌套超过一层,还是会有问题,例如:

let copyObject1 = {...myObject}; 
copyObject1.c.d = false;
console.log(myObject.c.d); // false

copyObject和myObject虽然是两个独立的对象,但是copyObject1.c和myObject.c仍然指向相同的引用,浅拷贝只能实现一层的拷贝。想要复制两个完全不同的对象,就要用到深拷贝。

2.2 深拷贝

深拷贝是指源对象与拷贝对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。

深拷贝实现方式

1.JSON做字符串转换

用 JSON.stringify 把对象转换成字符串,再用 JSON.parse 把字符串转换成新的对象。

function deepClone(obj) {
    let _obj = JSON.stringify(obj);
    let objClone = JSON.parse(_obj);
    return objClone;
}

优点:使用方便。缺点:只能JSON格式的对象才能使用这种方法,性能差,大概比遍历慢几倍,无法实现对函数 、RegExp等特殊对象的克隆。会抛弃对象的constructor,所有的构造函数会指向Object对象有循环引用,会报错含有symbol属性名的对象拷贝会漏掉symbol属性。

2.通过递归函数拷贝

上面我们介绍了浅拷贝,浅拷贝只能实现一层的拷贝,如果对象只有一层并且属性值都是原始值,浅拷贝就是深拷贝。如果我们把对象的所有子对象都通过递归调用的浅拷贝函数,生成的新对象就是一个和旧对象相互独立的对象。

const deepCopyData = (target)=> {
    // 克隆原始类型和null
    if (!(target !== null && (typeof target === 'object' || typeof target === 'function'))) {
        return target;
    }
    let cloneTarget = Array.isArray(target) ? [] : {};
    // 克隆对象和数组
    for (const key in target) {
        cloneTarget[key] = deepCopyData(target[key]);
    }
    return cloneTarget;
}

3.通过lodash实现深拷贝

var cloneValue = _.cloneDeep(value); value是要深拷贝的值,cloneValue用来接收拷贝后的值。使用非常方便,兼容也很好。

var objects = [{ 'a': 1 }, { 'b': 2 }];

var deep = _.cloneDeep(objects);
console.log(deep[0] === objects[0]);
// => false

虽然 _.cloneDeep 只用了两行代码就实现了深拷贝,使用起来非常方便。

但是深拷贝需要完全给新对象开辟不同的内存空间,所以数据结构越复杂,性能会越糟糕。有时候有些值我们不会进行赋值操作,所以即使保持引用也没关系。当我们生成新数据时,既要保证旧数据的可用且不变,又要避免深拷贝带来都性能损耗。当我们修改复杂对象中的某一部分时,则遍历父级不断生成新的结构,但允许未经过任何修改的兄弟结构保持原状。

再举个🌰:

let data = {
    "isSelect": "true",
    "count": "1",
    "pointData":[{
        "pointCode":"ECA2107301A3ISA68",
        "point":{
            "type":"START",
            "location":{
                "address":"悦玺",
            }
        }
    },{
        "pointCode":"ECA2107301A3ISA69",
        "point":{
            "type":"END",
            "location":{
                "address":"悦玺",
            }
        }
    }]
}

我们想修改data下面的isSelect,只需要 cloneData = {...data,isSelect:false},如果要修改 data.pointData[0].pointCode,并且其他兄弟节点引用不变,我们就需要多次浅拷贝或者浅拷贝配合map去实现。

let cloneData = {...data};

// 拓展运算符实现
cloneData.pointData = [...data.pointData];
cloneData.pointData[0] = {...data.pointData[0], pointCode : "123"};

// 通过map遍历
cloneData.pointData = data.pointData.map((o, i)=>{
    if (i===0) return {...o, pointCode : "123"};
    return o;
})

实验结果:
data.pointData[0].pointCode  // "ECA2107301A3ISA68";
cloneData.pointData[0].pointCode  // "123";
data.pointData[0] === cloneData.pointData[1] //  false
data.pointData[0].point === cloneData.pointData[0].point; // true

这两种方法能解决我们日常开发中的大部分的引用类型的问题。但是如果层级更多的话,如果修改最里面属性的值就更复杂了。除了这两种我们还可以通过immer来实现。

我们先大致看一下这段代码下面会详细分析。

// 通过immer拷贝
import produce from 'immer';
const cloneData = produce(data, draftTags => {
    draftTags.pointData[0].pointCode = "123";
});

data.pointData[0].pointCode  // "ECA2107301A3ISA68";
cloneData.pointData[0].pointCode  // "123";
data.pointData[0].point === cloneData.pointData[0].point; // true

三、immer

Immer是 Mobx 的作者 Mweststrate 研发的一个immutable库。其核心实现是利用了ES6的proxy,几乎以最小的成本实现了JS的不可变结构。

3.1 可变对象和不可变对象

可变对象

let objA = { name: 'liuzhou' };
let objB = objA;
objB.name = 'liu';
console.log(objA.name); // objA 的name也变成了 'liu'

优点:我们可以随意复制他们,改变并删除他们的某项属性等。

缺点:这种间接修改了其他对象的值,在复杂的代码逻辑中,会造成代码隐患。比如:我在申请单的数据,申请单下面是行程,行程下面是费用,费用里面有个城市组件。我在渲染行程的时候把数据一层层传下去,如果城市组件修改了行程的信息导致的问题,是很难监控的。我们如果定位不到城市组件,几乎没有任何地方能监听的引用类型变化产生的新数据变化。

不可变对象

对象一经创建就不能通过任何方式进行修改;即:不能添加新属性,不能修改已有属性,不能删除已有属性,不能修改已有属性的可枚举性、可配置性、可写性,也就是说这个对象永远是不可变的。

优点:使开发变得简单、可追溯、可测试并减少任何可能的副作用。缺点:没有可变对象那么自由,数据处理起来会相对麻烦一点。

函数式编程里强调,只有纯的、没有副作用的函数,才是合格的函数。纯的是指函数的调用参数相同,则永远返回相同的结果。它不依赖于程序执行期间函数外部任何状态或数据的变化。函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量)或修改参数。

const a = 0;
const f = (x)=>  { 
    return x + a + 1;
}
f(3);

const f = (x)=> { 
    x.b = x.b + 1;
    return x;
}
const obj = {
    a:1,
    b:2
}
f(obj);
console.log(obj.b) // 3

函数与外界交换数据只有一个唯一渠道——参数和返回值;函数从函数外部接受的所有输入信息都通过参数传递到该函数内部;函数输出到函数外部的所有信息都通过返回值传递到该函数外部。

我们不想在函数里修改传参,可以在刚进入函数时对要使用的参数进行浅拷贝或者深拷贝。如果函数的每一个参数都是个不可变对象,这样开发者想在函数修改参数,直接会报错,强制用户必须使用浅拷贝或者深拷贝来避免被修改,但这样造成了CPU和内存的消耗,不过immer可以很好地解决这些问题。

讨论一下我们是否要使用不可变对象。

Immer自动冻结功能。

Immer 还在内部做了一件很巧妙的事情,那就是通过 produce 生成的 nextState 是被冻结(freeze)的,(Immer 内部使用Object.freeze方法,只冻结 nextState 跟 currentState 相比修改的部分),这样,当直接修改 nextState 时,将会报错。这使得 nextState 成为了真正的不可变数据。

const [dataSource, setDataSource] = useState([]);
let copyData = produce(data, draftTags => {

});
console.log(copyData === data); // true

data.mileageData = 123;  // 没问题
copyData.mileageData = 123; // TypeError: Cannot assign to read only property 
setDataSource(copyData);

useEffect(() => {
    let _dataSource = dataSource;
    _dataSource.mileageData = 123; // TypeError: Cannot assign to read only property 
}, [dataSource]); 

// this.setState 是一样的也会冻结。

let nameData = { name: 'liuzhou' }
let nextState = produce(data, (o) => {
    o.data = nameData;
});

nameData.name = 'zhouliu';  // TypeError: Cannot assign to read only property 'name' of object

除了 produce 生成的对象会全部冻结,对原始的对象不会进行任何操作。相比 currentState 修改的部分,也会冻结。

3.2 immer 使用教程

Immer只提供了三个API,代码量也不多:

produce(state|function, function):核心API,根据原始对象,经过业务操作返回新对象,支持 Curry 方式使用。

setAutoFreeze:是否设置新产生的对象是个被冻结对象,默认生成的对象被冻结。

setUseProxies:是否使用代理对象(Proxy.revocable)的方式来监听处理。 Immer会判断是否可以使用ES6的Proxy,如果没有只能使用ES5的方式去实现代理。

import produce , { setAutoFreeze } from "immer"

// 常规写法
const cloneData = produce(data, draftTags => {
    draftTags.pointData[0].pointCode = "123";
});

cloneData.pointData[0].pointCode  = "456"  // TypeError: Cannot assign to read only property 
setAutoFreeze(false); // 设置这个属性后,修改produce返回的对象,就不会报错

// Immer支持柯里化,利用高阶函数,提前生成一个生产者producer。
let producer = produce((draft) => {
    draft.x = 2;
});
let nextState = producer(currentState);

setAutoFreeze(true) 生成的对象默认会冻结(冻结后不可改变)。如果把生成的对象传到下级,不可被直接修改,只可以通过immer或者拷贝出新对象去修改,但是引用如果没有被修改,修改新对象上原来引用的数据还是会报错。

如果我们想生成不是冻结对象 只要设置 setAutoFreeze(false) 就可以。设置是否冻结的属性是全局的。

var immer = new Immer();
var produce = immer.produce;
var setAutoFreeze = immer.setAutoFreeze.bind(immer);
exports.Immer = Immer;
exports.default = produce;;
exports.produce = produce;
exports.setAutoFreeze = setAutoFreeze;

我们通过 import produce from 'immer'; 在文件中都是produce,setAutoFreeze都是一个new Immer() 函数构造的,所以在一处设置setAutoFreeze会影响到其他的页面。如果我们在自己的页面维护一个独立的是否冻结,不影响全局我们可以在页面这样引入immer。

import { Immer } from "immer"
const immer = new Immer();
const { produce } = immer;
immer.setAutoFreeze(false);

在我们的项目中简便写法:

// 在hook页面
const [data, setData] = useState([]);
setData(produce(data, draftTags => {
    draftTags.pointData[0].pointCode = "123";
}));

// 在class页面
this.setState(({ data }) => {
    return {
        data: produce(data, draftTags => {
           draftTags.pointData[0].pointCode = "123";
        })
    };
});

3.3 immer 代码分析

Immer核心基本上都是围绕 Proxy 和 ES5 两种代理实现方式展开的,实现思路大体一致,但有细微差别。

Es5环境实现

import produce from 'immer';
const cloneData = produce(data, draftTags => {
    draftTags.pointData[0].pointCode = "123";
});

根据上面的例子不难看出,produce 就是一个函数,传两个参数,第一个参数是需要复制的源对象。第二个是个函数,函数有一个传参,根据参数名称和下面的是对参数的操作,可以看出这个函数的传参类似复制data的草稿,我们只要在函数里面对这个草稿进行操作,这个函数就能返回一个定向复制的一个数据。我们能不能模仿immer通过js实现这种可以定向拷贝的produce函数。其实我们想写出这样的函数难点在于如何监听对象的每一个节点的修改,定位到修改后,从源节点到操作节点的每一层都来一个浅拷贝就可以实现。

const produce = (data, draftTags) =>{
    const cloneData = {...data};
    function defineProperty(obj, key, val, parent){
        observer(val, {name : obj, code:''+key}) 
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                return val;
            },
            set(newval) {
                if (newval === val) return;
                console.log('监听赋值成功', newval);
                if (!cloneData.hasOwnProperty(key) && parent) {   // 代码的灵魂
                    parent.name[parent.code] = {...obj,[key]:newval}
                } else{ 
                    val = {...newval}
                }          
            }
        })
    }
    
    function observer(obj,pater) {
        if (typeof obj !== 'object' || obj == null) return;
        for (const key in obj) {
            defineProperty(obj, key, obj[key],pater);
        }
    }
    observer(cloneData);
    return draftTags(cloneData);
}

let data = {
    "isSelect": "true",
    "count": "1",
    "userInfo": {
        userName:'三刀',
        address:{
            city:'亳州'
        }
    },
    "pointData":[{
        "pointCode":"ECA2107301A3ISA68",
        "point":{
            "type":"START",
            "location":{
                "address":"悦玺",
            }
        }
    }]
}

const cloneData = produce(data, draftTags => {
    draftTags.userInfo.address.city = "杭州";
    return draftTags;
});
console.log(data.userInfo.address.city); // "亳州"
console.log(cloneData.userInfo.address.city); // "杭州"
console.log(cloneData.pointData === data.pointData); // true

这段代码是我模仿这immer在Es5的环境下,只能通过Object.defineProperty监听所有的属性的 setter/getter 方法来实现的。虽然只有三十行,但是还是可以用的。可以直接复制到浏览器控制台运行一下。

这种方式是在Es5的环境immer实现代理的方法,现在基本不用,因为在通过Object.defineProperty监听所有的属性的时候就比较耗时,在Es6我们通过Proxy对象代理来实现对属性的监听。

Proxy实现

Es5在通过Object.defineProperty监听所有的属性的时候就比较耗时。但是Es6支持通过Proxy去代理。

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

const handler = {
    get: function(obj, prop) {
        return prop in obj ? obj[prop] : 37;
    },
    set: function(obj, prop, value) {
        if (value > 200) {
            throw new RangeError('最大赋值200');
        }
        obj[prop] = value;
        return true;
    }
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = 300;
console.log(p.a, p.b);      // 1, undefined
console.log('c' in p, p.c); // false, 37

Proxy是代理在对象级别的,defineProperty是代理到静态的值级别,在Es5必须得把所有的属性全部添加defineProperty,Proxy对整个对象都会进行拦截。

我们一起看一下immer是怎么写produce函数的。

function produce(baseState, producer) {
    // produce的入参不能超过两个
    if (arguments.length !== 1 && arguments.length !== 2) throw new Error("produce expects 1 or 2 arguments, got " + arguments.length)

    // Curry方式试用produce
    if (typeof baseState === "function") {
        // 第二个参数只能接受初始状态量
        if (typeof producer === "function") throw new Error("if first argument is a function (curried invocation), the second argument to produce cannot be a function")

        const initialState = producer
        const recipe = baseState // 加工函数
        // 返回Currry包装函数
        return function() {
            const args = arguments
            // 优先取第二个参数作为初始状态量,如果为undefined,则取运行时的第一个参数作为初始状态量
            const currentState =
            args[0] === undefined && initialState !== undefined
                            ? initialState
                            : args[0]

            return produce(currentState, draft => {
                // 还是将参数原封不动传递给加工函数,只修改初始状态量为代理对象
                // this指向代理对象 draft
                // this指向代理对象
                return recipe.apply(draft, args)
            })
        }
    }

    {
        if (typeof producer !== "function") throw new Error("if first argument is not a function, the second argument to produce should be a function")
    }

    // 如果状态量是个原始对象,不需要代理直接返回
    if (typeof baseState !== "object" || baseState === null)
        return producer(baseState)
    // 状态量必须是个可代理对象,基本上也就是object或者array
    if (!isProxyable(baseState))
        throw new Error(
        `the first argument to an immer producer should be a primitive, plain object or array, got ${typeof baseState}: "${baseState}"`
    )
    // Proxy或者ES5方式注册代理
    return getUseProxies()
        ? produceProxy(baseState, producer)****
        : produceEs5(baseState, producer)
    }

Immer给每个被代理对象额外记录了一些信息,不仅可以作为特定的标识位,也能够提升运行效率,每个包装后的对象都会被完整记录在全局,在方法运行结束后,恢复代理前的状态。

// proxy.js
{
    modified: false, // 对象是否被修改,体现在setter函数上
    finalized: false, // 是否已经完成修改,如果为true确定已经有copy对象了
    parent, // 对象上层
    base, // 基础对象,不动
    copy: undefined, // 原对象的copy对象(属性有可能部分是代理对象,部分为原对象)
    proxies: {} // 记录子属性的代理
}

const objectTraps = {
// set、get、defineProperty等handler
// ......
}

const arrayTraps = {}
each(objectTraps, (key, fn) => {
    arrayTraps[key] = function() {
        // 将state作为proxy handler的第一个参数(target)保证后续操作的统一
        arguments[0] = arguments[0][0]
        return fn.apply(this, arguments)
    }
})

function createProxy(parentState, base) {
    // 初始化额外的信息
    const state = createState(parentState, base)
    // 注意:Proxy的方式监听的是整个包装后的对象
    const proxy = Array.isArray(base)
        ? Proxy.revocable([state], arrayTraps)
        : Proxy.revocable(state, objectTraps)
    proxies.push(proxy)
    return proxy.proxy
}

function set(state, prop, value) {
    if (!state.modified) {
        // 没有做任何更改 -> draft.a = draft.a
        if (
        (prop in state.base && is(state.base[prop], value)) ||
        (has(state.proxies, prop) && state.proxies[prop] === value)
        )
        return true
        markChanged(state)
    }
    state.copy[prop] = value
    return true;
}

function markChanged(state) {
    if (!state.modified) {
        state.modified = true
        state.copy = shallowCopy(state.base)
        // 将已经创建代理的属性替换掉copy中的同名属性
        Object.assign(state.copy, state.proxies)
        // 递归浅拷贝 + 修改modified
        if (state.parent) markChanged(state.parent)
    }
}

无论是Proxy还是ES5的实现方式,加工函数能访问的都只是代理对象,所有的修改都不会流入原始对象,且代理对象都是按需生成的,用户访问到哪个位置,代理注册到哪个位置。

当管理状态更新时,使用 immer 对我们来说是不费吹灰之力的。这是一个很轻巧的库,可以让你继续使用所学的关于 JavaScript 的所有知识,而不会产生额外的学习成本。

四、性能分析

我准备了一个大约2M的json文件。来分别测试浅拷贝,深拷贝和immer的性能。通过循环调用打印出100次复制所用的时间,再操作五次,取出平均值,得到复制100次的平均时间。

这是统计是 Object.assign(0.061ms), 拓展运算符(0.055ms), lodash clone(0.675)三种浅拷贝。前两种时间几乎相当,lodash在性能上差了十倍。拓展运算符不光性能最好,书写也非常简便。所以我们在日常使用还是建议使用拓展运算符来实现浅拷贝。

这是统计是 JSON.parse(466ms), 递归拷贝(56.25ms), lodash cloneDeep(256ms)三种深拷贝。和通过immer(11.5ms)这种定向拷贝。JSON.parse虽然书写简单但是性能太差,递归拷贝虽然比较快但是兼容性不好,lodash cloneDeep兼容性是很好的,但是是比较消耗性能的。所以我们在日常使用还是建议多使用immer来操作应用类型。防止出错的同时,最大限度的提高代码性能。

五、参考链接

posted @ 2023-03-20 15:37  刘舟  阅读(186)  评论(0编辑  收藏  举报