整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

dom-to-image库是如何将html转换成图片的

om-to-image库可以帮你把dom节点转换为图片,它的核心原理很简单,就是利用svg的foreignObject标签能嵌入html的特性,然后通过img标签加载svg,最后再通过canvas绘制img实现导出,好了,本文到此结束。

另一个知名的html2canvas库其实也支持这种方式。

虽然原理很简单,但是dom-to-image毕竟也有1000多行代码,所以我很好奇它具体都做了哪些事情,本文就来详细剖析一下,需要说明的是dom-to-image库已经六七年前没有更新了,可能有点过时,所以我们要看的是基于它修改的dom-to-image-more库,这个库修复了一些bug,以及增加了一些特性,接下来我们就来详细了解一下。

将节点转换成图片

我们用的最多的api应该就是toPng(node),所以以这个方法为入口:

function toPng(node, options) {
    return draw(node, options).then(function (canvas) {
        return canvas.toDataURL();
    });
}

toPng方法会调用draw方法,然后返回一个canvas,最后通过canvas的toDataURL方法获取到图片的base64格式的data:URL,我们就可以直接下载为图片。

看一下draw方法:

function draw(domNode, options) {
    options = options || {};
    return toSvg(domNode, options)// 转换成svg
        .then(util.makeImage)// 转换成图片
        .then(function (image) {// 通过canvas绘制图片
            // ...
      });
}

一共分为了三个步骤,一一来看。

将节点转换成svg

toSvg方法如下:

function toSvg(node, options) {
    const ownerWindow = domtoimage.impl.util.getWindow(node);
    options = options || {};
    copyOptions(options);
    let restorations = [];
    return Promise.resolve(node)
        .then(ensureElement)// 检查和包装元素
        .then(function (clonee) {// 深度克隆节点
            return cloneNode(clonee, options, null, ownerWindow);
        })
        .then(embedFonts)// 嵌入字体
        .then(inlineImages)// 内联图片
        .then(makeSvgDataUri)// svg转data:URL
        .then(restoreWrappers)// 恢复包装元素
}

node就是我们要转换成图片的DOM节点,首先调用了getWindow方法获取window对象:

function getWindow(node) {
    const ownerDocument = node ? node.ownerDocument : undefined;
    return (
        (ownerDocument ? ownerDocument.defaultView : undefined) ||
        global ||
        window
    );
}

说实话前端写了这么多年,但是ownerDocument和defaultView两个属性我完全没用过,ownerDocument属性会返回当前节点的顶层的 document对象,而在浏览器中,defaultView属性会返回当前 document 对象所关联的 window 对象,如果没有,会返回 null。

所以这里优先通过我们传入的DOM节点获取window对象,可能是为了处理iframe嵌入之类的情况把。

接下来合并了选项后,就通过Promise实例的then方法链式的调用一系列的方法,一一来看。

检查和包装元素

ensureElement方法如下:

function ensureElement(node) {
    // ELEMENT_NODE:1
    if (node.nodeType === ELEMENT_NODE) return node;
    const originalChild = node;
    const originalParent = node.parentNode;
    const wrappingSpan = document.createElement('span');
    originalParent.replaceChild(wrappingSpan, originalChild);
    wrappingSpan.append(node);
    restorations.push({
        parent: originalParent,
        child: originalChild,
        wrapper: wrappingSpan,
    });
    return wrappingSpan;
}

html节点的nodeType有如下类型:

值为1也就是我们普通的html标签,其他的比如文本节点、注释节点、document节点也是比较常用的,如果我们传入的节点的类型为1,ensureElement方法什么也不做直接返回该节点,否则会创建一个span标签替换掉原节点,并把原节点添加到该span标签里,可以猜测这个主要是处理文本节点,毕竟应该没有人会传其他类型的节点进行转换了。

同时它还把原节点,原节点的父节点,span标签都收集到restorations数组里,很明显,这是为了后面进行还原。

克隆节点

接下来执行了cloneNode方法:

cloneNode(clonee, options, null, ownerWindow)
// 参数:需要克隆的节点、选项、父节点的样式、所属window对象
function cloneNode(node, options, parentComputedStyles, ownerWindow) {
    const filter = options.filter;
    if (
        node === sandbox ||
        util.isHTMLScriptElement(node) ||
        util.isHTMLStyleElement(node) ||
        util.isHTMLLinkElement(node) ||
        (parentComputedStyles !== null && filter && !filter(node))
    ) {
        return Promise.resolve();
    }
    return Promise.resolve(node)
        .then(makeNodeCopy)// 处理canvas元素
        .then(function (clone) {// 克隆子节点
            return cloneChildren(clone, getParentOfChildren(node));
        })
        .then(function (clone) {// 处理克隆的节点
            return processClone(clone, node);
        });
}

先做了一堆判断,如果是script、style、link标签,或者需要过滤掉的节点,那么会直接返回。

sandbox、parentComputedStyles后面会看到。

接下来又调用了几个方法,没办法,跟着它一起入栈把。

处理canvas元素的克隆

function makeNodeCopy(original) {
    if (util.isHTMLCanvasElement(original)) {
        return util.makeImage(original.toDataURL());
    }
    return original.cloneNode(false);
}

如果元素是canvas,那么会通过makeImage方法将其转换成img标签:

function makeImage(uri) {
    if (uri === 'data:,') {
        return Promise.resolve();
    }
    return new Promise(function (resolve, reject) {
        const image = new Image();
        if (domtoimage.impl.options.useCredentials) {
            image.crossOrigin = 'use-credentials';
        }
        image.onload = function () {
            if (window && window.requestAnimationFrame) {
                // 解决 Firefox 的一个bug (webcompat/web-bugs#119834) 
                // 需要等待一帧
                window.requestAnimationFrame(function () {
                    resolve(image);
                });
            } else {
                // 如果没有window对象或者requestAnimationFrame方法,那么立即返回
                resolve(image);
            }
        };
        image.onerror = reject;
        image.src = uri;
    });
}

crossOrigin属性用于定义一些元素如何处理跨域请求,主要有两个取值:

anonymous:元素的跨域资源请求不需要凭证标志设置。

use-credentials:元素的跨域资源请求需要凭证标志设置,意味着该请求需要提供凭证。

除了use-credentials,给crossOrigin设置其他任何值都会解析成anonymous,为了解决跨域问题,我们一般都会设置成anonymous,这个就相当于告诉服务器,你不需要返回任何非匿名信息过来,例如cookie,所以肯定是安全的。不过在使用这两个值时都需要服务端返回Access-Control-Allow-Credentials响应头,否则肯定无法跨域使用的。

非canvas元素的其他元素,会直接调用它们的cloneNode方法进行克隆,参数传了false,代表只克隆自身,不克隆子节点。

克隆子节点

接下来调用了cloneChildren方法:

cloneChildren(clone, getParentOfChildren(node));

getParentOfChildren方法如下:

function getParentOfChildren(original) {
    // 如果该节点是Shadow DOM的附加节点,那么返回附加的Shadow DOM的根节点
    if (util.isElementHostForOpenShadowRoot(original)) {
        return original.shadowRoot; 
    }
    return original;
}
function isElementHostForOpenShadowRoot(value) {
    return isElement(value) && value.shadowRoot !== null;
}

这里涉及到了shadow DOM,有必要先简单了解一下。

shadow DOM是一种封装技术,可以将标记结构、样式和行为隐藏起来,比如我们熟悉的video标签,我们看到的只是一个video标签,但实际上它里面有很多我们看不到的元素,这个特性一般会和Web components结合使用,也就是可以创建自定义元素,就和Vue和React组件一样。

先了解一些术语:

Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上。

Shadow tree:Shadow DOM 内部的 DOM 树。

Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方。

Shadow root: Shadow tree 的根节点。

一个普通的DOM元素可以使用attachShadow方法来添加shadow DOM:

let shadow = div.attachShadow({ mode: "open" });

这样就可以给div元素附加一个shadow DOM,然后我们可以和创建普通元素一样创建任何元素添加到shadow下:

let para = document.createElement('p');
shadow.appendChild(para);

当mode设为open,我们就可以通过div.shadowRoot获取到Shadow DOM,如果设置的是closed,那么外部就获取不到。

所以前面的getParentOfChildren方法会判断当前节点是不是一个Shadow host节点,是的话就返回它内部的Shadow root节点,否则返回自身。

回到cloneChildren方法,它接收两个参数:克隆的节点、原节点。

function cloneChildren(clone, original) {
    // 获取子节点,如果原节点是slot节点,那么会返回slot内的节点,
    const originalChildren = getRenderedChildren(original);
    let done = Promise.resolve();
    if (originalChildren.length !== 0) {
        // 获取原节点的计算样式,如果原节点是shadow root节点,那么会获取它所附加到的普通元素的样式
        const originalComputedStyles = getComputedStyle(
            getRenderedParent(original)
        );
        // 遍历子节点
        util.asArray(originalChildren).forEach(function (originalChild) {
            done = done.then(function () {
                // 递归调用cloneNode方法
                return cloneNode(
                    originalChild,
                    options,
                    originalComputedStyles,
                    ownerWindow
                ).then(function (clonedChild) {
                    // 克隆完后的子节点添加到该节点
                    if (clonedChild) {
                        clone.appendChild(clonedChild);
                    }
                });
            });
        });
    }
    return done.then(function () {
        return clone;
    });
}

首先通过getRenderedChildren方法获取子节点:

function getRenderedChildren(original) {
    // 如果是slot元素,那么通过assignedNodes方法返回该插槽中的节点
    if (util.isShadowSlotElement(original)) {
        return original.assignedNodes();
    }
    // 普通元素直接通过childNodes获取子节点
    return original.childNodes;
}
// 判断是否是html slot元素
function isShadowSlotElement(value) {
    return (
        isInShadowRoot(value) && value instanceof getWindow(value).HTMLSlotElement
    );
}
// 判断一个节点是否处于shadow DOM树中
function isInShadowRoot(value) {
    // 如果是普通节点,getRootNode方法会返回document对象,如果是Shadow DOM,那么会返回shadow root
    return (
        value !== null &&
        Object.prototype.hasOwnProperty.call(value, 'getRootNode') &&
        isShadowRoot(value.getRootNode())
    );
}
// 判断是否是shadow DOM的根节点
function isShadowRoot(value) {
    return value instanceof getWindow(value).ShadowRoot;
}

这一连串的判断,如果对于shadow DOM不熟悉的话大概率很难看懂,不过没关系,跳过这部分也可以,反正就是获取子节点。

获取到子节点后又调用了如下方法:

const originalComputedStyles = getComputedStyle(
    getRenderedParent(original)
);
function getRenderedParent(original) {
    // 如果该节点是shadow root,那么返回它附加到的普通的DOM节点
    if (util.isShadowRoot(original)) {
        return original.host;
    }
    return original;
}

调用getComputedStyle获取原节点的样式,这个方法其实就是window.getComputedStyle方法,会返回节点的所有样式和值。

接下来就是遍历子节点,然后对每个子节点再次调用cloneNode方法,只不过会把原节点的样式也传进去。对于子元素又会递归处理它们的子节点,这样就能深度克隆完整棵DOM树。

处理克隆的节点

对于每个克隆节点,又调用了processClone(clone, node)方法:

function processClone(clone, original) {
    // 如果不是普通节点,或者是slot节点,那么直接返回
    if (!util.isElement(clone) || util.isShadowSlotElement(original)) {
        return Promise.resolve(clone);
    }
    return Promise.resolve()
        .then(cloneStyle)// 克隆样式
        .then(clonePseudoElements)// 克隆伪元素
        .then(copyUserInput)// 克隆输入框
        .then(fixSvg)// 修复svg
        .then(function () {
            return clone;
        });
}

又是一系列的操作,稳住,我们继续。

克隆样式

function cloneStyle() {
    copyStyle(original, clone);
}

调用了copyStyle方法,传入原节点和克隆节点:

function copyStyle(sourceElement, targetElement) {
    const sourceComputedStyles = getComputedStyle(sourceElement);
    if (sourceComputedStyles.cssText) {
       // ...
    } else {
       // ...
    }
}

window.getComputedStyle方法返回的是一个CSSStyleDeclaration对象,和我们使用div.style获取到的对象类型是一样的,但是div.style对象只能获取到元素的内联样式,使用div.style.color = '#fff'设置的也能获取到,因为这种方式设置的也是内联样式,其他样式是获取不到的,但是window.getComputedStyle能获取到所有css样式。

div.style.cssText属性我们都用过,可以获取和批量设置内联样式,如果要设置多个样式,比单个调用div.style.xxx方便一点,但是cssText会覆盖整个内联样式,比如下面的方式设置的字号是会丢失的,内联样式最终只有color:

div.style.fontSize = '23px'
div.style.cssText = 'color: rgb(102, 102, 102)'

但是window.getComputedStyle方法返回的对象的cssText和div.style.cssText不是同一个东西,即使有内联样式,window.getComputedStyle方法返回对象的cssText值也是空,并且它无法修改,所以不清楚什么情况下它才会有值。

假设有值的话,接下来的代码我也不是很能理解:

if (sourceComputedStyles.cssText) {
    targetElement.style.cssText = sourceComputedStyles.cssText;
    copyFont(sourceComputedStyles, targetElement.style);
}

function copyFont(source, target) {
    target.font = source.font;
    target.fontFamily = source.fontFamily;
    // ...
}

为什么不直接把原节点的style.cssText复制给克隆节点的style.cssText呢,另外为啥文本相关的样式又要单独设置一遍呢,无法理解。

我们看看另外一个分支:

else {
    copyUserComputedStyleFast(
        options,
        sourceElement,
        sourceComputedStyles,
        parentComputedStyles,
        targetElement
    );
    // ...
}

先调用了copyUserComputedStyleFast方法,这个方法内部非常复杂,就不把具体代码放出来了,大致介绍一下它都做了什么:

1.首先会获取原节点的所谓的默认样式,这个步骤也比较复杂:

1.1.先获取原节点及祖先节点的元素标签列表,其实就是一个向上递归的过程,不过存在终止条件,就是当遇到块级元素的祖先节点。比如原节点是一个span标签,它的父节点也是一个span,再上一个父节点是一个div,那么获取到的标签列表就是[span, span, div]。

​ 1.2.接下来会创建一个沙箱,也就是一个iframe,这个iframe的DOCTYPE和charset会设置成和当前页面的一样。

​ 1.3.再接下来会根据前面获取到的标签列表,在iframe中创建对应结构的DOM节点,也就是会创建这样一棵DOM树:div -> span -> span。并且会给最后一个节点添加一个零宽字符的文本,并返回这个节点。

​ 1.4.使用iframe的window.getComputedStyle方法获取上一步返回节点的样式,对于width和height会设置成auto。

​ 1.5.删除iframe里前面创建的节点。

​ 16.返回1.4步获取到的样式对象。

2.遍历原节点的样式,也就是sourceComputedStyles对象,对于每一个样式属性,都会获取到三个值:sourceValue、defaultValue、parentValue,分别来自原节点的样式对象sourceComputedStyles、第一步获取到的默认样式对象、父节点的样式对象parentComputedStyles,然后会做如下判断:

if (
    sourceValue !== defaultValue ||
    (parentComputedStyles && sourceValue !== parentValue)
) {
    // 样式优先级,比如important
    const priority = sourceComputedStyles.getPropertyPriority(name);
    // 将样式设置到克隆节点的style对象上
    setStyleProperty(targetStyle, name, sourceValue, priority);
}

如果原节点的某个样式值和默认的样式值不一样,并且和父节点的也不一样,那么就需要给克隆的节点手动设置成内联样式,否则其实就是继承样式或者默认样式,就不用管了,不得不说,还是挺巧妙的。

copyUserComputedStyleFast方法执行完后还做了如下操作:

if (parentComputedStyles === null) {
    [
        'inset-block',
        'inset-block-start',
        'inset-block-end',
    ].forEach((prop) => targetElement.style.removeProperty(prop));
    ['left', 'right', 'top', 'bottom'].forEach((prop) => {
        if (targetElement.style.getPropertyValue(prop)) {
            targetElement.style.setProperty(prop, '0px');
        }
    });
}

对于我们传入的节点,parentComputedStyles是null,本质相当于根节点,所以直接移除它的位置信息,防止发生偏移。

克隆伪元素

克隆完样式,接下来就是处理伪元素了:

function clonePseudoElements() {
    const cloneClassName = util.uid();
    [':before', ':after'].forEach(function (element) {
        clonePseudoElement(element);
    });
}

分别调用clonePseudoElement方法处理两种伪元素:

function clonePseudoElement(element) {
    // 获取原节点伪元素的样式
    const style = getComputedStyle(original, element);
    // 获取伪元素的content
    const content = style.getPropertyValue('content');
    // 如果伪元素的内容为空就直接返回
    if (content === '' || content === 'none') {
        return;
    }
    // 获取克隆节点的类名
    const currentClass = clone.getAttribute('class') || '';
    // 给克隆元素增加一个唯一的类名
    clone.setAttribute('class', `${currentClass} ${cloneClassName}`);
 // 创建一个style标签
    const styleElement = document.createElement('style');
    // 插入伪元素的样式
    styleElement.appendChild(formatPseudoElementStyle());
    // 将样式标签添加到克隆节点内
    clone.appendChild(styleElement);
}

window.getComputedStyle方法是可以获取元素的伪元素的样式的,通过第二个参数指定要获取的伪元素即可。

如果伪元素的content为空就不管了,总感觉有点不妥,毕竟我经常会用伪元素渲染一些三角形,content都是设置成空的。

如果不为空,那么会给克隆的节点新增一个唯一的类名,并且创建一个style标签添加到克隆节点内,这个style标签里会插入伪元素的样式,通过formatPseudoElementStyle方法获取伪元素的样式字符串:

function formatPseudoElementStyle() {
    const selector = `.${cloneClassName}:${element}`;
    // style为原节点伪元素的样式对象
    const cssText = style.cssText
    ? formatCssText()
    : formatCssProperties();

    return document.createTextNode(`${selector}{${cssText}}`);
}

如果样式对象的cssText有值,那么调用formatCssText方法:

function formatCssText() {
    return `${style.cssText} content: ${content};`;
}

但是前面说了,这个属性一般都是没值的,所以会走formatCssProperties方法:

function formatCssProperties() {
    const styleText = util
        .asArray(style)
        .map(formatProperty)
        .join('; ');
    return `${styleText};`;

    function formatProperty(name) {
        const propertyValue = style.getPropertyValue(name);
        const propertyPriority = style.getPropertyPriority(name)
        ? ' !important'
        : '';
        return `${name}: ${propertyValue}${propertyPriority}`;
    }
}

很简单,遍历样式对象,然后拼接成css的样式字符串。

克隆输入框

对于输入框的处理很简单:

function copyUserInput() {
    if (util.isHTMLTextAreaElement(original)) {
        clone.innerHTML = original.value;
    }
    if (util.isHTMLInputElement(original)) {
        clone.setAttribute('value', original.value);
    }
}

如果是textarea或者input元素,直接将原节点的值设置到克隆后的元素上即可。但是我测试发现克隆输入框也会把它的值给克隆过去,所以这一步可能没有必要。

修复svg

最后就是处理svg节点:

function fixSvg() {
    if (util.isSVGElement(clone)) {
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        if (util.isSVGRectElement(clone)) {
            ['width', 'height'].forEach(function (attribute) {
                const value = clone.getAttribute(attribute);
                if (value) {
                    clone.style.setProperty(attribute, value);
                }
            });
        }
    }
}

给svg节点添加命名空间,另外对于rect节点,还把宽高的属性设置成对应的样式,这个是何原因,我们也不得而知。

到这里,节点的克隆部分就结束了,不得不说,还是有点复杂的,很多操作其实我们也没有看懂为什么要这么做,开发一个库就是这样,要处理很多边界和异常情况,这个只有遇到了才知道为什么。

嵌入字体

节点克隆完后接下来会处理字体:

function embedFonts(node) {
    return fontFaces.resolveAll().then(function (cssText) {
        if (cssText !== '') {
            const styleNode = document.createElement('style');
            node.appendChild(styleNode);
            styleNode.appendChild(document.createTextNode(cssText));
        }
        return node;
    });
}

调用resolveAll方法,会返回一段css字符串,然后创建一个style标签添加到克隆的节点内,接下来看看resolveAll方法都做了什么:

function resolveAll() {
    return readAll()
        // ...
}

又调用了readAll方法:

function readAll() {
    return Promise.resolve(util.asArray(document.styleSheets))
        .then(getCssRules)
        .then(selectWebFontRules)
        .then(function (rules) {
            return rules.map(newWebFont);
        });
}

document.styleSheets属性可以获取到文档中所有的style标签和通过link标签引入的样式,结果是一个类数组,数组的每一项是一个CSSStyleSheet对象。

function getCssRules(styleSheets) {
    const cssRules = [];
    styleSheets.forEach(function (sheet) {
        if (
            Object.prototype.hasOwnProperty.call(
                Object.getPrototypeOf(sheet),
                'cssRules'
            )
        ) {
            util.asArray(sheet.cssRules || []).forEach(
                cssRules.push.bind(cssRules)
            );
        }
    });
    return cssRules;
}

通过CSSStyleSheet对象的cssRules属性可以获取到具体的css规则,cssRules的每一项也就是我们写的一条css语句:

function selectWebFontRules(cssRules) {
    return cssRules
        .filter(function (rule) {
            return rule.type === CSSRule.FONT_FACE_RULE;
        })
        .filter(function (rule) {
            return inliner.shouldProcess(rule.style.getPropertyValue('src'));
        });
}

遍历所有的css语句,找出其中的@font-face语句,shouldProcess方法会判断@font-face语句的src属性是否存在url()值,找出了所有存在的字体规则后会遍历它们调用newWebFont方法:

function newWebFont(webFontRule) {
    return {
        resolve: function resolve() {
            const baseUrl = (webFontRule.parentStyleSheet || {}).href;
            return inliner.inlineAll(webFontRule.cssText, baseUrl);
        },
        src: function () {
            return webFontRule.style.getPropertyValue('src');
        },
    };
}

inlineAll方法会找出@font-face语句中定义的所有字体的url,然后通过XMLHttpRequest发起请求,将字体文件转换成data:URL形式,然后替换css语句中的url,核心就是使用下面这个正则匹配和替换。

const URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g;

继续resolveAll方法:

function resolveAll() {
    return readAll()
        .then(function (webFonts) {
            return Promise.all(
                webFonts.map(function (webFont) {
                    return webFont.resolve();
                })
            );
        })
        .then(function (cssStrings) {
            return cssStrings.join('\n');
        });
}

将所有@font-face语句的远程字体url都转换成data:URL形式后再将它们拼接成css字符串即可完成嵌入字体的操作。

说实话,Promise链太长,看着容易晕。

内联图片

内联完了字体后接下来就是内联图片:

function inlineImages(node) {
    return images.inlineAll(node).then(function () {
        return node;
    });
}

处理图片的inlineAll方法如下:

function inlineAll(node) {
    if (!util.isElement(node)) {
        return Promise.resolve(node);
    }
    return inlineCSSProperty(node).then(function () {
        // ...
    });
}

inlineCSSProperty方法会判断节点background和 background-image属性是否设置了图片,是的话也会和嵌入字体一样将远程图片转换成data:URL嵌入:

function inlineCSSProperty(node) {
    const properties = ['background', 'background-image'];
    const inliningTasks = properties.map(function (propertyName) {
        const value = node.style.getPropertyValue(propertyName);
        const priority = node.style.getPropertyPriority(propertyName);
        if (!value) {
            return Promise.resolve();
        }
        // 如果设置了背景图片,那么也会调用inliner.inlineAll方法将远程url的形式转换成data:URL形式
        return inliner.inlineAll(value).then(function (inlinedValue) {
            // 将样式设置成转换后的值
            node.style.setProperty(propertyName, inlinedValue, priority);
        });
    });
    return Promise.all(inliningTasks).then(function () {
        return node;
    });
}

处理完节点的背景图片后:

function inlineAll(node) {
    return inlineCSSProperty(node).then(function () {
        if (util.isHTMLImageElement(node)) {
            return newImage(node).inline();
        } else {
            return Promise.all(
                util.asArray(node.childNodes).map(function (child) {
                    return inlineAll(child);
                })
            );
        }
    });
}

会检查节点是否是图片节点,是的话会调用newImage方法处理,这个方法也很简单,也是发个请求获取图片数据,然后将它转换成data:URL设置回图片的src。

如果是其他节点,那么就递归处理子节点。

将svg转换成data:URL

图片也处理完了接下来就可以将svg转换成data:URL了:

function makeSvgDataUri(node) {
    let width = options.width || util.width(node);
    let height = options.height || util.height(node);

    return Promise.resolve(node)
        .then(function (svg) {
            svg.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
            return new XMLSerializer().serializeToString(svg);
        })
        .then(util.escapeXhtml)
        .then(function (xhtml) {
            const foreignObjectSizing =
                  (util.isDimensionMissing(width)
                   ? ' width="100%"'
                   : ` width="${width}"`) +
                  (util.isDimensionMissing(height)
                   ? ' height="100%"'
                   : ` height="${height}"`);
            const svgSizing =
                  (util.isDimensionMissing(width) ? '' : ` width="${width}"`) +
                  (util.isDimensionMissing(height) ? '' : ` height="${height}"`);
            return `<svg xmlns="http://www.w3.org/2000/svg"${svgSizing}> 
    <foreignObject${foreignObjectSizing}>${xhtml}</foreignObject>
   </svg>`;
        })
        .then(function (svg) {
            return `data:image/svg+xml;charset=utf-8,${svg}`;
        });
}

其中的isDimensionMissing方法就是判断是否是不合法的数字。

主要做了四件事。

一是给节点添加命名空间,并使用XMLSerializer对象来将DOM节点序列化成字符串。

二是转换DOM字符串中的一些字符:

function escapeXhtml(string) {
    return string.replace(/%/g, '%25').replace(/#/g, '%23').replace(/\n/g, '%0A');
}

第三步就是拼接svg字符串了,将序列化后的字符串使用foreignObject标签包裹,同时会计算一下DOM节点的宽高设置到svg上。

最后一步是拼接成data:URL的形式。

恢复包装元素

在最开始的【检查和包装元素】步骤会替换掉节点类型不为1的节点,这一步就是用来恢复这个操作:

function restoreWrappers(result) {
    while (restorations.length > 0) {
        const restoration = restorations.pop();
        restoration.parent.replaceChild(restoration.child, restoration.wrapper);
    }
    return result;
}

这一步结束后将节点转换成svg的操作就结束了。

将svg转换成图片

现在我们可以回到draw方法:

function draw(domNode, options) {
        options = options || {};
        return toSvg(domNode, options)
            .then(util.makeImage)
            .then(function (image) {
                // ...
            });
}

获取到了svg的data:URL后会调用makeImage方法将它转换成图片,这个方法前面我们已经看过了,这里就不重复说了。

将图片通过canvas导出

继续draw方法:

function draw(domNode, options) {
        options = options || {};
        return toSvg(domNode, options)
            .then(util.makeImage)
            .then(function (image) {
                const scale = typeof options.scale !== 'number' ? 1 : options.scale;
                const canvas = newCanvas(domNode, scale);
                const ctx = canvas.getContext('2d');
                ctx.msImageSmoothingEnabled = false;// 禁用图像平滑
                ctx.imageSmoothingEnabled = false;// 禁用图像平滑
                if (image) {
                    ctx.scale(scale, scale);
                    ctx.drawImage(image, 0, 0);
                }
                return canvas;
            });
}

先调用newCanvas方法创建一个canvas:

function newCanvas(node, scale) {
    let width = options.width || util.width(node);
    let height = options.height || util.height(node);
 // 如果宽度高度都没有,那么默认设置成300
    if (util.isDimensionMissing(width)) {
        width = util.isDimensionMissing(height) ? 300 : height * 2.0;
    }
    // 如果高度没有,那么默认设置成宽度的一半
    if (util.isDimensionMissing(height)) {
        height = width / 2.0;
    }
 // 创建canvas
    const canvas = document.createElement('canvas');
    canvas.width = width * scale;
    canvas.height = height * scale;
 // 设置背景颜色
    if (options.bgcolor) {
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = options.bgcolor;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
    }
    return canvas;
}

把svg图片绘制到canvas上后,就可以通过canvas.toDataURL()方法转换成图片的data:URL,你可以渲染到页面,也可以直接进行下载。

总结

本文通过源码详细介绍了dom-to-image-more的原理,核心就是克隆节点和节点样式,内联字体、背景图片、图片,然后通过svg的foreignObject标签嵌入克隆后的节点,最后将svg转换成图片,图片绘制到canvas上进行导出。

可以看到源码中大量的Promise,很多不是异步的逻辑也会通过then方法来进行管道式调用,大部分情况会让代码很清晰,一眼就知道大概做了什么事情,但是部分地方串联了太长,反倒不太容易理解。

限于篇幅,源码中其实还要很多有意思的细节没有介绍,比如为了修改iframe的DOCTYPE和charset,居然写了三种方式,虽然我觉得第一种就够了,又比如获取节点默认样式的方式,通过iframe创建同样标签同样层级的元素,说实话我是从来没见过,再比如解析css中的字体的url时用的是如下方法:

function resolveUrl(url, baseUrl) {
    const doc = document.implementation.createHTMLDocument();
    const base = doc.createElement('base');
    doc.head.appendChild(base);
    const a = doc.createElement('a');
    doc.body.appendChild(a);
    base.href = baseUrl;
    a.href = url;
    return a.href;
}

base标签我也是从来没有见过。等等。

所以看源码还是挺有意思的一件事,毕竟平时写业务代码局限性太大了,很多东西都了解不到,强烈推荐各位去阅读一下。

接:https://juejin.im/book/5b936540f265da0a9624b04b

《高性能网站建设指南》的作者 Steve Souders 曾在一篇博客中提到:

我的大部分性能优化工作都集中在 JavaScript 和 CSS 上,从早期的 Move Scripts to the Bottom 和 Put Stylesheets at the Top 规则。为了强调这些规则的重要性,我甚至说过,“JS 和 CSS 是页面上最重要的部分”。

几个月后,我意识到这是错误的。图片才是页面上最重要的部分。

我关注 JS 和 CSS 的重点也是如何能够更快地下载图片。图片是用户可以直观看到的。他们并不会关注 JS 和 CSS。确实,JS 和 CSS 会影响图片内容的展示,尤其是会影响图片的展示方式(比如图片轮播,CSS 背景图和媒体查询)。但是我认为 JS 和 CSS 只是展示图片的方式。在页面加载的过程中,应当先让图片和文字先展示,而不是试图保证 JS 和 CSS 更快下载完成。

这段话可谓字字珠玑。此外,雅虎军规和 Google 官方的最佳实践也都将图片优化列为前端性能优化必不可少的环节——图片优化的优先级可见一斑。

就图片这块来说,与其说我们是在做“优化”,不如说我们是在做“权衡”。因为我们要做的事情,就是去压缩图片的体积(或者一开始就选取体积较小的图片格式)。但这个优化操作,是以牺牲一部分成像质量为代价的。因此我们的主要任务,是尽可能地去寻求一个质量与性能之间的平衡点。

2019 年,图片依然很大

这里先给大家介绍 HTTP-Archive 这个网站,它会定期抓取 Web 上的站点,并记录资源的加载情况、Web API 的使用情况等页面的详细信息,并会对这些数据进行处理和分析以确定趋势。通过它我们可以实时地看到世界范围内的 Web 资源的统计结果。

截止到 2018 年 8 月,过去一年总的 web 资源的平均请求体积是这样的:

而具体到图片这一类的资源,平均请求体积是这样的:

当然,随着我们工程师在性能方面所做的努力越来越有成效,平均来说,不管是资源总量还是图片体积,都在往越来越轻量的方向演化。这是一种值得肯定的进步。

但同时我们不得不承认,如图所示的这个图片体积,依然是太大了。图片在所有资源中所占的比重,也足够“触目惊心”了。为了改变这个现状,我们必须把图片优化提上日程。

不同业务场景下的图片方案选型

时下应用较为广泛的 Web 图片格式有 JPEG/JPG、PNG、WebP、Base64、SVG 等,这些格式都是很有故事的,值得我们好好研究一把。此外,老生常谈的雪碧图(CSS Sprites)至今也仍在一线的前端应用中发光发热,我们也会有所提及。

不谈业务场景的选型都是耍流氓。下面我们就结合具体的业务场景,一起来解开图片选型的神秘面纱!

前置知识:二进制位数与色彩的关系

在计算机中,像素用二进制数来表示。不同的图片格式中像素与二进制位数之间的对应关系是不同的。一个像素对应的二进制位数越多,它可以表示的颜色种类就越多,成像效果也就越细腻,文件体积相应也会越大。

一个二进制位表示两种颜色(0|1 对应黑|白),如果一种图片格式对应的二进制位数有 n 个,那么它就可以呈现 2^n 种颜色。

JPEG/JPG

关键字:有损压缩、体积小、加载快、不支持透明

JPG 的优点

JPG 最大的特点是有损压缩。这种高效的压缩算法使它成为了一种非常轻巧的图片格式。另一方面,即使被称为“有损”压缩,JPG的压缩方式仍然是一种高质量的压缩方式:当我们把图片体积压缩至原有体积的 50% 以下时,JPG 仍然可以保持住 60% 的品质。此外,JPG 格式以 24 位存储单个图,可以呈现多达 1600 万种颜色,足以应对大多数场景下对色彩的要求,这一点决定了它压缩前后的质量损耗并不容易被我们人类的肉眼所察觉——前提是你用对了业务场景。

使用场景

JPG 适用于呈现色彩丰富的图片,在我们日常开发中,JPG 图片经常作为大的背景图、轮播图或 Banner 图出现。

两大电商网站对大图的处理,是 JPG 图片应用场景的最佳写照:

打开淘宝首页,我们可以发现页面中最醒目、最庞大的图片,一定是以 .jpg 为后缀的:

京东首页也不例外:

使用 JPG 呈现大图,既可以保住图片的质量,又不会带来令人头疼的图片体积,是当下比较推崇的一种方案。

JPG 的缺陷

有损压缩在上文所展示的轮播图上确实很难露出马脚,但当它处理矢量图形Logo 等线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显。

此外,JPEG 图像不支持透明度处理,透明图片需要召唤 PNG 来呈现。

PNG-8 与 PNG-24

关键字:无损压缩、质量高、体积大、支持透明

PNG 的优点

PNG(可移植网络图形格式)是一种无损压缩的高保真的图片格式。8 和 24,这里都是二进制数的位数。按照我们前置知识里提到的对应关系,8 位的 PNG 最多支持 256 种颜色,而 24 位的可以呈现约 1600 万种颜色。

PNG 图片具有比 JPG 更强的色彩表现力,对线条的处理更加细腻,对透明度有良好的支持。它弥补了上文我们提到的 JPG 的局限性,唯一的 BUG 就是体积太大

PNG-8 与 PNG-24 的选择题

什么时候用 PNG-8,什么时候用 PNG-24,这是一个问题。

理论上来说,当你追求最佳的显示效果、并且不在意文件体积大小时,是推荐使用 PNG-24 的。

但实践当中,为了规避体积的问题,我们一般不用PNG去处理较复杂的图像。当我们遇到适合 PNG 的场景时,也会优先选择更为小巧的 PNG-8

如何确定一张图片是该用 PNG-8 还是 PNG-24 去呈现呢?好的做法是把图片先按照这两种格式分别输出,看 PNG-8 输出的结果是否会带来肉眼可见的质量损耗,并且确认这种损耗是否在我们(尤其是你的 UI 设计师)可接受的范围内,基于对比的结果去做判断。

应用场景

前面我们提到,复杂的、色彩层次丰富的图片,用 PNG 来处理的话,成本会比较高,我们一般会交给 JPG 去存储。

考虑到 PNG 在处理线条和颜色对比度方面的优势,我们主要用它来呈现小的 Logo、颜色简单且对比强烈的图片或背景等。

此时我们再次把目光转向性能方面堪称业界楷模的淘宝首页,我们会发现它页面上的 Logo,无论大小,还真的都是 PNG 格式:

主 Logo:

较小的 Logo:

颜色简单、对比度较强的透明小图也在 PNG 格式下有着良好的表现:

SVG

关键字:文本文件、体积小、不失真、兼容性好

SVG(可缩放矢量图形)是一种基于 XML 语法的图像格式。它和本文提及的其它图片种类有着本质的不同:SVG 对图像的处理不是基于像素点,而是是基于对图像的形状描述。

SVG 的特性

和性能关系最密切的一点就是:SVG 与 PNG 和 JPG 相比,文件体积更小,可压缩性更强

当然,作为矢量图,它最显著的优势还是在于图片可无限放大而不失真这一点上。这使得 SVG 即使是被放到视网膜屏幕上,也可以一如既往地展现出较好的成像品质——1 张 SVG 足以适配 n 种分辨率。

此外,SVG 是文本文件。我们既可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件(SVG 文件在使用上与普通图片文件无异)。这使得 SVG 文件可以被非常多的工具读取和修改,具有较强的灵活性

SVG 的局限性主要有两个方面,一方面是它的渲染成本比较高,这点对性能来说是很不利的。另一方面,SVG 存在着其它图片格式所没有的学习成本(它是可编程的)。

SVG 的使用方式与应用场景

SVG 是文本文件,我们既可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件(SVG 文件在使用上与普通图片文件无异)。

将 SVG 写入 HTML:

将 SVG 写入独立文件后引入 HTML:

<img src="文件名.svg" alt="">

在实际开发中,我们更多用到的是后者。很多情况下设计师会给到我们 SVG 文件,就算没有设计师,我们还有非常好用的 在线矢量图形库。对于矢量图,我们无须深究过多,只需要对其核心特性有所掌握、日后在应用时做到有迹可循即可。

Base64

关键字:文本文件、依赖编码、小图标解决方案

Base64 并非一种图片格式,而是一种编码方式。Base64 和雪碧图一样,是作为小图标解决方案而存在的。在了解 Base64 之前,我们先来了解一下雪碧图。

前置知识:最经典的小图标解决方案——雪碧图(CSS Sprites)

雪碧图、CSS 精灵、CSS Sprites、图像精灵,说的都是这个东西——一种将小图标和背景图像合并到一张图片上,然后利用 CSS 的背景定位来显示其中的每一部分的技术。

MDN 对雪碧图的解释已经非常到位:

图像精灵(sprite,意为精灵),被运用于众多使用大量小图标的网页应用之上。它可取图像的一部分来使用,使得使用一个图像文件替代多个小文件成为可能。相较于一个小图标一个图像文件,单独一张图片所需的 HTTP 请求更少,对内存和带宽更加友好。

我们几乎可以在每一个有小图标出现的网站里找到雪碧图的影子(下图截取自京东首页):

和雪碧图一样,Base64 图片的出现,也是为了减少加载网页图片时对服务器的请求次数,从而提升网页性能。Base64 是作为雪碧图的补充而存在的。

理解 Base64

通过我们上文的演示,大家不难看出,每次加载图片,都是需要单独向服务器请求这个图片对应的资源的——这也就意味着一次 HTTP 请求的开销。

Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,我们可以直接将编码结果写入 HTML 或者写入 CSS,从而减少 HTTP 请求的次数。

我们来一起看一个实例,现在我有这么一个小小的放大镜 Logo:

它对应的链接如下:

https://user-gold-cdn.xitu.io/2018/9/15/165db7e94699824b?w=22&h=22&f=png&s=3680

按照一贯的思路,我们加载图片需要把图片链接写入 img 标签:

<img src="https://user-gold-cdn.xitu.io/2018/9/15/165db7e94699824b?w=22&h=22&f=png&s=3680">

浏览器就会针对我们的图片链接去发起一个资源请求。

但是如果我们对这个图片进行 Base64 编码,我们会得到一个很长很长的字符串,我们可以直接用这个字符串替换掉上文中的链接地址。你会发现浏览器原来是可以理解这个字符串的,它自动就将这个字符串解码为了一个图片,而不需再去发送 HTTP 请求。

Base64 的应用场景

上面这个实例,其实源自我们 掘金 网站 Header 部分的搜索栏 Logo:

既然 Base64 这么棒,我们何不把大图也换成 Base64 呢?

这是因为,Base64 编码后,图片大小会膨胀为原文件的 4/3(这是由 Base64 的编码原理决定的)。如果我们把大图也编码到 HTML 或 CSS 文件中,后者的体积会明显增加,即便我们减少了 HTTP 请求,也无法弥补这庞大的体积带来的性能开销,得不偿失。

在传输非常小的图片的时候,Base64 带来的文件体积膨胀、以及浏览器解析 Base64 的时间开销,与它节省掉的 HTTP 请求开销相比,可以忽略不计,这时候才能真正体现出它在性能方面的优势。

因此,Base64 并非万全之策,我们往往在一张图片满足以下条件时会对它应用 Base64 编码:

  1. 图片的实际尺寸很小(大家可以观察一下掘金页面的 Base64 图,几乎没有超过 2kb 的)
  2. 图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是雪碧图的补充)
  3. 图片的更新频率非常低(不需我们重复编码和修改文件内容,维护成本较低)

Base64 编码工具推荐

这里最推荐的是利用 webpack 来进行 Base64 的编码——webpack 的 url-loader 非常聪明,它除了具备基本的 Base64 转码能力,还可以结合文件大小,帮我们判断图片是否有必要进行 Base64 编码。

除此之外,市面上免费的 Base64 编解码工具种类是非常多样化的,有很多网站都提供在线编解码的服务,大家选取自己认为顺手的工具就好。

WebP

关键字:年轻的全能型选手

WebP 是今天在座各类图片格式中最年轻的一位,它于 2010 年被提出, 是 Google 专为 Web 开发的一种旨在加快图片加载速度的图片格式,它支持有损压缩和无损压缩。

WebP 的优点

WebP 像 JPEG 一样对细节丰富的图片信手拈来,像 PNG 一样支持透明,像 GIF 一样可以显示动态图片——它集多种图片文件格式的优点于一身。

WebP 的官方介绍对这一点有着更权威的阐述:

与 PNG 相比,WebP 无损图像的尺寸缩小了 26%。在等效的 SSIM 质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。 无损 WebP 支持透明度(也称为 alpha 通道),仅需 22% 的额外字节。对于有损 RGB 压缩可接受的情况,有损 WebP 也支持透明度,与 PNG 相比,通常提供 3 倍的文件大小。

我们开篇提到,图片优化是质量与性能的博弈,从这个角度看,WebP 无疑是真正的赢家。

WebP 的局限性

WebP 纵有千般好,但它毕竟太年轻。我们知道,任何新生事物,都逃不开兼容性的大坑。现在是 2018 年 9 月,WebP 的支持情况是这样的:

坦白地说,虽然没有特别惨(毕竟还有亲爹 Chrome 在撑腰),但也足够让人望而却步了。

此外,WebP 还会增加服务器的负担——和编码 JPG 文件相比,编码同样质量的 WebP 文件会占用更多的计算资源。

WebP 的应用场景

现在限制我们使用 WebP 的最大问题不是“这个图片是否适合用 WebP 呈现”的问题,而是“浏览器是否允许 WebP”的问题,即我们上文谈到的兼容性问题。具体来说,一旦我们选择了 WebP,就要考虑在 Safari 等浏览器下它无法显示的问题,也就是说我们需要准备 PlanB,准备降级方案。

目前真正把 WebP 格式落地到网页中的网站并不是很多,这其中淘宝首页对 WebP 兼容性问题的处理方式就非常有趣。我们可以打开 Chrome 的开发者工具搜索其源码里的 WebP 关键字:

我们会发现检索结果还是挺多的(单就图示的加载结果来看,足足有 200 多条),下面大家注意一下这些 WebP 图片的链接地址(以其中一个为例):

<img src="//img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg_.webp" alt="手机app - 聚划算" class="app-icon">

.webp 前面,还跟了一个 .jpg 后缀!

我们现在先大胆地猜测,这个图片应该至少存在 jpg 和 webp 两种格式,程序会根据浏览器的型号、以及该型号是否支持 WebP 这些信息来决定当前浏览器显示的是 .webp 后缀还是 .jpg 后缀。带着这个预判,我们打开并不支持 WebP 格式的 Safari 来进入同样的页面,再次搜索 WebP 关键字:

Safari 提示我们找不到,这也是情理之中。我们定位到刚刚示例的 WebP 图片所在的元素,查看一下它在 Safari 里的图片链接:

<img src="//img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg" alt="手机app - 聚划算" class="app-icon">

我们看到同样的一张图片,在 Safari 中的后缀从 .webp 变成了 .jpg!看来果然如此——站点确实是先进行了兼容性的预判,在浏览器环境支持 WebP 的情况下,优先使用 WebP 格式,否则就把图片降级为 JPG 格式(本质是对图片的链接地址作简单的字符串切割)。

此外,还有另一个维护性更强、更加灵活的方案——把判断工作交给后端,由服务器根据 HTTP 请求头部的 Accept 字段来决定返回什么格式的图片。当 Accept 字段包含 image/webp 时,就返回 WebP 格式的图片,否则返回原图。这种做法的好处是,当浏览器对 WebP 格式图片的兼容支持发生改变时,我们也不用再去更新自己的兼容判定代码,只需要服务端像往常一样对 Accept 字段进行检查即可。

由此也可以看出,我们 WebP 格式的局限性确实比较明显,如果决定使用 WebP,兼容性处理是必不可少的。

叠样式表(Cascading Style Sheet,简称:CSS)是为网页添加样式的代码。本节将介绍 CSS 的基础知识,并解答类似问题:怎样将文本设置为黑色或红色?怎样将内容显示在屏幕的特定位置?怎样用背景图片或颜色来装饰网页?

CSS 究竟什么来头?

和 HTML 类似,CSS 也不是真正的编程语言,甚至不是标记语言。它是一门样式表语言,这也就是说人们可以用它来选择性地为 HTML 元素添加样式。举例来说,要选择一个 HTML 页面里所有的段落元素,然后将其中的文本改成红色,可以这样写 CSS:

p {
  color: red;
}

不妨试一下:首先新建一个 styles 文件夹,在其中新建一个 style.css 文件,将这三行 CSS 保存在这个新文件中。

然后再将该 CSS 文件连接至 HTML 文档,否则 CSS 代码不会对 HTML 文档在浏览器里的显示效果有任何影响。(如果你没有完成前几节的实践,请复习处理文件 和 HTML 基础。在笔记本里有这个方面的内容!)

1、打开 index.html 文件,然后将下面一行粘贴到文档头(也就是 <head></head> 标签之间)。

<link href="styles/style.css" rel="stylesheet">

2、保存 index.html 并用浏览器将其打开。应该看到以下页面:

如果段落文字变红,那么祝贺你,你已经成功地迈出了 CSS 学习的第一步。

“CSS 规则集”详解

让我们来仔细看一看上述CSS:

整个结构称为 规则集(通常简称“规则”),各部分释义如下:

  • 选择器(Selector
  • HTML 元素的名称位于规则集开始。它选择了一个或者多个需要添加样式的元素(在这个例子中就是 p 元素)。要给不同元素添加样式只需要更改选择器就行了。
  • 声明(Declaration
  • 一个单独的规则,比如说 color: red; 用来指定添加样式元素的属性
  • 属性(Properties
  • 改变 HTML 元素样式的途径。(本例中 color 就是 `` 元素的属性。)CSS 中,由编写人员决定修改哪个属性以改变规则。
  • 属性的值(Property value
  • 在属性的右边,冒号后面即属性的值,它从指定属性的众多外观中选择一个值(我们除了 red 之外还有很多属性值可以用于 color )。

注意其他重要的语法:

  • 每个规则集(除了选择器的部分)都应该包含在成对的大括号里({})。
  • 在每个声明里要用冒号(:)将属性与属性值分隔开。
  • 在每个规则集里要用分号(;)将各个声明分隔开。

如果要同时修改多个属性,只需要将它们用分号隔开,就像这样:

p {
  color: red;
  width: 500px;
  border: 1px solid black;
}

多元素选择

也可以选择多种类型的元素并为它们添加一组相同的样式。将不同的选择器用逗号分开。例如:

p, li, h1 {
  color: red;
}

不同类型的选择器

选择器有许多不同的类型。上面只介绍了元素选择器,用来选择 HTML 文档中给定的元素。但是选择的操作可以更加具体。下面是一些常用的选择器类型:

选择器名称

选择的内容

示例

元素选择器(也称作标签或类型选择器)

所有指定(该)类型的 HTML 元素

p 选择 <p>

ID 选择器

具有特定 ID 的元素(单一 HTML 页面中,每个 ID 只对应一个元素,一个元素只对应一个 ID)

#my-id 选择 <p id="my-id"><a id="my-id">

类选择器

具有特定类的元素(单一页面中,一个类可以有多个实例)

.my-class 选择 <p class="my-class"><a class="my-class">

属性选择器

拥有特定属性的元素

img[src] 选择 <img src="myimage.png"> 而不是 <img>

伪(Pseudo)类选择器

特定状态下的特定元素(比如鼠标指针悬停)

a:hover 仅在鼠标指针悬停在链接上时选择 <a>

选择器的种类远不止于此,更多信息请参阅 选择器。

字体和文本

译注:再一次说明,中文字体文件较大,不适合直接用于 Web Font。

在探索了一些 CSS 基础后,我们来把更多规则和信息添加至 style.css 中,从而让示例更美观。首先,让字体和文本变得更漂亮。

第一步:找到之前Google Font 输出的地址。并以<link>元素的形式添加进index.html文档头(<head></head>之间的任意位置)。代码如下:

 <link href="https://fonts.font.im/css?family=Open+Sans" rel="stylesheet" type="text/css"> 

以上代码为当前网页下载 Open Sans 字体,从而使自定义 CSS 中可以对 HTML 元素应用这个字体。

第二步:接下来,删除 style.css 文件中已有的规则。虽然测试是成功的了,但是红字看起来并不太舒服。

第三步:将下列代码添加到相应的位置,用你在 Google Fonts 找到的字体替代 font-family 中的占位行。( font-family 意味着你想要你的文本使用的字体。)这条规则首先为整个页面设定了一个全局字体和字号(因为 <html> 是整个页面的父元素,而且它所有的子元素都会继承相同的 font-sizefont-family):

html {
  /* px 表示 “像素(pixels)”: 基础字号为 10 像素 */
  font-size: 10px;
  /* Google fonts 输出的 CSS */
  font-family: 'Open Sans', sans-serif;
}

注:CSS 文档中所有位于 /**/ 之间的内容都是 CSS 注释,它会被浏览器在渲染代码时忽略。你可以在这里写下对你现在要做的事情有帮助的笔记。

译注:/*``*/ 不可嵌套,/*这样的注释是/*不行*/的*/。CSS 不接受 // 注释。

接下来为文档体内的元素(<h1> (en-US)、<li><p>)设置字号。将标题居中显示,并为正文设置行高和字间距,从而提高页面的可读性。

   h1 {
     font-size: 60px;
     text-align: center;
   }
   
   p, li {
     font-size: 16px;
     /* line-height 后而可以跟不同的参数,如果是数字,就是当前字体大小乘上数字 */
     line-height: 2;
     letter-spacing: 1px;
   }

可以随时调整这些 px 值来获得满意的结果,以下是大体效果:

一切皆盒子

编写 CSS 时你会发现,你的工作好像是围绕着一个一个盒子展开的——设置尺寸、颜色、位置,等等。页面里大部分 HTML 元素都可以被看作若干层叠的盒子。



并不意外,CSS 布局主要就是基于盒模型的。每个占据页面空间的块都有这样的属性:

  • padding:即内边距,围绕着内容(比如段落)的空间。
  • border:即边框,紧接着内边距的线。
  • margin:即外边距,围绕元素外部的空间。



这里还使用了:

  • width :元素的宽度
  • background-color :元素内容和内边距底下的颜色
  • color :元素内容(通常是文本)的颜色
  • text-shadow :为元素内的文本设置阴影
  • display :设置元素的显示模式(暂略)

开始在页面中添加更多 CSS 吧!大胆将这些新规则都添加到页面的底部,而不要纠结改变属性值会带来什么结果。

更改页面颜色

html{
  background-color:#00539f;
}

这条规则将整个页面的背景颜色设置为 所计划的颜色。

文档体格式设置

body{
   width:600px;
   margin:0 auto;
   background-color:#ff9500;
   padding:0 20px 20px 20px;
   border:5px solid black;
}

现在是 <body> 元素。以上条声明,我们来逐条查看:

  • width: 600px; —— 强制页面永远保持 600 像素宽。
  • margin: 0 auto; —— 为 marginpadding 等属性设置两个值时,第一个值代表元素的上方下方(在这个例子中设置为 0),而第二个值代表左边右边(在这里,auto 是一个特殊的值,意思是水平方向上左右对称)。你也可以使用一个,三个或四个值,参考 这里 。
  • background-color: #FF9500; —— 如前文所述,指定元素的背景颜色。我们给 body 用了一种略微偏红的橘色以与深蓝色的 `` 元素形成反差,你也可以尝试其它颜色。
  • padding: 0 20px 20px 20px; —— 我们给内边距设置了四个值来让内容四周产生一点空间。这一次我们不设置上方的内边距,设置右边,下方,左边的内边距为20像素。值以上、右、下、左的顺序排列。
  • border: 5px solid black; —— 直接为 body 设置 5 像素的黑色实线边框。

定位页面主标题并添加样式

h1{
  margin: 0;
  padding:20px 0;
  color: #00539f;
  text-shadow:3px 3px 1px black
}

你可能发现页面的顶部有一个难看的间隙,那是因为浏览器会在没有任何 CSS 的情况下 给 <h1>en-US等元素设置一些默认样式。但这并不是个好主意,因为我们希望一个没有任何样式的网页也有基本的可读性。为了去掉那个间隙,我们通过设置margin: 0;来覆盖默认样式。

至此,我们已经把标题的上下内边距设置为 20 像素,并且将标题文本与 HTML 的背景颜色设为一致。

需要注意的是,这里使用了一个 text-shadow 属性,它可以为元素中的文本提供阴影。四个值含义如下:

  • 第一个值设置水平偏移值 —— 即阴影右移的像素数(负值左移)。
  • 第二个值设置垂直偏移值 —— 即阴影下移的像素数(负值上移)。
  • 第三个值设置阴影的模糊半径 —— 值越大产生的阴影越模糊。
  • 第四个值设置阴影的基色。

不妨尝试不同的值看看能得出什么结果。

图像居中

img{
  display:block;
  margin:0 auto;
}

最后,我们把图像居中来使页面更美观。可以复用 body 的margin: 0 auto,但是需要一点点调整。<body>元素是块级元素,意味着它占据了页面的空间并且能够赋予外边距和其他改变间距的值。而图片是内联元素,不具备块级元素的一些功能。所以为了使图像有外边距,我们必须使用display: block 给予其块级行为。

注:以上说明假定所选图片小于页面宽度(600 pixels)。更大的图片会溢出 body 并占据页面的其他位置。要解决这个问题,可以:

1)使用 图片编辑器 来减小图片宽度; 2)用 CSS 限制图片大小,即减小 <img> 元素 width 属性的值(比如 400 px)。

注:如果你暂时不能理解 display: block 和块级元素与行内元素的差别也没关系;随着你对 CSS 学习的深入,你将明白这个问题。

小结

如果你按部就班完成本文的实践,那么最终可以得到以下页面


相关推荐:

前端新手看过来,手把手带你轻松上手html的实操