虽然写了很长一段时间的Vue了,对于CSS Scoped的原理也大致了解,但一直未曾关注过其实现细节。最近在重新学习webpack,因此查看了vue-loader源码,顺便从vue-loader的源码中整理CSS Scoped的实现。

本文展示了vue-loader中的一些源码片段,为了便于理解,稍作删减。参考

Vue CSS SCOPED实现原理
Vue loader官方文档

相关概念

CSS Scoped的实现原理

在Vue单文件组件中,我们只需要在style标签上加上scoped属性,就可以实现标签内的样式在当前模板输出的HTML标签上生效,其实现原理如下

  • 每个Vue文件都将对应一个唯一的id,该id可以根据文件路径名和内容hash生成
  • 编译template标签时时为每个标签添加了当前组件的id,如<div class="demo"></div>会被编译成<div class="demo" data-v-27e4e96e></div>
  • 编译style标签时,会根据当前组件的id通过属性选择器和组合选择器输出样式,如.demo{color: red;}会被编译成.demo[data-v-27e4e96e]{color: red;}

了解了大致原理,可以想到css scoped应该需要同时处理template和style的内容,现在归纳需要探寻的问题

  • 渲染的HTML标签上的data-v-xxx属性是如何生成的
  • CSS代码中的添加的属性选择器是如何实现的

resourceQuery

在此之前,需要了解首一下webpack中Rules.resourceQuery的作用。在配置loader时,大部分时候我们只需要通过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"_blank" href="https://webpack.js.org/api/loaders/#pitching-loader" rel="external nofollow" >pitching-loader官方文档 
webpack的pitching loader

webpack中loaders的执行顺序是从右到左执行的,如loaders:[a, b, c],loader的执行顺序是c->b->a,且下一个loader接收到的是上一个loader的返回值,这个过程跟"事件冒泡"很像。

但是在某些场景下,我们可能希望在"捕获"阶段就执行loader的一些方法,因此webpack提供了loader.pitch的接口。
一个文件被多个loader处理的真实执行流程,如下所示

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

loader和pitch的接口定义大概如下所示

// 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
}}

正常情况下,一个loader在execution阶段会返回经过处理后的文件文本内容。如果在pitch方法中直接返回了内容,则webpack会视为后面的loader已经执行完毕(包括pitch和execution阶段)。

在上面的例子中,如果b.pitch返回了result b,则不再执行c,则是直接将result b传给了a。

VueLoaderPlugin

接下来看看与vue-loader配套的插件:VueLoaderPlugin,该插件的作用是:

将在webpack.config定义过的其它规则复制并应用到 .vue 文件里相应语言的块中。

其大致工作流程如下所示

  • 获取项目webpack配置的rules项,然后复制rules,为携带了"htmlcode">
    // 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
    ]
    
    

    因此,为vue单文件组件中每个标签执行的lang属性,也可以应用在webpack配置同样后缀的rule。这种设计就可以保证在不侵入vue-loader的情况下,为每个标签配置独立的loader,如

    1. 可以使用pug编写template,然后配置pug-plain-loader
    2. 可以使用scss或less编写style,然后配置相关预处理器loader

    可见在VueLoaderPlugin主要做的两件事,一个是注册公共的pitcher,一个是复制webpack的rules。

    vue-loader

    接下来我们看看vue-loader做的事情。

    pitcher

    前面提到在VueLoaderPlugin中,该loader在pitch中会根据query.type注入处理对应标签的loader

    • 当type为style时,在css-loader后插入stylePostLoader,保证stylePostLoader在execution阶段先执行
    • 当type为template时,插入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 + `"htmlcode">
    
    // 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
     "htmlcode">
    
    if (descriptor.template) {
     const src = descriptor.template.src || resourcePath
     const idQuery = `&id=${id}`
     // 传入文件id和scoped=true,在为组件的每个HTML标签传入组件id时需要这两个参数
     const scopedQuery = hasScoped "pug"></template>标签
     // 将被解析成 import { render, staticRenderFns } from "./source.vue"
    }
    

    处理script标签

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

    处理style标签,为每个标签拼接type=style等参数

    // 在genStylesCode中,会处理css scoped和css moudle
    stylesCode = genStylesCode(
     loaderContext,
     descriptor.styles, 
     id,
     resourcePath,
     stringifyRequest,
     needsHotReload,
     isServer || isShadow // needs explicit injection"htmlcode">
    
    // 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 "htmlcode">
    
    function actuallyCompile(options) {
     const compile = optimizeSSR && compiler.ssrCompile "htmlcode">
    
    // 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中,会根据单文件组件的id,拼接一个scopeId,并作为compilerOptions传入编译器中,被解析成vnode的配置属性,然后在render函数执行时调用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为当前styles下的每一个选择器添加了属性选择器,其值即为传入的scopeId。由于只有当前组件渲染的DOM节点上上面存在相同的属性,从而就实现了css scoped的效果。

    小结

    回过头来整理一下vue-loader的工作流程

    首先需要在webpack配置中注册VueLoaderPlugin

    1. 在插件中,会复制当前项目webpack配置中的rules项,当资源路径包含query.lang时通过resourceQuery匹配相同的rules并执行对应loader时
    2. 插入一个公共的loader,并在pitch阶段根据query.type插入对应的自定义loader

    准备工作完成后,当加载*.vue时会调用vue-loader,

    • 一个单页面组件文件会被解析成一个descriptor对象,包含template、script、styles等属性对应各个标签,
    • 对于每个标签,会根据标签属性拼接src"color: #ff0000">小结

      最近一直在写React的项目,尝试了好几种在React中编写CSS的方式,包括CSS Module、Style Component等方式,感觉都比较繁琐。相比而言,在Vue中单页面组件中写CSS要方便很多。

      本文主要从源码层面分析了Vue-loader,整理了其工作原理,感觉收获颇丰

      1. webpack中Rules.resourceQuery和pitch loader的使用
      2. Vue单页面文件中css scoped的实现原理
      3. PostCSS插件的作用

      虽然一直在使用webpack和PostCSS,但也仅限于勉强会用的阶段,比如我甚至从来没有过编写一个PostCSS插件的想法。尽管目前大部分项目都使用了封装好的脚手架,但对于这些基础知识,还是很有必要去了解其实现的。

      以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。

    广告合作:本站广告合作请联系QQ:858582 申请时备注:广告合作(否则不回)
    免责声明:本站资源来自互联网收集,仅供用于学习和交流,请遵循相关法律法规,本站一切资源不代表本站立场,如有侵权、后门、不妥请联系本站删除!

稳了!魔兽国服回归的3条重磅消息!官宣时间再确认!

昨天有一位朋友在大神群里分享,自己亚服账号被封号之后居然弹出了国服的封号信息对话框。

这里面让他访问的是一个国服的战网网址,com.cn和后面的zh都非常明白地表明这就是国服战网。

而他在复制这个网址并且进行登录之后,确实是网易的网址,也就是我们熟悉的停服之后国服发布的暴雪游戏产品运营到期开放退款的说明。这是一件比较奇怪的事情,因为以前都没有出现这样的情况,现在突然提示跳转到国服战网的网址,是不是说明了简体中文客户端已经开始进行更新了呢?