【写在前面】
最近工作中不断的有场景,需要有一个脚手架进行快速的原型开发和idea验证。在尝试了Vue-cli等开源工具后,还是觉得离自己期望的有些diff。所以就动了想要搞一个脚手架的想法,希望它足够轻量,足够快。下载快,安装快,打包构建快,跑起来也快,最重要的是自己可以随时根据需求diy。
【看这里】
于是,我开始动手了,完整的代码在这里GitHub[1]
首先,最常用的技术栈是基于vue的,然后看到了最近webpack更新到了版本5,相传打包构建速度有很大提升。所以就基于webpack5来构建一个自己的脚手架吧。
在正式开始code之前,我勾画了一下它大概的样子和应该具备的能力。它最后应该长这样子:
基础版 | 标准版 |
dev-server 处理html/JS/VUE/CSS/LESS/SASS/IMG/JPG等各种文件 路由配置 mock数据 代理接口 打包分析 单元测试 编译构建 | dev-server 处理html/JS/VUE/CSS/LESS/SASS/IMG/JPG等各种文件 路由配置 mock数据 代理接口 打包分析 单元测试 编译构建 基本布局 导航配置 支持TS 支持Markdown 风格检查 |
嗯,先从基础版开干。。
// 初始化yarn init// 安装webpack相关依赖yarn add webpack webpack-cli webpack-dev-server webpack-merge --dev
来解释一下,webpack-cli[2]是为了支持在scripts脚本中直接调用webpack命令的。webpack-dev-server[3]是提供开发的server服务,webpack-merge[4]是可以合并多个webpack配置的。
// 建立文件目录,单元测试/mock数据/配置文件/业务逻辑mkdir test mock build src// 针对不同的场景,应该有不同的打包配置touch build/webpack.config.base.js build/webpack.config.dev.js build/webpack.config.prod.js// 再来一个公共文件,抽取一些公共配置touch build/config.js
到这里,文件的目录结构是这样子:
继续添加依赖
// vue三件套yarn add vue vue-router vuex// 加个工具库,网络库,组件库yarn add lodash axios view-design// 安装polyfill, 处理es6+语法yarn add @babel/core @babel/polyfill babel-loader
webpack中需要指定入口文件[5]来构建依赖树,我们使用`src/main.js`作为入口文件。
// src/main.jsimport '@babel/polyfill';import Vue from 'vue';import VueRouter from 'vue-router';import ViewUI from 'view-design';import routes from './common/router';import ApiClient from './common/client';import 'view-design/dist/styles/iview.css';Vue.use(VueRouter);Vue.use(ViewUI);Vue.prototype.$client = new ApiClient();const router = new VueRouter({routes});const app = new Vue({ router}).$mount('#app');
引入vue三件套,babel依赖,使用Vue.use()安装`VueRouter`和`ViewUI`, 其原理就是调用了VueRouter的内部install方法。需要注意,ViewUI的样式文件需要单独引入。除此之外,可以留意到有一个ApiClient的东西,这个是封装了一个网络请求的Client,并挂载到了Vue的原型链上,后面会详细解释。
Vue实例需要有一个挂载点,单页面应用也需要有一个html文件来渲染页面内容。所以我们需要再编辑一个index.html,提供基本的容器。
<html> <head> <meta charset="utf-8"> <title>Zero-Xtitle> head> <body> <div id="app"> <router-view>router-view> div> body>html>
文件很简单,提供了一个`id="app"`的挂载点和一个用于切换路由的`router-view`标签。但是我们在打包的时候实际上是动态生成的html文件,为什么会这样呢?因为单页面应用的原理,其实就是指定一个标签作为容器,动态的切换其内容,即动态的挂载/移除其子节点来完成的改变页面内容。而可以配合完成对页面动态切换子节点的控制逻辑,对应的就是路由插件。这些控制逻辑和样式等,最后都会被打包成一个个的文件,插入到html中,来实现页面的首次加载和渲染。它最后生成的样子,应该是这样的:
你可以看到有一些js文件和css样式文件被插入到了页面中,以我们最熟悉的原始的方式,使用`script`和`link`标签来加载。而支持完成这一些自动化转换工作的就是一个叫html-webpack-plugin[6]的插件。
它的大致的用法如下:
// 以index.html为模板,生成一个叫index.html的文件,将其它的依赖注入到页面的合适位置const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = { plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../index.html'), favicon: 'src/common/assets/img/favicon.ico', inject: true }) ], ... };
好了,我们要开始完善webpack的配置了
有了入口文件,还要指定最后打包的产出放到哪个位置,所以要有一个output[7]的选项,指定构建产出的一些配置:
const config = require('./config');module.exports = { entry: { app: './src/main.js' }, output: { path: config.common.assetsRoot, publicPath: config.common.assetsPublicPath, filename: utils.genFilePathWithName('[name].js'), chunkFilename: utils.genFilePathWithName('[name].bundle.js') },};
`output.filename`和`output.path`属性指定了,最后生成的文件名称和放置的位置。`output.publicPath`和`output.chunkFilename`属性指定了文件的引用路径。
module.exports = { //... output: { publicPath: '/assets/', chunkFilename: '[id].chunk.js' }};
一段这样的配置,最后引用的资源路径有可能是这样的`/assets/5.chunk.js`,然后它插入到html文件中就变成了这个样子:
<script src="/assets/5.chunk.js">script>
关于`output.filename`和`output.chunkFilename`的区别,filename主要是指主控逻辑(运行时依赖)的文件名称,这里的取值`[name].js`是和入口文件保持一致的意思,即生成的文件应该是app.js。chunkFilename则指定了按需加载的其它文件的命名方式。`utils.genFilePathWithName`方法是实现了一个将文件按照文件类型进行归类的逻辑。
/** * @file utils.js * @author nimingdexiaohai(nimingdexiaohai@163.com) */const path = require('path');const config = require('./config');module.exports = { // 按文件后缀分组到同类型文件目录下 genFilePathWithName: function(fileName) { return path.posix.join(config.common.assetsSubDirectory, path.extname(fileName).substring(1), fileName); }};
更直白点讲,就是把js文件都放到js目录下,css文件都放到css目录下。这样就实现了一个简单的归类,从这里也可以看出,`output.filename`是支持给定一个路径作为值的。最后文件放置的位置就应该是`output.path` + `output.filename`,比如/assets/js/app.js等。
/** * @file config.js * @author nimingdexiaohai(nimingdexiaohai@163.com) */const path = require('path');module.exports = { dev: { }, prod: { }, common: { assetsRoot: path.resolve(__dirname, '../dist'), assetsPublicPath: '/', assetsSubDirectory: 'static' }};
config.js中抽取了一些公共的配置。指定完了产出配置,再来看下这个需求"处理html/JS/VUE/CSS/LESS/SASS/IMG/JPG等各种文件", 之所以先搞定这个需求,而不是按照顺序先看dev-server,是因为dev-server只有开发时才用得到,我们尽量先搞定一些公共基础配置,这些配置可以复用到其它各种场景,比如开发、测试、生产环境等。
不同文件的打包处理是靠各种loader来实现的,这也是webpack的伟大之处,像处理js文件那样处理其它类型的文件,构建完整的依赖图。它的实现,需要配置`moudle`字段。
const MiniCssExtractPlugin = require('mini-css-extract-plugin');module.exports = { module: { rules: [ { test: /\.vue$/, use: 'vue-loader' }, { test: /\.js$/, use: [{ loader: 'babel-loader', options: { cacheDirectory: true } }], exclude: /node_modules/, }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, { test: /\.less$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] }, { test: /\.(png|gif|svg|ico|jpe?g|woff2?|eot|ttf|otf)(\?.*)?$/, use: [{ loader: 'url-loader', options: { limit: 8192, } }] } ] },};
`module`字段支持配置多个项,每个项包含一个正则和loader配置,意为满足正则匹配的文件使用该loader处理。我们倒着来看,`url-loader`用来处理各种图片、字体文件等,当它小于8kb时,文件会被转换成base64编码,直接硬编码进产出中;当文件大于8kb时,使用file-loader来处理,这个是url-loader的内在实现原理,所以也需要自己安装file-loader。
`.less`文件的处理相对复杂一点,它指定了三个loader,loader是按照配置顺序的逆序加载的,也就是会从后到前依次处理,先将less处理为css,再将css传入MiniCssExtractPlugin.loader处理。mini-css-extract-plugin[8]这个插件是将样式文件单独抽离,单独打包用的。
`mini-css-extract-plugin`这个插件的使用,还需要在plugins中指定抽取出来的样式文件名称,当然你也可以不配置,使用默认规则[name].css。
const VueLoaderPlugin = require('vue-loader/lib/plugin');const HtmlWebpackPlugin = require('html-webpack-plugin');const {CleanWebpackPlugin} = require('clean-webpack-plugin');plugins: [ new VueLoaderPlugin(), new CleanWebpackPlugin(), new MiniCssExtractPlugin({ filename: utils.genFilePathWithName('[name].css') }), new HtmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../index.html'), favicon: 'src/common/assets/img/favicon.ico', inject: true })],
clean-webpack-plugin[9]插件的作用是每次打包前,都将上次output目录中清理一下。这样做的目的,是为了应对当文件命名中包含了hash的时候,每次打包出来的文件名称都不一样,长久积累下去,就会出现很多无用的废弃文件,所以打包之前先清理下文件目录,只保留当前编译的产出。
到这里,我们的公共配置就快要完成了,别着急,还有一个事情。当文件目录特别长的时候,或者我们想要引入文件,而不用写后缀的时候,我们可以配置一些别名来实现小小的偷懒行为。
resolve: { symlinks: false, modules: [path.resolve(__dirname, '../node_modules')], extensions: ['.js', '.vue', '.less', 'css'], alias: { 'vue': 'vue/dist/vue.esm.js', '@': path.resolve(__dirname, '../src') } },
`resolve.symlinks`字段用于指定解析软链的规则。启用时,符号链接资源将解析为其实际路径,而不是其符号链接位置。请注意,当使用symlink软件包的工具时,这可能会导致模块解析失败,因此不推荐开启这个配置。
`resolve.modules`是说当直接匹配你导入的文件路径失败时,去哪些地方继续搜索。比如`import vue form vue;` 直接匹配当前目录下的vue肯定是没有这个文件的,所以就去node_modules中去找,就可以找到了。大部分的三方依赖都是这样被解析到的。
`resolve.extensions`是当你导入一个文件时,可以省略文件后缀,webpack会尝试依次搜索拼接了这些后缀的文件。
`resolve.alias`这个就是给一些目录指定了别名,一般是为了不用写冗长的路径而设置的。
到这里,基本上所有的基础配置就完成了,我们看下它完整的样子:
/** * @file webpack.config.base.js * @author nimingdexiaohai(nimingdexiaohai@163.com) */const path = require('path');const utils = require('./utils');const VueLoaderPlugin = require('vue-loader/lib/plugin');const HtmlWebpackPlugin = require('html-webpack-plugin');const {CleanWebpackPlugin} = require('clean-webpack-plugin');const MiniCssExtractPlugin = require('mini-css-extract-plugin');const config = require('./config');module.exports = { entry: { app: './src/main.js' }, output: { path: config.common.assetsRoot, publicPath: config.common.assetsPublicPath, filename: utils.genFilePathWithName('[name].js'), chunkFilename: utils.genFilePathWithName('[name].bundle.js') }, resolve: { symlinks: false, modules: [path.resolve(__dirname, '../node_modules')], extensions: ['.js', '.vue', '.less', 'css'], alias: { 'vue': 'vue/dist/vue.esm.js', '@': path.resolve(__dirname, '../src') } }, module: { rules: [ { test: /\.vue$/, use: 'vue-loader' }, { test: /\.js$/, use: [{ loader: 'babel-loader', options: { cacheDirectory: true } }], exclude: /node_modules/, }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, { test: /\.less$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] }, { test: /\.(png|gif|svg|ico|jpe?g|woff2?|eot|ttf|otf)(\?.*)?$/, use: [{ loader: 'url-loader', options: { limit: 8192, } }] } ] }, plugins: [ new VueLoaderPlugin(), new CleanWebpackPlugin(), new MiniCssExtractPlugin({ filename: utils.genFilePathWithName('[name].css') }), new HtmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../index.html'), favicon: 'src/common/assets/img/favicon.ico', inject: true }) ], optimization: { splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', chunks: 'initial', priority: -10 }, default: { minChunks: 2, priority: -20, reuseExistingChunk: true } } } }};
【相关文献】
[1] Github: https://github.com/hi-sunshine/Zero-X
[2] webpack-cli: https://www.npmjs.com/package/webpack-cli
[3] webpack-dev-server: https://www.npmjs.com/package/webpack-dev-server
[4] webpack-merge: https://www.npmjs.com/package/webpack-merge
[5] webpack entry: https://webpack.js.org/concepts/#entry
[6] html-webpack-plugin: https://www.npmjs.com/package/html-webpack-plugin
[7] webpack output: https://webpack.js.org/concepts/output/
[8] mini-css-extract-plugin: https://www.npmjs.com/package/mini-css-extract-plugin
[9] clean-webpack-plugin: https://www.npmjs.com/package/clean-webpack-plugin
【小结】
是不是看起来还不错?下一节,我们搞一下开发环境和生产环境的差异化配置。请持续关注《教你自己搭建一个脚手架(二)》哦,敬请期待~