<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ woai3c - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ freeCodeCamp 是一个免费学习编程的开发者社区，涵盖 Python、HTML、CSS、React、Vue、BootStrap、JSON 教程等，还有活跃的技术论坛和丰富的社区活动，在你学习编程和找工作时为你提供建议和帮助。 ]]>
        </description>
        <link>https://www.freecodecamp.org/chinese/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ woai3c - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/chinese/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 24 May 2026 19:37:50 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/chinese/news/author/tan/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ 低代码与大语言模型的探索实践 ]]>
                </title>
                <description>
                    <![CDATA[ 前言 ChatGPT，作为一个先进的自然语言处理工具，可以理解和生成人类语言，提供智能编程建议，自动化代码生成，以及提供交互式编程教学。这些功能为开发者提供了前所未有的便利，大大减少了编程的时间和复杂性。 低代码平台则允许用户通过图形界面来构建应用，减少了对专业编程知识的依赖，在特定业务场景下 能够提升开发效率。用户可以通过拖放组件和模型驱动的逻辑来快速地创建应用程序，而无需编写大量代码。 如果可以把 ChatGPT 和低代码平台结合起来使用，那么用户就可以通过对话来快速地创建应用程序，从而简化了搭建应用程序的过程，并且提升了用户体验。 可行性 低代码平台通常使用 JSON 格式的 DSL（领域特定语言 domain-specific language 指的是专注于某个应用程序领域的计算机语言） 来描述一个页面。用户拖拽组件、编辑页面，实际上是在和页面的 JSON 在进行交互。低代码平台通过渲染引擎把 JSON 数据渲染成为页面，至于页面要渲染成为 Vue、React 或者是纯粹的 HTML 页面，就得看各个低代码平台自己的实现了。一个应用程序一般会包含多个页面，然后通过路由来决 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/exploration-and-practices-for-low-code-and-llms/</link>
                <guid isPermaLink="false">6690f22edd1680043183e203</guid>
                
                    <category>
                        <![CDATA[ 人工智能 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Fri, 12 Jul 2024 03:40:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2024/07/low-code.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <h2 id="-">前言</h2><p>ChatGPT，作为一个先进的自然语言处理工具，可以理解和生成人类语言，提供智能编程建议，自动化代码生成，以及提供交互式编程教学。这些功能为开发者提供了前所未有的便利，大大减少了编程的时间和复杂性。</p><p>低代码平台则允许用户通过图形界面来构建应用，减少了对专业编程知识的依赖，在<strong>特定业务场景下</strong>能够提升开发效率。用户可以通过拖放组件和模型驱动的逻辑来快速地创建应用程序，而无需编写大量代码。</p><p>如果可以把 ChatGPT 和低代码平台结合起来使用，那么用户就可以通过对话来快速地创建应用程序，从而简化了搭建应用程序的过程，并且提升了用户体验。</p><h3 id="--1">可行性</h3><p>低代码平台通常使用 JSON 格式的 DSL（领域特定语言 domain-specific language 指的是专注于某个应用程序领域的计算机语言） 来描述一个页面。用户拖拽组件、编辑页面，实际上是在和页面的 JSON 在进行交互。低代码平台通过渲染引擎把 JSON 数据渲染成为页面，至于页面要渲染成为 Vue、React 或者是纯粹的 HTML 页面，就得看各个低代码平台自己的实现了。一个应用程序一般会包含多个页面，然后通过路由来决定各个页面的跳转逻辑。</p><p>因此，我们可以向 ChatGPT 提出生成页面的需求，ChatGPT 再根据我们提供的 prompt 来生成一份符合低代码平台数据格式的 JSON。理论上，这是可行的，现在来看一下怎么实现这个功能。</p><h2 id="-chatgpt-">使用 ChatGPT 进行页面生成</h2><p>首先，我们需要注册一个 OpenAI 或者 Azure 账号，在上面开通 API 服务（如何申请账号请自行搜索，网上有很多教程）。然后就可以使用这个 API &nbsp;来和 ChatGPT 进行交互了。</p><p>另外，我们还需要一个低代码平台，因为生成的 JSON 需要一个低代码平台来验证生成 JSON 是否真实可用。刚好前几年我写了一个<a href="https://github.com/woai3c/visual-drag-demo">低代码平台教学项目</a>，现在刚好可以用上。</p><h3 id="-dsl">低代码平台组件 DSL</h3><p>首先，我们要知道每一个组件的 DSL 描述，下面的代码是一个文本组件的 DSL 描述：</p><pre><code class="language-js">{
    "animations": [], // 动画属性
    "events": {}, // 事件
    "groupStyle": {}, // 组合组件样式
    "isLock": false, // 是否锁定
    "collapseName": "style",
    "linkage": { // 联动组件
        "duration": 0,
        "data": [
            {
                "id": "",
                "label": "",
                "event": "",
                "style": [
                    {
                        "key": "",
                        "value": ""
                    }
                ]
            }
        ]
    },
    "component": "VText", // 组件类型
    "label": "文字", // 组件名称
    "propValue": "双击编辑文字", // 组件值
    "icon": "wenben", // 组件图标
    "request": { // 组件 API 请求
        "method": "GET",
        "data": [],
        "url": "",
        "series": false,
        "time": 1000,
        "paramType": "",
        "requestCount": 0
    },
    "style": { // 组件样式
        "rotate": 0,
        "opacity": 1,
        "width": 200,
        "height": 28,
        "fontSize": "",
        "fontWeight": 400,
        "lineHeight": "",
        "letterSpacing": 0,
        "textAlign": "",
        "color": "",
        "top": 157,
        "left": 272
    },
    "id": "07l4byRWvsphAPo2uatxy" // 组件唯一 id
}
</code></pre><p>每一个组件就是一个 JSON 数据，那么整个页面就是一个 JSON 数组，里面包含了多个组件：</p><pre><code class="language-js">// 页面 JSON
[
 { ... }, // 组件1
 { ... }, // 组件2
 { ... }, // 组件3
]
</code></pre><h3 id="prompt-">prompt 编写</h3><p>要让 ChatGPT 来生成页面，那么我们要让 ChatGPT 知道页面、组件的数据结构，并且要给它提供示例。然后再向它提出页面生成的需求，从而为我们生成符合要求的页面。这个 prompt 的格式如下：</p><pre><code class="language-markdown">我有一个低代码平台项目，它可以根据符合规范的 JSON 数据生成页面，这个 JSON 数据是一个数组，里面的每一项都是一个 JSON 对象，每个 JSON 对象都对应着一个组件。
下面用 ### 包括起来的代码就是所有的组件列表。

###
---所有组件的 JSON 数据结构---
###

如果一个页面包含了一个文本和按钮组件，那么这个页面的 JSON 代码如下：
###
---示例页面 JSON 数据结构---
###

你作为一个技术专家，现在需要按照上面的规则来为我生成页面，并且生成的页面中每一个组件的属性都不能忽略，也不需要解释，只需要返回 JSON 数据即可。要注意的是，有些数值的单位是没有 px 的。

现在我需要生成一个海报页面，主要用于宣传编程有什么用。

</code></pre><p>由于篇幅有限，上面只展示了这个 prompt 的大纲，JSON 数据都省略了。完整的 prompt 请在 <a href="https://github.com/woai3c/lowcode-llm-demo/blob/main/prompts/prompt.md">lowcode-llm-demo</a> 上查看。</p><p>prompt 准备好了，现在我们需要调用 ChatGPT 的 API 来生成页面，示例代码如下：</p><pre><code class="language-js">import { AzureChatOpenAI } from '@langchain/azure-openai'
import 'dotenv/config'
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'

const model = new AzureChatOpenAI({
    modelName: process.env.AZURE_OPENAI_API_MODEL_NAME,
    azureOpenAIEndpoint: process.env.AZURE_OPENAI_API_ENDPOINT,
    azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,
    azureOpenAIEmbeddingsApiDeploymentName: process.env.AZURE_OPENAI_API_EMBEDDING_DEPLOYMENT_NAME,
    azureOpenAIApiVersion: process.env.AZURE_OPENAI_API_VERSION,
})

const dirName = dirname(fileURLToPath(import.meta.url))
const prompt = readFileSync(resolve(dirName, '../prompts/prompt-compress.md'), 'utf-8')
const response = await model.invoke(prompt)
console.log(JSON.stringify(response)) // 返回 ChatGPT 的结果
</code></pre><p>上面的代码执行后就能得到 ChatGPT 返回来的页面 JSON 数据。不过这个数据不能直接使用，还需要额外写点代码处理一下才能使用。下面的代码就是处理后的页面 JSON 数据结构：</p><pre><code class="language-js">[
    {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [
                {
                    "id": "",
                    "label": "",
                    "event": "",
                    "style": [
                        {
                            "key": "",
                            "value": ""
                        }
                    ]
                }
            ]
        },
        "component": "Picture",
        "label": "背景图片",
        "propValue": {
            "url": "img/programming_poster_bg.jpg" // 替换为自己的图片
        },
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 500,
            "height": 700,
            "top": 0,
            "left": 0,
            "position": "absolute",
            "zIndex": 0
        },
        "id": "backgroundImage"
    },
    {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [
                {
                    "id": "",
                    "label": "",
                    "event": "",
                    "style": [
                        {
                            "key": "",
                            "value": ""
                        }
                    ]
                }
            ]
        },
        "component": "VText",
        "label": "标题文字",
        "propValue": "编程改变世界",
        "icon": "wenben",
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 450,
            "height": 100,
            "fontSize": "32px",
            "fontWeight": 700,
            "textAlign": "center",
            "color": "rgba(16, 15, 15, 1)",
            "top": 50,
            "left": 25,
            "position": "absolute",
            "zIndex": 10
        },
        "id": "titleText"
    },
    {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [
                {
                    "id": "",
                    "label": "",
                    "event": "",
                    "style": [
                        {
                            "key": "",
                            "value": ""
                        }
                    ]
                }
            ]
        },
        "component": "VText",
        "label": "描述文字",
        "propValue": "通过编程，我们可以创建软件来解决问题、分析数据，甚至改善人们的生活。",
        "icon": "wenben",
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 400,
            "height": 200,
            "fontSize": "18px",
            "fontWeight": 400,
            "lineHeight": "1.5",
            "textAlign": "center",
            "color": "rgba(33, 31, 31, 1)",
            "top": 180,
            "left": 50,
            "position": "absolute",
            "zIndex": 10
        },
        "id": "descriptionText"
    },
    {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [
                {
                    "id": "",
                    "label": "",
                    "event": "",
                    "style": [
                        {
                            "key": "",
                            "value": ""
                        }
                    ]
                }
            ]
        },
        "component": "VButton",
        "label": "行动按钮",
        "propValue": "开始学习编程",
        "icon": "button",
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 200,
            "height": 50,
            "borderRadius": "25px",
            "fontSize": "20px",
            "fontWeight": 500,
            "textAlign": "center",
            "color": "#FFF",
            "backgroundColor": "#f44336",
            "top": 420,
            "left": 150,
            "position": "absolute",
            "zIndex": 10
        },
        "id": "actionButton"
    }
]
</code></pre><p>将 JSON 导入到<a href="https://woai3c.github.io/visual-drag-demo/" rel="nofollow">低代码平台</a>后，生成的页面如下所示：</p><figure class="kg-card kg-image-card"><img src="https://camo.githubusercontent.com/01f26873c8ae94d334d6fc3242d30c652d9e47a2c63e2af7d19c4249bdfbaf16/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f6469726563742f65383561613164646337383134363731386234353630343565656165303932362e706e67" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p><br>可以看到 ChatGPT 输出了一个半成品页面，背景图片的地址需要我们手动修改。ChatGPT 每次输出的页面都是随机的，下面是生成的另外一个页面。</p><figure class="kg-card kg-image-card"><img src="https://camo.githubusercontent.com/9136fff3c3f2469d3e990d80c823b482b07289814f7d8b179a99fb405ddeabe7/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f6469726563742f34383738363064306233333234303461616662363632663336366366383936342e706e67" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h3 id="--2">节省成本</h3><p>ChatGPT 很好用，但是有一个缺点，太贵了。所以我们需要想一些办法来降低成本，下面是几个比较可行的办法：</p><ol><li>精简 prompt</li><li>微调（Fine-tuning）</li><li>使用模板、分类标签</li></ol><h4 id="-prompt">精简 prompt</h4><p>ChatGPT 的 API &nbsp;是通过 token 来收费的，所以最简单直接的方法就是优化 prompt。下面是一个未优化的 prompt 示例：</p><blockquote>我需要一个用户管理系统的页面。在页面顶部，我需要一个添加用户的按钮。下面是一个表格，列出了所有用户的姓名、邮箱和注册日期。点击任何一个用户，将会打开一个包含完整用户信息的新页面，其中包含姓名、邮箱、注册日期、最后登录时间和用户角色。还需要有一个搜索栏，可以通过姓名或邮箱搜索用户。请基于这些要求为我的低代码平台生成代码。</blockquote><p>现在将这个 prompt 优化一下：</p><blockquote>生成页面：用户管理<br>组件：添加按钮，用户表格（姓名，邮箱，注册日期），用户详情（姓名，邮箱，注册日期，最后登录，角色），搜索栏（姓名，邮箱）</blockquote><p>在这个例子中，我们简化了描述页面的需求，从而减少了 token 的数量。</p><h4 id="-fine-tuning-">微调（Fine-tuning）</h4><p>在文章的开头，我展示了一个生成页面的 prompt 示例，并且可以看到这个 prompt 消耗的 token 数量非常大，因为它需要告诉 ChatGPT 每个组件的 JSON 数据结构是什么，以及一个完整的示例页面 JSON 数据结构是什么样的，这些示例都需要消耗大量的 token。</p><p>为了精简 prompt，同时又能达到未精简前的效果，这可以使用微调来实现。</p><blockquote>微调是一种机器学习技术，它可以让你自定义机器学习模型以适应特定的任务或数据集。通过微调，您可以在一个预训练的模型（如ChatGPT）的基础上进一步训练它，使其更好地理解和执行特定的任务。这样，您可以创建一个专门为您的低代码平台生成页面的模型。</blockquote><p>我们可以给 ChatGPT 提供大量的数据集进行微调训练，比如大量的低代码页面示例。这样你再让它生成一个“用于宣传编程的海报页面”，就不需要提供每个组件的数据结构了，因为这些数据 ChatGPT 已经通过微调学会了。</p><p>现在来看一下用于微调训练的数据集是什么样的：</p><pre><code class="language-json">{"prompt": "&lt;prompt text&gt;", "completion": "&lt;ideal generated text&gt;"}
{"prompt": "&lt;prompt text&gt;", "completion": "&lt;ideal generated text&gt;"}
{"prompt": "&lt;prompt text&gt;", "completion": "&lt;ideal generated text&gt;"}
</code></pre><p>上面是数据集的格式，现在我要提供大量的低代码平台数据集：</p><pre><code class="language-json">{"prompt":"生成一个带有矩形组件的页面","completion": "[{\"animations\":[],\"events\":{},\"groupStyle\":{},\"isLock\":false,\"collapseName\":\"style\",\"linkage\":{\"duration\":0,\"data\":[{\"id\":\"\",\"label\":\"\",\"event\":\"\",\"style\":[{\"key\":\"\",\"value\":\"\"}]}]},\"component\":\"RectShape\",\"label\":\"矩形\",\"propValue\":\"矩形\",\"icon\":\"juxing\",\"style\":{\"rotate\":0,\"opacity\":1,\"width\":200,\"height\":200,\"fontSize\":\"\",\"fontWeight\":400,\"lineHeight\":\"\",\"letterSpacing\":0,\"textAlign\":\"center\",\"color\":\"\",\"borderColor\":\"#000\",\"borderWidth\":1,\"backgroundColor\":\"\",\"borderStyle\":\"solid\",\"borderRadius\":\"\",\"verticalAlign\":\"middle\",\"top\":143,\"left\":379},\"id\":\"j3i41NnZw8Zcxu3BfbXwv\"}]"}
{"prompt":"生成一个带有文本组件的页面","completion": "[{\"animations\":[],\"events\":{},\"groupStyle\":{},\"isLock\":false,\"collapseName\":\"style\",\"linkage\":{\"duration\":0,\"data\":[{\"id\":\"\",\"label\":\"\",\"event\":\"\",\"style\":[{\"key\":\"\",\"value\":\"\"}]}]},\"component\":\"VText\",\"label\":\"文字\",\"propValue\":\"双击编辑文字\",\"icon\":\"wenben\",\"request\":{\"method\":\"GET\",\"data\":[],\"url\":\"\",\"series\":false,\"time\":1000,\"paramType\":\"\",\"requestCount\":0},\"style\":{\"rotate\":0,\"opacity\":1,\"width\":200,\"height\":28,\"fontSize\":\"\",\"fontWeight\":400,\"lineHeight\":\"\",\"letterSpacing\":0,\"textAlign\":\"\",\"color\":\"\",\"top\":145.3333282470703,\"left\":195},\"id\":\"WKqULBX4bKcmREgPJef3D\"}]"}
{"prompt":"生成一个带有按钮组件的页面","completion": "[{\"animations\":[],\"events\":{},\"groupStyle\":{},\"isLock\":false,\"collapseName\":\"style\",\"linkage\":{\"duration\":0,\"data\":[{\"id\":\"\",\"label\":\"\",\"event\":\"\",\"style\":[{\"key\":\"\",\"value\":\"\"}]}]},\"component\":\"VButton\",\"label\":\"按钮\",\"propValue\":\"按钮\",\"icon\":\"button\",\"style\":{\"rotate\":0,\"opacity\":1,\"width\":100,\"height\":34,\"borderWidth\":1,\"borderColor\":\"\",\"borderRadius\":\"\",\"fontSize\":\"\",\"fontWeight\":400,\"lineHeight\":\"\",\"letterSpacing\":0,\"textAlign\":\"\",\"color\":\"\",\"backgroundColor\":\"\",\"top\":126.33332824707031,\"left\":224},\"id\":\"6wgvR1wyRyNqIl37qs1iS\"}]"}
...
</code></pre><p>通过微调训练后，ChatGPT 就变成了一个专门的低代码模型，它会更好地理解低代码的需求。后面我们再让 ChatGPT 生成页面就不需要大量的 prompt 了，可以直接让它生成一个“用于宣传编程的海报页面”。记住，<strong>微调是一个需要精心设计和执行的过程，需要我们不停的校正，才能达到最好的效果</strong>。</p><h4 id="--3">使用模板、分类标签</h4><p>一个成功的低代码平台，一定会内置大量的模板，包括但不限于页面模板、应用模板等等。其实在大多数时候，用户提出生成页面的需求时，我们可以提取关键词，根据关键词找到符合用户需求的模板，再展示给用户选择。如果没有找到符合要求的模板，才使用 ChatGPT 来生成页面，这样不仅能节省成本，还避免了 ChatGPT 随机生成页面并且有可能生成错误页面的弊端。</p><p>现在我们来看看怎么做。首先，除了给模板命名，还需要给模板归类，比如打上几个类似于“医疗”、“后台管理系统”之类的标签。</p><p>当用户提出一个“生成用于宣传编程的页面”需求时，我们可以使用自然语言处理（NLP）库，如 <code>natural</code> 或者 <code>compromise</code> 进行关键词提取，然后再通过 <code>Elasticsearch</code> 来进行搜索，最后把搜索到的模板返回给用户。下面是代码示例：</p><pre><code class="language-js">const { Client } = require('@elastic/elasticsearch');
const { NlpManager } = require('node-nlp');

// 初始化Elasticsearch客户端
const client = new Client({ node: 'http://localhost:9200' });

// 初始化NLP管理器
const nlpManager = new NlpManager({ languages: ['en'], nlu: { useNoneFeature: false } });

// 假设我们有一些模板数据
const templates = [
    { name: 'Medical Service Promotion Page', tags: ['medical', 'promotion'] },
    { name: 'Programming Education Poster', tags: ['education', 'programming', 'poster'] },
    { name: 'Backend Management System Dashboard', tags: ['backend', 'management', 'system'] }
];

// 创建Elasticsearch索引
async function createIndex(indexName) {
    // 省略创建索引代码
}

// 索引模板数据到Elasticsearch
async function indexTemplates(indexName, templates) {
    // 省略索引数据代码
}

// 提取关键词
async function extractKeywords(text) {
    const result = await nlpManager.extractEntities(text);
    const keywords = result.entities.map(entity =&gt; entity.option || entity.utteranceText);
    return keywords;
}

// 使用Elasticsearch进行搜索
async function searchTemplates(indexName, keywords) {
    const { body } = await client.search({
        index: indexName,
        body: {
            query: {
                bool: {
                    should: [
                        { match: { name: { query: keywords.join(' '), boost: 2 } } },
                        { terms: { tags: keywords } }
                    ]
                }
            }
        }
    });

    return body.hits.hits.map(hit =&gt; hit._source);
}

// 主程序
async function main() {
    const indexName = 'templates';

    // 创建索引并索引数据
    await createIndex(indexName);
    await indexTemplates(indexName, templates);

    // 用户输入
    const userInput = "I want to create a poster page for programming promotion";

    // 提取关键词
    const keywords = await extractKeywords(userInput);

    // 执行搜索
    const results = await searchTemplates(indexName, keywords);

    // 输出结果
    console.log(results);
}

main().catch(console.error);
</code></pre><h2 id="-chatglm-6b-">使用开源大语言模型 ChatGLM-6B 进行页面生成</h2><p>对于无法使用外网或预算有限的项目，我们可以考虑使用开源的大语言模型。本文选了 <a href="https://github.com/THUDM/ChatGLM-6B">ChatGLM-6B</a> 开源模型来做演示。</p><h3 id="--4">安装</h3><p>ChatGLM-6B 本地部署的教程网上有很多，由于我的电脑是 Windows，并且没有 N 卡，所以我参考了这篇文章<a href="https://zhuanlan.zhihu.com/p/620455056" rel="nofollow">手把手教你本地部署清华大学 KEG 的 ChatGLM-6B 模型</a>来部署 ChatGLM-6B。</p><p>这篇文章讲得很细致，按照流程走下来只出现了两个小问题，解决后就跑通了。这里也记录一下这两个问题及相应的解决办法。</p><h4 id="tdm-gcc-">TDM-GCC 编译错误</h4><p>按照文章中的要求安装了 TDM-GCC 后发现编译 <code>quantization_kernels_parallel.c</code> 文件错误，卸载 TDM-GCC 后换了 <a href="https://www.mingw-w64.org/downloads/" rel="nofollow">MinGW-w64</a> 就好了，编译顺利通过。</p><h4 id="-python-">执行 python 脚本偶尔没反应</h4><p>这个问题排查了很久，经过不断的调试和重启，最后发现是系统资源不足（我的电脑 CPU 是 6800H，32G 内存，没有显卡）。在关掉无关程序后，只保留一个终端用于启动脚本后，就没有问题了。</p><h3 id="--5">使用</h3><p>ChatGLM-6B 的相关代码已经上传到了 <a href="https://github.com/woai3c/lowcode-llm-demo">GitHub </a>，大家可以把项目下载下来，然后按照仓库文档中的说明修改目录位置后，就可以执行 <code>cli-demo.py</code> 或者 <code>web-demo.py</code> 脚本和 ChatGLM-6B 进行交互了。下面的几张图片就是部署在我电脑上的 ChatGLM-6B 使用示例：</p><figure class="kg-card kg-image-card"><img src="https://camo.githubusercontent.com/20eaeef47be86fbb1d20d356202e4fe8cbe2cf5a4cf53b8ddda59ba9d37b39f7/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f6469726563742f37653262363530333432333334656633623731373236633166646362653632372e706e67" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://camo.githubusercontent.com/05f851eb4ddc05e40223cbdf24abc447e1c716f98c80c0f3ad04dd7cf2f79e48/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f6469726563742f62393237326130626561333134316339623566636263386336623161653336622e706e67" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>由于电脑配置不是特别好，ChatGLM-6B 在我电脑上运行起来比较慢，一个问题回答起来要花几分钟。像生成低代码页面的这个需求，就跑了十几分钟，最后程序直接崩了，只给我输出了一半的 JSON，不过数据结构是对的，所以换个好点的显卡后应该不是问题。</p><h2 id="--6">总结</h2><p>其实，大语言模型不仅能和低代码领域配合使用，经过训练后的模型可以和任何领域结合，从而生成该领域的专门模型。例如 Figma、即时设计、MasterGo 这种设计工具，它们存储的也是一份 JSON 数据。所以理论上也可以通过对话来生成设计页面。</p><p>还有其他的类似于 AI 客服、催收机器人都可以通过这种方法训练出来。</p><h3 id="--7">参考资料</h3><ul><li><a href="https://zhuanlan.zhihu.com/p/620455056" rel="nofollow">手把手教你本地部署清华大学 KEG 的 ChatGLM-6B 模型</a></li><li><a href="https://github.com/THUDM/ChatGLM-6B">ChatGLM-6B</a></li></ul><h3 id="--8">低代码系列文章</h3><ul><li><a href="https://github.com/woai3c/Front-end-articles/issues/19">可视化拖拽组件库一些技术要点原理分析</a></li><li><a href="https://github.com/woai3c/Front-end-articles/issues/20">可视化拖拽组件库一些技术要点原理分析（二）</a></li><li><a href="https://github.com/woai3c/Front-end-articles/issues/21">可视化拖拽组件库一些技术要点原理分析（三）</a></li><li><a href="https://github.com/woai3c/Front-end-articles/issues/33">可视化拖拽组件库一些技术要点原理分析（四）</a></li><li><a href="https://github.com/woai3c/Front-end-articles/issues/45">低代码与大语言模型的探索实践</a></li></ul><h3 id="--9">低代码相关仓库</h3><ul><li><a href="https://github.com/woai3c/visual-drag-demo">一个低代码（可视化拖拽）教学项目</a></li><li><a href="https://github.com/woai3c/lowcode-llm-demo/tree/main">低代码与大语言模型的结合实践 demo</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 如何为 Nestjs 编写单元测试和 E2E 测试 ]]>
                </title>
                <description>
                    <![CDATA[ 前言 最近在给一个 nestjs 项目写单元测试（Unit Testing）和 e2e 测试（End-to-End Testing，端到端测试，简称 e2e 测试），这是我第一次给后端项目写测试，发现和之前给前端项目写测试还不太一样，导致在一开始写测试时感觉无从下手。后来在看了一些示例之后才想明白怎么写测试，所以打算写篇文章记录并分享一下，以帮助和我有相同困惑的人。 同时我也写了一个 demo 项目，相关的单元测试、e2e 测试都写好了，有兴趣可以看一下。代码已上传到 GitHub: nestjs-demo [https://link.segmentfault.com/?enc=HLCKHEcBvrrecUEHdZldwg%3D%3D.rqCa%2BxjnMJTtq32ACk7AhaKfYgiVglZx55Z7ivxfu3eX7EDOP%2Fzeot%2FIeXAwGxeG] 。 单元测试和 E2E 测试的区别 单元测试和 e2e 测试都是软件测试的方法，但它们的目标和范围有所不同。 单元测试是对软件中的最小可测试单元进行检查和验证。比如一个函数、一个方法都可以是一个单元。在单元 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/how-to-write-unit-tests-and-e2e-tests-for-nestjs/</link>
                <guid isPermaLink="false">6690f01ddd1680043183e1d0</guid>
                
                    <category>
                        <![CDATA[ NextJS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 测试 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Fri, 12 Jul 2024 02:50:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2024/07/pexels-luis-gomes-546819.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <h2 id="-">前言</h2><p>最近在给一个 nestjs 项目写单元测试（Unit Testing）和 e2e 测试（End-to-End Testing，端到端测试，简称 e2e 测试），这是我第一次给后端项目写测试，发现和之前给前端项目写测试还不太一样，导致在一开始写测试时感觉无从下手。后来在看了一些示例之后才想明白怎么写测试，所以打算写篇文章记录并分享一下，以帮助和我有相同困惑的人。</p><p>同时我也写了一个 demo 项目，相关的单元测试、e2e 测试都写好了，有兴趣可以看一下。代码已上传到 GitHub: <a href="https://link.segmentfault.com/?enc=HLCKHEcBvrrecUEHdZldwg%3D%3D.rqCa%2BxjnMJTtq32ACk7AhaKfYgiVglZx55Z7ivxfu3eX7EDOP%2Fzeot%2FIeXAwGxeG" rel="nofollow">nestjs-demo</a>。</p><h2 id="-e2e-">单元测试和 E2E 测试的区别</h2><p>单元测试和 e2e 测试都是软件测试的方法，但它们的目标和范围有所不同。</p><p>单元测试是对软件中的最小可测试单元进行检查和验证。比如一个函数、一个方法都可以是一个单元。在单元测试中，你会对这个函数的各种输入给出预期的输出，并验证功能的正确性。单元测试的目标是快速发现函数内部的 bug，并且它们容易编写、快速执行。</p><p>而 e2e 测试通常通过模拟真实用户场景的方法来测试整个应用，例如前端通常使用浏览器或无头浏览器来进行测试，后端则是通过模拟对 API 的调用来进行测试。</p><p>在 nestjs 项目中，单元测试可能会测试某个服务（service）、某个控制器（controller）的一个方法，例如测试 Users 模块中的 <code>update</code> 方法是否能正确的更新一个用户。而一个 e2e 测试可能会测试一个完整的用户流程，如创建一个新用户，然后更新他们的密码，然后删除该用户。这涉及了多个服务和控制器。</p><h2 id="--1">编写单元测试</h2><p>为一个工具函数或者不涉及接口的方法编写单元测试，是非常简单的，你只需要考虑各种输入并编写相应的测试代码就可以了。但是一旦涉及到接口，那情况就复杂了。用代码来举例：</p><pre><code class="language-ts">async validateUser(
  username: string,
  password: string,
): Promise&lt;UserAccountDto&gt; {
  const entity = await this.usersService.findOne({ username });
  if (!entity) {
    throw new UnauthorizedException('User not found');
  }

  if (entity.lockUntil &amp;&amp; entity.lockUntil &gt; Date.now()) {
    const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000);
    let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`;
    if (diffInSeconds &gt; 60) {
      const diffInMinutes = Math.round(diffInSeconds / 60);
      message = `The account is locked. Please try again in ${diffInMinutes} minutes.`;
    }

    throw new UnauthorizedException(message);
  }

  const passwordMatch = bcrypt.compareSync(password, entity.password);
  if (!passwordMatch) {
    // $inc update to increase failedLoginAttempts
    const update = {
      $inc: { failedLoginAttempts: 1 },
    };

    // lock account when the third try is failed
    if (entity.failedLoginAttempts + 1 &gt;= 3) {
      // $set update to lock the account for 5 minutes
      update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 };
    }

    await this.usersService.update(entity._id, update);
    throw new UnauthorizedException('Invalid password');
  }

  // if validation is sucessful, then reset failedLoginAttempts and lockUntil
  if (
    entity.failedLoginAttempts &gt; 0 ||
    (entity.lockUntil &amp;&amp; entity.lockUntil &gt; Date.now())
  ) {
    await this.usersService.update(entity._id, {
      $set: { failedLoginAttempts: 0, lockUntil: null },
    });
  }

  return { userId: entity._id, username } as UserAccountDto;
}
</code></pre><p>上面的代码是 <code>auth.service.ts</code> 文件里的一个方法 <code>validateUser</code>，主要用于验证登录时用户输入的账号密码是否正确。它包含的逻辑如下：</p><ol><li>根据 <code>username</code> 查看用户是否存在，如果不存在则抛出 401 异常（也可以是 404 异常）</li><li>查看用户是否被锁定，如果被锁定则抛出 401 异常和相关的提示文字</li><li>将 <code>password</code> 加密后和数据库中的密码进行对比，如果错误则抛出 401 异常（连续三次登录失败会被锁定账户 5 分钟）</li><li>如果登录成功，则将之前登录失败的计数记录进行清空（如果有）并返回用户 <code>id</code> 和 <code>username</code> 到下一阶段</li></ol><p>可以看到 <code>validateUser</code> 方法包含了 4 个处理逻辑，我们需要对这 4 点都编写对应的单元测试代码，以确定整个 <code>validateUser</code> 方法功能是正常的。</p><h3 id="--2">第一个测试用例</h3><p>在开始编写单元测试时，我们会遇到一个问题，<code>findOne</code> 方法需要和数据库进行交互，它要通过 <code>username</code> 查找数据库中是否存在对应的用户。但如果每一个单元测试都得和数据库进行交互，那测试起来会非常麻烦。所以可以通过 mock 假数据来实现这一点。</p><p>举例，假如我们已经注册了一个 <code>woai3c</code> 的用户，那么当用户登录时，在 <code>validateUser</code> 方法中能够通过 <code>const entity = await this.usersService.findOne({ username });</code> 拿到用户数据。所以只要确保这行代码能够返回想要的数据，即使不和数据库交互也是没有问题的。而这一点，我们能通过 mock 数据来实现。现在来看一下 <code>validateUser</code> 方法的相关测试代码：</p><pre><code class="language-ts">import { Test } from '@nestjs/testing';
import { AuthService } from '@/modules/auth/auth.service';
import { UsersService } from '@/modules/users/users.service';
import { UnauthorizedException } from '@nestjs/common';
import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants';

describe('AuthService', () =&gt; {
  let authService: AuthService; // Use the actual AuthService type
  let usersService: Partial&lt;Record&lt;keyof UsersService, jest.Mock&gt;&gt;;

  beforeEach(async () =&gt; {
    usersService = {
      findOne: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: UsersService,
          useValue: usersService,
        },
      ],
    }).compile();

    authService = module.get&lt;AuthService&gt;(AuthService);
  });

  describe('validateUser', () =&gt; {
    it('should throw an UnauthorizedException if user is not found', async () =&gt; {
      await expect(
        authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
      ).rejects.toThrow(UnauthorizedException);
    });

    // other tests...
  });
});
</code></pre><p>我们通过调用 <code>usersService</code> 的 <code>fineOne</code> 方法来拿到用户数据，所以需要在测试代码中 mock <code>usersService</code> 的 <code>fineOne</code> 方法：</p><pre><code class="language-ts"> beforeEach(async () =&gt; {
    usersService = {
      findOne: jest.fn(), // 在这里 mock findOne 方法
    };

    const module = await Test.createTestingModule({
      providers: [
        AuthService, // 真实的 AuthService，因为我们要对它的方法进行测试
        {
          provide: UsersService, // 用 mock 的 usersService 代替真实的 usersService 
          useValue: usersService,
        },
      ],
    }).compile();

    authService = module.get&lt;AuthService&gt;(AuthService);
  });
</code></pre><p>通过使用 <code>jest.fn()</code> 返回一个函数来代替真实的 <code>usersService.findOne()</code>。如果这时调用 <code>usersService.findOne()</code> 将不会有任何返回值，所以第一个单元测试用例就能通过了：</p><pre><code class="language-ts">it('should throw an UnauthorizedException if user is not found', async () =&gt; {
  await expect(
    authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
  ).rejects.toThrow(UnauthorizedException);
});
</code></pre><p>因为在 <code>validateUser</code> 方法中调用 <code>const entity = await this.usersService.findOne({ username });</code> 的 <code>findOne</code> 是 mock 的假函数，没有返回值，所以 <code>validateUser</code> 方法中的第 2-4 行代码就能执行到了：</p><pre><code class="language-ts">if (!entity) {
  throw new UnauthorizedException('User not found');
}
</code></pre><p>抛出 401 错误，符合预期。</p><h3 id="--3">第二个测试用例</h3><p><code>validateUser</code> 方法中的第二个处理逻辑是判断用户是否锁定，对应的代码如下：</p><pre><code class="language-ts">if (entity.lockUntil &amp;&amp; entity.lockUntil &gt; Date.now()) {
  const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000);
  let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`;
  if (diffInSeconds &gt; 60) {
    const diffInMinutes = Math.round(diffInSeconds / 60);
    message = `The account is locked. Please try again in ${diffInMinutes} minutes.`;
  }

  throw new UnauthorizedException(message);
}
</code></pre><p>可以看到如果用户数据里有锁定时间 <code>lockUntil</code> 并且锁定结束时间大于当前时间就可以判断当前账户处于锁定状态。所以需要 mock 一个具有 <code>lockUntil</code> 字段的用户数据：</p><pre><code class="language-ts">it('should throw an UnauthorizedException if the account is locked', async () =&gt; {
  const lockedUser = {
    _id: TEST_USER_ID,
    username: TEST_USER_NAME,
    password: TEST_USER_PASSWORD,
    lockUntil: Date.now() + 1000 * 60 * 5, // The account is locked for 5 minutes
  };

  usersService.findOne.mockResolvedValueOnce(lockedUser);

  await expect(
    authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
  ).rejects.toThrow(UnauthorizedException);
});
</code></pre><p>在上面的测试代码里，先定义了一个对象 <code>lockedUser</code>，这个对象里有我们想要的 <code>lockUntil</code> 字段，然后将它作为 <code>findOne</code> 的返回值，这通过 <code>usersService.findOne.mockResolvedValueOnce(lockedUser);</code> 实现。然后 <code>validateUser</code> 方法执行时，里面的用户数据就是 mock 出来的数据了，从而成功让第二个测试用例通过。</p><h3 id="--4">单元测试覆盖率</h3><p>剩下的两个测试用例就不写了，原理都是一样的。如果剩下的两个测试不写，那么这个 <code>validateUser</code> 方法的单元测试覆盖率会是 50%，如果 4 个测试用例都写完了，那么 <code>validateUser</code> 方法的单元测试覆盖率将达到 100%。</p><p>单元测试覆盖率（Code Coverage）是一个度量，用于描述应用程序代码有多少被单元测试覆盖或测试过。它通常表示为百分比，表示在所有可能的代码路径中，有多少被测试用例覆盖。</p><p>单元测试覆盖率通常包括以下几种类型：</p><ul><li>行覆盖率（Lines）：测试覆盖了多少代码行。</li><li>函数覆盖率（Funcs）：测试覆盖了多少函数或方法。</li><li>分支覆盖率（Branch）：测试覆盖了多少代码分支（例如，<code>if/else</code> 语句）。</li><li>语句覆盖率（Stmts）：测试覆盖了多少代码语句。</li></ul><p>单元测试覆盖率是衡量单元测试质量的一个重要指标，但并不是唯一的指标。高的覆盖率可以帮助检测代码中的错误，但并不能保证代码的质量。覆盖率低可能意味着有未被测试的代码，可能存在未被发现的错误。</p><p>下图是 demo 项目的单元测试覆盖率结果：</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2024/07/image-3.png" class="kg-image" alt="image-3" srcset="https://chinese.freecodecamp.org/news/content/images/size/w600/2024/07/image-3.png 600w, https://chinese.freecodecamp.org/news/content/images/2024/07/image-3.png 655w" width="655" height="566" loading="lazy"></figure><p>像 service 和 controller 之类的文件，单元测试覆盖率一般尽量高点比较好，而像 module 这种文件就没有必要写单元测试了，也没法写，没有意义。上面的图片表示的是整个单元测试覆盖率的总体指标，如果你想查看某个函数的测试覆盖率，可以打开项目根目录下的 <code>coverage/lcov-report/index.html</code> 文件进行查看。例如我想查看 <code>validateUser</code> 方法具体的测试情况：</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2024/07/image-4.png" class="kg-image" alt="image-4" srcset="https://chinese.freecodecamp.org/news/content/images/size/w600/2024/07/image-4.png 600w, https://chinese.freecodecamp.org/news/content/images/2024/07/image-4.png 711w" width="711" height="780" loading="lazy"></figure><p>可以看到原来 <code>validateUser</code> 方法的单元测试覆盖率并不是 100%，还是有两行代码没有执行到，不过也无所谓了，不影响 4 个关键的处理节点，不要片面的追求高测试覆盖率。</p><h2 id="-e2e--1">编写E2E 测试</h2><p>在单元测试中我们展示了如何为 <code>validateUser()</code> 的每一个功能点编写单元测试，并且使用了 mock 数据的方法来确保每个功能点都能够被测试到。而在 e2e 测试中，我们需要模拟真实的用户场景，所以要连接数据库来进行测试。因此，这次测试的 <code>auth.service.ts</code> 模块里的方法都会和数据库进行交互。</p><p><code>auth</code> 模块主要有以下几个功能：</p><ul><li>注册</li><li>登录</li><li>刷新 token</li><li>读取用户信息</li><li>修改密码</li><li>删除用户</li></ul><p>e2e 测试需要将这六个功能都测试一遍，从<code>注册</code>开始，到<code>删除用户</code>结束。在测试时，我们可以建一个专门的测试用户来进行测试，测试完成后再删除这个测试用户，这样就不会在测试数据库中留下无用的信息了。</p><pre><code class="language-ts">beforeAll(async () =&gt; {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile()

  app = moduleFixture.createNestApplication()
  await app.init()

  // 执行登录以获取令牌
  const response = await request(app.getHttpServer())
    .post('/auth/register')
    .send({ username: TEST_USER_NAME, password: TEST_USER_PASSWORD })
    .expect(201)

  accessToken = response.body.access_token
  refreshToken = response.body.refresh_token
})

afterAll(async () =&gt; {
  await request(app.getHttpServer())
    .delete('/auth/delete-user')
    .set('Authorization', `Bearer ${accessToken}`)
    .expect(200)

  await app.close()
})
</code></pre><p><code>beforeAll</code> 钩子函数将在所有测试开始之前执行，所以我们可以在这里注册一个测试账号 <code>TEST_USER_NAME</code>。<code>afterAll</code> 钩子函数将在所有测试结束之后执行，所以在这删除测试账号 <code>TEST_USER_NAME</code> 是比较合适的，还能顺便对注册和删除两个功能进行测试。</p><p>在上一节的单元测试中，我们编写了关于 <code>validateUser</code> 方法的相关单元测试。其实这个方法是在登录时执行的，用于验证用户账号密码是否正确。所以这一次的 e2e 测试也将使用登录流程来展示如何编写 e2e 测试用例。</p><p>整个登录测试流程总共包含了五个小测试：</p><pre><code class="language-ts">describe('login', () =&gt; {
    it('/auth/login (POST)', () =&gt; {
      // ...
    })

    it('/auth/login (POST) with user not found', () =&gt; {
      // ...
    })

    it('/auth/login (POST) without username or password', async () =&gt; {
      // ...
    })

    it('/auth/login (POST) with invalid password', () =&gt; {
      // ...
    })

    it('/auth/login (POST) account lock after multiple failed attempts', async () =&gt; {
      // ...
    })
  })
</code></pre><p>这五个测试分别是：</p><ol><li>登录成功，返回 200</li><li>如果用户不存在，抛出 401 异常</li><li>如果不提供密码或用户名，抛出 400 异常</li><li>使用错误密码登录，抛出 401 异常</li><li>如果账户被锁定，抛出 401 异常</li></ol><p>现在我们开始编写 e2e 测试：</p><pre><code class="language-ts">// 登录成功
it('/auth/login (POST)', () =&gt; {
  return request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME, password: TEST_USER_PASSWORD })
    .expect(200)
})

// 如果用户不存在，应该抛出 401 异常
it('/auth/login (POST) with user not found', () =&gt; {
  return request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })
    .expect(401) // Expect an unauthorized error
})
</code></pre><p>e2e 的测试代码写起来比较简单，直接调用接口，然后验证结果就可以了。比如登录成功测试，我们只要验证返回结果是否是 200 即可。</p><p>前面四个测试都比较简单，现在我们看一个稍微复杂点的 e2e 测试，即验证账户是否被锁定。</p><pre><code class="language-ts">it('/auth/login (POST) account lock after multiple failed attempts', async () =&gt; {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile()

  const app = moduleFixture.createNestApplication()
  await app.init()

  const registerResponse = await request(app.getHttpServer())
    .post('/auth/register')
    .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })

  const accessToken = registerResponse.body.access_token
  const maxLoginAttempts = 3 // lock user when the third try is failed

  for (let i = 0; i &lt; maxLoginAttempts; i++) {
    await request(app.getHttpServer())
      .post('/auth/login')
      .send({ username: TEST_USER_NAME2, password: 'InvalidPassword' })
  }

  // The account is locked after the third failed login attempt
  await request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })
    .then((res) =&gt; {
      expect(res.body.message).toContain(
        'The account is locked. Please try again in 5 minutes.',
      )
    })

  await request(app.getHttpServer())
    .delete('/auth/delete-user')
    .set('Authorization', `Bearer ${accessToken}`)

  await app.close()
})
</code></pre><p><strong>当用户连续三次登录失败的时候，账户就会被锁定</strong>。所以在这个测试里，我们不能使用测试账号 <code>TEST_USER_NAME</code>，因为测试成功的话这个账户就会被锁定，无法继续进行下面的测试了。我们需要再注册一个新用户 <code>TEST_USER_NAME2</code>，专门用来测试账户锁定，测试成功后再删除这个用户。所以你可以看到这个 e2e 测试的代码非常多，需要做大量的前置、后置工作，其实真正的测试代码就这几行：</p><pre><code class="language-ts">// 连续三次登录
for (let i = 0; i &lt; maxLoginAttempts; i++) {
  await request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME2, password: 'InvalidPassword' })
}

// 测试账号是否被锁定
await request(app.getHttpServer())
  .post('/auth/login')
  .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })
  .then((res) =&gt; {
    expect(res.body.message).toContain(
      'The account is locked. Please try again in 5 minutes.',
    )
  })
</code></pre><p>可以看到编写 e2e 测试代码还是相对比较简单的，不需要考虑 mock 数据，不需要考虑测试覆盖率，只要整个系统流程的运转情况符合预期就可以了。</p><h2 id="--5">应不应该写测试</h2><p>如果有条件的话，我是比较建议大家写测试的。因为写测试可以提高系统的健壮性、可维护性和开发效率。</p><h3 id="--6">提高系统健壮性</h3><p>我们一般编写代码时，会关注于正常输入下的程序流程，确保核心功能正常运作。但是一些边缘情况，比如异常的输入，这些我们可能会经常忽略掉。但当我们开始编写测试时，情况就不一样了，这会逼迫你去考虑如何处理并提供相应的反馈，从而避免程序崩溃。可以说写测试实际上是在<strong>间接</strong>地提高系统健壮性。</p><h3 id="--7">提高可维护性</h3><p>当你接手一个新项目时，如果项目包含完善的测试，那将会是一件很幸福的事情。它们就像是项目的指南，帮你快速把握各个功能点。只看测试代码就能够轻松地了解每个功能的预期行为和边界条件，而不用你逐行的去查看每个功能的代码。</p><h3 id="--8">提高开发效率</h3><p>想象一下，一个长时间未更新的项目突然接到了新需求。改了代码后，你可能会担心引入 bug，如果没有测试，那就需要重新手动测试整个项目——浪费时间，效率低下。而有了完整的测试，一条命令就能得知代码更改有没有影响现有功能。即使出错了，也能够快速定位，找到问题点。</p><h3 id="--9">什么时候不建议写测试？</h3><p><strong>短期项目</strong>、<strong>需求迭代非常快的项目</strong>不建议写测试。比如某些活动项目，活动结束就没用了，这种项目就不需要写测试。另外，需求迭代非常快的项目也不要写测试，我刚才说写测试能提高开发效率是有前提条件的，就是<strong>功能迭代比较慢的情况下，写测试才能提高开发效率</strong>。如果你的功能今天刚写完，隔一两天就需求变更了要改功能，那相关的测试代码都得重写。所以干脆就别写了，靠团队里的测试人员测试就行了，因为写测试是非常耗时间的，没必要自讨苦吃。</p><p>根据我的经验来看，国内的绝大多数项目（尤其是政企类项目）都是没有必要写测试的，因为需求迭代太快，还老是推翻之前的需求，代码都得加班写，那有闲情逸致写测试。</p><h2 id="--10">总结</h2><p>在细致地讲解了如何为 Nestjs 项目编写单元测试及 e2e 测试之后，我还是想重申一下测试的重要性，它能够提高系统的健壮性、可维护性和开发效率。如果没有机会写测试，我建议大家可以自己搞个练习项目来写，或者说参加一些开源项目，给这些项目贡献代码，因为开源项目对于代码要求一般都比较严格。贡献代码可能需要编写新的测试用例或修改现有的测试用例。</p><p>最后，再推荐一下我的其他文章，如果你有兴趣，不妨一读：</p><ul><li><a href="https://www.freecodecamp.org/chinese/news/search?query=%E5%85%A5%E9%97%A8%E5%89%8D%E7%AB%AF%E5%B7%A5%E7%A8%8B">带你入门前端工程</a></li><li><a href="https://www.freecodecamp.org/chinese/news/browser-rendering-engine/">从零开始实现一个玩具版浏览器渲染引擎</a></li><li><a href="https://www.freecodecamp.org/chinese/news/build-a-micro-frontend-framework/">手把手教你写一个简易的微前端框架</a></li><li><a href="https://www.freecodecamp.org/chinese/news/tech-analysis-of-front-end-monitoring-sdk/">前端监控 SDK 的一些技术要点原理分析</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library/">可视化拖拽组件库一些技术要点原理分析</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-2/">可视化拖拽组件库一些技术要点原理分析（二）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-3/">可视化拖拽组件库一些技术要点原理分析（三）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-4/">可视化拖拽组件库一些技术要点原理分析（四）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/exploration-and-practices-for-low-code-and-llms/">低代码与大语言模型的探索实践</a></li><li><a href="https://www.freecodecamp.org/chinese/news/improve-front-end-performance/">前端性能优化 24 条建议（2020）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/create-a-scaffold/">手把手教你写一个脚手架</a></li><li><a href="https://www.freecodecamp.org/chinese/news/create-a-scaffold-2/">手把手教你写一个脚手架（二）</a></li></ul><h3 id="--11">参考资料</h3><ul><li><a href="https://nestjs.com/">NestJS</a>: A framework for building efficient, scalable Node.js server-side applications.</li><li><a href="https://www.mongodb.com/">MongoDB</a>: A NoSQL database used for data storage.</li><li><a href="https://jestjs.io/">Jest</a>: A testing framework for JavaScript and TypeScript.</li><li><a href="https://github.com/ladjs/supertest">Supertest</a>: A library for testing HTTP servers.</li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Vue 加载远程组件的解决方案 ]]>
                </title>
                <description>
                    <![CDATA[ 最近的项目有一个加载远程组件的需求。基于此我对 Vue 加载远程组件的方案进行了研究，并且整理了两个可行的解决方案。 HTML 文件 + umd 组件 这个方案是最简单、最容易实现的。组件以 umd 的格式进行打包，然后在 HTML 文件中直接使用。 <div id="app">     <test-input></test-input> </div> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script>     // 将组件 URL 挂载到 script 标签上，然后通过 window 获取这个组件     await lodaScript('http://localhost/component/input/0.1.0/bundle.js')     app.component('TestInput', window.TestInput) </script> 但是这个方案不适合在大型项目中使用，效率比较低。 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/loading-remote-components-in-vue/</link>
                <guid isPermaLink="false">64fed8dd58a38a1950d360ea</guid>
                
                    <category>
                        <![CDATA[ Vue ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 组件 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Wed, 13 Sep 2023 05:01:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2023/09/Vue-Blog-Cover-2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>最近的项目有一个加载远程组件的需求。基于此我对 Vue 加载远程组件的方案进行了研究，并且整理了两个可行的解决方案。</p><h3 id="html-umd-">HTML 文件 + umd 组件</h3><p>这个方案是最简单、最容易实现的。组件以 umd 的格式进行打包，然后在 HTML 文件中直接使用。</p><pre><code class="language-html">&lt;div id="app"&gt;
    &lt;test-input&gt;&lt;/test-input&gt;
&lt;/div&gt;
&lt;script src="https://unpkg.com/vue@3/dist/vue.global.js"&gt;&lt;/script&gt;
&lt;script&gt;
    // 将组件 URL 挂载到 script 标签上，然后通过 window 获取这个组件
    await lodaScript('http://localhost/component/input/0.1.0/bundle.js')
    app.component('TestInput', window.TestInput)
&lt;/script&gt;</code></pre><p>但是这个方案不适合在大型项目中使用，效率比较低。</p><h3 id="vue-esm-umd-">Vue 工程项目 + esm /umd 组件</h3><p>Vue 工程项目 + esm /umd 组件是我目前在使用的方案，但是在研究的过程中遇到了两个问题，逐一解决后，才把这个方案趟通了。</p><h3 id="-relative-references-must-start-with-either-or-">第一个问题 <code>Relative references must start with either "/", "./", or "../"</code></h3><p>由于我们的项目不需要兼容 IE，所以打包组件采用的是 esm 格式。打包后的组件源码如下：</p><pre><code class="language-js">import { reactive } from 'vue'
// other code...
</code></pre><p>然后在主项目中进行引用：</p><pre><code class="language-js">const { default: TestInput } = await import('http://localhost/component/input/0.1.0/bundle.mjs')
</code></pre><p>在动态导入远程组件到项目时，提示报错 <code>Relative references must start with either "/", "./", or "../"</code>。这是因为在浏览器中不支持以 <code>import { reactive } from 'vue'</code> 的方式进行导入，得把 <code>'vue'</code> 改成 <code>https://..../vue.js</code> 或者 <code>'./vue.js'</code> 的形式才可以。平时我们这样用没问题是因为有 vite、webpack 等构建工具帮忙解决了这个问题。</p><h3 id="-vue-">第二个问题 Vue 上下文环境不同</h3><p>产生上面的问题是因为要引入依赖，如果打包组件时把相关依赖都打在一起，那不就没有 import 语句了。结果试了一下还是不行，因为当前的 Vue 主项目和打包好的 Vue 组件存在两个不同的 Vue 上下文。导致在加载组件时报错，比如提示 <code>xxx 变量找不到</code> 这种问题。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-8fb51b72fa494fa4172ba9b4e4b4d6b4_1440w.webp" class="kg-image" alt="v2-8fb51b72fa494fa4172ba9b4e4b4d6b4_1440w" width="479" height="343" loading="lazy"></figure><p>虽然主项目和远程组件使用的 Vue 方法都是一样的，但由于各自的 Vue 上下文不一样，导致主项目无法正常使用远程组件。</p><p>以上两个问题困扰了我一天的时间，但是睡醒一觉后，终于想到了如何解决这两个问题。首先在浏览器上不能直接使用 <code>import { reactive } from 'vue'</code> 这种语句，那把它改成 <code>const { reactive } = Vue</code> 就能解决这个问题了。至于第二个问题，打包时不把依赖打在一起，而是在 <code>main.js</code> 文件中直接把整个 Vue 引进来：</p><pre><code class="language-js">import * as Vue from 'vue'
window.Vue = Vue
</code></pre><p>这样就能确保主项目和远程组件使用的是同一个 Vue 上下文。</p><p>为了解决代码转换问题，我写了一个 <a href="https://github.com/woai3c/rollup-plugin-import-to-const">rollup-plugin-import-to-const</a> 插件（支持 rollup、vite），打包 esm 组件时，它会自动的把 <code>import { reactive } from 'vue'</code> 转换成 <code>const { reactive } = Vue</code> 。</p><p>至此，就可以在主项目中加载远程 esm 组件了：</p><pre><code class="language-js">const { default: TestInput } = await import('http://localhost/component/input/0.1.0/bundle.mjs')
</code></pre><p>其实只要能解决上面的两个问题，不管是 esm 还是 umd、cjs 等格式，都能够实现加载远程组件的方案。比如换成 umd 的格式来打包组件，就不需要引入 rollup 插件去转换代码了，并且还能支持 webpack。唯一要做的只是在 <code>main.js</code> 上把 Vue 全引进来挂到 window 下。</p><pre><code class="language-js">import * as Vue from 'vue'
window.Vue = Vue
</code></pre><h3 id="-">总结</h3><p>远程组件的方案其实不止上面两种，比如还有直接加载 <code>.vue</code> 文件的方案，有个现成的 <a href="https://github.com/FranckFreiburger/vue3-sfc-loader">vue3-sfc-loader</a> 插件能用。 一般来说，加载远程组件的应用场景比较少，所以网上能搜到的讨论也比较少。目前比较常见的应用场景应该就是在低代码平台中加载远程组件了。</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 从零开始实现一个玩具版浏览器渲染引擎 ]]>
                </title>
                <description>
                    <![CDATA[ 浏览器渲染原理作为前端必须要了解的知识点之一，在面试中经常会被问到。在一些前端书籍或者培训课程里也会经常被提及，比如 MDN 文档中就有渲染原理 [https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work#%E6%B8%B2%E6%9F%93] 的相关描述。 作为一名工作多年的前端，我对于渲染原理自然也是了解的，但是对于它的理解只停留在理论知识层面。所以我决定自己动手实现一个玩具版的渲染引擎。 渲染引擎是浏览器的一部分，它负责将网页内容（HTML、CSS、JavaScript 等）转化为用户可阅读、观看、听到的形式。但是要独自实现一个完整的渲染引擎工作量实在太大了，而且也很困难。于是我决定退一步，打算实现一个玩具版的渲染引擎。刚好 Github 上有一个开源的用 Rust 写的玩具版渲染引擎 robinson [https://github.com/woai3c/tiny-rendering-engine]，于是决定模仿其源码自己用 JavaScript 实现一遍，并且也在 Github 上 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/browser-rendering-engine/</link>
                <guid isPermaLink="false">64fed78058a38a1950d360df</guid>
                
                    <category>
                        <![CDATA[ 浏览器 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ HTML ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CSS ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Tue, 12 Sep 2023 03:35:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2023/09/1694616133719.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>浏览器渲染原理作为前端必须要了解的知识点之一，在面试中经常会被问到。在一些前端书籍或者培训课程里也会经常被提及，比如 MDN 文档中就有<a href="https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work#%E6%B8%B2%E6%9F%93">渲染原理</a>的相关描述。</p><p>作为一名工作多年的前端，我对于渲染原理自然也是了解的，但是对于它的理解只停留在理论知识层面。所以我决定自己动手实现一个玩具版的渲染引擎。</p><p>渲染引擎是浏览器的一部分，它负责将网页内容（HTML、CSS、JavaScript 等）转化为用户可阅读、观看、听到的形式。但是要独自实现一个完整的渲染引擎工作量实在太大了，而且也很困难。于是我决定退一步，打算实现一个玩具版的渲染引擎。刚好 Github 上有一个开源的用 Rust 写的玩具版渲染引擎 <a href="https://github.com/woai3c/tiny-rendering-engine">robinson</a>，于是决定模仿其源码自己用 JavaScript 实现一遍，并且也在 Github 上开源了<a href="https://github.com/woai3c/tiny-rendering-engine">从零开始实现一个玩具版浏览器渲染引擎</a>。</p><p>这个玩具版的渲染引擎一共分为五个阶段：</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-266e8e94510504d11b930612229012ac_1440w.webp" class="kg-image" alt="v2-266e8e94510504d11b930612229012ac_1440w" width="739" height="204" loading="lazy"></figure><p>分别是：</p><ol><li>解析 HTML，生成 DOM 树</li><li>解析 CSS，生成 CSS 规则集合</li><li>生成 Style 树</li><li>生成布局树</li><li>绘制</li></ol><p>每个阶段的代码我在仓库上都用一个分支来表示。由于直接看整个渲染引擎的代码可能会比较困难，所以我建议大家从第一个分支开始进行学习，从易到难，这样学习效果更好。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-27a55d7173240190324c2f9d18b02a32_1440w.webp" class="kg-image" alt="v2-27a55d7173240190324c2f9d18b02a32_1440w" width="222" height="152" loading="lazy"></figure><p>现在我们先看一下如何编写一个 HTML 解析器。</p><h3 id="html-">HTML 解析器</h3><p>HTML 解析器的作用就是将一连串的 HTML 文本解析为 DOM 树。比如将这样的 HTML 文本：</p><pre><code class="language-html">&lt;div class="lightblue test" id=" div " data-index="1"&gt;test!&lt;/div&gt;</code></pre><p>解析为一个 DOM 树：</p><pre><code class="language-js">{
    "tagName": "div",
    "attributes": {
        "class": "lightblue test",
        "id": "div",
        "data-index": "1"
    },
    "children": [
        {
            "nodeValue": "test!",
            "nodeType": 3
        }
    ],
    "nodeType": 1
}
</code></pre><p>写解析器需要懂一些编译原理的知识，比如词法分析、语法分析什么的。但是我们的玩具版解析器非常简单，即使不懂也没有关系，大家看源码就能明白了。</p><p>再回到上面的那段 HTML 文本，它的整个解析过程可以用下面的图来表示，每一段 HTML 文本都有对应的方法去解析。</p><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-19271cbd4bdf074f13f0351d39415b4f_1440w.webp" class="kg-image" alt="v2-19271cbd4bdf074f13f0351d39415b4f_1440w" width="1223" height="305" loading="lazy"></figure><p>为了让解析器实现起来简单一点，我们需要对 HTML 的功能进行约束：</p><ol><li>标签必须要成对出现：<code>&lt;div&gt;...&lt;/div&gt;</code></li><li>HTML 属性值必须要有引号包起来 <code>&lt;div class="test"&gt;...&lt;/div&gt;</code></li><li>不支持注释</li><li>尽量不做错误处理</li><li>只支持两种类型节点 <code>Element</code> 和 <code>Text</code></li></ol><p>对解析器的功能进行约束后，代码实现就变得简单多了，现在让我们继续吧。</p><h3 id="-">节点类型</h3><p>首先，为这两种节点 <code>Element</code> 和 <code>Text</code> 定一个适当的数据结构：</p><pre><code class="language-ts">export enum NodeType {
    Element = 1,
    Text = 3,
}

export interface Element {
    tagName: string
    attributes: Record&lt;string, string&gt;
    children: Node[]
    nodeType: NodeType.Element
}

interface Text {
    nodeValue: string
    nodeType: NodeType.Text
}

export type Node = Element | Text
</code></pre><p>然后为这两种节点各写一个生成函数：</p><pre><code class="language-ts">export function element(tagName: string) {
    return {
        tagName,
        attributes: {},
        children: [],
        nodeType: NodeType.Element,
    } as Element
}

export function text(data: string) {
    return {
        nodeValue: data,
        nodeType: NodeType.Text,
    } as Text
}
</code></pre><p>这两个函数在解析到元素节点或者文本节点时调用，调用后会返回对应的 DOM 节点。</p><h3 id="html--1">HTML 解析器的执行过程</h3><p>下面这张图就是整个 HTML 解析器的执行过程：</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-5fc6cad8bfec2129f8f1c82cb71175a2_1440w.webp" class="kg-image" alt="v2-5fc6cad8bfec2129f8f1c82cb71175a2_1440w" width="987" height="638" loading="lazy"></figure><p>HTML 解析器的入口方法为 <code>parse()</code>，从这开始执行直到遍历完所有 HTML 文本为止：</p><ol><li>判断当前字符是否为 <code>&lt;</code>，如果是，则当作元素节点来解析，调用 <code>parseElement()</code>，否则调用 <code>parseText()</code></li><li><code>parseText()</code> 比较简单，一直往前遍历字符串，直至遇到 <code>&lt;</code> 字符为止。然后将之前遍历过的所有字符当作 <code>Text</code> 节点的值。</li><li><code>parseElement()</code> 则相对复杂一点，它首先要解析出当前的元素标签名称，这段文本用 <code>parseTag()</code> 来解析。</li><li>然后再进入 <code>parseAttrs()</code> 方法，判断是否有属性节点，如果该节点有 <code>class</code> 或者其他 HTML 属性，则会调用 <code>parseAttr()</code> 把 HTML 属性或者 <code>class</code> 解析出来。</li><li>至此，整个元素节点的前半段已经解析完了。接下来需要解析它的子节点。这时就会进入无限递归循环回到第一步，继续解析元素节点或文本节点。</li><li>当所有子节点解析完后，需要调用 <code>parseTag()</code>，看看结束标签名和元素节点的开始标签名是否相同，如果相同，则 <code>parseElement()</code> 或者 <code>parse()</code> 结束，否则报错。</li></ol><h3 id="html--2">HTML 解析器各个方法详解</h3><h3 id="-parse-">入口方法 parse()</h3><p>HTML 的入口方法是 <code>parse(rawText)</code></p><pre><code class="language-ts">parse(rawText: string) {
    this.rawText = rawText.trim()
    this.len = this.rawText.length
    this.index = 0
    this.stack = []

    const root = element('root')
    while (this.index &lt; this.len) {
        this.removeSpaces()
        if (this.rawText[this.index].startsWith('&lt;')) {
            this.index++
            this.parseElement(root)
        } else {
            this.parseText(root)
        }
    }
}
</code></pre><p>入口方法需要遍历所有文本，在一开始它需要判断当前字符是否是 <code>&lt;</code>，如果是，则将它当作元素节点来解析，调用 <code>parseElement()</code>，否则将当前字符作为文本来解析，调用 <code>parseText()</code>。</p><h3 id="-parseelement-">解析元素节点 <code>parseElement()</code></h3><pre><code class="language-ts">private parseElement(parent: Element) {
 // 解析标签
    const tag = this.parseTag()
    // 生成元素节点
    const ele = element(tag)

    this.stack.push(tag)

    parent.children.push(ele)
    // 解析属性
    this.parseAttrs(ele)

    while (this.index &lt; this.len) {
        this.removeSpaces()
        if (this.rawText[this.index].startsWith('&lt;')) {
            this.index++
            this.removeSpaces()
            // 判断是否是结束标签
            if (this.rawText[this.index].startsWith('/')) {
                this.index++
                const startTag = this.stack[this.stack.length - 1]
                // 结束标签
                const endTag = this.parseTag()
                if (startTag !== endTag) {
                    throw Error(`The end tagName ${endTag} does not match start tagName ${startTag}`)
                }

                this.stack.pop()
                while (this.index &lt; this.len &amp;&amp; this.rawText[this.index] !== '&gt;') {
                    this.index++
                }

                break
            } else {
                this.parseElement(ele)
            }
        } else {
            this.parseText(ele)
        }
    }

    this.index++
}
</code></pre><p><code>parseElement()</code> 会依次调用 <code>parseTag()</code> <code>parseAttrs()</code> 解析标签和属性，然后再递归解析子节点，终止条件是遍历完所有的 HTML 文本。</p><h3 id="-parsetext-">解析文本节点 <code>parseText()</code></h3><pre><code class="language-ts">private parseText(parent: Element) {
    let str = ''
    while (
        this.index &lt; this.len
        &amp;&amp; !(this.rawText[this.index] === '&lt;' &amp;&amp; /\w|\//.test(this.rawText[this.index + 1]))
    ) {
        str += this.rawText[this.index]
        this.index++
    }

    this.sliceText()
    parent.children.push(text(removeExtraSpaces(str)))
}
</code></pre><p>解析文本相对简单一点，它会一直往前遍历，直至遇到 <code>&lt;</code> 为止。比如这段文本 <code>&lt;div&gt;test!&lt;/div&gt;</code>，经过 <code>parseText()</code> 解析后拿到的文本是 <code>test!</code>。</p><h3 id="-parsetag-">解析标签 <code>parseTag()</code></h3><p>在进入 <code>parseElement()</code> 后，首先调用就是 <code>parseTag()</code>，它的作用是解析标签名：</p><pre><code class="language-ts">private parseTag() {
    let tag = ''

    this.removeSpaces()

    // get tag name
    while (this.index &lt; this.len &amp;&amp; this.rawText[this.index] !== ' ' &amp;&amp; this.rawText[this.index] !== '&gt;') {
        tag += this.rawText[this.index]
        this.index++
    }

    this.sliceText()
    return tag
}
</code></pre><p>比如这段文本 <code>&lt;div&gt;test!&lt;/div&gt;</code>，经过 <code>parseTag()</code> 解析后拿到的标签名是 <code>div</code>。</p><h3 id="-parseattrs-">解析属性节点 <code>parseAttrs()</code></h3><p>解析完标签名后，接着再解析属性节点：</p><pre><code class="language-ts">// 解析元素节点的所有属性
private parseAttrs(ele: Element) {
 // 一直遍历文本，直至遇到 '&gt;' 字符为止，代表 &lt;div ....&gt; 这一段文本已经解析完了
    while (this.index &lt; this.len &amp;&amp; this.rawText[this.index] !== '&gt;') {
        this.removeSpaces()
        this.parseAttr(ele)
        this.removeSpaces()
    }

    this.index++
}

// 解析单个属性，例如 class="foo bar"
private parseAttr(ele: Element) {
    let attr = ''
    let value = ''
    while (this.index &lt; this.len &amp;&amp; this.rawText[this.index] !== '=' &amp;&amp; this.rawText[this.index] !== '&gt;') {
        attr += this.rawText[this.index++]
    }

    this.sliceText()
    attr = attr.trim()
    if (!attr.trim()) return

    this.index++
    let startSymbol = ''
    if (this.rawText[this.index] === "'" || this.rawText[this.index] === '"') {
        startSymbol = this.rawText[this.index++]
    }

    while (this.index &lt; this.len &amp;&amp; this.rawText[this.index] !== startSymbol) {
        value += this.rawText[this.index++]
    }

    this.index++
    ele.attributes[attr] = value.trim()
    this.sliceText()
}
</code></pre><p><code>parseAttr()</code> 可以将这样的文本 <code>class="test"</code> 解析为一个对象 <code>{ class: "test" }</code>。</p><h3 id="--1">其他辅助方法</h3><p>有时不同的节点、属性之间有很多多余的空格，所以需要写一个方法将多余的空格清除掉。</p><pre><code class="language-ts">protected removeSpaces() {
    while (this.index &lt; this.len &amp;&amp; (this.rawText[this.index] === ' ' || this.rawText[this.index] === '\n')) {
        this.index++
    }

    this.sliceText()
}
</code></pre><p>同时为了方便调试，开发者经常需要打断点看当前正在遍历的字符是什么。如果以前遍历过的字符串还在，那么是比较难调试的，因为开发者需要根据 index 的值自己去找当前遍历的字符是什么。所以所有解析完的 HTML 文本，都需要截取掉，确保当前的 HTML 文本都是没有被遍历：</p><pre><code class="language-ts">protected sliceText() {
    this.rawText = this.rawText.slice(this.index)
    this.len = this.rawText.length
    this.index = 0
}
</code></pre><p><code>sliceText()</code> 方法的作用就是截取已经遍历过的 HTML 文本。用下图来做例子，假设当前要解析 <code>div</code> 这个标签名：</p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-d18d74c75c7c028b686132e9c2377b9d_1440w.webp" class="kg-image" alt="v2-d18d74c75c7c028b686132e9c2377b9d_1440w" width="1169" height="301" loading="lazy"></figure><p>那么解析后需要对 HTML 文本进行截取，就像下图这样：</p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-8a073dda6507fcb4e834339399065ba5_1440w.webp" class="kg-image" alt="v2-8a073dda6507fcb4e834339399065ba5_1440w" width="923" height="179" loading="lazy"></figure><h3 id="--2">小结</h3><p>至此，整个 HTML 解析器的逻辑已经讲完了，所有代码加起来 200 行左右，如果不算 TS 各种类型声明，代码只有 100 多行。</p><h3 id="css-">CSS 解析器</h3><p>CSS 样式表是一系列的 CSS 规则集合，而 CSS 解析器的作用就是将 CSS 文本解析为 CSS 规则集合。</p><pre><code class="language-css">div, p {
    font-size: 88px;
    color: #000;
}</code></pre><p>例如上面的 CSS 文本，经过解析器解析后，会生成下面的 CSS 规则集合：</p><pre><code class="language-js">[
    {
        "selectors": [
            {
                "id": "",
                "class": "",
                "tagName": "div"
            },
            {
                "id": "",
                "class": "",
                "tagName": "p"
            }
        ],
        "declarations": [
            {
                "name": "font-size",
                "value": "88px"
            },
            {
                "name": "color",
                "value": "#000"
            }
        ]
    }
]
</code></pre><p>每个规则都有一个 <code>selector</code> 和 <code>declarations</code> 属性，其中 <code>selectors</code> 表示 CSS 选择器，<code>declarations</code> 表示 CSS 的属性描述集合。</p><pre><code class="language-ts">export interface Rule {
    selectors: Selector[]
    declarations: Declaration[]
}

export interface Selector {
    tagName: string
    id: string
    class: string
}

export interface Declaration {
    name: string
    value: string | number
}
</code></pre><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-4ae9e0e860a5248e0e4e3a82f2c983eb_1440w.webp" class="kg-image" alt="v2-4ae9e0e860a5248e0e4e3a82f2c983eb_1440w" width="758" height="267" loading="lazy"></figure><p>每一条 CSS 规则都可以包含多个选择器和多个 CSS 属性。</p><h3 id="-css-parserule-">解析 CSS 规则 <code>parseRule()</code></h3><pre><code class="language-ts">private parseRule() {
    const rule: Rule = {
        selectors: [],
        declarations: [],
    }

    rule.selectors = this.parseSelectors()
    rule.declarations = this.parseDeclarations()

    return rule
}
</code></pre><p>在 <code>parseRule()</code> 里，它分别调用了 <code>parseSelectors()</code> 去解析 CSS 选择器，然后再对剩余的 CSS 文本执行 <code>parseDeclarations()</code> 去解析 CSS 属性。</p><h3 id="-parseselector-">解析选择器 <code>parseSelector()</code></h3><pre><code class="language-ts">private parseSelector() {
    const selector: Selector = {
        id: '',
        class: '',
        tagName: '',
    }

    switch (this.rawText[this.index]) {
        case '.':
            this.index++
            selector.class = this.parseIdentifier()
            break
        case '#':
            this.index++
            selector.id = this.parseIdentifier()
            break
        case '*':
            this.index++
            selector.tagName = '*'
            break
        default:
            selector.tagName = this.parseIdentifier()
    }

    return selector
}

private parseIdentifier() {
    let result = ''
    while (this.index &lt; this.len &amp;&amp; this.identifierRE.test(this.rawText[this.index])) {
        result += this.rawText[this.index++]
    }

    this.sliceText()
    return result
}
</code></pre><p>选择器我们只支持标签名称、前缀为 <code>#</code> 的 ID 、前缀为任意数量的类名 <code>.</code> 或上述的某种组合。如果标签名称为 <code>*</code>，则表示它是一个通用选择器，可以匹配任何标签。</p><p>标准的 CSS 解析器在遇到无法识别的部分时，会将它丢掉，然后继续解析其余部分。主要是为了兼容旧浏览器和防止发生错误导致程序中断。我们的 CSS 解析器为了实现简单，没有做这方面的做错误处理。</p><h3 id="-css-parsedeclaration-">解析 CSS 属性 <code>parseDeclaration()</code></h3><pre><code class="language-ts">private parseDeclaration() {
    const declaration: Declaration = { name: '', value: '' }
    this.removeSpaces()
    declaration.name = this.parseIdentifier()
    this.removeSpaces()

    while (this.index &lt; this.len &amp;&amp; this.rawText[this.index] !== ':') {
        this.index++
    }

    this.index++ // clear :
    this.removeSpaces()
    declaration.value = this.parseValue()
    this.removeSpaces()

    return declaration
}
</code></pre><p><code>parseDeclaration()</code> 会将 <code>color: red;</code> 解析为一个对象 <code>{ name: "color", value: "red" }</code>。</p><h3 id="--3">小结</h3><p>CSS 解析器相对来说简单多了，因为很多知识点在 HTML 解析器中已经讲到。整个 CSS 解析器的代码大概 100 多行，如果你阅读过 HTML 解析器的源码，相信看 CSS 解析器的源码会更轻松。</p><h3 id="--4">构建样式树</h3><p>本阶段的目标是写一个样式构建器，输入 DOM 树和 CSS 规则集合，生成一棵样式树 Style tree。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-266e8e94510504d11b930612229012ac_1440w.webp" class="kg-image" alt="v2-266e8e94510504d11b930612229012ac_1440w" width="739" height="204" loading="lazy"></figure><p>样式树的每一个节点都包含了 CSS 属性值以及它对应的 DOM 节点引用：</p><pre><code class="language-ts">interface AnyObject {
    [key: string]: any
}

export interface StyleNode {
    node: Node // DOM 节点
    values: AnyObject // style 属性值
    children: StyleNode[] // style 子节点
}
</code></pre><p>先来看一个简单的示例：</p><pre><code class="language-html">&lt;div&gt;test&lt;/div&gt;
div {
    font-size: 88px;
    color: #000;
}</code></pre><p>上述的 HTML、CSS 文本在经过样式树构建器处理后生成的样式树如下：</p><pre><code class="language-js">{
    "node": { // DOM 节点
        "tagName": "div",
        "attributes": {},
        "children": [
            {
                "nodeValue": "test",
                "nodeType": 3
            }
        ],
        "nodeType": 1
    },
    "values": { // CSS 属性值
        "font-size": "88px",
        "color": "#000"
    },
    "children": [ // style tree 子节点
        {
            "node": {
                "nodeValue": "test",
                "nodeType": 3
            },
            "values": { // text 节点继承了父节点样式
                "font-size": "88px",
                "color": "#000"
            },
            "children": []
        }
    ]
}
</code></pre><h3 id="-dom-">遍历 DOM 树</h3><p>现在我们需要遍历 DOM 树。对于 DOM 树中的每个节点，我们都要在样式树中查找是否有匹配的 CSS 规则。</p><pre><code class="language-ts">export function getStyleTree(eles: Node | Node[], cssRules: Rule[], parent?: StyleNode) {
    if (Array.isArray(eles)) {
        return eles.map((ele) =&gt; getStyleNode(ele, cssRules, parent))
    }

    return getStyleNode(eles, cssRules, parent)
}
</code></pre><h3 id="--5">匹配选择器</h3><p>匹配选择器实现起来非常容易，因为我们的CSS 解析器仅支持简单的选择器。 只需要查看元素本身即可判断选择器是否与元素匹配。</p><pre><code class="language-ts">/**
 * css 选择器是否匹配元素
 */
function isMatch(ele: Element, selectors: Selector[]) {
    return selectors.some((selector) =&gt; {
        // 通配符
        if (selector.tagName === '*') return true
        if (selector.tagName === ele.tagName) return true
        if (ele.attributes.id === selector.id) return true

        if (ele.attributes.class) {
            const classes = ele.attributes.class.split(' ').filter(Boolean)
            const classes2 = selector.class.split(' ').filter(Boolean)
            for (const name of classes) {
                if (classes2.includes(name)) return true
            }
        }

        return false
    })
}
</code></pre><p>当查找到匹配的 DOM 节点后，再将 DOM 节点和它匹配的 CSS 属性组合在一起，生成样式树节点 styleNode：</p><pre><code class="language-ts">function getStyleNode(ele: Node, cssRules: Rule[], parent?: StyleNode) {
    const styleNode: StyleNode = {
        node: ele,
        values: getStyleValues(ele, cssRules, parent),
        children: [],
    }

    if (ele.nodeType === NodeType.Element) {
        // 合并内联样式
        if (ele.attributes.style) {
            styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) }
        }

        styleNode.children = ele.children.map((e) =&gt; getStyleNode(e, cssRules, styleNode)) as unknown as StyleNode[]
    }

    return styleNode
}

function getStyleValues(ele: Node, cssRules: Rule[], parent?: StyleNode) {
    const inheritableAttrValue = getInheritableAttrValues(parent)

    // 文本节点继承父元素的可继承属性
    if (ele.nodeType === NodeType.Text) return inheritableAttrValue

    return cssRules.reduce((result: AnyObject, rule) =&gt; {
        if (isMatch(ele as Element, rule.selectors)) {
            result = { ...result, ...cssValueArrToObject(rule.declarations) }
        }

        return result
    }, inheritableAttrValue)
}
</code></pre><p>在 CSS 选择器中，不同的选择器优先级是不同的，比如 id 选择器就比类选择器的优先级要高。但是我们这里没有实现选择器优先级，为了实现简单，所有的选择器优先级是一样的。</p><h3 id="--6">继承属性</h3><p>文本节点无法匹配选择器，那它的样式从哪来？答案就是继承，它可以继承父节点的样式。</p><p>在 CSS 中存在很多继承属性，即使子元素没有声明这些属性，也可以从父节点里继承。比如字体颜色、字体家族等属性，都是可以被继承的。为了实现简单，这里只支持继承父节点的 <code>color</code>、<code>font-size</code> 属性。</p><pre><code class="language-ts">// 子元素可继承的属性，这里只写了两个，实际上还有很多
const inheritableAttrs = ['color', 'font-size']

/**
 * 获取父元素可继承的属性值
 */
function getInheritableAttrValues(parent?: StyleNode) {
    if (!parent) return {}
    const keys = Object.keys(parent.values)
    return keys.reduce((result: AnyObject, key) =&gt; {
        if (inheritableAttrs.includes(key)) {
            result[key] = parent.values[key]
        }

        return result
    }, {})
}
</code></pre><h3 id="--7">内联样式</h3><p>在 CSS 中，内联样式的优先级是除了 <code>!important</code> 之外最高的。</p><pre><code class="language-html">&lt;span style="color: red; background: yellow;"&gt;</code></pre><p>我们可以在调用 <code>getStyleValues()</code> 函数获得当前 DOM 节点的 CSS 属性值后，再去取当前节点的内联样式值。并对当前 DOM 节点的 CSS 样式值进行覆盖。</p><pre><code class="language-ts">styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) }

function getInlineStyle(str: string) {
    str = str.trim()
    if (!str) return {}
    const arr = str.split(';')
    if (!arr.length) return {}

    return arr.reduce((result: AnyObject, item: string) =&gt; {
        const data = item.split(':')
        if (data.length === 2) {
            result[data[0].trim()] = data[1].trim()
        }

        return result
    }, {})
}
</code></pre><h3 id="--8">布局树</h3><p>第四阶段讲的是如何将样式树转化为布局树，也是整个渲染引擎相对比较复杂的部分。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-266e8e94510504d11b930612229012ac_1440w.webp" class="kg-image" alt="v2-266e8e94510504d11b930612229012ac_1440w" width="739" height="204" loading="lazy"></figure><h3 id="css--1">CSS 盒子模型</h3><p>在 CSS 中，所有的 DOM 节点都可以当作一个盒子。这个盒子模型包含了内容、内边距、边框、外边距以及在页面中的位置信息。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-6ca1a4d0cb14b09aea28fefe3dc91dc2_1440w.webp" class="kg-image" alt="v2-6ca1a4d0cb14b09aea28fefe3dc91dc2_1440w" width="494" height="341" loading="lazy"></figure><p>我们可以用以下的数据结构来表示盒子模型：</p><pre><code class="language-ts">export default class Dimensions {
    content: Rect
    padding: EdgeSizes
    border: EdgeSizes
    margin: EdgeSizes
}

export default class Rect {
    x: number
    y: number
    width: number
    height: number
}

export interface EdgeSizes {
    top: number
    right: number
    bottom: number
    left: number
}
</code></pre><h3 id="--9">块布局和内联布局</h3><p>CSS 的 <code>display</code> 属性决定了盒子在页面中的布局方式。<code>display</code> 的类型有很多种，例如 <code>block</code>、<code>inline</code>、<code>flex</code> 等等，但这里只支持 <code>block</code> 和 <code>inline</code> 两种布局方式，并且所有盒子的默认布局方式为 <code>display: inline</code>。</p><p>我会用伪 HTML 代码来描述它们之间的区别：</p><pre><code class="language-html">&lt;container&gt;
  &lt;a&gt;&lt;/a&gt;
  &lt;b&gt;&lt;/b&gt;
  &lt;c&gt;&lt;/c&gt;
  &lt;d&gt;&lt;/d&gt;
&lt;/container&gt;</code></pre><p>块布局会将盒子从上至下的垂直排列。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-02572058706b774cb4eb6d2920127b6a_1440w.webp" class="kg-image" alt="v2-02572058706b774cb4eb6d2920127b6a_1440w" width="384" height="224" loading="lazy"></figure><p>内联布局则会将盒子从左至右的水平排列。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-dbd175cba44b114a7a8fe74efac00b7a_1440w.webp" class="kg-image" alt="v2-dbd175cba44b114a7a8fe74efac00b7a_1440w" width="393" height="130" loading="lazy"></figure><p>如果容器内同时存在块布局和内联布局，则会用一个匿名布局将内联布局包裹起来。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-98271cb522c63a126d86abfd9648ed8e_1440w.webp" class="kg-image" alt="v2-98271cb522c63a126d86abfd9648ed8e_1440w" width="388" height="236" loading="lazy"></figure><p>这样就能将内联布局的盒子和其他块布局的盒子区别开来。</p><p>通常情况下内容是垂直增长的。也就是说，在容器中添加子节点通常会使容器更高，而不是更宽。另一种说法是，默认情况下，子节点的宽度取决于其容器的宽度，而容器的高度取决于其子节点的高度。</p><h3 id="--10">布局树</h3><p>布局树是所有盒子节点的集合。</p><pre><code class="language-ts">export default class LayoutBox {
 dimensions: Dimensions
 boxType: BoxType
 children: LayoutBox[]
 styleNode: StyleNode
}
</code></pre><p>盒子节点的类型可以是 <code>block</code>、<code>inilne</code> 和 <code>anonymous</code>。</p><pre><code class="language-ts">export enum BoxType {
    BlockNode = 'BlockNode',
    InlineNode = 'InlineNode',
    AnonymousBlock = 'AnonymousBlock',
}
</code></pre><p>我们构建样式树时，需要根据每一个 DOM 节点的 <code>display</code> 属性来生成对应的盒子节点。</p><pre><code class="language-ts">export function getDisplayValue(styleNode: StyleNode) {
    return styleNode.values?.display ?? Display.Inline
}
</code></pre><p>如果 DOM 节点 <code>display</code> 属性的值为 <code>none</code>，则在构建布局树的过程中，无需将这个 DOM 节点添加到布局树上，直接忽略它就可以了。</p><p>如果一个块节点包含一个内联子节点，则需要创建一个匿名块（实际上就是块节点）来包含它。如果一行中有多个子节点，则将它们全部放在同一个匿名容器中。</p><pre><code class="language-ts">function buildLayoutTree(styleNode: StyleNode) {
    if (getDisplayValue(styleNode) === Display.None) {
        throw new Error('Root node has display: none.')
    }

    const layoutBox = new LayoutBox(styleNode)

    let anonymousBlock: LayoutBox | undefined
    for (const child of styleNode.children) {
        const childDisplay = getDisplayValue(child)
        // 如果 DOM 节点 display 属性值为 none，直接跳过
        if (childDisplay === Display.None) continue

        if (childDisplay === Display.Block) {
            anonymousBlock = undefined
            layoutBox.children.push(buildLayoutTree(child))
        } else {
         // 创建一个匿名容器，用于容纳内联节点
            if (!anonymousBlock) {
                anonymousBlock = new LayoutBox()
                layoutBox.children.push(anonymousBlock)
            }

            anonymousBlock.children.push(buildLayoutTree(child))
        }
    }

    return layoutBox
}
</code></pre><h3 id="--11">遍历布局树</h3><p>现在开始构建布局树，入口函数是 <code>getLayoutTree()</code>：</p><pre><code class="language-ts">export function getLayoutTree(styleNode: StyleNode, parentBlock: Dimensions) {
    parentBlock.content.height = 0
    const root = buildLayoutTree(styleNode)
    root.layout(parentBlock)
    return root
}
</code></pre><p>它将遍历样式树，利用样式树节点提供的相关信息，生成一个 <code>LayoutBox</code> 对象，然后调用 <code>layout()</code> 方法。计算每个盒子节点的位置、尺寸信息。</p><p>在本节内容的开头有提到过，盒子的宽度取决于其父节点，而高度取决于子节点。这意味着，我们的代码在计算宽度时需要自上而下遍历树，这样它就可以在知道父节点的宽度后设置子节点的宽度。然后自下而上遍历以计算高度，这样父节点的高度就可以在计算子节点的相关信息后进行计算。</p><pre><code class="language-ts">layout(parentBlock: Dimensions) {
 // 子节点的宽度依赖于父节点的宽度，所以要先计算当前节点的宽度，再遍历子节点
    this.calculateBlockWidth(parentBlock)
    // 计算盒子节点的位置
    this.calculateBlockPosition(parentBlock)
    // 遍历子节点并计算对位置、尺寸信息
    this.layoutBlockChildren()
    // 父节点的高度依赖于其子节点的高度，所以计算子节点的高度后，再计算自己的高度
    this.calculateBlockHeight()
}
</code></pre><p>这个方法执行布局树的单次遍历，向下执行宽度计算，向上执行高度计算。一个真正的布局引擎可能会执行几次树遍历，有些是自上而下的，有些是自下而上的。</p><h3 id="--12">计算宽度</h3><p>现在，我们先来计算盒子节点的宽度，这部分比较复杂，需要详细的讲解。</p><p>首先，我们要拿到当前节点的 <code>width</code> <code>padding</code> <code>border</code> <code>margin</code> 等信息：</p><pre><code class="language-ts">calculateBlockWidth(parentBlock: Dimensions) {
 // 初始值
 const styleValues = this.styleNode?.values || {}

 // 初始值为 auto
 let width = styleValues.width ?? 'auto'
 let marginLeft = styleValues['margin-left'] || styleValues.margin || 0
 let marginRight = styleValues['margin-right'] || styleValues.margin || 0

 let borderLeft = styleValues['border-left'] || styleValues.border || 0
 let borderRight = styleValues['border-right'] || styleValues.border || 0

 let paddingLeft = styleValues['padding-left'] || styleValues.padding || 0
 let paddingRight = styleValues['padding-right'] || styleValues.padding || 0

 // 拿到父节点的宽度，如果某个属性为 'auto'，则将它设为 0
 let totalWidth = sum(width, marginLeft, marginRight, borderLeft, borderRight, paddingLeft, paddingRight)
 // ...
</code></pre><p>如果这些属性没有设置，就使用 0 作为默认值。拿到当前节点的总宽度后，还需要和父节点对比一下是否相等。如果宽度或边距设置为 <code>auto</code>，则可以对这两个属性进行适当展开或收缩以适应可用空间。所以现在需要对当前节点的宽度进行检查。</p><pre><code class="language-ts">const isWidthAuto = width === 'auto'
const isMarginLeftAuto = marginLeft === 'auto'
const isMarginRightAuto = marginRight === 'auto'

// 当前块的宽度如果超过了父元素宽度，则将它的可扩展外边距设为 0
if (!isWidthAuto &amp;&amp; totalWidth &gt; parentWidth) {
    if (isMarginLeftAuto) {
        marginLeft = 0
    }

    if (isMarginRightAuto) {
        marginRight = 0
    }
}

// 根据父子元素宽度的差值，去调整当前元素的宽度
const underflow = parentWidth - totalWidth

// 如果三者都有值，则将差值填充到 marginRight
if (!isWidthAuto &amp;&amp; !isMarginLeftAuto &amp;&amp; !isMarginRightAuto) {
    marginRight += underflow
} else if (!isWidthAuto &amp;&amp; !isMarginLeftAuto &amp;&amp; isMarginRightAuto) {
 // 如果右边距是 auto，则将 marginRight 设为差值
    marginRight = underflow
} else if (!isWidthAuto &amp;&amp; isMarginLeftAuto &amp;&amp; !isMarginRightAuto) {
 // 如果左边距是 auto，则将 marginLeft 设为差值
    marginLeft = underflow
} else if (isWidthAuto) {
    // 如果只有 width 是 auto，则将另外两个值设为 0
    if (isMarginLeftAuto) {
        marginLeft = 0
    }

    if (isMarginRightAuto) {
        marginRight = 0
    }

    if (underflow &gt;= 0) {
        // 展开宽度，填充剩余空间，原来的宽度是 auto，作为 0 来计算的
        width = underflow
    } else {
        // 宽度不能为负数，所以需要调整 marginRight 来代替
        width = 0
        // underflow 为负数，相加实际上就是缩小当前节点的宽度
        marginRight += underflow
    }
} else if (!isWidthAuto &amp;&amp; isMarginLeftAuto &amp;&amp; isMarginRightAuto) {
    // 如果只有 marginLeft 和 marginRight 是 auto，则将两者设为 underflow 的一半
    marginLeft = underflow / 2
    marginRight = underflow / 2
}
</code></pre><p>详细的计算过程请看上述代码，重要的地方都已经标上注释了。</p><p>通过对比当前节点和父节点的宽度，我们可以拿到一个差值：</p><pre><code class="language-ts">// 根据父子元素宽度的差值，去调整当前元素的宽度
const underflow = parentWidth - totalWidth
</code></pre><p>如果这个差值为正数，说明子节点宽度小于父节点；如果差值为负数，说明子节点大于父节。上面这段代码逻辑其实就是根据 <code>underflow</code> <code>width</code> <code>padding</code> <code>margin</code> 等值对子节点的宽度、边距进行调整，以适应父节点的宽度。</p><h3 id="--13">定位</h3><p>计算当前节点的位置相对来说简单一点。这个方法会根据当前节点的 <code>margin</code> <code>border</code> <code>padding</code> 样式以及父节点的位置信息对当前节点进行定位：</p><pre><code class="language-ts">calculateBlockPosition(parentBlock: Dimensions) {
 const styleValues = this.styleNode?.values || {}
 const { x, y, height } = parentBlock.content
 const dimensions = this.dimensions

 dimensions.margin.top = transformValueSafe(styleValues['margin-top'] || styleValues.margin || 0)
 dimensions.margin.bottom = transformValueSafe(styleValues['margin-bottom'] || styleValues.margin || 0)

 dimensions.border.top = transformValueSafe(styleValues['border-top'] || styleValues.border || 0)
 dimensions.border.bottom = transformValueSafe(styleValues['border-bottom'] || styleValues.border || 0)

 dimensions.padding.top = transformValueSafe(styleValues['padding-top'] || styleValues.padding || 0)
 dimensions.padding.bottom = transformValueSafe(styleValues['padding-bottom'] || styleValues.padding || 0)

 dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left
 dimensions.content.y = y + height + dimensions.margin.top + dimensions.border.top + dimensions.padding.top
}

function transformValueSafe(val: number | string) {
    if (val === 'auto') return 0
    return parseInt(String(val))
}
</code></pre><p>比如获取当前节点内容区域的 x 坐标，计算方式如下：</p><pre><code class="language-ts">dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left
</code></pre><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-23cf320afb0d12b4b25079d4d1a57805_1440w.webp" class="kg-image" alt="v2-23cf320afb0d12b4b25079d4d1a57805_1440w" width="1140" height="603" loading="lazy"></figure><h3 id="--14">遍历子节点</h3><p>在计算高度之前，需要先遍历子节点，因为父节点的高度需要根据它下面子节点的高度进行适配。</p><pre><code class="language-ts">layoutBlockChildren() {
    const { dimensions } = this
    for (const child of this.children) {
        child.layout(dimensions)
        // 遍历子节点后，再计算父节点的高度
        dimensions.content.height += child.dimensions.marginBox().height
    }
}
</code></pre><p>每个节点的高度就是它上下两个外边距之间的差值，所以可以通过 <code>marginBox()</code> 获得高度：</p><pre><code class="language-ts">export default class Dimensions {
    content: Rect
    padding: EdgeSizes
    border: EdgeSizes
    margin: EdgeSizes

    constructor() {
        const initValue = {
            top: 0,
            right: 0,
            bottom: 0,
            left: 0,
        }

        this.content = new Rect()

        this.padding = { ...initValue }
        this.border = { ...initValue }
        this.margin = { ...initValue }
    }

    paddingBox() {
        return this.content.expandedBy(this.padding)
    }

    borderBox() {
        return this.paddingBox().expandedBy(this.border)
    }

    marginBox() {
        return this.borderBox().expandedBy(this.margin)
    }
}
export default class Rect {
    x: number
    y: number
    width: number
    height: number

    constructor() {
        this.x = 0
        this.y = 0
        this.width = 0
        this.height = 0
    }

    expandedBy(edge: EdgeSizes) {
        const rect = new Rect()
        rect.x = this.x - edge.left
        rect.y = this.y - edge.top
        rect.width = this.width + edge.left + edge.right
        rect.height = this.height + edge.top + edge.bottom

        return rect
    }
}
</code></pre><p>遍历子节点并执行完相关计算方法后，再将各个子节点的高度进行相加，得到父节点的高度。</p><h3 id="height-">height 属性</h3><p>默认情况下，节点的高度等于其内容的高度。但如果手动设置了 <code>height</code> 属性，则需要将节点的高度设为指定的高度：</p><pre><code class="language-ts">calculateBlockHeight() {
    // 如果元素设置了 height，则使用 height，否则使用 layoutBlockChildren() 计算出来的高度
    const height = this.styleNode?.values.height
    if (height) {
        this.dimensions.content.height = parseInt(height)
    }
}
</code></pre><p>为了简单起见，我们不需要实现<a href="https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_box_model/Mastering_margin_collapsing" rel="nofollow noreferrer">外边距折叠</a>。</p><h3 id="--15">小结</h3><p>布局树是渲染引擎最复杂的部分，这一阶段结束后，我们就了解了布局树中每个盒子节点在页面中的具体位置和尺寸信息。下一步，就是如何把布局树渲染到页面上了。</p><h3 id="--16">绘制</h3><p>绘制阶段主要是根据布局树中各个节点的位置、尺寸信息将它们绘制到页面。目前大多数计算机使用光栅（raster，也称为位图）显示技术。将布局树各个节点绘制到页面的这个过程也被称为“光栅化”。</p><p>浏览器通常在图形API和库（如Skia、Cairo、Direct2D等）的帮助下实现光栅化。这些API提供绘制多边形、直线、曲线、渐变和文本的功能。</p><p>实际上绘制才是最难的部分，但是这一步我们有现成的 <a href="https://link.zhihu.com/?target=https%3A//github.com/Automattic/node-canvas" rel="nofollow noreferrer">canvas</a> 库可以用，不用自己实现一个光栅器，所以相对来说就变得简单了。在真正开始绘制阶段之前，我们先来学习一些关于计算机如何绘制图像、文本的基础知识，有助于我们理解光栅化的具体实现过程。</p><h3 id="--17">计算机如何绘制图像、文本</h3><p>在计算机底层进行像素绘制属于硬件操作，它依赖于屏幕和显卡接口的具体细节。为了简单起点，我们可以用一段内存区域来表示屏幕，内存的一个 bit 就代表了屏幕中的一个像素。比如在屏幕中的 <code>(x,y)</code> 坐标绘制一个像素，可以用 <code>memory[x + y * rowSize] = 1</code> 来表示。从屏幕左上角开始，列是从左至右开始计数，行是从上至下开始计数。因此屏幕最左上角的坐标是 <code>(0,0)</code>。</p><p>为了简单起见，我们用 1 bit 来表示屏幕的一个像素，0 代表白色，1 代表黑色。屏幕每一行的长度用变量 <code>rowSzie</code> 表示，每一列的高度用 <code>colSize</code> 表示。</p><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-6e4622b6e30abe0a476676e106b8d027_1440w.webp" class="kg-image" alt="v2-6e4622b6e30abe0a476676e106b8d027_1440w" width="496" height="358" loading="lazy"></figure><h3 id="--18">绘制线条</h3><p>如果我们要在计算机上绘制一条直线，那么只要知道计算机的起点坐标 <code>(x1,y1)</code> 和终点坐标 <code>(x2,y2)</code> 就可以了。</p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-4cb7322d209b50961a3979ee8e8e51bd_1440w.webp" class="kg-image" alt="v2-4cb7322d209b50961a3979ee8e8e51bd_1440w" width="400" height="246" loading="lazy"></figure><p>然后根据 <code>memory[x + y * rowSize] = 1</code> 公式，将 <code>(x1,y1)</code> 至 <code>(x2,y2)</code> 之间对应的内存区域置为 1，这样就画出来了一条直线。</p><h3 id="--19">绘制文本</h3><p>为了在屏幕上显示文本，首先必须将物理上基于像素点的屏幕，在逻辑上以字符为单位划分成若干区域，每个区域能输出单个完整的字符。假设有一个 256 行 512 列的屏幕，如果为每个字符分配一个 11*8 像素的网格，那么屏幕上总共能显示 23 行，每行 64 个字符（还有 3 行像素没使用）。</p><p>有了这些前提条件后，我们现在打算在屏幕上画一个 <code>A</code>：</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-887b0d213e83b3958eb78c87f4b5d644_1440w.webp" class="kg-image" alt="v2-887b0d213e83b3958eb78c87f4b5d644_1440w" width="313" height="362" loading="lazy"></figure><p>上图的 <code>A</code> 在内存区域中用 11*8 像素的网格表示。为了在内存区域中绘制它，我们可以用一个二维数组来表示它：</p><pre><code class="language-js">const charA = [
 [0, 0, 1, 1, 0, 0, 0, 0], // 按从左至右的顺序来读取 bit，转换成十进制数字就是 12
 [0, 1, 1, 1, 1, 0, 0, 0], // 30
 [1, 1, 0, 0, 1, 1, 0, 0], // 51
 [1, 1, 0, 0, 1, 1, 0, 0], // 51
 [1, 1, 1, 1, 1, 1, 0, 0], // 63
 [1, 1, 0, 0, 1, 1, 0, 0], // 51
 [1, 1, 0, 0, 1, 1, 0, 0], // 51
 [1, 1, 0, 0, 1, 1, 0, 0], // 51
 [1, 1, 0, 0, 1, 1, 0, 0], // 51
 [0, 0, 0, 0, 0, 0, 0, 0], // 0
 [0, 0, 0, 0, 0, 0, 0, 0], // 0
]
</code></pre><p>上面二维数组的第一项，代表了第一行内存区域每个 bit 的取值。一共 11 行，画出了一个字母 <code>A</code>。</p><p>如果我们为 26 个字母都建一个映射表，按 ascii 的编码来排序，那么 <code>charsMap[65]</code> 就代表字符 <code>A</code>，当用户在键盘上按下 <code>A</code> 键时，就把 <code>charsMap[65]</code> 对应的数据输出到内存区域上，这样屏幕上就显示了一个字符 <code>A</code>。</p><h3 id="--20">绘制布局树</h3><p>科普完关于绘制屏幕的基础知识后，我们现在正式开始绘制布局树（为了方便，我们使用 <a href="https://link.zhihu.com/?target=https%3A//github.com/Automattic/node-canvas" rel="nofollow noreferrer">node-canvas</a> 库）。</p><p>首先要遍历整个布局树，然后逐个节点进行绘制：</p><pre><code class="language-ts">function renderLayoutBox(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) {
    renderBackground(layoutBox, ctx)
    renderBorder(layoutBox, ctx)
    renderText(layoutBox, ctx, parent)
    for (const child of layoutBox.children) {
        renderLayoutBox(child, ctx, layoutBox)
    }
}
</code></pre><p>这个函数对每个节点依次绘制背景色、边框、文本，然后再递归绘制所有子节点。</p><p>默认情况下，HTML 元素按照它们出现的顺序进行绘制。如果两个元素重叠，则后一个元素将绘制在前一个元素之上。这种排序反映在我们的布局树中，它将按照元素在 DOM 树中出现的顺序绘制元素。</p><h3 id="--21">绘制背景色</h3><pre><code class="language-ts">function renderBackground(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) {
    const { width, height, x, y } = layoutBox.dimensions.borderBox()
    ctx.fillStyle = getStyleValue(layoutBox, 'background')
    ctx.fillRect(x, y, width, height)
}
</code></pre><p>首先拿到布局节点的位置、尺寸信息，以 <code>x,y</code> 作为起点，绘制矩形区域。并且以 CSS 属性 <code>background</code> 的值作为背景色进行填充。</p><h3 id="--22">绘制边框</h3><pre><code class="language-ts">function renderBorder(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) {
    const { width, height, x, y } = layoutBox.dimensions.borderBox()
    const { left, top, right, bottom } = layoutBox.dimensions.border
    const borderColor = getStyleValue(layoutBox, 'border-color')
    if (!borderColor) return

    ctx.fillStyle = borderColor

    // left
    ctx.fillRect(x, y, left, height)
    // top
    ctx.fillRect(x, y, width, top)
    // right
    ctx.fillRect(x + width - right, y, right, height)
    // bottom
    ctx.fillRect(x, y + height - bottom, width, bottom)
}
</code></pre><p>绘制边框，其实我们绘制的是四个矩形，每一个矩形就是一条边框。</p><h3 id="--23">绘制文本</h3><pre><code class="language-ts">function renderText(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) {
    if (layoutBox.styleNode?.node.nodeType === NodeType.Text) {
        // get AnonymousBlock x y
        const { x = 0, y = 0, width } = parent?.dimensions.content || {}
        const styles = layoutBox.styleNode?.values || {}
        const fontSize = styles['font-size'] || '14px'
        const fontFamily = styles['font-family'] || 'serif'
        const fontWeight = styles['font-weight'] || 'normal'
        const fontStyle = styles['font-style'] || 'normal'

        ctx.fillStyle = styles.color
        ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`
        ctx.fillText(layoutBox.styleNode?.node.nodeValue, x, y + parseInt(fontSize), width)
    }
}
</code></pre><p>通过 canvas 的 <code>fillText()</code> 方法，我们可以很方便的绘制带有字体风格、大小、颜色的文本。</p><h3 id="--24">输出图片</h3><p>绘制完成后，我们可以借助 <code>canvas</code> 的 API 输出图片。下面用一个简单的示例来演示一下：</p><pre><code class="language-html">&lt;html&gt;
    &lt;body id=" body " data-index="1" style="color: red; background: yellow;"&gt;
        &lt;div&gt;
            &lt;div class="lightblue test"&gt;test1!&lt;/div&gt;
            &lt;div class="lightblue test"&gt;
                &lt;div class="foo"&gt;foo&lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;
    &lt;/body&gt;
&lt;/html&gt;
* {
    display: block;
}

div {
    font-size: 14px;
    width: 400px;
    background: #fff;
    margin-bottom: 20px;
    display: block;
    background: lightblue;
}

.lightblue {
    font-size: 16px;
    display: block;
    width: 200px;
    height: 200px;
    background: blue;
    border-color: green;
    border: 10px;
}

.foo {
    width: 100px;
    height: 100px;
    background: red;
    color: yellow;
    margin-left: 50px;
}

body {
    display: block;
    font-size: 88px;
    color: #000;
}</code></pre><p>上面这段 HTML、CSS 代码经过渲染引擎程序解析后生成的图片如下：</p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-57e893eb457caeba4ceb5b70a77b8889_1440w.webp" class="kg-image" alt="v2-57e893eb457caeba4ceb5b70a77b8889_1440w" width="1000" height="500" loading="lazy"></figure><h3 id="--25">总结</h3><p>至此，这个玩具版的渲染引擎就完成了。虽然这个玩具并没有什么用，但如果能通过实现它来了解真实的渲染引擎是如何运作的，从这个角度来看，它还是“有用”的。</p><h3 id="--26">参考资料</h3><ul><li><a href="https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html">Let's build a browser engine!</a></li><li><a href="https://github.com/mbrubeck/robinson">robinson</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work">渲染页面：浏览器的工作原理</a></li><li><a href="https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/Performance/Critical_rendering_path" rel="nofollow noreferrer">关键渲染路径</a></li><li><a href="https://link.zhihu.com/?target=https%3A//book.douban.com/subject/1998341/" rel="nofollow noreferrer">计算机系统要素</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ markdown 编辑器实现双屏同步滚动 ]]>
                </title>
                <description>
                    <![CDATA[ 由于一直在使用 markdown 编辑器写技术文章，所以对于编写体验很敏感。我发现各大社区的 markdown 编辑器基本都有同步滚动功能。只不过有些做得好，有些做得马马虎虎。出于好奇，我就打算自己亲自实现一下这个功能。 思考了一段时间，最后想出来了三种方案：  1. 百分比滚动  2. 双屏同时渲染占用面积大的元素  3. 每一行的元素都赋上一个索引，根据索引来精确同步每一行的滚动高度 百分比滚动 假设现在正在滚动 a 屏，那 a 屏的滚动百分比计算方式为：a 屏的滚动高度 / a 屏的内容总高度，用代码表示 a.scrollTop / a.scrollHeight。当滚动 a 屏时，需要手动同步 b 屏的滚动高度，也就是根据 a 屏的滚动百分比算出 b 屏的滚动高度： a.onscroll = () => { 	b.scrollTo({ top: a.scrollTop / a.scrollHeight * b.scrollHeight }) } ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/how-to-achieve-simultaneous-scrolling-on-both-screens-in-markdown-editor-2/</link>
                <guid isPermaLink="false">633bfd4b96409407bcba358c</guid>
                
                    <category>
                        <![CDATA[ markdown ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Fri, 07 Oct 2022 10:11:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2022/10/artem-sapegin-DErxVSSQNdM-unsplash.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>由于一直在使用 markdown 编辑器写技术文章，所以对于编写体验很敏感。我发现各大社区的 markdown 编辑器基本都有同步滚动功能。只不过有些做得好，有些做得马马虎虎。出于好奇，我就打算自己亲自实现一下这个功能。</p><p>思考了一段时间，最后想出来了三种方案：</p><ol><li>百分比滚动</li><li>双屏同时渲染占用面积大的元素</li><li>每一行的元素都赋上一个索引，根据索引来精确同步每一行的滚动高度</li></ol><h2 id="-">百分比滚动</h2><p>假设现在正在滚动 a 屏，那 a 屏的滚动百分比计算方式为：<code>a 屏的滚动高度 / a 屏的内容总高度</code>，用代码表示 <code>a.scrollTop / a.scrollHeight</code>。当滚动 a 屏时，需要手动同步 b 屏的滚动高度，也就是根据 a 屏的滚动百分比算出 b 屏的滚动高度：</p><pre><code class="language-js">a.onscroll = () =&gt; {
	b.scrollTo({ top: a.scrollTop / a.scrollHeight * b.scrollHeight })
}
</code></pre><p>原理就是这么简单，可惜实现效果不太好。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/380f955c3248465aac09139ee52dbced.gif#pic_center" class="kg-image" alt="在这里插入图片描述" width="983" height="751" loading="lazy"></figure><p>从上面的动图可以看出，当我在第二个大标题处停留的时候，左右双屏的内容是同步的。但当我滚动到第三个大标题时，左右双屏的内容高度已经差了将近 300 像素了。所以说这个方案勉勉强强能用吧，聊胜于无。</p><h2 id="--1">双屏同时渲染占用面积大的元素</h2><p>双屏内容高度不一致，是因为 markdown 同一个元素渲染后的高度和渲染前会有差别。例如一个图片，用 markdown 写就一行代码的事，但渲染出来的图片有大有小，高度几十、几百像素的都有。如果 markdown 的图片代码双屏同时渲染，倒是能解决这个问题。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/febdc97ccf50427c89ae8abce2030b39.png" class="kg-image" alt="在这里插入图片描述" width="851" height="267" loading="lazy"></figure><p>但是除了图片仍然有不少元素渲染前后的高度是有差距的，虽然没有图片这么夸张。譬如 h1 h2 这种，当文章内容越长，这种小差异带来的问题会越来越大，导致双屏内容高度的差距也会越来越大。所以说这种方案也不是很靠谱。</p><h2 id="--2">每一行的元素都赋上一个索引，根据索引来精确精确同步每一行的滚动高度</h2><p>之前两个方案都属于勉强能用，不够好。现在这个第三方案就比前面两个强多了，几乎能做到精确同步每一行的内容。具体怎么做呢？</p><h4 id="-markdown-">第一步，监听 markdown 编辑框的内容变化，为每一个元素赋上一个索引，空行空文本除外。</h4><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/e35b1af3d62a4e1986c2fbff1cf38a64.png" class="kg-image" alt="在这里插入图片描述" width="1240" height="364" loading="lazy"></figure><p>当把编辑框的 HTML 传给右边的框渲染时，需要把 <code>data-index</code> 赋值给渲染后的元素。这样就能通过 <code>data-index</code> 精确定位渲染前后的同一元素了。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/807f9374e8ea483a9546f9bcc6e4a928.png" class="kg-image" alt="在这里插入图片描述" width="1117" height="416" loading="lazy"></figure><h4 id="-a-b-">第二步，根据 a 屏的元素滚动高度计算 b 屏上同一索引的元素滚动高度</h4><p>在 a 屏进行滚动时，需要从上到下遍历 a 屏的所有元素，并且找到第一个在屏幕内的元素。<code>找到第一个在屏幕内的元素</code> 这句话的意思是因为在滚动过程中，有些元素会因为滚动跑到屏幕外面（原来在屏幕内，滚动到屏幕外），这些元素我们是不需要计算的。</p><p>判断一个元素是否在屏幕内：</p><pre><code class="language-js">// dom 是否在屏幕内
function isInScreen(dom) {
    const { top, bottom } = dom.getBoundingClientRect()
    return bottom &gt;= 0 &amp;&amp; top &lt; window.innerHeight
}
</code></pre><p>除了判断元素是否在屏幕内，还需要判断这个元素<strong><strong>在屏幕内的部分占整个元素高度的百分比</strong></strong>。譬如说一个图片的 markdown 字符串，由于滚动的原因，导致一半在屏幕内，一半在屏幕外。为了精确同步，那么渲染后的图片也必须有一半在屏幕内一半在屏幕外。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/fd617da2d5c3426cbcea5e38c31c8d44.gif#pic_center" class="kg-image" alt="在这里插入图片描述" width="690" height="363" loading="lazy"></figure><p><br>计算元素在屏幕内的百分比代码：</p><pre><code class="language-js">// dom 在当前屏幕展示内容的百分比
function percentOfdomInScreen(dom) {
	// 已经通过另一个函数 isInScreen() 确定了这个 dom 在屏幕内，所以只需要计算它在屏幕内的百分比，而不需要考虑它是否在屏幕外
    const { height, bottom } = dom.getBoundingClientRect()
    if (bottom &lt;= 0) return 0 // 不在屏幕内
    if (bottom &gt;= height) return 1 // 完全在屏幕内
    return bottom / height // 部分在屏幕内
}
</code></pre><p>现在我们就可以从上到下遍历 a 屏的所有元素，找到第一个在屏幕内的元素了：</p><pre><code class="language-js">// scrollContainer 即上面说的 a 屏，ShowContainer 是 b 屏
const nodes = Array.from(scrollContainer.children)
for (const node of nodes) {
    // 从上往下遍历，找到第一个在屏幕内的元素
    if (isInScreen(node) &amp;&amp; percentOfdomInScreen(node) &gt;= 0) {
        const index = node.dataset.index
        // 根据滚动元素的索引，找到它在渲染框中对应的元素
        const dom = ShowContainer.querySelector(`[data-index="${index}"]`)
		
		// 获取滚动元素在 a 屏中展示的内容百分比
		const percent = percentOfdomInScreen(node)
		// 计算这个对等元素在 b 屏中距离容器顶部的高度
        const heightToTop = getHeightToTop(dom)
        // 根据 percent 算出对等元素在 b 屏中需要隐藏的高度
        const domNeedHideHeight = dom.offsetHeight * (1 - percent)
		// scrollTo({ top: heightToTop }) 会把对等元素滚动到在 b 屏中恰好完全展示整个元素的位置
		// 然后再滚动它需要隐藏的高度 domNeedHideHeight，组合起来就是 scrollTo({ top: heightToTop + domNeedHideHeight })
        ShowContainer.scrollTo({ top: heightToTop + domNeedHideHeight })
        break
    }
}
</code></pre><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/42f6da16e84546baa916f487c1dc2466.gif#pic_center" class="kg-image" alt="在这里插入图片描述" width="596" height="578" loading="lazy"></figure><p>从动图来看，目前已经做到行内容的精确同步了。</p><h3 id="--3">踩坑</h3><p>有一些元素渲染后会变成嵌套元素，例如表格 table，渲染后的内容层级为：</p><pre><code class="language-html">&lt;table&gt;
	&lt;tbody&gt;
		&lt;tr&gt;
			&lt;td&gt;&lt;/td&gt;
		&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
</code></pre><p>按照目前的渲染逻辑，假如我写了个表格：</p><pre><code>|1|b|
...
</code></pre><p>那么 <code>|1|b|</code> 上的 <code>data-index</code> 会对应到 <code>table</code> 上。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/fc77b52eb8474623a5fad2fe89bc4cb3.png" class="kg-image" alt="在这里插入图片描述" width="420" height="41" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/540e65a70b4848ba86a0c3eab7352cb1.png" class="kg-image" alt="在这里插入图片描述" width="398" height="55" loading="lazy"></figure><p>那这就会有个 bug，当 <code>|1|b|</code> 滚动到 50% 的时候，整个 <code>table</code> 也会滚动到 50%。这个现象如下图所示：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/481b4aacede7497f99acc5ea81552846.png" class="kg-image" alt="在这里插入图片描述" width="659" height="178" loading="lazy"></figure><p>这和我们相要的效果不一样。a 屏连一行的内容都没滚完，b 屏整个内容已经滚动到一半了。</p><p>所以像这种嵌套的元素，在打 <code>data-index</code> 标记时，要把它打到真正的内容上。用表格 table 来做示例，就得把 <code>data-index</code> 的标记打在 <code>tr</code> 上。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/8f318fbdb240428391908757d5e259c5.png" class="kg-image" alt="在这里插入图片描述" width="480" height="94" loading="lazy"></figure><p>这样一来，同步滚动就正常了。同理，其他的嵌套元素也一样（譬如 ul ol）。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/b73507b417f846fe9ffb228d8ddb4e22.png" class="kg-image" alt="在这里插入图片描述" width="664" height="216" loading="lazy"></figure><h2 id="--4">总结</h2><p>完整的代码我已经放在 github 上了：</p><ul><li><a href="https://github.com/woai3c/markdown-editor-sync-scroll-demo">markdown-editor-sync-scroll-demo</a></li></ul><p>还有在线 DEMO：</p><ul><li><a href="https://jsrun.net/hwPKp">demo1</a></li><li><a href="https://jsrun.net/XwPKp">demo2</a></li><li><a href="https://jsrun.net/ywPKp">demo3</a></li><li><a href="https://jsrun.net/bwPKp">demo4</a></li><li><a href="https://jsrun.net/WwPKp">demo5</a></li><li><a href="https://jsrun.net/fwPKp">demo6</a></li></ul><p>如果在线 DEMO 比较慢，可以克隆项目后直接打开 html 文件访问。</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 移动端开发一些常见问题的解决方案 ]]>
                </title>
                <description>
                    <![CDATA[ 本文整理了我在移动开发中遇到的一些问题，并分享解决方案。 1. 获取设备的真实开发分辨率（逻辑分辨率） 最近查了好多关于移动端适配的资料，把人都看懵了，收获了一堆名词： CSS像素、物理分辨率、逻辑分辨率、设备像素比、PPI、DPI、DPR、DIP、Viewport 其实，对于开发来说，只需要了解三个概念。  1. 物理分辨率，就是设备上标称的分辨率。  2. 逻辑分辨率，开发时所用的分辨率。  3. 设备像素比，物理分辨率和逻辑分辨率之比。 例如 iphone 6，它的物理分辨率是 750 x 1334，逻辑分辨率是 375 x 667，设备像素比是 2（750 / 375）。打开 chrome 控制台，切换到 device toolbar， 选择 iphone 6 设备就能看到它的逻辑分辨率。 平时所说的 UI 设计稿 2 倍图、3 倍图。这个倍就是指设备像素比。例如设计稿是 2 倍图，里面的字体是 24 px，那我们用 24 / 2  就可以得出开发要用的像素为 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/solutions-to-some-mobile-development-issues/</link>
                <guid isPermaLink="false">5fbb6ec739641a0517d510ea</guid>
                
                    <category>
                        <![CDATA[ 移动端 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 分辨率 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Thu, 09 Dec 2021 08:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2020/11/WechatIMG2931.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文整理了我在移动开发中遇到的一些问题，并分享解决方案。</p><h2 id="1-"><strong><strong>1. 获取设备的真实开发分辨率（逻辑分辨率）</strong></strong></h2><p>最近查了好多关于移动端适配的资料，把人都看懵了，收获了一堆名词：</p><pre><code>CSS像素、物理分辨率、逻辑分辨率、设备像素比、PPI、DPI、DPR、DIP、Viewport
</code></pre><p>其实，对于开发来说，只需要了解三个概念。</p><ol><li>物理分辨率，就是设备上标称的分辨率。</li><li>逻辑分辨率，开发时所用的分辨率。</li><li>设备像素比，物理分辨率和逻辑分辨率之比。</li></ol><p>例如 iphone 6，它的物理分辨率是 <code>750 x 1334</code>，逻辑分辨率是 <code>375 x 667</code>，设备像素比是 2（<code>750 / 375</code>）。打开 chrome 控制台，切换到 device toolbar， 选择 iphone 6 设备就能看到它的逻辑分辨率。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121110407618.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>平时所说的 UI 设计稿 2 倍图、3 倍图。这个倍就是指设备像素比。例如设计稿是 2 倍图，里面的字体是 24 px，那我们用 <code>24 / 2</code> 就可以得出开发要用的像素为 12 px。</p><p>苹果设备大多都能查到它的逻辑分辨率是多少。但安卓设备不是，所以需要使用其他手段。</p><h3 id="-"><strong><strong>知道物理分辨率</strong></strong></h3><p>如果你知道物理分辨率，那可以通过 <code>window.devicePixelRatio</code> 获取设备像素比。然后再通过公式得出逻辑分辨率。</p><pre><code class="language-js">逻辑分辨率 = 物理分辨率 / 设备像素比
</code></pre><p>例如 PC 上的设备像素比就是 1。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121111155948.png#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>开发 PC 页面是很简单的，设计稿上的像素是多少，开发就写多少。</p><h3 id="--1"><strong><strong>什么都不知道</strong></strong></h3><p>即使你连物理分辨率都不知道，那也不要紧，一样有办法能得到设备的逻辑分辨率。</p><p>在页面建立一个刚好铺满全屏的 div 元素，然后获取它的宽高，这个宽高就是该设备的逻辑分辨率。</p><pre><code class="language-css">.test-div {
	position: fixed;
	left: 0;
	top: 0;
	width: 100vw;
	height: 100vh;
	background: red;
}
</code></pre><pre><code class="language-js">document.querySelector('test-div').clientWidth // 宽
document.querySelector('test-div').clientHeight // 高
</code></pre><p>刚好我手上有一个华为 m5 平板和华为 m5pro 平板，大小分别为 10.1 寸和 10.8 寸，用这种方法获取到它们的逻辑分辨率分别为 <code>960 x 600</code>、<code>1024 x 640</code>。</p><p>如图所示（华为 m5）：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20191213104213298.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>为了证明推断是正确的，我拿了一个专门在 m5pro 上使用的 app 放在 chrome 上运行（模拟该设备的逻辑分辨率），完美适配。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20191213105110800.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>为什么不使用 <code>window.screen.width</code> 或 <code>window.screen.height</code> 来获取逻辑分辨率呢？请看 MDN 的解释：</p><p>注意，该属性返回的高度值并不是全部对浏览器窗口可用。小工具（Widgets），如任务栏或其他特殊的程序窗口，可能会减少浏览器窗口和其他应用程序能够利用的空间。</p><p>也就是说，返回来的高度有一部分可能会被其他程序占用。</p><p><strong><strong>PS</strong></strong>：如果 app 在移动设备上不需要全屏展示，那么在 chrome 上模拟设备大小时要减去设备状态栏的高度。</p><h2 id="2-"><strong><strong>2. 页面适配</strong></strong></h2><p>在 css 中有很多相对长度，其中常用的有 em、rem、vw、vh 等，在小程序上还有 rpx。不管是 rem、vw 还是 rpx，都是跟屏幕宽度有关的。用这些计量单位开发的页面，不一定能兼容所有的移动端设备。</p><p>例如完美适配 iphone 6 的页面，在平板上就可能会出现样式问题。</p><p><strong><strong>适配 iphone 的页面</strong></strong>：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/2020112112523771.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p><strong><strong>放到平板下就乱了</strong></strong>：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121125328211.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>为此，有些网站做成了具有多套样式的自适应网站。例如用手机、平板、PC 访问的网站样式是不一样的。不过这种网站维护起来工作量很大，也很繁琐。而且现在手机与手机之间的差异也很大，需要写很多 <code>@media</code> 来解决适配的问题。</p><h3 id="--2"><strong><strong>一个可行的解决方案</strong></strong></h3><p>我觉得有一个比较可行的解决方案，对于适配多种移动设备有一定的帮助。</p><p>那就是<strong><strong>整体布局使用 vw、百分比 % 作为计量单位，内容样式上使用 px 作为计量单位</strong></strong>。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121142827563.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121142851802.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121142931403.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>从上面三个图可以看到，用 px 来做内容样式的计量单位，是可以适配多端的。即使为移动端设备写的网站，放到 PC 下也能适配。</p><p>这种解决方案就是简单方便，一劳永逸。目前我所开发过的移动端项目都是用的这种解决方案，暂时还未遇到适配上的问题。</p><p>而且这样写还有一个好处，就是<strong><strong>更大的屏幕可以看到更多的内容，而不是看到更大的内容</strong></strong>。例如在手机端下，一行只能显示 x 个字，要是内容超出屏幕就得用省略号代替。如果换成平板，可能就能把内容完整的显示出来。</p><h3 id="--3"><strong><strong>其他解决方案</strong></strong></h3><p>还有一个解决方案是在这个<a href="https://news.qq.com/zt2020/page/feiyan.htm#/">网站</a>发现的。这个网站整体布局和内容都是使用 vw 作为计量单位，在移动端上没什么问题。但在 PC 端下，它使用了 <code>scale</code> 属性。也就是说，当检测到你的设备是 PC 时，它会使用 <code>scale</code> 将网站进行缩小，最大宽度固定在 <code>750px</code>。这样，即使内容样式使用 vw 作为计量单位，也不会出现样式错乱的问题。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121152744749.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121152804833.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h3 id="--4"><strong><strong>栅格化布局</strong></strong></h3><p>在某些情况下，可以使用栅格化布局。例如当页面分辨率较大时，采取三列布局；当页面分辨率较小时，采用两列布局。</p><pre><code class="language-html">&lt;div class="container"&gt;
		&lt;div class="col col-md-4 col-sm-6"&gt;&lt;/div&gt;
		&lt;div class="col col-md-4 col-sm-6"&gt;&lt;/div&gt;
		&lt;div class="col col-md-4 col-sm-12"&gt;&lt;/div&gt;
		&lt;div class="col col-md-3 col-sm-3"&gt;&lt;/div&gt;
		&lt;div class="col col-md-6 col-sm-6"&gt;&lt;/div&gt;
		&lt;div class="col col-md-3 col-sm-3"&gt;&lt;/div&gt;
		&lt;div class="col col-md-1 col-sm-2"&gt;&lt;/div&gt;
		&lt;div class="col col-md-1 col-sm-2"&gt;&lt;/div&gt;
		&lt;div class="col col-md-2 col-sm-8"&gt;&lt;/div&gt;
		&lt;div class="col col-md-2 col-sm-3"&gt;&lt;/div&gt;
		&lt;div class="col col-md-6 col-sm-3"&gt;&lt;/div&gt;
	&lt;/div&gt;	
</code></pre><p><strong><strong>分辨率较大时的页面</strong></strong>：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121154641400.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p><strong><strong>分辨率较小时的页面</strong></strong>：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/20201121154707760.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3E0MTEwMjAzODI=,size_16,color_FFFFFF,t_70#pic_center" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p><br>如果你对栅格化原理不太了解，建议阅读我的另一篇文章<a href="https://zhuanlan.zhihu.com/p/61401978">栅格化系统的原理以及实现</a>。</p><h3 id="-ui-"><strong><strong>尽量使用移动端专用的 UI 组件库</strong></strong></h3><p>PC 端的 UI 组件库在移动端上会有很多样式问题，如非必要，不要使用。</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 前端监控原理深入剖析 ]]>
                </title>
                <description>
                    <![CDATA[ 前端监控分为性能监控和错误监控。其中监控又分为两个环节：数据采集和数据上报。本文主要讲的就是如何进行数据采集和数据上报。 数据采集 性能数据采集 性能数据采集需要使用 window.performance [https://developer.mozilla.org/zh-CN/docs/Web/API/Performance] API。 Performance 接口可以获取到当前页面中与性能相关的信息，它是 High Resolution Time API 的一部分，同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。 从 MDN 的文档可以看出，window.performance.timing 包含了页面加载各个阶段的起始及结束时间。 这些属性需要结合下图一起看，更好理解： 为了方便大家理解 timing 各个属性的意义，我在知乎找到一位网友对于 timing 写的简介，在此转载一下。 timing: {         // 同一个浏 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/frontend-monitoring/</link>
                <guid isPermaLink="false">5fc21fe739641a0517d511b3</guid>
                
                    <category>
                        <![CDATA[ 前端 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Wed, 24 Nov 2021 03:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2020/11/domenico-loia-hGV2TfOh0ns-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>前端监控分为性能监控和错误监控。其中监控又分为两个环节：数据采集和数据上报。本文主要讲的就是如何进行数据采集和数据上报。</p><h2 id="-"><strong><strong>数据采集</strong></strong></h2><h3 id="--1"><strong><strong>性能数据采集</strong></strong></h3><p>性能数据采集需要使用 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Performance">window.performance</a> API。</p><p>Performance 接口可以获取到当前页面中与性能相关的信息，它是 High Resolution Time API 的一部分，同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。</p><p>从 MDN 的文档可以看出，<code>window.performance.timing</code> 包含了页面加载各个阶段的起始及结束时间。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/b149fda27bde36a2c9b3152714b349e6.png" class="kg-image" alt="b149fda27bde36a2c9b3152714b349e6" width="600" height="400" loading="lazy"></figure><p>这些属性需要结合下图一起看，更好理解：</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/01ae5e4fd42fdd117e2939809c949e71.png" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>为了方便大家理解 <code>timing</code> 各个属性的意义，我在知乎找到一位网友对于 <code>timing</code> 写的简介，在此转载一下。</p><pre><code class="language-js">timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面，这个值会和fetchStart相同。
	navigationStart: 1543806782096,

	// 上一个页面unload事件抛出时的时间戳。如果没有上一个页面，这个值会返回0。
	unloadEventStart: 1543806782523,

	// 和 unloadEventStart 相对应，unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
	unloadEventEnd: 1543806782523,

	// 第一个HTTP重定向开始时的时间戳。如果没有重定向，或者重定向中的一个不同源，这个值会返回0。
	redirectStart: 0,

	// 最后一个HTTP重定向完成时（也就是说是HTTP响应的最后一个比特直接被收到的时间）的时间戳。
	// 如果没有重定向，或者重定向中的一个不同源，这个值会返回0. 
	redirectEnd: 0,

	// 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
	fetchStart: 1543806782096,

	// DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection)，或者这个信息存储到了缓存或者本地资源上，这个值将和fetchStart一致。
	domainLookupStart: 1543806782096,

	// DNS 域名查询完成的时间.
	//如果使用了本地缓存（即无 DNS 查询）或持久连接，则与 fetchStart 值相等
	domainLookupEnd: 1543806782096,

	// HTTP（TCP） 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection)，或者这个信息存储到了缓存或者本地资源上，这个值将和 fetchStart一致。
	connectStart: 1543806782099,

	// HTTP（TCP） 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接，则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
	connectEnd: 1543806782227,

	// HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接，则返回0。
	secureConnectionStart: 1543806782162,

	// 返回浏览器向服务器发出HTTP请求时（或开始读取本地缓存时）的时间戳。
	requestStart: 1543806782241,

	// 返回浏览器从服务器收到（或从本地缓存读取）第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开，该属性将会被数制成新的请求的相对应的发起时间。
	responseStart: 1543806782516,

	// 返回浏览器从服务器收到（或从本地缓存读取，或从本地资源读取）最后一个字节时
        //（如果在此之前HTTP连接已经关闭，则返回关闭时）的时间戳。
	responseEnd: 1543806782537,

	// 当前网页DOM结构开始解析时（即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时）的时间戳。
	domLoading: 1543806782573,

	// 当前网页DOM结构结束解析、开始加载内嵌资源时（即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时）的时间戳。
	domInteractive: 1543806783203,

	// 当解析器发送DOMContentLoaded 事件，即所有需要被执行的脚本已经被解析时的时间戳。
	domContentLoadedEventStart: 1543806783203,

	// 当所有需要立即执行的脚本已经被执行（不论执行顺序）时的时间戳。
	domContentLoadedEventEnd: 1543806783216,

	// 当前文档解析完成，即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
	domComplete: 1543806783796,

	// load事件被发送时的时间戳。如果这个事件还未被发送，它的值将会是0。
	loadEventStart: 1543806783796,

	// 当load事件结束，即加载事件完成时的时间戳。如果这个事件还未被发送，或者尚未完成，它的值将会是0.
	loadEventEnd: 1543806783802
}
</code></pre><p>通过以上数据，我们可以得到几个有用的时间</p><pre><code class="language-js">// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),
</code></pre><p>还有一个比较重要的时间就是<strong><strong>白屏时间</strong></strong>，它指从输入网址，到页面开始显示内容的时间。</p><p>将以下脚本放在 <code>&lt;/head&gt;</code> 前面就能获取白屏时间。</p><pre><code class="language-html">&lt;script&gt;
    whiteScreen = new Date() - performance.timing.navigationStart
    // 通过 domLoading 和 navigationStart 也可以
    whiteScreen = performance.timing.domLoading - performance.timing.navigationStart
&lt;/script&gt;
</code></pre><p>通过这几个时间，就可以得知页面首屏加载性能如何了。</p><p>另外，通过 <code>window.performance.getEntriesByType('resource')</code> 这个方法，我们还可以获取相关资源（js、css、img…）的加载时间，它会返回页面当前所加载的所有资源。</p><figure class="kg-card kg-image-card"><img src="https://img-blog.csdnimg.cn/img_convert/b2e1bcd6f06291b8b8c7f72825b91f68.png" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>它一般包括以下几个类型：</p><ul><li>sciprt</li><li>link</li><li>img</li><li>css</li><li>fetch</li><li>other</li><li>xmlhttprequest</li></ul><p>我们只需用到以下几个信息：</p><pre><code class="language-js">// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,
</code></pre><p>现在，写几行代码来收集这些数据。</p><pre><code class="language-js">// 收集性能信息
const getPerformance = () =&gt; {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () =&gt; {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item =&gt; {
        const arry = resource[item.initiatorType]
        arry &amp;&amp; arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}
</code></pre><h4 id="--2"><strong><strong>小结</strong></strong></h4><p>通过对性能及资源信息的解读，我们可以判断出页面加载慢有以下几个原因：</p><ol><li>资源过多、过大</li><li>网速过慢</li><li>DOM 元素过多</li></ol><p>除了用户网速过慢，我们没办法之外，其他两个原因都是有办法解决的，性能优化的文章和书籍网上已经有很多了，有兴趣可自行查找资料了解。</p><p><strong><strong>PS</strong></strong>：其实页面加载慢还有其他原因，例如没有使用按需加载、没有使用 CDN 等等。不过这里我们强调的仅通过对性能和资源信息的解读来获取原因。</p><h3 id="--3"><strong><strong>错误数据采集</strong></strong></h3><p>目前所能捕捉的错误有三种:</p><ol><li>资源加载错误，通过 <code>addEventListener('error', callback, true)</code> 在捕获阶段捕捉资源加载失败错误。</li><li>js 执行错误，通过 <code>window.onerror</code> 捕捉 js 错误。</li><li>promise 错误，通过 <code>addEventListener('unhandledrejection', callback)</code>捕捉 promise 错误，但是没有发生错误的行数，列数等信息，只能手动抛出相关错误信息。</li></ol><p>我们可以建一个错误数组变量 <code>errors</code> 在错误发生时，将错误的相关信息添加到数组，然后在某个阶段统一上报，具体如何操作请看下面的代码：</p><pre><code class="language-js">// 捕获资源加载失败错误 js css img...
addEventListener('error', e =&gt; {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error &amp;&amp; error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e =&gt; {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason &amp;&amp; e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})
</code></pre><h4 id="--4"><strong><strong>小结</strong></strong></h4><p>通过错误收集，可以了解到网站发生错误的类型及数量，从而做出相应的调整，以减少错误发生。完整代码和 DEMO 会在文章末尾放出，大家可以复制代码（HTML文件）在本地测试一下。</p><h2 id="--5"><strong><strong>数据上报</strong></strong></h2><h3 id="--6"><strong><strong>性能数据上报</strong></strong></h3><p>性能数据可以在页面加载完之后上报，尽量不要对页面性能造成影响。</p><pre><code class="language-js">window.onload = () =&gt; {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() =&gt; {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() =&gt; {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}
</code></pre><p>当然，你也可以设一个定时器，循环上报。不过每次上报最好做一下对比去重再上报，避免同样的数据重复上报。</p><h3 id="--7"><strong><strong>错误数据上报</strong></strong></h3><p>我在 DEMO 里提供的代码，是用一个 <code>errors</code> 数组收集所有的错误，再在某一阶段统一上报（延时上报）。</p><p>其实，也可以改成在错误发生时上报（即时上报）。这样可以避免“收集完错误，但延时上报还没触发，用户却已经关掉网页导致错误数据丢失”的问题。</p><pre><code class="language-js">// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error &amp;&amp; error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}
</code></pre><p>经网友提醒，可以使用 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon">navigator.sendBeacon()</a> 来进行上报。</p><pre><code class="language-js">window.addEventListener('unload', logData, false);

function logData() {
    navigator.sendBeacon("/log", analyticsData);
}
</code></pre><p>它的技术特点是：</p><p>使用 sendBeacon() 方法会使用户代理（浏览器）在有机会时异步地向服务器发送数据，同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了提交分析数据时的所有的问题：数据可靠，传输异步并且不会影响下一页面的加载。</p><h2 id="--8"><strong><strong>扩展</strong></strong></h2><h3 id="spa"><strong><strong>SPA</strong></strong></h3><p><code>window.performance</code> API 是有缺点的，在 SPA 切换路由时，<code>window.performance.timing</code> 的数据不会更新。<br>所以我们需要另想办法来统计切换路由到加载完成的时间。<br>拿 Vue 举例，一个可行的办法就是切换路由时，在路由的全局前置守卫 <code>beforeEach</code> 里获取开始时间，在组件的 <code>mounted</code> 钩子里执行 <code>vm.$nextTick</code> 函数来获取组件的渲染完毕时间。</p><pre><code class="language-js">router.beforeEach((to, from, next) =&gt; {
	store.commit('setPageLoadedStartTime', new Date())
})
</code></pre><pre><code class="language-js">mounted() {
	this.$nextTick(() =&gt; {
		this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
	})
}
</code></pre><p>除了性能和错误监控，其实我们还可以收集更多的信息。</p><h3 id="--9"><strong><strong>用户信息收集</strong></strong></h3><h4 id="navigator"><strong><strong>navigator</strong></strong></h4><p>使用 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Window/navigator">window.navigator</a> 可以收集到用户的设备信息，操作系统，浏览器信息…</p><h4 id="uv-unique-visitor-"><strong><strong>UV（Unique visitor）</strong></strong></h4><p>是指通过互联网浏览这个网页的访客，00:00-24:00 内相同的设备访问只被计算一次。一天内同个访客多次访问仅计算一个 UV。</p><p>在用户访问网站时，可以生成一个随机字符串+时间日期，保存在本地。在网页发生请求时（如果超过当天24小时，则重新生成），把这些参数传到后端，后端利用这些信息生成 UV 统计报告。</p><h4 id="pv-page-view-"><strong><strong>PV（Page View）</strong></strong></h4><p>即页面浏览量或点击量，用户每 1 次对网站中的每个网页访问均被记录 1 个PV。用户对同一页面的多次访问，访问量累计，用以衡量网站用户访问的网页数量。</p><h4 id="--10"><strong><strong>页面停留时间</strong></strong></h4><h5 id="--11"><strong><strong>传统网站</strong></strong></h5><p>用户在进入 A 页面时，通过后台请求把用户进入页面的时间捎上。过了 10 分钟，用户进入 B 页面，这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。</p><h5 id="spa-1"><strong><strong>SPA</strong></strong></h5><p>可以利用 router 来获取用户停留时间，拿 Vue 举例，通过 <code>router.beforeEach</code>、<code>destroyed</code> 这两个钩子函数来获取用户停留该路由组件的时间。</p><h4 id="--12"><strong><strong>浏览深度</strong></strong></h4><p>通过 <code>document.documentElement.scrollTop</code> 属性以及屏幕高度，可以判断用户是否浏览完网站内容。</p><h4 id="--13"><strong><strong>页面跳转来源</strong></strong></h4><p>通过 <code>document.referrer</code> 属性，可以知道用户是从哪个网站跳转而来。</p><h3 id="--14"><strong><strong>小结</strong></strong></h3><p>通过分析用户数据，我们可以了解到用户的浏览习惯、爱好等等信息，想想真是恐怖，毫无隐私可言。</p><h2 id="demo"><strong><strong>DEMO</strong></strong></h2><pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
    &lt;meta http-equiv="X-UA-Compatible" content="ie=edge"&gt;
    &lt;script&gt;
        function monitorInit() {
            const monitor = {
                // 数据上传地址
                url: '',
                // 性能信息
                performance: {},
                // 资源信息
                resources: {},
                // 错误信息
                errors: [],
                // 用户信息
                user: {
                    // 屏幕宽度
                    screen: screen.width,
                    // 屏幕高度
                    height: screen.height,
                    // 浏览器平台
                    platform: navigator.platform,
                    // 浏览器的用户代理信息
                    userAgent: navigator.userAgent,
                    // 浏览器用户界面的语言
                    language: navigator.language,
                },
                // 手动添加错误
                addError(error) {
                    const obj = {}
                    const { type, msg, url, row, col } = error
                    if (type) obj.type = type
                    if (msg) obj.msg = msg
                    if (url) obj.url = url
                    if (row) obj.row = row
                    if (col) obj.col = col
                    obj.time = new Date().getTime()
                    monitor.errors.push(obj)
                },
                // 重置 monitor 对象
                reset() {
                    window.performance &amp;&amp; window.performance.clearResourceTimings()
                    monitor.performance = getPerformance()
                    monitor.resources = getResources()
                    monitor.errors = []
                },
                // 清空 error 信息
                clearError() {
                    monitor.errors = []
                },
                // 上传监控数据
                upload() {
                    // 自定义上传
                    // axios.post({
                    //     url: monitor.url,
                    //     data: {
                    //         performance,
                    //         resources,
                    //         errors,
                    //         user,
                    //     }
                    // })
                },
                // 设置数据上传地址
                setURL(url) {
                    monitor.url = url
                },
            }

            // 获取性能信息
            const getPerformance = () =&gt; {
                if (!window.performance) return
                const timing = window.performance.timing
                const performance = {
                    // 重定向耗时
                    redirect: timing.redirectEnd - timing.redirectStart,
                    // 白屏时间
                    whiteScreen: whiteScreen,
                    // DOM 渲染耗时
                    dom: timing.domComplete - timing.domLoading,
                    // 页面加载耗时
                    load: timing.loadEventEnd - timing.navigationStart,
                    // 页面卸载耗时
                    unload: timing.unloadEventEnd - timing.unloadEventStart,
                    // 请求耗时
                    request: timing.responseEnd - timing.requestStart,
                    // 获取性能信息时当前时间
                    time: new Date().getTime(),
                }

                return performance
            }

            // 获取资源信息
            const getResources = () =&gt; {
                if (!window.performance) return
                const data = window.performance.getEntriesByType('resource')
                const resource = {
                    xmlhttprequest: [],
                    css: [],
                    other: [],
                    script: [],
                    img: [],
                    link: [],
                    fetch: [],
                    // 获取资源信息时当前时间
                    time: new Date().getTime(),
                }

                data.forEach(item =&gt; {
                    const arry = resource[item.initiatorType]
                    arry &amp;&amp; arry.push({
                        // 资源的名称
                        name: item.name,
                        // 资源加载耗时
                        duration: item.duration.toFixed(2),
                        // 资源大小
                        size: item.transferSize,
                        // 资源所用协议
                        protocol: item.nextHopProtocol,
                    })
                })

                return resource
            }

            window.onload = () =&gt; {
                // 在浏览器空闲时间获取性能及资源信息 https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
                if (window.requestIdleCallback) {
                    window.requestIdleCallback(() =&gt; {
                        monitor.performance = getPerformance()
                        monitor.resources = getResources()
                        console.log('页面性能信息')
                        console.log(monitor.performance)
                        console.log('页面资源信息')
                        console.log(monitor.resources)
                    })
                } else {
                    setTimeout(() =&gt; {
                        monitor.performance = getPerformance()
                        monitor.resources = getResources()
                        console.log('页面性能信息')
                        console.log(monitor.performance)
                        console.log('页面资源信息')
                        console.log(monitor.resources)
                    }, 0)
                }
            }

            // 捕获资源加载失败错误 js css img...
            addEventListener('error', e =&gt; {
                const target = e.target
                if (target != window) {
                    monitor.errors.push({
                        type: target.localName,
                        url: target.src || target.href,
                        msg: (target.src || target.href) + ' is load error',
                        // 错误发生的时间
                        time: new Date().getTime(),
                    })

                    console.log('所有的错误信息')
                    console.log(monitor.errors)
                }
            }, true)

            // 监听 js 错误
            window.onerror = function(msg, url, row, col, error) {
                monitor.errors.push({
                    type: 'javascript', // 错误类型
                    row: row, // 发生错误时的代码行数
                    col: col, // 发生错误时的代码列数
                    msg: error &amp;&amp; error.stack? error.stack : msg, // 错误信息
                    url: url, // 错误文件
                    time: new Date().getTime(), // 错误发生的时间
                })

                console.log('所有的错误信息')
                console.log(monitor.errors)
            }

            // 监听 promise 错误 缺点是获取不到行数数据
            addEventListener('unhandledrejection', e =&gt; {
                monitor.errors.push({
                    type: 'promise',
                    msg: (e.reason &amp;&amp; e.reason.msg) || e.reason || '',
                    // 错误发生的时间
                    time: new Date().getTime(),
                })

                console.log('所有的错误信息')
                console.log(monitor.errors)
            })

            return monitor
        }

        const monitor = monitorInit()
    &lt;/script&gt;
    &lt;link rel="stylesheet" href="test.css"&gt;
    &lt;title&gt;Document&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;button class="btn1"&gt;错误测试按钮1&lt;/button&gt;
    &lt;button class="btn2"&gt;错误测试按钮2&lt;/button&gt;
    &lt;button class="btn3"&gt;错误测试按钮3&lt;/button&gt;
    &lt;img src="https://avatars3.githubusercontent.com/u/22117876?s=460&amp;v=4" alt=""&gt;
    &lt;img src="test.png" alt=""&gt;
&lt;script src="192.168.10.15/test.js"&gt;&lt;/script&gt;
&lt;script&gt;
document.querySelector('.btn1').onclick = () =&gt; {
    setTimeout(() =&gt; {
        console.log(button)
    }, 0)
}

document.querySelector('.btn2').onclick = () =&gt; {
    new Promise((resolve, reject) =&gt; {
        reject({
            msg: 'test.js promise is error'
        })
    })
}

document.querySelector('.btn3').onclick = () =&gt; {
    throw ('这是一个手动扔出的错误')
}
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre><h2 id="--15"><strong><strong>参考资料</strong></strong></h2><ul><li><a href="https://fex.baidu.com/blog/2014/05/build-performance-monitor-in-7-days/">7 天打造前端性能监控系统</a></li><li><a href="https://github.com/wangweianger/zanePerfor">zanePerfor</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ vscode + vite + vue3 + ts + eslint + stylelint 代码自动格式化 ]]>
                </title>
                <description>
                    <![CDATA[ 使用 vite 创建的 vue3 项目有点简陋，很多功能都没有。所以本文将讲解一下如何对 vite + vue3 项目配置代码自动格式化。配置完成后的效果如下图所示： 安装 VSCode 插件 打开 VSCode，安装以下插件： eslint stylelint volar 打开 VSCode 配置文件（Mac command + shift + p，windows ctrl + shift + p，输入 settings）。 在配置文件中加入以下代码： "editor.codeActionsOnSave": {     "source.fixAll": true, }, "eslint.validate": [     "javascript",   ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/vscode-vite-vue3-ts-eslint-stylelint-automatic-code-formatting/</link>
                <guid isPermaLink="false">6191dc06a8fafe063bba22ef</guid>
                
                    <category>
                        <![CDATA[ VSCode ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vue ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ESLint ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Tue, 09 Nov 2021 04:10:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/11/nordwood-themes-bJjsKbToY34-unsplash.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>使用 vite 创建的 vue3 项目有点简陋，很多功能都没有。所以本文将讲解一下如何对 vite + vue3 项目配置代码自动格式化。配置完成后的效果如下图所示：</p><figure class="kg-card kg-image-card"><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7663bb223bbd4581a748d77ef4255673~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="10月-24-2021 22-06-50.gif" width="600" height="400" loading="lazy"></figure><h2 id="-vscode-">安装 VSCode 插件</h2><p>打开 VSCode，安装以下插件：</p><pre><code>eslint
stylelint
volar</code></pre><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/1.png" class="kg-image" alt="1" width="600" height="400" loading="lazy"></figure><p>打开 VSCode 配置文件（Mac <code>command + shift + p</code>，windows <code>ctrl + shift + p</code>，输入 <code>settings</code>）。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/2.png" class="kg-image" alt="2" width="600" height="400" loading="lazy"></figure><p>在配置文件中加入以下代码：</p><pre><code class="language-json">"editor.codeActionsOnSave": {
    "source.fixAll": true,
},
"eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript"
],
"eslint.alwaysShowStatus": true,
"stylelint.validate": [
    "css",
    "less",
    "postcss",
    "scss",
    "vue",
    "sass"
],</code></pre><h2 id="-">安装项目依赖</h2><p>安装以下依赖:</p><pre><code>npm i -D eslint eslint-config-airbnb-base eslint-plugin-import eslint-plugin-typescript eslint-plugin-vue husky sass stylelint stylelint-config-standard stylelint-scss typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser vue-eslint-parser</code></pre><p>注意，有些依赖版本对不上的时候，会造成格式化失败。例如 eslint 插件使用 8.0 版本以上格式化就报错，后来使用的是 7.0 的版本。安装后的具体版本如下：</p><pre><code class="language-json">"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
"eslint": "^7.2.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-typescript": "^0.14.0",
"eslint-plugin-vue": "^7.20.0",
"husky": "^4.2.3",
"sass": "^1.43.3",
"stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0",
"stylelint-scss": "^3.21.0",
"typescript": "^4.4.3",
"vue-eslint-parser": "^7.11.0",</code></pre><p>建议大家直接复制上面的代码到 package.json 文件中再下载。</p><h2 id="-eslintrc-js-stylelintrc-js-">配置 <code>.eslintrc.js</code> <code>.stylelintrc.js</code> 文件</h2><p>我的 eslint 配置是基于 airbnb 规范的， css 规范用的是官方插件。</p><h4 id="-eslintrc-js-"><code>.eslintrc.js</code> 文件</h4><pre><code class="language-js">module.exports = {
    root: true,
    globals: {
        defineEmits: 'readonly',
        defineProps: 'readonly',
    },
    extends: [
        'plugin:@typescript-eslint/recommended',
        'plugin:vue/vue3-recommended',
        'airbnb-base',      
    ],
    parser: 'vue-eslint-parser',
    plugins: [
        '@typescript-eslint',
    ],
    parserOptions: {
        parser: '@typescript-eslint/parser',
        ecmaVersion: 2020,
    },
    rules: {
        'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
        'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
        'no-bitwise': 'off',
        'no-tabs': 'off',
        'array-element-newline': ['error', 'consistent'],
        indent: ['error', 4, { MemberExpression: 0, SwitchCase: 1, ignoredNodes: ['TemplateLiteral'] }],
        quotes: ['error', 'single'],
        'comma-dangle': ['error', 'always-multiline'],
        'object-curly-spacing': ['error', 'always'],
        'max-len': ['error', 120],
        'no-new': 'off',
        'linebreak-style': 'off',
        'import/extensions': 'off',
        'eol-last': 'off',
        'no-shadow': 'off',
        'no-unused-vars': 'warn',
        'import/no-cycle': 'off',
        'arrow-parens': 'off',
        semi: ['error', 'never'],
        eqeqeq: 'off',
        'no-param-reassign': 'off',
        'import/prefer-default-export': 'off',
        'no-use-before-define': 'off',
        'no-continue': 'off',
        'prefer-destructuring': 'off',
        'no-plusplus': 'off',
        'prefer-const': 'off',
        'global-require': 'off',
        'no-prototype-builtins': 'off',
        'consistent-return': 'off',
        'vue/require-component-is': 'off',
        'prefer-template': 'off',
        'one-var-declaration-per-line': 'off',
        'one-var': 'off',
        'import/named': 'off',
        'object-curly-newline': 'off',
        'default-case': 'off',
        'import/order': 'off',
        'no-trailing-spaces': 'off',
        'func-names': 'off',
        radix: 'off',
        'no-unused-expressions': 'off',
        'no-underscore-dangle': 'off',
        'no-nested-ternary': 'off',
        'no-restricted-syntax': 'off',
        'no-mixed-operators': 'off',
        'no-await-in-loop': 'off',
        'import/no-extraneous-dependencies': 'off',
        'import/no-unresolved': 'off',
        'no-case-declarations': 'off',
        'template-curly-spacing': 'off',
        'vue/valid-v-for': 'off',
        '@typescript-eslint/no-var-requires': 'off',
        '@typescript-eslint/no-empty-function': 'off',
        'no-empty': 'off',
        '@typescript-eslint/no-explicit-any': 'off',
        'guard-for-in': 'off',
        '@typescript-eslint/ban-types': 'off',
        'class-methods-use-this': 'off',
        'no-return-await': 'off',
        'vue/html-indent': ['error', 4],
        'vue/html-self-closing': 'off',
        'vue/max-attributes-per-line': ['warn', {
            singleline: {
                max: 3,
                allowFirstLine: true,
            },      
            multiline: {
                max: 1,
                allowFirstLine: false,
            },
        }],
        'vue/singleline-html-element-content-newline': 'off',
    },
}</code></pre><h4 id="-stylelintrc-js-"><code>.stylelintrc.js</code> 文件</h4><pre><code class="language-js">module.exports = {
    extends: 'stylelint-config-standard',
    rules: {
        'indentation': 4,
        'selector-pseudo-element-no-unknown': [
            true,
            {
                ignorePseudoElements: ['v-deep']
            }
        ],
        'number-leading-zero': 'never',
        'no-descending-specificity': null,
        'font-family-no-missing-generic-family-keyword': null,
        'selector-type-no-unknown': null,
        'at-rule-no-unknown': null,
        'no-duplicate-selectors': null,
        'no-empty-source':null,
        'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global'] }]
    }
}</code></pre><h2 id="--1">踩坑</h2><h3 id="unknown-word-csssyntaxerror-"><code>Unknown word (CssSyntaxError)</code> 错误</h3><p>这个错误是因为安装的插件 <code>stylelint</code> <code>stylelint-config-standard</code> <code>stylelint-scss</code> 版本太新的问题，对于 vue3 模板文件的支持不是很好，不能正确识别 <code>.vue</code> 文件的 css 代码。所以将以上三个插件的版本降一个大版本就好了，最后的版本如下：</p><pre><code>"stylelint": "^13.13.1",
"stylelint-config-standard": "^22.0.0",
"stylelint-scss": "^3.21.0",</code></pre><p>另一种解决方案是安装 <code>postcss-html</code> 插件，但是 <code>stylelint</code> 14 版本的插件有很多问题，验证规则也和之前不太一样，并且 <code>.scss</code> 文件还验证不了。最后没找到解决方案，建议还是将版本降级比较好。</p><p>同时需要将 VSCode 的 <code>stylelint</code> 插件降级，现在插件的最新版本是 1.0.3，不支持 <code>stylelint</code> 13 版本。点击插件旁边的小齿轮，再点 <code>Install Another Version</code>，选择其他版本进行安装。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/3.jpeg" class="kg-image" alt="3" width="600" height="400" loading="lazy"></figure><p>选 <code>0.87.6</code> 版本安装就可以了，这时 css 自动格式化功能恢复正常。</p><h3 id="-vue-html-">忽略 <code>.vue</code> 文件中的 HTML 模板验证规则无效</h3><p>举个例子，如果你将 HTML 模板每行的代码文本长度设为 100，当超过这个长度后 eslint 将会报错。此时如果你还是想超过这个长度，可以选择忽略这个规则：</p><pre><code class="language-js">/* eslint-disable max-len */</code></pre><p>注意，以上这行忽略验证的代码是不会生效的，因为这个注释是 JavaScript 注释，我们需要将注释改为 HTML 格式，这样忽略验证才会生效：</p><pre><code class="language-html">&lt;!-- eslint-disable max-len --&gt;</code></pre><h2 id="--2">总结</h2><p>这样配置完成之后，基本上 vue3 项目里的代码都能格式化了（css js html 及各种衍生代码）。没有使用 prettier 是因为不够自由，而且它的功能已经可以由 eslint stylelint 来代替了。</p><p>为了不让大家再次踩坑，我已经将配置好的项目 demo 上传到 GitHub 了，有需要直接克隆项目就行。这是<a href="https://github.com/woai3c/vite-vue3-eslint-stylelint-demo">项目地址</a>。</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 常用的 Git 进阶知识与技巧 ]]>
                </title>
                <description>
                    <![CDATA[ 我将在这篇文章里分享一些常用的 Git 进阶知识与技巧，欢迎大家补充。 1. 同一电脑存在多个 Git 账号 假设我们在同一电脑上拥有多个 Git 账号，例如公司内部使用的是 Gitlab，个人使用的是 GitHub 或者 Gitee。那就会遇到一种情况，上班时想给个人开源项目提交代码，但是 Git 绑定的是公司的账号。 在这种情况下，我们可以让 Git 绑定多个不同的 ssh key，每个 ssh key 对应一个不同的 Git 服务器。 生成第一个 ssh key： ssh-keygen -t rsa -C "xxx@xxx.xx" 生成第二个 ssh key： ssh-keygen -t rsa -f path/to/file  -C "xxx@xxx.xx" 参数 -f 表示指定生成的文件名， path/to/file 是文件名路径，例如 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/commonly-used-git-advanced-knowledge-and-skills/</link>
                <guid isPermaLink="false">6189ed8e4aadd7063f2ba590</guid>
                
                    <category>
                        <![CDATA[ Git ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Fri, 05 Nov 2021 03:39:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/11/python-looping-techniques.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>我将在这篇文章里分享一些常用的 Git 进阶知识与技巧，欢迎大家补充。</p><h2 id="1-git-">1. 同一电脑存在多个 Git 账号</h2><p>假设我们在同一电脑上拥有多个 Git 账号，例如公司内部使用的是 Gitlab，个人使用的是 GitHub 或者 Gitee。那就会遇到一种情况，上班时想给个人开源项目提交代码，但是 Git 绑定的是公司的账号。</p><p>在这种情况下，我们可以让 Git 绑定多个不同的 ssh key，每个 ssh key 对应一个不同的 Git 服务器。</p><p>生成第一个 ssh key：</p><pre><code>ssh-keygen -t rsa -C "xxx@xxx.xx"</code></pre><p>生成第二个 ssh key：</p><pre><code>ssh-keygen -t rsa -f path/to/file  -C "xxx@xxx.xx"</code></pre><p>参数 <code>-f</code> 表示指定生成的文件名， <code>path/to/file</code> 是文件名路径，例如 <code>~/.ssh/id_rsa_github</code>。</p><p>执行上面两条命令后会得到两对 ssh key。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/WechatIMG3440.jpeg" class="kg-image" alt="WechatIMG3440" width="600" height="400" loading="lazy"></figure><p>这时还需要在该目录下创建一个 <code>config</code> 文件。写上以下内容：</p><pre><code class="language-bash">Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_rsa_github # 私钥文件路径
                                                            
Host gitlab.com
HostName gitlab.com
User git
IdentityFile ~/.ssh/id_rsa

Host gitee.com
HostName gitee.com
User git
IdentityFile ~/.ssh/id_rsa_github</code></pre><p>这个配置文件的作用是指定私钥文件位置。</p><p>然后我们可以把第一个密钥配置到公司的 Gitlab 服务器，并把相应的 Git 账号和邮箱设成全局。</p><pre><code>git config --global user.name "xxx"
git config --global user.email "xxx@xxx.xx"</code></pre><p>然后把另一对 ssh key 配置到 GitHub 上，并在电脑上的 GitHub 项目里单独配置 Git 用户姓名和邮箱。</p><pre><code>git config user.name "xxx"
git config user.email "xxx@xxx.xx"</code></pre><p>至此，我们就大功告成了。可以同时在不同的 Gitlab 和 GitHub 项目上提交代码了。</p><h2 id="2-git-commit-">2. 修改 git commit 记录的用户姓名和邮箱</h2><p>假设电脑上同时存在 Gitlab 和 GitHub 项目，其中 Gitlab 用户信息已经全局配置过了。现在新拉了一个 GitHub 项目，提交了一个 commit 并且已经推送到了远程仓库。这时发现该项目未配置 GitHub 的用户信息，默认使用的是全局账号 Gitlab 的用户信息。</p><p>我们可以通过以下命令来修改最近一次提交的用户信息：</p><pre><code>git commit --amend --author="username &lt;yyy@ccc.com&gt;" --no-edit</code></pre><p><code>username</code> 是用户名，用户邮箱旁边的 <code>&lt;&gt;</code> 符号不能去掉。修改后再执行 <code>git push -f</code> 推送到远程仓库。</p><p>如果要修改多个 commit 的用户信息怎么办？ 可以通过以下的代码来修改：</p><pre><code class="language-sh">git filter-branch --commit-filter '
    if [ "$GIT_AUTHOR_EMAIL" = "tanguangzhi@shiqiao.com" ];
    then
            GIT_AUTHOR_NAME="woai3c";
            GIT_AUTHOR_EMAIL="411020382@qq.com";
            git commit-tree "$@";
    else
            git commit-tree "$@";
    fi' HEAD</code></pre><p>将上述代码中的用户名和邮箱修改后，保存为 <code>.sh</code> 格式的文件，例如 <code>edit.sh</code>。然后在项目根目录下执行 <code>sh edit.sh</code>（windows 下可右击-&gt; <code>Git Bash Here</code> -&gt; <code>sh edit.sh</code>），再执行 <code>git push -f</code> 强推即可。</p><h2 id="3-">3. 修改某个历史记录的消息</h2><p>假设当前分支有 <code>a b c d</code> 四个 commit 记录:</p><pre><code>a
b
c
d</code></pre><p>如果你想对 <code>c</code> 记录的消息进行修改。可以使用 <code>git rebase</code> 将 <code>c</code> 记录换到最前面，然后使用 <code>git commit --amend</code> 对其消息进行修改。</p><h4 id="-">具体操作步骤</h4><p>执行以下命令对记录 <code>d</code> 前面的三个 commit 进行编辑：</p><pre><code>git rebase -i d</code></pre><p>进行 vim 编辑界面后，移动光标到 c 记录上，按下 <code>dd</code> 剪切该记录，然后移动光标到第一行，按下 <code>p</code> 粘贴，再输入 <code>:wq</code> 保存。</p><p>执行 <code>git commit --amend</code> 对切换顺序后的 <code>c</code> 记录进行修改。进入 vim 编辑界面后，按 <code>i</code> 进行修改，然后按 <code>ESC</code>，再输入 <code>:wq</code> 保存。</p><p>最后用前面讲过的 <code>git rebase</code> 操作将 <code>c</code> 记录恢复到原来的位置。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/1636430199310.jpg" class="kg-image" alt="1636430199310" width="600" height="400" loading="lazy"></figure><p>这个过程的执行结果就和上图一样，这是当前分支修改后和远程分支上的对比，箭头指向的记录消息就是修改后的消息。</p><p>如果想把修改后的记录同步到远程仓库，这时只要执行 <code>git push -f</code> 就可以了。</p><h4 id="--1">第二种方式</h4><ol><li>使用 <code>git checkout -b &lt;branchName&gt; c</code> 从指定记录切出一个分支</li><li>在新分支使用 <code>git commit --amend</code> 修改提交消息</li><li>使用 <code>git cherry-pick</code> 将 <code>b a</code> 记录，追加到新分支（<strong>注意</strong>，这里的 <code>b a</code> 提交记录是指原分支上的 commit，也就是选取原分支上的 <code>b a</code> 记录添加到新分支上，这样新分支上的记录就变成了 <code>a b c</code>，并且 c 记录的提交消息在第二步已经修改过）</li><li>使用 <code>git checout 原分支名</code> 切换回原来的分支，再执行 <code>git rebase &lt;branchName&gt;</code> 合并新分支，最后强推到远程分支</li></ol><h2 id="4-commit-">4. 挑选指定的 commit 进行合并</h2><p>假设你切了一个 bugFix 分支来修复线上 bug，经过一段时间的努力后终于将 bug 修复了。但是为了调试（加了很多 debug 代码）或其他原因，bugFix 上多了很多无用的记录消息。</p><pre><code>commit3: 修复登录 bug
commit2: 添加 debug 语句
commit1: 添加 console 语句</code></pre><p>例如上面的三个记录，前面的两个记录添加了很多调试代码，在最后一个记录终于修复了 bug，并且删除了调试代码。这时如果直接将 bugFix 分支合到 master 分支，就会把调试的记录也合并进来。</p><p>这时可以使用 <code>git cherry-pick</code> 只将最后一个记录合并到 master 分支。或者使用 <code>git rebase</code> 将 bugFix 分支另外两个记录抛弃，然后再合并。</p><h2 id="5-">5. <code>^</code> 和 <code>~</code> 的区别</h2><p>操作符 <code>^</code> 与 <code>~</code> 符一样，后面也可以跟一个数字。 <code>~</code> 表示向上返回几代记录。</p><p>但是该操作符后面的数字与 <code>~</code> 后面的不同，并不是用来指定向上返回几代，而是指定合并提交记录的第几个父记录。</p><p>Git 默认选择合并提交的“第一个”父记录，在操作符 <code>^</code> 后跟一个数字可以改变这一默认行为。</p><p>单看文字可能不太好理解，下面看几个示例。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/WechatIMG3441.jpeg" class="kg-image" alt="WechatIMG3441" width="600" height="400" loading="lazy"></figure><p>执行命令 <code>git checkout main^</code> 回到第一个父记录（原来 HEAD 指向 c3，现在指向 c1）。</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2021/11/WechatIMG3442.jpeg" class="kg-image" alt="WechatIMG3442" width="600" height="400" loading="lazy"></figure><p>执行命令 <code>git checkout main^2</code> 回到第二个父记录（原来 HEAD 指向 c3，现在指向 c2）。</p><p>最后再来一个更复杂的示例：</p><pre><code>G   H   I   J
 \ /     \ /
  D   E   F
   \  |  / \
    \ | /   |
     |/    |
      B     C
       \   /
        \ /
         A
A =      = A^0
B = A^   = A^1     = A~1
C = A^2  = A^2
D = A^^  = A^1^1   = A~2
E = B^2  = A^^2
F = B^3  = A^^3
G = A^^^ = A^1^1^1 = A~3
H = D^2  = B^^2    = A^^^2  = A~2^2
I = F^   = B^3^    = A^^3^
J = F^2  = B^3^2   = A^^3^2</code></pre><p>通过这些示例我们还能发现 <code>~n</code> 等于连续的 n 个 <code>^</code>。</p><h2 id="6-git-revert-git-reset-">6. <code>git revert</code> 与 <code>git reset</code> 的区别</h2><p><code>git reset</code> 可以回退 Git 的历史记录。例如当前分支有三个记录，并且 HEAD 指向 c 记录：</p><pre><code>c &lt;- HEAD
b 
a</code></pre><p>如果我们想回退到 b 记录，只要执行 <code>git reset b</code> 就可以了：</p><pre><code>b &lt;- HEAD 
a</code></pre><p>接着使用 <code>git push -f</code> 将回退版本后的分支强制推送到远程仓库，这样本地分支和远程分支就同步了。</p><pre><code>git push -f</code></pre><p><code>git revert</code> 也可以撤销记录，只不过它撤销的记录不会消失，这一点和 <code>git reset</code> 不一样。<code>git reset</code> 撤销的记录就跟消失了一样。</p><p>现在我们用 <code>git revert</code> 来重新演示下刚才的操作：</p><pre><code>c
b 
a</code></pre><p>如果我们执行 <code>git revert b</code>，则会在当前分支上再生成一个新的 commit 记录，变成 <code>a b c b'</code>，这个 <code>b'</code> 的状态和记录 <code>b</code> 是一样的。</p><p>也就是说，执行 <code>git reset b</code> 后，当前的分支记录会变成 <code>a b</code>。执行 <code>git revert b</code> 后，当前的分支记录会变成 &nbsp;<code>a b c b'</code>。</p><p>如果你想让别人知道你撤销过记录，就使用 <code>git revert</code>，因为它会留下撤销的记录，否则用 <code>git reset</code>。</p><h2 id="--2">参考资料</h2><ul><li><a href="https://blog.csdn.net/jifaliwo123/article/details/79126785">一台电脑绑定两个git帐号（GitHub和GitLab）</a></li><li><a href="https://git-scm.com/book/zh/v2/Git-%E5%B7%A5%E5%85%B7-%E9%87%8D%E5%86%99%E5%8E%86%E5%8F%B2">Git-工具-重写历史</a></li><li><a href="https://learngitbranching.js.org/?locale=zh_CN">learngitbranching</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 前端监控 SDK 的技术要点原理分析 ]]>
                </title>
                <description>
                    <![CDATA[ 一个完整的前端监控平台包括三个部分：数据采集与上报、数据整理和存储、数据展示。 本文要讲的就是其中的第一个环节——数据采集与上报。下图是本文要讲述内容的大纲，大家可以先大致了解一下： 仅看理论知识是比较难以理解的，为此我结合本文要讲的技术要点写了一个简单的监控 SDK [https://github.com/woai3c/monitor-demo]，可以用它来写一些简单的 DEMO，帮助加深理解。再结合本文一起阅读，效果更好。 性能数据采集 chrome 开发团队提出了一系列用于检测网页性能的指标：  * FP(first-paint)，从页面加载开始到第一个像素绘制到屏幕上的时间  * FCP(first-contentful-paint)，从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间  * LCP(largest-contentful-paint)，从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间  * CLS(layout-shift)，从页面加载开始和其生命周期状态    [https://developers.google.com/web/upd ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/tech-analysis-of-front-end-monitoring-sdk/</link>
                <guid isPermaLink="false">6165069621a1350622df539c</guid>
                
                    <category>
                        <![CDATA[ SDK ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 前端开发 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Wed, 13 Oct 2021 09:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/10/safar-safarov-koOdUvfGr4c-unsplash-1.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>一个完整的前端监控平台包括三个部分：数据采集与上报、数据整理和存储、数据展示。</p><p>本文要讲的就是其中的第一个环节——数据采集与上报。下图是本文要讲述内容的大纲，大家可以先大致了解一下：</p><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/97c87a9eb80b4186a462614f206bb7ee~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3633ae631be548baa55fffa97936339e~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>仅看理论知识是比较难以理解的，为此我结合本文要讲的技术要点写了一个简单的<a href="https://github.com/woai3c/monitor-demo">监控 SDK</a>，可以用它来写一些简单的 DEMO，帮助加深理解。再结合本文一起阅读，效果更好。</p><h2 id="-">性能数据采集</h2><p>chrome 开发团队提出了一系列用于检测网页性能的指标：</p><ul><li>FP(first-paint)，从页面加载开始到第一个像素绘制到屏幕上的时间</li><li>FCP(first-contentful-paint)，从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间</li><li>LCP(largest-contentful-paint)，从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间</li><li>CLS(layout-shift)，从页面加载开始和其<a href="https://developers.google.com/web/updates/2018/07/page-lifecycle-api">生命周期状态</a>变为隐藏期间发生的所有意外布局偏移的累积分数</li></ul><p>这四个性能指标都需要通过 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver">PerformanceObserver</a> 来获取（也可以通过 <code>performance.getEntriesByName()</code> 获取，但它不是在事件触发时通知的）。PerformanceObserver 是一个性能监测对象，用于监测性能度量事件。</p><h3 id="fp">FP</h3><p>FP(first-paint)，从页面加载开始到第一个像素绘制到屏幕上的时间。其实把 FP 理解成白屏时间也是没问题的。</p><p>测量代码如下：</p><pre><code class="language-js">const entryHandler = (list) =&gt; {        
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-paint') {
            observer.disconnect()
        }

       console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
// buffered 属性表示是否观察缓存数据，也就是说观察代码添加时机比事情触发时机晚也没关系。
observer.observe({ type: 'paint', buffered: true })</code></pre><p>通过以上代码可以得到 FP 的内容:</p><pre><code class="language-js">{
    duration:&nbsp;0,
    entryType:&nbsp;"paint",
    name:&nbsp;"first-paint",
    startTime:&nbsp;359, // fp 时间
}</code></pre><p>其中 <code>startTime</code> 就是我们要的绘制时间。</p><h3 id="fcp">FCP</h3><p>FCP(first-contentful-paint)，从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标，"内容"指的是文本、图像（包括背景图像）、<code>&lt;svg&gt;</code>元素或非白色的<code>&lt;canvas&gt;</code>元素。</p><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a4f1c9b61029448dae2b1cfb57b4ef75~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>为了提供良好的用户体验，FCP 的分数应该控制在 1.8 秒以内。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9818c66879b345e3b4845ff3fe01e8c9~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>测量代码：</p><pre><code class="language-js">const entryHandler = (list) =&gt; {        
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
            observer.disconnect()
        }
        
        console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })</code></pre><p>通过以上代码可以得到 FCP 的内容:</p><pre><code class="language-js">{
    duration:&nbsp;0,
    entryType:&nbsp;"paint",
    name:&nbsp;"first-contentful-paint",
    startTime:&nbsp;459, // fcp 时间
}</code></pre><p>其中 <code>startTime</code> 就是我们要的绘制时间。</p><h3 id="lcp">LCP</h3><p>LCP(largest-contentful-paint)，从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间。LCP 指标会根据页面<a href="https://web.dev/lcp/#what-elements-are-considered">首次开始加载</a>的时间点来报告可视区域内可见的最大<a href="https://web.dev/lcp/#what-elements-are-considered">图像或文本块</a>完成渲染的相对时间。</p><p>一个良好的 LCP 分数应该控制在 2.5 秒以内。</p><figure class="kg-card kg-image-card"><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c090dd8b042c46d2adaba5395ca68f47~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>测量代码：</p><pre><code class="language-js">const entryHandler = (list) =&gt; {
    if (observer) {
        observer.disconnect()
    }

    for (const entry of list.getEntries()) {
        console.log(entry)
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })</code></pre><p>通过以上代码可以得到 LCP 的内容:</p><pre><code class="language-js">{
    duration: 0,
    element: p,
    entryType: "largest-contentful-paint",
    id: "",
    loadTime: 0,
    name: "",
    renderTime: 1021.299,
    size: 37932,
    startTime: 1021.299,
    url: "",
}</code></pre><p>其中 <code>startTime</code> 就是我们要的绘制时间。element 是指 LCP 绘制的 DOM 元素。</p><p>FCP 和 LCP 的区别是：FCP 只要任意内容绘制完成就触发，LCP 是最大内容渲染完成时触发。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0e64637ac9d243a58101d8ed01fe886e~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>LCP 考察的元素类型为：</p><ul><li><code>&lt;img&gt;</code>元素</li><li>内嵌在<code>&lt;svg&gt;</code>元素内的<code>&lt;image&gt;</code>元素</li><li><code>&lt;video&gt;</code>元素（使用封面图像）</li><li>通过<a href="https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fdocs%2FWeb%2FCSS%2Furl()" rel="nofollow noopener noreferrer"><code>url()</code></a>函数（而非使用 <a href="https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Images/Using_CSS_gradients">CSS 渐变</a>）加载的带有背景图像的元素</li><li>包含文本节点或其他行内级文本元素子元素的<a href="https://developer.mozilla.org/zh-CN/docs/Web/HTML/Block-level_elements">块级元素</a>。</li></ul><h3 id="cls">CLS</h3><p>CLS(layout-shift)，从页面加载开始和其<a href="https://developers.google.com/web/updates/2018/07/page-lifecycle-api">生命周期状态</a>变为隐藏期间发生的所有意外布局偏移的累积分数。</p><p>布局偏移分数的计算方式如下：</p><pre><code>布局偏移分数 = 影响分数 * 距离分数</code></pre><p><a href="https://github.com/WICG/layout-instability#Impact-Fraction">影响分数</a>测量<em>不稳定元素</em>对两帧之间的可视区域产生的影响。</p><p><em>距离分数</em>指的是任何<em>不稳定元素</em>在一帧中位移的最大距离（水平或垂直）除以可视区域的最大尺寸维度（宽度或高度，以较大者为准）。</p><p><strong>CLS 就是把所有布局偏移分数加起来的总和</strong>。</p><p>当一个 DOM 在两个渲染帧之间产生了位移，就会触发 CLS（如图所示）。</p><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ff07d41c624248a1b66c5761f0482f2c~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d0d5ab8100c9489a991dd0be8e198af0~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>上图中的矩形从左上角移动到了右边，这就算是一次布局偏移。同时，在 CLS 中，有一个叫<strong>会话窗口</strong>的术语：一个或多个快速连续发生的单次布局偏移，每次偏移相隔的时间少于 1 秒，且整个窗口的最大持续时长为 5 秒。</p><figure class="kg-card kg-image-card"><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c6af2ec569644013962645820efb16d3~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>例如上图中的第二个会话窗口，它里面有四次布局偏移，每一次偏移之间的间隔必须少于 1 秒，并且第一个偏移和最后一个偏移之间的时间不能超过 5 秒，这样才能算是一次会话窗口。如果不符合这个条件，就算是一个新的会话窗口。可能有人会问，为什么要这样规定？其实这是 chrome 团队根据大量的实验和研究得出的分析结果 <a href="https://web.dev/evolving-cls/">Evolving the CLS metric</a>。</p><p>CLS 一共有三种计算方式：</p><ol><li>累加</li><li>取所有会话窗口的平均数</li><li>取所有会话窗口中的最大值</li></ol><h4 id="--1">累加</h4><p>也就是把从页面加载开始的所有布局偏移分数加在一起。但是这种计算方式对生命周期长的页面不友好，页面存留时间越长，CLS 分数越高。</p><h4 id="--2">取所有会话窗口的平均数</h4><p>这种计算方式不是按单个布局偏移为单位，而是以会话窗口为单位。将所有会话窗口的值相加再取平均值。但是这种计算方式也有缺点。</p><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/42e5208d83f349db84cf4a27194a57f2~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>从上图可以看出来，第一个会话窗口产生了比较大的 CLS 分数，第二个会话窗口产生了比较小的 CLS 分数。如果取它们的平均值来当做 CLS 分数，则根本看不出来页面的运行状况。原来页面是早期偏移多，后期偏移少，现在的平均值无法反映出这种情况。</p><h4 id="--3">取所有会话窗口中的最大值</h4><p>这种方式是目前最优的计算方式，每次只取所有会话窗口的最大值，用来反映页面布局偏移的最差情况。详情请看 <a href="https://web.dev/evolving-cls/">Evolving the CLS metric</a>。</p><p>下面是第三种计算方式的测量代码：</p><pre><code class="language-js">let sessionValue = 0
let sessionEntries = []
const cls = {
    subType: 'layout-shift',
    name: 'layout-shift',
    type: 'performance',
    pageURL: getPageURL(),
    value: 0,
}

const entryHandler = (list) =&gt; {
    for (const entry of list.getEntries()) {
        // Only count layout shifts without recent user input.
        if (!entry.hadRecentInput) {
            const firstSessionEntry = sessionEntries[0]
            const lastSessionEntry = sessionEntries[sessionEntries.length - 1]

            // If the entry occurred less than 1 second after the previous entry and
            // less than 5 seconds after the first entry in the session, include the
            // entry in the current session. Otherwise, start a new session.
            if (
                sessionValue
                &amp;&amp; entry.startTime - lastSessionEntry.startTime &lt; 1000
                &amp;&amp; entry.startTime - firstSessionEntry.startTime &lt; 5000
            ) {
                sessionValue += entry.value
                sessionEntries.push(formatCLSEntry(entry))
            } else {
                sessionValue = entry.value
                sessionEntries = [formatCLSEntry(entry)]
            }

            // If the current session value is larger than the current CLS value,
            // update CLS and the entries contributing to it.
            if (sessionValue &gt; cls.value) {
                cls.value = sessionValue
                cls.entries = sessionEntries
                cls.startTime = performance.now()
                lazyReportCache(deepCopy(cls))
            }
        }
    }
}

const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'layout-shift', buffered: true })</code></pre><p>在看完上面的文字描述后，再看代码就好理解了。一次布局偏移的测量内容如下：</p><pre><code class="language-js">{
  duration: 0,
  entryType: "layout-shift",
  hadRecentInput: false,
  lastInputTime: 0,
  name: "",
  sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution],
  startTime: 1176.199999999255,
  value: 0.000005752046026677329,
}</code></pre><p>代码中的 <code>value</code> 字段就是布局偏移分数。</p><h3 id="domcontentloaded-load-">DOMContentLoaded、load 事件</h3><p>当纯 HTML 被完全加载以及解析时，<code>DOMContentLoaded</code> 事件会被触发，不用等待 css、img、iframe 加载完。</p><p>当整个页面及所有依赖资源如样式表和图片都已完成加载时，将触发 <code>load</code> 事件。</p><p>虽然这两个性能指标比较旧了，但是它们仍然能反映页面的一些情况。对于它们进行监听仍然是必要的。</p><pre><code class="language-js">import { lazyReportCache } from '../utils/report'

['load', 'DOMContentLoaded'].forEach(type =&gt; onEvent(type))

function onEvent(type) {
    function callback() {
        lazyReportCache({
            type: 'performance',
            subType: type.toLocaleLowerCase(),
            startTime: performance.now(),
        })

        window.removeEventListener(type, callback, true)
    }

    window.addEventListener(type, callback, true)
}</code></pre><h3 id="--4">首屏渲染时间</h3><p>大多数情况下，首屏渲染时间可以通过 <code>load</code> 事件获取。除了一些特殊情况，例如异步加载的图片和 DOM。</p><pre><code class="language-html">&lt;script&gt;
    setTimeout(() =&gt; {
        document.body.innerHTML = `
            &lt;div&gt;
                &lt;!-- 省略一堆代码... --&gt;
            &lt;/div&gt;
        `
    }, 3000)
&lt;/script&gt;</code></pre><p>像这种情况就无法通过 <code>load</code> 事件获取首屏渲染时间了。这时我们需要通过 <a href="https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver/MutationObserver">MutationObserver</a> 来获取首屏渲染时间。MutationObserver 在监听的 DOM 元素属性发生变化时会触发事件。</p><p>首屏渲染时间计算过程：</p><ol><li>利用 MutationObserver 监听 document 对象，每当 DOM 元素属性发生变更时，触发事件。</li><li>判断该 DOM 元素是否在首屏内，如果在，则在 <code>requestAnimationFrame()</code> 回调函数中调用 <code>performance.now()</code> 获取当前时间，作为它的绘制时间。</li><li>将最后一个 DOM 元素的绘制时间和首屏中所有加载的图片时间作对比，将最大值作为首屏渲染时间。</li></ol><h4 id="-dom">监听 DOM</h4><pre><code class="language-js">const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout
const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK']
    
observer = new MutationObserver(mutationList =&gt; {
    const entry = {
        children: [],
    }

    for (const mutation of mutationList) {
        if (mutation.addedNodes.length &amp;&amp; isInScreen(mutation.target)) {
             // ...
        }
    }

    if (entry.children.length) {
        entries.push(entry)
        next(() =&gt; {
            entry.startTime = performance.now()
        })
    }
})

observer.observe(document, {
    childList: true,
    subtree: true,
})</code></pre><p>上面的代码就是监听 DOM 变化的代码，同时需要过滤掉 <code>style</code>、<code>script</code>、<code>link</code> 等标签。</p><h4 id="--5">判断是否在首屏</h4><p>一个页面的内容可能非常多，但用户最多只能看见一屏幕的内容。所以在统计首屏渲染时间的时候，需要限定范围，把渲染内容限定在当前屏幕内。</p><pre><code class="language-js">const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight

// dom 对象是否在屏幕内
function isInScreen(dom) {
    const rectInfo = dom.getBoundingClientRect()
    if (rectInfo.left &lt; viewportWidth &amp;&amp; rectInfo.top &lt; viewportHeight) {
        return true
    }

    return false
}</code></pre><h4 id="-requestanimationframe-dom-">使用 <code>requestAnimationFrame()</code> 获取 DOM 绘制时间</h4><p>当 DOM 变更触发 MutationObserver 事件时，只是代表 DOM 内容可以被读取到，并不代表该 DOM 被绘制到了屏幕上。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/67230c5e58ff4c699be7758656e4504f~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>从上图可以看出，当触发 MutationObserver 事件时，可以读取到 <code>document.body</code> 上已经有内容了，但实际上左边的屏幕并没有绘制任何内容。所以要调用 <code>requestAnimationFrame()</code> 在浏览器绘制成功后再获取当前时间作为 DOM 绘制时间。</p><h4 id="--6">和首屏内的所有图片加载时间作对比</h4><pre><code class="language-js">function getRenderTime() {
    let startTime = 0
    entries.forEach(entry =&gt; {
        if (entry.startTime &gt; startTime) {
            startTime = entry.startTime
        }
    })

    // 需要和当前页面所有加载图片的时间做对比，取最大值
    // 图片请求时间要小于 startTime，响应结束时间要大于 startTime
    performance.getEntriesByType('resource').forEach(item =&gt; {
        if (
            item.initiatorType === 'img'
            &amp;&amp; item.fetchStart &lt; startTime 
            &amp;&amp; item.responseEnd &gt; startTime
        ) {
            startTime = item.responseEnd
        }
    })
    
    return startTime
}</code></pre><h4 id="--7">优化</h4><p>现在的代码还没优化完，主要有两点注意事项：</p><ol><li>什么时候上报渲染时间？</li><li>如果兼容异步添加 DOM 的情况？</li></ol><p>第一点，必须要在 DOM 不再变化后再上报渲染时间，一般 load 事件触发后，DOM 就不再变化了。所以我们可以在这个时间点进行上报。</p><p>第二点，可以在 LCP 事件触发后再进行上报。不管是同步还是异步加载的 DOM，它都需要进行绘制，所以可以监听 LCP 事件，在该事件触发后才允许进行上报。</p><p>将以上两点方案结合在一起，就有了以下代码：</p><pre><code class="language-js">let isOnLoaded = false
executeAfterLoad(() =&gt; {
    isOnLoaded = true
})


let timer
let observer
function checkDOMChange() {
    clearTimeout(timer)
    timer = setTimeout(() =&gt; {
        // 等 load、lcp 事件触发后并且 DOM 树不再变化时，计算首屏渲染时间
        if (isOnLoaded &amp;&amp; isLCPDone()) {
            observer &amp;&amp; observer.disconnect()
            lazyReportCache({
                type: 'performance',
                subType: 'first-screen-paint',
                startTime: getRenderTime(),
                pageURL: getPageURL(),
            })

            entries = null
        } else {
            checkDOMChange()
        }
    }, 500)
}</code></pre><p><code>checkDOMChange()</code> 代码每次在触发 MutationObserver 事件时进行调用，需要用防抖函数进行处理。</p><h3 id="--8">接口请求耗时</h3><p>接口请求耗时需要对 XMLHttpRequest 和 fetch 进行监听。</p><p><strong>监听 XMLHttpRequest</strong></p><pre><code class="language-js">originalProto.open = function newOpen(...args) {
    this.url = args[1]
    this.method = args[0]
    originalOpen.apply(this, args)
}

originalProto.send = function newSend(...args) {
    this.startTime = Date.now()

    const onLoadend = () =&gt; {
        this.endTime = Date.now()
        this.duration = this.endTime - this.startTime

        const { status, duration, startTime, endTime, url, method } = this
        const reportData = {
            status,
            duration,
            startTime,
            endTime,
            url,
            method: (method || 'GET').toUpperCase(),
            success: status &gt;= 200 &amp;&amp; status &lt; 300,
            subType: 'xhr',
            type: 'performance',
        }

        lazyReportCache(reportData)

        this.removeEventListener('loadend', onLoadend, true)
    }

    this.addEventListener('loadend', onLoadend, true)
    originalSend.apply(this, args)
}</code></pre><p>如何判断 XML 请求是否成功？可以根据他的状态码是否在 200~299 之间。如果在，那就是成功，否则失败。</p><p><strong>监听 fetch</strong></p><pre><code class="language-js">const originalFetch = window.fetch

function overwriteFetch() {
    window.fetch = function newFetch(url, config) {
        const startTime = Date.now()
        const reportData = {
            startTime,
            url,
            method: (config?.method || 'GET').toUpperCase(),
            subType: 'fetch',
            type: 'performance',
        }

        return originalFetch(url, config)
        .then(res =&gt; {
            reportData.endTime = Date.now()
            reportData.duration = reportData.endTime - reportData.startTime

            const data = res.clone()
            reportData.status = data.status
            reportData.success = data.ok

            lazyReportCache(reportData)

            return res
        })
        .catch(err =&gt; {
            reportData.endTime = Date.now()
            reportData.duration = reportData.endTime - reportData.startTime
            reportData.status = 0
            reportData.success = false

            lazyReportCache(reportData)

            throw err
        })
    }
}</code></pre><p>对于 fetch，可以根据返回数据中的的 <code>ok</code> 字段判断请求是否成功，如果为 <code>true</code> 则请求成功，否则失败。</p><p><strong>注意</strong>，监听到的接口请求时间和 chrome devtool 上检测到的时间可能不一样。这是因为 chrome devtool 上检测到的是 HTTP 请求发送和接口整个过程的时间。但是 xhr 和 fetch 是异步请求，接口请求成功后需要调用回调函数。事件触发时会把回调函数放到消息队列，然后浏览器再处理，这中间也有一个等待过程。</p><h3 id="--9">资源加载时间、缓存命中率</h3><p>通过 <code>PerformanceObserver</code> 可以监听 <code>resource</code> 和 <code>navigation</code> 事件，如果浏览器不支持 <code>PerformanceObserver</code>，还可以通过 <code>performance.getEntriesByType(entryType)</code> 来进行降级处理。</p><p>当 <code>resource</code> 事件触发时，可以获取到对应的资源列表，每个资源对象包含以下一些字段：</p><figure class="kg-card kg-image-card"><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0e6cb30ae9a4447bbe43bfcff6c6c4a1~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>从这些字段中我们可以提取到一些有用的信息：</p><pre><code class="language-js">{
    name: entry.name, // 资源名称
    subType: entryType,
    type: 'performance',
    sourceType: entry.initiatorType, // 资源类型
    duration: entry.duration, // 资源加载耗时
    dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 耗时
    tcp: entry.connectEnd - entry.connectStart, // 建立 tcp 连接耗时
    redirect: entry.redirectEnd - entry.redirectStart, // 重定向耗时
    ttfb: entry.responseStart, // 首字节时间
    protocol: entry.nextHopProtocol, // 请求协议
    responseBodySize: entry.encodedBodySize, // 响应内容大小
    responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 响应头部大小
    resourceSize: entry.decodedBodySize, // 资源解压后的大小
    isCache: isCache(entry), // 是否命中缓存
    startTime: performance.now(),
}</code></pre><p><strong>判断该资源是否命中缓存</strong></p><p>在这些资源对象中有一个 &nbsp;<code>transferSize</code> 字段，它表示获取资源的大小，包括响应头字段和响应数据的大小。如果这个值为 0，说明是从缓存中直接读取的（强制缓存）。如果这个值不为 0，但是 <code>encodedBodySize</code> 字段为 0，说明它走的是协商缓存（<code>encodedBodySize</code> 表示请求响应数据 body 的大小）。</p><pre><code class="language-js">function isCache(entry) {
    // 直接从缓存读取或 304
    return entry.transferSize === 0 || (entry.transferSize !== 0 &amp;&amp; entry.encodedBodySize === 0)
}</code></pre><p>不符合以上条件的，说明未命中缓存。然后将<code>所有命中缓存的数据/总数据</code>就能得出缓存命中率。</p><h3 id="-bfc-back-forward-cache-">浏览器往返缓存 BFC（back/forward cache）</h3><p>bfcache 是一种内存缓存，它会将整个页面保存在内存中。当用户返回时可以马上看到整个页面，而不用再次刷新。据该文章 <a href="https://web.dev/bfcache/">bfcache</a> 介绍，firfox 和 safari 一直支持 bfc，chrome 只有在高版本的移动端浏览器支持。但我试了一下，只有 safari 浏览器支持，可能我的 firfox 版本不对。</p><p>但是 bfc 也是有缺点的，当用户返回并从 bfc 中恢复页面时，原来页面的代码不会再次执行。为此，浏览器提供了一个 <code>pageshow</code> 事件，可以把需要再次执行的代码放在里面。</p><pre><code class="language-js">window.addEventListener('pageshow', function(event) {
  // 如果该属性为 true，表示是从 bfc 中恢复的页面
  if (event.persisted) {
    console.log('This page was restored from the bfcache.');
  } else {
    console.log('This page was loaded normally.');
  }
});</code></pre><p>从 bfc 中恢复的页面，我们也需要收集他们的 FP、FCP、LCP 等各种时间。</p><pre><code class="language-js">onBFCacheRestore(event =&gt; {
    requestAnimationFrame(() =&gt; {
        ['first-paint', 'first-contentful-paint'].forEach(type =&gt; {
            lazyReportCache({
                startTime: performance.now() - event.timeStamp,
                name: type,
                subType: type,
                type: 'performance',
                pageURL: getPageURL(),
                bfc: true,
            })
        })
    })
})</code></pre><p>上面的代码很好理解，在 <code>pageshow</code> 事件触发后，用当前时间减去事件触发时间，这个时间差值就是性能指标的绘制时间。<strong>注意</strong>，从 bfc 中恢复的页面的这些性能指标，值一般都很小，一般在 10 ms 左右。所以要给它们加个标识字段 <code>bfc: true</code>。这样在做性能统计时可以对它们进行忽略。</p><h3 id="fps">FPS</h3><p>利用 <code>requestAnimationFrame()</code> 我们可以计算当前页面的 FPS。</p><pre><code class="language-js">const next = window.requestAnimationFrame 
    ? requestAnimationFrame : (callback) =&gt; { setTimeout(callback, 1000 / 60) }

const frames = []

export default function fps() {
    let frame = 0
    let lastSecond = Date.now()

    function calculateFPS() {
        frame++
        const now = Date.now()
        if (lastSecond + 1000 &lt;= now) {
            // 由于 now - lastSecond 的单位是毫秒，所以 frame 要 * 1000
            const fps = Math.round((frame * 1000) / (now - lastSecond))
            frames.push(fps)
                
            frame = 0
            lastSecond = now
        }
    
        // 避免上报太快，缓存一定数量再上报
        if (frames.length &gt;= 60) {
            report(deepCopy({
                frames,
                type: 'performace',
                subType: 'fps',
            }))
    
            frames.length = 0
        }

        next(calculateFPS)
    }

    calculateFPS()
}</code></pre><p>代码逻辑如下：</p><ol><li>先记录一个初始时间，然后每次触发 <code>requestAnimationFrame()</code> 时，就将帧数加 1。过去一秒后用<code>帧数/流逝的时间</code>就能得到当前帧率。</li></ol><p>当连续三个低于 20 的 FPS 出现时，我们可以断定页面出现了卡顿，详情请看 <a href="https://link.juejin.cn?target=https%3A%2F%2Fzhuanlan.zhihu.com%2Fp%2F39292837" rel="nofollow noopener noreferrer">如何监控网页的卡顿</a>。</p><pre><code class="language-js">export function isBlocking(fpsList, below = 20, last = 3) {
    let count = 0
    for (let i = 0; i &lt; fpsList.length; i++) {
        if (fpsList[i] &amp;&amp; fpsList[i] &lt; below) {
            count++
        } else {
            count = 0
        }

        if (count &gt;= last) {
            return true
        }
    }

    return false
}</code></pre><h3 id="vue-">Vue 路由变更渲染时间</h3><p>首屏渲染时间我们已经知道如何计算了，但是如何计算 SPA 应用的页面路由切换导致的页面渲染时间呢？本文用 Vue 作为示例，讲一下我的思路。</p><pre><code class="language-js">export default function onVueRouter(Vue, router) {
    let isFirst = true
    let startTime
    router.beforeEach((to, from, next) =&gt; {
        // 首次进入页面已经有其他统计的渲染时间可用
        if (isFirst) {
            isFirst = false
            return next()
        }

        // 给 router 新增一个字段，表示是否要计算渲染时间
        // 只有路由跳转才需要计算
        router.needCalculateRenderTime = true
        startTime = performance.now()

        next()
    })

    let timer
    Vue.mixin({
        mounted() {
            if (!router.needCalculateRenderTime) return

            this.$nextTick(() =&gt; {
                // 仅在整个视图都被渲染之后才会运行的代码
                const now = performance.now()
                clearTimeout(timer)

                timer = setTimeout(() =&gt; {
                    router.needCalculateRenderTime = false
                    lazyReportCache({
                        type: 'performance',
                        subType: 'vue-router-change-paint',
                        duration: now - startTime,
                        startTime: now,
                        pageURL: getPageURL(),
                    })
                }, 1000)
            })
        },
    })
}</code></pre><p>代码逻辑如下：</p><ol><li>监听路由钩子，在路由切换时会触发 <code>router.beforeEach()</code> 钩子，在该钩子的回调函数里将当前时间记为渲染开始时间。</li><li>利用 <code>Vue.mixin()</code> 对所有组件的 <code>mounted()</code> 注入一个函数。每个函数都执行一个防抖函数。</li><li>当最后一个组件的 <code>mounted()</code> 触发时，就代表该路由下的所有组件已经挂载完毕。可以在 <code>this.$nextTick()</code> 回调函数中获取渲染时间。</li></ol><p>同时，还要考虑到一个情况。不切换路由时，也会有变更组件的情况，这时不应该在这些组件的 <code>mounted()</code> 里进行渲染时间计算。所以需要添加一个 <code>needCalculateRenderTime</code> 字段，当切换路由时将它设为 true，代表可以计算渲染时间了。</p><h2 id="--10">错误数据采集</h2><h3 id="--11">资源加载错误</h3><p>使用 <code>addEventListener()</code> 监听 error 事件，可以捕获到资源加载失败错误。</p><pre><code class="language-js">// 捕获资源加载失败错误 js css img...
window.addEventListener('error', e =&gt; {
    const target = e.target
    if (!target) return

    if (target.src || target.href) {
        const url = target.src || target.href
        lazyReportCache({
            url,
            type: 'error',
            subType: 'resource',
            startTime: e.timeStamp,
            html: target.outerHTML,
            resourceType: target.tagName,
            paths: e.path.map(item =&gt; item.tagName).filter(Boolean),
            pageURL: getPageURL(),
        })
    }
}, true)</code></pre><h3 id="js-">js 错误</h3><p>使用 <code>window.onerror</code> 可以监听 js 错误。</p><pre><code class="language-js">// 监听 js 错误
window.onerror = (msg, url, line, column, error) =&gt; {
    lazyReportCache({
        msg,
        line,
        column,
        error: error.stack,
        subType: 'js',
        pageURL: url,
        type: 'error',
        startTime: performance.now(),
    })
}</code></pre><h3 id="promise-">promise 错误</h3><p>使用 <code>addEventListener()</code> 监听 unhandledrejection 事件，可以捕获到未处理的 promise 错误。</p><pre><code class="language-js">// 监听 promise 错误 缺点是获取不到列数据
window.addEventListener('unhandledrejection', e =&gt; {
    lazyReportCache({
        reason: e.reason?.stack,
        subType: 'promise',
        type: 'error',
        startTime: e.timeStamp,
        pageURL: getPageURL(),
    })
})</code></pre><h3 id="sourcemap">sourcemap</h3><p>一般生产环境的代码都是经过压缩的，并且生产环境不会把 sourcemap 文件上传。所以生产环境上的代码报错信息是很难读的。因此，我们可以利用 <a href="https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fmozilla%2Fsource-map" rel="nofollow noopener noreferrer">source-map</a> 来对这些压缩过的代码报错信息进行还原。</p><p>当代码报错时，我们可以获取到对应的文件名、行数、列数:</p><pre><code class="language-js">{
    line: 1,
    column: 17,
    file: 'https:/www.xxx.com/bundlejs',
}</code></pre><p>然后调用下面的代码进行还原：</p><pre><code class="language-js">async function parse(error) {
    const mapObj = JSON.parse(getMapFileContent(error.url))
    const consumer = await new sourceMap.SourceMapConsumer(mapObj)
    // 将 webpack://source-map-demo/./src/index.js 文件中的 ./ 去掉
    const sources = mapObj.sources.map(item =&gt; format(item))
    // 根据压缩后的报错信息得出未压缩前的报错行列数和源码文件
    const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
    // sourcesContent 中包含了各个文件的未压缩前的源码，根据文件名找出对应的源码
    const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
    return {
        file: originalInfo.source,
        content: originalFileContent,
        line: originalInfo.line,
        column: originalInfo.column,
        msg: error.msg,
        error: error.error
    }
}

function format(item) {
    return item.replace(/(\.\/)*/g, '')
}

function getMapFileContent(url) {
    return fs.readFileSync(path.resolve(__dirname, `./maps/${url.split('/').pop()}.map`), 'utf-8')
}</code></pre><p>每次项目打包时，如果开启了 sourcemap，那么每一个 js 文件都会有一个对应的 map 文件。</p><pre><code>bundle.js
bundle.js.map</code></pre><p>这时 js 文件放在静态服务器上供用户访问，map 文件存储在服务器，用于还原错误信息。<code>source-map</code> 库可以根据压缩过的代码报错信息还原出未压缩前的代码报错信息。例如压缩后报错位置为 <code>1 行 47 列</code>，还原后真正的位置可能为 <code>4 行 10 列</code>。除了位置信息，还可以获取到源码原文。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b1c6b5eebb7b4ef59d4dd6ad613484eb~tplv-k3u1fbpfcp-watermark.awebp?" class="kg-image" alt="image.png" width="600" height="400" loading="lazy"></figure><p>上图就是一个代码报错还原后的示例。鉴于这部分内容不属于 SDK 的范围，所以我另开了一个 <a href="https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fwoai3c%2Fsource-map-demo" rel="nofollow noopener noreferrer">仓库</a> 来做这个事，有兴趣可以看看。</p><h3 id="vue--1">Vue 错误</h3><p>利用 <code>window.onerror</code> 是捕获不到 Vue 错误的，它需要使用 Vue 提供的 API 进行监听。</p><pre><code class="language-js">Vue.config.errorHandler = (err, vm, info) =&gt; {
    // 将报错信息打印到控制台
    console.error(err)

    lazyReportCache({
        info,
        error: err.stack,
        subType: 'vue',
        type: 'error',
        startTime: performance.now(),
        pageURL: getPageURL(),
    })
}</code></pre><h2 id="--12">行为数据采集</h2><h3 id="pv-uv">PV、UV</h3><p>PV(page view) 是页面浏览量，UV(Unique visitor)用户访问量。PV 只要访问一次页面就算一次，UV 同一天内多次访问只算一次。</p><p>对于前端来说，只要每次进入页面上报一次 PV 就行，UV 的统计放在服务端来做，主要是分析上报的数据来统计得出 UV。</p><pre><code class="language-js">export default function pv() {
    lazyReportCache({
        type: 'behavior',
        subType: 'pv',
        startTime: performance.now(),
        pageURL: getPageURL(),
        referrer: document.referrer,
        uuid: getUUID(),
    })
}</code></pre><h3 id="--13">页面停留时长</h3><p>用户进入页面记录一个初始时间，用户离开页面时用当前时间减去初始时间，就是用户停留时长。这个计算逻辑可以放在 <code>beforeunload</code> 事件里做。</p><pre><code class="language-js">export default function pageAccessDuration() {
    onBeforeunload(() =&gt; {
        report({
            type: 'behavior',
            subType: 'page-access-duration',
            startTime: performance.now(),
            pageURL: getPageURL(),
            uuid: getUUID(),
        }, true)
    })
}</code></pre><h3 id="--14">页面访问深度</h3><p>记录页面访问深度是很有用的，例如不同的活动页面 a 和 b。a 平均访问深度只有 50%，b 平均访问深度有 80%，说明 b 更受用户喜欢，根据这一点可以有针对性的修改 a 活动页面。</p><p>除此之外还可以利用访问深度以及停留时长来鉴别电商刷单。例如有人进来页面后一下就把页面拉到底部然后等待一段时间后购买，有人是慢慢的往下滚动页面，最后再购买。虽然他们在页面的停留时间一样，但明显第一个人更像是刷单的。</p><p>页面访问深度计算过程稍微复杂一点：</p><ol><li>用户进入页面时，记录当前时间、scrollTop 值、页面可视高度、页面总高度。</li><li>用户滚动页面的那一刻，会触发 <code>scroll</code> 事件，在回调函数中用第一点得到的数据算出页面访问深度和停留时长。</li><li>当用户滚动页面到某一点时，停下继续观看页面。这时记录当前时间、scrollTop 值、页面可视高度、页面总高度。</li><li>重复第二点...</li></ol><p>具体代码请看：</p><pre><code class="language-js">let timer
let startTime = 0
let hasReport = false
let pageHeight = 0
let scrollTop = 0
let viewportHeight = 0

export default function pageAccessHeight() {
    window.addEventListener('scroll', onScroll)

    onBeforeunload(() =&gt; {
        const now = performance.now()
        report({
            startTime: now,
            duration: now - startTime,
            type: 'behavior',
            subType: 'page-access-height',
            pageURL: getPageURL(),
            value: toPercent((scrollTop + viewportHeight) / pageHeight),
            uuid: getUUID(),
        }, true)
    })

    // 页面加载完成后初始化记录当前访问高度、时间
    executeAfterLoad(() =&gt; {
        startTime = performance.now()
        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
        scrollTop = document.documentElement.scrollTop || document.body.scrollTop
        viewportHeight = window.innerHeight
    })
}

function onScroll() {
    clearTimeout(timer)
    const now = performance.now()
    
    if (!hasReport) {
        hasReport = true
        lazyReportCache({
            startTime: now,
            duration: now - startTime,
            type: 'behavior',
            subType: 'page-access-height',
            pageURL: getPageURL(),
            value: toPercent((scrollTop + viewportHeight) / pageHeight),
            uuid: getUUID(),
        })
    }

    timer = setTimeout(() =&gt; {
        hasReport = false
        startTime = now
        pageHeight = document.documentElement.scrollHeight || document.body.scrollHeight
        scrollTop = document.documentElement.scrollTop || document.body.scrollTop
        viewportHeight = window.innerHeight        
    }, 500)
}

function toPercent(val) {
    if (val &gt;= 1) return '100%'
    return (val * 100).toFixed(2) + '%'
}</code></pre><h3 id="--15">用户点击</h3><p>利用 <code>addEventListener()</code> 监听 <code>mousedown</code>、<code>touchstart</code> 事件，我们可以收集用户每一次点击区域的大小，点击坐标在整个页面中的具体位置，点击元素的内容等信息。</p><pre><code class="language-js">export default function onClick() {
    ['mousedown', 'touchstart'].forEach(eventType =&gt; {
        let timer
        window.addEventListener(eventType, event =&gt; {
            clearTimeout(timer)
            timer = setTimeout(() =&gt; {
                const target = event.target
                const { top, left } = target.getBoundingClientRect()
                
                lazyReportCache({
                    top,
                    left,
                    eventType,
                    pageHeight: document.documentElement.scrollHeight || document.body.scrollHeight,
                    scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
                    type: 'behavior',
                    subType: 'click',
                    target: target.tagName,
                    paths: event.path?.map(item =&gt; item.tagName).filter(Boolean),
                    startTime: event.timeStamp,
                    pageURL: getPageURL(),
                    outerHTML: target.outerHTML,
                    innerHTML: target.innerHTML,
                    width: target.offsetWidth,
                    height: target.offsetHeight,
                    viewport: {
                        width: window.innerWidth,
                        height: window.innerHeight,
                    },
                    uuid: getUUID(),
                })
            }, 500)
        })
    })
}</code></pre><h3 id="--16">页面跳转</h3><p>利用 <code>addEventListener()</code> 监听 <code>popstate</code>、<code>hashchange</code> 页面跳转事件。需要注意的是调用<code>history.pushState()</code>或<code>history.replaceState()</code>不会触发<code>popstate</code>事件。只有在做出浏览器动作时，才会触发该事件，如用户点击浏览器的回退按钮（或者在Javascript代码中调用<code>history.back()</code>或者<code>history.forward()</code>方法）。同理，<code>hashchange</code> 也一样。</p><pre><code class="language-js">export default function pageChange() {
    let from = ''
    window.addEventListener('popstate', () =&gt; {
        const to = getPageURL()

        lazyReportCache({
            from,
            to,
            type: 'behavior',
            subType: 'popstate',
            startTime: performance.now(),
            uuid: getUUID(),
        })

        from = to
    }, true)

    let oldURL = ''
    window.addEventListener('hashchange', event =&gt; {
        const newURL = event.newURL

        lazyReportCache({
            from: oldURL,
            to: newURL,
            type: 'behavior',
            subType: 'hashchange',
            startTime: performance.now(),
            uuid: getUUID(),
        })

        oldURL = newURL
    }, true)
}</code></pre><h3 id="vue--2">Vue 路由变更</h3><p>Vue 可以利用 <code>router.beforeEach</code> 钩子进行路由变更的监听。</p><pre><code class="language-js">export default function onVueRouter(router) {
    router.beforeEach((to, from, next) =&gt; {
        // 首次加载页面不用统计
        if (!from.name) {
            return next()
        }

        const data = {
            params: to.params,
            query: to.query,
        }

        lazyReportCache({
            data,
            name: to.name || to.path,
            type: 'behavior',
            subType: ['vue-router-change', 'pv'],
            startTime: performance.now(),
            from: from.fullPath,
            to: to.fullPath,
            uuid: getUUID(),
        })

        next()
    })
}</code></pre><h2 id="--17">数据上报</h2><h3 id="--18">上报方法</h3><p>数据上报可以使用以下几种方式：</p><ul><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon">sendBeacon</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest">XMLHttpRequest</a></li><li>image</li></ul><p>我写的简易 SDK 采用的是第一、第二种方式相结合的方式进行上报。利用 sendBeacon 来进行上报的优势非常明显。</p><p>使用 <strong><code>sendBeacon()</code></strong> 方法会使用户代理在有机会时异步地向服务器发送数据，同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了提交分析数据时的所有的问题：数据可靠，传输异步并且不会影响下一页面的加载。</p><p>在不支持 sendBeacon 的浏览器下我们可以使用 XMLHttpRequest 来进行上报。一个 HTTP 请求包含发送和接收两个步骤。其实对于上报来说，我们只要确保能发出去就可以了。也就是发送成功了就行，接不接收响应无所谓。为此，我做了个实验，在 beforeunload 用 XMLHttpRequest 传送了 30kb 的数据（一般的待上报数据很少会有这么大），换了不同的浏览器，都可以成功发出去。当然，这和硬件性能、网络状态也是有关联的。</p><h3 id="--19">上报时机</h3><p>上报时机有三种：</p><ol><li>采用 <code>requestIdleCallback/setTimeout</code> 延时上报。</li><li>在 beforeunload 回调函数里上报。</li><li>缓存上报数据，达到一定数量后再上报。</li></ol><p>建议将三种方式结合一起上报：</p><ol><li>先缓存上报数据，缓存到一定数量后，利用 <code>requestIdleCallback/setTimeout</code> 延时上报。</li><li>在页面离开时统一将未上报的数据进行上报。</li></ol><h2 id="--20">总结</h2><p>仅看理论知识是比较难以理解的，为此我结合本文所讲的技术要点写了一个简单的<a href="https://github.com/woai3c/monitor-demo">监控 SDK</a>，可以用它来写一些简单的 DEMO，帮助加深理解。再结合本文一起阅读，效果更好。</p><h2 id="--21">参考资料</h2><h3 id="--22">性能监控</h3><ul><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Performance_API">Performance API</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceResourceTiming">PerformanceResourceTiming</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Resource_Timing_API/Using_the_Resource_Timing_API">Using_the_Resource_Timing_API</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceTiming">PerformanceTiming</a></li><li><a href="https://web.dev/metrics/">Metrics</a></li><li><a href="https://web.dev/evolving-cls/">evolving-cls</a></li><li><a href="https://web.dev/custom-metrics/">custom-metrics</a></li><li><a href="https://github.com/GoogleChrome/web-vitals">web-vitals</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver">PerformanceObserver</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Element_timing_API">Element_timing_API</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEventTiming">PerformanceEventTiming</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Timing-Allow-Origin">Timing-Allow-Origin</a></li><li><a href="https://web.dev/bfcache/">bfcache</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver">MutationObserver</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest">XMLHttpRequest</a></li><li><a href="https://zhuanlan.zhihu.com/p/39292837">如何监控网页的卡顿</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator/sendBeacon">sendBeacon</a></li></ul><h3 id="--23">错误监控</h3><ul><li><a href="https://github.com/joeyguo/noerror">noerror</a></li><li><a href="https://github.com/mozilla/source-map">source-map</a></li></ul><h3 id="--24">行为监控</h3><ul><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Window/popstate_event">popstate</a></li><li><a href="https://developer.mozilla.org/zh-CN/docs/Web/API/Window/hashchange_event">hashchange</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 用 Jenkins 和 Github Actions 实现前端项目自动化部署 ]]>
                </title>
                <description>
                    <![CDATA[ 本教程主要讲解怎么使用 Jenkins 和 Github Actions 部署前端项目。  1. 第一部分是使用 Gitea 配置局域网 git 服务器，再使用 Jenkins 将 Gitea 下的项目部署到局域网服务器。  2. 第二部分是使用 Github Actions 将 Github 项目部署到 Github Page 和阿里云。 阅读本教程并不需要你提前了解 Jenkins 和 Github Actions 的知识，只要按照本教程的指引，就能够实现自动化部署项目。 PS：本人所用电脑操作系统为 windows，即以下所有的操作均在 windows 下运行。其他操作系统的配置大同小异，不会有太大差别。 Gitea + Jenkins 自动构建前端项目并部署到服务器 Gitea 用于构建 Git 局域网服务器，Jenkins 是 CI/CD 工具，用于部署前端项目。 配置 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/automated-deployment-of-front-end-projects-with-jenkins-and-github-actions/</link>
                <guid isPermaLink="false">61650bdb21a1350622df53c5</guid>
                
                    <category>
                        <![CDATA[ Jenkins ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 前端开发 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Tue, 12 Oct 2021 04:20:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/10/article-cover-pic.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本教程主要讲解怎么使用 Jenkins 和 Github Actions 部署前端项目。</p><ol><li>第一部分是使用 Gitea 配置局域网 git 服务器，再使用 Jenkins 将 Gitea 下的项目部署到局域网服务器。</li><li>第二部分是使用 Github Actions 将 Github 项目部署到 Github Page 和阿里云。</li></ol><p>阅读本教程并不需要你提前了解 Jenkins 和 Github Actions 的知识，只要按照本教程的指引，就能够实现自动化部署项目。</p><p>PS：本人所用电脑操作系统为 windows，即以下所有的操作均在 windows 下运行。其他操作系统的配置大同小异，不会有太大差别。</p><h2 id="gitea-jenkins-">Gitea + Jenkins 自动构建前端项目并部署到服务器</h2><p>Gitea 用于构建 Git 局域网服务器，Jenkins 是 CI/CD 工具，用于部署前端项目。</p><h3 id="-gitea">配置 Gitea</h3><ol><li>下载 <a href="https://link.juejin.cn?target=https%3A%2F%2Fdl.gitea.io%2Fgitea" rel="nofollow noopener noreferrer">Gitea</a>，选择一个喜欢的版本，例如 1.13，选择 <code>gitea-1.13-windows-4.0-amd64.exe</code> 下载。</li><li>下载完后，新建一个目录（例如 gitea），将下载的 Gitea 软件放到该目录下，双击运行。</li><li>打开 <code>localhost:3000</code> 就能看到 Gitea 已经运行在你的电脑上了。</li><li>点击注册，第一次会弹出一个初始配置页面，数据库选择 <code>SQLite3</code>。另外把 <code>localhost</code> 改成你电脑的局域网地址，例如我的电脑 IP 为 <code>192.168.0.118</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9ff66f2a9ff4436bb46216f9777b1ca8~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5685667856ca40ed8f534bc6e344e4ca~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>填完信息后，点击立即安装，等待一会，即可完成配置。</li><li>继续点击注册用户，第一个注册的用户将会成会管理员。</li><li>打开 Gitea 的安装目录，找到 <code>custom\conf\app.ini</code>，在里面加上一行代码 <code>START_SSH_SERVER = true</code>。这时就可以使用 ssh 进行 push 操作了。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/89726b25bd634c17973e8eeb1d7944a3~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>8. 如果使用 http 的方式无法克隆项目，请取消 git 代理。</p><pre><code class="language-git">git config --global --unset http.proxy
git config --global --unset https.proxy
复制代码</code></pre><h3 id="-jenkins">配置 Jenkins</h3><ol><li>需要提前安装 JDK，JDK 安装教程网上很多，请自行搜索。</li><li>打开 <a href="https://www.jenkins.io/zh/download/">Jenkins</a> 下载页面。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f34a061c138a445ca7e35105e68e0d0d~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>安装过程中遇到 <code>Logon Type</code> 时，选择第一个。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/79c134e86d404d37af1df9ec71c5cf2c~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>端口默认为 8080，这里我填的是 8000。安装完会自动打开 <code>http://localhost:8000</code> 网站，这时需要等待一会，进行初始化。</li><li>按照提示找到对应的文件（直接复制路径在我的电脑中打开），其中有管理员密码。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/68c2491dc87f40b7a2ed2f3a7c64c624~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>6. 安装插件，选择第一个。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/de722d8c3568470f8401f696da106848~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>创建管理员用户，点击完成并保存，然后一路下一步。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bdbb765c02c14e1f8e839d8abd37f321~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>8. 配置完成后自动进入首页，这时点击 <code>Manage Jenkins</code> -&gt; <code>Manage plugins</code> 安装插件。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a9b6d41606164e1f80047d0327c05306~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>9. 点击 <code>可选插件</code>，输入 nodejs，搜索插件，然后安装。 10. 安装完成后回到首页，点击 <code>Manage Jenkins</code> -&gt; <code>Global Tool Configuration</code> 配置 nodejs。如果你的电脑是 win7 的话，nodejs 版本最好不要太高，选择 v12 左右的就行。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/dc81f6a6588e4ddf9602a400c884bea7~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h3 id="-">创建静态服务器</h3><ol><li>建立一个空目录，在里面执行 <code>npm init -y</code>，初始化项目。</li><li>执行 <code>npm i express</code> 下载 express。</li><li>然后建立一个 <code>server.js</code> 文件，代码如下：</li></ol><pre><code class="language-js">const express = require('express')
const app = express()
const port = 8080

app.use(express.static('dist'))

app.listen(port, () =&gt; {
    console.log(`Example app listening at http://localhost:${port}`)
})
复制代码</code></pre><p>它将当前目录下的 <code>dist</code> 文件夹设为静态服务器资源目录，然后执行 <code>node server.js</code> 启动服务器。</p><p>由于现在没有 <code>dist</code> 文件夹，所以访问网站是空页面。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/de3ae14bee7040709ef8c9e80f68e1c3~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>不过不要着急，一会就能看到内容了。</p><h3 id="--1">自动构建 + 部署到服务器</h3><ol><li>下载 Jenkins 提供的 demo 项目 <a href="https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fjenkins-docs%2Fbuilding-a-multibranch-pipeline-project" rel="nofollow noopener noreferrer">building-a-multibranch-pipeline-project</a>，然后在你的 Gitea 新建一个仓库，把内容克隆进去，并提交到 Gitea 服务器。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c452046cb4d446728b2a44eb432b4850~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>2. 打开 Jenkins 首页，点击 <code>新建 Item</code> 创建项目。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5427e0513e0e4ce783bf6abd32730c54~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>3. 选择<code>源码管理</code>，输入你的 Gitea 上的仓库地址。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a95f9287739445c28eb0d95ac5e37494~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>你也可以尝试一下定时构建，下面这个代码表示每 5 分钟构建一次。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/06f2030ea15044c6980270ce6e58335b~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>选择你的构建环境，这里选择刚才配置的 nodejs。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/352fcfd12ee141c68557b6b42ffe640a~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>6. 点击增加构建步骤，windows 要选 <code>execute windows batch command</code>，linux 要选 <code>execute shell</code>。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2e6ee4b6dd10431f9ac16318766661e9~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="2e6ee4b6dd10431f9ac16318766661e9~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><ol><li>输入 <code>npm i &amp;&amp; npm run build &amp;&amp; xcopy .\build\* G:\node-server\dist\ /s/e/y</code>，这行命令的作用是安装依赖，构建项目，并将构建后的静态资源复制到指定目录 <code>G:\node-server\dist\ </code>。这个目录是静态服务器资源目录。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/01af43df1d80491e9dd8a489ef57c2f4~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>8. 保存后，返回首页。点击项目旁边的小三角，选择 <code>build now</code>。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5cea232eb18e4725b0c57678561bd5d6~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>9. 开始构建项目，我们可以点击项目查看构建过程。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/faad4f4509964a849c1796d69874ef32~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>10. 构建成功，打开 <code>http://localhost:8080/</code> 看一下结果。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c6ef1268d3c7409cabd96e33cf944c93~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f46e5659f8b04211b7497852209960da~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>11. 由于刚才设置了每 5 分钟构建一次，我们可以改变一下网站的内容，然后什么都不做，等待一会再打开网站看看。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fb5de42e30e44348b0bcc762f3624f02~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>12. 把修改的内容提交到 Gitea 服务器，稍等一会。打开网站，发现内容已经发生了变化。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/50b88365d90549eb81f0a86954ff62d5~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h3 id="-pipeline-">使用 pipeline 构建项目</h3><p>使用流水线构建项目可以结合 Gitea 的 <code>webhook</code> 钩子，以便在执行 <code>git push</code> 的时候，自动构建项目。</p><ol><li>点击首页右上角的用户名，选择<code>设置</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5cd283b66d204fa6934509e1891d0f3d~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>添加 token，记得将 token 保存起来。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ead6e4c2346d4b5fa03601d1ff7d4824~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>打开 Jenkins 首页，点击 <code>新建 Item</code> 创建项目。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/51273f6a00384a3d9564b572f83695be~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>4. 点击<code>构建触发器</code>，选择<code>触发远程构建</code>，填入刚才创建的 token。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1c9bdcd8fd8549c4a21be519069f620f~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>5. 选择流水线，按照提示输入内容，然后点击<code>保存</code>。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b343b4750c0a4c959125a0832e3de5fe~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>6. 打开 Jenkins 安装目录下的 <code>jenkins.xml</code> 文件，找到 <code>&lt;arguments&gt;</code> 标签，在里面加上 <code>-Dhudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION=true</code>。它的作用是关闭 <code>CSRF</code> 验证，不关的话，Gitea 的 <code>webhook</code> 会一直报 403 错误，无法使用。加好参数后，在该目录命令行下输入 <code>jenkins.exe restart</code> 重启 Jenkins。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c46ea1cc26664600a231615fa2ab0d5c~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>7. 回到首页，配置全局安全选项。勾上<code>匿名用户具有可读权限</code>，再保存。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6f2fdfa1709e460ab73fb4429c147ac4~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3eb4cf1f1637407abfe7ac811104a3df~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>打开你的 Gitea 仓库页面，选择<code>仓库设置</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d66a648f453d4393810a7bfaed1edd7c~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>点击<code>管理 web 钩子</code>，添加 web 钩子，钩子选项选择 <code>Gitea</code>。</li><li>目标 URL 按照 Jenkins 的提示输入内容。然后点击<code>添加 web 钩子</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f0f7c0356f1d4126936d77976873c69a~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/10831af1048c48a5918e2a1dfd05677b~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>11. 点击创建好的 web 钩子，拉到下方，点击测试推送。不出意外，应该能看到推送成功的消息，此时回到 Jenkins 首页，发现已经在构建项目了。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f2cbe9a5c87e44ed9d82359893cc8c2f~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>12. 由于没有配置 <code>Jenkinsfile</code> 文件，此时构建是不会成功的。所以接下来需要配置一下 <code>Jenkinsfile</code> 文件。将以下代码复制到你 Gitea 项目下的 <code>Jenkinsfile</code> 文件。jenkins 在构建时会自动读取文件的内容执行构建及部署操作。</p><pre><code>pipeline {
    agent any
    stages {
        stage('Build') {
            steps {  // window 使用 bat， linux 使用 sh
                bat 'npm i'
                bat 'npm run build'
            }
        }
        stage('Deploy') {
            steps {
                bat 'xcopy .\\build\\* D:\\node-server\\dist\\ /s/e/y' // 这里需要改成你的静态服务器资源目录
            }
        }
    }
}
复制代码</code></pre><ol><li>每当你的 Gitea 项目执行 <code>push</code> 操作时，Gitea 都会通过 <code>webhook</code> 发送一个 post 请求给 Jenkins，让它执行构建及部署操作。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5fc2ee216393438aa91fd26d0e8096db~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h3 id="--2">小结</h3><p>如果你的操作系统是 Linux，可以在 Jenkins 打包完成后，使用 ssh 远程登录到阿里云，将打包后的文件复制到阿里云上的静态服务器上，这样就能实现阿里云自动部署了。具体怎么远程登录到阿里云，请看下文中的 《Github Actions 部署到阿里云》 一节。</p><h2 id="github-actions-">Github Actions 自动构建前端项目并部署到服务器</h2><p>如果你的项目是 Github 项目，那么使用 Github Actions 也许是更好的选择。</p><h3 id="-github-page">部署到 Github Page</h3><p>接下来看一下如何使用 Github Actions 部署到 Github Page。</p><p>在你需要部署到 Github Page 的项目下，建立一个 yml 文件，放在 <code>.github/workflow</code> 目录下。你可以命名为 <code>ci.yml</code>，它类似于 Jenkins 的 <code>Jenkinsfile</code> 文件，里面包含的是要自动执行的脚本代码。</p><p>这个 yml 文件的内容如下：</p><pre><code class="language-yml">name: Build and Deploy
on: # 监听 master 分支上的 push 事件
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest # 构建环境使用 ubuntu
    steps:
      - name: Checkout
        uses: actions/checkout@v2.3.1  
        with:
          persist-credentials: false

      - name: Install and Build # 下载依赖 打包项目
        run: |
          npm install
          npm run build

      - name: Deploy # 将打包内容发布到 github page
        uses: JamesIves/github-pages-deploy-action@3.5.9 # 使用别人写好的 actions
        with:  # 自定义环境变量
          ACCESS_TOKEN: ${{ secrets.VUE_ADMIN_TEMPLATE }} # VUE_ADMIN_TEMPLATE 是我的 secret 名称，需要替换成你的
          BRANCH: master
          FOLDER: dist
          REPOSITORY_NAME: woai3c/woai3c.github.io # 这是我的 github page 仓库
          TARGET_FOLDER: github-actions-demo # 打包的文件将放到静态服务器 github-actions-demo 目录下

复制代码</code></pre><p>上面有一个 <code>ACCESS_TOKEN</code> 变量需要自己配置。</p><ol><li>打开 Github 网站，点击你右上角的头像，选择 <code>settings</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9bb947ff3c7243af92c62b221b472056~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>点击左下角的 <code>developer settings</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ea820b671dc64a24b2863c7dd1bdb093~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>在左侧边栏中，单击 <code>Personal access tokens（个人访问令牌）</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/50368065e962408cb53c938c4b509848~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>单击 <code>Generate new token（生成新令牌）</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ebc0215aa43d4bdba85a79d97beeeca3~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>输入名称并勾选 <code>repo</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b2aed7e43e6a423199c0a583355d03d5~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>拉到最下面，点击 <code>Generate token</code>，并将生成的 token 保存起来。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/314f733f0ead462ca497fd49531874ee~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><ol><li>打开你的 Github 项目，点击 <code>settings</code>。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5a7a9afc78d048049e9fcb7e2af40eec~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>点击 <code>secrets</code>-&gt;<code>new secret</code>。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5dc749949c5c4238863f12e2eae08805~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>创建一个密钥，名称随便填（中间用下划线隔开），内容填入刚才创建的 token。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f112c870534a4ed4878045f4fd4c39fd~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d714a34c74e5463cace0552442da3880~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>将上文代码中的 <code>ACCESS_TOKEN: ${{ secrets.VUE_ADMIN_TEMPLATE }}</code> 替换成刚才创建的 secret 名字，替换后代码如下 <code>ACCESS_TOKEN: ${{ secrets.TEST_A_B }}</code>。保存后，提交到 Github。</p><p>以后你的项目只要执行 <code>git push</code>，Github Actions 就会自动构建项目并发布到你的 Github Page 上。</p><p>Github Actions 的执行详情点击仓库中的 <code>Actions</code> 选项查看。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9afeda4174cd45b0814552c3a4eaa180~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/537454335ae947e18a9454988f7e57fc~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>具体详情可以参考一下我的 demo 项目 <strong><a href="https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fwoai3c%2Fgithub-actions-demo" rel="nofollow noopener noreferrer">github-actions-demo</a></strong>。</p><p>构建成功后，打开 Github Page 网站，可以发现内容已经发布成功。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/11313081f18b40b0a474be43de79ae1c~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h3 id="github-actions--1">Github Actions 部署到阿里云</h3><h4 id="--3">初始化阿里云服务器</h4><ol><li>购买阿里云服务器，选择操作系统，我选的 ubuntu</li><li>在云服务器管理控制台选择实例-&gt;更多-&gt;密钥-&gt;重置实例密码（一会登陆用）</li><li>选择远程连接-&gt;VNC，会弹出一个密码，记住它，以后远程连接要用（ctrl + alt + f1~f6 切换终端，例如 ctrl + alt + f1 是第一个终端）</li><li>进入后是一个命令行 输入 <code>root</code>（默认用户名），密码为你刚才重置的实例密码</li><li>登陆成功， 更新安装源 <code>sudo apt-get update &amp;&amp; sudo apt-get upgrade -y</code></li><li>安装 npm <code>sudo apt-get install npm</code></li><li>安装 npm 管理包 <code>sudo npm install -g n</code></li><li>安装 node 最新稳定版 <code>sudo n stable</code></li></ol><h4 id="--4">创建一个静态服务器</h4><pre><code class="language-js">mkdir node-server // 创建 node-server 文件夹
cd node-server // 进入 node-server 文件夹
npm init -y // 初始化项目
npm i express
touch server.js // 创建 server.js 文件
vim server.js // 编辑 server.js 文件
复制代码</code></pre><p>将以下代码输入进去（用 vim 进入文件后按 i 进行编辑，保存时按 esc 然后输入 :wq，再按 enter），更多使用方法请自行搜索。</p><pre><code class="language-js">const express = require('express')
const app = express()
const port = 3388 // 填入自己的阿里云映射端口，在网络安全组配置。

app.use(express.static('dist'))

app.listen(port, '0.0.0.0', () =&gt; {
    console.log(`listening`)
})
复制代码</code></pre><p>执行 <code>node server.js</code> 开始监听，由于暂时没有 <code>dist</code> 目录，先不要着急。</p><p>注意，监听 IP 必须为 <code>0.0.0.0</code> ，详情请看<a href="https://www.alibabacloud.com/help/zh/doc-detail/50775.htm">部署 Node.js 项目注意事项</a>。</p><p>阿里云入端口要在网络安全组中查看与配置。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aabf84961fa146ed8345785b069788bd~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><h4 id="--5">创建阿里云密钥对</h4><p>请参考<a href="https://www.alibabacloud.com/help/zh/doc-detail/51793.htm">创建 SSH 密钥对</a>和<a href="https://www.alibabacloud.com/help/zh/doc-detail/51796.htm?spm=a2c63.p38356.879954.9.cf992580IYf2O7#concept-zzt-nl1-ydb">绑定 SSH 密钥对</a> ，将你的 ECS 服务器实例和密钥绑定，然后将私钥保存到你的电脑（例如保存在 ecs.pem 文件）。</p><p>打开你要部署到阿里云的 Github 项目，点击 setting-&gt;secrets。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8c335a8b83c945428221ff8b064a2bd8~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>点击 new secret</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bbb7d4958522458fb3238388044710df~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>secret 名称为 <code>SERVER_SSH_KEY</code>，并将刚才的阿里云密钥填入内容。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ac2bf48aae7c46bdb1e641f832db57c6~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>点击 add secret 完成。</p><p>在你项目下建立 <code>.github\workflows\ci.yml</code> 文件，填入以下内容：</p><pre><code class="language-yml">name: Build app and deploy to aliyun
on:
  #监听push操作
  push:
    branches:
      # master分支，你也可以改成其他分支
      - master
jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: '12.16.2'
    - name: Install npm dependencies
      run: npm install
    - name: Run build task
      run: npm run build
    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: '-rltgoDzvO --delete'
          SOURCE: dist # 这是要复制到阿里云静态服务器的文件夹名称
          REMOTE_HOST: '118.190.217.8' # 你的阿里云公网地址
          REMOTE_USER: root # 阿里云登录后默认为 root 用户，并且所在文件夹为 root
          TARGET: /root/node-server # 打包后的 dist 文件夹将放在 /root/node-server
复制代码</code></pre><p>保存，推送到 Github 上。</p><p>以后只要你的项目执行 <code>git push</code> 操作，就会自动执行 <code>ci.yml</code> 定义的脚本，将打包文件放到你的阿里云静态服务器上。</p><p>这个 Actions 主要做了两件事：</p><ol><li>克隆你的项目，下载依赖，打包。</li><li>用你的阿里云私钥以 SSH 的方式登录到阿里云，把打包的文件上传（使用 rsync）到阿里云指定的文件夹中。</li></ol><p>如果还是不懂，建议看一下我的 <a href="https://github.com/woai3c/github-actions-aliyun-demo">demo</a>。</p><h3 id="ci-yml-"><code>ci.yml</code> 配置文件讲解</h3><ol><li><code>name</code>，表示这个工作流程（workflow）的名称。</li><li><code>on</code>，表示监听的意思，后面可以加上各种事件，例如 <code>push</code> 事件。</li></ol><p>下面这段代码表示要监听 <code>master</code> 分支的 <code>push</code> 事件。当 Github Actions 监听到 <code>push</code> 事件发生时，它就会执行下面 <code>jobs</code> 定义的一系列操作。</p><pre><code class="language-yml">name: Build app and deploy to aliyun
on:
  #监听push操作
  push:
    branches:
      # master分支，你也可以改成其他分支
      - master
jobs:
...
复制代码</code></pre><ol><li><code>jobs</code>，看字面意思就是一系列的作业，你可以在 <code>jobs</code> 字段下面定义很多作业，例如 <code>job1</code>、<code>job2</code> 等等，并且它们是并行执行的。</li></ol><pre><code class="language-yml">jobs:
  job1:
  	...
  job2:
  	...
  job3:
	...
复制代码</code></pre><p>回头看一下 <code>ci.yml</code> 文件，它只有一个作业，即 <code>build</code>，作业的名称是自己定义的，你叫 <code>good</code> 也可以。</p><ol><li><code>runs-on</code>，表示你这个工作流程要运行在什么操作系统上，<code>ci.yml</code> 文件定义的是最新稳定版的 <code>ubuntu</code>。除了 ubuntu，它还可以选择 Mac 或 Windows。</li></ol><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aa286b043d2f455d81c1ac034387481c~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="aa286b043d2f455d81c1ac034387481c~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><ol><li><code>steps</code>，看字面意思就是一系列的步骤，也就是说这个作业由一系列的步骤完成。例如先执行 <code>step1</code>，再执行 <code>step2</code>...</li></ol><h4 id="setps-"><code>setps</code> 步骤讲解</h4><p><code>setps</code> 其实是一个数组，在 YAML 语法中，以 <code>-</code> 开始就是一个数组项。例如 <code>['a', 'b', 'c']</code> 用 YAML 语法表示为：</p><pre><code class="language-yml">- a
- b
- c
复制代码</code></pre><p>所以 <code>setps</code> 就是一个步骤数组，从上到下开始执行。从 <code>ci.yml</code> 文件来看，每一个小步骤都有几个相关选项：</p><ol><li><code>name</code>，小步骤的名称。</li><li><code>uses</code>，小步骤使用的 actions 库名称或路径，Github Actions 允许你使用别人写好的 Actions 库。</li><li><code>run</code>，小步骤要执行的 <code>shell</code> 命令。</li><li><code>env</code>，设置与小步骤相关的环境变量。</li><li><code>with</code>，提供参数。</li></ol><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/71864f3ffec24798a7ccb1ca6eb3b1cf~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="71864f3ffec24798a7ccb1ca6eb3b1cf~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><p>综上所述，<code>ci.yml</code> 文件中的 <code>setps</code> 就很好理解了，下面从头到尾解释一边：</p><pre><code class="language-yml">    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: '12.16.2'
    - name: Install npm dependencies
      run: npm install
    - name: Run build task
      run: npm run build
    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: '-rltgoDzvO --delete'
          SOURCE: dist # 这是要复制到阿里云静态服务器的文件夹名称
          REMOTE_HOST: '118.190.217.8' # 你的阿里云公网地址
          REMOTE_USER: root # 阿里云登录后默认为 root 用户，并且所在文件夹为 root
          TARGET: /root/node-server # 打包后的 dist 文件夹将放在 /root/node-server
复制代码</code></pre><ol><li>使用 <code>actions/checkout@v1</code> 库克隆代码到 <code>ubuntu</code> 上。</li><li>使用 <code>actions/setup-node@v1</code> 库安装 nodejs，<code>with</code> 提供了一个参数 <code>node-version</code> 表示要安装的 nodejs 版本。</li><li>在 <code>ubuntu</code> 的 <code>shell</code> 上执行 <code>npm install</code> 下载依赖。</li><li>执行 <code>npm run build</code> 打包项目。</li><li>使用 <code>easingthemes/ssh-deploy@v2.1.5</code> 库，这个库的作用就是用 <code>SSH</code> 的方式远程登录到阿里云服务器，将打包好的文件夹复制到阿里云指定的目录上。</li></ol><p>从 <code>env</code> 上可以看到，这个 actions 库要求我们提供几个环境变量：</p><ol><li><code>SSH_PRIVATE_KEY</code>: 阿里云密钥对中的私钥（需要你提前写在 github secrets 上），</li><li><code>ARGS: '-rltgoDzvO --delete'</code>，没仔细研究，我猜是复制完文件就删除掉。</li><li><code>SOURCE</code>：打包后的文件夹名称</li><li><code>REMOTE_HOST</code>: 阿里云公网 IP 地址</li><li><code>REMOTE_USER</code>: 阿里云服务器的用户名</li><li><code>TARGET</code>: 你要拷贝到阿里云服务器指定目录的名称</li></ol><p>如果你想了解一下其他 actions 库的实现，可以直接复制 actions 库的名称去搜索引擎搜索一下，例如搜索 <code>actions/checkout</code> 的结果为：</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d2e353600ce54f1e8b0517c13d596b9c~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="d2e353600ce54f1e8b0517c13d596b9c~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><p>都看到这了，给个赞再走吧。</p><h2 id="--6">参考资料</h2><ul><li><a href="https://www.jenkins.io/zh/doc/">Jenkins 用户手册</a></li><li><a href="https://docs.github.com/cn/actions">GitHub Actions 文档</a></li><li><a href="https://docs.github.com/cn/pages/getting-started-with-github-pages/about-github-pages">GitHub Pages 文档</a></li><li><a href="https://docs.gitea.io/zh-cn/">Gitea 文档</a></li><li><a href="https://docs.github.com/cn/actions/learn-github-actions/workflow-syntax-for-github-actions">GitHub 操作的工作流程语法</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 如何搭建 Vue 服务端渲染项目 ]]>
                </title>
                <description>
                    <![CDATA[ 本文将分成以下两部分：  1. 简述 Vue SSR 过程  2. 从零开始搭建 SSR 项目 建议读者先阅读官方指南——Vue.js 服务器端渲染指南 [https://ssr.vuejs.org/zh/]，再回到本文开始阅读。 好了，下面开始正文。 简述 Vue SSR 过程 客户端渲染过程  1. 访问客户端渲染的网站。  2. 服务器返回一个包含了引入资源语句和 <div id="app"></div> 的 HTML 文件。  3. 客户端通过 HTTP 向服务器请求资源，当必要的资源都加载完毕后，执行 new Vue() 开始实例化并渲染页面。 服务端渲染过程  1. 访问服务端渲染的网站。  2. 服务器会查看当前路由组件需要哪些资源文件，然后将这些文件的内容填充到 HTML 文件。如果有 asyncData() ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/build-a-vue-server-side-rendering-project/</link>
                <guid isPermaLink="false">615ebdcc21a1350622df4f50</guid>
                
                    <category>
                        <![CDATA[ Vue ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Thu, 07 Oct 2021 09:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/10/1_zyNSb0UXhP8TfxYbj-GNWg.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文将分成以下两部分：</p><ol><li>简述 Vue SSR 过程</li><li>从零开始搭建 SSR 项目</li></ol><p>建议读者先阅读官方指南——<a href="https://ssr.vuejs.org/zh/">Vue.js 服务器端渲染指南</a>，再回到本文开始阅读。</p><p>好了，下面开始正文。</p><h2 id="-vue-ssr-">简述 Vue SSR 过程</h2><h3 id="-">客户端渲染过程</h3><ol><li>访问客户端渲染的网站。</li><li>服务器返回一个包含了引入资源语句和 <code>&lt;div id="app"&gt;&lt;/div&gt;</code> 的 HTML 文件。</li><li>客户端通过 HTTP 向服务器请求资源，当必要的资源都加载完毕后，执行 <code>new Vue()</code> 开始实例化并渲染页面。</li></ol><h3 id="--1">服务端渲染过程</h3><ol><li>访问服务端渲染的网站。</li><li>服务器会查看当前路由组件需要哪些资源文件，然后将这些文件的内容填充到 HTML 文件。如果有 <code>asyncData()</code> 函数，就会执行它进行数据预取并填充到 HTML 文件里，最后返回这个 HTML 页面。</li><li>当客户端接收到这个 HTML 页面时，可以马上就开始渲染页面。与此同时，页面也会加载资源，当必要的资源都加载完毕后，开始执行 <code>new Vue()</code> 开始实例化并接管页面。</li></ol><p>从上述两个过程中，可以看出，区别就在于第二步。客户端渲染的网站会直接返回 HTML 文件，而服务端渲染的网站则会渲染完页面再返回这个 HTML 文件。</p><p><strong>这样做的好处是什么？是更快的内容到达时间 (time-to-content)</strong>。</p><p>假设你的网站需要加载完 abcd 四个文件才能渲染完毕。并且每个文件大小为 1 M。</p><p>这样一算：客户端渲染的网站需要加载 4 个文件和 HTML 文件才能完成首页渲染，总计大小为 4M（忽略 HTML 文件大小）。而服务端渲染的网站只需要加载一个渲染完毕的 HTML 文件就能完成首页渲染，总计大小为已经渲染完毕的 HTML 文件（这种文件不会太大，一般为几百K，我的个人博客网站（SSR）加载的 HTML 文件为 400K）。<strong>这就是服务端渲染更快的原因</strong>。</p><h3 id="--2">客户端接管页面</h3><p>对于服务端返回来的 HTML 文件，客户端必须进行接管，对其进行 <code>new Vue()</code> 实例化，用户才能正常使用页面。</p><p>如果不对其进行激活的话，里面的内容只是一串字符串而已，例如下面的代码，点击是无效的：</p><pre><code class="language-html">&lt;button @click="sayHi"&gt;如果不进行激活，点我是不会触发事件的&lt;/button&gt;</code></pre><p>那客户端如何接管页面呢？下面引用<a href="https://juejin.cn/post/6844904163692937229#heading-6">一篇文章</a>中的内容：</p><blockquote>客户端 new Vue() 时，客户端会和服务端生成的DOM进行Hydration对比(判断这个DOM和自己即将生成的DOM是否相同（vuex store 数据同步才能保持一致）。</blockquote><blockquote>如果相同就调用app.$mount('#app')将客户端的vue实例挂载到这个DOM上，即去“激活”这些服务端渲染的HTML之后，其变成了由Vue动态管理的DOM，以便响应后续数据的变化，即之后所有的交互和vue-router不同页面之间的跳转将全部在浏览器端运行。</blockquote><blockquote>如果客户端构建的虚拟 DOM 树与服务器渲染返回的HTML结构不一致，这时候，客户端会请求一次服务器再渲染整个应用程序，这使得ssr失效了，达不到服务端渲染的目的了。</blockquote><h3 id="--3">小结</h3><p>不管是客户端渲染还是服务端渲染，都需要等待客户端执行 <code>new Vue()</code> 之后，用户才能进行交互操作。<strong>但服务端渲染的网站能让用户更快的看见页面</strong>。</p><h2 id="-ssr-">从零开始搭建 SSR 项目</h2><h3 id="-weback">配置 weback</h3><p>webpack 配置文件共有 3 个：</p><ol><li><code>webpack.base.config.js</code>，基础配置文件，客户端与服务端都需要它。</li><li><code>webpack.client.config.js</code>，客户端配置文件，用于生成客户端所需的资源。</li><li><code>webpack.server.config.js</code>，服务端配置文件，用于生成服务端所需的资源。</li></ol><h4 id="webpack-base-config-js-"><code>webpack.base.config.js</code> 基础配置文件</h4><pre><code class="language-js">const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'

function resolve(dir) {
    return path.join(__dirname, '..', dir)
}

module.exports = {
    context: path.resolve(__dirname, '../'),
    devtool: isProd ? 'source-map' : '#cheap-module-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        // chunkhash 同属一个 chunk 中的文件修改了，文件名会发生变化 
        // contenthash 只有文件自己的内容变化了，文件名才会变化
        filename: '[name].[contenthash].js',
        // 此选项给打包后的非入口js文件命名，与 SplitChunksPlugin 配合使用
        chunkFilename: '[name].[contenthash].js',
    },
    resolve: {
        extensions: ['.js', '.vue', '.json', '.css'],
        alias: {
            public: resolve('public'),
            '@': resolve('src')
        }
    },
    module: {
        // https://juejin.cn/post/6844903689103081485
        // 使用 `mini-css-extract-plugin` 插件打包的的 `server bundle` 会使用到 document。
        // 由于 node 环境中不存在 document 对象，所以报错。
        // 解决方案：样式相关的 loader 不要放在 `webpack.base.config.js` 文件
        // 将其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 文件
        // 其中 `mini-css-extract-plugin` 插件要放在 `webpack.client.config.js` 文件配置。
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader',
                options: {
                    compilerOptions: {
                        preserveWhitespace: false
                    }
                }
            },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.(png|svg|jpg|gif|ico)$/,
                use: ['file-loader']
            },
            {
                test: /\.(woff|eot|ttf)\??.*$/,
                loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
            },
        ]
    },
    plugins: [new VueLoaderPlugin()],
}</code></pre><p>基础配置文件比较简单，<code>output</code> 属性的意思是打包时根据文件内容生成文件名称。<code>module</code> 属性配置不同文件的解析 loader。</p><h4 id="webpack-client-config-js-"><code>webpack.client.config.js</code> 客户端配置文件</h4><pre><code class="language-js">const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackBar = require('webpackbar')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

const isProd = process.env.NODE_ENV === 'production'

const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(
            process.env.NODE_ENV || 'development'
        ),
        'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin(),
    new MiniCssExtractPlugin({
        filename: 'style.css'
    })
]

if (isProd) {
    plugins.push(
        // 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
        new CompressionPlugin(),
        // 该插件会根据模块的相对路径生成一个四位数的hash作为模块id, 用于生产环境。
        new webpack.HashedModuleIdsPlugin(),
        new WebpackBar(),
    )
}

const config = {
    entry: {
        app: './src/entry-client.js'
    },
    plugins,
    optimization: {
        runtimeChunk: {
            name: 'manifest'
        },
        splitChunks: {
            cacheGroups: {
                vendor: {
                    name: 'chunk-vendors',
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10,
                    chunks: 'initial',
                },
                common: {
                    name: 'chunk-common',
                    minChunks: 2,
                    priority: -20,
                    chunks: 'initial',
                    reuseExistingChunk: true
                }
            },
        }
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            // 解决 export 'default' (imported as 'mod') was not found
                            // 启用 CommonJS 语法
                            esModule: false,
                        },
                    },
                    'css-loader'
                ]
            }
        ]
    },
}

if (isProd) {
    // 压缩 css
    config.optimization.minimizer = [
        new CssMinimizerPlugin(),
    ]
}

module.exports = merge(base, config)</code></pre><p>客户端配置文件中的 <code>config.optimization</code> 属性是打包时分割代码用的。它的作用是将第三方库都打包在一起。</p><p>其他插件作用：</p><ol><li><code>MiniCssExtractPlugin</code> 插件, 将 css 提取出来单独打包。</li><li><code>CssMinimizerPlugin</code> 插件，压缩 css。</li><li><code>CompressionPlugin</code> 插件，将资源压缩成 gzip 格式（大大提升传输效率）。另外还需要在 node 服务器上引入 <code>compression</code> 插件配合使用。</li><li><code>WebpackBar</code> 插件，打包时显示进度条。</li></ol><h4 id="webpack-server-config-js-"><code>webpack.server.config.js</code> 服务端配置文件</h4><pre><code class="language-js">const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const WebpackBar = require('webpackbar')

const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
        'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
]

if (process.env.NODE_ENV == 'production') {
    plugins.push(
        new WebpackBar()
    )
}

module.exports = merge(base, {
    target: 'node',
    devtool: '#source-map',
    entry: './src/entry-server.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({
        allowlist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中，而是在运行时(runtime)再去从外部获取这些扩展依赖
    }),
    plugins,
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'vue-style-loader',
                    'css-loader'
                ]
            }
        ]
    },
})</code></pre><p>服务端打包和客户端不同，它将所有文件一起打包成一个文件 <code>server-bundle.js</code>。同时解析 css 需要使用 <code>vue-style-loader</code>，这一点在官方指南中有说明：</p><figure class="kg-card kg-image-card"><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e2cc5dc0d35d41cdabf633b78667ea1e~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="e2cc5dc0d35d41cdabf633b78667ea1e~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><h3 id="--4">配置服务器</h3><h4 id="--5">生产环境</h4><p><code>pro-server.js</code> 生产环境服务器配置文件</p><pre><code class="language-js">const fs = require('fs')
const path = require('path')
const express = require('express')
const setApi = require('./api')
const LRU = require('lru-cache') // 缓存
const { createBundleRenderer } = require('vue-server-renderer')
const favicon = require('serve-favicon')
const resolve = file =&gt; path.resolve(__dirname, file)

const app = express()
// 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
const compression = require('compression')
app.use(compression())
// 设置 favicon
app.use(favicon(resolve('../public/favicon.ico')))

// 新版本 需要加 new，旧版本不用
const microCache = new LRU({
    max: 100,
    maxAge: 60 * 60 * 24 * 1000 // 重要提示：缓存资源将在 1 天后过期。
})

const serve = (path) =&gt; {
    return express.static(resolve(path), {
        maxAge: 1000 * 60 * 60 * 24 * 30
    })
}

app.use('/dist', serve('../dist', true))

function createRenderer(bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            basedir: resolve('../dist'),
            runInNewContext: false
        })
    )
}

function render(req, res) {
    const hit = microCache.get(req.url)
    if (hit) {
        console.log('Response from cache')
        return res.end(hit)
    }

    res.setHeader('Content-Type', 'text/html')

    const handleError = err =&gt; {
        if (err.url) {
            res.redirect(err.url)
        } else if (err.code === 404) {
            res.status(404).send('404 | Page Not Found')
        } else {
            res.status(500).send('500 | Internal Server Error~')
            console.log(err)
        }
    }

    const context = {
        title: 'SSR 测试', // default title
        url: req.url
    }

    renderer.renderToString(context, (err, html) =&gt; {
        if (err) {
            return handleError(err)
        }

        microCache.set(req.url, html)
        res.send(html)
    })
}

const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 将js文件注入到页面中
const renderer = createRenderer(bundle, {
    template,
    clientManifest
})

const port = 8080

app.listen(port, () =&gt; {
    console.log(`server started at localhost:${ port }`)
})

setApi(app)

app.get('*', render)</code></pre><p>从代码中可以看到，当首次加载页面时，需要调用 <code>createBundleRenderer()</code> 生成一个 renderer，它的参数是打包生成的 <code>vue-ssr-server-bundle.json</code> 和 <code>vue-ssr-client-manifest.json</code> 文件。当返回 HTML 文件后，页面将会被客户端接管。</p><p>在文件的最后有一行代码 <code>app.get('*', render)</code>，它表示所有匹配不到的请求都交给它处理。所以如果你写了 ajax 请求处理函数必须放在前面，就像下面这样：</p><pre><code class="language-js">app.get('/fetchData', (req, res) =&gt; { ... })
app.post('/changeData', (req, res) =&gt; { ... })
app.get('*', render)</code></pre><p>否则你的页面会打不开。</p><h4 id="--6">开发环境</h4><p>开发环境的服务器配置和生产环境没什么不同，区别在于开发环境下的服务器有热更新。</p><p>一般用 webpack 进行开发时，简单的配置一下 dev server 参数就可以使用热更新了，但是 SSR 项目需要自己配置。</p><p>由于 SSR 开发环境服务器的配置文件 <code>setup-dev-server.js</code> 代码太多，我对其进行简化后，大致代码如下：</p><pre><code class="language-js">// dev-server.js
const express = require('express')
const webpack = require('webpack')
const webpackConfig = require('../build/webpack.dev') // 获取 webpack 配置文件
const compiler = webpack(webpackConfig)
const app = express()

app.use(require('webpack-hot-middleware')(compiler))
app.use(require('webpack-dev-middleware')(compiler, {
    noInfo: true,
    stats: {
        colors: true
    }
}))</code></pre><p>同时需要在 webpack 的入口文件加上这一行代码 <code>webpack-hot-middleware/client?reload=true</code>。</p><pre><code class="language-js">// webpack.dev.js
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base.config.js') // 这个配置和热更新无关，可忽略

module.exports = merge(webpackBaseConfig, {
    mode: 'development',
    entry: {
        app: ['webpack-hot-middleware/client?reload=true' , './client/main.js'] // 开启热模块更新
    },
    plugins: [new webpack.HotModuleReplacementPlugin()]
})</code></pre><p>然后使用 <code>node dev-server.js</code> 来开启前端代码热更新。</p><p>热更新主要使用了两个插件：<code>webpack-dev-middleware</code> 和 <code>webpack-hot-middleware</code>。顾名思义，看名称就知道它们的作用，</p><p><code>webpack-dev-middleware</code> 的作用是生成一个与 webpack 的 compiler 绑定的中间件，然后在 express 启动的 app 中调用这个中间件。</p><p>这个中间件的作用呢，简单总结为以下三点：通过watch mode，监听资源的变更，然后自动打包; 快速编译，走内存；返回中间件，支持express 的 use 格式。</p><p><code>webpack-hot-middleware</code> 插件的作用就是热更新，它需要配合 <code>HotModuleReplacementPlugin</code> 和 <code>webpack-dev-middleware</code> 一起使用。</p><h3 id="-vue-ssr-client-manifest-json-vue-ssr-server-bundle-json">打包文件 <code>vue-ssr-client-manifest.json</code> 和 <code>vue-ssr-server-bundle.json</code></h3><p>webpack 需要对源码打包两次，一次是为客户端环境打包的，一次是为服务端环境打包的。</p><p>为客户端环境打包的文件，和以前我们打包的资源一样，不过多出了一个 <code>vue-ssr-client-manifest.json</code> 文件。服务端环境打包只输出一个 <code>vue-ssr-server-bundle.json</code> 文件。</p><p><code>vue-ssr-client-manifest.json</code> 包含了客户端环境所需的资源名称：</p><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/07cf395cb58348a2a350bc965042b6c4~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="07cf395cb58348a2a350bc965042b6c4~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><p>从上图中可以看到有三个关键词：</p><ol><li>all，表示这是打包的所有资源。</li><li>initial，表示首页加载必须的资源。</li><li>async，表示需要异步加载的资源。</li></ol><p><code>vue-ssr-server-bundle.json</code> 文件：</p><figure class="kg-card kg-image-card"><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/388d798839004215b6250e5e1c553794~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="388d798839004215b6250e5e1c553794~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><ol><li>entry， 服务端入口文件。</li><li>files，服务端依赖的资源。</li></ol><h3 id="--7">填坑记录</h3><h4 id="1-vue-router-failed-to-resolve-async-component-default-referenceerror-window-is-not-defined">1. <code>[vue-router] failed to resolve async component default: referenceerror: window is not defined</code></h4><p>由于在一些文件或第三方文件中可能会用到 window 对象，并且 node 中不存在 window 对象，所以会报错。 此时可在 <code>src/app.js</code> 文件加上以下代码进行判断：</p><pre><code class="language-js">// 在 app.js 文件添加上这段代码，对环境进行判断
if (typeof window === 'undefined') {
	global.window = {}
}</code></pre><h4 id="2-mini-css-extract-plugin-referenceerror-document-is-not-defined">2. <code>mini-css-extract-plugin</code> 插件造成 <code>ReferenceError: document is not defined</code></h4><p>使用 <code>mini-css-extract-plugin</code> 插件打包的的 <code>server bundle</code>， 会使用到 document。由于 node 环境中不存在 document 对象，所以报错。</p><p>解决方案：样式相关的 loader 不要放在 <code>webpack.base.config.js</code> 文件，将其分拆到 <code>webpack.client.config.js</code> 和 <code>webpack.client.server.js</code> 文件。其中 <code>mini-css-extract-plugin</code> 插件要放在 <code>webpack.client.config.js</code> 文件配置。</p><p>base</p><pre><code class="language-js">module: {
    rules: [
        {
            test: /\.vue$/,
            loader: 'vue-loader',
            options: {
                compilerOptions: {
                    preserveWhitespace: false
                }
            }
        },
        {
            test: /\.js$/,
            loader: 'babel-loader',
            exclude: /node_modules/
        },
        {
            test: /\.(png|svg|jpg|gif|ico)$/,
            use: ['file-loader']
        },
        {
            test: /\.(woff|eot|ttf)\??.*$/,
            loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
        },
    ]
}</code></pre><p>client</p><pre><code class="language-js">module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                {
                    loader: MiniCssExtractPlugin.loader,
                    options: {
                        // 解决 export 'default' (imported as 'mod') was not found
                        esModule: false,
                    },
                },
                'css-loader'
            ]
        }
    ]
}</code></pre><p>server</p><pre><code class="language-js">module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                'vue-style-loader',
                'css-loader'
            ]
        }
    ]
}</code></pre><h4 id="3-">3. 开发环境下跳转页面样式不生效，但生产环境正常。</h4><p>由于开发环境使用的是 <code>memory-fs</code> 插件，打包文件是放在内存中的。如果此时 <code>dist</code> 文件夹有刚才打包留下的资源，就会使用 <code>dist</code> 文件夹中的资源，而不是内存中的资源。并且开发环境和打包环境生成的资源名称是不一样的，所以就造成了这个 BUG。</p><p>解决方法是执行 <code>npm run dev</code> 时，删除 <code>dist</code> 文件夹。所以要在 <code>npm run dev</code> 对应的脚本中加上 <code>rimraf dist</code>。</p><pre><code class="language-json">"dev": "rimraf dist &amp;&amp; node ./server/dev-server.js --mode development",</code></pre><h4 id="4-vue-router-failed-to-resolve-async-component-default-referenceerror-document-is-not-defined">4. <code>[vue-router] Failed to resolve async component default: ReferenceError: document is not defined</code></h4><p>不要在有可能使用到服务端渲染的页面访问 DOM，如果有这种操作请放在 <code>mounted()</code> 钩子函数里。</p><p>如果你引入的数据或者接口有访问 DOM 的操作也会报这种错，在这种情况下可以使用 <code>require()</code>。因为 <code>require()</code> 是运行时加载的，所以可以这样使用：</p><pre><code class="language-js">&lt;script&gt;
// 原来报错的操作，这个接口有 DOM 操作，所以这样使用的时候在服务端会报错。
import { fetchArticles } from '@/api/client'

export default {
  methods: {
    getAppointArticles() {
      fetchArticles({
        tags: this.tags,
        pageSize: this.pageSize,
        pageIndex: this.pageIndex,
      })
      .then(res =&gt; {
      	this.$store.commit('setArticles', res)
      })
    },
  }
}
&lt;/script&gt;</code></pre><p>修改后：</p><pre><code class="language-js">&lt;script&gt;
// 先定义一个外部变量，在 mounted() 钩子里赋值
let fetchArticles
export default {
  mounted() {
    // 由于服务端渲染不会有 mounted() 钩子，所以在这里可以保证是在客户端的情况下引入接口
  	fetchArticles = require('@/api/client').fetchArticles
  },
  methods: {
    getAppointArticles() {
      fetchArticles({
        tags: this.tags,
        pageSize: this.pageSize,
        pageIndex: this.pageIndex,
      })
      .then(res =&gt; {
      	this.$store.commit('setArticles', res)
      })
    },
  }
}
&lt;/script&gt;</code></pre><p>修改后可以正常使用。</p><h4 id="5-">5. 开发环境下，开启服务器后无任何反应，也没见控制台输出报错信息。</h4><p>这个坑其实是有报错信息的，但是没有输出，导致以为没有错误。</p><p>在 <code>setup-dev-server.js</code> 文件中有一行代码 <code>if (stats.errors.length) return</code>，如果有报错就直接返回，不执行后续的操作。导致服务器没任何反应，所以我们可以在这打一个 <code>console.log</code> 语句，打印报错信息。</p><figure class="kg-card kg-image-card"><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6e4f7e6787ab487a9502d2d807a9638d~tplv-k3u1fbpfcp-watermark.awebp" class="kg-image" alt="6e4f7e6787ab487a9502d2d807a9638d~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></figure><h3 id="--8">小结</h3><p>这个 DEMO 是基于官方 DEMO <a href="https://github.com/vuejs/vue-hackernews-2.0/">vue-hackernews-2.0</a> 改造的。不过官方 DEMO 发表于 4 年前，最近修改时间是 2 年前，很多选项参数已经过时了。并且官方 DEMO 需要翻墙才能使用。所以我在此基础上对其进行了改造，改造后的 DEMO 放在 <a href="https://github.com/woai3c/vue-ssr-demo">GitHub</a> 上，它是一个比较完善的 DEMO，可以在此基础上进行二次开发。</p><p>如果你不仅仅满足于一个 DEMO，建议看一看我的<a href="https://github.com/woai3c/node-blog">个人博客项目</a>，它原来是客户端渲染的项目，后来重构为服务端渲染，绝对实战。</p><h2 id="--9">参考资料</h2><ul><li><a href="https://ssr.vuejs.org/zh/">Vue.js 服务器端渲染指南</a></li><li><a href="https://juejin.cn/post/6844904163692937229">vue-ssr服务端渲染透析</a></li><li><a href="https://juejin.cn/post/6844903489450016782">webpack-dev-middleware详解</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 技术点评：对低代码平台的一些看法 ]]>
                </title>
                <description>
                    <![CDATA[ 由于本人水平有限，文中所表达的观点不一定准确甚至有错误。如果您有不同的意见，欢迎在评论区留言。 什么是 nocode lowcode procode  * nocode 无代码：不需要写代码就能做出一个应用。  * lowcode 低代码：仅需写少量代码就可以做出一个应用。  * procode 专业代码：应用所有的代码都需要开发人员编写。 由于 nocode 和 lowcode 没有本质区别，所以下文统一使用低代码来代指它们。 低代码平台在近几年特别火，在各大技术社区经常能看到低代码相关的文章。国内也有不少的低代码平台，例如墨刀、云凤蝶、宜搭等等。 在现在这个年代，软件就是生产力的代名词。以前在公司请个假还得拿张请假条，层层审批，找各个领导签字。现在只需要在软件上发个申请就可以了。因此，低代码平台为什么这么火也不难理解了，因为可以解放生产力。与之相对的是另一个问题：供需不平衡，需求太大，供应不足（软件开发人员相对不足）。低代码平台就是为了解决这类问题而出现的。 例如运营、财务、销售等部门需要特定的系统，但又没有 IT 部门或者 IT 部门人员不足。这个时候就可以选择使用低代 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/lowcode/</link>
                <guid isPermaLink="false">6126505db03439064c61ca83</guid>
                
                    <category>
                        <![CDATA[ 前端 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Wed, 25 Aug 2021 14:25:18 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/08/glenn-carstens-peters-npxXWgQ33ZQ-unsplash-1.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>由于本人水平有限，文中所表达的观点不一定准确甚至有错误。如果您有不同的意见，欢迎在评论区留言。</p><h2 id="-nocode-lowcode-procode">什么是 nocode lowcode procode</h2><ul><li>nocode 无代码：不需要写代码就能做出一个应用。</li><li>lowcode 低代码：仅需写少量代码就可以做出一个应用。</li><li>procode 专业代码：应用所有的代码都需要开发人员编写。</li></ul><p>由于 nocode 和 lowcode 没有本质区别，所以下文统一使用低代码来代指它们。</p><p>低代码平台在近几年特别火，在各大技术社区经常能看到低代码相关的文章。国内也有不少的低代码平台，例如墨刀、云凤蝶、宜搭等等。</p><p>在现在这个年代，软件就是生产力的代名词。以前在公司请个假还得拿张请假条，层层审批，找各个领导签字。现在只需要在软件上发个申请就可以了。因此，低代码平台为什么这么火也不难理解了，因为可以解放生产力。与之相对的是另一个问题：供需不平衡，需求太大，供应不足（软件开发人员相对不足）。低代码平台就是为了解决这类问题而出现的。</p><p>例如运营、财务、销售等部门需要特定的系统，但又没有 IT 部门或者 IT 部门人员不足。这个时候就可以选择使用低代码平台了，各部门人员通过简单的培训（熟悉文档）就可以上手开发，通过拖拉拽的方式生成一个应用。</p><h2 id="-">优点与缺点</h2><p>优点很明显，通过简单的培训就可以让很多不懂技术的人员上手开发。将一些原本软件开发人员才能做的事交给其他人来做，这样可以降低成本。因为软件人员的薪资成本一般比其他岗位的薪资高，使用低代码平台就是将相对高的人工成本转移到成本相对低的人身上（可以少招一些程序员）。</p><p>但缺点也很明显，具体有以下几点：</p><ol><li>低代码平台不是通用的解决方案，它只能针对某一业务场景。目前的低代码平台有面向工作流、面向原型、面向表单等一些常用的业务场景，或者说只能面向简单的业务场景。没有通用的解决方案。</li><li>无法做到定制化，低代码平台由于面向的人员比较多，开发功能时需要考虑到不同的用户，无法专门为某一企业做定制化开发。</li><li>降低成本，就是将开发人员的成本转移到其他人身上，降低的是薪资成本。但从开发效率来看，反而是降低了。因为用拖拉拽的方式开发应用效率是很低的，远远比不上直接敲代码。我在之前的公司开发过一个类似于墨刀的低代码平台，在测试时花了半小时靠拖拉拽的方式开发了一个小应用。为了做一个对比，我用敲代码的方式，不到 10 分钟就搞定了（当然，不同的业务场景可能时间不太一样，但不会有太大的差别）。不过这个缺点不适用于没有开发人员的企业。</li></ol><p>总的来说，低代码平台还是利大于弊的。因为目前还有很多企业是没有 IT 部门的，低代码平台可以很好的解决他们的问题。</p><p>这里有一个实际的例子，在我工作的上一家公司，我们接了同一个客户很多的项目。这些项目基本上都是同一类型的项目，功能上可能会有稍微的差别，但没有本质区别。这个客户必须为他的每一个项目付费，即使这些项目可能只有几个页面不一样。为了改善这种情况，我们针对用户的需求开发了一个低代码产品，后面用户要做什么项目只需要自己开发就可以了。</p><p>对于客户来说，他再也不用为每一个项目付费了，因为可以自己开发。这样就降低了费用成本，自己开发虽然时间长一点，但相对于降低的成本 ，客户是能够接受的。</p><h2 id="--1">未来</h2><p>最近看了不少关于低代码平台市场的研究报告，例如头豹研究院发布的《2020中国低代码开发平台行业概览》是这样说的：</p><p>在中国云服务市场高速扩张的背景之下，过去5年低代码开发服务初步落地。2016至2019年间，中国低代码开发平台行业融资额由100万元攀升至1.2亿元。</p><p>国内国外很多的大公司也在推出或收购低代码平台，争取在低代码平台这个赛道上分一杯羹。例如国外的有微软、亚马逊、西门子等等，国内的有腾讯、字节、阿里等等。</p><p>不说远的，我认为未来两三年内低代码平台的发展还是很有潜力的，市场规模每年都在涨。</p><h2 id="--2">低代码平台的一些功能点</h2><p>从目前的低代码平台来看，基本上都是靠拖拉拽来生成应用。可以说低代码平台是重前端，轻后端的产品，对于前端的技术要求更高。我之前对低代码平台做过一番调研，写了一个类似于墨刀的 DEMO。并且写了三篇文档对这个 DEMO 涉及的功能点进行原理分析：</p><ol><li>编辑器</li><li>自定义组件</li><li>拖拽</li><li>删除组件、调整图层层级</li><li>放大缩小</li><li>撤消、重做</li><li>组件属性设置</li><li>吸附</li><li>预览、保存代码</li><li>绑定事件</li><li>绑定动画</li><li>导入 PSD</li><li>手机模式</li><li>拖拽旋转</li><li>复制粘贴剪切</li><li>数据交互</li><li>发布</li><li>多个组件的组合和拆分</li><li>文本组件</li><li>矩形组件</li><li>锁定组件</li><li>快捷键</li><li>网格线</li><li>编辑器快照的另一种实现方式</li></ol><p>如果你想了解更多，可以看一下我的项目 <a href="https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fwoai3c%2Fvisual-drag-demo" rel="nofollow noopener noreferrer">visual-drag-demo</a>。</p><p>在线预览：</p><ul><li><a href="https://woai3c.gitee.io/visual-drag-demo/#/">预览入口一</a></li><li><a href="https://woai3c.github.io/visual-drag-demo">预览入口二</a></li></ul><p>文档：</p><ul><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library/">可视化拖拽组件库一些技术要点原理分析</a></li><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-2/">可视化拖拽组件库一些技术要点原理分析（二）</a></li><li><a href="https://chinese.freecodecamp.org/news/visual-drag-and-drop-component-library-part-3/">可视化拖拽组件库一些技术要点原理分析（三）</a></li></ul><p>我觉得低代码平台比较重要的一个点就是物料库。物料库越丰富越好，这是毋容置疑。另外添加新物料的方式，建议做成类似于插件的形式，这样可以方便扩展。最好就是插件能够以 npm 包的形式发布，添加插件就是添加 npm 包。这样无论是添加或删除物料都不用对低代码平台的源码进行修改。</p><h2 id="--3">总结</h2><p>一句话，比较看好低代码平台的前景。如果你是一个前端，并且对于自己的职业生涯没有清晰的规划，可以考虑一下低代码这个方向。</p> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 实现一个 webpack loader 和 webpack plugin ]]>
                </title>
                <description>
                    <![CDATA[ loader > 官网上的定义：loader 是一个转换器，用于对源代码进行转换。 例如 babel-loader 可以将 ES6 代码转换为 ES5 代码；sass-loader 将 sass 代码转换为 css 代码。 一般 loader 的配置代码如下： module: {         rules: [             {       ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/code-a-webpack-loader-and-a-webpack-plugin/</link>
                <guid isPermaLink="false">612c5ca60cd9ee0623e15aa3</guid>
                
                    <category>
                        <![CDATA[ Webpack ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Tue, 24 Aug 2021 02:00:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/08/1_5d_eDFKTmlTdYafG9dahdw.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <h2 id="loader">loader</h2><blockquote>官网上的定义：loader 是一个转换器，用于对源代码进行转换。</blockquote><p>例如 <code>babel-loader</code> 可以将 ES6 代码转换为 ES5 代码；<code>sass-loader</code> 将 <code>sass</code> 代码转换为 <code>css</code> 代码。</p><p>一般 loader 的配置代码如下：</p><pre><code class="language-js">module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    // loader 的执行顺序从下到上
                    {
                        loader: path.resolve('./src/loader2.js'),
                    },
                    {
                        loader: path.resolve('./src/loader1.js'),
                    },
                ]
            }
        ]
    },</code></pre><p>rules 数组包含了一个个匹配规则和具体的 loader 文件。</p><p>上述代码中的 <code>test: /\.js$/</code> 就是匹配规则，表示对 js 文件使用下面的两个 loader。</p><p>而 loader 的处理顺序是自下向上的，即先用 loader1 处理源码，然后将处理后的代码再传给 loader2。</p><p>loader2 处理后的代码就是最终的打包代码。</p><h3 id="loader-">loader 的实现</h3><p>loader 其实是一个函数，它的参数是匹配文件的源码，返回结果是处理后的源码。下面是一个最简单的 loader，它什么都没做：</p><pre><code class="language-js">module.exports = function (source) {
    return source
}</code></pre><p>这么简单的 loader 没有挑战性，我们可以写一个稍微复杂一点的 loader，它的作用是将 <code>var</code> 关键词替换为 <code>const</code>：</p><pre><code class="language-js">module.exports = function (source) {
    return source.replace(/var/g, 'const')
}</code></pre><p>写完之后，我们来测试一下，测试文件为：</p><pre><code class="language-js">function test() {
    var a = 1;
    var b = 2;
    var c = 3;
    console.log(a, b, c);
}

test()</code></pre><p><code>wepback.config.js</code> 配置文件为：</p><pre><code class="language-js">const path = require('path')

module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: path.resolve('./src/loader1.js'),
                    },
                ]
            }
        ]
    },
}</code></pre><p>运行 <code>npm run build</code>，得到打包文件 <code>bundle.js</code>，我们来看一看打包后的代码：</p><pre><code class="language-js">eval("function test() {\r\n    const a = 1;\r\n    const b = 2;\r\n    const c = 3;\r\n    console.log(a, b, c);\r\n}\r\n\r\ntest()\n\n//# sourceURL=webpack:///./src/index.js?");</code></pre><p>可以看到，代码中的 <code>var</code> 已经变成了 <code>const</code>。</p><h3 id="-loader">异步 loader</h3><p>刚才实现的 loader 是一个同步 loader，在处理完源码后用 <code>return</code> 返回。</p><p>下面我们来实现一个异步 loader：</p><pre><code class="language-js">module.exports = function (source) {
    const callback = this.async()

    // 由于有 3 秒延迟，所以打包时需要 3+ 秒的时间
    setTimeout(() =&gt; {
        callback(null, `${source.replace(/;/g, '')}`)
    }, 3000)
}</code></pre><p>异步 loader 需要调用 webpack 的 <code>async()</code> 生成一个 callback，它的第一个参数是 <code>error</code>，这里可设为 <code>null</code>，第二个参数就是处理后的源码。当你异步处理完源码后，调用 <code>callback</code> 即可。</p><p>下面来试一下异步 loader 到底有没生效，这里设置了一个 3 秒延迟。我们来对比一下打包时间：</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1426f2b0393d47b084d4f7c596896c0b~tplv-k3u1fbpfcp-watermark.image" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d43faffb61a144beb5f620d68a597f16~tplv-k3u1fbpfcp-watermark.image" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>上图是调用同步 loader 的打包时间，为 141 ms；下图是调用异步 loader 的打包时间，为 3105 ms，说明异步 loader 生效了。</p><p>如果想看完整 demo 源码，请点击我的 <a href="https://github.com/woai3c/webpack-demo">GitHub</a>。</p><h2 id="plugin">plugin</h2><p>webpack 在整个编译周期中会触发很多不同的事件，plugin 可以监听这些事件，并且可以调用 webpack 的 API 对输出资源进行处理。</p><p>这是它和 loader 的不同之处，loader 一般只能对源文件代码进行转换，而 plugin 可以做得更多。plugin 在整个编译周期中都可以被调用，只要监听事件。</p><p>对于 webpack 编译，有两个重要的对象需要了解一下：</p><blockquote>Compiler 和 Compilation</blockquote><p>在插件开发中最重要的两个资源就是 compiler 和 compilation 对象。理解它们的角色是扩展 webpack 引擎重要的第一步。</p><blockquote>compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立，并配置好所有可操作的设置，包括 options，loader 和 plugin。当在 webpack 环境中应用一个插件时，插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。</blockquote><blockquote>compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时，每当检测到一个文件变化，就会创建一个新的 compilation，从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调，以供插件做自定义处理时选择使用。</blockquote><blockquote>这两个组件是任何 webpack 插件不可或缺的部分（特别是 compilation），因此，开发者在阅读源码，并熟悉它们之后，会感到获益匪浅。</blockquote><h3 id="plugin-">plugin 的实现</h3><p>我们看一下官网的定义，webpack 插件由以下部分组成：</p><ol><li>一个 JavaScript 命名函数。</li><li>在插件函数的 prototype 上定义一个 apply 方法。</li><li>指定一个绑定到 webpack 自身的事件钩子。</li><li>处理 webpack 内部实例的特定数据。</li><li>功能完成后调用 webpack 提供的回调。</li></ol><p>简单的说，一个具有 apply 方法的函数就是一个插件，并且它要监听 webpack 的某个事件。下面来看一个简单的示例：</p><pre><code class="language-js">function Plugin(options) { }

Plugin.prototype.apply = function (compiler) {
    // 所有文件资源都被 loader 处理后触发这个事件
    compiler.plugin('emit', function (compilation, callback) {
        // 功能完成后调用 webpack 提供的回调
        console.log('Hello World')
        callback()
    })
}

module.exports = Plugin</code></pre><p>写完插件后要怎么调用呢？</p><p>先在 webpack 配置文件中引入插件，然后在 plugins 选项中配置：</p><pre><code class="language-js">const Plugin = require('./src/plugin')

module.exports = {
	...
    plugins: [
        new Plugin()
    ]
}</code></pre><p>这就是一个简单的插件了。</p><p>下面我们再来写一个复杂点的插件，它的作用是将经过 loader 处理后的打包文件 <code>bundle.js</code> 引入到 <code>index.html</code> 中：</p><pre><code class="language-js">function Plugin(options) { }

Plugin.prototype.apply = function (compiler) {
    // 所有文件资源经过不同的 loader 处理后触发这个事件
    compiler.plugin('emit', function (compilation, callback) {
        // 获取打包后的 js 文件名
        const filename = compiler.options.output.filename
        // 生成一个 index.html 并引入打包后的 js 文件
        const html = `&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
    &lt;title&gt;Document&lt;/title&gt;
    &lt;script src="${filename}"&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;body&gt;
    
&lt;/body&gt;
&lt;/html&gt;`
        // 所有处理后的资源都放在 compilation.assets 中
        // 添加一个 index.html 文件
        compilation.assets['index.html'] = {
            source: function () {
                return html
            },
            size: function () {
                return html.length
            }
        }

        // 功能完成后调用 webpack 提供的回调
        callback()
    })
}

module.exports = Plugin</code></pre><p>OK，执行一下，看看效果。</p><figure class="kg-card kg-image-card"><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d6e9c91ae0ab4a30aefd9ebdff581c41~tplv-k3u1fbpfcp-watermark.image" class="kg-image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></figure><p>完美，和预测的结果一模一样。</p><p>完整 demo 源码，请看我的 <a href="https://github.com/woai3c/webpack-demo">GitHub</a>。</p><p>想了解更多的事件，请看官网介绍 <a href="https://webpack.docschina.org/api/compiler-hooks/">compiler 钩子</a>。</p><h2 id="-">参考资料</h2><ul><li><a href="https://www.webpackjs.com/contribute/writing-a-loader/">编写一个 loader</a></li><li><a href="https://www.webpackjs.com/contribute/writing-a-plugin/">编写一个插件</a></li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 详解 HTTP、HTTPS 和 HTTP2 ]]>
                </title>
                <description>
                    <![CDATA[ 你知道 HTTP 和 HTTPS 的区别吗？如果你想了解更多的计算机网络基础知识，那么你可能会想弄清楚这些概念。 在这篇文章中，我将尽量用通俗易懂的方式来向读者讲述 HTTP 的知识。我建议大家在学习 HTTP 知识的时候，利用 Chrome 开发者工具来做实践，这可以帮助我们理解得更深刻。 此图是在网上找来的，侵删HTTP 概述 HTTP 超文本传输​​协议是位于 TCP/IP 体系结构中的应用层协议，它是万维网数据通信的基础。 当我们访问一个网站时，需要通过统一资源定位符（uniform resource locator，URL）来定位服务器并获取资源。 <协议>://<域名>:<端口>/<路径> 一个 URL 的一般形式通常如上所示（http://test.com/index.html ），现在最常用的协议就是 HTTP，HTTP 的默认端口是 80，通常可以省略。 HTTP/1.1 HTTP/1.1 是目前使用最广泛的版本，一般没有特别标明版本都是指 HTTP/1.1。 HTTP 连接建立过程 我们来看一下在浏览器输入 URL 后获取 HTML 页面的过程。   ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/http-https-and-http2/</link>
                <guid isPermaLink="false">5fb235e95f583f056509114b</guid>
                
                    <category>
                        <![CDATA[ HTTP ]]>
                    </category>
                
                    <category>
                        <![CDATA[ HTTPS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 计算机网络 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Thu, 19 Aug 2021 07:20:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2021/04/photo-1593642533144-3d62aa4783ec-1.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>你知道 HTTP 和 HTTPS 的区别吗？如果你想了解更多的计算机网络基础知识，那么你可能会想弄清楚这些概念。</p><p>在这篇文章中，我将尽量用通俗易懂的方式来向读者讲述 HTTP 的知识。我建议大家在学习 HTTP 知识的时候，利用 Chrome 开发者工具来做实践，这可以帮助我们理解得更深刻。</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pic2.zhimg.com/80/v2-8d547f61a175c4e6cc05ad90a7406791_720w.jpg" class="kg-image" alt="v2-8d547f61a175c4e6cc05ad90a7406791_720w" width="600" height="400" loading="lazy"><figcaption>此图是在网上找来的，侵删</figcaption></figure><h2 id="http-">HTTP 概述</h2><p>HTTP 超文本传输​​协议是位于 TCP/IP 体系结构中的应用层协议，它是万维网数据通信的基础。</p><p>当我们访问一个网站时，需要通过统一资源定位符（uniform resource locator，URL）来定位服务器并获取资源。</p><pre><code class="language-text">&lt;协议&gt;://&lt;域名&gt;:&lt;端口&gt;/&lt;路径&gt;</code></pre><p>一个 URL 的一般形式通常如上所示（<code>http://test.com/index.html</code> ），现在最常用的协议就是 HTTP，HTTP 的默认端口是 80，通常可以省略。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-91035fa397152281c18e1a2f9ece81f4_720w.jpg" class="kg-image" alt="v2-91035fa397152281c18e1a2f9ece81f4_720w" width="600" height="400" loading="lazy"></figure><h2 id="http-1-1">HTTP/1.1</h2><p>HTTP/1.1 是目前使用最广泛的版本，一般没有特别标明版本都是指 HTTP/1.1。</p><h3 id="http--1">HTTP 连接建立过程</h3><p>我们来看一下在浏览器输入 URL 后获取 HTML 页面的过程。</p><ol><li>先通过<a href="https://link.zhihu.com/?target=https%3A//baike.baidu.com/item/%25E5%259F%259F%25E5%2590%258D%25E7%25B3%25BB%25E7%25BB%259F%25EF%25BC%2588%25E6%259C%258D%25E5%258A%25A1%25EF%25BC%2589%25E5%258D%258F%25E8%25AE%25AE/15134609%3Ffromtitle%3Ddns%26fromid%3D427444" rel="nofollow noreferrer">域名系统（Domain Name System，DNS）</a>查询将域名转换为 IP 地址。即将 <code>test.com</code> 转换为 <code>221.239.100.30</code> 这一过程。</li><li>通过三次握手（稍后会讲）建立 TCP 连接。</li><li>发起 HTTP 请求。</li><li>目标服务器接收到 HTTP 请求并处理。</li><li>目标服务器往浏览器发回 HTTP 响应。</li><li>浏览器解析并渲染页面。</li></ol><p>下图中的 RTT 为往返时延（Round-Trip Time： 往返时延。表示从发送端发送数据开始，到发送端收到来自接收端的确认，总共经历的时延）。<br></p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-ddcb4a085bbde7f8bf80f792d4ac7e48_720w.jpg" class="kg-image" alt="v2-ddcb4a085bbde7f8bf80f792d4ac7e48_720w" width="600" height="400" loading="lazy"></figure><h3 id="http--2">HTTP 连接拆除过程</h3><p>所有 HTTP 客户端（浏览器）、服务器都可在任意时刻关闭 TCP 连接。通常会在一条报文结束时关闭连接，但出错的时候，也可能在首部行的中间或其他任意位置关闭连接。</p><h3 id="tcp-">TCP 三次握手和四次挥手</h3><p>由于 HTTP 是基于 TCP 的，所以打算在这补充一下 TCP 连接建立和拆除的过程。</p><p>首先，我们需要了解一些 TCP 报文段的字段和标志位：</p><ol><li>32 比特的序号字段和确认号字段，TCP 字节流每一个字节都按顺序编号。确认号是接收方期望从对方收到的下一字节的序号。</li><li>ACK 标志位，用于指示确认字段中的值是有效的 ACK=1 有效，ACK=0 无效。</li><li>SYN 标志位，用于连接建立，SYN 为 1 时，表明这是一个请求建立连接报文。</li><li>FIN 标志位，用于连接拆除，FIN 为 1 时，表明发送方数据已发送完毕，并要求释放连接。<br></li></ol><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-578eeb6309c44c4607344fe019dff19b_720w.jpg" class="kg-image" alt="v2-578eeb6309c44c4607344fe019dff19b_720w" width="600" height="400" loading="lazy"></figure><h3 id="tcp--1">TCP 三次握手建立连接</h3><p>TCP 标准规定，ACK 报文段可以携带数据，但不携带数据就不用消耗序号。</p><ol><li>客户端发送一个不包含应用层数据的 TCP 报文段，首部的 SYN 置为 1，随机选择一个初始序号（一般为 0）放在 TCP 报文段的序号字段中。（SYN 为 1 的时候，不能携带数据，但要消耗掉一个序号）</li><li>TCP 报文段到达服务器主机后，服务器提取报文段，并为该 TCP 连接分配缓存和变量。然后向客户端发送允许连接的 ACK 报文段（不包含应用层数据）。这个报文段的首部包含 4 个信息：ACK 置 为 1，SYN 置为 1；确认号字段置为客户端的序号 + 1；随机选择自己的初始序号（一般为 0）。</li><li>收到服务器的 TCP 响应报文段后，客户端也要为该 TCP 连接分配缓存和变量，并向服务器发送一个 ACK 报文段。这个报文段将服务器端的序号 + 1 放置在确认号字段中，用来对服务器允许连接的报文段进行响应，因为连接已经建立，所以 SYN 置为 0。最后一个阶段，报文段可以携带客户到服务器的数据。并且以后的每一个报文段，SYN 都置为 0。</li></ol><p>下图是一个具体的示例：<br></p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://pic2.zhimg.com/80/v2-eb4981e4f58f1822c00d7d290d14ee51_720w.jpg" class="kg-image" alt="v2-eb4981e4f58f1822c00d7d290d14ee51_720w" width="600" height="400" loading="lazy"><figcaption>此截图是我使用 Wireshark 抓包工具截取的 TCP 报文段截图</figcaption></figure><h3 id="tcp--2">TCP 四次挥手拆除连接</h3><p>FIN 报文段即使不携带数据，也要消耗序号。</p><ol><li>客户端发送一个 FIN 置为 1 的报文段。</li><li>服务器回送一个确认报文段。</li><li>服务器发送 FIN 置为 1 的报文段。</li><li>客户端回送一个确认报文段。</li></ol><h3 id="tcp--3">TCP 为什么是四次挥手，而不是三次？</h3><ol><li>当 A 给 B 发送 FIN 报文时，代表 A 不再发送报文，但仍可以接收报文。</li><li>B 可能还有数据需要发送，因此先发送 ACK 报文，告知 A “我知道你想断开连接的请求了”。这样 A 便不会因为没有收到应答而继续发送断开连接的请求（即 FIN 报文）。</li><li>B 在处理完数据后，就向 A 发送一个 FIN 报文，然后进入 LAST_ACK 阶段（超时等待）。</li><li>A 向 B 发送 ACK 报文，双方都断开连接。</li></ol><p>参考资料：</p><ul><li><a href="https://www.zhihu.com/question/63264012">知乎网友-魔方的回答</a></li></ul><h3 id="http--3">HTTP 报文格式</h3><p>HTTP 报文由请求行、首部、实体主体组成，它们之间由 CRLF（回车换行符） 分隔开。</p><p><strong>注意：实体包括首部(也称为实体首部)和实体主体，sp 即是空格 space</strong>。<br></p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-392bc8d0de5ac6d44dd520ca4e42afc8_720w.jpg" class="kg-image" alt="v2-392bc8d0de5ac6d44dd520ca4e42afc8_720w" width="600" height="400" loading="lazy"></figure><p><br>请求行和首部是由 ASCII 文本组成的，实体主体是可选的，可以为空也可以是任意二进制数据。</p><p>请求报文和响应报文的格式基本相同。</p><p><strong>请求报文格式</strong>：</p><pre><code class="language-text">&lt;method&gt; &lt;request-URL&gt; &lt;version&gt;
&lt;headers&gt;
&lt;entity-body&gt;</code></pre><p><strong>响应报文格式</strong>：</p><pre><code class="language-text">&lt;version&gt; &lt;status&gt; &lt;reason-phrase&gt;
&lt;headers&gt;
&lt;entity-body&gt;</code></pre><p><strong>一个请求或响应报文由以下字段组成</strong>：</p><ol><li>请求方法，客户端希望服务器对资源执行的动作。</li><li>请求 URL，命名了所请求的资源。</li><li>协议版本，报文所使用的 HTTP 版本。</li><li>状态码，这三位数字描述了请求过程中所发生的情况。</li><li>原因短语，数字状态码的可读版本（例如上面的响应示例跟在 200 后面的 OK，一般按规范写最好）。</li><li>首部，可以有零或多个首部。</li><li>实体的主体部分，可以为空也可以包含任意二进制数据。</li></ol><p><strong>一个 HTTP 请求示例</strong>：</p><pre><code class="language-text">GET /2.app.js HTTP/1.1
Host: 118.190.217.8:3389
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
Accept: */*
Referer: http://118.190.217.8:3389/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9</code></pre><p><strong>一个 HTTP 响应示例</strong>：</p><pre><code class="language-text">HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Sat, 07 Mar 2020 03:52:30 GMT
ETag: W/"253e-170b31f7de7"
Content-Type: application/javascript; charset=UTF-8
Vary: Accept-Encoding
Content-Encoding: gzip
Date: Fri, 15 May 2020 05:38:05 GMT
Connection: keep-alive
Transfer-Encoding: chunked</code></pre><h3 id="-">方法</h3><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-1eff029d1e35612fb85ad1d6765d50c4_720w.jpg" class="kg-image" alt="v2-1eff029d1e35612fb85ad1d6765d50c4_720w" width="600" height="400" loading="lazy"></figure><h3 id="get-head">GET 和 HEAD</h3><p>其中 GET 和 HEAD 被称为安全方法，因为它们是幂等的（如果一个请求不管执行多少次，其结果都是一样的，这个请求就是<strong>幂等的</strong>），类似于 POST 就不是幂等的。</p><p>HEAD 方法和 GET 方法很类似，但服务器在响应中只返回首部。这就允许客户端在未获取实际资源的情况下，对资源的首部进行检查。使用 HEAD，可以：</p><ol><li>在不获取资源的情况下了解资源的情况。</li><li>通过查看响应状态码，看看某个对象是否存在。</li><li>通过查看首部，了解测试资源是否被修改了。</li></ol><p>服务器开发者必须确保返回的首部与 GET 请求所返回的首部完全相同。遵循 HTTP/1.1 规范，就必须实现 HEAD 方法。</p><h3 id="put">PUT</h3><p>与 GET 方法从服务器读取文档相反，PUT 方法会向服务器写入文档。PUT 方法的语义就是让服务器用请求的主体部分来创建一个由所请求的 URL 命名的新文档。 如果那个文档已存在，就覆盖它。</p><h3 id="post">POST</h3><p>POST 方法通常用来向服务器发送表单数据。</p><h3 id="trace">TRACE</h3><p>客户端发起一个请求时，这个请求可能要穿过路由器、防火墙、代理、网关等。每个中间节点都可能会修改原始的 HTTP 请求，TRACE 方法允许客户端在最终发起请求时，看看它变成了什么样子。</p><p>TRACE 请求会在目的服务器端发起一个“环回”诊断。行程最后一站的服务器会弹回一条 TRACE 响应，并在响应主体中携带它收到的原始请求报文。 这样客户端就可以查看在所有中间 HTTP 应用程序组成的请求/响应链上，原始报文是否被毁坏或修改过。<br></p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-f66c499fa15d2ae881c5aec8d3ee70be_720w.jpg" class="kg-image" alt="v2-f66c499fa15d2ae881c5aec8d3ee70be_720w" width="600" height="400" loading="lazy"></figure><p>TRACE 方法主要用于诊断，用于验证请求是否如愿穿过了请求/响应链。它也是一种工具，用来查看代理和其他应用程序对用户请求所产生的效果。 TRACE 请求中不能带有实体的主体部分。TRACE 响应的实体主体部分包含了响应服务器收到的请求的精确副本。</p><h3 id="options">OPTIONS</h3><p>OPTIONS 方法请求 Web 服务器告知其支持的各种功能。</p><h3 id="delete">DELETE</h3><p>DELETE 方法就是让服务器删除请求 URL 所指定的资源。</p><h3 id="--1">状态码</h3><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-aff02263117f428811e86b71ad01ec7a_720w.jpg" class="kg-image" alt="v2-aff02263117f428811e86b71ad01ec7a_720w" width="600" height="400" loading="lazy"></figure><h3 id="300-399-">300~399 重定向状态码</h3><p>重定向状态码要么告诉客户端使用替代位置来访问他们感兴趣的资源，要么提供一个替代的响应而不是资源的内容。 如果资源已被移动，可以发送一个重定向状态码和一个可选的 Location 首部来告知客户端资源已被移走，以及现在在哪里可以找到它。这样，浏览器可以在不打扰使用者的情况下，透明地转入新的位置。</p><h3 id="400-499-">400~499 客户端错误状态码</h3><p>有时客户端会发送一些服务器无法处理的东西，例如格式错误的请求报文、一个不存在的 URL。</p><h3 id="500-599-">500~599 服务器错误状态码</h3><p>有时客户端发送了一条有效请求，服务器自身却出错了。</p><h3 id="--2">首部</h3><p>首部和方法共同配合工作，决定了客户端和服务器能做什么事情。</p><p><strong>首部分类</strong>：</p><ol><li>通用首部，可以出现在请求或响应报文中。</li><li>请求首部，提供更多有关请求的信息。</li><li>响应首部，提供更多有关响应的信息。</li><li>实体首部，描述主体的长度和内容，或者资源自身。</li><li>扩展首部，规范中没有定义的新首部。</li></ol><h3 id="--3">通用首部</h3><p>有些首部提供了与报文相关的最基本信息，它们被称为通用首部。以下是一些常见的通用首部：<br></p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-2f7b70e0538ccfed82cb4bea696b5f79_720w.jpg" class="kg-image" alt="v2-2f7b70e0538ccfed82cb4bea696b5f79_720w" width="600" height="400" loading="lazy"></figure><h3 id="--4"><br>请求首部</h3><p>请求首部是只在请求报文中有意义的首部，用于说明请求的详情。以下是一些常见的请求首部：</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-bd43f3dbc08e9df61b77041526831854_720w.jpg" class="kg-image" alt="v2-bd43f3dbc08e9df61b77041526831854_720w" width="600" height="400" loading="lazy"></figure><h3 id="--5">响应首部</h3><p>响应首部让服务器为客户端提供了一些额外的信息。</p><h3 id="--6">实体首部</h3><p>实体首部提供了有关实体及其内容的大量信息，从有关对象类型的信息，到能够对资源使用的各种有效的请求方法。</p><p>例如<strong>内容首部</strong>，提供了与实体内容有关的特定信息，说明了其类型、尺寸以及处理它所需的其他有用信息。 另外，通用的缓存首部说明了如何或什么时候进行缓存。<strong>实体的缓存首部</strong>提供了与被缓存实体有关的信息。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-97b2ecf5532a21f520742cc0edb2f634_720w.jpg" class="kg-image" alt="v2-97b2ecf5532a21f520742cc0edb2f634_720w" width="600" height="400" loading="lazy"></figure><h3 id="--7">性能优化</h3><h3 id="1-http-">1. 减少 HTTP 请求</h3><p>每发起一个 HTTP 请求，都得经历三次握手建立 TCP 连接，如果连接只用来交换少量数据，这个过程就会严重降低 HTTP 性能。所以我们可以将多个小文件合成一个大文件，从而减少 HTTP 请求次数。</p><p>其实由于持久连接（重用 TCP 连接，以消除连接及关闭时延；HTTP/1.1 默认开启持久连接）的存在，每个新请求不一定都需要建立一个新的 TCP 连接。但是，浏览器处理完一个 HTTP 请求才能发起下一个，所以在 TCP 连接数没达到浏览器规定的上限时，还是会建立新的 TCP 连接。从这点来看，减少 HTTP 请求仍然是有必要的。</p><h3 id="2-cdn">2. 静态资源使用 CDN</h3><p>内容分发网络（CDN）是一组分布在多个不同地理位置的 Web 服务器。我们都知道，当服务器离用户越远时，延迟越高。CDN 就是为了解决这一问题，在多个位置部署服务器，让用户离服务器更近，从而缩短请求时间。</p><h3 id="3-">3. 善用缓存</h3><p>为了避免用户每次访问网站都得请求文件，我们可以通过添加 Expires 头来控制这一行为。Expires 设置了一个时间，只要在这个时间之前，浏览器都不会请求文件，而是直接使用缓存。</p><p>不过这样会产生一个问题，当文件更新了怎么办？怎么通知浏览器重新请求文件？</p><p>可以通过更新页面中引用的资源链接地址，让浏览器主动放弃缓存，加载新资源。</p><p>具体做法是把资源地址 URL 的修改与文件内容关联起来，也就是说，只有文件内容变化，才会导致相应 URL 的变更，从而实现文件级别的精确缓存控制。什么东西与文件内容相关呢？我们会很自然的联想到利用<a href="https://link.zhihu.com/?target=https%3A//baike.baidu.com/item/%25E6%25B6%2588%25E6%2581%25AF%25E6%2591%2598%25E8%25A6%2581%25E7%25AE%2597%25E6%25B3%2595/3286770%3Ffromtitle%3D%25E6%2591%2598%25E8%25A6%2581%25E7%25AE%2597%25E6%25B3%2595%26fromid%3D12011257" rel="nofollow noreferrer">数据摘要要算法</a>对文件求摘要信息，摘要信息与文件内容一一对应，就有了一种可以精确到单个文件粒度的缓存控制依据了。</p><p>参考资料：</p><ul><li><a href="https://www.zhihu.com/question/20790576/answer/32602154">张云龙--大公司里怎样开发和部署前端代码？</a></li></ul><h3 id="4-">4. 压缩文件</h3><p>压缩文件可以减少文件下载时间，让用户体验性更好。</p><p>gzip 是目前最流行和最有效的压缩方法。可以通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。当然，服务器也得支持这一功能。</p><p>举个例子，我用 Vue 开发的项目构建后生成的 app.js 文件大小为 1.4MB，使用 gzip 压缩后只有 573KB，体积减少了将近 60%。</p><h3 id="5-max-age-no-cache-">5. 通过 max-age 和 no-cache 实现文件精确缓存</h3><p>通用消息头部 <code>Cache-Control</code> 其中有两个选项：</p><ol><li><code>max-age</code>: 设置缓存存储的最大周期，超过这个时间缓存被认为过期(单位秒)。在这个时间前，浏览器读取文件不会发出新请求，而是直接使用缓存。</li><li><code>no-cache</code>: 指定 no-cache 表示客户端可以缓存资源，每次使用缓存资源前都必须重新验证其有效性。</li></ol><p>我们可以将那些长期不变的静态资源设置一个非常长的缓存时间，例如设置成缓存一年。</p><p>然后将 <code>index.html</code> 文件设置成 <code>no-cache</code>。这样每次访问网站时，浏览器都会询问 <code>index.html</code> 是否有更新，如果没有，就使用旧的 <code>index.html</code> 文件。如果有更新，就读取新的 <code>index.html</code> 文件。当加载新的 <code>index.html</code> 时，也会去加载里面新的 URL 资源。</p><p>例如 <code>index.html</code> 原来引用了 <code>a.js</code> 和 <code>b.js</code>，现在更新了变成 <code>a.js</code> 和 <code>c.js</code>。那就只会加载 <code>c.js</code> 文件。</p><p>具体请看 <a href="https://link.zhihu.com/?target=https%3A//github.com/woai3c/node-blog/blob/master/doc/node-blog7.md" rel="nofollow noreferrer">webpack + express 实现文件精确缓存</a>。</p><h2 id="https">HTTPS</h2><p>HTTPS 是最流行的 HTTP 安全形式，由网景公司首创，所有主要的浏览器和服务器都支持此协议。 使用 HTTPS 时，所有的 HTTP 请求和响应数据在发送之前，都要进行加密。加密可以使用 SSL 或 TLS。</p><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-bb65d4c1c08bffc3a1e10968e18911f7_720w.jpg" class="kg-image" alt="v2-bb65d4c1c08bffc3a1e10968e18911f7_720w" width="600" height="400" loading="lazy"></figure><p>SSL/TLS 协议作用在 HTTP 协议之下，对于上层应用来说，原来的发送/接收数据流程不变，这就很好地兼容了老的 HTTP 协议。由于 SSL/TLS 差别不大，下面统一使用 SSL。</p><p>要想了解 HTTPS 为何安全，还得继续了解一下这些概念：<strong>加密算法</strong>、<strong>摘要算法</strong>、<strong>数字签名</strong>和<strong>数字证书</strong>。</p><h3 id="--8">加密算法</h3><h3 id="--9">对称密钥密码体制</h3><p>对称密钥密码体制，即加密密钥和解密密钥是使用相同的密码体制。对称密钥加密技术的缺点之一就是发送者和接收者在对话之前，一定要有一个共享的密钥，所以不太安全。</p><h3 id="--10">公钥密码体制</h3><p>公钥密码体制使用不同的加密密钥与解密密钥。公钥密码体制产生的主要原因有两个：一是对称密钥密码体制的密钥分配问题，二是对数字签名的需求。</p><p>在公钥密码体制中，加密密钥是公开的，解密密钥是需要保密的，加密算法和解密算法也是公开的。</p><p>公钥密码体制的加密和解密有如下特点：</p><ol><li><strong>密钥对产生器</strong>产生出接收者 B 的一对密钥，即加密密钥 PK 和解密密钥 SK。</li><li>发送者 A 用 B 的公钥 PK 作为加密密钥来加密信息，B 接收后用解密密钥 SK 解密。</li></ol><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-5ad7018640fb80e50e92d20484e81f86_720w.jpg" class="kg-image" alt="v2-5ad7018640fb80e50e92d20484e81f86_720w" width="600" height="400" loading="lazy"></figure><p>使用对称密钥时，由于双方使用同样的密钥，因此在通信信道上可以进行一对一的双向保密通信，双方都可以用同一个密钥加密解密。</p><p>使用公开密钥时，在通信信道上可以是多对一的单向保密信道。即可以有多人持有 B 的公钥，但只有 B 才能解密。</p><h3 id="--11">摘要算法</h3><p>摘要算法的主要特征是加密过程不需要密钥，并且经过加密的数据无法被解密，目前可以被解密逆向的只有CRC32算法，只有输入相同的明文数据经过相同的消息摘要算法才能得到相同的密文。</p><h3 id="--12">数字签名</h3><p>用加密系统对报文进行签名，以说明是谁编写的报文，同时证明报文未被篡改过，这种技术称为<strong>数字签名</strong>。</p><p>数字签名是附加在报文上的特殊加密校验码。使用数字签名的好处有：</p><ol><li>签名可以证明是作者编写了这条报文。只有作者才会有最机密的私有密钥，因此，只有作者才能计算出这些校验和。</li><li>签名可以防止报文被篡改，如果有人在报文传输过程中对其进行了修改，校验和就不再匹配了。</li></ol><p>数字签名通常是用非对称公开密钥技术产生的。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-dbe067a589d86e584a226ddd31c49f80_720w.jpg" class="kg-image" alt="v2-dbe067a589d86e584a226ddd31c49f80_720w" width="600" height="400" loading="lazy"></figure><p>看上图，任何人都能用 A 的公钥 PK 对密文进行 E 运算后得到 A 发送的明文。可见这种通信并非为了保密，而是为了进行签名和核实签名，即确认此信息是 A 发送的（使用 A 的密钥进行加密的报文，只有使用 A 的公钥才能正确解密）。 但上述过程仅对报文进行了签名，对报文 X 本身却未保密，所以要采用下图的方法，同时实现秘密通信和数字签名。</p><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-113d720a3ca11d8d18ead0a54ac00e43_720w.jpg" class="kg-image" alt="v2-113d720a3ca11d8d18ead0a54ac00e43_720w" width="600" height="400" loading="lazy"></figure><h3 id="--13">数字证书</h3><p>假如你想访问一个网站，怎么确保对方给你的公钥是你想访问的网站的公钥，而不是被中间人篡改过的？</p><p>数字证书的出现就是为了解决这个问题，它是由数字证书认证机构颁发的，用来证明公钥拥有者的身份。换句话说，数字证书的作用就相当于人的身份证，身份证证明了张三就是张三，而不是别人。</p><p><strong>数字证书一般包含以下内容</strong>：</p><ol><li>对象的名称（人、服务器、组织等）；</li><li>过期时间；</li><li>证书发布者（由谁为证书担保）；</li><li>来自证书发布者的数字签名；</li><li>对象的公钥；</li><li>对象和所用签名算法的描述性信息。</li></ol><p>任何人都可以创建一个数字证书，但由谁来担保才是重点。</p><p><strong>数字证书的数字签名计算过程</strong>：</p><ol><li>用摘要算法对数字证书的内容计算出摘要；</li><li>用数字证书的私钥对摘要进行加密得到数字签名。</li></ol><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-17ec75ef0f9ff1892eb6aada2c3d44e9_720w.jpg" class="kg-image" alt="v2-17ec75ef0f9ff1892eb6aada2c3d44e9_720w" width="600" height="400" loading="lazy"></figure><p>当浏览器收到证书时，会对签名颁发机构进行验证，如果颁发机构是个很有权威的公共签名机构，浏览器可能就知道其公开密钥了（浏览器会预装很多签名颁发机构的证书）。如果对签名颁发机构一无所知，浏览器通常会向用户显示一个对话框，看看他是否相信这个签名发布者。</p><p>因为数字证书的公钥是公开的，任何人都可以用公钥解密出数字证书的数字签名的摘要，然后再用同样的摘要算法对证书内容进行摘要计算，将得出的摘要和解密后的摘要作对比，如果内容一致则说明这个证书没有被篡改过，可以信任。</p><p>这个过程是建立在被大家所认可的证书机构之上得到的公钥，所以这是一种安全的方式。</p><figure class="kg-card kg-image-card"><img src="https://pic1.zhimg.com/80/v2-be99bd75b57e167a6dd28534c6070574_720w.jpg" class="kg-image" alt="v2-be99bd75b57e167a6dd28534c6070574_720w" width="600" height="400" loading="lazy"></figure><h3 id="https-">HTTPS 连接建立过程</h3><p>HTTPS 连接建立过程和 HTTP 差不多，区别在于 HTTP（默认端口 80） 请求只要在 TCP 连接建立后就可以发起，而 HTTPS（默认端口 443） 在 TCP 连接建立后，还需要经历 SSL 协议握手，成功后才能发起请求。</p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-ee3edd71c7aa0e6434f271aa71852335_720w.jpg" class="kg-image" alt="v2-ee3edd71c7aa0e6434f271aa71852335_720w" width="600" height="400" loading="lazy"></figure><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-0adc50b67e0be922b2a9d6de861f1c95_720w.jpg" class="kg-image" alt="v2-0adc50b67e0be922b2a9d6de861f1c95_720w" width="600" height="400" loading="lazy"></figure><p>我知道肯定会有人不满足于简化版的 SSL 握手过程，所以我找了一篇文章<a href="https://link.zhihu.com/?target=https%3A//www.jianshu.com/p/7158568e4867" rel="nofollow noreferrer">SSL/TLS 握手过程详解</a>，这篇文章非常详细的讲解了 SSL 握手的每一步骤。建议有兴趣的同学看一看。</p><h2 id="http-2">HTTP/2</h2><p>HTTP/2 是 HTTP/1.x 的扩展，而非替代。所以 HTTP 的语义不变，提供的功能不变，HTTP 方法、状态码、URL 和首部字段等这些核心概念也不变。</p><p>之所以要递增一个大版本到 2.0，主要是因为它改变了客户端与服务器之间交换数据的方式。HTTP 2.0 增加了新的二进制分帧数据层，而这一层并不兼容之前的 HTTP 1.x 服务器及客户端——是谓 2.0。</p><h3 id="http-2-">HTTP/2 连接建立过程</h3><p>现在的主流浏览器 HTTP/2 的实现都是基于 SSL/TLS 的，也就是说使用 HTTP/2 的网站都是 HTTPS 协议的，所以本文只讨论基于 SSL/TLS 的 HTTP/2 连接建立过程。</p><p>基于 SSL/TLS 的 HTTP/2 连接建立过程和 HTTPS 差不多。在 SSL/TLS 握手协商过程中，客户端在 ClientHello 消息中设置 ALPN（应用层协议协商）扩展来表明期望使用 HTTP/2 协议，服务器用同样的方式回复。通过这种方式，HTTP/2 在 SSL/TLS 握手协商过程中就建立起来了。</p><h3 id="http-1-1-">HTTP/1.1 的问题</h3><h3 id="1-">1. 队头阻塞</h3><p>在 HTTP 请求应答过程中，如果出现了某种情况，导致响应一直未能完成，那后面所有的请求就会一直阻塞着，这种情况叫队头阻塞。</p><h3 id="2-tcp-">2. 低效的 TCP 利用</h3><p>由于 <a href="https://link.zhihu.com/?target=https%3A//baike.baidu.com/item/%25E6%2585%25A2%25E5%2590%25AF%25E5%258A%25A8/8242395" rel="nofollow noreferrer">TCP 慢启动机制</a>，导致每个 TCP 连接在一开始的时候传输速率都不高，在处理多个请求后，才会慢慢达到“合适”的速率。对于请求数据量很小的 HTTP 请求来说，这种情况就是种灾难。</p><h3 id="3--1">3. 臃肿的消息首部</h3><p>HTTP/1.1 的首部无法压缩，再加上 cookie 的存在，经常会出现首部大小比请求数据大小还大的情况。</p><h3 id="4--1">4. 受限的优先级设置</h3><p>HTTP/1.1 无法为重要的资源指定优先级，每个 HTTP 请求都是一视同仁。</p><p>在继续讨论 HTTP/2 的新功能之前，先把 HTTP/1.1 的问题列出来是有意义的。因为 HTTP/2 的某些新功能就是为了解决上述某些问题而产生的。</p><h3 id="--14">二进制分帧层</h3><p>HTTP/2 是基于帧的协议。采用分帧是为了将重要信息封装起来，让协议的解析方可以轻松阅读、解析并还原信息。</p><p>而 HTTP/1.1 是以文本分隔的。解析 HTTP/1.1 不需要什么高科技，但往往速度慢且容易出错。你需要不断地读入字节，直到遇到分隔符 CRLF 为止，同时还要考虑不守规矩的客户端，它只会发送 LF。</p><p>解析 HTTP/1.1 的请求或响应还会遇到以下问题：</p><ol><li>一次只能处理一个请求或响应，完成之前不能停止解析。</li><li>无法预判解析需要多少内存。</li></ol><p>HTTP/2 有了帧，处理协议的程序就能预先知道会收到什么，并且 HTTP/2 有表示帧长度的字段。</p><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-b5e21a1d51a86e69835cf4fc2b0ead6d_720w.jpg" class="kg-image" alt="v2-b5e21a1d51a86e69835cf4fc2b0ead6d_720w" width="600" height="400" loading="lazy"></figure><h3 id="--15">帧结构</h3><pre><code class="language-text">+-----------------------------------------------+
 |                 Length (24)                   |
 +---------------+---------------+---------------+
 |   Type (8)    |   Flags (8)   |
 +-+-------------+---------------+-------------------------------+
 |R|                 Stream Identifier (31)                      |
 +=+=============================================================+
 |                   Frame Payload (0...)                      ...
 +---------------------------------------------------------------+</code></pre><figure class="kg-card kg-image-card"><img src="https://pic4.zhimg.com/80/v2-7f7d60db1b3a2d3ff25b613a4cbeaa97_720w.jpg" class="kg-image" alt="v2-7f7d60db1b3a2d3ff25b613a4cbeaa97_720w" width="600" height="400" loading="lazy"></figure><p>由于 HTTP/2 是分帧的，请求和响应都可以多路复用，有助于解决类似类似队头阻塞的问题。</p><h3 id="--16">帧类型</h3><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-de35fa232ac8c4b181ede6f7af13925d_720w.jpg" class="kg-image" alt="v2-de35fa232ac8c4b181ede6f7af13925d_720w" width="600" height="400" loading="lazy"></figure><h3 id="--17">多路复用</h3><p>在 HTTP/1.1 中，如果客户端想发送多个并行的请求，那么必须使用多个 TCP 连接。</p><p>而 HTTP/2 的二进制分帧层突破了这一限制，所有的请求和响应都在同一个 TCP 连接上发送：客户端和服务器把 HTTP 消息分解成多个帧，然后乱序发送，最后在另一端再根据流 ID 重新组合起来。</p><p>这个机制为 HTTP 带来了巨大的性能提升，因为：</p><ul><li>可以并行交错地发送请求，请求之间互不影响；</li><li>可以并行交错地发送响应，响应之间互不干扰；</li><li>只使用一个连接即可并行发送多个请求和响应；</li><li>消除不必要的延迟，从而减少页面加载的时间；</li><li>不必再为绕过 HTTP 1.x 限制而多做很多工作；</li></ul><figure class="kg-card kg-image-card"><img src="https://pic2.zhimg.com/80/v2-369330c2d4712cb12cdb7124ec691931_720w.jpg" class="kg-image" alt="v2-369330c2d4712cb12cdb7124ec691931_720w" width="600" height="400" loading="lazy"></figure><h3 id="--18">流</h3><p>HTTP/2 规范对流的定义是：HTTP/2 连接上独立的、双向的帧序列交换。如果客户端想要发出请求，它会开启一个新流，然后服务器在这个流上回复。 由于有分帧，所以多个请求和响应可以交错，而不会互相阻塞。流 ID 用来标识帧所属的流。</p><p>客户端到服务器的 HTTP/2 连接建立后，通过发送 HEADERS 帧来启动新的流。如果首部需要跨多个帧，可能还会发送 CONTINUATION 帧。该 HEADERS 帧可能来自请求或响应。 后续流启动的时候，会发送一个带有递增流 ID 的新 HEADERS 帧。</p><h3 id="--19">消息</h3><p>HTTP 消息泛指 HTTP 请求或响应，消息由一或多个帧组成，这些帧可以乱序发送，然后再根据每个帧首部的流 ID 重新组装。</p><p>一个消息至少由 HEADERS 帧（它初始化流）组成，并且可以另外包含 CONTINUATION 和 DATA 帧，以及其他的 HEADERS 帧。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-b42c13b2eb2f9e68fa6c525b0c2ec1ba_720w.jpg" class="kg-image" alt="v2-b42c13b2eb2f9e68fa6c525b0c2ec1ba_720w" width="600" height="400" loading="lazy"></figure><p>HTTP/1.1 的请求和响应部分都分成消息首部和消息体两部分；HTTP/2 的请求和响应分成 HEADERS 帧和 DATA 帧。</p><h3 id="--20">优先级</h3><p>把 HTTP 消息分解为很多独立的帧之后，就可以通过优化这些帧的交错和传输顺序，进一步提升性能。</p><p>通过 HEADERS 帧和 PRIORITY 帧，客户端可以明确地和服务器沟通它需要什么，以及它需要这些资源的顺序。具体来讲，服务器可以根据流的优先级，控制资源分配（CPU、内存、带宽），而在响应数据准备好之后，优先将最高优先级的帧发送给客户端。</p><h3 id="--21">流量控制</h3><p>在同一个 TCP 连接上传输多个数据流，就意味着要共享带宽。标定数据流的优先级有助于按序交付，但只有优先级还不足以确定多个数据流或多个连接间的资源分配。</p><p>为解决这个问题，HTTP/2 为数据流和连接的流量控制提供了一个简单的机制：</p><ul><li>流量控制基于每一跳进行，而非端到端的控制；</li><li>流量控制基于 WINDOW_UPDATE 帧进行，即接收方广播自己准备接收某个数据流的多少字节，以及对整个连接要接收多少字节；</li><li>流量控制窗口大小通过 WINDOW_UPDATE 帧更新，这个字段指定了流 ID 和窗口大小递增值；</li><li>流量控制有方向性，即接收方可能根据自己的情况为每个流乃至整个连接设置任意窗口大小；</li><li>流量控制可以由接收方禁用，包括针对个别的流和针对整个连接。</li></ul><p>HTTP/2 连接建立之后，客户端与服务器交换 SETTINGS 帧，目的是设置双向的流量控制窗口大小。除此之外，任何一端都可以选择禁用个别流或整个连接的流量控制。</p><h3 id="--22">服务器推送</h3><p>HTTP/2 新增的一个强大的新功能，就是服务器可以对一个客户端请求发送多个响应。换句话说，除了对最初请求的响应外，服务器还可以额外向客户端推送资源，而无需客户端明确地请求。</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-d4e4f7bbaa1d8d2707951dfa993291e2_720w.jpg" class="kg-image" alt="v2-d4e4f7bbaa1d8d2707951dfa993291e2_720w" width="600" height="400" loading="lazy"></figure><p>为什么需要这样一个机制呢？通常的 Web 应用都由几十个资源组成，客户端需要分析服务器提供的文档才能逐个找到它们。那为什么不让服务器提前就把这些资源推送给客户端，从而减少额外的时间延迟呢？服务器已经知道客户端下一步要请求什么资源了，这时候服务器推送即可派上用场。</p><p>另外，客户端也可以拒绝服务器的推送。</p><h3 id="--23">首部压缩</h3><p>HTTP/1.1 存在的一个问题就是臃肿的首部，HTTP/2 对这一问题进行了改进，可以对首部进行压缩。 在一个 Web 页面中，一般都会包含大量的请求，而其中有很多请求的首部往往有很多重复的部分。</p><p>例如有如下两个请求：</p><pre><code class="language-text">:authority: unpkg.zhimg.com
:method: GET
:path: /za-js-sdk@2.16.0/dist/zap.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36</code></pre><pre><code class="language-http">:authority: zz.bdstatic.com
:method: GET
:path: /linksubmit/push.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36</code></pre><p>从上面两个请求可以看出来，有很多数据都是重复的。如果可以把相同的首部存储起来，仅发送它们之间不同的部分，就可以节省不少的流量，加快请求的时间。</p><p>HTTP/2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键－值对，对于相同的数据，不再通过每次请求和响应发送。</p><p>下面再来看一个简化的例子，假设客户端按顺序发送如下请求首部：</p><pre><code class="language-text">Header1:foo
Header2:bar
Header3:bat</code></pre><p>当客户端发送请求时，它会根据首部值创建一张表：</p><figure class="kg-card kg-image-card"><img src="https://pic3.zhimg.com/80/v2-50b11e7a83b07c281fcfb483c576f542_720w.jpg" class="kg-image" alt="v2-50b11e7a83b07c281fcfb483c576f542_720w" width="600" height="400" loading="lazy"></figure><p>如果服务器收到了请求，它会照样创建一张表。 当客户端发送下一个请求的时候，如果首部相同，它可以直接发送这样的首部块：</p><pre><code class="language-text">62 63 64</code></pre><p>服务器会查找先前建立的表格，并把这些数字还原成索引对应的完整首部。</p><h3 id="--24">性能优化</h3><p>使用 HTTP/2 代替 HTTP/1.1，本身就是一种巨大的性能提升。 这小节要聊的是在 HTTP/1.1 中的某些优化手段，在 HTTP/2 中是不必要的，可以取消的。</p><h3 id="--25">取消合并资源</h3><p>在 HTTP/1.1 中要把多个小资源合并成一个大资源，从而减少请求。而在 HTTP/2 就不需要了，因为 HTTP/2 所有的请求都可以在一个 TCP 连接发送。</p><h3 id="--26">取消域名拆分</h3><p>取消域名拆分的理由同上，再多的 HTTP 请求都可以在一个 TCP 连接上发送，所以不需要采取多个域名来突破浏览器 TCP 连接数限制这一规则了。</p><h2 id="--27">参考资料</h2><ul><li><a href="https://link.zhihu.com/?target=https%3A//book.douban.com/subject/10746113/" rel="nofollow noreferrer">HTTP权威指南</a></li><li><a href="https://link.zhihu.com/?target=https%3A//book.douban.com/subject/27665112/" rel="nofollow noreferrer">HTTP/2基础教程</a></li><li><a href="https://link.zhihu.com/?target=https%3A//www.jianshu.com/p/7158568e4867" rel="nofollow noreferrer">SSL/TLS 握手过程详解</a></li><li><a href="https://link.zhihu.com/?target=https%3A//www.jianshu.com/p/ffe8c203a471" rel="nofollow noreferrer">互联网安全之数字签名、数字证书与PKI系统</a></li><li><a href="https://link.zhihu.com/?target=https%3A//book.douban.com/subject/26960678/" rel="nofollow noreferrer">计算机网络（第7版）</a></li><li><a href="https://link.zhihu.com/?target=https%3A//book.douban.com/subject/25856314/" rel="nofollow noreferrer">Web性能权威指南</a></li></ul><h3 id="--28"><a href="https://www.zhihu.com/people/tan-guang-zhi-19/posts">更多文章，敬请关注</a></h3> ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
