手把手带你用Webpack5从零到一搭建一个vue 3 + ts + scss + pinia企业级应用

532 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

项目地址:github.com/YolandaShan…

工作项目中需要新起一个项目想采用 vue3 + ts 的技术栈,出于方便后续自定义配置所以不想采用 vue-cli,对于 vite 团队的态度也还偏于保守一些,毕竟本地打包和生产环境打包的产物不一致还是让大家有些许没底(pai bei guo),综合经过大(ling)家(dao)讨(pai)论(ban)最后决定还是采用 webpack 搭建项目。

作为团队的颜值担当的我果(bei)断(po)承担起了这样的责任!

下面简单介绍一些项目的初始化过程以及技术选型的思考,源代码可以看上面👆。

一、项目初始化

新建项目并初始化

mkdir webpack-vue3-fe && cd webpack-vue3-fe/ && pnpm init -y

接下来我们配置 webpack

pnpm i -D webpack webpack-cli 

接下来我们就引入 TypeScript,TypeScript 借鉴 C# 为 JavaScript 引入类型系统,弥补了 JavaScript 作为弱类型解释性语言只有往往只有在代码执行过程中才能发现问题的弊端,虽然某种意义上也失去了 JavaScript 的灵活性,但是对于企业项目尤其大型项目依然是非常有必要的。

pnpm i -D typescript ts-loader

webpack 支持编译 ts 文件的 loader 除了 ts-loader 还有 babel-loader,不过吧 babel-loader 还需要引入 @babel/preset-typescript,而且它只能做类型转换工作没法做类型检查。因此我们这里采用 ts-loader。(如果项目中已经引入 babel 做编译那么不妨使用 @babel/preset-typescript 来做 ts 文件的类型转换,通过 tsc --noEmit 来执行类型检查,vue 的 SFC 文件可以引入 vue-tsc

这儿就需要提到我们如何来书写 webpack 的 config,为了能够充分利用类型提示来方便我们的 webpack 配置,我之前一般喜欢采用如下操作:

pnpm i -D @types/webpack

然后在 webpack.config.js 文件中通过魔法注释实现配置的类型提示:

// webpack.config.js
const path = require('path')

/**
 * @type import('webpack').Configuration
 */
module.exports = {
    mode: 'development',
    entry: path.resolve(__dirname, 'src/index.ts')
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'foo.bundle.js',
    },
}

后来在阅读官方文档时发现原来可以直接用 ts 文件来写 webpack 配置:

pnpm i -D ts-node @types/node@18.0.0 @types/webpack
// webpack.config.ts
import * as path from 'path';
import * as webpack from 'webpack';

const config: webpack.Configuration = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.ts'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'foo.bundle.js',
  },
};

export default config;

这儿为什么 @types/node 要写死 18.0.0 版本呢?这是因为我在开发过程中发现如果不指定这个版本在 vue 的 template 中为普通 html 元素比如 div 添加 click 事件添加事件处理函数会报错,具体问题可以点此链接

我们接着来配置 tsconfig.json,这儿我先在终端执行 tsc --init --locale zh-CN 初始化一份 tsconfig.json 文件,然后进行修改:

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2016", // 编译生成语言的版本  
    "experimentalDecorators": true,
    "module": "commonjs", // 编译生成语言的模块标准
    "moduleResolution": "node", // 模块解析策略,默认采用 node
    "baseUrl": "./", // 解析非相对模块名的基准目录, 相对模块不会受baseUrl的影响
    "allowJs": true, // 允许编译 JS 文件(js、jsx)
    "checkJs": true, // 允许在 JS 文件中报错,可以提示相应的错误信息,通常与 allowJs 搭配使用
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,  // 支持在 CommonJs 模块下使用 import d from 'cjs', 解决TypeScript 对于 CommonJs/AMD/UMD 模块与 ES6 模块处理方式相同导致的问题。允许 “export =” 导出,既可以通过 “import from” 导入,也可以通过 “import =” 的方式导入
    "strict": true, // 开启严格模式
    "skipLibCheck": true, // 忽略所有的声明文件( *.d.ts)的类型检查。
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "tests/**/*.ts",
    "tests/**/*.tsx",
    "types/**/*.d.ts",
    "types/*.d.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

接下来我们为项目引入 scss,这里我想引入了 postcss 对项目内 CSS 规则进行处理,主要是通过 Autoprefixer 自动获取浏览器的流行度和能够支持的属性,并根据这些数据帮你自动为 CSS 规则添加前缀。

 pnpm add -D postcss postcss-loader autoprefixer sass sass-loader css-loader mini-css-extract-plugin

这里涉及到一个 style-loadermini-css-extract-plugin 的取舍问题,style-loader 会将加载处理完成的 CSS 代码注入到 <style> 标签,而 mini-css-extract-plugin 会生成一个单独的 CSS 文件。

经过 style-loader + css-loader 处理后,样式代码最终会被写入 Bundle 文件,并在运行时通过 style 标签注入到页面。这种将 JS、CSS 代码合并进同一个产物文件的方式有几个问题:

  • JS、CSS 资源无法并行加载,从而降低页面性能;
  • 资源缓存粒度变大,JS、CSS 任意一种变更都会致使缓存失效。

因此我在这里选用了 mini-css-extract-plugin,不过这里需要注意 mini-css-extract-plugin 库同时提供 Loader、Plugin 组件,需要同时使用。

// webpack.config.ts
const config: webpack.Configuration = {
  ...
  module: {
    rules: [
        {
            test: /\.[jt]s$/,
            use: 'ts-loader',
            exclude: /node_modules/
        },
        {
            test: /\.s[ac]ss$/,
            use: [
                MiniCssExtractPlugin.loader,
                'css-loader',
                'sass-loader',
                {
                    loader: 'postcss-loader',
                    options: {
                        postcssOptions: {
                            plugins: [require('autoprefixer')]
                        }
                    }
                }
            ]
        }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin(),
  ]
};

到这儿我们就将最基本的项目框架搭建完成,它引入了 type 和 scss 并且完成了相应的 webpack 配置。那么接下来我们的项目如何启动起来呢?

pnpm i -D html-webpack-plugin webpack-dev-server
// webpack.congfig.ts
import * as path from 'path';
import * as webpack from 'webpack';
import 'webpack-dev-server';
import HtmlWebpackPlugin from 'html-webpack-plugin'
import { CleanWebpackPlugin } from 'clean-webpack-plugin'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'

const config: webpack.Configuration = {
  mode: 'development',
  entry: path.resolve(__dirname, './src/index.ts'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[hash:8].js',
  },
  devServer: {
    port: '3000',
    hot: true,
    open: true
  },
  module: {
    rules: [
        {
            test: /\.[jt]s$/,
            use: 'ts-loader',
            exclude: /node_modules/,
        },
        {
            test: /\.s[ac]ss$/,
            use: [
                MiniCssExtractPlugin.loader,
                'css-loader',
                'sass-loader',
                {
                    loader: 'postcss-loader',
                    options: {
                        postcssOptions: {
                            plugins: [require('autoprefixer')]
                        }
                    }
                }
            ]
        }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin(),
    new HtmlWebpackPlugin({
        template: path.resolve(__dirname, './public/index.html'),
        title: 'webpack-vues-ts'
    }),
    new CleanWebpackPlugin(),
  ]
};

export default config;

然后我们可以在 package.json 中配置相应启动服务及打包的指令方便操作:

// package.json
{
  ...
  "scripts": {
    "serve": "webpack serve",
    "build": "webpack"
  },
  ...
}

至此我们的项目就初步搭建完成了,此时我们的项目目录为:

. 
├── public
| └── index.html
├── src 
| └── index.ts
| └── index.scss
└── pnpm-lock.yml
└── package.json
└── tsconfig.json
└── webpack.config.ts

接下来我们为项目引入 vue3。

二、重中之重,引入vue3

众所周知,技术还行的程序员尤雨溪曾说过“vue 3 非常好用!“(我瞎编的)。接下来就让我们为项目引入 vue 3。

pnpm i vue
pnpm i -D vue-loader

大家在 vue 项目开发过程中通常使用 Vue SFC(Single File Component) 形式编写组件代码,文件主要由三种类型的顶层语法块组成:

  • <template>:用于指定 Vue 组件模板内容,支持类 HTML、Pug 等语法,其内容会被预编译为 JavaScript 渲染函数;
  • <script>:用于定义组件选项对象,在 Vue2 版本支持导出普通对象或 defineComponent 值;Vue3 中还支持 <script setup> 方式定义组件的 setup() 函数;
  • <style>:用于定义组件样式,通过配置适当 Loader 可实现 Less、Sass、Stylus 等预处理器语法支持;也可通过添加 scopedmodule 属性将样式封装在当前组件内;

因此我们需要引入专门用于 Vue SFC 文件解析的 vue-loadervue-loader 能够将 SFC 文件中的不同模块拆分并复合使用其它 Webpack Loader 的能力处理各个模块内容,比如可以配合 babel-loaderts-loader 等处理 SFC 的 <script> 模块,配合 less-loadersass-loader 等处理 <style> 模块。接下来我们就配置 SFC 的解析。

// webpack.config.ts
...
const config: Webpack.Configuration = {
  ...
  module: {
    rules: [
       {
            test: /\.vue$/,
            use: 'vue-loader',
            exclude: /node_modules/,
        },
        {
            test: /\.[jt]s$/,
            use: {
                loader: 'ts-loader',
                options: {
                    appendTsSuffixTo: [/\.vue$/],
                },
            },
            exclude: /node_modules/,
        }
    ]
  },
  ...
}

这儿大家可能会注意到我们不仅仅为 .vue 文件添加了 vue-loader 进行处理同时还为 ts-loader 添加了 appendTsSuffixTo: [/\.vue$/], 配置,这儿 appendTsSuffixTo 的作用官方的描述为:

A list of regular expressions to be matched against filename. If filename matches one of the regular expressions, a .ts or .tsx suffix will be appended to that filename.
This is useful for *.vue file format for now. (Probably will benefit from the new single file format in the future.)。

大致意思是,会给匹配到的对应文件添加个.ts.tsx后缀以对相应文件进行解析。这儿往往是大家比较容易遗漏的点。

这里我们改造一下项目的代码进行验证:

// src/hello-word.vue
<template>
    <div class="text">{{ msg }}</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'

const msg = ref('hello world')
</script>
<style lang="scss" scoped>
.text {
    color: greenyellow;
}
</style>
// src/index.ts
import HelloWord from './hello-word.vue'
import { createApp } from 'vue'

const app = createApp(HelloWord)
app.mount('#app')

当我们启动项目的时候发现项目已经成功启动:

image.png

接下来我们来完善我们的 vue 项目。

首先引入数据状态管理库 pinia,pinia 是新一代的状态管理器,其对于 TypeScript 提供了更好的支持,并且也更符合 Composition API 的写法,而且相较于 vuex 其功能也更加强大,因此最终我们选择 pinia。

其实在这个过程我也思考结合 Composition API 我们完全在 vue3 项目中通过 export const state = reactive({}) 来实现数据的跨组件共享,我们也可以建立专门的目录进行管理,但是这种方式如果是个人的小项目或许可以,但对于多人合作的企业级项目绝对行不通,主要有这么几个点:

  • 项目组件过多且多人协作开发,因此往往一个不留神的数据改动会导致很多其他组件受影响,尤其是新手很容易由于对项目部了解导致改动影响其他模块的功能。
  • Chrome Devtool 不支持,没有 Chrome DevTool 的支持我们在问题定位阶段对数据流的追踪将会非常困难,大家懂得都懂。
  • SSR 渲染容易产生安全隐患

引入 pinia 的方法官网有详细的介绍,这里我们的改造主要是新增 store 目录并在入口文件中导入:

. 
├── public
| └── index.html
├── src 
| └── store
|     └── modules
|     └── types
|     └── index.ts
| └── App.vue
| └── index.ts
└── pnpm-lock.yml
└── package.json
└── tsconfig.json
└── webpack.config.ts
// store/index.ts
import { createPinia } from 'pinia';
import { useUserStore } from './modules/user';

const pinia = createPinia();

export { useUserStore };
export default pinia;

// store/modules/user.ts
import { defineStore } from 'pinia'
import { UserState, RoleEnum } from '../types'

export const useUserStore = defineStore('user', {
    state: (): UserState => ({
        username: '张三',
        role: RoleEnum.Admin
    }),
    actions: {
      // 设置用户的信息
      setInfo(partial: Partial<UserState>) {
        this.$patch(partial);
      },
    }
})

// src/index.ts
import { createApp } from 'vue'
import store from './store'
import APP from './App.vue'

const app = createApp(APP)

app.use(store)

app.mount('#app')

接下来引入 vue-router :

pnpm i vue-router@4

这儿大家按照文档一步一步搭建就可以了,我这边的主要改动有:

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/pages/home/index.vue'

const router = createRouter({
    history: createWebHistory(),
    routes: [
       { path: '/', component: Home },
       { path: '/about', component: import('@/pages/about/index.vue') },
    ]
})

export default router

// src/index.ts
import { createApp } from 'vue'
import store from './store'
import router from './router'
import APP from './App.vue'

const app = createApp(APP)

app.use(store)
app.use(router)
app.mount('#app')
// src/App.vue
<template>
    <div class="menu">
        <div class="menu-item" @click="changeMenu('home')">Home</div>
        <div class="menu-item" @click="changeMenu('about')">About</div>
    </div>
    <router-view></router-view>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'

const router = useRouter()

function changeMenu(menu: 'home' | 'about') {
    if (menu === 'home') {
        router.push('/')
    } else {
        router.push('/about')
    }
}

</script>
<style lang="scss" scoped>
.menu {
    height: 60px;
    border-bottom: 1px solid #666;
    display: flex;
    align-items: center;
    &-item {
        font-size: 16px;
        width: 100px;
        border: 1px solid black;
        color: green;
        text-align: center;
        border-radius: 4px;
        margin-right: 6px;
        cursor: pointer;
    }
}
</style>

这里项目的整体结构为:

. 
├── public
| └── index.html
├── src 
| └── pages
|     └── home
|     └── about
| └── store
|     └── modules
|     └── types
|     └── index.ts
| └── router
|     └── index.ts
| └── App.vue
| └── index.ts
└── pnpm-lock.yml
└── package.json
└── tsconfig.json
└── webpack.config.ts

三、缝缝补补,引入eslint、husky...

首先我们为我们的项目引入 eslint,作为代码检查工具,Eslint 是一个企业级应用维护代码质量避免部分代码错误必不可少的工具,我个人认为团队内沉淀自己的一套 lint 规则并在团队内共同使用是很有必要的,可以很大程度上降低其他同学上手的门槛也有利于维护团队内代码风格,这里我按照 eslint 官方推荐的方案进行初始化。

npx eslint --init

接下来我们就可以在终端根据提示一步一步完成配置的生成并安装。

最后生成的 .eslintrc.json 文件内容为:

{
    "env": {
        "browser": true,
        "es2021": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:vue/base",
        "plugin:vue/vue3-recommended",
        "plugin:vue/vue3-essential",
        "plugin:@typescript-eslint/recommended"
    ],
    "overrides": [
    ],
    "parser": "vue-eslint-parser",
    "parserOptions": {
        "parser": "@typescript-eslint/parser",
        "ecmaVersion": "latest",
        "sourceType": "module"
    },
    "plugins": [
        "vue",
        "@typescript-eslint"
    ],
    "rules": {
    }
}

在 webpack 中我们可以通过 eslint-webpack-plugin 来接入 eslint:

pnpm i -D eslint-webpack-plugin

接下来我们在 webpack.config.ts 文件中进行配置:

// webpack.config.ts
import ESLintPlugin from 'eslint-webpack-plugin'
...
const config: Webpack.Configuration = {
  ...
  plugins: [
    ...
    new EslintPlugin()
  ]
  ...
}

这样项目打包的时候就会进行 eslint 校验。

接着我们再为项目引入 huskycommitlintlint-staged

pnpm i husky lint-staged @commitlint/{config-conventional,cli} -D # 依赖安装
npm set-script prepare "husky install" # 添加 npm scripts: prepare
npm set-script lint "lint-staged" # 添加 npm scripts: lint
pnpm run prepare

npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"' # 添加 commit-msg 钩子,校验 commit 信息
npx husky add .husky/pre-commit "npm run lint" # 添加 pre-commit 钩子,执行 lint script

echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js # 生成 commitlint 的配置文件,这里使用 @commitlint/config-conventional 规范

接着我们修改 package.json 文件

{
  ...
  "lint-staged": {
    "*.{ts, js, vue}": "eslint --fix"
  }
}

至此我们的应用就基本搭建完成了,大家如果感兴趣可以点击文章最上面链接查看项目代码,希望能对大家有所帮助。