手寫一個簡易的Webpack

魯迅說: 當咱們會用同樣東西的時候,就要適當地去了解一下這個東西是怎麼運轉的。css


一. 什麼是Webpack

二. 寫一個簡單的Webpack

1. 看一下Webpack的流程圖

固然我不可能實現所有功能, 由於能力有限, 我只挑幾個重要的實現node

2. 準備工做

建立兩個項目, 一個爲項目juejin-webpack, 一個爲咱們本身寫的打包工具, 名字爲xydpackwebpack

1)juejin-webpack項目主入口文件內容和打包配置內容爲 :web

// webpack.config.js

const path = require('path')
const root = path.join(__dirname, './')

const config = {
    mode : 'development',
    entry : path.join(root, 'src/app.js'),
    output : {
        path : path.join(root, 'dist'),
        filename : 'bundle.js'
    }
}

module.exports = config
複製代碼
// app.js

/* 
    // moduleA.js
        let name = 'xuyede'
        module.exports = name
*/

const name = require('./js/moduleA.js')

const oH1 = document.createElement('h1')
oH1.innerHTML = 'Hello ' + name
document.body.appendChild(oH1)
複製代碼

2)爲了方便調試,咱們須要把本身的xydpacklink到本地, 而後引入到juejin-webpack中, 具體操做以下npm

// 1. 在xydpack項目的 package.json文件中加上 bin屬性, 並配置對應的命令和執行文件
{
  "name": "xydpack",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "bin": {
    "xydpack" : "./bin/xydpack.js"
  }
}

// 2. 在xydpack項目中添加相應路徑的xydpack.js文件, 並在頂部加上該文件的運行方式
#! /usr/bin/env node
console.log('this is xydpack')

// 3. 在 xydpack項目的命令行上輸入 npm link

// 4. 在 juejin-webpack項目的命令行上輸入 npm link xydpack

// 5. 在 juejin-webpack項目的命令行上輸入 npx xydpack後, 會輸出 this is xydpack 就成功了

複製代碼

3. 編寫 xydpack.js

從第一步的流程圖中咱們能夠看出, webpack打包文件的第一步是獲取打包配置文件的內容, 而後去實例化一個Compiler類, 再經過run去開啓編譯, 因此我能夠把xydpack.js修改成json

#! /usr/bin/env node

const path = require('path')
const Compiler = require('../lib/compiler.js')
const config = require(path.resolve('webpack.config.js'))

const compiler = new Compiler(config)
compiler.run()

複製代碼

而後去編寫compiler.js的內容瀏覽器

ps : 編寫xydpack能夠經過在juejin-webpack項目中使用npx xydpack 去調試bash

4. 編寫 compiler.js

1. Compiler

根據上面的調用咱們能夠知道, Compiler爲一個類, 而且有run方法去開啓編譯babel

class Compiler {
    constructor (config) {
        this.config = config
    }
    run () {}
}

module.exports = Compiler

複製代碼
2. buildModule

在流程圖中有一個buildModule的方法去實現構建模塊的依賴和獲取主入口的路徑, 因此咱們也加上這個方法app

const path = require('path')

class Compiler {
    constructor (config) {
        this.config = config
        this.modules = {}
        this.entryPath = ''
        this.root = process.cwd()
    }
    buildModule (modulePath, isEntry) {
        // modulePath : 模塊路徑 (絕對路徑)
        // isEntry : 是不是主入口
    }
    run () {
        const { entry } = this.config
        this.buildModule(path.resolve(this.root, entry), true)
    }
}

module.exports = Compiler
複製代碼

buildModule方法中, 咱們須要從主入口出發, 分別獲取模塊的路徑以及對應的代碼塊, 並把代碼塊中的require方法改成__webpack_require__方法

const path = require('path')
const fs = require('fs')

class Compiler {
    constructor (config) { //... }
    getSource (modulePath) {
        const content = fs.readFileSync(modulePath, 'utf-8')
        return content
    }
    buildModule (modulePath, isEntry) {
        // 模塊的源代碼
        let source = this.getSource(modulePath)
        // 模塊的路徑
        let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')

        if (isEntry) this.entryPath = moduleName
    }
    run () {
        const { entry } = this.config
        this.buildModule(path.resolve(this.root, entry), true)
    }
}

module.exports = Compiler

複製代碼
3. parse

獲得模塊的源碼後, 須要去解析,替換源碼和獲取模塊的依賴項, 因此添加一個parse方法去操做, 而解析代碼須要如下兩個步驟 :

  1. 使用AST抽象語法樹去解析源碼
  2. 須要幾個包輔助
@babel/parser -> 把源碼生成AST
@babel/traverse -> 遍歷AST的結點
@babel/types -> 替換AST的內容
@babel/generator -> 根據AST生成新的源碼
複製代碼

注意 : @babel/traverse@babel/generatorES6的包, 須要使用default導出

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

class Compiler {
    constructor (config) { //... }
    getSource (modulePath) { //... }
    parse (source, dirname) {
        // 生成AST
        let ast = parser.parse(source)
        // 遍歷AST結點
        traverse(ast, {
            
        })
        // 生成新的代碼
        let sourceCode = generator(ast).code
    }
    buildModule (modulePath, isEntry) {
        let source = this.getSource(modulePath)
        let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')

        if (isEntry) this.entryPath = moduleName

        this.parse(source, path.dirname(moduleName))
    }
    run () {
        const { entry } = this.config
        this.buildModule(path.resolve(this.root, entry), true)
    }
}

module.exports = Compiler

複製代碼

那麼獲得的ast是什麼呢, 你們能夠去 AST Explorer 查看代碼解析成ast後是什麼樣子。

當有函數調用的語句相似require()/ document.createElement()/ document.body.appendChild(), 會有一個CallExpression的屬性保存這些信息, 因此接下來要乾的事爲 :

  • 代碼中須要改的函數調用是require, 因此要作一層判斷
  • 引用的模塊路徑加上主模塊path的目錄名
const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default

class Compiler {
    constructor (config) { //... }
    getSource (modulePath) { //... }
    parse (source, dirname) {
        // 生成AST
        let ast = parser.parse(source)
        // 模塊依賴項列表
        let dependencies = []
        // 遍歷AST結點
        traverse(ast, {
            CallExpression (p) {
                const node = p.node
                if (node.callee.name === 'require') {
                    // 函數名替換
                    node.callee.name = '__webpack_require__'
                    // 路徑替換
                    let modulePath = node.arguments[0].value
                    if (!path.extname(modulePath)) {
                        // require('./js/moduleA')
                        throw new Error(`沒有找到文件 : ${modulePath} , 檢查是否加上正確的文件後綴`)
                    }
                    modulePath = './' + path.join(dirname, modulePath).replace(/\\/g, '/')
                    node.arguments = [t.stringLiteral(modulePath)]
                    // 保存模塊依賴項
                    dependencies.push(modulePath)
                }
            }
        })
        // 生成新的代碼
        let sourceCode = generator(ast).code
        return { 
            sourceCode, dependencies
        }
    }
    buildModule (modulePath, isEntry) {
        let source = this.getSource(modulePath)
        let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')

        if (isEntry) this.entryPath = moduleName

        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))
    }
    run () {
        const { entry } = this.config
        this.buildModule(path.resolve(this.root, entry), true)
    }
}

module.exports = Compiler
複製代碼

遞歸獲取全部的模塊依賴, 並保存全部的路徑與依賴的模塊

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

class Compiler {
    constructor (config) { //... }
    getSource (modulePath) { //... }
    parse (source, dirname) { //... }
    buildModule (modulePath, isEntry) {
        let source = this.getSource(modulePath)
        let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/')

        if (isEntry) this.entryPath = moduleName

        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))

        this.modules[moduleName] = JSON.stringify(sourceCode)

        dependencies.forEach(d => this.buildModule(path.join(this.root, d)), false)
    }
    run () {
        const { entry } = this.config
        this.buildModule(path.resolve(this.root, entry), true)
    }
}

module.exports = Compiler
複製代碼
4. emit

在獲取了全部的模塊依賴關係和主入口後, 接下來要把數據插入模板並寫入配置項中的output.path

由於須要一個模板, 因此借用一下webpack的模板, 使用EJS去生成模板, 不瞭解EJS的點這裏, 模板的內容爲 :

// lib/template.ejs

(function (modules) {
    var installedModules = {};
  
    function __webpack_require__(moduleId) {
      if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
      }
  
      var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
      };
  
      modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
      module.l = true;
      return module.exports;
    }
  
    return __webpack_require__(__webpack_require__.s = "<%-entryPath%>");
})
({
    <%for (const key in modules) {%>
        "<%-key%>":
        (function (module, exports, __webpack_require__) {
            eval(<%-modules[key]%>);
        }),
    <%}%>
});
複製代碼

下面咱們編寫emit函數

const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const ejs = require('ejs')

class Compiler {
    constructor (config) { //... }
    getSource (modulePath) { //... }
    parse (source, dirname) { //... }
    buildModule (modulePath, isEntry) { //... }
    emit () {
        const { modules, entryPath } = this
        const outputPath = path.resolve(this.root, this.config.output.path)
        const filePath = path.resolve(outputPath, this.config.output.filename)
        if (!fs.readdirSync(outputPath)) {
            fs.mkdirSync(outputPath)
        }
        ejs.renderFile(path.join(__dirname, 'template.ejs'), { modules, entryPath })
            .then(code => {
                fs.writeFileSync(filePath, code)
            })
    }
    run () {
        const { entry } = this.config
        this.buildModule(path.resolve(this.root, entry), true)
        this.emit()
    }
}

module.exports = Compiler
複製代碼

若是寫到這, 在juejin-webpack項目裏輸入npx xydpack就會生成一個dist目錄, 裏面有一個bundle.js文件, 可運行在瀏覽器中, 演示

三. 加上 loader

通過二以後, 只是單純地轉了一下代碼, 好像沒啥意義~

因此咱們要加上loader, 對loader不熟悉的點 這裏 , 由於是手寫嘛, 因此咱們loader也本身寫一下

注意 : 由於這個東西至關簡易, 因此只能玩一下樣式的loader, 其餘的玩不了, 因此只演示寫一下樣式的loader

1. 樣式的loader

我我的習慣使用stylus去編寫樣式, 因此樣式就寫stylus-loaderstyle-loader

首先, 在配置項上加上loader, 而後在app.js中引入init.styl

// webpack.config.js
const path = require('path')
const root = path.join(__dirname, './')

const config = {
    mode : 'development',
    entry : path.join(root, 'src/app.js'),
    output : {
        path : path.join(root, 'dist'),
        filename : 'bundle.js'
    },
    module : {
        rules : [
            {
                test : /\.styl(us)?$/,
                use : [
                    path.join(root, 'loaders', 'style-loader.js'),
                    path.join(root, 'loaders', 'stylus-loader.js')
                ]
            }
        ]
    }
}

module.exports = config
-----------------------------------------------------------------------------------------
// app.js

const name = require('./js/moduleA.js')
require('./style/init.styl')

const oH1 = document.createElement('h1')
oH1.innerHTML = 'Hello ' + name
document.body.appendChild(oH1)

複製代碼

在根目錄建立一個loaders目錄去編寫咱們的loader

// stylus-loader

const stylus = require('stylus')
function loader (source) {
    let css = ''
    stylus.render(source, (err, data) => {
        if (!err) {
            css = data
        } else {
           throw new Error(error)
        }
    })
    return css
}
module.exports = loader
-----------------------------------------------------------------------------------------
// style-loader

function loader (source) {
    let script = `
        let style = document.createElement('style')
        style.innerHTML = ${JSON.stringify(source)}
        document.body.appendChild(style)
    `
    return script
}
module.exports = loader
複製代碼

loader是在讀取文件的時候進行操做的, 所以修改compiler.js, 在getSource函數加上對應的操做

const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const t = require('@babel/types')
const traverse = require('@babel/traverse').default
const generator = require('@babel/generator').default
const ejs = require('ejs')

class Compiler {
    constructor (config) { //... }
    getSource (modulePath) {
        try {
            let rules = this.config.module.rules
            let content = fs.readFileSync(modulePath, 'utf-8')

            for (let i = 0; i < rules.length; i ++) {
                let { test, use } = rules[i]
                let len = use.length - 1

                if (test.test(modulePath)) {
                    // 遞歸處理全部loader
                    function loopLoader () {
                        let loader = require(use[len--])
                        content = loader(content)
                        if (len >= 0) {
                            loopLoader()
                        }
                    }
                    loopLoader()
                }
            }

            return content
        } catch (error) {
            throw new Error(`獲取數據錯誤 : ${modulePath}`)
        }
    }
    parse (source, dirname) { //... }
    buildModule (modulePath, isEntry) { //... }
    emit () { //... }
    run () { //... }
}

module.exports = Compiler
複製代碼

而後運行npx xydpack打包, 會添加一段這樣的代碼

"./src/style/init.styl":
(function (module, exports, __webpack_require__) {
    eval("let style = document.createElement('style');\nstyle.innerHTML = \"* {\\n padding: 0;\\n margin: 0;\\n}\\nbody {\\n color: #f40;\\n}\\n\";\ndocument.head.appendChild(style);");
}),

複製代碼

而後運行就能夠了, 演示

*2. 腳本的loader

腳本的loader, 第一個想到的就是babel-loader, 咱們本身寫一個babel-loader, 可是須要使用webpack去打包, 修改配置文件爲

// webpack.config.js

resolveLoader : {
    modules : ['node_modules', path.join(root, 'loaders')]
},
module : {
    rules : [
        {
            test : /\.js$/,
            use : {
                loader : 'babel-loader',
                options : {
                    presets : [
                        '@babel/preset-env'
                    ]
                }
            }
        }
    ]
}

複製代碼

使用babel須要三個包: @babel/core | @babel/preset-env | loader-utils安裝後, 而後編寫babel-loader

const babel = require('@babel/core')
const loaderUtils = require('loader-utils')

function loader (source) {
    let options = loaderUtils.getOptions(this)
    let cb = this.async();
    babel.transform(source, { 
        ...options,
        sourceMap : true,
        filename : this.resourcePath.split('/').pop(),
    }, (err, result) => {
        // 錯誤, 返回的值, sourceMap的內容
        cb(err, result.code, result.map)
    })
}

module.exports = loader
複製代碼

而後使用webpack打包就好了

四. 總結

到這裏, 咱們就能夠大概猜一下webpack的運做流程是這樣的 :

  1. 獲取配置參數
  2. 實例化Compiler, 經過run方法開啓編譯
  3. 根據入口文件, 建立依賴項, 並遞歸獲取全部模塊的依賴模塊
  4. 經過loader去解析匹配到的模塊
  5. 獲取模板, 把解析好的數據套進不一樣的模板
  6. 輸出文件到指定路徑

注意 : 我這個只是本身鬧着玩的, 要學webpack, 點 這裏

ps : 立刻畢業而後失業了, 有沒有哪家公司缺頁面仔的請聯繫我, 切圖也行的, 我很耐造

郵箱 : will3virgo@163.com

相關文章
相關標籤/搜索