Babel - 应用

Babel是一系列模块的结合,本文会介绍其中主要模块的使用方法。

注意:本文并不能代替API文档,详细的文档可以查阅这里

babel-parser


babel-parseracorn fork出来的项目,跟acorn一样执行快速并且易用性高。使用插件模式的架构,对当前非标准的特性进行扩展支持。

  • 安装包
$ npm install --save @babel/parser

先从一段简单的代码解析开始

import parser from "@babel/parser";

const code = `function square(n) {
  return n * n;
}`;

console.log(parser.parse(code));

相应的输出为

Node {
  type: 'File',
  start: 0,
  end: 40,
  loc: SourceLocation {
    start: Position { line: 1, column: 0, index: 0 },
    end: Position { line: 3, column: 1, index: 40 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 40,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'script',
    interpreter: null,
    body: [ [Node] ],
    directives: []
  },
  comments: []
}

只传入code的话表示使用默认的配置进行代码parse。也可以指定配置,比如下面的代码:

parser.parse(code, {
  sourceType: "module", // default: "script"
  plugins: ["jsx"] // default: []
});

相应的输出为

Node {
  type: 'File',
  start: 0,
  end: 40,
  loc: SourceLocation {
    start: Position { line: 1, column: 0, index: 0 },
    end: Position { line: 3, column: 1, index: 40 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 40,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'module',
    interpreter: null,
    body: [ [Node] ],
    directives: []
  },
  comments: []
}

可以看到输出的AST中,program节点的sourceType变为module
sourceType的值可以是module也可以是script,这个值代表着以哪种模式对代码进行parsemodule模式将使用严格模式并允许模块定义,但script不会。

注意: sourceType默认值为script,这种模式下如果代码中有importexportparse时会报错。这种情况下,将sourceType设置为module即可避免报错。

babel-traversal


开发者可以使用该模块管理AST的状态,包括 替换/删除新增节点。
安装包

$ npm install --save @babel/traverse

使用babel-traverse更新部分节点

import parser from "@babel/parser";
import traverse from "@babel/traverse";

const code = `function square(n) {
  return n * n;
}`;

const ast = parser.parse(code);

traverse(ast, {
  enter(path) {
    if (
      path.node.type === "Identifier" &&
      path.node.name === "n"
    ) {
      path.node.name = "x";
    }
  }
});

babel-types


babel-typeslodash风格的操作AST节点的工具库。提供AST节点的创建校验转换。使用精心设计的工具方法可以帮助更加单纯地把精力放在AST操作逻辑上,而不是节点操作的细节。
安装包

$ npm install --save @babel/types

简单的使用示例

import traverse from "@babel/traverse";
import * as t from "@babel/types";

traverse(ast, {
  enter(path) {
    if (t.isIdentifier(path.node, { name: "n" })) {
      path.node.name = "x";
    }
  }
});

Definitions

babel-types为每一类节点都提供了相关的定义,每个定义包含如下信息:

  • 属性属于哪里???
  • 合法的值域
  • 创建该类节点的方法
  • 访问节点的方法
  • 节点的别名

一个简单的示例如下

defineType("BinaryExpression", {
  builder: ["operator", "left", "right"],
  fields: {
    operator: {
      validate: assertValueType("string")
    },
    left: {
      validate: assertNodeType("Expression")
    },
    right: {
      validate: assertNodeType("Expression")
    }
  },
  visitor: ["left", "right"],
  aliases: ["Binary", "Expression"]
});

Builders

上面BinaryExpression的定义中,有一个字段是builder

builder: ["operator", "left", "right"]

每个节点都有一个builder方法,比如创建二元计算表达式

t.binaryExpression("*", t.identifier("a"), t.identifier("b"));

上面的代码将会创建一个AST节点

{
  type: "BinaryExpression",
  operator: "*",
  left: {
    type: "Identifier",
    name: "a"
  },
  right: {
    type: "Identifier",
    name: "b"
  }
}

相应的代码为

a * b

Builder还会验证它创建的节点,如果使用不当,会引发描述性错误。这也引出了下面会提到的方法。

Validators

BinaryExpression的定义中还有关于fields的相关信息,其中就有这些field的验证。

fields: {
  operator: {
    validate: assertValueType("string")
  },
  left: {
    validate: assertNodeType("Expression")
  },
  right: {
    validate: assertNodeType("Expression")
  }
}

验证方法通常有两类

    1. isX。判断是否为X
t.isBinaryExpression(maybeBinaryExpressionNode);
// !!!
// 传递第二个参数,对节点的 属性/属性值,进行进一步判断
t.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
    1. 断言。
t.assertBinaryExpression(maybeBinaryExpressionNode);
t.assertBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// Error: Expected type "BinaryExpression" with option { "operator": "*" }

第1种方法,返回bool值,表示是否为判定的X,并不会中断程序执行。第2种方法,抛出错误,将会中断后续执行。

Converters

制作中

babel-generator


babel-generator包用于生成代码。接收AST输出带有sourcemap信息的代码。
安装

$ npm install --save @babel/generator

简单使用示例

import parser from "@babel/parser";
import generate from "@babel/generator";

const code = `function square(n) {
  return n * n;
}`;

const ast = parser.parse(code);

generate(ast, {}, code);
// {
//   code: "...",
//   map: "..."
// }

第二个参数是生成代码时的配置,默认是空对象,也可以根据说明文档传入指定的配置

generate(ast, {
  retainLines: false,
  compact: "auto",
  concise: false,
  quotes: "double",
  // ...
}, code);

babel-template


babel-template 是另外一个小而美的包(前一个是babel-types😄)。引入这个包可以让开发者使用模板字符串的方式替代大量的AST操作方案来生成AST节点。在计算机领域这个模式被称为quasiquotes

$ npm install --save @babel/template

国际惯例,简单的示例

import template from "@babel/template";
import generate from "@babel/generator";
import * as t from "@babel/types";

const buildRequire = template(`
  var IMPORT_NAME = require(SOURCE);
`);

const ast = buildRequire({
  IMPORT_NAME: t.identifier("myModule"),
  SOURCE: t.stringLiteral("my-module")
});

console.log(generate(ast).code);
// var myModule = require("my-module");

开发Babel插件


前面介绍了Babel相关的基础和包的使用,下面通过开发一个Babel的插件将这些知识串联起来。

插件本质是一个方法

export default function(babel) {
  // plugin contents
}

方法返回一个包含visitor属性的对象

export default function({ types: t }) {
  return {
    visitor: {
      // visitor contents
    }
  };
};

visitor上使用相应的访问器(特定的节点类型)操作节点,每个访问器有2个参数pathstate

export default function({ types: t }) {
  return {
    visitor: {
      Identifier(path, state) {},
      ASTNodeTypeHere(path, state) {}
    }
  };
};

介绍完插件的基本知识点之后,我们开发一个将代码中==替换为===的插件。
插件的逻辑非常简单

  • 使用访问器hook节点
  • 如果是BinaryExpression节点,则检查operator字段,是==则替换为===,否则不处理。
export default function({ types: t }) {
  return {
    visitor: {
      BinaryExpression: (path) => {
        if (path.node.operator === '==') {
            path.node.operator = '==='
        }
      }
    }
  };
};

如你所见,使用babel操作代码就是这么简单!

常用操作


Visiting


获取子节点的path

访问节点的属性通常是先获取节点实例,然后通过点运算符获取相应属性的值,path.node.property

// the BinaryExpression AST node has properties: `left`, `right`, `operator`
BinaryExpression(path) {
  path.node.left;
  path.node.right;
  path.node.operator;
}

如果获取相应属性的path,则需要使用get方法,参数为属性名的字符串值

BinaryExpression(path) {
  path.get('left'); 
}
Program(path) {
  path.get('body.0');
}

上面的代码中对body的子元素访问相对特殊,不能直接get('body'),因为body下是BlockStatement的数组。使用.连接访问路径。比如对于下面的代码

export default function f() {
  return bar;
}

获取其return相应的path,相应的代码如下所示

ExportDefaultDeclaration(path) {
  path.get("declaration.body.body.0");
}
判断节点类型(babel-types)

判断二元表达式的左节点是否为名称是n的变量

BinaryExpression(path) {
  if (t.isIdentifier(path.node.left, { name: "n" })) {
    // ...
  }
}
判断path的类型
BinaryExpression(path) {
  if (path.get('left').isIdentifier({ name: "n" })) {
    // ...
  }
}
检查变量是否被引用
Identifier(path) {
  if (path.isReferencedIdentifier()) {
    // ...
  }
}
// or
Identifier(path) {
  if (t.isReferenced(path.node, path.parent)) {
    // ...
  }
}
查找祖先路径

有的场景下需要自当前节点向上遍历,以找到符合条件的祖先节点path
findParent回调函数执行返回值为true时,返回对应的NodePath,遍历结束。

path.findParent((path) => path.isObjectExpression());

如果要包含当前节点,则使用find方法

path.find((path) => path.isObjectExpression());

获取最近的祖先函数节点path或者programpath也有相应的快捷方法

path.getFunctionParent();
查找兄弟路径

如果一个path是在Functionprogram节点的body中(数组型),那么这个path就会有兄弟路径

  • path.inList检查path是否在list
  • path.getSibling(index)获取相邻指定步长的兄弟path
  • path.key当前path在列表中的位置
  • path.container节点的容器
  • path.listKey列表容器的名称
var a = 1; // pathA, path.key = 0
var b = 2; // pathB, path.key = 1
var c = 3; // pathC, path.key = 2
export default function({ types: t }) {
  return {
    visitor: {
      VariableDeclaration(path) {
        // if the current path is pathA
        path.inList // true
        path.listKey // "body"
        path.key // 0
        path.getSibling(0) // pathA
        path.getSibling(path.key + 1) // pathB
        path.container // [pathA, pathB, pathC]
        path.getPrevSibling() // path(undefined) *
        path.getNextSibling() // pathB
        path.getAllPrevSiblings() // []
        path.getAllNextSiblings() // [pathB, pathC]
      }
    }
  };
}

path(undefined)是一个NodePathpath.node === undefined

中止遍历
  • 特定的条件下不执行,使用return
BinaryExpression(path) {
  if (path.node.operator !== '**') return;
}
  • 使用pathapipath.skip()忽略对当前path的子节点的遍历,path.stop()停止所有未执行的遍历。
outerPath.traverse({
  Function(innerPath) {
    innerPath.skip(); // if checking the children is irrelevant
  },
  ReferencedIdentifier(innerPath, state) {
    state.iife = true;
    innerPath.stop(); // if you want to save some state and then stop traversal, or deopt
  }
});

操作AST


替换节点
BinaryExpression(path) {
  path.replaceWith(
    t.binaryExpression("**", path.node.left, t.numberLiteral(2))
  );
}

将同一个变量的相乘替换为该变量的2次方

  function square(n) {
-   return n * n;
+   return n ** 2;
  }
替换单个节点为多个节点
ReturnStatement(path) {
  path.replaceWithMultiple([
    t.expressionStatement(t.stringLiteral("Is this the real life?")),
    t.expressionStatement(t.stringLiteral("Is this just fantasy?")),
    t.expressionStatement(t.stringLiteral("(Enjoy singing the rest of the song in your head)")),
  ]);
}
  function square(n) {
-   return n * n;
+   "Is this the real life?";
+   "Is this just fantasy?";
+   "(Enjoy singing the rest of the song in your head)";
  }
使用源码串替换节点
FunctionDeclaration(path) {
  path.replaceWithSourceString(`function add(a, b) {
    return a + b;
  }`);
}

原来的函数代码被直接替换为新的代码

- function square(n) {
-   return n * n;
+ function add(a, b) {
+   return a + b;
  }

除非处理的是动态代码,否则这种方案是不推荐的,更好的做法是在外部先将待替换的源码转成节点,然后完成替换。

插入兄弟节点
FunctionDeclaration(path) {
  path.insertBefore(t.expressionStatement(t.stringLiteral("Because I'm easy come, easy go.")));
  path.insertAfter(t.expressionStatement(t.stringLiteral("A little high, little low.")));
}
+ "Because I'm easy come, easy go.";
  function square(n) {
    return n * n;
  }
+ "A little high, little low.";
往容器中插入节点
ClassMethod(path) {
  path.get('body').unshiftContainer('body', t.expressionStatement(t.stringLiteral('before')));
  path.get('body').pushContainer('body', t.expressionStatement(t.stringLiteral('after')));
}
 class A {
  constructor() {
+   "before"
    var a = 'middle';
+   "after"
  }
 }
删除节点
FunctionDeclaration(path) {
  // 把当前的函数申明节点删除
  path.remove();
}
- function square(n) {
-   return n * n;
- }
替换父节点
BinaryExpression(path) {
  path.parentPath.replaceWith(
    t.expressionStatement(t.stringLiteral("Anyway the wind blows, doesn't really matter to me, to me."))
  );
}
  function square(n) {
-   return n * n;
+   "Anyway the wind blows, doesn't really matter to me, to me.";
  }
删除父节点
BinaryExpression(path) {
  path.parentPath.remove();
}
  function square(n) {
-   return n * n;
  }

Scope


判断变量是否绑定
FunctionDeclaration(path) {
  if (path.scope.hasBinding("n")) {
    // ...
  }
}

path.scope.hasBinding方法将会自底向顶地遍历scope树,判断是否绑定变量n
如果只想在当前scope上查找,则使用path.scope. hasOwnBinding

FunctionDeclaration(path) {
  if (path.scope.hasOwnBinding("n")) {
    // ...
  }
}
生成UID

path.scope.generateUidIdentifier会生成一个不与给定作用域下其他变量冲突的标识符

FunctionDeclaration(path) {
  path.scope.generateUidIdentifier("uid");
  // Node { type: "Identifier", name: "_uid" }
  path.scope.generateUidIdentifier("uid");
  // Node { type: "Identifier", name: "_uid2" }
}
将变量定义提升到当前作用域的父级作用域
FunctionDeclaration(path) {
  const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
  path.remove();
  path.scope.parent.push({ id, init: path.node });
}

产生的变化如下

- function square(n) {
+ var _square = function square(n) {
    return n * n;
- }
+ };
修改binding及引用
FunctionDeclaration(path) {
  path.scope.rename("n", "x");
}
- function square(n) {
-   return n * n;
+ function square(x) {
+   return x * x;
  }

也可以使用生成的UIDbinding进行变量命名

FunctionDeclaration(path) {
  // 第二个参数不传,则会自动生成uid
  path.scope.rename("n");
}
- function square(n) {
-   return n * n;
+ function square(_n) {
+   return _n * _n;
  }

最佳实践

使用工具函数

使用工具函数能够大大减少节点操作的复杂度,减少相应的操作错误概率。如

function buildAssignment(left, right) {
  return t.assignmentExpression("=", left, right);
}
按需遍历,尽早退出

遍历AST节点开销较大,而且很容易在遍历时对不必要的节点进行访问。Babel中可以将同样操作的访问器合并到一起进行处理。

比如下面的代码

path.traverse({
  Identifier(path) {
    // ...
  }
});

path.traverse({
  BinaryExpression(path) {
    // ...
  }
});

从逻辑上看并没有任何问题,在需要的地方遍历AST,但这种写法会造成对同样的树进行多次遍历,这显然是一种浪费。可以对其中的访问器进行合并,一次遍历就能完成相关的逻辑处理,如下:

path.traverse({
  Identifier(path) {
    // ...
  },
  BinaryExpression(path) {
    // ...
  }
});
明确规则代替遍历

在一些场景下,节点的查找规则是确定的,这种情况使用明确的查找规则代替遍历会是更优的做法。
比如查找函数参数

const nestedVisitor = {
  Identifier(path) {
    // ...
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    path.get('params').traverse(nestedVisitor);
  }
};

可以换成

const MyVisitor = {
  FunctionDeclaration(path) {
    // 直接通过点运算符获取params列表
    path.node.params.forEach(function() {
      // ...
    });
  }
};
优化嵌套访问器

在访问器中有嵌套逻辑时,编写嵌套逻辑的代码是有其意义的

// 外层 访问器
const MyVisitor = {
  FunctionDeclaration(path) {
    path.traverse({
      // 内层访问器
      Identifier(path) {
        // ...
      }
    });
  }
};

它的问题在于,每次命中FunctionDeclaration时都会新创建一个访问器对象,这个开销是巨大的。更好的做法是定义一个访问器对象,每次将该对象传入。

const nestedVisitor = {
  Identifier(path) {
    // ...
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    path.traverse(nestedVisitor);
  }
};

如果被嵌套的访问器需要使用内部的状态,可以将state传入travese,通过this获取相应的状态,如下

const nestedVisitor = {
  Identifier(path) {
    // 通过this使用相应的状态值
    if (path.node.name === this.exampleState) {
      // ...
    }
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    var exampleState = path.node.params[0].name;
    // 第二个参数是传入的状态
    path.traverse(nestedVisitor, { exampleState });
  }
};
小心嵌套的结构

在做AST转换时,通常会被忽视掉的是拿到的是一个嵌套结构。比如对一个类的构造函数进行处理时,类代码如下

class Foo {
  constructor() {
    // ...
  }
}

访问其构造函数

const constructorVisitor = {
  ClassMethod(path) {
    if (path.node.name === 'constructor') {
      // ...
    }
  }
}

const MyVisitor = {
  ClassDeclaration(path) {
    if (path.node.id.name === 'Foo') {
      path.traverse(constructorVisitor);
    }
  }
}

上面的构造函数的访问器明显忽略了一个事实,类是可以继承的,它的构造函数可能是一个链条

class Foo {
  constructor() {
    class Bar {
      constructor() {
        // ...
      }
    }
  }
}

假定它是一个单独的构造函数,往往会出错。

单元测试

对Babel插件的测试有几种主要的方案:快照测试/AST测试和执行测试。

快照测试

jest提供了比较方便的快照测试能力。针对具体的测试用例,事先准备好相应的快照文件,然后将测试用例的结果跟快照文件的内容进行比对,如果相同则测试通过,如果不同则测试失败,并抛出不同的内容。

测试用例

// src/__tests__/index-test.js
const babel = require('babel-core');
const plugin = require('../');

var example = `
var foo = 1;
if (foo) console.log(foo);
`;

it('works', () => {
  const {code} = babel.transform(example, {plugins: [plugin]});
  expect(code).toMatchSnapshot();
});

快照文件

exports[`test works 1`] = `
"
var bar = 1;
if (bar) console.log(bar);"
`;

如果把上面快照文件中的bar改成baz,重新运行jest命令,会抛出如下错误提示

Received value does not match stored snapshot 1.

    - Snapshot
    + Received

    @@ -1,3 +1,3 @@
     "
    -var bar = 1;
    -if (bar) console.log(bar);"
    +var baz = 1;
    +if (baz) console.log(baz);"

对于内容较多的快照文件,可以使用jest -u更新快照内容,避免手动创建。

AST测试

除了上述的快照测试,还可以对AST进行审查。下面是一个简单的示例

it('contains baz', () => {
  const {ast} = babel.transform(example, {plugins: [plugin]});
  const program = ast.program;
  const declaration = program.body[0].declarations[0];
  assert.equal(declaration.id.name, 'baz');
  // or babelTraverse(program, {visitor: ...})
});

通过校验AST的结构(上面的示例是特定节点),来断言插件功能是否正确。

执行测试

AST转换为代码,执行转换后的代码,借此判断插件是否正确。示例代码如下

it('foo is an alias to baz', () => {
  var input = `
    var foo = 1;
    // test that foo was renamed to baz
    var res = baz;
  `;
  var {code} = babel.transform(input, {plugins: [plugin]});
  var f = new Function(`
    ${code};
    return res;
  `);
  var res = f();
  assert(res === 1, 'res is 1');
});
babel-plugin-tester

使用babel-plugin-tester会简化babel插件的测试,具体使用可以参考相关的文档,下面是一个简单的示例

import pluginTester from 'babel-plugin-tester';
import identifierReversePlugin from '../identifier-reverse-plugin';

pluginTester({
  plugin: identifierReversePlugin,
  fixtures: path.join(__dirname, '__fixtures__'),
  tests: {
    'does not change code with no identifiers': '"hello";',
    'changes this code': {
      code: 'var hello = "hi";',
      output: 'var olleh = "hi";',
    },
    'using fixtures files': {
      fixture: 'changed.js',
      outputFixture: 'changed-output.js',
    },
    'using jest snapshots': {
      code: `
        function sayHi(person) {
          return 'Hello ' + person + '!'
        }
      `,
      snapshot: true,
    },
  },
});

至此,Babel handbook基本翻译结束,里面可能会有一些错漏,感兴趣的读者可以点击这里查看原文。

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

推荐阅读更多精彩内容