project-init

参考一:我是这样搭建Typescript+React项目环境的!(2.7w字详解)

参考二:https://github.com/gwuhaolin/dive-into-webpack/blob/master/4优化/4-7区分环境.html

项目初始化相关的都放在这里

webpack 可以放 webpack 的 webpack 的核心概念;编译过程;依赖分析原理;tapable 机制,与其他编译工具的对比;webpack 5 的新特性等

eslint 可以放:ESLint 的基本使用与原理、ESLint 插件怎么写

"todohighlight.keywords": [
    "DEBUG:",
    "REVIEW:",
    {
      "text": "NOTE:",
      "color": "#ff0000",
      "backgroundColor": "yellow",
      "overviewRulerColor": "grey"
    }
  ],

Npm Init

项目基础

新建 Git 仓库

参考 git

package.json

每一个项目都需要一个 package.json 文件,它的作用是记录项目的配置信息,比如我们的项目名称、包的入口文件、项目版本等,也会记录所需的各种依赖,还有很重要的 script 字段,它指定了运行脚本命令的 npm 命令行缩写。

通过以下命令就能快速生成该文件:

npm init

通过修改生成的默认配置,现在的内容如下:

{
  "name": "react-ts-quick-starter",
  "version": "1.0.0",
  "description": "Quickly create react + typescript project development environment and scaffold for developing npm package components",
  "main": "index.js",
  "scripts": {},
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vortesnail/react-ts-quick-starter.git"
  },
  "keywords": ["react-project", "typescript-project", "react-typescript", "react-ts-quick-starter"],
  "author": {
    "name": "vortesnail",
    "url": "https://github.com/vortesnail",
    "email": "[email protected]"
  },
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/vortesnail/react-ts-quick-starter/issues"
  },
  "homepage": "https://github.com/vortesnail/react-ts-quick-starter#readme"
}

暂时修改了以下配置:

LICENSE

我们在建仓库的时候会有选项让我们选择开源协议,我当时就选了 MIT 协议,如果没选的也不要紧,去网站 choosealicense 选择合适的 license(一般会选宽松的 MIT 协议),复制到项目根目录下的 LICENSE 文件内即可,然后修改作者名和年份,如下:

.gitignore

所有不需要上传至 git 仓库的都要添加进来,比如我们常见的 builddist 等,还有操作系统默认生成的,比如 MacOs 会生成存储项目文件夹显示属性的 DS_Store 文件。

使用 vscode 的 gitignore 插件,下载安装该插件之后, ctrl+shift+p 召唤命令面板,输入 Add gitignore 命令,即可在输入框输入系统或编辑器名字,来自动添加需要忽略的文件或文件夹至 .gitignore 中。

我添加了以下: NodeWindowsMacOSSublimeTextVimVscode ,大家酌情添加吧。如果默认中没有的,可自行手动输入至 .gitignore 中,比如我自己加了 dist/build/ ,用于忽略之后 webpack 打包生成的文件。

node_modules
dist

ESLint

基本

npm install eslint -D

安装成功后,执行以下命令:

npx eslint --init

在漫长的安装结束后,项目根目录下多出了新的文件 .eslintrc.js ,这便是我们的 eslint 配置文件了。其默认内容如下:

export default {
  env: {
    browser: true,
    es2020: true,
    node: true,
  },
  extends: ['plugin:react/recommended', 'airbnb'],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 11,
    sourceType: 'module',
  },
  plugins: ['react', '@typescript-eslint'],
  rules: {},
}

extendsplugins 的关系,其实 plugins 就是 插件 的意思,都是需要 npm 包的安装才可以使用,只不过默认支持简写,官网都有说;至于 extneds 其实就是使用我们已经下载的插件的某些预设规则。

现在我们对该配置文件作以下修改:

需要添加一条很重要的 rule ,不然在 .ts.tsx 文件中引入另一个文件模块会报错,比如:

rules: {
  'import/extensions': [
    ERROR,
    'ignorePackages',
    {
      ts: 'never',
      tsx: 'never',
      json: 'never',
      js: 'never',
    },
  ],
}

在之后我们安装 typescript 之后,会出现以下的怪异错误:

image-20210902164012630

大家先添加以下配置,毕竟之后一定要安装 typscript 的,把最常用的扩展名排在最前面,这里寻找文件时最快能找到:

  settings: {
    'import/resolver': {
      node: {
        extensions: ['.tsx', '.ts', '.js', '.json'],
      },
    },
  },

接下来安装 2 个社区中比较火的 eslint 插件:

npm install eslint-plugin-promise eslint-plugin-unicorn -D

更多配置请参考 scaffold

Vscode 支持 Eslint 自动修复

我们知道 eslint 由编辑器支持是有自动修复功能的,首先我们需要安装扩展:eslint

再到之前创建的 .vscode/settings.json 中添加以下代码:

{
  "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
  "typescript.tsdk": "./node_modules/typescript/lib", // 代替 vscode 的 ts 语法智能提示
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
  },
}

不过有时候我们并不希望 ESLintPrettier 去对某些文件做任何修改,比如某个特定的情况下我想去看看打包之后的文件内容,里面的内容一定是非常不符合各种 lint 规则的,但我不希望按保存时对其进行格式化,此时就需要我们添加 .eslintignore.prettierignore ,我一般会使这两个文件的内容都保持一致:

/node_modules
/build
/dist

Js 和 Ts 混用问题

特征是项目代码都是 ts 的,但是工具链配置文件都是 js,而且是 commonjs 模块的风格

需要 overwrite,针对工具链配置文件忽略一些 rule

    overrides: [
        {
            files: ['**/*.js'],
            rules: {
                'unicorn/prefer-module': OFF,
            },
        },
    ],

StyleLint

官网:https://stylelint.io/

中文文档:https://cloud.tencent.com/developer/section/1489630

更好的中文文档:https://stylelint.docschina.org/user-guide/configuration/

npm install stylelint stylelint-config-standard -D
module.exports =  {
    extends: ['stylelint-config-standard'],
    rules: {
        'comment-empty-line-before': null,
        'declaration-empty-line-before': null,
        'function-name-case': 'lower',
        'no-descending-specificity': null,
        'no-invalid-double-slash-comments': null,
        'rule-empty-line-before': 'always',
    },
    ignoreFiles: ['node_modules/**/*', 'build/**/*'],
}

同样,简单介绍下配置上的三个属性:

eslint 一样,想要在编辑代码时有错误提示以及自动修复功能,我们需要 vscode 安装一个扩展:styleint

并且在 .vscode/settings.json 中增加以下代码:

{
	// 使用 stylelint 自身的校验即可
  "css.validate": false,
  "less.validate": false,
  "scss.validate": false,
  
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    // 同时支持了eslint和stylelint
    "source.fixAll.eslint": true,
  },
}

我们可以在社区下载一些优秀的 stylelint extendsstylelint plugins

npm install stylelint-order stylelint-config-hudochenkov stylelint-declaration-block-no-ignored-properties -D

现在更改一下我们的配置文件:

module.exports = {
  extends: ['stylelint-config-standard', 'stylelint-config-hudochenkov/full'],
  plugins: ['stylelint-order', 'stylelint-declaration-block-no-ignored-properties'],
  rules: {
    'plugin/declaration-block-no-ignored-properties': true,
    'comment-empty-line-before': null,
    'declaration-empty-line-before': null,
    'function-name-case': 'lower',
    'no-descending-specificity': null,
    'no-invalid-double-slash-comments': null,
    'rule-empty-line-before': 'always',
  },
  ignoreFiles: ['node_modules/**/*', 'build/**/*'],
}

Prettier @deprecated

配置

如果说 EditorConfig 帮你统一编辑器风格,那 Prettier 就是帮你统一项目风格的。 Prettier 拥有更多配置项(实际上也不多,数了下二十个),且能在发布流程中执行命令自动格式化,能够有效的使项目代码风格趋于统一

npm install prettier -D

安装 vscode prettier 扩展

安装成功之后在根目录新建文件 .prettierrc ,输入以下配置:

{
  "trailingComma": "all",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true,
  "endOfLine": "lf",
  "printWidth": 120,
  "bracketSpacing": true,
  "arrowParens": "always"
}

其实 Prettier 的配置项很少,大家可以去 Prettier Playground 大概把玩一会儿,下面我简单介绍下上述的配置:

setting.json

{
      // 指定哪些文件不参与搜索
    "search.exclude": {
        "**/node_modules": true,
        "dist": true,
        "yarn.lock": true
    },
    // 启动 formatter 的 格式化功能,区别于 linter 的 fix 功能
    "editor.formatOnSave": true,
    "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[markdown]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[css]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[less]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    }
}

解决冲突 @deprecated

基本原则:凡是代码格式相关统一交给 prettier,eslint 和 style 不在配置相关的 rule

Eslint-config-prettier

官方提供了很好的解决方案,查阅 Integrating with Linters 可知,针对 eslintstylelint 都有很好的插件支持,其原理都是禁用与 prettier 发生冲突的规则。

https://stackoverflow.com/questions/44690308/whats-the-difference-between-prettier-eslint-eslint-plugin-prettier-and-eslint

安装插件 eslint-config-prettier ,这个插件会禁用所有和 prettier 起冲突的规则:

npm install eslint-config-prettier -D

添加以下配置到 .eslintrc.jsextends 中:

{
  extends: [
    // other configs ...
    // All configs have been merged into one
   	'prettier',
  ]
}

这里需要注意, 'prettier' 及之后的配置要放到原来添加的配置的后面,这样才能让 prettier 禁用之后与其冲突的规则。

Eslint-plugin-prettier

Plugins usually contain implementations for additional rules that ESLint will check for. This plugin uses Prettier under the hood and will raise ESLint errors when your code differs from Prettier's expected output.

该插件实现了额外的 eslint 规则,将 prettier 的规则作为 eslint 的一部分去使用,作用仅仅是提示,说明这些 prettier 的 eslint rule,没有实现 --fix,还是需要使用 formatter

npm i eslint-plugin-prettier -D
{
  plugins: ['prettier'],
  rules: {
    'prettier/prettier': ERROR
  }
}

Stylelint

stylelint 的冲突解决也是一样的,先安装插件 stylelint-config-prettier

npm install stylelint-config-prettier -D

添加以下配置到 .stylelintrc.jsextends 中:

{  
	extends: [
  	// other configs ...
    'stylelint-config-prettier'
  ]
}

再安装

npm install --save-dev stylelint-prettier
{
  "plugins": ["stylelint-prettier"],
  "rules": {
    "prettier/prettier": true
  }
}

Prefer

https://eslint.org/docs/rules/

Eslint

rule: {
    /**
     * 代码格式相关的规则,代替prettier
     */
    'indent': [ERROR, 4, { SwitchCase: 1 }],
    // 空格相关的规则
    'keyword-spacing': ERROR,
    'object-curly-spacing': [ERROR, 'always'],
    'lines-between-class-members': [ERROR, 'always'],
    // 同一个对象内部,是否使用单引号、双引号保持一致即可
    'quote-props': [ERROR, 'consistent'],
    'semi': [ERROR, 'always'],
    'max-len': [ERROR, 120],
    'quotes': [ERROR, 'single'],
    'jsx-quotes': [ERROR, 'prefer-single'],
    'brace-style': [ERROR, 'stroustrup', { 'allowSingleLine': false }],
    // 换行符,不同的系统不一样,不做要求
    'linebreak-style': OFF,
    // 控制对象、数组的换行,要么全部换行,要么全部不换行,保持一致即可
    'object-curly-newline': [ERROR, { consistent: true }],
    'array-bracket-newline': [ERROR, 'consistent'],
    'array-element-newline': [ERROR, 'consistent'],
    'no-multiple-empty-lines': [
        ERROR,
        {
            'max': 1,
            'maxBOF': 0,
            // 与 eol-last 规则保持一致
            'maxEOF': 1,
        },
    ],
}

使用我自己的 eslint 配置

https://github.com/antfu/eslint-config

https://zhuanlan.zhihu.com/p/572527461

Prettier

{
    "singleQuote": true,
    "quoteProps": "consistent",
}

换行问题,prettier 总是无脑在一行:https://github.com/prettier/prettier/issues/2716

prettier 的经验值控制换行,如果结构比较复杂的话,也还是回换行的,只是会完全由 prettier 控制,而 prettier 控制是否换行的基础是 printWidth,只能把 printWidth 调小一点,让他格式化,之后别再触发 formatter

Lint 命令

我们在 package.jsonscripts 中增加以下三个配置:

{
	scripts: {
  	"lint": "npm run lint-eslint && npm run lint-stylelint",
    "lint-eslint": "eslint --color -c .eslintrc.js --ext .ts,.tsx,.js src",
    "lint-stylelint": "stylelint --config .stylelintrc.js src/**/*.{less,css,scss}"
  }
}

在控制台执行 npm run lint-eslint 时,会去对 src/ 下的指定后缀文件进行 eslint 规则检测, lint-stylelint 也是同理, npm run lint 会两者都按顺序执行。

其实我个人来说,这几个命令我是都不想写进 scripts 中的,因为我们写代码的时候,不规范的地方就已经自动修复了,只要保持良好的习惯,看到有爆红线的时候想办法去解决它,而不是视而不见,那么根本不需要对所有包含的文件再进行一次命令式的规则校验。

但是对于新提交缓存区的代码还是有必要执行一次校验的,这个后面会说到。

Husky & Lint-staged

在项目开发过程中,每次提交前我们都要对代码进行格式化以及 eslintstylelint 的规则校验,以此来强制规范我们的代码风格,以及防止隐性 BUG 的产生。

那么有什么办法只对我们 git 缓存区最新改动过的文件进行以上的格式化和 lint 规则校验呢?答案就是 lint-staged

我们还需要另一个工具 husky ,它会提供一些钩子,比如执行 git commit 之前的钩子 pre-commit ,借助这个钩子我们就能执行 lint-staged 所提供的代码文件格式化及 lint 规则校验!

npm install husky@3 lint-staged -D

随后在 package.json 中添加以下代码(位置随意,我比较习惯放在 repository 上面):

{
	"husky": {
    "hooks": {
      "pre-commit": "lint-staged",
    }
  },
  "lint-staged": {
    "*.{ts,tsx,js}": [
      "eslint --color --config .eslintrc.js"
    ],
    "*.{css,less,scss}": [
      "stylelint --config .stylelintrc.js"
    ],
    "*.{ts,tsx,js,json,html,yml,css,less,scss,md}": [
      "prettier --write"
    ]
  },
}

首先,我们会对暂存区后缀为 .ts .tsx .js 的文件进行 eslint 校验, --config 的作用是指定配置文件。之后同理对暂存区后缀为 .css .less .scss 的文件进行 stylelint 校验,注意⚠️,我们没有添加 --fix 来自动修复不符合规则的代码,因为自动修复的内容对我们不透明,你不知道哪些代码被更改,这对我来说是无法接受的。

但是在使用 prettier 进行代码格式化时,完全可以添加 --write 来使我们的代码自动格式化,它不会更改语法层面上的东西,所以无需担心。

@7 新版操作

在 package.json 似乎没有办法初始化脚本了

Edit package.json > prepare script and run it once: npm run prepare

npx husky add .husky/pre-commit "lint-staged"
git add .husky/pre-commit

npx husky add .husky/commit-msg "commitlint --config .commitlintrc.js -E HUSKY_GIT_PARAMS"
git add .husky/commit-msg

但是无法执行 lint-staged

@3

安装@3 版本就好了,相关脚本可以正常安装

Commitlint + Changelog

建议阅读 Commit message 和 Change log 编写指南(阮一峰)

继续参考:https://github.com/vortesnail/blog/issues/14#

npm install @commitlint/cli @commitlint/config-conventional -D

@commitlint/config-conventional 类似 eslint 配置文件中的 extends ,它是官方推荐的 angular 风格的 commitlint 配置,提供了少量的 lint 规则,默认包括了以下除了我自己新增的 type

随后在根目录新建文件 .commitlintrc.js ,这就是我们的 commitlint 配置文件,写入以下代码:

module.exports = {
  extends: ['@commitlint/config-conventional']
}

随后回到 package.jsonhusky 配置,增加一个钩子:

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint --config .commitlintrc.js -E HUSKY_GIT_PARAMS"
    }
  },
}

E HUSKY_GIT_PARAMS 简单理解就是会拿到我们的 message ,然后 commitlint 再去进行 lint 校验。

接着配置生成我们的 changelog ,首先安装依赖:

npm install conventional-changelog-cli -D

package.jsonscripts 下增加一个命令:

{
  "scripts": {
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
  },
}

之后就可以通过 npm run changelog 生成 angular 风格的 changelog ,需要注意的是,上面这条命令产生的 changelog 是基于上次 tag 版本之后的变更(feat、fix 等等)所产生的。

现在就来测试一下我们上面的工作有没有正常运行吧!执行以下提交信息不规范(chore 写成 chora)的命令:

# 提交所有变化到缓存区
git add -A
# 把暂存区的所有修改提交到分支 
git commit -m "chora: add commitlint to force commit style"

设置 Commit 模板

git config commit.template ./ci_template

在项目的本地终端执行即可

也可以加上 --global 参数设置全局模板,只是这样的话不方便团队统一

Rollup Init

Rollup or Webpack

Rollup

Webpack

所需插件

import typescript from 'rollup-plugin-typescript2'; // 处理typescript
import babel from 'rollup-plugin-babel'; // 处理es6
import resolve from '@rollup/plugin-node-resolve'; // 你的包用到第三方npm包
import commonjs from '@rollup/plugin-commonjs'; // 你的包用到的第三方只有commonjs形式的包
import builtins from 'rollup-plugin-node-builtins'; // 如果你的包或依赖用到了node环境的builtins fs等
import globals from 'rollup-plugin-node-globals'; // 如果你的包或依赖用到了globals变量
import { terser } from 'rollup-plugin-terser'; // 压缩,可以判断模式,开发模式不加入到plugins

发布配置

export default {
  input: 'src/index.ts', // 源文件入口
  output: [
    {
      file: 'dist/index.esm.js', // package.json 中 "module": "dist/index.esm.js"
      format: 'esm', // es module 形式的包, 用来import 导入, 可以tree shaking
      sourcemap: true
    }, {
      file: 'dist/index.cjs.js', // package.json 中 "main": "dist/index.cjs.js",
      format: 'cjs', // commonjs 形式的包, require 导入 
      sourcemap: true
    }, {
      file: 'dist/index.umd.js',
      name: 'GLWidget',
      format: 'umd', // umd 兼容形式的包, 可以直接应用于网页 script
      sourcemap: true
    }
  ],
  plugins: plugins
}

这样就可以同时发布 3 种格式的包供其他人选择使用

发布 Ts 声明文件

tsconfig.json

{
  "compilerOptions": {
    "declaration": true // 生成*.d.ts
    ...
  }
  ...
}

如果 rollup-plugin-typescript2 没有额外配置的话,会在 dist 文件夹生成对应的声明文件,在 package.json 中指定 types 字段,那其他人用 typescript 开发时就可以获取提示了

{
  "types": "dist/index.d.ts"
  ...
}

自定义插件

在开发中我有个需求,想要模块化 glsl,这样可以更好的组织 shader 代码,另一方面编写 glsl 文件可以获得编辑器的提示和高亮辅助

已有方案

现在有比较知名的库 glslify 来做模块化,我为什么没用呢

那怎么不用 glslify 的 rollup 插件在打包阶段解决呢

那听起来和 scss 做的模块化类似,可以@import 变量进来,那我就仿照 scss 的方式写一个简单的 rollup 插件解决自己的需求

插件形式

function includeText(userOptions = {}) {
  return {
    name: 'include-text',
    async transform(source, id) { // hooks
      let transformedCode = xxx(souce) // 按你的方式改变code
      return { code: `export default ${JSON.stringify(transformedCode.toString())}`, map: { mappings: '' }};
    }
  }
module.exports = includeText;

主要功能

我主要用了 transform 这个 hook,source 是代码,id 是这个代码对应的文件,我要做的就是

监听文件变化

到这里基本功能就完成了,在使用中会发现,你修改 js 中 import 的 glsl 中代码,是可以触发自动编译打包的,但是 glsl 中 import 的 glsl 文件是无法触发的,那么就要用到 addWatchFile 这个 api

async transform(source, id) {
  this.addWatchFile(xxx)
}

递归的将所有找到的@import 文件全部进行 addWatchFile 操作

总结

以上就是在 GLWidget 项目中用到 rollup 相关内容,完成了用 typescript 编写,发布 3 种形式 npm 包的步骤,在过程中编写了处理 glsl 文件的插件。

Webpack Init

重点在于入口、出口、loader、plugins,这几个顶级属性配好了就可以启动了。

其他的是后期要优化的,比如热更新、代码分离等。

基础开发环境

基础

npm install --save-dev webpack webpack-cli

新建 config 文件夹,新建 webpack.base.conf.js

const path = require('path')

module.exports = {
  // 入口起点,从项目的根目录开始读取路径,而不是配hi文件所在的config目录
  entry: {
    app: './src/index.js',
  },
  // 输出
  output: {
    // 使用了path包,是相对路径,从本文件开始
    path: path.resolve(__dirname, '../dist'),
    filename: "[name].[contenthash].js",
    publicPath: '/',
    // webpack 默认通过箭头函数包裹打包内容实现作用域
    environment: {
        arrowFunction: false
    }
  },
  module: {
    rules: [
    ],
  },
  // 代码模块路径解析的配置
  resolve: {
	// 自动添加模块后缀名
    extensions: [".wasm", ".mjs", ".js", ".json", ".jsx"],
  },
  plugins: [
  ],
}

html-webpack-plugin:关联 HTML

webpack 默认从作为入口的 .js 文件进行构建(更多是基于 SPA 去考虑),但通常一个前端项目都是从一个页面(即 HTML)出发的,最简单的方法是,创建一个 HTML 文件,使用 script 标签直接引用构建好的 JS 文件,如…

<script src="./dist/bundle.js"></script>

但是,如果我们的文件名或者路径会变化,例如使用 [hash] 来进行命名,那么最好是将 HTML 引用路径和我们的构建结果关联起来,这个时候我们可以使用 html-webpack-plugin

html-webpack-plugin 是一个独立的 node package,所以在使用之前我们需要先安装它,把它安装到项目的开发依赖中

npm install --save-dev html-webpack-plugin

然后在 webpack 配置中,将 html-webpack-plugin 添加到 plugins 列表中

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

module.exports = {
  // ...
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html', // 配置输出文件名和路径
      template: 'public/index.html', // 配置文件模板,也就是入口
      inject: 'body', // inject
    }),
  ],
}

这样配置好之后,构建时 html-webpack-plugin 会为我们创建一个 HTML 文件,其中会引用构建出来的 JS 文件。实际项目中,默认创建的 HTML 文件并没有什么用,我们需要自己来写 HTML 文件,可以通过 html-webpack-plugin 的配置,传递一个写好的 HTML 模板…

这样,通过 html-webpack-plugin 就可以将我们的页面和构建 JS 关联起来,回归日常,从页面开始开发。如果需要添加多个页面关联,那么实例化多个 html-webpack-plugin, 并将它们都放到 plugins 字段数组中就可以了… @@@

配置参数

filename输出 文件的文件名称,默认为 index.html,不配置就是该文件名;此外,还可以为输出文件指定目录位置(例如 'html/index.html')

template: 本地模板文件 的位置,支持加载器 (如 handlebars、ejs、undersore、html 等),如比如 handlebars!src/index.hbs

inject:向 template 或者 templateContent 中注入所有静态资源,不同的配置值注入的位置不经相同。

从这里的配置可以看出,想要启动项目,我们需要一个 html 模板,一个入口 js

启用静态服务

我们可以使用 webpack-dev-server 在本地开启一个简单的静态服务来进行开发

npm install --save-dev webpack-dev-server

webpack.config.js

module.exports = {
    // 开发服务器
    devServer: {
        static: false, // 默认 dev-server 会为根文件夹提供本地服务器,如果想为另外一个目录下的文件提供本地服务器,应该在这里设置其所在目录,设置为 false 禁用
        historyApiFallback: true, // 在开发单页应用时非常有用,它依赖于HTML5 history API,如果设置为true,所有的跳转将指向index.html
        open: true, // 自动打开浏览器
        compress: true, // 启用gzip压缩
        hot: true, // 模块热更新,取决于HotModuleReplacementPlugin
        host: '127.0.0.1', // 设置默认监听域名,如果省略,默认为“localhost”
        port: 8080, // 设置默认监听端口,如果省略,默认为“8080”
        devMiddleware: {
            stats: 'errors-only', // 控制终端仅打印 error
        },
        client: {
            logging: 'error', // 控制浏览器控制台显示的信息
            overlay: true, // Shows a full-screen overlay in the browser when there are compiler errors or warnings
            progress: true, // 将运行进度输出到控制台
        },
    },
  // ...
  plugins: [
  ],
} 

一些配置项的说明:

package.json

"scripts": {
  "dev":"webpack-dev-server --mode development --config config/webpack.base.conf.js",
  "start": "npm run dev"
},

这里的 mode 也可以直接在配置文件中声明

尝试着运行 npm start 或者 yarn start,然后就可以访问 http://localhost:8080/ 来查看你的页面了。默认是访问 index.html,如果是其他页面要注意访问的 URL 是否正确

构建 CSS

我们编写 CSS,并且希望使用 webpack 来进行构建,为此,需要在配置中引入 loader 来解析和处理 CSS 文件

npm install --save-dev css-loader style-loader

module.exports = {
  module: {
    rules: [
      // ...
      {
        test: /\.css$/,
        include: [
          path.resolve(__dirname, '../src'),
        ],
        use: ['style-loader','css-loader',],
      },
    ],
  }
}...

css-loader 负责解析 CSS 代码,主要是为了处理 CSS 中的依赖,例如 @importurl() 等引用外部文件的声明;

style-loader 会将 css-loader 解析的结果转变成 JS 代码,运行时动态插入 style 标签来让 CSS 代码生效…

经由上述两个 loader 的处理后,CSS 代码会转变为 JS,和 index.js 一起打包了。如果需要单独把 CSS 文件分离出来:

  1. webpack4 中我们需要使用 extract-text-webpack-plugin 插件
  2. webapck5mini-css-extract-plugin,而且不再需要 style-loader
const ExtractTextPlugin = require('extract-text-webpack-plugin')

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        // 因为这个插件需要干涉模块转换的内容,所以需要使用它对应的 loader
        use: ExtractTextPlugin.extract({ 
          fallback: 'style-loader',
          use: 'css-loader',
        }), 
      },
    ],
  },
  plugins: [
    // 引入插件,配置文件名,这里同样可以使用 [hash]
    new ExtractTextPlugin('index.css'),
  ],
}...
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
  plugins: [ new MiniCssExtractPlugin({ 
      // Options similar to the same options in webpackOptions.output 
      // both options are optional filename: "[name].css", chunkFilename: "[id].css" })
  ],
    module: {
        rules: [
            {
                test: /\.css$/,
                include: [
                    path.resolve(__dirname, '../src'),
                ],
                use: [MiniCssExtractPlugin.loader, 'css-loader'],
            },
        ],
    },
}

CSS 预处理器

npm install --save-dev less less-loader

node-sass sass-loader

在上述使用 CSS 的基础上,通常我们会使用 Less/Sass 等 CSS 预处理器,webpack 可以通过添加对应的 loader 来支持,以使用 Less 为例,我们可以在官方文档中找到对应的 loader

module.exports = {
    // ...
    module: {
        rules: [
            {
                test: /\.less$/,
                include: [
                    path.resolve(__dirname, '../src'),
                ],
                use: [
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    {
                        loader: 'less-loader',
                        // 支持antd 按需载入
                        options: {
                            lessOptions: {
                                javascriptEnabled: true,
                            },
                        },
                    },
                ],
            },
        ],
    },
    // ...
}...

处理图片文件

在前端项目的样式中总会使用到图片,虽然我们已经提到 css-loader 会解析样式中用 url() 引用的文件路径,但是图片对应的 jpg/png/gif 等文件格式,webpack 处理不了。是的,我们只要添加一个处理图片的 loader 配置就可以了

npm i --save-dev file-loader url-loader

File-loader

Url-loader

对比

Ts 图片类型报错

不幸的是,当你尝试引入一张图片的时候,会有以下 ts 的报错(如果你安装了 ts 的话):

image.png

这个时候在 src/ 下新建以下文件 typings/file.d.ts ,输入以下内容即可:

declare module '*.svg' {
  const path: string
  export default path
}

declare module '*.bmp' {
  const path: string
  export default path
}

declare module '*.gif' {
  const path: string
  export default path
}

declare module '*.jpg' {
  const path: string
  export default path
}

declare module '*.jpeg' {
  const path: string
  export default path
}

declare module '*.png' {
  const path: string
  export default path
}

内置静态资源构建能力 —— Asset Modules

在 Webpack5 之前,我们一般都会使用以下几个 loader 来处理一些常见的静态资源,比如 PNG 图片、SVG 图标等等,他们的最终的效果大致如下所示:

Webpack5 提供了内置的静态资源构建能力,我们不需要安装额外的 loader,仅需要简单的配置就能实现静态资源的打包和分目录存放。如下:满足规则匹配的资源就能够被存放在 assets 文件夹下面。

// webpack.config.js
module.exports = {
    ...,
    module: {
      rules: [
          {
            test: /\.(png|jpg|svg|gif)$/,
            type: 'asset/resource',
            generator: {
                // [ext]前面自带"."
                filename: 'assets/[contenthash].[name][ext]',
            },
        },
      ],
    },
}

其中 type 取值如下几种:

使用 Babel

Babel 是一个让我们能够使用 ES 新特性的 JS 编译工具,我们可以在 webpack 中配置 Babel,以便使用 ES6ES7 标准来编写 JS 代码

npm i --save-dev babel-loader @babel/core

npm i --save-dev @babel/preset-env @babel/preset-react

module.exports = {
    // ...
    module: {
        rules: [
            {
                test: /\.(tsx|jsx)?/, // 支持 js 和 jsx
                include: [
                    path.resolve(__dirname, '../src'), // src 目录下的才需要经过 babel-loader 处理
                ],
                options: {
                    cacheDirectory: true,
                    presets: ['@babel/env', '@babel/react', '@babel/typescript'],
                    plugins: [
                        ['@babel/plugin-proposal-decorators', { legacy: true }],
                        '@babel/plugin-proposal-class-properties',
                        ['@babel/plugin-proposal-object-rest-spread', { useBuiltIns: true }],
                    ],
                },
                loader: 'babel-loader',
            },
        ],
    },
}...

babel-loader 在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积大冗余,同时也会减慢编译效率,所以我们开启 cacheDirectory 将这些公共文件缓存起来,下次编译就会加快很多。

使用 Async 语法

npm i --save-dev @babel/runtime @babel/plugin-transform-runtime

babelrc

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react"],
  "plugins": [[
    "@babel/plugin-transform-runtime",
    {
      "helpers": true, // 默认,可以不写
      "regenerator": true, // 提供的 不污染全局的 regeneratorRuntime
      "useESModules": true // 使用 es modules helpers, 减少 commonJS 语法代码
    }
  ]]
}

配置执行命令

package.json 中修改 scripts 属性为:

  "scripts": {
    "dev": "webpack-dev-server --colors --config config/webpack.base.conf.js",
    "start": "npm run dev",
    "build": "webpack --progress --colors --config config/webpack.base.conf.js"
  },

dev 字段配置开发环境命令 npm run dev

环境差异配置

我们在日常的前端开发工作中,一般都会有两套构建环境:一套开发时使用,构建结果用于本地开发调试,不进行代码压缩,打印 debug 信息,包含 sourcemap 文件

另外一套构建后的结果是直接应用于线上的,即代码都是压缩后,运行时不打印 debug 信息,静态文件不包括 sourcemap 的。有的时候可能还需要多一套测试环境,在运行时直接进行请求 mock 等工作

webpack 4.x 版本引入了 mode 的概念,在运行 webpack 时需要指定使用 productiondevelopment 两个 mode 其中一个,这个功能也就是我们所需要的运行两套构建环境的能力。

常见的环境差异配置

在配置文件中区分 Mode

前面我们列出了几个环境差异配置,可能这些构建需求就已经有点多了,会让整个 webpack 的配置变得复杂,尤其是有着大量环境变量判断的配置。我们可以把 webpack 的配置按照不同的环境拆分成多个文件,运行时直接根据环境变量加载对应的配置即可。基本的划分如下…

首先我们要明白,对于 webpack 的配置,其实是对外暴露一个 JS 对象,所以对于这个对象,我们都可以用 JS 代码来修改它,例如

const config = {
  // ... webpack 配置
}

// 我们可以修改这个 config 来调整配置,例如添加一个新的插件
config.plugins.push(new YourPlugin());

module.exports = config;...

因此,只要有一个工具能比较智能地合并多个配置对象,我们就可以很轻松地拆分 webpack 配置,然后通过判断环境变量,使用工具将对应环境的多个配置对象整合后提供给 webpack 使用。这个工具就是 webpack-merge

我们的 webpack 配置基础部分,即 webpack.base.js 应该大致是这样的

module.exports = {
  entry: '...',
  output: {
    // ...
  },
  resolve: {
    // ...
  },
  module: {
    // 这里是一个简单的例子,后面介绍 API 时会用到
    rules: [
      {
        test: /\.js$/, 
        use: ['babel'],
      },
    ],
    // ...
  },
  plugins: [
    // ...
  ],
}...

然后 webpack.development.js 需要添加 loaderplugin,就可以使用 webpack-mergeAPI,例如

const { merge } = require('webpack-merge')
const webpack = require('webpack')
const base = require('./webpack.base.js')

module.exports = merge(base, {
  module: {
    rules: [
      // 用 smart API,当这里的匹配规则相同且 use 值都是数组时,smart 会识别后处理
      // 和上述 base 配置合并后,这里会是 { test: /\.js$/, use: ['babel', 'coffee'] }
      // 如果这里 use 的值用的是字符串或者对象的话,那么会替换掉原本的规则 use 的值
      {
        test: /\.js$/,
        use: ['coffee'],
      },
      // ...
    ],
  },
  plugins: [
    // plugins 这里的数组会和 base 中的 plugins 数组进行合并
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    }),
  ],
})...

可见 webpack-merge 提供的 merge 方法,可以帮助我们更加轻松地处理 loader 配置的合并。webpack-merge 还有其他 API 可以用于自定义合并行为 https://github.com/survivejs/webpack-merge

完整代码

Base

configs/webpack.base.conf.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    // 入口起点,从项目的根目录开始读取路径,而不是配置文件所在的config目录
    entry: {
        app: './src/index.js',
    },
    // 输出
    output: {
        // 使用了path包,是相对路径,从本文件开始
        path: path.resolve(__dirname, '../dist'),
        filename: '[name].[contenthash].js',
        publicPath: '/',
        // webpack 默认通过箭头函数包裹打包内容实现作用域
        environment: {
            arrowFunction: false,
        },
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                include: [
                    path.resolve(__dirname, '../src'),
                ],
                use: [MiniCssExtractPlugin.loader, 'css-loader'],
            },
            {
                test: /\.less$/,
                include: [
                    path.resolve(__dirname, '../src'),
                ],
                use: [
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    {
                        loader: 'less-loader',
                        // 支持antd 按需载入
                        options: {
                            lessOptions: {
                                javascriptEnabled: true,
                            },
                        },
                    },
                ],
            },
            {
                test: /\.(png|jpg|svg|gif)$/,
                type: 'asset',
                generator: {
                    // [ext]前面自带"."
                    filename: 'assets/[contenthash].[name][ext]',
                },
            },
            {
                test: /\.(tsx|js)$/, // 支持 js 和 jsx
                include: [
                    path.resolve(__dirname, '../src'), // src 目录下的才需要经过 babel-loader 处理
                ],
                options: {
                    cacheDirectory: true,
                    presets: [['@babel/env'], '@babel/typescript'],
                    plugins: [
                        [
                            '@babel/plugin-transform-runtime',
                            {
                                'helpers': true, // 默认,可以不写
                                'regenerator': true, // 提供的 不污染全局的 regeneratorRuntime
                                'useESModules': true, // 使用 es modules helpers, 减少 commonJS 语法代码
                            },
                        ],
                        // ['@babel/plugin-proposal-decorators', { legacy: true }],
                        // '@babel/plugin-proposal-class-properties',
                        // ['@babel/plugin-proposal-object-rest-spread', { useBuiltIns: true }],
                    ],
                },
                loader: 'babel-loader',
            },
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: 'index.html', // 配置输出文件名和路径
            template: 'public/index.html', // 配置文件模板,也就是入口
            inject: 'body', // inject
        }),
        new MiniCssExtractPlugin(),
    ],
};

Dev

configs/webpack.dev.conf.js

const { merge } = require('webpack-merge');
const baseWebpackConfig = require('./webpack.base.conf'); // 引入公用的config

module.exports = merge(baseWebpackConfig, {
    // 模式
    mode: 'development',
    // 调试工具
    devtool: 'inline-source-map',
    // 开发服务器
    devServer: {
        static: false, // 默认 dev-server 会为根文件夹提供本地服务器,如果想为另外一个目录下的文件提供本地服务器,应该在这里设置其所在目录,设置为 false 禁用
        historyApiFallback: true, // 在开发单页应用时非常有用,它依赖于HTML5 history API,如果设置为true,所有的跳转将指向index.html
        open: true, // 自动打开浏览器
        compress: true, // 启用gzip压缩
        hot: true, // 模块热更新,自动添加了HotModuleReplacementPlugin,也无需在启动时添加参数
        host: '127.0.0.1', // 设置默认监听域名,如果省略,默认为“localhost”
        port: 8080, // 设置默认监听端口,如果省略,默认为“8080”
        devMiddleware: {
            stats: 'errors-only', // 控制终端仅打印 error
        },
        client: {
            logging: 'error', // 控制浏览器控制台显示的信息
            overlay: true, // Shows a full-screen overlay in the browser when there are compiler errors or warnings
            progress: true, // 将运行进度输出到控制台
        },
    },
    // 插件
    plugins: [
    ],
    optimization: {
        nodeEnv: 'development',
    },
    // 代码模块路径解析的配置
    resolve: {
    // 自动添加模块后缀名
        extensions: ['.wasm', '.mjs', '.js', '.json', '.jsx'],
    },
});

Prod

configs/webpack.prod.conf.js

const { merge } = require('webpack-merge');

const baseConfig = require('./webpack.base.conf');

const config = merge(baseConfig, {
    mode: 'production',
    module: {
        rules: [

        ],
    },
    plugins: [

    ],
});

module.exports = config;

更新配置执行命令

package.json

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack-dev-server --hot --inline --progress --colors --config config/webpack.dev.conf.js",
    "start": "npm run dev",
    "build": "webpack --progress --colors --config config/webpack.prod.conf.js"
  }

不同的命令执行不同的 webpack.config

开发环境完善

模块热替换

HMR 全称是 Hot Module Replacement,即模块热替换。在这个概念出来之前,我们使用过 Hot Reloading,当代码变更时通知浏览器刷新页面,以避免频繁手动刷新浏览器页面。HMR 可以理解为增强版的 Hot Reloading,但不用整个页面刷新,而是局部替换掉部分模块代码并且使其生效,可以看到代码变更后的效果。所以,HMR 既避免了频繁手动刷新页面,也减少了页面刷新时的等待,可以极大地提高前端页面开发效率…

HMR,模块热替换(HMR)功能会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。主要通过以下几种方式:

配置使用 HMR

module.hot 常见的 API

对比

Css Import 使用 Alias 相对路径

问题

原因

解决

PostCSS 处理浏览器兼容问题

postcss 一种对 css 编译的工具,类似 babel 对 js 一样通过各种插件对 css 进行处理,在这里我们主要使用以下插件:

安装上面提到的所需的包:

npm install postcss-loader postcss-flexbugs-fixes postcss-preset-env autoprefixer postcss-normalize -D

postcss-loader 放到 css-loader 后面,配置如下:

{
  loader: 'postcss-loader',
  options: {
    ident: 'postcss',
    plugins: [
      require('postcss-flexbugs-fixes'),
      require('postcss-preset-env')({
        autoprefixer: {
          grid: true,
          flexbox: 'no-2009'
        },
        stage: 3,
      }),
      require('postcss-normalize'),
    ],
    sourceMap: isDev,
  },
},

但是我们要为每一个之前配置的样式 loader 中都要加一段这个,这代码会显得非常冗余,于是我们把公共逻辑抽离成一个函数,与 cra 一致,命名为 getCssLoaders ,因为新增了 postcss-loader ,所以我们要修改 importLoaders ,于是我们现在的 webpack.common.js 修改为以下这样:

const getCssLoaders = (importLoaders) => [
  'style-loader',
  {
    loader: 'css-loader',
    options: {
      modules: false,
      sourceMap: isDev,
      importLoaders,
    },
  },
  {
    loader: 'postcss-loader',
    options: {
      ident: 'postcss',
      plugins: [
        // 修复一些和 flex 布局相关的 bug
        require('postcss-flexbugs-fixes'),
        require('postcss-preset-env')({
          autoprefixer: {
            grid: true,
            flexbox: 'no-2009'
          },
          stage: 3,
        }),
        require('postcss-normalize'),
      ],
      sourceMap: isDev,
    },
  },
]

module.exports = {
	// other...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: getCssLoaders(1),
      },
      {
        test: /\.less$/,
        use: [
          ...getCssLoaders(2),
          {
            loader: 'less-loader',
            options: {
              sourceMap: isDev,
            },
          },
        ],
      },
      {
        test: /\.scss$/,
        use: [
          ...getCssLoaders(2),
          {
            loader: 'sass-loader',
            options: {
              sourceMap: isDev,
            },
          },
        ],
      },
    ]
  },
  plugins: [//...],
}

最后,我们还得在 package.json 中添加 browserslist (指定了项目的目标浏览器的范围):

{
  "browserslist": [
    ">0.2%",
    "not dead", 
    "ie >= 9",
    "not op_mini all"
  ],
}

现在,在如果你在入口文件(比如我之前一直用的 app.js )随便引一个写了 display: flex 语法的样式文件, npm run start 看看是不是自动加了浏览器前缀了呢?快试试吧!

Dev-server 配置跨域请求

如果你有单独的后端开发服务器 API,并且希望在同域名下发送 API 请求 ,那么代理某些 URL 会很有用。

dev-server 使用了非常强大的 http-proxy-middleware 包。更多高级用法,请查阅其 文档

    proxy: {
      '/api': {
        target: 'http://localhost:6503',
        pathRewrite: {'^/api' : ''},
        changeOrigin:true,
      }
    }

基本使用

localhost:3000 上有后端服务的话,你可以这样启用代理:

webpack.config.js:

  module.exports = {
    //...
    devServer: {
      proxy: {
        '/api': 'http://localhost:3000'
      }
    }
  };

请求到 /api/users 现在会被代理到请求 http://localhost:3000/api/users

重写路径

如果你不想始终传递 /api ,则需要重写路径:

  module.exports = {
    //...
    devServer: {
      proxy: {
        '/api': {
          target: 'http://localhost:3000',
          pathRewrite: {'^/api' : ''}
        }
      }
    }
  };

后端不需要再加上 ‘api’ ,但是前端请求还是要的,用作转发的标志

代理多个路径

如果你想要代理多个路径特定到同一个 target 下,你可以使用由一个或多个「具有 context 属性的对象」构成的数组:

  module.exports = {
    //...
    devServer: {
      proxy: [{
        context: ['/auth', '/api'],
        target: 'http://localhost:3000',
      }]
    }
  };

配合 HTTPs

https://webpack.docschina.org/configuration/dev-server/#devserver-proxy

代理根路径

生产环境完善

打包编译前清理 Dist 目录

我们发现每次打出来的文件都会继续残留在 dist 目录中,当然如果你足够勤快,可以每次打包前手动清理一下,但是这种勤劳是毫无意义的。

借助 clean-webpack-plugin 可以实现每次打包前先处理掉之前的 dist 目录,以保证每次打出的都是当前最新的,我们先安装它:

npm install clean-webpack-plugin -D

打开 webpack.prod.js 文件,增加以下代码:

const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
	// other...
  plugins: [
    new CleanWebpackPlugin(),
  ],
}

它不需要你去指定要删除的目录的位置,会自动找到 output 中的 path 然后进行清除。

现在再执行一下 npm run build ,看看打出来的 dist 目录是不是干净清爽了许多?

完善 Typescript 配置

tsconfig.json

每个 Typescript 都会有一个 tsconfig.json 文件,其作用简单来说就是:

一般都会把 tsconfig.json 文件放在项目根目录下。在控制台输入以下代码来生成此文件:

npx tsc --init

打开生成的 tsconfig.json ,有很多注释和几个配置,有点点乱,我们就将这个文件的内容删掉吧,重新输入我们自己的配置。

此文件中现在的代码为:

{
    "compilerOptions": {
        // 基本配置
        // 编译成哪个版本的 es
        "target": "ES5",
        // 指定生成哪个模块系统代码
        "module": "ESNext",
        // 编译过程中需要引入的库文件的列表
        "lib": [
            "dom",
            "dom.iterable",
            "esnext"
        ],
        // 允许编译 js 文件
        "allowJs": true,
        "isolatedModules": true,
        // 启用所有严格类型检查选项
        "strict": true,

        // 模块解析选项
        // 指定模块解析策略
        "moduleResolution": "node",
        // 支持 CommonJS 和 ES 模块之间的互操作性
        "esModuleInterop": true,
        // 支持导入 json 模块
        "resolveJsonModule": true,
        // 根路径
        "baseUrl": "./",
        // 路径映射,与 baseUrl 关联
        "paths": {
            "Src/*": [
                "src/*"
            ],
            "Components/*": [
                "src/components/*"
            ],
            "Utils/*": [
                "src/utils/*"
            ],
            // 因为相对于 baseUrl 所以可以直接访问 node_modules
            "jquery": ["node_modules/jquery/dist/jquery"]
        },

        // 实验性选项
        // 启用实验性的ES装饰器
        "experimentalDecorators": true,
        // 给源码里的装饰器声明加上设计类型元数据
        "emitDecoratorMetadata": true,

        // 开启 ts 严格模式
        "strict": true,
        // 禁止对同一个文件的不一致的引用
        "forceConsistentCasingInFileNames": true,
        // 忽略所有的声明文件( *.d.ts)的类型检查
        "skipLibCheck": true,
        // 允许从没有设置默认导出的模块中默认导入
        "allowSyntheticDefaultImports": true,
        // 只想使用tsc的类型检查作为函数时(当其他工具(例如Babel实际编译)时)使用它
        "noEmit": true
    },
    "exclude": [
        "node_modules"
    ]
}

compilerOptions 用来配置编译选项,其完整的可配置的字段从 这里 可查询到; exclude 指定了不需要编译的文件,我们这里是只要是 node_modules 下面的我们都不进行编译,当然,你也可以使用 include 去指定需要编译的文件,两个用一个就行。

编译配置

targetmodule :这两个参数实际上没有用,它是通过 tsc 命令执行才能生成对应的 es5 版本的 js 语法,但是实际上我们已经使用 babel 去编译我们的 ts 语法了,根本不会使用 tsc 命令,所以它们在此的作用就是让编辑器提供错误提示。

模块配置

isolatedModules :可以提供额外的一些语法检查。

  1. 不能重复 export
  2. 每个文件必须是作为独立的模块:

baseUrlpaths:可以用于快速查找路径

  1. 首先 baseUrl 一定要设置正确,我们的 tsconfig.json 是放在项目根目录的,那么 baseUrl 设为 ./ 就代表了项目根路径。于是, paths 中的每一项路径映射,比如 ["src/*"] 其实就是相对根路径。
  2. 需要改 .eslintrc.js 文件的配置了,首先得安装 npm install eslint-import-resolver-typescript -D
  3.  settings: {
       'import/resolver': {
         node: {
           extensions: ['.tsx', '.ts', '.js', '.json'],
         },
         typescript: {},
       },
     },
    
  4. 需要添加 typescript: {} 即可

但是上面我们完成的工作仅仅是对于编辑器来说可识别这个路径映射,我们需要在 webpack.common.js 中的 resolve.alias 添加相同的映射规则配置:

module.exports = {
  // other...
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],
    alias: {
      'Src': resolve(PROJECT_PATH, './src'),
      'Components': resolve(PROJECT_PATH, './src/components'),
      'Utils': resolve(PROJECT_PATH, './src/utils'),
    }
  },
  module: {//...},
  plugins: [//...],
}

现在,两者一致就可以正常开发和打包了!可能有的小伙伴会疑惑,我只配置 webpack 中的 alias 不就行了吗?虽然开发时会有报红,但并不会影响到代码的正确,毕竟打包或开发时 webpack 都会进行路径映射替换。是的,的确是这样,但是在 tsconfig.json 中配置,会给我们增加智能提示,比如我打字打到 Com ,编辑器就会给我们提示正确的 Components ,而且其下面的文件还会继续提示。

Fork-ts-checker-webpack-plugin

babel-loader 和 typescript-preset 支持编译 ts,但是没有支持类型检查,通过这个插件做支持即可,会另外开启一个进程做类型检查,不会影响编译的速度

npm i -D fork-ts-checker-webpack-plugin

https://github.com/TypeStrong/fork-ts-checker-webpack-plugin

        new ForkTsCheckerWebpackPlugin({
            typescript: {
                diagnosticOptions: {
                    semantic: true,
                    syntactic: true,
                },
                mode: 'write-references',
            },
        }),

上述是官网的推荐配置

产出类型文件

如果是使用 tsc 编译,则默认会产出类型文件

如果是使用 rollup 编译,也可以通过配置产出类型文件

        typescript({
            typescript: ttypescript,
            tsconfig: path.resolve(__dirname, 'tsconfig.json'),
            clean: true,
            tsconfigOverride: {
                compilerOptions: {
                    sourceMap: true,
                    declaration: true,
                    declarationMap: true,
                    'plugins': [
                        {'transform': '@zerollup/ts-transform-paths'}
                    ]
                }
            }
        }),

如果使用 babel 编译,则无法产出类型文件,需要另外运行 tsc --emitDeclarationOnly,通过指定 package.json types 字段保证 import 包时可以获得正确的类型

React Init

环境配置

使用脚手架

npm install -g create-react-app 
npx create-react-app my-app --template typescript
cd hello-react
npm start

npm run ject ,会将封装在 CRA 中的配置全部 反编译 到当前项目,这样用户就可以完全取得 webpack 文件的控制权

https://blog.csdn.net/qq_36709020/article/details/80275602?utm_source=blogxgwz1

基础支持

npm install --save react react-dom

npm i -D @types/react @types/react-dom

Eslint 支持

npm i -D @chiyu-git/eslint-config-react

module.exports = {
    extends: [
        '@chiyu-git/eslint-config-react',
    ],
};
"lint-eslint": "eslint -c .eslintrc.js --ext .ts,.tsx,.js src",

    "lint-staged": {
        "*.{ts,tsx,js}": [
            "eslint --config .eslintrc.js"
        ],
        "*.{css,less,scss}": [
            "stylelint --config .stylelintrc.js"
        ]
    },

Babel 支持

npm i -D @babel/preset-react

test: /\.(tsx|js)$/, // 支持 tsx ts 和 js
presets: [
    ['@babel/env', {
        useBuiltIns: 'usage',
        corejs: 3,
    }],
    '@babel/react',
    '@babel/typescript',
],
    resolve: {
    	// 自动添加模块后缀名
        extensions: ['.tsx', '.ts', '.wasm', '.mjs', '.js', '.json', '.jsx'],
    },

Typescript 支持

    "jsx": "react",                           // 在 .tsx 文件里支持 JSX
npm install @types/react @types/react-dom -D

基本使用

使用 Router

npm install --save react-router-dom

一般来说,一个网站起码会有一个导航栏,用于提供各种链接,而不是让用户手动输入 URL 来实现页面的切换。此外,可能还会有一个公共的页脚,用于显示版权信息、友情链接或者备案信息等。

那么,这些文件应该怎么组织呢?显然,它们应该被放置在布局文件所在的 src/layouts 文件夹下。下面让我们来创建这些文件。

src/layouts 目录,添加两个文件——Frame.js 和 Nav.js:

先生成几个简单的路由入口组件,如 components 目录下的 Home.tsx 和 Detail.tsx

import React, {Component } from 'react'

class Detail extends Component {
  render() {
    return (
      <div>Detail</div>
    )
  }
}

export default Detail

公共部分含有路由组件,比如底部导航,在 Rouer 下再包裹一层即可

// src/layouts/Frame.js
import React, { PureComponent } from 'react';
import { Switch, Route, BrowserRouter } from 'react-router-dom';
import Home from '@components/Home';
import Detail from '@components/Detail';
import Nav from './Nav';

class Frame extends PureComponent {
    render() {
        return (
            <BrowserRouter>
                <div>
                    <Nav />
                    {/*需要紧紧跟着Router,中间插入了其他则不行*/}
                    <Switch>
                      	 {/* 默认路由,path='/' 必须加上exact,否则任何时候都是匹配的*/}
                        <Route exact path='/' component={Home} />
                        <Route path='/detail/:id' component={Detail} />
                        <Route path='/ome' component={Home} />
                    </Switch>
                </div>
            </BrowserRouter>
        );
    }
}

export default Frame;

启动应用

入口 js 文件:src/index.js

import React from 'react'
import ReactDOM from 'react-dom'

import Frame from './layouts/Frame'

ReactDOM.render(
  (
      <Frame/>
  ), 
  document.getElementById('root'));

React 官方并不推荐将组件渲染到 document.body 上,因为这个节点很可能会被修改,比如动态添加一个 <script> 标签等,这将使 React 的 DOM diff 计算变得更加困难。

入口 html 文件:根目录下 index.html

只需含有 id 为 root 的一个元素即可

这两个文件的位置和名字,都已经在 webpack.config.js 中被定义

San Init

npm install --save san

npm install --save-dev san-loader

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      },
      {
        test: /\.san$/,
        use: 'san-loader',
      },
    ],
  },
}...

通用配置

请求

enhanceFetch

/**
 * @file 对原生fetch进行包装 方便使用
 */

/**
 * 返回一个promise,外部可以通过then在获取到数据后再继续操作
 *
 * @param requestUrl
 * @param method
 * @param params
 * @returns Promise
 */
export async function enhanceFetch(
    requestUrl = '',
    method = 'GET',
    params = {},
) {
    console.log(method, params, requestUrl);

    let url = requestUrl;
    const result = null;
    let response: Response;

    // 无论是GET还是POST都需要拼接参数
    let query = '';
    for (const [key, value] of Object.entries(params)) {
        query += `${key}=${value}&`;
    }
    // 去除最后一个 &
    if (query) {
        query = query.slice(0, -1);
    }

    if (method === 'GET' && query) {
        url += `?${query}`;
    }

    // 不同的请求不同的fetch
    try {
        switch (method) {
            case 'GET':
                response = await fetch(url);
                break;
            case 'POST':
                response = await fetch(url, {
                    method,
                    headers: {
                        'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
                    },
                    body: query,
                    mode: 'cors',
                });
                break;
            default:
        }
    }
    catch (error) {
        console.log('Request Error:', error);
    }

    return response!.json();
}

Css Rest

public/reset.css

<link rel="stylesheet" type="text/css" media="screen" href="./reset.css" />

再加上一个 border-box

移动端默认配置

移动端常见问题

移动端数字和邮箱会自动变成可点击的,并且点击后唤醒电话或邮箱 app

<meta name="format-detection" content="telephone=no,email=no"/>

链接点击的时候,会有高亮的默认背景,可以通过 -webkit-tap-highlight-color 设置该值

a{
	-webkit-tap-highlight-color:rgba(0,0,0,0);
}

按钮过圆的问题

input{
  webkit-appearance:none;
  border-radius:5px;
}

Font Boosting 是 Webkit 给移动端浏览器提供的一个特性:当我们在手机上浏览网页时,很可能因为原始页面宽度较大,在手机屏幕上缩小后就看不清其中的文字了。而 Font Boosting 特性在这时会 自动 将其中的 文字字体变大

但是文本内容不可能都指定宽高。不过还好,我们通过指定 max-height 就可以无副作用的禁掉 Font Boosting 特性。用类似 p { max-height: 999999px; } 的方式来处理

300ms 延迟

    <script src="https://as.alipayobjects.com/g/component/fastclick/1.0.6/fastclick.js"></script>
    <script>
      if ('addEventListener' in document) {
        document.addEventListener('DOMContentLoaded', function() {
          FastClick.attach(document.body);
        }, false);
      }
      if(!window.Promise) {
        document.writeln('<script src="https://as.alipayobjects.com/g/component/es6-promise/3.2.2/es6-promise.min.js"'+'>'+'<'+'/'+'script>');
      }
    </script>

Rest

Webpack 搭配 Eslint

webpack 搭配 eslint:https://juejin.cn/post/6844903859488292871

eslint 为什么要和 webpack 搭配?没搞懂,是要确保打包之后的也符合规范?有点离谱

是为了在调试的时候,打包可以提示 esilnt error,感觉还不如编辑器的提示,没有必要融合 webpack

eslint-import-resolver-webpack: 可以借助 webpack 的配置来辅助 eslint 解析,最有用的就是 alias,从而避免 unresolved 的错误

eslint-import-resolver-typescript:和 eslint-import-resolver-webpack 类似,主要是为了解决 alias 的问题

生产环境配置

先配置项目基础,react 和 typescript 可插拔式添加配置即可

公共变量文件

拆分成:

  1. path 的公共变量抽取
  2. 环境变量的区分
  3. 生产环境完善的 hash8

在上面简单的 webpack 配置中,我们发现有两个表示路径的语句:

path.resolve(__dirname, '../../src/app.js')
path.resolve(__dirname, '../../dist')
/Users/RMBP/Desktop/react-ts-quick-starter/scripts/config

所以我们上面的写法,大家可以简单理解为, path.resolve根据当前文件的执行路径下 而找到的想要访问到的 文件相对路径 转换成了:该文件在系统中的绝对路径!

比如我的就是:

/Users/RMBP/Desktop/react-ts-quick-starter/src/app.js

但是大家也看出来了,这种写法需要不断的 ../../ ,这个在文件层级较深时,很容易出错且很不优雅。那我们就换个思路,都从根目录开始找所需的文件路径不久很简单了吗,相当于省略了 ../../ 这一步。

scripts 下新建一个 constant.js 文件,专门用于存放我们的公用变量(之后还会有其他的):

scripts/
	config/
  	webpack.common.js
+ constant.js

在里面定义我们的变量:

const path = require('path')

const PROJECT_PATH = path.resolve(__dirname, '../')
const PROJECT_NAME = path.parse(PROJECT_PATH).name

module.exports = { 
  PROJECT_PATH,
  PROJECT_NAME
}

上面两个简单的 node api 大家可以自己简单了解一下,不想了解也可以,只要明白其有啥作用就行。

然后在 webpack.common.js 中引入,修改代码:

const { resolve } = require('path')
const { PROJECT_PATH } = require('../constants')

module.exports = {
  entry: {
    app: resolve(PROJECT_PATH, './src/app.js'),
  },
  output: {
    filename: 'js/[name].[hash:8].js',
    path: resolve(PROJECT_PATH, './dist'),
  },
}

好了,现在是不是看起来清爽多了,大家可以 npm run build 验证下自己代码是不是有写错或遗漏啥的~🐶

Html-webpack-plugin

因为 html-webpack-plugin 在开发和生产环境我们都需要配置,于是我们打开 webpck.common.js 增加以下内容:

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

module.exports = {
  entry: {...},
  output: {...},
  plugins: [
  	new HtmlWebpackPlugin({
      template: resolve(PROJECT_PATH, './public/index.html'),
      filename: 'index.html',
      cache: fale, // 特别重要:防止之后使用v6版本 copy-webpack-plugin 时代码修改一刷新页面为空问题。
      minify: isDev ? false : {
        removeAttributeQuotes: true,
        collapseWhitespace: true,
        removeComments: true,
        collapseBooleanAttributes: true,
        collapseInlineTagWhitespace: true,
        removeRedundantAttributes: true,
        removeScriptTypeAttributes: true,
        removeStyleLinkTypeAttributes: true,
        minifyCSS: true,
        minifyJS: true,
        minifyURLs: true,
        useShortDoctype: true,
      },
    }),
  ]
}

Devtool

devtool 中的一些设置,可以帮助我们将编译后的代码映射回原始源代码,即大家经常听到的 source-map ,这对于调试代码错误的时候特别重要,而不同的设置会明显影响到构建和重新构建的速度。所以选择一个适合自己的很重要。

它都有哪些值可以设置,官方 devtool 说明 中说的很详细,我就不具体展开了,**在这里我非常非常无敌强烈建议大家故意写一些有错误的代码,然后使用每个设置都试试看!**在开发环境中,我个人比较能接受的是 eval-source-map ,所以我会在 webpack.dev.js 中添加以下代码:

module.exports = merge(common, {
  mode: 'development',
+ devtool: 'eval-source-map',
})

在生产环境中我直接设为 none ,不需要 source-map 功能,在 webpack.prod.js 中添加以下代码:

module.exports = merge(common, {
  mode: 'production',
+ devtool: 'none',
})

通过上面配置,我们本地进行开发时,代码出现了错误,控制台的错误日志就会精确地告诉你错误的代码文件、位置等信息。比如我们在 src/app.js 中第 5 行故意写个错误代码:

const root = document.querySelector('#root')
root.innerHTML = 'hello, webpack!'

const a = 5
a = 6

其错误日志提示我们:你的 app.js 文件中第 5 行出错了,具体错误原因为 balabala.... ,赶紧看看吧~

Loader sourceMap

于是,打开我们的 webpack.common.js ,写入以下代码:

module.exports = {
	// other...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: false, // 默认就是 false, 若要开启,可在官网具体查看可配置项
              sourceMap: isDev, // 开启后与 devtool 设置一致, 开发环境开启,生产环境关闭
              importLoaders: 0, // 指定在 CSS loader 处理前使用的 laoder 数量
            },
          },
        ],
      },
    ]
  },
}

图片和字体文件处理

我们可以使用 file-loader 或者 url-loader 来处理本地资源文件,比如图片、字体文件,而 url-loader 具有 file-loader 所有的功能,还能在图片大小限制范围内打包成 base64 图片插入到 js 文件中,这样做的好处是什么呢?别急,我们先安装所需要的包(后者依赖前者,所以都要安装):

npm install file-loader url-loader -D

然后在 webpack.common.js 中继续在 modules.rules 中添加以下代码:

module.exports = {
  // other...
  module: {
    rules: [
      // other...
      {
        test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10 * 1024,
              name: '[name].[contenthash:8].[ext]',
              outputPath: 'assets/images',
            },
          },
        ],
      },
      {
        test: /\.(ttf|woff|woff2|eot|otf)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: '[name].[contenthash:8].[ext]',
              outputPath: 'assets/fonts',
            },
          },
        ],
      },
    ]
  },
  plugins: [//...],
}

接下来大家引一下本地的图片并放到 img 标签中,或者去 iconfont 下个字体图标试试吧~

不幸的是,当你尝试引入一张图片的时候,会有以下 ts 的报错(如果你安装了 ts 的话):

这个时候在 src/ 下新建以下文件 typings/file.d.ts ,输入以下内容即可:

declare module '*.svg' {
  const path: string
  export default path
}

declare module '*.bmp' {
  const path: string
  export default path
}

declare module '*.gif' {
  const path: string
  export default path
}

declare module '*.jpg' {
  const path: string
  export default path
}

declare module '*.jpeg' {
  const path: string
  export default path
}

declare module '*.png' {
  const path: string
  export default path
}

其实看到现在已经很不容易了,不过我相信大家仔细跟到现在的话,也会收获不少的,上面的 webpack 基本配置只是配置了最基本的功能,接下来我们要达到支持 React,TypeScript 以及一堆的开发环境和生产环境的优化,大家加油噢~

Css-minimizer-webpack-plugin

Cross-env

虽然都分开了配置,但是在公共配置中,还是可能会出现某个配置的某个选项在开发环境和生产环境中采用不同的配置,这个时候我们有两种选择:

显而易见,为了使代码最大的优雅,采用第二种。

cross-env 可跨平台设置和使用环境变量,不同操作系统设置环境变量的方式不一定相同,比如 Mac 电脑上使用 export NODE_ENV=development ,而 Windows 电脑上使用的是 set NODE_ENV=development ,有了这个利器,我们无需在考虑操作系统带来的差异性。

安装它:

npm install cross-env -D

然后在 package.json 中添加修改以下代码:

{
  "scripts": {
+   "start": "cross-env NODE_ENV=development webpack --config ./scripts/config/webpack.dev.js",
+   "build": "cross-env NODE_ENV=production webpack --config ./scripts/config/webpack.prod.js",
-   "build": "webpack --config ./scripts/config/webpack.common.js",
  },
}

修改 srcipt/constants.js 文件,增加一个公用布尔变量 isDev

const isDev = process.env.NODE_ENV !== 'production'

module.exports = {
  isDev,
	// other
}

我们现在就使用这个环境变量做点事吧!记得之前配的公共配置中,我们给出口文件的名字配了 hash:8 ,原因是在生产环境中,即用户已经在访问我们的页面了,他第一次访问时,请求了比如 app.js 文件,根据浏览器的缓存策略会将这个文件缓存起来。然后我们开发代码完成了一版功能迭代,涉及到打包后的 app.js 发生了大变化,但是该用户继续访问我们的页面时,如果缓存时间没有超出或者没有人为清除缓存,那么他将继续得到的是已缓存的 app.js ,这就糟糕了。

于是,当我们文件加了 hash 后,根据入口文件内容的不同,这个 hash 值就会发生非常夸张的变化,当更新到线上,用户再次请求,因为缓存文件中找不到同名文件,就会向服务器拿最新的文件数据,这下就能保证用户使用到最新的功能。

不过,这个 hash 值在开发环境中并不需要,于是我们修改 webpack.common.js 文件:

- const { PROJECT_PATH } = require('../constants')
+ const { isDev, PROJECT_PATH } = require('../constants')

module.exports = {
	// other...
  output: {
-   filename: 'js/[name].[hash:8].js',
+   filename: `js/[name]${isDev ? '' : '.[hash:8]'}.js`,
    path: resolve(PROJECT_PATH, './dist'),
  },
}

5. Mode

在我们没有设置 mode 时,webpack 默认为我们设为了 mode: 'prodution' ,所以之前打包后的 js 文件代码都没法看,因为在 production 模式下,webpack 默认会丑化、压缩代码,还有其他一些默认开启的配置。

我们只要知道,不同模式下 webpack 为为其默认开启不同的配置,有不同的优化,详细可见 webpack.mode

然后接下来大家可以分别执行以下命令,看看分别打的包有啥区别,主要感知下我们上面所说的:

# 开发环境打包
npm run start

# 生产环境打包
npm run build

因为 html-webpack-plugin 在开发和生产环境我们都需要配置,于是我们打开 webpck.common.js 增加以下内容:

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

module.exports = {
  entry: {...},
  output: {...},
  plugins: [
  	new HtmlWebpackPlugin({
      template: resolve(PROJECT_PATH, './public/index.html'),
      filename: 'index.html',
      cache: fale, // 特别重要:防止之后使用v6版本 copy-webpack-plugin 时代码修改一刷新页面为空问题。
      minify: isDev ? false : {
        removeAttributeQuotes: true,
        collapseWhitespace: true,
        removeComments: true,
        collapseBooleanAttributes: true,
        collapseInlineTagWhitespace: true,
        removeRedundantAttributes: true,
        removeScriptTypeAttributes: true,
        removeStyleLinkTypeAttributes: true,
        minifyCSS: true,
        minifyJS: true,
        minifyURLs: true,
        useShortDoctype: true,
      },
    }),
  ]
}

可以看到,我们以 public/index.html 文件为模板,并且在生产环境中对生成的 html 文件进行了代码压缩,比如去除注释、去除空格等。

plugin 是 webpack 的核心功能,它丰富了 webpack 本身,针对是 loader 结束后,webpack 打包的整个过程,它并不直接操作文件,而是基于事件机制工作,会监听 webpack 打包过程中的某些节点,执行广泛的任务。

缓存处理应该单独拆出来

单测

eslint-config-jest-enzyme: jest 和 enzyme 专用的校验规则,保证一些断言语法可以让 Eslint 识别而不会发出警告。

eslint-plugin-jest: Jest 专用的 Eslint 规则校验插件.

项目支持 AB 实验

函数库兼容性

自己实现

如果是我们自己去组织这些函数,我们该怎么做呢?我想我会这样做:

(function(){
  var root = this;

  var _ = {};

  root._ = _;

  // 在这里添加自己的方法
  _.reverse = function(string){
    return string.split('').reverse().join('');
  }

})()

_.reverse('hello');
=> 'olleh'

我们将所有的方法添加到一个名为 _ 的对象上,然后将该对象挂载到全局对象上。

之所以不直接 window._ = _ 是因为我们写的是一个工具函数库,不仅要求可以运行在浏览器端,还可以运行在诸如 Node 等环境中。

Root

然而 underscore 可不会写得如此简单,我们从 var root = this 开始说起。

之所以写这一句,是因为我们要通过 this 获得全局对象,然后将 _ 对象,挂载上去。

然而在严格模式下,this 返回 undefined,而不是指向 Window,幸运的是 underscore 并没有采用严格模式,可是即便如此,也不能避免,因为在 ES6 中模块脚本自动采用严格模式,不管有没有声明 use strict

如果 this 返回 undefined,代码就会报错,所以我们的思路是对环境进行检测,然后挂载到正确的对象上。我们修改一下代码:

var root = (typeof window == 'object' && window.window == window && window) ||
           (typeof global == 'object' && global.global == global && global);

在这段代码中,我们判断了浏览器和 Node 环境,可是只有这两个环境吗?那我们来看看 Web Worker。

Web Worker

Web Worker 属于 HTML5 中的内容,引用《JavaScript 权威指南》中的话就是:

在 Web Worker 标准中,定义了解决客户端 JavaScript 无法多线程的问题。其中定义的 “worker” 是指执行代码的并行过程。不过,Web Worker 处在一个自包含的执行环境中,无法访问 Window 对象和 Document 对象,和主线程之间的通信业只能通过异步消息传递机制来实现。

在 Web Worker 中,是无法访问 Window 对象的,所以 typeof windowtypeof global 的结果都是 undefined,所以最终 root 的值为 false,将一个基本类型的值像对象一样添加属性和方法,自然是会报错的。

虽然在 Web Worker 中不能访问到 Window 对象,但是我们却能通过 self 访问到 Worker 环境中的全局对象。我们只是要找全局变量挂载而已,所以完全可以挂到 self 中嘛。

而且在浏览器中,除了 window 属性,我们也可以通过 self 属性直接访问到 Winow 对象。

console.log(window.window === window); // true
console.log(window.self === window); // true

考虑到使用 self 还可以额外支持 Web Worker,我们直接将代码改成 self:

var root = (typeof self == 'object' && self.self == self && self) ||
           (typeof global == 'object' && global.global == global && global);

Node Vm

到了这里,依然没完,让你想不到的是,在 node 的 vm 模块中,也就是沙盒模块,runInContext 方法中,是不存在 window,也不存在 global 变量的,查看代码

但是我们却可以通过 this 访问到全局对象,所以就有人发起了一个 PR,代码改成了:

var root = (typeof self == 'object' && self.self == self && self) ||
           (typeof global == 'object' && global.global == global && global) ||
           this;

微信小程序

到了这里,还是没完,轮到微信小程序登场了。

因为在微信小程序中,window 和 global 都是 undefined,加上又强制使用严格模式,this 为 undefined,挂载就会发生错误,所以就有人又发了一个 PR,代码变成了:

var root = (typeof self == 'object' && self.self == self && self) ||
           (typeof global == 'object' && global.global == global && global) ||
           this ||
           {};

这就是现在 v1.8.3 的样子。

虽然作者可以直接讲解最终的代码,但是作者更希望带着大家看看这看似普通的代码是如何一步步演变成这样的,也希望告诉大家,代码的健壮性,并非一蹴而就,而是汇集了很多人的经验,考虑到了很多我们意想不到的地方,这也是开源项目的好处吧。

函数对象

现在我们讲第二句 var _ = {};

如果仅仅设置 _ 为一个空对象,我们调用方法的时候,只能使用 _.reverse('hello') 的方式,实际上,underscore 也支持类似面向对象的方式调用,即:

_('hello').reverse(); // 'olleh'

再举个例子比较下两种调用方式:

// 函数式风格
_.each([1, 2, 3], function(item){
    console.log(item)
});

// 面向对象风格
_([1, 2, 3]).each(function(item){
    console.log(item)
});

可是该如何实现呢?

既然以 _([1, 2, 3]) 的形式可以执行,就表明 _ 不是一个字面量对象,而是一个函数!

幸运的是,在 JavaScript 中,函数也是一种对象,我们完全可以将自定义的函数定义在 _ 函数上!

目前的写法

var root = (typeof self == 'object' && self.self == self && self) ||
           (typeof global == 'object' && global.global == global && global) ||
           this ||
           {};

var _ = function() {}

root._ = _;

如何做到 _([1, 2, 3]).each(...) 呢?即 _ 函数返回一个对象,这个对象,如何调用挂在 _ 函数上的方法呢?

我们看看 underscore 是如何实现的:

var _ = function(obj) {
    if (!(this instanceof _)) return new _(obj);
    this._wrapped = obj;
};

_([1, 2, 3]);

我们分析下 _([1, 2, 3]) 的执行过程:

  1. 执行 this instanceof _,this 指向 window ,window instanceof _ 为 false,! 操作符取反,所以执行 new _(obj)
  2. new _(obj) 中,this 指向实例对象,this instanceof _ 为 true,取反后,代码接着执行
  3. 执行 this._wrapped = obj, 函数执行结束
  4. 总结,_([1, 2, 3]) 返回一个对象,为 {_wrapped: [1, 2, 3]},该对象的原型指向 _.prototype

示意图如下:

然后问题来了,我们是将方法挂载到 _ 函数对象上,并没有挂到函数的原型上呐,所以返回了的实例,其实是无法调用 _ 函数对象上的方法的!

所以我们还需要一个方法将 _ 上的方法复制到 _.prototype 上,这个方法就是 _.mixin

_.Functions

为了将 _ 上的方法复制到原型上,首先我们要获得 _ 上的方法,所以我们先写个 _.functions 方法。

_.functions = function(obj) {
    var names = [];
    for (var key in obj) {
        if (_.isFunction(obj[key])) names.push(key);
    }
    return names.sort();
};

isFunction 函数可以参考 《JavaScript专题之类型判断(下)》

Mixin

es-proto mixin 方案

现在我们可以写 mixin 方法了。

var ArrayProto = Array.prototype;
var push = ArrayProto.push;

_.mixin = function(obj) {
  	const funcs = _.functions(obj)
    for(left [key,func] of Object.ectries(funcs)){
      _.prototype[key] = function(...rest) {
            var args = [this._wrapped];
            return func.apply(_, [...args,...rest]);
        };
    }
    return _;
};

_.mixin(_);

值得注意的是:因为 _[name] = obj[name] 的缘故,我们可以给 underscore 拓展自定义的方法:

_.mixin({
  addOne: function(num) {
    return num + 1;
  }
});

_(2).addOne(); // 3

至此,我们算是实现了同时支持面向对象风格和函数风格。

es-object

导出

终于到了讲最后一步 root._ = _,我们直接看源码:

if (typeof exports != 'undefined' && !exports.nodeType) {
  if (typeof module != 'undefined' && !module.nodeType && module.exports) {
    exports = module.exports = _;
  }
  exports._ = _;
} else {
  root._ = _;
}

为了支持模块化,我们需要将 _ 在合适的环境中作为模块导出,但是 nodejs 模块的 API 曾经发生过改变,比如在早期版本中:

// add.js
exports.addOne = function(num) {
  return num + 1
}

// index.js
var add = require('./add');
add.addOne(2);

在新版本中:

// add.js
module.exports = function(1){
    return num + 1
}

// index.js
var addOne = require('./add.js')
addOne(2)

所以我们根据 exports 和 module 是否存在来选择不同的导出方式,那为什么在新版本中,我们还要使用 exports = module.exports = _ 呢?

这是因为在 nodejs 中,exports 是 module.exports 的一个引用,当你使用了 module.exports = function(){},实际上覆盖了 module.exports,但是 exports 并未发生改变,为了避免后面再修改 exports 而导致不能正确输出,就写成这样,将两者保持统一。

最后为什么要进行一个 exports.nodeType 判断呢?这是因为如果你在 HTML 页面中加入一个 id 为 exports 的元素,比如

<div id="exports"></div>

就会生成一个 window.exports 全局变量,你可以直接在浏览器命令行中打印该变量。

此时在浏览器中,typeof exports != 'undefined' 的判断就会生效,然后 exports._ = _,然而在浏览器中,我们需要将 _ 挂载到全局变量上呐,所以在这里,我们还需要进行一个是否是 DOM 节点的判断。