all articles

learn webpack 2 - loader & lspack(self-made webpack)

2017-04-06 @sunderls

webpack js esprima

1. 什么是loader

loader是webpack中最重要的概念之一了,大家常用的可能有babel-loader, css-loader之类的。npm在import的时候默认支持.js, .json.node(抱歉,二进制的.node不是特别清楚),默认将会把文件当作js进行解析,所以平白无故的import 'app.css'的时候会报错。而loader的设定会把目标文件的内容包装成为js然后提供给webpack,感觉可以认为loader是一个预处理程序。

各种各样的loader使得webpack能够bundle几乎所有东西,成为一个强大的打包工具。

2. 一个简单的loader - json-loader

直接看一个非常简单的json-loader,如其名就是处理json文件的。其github上有说,webpack2默认支持json,不怎么能用得到这个loader,不过对于内容是json但文件扩展名不是json的时候还是有一定用处。

直接看源代码

var loaderUtils = require('loader-utils');

module.exports = function(source) {
    var value = typeof source === "string" ? JSON.parse(source) : source;
    var options = loaderUtils.getOptions(this) || {}; 
    value = JSON.stringify(value)
        .replace(/\u2028/g, '\\u2028')
        .replace(/\u2029/g, '\\u2029');
    value = options.stringify ? `'${value}'` : value
    var module = this.version && this.version >= 2 ? `export default ${value};` : `module.exports = ${value};`;
    return module 
}

可以看到源代码非常简单: 核心处理逻辑就是先JSON.parse(source)转换为object,然后再JSON.stringify()转换为string,为什么一来一回是为了支持stringify的参数,json-loader默认返回json object,可以传入stringify获得json string require('json-loader?stringify!../index.json');

其中对\u2028(Line separator) \u2029(Paragraph separator)这两个符号在js当中,当作了一行的结束,所以在一个字符串中是不能放着两个符号的,需要escape,但是JSON是没有这个问题的。这将导致JSON.stringify出来的字符串中可能出现这两个字符,进而出错。具体看这里

举个例子:

var a = {
    b: 'a\u2028b\nc'
}

var str = JSON.stringify(a) //  "{"b":"a
\u2028b\nc"}"

eval(`var module = ${str}`) // Uncaught SyntaxError: Invalid or unexpected token

eval(`var module = ${str.replace('\u2028', '\\u2028')}`) //

3. 看上去也不是很复杂

嗯,从基本概念上看也不是很复杂,干脆尝试自己写一个webpack。分析一下步骤:

  1. 读取config.js,获取其中的entry和output(暂时只支持一个entry)
  2. 获取entry file的文件内容,分析其中import到的module(暂时只支持es6的import),然后分析所有关联到的文件按照如下方法wrap成为一个module:
    1. 如果文件名不是js,则在config.js中找到对应的loader并且转换问js文本,否则报错。
    2. 对js文本头尾添加闭包function头尾,通过import和export传入用到的modules
  3. 最终调用entry module,执行方法。

嗯,感觉上还可以。其中对于import/export的分析,我们用esprima这个parser来试试,而关于parser,我也会去尝试学习学习。

4. 好,来写一个lspack。

首先要支持这样的config:

module.exports = {
    entry: './src/index.js',
    output: './dist/bundle.js'
}

具体js例而言,我们假设有如下的app场景:src/之下有一个index.js,引用了moduleA.js,而moduleA🈶️引用了moduleB

index.js

import A from './moduleA';

console.log('in enty js');
A.log();

moduleA.js

import {log as logB} from './moduleB';

export default {
    log() {
        console.log('ModuleA log()');
        logB();
    }
}

moduleB.js

export function log(){
    console.log('ModuleB log()');
}

具体function什么也没做,就是打印了个log而已。加上config,这些就是开发着书写的代码了,然后我们来写lspack

4.1 首先载入库文件

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const esprima = require('esprima');
const escodegen = require('escodegen');

我们将用esprima来parse js文件,然后用escodegen来生成js文件。

4.2 resolveModulePath()

由于import的时候可以省略文件扩展,以及存在相对文件路径以及node_module问题,我们写一个helper来统一文件路径。

// add .js to modulePath if not there
// support only relative paths now
const resolveModulePath = (modulePath, relativeTo = '.') => {
    return path.resolve(relativeTo, /\.\w+$/.test(modulePath) ? modulePath : modulePath + '.js');
}

这样 import A from './A'import A from './A.js'就不会出现重复。

4.3 如果管理module

我们将所有的module存在一个数组中,用index来标示,这样感觉最简单。


const modulePaths = [];
const moduleTexts = [];

// get module's ID
// push into modules if not found
const getModuleId = (modulePath) => {
    const id = modulePaths.indexOf(modulePath);
    if (id === -1) {
        return transform(modulePath);
    }

    return id;
}

getModuleId()如果发现module已经存在,就返回id,否则将文件处理过后压入,再返回id。

4.4 递归 transform()

这里稍微复杂一点

// 将文件转化为module
// 1. 获取文件内容
// 2. 得到其中的import和export语句,并进行转化
// 3. 返回id
const transform = (modulePath) => {
    // 这里用一个数组处理转换过后的语句
    // 为了让其支持export和import,闭包传入exports和require
    // 具体可以看这篇文章 http://tech.colla.me/show/further_reading_on_node.js_modules
    const transformedResult = [
        'function(exports, require){'
    ];

    // 读取文件内容
    const fileText = fs.readFileSync(modulePath, {
        encoding: 'utf8'
    });

    // 用esprima解析文件,注意sourceType的指定
    const program = esprima.parse(fileText, { sourceType: 'module'});

    // 处理import和export
    program.body.forEach(line => {
        switch (line.type) {
        case 'ImportDeclaration': 
            // 如果遇到import 就获取其moduleId
            // 根据前面的定义可以发现getModuleId将递归地触发transform
            // 具体语法格式可以参见 esprima文档 http://esprima.readthedocs.io/en/latest/syntax-tree-format.html
            const id = getModuleId(resolveModulePath(line.source.value, path.dirname(modulePath)));

            line.specifiers.forEach(specifier => {
                // [TODO] 有更多的type,但是这里就简单起见了。
                switch (specifier.type) {
                    case 'ImportDefaultSpecifier':
                        //import A from './moduleA'  ==>  var A = require(1)['default'];
                        transformedResult.push(`var ${specifier.local.name} = require(${id})['default'];\n`);
                        break;
                    case 'ImportSpecifier':
                        // import {log as logB} from './moduleB'; ==> var logB = require(0)['log'];
                        transformedResult.push(`var ${specifier.local.name} = require(${id})['${specifier.imported.name}'];\n`);
                        break;
                }
            });
            break;
        case 'ExportDefaultDeclaration': 
            if (line.declaration.type === 'ObjectExpression') {
                // export default {} ==> exports.default = {}
                transformedResult.push(`exports.default = ${escodegen.generate(line.declaration)}`);
                break;
            }
        case 'ExportNamedDeclaration':
            if (line.declaration.type === 'FunctionDeclaration') {
                // export function log(){ } ==> exports.log = function(){}
                transformedResult.push(`exports.${line.declaration.id.name} = function()${escodegen.generate(line.declaration.body)}`);
                break;
            }
        // [TODO] more types
        default:
            // 默认情况下原语句不变
            transformedResult.push(escodegen.generate(line));
        }
    });

    // 注意module的wrap格式 function(exports, require) { return exports;}
    transformedResult.push('\nreturn exports; }\n');

    // 这里将自身压入
    moduleTexts.push(transformedResult.join('\n'));
    modulePaths.push(modulePath);
    return moduleTexts.length - 1;
};

嗯,最复杂import , export也搞定了。

4.5 lspack.config.js

读取配置文件,parse entry 文件。

// read lspack.config.js and parse
const config = require(path.resolve('./lspack.config.js'));
const entryModuleId = transform(resolveModulePath(config.entry));

4.6 bootstrap 最后bundle

// 将转换好的module最后打包
const bundle = `
(function(moduleFactories) {
    // module都需要懒加载
    var modules = [];

    // 定义一个用id获取module的require方法
    function require(id) {
        if (modules[id]) {
            return modules[id];
        } else {
            // 如果发现module未被初始化,就用这个require去初始化一个module
            var m = moduleFactories[id]({}, require);
            modules.push(m);
            return modules[id];
        }
    }

    // require entry module的id,app开始执行!
    require(${entryModuleId});
})([${moduleTexts.join(',')}])
`;

// write to dist
fs.writeFileSync(config.output, bundle);

4.7 测试

本地测试的时候可以用npm link 非常方便

> npm link
> cd example
> lspack

然后可以看到dist中出现了打包好的bundle.js,打开index.html或者node dist/bundle.js可以看到如下的log

in enty js
ModuleA log()
ModuleB log()

说明没啥问题(注:以上实现只针对了遇到的import ,而import和export还有很多其他的语法这里暂时不做处理,因为都是重复劳动罢了),具体打包过后的文件可以看这里,完整sourcecode在 github

4.8 。。还有loader没有写

比如我们自定义一种文件格式.ls:

a 3
b 4

上述内容等价于如下json:

{
    a: 3,
    b: 4
}

如何办呢? 由于loader只是做文本转换而已,所以感觉比较容易。 首先在lspack.config.js中配置loader

module.exports = {
    entry: './src/index.js',
    output: './dist/bundle.js',
    rules: [
        {
            reg: /\.ls$/,
            loader: 'ls'
        }
    ]
}

暂时假设ls-loader是内置的(换成npm也非常容易),则在lspack中:

  1. config需要首先读入来获取loaders信息
  2. 获取fileText的时候需要加入loader处理

这里单独把读取文本抽离出来:

// get module text contents, with help of loaders
const getModuleContents= (modulePath) => {
    let text = fs.readFileSync(modulePath, {
        encoding: 'utf8'
    });

    // if it is not js, apply loaders
    if (!/\.js/.test(modulePath)) {
        config.rules.forEach(rule => {
            if (rule.reg.test(modulePath)) {
                text = require(`./loaders/${rule.loader}`)(text);
            }
        });
    }
    console.log(text);
    return text;
};

然后我们来写ls-loader:

/*
a 3
b 4

transform above format to json 
{"a":3,"b":4}
*/
module.exports = (text) => {
    return 'export default ' + JSON.stringify(text.split('\n').reduce((a, b) => {
        const segs = b.split(' ');
        a[segs[0]] = segs[1];
        return a;
    }, {}));
}

很简单,把文本按行和空格split,组合称为json,然后加上export default就OK了。

测试一下在index.js加入下述内容:

import data from './data.ls';
console.log(`data.a == ${data.a}`);

> lspack && node dist/bundle.js 可以看到log中成功打印出data.a == 3

loader部分的commit在这里

5 总结

我们通过自己实现一个非常非常简陋版的webpack,以及一个非常简单的loader,知道了webpack的基本逻辑就是分析js依赖然后打包,而loader不过是一个将非js module的文件转换为js module而已。

说的这么轻松,但是做好估计不简单。接下来我们继续谦虚地学习webpack,同时本次文章中也涉及到了parser,后面争取也尝试一下。