JS进阶:编写一个JS代码打包工具

作者: 8b9a7eb5887b | 来源:发表于2018-11-09 18:45 被阅读3次

零、前言

之前在开发前端项目的时候,我曾经尝试过使用两种方式来组织前端的JS代码。

  1. 通过使用script标签将js文件引入到html页面中
  2. 在项目中使用requirejs来组织js代码

使用这两种方式,在JS文件比较多的情况下,貌似都需要来回多次向后台发起资源请求。前者还需要将部分方法、变量挂载到全局变量(window)中,以便于不同的文件之间进行通讯。

相比前面两种方法,我更喜欢将所有的JS代码都写在同一个文件内。这样做的好处有:1. 浏览器只需要向后台请求一次JS文件;2. 由于所有的代码都在同一个文件内,所以也不需要将内部接口挂载到全局变量中。

但是,如果要我自己只在一个文件内进行代码的编写,那我肯定是不会这么干的,大家都知道这其实是一种很不利于代码维护的作法,并且很可能你还没写完代码,就已经因为看长篇的代码而晕倒了。

所以我想要做的是,代码还是划分成多个模块,编写在多个不同文件内。只是在最后通过使用打包工具,将这些JS文件拼接成一个JS文件。如我所说的这一类的打包工具市面上其实也存在很多,比如webpack等等。但是这些工具无论是在学习难度上、操作难度上还是在配置难度上,貌似都有着出奇一致的复杂性。基于此些种种,于是我便萌生出了这么一个想法:为什么不自己动手,试着编写一个简易一点的JS代码打包工具呢?

一、流程构想

1.1 我编写这个插件的目的是什么?

我所需要的代码“打包器”,其实只需要具备一项核心功能:能够将多个JS文件打包拼接成一个独立的JS文件。
所以我需要让它知道的,只是在什么时候需要将一个文件的内容拼接到另一个文件的指定位置中(其实也可以理解为两个JS文件之间的引用关系),而并不需要它真的具有能够处理原生JS代码的能力。

1.2 如何处理文件之间的引用关系?

这里我引入了es6的语法import ... from ...,只是由于打包器并不会去执行JS文件,它不具备解析执行JS代码的能力,它所做的只是将多个文件进行拼接而已,所以在本插件中,这个写法具有不同于es6中的含义。

我们可以通过具体的代码来理解这样一种关系:

// 这是一个将要被引用的JS文件,sum.js
functiom sum (a, b) { return a + b; }
_exports(sum);

_exports(sum),看到这里,也许你会问我这个方法具有什么含义呢?其实很简单,在我的构思中,一个单独的JS文件就是一个单独的模块,一个单独的命名空间,在打包后将以一个闭包的形式呈现出来。_exports 的功能,就是向闭包外部,暴露自身内部的成员、方法或变量。它的用法:

1. _exports(name, val)

_exports('PI', 3.1415926535); // self.PI == 3.1415926535

2. _exports(val)

_exports(3.1415926535); // self === 3.1415626535

回归正题,当打包器处理到JS文件的import这一行时:

import sum from "sum";

它将会被渲染成如下内容:

// import sum from "sum";
// 将被渲染为:
var sum = (function () {
  var sum_12345678 = {};
  var _exports = function (name, val) {
    val ? sum_12345678[name] = val : sum_12345678[name] = name;
  }
  
  function sum (a, b) { return a + b; }
  _exports(sum);

  return sum_12345678;
})();

看到了这里,也许你应该知道了我是如何去实现文件与文件之间的这种衔接关系的了。
当打包器在处理import语句时,它将会试图寻找外部的JS文件进行渲染并拼接在原文件中(这里我将称之为被打包器识别的入口文件)。对于其他的代码部分,打包器则会原封不动的进行保留。所以你可以看到这个插件的原理其实是非常简单的,它除了拼接代码外,不会做更多的工作了。

二、工程化

我们知道,要让一个插件能够运用在我们的工作环境中,就必须对其实现工程化,以便达到可以即拿即用的效果。我将在nodejs环境下,工程化的实现这么一个小插件:简易的JS代码自动打包者

2.1 项目目录结构
build
|- build.js 
jspacker.json

build.js - 打包器的源码文件,同时也是项目的启动入口
jspacker.json - 打包器的相关配置选项

2.2 build.js

由于该源码篇幅较长,所以我将它放置在文末附录处。

2.3 jspacker.json
{
  "sourceDir": "./src",
  "sourceMap": ["main.js"],
  "outputDir": "./dist"
}

sourceDir - 用于放置待编译的源码的文件夹,打包器所识别的所有JS文件的路径,都是基于该目录下的相对路径。
sourceMap - 用于打包器识别的入口文件数组,支持配置多个入口。
outputDir - 用于告知打包器,代码打包后将它放置在哪一个目录下。

2.4 启动插件
node build/build

插件运行后,会持续监听sourceDir文件夹,每当这个目录下有文件被保存时,打包器将会自动进行编译(入口文件)。

2.5 打包示例
// 入口文件 main.js
import sum from "sum";
console.info(sum(1, 2));
// 被引用的文件 sum.js
function sum (a, b) {
  return a + b;
}

_exports(sum);
// 打包后的文件 dist/main.js
'use strict';
(function (global, factory) {

  function _exports (name, val) { global[name] = val; }

  if (typeof module === 'object' && typeof module.exports === 'object') {
    module.exports = factory(_exports);
  } else if (typeof define === 'function') {
    define(factory.bind(this, _exports));
  } else {
    factory.call(global, _exports);
  }
})(this, function (exports) {
    var sum = (function(){
        var sum_1541757530591 = {};
        function _exports (name, val) { 
          val ? sum_1541757530591[name] = val : sum_1541757530591 = name;
        }
        function sum (a, b) {
          return a + b;
        }

        _exports(sum);

        return sum_1541757530591;
    })();

    console.info(sum(1,2));

});

打包后的代码,这里我是参考了JQuery以及Vue等前端框架的源码结构。
打包器的打包方式也有所区分,分为:入口式打包、模块式打包。

2.6 入口式打包

针对jspacker中的入口配置项,将在源码的外层包裹上类似Jquery与Vue一样的闭包代码:

'use strict';
(function (global, factory) {

  function _exports (name, val) { global[name] = val; }

  if (typeof module === 'object' && typeof module.exports === 'object') {
    module.exports = factory(_exports);
  } else if (typeof define === 'function') {
    define(factory.bind(this, _exports));
  } else {
    factory.call(global, _exports);
  }
})(this, function (exports) {
    // 你的代码
});

这里你会发现,你所编写的代码都被包裹在一个被称为工厂函数的空间内,同时打包器向这个函数传入了一个参数:exports,你已经知道了,这是一个用于向闭包外部暴露内部接口、变量的方法。在浏览器中,它会将这些属性绑定在全局变量window上。

2.7 模块式打包

当打包器遇到import语法时,它将识别你想要引用的文件路径(相对于你在jspacker文件中所配置的源码目录),并对该文件进行模块式打包。假设你的import如下:

import sum from "sum";

打包器将识别出你要引用sum.js文件,并将其绑定在一个叫sum的变量中。于是你的这句代码,将被渲染成如下内容:

var sum = (function(){
  var sum_1541757530591 = {};
  function _exports (name, val) { 
    val ? sum_1541757530591[name] = val : sum_1541757530591 = name;
  }
  function sum (a, b) {
    return a + b;
  }
  _exports(sum);
  return sum_1541757530591;
})();

你看到了,模块式打包也是以闭包的形式呈现出来,正如我上面所提到的一样。闭包内有一个相对来说的全局变量:sum_1541757530591,这个变量后面跟着的一串数字,是为了不与你在该模块下定义的变量名重复,而加上的当前时间戳,你也可以通过这个时间戳了解到这个打包器的编译速度。
除此以外,这个闭包内还声明了一个方法:_exports,用于向闭包外部暴露内部接口、变量。实际上,其实是通过该方法,将内部接口、变量绑定在闭包内的相对全局变量身上,在通过return返回。

2.8 结语

以上便是有关:JS简易打包工具 的具体实现方法,希望对你有所启发,同时欢迎各位在评论区留言与我一起学习探讨,共同进步!

虚心等候着各位前辈的指点。

三、附录

3.1 build/build.js源码
// build/build.js
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');

const configJson = path.resolve(__dirname, '../jpacker.json');
const config = JSON.parse(fs.readFileSync(configJson, 'utf-8'));

// 开始编译
function start () {
  console.info('开始编译');
  clearOutputFolder();
  for(let entry of config.sourceMap) {
    loadEntry(entry, {});
  }
  console.info('编译成功');
}

// 清空输出文件夹
function clearOutputFolder () {

  let outputDir = path.resolve(
    path.resolve(__dirname, '../'), 
    config.outputDir
  );
  removeFolder(outputDir);
  fs.mkdirSync(outputDir);
}

// 递归删除文件夹
function removeFolder (folderPath) {
  if(!fs.existsSync(folderPath) || 
    !fs.statSync(folderPath).isDirectory()) return ;
  let childs = fs.readdirSync(folderPath);
  for(let v of childs) {
    let tmpPath = path.resolve(folderPath, v);
    let stat = fs.statSync(tmpPath);
    if(stat.isDirectory()) {
      removeFolder(tmpPath);
    } else {
      fs.unlinkSync(tmpPath);
    }
  }
  fs.rmdirSync(folderPath);
}

// 递归创建文件夹
function ensureFolder (folder) {
  if(fs.existsSync(folder)) return true;
  ensureFolder(path.dirname(folder));
  fs.mkdirSync(folder);
  return true;
}

// 编译文件
function loadEntry (entryPath, _modules) {
  if(!/\.js$/.test(entryPath)) {
    entryPath += '.js';
  }
  let filePath = path.resolve(
    path.resolve(__dirname, '../'),
    config.sourceDir,
    entryPath
  );
  let fileContent = fs.readFileSync(filePath, 'utf-8');
  if(!/(\r\n)$/.test(fileContent)) {
    fileContent += '\r\n';
  }
  let buildContent = fileContent.replace(/.*(\r\n)+/g, re => {
    let parser = jsparser(re);
    return parser ? loadModule(parser.name,
      parser.file, 1, _modules) : `\t${re}`;
  });
  let outputUrl = path.resolve(
    path.resolve(__dirname, '../'),
    config.outputDir,
    entryPath
  );
  ensureFolder(path.dirname(outputUrl));
  fs.writeFileSync(outputUrl, 
`'use strict';
(function (global, factory) {

  function _exports (name, val) { global[name] = val; }

  if (typeof module === 'object' && typeof module.exports === 'object') {
    module.exports = factory(_exports);
  } else if (typeof define === 'function') {
    define(factory.bind(this, _exports));
  } else {
    factory.call(global, _exports);
  }
})(this, function (exports) {
${buildContent}
});
`
);
}

// 编译模块
function loadModule (name, modePath, level, _modules) {
  let tab = Array(level + 1).fill('\t').join('');
  if(modePath in _modules) {
    return `${tab.substr(1)}var ${name} = ${_modules[modePath]};`;
  }
  _modules[modePath] = name;
  let filePath = path.resolve(
    path.resolve(__dirname, '../'),
    config.sourceDir,
    modePath
  );
  let fileContent = fs.readFileSync(filePath, 'utf-8');
  if(!/(\r\n)$/.test(fileContent)) {
    fileContent += '\r\n';
  }
  let buildContent = fileContent.replace(/.*(\r\n)+/g, re => {
    let parser = jsparser(re);
    return parser ? loadModule(parser.name,
      parser.file, level + 1, _modules) : `${tab}${re}`;
  });
  let name2 = `${name}_${Date.now()}`;
  return `${tab.substr(1)}var ${name} = (function(){
${tab}var ${name2} = {};
${tab}function _exports (name, val) { 
${tab}  val ? ${name2}[name] = val : ${name2} = name;
${tab}}
${buildContent}
${tab}return ${name2};
${tab.substr(1)}})();\r\n\r\n`;
}

// 解析
function jsparser (re) {
  if(!/import .+ from ((\".+\")|(\'.+\'))/.test(re)) return false;
  let name = re.replace(/import( )+/, '').replace(/( )+from.*/, '').trim();
  let file = re.match(/(\"(\S*)\")|(\'(\S*)\')/g)[0].replace(/\"|\'/g, '').trim();
  if(!/\.js$/.test(file)) {
    file += '.js';
  }
  return { name, file };
}

chokidar.watch(path.resolve(
  path.resolve(__dirname, '../'),
  config.sourceDir
)).on('change', p => start());
start();
3.2 项目地址

https://gitee.com/lixiaohui2019/jspacker.git

相关文章

  • JS进阶:编写一个JS代码打包工具

    零、前言 之前在开发前端项目的时候,我曾经尝试过使用两种方式来组织前端的JS代码。 通过使用script标签将js...

  • npm+webpack下创建React项目步骤

    npm是一个js的管理工具库,webpack是js的打包工具,可以把多个js文件打包成一个js文件,自动判断依赖关...

  • 前端(10 HTML知识)

    1.js代码编写 2.js编写位置 3.js基本语法

  • React / Vue 渲染过程对比

    React 1.使用JSX语法编写代码2.webpack打包时,JSX通过babel解析成js3.js通过Reac...

  • jspath学习资源和脚本编写

    一) 加载js 和 编写js 的过程 将js 文件加入到项目中 加载js 代码 编写脚本 fixInstance...

  • rn1转

    RN现在主要有3个线程 JS thread。JS代码执行线程,负责逻辑层面的处理。Metro(打包工具)将Reac...

  • ReactNative底层架构简述

    RN现在主要有3个线程: JS thread。JS代码执行线程,负责逻辑层面的处理。Metro(打包工具)将Rea...

  • 二、JS 进阶

    JS 进阶 HTML 标记(结构)、CSS 样式(表示),HTML + CSS 只能编写出静态页面;JS 可以为网...

  • 二、JS 进阶

    JS 进阶 HTML 标记(结构)、CSS 样式(表示),HTML + CSS 只能编写出静态页面;JS 可以为网...

  • 什么是css和js

    JS代码就是JavaScript编写的脚本文件的代码。用单独文件保存,扩展名为.JS JS即Javascript,...

网友评论

    本文标题:JS进阶:编写一个JS代码打包工具

    本文链接:https://www.haomeiwen.com/subject/wwboxqtx.html