從零開始編寫一個babel外掛

fiveoneLei發表於2019-03-04

構建webpack生產環境

我們編寫的babel外掛是所屬於babel-loader,而babel-loader基本執行與webpack環境.所以為了檢測babel外掛的是否起作用,我們必須構建webpack環境.

目錄結構

|-- babel-plugin-wyimport
    |-- .editorconfig
    |-- .gitignore
    |-- package.json
    |-- README.md
    |-- build
    |   |-- app.be45e566.js
    |   |-- index.html
    |-- config
    |   |-- paths.js
    |   |-- webpack.dev.config.js
    |   |-- webpack.prod.config.js
    |-- scripts
    |   |-- build.js
    |   |-- start.js
    |-- src
        |-- index.js
複製程式碼

webpack.prod.config.js

配置檔案,沒有對程式碼進行壓縮和混淆,主要為了方便對比編譯前後的檔案內容

`use strict`

process.env.BABEL_ENV = `production`;
process.env.NODE_ENV = `production`;

const path = require(`path`);
const paths = require("./paths");
const fs = require(`fs`);
const webpack = require("webpack");
const ExtractTextPlugin = require(`extract-text-webpack-plugin`);
const { WebPlugin } = require(`web-webpack-plugin`);

module.exports = {
    output: {
        path: paths.build,
        filename: `[name].[chunkhash:8].js`,
        chunkFilename: `static/js/[name].[chunkhash:8].chunk.js`,
        publicPath: "/"
    },
    entry: {
        "app":path.resolve(paths.src, "index.js")
    },
    resolve:{
        extensions:[".js", ".json"],
        modules: ["node_modules", paths.src]
    },
    module: {
        rules: [
            {
                test:/.css$/,
                include:paths.src,
                loader: ExtractTextPlugin.extract({
                    use: [
                        {
                            options:{
                                root: path.resolve(paths.src, "images")
                            },
                            loader: require.resolve(`css-loader`)
                        }
                    ]
                })
            },
            {
                test:/.less$/,
                include:paths.src,
                use:[
                    require.resolve(`style-loader`),
                    {
                        loader:require.resolve(`css-loader`),
                        options:{
                            root: path.resolve(paths.src, "images")
                        }
                    },
                    {
                        loader: require.resolve(`less-loader`)
                    }
                ]
            },
            {
                test: [/.bmp$/, /.gif$/, /.jpe?g$/, /.png$/],
                loader: require.resolve(`url-loader`),
                options: {
                  limit: 1000,
                  name: `static/images/[name].[hash:8].[ext]`,
                },
            },
            {
                test:/.(js|jsx)$/,
                include:paths.src,
                loader: require.resolve("babel-loader"),
                options:{
                    presets:["react-app"],
                    plugins:[
                        //["wyimport", {libraryName:"lodash"}]
                    ],
                    compact: true
                    //cacheDirectory: true
                }
            },
            {
                exclude: [
                  /.html$/,
                  /.(js|jsx)$/,
                  /.css$/,
                  /.less$/,
                  /.json$/,
                  /.bmp$/,
                  /.gif$/,
                  /.jpe?g$/,
                  /.png$/,
                  /.svg$/
                ],
                loader: require.resolve(`file-loader`),
                options: {
                  name: `static/[name].[hash:8].[ext]`,
                },
            }
        ]
    },
    plugins: [
        new ExtractTextPlugin(`[name].[chunkhash:8].css`),
        new WebPlugin({
            //輸出的html檔名稱
            filename: `index.html`,
            //這個html依賴的`entry`
            requires:["app"]
        }),
    ]
}
複製程式碼

build.js

啟動檔案,主要計算編譯前後的檔案內容大小

const webpack = require(`webpack`);
const path = require(`path`);
const config = require(`../config/webpack.prod.config`);
const chalk = require(`chalk`);
const paths = require(`../config/paths`);
const fs = require("fs");

// 獲取目錄大小
const getDirSize = (rootPath, unit ="k") => {
	if (!fs.existsSync(rootPath)) {
		return 0;
	}
	let buildSize = 0;
	const dirSize = (dirPath) => {
		let files = fs.readdirSync(dirPath, "utf-8")
		files.forEach((files) => {
			let filePath = path.resolve(dirPath, files);
			let stat = fs.statSync(filePath) || [];
			if (stat.isDirectory()){
				dirSize(filePath)
			} else {
				buildSize += stat.size
			}
		})
	}
	dirSize(rootPath)
	let map = new Map([["k",(buildSize/1024).toFixed(2)+"k"], ["M",buildSize/1024/1024+"M"]])
	return map.get(unit);
}
// 清空目錄檔案
const rmDir = (path, isDeleteDir) => {
	if(fs.existsSync(path)) {
        files = fs.readdirSync(path);
        files.forEach(function(file, index) {
            var curPath = path + "/" + file;
            if(fs.statSync(curPath).isDirectory()) { // recurse
                rmDir(curPath);
            } else { // delete file
                fs.unlinkSync(curPath);
            }
        });
        fs.rmdirSync(path);
    }
}

const measureFileBeforeBuild = () => {
	console.log(`打包之前build資料夾的大小: ${chalk.green(getDirSize(paths.build))}
`)
	rmDir(paths.build)  //刪除build資料夾
	return build().then((stats) => {
		console.log(chalk.green(`打包完成
`))
		console.log(`打包之後資料夾大小:${chalk.green(getDirSize(paths.build))}	花費時間: ${chalk.green((stats.endTime-stats.startTime)/1000)}s`)
	}, err => {
		console.log(chalk.red(`Failed to compile.
`));
      	console.log((err.message || err) + `
`);
      	process.exit(1);
	})
}

const build = () => {
	const compiler = webpack(config)
	return new Promise((resolve, reject) => {
		compiler.run((err, stats) => {
			console.log(chalk.green("開始打包..."))
			if (err) {
				return reject(err);
			}
			const message = stats.toJson({}, true)
			if (message.errors.length) {
				return reject(message.errors);
			}
			return resolve(stats)
		})
	})
}
measureFileBeforeBuild()
複製程式碼

小試牛刀

我們在src/index.js檔案裡面輸入

 import { uniq } from "lodash"
複製程式碼

然後 npm run build

從零開始編寫一個babel外掛

大小是531k,很明顯lodash被全部引入了進來了,所以這樣引入lodash庫的同學注意咯!
正常我們應該這樣寫來按需載入

 //import { uniq } from "lodash"
 import uniq from "lodash/uniq"
複製程式碼

然後 npm run build

從零開始編寫一個babel外掛

如果一個檔案引入lodash很多方法如

import uniq   from "lodash/uniq";
import extend from "lodash/extend";
import flatten from "lodash/flatten";
import cloneDeep from "lodash/cloneDeep";
...
複製程式碼

這樣的寫法就相當臃腫,那麼能不能這麼寫import {uniq, extend, flatten, cloneDeep } from "lodash"並且也實現按需載入呢? 很簡單,只要將它編譯輸出成

import uniq   from "lodash/uniq";
import extend from "lodash/extend";
import flatten from "lodash/flatten";
import cloneDeep from "lodash/cloneDeep";
複製程式碼

就可以了

知識準備

編寫plugin之前首先我們要清楚以下二點

  1. plugin在什麼時候起作用?
  2. plugin是如何起作用

webpack編譯原理

babel-loader作為webpack的一個loader.首先我們弄清楚webpack的編譯過程和loaderwebpack中作用
這裡有一篇文章說很好,大家先去閱讀理解之後再往下看

babel的基本概念

知乎有一篇文章講得比較清楚,對babel不是很清楚的同學先進去瞭解之後再往下看!

在這裡,我主要想強調一下babel引數的配置,如果我寫了一個名叫fiveonebabel外掛,我在引數中這麼配置

    {
        presets:["react-app", "es2015"],
        plugins:[
            ["fiveone", {libraryName:"lodash"}],
            ["transform-runtime", {}]
        ],
    }
    起作用的順序為fiveone->transform-runtime->es2015->react-app
複製程式碼

編譯順序為首先plugins從左往右然後presets從右往左

babel編譯原理

上面二節解釋了plugin在什麼時候起作用,下面解釋一下plugin如何起作用?

  1. babylon直譯器把程式碼字串轉化為AST樹, 例如import {uniq, extend, flatten, cloneDeep } from "lodash"轉化為AST
    從零開始編寫一個babel外掛
  2. babel-traverseAST樹進行解析遍歷出整個樹的path.
  3. plugin轉換出新的AST樹.
  4. 輸出新的程式碼字串
    文獻地址

我們要編寫的plugin在第三步.通過path來轉換出新的AST樹?下面我們就開始如何進行第三步!

開始babel-plugin

首先我們需要安裝二個工具babel-corebabel-types;

npm install --save babel-core babel-types;

  1. babel-core提供transform方法將程式碼字串轉換為AST
  2. babel-types提供各種操作AST節點的工具庫

我們在src/index.js中輸入

var babel = require(`babel-core`);
var t = require(`babel-types`);
const code = `import {uniq, extend, flatten, cloneDeep } from "lodash"`;
const visitor = {
    Identifier(path){
        console.log(path.node.name)
    }
}
const result = babel.transform(code, {
    plugins: [{
        visitor: visitor
    }]
})
複製程式碼

執行node src index.js

從零開始編寫一個babel外掛

visitor

babel對AST樹進行遍歷,遍歷的過程會提供一個叫visitor物件的方法對某個階段訪問, 例如上面的

    Identifier(path){
        console.log(path.node.name)
    }
複製程式碼

就是訪問了Identifier節點,AST樹展開如下

從零開始編寫一個babel外掛

為什麼會輸出二個uniq,因為每個節點進入和退出都會呼叫該方法。
遍歷會有二次,一個是像下遍歷進入,一個是像上遍歷退出.
我們將src/index.js中的Identifier方法改為

Identifier:{
    enter(path) {
        console.log("我是進入的:",path.node.name)
    },
    exit(path) {
        console.log("我是進入的:",path.node.name)
    }
}
複製程式碼

執行node src index.js

從零開始編寫一個babel外掛

遍歷流程: 向下遍歷-進入uniq->退出uniq->向上遍歷-進入uniq->退出uniq

path

path 表示兩個節點之間的連線,通過這個物件我們可以訪問到當前節點、子節點、父節點和對節點的增、刪、修改、替換等等一些操作。下面演示將uniq替換_uniq
程式碼如下:

var babel = require(`babel-core`);
var t = require(`babel-types`);
const code = `import {uniq, extend, flatten, cloneDeep } from "lodash"`;

const visitor = {
    Identifier(path){
        if (path.node.name == "uniq") {
            var newIdentifier = t.identifier(`_uniq`)  //建立一個名叫_uniq的新identifier節點
            path.replaceWith(newIdentifier)            //把當前節點替換成新節點
        }
    }
}

const result = babel.transform(code, {
    plugins: [{
        visitor: visitor
    }]
})
console.log(result.code) //import { _uniq, extend, flatten, cloneDeep } from "lodash";
複製程式碼

開始

有了以上概念我們現在把程式碼字串import {uniq, extend, flatten, cloneDeep } from "lodash"轉化成

import uniq   from "lodash/uniq";
import extend from "lodash/extend";
import flatten from "lodash/flatten";
import cloneDeep from "lodash/cloneDeep";
複製程式碼

程式碼如下

var babel = require(`babel-core`);
var t = require(`babel-types`);
const code = `import {uniq, extend, flatten, cloneDeep } from "lodash"`;

const visitor = {
    ImportDeclaration(path, _ref = {opts:{}}){
        const specifiers = path.node.specifiers;
        const source = path.node.source;
        if (!t.isImportDefaultSpecifier(specifiers[0]) ) {
            var declarations = specifiers.map((specifier, i) => {         //遍歷  uniq extend flatten cloneDeep
                return t.ImportDeclaration(                               //建立importImportDeclaration節點
                    [t.importDefaultSpecifier(specifier.local)],
                    t.StringLiteral(`${source.value}/${specifier.local.name}`)
                )
            })
            path.replaceWithMultiple(declarations)

        }
    }
}

const result = babel.transform(code, {
    plugins: [{
        visitor: visitor
    }]
})
console.log(result.code)
複製程式碼

然後node src/index.js

從零開始編寫一個babel外掛

KO,有人會問,小編你怎麼知道這麼寫?
很簡單在AST

將1變換成2就可以了

配置到node_modules

程式碼寫完了,起作用的話需要配置,我們把這個外掛命名為fiveone所以在node_modules裡面新建一個名叫babel-plugin-fiveone的資料夾

從零開始編寫一個babel外掛

babel-plugin-fiveone/index.js中輸入

var babel = require(`babel-core`);
var t = require(`babel-types`);
const visitor = {
    // 對import轉碼
    ImportDeclaration(path, _ref = {opts:{}}){
        const specifiers = path.node.specifiers;
        const source = path.node.source;
        if (!t.isImportDefaultSpecifier(specifiers[0]) ) {
            var declarations = specifiers.map((specifier) => {      //遍歷  uniq extend flatten cloneDeep
                return t.ImportDeclaration(                         //建立importImportDeclaration節點
                    [t.importDefaultSpecifier(specifier.local)],
                    t.StringLiteral(`${source.value}/${specifier.local.name}`)
                )
            })
            path.replaceWithMultiple(declarations)

        }
    }

};
module.exports = function (babel) {
    return {
        visitor
    };
}

複製程式碼

然後修改webpack.prod.config.jsbabel-loader的配置項

options:{
    presets:["react-app"],
    plugins:[
        ["fiveone", {}]
    ],
}
複製程式碼

然後src/index.js中輸入

import {uniq, extend, flatten, cloneDeep } from "lodash"
複製程式碼

npm run build

從零開始編寫一個babel外掛

很明顯實現了按需載入

然而不能對所有的庫都進入這麼轉碼所以在babel-loader的plugin增加lib

options:{
    presets:["react-app"],
    plugins:[
        ["fiveone", {libraryName:"lodash"}]
    ],
}
複製程式碼

babel-plugin-fiveone/index.js中修改為


var babel = require(`babel-core`);
var t = require(`babel-types`);
const visitor = {
    // 對import轉碼
    ImportDeclaration(path, _ref = {opts:{}}){
        const specifiers = path.node.specifiers;
        const source = path.node.source;
        // 只有libraryName滿足才會轉碼
        if (_ref.opts.libraryName == source.value && (!t.isImportDefaultSpecifier(specifiers[0])) ) { //_ref.opts是傳進來的引數
            var declarations = specifiers.map((specifier) => {      //遍歷  uniq extend flatten cloneDeep
                return t.ImportDeclaration(                         //建立importImportDeclaration節點
                    [t.importDefaultSpecifier(specifier.local)],
                    t.StringLiteral(`${source.value}/${specifier.local.name}`)
                )
            })
            path.replaceWithMultiple(declarations)

        }
    }

};
module.exports = function (babel) {
    return {
        visitor
    };
}
複製程式碼

結束

如果文章有些地方有問題請指正,非常感謝!
github地址:github.com/Amandesu/ba…
如果大家有所收穫,可以隨手給個star不勝感激!

參考連結

相關文章