Webpack 系列 - 认识webpack loader :style-loader

1,458 阅读4分钟

Webpack

从官方的说法来看 Webpack 是一个 JS 模块打包工具,可以用它打包 Web 网站的 JS 代码库,也可以用来打包第三方代码库。不像 RequireJs 只支持 AMD,NodeJS 是 CommonJS, SeaJS 只支持 CMD,如今还有 ES6 Module ...。在我看来易于编码的才是好工程,但浏览器不一定认识它。Webpack就像个黑盒,输入我的工程,输出在浏览器运行。这样开发者就可灵活根据自己喜好编码。

Webpack工作流程

简单来说可以概括为以下几步:

  1. 参数解析
  2. 找到入口文件
  3. 调用 Loader 编译文件
  4. 遍历 AST,收集依赖
  5. 生成 Chunk
  6. 输出文件

其中,真正起编译作用的便是 Loader,本文也就 Loader 中的 style-loader 进行引出。

webpack loader

webpack本身只能打包Javascript文件,对于其他资源例如 css,图片,或者其他的语法集比如jsx,是没有办法加载的。 这就需要对应的loader将资源转化,加载进来。比如, 你的工程中,样式文件都使用了less语法,是不能被浏览器识别的,这时候我们就需要使用对应的loader,来把less语法转换成浏览器可以识别的css语法。本文通过分析style-loader原来让大家对webpack loader 有个更深的认识。

style-loader

style-loader 的功能就是在在 DOM 里插入一个 <style> 标签,并且将 CSS 写入这个标签内。

const style = document.createElement('style'); // 新建一个 style 标签
style.type = 'text/css';
style.appendChild(document.createTextNode(content)) // CSS 写入 style 标签
document.head.appendChild(style); // style 标签插入 head 中

大概就是上面代码功能的扩展以及附加一些功能。

style-loader的webpack配置使用

 module: {    
     rules: [      
         {        
             test: /.(css)$/,        
             use: [          
                 {            
                     loader: 'style-loader',            
                     options: {},          
                 },          
                 { loader: 'css-loader' },        
             ],      
         },    
     ],  
 },

上面代码是一段简单的style-loader 和 css-loader 这两个 loader的使用。

源码解析

style-loader 主要可以分为:

  • 打包阶段
  • runtime 阶段

打包阶段

先看下引入了哪些依赖:

var _path = _interopRequireDefault(require("path"));

var _loaderUtils = _interopRequireDefault(require("loader-utils"));

var _schemaUtils = require("schema-utils");

var _isEqualLocals = _interopRequireDefault(require("./runtime/isEqualLocals"));

var _options = _interopRequireDefault(require("./options.json"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

这里定义了一个 _interopRequireDefault 方法,传入的是一个 require()

这个方法的作用是:如果引入的是 es6 模块,直接返回,如果是 commonjs 模块,则将引入的内容放在一个对象的 default 属性上,然后返回这个对象。

loader-utils: webpack工具类,style-loader只用到了里面两个方法getOptions和stringifyRequest。前一个方法检索被调用loader的配置选项,后一个方法将一个请求转化为非绝对路径的可被require或import的字符串;

schema-utils: 此类用于检验 loader传入的参数和定义的参数类型是否匹配。

接下来主方法:

const loaderApi = () => {};

loaderApi.pitch = function loader(request) {
   ...
};

var _default = loaderApi;
exports.default = _default;

默认的 loader 都是从右向左像管道一样执行,而 pitch 是从左到右执行的。

为什么 style-loader 需要这样呢?

我们知道默认 loader 的执行是从右向左的,并且会将上一个 loader 处理的结果传递给下一个 loader,如果按照这种默认行为,css-loader 会返回一个 js 字符串给 style-loader

style-loader 的作用是将 CSS 代码插入到 DOM 中,如果按照顺序从 css-loader 接收到一个 js 字符串的话,就无法获取到真实的 CSS 样式了。所以正确的做法是先执行 style-loader,在它里面去执行 css-loader ,拿到经过处理的 CSS 内容,再插入到 DOM 中。

接下来看看 loader 的主体内容:

//获取 webpack 配置里的 options
const options = _loaderUtils.default.getOptions(this);
//校验 options
(0, _schemaUtils.validate)(_options.default, options, {
   name: 'Style Loader',
   baseDataPath: 'options'
});

// style 标签插入的位置,默认是 head
const insert = typeof options.insert === 'undefined' ? '"head"' : typeof options.insert === 'string' ? JSON.stringify(options.insert) : options.insert.toString();

//设置以哪种方式插入 DOM 中
const injectType = options.injectType || 'styleTag';
const esModule = typeof options.esModule !== 'undefined' ? options.esModule : true;

//hot 需要
const namedExport = esModule && options.modules && options.modules.namedExport;
const runtimeOptions = {
   injectType: options.injectType,
   attributes: options.attributes,
   insert: options.insert,
   base: options.base
};
 
switch (injectType) {  
   case 'linkTag': {}  
   case 'lazyStyleTag':  
   case 'lazySingletonStyleTag': {}  
   case 'styleTag':  
   case 'singletonStyleTag':  
   default: {}
}

根据不同的 injectType 会 return 不同的 js 代码,在 runtime 的时候执行。

这里现在我们只看下默认情况,代码中很多热更新的处理,我们理出主要代码:


return `${

    esModule ? `
        import api from ${_loaderUtils.default.stringifyRequest(this, `!${_path.default.join(__dirname, 'runtime/injectStylesIntoStyleTag.js')}`)};

        import content${namedExport ? ', * as locals' : ''} from ${_loaderUtils.default.stringifyRequest(this, `!!${request}`)};` 

        : 

        `var api = require(${_loaderUtils.default.stringifyRequest(this, `!${_path.default.join(__dirname, 'runtime/injectStylesIntoStyleTag.js')}`)});

         var content = require(${_loaderUtils.default.stringifyRequest(this, `!!${request}`)});

        content = content.__esModule ? content.default : content;`

}

var options = ${JSON.stringify(runtimeOptions)};

options.insert = ${insert};
options.singleton = ${isSingleton};

var update = api(content, options);

${hmrCode}

${esModule ? namedExport ? 

`export * from ${_loaderUtils.default.stringifyRequest(this, `!!${request}`)};` 

: 

'export default content.locals || {};' : 'module.exports = content.locals || {};'}

`;
 

首先调用require方法获取css文件的内容,将其赋值给content,如果content是字符串,则将content赋值为数组,即:[[module.id], content, ''],接着我们覆盖了options的insert、singleton属性,由于我们暂时只看默认的,所以insert=head,singleton=false;

_loaderUtils.default.stringifyRequest(this, `!!${request}`) 这个方法的作用是将绝对路径转换成相对路径。

contentupdate 的实际内容是:

var content = require("!!../../node_modules/css-loader/dist/cjs.js!./xxx.css");
var update = require("!../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js")(content, options);

意思也就是调用 injectStylesIntoStyleTage 模块来处理经过 css-loader 处理过的样式内容 content

上述代码都是 style-loader 返回的,真正执行是在 runtime 阶段。

runtime 阶段

将样式插入 DOM 的操作实际是在 runtime 阶段进行的,还是以默认情况举例,看看 injectStylesIntoStyleTage 做了什么。

module.exports

module.exports = function (list, options) {
  options = options || {}; // Force single-tag solution on IE6-9, which has a hard limit on the # of <style>
  // tags it will allow on a page

  if (!options.singleton && typeof options.singleton !== 'boolean') {
    options.singleton = isOldIE();
  }

  list = list || [];
    
  // 主要方法
  var lastIdentifiers = modulesToDom(list, options);
  // 更新时用
  return function update(newList) {
    newList = newList || [];

    if (Object.prototype.toString.call(newList) !== '[object Array]') {
      return;
    }

    for (var i = 0; i < lastIdentifiers.length; i++) {
      var identifier = lastIdentifiers[i];
      var index = getIndexByIdentifier(identifier);
      stylesInDom[index].references--;
    }

    var newLastIdentifiers = modulesToDom(newList, options);

    for (var _i = 0; _i < lastIdentifiers.length; _i++) {
      var _identifier = lastIdentifiers[_i];

      var _index = getIndexByIdentifier(_identifier);

      if (stylesInDom[_index].references === 0) {
        stylesInDom[_index].updater();

        stylesInDom.splice(_index, 1);
      }
    }

    lastIdentifiers = newLastIdentifiers;
  };
};

可以看到 module.exportupdate方法是更新使用,我们这里最关注的还是modulesToDom方法。

function modulesToDom(list, options) {
  var idCountMap = {};
  var identifiers = [];

  for (var i = 0; i < list.length; i++) {
    var item = list[i];
    var id = options.base ? item[0] + options.base : item[0];
    var count = idCountMap[id] || 0;
    var identifier = "".concat(id, " ").concat(count);
    idCountMap[id] = count + 1;
    var index = getIndexByIdentifier(identifier);
    var obj = {
      css: item[1],
      media: item[2],
      sourceMap: item[3]
    };

    if (index !== -1) {
      stylesInDom[index].references++;
      stylesInDom[index].updater(obj);
    } else {
      stylesInDom.push({
        identifier: identifier,
        updater: addStyle(obj, options),
        references: 1
      });
    }

    identifiers.push(identifier);
  }

  return identifiers;
}

将传递进来的内容转换为了styles数组,在文件顶部,定义了stylesInDom对象,主要是用来记录已经被加入DOM中的styles, 接下来看addStyle:

function addStyle(obj, options) {
  var style;
  var update;
  var remove;

  if (options.singleton) {
    var styleIndex = singletonCounter++;
    style = singleton || (singleton = insertStyleElement(options));
    update = applyToSingletonTag.bind(null, style, styleIndex, false);
    remove = applyToSingletonTag.bind(null, style, styleIndex, true);
  } else {
    style = insertStyleElement(options);
    update = applyToTag.bind(null, style, options);

    remove = function remove() {
      removeStyleElement(style);
    };
  }

  update(obj);
  return function updateStyle(newObj) {
    if (newObj) {
      if (newObj.css === obj.css && newObj.media === obj.media && newObj.sourceMap === obj.sourceMap) {
        return;
      }

      update(obj = newObj);
    } else {
      remove();
    }
  };
}

可以看到它返回一个函数,其主要内容是判断传入的对象是否与原对象相等,如果相等,则什么都不做,否则调用update函数,如果对象为空,则调用remove函数。而update与remove是在else中被赋值的,在赋值之前,我们首先看insertStyleElement函数:

function insertStyleElement(options) {
  var style = document.createElement('style');
  var attributes = options.attributes || {};

  if (typeof attributes.nonce === 'undefined') {
    var nonce = typeof __webpack_nonce__ !== 'undefined' ? __webpack_nonce__ : null;

    if (nonce) {
      attributes.nonce = nonce;
    }
  }

  Object.keys(attributes).forEach(function (key) {
    style.setAttribute(key, attributes[key]);
  });

  if (typeof options.insert === 'function') {
    options.insert(style);
  } else {
    var target = getTarget(options.insert || 'head');

    if (!target) {
      throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");
    }

    target.appendChild(style);
  }

  return style;
}

创建一个style标签,并将其插入insert中,即head中,回到之前的地方,我们定义了update和remove,之后我们手动调用update函数,即applyToTag

function applyToTag(style, options, obj) {
  var css = obj.css;
  var media = obj.media;
  var sourceMap = obj.sourceMap;

  if (media) {
    style.setAttribute('media', media);
  } else {
    style.removeAttribute('media');
  }

  if (sourceMap && typeof btoa !== 'undefined') {
    css += "\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap)))), " */");
  } // For old IE

  /* istanbul ignore if  */


  if (style.styleSheet) {
    style.styleSheet.cssText = css;
  } else {
    while (style.firstChild) {
      style.removeChild(style.firstChild);
    }

    style.appendChild(document.createTextNode(css));
  }
}

即给刚创建的style标签更新内容,而remove函数指向removeStyleElement函数.

最后说一下,style-loader会返回一个字符串,而在浏览器中调用时,会将创建一个style标签,将其加入head中,并将css的内容放入style中,同时每次该文件更新也会相应的更新Style结构,如果该css文件内容被删除,则style的内容也会被相应的删除,更我们一开始说的一样新建一个 style 标签,然后写入内容。