在 Laradock 中開發 Vue 專案

Clusteramaryllis發表於2019-07-26

新增本地域名

# /etc/hosts
127.0.0.1 rua.rua

客戶端渲染

Nginx 配置

新增 nginx 站點:

# ~/laradock/nginx/sites/rua.conf
server {

    listen 80;
    listen [::]:80;

    server_name rua.rua;
    charset utf-8;

    location / {
      proxy_pass http://workspace:3000;
      proxy_set_header Host $host;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_connect_timeout 60;
      proxy_read_timeout 600;
      proxy_send_timeout 600;
    }

    error_log /var/log/nginx/rua_error.log;
    access_log /var/log/nginx/rua_access.log;
}

重建 nginx 容器:

# ~/laradock
docker-compose down
docker-compose build nginx
# 重啟 nginx  和 workspace 容器
docker-compose up -d nginx workspace

進入 laradock 中

# 進入 laradock 目錄
cd ~/Development/web/laradock

docker-compose exec --user=laradock workspace bash

安裝 vue-cli3

yarn global add @vue/cli

初始化 vue 專案

# 建立 vue 專案
vue create project
# 根據提示選擇需要安裝的外掛

修改 webpack 配置

在vue-cli3建立的vue專案,已經沒有了之前的webpack.base.conf.js、webpack.dev.conf.js、webpack.prod.conf.js。那麼如何進行webpack的配置呢?
在vue-cli官網上也說明了如何使用。
調整 webpack 配置最簡單的方式就是在 vue.config.js 中的 configureWebpack 選項提供一個物件,該物件將會被 webpack-merge 合併入最終的 webpack 配置。

在專案根目錄下,新建一個vue.config.js

// ./vue.config.js
module.exports = {
    devServer: {
    public: 'http://rua.rua',
    disableHostCheck: true,
    port: 3000,
    watchOptions: {
      poll: 1000 // enable polling since fsevents are not supported in docker
    }
  }
}

執行專案:

yarn run serve
# 訪問 http://rua.rua 即可訪問到 vue 專案

服務端渲染

更新 Laradock 配置

開放 WorkSpace 8080埠

開放 8080 埠以便於訪問 webpack-dev-server 服務實現程式碼熱更新(HMR)。

# ~/laradock/docker-compose.yaml

workspace:
  ports:
    - "8080:8080"

重建 WorkSpace 容器

docker-compose down
docker-compose build workspace
# 重啟 nginx  和 workspace 容器
docker-compose up -d nginx workspace

安裝依賴

# dependencies
yarn add cross-env koa koa-mount koa-router koa-send koa-static lodash vue-server-renderer axios

# devDependencies
yarn add -D webpack-node-externals memory-fs concurrently

改造入口檔案

// ./src/main.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'

Vue.config.productionTip = false

export function createApp () {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })
  return { app, router }
}

建立 ./src/entry-client.js./src/entry-server.js 兩個檔案。

// ./src/entry-client.js
import { createApp } from './main'

const { app, router } = createApp()

router.onReady(() => {
  app.$mount('#app')
})
// ./src/entry-server.js
import { createApp } from './main'

export default context => {
  // 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise,
  // 以便伺服器能夠等待所有的內容在渲染前,
  // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    // 設定伺服器端 router 的位置
    router.push(context.url)

    // 等到 router 將可能的非同步元件和鉤子函式解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,執行 reject 函式,並返回 404
      if (!matchedComponents.length) {
        return reject(new Error('no components matched'))
      }

      resolve(app)
    }, reject)
  })
}

改造 vue-router

// ./src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/views/Home.vue'

Vue.use(Router)

export function createRouter () {
  return new Router({
    mode: 'history', // 一定要是history模式
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
      }
    ]
  })
}

改造 vuex

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    state: {

    },
    mutations: {

    },
    actions: {

    }
  })
}

修改webpack配置

// ./vue.config.js
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 TARGET_NODE = process.env.WEBPACK_TARGET === 'node'
const target = TARGET_NODE ? 'server' : 'client'
const isDev = process.env.NODE_ENV !== 'production'

module.exports = {
  publicPath: isDev ? 'http://127.0.0.1:8080' : 'http://rua.rua',
  devServer: {
    public: 'http://127.0.0.1:8080',
    historyApiFallback: true,
    disableHostCheck: true,
    host: '0.0.0.0',
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    watchOptions: {
      poll: 1000 // enable polling since fsevents are not supported in docker
    }
  },
  css: {
    extract: process.env.NODE_ENV === 'production'
  },
  configureWebpack: () => ({
    // 將 entry 指向應用程式的 server / client 檔案
    entry: `./src/entry-${target}.js`,
    // 對 bundle renderer 提供 source map 支援
    devtool: 'source-map',
    target: TARGET_NODE ? 'node' : 'web',
    node: TARGET_NODE ? undefined : false,
    output: {
      libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
    },
    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // 外接化應用程式依賴模組。可以使伺服器構建速度更快,
    // 並生成較小的 bundle 檔案。
    externals: TARGET_NODE
      ? nodeExternals({
        // 不要外接化 webpack 需要處理的依賴模組。
        // 你可以在這裡新增更多的檔案型別。例如,未處理 *.vue 原始檔案,
        // 你還應該將修改 `global`(例如 polyfill)的依賴模組列入白名單
        whitelist: [/\.css$/]
      })
      : undefined,
    optimization: {
      splitChunks: TARGET_NODE ? false : undefined
    },
    plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  }),
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        return merge(options, {
          optimizeSSR: false
        })
      })

    // fix ssr hot update bug
    if (TARGET_NODE) {
      config.plugins.delete("hmr");
    }
  }
}

服務端編碼

專案結構

./rua
|____app
| |____server.js
| |____dev.ssr.js
| |____prod.ssr.js
|____public
| |____index.template.html
|____...

index.template.html

<!-- ./public/index.template.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="/favicon.ico">
    <title>{{ title }}</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

server.js

// ./app/server.js
const Koa = require('koa')
const koaStatic = require('koa-static')
const koaMount = require('koa-mount')
const path = require('path')

const resolve = file => path.resolve(__dirname, file)
const app = new Koa()

const isDev = process.env.NODE_ENV !== 'production'
const router = isDev ? require('./dev.ssr') : require('./prod.ssr')

app.use(router.routes()).use(router.allowedMethods())
// 開放目錄
app.use(koaMount('/dist', koaStatic(resolve('../dist'))))
app.use(koaMount('/public', koaStatic(resolve('../public'))))

const port = process.env.PORT || 3000

app.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})

module.exports = app

dev.ssr.js

// ./app/dev.ssr.js
const webpack = require('webpack')
const axios = require('axios')
const MemoryFS = require('memory-fs')
const fs = require('fs')
const path = require('path')
const send = require('koa-send')
const Router = require('koa-router')
// 1、webpack配置檔案
const webpackConfig = require('@vue/cli-service/webpack.config')
const { createBundleRenderer } = require('vue-server-renderer')

// 2、編譯webpack配置檔案
const serverCompiler = webpack(webpackConfig)
const mfs = new MemoryFS()
// 指定輸出到的記憶體流中
serverCompiler.outputFileSystem = mfs

// 3、監聽檔案修改,實時編譯獲取最新的 vue-ssr-server-bundle.json
let bundle
serverCompiler.watch({}, (err, stats) =>{
  if (err) {
    throw err
  }
  stats = stats.toJson()
  stats.errors.forEach(error => console.error(error) )
  stats.warnings.forEach( warn => console.warn(warn) )
  const bundlePath = path.join(
    webpackConfig.output.path,
    'vue-ssr-server-bundle.json'
  )
  bundle = JSON.parse(mfs.readFileSync(bundlePath,'utf-8'))
  console.log('new bundle generated')
})

const handleRequest = async ctx => {
  if (! bundle) {
    ctx.body = '等待webpack打包完成後在訪問在訪問'
    return
  }

  const url = ctx.path
  if (url.includes('favicon.ico')){
    console.log(`proxy ${url}`)
    return await send(ctx, url, { root: path.resolve(__dirname, '../public') })
  }

  // 4、獲取最新的 vue-ssr-client-manifest.json
  const clientManifestResp = await axios.get('http://localhost:8080/vue-ssr-client-manifest.json')
  const clientManifest = clientManifestResp.data

  const renderer = createBundleRenderer(bundle, {
    runInNewContext: false,
    template: fs.readFileSync(path.resolve(__dirname, '../public/index.template.html'), 'utf-8'),
    clientManifest: clientManifest
  })

  const context = {
    title: 'Rua',
    url
  }

  const html = await renderToString(context, renderer)
  ctx.body = html
}

const renderToString = (context, renderer) => {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}

const router = new Router()

router.get('*', handleRequest)

module.exports = router

prod.ssr.js

// ./app/prod.ssr.js
const fs = require('fs')
const path = require('path')
const Router = require('koa-router')
const send = require('koa-send')
const router = new Router()

const resolve = file => path.resolve(__dirname, file)

const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json')

const renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  template: fs.readFileSync(resolve('../dist/index.template.html'), 'utf-8'),
  clientManifest: clientManifest
})

const renderToString = (context) => {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}

// 第 3 步:新增一箇中介軟體來處理所有請求
const handleRequest = async (ctx, next) => {

  const url = ctx.path

  if (url.includes('.')) {
    return await send(ctx, url, {root: path.resolve(__dirname,'../dist')})
  }

  ctx.res.setHeader('Content-Type', 'text/html')
  const context = {
    title: 'Rua',
    url
  }

  // 將 context 資料渲染為 HTML
  const html = await renderToString(context)
  ctx.body = html
}

router.get('*', handleRequest)

module.exports = router

新增 packjson.json 指令碼

{
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "test:e2e": "vue-cli-service test:e2e",
    "test:unit": "vue-cli-service test:unit",
    "build:client": "vue-cli-service build",
    "build:server": "cross-env NODE_ENV=production WEBPACK_TARGET=node HOST=0.0.0.0 vue-cli-service build",
    "build:win": "yarn run build:server && move dist\\vue-ssr-server-bundle.json bundle && yarn run build:client && move bundle dist\\vue-ssr-server-bundle.json && cross-env WEBPACK_TARGET=node NODE_ENV=production node ./server/ssr.js",
    "build:mac": "yarn run build:server && mv dist/vue-ssr-server-bundle.json bundle && yarn run build:client && mv bundle dist/vue-ssr-server-bundle.json",
    "start": "cross-env NODE_ENV=production node ./app/server.js",
    "dev:serve": "cross-env WEBPACK_TARGET=node node ./app/server.js",
    "dev": "concurrently --raw \"yarn run serve\" \"yarn run dev:serve\" "
  }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章