Electron快速入门,聊聊跨进程通信那些事儿

前言

有句话叫做“有需求就有市场”,技术领域也同样是如此。在过往的前端领域之上,当面临需要涉及操作系统的时候,前端coder往往显得力不从心。这便是桌面应用的需求造就了 Electron 的到来。

什么是Electron?

简介

image.png

打开官网,我们便可以看到其介绍,使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。顾名思义,我们可以完全自主控制地去构建跨平台桌面应用了,无需强依赖于桌面应用原生开发人员,有效降低沟通成本,再也不用求爷爷告奶奶去协调资源,完全可以自主访问以往受限的操作系统相关底层API。

当然,这也并不意味着百利而无一害,毕竟获得更多 power 的同时,也会承担更多 Risk。

优秀应用

  • Visual Studio Code
  • Atom
  • Postman
  • 社交通讯 WhatsApp
  • MongoDB 桌面管理工具 Compass
  • 接口管理工具 Apifox
  • ... ...

技术选型

image.png

Electron核心组成

Electron 是基于 ChromiumNode 实现的,才使得我们可以无缝轻松使用其开发跨平台桌面应用,降低了学习门槛,更加轻松上手开发。

image.png

为了弥补前端访问系统API方面的不足,Electron 内部对系统API进行了封装,相关譬如系统对话框、系统托盘、系统菜单、剪切板等。而其他诸如网络访问控制、本地文件系统等则由 Node 提供底层支持。

Electron 通过各操作系统之间的消息循环打通 Node 和 Chromium 的事件循环,保证了其两者的松耦合。进而推出了主进程渲染进程的概念。

Electron 起了一个新到安全线程去轮询,
当 Nodejs 有新的事件之后,通过 PostTask 转发到 Chromiums 的事件循环当中,完成 Electron 的事件融合

具体相关源码:https://github.com/electron/electron/blob/main/shell/common/node_bindings.cc

Electron 工作机制

啥也不说,先上个图

左侧是我们传统开发中前端人员所能控制展示的区域,而当基于 Electron 去开发桌面应用时,我们可控区域如右侧所示,全部交由前端自主开发。

而 Electron 开发中,页面不再是用户手动输入打开,而是开发着自主硬编码好的。

Electron应用程序主要分为主进程渲染进程两个部分,即对应着右侧图中上下两个部分。

进程

image.png

一个 Electron 应用程序由一个主进程(有且只有一个) + 多个渲染进程组成。

主进程

功能:桥梁作用,连接操作系统和渲染进程,负责管理所有窗口及其对应的渲染进程。

  • 有且只有一个,整个应用入口
  • 创建、管理渲染进程
  • 控制应用生命周期
  • 使用 NodeJS 特性
  • 调用操作系统 API
  • ...

渲染进程

功能:负责完成渲染页面、接收用户输入、相应用户交互等工作。

  • 渲染页面
  • 使用部分 Electron 模块 API
  • 使用 NodeJS 特性
  • 一个应用可存在多个渲染进程
  • 控制用户交互逻辑
  • 访问 Dom API

核心模块归属情况

image.png

上图为笔者整理的常用模块归属情况,详细主进程、渲染进程会在后续的实战部分进行部分讲解。

IPC 通信

大概了解完两个进程的功能之后,我们接下去该考虑一下这两者之间,是如何进行协调通信的。


image.png

Electron 中通过提供ipcMain、ipcRenderer来作为主进程、渲染进程之间的通信桥梁。

[图片上传失败...(image-564a5b-1635131617771)]

从接口定义中不难推断出其管道IPC是通过继承 EventEmitter 来实现IpcMain、IpcRenderer,并拓展了其他工具类方法。纵使翻阅 electron 源码也是如此,感兴趣的同学可以自己去研究研究,这里只做简单了解。

讲到这里,对于主进程和渲染进程的通信就变得十分容易理解了,通过管道IPC,采用熟知的发布订阅模式进行两者之间的通信。

窗口获取

  • BrowserWindow.getFocusedWindow(): 获取当前激活状态窗口
  • remote.getCurrentWindow(): 获取当前渲染进程关联窗口
  • BrowserWindow.fromI(id): 根据id获取窗口实例
  • BrowserWindow.getAllWindow(): 获取所有窗口

remote

在讲实际项目基本操作之前,先介绍一下一个比较特殊的 remote 模块

remote:这是一个 Electron 内部的模块,渲染进程可以通过此模块访问到主进程的模块、对象和方法。包括在渲染进程创建窗口、创建菜单等类似本应该由主进程完成的操作通过 remote 依然可以在渲染进程进行完成。前提是创建窗口的时候,开启了 nodeIntegration 配置,让渲染进程有能力去访问 Node.js 相关API。但是其背后的机制是一样的,通过通知主进程,主进程接收消息后再进行相关操作,然后把相关的实例以远程对象形式返回到渲染进程。

局限性

当然,remote虽然极大便利了开发者,但是也带来了一些局限性

  • 性能损耗大:跨进程操作
  • 制造混乱:异步导致执行顺序错乱
  • 制造假象:代理对象导致数据混乱
  • 安全问题:恶意代码攻击

在不久的将来,remote 模块将从 electron 内部移除,但是还很漫长,保持关注即可。

实战

从这里开始,我们将从实际的项目基本功能演练进行相关核心模块的使用演示。

进程互访

渲染进程TO主进程

其核心原理是因为暴露了 remote 模块,让开发者可以相对随心所欲的进行访问。

比如我们在主进程里想要获取应用程序的程序路径,我们可以在主进程这么获取:

import { app } from 'electron'
//  获取应用程序路径
const ROOT_PATH = app.getAppPath()

而在渲染进程中,有了 remote 模块,此类简单属性获取也变得更加方便:

const { app } = require('electron').remote
//  获取应用程序路径
const ROOT_PATH = app.getAppPath()

然鹅,其不仅可以访问主进程的属性,还可以调用相关方法,再举个栗子:

const { remote } = require('electron')
//  渲染进程打开开发者工具
remote.getCurrentWindow().webContents.openDevTools()

结论:通过 remote 模块,我们可以方便的访问主进程的模块、对象和方法。

主进程TO渲染进程

渲染进程是由主进程控制的,通过创建的渲染进程的窗口win.webContents对象,可以轻易地访问渲染进程相关内容。

这里官网的相关事例说明相对完善,可以自行查看。

const {BrowserWindow} = require('electron')
let win = new BrowserWindow({width: 800, height: 600})
win.loadURL('http://github.com')
//  获取当前网页窗口的网址
let currentURL = win.webContents.getURL()

进程通信

其核心即为管道IPC通信,上文有所说明,不再赘述。

主进程TO渲染进程

主要有两种方式进行通信:

  • ipcMain 接收渲染进程消息
  • webContents 发送给渲染进程

比方说呢,项目里我有一个地方需要监听用户通过 a 标签打开外链,但是我又不想它重新创建一个窗口,所以需要系统干预进行处理。

我的解决方案就是通过 进程通信 + shell 模块来通过系统默认浏览器来打开目标链接。

<a href="www.baidu.com" target="_blank">百度</a>
const { ipcMain, shell } = require('electron');
ipcMain.on('open-url', (event, url) => {
  //  'open-url' 为管道消息名称
  //  event 为消息发送相关信息 
  //  event.sender 为渲染进程的webContents对象事例
  //  url 为传递参数
  
  //  通过系统默认浏览器打开目标外链
  shell.openExternal(url)
})

如果此时到这里之后,我们想告诉渲染进程我们已经成功接收并执行了,也就是回调,那么我们就可以通过渲染进程事例进行对渲染进程消息通知:

方法1: webContents 直接回传

const { ipcMain, shell } = require('electron');
const win = new BrowserWindow({
  //... ...
})
ipcMain.on('open-url', (event, url) => {
  //  ... ...
  //  通过系统默认浏览器打开目标外链
  shell.openExternal(url)
  //  向渲染进程进行消息通知
  win.webContents.send('ready-open-url')
})

方法2: ipcMain.on 接收消息通知时,event.sender 为渲染进程的webContents 对象事例,我们也可以直接进行消息通知:

const { ipcMain, shell } = require('electron');
const win = new BrowserWindow({
  //... ...
})
ipcMain.on('open-url', (event, url) => {
  //  ... ...
  //  通过系统默认浏览器打开目标外链
  shell.openExternal(url)
  //  向渲染进程进行消息通知
  event.sender.send('ready-open-url')
})

方法3: ipcMain.on 接收消息通知时,event 提供reply方法,相应消息给来源渲染进程,本质上与方法2逻辑一致。

//  ... ... 
ipcMain.on('open-url', (event, url) => {
  //  ... ...
  //  通过系统默认浏览器打开目标外链
  shell.openExternal(url)
  //  向渲染进程进行消息通知
  event.replay('ready-open-url')
})

渲染进程TO主进程

主要是通过 ipcRenderer 模块进行向主进程进行消息通知。

还是拿上面的例子来说,打开外链,那么我们就需要在渲染进程中进行向主进程通知,我需要打开某个外链。具体如下:

本事例为在 Vue 中的实践

const { ipcRenderer } = require('electron')
const links = document.querySelectorAll('a[href]')
links.forEach(link => {
  link.addEventListener('click', e => {
    const url = link.getAttribute('href')
    e.preventDefault()
    ipcRenderer.send('open-url', url)
  })
})

当然这是一个异步的消息队列~
可能在某些需求场景下,我们需要传递的是同步消息, 那么我们只要在主进程里直接设置 returnValue 的值即可,而渲染进程不需要再重复监听。

还是拿上面的打开外链做个演示说明:

//  主进程
ipcMain.on('open-url', (event, url) => {
  //  通过系统默认浏览器打开目标外链
  shell.openExternal(url);
  //  设置返回值
  event.returnValue = 'success';
})

//  渲染进程
const returnVal = ipcRenderer.sendSync('open-url', url);
console.log(returnVal) // success

当然,同步通信会阻塞渲染进程,孰轻孰重需要谨慎选择~

渲染进程TO渲染进程

当我们程序相对复杂,创建了多个渲染进程的时候,就容易出现多个渲染进程之间相互通信的场景。

解决方案其实也是显而易见的,既然是一个爹(主进程)生的,那么直接通过主进程进行一个过渡中转,就可以实现双方的一个通信了。毕竟窗口的创建往往就是在主进程里完成的,其持有所有窗口的实例,只要拿到目标窗口的id即可进行通信。

每个窗口 webContents.getProcessId() 或者 webContents.id 即可获得对应窗口的id。

伪代码如下:

//  win1窗口发送消息
ipcRenderer.sendTo(win2.webContents.id, 'send-msg', params1, params2)

//  win2窗口接收消息
ipcRenderer.on('send-msg', (event, params1, params2) => {
  //  ... ...
})

其中 ipcRenderer.sendTo 中,第一个参数为目标窗口id,第二个参数为管道消息名称,其余为传递参数。

当然,需要发送消息给到的目标窗口是打开的状态,否则可就接受不到了。

到此,三种场景的进程通信介绍完毕了。

[图片上传失败...(image-4ae3e2-1635131617771)]

有个小注意事项⚠️需要关注一下:

进程之间的通信过程中,发送的json对象都会被序列化和反序列化,所以传递的时候需要注意其方法和原型链上的数据是不会被传递的。

这一点,跟小程序 setData 进行视图层和逻辑层数据传输是十分类似的,evaluteJavascript 所实现的,最终都转化为字符串传递。

搭建开发环境

electron的安装,兴许是一个漫长的过程,这里强烈建议大家有条件的话能够科学上网,可以省掉不少破事。当然没有的话,也没关系(假的),我们也有解决方案。

包管理工具的话,大家就各自选择了,npm/yarn 都可以,这里以 yarn 进行说明。

初始化项目

yarn init

electron 依赖包有点大,默认从github下载,所以巨艰难。
设置镜像

yarn config set set ELECTRON_MIRROR=http://npm.taobao.org/mirrors/electron/

全局安装 electron

yarn global add electron
image.png

这是正常安装成功 node_modules/electron 里应有的文件结构,如果后续运行报错了,大概率就是安装失败了。

可以选择手工操作处理此类问题,如果你上网不够科学的话~

解决方案:

  • /node_modules/electron/ 目录下创建path.txt
    • win输入:electron.exe
    • mac输入:Electron.app/Contents/MacOS/Electron
  • /node_modules/electron/ 目录下创建dist目录
image.png

至此,electron 安装就算是成功了。

package.json 中配置“main” 入口文件即 electron 的启动文件,即主进程的相关代码。

下面贴一个以 Vue 框架进行开发的项目文件结构图。

image.png

引入现代框架

通过引用模板项目即可快速入手开发,一个字-香!

Angular

React

  • electron-react-boilerplate
    该项目模板汇集了 Electron、React、Redux、React Router、webpack、React Hot Loader等,对入手尝鲜 Electron 来说,简直是不要太香。

Vue

通过引用前端三剑客框架,我们就可以快速投入到 Electron 的GUI应用开发之中,当然如果你执着于 jQuery,也是可以引用开发的,只是不建议而已,这就涉及到 Electron 性能相关了,这里不再展开。

发布打包

设置图标

  • 准备一张1024*1024尺寸的png图 放在public下
  • 安装 electron-icon-builder 插件
yarn add electron-icon-builder --dev

容易安装失败 多装几次(科学上网)

  • package.json 添加指令配置
"build-icon": "electron-icon-builder --input=./public/logo.png --output=build --flatten"
  • 执行
yarn build-icon

生成应用图标到对应的build文件夹

[图片上传失败...(image-97224b-1635131617771)]

打包安装包

yarn electron:build
image.png

直到 Done 出来之后也就大功告成了~

image.png

一个 electron 应用也就生成好了。

核心模块演示

设置全局变量

项目开发中,经常有个需求便是主题换肤,在尝试过程中自然就想到了 mac 下的系统主题切换。由此来演示下如何设置全局变量,并在渲染进行获取。

主进程

import { nativeTheme } from 'electron' 
/** 添加全局属性 * */
global.selfConfigs = {
  nativeTheme: () => nativeTheme.shouldUseDarkColors
}

渲染进程

const nativeTheme = require('electron').remote.getGlobal('selfConfigs').nativeTheme()

当然,直接通过 remote 调用 nativeTheme 也是可以的,just a 栗子。
[图片上传失败...(image-c3b7c0-1635131617771)]

脚本注入

  • 通过 preload 配置项,进行脚本注入
let win = new BrowserWindow({
  webPreferences: {
    preload: jsFilePath,
    nodeIntegration: true
  }
})
  • 通过 executeJavaScript 注入脚本

比方说,在 window 上添加自定义属性

主进程

let win = new BrowserWindow({
  //  ...
})
win.webContents.executeJavaScript(`
  window.onlyConfig = {a:1,b:2}
`)

渲染进程

console.log(window.onlyConfig) //  {a:1,b:2}

实现系统消息通知

有两种可实现方式,两种方式的使用方法区别不大。

  • HTML API 发送消息通知,缺点就是需要用户授权同意之后
  • 主进程直接发送系统消息
    const { Notification } = this.$electron.remote
    const notification = new Notification({
      title: '新建通知', BVVv    body: '您新建了一个md文档,请点击查看'
    })
    notification.show()
    notification.on('click', () => {})
image.png

实现系统托盘及相关菜单

系统托盘由 Tray 模块提供,用于添加托盘图标和上下文菜单至通知栏。

啥也不说了,先上大头贴


image.png

实现原理相对简单,通过定时器刷新托盘图标,并添加相对应的上下文菜单进行逻辑操作即可,更多功能可以自行DIY。

/** 添加系统托盘 * */
  let toggleSwitch = true; let toggleFlag = false; let timer
  const icon1 = path.join(__dirname, '../public/icon.png')
  const icon2 = path.join(__dirname, '../public/icon2.png')
  tray = new Tray(icon1)
  tray.setToolTip('Electron 系统托盘')
  tray.on('click', () => {
    console.log('托盘单击')
    win.isVisible() ? win.hide() : win.show()
  })
  tray.on('right-click', () => {
    const menuConfig = Menu.buildFromTemplate([
      {
        label: toggleSwitch ? '开启闪烁图标' : '关闭闪烁图标',
        click: () => {
          if (toggleSwitch) {
            timer = setInterval(() => {
              if (toggleFlag) {
                tray.setImage(icon2)
              } else {
                tray.setImage(icon1)
              }
              toggleFlag = !toggleFlag
            }, 600)
          } else {
            tray.setImage(icon1)
            clearInterval(timer)
          }
          toggleSwitch = !toggleSwitch
        }
      },
      {
        label: '退出',
        click: () => app.quit()
      }
    ])
    tray.popUpContextMenu(menuConfig)
  })
  /** 添加系统托盘 * */

实现系统右键菜单

以往,我们处理的思路是根据用户右键所在鼠标坐标生成一个右键菜单,相对麻烦并且还需要考虑边界状态。好比如编写此篇文章所用到的 mdnice ,即是用此方案使用了自定义右键菜单。

image.png

通过 electron 暴露的 screen 模块,获取到当前鼠标所在位置

window.oncontextmenu = () => {
  const point = require('electron').screen.getCursorScreenPoint();
}

而在 electron 里,我们可以直接自定义系统右键菜单,兼容性更佳。

//  监听右键菜单触发
  win.webContents.on('context-menu', (event, params) => {
    const selectEnabled = !!params.selectionText.trim().length
    const template = [
      {
        label: '为当前页面生成二维码',
        click: () => {
          console.log(`当前页面地址为:${params.pageURL}`)
        }
      }
    ]
    if (selectEnabled) {
      template.unshift(...[{
        label: '复制',
        role: 'copy',
        visible: () => !selectEnabled
      },
      {
        label: '剪切',
        role: 'cut'
      }])
    }
    const RightMenu = Menu.buildFromTemplate(template)
    RightMenu.popup()
  })

最终实现如下基础效果:


image.png

常见问题

npm 安装electron不成功

解决方案: 通过cnpm淘宝镜像安装 避免安装失败

报错 require is not defined

原因:electron12以后默认没法在渲染进程中引入Nodejs模块

解决方案:

找到 ./background.js里的 new BrowserWindow 添加配置项 nodeIntegration 设置为 true

导入electron.remote后,提示undefined

原因: 在electron10版本之后,remote默认关闭,需要手动开启

解决方案:

找到 ./background.js里的 new BrowserWindow 添加配置项

const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      enableRemoteModule: true, // 解决remote为undefined问题
      nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION
    }
  })

mac 下快捷键失效的问题

发现在mac下,本该熟练的复制、剪切、粘贴等快捷失效了。你说说,作为一名合格的 CV 工程师,这你能忍?

这时候就想起尤大的表情包,看文档!

马不停蹄一股脑加了段代码,瞬间感觉牛逼哄哄

  // 判断 mac 下 注册快捷键
  if (process.platform === 'darwin') {
    const contents = win.webContents
    globalShortcut.register('CommandOrControl+C', () => {
      contents.copy()
    })
    globalShortcut.register('CommandOrControl+V', () => {
      contents.paste()
    })
  }

后面发现这个方案并不是有效的解决方案,注册完快捷键后发现 electron 占据了系统的原有快捷键,这才发现除了 electron 以外的其他应用,这些快捷键都失效了~

[图片上传失败...(image-220ab6-1635131617771)]

后面仔细研究一番之后,通过判断应用是否激活状态,来进行相关快捷键的注册/注销.

//  处理系统本身的快捷键 复制 全选 等
  win.on('focus', () => {
    // mac下快捷键失效的问题
    if (process.platform === 'darwin') {
      globalShortcut.register('CommandOrControl+C', () => {
        console.log('注册复制快捷键成功')
        contents.copy()
      })
      globalShortcut.register('CommandOrControl+V', () => {
        console.log('注册粘贴快捷键成功')
        contents.paste()
      })
      globalShortcut.register('CommandOrControl+X', () => {
        console.log('注册剪切快捷键成功')
        contents.cut()
      })
      globalShortcut.register('CommandOrControl+A', () => {
        console.log('注册全选快捷键成功')
        contents.selectAll()
      })
    }
  })
  win.on('blur', () => {
    globalShortcut.unregister('CommandOrControl+C') // 注销键盘事件
    globalShortcut.unregister('CommandOrControl+V') // 注销键盘事件
    globalShortcut.unregister('CommandOrControl+X') // 注销键盘事件
    globalShortcut.unregister('CommandOrControl+A') // 注销键盘事件
  })

windows 下控制台出现中文乱码

常见的gb2312为936 utf8为65001 配置执行命令即可解决

"start": "chcp 65001 && electron ."

Vue 构建的 history 模式项目打包空白

history 模式匹配不到对应静态资源,需要做一层处理,或者router 的 mode 切换为 hash 即可。

总结

electron 优势

  • 上手门槛低
  • 开发周期短

electron 不足

  • 打包后应用体积过大
  • 版本发布过快
  • 安全性问题
  • 资源消耗较大
  • 平台上架难

前端想象力

  • 无浏览器兼容问题
  • 支持 ES 高级语法
  • 无跨域问题
  • 支持 Node.js

参考

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

推荐阅读更多精彩内容