Loader 原理

上文我们写了一个打包器,但是只能加载 JS 文件,现在我们尝试让他可以加载 CSS

如何加载 CSS

思路

  1. 我们的 bundle 只能加载 JS
  2. 我们想要加载 CSS
  3. 如果我们能把 CSS 变成 JS。那么就可以加载 CSS 了
  // 获取文件内容,将内容放至 depRelation
  let code = readFileSync(filepath).toString()
  if (/\.css$/.test(filepath)) {
    code = `
      const code = ${JSON.stringify(code)};
      export default code;
    `
  }

如此一来,我们的 CSS 文件就变成了 js文件,但是目前并没有用,CSS 并不会生效。

再加一个骚操作即可让 CSS 生效

  // 获取文件内容,将内容放至 depRelation
  let code = readFileSync(filepath).toString()
  if (/\.css$/.test(filepath)) {
    code = `
      const code = ${JSON.stringify(code)};
      if (document) {
        const style = document.createElement('style');
        style.innerText = code;
        document.head.appendChild(style);
      }
      export default code;
    `
  }

完整代码

import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { writeFileSync, readFileSync } from 'fs'
import { resolve, relative, dirname, join } from 'path';
import * as babel from '@babel/core'
import {mkdir} from 'shelljs'

// 设置根目录
const projectName = 'project_css'
const projectRoot = resolve(__dirname, projectName)
// 类型声明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = [] // 数组!

// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, 'index.js'))

// 先创建 dist 目录
const dir = `./${projectName}/dist`
mkdir('-p', dir) 
// 再创建 bundle 文件
writeFileSync(join(dir, 'bundle.js'), generateCode()) 
console.log('done')

function generateCode() {
  let code = ''
  code += 'var depRelation = [' + depRelation.map(item => {
    const { key, deps, code } = item
    return `{
      key: ${JSON.stringify(key)}, 
      deps: ${JSON.stringify(deps)},
      code: function(require, module, exports){
        ${code}
      }
    }`
  }).join(',') + '];\n'
  code += 'var modules = {};\n'
  code += `execute(depRelation[0].key)\n`
  code += `
  function execute(key) {
    if (modules[key]) { return modules[key] }
    var item = depRelation.find(i => i.key === key)
    if (!item) { throw new Error(\`\${item} is not found\`) }
    var pathToKey = (path) => {
      var dirname = key.substring(0, key.lastIndexOf('/') + 1)
      var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
      return projectPath
    }
    var require = (path) => {
      return execute(pathToKey(path))
    }
    modules[key] = { __esModule: true }
    var module = { exports: modules[key] }
    item.code(require, module, module.exports)
    return modules[key]
  }
  `
  return code
}

function collectCodeAndDeps(filepath: string) {
  const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
  if (depRelation.find(i => i.key === key)) {
    // 注意,重复依赖不一定是循环依赖
    return
  }
  // 获取文件内容,将内容放至 depRelation
  let code = readFileSync(filepath).toString()
  if (/\.css$/.test(filepath)) {
    code = `
      const code = ${JSON.stringify(code)};
      if (document) {
        const style = document.createElement('style');
        style.innerText = code;
        document.head.appendChild(style);
      }
      export default code;
    `
  }

  const { code: es5Code } = babel.transform(code, {
    presets: ['@babel/preset-env']
  })
  // 初始化 depRelation[key]
  const item = { key, deps: [], code: es5Code }
  depRelation.push(item)
  // 将代码转为 AST
  const ast = parse(code, { sourceType: 'module' })
  // 分析文件依赖,将内容放至 depRelation
  traverse(ast, {
    enter: path => {
      if (path.node.type === 'ImportDeclaration') {
        // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
        const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
        // 然后转为项目路径
        const depProjectPath = getProjectPath(depAbsolutePath)
        // 把依赖写进 depRelation
        item.deps.push(depProjectPath)
        collectCodeAndDeps(depAbsolutePath)
      }
    }
  })
}
// 获取文件相对于根目录的相对路径
function getProjectPath(path: string) {
  return relative(projectRoot, path).replace(/\\/g, '/')
}

让我们搞个页面试试代码就知道了

// index.js
import a from './a.js'
import b from './b.js'
import './index.css'
console.log(a.getB())
console.log(b.getA())
// index.css
body {background-color: #c03;}s

运行 node -r ts-node/register bundler_css.ts ,然后新建一个页面引入即可




    
    
    
    Document


    


到此我们已经成功加载一个 CSS 文件,但是我们没有使用 loader,我们目前是写死在打包器的。

创建 CSS loader

其实很简单

只需要创建文件 css-loader.js,并把代码复制过去即可

// css-loader.js
const cssLoader = code => `
  const code = ${JSON.stringify(code)};
  if (document) {
    const style = document.createElement('style');
    style.innerText = code;
    document.head.appendChild(style);
  }
  export default code;
`

module.exports = cssLoader

之前的代码变成引入的文件

  if (/\.css$/.test(filepath)) {
    code = require('./loader/css-loader.js')(code)
  }

为什么要用 require,因为很多 loader 的名字都是从配置文件中读取的,主要是为了方便动态加载

loader 长什么样子
  • 一个loader 可以是普通函数
  • function transform(code){
      const code2 = doSomething(code)
      return code        
    }
    modules.exports = transform
    
  • 一个 loader 也可以是一个异步函数
  • async function transform(code){
      const code2 = await doSomething(code)
      return code        
    }
    modules.exports = transform
    

简单的 loader 搞定,开始优化

单一职责原则
  • webpack 里每个 loader 只做一件事
  • 目前我们的 css-loader 做了两件事
  • 一是把 CSS 变为 JS 字符串
  • 二是把 JS 字符串放到 style 标签里

不浮于表面,是 P6 的觉悟
如果你知道的东西跟别人差不多,很难进大公司

很显然我们只要把我们的 loader 拆成两个 loader 就可以了
// css-loader
const cssLoader = code => `
  const code = ${JSON.stringify(code)};
  export default code;
`

module.exports = cssLoader
// style-loader
const styleLoader = code => `
  if (document) {
    const style = document.createElement('style');
    style.innerText = ${JSON.stringify(code)};
    document.head.appendChild(style);
  }
`
module.exports = styleLoader
// bundle_css_loader_1
if (/\.css$/.test(filepath)) {
  code = require('./loader1/css-loader.js')(code)
  code = require('./loader1/style-loader.js')(code)
}

运行发现检查代码发现这是行不通的

经过 style-loader 转换过的代码,并不是我们想要的结果,说明我们的思路存在一些问题

分析

我的代码错在哪儿呢?

  • style-loader 不是转译
  • sass-loader、less-loader 这些 loader 是把代码从一种语言转译为另一种语言
  • 因此将这样的 loader 连接起来不会出问题
  • 但 style-loader 是在插入代码,不是转译,所以需要寻找插入时机和插入位置
  • 插入代码的时机应该是在获取到 css-loader 的结果之后
  • 插入代码的位置应该是在就代码的下面
目前缺乏一种机制可以让我们随意插入代码,而 webpack 是可以的,所以目前来说我们做不到--写不出 style-loader
  • Webpack 官方 style-loader 的思路
  • style-loader 在 pitch 钩子里通过 css-loader 来 require 文件内容
  • 然后在文件内容后面添加 injectStyleIntoStyleTag(content, ...) 代码
  • 接下来看看 webpack 的核心代码在哪儿
  • 并分析

阅读 style-loader 源码理解 webpack

  • 不推荐这么做
  • 直接看源码
  • 应该这么做
  • 不看源码,大胆假设
  • 遇到问题,小心求证
  • 带着问题看源码唯一正确的方式(我认为)
  • 一定要自己先想一次
  • 当你的思路无法满足需求的时候,去看别人的实现
  • 看懂了,就悟了

全部折叠以后,代码结构十分清晰,首先他声明了一个 loaderApi 函数,然后添加了一个 pitch 函数(非常重要

这个 style-loader 非常奇怪,本身竟然是一个空函数,所有的逻辑都在 pitch 函数里面。

由于我折叠了所有代码,逻辑结构得长清楚,首先获取所有的选项,然后验证这些选项,之后声明一些变量,最终会在一个 switch case 负责主要的逻辑

一般来说我们会把代码插入到 style 标签里面

返回的东西中,会判断是否是最新的模块系统,是的话就es6的,否则就用 nodeJS 的模块,所以我们只展开最新的

首先引入一个 api 函数,直接看后面的英文('runtime/injectStylesIntoStyleTag.js'),我们可以很容易发现这个 api 函数就是一个把 style 插入 styleTag 的函数
其次是引入 content,很显然这就是要插入的 style 内容,
我们有了一个插入函数和插入内容,有了这两样东西,那就逻辑上来说就很简单了,直接一结合就是想要的结果

果然,把 content 和 options 传给 api 这个函数,这样之后页面就有想要的样式了,所以这个style-loader 核心代码就三块。

其他代码基本就是在做各种兼容

这个架构看起来好像和我们写的 style-loader 一样的,但是为什么我们就很难实现了,关键在于 webpack 的 style-loader 可以去加载这个request,但是我们没有这个 request 对象,
再来看看我们的问题

我们这个地方能写啥呢?我们拿不到内容,而webpack 比我们多传入了一个 request 对象,它可以拿到代码之外的东西。

webpack 到底有多少个 loader

  • 官方的推荐列表
  • 社区的推荐列表

你可能感兴趣的:(Loader 原理)