Webpack 是你们熟知的前端开发利器,它能够搭建包含热更新的开发环境,也能够生成压缩后的生产环境代码,还拥有灵活的扩展性和丰富的生态环境。但它的缺点也很是明显,那就是配置项又多又复杂,随便拿出某一个配置项(例如 rules
, plugins
, devtool
等等)都够写上一篇文章来讲明它的 N 种用法,对新手形成极大的困扰。Vue.js(如下简称 Vue)绝大部分状况使用 webpack 进行构建,间接地把这个问题丢给了 Vue 的新手们。不过不管是 Vue 仍是 webpack,其实他们都知道配置问题的症结所在,所以他们也想了各自的办法来解决这个问题,咱们先看看他们的努力。javascript
在以前一长段时间中,咱们要初始化一个 Vue 项目,通常是使用 vue-cli 提供的 vue init
命令(这也是 Vue cli 的 v2 版本,以后简称 Vue cli 2.x)。并且一般一些比较有规模的项目都会使用 vue init webpack my-project
来使用 webpack 模板,那么上面提到的配置问题就来了。css
为了解决这个问题, Vue 的作法是提供开箱即用的配置,即经过 vue init
出来的项目,默认生成的巨多的配置文件,截图以下:html
开箱即用是保证了,但一旦要修改,就至关因而进入了一个黑盒,开发者对于一堆文件,一堆 JSON 望洋兴叹。前端
webpack 4 推出也有一年左右了,它的核心改动之一是极大地简化配置。它添加了 mode
,把一些显而易见的配置作成内置的。所以例如 NoEmitOnErrorsPlugin()
, UglifyJSPlugin()
等等都没必要写了;分包用的 CommonsChunkPlugin()
也浓缩成了一个配置项 optimization.splitChunks
,而且已有能适应绝大部分状况的默认值。vue
听说 webpack 4 构建出来的代码的体积还更小了,所以此次升级显然是必要的。java
大约小半年前,Vue cli 推出了 v3 版本,也是一个颠覆性的升级。它把核心精简为 @vue/cli
,把 webpack 搞成了 @vue/cli-service
, 把其余东西抽象为“插件”。这些插件包括 babel, eslint, Vuex, Unit Testing 等等,还容许自定义编写和发布。我不在这里介绍 Vue cli 3.x 的用法和生态,但从结果看,如今经过 vue create
建立的的 Vue 项目清爽了很多。node
若是咱们单纯开发一个前端 Vue 项目,webpack-dev-server 能帮助咱们启动一个 nodejs 服务器并支持热加载,很是好用。可若是咱们要开发的是一个 nodejs + Vue 的全栈项目呢?二者是不可能启动在同一个端口的。那咱们能作的只是让 nodejs 启动在端口 A,让 Vue (webpack-dev-server) 启动在端口 B。而若是 Vue 须要发送请求访问 nodejs 提供的 API 时,还会赶上跨域问题,虽然能够经过配置 proxy 解决,但依然很是繁琐。而实质上,这是一整个项目的先后端而已,咱们应该使用一条命令,一个端口来启动它们。webpack
抛开 Vue,此类需求 webpack 自己实际上是支持的。由于它除了提供 webpack-dev-server 以外,还提供了 webpack-dev-middleware。它以 express middleware 的方式,一样集成了热加载的功能。所以若是咱们的 nodejs 使用的是 express 做为服务框架的话,咱们能够以 app.use
的方式引入这个中间件,就能够达成二者的融合了。git
再说回 Vue cli 3。它经过 vue-cli-service
命令,把 webpack 和 webpack-dev-server 包裹起来,这样用户就看不到配置文件了,达成了简洁的目的。不过实质上,配置文件依然存在,只是移动到了 node_modules/@vue/cli-service/webpack.config.js
而已。固然为了个性化需求,它也支持用户经过配置对象 (configureWebpack
) 或者链式调用 (chainWebpack
) 两种间接的方式,但再也不提供直接修改配置文件的方式了。github
然而致命的是,即使它提供了足够的方式修改配置,但它不能把 webpack-dev-server 变成 webpack-dev-middleware。这表示使用 Vue cli 3 建立的 Vue 部分和 nodejs(express) 部分是不能融合的。
说了这么多,其实这就是我最近实际碰到的问题以及分析问题的思路。鉴于 Vue cli 3 黑盒的特性,咱们没法继续使用它了(可能之后有升级能解决这个问题,至少目前不行)。而使用 Vue cli 2 又由于它内置的是 webpack 3 且配置文件一大堆,也让人无所适从。这么看,惟一剩下的路就只能自行使用并配置 webpack 4了,这也是本文的内容所在。
目前比较主流的构建 nodejs 部分的 Web 框架是 express,且不说它的语法有多优雅,使用有多普遍等等,最主要的缘由是刚才提过的 webpack-dev-middleware 就是一个 express 的中间件,所以二者能够无缝衔接。
惋惜的是,在我实际的项目中,我使用了 koa 做为了个人 nodejs 框架。其实要说它比 express 好在哪里我也说不上来,也不是本文的重点。可能出于尝鲜的目的,或者团队技术栈统一的目的,或者其余鬼使神差的巧合,反正我用了它,并且开始时还没意识到有这个融合的问题,直到后来发现 webpack-dev-middleware 和 koa 是不兼容的,我心里有过一丝后悔……固然这是后话了。
本文以 koa 为基准。若是您使用的是 express,其实大同小异,并且更加简单。
Vue 没什么好多说的,就一个版本,不存在 express / koa / 其余的选择。只是这里我没有使用 SSR,而是普通的 SPA 项目(单页应用,前端渲染)。
既然是两个项目合体,总有一个目录结构的安排问题。这里我不谈每一个项目内部须要如何组织,那是 Vue / koa 自己的问题,也是我的喜爱的问题。我想谈的是这二者之间的组织方式,不外乎如下 3 种:(实际上也是我的喜爱问题,见仁见智,这里只是统一一下表述,避免后续的混淆)
如下截图中的先后端项目均为独立项目,即融合以前的,能够单独运行的那种,因此能看到两份 package.json 和 package-lock.json
除了红框中的 vue 目录外,其余都是 nodejs 的代码。并且由于我只是作个示意,因此 nodejs 代码其实也仅仅包含两个 index.js,public 目录和两个 package.json。实际的 nodejs 项目应该会有更多的代码,例如 actions(把每一个路由处理单独到一个目录),middlewares(过全部路由的中间件)等等。
这个安排的思路是认为前端是整个项目的一部分(页面展现部分),因此 Vue 单独放在一个目录里面。我采用的就是这种结构。
这就和前面一种相反,红框中的是后端代码。这么安排的理由多是由于咱们是前端开发者,因此把前端代码位于基础位置,后端提供的 API 辅助 Vue 的代码运行。
看了前面两种,天然能想到这第三种办法。不过我认为这种办法纯粹没事儿找事儿,由于根据 npm 的要求,package.json 是必须放在根目录的,因此实际上想把二者彻底分离并公平对待是弊大于利的(例如各种调用路径都会多几层),适合强迫症患者。
Vue 部分的改造点主要是:
package.json 融合到根目录(nodejs) 的 package.json 里面去。这里主要包括依赖 (dependency
和 devDependency
)以及执行命令(scripts
)两部分。其他的如 browserslist
, engine
等 babel 可能用到的字段,由于 nodejs 代码不须要 babel,因此能够直接复制过去,不存在融合。
编写 webpack.config.js
。(由于 Vue cli 3 是自动生成且隐藏的,这个就须要本身写)
下面详细来看。
刚才有提到过,像 browserslist
, engine
这类 babel 等使用的字段,由于 nodejs 端是不须要的,因此简单的复制过去便可。须要动脑的是依赖和命令。
依赖方面,其实先后端共用的依赖也基本不存在,因此实际上也是一个简单的复制。须要注意的是相似 vue
, vue-router
, webpack
, webpack-cli
等等都是 devDependency
,而不是 dependency
。真正须要放到 dependency
的,其实只有 @babel/runtime
这一个(由于使用了 plugin-transform-runtime
)。
命令方面,自己 Vue 必备的是“启动开发环境”和“构建”两条命令(可选的还有测试,这个我这里先不讨论)。由于开发环境须要和 nodejs 融合,因此这条咱们放到 nodejs 部分说。剩下的是构建命令,常规操做是经过设置 NODE_ENV
为 production
来让 webpack 走入线上构建的状况。另外值得注意的是,由于如今 package.json 和 webpack.config.js 不在同级目录了,因此须要额外指定目录,命令以下:(cross-env
是一个至关好用的跨平台设置环境变量的工具)
{
"scripts": {
"build": "cross-env NODE_ENV=production webpack --config vue/webpack.config.js"
}
}
复制代码
本文的重点不是 webpack 的配置方式,所以这里比较简略,不详细讲述每一个配置项的含义
webpack.config.js 本质上是一个返回 JSON 的配置文件,咱们会用到其中的几个 key。若是要了解 webpack 所有的配置项,能够查看 webpack 的中文网站介绍。另外若是不想分段查看,你能够在这里找到完整的 webpack.config.js。
webpack 4 新增配置项,常规可选值 'production'
和 'development'
。这里咱们根据 process.env.NODE_ENV
来肯定值。
let isProd = process.env.NODE_ENV === 'production'
module.exports = {
mode: isProd ? 'production' : 'development'
}
复制代码
定义 webpack 的入口。咱们须要把入口设置为建立 Vue 实例的那个 JS,例如 vue/src/main.js
。
{
entry: {
"app": [path.resolve(__dirname, './src/main.js')]
}
}
复制代码
定义 webpack 的输出配置。在开发状态下,webpack-dev-middleware(如下简称 wdm)并不会真的去生成这个 dist
目录,它是经过一个内存文件系统,把文件输出到内存。因此这个目录仅仅是一个标识而已。
{
output: {
filename: '[name].[hash:8].js',
path: isProd ? resolvePath('../vue-dist') : resolvePath('dist'),
publicPath: '/'
}
}
复制代码
主要定义两个东西:webpack 处理 import
时自动添加的后缀顺序和供快速访问的别名。
{
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolvePath('src'),
}
}
}
复制代码
module
在 webpack 中主要肯定如何处理项目中不一样类型的模块。咱们这里采用最经常使用的配法,即告诉 webpack,什么样的后缀文件用什么样的 loader 来处理。
{
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js?$/,
loader: 'babel-loader',
exclude: file => (
/node_modules/.test(file) && !/\.vue\.js/.test(file)
)
},
{
test: /\.less$/,
use: [
isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
'css-loader',
'less-loader'
]
},
{
test: /\.css$/,
use: [
isProd ? MiniCssExtractPlugin.loader : 'vue-style-loader',
'css-loader'
]
}
]
}
}
复制代码
上述配置了 4 种文件的处理方式,它们分别是:
/\.vue$/
处理 Vue 文件,使用 Vue 专门提供的 vue-loader
。这个处理器作的事情就是把 Vue 里面的 <script>
和 <style
> 的部分独立出来,让它们能够继续由下面的 rules 分别处理。不然一个 .vue
文件是不可能进入 .js
或者 .css
的处理流程的。另外若是 <style>
有 lang
属性,还能够进入例如 .less
, .styl
等其余处理流程。
它还须要专门的插件 VueLoaderPlugin()
,以后能够看到,不要漏掉。
/\.js?$/
表面上是处理后缀为 .js
的文件,但实质上在这里也用来处理 Vue 里面 <script>
的内容。在这里咱们要作的是使用 babel-loader
对代码中的高级写法转译为兼容低版本浏览器的写法。具体转译规则使用 .babelrc
文件配置。另外这里还忽略了 node_modules
(由于其余包在发布时都已经转码过了,不用再处理徒增时间)。不过得确保 node_modules
里的单体 Vue 文件依然参与转译,这也是 Vue 官方文档 的推荐写法。
/\.less$/
个人项目中使用 less 做为样式预处理器,所以在每一个 Vue 文件都使用了 <style lang="less">
。这样经过 vue-loader
,就能让这条规则中配置的几个 loader 来处理 Vue 文件中的样式了。这几个 loader 分别作的事情是:
vue-style-loader
把样式以 <style>
标签的形式插入在页面头部,只在开发状态下使用。
它和 style-loader
的差别并不大,但既然 Vue 官方文档建议使用这个,那咱们就用这个吧。
mini-css-extract-plugin
的 loader
把样式抽成一个单独的 css 文件并在 <head>
标签中以 <link rel="stylesheet">
的方式引用,取代原来 webpack 3.x 的 extract-text-webpack-plugin
,只在生产状态下使用。
它一样须要在插件中增长 MiniCssExtractPlugin()
以配合使用。
css-loader
支持经过隐式的方式加载资源。例如若是在 JS 文件中编写 import 'style.css'
,或者在样式文件中编写 url('image.png')
,通过 css-loader
能够把 style.css
和 image.png
都引入到 webpack 的处理流程中。这样针对 css 的全部 loader 就能够处理 style.css
,而针对全部图片的 loader(例如尺寸很小就自动转为 base64 的 url-loader
)均可以处理 image.png
了。
less-loader
使用 less 预处理器必须加载的 loader,用以把 less 语法转化为普通的 css 语法。
基本上每一个预处理器都有对应的 loader,例如 stylus-loader
, sass-loader
等等,能够按需使用。
/\.css$/
和 .js
规则类似,这条规则能够同时应用于 .css
的后缀文件以及 Vue 中的 <style>
(且没有写 lang
的)部分。
插件和规则相似,也是对加载进入 webpack 的资源进行处理。不过和规则不一样,它并不以正则(多数为后缀名)决定是否进入,而是所有进入后经过插件自身的 JS 写法来肯定处理哪些,所以更加灵活。
前面提过,有些功能须要 loader 和 plugins 配合使用,所以也须要声明在这里,好比 VueLoaderPlugin()
和 MiniCssExtractPlugin()
。
在咱们的项目中,插件分为两类。一类是不论环境(开发仍是生产)都要使用的,这一类有 2 个:
{
"plugins": [
// 和 vue-loader 配合使用
new VueLoader(),
// 输出 index.html 到 output
new HtmlwebpackPlugin({
template: resolvePath('index.html')
})
]
}
复制代码
另一类是生产环境才须要使用的,也有 2 个:
if (isProd) {
webpackConfig.plugins.push(
// 每次 build 清空 output 目录
new CleanWebpackPlugin(resolvePath('../vue-dist'))
)
webpackConfig.plugins.push(
// 分离单独的 CSS 文件到 output,和 MiniCssExtractPlugin.loader 配合使用
new MiniCssExtractPlugin({
filename: 'style.css',
})
)
}
复制代码
optimization 是一个 webpack 4 新增的配置项,主要处理生产环境下的各种优化(例如压缩,提取公共代码等等),因此大部分的优化都在 mode === 'production'
时会使用。这里咱们只使用它的一个功能,即分包,之前的写法是 new webpack.optimize.CommonChunkPlugin()
,如今只要配置就能够了,配置方法也很简单:
{
optimization: {
splitChunks: {
chunks: 'all'
}
}
}
复制代码
这样,从 node_modules
来的代码会打包到一块儿,命名为 vendors~app.[hash].js
,这里面可能包含了 Vue, Vuex, Vue Router 等等第三方的代码。这些代码不会常常修改,因此独立出来并添加长时间的强制缓存能显著提高站点访问速度。
经过运行 npm run build
,可以调用 webpack-cli
来运行刚才编写的配置文件。编译成功后,会在根目录下生成一个 vue-dist
目录,里面存放的内容以下:(若是作了 Vue 的路由懒加载,即 const XXX = () => import('@/XXX.vue')
,文件会根据路由分割,所以数量会更多)
总共 4 个文件
index.html
存放惟一的 HTML 入口,里面包含对各 JS, CSS 文件的引用,并定义了容器节点。使用静态服务器启动后,因为 JS 的执行,能够执行前端渲染。
style.css
存放全部的样式。这些样式都是从每一个 Vue 文件的 <style lang="less">
部分中抽出来合并而成的。
app.[hash].js
存放全部的自定义 JS,即每一个 Vue 文件的 <script>
部分,以及如 app.js
, router.js
, store.js
的代码等等。
vendors~app.[hash].js
如上所述,存放全部类库 JS,如 vue-router, vuex 自己的代码。
对 nodejs 来讲,须要关心的只是这个 index.html
而已,其余 3 个都会由它负责引入。那么咱们接下来看看如何改造 nodejs 部分。
koa 部分须要咱们改造的点主要有:
package.json
nodejs 项目的标配,记录依赖,脚本,项目信息等等。咱们须要在这里和 Vue 端的 package.json 进行合并,尤为是 npm run dev
脚本的合并。
index.js
nodejs 的代码入口,经过命令 node index.js
启动整个项目。在这里能够注册路由规则,注册中间件,注册 wdm 等等,绝大部分逻辑都在这里。
由于开发环境和生产环境的行为不尽相同(例如开发环境须要 wdm 而生产环境不须要),所以能够分为两个文件(index.dev.js
和 index.prod.js
),也能够在一个文件中经过环境变量判断,这个因人而异。
虽然 koa 的路由规则和中间件均可以写在这里,但一般只要是略有规模的项目,都会把路由处理和中间件分别独立成 actions
和 middlewares
目录分开存放(名字怎么起看本身喜爱)。配置文件(例如配置启动端口号)也一般会独立成 config.js
或者 config
目录。其余的例如 util
目录等也都按需创建。
咱们须要在这里统一先后路由,并使用 wdm 等
在“改造 Vue 部分”的 package.json 中曾经讲过,Vue 项目的依赖都直接复制到外层的 package.json 中来,还增长了一条 npm run build
命令。这里会再列出两条命令,达成最基本的的需求。
我为了区分运行环境,把 index.js
拆解为了 index.dev.js
和 index.prod.js
。如上面所说,你也能够就在一个文件里用 process.env.NODE_ENV
来判断运行环境。
常规的 koa 服务,通常咱们经过 node index.js
来启动。但 nodejs 默认没有热加载,所以修改了 nodejs 代码须要重启服务器,比较麻烦。之前我会使用 chokidar
模块监听文件系统的变化,并在变化时执行 delete require.cache[path]
来实现简单的热加载机制。但如今有一款更方便的工具帮咱们作了这个事情,那就是 nodemon
。
"nodemon -e js --ignore vue/ index.dev.js"
复制代码
它的使用方式也很简单,把 node index.js
换成 nodemon index.js
,他就会监听以这个入口执行的全部文件的变化,并自动重启。但咱们这里还额外使用了两个配置项。-e
表示指定扩展名,这里咱们只监听 js。 --ignore
指定忽略项,由于 vue/
目录中有 webpack 帮咱们执行热加载,所以它的修改能够忽略。其余可用的配置项能够参考 nodemon 的主页。
这个就简单了,直接执行 node
命令便可。因此最终的脚本部分以下:
{
"scripts": {
"dev": "nodemon -e js --ignore vue/ index.dev.js",
"build": "cross-env NODE_ENV=production webpack --config vue/webpack.config.js",
"start": "node index.prod.js"
}
}
复制代码
这个文件是 koa 的启动入口,它的大体结构以下(我使用了 koa-router 来管理路由,且只列举最最简单的骨架):
// 引用基本类库
const Koa = require('koa')
const Router = require('koa-router')
const koaStatic = require('koa-static')
// 初始化
const app = new Koa()
const router = new Router()
// 常规项目可能有中间件,即处理全部路由的逻辑,如验证登陆,记录日志等等,这里省略
// 注册路由到 koa-router。
// 常规项目路由不少,应该独立到一个目录去一个个注册
router.get('/api/hello', ctx => {
ctx.body = {message: 'Greeting from koa'}
})
// koa-router 以中间件的形式注册给 koa
// 就理解为固定写法
app.use(router.routes());
app.use(router.allowedMethods());
// 为 public 目录启动静态服务,能够放图片,字体等等。Vue 部分打包的资源在 vue-dist,不在这里。
app.use(koaStatic('public'));
// 实际项目可能端口还要写到配置文件,这里随意了
app.listen(8080)
复制代码
咱们从开发环境和线上环境两个方面来讨论对这个文件的改造方式。
首先是合并先后端路由。
Vue 端使用的是 history 路由模式,所以自己就须要 nodejs 来配合,Vue 官方推荐的是 connect-history-api-fallback 中间件,不过那是针对 express 的。我找到了一个给 koa 使用的相同功能的中间件,名为 koa2-history-api-fallback。
不管是哪个中间件,原理是同样的。由于 SPA 只生成一个 index.html
,所以全部的 navigate 路由都必须定向到这个文件才行,不然例如 /user/index
这样的路由,浏览器会去找 /user/index.html
,显然是找不到的。
既然 Vue 需求的是全部的 navigate 路由,显然它不能注册在 koa 的路由以前,不然 koa 的路由将永远没法生效。所以路由注册顺序就很天然了:前后端再前端。另外这里说的 navigate 路由,指的是请求 HTML 的第一个请求,静态资源的请求不在其内,所以例如上述的 public
静态路由和 Vue 的中间件的先后顺序就无所谓了。
因此路由部分融合后大概是这样:
// 后端(koa)路由
// koa-router 的单个注册部分省略
app.use(router.routes());
app.use(router.allowedMethods());
// 前端(vue)路由
// 全部 navigate 请求重定向到 '/',由于 webpack-dev-middleware 只服务这个路由
app.use(history({
htmlAcceptHeaders: ['text/html'],
index: '/'
}));
app.use(koaStatic('public'));
复制代码
其次就是问题的最开端,webpack-dev-middleware 的使用。
和 Vue 的中间件相似,webpack-dev-middleware 也是只支持 express 的(这些都代表 express 的生态更好),而后我也找了个 koa 版本的替代方案,叫作 koa-webpack。
使用起来倒也不麻烦,以下:
const koaWebpack = require('koa-webpack')
const webpackConfig = require('./vue/webpack.config.js')
// 注意这里是个异步,因此和其余 app.use 以及最终的 app.listen 必须在一块儿执行
// 可使用 async/await 或者 Promise 保证这一点
koaWebpack({
config: webpackConfig,
devMiddleware: {
stats: 'minimal'
}
}).then(middleware => {
app.use(middleware)
})
复制代码
一个完整的 index.dev.js
能够查看这里。
线上环境和开发环境有两处不一样,咱们着重讲一下这两个不一样点。
首先,线上环境不使用 webpack-dev-middleware (koa-webpack),所以这部分代码不须要了。
其次,由于构建后的 Vue 代码所有位于 vue-dist 目录,而咱们须要的 HTML 入口以及其余 JS, CSS文件都在其中,所以咱们须要把 vue-dist 目录添加到静态服务中可供访问,另外 history fallback 的目标也有所改变,以下:
// 后端(koa)路由
// koa-router 的单个注册部分省略
app.use(router.routes());
app.use(router.allowedMethods());
// 前端(vue)路由
// 全部 navigate 请求重定向到 /vue-dist/index.html 这个文件,配合下面的 koaStatic('vue-dist'),这里只要填到 '/index.html' 便可。
app.use(history({
htmlAcceptHeaders: ['text/html'],
index: '/index.html'
}));
app.use(koaStatic('vue-dist'));
app.use(koaStatic('public'));
复制代码
一个完整的 index.prod.js
能够查看这里。
虽然咱们讨论了这么多,但不要惧怕,实际上的重点只有三个,咱们来总结一下:
咱们须要本身编写 Vue 的 webpack.config.js,处理 loader, plugins 等等
咱们须要合并先后端的两个 package.json,把两方的依赖合并,并编写三条脚本 (dev
, build
, start
)
咱们须要改动 index.js
,处理路由顺序,并在开发环境调用 webpack-dev-middleware
为了简单上手,我把项目中的业务代码抽离,留下了一个骨架,能够做为 Vue + koa 项目的启动模板,放在 easonyq/vue-nodejs-template。不过我以为咱们仍是应当掌握配置方法和原理,这样之后若是技术栈的某一块发生了变化(例如 webpack 出了 5),咱们也可以本身研究修改,而不是每次都以解决任务为最优先,能跑起来就无论了。
愿咱们你们在前端道路上都能越走越顺!