Vue的样式隔离是怎么实现的?(vue绑定样式)
使用vue
的时候,在<style>
标签加上scoped
就可以实现样式隔离,只会作用在当前组件。
<template> <div class="about"> <h1>This is an about page</h1> </div> </template> <style scoped> .about { color: red } </style> 复制代码
chrome上面:
我们通过调试源码来分析下这个是怎么实现的。
vue-loader源码流程分析
先上一张vue-loader
工作流程图,先混个印象,回头再细看。
查看配置
不知道你是否看过vue-cli
默认的webpack
配置, 可以通过vue inspect > config.js
命令把项目的配置通过文件输出,@vue/cli 4.5.10
默认配置如下。只关注对.vue
文件的配置
module.exports = { module: { rules: [ /* config.module.rule('vue') */ { test: /\.vue$/, use: [ { loader: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\cache-loader\\dist\\cjs.js', options: { // 之前打包结果的保存路径 cacheDirectory: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\.cache\\vue-loader', cacheIdentifier: 'ffa56dac' } }, { loader: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\vue-loader\\lib\\index.js', options: { compilerOptions: { whitespace: 'condense' }, cacheDirectory: 'D:\\work_space\\qiankun-example\\sub-vue\\node_modules\\.cache\\vue-loader', cacheIdentifier: 'ffa56dac' } } ] }, ] }, plugins: [ /* config.plugin('vue-loader') */ new VueLoaderPlugin(), // .... ], } 复制代码
通过配置看出两点
vue-loader
之后还有cache-loader
,这个loader主要作用是缓存上次编译的结果,针对未被修改的文件,可以直接拉去缓存,大大缩短编译时间,也是性能提升最明显的一个loader。处理
vue
文件同时需要vue-loader
和VueLoaderPlugin
插件,缺一不可
VueLoaderPlugin
这个插件主要作用有以下几点
给
webpack中Compiler
对象添加一个标识,vue-loader
在执行的时候会监测是否注册了VueLoaderPlugin
检测
compiler
的rules
中是否存在处理.vue
文件的loader,没有抛出错误添加一个
RuleSet
格式的全局pitcher-loader
, 如import Foo from './foo.css?vue
会匹配这个loader,带有vue
参数复制一份
Ruleset
格式的rules
覆盖之前的rules
源码片段分析:
sub-vue\node_modules\vue-loader\lib\plugin-webpack4.js
:
// 添加一个标记,用于检测是是否已经配置了vueLoaderPlugin if (compiler.hooks) { // webpack 4 compiler.hooks.compilation.tap(id, compilation => { const normalModuleLoader = compilation.hooks.normalModuleLoader normalModuleLoader.tap(id, loaderContext => { loaderContext[NS] = true }) }) } // 前置全局的pitcher-loader,用于处理vue中的template、script、style块 const pitcher = { loader: require.resolve('./loaders/pitcher'), resourceQuery: query => { // 这个loader只有这种格式的请求才会被命中,query中存在vue字段 // import script from \"./App.vue?vue&type=script&lang=js const parsed = qs.parse(query.slice(1)) return parsed.vue != null }, options: { // 缓存存放的路径 // sub-vue\node_modules\.cache\vue-loader cacheDirectory: vueLoaderUse.options.cacheDirectory, } } // 使用ruleset格式的rules替换原来的rule compiler.options.module.rules = [ pitcher, ...clonedRules, ...rules ] } 复制代码
VueLoader
配置完插件,npm run build
后进入webpack
的buildModule
阶段,此时会使用loaderRunner
解析源文件。
下面两种请求都会经过vue-loader
,区别就是是否带有resourceQuery
import App from './App.vue
import * from "./App.vue?vue&type=template&id=1511d40d&
import App from './App.vue
解析
resourceQuery
,拿到loader的query参数,上面的解析结果是{}
通过
vue-template-compiler
的parseComponent
把.vue
SFC解析为三大块template、script、style
,script
还会生成mapping
传递给后面的loader,这里用到了LRU
缓存算法,因为后续相同的源文件还会继续调用parse
方法,需要缓存防止性能浪费,同时还使用hash-sum
模块计算出源文件对应的唯一的cacheKey
,如果源文件被修改,缓存自然自然会失效。
// sub-vue\node_modules\@vue\component-compiler-utils\dist\parse.js const cache = new (require('lru-cache'))(100); function parse(options) { // ...忽略 // 缓存编译结果 const cacheKey = hash(filename + source + JSON.stringify(compilerParseOptions)); let output = cache.get(cacheKey); if (output) return output; output = compiler.parseComponent(source, compilerParseOptions); 复制代码
因为
resourceQuery
为空不会走这里的逻辑
// sub-vue\node_modules\vue-loader\lib\index.js // if the query has a type field, this is a language block request // e.g. foo.vue?type=template&id=xxxxx // and we will return early if (incomingQuery.type) { return selectBlock( descriptor, loaderContext, incomingQuery, !!options.appendExtension ) } 复制代码
计算
css-scoped id
, 用于css
隔离,id就是类似data-v-039c5b43
中的039c5b43
// sub-vue\node_modules\vue-loader\lib\index.js const id = hash( isProduction ? (shortFilePath + '\n' + source.replace(/\r\n/g, '\n')) : shortFilePath ) 复制代码
组装这次要返回的字符串,对template、script、style分别转换成带参数的请求,主要是加上
?vue&type=template&id=1511d40d&
这样的resourceQuery
。 如果是开发模式还会带上vue-loader
自己实现模块热替换接口,同时还会带上运行时的一段normalize component
函数用于格式化组件暴露的值。
此时请求
import App from './App.vue
,vue-loader
返回的JSON字符串是这样的。它把一个请求重新拆分为三个请求,通过type=
区分
import * from "./App.vue?vue&type=template&id=1511d40d&
这个模块请求还是通过vue-loader
解析,但是处理顺序会有区别。上面请求带有vue
命中注册过的全局pitcher-loader
,这个是一种特殊的loader,普通loader都是按照倒序执行的。但是如果存在pitcher-loader顺序会有变化,这里稍微说下区别
假如配置一个处理css的rule如下
{ test: /\.css$/, use: [normalLoader1, normalLoader2, normalLoader3, normalLoader4] } 复制代码
那么就是倒序串联执行
如果遇到pitcher-loader
{ test: /\.css$/, use: [normalLoader1, pitcherLoader, normalLoader3, normalLoader4] } 复制代码
从pitcher-loader先执行,跳过normalLoader3, normalLoader4,pticher-loader函数入参不再是源文件buffer,而是剩余请求normalLoader3, normalLoader4
,把然后再到normalLoader1。具体的实现可以查看LoaderRunner
源码sub-vue\node_modules\loader-runner\lib\LoaderRunner.js
。一般来说,pitcher-loader后面不会有normalLoader,常见的pticher-loader就是开发模式使用的style-loader
,把css通过style标签插入到head。
import * from "./App.vue?vue&type=template&id=1511d40d&
时会发生下面这些事情
先经过pitcher-loader处理,pitcher-loader会以下处理
-!../node_modules/cache-loader/dist/cjs.js?{\\"cacheDirectory\\":\\"node_modules/.cache/vue-loader\\",\\"cacheIdentifier\\":\\"7e12f88b-vue-loader-template\\"} !../node_modules/vue-loader/lib/loaders/templateLoader.js??vue-loader-options!../node_modules/cache-loader/dist/cjs.js??ref--0-0 !../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=template&id=1511d40d& 复制代码
vue中template处理流程:
过滤loader,移除eslint-loader和自身
匹配当前的type, type为template,则会匹配template逻辑部分,重新序列化请求,加上cache-loader和templateLoader, 上面的请求被转为了,会经过三个inline-loader处理
其中-!, !, !!
符号意思参考wepack-inline-loader
给template标签元素加上_scoped
转换后的行内请求首先回到
vue-loader
处理, 之前已经计算过descriptor
结果,缓存直接返回。
进入selectBlock, slectBlock会分别处理不同类型的type,
loaderContext.callback
可以知道vue-loader
是一个异步loader,templateLoader
会等待它完成后再调用。
template-loader
会接收vue-loader
的处理结果,他的作用是vue-template-compiler
经过parser、optimization、generate
阶段会返回render
函数,和静态节点staticRender
你可能发现scopedId
也传给了compiler
,是不是生成render
函数已经带上了data-v-1511d40d
,其实并没有。出于打包后体积考虑,compiler没有再render函数就添加上{attrs: {'data-v-1511d40d'}}
,而是把_scopedId
运行时patch
阶段再加上。
回到上面说的normalize component
,当代码浏览器运行的时候,会发现$options
有个_scopedId
, 这个会在首次patch
时候动态设置上。
自此我们已经知道标签上的_scoped
id是怎么来的,那么css属性选择器是怎么加上的呢?
给css样式加上_scopedId
上面的分析都是针对template
的,我们还存在style
要处理,同样的pitch-loader
再处理src\\App.vue?vue&type=style&index=0&lang=css&
, 把请求转换为行内请求,如下
"-!../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../node_modules/css-loader/dist/cjs.js??ref--6-oneOf-1-1 !../node_modules/vue-loader/lib/loaders/stylePostLoader.js !../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-2 !../node_modules/cache-loader/dist/cjs.js??ref--0-0 !../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&lang=css" 复制代码
npm run build
模式,style会经过5个loader。 再vue-loader
处理的时候,跟处理template流程一样,只是单纯返回缓存。而_scpoedId
就是在style-post-loader
处理时加上的
// sub-vue\node_modules\vue-loader\lib\loaders\stylePostLoader.js const qs = require('querystring') const { compileStyle } = require('@vue/component-compiler-utils') // 这是一个后置loader用来转换css, 用于加上css-scoped module.exports = function (source, inMap) { const query = qs.parse(this.resourceQuery.slice(1)) // 通过postcss解析器重新组装css const { code, map, errors } = compileStyle({ source, filename: this.resourcePath, id: `data-v-${query.id}`, map: inMap, scoped: !!query.scoped, trim: true }) } 复制代码
compileStyle
方法时通过postcss
能力返回新的css,postcss跟babel-loader有点类似,它可以处理一些css语法,如变量,自动加上浏览器前缀之类。它也是可以把css解析为css-ast
,然后通过插件机制对不同的节点类型进行编辑,通过generate
重新生成新的css。而vue则通过自定义一个postcss-plugin来给css加上_scopedId
和实现/deep/
能力
一个postcss-plugin插件格式是这样的
module.exports = (opts = {}) => { return { postcssPlugin: '插件名字', prepare (result) { // 这里可以放一些公共的逻辑 return { Declaration (node) {}, Rule (node) {}, AtRule (node) {} } } } } 复制代码
css 的 AST 比 js 的简单多了,主要有这么几种:
@符号样式开头的,比如@meida,媒体查询
Rule,就是具体的css类名
Dec就是具体的样式
padding: 5px
这种
那么只要写一个插件,遍历这几种类型,修改css-ast,加上_scopedId
即可,实现并不难。 具体可以查看\node_modules\@vue\component-compiler-utils\lib\stylePlugins\scoped.ts
文件的具体实现。
至此css样式隔离实现。最近看的京东那套微前端实现,它的css隔离也是类似的方式,只是不依赖postcss
,自己撸了一个cssParser
,通过正则暴力替换。
总结
因为vue-loader
跟webpack是强相关的,因此需要了解一些webpack
流程的知识,不然实在难啃。话说你们的vite
上生产了吗?
作者:狗胜
链接:https://juejin.cn/post/7057854547229671432