组件库与工程化

217 阅读23分钟

组件库

  • 组件库封装原则,统一风格增加定制功能降低维护成本提高开发效率
  • 如何选择webpack,rollup等构建工具,输出esm等
  • 项目目录划分,文件命名规范
  • 代码规范化:通过 ESLintStylelintPrettier 等工具实现代码规范化和格式化,并封装为自己的规范预设
  • 规范化提交:使用规范化的提交信息可以提高 Git 日志的可读性使用commitizen cz-conventional-changelog;分支管理。
  • 文档站点:可以基于 dumi 搭建文档站点,并实现 CDN 加速、增量发布等优化。
  • 按需加载:需要配合 babel-plugin-import 实现按需加载,即在编译时修改导入路径(导入单个组件)来实现组件的按需加载。
  • Webpack、Rollup 等工具都已经支持了 Tree shaking。在项目的配置中开启 Tree shaking,然后使用 ES Modules 的导入导出语法,即可实现按需加载。有一个需要特别注意的地方,就是副作用sideEffects
  • 组件设计:需要考虑响应式、主题 SCSS、国际化、TypeScript 支持等问题,以保证组件的灵活性和可扩展性。
  • 发布前的自动化脚本:需要编写自动化脚本来规范发布流程,确保发布的一致性和可靠性。
  • 组件库的渐进升级策略遵循semver规范进行版本控制。文档站点同步更新。
  • 定义语言包
    • 定义了一个 JavaScript 对象作为语言包。每种语言都有一个对应的语言包
    • 加载语言包,使用全局$t 方法获取语言包中的文本
  • 主题定制
    • 组件的样式使用SCSS变量定义,这样可以通过改变SCSS变量的值来修改样式。
    • 在线主题编辑器用户可以在工具中配置主题,生成主题文件。
    • 让用户可以通过导入自定义的主题文件(SCSS变量文件)来覆盖默认样式。

工程化

Commonjs 和 ES6 Module的区别

juejin.cn/post/695936…

Commonjs是拷贝输出(互不影响),ES6模块化是引用输出(则是值的动态映射,并且这个映射是只读的。互相影响)

Commonjs是运行时(动态)加载,ES6模块化是编译时(静态)输出接口

Commonjs是单个值导出,ES6模块化可以多个值导出

Commonjs是动态语法可写在函数体中,ES6模块化静态语法只能写在顶层

Commonjs的this是当前模块化,ES6模块化的this是undefined

为什么Commonjs不适用于浏览器

var math = require('math');
math.add(2, 3);

第二行math.add(2, 3),在第一行require('math')之后运行,因此必须等math.js加载完成。也就是说,如果加载时间很长,整个应用就会停在那里等。

这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于"假死"状态。

因此,浏览器端的模块,不能采用"同步加载"(synchronous),只能采用"异步加载"(asynchronous)。这就是AMD规范诞生的背景。

import xx from 'aaa' 和 require('aaa') 到底引入了aaa的什么文件? 根据模块内的package.json识别的。 优先 module 字段,否则 main。

exports 与 module.exports 的区别?

  • 所谓的 exports 仅仅是 module.exports 的引用而已
  • exports 等价于 module.exports 本质是导出exports对象{}
  • exports.a = 3 等价于 module.exports.a = 3
  • exports.a = 3 module.exports.b = 4 输出 {a:3,b:4}
  • exports.a = 3 module.exports = { b: 4 } 输出 { b: 4 }

由浅入深配置webpack4

初始化

  • 初始化项目npm init。在该根目录下,会生成一个package.json文件,这个文件描述了node项目,node包的一些信息。
  • 局部安装。npm install webpack webpack-cli -D
  • npm scripts,运行npm run dev,它会被翻译成对于的指令,也会打包对应的文件。
  • dev:mode,devtool,devServer,plugins(HotModuleReplacementPlugin),optimization.usedExports
  • build:mode,devtool。
"scripts": {
    "dev": "webpack-dev-server --config ./build/webpack.dev.js", 
    "build": "webpack --config ./build/webpack.prod.js", 
    "start": "npx webpack --config ./build/webpack.dev.js"
  }

1. Loaders

webpack是默认知道如何打包js文件的,但是对于一些,比如图片,字体图标的模块,webpack就不知道如何打包了,Loader将这些处理成webpack能够处理的模块。遇到非js结尾的模块,webpack会去module中找相应的规则,匹配到了对于的规则,然后去求助于对应的loader。对应的loader就会将该模块打包到相应的目录下,上面的例子就是dist目录,并且呢,返回的是该模块的路径

  • 当你url-loader打包的图片大小比limit配置的参数大,那么跟file-loader一样会打包到outputPath: 'images/'目录下,当图片较小时,那么就会以Base64打包到bundle.js文件中。

  • 对于字体图标打包test: /\.(woff|woff2|eot|ttf|otf)$/,可以使用file-loader完成

  • 模块的加载就是从右像左来的,先加载postcss-loader给css3新特性加上厂商前缀,再加载sass-loader翻译成css文件,然后使用css-loader主要作用就是将多个css文件整合到一起,形成一个css文件,在通过style-loader会把整合的css部分挂载到head标签中。

  • 在scss文件中引入scss文件,那么规则肯定不会从postcss-loader开始打包,需要在css-loader中配置options,加入importLoaders :2, 这样子就会走postcss-loader,和sass-loader,这样子的语法,无论你是在js中引入scss文件,还是在scss中引入scss文件,都会重新依次从下往上执行所以loader。modules:true这个配置是希望你的css样式作用的是当前的模块中,而不是全局的话,就需要加上这个配置。modules:true会作用域当前的css环境中,样式不会全局引入,语法上也需要使用如下引入import style from './index.scss'

  • 实现一个loader。调用测试方式通过 Npm link或者通过在 rule 对象的loader设置 path.resolve 指向这个本地文件path.resolve('path/to/loader.js')

  • 匹配(test)多个 loaders,你可以使用 resolveLoader.modules 配置,webpack 将会从这些目录中搜索这些 loaders。例如,如果你的项目中有一个 /loaders 本地目录:

// webpack.config.js文件
resolveLoader: {
  // 这里就是说先去找 node_modules 目录中,如果没有的话再去 loaders 目录查找
  modules: [
    'node_modules',
    path.resolve(__dirname, 'loaders')
  ]
}
module.exports = function (content) { 
  console.log(this.data.value) // pitch设置的42
  // 获取到用户传给当前 loader 的参数 
  const options = this.getOptions() 
  const res = someSyncOperation(content, options) 
  this.callback(null, res, sourceMaps); // 注意这里由于使用了 this.callback 直接return 就行 
  return
  
  //上面同步,下面异步
  var callback = this.async() 
  someAsyncOperation(content, function (err, result) { 
    if (err) return callback(err) 
    callback(null, result, sourceMaps, meta) 
  })
}

module.exports.raw = true; // loader的content变成Buffer,如file-loader

module.exports.pitch = function (remainingRequest, precedingRequest, data) {
  data.value = 42; 
};

2. Plugins

webpack插件是阶段式的构建回调,webpack 给我们提供了非常多的 hooks 用来在构建的阶段让开发者自由的去引入自己的行为。更多的是优化,提取精华(公共模块去重),压缩处理(css/js/html)等,对webpack功能的扩展。

  • mini-css-extract-plugin将css提取为独立的文件插件,支持按需加载的css和sourceMap。目前缺失功能,HMR。所以,我们可以把它运用到生成环境中去。需要注意的一点是,当你的webpack版本是4版本的时候,需要去package.json中配置sideEffects属性,这样子就避免了把css文件作为Tree-shaking
  • optimize-css-assets-webpack-plugincss代码经行代码压缩,此时设置optimization.minimizer会覆盖webpack默认提供的规则,比如JS代码就不会再去自动压缩了,需手动开启如下。
  • uglifyjs-webpack-plugin JS代码压缩{ sourceMap: true, parallel: true, // 启用多线程并行运行提高编译速度 }
  • 实现一个Plugin。Compiler 和 Compilation 提供了非常多的钩子供我们使用,这些方法的组合可以让我们在构建过程的不同时间获取不同的内容,具体详情可参见官网直达
  • 实现一个Plugin和loaderjuejin.cn/post/697605…
class HelloPlugin{ 
  apply(compiler){ 
    // 一个 `apply` 方法,`apply` 方法在 webpack 装载这个插件的时候被调用,并且会传入 `compiler` 对象。
    // `compiler` 对象可以理解为一个和 webpack 环境整体绑定的一个对象,它包含了所有的环境配置,
    // 包括 options,loader 和 plugin,当 webpack **启动**时,这个对象会被实例化,并且他是**全局唯一**的
    // tap是同步,`tapAsync` 或者 `tapPromise`是异步
    compiler.hooks.<compilation | hookName>.tapAsync(HelloPlugin,(compilation, callback)=>{
        // `compilation` 在每次构建资源的过程中都会被创建出来,
        // 一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。
        // 它同样也提供了很多的 hook 。
       compilation.hooks.processAssets.tapAsync(HelloPlugin,(params) => {
         setTimeout(() => { 
             /** do some thing */     
             console.log('async') 
             callback() 
         }, 1000)
       })
    }) 
  } 
} 
module.exports = HelloPlugin

// 对插件进行配置:webpack.config.js
const HelloPlugin = require('./plugins/HelloPlugin')

module.exports = {
  plugins: [
    new HelloPlugin()
  ],
}

3. entry和output

entry: {
        index :'./src/index.js',
        bundle : './src/create.js',
    },
output: {
        filename: '[name].[contenthash].js',
        publicPath: "https://cdn.example.com/assets/",
        path: path.join(__dirname, 'dist')
    }    
  • entry这样子配置就可以接受多个打包的文件入口,同时的话,output输出文件的filename需要使用占位符name
  • 这样子就会生成两个文件,不会报错,对于的名字就是entry名称对应
  • 如果已经将资源挂载到了cdn上,那么你的publicPath就会把路径前做修改加上publicPath值

4. devtool配置source-map

  • devtool配置source-map,解决的问题就是,当你代码出现问题时,会映射到你的原文件目录下的错误,并非是打包好的错误,这点也很显然,如果不设置的话,只会显示打包后bundle.js文件中报错,对应查找错误而言,是很不利的
  • 生成 Source Map 之后,一般在浏览器中调试使用,前提是需要开启该功能,打开开发者工具,找到 Settins,勾选js,cssSource Map
// development devtool:'cheap-module-eval-source-map'
// production  devtool:'cheap-module-source-map'

通俗的来说, Source Map 就是一个信息文件,里面存储了代码打包转换后的位置信息,实质是一个 json 描述文件,维护了打包前后的代码映射关系。

image.png 正是因为这句注释,标记了该文件的 Source Map 地址,浏览器才可以正确的找到源代码的位置。 sourceMappingURL 指向 Source Map 文件的 URL 。

image.png

  • versionSource map的版本,目前为v3
  • sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并。
  • names:转换前的所有变量名和属性名。
  • mappings:记录位置信息的字符串。
  • file:转换后的文件名。
  • sourceRoot:转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空。
  • sourcesContent:转换前文件的原始内容。
  • 关于mappings属性
    • 将案例改成如下不报错的情况:var a = 1; console.log(a);mappings 属性的值是: AAAA; AACA, c。。这是一个字符串,它分成三层:
    • 第一层是行对应,以分号(; )表示,每个分号对应转换后源码的一行。所以,第一个分号前的内容,就对应源码的第一行,以此类推。
    • 第二层是位置对应,以逗号(, )表示,每个逗号对应转换后源码的一个位置。所以,第一个逗号前的内容,就对应该行源码的第一个位置,以此类推。
    • 第三层是位置转换,以VLQ 编码表示,代表该位置对应的转换前的源码位置。
    • 因为源代码中有两行,所以有一个分号,分号前后表示了第一行和第二行。即mappings中的AAAAAACA,c。分号后面表示第二行,也就是代码console.log(a);可以拆分出两个位置,分别是consolelog(a),所以存在一个逗号。即AACA,c中的AACAc。总结,就是转换后的源码分成两行,第一行有一个位置,第二行有两个位置
    • 至于这个 AAAA , AAcA 等字母是怎么来的,可以参考阮一峰老师的JavaScript Source Map 详解有作详细的介绍。

5. webpack-dev-server

可以开启一个服务器,而且可以实时去监听打包文件是否改变,改变的话,就会出现去更新,打包的文件会放在内存中,不会生成dist目录。

devServer: {
        contentBase: path.join(__dirname, "dist"),   // dist目录开启服务器
        compress: true,    // 是否使用gzip压缩
        port: 9000,    // 端口号
        open : true   // 自动打开网页
        proxy: {}     // 代理
        hot: true,   // 开启热更新  运行 webpack-dev-server 命令时 加入 `--hot`参数 直接开启 HMR
        // hotOnly: true  // 手动处理 HMR 逻辑过程中 如果 HMR 过程中出现报错 导致的 HRM 失效
    },
    

6. 模块热替换(HMR - Hot Module Replacement)

Hot Module Replacement 是指当我们对代码修改并保存后,Webpack 将会对代码进行重新打包,并将新的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,而无需重新加载整个页面,不同模块的文件更新,只会下载当前模块文件。不需要重新去本地服务器重新去加载其他未修改的资源。

// 添加热替换插件
const webpack = require('webpack')
plugins: [
        new webpack.NamedModulesPlugin(),  // 可配置也可不配置,以便更容易查看要修补(patch)的依赖。
        new webpack.HotModuleReplacementPlugin() // 这个是必须配置的插件
    ],

对于css的内容修改,css-loader底层会帮我们做好实时热更新

对于脱离于框架的原生JS模块的话,我们需要手动的去配置,表示的就是接受一个需要实时热更新的模块,当内容发生变化时,会帮你检测到,然后执行回调函数

:::info 当前主流开发框架 Vue、React都内置loader提供了统一的模块替换函数accept, 因此 Vue、React 项目并不需要针对 HMR 做手动的代码处理,同时 css 文件也由 style-loader 统一处理accept,因此也不需要额外的处理,因此接下去的代码处理逻辑,全部建立在纯原生开发的基础之上实现 :::

// module.hot.accept 其实等价于 module.hot._acceptedDependencies('./child) = render
    if(module.hot){
        module.hot.accept('./print',()=>{
            print() // 用于在模块修改后触发的函数,手动js操作替换dom或者编译后module替换
        })
    }

通过访问全局的module对象下的hot 成员它提供了一个accept 方法,这个方法用来注册当某个模块更新以后需要如何处理,它接受两个参数 一个是需要监听模块的 path(相对路径),第二个参数就是当模块更新以后如何处理,其实也就是一个回调函数。当你手动处理了某个模块的更新以后,是不会触发浏览器自动刷新机制的。

accept 往hot._acceptedDependencies这个对象里存入局部更新的 callback, 当模块改变时,对模块需要做的变更,搜集到_acceptedDependencies中,同时当被监听的模块内容发生了改变以后,父模块可以通过_acceptedDependencies知道哪些内容发生了变化。

其实当 accept 方法执行了以后,在其回调里是可以获取到最新的被修改了以后的模块的函数内容的。既然是可以获取到最新的函数内容 其实也就很简单了 我们只需要移除之前的 dom 节点 并替换为最新的 dom 节点即可,同时我们也需要记录节点里的内容状态,当节点替换为最新的节点以后,追加更新原本的内容状态。到这里为止,对于如何手动实现一个 child 模块的热更新替换逻辑已经全部实现完毕了。

webpack打包HMR原理 juejin.cn/post/697382… image.png

  • Webpack Compile: watch 打包本地文件写入内存,同时监听 compile 下的 done 事件,当 compile 完成以后,通过HMR Server sendStats 方法, 将重新编译打包好的新模块 hash 值发送给浏览器HMR Runtime

  • Boundle Server: 启一个本地服务,提供文件在浏览器端进行访问。

  • HMR Server: 将热更新的文件输出给 HMR Runtime。 建立了一个 webSocket 长链接,用于通知浏览器在 webpack 编译和打包下的各个状态,同时监听 compile 下的 done 事件,当 compile 完成以后,通过 sendStats 方法, 将重新编译打包好的新模块 hash 值发送给浏览器。

  • HMR Runtime: 生成的文件,注入至浏览器内存。 webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂时缓存起来,同时当接收到到 type 为 ok 的时候,对浏览器执行 reload 操作。首先会根据 hot 配置决定是采用哪种更新策略,刷新浏览器或者代码进行热更新(HMR),如果配置了 HMR,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,如果没有配置模块热更新,就直接调用 applyReload下的location.reload 方法刷新页面。

    • 首先是 webpack/hot/dev-server(以下简称 dev-server) 监听上面 webpack-dev-server/client 发送的 webpackHotUpdate 消息,获取currentHash
    • 调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,check 过程中会利用webpack/lib/web/JsonpMainTemplate.runtime.js中的hotDownloadUpdateChunk(通过 jsonp 请求新的模块代码并且返回给 HMR Runtime)以及hotDownloadManifest(发送 AJAx 请求向 Server 请求是否有更新的文件,如果有则会将新的文件返回给浏览器)
    • HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。
  • Bundle: 构建输出文件

7. Babel处理ES6语法

npm install --save-dev babel-loader @babel/core
// @babel/core 是babel中的一个核心库

npm install --save-dev @babel/preset-env
// preset-env 这个模块就是将语法翻译成es5语法,这个模块包括了所有翻译成es5语法规则

npm install --save @babel/polyfill
// 将Promise,map等低版本浏览器中没有实现的语法,用polyfill来实现.
// 该模块就需要自己去实现Promise,map等语法的功能,这也就是为什么打包后的文件很大的原因.

polyfill在js文件最开始导入

import "@babel/polyfill";
module: {
  rules: [
    {
            test: /.js$/,
            exclude: /node_modules/,
            loader: "babel-loader",
            options: {
                "presets": [
                    [
                        "@babel/preset-env",
                        {
                            "useBuiltIns": "usage""modules": false // false生成esm。默认值auto
                        }
                    ]
                ]
            }
        }
  ]
}
// exclude参数: node_modules目录下的js文件不需要做转es5语法,也就是排除一些目录
// "useBuiltIns"参数:usage。只会对我们index.js当前要打包的文件中使用过的语法,比如Promise,map做polyfill,
// 其他es6未出现的语法,我们暂时不去做polyfill,减少打包后体积。

当你生成第三方模块时,或者是生成UI组件库时,使用polyfill解决问题,就会出现问题了,上面的场景使用babel会污染环境,这个时候,我们需要换一种方案来解决

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

npm install --save @babel/runtime

npm install --save @babel/runtime-corejs2 // 当你的 "corejs": 2,需要安装下面这个 

根目录下新建.babelrc文件,将原本要在options中的配置信息写在.babelrc文件

{
    
    "plugins": [
      [
        "@babel/plugin-transform-runtime",
        {
          "corejs": 2,
          "helpers": true,
          "regenerator": true,
          "useESModules": false
        }
      ]
    ]
  }
  • 从业务场景来看,可以使用@babel/preset-env

  • 从自己生成第三方库或者时UI时,使用@babel/plugin-transform-runtime,它作用是将 helper 和 polyfill 都改为从一个统一的地方引入,并且引入的对象和全局变量是完全隔离的,避免了全局的污染

8. tree shaking

当你引入一个模块时,你可能用到的只是其中的某些功能,通过tree-shaking,就能将没有使用的模块摇掉,这样达到了删除无用代码的目的。

  • commonjs使tree shaking失效,动态require一个模块,只有在运行时执行后才知道引用的什么模块,这个就不能通过静态分析去做优化。
  • esm才支持。ES6 module特点只能作为模块顶层的语句出现,import 的模块名只能是字符串常量,import binding 是 immutable的。
  • ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析(不执行代码,编译时AST分析,从字面量上对代码进行分析标记,调用和非调用的ast标记不一样),这就是tree-shaking的基础原理。

image.png

  • webpack4默认的production下是会进行tree-shaking的。

  • optimization.usedExports: true,使webpack确定每个模块导出项(exports)的使用情况。依赖于optimization.providedExports的配置。optimization.usedExports收集到的信息会被其他优化项或产出代码使用到(模块未用到的导出项不会被导出,在语法完全兼容的情况下会把导出名称混淆为单个char)。为了最小化代码体积,未用到的的导出项目(exports)会被删除。生产环境(production)默认开启。

  • 将文件标记为无副作用(side-effect-free)

有时候,当我们的模块不是达到很纯粹,这个时候,webpack就无法识别出哪些代码需要删除,所以,此时有必要向 webpack 的 compiler 提供提示哪些代码是“纯粹部分”。这种方式是通过 package.json 的 "sideEffects" 属性来实现的。

{
  "name": "webpack-demo",
  "sideEffects": false   //告知 webpack不包含副作用,它可以安全地删除未用到的 export 导出。
}

注意,任何导入的文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并导入 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:

{
  "name": "webpack-demo",
  "sideEffects": [
    "*.css"
  ]
}
  • 压缩输出 引入一个能够删除未引用代码(dead code)的压缩工具(minifier)(例如 UglifyJSPlugin),当然了,webpack4开始,也可以通过 "mode" 配置选项轻松切换到压缩输出,只需设置为 "production"

9. SplitChunksPlugin代码分隔

当你有多个入口文件,或者是打包文件需要做一个划分,举个例子,比如第三方库lodash,jquery等库需要打包到一个目录下,自己的业务逻辑代码需要打包到一个文件下,这个时候,就需要提取公共模块了

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 在cacheGroups外层的属性设定适用于所有缓存组,不过每个缓存组内部可以重设这些属性
      chunks: "async", //将什么类型的代码块用于分割,三选一: "initial":入口代码块 | "all":全部 | "async":按需加载的代码块
      minSize: 30000, //大小超过30kb的模块才会被提取
      maxSize: 0, //只是提示,可以被违反,会尽量将chunk分的比maxSize小,当设为0代表能分则分,分不了不会强制
      minChunks: 1, //某个模块至少被多少代码块引用,才会被提取成新的chunk
      maxAsyncRequests: 5, //分割后,按需加载的代码块最多允许的并行请求数,在webpack5里默认值变为6
      maxInitialRequests: 3, //分割后,入口代码块最多允许的并行请求数,在webpack5里默认值变为4
      automaticNameDelimiter: "~", //代码块命名分割符
      name: true, //每个缓存组打包得到的代码块的名称
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/, //匹配node_modules中的模块
          priority: -10, //优先级,当模块同时命中多个缓存组的规则时,分配到优先级高的缓存组
        },
        default: {
          minChunks: 2, //覆盖外层的全局属性
          priority: -20,
          reuseExistingChunk: true, //是否复用已经从原代码块中分割出来的模块
        },
      },
    },
  },
};

其中五个属性是控制代码分割规则的关键,额外提一提:

  • minSize(默认 30000):使得比这个值大的模块才会被提取。
  • minChunks(默认 1):用于界定至少重复多少次的模块才会被提取。
  • maxInitialRequests(默认 3):一个代码块最终就会对应一个请求数,所以该属性决定入口最多分成的代码块数量,太小的值会使你无论怎么分割,都无法让入口的代码块变小。
  • maxAsyncRequests(默认 5):同上,决定每次按需加载时,代码块的最大数量。
  • test:通过正则表达式精准匹配要提取的模块,可以根据项目结构制定各种规则,是手动优化的关键。
  • 在cacheGroups外层的属性设置适用于所有的缓存组,不过每个缓存组内部都可以重新设置它们的值。每个缓存组根据规则将匹配的模块会分配到代码块(chunk)中,每个缓存组的打包结果可以是单一 chunk,也可以是多个 chunk。

这些规则一旦制定,只有全部满足的模块才会被提取,所以需要根据项目情况合理配置才能达到满意的优化结果。这里有篇实际项目中如何代码分隔的,有兴趣的可以看看SplitChunk代码实例

10. Lazy-loding懒加载和Chunk

在webpack中,什么是懒加载,当我需要按需引入某个模块时,这个时候,我们就可以使用懒加载,其实实现的方案就是import语法,在达到某个条件时,我们才会去请求资源。

我们的先借助一个插件,完成对import语法的识别。cnpm install --save-dev @babel/plugin-syntax-dynamic-import

.babelrc文件下配置,增加一个插件

{
  "plugins": ["@babel/plugin-syntax-dynamic-import"]
}

这样子的话,我们就可以项目中自由的使用import按需加载模块了。

// create.js
async function create() {
    const {
        default: _
    } = await import(/*webpackChunkName:"lodash"*/'lodash')
    let element = document.createElement('div')
    element.innerHTML = _.join(['TianTian', 'lee'], '-')
    return element
}

function demo() {
    document.addEventListener('click', function () {
        create().then(element => {
            document.body.appendChild(element)
        })
    })
}

export default demo;

触发create函数,然后加载loadsh库,最后再页面中懒加载lodash,打包是正常打包,但是有些资源,可以当你触发某些条件,再去加载,这也算是优化手段。

Chunk在Webpack里指一个代码块,代码块中引用的文件(如:js、css、图片等);可以简单的理解为一个 export/import 就是一个 module。

Chunk是Webpack打包过程中,一堆module的集合。Webpack通过引用关系逐个打包模块,这些module就形成了一个Chunk。

//9.xxxxxxxxx.js

//chunk id为 9 ,包含了Vc2m和JFUb两个module
(window.webpackJsonp = window.webpackJsonp || []).push([
  [9],
  {
    Vc2m: function(e, t, l) {},
    JFUb: function(e, t, l) {}
  }
]);

产生Chunk的三种途径

  • entry入口
  • 异步加载模块
  • 代码分割(code spliting)

11. contenthash解决浏览器缓存

  • hash,主要用于开发环境中,在构建的过程中,当你的项目有一个文件发现了改变,整个项目的hash值就会做修改(整个项目的hash值是一样的),这样子,每次更新,文件都不会让浏览器缓存文件,保证了文件的更新率,提高开发效率。

  • chunkhash跟打包的chunk有关,具体来说webpack是根据入口entry配置文件来分析其依赖项并由此来构建该entry的chunk,并生成对应的hash值。不同的chunk会有不同的hash值。在生产环境中,我们会把第三方或者公用类库进行单独打包,所以不改动公共库的代码,该chunkhash就不会变,可以合理的使用浏览器缓存了。但是这个中hash的方法其实是存在问题的,生产环境中我们会用webpack的插件,将css代码打单独提取出来打包。这时候chunkhash的方式就不够灵活,因为只要同一个chunk里面的js修改后,csschunkhash也会跟随着改动。因此我们需要contenthash

  • contenthash表示由文件内容产生的hash值,内容不同产生的contenthash值也不一样。生产环境中,通常做法是把项目中css都抽离出对应的css文件来加以引用。

对于webpack,旧版本而言,即便每次你npm run build,内容不做修改的话,contenthash值还是会有所改变,这个是因为,当你在模块之间存在相互之间的引用关系,有一个manifest文件(chunkid列表)。manifest文件是用来引导所以模块的交互,manifest文件包含了加载和处理模块的逻辑,举个例子,你的第三方库打包后的文件,我们称之为vendors,你的逻辑代码称为main,当你webpack生成一个bundle时,它同时会去维护一个manifest文件,你可以理解成每个bundle文件存在这里信息,所以每个bundle之间的manifest信息有不同,这样子我们就需要将manifest文件给提取出来。

这个时候,需要在optimization中增加一个配置👇

module.exports = {
  optimization: {
    splitChunks: {
      // ...
    },
    runtimeChunk: {// 解决的问题是老版本中内容不发生改变的话,contenthash依旧会发生改变
      name: 'manifest'
    }
  }
}

我们看看我们应该如何去配置output吧,我们先看下webpack.prod.js配置

output: {
        filename: '[name].[contenthash].js',
        chunkFilename:'[vendors].[contenthash].js',
        // publicPath: "https://cdn.example.com/assets/",
        path: path.join(__dirname, '../dist')
    }

对于的webpack.dev.js中只需要将contenthash改为hash就行,这样子开发的时候,提高开发效率。

webpackChunkName 结合 vue 的懒加载可以这样写。

{
    path: '/test',
    component: () => import(/* webpackChunkName: "test" */ '@/views/test')
  },

同一个webpackChunkName打包成同一个chunk。打包之后就生成了名为 test的 chunk 文件。

自定义 nameResolver 生成webpackChunkName 适配 webpack4 和 vue 的新实现方案:

new webpack.NamedChunksPlugin(chunk => {
  if (chunk.name) {
    return chunk.name;
  }
  return Array.from(chunk.modulesIterable, m => m.id).join("_");
});

12. shimming 全局变量

当你再使用第三方库,此时需要引入它,又或者是你有很多的第三方库或者是自己写的库,每个js文件都需要依赖它,让人很繁琐,这个时候,shimming垫片就派上用场了。使用ProvidePlugin后,能够在通过 webpack 编译的每个模块中,通过访问一个变量来获取到 package 包。

避免多个lodash包被打包多次,使用的是splitChunksPlugin

new webpack.ProvidePlugin({
			// 这里设置的就是你相应的规则了
			// 等价于在你使用lodash模块中语句
			// import _ from 'lodash'
            _: 'lodash'
})

13. 什么是 MFSU

MFSU 是一种基于 webpack5 新特性 Module Federation 的打包提速方案。其核心的思路是通过分而治之,将应用源代码的编译和应用依赖的编译分离,将变动较小的应用依赖构建为一个 Module Federation 的 remote 应用,以免去应用热更新时对依赖的编译。开启 MFSU 可以大幅减少热更新所需的时间了

juejin.cn/post/685988… juejin.cn/post/684490… juejin.cn/post/684490…

实现一个简单的Webpack

1.babel生成依赖图谱

转换代码需要利用@babel/parser生成AST抽象语法树,然后利用@babel/traverse进行AST遍历,获取通过import引入的模块,记录依赖关系dependencies,最后通过@babel/core和@babel/preset-env进行代码的转换es5的code。 juejin.cn/post/684490…

//导入包 
const fs = require('fs') 
const path = require('path') 
const parser = require('@babel/parser') 
const traverse = require('@babel/traverse').default 
const babel = require('@babel/core')

function stepOne(filename){
    //读入文件
    const content =  fs.readFileSync(filename, 'utf-8')
    const ast = parser.parse(content, {
        sourceType: 'module'//babel官方规定必须加这个参数,不然无法识别ES Module
    })
    const dependencies = {}
    //遍历AST抽象语法树
    traverse(ast, {
        //获取通过import引入的模块
        ImportDeclaration({node}){
            const dirname = path.dirname(filename)
            const newFile = './' + path.join(dirname, node.source.value)
            //保存所依赖的模块
            dependencies[node.source.value] = newFile
        }
    })
    //通过@babel/core和@babel/preset-env进行代码的转换
    const {code} = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
    })
    return{
        filename,//该文件名
        dependencies,//该文件所依赖的模块集合(键值对存储)
        code//转换后的代码
    }
}

第二步,根据dependencies路径递归执行stepOne继续生成 {
        filename,
        dependencies,
        code
}
//entry为入口文件
function stepTwo(entry){
    const entryModule = stepOne(entry)
    //这个数组是核心,虽然现在只有一个元素,往后看你就会明白
    const graphArray = [entryModule]
    for(let i = 0; i < graphArray.length; i++){
        const item = graphArray[i];
        const {dependencies} = item;//拿到文件所依赖的模块集合(键值对存储)
        for(let j in dependencies){
            graphArray.push(
                stepOne(dependencies[j])
            )//敲黑板!关键代码,目的是将入口模块及其所有相关的模块放入数组
        }
    }
    //接下来生成图谱
    const graph = {}
    graphArray.forEach(item => {
        graph[item.filename] = {
            dependencies: item.dependencies,
            code: item.code
        }
    })
    return graph
}
// 最后生成依赖图谱 console.log(stepTwo('./src/index.js'))
graph = 
{ 
   './src/index.js': 
    { 
    dependencies: { './message.js': './src\\message.js' }, 
    code: '"use strict";\n\nvar _message = _interopRequireDefault(require("./message.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_message["default"]);' 
    },
 }

2.重写require和exports传入依赖图谱的代码执行

//下面是生成代码字符串的操作
function step3(entry){
    //要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object],显然不行
    const graph = JSON.stringify(stepTwo(entry))
    return `
        (function(graph) {
            //require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
            function require(module) {
                //localRequire的本质是拿到依赖包的exports变量
                function localRequire(relativePath) {
                    return require(graph[module].dependencies[relativePath]);
                }
                var exports = {};
                //localRequire, exports传入执行的代码中,重写graph代码中的require
                (function(require, exports, code) {
                    eval(code);
                })(localRequire, exports, graph[module].code);
                return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
            }
            require('${entry}')
        })(${graph})`
}

vite原理浅析

juejin.cn/post/705029… juejin.cn/post/705547…

1.vite基本原理

  • 背景:webpack是如何实现dev服务的?
    • 解析模块依赖,浏览器是不认识commonjs语法的。即使浏览器比较新可以解析esm,但浏览器不知道要去node_modules里去找依赖所以解析cjs或esm的语法 都是webpack来帮我们处理的,把所有依赖(包括第三方库)打包成一个bundle(有很多webpack的api及代码注入),用script标签来加载,这样就可以解决兼容问题了
    • sourcemap方面,因为是打包过的 多个文件合成了1个,所以sourcemap需要额外处理,才方便定位到具体的文件的具体行数,所以webpack的文件打包出来会非常大, 此处vite会简单很多
    • 热更新方面,当项目变大后,热更新会越来越慢(因为每次都要重新打包,MFSU优化),项目的启动速度也会越来越慢。vite在热更新方面有极大的提升
    • 因为webpack诞生在es6出来之前,当时没有esm的概念,webpack只能自己实现 类似require 的功能函数,并且利用 iife + 函数作用域,确保模块之前的作用域不会冲突
  • vite基本原理是-浏览器原生支持esm

    • 不用打包(热更新快的原理),浏览器直接可以解析esm模块(import导入export导出),但是要对node_modules的依赖预构建。chorme都要61以上;
    • esm的好处是可以做tree shaking,让源代码的体积变得更小,也是js未来的主流。
    • 注意点:不能识别commonjs(require、module.exports)。
    • 浏览器已经原生支持 import动态导入(能支持() => import('xx')),chorme63以上。
    • 览器直接可以支持引入<script type="module">,chorme61以上。
  • vite热更新和dev构建为什么快

    • vite不用打包,只需处理一些源代码的路径问题(后面会讲)和预构建(后面会讲),所以几乎可以秒开 和 快速热更新并且项目热更新的速度不会随项目变大而变慢,因为vite不用重新打包,只需更新对应的文件即可。
  • vite热更新是如何处理的

    • 大致过程和webpack-dev-server的差不多,但是vite可以不用打包,只需要更新修改过的文件即可。起2个服务:客户端服务,websocket服务,
    • 监听本地文件变更,区分变更的类型,然后做相应的处理,通过ws通知客户端服务,客户端服务在去加载新的资源或刷新网页
    • 热更新时,不同的文件有不同的处理方式
      • 当你只修改了 script 里的内容时:不会刷新,Vue 组件重新加载(重新走生命周期)
      • 当你只修改了 template 里的内容时: 不会刷新,Vue 组件重新渲染(不会重新走生命周期)
      • 样式更新,样式移除时:不会刷新,直接更新样式,覆盖原来的
      • js 文件更新时:网页重刷新
  • sourcemap是如何处理的

    • 用户写的源代码,vite不会对进行打包,所以vite的sourcemap要简单很多
      • dev环境下vite解析出来的单个.vue文件 几乎和原代码一样大,而webpack因为要注入代码和sourcemap和热更新代码,所以会很大。博主做过测试,一个几kb的.vue文件,webpack dev环境去访问,至少会有70多kb的大小!
    • 第三方依赖的话,在预构建阶段,借助esbuild打包好(sourcemap有独立开源库处理)
  • vite对源文件做的转换和处理(比如让浏览器知道去node_modules里面去找依赖)

// vite处理前
import { createApp } from 'vue'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/antd.less'
import './assets/css/common2.less'
import './permission'
import { i18n } from './i18n'
import App from './App.vue' 
...

---------------------------------------------------------------------------

// vite处理后
import { createApp } from '/node_modules/.vite/vue.js?v=9c1d7fbb'
import Antd from '/node_modules/.vite/ant-design-vue.js?v=9c1d7fbb'
import '/node_modules/_ant-design-vue@3.0.0-beta.5@ant-design-vue/dist/antd.less'
import '/src/assets/css/common2.less'
import '/src/permission.js'
import { i18n } from '/src/i18n.js'
import App from '/src/App.vue' 
...

  • vite第一次构建为什么会慢?碰到大模块怎么处理?(例如有上百个模块的组件库)

    • 第三方依赖多一点的项目,应该能发现,vite的第一次构建,其实是不快的。但第一次之后,以后重启项目都会很快,1s内就可以完成。
    • 第一次构建慢的原因:vite需要做 依赖预构建
    • 第一次之后快的原因:
      • 因为有缓存,缓存是放在/node_modules/.vite文件内的。
      • 如果某些时候碰到 依赖不更新,可以在vite命令后,加上--force,会自动删除 node_modules的.vite文件,然后重新构建
  • 为什么需要做依赖预构建?

    • 碰到大模块怎么处理?(例如有上百个模块的组件库)vite需要做预解析,打包成一个bundle(减少请求,否则要请求上百个模块)
      • 预构建是使用esbuild打包的, esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。
      • 一些包将它们的 ES 模块构建作为许多单独的文件相互导入。例lodash-es,当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!,通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求了!
    • 需要兼容 CommonJS 和 UMD依赖也通常会存在多种模块化格式(例如 CommonJS 或者 UMD),需要通过esbuild 将依赖打包为 ESM(浏览器只能识别ESM模块)
  • 公共依赖部分的处理(比如a、b、c 3个文件 同时依赖一个x包)

    • 如果是esm的依赖包,dev环境中,浏览器会自动处理,公共的x包只会下载和解析一次
    • 如果是cjs或umd的包,vite会有一个预构建的过程,会先把他们转成esm包,然后同上

2.production如何打包

  • 使用rollup打包 : rollupjs.org/guide/en/打包出来的模块,最低都是es2015(也就是es6)兼容性:(其实就是动态import的兼容性)chorme需要63版本以上(官网有写

  • 为什么prd打包选择rollup,不用webpack,或esbuild

    • esbuild主要是打包js很快,但是生产包的是应用程序,除了.js文件外,还会有很多其他的资源 如.css 图片 字体 等等。暂时rollup和webpack的生态更成熟
    • esbuild主要用在dev的预构建(不太熟悉vite dev的话,可以参考这篇vite原理浅析-dev篇
    • 为什么不用webpack。rollup打包产物解析及原理(对比webpack)
    • 打出来的包体积小(tree shaking),符合js模块标准(esm)。1. 打出来的包结构清晰,几乎无额外代码注入。(不用像webpack一样有很多代码注入,用iife + function包裹模块保证互相之间作用域不干扰。script标签加载代码是没有作用域的,只能在代码内 用iife的方式实现作用域效果自己实现require,modules.exports,export,让浏览器可以兼容cjs和esm语法),
  • 为什么打包的文件比webpack小很多?(高版本webpack也有tree shaking)为什么不用webpack?

    • 对于应用程序,无需考虑浏览器兼容问题的话。开发者写esm代码 -> rollup通过入口,递归识别esm模块 -> 最终打包成一个或多个bundle.js -> 浏览器直接可以支持引入<script type="module">
    • 打包成npm包,开发者写esm代码 -> rollup通过入口,递归识别esm模块 -> (可以支持配置输出多种格式的模块,如esm、cjs、umd、amd)最终打包成一个或多个bundle.js。(开发者要写cjs也可以,需要插件@rollup/plugin-commonjs) 初步看来很明显,rollup 比较适合打包js库(react、vue等的源代码库都是rollup打包的)或 高版本无需往下兼容的浏览器应用程序,可以充分使用上esm的tree shaking,使源库体积最小(webpack导出区别巨大,注入代码较多,导出esm支持的不太好,加output配置babel配置,解释rollup为什么适合打包库)
    • rollup打包效果(非常干净,无注入代码,rollup不做额外polyfill)。webpack打包效果(有很多注入代码)。都是webpack自己的兼容代码目的是自己实现require,modules.exports,export,让浏览器可以兼容cjs和esm语法 image.png
  • rollup如何打包第三方依赖 和 懒加载模块 和 公共模块?

    • 单个chunk包,无额外配置,一般会把所有js打成一个包。打包外部依赖(第三方)也是一样的。很多第三方依赖很早就有了,所以用的是commonjs模块导出,rollup打包的话,需要安装插件@rollup/plugin-node-resolve。因为是cjs的包,所以也不存在tree shaking。插件原理是,把cjs的包,转成esm包,在打包。
    • 多个chunk包(代码分离),1.只需配置多个入口文件;2.代码分离 (动态import, import(xxx).then(module => {})自动分块和懒加载 )3.还有一种方法可以通过 output.manualChunks 选项显式告诉 Rollup 哪些模块要分割成单独的块
    • 对于公共依赖,rollup不会出现重复打包的情况!并且完全无注入代码!无需额外配置。  对比webpack的话,webpack需要配置 optimization.splitChunks (webpack4.x 以上)

npm知识

juejin.cn/post/684490…

1.npm install 机制

  • 扁平化安装模块。npm install时,首先将package.json里的依赖按照首字母(@排最前)进行排序,然后将排序后的依赖包按照广度优先遍历的算法进行安装,优先安装一级模块在node_modules目录下,当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前依赖包的node_modules下安装该模块。
  • package-lock.jsonpackage-lock.json文件精确描述了node_modules 目录下所有的包的树状依赖结构,每个包的版本号都是完全精确的。即项目目录下存在package-lock.json可以让每次安装生成的依赖目录结构保持相同。 npm为了让开发者在安全的前提下使用最新的依赖包,在package.json中通常做了锁定大版本的操作,这样在每次npm install的时候都会拉取依赖包大版本下的最新的版本。这种机制最大的一个缺点就是当有依赖包有小版本更新时,可能会出现协同开发者的依赖包不一致的问题。

2. npm 中的依赖包

dependencies - 业务依赖 通过命令npm install/i packageName -S/--save把包装在此依赖项里。如果没有指定版本,直接写一个包的名字,则安装当前npm仓库中这个包的最新版本。如果要指定版本的,可以把版本号写在包名后面,比如npm i vue@3.0.1 -S

devDependencies - 开发依赖 通过命令npm install/i -D/--save-dev把包安装成开发依赖。千万别以为只有在dependencies中的模块才会被一起打包,而在devDependencies中的不会!模块能否被打包,取决于项目里是否被引入了该模块。

peerDependencies - 同伴依赖 这种依赖的作用是提示宿主环境去安装插件在peerDependencies中所指定依赖的包。element-ui@2.6.3只是提供一套基于vueui组件库,但它要求宿主环境需要安装指定的vue版本,如果不正确会给用户打印警告提示,比如提示用户有的包必须安装或者有的包版本不对等。

bundledDependencies / bundleDependencies - 打包依赖

optionalDependencies - 可选依赖

依赖包版本号

  • npm采用了semver规范作为依赖版本管理方案。版本格式一般为:主版本号.次版本号.修订号x.y.z

  • 大版本的改动很可能是一次颠覆性的改动,也就意味着可能存在与低版本不兼容的API或者用法;小版本的改动应当兼容同一个大版本内的API和用法,因此应该让开发者无感;一般用于修复bug或者很细微的变更,也需要保持向前兼容。(尽量避开大版本号是 0 的包)

  • 如果一个模块在package.jsonpackage-lock.json中的大版本不相同,则在执行npm install时,都将根据package.json中大版本下的最新版本进行更新,并将版本号更新至package-lock.json

  • 在大版本相同的前提下,如果一个模块在package.json中的小版本要大于package-lock.json中的小版本,则在执行npm install时,会将该模块更新到大版本下的最新的版本,并将版本号更新至package-lock.json。如果小于,则被package-lock.json中的版本锁定。

  • 如果一个模块在package.json中有记录,而在package-lock.json中无记录,执行npm install后,则会在package-lock.json生成该模块的详细记录。同理,一个模块在package.json中无记录,而在package-lock.json中有记录,执行npm install后,则会在package-lock.json删除该模块的详细记录。

  • 更新到指定版本号(升级大版本号)npm install packageName@x.x.x;卸载某个模块:npm uninstall packageName;(升级小版本号)npm update packageName

  • "1.2.3" 表示精确版本号。任何其他版本号都不匹配。在一些比较重要的线上项目中,建议使用这种方式锁定版本。

  • "^1.2.3" ^ 是一个兼具更新和安全的写法,但是对于大版本号为 1 和 0 的版本还是会有不同的处理机制的。

"^1.2.3" 等价于 ">= 1.2.3 < 2.0.0"。即只要最左侧的 "1" 不变,其他都可以改变。所以 "1.2.4", "1.3.0" 都可以兼容。

"^0.2.3" 等价于 ">= 0.2.3 < 0.3.0"。因为最左侧的是 "0",那么只要第二位 "2" 不变,其他的都兼容,比如 "0.2.4""0.2.99""^0.0.3" 等价于 ">= 0.0.3 < 0.0.4"。大版本号和小版本号都为 "0" ,所以也就等价于精确的 "0.0.3"
  • "~1.2.3" 表示只兼容补丁更新的版本号,~ 是一个比^更加谨慎安全的写法。
"~1.2.3" 列出了小版本号 "2",因此只兼容第三位的修改,等价于 ">= 1.2.3 < 1.3.0""~1.2" 也列出了小版本号 "2",因此和上面一样兼容第三位的修改,等价于 ">= 1.2.0 < 1.3.0""~1" 没有列出小版本号,可以兼容第二第三位的修改,因此等价于 ">= 1.0.0 < 2.0.0"
  • "1.2.3-alpha.1"、"1.2.3-beta.1"、"1.2.3-rc.1"

带预发布关键词的版本号。先说说几个预发布关键词的定义:

alpha(α):预览版,或者叫内部测试版;一般不向外部发布,会有很多bug;一般只有测试人员使用。

beta(β):测试版,或者叫公开测试版;这个阶段的版本会一直加入新的功能;在alpha版之后推出。

rc(release candidate):最终测试版本;可能成为最终产品的候选版本,如果未出现问题则可发布成为正式版本。

以包开发者的角度来考虑这个问题:假设当前线上版本是 "1.2.3",如果我作了一些改动需要发布版本 "1.2.4",但我不想直接上线(因为使用 "~1.2.3" 或者 "^1.2.3" 的用户都会直接静默更新),这就需要使用预发布功能。因此我可能会发布 "1.2.4-alpha.1" 或者 "1.2.4-beta.1" 等等。

">1.2.4-alpha.1"表示接受 "1.2.4-alpha" 版本下所有大于 1 的预发布版本。因此 "1.2.4-alpha.7" 是符合要求的,但 "1.2.4-beta.1""1.2.5-alpha.2" 都不符合。此外如果是正式版本(不带预发布关键词),只要版本号符合要求即可,不检查预发布版本号,例如 "1.2.5", "1.3.0" 都是认可的。

"~1.2.4-alpha.1" 表示 ">=1.2.4-alpha.1 < 1.3.0"。这样 "1.2.5", "1.2.4-alpha.2" 都符合条件,而 "1.2.5-alpha.1", "1.3.0" 不符合。

"^1.2.4-alpha.1" 表示 ">=1.2.4-alpha.1 < 2.0.0"。这样 "1.2.5", "1.2.4-alpha.2", "1.3.0" 都符合条件,而 "1.2.5-alpha.1", "2.0.0" 不符合。

3.npm scripts 脚本

"scripts": {
  "serve": "vue-cli-service serve --serve1",
  ...
}

这样就可以通过npm run serve脚本代替vue-cli-service serve脚本来启动项目,而无需每次敲一遍这么冗长的脚本,找到 ./node_modules/.bin/vue-cli-service 软链接文件执行。

  • package.json中的字段 bin 表示的是一个可执行文件到指定文件源的映射。通过npm bin指令显示当前项目的bin目录的路径。
  • 例如在@vue/clipackage.json中:
"bin": {
  "vue-cli-service": "bin/vue-cli-service.js"
}

软链接(符号链接) 是一类特殊的可执行文件, 其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用。

如果全局安装@vue/cli的话, @vue/cli源文件会被安装在全局源文件安装目录(/user/local/lib/node_modules)下,而npm会在全局可执行bin文件安装目录(/usr/local/bin)下创建一个指向/usr/local/lib/node_modules/@vue/cli/bin/vue.js文件的名为vue的软链接,这样就可以直接在终端输入vue来执行相关命令。如下图所示:

如果局部安装@vue/cli的话,npm则会在本地项目./node_modules/.bin目录下创建一个指向./node_moudles/@vue/cli/bin/vue.js名为vue的软链接,这个时候需要在终端中输入./node_modules/.bin/vue来执行。

  • 每当执行npm run时,会自动新建一个Shell,这个 Shell会将当前项目的node_modules/.bin的绝对路径加入到环境变量PATH中,执行结束后,再将环境变量PATH恢复原样,或者(全局安装自动加的环境变量)命令会在PATH环境变量里包含的路径中去寻找相同名字的可执行文件

  • PATH环境变量,是告诉系统,当要求系统运行一个程序系统除了在当前目录下面寻找此程序外,还应到哪些目录下去寻找。

  • 除了第一个可执行的命令,以空格分割的任何字符串(除了一些shell的语法)都是参数,并且都能通过process.argv属性访问

  • 串行执行:npm run script1 && npm run script2;并行执行:npm run script1 & npm run script2

  • 指令钩子 执行npm run serve命令时,会依次执行npm run preservenpm run servenpm run postserve,所以可以在这两个钩子中自定义一些动作:

"scripts": {
  "preserve": "xxxxx",
  "serve": "vue-cli-service serve",
  "postserve": "xxxxxx"
}

4.npm 工程管理

  • 项目版本号管理
npm version (v)1.2.3 -m "upgrade to for reasons"

git环境中,执行npm version修改完版本号以后,还会默认执行git add->git commit->git tag操作

  • 模块 tag 管理 在发布包的时候执行npm publish默认会打上latest这个tag,实际上是执行了npm publish --tag latest。而在安装包的时候执行npm install xxx则会默认下载latest这个tag下面的最新版本,实际上是执行了npm install xxx@latest

  • 域级包管理 其中以@开头的包名,是一个域级包(scoped package),这种域级包的作用是将一些packages集中在一个命名空间下,一方面可以集中管理,一方面可以防止与别的包产生命名冲突。

由于用@声明了该包,npm会默认将此包认定为私有包,而在npm上托管私有包是需要收费的,所以为了避免发布私有包,可以在发布时添加--accss=public参数告知npm这不是一个私有包:

npm publish --access=public

5.npm 包发布

  • 如果没有npm账号,先注册个账号;
  • 进入到项目的根目录下,执行npm login,然后根据提示输入用户名、密码;
  • 执行npm publish发布,发布成功后,你的npm包就可以在npm官网上检索到了;
  • 撤回发布的版本,可以执行npm unpublish packageName@x.x.x
  • package.json文件的main字段中配置引入包的入口文件,一般配置打包后的文件路径。
{
    "main": "./lib/index.js",
}
  • 在包的根目录新建.npmignore文件,来指定包的哪几个文件夹不需要被发布。
.idea
node_modules
src
test
.eslintignore
.eslintrc
  • 发布一个支持 tree shaking 机制的包

module

该字段指向一个既符合ES Module模块规范但是又使用ES5语法的源文件。这么做的目的是为了启动tree shaking的同时,又避免代码兼容性的问题。

{
    "main": "./lib/index.js", // 指向 CommonJS 模块规范的代码入口文件
    "module": "./lib/index.es.js" // 指向 ES Module 模块规范的代码入口文件
}

如上配置要求你的包中要发布两种模块规范的版本。如果你的npm环境支持module字段,则会优先使用ES Module模块规范的入口文件,如果不支持则会使用CommonJS模块规范的入口文件。

sideEffects

该字段表示你的npm包是否有副作用。具体点就是,当该字段设为false时,表明这个包时没有副作用的,可以应用tree shaking;如果为数组时,数组的每一项表示的是有副作用的文件,这些文件将不会应用tree shaking

{
  "sideEffects": [
    "dist/*",
    "es/components/**/style/*",
    "lib/components/**/style/*",
    "*.less"
  ]
}

其实要想发布一个支持tree shaking机制的包,最主要是要构建出一个符合module字段要求的源文件,也就是一个既符合ES Module模块规范但是又采用ES5语法的源文件。rollup可以直接构建出符合ES Module模块规范的文件

6.npx与npm link

  • npx

在介绍bin字段的时候有提到过,如果局部安装@vue/cli的话,调用vue指令只能在package.jsonscripts字段里面,如果想在命令行下调用,需要输入:

## 项目根目录
`./node_modules/.bin/vue`

为了方便调用项目内部安装的包,我们可以使用npx命令代替执行:

npx vue

npx的原理很简单,就是运行的时候,会到./node_modules/.bin路径和环境变量$PATH里面,检查命令是否存在除了调用项目内部包,npx还可以从npm仓库下载包到本地全局,执行完命令以后再删除。也就是说,可以使用npx来使用你本地没有安装过但是存在npm仓库上的包

## 自动安装,使用完后删除,再次执行则会再次安装
npx @vue/cli create vue-project

## 等同于
npm i @vue/cli -g
vue create vue-project
复制代码
  • npm link

开发的是一个功能包B,然后你需要在项目C中本地调试它,我们可以执行如下操作:

# 功能包 B 中,把 B link 到全局 全局`node`模块安装路径`/usr/local/lib/node_modules/`
# 全局`node`命令安装路径`/usr/local/bin/`创建软链接
npm link

# 项目 C 中,link 功能包 B,链接到项目`C`模块安装路径`./node_modules/`
npm link B

7.package.json配置补充

juejin.cn/post/714500… juejin.cn/post/702353…

files

项目在进行 npm 发布时,可以通过 files 指定需要跟随一起发布的内容来控制 npm 包的大小,避免安装时间太长。

发布时默认会包括 package.json,license,README 和main 字段里指定的文件。忽略 node_modules,lockfile 等文件。

在此基础上,我们可以指定更多需要一起发布的内容。可以是单独的文件,整个文件夹,或者使用通配符匹配到的文件。

"files": [
  "filename.js",
  "directory/",
  "glob/*.{js,json}"
 ]

一般情况下,files 里会指定构建出来的产物以及类型文件,而 src,test 等目录下的文件不需要跟随发布。

type

在 node 支持 ES 模块后,要求 ES 模块采用 .mjs 后缀文件名。只要遇到 .mjs 文件,就认为它是 ES 模块。如果不想修改文件后缀,就可以在 package.json文件中,指定 type 字段为 module。

"type": "module"

这样所有 .js 后缀的文件,node 都会用 ES 模块解释。

# 使用 ES 模块规范
$ node index.js

如果还要使用 CommonJS 模块规范,那么需要将 CommonJS 脚本的后缀名都改成.cjs,不过两种模块规范最好不要混用,会产生异常报错。

main

main 字段用来指定加载的入口文件,在 browser 和 Node 环境中都可以使用。如果我们将项目发布为npm包,那么当使用 require 导入npm包时,返回的就是main字段所列出的文件的module.exports 属性。如果不指定该字段,默认是项目根目录下的index.js。如果没找到,就会报错。 ​

该字段的值是一个字符串:

"main": "./src/index.js",

browser

browser字段可以定义 npm 包在 browser 环境下的入口文件。如果 npm 包只在 web 端使用,并且严禁在 server 端使用,使用 browser 来定义入口文件。

"browser": "./src/index.js" 

module

module字段可以定义 npm 包的 ESM 规范的入口文件,browser 环境和 node 环境均可使用。如果 npm 包导出的是 ESM 规范的包,使用 module 来定义入口文件。

"module": "./src/index.mjs",

需要注意, .js 文件是使用 commonJS 规范的语法(require('xxx')), .mjs 是用 ESM 规范的语法(import 'xxx')。

上面三个的入口入口文件相关的配置是有差别的,特别是在不同的使用场景下。在Web环境中,如果使用loader加载ESM(ES module),那么这三个配置的加载顺序是browser→module→main,如果使用require加载CommonJS模块,则加载的顺序为main→module→browser。 ​

Webpack在进行项目构建时,有一个target选项,默认为Web,即构建Web应用。如果需要编译一些同构项目,如node项目,则只需将webpack.config.js的target选项设置为node进行构建即可。如果再Node环境中加载CommonJS模块,或者ESM,则只有main字段有效。

exports

node 在 14.13 支持在 package.json 里定义 exports 字段,拥有了条件导出的功能。

exports 字段可以配置不同环境对应的模块入口文件,并且当它存在时,它的优先级最高。

比如使用 require 和 import 字段根据模块规范分别定义入口:

"exports": {
  "require": "./index.js",
  "import": "./index.mjs"
 }
}

这样的配置在使用 import 'xxx' 和 require('xxx') 时会从不同的入口引入文件,exports 也支持使用 browser 和 node 字段定义 browser 和 Node 环境中的入口。

上方的写法其实等同于:

"exports": {
  ".": {
    "require": "./index.js",
    "import": "./index.mjs"
  }
 }
}

为什么要加一个层级,把 require 和 import 放在 "." 下面呢?

因为 exports 除了支持配置包的默认导出,还支持配置包的子路径。

比如一些第三方 UI 包需要引入对应的样式文件才能正常使用。

import `packageA/dist/css/index.css`;

我们可以使用 exports 来封装文件路径:

"exports": {
  "./style": "./dist/css/index.css'
},

用户引入时只需:

import `packageA/style`;

除了对导出的文件路径进行封装,exports 还限制了使用者不可以访问未在 "exports" 中定义的任何其他路径。

比如发布的 dist 文件里有一些内部模块 dist/internal/module ,被用户单独引入使用的话可能会导致主模块不可用。为了限制外部的使用,我们可以不在 exports 定义这些模块的路径,这样外部引入 packageA/dist/internal/module 模块的话就会报错。

结合上面入口文件配置的知识,再来看看下方 vite 官网推荐的第三方库入口文件的定义,就很容易理解了。

图片

license

license 字段用于指定软件的开源协议,开源协议表述了其他人获得代码后拥有的权利,可以对代码进行何种操作,何种操作又是被禁止的。常见的协议如下:

  • MIT :只要用户在项目副本中包含了版权声明和许可声明,他们就可以拿你的代码做任何想做的事情,你也无需承担任何责任。
  • Apache :类似于 MIT ,同时还包含了贡献者向用户提供专利授权相关的条款。
  • GPL :修改项目代码的用户再次分发源码或二进制代码时,必须公布他的相关修改。

可以这样来声明该字段:

"license": "MIT"

typings

typings字段用来指定TypeScript类型定义的入口文件:

"typings": "types/index.d.ts",

该字段的作用和main配置相同。

eslintConfig

eslint的配置可以写在单独的配置文件.eslintrc.json 中,也可以写在package.json文件的eslintConfig配置项中。

"eslintConfig": {
      "root": true,
      "env": {
        "node": true
      },
      "extends": [
        "plugin:vue/essential",
        "eslint:recommended"
      ],
      "rules": {},
      "parserOptions": {
        "parser": "babel-eslint"
     },
}

babel

babel用来指定Babel的编译配置,代码如下:

"babel": {
	"presets": ["@babel/preset-env"],
	"plugins": [...]
}

unpkg

使用该字段可以让 npm 上所有的文件都开启 cdn 服务,该CND服务由unpkg提供:

"unpkg": "dist/vue.global.js"

我们想通过 CDN 的方式使用链接引入 vue 时,访问 https://unpkg.com/vue 会重定向到 https://unpkg.com/vue@3.2.37/dist/vue.global.js,其中 3.2.27 是 Vue 的最新版本。

lint-staged

lint-staged是一个在Git暂存文件上运行linters的工具,配置后每次修改一个文件即可给所有文件执行一次lint检查,通常配合gitHooks一起使用。

"lint-staged": {
	"*.js": [
  	  "eslint --fix",
          "git add"
         ]
 }
复制代码

使用lint-staged时,每次提交代码只会检查当前改动的文件。

gitHooks

gitHooks用来定义一个钩子,在提交(commit)之前执行ESlint检查。在执行lint命令后,会自动修复暂存区的文件。修复之后的文件并不会存储在暂存区,所以需要用git add命令将修复后的文件重新加入暂存区。在执行pre-commit命令之后,如果没有错误,就会执行git commit命令:

"gitHooks": {
    "pre-commit": "lint-staged"
}

这里就是配合上面的lint-staged来进行代码的检查操作。

ESlint

juejin.cn/post/712223…

  • 引入和配置ESLint
npm init @eslint/config
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · vue
✔ Does your project use TypeScript? · Yes
✔ Where does your code run? · browser
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · airbnb
✔ What format do you want your config file to be in? · JavaScript
  • ESLint检查代码
"scripts": {
  // 检查代码
  "lint": "eslint --ext .vue,.ts src/"
  // 自动修复
  "lint:fix": "eslint --ext .vue,.ts src/ --fix"
  // 在 npm install 之后自动执行,生成`.husky`目录。 
  "prepare": "husky install"
},
  • 配置husky门禁,增加pre-commit钩子
    • 安装npm i lint-staged husky -D,增加 prepare 脚本命令
    • 执行npm run prepare脚本,根目录自动生成.husky目录。
    • 执行npx husky add .husky/pre-commit "npx lint-staged",会在.husky目录自动生成pre-commit文件钩子。
    • 增加 lint-staged 配置
    •       "lint-staged": {
              "src/**/*.{vue,ts}": "eslint --fix"
            },
      
    • 对本次提交修改的代码进行代码检查,并自动修复,不能修复的错误会提示出来,只有所有 ESLint 错误都修复了才能git commit成功

git

juejin.cn/post/713304…

branch管理

  • git checkout -b feat/sass-v1 origin/feat/sass-v1 // 克隆远端分支feat/sass-v1到本地
  • git checkout -b feat/saas-0817 // 从当前分支新建一个分支feat/saas-0817
  • git merge [branchName] 将branchName合并到当前分支
  • git merge [branchName] --squash 将branchName合并到当前分支,并将branchName上的所有提交合并成一次提交
  • git commit --amend 修改上次的提交信息,push后不会增加新的commit记录,但是会修改本次的commithash(也可以理解为删掉了最新的一次commit,重新又提交了一次)
  • git branch -D [branchName] 删除本地分支
  • git push origin -D [branchName] 删除远端分支

rebase branch

  • git pull --rebase origin [branchName] = git fetch + git rebase
// 假设当前分支dev, commit 为 a b c d e
// 假设master分支, commit 为 a b f g h
git pull --rebase origin master
// 当前分支dev commit 变为 a b c d e f g h
  • git rebase master
// 假设当前分支dev, commit 为 a b c d e
// 假设master分支, commit 为 a b f g h
git rebase origin/master
// 当前分支dev commit 变为 a b f g h c d e

stash贮藏代码

  • 场景:当你的功能还没开发完不能commit但是现在需要rebase下master,缓存区的代码该咋办?当你写了几行代码,但是现在需要切到其他分支去改bug,缓存区的代码该咋办? 用git stash就好啦
  • git stash 贮藏代码
  • git stash pop 恢复到工作区和缓存区,会移除stashid
  • git stash list 查看当前贮藏区

reset回退

  • git log 查看提交日志
  • git reset 将所有暂存区回退到工作区
  • git checkout . 丢弃工作区所有的更改
  • git reset --hard [commit hash] 将从commithash(不包括此hash)之后的丢弃
  • git reset --hard 将暂存区、工作区所有内容丢弃
  • git reset --soft [commit hash] 将从commithash(不包括此hash)之后的提交回退到暂存区
  • git reset --soft HEAD~4 回退最近4次提交到暂存区

cherry-pick 复制提交

  • 场景:当你在merge或者rebase的时候发现冲突太多了,想哭的时候,可以用原分支check目标分支处理,然后再cherry-pick当前分支的每个提交,这样冲突就会少很多。或者另一分支上有些代码还没有merge到master,但是你当前分支又非要用的时候,就可以cherry-pick过来一份。
  • git cherry-pick [commit hash] 将其他分支上已提交的commit在当前分支再提交一次,产生新的commithash

revert

  • git revert [commit hash] 非merge的commit

  • git revert -m [1|2] [commit hash] merge类型的commit

    • 通过git show [commit hash]查看

image.png

第三行第一个hash为编号1,第二个hash为编号2,
以哪个父hash为主线则保留哪个,删除另一个

image.png 转存失败,建议直接上传图片文件

git revert -m 1 bd86846 则回滚bd86846的提交,
且以ba25a9d master分支为主线保留,回滚掉1c7036f 所在分支提交

rebase -i

  • 场景:使用merge导致git提交线乱七八糟,提交日志过多非常难看。自从使用了rebase提交线变得无比丝滑,使用rebase -i合并每个需求的所有提交成1个,使日志变得清晰