在上一篇文章《基于 @vue/cli3 与 koa 创建 ssr 工程》中,我们讲解了如何基于 @vue/cli3
创建一个 ssr 工程。
在本篇文章,我们来创建一个 @vue/cli3
插件,并将第一篇文章中 ssr 工程的服务器端部分整合进插件中。
首先,我们来看一个 cli3
插件提供了那些功能:
- 使用脚手架创建一个新工程或在一个既有工程安装并执行插件后,生成自定义的工程文件
- 基于
@vue/cli-service
提供统一的自定义命令,例如:vue-cli-service ssr:build
除了上述两个功能外,我们还希望在插件内部整合服务端逻辑,这样对于多个接入插件的工程项目能实行统一的管理,方便后续统一增加日志、监控等功能。
创建插件 npm 库
官方对于发布一个 cli3
的插件做了如下限制
为了让一个 CLI 插件能够被其它开发者使用,你必须遵循 vue-cli-plugin-<name>
的命名约定将其发布到 npm
上。插件遵循命名约定之后就可以:
- 被
@vue/cli-service
发现; - 被其它开发者搜索到;
- 通过
vue add <name>
或vue invoke <name>
安装下来。
因此,我们新建并初始化一个工程 createPluginExample
,并将工程的 name
命名为 vue-cli-plugin-my_ssr_plugin_demo
mkdir createPluginExample && cd createPluginExample && yarn init
package.json 的内容为:
{
"name": "vue-cli-plugin-my_ssr_plugin_demo",
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
创建插件 npm 库的模板内容
官方对于第一个插件功能,引入了一个叫做 Generator
的机制来实现
Generator
有两种展现形式:generator.js
或 generator/index.js
generator.js
或 generator/index.js
的内容导出一个函数,这个函数接收三个参数,分别是:
- 一个
GeneratorAPI
实例 - 这个插件的
generator
选项 - 整个
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) {
// 有条件地生成文件
}
}
以及两种安装方式:
- 在使用脚手架创建一个新项目时,插件被设置在对应的
preset
中被安装 - 在一个既有的项目中,通过
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
做三件事情:
- 按照
ssr
工程模式自定义工程的package.json
的内容 - 执行
api.render('./template')
触发generator
使用EJS
渲染generator/template/
中的文件,并替换工程中根目录下对应的文件 - 在工程创建完毕后,执行一些收尾工作
关于第一件事情
首先我们需要在创建工程项目后,自动创建好基于 ssr
的一些命令,比如服务器端构建 ssr:build
,开发环境启动 ssr
服务 dev
其次,我们还需要在创建工程项目后,自动安装好 ssr
依赖的某些第三方工具,例如:concurrently
第二件事件比较简单,我们这里直接按照官方文档写就可以。
关于第三件事情:
- 因为默认的
spa
工程会在src
下生成router.js
、store.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
文件中,我们会做如下三件事情:
- 将
vue.config.js
中的配置整合进插件中,也就是index.js
中提供的api.chainWebpack
内部,这样做的好处是安装此插件的工程项目不必再关心ssr
相关的webpack
配置细节 - 提供开发环境启动
ssr
服务的命令:ssr:serve
- 提供产品环境构建
ssr
服务 bundle 的命令:ssr:build
当调用 vue-cli-service <command> [...args]
时会创建一个叫做 Service
的插件。
Service
插件负责管理内部的 webpack
配置、暴露服务和构建项目的命令等, 它属于插件的一部分。
一个 Service
插件导出一个函数,这个函数接收两个参数:
- 一个
PluginAPI
实例 - 一个包含
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
