【第二期】建立 @vue/cli3 插件,並整合 ssr 功能 ----【SSR第二篇】

在上一篇文章《基於 @vue/cli3 與 koa 建立 ssr 工程》中,咱們講解了如何基於 @vue/cli3 建立一個 ssr 工程。javascript

在本篇文章,咱們來建立一個 @vue/cli3 插件,並將第一篇文章中 ssr 工程的服務器端部分整合進插件中。css

首先,咱們來看一個 cli3 插件提供了那些功能:html

  1. 使用腳手架建立一個新工程或在一個既有工程安裝並執行插件後,生成自定義的工程文件
  2. 基於 @vue/cli-service 提供統一的自定義命令,例如: vue-cli-service ssr:build

除了上述兩個功能外,咱們還但願在插件內部整合服務端邏輯,這樣對於多個接入插件的工程項目能實行統一的管理,方便後續統一增長日誌、監控等功能。前端

建立插件 npm 庫

官方對於發佈一個 cli3 的插件作了以下限制vue

爲了讓一個 CLI 插件可以被其它開發者使用,你必須遵循 vue-cli-plugin-<name> 的命名約定將其發佈到 npm 上。插件遵循命名約定以後就能夠:java

  • @vue/cli-service 發現;
  • 被其它開發者搜索到;
  • 經過 vue add <name>vue invoke <name> 安裝下來。

所以,咱們新建並初始化一個工程 createPluginExample,並將工程的 name 命名爲 vue-cli-plugin-my_ssr_plugin_demonode

mkdir createPluginExample && cd createPluginExample && yarn init
複製代碼

package.json 的內容爲:webpack

{
  "name": "vue-cli-plugin-my_ssr_plugin_demo",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}
複製代碼

建立插件 npm 庫的模板內容

官方對於第一個插件功能,引入了一個叫作 Generator 的機制來實現web

Generator 有兩種展示形式:generator.jsgenerator/index.jsvue-router

generator.jsgenerator/index.js 的內容導出一個函數,這個函數接收三個參數,分別是:

  1. 一個 GeneratorAPI 實例
  2. 這個插件的 generator 選項
  3. 整個 preset 的內容

關於 preset ,咱們能夠將其看做是:將手動建立一個工程項目過程當中,經過會話選擇的自定義選項內容保存下來的預設文件

例如:

module.exports = (api, options, rootOptions) => {
  // 修改 `package.json` 裏的字段
  api.extendPackage({
    scripts: {
      test: 'vue-cli-service test'
    }
  })

  // 複製並用 ejs 渲染 `./template` 內全部的文件
  api.render('./template')

  if (options.foo) {
    // 有條件地生成文件
  }
}
複製代碼

以及兩種安裝方式:

  1. 在使用腳手架建立一個新項目時,插件被設置在對應的 preset 中被安裝
  2. 在一個既有的項目中,經過 vue invoke 獨立調用時被安裝

Generator 容許在文件夾 generator 中建立一個叫作 template 的文件夾

若是在 generator/index.js 中顯式調用了 api.render('./template')

那麼 generator 將會使用 EJS 渲染 ./template 中的文件,並替換工程中根目錄下對應的文件。

由於咱們的 ssr 工程項目須要對默認的 spa 工程中的部分文件作一些改造(詳見上一篇文章《基於 @vue/cli3 與 koa 建立 ssr 工程》

因此在這裏咱們選擇 generator/index.js 這種展示形式。

並在 generator 中建立文件夾 template 並將第一篇文章中已經改造好的文件放置在 ./template 文件夾中。

此時,咱們的 createPluginExample 工程目錄結構以下:

.
├── generator
│   ├── index.js
│   └── template
│       ├── src
│       │   ├── App.vue
│       │   ├── components
│       │   │   └── HelloWorld.vue
│       │   ├── entry-client.js
│       │   ├── entry-server.js
│       │   ├── main.js
│       │   ├── router
│       │   │   ├── index.js
│       │   ├── store
│       │   │   ├── index.js
│       │   │   └── modules
│       │   │       └── book.js
│       │   └── views
│       │       ├── About.vue
│       │       └── Home.vue
│       └── vue.config.js
└── package.json
複製代碼

接下來讓咱們看 generator/index.js 中的內容

定製插件安裝過程

咱們須要在 generator/index.js 作三件事情:

  1. 按照 ssr 工程模式自定義工程的 package.json 的內容
  2. 執行 api.render('./template') 觸發 generator 使用 EJS 渲染 generator/template/ 中的文件,並替換工程中根目錄下對應的文件
  3. 在工程建立完畢後,執行一些收尾工做

關於第一件事情

首先咱們須要在建立工程項目後,自動建立好基於 ssr 的一些命令,好比服務器端構建 ssr:build,開發環境啓動 ssr 服務 dev

其次,咱們還須要在建立工程項目後,自動安裝好 ssr 依賴的某些第三方工具,例如:concurrently

第二件事件比較簡單,咱們這裏直接按照官方文檔寫就能夠。

關於第三件事情:

  • 由於默認的 spa 工程會在 src 下生成 router.jsstore.js 這些文件,而插件在安裝過程當中不會刪除掉這些文件,所以咱們須要在工程安裝完畢後,清理這些文件。
  • 另外,由於咱們後面會將服務器端的邏輯整合到插件內部,所以像服務器端構建 ssr:build 命令就須要在產品環境下執行了,所以咱們須要將咱們的插件 vue-cli-plugin-my_ssr_plugin_demo, 以及 @vue/cli-plugin-babel@vue/cli-service, 由 devDependencies 中移動到 dependencies 中。
  • 最後,還記得咱們在第一篇文章《基於 @vue/cli3 與 koa 建立 ssr 工程》中的 public/index.ejs 麼,由於這個文件自己就是 ejs 格式的,因此在插件安裝過程當中渲染 generator/template 文件夾中的內容時,會影響到它,因此咱們將其放在 generator/ 文件夾下,在安裝過程結束後,將其複製到工程的 public

最終,generator/index.js 的內容以下:

const shell = require('shelljs')
const chalk = require('chalk')

module.exports = (api, options, rootOptions) => {
  // 修改 `package.json` 裏的字段
  api.extendPackage({
    scripts: {
    'serve': 'vue-cli-service serve',
    'ssr:serve': 'NODE_ENV=development TARGET_NODE=node PORT=3000 CLIENT_PORT=8080 node ./app/server.js',
    'dev': 'concurrently \'npm run serve\' \'npm run ssr:serve\'',
    'build': 'vue-cli-service build && TARGET_NODE=node vue-cli-service build --no-clean --silent',
    'start': 'NODE_ENV=production TARGET_NODE=node PORT=3000 node ./node_modules/vue-cli-plugin-my_ssr_plugin_demo/app/server.js'
    },
    dependencies: {
    },
    devDependencies: {
      'concurrently': '^4.1.0'
    }
  })

  // 複製並用 ejs 渲染 `./template` 內全部的文件
  api.render('./template', Object.assign({ BASE_URL: '' }, options))

  api.onCreateComplete(() => {
    shell.cd(api.resolve('./'))

    shell.exec('cp ./node_modules/vue-cli-plugin-my_ssr_plugin_demo/generator/tmp.ejs ./public/index.ejs')
    shell.exec('rm ./public/index.html')
    shell.exec('rm ./public/favicon.ico')

    const routerFile = './src/router.js'
    const storeFile = './src/store.js'

    console.log(chalk.green('\nremove the old entry file of vue-router and vuex'))
    shell.exec(` echo \n\n if [ -f ${routerFile} ];then rm -f ${routerFile} fi if [ -f ${storeFile} ];then rm -f ${storeFile} fi `)

    let packageJson = JSON.parse(shell.exec('cat ./package.json', { silent: true }).stdout)
    const needToMove = [
      '@vue/cli-plugin-babel',
      '@vue/cli-service',
      'vue-cli-plugin-my_ssr_plugin_demo'
    ]

    needToMove.forEach(name => {
      if (!packageJson.devDependencies[name]) return
      packageJson.dependencies[name] = packageJson.devDependencies[name]
      delete packageJson.devDependencies[name]
    })

    console.log(chalk.green(`move the ${needToMove.join(',')} from devDependencies to dependencies`))
    shell.exec(`echo '${JSON.stringify(packageJson, null, 2)}' > ./package.json`)
  })
}
複製代碼

接下來咱們來看服務器端部分

整合服務器端邏輯

在第一篇文章中,咱們將服務器端的邏輯都存放在 app/ 文件夾中

app
├── middlewares
│   ├── dev.ssr.js
│   ├── dev.static.js
│   └── prod.ssr.js
└── server.js
複製代碼

咱們只須要將此文件夾複製到插件工程的根目錄下,而後在根目錄下建立一個名爲 index.js 的文件。

index.js 文件中,咱們會作以下三件事情:

  1. vue.config.js 中的配置整合進插件中,也就是 index.js 中提供的 api.chainWebpack 內部,這樣作的好處是安裝此插件的工程項目沒必要再關心 ssr 相關的 webpack 配置細節
  2. 提供開發環境啓動 ssr 服務的命令: ssr:serve
  3. 提供產品環境構建 ssr 服務 bundle 的命令: ssr:build

當調用 vue-cli-service <command> [...args] 時會建立一個叫作 Service 的插件。

Service 插件負責管理內部的 webpack 配置、暴露服務和構建項目的命令等, 它屬於插件的一部分。

一個 Service 插件導出一個函數,這個函數接收兩個參數:

  1. 一個 PluginAPI 實例
  2. 一個包含 vue.config.js 內指定的項目本地選項的對象

Service 插件針對不一樣的環境擴展/修改內部的 webpack 配置,並向 vue-cli-service 注入額外的命令,例如:

module.exports = (api, projectOptions) => {
  api.chainWebpack(webpackConfig => {
    // 經過 webpack-chain 修改 webpack 配置
  })

  api.configureWebpack(webpackConfig => {
    // 修改 webpack 配置
    // 或返回經過 webpack-merge 合併的配置對象
  })

  api.registerCommand('test', args => {
    // 註冊 `vue-cli-service test`
  })
}
複製代碼

在這裏,咱們將第一篇中的 vue.config.js 中的內容移到 index.js 中的 api.chainWebpack

const get = require('lodash.get')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const merge = require('lodash.merge')

module.exports = (api, projectOptions) => {
  const clientPort = get(projectOptions, 'devServer.port', 8080)

  api.chainWebpack(config => {
    const TARGET_NODE = process.env.WEBPACK_TARGET === 'node'
    const DEV_MODE = process.env.NODE_ENV === 'development'

    if (DEV_MODE) {
      config.devServer.headers({ 'Access-Control-Allow-Origin': '*' }).port(clientPort)
    }

    config
      .entry('app')
      .clear()
      .add('./src/entry-client.js')
      .end()
      // 爲了讓服務器端和客戶端可以共享同一份入口模板文件
      // 須要讓入口模板文件支持動態模板語法(這裏選擇了 TJ 的 ejs)
      .plugin('html')
      .tap(args => {
        return [{
          template: './public/index.ejs',
          minify: {
            collapseWhitespace: true
          },
          templateParameters: {
            title: 'spa',
            mode: 'client'
          }
        }]
      })
      .end()
      // Exclude unprocessed HTML templates from being copied to 'dist' folder.
      .when(config.plugins.has('copy'), config => {
        config.plugin('copy').tap(([[config]]) => [
          [
            {
              ...config,
              ignore: [...config.ignore, 'index.ejs']
            }
          ]
        ])
      })
      .end()

    // 默認值: 當 webpack 配置中包含 target: 'node' 且 vue-template-compiler 版本號大於等於 2.4.0 時爲 true。
    // 開啓 Vue 2.4 服務端渲染的編譯優化以後,渲染函數將會把返回的 vdom 樹的一部分編譯爲字符串,以提高服務端渲染的性能。
    // 在一些狀況下,你可能想要明確的將其關掉,由於該渲染函數只能用於服務端渲染,而不能用於客戶端渲染或測試環境。
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        })
      })

    config.plugins
      // Delete plugins that are unnecessary/broken in SSR & add Vue SSR plugin
      .delete('pwa')
      .end()
      .plugin('vue-ssr')
      .use(TARGET_NODE ? VueSSRServerPlugin : VueSSRClientPlugin)
      .end()

    if (!TARGET_NODE) return

    config
      .entry('app')
      .clear()
      .add('./src/entry-server.js')
      .end()
      .target('node')
      .devtool('source-map')
      .externals(nodeExternals({ whitelist: /\.css$/ }))
      .output.filename('server-bundle.js')
      .libraryTarget('commonjs2')
      .end()
      .optimization.splitChunks({})
      .end()
      .plugins.delete('named-chunks')
      .delete('hmr')
      .delete('workbox')
  })
複製代碼

接下來讓咱們建立開發環境啓動 ssr 服務的命令: ssr:serve

const DEFAULT_PORT = 3000

...

  api.registerCommand('ssr:serve', {
    description: 'start development server',
    usage: 'vue-cli-service ssr:serve [options]',
    options: {
      '--port': `specify port (default: ${DEFAULT_PORT})`
    }
  }, args => {
    process.env.WEBPACK_TARGET = 'node'

    const port = args.port || DEFAULT_PORT

    console.log(
      '[SSR service] will run at:' +
      chalk.blue(` http://localhost:${port}/ `)
    )

    shell.exec(` PORT=${port} \ CLIENT_PORT=${clientPort} \ CLIENT_PUBLIC_PATH=${projectOptions.publicPath} \ node ./node_modules/vue-cli-plugin-my_ssr_plugin_demo/app/server.js \ `)
  })

...

module.exports.defaultModes = {
  'ssr:serve': 'development' // 爲 ssr:serve 指定開發環境模式
}
複製代碼

最後,咱們建立產品環境構建 ssr 服務 bundle 的命令: ssr:build

const onCompilationComplete = (err, stats) => {
  if (err) {
    console.error(err.stack || err)
    if (err.details) console.error(err.details)
    return
  }

  if (stats.hasErrors()) {
    stats.toJson().errors.forEach(err => console.error(err))
    process.exitCode = 1
  }

  if (stats.hasWarnings()) {
    stats.toJson().warnings.forEach(warn => console.warn(warn))
  }
}

...

  api.registerCommand('ssr:build', args => {
    process.env.WEBPACK_TARGET = 'node'

    const webpackConfig = api.resolveWebpackConfig()
    const compiler = webpack(webpackConfig)

    compiler.run(onCompilationComplete)

    shell.exec('node ./node_modules/vue-cli-plugin-my_ssr_plugin_demo/bin/initRouter.js')
  })

...

module.exports.defaultModes = {
  'ssr:build': 'production', // 爲 ssr:build 指定產品環境模式
  'ssr:serve': 'development'
}
複製代碼

最終,完整的 index.js 內容以下:

const webpack = require('webpack')
const get = require('lodash.get')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const merge = require('lodash.merge')
const shell = require('shelljs')
const chalk = require('chalk')

const DEFAULT_PORT = 3000

const onCompilationComplete = (err, stats) => {
  if (err) {
    console.error(err.stack || err)
    if (err.details) console.error(err.details)
    return
  }

  if (stats.hasErrors()) {
    stats.toJson().errors.forEach(err => console.error(err))
    process.exitCode = 1
  }

  if (stats.hasWarnings()) {
    stats.toJson().warnings.forEach(warn => console.warn(warn))
  }
}

module.exports = (api, projectOptions) => {
  const clientPort = get(projectOptions, 'devServer.port', 8080)

  api.chainWebpack(config => {
    const TARGET_NODE = process.env.WEBPACK_TARGET === 'node'
    const DEV_MODE = process.env.NODE_ENV === 'development'

    if (DEV_MODE) {
      config.devServer.headers({ 'Access-Control-Allow-Origin': '*' }).port(clientPort)
    }

    config
      .entry('app')
      .clear()
      .add('./src/entry-client.js')
      .end()
      // 爲了讓服務器端和客戶端可以共享同一份入口模板文件
      // 須要讓入口模板文件支持動態模板語法(這裏選擇了 TJ 的 ejs)
      .plugin('html')
      .tap(args => {
        return [{
          template: './public/index.ejs',
          minify: {
            collapseWhitespace: true
          },
          templateParameters: {
            title: 'spa',
            mode: 'client'
          }
        }]
      })
      .end()
      // Exclude unprocessed HTML templates from being copied to 'dist' folder.
      .when(config.plugins.has('copy'), config => {
        config.plugin('copy').tap(([[config]]) => [
          [
            {
              ...config,
              ignore: [...config.ignore, 'index.ejs']
            }
          ]
        ])
      })
      .end()

    // 默認值: 當 webpack 配置中包含 target: 'node' 且 vue-template-compiler 版本號大於等於 2.4.0 時爲 true。
    // 開啓 Vue 2.4 服務端渲染的編譯優化以後,渲染函數將會把返回的 vdom 樹的一部分編譯爲字符串,以提高服務端渲染的性能。
    // 在一些狀況下,你可能想要明確的將其關掉,由於該渲染函數只能用於服務端渲染,而不能用於客戶端渲染或測試環境。
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        })
      })

    config.plugins
      // Delete plugins that are unnecessary/broken in SSR & add Vue SSR plugin
      .delete('pwa')
      .end()
      .plugin('vue-ssr')
      .use(TARGET_NODE ? VueSSRServerPlugin : VueSSRClientPlugin)
      .end()

    if (!TARGET_NODE) return

    config
      .entry('app')
      .clear()
      .add('./src/entry-server.js')
      .end()
      .target('node')
      .devtool('source-map')
      .externals(nodeExternals({ whitelist: /\.css$/ }))
      .output.filename('server-bundle.js')
      .libraryTarget('commonjs2')
      .end()
      .optimization.splitChunks({})
      .end()
      .plugins.delete('named-chunks')
      .delete('hmr')
      .delete('workbox')
  })

  api.registerCommand('ssr:build', args => {
    process.env.WEBPACK_TARGET = 'node'

    const webpackConfig = api.resolveWebpackConfig()
    const compiler = webpack(webpackConfig)

    compiler.run(onCompilationComplete)

    shell.exec('node ./node_modules/vue-cli-plugin-my_ssr_plugin_demo/bin/initRouter.js')
  })

  api.registerCommand('ssr:serve', {
    description: 'start development server',
    usage: 'vue-cli-service ssr:serve [options]',
    options: {
      '--port': `specify port (default: ${DEFAULT_PORT})`
    }
  }, args => {
    process.env.WEBPACK_TARGET = 'node'

    const port = args.port || DEFAULT_PORT

    console.log(
      '[SSR service] will run at:' +
      chalk.blue(` http://localhost:${port}/ `)
    )

    shell.exec(` PORT=${port} \ CLIENT_PORT=${clientPort} \ CLIENT_PUBLIC_PATH=${projectOptions.publicPath} \ node ./node_modules/vue-cli-plugin-my_ssr_plugin_demo/app/server.js \ `)
  })
}

module.exports.defaultModes = {
  'ssr:build': 'production',
  'ssr:serve': 'development'
}
複製代碼

完整的 vue-cli-plugin-my_ssr_plugin_demo 目錄結構以下:

.
├── app
│   ├── middlewares
│   │   ├── dev.ssr.js
│   │   ├── dev.static.js
│   │   └── prod.ssr.js
│   └── server.js
├── generator
│   ├── index.js
│   └── template
│       ├── src
│       │   ├── App.vue
│       │   ├── components
│       │   │   └── HelloWorld.vue
│       │   ├── entry-client.js
│       │   ├── entry-server.js
│       │   ├── main.js
│       │   ├── router
│       │   │   ├── index.js
│       │   ├── store
│       │   │   ├── index.js
│       │   │   └── modules
│       │   │       └── book.js
│       │   └── views
│       │       ├── About.vue
│       │       └── Home.vue
│       └── vue.config.js
├── index.js
└── package.json
複製代碼

至此,咱們的 vue-cli-plugin-my_ssr_plugin_demo 插件就基本完成了

使用建立好的插件來初始化 ssr 工程

咱們使用腳手架建立一個新的 spa 工程

vue create myproject
複製代碼

而後在工程內部安裝插件

vue add vue-cli-plugin-my_ssr_plugin_demo
複製代碼

安裝完畢後,咱們就完成了 ssr 工程的初始化

在下一篇文章中,咱們重點來說如何基於咱們的 vue-cli-plugin-my_ssr_plugin_demo 插件,集成日誌系統


水滴前端團隊招募夥伴,歡迎投遞簡歷到郵箱:fed@shuidihuzhu.com

相關文章
相關標籤/搜索