Анализ реализации CSS Scoped из исходного кода vue-loader

Vue.js

Эта статья синхронизирована в личном блогеshymean.comвверх, добро пожаловать, чтобы следовать

Хотя Vue был написан уже давно, дляCSS ScopedПринцип тоже в целом понятен, но не обратил внимания на детали его реализации. Я недавно заново изучаю веб-пакет, поэтому я проверилvue-loaderисходный код, кстати, изvue-loaderв исходном кодеCSS Scopedреализация.

В этой статье показаноvue-loaderНекоторые фрагменты исходного кода в , были немного удалены для простоты понимания. Ссылаться на

Связанные концепции

Принцип реализации CSS Scoped

В однофайловых компонентах Vue нам нужно толькоstyleдобавить на этикеткуscopedатрибут, стиль в теге может повлиять на вывод тега HTML текущим шаблоном.Принцип реализации следующий

  • Каждому файлу Vue будет соответствовать уникальный идентификатор, который может быть сгенерирован на основе имени пути к файлу и хэша содержимого.

  • компилироватьtemplateМетка всегда добавляет идентификатор текущего компонента к каждой метке, например<div class="demo"></div>будет скомпилирован в<div class="demo" data-v-27e4e96e></div>

  • компилироватьstyleПри маркировке стиль будет выводиться через селектор атрибутов и селектор комбинаций в соответствии с идентификатором текущего компонента, например.demo{color: red;}будет скомпилирован в.demo[data-v-27e4e96e]{color: red;}

Поняв общий принцип, вы можете подумать, что область действия css должна обрабатывать содержимое шаблона и стиля одновременно, а теперь подытожим проблемы, которые необходимо изучить.

  • на отображаемом HTML-тегеdata-v-xxxКак генерируются свойства
  • Как реализован селектор добавленных атрибутов в коде CSS

resourceQuery

Перед этим нужно разобраться с первым вебпакомRules.resourceQueryэффект. При настройке загрузчика большую часть времени нам нужно только пройтиtestсоответствовать типу файла

{
  test: /\.vue$/,
  loader: 'vue-loader'
}
// 当引入vue后缀文件时,将文件内容传输给vue-loader进行处理
import Foo from './source.vue'

resourceQueryПредоставляет совпадающие пути в соответствии с формой импортированного параметра пути к файлу.

{
  resourceQuery: /shymean=true/,
  loader: path.resolve(__dirname, './test-loader.js')
}
// 当引入文件路径携带query参数匹配时,也将加载该loader
import './test.js?shymean=true'
import Foo from './source.vue?shymean=true'

vue-loaderчерезresourceQueryИ соедините разные параметры запроса, и назначьте каждый тег соответствующему загрузчику для обработки.

loader.pitch

Ссылаться на

Порядок выполнения загрузчиков в webpack выполняется справа налево, напримерloaders:[a, b, c], порядок выполнения загрузчикаc->b->a, а следующий загрузчик получает значение, возвращаемое предыдущим загрузчиком.Этот процесс очень похож на "пузырьковое событие".

Но в некоторых сценариях мы можем захотеть выполнить некоторые методы загрузчика на этапе «захвата», поэтому веб-пакет предоставляетloader.pitchИнтерфейс.

Реальный поток выполнения файла, обрабатываемого несколькими загрузчиками, выглядит следующим образом.

a.pitch -> b.pitch -> c.pitch -> request module -> c -> b -> a

Интерфейсные определения загрузчика и шага примерно следующие:

// loader文件导出的真实接口,content是上一个loader或文件的原始内容
module.exports = function loader(content){
  // 可以访问到在pitch挂载到data上的数据
  console.log(this.data.value) // 100
}
// remainingRequest表示剩余的请求,precedingRequest表示之前的请求
// data是一个上下文对象,在上面的loader方法中可以通过this.data访问到,因此可以在pitch阶段提前挂载一些数据
module.exports.pitch = function pitch(remainingRequest, precedingRequest, data) {
  data.value = 100
}}

Обычно загрузчик возвращает обработанное текстовое содержимое файла на этапе выполнения. Если контент возвращается непосредственно в методе подачи, веб-пакет будет считать, что последующий загрузчик был выполнен (включая этапы подачи и выполнения).

В приведенном выше примере, еслиb.pitchвернулсяresult b, то c уже не выполняется, он сразуresult bперешел к а.

VueLoaderPlugin

Следующий взгляд наvue-loaderПоддерживающие плагины:VueLoaderPlugin, роль плагина:

будетwebpack.configДругие определенные правила копируются и применяются к.vueв соответствующем языковом блоке в файле.

Его общий рабочий процесс выглядит следующим образом

  • Получить проектwebpackнастроенrulesэлемент, затем скопируйтеrules, нести?vue&lang=xx...Зависимая от файла конфигурация параметра запросаxxТот же загрузчик, что и файл суффикса
  • Настройте общедоступный загрузчик для файлов Vue:pitcher
  • Буду[pitchLoder, ...clonedRules, ...rules]как новые правила для webapck
// vue-loader/lib/plugin.js
const rawRules = compiler.options.module.rules // 原始的rules配置信息
const { rules } = new RuleSet(rawRules)

// cloneRule会修改原始rule的resource和resourceQuery配置,携带特殊query的文件路径将被应用对应rule
const clonedRules = rules
      .filter(r => r !== vueRule)
      .map(cloneRule) 
// vue文件公共的loader
const pitcher = {
  loader: require.resolve('./loaders/pitcher'),
  resourceQuery: query => {
    const parsed = qs.parse(query.slice(1))
    return parsed.vue != null
  },
  options: {
    cacheDirectory: vueLoaderUse.options.cacheDirectory,
    cacheIdentifier: vueLoaderUse.options.cacheIdentifier
  }
}
// 更新webpack的rules配置,这样vue单文件中的各个标签可以应用clonedRules相关的配置
compiler.options.module.rules = [
  pitcher,
  ...clonedRules,
  ...rules
]

Следовательно, атрибут lang, выполняемый для каждого тега в однофайловом компоненте vue, также может быть применен к правилу с тем же суффиксом, настроенным в webpack. Этот дизайн может гарантировать, что для каждого тега настроен отдельный загрузчик без вторжения в vue-loader, например

  • можно использоватьpugНапишите шаблон, затем настройтеpug-plain-loader
  • можно использоватьscssилиlessНаписание стиля, а затем настройка соответствующего загрузчика препроцессора

видимый вVueLoaderPluginЕсть две основные вещи, которые нужно сделать, одна из них — зарегистрировать публичныйpitcher, один из них - скопировать webpackrules.

vue-loader

Далее посмотримvue-loaderСписок задач.

pitcher

упоминалось ранее вVueLoaderPlugin, загрузчик будет основан на шаге в шагеquery.typeВставьте соответствующий тегloader

  • Когда шрифт — это стиль, вcss-loaderВставить послеstylePostLoader, гарантияstylePostLoaderВыполнить первым на этапе выполнения
  • Когда тип является шаблоном, вставьтеtemplateLoader
// pitcher.js
module.exports = code => code
module.exports.pitch = function (remainingRequest) {
  if (query.type === `style`) {
    // 会查询cssLoaderIndex并将其放在afterLoaders中
    // loader在execution阶段是从后向前执行的
    const request = genRequest([
      ...afterLoaders,
      stylePostLoaderPath, // 执行lib/loaders/stylePostLoader.js
      ...beforeLoaders
    ])
    return `import mod from ${request}; export default mod; export * from ${request}`
  }
  // 处理模板
  if (query.type === `template`) {
    const preLoaders = loaders.filter(isPreLoader)
    const postLoaders = loaders.filter(isPostLoader)
    const request = genRequest([
      ...cacheLoader,
      ...postLoaders,
      templateLoaderPath + `??vue-loader-options`, // 执行lib/loaders/templateLoader.js
      ...preLoaders
    ])
    return `export * from ${request}`
  }
  // ...
}

из-заloader.pitchОн будет выполняться перед загрузчиком и выполняться в фазе захвата, поэтому в основном выполняются вышеуказанные приготовления: проверкаquery.typeИ напрямую вызвать соответствующий загрузчик

  • type=style,воплощать в жизньstylePostLoader
  • type=template,воплощать в жизньtemplateLoader

Мы изучим конкретные роли этих двух загрузчиков позже.

vueLoader

Далее, чтобы увидетьvue-loaderкоторый выполняет работу при введенииx.vueвремя файла

// vue-loader/lib/index.js 下面source为Vue代码文件原始内容

// 将单个*.vue文件内容解析成一个descriptor对象,也称为SFC(Single-File Components)对象
// descriptor包含template、script、style等标签的属性和内容,方便为每种标签做对应处理
const descriptor = parse({
  source,
  compiler: options.compiler || loadTemplateCompiler(loaderContext),
  filename,
  sourceRoot,
  needMap: sourceMap
})

// 为单文件组件生成唯一哈希id
const id = hash(
  isProduction
  ? (shortFilePath + '\n' + source)
  : shortFilePath
)
// 如果某个style标签包含scoped属性,则需要进行CSS Scoped处理,这也是本章节需要研究的地方
const hasScoped = descriptor.styles.some(s => s.scoped)

иметь дело сtemplate标签, сращиваниеtype=templateРавные параметры запроса

if (descriptor.template) {
  const src = descriptor.template.src || resourcePath
  const idQuery = `&id=${id}`
  // 传入文件id和scoped=true,在为组件的每个HTML标签传入组件id时需要这两个参数
  const scopedQuery = hasScoped ? `&scoped=true` : ``
  const attrsQuery = attrsToQuery(descriptor.template.attrs)
  const query = `?vue&type=template${idQuery}${scopedQuery}${attrsQuery}${inheritQuery}`
  const request = templateRequest = stringifyRequest(src + query)
  // type=template的文件会传给templateLoader处理
  templateImport = `import { render, staticRenderFns } from ${request}`
  
  // 比如,<template lang="pug"></template>标签
  // 将被解析成 import { render, staticRenderFns } from "./source.vue?vue&type=template&id=27e4e96e&lang=pug&"
}

иметь дело сscriptЭтикетка

let scriptImport = `var script = {}`
if (descriptor.script) {
  // vue-loader没有对script做过多的处理
  // 比如vue文件中的<script></script>标签将被解析成
  // import script from "./source.vue?vue&type=script&lang=js&"
  // export * from "./source.vue?vue&type=script&lang=js&"
}

иметь дело сstyleбирки, сшивание каждой биркиtype=styleи т.д. параметры

// 在genStylesCode中,会处理css scoped和css moudle
stylesCode = genStylesCode(
  loaderContext,
  descriptor.styles, 
  id,
  resourcePath,
  stringifyRequest,
  needsHotReload,
  isServer || isShadow // needs explicit injection?
)

// 由于一个vue文件里面可能存在多个style标签,对于每个标签,将调用genStyleRequest生成对应文件的依赖
function genStyleRequest (style, i) {
  const src = style.src || resourcePath
  const attrsQuery = attrsToQuery(style.attrs, 'css')
  const inheritQuery = `&${loaderContext.resourceQuery.slice(1)}`
  const idQuery = style.scoped ? `&id=${id}` : ``
  // type=style将传给stylePostLoader进行处理
  const query = `?vue&type=style&index=${i}${idQuery}${attrsQuery}${inheritQuery}`
  return stringifyRequest(src + query)
}

видимый вvue-loader, в основном сращивая весь файл по соответствующему пути запроса согласно тегу, а затем передавая его в webpack для вызова соответствующего загрузчика по порядку.

templateLoader

Вернемся к первому вопросу, упомянутому в начале: в текущем компоненте, как генерируется хеш-атрибут в каждом визуализируемом HTML-теге.

Мы знаем, что VNode, возвращаемый методом рендеринга компонента, описывает тег HTML и структуру, соответствующую компоненту.Узел DOM, соответствующий тегу HTML, создается из виртуального узла DOM, а Vnode содержит основные свойства, необходимые для рендеринга. узел DOM.

Затем нам нужно только понять процесс присвоения хэш-идентификатора файла компонента на vnode, и следующие проблемы будут решены.

// templateLoader.js
const { compileTemplate } = require('@vue/component-compiler-utils')

module.exports = function (source) {
	const { id } = query
  const options = loaderUtils.getOptions(loaderContext) || {}
  const compiler = options.compiler || require('vue-template-compiler')
  // 可以看见,scopre=true的template的文件会生成一个scopeId
  const compilerOptions = Object.assign({
    outputSourceRange: true
  }, options.compilerOptions, {
    scopeId: query.scoped ? `data-v-${id}` : null,
    comments: query.comments
  })
  // 合并compileTemplate最终参数,传入compilerOptions和compiler
  const finalOptions = {source, filename: this.resourcePath, compiler,compilerOptions}
  const compiled = compileTemplate(finalOptions)
  
  const { code } = compiled

  // finish with ESM exports
  return code + `\nexport { render, staticRenderFns }`
}

оcompileTemplateреализации, нам не нужно заботиться о его деталях, он в основном вызывает параметры конфигурации внутриcompilerметод компиляции

function actuallyCompile(options) {
	const compile = optimizeSSR && compiler.ssrCompile ? compiler.ssrCompile : compiler.compile
  const { render, staticRenderFns, tips, errors } = compile(source, finalCompilerOptions);
  // ...
}

Вы можете видеть в исходном коде Vue,templateсвойства будут проходитьcompileToFunctionsСкомпилировано в метод рендеринга; вvue-loader, этот шаг может быть выполненvue-template-compilerОбрабатывается заранее на этапе упаковки.

vue-template-compilerэто сVueПакет, выпущенный с исходным кодом, когда они используются одновременно, необходимо убедиться, что их номера версий совпадают, иначе будет выдано сообщение об ошибке. так,compiler.compileНа самом деле он есть в исходном коде Vue.vue/src/compiler/index.jsизbaseCompileметод, следуйте исходному коду и отключите его, вы можете найти

// elementToOpenTagSegments.js
// 对于单个标签的属性,将拆分成一个segments
function elementToOpenTagSegments (el, state): Array<StringSegment> {
  applyModelTransform(el, state)
  let binding
  const segments = [{ type: RAW, value: `<${el.tag}` }]
  // ... 处理attrs、domProps、v-bind、style、等属性
  
  // _scopedId
  if (state.options.scopeId) {
    segments.push({ type: RAW, value: ` ${state.options.scopeId}` })
  }
  segments.push({ type: RAW, value: `>` })
  return segments
}

с предыдущим<div class="demo"></div>Например, разобранныйsegmentsдля

[
    { type: RAW, value: '<div' },
    { type: RAW, value: 'class=demo' },
    { type: RAW, value: 'data-v-27e4e96e' }, // 传入的scopeId
    { type: RAW, value: '>' },
]

До сих пор мы знаем, чтоtemplateLoader, он будет соединять один компонент файла в соответствии с идентификаторомscopeId, и в качествеcompilerOptionsПереданный в компилятор, он анализируется в свойствах конфигурации vnode, а затем вызывается при выполнении функции рендеринга.createElement, как исходное свойство vnode, отображаемое на узле DOM.

stylePostLoader

существуетstylePostLoader, все, что нужно сделать, это добавить лимит комбинации селекторов атрибутов ко всем селекторам,

const { compileStyle } = require('@vue/component-compiler-utils')
module.exports = function (source, inMap) {
  const query = qs.parse(this.resourceQuery.slice(1))
  const { code, map, errors } = compileStyle({
    source,
    filename: this.resourcePath,
    id: `data-v-${query.id}`, // 同一个单页面组件中的style,与templateLoader中的scopeId保持一致
    map: inMap,
    scoped: !!query.scoped,
    trim: true
  })
	this.callback(null, code, map)
}

нам нужно знатьcompileStyleлогика

// @vue/component-compiler-utils/compileStyle.ts
import scopedPlugin from './stylePlugins/scoped'
function doCompileStyle(options) {
  const { filename, id, scoped = true, trim = true, preprocessLang, postcssOptions, postcssPlugins } = options;
  if (scoped) {
    plugins.push(scopedPlugin(id));
  }
  const postCSSOptions = Object.assign({}, postcssOptions, { to: filename, from: filename });
	// 省略了相关判断
  let result = postcss(plugins).process(source, postCSSOptions);
}

Наконец, давайте разбиратьсяscopedPluginреализация,

export default postcss.plugin('add-id', (options: any) => (root: Root) => {
  const id: string = options
  const keyframes = Object.create(null)
  root.each(function rewriteSelector(node: any) {
    node.selector = selectorParser((selectors: any) => {
      selectors.each((selector: any) => {
        let node: any = null
        // 处理 '>>>' 、 '/deep/'、::v-deep、pseudo等特殊选择器时,将不会执行下面添加属性选择器的逻辑

        // 为当前选择器添加一个属性选择器[id],id即为传入的scopeId
        selector.insertAfter(
          node,
          selectorParser.attribute({
            attribute: id
          })
        )
      })
    }).processSync(node.selector)
  })
})

Так как яPostCSSЯ не очень разбираюсь в разработке плагинов, поэтому могу только примерно разобраться здесь, полистать документацию, а соответствующий API может сослаться наWriting a PostCSS Plugin.

Пока что мы знаем ответ на второй вопрос:selector.insertAfterСелектор атрибута добавляется для каждого селектора в текущих стилях, и его значением является входящий scopeId. Поскольку в узле DOM, отображаемом текущим компонентом, существуют только те же атрибуты, это достигаетсяcss scopedЭффект.

резюме

вернуться и разобратьсяvue-loaderрабочий процесс

  • Для начала нужно зарегистрироваться в конфигурации webpackVueLoaderPlugin
    • В плагине элемент правил в текущей конфигурации веб-пакета проекта будет скопирован, когда путь к ресурсу содержитquery.langпройти, когдаresourceQueryПри сопоставлении тех же правил и выполнении соответствующего загрузчика
    • Вставьте общедоступный загрузчик иpitchэтап в соответствии сquery.typeВставьте соответствующий пользовательский загрузчик
  • После завершения подготовки при загрузке*.vueбудет вызван, когдаvue-loader,
    • Файл компонента с одной страницей будет разобран наdescriptorобъект, в том числеtemplate,script,stylesАтрибуты соответствуют каждой метке,
    • Для каждого тега он будет склеен в соответствии с атрибутом тегаsrc?vue&queryсправочный код, гдеsrcЭто одностраничный путь к компоненту, а запрос является параметром некоторых функций, наиболее важными из которых являютсяlang,typeа такжеscoped
      • если он содержитlangАтрибут соответствует тому же суффиксу, который соответствует правилам приложения и загрузчикам.
      • согласно сtypeЗапустите соответствующий пользовательский загрузчик,templateбудет выполнятьtemplateLoader,styleбудет выполнятьstylePostLoader
  • существуетtemplateLoader, пройдешьvue-template-compilerПреобразование шаблона в функцию рендеринга, в процессе,
    • будет передан вscopeIdдобавить к каждой меткеsegments, и, наконец, передается как свойство конфигурации vnode вcreateElemenetметод,
    • Когда функция рендеринга вызывается и страница визуализируется, онаscopeIdАтрибуты отображаются на странице как необработанные атрибуты.
  • существуетstylePostLoader, проанализируйте содержимое тега стиля с помощью PostCSS и передайтеscopedPluginДобавить по одному к каждому селектору[scopeId]селектор атрибутов для

Из-за необходимости поддержки исходного кода Vue (vue-template-compilerКомпилятор), CSS Scoped можно рассматривать как собственное решение Vue для обработки собственной глобальной области действия CSS. Помимо css, vue также поддерживаетcss module, я планирую сравнить их вместе в следующем посте блога о написании CSS в React.

резюме

Недавно я работал над проектом React и пробовал несколько способов написания CSS в React, в том числеCSS Module,Style ComponentИ так далее, это кажется более сложным. Напротив, писать CSS в одностраничном компоненте на Vue намного удобнее.

В этой статье в основном анализируется уровень исходного кодаVue-loader, разобрался с его принципом работы, и чувствую, что многого добился

  • в вебпакеRules.resourceQueryа такжеpitch loaderиспользование
  • Одностраничный файл Vuecss scopedПринцип реализации
  • Роль плагинов PostCSS

Хоть я и пользуюсь webpack и PostCSS, но пока только на стадии неохоты, например никогда даже не думал писать плагин для PostCSS. Хотя в настоящее время в большинстве проектов используются упакованные леса, для этих основ необходимо понимать их реализацию.