Published on

前端工程化

CommonJS 和 ES Module

CommonJS

CommonJS:社区标准。是在JS运行时执行的,同时它的执行也是同步的。

// require 伪代码
function require(path){
    // 判断路径对应的模块是否有缓存
    if(cache[path]){
        return cache[path]
    }

    // 通过函数作用域防止变量污染
    function _run(exports,require, module,_filename,_dirname){
        // 这里放的是路径对应的模块的代码
        // 第一个参数:模块内要导出的值
        // 第二个参数:require函数本身,模块也可以用其他模块
        // 第三个参数:module,下面的变量module
        // 第四个参数:模块路径
        // 第五个参数:模块所在目录
    }

    const module = {
        exports: {}
    }

    _run().call(
        module.exports,
        module.exports,
        require,
        module,
        模块路径,
        模块所在目录
    )

    return module.exports
}

模块函数的 this module.exports exports 都是一个东西

ES Module

同时支持静态依赖动态依赖

// 静态
import a from './a' // 文件顶部

// 动态 相比于require的同步加载,ESM是异步加载
import('./a').then() // vue-router 的路由懒加载

ES6 Module:ECMAScript 标准。编译时(也可以运行时加载:用 import())。更推荐这个,因为编译时,方便优化。

⚠️注意

import {const,increase} from './a'

// 上述一个是基本类型,一个是函数

// 它们都是对模块内地址的传递

npx

两个作用:运行本地(当前项目下的node_modules)的包、临时运行命令

ESLint

ESLint:代码检查工具

  1. 将包安装在本地包管理器
  2. 项目根目录书写配置文件和规则
  3. 执行命令去执行校验

更加方便的方法:安装ESLint插件,它会根据项目根目录中的ESLint配置文件在书写代码的时候就开始提示错误

可以继承 airbnb 的配置

浏览器已经有模块化,为什么还需要工程化?

因为浏览器在用到模块文件后,都会去请求一次。项目工程大了以后,需要请求很多的JS文件,浪费请求资源,更多的请求降低了页面的访问效率;同时也不支持CommonJS模块化(有的第三方模块是用这个来实现的)

webpack编译过程

初始化 -> 编译 ->

初始化:根据命令、配置文件、默认配置生成最终的默认配置

vite对静态资源的打包

vite在打包时对静态文件的处理方式只有在以下情况下:

  1. 标签静态链接路径
  2. css静态链接路径
  3. 动态导入

场景: 在js处理一些图片路径的时候,我会先静态import图片,拿到图片后通过JS做编辑,这样的坏处就是图片多需要一直导入。

如何清理源码中没有被应用的代码JS、TS、CSS

  1. ESLink、Terser 单文件检测 针对JS
  2. Tree Shaking 整个项目的检测 针对JS
  3. PurgeCSS 发现未被应用的类
  4. 自定义

webpack打包结果分析

(function(modules){

    function require(moduleId){
        const func = modules[moduleId]
        const module  = {
            export:{}
        }
        func(module,module.export,require)

        // 模块执行结果
        const result = module.export
        return result
    }
    require('./src/a.js')
})({
    //该对象保存所有模块的代码
    "./src/a.js": function(module,export,require){
        console.log('module a')
        module.exports = 'a'
    },
    "./src/index.js": function(module,export,require){
        console.log('index module')
        var a = require('./src/a.js')
        console.log(a)
    }
})

定位错误

source map 源码地图

编译过程

可以分为以下三个步骤:初始化、编译、输出

初始化

CLI参数、配置文件、默认配置进行融合,形成一个最终的配置对象

编译

最重要的是得到模块列表

  1. 创建chunk,他表示通过某个入口查找的所有依赖的统称
  2. 构建依赖模块也就是模块列表,构建方式:每次先检查模块列表中是否有,没有的话需要通过AST分析,从而 拿到该模块的依赖模块数组,require替换为webpack_require(文件本身是没有被替换的,在内存中完成)。完成代码替换后,放入到模块列表中。之后再去依赖模块的数组挨个去递归完成上述操作。
  3. 产生chunk assets,生成文件名和对应的文件内容
  4. 将所有的 chunk assets合并,并进行哈希

输出

分别将每一个chunk,写入到文件中

入口和出口

入口配置:是对初始创建chunk的配置,可以有多个入口,但是出口也要有对应的多个出口。此外,一个chunk的入口文件可以是多个,由一个数组来控制。 出口配置:个数要与出口的个数对应,同时可以配置文件的位置和名字。同时针对浏览器的缓存策略可以通过添加hash来避免。

module.exports = {
    mode: 'development' / 'production',
    entry: {
        main: './src/main.js',
        a:['./src/a.js','./src/index.js']
    },
    output:{
        path: path.resolve(__dirname,'target') // 配置资源的存放位置
        fileName: '[name]_[hash:8].js',
        devtool:'source-map'
    }
}

最佳实践:

情况一

一个页面对应一个JS文件,功能相差巨大,公共部分代码很少的情况。

出现的问题:打包完后使用到公共部分的模块,会有代码重叠,智慧影响到网络传输,也不是什么大问题

|- src
    |- pageA
        |- index.js  页面A的启动模块
    |- pageB
        |- index.js  页面B的启动模块
    |- pageC
        |- index.js  页面C的主功能模块
        |- fn.js     页面C的附加功能
    |- common        公共代码
module.exports = {
    mode: 'development' / 'production',
    entry: {
        a: './src/pageA/index.js',
        b: './src/pageB/index.js',
        a:['./src/pageC/index.js','./src/pageC/fn.js']
    },
    output:{
        path: path.resolve(__dirname,'target') // 配置资源的存放位置
        fileName: '[name]_[hash:8].js',
        devtool:'source-map'
    }
}

情况二

单页应用

module.export = {
    entry: './src/index.js',
    output: {
        fileName: 'index.[hash:5].js'
    }
}

loader

本质上是一个函数,用来处理源代码字符串。 他的执行顺序是在AST抽象语法树分析前面。

配置对象

module.export = {
    module: {
        rules:[
            {
                test: /index\.js$/ // 正则表达式,用来匹配模块的路径
                ues:['./loader/loader1.js','./loader/loader2.js']
            },
                        {
                test: /\.js$/ // 正则表达式,用来匹配模块的路径
                ues:['./loader/loader3.js','./loader/loader4.js']
            }
        ]
    }
}

情景:每个loader有自己的打印内容,问:以下情景搭配上述配置,输出的顺序是什么?

// index.js

require('./a')

答:loader的使用是从后往前,当收集index的依赖的时候,loader的执行顺序是4 3 2 1。当index的代码替换完成放入模块列表后,开始a模块的处理流程,loader的执行顺序是4 3 综上输出的顺序是4 3 2 1 4 3

场景练习

练习1:我有一个css文件,我通过模块导入把它导入到js代码中,我的loader该如何编写来实现对css效果的应用?

实现思路:loader修改的是源代码,可以匹配到css模块,然后返回一段JS代码,这段代码把css应用起来

// webpack配置
module.export = {
    module:{
        rules:[
            {
                test:'.css' // 正则匹配css模块
                use: 'css.loader.js'
            }
        ]
    }
}

// loader
module.exports = function (sourceCode){

    return`
        var element = document.createElement('style')
        element.innerHTML = \`${sourceCode}\`
        document.head.appendChild(style)
    `
}

练习2:我有一个图片模块,我把它导入JS去引用,loader该如何编写?

// webpack配置
module.export = {
    module:{
        rules:[
            {
                test:'img' // 正则匹配css模块
                use: 'img.loader.js'
            }
        ]
    }
}

// img.loader.js
loader.raw = true // 该loader要处理的是原始数据

function loader(sourceCode){
    // 拿到源二进制数据 buffer.toString => 转 Base64
    return `
        module.exports = \`${getBase64(sourceCode)}`\
    `
}

function getBase64(buffer){
    return `data: image/png;base64," + buffer.toString("base64")`
}

module.export loader

plugin

本质是一个带有apply方法的对象,在初始化阶段就会执行。该方法中会传入一个compiler对象(webPack运行期间只有一个)

然后可以在compiler或者compilation(这个是在编译和输出阶段创建的对象,每次热更新就会创建一个)注册hook完成插件的编写

细节配置

module.export = {
    output:{
        library: 'myLibrary', // 这个是打包完后的自执行函数执行完后返回的结果的变量,通常用于库的打包(jquery)
        libraryTarget: 'umd' //  这个是导出库的类型,有umd、commonjs、commonjs2、amd、window、global、jsonp
    },
    target: 'web', // 这个是打包后的代码运行的环境,有web、node、electron、webworker、async-node、node-webkit、atom
    module:{
        rules:[] // 配置loader
        noParse: [] // 配置不解析的模块,将源码直接放入到打包结果中
    },
    resolve:{
        modules: [] // 配置模块的查找路径(node_modules),从左到右依次查找
        extensions: ['.js', '.json', '.jsx', '.ts', '.tsx'] // 配置模块的扩展名就是在导入时可以省略最后的文件类型,从左到右依次查找
        alias: {
            '@': path.resolve(__dirname, 'src')
        } // 配置模块的别名,用于模块的导入
    },
    externals: {  // 场景:开发阶段我下载好了包,但是生产环境我想通过CDN来使用三方库,业务代码通过import $ from 'jquery' 来使用。那么我就可以通过externals配置来告诉webpack不要把jquery打到bundle,而是运行时直接去拿全局的 $ 和 _(这时候CDN已经拿到了,有点像JSONP的解决方案)。
        jquery: '$',
        lodash: '_'
    }

}

CSS工程化

解决类名冲突

  1. BEM 规定类名的约定
  2. css-in-js用对象来表示css属性
  3. css Module

预编译器

在开发css的时候,由于语法本身的原因,比如没有可利用的重复代码片段、变量等等,在大型项目中开发会非常的困难。 所以我们希望可以用一种语言以更优雅的表达方式来书写样式,所以社区中诞生了类似lesssass这些语言,这些语言完全支持css,但同时也给了用户更加舒适的编程环境。 但是浏览器最终看不懂这些代码,所以我们需要编译器把这些高级的css语法转换为css。这就是预编译器。

单独使用预编译器,需要下载。如果使用webpack只需要下载对应的loader即可

PostCss

如今,css的工程化还没有一个标准的处理方式,postcss是希望可以统一大部分的css处理方式。

它和预编译器类似,也是一个编译器,不过它需要配置插件来完成对css处理。

单独使用,也是需要下载,通过它的脚手架来使用。如果使用webpack,也是下载对应的loader即可(注意postcss对应的配置文件)。

性能优化

工程化方面的性能优化主要包括:构建性能、传输性能、运行性能

构建性能:说的是开发阶段的打包性能,性能过慢会严重影响开发效率。生产阶段的构建次数相对来说很少,不是重点

传输性能:包括总的传输量(减少重复代码)、文件数量(请求次数)、浏览器缓存

运行性能:主要是代码层面的,打包影响的不多

构建性能优化

  1. 减少模块解析:将没有依赖的模块或者已经打包好的模块(三方库),跳过抽象语法树分析,如果有loader,loader之后的代码就是最终结果,如果没有loader,源代码就是最终的结果。

  2. 优化loader性能:

    • 减少loader模块
    • 增加loader-catch
    • 开启多线程

传输新能优化

  1. 分包:将一个整体的代码,分到不同的文件中

好处:降低打包体积,同时对于公共库的拆分可以利用浏览器的缓存机制

时机:多处使用的公共模块,还有大型的第三方库(最好是没有其他依赖)

手动分包

流程:先打包好公共模块,打包环境用开发环境,打包模式用库的打包方式,结果要用全局变量导出来; 建立一个存放资源目录的文件夹,用于后续正常项目打包;

用到的插件:DllPlugin DllReferencePlugin

自动分包

代码压缩

会更换更少的字符的变量名,以及移除注释、空格等无用代码

terser-webpack-plugin

对于纯函数和副作用函数,代码压缩对于没有导入的纯函数会直接删除掉,但是副作用函数不会。

可以在 package.json 中添加 sideEffects 属性来告诉 webpack 哪些文件是有副作用的,从而避免这些文件被删除。

tree Shaking

作用:对于导入的模块,删除没有使用的其他函数代码

场景一:对于自己在写代码使用导入和导出,最好避免默认导出,而是使用命名导出,这样就可以在tree Shaking的时候删除没有使用的函数代码。

场景二:对于第三方库,去找ES版本;CommonJS版本的话可以使用同步导入,会加大Tree Shaking的难度。

作用域分析:对于一些 dead code ,webpack的tree Shaking还是识别不到,需要插件深度分析AST去解决。

比如:

myMath.js
import {chunk} from 'lodash'

export function add(a,b){
    return a + b
}
export function subtract(a,b){
    return a - b
}
export function myChunk(a,b){
    return chunk(a,b)
}
index.js
import {myChunk} from './myMath'

console.log(add(2,2))

以上代码并没有使用myChunk函数,但是打包后还是会有Lodash的代码。

要解决这个问题就需要加入插件去分析 webpack-deep-scope-plugin

副作用: tree shaking的第一大原则就是:在保证代码正确运行的情况下,做代码的删减。所以对于一些确定不了是否是副作用的模块,就会把他当作副作用模块不做处理。

我们可以通过在package.json中添加sideEffects属性来告诉webpack哪些文件是有副作用的,从而避免这些文件被删除。

package.json
{
    "sideEffects": false // boolean的话会太暴力,推荐使用数组
}

css tree Shaking

webpack的tree shaking是基于ES Module的,对css是无能为力的,所以需要第三方插件。但是如果使用的是css Module的话,也不行。

懒加载

有些代码在初始化阶段是没有执行的,比如:

index.js
import { chunk } from 'lodash'

const buttonDom = document.getElementById('button')
buttonDom.addEventListener('click', () => {
    console.log(chunk([1,2,3,4,5], 2))
})

如果该回调用户首次点击的频率不高,我们可以将这个包拆分出来,使用异步加载。减少首次传输的体积。

index.js
const buttonDom = document.getElementById('button')

buttonDom.addEventListener('click', () => {
    const { chunk } = await import(/* webpackChunkName: "lodash" */ 'lodash')
    console.log(chunk([1,2,3,4,5], 2))
})

上述代码就是模块懒加载的例子,打包会从拆出来一个lodash的包,然后异步加载。

高阶玩法:这样使用异步加载的策略,由于是动态导入,所以webpack无法tree Shaking的难度。

想要解决这个问题,可以包一层:

index.js
const buttonDom = document.getElementById('button')
buttonDom.addEventListener('click', () => {
    const { chunk } = await import(/* webpackChunkName: "lodash" */ './tools')
    console.log(chunk([1,2,3,4,5], 2))
})
tools.js
export { chunk } from 'lodash'

其他优化

代码风格优化 ESLint

bundle analyzer