wepack从0开始配置vue环境之四: vuessr渲染

github传送门
webpack之一webpack基础配置
webpack之二webpack部署优化
webpack之三集成vuex和vue-Router

  • 新建webpack配置文件用于配置ssr

  1. 新建/build/webpack.config.server.js:
  2. 新建入口文件/client/server-entry.js
  3. 在配置文件中指定entry的文件为server-entery.js
  4. 在配置文件中指定output.libraryTarget = 'commonjs2' // 指定模块导出方式, output.path = ''指定一个新的目录
  5. 添加externals: Object.keys(require('..package.json').dependencies), 声明不要打包某些文件[]; 在node端运行, 不需要在单独打包libs文件到js文件里, 直接通过require()方式, 就直接可以调用node_modules里的模块
  6. 使用extract-text-webpack-plugin的方式去写css-loader
  7. 在webpack.definePlugin()里添加VUE_ENV = server变量
  8. 安装vue-server-renderer到生产环境, 在server配置中引入server-plugin, 在plugins里添加这个插件
const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const VueServerPlugin = require('vue-server-renderer/server-plugin') // 有了这个插件, 打包输出的会是json文件
const baseConfig = require('./webpack.config.base')

const isDev = process.env.NODE_ENV === 'development'

let config

config = merge(baseConfig, {
  target: 'node', // 定义打包出来的js的执行环境
  entry: path.join(__dirname, '../client/server-entry.js'),
  output: {
    libraryTarget: 'commonjs2',
    filename: 'server-entry.js',
    path: path.join(__dirname, '../server-build')
  },
  externals: Object.keys(require('../package.json').dependencies),
  devtool: 'source-map', //ssr用source-map
  module: {
    rules: [{
      test: /\.styl$/,
      use: ExtractTextPlugin.extract({
        fallback: 'vue-style-loader',
        use: [
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              sourceMap: true
            }
          },
          'stylus-loader'
        ]
      })
    }]
  },
  plugins: [
    new ExtractTextPlugin('style.[contenthash:8].css'),
    new webpack.DefinePlugin({
      'process.env.NODE_ENv': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"' // ssr官方规定
    }),
    new VueServerPlugin()
  ]
})


module.exports = config
  • 使用koa实现node server

  1. 安装koa到生产环境, 新建/server/server.js
  2. 编写server.js入口代码:koa可以用try-catch在最外层捕捉到错误
  3. 安装koa-router到生产环境
  4. /server/routers/ssr.js和/server/routers/dev-ssr.js
  5. 编写开发环境逻辑. 安装axios到生产环境, 安装memory-fs到开发环境
  6. 引入webpack.config.server.js配置, 使用webpack()方法执行打包, 实例化一个memoryFs, 将webpack打包输出道内存 outputRileSystem = mfs
  7. 使用watch({}, (err, stats) => {})方法监听每一次webpack打包, 并获取打包的文件
  8. 创建handleSSR中间件, 处理打包出来的bundle
  9. 新建/server/server.template.ejs, 显示bundle的数据, 安装ejs模块
  10. 用fs读取template.ejs里的内容
  11. 使用axios去向webpack-dev-server去请求一个json文件, 从而实现在devServer和nodeServer间建立联系
  12. 修改webpack.config.client.js添加vue-server-renderer, 生成vue-ssr-client-manifest.json
const Router = require('koa-router')
const axios = require('axios')
const path = require('path')
const fs = require('fs')
// 在内存里操作文件, 提高效率, 只用在开发环境
const MemoryFs = require('memory-fs')
// 直接在nodejs里打包代码
const webpack = require('webpack')
const VueServerRenderer = require('vue-server-renderer')
// 引入server-render.js
const serverRender = require('./server-render')

// 引入webpack配置文件
const serverConfig = require('../../build/webpack.config.server')
// 在node环境下执行打包命令, 这个serverCompiler可以调用run()和watch()方法
const serverCompiler = webpack(serverConfig)
// 实例化一个mfs
const mfs = new MemoryFs()
// 指定webpack打包的输出目录在内存里
serverCompiler.outputFileSystem = mfs

let bundle // 用来记录webpack每次打包出来的文件

serverCompiler.watch({}, (err, stats) => {
  if (err) throw err
  stats = stats.toJson()
  stats.errors.forEach(err => console.log(err))
  stats.warnings.forEach(warn => console.warn(err))

  const bundlePath = path.join(
    serverConfig.output.path,
    'vue-ssr-server-bundle.json' // vue-server-renderer默认生成的json名
  )
  bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))
  console.log('new bundle completed')
})

const handleSSR = async (ctx) => {
  if (!bundle) {
    ctx.body = "稍定一会"
    return
  }

  const clientManifestRes = await axios.get(
    'http://127.0.0.1:8001/public/vue-ssr-client-manifest.json'
  )

  const clientManifest = clientManifestRes.data

  const template = fs.readFileSync(path.join(__dirname, '../server.template.ejs'), 'utf-8')

  const renderer = VueServerRenderer.createBundleRenderer(bundle, {
    inject: false,
    clientManifest
  })

  await serverRender(ctx, renderer, template)
}

const router = new Router()

router.get('*', handleSSR)

module.exports = router

  • ssr server-entry.js配置

  1. 新建/server/routers/server-render.js , 导出一个带ctx, renderer, template参数的方法
const ejs = require('ejs')
module.exports = async (ctx, renderer, template) => {
  // 声明我们给前边的是html文档
  ctx.headers['Content-Type'] = 'text/html'
  const context = {
    url: ctx.path
  }
  try {
    const appString = await renderer.renderToString(context)
    const html = ejs.render(template, {
      appString,
      style: context.renderStyles(),
      script: context.renderScript()
    })
    ctx.body  = html
  } catch (err) {
    console.log('render err', err)
    throw err
  }
}
  1. 新建/client/create-app.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Vuex from 'vuex'

import App from './app.vue'
import createStore from './store/store'
import createRouter from './config/router'

import '@assets/css/reset.styl'

Vue.use(Vuex)
Vue.use(VueRouter)

export default () => {
  // 每次都要返回一个新的store和router
  const store = createStore()
  const router = createRouter()

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })

  return {app, router, store}
}
  1. 新建server-entry.js并配置
import createApp from './create-app'

export default context => {
  return new Promise((resolve, reject) => {
    const {app, router} = createApp()

    console.log(context.url, 'server-entry')
    router.push(context.url)
    // 路由跳转后, 所有的异步操作都完成后执行
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject(new Error('no componet matched'))
      }
      resolve(app)
    })
  })
}

  • 开发环境静态资源处理

  1. 修改webpack.config.base.js里的output.public = 'http:127.0.0.1:8001/public/'
  2. 处理favicon.ico, 安装koa-send到生产环境, 在server.js里处理favicon.ico
  3. 使用nodemon自动重启node服务安装到开发环境, 新建/nodemon.json并配置
{
  "restartable": "rs",
  "ignore": [
    ".git",
    "node_modules/**/node_modules",
    ".eslint",
    "client",
    "build/webpack.config.client.js",
    "public"
  ],
  "verbose": true,
  "env": {
    "NODE_ENV": "development"
  },
  "ext": "js json ejs"
}
// script里修改为 nodemon server/server.js
  1. 安装concurrently 同时启动两个服务, 安装在生产环境
"script": {
  "dev":  "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
}
  • 使用vue-meta处理页面元信息

  1. 安装vue-meta 到生产环境, 在入口文件中引入并, Vue.use()一下
  2. 在组件里添加metaInfo: {} , 在选项里边写meta元信息
  3. ssr需要client这边的入口文件做依稀配合, 新建/client/client-entry.js并配置
import createApp from './create-app'

const {app, router} = createApp()

router.onReady(() => {
  app.$mount('#root')
})
  1. 修改webpack.config.client.js文件的entry
  2. 在服务器端添加meta信息,更新server-entry.jsvue-meta文档
 context.meta = app.$meta()
// 服务器端
const {title} = context.meta.$inject()
{
  title: title.text()
}
  • 生产环境ssr配置

  1. 在package.json的script添加build:server命令, 添加build命令
"build:client": "cross-env NODE_ENV=prodution webpack --config build/webpack.config.client.js",
"build:server": "cross-env NODE_ENV=prodution webpack --config build/webpack.config.server.js",
"build": "npm run clean && npm run build:client && npm run build:server",
  1. 使用webpack.optimize.UglifyJsPlugin()报错, 安装使用uglifyjs-webpack-plugin
  2. 编写ssr.js, 因为生产环境都是把代码打包好的, 所有, 逻辑很简单
const Router = require('koa-router')
const path = require('path')
const fs = require('fs')
const VueServerRenderer = require('vue-server-renderer')
const serverRender = require('./server-render')

const clientManifest = require('../../public/vue-ssr-client-manifest.json')

const renderer = VueServerRenderer.createBundleRenderer(
  path.join(__dirname, '../../server-build/vue-ssr-server-bundle.json'),
  {
    inject: false,
    clientManifest
  }
)

const template = fs.readFileSync(path.join(__dirname, '../server.template.ejs'), 'utf-8')

const router = new Router()

router.get('*', async (ctx) => {
  await serverRender(ctx, renderer, template)
})

module.exports = router
  1. 解决静态资源路径问题, 修改webpack.config.client.js的output.publicPath = '/public/', 修改webpack.config.base.js的output.path = '../public'
  2. 新建/server/routers/static.js -> 使用koa-send 配置将public设置成静态目录
const Router = require('koa-router')
const send = require('koa-send')

const staticRouter = new Router({prefix: '/public'})

staticRouter.get('/*', async (ctx) => {
  await send(ctx, ctx.path)
})

module.exports = staticRouter
  • 服务器api请求实现
  1. 创建/server/db/db.js, 编写连接云数据库代码, 并封装代理接口
const axios = require('axios')
const sha1 = require('sha1')

const className = 'test'

const request = axios.create({
  baseURL: `https://d.apicloud.com/mcm/api`
})

const createError = (code, res) => {
  const err = new Error(res.message)
  err.code = code
  return err
}

const handleRequest = ({data, status, ...rest}) => {
  if (status === 200) {
    return data
  } else {
    throw createError(status, rest)
  }
}

module.exports = (appId, appKey) => {
  const getHeaders = () => {
    const now = Date.now()
    // SHA1(应用ID + 'UZ' + 应用KEY +'UZ' + 当前时间毫秒数)+ '.' +当前时间毫秒数
    return {
      "X-APICloud-AppId": appId,
      "X-APICloud-AppKey": `${sha1(`${appId}UZ${appKey}UZ${now}`)}.${now}`
    }
  }
  return {
    async getAll () {
      return handleRequest(await request.get(`/${className}`, {
        headers: getHeaders()
      }))
    },
    async addOne (content) {
      return handleRequest(await request.post(
        `/${className}`,
        content,
        {headers: getHeaders()}
      ))
    },
    async getOne (id) {
      return handleRequest(await request.get(
        `/${className}/${id}`,
        {headers: getHeaders()}
      ))
    },
    async update (id, content) {
      return handleRequest(await request.put(
        `/${className}/${id}`,
        content,
        {headers: getHeaders()}
      ))
    },
    async delOne (id) {
      return handleRequest(await request.delete(`/${className}/${id}`, {
        headers: getHeaders()
      }))
    },
    async delAll (ids) {
      const requests = ids.map(id => {
        return {
          method: 'DELETE',
          path: `/${className}/${id}`
        }
      })
      return handleRequest(await request.post(
        '/batch',
        {requests},
        {headers: getHeaders()}
      ))
    }
  }
}
  1. 写个koa中间件, 把云数据库接口绑定到ctx上
const createDb = require('./db/db')
const dbConfig = require('../app.config').db
// console.log(dbConfig)
const db = createDb(dbConfig.appId, dbConfig.appKey)

app.use(async (ctx, next) => {
  ctx.db = db
  await next()
})
  1. 在/server/routes/api.js里根据代理接口封装koa接口
const Router = require('koa-router')

const apiRouter = new Router({prefix: '/api'})

const validateUser = async (ctx, next) => {
  if (!ctx.session.user) {
    ctx.status = 401
    ctx.body = 'need login'
  } else {
    await next()
  }
}
// 做用户登录验证
apiRouter.use(validateUser)

const handleSucc = data => {
  return {
    succ: true,
    data
  }
}

apiRouter
  .post('/add', async (ctx) => {
    const content = ctx.request.body
    const data = await ctx.db.addOne(content)
    console.log(data)
    ctx.body = handleSucc(data)
  })
  .get('/one/:id', async (ctx) => {
    const id = ctx.params.id
    const data = await ctx.db.getOne(id)
    ctx.body = handleSucc(data)
  })
  .get('/all', async (ctx) => {
    const data = await ctx.db.getAll()
    ctx.body = handleSucc(data)
  })
  .put('/update/:id', async (ctx) => {
    const id = ctx.params.id
    const content = ctx.request.body
    console.log(id, content)
    const data = await ctx.db.update(id, content)
    ctx.body = handleSucc(data)
  })
  .delete('/del/:id', async (ctx) => {
    const id = ctx.params.id
    const data = await ctx.db.delOne(id)
    ctx.body = handleSucc(data)
  })
  .post('/delall', async (ctx) => {
    const ids = ctx.request.body.ids
    const data = await ctx.db.delAll(ids)
    ctx.body = handleSucc(data)
  })

module.exports = apiRouter

  1. 封装登录接口1. 安装koa-session -S并配置指定app.keys,
// 配置koa-session
app.keys =['vue ssr kay']
app.use(koaSession({
  key: 'user-session-id',
  maxAge: 2*60*60*1000
}, app))
  1. 在业务代码中使用axios请求koa接口
    -- 创建/client/model/client-model.js和util.js, 封装接口, 需注意: 服务器返回的错误在axios走的是catch, 对于401报错需要单独处理, catch()里的错误信息, 储存在err.response里拿到status后reject()出去
// createError()函数封装
export const createError = (code, msg) => {
  const err = new Error(msg)
  err.code = code
  return err
}
import axios from 'axios'

import {createError} from './util'

const request = axios.create({
  baseURL: '/'
})


const handleRequest = (request) => {
  return new Promise((resolve, reject) => {
    request.then(res => {
      const data = res.data
      if (!data) {
        return reject(createError(400, 'no data'))
      }
      if (!data.succ) {
        return reject(createError(400, data.message))
      }
      resolve(data.data)
    }).catch(err => { // 服务器包里(ctx.status)报的错会走axios的catch
      // axios的错误信息放在err.response里
      // console.log(err, err.response)
      const errRes = err.response
      if (errRes.status === 401) {
        reject(createError(401, errRes.data))
      }
    })
  })
}

module.exports = {
  getAll () {
    return handleRequest(request.get('/api/all'))
  }
}
  1. 添加actions, 调用api, 在跳转时为了解耦actions和router使用bus派发事件在入口文件里,监听事件并跳转登录页面
import model from '../../model/client-model'
import bus from '../../bus/bus'

const handleError = err => {
  if (err.code === 401) {
    console.log(err.message)
    bus.$emit('login')
  }
}

export default {
  updateCountAsync (store, count) {
    setTimeout(() => {
      store.commit('updateCount', count)
    }, 1000)
  },
  fetchAll ({commit}) {
    model.getAll()
      .then(data => {
        commit('allArticles', data)
      })
      .catch(err => {
        // console.log(err.code)
        handleError(err)
      })
  }
}
// 入口文件 client-entry.js
import createApp from './create-app'
import bus from './bus/bus'

const {app, router} = createApp()

bus.$on('login', () => {
  router.replace('/login')
})

router.onReady(() => {
  app.$mount('#root')
})
  1. 在clent-model.js中完善login接口, 然后更改 actions -> mutations -> state -> login.vue写登录业务代码, 所有接口都是这样实现的
  2. 数据请求的时候使用全局loading
    -- 编写loading.vue组件
    -- 在跟组件app.vue中引入loading组件
    -- 在store中声明一个控制loading显示隐藏的字段loading默认false
    -- 在mutations里声明一个startLoading 和 stopLoading
    -- 在actions.js中, 请求数据前用startLoading, 成功或失败的时候, stopLoading
// app.vue



// 在state中添加
export default {
  count: 0,
  articles: [],
  user: {},
  loading: false
}

// 在mutations添加
 startLoading (state) {
    state.loading = true
  },
  stopLoading (state) {
    state.loading = false
  }
// 
  • 服务器端渲染获取数据

? 问题: 客户端调通后, 切换到服务器渲染, 发现请求回来的数据并没有加入到html结构里, 所有的数据都是js渲染的, 爬虫爬不到, 还有首屏渲染数据的复用
解决问题:
思考1 :首先考虑ssr时候如何拿到数据1, 数据请求在mounted中, 而在服务器端, 是不会执行到mouted的, 就那不到数据
浏览器端有同域的概念, 所以写个 '/' 会自动加上域名的端口, 而服务器端没有同域名所有就不能只写个 '/'

  1. 在请求页面声明一个asyncData()方法, 通过getMatchedCompoents()[返回组件实例的数组]获取到asyncData(), 并传参
  asyncData ({route, store}) {
    store.dispatch('fetchAll')
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(123)
      }, 2000)
    })
  },
  1. 解决数据请求问题, 可以通过修改axios.create(basnURL), 自己想自己发请求方式实现, 但是没法拿到cookie, 必须通过renderToString()方法比较复杂
  2. 使用服务器端的db方法, 直接向apicloud请求数据, 配置webpack.config.client.js和webpack.config.server.js的resolve.alias, 设置不同环境不同的路径
  3. 在node端的db.js使用async和await,需要另行配置.babelrc
{
  "presets":[
    "stage-1"
  ],
  "plugins": [
    "transform-vue-jsx",
    "syntax-dynamic-import"
  ],
  "env": {
    "browser": {
      "presets": [
        [
          "env",
          {
            "targets": {
              "browsers": ["last 2 versions", "safari >= 7"]
            }
          }
        ]
      ]
    },
    "node": {
      "presets": [
        "env",
        {
          "targets": {
            "node": "current"
          }
        }
      ]
    }
  }
}

  1. 配置server-entry.js
import createApp from './create-app'

export default context => {
  return new Promise((resolve, reject) => {
    const {app, router, store} = createApp()

    console.log(context.url, 'server-entry')
    router.push(context.url)
    // 路由跳转后, 所有的异步操作都完成后执行
    router.onReady(() => {
      // 可以通过router.getMatchedComponents() 获取到匹配的组件实例
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject(new Error('no componet matched'))
      }
      Promise.all(matchedComponents.map(component => {
        // 通过匹配到的实例, 可以调用实例的任何属性和方法
        // 调用asyncData(), 还可以传参数
        if (component.asyncData) {
          return component.asyncData({
            route: router.currentRoute,
            store
          })
        }
      })).then(data => {
        console.log(store.state)
        context.meta = app.$meta()
        resolve(app)
      })
      // 这里的resolve(app) 要等获取玩数据在resolve()
      // context.meta = app.$meta()
      // resolve(app)
    })
  })
}
  • 前后端数据复用和Server端用户认证

  1. 在客户端的client的context添加属性state=store.state,服务端拿到的store数据用, renderToString()完成后, 会把client中的store放到context.renderState()上返回的是一个

你可能感兴趣的:(wepack从0开始配置vue环境之四: vuessr渲染)