TypeScript 代码整洁之道

将 Clean Code 的概念适用到 TypeScript,全文详见:《TypeScript 代码整洁之道》

目录

  1. 简介
  2. 变量
  3. 函数
  4. 对象与数据结构
  5. SOLID原则
  6. 测试
  7. 并发
  8. 错误处理
  9. 格式化
  10. 注释

简介

WTF/min

本文不是一份 TypeScript 编码风格规范,而是将 Robert C. Martin 的软件工程著作 《Clean Code》 适用到 TypeScript,引导读者使用 TypeScript 编写易读、复用和可扩展的软件。

实际上,并不是每一个原则都要严格遵守,能被广泛认同的原则就更少了。本文起来虽然只是一份指导原则,但却是 Clean Code 作者对多年编程经验的凝练。

软件工程技术已有50多年的历史了,我们仍然要学习很多的东西。当软件架构和架构本身一样古老的时候,也许我们需要遵守更严格的规则。但是现在,让这些指导原则作为评估您和您的团队代码质量的试金石。

另外,理解这些原则不会立即让您变的优秀,也不意味着不会犯错。每一段代码都是从不完美开始的,通过反复走查不断趋于完美,就像黏土制作成陶艺一样,享受这个过程吧!

变量

计算机科学只存在两个难题:缓存失效和命名。—— Phil KarIton

使用有意义的变量名

做有意义的区分,让读者更容易理解变量的含义。

反例:


function between<T>(a1: T, a2: T, a3: T) {

  return a2 <= a1 && a1 <= a3;

}

正例:


function between<T>(value: T, left: T, right: T) {

  return left <= value && value <= right;

}

可读的变量名

如果你不能正确读出它,那么你在讨论它时听起来就会像个白痴。

反例:


class DtaRcrd102 {

  private genymdhms: Date; #  // 你能读出这个变量名么? 

  private modymdhms: Date;

  private pszqint = '102';

}

正例:


class Customer {

  private generationTimestamp: Date;

  private modificationTimestamp: Date;

  private recordId = '102';

}

合并功能一致的变量

反例:


function getUserInfo(): User;

function getUserDetails(): User;

function getUserData(): User;

正例:


function getUser(): User;

便于搜索的名字

往往我们读代码要比写的多,所以易读性和可搜索非常重要。如果不抽取并命名有意义的变量名,那就坑了读代码的人。代码一定要便于搜索,TSLint 就可以帮助识别未命名的常量。

反例:


//86400000 代表什么?

setTimeout(restart, 86400000);

正例:


// 声明为常量,要大写且有明确含义。

const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000;

setTimeout(restart, MILLISECONDS_IN_A_DAY);

使用自解释的变量名

反例:


declare const users:Map<string, User>;

for (const keyValue of users) {
  // ...
}

正例:


declare const users:Map<string, User>;

for (const [id, user] of users) {
  // ...
}

避免思维映射

不要让人去猜测或想象变量的含义,明确是王道。

反例:


const u = getUser();

const s = getSubscription();

const t = charge(u, s);

正例:


const user = getUser();

const subscription = getSubscription();

const transaction = charge(user, subscription);

不添加无用的上下文

如果类名或对象名已经表达了某些信息,在内部变量名中不要再重复表达。

反例:


type Car = {

  carMake: string;

  carModel: string;

  carColor: string;

}

function print(car: Car): void {

  console.log(`${this.carMake} ${this.carModel} (${this.carColor})`);

}

正例:


type Car = {

  make: string;

  model: string;

  color: string;

}

function print(car: Car): void {

  console.log(`${this.make} ${this.model} (${this.color})`);

}

使用默认参数,而非短路或条件判断

通常,默认参数比短路更整洁。

反例:


function loadPages(count: number) {

  const loadCount = count !== undefined ? count : 10;

  // ...

}

正例:


function loadPages(count: number = 10) {

  // ...

}

函数

参数越少越好 (理想情况不超过2个)

限制参数个数,这样函数测试会更容易。超过三个参数会导致测试复杂度激增,需要测试众多不同参数的组合场景。
理想情况,只有一两个参数。如果有两个以上的参数,那么您的函数可能就太过复杂了。

如果需要很多参数,请您考虑使用对象。为了使函数的属性更清晰,可以使用解构,它有以下优点:

  1. 当有人查看函数签名时,会立即清楚使用了哪些属性。
  2. 解构对传递给函数的参数对象做深拷贝,这可预防副作用。(注意:不会克隆从参数对象中解构的对象和数组)
  3. TypeScript 会对未使用的属性显示警告。

反例:


function createMenu(title: string, body: string, buttonText: string, cancellable: boolean) {

  // ...

}

createMenu('Foo', 'Bar', 'Baz', true);

正例:


function createMenu(options: {title: string, body: string, buttonText: string, cancellable: boolean}) {

  // ...

}

createMenu(
  {
    title: 'Foo',
    body: 'Bar',
    buttonText: 'Baz',
    cancellable: true
  }
);

通过 TypeScript 的类型别名,可以进一步提高可读性。


type MenuOptions = {title: string, body: string, buttonText: string, cancellable: boolean};

function createMenu(options: MenuOptions) {

  // ...

}

createMenu(
  {
    title: 'Foo',
    body: 'Bar',
    buttonText: 'Baz',
    cancellable: true
  }
);

只做一件事

这是目前软件工程中最重要的规则。如果函数做不止一件事,它就更难组合、测试以及理解。反之,函数只有一个行为,它就更易于重构、代码就更清晰。如果能做好这一点,你一定很优秀!

反例:


function emailClients(clients: Client) {

  clients.forEach((client) => {

    const clientRecord = database.lookup(client);

    if (clientRecord.isActive()) {

      email(client);

    }

  });

}

正例:


function emailClients(clients: Client) {

  clients.filter(isActiveClient).forEach(email);

}

function isActiveClient(client: Client) {

  const clientRecord = database.lookup(client);

  return clientRecord.isActive();

}

名副其实

通过函数名就可以看得出函数实现的功能。

反例:


function addToDate(date: Date, month: number): Date {
  // ...
}

const date = new Date();

// 从函数名很难看的出需要加什么?
addToDate(date, 1);

正例:


function addMonthToDate(date: Date, month: number): Date {
  // ...
}

const date = new Date();

addMonthToDate(date, 1);

每个函数只包含同一个层级的抽象

当有多个抽象级别时,函数应该是做太多事了。拆分函数以便可复用,也让测试更容易。

反例:


function parseCode(code:string) {

  const REGEXES = [ /* ... */ ];
  const statements = code.split(' ');
  const tokens = [];

  REGEXES.forEach((regex) => {

    statements.forEach((statement) => {
      // ...
    });

  });

  const ast = [];

  tokens.forEach((token) => {
    // lex...
  });

  ast.forEach((node) => {
    // 解析 ...
  });

}

正例:


const REGEXES = [ /* ... */ ];

function parseCode(code:string) {

  const tokens = tokenize(code);

  const syntaxTree = parse(tokens);

  syntaxTree.forEach((node) => {

    // parse...

  });

}

function tokenize(code: string):Token[] {

  const statements = code.split(' ');

  const tokens:Token[] = [];

  REGEXES.forEach((regex) => {

    statements.forEach((statement) => {

      tokens.push( /* ... */ );

    });

  });

  return tokens;

}

function parse(tokens: Token[]): SyntaxTree {

  const syntaxTree:SyntaxTree[] = [];

  tokens.forEach((token) => {

    syntaxTree.push( /* ... */ );

  });

  return syntaxTree;

}

删除重复代码

重复乃万恶之源!重复意味着如果要修改某个逻辑,需要修改多处代码:cry:。
想象一下,如果你经营一家餐厅,要记录你的库存:所有的西红柿、洋葱、大蒜、香料等等。如果要维护多个库存列表,那是多么痛苦的事!

存在重复代码,是因为有两个或两个以上很近似的功能,只有一点不同,但是这点不同迫使你用多个独立的函数来做很多几乎相同的事情。删除重复代码,则意味着创建一个抽象,该抽象仅用一个函数/模块/类就可以处理这组不同的东西。

合理的抽象至关重要,这就是为什么您应该遵循SOLID原则。糟糕的抽象可能还不如重复代码,所以要小心!话虽如此,还是要做好抽象!尽量不要重复。

反例:


function showDeveloperList(developers: Developer[]) {

  developers.forEach((developer) => {

    const expectedSalary = developer.calculateExpectedSalary();

    const experience = developer.getExperience();

    const githubLink = developer.getGithubLink();

    const data = {

      expectedSalary,

      experience,

      githubLink

    };

    render(data);

  });

}

function showManagerList(managers: Manager[]) {

  managers.forEach((manager) => {

    const expectedSalary = manager.calculateExpectedSalary();

    const experience = manager.getExperience();

    const portfolio = manager.getMBAProjects();

    const data = {

      expectedSalary,

      experience,

      portfolio

    };

    render(data);

  });

}

正例:


class Developer {

  // ...

  getExtraDetails() {

    return {

      githubLink: this.githubLink,

    }

  }

}

class Manager {

  // ...

  getExtraDetails() {

    return {

      portfolio: this.portfolio,

    }

  }

}

function showEmployeeList(employee: Developer | Manager) {

  employee.forEach((employee) => {

    const expectedSalary = developer.calculateExpectedSalary();

    const experience = developer.getExperience();

    const extra = employee.getExtraDetails();

    const data = {

      expectedSalary,

      experience,

      extra,

    };

    render(data);

  });

}

有时,在重复代码和引入不必要的抽象而增加的复杂性之间,需要做权衡。当来自不同领域的两个不同模块,它们的实现看起来相似,复制也是可以接受的,并且比抽取公共代码要好一点。因为抽取公共代码会导致两个模块产生间接的依赖关系。

使用Object.assign解构来设置默认对象

反例:


type MenuConfig = {title?: string, body?: string, buttonText?: string, cancellable?: boolean};

function createMenu(config: MenuConfig) {

  config.title = config.title || 'Foo';

  config.body = config.body || 'Bar';

  config.buttonText = config.buttonText || 'Baz';

  config.cancellable = config.cancellable !== undefined ? config.cancellable : true;

}

const menuConfig = {

  title: null,

  body: 'Bar',

  buttonText: null,

  cancellable: true

};

createMenu(menuConfig);

正例:


type MenuConfig = {title?: string, body?: string, buttonText?: string, cancellable?: boolean};

function createMenu(config: MenuConfig) {

  const menuConfig = Object.assign({

    title: 'Foo',

    body: 'Bar',

    buttonText: 'Baz',

    cancellable: true

  }, config);

}

createMenu({ body: 'Bar' });

或者,您可以使用默认值的解构:


type MenuConfig = {title?: string, body?: string, buttonText?: string, cancellable?: boolean};

function createMenu({title = 'Foo', body = 'Bar', buttonText = 'Baz', cancellable = true}: MenuConfig) {

  // ...

}

createMenu({ body: 'Bar' });

为了避免副作用,不允许显式传递undefinednull值。参见 TypeScript 编译器的--strictnullcheck选项。

不要使用Flag参数

Flag参数告诉用户这个函数做了不止一件事。如果函数使用布尔值实现不同的代码逻辑路径,则考虑将其拆分。

反例:


function createFile(name:string, temp:boolean) {

  if (temp) {

    fs.create(`./temp/${name}`);

  } else {

    fs.create(name);

  }

}

正例:


function createFile(name:string) {

  fs.create(name);

}

function createTempFile(name:string) {

  fs.create(`./temp/${name}`);

}

避免副作用 (part1)

当函数产生除了“一个输入一个输出”之外的行为时,称该函数产生了副作用。比如写文件、修改全局变量或将你的钱全转给了一个陌生人等。

在某些情况下,程序需要一些副作用。如先前例子中的写文件,这时应该将这些功能集中在一起,不要用多个函数/类修改某个文件。用且只用一个 service 完成这一需求。

重点是要规避常见陷阱,比如,在无结构对象之间共享状态、使用可变数据类型,以及不确定副作用发生的位置。如果你能做到这点,你才可能笑到最后!

反例:


// Global variable referenced by following function.

// If we had another function that used this name, now it'd be an array and it could break it.

let name = 'Robert C. Martin';

function toBase64() {

  name = btoa(name);

}

toBase64(); // produces side effects to `name` variable

console.log(name); // expected to print 'Robert C. Martin' but instead 'Um9iZXJ0IEMuIE1hcnRpbg=='

正例:


// Global variable referenced by following function.

// If we had another function that used this name, now it'd be an array and it could break it.

const name = 'Robert C. Martin';

function toBase64(text:string):string {

  return btoa(text);

}

const encodedName = toBase64(name);

console.log(name);

避免副作用 (part2)

在 JavaScript 中,原类型是值传递,对象、数组是引用传递。

有这样一种情况,如果您的函数修改了购物车数组,用来添加购买的商品,那么其他使用该cart数组的函数都将受此添加操作的影响。想象一个糟糕的情况:

用户点击“购买”按钮,该按钮调用purchase函数,函数请求网络并将cart数组发送到服务器。由于网络连接不好,购买功能必须不断重试请求。恰巧在网络请求开始前,用户不小心点击了某个不想要的项目上的“Add to Cart”按钮,该怎么办?而此时网络请求开始,那么purchase函数将发送意外添加的项,因为它引用了一个购物车数组,addItemToCart函数修改了该数组,添加了不需要的项。

一个很好的解决方案是addItemToCart总是克隆cart,编辑它,并返回克隆。这确保引用购物车的其他函数不会受到任何更改的影响。

注意两点:

  1. 在某些情况下,可能确实想要修改输入对象,这种情况非常少见。且大多数可以重构,确保没副作用!(见纯函数)

  2. 性能方面,克隆大对象代价确实比较大。还好有一些很好的库,它提供了一些高效快速的方法,且不像手动克隆对象和数组那样占用大量内存。

反例:


function addItemToCart(cart: CartItem[], item:Item):void {

  cart.push({ item, date: Date.now() });

};

正例:


function addItemToCart(cart: CartItem[], item:Item):CartItem[] {

  return [...cart, { item, date: Date.now() }];

};

不要写全局函数

在 JavaScript 中污染全局的做法非常糟糕,这可能导致和其他库冲突,而调用你的 API 的用户在实际环境中得到一个 exception 前对这一情况是一无所知的。

考虑这样一个例子:如果想要扩展 JavaScript 的 Array,使其拥有一个可以显示两个数组之间差异的 diff方法,该怎么做呢?可以将新函数写入Array.prototype ,但它可能与另一个尝试做同样事情的库冲突。如果另一个库只是使用diff来查找数组的第一个元素和最后一个元素之间的区别呢?

更好的做法是扩展Array,实现对应的函数功能。

反例:


declare global {

  interface Array<T> {

    diff(other: T[]): Array<T>;

  }

}

if (!Array.prototype.diff){

  Array.prototype.diff = function <T>(other: T[]): T[] {

    const hash = new Set(other);

    return this.filter(elem => !hash.has(elem));

  };

}

正例:


class MyArray<T> extends Array<T> {

  diff(other: T[]): T[] {

    const hash = new Set(other);

    return this.filter(elem => !hash.has(elem));

  };

}

函数式编程优于命令式编程

尽量使用函数式编程!

反例:


const contributions = [

  {

    name: 'Uncle Bobby',

    linesOfCode: 500

  }, {

    name: 'Suzie Q',

    linesOfCode: 1500

  }, {

    name: 'Jimmy Gosling',

    linesOfCode: 150

  }, {

    name: 'Gracie Hopper',

    linesOfCode: 1000

  }

];

let totalOutput = 0;

for (let i = 0; i < contributions.length; i++) {

  totalOutput += contributions[i].linesOfCode;

}

正例:


const contributions = [

  {

    name: 'Uncle Bobby',

    linesOfCode: 500

  }, {

    name: 'Suzie Q',

    linesOfCode: 1500

  }, {

    name: 'Jimmy Gosling',

    linesOfCode: 150

  }, {

    name: 'Gracie Hopper',

    linesOfCode: 1000

  }

];

const totalOutput = contributions

  .reduce((totalLines, output) => totalLines + output.linesOfCode, 0)

封装判断条件

反例:


if (subscription.isTrial || account.balance > 0) {

  // ...

}

正例:


function canActivateService(subscription: Subscription, account: Account) {

  return subscription.isTrial || account.balance > 0

}

if (canActivateService(subscription, account)) {

  // ...

}

避免“否定”的判断

反例:


function isEmailNotUsed(email: string) {

  // ...

}

if (isEmailNotUsed(email)) {

  // ...

}

正例:


function isEmailUsed(email) {

  // ...

}

if (!isEmailUsed(node)) {

  // ...

}

避免判断条件

这看起来似乎不太可能完成啊。大多数人听到后第一反应是,“没有 if 语句怎么实现功能呢?” 在多数情况下,可以使用多态性来实现相同的功能。接下来的问题是 “为什么要这么做?” 原因就是之前提到的:函数只做一件事。

反例:


class Airplane {

  private type: string;

  // ...

  getCruisingAltitude() {

    switch (this.type) {

      case '777':

        return this.getMaxAltitude() - this.getPassengerCount();

      case 'Air Force One':

        return this.getMaxAltitude();

      case 'Cessna':

        return this.getMaxAltitude() - this.getFuelExpenditure();

      default:

        throw new Error('Unknown airplane type.');

    }

  }

}

正例:


class Airplane {

  // ...

}

class Boeing777 extends Airplane {

  // ...

  getCruisingAltitude() {

    return this.getMaxAltitude() - this.getPassengerCount();

  }

}

class AirForceOne extends Airplane {

  // ...

  getCruisingAltitude() {

    return this.getMaxAltitude();

  }

}

class Cessna extends Airplane {

  // ...

  getCruisingAltitude() {

    return this.getMaxAltitude() - this.getFuelExpenditure();

  }

}

避免类型检查

TypeScript 是 JavaScript 的一个严格的语法超集,具有静态类型检查的特性。所以指定变量、参数和返回值的类型,以充分利用此特性,能让重构更容易。

反例:


function travelToTexas(vehicle: Bicycle | Car) {

  if (vehicle instanceof Bicycle) {

    vehicle.pedal(this.currentLocation, new Location('texas'));

  } else if (vehicle instanceof Car) {

    vehicle.drive(this.currentLocation, new Location('texas'));

  }

}

正例:


type Vehicle = Bicycle | Car;

function travelToTexas(vehicle: Vehicle) {

  vehicle.move(this.currentLocation, new Location('texas'));

}

不要过度优化

现代浏览器在运行时进行大量的底层优化。很多时候,你做优化只是在浪费时间。有些优秀资源可以帮助定位哪里需要优化,找到并修复它。

反例:


// 在旧版本浏览器中,`list.length` 会被重复计算,浪费资源,在现代浏览器中已经被优化。

for (let i = 0, len = list.length; i < len; i++) {

  // ...

}

正例:


for (let i = 0; i < list.length; i++) {

  // ...

}

删除无用代码

无用代码和重复代码一样无需保留。如果没有地方调用它,请删除!如果仍然需要它,可以查看版本历史。

反例:


function oldRequestModule(url: string) {

  // ...

}

function requestModule(url: string) {

  // ...

}

const req = requestModule;

inventoryTracker('apples', req, 'www.inventory-awesome.io');

正例:


function requestModule(url: string) {

  // ...

}

const req = requestModule;

inventoryTracker('apples', req, 'www.inventory-awesome.io');

使用迭代器和生成器

像使用流一样处理数据集合时,请使用生成器和迭代器。

理由如下:

  • 将调用者与生成器实现解耦,在某种意义上,调用者决定要访问多少项。
  • 延迟执行,按需使用。
  • 内置支持使用for-of语法进行迭代
  • 允许实现优化的迭代器模式

反例:

function fibonacci(n: number): number[] {
  if (n === 1) return [0];
  if (n === 2) return [0, 1];

  const items: number[] = [0, 1];
  while (items.length < n) {
    items.push(items[items.length - 2] + items[items.length - 1]);
  }

  return items;
}

function print(n: number) {
  fibonacci(n).forEach(fib => console.log(fib));
}

// Print first 10 Fibonacci numbers.
print(10);

正例:

// Generates an infinite stream of Fibonacci numbers.
// The generator doesn't keep the array of all numbers.
function* fibonacci(): IterableIterator<number> {
  let [a, b] = [0, 1];

  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

function print(n: number) {
  let i = 0;
  for (const fib in fibonacci()) {
    if (i++ === n) break;  
    console.log(fib);
  }  
}

// Print first 10 Fibonacci numbers.
print(10);

有些库通过链接“map”、“slice”、“forEach”等方法,达到与原生数组类似的方式处理迭代。参见 itiriri 里面有一些使用迭代器的高级操作示例(或异步迭代的操作 itiriri-async)。

import itiriri from 'itiriri';

function* fibonacci(): IterableIterator<number> {
  let [a, b] = [0, 1];
 
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

itiriri(fibonacci())
  .take(10)
  .forEach(fib => console.log(fib));

全文详见:《TypeScript 代码整洁之道》

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

推荐阅读更多精彩内容