目标是实现webpack打包零配置

270

目标是实现webpack打包零配置(配置在内部已默认设置,可重写覆盖)

创建脚手架cli

包括创建项目、本地运行项目、打包线上项目

基本功能

  • SPA/MPA 单选
  • Router、Vuex 多选
  • eslint 默认安装
  • 自动安装依赖,无需npm i
  • 默认别名'@' 指向'./src' 如:import home from "@/pages/home"

创建命令

新建一个项目如: test-cli,创建package.json, 在bin对象中定义命令名称及入口

package.json

"bin": {
    "test-cli": "bin/cli.js"
 }

commander

node.js 命令行界面的完整解决方案。

npm commander

bin/cli.js

#!/usr/bin/env node

// 定义指令
const program = require("commander");
program.command("create <project-name>")
    .description("create a new project powered by test-cli")
    .option("-f, --force", "Overwrite target directory if it exists")
    .action((name, options) => {
        require('../lib/create')(name, options) //添加执行文件
    });
program.parse(process.argv);

/usr/bin/env就是告诉系统可以在PATH目录中查找。 所以配置#!/usr/bin/env node, 就是解决了不同的用户node路径不同的问题,可以让系统动态的去查找node来执行你的脚本文件

npm link

本地项目和本地npm模块之间建立连接,可以在本地进行模块测试

在test-cli根目录 执行npm link,这时就可以在终端中输入 test-cli create [项目名]命令

inquirer

一组常见的交互式命令行用户界面。

npm inquirer

prompts.js

module.exports = {
  features: () => {
    return [
      {
        name: "isSPA",
        type: "list",
        message: `create project SPA or MPA`,
        choices: [
          { name: "SPA", value: PromptKey.isSPA.spa },
          { name: "MPA", value: PromptKey.isSPA.mpa },
        ],
      },{
        name: 'features',
        type: 'checkbox',
        when(anwser){
          if(anwser.isSPA === PromptKey.isSPA.spa){
            return true
          } else {
            chalk.red.bold(`MPA support future!!`)
            exit(1);
            return false
          }
        },
        message: 'Check the features needed for your project:',
        choices: [PgRouter, PgVuex],
        pageSize: 10
      }
    ];
  },
  async handle({anwsers, framework}){
    let templatesPreDir = `${framework}/${anwsers.isSPA}/`
    let result = {templates : [], plugins: [], templatesPreDir};
    anwsers.features.map(f => {
      let plugin = plugins[f]({value: PromptKey.features[f]});
      f = f === PromptKey.features.vuex ? 'store' : f;
      result.templates.push({
        templatesPreDir,
        path: templatesPreDir + f,
        writeInMain: plugin.writeInMain
      })
      result.plugins.push({
        dependencies: plugin.dependencies
      })
    })
    return result
  }
};

根据用户选择的命令处理模板

创建模板

在github或gitlab中创建一个vue(因该脚手架后面会增加vue构建及打包功能,所有选择vue)项目模板,模板根据自身需要创建目录

cli-01.png

下载模板

把模板下载到当前目录

git-clone

通过 shell 命令克隆一个 git 存储库。 npm git-clone

loadRemote.js

const gitClone = require('git-clone')
const chalk = require('./chalk')

module.exports = async function loadRemote(repository, branch, filePath){
  
  branch = branch || 'master'
  // await fs.remove(filePath)
  return await new Promise((resolve, reject) => {
    if(!filePath){
      chalk.bold.red('没有指定下载到目标目录')
      reject()
    }
    gitClone(repository, filePath, { checkout: branch }, err => {
      if (err) return reject(err)
      resolve()
    })
  }).catch(err => {
    console.log(err)
    chalk.bold.red(`git clone error ${err && err.message}`)
  })
}

chalk

终端字符串样式

npm chalk

删除.git文件 fs.removeSync(targetDir + '/.git')

ejs

嵌入式 JavaScript 模板

npm ejs

把prompts 用户选择的数据,传递到ejs模板中

import Vue from 'vue';
import App from './App.vue';
<% if (typeof router !== 'undefined') { %><%- router.import %><% } %>
<% if (typeof vuex !== 'undefined') { %><%- vuex.import%><% } %>

Vue.config.productionTip = false;

new Vue({
  <% if (typeof router !== 'undefined') { %><%- router.vueOpt%><% } %>
  <% if (typeof vuex !== 'undefined') { %><%- vuex.vueOpt%><% } %>
  render: h => h(App),
}).$mount('#app');

把ejs返回的数据写入到模板的main.js中
fs.writeFileSync(path.resolve(targetDir, './src/main.js'), str)

prettier

美化代码

npm prettier

读取模板中的.prettierrc.js文件,进行格式化代码

format.js

const fs = require('fs-extra')
const path = require('path')
const prettier = require("prettier")

class Format {
  constructor(targetDir){
    this.targetDir = targetDir
    this.formatOptions = {}

    // 获取格式化文件
    if(fs.existsSync(`${targetDir}/.prettierrc.js`)){
      this.formatOptions = require(`${targetDir}/.prettierrc.js`)
    }

  }
  run(_path){
    let url
    if(_path){
      url = _path
    }else{
      url = this.targetDir
    }
    fs.readdir(url, (err, files) => {
      if (err) {
          console.log('err', err)
      } else {
          files.forEach ( (filename) => {
              // 获取绝对路径
              let filedir = path.join(url, filename)
              fs.stat(filedir, (error, stats) => {
                  if (error) {
                      console.log(error)
                  } else {
                      // 文件夹、文件的不同处理
                      let isFile = stats.isFile()
                      let isDir = stats.isDirectory()

                      if (isFile) {
                        try{
                          let parser = 'babel'
                          const extname = path.extname(filedir)
                          if(extname === '.vue'){
                            parser = 'vue'
                          }else if(extname === '.html'){
                            parser = 'html'
                          }
                          const exts = ['.vue', '.js', 'html']
                          if(!exts.includes(extname)){
                            return
                          }

                          this.formatOptions.parser = parser
                          const formatTemp = prettier.format(fs.readFileSync(filedir, 'utf-8'), this.formatOptions)
                          fs.writeFileSync(filedir, formatTemp)
                        }catch(err){
                          console.log('写入文件失败!', err)
                        }
                      }

                      if (isDir) {
                        // 递归
                        this.run(filedir)
                      }
                  }
              })
          })
      }
    })
  }
}

module.exports = Format;

install

execa

execa是可以调用shell和本地外部程序的javascript封装

npm execa

install.js

/**
 * 自动化安装依赖包
 */
const execa = require('execa')
const ora = require('ora')
const { chalk } = require("../utils/index")

class Installer {
    async run(targetDir){

      const spinner = ora(chalk.blue('Loading install...')).start()

      const command = 'npm'
      const args = ['install', '--loglevel', 'error']
      try{
        await execa(command, args, {
          cwd: targetDir,
          stdio: ['inherit', 'inherit', 'inherit']
        })
        spinner.succeed(chalk.green('package install done!'))
      }catch(e){
        spinner.fail(chalk.red('package install fail!'))
      }
      spinner.stop()
    }
}
module.exports = new Installer();

ora

优雅的终端旋转器

npm ora

create.js

const fs = require('fs-extra')
const path = require('path')
const { exit } = require('process');
const { chalk, loadRemote } = require("../utils/index");
const packageJson = require('../package.json')
const promptFactory = require('./prompts/promptFactory')
const moveTemplate = require('./moveTemplate');
const install = require('./install');
const Format = require('./format');

async function create (projectName, options) {
  const cwd = options.cwd || process.cwd()
  const targetDir = path.resolve(cwd, projectName)
  
  const framework = 'vue'; // vue是test-project中的一个目录,之后可能会有react等模板,也会有react目录
  const prompts = new promptFactory(framework, {targetDir, ...options});
  await prompts.handlePrompts();

  chalk.yellow('\nload remote source...')
  await loadRemote(packageJson["test-project"].repository, framework, targetDir) 
  fs.removeSync(targetDir + '/.git')
  chalk.yellow('\nload remote source done')

  let tpls = prompts.getTemplates()
  let templatesPreDir = prompts.getTemplatesPreDir()

  await moveTemplate(tpls, targetDir, templatesPreDir).catch(err => {
    chalk.red.bold(`move templates error ${err && err.message}`)
  })

  const format = new Format(targetDir)
  format.run()

  install.run(targetDir)
}
module.exports = (...args) => {
  return create(...args).catch(err => {
    console.log('create error', err)
    process.exit(1)
  })
}

cli 完成

cli-service

目标是实现webpack打包零配置

依赖于test.config.js(test-project)

基本功能

  • 支持vue Spa模式
  • 支持dev,test,build环境配置
  • 支持原生webpack配置
  • 支持js/css/image压缩
  • 支持配置静态资源cdn
  • 支持css/sass/less/stylus, 支持css module和css extract特性
  • 支持font引用
  • 支持eslint, postcss特性
  • build环境默认启动webpack-bundle-analyzer

创建命令

program
    .command('serve')
    .description('start the development environment application')
    .action(() => {
        require('../cmd/dev')
    })

program
    .command('build')
    .description('compiles the application for production deployment')
    .option('-t, --test', 'start the test environment application')
    .action((options) => {
        require('../cmd/build')(options)
    })

在上面的模板中(test-project) 读取test.config.js, 根据配置进行打包编译等处理

test.config.js

const path = require('path')
const resolve = (filePath) => path.resolve(process.cwd(), filePath)

module.exports = {
    entry: './src/main.js',
    dev: {
        filename: '[name].bundle.js',
        path: resolve('./dist'),
        publicPath: '/'
    },
    build: {
        filename: '[name].bundle.js',
        path: resolve('./dist'),
        // publicPath: '//www.baidu.com/',
        publicPath: '/',
        assetModuleFilename: 'images/[hash][ext][query]'
    },
    resolve: {},
    module: {
        rules: []
    },
    plugins: [],
    devServer: {
        proxy: {
            '/api': {
                target: 'http://www.baidu.com',
                pathRewrite: { '^/api': '' },
                changeOrigin: true
            }
        }
    }
}

config

打包系统-默认配置,test.config.js可重写并覆盖

webpack.js

"use strict";

const path = require('path')

const baseDir = process.cwd()
const resolve = (filePath) => path.resolve(process.cwd(), filePath)

module.exports = {
    context: baseDir,
    entry: './src/main.js',
    output: {
        filename: '[name].bundle.[contenthash].js',
        publicPath: '/',
        path: resolve('./dist'),
        assetModuleFilename: 'images/[hash][ext][query]'
    },
    mode: '',   //development, production(默认) 或 none
    resolve: {
        extensions: [".js", ".vue", "..."],
        alias: {
            '@': resolve('./src'),
            'vue$': 'vue/dist/vue.esm.js'
        }
    },
    externals: [],
    resolveLoader: {
        modules: [
            path.join(baseDir, "node_modules"),
            path.join(__dirname, "../node_modules")
        ]
    },
    module: {
        rules: []
    },
    optimization: {},
    plugins: []
}

rules.js

"use strict";

module.exports = [
{
    test: /\.css$/,
    use: [
        // mini-css-extract-plugin 和 style-loader 是一样的功能(将JS字符串嵌入<style>标签内),不可以同时使用。
        // "style-loader",
        "css-loader",
        "postcss-loader"
    ]
},
{
    test: /\.sass$/,
    use: [
        // "style-loader",
        "css-loader",
        "sass-loader",
        "postcss-loader"
    ]
},
 {
    test: /\.less$/,
    use: [
        // "style-loader",
        "css-loader",
        "less-loader",
        "postcss-loader"
    ]
}, 
{
    test: /\.stylus/,
    use: [{
        loader: "css-loader",
        options: {
            sourceMap: false
        }
    }, {
        loader: "stylus-loader",
        options: {
            sourceMap: false
        }
    }, {
        loader: "postcss-loader"
    }]
},
{
    test: /\.ts$/,
    exclude: [/node_modules/],
    use: [
        "ts-loader"
    ]
}, 
{
    test: /\.vue$/,
    loader: 'vue-loader'
},
{
    test: /\.html$/,
    use: [
        "html-loader"
    ]
},
{
    test: /\.(jsx?|ts)$/,
    exclude: [/node_modules/],
    use: [
        "eslint-loader"
    ],
    enforce: "pre"
},
{
    test: /\.(png|jpg|gif|jpeg|svg|ttf|eot|woff2?|oft)$/,
    type: 'asset',
    parser: {
      dataUrlCondition: {
        maxSize: 10000
      }
    }
  }
]

plugins.js

"use strict";

const path = require('path')
const webpack = require('webpack')

/**
 * 获取当前env
 * @returns env 环境
 */
function getEnv() {
    const NODE_ENV = process.env.NODE_ENV
            ? process.env.NODE_ENV
            : this.env ? this.env : "development"
    return JSON.stringify(NODE_ENV)
}

/**
 * 配置全局变量
 */
exports.define = {
    name: webpack.DefinePlugin,
    args() {
        const NODE_ENV = getEnv.call(this)
        return {
            "process.env.NODE_ENV": NODE_ENV
        }
    }
}

/**
 * 创建html
 */
exports.html = {
    name: 'html-webpack-plugin',
    args() {
        const NODE_ENV = getEnv.call(this)
        return {
            title: 'test-service',
            minify: NODE_ENV === 'production',
            template: path.resolve(
                this.baseDir,
                './src/index.html'
            )
        }
    }
}

/**
 * vue-loader webpack5需要单独vue-loader-plugin
 */
exports.vue = {
    name: 'vue-loader',
    entry: 'VueLoaderPlugin'
}

/**
 * CSS 提取到单独的文件中
 */
exports.mincss = {
    env: ['prod'],
    name: 'mini-css-extract-plugin',
    args() {
        return {
            filename: '[name].[contenthash].css',
            chunkFilename: '[id].[contenthash].css',
            ignoreOrder: true
        }
    }
}

/**
 * 复制文件
 */
exports.copy = {
     name: 'copy-webpack-plugin',
     args() {
         return {
             patterns:[{
                 from: path.resolve(this.baseDir, './src/static'),
                 to: path.resolve(this.baseDir, './dist')
             }]
         }
     }
 }

/**
 * 可视化输出文件的大小
 */
exports.analyzer = {
    env: ['prod'],
    name: 'webpack-bundle-analyzer',
    entry: 'BundleAnalyzerPlugin'
}

/**
 * 压缩css
 */
exports.cssminimizer = {
    env: ['prod'],
    name: 'css-minimizer-webpack-plugin'
}

合并webpack配置(test.config.js和config目录下的webpack.js合并)

webpack-merge

webpack 配置项合并
npm gitwebpack-merge

设置rules

合并test.config.rules.js合并

baseConfig.js

setRules(rules) {
    if(rules){
        this.rules = utils.unionBy(this.rules, rules, 'test')
    }

    const target = _.cloneDeep(this.rules)

    const cssExtension = ['css','wxss','less','postcss','sass','scss','stylus','styl']
    const pattern=/[\\\/.$]/g;

    target.map((item)=>{
        const name = item.test.toString().replace(pattern, '')
        if(cssExtension.includes(name)){
            if(this.env === 'production'){
                item.use.unshift({
                    loader: MiniCssExtractPlugin.loader,
                    options: {
                        esModule: false
                    }
                })
            }else{
                item.use.unshift('style-loader')
            }
            
        }
    })

    this.webpackConfig.module.rules = target
}

设置plugins

setPlugins(plugins){
    let target = _.cloneDeep(this.plugins)
    
    let webpackPlugins = []

    Object.keys(target).forEach(name => {
        let itemPlugin = target[name]

        if(this.isEnv(itemPlugin.env)){
            
            let plugin, pluginName
            
            if (_.isString(itemPlugin.name) || _.isFunction(itemPlugin.name)) {
                let Clazz = itemPlugin.name
                if (_.isString(itemPlugin.name)) {
                    pluginName = itemPlugin.name;
                    Clazz = require(itemPlugin.name)
                } else if (_.isFunction(itemPlugin.name)) {
                    pluginName = itemPlugin.name.name
                }

                if (itemPlugin.entry) {
                    Clazz = Clazz[itemPlugin.entry]
                }

                if (itemPlugin.args) {
                    let args;
                    if (_.isFunction(itemPlugin.args)) {
                        args = itemPlugin.args.apply(this)
                    } else {
                        args = itemPlugin.args
                    }
                    plugin = new (Function.prototype.bind.apply(Clazz, [null].concat(args)))
                } else {
                    plugin = new Clazz()
                }
            }

            if (plugin) {
                plugin.__plugin__ = pluginName
                plugin.__lable__ = name
                webpackPlugins.push(plugin)
            }
        }
    })

    let newPlugins = webpackPlugins
    if(plugins){
        // 基础plugins配置 合并 test.config.js plugins
        let mergePlugins = [...plugins, ...webpackPlugins] 
        let hash = {}
        newPlugins = mergePlugins.reduce((item, next) => {
            hash[next.constructor.name] ? '' : hash[next.constructor.name] = true && item.push(next)
            return item
        }, [])
    }
    
    this.webpackConfig.plugins = newPlugins
}

dev环境启动serve

webpack-dev-server

创建本地服务器 npm webpack-dev-server

devConfig.js

"use strict";

const webpackDevServer = require("webpack-dev-server")
const BaseConfig = require('./BaseConfig')

class DevConfg extends BaseConfig {
    constructor(options){
        super(options)

        this.options = options

        this.init()
    }

    init(){
        this.options.mode = 'development'

        if(this.options.dev){
            this.webpackConfig.output = this.options.dev
        }

        this.initBase(this.options)

    }

    run(){
        this.createDevServer()
    }

    /**
     * 创建 devServer 
     */
    createDevServer(){
        logger.info('start server...')

        const devServer = this.mergeDevServerConfig()

        const compiler = this.webpack(this.webpackConfig)
        const server = new webpackDevServer(compiler, devServer)

        server.listen(this.port, '0.0.0.0', err => {
            if(err){
                console.log(err)
            }
        })

        const sigs = ["SIGINT", "SIGTERM"]
        sigs.forEach(function(sig) {
            process.on(sig, function() {
                server.close()
                process.exit()
            });
        });
    }
    /**
     * 合并devServer配置
     */
    mergeDevServerConfig() {
        let devServer = {
            hot: true,
            open: true,
            disableHostCheck: true
        }
        if(this.webpackConfig.devServer){
            devServer = Object.assign({}, devServer, this.webpackConfig.devServer)
        }

        return devServer
    }
}

module.exports = DevConfg

prod打包及测试环境打包

"use strict";

const webpack = require('webpack')
const BaseConfig = require('./BaseConfig')

class ProdConfg extends BaseConfig {
    constructor(options){
        super(options)

        this.options = options

        this.init()
    }

    init(){
        this.options.mode = 'production'
        this.options.devtool = 'source-map'

        if(this.options.build && !this.options.test){
            this.webpackConfig.output = this.options.build
        }
        if(this.options.test && this.options.dev){
            this.webpackConfig.output = this.options.dev
        }

        // webpack5 已内置该插件
        // this.webpackConfig.optimization.minimize = true
        // this.webpackConfig.optimization.minimizer = [new TerserPlugin()]
        
        this.initBase(this.options)
    }

    run() {
        this.builder()
    }

    /**
     * prod 打包
     */
    builder(){
        webpack(this.webpackConfig, (err, stats) => {
            if (err) console.log(err)
            if (stats.hasErrors()) {
                console.log(new Error(`Build failed with errors.', ${stats.toString({colors: true})}`))
            }
        })
    }
}

module.exports = ProdConfg

cli-service 完成

test-project 中的package.json中要引入test-service 包

直接把test-cli和cli-service包发布到npm npm publish

已上为部分代码,仅供参考