进击的模块化+webpack的简单实现

3,184 阅读4分钟

本文的初衷是来实现一个我们工作中最常用的构建工具,webpack,当然我们所实现的构建工具和真正的webpack差距甚远。这里只是简单一个实现罢了,感兴趣的同学可以继续看下去。

不过在说自动化构建之前还是要诉说一下模块化开发的发展史,这是前端er都经历过的一段历史,值得我们和所有人再去回顾一番!!!

模块化

模块化是指把一个复杂的系统分解到多个模块以方便编写

命名空间

开发网页要通过命名空间的方式来组织代码

<script src="jquery.js">
  • 命名空间冲突,两个库可能会使用同一个名称
  • 无法合理的管理项目的依赖和版本
  • 无法方便的控制依赖的加载顺序

CommonJS

CommonJS是一种使用广泛的JavaScript模范化管理,核心思想是通过require方法来同步地加载依赖的其他模块,通过module.exports导出需要暴露的接口

用法

采用CommonJS导入及导出时的代码如下:

// 导入
const A = require('./a.js');
fn();
// 导出
module.exports = A.fn;
原理实现
// a.js
module.exports = '刚好遇见你';

//b.js
const fs = require('fs');
// CommonJS简单实现
function req(pathName) {
    // content代表的是文件内容
    let content = fs.readFileSync(pathName, 'utf8');
    // 最后一个参数是函数的内容体
    let fn = new Function('exports', 'require', 'module', '__filename', '__dirname', content+'\n return module.exports');
    let module = {
        exports: {}
    };
    // 函数执行就可以取到module.exports的值了
    return fn(module.exports, req, module, __filename, __dirname);
}
const str = req('./a.js');  // 导入a模块
console.log(str);   // '刚好遇见你'

AMD

AMD也是一种JavaScript模块化规范,与CommonJS最大的不同在于它采用异步的方式去加载依赖的模块。 AMD规范主要是为了解决针对浏览器环境的模块化问题,最具代表性的实现是RequireJS

AMD的优点

  • 可在不转换代码的情况下直接在浏览器里运行
  • 可加载多个依赖
  • 代码可运行在浏览器环境和Node环境中

AMD的缺点

  • Js运行环境没有原生支持AMD,需要先导入实现了AMD的库才能正常使用(这里指的是RequireJS)
用法
// define定义模块
define('song', [], () => {
    return '告白气球';
});
define('singer', ['song', 'album'], (song, album) => {  // 依赖了song和album模块
    let singer = '周杰伦';
    return `${singer}${song}属于专辑《${album}》`;
});
define('album', [], () => {
    return '床边故事';
});
// require使用模块
require(['singer'], singer => {
    console.log(singer);   // 周杰伦的告白气球属于专辑床边故事
});
原理实现

RequireJS有两个方法,一个是define,另一个是require,所以首先我们先定义两个函数,看如下代码

let factories = {};     // 管理一个关联对象,将模块名和函数关联起来
// 定义模块define  三个参数:1.模块名 2.依赖 3.工厂函数
function define(name, depend, factory) {
    factories[name] = factory;
    factory.depend = depend;    // 将依赖记到factory上
}
// 通过require使用模块
function require(modules, callback) {
    let result = modules.map(mod => {   // 返回一个结果数组
        let factory = factories[mod];   // 拿到模块对应的函数
        let exports;
        let depend = factory.depend;    // 取到函数上的依赖 ['a']
        
        // require(['song','album'], function(song,album) {})  可能会有很多依赖
        require(depend, () => {         // 递归require
            exports = factory.apply(null, arguments);
        });
        return exports;     // exports得到的是函数返回的值  如:'告白气球' , ' 床边故事'
    });
    callback.apply(null, result);   //  result为一个结果数组,所以用apply
}

★ define作用就是把定义模块的函数保留下来

★ require需要哪个模块的时候就把该函数执行,然后将执行后的结果传到回调里

ES6 模块化

  • ES6模块化是ECMA提出的JS模块化规范,它在语言的层面上实现了模块化
  • 最主要的是它将取代CommonJS和AMD规范,成为浏览器和服务器通用的模块解决方案
// 导入
import {each, ...} from 'underscore.js';	// es6 按需引入
var _ = require('underscore.js');			// amd 全局引入

// 导出
export {each, map, ...};	// es6 多点暴露
module.exports = _;		// amd 全局暴露

小遗憾:ES6模块虽然是终极模块化方案,但它的缺点在于目前无法直接运行在大部分 JavaScript 运行环境下,必须通过工具转换成标准的 ES5 后才能正常运行

好了,以上内容就是模块化大致的发展历程

如今我们在工作中开始大量使用ES6等先进的语法来开发项目了,但是正如ES6模块化的“小遗憾”一样,还有一些环境下并不能支持,所以本着为国为民的态度,我们还需将其转化为能够识别的代码

正因如此慢慢的出现了自动化构建,简单来说,就是把源码转换成发布到线上的可执行JS、CSS、HTML 代码,当然这是最主要的目的,除此之外还有很多用途(文件优化、自动刷新、模块合并、代码校验等),这里就不一一细说了。我们直接进入主题,来说说webpack这个目前构建工具中的爆款吧

webpack

webpack是一个打包模块化JS的工具,在webpack里一切文件皆模块,通过loader转换文件,通过plugin注入钩子,最后输出由多个模块组合成的文件。webpack 专注于构建模块化项目

安装webpack

安装到项目中

  • 需要先在项目中npm init初始化一下
  • 建议node版本安装到8.2以上
// 安装最新版
npm i webpack -D
// 安装指定版本
npm i webpack@<version> -D
// 目前webpack4已经发布 这里的安装需要多一个webpack-cli
npm i webpack webpack-cli -D

★ npm i -D 是 npm install --save-dev 的简写,是指安装模块并保存到 package.json 的 devDependencies

安装到全局

npm i webpack -g

注意:推荐安装到当前项目,原因是可防止不同项目依赖不同版本的webpack而导致冲突

使用webpack

默认情况下我们会将src下的入口文件进行打包

// node v8.2版本以后都会有一个npx
// npx会执行bin里的文件
npx webpack     // 不设置mode的情况下 打包出来的文件自动压缩

// 设置mode为开发模式,打包后的文件不被压缩
npx webpack --mode development

这里使用webpack打包编译,是针对src目录下的默认文件index.js来做的处理

源文件目录结构
src -
    - index.js
    - a.js
    
// a.js
module.exports = '刚好遇见你';
// index.js
let str = require('./a.js');
console.log(str);

代码打包后会生成一个dist目录,并创建一个main.js,将打包后的代码放在其中,那么我们就来看看打包后的代码,到底是何方神圣

编译后的样子

打包后目录结构
dist -
     - main.js
     
// 下面来看下内部,取其精华
(function (modules) {
    function require(moduleId) {    // moduleId代表的是文件名
        var module = {
            exports: {}
        };
        modules[moduleId].call(module.exports, module, module.exports, require);
        return module.exports;
    }
    return require("./src/index.js");
})
({
    "./src/index.js": (function (module, exports, require) {
        eval("let str =  require(/*! ./a.js */ \"./src/a.js\");\n\nconsole.log(str);\n\n//# sourceURL=webpack:///./src/index.js?");
    }),
    "./src/a.js": (function (module, exports) {
        eval("module.exports = '刚好遇见你';\n\n//# sourceURL=webpack:///./src/a.js?");
    })
});
  • 整体来说还是包在了一个自执行函数内,函数中的参数modules,其实就是下面()内的对象{}。
  • modules[moduleId].call()这段代码其实就是将下面()内对应的key执行,modules['./src/index.js'] ()得到eval解析的代码

写一个试试

试试就试试,根据打包后的核心代码我们也来实现一个看看,来弄一个类似webpack脚手架,废话不多说,搞起来

// pack目录
pack -
     - bin
        - pack.js
  • 首先我们先创建一个文件夹叫pack,里面有对应的文件
  • 然后我们希望在命令行里直接执行pack命令就可以进行打包
    • 必须是个模块才可以执行
    • 在pack目录下npm init -y
    • 初始化后的package.json文件中将bin下的pack路径改成"bin/pack.js"
    • 将pack.js的命令引用到npm全局下,再执行pack命令的时候就可以直接使用
    • 在pack目录下执行npm link就可将pack的包放到了npm全局下(mac下需要加sudo)
    • 每次修改pack.js后,都需要重新npm link一下
  • 命令行中再次执行pack便可以打包了

上面几项说的是一个整体流程,接下来我们开始实现pack.js里的主要逻辑

// pack.js
#! /usr/bin/env node    
// 写上面这句话是告诉文件是在node下执行,不然会报错无法编译
let entry = './src/index.js';   // 入口文件
let output = './dist/main.js'   // 出口文件
let fs = require('fs');
let path = require('path');
let script = fs.readFileSync(entry, 'utf8');
let results = [];
// 如果有require引用的依赖,那就需要替换处理依赖
script = script.replace(/require\(['"](.+?)['"]\)/g, function() {
    let name = path.join('./src/',  arguments[1]);     // ./src/a.js
    let content = fs.readFileSync(name, 'utf8');
    results.push({
        name,
        content
    });
    return `require('${name}')`;    // require('./src/a.js')
});
// 用ejs可以实现内容的替换
let ejs = require('ejs');

// 这里的模板其实就是dist/main.js里的核心代码
let template = `
    (function (modules) {
    function require(moduleId) {
        var module = {
            exports: {}
        };
        modules[moduleId].call(module.exports, module, module.exports, require);
        return module.exports;
    }
    return require("<%-entry%>");
})
    ({
        "<%-entry%>": (function (module, exports, require) {
            eval(\`<%-script%>\`);
        })
        <%for(let i=0;i<results.length;i++){
            let mod = results[i];%>,
            "<%-mod.name%>": (function (module, exports, require) {
                eval(\`<%-mod.content%>\`);
            })
        <%}%>
    });
`;

// result为替换后的结果,最终要写到output中
let result = ejs.render(template, {
    entry,
    script,
    results
});

try {
    fs.writeFileSync(output, result);
} catch(e) {
    console.log('编译失败', e);
}
console.log('编译成功');

上面用到了ejs模板引擎,下面给大家写一下简单的用法

let name = '周杰伦';
console.log(<a><%-name%></a>);  // <a>周杰伦</a>

实现一个loader

接下来再写一个loader吧,loader其实就是函数,我们加载个css样式进行编译看看。在src目录下添加一个style.css文件

// style.css
* {
    margin: 0;
    padding: 0;
}
body {
    background: #0cc;
}

// index.js引入css文件
let str = require('./a.js');
require('./style.css');
console.log(str);

根据代码添加一个style-loader去编译css文件

// pack.js
// 省略...
let results = [];
// loader其实就是函数
// 这里写一个style-loader
+ let styleLoader = function(src) {
    // src就是样式中的内容
    return `
        let style = document.createElement('style');
        style.innerHTML = ${JSON.stringify(src).replace(/(\\r)?\\n/g, '')};
        document.head.appendChild(style);
    `;
+ };
// 如果有require引用的依赖,那就需要替换处理依赖
script = script.replace(/require\(['"](.+?)['"]\)/g, function() {
    let name = path.join('src',  arguments[1]);     // ./src/a.js
    let content = fs.readFileSync(name, 'utf8');
    // 如果有css文件,就进行编译
+   if (/\.css$/.test(name)) {
+       content = styleLoader(content);
+   }

    results.push({
        name,
        content
    });
    return `require('${name}')`;    // require('./src/a.js')
});

这里用JSON.stringify处理字符串不能换行的问题,如下代码

body {
    background: #0cc;
}

但是有个小瑕疵就是会带上换行符,所以replace这里来处理stringify后的\r\n换行符(mac下只有\n)

写到这里,一个简单的编译工具就完成了,这个功能虽然很不完善,但是也是开拓一下视野去实现一下我们常用的webpack是如何从0到1的过程。

当然我们实现的这个不能和成熟的webpack去比较,而且webpack的实现不仅于此。我也在尝试着继续研究webpack如何实现,像涉及到的ast这些内容,也在慢慢学习中。

希望下一次再写的时候会给各位观众一个更高大上的webpack实现了!

感谢各位的观看了。此致,敬礼!