神思者_伊祎

神思者_伊祎 查看完整档案

北京编辑天津理工大学  |  机械设计制造及自动化 编辑  |  填写所在公司/组织 none.com 编辑
编辑

hold ? fish : palm;

个人动态

神思者_伊祎 回答了问题 · 10月26日

react-native 中,怎么将得到的图片uri转换为base64的编码?

最近也遇到这个问题了,看了很多资料好像并没有什么直接的操作方法,项目时间紧迫,没时间仔细研究,就用很笨拙的方式,暂时满足了这个需求,用react-native-view-shot截屏的方式,获取到了当前图片的临时截图路径url,再通过RNFS.readFile转换成了base64的图片,效率并不高,具体代码如下:
APP内的js文件中html图片的展示(将image标签用ViewShot括起来,方便获取截屏区域):

<ViewShot ref="viewShot">
  <Image
    onLoadStart={() => {this.setSize(this.state.imgUrl)}}
    onLayout={() => {this.setSize(this.state.imgUrl)}}
    style={{
      width: this.state.imgW,
      height: this.state.imgH,
    }}
    source={{uri: this.state.imgUrl, isStatic: true}}
  />
</ViewShot>

具体转换操作js代码:

this.refs.viewShot.capture().then(url => {
  RNFS.readFile(url, 'base64').then(res => {
    console.log(res) //当前的res就是获取到的最终的base64格式
  })
});

楼主若有更好的解决方案,望分享

关注 5 回答 5

神思者_伊祎 关注了用户 · 7月16日

Peter谭金杰 @jerrytanjinjie

前端架构师

微信公众号:前端巅峰

欢迎技术探讨~

个人微信:CALASFxiaotan

关注 4233

神思者_伊祎 回答了问题 · 2019-08-28

nuxt 想把路由指向static中存放的静态html应该怎么做

楼主,问题有结果了吗?

关注 4 回答 3

神思者_伊祎 收藏了文章 · 2019-08-16

Vue项目SSR改造实战

博客已全站升级到https,如果遇到无法访问,请手动加上https://前缀

我们先看“疗效”,你可以打开我的博客u3xyz.com,通过查看源代码来看SSR直出效果。我的博客已经快上线一年了,但不吹不黑,访问量非常地小,我也一直在想办法提升访问量(包括在sf写文章,哈哈)。当然,在PC端,搜索引擎一直都是一个重要的流量来源。这里就不得不提到SEO。下图是我的博客以前在百度的快照:

SSR前快照

细心的朋友会发现,这个快照非常简单,简单到几乎什么都没有。这也是没办法的事,博客是基于Vue的SPA页面,整个项目本来就是一个“空架子”,这个快照从博客2月份上线以来就一直是上面的样子,直到最近上线SSR。搜索引擎蜘蛛每次来抓取你的网站都是一个样子,慢慢得,它也就不会来了,相应的,网站的权重,排名肯定不会好。到目前为此,我的博客不用网址进行搜索都搜不到。在上线了SSR后,再加上一些SEO优化,百度快照终于更新了:

SSR后快照

为什么要做SSR

文章开始基本已经回答了为什么要做SSR这个问题,当然,还有另一个原因是SSR概念现在在前端非常火,无奈在实际项目中没有机会,也只有拿博客来练手了。下面将详细介绍本博客项目SSR全过程。

SSR改造实战

总的来说SSR改造还是相当容易的。推荐在动手之前,先了解官方文档官方Vue SSR Demo,这会让我们事半功倍。

1. 构建改造

VueSSR原理

上图是Vue官方的SSR原理介绍图片。从这张图片,我们可以知道:我们需要通过Webpack打包生成两份bundle文件:

  • Client Bundle,给浏览器用。和纯Vue前端项目Bundle类似
  • Server Bundle,供服务端SSR使用,一个json文件

不管你项目先前是什么样子,是否是使用vue-cli生成的。都会有这个构建改造过程。在构建改造这里会用到 vue-server-renderer 库,这里要注意的是 vue-server-renderer 版本要与Vue版本一样。下图是我的构建文件目录:

构建

  • util.js 提供一些公共方法
  • webpack.base.js是公共的配置
  • webpack.client.js 是生成Client Bundle的配置。核心配置如下:
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

// ...

const config = merge(baseConfig, {
  target: 'web',
  entry: './src/entry.client.js',
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"client"'
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vender',
      minChunks: 2
    }),
    // extract webpack runtime & manifest to avoid vendor chunk hash changing
    // on every build.
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest'
    }),
    new VueSSRClientPlugin()
  ]
})
  • webpack.server.js 是生成Server Bundle的配置,核心配置如下:
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

// ...

const config = merge(baseConfig, {
  target: 'node',
  devtool: '#source-map',
  entry: './src/entry.server.js',
  output: {
    libraryTarget: 'commonjs2',
    filename: 'server-bundle.js'
  },
  externals: nodeExternals({
    // do not externalize CSS files in case we need to import it from a dep
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
  ]
})

2. 代码改造

2.1 必须使用VueRouter, Vuex。ajax库建议使用axios

可能你的项目没有使用VueRouter或Vuex。但遗憾的是,Vue-SSR必须基于 Vue + VueRouter + Vuex。Vuex官方没有提,但其实文档和Demo都是基于Vuex。我的博客以前也没有用Vuex,但经过一翻折腾后,还是乖乖加上了Vuex。另外,因为代码要能同时在浏览器和Node.js环境中运行,所以ajax库建议使用axios这样的跨平台库。

2.2 两个打包入口(entry),重构app, store, router, 为每个对象增加工厂方法createXXX

每个用户通过浏览器访问Vue页面时,都是一个全新的上下文,但在服务端,应用启动后就一直运行着,处理每个用户请求的都是在同一个应用上下文中。为了不串数据,需要为每次SSR请求,创建全新的app, store, router

项目目录

上图是我的项目文件目录。

  • app.js, 通用的启动Vue应用代码
  • App.vue,Vue应用根组件
  • entry.client.js,浏览器环境入口
  • entry.server.js,服务器环境入口
  • index.html,html模板

再看一下具体实现的核心代码:

// app.js

import Vue from 'vue'
import App from './App.vue' // 根组件
import {createRouter} from './routers/index' 
import {createStore} from './vuex/store'
import {sync} from 'vuex-router-sync' // 把当VueRouter状态同步到Vuex中

// createApp工厂方法
export function createApp (ssrContext) {
  let router = createRouter() // 创建全新router实例
  let store = createStore() // 创建全新store实例

  // 同步路由状态到store中
  sync(store, router)
  
  // 创建Vue应用
  const app = new Vue({
    router,
    store,
    ssrContext,
    render: h => h(App)
  })
  return {app, router, store}
}
// entry.client.js 

import Vue from 'vue'
import { createApp } from './app'

const { app, router, store } = createApp()

// 如果有__INITIAL_STATE__变量,则将store的状态用它替换
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    
  // 通过路由勾子,执行拉取数据逻辑
  router.beforeResolve((to, from, next) => {
    // 找到增量组件,拉取数据 
    const matched = router.getMatchedComponents(to) 
    const prevMatched = router.getMatchedComponents(from) 
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })
    // 组件数据通过执行asyncData方法获取
    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }
    // 要注意asyncData方法要返回promise,asyncData调用的vuex action也必须返回promise
    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
      .then(() => {
        next()
      })
      .catch(next)
  })

  // 将Vue实例挂载到dom中,完成浏览器端应用启动
  app.$mount('#app')
})
// entry.server.js
import { createApp } from './app'

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

    // 设置路由
    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }

      // 执行asyncData方法,预拉取数据
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store: store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // 将store的快照挂到ssr上下文上
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}
// createStore

import Vue from 'vue'
import Vuex from 'vuex'
// ...

Vue.use(Vuex)

// createStore工厂方法
export function createStore () {
  return new Vuex.Store({
    // rootstate
    state: {
      appName: 'appName',
      title: 'home'
    },

    modules: {
      // ...
    },

    strict: process.env.NODE_ENV !== 'production' // 线上环境关闭store检查
  })
}
// createRouter

import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)

// createRouter工厂方法
export function createRouter () {
  return new Router({
    mode: 'history', // 注意这里要使用history模式,因为hash不会发送到服务端
    fallback: false,
    routes: [
      {
        path: '/index',
        name: 'index',
        component: () => System.import('./index/index.vue') // 代码分片
      },
      {
        path: '/detail/:aid',
        name: 'detail',
        component: () => System.import('./detail/detail.vue')
      },
      // ...
      {
        path: '/',
        redirect: '/index'
      }
    ]
  })
}

3. 重构组件获取数据方式

关于状态管理,要严格遵守Redux思想。建议把应用所有状态都存于store中,组件使用时再mapState下来,状态更改严格使用action的方式。另一个要提一点的是,action要返回promise。这样我们就可以使用asyncData方法获取组件数据了

const actions = {
  getArticleList ({state, commit}, curPageNum) {
    commit(FETCH_ARTICLE_LIST, curPageNum)

    // action 要返回promise
    return apis.getArticleList({
      data: {
        size: state.pagi.itemsPerPage,
        page: curPageNum
      }
    }).then((res) => {
      // ...
    })
  }
}

// 组件asyncData实现
export default {
  asyncData ({ store }) {
    return store.dispatch('getArticleList', 1)
  }
}

3. SSR服务器实现

在完成构建和代码改造后,如果一切顺利。我们能得到下面的打包文件:

dist文件

这时,我们可以开始实现SSR服务端代码了。下面是我博客SSR实现(基于Koa)

// server.js
const Koa = require('koa')
const path = require('path')
const logger = require('./logger')
const server = new Koa()
const { createBundleRenderer } = require('vue-server-renderer')
const templateHtml = require('fs').readFileSync(path.resolve(__dirname, './index.template.html'), 'utf-8')

let distPath = './dist'

const renderer = createBundleRenderer(require(`${distPath}/vue-ssr-server-bundle.json`), { 
  runInNewContext: false,
  template: templateHtml, 
  clientManifest: require(`${distPath}/vue-ssr-client-manifest.json`) 
})

server.use(function * (next) {
  let ctx = this
  const context = { url: ctx.req.url, pageTitle: 'default-title' }

  // cgi请求,前端资源请求不能转到这里来。这里可以通过nginx做
  if (/\.\w+$/.test(context.url)) {
    return yield next
  }

  // 注意这里也必须返回promise  
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, function (err, html) {
      if (err) {
        logger.error(`[error][ssr-error]: ` + err.stack)
        return reject(err)
      }
      ctx.status = 200
      ctx.type = 'text/html; charset=utf-8'
      ctx.body = html
      resolve(html)
    })
  })
})

// 错误处理
server.on('error', function (err) {
  logger.error('[error][server-error]: ' + err.stack)
})

let port = 80

server.listen(port, () => {
  logger.info(`[info]: server is deploy on port: ${port}`)
})

4. 服务器部署

服务器部署,跟你的项目架构有关。比如我的博客项目在服务端有2个后端服务,一个数据库服务,nginx用于请求转发:

u3xyz架构

5. 遇到的问题及解决办法

加载不到组件的JS文件
[vue-router] Failed to resolve async component default: Error: Cannot find module 'js\main1.js'
[vue-router] uncaught error during route navigation:

解决办法:

去掉webpack配置中的output.chunkFilename: getFileName('js/main[name]-$hash.js')

if you are using CommonsChunkPlugin, make sure to use it only in the client config because the server bundle requires a single entry chunk.

所以对webpack.server.js不要对配置CommonsChunkPlugin,也不要设置output.chunkFilename

代码高亮codeMirror使用到navigator对象,只能在浏览器环境运行

把执行逻辑放到mounted回调中。实现不行,就封装一个异步组件,把组件的初始化放到mounted中:

mounted () {
  let paragraph = require('./paragraph.vue')
  Vue.component('paragraph', paragraph)
  new Vue().$mount('#paragraph')
},
串数据

dispatch的action没有返回promise,保证返回promise即可

路由跳转

路由跳转使用router方法或<router-link />标签,这两种方式能自适应浏览器端和服务端,不要使用a标签

小结

本文主要记录了我的博客u3xyz.comSSR过程:

  • 构建webpack改造
  • 代码改造
  • server端SSR实现
  • 上线部署

最后希望文章能对大家有些许帮助!

愿文地址:Vue项目SSR改造实战

查看原文

神思者_伊祎 收藏了文章 · 2019-08-16

带你五步学会Vue SSR

前言

SSR大家肯定都不陌生,通过服务端渲染,可以优化SEO抓取,提升首页加载速度等,我在学习SSR的时候,看过很多文章,有些对我有很大的启发作用,有些就只是照搬官网文档。通过几天的学习,我对SSR有了一些了解,也从头开始完整的配置出了SSR的开发环境,所以想通过这篇文章,总结一些经验,同时希望能够对学习SSR的朋友起到一点帮助。

我会通过五个步骤,一步步带你完成SSR的配置:

  1. 纯浏览器渲染
  2. 服务端渲染,不包含Ajax初始化数据
  3. 服务端渲染,包含Ajax初始化数据
  4. 服务端渲染,使用serverBundleclientManifest进行优化
  5. 一个完整的基于Vue + VueRouter + Vuex的SSR工程

如果你现在对于我上面说的还不太了解,没有关系,跟着我一步步向下走,最终你也可以独立配置一个SSR开发项目,所有源码我会放到github上,大家可以作为参考

正文

1. 纯浏览器渲染

这个配置相信大家都会,就是基于weback + vue的一个常规开发配置,这里我会放一些关键代码,完整代码可以去github查看。

目录结构
- node_modules
- components  
    - Bar.vue
    - Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
app.js
import Vue from 'vue';
import App from './App.vue';

let app = new Vue({
  el: '#app',
  render: h => h(App)
});
App.vue
<template>
  <div>
    <Foo></Foo>
    <Bar></Bar>
  </div>
</template>

<script>
import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';

export default {
  components: {
    Foo, Bar
  }
}
</script>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>纯浏览器渲染</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>
components/Foo.vue
<template>
  <div class="foo">
    <h1>Foo Component</h1>
  </div>
</template>

<style>
.foo {
  background: yellowgreen;
}
</style>
components/Bar.vue
<template>
  <div class="bar">
    <h1>Bar Component</h1>
  </div>
</template>

<style>
.bar {
  background: bisque;
}
</style>
webpack.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  mode: 'development',

  entry: './app.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
        // 如果需要单独抽出CSS文件,用下面这个配置
        // use: ExtractTextPlugin.extract({
        //   fallback: 'vue-style-loader',
        //   use: [
        //     'css-loader',
        //     'postcss-loader'
        //   ]
        // })
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: './index.html'
    }),
    // 如果需要单独抽出CSS文件,用下面这个配置
    // new ExtractTextPlugin("styles.css")
  ]
};
postcss.config.js
module.exports = {
  plugins: [
    require('autoprefixer')
  ]
};
.babelrc
{
  "presets": [
    "@babel/preset-env"
  ],
  "plugins": [
    // 让其支持动态路由的写法 const Foo = () => import('../components/Foo.vue')
    "dynamic-import-webpack"    
  ]
}
package.json
{
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build": "webpack"
  },
  "dependencies": {
    "vue": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "babel-plugin-dynamic-import-webpack": "^1.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9"
  }
}
命令
启动开发环境
yarn start
构建生产环境
yarn run build

最终效果截图:

纯浏览器渲染

完整代码查看github

2. 服务端渲染,不包含Ajax初始化数据

服务端渲染SSR,类似于同构,最终要让一份代码既可以在服务端运行,也可以在客户端运行。如果说在SSR的过程中出现问题,还可以回滚到纯浏览器渲染,保证用户正常看到页面。

那么,顺着这个思路,肯定就会有两个webpack的入口文件,一个用于浏览器端渲染weboack.client.config.js,一个用于服务端渲染webpack.server.config.js,将它们的公有部分抽出来作为webpack.base.cofig.js,后续通过webpack-merge进行合并。同时,也要有一个server来提供http服务,我这里用的是koa

我们来看一下新的目录结构:

- node_modules
- config    // 新增
    - webpack.base.config.js
    - webpack.client.config.js
    - webpack.server.config.js
- src
    - components  
        - Bar.vue
        - Foo.vue
    - App.vue
    - app.js
    - entry-client.js   // 新增
    - entry-server.js   // 新增
    - index.html
    - index.ssr.html    // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

在纯客户端应用程序(client-only app)中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)。

所以,我们要对app.js做修改,将其包装为一个工厂函数,每次调用都会生成一个全新的根组件。

app.js

import Vue from 'vue';
import App from './App.vue';

export function createApp() {
  const app = new Vue({
    render: h => h(App)
  });

  return { app };
}

在浏览器端,我们直接新建一个根组件,然后将其挂载就可以了。

entry-client.js

import { createApp } from './app.js';

const { app } = createApp();

app.$mount('#app');

在服务器端,我们就要返回一个函数,该函数的作用是接收一个context参数,同时每次都返回一个新的根组件。这个context在这里我们还不会用到,后续的步骤会用到它。

entry-server.js

import { createApp } from './app.js';

export default context => {
  const { app } = createApp();

  return app;
}

然后再来看一下index.ssr.html

index.ssr.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>服务端渲染</title>
</head>
<body>
  <!--vue-ssr-outlet-->

  <script type="text/javascript" data-original="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>

<!--vue-ssr-outlet-->的作用是作为一个占位符,后续通过vue-server-renderer插件,将服务器解析出的组件html字符串插入到这里。

<script type="text/javascript" data-original="<%= htmlWebpackPlugin.options.files.js %>"></script>是为了将webpack通过webpack.client.config.js打包出的文件放到这里(这里是为了简单演示,后续会有别的办法来做这个事情)。

因为服务端吐出来的就是一个html字符串,后续的Vue相关的响应式、事件响应等等,都需要浏览器端来接管,所以就需要将为浏览器端渲染打包的文件在这里引入。

用官方的词来说,叫客户端激活(client-side hydration)

所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:

// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性:

<div id="app" data-server-rendered="true">

Vue在浏览器端就依靠这个属性将服务器吐出来的html进行激活,我们一会自己构建一下就可以看到了。

接下来我们看一下webpack相关的配置:

webpack.base.config.js

const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  mode: 'development',

  resolve: {
    extensions: ['.js', '.vue']
  },

  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'
      },
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin()
  ]
};

webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});

注意,这里的入口文件变成了entry-client.js,将其打包出的client.bundle.js插入到index.html中。

webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});

这里有几个点需要注意一下:

  1. 入口文件是 entry-server.js
  2. 因为是打包服务器端依赖的代码,所以target要设为node,同时,outputlibraryTarget要设为commonjs2

这里关于HtmlWebpackPlugin配置的意思是,不要在index.ssr.html中引入打包出的server.bundle.js,要引为浏览器打包的client.bundle.js,原因前面说过了,是为了让Vue可以将服务器吐出来的html进行激活,从而接管后续响应。

那么打包出的server.bundle.js在哪用呢?接着往下看就知道啦~~

package.json

{
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build:client": "webpack --config config/webpack.client.config.js",
    "build:server": "webpack --config config/webpack.server.config.js"
  },
  "dependencies": {
    "koa": "^2.5.3",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0",
    "vue": "^2.5.17",
    "vue-server-renderer": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "style-loader": "^0.23.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9",
    "webpack-merge": "^4.1.4"
  }
}

接下来我们看server端关于http服务的代码:

server/server.js

const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

// 后端Server
backendRouter.get('/index', (ctx, next) => {
  // 这里用 renderToString 的 promise 返回的 html 有问题,没有样式
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
  });
});

backendApp.use(serve(path.resolve(__dirname, '../dist')));

backendApp
  .use(backendRouter.routes())
  .use(backendRouter.allowedMethods());

backendApp.listen(3000, () => {
  console.log('服务器端渲染地址: http://localhost:3000');
});


// 前端Server
frontendRouter.get('/index', (ctx, next) => {
  let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
  ctx.type = 'html';
  ctx.status = 200;
  ctx.body = html;
});

frontendApp.use(serve(path.resolve(__dirname, '../dist')));

frontendApp
  .use(frontendRouter.routes())
  .use(frontendRouter.allowedMethods());

frontendApp.listen(3001, () => {
  console.log('浏览器端渲染地址: http://localhost:3001');
});

这里对两个端口进行监听,3000端口是服务端渲染,3001端口是直接输出index.html,然后会在浏览器端走Vue的那一套,主要是为了和服务端渲染做对比使用。

这里的关键代码是如何在服务端去输出html`字符串。

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

可以看到,server.bundle.js在这里被使用啦,因为它的入口是一个函数,接收context作为参数(非必传),输出一个根组件app

这里我们用到了vue-server-renderer插件,它有两个方法可以做渲染,一个是createRenderer,另一个是createBundleRenderer

const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 选项 */ })
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })

createRenderer无法接收为服务端打包出的server.bundle.js文件,所以这里只能用createBundleRenderer

serverBundle 参数可以是以下之一:

  • 绝对路径,指向一个已经构建好的 bundle 文件(.js.json)。必须以 / 开头才会被识别为文件路径。
  • webpack + vue-server-renderer/server-plugin 生成的 bundle 对象。
  • JavaScript 代码字符串(不推荐)。

这里我们引入的是.js文件,后续会介绍如何使用.json文件以及有什么好处。

renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
});

使用createRenderercreateBundleRenderer返回的renderer函数包含两个方法renderToStringrenderToStream,我们这里用的是renderToString成功后直接返回一个完整的字符串,renderToStream返回的是一个Node流。

renderToString支持Promise,但是我在使用Prmoise形式的时候样式会渲染不出来,暂时还不知道原因,如果大家知道的话可以给我留言啊。

配置基本就完成了,来看一下如何运行。

yarn run build:client       // 打包浏览器端需要bundle
yarn run build:server       // 打包SSR需要bundle

yarn start      // 其实就是 node server/server.js,提供http服务

最终效果展示:

访问http://localhost:3000/index

SSR没有ajax数据

我们看到了前面提过的data-server-rendered="true"属性,同时会加载client.bundle.js文件,为了让Vue在浏览器端做后续接管。

访问http://localhost:3001/index还和第一步实现的效果一样,纯浏览器渲染,这里就不放截图了。

完整代码查看github

3. 服务端渲染,包含Ajax初始化数据

如果SSR需要初始化一些异步数据,那么流程就会变得复杂一些。

我们先提出几个问题:

  1. 服务端拿异步数据的步骤在哪做?
  2. 如何确定哪些组件需要获取异步数据?
  3. 获取到异步数据之后要如何塞回到组件内?

带着问题我们向下走,希望看完这篇文章的时候上面的问题你都找到了答案。

服务器端渲染和浏览器端渲染组件经过的生命周期是有区别的,在服务器端,只会经历beforeCreatecreated两个生命周期。因为SSR服务器直接吐出html字符串就好了,不会渲染DOM结构,所以不存在beforeMountmounted的,也不会对其进行更新,所以也就不存在beforeUpdateupdated等。

我们先来想一下,在纯浏览器渲染的Vue项目中,我们是怎么获取异步数据并渲染到组件中的?一般是在created或者mounted生命周期里发起异步请求,然后在成功回调里执行this.data = xxxVue监听到数据发生改变,走后面的Dom Diff,打patch,做DOM更新。

那么服务端渲染可不可以也这么做呢?答案是不行的

  1. mounted里肯定不行,因为SSR都没有mounted生命周期,所以在这里肯定不行。
  2. beforeCreate里发起异步请求是否可以呢,也是不行的。因为请求是异步的,可能还没有等接口返回,服务端就已经把html字符串拼接出来了。

所以,参考一下官方文档,我们可以得到以下思路:

  1. 在渲染前,要预先获取所有需要的异步数据,然后存到Vuexstore中。
  2. 在后端渲染时,通过Vuex将获取到的数据注入到相应组件中。
  3. store中的数据设置到window.__INITIAL_STATE__属性中。
  4. 在浏览器环境中,通过Vuexwindow.__INITIAL_STATE__里面的数据注入到相应组件中。

正常情况下,通过这几个步骤,服务端吐出来的html字符串相应组件的数据都是最新的,所以第4步并不会引起DOM更新,但如果出了某些问题,吐出来的html字符串没有相应数据,Vue也可以在浏览器端通过`Vuex注入数据,进行DOM更新。

更新后的目录结构:

- node_modules
- config
   - webpack.base.config.js
   - webpack.client.config.js
   - webpack.server.config.js
- src
   - components  
       - Bar.vue
       - Foo.vue
   - store             // 新增
       store.js
   - App.vue
   - app.js
   - entry-client.js
   - entry-server.js   
   - index.html
   - index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

先来看一下store.js:

store/store.js

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

Vue.use(Vuex);

const fetchBar = function() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('bar 组件返回 ajax 数据');
    }, 1000);
  });
};

function createStore() {
  const store = new Vuex.Store({
    state: {
      bar: ''
    },

    mutations: {
      'SET_BAR'(state, data) {
        state.bar = data;
      }
    },

    actions: {
      fetchBar({ commit }) {
        return fetchBar().then((data) => {
          commit('SET_BAR', data);
        }).catch((err) => {
          console.error(err);
        })
      }
    }
  });

  if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
  }
  
  return store;
}

export default createStore;

typeof window

如果不太了解Vuex,可以去Vuex官网先看一些基本概念。

vuex

这里fetchBar可以看成是一个异步请求,这里用setTimeout模拟。在成功回调中commit相应的mutation进行状态修改。

这里有一段关键代码:

if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
}

因为store.js同样也会被打包到服务器运行的server.bundle.js中,所以运行环境不一定是浏览器,这里需要对window做判断,防止报错,同时如果有window.__INITIAL_STATE__属性,说明服务器已经把所有初始化需要的异步数据都获取完成了,要对store中的状态做一个替换,保证统一。

components/Bar.vue

<template>
  <div class="bar">
    <h1 @click="onHandleClick">Bar Component</h1>
    <h2>异步Ajax数据:</h2>
    <span>{{ msg }}</span>
  </div>
</template>

<script>
  const fetchInitialData = ({ store }) => {
    store.dispatch('fetchBar');
  };

  export default {
    asyncData: fetchInitialData,

    methods: {
      onHandleClick() {
        alert('bar');
      }
    },

    mounted() {
      // 因为服务端渲染只有 beforeCreate 和 created 两个生命周期,不会走这里
      // 所以把调用 Ajax 初始化数据也写在这里,是为了供单独浏览器渲染使用
      let store = this.$store;
      fetchInitialData({ store });
    },

    computed: {
      msg() {
        return this.$store.state.bar;
      }
    }
  }
</script>

<style>
.bar {
  background: bisque;
}
</style>

这里在Bar组件的默认导出对象中增加了一个方法asyncData,在该方法中会dispatch相应的action,进行异步数据获取。

需要注意的是,我在mounted中也写了获取数据的代码,这是为什么呢? 因为想要做到同构,代码单独在浏览器端运行,也应该是没有问题的,又由于服务器没有mounted生命周期,所以我写在这里就可以解决单独在浏览器环境使用也可以发起同样的异步请求去初始化数据。

components/Foo.vue

<template>
  <div class="foo">
    <h1 @click="onHandleClick">Foo Component</h1>
  </div>
</template>

<script>
export default {
  methods: {
    onHandleClick() {
      alert('foo');
    }
  },
}
</script>

<style>
.foo {
  background: yellowgreen;
}
</style>

这里我对两个组件都添加了一个点击事件,为的是证明在服务器吐出首页html后,后续的步骤都会被浏览器端的Vue接管,可以正常执行后面的操作。

app.js

import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';

export function createApp() {
  const store = createStore();

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

  return { app, store, App };
}

在建立根组件的时候,要把Vuex的store传进去,同时要返回,后续会用到。

最后来看一下entry-server.js,关键步骤在这里:

entry-server.js

import { createApp } from './app.js';

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

    let components = App.components;
    let asyncDataPromiseFns = [];
  
    Object.values(components).forEach(component => {
      if (component.asyncData) {
        asyncDataPromiseFns.push(component.asyncData({ store }));
      }
    });
  
    Promise.all(asyncDataPromiseFns).then((result) => {
      // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
      context.state = store.state;
  
      console.log(222);
      console.log(store.state);
      console.log(context.state);
      console.log(context);
  
      resolve(app);
    }, reject);
  });
}

我们通过导出的App拿到了所有它下面的components,然后遍历,找出哪些componentasyncData方法,有的话调用并传入store,该方法会返回一个Promise,我们使用Promise.all等所有的异步方法都成功返回,才resolve(app)

context.state = store.state作用是,当使用createBundleRenderer时,如果设置了template选项,那么会把context.state的值作为window.__INITIAL_STATE__自动插入到模板html中。

这里需要大家多思考一下,弄清楚整个服务端渲染的逻辑。

如何运行:

yarn run build:client
yarn run build:server

yarn start

最终效果截图:

服务端渲染:打开http://localhost:3000/index

server-remder-ajax

可以看到window.__INITIAL_STATE__被自动插入了。

我们来对比一下SSR到底对加载性能有什么影响吧。

服务端渲染时performance截图

sercer_render_ajax_performance

纯浏览器端渲染时performance截图

client_render_ajax_performance

同样都是在fast 3G网络模式下,纯浏览器端渲染首屏加载花费时间2.9s,因为client.js加载就花费了2.27s,因为没有client.js就没有Vue,也就没有后面的东西了。

服务端渲染首屏时间花费0.8s,虽然client.js加载扔花费2.27s,但是首屏已经不需要它了,它是为了让Vue在浏览器端进行后续接管。

从这我们可以真正的看到,服务端渲染对于提升首屏的响应速度是很有作用的。

当然有的同学可能会问,在服务端渲染获取初始ajax数据时,我们还延时了1s,在这个时间用户也是看不到页面的。没错,接口的时间我们无法避免,就算是纯浏览器渲染,首页该调接口还是得调,如果接口响应慢,那么纯浏览器渲染看到完整页面的时间会更慢。

完整代码查看github

4. 使用serverBundle和clientManifest进行优化

前面我们创建服务端renderer的方法是:

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

serverBundle我们用的是打包出的server.bundle.js文件。这样做的话,在每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map。

vue-server-renderer 提供一个名为 createBundleRenderer 的 API,用于处理此问题,通过使用 webpack 的自定义插件,server bundle 将生成为可传递到 bundle renderer 的特殊 JSON 文件。所创建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下优点:

  • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map'
  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。
  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk

preloadprefetch有不了解的话可以自行查一下它们的作用哈。

那么我们来修改webpack配置:

webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new VueSSRClientPlugin(),   // 新增
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});

webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
   // 对 bundle renderer 提供 source map 支持
  devtool: '#source-map',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  externals: [nodeExternals()],     // 新增
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new VueSSRServerPlugin(),   // 这个要放到第一个写,否则 CopyWebpackPlugin 不起作用,原因还没查清楚
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});

因为是服务端引用模块,所以不需要打包node_modules中的依赖,直接在代码中require引用就好,所以配置externals: [nodeExternals()]

两个配置文件会分别生成vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json。作为createBundleRenderer的参数。

来看server.js

server.js

const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
});

效果和第三步就是一样的啦,就不截图了,完整代码查看github

5. 配置一个完整的基于Vue + VueRouter + Vuex的SSR

这里和第四步不一样的是引入了vue-router,更接近于实际开发项目。

src下新增router目录。

router/index.js

import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';

Vue.use(Router);

function createRouter() {
  const routes = [
    {
      path: '/bar',
      component: Bar
    },
    {
      path: '/foo',
      component: () => import('../components/Foo.vue')   // 异步路由
    }
  ];

  const router = new Router({
    mode: 'history',
    routes
  });

  return router;
}

export default createRouter;

这里我们把Foo组件作为一个异步组件引入,做成按需加载。

app.js中引入router,并导出:

app.js

import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';

export function createApp() {
  const store = createStore();
  const router = createRouter();

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

  return { app, store, router, App };
}

修改App.vue引入路由组件:

App.vue

<template>
  <div id="app">
    <router-link to="/bar">Goto Bar</router-link> 
    <router-link to="/foo">Goto Foo</router-link> 
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  beforeCreate() {
    console.log('App.vue beforeCreate');
  },

  created() {
    console.log('App.vue created');
  },

  beforeMount() {
    console.log('App.vue beforeMount');
  },

  mounted() {
    console.log('App.vue mounted');
  }
}
</script>

最重要的修改在entry-server.js中,

entry-server.js

import { createApp } from './app.js';

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

    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      console.log(context.url)
      console.log(matchedComponents)

      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      Promise.all(matchedComponents.map(component => {
        if (component.asyncData) {
          return component.asyncData({ store });
        }
      })).then(() => {
        // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
        context.state = store.state;

        // 返回根组件
        resolve(app);
      });
    }, reject);
  });
}

这里前面提到的context就起了大作用,它将用户访问的url地址传进来,供vue-router使用。因为有异步组件,所以在router.onReady的成功回调中,去找该url路由所匹配到的组件,获取异步数据那一套还和前面的一样。

于是,我们就完成了一个基本完整的基于Vue + VueRouter + VuexSSR配置,完成代码查看github

最终效果演示:

访问http://localhost:3000/bar

ssr-router

完整代码查看github

后续

上面我们通过五个步骤,完成了从纯浏览器渲染到完整服务端渲染的同构,代码既可以运行在浏览器端,也可以运行在服务器端。那么,回过头来我们在看一下是否有优化的空间,又或者有哪些扩展的思考。

1. 优化
  • 我们目前是使用renderToString方法,完全生成html后,才会向客户端返回,如果使用renderToStream,应用bigpipe技术可以向浏览器持续不断的返回一个流,那么文件的加载浏览器可以尽早的显示一些东西出来。
const stream = renderer.renderToStream(context)

返回的值是 Node.js stream

let html = ''

stream.on('data', data => {
  html += data.toString()
})

stream.on('end', () => {
  console.log(html) // 渲染完成
})

stream.on('error', err => {
  // handle error...
})

在流式渲染模式下,当 renderer 遍历虚拟 DOM 树(virtual DOM tree)时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端。

然而,当第一个数据 chunk 被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文(render context),当流(stream)启动时,这些数据将不可用。这是因为,大量上下文信息(context information)(如头信息(head information)或内联关键 CSS(inline critical CSS))需要在应用程序标记(markup)之前出现,我们基本上必须等待流(stream)完成后,才能开始使用这些上下文数据。

因此,如果你依赖由组件生命周期钩子函数填充的上下文数据,则不建议使用流式传输模式。

  • webpack优化

webpack优化又是一个大的话题了,这里不展开讨论,感兴趣的同学可以自行查找一些资料,后续我也可能会专门写一篇文章来讲webpack优化。

2. 思考
  • 是否必须使用vuex

答案是不用。Vuex只是为了帮助你实现一套数据存储、更新、获取的机制,入股你不用Vuex,那么你就必须自己想一套方案可以将异步获取到的数据存起来,并且在适当的时机将它注入到组件内,有一些文章提出了一些方案,我会放到参考文章里,大家可以阅读一下。

  • 是否使用SSR就一定好?

这个也是不一定的,任何技术都有使用场景。SSR可以帮助你提升首页加载速度,优化搜索引擎SEO,但同时由于它需要在node中渲染整套Vue的模板,会占用服务器负载,同时只会执行beforeCreatecreated两个生命周期,对于一些外部扩展库需要做一定处理才可以在SSR中运行等等。

结语

本文通过五个步骤,从纯浏览器端渲染开始,到配置一个完整的基于Vue + vue-router + Vuex的SSR环境,介绍了很多新的概念,也许你看完一遍不太理解,那么结合着源码,去自己手敲几遍,然后再来看几遍文章,相信你一定可以掌握SSR

最后,本文所有源代码都放在我的github上,如果对你有帮助的话,就来点一个赞吧~~

欢迎关注我的公众号

微信公众号

参考链接

查看原文

神思者_伊祎 发布了文章 · 2019-07-29

Vue现有项目改造为Nuxt项目

公司项目,最初只为了实现前后端分离式开发,直接选择了vue框架进行开发,然而现在项目基本完成了,发现蜘蛛根本就抓取不到网站数据,搜索引擎搜出来,都是一片空白没有数据,需要对项目做SEO优化。

本人第一次接触SEO的优化,完全是零基础,开启了小白菜的SEO探索之旅,记录一下一路的过程,方便自己回顾与大家的探讨,如有不度之处,还请路过的大神指导一下。

常用SEO优化方式

在查阅了一些资料后,常见的SEO优化有以下两种:

  • prerender-spa-plugin客户端预渲染
  • ssr服务端渲染

在选择预渲染模式还是服务端渲染的模式时,简单做了个demo进行了一下测试,由于公司项目以检索为主的产品,后期需要蜘蛛抓取的界面太庞大,最终选择用vue提供的nuxt.js框架去改造现有的项目。

简单写一下prerender-spa-plugin客户端预渲染的过程

prerender-spa-plugin客户端预渲染

相关文档可查看:prerender-spa-plugin

安装

直接在vue项目中,通过npm或者yarn进行安装
##### Yarn
$ yarn add prerender-spa-plugin
##### NPM
$ npm i prerender-spa-plugin -S

配置

在webpack.prod.js中配置

顶部引入:

const PrerenderSPAPlugin = require('prerender-spa-plugin')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer

在plugins中配置

new PrerenderSPAPlugin({
    //生成的预渲染html文件存放路径
    staticDir: path.join(__dirname, '../dist'),
    //需要预渲染的界面路径(router)
    routes: ['/', '/index'],
    //跨域转发配置,预渲染时获取数据的api地址
    server: {
        proxy: {
          '/api': {
            target: 'http://192.168.1.223:9002',
            changeOrigin: true,
            pathRewrite: {
              '^/api': '/'
            }
          }
        }
     },
    //在一定时间后再捕获页面信息,使得页面数据信息加载完成,
    必选,不然界面依然会没有数据
    captureAfterTime: 5000,
    //忽略打包错误
    maxAttempts: 10,
    renderer: new Renderer({
        //可选,页面在被搜索时的关键词
        inject: {
          foo: 'bar'
        },
        //可选,无桌面系统可去掉
        headless: false,
        //必选,‘render-event'必须与main.js中mounted下的配置名称一致
        renderAfterDocumentEvent: 'render-event'
    })
})
在main.js中的配置
new Vue({
    el: '#app',
    router,
    store,
    template: '<App/>',
    mounted () {
      document.dispatchEvent(new Event('render-event'))
    }
})
config下index.js配置

// build时需要将'/'切换为'./'
assetsPublicPath: './'

到此整个预渲染模式,改造完成了。
此次demo中尚未处理meta信息,需要配置meta信息的,可查看文档vue-meta-info,这是一个基于vue 2.0的插件,它会让你更好的管理你的vue页面里面的meta信息,详细配置过程可参考:muwoo作者写的“处理 Vue 单页面 Meta SEO的另一种思路”文章,里边整个过程写的挺详细的

Nuxt.js框架改造现有Vue项目

作为新手,并不介意自己动手去配置环境,建议直接用nuxt.js提供的脚手架(create-nuxt-app)进行快速搭建项目,具体搭建过程可查看官网文档:Nuxt.js

nuxt.js项目搭建

确保安装了npx(npx在NPM版本5.2.0默认安装了)

$ npx create-nuxt-app <项目名>

或者

$ yarn create nuxt-app <项目名>

之后会有一系列的选项:

  1. 在集成的服务器端框架之间进行选择:

  2. 选择您喜欢的UI框架:

  3. 选择你想要的Nuxt模式 (Universal or SPA),不懂此选项的可以看一下这篇文章render mode的区别
  4. 添加 axios module 以轻松地将HTTP请求发送到您的应用程序中。
  5. 添加 EsLint 以在保存时代码规范和错误检查您的代码。
  6. 添加 Prettier 以在保存时格式化/美化您的代码。

官网都有详细的讲解,根据自己的需求选择就好了,当安装完后,项目就可以直接运行了

npm run dev
问题
在启动时遇到如下问题:

clipboard.png

找了一圈,发现搭建项目时默认的element-ui版本太低,直接用npm uninstall element-ui卸载当前版本,重新使用npm install element-ui@版本号安装即可,版本号使用2.7.2及以上均可

项目目录简介

既然是对现有Vue项目的改造,就先看一下nuxt.js各目录的功能及vue项目目录的对比吧

nuxt.js的目录结构

├── assets                         // 资源文件。用于组织未编译的静态资源入LESS、SASS 或 JavaScript
│   └── logo.jpg                   // 默认logo图片
├── components                     // 组件。用于自己编写的Vue组件,比如滚动组件,日历组件,分页组件
│   └── AppLogo.vue                // 默认logo组件
├── layouts                        // 布局。页面都需要有一个布局,默认为 default。它规定了一个页面如何布局页面。所有页面都会加载在布局页面中的 <nuxt /> 标签中。
│   └── default.vue                // 默认模板页面,类似mvc中的layout
├── middleware                     // 中间件。存放中间件。可以在页面中调用: middleware: 'middlewareName' 。
├── pages                         // 页面。一个 vue 文件即为一个页面。
│   └── index.vue                  // 默认首页面
├── plugins                        // 用于存放JavaScript插件的地方
│   └── element-ui.js              // 上边我们安装的UI框架
├── static                         // 用于存放静态资源文件,比如图片,此类文件不会被 Nuxt.js 调用 Webpack 进行构建编译处理。 服务器启动的时候,该目录下的文件会映射至应用的根路径 / 下。
├── store                         // 用于组织应用的Vuex 状态管理。
├── .editorconfig                  // 开发工具格式配置
├── .eslintrc.js                   // ESLint的配置文件,用于检查代码格式
├── .gitignore                     // 配置git不上传的文件
├── nuxt.config.js                 // 用于组织Nuxt.js应用的个性化配置,比如网站title,已便覆盖默认配置
├── package.json                   // npm包管理配置文件
└── README.md                      // 说明文档

nuxt.js目录与vue.js目录的对比

clipboard.png

整体来看,目录结构没有太大的变动,区别比较大的就是router文件夹,nuxt.js项目中并没有router路由的配置,这个就是 nuxt 框架的独到之处,为了能实现更好的SSR渲染,它使用的是根据页面结构,自动路由,所以你的文件名,就是你的路由名称,具体规则可查看官网文档路由

好了,nuxt项目启动了,目录结构也清楚了,接下来就是整个现有Vue项目的迁移了。

迁移项目

目录结构重组

因为之前写习惯了vue项目,并不太想改动目录结构,就简单粗暴的在nuxt目录下新建了一个src目录,将assets、components、layouts、middleware、pages、plugins以及store全部拖到了src中,src继续保持与static同级,这样整个项目结构跟vue没有啥区别了。

最终的项目结构如下如:
image.png

PS:在添加了src后需要修改一下项目的启动配置,在nuxt.config.js中修改srcDir为'src/'
image.png

已有vue页面迁移

将vue中对应的页面放到现在的nuxt目录下对应的位置,注意一下vue文件的命名就可以

全局配置文件及第三方组件的迁移

vue项目中有用到一些全局配置文件和第三方文件,这部分js的话,直接放在plugins中,以扩展组件的形式在项目启动时,挂载到全局中

1.自定义的配置文件修改

将自定义的变量绑定到vue的原型中,Vue.use注册到vue项目中,在vue文件中可以直接用$config(自定义的变量名)调用该变量,而不需要再单独去import了;最后用export default抛出该变量,是为了在其他js中使用。
image.png

PS:只有在vue页面中使用该变量时不需要import,如果要在其他的js中使用,还是需要import进来的。

2.第三方组件的迁移

直接用npm install将第三方组件加载到项目中,在需要的vue界面用import载入就可以,但是需要注意的一点是,第三方组件中可能用到了document、window等浏览器对象,而nuxt项目是需要在客户端和服务端都要进行运行的,服务端并没有window等对象,在服务端运行时会报错,所以第三方组件也跟自定义组件类似的用plugins组件的形式载入比较安全,在plugins下单独创建一个同名的js文件,判定是客户端时再去加载该组件就行了。
image.png

plugins中扩展组件的相关配置

在plugins中创建的js需要再项目启动时注册到项目中,也就是在nuxt.config.js中的plugins中进行配置

plugins: [
    { src: '@/plugins/config.js', ssr: true },
    { src: '@/plugins/d3.js', ssr: true }
]

全局样式文件迁移

全局样式文件css,在nuxt.config.js配置文件中的css中引入

css: [
    '@/assets/index.css'
]

用户登录状态store的迁移

项目比较着急,实在懒得用nuxt提供的方式再去改造这部分代码,直接沿用了vue中mutations和actions方式,暂时项目并没有出现问题(后期如果有问题再做修改

PS:如果该js中用到了window等浏览器的对象,加个process.client去判断就行,其余的可以不用修改

element-ui的迁移

跟vue一样先npm install element-ui --save,之后再plugins下新建一个element-ui.js文件,内容如下:

import Vue from 'vue'
import Element from 'element-ui'
import locale from 'element-ui/lib/locale/lang/en'

Vue.use(Element, { locale })

然后再nuxt.config.js中进行配置:

plugins: [
    { src: '@/plugins/element-ui', ssr: true }
],
css: [
    'element-ui/lib/theme-chalk/index.css'
]

防止element-ui多次被打包,在nuxt.config.js下的build中进行配置

build: {
    vendor: ['element-ui']
}

jquery的使用

npm install jquery --save
然后再nuxt.config.js下的build中配置

build: {
    plugins: [
        new webpack.ProvidePlugin({
        '$': 'jquery',
        'jQuery': 'jquery',
        'window.jQuery': 'jquery'
      })
    ]
}

axios和$axios的使用

因为vue中用了axios,后期也没有修改原来的api请求,所以就继续使用了axios,直接npm install axios --save安装,在需要使用的地方import即可。
如果在vue项目中已经封装了axios,直接可以把vue中写的关于api的js都挪到plugins下,把export default axios抛出,再在nuxt.config.js下按照扩展的配置在plugins中添加就可以正常使用了。

plugins: [
    { src: '@/plugins/api/index.js', ssr: true }
],

正常客户端的请求使用axios并没有什么问题,而在asyncData预加载服务端请求时就比较麻烦了,在asyncData请求中使用了nuxt默认集成的$axios,这个需要再nuxt.config.js下的modules中配置

modules: [
    '@nuxtjs/axios',
    '@gauseen/nuxt-proxy'
],

跨域代理配置:

proxyTable: {
    '/api/': {
      target: '数据请求的ip地址',
      ws: false,
      pathRewrite: {
        '^/api': '/'
      }
    }
  },

asyncData下数据请求
单个请求:

async asyncData ({ app, params }) {
     let { data } = await app.$axios.get(url).then(res => {...})
}

多个请求:

async asyncData ({ app, query }) {
    // 请求带参数时的写法,query指的是当前访问的url中携带的参数
      let searchQuery = {
        type: query.searchTag,
        q: query.searchKeys,
        page: 1
      }
      const [nounList, resultList] = await Promise.all([
        app.$axios.get('请求的api地址', { params: { q: query.searchKeys }}),
        app.$axios.get(`/api/search/${searchQuery.type}`, { params: searchQuery })
      ])
      return {
        nounList: nounList.data,
        resultList: resultList.data
      }
    }

一般情况下数据请求

this.$axios.get(url).then(res =>  {...})

PS:整个项目中可以同时使用axios和nuxt默认集成的$axios,可以根据自己的需求合理使用

总结

到现在整个vue项目基本上都改造完了,可以正常使用了。
第一次接触nuxt,对其中的一些原理不是很懂,查阅了大量的文档和别人的博客,虽然完成了这次的改造,但整个项目还是存在一些瑕疵,还在不断的改善中。

细节

  • data中数据的初始化定义,不能直接使用location、window等去赋值,也不能用自定义的config配置文件中的变量直接去赋值,也不能直接require图片;这些赋值都可以挪到mounted钩子函数中完成。
  • 不同界面的title等设置,每个vue界面都提供了head钩子函数

    head () {
          return {
            title: '百度--搜了个啥',
            meta: [
              { hid: 'index', name: 'index', content: 'index page'}
            ]
          }
        },

遗留的问题

  • static中的html静态文件没法直接访问,查询了很多网站,都没有找到相关的文档,心塞塞中.......

PS:目前整个项目还在持续测试和完善中,后续会将遇到的问题和解决方案不定期的更新,有问题或者不完善的地方随时欢迎各位小伙伴提意见,我们一起探讨呀

查看原文

赞 20 收藏 12 评论 4

神思者_伊祎 关注了标签 · 2019-07-10

element-ui

Element,一套为开发者、设计师和产品经理准备的基于 Vue 2.0 的桌面端组件库

A Vue.js 2.0 UI Toolkit for Web

https://element.eleme.cn/
https://github.com/ElemeFE/el...

关注 650

神思者_伊祎 关注了标签 · 2019-07-10

bootstrap

简洁、直观、强悍的前端开发框架,让web开发更迅速、简单。

关注 2812

神思者_伊祎 关注了标签 · 2019-07-10

javascript

JavaScript 是一门弱类型的动态脚本语言,支持多种编程范式,包括面向对象和函数式编程,被广泛用于 Web 开发。

一般来说,完整的JavaScript包括以下几个部分:

  • ECMAScript,描述了该语言的语法和基本对象
  • 文档对象模型(DOM),描述处理网页内容的方法和接口
  • 浏览器对象模型(BOM),描述与浏览器进行交互的方法和接口

它的基本特点如下:

  • 是一种解释性脚本语言(代码不进行预编译)。
  • 主要用来向HTML页面添加交互行为。
  • 可以直接嵌入HTML页面,但写成单独的js文件有利于结构和行为的分离。

JavaScript常用来完成以下任务:

  • 嵌入动态文本于HTML页面
  • 对浏览器事件作出响应
  • 读写HTML元素
  • 在数据被提交到服务器之前验证数据
  • 检测访客的浏览器信息

《 Javascript 优点在整个语言中占多大比例?

关注 134743

神思者_伊祎 关注了标签 · 2019-07-10

chrome

Google Chrome是一个由Google开发的网页浏览器。相应的开源计划名为Chromium,其采用BSD许可证授权并开放源代码,但Google Chrome本身并非自由软件,也未开放源代码。其代码是基于其他开放源代码软件所撰写,包括WebKit和Mozilla,并开发出称为“V8”的高性能JavaScript引擎。

关注 10335

认证与成就

  • 获得 20 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-01-28
个人主页被 353 人浏览