深入客户端存储

前言

从最早期的 Cookie,到后来的 AppCache、localStorage 和 sessionStorage,再到现在的 indexedDB,客户端存储的概念由来已久。

技术不断往前迭代,浏览器开放的能力越来越多,前端开发者能够做到的事情也越来越多样。纯粹的静态网页到有丰富交互的动态网页花了几十年时间,简单的 H5 小游戏往复杂的中大型游戏过渡的速度却在逐渐加快。

Cookie 支持的操作非常有限,localStorage 有关的 API 精巧而简便,到了 indexedDB,我们在抱怨其复杂性的同时,也不得不承认,它的功能更加强大了,赋予我们更多可能。

曾经一度,浏览器厂商对前端的定义是精巧、是简洁、是灵便,是一棵新生的树,卓卓成长,拥有无限可能,而随着一桩桩要求被提出,一件件功能被满足,树木的根须越扎越深,树干愈发粗壮,它开始能够承载更多,那些可能性逐一抽枝,向天生长,如今的前端早今非昔比。

Cookie 面世几十载,localStorage 出现的时间也不短,网络上关于这两者的文章已经太多,本文不再过多赘述,本文的重点将集中在最后出现的这家伙身上,——indexedDB,讨论它出现的背景,介绍它具体的用法,再稍稍展望一下未来。

好了,废话不说话,我们直接开始吧。

什么是 indexedDB?

在深入使用之前,我们需要先知道,什么是 indexedDB,以及,有了 localStorage,我们为什么还需要 indexedDB?

这个问题可以用 Cookie 和 localStorage 的关系来类比,有了 Cookie,我们为什么还需要 localStorage?

答案很简单,存储空间不够用。

最早的 Cookie 只能存储 4KB 的数据,这个数据量对上世纪末的前端页面来说绰绰有余,那时候的网速只有几 KB,4KB 的存储足够 Cookie 横行绝大多数网页。

而随着宽带速率的提升,更复杂的网页成为可能,精美的图片、漂亮的字体,视频、音频,前端的交互变得复杂,所涉及到的动态数据也越来越多,前后端分离的愿景尚未完全实现,被越来越严重的前后端耦合拉得开起了倒车。

这种情况下,HTML5 发布了,localStorage 应运而生。

localStorage 支持的数据量达到 2.5MB 到 10MB,较前者提升了千倍不止,这在一定程度上解决了前后端耦合的问题,赋能了前端同学,也降低了后端同学的工作量,大家都很开心。

但前端同学的野心不止于此,浏览器同样。

做过 3D WebGL 开发的同学都知道,资源文件的加载十分令人头疼,这些文件的体积往往相当可观,localStorage 容纳不下,网络侧加载又十分耗时,indexedDB 的出现为这一现象提供了有效的解决方案。

尝试给 indexedDB 下一个定义之前,我们需要先了解两个概念。

关系型数据库非关系型数据库

不熟悉后端的同学可能对这两个概念都陌生,但是提及一些名词,你们大概就会模模糊糊的有概念。

MySql、Access、Oracle。
MongoDB、Redis、HBase。

这么多单词,总有一两个你熟悉。

是的,后端同学经常挂在嘴上的 MySql、Access、Oracle……这些可以用 SQL 语句进行查询的数据库,属于关系型数据库,它们拥有具体的行和列,一系列行和列组成一张表,若干张表构成一个数据库。

其他那些则统统属于非关系型数据库。

indexedDB 是一种运行在客户端的数据库,它以键值对(key-value)的形式存储数据,属于非关系型数据库。

indexedDB 遵循同源协议,只有同域的 js 代码能够访问它,其他都不可以。

有了这个基础概念之后,我们就可以进一步去了解 indexedDB 了。

首先,关于存储空间

我们知道,indexedDB 之所以提出,是因为 localStorage 提供的 5MB 存储空间实在不够用,那么,indexedDB 可以让我们在客户端存储多少数据呢?

答案是,大体上比 localStorage 多,具体到不同浏览器,有不同的限制。

拿 Chrome 来说,Chrome67 之前的版本规定 indexedDB 最多可使用 50% 的硬盘空间,从 Chrome 67 开始,这一规定发生变化。

在 Chrome 正常模式下

该模式下的站点可用空间可通过如下公式计算:

(硬盘总空间 - 保留空间 - 已使用空间) * 20%

这里保留空间指得是,浏览器需要留存出来的空间(should remain avaliable),它的定义是 2 GB 和硬盘总空间的 10% 中的较低值,也就是:

min(2GB, 硬盘总空间*10%)

也就是说,当 硬盘总空间 - 已使用空间 <= 保留空间 时,站点的数据将无法存储。

举例来说,一块存储空间为 256GB 的硬盘,它的保留空间为 2 GB 和硬盘总空间的 10% 中的较低值,也就是 2GB,这时浏览器可使用的临时存储空间大小就是 256GB - 2GB = 254GB。
假设我们的站点上线时,用户的 254GB 存储空间已经被占用 4GB,那么我们的站点可以分配到的存储空间就是 (254 - 4) * 20%,也就是 50 GB。

在 Chrome 隐身模式下

新站点的配额为固定的 100MB。

其次,关于存储格式

除了存储空间问题,indexedDB 也解决了 localStorage 存储格式单一的问题,它支持的存储数据格式更加丰富,除了基本的字符串外,它还支持二进制数据的存储,比如 ArrayBuffer,比如 Blob。

于是我们之前的提到的 3D 模型文件、各类图片文件,就可以被转换成 Blob 格式,存储在 indexedDB 中,免去网络请求的消耗。

讲了那么多,indexedDB 那么强大,那么,我们该如何去使用它呢?

开始了,该怎么使用 indexedDB?

indexedDB 相较 localStorage 来说,拥有更多的新概念、更复杂 API,直接引入那些概念,对没接触过后端的同学来说,存在一定门槛,所以本文通过一个实例来讲解。

这个例子假设我们现在需要开发一款软件,写作软件,面向文字工作者。

开发软件的时候,我们需要新建项目、设置项目信息,再在项目内部编写代码,对于这些文字工作者来说,他们也有新建书籍、设置书籍基础信息,以及在书籍内部编写章节内容的需要。

有了这一点基础认知,我们就可以尝试分解软件功能。总体来说,它应该包含两部分内容:

  1. 新建书籍。
  2. 编辑章节内容。

进一步对这两个需求进行分析,我们还可以得出以下更详细的功能需求。

  1. 书籍相关的:

    1. 书籍列表:一个展示书籍列表的页面,一个获取书籍信息的方法。
    2. 新建书籍:一个新建书籍的对话框,一个新建书籍的方法。
    3. 编辑书籍:一个编辑书籍的对话框,一个编辑书籍的方法。
    4. 删除书籍:一个删除书籍的对话框,一个删除书籍的方法。
  2. 章节相关的:

    1. 章节列表:一个展示章节列表的页面,一个获取章节信息的方法。
    2. 章节内容:一个展示章节内容的页面,一个获取章节内容的方法。
    3. 新增章节:……
    4. 修改章节标题:……
    5. 删除章节:……
    6. 编辑章节内容:……

有了项目文档,我们就可以着手去开发了。

前端大体是一个单页应用,使用熟悉的技术栈去开发就好,本文不再赘述。

数据存储方面,考虑到一个用户不止编写一本书籍,一本书籍包含若干章节,每个章节的编辑历史也很复杂,localStorage 的存储空间不出意外百分百不够用,在不借助后端的情况下,我们只能求助于 indexedDB。

那么就来使用 indexedDB 吧。

新建一个数据库

使用 window.indexedDB.open 可以打开或者新建一个数据库,像这样:

const request = window.indexedDB.open(dbName, dbVersion);

dbName 指代数据库的名字,dbVersion 指代数据库的版本,该方法返回一个异步请求,通过设置它成功(success)、失败(error)和升级(upgradeneeded)回调,我们可以获得一个数据库对象,进行对应的处理,像这样:

let db = null;

const dbName = 'coolb-writer';
const dbVersion = 1;

const request = window.indexedDB.open(dbName, dbVersion);

request.onupgradeneeded = (ev) => {
    db = ev.target.result;
};

request.onsuccess = (ev) => {
    db = ev.target.result;
};

request.onerror = (error)=>{
    console.log('新建或打开数据库出错', error);
};

这里可能有一些内容令人费解,比如,新建数据库就新建数据库,为什么要指定 dbName?比如,有 dbName 就算了,为什么还要 dbVersion?再比如,success 好理解,error 也不难接受,upgradeneeded 回调是什么?我们为什么需要监听它?

下面来逐一解答。

  1. 新建数据库就新建数据库,为什么要指定 dbName

    首先,我们知道 indexedDB 遵循同源协议,只有同域的 js 代码能够访问它,其他都不可以。

    在我们举例的场景下,一个页面只需要一个数据库,取什么名字都无所谓。

    但在稍微复杂一点的场景下,比如公司内部的中台系统,——一个涉及到多人协作的公司内部,为了提高工作效率、降低出错概率,重复性的劳动往往被流程化的内部系统所取代,比如 OA 系统、文档中心,比如发布系统、监控系统,等等等等。

    这些系统互相之间保持高度独立,但在某些子功能模块上,它们也可能互相关联。出于方便,我们将它们托管在同一个域名下,以不同的路径区分。

    因为同域,所以它们可以访问彼此的数据库,这种情况下,给你的数据库取一个识别度高的名字,就很有必要了。

  2. dbName 就算了,为什么还要 dbVersion

    因为你的项目大概率需要迭代,数据库也存在一定概率,需要跟着升级。

    怎么理解这句话呢?

    简单来说,复杂的项目出于各种原因,可能被抽丝剥茧,进行分期开发:一期只包含最核心功能,二三期根据市场反馈和决策者的抉择,进行适当迭代和功能增减。

    这个过程中,你的数据库很难保持一成不变。

    就拿我们举的例子来说,最开始我们只包含核心功能,但是软件上线后,我们可能很快收到一个反馈:【能不能支持一下编辑历史啊?一个章节大几千小几万字的,个把月才能写完,有时候我写了一段内容,觉得不好,删了,回头还想找回来就没了,你们能不能支持一下回看编辑内容啊?我看别的软件都有,应该不难做吧?拜托支持一下,这对我真的很重要!】

    你当然能够理解这个功能的重要性,毕竟,你自己写代码的时候,也用到了版本控制。

    想要支持这个功能,你就需要新增一个Object Store(类似关系型数据库的表,具体后面会介绍),同时对已有的 Object Store 进行适当调整,这个操作我们通过修改数据库版本号的方式来实现。

    数据库的版本号修改后,upgradeneeded 事件便会被触发,一切你需要做的事情,都可以在那里面进行。这就是 dbVersion 的存在作用。

  3. success 好理解,error 也不难接受,upgradeneeded 回调是什么?我们为什么需要监听它?

    upgradeneeded 回调只在新增升级数据库时,被触发。

    新增,也就是 window.indexedDB.open 第一次被调用时;升级,也就是 dbVersion 调整后的第一次。只有这两种情况下,upgradeneeded 回调被触发。

    同时,新增、调整、删除 Object Store 的操作,也只能在这里面进行,success 里面不行,error 里面也不可以。

    也就是说,upgradeneeded 回调是用来,让我们在适当的时候,进行数据库本身的维护工作的,相对应的,success 回调只处理跟数据有关的内容。

看完以上内容,相信大家对创建数据库的部分内容已经了解清楚。

至此,我们便有了一个可以用来存储数据的数据库,下面来看看怎么把数据存进去,以及,怎么根据需求处理数据吧。

新建数据仓库

上一步我们新建了一个名为 coolb-writer 数据库 ,把数据库想象成一个空旷的仓库,为了有序存放数据,除了遮风避雨的仓库本身,我们还需要一只只整齐摆放的货架。

在关系型数据库里,这一只只货架,被我们称为,在非关系型数据库 indexedDB 这儿,我们称之为 Object Store,也就是数据仓库

创建数据仓库(Object Store)需要使用数据库(db)对象的 createObjectStore 方法,像这样:

request.onupgradeneeded = (ev) => {
    db = ev.target.result;
    const bookStore = db.createObjectStore('books', { keyPath:'bid' });
    const chapterStore = db.createObjectStore('chapters', { keyPath:'cid' });
};

通过这两次调用,我们创建了两个名为 books 和 chapters 的数据仓库,并为它们指定主键 bid 和 cid。

考虑到 upgradeneeded 回调可能不止一次被调用,所以上面的写法最好还是改进一下:

function createObjectStoreIfNotExist(name, config) {
    if (!db.objectStoreNames.contains(name)) {
        db.createObjectStore(name, config);
    }
}
request.onupgradeneeded = (ev) => {
    db = ev.target.result;

    createObjectStoreIfNotExist('books', { keyPath: 'bid' });
    createObjectStoreIfNotExist('chapters', { keyPath: 'cid' });
};

bid 和 cid 是 books 和 chapters 中所存储数据的一个属性,books 所存储的数据大体是如下格式:

{
    'bid': 1678680731689,
    'name': ...,
    'thumb': ...,
    'process': ...,
    'tags': ...
}

除了 bid、name、thumb 等,我们也可以指定 books 的主键为 tags 下面的属性,比如 tags.id,此外,如果数据记录内没有适合作为主键的属性,也可以让 indexedDB 自动生成主键。

const objectStore = db.createObjectStore(objectStoreName, { autoIncrement: true });

数据仓库新建完成之后,我们可以尝试为其添加索引。

新建索引

在新建索引之前,我们需要先知道,什么是索引?

纯纯的前端同学可能对这个概念很陌生,所以这里还是通过一个例子来介绍,这也是 indexedDB 相较 localStotage 的强大之处。

我们可以先回忆一下,使用 localStotage 存储数据时,我们是怎么做的。

localStorage.setItem(key, value);
localStorage.getItem(key);

通过一个关键字 key,我们可以完成对数据的读取和存储功能。

假设当前项目中,我们通过 localStorage 存取数据,那么在存储一本书时,我们可以这样做:

const bid = generateUniqueBid();
const bdata = {
    bid,
    name: 'JavaScript 高级程序设计',
    thumb: File,
    process: 0, // 0 新建,1 编写中,2 已完结,3 坑
    ...
};

localStorage.setItem(bid, JSON.stringify(bdata));

在读取一本书的详细数据时,我们可以这样做:

localStorage.getItem(bid);

可如果我们想要读取所有状态为“已完结”的书籍呢?做不到,只能先读取所有书籍的详细信息,再在前端遍历获取。

索引的出现,为这一需求,提供了解决方案。

以上案例中的 bid 可以类比到 indexedDB 中的主键,name、process 一类,可能作为查询条件的字段则可以类比到 indexedDB 中的索引。

创建索引(Index)需要使用数据仓库(Object Store)对象的 createIndex 方法,像这样:

function createObjectStoreIfNotExist(name, config, indexes = {}) {
    if (db.objectStoreNames.contains(name)) {
        const objectStore = db.createObjectStore(name, config);
        // 通过数据仓库的 createIndex 方法来创建索引。
        for (let name in indexes) {
            objectStore.createIndex(name, name, indexes[name]);
        }
    }
}
request.onupgradeneeded = (ev) => {
    db = ev.target.result;

    createObjectStoreIfNotExist('books', { keyPath: 'bid' }, {
        name: { unique: false },
        process: { unique: false }
    });
    createObjectStoreIfNotExist('chapters', { keyPath: 'cid' }, {
        name: { unique: false }
    });
};

查询数据

在 indexedDB 中,我们可以通过主键查询数据,也可以通过索引查询数据,具体落实查询时,我们会需要涉及一个叫做事务(transaction)的概念。

事务(transaction)保证了所有操作的按顺序进行,避免了写入时读取,或者同时写入的问题。此外,事务作为一个完整的工作单位,存在不可分割的特性,也就是说,一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。

下面来看看具体的操作。

1. 按照主键读取单条数据:

function getData(objectStoreName, id) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readonly');
        const objectStore = transaction.objectStore(objectStoreName);
        const request = objectStore.get(id);

        request.onsuccess = (ev) => resolve(ev.target.result);
        request.onerror = (err) => reject(err);
    });
}

getData('books', bid);

2. 按照索引读取单条数据:

function getOneByIndex(objectStoreName, indexName, indexValue) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readonly');
        const objectStore = transaction.objectStore(objectStoreName);
        const index = objectStore.index(indexName);
        const request = index.get(indexValue);

        request.onsuccess = (ev) => resolve(ev.target.result);
        request.onerror = (err) => reject(err);
    });
}

getOneByIndex('books', 'process', 2);

除了单条读取数据外,我们还可以批量读取数据,这时会涉及到一个叫做游标(Cusor)的概念。

3. 借助游标,读取数据仓库中的所有数据:

function getAll(objectStoreName) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readonly');
        const objectStore = transaction.objectStore(objectStoreName);
        const request = objectStore.openCursor();

        let results = [];

        request.onsuccess = (ev) => {
            const cursor = ev.target.result;
            if (cursor) {
                results.push(cursor.value);
                cursor.continue();
            } else {
                resolve(results);
            }
        };
        request.onerror = (err) => reject(err);
    });
}

4. 借助游标和索引,读取数据仓库中,满足条件的部分数据:

function getDataByIndex(objectStoreName, indexName, indexValue) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readonly');
        const store = transaction.objectStore(objectStoreName);

        const index = store.index(indexName);
        const range = IDBKeyRange.only(indexValue);

        const request = index.openCursor(range);

        let result = [];

        request.onsuccess = (ev) => {
            const cursor = ev.target.result;
            if (cursor) {
                result.push(cursor.value);
                cursor.continue();
            } else {
                resolve(result);
            }
        };
        request.onerror = (err) => reject(err);
    });
}

以上示例中,我们通过 IDBKeyRange.only 创建一个唯一匹配的 range 对象,表示只匹配值为 indexValue 的所有数据,除此之外 IDBKeyRange 还支持如下类型的匹配:

匹配值域 方法
所有 <= x 的值 IDBKeyRange.upperBound(x)
所有 < x 的值 IDBKeyRange.upperBound(x, true)
所有 >= y 的值 IDBKeyRange.lowerBound(y)
所有 > y 的值 IDBKeyRange.lowerBound(y, true)
所有 >= x && <= y 的值 IDBKeyRange.bound(x, y)
所有 > x && < y 的值 IDBKeyRange.bound(x, y, true, true)
所有 > x && <= y 的值 IDBKeyRange.bound(x, y, true, false)
所有 >= x && < y 的值 IDBKeyRange.bound(x, y, false, true)
=== x 的值 IDBKeyRange.only(x)

插入数据

插入数据同样通过事务进行。

function insertData(objectStoreName, data) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readwrite');
        const objectStore = transaction.objectStore(objectStoreName);
        const request = objectStore.add(data);

        request.onsuccess = (ev) => resolve(ev);
        request.onerror = (err) => reject(err);
    });
}

更新数据

更新数据通过一个叫做 put 的方法,put 方法也可以用于新增数据,它和 add 方法的区别是:

  1. 如果 objectStore 中已有对应 id,则表示更新,否则表示添加。
  2. 在设置了自增,也就是 autoIncrement 为 true 的情况下,put 方法必须传第二个参数,第二个参数指定被更新的主键的值,传错或不传,都表示新增。
function editData(objectStoreName, data) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readwrite');
        const objectStore = transaction.objectStore(objectStoreName);
        const request = objectStore.put(data);

        request.onsuccess = (ev) => resolve(ev);
        request.onerror = (err) => reject(err);
    });
}

删除数据

1. 按照主键,删除单条数据

function deleteData(objectStoreName, id) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readwrite');
        const objectStore = transaction.objectStore(objectStoreName);
        const request = objectStore.delete(id);

        request.onsuccess = (ev) => resolve(ev);
        request.onerror = (err) => reject(err);
    });
}

2. 按照游标和索引,批量删除数据

function deleteDataByIndex(objectStoreName, indexName, indexValue) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction([objectStoreName], 'readwrite');
        const store = transaction.objectStore(objectStoreName);

        const index = store.index(indexName);
        const range = IDBKeyRange.only(indexValue);

        const request = index.openCursor(range);

        request.onsuccess = (ev) => {
            const cursor = ev.target.result;
            if (cursor) {
                cursor.delete();
                cursor.continue();
            } else {
                resolve();
            }
        };
        request.onerror = (err) => reject(err);
    });
}

以上便是 indexedDB 中最常用的一些 API,通过组合这些函数,我们可以实现对写作软件中所涉及所有数据的操作。

一点点展望

目前来看,indexedDB 的使用还不算广泛,同时,它的 API 使用起来也较为麻烦,市面上已经存在部分工具包,帮助我们更加便捷地使用 indexedDB,同时也可以优雅降级,在不支持 indexedDB 的浏览器上,使用更加古早的 API。

这种状况有点类似 es6、7、8 之前的 underscore 和 lodash,我们现在也一定程度上依赖这些工具包,但 JS 本身的规范是朝着便捷和可用性的方向上靠的,所以,未来,随着 indexedDB 的使用愈发广泛,我们也许也可以期待一下它的功能变得更强大,同时 API 变得更简单。

也许,哪天,对帧率要求极高的超大型游戏也能稳定流畅地运行在浏览器端也说不定。

以上。

参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,451评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,172评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,782评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,709评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,733评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,578评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,320评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,241评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,686评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,878评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,992评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,715评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,336评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,912评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,040评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,173评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,947评论 2 355

推荐阅读更多精彩内容