前端工程化

# 前端工程化

# 脚手架与AST

# 脚手架

脚手架:vue-cli、create-react-app、angular-cli

脚手架一类快速形成工程化目录的工具(command-line-interface, 缩写:CLI),简单来说,脚手架就是帮你减少「为重复性工作而做的重复性工作」的工具。

将研发过程:

  • 自动化:项目重复代码拷贝/git 操作/发布上线操作
  • 标准化:项目创建/git flow/发布流程/回滚流程
  • 数据化:研发过程系统化、数据化,使得研发过程可量化

构建脚手架

在完善构建脚手架前,需要引入一些脚手架构建中必须用到的工具库:

  • commander 可以自定义一些命令行指令,在输入自定义的命令行的时候,会去执行相应的操作
  • inquirer 可以在命令行询问用户问题,并且可以记录用户回答选择的结果
  • fs-extra 是fs的一个扩展,提供了非常多的便利API,并且继承了fs所有方法和为fs方法添加了promise的支持。
  • chalk 可以美化终端的输出
  • figlet 可以在终端输出logo
  • ora 控制台的loading动画
  • download-git-repo 下载远程模板
  1. 初始化
  • mkdir cli-test

  • npm -y init

  • 脚本中添加bin命令

 "bin": {
    "test-cli": "./bin/index.js"
  },
  • 新建 bin/index.js
#! /usr/bin/env node

console.log('hello fe cli')

文件以#!开头代表这个文件被当做一个执行文件来执行,可以当做脚本运行。后面的/usr/bin/env node代表这个文件用node执行,node基于用户安装根目录下的环境变量中查找

  • 将当前命令链接到全局,即可测试是否正常:npm link

  • 启动:test-cli

  1. 构建
  • 安装:npm install

  • index.js 中添加脚本

  • 启动:test-cli

其他命令:test-cli create [name]

# AST

抽象语法树

AST Explorer (opens new window)

AST 类型大全: @babel/types (opens new window)

# 打包工具-Webpack

# webpack 简介

  • webpack 把⼀切静态资源视为模块,所以⼜叫做静态模块打包器。通过⼊⼝⽂件递归构建依赖图,借助不同的 loader 处理相应的⽂件源码,最终输出⽬标环境可执⾏的代码。

  • 通常我们使⽤其构建项⽬时,维护的是⼀份配置⽂件,如果把整个 webpack 功能视为⼀个复杂的函数,那么这份配置就是函数的参数,我们通过修改参数来控制输出的结果。

  • 在开发环境中,为了提升开发效率和体验,我们希望源码的修改能实时且⽆刷新地反馈在浏览器中,这种技术就是 HRM(Hot Module Replacement)

  • 借助 webpack loader,我们可以差异化处理不同的⽂件类型。准确地说,loader 站在⽂件类型的维度上处理不同的任务,将各种语法的源码转换为统⼀的资源例如 less/sass => css,ts/jsx => js,ES6=> ES5。因此它只作⽤于静态资源。

  • webpack plugin 则以 webpack 打包的整个过程为维度,监听某些节点来执⾏定义的事件,能够处理 loader 不能完成的任务。例如:资源优化、模块拆分、环境变量定义等等。

官方文档 (opens new window)

# webpack核心概念

  • Entry

entry是配置模块的入口,可抽象成输入,Webpack 执行构建的第一步将从入口开始搜寻及递归解析出所有入口依赖的模块。

Entry支持一个入口,也可以支持多个入口,更可以支持由函数动态写入。 Webpack 在寻找相对路径的文件时会以 context 为根目录,context 默认为执行启动 Webpack 时所在的当前工作目录。

  • Module

Module,通过rules 配置模块的读取和解析规则,通常用来配置 Loader。其类型是一个数组,数组里每一项都描述了如何去处理部分文件。 配置一项 rules 时大致通过以下方式:

1,条件匹配:通过 test 、 include 、 exclude 三个配置项来命中 Loader 要应用规则的文件。

2,应用规则:对选中后的文件通过 use 配置项来应用 Loader,可以只应用一个 Loader 或者按照从后往前的顺序应用一组 Loader,同时还可以分别给 Loader 传入参数。

  • Loader

Loader 可以看作具有文件转换功能的翻译员,配置里的 module.rules 数组配置了一组规则,告诉 Webpack 在遇到哪些文件时使用哪些 Loader 去加载和转换。 如上配置告诉 Webpack 在遇到以 .css结尾的文件时先使用 css-loader 读取 CSS 文件,再交给 style-loader 把 CSS 内容注入到 JavaScript 里。

  • Plugin

Plugin 是用来扩展 Webpack 功能的,通过在构建流程里注入钩子实现,它给 Webpack 带来了很大的灵活性。

使用 Plugin 的难点在于掌握 Plugin 本身提供的配置项,而不是如何在 Webpack 中接入 Plugin。 几乎所有 Webpack 无法直接实现的功能都能在社区找到开源的 Plugin 去解决,你需要善于使用搜索引擎去寻找解决问题的方法。

  • Output

output 配置如何输出最终想要的代码。output 是一个 object,里面包含一系列配置项。例如:

1,output.filename :配置输出文件的名称,为string 类型。

2,Path: output.path 配置输出文件存放在本地的目录,必须是 string 类型的绝对路径

3,publicPath:在复杂的项目里可能会有一些构建出的资源需要异步加载,加载这些异步资源需要对应的 URL 地址。

# webpack.config.js 配置

const path = require('path');

module.exports = {
  // entry 表示 入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入。
  // 类型可以是 string | object | array   
  entry: './app/entry', // 只有1个入口,入口只有1个文件
  entry: ['./app/entry1', './app/entry2'], // 只有1个入口,入口有2个文件
  entry: { // 有2个入口
    a: './app/entry-a',
    b: ['./app/entry-b1', './app/entry-b2']
  },

  // 如何输出结果:在 Webpack 经过一系列处理后,如何输出最终想要的代码。
  output: {
    // 输出文件存放的目录,必须是 string 类型的绝对路径。
    path: path.resolve(__dirname, 'dist'),

    // 输出文件的名称
    filename: 'bundle.js', // 完整的名称
    filename: '[name].js', // 当配置了多个 entry 时,通过名称模版为不同的 entry 生成不同的文件名称
    filename: '[chunkhash].js', // 根据文件内容 hash 值生成文件名称,用于浏览器长时间缓存文件

    // 发布到线上的所有资源的 URL 前缀,string 类型
    publicPath: '/assets/', // 放到指定目录下
    publicPath: '', // 放到根目录下
    publicPath: 'https://cdn.example.com/', // 放到 CDN 上去

    // 导出库的名称,string 类型
    // 不填它时,默认输出格式是匿名的立即执行函数
    library: 'MyLibrary',

    // 导出库的类型,枚举类型,默认是 var
    // 可以是 umd | umd2 | commonjs2 | commonjs | amd | this | var | assign | window | global | jsonp ,
    libraryTarget: 'umd', //浏览器和node端都可以运行

    // 是否包含有用的文件路径信息到生成的代码里去,boolean 类型
    pathinfo: true, 

    // 附加 Chunk 的文件名称
    chunkFilename: '[id].js',
    chunkFilename: '[chunkhash].js',

    // JSONP 异步加载资源时的回调函数名称,需要和服务端搭配使用
    jsonpFunction: 'myWebpackJsonp',

    // 生成的 Source Map 文件名称
    sourceMapFilename: '[file].map',

    // 浏览器开发者工具里显示的源码模块名称
    devtoolModuleFilenameTemplate: 'webpack:///[resource-path]',

    // 异步加载跨域的资源时使用的方式
    crossOriginLoading: 'use-credentials',
    crossOriginLoading: 'anonymous',
    crossOriginLoading: false,
  },

  // 配置模块相关
  module: {
    rules: [ // 配置 Loader
      {  
        test: /.jsx?$/, // 正则匹配命中要使用 Loader 的文件
        include: [ // 只会命中这里面的文件
          path.resolve(__dirname, 'app')
        ],
        exclude: [ // 忽略这里面的文件
          path.resolve(__dirname, 'app/demo-files')
        ],
        use: [ // 使用那些 Loader,有先后次序,从后往前执行
          'style-loader', // 直接使用 Loader 的名称
          {
            loader: 'css-loader',      
            options: { // 给 html-loader 传一些参数
            }
          }
        ]
      },
    ],
    noParse: [ // 不用解析和处理的模块
      /special-library.js$/  // 用正则匹配
    ],
  },

  // 配置插件
  plugins: [
  ],

  // 配置寻找模块的规则
  resolve: { 
    modules: [ // 寻找模块的根目录,array 类型,默认以 node_modules 为根目录
      'node_modules',
      path.resolve(__dirname, 'app')
    ],
    extensions: ['.js', '.json', '.jsx', '.css'], // 模块的后缀名
    alias: { // 模块别名配置,用于映射模块
       // 把 'module' 映射 'new-module',同样的 'module/path/file' 也会被映射成 'new-module/path/file'
      'module': 'new-module',
      // 使用结尾符号 $ 后,把 'only-module' 映射成 'new-module',
      // 但是不像上面的,'module/path/file' 不会被映射成 'new-module/path/file'
      'only-module$': 'new-module', 
    },
    alias: [ // alias 还支持使用数组来更详细的配置
      {
        name: 'module', // 老的模块
        alias: 'new-module', // 新的模块
        // 是否是只映射模块,如果是 true 只有 'module' 会被映射,如果是 false 'module/inner/path' 也会被映射
        onlyModule: true, 
      }
    ],
    symlinks: true, // 是否跟随文件软链接去搜寻模块的路径
    descriptionFiles: ['package.json'], // 模块的描述文件
    mainFields: ['main'], // 模块的描述文件里的描述入口的文件的字段名称
    enforceExtension: false, // 是否强制导入语句必须要写明文件后缀
  },

  // 输出文件性能检查配置
  performance: { 
    hints: 'warning', // 有性能问题时输出警告
    hints: 'error', // 有性能问题时输出错误
    hints: false, // 关闭性能检查
    maxAssetSize: 200000, // 最大文件大小 (单位 bytes)
    maxEntrypointSize: 400000, // 最大入口文件大小 (单位 bytes)
    assetFilter: function(assetFilename) { // 过滤要检查的文件
      return assetFilename.endsWith('.css') || assetFilename.endsWith('.js');
    }
  },

  devtool: 'source-map', // 配置 source-map 类型,默认值为false

  context: __dirname, // Webpack 使用的根目录,string 类型必须是绝对路径

  // 配置输出代码的运行环境
  target: 'web', // 浏览器,默认
  target: 'webworker', // WebWorker
  target: 'node', // Node.js,使用 `require` 语句加载 Chunk 代码
  target: 'async-node', // Node.js,异步加载 Chunk 代码
  target: 'node-webkit', // nw.js
  target: 'electron-main', // electron, 主线程
  target: 'electron-renderer', // electron, 渲染线程

  externals: { // 使用来自 JavaScript 运行环境提供的全局变量
    jquery: 'jQuery'
  },

  stats: { // 控制台输出日志控制
    assets: true,
    colors: true,
    errors: true,
    errorDetails: true,
    hash: true,
  },

  //DevServer 相关的配置
  devServer: { 
    proxy: { // 代理到后端服务接口
      '/api': 'http://localhost:3000'
    },
    contentBase: path.join(__dirname, 'public'), // 配置 DevServer HTTP 服务器的文件根目录
    compress: true, // 是否开启 gzip 压缩
    historyApiFallback: true, // 是否开发 HTML5 History API 网页
    hot: true, // 是否开启模块热替换功能
    //另一选型only,true时热更新页面失败刷新页面,only热更新失败不刷新页面
    https: false, // 是否开启 HTTPS 模式
    open: false, //第一次构建完成,是否启动浏览器
    host: 0.0.0.0, //支持任何地址访问DevServer的Http服务
    allowedHosts: ['baidu.com', 'sub.host.com'], //允许访问域名列表
          disableHostCheck: false, //host检查关闭,可直接使用ip访问服务器
    inline: false, //关闭inline使用iframe方式
  },

  profile: true, // 是否捕捉 Webpack 构建的性能信息,用于分析什么原因导致构建性能不佳
  cache: false, // 是否启用缓存提升构建速度
  watch: true, // 是否开始
  watchOptions: { // 监听模式选项
    // 不监听的文件或文件夹,支持正则匹配。默认为空
    ignored: /node_modules/,
    // 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
    // 默认为300ms 
    aggregateTimeout: 300,
    // 判断文件是否发生变化是不停的去询问系统指定文件有没有变化,默认每秒问 1000 次
    poll: 1000
  },
  //优化配置,拆包处理 
  optimization: {
    splitChunks: {
      cacheGroups:{
        vendor:{
          filename: 'vendor.js',
          chunks: 'all', //包类型,async(异步时拆包),initial
          test: /[\/]node_modules[\/](react|react-dom)[\/]/   //正则表达式
        }
      }
    }
  }
}

# webpack loaders

特点:⽆需导⼊,针对特定⽂件进⾏处理,输⼊⽂件内容并输出处理后的内容,交给下⼀个 loader 处理。

⼏⼤原则按重要性排序:单⼀职责、可链式调⽤、模块化、⽆状态、尽量借助⼯具库(loader-utils、schema-utils 等)、标记依赖项、解决模块依赖关系、公共代码复⽤、避免绝对路径、peerdependencies。

  • babel-loader

用于将ES6转换为ES5,安装使用@babel/core、@babel/preset-env、babel-loader

// webpack.config.js

module: {
  rules: [
    {
      test: /.js$/,
      include: [resolve('src'), resolve('test')],
      exclude: /(node_modules|bower_components)/,
      use: {
        loader: 'babel-loader',
        options: { 
          presets: ['env'], //对应babel-preset-env
          plugins: [
            ['@babel/plugin-proposal-object-rest-spread'],
            //装饰器loader,需安装@babel/plugin-proposal-decorators
            ["@babel/plugin-proposal-decorators", { "legacy": true }],
          ],
          cacheDirectory: true 
          /* 默认值为 false。当有设置时,指定的目录将用来缓存 loader 的执行结果。之后的 webpack 构建,将会尝试读取缓存,来避免在每次执行时,可能产生的、高性能消耗的 Babel 重新编译过程(recompilation process)。*/
        }
      }
    }
  ]
}


//package.json文件,babel-core和babel-loader是核心插件,babel-preset-env处理代码的预设
"devDependencies": {
    "babel-core": "^6.26.0",   // 核心包
    "babel-loader": "^7.1.2",   // 基础包
    "babel-preset-env": "^1.6.1",  // 根据配置转换成浏览器支持的 es5  
    "babel-plugin-transform-runtime": "^6.23.0",  
     //polyfill作用:es6新语法引入,promise、Iterator、Generator、Set、Maps、Proxy、Reflect、Symbol
    "babel-preset-react": "^6.24.1", //react语法的转换
    "babel-plugin-import": "^1.6.3",  // import的转换 
    "babel-preset-stage-0": "^6.24.1", //babel-preset-stage-0 打包处于 strawman 阶段的语法)
}
  • .babelrc

增加了.babelrc文件后,options项即可省略,在执行babel-loader的时候默认会去读.babelrc中的配置,webpack和.babelrc文件里的配置都会生效,比如transform-remove-console插件在任意一处配置,都会生效。在.babelrc配置文件中,主要是对预设(presets)和插件(plugins)进行配置。

  • thread-loader

多进程打包~

  • url-loader

用于将文件转换为base64 URI的webpack加载程序。

  • eslint-loader

# webpack plugins

特点:需要导⼊并实例化,通过钩⼦可以涉及整个构建流程,因此贯穿整个构建范围。

本质:原型上具有 apply⽅法的具名构造函数或类。

CopyWebpackPlugin, HtmlWebpackPlugin, MiniCssExtractPlugin,

# 热更新原理(HMR)

HMR 全称 Hot Module Replacement,中文语境通常翻译为模块热更新,它能够在保持页面状态的情况下动态替换资源模块,提供丝滑顺畅的 Web 页面开发体验。

  • 使用 HMR
  1. 配置 devServer.hot 属性为 true,如:
// webpack.config.js
module.exports = {
  // ...
  devServer: {
    // 必须设置 devServer.hot = true,启动 HMR 功能
    hot: true
  }
};
    • 之后,还需要调用 module.hot.accept 接口,声明如何将模块安全地替换为最新代码,如:
import component from "./component";
let demoComponent = component();

document.body.appendChild(demoComponent);
// HMR interface
if (module.hot) {
  // Capture hot update
  module.hot.accept("./component", () => {
    const nextComponent = component();

    // Replace old content with the hot loaded one
    document.body.replaceChild(nextComponent, demoComponent);

    demoComponent = nextComponent;
  });
}
  • 实现原理
  1. 使用 webpack-dev-server (后面简称 WDS)托管静态资源,同时以 Runtime 方式注入 HMR 客户端代码
  2. 浏览器加载页面后,与 WDS 建立 WebSocket 连接
  3. Webpack 监听到文件变化后,增量构建发生变更的模块,并通过 WebSocket 发送 hash 事件
  4. 浏览器接收到 hash 事件后,请求 manifest 资源文件,确认增量变更范围
  5. 浏览器加载发生变更的增量模块
  6. Webpack 运行时触发变更模块的 module.hot.accept 回调,执行代码变更逻辑
  7. done

# 注入 HMR 客户端运行时

执行 npx webpack serve 命令后,WDS 调用 HotModuleReplacementPlugin 插件向应用的主 Chunk 注入一系列 HMR Runtime:

经过 HotModuleReplacementPlugin 处理后,构建产物中即包含了所有运行 HMR 所需的客户端运行时与接口。这些 HMR 运行时会在浏览器执行一套基于 WebSocket 消息的时序框架:

# 增量构建

除注入客户端代码外,HotModuleReplacementPlugin 插件还会借助 Webpack 的 watch 能力,在代码文件发生变化后执行增量构建,生成:

  • manifest 文件:JSON 格式文件,包含所有发生变更的模块列表,命名为 [hash].hot-update.json
  • 模块变更文件:js 格式,包含编译后的模块代码,命名[hash].hot-update.js

增量构建完毕后,Webpack 将触发 compilation.hooks.done 钩子,并传递本次构建的统计信息对象 stats。WDS 则监听 done 钩子,在回调中通过 WebSocket 发送模块更新消息:

{"type":"hash","data":"${stats.hash}"}

# 加载更新

客户端接受到 hash 消息后,首先发出 manifest 请求获取本轮热更新涉及的 chunk

注意,在 Webpack 4 及之前,热更新文件以模块为单位,即所有发生变化的模块都会生成对应的热更新文件; Webpack 5 之后热更新文件以 chunk 为单位,如上例中,main chunk 下任意文件的变化都只会生成 main.[hash].hot-update.js 更新文件。

manifest 请求完成后,客户端 HMR 运行时开始下载发生变化的 chunk 文件,将最新模块代码加载到本地。

# module.hot.accept 回调

经过上述步骤,浏览器加载完最新模块代码后,HMR 运行时会继续触发 module.hot.accept 回调,将最新代码替换到运行环境中。

module.hot.accept 是 HMR 运行时暴露给用户代码的重要接口之一,它在 Webpack HMR 体系中开了一个口子,让用户能够自定义模块热替换的逻辑。

  • module.hot.accept示例如下:
// src/bar.js
export const bar = 'bar'

// src/index.js
import { bar } from './bar';
const node = document.createElement('div')
node.innerText = bar;
document.body.appendChild(node)

module.hot.accept('./bar.js', function () {
    node.innerText = bar;
})

示例中,module.hot.accept 函数监听 ./bar.js 模块的变更事件,一旦代码发生变动就触发回调,将 ./bar.js 导出的值应用到页面上,从而实现热更新效果。

  • 失败兜底

module.hot.accept 函数只接受具体路径的 path 参数,也就是说我们无法通过 glob 或类似风格的方式批量注册热更新回调。一旦某个模块没有注册对应的 module.hot.accept 函数后,HMR 运行时会执行兜底策略,通常是刷新页面,确保页面上运行的始终是最新的代码。

  • 更新事件冒泡

在 Webpack HMR 框架中,module.hot.accept 函数只能捕获当前模块对应子孙模块的更新事件。

更新事件会沿着模块依赖树自底向上逐级传递,但不支持反向或跨子树传递; 这一特性与 DOM 事件规范中的冒泡过程极为相似,使用时如果摸不准模块的依赖关系,建议直接在应用的入口文件中编写热更新函数。

回顾整个 HMR 过程,所有的状态流转均由 WebSocket 消息驱动,这部分逻辑由 HMR 运行时控制,开发者几乎无感。

唯一需要开发者关心的是为每一个需要处理热更新的文件注册 module.hot.accept 回调,所幸这部分需求已经被许多成熟的 Loader 处理。

# 自动化构建

# 常见自动化构建工具

自动化构建工具是专门设计为开发者提供一个自动化方式来编译、测试、打包和部署应用程序或软件项目的工具。这些工具旨在简化和标准化构建过程,确保构建的一致性、可靠性和重复性。通过自动化这些任务,开发团队可以更快速、更高效地交付高质量的软件。

以下是自动化构建工具的主要特点和功能:

  1. 编译:将源代码转换为可执行代码或字节码。
  2. 测试:自动运行单元测试、集成测试和其他测试,确保代码的质量。
  3. 打包:将编译后的代码、资源文件和依赖打包成为 JAR、WAR、TAR 或其他格式的文件。
  4. 依赖管理:自动下载和管理项目所需的外部库和框架。
  5. 版本控制集成:与 Git、Subversion 等版本控制系统集成,从而可以自动拉取或提交代码更改。
  6. 文档生成:自动化生成 API 文档、用户文档或其他相关文档。
  7. 部署:将打包后的应用自动部署到服务器、容器或其他运行环境。
  8. 通知和报告:在构建过程中发生错误或完成后发送通知,生成各种报告如代码覆盖率、静态代码分析等。
  9. 可扩展性:通过插件或扩展模块,可以增加新的功能或集成第三方工具。
  10. 跨平台:大多数构建工具都可以在不同的操作系统和平台上运行。

常见的自动化构建工具包括:

  • Java 世界:Maven、Gradle、Ant
  • JavaScript/Node.js 世界:npm、Grunt、Gulp、Webpack
  • C/C++ 世界:Make、CMake、Autotools
  • .NET 世界:MSBuild、Nant

前端的构建工具常见的有Grunt、Gulp、Webpack三种:

  • gulp是一个自动化构建工具,是Grunt的升级版,主要用来设定程序自动处理静态资源的工作,也即是对前端项目资源进行打包,通常有些项目文件很大,使用Gulp压缩之后文件的体积就变的很小而功能不减。
  • Webpack侧重于前端模块的打包,最初Webpack本身就是为前端JS代码打包而设计的,后来被扩展到其他资源的打包处理。Webpack是通过loader(加载器)和plugins(插件)对资源进行处理的。

Gulp是对整个前端资源进行整合、归置,至于文件之间的调用关系是不做具体的管理,而Webpack是通过解析文件之间的引用关系进行资源的管理。

# NPM Script

npm脚本

npm 允许在package.json文件里面,使用scripts字段定义脚本命令。

{ 
    "scripts": {
        "build": "node build.js"
    }
}

npm run build 等同于执行 node build.js

  • 原理

每当执行npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。

比较特别的是,npm run新建的这个 Shell,会将当前目录的node_modules/.bin子目录加入PATH变量,执行结束后,再将PATH变量恢复原样。

这意味着,当前目录的node_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。比如,当前项目的依赖里面有 Mocha,只要直接写mocha test就可以了。

"test": "mocha test"
// "test": "./node_modules/.bin/mocha test"

由于 npm 脚本的唯一要求就是可以在 Shell 执行,因此它不一定是 Node 脚本,任何可执行文件都可以写在里面。

  • 通配符

由于 npm 脚本就是 Shell 脚本,因为可以使用 Shell 通配符。

"lint": "jshint *.js" // *表示任意文件名
"lint": "jshint **/*.js"  // **表示任意一层子目录
"test": "tap test/\*.js"  // 如果要将通配符传入原始命令,防止被 Shell 转义,要将星号转义。

"test": "node index.js -s -p" // 传参

npm run script1.js & npm run script2.js // 如果是并行执行(即同时的平行执行),可以使用&符号。

npm run script1.js && npm run script2.js // 如果是继发执行(即只有前一个任务成功,才执行下一个任务),可以使用&&符号。

// 一般来说,npm 脚本由用户提供。但是,npm 对两个脚本提供了默认值: 
"start": "node server.js""install": "node-gyp rebuild"


// npm 脚本有pre和post两个钩子。举例来说,build脚本命令的钩子就是prebuild和postbuild:
"prebuild": "echo I run before the build script",
"build": "cross-env NODE_ENV=production webpack",
"postbuild": "echo I run after the build script"

// 用户执行npm run build的时候,会自动按照下面的顺序执行:
npm run prebuild && npm run build && npm run postbuild
// 可以在这两个钩子里面,完成一些准备工作和清理工作:
"clean": "rimraf ./dist && mkdir dist",
"prebuild": "npm run clean",
"build": "cross-env NODE_ENV=production webpack"


// npm 提供一个npm_lifecycle_event变量,返回当前正在运行的脚本名称,比如pretest、test、posttest等等:
const TARGET = process.env.npm_lifecycle_event;if (TARGET === 'test') {
  console.log(`Running the test task!`);}if (TARGET === 'pretest') {
  console.log(`Running the pretest task!`);}if (TARGET === 'posttest') {


// 四个常用的 npm 脚本有简写形式:
npm start // npm run start
npm stop   // npm run stop的简写
npm test  // npm run test的简写
npm restart   // npm run stop && npm run restart && npm run start的简写


// 通过npm_package_前缀,npm 脚本可以拿到package.json里面的字段:
console.log(process.env.npm_package_name); // foo
console.log(process.env.npm_package_version); // 1.2.5


// npm_package_前缀也支持嵌套的package.json字段:
"repository": {"type": "git","url": "xxx"},
scripts: {"view": "echo $npm_package_repository_type"}

"scripts": {"install": "foo.js"} // npm_package_scripts_install变量的值等于foo.js。


// npm 脚本还可以通过npm_config_前缀,拿到 npm 的配置变量,即npm config get xxx命令返回的值。比如,当前模块的发行标签,可以通过npm_config_tag取到:
"view": "echo $npm_config_tag"
// package.json里面的config对象,可以被环境变量覆盖:
{ 
    "name" : "foo",
    "config" : { "port" : "8080" },
    "scripts" : { "start" : "node server.js" }
}
// 上面代码中,npm_package_config_port变量返回的是8080, 这个值可以用下面的方法覆盖:
$ npm config set foo:port 80


// env命令可以列出所有环境变量:
"env": "env"

# Gulp

官方文档 (opens new window)

# Vite

Vite 官方中文文档 (opens new window)

Vite一种新型前端构建工具,能够显著提升前端开发体验。它主要由两部分组成:

  • 一个开发服务器,它基于 原生 ES 模块 提供了 丰富的内建功能,如速度快到惊人的 模块热更新(HMR)。

  • 一套构建指令,它使用 Rollup (opens new window) 打包你的代码,并且它是预配置的,可输出用于生产环境的高度优化过的静态资源。

Vite 是一种具有明确建议的工具,具备合理的默认设置。通过 插件,Vite 支持与其他框架或工具的集成。如有需要,您可以通过 配置部分 自定义适应你的项目。

Vite 还提供了强大的扩展性,可通过其 插件 API 和 JavaScript API 进行扩展,并提供完整的类型支持

Vite 旨在利用生态系统中的新进展解决上述问题:浏览器开始原生支持 ES 模块,且越来越多 JavaScript 工具使用编译型语言编写。

Vite 通过在一开始将应用中的模块区分为 依赖 和 源码 两类,改进了开发服务器启动时间。

  • 依赖 大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会存在多种模块化格式(例如 ESM 或者 CommonJS)。

Vite 将会使用 esbuild 预构建依赖。esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

  • 源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载(例如基于路由拆分的代码模块)。

Vite 以 原生 ESM (opens new window) 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。

  • Vite的热更新

在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活(大多数时候只是模块本身),使得无论应用大小如何,HMR 始终能保持快速更新。

Vite 同时利用 HTTP 头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。

Q: 为什么生产环境仍需打包?

尽管原生 ESM 现在得到了广泛支持,但由于嵌套导入会导致额外的网络往返,在生产环境中发布未打包的 ESM 仍然效率低下(即使使用 HTTP/2)。为了在生产环境中获得最佳的加载性能,最好还是将代码进行 tree-shaking、懒加载和 chunk 分割(以获得更好的缓存)。

  • 浏览器支持

默认的构建目标是能支持 原生 ESM 语法的 script 标签、原生 ESM 动态导入 和 import.meta 的浏览器。传统浏览器可以通过官方插件 @vitejs/plugin-legacy 支持

# 自动化测试

我们把平时项目开发中的测试主要分为两种:

  • 手动测试
  • 自动化测试

自动化测试,根据测试的对象,范围,场景等不同,主要分为以下几种:

  • 单元测试:验证独立的单元模块是否正常工作
  • 集成测试:验证多个单元模块间的协同工作
  • 端到端测试(e2e):从用户的角度,通过机器来模仿用户在真实浏览器中验证应用交互
  • 快照测试:验证程序的UI变化

# 分类

# 单元测试

单元测试是对应用程序最小的部分(单元)运行测试的过程。通常,测试的单元是函数,但在前端应用中,组件也是被测单元。

单元测试应该避免依赖性问题,比如不存取数据库、不访问网络等等,而是使用工具虚拟出运行环境。这种虚拟使得测试成本最小化,不用花大力气搭建各种测试环境。

单元测试的优点:

  • 提升代码质量,减少 Bug
  • 快速反馈,减少调试时间
  • 让代码维护更容易
  • 有助于代码的模块化设计
  • 代码覆盖率高

单元测试的缺点:

  • 由于单元测试是独立的,所以无法保证多个单元运行到一起是否正确

常见的 JavaScript 单元测试框架:Jest, Mocha, Jasmine, Karma, ava, Tape

# 集成测试

人们定义集成测试的方式并不相同,尤其是对于前端。有些人认为在浏览器环境上运行的测试是集成测试;有些人认为对具有模块依赖性的单元进行的任何测试都是集成测试;也有些人认为任何完全渲染的组件测试都是集成测试。

简单来说:前端页面就是由一系列的组件组合在一起的,所以所谓的集成测试,其实就是测试这个组件放在一起,是否能够正常工作

优点:

  • 由于是从用户使用角度出发,更容易获得软件使用过程中的正确性
  • 集成测试相对于写了软件的说明文档
  • 由于不关注底层代码实现细节,所以更有利于快速重构
  • 相比单元测试,集成测试的开发速度要更快一些

缺点:

  • 测试失败的时候无法快速定位问题
  • 代码覆盖率较低
  • 速度比单元测试要慢

# 端到端测试

E2E(end to end)端到端测试是最直观可以理解的测试类型。在前端应用程序中,端到端测试可以从用户的视角通过浏览器自动检查应用程序是否正常工作。

优点:

  • 真实的测试环境,更容易获得程序的信心

缺点:

  • 首先,端到端测试运行不够快。启动浏览器需要占用几秒钟,网站响应速度又慢。通常一套端到端测试需要 30 分钟的运行时间。如果应用程序完全依赖于端到端测试,那么测试套件将需要数小时的运行时间。
  • 端到端测试的另一个问题是调试起来比较困难。要调试端到端测试,需要打开浏览器并逐步完成用户操作以重现 bug。本地运行这个调试过程就已经够糟糕了,如果测试是在持续集成服务器上失败而不是本地计算机上失败,那么整个调试过程会变得更加糟糕。

# 快照测试

快照测试类似于“找不同”游戏。快照测试会给运行中的应用程序拍一张图片,并将其与以前保存的图片进行比较。如果图像不同,则测试失败。这种测试方法对确保应用程序代码变更后是否仍然可以正确渲染很有帮助。

当然,在前端中,其实并不是比较图片,而是比较前后生成的html结构,本质上是一个字符串的比较。

哪些场景会用到快照测试呢?典型的就是组件库中,例如:ant design,vant等其实每个组件都会有对应的快照测试。

比较

  • 单元测试:从程序角度出发,对应用程序最小的部分(函数、组件)运行测试的过程,它是从程序员的角度编写的,保证一些方法执行特定的任务,给出特定输入,得到预期的结果。
  • 集成测试:从用户角度出发,对应用中多个模块组织到一起的正确性进行测试。
  • 快照测试:快照测试类似于“找不同”游戏,主要用于 UI 测试。
  • 端到端测试:端到端测试是从用户的角度编写的,基于真实浏览器环境测试用户执行它所期望的工作。

奖杯模型

奖杯模型中自下而上分为静态测试 < 单元测试 < 集成测试 < e2e 测试, 它们的职责大致如下:

  • 静态测试:在编写代码逻辑阶段时进行报错提示。(代表库: ESLint、Flow、TypeScript)
  • 单元测试:在奖杯模型中, 单元测试的职责是对一些边界情况或者特定的算法进行测试。(代表库: Jest、Mocha)
  • 集成测试:模拟用户的行为进行测试,对网络请求、获取数据库的数据等依赖第三方环境的行为进行 Mock。(代表库: Jest、react-testing-library、Vue Testing Library 等)
  • e2e 测试:模拟用户在真实环境上操作行为(包括网络请求、获取数据库数据等)的测试。(代表库: Cypress

一些建议:

  • 如果你是开发纯函数库,建议写更多的单元测试 + 少量的集成测试
  • 如果你是开发组件库,建议写更多的单元测试、为每个组件编写快照测试、写少量的集成测试 + 端到端测试
  • 如果你是开发业务系统,建议写更多的集成测试、为工具类库、算法写单元测试、写少量的端到端测试

# 测试覆盖率

测试覆盖率(test coverage)是衡量软件测试完整性的一个重要指标。掌握测试覆盖率数据,有利于客观认识软件质量,正确了解测试状态,有效改进测试工作。

  • 代码覆盖率

最著名的测试覆盖率就是代码覆盖率。这是一种面向软件开发和实现的定义。它关注的是在执行测试用例时,有哪些软件代码被执行到了,有哪些软件代码没有被执行到。被执行的代码数量与代码总数量之间的比值,就是代码覆盖率。

这里,根据代码粒度的不同,代码覆盖率可以进一步分为四个测量维度。它们形式各异,但本质是相同的。

  • 行覆盖率(line coverage):是否每一行都执行了?
  • 函数覆盖率(function coverage):是否每个函数都调用了?
  • 分支覆盖率(branch coverage):是否每个if代码块都执行了?
  • 语句覆盖率(statement coverage):是否每个语句都执行了?

如何度量代码覆盖率呢?一般可以通过第三方工具完成,比如 Jest 自带了测试覆盖率统计。

这些度量工具有个特点,那就是它们一般只适用于白盒测试,尤其是单元测试。对于黑盒测试(例如功能测试/系统测试)来说,度量它们的代码覆盖率则相对困难多了。

  • 需求覆盖率

对于黑盒测试,例如功能测试/集成测试/系统测试等来说,测试用例通常是基于软件需求而不是软件实现所设计的。因此,度量这类测试完整性的手段一般是需求覆盖率,即测试所覆盖的需求数量与总需求数量的比值。

如何度量需求覆盖率呢?通常没有现成的工具可以使用,而需要依赖人工计算,尤其是需要依赖人工去标记每个测试用例和需求之间的映射关系。

总结

  1. 测试覆盖率还是要和测试成本结合起来,比如一个不会经常变的公共方法就尽可能的将测试覆盖率做到趋于 100%。而对于一个完整项目,我建议前期先做最短的时间覆盖 80% 的测试用例,后期再慢慢完善。 经常做更改的活动页面我认为没必要必须趋近 100%,因为要不断的更改测试永用例,维护成本太高。

  2. 大多数情况下,将 100% 代码覆盖率作为目标并没有意义。当然,如果你在开发一个极其重要的支付应用,存在的 Bug 可能会导致数百万美元的损失,那么 100% 代码覆盖率对你是有用的。

# 自动化测试的应用场景

前端自动化测试方法主要是两大类,白盒测试和黑盒测试

  • 白盒测试:说白了就是代码的逻辑是否正确,流程逻辑,函数调用,异常处理等等,比如常见的单元测试
  • 黑盒测试:主要是对一个功能的验证,不关心代码的具体实现,比如端到端测试(E2E,也是集成测试的一种类别)。

# 单元测试的场景

假如我们要写一个数字类型的加法函数,我们要先想好它的输入和输出:

// 代码:
function sum(a, b) {
    return a + b;
}
export default sum;


// 对应的测试代码(以 Jest 为例)是:
import sum from './sum';
test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
});

也可以做一些边界值的测试,比如输入非数字类型的参数和空值...

单元测试一般是对程序最小单元运行测试的过程,除此之外我们可能会接触到的其它单测场景:

  • 组件的单元测试:UI 组件、无状态组件、基础组件。
  • 纯函数的单元测试,我们可能会封装一些 util 工具等等,但是要保证我们写的函数是易于测试的,即没有副作用,函数的输入和输出是稳定的。

# 集成测试/端到端测试的场景

集成测试是把不同的模块集成在一起,来测试模块与模块之前的配合是否正常工作。比如在前端我们点击一个按钮会进行表单提交,而这涉及到按钮的点击事件是否正常,表单的校验或者发送请求是否正常触发。

而 E2E的定义和集成测试是差不多的,它们通常都是站在用户视角并且以真正的运行环境来测试整个流程和功能的。集成测试和端到端的定义的边界是较模糊的,所以我们可以放在一起介绍。下面是一个用户使用某个系统的简单场景:

  1. 访问某个系统主页
  2. 点击某个元素,然后进入另外一个页面

那么它的测试代码可以这么写(以 Cypress 为例):

describe('My First Test', () => {
  it('clicking "type" navigates to a new url', () => {
    // 1.访问 https://example.cypress.io -> 模拟用户输入 URL
    cy.visit('https://example.cypress.io')
    
    // 2.点击某个元素 -> 模拟用户点击
    cy.contains('type').click()

    // 3.跳转新页面的断言
    // includes '/commands/actions'
    cy.url().should('include', '/commands/actions')
  })
})

做自动化测试一般有一个流程:输入 - 断言 - 验证。不管是单测还是 E2E 都可以遵循这个原则,如果不知道如何开始,可以考虑我们的预期结果是什么,以终为始,再去慢慢实现函数或组件的具体功能,这就是 TDD 的一种模式,这在后面我们会介绍到。

# 自动化测试框架介绍

# 单元测试框架

目前市面上比较流行的一些前端单元测试框架主要是:Vitest, Jest, Mocha, Jasmine, Karma, ava, Tape

测试框架

  • Jest

    • 优点:功能很丰富,基本开箱即用。内置断言、快照、隔离环境、覆盖率、Mock 等功能,社区活跃,基于Jasmine,测试速度相对较快。
    • 缺点:对于较大的快照文件,无法进行。无法共享项目的构建系统。
  • Vitest

    • 优点:适用于 Vite 构建的项目,特点就是字:极快!。兼容大部分 Jest 的 API,如果原来是 Jest,则可以无缝切换到 Vitest。
    • 缺点:框架比较新,生态还不是很成熟,主要应用于 Vite 的项目(非 Vite 构建的项目也是支持的)。
  • Mocha

    • 优点:灵活自由,允许开发者自由配置,无内置断言,但可以自主选择断言库,支持浏览器和 Node
    • 缺点:灵活性高也带来了需要配合多个库使用,前期学习成本较高。

# 备注

  • 手写一个脚手架? 学习 vite, vue-cli, react-create-qpp 等脚手架

  • webpack HMR原理

  • webpack 和 Vite 的异同? Vite 优点?

  • 单元测试中 Jest 和 Vitest 的应用?

  • E2E测试:cypress 的应用?

# 收藏

# 参考

上次更新: 11/26/2023, 7:38:38 PM
最近更新
01
taro开发实操笔记
09-29
02
前端跨端技术调研报告
07-28
03
Flutter学习笔记
07-15
更多文章>