JavaScript-浏览器模型(七)
(11)IDBObjectStore.createIndex()
IDBObjectStore.createIndex()
方法用于新建当前数据库的一个索引。该方法只能在VersionChange
监听函数里面调用。
objectStore.createIndex(indexName, keyPath, objectParameters)
该方法可以接受三个参数。
- indexName:索引名
- keyPath:主键
- objectParameters:配置对象(可选)
第三个参数可以配置以下属性。
- unique:如果设为
true
,将不允许重复的值 - multiEntry:如果设为
true
,对于有多个值的主键数组,每个值将在索引里面新建一个条目,否则主键数组对应一个条目。
假定对象仓库中的数据记录都是如下的person
类型。
var person = {
name: name,
email: email,
created: new Date()
};
可以指定这个对象的某个属性来建立索引。
var store = db.createObjectStore('people', { autoIncrement: true });
store.createIndex('name', 'name', { unique: false });
store.createIndex('email', 'email', { unique: true });
上面代码告诉索引对象,name
属性不是唯一值,email
属性是唯一值。
(12)IDBObjectStore.deleteIndex()
IDBObjectStore.deleteIndex()
方法用于删除指定的索引。该方法只能在VersionChange
监听函数里面调用。
objectStore.deleteIndex(indexName)
(13)IDBObjectStore.openCursor()
IDBObjectStore.openCursor()
用于获取一个指针对象。
IDBObjectStore.openCursor()
指针对象可以用来遍历数据。该对象也是异步的,有自己的success
和error
事件,可以对它们指定监听函数。
var t = db.transaction(['test'], 'readonly');
var store = t.objectStore('test');
var cursor = store.openCursor();
cursor.onsuccess = function (event) {
var res = event.target.result;
if (res) {
console.log('Key', res.key);
console.dir('Data', res.value);
res.continue();
}
}
监听函数接受一个事件对象作为参数,该对象的target.result
属性指向当前数据记录。该记录的key
和value
分别返回主键和键值(即实际存入的数据)。continue()
方法将光标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则光标指向null
。
openCursor()
方法的第一个参数是主键值,或者一个 IDBKeyRange 对象。如果指定该参数,将只处理包含指定主键的记录;如果省略,将处理所有的记录。该方法还可以接受第二个参数,表示遍历方向,默认值为next
,其他可能的值为prev
、nextunique
和prevunique
。后两个值表示如果遇到重复值,会自动跳过。
(14)IDBObjectStore.openKeyCursor()
IDBObjectStore.openKeyCursor()
用于获取一个主键指针对象。
IDBObjectStore.openKeyCursor()
8、IDBTransaction 对象
IDBTransaction 对象用来异步操作数据库事务,所有的读写操作都要通过这个对象进行。
IDBDatabase.transaction()
方法返回的就是一个 IDBTransaction 对象。
var db;
var DBOpenRequest = window.indexedDB.open('demo', 1);
DBOpenRequest.onsuccess = function(event) {
db = DBOpenRequest.result;
var transaction = db.transaction(['demo'], 'readwrite');
transaction.oncomplete = function (event) {
console.log('transaction success');
};
transaction.onerror = function (event) {
console.log('transaction error: ' + transaction.error);
};
var objectStore = transaction.objectStore('demo');
var objectStoreRequest = objectStore.add({ foo: 1 });
objectStoreRequest.onsuccess = function (event) {
console.log('add data success');
};
};
事务的执行顺序是按照创建的顺序,而不是发出请求的顺序。
var trans1 = db.transaction('foo', 'readwrite');
var trans2 = db.transaction('foo', 'readwrite');
var objectStore2 = trans2.objectStore('foo')
var objectStore1 = trans1.objectStore('foo')
objectStore2.put('2', 'key');
objectStore1.put('1', 'key');
上面代码中,key
对应的键值最终是2
,而不是1
。因为事务trans1
先于trans2
创建,所以首先执行。
注意,事务有可能失败,只有监听到事务的complete
事件,才能保证事务操作成功。
IDBTransaction 对象有以下属性。
IDBTransaction.db
:返回当前事务所在的数据库对象 IDBDatabase。IDBTransaction.error
:返回当前事务的错误。如果事务没有结束,或者事务成功结束,或者被手动终止,该方法返回null
。IDBTransaction.mode
:返回当前事务的模式,默认是readonly
(只读),另一个值是readwrite
。IDBTransaction.objectStoreNames
:返回一个类似数组的对象 DOMStringList,成员是当前事务涉及的对象仓库的名字。IDBTransaction.onabort
:指定abort
事件(事务中断)的监听函数。IDBTransaction.oncomplete
:指定complete
事件(事务成功)的监听函数。IDBTransaction.onerror
:指定error
事件(事务失败)的监听函数。
IDBTransaction 对象有以下方法。
IDBTransaction.abort()
:终止当前事务,回滚所有已经进行的变更。IDBTransaction.objectStore(name)
:返回指定名称的对象仓库 IDBObjectStore。
9、IDBIndex 对象
IDBIndex 对象代表数据库的索引,通过这个对象可以获取数据库里面的记录。数据记录的主键默认就是带有索引,IDBIndex 对象主要用于通过除主键以外的其他键,建立索引获取对象。
IDBIndex 是持久性的键值对存储。只要插入、更新或删除数据记录,引用的对象库中的记录,索引就会自动更新。
IDBObjectStore.index()
方法可以获取 IDBIndex 对象。
var transaction = db.transaction(['contactsList'], 'readonly');
var objectStore = transaction.objectStore('contactsList');
var myIndex = objectStore.index('lName');
myIndex.openCursor().onsuccess = function (event) {
var cursor = event.target.result;
if (cursor) {
var tableRow = document.createElement('tr');
tableRow.innerHTML = '<td>' + cursor.value.id + '</td>'
+ '<td>' + cursor.value.lName + '</td>'
+ '<td>' + cursor.value.fName + '</td>'
+ '<td>' + cursor.value.jTitle + '</td>'
+ '<td>' + cursor.value.company + '</td>'
+ '<td>' + cursor.value.eMail + '</td>'
+ '<td>' + cursor.value.phone + '</td>'
+ '<td>' + cursor.value.age + '</td>';
tableEntry.appendChild(tableRow);
cursor.continue();
} else {
console.log('Entries all displayed.');
}
};
IDBIndex 对象有以下属性。
IDBIndex.name
:字符串,索引的名称。IDBIndex.objectStore
:索引所在的对象仓库。IDBIndex.keyPath
:索引的主键。IDBIndex.multiEntry
:布尔值,针对keyPath
为数组的情况,如果设为true
,创建数组时,每个数组成员都会有一个条目,否则每个数组都只有一个条目。IDBIndex.unique
:布尔值,表示创建索引时是否允许相同的主键。
IDBIndex 对象有以下方法,它们都是异步的,立即返回的都是一个 IDBRequest 对象。
IDBIndex.count()
:用来获取记录的数量。它可以接受主键或 IDBKeyRange 对象作为参数,这时只返回符合主键的记录数量,否则返回所有记录的数量。IDBIndex.get(key)
:用来获取符合指定主键的数据记录。IDBIndex.getKey(key)
:用来获取指定的主键。IDBIndex.getAll()
:用来获取所有的数据记录。它可以接受两个参数,都是可选的,第一个参数用来指定主键,第二个参数用来指定返回记录的数量。如果省略这两个参数,则返回所有记录。由于获取成功时,浏览器必须生成所有对象,所以对性能有影响。如果数据集比较大,建议使用 IDBCursor 对象。IDBIndex.getAllKeys()
:该方法与IDBIndex.getAll()
方法相似,区别是获取所有主键。IDBIndex.openCursor()
:用来获取一个 IDBCursor 对象,用来遍历索引里面的所有条目。IDBIndex.openKeyCursor()
:该方法与IDBIndex.openCursor()
方法相似,区别是遍历所有条目的主键。
10、IDBCursor 对象
IDBCursor 对象代表指针对象,用来遍历数据仓库(IDBObjectStore)或索引(IDBIndex)的记录。
IDBCursor 对象一般通过IDBObjectStore.openCursor()
方法获得。
var transaction = db.transaction(['rushAlbumList'], 'readonly');
var objectStore = transaction.objectStore('rushAlbumList');
objectStore.openCursor(null, 'next').onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
var listItem = document.createElement('li');
listItem.innerHTML = cursor.value.albumTitle + ', ' + cursor.value.year;
list.appendChild(listItem);
console.log(cursor.source);
cursor.continue();
} else {
console.log('Entries all displayed.');
}
};
};
IDBCursor 对象的属性。
IDBCursor.source
:返回正在遍历的对象仓库或索引。IDBCursor.direction
:字符串,表示指针遍历的方向。共有四个可能的值:next(从头开始向后遍历)、nextunique(从头开始向后遍历,重复的值只遍历一次)、prev(从尾部开始向前遍历)、prevunique(从尾部开始向前遍历,重复的值只遍历一次)。该属性通过IDBObjectStore.openCursor()
方法的第二个参数指定,一旦指定就不能改变了。IDBCursor.key
:返回当前记录的主键。IDBCursor.value
:返回当前记录的数据值。- IDBCursor.primaryKey:返回当前记录的主键。对于数据仓库(objectStore)来说,这个属性等同于 IDBCursor.key;对于索引,IDBCursor.key 返回索引的位置值,该属性返回数据记录的主键。
IDBCursor 对象有如下方法。
IDBCursor.advance(n)
:指针向前移动 n 个位置。IDBCursor.continue()
:指针向前移动一个位置。它可以接受一个主键作为参数,这时会跳转到这个主键。IDBCursor.continuePrimaryKey()
:该方法需要两个参数,第一个是key
,第二个是primaryKey
,将指针移到符合这两个参数的位置。IDBCursor.delete()
:用来删除当前位置的记录,返回一个 IDBRequest 对象。该方法不会改变指针的位置。IDBCursor.update()
:用来更新当前位置的记录,返回一个 IDBRequest 对象。它的参数是要写入数据库的新的值。
11、IDBKeyRange 对象
IDBKeyRange 对象代表数据仓库(object store)里面的一组主键。根据这组主键,可以获取数据仓库或索引里面的一组记录。
IDBKeyRange 可以只包含一个值,也可以指定上限和下限。它有四个静态方法,用来指定主键的范围。
IDBKeyRange.lowerBound()
:指定下限。IDBKeyRange.upperBound()
:指定上限。IDBKeyRange.bound()
:同时指定上下限。IDBKeyRange.only()
:指定只包含一个值。
下面是一些代码实例。
// All keys ≤ x
var r1 = IDBKeyRange.upperBound(x);
// All keys < x
var r2 = IDBKeyRange.upperBound(x, true);
// All keys ≥ y
var r3 = IDBKeyRange.lowerBound(y);
// All keys > y
var r4 = IDBKeyRange.lowerBound(y, true);
// All keys ≥ x && ≤ y
var r5 = IDBKeyRange.bound(x, y);
// All keys > x &&< y
var r6 = IDBKeyRange.bound(x, y, true, true);
// All keys > x && ≤ y
var r7 = IDBKeyRange.bound(x, y, true, false);
// All keys ≥ x &&< y
var r8 = IDBKeyRange.bound(x, y, false, true);
// The key = z
var r9 = IDBKeyRange.only(z);
IDBKeyRange.lowerBound()
、IDBKeyRange.upperBound()
、IDBKeyRange.bound()
这三个方法默认包括端点值,可以传入一个布尔值,修改这个属性。
与之对应,IDBKeyRange 对象有四个只读属性。
IDBKeyRange.lower
:返回下限IDBKeyRange.lowerOpen
:布尔值,表示下限是否为开区间(即下限是否排除在范围之外)IDBKeyRange.upper
:返回上限IDBKeyRange.upperOpen
:布尔值,表示上限是否为开区间(即上限是否排除在范围之外)
IDBKeyRange 实例对象生成以后,将它作为参数输入 IDBObjectStore 或 IDBIndex 对象的openCursor()
方法,就可以在所设定的范围内读取数据。
var t = db.transaction(['people'], 'readonly');
var store = t.objectStore('people');
var index = store.index('name');
var range = IDBKeyRange.bound('B', 'D');
index.openCursor(range).onsuccess = function (e) {
var cursor = e.target.result;
if (cursor) {
console.log(cursor.key + ':');
for (var field in cursor.value) {
console.log(cursor.value[field]);
}
cursor.continue();
}
}
IDBKeyRange 有一个实例方法includes(key)
,返回一个布尔值,表示某个主键是否包含在当前这个主键组之内。
var keyRangeValue = IDBKeyRange.bound('A', 'K', false, false);
keyRangeValue.includes('F') // true
keyRangeValue.includes('W') // false
十五、Web Worker
1、概述
JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务可以交由 Worker 线程执行,主线程(通常负责 UI 交互)能够保持流畅,不会被阻塞或拖慢。
Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。
Web Worker 有以下几个使用注意点。
(1)同源限制
分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。
(2)DOM 限制
Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用document
、window
、parent
这些对象。但是,Worker 线程可以使用navigator
对象和location
对象。
(3)全局对象限制
Worker 的全局对象WorkerGlobalScope
,不同于网页的全局对象Window
,很多接口拿不到。比如,理论上 Worker 线程不能使用console.log
,因为标准里面没有提到 Worker 的全局对象存在console
接口,只定义了Navigator
接口和Location
接口。不过,浏览器实际上支持 Worker 线程使用console.log
,保险的做法还是不使用这个方法。
(4)通信联系
Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。
(5)脚本限制
Worker 线程不能执行alert()
方法和confirm()
方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。
(6)文件限制
Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://
),它所加载的脚本,必须来自网络。
2、基本用法
2.1 主线程
主线程采用new
命令,调用Worker()
构造函数,新建一个 Worker 线程。
var worker = new Worker('work.js');
Worker()
构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。
然后,主线程调用worker.postMessage()
方法,向 Worker 发消息。
worker.postMessage('Hello World');
worker.postMessage({method: 'echo', args: ['Work']});
worker.postMessage()
方法的参数,就是主线程传给 Worker 的数据。它可以是各种数据类型,包括二进制数据。
接着,主线程通过worker.onmessage
指定监听函数,接收子线程发回来的消息。
worker.onmessage = function (event) {
doSomething(event.data);
}
function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}
上面代码中,事件对象的data
属性可以获取 Worker 发来的数据。
Worker 完成任务以后,主线程就可以把它关掉。
worker.terminate();
2.2 Worker 线程
Worker 线程内部需要有一个监听函数,监听message
事件。
self.addEventListener('message', function (e) {
self.postMessage('You said: ' + e.data);
}, false);
上面代码中,self
代表子线程自身,即子线程的全局对象。因此,等同于下面两种写法。
// 写法一
this.addEventListener('message', function (e) {
this.postMessage('You said: ' + e.data);
}, false);
// 写法二
addEventListener('message', function (e) {
postMessage('You said: ' + e.data);
}, false);
除了使用self.addEventListener()
指定监听函数,也可以使用self.onmessage
指定。监听函数的参数是一个事件对象,它的data
属性包含主线程发来的数据。self.postMessage()
方法用来向主线程发送消息。
根据主线程发来的数据,Worker 线程可以调用不同的方法,下面是一个例子。
self.addEventListener('message', function (e) {
var data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg);
self.close(); // Terminates the worker.
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);
上面代码中,self.close()
用于在 Worker 内部关闭自身。
2.3 Worker 加载脚本
Worker 内部如果要加载其他脚本,有一个专门的方法importScripts()
。
importScripts('script1.js');
该方法可以同时加载多个脚本。
importScripts('script1.js', 'script2.js');
2.4 错误处理
主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error
事件。
worker.onerror(function (event) {
console.log([
'ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message
].join(''));
});
// 或者
worker.addEventListener('error', function (event) {
// ...
});
Worker 内部也可以监听error
事件。
2.5 关闭 Worker
使用完毕,为了节省系统资源,必须关闭 Worker。
// 主线程
worker.terminate();
// Worker 线程
self.close();
3、数据通信
前面说过,主线程与 Worker 之间的通信内容,可以是文本,也可以是对象。需要注意的是,这种通信是拷贝关系,即是传值而不是传址,Worker 对通信内容的修改,不会影响到主线程。事实上,浏览器内部的运行机制是,先将通信内容串行化,然后把串行化后的字符串发给 Worker,后者再将它还原。
主线程与 Worker 之间也可以交换二进制数据,比如 File、Blob、ArrayBuffer 等类型,也可以在线程之间发送。下面是一个例子。
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。
如果要直接转移数据的控制权,就要使用下面的写法。
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);
// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);
4、同页面的 Web Worker
通常情况下,Worker 载入的是一个单独的 JavaScript 脚本文件,但是也可以载入与主线程在同一个网页的代码。
<!DOCTYPE html>
<body>
<script id="worker" type="app/worker">
addEventListener('message', function () {
postMessage('some message');
}, false);
</script>
</body>
</html>
上面是一段嵌入网页的脚本,注意必须指定``标签的type
属性是一个浏览器不认识的值,上例是app/worker
。
然后,读取这一段嵌入页面的脚本,用 Worker 来处理。
var blob = new Blob([document.querySelector('#worker').textContent]);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
worker.onmessage = function (e) {
// e.data === 'some message'
};
上面代码中,先将嵌入网页的脚本代码,转成一个二进制对象,然后为这个二进制对象生成 URL,再让 Worker 加载这个 URL。这样就做到了,主线程和 Worker 的代码都在同一个网页上面。
5、实例:Worker 线程完成轮询
有时,浏览器需要轮询服务器状态,以便第一时间得知状态改变。这个工作可以放在 Worker 里面。
function createWorker(f) {
var blob = new Blob(['(' + f.toString() + ')()']);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
return worker;
}
var pollingWorker = createWorker(function (e) {
var cache;
function compare(new, old) { ... };
setInterval(function () {
fetch('/my-api-endpoint').then(function (res) {
var data = res.json();
if (!compare(data, cache)) {
cache = data;
self.postMessage(data);
}
})
}, 1000)
});
pollingWorker.onmessage = function () {
// render data
}
pollingWorker.postMessage('init');
上面代码中,Worker 每秒钟轮询一次数据,然后跟缓存做比较。如果不一致,就说明服务端有了新的变化,因此就要通知主线程。
6、实例: Worker 新建 Worker
Worker 线程内部还能再新建 Worker 线程(目前只有 Firefox 浏览器支持)。下面的例子是将一个计算密集的任务,分配到10个 Worker。
主线程代码如下。
var worker = new Worker('worker.js');
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data;
};
Worker 线程代码如下。
// worker.js
// settings
var num_workers = 10;
var items_per_worker = 1000000;
// start the workers
var result = 0;
var pending_workers = num_workers;
for (var i = 0; i < num_workers; i += 1) {
var worker = new Worker('core.js');
worker.postMessage(i * items_per_worker);
worker.postMessage((i + 1) * items_per_worker);
worker.onmessage = storeResult;
}
// handle the results
function storeResult(event) {
result += event.data;
pending_workers -= 1;
if (pending_workers <= 0)
postMessage(result); // finished!
}
上面代码中,Worker 线程内部新建了10个 Worker 线程,并且依次向这10个 Worker 发送消息,告知了计算的起点和终点。计算任务脚本的代码如下。
// core.js
var start;
onmessage = getStart;
function getStart(event) {
start = event.data;
onmessage = getEnd;
}
var end;
function getEnd(event) {
end = event.data;
onmessage = null;
work();
}
function work() {
var result = 0;
for (var i = start; i < end; i += 1) {
// perform some complex calculation here
result += 1;
}
postMessage(result);
close();
}
7、API
7.1 主线程
浏览器原生提供Worker()
构造函数,用来供主线程生成 Worker 线程。
var myWorker = new Worker(jsUrl, options);
Worker()
构造函数,可以接受两个参数。第一个参数是脚本的网址(必须遵守同源政策),该参数是必需的,且只能加载 JS 脚本,否则会报错。第二个参数是配置对象,该对象可选。它的一个作用就是指定 Worker 的名称,用来区分多个 Worker 线程。
// 主线程
var myWorker = new Worker('worker.js', { name : 'myWorker' });
// Worker 线程
self.name // myWorker
Worker()
构造函数返回一个 Worker 线程对象,用来供主线程操作 Worker。Worker 线程对象的属性和方法如下。
- Worker.onerror:指定 error 事件的监听函数。
- Worker.onmessage:指定 message 事件的监听函数,发送过来的数据在
Event.data
属性中。 - Worker.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
- Worker.postMessage():向 Worker 线程发送消息。
- Worker.terminate():立即终止 Worker 线程。
7.2 Worker 线程
Web Worker 有自己的全局对象,不是主线程的window
,而是一个专门为 Worker 定制的全局对象。因此定义在window
上面的对象和方法不是全部都可以使用。
Worker 线程有一些自己的全局属性和方法。
- self.name: Worker 的名字。该属性只读,由构造函数指定。
- self.onmessage:指定
message
事件的监听函数。 - self.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
- self.close():关闭 Worker 线程。
- self.postMessage():向产生这个 Worker 线程发送消息。
- self.importScripts():加载 JS 脚本。
- 点赞
- 收藏
- 关注作者
评论(0)