vue-cli ui 插件机制详解

前言

在使用vue-cli的项目中,我们可以使用vue ui来打开一个可视化的项目管理器,在其中可以操作项目,运行任务,安装插件、依赖等。
除了官方提供的默认功能之外,我们还可以通过ui插件来进行拓展。
ui插件的写法是这样的:

module.exports = api => {
  api.describeTask({
    match: /vue-cli-service serve(\s+--\S+(\s+\S+)?)*$/,
    description: 'org.vue.vue-webpack.tasks.serve.description',
    link: 'https://cli.vuejs.org/guide/cli-service.html#vue-cli-service-serve',
    icon: '/public/webpack-logo.png',
    prompts: [
     // ...
    ],
    onBeforeRun: ({ answers, args }) => {
     // ...
    }
  });
  api.addView({
    // ...
  })
}

一个UI插件其实就是个函数,这个函数接收api对象作为参数,api对象是PluginApi的实例。

正文

从打开一个项目开始发生了什么?

我们在使用可视化页面打开一个项目的时候,客户端会发起一个mutation类型的graphql查询。apollo-server接收到之后就会进入相对应的resolver,在resolver中调用函数去打开项目。

module.exports = {
  Mutation: {
    projectOpen: (root, { id }, context) => projects.open(id, context),
  }
}

我们看一下project.open做了什么:

async function open (id, context) {
  const project = findOne(id, context)

  // ...
  lastProject = currentProject
  currentProject = project
  cwd.set(project.path, context)

  // 加载插件
  await plugins.list(project.path, context)

  // ...

  return project
} 

也就是先拿到该项目的信息,设置cwd,调用plugins.list方法,把信息设置到db中之后将项目信息返回给前端。
这里,重点就在plugin.list方法。
我们看一下他是如何实现的:

async function list (file, context, { resetApi = true, lightApi = false, autoLoadApi = true } = {}) {
  let pkg = folders.readPackage(file, context)
  let pkgContext = cwd.get()
  // 从package.json里加载插件信息
  if (pkg.vuePlugins && pkg.vuePlugins.resolveFrom) {
    pkgContext = path.resolve(cwd.get(), pkg.vuePlugins.resolveFrom)
    pkg = folders.readPackage(pkgContext, context)
  }
  pkgStore.set(file, { pkgContext, pkg })

  let plugins = []
  // 重依赖中查找插件
  plugins = plugins.concat(findPlugins(pkg.devDependencies || {}, file))
  plugins = plugins.concat(findPlugins(pkg.dependencies || {}, file))

  // 将cli-service放到最顶层
  const index = plugins.findIndex(p => p.id === CLI_SERVICE)
  if (index !== -1) {
    const service = plugins[index]
    plugins.splice(index, 1)
    plugins.unshift(service)
  }

  pluginsStore.set(file, plugins)
  // 如果条件满足就去重置插件api,首次打开项目会进入该语句
  if (resetApi || (autoLoadApi && !pluginApiInstances.has(file))) {
    await resetPluginApi({ file, lightApi }, context)
  }
  return plugins
}

在这里,它会从两个地方查找插件:

  • package.json的vuePlugins字段
  • 依赖

查到之后将其存入map中进行缓存,供后续使用。
如果是首次加载,或者重置时会进入resetPluginApi方法。插件的加载和调用就是在这里完成。
我们看一下具体实现:

function resetPluginApi ({ file, lightApi }, context) {
  return new Promise((resolve, reject) => {
    const widgets = require('./widgets')
    let pluginApi = pluginApiInstances.get(file)
    let projectId
    // 做一些清理工作
    if (pluginApi) {
      // ...
    }
    if (!lightApi) {
      // ...
    }

    setTimeout(async () => {
      const projects = require('./projects')
      const project = projects.findByPath(file, context)
      //  ...
      const plugins = getPlugins(file)

      // ...

      pluginApi = new PluginApi({
        plugins,
        file,
        project,
        lightMode: lightApi
      }, context)
      pluginApiInstances.set(file, pluginApi)

      // 运行插件api

      // 运行默认ui插件
      runPluginApi(path.resolve(__dirname, '../../'), pluginApi, context, 'ui-defaults')
      // 运行在上面获取到的依赖中的插件
      plugins.forEach(plugin => runPluginApi(plugin.id, pluginApi, context))
      // 运行package.json中vuePlugins字段中的插件
      const { pkg, pkgContext } = pkgStore.get(file)
      if (pkg.vuePlugins && pkg.vuePlugins.ui) {
        const files = pkg.vuePlugins.ui
        if (Array.isArray(files)) {
          for (const file of files) {
            runPluginApi(pkgContext, pluginApi, context, file)
          }
        }
      }
      // 添加客户端附加组件
      pluginApi.clientAddons.forEach(options => {
        clientAddons.add(options, context)
      })
      // 添加视图
      for (const view of pluginApi.views) {
        await views.add({ view, project }, context)
      }
      // 注册组件
      for (const definition of pluginApi.widgetDefs) {
        await widgets.registerDefinition({ definition, project }, context)
      }

      if (projectId !== project.id) {
        callHook({
          id: 'projectOpen',
          args: [project, projects.getLast(context)],
          file
        }, context)
      } else {
        callHook({
          id: 'pluginReload',
          args: [project],
          file
        }, context)

        // View open hook
        const currentView = views.getCurrent()
        if (currentView) views.open(currentView.id)
      }

      // Load widgets for current project
      widgets.load(context)

      resolve(true)
    })
  })
}

可以看到,resetPluginApi函数,进入之后:

  • 拿到一些相关信息
  • 做一些清理工作
  • 创建PluginApi实例
  • 运行在三个地方定义的api(默认、依赖、vuePlugins字段),运行时将PluginApi实例传入(回想一下插件的写法)。
  • 插件在运行时,就会调用PluginApi实例上的方法,在该示例上添加一系列的信息。在需要时即可直接拿出来进行消费。
  • 消费PluginApi实例上的信息,添加视图、附加组件等,调用一些钩子函数,加载一些插件。

到此为止,插件的加载过程完毕。
弯弯绕绕这么多,但总的来说,插件的加载过程可以抽象成一下的一个图:


插件加载过程.png

也就是:

  • 导入(打开)一个项目时区缓存项目信息
  • 加载插件信息
  • 创建插件实例
  • 运行插件
  • 使用插件实例上的信息

以上就是vue-cli ui插件机制的实现过程。

附录

PluginApi的定义

const path = require('path')
// Connectors
const logs = require('../connectors/logs')
const sharedData = require('../connectors/shared-data')
const views = require('../connectors/views')
const suggestions = require('../connectors/suggestions')
const progress = require('../connectors/progress')
const app = require('../connectors/app')
// Utils
const ipc = require('../util/ipc')
const { notify } = require('../util/notification')
const { matchesPluginId } = require('@vue/cli-shared-utils')
const { log } = require('../util/logger')
// Validators
const { validateConfiguration } = require('./configuration')
const { validateDescribeTask, validateAddTask } = require('./task')
const { validateClientAddon } = require('./client-addon')
const { validateView, validateBadge } = require('./view')
const { validateNotify } = require('./notify')
const { validateSuggestion } = require('./suggestion')
const { validateProgress } = require('./progress')
const { validateWidget } = require('./widget')

/**
 * @typedef SetSharedDataOptions
 * @prop {boolean} disk Don't keep this data in memory by writing it to disk
 */

/** @typedef {import('../connectors/shared-data').SharedData} SharedData */

class PluginApi {
  constructor ({ plugins, file, project, lightMode = false }, context) {
    // Context
    this.context = context
    this.pluginId = null
    this.project = project
    this.plugins = plugins
    this.cwd = file
    this.lightMode = lightMode
    // Hooks
    this.hooks = {
      projectOpen: [],
      pluginReload: [],
      configRead: [],
      configWrite: [],
      taskRun: [],
      taskExit: [],
      taskOpen: [],
      viewOpen: []
    }
    // Data
    this.configurations = []
    this.describedTasks = []
    this.addedTasks = []
    this.clientAddons = []
    this.views = []
    this.actions = new Map()
    this.ipcHandlers = []
    this.widgetDefs = []
  }

  /**
   * Register an handler called when the project is open (only if this plugin is loaded).
   *
   * @param {function} cb Handler
   */
  onProjectOpen (cb) {
    if (this.lightMode) return
    if (this.project) {
      cb(this.project)
      return
    }
    this.hooks.projectOpen.push(cb)
  }

  /**
   * Register an handler called when the plugin is reloaded.
   *
   * @param {function} cb Handler
   */
  onPluginReload (cb) {
    if (this.lightMode) return
    this.hooks.pluginReload.push(cb)
  }

  /**
   * Register an handler called when a config is red.
   *
   * @param {function} cb Handler
   */
  onConfigRead (cb) {
    if (this.lightMode) return
    this.hooks.configRead.push(cb)
  }

  /**
   * Register an handler called when a config is written.
   *
   * @param {function} cb Handler
   */
  onConfigWrite (cb) {
    if (this.lightMode) return
    this.hooks.configWrite.push(cb)
  }

  /**
   * Register an handler called when a task is run.
   *
   * @param {function} cb Handler
   */
  onTaskRun (cb) {
    if (this.lightMode) return
    this.hooks.taskRun.push(cb)
  }

  /**
   * Register an handler called when a task has exited.
   *
   * @param {function} cb Handler
   */
  onTaskExit (cb) {
    if (this.lightMode) return
    this.hooks.taskExit.push(cb)
  }

  /**
   * Register an handler called when the user opens one task details.
   *
   * @param {function} cb Handler
   */
  onTaskOpen (cb) {
    if (this.lightMode) return
    this.hooks.taskOpen.push(cb)
  }

  /**
   * Register an handler called when a view is open.
   *
   * @param {function} cb Handler
   */
  onViewOpen (cb) {
    if (this.lightMode) return
    this.hooks.viewOpen.push(cb)
  }

  /**
   * Describe a project configuration (usually for config file like `.eslintrc.json`).
   *
   * @param {object} options Configuration description
   */
  describeConfig (options) {
    if (this.lightMode) return
    try {
      validateConfiguration(options)
      this.configurations.push({
        ...options,
        pluginId: this.pluginId
      })
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'describeConfig' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Describe a project task with additional information.
   * The tasks are generated from the scripts in the project `package.json`.
   *
   * @param {object} options Task description
   */
  describeTask (options) {
    try {
      validateDescribeTask(options)
      this.describedTasks.push({
        ...options,
        pluginId: this.pluginId
      })
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'describeTask' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Get the task description matching a script command.
   *
   * @param {string} command Npm script command from `package.json`
   * @returns {object} Task description
   */
  getDescribedTask (command) {
    return this.describedTasks.find(
      options => typeof options.match === 'function' ? options.match(command) : options.match.test(command)
    )
  }

  /**
   * Add a new task independently from the scripts.
   * The task will only appear in the UI.
   *
   * @param {object} options Task description
   */
  addTask (options) {
    try {
      validateAddTask(options)
      this.addedTasks.push({
        ...options,
        pluginId: this.pluginId
      })
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'addTask' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Register a client addon (a JS bundle which will be loaded in the browser).
   * Used to load components and add vue-router routes.
   *
   * @param {object} options Client addon options
   *   {
   *     id: string,
   *     url: string
   *   }
   *   or
   *   {
   *     id: string,
   *     path: string
   *   }
   */
  addClientAddon (options) {
    if (this.lightMode) return
    try {
      validateClientAddon(options)
      if (options.url && options.path) {
        throw new Error('\'url\' and \'path\' can\'t be defined at the same time.')
      }
      this.clientAddons.push({
        ...options,
        pluginId: this.pluginId
      })
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'addClientAddon' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /* Project view */

  /**
   * Add a new project view below the builtin 'plugins', 'config' and 'tasks' ones.
   *
   * @param {object} options ProjectView options
   */
  addView (options) {
    if (this.lightMode) return
    try {
      validateView(options)
      this.views.push({
        ...options,
        pluginId: this.pluginId
      })
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'addView' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Add a badge to the project view button.
   * If the badge already exists, add 1 to the counter.
   *
   * @param {string} viewId Project view id
   * @param {object} options Badge options
   */
  addViewBadge (viewId, options) {
    if (this.lightMode) return
    try {
      validateBadge(options)
      views.addBadge({ viewId, badge: options }, this.context)
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'addViewBadge' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Remove 1 from the counter of a badge if it exists.
   * If the badge counter reaches 0, it is removed from the button.
   *
   * @param {any} viewId
   * @param {any} badgeId
   * @memberof PluginApi
   */
  removeViewBadge (viewId, badgeId) {
    views.removeBadge({ viewId, badgeId }, this.context)
  }

  /* IPC */

  /**
   * Add a listener to the IPC messages.
   *
   * @param {function} cb Callback with 'data' param
   */
  ipcOn (cb) {
    const handler = cb._handler = ({ data, emit }) => {
      if (data._projectId) {
        if (data._projectId === this.project.id) {
          data = data._data
        } else {
          return
        }
      }
      // eslint-disable-next-line standard/no-callback-literal
      cb({ data, emit })
    }
    this.ipcHandlers.push(handler)
    return ipc.on(handler)
  }

  /**
   * Remove a listener for IPC messages.
   *
   * @param {any} cb Callback to be removed
   */
  ipcOff (cb) {
    const handler = cb._handler
    if (!handler) return
    const index = this.ipcHandlers.indexOf(handler)
    if (index !== -1) this.ipcHandlers.splice(index, 1)
    ipc.off(handler)
  }

  /**
   * Send an IPC message to all connected IPC clients.
   *
   * @param {any} data Message data
   */
  ipcSend (data) {
    ipc.send(data)
  }

  /**
   * Get the local DB instance (lowdb)
   *
   * @readonly
   */
  get db () {
    return this.context.db
  }

  /**
   * Display a notification in the user OS
   * @param {object} options Notification options
   */
  notify (options) {
    try {
      validateNotify(options)
      notify(options)
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'notify' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Indicates if a specific plugin is used by the project
   * @param {string} id Plugin id or short id
   */
  hasPlugin (id) {
    return this.plugins.some(p => matchesPluginId(id, p.id))
  }

  /**
   * Display the progress screen.
   *
   * @param {object} options Progress options
   */
  setProgress (options) {
    if (this.lightMode) return
    try {
      validateProgress(options)
      progress.set({
        ...options,
        id: '__plugins__'
      }, this.context)
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'setProgress' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Remove the progress screen.
   */
  removeProgress () {
    progress.remove('__plugins__', this.context)
  }

  /**
   * Get current working directory.
   */
  getCwd () {
    return this.cwd
  }

  /**
   * Resolves a file relative to current working directory
   * @param {string} file Path to file relative to project
   */
  resolve (file) {
    return path.resolve(this.cwd, file)
  }

  /**
   * Get currently open project
   */
  getProject () {
    return this.project
  }

  /* Namespaced */

  /**
   * Retrieve a Shared data instance.
   *
   * @param {string} id Id of the Shared data
   * @returns {SharedData} Shared data instance
   */
  getSharedData (id) {
    return sharedData.get({ id, projectId: this.project.id }, this.context)
  }

  /**
   * Set or update the value of a Shared data
   *
   * @param {string} id Id of the Shared data
   * @param {any} value Value of the Shared data
   * @param {SetSharedDataOptions} options
   */
  async setSharedData (id, value, { disk = false } = {}) {
    return sharedData.set({ id, projectId: this.project.id, value, disk }, this.context)
  }

  /**
   * Delete a shared data.
   *
   * @param {string} id Id of the Shared data
   */
  async removeSharedData (id) {
    return sharedData.remove({ id, projectId: this.project.id }, this.context)
  }

  /**
   * Watch for a value change of a shared data
   *
   * @param {string} id Id of the Shared data
   * @param {function} handler Callback
   */
  watchSharedData (id, handler) {
    sharedData.watch({ id, projectId: this.project.id }, handler)
  }

  /**
   * Delete the watcher of a shared data.
   *
   * @param {string} id Id of the Shared data
   * @param {function} handler Callback
   */
  unwatchSharedData (id, handler) {
    sharedData.unwatch({ id, projectId: this.project.id }, handler)
  }

  /**
   * Listener triggered when a Plugin action is called from a client addon component.
   *
   * @param {string} id Id of the action to listen
   * @param {any} cb Callback (ex: (params) => {} )
   */
  onAction (id, cb) {
    let list = this.actions.get(id)
    if (!list) {
      list = []
      this.actions.set(id, list)
    }
    list.push(cb)
  }

  /**
   * Call a Plugin action. This can also listened by client addon components.
   *
   * @param {string} id Id of the action
   * @param {object} params Params object passed as 1st argument to callbacks
   * @returns {Promise}
   */
  callAction (id, params) {
    const plugins = require('../connectors/plugins')
    return plugins.callAction({ id, params }, this.context)
  }

  /**
   * Retrieve a value from the local DB
   *
   * @param {string} id Path to the item
   * @returns Item value
   */
  storageGet (id) {
    return this.db.get(id).value()
  }

  /**
   * Store a value into the local DB
   *
   * @param {string} id Path to the item
   * @param {any} value Value to be stored (must be serializable in JSON)
   */
  storageSet (id, value) {
    log('Storage set', id, value)
    this.db.set(id, value).write()
  }

  /**
   * Add a suggestion for the user.
   *
   * @param {object} options Suggestion
   */
  addSuggestion (options) {
    if (this.lightMode) return
    try {
      validateSuggestion(options)
      suggestions.add(options, this.context)
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'addSuggestion' options are invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid options: ${e.message}`))
    }
  }

  /**
   * Remove a suggestion
   *
   * @param {string} id Id of the suggestion
   */
  removeSuggestion (id) {
    suggestions.remove(id, this.context)
  }

  /**
   * Register a widget for project dashboard
   *
   * @param {object} def Widget definition
   */
  registerWidget (def) {
    if (this.lightMode) return
    try {
      validateWidget(def)
      this.widgetDefs.push({
        ...def,
        pluginId: this.pluginId
      })
    } catch (e) {
      logs.add({
        type: 'error',
        tag: 'PluginApi',
        message: `(${this.pluginId || 'unknown plugin'}) 'registerWidget' widget definition is invalid\n${e.message}`
      }, this.context)
      console.error(new Error(`Invalid definition: ${e.message}`))
    }
  }

  /**
   * Request a route to be displayed in the client
   */
  requestRoute (route) {
    app.requestRoute(route, this.context)
  }

  /**
   * Create a namespaced version of:
   *   - getSharedData
   *   - setSharedData
   *   - onAction
   *   - callAction
   *
   * @param {string} namespace Prefix to add to the id params
   */
  namespace (namespace) {
    return {
      /**
       * Retrieve a Shared data instance.
       *
       * @param {string} id Id of the Shared data
       * @returns {SharedData} Shared data instance
       */
      getSharedData: (id) => this.getSharedData(namespace + id),
      /**
       * Set or update the value of a Shared data
       *
       * @param {string} id Id of the Shared data
       * @param {any} value Value of the Shared data
       * @param {SetSharedDataOptions} options
       */
      setSharedData: (id, value, options) => this.setSharedData(namespace + id, value, options),
      /**
       * Delete a shared data.
       *
       * @param {string} id Id of the Shared data
       */
      removeSharedData: (id) => this.removeSharedData(namespace + id),
      /**
       * Watch for a value change of a shared data
       *
       * @param {string} id Id of the Shared data
       * @param {function} handler Callback
       */
      watchSharedData: (id, handler) => this.watchSharedData(namespace + id, handler),
      /**
       * Delete the watcher of a shared data.
       *
       * @param {string} id Id of the Shared data
       * @param {function} handler Callback
       */
      unwatchSharedData: (id, handler) => this.unwatchSharedData(namespace + id, handler),
      /**
       * Listener triggered when a Plugin action is called from a client addon component.
       *
       * @param {string} id Id of the action to listen
       * @param {any} cb Callback (ex: (params) => {} )
       */
      onAction: (id, cb) => this.onAction(namespace + id, cb),
      /**
       * Call a Plugin action. This can also listened by client addon components.
       *
       * @param {string} id Id of the action
       * @param {object} params Params object passed as 1st argument to callbacks
       * @returns {Promise}
       */
      callAction: (id, params) => this.callAction(namespace + id, params),
      /**
       * Retrieve a value from the local DB
       *
       * @param {string} id Path to the item
       * @returns Item value
       */
      storageGet: (id) => this.storageGet(namespace + id),
      /**
       * Store a value into the local DB
       *
       * @param {string} id Path to the item
       * @param {any} value Value to be stored (must be serializable in JSON)
       */
      storageSet: (id, value) => this.storageSet(namespace + id, value),
      /**
       * Add a suggestion for the user.
       *
       * @param {object} options Suggestion
       */
      addSuggestion: (options) => {
        options.id = namespace + options.id
        return this.addSuggestion(options)
      },
      /**
       * Remove a suggestion
       *
       * @param {string} id Id of the suggestion
       */
      removeSuggestion: (id) => this.removeSuggestion(namespace + id),
      /**
       * Register a widget for project dashboard
       *
       * @param {object} def Widget definition
       */
      registerWidget: (def) => {
        def.id = namespace + def.id
        return this.registerWidget(def)
      }
    }
  }
}

module.exports = PluginApi

如上所示,就是一些供插件使用的方法和一些数组,以及一个订阅发布模式。

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

推荐阅读更多精彩内容