​时下,前端领域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项目就搭建完成了,看一下项目结构。

结构.png

完整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~~~

本文首发于本人公众号,前端小白菜,分享与关注前端技术,欢迎关注~~

前端小白菜


孤月
144 声望5 粉丝

保持一颗不断进取的心,相信自己终会成功。