普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月18日技术

运行时vs编译时:CSS in JS四种主流方案介绍和对比

作者 漂流瓶jz
2026年4月18日 18:44

CSS作为前端代码中的重要组成部分,在工程中一般是以独立CSS文件的形式存在的。而CSS in JS,顾名思义,是在JavaScript中写CSS代码。尤其是React框架的流行,JavaScript和HTML模板都在JavaScript文件中描述了,只有CSS代码的组织还比较疏离。因此出现了很多CSS in JS的开源库,帮助我们将CSS放到JavaScript代码中,实现React组件代码的耦合性。

React工程示例

首先我们先展示一下React工程中是如何使用CSS的,以及React本身有没有CSS in JS的能力。

不使用CSS in JS

首先使用Vite创建React工程,执行命令行:

# 选择React
npm create vite@latest
# 安装依赖
npm install
# 启动开发服务
npm run dev

然后将src/App.jsx文件修改为如下内容,这是一个React组件。

import "./App.css";
export default function App() {
  return <div className="class1">你好 jzplp</div>;
}

然后是对应的src/App.css内容:

.class1 {
  color: red;
  font-size: 15px;
}

打开浏览器,可以看到对应的组件样式是生效的。这就是普通React工程中CSS的使用方式,在独立文件中被引用。像CSS Modules, Less和SCSS等也都是类似这种使用模式。那如果CSS样式本身需要根据不同的场景变化呢?可以根据不同的场景提供不同的类名。这里修改下App.jsx作为示例,就不展示App.css文件了。

import "./App.css";
export default function App({ state }) {
  return <div className={state === 1 ? "class1" : "class2"}>你好 jzplp</div>;
}

内联样式

使用类名控制样式变化可以生效,但这算是间接控制样式。有没有直接可以在JavaScript代码中控制CSS的方式呢?有的,React提供了内联样式,可以让我们直接控制:

import "./App.css";
export default function App({ state }) {
  return (
    <div
      style={{
        color: state === 1 ? "red" : "blue",
        fontSize: "14px",
      }}
    >
      你好 jzplp
    </div>
  );
}

对style属性设置为对象,对象的key是CSS属性驼峰形式,可以实现对HTML中内联样式的直接控制。看起来这样挺好用的,但是它的限制还是非常大。例如不能使用伪类或者媒体查询这种CSS规则。

直接操作DOM可以使用CSS规则,但这不优雅也失去了使用React的优势。因此,如果想要实现真正的CSS in JS,还是要看专门的工具。

styled-components初步

styled-components是最知名的CSS in JS工具,可以在React中使用。

接入方式

首先安装依赖styled-components,然后删除App.css,我们不再需要独立的CSS文件了。修改App.tsx:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <Button>你好 jzplp</Button>
    </div>
  );
}

此时在浏览器上可以看到生效的结果。通过代码可以看到,styled-components是利用了‌ECMAScript中模板字符串的“标签模板字符串”特性。因此我们提供的CSS字符串可以被styled-components对应的函数解析,最终生成样式。

实现方式

上面的代码是如何生效的呢?这里我们修改一下代码,增加不同状态:

import styled from "styled-components";
import { useState } from "react";

const Div0 = styled.div``;
const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div0>1你好 jzplp</Div0>
      <Div>2你好 jzplp</Div>
      {state % 2 === 1 && <Button>3你好 jzplp</Button>}
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

css-in-js-1.png

首先看下初始状态的效果。这里有Div0和Div两个组件,都是用styled-components生成的,因此都有一个sc-开头的类名,但这个类名上并不包含样式。Div因为有了样式,所以有另一个类名,这个类名的具体样式写在head中的style标签里面。Button组件因为没有展示,所以对应的样式也没有注入。我们再切换状态试试:

css-in-js-2.png

切换状态之后,Button组件展示并附带着样式。注意style标签中增加了一行,正是Button的样式。此时如果再次切换状态将Button组件销毁,style标签中对应的样式并不会删除。

通过对于浏览器现象的观察,我们发现了styled-components的实现方式:当组件被渲染时,将JavaScript中的CSS属性集合放到style标签中,同时动态提供hash类名。将类名提供给HTML标签作为属性渲染。这样就实现了JavaScript控制CSS代码,且在组件被渲染时才注入CSS。此时我们执行如下命令:

npm run build
npm run preview

然后查看打包后生产模式的效果,发现style标签中并没有CSS代码了,但样式还是生效的,也是通过hash类名。且在浏览器调试工具上点击类的来源,也能点到那个style标签,只不过浏览器上看不到其中的内容。

传参方式

前面的例子中CSS代码全是字符串,但使用模板字符串除了能换行之外,好像也没有什么优势。使用标签模板字符串的优势在于传参。组件可以根据不同的传参切换样式:

import styled from "styled-components";

interface DivProps {
  bgColor: string;
  lineHeight?: number;
}

const Div = styled.div<DivProps>`
  color: red;
  background: ${props => props.bgColor};
  font-size: 14px;
  line-height: ${props => props.lineHeight || 20}px;
`;

export default function App() {
  return (
    <div>
      <Div bgColor="blue" lineHeight={30}>2你好 jzplp</Div>
      <Div bgColor="yellow">2你好 jzplp</Div>
    </div>
  );
}

可以看到,首先我们在styled对应标签的函数中增加了props入参的泛型TypeScript类型。然后在模板字符串的插值中传入函数,函数的入参为props,返回对应场景下的CSS代码值。由于我们使用的“标签模板字符串”功能,因此标签函数可以读取并处理模板字符串中的插值,最后整合成完整的CSS代码。我们看下浏览器效果:

css-in-js-3.png

可以看到,不同的入参会生成不同的类名。这里我有一个疑问,如果我们的入参一直在变化,会不会一直生成类名?我们试一下:

import styled from "styled-components";
import { useState } from "react";

interface DivProps {
  color: number;
}

const Div = styled.div<DivProps>`
  color: #${(props) => props.color};
`;

export default function App() {
  const [state, setState] = useState(0);
  return (
    <div>
      <Div color={state}>你好 {state}</Div>
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

在上面代码中,点击一下state值加1,同时Div中的入参也会变化,生成的CSS值也会不一样。我们多点几次看看效果:

css-in-js-4.png

通过浏览器效果可以看到,我们每点击一次,就会生成一个新的类名和CSS规则。旧的CSS规则虽然永远不会被使用到了,但依然保存在浏览器中。不过代码其实不知道我们的CSS规则今后会不会被使用到。

styled-components特性

前面我们引入了styled-components,简单介绍了实现方式和传参。这里再介绍一下更多特性。

组件继承

类似于面向对象,使用styled-components生成的组件也有继承特性,子组件可以继承父组件的样式。我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
`;
const DivChild = styled(Div)`
  background: yellow;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <DivChild>你好 jzplp</DivChild>
      <Div as="p">你好 jzplp</Div>
    </div>
  );
}

我们定义了Div父组件,DivChild继承并提供了自己的样式,通过结果可以看到,两个样式都生效了。第三个组件我们使用了as属性,它可以在使用预定义styled组件的同时,修改标签名。

css-in-js-5.png

父组件的props参数,子组件也是继承的,同时子组件也可以有自己的参数。我们看下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size:${(props) => props.size}px;
`;
const DivChild = styled(Div)`
  background: yellow;
  line-height: ${(props) => props.size + 10}px;
  border: ${(props) => props.borderSize}px solid blue;
`;

export default function App() {
  return (
    <div>
      <Div size={20}>你好 jzplp</Div>
      <DivChild size={30} borderSize={2}>你好 jzplp</DivChild>
    </div>
  );
}

css-in-js-6.png

通过结果可以看到,组件的参数和子组件都有自己的参数,子组件也可以使用父组件的参数,可以同时生效。

与React组件继承

前面我们看到的是styled组件互相的继承关系。事实上,它与普通React组件也可以互相继承。首先是普通React组件作为父组件:

import styled from "styled-components";

function Comp({ state, className }) {
  return <div className={className}>{state}</div>;
}

const DivComp = styled(Comp)`
  color: ${(props) => props.color};
  font-size: 20px;
`;

export default function App() {
  return (
    <div>
      <Comp state={1} />
      <DivComp state={2} color='red' />
    </div>
  );
}

css-in-js-7.png

可以看到,作为父组件的Comp组件,需要提供className参数,并在组件内部恰当位置作为属性。这样子组件和父组件的props都可以正常使用,styled-components会将接收到的属性透传给父组件。然后我们再看下普通React组件作为子组件的场景:

import styled from "styled-components";

const Div = styled.div`
  color: ${(props) => props.color};
  font-size: 20px;
`;

function Comp(props = {}) {
  return <Div {...props}>{props.state}</Div>;
}

export default function App() {
  return (
    <div>
      <Div color="red">1</Div>
      <Comp state={2} color="yellow" />
    </div>
  );
}

css-in-js-8.png

当普通React组件作为子组件时,我们需要手动处理透传需要的prop到子组件中。

嵌套选择器

前面使用React内联样式的时候,我们提到内联样式并不支持嵌套选择器,这其实是直接用React做CSS in JS的最大阻碍。 styled-components引入了stylis工具来处理,支持使用嵌套选择器。这里举下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  &:hover {
    background: yellow;
  }
  &.class1 {
    border: 2px solid blue;
  }
  .class2 & {
    border: 2px solid green;
  }
`;

export default function App() {
  return (
    <div>
      <Div>1 jzplp</Div>
      <Div>2 jzplp</Div>
      <Div className="class1">3 jzplp</Div>
      <div className="class2">
        <Div>4 jzplp</Div>
      </div>
    </div>
  );
}

css-in-js-9.png

通过例子可以看到,无论是伪类,还是各种选择器都可以,其中使用&标识本标签的选择器。也能生效到子元素中,即使这个元素非styled组件:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  .class1 {
    border: 2px solid blue;
  }
`;

export default function App() {
  return (
    <div>
      <Div>
        <div className="class1">3 jzplp</div>
      </Div>
    </div>
  );
}

css-in-js-10.png

CSS片段

styled-components支持创建一个CSS片段,可以提供给组件使用,并且可以带参数,使用css方法即可。

import styled, { css } from "styled-components";

const jzCss = css`
  color: blue;
  font-size: ${props => props.size}px;
`

const Div = styled.div`
${
  props => {
    if(props.type === 1) return jzCss;
    else return `
      color: red;
      background: green;
    `;
  }
}
`;

export default function App() {
  return (
    <div>
      <Div type={1} size={20}>jzplp 1</Div>
      <Div type={1} size={30}>jzplp 2</Div>
      <Div type={2}>jzplp 3</Div>
    </div>
  );
}

css-in-js-11.png

可以看到代码中先创建了一个带参数的CSS片段,然后在组件字符串插值的返回中传入这个片段,参数可以正常生效渲染。else情况则直接返回了字符串CSS属性,看浏览器上也可以生效。那是不是说不需要css函数处理,直接返回模板字符串就可以了?这是肯定不行的,我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  ${() => {
    return `
        color: blue;
        font-size: ${(props) => props.size}px;
      `;
  }}
`;

export default function App() {
  return (
    <div>
      <Div size={20}>jzplp 1</Div>
    </div>
  );
}

css-in-js-12.png

在浏览器中可以看到,模板字符串中的插值并没有被成功处理,而是被直接转为了普通字符串,最后成了错误的CSS代码。因此必须使用css函数创建CSS片段。

CSS动画

styled-components也支持创建@keyframes的CSS动画,同时在CSS中被引用。

import styled, { keyframes } from "styled-components";

const colorChange = keyframes`
  0% {
    color: red;
  }
  50% {
    color: blue;
  }
  100% {
    color: red;
  }
`;

const Div = styled.div`
  animation: ${colorChange} 2s infinite;
`;

export default function App() {
  return (
    <div>
      <Div>你好,jzplp</Div>
    </div>
  );
}

可以看到我们使用keyframes方法创建了一个keyframes动画,在需要的CSS位置中,当作animation-name属性插入动画即可。效果如下:

css-in-js-13.gif

这里只是简单介绍了styled-components的部分特性,如果希望深入了解请看styled-components相关文档。

@emotion/styled

下面来介绍一下Emotion这个库。这个库有好几种使用方式,首先我们从类似styled-components,即styled组件的使用方式开始介绍,主要使用@emotion/styled这个包。

接入方式

首先安装@emotion/styled依赖,然后修改src/App.jsx:

import styled from "@emotion/styled";

const Div = styled.div`
  color: red;
  background: ${(props) => props.bg};
`;

export default function App() {
  return (
    <div>
      <Div bg="blue">你好,jzplp</Div>
      <Div bg="yellow">你好,jzplp</Div>
    </div>
  );
}

上面代码的使用方式与styled-components一模一样,换个包名也能生效。效果也一样,区别在于开发模式下@emotion/styled有两个style标签,如下图。生产模式与styled-components一样,都是一个style标签且看不到内容。

css-in-js-14.png

CSS片段

不仅接入方式,@emotion/styled的大部分特性都和styled-components一样,包括继承,嵌套选择器等。但CSS片段有些不一样:

import styled from "@emotion/styled";
import { css } from '@emotion/react';

// 错误方式
const commonStyle1 = css`
  font-size: 20px;
  background: ${(props) => props.bg};
`;

const commonStyle2 = (props) => css`
  font-size: 20px;
  background: ${props.bg};
`;

const Div1 = styled.div`
  color: red;
  ${commonStyle1}
`;

const Div2 = styled.div`
  color: green;
  ${commonStyle2}
`;

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div2 bg="yellow">你好,jzplp2</Div2>
    </div>
  );
}

css-in-js-15.png

首先CSS片段的函数是在另一个包@emotion/react中引入的。commonStyle1是用styled-components模式引入的,将函数直接放到CSS片段中,是不生效的。必须要将片段本身放到一个大函数内部才行,如commonStyle2的形式。

函数入参

@emotion/styled中的组件也支持函数作为入参,而非模板字符串。函数可以返回style对象,也能返回拼好的字符串。

import styled from "@emotion/styled";

const Div1 = styled.div((props) => {
  return {
    color: "red",
    background: props.bg,
  };
});

const Div2 = styled.div((props) => {
  return `
    color: pink;
    background: ${props.bg};
  `;
});

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div1 bg="green">你好,jzplp2</Div1>
      <Div2 bg="yellow">你好,jzplp3</Div2>
      <Div2 bg="green">你好,jzplp4</Div2>
    </div>
  );
}

css-in-js-16.png

@emotion/react

接入css属性

Emotion除了styled组件使用方式之外,也可以使用组件css属性(css Prop)的使用方式。这种方式是在React中JSX的组件上增加一个css属性。因此这种方式需要修改React编译相关参数。这里以vite@8和@vitejs/plugin-react@6为例来说明。首先安装依赖@emotion/react。然后修改vite.config.js配置文件:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
      // 指定转换jsx语法的模块
      jsxImportSource: '@emotion/react',
    })],
})

然后就可以使用了。如果需要TypeScript类型正确提示,则需要修改tsconfig.json:

{
  "compilerOptions": {
    // types在原有基础上新增"@emotion/react/types/css-prop"
    "types": ["...", "@emotion/react/types/css-prop"],
    "jsxImportSource": "@emotion/react",
  },
}

然后我们修改App.jsx,内容如下:

export default function App() {
  return (
    <>
      <div
        css={{
          color: "red",
          "&:hover": {
            background: "green",
          },
        }}
      >
        你好 jzplp
      </div>
    </>
  );
}

可以看到,我们直接以对象的形式对组件JSX的css属性赋值,而且还包含hover伪类,最后在开发模式和生产模式都可以正常生效。

css-in-js-17.png

除了对象形式之外,css属性还支持CSS片段的形式:

import {css} from "@emotion/react";
export default function App() {
  return (
    <>
      <div
        css={css`
          color: red;
          &:hover {
            background: green;
          }
        `}
      >
        你好 jzplp
      </div>
    </>
  );
}

css属性继承

从前面的接入例子中我们看到,css属性还是通过class名的形式生效的。因此它的优先级低于style属性,即内联样式。如果父组件提供了css属性想让子组件生效,则需要传入className参数。那么如果父子组件都有css属性,他们的优先级如何呢?这里举个例子:

function Comp1({ className }) {
  return (
    <div
      css={{
        background: 'yellow',
        color: "red",
      }}
      className={className}
    >
      你好 jzplp
    </div>
  );
}

function Comp2() {
  return (
    <Comp1
      css={{
        fontSize: "20px",
        color: "blue",
      }}
    >
      你好 jzplp
    </Comp1>
  );
}

export default function App() {
  return (
    <>
      <Comp1 />
      <Comp2 />
    </>
  );
}

这里创建了两个组件,Comp1是子组件,Comp2是父组件。子组件和父组件中的CSS属性不冲突的可以同时生效,冲突属性以父组件的为准,例如这里的color。

css-in-js-18.png

样式对象

前面我们演示过,不管是styled组件,css属性还是CSS片段,都可以接收对象类型的样式数据。关于对象类型还有其它特性,这里我们一起描述一下。首先是数组类型,这里列举了两个例子:

import styled from "@emotion/styled";

const styleList = [
  {
    color: "red",
  },
  {
    fontSize: "20px",
  },
];

const Comp1 = styled.div(styleList);

function Comp2() {
  return <div css={styleList}>你好 jzplp2</div>;
}

export default function App() {
  return (
    <>
      <Comp1>你好 jzplp1</Comp1>
      <Comp2 />
    </>
  );
}

styled组件也同时支持多个入参:

import styled from "@emotion/styled";

const Comp1 = styled.div(
  {
    color: "red",
  },
  (props) => {
    return {
      fontSize: `${props.size}px`,
    };
  },
);

export default function App() {
  return <Comp1 size={20}>你好 jzplp1</Comp1>;
}

样式对象还支持回退值(默认值)。通过对属性值传入一个数组,最后面的值优先级最高,如果不存在则取前面的值:

function Div1({ color, children }) {
  return (
    <div
      css={{
        color: ["red", color],
      }}
    >{children}</div>
  );
}

export default function App() {
  return (
    <>
      <Div1>你好 jzplp1</Div1>
      <Div1 color="blue">你好 jzplp2</Div1>
    </>
  );
}

css-in-js-19.png

全局样式

@emotion/react支持创建全局样式,也是使用组件的形式。当组件被渲染时,全局样式生效,组件不被渲染时则不生效。

import { useState } from "react";
import { Global } from "@emotion/react";

export default function App() {
  const [state, setState] = useState(0);
  return (
    <>
      <Global
        styles={{
          ".class1": {
            color: "red",
          },
        }}
      />
      {!!(state % 2) && (
        <Global
          styles={{
            ".class2": {
              background: "yellow",
            },
          }}
        />
      )}
      <div className="class1 class2">你好 jzplp</div>
      <div onClick={() => setState(state + 1)}>按下变换</div>
    </>
  );
}

css-in-js-20.png

使用全局样式时,我们只要使用普通类名即可生效。当我们切换state的状态时,黄色的背景颜色也在变化。对应Global组件不渲染时,插入的CSS代码会被移除,渲染时会被引入。

@emotion/css

前面我们描述Emotion相关包,都是使用在React框架之内的。在React之外,还提供了@emotion/css包,用法和前面类似。

接入方式

首先我们创建一个工程接入@emotion/css。这里选用Vite。首先执行命令行:

npm init -y
npm add -D vite
npm add @emotion/css

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的@emotion/css实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现,引入了@emotion/css:

import { css } from '@emotion/css'

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
`;
const styles2 = css({
  color: 'blue',
});

console.log(styles1, styles2);
genEle('test1', styles1);
genEle('test2', styles2);

/* 输出结果
css-qm6gfa css-14ksm7b
*/

最后执行npm run dev命令,在浏览器查看结果。可以看到,我们和在React中使用一样,还是用模板字符串或者样式对象创建,但是创建后得到的结果是类名,我们直接放到DOM元素上即可。

css-in-js-21.png

样式数据

除了上面的模板字符串和样式对象之外,@emotion/css也支持数组类型以及嵌套选择器等属性,这里举例看一下。

import { css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
  &:hover {
    background: yellow;
  }
`;
const styles2 = css([
  {
    color: "blue",
  },
  {
    "&:hover": {
      background: "yellow",
    },
  },
]);

console.log(styles1, styles2);
genEle("test1", styles1);
genEle("test2", styles2);

全局样式

@emotion/css也支持全局样式,但是引入方式不一样:

import { injectGlobal  } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

injectGlobal`
  .class1 {
    color: red;
    &:hover {
      background: yellow;
    } 
  }
`;

genEle("test1", 'class1');

cx优先级处理

@emotion/css提供了一个cx方法,它的作用和知名的classnames包一样,都是合并class类名的。但是这个cx提供了优先级功能,即后面的类中的样式优先级比前面的高:

import { cx, css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cls1 = css`
  font-size: 20px;
  background: green;
`;
const cls2 = css`
  font-size: 20px;
  background: blue;
`;

genEle("test1", cx(cls1, cls2));
genEle("test2", cx(cls2, cls1));

这里我们创建了两个样式,其中背景颜色是冲突的。然后我创建了两个div,使用cx方法合并类名,但是连接各个div合并的顺序相反。在浏览器查看效果发现,实际展示的背景色也不一样,以后面的类名为准。

css-in-js-22.png

linaria与React

前面介绍的styled-components和@emotion,都是运行时CSS,即对应的生成样式的代码执行到的时候,这段CSS才会生成。因此这种库需要在打包后的代码中保留注入CSS的逻辑。还有另一类CSS in JS的库是零运行时的,即编译时生成CSS文件,在生产代码中直接引入即可,无需额外注入。这里先介绍linaria,它就是一个零运行时的库。

接入方式

我们依然以vite为例接入。linaria的背后依赖WyW-in-JS,它是一个零运行时CSS库的辅助工具包。首先执行命令行:

npm create vite@latest
npm add @linaria/core @linaria/react @wyw-in-js/vite

修改vite.config.ts,增加wyw配置:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wyw from '@wyw-in-js/vite';

export default defineConfig({
  plugins: [react(), wyw()],
})

然后修改App.tsx,接入linaria:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

可以看到,这个使用方式就是styled组件的使用方式,和前面的库基本一致。以开发模式在浏览器中运行效果如下:

css-in-js-23.png

零运行时特性

在前面的接入方式中,我们并没有看到它与其它CSS in JS库的不同点。这一节我们专门看一下零运行时和前面的库究竟有什么不一样。首先修改App.tsx:

import { useState } from "react";
import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div1>jzplp1</Div1>
      {state % 2 === 1 && <Div2>jzplp2</Div2>}
      <div onClick={() => setState(state + 1)}>按下切换</div>
      <div>当前state {state}</div>
    </div>
  );
}

在代码中我们设置了变化的state状态,一开始为0时不展示Div2,当按下切换时,Div2才被执行和创建。注意当一开始Div2没被执行的时候,运行时的CSS in JS库是不会创建Div2对应的CSS代码的(因为代码都没执行到那里)。但linaria却会将CSS代码全都创建和引入,即使这些代码没有被使用。我们看下初始化时,linaria的效果:

css-in-js-24.png

注意看浏览器网络请求中有一个CSS请求,它的内容为当前引入的CSS代码,包括没被创建的元素的CSS代码:

css-in-js-25.png

然后我们将代码打包(npm run build)后,查看一下打包文件。可以看到我们在JavaScript中写的CSS代码,已经被编译成一个独立的CSS文件被引入到HTML中了,变成了普通CSS的形式。

css-in-js-26.png

零运行时中的参数

前面我们说过零运行时的库,会把CSS提前编译好放到文件中。对于固定的CSS代码来说非常容易,但当CSS中出现变量参数时,即CSS代码不确定时,应该怎么实现呢?这里我们举个例子看一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: ${(props) => props.color || "yellow"};
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div1 color="red">jzplp1</Div1>
      <Div1 color="blue">jzplp1</Div1>
    </div>
  );
}

通过代码可以看到,传参方式和其它库基本一致。但是查看生成的代码,却发现不一致之处。运行时的库会直接生成完整的CSS代码提供,并且有一个自己专属的类名。但零运行时的库却将参数作为一个CSS变量引用。同时在对应组件的style属性中提供对应的CSS属性值,以此实现代码的零运行时特性。

css-in-js-27.png

打包后查看生成代码,也可以看到生成的带变量的CSS规则代码,以及我们根据处理props属性生成CSS变量的函数。这个函数只能在运行时处理。

css-in-js-28.png

CSS变量参数的限制

CSS变量虽然有效,但它的逻辑并不是字符串匹配,因此使用方式还是和运行时库有区别。我们先举一个普通的CSS代码作为例子,尝试了三种变量组合的场景。

.class1 {
  --classVar1: 21px;
  font-size:var(--classVar1);
}
.class2 {
  --classVar1: 21;
  font-size:var(--classVar1)px;
}
.class3 {
  --classVar1: 1px;
  font-size: 2var(--classVar1);
}

然后是对应的React代码:

import "./App.css";

export default function App() {
  return (
    <div>
      <div className="class1">jzplp1</div>
      <div className="class2">jzplp2</div>
      <div className="class3">jzplp3</div>
    </div>
  );
}

css-in-js-29.png

看可以看到只有第一个例子生效了,是21px整个作为CSS变量值。将这个值拆开是不能生效的,更不能像字符串那样随意拼合。那利用CSS变量作为参数方案的linaria呢?我们试一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  font-size: ${(props) => props.size + "px"};
`;

const Div2 = styled.div`
  font-size: ${(props) => props.size}px;
`;

const Div3 = styled.div`
  font-size: ${(props) => props.size}1px;
`;

const Div4 = styled.div`
  font-size: 2${(props) => props.size}px;
`;

export default function App() {
  return (
    <div>
      <Div1 size={21}>jzplp1</Div1>
      <Div2 size={21}>jzplp2</Div2>
      <Div3 size={2}>jzplp3</Div3>
      <Div4 size={1}>jzplp4</Div4>
    </div>
  );
}

css-in-js-30.png

通过例子可以看到,21px整个作为参数与21作为参数都是可以的,2或者1单独作为参数就不行了,会造成CSS代码解析错误。这里21生效应该是linaria特殊处理过,把后面的px拼接上了。

CSS声明块作为参数

linaria也可以接收CSS声明块作为模板参数:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-31.png

可以看到,不管是字符串模板还是对象形式都是支持的。但整个块对象不能是由函数返回的,即只能在编译时处理完成,不能有运行时特性。这还是由于前面CSS变量作为实现方案的原因,导致无法生效。这里举个例子:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${() => cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${() => cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-32.png

通过结果看到,用函数包裹起来(假设它是接收props之后运行时计算的CSS代码)并未生效。这里为了方便查看效果,我们打个包看一下构建成果:

css-in-js-33.png

通过生产代码可以看到,这个函数逻辑以及返回值被原封不动的保留下来返回了,但是linaria却无法识别整个CSS声明,因此不能生效。

linaria中的css片段

linaria中也有css方法,使用方式与其它库类似,而且支持不在React框架中使用。

纯JavaScript接入方式

这里我们抛弃React,使用纯JavaScript的方式接入linaria。使用linaria必须要编译,这里依然选用Vite。首先执行命令行:

npm init -y
npm add -D vite @wyw-in-js/vite 
npm add @linaria/core

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的linaria实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现:

import { css } from '@linaria/core';

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cssData = css`
  color: red;
  font-size: 20px;
`;

console.log(data);
genEle('jzplp', cssData);

/* 输出结果
cf71da1
*/

可以看到,css函数也是使用模板字符串来写CSS规则,返回值是一个类名,可以直接用在元素上。使用linaria还要修改打包配置,创建vite.config.js:

import { defineConfig } from "vite";
import wyw from "@wyw-in-js/vite";

export default defineConfig({
  plugins: [wyw()],
});

然后执行npm run dev,开发模式下正常生效:

css-in-js-34.png

然后我们执行npm run build,查看打包后的生成代码:

css-in-js-35.png

可以看到,我们用css函数生成的CSS代码,已经被放到独立的CSS文件中,css函数使用的位置,已经直接变成了class类名。这也是零运行时库的效果,在编译时就将CSS代码生成完毕。

全局样式

linaria也支持创建全局样式,这里我们试一下。

import { css } from "@linaria/core";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

css`
  :global() {
    div {
      color: red;
    }
    .class1 {
      background: yellow;
    }
  }
`;

const cssData = css`
  font-size: 20px;
`;

console.log(cssData);
genEle("jzplp1", "class1");
genEle("jzplp2", cssData);

css-in-js-36.png

将全局特性包裹在:global()中,即可生效。这段CSS甚至都不需要被哪个标签引用。我们再看看打包后的结果:

css-in-js-37.png

可以看到,对应的这段css函数代码没有了,全局特性转移到了CSS文件中。

vanilla-extract

vanilla-extract是另一个CSS in JS库,正如它的名字,vanilla表示不使用框架的纯JavaScript。vanilla-extract这个库是框架无关的,同样他也是一个零运行时库。

接入方式

我们还是使用Vite接入,但这次试一下Vite提供的vanilla模板。执行命令行:

npm create vite@latest
# 选择 Vanilla + TypeScript模板
npm add @vanilla-extract/css @vanilla-extract/vite-plugin

创建vite.config.js文件,内容如下:

import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';

export default {
  plugins: [vanillaExtractPlugin()]
};

创建src/styles.css.ts文件,内容为创建样式,并导出。

import { style } from '@vanilla-extract/css';

export const cls1 = style({
  color: 'red'
});

然后删除无用的文件,修改src/main.js的内容为引入创建的样式,并作为类名放到HTML标签上。

import { cls1 } from "./styles.css.ts";

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-38.png

在开发模式运行,通过浏览器可以看到,也是在head中插入了style标签提供样式。我们再打包看看生成文件:

css-in-js-39.png

通过生成文件可以看到,vanilla-extract也是在编译时就生成独立的样式文件引入,不需要运行时处理。

独立.css.ts文件

vanilla-extract与其它CSS in JS方案不同点在于,虽然它确实是用JavaScript写CSS代码,但却要求独立的文件类型“.css.ts”。如果在其它文件中写入会造成错误。这里我们试一试,修改前面的src/main.js:

import { style } from '@vanilla-extract/css';

const cls1 = style({
  color: 'red'
});

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

这时候开发模式打开浏览器,会看到报错,元素也没有正常展示:

css-in-js-40.png

回想起使用CSS in JS方案的重要原因就是希望CSS代码与组件的联系更紧密。这样强制的独立.css.ts文件,看起来没有增加紧密感。

生成style

vanilla-extract使用style创建样式,返回对应的类名。style方法接收对象形式的CSS规则,但是与常规CSS写法有点不同,这里介绍部分不同点。

px单位

首先是如果属性值的单位是px,可以省略,写成数字形式。这里举个例子:

export const cls1 = style({
  fontSize: 10,
  margin: 20,
  padding: "10px",
  flex: 1,
});

/* 生成结果
.r9osg00 {
  flex: 1;
  margin: 20px;
  padding: 10px;
  font-size: 10px;
}
*/

我们直接对代码打包,观察生成的CSS文件。可以看到vanilla-extract并不是所有数字都会转换,那些没有单位的CSS属性并不会被转换。

浏览器引擎前缀写法

在对象中写CSS属性需要以camelCase驼峰命名法,但对于浏览器引擎前缀这种最前面带中划线的形式,vanilla-extract要求使用PascalCase帕斯卡命名法,即最前面大写。

export const cls1 = style({
  WebkitTapHighlightColor: "rgba(0, 0, 0, 0)",
});

/* 生成结果
.r9osg00 {
  -webkit-tap-highlight-color: #0000;
}
*/

媒体查询/容器查询/@layer等

它们的写法多了一层嵌套:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  "@container": {
    "(min-width: 768px)": {
      padding: 10,
    },
  },
  "@media": {
    "screen and (min-width: 768px)": {
      padding: 10,
    },
    "(prefers-reduced-motion)": {
      transitionProperty: "color",
    },
  },
  "@layer": {
    typography: {
      fontSize: "1rem",
    },
  },
  "@supports": {
    "(display: grid)": {
      display: "grid",
    },
  },
});

/* 生成结果
@layer typography {
  .r9osg00 {
    font-size: 1rem;
  }
}
@media screen and (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
@media (prefers-reduced-motion) {
  .r9osg00 {
    transition-property: color;
  }
}
@supports (display: grid) {
  .r9osg00 {
    display: grid;
  }
}
@container (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
*/

后备值

在之前讲PostCSS中postcss-custom-properties插件的时候,我们提到过当浏览器读取到一个不支持的CSS属性值时,如果这个属性前面已经有一个后备值了,那就使用那个后备值,不会应用不支持的属性值。

但以对象的形式写CSS属性,key同一个的情况下,没办法写两个值。这里vanilla-extract接收一个值数组,实现后备值功能:

export const cls1 = style({
  overflow: ['auto', 'overlay']
});

/* 生成结果
.r9osg00 {
  overflow: auto;
  overflow: overlay;
}
*/

CSS变量

vanilla-extract支持使用CSS变量,需要在vars属性内部。创建后的CSS变量可以在任意位置使用,但需要满足选择器的条件。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  vars: {
    "--jzplp1": "red",
  },
  color: "var(--jzplp1)",
});

export const cls2 = style({
  color: "var(--jzplp1)",
  fontSize: 20,
});

/* 生成结果
.r9osg00 {
  --jzplp1: red;
  color: var(--jzplp1);
}
.r9osg01 {
  color: var(--jzplp1);
  font-size: 20px;
}
*/

还可以通过createVar方法创建带有哈希的模块化CSS变量,在使用的位置引用即可。

import { style, createVar } from "@vanilla-extract/css";

const cssVar = createVar();

export const cls1 = style({
  vars: {
    [cssVar]: "red",
  },
  color: cssVar,
});

export const cls2 = style({
  color: cssVar,
  fontSize: 20,
});

/* 生成结果
.r9osg01 {
  --r9osg00: red;
  color: var(--r9osg00);
}
.r9osg02 {
  color: var(--r9osg00);
  font-size: 20px;
}
*/

嵌套选择器

vanilla-extract支持使用嵌套选择器,但是有一些特殊的规则。

顶层使用

对于简单的无参数的伪类或者伪元素选择器,可以与其它CSS属性放在一起顶层使用。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  ":hover": {
    background: "yellow",
  },
  "::before": {
    content: "jzplp",
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:before {
  content: "jzplp";
}
*/

可以看到,与属性一同使用时可以省略&符号。但是这里不能添加带参数或者复杂的组合选择器,否则会编译报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  "&:hover": {
    background: "yellow",
  },
  ":not(.cls)": {
    content: "jzplp",
  },
});

/* 分别报错
error TS2353: Object literal may only specify known properties, and '"&:hover"' does not exist in type 'ComplexStyleRule'.
error TS2353: Object literal may only specify known properties, and '":not(.cls)"' does not exist in type 'ComplexStyleRule'.
*/

selectors中使用

在selectors属性中可以编写复杂的选择器,但要自己写&符号。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    "&:hover": {
      background: "yellow",
    },
    "&:not(.cls)": {
      content: "jzplp",
    },
    ".abc &": {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:not(.cls) {
  content: "jzplp";
}
.abc .r9osg00 {
  font-size: 20px;
}
*/

禁止场景

选择器也不是随便写都行的,vanilla-extract要求选择器必须作用于当前类。如果作用于其它类,那么编译时也会报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    ":hover": {
      background: "yellow",
    },
    "& .abc": {
      fontSize: 20
    },
  },
});

/* 分别报错
Error: Invalid selector: :hover
Error: Invalid selector: & .abc
Style selectors must target the '&' character (along with any modifiers), e.g. `${parent} &` or `${parent} &:hover`.
*/

它会实际分析CSS规则,并不是把&写在什么位置就能避开的。

传入类名

嵌套选择器也支持传入其它style函数生成的类名,同样需要遵守选择器必须作用于当前类。

import { style } from "@vanilla-extract/css";

const cls = style({
  color: 'red',
})

export const cls1 = style({
  selectors: {
    [`.${cls} &`]: {
      background: "yellow",
    },
    [`&:not(.${cls})`]: {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00 .r9osg01 {
  background: #ff0;
}
.r9osg01:not(.r9osg00) {
  font-size: 20px;
}
*/

全局样式

vanilla-extract可通过globalStyle函数创建全局样式,第一个参数是选择器,第二是样式对象。

import { globalStyle } from "@vanilla-extract/css";

globalStyle("body", {
  vars: {
    "--jzplp": "10px",
  },
  margin: 0,
});

globalStyle(".abc .bcd:hover", {
  color: "red",
});

/* 生成结果
body {
  --jzplp: 10px;
  margin: 0;
}
.abc .bcd:hover {
  color: red;
}
*/

全局样式中也能包含使用style函数生成的模块化类名,这时候就没有选择器作用限制了:

import { globalStyle, style } from "@vanilla-extract/css";

const cls = style({
  color: "red",
});

globalStyle(`body .${cls}`, {
  margin: 0,
});

globalStyle(`.${cls} :not(div)`, {
  fontSize: 10,
});

/* 生成结果
.r9osg00 {
  color: red;
}
body .r9osg00 {
  margin: 0;
}
.r9osg00 :not(div) {
  font-size: 10px;
}
*/

主题

创建主题

vanilla-extract支持使用createTheme方法创建主题,主题实际上就是一组预设CSS变量。首先我们创建主题并直接打包,看下输出结果:

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

console.log(theme1);
console.log(vars);

/* 打包命令行输出
r9osg00
{
  color: { banner: 'var(--r9osg01)', font: 'var(--r9osg02)' },
  size: { margin: 'var(--r9osg03)' }
}
*/

/* 打包后CSS文件内容
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
*/

上面创建了一个主题。其中vars表示这个主题模式的对象,其中包含变量的引用。theme1是类名,使用这个类即可对这些CSS变量提供值。

使用主题

下面我们再来看使用方式。首先是styles.css.ts,除了创建主题之外还创建了样式,其中引用了vars中的变量。

import { createTheme, style } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const cls1 = style({
  color: vars.color.font,
});

export { theme1, cls1 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  color: var(--r9osg02);
}
*/

然后是main.js,将主题类放到body中,再将应用主题的类放到div上。这样div会使用body上的变量,使主题生效。

import { theme1, cls1 } from './styles.css';

document.body.className = theme1;

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-41.png

复用主题

既然是主题,那么应该不会只有一个,主题应该是同变量但是值不同的一组结构,这时候可以复用vars继续创建主题。

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  --r9osg01: yellow;
  --r9osg02: pink;
  --r9osg03: 30px;
}
*/

可以看到,创建的theme2应该遵守同样的结构,但是值不同。theme2仅生成类名,在对应的标签上赋值即可实现主题切换。

仅创建vars

通过上面的例子可以看到,vars表示主题的结构和引用,生成的类名表示实际的主题值。但是现在创建主题结构和创建主题值合二为一了,如果希望分开生成,vanilla-extract提供了createThemeContract方法。可以将上面的代码改下如下,效果不变。

import { createTheme, createThemeContract } from "@vanilla-extract/css";

const vars = createThemeContract({
  color: {
    banner: "",
    font: "",
  },
  size: {
    margin: "",
  },
});

const theme1 = createTheme(vars, {
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

总结

库列表

前面我们介绍了四个CSS in JS的库,但这仅仅是九牛一毛。社区中CSS in JS的库非常非常多,这里用表格列举一些知名度较高的:

库名称 零运行时 纯JS 适配React框架 备注
styled-components 不支持 支持 本文已介绍
Emotion 支持 支持 本文已介绍
linaria 支持 支持 本文已介绍
vanilla-extract 支持 不支持 本文已介绍
Panda CSS 支持 支持 -
Compiled 不支持 支持 -
Radium 不支持 支持 已停止维护
JSS 插件支持 支持 支持 已停止维护

特点总结

本文只是简单介绍了几个CSS in JS库的使用方式和特点,并未详细探究原理和区别。事实上关于CSS in JS库还有很多值得探讨的主题,例如服务端渲染,性能优化等。根据介绍的几个CSS in JS库以及网络上相关分析,这里我们总结一下CSS in JS的特点,其中有优点,也有缺点:

  • 使用方式和API各有特色,但也有很多相似之处
    • 可以看到很多API设计都是类似的,例如styled组件、css的props、模板字符串或对象作为CSS规则表示,这些API在很多库中都以类似的方式出现
    • 但很多库的设计都有自己的特点和使用方式,并没有千篇一律
  • 虽然CSS in JS的设计各有特色,但还是可以大致分成 运行时库和零运行时库两类
    • 运行时库对于CSS传参的灵活性高,但是运行时生成CSS,有额外的性能损失
    • 零运行时库在编译时生成CSS文件,没有运行时性能损失,但对于CSS参数等灵活性低
  • CSS in JS库对于类型检查和提示更友好,支持TypeScript更完善
  • 由于CSS是在JavaScript中撰写的,因此控制和切换CSS要更方便
  • CSS in JS的类名时根据hash生成的,自带模块化类名,省去了起名的烦恼,也防止了CSS污染
  • 类名是自动生成的,因此有些开发者认为比较难看,不方便查找元素等。相比较CSS Modules可以配置原类名+hash,可以轻松识别类名
  • 在JavaScript中写CSS,更方便组织代码,例如将CSS与HTML和JS放在一起,以组件化的形式组织
  • 对于运行时CSS,CSS代码只有在需要的时候才加载,对于部分页面场景,具有更小的首屏文件体积和其它优势
  • 使用独立的CSS文件我们不清楚哪些样式是真正使用的,哪些样式没有被用到。而CSS in JS的CSS规则引用关系明显,我们可以轻松找到未被使用的样式,也可以利用tree-shaking等技术编译时去掉不需要的样式
  • 有些人很喜欢CSS in JS来组织CSS代码,但是有些人却觉得多此一举。萝卜青菜,各有所爱
  • CSS in JS在React框架使用居多,Vue框架有自己的方案(组件作用域CSS,我们之前介绍过),基本不需要CSS in JS

参考

深入浅出 Tree Shaking:Rollup 是如何“摇”掉死代码的?

2026年4月18日 18:35

前言

在前端性能优化中,减小 JS 包体积是重中之重。Tree Shaking(摇树优化) 就像它的名字一样:通过摇晃代码这棵大树,让那些无用的“枯叶”(死代码)掉落。本文将带你揭秘 Rollup 实现 Tree Shaking 的底层原理。

一、 核心基石:为什么是基于ESM?

Tree Shaking 的实现并非偶然,它深度依赖于 ESM (ES Module) 规范。

  • 静态分析:ESM 要求 importexport 必须在代码顶层,不能出现在 if 块或函数内部。
  • 编译时确定:这意味着 Rollup 不需要执行代码,只需扫描一遍源码,就能在编译阶段清晰地知道模块间的依赖关系。
  • 对比 CommonJSrequire 是动态加载的,只有运行到那一行才知道加载了什么,因此 CJS 无法进行彻底的 Tree Shaking。

二、Rollup Tree Shaking 实现原理:从扫描到删除的四步曲

Rollup 的“摇树”过程可以分为以下四个精密步骤:

1. 递归扫描与依赖图构建

从入口文件(如 main.js)开始,递归扫描所有 import/export 语句。Rollup 会记录:

  • 每个模块导出了哪些变量/函数。
  • 每个模块导入了哪些内容。
  • 模块间的引用链路(A 引用了 B 的哪个具体成员)。
  • 基于这些信息,Rollup 会构建出一个完整的模块依赖图,清晰呈现整个项目的代码引用链路。

步骤 2:标记活代码与死代码

在模块依赖图的基础上,Rollup 会从入口文件出发,反向追踪所有被引用的内容,标记出活代码和死代码:

  • 首先标记出哪些导出项被外部(其他模块或入口文件)引用;
  • 接着判断这些被引用的导出项,是否真的在代码中被使用(而非仅导入未使用),若被使用则标记为活代码,未被使用则标记为死代码

步骤 3:AST 分析优化(补充细节)

在标记活代码的过程中,Rollup 会深入分析每个模块的 AST(抽象语法树) ,精准追踪变量、函数的定义和引用关系。这里有一个容易被忽略的点:

  • 即使一个变量、函数在模块内定义了,但既没有被 export 导出,也没有在模块内部被引用,它依然会被判定为死代码,被 Tree Shaking 摇掉——也就是说,Tree Shaking 不仅会处理“导出未使用”的代码,也会清理模块内部“定义未使用”的冗余代码。

步骤 4:删除死代码,生成最终产物

最后,Rollup 会遍历所有模块,只保留标记为活代码的内容,直接删除所有死代码(未被引用的导出项、模块内部未使用的定义等),最终生成精简、无冗余的打包产物。

Rollup 的 Tree Shaking 是原生支持的,无需额外配置,打包时会自动执行上述流程,且输出的代码更接近手写风格,无多余的运行时代码,优化效果直观可见。


三、 实战:不同导出方式的“招魂”效果

Tree Shaking 的效果,很大程度上取决于代码的导出方式——只有静态导出才能被 Rollup 精准分析,动态导出则无法实现 Tree Shaking。以下是常见的导出方式对比:

导出方式 是否支持树摇 深度原因分析
export const a = 1 完美支持 静态导出,引用关系明确。
export function b() {} 完美支持 未被调用时可被精准识别并删除。
export default { a:1 } 不支持/效果差 默认导出是一个对象,工具难以判断你是否会动态访问对象的某个 Key。
export * from './x.js' 支持 按需转发,只会转发那些被下游真正引用的成员。

四、 Tree Shaking 避坑指南

避坑点 1:CommonJS 模块会导致 Tree Shaking 罢工

  • 如果项目中引入了使用 require/module.exports 的第三方库,Tree Shaking 会直接失效。原因如下:

    • CommonJS 模块是动态模块,require 可以接收变量(如 require(./${name}.js)),导入导出关系只能在运行时确定,Rollup 无法在打包前进行静态分析,因此无法识别死代码,Tree Shaking 自然无法生效。

    实战建议:优先使用支持 ESM 规范的第三方库(如 lodash-es 替代 lodash),避免在 ESM 项目中混用 CommonJS 模块。

避坑点 2:副作用代码会干扰 Tree Shaking

  • 如果模块中存在“副作用代码”(即执行后会影响全局环境、修改外部变量、执行 DOM 操作等的代码,如顶层的 console.log、window.xxx = xxx),即使这些代码未被引用,Rollup 也会保守地保留它们,避免影响项目运行逻辑,从而导致部分死代码无法被摇掉。

    解决方案:如果确认模块无副作用,可在 package.json 中添加 "sideEffects": false,告诉 Rollup 该模块可安全删除未引用代码;若有部分文件有副作用(如 CSS 文件),可显式声明:"sideEffects": ["./src/style.css"]

避坑点 3:动态访问会导致 Tree Shaking 失效

  • 如果代码中存在动态访问导出项的情况(如 import * as utils from './utils.js'; utils[dynamicKey]()),Rollup 无法在静态分析阶段确定哪些导出项被使用,会保留整个模块的所有导出项,导致 Tree Shaking 失效。

    实战建议:尽量使用具名导入(import { func } from './utils.js'),避免动态访问导出项。


五、 总结

Rollup Tree Shaking 的核心是“基于 ESM 静态规范,通过静态分析识别并删除死代码”,其流程简洁高效,且原生支持无需额外配置。想要用好 Tree Shaking,关键记住 3 点:

  • 坚持使用 ESM 规范(import/export),避免混用 CommonJS;
  • 优先使用静态具名导出,避免默认导出对象、动态导出;
  • 处理好副作用代码,必要时通过 package.json 的 sideEffects 字段声明。

大屏开发必读:Scale/VW/Rem/流式/断点/混合方案全解析(附完整demo)

2026年4月18日 16:35

大屏适配终极指南:6 种主流方案实战对比

数据可视化大屏开发中,屏幕适配是最令人头疼的问题之一。本文通过 6 个完整 Demo,深度对比 Scale、VW/VH、Rem、流式布局、响应式断点、混合方案这 6 种主流方案的优缺点与适用场景。

前言

在开发数据可视化大屏时,你是否遇到过这些问题:

  • 设计稿是 1920×1080,实际大屏是 3840×2160,怎么适配?
  • 大屏比例不是 16:9,出现黑边或拉伸怎么办?
  • 小屏幕上字体太小看不清,大屏幕上内容太空旷
  • 图表、地图、视频等组件在不同尺寸下表现不一致

本文将通过 6 个完整的可交互 Demo,带你逐一攻克这些难题。

方案一:Scale 等比例缩放

核心原理

通过 CSS transform: scale() 将整个页面按屏幕比例缩放,是最简单直观的方案。

const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;

function setScale() {
  const scaleX = window.innerWidth / DESIGN_WIDTH;
  const scaleY = window.innerHeight / DESIGN_HEIGHT;
  const scale = Math.min(scaleX, scaleY);

  screen.style.transform = `scale(${scale})`;

  // 居中显示
  const left = (window.innerWidth - DESIGN_WIDTH * scale) / 2;
  const top = (window.innerHeight - DESIGN_HEIGHT * scale) / 2;
  screen.style.left = left + 'px';
  screen.style.top = top + 'px';
}

优缺点分析

优点:

  • 完美还原设计稿比例
  • 实现简单,几行代码搞定
  • 字体、图表自动缩放,无需额外处理
  • 兼容性好,不需要关注浏览器兼容性

缺点:

  • 屏幕比例不符时出现黑边(如 4:3 屏幕)
  • 小屏幕上字体会缩得很小,可能看不清
  • 无法利用多余空间,浪费屏幕资源

适用场景

数据可视化大屏、监控中心大屏等需要精确还原设计稿的场景。

方案二:VW/VH 视口单位

核心原理

使用 CSS viewport 单位(vw/vh)代替 px,让元素根据视口尺寸自适应。

.container {
  width: 50vw;      /* 视口宽度的 50% */
  height: 30vh;     /* 视口高度的 30% */
  font-size: 2vw;   /* 字体随视口宽度变化 */
  padding: 2vh 3vw; /* 内边距也自适应 */
}

优缺点分析

优点:

  • 纯 CSS 方案,无 JS 依赖
  • 充分利用全部屏幕空间,无黑边
  • 无缩放导致的模糊问题

缺点:

  • 计算公式复杂,需要手动换算
  • 宽高难以协调,容易出现变形
  • 单位换算易出错,维护成本高

适用场景

内容型页面、需要充分利用屏幕空间的场景。

方案三:Rem 动态计算

核心原理

根据视口宽度动态计算根字体大小,所有尺寸使用 rem 单位。

function setRem() {
  const designWidth = 1920;
  const rem = (window.innerWidth / designWidth) * 100;
  document.documentElement.style.fontSize = rem + 'px';
}

// CSS 中使用 rem
.card {
  width: 4rem;      /* 设计稿 400px */
  height: 2rem;     /* 设计稿 200px */
  font-size: 0.24rem; /* 设计稿 24px */
}

优缺点分析

优点:

  • 计算相对直观(设计稿 px / 100 = rem)
  • 兼容性最好,支持 IE9+
  • 无缩放模糊问题

缺点:

  • 依赖 JS 初始化,页面可能闪烁
  • 高度处理困难,只能基于宽度适配
  • 需要手动或使用工具转换单位

适用场景

移动端 H5 页面、PC 端后台系统。

方案四:流式/弹性布局

核心原理

使用百分比、Flexbox、Grid 等 CSS 原生能力实现响应式布局。

.dashboard {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
}

.card {
  display: flex;
  flex-direction: column;
  padding: 2%;
}

.chart {
  flex: 1;
  min-height: 200px;
}

优缺点分析

优点:

  • 纯 CSS 方案,无任何依赖
  • 自然响应式,适配各种屏幕
  • 内容自适应,信息密度可变

缺点:

  • 设计稿还原度低,无法精确控制
  • 图表比例容易失控
  • 空间利用率低,不够美观

适用场景

B 端后台系统、管理平台等以内容为主的页面。

方案五:响应式断点

核心原理

使用 @media 查询针对不同屏幕尺寸写多套样式规则。

/* 1920×1080 大屏 */
@media (min-width: 1920px) {
  .container { width: 1800px; font-size: 24px; }
  .grid { grid-template-columns: repeat(4, 1fr); }
}

/* 3840×2160 4K 屏 */
@media (min-width: 3840px) {
  .container { width: 3600px; font-size: 48px; }
  .grid { grid-template-columns: repeat(6, 1fr); }
}

/* 1366×768 小屏 */
@media (max-width: 1366px) {
  .container { width: 1300px; font-size: 18px; }
  .grid { grid-template-columns: repeat(3, 1fr); }
}

优缺点分析

优点:

  • 精确控制各个分辨率的显示效果
  • 字体可读性可控
  • 适合有固定规格的大屏群

缺点:

  • 代码量爆炸,维护极其困难
  • 无法覆盖所有可能的分辨率
  • 新增规格需要大量修改

适用场景

有固定规格的大屏群、展厅多屏展示系统。

方案六:混合方案(推荐)

核心原理

结合 Scale + Rem 的优点,既保证设计稿比例,又确保字体在小屏可读。

const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;
const MIN_SCALE = 0.6; // 最小缩放限制

function adapt() {
  const winW = window.innerWidth;
  const winH = window.innerHeight;

  // Scale 计算 - 保证整体比例
  const scaleX = winW / DESIGN_WIDTH;
  const scaleY = winH / DESIGN_HEIGHT;
  const scale = Math.max(Math.min(scaleX, scaleY), MIN_SCALE);

  // 应用 scale
  screen.style.transform = `scale(${scale})`;

  // Rem 计算 - 根据缩放比例调整根字体
  // 当 scale < 1 时,增加根字体补偿
  const baseRem = 100;
  const fontScale = Math.max(scale, MIN_SCALE);
  const rem = baseRem * fontScale;
  document.documentElement.style.fontSize = rem + 'px';
}

优缺点分析

优点:

  • 等比例缩放保证布局一致性
  • 字体最小值保护,防止过小不可读
  • 大屏清晰、小屏可读
  • 兼顾视觉和体验

缺点:

  • 需要 JS 支持
  • 计算逻辑稍复杂

适用场景

通用推荐方案,适合绝大多数大屏开发场景。

方案对比一览表

方案 实现难度 设计稿还原度 响应式表现 小屏可读性 维护成本 推荐场景
Scale 简单 极高 一般 数据可视化大屏
VW/VH 中等 中等 中等 中等 内容型页面
Rem 中等 一般 中等 中等 移动端 H5
流式布局 简单 极好 B 端后台系统
断点方案 复杂 中-高 极高 固定规格大屏群
混合方案 中等 中等 通用推荐

场景推荐速查

🖥️ 数据可视化大屏 / 监控中心

需要精确还原设计稿,图表比例严格保持,像素级对齐。

推荐: Scale 等比例缩放(接受黑边)或 混合方案

示例: 企业展厅大屏、运营监控看板

📊 B 端后台 / 管理系统

内容为主,需要充分利用屏幕空间,信息密度要高。

推荐: 流式布局 或 VW/VH 方案

示例: CRM 系统、数据管理平台

🌐 多端适配 / 响应式网站

需要覆盖手机、平板、电脑、大屏等多种设备。

推荐: 响应式断点 + 流式布局

示例: 企业官网、数据门户

完整 Demo 代码

以下是 6 种大屏适配方案的完整可运行代码,保存为 HTML 文件后可直接在浏览器中打开体验。

Demo 1: Scale 等比例缩放

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案1: Scale 等比例缩放 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }

        /* 信息面板 - 不参与缩放 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
        }

        .info-panel h3 {
            color: #4fc3f7;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .info-panel .current-scale {
            background: #ff9800;
            color: #000;
            padding: 8px 12px;
            border-radius: 4px;
            font-weight: bold;
            margin-top: 10px;
        }

        /* 大屏容器 - 按 1920*1080 设计 */
        .screen-container {
            width: 1920px;
            height: 1080px;
            transform-origin: 0 0;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            position: relative;
            overflow: hidden;
        }

        /* 大屏内容样式 */
        .header {
            height: 100px;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 48px;
            color: #fff;
            text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
            letter-spacing: 8px;
        }

        .main-content {
            display: flex;
            padding: 30px;
            gap: 20px;
            height: calc(100% - 100px);
        }

        .sidebar {
            width: 400px;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 20px;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            font-size: 24px;
            margin-bottom: 15px;
        }

        .card-value {
            color: #fff;
            font-size: 56px;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            margin-top: 15px;
            border-radius: 8px;
            min-height: 180px;
        }

        .notice {
            position: absolute;
            bottom: 20px;
            left: 20px;
            right: 20px;
            background: rgba(255, 152, 0, 0.2);
            border: 1px solid #ff9800;
            color: #ff9800;
            padding: 15px 20px;
            border-radius: 8px;
            font-size: 18px;
        }

        .grid-line {
            position: absolute;
            top: 50%;
            left: 0;
            right: 0;
            height: 1px;
            border-top: 2px dashed rgba(79, 195, 247, 0.1);
        }

        .grid-line::before {
            content: '设计稿中心线 (960px)';
            position: absolute;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(79, 195, 247, 0.3);
            font-size: 14px;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案1: Scale 等比例缩放</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 完美还原设计稿比例<br>
            • 实现简单直观<br>
            • 字体/图表自动缩放<br><br>
            <div class="cons">✗ 缺点:</div>
            • 屏幕比例不符时出现黑边<br>
            • 字体过小可能看不清<br>
            • 无法利用多余空间
        </div>
        <div class="current-scale" id="scaleInfo">
            缩放比例: 1.0<br>
            窗口尺寸: 1920×1080
        </div>
    </div>

    <!-- 大屏容器 -->
    <div class="screen-container" id="screen">
        <div class="header">SCALE 方案演示 - 1920×1080 设计稿</div>

        <div class="main-content">
            <div class="sidebar">
                <div class="card-title">左侧信息面板</div>
                <p style="color: rgba(255,255,255,0.7); font-size: 18px; line-height: 1.8;">
                    这是基于 1920×1080 设计稿开发的页面。<br><br>
                    修改浏览器窗口大小,观察整个页面如何等比例缩放。注意两侧的空白区域(当屏幕比例不是 16:9 时)。
                </p>
            </div>

            <div class="chart-area">
                <div class="card">
                    <div class="card-title">实时用户数</div>
                    <div class="card-value" id="value1">128,456</div>
                    <div class="mini-chart" id="chart1"></div>
                </div>
                <div class="card">
                    <div class="card-title">交易金额</div>
                    <div class="card-value" id="value2">¥2.3M</div>
                    <div class="mini-chart" id="chart2"></div>
                </div>
                <div class="card">
                    <div class="card-title">系统负载</div>
                    <div class="card-value" id="value3">68%</div>
                    <div class="mini-chart" id="chart3"></div>
                </div>
                <div class="card">
                    <div class="card-title">响应时间</div>
                    <div class="card-value" id="value4">23ms</div>
                    <div class="mini-chart" id="chart4"></div>
                </div>
            </div>
        </div>

        <div class="grid-line"></div>

        <div class="notice">
            💡 提示:调整浏览器窗口为 4:3 比例或手机尺寸,观察两侧的黑边/留白。这是 Scale 方案的典型特征。
        </div>
    </div>

    <script>
        const screen = document.getElementById('screen');
        const scaleInfo = document.getElementById('scaleInfo');

        // 设计稿尺寸
        const DESIGN_WIDTH = 1920;
        const DESIGN_HEIGHT = 1080;

        function setScale() {
            const winW = window.innerWidth;
            const winH = window.innerHeight;

            // 计算宽高缩放比例,取较小值保持完整显示
            const scaleX = winW / DESIGN_WIDTH;
            const scaleY = winH / DESIGN_HEIGHT;
            const scale = Math.min(scaleX, scaleY);

            // 应用缩放
            screen.style.transform = `scale(${scale})`;

            // 可选:居中显示
            const left = (winW - DESIGN_WIDTH * scale) / 2;
            const top = (winH - DESIGN_HEIGHT * scale) / 2;
            screen.style.position = 'absolute';
            screen.style.left = left + 'px';
            screen.style.top = top + 'px';

            // 更新信息
            scaleInfo.innerHTML = `
                缩放比例: ${scale.toFixed(3)}<br>
                窗口尺寸: ${winW}×${winH}<br>
                设计稿: 1920×1080<br>
                空白区域: ${Math.round((winW - DESIGN_WIDTH * scale))}×${Math.round((winH - DESIGN_HEIGHT * scale))}
            `;
        }

        setScale();

        // ===== ECharts 图表配置 =====
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        chart1.setOption({
            backgroundColor: 'transparent',
            grid: { top: 30, right: 20, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 12 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff' }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                }
            }]
        });

        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        chart2.setOption({
            backgroundColor: 'transparent',
            grid: { top: 30, right: 20, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 12 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', formatter: '¥{value}万' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                }
            }]
        });

        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        chart3.setOption({
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['40%', '70%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 11 }
            }]
        });

        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        chart4.setOption({
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '80%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 10,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' } },
                detail: {
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 20,
                    offsetCenter: [0, '70%']
                },
                data: [{ value: 23 }]
            }]
        });

        // 图表引用数组用于resize
        const charts = [chart1, chart2, chart3, chart4];

        // 防抖处理 resize
        let timer;
        window.addEventListener('resize', () => {
            clearTimeout(timer);
            timer = setTimeout(() => {
                setScale();
                charts.forEach(chart => chart.resize());
            }, 100);
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
            document.getElementById('value4').textContent = newValue + 'ms';
        }, 3000);
    </script>
</body>
</html>

使用说明:将以上代码保存为 1-scale-demo.html,直接在浏览器中打开即可体验。调整窗口大小观察等比例缩放效果,注意两侧的黑边。

Demo 2: VW/VH 视口单位方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案2: VW/VH 方案 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
        }

        .info-panel h3 {
            color: #81c784;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .info-panel .formula {
            background: #333;
            padding: 10px;
            border-radius: 4px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            margin-top: 10px;
            color: #ff9800;
        }

        /* VW/VH 布局 - 直接使用视口单位 */
        .header {
            /* 设计稿 100px / 1080px * 100vh */
            height: 9.259vh;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            /* 设计稿 48px / 1080px * 100vh */
            font-size: 4.444vh;
            color: #fff;
            text-shadow: 0 0 2vh rgba(79, 195, 247, 0.5);
            letter-spacing: 0.7vw;
        }

        .main-content {
            display: flex;
            /* 设计稿 30px / 1080px * 100vh */
            padding: 2.778vh 1.562vw;
            /* 设计稿 20px / 1080px * 100vh */
            gap: 1.852vh 1.042vw;
            /* 总高度 100vh - header 高度 */
            height: 90.741vh;
        }

        .sidebar {
            /* 设计稿 400px / 1920px * 100vw */
            width: 20.833vw;
            background: rgba(15, 52, 96, 0.3);
            /* 设计稿 16px / 1080px * 100vh */
            border-radius: 1.481vh;
            /* 设计稿 20px / 1080px * 100vh */
            padding: 1.852vh;
            border: 0.093vh solid rgba(79, 195, 247, 0.2);
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 1.852vh 1.042vw;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 1.481vh;
            padding: 1.852vh;
            border: 0.093vh solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            /* 设计稿 24px / 1080px * 100vh */
            font-size: 2.222vh;
            margin-bottom: 1.389vh;
        }

        .card-value {
            color: #fff;
            /* 设计稿 56px / 1080px * 100vh */
            font-size: 5.185vh;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            min-height: 13.889vh;
            margin-top: 1.5vh;
            border-radius: 0.741vh;
        }

        /* 代码展示区域 */
        .code-panel {
            margin-top: 2vh;
            background: rgba(0, 0, 0, 0.5);
            padding: 1.5vh;
            border-radius: 1vh;
            font-family: 'Courier New', monospace;
            font-size: 1.3vh;
            color: #a5d6a7;
            overflow: hidden;
        }

        .notice {
            position: fixed;
            bottom: 2vh;
            left: 2vw;
            right: 22vw;
            background: rgba(244, 67, 54, 0.2);
            border: 1px solid #f44336;
            color: #f44336;
            padding: 1.5vh 2vw;
            border-radius: 0.8vh;
            font-size: 1.6vh;
        }

        /* 问题演示:文字溢出 */
        .overflow-demo {
            background: rgba(244, 67, 54, 0.1);
            border: 1px dashed #f44336;
            padding: 1vh;
            margin-top: 1vh;
            font-size: 1.5vh;
        }

        /* 使用 CSS 变量简化计算 */
        :root {
            --vh: 1vh;
            --vw: 1vw;
        }

        /* 但这不是完美的解决方案 */
        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 1.667vh;
            line-height: 1.8;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案2: VW/VH 方案</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 无 JS 依赖<br>
            • 利用全部视口空间<br>
            • 无黑边/留白<br><br>
            <div class="cons">✗ 缺点:</div>
            • 计算公式复杂<br>
            • 单位换算容易出错<br>
            • 字体可能过大/过小<br>
            • 宽高比例难以协调
        </div>
        <div class="formula">
            计算公式:<br>
            100px / 1920px * 100vw<br>
            = 5.208vw
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">VW/VH 方案演示 - 满屏无黑边</div>

    <div class="main-content">
        <div class="sidebar">
            <div class="card-title">左侧信息面板</div>
            <p>
                此方案使用 vw/vh 单位代替 px。<br><br>
                虽然页面始终铺满屏幕,但计算复杂。注意右侧卡牌区域在小屏幕上文字可能显得过大。
            </p>

            <div class="code-panel">
                /* 实际开发中的混乱 */<br>
                width: 20.833vw;<br>
                height: 13.889vh;<br>
                font-size: 4.444vh;<br>
                padding: 1.852vh 1.562vw;<br>
                /* 这些数字是怎么来的? */
            </div>

            <div class="overflow-demo">
                <strong>⚠️ 问题演示:</strong><br>
                当屏幕很宽但很矮时,文字按 vh 计算变得极小,而容器按 vw 计算保持宽大,导致内容稀疏。
            </div>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        ⚠️ 缺点演示:调整浏览器窗口为超宽矮屏(如 2560×600),观察字体与容器比例的失调。对比 Scale 方案在这个场景的表现。
    </div>

    <script>
        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 3: Rem 动态计算方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案3: Rem 方案 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <script>
        // Rem 计算逻辑
        (function() {
            const designWidth = 1920;

            function setRem() {
                const winWidth = window.innerWidth;
                // 以设计稿宽度为基准,100rem = 设计稿宽度
                const rem = winWidth / designWidth * 100;
                document.documentElement.style.fontSize = rem + 'px';
            }

            setRem();

            let timer;
            window.addEventListener('resize', function() {
                clearTimeout(timer);
                timer = setTimeout(setRem, 100);
            });
        })();
    </script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 0.2rem;
            border-radius: 0.08rem;
            z-index: 9999;
            width: 3.3rem;
            font-size: 0.073rem;
        }

        .info-panel h3 {
            color: #4fc3f7;
            margin-bottom: 0.062rem;
            font-size: 0.083rem;
        }

        .info-panel .pros-cons {
            margin-bottom: 0.078rem;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .info-panel .current-rem {
            background: #4caf50;
            color: #000;
            padding: 0.05rem;
            border-radius: 0.04rem;
            font-weight: bold;
            margin-top: 0.05rem;
        }

        /* Rem 布局 - 1rem = 设计稿中 100px (在 1920px 宽度下) */
        .header {
            /* 设计稿 100px = 1rem */
            height: 1rem;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            /* 设计稿 48px = 0.48rem */
            font-size: 0.48rem;
            color: #fff;
            text-shadow: 0 0 0.1rem rgba(79, 195, 247, 0.5);
            letter-spacing: 0.08rem;
        }

        .main-content {
            display: flex;
            /* 设计稿 30px = 0.3rem */
            padding: 0.3rem;
            /* 设计稿 20px = 0.2rem */
            gap: 0.2rem;
            /* 总高度 100vh - header */
            min-height: calc(100vh - 1rem);
        }

        .sidebar {
            /* 设计稿 400px = 4rem */
            width: 4rem;
            background: rgba(15, 52, 96, 0.3);
            /* 设计稿 16px = 0.16rem */
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 0.18rem;
            line-height: 1.8;
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 0.2rem;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            font-size: 0.22rem;
            margin-bottom: 0.12rem;
        }

        .card-value {
            color: #fff;
            font-size: 0.56rem;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            min-height: 1.5rem;
            margin-top: 0.15rem;
            border-radius: 0.08rem;
        }

        /* 代码展示 */
        .code-panel {
            margin-top: 0.25rem;
            background: rgba(0, 0, 0, 0.5);
            padding: 0.15rem;
            border-radius: 0.1rem;
            font-family: 'Courier New', monospace;
            font-size: 0.13rem;
            color: #a5d6a7;
        }

        /* 闪烁问题演示 */
        .flash-demo {
            margin-top: 0.2rem;
            padding: 0.15rem;
            background: rgba(255, 152, 0, 0.1);
            border: 1px dashed #ff9800;
            color: #ff9800;
            font-size: 0.15rem;
        }

        .fouc-warning {
            background: rgba(244, 67, 54, 0.2);
            border: 1px solid #f44336;
            color: #f44336;
            padding: 0.15rem;
            margin-top: 0.2rem;
            border-radius: 0.08rem;
            font-size: 0.15rem;
        }

        .notice {
            position: fixed;
            bottom: 0.2rem;
            left: 0.3rem;
            right: 3.6rem;
            background: rgba(255, 152, 0, 0.2);
            border: 1px solid #ff9800;
            color: #ff9800;
            padding: 0.15rem 0.2rem;
            border-radius: 0.08rem;
            font-size: 0.16rem;
        }

        /* FOUC 模拟 - 页面加载时的闪烁 */
        .no-js-fallback {
            display: none;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案3: Rem 方案</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 计算相对直观 (设计稿/100)<br>
            • 兼容性好 (支持 IE)<br>
            • 宽高等比缩放<br><br>
            <div class="cons">✗ 缺点:</div>
            • 依赖 JS 设置根字体<br>
            • 页面加载可能闪烁<br>
            • 高度仍需要特殊处理<br>
            • 设计稿转换工作量大
        </div>
        <div class="current-rem" id="remInfo">
            根字体: 100px<br>
            (1920px 宽度下)
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">REM 方案演示 - JS 动态计算</div>

    <div class="main-content">
        <div class="sidebar">
            <div class="card-title">左侧信息面板</div>
            <p>
                1rem = 设计稿的 100px<br><br>
                在 1920px 宽度的屏幕上,根字体大小为 100px,便于计算转换。
            </p>

            <div class="code-panel">
                // JS 计算根字体 (head 中)<br>
                const rem = winWidth / 1920 * 100;<br>
                html.style.fontSize = rem + 'px';<br><br>
                // CSS 使用<br>
                width: 4rem;  /* = 400px */
            </div>

            <div class="flash-demo">
                <strong>⚠️ 闪烁问题 (FOUC):</strong><br>
                如果 JS 在 head 末尾执行,页面会先按默认 16px 渲染,然后跳动到计算值。
            </div>

            <div class="fouc-warning">
                💡 解决方案:将 rem 计算脚本放在 &lt;head&gt; 最前面,或使用内联 style。
            </div>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        💡 修改窗口大小观察变化。注意:此方案主要处理宽度适配,高度方向元素可能会超出屏幕(尝试将窗口压得很矮)。
    </div>

    <script>
        // 更新 rem 信息
        function updateRemInfo() {
            const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
            document.getElementById('remInfo').innerHTML = `
                根字体: ${rem.toFixed(2)}px<br>
                窗口宽度: ${window.innerWidth}px<br>
                1rem = 设计稿的 100px
            `;
        }

        updateRemInfo();

        let timer;
        window.addEventListener('resize', function() {
            clearTimeout(timer);
            timer = setTimeout(updateRemInfo, 100);
        });

        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 4: 流式/弹性布局方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案4: 流式布局 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 - 使用 px 保持固定大小参考 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
        }

        .info-panel h3 {
            color: #ff9800;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        /* 流式布局 - 使用 % fr auto 等弹性单位 */
        .header {
            height: 10%; /* 百分比高度 */
            min-height: 60px;
            max-height: 120px;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: clamp(24px, 4vw, 48px); /* 流体字体 */
            color: #fff;
            text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
            letter-spacing: 0.5vw;
            padding: 0 5%;
        }

        .main-content {
            display: flex;
            padding: 3%;
            gap: 2%;
            min-height: calc(90% - 60px);
        }

        .sidebar {
            width: 25%; /* 百分比宽度 */
            min-width: 200px;
            max-width: 400px;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: clamp(14px, 1.5vw, 18px);
            line-height: 1.8;
        }

        /* CSS Grid 布局 */
        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
            gap: 20px;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
            min-height: 150px;
        }

        .card-title {
            color: #4fc3f7;
            font-size: clamp(16px, 2vw, 24px);
            margin-bottom: 10px;
        }

        .card-value {
            color: #fff;
            font-size: clamp(28px, 4vw, 56px);
            font-weight: bold;
            white-space: nowrap;
        }

        .mini-chart {
            flex: 1;
            min-height: 100px;
            margin-top: 10px;
            border-radius: 8px;
        }

        /* 问题展示:数据大屏布局崩坏 */
        .problem-demo {
            margin-top: 20px;
            padding: 15px;
            background: rgba(244, 67, 54, 0.1);
            border: 1px dashed #f44336;
            border-radius: 8px;
        }

        .problem-demo h4 {
            color: #f44336;
            margin-bottom: 10px;
        }

        .problem-demo p {
            color: #f44336;
            font-size: 14px;
        }

        /* 视觉偏差对比: */
        .comparison-box {
            display: flex;
            gap: 20px;
            margin-top: 15px;
        }

        .fixed-ratio {
            width: 100px;
            height: 60px;
            background: #4fc3f7;
            display: flex;
            align-items: center;
            justify-content: center;
            color: #000;
            font-size: 12px;
        }

        .fluid-shape {
            width: 20%;
            padding-bottom: 12%;
            background: #ff9800;
            position: relative;
        }

        .fluid-shape span {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: #000;
            font-size: 12px;
            white-space: nowrap;
        }

        .notice {
            position: fixed;
            bottom: 20px;
            left: 20px;
            right: 360px;
            background: rgba(244, 67, 54, 0.2);
            border: 1px solid #f44336;
            color: #f44336;
            padding: 15px 20px;
            border-radius: 8px;
            font-size: 14px;
        }

        .label-tag {
            display: inline-block;
            background: #4caf50;
            color: #fff;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 12px;
            margin-right: 5px;
        }

        .label-tag.warning {
            background: #ff9800;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案4: 流式/弹性布局</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 纯 CSS,无依赖<br>
            • 自然响应式<br>
            • 内容自适应<br><br>
            <div class="cons">✗ 缺点:</div><strong>大屏空间利用率低</strong><br>
            • 图表比例难以控制<br>
            • 无法精确还原设计稿<br>
            • 文字/图形可能变形
        </div>
        <div style="margin-top: 10px; font-size: 12px; color: #999;">
            适合:后台管理系统<br>
            不适合:数据可视化大屏
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">流式布局演示 - 自然伸缩</div>

    <div class="main-content">
        <div class="sidebar">
            <div style="color: #ff9800; font-size: 18px; margin-bottom: 15px;">左侧信息面板</div>
            <p>
                <span class="label-tag">%</span> 百分比宽度<br>
                <span class="label-tag">fr</span> Grid 弹性分配<br>
                <span class="label-tag warning">clamp</span> 流体字体<br><br>

                这个方案使用 CSS 的固有响应式能力。但请注意右侧卡片在宽屏上的变化——它们会无限拉宽!
            </p>

            <div class="problem-demo">
                <h4>⚠️ 大屏场景的问题</h4>
                <p>
                    <strong>问题1:</strong> 宽屏下卡片过度拉伸<br>
                    <strong>问题2:</strong> 图表比例失控<br>
                    <strong>问题3:</strong> 无法精确对齐设计稿像素
                </p>

                <div class="comparison-box">
                    <div class="fixed-ratio">固定比例</div>
                    <div class="fluid-shape"><span>20%宽度</span></div>
                </div>
                <p style="margin-top: 10px; font-size: 12px;">
                    调整窗口宽度,观察流体元素的宽高比例变化。
                </p>
            </div>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        ❌ 调整窗口到超宽屏(≥2560px),观察右侧卡片的变形:宽高比例完全失控,图表变成"矮胖"形状。对比 Scale 方案在这个场景的表现。
    </div>

    <script>
        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 5: 响应式断点方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案5: 响应式断点 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
            max-height: 90vh;
            overflow-y: auto;
        }

        .info-panel h3 {
            color: #e91e63;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .current-breakpoint {
            background: #e91e63;
            color: #fff;
            padding: 10px;
            border-radius: 4px;
            font-weight: bold;
            margin-top: 10px;
            font-size: 16px;
        }

        .breakpoint-list {
            margin-top: 10px;
            font-size: 12px;
        }

        .breakpoint-list div {
            padding: 4px 0;
        }

        .breakpoint-list .active {
            color: #e91e63;
            font-weight: bold;
        }

        /* 断点样式定义 */

        /* 默认/移动端: < 768px */
        .header {
            height: 50px;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 16px;
            color: #fff;
            text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
            letter-spacing: 2px;
        }

        .main-content {
            display: flex;
            flex-direction: column;
            padding: 10px;
            gap: 15px;
        }

        .sidebar {
            width: 100%;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 8px;
            padding: 15px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 14px;
            line-height: 1.6;
        }

        .chart-area {
            display: grid;
            grid-template-columns: 1fr;
            gap: 15px;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 8px;
            padding: 15px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .card-title {
            color: #4fc3f7;
            font-size: 16px;
            margin-bottom: 8px;
        }

        .card-value {
            color: #fff;
            font-size: 28px;
            font-weight: bold;
        }

        .mini-chart {
            height: 100px;
            margin-top: 10px;
            border-radius: 4px;
        }

        /* 平板: 768px - 1200px */
        @media (min-width: 768px) {
            .header {
                height: 70px;
                font-size: 24px;
                letter-spacing: 4px;
            }

            .main-content {
                flex-direction: row;
                padding: 20px;
            }

            .sidebar {
                width: 280px;
                padding: 20px;
            }

            .chart-area {
                grid-template-columns: repeat(2, 1fr);
                flex: 1;
            }

            .card-title {
                font-size: 18px;
            }

            .card-value {
                font-size: 32px;
            }

            .mini-chart {
                height: 100px;
            }
        }

        /* 代码体积问题展示 */
        @media (min-width: 1200px) {
            .header {
                height: 80px;
                font-size: 32px;
                letter-spacing: 6px;
            }

            .main-content {
                padding: 25px;
                gap: 20px;
            }

            .sidebar {
                width: 350px;
            }

            .card-title {
                font-size: 20px;
                margin-bottom: 10px;
            }

            .card-value {
                font-size: 40px;
            }

            .mini-chart {
                height: 120px;
                margin-top: 15px;
            }
        }

        /* 大屏幕: 1920px - 2560px */
        @media (min-width: 1920px) {
            .header {
                height: 100px;
                font-size: 40px;
                letter-spacing: 8px;
            }

            .main-content {
                padding: 30px;
            }

            .sidebar {
                width: 400px;
            }

            .sidebar p {
                font-size: 16px;
            }

            .card-value {
                font-size: 48px;
            }

            .mini-chart {
                height: 150px;
            }
        }

        /* 超大屏幕: > 2560px */
        @media (min-width: 2560px) {
            .header {
                height: 120px;
                font-size: 48px;
            }

            .chart-area {
                grid-template-columns: repeat(4, 1fr);
            }

            .card-value {
                font-size: 56px;
            }
        }

        /* 代码体积问题展示 */
        .code-stats {
            margin-top: 15px;
            padding: 10px;
            background: rgba(244, 67, 54, 0.1);
            border: 1px dashed #f44336;
            border-radius: 4px;
        }

        .code-stats h4 {
            color: #f44336;
            margin-bottom: 5px;
        }

        .code-stats .stat {
            display: flex;
            justify-content: space-between;
            font-size: 12px;
            padding: 3px 0;
        }

        .notice {
            position: fixed;
            bottom: 20px;
            left: 20px;
            right: 360px;
            background: rgba(233, 30, 99, 0.2);
            border: 1px solid #e91e63;
            color: #e91e63;
            padding: 15px 20px;
            border-radius: 8px;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案5: 响应式断点</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 精确控制各分辨率<br>
            • 字体可读性可控<br>
            • 适合固定规格大屏<br><br>
            <div class="cons">✗ 缺点:</div><strong>代码量爆炸</strong><br>
            • 维护困难<br>
            • 无法覆盖所有尺寸<br>
            • CSS 文件体积大
        </div>

        <div class="current-breakpoint" id="currentBP">
            当前断点: 默认/移动端
        </div>

        <div class="breakpoint-list">
            <div data-bp="0">&lt; 768px: 移动端</div>
            <div data-bp="1">768px - 1200px: 平板</div>
            <div data-bp="2">1200px - 1920px: 桌面</div>
            <div data-bp="3">1920px - 2560px: 大屏</div>
            <div data-bp="4">&gt; 2560px: 超大屏</div>
        </div>

        <div class="code-stats">
            <h4>⚠️ 维护成本</h4>
            <div class="stat">
                <span>每个组件</span>
                <span>×5 套样式</span>
            </div>
            <div class="stat">
                <span>10个组件</span>
                <span>= 50套样式</span>
            </div>
            <div class="stat">
                <span>新增分辨率</span>
                <span>全文件修改</span>
            </div>
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">响应式断点演示 - @media 查询</div>

    <div class="main-content">
        <div class="sidebar">
            <div style="color: #e91e63; font-size: 20px; margin-bottom: 15px;">左侧信息面板</div>
            <p>
                调整窗口宽度,观察布局在不同断点的变化。<br><br>

                <strong>可能遇到的问题:</strong><br>
                • 1366px 和 1440px 怎么办?<br>
                • 3840px(4K)应该如何显示?<br>
                • 修改一个组件要改 5 处?
            </p>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        🔧 拖动窗口宽度观察布局变化。注意:即使 "桌面" 断点(1920px)也不是精确还原设计稿,只是 "看起来差不多"。
    </div>

    <script>
        // 检测当前断点
        const breakpoints = [
            { name: '默认/移动端', max: 768 },
            { name: '平板', max: 1200 },
            { name: '桌面', max: 1920 },
            { name: '大屏', max: 2560 },
            { name: '超大屏', max: Infinity }
        ];

        function detectBreakpoint() {
            const width = window.innerWidth;
            let activeIndex = 0;

            for (let i = 0; i < breakpoints.length; i++) {
                if (width < breakpoints[i].max) {
                    activeIndex = i;
                    break;
                }
                activeIndex = i;
            }

            document.getElementById('currentBP').textContent =
                `当前断点: ${breakpoints[activeIndex].name} (${width}px)`;

            // 高亮断点列表
            document.querySelectorAll('.breakpoint-list div').forEach((div, index) => {
                div.classList.toggle('active', index === activeIndex);
            });
        }

        detectBreakpoint();

        let timer;
        window.addEventListener('resize', () => {
            clearTimeout(timer);
            timer = setTimeout(detectBreakpoint, 100);
        });

        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 25, right: 10, bottom: 20, left: 40 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 9 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 9 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 25, right: 10, bottom: 20, left: 45 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 9 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 9, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 5,
                lineStyle: { color: '#e91e63', width: 2 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['30%', '60%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 9 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '70%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 6,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 3 },
                axisTick: { distance: -6, length: 3, lineStyle: { color: '#fff' } },
                splitLine: { distance: -6, length: 8, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -15, fontSize: 8 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 12,
                    offsetCenter: [0, '60%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 6: 混合方案(推荐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案6: 混合方案(推荐) - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <script>
        // 混合方案:Scale + Rem
        (function() {
            const designWidth = 1920;
            const designHeight = 1080;
            const minScale = 0.6; // 最小缩放限制,防止过小

            function adapt() {
                const winW = window.innerWidth;
                const winH = window.innerHeight;

                // Scale 计算
                const scaleX = winW / designWidth;
                const scaleY = winH / designHeight;
                const scale = Math.max(Math.min(scaleX, scaleY), minScale);

                // 应用 scale
                const screen = document.getElementById('screen');
                if (screen) {
                    screen.style.transform = `scale(${scale})`;
                }

                // Rem 计算 - 根据缩放比例调整根字体
                // 当 scale < 1 时,增加根字体补偿
                const baseRem = 100;
                const fontScale = Math.max(scale, minScale);
                const rem = baseRem * fontScale;

                document.documentElement.style.fontSize = rem + 'px';

                // 更新信息面板
                updateInfo(scale, rem, winW, winH);
            }

            function updateInfo(scale, rem, winW, winH) {
                const info = document.getElementById('mixedInfo');
                if (info) {
                    info.innerHTML = `
                        Scale: ${scale.toFixed(3)}<br>
                        Rem: ${rem.toFixed(1)}px<br>
                        窗口: ${winW}×${winH}<br>
                        最小限制: ${minScale}
                    `;
                }
            }

            // 页面加载前执行
            adapt();

            // 防抖 resize
            let timer;
            window.addEventListener('resize', () => {
                clearTimeout(timer);
                timer = setTimeout(adapt, 100);
            });

            // 暴露全局供切换模式使用
            window.adaptMode = 'mixed'; // mixed, scale-only, rem-only

            window.setAdaptMode = function(mode) {
                window.adaptMode = mode;

                const screen = document.getElementById('screen');
                const designWidth = 1920;
                const designHeight = 1080;
                const winW = window.innerWidth;
                const winH = window.innerHeight;

                if (mode === 'scale-only') {
                    const scale = Math.min(winW / designWidth, winH / designHeight);
                    screen.style.transform = `scale(${scale})`;
                    document.documentElement.style.fontSize = '100px';
                } else if (mode === 'rem-only') {
                    screen.style.transform = 'none';
                    const rem = winW / designWidth * 100;
                    document.documentElement.style.fontSize = rem + 'px';
                } else {
                    adapt();
                }
            };

            // 初始化覆盖
            window.setAdaptMode('mixed');
        })();
    </script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            overflow: hidden;
            background: #0a0a1a;
        }

        /* 信息面板 - 固定不缩放 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.9);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 340px;
            font-size: 14px;
            border: 1px solid #4caf50;
        }

        .info-panel h3 {
            color: #4caf50;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .recommend {
            background: #4caf50;
            color: #000;
            padding: 5px 10px;
            border-radius: 4px;
            font-size: 12px;
            font-weight: bold;
            display: inline-block;
            margin-bottom: 10px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #fff176;
        }

        .mode-switcher {
            margin-top: 15px;
            padding-top: 15px;
            border-top: 1px solid #333;
        }

        .mode-switcher h4 {
            color: #4fc3f7;
            margin-bottom: 10px;
        }

        .mode-btn {
            display: block;
            width: 100%;
            padding: 8px 12px;
            margin-bottom: 6px;
            background: #333;
            color: #fff;
            border: 1px solid #555;
            border-radius: 4px;
            cursor: pointer;
            text-align: left;
            font-size: 12px;
        }

        .mode-btn:hover {
            background: #444;
        }

        .mode-btn.active {
            background: #4caf50;
            border-color: #4caf50;
            color: #000;
        }

        .info-value {
            background: #2196f3;
            color: #fff;
            padding: 10px;
            border-radius: 4px;
            margin-top: 10px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
        }

        /* 大屏容器 */
        .screen-container {
            width: 1920px;
            height: 1080px;
            transform-origin: 0 0;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            position: absolute;
            overflow: hidden;
            top: 0;
            left: 0;
        }

        /* 居中显示 */
        .centered {
            transition: transform 0.3s ease, left 0.3s ease, top 0.3s ease;
        }

        /* 大屏内容样式 - 使用 rem */
        .header {
            height: 1rem;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 0.5rem;
            color: #fff;
            text-shadow: 0 0 0.1rem rgba(79, 195, 247, 0.5);
            letter-spacing: 0.08rem;
        }

        .main-content {
            display: flex;
            padding: 0.3rem;
            gap: 0.2rem;
            height: calc(100% - 1rem);
        }

        .sidebar {
            width: 4rem;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 0.18rem;
            line-height: 1.8;
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 0.2rem;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            font-size: 0.22rem;
            margin-bottom: 0.08rem;
        }

        .card-value {
            color: #fff;
            font-size: 0.56rem;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            min-height: 1.5rem;
            margin-top: 0.15rem;
            border-radius: 0.08rem;
        }

        /* 特性说明卡片 */
        .feature-card {
            margin-top: 0.15rem;
            padding: 0.15rem;
            background: rgba(76, 175, 80, 0.1);
            border: 1px solid #4caf50;
            border-radius: 0.1rem;
        }

        .feature-card h4 {
            color: #4caf50;
            font-size: 0.18rem;
            margin-bottom: 0.05rem;
        }

        .feature-card p {
            font-size: 0.14rem;
            color: rgba(255,255,255,0.8);
        }

        /* 对比指示器 */
        .compare-indicator {
            position: fixed;
            bottom: 20px;
            left: 20px;
            background: rgba(0, 0, 0, 0.9);
            padding: 15px 20px;
            border-radius: 8px;
            color: #fff;
            border-left: 4px solid #4caf50;
        }

        .compare-indicator h4 {
            color: #4caf50;
            margin-bottom: 5px;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <div class="recommend">⭐ 生产环境推荐</div>
        <h3>方案6: 混合方案</h3>
        <div class="pros-cons">
            <div class="pros">✓ 结合 Scale + Rem 优点</div>
            • 等比例缩放保证布局<br>
            • 字体最小值防止过小<br>
            • 大屏清晰、小屏可读<br><br>
            <div class="cons">△ 注意:</div>
            • 需要 JS 支持<br>
            • 计算逻辑稍复杂
        </div>

        <div class="mode-switcher">
            <h4>模式切换对比:</h4>
            <button class="mode-btn active" onclick="switchMode('mixed', this)">
                🌟 混合方案 (推荐)
            </button>
            <button class="mode-btn" onclick="switchMode('scale-only', this)">
                📐 纯 Scale (字体过小)
            </button>
            <button class="mode-btn" onclick="switchMode('rem-only', this)">
                🔤 纯 Rem (可能变形)
            </button>
        </div>

        <div class="info-value" id="mixedInfo">
            Scale: 1.0<br>
            Rem: 100px<br>
            窗口: 1920×1080
        </div>
    </div>

    <!-- 对比指示器 -->
    <div class="compare-indicator">
        <h4>💡 对比技巧</h4>
        <p>1. 切换到"纯 Scale",缩小窗口,观察字体变小</p>
        <p>2. 切换回"混合方案",字体有最小值限制</p>
        <p>3. 调整到4K屏,观察布局比例保持与设计稿一致</p>
    </div>

    <!-- 大屏容器 -->
    <div class="screen-container centered" id="screen">
        <div class="header">混合方案演示 - Scale + Rem 双重保障</div>

        <div class="main-content">
            <div class="sidebar">
                <div style="color: #4caf50; font-size: 0.24rem; margin-bottom: 0.15rem;">混合方案说明</div>
                <p>
                    此方案结合 Scale 的视觉一致性 和 Rem 的灵活性。<br><br>

                    <strong>核心算法:</strong><br>
                    1. 计算 screen 的 scale 比例<br>
                    2. 根字体 = baseFont * max(scale, minLimit)<br>
                    3. 所有尺寸使用 rem 单位<br><br>

                    这样既保持设计稿比例,又确保文字可读。
                </p>

                <div class="feature-card">
                    <h4>🎯 最小字体保护</h4>
                    <p>当屏幕缩小时,字体不会无限缩小,保证基本可读性。</p>
                </div>

                <div class="feature-card">
                    <h4>📐 严格比例保持</h4>
                    <p>图表、卡片的宽高比例严格遵循设计稿,无变形。</p>
                </div>
            </div>

            <div class="chart-area">
                <div class="card">
                    <div class="card-title">实时用户数</div>
                    <div class="card-value">128,456</div>
                    <div class="mini-chart" id="chart1"></div>
                </div>
                <div class="card">
                    <div class="card-title">交易金额</div>
                    <div class="card-value">¥2.3M</div>
                    <div class="mini-chart" id="chart2"></div>
                </div>
                <div class="card">
                    <div class="card-title">系统负载</div>
                    <div class="card-value">68%</div>
                    <div class="mini-chart" id="chart3"></div>
                </div>
                <div class="card">
                    <div class="card-title">响应时间</div>
                    <div class="card-value">23ms</div>
                    <div class="mini-chart" id="chart4"></div>
                </div>
            </div>
        </div>
    </div>

    <script>
        function switchMode(mode, btn) {
            window.setAdaptMode(mode);

            // 更新按钮状态
            document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');

            // 更新位置信息
            updatePosition();
        }

        function updatePosition() {
            const screen = document.getElementById('screen');
            const winW = window.innerWidth;
            const winH = window.innerHeight;
            const designW = 1920;
            const designH = 1080;

            // 获取当前 scale
            const transform = getComputedStyle(screen).transform;
            let scale = 1;
            if (transform && transform !== 'none') {
                const matrix = transform.match(/matrix\(([^)]+)\)/);
                if (matrix) {
                    const values = matrix[1].split(',').map(parseFloat);
                    scale = values[0];
                }
            }

            // 居中计算
            const left = (winW - designW * scale) / 2;
            const top = (winH - designH * scale) / 2;

            screen.style.left = Math.max(0, left) + 'px';
            screen.style.top = Math.max(0, top) + 'px';
        }

        // 初始化位置
        window.addEventListener('load', updatePosition);
        window.addEventListener('resize', updatePosition);

        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>
/* 使用 rem 单位编写样式 */
.header {
    height: 1rem;           /* 设计稿 100px */
    font-size: 0.5rem;      /* 设计稿 50px */
    letter-spacing: 0.08rem;
}

.sidebar {
    width: 4rem;            /* 设计稿 400px */
    padding: 0.2rem;        /* 设计稿 20px */
}

.card-title {
    font-size: 0.22rem;     /* 设计稿 22px */
}

.card-value {
    font-size: 0.56rem;     /* 设计稿 56px */
}

完整 Demo 文件列表:

  • demo1 - Scale 等比例缩放(上方已提供完整代码)
  • demo2 - VW/VH 视口单位方案
  • demo3 - Rem 动态计算方案
  • demo4 - 流式/弹性布局方案
  • demo5 - 响应式断点方案
  • demo6 - 混合方案(推荐)

以上所有demo均可在本地环境中直接运行,建议按顺序体验对比各方案的表现差异

核心代码示例

Scale 方案核心代码

<!DOCTYPE html>
<html>
<head>
  <style>
    .screen-container {
      width: 1920px;
      height: 1080px;
      transform-origin: 0 0;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
    }
  </style>
</head>
<body>
  <div class="screen-container" id="screen">
    <!-- 大屏内容 -->
  </div>

  <script>
    const DESIGN_WIDTH = 1920;
    const DESIGN_HEIGHT = 1080;

    function setScale() {
      const scaleX = window.innerWidth / DESIGN_WIDTH;
      const scaleY = window.innerHeight / DESIGN_HEIGHT;
      const scale = Math.min(scaleX, scaleY);

      const screen = document.getElementById('screen');
      screen.style.transform = `scale(${scale})`;

      // 居中显示
      const left = (window.innerWidth - DESIGN_WIDTH * scale) / 2;
      const top = (window.innerHeight - DESIGN_HEIGHT * scale) / 2;
      screen.style.left = left + 'px';
      screen.style.top = top + 'px';
    }

    setScale();
    window.addEventListener('resize', setScale);
  </script>
</body>
</html>

混合方案核心代码

// 混合方案:Scale + Rem
const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;
const MIN_SCALE = 0.6; // 最小缩放限制,防止过小

function adapt() {
  const winW = window.innerWidth;
  const winH = window.innerHeight;

  // Scale 计算
  const scaleX = winW / DESIGN_WIDTH;
  const scaleY = winH / DESIGN_HEIGHT;
  const scale = Math.max(Math.min(scaleX, scaleY), MIN_SCALE);

  // 应用 scale
  const screen = document.getElementById('screen');
  screen.style.transform = `scale(${scale})`;

  // Rem 计算 - 根据缩放比例调整根字体
  // 当 scale < 1 时,增加根字体补偿
  const baseRem = 100;
  const fontScale = Math.max(scale, MIN_SCALE);
  const rem = baseRem * fontScale;

  document.documentElement.style.fontSize = rem + 'px';
}

adapt();
window.addEventListener('resize', adapt);

总结

大屏适配没有银弹,每种方案都有其适用场景:

  1. 简单大屏项目:使用 Scale 方案,快速实现
  2. 内容管理系统:使用流式布局,灵活适配
  3. 移动端优先:使用 Rem 方案,成熟稳定
  4. 多端统一:使用混合方案,兼顾体验

选择方案时需要综合考虑:

  • 设计稿还原要求
  • 目标设备规格
  • 开发维护成本
  • 团队技术栈

希望本文能帮助你在大屏开发中游刃有余!


如果觉得有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

手撕 Claude Code-4: TodoWrite 与任务系统

作者 唐旺仔
2026年4月18日 16:05

第 4 章:TodoWrite 与任务系统

源码位置:src/tools/TodoWriteTool/src/tasks/src/utils/tasks.ts


4.1 两个容易混淆的概念

Claude Code 中有两个相关但不同的"任务"概念:

概念 说明 存储位置
Todo(任务清单) Claude 在当前会话中规划的工作步骤 AppState.todos
Task(系统任务) 运行时管理的实体(子代理、Shell 命令、后台会话等) AppState.tasks

本章先讲 TodoWrite(任务清单),再讲 Task System(系统任务)。


4.2 TodoWrite:Claude 的工作计划本

4.2.1 数据模型

// src/utils/todo/types.ts
type TodoItem = {
  content: string                                    // 任务描述
  status: 'pending' | 'in_progress' | 'completed'
  activeForm: string                                 // 任务的主动形式(如 "Reading src/foo.ts")
}

type TodoList = TodoItem[]

4.2.2 工具实现

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:65

async call({ todos }, context) {
  const appState = context.getAppState()
  
  // 每个代理有自己的 todo 列表(用 agentId 或 sessionId 区分)
  const todoKey = context.agentId ?? getSessionId()
  const oldTodos = appState.todos[todoKey] ?? []
  
  // 关键逻辑:如果所有任务都已完成,AppState 中清空为 []
  const allDone = todos.every(_ => _.status === 'completed')
  const newTodos = allDone ? [] : todos

  // 验证提醒:仅当 allDone && 3+ 项 && 无验证步骤时才触发
  let verificationNudgeNeeded = false
  if (
    feature('VERIFICATION_AGENT') &&
    getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
    !context.agentId &&   // 只在主线程触发,不在子代理
    allDone &&            // 必须是关闭列表的那一刻
    todos.length >= 3 &&
    !todos.some(t => /verif/i.test(t.content))
  ) {
    verificationNudgeNeeded = true
  }

  // 注意:API 是 setAppState(接收 prev → newState 函数),不是 updateAppState
  context.setAppState(prev => ({
    ...prev,
    todos: { ...prev.todos, [todoKey]: newTodos },
  }))

  // 返回值中 newTodos 是原始输入 todos(非清空后的 [])
  return {
    data: { oldTodos, newTodos: todos, verificationNudgeNeeded },
  }
}

4.2.3 关键设计点

1. 所有完成即清空

当所有 todo 都标记为 completed,AppState 中的列表被清空为 [] 而不是保留所有已完成项。这避免了 token 浪费(每次 API 调用都要序列化已完成的任务)。注意:工具返回值中的 newTodos 字段仍为原始 todos 输入,清空仅影响 AppState 存储层。

2. agentId 隔离

每个子代理有独立的 todo 列表(通过 agentId 作为 key)。主线程用 sessionId。这样并行运行的子代理不会互相干扰。

3. shouldDefer: true

TodoWrite 被标记为延迟披露,不在每次 API 请求中直接包含其 Schema。Claude 通过 ToolSearch 找到它。

4. 与 TodoV2(Task API)互斥

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:53 中:

isEnabled() {
  return !isTodoV2Enabled()
}

源码位置:src/utils/tasks.ts:133 isTodoV2Enabled() 在以下情况返回 true(即 TodoWrite 被禁用):

  • 环境变量 CLAUDE_CODE_ENABLE_TASKS=1
  • 非交互式会话(SDK 模式)

这意味着 SDK 用户默认使用 Task API 而非 TodoWrite。

5. verificationNudgeNeeded

这是一个"行为引导"机制。触发条件(需同时满足):

  • Feature flag VERIFICATION_AGENT 已开启(编译时 gate)
  • GrowthBook flag tengu_hive_evidence 为 true
  • 当前在主线程(!context.agentId
  • 当前调用正在关闭整个列表(allDone === true
  • 任务数 ≥ 3
  • 没有任何任务内容匹配 /verif/i

满足时,工具结果会追加提示,要求 Claude 在写最终摘要前先 spawn 验证代理。


4.3 Task System:运行时实体管理

Task System 管理着 Claude Code 中所有的"运行中的实体"。

4.3.0 TaskStateBase:所有任务的公共基础

源码位置:src/Task.ts:45

type TaskStateBase = {
  id: string           // 任务 ID(带类型前缀,见下表)
  type: TaskType       // 'local_bash' | 'local_agent' | 'remote_agent' | 'in_process_teammate' | 'local_workflow' | 'monitor_mcp' | 'dream'
  status: TaskStatus   // 'pending' | 'running' | 'completed' | 'failed' | 'killed'
  description: string
  toolUseId?: string   // 触发此任务的 tool_use block ID(用于通知回调)
  startTime: number
  endTime?: number
  totalPausedMs?: number
  outputFile: string   // 磁盘输出文件路径(快照,/clear 后不变)
  outputOffset: number // 已读取字节偏移量(用于增量 delta 读取)
  notified: boolean    // 是否已发送完成通知(防止重复通知)
}

Task ID 前缀表(src/Task.ts:79):

类型 前缀 示例
local_bash b b3f9a2c1
local_agent a a7d8e4f2
remote_agent r r2a1b3c4
in_process_teammate t t5e6f7a8
local_workflow w w9b0c1d2
monitor_mcp m m3e4f5a6
dream d d7b8c9d0
主会话(特殊) s s1a2b3c4

local_agent 和主会话后台化共用 a 前缀(LocalAgentTaskState),但主会话后台化通过 LocalMainSessionTask.ts 中独立的 generateMainSessionTaskId() 使用 s 前缀,与普通子代理区分。

4.3.1 TaskState 联合类型

源码位置:src/tasks/types.ts

// 注意:类型名是 TaskState,不是 Task
type TaskState =
  | LocalShellTaskState        // 长运行 Shell 命令
  | LocalAgentTaskState        // 本地子代理(含后台化主会话)
  | RemoteAgentTaskState       // 远程代理
  | InProcessTeammateTaskState // in-process 队友(团队协作)
  | LocalWorkflowTaskState     // 工作流
  | MonitorMcpTaskState        // MCP 监控
  | DreamTaskState             // 记忆整合子代理(auto-dream)

注意LocalMainSessionTask 不是独立的联合类型成员。它是 LocalAgentTaskState 的子类型:

// src/tasks/LocalMainSessionTask.ts
type LocalMainSessionTaskState = LocalAgentTaskState & { agentType: 'main-session' }

这意味着后台化主会话与普通子代理共用同一个 type: 'local_agent' 标识符,通过 agentType 字段区分。

4.3.2 主会话后台化(LocalMainSessionTask)

这是一个特殊功能:用户按 Ctrl+B 两次可以将当前对话"后台化",然后开始新的对话。后台化后,原查询继续在后台独立运行。

源码位置:src/tasks/LocalMainSessionTask.ts

// LocalMainSessionTaskState 不是独立类型,而是 LocalAgentTaskState 的子类型
type LocalMainSessionTaskState = LocalAgentTaskState & {
  agentType: 'main-session'  // 唯一区分标识
}
// type: 'local_agent'(继承)
// taskId: 's' 前缀(区分普通代理的 'a' 前缀)
// messages?: Message[](继承,存储后台查询的消息历史)
// isBackgrounded: boolean(继承,true = 后台,false = 前台展示中)
// 无独立 transcript 字段

关键函数:

// 注册后台会话任务,返回 { taskId, abortSignal }
registerMainSessionTask(description, setAppState, agentDefinition?, abortController?)

// 启动真正的后台查询(wrap runWithAgentContext + query())
startBackgroundSession({ messages, queryParams, description, setAppState })

// 将后台任务切回前台展示
foregroundMainSessionTask(taskId, setAppState): Message[]

为什么需要隔离的转录路径? 后台任务使用 getAgentTranscriptPath(agentId) 而不是主会话的路径。若用同一路径,/clear 会意外覆盖后台会话数据。后台任务通过 initTaskOutputAsSymlink() 创建软链接,/clear 重链接时不影响后台任务的历史记录。

4.3.3 本地代理任务(LocalAgentTask)

每个子代理对应一个 LocalAgentTaskState

源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:116

type LocalAgentTaskState = TaskStateBase & {
  type: 'local_agent'
  agentId: string
  prompt: string
  selectedAgent?: AgentDefinition
  agentType: string          // 区分子类型,'main-session' = 后台化主会话
  model?: string
  abortController?: AbortController
  error?: string
  result?: AgentToolResult
  progress?: AgentProgress   // 进度信息(包含 toolUseCount、tokenCount)
  retrieved: boolean         // 结果是否已被取回
  messages?: Message[]       // 代理的消息历史(UI 展示用)
  lastReportedToolCount: number
  lastReportedTokenCount: number
  isBackgrounded: boolean    // false = 前台展示中,true = 后台运行
  pendingMessages: string[]  // SendMessage 排队的消息,在工具轮边界处理
  retain: boolean            // UI 持有此任务(阻止驱逐)
  diskLoaded: boolean        // 是否已从磁盘加载 sidechain JSONL
  evictAfter?: number        // 驱逐时间戳(任务完成后设置)
}

注意:文档中常见误写 toolUseCount 为 LocalAgentTaskState 的直接字段,实际它在 progress.toolUseCount 中。status 字段继承自 TaskStateBase'running' | 'completed' | 'failed' | 'stopped')。

进度追踪通过专门的辅助函数:

// src/tasks/LocalAgentTask/LocalAgentTask.tsx
createProgressTracker()           // 初始化 ProgressTracker(分别追踪 input/output tokens)
updateProgressFromMessage()       // 从 assistant message 累积 token 和工具调用数
getProgressUpdate()               // 生成 AgentProgress 快照(供 UI 消费)
createActivityDescriptionResolver() // 通过 tool.getActivityDescription() 生成人类可读描述

Token 追踪的精妙设计ProgressTracker 分开存储 latestInputTokens(Claude API 累积值,取最新)和 cumulativeOutputTokens(逐轮累加),避免重复计数。

4.3.4 in-process 队友任务(InProcessTeammateTask)

这是 Agent Teams 的核心数据结构:

src/tasks/InProcessTeammateTask/types.ts

type TeammateIdentity = {
  agentId: string        // e.g., "researcher@my-team"
  agentName: string      // e.g., "researcher"
  teamName: string
  color?: string
  planModeRequired: boolean
  parentSessionId: string  // Leader 的 sessionId
}

type InProcessTeammateTaskState = TaskStateBase & {
  type: 'in_process_teammate'
  identity: TeammateIdentity        // 队友身份(存储在 AppState 中的 plain data)
  prompt: string
  model?: string
  selectedAgent?: AgentDefinition
  abortController?: AbortController         // 终止整个队友
  currentWorkAbortController?: AbortController  // 终止当前轮次
  awaitingPlanApproval: boolean             // 是否等待 plan 审批
  permissionMode: PermissionMode            // 独立权限模式(Shift+Tab 切换)
  error?: string
  result?: AgentToolResult
  progress?: AgentProgress
  messages?: Message[]              // UI 展示用(上限 50 条,TEAMMATE_MESSAGES_UI_CAP)
  pendingUserMessages: string[]     // 查看该队友时用户输入的队列消息
  isIdle: boolean                   // 是否处于空闲(等待 leader 指令)
  shutdownRequested: boolean        // 是否已请求关闭
  lastReportedToolCount: number
  lastReportedTokenCount: number
}

常见误解mailbox 字段不存在于 InProcessTeammateTaskState。队友间的通信邮箱存储在运行时上下文 teamContext.inProcessMailboxes(AsyncLocalStorage 中),不在 AppState 里。pendingUserMessages 是用户从 UI 发给该队友的消息队列,与邮箱是两回事。

内存上限设计messages 字段上限 50 条(TEAMMATE_MESSAGES_UI_CAP),超出后从头部截断。原因是生产环境出现过单个 whale session 启动 292 个 agent、内存达 36.8GB 的情况,根本原因正是此字段持有第二份完整消息副本。

4.3.5 记忆整合任务(DreamTask)

源码位置:src/tasks/DreamTask/DreamTask.ts

type DreamTaskState = TaskStateBase & {
  type: 'dream'
  phase: 'starting' | 'updating'    // starting → updating(首个 Edit/Write 后翻转)
  sessionsReviewing: number          // 正在整理的会话数量
  filesTouched: string[]             // 被 Edit/Write 触碰的文件(不完整,仅 pattern-match 到的)
  turns: DreamTurn[]                 // assistant 轮次(工具调用折叠为计数)
  abortController?: AbortController
  priorMtime: number                 // 用于 kill 时回滚 consolidationLock 时间戳
}

DreamTask 是"auto-dream"记忆整合的 UI 表面层。它不改变子代理运行逻辑,只是让原本不可见的 fork agent 在 footer pill 和 Shift+Down 对话框中可见。子代理按 4 阶段 prompt 运行(orient → gather → consolidate → prune),但 DreamTask 不解析阶段,只通过工具调用类型推断 phase。


4.4 任务注册与生命周期框架

源码位置:src/utils/task/framework.ts

4.4.1 registerTask:注册与恢复

registerTask(task: TaskState, setAppState): void

注册一个新任务时,有两条路径:

新建existing === undefined):

  1. 将 task 写入 AppState.tasks[task.id]
  2. 向 SDK 事件队列发出 task_started 事件

恢复/替换existing !== undefined,如 resumeAgentBackground):

  1. 合并保留以下 UI 状态(避免用户正在查看的面板闪烁):
    • retain:UI 持有标记
    • startTime:面板排序稳定性
    • messages:用户刚发送的消息还未落盘
    • diskLoaded:避免重复加载 sidechain JSONL
    • pendingMessages:待处理消息队列
  2. 发出 task_started(防止 SDK 重复计数)

4.4.2 任务完成通知(XML 格式)

子代理/后台任务完成时,通过 enqueuePendingNotification 将 XML 推入消息队列:

<task_notification>
  <task_id>a7d8e4f2</task_id>
  <tool_use_id>toolu_01xxx</tool_use_id>  <!-- 可选 -->
  <output_file>/tmp/.../tasks/a7d8e4f2.output</output_file>
  <status>completed</status>
  <summary>Task "修复登录 bug" completed successfully</summary>
</task_notification>

这段 XML 在下一轮 API 调用前作为 user 消息被注入 messages,使 LLM 感知到后台任务完成。

4.4.3 evictTerminalTask:两级驱逐

任务完成后并不立即从 AppState.tasks 中删除,而是两级驱逐:

任务完成 → status='completed' + notified=true
  │
  ├─ 如果 retain=true 或 evictAfter > Date.now()
  │    → 保留(UI 正在展示,等待 30s grace period 后驱逐)
  │
  └─ 否则 → 立即从 AppState.tasks 中删除(eagerly evict)
               作为保底,generateTaskAttachments() 也会在下次 poll 时驱逐

PANEL_GRACE_MS = 30_000(30秒)是 coordinator panel 中 agent 任务的展示宽限期,确保用户能看到结果后再消失。


4.5 后台 API 任务工具

用户(通过 Claude)可以创建和管理后台任务:

// TaskCreateTool / TaskUpdateTool / TaskStopTool / TaskGetTool / TaskListTool

这些工具允许 Claude 自己创建和监控后台任务,实现真正的异步多任务处理。注意:这套 Task API 工具仅在 TodoV2 模式下启用(即 isTodoV2Enabled() === true),与 TodoWrite 互斥。


4.6 任务输出的磁盘管理

源码位置:src/utils/task/diskOutput.ts

4.6.1 输出文件路径

// 注意:路径不是 .claude/tasks/,而是项目临时目录下的会话子目录
getTaskOutputPath(taskId) → `{projectTempDir}/{sessionId}/tasks/{taskId}.output`

为什么包含 sessionId? 防止同一项目的并发 Claude Code 会话互相踩踏输出文件。路径在首次调用时被 memoize(let _taskOutputDir),/clear 触发 regenerateSessionId() 时不会重新计算,确保跨 /clear 存活的后台任务仍能找到自己的文件。

4.6.2 DiskTaskOutput 写队列

任务输出通过 DiskTaskOutput 类异步写入磁盘:

class DiskTaskOutput {
  append(content: string): void  // 入队,自动触发 drain
  flush(): Promise<void>         // 等待队列清空
  cancel(): void                 // 丢弃队列(任务被 kill 时)
}

核心设计要点

  1. 写队列#queue: string[] 平铺数组,单个 drain 循环消费,chunk 写入后立即可被 GC,避免 .then() 链持有引用导致内存膨胀

  2. 5GB 上限:超限后追加截断标记并停止写入:

    [output truncated: exceeded 5GB disk cap]
    
  3. O_NOFOLLOW 安全:Unix 上用 O_NOFOLLOW flag 打开文件,防止沙箱中的攻击者通过创建软链接将 Claude Code 写入任意宿主文件

  4. 事务追踪:所有 fire-and-forget 异步操作(initTaskOutputevictTaskOutput 等)注册到 _pendingOps: Set<Promise>,测试可通过 allSettled 等待全部完成,防止跨测试的 ENOENT 竞争

4.6.3 增量输出读取(OutputOffset)

TaskStateBase.outputOffset 记录已消费的字节偏移,实现增量读取:

// 仅读取 fromOffset 之后的新内容(最多 8MB)
getTaskOutputDelta(taskId, fromOffset): Promise<{ content: string; newOffset: number }>

framework.ts 中的 generateTaskAttachments() 在每次 poll 时调用 getTaskOutputDelta,将新增内容附加到 task_status attachment 中推送给 LLM,避免重复加载完整输出文件。

4.6.4 关键函数对比

函数 是否删磁盘文件 是否清内存 适用场景
evictTaskOutput(taskId) 是(flush 后清 Map) 任务完成,结果已消费
cleanupTaskOutput(taskId) 彻底清理(测试、取消)
flushTaskOutput(taskId) 读取前确保写入完成

4.7 Cron 定时任务

通过 Cron 工具,可以创建定期自动运行的任务:

// CronCreate / CronDelete / CronList 工具
// 底层通过 src/utils/cron.ts 管理

// 使用示例(Claude 会这样调用):
// CronCreate({ schedule: '0 9 * * 1', command: '/review-pr' })
// → 每周一早上 9 点自动运行 /review-pr

4.8 流程图:任务的完整生命周期

用户输入复杂任务
      │
      ▼
Claude 调用 TodoWrite         ← 规划工作步骤
  todos: [
    { content: '读取代码', status: 'pending' },
    { content: '修改功能', status: 'pending' },
    { content: '运行测试', status: 'pending' },
  ]
      │
      ▼
Claude 开始执行第一步
TodoWrite: { status: 'in_progress' }  ← 标记进行中
      │
      ├─ 如果需要并行工作:
      │    Agent({ subagent_type: 'general-purpose', ... })
      │         │
      │         ▼
      │    创建 LocalAgentTaskState(id: 'a-xxxxxxxx',agentId 同值)
      │    子代理在独立上下文中运行
      │         │
      │         ▼
      │    子代理有自己的 Todo 列表(隔离)
      │
      ▼
每步完成后:
TodoWrite: { status: 'completed' }    ← 标记完成
      │
      ▼
所有步骤完成:
TodoWrite: todos.every(done) → 清空列表 []
      │
      ▼
Agent Loop 检测到无工具调用 → 停止

小结

组件 职责 源码位置
TodoWriteTool 会话内工作计划追踪(交互模式) src/tools/TodoWriteTool/TodoWriteTool.ts
Task API 工具 后台任务创建与管理(SDK/非交互模式) TaskCreateTool / TaskStopTool
TaskStateBase 所有任务的公共字段(含 id、status、outputOffset) src/Task.ts
LocalMainSessionTask 主会话后台化(LocalAgentTaskState 子类型) src/tasks/LocalMainSessionTask.ts
LocalAgentTaskState 子代理生命周期管理 src/tasks/LocalAgentTask/LocalAgentTask.tsx
InProcessTeammateTaskState 团队队友状态(含内存上限 50 条) src/tasks/InProcessTeammateTask/types.ts
DreamTaskState 记忆整合子代理的 UI 表面层 src/tasks/DreamTask/DreamTask.ts
任务框架 registerTask / evictTerminalTask / 通知 XML src/utils/task/framework.ts
磁盘输出管理 DiskTaskOutput 写队列 / 增量读取 / 5GB 上限 src/utils/task/diskOutput.ts
Cron 定时任务 自动化周期任务 CronCreate/Delete/List 工具

关键设计约定总结

约定 说明
LocalMainSessionTask 不是独立类型 它是 LocalAgentTaskState & { agentType: 'main-session' }
TaskState(不是 Task) 联合类型的正确名称
setAppState(不是 updateAppState AppState 更新的实际 API
toolUseCountprogress 不是 LocalAgentTaskState 的直接字段
TodoWrite 与 Task API 互斥 通过 isTodoV2Enabled()isEnabled() 中切换

【节点】[DDY节点]原理解析与实际应用

作者 SmalBox
2026年4月18日 15:52

【Unity Shader Graph 使用与特效实现】专栏-直达

DDY 节点是 Unity URP Shader Graph 中一个重要的高级功能节点,它提供了在像素着色器阶段计算屏幕空间 Y 方向偏导数的能力。这个节点基于 GPU 的导数计算硬件,能够高效地获取相邻像素间的数值变化率,在计算机图形学中有着广泛的应用场景。

偏导数的概念源自微积分,在图形学上下文中,它表示某个值在屏幕空间相邻像素间的变化率。DDY 节点专门计算 Y 方向(垂直方向)的变化率,而与之对应的 DDX 节点则计算 X 方向(水平方向)的变化率。这两个节点共同构成了现代 GPU 并行架构中导数计算的核心功能。

在 Shader Graph 中使用 DDY 节点时,理解其工作原理和限制条件至关重要。由于该节点依赖于像素着色器中的片段并行处理特性,它只能在特定的渲染阶段使用,并且对硬件有一定的要求。掌握 DDY 节点的正确使用方法,能够为着色器开发带来更多可能性,实现各种高级视觉效果。

描述

DDY 节点的核心功能是计算输入值在屏幕空间 Y 坐标方向上的偏导数。从数学角度理解,偏导数描述了多变量函数沿某一坐标轴方向的变化率。在着色器编程的语境中,这意味着 DDY 节点能够测量某个着色器属性或计算值在垂直相邻像素之间的差异。

屏幕空间偏导数的计算基于 GPU 的硬件特性。现代 GPU 通常以 2x2 像素块为单位并行执行像素着色器,这种架构被称为"像素四边形"(Pixel Quad)。在这种结构中,DDY 节点通过比较当前像素与同一像素四边形中下方像素的数值差异来计算偏导数。这种硬件级的并行计算使得导数计算非常高效,不需要额外的复杂数学运算。

DDY 节点的一个重要限制是它只能在像素着色器阶段使用。这是因为导数计算依赖于片段着色器中的像素级并行处理。如果尝试在顶点着色器或其他渲染阶段使用 DDY 节点,将会导致编译错误或未定义的行为。在 Shader Graph 中,当将 DDY 节点连接到非像素着色器阶段的节点时,系统通常会发出警告或错误提示。

在实际应用中,DDY 节点最常见的用途包括:

  • 计算法线贴图的表面法线
  • 实现基于导数的边缘检测
  • 创建各向异性高光效果
  • 优化纹理采样和 mipmap 级别选择
  • 实现屏幕空间的环境光遮蔽

理解 DDY 节点的数学原理对于正确使用它至关重要。偏导数的计算可以近似表示为:ddy(p) ≈ p(x, y+1) - p(x, y),其中 p 是输入值,(x, y) 是当前像素的屏幕坐标。这个近似计算由 GPU 硬件在像素四边形级别自动完成,为着色器程序员提供了高效的导数访问方式。

端口

DDY 节点的端口设计遵循 Shader Graph 的标准约定,提供了清晰的输入输出接口,使得节点能够灵活地集成到各种着色器网络中。

输入端口

输入端口标记为 "In",是节点的唯一输入通道,接受动态矢量类型的数据。动态矢量意味着该端口可以接受各种维度的向量输入,包括:

  • float(标量值)
  • float2(二维向量)
  • float3(三维向量)
  • float4(四维向量)

这种灵活性使得 DDY 节点能够处理各种类型的数据,从简单的灰度值到完整的颜色信息。当输入多维向量时,DDY 节点会独立计算每个分量的偏导数,返回一个与输入维度相同的输出向量。

输入值的内容可以是任何在像素着色器中有效的表达式或节点输出,包括:

  • 纹理采样结果
  • 数学运算输出
  • 时间变量
  • 顶点数据插值
  • 其他自定义计算的结果

输出端口

输出端口标记为 "Out",提供计算得到的偏导数结果。与输入端口类似,输出也是动态矢量类型,其维度与输入保持一致。输出值的每个分量对应于输入向量相应分量的偏导数。

输出值的范围和特性取决于输入内容:

  • 当输入是连续平滑变化的值时,输出通常较小且变化平缓
  • 当输入在相邻像素间有剧烈变化时,输出值会相应增大
  • 在边缘或高对比度区域,输出值可能显著增加
  • 在平坦或均匀区域,输出值接近零

理解输出值的这些特性对于正确解释和使用 DDY 节点的结果至关重要。在实际应用中,通常需要对输出值进行适当的缩放、钳制或后续处理,以适应特定的视觉效果需求。

端口连接实践

在 Shader Graph 中连接 DDY 节点时,需要考虑数据类型和精度的匹配。虽然动态矢量端口提供了很大的灵活性,但最佳实践包括:

  • 确保输入数据的范围合理,避免极端值导致导数计算不稳定
  • 注意数据精度,在移动平台或性能受限环境下考虑使用半精度浮点数
  • 合理组织节点网络,避免不必要的复杂连接影响可读性
  • 使用适当的注释和分组,使包含 DDY 节点的复杂网络更易于理解和维护

生成的代码示例

DDY 节点在背后生成的代码揭示了其在底层着色器语言中的实现方式。理解这些生成的代码有助于深入掌握节点的行为特性,并在需要时进行自定义扩展或优化。

HLSL 代码实现

在大多数情况下,DDY 节点会生成类似于以下示例的 HLSL 代码:

void Unity_DDY_float4(float4 In, out float4 Out)
{
    Out = ddy(In);
}

这个简单的函数封装了 HLSL 内置的 ddy() 函数,该函数是 DirectX 着色器语言中用于计算屏幕空间 Y 方向偏导数的原生指令。函数接受一个 float4 类型的输入参数,并输出相应的偏导数结果。

对于不同维度的输入,生成的函数签名会相应调整:

  • 对于 float 输入:Unity_DDY_float(float In, out float Out)
  • 对于 float2 输入:Unity_DDY_float2(float2 In, out float2 Out)
  • 对于 float3 输入:Unity_DDY_float3(float3 In, out float3 Out)

底层硬件实现

虽然从代码层面看,DDY 节点的实现很简单,但它在硬件层面的执行却涉及 GPU 的并行架构特性。当 GPU 执行包含 ddy() 调用的像素着色器时:

  • 着色器单元以 2x2 像素块(像素四边形)为单位调度执行
  • 在每个像素四边形中,四个片段并行处理
  • 硬件自动比较同一四边形中垂直相邻像素的寄存器值
  • 计算得到的导数值用于所有四个像素的着色计算

这种实现方式意味着导数计算基本上没有额外的性能开销,因为 GPU 本来就需要并行处理像素四边形中的多个片段。这也是为什么导数计算只能在像素着色器中工作的原因——其他着色器阶段没有这种并行处理架构。

精度和性能考虑

在使用 DDY 节点时,了解其精度特性和性能影响很重要:

  • 导数计算基于实际执行的像素值,因此结果完全准确
  • 在几何边缘或遮挡边界处,导数可能不太可靠,因为相邻像素可能属于不同物体
  • 性能开销通常很小,但在低端移动设备上,复杂的导数计算网络仍可能影响性能
  • 在某些情况下,使用近似计算方法可能比直接使用 DDY 节点更高效

自定义扩展和应用

通过理解生成的代码模式,开发者可以创建自定义的导数计算函数,扩展 DDY 节点的功能:

// 自定义带缩放的导数计算
void Custom_DDY_Scaled(float4 In, float Scale, out float4 Out)
{
    Out = ddy(In) * Scale;
}

// 带钳制的导数计算,避免过大的导数值
void Custom_DDY_Clamped(float4 In, float MaxDerivative, out float4 Out)
{
    Out = clamp(ddy(In), -MaxDerivative, MaxDerivative);
}

// 计算导数的大小,用于边缘检测等应用
void Custom_DDY_Length(float4 In, out float Out)
{
    Out = length(ddy(In));
}

这些自定义函数可以在 Shader Graph 中通过 Custom Function 节点实现,为特定的应用场景提供更专门的导数计算功能。

实际应用案例

DDY 节点在着色器开发中有着广泛的应用,以下是一些典型的实际应用案例,展示了如何充分利用这个节点的特性。

法线贴图处理

在基于物理的渲染中,法线贴图是增强表面细节的关键技术。DDY 节点可以用于计算法线贴图的正确 mipmap 级别,或者在需要时重建世界空间法线:

// 使用 DDY 计算法线贴图的适当 LOD 级别
float CalculateNormalMapLOD(float2 uv)
{
    float2 deriv = float2(ddx(uv.x), ddy(uv.y));
    float lod = 0.5 * log2(max(dot(deriv, deriv), 1.0));
    return lod;
}

// 结合 DDX 和 DDY 重建世界空间法线
float3 ReconstructWorldNormal(float2 uv, float3 normalTS, float3x3 TBN)
{
    float3 ddx_normal = ddx(normalTS);
    float3 ddy_normal = ddy(normalTS);
    // 应用复杂的法线重建算法
    // ...
}

边缘检测效果

DDY 节点在屏幕后处理中常用于边缘检测,通过分析颜色或深度的变化来识别图像中的边缘:

// 基于颜色导数的简单边缘检测
float EdgeDetectionColor(float2 uv, sampler2D colorTexture)
{
    float3 color = tex2D(colorTexture, uv).rgb;
    float3 deriv_x = ddx(color);
    float3 deriv_y = ddy(color);

    float edge = length(deriv_x) + length(deriv_y);
    return saturate(edge * 10.0); // 调整灵敏度
}

// 结合深度和颜色的高级边缘检测
float AdvancedEdgeDetection(float2 uv, sampler2D colorTexture, sampler2D depthTexture)
{
    float depth = tex2D(depthTexture, uv).r;
    float3 color = tex2D(colorTexture, uv).rgb;

    float depth_edge = abs(ddy(depth)) * 100.0; // 深度边缘
    float color_edge = length(ddy(color)) * 10.0; // 颜色边缘

    return saturate(max(depth_edge, color_edge));
}

各向异性高光

各向异性高光效果模拟表面在特定方向反射光线的特性,如拉丝金属或头发材质。DDY 节点可以帮助确定高光的方向和强度:

// 简单的各向异性高光计算
float AnisotropicSpecular(float3 worldNormal, float3 viewDir, float2 uv)
{
    // 使用 UV 导数确定各向异性方向
    float2 deriv = float2(ddx(uv.x), ddy(uv.y));
    float anisotropy = length(deriv);

    // 基于导数方向调整高光
    float3 anisotropicDir = normalize(float3(deriv.x, deriv.y, 0));
    // 进一步的高光计算...

    return specular;
}

纹理采样优化

通过分析纹理坐标的导数,可以优化纹理采样,选择适当的 mipmap 级别,平衡质量和性能:

// 基于导数的自适应纹理采样
float4 AdaptiveTextureSample(sampler2D tex, float2 uv)
{
    // 计算纹理坐标的导数
    float2 duv_dx = ddx(uv);
    float2 duv_dy = ddy(uv);

    // 计算适当的 LOD 级别
    float lod = 0.5 * log2(max(dot(duv_dx, duv_dx), dot(duv_dy, duv_dy)));

    // 使用计算出的 LOD 进行采样
    return tex2Dlod(tex, float4(uv, 0, lod));
}

最佳实践和注意事项

为了确保 DDY 节点的正确使用和最佳性能,遵循一些最佳实践和注意事项非常重要。

平台兼容性

DDY 节点在不同平台和图形 API 上的支持程度可能有所差异:

  • 在所有现代桌面 GPU(DirectX 11+、Vulkan、Metal)上完全支持
  • 在移动平台上,需要 OpenGL ES 3.0+ 或 Vulkan 支持
  • 在较旧的硬件或图形 API 上可能有限制或性能问题
  • 在 WebGL 中,支持程度取决于浏览器和硬件能力

为了确保跨平台兼容性,建议:

  • 在图形设置中配置适当的回退方案
  • 使用 Shader Graph 的条件编译功能处理平台差异
  • 在移动平台上测试导数计算的性能影响

性能优化

虽然 DDY 节点本身很高效,但在复杂着色器中仍需注意性能:

  • 避免在循环或复杂控制流中过度使用 DDY 节点
  • 考虑复用导数计算结果,而不是重复计算
  • 对于简单的应用,考虑使用近似的分析方法代替精确的导数计算
  • 在性能敏感的平台,评估使用 DDY 节点的实际性能影响

数学精度考虑

导数计算对数值精度很敏感,特别是在 HDR 或高动态范围场景中:

  • 注意输入值的范围,过大的值可能导致导数计算不稳定
  • 在需要高精度的应用中,考虑使用更高精度的浮点数格式
  • 注意导数计算在 discontinuities(不连续点)处的行为可能不符合预期

调试和可视化

调试包含 DDY 节点的着色器可能具有挑战性,以下技巧可以帮助:

  • 使用 Color 节点将导数值可视化,检查其范围和分布
  • 创建调试视图,单独显示导数计算的结果
  • 使用适当的缩放和偏移,使导数值在可视范围内
  • 在简单测试案例中验证导数计算的行为

与其他节点的结合

DDY 节点通常与其他数学和工具节点结合使用,创建复杂的视觉效果:

  • 结合 DDX 节点获取完整的梯度信息
  • 使用数学节点对导数结果进行后处理
  • 与条件节点结合,创建基于导数阈值的效果
  • 在子图中封装复杂的导数计算逻辑,提高可重用性

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

深度起底 Vite:从打包流程到插件钩子执行时序的全链路解析

2026年4月18日 15:49

前言

Vite 之所以能颠覆 Webpack 的统治地位,不仅是因为它在开发阶段的“快”,更在于它巧妙地结合了 原生 ESMRollup 的生产构建能力。本文将带你拆解 Vite 打包的每一个步骤,并揭秘其插件系统的核心钩子。

一、 Vite 生产打包流水线

Vite 的生产构建完全基于 Rollup 实现,但在 Rollup 打包前后增加了 Vite 特有的预处理和后优化步骤,整个流程可以分为以下 6 个核心阶段:

步骤 1:加载并解析配置

Vite 会优先读取项目根目录的vite.config.js/ts(或vite.config.mjs)配置文件,同时合并命令行参数和默认配置,形成最终的运行配置。

  • 解析核心配置项:root(项目根目录)、base(公共基础路径)、build(打包配置,如输出目录、目标环境、代码分割等)、plugins(插件)、resolve(路径解析)等
  • 同时会读取package.json中的type: module等配置,确定项目的模块规范

配置合并优先级为:命令行参数 > 配置文件导出的配置 > Vite 内置默认配置

步骤 2:预构建与依赖分析

这是 Vite 区别于传统打包工具的关键步骤,主要为了解决第三方依赖的兼容性和打包性能问题。

  • Vite 会扫描项目中的所有依赖(主要是node_modules中的第三方包),对非 ES 模块的依赖(如 CommonJS、UMD 格式)进行预构建,统一转换为标准 ES 模块
  • 分析项目入口文件(默认是根目录的index.html),递归解析所有文件的依赖关系(包括.vue/.js/.ts/.css/.scss等各种类型的文件),构建完整的模块依赖图

补充:预构建的结果会被缓存到node_modules/.vite目录,只有当依赖发生变化时才会重新预构建,极大提升了后续打包速度

步骤 3:插件执行

Vite 会按照配置的顺序依次执行所有插件,并调用相应的插件生命周期钩子,在这个阶段完成各种文件转换、代码预处理和资源处理操作。

  • 插件会在不同的生命周期钩子中执行对应的逻辑,例如:

    • .vue/.jsx/.tsx等非原生 JS 文件进行编译转换
    • 小图片 / 字体等静态资源的 base64 转换(由 Vite 内置的 assets 插件处理)
    • CSS 预处理器编译、PostCSS 处理、CSS Modules 转换等

步骤 4:使用 Rollup 进行核心打包

这是生产构建的核心阶段,Vite 将完全委托给 Rollup 进行代码打包和优化。

  • 按入口文件拆分代码块(chunk),同时支持根据动态导入import()自动拆分代码块,还可以通过配置将第三方依赖单独拆分为 vendor chunk
  • 生成兼容目标环境的代码:默认打包为现代浏览器支持的 ES 模块格式,也可通过build.target配置兼容 ES5 及更低版本
  • 处理模块间的依赖引用:将代码中的相对路径替换为配置的base路径,确保生产环境下所有资源都能正确加载
  • 执行 Rollup 特有的优化:包括 Tree Shaking 移除无用代码、作用域提升(Scope Hoisting)减少代码体积等

步骤 5:Rollup 产物最终优化

在 Rollup 完成基础打包后,Vite 会对输出的产物进行最后一轮的生产环境优化。

  • 对 JavaScript 和 CSS 代码进行压缩混淆(默认使用 Terser 压缩 JS,esbuild 压缩 CSS)
  • 生成资源哈希文件名,实现静态资源的长效缓存
  • 可选生成 sourcemap 文件,方便生产环境调试
  • 可选生成manifest.json文件,记录资源文件名与哈希后的文件名的映射关系,用于后端集成

步骤 6:输出最终产物

将所有打包和优化后的产物输出到指定的目录(默认是dist目录)。

  • 静态资源(JS、CSS、图片、字体等)会输出到dist/assets目录
  • 入口 HTML 文件会输出到dist根目录
  • 其他配置的静态资源会按照原目录结构输出到dist目录

二、 揭秘 Vite 插件钩子 (Hooks)

Vite 的插件系统扩展自 Rollup 的插件系统,同时增加了一些 Vite 特有的钩子函数,以支持开发服务器和热更新等 Vite 独有的功能。

2.1 通用 Rollup 兼容钩子

Vite 在开发阶段会模拟 Rollup 的行为,调用一系列与 Rollup 兼容的钩子;而在生产环境下,由于 Vite 直接使用 Rollup 进行打包,所有 Rollup 插件钩子都会生效。

这些通用钩子主要分为三个阶段:

  1. 服务器启动阶段optionsbuildStart钩子会在开发服务启动时被调用
  2. 请求响应阶段:当浏览器发起请求时,Vite 内部依次调用resolveIdloadtransform钩子
  3. 服务器关闭阶段:Vite 会依次执行buildEndcloseBundle钩子

重要说明:除了以上钩子,其他 Rollup 插件钩子(如moduleParsedrenderChunk等)均不会在 Vite 开发阶段调用。这是因为开发阶段 Vite 采用按需编译的模式,不需要对整个项目进行完整打包。

2.2 Vite 独有钩子

Vite 提供了一些独有的钩子函数,这些钩子只会在 Vite 内部调用,放到纯 Rollup 环境中会被直接忽略。

钩子名称 调用时机 主要用途
config Vite 读取完配置文件之后,最终配置合并之前 对用户导出的配置对象进行自定义修改,例如注入默认值、根据环境动态调整配置、合并插件配置等
configResolved Vite 完成最终配置解析之后 此时配置已完全合并且不可再修改,常用于获取最终配置进行调试,或根据最终配置初始化插件内部状态
configureServer 仅在开发服务器启动时调用 扩展或拦截开发服务器行为,例如添加自定义中间件、模拟 API 接口、修改服务器配置、实现请求代理等
transformIndexHtml 转换原始 HTML 文件内容时调用 动态修改 index.html 内容,例如注入脚本标签、修改 meta 标签、添加全局变量、SSR 内容注入等
handleHotUpdate Vite 服务端处理热更新时调用 自定义热更新逻辑,例如过滤不需要热更新的模块、触发自定义的热更新行为、向客户端发送自定义消息等

三、 核心:钩子函数的执行顺序

理解执行顺序是编写高质量插件的前提。

1. 服务启动阶段

configconfigResolvedoptionsconfigureServerbuildStart

2. 请求响应阶段

  • HTML 请求:仅执行 transformIndexHtml
  • 非 HTML 请求 (JS/CSS等):resolveIdloadtransform

3. 热更新与关闭阶段

  • HMR 触发handleHotUpdate
  • 服务关闭buildEndcloseBundle


四、 知识扩展:开发与生产的差异

  • 开发阶段:Vite 利用浏览器原生 ESM,只有在文件被请求时才触发 transform 钩子,这是其“快”的底层逻辑。
  • 生产阶段:Vite 完完全全变成了一个“Rollup 配置封装器”,所有插件钩子都会遵循 Rollup 的打包逻辑执行。

Superpowers 从“调教提示词”转向“构建工程规范”

作者 去伪存真
2026年4月18日 15:42

前言

最近留意到github上有个编程skills比较火爆superpowers , 很多人第一眼会觉得 superpowers 是一个“AI 编程插件”, 但我更倾向把它理解为一套约束 AI 行为的执行协议(Execution Protocol), 什么意思?在 Cursor 或 Codex 里:你输入 prompt, AI 自由发挥,本质是:无约束生成。而 superpowers 做的事情是:

  • 把整个过程拆成多个阶段
  • 每个阶段有明确输入 / 输出
  • 严格限制 AI 当前“能做什么”

一、 我们正在面临的“AI 协作困境”

在superpowers实现前,先聊聊目前 AI 编程里最扎心的三个痛点:

  1. 认知边界的迷失 (Context Drift): AI 是个典型的“局部主义者”。在几万行的代码库里,它往往只盯着你喂给它的那几个文件。它不知道你项目里已经封装好了 axios,于是自作聪明地又写了一个。结果就是:项目跑通了,但代码复用度降低了。
  2. 调试的“黑盒”陷阱: 当代码报错时,AI 最常见的反应是“复读机式修复”。它会说“抱歉,我漏了一个判空”,改完报错;再说“抱歉,可能是异步问题”,再改。这种 Trial-and-Error(盲碰)的模式,不仅浪费 Token,更是在消磨开发者的耐心。
  3. 审查负担 (Review Fatigue): 现在最累的不是写代码,是 Review。AI 甩给你 500 行改动,你敢合吗?因为缺乏可信的验证路径,你甚至觉得 Review 它的代码比自己重写一遍还累。

二、 核心哲学:工程纪律大于模型能力

superpowers 的核心逻辑是:与其期待模型更聪明,不如让它的行为更规范。

在没有 superpowers 之前,AI 编程助手(如 Cursor、Claude)更像是一个极速打字员,而有了它之后,AI 变成了一个资深工程师

2.1 强制性的“先思后行”(The Thinking Protocol)

  • 普通 AI: 看到需求直接开改,改完发现引出 3 个新 Bug。
  • Superpowers AI: 被禁止直接动代码。它必须先调用 research 技能查阅现有逻辑,再调用 plan 技能产出方案,最后才动手。这种认知顺序的强制化,极大地减少了“重构灾难”。

2.2 系统化调试(Systematic Debugging)

这是该项目最硬核的优势。它将调试从“碰运气”升级为“科学实验”:

  • 它要求 AI 必须通过 Observation(观察) -> Hypothesis(假设) -> Verification(验证) 的闭环。
  • AI 不能只说“修好了”,必须提供测试通过的证据。

2.3 环境隔离与安全(Isolation)

利用 git-worktree 技能,AI 的所有实验性修改都在平行空间进行。这让开发者敢于让 AI 进行大规模重构,因为一键即可回滚,主开发分支永远处于受保护状态。


三、 技术底层:它是如何“驯服”AI 的?

superpowers 的底层逻辑并非改变了 LLM(大模型)本身,而是通过工程抽象重新定义了人机交互的边界。

3.1 状态机模型(State Machine)

superpowers 将软件开发抽象成了一个有状态的流程。它定义了不同的“运行等级”:

  • Level 0: Context Gathering(只允许搜索和读取)

  • Level 1: Planning(产出文档,禁止改码)

  • Level 2: Execution(激活文件写入,配合 TDD)

    这种物理层面的权限截断,确保了 AI 不会在还没搞懂逻辑时就开始乱写。

3.2 Skill 作为“原子化执行协议”

在源码中,每一个 .md 文件(如 systematic-debugging.md)都是一个 Skill 定义。它的原理包括:

  • JSON Schema 绑定: 每个技能对应一组工具调用指令。当 AI 调用技能时,它必须填入符合规范的参数。
  • 反馈闭环: 技能执行后,结果会以标准格式返回给 AI。如果 AI 没有按照协议(协议要求先写测试),系统会拒绝执行后续动作。

3.3 基于“上下文修剪”的精准控制

加载全量技能会造成 Token 溢出且让 AI 产生干扰。superpowers 的实现逻辑是:

  • 动态注入: 根据当前任务阶段,动态地将相关的 SKILLS.md 内容注入到 System Message 中。
  • 少即是多: 限制 AI 在特定时刻能看到的“超能力”,反而提高了它的执行精度。

2.4 为什么它更稳定?

我们可以用一个公式来总结它的原理:

Stability=(LLM Reasoning)×(Engineering Constraints)nStability = (LLM\ Reasoning) \times (Engineering\ Constraints)^n

  • 模型(LLM): 提供推理基础。
  • 约束(Constraints):superpowers 提供。

它通过把软件工程的最佳实践(Best Practices)转化为模型必须遵守的原子化指令(Atomic Skills) ,实现了从“概率性代码生成”向“确定性工程闭环”的跨越。

一句话总结: superpowers 的核心不是给 AI 自由,而是给 AI 划定了一条“通往成功的狭窄路径”。

四、 实战:AI 视角下的“重构三部曲”

假设我们要重构一个逻辑混乱的老旧模块,在 superpowers 的加持下,AI 的执行路径是这样的:

4.1:Brainstorming(拒绝直接上手)

AI 接收任务后,第一步是调用 searchlist_files 摸清家底。它会先输出一个“重构提案”,问你: “我发现这里有三个依赖项,我打算先解耦 A 再改动 B,你觉得呢?” 这种先提问、出方案的习惯,像极了靠谱的架构师。

4.2 环境隔离 (Git Worktrees)

为了保证安全,它会自动创建一个 Git Worktree。这意味着 AI 的所有折腾都在一个隔离的平行空间进行。即便它把代码改得一塌糊涂,也不会影响你当前的工作区。这种自动化运维的能力,让 AI 真正具备了“独立作业”的条件。

4.3 根因追踪 (Systematic Debugging)

遇到一个深层 Bug 时,AI 不再盲目改代码,而是开始执行“系统化观测”。它会先插入 Trace 日志,观察数据流,确认假设后才落笔。这种逻辑闭环,让它的修复成功率呈指数级提升。


五、 总结:AI 时代,我们该学什么?

随着 superpowers 这类工具的成熟,开发者的角色正在发生深刻转变:

我们不再是写代码的人,而是定义“产品”和检查“实现”的人。

与其在提示词里求 AI “请写得好一点”,不如像 superpowers 这样,给它一套标准化的技能说明书。如果你所在的团队有特殊的开发规范(比如特定的内部库或部署流程),你完全可以基于它的框架,自定义一套私有的 Skills。

好的工具不应该给 AI 自由,而应该给 AI 边界。 这或许就是 AI 编程工程化的终极答案。

Vue 封装 Echarts 组件

作者 Fisschl
2026年4月18日 14:18

为了方便在不同页面使用 echarts,可以封装一个组件。如果不封装,也可以手动实例化 echarts,并且额外处理监听容器尺寸变化的功能。

<script setup lang="ts">
import { useResizeObserver } from "@vueuse/core";
import type { EChartsOption } from "echarts";
import { init, type ECharts, type ECElementEvent } from "echarts/core";

const props = defineProps<{
  /** Echarts 图表配置选项 */
  options?: EChartsOption;
  /** 图表渲染器类型,默认为 svg */
  renderer?: "canvas" | "svg";
}>();
const emit = defineEmits<{
  chartClick: [event: ECElementEvent];
}>();

/** 图表容器元素的引用 */
const container = useTemplateRef("figure-element");
/** Echarts 图表实例 */
const chart = shallowRef<ECharts>();

defineExpose({
  container,
  chart,
});

/**
 * 监听图表配置变化并更新图表
 */
watchEffect(() => {
  if (!chart.value || !props.options) return;
  chart.value.setOption(props.options);
});

/**
 * 监听容器尺寸变化并自动调整图表大小
 */
useResizeObserver(container, () => {
  if (!chart.value || chart.value.isDisposed()) return;
  chart.value.resize();
});

/**
 * 初始化图表实例
 */
watch(container, (container) => {
  if (!container) return;

  const instance = init(container, undefined, {
    renderer: props.renderer || "svg",
    locale: "ZH",
  });

  /** 绑定图表点击事件 */
  instance.on("click", (event) => {
    emit("chartClick", event);
  });

  chart.value = instance;
  onWatcherCleanup(() => instance.dispose());
});
</script>

<template>
  <figure ref="figure-element" :class="$style.figure" />
</template>

<style module>
.figure {
  overflow: hidden;
}
</style>

然后在页面中使用。

<script setup lang="ts">
import type { EChartsOption } from "echarts";
import { BarChart, LineChart, PieChart, ScatterChart } from "echarts/charts";
import {
  GridComponent,
  LegendComponent,
  TitleComponent,
  TooltipComponent,
} from "echarts/components";
import { use } from "echarts/core";
import { UniversalTransition } from "echarts/features";
import { SVGRenderer } from "echarts/renderers";
import EchartsContainer from "@/components/Echarts/EchartsContainer.vue";

use([
  GridComponent,
  LineChart,
  BarChart,
  SVGRenderer,
  PieChart,
  ScatterChart,
  UniversalTransition,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
]);

/** 基础柱状图配置 */
const barChartOption: EChartsOption = {
  tooltip: {
    trigger: "axis",
    axisPointer: {
      type: "shadow",
    },
  },
  xAxis: {
    type: "category",
    data: ["一月", "二月", "三月", "四月", "五月", "六月"],
    axisTick: {
      alignWithLabel: true,
    },
  },
  yAxis: {
    type: "value",
  },
  series: [
    {
      name: "销售额",
      type: "bar",
      data: [120, 200, 150, 80, 70, 110],
    },
  ],
};

/** 折线图配置 */
const lineChartOption: EChartsOption = {
  tooltip: {
    trigger: "axis",
  },
  legend: {
    data: ["新用户", "活跃用户"],
  },
  xAxis: {
    type: "category",
    data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
  },
  yAxis: {
    type: "value",
  },
  series: [
    {
      name: "新用户",
      type: "line",
      data: [120, 132, 101, 134, 90, 230, 210],
      smooth: true,
      itemStyle: {
        color: "#67C23A",
      },
    },
    {
      name: "活跃用户",
      type: "line",
      data: [220, 182, 191, 234, 290, 330, 310],
      smooth: true,
      itemStyle: {
        color: "#E6A23C",
      },
    },
  ],
};

/** 饼图配置 */
const pieChartOption: EChartsOption = {
  tooltip: {
    trigger: "item",
    formatter: "{a} <br/>{b}: {c} ({d}%)",
  },
  legend: {
    orient: "vertical",
    left: "left",
  },
  series: [
    {
      name: "产品分类",
      type: "pie",
      radius: "50%",
      data: [
        { value: 1048, name: "电子产品" },
        { value: 735, name: "服装配饰" },
        { value: 580, name: "家居用品" },
        { value: 484, name: "食品饮料" },
        { value: 300, name: "其他" },
      ],
    },
  ],
};

/** 散点图配置 */
const scatterChartOption: EChartsOption = {
  tooltip: {
    trigger: "item",
  },
  xAxis: {
    type: "value",
    name: "X轴",
  },
  yAxis: {
    type: "value",
    name: "Y轴",
  },
  series: [
    {
      name: "数据点",
      type: "scatter",
      data: Array.from({ length: 50 }, () => [Math.random() * 100, Math.random() * 100]),
    },
  ],
};

/** 处理图表点击事件 */
const handleChartClick = (chartType: string) => {
  ElMessage.info(`点击了${chartType}图表`);
};
</script>

<template>
  <div :class="$style.container">
    <!-- 柱状图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="barChartOption"
      @chart-click="handleChartClick('柱状图')"
    />

    <!-- 折线图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="lineChartOption"
      @chart-click="handleChartClick('折线图')"
    />

    <!-- 饼图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="pieChartOption"
      @chart-click="handleChartClick('饼图')"
    />

    <!-- 散点图 -->
    <EchartsContainer
      :class="$style.chart"
      :options="scatterChartOption"
      @chart-click="handleChartClick('散点图')"
    />
  </div>
</template>

<style module>
.container {
  padding: 2rem;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

.chart {
  height: 30rem;
}
</style>

uni-app 运行时揭秘:styleIsolation 的转化

2026年4月18日 14:16

背景

大家好,我是 uni-app 的核心开发 前端笨笨狗。本篇是 uni-app 源码分析的第三篇文章,欢迎关注!

前两天有开发者在群里面问我 uni-app 中如何配置 styleIsolation,我告诉了他正确的配置方案,也计划写篇文章揭秘 uni-app 是如何通过运行时将开发者的配置转化为原生微信小程序的配置。

指南

选项式

uni-app 中,开发者可以通过在页面组件中添加 options 配置项来设置 styleIsolation,示例如下:

<script>
export default {
  name: 'MyComp',
  options: {
    styleIsolation: 'isolated'
  },  
}
</script>
<script>
import { defineComponent } from "vue";

export default defineComponent({
  name: "MyComp",
  options: {
    styleIsolation: "isolated",
  },
});
</script>

组合式

在使用组合式 API 的页面组件中,开发者同样可以通过 defineOptions 来设置 styleIsolation,示例如下:

<script setup>
defineOptions({
  name: 'MyComp',
  options: {
    styleIsolation: 'isolated'
  }
})
</script>

原理

createComponent 这个函数大家如果看过 vue 文件的 js 编译产物就一定不会陌生,比如

<script setup>
defineOptions({
  options: {
    styleIsolation: "shared",
  },
});
</script>

会被编译为

const _sfc_main = {
  __name: "comp",
  options: {
    styleIsolation: "shared"
  }
  setup(__props) {
    return (_ctx, _cache) => {
      return {};
    };
  }
};
wx.createComponent(_sfc_main);

也就是 script 中写的代码会被编译成一个对象,这个对象就是 vue 组件的配置项,而微信小程序又不认识 vue 组件的配置项,那么怎么把 vue 组件的配置项转化为微信小程序的配置项呢?这就要靠 uni-app 的运行时了,在 common/vendor.js 中,createComponent 函数会调用 parseComponent 函数来解析 vue 组件的配置项,parseComponent 的返回值就是微信小程序组件的配置项,也就是 Component 构造器 的参数,可以用来构造小程序原生组件。

function initCreateComponent() {
  return function createComponent(vueComponentOptions) {
    return Component(parseComponent(vueComponentOptions));
  };
}

const createComponent = initCreateComponent();
wx.createComponent = createComponent;

parseComponent 解析到页面组件时,会检查组件的 options 配置项,如果发现 styleIsolation,就会将其转化为微信小程序的配置项。

function parseComponent(vueOptions) {
  vueOptions = vueOptions.default || vueOptions;
  const options = {
    multipleSlots: true,
    // styleIsolation: 'apply-shared',
    addGlobalClass: true,
    pureDataPattern: /^uP$/
  };
  // 将开发者在 options 中设置的配置项转化为微信小程序的配置项
  if (vueOptions.options) {
    Object.assign(options, vueOptions.options);
  }
  const mpComponentOptions = {
    options,
    // 省略其他配置项
  };
  return mpComponentOptions;
}

这样一来,开发者在页面组件中设置的 styleIsolation 就会被正确地转化为微信小程序的配置项,从而自由控制样式隔离。

Vue v-html 与 v-text 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月18日 14:06

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-html/v-text 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-html 和 v-text 指令用法。

编译对照

v-html:动态 HTML 内容渲染

v-html 是 Vue 中用于将 HTML 字符串动态渲染为 DOM 元素的指令,它会替换元素内的所有内容,并解析 HTML 标签。

  • Vue 代码:
<div v-html="htmlContent"></div>
  • VuReact 编译后 React 代码:
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />

从示例可以看到:Vue 的 v-html 指令被编译为 React 的 dangerouslySetInnerHTML 属性。VuReact 采用 HTML 注入编译策略,将模板指令转换为 React 的特殊属性,完全保持 Vue 的 HTML 渲染语义——将 htmlContent 字符串解析为 HTML 并插入到 DOM 中。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-html 的行为,直接渲染 HTML 字符串
  2. 安全警告:React 的 dangerouslySetInnerHTML 属性名本身就提醒开发者注意 XSS 攻击风险
  3. 内容替换:与 Vue 一样,会替换元素内的所有现有内容

v-text:纯文本内容渲染

v-text 是 Vue 中用于将纯文本内容设置到元素内的指令,它会替换元素内的所有内容,但不会解析 HTML 标签。

  • Vue 代码:
<p v-text="message"></p>
  • VuReact 编译后 React 代码:
<p>{message}</p>

从示例可以看到:Vue 的 v-text 指令被编译为 React 的 JSX 插值表达式。VuReact 采用 文本插值编译策略,将模板指令转换为 JSX 的大括号表达式,完全保持 Vue 的文本渲染语义——将 message 作为纯文本内容插入到元素中。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-text 的行为,渲染纯文本内容
  2. 自动转义:React 的 JSX 插值会自动转义 HTML 特殊字符,防止 XSS 攻击
  3. 内容替换:与 Vue 一样,会替换元素内的所有现有内容

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写内容渲染逻辑。编译后的代码既保持了 Vue 的语义,又符合 React 的安全最佳实践。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue路由跳转全场景实战(Vue2+Vue3适配)| 新手必看

2026年4月18日 12:50

Vue路由跳转是前端项目页面切换的核心操作,贯穿整个Vue项目开发(从简单页面跳转,到带参跳转、权限控制跳转)。本文将整合Vue2、Vue3路由跳转的所有常用方式,明确不同场景的使用选择,补充参数传递、导航守卫、常见问题及解决方案,提供可直接复制的实战示例,兼顾新手入门与实战适配。

一、Vue路由跳转核心前提(必看)

无论Vue2还是Vue3,路由跳转前需确保已完成路由配置(引入Vue Router、创建路由实例、挂载路由),基础配置如下(简化版,可直接复用):

// Vue2 基础路由配置(router/index.js)
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)

const routes = [
  { path: '/', name: 'Home', component: () => import('../views/Home.vue') },
  { path: '/about', name: 'About', component: () => import('../views/About.vue') },
  { path: '/user/:id', name: 'User', component: () => import('../views/User.vue') }
]

const router = new VueRouter({ mode: 'history', routes })
export default router

// Vue3 基础路由配置(router/index.js)
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
  { path: '/', name: 'Home', component: () => import('../views/Home.vue') },
  { path: '/about', name: 'About', component: () => import('../views/About.vue') },
  { path: '/user/:id', name: 'User', component: () => import('../views/User.vue') }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})
export default router

说明:Vue2需通过Vue.use(VueRouter)注册路由,Vue3通过createRouter创建路由实例,二者跳转语法有细微差异,下文将分别说明并标注适配版本。

二、Vue路由跳转3种核心方式(实战常用)

Vue路由跳转主要分为「声明式跳转」和「编程式跳转」,其中编程式跳转更灵活,可结合业务逻辑(如登录判断)使用,声明式跳转适合简单页面切换。

方式1:声明式跳转( 标签,最简洁)

核心:通过Vue Router提供的<router-link>标签实现跳转,无需写JS逻辑,自动渲染为a标签(可通过tag属性修改标签类型),适配Vue2、Vue3,用法完全一致。

1. 基础跳转(无参数)

<!-- 方式1:通过path跳转(推荐,简洁直观) -->
<router-link to="/">首页</router-link>
<router-link to="/about">关于我们</router-link>

<!-- 方式2:通过name跳转(需配置路由name,适合路径较长场景) -->
<router-link :to="{ name: 'Home' }">首页</router-link>
<router-link :to="{ name: 'About' }">关于我们</router-link>

<!-- 可选:修改渲染标签(默认a标签,改为button) -->
<router-link to="/" tag="button">首页(按钮形式)</router-link>

2. 带参数跳转(query参数 / params参数)

跳转时传递参数,用于页面间数据交互,两种参数类型用法不同,需区分场景:

<!-- 1. query参数(暴露在URL上,可刷新保留,适合简单数据) -->
<!-- 方式:path/name + query对象 -->
<router-link :to="{ path: '/user', query: { id: 1, name: '张三' } }">
  进入用户页(query参数)
</router-link>
<router-link :to="{ name: 'User', query: { id: 1, name: '张三' } }">
  进入用户页(name+query)
</router-link>
<!-- 跳转后URL:http://localhost:8080/user?id=1&name=张三 -->

<!-- 2. params参数(不暴露在URL上,刷新丢失,适合敏感数据) -->
<!-- 注意:params必须配合name跳转,不能配合path -->
<router-link :to="{ name: 'User', params: { id: 1, name: '张三' } }">
  进入用户页(params参数)
</router-link>
<!-- 跳转后URL:http://localhost:8080/user/1(需配置路由path为/user/:id) -->

方式2:编程式跳转(router.push / router.replace,最灵活)

核心:通过JS代码调用router.push(保留历史记录)或router.replace(不保留历史记录,无法返回上一页)实现跳转,适合结合业务逻辑(如登录成功后跳转、按钮点击跳转),Vue2和Vue3用法略有差异。

1. Vue2 编程式跳转

// 1. 基础跳转(无参数)
this.$router.push('/') // path跳转
this.$router.push({ name: 'Home' }) // name跳转

// 2. 带参数跳转(query / params)
// query参数
this.$router.push({
  path: '/user',
  query: { id: 1, name: '张三' }
})
// params参数(需配合name)
this.$router.push({
  name: 'User',
  params: { id: 1, name: '张三' }
})

// 3. 替换跳转(不保留历史记录)
this.$router.replace('/about')

// 4. 后退/前进(操作历史记录)
this.$router.go(-1) // 后退1页(类似浏览器返回键)
this.$router.back() // 等同于go(-1)
this.$router.go(1) // 前进1页

2. Vue3 编程式跳转

Vue3 setup语法中,无this,需通过useRouter引入路由实例,用法如下:

// 1. 引入路由实例(必须先引入)
import { useRouter } from 'vue-router'
const router = useRouter()

// 2. 基础跳转(无参数)
router.push('/')
router.push({ name: 'Home' })

// 3. 带参数跳转(query / params)
router.push({
  path: '/user',
  query: { id: 1, name: '张三' }
})
router.push({
  name: 'User',
  params: { id: 1, name: '张三' }
})

// 4. 替换跳转(不保留历史记录)
router.replace('/about')

// 5. 后退/前进
router.go(-1)
router.back()
router.go(1)

方式3:路由重定向(redirect,自动跳转)

核心:在路由配置中通过redirect属性,实现页面自动跳转(无需用户操作),适合默认页面、404页面、旧路径跳转新路径场景,Vue2、Vue3用法一致。

// 路由配置中添加redirect
const routes = [
  // 1. 默认跳转(访问根路径,自动跳转到首页)
  { path: '/', redirect: '/home' },
  // 2. 通过name重定向
  { path: '/index', redirect: { name: 'Home' } },
  // 3. 旧路径跳转新路径(兼容旧链接)
  { path: '/old-user', redirect: '/user' },
  // 4. 404页面(匹配所有未定义路径,跳转到404组件)
  { path: '/:pathMatch(.*)*', redirect: '/404' }
]

三、路由跳转参数接收(配套必备)

跳转时传递的query/params参数,需在目标页面接收后使用,Vue2和Vue3接收方式不同,以下是完整示例:

1. Vue2 参数接收

// 1. 接收query参数
export default {
  mounted() {
    const id = this.$route.query.id // 接收query参数id
    const name = this.$route.query.name // 接收query参数name
    console.log(id, name) // 输出:1 张三
  }
}

// 2. 接收params参数
export default {
  mounted() {
    const id = this.$route.params.id // 接收params参数id
    const name = this.$route.params.name // 接收params参数name
    console.log(id, name) // 输出:1 张三
  }
}

2. Vue3 参数接收

Vue3 setup语法中,需通过useRoute引入路由对象,接收参数:

// 引入路由对象
import { useRoute } from 'vue-router'
const route = useRoute()

// 接收参数(可在setup中直接使用,或在生命周期中使用)
const id = route.query.id // query参数
const name = route.query.name

const paramsId = route.params.id // params参数
const paramsName = route.params.name

console.log(id, paramsId) // 输出:1 1

四、路由跳转进阶:导航守卫(权限控制)

实际开发中,常需要对路由跳转进行权限控制(如未登录不能访问个人中心),此时需使用导航守卫,拦截跳转并判断权限,Vue2、Vue3用法基本一致,以下是实战示例:

1. 全局导航守卫(控制所有路由跳转)

// Vue2 全局导航守卫(router/index.js)
router.beforeEach((to, from, next) => {
  // to:目标路由对象
  // from:当前跳转前的路由对象
  // next:放行/拦截方法
  
  // 示例:未登录不能访问/user路径
  const token = localStorage.getItem('token') // 模拟登录状态
  if (to.path === '/user' && !token) {
    next('/login') // 未登录,拦截并跳转到登录页
  } else {
    next() // 已登录,放行
  }
})

// Vue3 全局导航守卫(用法完全一致)
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  if (to.meta.requireAuth && !token) { // 结合路由元信息,更灵活
    next('/login')
  } else {
    next()
  }
})

// 路由元信息配置(标记需要权限的路由)
const routes = [
  {
    path: '/user',
    name: 'User',
    component: () => import('../views/User.vue'),
    meta: { requireAuth: true } // 标记:需要登录才能访问
  }
]

2. 组件内导航守卫(控制单个组件跳转)

仅对当前组件的跳转进行拦截,适合单个组件的特殊权限控制:

// Vue2 组件内守卫
export default {
  // 进入组件前拦截
  beforeRouteEnter(to, from, next) {
    const token = localStorage.getItem('token')
    if (!token) {
      next('/login')
    } else {
      next()
    }
  },
  // 离开组件前拦截(如提示用户未保存内容)
  beforeRouteLeave(to, from, next) {
    if (confirm('确定要离开吗?内容未保存')) {
      next()
    } else {
      next(false) // 取消跳转
    }
  }
}

// Vue3 组件内守卫(setup语法)
import { onBeforeRouteEnter, onBeforeRouteLeave } from 'vue-router'

// 进入组件前拦截
onBeforeRouteEnter((to, from, next) => {
  const token = localStorage.getItem('token')
  if (!token) {
    next('/login')
  } else {
    next()
  }
})

// 离开组件前拦截
onBeforeRouteLeave((to, from, next) => {
  if (confirm('确定要离开吗?内容未保存')) {
    next()
  } else {
    next(false)
  }
})

五、路由跳转常见问题及解决方案

1. 跳转后页面不刷新

原因:路由参数变化(如从/user/1跳转到/user/2),组件会复用,不会重新触发mounted生命周期。

解决方案:监听路由变化,触发数据重新请求:

// Vue2 监听路由
watch: {
  '$route'(to, from) {
    // 路由变化时,重新请求数据
    this.getUserData(to.params.id)
  }
}

// Vue3 监听路由
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()

watch(
  () => route.params,
  (newParams) => {
    // 监听params变化,重新请求数据
    getUserData(newParams.id)
  },
  { deep: true }
)

2. params参数刷新后丢失

原因:params参数不暴露在URL上,页面刷新后,路由信息重置,参数丢失。

解决方案:1. 改用query参数(适合非敏感数据);2. 将params参数存储到localStorage/sessionStorage,刷新后重新读取。

3. 路由跳转后,URL正确但页面空白

常见原因:1. 路由配置错误(path拼写错误、component路径错误);2. 未在页面中添加<router-view>标签(路由出口,用于渲染跳转后的组件)。

解决方案:核对路由path和component路径,确保App.vue中包含<router-view>

<!-- App.vue 必须添加路由出口 -->
<template>
  <div id="app">
    <router-link to="/"&gt;首页&lt;/router-link&gt;
    &lt;router-view /&gt; <!-- 路由跳转后的组件会渲染在这里 -->
  </div>
</template>

4. Vue3中报错“Cannot read property 'push' of undefined”

原因:Vue3 setup语法中,未通过useRouter引入路由实例,直接使用this.$router(setup中无this)。

解决方案:正确引入useRouter,创建路由实例后再使用:

// 正确用法
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/about') // 无报错

六、总结

Vue路由跳转核心分为3种方式,结合场景选择即可:

  • 简单页面切换:用声明式跳转() ,简洁高效;
  • 需结合业务逻辑(登录、判断):用编程式跳转(router.push) ,灵活可控;
  • 自动跳转(默认页、404):用路由重定向(redirect) ,无需用户操作。

关键注意点:Vue2和Vue3的跳转语法差异主要在“是否使用this”,Vue3需通过useRouter/useRoute引入路由实例和路由对象;参数传递需区分query(刷新保留)和params(刷新丢失);权限控制用导航守卫,避免未授权访问。

本文所有示例均可直接复制到项目中使用,只需根据自身项目的路由配置,修改路径和组件名称即可快速适配。

高并发没那么神秘:用人话讲清系统是怎么被打爆的

作者 LeonGao
2026年4月18日 13:15

提到“高并发”,很多开发者都会觉得神秘又可怕——“系统被高并发打爆了”“QPS太高扛不住了”“雪崩了,救急!”。其实高并发一点都不神秘,说白了就是“请求太多,资源太少,一个环节堵死,全链路崩溃”。

今天就用人话,把“系统被高并发打爆的全过程”讲透,再分享5个直接能用的防御技巧,让你以后遇到高并发,不用慌,知道问题出在哪、该怎么解决。

一、先搞懂:高并发打爆系统,本质是什么?

用一个生活中的例子就能讲明白:服务器就像一个食堂,请求就像来吃饭的人,CPU、内存、数据库、缓存,就像食堂的窗口、桌子、厨师。

平时人少的时候,一切正常;一旦到了饭点,上千人同时涌进食堂,窗口不够、厨师不够、桌子不够,大家挤在一起,没人能吃上饭,最后食堂彻底混乱——这就是高并发打爆系统的本质:请求无限,资源有限,短板效应+连锁反应,最终导致系统宕机

更关键的是:系统被打爆,从来都不是“突然崩了”,而是有一个“循序渐进”的过程,只要能抓住这个过程中的关键节点,就能提前预防,避免崩溃。

二、系统被打爆的4个经典场景(90%的崩溃都在这)

不管是电商秒杀、热搜爆发,还是爬虫攻击,系统被打爆的场景其实就4种,搞懂这4种场景,就能应对大部分高并发问题。

(1)数据库被打崩:最常见的“死法”

数据库是系统的“软肋”,也是最容易被高并发打崩的环节,尤其是没有做缓存的系统,几乎一冲就垮。

崩溃原因(用人话讲):无数请求同时查数据库、数据库连接池被耗尽、SQL写得太烂(没有索引、全表扫描)、多个请求同时修改一条数据(锁竞争)。

连锁反应:数据库响应变慢 → 应用线程一直等待数据库返回结果,导致线程池满 → 应用无法处理新的请求 → 更多用户重试,请求量翻倍 → 数据库压力更大,最终宕机 → 全站卡死。

一句话总结:数据库就一个收银台,1000人同时排队,直接挤爆,后面的人连队都排不上。

(2)缓存雪崩/击穿/穿透:最坑的“死法”

很多人做了缓存,还是被打崩,原因就是没处理好缓存的3个常见问题:雪崩、击穿、穿透。这三个问题,本质都是“缓存没起到作用,请求全砸到了数据库上”。

  • 缓存雪崩:大量缓存Key在同一时间过期 → 所有请求都缓存未命中,直接砸向数据库 → 数据库瞬间被压垮;
  • 缓存击穿:一个超级热点Key(比如首页Banner、热门商品)过期 → 上万个请求同时查询这个Key,缓存未命中,全部打向数据库 → 数据库宕机;
  • 缓存穿透:查询的是不存在的数据(比如爬虫伪造的用户ID、不存在的商品ID) → 缓存不命中,每次都要查数据库 → 大量无效请求持续冲击数据库,最终把数据库打崩。

一句话总结:本来有保安(缓存)拦着歹徒(请求),结果保安突然集体下班(雪崩)、单个保安请假(击穿)、歹徒绕开保安(穿透),歹徒直接冲进银行(数据库),把银行搞垮。

(3)线程/连接池耗尽:应用“自杀式”崩溃

应用的线程池、数据库连接池,都是有上限的,就像食堂的窗口,数量是固定的。一旦请求太多,或者处理请求太慢,就会导致线程/连接池耗尽,应用自己“自杀”。

崩溃原因:请求量突增、接口处理太慢(比如同步调用第三方接口,等待时间太长)、线程没有及时释放(比如代码有死循环、锁没有释放)。

连锁反应:线程池满 → 新的请求无法获取线程,只能排队 → 排队时间过长,请求超时 → 用户反复重试,请求量翻倍 → 线程池彻底耗尽 → 应用无响应,最终崩溃。

一句话总结:食堂只有10个窗口,来了10000人,全堵在门口,后面的人永远进不来,窗口也被占满,最后食堂无法正常运转。

(4)依赖雪崩:第三方/下游拖死你

很多系统都要依赖第三方服务(比如支付接口、短信接口、地图接口),或者下游服务(比如订单服务依赖库存服务),一旦这些依赖的服务出问题,你的系统也会被拖垮。

崩溃原因:第三方/下游服务响应太慢、服务宕机、接口报错 → 你的系统线程一直等待依赖服务返回结果,无法释放 → 线程池耗尽 → 你的系统也无法处理新请求,最终崩溃。

一句话总结:你在等外卖,外卖小哥迷路了,你啥也干不了,后面一堆事(比如做饭、洗碗)全堵死,最后你也没法正常生活。

三、高并发“打爆链”:标准流程(背下来,提前预防)

不管是哪种场景,系统被高并发打爆,都遵循一个固定的“打爆链”,只要能在某个环节打断这个链条,就能避免系统崩溃:

  1. 流量突增:比如秒杀活动开始、热搜爆发、爬虫攻击,请求量瞬间翻倍甚至十倍;
  2. 缓存失效/不够:缓存没有挡住足够的请求,大量请求直接砸向数据库;
  3. 数据库压力飙升:慢查询增多、锁等待时间变长、数据库连接池满;
  4. 应用线程池占满:应用线程一直等待数据库或依赖服务返回,无法释放;
  5. 超时风暴:新请求排队超时,用户和前端反复重试,请求量再次翻倍;
  6. 全链路崩溃:整个集群的CPU、内存、连接池全部耗尽,系统宕机、重启,甚至反复崩溃。

四、人话版:高并发防御“5板斧”(直接用,不用复杂配置)

搞懂了崩溃的原因和流程,防御就很简单了。下面这5个技巧,不用复杂的架构设计,中小团队半天就能落地,能应对90%的高并发场景。

(1)缓存挡在最前面,命中率90%+才算合格

缓存是防御高并发的“第一道防线”,也是最有效的一道防线。核心就是“能缓存就缓存,能不查DB就不查DB”。

具体做法:

  • 热点数据全缓存:商品、用户、配置、排行榜等,只要查询频率高,就放进Redis;
  • 优化缓存策略:过期时间加随机值(防雪崩)、热点Key本地缓存+Redis双缓存(防击穿)、不存在的数据缓存空值(防穿透);
  • 监控缓存命中率:至少保证命中率在90%以上,低于90%就说明缓存策略有问题,需要优化。

(2)数据库绝对不能裸奔,做好3个基础优化

就算缓存挡住了大部分请求,还是会有少量请求进入数据库,所以数据库的优化也必不可少,核心是“减少数据库的压力”。

具体做法:

  • 索引必须优化:禁止无索引查询,常用查询字段(比如商品ID、用户ID、订单号)必须建索引,避免全表扫描;
  • 读写分离:主库写、从库读,把读压力分散到从库,主库只负责写操作,提升写性能;
  • 控制连接池上限:给数据库连接池设置合理的上限,避免连接池耗尽,导致数据库无法处理请求。

(3)限流:直接扔掉多余的流量,别硬扛

高并发场景下,“硬扛”只会让系统崩溃,不如主动“扔掉”多余的流量,保证系统能正常处理核心请求。

具体做法:

  • 实现双重限流:单机限流(控制单个应用的请求量)+ 分布式限流(控制整个集群的请求量),推荐用Redis+Lua实现分布式限流;
  • 设置合理阈值:根据系统的承载能力,设置每秒能处理的最大请求数,超过阈值直接返回“系统繁忙,请稍后再试”,不要让多余的请求进入系统,消耗资源。

(4)熔断+降级:保核心,弃次要,留一线生机

高并发高峰期,与其让整个系统崩溃,不如主动关掉非核心功能,优先保证核心功能可用——这就是降级;遇到依赖服务挂了,就及时切断调用,避免拖垮自己——这就是熔断。

具体做法:

  • 熔断:给依赖的服务设置超时时间和失败次数阈值,一旦超时次数或失败次数达到阈值,就自动熔断,停止调用该服务,快速返回失败结果,等依赖服务恢复后,再自动恢复调用;
  • 降级:高峰期关掉非核心功能,比如电商的评论、推荐、统计、历史订单查询,把所有资源都用来支撑下单、支付、登录这些核心操作,等高峰期过后,再恢复非核心功能。

(5)异步削峰:别同步硬扛,让系统“喘口气”

很多系统被打崩,是因为大量请求同步处理,导致线程一直被占用,无法释放。异步削峰,就是让请求“快速进入、慢慢处理”,给系统“喘口气”的时间。

具体做法:

  • 能异步的全异步:下单、支付通知、短信发送、日志记录等操作,都用异步处理,前端快速返回“请求已接收”,后台用消息队列(Kafka/RocketMQ)慢慢处理;
  • 用消息队列削峰:请求高峰期,消息队列先接收所有请求,再按照系统的处理能力,慢慢把请求分发到应用中,避免请求瞬间冲击系统。

最后总结

高并发没那么神秘,本质就是“请求太多,资源太少”。系统被打爆,不是突然发生的,而是有一个循序渐进的过程,只要做好“缓存挡、数据库护、限流卡、熔断降、异步削”这5件事,就能让你的系统在高并发下稳稳运行。

对普通开发者来说,不用追求“高大上”的架构,先把这些基础的防御技巧做好,就能应对大部分高并发场景,避免系统被打爆。收藏这篇文章,下次遇到高并发问题,直接对照着做,不用慌!

不用学微服务,也能设计不崩的系统:最小可行思路

作者 LeonGao
2026年4月18日 13:14

做开发的都有个误区:觉得系统要稳定,就必须上微服务。尤其是刚接触架构设计的同学,总觉得“微服务=高级、稳定”,哪怕是小团队、小项目,也硬着头皮拆微服务,最后运维搞崩、调试搞崩、部署搞崩,反而比单体系统更不稳定。

其实,对90%的中小团队、普通项目来说,稳定 ≠ 微服务;稳定 = 简单 + 边界清晰 + 防雪崩设计。先把单体做扎实、做好“防崩三板斧”,比硬上微服务强10倍。今天就分享一套“最小可行稳定架构”(MVA),不用微服务,也能让你的系统扛住百万级DAU、万级QPS,稳稳当当不崩溃。

一、为什么不用急着上微服务?

先明确一个核心认知:微服务是“解决方案”,不是“标配”。它的核心作用是解决“系统庞大、业务复杂、高并发、多团队协作”的问题,就像一剂猛药,对症才有效,乱喝只会伤身。

对小团队、小项目来说,硬上微服务只会带来3个“爆炸式”麻烦:

  • 运维爆炸:需要部署多个服务、管理服务间通信、处理服务降级熔断,运维成本直接翻倍;
  • 调试爆炸:一个问题可能跨多个服务,排查起来要翻多个服务的日志,效率极低;
  • 部署爆炸:多个服务需要协调部署顺序,稍有不慎就会出现服务依赖失败,导致整个系统不可用。

更关键的是:90%的系统崩溃,不是因为单体架构,而是因为没做缓存、没做限流、没做隔离,或是数据库设计得太烂。与其花精力搞微服务,不如先把这些基础工作做扎实。

二、最小可行稳定架构(MVA):5条铁律,直接套用

这套思路的核心是“简单、可控、防崩”,不需要复杂的架构设计,中小团队半天就能落地,落地后就能解决大部分系统稳定问题。

(1)模块化单体,不是“一坨代码”

很多人吐槽单体架构,其实吐槽的是“混乱的单体”——所有代码堆在一起,没有分层、没有模块,改一个地方牵一发而动全身。真正好用的单体,是“模块化单体”,核心是“分层清晰、模块隔离”。

具体做法很简单:

  • 代码严格分层:Controller(接收请求)→ Service(业务逻辑)→ Dao(数据访问)→ DB(数据库),每层只做自己的事,不跨层调用;
  • 业务拆分成独立模块:比如用户模块、订单模块、商品模块、支付模块,模块间只通过接口调用,绝对不允许直接操作其他模块的数据库或方法;
  • 好处:部署简单(只部署一个应用)、调试简单(问题定位在单个模块内)、扩容简单(单机扩容就能扛住大部分流量);更重要的是,以后业务增长了,要拆微服务,直接把模块拆出来就行,不用重构代码。

(2)必须加缓存:挡住80%~90%的查询请求

数据库是系统的“性能瓶颈”,尤其是读请求,一旦并发量上来,数据库很容易被打崩。而缓存,就是挡住这些读请求的“第一道防线”,能直接挡住80%~90%的查询,让数据库只处理少量的写请求和缓存未命中的读请求。

缓存的核心用法(不用复杂,做到这3点就够):

  • 热点数据全进Redis:商品信息、用户信息、配置信息、排行榜、热门文章等,只要是查询频率高、更新频率低的数据,都放进Redis;
  • 核心规则:能缓存就缓存,能不查DB就不查DB,优先从缓存获取数据,缓存未命中再查DB,查完后同步更新缓存;
  • 简单防崩技巧:缓存预热(系统启动时,提前把热点数据加载到缓存)、过期时间加随机值(避免大量缓存同一时间过期,导致缓存雪崩)、热点Key永不过期(比如首页Banner、热门商品,直接设置永不过期,更新时手动刷新)。

(3)必须做限流、降级、熔断:系统的“安全气囊”

哪怕你做了缓存,也难免遇到流量突增(比如活动、热搜、爬虫),这时候限流、降级、熔断就是系统的“安全气囊”,能在流量过载时保护系统不崩溃。

用大白话讲清楚三者的区别和用法:

  • 限流:相当于给系统“设上限”,每秒只允许一定数量的请求进入,超过这个上限就直接返回“系统繁忙,请稍后再试”,比如设置每秒最多处理1000个请求,多余的请求直接拒绝,避免系统被压垮;
  • 降级:高峰期“弃卒保车”,关掉非核心功能,优先保证核心功能可用。比如电商高峰期,关掉评论、推荐、统计这些非核心功能,把所有资源都用来支撑下单、支付、登录这些核心操作;
  • 熔断:当依赖的服务(比如第三方支付、短信接口)挂了或者响应太慢时,自动切断调用,不反复重试,快速返回失败结果,避免因为等待依赖服务而耗尽系统线程,导致整个系统卡死。

(4)资源隔离:别让一个功能拖垮整个系统

很多系统崩溃,都是“连锁反应”——一个功能出问题,导致整个系统不可用。比如订单接口慢,导致线程池满,进而导致登录、商品查询等所有接口都慢,最后全站卡死。而资源隔离,就是避免这种连锁反应的关键。

核心隔离手段(3个最实用的):

  • 线程池隔离:给不同的业务模块分配独立的线程池,比如订单模块用一个线程池,支付模块用一个线程池,查询模块用一个线程池,一个模块的线程池满了,不会影响其他模块;
  • 数据库隔离:核心业务数据库(比如订单库、用户库)和非核心数据库(比如日志库、统计库)分开,不共用一个连接池,避免非核心业务的查询拖慢核心数据库;
  • 读写分离:数据库主库负责写操作(新增、修改、删除),从库负责读操作(查询),把读压力分散到从库,避免读请求影响写请求的性能。

(5)监控+告警:从第一天就必须有

很多系统崩溃后,开发者还不知道,直到用户投诉才发现——这就是没有监控和告警的后果。监控和告警,是系统稳定的“眼睛”,能让你在问题出现的第一时间发现,及时处理,避免问题扩大。

必看的7个核心指标(少一个都不行):

  • 系统指标:CPU使用率、内存使用率、磁盘使用率;
  • 接口指标:QPS(每秒请求数)、响应时间、错误率;
  • 数据库指标:数据库连接数、慢查询数量、锁等待时间;
  • 缓存指标:缓存命中率、缓存过期数量、缓存报错数量。

告警规则:给每个指标设置阈值,超过阈值就立即告警(比如CPU使用率超过80%、响应时间超过500ms、错误率超过1%),告警方式优先选短信+企业微信/钉钉,确保能第一时间收到通知。

三、最小可行架构Checklist(直接复制使用)

落地完成后,对照这个Checklist检查一遍,确保没有遗漏,做到这些,你的系统基本不会崩:

  • ✅ 采用模块化单体,分层清晰、模块隔离
  • ✅ Redis缓存全覆盖热点数据,缓存命中率≥90%
  • ✅ 接口实现单机+分布式限流,设置合理阈值
  • ✅ 实现熔断降级,优先保证核心业务可用
  • ✅ 数据库做好索引优化、读写分离,控制连接池上限
  • ✅ 部署监控+告警,核心指标全覆盖
  • ✅ 关键组件(数据库、Redis)做主备,避免单点故障

最后总结一句:对中小团队来说,“简单可控”比“高大上”更重要。不用盲目追求微服务,先把上面这7条做好,你的系统在百万级DAU、万级QPS下,基本能稳稳运行,比硬上微服务更省心、更稳定。

类型与语法的“直觉对齐”:TS 切入的 Go 语言初体验

作者 donecoding
2026年4月18日 11:23

🚀 省流助手(速通结论)

  • 物理顺序声明:Go 没有任何形式的声明置后。:= 是声明+推导+赋值的原子操作,它必须在逻辑读取前完成“内存占位”。
  • 零值机制:彻底消灭 undefined。变量永远有初值(0, "", false),这种“零值机制”让防御性编程不再靠猜。
  • 引号分级:单引号 ' 是数字(Rune),双引号 " 是字符串,反引号 ` 只是纯文本“复印机”,不支持 ${} 插值。
  • Map 门槛:Go 的 map 行为对标 new Map()。禁止在 nil(未 make)状态下写入,否则程序直接崩溃。

1. 变量声明与提升:绝对的物理顺序

在 TS 中,由于其复杂的编译背景,我们有时会下意识地混淆“声明”与“可见性”。但在 Go 面前,物理行号即是编译器的唯一识别边界。Go 编译器是单向阅读的,不存在任何形式的标识符预扫描。

TypeScript(现代规范:先声明后使用)

function initSystem() {
    // 现代 TS 严格要求先声明后使用,否则触发暂时性死区 (TDZ)
    const isDevelopment = process.env.NODE_ENV === 'development';
    
    if (isDevelopment) { 
        console.log("Debug Mode");
    }
}

Go(严格逻辑:物理行号即生死线)

func main() {
    // 虽然 TS 也要先声明,但 Go 的 := 是一种更彻底的“原地占位”
    // 在这一行之前,isDev 这个标识符在当前作用域内完全不存在
    
    isDev := os.Getenv("ENV") == "dev" 
    if isDev {
        fmt.Println("Debug Mode")
    }
}

🪝 思维钩子:Go 没有“回头路”。:= 不仅是赋值,它是声明+推导+内存分配的原子操作。在执行这一行前,该变量名在编译器眼里尚未被“拨备”,这要求你在重构代码块时必须保持极强的线性逻辑。


2. 零值 vs Undefined:再见,运行时空指针

TS 程序员的一半生命都在处理 Cannot read property of undefined。Go 认为“不可预测的空”是程序不稳定的根源,因此引入了强悍的零值机制。

TypeScript(不可预测的初始状态)

let count: number;
// 在 TS 中,仅声明不赋值会导致变量处于 undefined
// 即使开启了 strictPropertyInitialization,也常在复杂场景下产生运行时不确定性

Go(确定的物理起始点)

var count int
fmt.Println(count) // ✅ 0 (内存已自动初始化填零)

var name string
fmt.Println(name)  // ✅ "" (空字符串,不是 nil)

🪝 思维钩子:在 Go 中,有类型必有初值。变量被创造的那一刻,它就处于一个可预测、可参与运算的起始状态。这种“内存填零”的承诺,让你不再需要猜测变量是否被“填充”过。


3. 引号的阶级森严:被类型锁死的语义

在 TS 中,引号是风格问题(Linter 说了算);在 Go 中,引号是指令(编译器说了算)。

TypeScript(风格自由)

const s = 'Hello';          // ✅ 常用
const message = `Value: ${v}`; // ✅ 模板字符串插值

Go(类型锁死)

s := "Hello" // ✅ 字符串必须双引号

// char := 'Hello' // ❌ 编译报错:单引号不能包多个字符
char := 'H'        // ✅ 这是 int32 类型 (代表数字 72)

raw := `Raw Text`  // ✅ 原始文本,但不支持 ${} 插值

🪝 思维钩子:单引号 = 数字。如果你在 Go 里用单引号包了一串字符,编译器会认为你试图在一个存储单个字符(Rune)的容器里强塞一段序列。


4. 集合的真身:Map 的内存门槛

在 TS 中,对象 {} 可以承载绝大部分映射需求。但在 Go 中,必须区分“标识符声明”与“内存空间分配”。

TypeScript(动态初始化)

const cache: Record<string, number> = {};
cache["token"] = 123; // ✅ 随时随地,直接写入

Go(引用类型需 make)

var cache map[string]int // ⚠️ 只是声明了名字,内存指针仍是 nil

// cache["token"] = 123  // ❌ 运行时崩溃 (Panic!)

cache = make(map[string]int) // ✅ 必须使用 make 分配底层哈希表空间
cache["token"] = 123

🪝 思维钩子:Go 的 map 对标的是 TS 的 new Map()。在 TS 里 {} 是个空盒子;在 Go 里 nil map 是一个尚未制造出来的盒子。向一个不存在的地方放东西,程序直接奔着崩溃去。


下篇预告:
下一篇我们将进入 Go 逻辑组织的核心:结构体方法(Receiver)、权限控制(大小写)以及最关键的内存控制——指针。为什么 a := b 后改 a 却不动 b?我们下一篇见。

你的 Vue v-for,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月18日 11:06

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-for 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-for 指令用法。

编译对照

基础数组遍历

最简单的 v-for 指令,用于遍历数组并渲染列表项。

  • Vue 代码:
<li v-for="(item, i) in list" :key="item.id">{{ i }} - {{ item.name }}</li>
  • VuReact 编译后 React 代码:
{
  list.map((item, i) => (
    <li key={item.id}>
      {i} - {item.name}
    </li>
  ));
}

从示例可以看到:Vue 的 v-for 指令被编译为 React 的 map 函数。VuReact 采用 数组映射编译策略,将模板指令转换为 JSX 数组表达式,完全保持 Vue 的列表渲染语义——遍历数组中的每个元素,生成对应的 JSX 元素,并自动处理 key 属性以保证 React 的渲染性能。


对象遍历

v-for 也可以用于遍历对象的属性和值。

  • Vue 代码:
<li v-for="(val, key, i) in obj" :key="key">{{ i }} - {{ key }}: {{ val }}</li>
  • VuReact 编译后 React 代码:
{
  Object.entries(obj).map(([key, val], i) => (
    <li key={key}>
      {i} - {key}: {val}
    </li>
  ));
}

对于对象遍历,VuReact 采用 Object.entries 转换策略,将 Vue 的对象遍历语法转换为 Object.entries(obj).map() 形式。这种编译方式完全模拟 Vue 的对象遍历语义——按顺序遍历对象的键值对,保持 (值, 键, 索引) 的参数顺序,确保数据渲染的一致性。


嵌套 v-for 循环

复杂的嵌套列表渲染,使用多层 v-for 循环。

  • Vue 代码:
<div v-for="category in categories" :key="category.id">
  <h3>{{ category.name }}</h3>
  <ul>
    <li v-for="product in category.products" :key="product.id">
      {{ product.name }} - ${{ product.price }}
    </li>
  </ul>
</div>
  • VuReact 编译后 React 代码:
{
  categories.map((category) => (
    <div key={category.id}>
      <h3>{category.name}</h3>
      <ul>
        {category.products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  ));
}

对于嵌套循环,VuReact 采用 嵌套 map 函数编译策略,将 Vue 的嵌套 v-for 转换为嵌套的 map 函数调用。这种编译方式完全保持 Vue 的嵌套循环语义——外层循环的每个迭代都会创建内层循环的完整列表,保持组件结构的层次关系。


v-if + v-for

实际业务中经常需要结合条件进行列表渲染。

  • Vue 代码:
<template v-if="cond" v-for="user in users" :key="user.id">
  <img :src="user.avatar" :alt="user.name" />
  <div class="user-info">
    <h4>{{ user.name }}</h4>
    <p>{{ user.email }}</p>
    <span class="role-badge">{{ user.role }}</span>
  </div>
  <div class="user-actions">
    <button @click="editUser(user.id)">编辑</button>
    <button @click="deleteUser(user.id)" class="danger">删除</button>
  </div>
</template>
  • VuReact 编译后 React 代码:
{
  cond
    ? users.map((user) => (
        <div key={user.id} className="user-card">
          <img src={user.avatar} alt={user.name} />
          <div className="user-info">
            <h4>{user.name}</h4>
            <p>{user.email}</p>
            <span className="role-badge">{user.role}</span>
          </div>
          <div className="user-actions">
            <button onClick={() => editUser(user.id)}>编辑</button>
            <button onClick={() => deleteUser(user.id)} className="danger">
              删除
            </button>
          </div>
        </div>
      ))
    : null;
}

对于带条件的列表渲染,VuReact 展示了智能的条件编译能力

  1. 优先条件编译:将 v-if 转换为三元表达式,包裹整个 v-for 渲染结果
  2. 自动提取 key:当 <template> 标签上存在 :key 属性时,会自动将其传递给内部的第一个子元素
  3. 事件绑定处理@click 转换为 onClick,并自动包装为箭头函数以传递参数
  4. 属性绑定转换:src:alt 等转换为 React 属性语法
  5. 样式类名处理class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的列表渲染语义,同时生成符合 React 最佳实践的代码。


使用 v-for 范围值

Vue 的 v-for 也支持使用数字范围进行迭代。

  • Vue 代码:
<span v-for="n in 5" :key="n">{{ n }}</span>
  • VuReact 编译后 React 代码:
{
  Array.from({ length: 5 }, (_, n) => (
    <span key={n + 1}>{n + 1}</span>
  ));
}

对于范围值迭代,VuReact 采用 Array.from 转换策略,将 Vue 的数字范围语法转换为数组生成和映射。这种编译方式完全模拟 Vue 的范围迭代语义——从 1 开始到指定数字结束(包含),保持迭代顺序和数值的一致性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

零成本打造专业域名邮箱:Cloudflare + Gmail 终极配置保姆级全攻略

作者 Immerse
2026年4月18日 11:00

大家好,我是 Immerse

专注分享 AI 玩法独立开发AI 出海的 AGI 实践者,更多干货欢迎关注公众号 #沉浸式AI 或访问 yaolifeng.com


如果你手上有自己的域名,只是想把 hi@你的域名.com 用起来收发邮件,又不想每年给 Google Workspace 或 Microsoft 365 交钱,这套 Cloudflare Email Routing + Gmail 现在依然够用。

Cloudflare Email Routing 负责把别人发到你域名邮箱的邮件转进 Gmail。Gmail 负责从这个地址回信。

它不是完整企业邮箱,但拿来放在博客、作品集、简历、联系页、独立开发者官网上,已经很够用了。

主要边界

  • 能做到:别人发到你的域名邮箱,你在 Gmail 里收到;你也能在 Gmail 里用这个域名地址发信和回信
  • 做不到:Cloudflare 不给你真正的邮箱空间,也不提供发信服务器;多人协作、共享日历、管理员后台,这套都没有
  • 要注意:Cloudflare 开启 Email Routing 时会接管你这个域名的邮件 MX 记录。如果你这个域名已经在用腾讯企业邮、阿里企业邮、Google Workspace,不要直接点开通

如果你要一个看起来专业、能正常往来邮件的联系邮箱,这套很合适。你要的是团队邮箱系统,那就别省这点钱,直接上正式服务。

准备资料

  1. 一个已经接入 Cloudflare DNS 的域名
  2. 一个你能正常登录的 Gmail 账号
  3. 能开启 Google 两步验证
  4. 一个备用邮箱用来做测试,QQOutlook、另一个 Gmail 都行

部分参考文档:

先把收件打通

先做最小可用版:让别人发到 hi@你的域名.com 的邮件,能够进你的 Gmail。

  1. 登录 Cloudflare,进入你的域名,打开 Email -> Email Routing
  2. 第一次开通时,Cloudflare 会让你添加或替换邮件相关的 MXTXT 记录。这里要认真看一眼,如果面板提示要删除旧的 MX,就代表旧邮箱服务会一起停掉
  3. 开通后,去 Routing rules 里创建一个自定义地址。比如把 hi@你的域名.com 转发到 yourname@gmail.com
  4. Cloudflare 会往你的 Gmail 发一封验证邮件。点掉验证之前,这条转发规则不算真的生效
  5. 验证完成后,用另一个邮箱发一封测试邮件到你的域名邮箱,不要直接用同一个 Gmail 自己给自己发,很多时候会把你绕晕

你看到的正确结果应该是这样的:

  • Gmail 收到了这封信
  • 收件人还是你的域名邮箱,不是裸露的 Gmail
  • Cloudflare 面板里的规则状态是已启用

小建议:有建明确地址,不要一上来开 Catch-allCatch-all 很方便,但也很容易把拼错地址、垃圾邮件、爬虫乱发的邮件一起吞进来。大多数人先用 hicontacthello 这种固定地址就够了。

再把发件打通

收件只是前半段。那怎么用对应的这个域名发出去。

注意,Cloudflare 不负责发信。它只是把邮件转进来。真正往外发信的,还是 Gmail,或者你后面换掉的别家 SMTP 服务。

开 Google 两步验证,生成应用专用密码

Gmail 不会让你直接拿账号密码去配 SMTP。你要用的是应用专用密码。

  1. 打开 Google 账号的安全页,先把两步验证开起来
  2. 开完以后,再进入 应用专用密码
  3. 新建一个密码,名字随便写,比如“域名邮箱”
  4. Google 会给你一串 16 位密码,保存下来

如果找不到“应用密码”入口,可能的两个原因:

  • 你的账号还没开两步验证
  • 你开了 Advanced Protection,Google 官方说明里明确写了,这种模式下应用专用密码不可用

碰到第二种情况,就别死磕了。要么退出 Advanced Protection,要么直接跳到后面的“换第三方 SMTP”方案。

在 Gmail 里添加对应的发件域名

  1. 打开 Gmail,点右上角设置,进入“查看所有设置”
  2. 打开 账号和导入
  3. 在“用这个地址发送邮件”里点“添加其他电子邮件地址”
  4. 名字填你想展示给别人的名字,邮箱填你的域名地址,比如 hi@你的域名.com
  5. Treat as an alias 这个选项,默认保留勾选就行。你本来就是在给同一个人加另一个发件地址
  6. SMTP 配成下面这样
SMTP 服务器:smtp.gmail.com
端口:465
用户名:你的 Gmail 完整地址
密码:刚刚生成的 16 位应用专用密码
加密方式:SSL

如果 465 + SSL 连不上,再试一次 587 + TLS。这是 Google 官方文档里也支持的组合。

  1. 提交后,Gmail 会往你的域名邮箱发一封确认邮件
  2. 这封确认邮件会先到 Cloudflare,再转发进你的 Gmail
  3. 点掉确认链接,或者把验证码填回去,这个发件地址就能用了

配置自动选择回复邮件

注意,如果你想要别人给你发了邮件,你想用对应的邮件回复,这一步设置是必须的。

设置下面这一项

测试整个流程

用其他邮箱,比如 QQ,163 邮箱测试一遍

  1. 用另一个邮箱发信到 hi@你的域名.com
  2. 去 Gmail 收件箱里打开这封信,直接点回复
  3. 发出去之前,看一眼 From,确认显示的是你的域名邮箱
  4. 回到对方邮箱,确认能收到回复

到这一步,主流程就已经跑通了。

对方为什么还会看到 via gmail.com 或者 on behalf of

这不是你哪里配错了,很多时候是 Gmail 这条发信链路本来就有这个边界。

你现在这套方案里:

  • Cloudflare 负责收件转发
  • Gmail 负责真正把邮件发出去

所以在部分收件客户端里,对方可能会看到你的 Gmail 痕迹,比如 via gmail.com,或者 yourname@gmail.com on behalf of hi@你的域名.com

这在个人沟通、博客联系邮箱、独立开发者业务往来里通常问题不大

最容易踩的几个坑

  • Cloudflare 规则建好了但一直收不到邮件。先看目标 Gmail 有没有点验证邮件,没验证就不会真正转发
  • 一开通 Email Routing,原来的企业邮箱突然废了。因为 MX 已经被 Cloudflare 接管了
  • Gmail 一直提示用户名或密码错误。这里填的不是你的 Google 登录密码,是 16 位应用专用密码
  • Google 账号里根本没有应用专用密码。先查两步验证,再查是不是开了 Advanced Protection
  • 你给自己发测试邮件,觉得没收到。先换一个别的邮箱测,自己给自己发最容易被 Gmail 的会话和去重逻辑干扰判断
  • 对方看到 via gmail.com。这通常不是配置错,是 Gmail SMTP 的边界

真要说这套配置最难的地方,也就两个:一个是别把 MX 记录改糊涂,另一个是 Gmail 一定要用应用专用密码。

大人工智能时代下前端界面全新开发模式的思考(五)

2026年4月18日 10:51

第五章:角色的重构——AI时代前端工程师的核心竞争力

当工具改变时,工作方式必然改变;当工作方式改变时,职业定位必然重构。在AI时代,前端工程师的角色正在经历深刻的转变。这一章我们将探讨这种转变的内涵、必要的能力模型,以及未来的发展方向。


5.1 能力模型的转变:从"写代码"到"驾驭AI"

5.1.1 传统能力 vs 新兴能力对比

传统能力 重要性变化 新兴能力 培养方式
手写代码速度 ↓↓ 大幅降低 需求理解与拆解 ↑↑ 业务分析训练
语法记忆 ↓↓↓ 几乎不再重要 Prompt Engineering ↑ 系统学习 + 实践
框架熟练度 ↓ 适度下降 架构设计能力 ↑↑ 理论学习 + 项目实践
Debug能力 → 保持重要 AI结果审查能力 ↑↑↑ Code Review训练
CSS细节掌握 ↓ 适度下降 用户体验敏感度 ↑↑ 设计思维训练
业务理解 ↑ 更加重要 系统思维能力 ↑↑ 跨领域学习
学习能力 ↑↑ 至关重要 AI工具学习能力 ↑↑↑ 持续实践
代码优化 ↓ 适度下降 质量把控能力 ↑↑ 建立检查清单
文档编写 ↓ 适度下降 知识萃取能力 ↑ 写作训练
团队协作 ↑ 更加重要 AI协作能力 ↑↑ 流程设计

5.1.2 为什么这些能力在变化?

1. 手写代码速度不再重要

AI可以在几秒内生成几十行代码。人类的打字速度不再是瓶颈。重点转向"写什么"而非"怎么写"。

案例

任务:写一个表单验证函数

传统方式:
- 思考验证逻辑(5分钟)
- 手写代码(10分钟)
- 调试(5分钟)
- 总计:20分钟

AI方式:
- 写Prompt(1分钟)
- AI生成(10秒)
- 审查修改(3分钟)
- 总计:4分钟

效率提升:5倍

2. Prompt Engineering成为核心技能

与AI沟通需要学习新的"语言"。好的Prompt可以让AI输出质量提升10倍。

Prompt质量的差异

❌ 低效Prompt(输出质量60分):
"写一个登录组件"

✅ 高效Prompt(输出质量90分):
"创建一个企业级登录表单组件

技术栈:React 18 + TypeScript + Tailwind CSS + shadcn/ui

功能要求:
1. 表单字段:邮箱(必填,验证格式)、密码(必填,8+字符)
2. 实时验证:失去焦点时验证,显示错误信息
3. 记住我功能:使用localStorage持久化
4. 加载状态:提交时显示Spinner,禁用按钮
5. 错误处理:显示API返回的错误信息

UI要求:
1. 居中卡片布局,最大宽度400px
2. 深色主题支持
3. 移动端适配

可访问性:
1. 所有输入框关联label
2. 错误信息使用aria-describedby
3. 支持键盘导航

代码规范:
1. 使用函数组件和Hooks
2. 完整TypeScript类型定义
3. 添加JSDoc注释
4. 导出为可复用组件"

3. AI结果审查能力至关重要

AI生成的代码可能有错误、安全漏洞、性能问题。能够快速识别这些问题成为核心竞争力。

这需要:

  • 深厚的技术功底(知道什么是好的代码)
  • 安全意识(识别潜在漏洞)
  • 性能敏感度(发现性能陷阱)
  • 可访问性知识(检查a11y问题)

5.1.3 新能力培养路线图

短期(1-3个月):掌握基础

## 第一个月:工具熟练

Week 1-2: IDE集成工具
├─ 安装并配置Cursor或GitHub Copilot
├─ 学习快捷键和核心功能
├─ 建立个人Prompt库
└─ 目标:AI生成代码占日常编码30%

Week 3-4: 设计转代码工具
├─ 注册v0.dev账号
├─ 实践5个不同类型的UI生成
├─ 学习如何将v0代码集成到项目
└─ 目标:原型开发速度提升50%

## 第二个月:Prompt工程

Week 5-6: Prompt基础
├─ 学习结构化Prompt写法
├─ 掌握Few-shot示例技巧
├─ 理解上下文管理
└─ 目标:Prompt质量达到80分

Week 7-8: 高级Prompt技巧
├─ 链式Prompt(Chain-of-Thought)
├─ 多模态Prompt(图文混合)
├─ Prompt模板化
└─ 目标:建立团队Prompt库

## 第三个月:质量把控

Week 9-10: 代码审查
├─ 建立个人审查清单
├─ 学习识别AI常见错误模式
├─ 实践审查20+ AI生成代码
└─ 目标:审查时间<10分钟/组件

Week 11-12: 安全与性能
├─ 学习常见安全漏洞
├─ 掌握性能优化技巧
├─ 建立安全检查自动化
└─ 目标:AI代码零安全漏洞上线

中期(3-12个月):深化能力

## 第4-6个月:架构设计

1. AI-Native应用架构
   ├─ 学习Vercel AI SDK深度使用
   ├─ 掌握Streaming UI设计模式
   ├─ 理解Tool Calling系统设计
   └─ 实践项目:构建AI聊天应用

2. 多Agent协作
   ├─ 学习Agent设计模式
   ├─ 理解Orchestrator架构
   ├─ 实践Multi-Agent系统
   └─ 实践项目:构建AI工作流系统

## 第7-9个月:质量体系建设

1. 自动化质量门禁
   ├─ 建立CI/CD流水线
   ├─ 集成安全扫描(SAST)
   ├─ 集成性能测试(Lighthouse CI)
   └─ 实践项目:建立团队质量标准

2. Prompt资产管理
   ├─ 建立团队Prompt库
   ├─ 制定Prompt编写规范
   ├─ 建立Prompt版本管理
   └─ 实践项目:构建Prompt管理系统

## 第10-12个月:领导与影响

1. 团队赋能
   ├─ 培训团队成员使用AI工具
   ├─ 建立AI使用最佳实践
   ├─ 推动AI流程标准化
   └─ 目标:团队效率提升30%

2. 行业影响
   ├─ 撰写技术博客分享经验
   ├─ 参与开源项目贡献
   ├─ 技术演讲和分享
   └─ 目标:建立个人技术品牌

长期(1-3年):成为专家

## Year 1: AI系统架构师

目标:能够设计AI原生前端系统

关键里程碑:
- 主导2+ AI原生项目架构
- 设计并实施团队AI开发流程
- 建立可复用的AI组件库
- 发表3+ 技术文章或演讲

## Year 2: 跨领域专家

目标:融合技术、产品、设计能力

关键里程碑:
- 具备产品思维,能独立设计产品
- 具备设计能力,能进行UI/UX设计
- 理解业务,能与业务方深度沟通
- 建立跨领域影响力

## Year 3: 行业引领者

目标:成为行业认可的AI前端专家

关键里程碑:
- 出版技术书籍或课程
- 在顶级技术会议演讲
- 建立行业标准或规范
- 培养下一代AI前端工程师

5.2 从"创造者"到"策展人"的角色转变

5.2.1 角色的本质转变

传统角色:代码的创造者(Creator)
├─ 从0开始编写每一行代码
├─ 对代码有完全的控制权
├─ 工作是线性的:需求 → 编码 → 测试 → 交付
├─ 技能重点:语法、框架、算法
└─ 价值体现:编码速度和代码质量

新角色:AI输出的策展人(Curator)
├─ 指导AI生成代码,然后筛选、修改、整合
├─ 对代码有审核和决策权
├─ 工作是循环的:需求 → AI生成 → 审查 → 反馈 → 再生成 → ...
├─ 技能重点:需求理解、Prompt工程、质量把控
└─ 价值体现:决策质量和最终交付质量

5.2.2 策展人的核心工作

1. 需求翻译(Requirement Translation)

将业务需求转化为AI可以理解的Prompt。

业务需求(模糊):
"做一个用户管理功能"

策展人拆解:
1. 功能需求:
   ├─ 展示用户列表(姓名、邮箱、角色、状态)
   ├─ 支持搜索(按姓名、邮箱)
   ├─ 支持筛选(按角色、状态)
   ├─ 支持分页(每页10/20/50条)
   ├─ 支持行内编辑(姓名、邮箱、角色)
   └─ 支持删除(带确认对话框)

2. 技术需求:
   ├─ 使用React + TypeScript
   ├─ 使用shadcn/ui组件库
   ├─ 使用React Query进行数据获取
   ├─ 实现乐观更新
   └─ 处理加载状态和错误状态

3. 可访问性需求:
   ├─ 所有交互元素支持键盘导航
   ├─ 屏幕阅读器友好
   ├─ 颜色对比度符合WCAG 2.1 AA
   └─ 焦点管理正确

4. 性能需求:
   ├─ 列表虚拟滚动(数据量大时)
   ├─ 搜索防抖(300ms)
   ├─ 图片懒加载
   └─ Bundle大小<100KB

转化为Prompt:
"创建一个企业级用户管理表格组件...(详细Prompt见第三章)"

2. 质量把控(Quality Assurance)

审查AI生成代码的正确性、性能、安全、可维护性。

## 质量把控检查清单

### 功能正确性
- [ ] 是否实现了所有需求?
- [ ] 边界情况是否处理?
- [ ] 错误处理是否完善?
- [ ] 状态管理是否正确?

### 代码质量
- [ ] 是否符合团队代码规范?
- [ ] 是否有重复代码?
- [ ] 命名是否清晰?
- [ ] 注释是否充分?

### 性能
- [ ] 是否有不必要的重渲染?
- [ ] 是否正确使用useMemo/useCallback?
- [ ] 是否有内存泄漏风险?
- [ ] Bundle大小是否合理?

### 安全
- [ ] 是否有XSS风险?
- [ ] 用户输入是否验证?
- [ ] 敏感操作是否有权限检查?
- [ ] 是否有CSRF防护?

### 可访问性
- [ ] 是否有alt属性?
- [ ] 是否有label关联?
- [ ] 是否支持键盘导航?
- [ ] 颜色对比度是否足够?

3. 创意指导(Creative Direction)

提供设计方向和用户体验建议,在多个AI生成方案中做出选择。

场景:AI生成了3个不同的按钮设计方案

方案A:传统扁平设计
方案B:新拟物化设计(Neumorphism)
方案C:玻璃拟态设计(Glassmorphism)

策展人决策:
├─ 考虑品牌调性:企业级产品,需要稳重感
├─ 考虑用户群体:B端用户,注重效率而非视觉
├─ 考虑可维护性:方案A最成熟,生态最好
├─ 考虑实现成本:方案A开发成本最低
└─ 决策:选择方案A,但在hover状态添加微动效提升体验

4. 最终决策(Final Decision)

决定哪些代码可以进入生产环境,对技术选型和架构方案拍板。

决策责任:
- 代码是否合并到主分支?
- 技术选型是否采用?
- 架构方案是否可行?
- 项目是否按期交付?

承担最终的质量责任和业务责任

5.2.3 角色转变的挑战

挑战1:控制欲的释放

传统思维:"我自己写代码,完全可控"
新思维:"我指导AI写代码,信任但要验证"

困难:
- 不放心AI生成的代码
- 总想手动修改每一处
- 效率提升不明显

解决方案:
- 建立质量门禁,信任检查结果
- 从小功能开始,逐步建立信心
- 记录AI错误模式,针对性改进Prompt

挑战2:价值感的重构

传统价值:"我写了1000行代码,很有成就感"
新价值:"我通过AI完成了一个功能,效率提升5倍"

困难:
- 感觉"不是自己写的"
- 成就感降低
- 职业价值感危机

解决方案:
- 重新定义价值:从"写代码"到"解决问题"
- 关注业务价值:功能上线、用户满意
- 关注团队价值:知识分享、流程优化

挑战3:学习焦虑

焦虑:
"每天都有新工具,学不过来"
"不学AI会被淘汰,学了又担心基础退化"
"不知道应该学哪个工具"

解决方案:
- 聚焦核心:掌握1-2个主力工具即可
- 基础为王:技术基础比工具更重要
- 持续学习:建立学习习惯,而非突击学习

5.3 人机协作的新模式:70/30法则

5.3.1 70/30法则的定义

AI负责"从0到70%":
- 快速原型搭建
- 样板代码生成
- 文档和注释编写
- 测试用例生成
- 常见功能实现
- 格式化代码
- 简单重构

人类负责"70到100%":
- 业务逻辑打磨
- 边缘情况处理
- 性能优化
- 安全加固
- 可访问性完善
- 架构设计
- 最终质量把控

这个比例不是固定的,而是根据任务复杂度和团队成熟度动态调整:

简单任务(如工具函数):
AI: 90% → 人类: 10%(主要是审查)

中等任务(如表单组件):
AI: 70% → 人类: 30%(审查+优化)

复杂任务(如状态管理库):
AI: 50% → 人类: 50%(AI辅助,人类主导)

核心任务(如架构设计):
AI: 30% → 人类: 70%(AI参考,人类决策)

5.3.2 协作流程示例

任务:开发一个电商购物车功能

Step 1: 人类拆解需求(人类主导)
策展人:
├─ 功能拆解:
│   ├─ 添加商品到购物车
│   ├─ 修改商品数量
│   ├─ 删除商品
│   ├─ 计算总价(含优惠)
│   ├─ 持久化(localStorage + API同步)
│   └─ 购物车UI(侧边栏/页面)
├─ 技术选型:
│   ├─ 状态管理:Zustand
│   ├─ UI库:shadcn/ui
│   ├─ 动画:Framer Motion
│   └─ 数据获取:React Query
└─ 生成详细Prompt

Step 2: AI生成初稿(AI主导 70%)
AI生成:
├─ CartContext和Provider
├─ useCart Hook(基础CRUD)
├─ CartItem组件
├─ CartSummary组件
├─ 基础样式
└─ 简单测试用例

Step 3: 人类审查和补充(人类主导 30%)
策展人:
├─ 审查AI代码
├─ 添加优惠计算逻辑(满减、折扣码)
├─ 处理库存不足情况
├─ 添加错误边界
├─ 优化动画细节
├─ 完善可访问性
└─ 优化性能(虚拟列表、防抖)

Step 4: AI辅助优化(AI辅助)
AI帮助:
├─ 优化useMemo/useCallback使用
├─ 生成更多测试用例
├─ 优化类型定义
└─ 生成使用文档

Step 5: 人类最终确认(人类主导)
策展人:
├─ 最终Code Review
├─ 功能测试
├─ 性能测试
├─ 合并到主分支
└─ 部署上线

总耗时:
传统方式:3天
AI协作方式:1天
效率提升:3

5.3.3 协作模式演进

阶段1: AI辅助(Assisted)
├─ AI帮助代码补全
├─ AI帮助文档生成
├─ 核心逻辑人工编写
└─ 比例:AI 30% : 人类 70%

阶段2: AI协作(Collaborative)← 当前推荐
├─ AI生成工具函数
├─ AI帮助重构
├─ 人工审查和修改
└─ 比例:AI 70% : 人类 30%

阶段3: AI主导(AI-Driven)- 谨慎采用
├─ AI自动生成大部分代码
├─ 人工主要审查
├─ 适用于探索性项目
└─ 比例:AI 90% : 人类 10%

阶段4: AI自主(Autonomous)- 未来展望
├─ AI理解需求自动完成
├─ 人工只需最终确认
├─ 适用于标准化任务
└─ 比例:AI 95% : 人类 5%

建议:大多数团队应保持在阶段2

5.4 不可替代的人类价值

尽管AI能力越来越强,但在前端领域,仍有一些价值是AI难以替代的。

5.4.1 用户体验的守护者

AI能生成"能用"的UI,但生成不了"好用"的UI。

AI能做到:
✓ 按照设计稿精确还原界面
✓ 实现交互逻辑
✓ 保证功能正确
✓ 遵循设计规范

AI难以做到:
✗ 理解用户的情感需求
✗ 感知微妙的体验细节
✗ 权衡不同设计方案的优劣
✗ 创造令人惊喜的体验
✗ 处理文化差异和本地化

案例分析

场景:设计一个支付流程

AI生成版本:
├─ 功能完整:选择支付方式、输入信息、确认支付
├─ 逻辑正确:验证、扣款、跳转
└─ 但:没有任何情感设计,冷冰冰的流程

人类优化版本:
├─ 添加信任元素:安全标识、加密提示
├─ 减少焦虑:进度指示、明确反馈
├─ 错误友好:具体错误提示、解决方案
├─ 成功庆祝:动画反馈、感谢语
├─ 情感化文案:"感谢您的支持"而非"支付成功"
└─ 结果:转化率提升15%

5.4.2 审美与同理心

这是人类相对于AI的最后堡垒。

审美判断

AI可以生成:
- 符合设计规范的配色
- 对齐良好的布局
- 标准的字体层级

但无法判断:
- 这个蓝色是否"太冷"了?
- 这个间距是否"太紧"了?
- 这个动画是否"太花哨"了?
- 整体感觉是否"优雅"?

同理心

场景:设计一个面向老年人的健康应用

AI会:
- 按照标准设计规范生成界面
- 使用常见的交互模式

人类会考虑:
- 字体要更大(老年人视力下降)
- 对比度要更高
- 按钮要更大,容易点击
- 操作步骤要简化
- 错误提示要明确,给出具体指引
- 避免使用技术术语
- 考虑网络环境,支持离线使用

5.4.3 创造性突破

AI擅长模式化工作,但不擅长创新性设计。

AI擅长(模式化):
✓ 标准组件的搭建
✓ 常见布局的实现
✓ 已有功能的复制
✓ 基于示例的生成

人类擅长(创造性):
✓ 创新性的交互模式
✓ 突破性的视觉设计
✓ 跨领域的灵感融合
✓ 颠覆性的产品概念
✓ 解决新问题的新方法

创造性案例

案例:Apple的3D Touch

这不是AI能想到的:
- 重新定义了手机交互
- 创造了Peek和Pop模式
- 启发了整个行业的交互创新

需要:
- 对用户行为的深度观察
- 对技术可能性的创造性思考
- 对体验的极致追求
- 勇于尝试和冒险

5.4.4 复杂的权衡决策

工程决策往往涉及多因素的复杂权衡:

场景:选择前端框架

AI可以列出:
- React的优缺点
- Vue的优缺点
- Angular的优缺点
- Svelte的优缺点

但难以做出最终决策,因为需要权衡:

技术因素:
├─ 团队现有技术栈和经验
├─ 项目的长期演进方向
├─ 性能需求
└─ 与后端技术的配合

业务因素:
├─ 招聘市场的技术人才分布
├─ 第三方生态的成熟度
├─ 商业支持(如Vercel对Next.js的支持)
└─ 客户或监管要求

团队因素:
├─ 团队成员的学习成本
├─ 现有代码库的迁移成本
├─ 团队的技术偏好
└─ 团队规模和发展阶段

时间因素:
├─ 项目deadline
├─ 技术债的累积速度
└─ 市场窗口期

需要综合考虑所有这些因素,做出最优决策

5.5 小结:重新定义前端工程师

在AI时代,前端工程师的定义正在从"实现UI的开发者"扩展为"连接用户与系统的体验设计师"。

5.5.1 新的定位

不只是写代码,更是

  • 设计体验
  • 创造价值
  • 技术决策
  • 质量把控
  • 团队协作

5.5.2 核心竞争力公式

AI时代前端工程师价值 = 
  技术深度 × AI工具熟练度 × 业务理解 × 创造力 × 学习能力

各项满分10分:
- 技术深度:8分(扎实的基础)
- AI工具熟练度:7分(熟练使用主流工具)
- 业务理解:7分(理解业务逻辑)
- 创造力:6分(有创新思维)
- 学习能力:9分(持续学习)

总分 = 8 × 7 × 7 × 6 × 9 = 21,168

如果某项为0,总分就是0!

5.5.3 未来的前端工程师画像

画像:AI时代的全栈体验工程师

技能:
├─ 扎实的前端基础(JS/CSS/React)
├─ 熟练使用AI工具(Cursor/v0/Vercel AI SDK)
├─ Prompt工程能力
├─ 架构设计能力
├─ 产品思维能力
├─ UI/UX设计能力
└─ 跨领域学习能力

工作内容:
├─ 30%:需求分析和拆解
├─ 20%:Prompt工程和AI协作
├─ 20%:代码审查和质量把控
├─ 15%:架构设计和技术决策
├─ 10%:用户体验设计
└─ 5%:手写核心代码

价值体现:
├─ 解决复杂问题
├─ 创造优秀体验
├─ 提升团队效率
├─ 推动技术创新
└─ 引领行业发展

那些只关注"写代码"的工程师,可能会逐渐被AI取代。而那些能够将技术、设计、业务、AI工具融会贯通的工程师,将在新时代大放异彩。

记住

在AI时代,最重要的能力不是"会写代码",而是"会解决问题"。

AI是工具,你是主人。

用AI放大你的能力,但不要被AI定义你的价值。


下章预告

第六章《未来的图景——AI-Native Frontend的愿景》将展望:

  • 2024-2030演进路线图(短期/中期/长期)
  • AI-Native Frontend的四大特征
  • 可能的颠覆性变化(Low-Code终结、外包重构等)
  • 挑战与应对策略
  • 在变革中坚守本质

Vue v-if 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月18日 10:35

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-if/v-else/v-else-if 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的条件指令用法。

编译对照

基础 v-if 条件渲染

最简单的 v-if 指令,用于根据条件显示或隐藏元素。

  • Vue 代码:
<div v-if="cond">内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : null;
}

从示例可以看到:Vue 的 v-if 指令被编译为 React 的三元表达式。VuReact 采用 条件表达式编译策略,将模板指令转换为 JSX 内联表达式,完全保持 Vue 的条件渲染语义——当 cond 为真时渲染 <div>,为假时渲染 null(React 中 null 不会被渲染到 DOM)。


v-if 与 v-else 组合

v-ifv-else 组合使用,实现二选一的条件渲染。

  • Vue 代码:
<div v-if="cond">内容</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  cond ? <div>内容</div> : <div>其他内容</div>;
}

VuReact 将 v-if/v-else 组合编译为完整的三元表达式完全模拟 Vue 的条件分支语义——两个分支互斥,确保同一时间只有一个元素被渲染。这种编译方式保持了代码的简洁性和可读性,同时与 React 的表达式渲染模式完美契合。


多条件 v-else-if 链

复杂的多条件判断链,使用 v-ifv-else-ifv-else 组合。

  • Vue 代码:
<div v-if="type === 'A'">内容A</div>
<div v-else-if="type === 'B'">内容B</div>
<div v-else>其他内容</div>
  • VuReact 编译后 React 代码:
{
  type === 'A' ? <div>内容A</div> : type === 'B' ? <div>内容B</div> : <div>其他内容</div>;
}

对于多条件链,VuReact 采用嵌套三元表达式编译策略,将 Vue 的 v-else-if 链转换为嵌套的条件表达式。这种编译方式完全保持 Vue 的条件链语义——按顺序检查条件,第一个满足条件的分支被渲染,后续分支被跳过。


复杂业务场景条件渲染

实际业务中的复杂条件渲染,包含嵌套条件、事件绑定、插值表达式等。

  • Vue 代码:
<div v-if="user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin)">
  <h1>管理员控制面板</h1>
  <button @click="deleteAll">删除所有数据</button>
</div>
<div v-else-if="user.role === 'editor' && articles.length > 0 && !isSuspended">
  <h2>编辑文章 (共{{ articles.length }}篇)</h2>
  <ul>
    <li v-for="article in articles" :key="article.id">{{ article.title }}</li>
  </ul>
</div>
<div v-else-if="user.role === 'viewer' && hasSubscription">
  <h3>订阅用户视图</h3>
  <p>您的订阅将于{{ subscriptionEndDate }}到期</p>
</div>
<div v-else-if="user.role === 'guest' && showTrial">
  <div class="trial-banner">
    <p>试用用户,剩余{{ trialDays }}天</p>
    <button @click="upgrade">升级账户</button>
  </div>
</div>
<div v-else>
  <div class="error-state">
    <p v-if="isLoading">加载中...</p>
    <p v-else-if="errorMessage">{{ errorMessage }}</p>
    <p v-else>无访问权限或账户状态异常</p>
    <button @click="retry">重试 ({{ retryCount }}/3)</button>
  </div>
</div>
  • VuReact 编译后 React 代码:
{
  user.role === 'admin' && (user.permissions.includes('write') || isSuperAdmin) ? (
    <div>
      <h1>管理员控制面板</h1>
      <button onClick={deleteAll}>删除所有数据</button>
    </div>
  ) : user.role === 'editor' && articles.length > 0 && !isSuspended ? (
    <div>
      <h2>编辑文章 (共{articles.length}篇)</h2>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  ) : user.role === 'viewer' && hasSubscription ? (
    <div>
      <h3>订阅用户视图</h3>
      <p>您的订阅将于{subscriptionEndDate}到期</p>
    </div>
  ) : user.role === 'guest' && showTrial ? (
    <div>
      <div className="trial-banner">
        <p>试用用户,剩余{trialDays}天</p>
        <button onClick={upgrade}>升级账户</button>
      </div>
    </div>
  ) : (
    <div>
      <div className="error-state">
        {isLoading ? (
          <p>加载中...</p>
        ) : errorMessage ? (
          <p>{errorMessage}</p>
        ) : (
          <p>无访问权限或账户状态异常</p>
        )}
        <button onClick={retry}>重试 ({retryCount}/3)</button>
      </div>
    </div>
  );
}

对于复杂的业务场景,VuReact 展示了完整的条件编译能力

  1. 复杂条件表达式:将 Vue 的复杂条件逻辑(&&||、函数调用等)原样转换为 JSX 表达式
  2. 事件绑定转换@click 转换为 onClick,保持事件语义
  3. 插值表达式{{ }} 转换为 { },保持数据绑定
  4. 样式类名转换class 转换为 className,符合 React 规范

VuReact 的编译策略完全保持 Vue 的条件渲染语义,同时生成符合 React 最佳实践的代码,提高可维护性。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Vue 项目高德地图性能优化实战:从卡死到丝滑的完整过程

2026年4月18日 10:30

几千个点位一次渲染就卡爆浏览器?路由切换越用越慢直到内存崩溃?本文将完整还原 Vue 3 + 高德地图项目的优化实战过程,附详细代码示例。

一、问题来了:地图怎么就卡死了?

去年接手了一个数据可视化大屏项目,核心功能是在高德地图上展示全国范围内的实时业务数据点位,大概有三四千个。开发阶段本地测试一切正常,上了测试环境之后,问题一个接一个地冒出来了:页面首次加载白屏时间长、地图缩放拖拽掉帧、切换页面之后浏览器越来越卡,跑几个来回就崩溃了

更让人头疼的是,这个问题在 Chrome 和 Edge 上特别明显,Firefox 反而相对平稳,说明这绝不是简单的代码 bug,而是涉及到浏览器渲染机制、地图 SDK 加载策略和 Vue 生命周期管理的综合性能问题。

经过反复排查,我总结出了导致地图卡顿的几个核心原因:

问题维度 典型表现 排查工具
加载策略 首屏白屏长,LCP 差 Lighthouse、Network 瀑布流
渲染性能 平移/缩放掉帧,交互卡顿 Performance 面板、FPS 监控
内存管理 多页面切换后越用越慢 Memory 面板、堆快照分析

接下来,我按实际优化顺序,逐一给出解决方案。

二、优化一:异步加载,别让地图拖垮首屏

很多教程和快速示例都建议在 index.html 里直接用 <script> 标签同步引入高德地图 JS API。这种方式虽然简单,但有一个致命问题:它会阻塞 HTML 的解析和渲染。在网络不好或 API 服务器响应慢的情况下,用户面对的就是一个长时间的空白页面。

更糟糕的是,整个高德 SDK 会在应用初始化时全部加载,不管当前页面是否真的需要地图。

改进方案:使用 AMapLoader 动态加载

高德官方提供了 @amap/amap-jsapi-loader,但要注意不能在文件顶部直接 import,否则 Vite 打包时这个依赖会被打进首屏 bundle,一样会增加首包体积。

正确的做法是在 onMounted 中动态导入:

// ❌ 错误:在文件顶部 import,会被打进首屏 bundle
import AMapLoader from '@amap/amap-jsapi-loader'

// ✅ 正确:在函数内部动态 import,单独拆分成 chunk
async function initMap() {
  const { default: AMapLoader } = await import('@amap/amap-jsapi-loader')
  
  window._AMapSecurityConfig = {
    securityJsCode: import.meta.env.VITE_AMAP_SECURITY_CODE
  }
  
  const AMap = await AMapLoader.load({
    key: import.meta.env.VITE_AMAP_KEY,
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.MarkerCluster', 'AMap.Geolocation']
  })
  
  // 初始化地图
  const map = new AMap.Map('map-container', {
    zoom: 10,
    center: [116.397428, 39.90923]
  })
  
  return { AMap, map }
}

这样做的好处是:高德相关代码会被打包成单独的 chunk,只有执行到这个函数时才会加载,首屏 bundle 体积更小,页面渲染更快。而且 SSR 场景下 Node 环境不会执行 onMounted 钩子,自然也就避开了 window is not defined 的问题。

三、优化二:shallowRef,不让 Vue 响应式拖后腿

地图实例、Marker 对象这些数据非常庞大,如果直接用 Vue 的 ref 存储,Vue 会递归地给它们的每个属性添加响应式代理,这会导致严重的性能损耗。

一个真实踩坑场景:有次我把地图实例直接放进了 ref,结果地图缩放时明显感觉掉帧,排查了半天才发现是 Vue 在给地图对象做响应式劫持。

解决方案:用 shallowRef 替代 ref

import { shallowRef, onMounted, onUnmounted } from 'vue'

// ✅ 使用 shallowRef 存储地图相关实例
const map = shallowRef(null)
const currentLocationMarker = shallowRef(null)
const cluster = shallowRef(null)

async function initMap() {
  const { AMap, mapInstance } = await loadMap()
  map.value = mapInstance
  
  // 存储插件实例也要用 shallowRef
  const markerCluster = new AMap.MarkerCluster(mapInstance, points, {
    gridSize: 60,
    maxZoom: 18
  })
  cluster.value = markerCluster
}

shallowRef 只追踪 .value 的访问,不会对其内部属性进行响应式代理,对于地图实例这种大型对象来说非常合适。

四、优化三:点聚合,把几千个点变成几十个簇

当数据量超过 500 个点时,直接渲染所有 Marker 会让地图操作变得卡顿。我手上的项目有 8000+ 个点位,首次进入时地图直接崩了。

救星就是 AMap.MarkerCluster 点聚合插件。

它的原理很简单:当地图缩放到较低级别时,把距离相近的点合并成一个带数字的聚合点;放大地图时再自动展开成独立的点标记。官方宣称可以支持 10 万以内的点位保持较好性能。

async function initMapWithCluster(pointsData) {
  const { AMap, map } = await initMap()
  
  // 准备点位数据,格式必须包含 lnglat
  const points = pointsData.map(item => ({
    lnglat: [item.lng, item.lat],
    weight: item.value, // 可选权重
    extData: item       // 附加业务数据
  }))
  
  // 初始化点聚合
  const markerCluster = new AMap.MarkerCluster(map, points, {
    gridSize: 60,        // 聚合网格大小,默认60
    maxZoom: 18,         // 最大聚合级别,18级以上不聚合
    minClusterSize: 2,   // 至少2个点才聚合
    renderClusterMarker: (context) => {
      // 自定义聚合点样式
      const div = document.createElement('div')
      const count = context.count
      div.style.backgroundColor = `rgba(66, 133, 244, ${Math.min(0.8, 0.3 + count / 100)})`
      div.style.width = `${Math.min(60, 30 + count / 5)}px`
      div.style.height = `${Math.min(60, 30 + count / 5)}px`
      div.style.borderRadius = '50%'
      div.style.display = 'flex'
      div.style.alignItems = 'center'
      div.style.justifyContent = 'center'
      div.style.color = 'white'
      div.style.fontWeight = 'bold'
      div.style.fontSize = `${Math.min(18, 12 + count / 10)}px`
      div.innerHTML = count > 99 ? '99+' : count
      context.marker.setContent(div)
    }
  })
  
  return { map, markerCluster }
}

点聚合配置的几个关键参数:

  • gridSize:聚合网格的像素大小,调大可以让更多点被聚合,调小则保留更多独立点
  • minClusterSize:至少多少个点才触发聚合,设置为 2 表示单个点不聚合
  • maxZoom:在哪个缩放级别以上停止聚合,让用户可以查看独立点位

聚合前后的性能差异非常明显:8000 个独立 Marker 会导致地图完全卡死,而通过聚合只需要渲染几十个聚合点和当前视野内的少量 Marker,流畅度天差地别。

五、优化四:动态更新聚合图层,别让旧图层“粘”在地图上

一个容易被忽略的坑:当查询条件变化、点位数据需要全部更新时,旧的聚合图层很难彻底清除。直接调用 map.clearMap() 对聚合图层内部生成的 Marker 无效,因为那些 Marker 是 MarkerCluster 实例管理的,不是直接添加到地图上的。

正确的做法是销毁旧的聚合实例,再重新创建:

// ❌ 错误:直接清地图,聚合图层还在
function updatePointsWrong(newPoints) {
  map.value.clearMap()  // 对聚合图层无效!
  initMapWithCluster(newPoints)  // 新旧图层叠加,地图混乱
}

// ✅ 正确:先销毁旧聚合实例
function updatePointsCorrect(newPoints) {
  // 1. 销毁旧的聚合实例
  if (cluster.value) {
    cluster.value.setMap(null)  // 从地图上移除
    cluster.value = null
  }
  
  // 2. 重新创建聚合图层
  const { map: newMap, markerCluster } = await initMapWithCluster(newPoints)
  map.value = newMap
  cluster.value = markerCluster
}

如果只是部分点位数据变化,不需要完全重建,可以用 setData 方法更新:

// 动态更新点位数据
function refreshPoints(newPointsData) {
  const newPoints = newPointsData.map(item => ({
    lnglat: [item.lng, item.lat],
    extData: item
  }))
  cluster.value.setData(newPoints)  // 高效更新,无需重建实例
}

关键要点MarkerCluster 是一个管理器,它内部生成的 Marker 由它自己管理。要清除聚合图层,必须调用 setMap(null) 销毁整个聚合实例,或者用 setData 更新数据,而不是试图用 map.clearMap() 手动清理。

六、优化五:视口裁剪 + 防抖,只渲染用户看得见的点

即使使用了点聚合,在聚合层级以下(比如缩放到某个城市级别时)仍然可能需要渲染成百上千个独立 Marker。这些点如果不在当前视口内,渲染它们完全是浪费资源。

优化思路:只渲染当前地图视野内的点位,并监听地图的缩放/移动事件动态更新。

import { ref, onMounted, onUnmounted } from 'vue'

const map = ref(null)
const visiblePoints = ref([])      // 当前视野内的点位
const allPoints = ref([])          // 全量点位数据
let renderTimer = null

// 计算当前视野内的点位
function updateVisibleMarkers() {
  if (!map.value) return
  
  const bounds = map.value.getBounds()  // 获取当前地图边界
  const sw = bounds.getSouthWest()      // 西南角坐标
  const ne = bounds.getNorthEast()      // 东北角坐标
  
  // 筛选视野内的点
  visiblePoints.value = allPoints.value.filter(point => {
    return point.lng >= sw.lng && point.lng <= ne.lng &&
           point.lat >= sw.lat && point.lat <= ne.lat
  })
  
  // 重新渲染 Marker
  renderMarkersInViewport()
}

// 防抖处理:用户停止操作后再渲染
function onMapViewChange() {
  if (renderTimer) clearTimeout(renderTimer)
  renderTimer = setTimeout(() => {
    updateVisibleMarkers()
  }, 200)  // 200ms 防抖延迟
}

onMounted(() => {
  initMap().then(({ map: mapInstance }) => {
    map.value = mapInstance
    map.value.on('moveend', onMapViewChange)   // 移动结束
    map.value.on('zoomend', onMapViewChange)   // 缩放结束
    updateVisibleMarkers()
  })
})

onUnmounted(() => {
  if (map.value) {
    map.value.off('moveend', onMapViewChange)
    map.value.off('zoomend', onMapViewChange)
    map.value.destroy()  // 组件卸载时销毁地图实例,释放内存
  }
})

这种做法的核心思想是 只渲染用户看得见的内容,配合 200ms 的防抖处理,避免在用户快速拖动时频繁触发渲染。实测下来,地图交互的帧率从 20fps 提升到了 55fps 以上。

七、容易被忽视的两个细节

1. 自定义图标尺寸别太大

如果用了自定义的 Marker 图标,高德官方强烈建议将图标尺寸控制在 60px × 60px 以内。图标太大不仅占内存,每次缩放时的重绘开销也成倍增加。

2. 组件销毁时务必清理干净

Vue 项目中非常隐蔽的一个问题:地图实例、Marker、事件监听器如果在组件销毁时没有正确清理,就会一直驻留在内存中。用户在有地图的多个路由间来回切换几次,内存占用就会像滚雪球一样越来越大,最终触发频繁的 GC 停顿,导致页面卡顿。

onUnmounted(() => {
  // 1. 移除所有事件监听
  if (map.value) {
    map.value.off('moveend', onMapViewChange)
    map.value.off('zoomend', onMapViewChange)
  }
  
  // 2. 销毁聚合实例
  if (cluster.value) {
    cluster.value.setMap(null)
    cluster.value = null
  }
  
  // 3. 销毁地图实例
  if (map.value) {
    map.value.destroy()
    map.value = null
  }
})

八、总结:这些优化让地图起飞了

经过以上几轮优化,项目的性能数据有了质的提升:

  • 首屏加载时间:从 4.2 秒降到 1.5 秒(减少约 65%)
  • 地图操作帧率:从 20fps 左右提升到 55fps 以上
  • 内存占用:路由切换 5 次后内存从 350MB 降到 120MB
  • 最大支持点位:从 2000 个提升到 50000 个

最后把这些要点总结成一张速查表:

优化手段 适用场景 核心代码/配置
动态加载 SDK 首屏优化 await import('@amap/amap-jsapi-loader')
shallowRef 存储 地图实例、Marker const map = shallowRef(null)
MarkerCluster 点聚合 点位 > 500 new AMap.MarkerCluster(map, points, { gridSize: 60 })
销毁旧聚合实例 动态更新点位 cluster.setMap(null) → 重建
视口裁剪 + 防抖 缩放/拖拽时 bounds 筛选 + setTimeout 200ms
组件销毁清理 路由切换 map.destroy() + 解绑事件

性能优化的核心原则其实很朴素:能异步加载的绝不同步加载,能按需渲染的绝不全量渲染,能复用的实例绝不重复创建。希望这篇文章能帮你少踩一些我踩过的坑。

❌
❌