使用 React 加快页面速度✨

2025-06-07

使用 React 加快页面速度✨

照片由Mathew SchwartzUnsplash上拍摄

这是我们主页改版的故事,最终获得了灯塔评分。如果您想了解我们最初是如何打造主页的,请查看我们 2019 年的博客文章

我在这里写的很多内容都可以在使用 React 的快速页面文章中看到。

起源

我们的主页最初是用 React SPA 做的。为什么?因为当时是 2019 年,UI 设计师所有组件都是用 React 创建的。而且,它的体验超级流畅,实际上还算可以接受。

然而,尽管有些人可能会认为 SPA 不利于 SEO,但这并不是我们想要预渲染页面的原因。SEO 看起来确实不错,但一些更简单的爬虫无法找到所有信息,因为它隐藏在一些 JavaScript 后面。

那么我们能做什么呢?好吧,我们可以使用静态站点生成 (SSG) 方法在构建时渲染所有内容。

进入 SSG

我们不想改变页面的整体内容或背后的引擎。毕竟,DX 已经很棒了,而且我们认为组件的可复用性比迁移到最新的 SSG 框架更重要。

构建完成后(当时使用的是Parcel v1打包工具),我们进行了一个构建后处理,该处理过程会获取生成的index.html并遍历所有检测到的页面。由于我们的路由是声明式的(我们已经根据文件系统路径生成了路由),因此查找页面非常容易。

对于每个页面,我们都运行了简单的脚本,该脚本执行以下操作:

  1. 教 Node.js 如何 ESM 工作(esm模块)
  2. 允许通过 TypeScript 使用ts-node(我们的源正在使用 TypeScript)
  3. 添加一些全局变量,document例如localStorage
  4. 注册一些额外的扩展,例如,将图像解析为 Node.js 中的模块(这些图像应该解析为捆绑器中已经生成的图像)
  5. 评估页面 - 使用renderToString
  6. 将应用的内容容器替换为预渲染的页面
  7. 将修改后的应用程序的 HTML 保存在与页面路径匹配的新文件中

在代码中,其工作原理如下:



const { readFileSync, writeFileSync, mkdirSync } = require('fs');
const { basename, dirname, resolve } = require('path');

require = require('esm')(module);

require('ts-node').register({
  compilerOptions: {
    module: 'commonjs',
    target: 'es6',
    jsx: 'react',
    importHelpers: true,
    moduleResolution: 'node',
  },
  transpileOnly: true,
});

global.XMLHttpRequest = class {};
global.XDomainRequest = class {};
global.localStorage = {
  getItem() {
    return undefined;
  },
  setItem() {},
};
global.document = {
  title: 'sample',
  querySelector() {
    return {
      getAttribute() {
        return '';
      },
    };
  },
};

const React = require('react');
const { MemoryRouter } = require('react-router');
const { renderToString } = require('react-dom/server');

React.lazy = () => () => React.createElement('div', undefined, 'Loading ...');
React.Suspense = ({ children }) => React.createElement(React.Fragment, undefined, children);
React.useLayoutEffect = () => {};

function setupExtensions(files) {
  ['.png', '.svg', '.jpg', '.jpeg', '.mp4', '.mp3', '.woff', '.tiff', '.tif', '.xml'].forEach(extension => {
    require.extensions[extension] = (module, file) => {
      const parts = basename(file).split('.');
      const ext = parts.pop();
      const front = parts.join('.');
      const ref = files.filter(m => m.startsWith(front) && m.endsWith(ext)).pop() || '';
      module.exports = '/' + ref;
    };
  });

  require.extensions['.codegen'] = (module, file) => {
    const content = readFileSync(file, 'utf8');
    module._compile(content, file);
    const code = module.exports();
    module._compile(code, file);
  };
}

function renderApp(source, target, dist) {
  const sourceModule = require(source);
  const route = (sourceModule.meta.route || target).substring(1);
  const Page = sourceModule.default;
  const Layout = require('../../scripts/layout').default;
  const element = React.createElement(
    MemoryRouter,
    undefined,
    React.createElement(Layout, undefined, React.createElement(Page)),
  );
  return {
    content: renderToString(element),
    outPath: resolve(dist, route, 'index.html'),
  };
}

function makePage(outPath, html, content) {
  const outDir = dirname(outPath);
  const file = html.replace(/<div id="app">(.*)<\/div>/, `<div id="app">${content}</div>`);

  mkdirSync(outDir, {
    recursive: true,
  });

  writeFileSync(outPath, file, 'utf8');
}

process.on('message', msg => {
  const { source, target, files, html, dist } = msg;
  setupExtensions(files);

  setTimeout(() => {
    const { content, outPath } = renderApp(source, target, dist);
    makePage(outPath, html, content);

    process.send({
      content,
    });
  }, 100);
});


Enter fullscreen mode Exit fullscreen mode

整体process使用,因为给定的模块是从分叉进程调用的。因此每个页面都是在一个独立的进程中生成的。

下图说明了此过程。

网站建设流程

到目前为止,我们取得了什么成果?我们有一个预渲染的页面,它已经可以正常工作,并且可以转换为 SPA。不错。但对我们来说还不够。

现代化堆栈

我已经将这个过程描述为“曾经”。Parcel v1 已经很久没有更新了,而且从今天的角度来看,它运行速度相当慢。它也不支持一些现代概念,应该永久退役。

我们选择了Vite作为替代方案。这是一个合适的替代方案,因为它配备了超快的开发服务器和非常优化的发布版本。

此外,由于我们使用codegen进行路线检索,我们仍然可以继续将其用于 Vite。

毕竟,Vite 允许转换的整个配置如下所示:



import codegen from 'vite-plugin-codegen';
import { resolve } from 'path';

export default {
  build: {
    assetsInlineLimit: 0,
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  plugins: [codegen()],
};


Enter fullscreen mode Exit fullscreen mode

为了避免 Vite 内联较小资产的原始行为(这会导致我们的 SSG 行为出现问题),我们将其设置assetsInlineLimit为 0。

在从 Parcel v1 过渡到 Vite 时,我们还改进了一项功能,那就是引入了路径别名。通过上面的配置,我们可以直接写入,import '@/foo/bar'而不必显式地使用相对路径src/foo/bar。这使得模块更加灵活,也更易于维护。

之前,我们使用了一些工具(esmts-node、...)来实现 SSG。新的设置下,是时候减少工具数量,并将它们全部替换为 了esbuild

使用 esbuild 的新 SSG 管道

因此,SSG 内核模块的更新代码也发生了一些变化:



const { writeFile, readFile, mkdir } = require('fs/promises');
const { dirname, resolve, basename, relative } = require('path');
const { createContext, runInContext } = require('vm');
const { compile } = require('./compile');

const React = require('react');
const ReactRouter = require('react-router');
const ReactRouterDom = require('react-router-dom');
const { renderToString } = require('react-dom/server');

React.lazy = () => () => React.createElement('div', undefined, 'Loading ...');
React.Suspense = ({ children }) => React.createElement(React.Fragment, undefined, children);
React.useLayoutEffect = () => {};
React.useEffect = () => {};

async function renderApp(source, target, language, files, dist) {
  const result = await compile({
    dist,
    files,
    target,
    platform: 'node',
    stdin: {
      contents: `
        import { MemoryRouter } from "react-router";
        import Page, { meta } from "./${basename(source)}";
        import Layout from "${relative(dirname(source), resolve(__dirname, '../../layouts/default'))}";

        const page = (
          <MemoryRouter>
            <Layout language="${language}">
              <Page />
            </Layout>
          </MemoryRouter>
        );

        export { page, meta };
      `,
      sourcefile: resolve(dirname(source), 'temp-page.jsx'),
      resolveDir: dirname(source),
      loader: 'jsx',
    },
  });
  const module = {
    exports: {},
  };
  const ctx = createContext({
    exports: module.exports,
    require(name) {
      switch (name) {
        case 'react':
          return React;
        case 'react-router':
          return ReactRouter;
        case 'react-router-dom':
          return ReactRouterDom;
        default:
          console.error('Cannot require', name);
          return undefined;
      }
    },
    setTimeout() {},
    setInterval() {},
    React,
    module,
    XMLHttpRequest: class {},
    XDomainRequest: class {},
    localStorage: {
      getItem() {
        return undefined;
      },
      setItem() {},
    },
    document: {
      title: 'sample',
      querySelector() {
        return {
          getAttribute() {
            return '';
          },
        };
      },
    },
  });
  const code = result.outputFiles.find((m) => m.path.endsWith('.js')).text;

  runInContext(code, ctx);
  const { page, meta } = module.exports;

  return {
    content: renderToString(page),
    meta,
  };
}

async function makeFile(outPath, content) {
  const outDir = dirname(outPath);

  await mkdir(outDir, {
    recursive: true,
  });

  await writeFile(outPath, content, 'utf8');
}

function makePage(outPath, meta, html, content) {
  return makeFile(outPath, html.replace(/<div id="app">.*?<\/div>/s, `<div id="app">${content}</div>`);
}

process.on('message', (msg) => {
  const { source, target, files, language, html, dist } = msg;

  setTimeout(async () => {
    const { content, replacements, meta } = await renderApp(source, target, language, files, dist, html);
    const route = (meta.route || target).substring(1);
    const outPath = resolve(dist, route, 'index.html');
    await makePage(outPath, meta, html, content);

    process.send({
      done: true,
      outPath,
      replacements,
    });
  }, 100);
});


Enter fullscreen mode Exit fullscreen mode

虽然整体流程保持不变,但我们现在MemoryRouter使用 CommonJS 模块系统将包含一些导入(例如 )的页面转换为纯 JavaScript 脚本。因此,使用 Node.jsvm模块的评估不再需要esmts-node。一切都已由 esbuild 处理。

在上面的代码中,实际的 esbuild 用法隐藏在compile函数中,所以让我们看看如何设置它:



const { build } = require('esbuild');
const { codegenPlugin } = require('esbuild-codegen-plugin');
const { resolve, basename } = require('path');

function compile(opts) {
  const { dist, target, stdin, files, platform, entryPoints } = opts;
  const isBrowser = platform === 'browser';
  return build({
    stdin,
    entryPoints,
    outdir: resolve(dist, target.substring(1)),
    write: false,
    bundle: true,
    splitting: isBrowser,
    minify: isBrowser,
    format: !isBrowser ? 'cjs' : 'esm',
    platform,
    loader: {
      '.jpg': 'file',
      '.png': 'file',
      '.svg': 'file',
      '.avif': 'file',
      '.webp': 'file',
    },
    alias: {
      '@': resolve(__dirname, '../..'),
    },
    external: ['react', 'react-router', 'react-router-dom', 'react-dom', 'react-dom/client'],
    plugins: [
      codegenPlugin(),
      {
        name: 'dynamic-assets',
        setup(build) {
          build.onResolve({ filter: /.*/ }, (args) => {
            const name = basename(args.path);
            const idx = name.lastIndexOf('.');
            const front = name.substring(0, idx);
            const ext = name.substring(idx);
            const prefix = front + '-';
            const file = files.find((m) => m.startsWith(prefix) && m.endsWith(ext));

            if (file) {
              return {
                path: file,
                namespace: 'dynamic-asset',
              };
            }

            return undefined;
          });

          build.onLoad({ namespace: 'dynamic-asset', filter: /.*/ }, (args) => {
            const path = `/assets/${args.path}`;
            return {
              contents: `export default ${JSON.stringify(path)};`,
              loader: 'js',
            };
          });
        },
      },
    ],
  });
}

exports.compile = compile;


Enter fullscreen mode Exit fullscreen mode

非常简单。需要注意的几点是:

  • 再次强调,它的使用体验codegen非常棒,因为每个打包器都有一个插件。所以我们可以直接使用 esbuild 的 codegen 插件,这里就介绍到这里了。
  • 上面定义的动态资产插件会在文件中查找名称和扩展名是否合适;如果合适,它将使用 Vite 已经生成的文件。
  • 我们compile不仅可以将该函数重用于 Node.js(SSG 部分),还可以将其重用于编译一些在浏览器中运行的 JS 函数。

尤其是最后一点至关重要。目前,这个设置实际上将动态部分(SPA)移到了完全静态的部分。但我们仍然有一些交互部分……所以,像我们之前那样完全“水合”这个功能可能还不够好。

但即使采用了这种更现代化的设置,有一件事仍然是 2019 年的:整体性能。

灯塔问题

2019 年,我们的 Lighthouse 评分仍然很高,但现在充其量只能算是平庸。Lighthouse 变得更加激进了——尤其是在我们这次遇到的水合场景方面。

初始灯塔得分

得分概要如下:

类别 桌面 移动的
分数 70 50
最佳实践 81 79
首次内容绘制 0.4秒 1.7秒
最大的内容绘画 1.6秒 8.4秒
总阻塞时间 0毫秒 20毫秒
累计布局偏移 1.828 1.899
速度指数 0.7秒 2.1秒

有些发现似乎是可选的,但可以很快实施(或根本不实施):

  • 简单:没有 CSP 标头(因为这只是作为静态内容,我们可以轻松地将其作为meta标签嵌入到 HTML 中)
  • 繁琐:在移动设备上,某些图像的分辨率较低(大概需要picture使用不同的source元素进入标签才能返回适合的“正确”分辨率)
  • 不可能:使用了弃用的 API(unload处理程序;改用pagehide事件 - 实际上我们不使用它:问题来自/注入自 Chrome 扩展程序)

当然,问题是为什么分数这么低——以及我们能做些什么。值得注意的是,我们的 CLS(累积布局偏移)和 LCP(最大内容绘制)相当大。

改进

首先要做的是减少 JS 引擎的工作量。为此,我们需要重新思考我们的数据融合策略。React SPA 的标准策略是将所有数据都进行数据融合。但这会带来很大的开销,而且启动速度也相当慢。

更好的方法是在这里加入一种岛屿架构风格。我们不需要一开始就水化所有内容,而是只水化部分内容,并且只在需要时(例如,当它们变得可见时)才水化这些部分。

我们该怎么做呢?是时候施展魔法了

让我们考虑以下代码:



const Testimonials: React.FC = () => (
  <>
    <div className="container">
      <h1>Testimonials</h1>
    </div>
    <div className="quote-carousel">
      <TestimonialsSlides />
    </div>
  </>
);


Enter fullscreen mode Exit fullscreen mode

这是首页的推荐部分。除了 之外,所有内容都应该是静态的TestimonialsSlides。这是一个包含一些内容的轮播。所有内容均在 React 中定义。

如果我们直接告诉系统给它补充水分会怎么样?假设我们将代码重写如下:



const Testimonials: React.FC = () => (
  <>
    <div className="container">
      <h1>Testimonials</h1>
    </div>
    <div className="quote-carousel" data-hydrate={TestimonialsSlides}>
      <TestimonialsSlides />
    </div>
  </>
);


Enter fullscreen mode Exit fullscreen mode

我们唯一改动的是添加了一个data-hydrate属性。当然,这应该是一个字符串——但(令人惊讶的是)我们无论如何都不会用到这个属性。我们实际上会在构建时更改属性值。

我们设想的架构工作原理如下:

水合替代品的架构

SSG 内核模块中的构建时更改是为了替换而添加的:



const { createHash } = require('crypto');

async function getUniqueName(path) {
  const fn = basename(path);
  const name = fn.substring(0, fn.lastIndexOf('.'));
  const content = await readFile(path);
  const value = createHash('sha1').update(content);
  const hash = value.digest('hex').substring(0, 6);
  return `${name}.${hash}`;
}

const matcher = /"data-(hydrate|render|load)":\s+(\w+)[,\s]/g;
const replacements = [];

while (true) {
  const match = matcher.exec(code);

  if (!match) {
    break;
  }

  const lookup = match[0];
  const kind = match[1];
  const componentName = match[2];
  const pos = code.indexOf(`var ${componentName}`);
  const idx = code.lastIndexOf('\n// ', pos) + 4;
  const src = code.substring(idx, code.indexOf('\n', idx));
  const entry = resolve(__dirname, '../../..', src);
  const name = await getUniqueName(entry);
  replacements.push({
    lookup,
    entry,
    name,
    value: `"data-${kind}": "/assets/${name}.js",`,
  });
}

for (const { lookup, value } of replacements) {
  code = code.replace(lookup, value);
}


Enter fullscreen mode Exit fullscreen mode

太棒了!通过这项更改,我们在构建过程中替换了属性。我们还确定了替换项,以便稍后在联合构建过程中使用它们(以便它们生成通用的块):



const { writeFile } = require('fs/promises');
const { basename, resolve } = require('path');
const { compile } = require('./compile');

process.on('message', (msg) => {
  const { replacements, dist, files, target } = msg;

  setTimeout(async () => {
    const result = await compile({
      dist,
      files,
      target,
      platform: 'browser',
      entryPoints: replacements.map((m) => ({ in: m.entry, out: m.name })),
    });

    for (const file of result.outputFiles) {
      const name = basename(file.path);
      const content = file.text;
      await writeFile(resolve(dist, target.substring(1), name), content, 'utf8');
    }

    process.send({
      done: true,
    });
  }, 100);
});


Enter fullscreen mode Exit fullscreen mode

唯一缺少的是 SPA 脚本的替代品。替代品应该能够与data-hydrate属性配合使用,该属性告诉网站使用特定文件填充容器:



function integrate() {
  function getProps(element: Element) {
    try {
      return JSON.parse(element.getAttribute('data-props'));
    } catch {
      return {};
    }
  }

  function load(fn: string) {
    const react = import('react');
    const reactDom = import('react-dom/client');
    const mod = import(fn);
    return Promise.all([react, reactDom, mod]);
  }

  document.querySelectorAll('*[data-hydrate]').forEach((element) => {
    const fn = element.getAttribute('data-hydrate');
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          observer.disconnect();
          load(fn).then(([{ createElement }, { hydrateRoot }, m]) => {
            requestAnimationFrame(() => hydrateRoot(element, createElement(m.default, getProps(element))));
          });
        }
      });
    });

    observer.observe(element);
  });

  document.querySelectorAll('*[data-load]').forEach((element) => {
    const fn = element.getAttribute('data-load');
    load(fn).then(([{ createElement }, { hydrateRoot }, m]) => {
      requestIdleCallback(() => hydrateRoot(element, createElement(m.default, getProps(element))));
    });
  });

  document.querySelectorAll('*[data-render]').forEach((element) => {
    const fn = element.getAttribute('data-render');
    load(fn).then(([{ createElement }, { createRoot }, m]) => {
      createRoot(element).render(createElement(m.default, getProps(element)));
    });
  });
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', integrate);
} else {
  integrate();
}


Enter fullscreen mode Exit fullscreen mode

差不多就是这样了!最棒的是,这个脚本非常小,可以内联。因为我们也知道何时/是否需要替换,所以我们只在需要的时候(例如,当我们可能需要hydrate的时候)内联脚本。

最后,我们进行了外部化react(以及其他),但没有指定替换。当 esbuild 生成 esm 文件时,这些外部文件将作为标准导入放置,例如:



import * as React from 'react';


Enter fullscreen mode Exit fullscreen mode

如果在浏览器中执行这样的操作,我们就会遇到问题。解决方案是在我们的页面中引入一个 importmap:



<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@18.3.1",
    "react-dom": "https://esm.sh/react-dom@18.3.1",
    "react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
  }
}
</script>


Enter fullscreen mode Exit fullscreen mode

请注意,导入映射无论如何都是延迟加载的。因此,它开箱即用,性能最佳。

一切就绪后,我们可以再次查看灯塔得分。

改进后的 Lighthouse 评分

得分概要如下:

类别 桌面 移动的
分数 78 65
最佳实践 81 79
首次内容绘制 0.6秒 4.3秒
最大的内容绘画 1.3秒 9.4秒
总阻塞时间 0毫秒 10毫秒
累计布局偏移 0 0
速度指数 0.6秒 4.3秒

虽然分数已经好多了,但 LCP 和整体速度指数实际上却变差了。分数提高的主要原因是 CLS 现在为 0,这很好。

那么我们错在哪里了?

最后的润色

虽然 SPA 过渡会影响我们的评分,但不如水合不良的组件那么严重。其中一个组件是主轮播,它会立即显示。

事实证明,轮播组件仅适用于 SPA。它根据给定的内容动态计算幻灯片的宽度和位置。下图展示了它的工作原理:

轮播组件

简而言之,我们有一个容器,用于获取组件的尺寸。在容器内部,我们使用一个内容元素,其宽度等于容器宽度乘以幻灯片数量 + 2。由于我们引入了第一张和最后一张幻灯片的重复项,因此我们将其加 2。这样,我们就能获得真正的轮播体验。从最后一张幻灯片开始,您可以继续向右滑动回到第一张幻灯片,反之亦然。本质上,这允许您继续沿一个方向滚动。

我们可以做些什么来改进这里的代码?

我们不再依赖运行时设置style属性,而是返回一个预先计算的style对象。这样,我们只会在滚动操作开始时更改动态属性。

最初,代码具有如下功能:



const updateOffset = () => {
  const c = container.current?.parentElement;

  if (c) {
    const o = offset.current;
    let transform = 'translateX(0)';
    let transition = 'none';

    if (state.desired !== state.active) {
      const dist = Math.abs(state.active - state.desired);
      const pref = Math.sign(o || 0);
      const dir = (dist > length / 2 ? 1 : -1) * Math.sign(state.desired - state.active);
      const shift = (100 * (pref || dir)) / (length + 2);
      transition = smooth;
      transform = `translateX(${shift}%)`;
    } else if (!isNaN(o)) {
      if (o !== 0) {
        transform = `translateX(${o}px)`;
      } else {
        transition = elastic;
      }
    }

    c.style.transform = transform;
    c.style.transition = transition;
    c.style.left = `-${(state.active + 1) * 100}%`;
  }
};


Enter fullscreen mode Exit fullscreen mode

现在我们转到:



const updateOffset = () => {
  const c = container.current;

  if (c) {
    const o = offset.current;
    let transform = 'translateX(0)';
    let transition = 'none';

    if (state.desired !== state.active) {
      const shift = getShift(o, state.active, state.desired, length + 2);
      transition = smooth;
      transform = `translateX(${shift}%)`;
    } else if (!isNaN(o)) {
      if (o !== 0) {
        transform = `translateX(${o}px)`;
      } else {
        transition = elastic;
      }
    }

    c.style.transform = transform;
    c.style.transition = transition;
  }
};


Enter fullscreen mode Exit fullscreen mode

getShift功能也得到了很大改进:



function getShift(o: number, active: number, desired: number, total: number) {
  if (!o) {
    const end = total - 3;

    if (!desired && active === end) {
      o = -1;
    } else if (desired === end && !active) {
      o = 1;
    }
  }

  if (o) {
    const pref = Math.sign(o);
    return (100 * pref) / total;
  } else {
    const diff = active - desired;
    return (100 * diff) / total;
  }
}


Enter fullscreen mode Exit fullscreen mode

这会带来更好的用户体验,同时也改善 SSG / SSR 的故事。

除了轮播组件之外,我们还优化了图片,并花了一些时间进行内容清理。没有什么特别的改进,只是偶尔有一些小进步。

最终灯塔得分

得分概要如下:

类别 桌面 移动的
分数 99 87
最佳实践 78 75
首次内容绘制 0.6秒 2.6秒
最大的内容绘画 0.8秒 3.4秒
总阻塞时间 0毫秒 10毫秒
累计布局偏移 0.009 0
速度指数 0.6秒 2.6秒

就是它了!一个部件的“小”改动竟然能带来如此巨大的改变,真是太棒了。但最终一切都取决于细节。

虽然现在每个指标看起来都不错,但最佳实践却变差了一点。为什么?因为我们iframe在首页添加了一个 YouTube 嵌入 ( ) 的小视频。由于 YouTube 会携带相当多的 Cookie 和其他东西,Lighthouse 对第三方内容并不十分满意。所以我们实际上可以忽略这一点。

正如所希望的 90 年代评级一样,这无疑是一次成功,也是所期望的。

结论

总的来说,我们保持了代码库的稳定,只是对整个源代码进行了些许改造。这足以让我们达到更高的 DX 和性能水平。

下一步,我们将对部分内容进行现代化改造,并通过劫持内部链接重新引入单页应用 (SPA) 转换;在页面转换期间加载 HTML 片段。我们可能还会将导入映射替换为集成的 bundle,并使用兼容模式下的 Preact 进行替换。

文章来源:https://dev.to/smapiot/faster-pages-with-react-h8j
PREV
如何为你的Web项目构建高负载架构?
NEXT
在 React 应用中处理 SEO