qcyhust

9 天前
  • 61

    浏览
  • 0

    评论
  • 0

    收藏

Parcel——开始前端开发零配置体验

本文作者:ivweb qcyhust 原文出处:IVWEB社区 未经同意,禁止转载     IVWEB公众群2

Parcel——开始前端开发零配置体验

导语:
前端开发在开启一个新的项目时总是有个绕不开的环节,那就是项目的开发配置。几年前,我们还在用grant或是gulp来定义一系列的task构建项目开发流程,再往后webpack的出现开始有了广泛的模块概念,利用各种loader或是plugin处理各个前端模块。当然,无论是gulp还是webpack,都需要编写相对应的配置文件来定制开发过程中需要的功能。业务之外的工作量总是催生人性的懒惰,这样一个打着极速零配置名号出现的web打包工具就很抓人眼球。这里利用官网的项目扩展,看看pacel的工作流程。

项目结构

为了测试代码拆分功能,新建了一个简单的项目,结构如下:

parcel
└───src
    └─── index.html
    │─── index.js
    │─── main.js
    │─── handle.js
    └─── main.css

在index.html中引用index.js作为入口文件。
index.js中引用样式文件main.css 和 main.js,同时用import方法动态加载handle.js

index.html

<html>
<body>
  <button class="main">点击加载组件</button>
  <script src="./index.js"></script>
</body>
</html>

index.js

import './main.css';
import main from './main';

document.querySelector('.main').onclick = function() {
  const main = import('./handle');
  main.then(({ handler }) => {
    handler();
  });
};

怎么零配置

像其他工具一样,当本地全局安装parcel后就可以在命令行中使用了。比如我的项目以index.html作为入口,在命令行中输入parcel index.html。然后如果不是太讲究的话,就可以没有然后了。
这时查看到根目录中多了一个dist文件夹,在浏览器中输入 http://localhost:1234 就可以呈现项目,更改文件内容的话会发现热替换也已经开启,打开devTool,查看sourses加载的资源,会发现只有一个js和css文件,点击页面按钮后第二个js才加载并执行,动态加载也达成。

加载前

加载后

相对于以前在gulp和webpack中的体验,parcel的零配置口号实至名归, 唯一的配置就是把parcel命令写到到package.json脚本中。

一个命令达成功能成就:
:white_check_mark: 静态文件打包
:white_check_mark: dev-sever
:white_check_mark: 热模块替换
:white_check_mark: 代码分拆,动态加载

过程剖析

一句简单的命令parcel index.html是经历了怎样的过程完成了项目了构建,在不考虑cache的情况下,打开pacel看看里面到底装了什么,怎么实现的以上几大功能。
同其他node命令行工具一样,在入口处parcel利用commander对参数(端口,是否开启缓存,是否开启热替换等)进行解析,然后调用主程序。

/*
 * @param {String} main 入口文件
 * @param {Object} command 命令行参数
 */
async function bundle(main, command) {
  ...
  // 调用主程序
  const bundler = new Bundler(main, command);

  if (command.name() === 'serve') {
    // 建立服务
    const server = await bundler.serve(command.port || 1234);
    // 打开浏览器
    if (command.open) {
      require('opn')(`http://localhost:${server.address().port}`);
    }
  } else {
    bundler.bundle();
  }
}
dev-sever

在开发过程中利用一个sever来服务开发的前端项目可以方便的调试接口,前端路由等,webpack提供webpack-dev-server来提供服务,其本质上是一个简洁的Express服务器。这个服务器的功能不需要有多复杂,服务器常见的会话管理,路由,数据连接等功能都不需要,主要目的是提供前端资源的服务,所以parcel搭建的dev server更加简洁。

parcel在主程序中调用serve方法, 默认接口1234

async serve(port = 1234) {
  this.bundle();
  return await Server.serve(this, port);
}

Server的工作是在node中创建一个http服务,利用serve-static模块生成中间件提供dist文件夹的静态资源服务。

function middleware(bundler) {
  // 静态资源中间件
  const serve = require('serve-static')(bundler.options.outDir, {index: false});

  return function(req, res, next) {
    ...
    // 响应函数
    function respond() {
      if (bundler.errored) {
        return send500();
      } else if (!req.url.startsWith(bundler.options.publicURL)) {
        return sendIndex();
      } else {
        req.url = req.url.slice(bundler.options.publicURL.length);
        return serve(req, res, send404);
      }
    }
    ...
  };
}

async function serve(bundler, port) {
  ...
  // 创建服务器
  let server = http.createServer(middleware(bundler)).listen(freePort);
  ...
}

当访问 http://localhost:1234 时服务器调用sendIndex响应dist中的入口html文件,解析html后,响应其他资源文件(js,css等)

热模块替换 hmr

热替换要实现修改前端文件时,不用刷新浏览器,修改的内容能自动更新。这就不仅仅是node程序的工作了,需要在浏览器中作出响应。在 http://localhost:1234 中,查看network会发现前端请求除了需要的资源文件外,还有一条协议转换请求101,这是一条希望升级到websocket请求的协议。parcel在node服务和浏览器间搭建一条websocket通道,约定消息类型和更新方法,从而实现热替换。

node端:

class HMRServer {
  async start() {
    // 启动ws服务
    this.wss = new require('ws').Server({port: 0});
    ...
  }
  ...
  emitUpdate(assets) {
    ...
    if (containsHtmlAsset) {
      // 如果更新html发送type为reload
      this.broadcast({
        type: 'reload'
      });
    } else {
      // 如果更新不是html文件,发送type为update,并且带上更新的资源对象
      this.broadcast({
        type: 'update',
        assets ...
      });
    }
  }
}

web端代码被打包进js文件中,随业务代码一同运行在浏览器中:

if (!module.bundle.parent) {
  var ws = new WebSocket('ws://localhost:52833/');
  ws.onmessage = function(event) {
    var data = JSON.parse(event.data);

    if (data.type === 'update') {
      // 更新的资源asset中属性generated包含了新的业务代码,封装为函数再运行即可
      // var fn = new Function('require', 'module', 'exports', asset.generated.js);
      // modules[asset.id] = [fn, asset.deps];
      // modules[name][0].call(module.exports, localRequire, module, module.exports);
    }
    // 如果更新html就直接reload
    if (data.type === 'reload') {
      window.location.reload();
    }
    ...
  };
}
生成资源 assets

parcel官网的描述中parcel是基于资源的,资源可以代表任意文件,parcel会自动地分析这些文件和包中引用的依赖,相同类型的资源会被组合到同一捆绑包中。资源可以理解为html,js,css,scss或less等等前端资源文件。parcel将入口文件作为mainAsset,递归分析它的依赖,从而构建出一棵资源树。

// 入口文件初始化
if (isInitialBundle) {
  // 创建dist目录
  await fs.mkdirp(this.options.outDir);
  // 根据入口文件mainFile生成mainAsset
  this.mainAsset = await this.resolveAsset(this.mainFile);
  this.buildQueue.add(this.mainAsset);
}

resolveAsset方法接收入口文件路径,生成一个资源对象:

async resolveAsset(name, parent) {
  // 如果是包文件可以提取包pkg
  let {path, pkg} = await this.resolver.resolve(name, parent);
  ...
  // 调用parser getAsset方法得到asset对象
  let asset = this.parser.getAsset(path, pkg, this.options);
  ...
  return asset;
}

getAsset根据文件类型,找到具体的Asset类来解析资源文件并生成资源对象,一个资源对象拥有name,type,generated,depAssets等属性,以index.html为例,调用getAsset会返回htmlAsset实例:

getAsset(filename, pkg, options = {}) {
  let Asset = this.findParser(filename);
  ...
  return new Asset(filename, pkg, options);
}
findParser(filename) {
  ...
  // 得到文件后缀
  let extension = path.extname(filename);
  let parser = this.extensions[extension] || RawAsset;
  if (typeof parser === 'string') {
    // 加载具体的Asset类文件
    parser = this.extensions[extension] = require(parser);
  }
  return parser;
}

parser中注册了17中后缀和他们对应的12中Asset类,index.html对应HTMLAsset,index.js对应JSAsset。每一个Asset利用相应的工具分析文件内容,生成ast语法树,根据语法树提取出它的依赖文件路径,是否需要拆分,然后修改语法树,生成新的文件内容写入资源generated属性中。 一棵html ast树:

[
  {
    "tag":"html",
    "content":[
      "\n",
      {
        "tag":"body",
        "content":["\n  ",
        {
          "tag":"button",
          "attrs":{
            "class":"main"
          },
          "content":["点击加载组件"]
        },
        "\n",
        {
          "tag":"script",
          "attrs":{
            "src":"./index.js"
          }
        },"\n"]
      },"\n"]
    },
    "\n"
  ]

HTMLAsset资源:

// 可能有依赖的节点
const ATTRS = {
  src: [
    'script',
    'img',
    'audio',
    'video',
    'source',
    'track',
    'iframe',
    'embed'
  ],
  href: ['link', 'a'],
  poster: ['video']
};

class HTMLAsset extends Asset {
  constructor(name, pkg, options) {
    super(name, pkg, options);
    this.type = 'html';
    this.isAstDirty = false;
  }

  parse(code) {
    // 利用posthtml-parser模块解析html文件ast树
    let res = require('posthtml-parser')(code);
    ...
    return res;
  }

  collectDependencies() {
    // 遍历ast树
    this.ast.walk(node => {
      if (node.attrs) {
        for (let attr in node.attrs) {
          let elements = ATTRS[attr];
          // 解析到ast中有链接属性
          if (elements && elements.includes(node.tag)) {
            // 添加到资源依赖dependencies中
            let assetPath = this.addURLDependency(node.attrs[attr]);
            ...
            // ast被修改后就需要重建文件内容
            this.isAstDirty = true;
          }
        }
      }

      return node;
    });
  }

  generate() {
    // 生成文件内容
    let html = this.isAstDirty ? render(this.ast) : this.contents;
    return {html};
  }
}

JSAsset在解析和重建文件的过程采用babel提供的功能,所以可以在项目根目录配置.babelrc文件来配置babel。
生成了入口文件资源后,调用buildQueuedAssets函数用来生成资源树:

...
// 生成资源树
let bundle = await this.buildQueuedAssets(isInitialBundle);
...
async buildQueuedAssets(isInitialBundle = false) {
  ...
  while (this.buildQueue.size > 0) {
    let promises = [];
    for (let asset of this.buildQueue) {
      ...
      promises.push(this.loadAsset(asset));
      ...
    }

    // 异步加载资源
    await Promise.all(promises);
  }
  // 触发一次热替换
  if (this.hmr && !isInitialBundle) {
    this.hmr.emitUpdate([...this.findOrphanAssets(), ...loadedAssets]);
  }
  ...
}
// 加载资源对象
async loadAsset(asset) {
  // 调用资源对象的异步方法dependencies,generated,hash等资源,添加到asset属性上
  processed = await this.farm.run(asset.name, asset.package, this.options);

  asset.generated = processed.generated;
  asset.hash = processed.hash;
  ...
  // 递归处理资源的依赖
  await Promise.all(
    dependencies.map(async dep => {
      let assetDep = await this.resolveDep(asset, dep);
      ...
      asset.dependencies.set(dep.name, dep);
      asset.depAssets.set(dep.name, assetDep);

      await this.loadAsset(assetDep);
    })
  );
  ...
}

最后,一个资源对象就出来了

HTMLAsset {
  id: 1,
  name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/src/index.html',
  basename: 'index.html',
  package: {},
  options:{ ... },
  encoding: 'utf8',
  type: 'html',
  processed: true,
  contents: null,
  ast: null,
  generated: { html: '<html>\n<body>\n  <button class="main">点击加载组件</button>\n  <script src="/dist/78167cf4c678ec8bfbf3c96500b17b10.js"></script>\n</body>\n</html>\n' },
  hash: 'c2a7cac48926283fbfd8ed3c6a1693ef',
  parentDeps: Set {},
  dependencies: Map { './index.js' => [Object] },
  depAssets: Map { './index.js' => [Object] },
  parentBundle: null,
  bundles: Set {},
  isAstDirty: false
}


  JSAsset {
    id: 4,
    name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/src/index.js',
    basename: 'index.js',
    package: {},
    options: { ... },
    encoding: 'utf8',
    type: 'js',
    processed: true,
    contents: null,
    ast: null,
    generated: { js: '"use strict";\n\nrequire("./main.css");\n\nvar _main = require("./main");\n\nvar _main2 = _interopRequireDefault(_main);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\n// 导入一个 CSS module\ndocument.querySelector(\'.main\').onclick = function () {\n  const main = require("_bundle_loader")(require.resolve(\'./handle\'));\n  main.then(({ handler }) => {\n    handler();\n  });\n};' },
    hash: '611655beed1bac39ae2b2b9ea57038b7',
    parentDeps: Set {},
    dependencies:
     Map {
       '_bundle_loader' => [Object],
       './main.css' => [Object],
       './handle' => [Object],
       './main' => [Object] },
    depAssets:
     Map {
       '_bundle_loader' => [Object],
       './main.css' => [Object],
       './handle' => [Object],
       './main' => [Object] },
    parentBundle: null,
    bundles: Set {},
    globals: Map {},
    isAstDirty: false,
    isES6Module: false,
    outputCode: null
  }

得到了资源以及他们的依赖,接下来就要构建文件。

生成文件束 bundle

一旦资源树被构建好,资源会被放置在文件束树中。一个文件束对应一个最终打包的文件,如index.html的资源放在一个文件束中,index.js和main.js会打包成一个文件,他们的资源会被方法同一个文件束中,成为整个文件束树的一个枝干。当不同类型的文件资源被引入,兄弟文件束就会被创建。例如你在js文件中引入了CSS文件,那它会被放置在这个js文件束的兄弟文件束中。
createBundleTree接收入口文件资源,开始构建一个bundle文件束树,取出资源的依赖,递归的构建这个文件束的兄弟文件束和子文件束。

// 创建文件束
let bundle = this.createBundleTree(this.mainAsset);
createBundleTree(asset, dep, bundle) {
  ...
  // 初始化时,没有文件束就要新建一个
  if (!bundle) {
    // 文件束都是Bundle的实例对象
    bundle = new Bundle(
      asset.type,
      Path.join(this.options.outDir, asset.generateBundleName(true))
    );
    // 设置文件束的入口资源
    bundle.entryAsset = asset;
  }

  // 动态加载import和html文件依赖的资源会被放置在当前文件束的子文件束中
  if (dep && dep.dynamic) {
    bundle = bundle.createChildBundle(
      asset.type,
      Path.join(this.options.outDir, asset.generateBundleName())
    );
    bundle.entryAsset = asset;
  }

  // 文件引用了其他类型的文件就创建兄弟文件束,并把资源放置在其中
  bundle.getSiblingBundle(asset.type).addAsset(asset);

  // 文件束中放置资源
  if (asset.generated[bundle.type] != null) {
    bundle.addAsset(asset);
  }

  asset.parentBundle = bundle;

  // 递归处理资源的依赖
  for (let dep of asset.dependencies.values()) {
    let assetDep = asset.depAssets.get(dep.name);
    this.createBundleTree(assetDep, dep, bundle);
  }

  return bundle;
}

我们的项目生成的文件束树省略部分属性后得到:

Bundle {
  type: 'html',
  name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/dist/index.html',
  entryAsset: HTMLAsset { index.html 对应的asset },
  assets:
   Set {
     HTMLAsset { index.html 对应的asset }
   },
  childBundles:
   Set {
     Bundle {
       type: 'js',
       name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/dist/78167cf4c678ec8bfbf3c96500b17b10.js',
       parentBundle: 文件束树
       entryAsset:
         JSAsset { index.js 对应的asset },
       assets:
         Set {
           JSAsset { index.js 对应的asset },
           CSSAsset { main.css 对应的asset },
           JSAsset { main.js 对应的asset },
         }.
       childBundles:
         Set {
           Bundle {
             type: 'css',
             name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/dist/78167cf4c678ec8bfbf3c96500b17b10.css',
           },
          Bundle {
             type: 'js',
             name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/dist/3c2b35351a5a96cc4ce69ba93f980eb9.js',
          }
        },
      siblingBundles:
        Map {
          'css' => Bundle {
            type: 'css',
            name: '/Users/qiuchengyun/Library/Mobile Documents/com~apple~CloudDocs/projects/parcel/dist/78167cf4c678ec8bfbf3c96500b17b10.css',
          }
        }
      },
      siblingBundles: Map {}
    }
  }

文件束树以index.html开始,以index.js构建兄弟文件束,在index.js中引用的main.js与index.js在同一个文件束中,样式文件被放入兄弟文件束和子文件束中,动态加载的handle在另一个子文件束中。

代码分拆 import

代码拆分时通过使用动态import()函数的语法提案来控制的,import方法异步加载模块并返回一个Promise对象。动态import提供了异步加载模块并执行的功能,通过代码拆分,可以将一些不需要在网页初始化时提供给用户的代码从打包主文件中拆分出来,减少主文件的大小,加快首屏响应速度。在文件束树中,需要拆分的文件会被单独创建子文件束,这样就不会和其他代码一起打包。
web端代码则添加了异步加载的功能:

// 异步加载js文件
function loadJSBundle(bundle) {
  return new Promise(function (resolve, reject) {
    var script= document.createElement('script');
    script.async = true;
    script.type = 'text/javascript';
    script.charset = 'utf-8';
    script.src = bundle;
    ...

    document.getElementsByTagName('head')[0].appendChild(script);
  });
}
打包 package

在文件束树被构建之后,最后就是打包各个文件。每个文件束都会被packager写到一个特定文件类型的文件中。

// 打包资源
this.bundleHashes = await bundle.package(this, this.bundleHashes);
async package(bundler, oldHashes, newHashes = new Map()) {
  ...
  let promises = [];
  // hash发生变化时打包
  if (!oldHashes || oldHashes.get(this.name) !== hash) {
    promises.push(this._package(bundler));
  }

  for (let bundle of this.childBundles.values()) {
    // 打包子文件束中的资源
    promises.push(bundle.package(bundler, oldHashes, newHashes));
  }

  await Promise.all(promises);
  return newHashes;
}

async _package(bundler) {
  // 取文件类型对应的packager
  let Packager = bundler.packagers.get(this.type);
  let packager = new Packager(this, bundler);

  await packager.start();
  ...
}

不同的文件类型对应不同的packager,parcel提供了4中packeager,其中主要的是HTMLPackager,JSPackager和CSSPackager三个,这里简单看看HTMLPackager和JSPackager是怎么工作的。
Packager

class Packager {
  constructor(bundle, bundler) {
    ...
    this.setup();
  }

  setup() {
    // 创建写入文件流 this.bundle.name为文件路径
    this.dest = require('fs').createWriteStream(this.bundle.name);
    ...
  }

  async end() {
    await this.dest.end();
  }
}
HTMLPackager:
class HTMLPackager extends Packager {
  async addAsset(asset) {
    // 取出资源的文件内容
    let html = asset.generated.html || '';

    // 从子文件束中取出css文件束
    let cssBundles = Array.from(this.bundle.childBundles)
      .map(b => b.siblingBundles.get('css'))
      .filter(Boolean);

    if (cssBundles.length > 0) {
      // 如果html依赖css文件的话往head中插入css link标签
      ...
    }
    // 写入文件内容
    await this.dest.write(html);
  }
}

JSPackager要复杂一些,需要在业务代码中加入hmr,模块等功能

// 模块require等功能代码
const prelude = fs
  .readFileSync(__dirname + '/../builtins/prelude.js', 'utf8')
  .trim();
// 热替换功能代码
const hmr = fs
  .readFileSync(__dirname + '/../builtins/hmr-runtime.js', 'utf8')
  .trim();

class JSPackager extends Packager {
  async start() {
    this.first = true;
    // 存放文件内容,放置重复
    this.dedupe = new Map();

    await this.dest.write(prelude + '({');
  }

  async addAsset(asset) {
    ...
    let deps = {};
    for (let dep of asset.dependencies.values()) {
      // 取出依赖资源
      let mod = asset.depAssets.get(dep.name);
      ...
      // 取出依赖资源的文件内容
      deps[dep.name] = this.dedupe.get(mod.generated.js) || mod.id;
      ...
    }
    // 写入文件中
    await this.writeModule(asset.id, asset.generated.js, deps);
  }

  // 业务代码被作为一个module添加在文件中
  async writeModule(id, code, deps = {}) {
    let wrapped = this.first ? '' : ',';
    wrapped +=
      id + ':[function(require,module,exports) {\n' + (code || '') + '\n},';
    wrapped += JSON.stringify(deps);
    wrapped += ']';

    this.first = false;
    await this.dest.write(wrapped);
  }

  async end() {
    let entry = [];

    // 添加热替换功能的代码
    if (this.options.hmr) {
      await this.writeModule(
        0,
        hmr.replace('{{HMR_PORT}}', this.options.hmrPort)
      );
      entry.push(0);
    }

    ...

    await this.dest.end('},{},' + JSON.stringify(entry) + ')');
  }
}

如果资源被多于一个文件束引用,它会被提升到文件束树中最近的公共祖先中,这样该资源就不会被多次打包。

if (asset.parentBundle) {
  // 如果这个资源已经被引用了
  if (asset.parentBundle !== bundle) {
    let commonBundle = bundle.findCommonAncestor(asset.parentBundle);
    if (
      asset.parentBundle !== commonBundle &&
      asset.parentBundle.type === commonBundle.type
    ) {
      // 移动资源
      this.moveAssetToBundle(asset, commonBundle);
    }
  }

  return;
}

总结

parcel是一个高度集成的前端打包工具,减少了开发者在创建新项目时的配置工作。这里仅仅探讨了它在develop中的表现,parcel能做的当然还包括build代码,快速缓存。

0 条评论