Electron+Mobx+React 开发记录

nojsja.gitee.io/blogs 更多内容已经在个人博客发布,请知悉

Hello World

> Contents

  1. 前言
  2. 开发环境搭建
  3. 引入Webpack4.0前端打包工具
  4. Electron代码结构和代码热更新
  5. 前端界面React + Mobx 代码结构和热更新
  6. Linux桌面客户端开发遇到的问题

前言


最近桌面系统从Ubuntu18.04切换到了Manjaro Linux 17,之前听说Manjaro的软件丰富,仓库更新及时,很多常用软件都能一键安装(比如QQ,微信),同时也支持主流的Linux桌面环境:Gnome、KDE、Cinnamon、Mate、Deepin等等,安装了Gnome版本的Manjaro之后发现果然还不错。系统安装好后配置比较繁琐,就想给Manjaro写一个GUI客户端工具用于安装常用软件和作为简单的系统管理工具 - electronux
作为一名正直的前端开发人员,理所应当地就准备使用Electron + Node.js + React + Mobx + Webpack + Shell 来进行开发啦 ~ 目前仍然在开发中,这篇文章用于记录自己的环境搭建过程、一些对Electron+React开发的理解以及谈谈自己遇到的一些Linux桌面软件开发时遇到的问题和解决办法。

clean_detail.png
clean_search.png
electron_pssword.png
info_total.png
install_detail.png
install_list.png
install_permission.png
startup_list.png

开发环境搭建


代码目录结构
electronux  
|---- [dir ] app ( 主代码目录 )
|----------- [dir ] app/configure ( 应用配置更新 )
|----------- [dir ] app/runtime ( 运行数据文件 )
|
|----------- [dir ] app/services ( 后台服务存放目录 )
|------------------------ [dir ] app/services/middleware ( 一些中间处理件 )
|------------------------ [dir ] app/services/shell ( shell脚本存放目录 )
|------------------------ [dir ] app/services/main-serv ( 主进程服务 )
|------------------------ [dir ] app/services/render-serv ( 渲染进程服务 )
|
|----------- [dir ] app/stores ( 前端状态管理文件目录 )
|----------- [dir ] app/styles  ( 公用样式表文件 )
|----------- [dir ] app/utils  ( 公用工具函数 )
|
|----------- [dir ] app/views  ( UI界面代码 )
|------------------------ [dir ] app/views/module1  ( 界面模块1 )
|------------------------ [dir ] app/views/module2  ( 界面模块2)
|------------------------ [dir ] app/views/module3  ( 界面模块3 )
|
|----------- [file] app/App.js  ( 前端应用入口文件 )
|----------- [file] app/index.js ( 前端应用热加载文件 )
|
|---- [dir ] dist ( 前端代码编译打包文件存放目录 )
|---- [dir ] resources ( 前端静态资源存放目录 )
|
|---- [file] .babelrc ( babel配置文件 )
|---- [file] .editorconfig (编辑器编码规范文件)
|---- [file] .eslintrc ( 代码格式检查配置文件 )
|---- [file] .gitignore ( git忽略追踪配置文件 )
|---- [file] electron-builder.json ( electron-builder打包配置文件 )
|---- [file] index.html  ( 应用渲染入口页面 )
|---- [file] index.js ( 应用主进程入口文件 )
|---- [file] package.json (前端模块和框架配置文件)
|---- [file] webpack.config.js (webpack开发环境配置文件)
|---- [file] webpack.prod.config.js  ( webpack生产环境配置文件 )

项目环境依赖配置文件
{
  "name": "electronux",
  "description": "linux manager-software powered by electron & react & Mobx ",
  "version": "1.0.0",
  "author": {
    "name": "nojsja",
    "email": "yangwei020154@gmail.com"
  },
  "scripts": {
    "start": "concurrently \"npm run start-dev\" \"npm run start-electron\"",
    "start-dev": "cross-env NODE_ENV=development webpack-dev-server",
    "start-electron": "nodemon --exec 'cross-env NODE_ENV=development electron --inspect=5858 index'",
    "start-production": "cross-env NODE_ENV=production electron --inspect=5858 index",
    "build-all": "npm run dist && npm run build",
    "dist": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
    "build": "electron-builder -l"
  },
  "keywords": [
    "electron",
    "react",
    "mobx",
    "react-router",
    "webpack4"
  ],
  "license": "",
  "nodemonConfig": {
    "ignore": [
      "resources/*",
      "node_modules/*",
      "dist/*",
      "build/*",
      "app/stores/*",
      "app/styles/*",
      "app/services/shell/*",
      "app/configure/view.conf",
      "app/views/*",
      "app/App.js",
      "app/main.js",
      "app/index.js",
      "electron-builder.yml"
    ],
    "delay": "1000"
  },
  "dependencies": {
    "semantic-ui-css": "^2.4.0",
    "semantic-ui-react": "^0.82.5",
    "mobx": "^4.4.1",
    "mobx-react": "^5.2.8",
    "prop-types": "^15.6.2",
    "react": "^16.5.1",
    "react-dom": "^16.5.1",
    "react-hot-loader": "^4.3.8",
    "react-router": "^4.3.1",
    "react-router-dom": "^4.3.1",
    "history": "^4.7.2"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-eslint": "^10.0.1",
    "babel-loader": "^7.1.5",
    "babel-plugin-transform-decorators-legacy": "^1.3.5",
    "babel-preset-env": "^1.7.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1",
    "clean-webpack-plugin": "^0.1.19",
    "concurrently": "^3.6.1",
    "cross-env": "^5.2.0",
    "css-loader": "^0.28.11",
    "electron": "^2.0.9",
    "electron-builder": "^20.28.4",
    "eslint": "^5.6.1",
    "eslint-config-airbnb": "^17.1.0",
    "eslint-plugin-import": "^2.14.0",
    "eslint-plugin-jsx-a11y": "^6.1.2",
    "eslint-plugin-react": "^7.11.1",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-loader": "^0.5.5",
    "html-webpack-plugin": "^3.2.0",
    "node-sass": "^4.9.4",
    "nodemon": "^1.18.4",
    "sass-loader": "^7.1.0",
    "source-map-support": "^0.5.9",
    "style-loader": "^0.21.0",
    "url-loader": "^1.1.2",
    "webpack": "^4.19.0",
    "webpack-cli": "^2.1.5",
    "webpack-dev-server": "^3.1.8"
  }
}

引入Webpack4.0前端打包工具


webpack开发环境配置文件
const path = require('path');
const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

// 拆分样式文件
const extractSass = new ExtractTextPlugin({
  filename: 'style.scss.css',
});

const extractCss = new ExtractTextPlugin({
  filename: 'style.css',
});

module.exports = {
  devtool: 'source-map',
  entry: [
    'react-hot-loader/patch',
    'webpack-dev-server/client?http://localhost:3000',
    'webpack/hot/only-dev-server',
    './app/index',
  ],
  mode: 'development',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  resolve: {
    alias: {
      resources: path.resolve(__dirname, 'resources'),
      app: path.resolve(__dirname, 'app'),
    },
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
      },
      {
        test: /\.css$/,
        use: extractCss.extract({
          fallback: 'style-loader',
          use: 'css-loader',
          publicPath: '/',
        }),
      },
      {
        test: /\.scss$/,
        use: extractSass.extract({
          use: [{
            loader: 'css-loader',
          }, {
            loader: 'sass-loader',
          }],
          fallback: 'style-loader', // 在开发环境使用 style-loader
          publicPath: '/',
        }),
      },
      {
        test: /\.html$/,
        use: {
          loader: 'html-loader',
        },
      },
      {
        test: /\.(png|jpg|gif|svg|ico|woff|eot|ttf|woff2)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[path][name].[ext]',
            },
          },
        ],
      },
    ],
  },

  plugins: [
    extractSass,
    extractCss,
    new webpack.HotModuleReplacementPlugin(),
    new CleanWebpackPlugin(['dist']),
    new webpack.NamedModulesPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
  ],

  devServer: {
    host: 'localhost',
    port: 3000,
    historyApiFallback: true,
    hot: true,
  },
  target: 'electron-renderer',
};

Electron基本原理和代码热更新


Electron 运行 package.json 的 main 脚本的进程被称为主进程。 在主进程中运行的脚本通过创建web页面来展示用户界面。 一个 Electron 应用总是有且只有一个主进程。
由于 Electron 使用了 Chromium 来展示 web 页面,所以 Chromium 的多进程架构也被使用到。 每个 Electron 中的 web 页面运行在它自己的渲染进程中。
在普通的浏览器中,web页面通常在一个沙盒环境中运行,不被允许去接触原生的资源。 然而 Electron 的用户在 Node.js 的 API 支持下可以在页面中和操作系统进行一些底层交互。
进程使用 BrowserWindow 实例创建页面。 每个 BrowserWindow 实例都在自己的渲染进程里运行页面。 当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。
主进程管理所有的web页面和它们对应的渲染进程。 每个渲染进程都是独立的,它只关心它所运行的 web 页面。
在页面中调用与 GUI 相关的原生 API 是不被允许的,因为在 web 页面里操作原生的 GUI 资源是非常危险的,而且容易造成资源泄露。 如果你想在 web 页面里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

创建主进程

在index.js文件中我们引入electron和所有的自定义模块文件,并根据开发环境或是生产环境来进行主进程窗口加载,开发环境下使用http协议加载由webpack-dev-server启动的http服务,生产环境下使用file协议加载本地由webpack打包好的前端bundle.js文件,所以开发环境下npm start指令其实主要是执行了两步操作,一是启动webpack-dev-server,此时已经可以通过外部浏览器访问到localhost:3000的http服务,只不过我们实际是用electron之中的chromium浏览器来加载的,它与node.js主进程共享同一个chrome v8引擎,所以理论上,在页面加载后,你同样可以在渲染进程中使用node.js API,比如用使用fs模块访问文件系统。

主进程代码热更新

我用了nodemon工具实现了主进程代码热更新,如果不用nodemon工具那么 npm start-electron命令实际是执行cross-env NODE_ENV=development electron index,就是简单的用electron启动主进程文件,使用nodemon之后npm start-electron实际上是执行nodemon --exec 'cross-env NODE_ENV=development electron index',最后在package.json文件中增加一个nodemonConfig字段用于指定哪些文件需要纳入nodemon监听即可。

=> package.json中定义的启动脚本:

  "scripts": {
    "start": "concurrently \"npm run start-dev\" \"npm run start-electron\"",
    "start-dev": "cross-env NODE_ENV=development webpack-dev-server",
    "start-electron": "nodemon --exec 'cross-env NODE_ENV=development electron index'",
    "build": "npm run dist && npm run build-all",
    "dist": "cross-env NODE_ENV=production webpack  --config webpack.production.config.js",
    "build-all": "build -lmw"
  },

=> package.json中nodemonConfig字段

"nodemonConfig": {
    "ignore": [
      "resources/*",
      "node_modules/*",
      "dist/*",
      "app/stores/*",
      "app/styles/*",
      "app/services/shell/*",
      "app/configure/view.conf",
      "app/views/*",
      "app/App.js",
      "app/main.js",
      "app/index.js"
    ],
    "delay": "1000"
  },

=> 项目启动文件index.js:

...
// 根据运行环境加载窗口 //
function loadWindow(window, env) {
  if (env === 'development') {
    // wait for webpack-dev-server start
    setTimeout(() => {
      window.loadURL(url.format({
        pathname: 'localhost:3000',
        protocol: 'http:',
        slashes: true,
      }));
      // window.webContents.openDevTools();
    }, 1e3);
  } else {
    window.loadURL(url.format({
      pathname: path.join(path.resolve(__dirname, './dist'), 'index.html'),
      protocol: 'file:',
      slashes: true,
    }));
  }
}

/* ------------------- main window ------------------- */

function createWindow() {
  const { width, height } = getAppConf();
  win = new BrowserWindow({
    width,
    height,
    title: 'electronux',
    autoHideMenuBar: true,
  });

  win.on('resize', () => {
    const [_width, _height] = win.getContentSize();
    viewConf.set({
      width: _width,
      height: _height,
    });
  });

  loadWindow(win, nodeEnv);
}

/* ------------------- electron event ------------------- */

app.on('ready', () => {
  if (nodeEnv === 'development') {
    sourceMapSupport.install();
  }
  createWindow();
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('will-quit', () => {
  viewConf.write().then(() => 0, (err) => {
    console.error(err);
    throw new Error('App quit: view-conf write error !');
  });
});

app.on('activate', () => {
  if (win === null) {
    createWindow();
  }
});

前端界面React + Mobx 代码结构和热更新


代码结构
  1. App.js前端入口文件
    入口文件基本是整个前端应用的关键点,我们使用mobx-react包提供的Provider组件加载整个应用,并把各个应用模块(按功能划分)的mobx store示例作为props属性传入Provider,在各个组建中使用修饰器@inject就能直接使用store实例了,页面层次比较多的话最好使用React Router进行路由管理,值得注意的是React Router V4版本跟之前版本的理念和使用方式有很大区别,可以去官网查阅相关文档react-router4
/* ------------------- export global history ------------------- */
export const history = createHistory();

const stores = {
  install: new InstallState(),
  startup: new StartupState(),
  info: new InfoState(),
  clean: new CleanState(),
  pub: new PublicState(),
};

function App() {
  return (
    <Provider {...stores}>
      <Router history={history}>
        <Route path="/" component={HomePage} />
      </Router>
    </Provider>
  );
}

/* ------------------- export provider ------------------- */
export default App;

  1. mobx store 存储
    这是项目其中一个系统清理模块的mobx store,在store中被mobx监听的属性最好结构层次简单、只有单一的功能划分,不要把一个属性对象的嵌套写得太深。开发时我们把UI界面的数据抽象成store中的数据时可能会下意识地根据页面显示状态而把单个属性对象写得过于复杂,但其实页面显示状态只是逻辑的数据结构,我们在store中存储的时候应该尽量将这种逻辑数据结构翻译成扁平化的数据结构,然后再在各个属性对象之间建立映射关系。
    并且使用了mobx之后请尽量依赖mobx的数据引用监听自动更新特性,多写computedautorun来自动生成数据,使用action修饰一些需要更改store属性的方法。
class Clean {
  constructor() { }
  /* ------------------- observable ------------------- */

  // 所有检查项目 //
  @observable items = {
    appCache: false,
    appLog: false,
    trash: false,
    packageCache: false,
  };

  // 主界面加载 //
  @observable loadingMain = false;

  // 清理路径 //
  cleanPaths = {
    appCache: [`/home/${this.userinfo.username}/.cache`],
    appLog: ['/var/log/'],
    trash: [`/home/${this.userinfo.username}/.local/share/Trash/files`],
    packageCache: ['/var/cache/pacman/pkg'],
  }

  // 路径模块映射 //
  @observable cleanPathMap = {
    appCache: [], // '/var/log/pacman.log'
    appLog: [],
    trash: [],
    packageCache: [],
  }

  // 清理内容 //
  @observable cleanContents = observable.map({})

  // 清理大小 //
  cleanSizes = {
    // '/var/log//pacman.log': '10kb',
  }

  // ---- 清理选项细节-数据对象逻辑树结构 ---- //
  // @observable cleanDetails = {
  //   appCache: {
  //     url: [`/home/${this.userinfo.username}/.cache`], // 指定扫描路径多个
  //     contents: { // 绝对路径
  //       // '/var/cache/pacman/pkg/zsh-5.6.2-1-x86_64.pkg.tar.xz': false,
  //     },
  //     size: {
  //       // '/var/cache/pacman/pkg/zsh-5.6.2-1-x86_64.pkg.tar.xz': '10kb',
  //     },
  //   },
  //   appLog: {
  //     url: ['/var/log/'],
  //     contents: {
  //       // '/var/log//pacman.log': false,
  //     },
  //     size: {
  //       // '/var/log//pacman.log': '10kb',
  //     },
  //   }
  // }

  /* ------------------- static ------------------- */


  /* ------------------- computed ------------------- */

  // 获取所有被选中的detail item //
  @computed get allCheckedDetail() {
    const a = [];
    this.cleanContents.forEach((v, k) => {
      if (v) a.push(k);
    });
    return a;
  }

  // 清理路径详细信息 //
  @computed get cleanDetail() {
    const result = [];
    Object.keys(this.cleanPathMap).forEach((item) => {
      if (this.items[item]) {
        const oneResult = {
          label: item,
          contents: [],
        };
        this.cleanPathMap[item].forEach((it) => {
          oneResult.contents.push({
            content: it,
            size: this.cleanSizes[it] || 0,
          });
        });

        result.push(oneResult);
      }
    });

    return result;
  }
}

export default Clean;

  1. 页面组件划分
    在views目录下创建的各个目录都是一个单独的组件目录,组件目录下有一个组件入口文件和css样式表文件以及其它子组件,入口文件载入css文件和子组件,使用@inject修饰器后各个组件都可以独立访问mobx store实例,不必在父和子组件之间通过props进行逐级参数传递,但是如果一个子组件依赖父组件来加工原始数据的话也可以使用props传递参数。
    使用了mobx之后,并不是说每个页面需要使用的数据都有必要纳入mobx store的管理,在我的代码中只是把关键性数据以及关键性数据加工方法存入了store中,每个组件拿到store传递下来的数据后一些页面状态可能需要依赖组件各自的数据处理函数进行数据二次加工,我觉得这样应该会减轻store实例的负载压力,非绝对中心化。比如在一个列表菜单组件中,这个组件的列表数据可以切换显示和隐藏,但是控制这个列表显示/隐藏的参数状态visible没有必要纳入store实例管理,相对的管理这个列表组件的store实例只是存储了列表数据的数组,以及一些必要的数据加工方法。

  2. 渲染进程和主进程ipc通信的问题
    页面的每个渲染进程(ipcRender),虽然说可以直接使用node.js原生模块和api,但是不建议在渲染进程中过度使用原生模块,一是因为一些node.js原生模块并没有考虑到进程安全的问题,第二个原因是渲染进程应该专注处理页面交互和数据处理问题,划清代码的功能区域,把和系统交互的问题交由主进程(ipcMain)处理,把网络数据请求也交由各自的service服务,减少不必要的模块和数据耦合。渲染进程通过ipc通信向主进程发送处理请求,主进程和service负责原始数据的获取和网络数据的传输,最后主进程通过ipc通信向对应的渲染进程返回处理结果,service拿到的网络数据也通过回调事件发送给渲染进程。项目中我把mobx store作为和主进程通信的桥梁,mobx store向主进程发送信号,同时也在接收到主进程的ipc通信事件后再把主进程发回来的数据更新到各个observer。总之主进程和service服务负责系统交互、原始数据获取和传输,渲染进程mobx store负责响应信号和事件进行业务数据更新,各个view子组件只负责页面渲染和用户交互。

前端代码热更新
  1. webpack.config.js中启动webpack-dev-server的热更新功能
devServer: {
    host: 'localhost',
    port: 3000,
    historyApiFallback: true,
    hot: true,
  },
  1. 使用react-hot-loader的AppContainer组件
import { AppContainer } from 'react-hot-loader';

import 'semantic-ui-css/semantic.min.css';
import './styles/public.css';

import App from './App';

render(
  <AppContainer>
    <App />
  </AppContainer>,
  document.getElementById('root')
);

Linux桌面客户端开发遇到的问题


使用node.js子进程child_process执行shell脚本时无法取得系统root权限

项目中有的脚本需要使用root权限,比如安装和卸载软件、扫描系统关键路径,node.js里执行shell脚本可以使用child_process模块(node.js子进程),child_process有几个方法,spawnexecexecFilefork,它们都能创建子进程以执行指定文件或命令,具体的使用方法见Node API,如果我们的脚本或指令需要使用root权限那可就麻烦了,桌面应用又不是终端,不可能用着用着让用户去终端输入密码吧,况且只是在开发环境下能看到终端输出,应用打包安装运行起来后就是一个独立的应用程序了,根本没法输入终端密码,仔细查阅了Electron官网API发现electron官方并没有集成一个什么系统权限调用窗口之类的组件。没办法了,这种情况下手动写出了两种方法:

  1. 调用获取系统权限的系统自带组件来执行自定义命令和脚本
  2. 封装一个弹窗组件来获取用户首次输入的密码,然后手动把密码记录到文件中,应用启动的时候从文件中读出密码,在使用child_process创建子进程的时候再监听子进程的输出事件和错误事件,然后把读取到的保存在内存中的密码以输入流(input stream)的形式发送给child_process创建的子进程,子进程读取到输入流传入的密码后就能继续执行了。
electron_pssword.png
install_permission.png

具体代码见github/nojsja/electronux/app/utils/sudo-prompt.js

感谢阅读,文章中出现的错误之处还请指正~

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

推荐阅读更多精彩内容