Skip to content

使用vue-cli 搭建 vue ssr 指南 #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
codeDebugTest opened this issue Feb 23, 2022 · 0 comments
Open

使用vue-cli 搭建 vue ssr 指南 #4

codeDebugTest opened this issue Feb 23, 2022 · 0 comments

Comments

@codeDebugTest
Copy link
Owner

codeDebugTest commented Feb 23, 2022

背景

项目中需要用到vue-ssr, 看了一遍官网的例子 HackerNews Demo
, 发现还是手写webpack 进行搭建的。另外也发现比较老旧了,想着应该可以vue-cli 进行编译打包提速。那么在这期间遇到了一些问题,本文主要给大家讲述这搭建期间遇到的一些问题以及规避办法。

过程

运行官网例子HackerNews Demo

在下载官网例子后,本地运行时会发现页面是空白页面打不开。这是因为官网调用的接口服务器在国外需要翻墙。issue322。如果只是想要运行起来看看效果,可以把 源码 中的 asyncData 注释掉。

vue-cli 搭建ssr

初始化项目

先用vue-cli 生成csr 项目,这样dependencies,eslint, babel 等配置就已经构建好了。然后讲vue-ssr 所需要的entry-client、entry-server、store、router 等配置好。
然后将 index.html 中 入口节点 app去除,

<div id="app"></div>

增加ssr 出口

<!--vue-ssr-outlet-->

至此整个SSR 的源码部分已经搞定

构建

1.增加编译命令

官网是通过 指定不同webpack config 文件进行编译。我们因为统一用vue.config.js 进行编译,那么为了区分client / server 编译我们指定编译目标 WEBPACK_TARGET,如下:

"build:client": "cross-env NODE_ENV=production WEBPACK_TARGET=client vue-cli-service build",
"build:server": "cross-env NODE_ENV=production WEBPACK_TARGET=server vue-cli-service build",

由于可能跨平台执行脚本(如:打包过程是专门的流水线服务linux 环境执行),所以我们增加cross-env

2.编译入口

先区分端类型,指定入口文件

const target = process.env.WEBPACK_TARGET || 'client'
const isServer = target === 'server'

module.exports = {
    ... ... 
    configureWebpack: {
        entry: `./src/entry-${target}.js`,
    }
    ... ...
}

增加 vue-server-renderer plugin 配置

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = {
    ... ... 
    configureWebpack: {
        plugins: [isServer ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
    }
    ... ...
}

3.server 端配置

阅读官网build 文件夹内容,会看到一个client/server 共用的 base 文件。里面都是相关的 babel、loader、uglify 等配置,这些配置其实已经集成到了vue.config.js 中,我们不需要再关心了。(vue-cli 的好处提现出来了)

查看官网的ssr指南-构建配置, 看到如下配置:
image
我们将此转义到vue-config.js 中,如下:

const nodeExternals = require('webpack-node-externals')
module.exports = {
    ... ... 
    configureWebpack: {
         target: isServer ? 'node' : 'web',
         output: { libraryTarget: isServer ? 'commonjs2' : undefined },
         externals: isServer
              ? nodeExternals({
                   whitelist: [/\.css$/]
              })
              : undefined
    }
    ... ...
}

这里需要重点注意的是 webpack-node-externals 依赖的版本号,从2.1.0 开始 已经不在支持 whitelist 配置了, 改为 allowlist

注意事项:
1)由于server 端渲染只需要一个entry file 所以对于 vue.config.js 默认配置的 optimization 是不需要的。因此我们增加如下配置:

module.exports = {
    ... ... 
    configureWebpack: {
         optimization: { splitChunks: isServer ? false : undefined }
    }
    ... ...
}

2)vue.config.js 默认慧配置hot modules reload, 此配置在服务端是不需要的,因此我们删掉:

module.exports = {
    ... ... 
    chainWebpack: config => {
         if (isServer) {
            config.plugins.delete('hmr')
         }
    }
    ... ...
}

4.client 端配置

如官网ssr指南-构建配置, 所说:客户端配置 (client config) 和基本配置 (base config) 大体上相同。所以客户端不再增加配置

5.node 渲染服务

官网-Server 对于静态资源直接读取,剩余的路由走 server-render。我这里用koa 作为web框架,配置如下:
image
外层主文件

const router = require('./server');
const app = new Koa()
app.use(koaStatic(resolve('../dist')))
app.use(router.routes()).use(router.allowedMethods())

需要注意的是我们使用vue-config.js 中,静态资源会分布到js、css、img 等文件夹,我们部署到服务器直接通过[域名]/js/xxx.js这样去访问静态资源。所以增加了模式匹配 /^/(?!(js|css|img))// 。当然也可以明确路由如:

router.get('home', handleRequest)
router.get('about', handleRequest) 

**注意不能用 router.get(' ', handleRequest) , 因为koa-router 7 (当前9.4)以后不允许使用 '' **

6.ssr development 模式

查看官网build文件夹有个 setup-dev-server 文件 ,该文件主要用来本地开发时 ssr 的 hot-reload , 因此会看到 Server端的watch

const serverCompiler = webpack(serverConfig)
  const mfs = new MFS()
  serverCompiler.outputFileSystem = mfs
  serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    stats = stats.toJson()
    if (stats.errors.length) return

    // read bundle generated by vue-ssr-webpack-plugin
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })

client 端的 hot middleware

  clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  )

  // dev middleware
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    noInfo: true
  })
  app.use(devMiddleware)
  clientCompiler.plugin('done', stats => {
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))
    if (stats.errors.length) return
    clientManifest = JSON.parse(readFile( // 实时更新vue-ssr-client-manifest.json
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    update()
  })

  // hot middleware
  app.use(require('webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))

以及 index.html 的热载入

  // read template from disk and watch
  template = fs.readFileSync(templatePath, 'utf-8')
  chokidar.watch(templatePath).on('change', () => {
    template = fs.readFileSync(templatePath, 'utf-8')
    console.log('index.html template updated.')
    update()
  })

那么 vue.config.js 中,我们可以借助其本身的静态服务(dev.server),来帮助我们热更新静态资源,同时vue-ssr-client-mainifest.json 的更新我们也可以交给静态服务来处理:

   // 通过静态服务实时获取最新的 vue-ssr-client-manifest.json
  const clientManifestResp = await axios.get(`http://localhost:${process.env.STATIC_PORT}/vue-ssr-client-manifest.json`)
  const clientManifest = clientManifestResp.data

  const renderer = createBundleRenderer(bundle, {
    runInNewContext: false,
    // index.html 直接读取文件内容
    template: fs.readFileSync(path.resolve(__dirname, '../public/index.ssr.html'), 'utf-8'),
    clientManifest
  })
  const html = await renderToString(ctx, renderer)
  ctx.body = html
}
// 静态资源我们借助静态服务转发请求
async function handleStaticResourceRequest (ctx) {
  console.log('get static resource: ',  ctx.path)
  let resource
  try {
    resource = await axios.get(
            `http://localhost:${process.env.STATIC_PORT}/${ctx.path}`,
            { responseType: 'stream' }
    )
  } catch (e) {
    console.log('static resources not found', ctx.path)
    ctx.status = '404'
    return
  }
  ctx.body = resource.data
}

const router = new Router()
// because it can't read dist statice files on development mode, so we can proxy request by statice server.
router.get(/^\/((js|css|img|)\/(.*)|favicon\.ico)$/, handleStaticResourceRequest)

这里需要重点注意请求静态资源时,一定要配置 {responseType: 'stream'},不然对于图片资源会加载失败。

剩下的server 端热更新,由于我们用的vue.config.js ,我们需要找到其中的webpack config ,参考官网进行配置。
其中的 webpack config 在 vue-cli 官网中有说明,
image
那么剩下的就是配置了,如下:

const webpackConfig = require('@vue/cli-service/webpack.config')
const serverCompiler = webpack(webpackConfig)
const mfs = new MemoryFS()
serverCompiler.outputFileSystem = mfs
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')
})

至此我们的本地服务配置完毕,接下来我们添加script 命令

    "serve": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
    "dev:client": "STATIC_PORT=8090 vue-cli-service serve",
    "dev:server": "STATIC_PORT=8090 cross-env WEBPACK_TARGET=server node ./server/index.js",

由于vue.config.js 默认配置dev server 端口号为8090,我们通过vue-cli-service serve 很容易与其他服务冲突,导致我们的ssr 渲染失败。所以这里我们配置了环境变量STATIC_PORT 为8090, 使得我们静态服务端口号为8090,防止与其他服务端口号冲突。同时我们在vue.config.js 中更改dev.server 端口

module.exports = {
    devServer: { port: isDev && !isServer ? process.env.STATIC_PORT : undefined }
}

至此我们配置完成,运行 npm run serve 试试,启动正常。不幸的是页面访问时报错了,仔细看发现 ssr node 服务渲染时报 document not defined

7.解决 document not defined 问题

我们代码中并没有明确用 document.xxxx 那哪来的呢,找了一圈发现 是vue-cli 默认对于css 处理用了 mini-css-extract-plugin 而该插件在extract css 文件时会插入document.getElementsByTagName [参考] (vuejs/vue-router#2380 (comment))
知道问题根结我们要去除该插件,vue-cli 用@sodatea 给的解决方案
本质是它自己写了个loader。然后将mini-css-extract-plugin 进行替换

这就好办了,我们偷懒将这个loader 复制到本地吧。同时配置vue-config.js

const CssContextLoader = require.resolve('./build-loaders/css-context')
module.exports = {
    ... ... 
    chainWebpack: config => {
         if (isServer) {
                const langs = ['css', 'less'];
                const types = ['vue-modules', 'vue', 'normal-modules', 'normal'];
                langs.forEach(lang => {
                    types.forEach(type => {
                        const rule = config.module.rule(lang).oneOf(type);
                        rule.uses.delete('extract-css-loader');
                        rule.use('css-context-loader').loader(CssContextLoader).before('css-loader');
                    });
                });
         }
    }
    ... ...
}

到此我们的整个项目已经完全可以run 了。

8.其他

1)增加个编译进度条

const WebpackBar = require('webpackbar');
module.exports = {
    ... ... 
    chainWebpack: config => {
        config.plugin('loader')
            .use(WebpackBar, [{name: target, color: isServer ? 'orange' : 'green'}]);
    }
    ... ...
}

2)本地更文件后,服务页面不能自动刷新,需要手动更新。检查错误一看原来静态资源会有个websocket 服务,会获取最新的hot-update..js 以及 hot-update.json。那么我们继续用静态服务进行代理改文件,在上述的本地node服务中增加如下代码:

// hot-update 资源请求
router.get(/.*\.hot-update\.(json|js)/, handleStaticResourceRequest);

好了,大功告成了。

9. 降级方案CSR

我们用了vue.config.js 编译其实产物也可以支持CSR哦。如果业务有需要(ssr 失败降级为csr), 我们增加 csr 的 index.html,vue.config.js 自动会将其放到dist 目录中。
对于ssr 模板文件html 我们新增index.ssr.html,并在vue.config.js 中将我们的 index.ssr.html 复制到dist 目录就可以了。

module.exports = {
    chainWebpack: config => {
         // 复制 index.ssr.html 给ssr server 用
         config.plugin('copy').tap(args => {
             args[0].push({
                 from: resolve('public/index.ssr.html'),
                 to: resolve('dist/index.ssr.html'),
                 toType: 'file',
             });
            return args;
        });
    }
}

最后

  1. 整个项目的配置源码参见vue2-ssr-demo
  2. 后续出一份vue3 ssr 的指南
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant