浏览器渲染原理及性能优化

一. 浏览器简介

1. 浏览器种类

  • 目前使用的主流浏览器有五个:Internet Explorer、Firefox、Safari、Chrome 浏览器和 Opera。本文中以开放源代码浏览器为例,即 Firefox、Chrome 浏览器和 Safari(部分开源)。

2. 浏览器结构

  1. 用户界面 : 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
  2. 浏览器引擎 : 在用户界面和呈现引擎之间传送指令。
  3. 呈现引擎 : 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  4. 网络 : 用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
  5. 用户界面后端 : 用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。
  6. JavaScript 解释器 : 用于解析和执行 JavaScript 代码。
  7. 数据存储 : 这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。
  • 浏览器结构图

3. 渲染流程简介

  1. 呈现引擎将开始解析 HTML 文档,并将各标记逐个转化成“内容树”上的 DOM 节点。同时也会解析外部 CSS 文件以及样式元素中的样式数据。HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构:呈现树.
  2. 呈现树包含多个带有视觉属性(如颜色和尺寸)的矩形。这些矩形的排列顺序就是它们将在屏幕上显示的顺序。
  3. 呈现树构建完毕之后,进入“布局”处理阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标。下一个阶段是绘制 - 呈现引擎会遍历呈现树,由用户界面后端层将每个节点绘制出来。
  • 渲染主流程图

二. 浏览器渲染流程详解

1. 解析

  1. 什么是解析:解析是呈现引擎中非常重要的一个环节,解析文档是指将文档转化成为有意义的结构,也就是可让代码理解和使用的结构。解析得到的结果通常是代表了文档结构的节点树,它称作解析树或者语法树。

  2. 解析的过程:词法分析和语法分析

    • 词法分析: 将输入内容分割成大量标记的过程。标记是语言中的词汇,即构成内容的单位。在人类语言中,它相当于语言字典中的单词。
    • 语法分析: 是应用语言的语法规则的过程。
  3. 解析示例

    1. 文档:

      2 + 3 - 1
      
    2. 词法定义:词汇通常用==正则表达式==表示

      词法:我们用的语言可包含整数、加号和减号

      INTEGER :0|[1-9][0-9]* 
      PLUS : +
      MINUS: -
      
    3. 语法定义:语法通常使用一种称为==BNF==的格式来定义

      1. 构成语言的语法单位是表达式、项和运算符。
      2. 我们用的语言可以包含任意数量的表达式。
      3. 表达式的定义是:一个“项”接一个“运算符”,然后再接一个“项”。
      4. 运算符是加号或减号。
      5. 项是一个整数或一个表达式
      expression :=  term  operation  term
      operation :=  PLUS | MINUS
      term := INTEGER | expression
      
    4. 解析树:

      • 让我们分析一下2 + 3 - 1, 匹配语法规则的第一个子串是 2,而根据第 5 条语法规则,这是一个项。匹配语法规则的第二个子串是 2 + 3,而根据第 3 条规则(一个项接一个运算符,然后再接一个项),这是一个表达式。下一个匹配项已经到了输入的结束。2 + 3 - 1 是一个表达式,因为我们已经知道 2 + 3 是一个项,这样就符合“一个项接一个运算符,然后再接一个项”的规则。2 + + 不与任何规则匹配,因此是无效的输入。

2. DOM树构建

  1. 文档:

    <!DOCTYPE html>
    <html>
    <body>
        <div>
            <h1 class="title">demo</h1>
            <input value="hello">
        </div>
    </body>
    </html>
    
  2. 词法分析: 浏览器中的词法分析器负责将输入内容分解成一个个有效标记.

  • 在chrome中有个HTMLDocumentParser的c++类就负责解析html文本为tokens,一个token就是一个标签文本的序列化,并借助HTMLTreeBuilder对这些tokens分类处理,根据不同的标签类型、在文档不同位置,调用HTMLConstructionSite不同的函数构建DOM树。

  • 这里我们只要关注序列化后的token是什么东西就好了,为此,写了一个函数,把tokens的一些关键信息打印出来

    String getTokenInfo(){
        String tokenInfo = "";
        tokenInfo = "tagName: " + this->m_name + "|type: " + getType() + "|attr:" +       getAttributes() + "|text: " + this->m_data;
        return tokenInfo;
    }
    
    tagName: html  |type: DOCTYPE   |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n"
    tagName: html  |type: startTag  |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n"
    tagName: body  |type: startTag  |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n    "
    tagName: div   |type: startTag  |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n        "
    tagName: h1    |type: startTag  |attr:class=title   |text: "
    tagName:       |type: Character |attr:              |text: demo"
    tagName: h1    |type: EndTag    |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n        "
    tagName: input |type: startTag  |attr:value=hello   |text: "
    tagName:       |type: Character |attr:              |text: \n    "
    tagName: div   |type: EndTag    |attr:              |text: "
    tagName:       |type: Character |attr:              |text:     \n"
    tagName: body  |type: EndTag    |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n"
    tagName: html  |type: EndTag    |attr:              |text: "
    tagName:       |type: Character |attr:              |text: \n"
    tagName:       |type: EndOfFile |attr:              |text: "
    
  1. 语法分析:浏览器中的解析器负责根据语言的语法规则分析文档的结构,从而构建解析树, HTML 的定义采用了 ==DTD== 格式。此格式可用于定义 SGML 族的语言。它包括所有允许使用的元素及其属性和层次结构的定义
  • 树构建算法
    <html>
        <body>
            Hello world
        </body>
    </html>
    
    树构建阶段的输入是一个来自标记化阶段的标记序列Tokens
    • 第一个模式是initial mode。接收HTML标记后转为before html模式,并在这个模式下重新处理此标记。这样会创建一个 HTMLHtmlElement 元素,并将其附加到Document根对象上。
    • 然后状态将改为before head。此时我们接收body标记。即使我们的示例中没有head标记,系统也会隐式创建一个HTMLHeadElement,并将其添加到树中。
    • 现在我们进入了in head模式,然后转入after head模式。系统对body 标记进行重新处理,创建并插入HTMLBodyElement,同时模式转变为in body
    • 现在,接收由Hello world字符串生成的一系列字符标记。接收第一个字符时会创建并插入Text节点,而其他字符也将附加到该节点。
    • 接收body结束标记会触发after body模式。现在我们将接收HTML 结束标记,然后进入after after body模式。接收到文件结束标记后,解析过程就此结束。
  1. DOM解析树

3. CSSOM树构建

  1. 文档:
    p, div {
        margin-top: 3px;
    }
    .error {
        color: red;
    }
    
  2. 词法:
    comment   \/\*[^*]*\*+([^/*][^*]*\*+)*\/
    num   [0-9]+|[0-9]*"."[0-9]+
    nonascii  [\200-\377]
    nmstart   [_a-z]|{nonascii}|{escape}
    nmchar    [_a-z0-9-]|{nonascii}|{escape}
    name    {nmchar}+
    ident   {nmstart}{nmchar}*
    
  3. 语法:
    ruleset
      : selector [ ',' S* selector ]*
        '{' S* declaration [ ';' S* declaration ]* '}' S*
      ;
    selector
      : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]?
      ;
    simple_selector
      : element_name [ HASH | class | attrib | pseudo ]*
      | [ HASH | class | attrib | pseudo ]+
      ;
    class
      : '.' IDENT
      ;
    element_name
      : IDENT | '*'
      ;
    attrib
      : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
        [ IDENT | STRING ] S* ] ']'
      ;
    pseudo
      : ':' [ IDENT | FUNCTION S* [IDENT S*] ')' ]
      ;
    
  4. CSSOM解析树


4. 呈现树构建

在 DOM 树构建的同时,浏览器还会构建另一个树结构:呈现树。这是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是让您按照正确的顺序绘制内容。
Firefox 将呈现树中的元素称为“框架”. WebKit 使用的术语是呈现器或呈现对象。
呈现器知道如何布局并将自身及其子元素绘制出来。

WebKits RenderObject 类是所有呈现器的基类,其定义如下:

class RenderObject{
  virtual void layout();
  virtual void paint(PaintInfo);
  virtual void rect repaintRect();
  Node* node;  //the DOM node
  RenderStyle* style;  // the computed style
  RenderLayer* containgLayer; //the containing z-index layer
}

每一个呈现器都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,这一点在 CSS2 规范中有所描述。它包含诸如宽度、高度和位置等几何信息。
框的类型会受到与节点相关的“display”样式属性的影响(请参阅样式计算章节)。下面这段 WebKit 代码描述了根据 display 属性的不同,针对同一个 DOM 节点应创建什么类型的呈现器。

  1. 呈现树和DOM树对照关系:
呈现树和DOM树对照关系
呈现树和DOM树对照关系
  1. 呈现树构建示例:

    1. HTML文档:
    <html>
      <body>
        <div class="err" id="div1">
          <p>
            this is a <span class="big"> big error </span>
            this is also a
            <span class="big"> very  big  error</span> error
          </p>
        </div>
        <div class="err" id="div2">another error</div>
      </body>
    </html>
    
    1. CSS样式:
    1.  div {
            margin:5px;color:black
        }
    2.  .err {
            color:red
        }
    3.  .big {
            margin-top:3px
        }
    4.  div span {
            margin-bottom:4px
        }
    5.  #div1 {
            color:blue
        }
    6.  #div2 {
            color:green
        }
    
    1. 显现树样式计算:使用规则树计算样式上下文树
    • 样式上下文树: Firefox 还采用了样式上下文树, WebKit 也有样式对象,但它们不是保存在类似样式上下文树这样的树结构中,只是由 DOM 节点指向此类对象的相关样式。
    • 规则树: 所有匹配的规则都存储在树中。路径中的底层节点拥有较高的优先级。规则树包含了所有已知规则匹配的路径。规则的存储是延迟进行的。规则树不会在开始的时候就为所有的节点进行计算,而是只有当某个节点样式需要进行计算时,才会向规则树添加计算的路径。
      使用规则树计算样式上下文树
      使用规则树计算样式上下文树

      chrome中查看样式
      chrome中查看样式

    样式表解析完毕后,系统会根据选择器将 CSS规则添加到某个哈希表中。这些哈希表的选择器各不相同,包括ID、类名称、标记名称等,还有一种通用哈希表,适合不属于上述类别的规则。如果选择器是 ID,规则就会添加到 ID 表中;如果选择器是类,规则就会添加到类表中,依此类推。这种处理可以大大简化规则匹配。我们无需查看每一条声明,只要从哈希表中提取元素的相关规则即可。这种优化方法可排除掉 95% 以上规则,因此在匹配过程中根本就不用考虑这些规则了

5. 布局(layout)

  1. 呈现器布局
    呈现器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。根呈现器的位置左边是 0,0,其尺寸为视口(也就是浏览器窗口的可见区域)。所有的呈现器都有一个“layout”或者“reflow”方法,每一个呈现器都会调用其需要进行布局的子代的 layout 方法。

  2. Dirty 位系统 :
    为避免对所有细小更改都进行整体布局,浏览器采用了一种dirty 位系统。如果某个呈现器发生了更改,或者将自身及其子代标注为dirty,则需要进行布局。有两种标记:dirtychildren are dirtychildren are dirty表示尽管呈现器自身没有变化,但它至少有一个子代需要布局。

  3. 全局布局和增量布局

    1. 全局布局: 是指触发了整个呈现树范围的布局,触发原因可能包括:
      1. 影响所有呈现器的全局样式更改,例如字体大小更改。
      2. 屏幕大小调整。
    2. 增量布局: 可以采用增量方式,也就是只对 dirty 呈现器进行布局(这样可能存在需要进行额外布局的弊端)。
      当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。
  4. 布局步骤:

    1. 父呈现器确定自己的宽度。
    2. 父呈现器依次处理子呈现器,并且:
    3. 放置子呈现器(设置 x,y 坐标)。如果有必要,调用子呈现器的布局(如果子呈现器是 dirty 的,或者这是全局布局,或出于其他某些原因),这会计算子呈现器的高度。
    4. 父呈现器根据子呈现器的累加高度以及边距和补白的高度来设置自身高度,此值也可供父呈现器的父呈现器使用。
    5. 将其 dirty 位设置为 false。

6. 绘制(paint)

  1. 呈现器绘制: 本质上就是填充像素的过程。包括绘制文字、颜色、图像、边框和阴影等,也就是一个DOM元素所有的可视效果。一般来说,这个绘制过程是在多个层上完成的。
    在绘制阶段,系统会遍历呈现树,并调用呈现器的“paint”方法,将呈现器的内容显示在屏幕上。绘制工作是使用用户界面基础组件完成的。
  2. 全局绘制和增量绘制
  3. 绘制顺序: 绘制的顺序其实就是元素进入堆栈样式上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。块呈现器的堆栈顺序如下:
    1. 背景颜色
    2. 背景图片
    3. 边框
    4. 子代
    5. 轮廓

三、浏览器事件模型

1. 呈现引擎的线程

呈现引擎采用了单线程。几乎所有操作(除了网络操作)都是在单线程中进行的。在 Firefox 和 Safari 中,该线程就是浏览器的主线程。而在 Chrome 浏览器中,该线程是标签进程的主线程。

2. 事件循环:

浏览器的主线程是事件循环。它是一个无限循环,永远处于接受处理状态,并等待事件(如布局和绘制事件)发生,并进行处理。

3. Javascript单线程模式

  1. 为什么是单线程 : JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

  2. 任务队列: 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
    如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
    JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
    于是,所有任务可以分成两种,一种是同步任务,另一种是异步任务。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入任务队列(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

  3. Event Loop

    jsEventLoop
    jsEventLoop

四. 性能优化及调试

1. 回顾网页渲染过程:

  1. HTML代码转化成DOM
  2. CSS代码转化成CSSOM(CSS Object Model)
  3. 结合DOM和CSSOM,生成一棵渲染树(包含每个节点的视觉信息)
  4. 生成布局(layout),即将所有渲染树的所有节点进行平面合成
  5. 将布局绘制(paint)在屏幕上

这五步里面,第一步到第三步都非常快,耗时的是第四步和第五步。

"生成布局"(flow)和"绘制"(paint)这两步,合称为"渲染"(render)。

2. 重排和重绘

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。

以下三种情况,会导致网页重新渲染。

  • 修改DOM
  • 修改样式表
  • 用户事件(比如鼠标悬停、页面滚动、输入框键入文字、改变窗口大小等等)

重新渲染,就需要重新生成布局和重新绘制。前者叫做"重排"(reflow),后者叫做"重绘"(repaint)。

需要注意的是,"重绘"不一定需要"重排",比如改变某个网页元素的颜色,就只会触发"重绘",不会触发"重排",因为布局没有改变。但是,"重排"必然导致"重绘",比如改变一个网页元素的位置,就会同时触发"重排"和"重绘",因为布局改变了。

3. 对于性能的影响

重排和重绘会不断触发,这是不可避免的。但是,它们非常耗费资源,是导致网页性能低下的根本原因。

提高网页性能,就是要降低"重排"和"重绘"的频率和成本,尽量少触发重新渲染

前面提到,DOM变动和样式变动,都会触发重新渲染。但是,浏览器已经很智能了,会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,尽量避免多次重新渲染。

div.style.color = 'blue';
div.style.marginTop = '30px';

上面代码中,div元素有两个样式变动,但是浏览器只会触发一次重排和重绘

如果写得不好,就会触发两次重排和重绘

div.style.color = 'blue';
var margin = parseInt(div.style.marginTop);
div.style.marginTop = (margin + 10) + 'px';

上面代码对div元素设置背景色以后,第二行要求浏览器给出该元素的位置,所以浏览器不得不立即重排。

一般来说,样式的写操作之后,如果有下面这些属性的读操作,都会引发浏览器立即重新渲染。

- offsetTop/offsetLeft/offsetWidth/offsetHeight
- scrollTop/scrollLeft/scrollWidth/scrollHeight
- clientTop/clientLeft/clientWidth/clientHeight
- getComputedStyle()

所以,从性能角度考虑,尽量不要把读操作和写操作,放在一个语句里面

// bad
div.style.left = div.offsetLeft + 10 + "px";
div.style.top = div.offsetTop + 10 + "px";

// good
var left = div.offsetLeft;
var top  = div.offsetTop;
div.style.left = left + 10 + "px";
div.style.top = top + 10 + "px";

一般的规则是:

  • 样式表越简单,重排和重绘就越快。
  • 重排和重绘的DOM元素层级越高,成本就越高。
  • table元素的重排和重绘成本,要高于div元素

4、提高性能的九个技巧

有一些技巧,可以降低浏览器重新渲染的频率和成本。

  1. 第一条: 是上一节说到的,DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。

  2. 第二条:如果某个样式是通过重排得到的,那么最好缓存结果。避免下一次用到的时候,浏览器又要重排。

  3. 第三条: 不要一条条地改变样式,而要通过改变class,或者csstext属性,一次性地改变样式。

    // bad
    var left = 10;
    var top = 10;
    el.style.left = left + "px";
    el.style.top  = top  + "px";
    
    // good 
    el.className += " theclassname";
    
    // good
    el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
    
  1. 第四条: 尽量使用离线DOM,而不是真实的网面DOM,来改变元素样式。比如,操作Document Fragment对象,完成后再把这个对象加入DOM。再比如,使用 cloneNode() 方法,在克隆的节点上进行操作,然后再用克隆的节点替换原始节点。

  2. 第五条:先将元素设为display: none(需要1次重排和重绘),然后对这个节点进行100次操作,最后再恢复显示(需要1次重排和重绘)。这样一来,你就用两次重新渲染,取代了可能高达100次的重新渲染。

  3. 第六条: position属性为absolute或fixed的元素,重排的开销会比较小,因为不用考虑它对其他元素的影响。

  4. 第七条: 只在必要的时候,才将元素的display属性为可见,因为不可见的元素不影响重排和重绘。另外,visibility : hidden的元素只对重绘有影响,不影响重排。

  5. 第八条: 使用虚拟DOM的脚本库,比如React等。

  6. 第九条:使用 window.requestAnimationFrame()、window.requestIdleCallback() 这两个方法调节重新渲染。

五. 附录

1. 渲染总流程图

浏览器渲染流程
浏览器渲染流程

2. 参考文章

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

推荐阅读更多精彩内容