共计 6959 个字符,预计需要花费 18 分钟才能阅读完成。
时下,前端领域 Vue/React/Angular 三剑客横行,一般来说,开发前端项目都会从这三个框架中选取一个,一些大厂甚至会基于这三大框架构建更适合业务的开发框架。
然而有些时候,需要开发一些比较小的项目,只有一个页面或者只有几个小页面,这时候用这些框架就显得有点重了,直接原生开发又无法使用 sass/ES6 之类的新技术,总归是有些不舒服的。这种情况下,可以搭建一个小型的 webpack 多页面项目。
接下来一步步从零搭建一个 webpack+typescript+babel+sass 的多页应用脚手架。
一、配置 webpack
现在 webpack 基本上已经成为前端工程项目的标配,不仅平时工作中需要使用到,也是前端面试必不可少的一环,曾经一度还有 webpack 配置工程师的说法。
先初始化一个项目吧:
npm init -y
再添加一下 webpack 和 webpack-cli
npm i webpack webpack-cli -D
建立一个 webpack 配置文件 webpack.config.js:
const config = { | |
mode: 'development', | |
entry: {}, | |
output: {}, | |
resolve: {}, | |
module: {}, | |
plugins: [],}; | |
| |
module.exports = config; |
添加入口与输出位置:
{ | |
entry: { | |
index: './src/pages/index.ts', | |
about: './src/pages/about.ts', | |
}, | |
output: {filename: 'assets/js/[name].[hash:8].js', | |
path: './dist/', | |
}, | |
} |
不想多做处理,可以就直接写死入口,每多一个页面,就在配置中多加一个入口,这样的话,一两个页面没什么问题,但是问题稍微多一点,就不太好了,作为一个工程师,肯定是需要花半个小时做成自动化的来节省每次几十秒的手动配置的啦。
二、自动读取入口文件
想要自动读取也很简单,只需要定好创建入口文件规则,然后使用 Node Path API 遍历一下,自动添加到 webpack 配置文件中就好了。主要用到 path 和 fs 两个包,那么写两个工具函数来读取一下入口文件夹吧。
const path = require('path'); | |
const fs = require('fs'); | |
| |
| |
/** | |
* 同步判断文件是否存在 | |
* | |
* @param {string} file 文件地址 | |
*/ | |
function isFileExistAsync(file) { | |
try {fs.accessSync(file, fs.constants.F_OK); | |
} catch (err) {return false;} | |
return true; | |
} | |
| |
| |
/** | |
* 遍历某个文件夹,找出该文件夹下的所有一级子文件夹 | |
* | |
* @param {string} dir 文件目录地址 | |
*/ | |
function readDir(dir) {let res = []; | |
const list = fs.readdirSync(dir); | |
list.forEach(file => {const pageDir = path.resolve(dir, file); | |
const info = fs.statSync(pageDir); | |
if(info.isDirectory()){res.push(pageDir); | |
} | |
}); | |
return res; | |
} |
有了这两个函数,咱们可以把符合要求的入口文件添加的 config 对象中,暴露给 webpack。
const entryScriptExt = 'ts';// 入口文件是 ts 文件,也可以换成 js 文件 | |
readDir(pageRoot).forEach(dir => {const entry = path.resolve(dir, `index.${entryScriptExt}`); | |
if (isFileExistAsync(entry)) {const { name} = path.parse(dir); | |
config.entry[name] = entry; | |
} | |
}); |
三、Babel 处理
目前 web 端还不支持 ES6/7,新一点浏览器支持性会好一些,但也不是完全支持,想要使用 ES6/ 7 以及 TypeScript 还是需要使用 Babel 将代码编译到 es5 才行,另外这里使用 TypeScript,Babel7 中可以使用 @babel/preset-typescript 来编译 TypeScript,这样也就不需要再加载一个 ts-loader 了。
先添加依赖:
npm i @babel/core @babel/preset-env @babel/preset-typescript @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread babel-loader -D
修改 webpack 配置:
{ | |
module: { | |
rules: [{test: /\.(ts|js)?$/, | |
exclude: /(node_modules|bower_components)/, | |
use: { | |
loader: 'babel-loader', | |
options: { | |
presets: [ | |
'@babel/preset-env', | |
'@babel/preset-typescript', | |
], | |
plugins: [ | |
'@babel/plugin-proposal-class-properties', | |
'@babel/plugin-proposal-object-rest-spread' | |
], | |
} | |
} | |
}], | |
} | |
} |
四、css 预处理
css 可以使用 sass/less 或者其他你喜欢的处理器来处理,这里以 sass 为例:
先添加依赖:
npm i sass-loader node-sass style-loader css-loader -D
修改 webpack 配置:
{ | |
module: { | |
rules: [{ | |
test: /\.scss$/, | |
use: [{loader: 'style-loader',}, {loader: 'css-loader',}, {loader: 'sass-loader',}], | |
}], | |
} | |
} |
五、静态资源处理
除了 css 与 js,咱们还需要处理一下图片,不然可能会出现图片 404 的现象。这里使用 url-loader 和 html-loader 来处理 css 与 html 中的图片。url-loader 还可以将一些小图片转化成 base64 内联在 html 中,这样可以减少很多小图片的请求。
使用 html-loader 是因为,html 中的图片路径如果不加处理,webpack 会把它当做字符串从而忽略掉了这个文件,使用 html-loader 可以将图片的 src 转化为 require 加载,从而被 webpack 捕获,最终被 url-loader 处理。
<img src="./image.png" />
变成使用 require 加载,再赋给图片地址,以下为示意,只说明原理。
<img src="<%require('./image.png')%>" />
先添加依赖:
npm i url-loader html-loader -D
修改 webpack 配置:
{ | |
module: { | |
rules: [{test: /\.(png|jpg|gif|jpeg|webp|svg|bmp|eot|ttf|woff)$/, | |
use: [{ | |
loader: 'url-loader', | |
options: { | |
limit: 8192, | |
name:'assets/images/[name]-[hash:8].[ext]', | |
} | |
}] | |
}, {test: /\.(html)$/, | |
use: { | |
loader: 'html-loader', | |
options: {attrs: [':data-src', ':src'], | |
} | |
}, | |
}], | |
} | |
} |
六、自动插入 js 文件
这里使用的是静态文件开发模式,最终生成的文件是应该是一个 html 加一个 js 文件的,开发的时候是一个 html 模板加一个 ts 文件,最终 dist 目录下也应该有这个模板文件,这里需要 html-webpack-plugin 插件来将模板文件复制到 dist 目录,并且将生成的 js 文件插入到模板中。
npm i html-webpack-plugin -D
这是一个插件,一次调用只能处理一个入口文件,这里是多个入口,所以需要修改一下上面的入口文件遍历。
readDir(pageRoot).forEach(dir => {const entry = path.resolve(dir, `index.${entryScriptExt}`); | |
if (isFileExistAsync(entry)) {const { name} = path.parse(dir); | |
const page = path.resolve(dir, 'index.html'); | |
const htmlWebpackPluginConfig = {filename: `${name}.html`, | |
chunks: [name], | |
minify: !isDev && { | |
removeAttributeQuotes:true, | |
removeComments: true, | |
collapseWhitespace: true, | |
removeScriptTypeAttributes:true, | |
removeStyleLinkTypeAttributes:true | |
}, | |
}; | |
if (isFileExistAsync(page)) {htmlWebpackPluginConfig.template = page;} | |
config.entry[name] = entry; | |
config.plugins.push(new HtmlWebpackPlugin(htmlWebpackPluginConfig)); | |
} | |
}); |
每个入口都实例化一个插件实例,如果没有提供模板文件,则使用默认的模板文件。
七、开发服务器
开发的时候还需要配合 webpack-dev-server,这样开发的时候可以启动一个开发服务,并且可以实时编译和热替换,开发利器~~
npm i webpack-dev-server cross-env -D
开发服务器应该只在开发环境下使用,线上环境是不需要的,通过判断 NODE_ENV 来确定是不是开发环境,只在开发环境下添加相关配置。
const isDev = process.env.NODE_ENV === 'development'; | |
isDev && (config.devServer = {contentBase: path.join(__dirname, 'dist'), | |
compress: true, | |
hot: true, | |
port: 10086, | |
}); |
再看一下启动命令
"scripts": { | |
"dev": "cross-env NODE_ENV=development webpack-dev-server --config ./build/webpack.config.js", | |
"prod": "cross-env NODE_ENV=production webpack --config ./build/webpack.config.js --mode=production" | |
} |
完美,一个小型的多页 webpack 项目就搭建完成了,看一下项目结构。
完整 webpack 配置文件如下
const path = require('path'); | |
const fs = require('fs'); | |
const HtmlWebpackPlugin = require('html-webpack-plugin'); | |
const {CleanWebpackPlugin} = require('clean-webpack-plugin'); | |
| |
const isDev = process.env.NODE_ENV === 'development'; | |
const pageRoot = path.resolve(__dirname, '../src/pages'); | |
const dist = path.resolve(__dirname, '../dist'); | |
const entryScriptExt = 'ts';// 入口文件是 ts 文件 | |
| |
const config = { | |
devtool: isDev ? 'inline-source-map' : 'none', | |
mode: 'development', | |
entry: {}, | |
output: {filename: 'assets/js/[name].[hash:8].js', | |
path: dist, | |
}, | |
resolve: {extensions: ['.tsx', '.js', '.jsx'], | |
}, | |
module: { | |
rules: [{test: /\.(ts|js)?$/, | |
exclude: /(node_modules|bower_components)/, | |
use: { | |
loader: 'babel-loader', | |
options: { | |
presets: [ | |
'@babel/preset-env', | |
'@babel/preset-typescript', | |
], | |
plugins: [ | |
'@babel/plugin-proposal-class-properties', | |
'@babel/plugin-proposal-object-rest-spread' | |
], | |
} | |
} | |
}, { | |
test: /\.scss$/, | |
use: [{loader: 'style-loader',}, {loader: 'css-loader',}, {loader: 'sass-loader',}], | |
}, {test: /\.(png|jpg|gif|jpeg|webp|svg|bmp|eot|ttf|woff)$/, | |
use: [{ | |
loader: 'url-loader', | |
options: { | |
limit: 8192, | |
name:'assets/images/[name]-[hash:8].[ext]', | |
} | |
}] | |
}, {test: /\.(html)$/, | |
use: { | |
loader: 'html-loader', | |
options: {attrs: [':data-src', ':src'], | |
} | |
}, | |
}, ], | |
}, | |
plugins: [new CleanWebpackPlugin(), | |
], | |
}; | |
| |
isDev && (config.devServer = {contentBase: path.join(__dirname, 'dist'), | |
compress: true, | |
hot: true, | |
port: 10086, | |
}); | |
| |
readDir(pageRoot).forEach(dir => {const entry = path.resolve(dir, index.${entryScriptExt}); | |
if (isFileExistAsync(entry)) {const { name} = path.parse(dir); | |
const page = path.resolve(dir, 'index.html'); | |
const htmlWebpackPluginConfig = {filename: `${name}.html`, | |
chunks: [name], | |
minify: !isDev && { | |
removeAttributeQuotes:true, | |
removeComments: true, | |
collapseWhitespace: true, | |
removeScriptTypeAttributes:true, | |
removeStyleLinkTypeAttributes:true | |
}, | |
}; | |
if (isFileExistAsync(page)) {htmlWebpackPluginConfig.template = page;} | |
config.entry[name] = entry; | |
config.plugins.push(new HtmlWebpackPlugin(htmlWebpackPluginConfig)); | |
} | |
}); | |
| |
/** | |
* 同步判断文件是否存在 | |
* | |
* @param {string} file 文件地址 | |
*/ | |
function isFileExistAsync(file) { | |
try {fs.accessSync(file, fs.constants.F_OK); | |
} catch (err) {return false;} | |
return true; | |
} | |
| |
/** | |
* 遍历某个文件夹,找出该文件夹下的所有一级子文件夹 | |
* | |
* @param {string} dir 文件目录地址 | |
*/ | |
function readDir(dir) {let res = []; | |
const list = fs.readdirSync(dir); | |
list.forEach(file => {const pageDir = path.resolve(dir, file); | |
const info = fs.statSync(pageDir); | |
if(info.isDirectory()){res.push(pageDir); | |
// 暂时不要多层嵌套 | |
// res = [...res, ...readDir(pageDir)]; | |
} | |
}); | |
return res; | |
} | |
| |
module.exports = config; |
完整项目源代码放在 github 上,可以直接当做脚手架使用,如果此项目对您有帮助的话,欢迎不吝 star~~~
本文首发于本人公众号, 前端小白菜 ,分享与关注前端技术,欢迎关注~~