Подробный принцип упаковки webpack, реализация загрузчика и плагина

Webpack

Основное содержание, обсуждаемое в этой статье, выглядит следующим образом:

  1. webpackОсновы упаковки
  2. Как реализовать самостоятельноloaderа такжеplugin

Примечание. В этой статье используетсяwebpackверсияv4.43.0, webpack-cliверсияv3.3.11,nodeверсияv12.14.1,npmВерсияv6.13.4(Если хочешьyarnтакже возможно), для демонстрационных целейchromeверсия браузера81.0.4044.129(正式版本) (64 位)

1. Основные принципы упаковки webpack

Одной из основных функций веб-пакета является упаковка модульного кода, который мы пишем, для генерации кода, который можно запустить в браузере Здесь мы также начинаем с простого и шаг за шагом изучаем принцип упаковки веб-пакета.

1.1 Простое требование

Сначала мы создаем пустой проект, используяnpm init -yБыстро инициализировать одинpackage.json, затем установитеwebpack webpack-cli

Далее создайте в корневом каталогеsrcсодержание,srcСоздано в каталогеindex.js,add.js,minus.js, созданный в корневом каталогеindex.htmlindex.htmlпредставлятьindex.js,существуетindex.jsпредставлятьadd.js,minus.js,

Структура каталогов следующая:

Содержимое файла следующее:

// add.js
export default (a, b) => {
    return a + b
}
// minus.js
export const minus = (a, b) => {
    return a - b
}
// index.js
import add from './add.js'
import { minus } from './minus.js'

const sum = add(1, 2)
const division = minus(2, 1)
console.log('sum>>>>>', sum)
console.log('division>>>>>', division)
<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>demo</title>
</head>
<body>
    <script src="./src/index.js"></script>
</body>
</html>

прямо вindex.htmlпредставлятьindex.jsкод, который явно не работает в браузере, вы увидите такие ошибки

Uncaught SyntaxError: Cannot use import statement outside a module

да мы не можемscriptпредставилjsфайл, использоватьes6модульный синтаксис

1.2 Реализовать основную функцию упаковки webpack

Сначала мы создаем файл bundle.js в корневом каталоге проекта, который используется для модуляции модуля, который мы только что написали.jsфайлы кода для упаковки

Давайте сначала посмотрим на описание процесса упаковки на официальном сайте webpack:

it internally builds a dependency graph which maps every module your project needs and generates one or more bundles(webpack会在内部构建一个 依赖图(dependency graph),此依赖图会映射项目所需的每个模块,并生成一个或多个 bundle)

Перед официальным стартом совместите вышеперечисленноеwebpackАнализируется описание официального сайта, и основной процесс нашей работы по упаковке выглядит следующим образом:

  1. Во-первых, нам нужно прочитать содержимое входного файла (то есть содержимое index.js)
  2. Во-вторых, проанализируйте входной файл, рекурсивно прочитайте содержимое файлов, от которых зависит модуль, и создайте граф зависимостей.
  3. Наконец, в соответствии с графом зависимостей, сгенерируйте окончательный код, который сможет запустить браузер.

1. Обработать один модуль (в качестве примера возьмем запись)

1.1 Получить содержимое модуля

Поскольку мы хотим прочитать содержимое файла, нам нужно использоватьnode.jsосновной модульfs, давайте сначала посмотрим на то, что мы читаем:

// bundle.js
const fs = require('fs')
const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    console.log(body)
}
getModuleInfo('./src/index.js')

Мы определяем методgetModuleInfo, в этом методе мы читаем содержимое файла, распечатываем его, и результат вывода выглядит следующим образом:

Мы видим, что входной файлindex.jsВсе содержимое вывода находится в виде строки, и мы можем использовать регулярные выражения или некоторые другие методы для извлеченияimportтак же какexportСодержимое входного файла и соответствующее имя файла пути используются для анализа содержимого входного файла и получения полезной информации. но еслиimportа такжеexportКонтента много, это будет очень хлопотный процесс, тут воспользуемся помощьюbabelПредоставляет функцию для завершения анализа входного файла

1.2 Содержание модуля анализа

мы устанавливаем@babel/parser, номер версии, установленной во время демонстрации,^7.9.6

Функция этого модуля babel заключается в преобразовании содержимого кода нашего файла js в форму объектов js.Эта форма объектов js называется抽象语法树(Abstract Syntax Tree, 以下简称AST)

// bundle.js
const fs = require('fs')
const parser = require('@babel/parser')
const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    const ast = parser.parse(body, {
        // 表示我们要解析的是es6模块
       sourceType: 'module' 
    })
    console.log(ast)
    console.log(ast.program.body)
}
getModuleInfo('./src/index.js')

использовать@babel/parserизparseметод, называемый преобразованием входного файлаAST, мы распечатываемast, обратите внимание, что содержимое файла находится вast.program.body, как показано на следующем рисунке:

Содержимое входного файла помещается в массив из шестиNodeузлы, мы можем видеть, что каждый узел имеетtypeсвойств, где первые два изtypeсобственностьImportDeclaration, что соответствует двум записям в нашем файле записейimportзаявление, и каждыйtypeсобственностьImportDeclarationузел, которыйsource.valueСвойства — это относительные пути, по которым этот модуль импортируется, так что мы получаем важную информацию в файле записи, полезную для упаковки.

Далее нам нужно обработать полученный ast и вернуть структурированные данные для последующего использования.

1.3 Содержимое модуля обработки

правильноast.program.bodyСбор и обработка части данных по сути является обходом массива и обработкой данных в цикле.Здесь также вводится модуль babel.@babel/traverseдля завершения этой работы.

Установить@babel/traverse, номер версии, установленной во время демонстрации,^7.9.6

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default

const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    const ast = parser.parse(body, {
       sourceType: 'module' 
    })
    const deps = {}
    traverse(ast, {
        ImportDeclaration({ node }) {
            const dirname = path.dirname(file);
            const absPath = './' + path.join(dirname, node.source.value)
            deps[node.source.value] = absPath
        }
    })
    console.log(deps)
}
getModuleInfo('./src/index.js')

создать объектdeps, используемый для сбора зависимостей, введенных самим модулем, используйтеtraverseтраверсast, нам просто нужноImportDeclarationузел для обработки, обратите внимание, что обработка, которую мы выполняем, фактически преобразует относительный путь в абсолютный путь, здесь я используюMacсистема, если онаwindowsсистемы, обратите внимание на разницу между косыми чертами

После получения зависимостей нам нужноastСделайте преобразование синтаксиса, поместитеes6Синтаксис преобразуется вes5синтаксис, использованиеbabelосновной модуль@babel/coreтак же как@babel/preset-envЗаканчивать

Установить@babel/core @babel/preset-env, номера версий, установленных во время демонстрации, оба^7.9.6

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

const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    const ast = parser.parse(body, {
       sourceType: 'module' 
    })
    const deps = {}
    traverse(ast, {
        ImportDeclaration({ node }) {
            const dirname = path.dirname(file);
            const absPath = './' + path.join(dirname, node.source.value)
            deps[node.source.value] = absPath
        }
    })
    const { code } = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
    })
    const moduleInfo = { file, deps, code }
    console.log(moduleInfo)
    return moduleInfo
}
getModuleInfo('./src/index.js')

Как показано на рисунке ниже, мы окончательно преобразуем код модуля в информацию в виде объекта, который содержит абсолютный путь к файлу, информацию о модуле, от которого зависит файл, и внутренний процесс модуль.babelпреобразованный код

2. Рекурсивно получить информацию обо всех модулях

Этот процесс заключается в получении依赖图(dependency graph)Этот процесс начинается с входного модуля и вызывает каждый модуль и его зависимые модули.getModuleInfoМетод анализируется, и, наконец, возвращается объект, содержащий всю информацию о модуле.

const parseModules = file => {
    // 定义依赖图
    const depsGraph = {}
    // 首先获取入口的信息
    const entry = getModuleInfo(file)
    const temp = [entry]
    for (let i = 0; i < temp.length; i++) {
        const item = temp[i]
        const deps = item.deps
        if (deps) {
            // 遍历模块的依赖,递归获取模块信息
            for (const key in deps) {
                if (deps.hasOwnProperty(key)) {
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    temp.forEach(moduleInfo => {
        depsGraph[moduleInfo.file] = {
            deps: moduleInfo.deps,
            code: moduleInfo.code
        }
    })
    console.log(depsGraph)
    return depsGraph
}
parseModules('./src/index.js')

Полученный объект depsGraph выглядит следующим образом:

Данные модульного анализа, которые мы в итоге получили, показаны на рисунке выше.Далее нам нужно создать код, который запустит окончательный браузер, на основе полученных здесь данных модульного анализа.

3. Сгенерируйте окончательный код

Прежде чем мы его реализуем, обратите внимание на граф зависимостей, полученный в предыдущем разделе.Вы можете видеть, что окончательный код содержит синтаксис, такой как экспорт и требование.Поэтому, когда мы генерируем окончательный код, мы должны реализовать и реализовать определенные экспорты и требования. иметь дело с

Сначала мы вызываем упомянутый ранее метод parseModules, чтобы получить объект графа зависимостей всего приложения:

const bundle = file => {
    const depsGraph = JSON.stringify(parseModules(file))
}

Далее мы должны преобразовать содержимое объекта графа зависимостей в исполняемый код и вывести его в виде строки. Помещаем весь код в самовыполняющуюся функцию, параметром является объект графа зависимостей

const bundle = file => {
    const depsGraph = JSON.stringify(parseModules(file))
    return `(function(graph){
        function require(file) {
            var exports = {};
            return exports
        }
        require('${file}')
    })(${depsGraph})`
}

Следующее содержимое на самом деле очень простое, то есть мы получаем информацию о коде входного файла, просто выполняем его, используем функцию eval для выполнения и изначально пишем код следующим образом:

const bundle = file => {
    const depsGraph = JSON.stringify(parseModules(file))
    return `(function(graph){
        function require(file) {
            var exports = {};
            (function(code){
                eval(code)
            })(graph[file].code)
            return exports
        }
        require('${file}')
    })(${depsGraph})`
}

Выше написанное проблематично, нам нужно сделать абсолютное преобразование пути к файлу, иначеgraph[file].codeОн недоступен, определите метод adsRequire для преобразования относительного пути в абсолютный путь

const bundle = file => {
    const depsGraph = JSON.stringify(parseModules(file))
    return `(function(graph){
        function require(file) {
            var exports = {};
            function absRequire(relPath){
                return require(graph[file].deps[relPath])
            }
            (function(require, exports, code){
                eval(code)
            })(absRequire, exports, graph[file].code)
            return exports
        }
        require('${file}')
    })(${depsGraph})`
}

Далее нам просто нужно выполнить метод пакета и записать сгенерированный контент в файл JavaScript.

const content = bundle('./src/index.js')
// 写入到dist/bundle.js
fs.mkdirSync('./dist')
fs.writeFileSync('./dist/bundle.js', content)

Наконец, мы вводим это в index.html./dist/bundle.jsфайл, мы видим, что консоль правильно выводит результат, который мы хотим

4. Полный код bundle.js

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

const getModuleInfo = file => {
    const body = fs.readFileSync(file, 'utf-8')
    console.log(body)
    const ast = parser.parse(body, {
       sourceType: 'module' 
    })
    // console.log(ast.program.body)
    const deps = {}
    traverse(ast, {
        ImportDeclaration({ node }) {
            const dirname = path.dirname(file);
            const absPath = './' + path.join(dirname, node.source.value)
            deps[node.source.value] = absPath
        }
    })
    const { code } = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"]
    })
    const moduleInfo = { file, deps, code }
    return moduleInfo
}

const parseModules = file => {
    // 定义依赖图
    const depsGraph = {}
    // 首先获取入口的信息
    const entry = getModuleInfo(file)
    const temp = [entry]
    for (let i = 0; i < temp.length; i++) {
        const item = temp[i]
        const deps = item.deps
        if (deps) {
            // 遍历模块的依赖,递归获取模块信息
            for (const key in deps) {
                if (deps.hasOwnProperty(key)) {
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    temp.forEach(moduleInfo => {
        depsGraph[moduleInfo.file] = {
            deps: moduleInfo.deps,
            code: moduleInfo.code
        }
    })
    // console.log(depsGraph)
    return depsGraph
}


// 生成最终可以在浏览器运行的代码
const bundle = file => {
    const depsGraph = JSON.stringify(parseModules(file))
    return `(function(graph){
        function require(file) {
            var exports = {};
            function absRequire(relPath){
                return require(graph[file].deps[relPath])
            }
            (function(require, exports, code){
                eval(code)
            })(absRequire, exports, graph[file].code)
            return exports
        }
        require('${file}')
    })(${depsGraph})`
}


const build = file => {
    const content = bundle(file)
    // 写入到dist/bundle.js
    fs.mkdirSync('./dist')
    fs.writeFileSync('./dist/bundle.js', content)
}

build('./src/index.js')

2. Почеркloaderа такжеplugin

2.1 Как реализовать самостоятельноloader

Загрузчик — это, по сути, функция, которая выполняется, когда мы загружаем некоторые файлы.

2.1.1 Как реализовать синхронизациюloader

Сначала инициализируем проект, структура проекта показана на рисунке:

Содержимое файлов index.js и webpack.config.js следующее:

// index.js
console.log('我要学好前端,因为学好前端可以: ')

// webpack.config.js
const path = require('path')
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    }
}

Создаем в корневом каталогеsyncLoader.js, используемый для реализации синхронного загрузчика, обратите внимание, что эта функция должна возвращатьbufferилиstring

// syncloader.ja
module.exports = function (source) {
    console.log('source>>>>', source)
    return source
}

В то же время мыwebpack.config.jsиспользовать это вloader, мы используем здесьresolveLoaderэлемент конфигурации, указатьloaderНайдите путь к файлу, поэтому мы используемloaderможно указать напрямуюloaderимя

const path = require('path')
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    },
    resolveLoader: {
        // loader路径查找顺序从左往右
        modules: ['node_modules', './']
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: 'syncLoader'
            }
        ]
    }
}

Далее мы запускаем команду упаковки и видим, что командная строка выводит исходное содержимое, то есть содержимое файла функции загрузчика.

Затем модифицируем наш загрузчик:

module.exports = function (source) {
    source += '升值加薪'
    return source
}

Мы снова запускаем команду package, чтобы увидеть упакованный код:

Таким образом, мы реализовали простой загрузчик, который добавляет часть информации в наш файл. мы можем попробоватьloaderраспечатать в функцииthis, и обнаружил, что результатом вывода является очень длинная строка содержимого,thisМы можем многоеloaderполезная информация, используемая в , так, дляloaderпишите, не используйте стрелочные функции, это изменитthisуказывает на.

Вообще говоря, мы будем использовать официально рекомендованныйloader-utilsпакет для выполнения более сложныхloaderпишу

мы продолжаем установкуloader-utils, версия есть^2.0.0

Сначала мы преобразуемwebpack.config.js:

const path = require('path')

module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    },
    resolveLoader: {
        // loader路径查找顺序从左往右
        modules: ['node_modules', './']
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: {
                    loader: 'syncLoader',
                    options: {
                        message: '升值加薪'
                    }
                }
            }
        ]
    }
}

Обратите внимание, что у нас естьloaderповысилсяoptionsЭлемент конфигурации, затем используйте loader-utils в функции загрузчика, чтобы получить содержимое элемента конфигурации, объединив содержимое, мы все еще можем получить тот же результат упаковки, что и раньше.

// syncLoader.js
const loaderUtils = require('loader-utils')
module.exports = function (source) {
    const options = loaderUtils.getOptions(this)
    console.log(options)
    source += options.message
    // 可以传递更详细的信息
    this.callback(null, source)
}

Таким образом, мы выполнили простую синхронизациюloaderпишу

2.1.2 Как реализовать асинхронныйloader

Очень похоже на способ написания синхронного загрузчика, мы создаем файл asyncLoader.js в корневом каталоге, его содержимое выглядит следующим образом:

const loaderUtils = require('loader-utils')
module.exports = function (source) {
    const options = loaderUtils.getOptions(this)
    const asyncfunc = this.async()
    setTimeout(() => {
        source += '走上人生颠覆'
        asyncfunc(null, res)
    }, 200)
}

Обратите внимание здесьthis.async(), говоря официальным языкомTells the loader-runner that the loader intends to call back asynchronously. Returns this.callback.То есть сообщите WebPack, что этот загрузчик работает асинхронно, и возврат согласуется с синхронным использованием.this.callback

Далее мы модифицируем webpack.config.js

const path = require('path')
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    },
    resolveLoader: {
        // loader路径查找顺序从左往右
        modules: ['node_modules', './']
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: 'syncLoader',
                        options: {
                            message: '走上人生巅峰'
                        }
                    },
                    {
                        loader: 'asyncLoader'
                    }
                ]
            }
        ]
    }
}

Учтите, что порядок выполнения загрузчика взят из интернета, поэтому в тексте сначала напишите "поощрение и повышение зарплаты", а потом напишите "выйти на пик жизни"

До сих пор мы кратко представили, как написатьloader, в реальном проекте можно рассмотреть какую-то общую простую логику, можно написатьloaderдля завершения (например, замена интернационализированного текста)

2.2 Как реализовать самостоятельноplugin

pluginобычно вwebpackДля выполнения некоторых операций на определенном временном узле упаковки используемplugin, как правилоnew Plugin()Данная форма используется, поэтому, прежде всего, должно быть понятно, что,pluginДолжен быть класс.

Инициализируем проект, аналогичный предыдущей реализации загрузчика, и создаем проект в корневом каталогеdemo-webpack-plugin.jsфайл, мы сначалаwebpack.config.jsиспользовать его в

const path = require('path')
const DemoWebpackPlugin = require('./demo-webpack-plugin')
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    },
    plugins: [
        new DemoWebpackPlugin()
    ]
}

увидеть сноваdemo-webpack-plugin.jsреализация

class DemoWebpackPlugin {
    constructor () {
        console.log('plugin init')
    }
    apply (compiler) {

    }
}

module.exports = DemoWebpackPlugin

мы вDemoWebpackPluginКонструктор печатает сообщение, которое выводится, когда мы выполняем команду упаковки,pluginВ классе необходимо реализоватьapplyметод,webpackПри упаковке он позвонитpluginизapllyметод выполненияpluginлогике, этот метод принимаетcompilerВ качестве параметра этоcompilerдаwebpackПример

Суть плагина в том, что при выполнении метода apply он может оперировать каждый момент времени node (хуки, то есть хуки жизненного цикла), упакованные webpack на этот раз, и делать какие-то операции на разных временных узлах.

Чтобы узнать о различных хуках жизненного цикла процесса компиляции веб-пакета, вы можете обратиться кCompiler Hooks

Точно так же эти хуки также делятся на синхронные и асинхронные, как показано ниже.compiler hooksСпособ написания, некоторое ключевое содержание могут относиться к примечаниям:

class DemoWebpackPlugin {
    constructor () {
        console.log('plugin init')
    }
    // compiler是webpack实例
    apply (compiler) {
        // 一个新的编译(compilation)创建之后(同步)
        // compilation代表每一次执行打包,独立的编译
        compiler.hooks.compile.tap('DemoWebpackPlugin', compilation => {
            console.log(compilation)
        })
        // 生成资源到 output 目录之前(异步)
        compiler.hooks.emit.tapAsync('DemoWebpackPlugin', (compilation, fn) => {
            console.log(compilation)
            compilation.assets['index.md'] = {
                // 文件内容
                source: function () {
                    return 'this is a demo for plugin'
                },
                // 文件尺寸
                size: function () {
                    return 25
                }
            }
            fn()
        })
    }
}

module.exports = DemoWebpackPlugin

наше этоpluginФункция состоит в том, чтобы автоматически генерироватьmdДокумент, содержание документа очень простое предложение

Вышеупомянутые асинхронные хуки также могут быть написаны следующими двумя способами:

// 第二种写法(promise)
compiler.hooks.emit.tapPromise('DemoWebpackPlugin', (compilation) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve()
        }, 1000)
    }).then(() => {
        console.log(compilation.assets)
        compilation.assets['index.md'] = {
            // 文件内容
            source: function () {
                return 'this is a demo for plugin'
            },
            // 文件尺寸
            size: function () {
                return 25
            }
        }
    })
})
// 第三种写法(async await)
compiler.hooks.emit.tapPromise('DemoWebpackPlugin', async (compilation) => {
    await new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve()
        }, 1000)
    })
    console.log(compilation.assets)
    compilation.assets['index.md'] = {
        // 文件内容
        source: function () {
            return 'this is a demo for plugin'
        },
        // 文件尺寸
        size: function () {
            return 25
        }
    }
})

Окончательный результат тот же, и документ md генерируется каждый раз, когда он упакован.

До сих пор в этой статье были представлены основные принципы упаковки веб-пакетов и способы самостоятельной реализации загрузчика и плагина. Я надеюсь, что содержание этой статьи поможет вам изучить и использовать webpack, спасибо.