从零开始构建 Vue + TypeScript 脚手架模版

2,078 阅读5分钟

前言

通常我们开发vue项目,都会使用到vue-cli脚手架工具构建想要的项目模版,这样固然能无视各种配置,高效地编写项目。可是vue-cli这样类似黑盒的模版配置,对于初学者来说,其实是很伤的。每天都只会关注业务逻辑代码,写了一两年甚至连webpack的基本配置都不了解。

而且,vue-cli 即使方便,也不能完全契合我们的项目,这时我们就需要手动的构建项目初始模版。为实现项目的工程化打下基础。

项目地址

项目地址

  • master: webpack4 + express + vue
  • ts-dev: webpack4 + express + vue(vue-class-component) + typescript (本文讲解)

构建文件目录

`-- build                                   构建文件目录
|   |-- configs                             项目配置
|       |-- appEnvs.js                      全局变量配置
|       |-- options.js                      其他配置
|       |-- proxy.js                        服务代理配置
|   |-- plugin                              插件
|   |-- rules                               规则
|   `-- development.js                      开发环境配置
|   `-- production.js                       生产环境配置
|   `-- webpack.base.js                     基础环境配置
|-- public                                  公共文件夹
`-- src                                     代码目录
    |-- @types                              typescript 声明文件
    |-- http                                http 文件
    |-- assets                              多媒体 文件
    |-- components                          组件 文件
    |-- store                               状态 文件
    |-- utils                               工具 文件
    |-- views                               视图 文件夹 
    |   |-- home
    |        |-- home.module.scss           css module 文件
    |        |-- index.tsx                  tsx 文件
    |-- App.tsx        
    |-- main.ts                             入口文件   
    |-- router.ts                           路由文件   
|-- .editorconfig
|-- .prettierrc         
|-- .postcssrc.js
|-- babel.config.js
|-- package.json
|-- tsconfig.json
|-- tslint.json

webpack 配置

  • webpack.base.js
const path = require("path");
// 抛出一些配置, 比如port, builtPath
const config = require("./configs/options");
// css less scss loder 整合
const cssLoaders = require("./rules/cssLoaders");

function resolve(name) {
  return path.resolve(__dirname, "..", name);
}

// 开发环境变更不刷新页面,热替换
function addDevClient(options) {
  if (options.mode === "development") {
    Object.keys(options.entry).forEach(name => {
      options.entry[name] = [
        "webpack-hot-middleware/client?reload=true&noInfo=true"
      ].concat(options.entry[name]);
    });
  }
  return options.entry;
}

// webpack 配置
module.exports = options => {
  const entry = addDevClient({
    entry: {
      app: [resolve("src/main.ts")]
    },
    mode: options.mode
  });
  return {
    // Webpack打包的入口
    entry: entry,
    // 定义webpack如何输出的选项
    output: {
      publicPath: "/",  // 构建文件的输出目录
      path: resolve(config.builtPath || "dist"), // 所有输出文件的目标路径
      filename: "static/js/[name].[hash].js", // 「入口(entry chunk)」文件命名模版
      chunkFilename: "static/js/[name].[chunkhash].js" // 非入口(non-entry) chunk 文件的名称
    },
    resolve: {
      // 模块的查找目录
      modules: [resolve("node_modules"), resolve("src")],
      // 用到的文件的扩展
      extensions: [".tsx", ".ts", ".js", ".vue", ".json"],
      // 模块别名列表
      alias: {
        vue$: "vue/dist/vue.esm.js",
        "@components": resolve("src/components"),
        "@": resolve("src")
      }
    },
    // 防止将某些 import 的包(package)打包到 bundle 中,
    // 而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。
    // 减少打包后的问价体积。在首页加入 cdn 引入
    externals: {
      vue: "Vue",
      vuex: "Vuex",
      "vue-router": "VueRouter"
    },
    // 模块相关配置
    module: {
      rules: [
        {
          test: /(\.jsx|\.js)$/,
          use: ["babel-loader"],
          exclude: /node_modules/
        },
        // .tsx 文件的解析
        {
          test: /(\.tsx)$/,
          exclude: /node_modules/,
          use: ["babel-loader", "vue-jsx-hot-loader", "ts-loader"]
        },
        {
          test: /(\.ts)$/,
          exclude: /node_modules/,
          use: ["babel-loader", "ts-loader"]
        },
        ...cssLoaders({
          mode: options.mode,
          sourceMap: options.sourceMap,
          extract: options.mode === "production"
        }),
        {
          test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
          loader: "url-loader",
          options: {
            limit: 10000,
            name: "static/img/[name].[hash:7].[ext]"
          }
        },
        {
          test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
          loader: "url-loader",
          options: {
            limit: 10000,
            name: "static/media/[name].[hash:7].[ext]",
            fallback: "file-loader"
          }
        },
        {
          test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
          loader: "url-loader",
          options: {
            limit: 10000,
            name: "static/fonts/[name].[hash:7].[ext]"
          }
        }
      ]
    }
  };
};

开发环境配置

  • development.js
const webpack = require('webpack')
const path = require('path')

const express = require('express')

const merge = require('webpack-merge')
const chalk = require('chalk')

// 两个合体实现本地服务热替换
// 具体实现 https://github.com/webpack-contrib/webpack-hot-middleware
const webpackMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')

const ProgressBarPlugin = require("progress-bar-webpack-plugin");
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')

const baseConfig = require('./webpack.base')
const config = require('./configs/options')

const HtmlWebpackPlugin = require('html-webpack-plugin')

const proxyTable = require('./configs/proxy')

// http-proxy-middleware 添加代理
const useExpressProxy = require('./plugins/useExpressProxy')

// 全局变量
const appEnvs = require('./configs/appEnvs')
const app = express()

// 合并 webpack 请求
const compiler = webpack(merge(baseConfig({ mode: 'development' }), {
  mode: 'development',
  devtool: '#cheap-module-eval-source-map',
  // 插件
  plugins: [
    new ProgressBarPlugin(), // 进度条插件
    new FriendlyErrorsWebpackPlugin(),
    // 通过 DefinePlugin 来设置 process.env 环境变量的快捷方式。
    new webpack.EnvironmentPlugin(appEnvs),
    // 模块热替换插件, 与 webpack-hot-middleware 配套使用
    new webpack.HotModuleReplacementPlugin(),
    new HtmlWebpackPlugin({
      template: resolve('./public/index.html'),
      filename: 'index.html'
    })
  ],
  optimization: {
    // 跳过生成阶段,不会因为错误代码退出
    noEmitOnErrors: true
  }
}))

function resolve (name) {
  return path.resolve(__dirname, '..', name)
}

const devMiddleware = webpackMiddleware(compiler, {
  // 同 webpack publicPath
  publicPath: '/',
  logLevel: 'silent'
})

const hotMiddleware = webpackHotMiddleware(compiler, {
  log: false
})

compiler.hooks.compilation.tap('html-webpack-plugin-after-emit', () => {
  hotMiddleware.publish({
    action: 'reload'
  })
})

// 加载中间件
app.use(devMiddleware)
app.use(hotMiddleware)

// 添加代理配置
useExpressProxy(app, proxyTable)

devMiddleware.waitUntilValid(() => {
  console.log(chalk.yellow(`I am ready. open http://localhost:${ config.port || 3000 } to see me.`))
})

app.listen(config.port || 3000)

生产环境配置

  • production.js
const webpack = require('webpack')
const path = require('path')

const ora = require('ora')
const chalk = require('chalk')
const merge = require('webpack-merge')

const baseConfig = require('./webpack.base.js')

// 替代 extract-text-webpack-plugin, 用于提取 css 文件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// 用于 css 文件优化压缩
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')

const HtmlWebpackPlugin = require('html-webpack-plugin')
// 重新构建时清空 dist 文件
const CleanWebpackPlugin = require('clean-webpack-plugin')

const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin')

const config = require('./configs/options')
const appEnvs = require('./configs/appEnvs')

const compiler = webpack(merge(baseConfig({ mode: 'production' }), {
  mode: 'production',
  output: {
    publicPath: './'
  },
  performance: {
    hints: false
  },
  plugins: [
    new webpack.HashedModuleIdsPlugin(),
    new webpack.EnvironmentPlugin(appEnvs),
    new webpack.SourceMapDevToolPlugin({
      test: /\.js$/,
      filename: 'sourcemap/[name].[chunkhash].map',
      append: false
    }),
    new CleanWebpackPlugin([`${config.builtPath || 'dist'}/*`], {
      root: path.resolve(__dirname, '..')
    }),
    new HtmlWebpackPlugin({
      template: resolve('./public/index.html'),
      filename: 'index.html',
      chunks: ['app', 'vendors', 'mainifest'],
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
      }
    }),
    new MiniCssExtractPlugin({
      filename: 'static/css/[name].[contenthash].css'
      chunkFilename: 'static/css/[id].[contenthash].css'
    })
  ],
  optimization: {
    // 将webpack运行时生成代码打包到 mainifest.js
    runtimeChunk: {
      name: 'mainifest'
    },
    // 替代 commonChunkPlugin, 拆分代码
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~',
      name: true,
      cacheGroups: {
        // node_modules 中用到的合并到 vendor.js
        vendor: {
          test: /node_modules\/(.*)\.js/,
          name: 'vendors',
          chunks: 'initial',
          priority: -10,
          reuseExistingChunk: false
        },
        // 与 mini-css-extract-plugin 配合,将 css 整合到一个文件
        styles: {
          name: 'styles',
          test:  /(\.less|\.scss|\.css)$/,
          chunks: 'all',
          enforce: true,
        },
      }
    },
    minimizer: [
      // ParallelUglifyPlugin 可以把对JS文件的串行压缩变为开启多个子进程并行执行
      new ParallelUglifyPlugin({
        uglifyJS: {
          output: {
            beautify: false,
            comments: false
          },
          compress: {
            warnings: false,
            drop_console: true,
            collapse_vars: true,
            reduce_vars: true
          }
        },
        cache: true, // 开启缓存
        parallel: true, // 平行压缩
        sourceMap: true // set to true if you want JS source maps
      }),
      // 压缩 css
      new OptimizeCssAssetsPlugin({
        assetNameRegExp: /(\.less|\.scss|\.css)$/g,
        cssProcessor: require("cssnano"), // css 压缩优化器
          cssProcessorOptions: {
            safe: true,
            autoprefixer: { disable: true },
            discardComments: { removeAll: true }
          }, // 去除所有注释
        canPrint: true
      })
    ]
  }
}))

function resolve (name) {
  return path.resolve(__dirname, '..', name)
}

const spinner = ora('building for production...').start()

compiler.run((err, stats) => {
  spinner.stop()
  if (err) throw err
  process.stdout.write(stats.toString({
    colors: true,
    modules: false,
    children: false,
    chunks: false,
    chunkModules: false
  }) + '\n\n')

  console.log(chalk.cyan('  Build complete..\n'))
  console.log(chalk.yellow(
    '  Tip: built files are meant to be served over an HTTP server.\n' +
    '  Opening index.html over file:// won\'t work.\n'
  ))
})

Vue + TypeScript

为了在 vue 中使用 typescript, 我们使用的是 vue-class-component, 首先我们需要让项目兼容 jsx 以及 typescript

Babel 7

我使用的是 babel@7, 相比较 6, 有些许改动,所有的packages@babel/xxx

对于 jsx 的兼容,我直接使用了 @vue/babel-preset-jsx, 内部加载了 babel-plugin-transform-jsx

  • babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
        targets: {
          browsers: ['> 1%', 'last 2 versions', 'not ie <= 8']
        }
      }
    ],
    '@vue/babel-preset-jsx'
  ],
  plugins: [
    '@babel/plugin-transform-runtime'
  ],
  comments: false,
  env: {
    test: {
      presets: ['@babel/preset-env'],
      plugins: ['babel-plugin-dynamic-import-node']
    }
  }
}

TsConfig

  • tsconfig.js
{
  "include": [
      "src/**/*.ts",
      "src/**/*.tsx",
      "src/**/*.vue",
      "tests/**/*.ts",
      "tests/**/*.tsx"
  ],
  "exclude": ["node_modules"],
  "compilerOptions": {
      // typeRoots option has been previously configured
      "typeRoots": [
          // add path to @types
          "src/@types"
      ],
      "baseUrl": ".",
      "paths": {
          "*": ["types/*"],
          "@/*": ["src/*"]
      },
      // 以严格模式解析
      "strict": true,
      // 在.tsx文件里支持JSX
      "jsx": "preserve",
      // 使用的JSX工厂函数
      "jsxFactory": "h",
      // 允许从没有设置默认导出的模块中默认导入
      "allowSyntheticDefaultImports": true,
      // 启用装饰器
      "experimentalDecorators": true,
      // "strictFunctionTypes": false,
      // 允许编译javascript文件
      "allowJs": true,
      // 采用的模块系统
      "module": "esnext",
      // 编译输出目标 ES 版本
      "target": "es5",
      // 如何处理模块
      "moduleResolution": "node",
      // 在表达式和声明上有隐含的any类型时报错
      "noImplicitAny": true,
      "importHelpers": true,
      "lib": ["dom", "es5", "es6", "es7", "es2015.promise"],
      "sourceMap": true,
      "pretty": true,
      "esModuleInterop": true
  }
}

  • tslint.js
{
  "defaultSeverity": "warning",
  "extends": ["tslint:recommended"],
  "linterOptions": {
    "exclude": ["node_modules/**"]
  },
  "allowJs": true,
  "rules": {
    "arrow-parens": false,
    "trailing-comma": false,
    "quotemark": [true],
    "indent": [true, "spaces", 2],
    "interface-name": false,
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "no-console": false,
    "no-debugger": false,
    "no-unused-expression": [true, "allow-fast-null-checks"],
    "no-unused-variable": false,
    "triple-equals": true,
    "no-parameter-reassignment": true,
    "no-conditional-assignment": true,
    "no-construct": true,
    "no-duplicate-super": true,
    "no-duplicate-switch-case": true,
    "no-object-literal-type-assertion": true,
    "no-return-await": true,
    "no-sparse-arrays": true,
    "no-string-throw": true,
    "no-switch-case-fall-through": true,
    "prefer-object-spread": true,
    "radix": true,
    "cyclomatic-complexity": [true, 20],
    "member-access": false,
    "deprecation": false,
    "use-isnan": true,
    "no-duplicate-imports": true,
    "no-mergeable-namespace": true,
    "encoding": true,
    "import-spacing": true,
    "interface-over-type-literal": true,
    "new-parens": true,
    "no-angle-bracket-type-assertion": true,
    "no-consecutive-blank-lines": [true, 3]
  }
}

项目代码

万事俱备之后,我们开始编写 .tsx 文件

  • App.tsx
import { Vue, Component } from "vue-property-decorator";
import { CreateElement } from "vue";

@Component
export default class extends Vue {
  // 这里 h: CreateElement 是重点,没有就会报错
  // 没有自动注入h, 涉及到 babel-plugin-transform-jsx 的问题
  // 自己也不明白,为什么会没有自动注入
  render(h: CreateElement) {
    return (
      <div id="app">
        <router-view />
      </div>
    );
  }
}

结尾

文章没有写的很细,其实很多重要的项目配置还没有加上,比如 commitizen, lint-stage, jest, cypress, babel-plugin-vue-jsx-sync, babel-plugin-jsx-v-model.....

更多的也是为了从零开始了解体会整个项目的构建过程。

代码已上传。欢迎来访。 项目地址