上篇梳理了 vue-loader 的基本流程,结尾留了个小问题,组件 CSS scoped 如何实现,这篇了解下大致逻辑
实现原理
scoped 的原理很简单,通过CSS的属性选择器实现。组件内的 style 添加了 scoped 属性后,编译后的CSS都会带上一个 data-v-id 的属性,同时组件内的dom也会带有该属性,每个组件id唯一,实现CSS作用域。
模板样式编译
vue-loader通过 postcss 实现添加属性选择器到样式。原理依然是通过 postcss parse 出的样式AST,遍历每个样式节点,依次添加属性。
执行pitcher loader 的pitch 函数中, 当 type === 'style' 时,会注入style-post-loader。源码中也给出了注释,在 css-loader 前注入 style-post-loader 实现 scoped css
export function pitch() {
// ...
// Inject style-post-loader before css-loader for scoped CSS and trimming
if (query.type === `style`) {
const cssLoaderIndex = loaders.findIndex(isCSSLoader);
if (cssLoaderIndex > -1) {
// if inlined, ignore any loaders after css-loader and replace w/ inline
// loader
const afterLoaders = query.inline != null
? [styleInlineLoaderPath]
: loaders.slice(0, cssLoaderIndex + 1);
const beforeLoaders = loaders.slice(cssLoaderIndex + 1);
return genProxyModule([...afterLoaders, stylePostLoaderPath, ...beforeLoaders], context, !!query.module || query.inline != null);
}
}
// ....
}
const StylePostLoader = function (source, inMap) {
const query = qs.parse(this.resourceQuery.slice(1));
const { code, map, errors } = (0, compiler_sfc_1.compileStyle)({
source: source,
filename: this.resourcePath,
id: `data-v-${query.id}`,
map: inMap,
scoped: !!query.scoped,
trim: true,
isProd: this.mode === 'production' || process.env.NODE_ENV === 'production',
});
if (errors.length) {
this.callback(errors[0]);
}
else {
this.callback(null, code, map);
}
};
style-post-loader 具体逻辑在compiler-sfc中,之前 compiler-sfc 是一个独立的依赖,需要手动安装和vue版本对应的包,因为一些npm机制的原因偶尔会由于版本问题导致一些编译后的bug,现在已经集成到了vue包中。
function doCompileStyle(options) {
const { filename, id, scoped = false, trim = true, isProd = false, modules = false, modulesOptions = {}, preprocessLang, postcssOptions, postcssPlugins } = options;
// ....
const source = preProcessedSource ? preProcessedSource.code : options.source;
const shortId = id.replace(/^data-v-/, '');
const longId = `data-v-${shortId}`;
const plugins = (postcssPlugins || []).slice();
plugins.unshift(cssVarsPlugin({ id: shortId, isProd }));
if (trim) {
plugins.push(trimPlugin());
}
if (scoped) {
plugins.push(scopedPlugin(longId));
}
let cssModules;
// ....
let result;
let code;
let outMap;
// stylus output include plain css. so need remove the repeat item
const dependencies = new Set(preProcessedSource ? preProcessedSource.dependencies : []);
// sass has filename self when provided filename option
dependencies.delete(filename);
const errors = [];
if (preProcessedSource && preProcessedSource.errors.length) {
errors.push(...preProcessedSource.errors);
}
const recordPlainCssDependencies = (messages) => {
messages.forEach(msg => {
if (msg.type === 'dependency') {
// postcss output path is absolute position path
dependencies.add(msg.file);
}
});
return dependencies;
};
try {
result = _postcss__default(plugins).process(source, postCSSOptions);
// In async mode, return a promise.
if (options.isAsync) {
return result
.then(result => ({
code: result.css || '',
map: result.map && result.map.toJSON(),
errors,
modules: cssModules,
rawResult: result,
dependencies: recordPlainCssDependencies(result.messages)
}))
.catch(error => ({
code: '',
map: undefined,
errors: [...errors, error],
rawResult: undefined,
dependencies
}));
}
recordPlainCssDependencies(result.messages);
// force synchronous transform (we know we only have sync plugins)
code = result.css;
outMap = result.map;
}
catch (e) {
errors.push(e);
}
return {
code: code || ``,
map: outMap && outMap.toJSON(),
errors,
rawResult: result,
dependencies
};
}
该loader实际上就是根据各种条件,添加不同的plugin,最终传入到postcss-loader中进行source处理,最终返回处理后的code。
scoped的实现,依赖 scopedPlugin,当传入的options 有scoped属性时,会在plugins数组中添加该plugin
const scopedPlugin = (id = '') => {
debugger
const keyframes = Object.create(null);
const shortId = id.replace(/^data-v-/, '');
return {
postcssPlugin: 'vue-sfc-scoped',
Rule(rule) {
processRule(id, rule);
},
AtRule(node) {
if (/-?keyframes$/.test(node.name) &&
!node.params.endsWith(`-${shortId}`)) {
// register keyframes
keyframes[node.params] = node.params = node.params + '-' + shortId;
}
},
OnceExit(root) {
if (Object.keys(keyframes).length) {
// If keyframes are found in this <style>, find and rewrite animation names
// in declarations.
// Caveat: this only works for keyframes and animation rules in the same
// <style> element.
// individual animation-name declaration
root.walkDecls(decl => {
if (animationNameRE.test(decl.prop)) {
decl.value = decl.value
.split(',')
.map(v => keyframes[v.trim()] || v.trim())
.join(',');
}
// shorthand
if (animationRE.test(decl.prop)) {
decl.value = decl.value
.split(',')
.map(v => {
const vals = v.trim().split(/\s+/);
const i = vals.findIndex(val => keyframes[val]);
if (i !== -1) {
vals.splice(i, 1, keyframes[vals[i]]);
return vals.join(' ');
}
else {
return v;
}
})
.join(',');
}
});
}
}
};
};
配置了一些postcss提供的处理不同节点时的钩子函数,核心的部分在 Rule(表示一个 CSS 规则:一个选择器后跟一个声明块)中,当遇到选择器节点时执行 调用processRule 处理节点
function processRule(id, rule) {
if (processedRules.has(rule) ||
(rule.parent &&
rule.parent.type === 'atrule' &&
/-?keyframes$/.test(rule.parent.name))) {
return;
}
processedRules.add(rule);
rule.selector = selectorParser(selectorRoot => {
selectorRoot.each(selector => {
rewriteSelector(id, selector, selectorRoot);
});
}).processSync(rule.selector);
}
processRule 方法核心逻辑是重写 rule 的selector,也就是当前节点的选择器,为选择器添加 [data-v-id] 属性
function rewriteSelector(id, selector, selectorRoot, slotted = false) {
let node = null;
let shouldInject = true;
// find the last child node to insert attribute selector
selector.each(n => {
// DEPRECATED ">>>" and "/deep/" combinator
if (n.type === 'combinator' &&
(n.value === '>>>' || n.value === '/deep/')) {
n.value = ' ';
n.spaces.before = n.spaces.after = '';
warn(`the >>> and /deep/ combinators have been deprecated. ` +
`Use :deep() instead.`);
return false;
}
if (n.type === 'pseudo') {
const { value } = n;
// deep: inject [id] attribute at the node before the ::v-deep
// combinator.
if (value === ':deep' || value === '::v-deep') {
if (n.nodes.length) {
// .foo ::v-deep(.bar) -> .foo[xxxxxxx] .bar
// replace the current node with ::v-deep's inner selector
let last = n;
n.nodes[0].each(ss => {
selector.insertAfter(last, ss);
last = ss;
});
// insert a space combinator before if it doesn't already have one
const prev = selector.at(selector.index(n) - 1);
if (!prev || !isSpaceCombinator(prev)) {
selector.insertAfter(n, selectorParser.combinator({
value: ' '
}));
}
selector.removeChild(n);
}
else {
// DEPRECATED usage
// .foo ::v-deep .bar -> .foo[xxxxxxx] .bar
warn(`::v-deep usage as a combinator has ` +
`been deprecated. Use :deep(<inner-selector>) instead.`);
const prev = selector.at(selector.index(n) - 1);
if (prev && isSpaceCombinator(prev)) {
selector.removeChild(prev);
}
selector.removeChild(n);
}
return false;
}
// slot: use selector inside `::v-slotted` and inject [id + '-s']
// instead.
// ::v-slotted(.foo) -> .foo[xxxxxxx-s]
if (value === ':slotted' || value === '::v-slotted') {
rewriteSelector(id, n.nodes[0], selectorRoot, true /* slotted */);
let last = n;
n.nodes[0].each(ss => {
selector.insertAfter(last, ss);
last = ss;
});
// selector.insertAfter(n, n.nodes[0])
selector.removeChild(n);
// since slotted attribute already scopes the selector there's no
// need for the non-slot attribute.
shouldInject = false;
return false;
}
// global: replace with inner selector and do not inject [id].
// ::v-global(.foo) -> .foo
if (value === ':global' || value === '::v-global') {
selectorRoot.insertAfter(selector, n.nodes[0]);
selectorRoot.removeChild(selector);
return false;
}
}
if (n.type !== 'pseudo' && n.type !== 'combinator') {
node = n;
}
});
if (node) {
node.spaces.after = '';
}
else {
// For deep selectors & standalone pseudo selectors,
// the attribute selectors are prepended rather than appended.
// So all leading spaces must be eliminated to avoid problems.
selector.first.spaces.before = '';
}
if (shouldInject) {
const idToAdd = slotted ? id + '-s' : id;
selector.insertAfter(
// If node is null it means we need to inject [id] at the start
// insertAfter can handle `null` here
node, selectorParser.attribute({
attribute: idToAdd,
value: idToAdd,
raws: {},
quoteMark: `"`
}));
}
}
rewriteSelector 方法主要用来实现 样式穿透及添加一个 [data-v-id] 的attribute 到 selector 的子节点。样式穿透部分源码中注释也很清楚,实现 scoped 的核心是 selector.insertAfter,给 selector 插入一个新的 attribute 节点
遍历处理完成后继续调用 processSync 方法处理当前 rule 节点的 selector,将 attribute 添加到选择器,最终返回添加了 [data-v-id] 的选择器 string
rule.selector = selectorParser(selectorRoot => {
selectorRoot.each(selector => {
rewriteSelector(id, selector, selectorRoot)
})
}).processSync(rule.selector)