# ran — full documentation corpus
# https://ran.chaxus.com • auto-generated at build time
================================================================
# https://ran.chaxus.com/cn/src/article/ai/
================================================================
---
description: 'AI Agent 的工作原理:Prompt、角色、工具与上下文——LLM 智能体背后的基础构件。'
---
# Prompt
## System Prompt
用来描述 AI 的角色,性格,背景知识,语气等,总之只要不是用户直接说出来的内容,都可以放到 System Prompt 里面。
## User Prompt
用户直接发送给 AI 的内容。
每次用户发送 User Prompt 的时候,系统会自动把 System Prompt 也一起发起 AI 模型,这样就会显得非常自然。
但到了这个程度,AI 也只是一个聊天机器人,只能问问题,然后 AI 模型去回复问题。
那么如何让 AI 模型去自动的执行一些任务呢?这时候,Agent 就出现了。
# Agent
比如期望用 AI 来管理一些文件,那么得先写好一些文件管理函数:
- list_file 列出目录下的所有文件
- read_file 读取文件的内容
然后把这些函数及其他们的功能描述,使用方法,注册到 Agent 中。
Agent 会根据这些信息,生成一个 System Prompt,告诉 AI 模型,用户给了哪些工具,能够做什么。
以及 AI 使用他们应该返回什么样的格式。
当用户在发送 User Prompt 的时候,连同 System Prompt,一起发送给 AI 模型。
如果 AI 模型足够聪明,就会返回一个格式:需要调用某个函数,比如 list_file 给 Agent。
Agent 解析之后,就会调用对应的函数,然后把结果再给 AI,AI 再根据 Agent 返回的结果,再决定下一步去做什么操作。
这个过程就这样反复,直到任务完成为止。
最后,把这种在 AI 模型,提供的工具 (list_file,read_file),和最终用户之间传话的工具,就叫 AI Agent。
这些提供给 AI Agent 调用的函数,或者服务,就叫 Agent Tool。
但这样可能存在一个问题,虽然我们在 System Prompt 里面描述和规定了 AI 应该用什么格式进行返回,但 AI 模型说到底是一个概率模型。还是有可能返回的格式不对。
一般的 AI Agent 会判断,如果 AI 模型返回的格式不对,会自动进行重试。面对这种场景,FunctionCalling 就出现了。
# FunctionCalling
核心作用就是统一格式,规范描述,比如上面的 System Prompt,使用自然语言描述的,AI 看的懂就行。
FunctionCalling 则对这些描述进行了标准化,每一个 Tool 都用一个 json 来定义,比如:
```json
{
"name": "list_file",
"desc": "列出目录下的所有文件",
"params": {
"path": "str"
}
}
```
然后这些字段也从 System Prompt 中剥离了出来,这样,所有的工具定义,描述,和返回都放在了相同的地方。这样 AI 使用工具时,也会遵循相同 json 格式进行回复。于是人们就可以更加有针对性的训练 AI 模型。甚至在这种情况下,如果 AI 依然返回了错误的回复,因为这种返回的格式是固定的,AI 服务端自己就能检测到,然后进行重试。这样用户根本感觉不到,降低了用户端的开发难度,同时也节约了重试的 token 成本。
但 FunctionCalling 也有自己的问题,那就是没有统一的标准。目前每家大厂的 API 格式都不太一样。甚至还有一些模型,不支持 FunctionCalling,所以,要写一个通用的 AI Agent,还是挺麻烦的。
因此 FunctionCalling 和 System Prompt 这两种方式,在市面上都是并存的。
以上都是 AI Agent 如何和 AI 模型之间的通信,那么 Agent Tool 和 Agent 是怎么通信的呢。
# MCP
一般都是把 Agent 和 Agent Tool 写到一个程序里,这样就能直接调用,搞定。
但后来人们发现,有些 Agent Tool 还是挺通用的,比如浏览网页的功能,每个 Agent 都需要,那么总不能在每个 Agent 里面都拷贝一份吧。于是,就想到来一个办法:
把 Tool 做成一个服务,进行统一的托管。让所有的 Agent 都来调用,这个过程,就是 MCP。
MCP 是一个通信协议,专门用来规范 Agent 和 Tool 服务之间是怎么交互的。
运行 Tool 的服务叫 MCP Server,调用它的 Agent 叫 MCP Client。
MCP 规定了 MCP Server 如何和 MCP Client 进行通信,以及 MCP Server 要提供哪些接口,比如查询 MCP Server 有哪些接口,接口的功能,描述,如何使用。除了普通的 Tool 这种函数的调用形式。
MCP 也可以直接提供数据,提供文件读取的服务 Resources,或者为 Agent 提供提示词的模版叫 Prompt。
MCP Server 既可以和 Agent 跑在同一台机器上,通过标准输入输出进行通信,也可以部署在网络上,通过 http 进行通信。
虽然 MCP 是为了 AI 而定制出来的标准,但实际上,MCP 本身和 AI 模型没有关系,它并不关心 Agent 用的是哪个模型,MCP 只负责帮 Agent 管理工具,资源和提示词。
最后,总结一下全部的流程:
用户 --> 发送消息给 Agent,Agent(MCP Client)调用 MCP Server 的函数,并把结果一起给 AI 模型。
AI 模型通过 FunctionCalling 或者普通回复的方式,产生调用 Tool 的请求,Agent 收到这个请求后,通过 MCP 协议去调用 MCP Server 的工具。将结果返回给 Agent,Agent 再把结果给 AI 模型,AI 模型再把最终结果,返回个 Agent,Agent 再发送给用户。
# 写一个 Agent
Agent 是一个在用户,AI 模型,工具函数之间进行传话的程序。
```mermaid
sequenceDiagram
Tools->>Agent: 将 Tools 里的函数注册到 Agent,让 Agent 知道有哪些工具函数可以使用
User->>Agent: 将问题发送给 Agent
Agent->>AI: 用户的问题是 User Prompt,还有将工具函数信息通过 System Prompt 或者 FunctionCalling 的方式告诉 AI 模型
AI->>Agent: 思考用户的问题,并返回响应的指令,告诉 Agent 应该执行 Tools 中的哪些方法。
Agent->>Tools: 使用 Tools 中的方法,并获得结果
Agent->>AI: 将结果告诉给 AI 模型,AI 模型继续分析问题,看是否需要继续调用 Agent,直到结束。
AI->>Agent: 告诉 Agent 思考过程结束,并将结果发送给 Agent。
Agent->>User: 将最终结果给用户输出。
```
简单来说,就是下面这种方式
```mermaid
graph TD
customer[用户] --> Agent
Agent --> AI
Tools[工具函数] --> Agent
Agent --> customer[用户]
Agent --> Tools[工具函数]
Agent --> AI
```
================================================================
# https://ran.chaxus.com/cn/src/article/doc_preview
================================================================
---
title: 'docx / pptx / xlsx / pdf 文件预览方案'
description: '最全的浏览器端文件预览方案总结:docx、pptx、xlsx(Excel)、pdf,含取舍与踩坑。'
---
最全的 docx,pptx,xlsx(excel),pdf 文件预览方案总结
最近遇到了文件预览的需求,但一搜索发现,这还不是一个简单的功能。于是又去查询了很多资料,调研了一些方案,也踩了好多坑。最后总结方案如下
1. 花钱解决 (使用市面上现有的文件预览服务)
1. 微软
2. google
3. 阿里云 IMM
4. XDOC
5. Office Web 365
6. wps 开放平台
2. 前端方案
1. pptx 的预览方案
2. pdf 的预览方案
3. docx 的预览方案
4. xlsx(excel) 的预览方案
5. 前端预览方案总结
3. 服务端方案
1. openOffice
2. kkFileView
3. onlyOffice
如果有其他人也遇到了同样的问题,有了这篇文章,希望能更方便的解决。
基本涵盖了所有解决方案。因此,标题写上 **最全** 的文件预览方案调研总结,应该不为过吧。
## 一:市面上现有的文件预览服务
### 1.微软
`docx`,`pptx`,`xlsx`可以说是`office`三件套,那自然得看一下 **微软官方** 提供的文件预览服务。使用方法特别简单,只需要将文件链接,拼接到参数后面即可。
记得`encodeURL`
```js
https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}
```
#### (1).PPTX 预览效果:

- 优点:还原度很高,功能很丰富,可以选择翻页,甚至支持点击播放动画。
- 缺点:不知道是不是墙的原因,加载稍慢。
#### (2).Excel 预览效果:

#### (3).Doxc 预览效果

#### (4).PDF 预览效果
这个我测试没有成功,返回了一个错误,其他人可以试试。

#### (5).总的来说
对于`docx`,`pptx`,`xlsx`都有较好的支持,`pdf`不行。
还有一个坑点是:这个服务是否稳定,有什么限制,是否收费,都查不到一个定论。在`office`官方网站上甚至找不到介绍这个东西的地方。
目前只能找到一个`Q&A`:https://answers.microsoft.com/en-us/msoffice/forum/all/what-is-the-status-of-viewofficeappslivecom/830fd75c-9b47-43f9-89c9-4303703fd7f6
微软官方人员回答表示:

翻译翻译,就是:几乎永久使用,没有收费计划,不会存储预览的文件数据,限制文件`10MB`,建议用于 **查看互联网上公开的文件**。
但经过某些用户测试发现:

使用了微软的文件预览服务,然后删除了文件地址,仍然可访问,但过一段时间会失效。
### 2.Google Drive 查看器
接入简单,同 `Office Web Viewer`,只需要把 `src` 改为`https://drive.google.com/viewer?url=${encodeURIComponent(url)}`即可。
限制`25MB`,支持以下格式:

测试效果,支持`docx,pptx,xlsx,pdf`预览,但`pptx`预览的效果不如微软,没有动画效果,样式有小部分会错乱。
**由于某些众所周知的原因,不可用**
### 3.阿里云 IMM
官方文档如下:https://help.aliyun.com/document_detail/63273.html

付费使用
### 4.XDOC 文档预览
说了一些大厂的,在介绍一些其他的,**需要自行分辨**
官网地址:https://view.xdocin.com/view-xdocin-com_6x5f4x.htm

### 5.Office Web 365
需要注意的是,虽然名字很像`office`,但我们看网页的`Copyright`可以发现,其实是一个西安的公司,**不是微软**。
但毕竟也提供了文件预览的服务
官网地址:https://www.officeweb365.com/

### 6.WPS 开放平台
官方地址:https://solution.wps.cn/

付费使用,价格如下:

## 二:前端处理方案
### 1.pptx 的预览方案
先查一下有没有现成的轮子,目前`pptx`的开源预览方案能找到的只有这个:https://github.com/g21589/PPTX2HTML。但已经六七年没有更新,也没有维护,笔者使用的时候发现有很多兼容性问题。
简单来说就是,没有。对于这种情况,我们可以自行解析,主要步骤如下:
1. 查询`pptx`的国际标准
2. 解析`pptx`文件
3. 渲染成`html`或者`canvas`进行展示
我们先去找一下`pptx`的国际标准,官方地址:[officeopenxml](http://officeopenxml.com/)
先解释下什么是`officeopenxml`:
> Office OpenXML,也称为 OpenXML 或 OOXML,是一种基于 XML 的办公文档格式,包括文字处理文档、电子表格、演示文稿以及图表、图表、形状和其他图形材料。该规范由微软开发,并于 2006 年被 ECMA 国际采用为 ECMA-376。第二个版本于 2008 年 12 月发布,第三个版本于 2011 年 6 月发布。该规范已被 ISO 和 IEC 采用为 ISO/IEC 29500。
> 虽然 Microsoft 继续支持较旧的二进制格式 (.doc、.xls 和.ppt),但 OOXML 现在是所有 Microsoft Office 文档 (.docx、.xlsx 和.pptx) 的默认格式。
由此可见,`Office OpenXML`由微软开发,目前已经是国际标准。接下来我们看一下`pptx`里面有哪些内容,具体可以看`pptx`的官方标准:[officeopenxml-pptx](http://officeopenxml.com/anatomyofOOXML-pptx.php)
> PresentationML 或.pptx 文件是一个**zip 文件**,其中包含许多“部分”(通常是 UTF-8 或 UTF-16 编码)或 XML 文件。该包还可能包含其他媒体文件,例如图像。该结构根据 OOXML 标准 ECMA-376 第 2 部分中概述的开放打包约定进行组织。

根据国际标准,我们知道,`pptx`文件本质就是一个`zip`文件,其中包含许多部分:
> 部件的数量和类型将根据演示文稿中的内容而有所不同,但始终会有一个 [Content_Types].xml、一个或多个关系(.rels)部件和一个演示文稿部件(演示文稿.xml),它位于 ppt 文件夹中,用于 Microsoft Powerpoint 文件。通常,还将至少有一个幻灯片部件,以及一张母版幻灯片和一张版式幻灯片,从中形成幻灯片。
那么`js`如何读取`zip`呢?
找到一个工具:https://www.npmjs.com/package/jszip
于是我们可以开始尝试解析`pptx`了。
```ts
import JSZip from 'jszip';
// 加载 pptx 数据
const zip = await JSZip.loadAsync(pptxData);
```
- 解析`[Content_Types].xml`
每个`pptx`必然会有一个 `[Content_Types].xml`。此文件包含包中部件的所有内容类型的列表。每个部件及其类型都必须列在 `[Content_Types].xml` 中。通过它里面的内容,可以解析其他的文件数据
```ts
const filesInfo = await getContentTypes(zip);
async function getContentTypes(zip: JSZip) {
const ContentTypesJson = await readXmlFile(zip, '[Content_Types].xml');
const subObj = ContentTypesJson['Types']['Override'];
const slidesLocArray = [];
const slideLayoutsLocArray = [];
for (let i = 0; i < subObj.length; i++) {
switch (subObj[i]['attrs']['ContentType']) {
case 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml':
slidesLocArray.push(subObj[i]['attrs']['PartName'].substr(1));
break;
case 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml':
slideLayoutsLocArray.push(subObj[i]['attrs']['PartName'].substr(1));
break;
default:
}
}
return {
slides: slidesLocArray,
slideLayouts: slideLayoutsLocArray,
};
}
```
- 解析演示文稿
先获取`ppt`目录下的`presentation.xml`演示文稿的大小
由于演示文稿是`xml`格式,要真正的读取内容需要执行 `readXmlFile`
```ts
const slideSize = await getSlideSize(zip);
async function getSlideSize(zip: JSZip) {
const content = await readXmlFile(zip, 'ppt/presentation.xml');
const sldSzAttrs = content['p:presentation']['p:sldSz']['attrs'];
return {
width: (parseInt(sldSzAttrs['cx']) * 96) / 914400,
height: (parseInt(sldSzAttrs['cy']) * 96) / 914400,
};
}
```
- 加载主题
根据 `officeopenxml`的标准解释
> 每个包都包含一个关系部件,用于定义其他部件之间的关系以及与包外部资源的关系。这样可以将关系与内容分开,并且可以轻松地更改关系,而无需更改引用目标的源。
> 除了包的关系部分之外,作为一个或多个关系源的每个部件都有自己的关系部分。每个这样的关系部件都可以在部件的\_rels 子文件夹中找到,并通过在部件名称后附加“.rels”来命名。
其中主题的相关信息就在`ppt/_rels/presentation.xml.rels`中
```ts
async function loadTheme(zip: JSZip) {
const preResContent = await readXmlFile(zip, 'ppt/_rels/presentation.xml.rels');
const relationshipArray = preResContent['Relationships']['Relationship'];
let themeURI;
if (relationshipArray.constructor === Array) {
for (let i = 0; i < relationshipArray.length; i++) {
if (
relationshipArray[i]['attrs']['Type'] ===
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme'
) {
themeURI = relationshipArray[i]['attrs']['Target'];
break;
}
}
} else if (
relationshipArray['attrs']['Type'] === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme'
) {
themeURI = relationshipArray['attrs']['Target'];
}
if (themeURI === undefined) {
throw Error("Can't open theme file.");
}
return readXmlFile(zip, 'ppt/' + themeURI);
}
```
后续`ppt`里面的其他内容,都可以这么去解析。根据`officeopenxml`标准,可能包含:
| Part | Description |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Comments Authors | Contains information about each author who has added a comment to the presentation. |
| Comments | Contains comments for a single slide. |
| Handout Master | Contains the look, position, and size of the slides, notes, header and footer text, date, or page number on the presentation's handout. There can be only one such part. |
| Notes Master | Contains information about the content and formatting of all notes pages. There can be only one such part. |
| Notes Slide | Contains the notes for a single slide. |
| Presentation | Contains the definition of a slide presentation. There must be one and only one such part. See [Presentation](http://officeopenxml.com/PrPresentation.php). |
| Presentation Properties | Contains all of the presentation's properties. There must be one and only one such part. |
| Slide | Contains the content of a single slide. |
| Slide Layout | Contains the definition for a slide template. It defines the default appearance and positioning of drawing objects on the slide. There must be one or more such parts. |
| Slide Master | Contains the master definition of formatting, text, and objects that appear on each slide in the presentation that is derived from the slide master. There must be one or more such parts. |
| Slide Synchronization Data | Contains properties specifying the current state of a slide that is being synchronized with a version of the slide stored on a central server. |
| User-Defined Tags | Contains a set of user-defined properties for an object in a presentation. There can be zero or more such parts. |
| View Properties | Contains display properties for the presentation. |
等等内容,我们根据标准一点点解析并渲染就好了。
完整源码:[ranui](https://github.com/chaxus/ran/tree/main/packages/ranui)
使用文档:[preview 组件](https://ran.chaxus.com/src/ranui/preview/)
### 2.pdf 的预览方案
#### (1).iframe 和 embed
`pdf`比较特别,一般的浏览器默认支持预览`pdf`。因此,我们可以使用浏览器的能力:
```html
```
但这样就完全依赖浏览器,对`PDF`的展示,交互,是否支持全看浏览器的能力,且不同的浏览器展示和交互往往不同,如果需要统一的话,最好还是尝试其他方案。
`embed`的解析方式也是一样,这里不举例子了
#### (2)pdfjs
npm:
github 地址:
由`mozilla`出品,就是我们常见的`MDN`的老大。
而且目前 火狐浏览器 使用的 PDF 预览就是采用这个,我们可以用火狐浏览器打开`pdf`文件,查看浏览器使用的`js`就能发现

需要注意的是,最新版`pdf.js`限制了`node`版本,需要大于等于`18`
github 链接:https://github.com/mozilla/pdf.js/blob/master/package.json

如果你项目`node`版本小于这个情况,可能会无法使用。
如果遇到这种情况,可以用以前版本,以前的版本没有限制。
具体使用情况如下:
- 完整源码:https://github.com/chaxus/ran/tree/main/packages/ranui
- 使用文档:https://ran.chaxus.com/src/ranui/preview/
```ts
import * as pdfjs from 'pdfjs-dist';
import * as pdfjsWorker from 'pdfjs-dist/build/pdf.work.entry';
interface Viewport {
width: number;
height: number;
viewBox: Array;
}
interface RenderContext {
canvasContext: CanvasRenderingContext2D | null;
transform: Array;
viewport: Viewport;
}
interface PDFPageProxy {
pageNumber: number;
getViewport: () => Viewport;
render: (options: RenderContext) => void;
}
interface PDFDocumentProxy {
numPages: number;
getPage: (x: number) => Promise;
}
class PdfPreview {
private pdfDoc: PDFDocumentProxy | undefined;
pageNumber: number;
total: number;
dom: HTMLElement;
pdf: string | ArrayBuffer;
constructor(pdf: string | ArrayBuffer, dom: HTMLElement | undefined) {
this.pageNumber = 1;
this.total = 0;
this.pdfDoc = undefined;
this.pdf = pdf;
this.dom = dom ? dom : document.body;
}
private getPdfPage = (number: number) => {
return new Promise((resolve, reject) => {
if (this.pdfDoc) {
this.pdfDoc.getPage(number).then((page: PDFPageProxy) => {
const viewport = page.getViewport();
const canvas = document.createElement('canvas');
this.dom.appendChild(canvas);
const context = canvas.getContext('2d');
const [_, __, width, height] = viewport.viewBox;
canvas.width = width;
canvas.height = height;
viewport.width = width;
viewport.height = height;
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
const renderContext = {
canvasContext: context,
viewport: viewport,
transform: [1, 0, 0, -1, 0, viewport.height],
};
page.render(renderContext);
resolve({ success: true, data: page });
});
} else {
reject({ success: false, data: null, message: 'pdfDoc is undefined' });
}
});
};
pdfPreview = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
window.pdfjsLib.getDocument(this.pdf).promise.then(async (doc: PDFDocumentProxy) => {
this.pdfDoc = doc;
this.total = doc.numPages;
for (let i = 1; i <= this.total; i++) {
await this.getPdfPage(i);
}
});
};
prevPage = () => {
if (this.pageNumber > 1) {
this.pageNumber -= 1;
} else {
this.pageNumber = 1;
}
this.getPdfPage(this.pageNumber);
};
nextPage = () => {
if (this.pageNumber < this.total) {
this.pageNumber += 1;
} else {
this.pageNumber = this.total;
}
this.getPdfPage(this.pageNumber);
};
}
const createReader = (file: File): Promise => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = (error) => {
reject(error);
};
reader.onabort = (abort) => {
reject(abort);
};
});
};
export const renderPdf = async (file: File, dom?: HTMLElement): Promise => {
try {
if (typeof window !== 'undefined') {
const pdf = await createReader(file);
if (pdf) {
const PDF = new PdfPreview(pdf, dom);
PDF.pdfPreview();
}
}
} catch (error) {
console.log('renderPdf', error);
}
};
```
### 3.docx 的预览方案
我们可以去查看`docx`的国际标准,去解析文件格式,渲染成`html`和`canvas`,不过比较好的是,已经有人这么做了,还开源了
`npm`地址:https://www.npmjs.com/package/docx-preview
使用方法如下:
```ts
import { renderAsync } from 'docx-preview';
interface DocxOptions {
bodyContainer?: HTMLElement | null;
styleContainer?: HTMLElement;
buffer: Blob;
docxOptions?: Partial>;
}
export const renderDocx = (options: DocxOptions): Promise | undefined => {
if (typeof window !== 'undefined') {
const { bodyContainer, styleContainer, buffer, docxOptions = {} } = options;
const defaultOptions = {
className: 'docx',
ignoreLastRenderedPageBreak: false,
};
const configuration = Object.assign({}, defaultOptions, docxOptions);
if (bodyContainer) {
return renderAsync(buffer, bodyContainer, styleContainer, configuration);
} else {
const contain = document.createElement('div');
document.body.appendChild(contain);
return renderAsync(buffer, contain, styleContainer, configuration);
}
}
};
```
### 4.xlsx 的预览方案
我们可以使用这个:
`npm`地址:https://www.npmjs.com/package/@vue-office/excel
支持`vue2`和`vue3`,也有`js`的版本
对于`xlsx`的预览方案,这个是找到最好用的了。
### 5.前端预览方案总结
我们对以上找到的优秀的解决方案,进行改进和总结,并封装成一个`web components`组件:[preview 组件](https://ran.chaxus.com/src/ranui/preview/)
为什么是`web components`组件?
因为它跟框架无关,可以在任何框架中使用,且使用起来跟原生的`div`标签一样方便。
并编写使用文档:[preview 组件文档](https://ran.chaxus.com/src/ranui/preview/),
文档支持交互体验。
源码公开,`MIT`协议。
目前`docx`,`pdf`,`xlsx`预览基本可以了,都是最好的方案。`pptx`预览效果不太好,因为需要自行解析。不过**源码完全公开**,需要的可以提`issue`,`pr`或者干脆自取或修改,源码地址:https://github.com/chaxus/ran/tree/main/packages/ranui
## 三:服务端预览方案
### 1.openOffice
由于浏览器不能直接打开`docx`,`pptx`,`xlsx`等格式文件,但可以直接打开`pdf`和图片。因此,我们可以换一个思路,用服务端去转换下文件的格式,转换成浏览器能识别的格式,然后再让浏览器打开,这不就 OK 了吗,甚至不需要前端处理了。
我们可以借助`openOffice`的能力,先介绍一下`openOffice`:
> `Apache OpenOffice`是领先的开源办公软件套件,用于文字处理,电子表格,演示文稿,图形,数据库等。它有多种语言版本,适用于所有常用计算机。它以国际开放标准格式存储您的所有数据,还可以从其他常见的办公软件包中读取和写入文件。它可以出于任何目的完全免费下载和使用。
官网如下:https://www.openoffice.org/
需要先下载`opneOffice`,找到`bin`目录,进行设置
```java
configuration.setOfficeHome("这里的路径一般为 C:\\Program Files (x86)\\OpenOffice 4");
```
测试下转换的文件路径
```java
public static void main(String[] args) {
convertToPDF("/Users/Desktop/asdf.docx", "/Users/Desktop/adsf.pdf");
}
```
完整如下:
```java
package org.example;
import org.artofsolving.jodconverter.OfficeDocumentConverter;
import org.artofsolving.jodconverter.office.DefaultOfficeManagerConfiguration;
import org.artofsolving.jodconverter.office.OfficeManager;
import java.io.File;
public class OfficeUtil {
private static OfficeManager officeManager;
private static int port[] = {8100};
/**
* start openOffice service.
*/
public static void startService() {
DefaultOfficeManagerConfiguration configuration = new DefaultOfficeManagerConfiguration();
try {
System.out.println("准备启动 office 转换服务....");
configuration.setOfficeHome("这里的路径一般为 C:\\Program Files (x86)\\OpenOffice 4");
configuration.setPortNumbers(port); // 设置转换端口,默认为 8100
configuration.setTaskExecutionTimeout(1000 * 60 * 30L);// 设置任务执行超时为 30 分钟
configuration.setTaskQueueTimeout(1000 * 60 * 60 * 24L);// 设置任务队列超时为 24 小时
officeManager = configuration.buildOfficeManager();
officeManager.start(); // 启动服务
System.out.println("office 转换服务启动成功!");
} catch (Exception e) {
System.out.println("office 转换服务启动失败!详细信息:" + e);
}
}
/**
* stop openOffice service.
*/
public static void stopService() {
System.out.println("准备关闭 office 转换服务....");
if (officeManager != null) {
officeManager.stop();
}
System.out.println("office 转换服务关闭成功!");
}
public static void convertToPDF(String inputFile, String outputFile) {
startService();
System.out.println("进行文档转换转换:" + inputFile + " --> " + outputFile);
OfficeDocumentConverter converter = new OfficeDocumentConverter(officeManager);
converter.convert(new File(inputFile), new File(outputFile));
stopService();
}
public static void main(String[] args) {
convertToPDF("/Users/koolearn/Desktop/asdf.docx", "/Users/koolearn/Desktop/adsf.pdf");
}
}
```
### 2.kkFileView
`github`地址:https://github.com/kekingcn/kkFileView
支持的文件预览格式非常丰富

接下来是 `从零到一` 的启动步骤,按着步骤来,任何人都能搞定
1. 安装`java`:
```sh
brew install java
```
1. 安装`maven`,`java`的包管理工具:
```sh
brew install mvn
```
1. 检查是否安装成功
执行`java --version`和`mvn -v`。我这里遇到`mvn`找不到`java home`的报错。解决方式如下:
我用的是`zsh`,所以需要去`.zshrc`添加路径:
```
export JAVA_HOME=$(/usr/libexec/java_home)
```
添加完后,执行
```
source .zshrc
```
4. 安装下`libreoffice `:
`kkFileView`明确要求的额外依赖,否则无法启动
```
brew install libreoffice
```
5. `mvn`安装依赖
进入项目,在根目录执行依赖安装,同时清理缓存,跳过单测 (遇到了单测报错的问题)
```
mvn clean install -DskipTests
```
6. 启动项目
找到主文件,主函数`mian`,点击`vscode`上面的`Run`即可执行,路径如下图

1. 访问页面
启动完成后,点击终端输出的地址

1. 最终结果
最终展示如下,可以添加链接进行预览,也可以选择本地文件进行预览

预览效果非常好
### 3.onlyOffice
官网地址:https://www.onlyoffice.com/zh
`github`地址:https://github.com/ONLYOFFICE
开发者版本和社区版免费,企业版付费:https://www.onlyoffice.com/zh/docs-enterprise-prices.aspx
预览的文件种类没有`kkFileView`多,但对`office`三件套有很好的支持,甚至支持多人编辑。
## 四:总结
1. 外部服务,推荐微软的`view.officeapps.live.com/op/view.aspx`,但只建议预览一些互联网公开的文件,不建议使用在要求保密性和稳定性的文件。
2. 对保密性和稳定性有要求,且不差钱的,可以试试大厂服务,阿里云解决方案。
3. 服务端技术比较给力的,使用服务端预览方案。目前最好最全的效果是服务端预览方案。
4. 不想花钱,没有服务器的,使用前端预览方案,客户端渲染零成本。
## 五:参考文档:
1. [在 java 中如何使用 openOffice 进行格式转换](https://blog.csdn.net/Li_Zhongxin/article/details/132105957)
2. [MAC 搭建 OpenOffice 完整教程 - 保姆级](https://blog.51cto.com/u_15899048/5902747)
3. [纯 js 实现 docx、xlsx、pdf 文件预览库,使用超简单](https://juejin.cn/post/7251199685130059833)
4. [前端实现 word、excel、pdf、ppt、mp4、图片、文本等文件的预览](https://juejin.cn/post/7071598747519549454)
================================================================
# https://ran.chaxus.com/cn/src/article/functional_programming
================================================================
---
description: '函数式编程入门:纯函数、组合、柯里化与不可变性,配 JavaScript/TypeScript 示例。'
---
# 函数式编程
- 概述:函数式编程 (`Functional Programming`)`FP`就是编程规范之一,我们常听说的编程规范还有面向对象编程,面向过程编程。
- 面向对象的编程思维方式:把现实世界中的事物抽象成程序世界的类和对象,通过封装,继承和多态演示事物事件的联系
- 函数编程的思维方式:把现实世界的事物和事物之间的**联系**抽象到程序世界 (对运算过程进行抽象)
- 程序的本质:根据输入,通过某种运算,获得相应的输出
- 函数式编程中的函数不是指程序中的 (函数) 方法,**而是数学中的函数,即映射关系**
- 相同的输入始终要得到相同的输出 (纯函数)
- 函数式编程用来描述数据 (函数) 之间的映射关系
```js
//非函数式编程,面向过程的编程方式
let num1 = 1;
let num2 = 2;
let sum = num1 + num2;
//函数式编程,对面向过程的抽象
function sum(n1, n2) {
return n1 + n2;
}
let result = sum(1, 2);
```
## 一。`JS`函数基本知识
- 函数可以储存在变量中
- 函数可以作为参数
- 函数可以作为返回值
在`JavaScript`中,函数就是一个普通的对象,(可以通过`new Function()`),我们可以把函数存储到变量/数组中,它还可以作为另一个函数的参数和返回值,甚至我们还可以在程序运行的时候通过`new Function('alert(1)')`来构建一个新的函数
- 把函数赋值给变量
```js
let fn = function () {
console.log('First-class Function MDN');
};
```
## 二。高阶函数
- 高阶函数 (`Higher-order function`)
- 可以把函数作为参数传递给另一个函数
- 可以把函数作为另一个函数的返回结果
1. 函数作为参数
```js
//forEach
function forEach(array, fn) {
for (let item of array) {
fn(item);
}
}
//filter
function filter(array, fn) {
let result = [];
for (let item of array) {
if (fn(item)) {
result.push(item);
}
}
return result;
}
//测试
let array = [1, 2, 3, 4, 5, 6, 7];
forEach(array, function (item) {
console.log(item);
});
let r = filter(array, function (item) {
return item % 2 === 0;
});
console.log(r);
```
2. 函数作为返回值
```js
//高阶函数,函数作为返回值
function makeFn() {
let msg = 'Highter-order Function';
return function () {
console.log(msg);
};
}
//第一种调用方式
const fn = makeFn();
fn();
//第二种调用方式
makeFn()();
//once 只能执行一次的函数
function once(fn) {
let done = false;
return function () {
if (!done) {
done = true;
return fn.apply(this, arguments);
}
};
}
let pay = once(function (money) {
console.log(`支付${money}`);
});
pay(5);
pay(5);
pay(5);
pay(5);
pay(5);
```
## 三。闭包
**概述**:有权访问另一个函数作用域中的变量的函数
**本质**:函数执行的时候会入栈,当执行完后会移除栈,但是堆上的作用域成员因为被外部引用而不能释放。因此内部函数依然可以访问外部函数的成员。
**栈**:栈会自动分配内存,会自动释放,存放基本数据类型,占据固定大小的空间。
**栈的溢出**:递归调用方法,随着栈的深度增加,直到内存不够分配,产生溢出。
**栈的优势**:所有方法中的变量都存在栈中,随着方法执行的结束,这个方法的内存栈也随之销毁,存取速度很快,仅次于 CPU 的寄存器,可以共享。
**堆**:动态分配内存,大小不定,也不会自动释放内存,不会随着方法的结束而销毁堆内存,存放引用数据类型,实际保存的不是变量本身,而是指向该对象的指针。
**堆溢出**:循环创建对象,就是不断的 new 对象
## 四。纯函数
- 概念:相同的输入永远会得到相同的输出,而且没有任何可观察的副作用,类似数学中的函数`y=f(x)`
- 例子:`slice`函数就是纯函数,对一个数组,相同的输入永远得到相同的输出,splice 就是非纯函数,相同的输入可能会得到不同的结果,因为会改变原数组
- 函数式编程不会保留计算中间的结果,所以变量不可变 (无状态)
- 可以把一个函数的执行结果交给另一个函数去执行
- 纯函数可以缓存,因为相同的输入必然有相同的输出
```js
//memoize 函数
function memoize(fn) {
let cache = {};
return function () {
let key = JSON.stringify(arguments);
cache[key] = cache[key] || fn.apply(fn, arguments);
return cache[key];
};
}
```
- 可测试,让测试更方便
- 多线程环境下操作共享的内存数据可能会出现意外的情况,而纯函数不需要共享的数据空间,只和输入有关,所以并行环境下可以任意运行纯函数
- 副作用,副作用会让纯函数变的不纯,比如依赖外部的状态,就无法保证输出相同,带来副作用,副作用来源:配置文件,数据库,获取用户的输入等等...所有的外部交互都可能带来副作用,副作用使得方法通用性下降,不适合扩展和重用,同时给程序带来安全隐患,副作用不可能完全禁止,只能尽可能的在控制范围内。
```js
//不纯的函数
let mini = 18;
function checkAge(age) {
return age > mini;
}
//纯函数 (有硬编码,后续可以通过柯里化来解决)
function checkAge(age) {
let mini = 18;
return age > mini;
}
```
## 五。柯里化 (`Haskell Brooks Curry`)
```js
//解决上述硬编码的问题
function checkAge(min) {
return function (age) {
return age >= min;
};
}
let checkAge18 = checkAge(18);
checkAge18(22);
```
`es6`进行简化
```js
let checkAge = (min) => (age) => age >= min;
```
- 当一个函数有多个参数的时候,可以先传递一部分,先调用它,并返回一个函数 (这部分参数以后保持不变)
- 然后返回一个新的函数接受剩下的参数,返回结果
- `lodash`中的柯里化函数
- `_.curry(func)`
- 功能:创建一个函数,该函数接受一个或多个 func 的参数,如果该函数所有的参数都被传递,则返回函数的结果,否则,返回该函数并等待继续传递参数
- 参数:需要柯里化的函数
- 返回值:柯里化后的函数
```js
//lodash 中的 curry 的使用
const _ = require('lodash');
function getSum(a, b, c) {
return a + b + c;
}
const curried = _.curry(getSum);
console.log(curried(1, 2, 3)); //6
console.log(curried(1)(2, 3)); //6
console.log(curried(1)(2)(3)); //6
```
实现一个 curry 函数
```js
function curry(func) {
return function curriedFn(...args) {
if (args.length < func.length) {
return function () {
return curriedFn(...args.concat(Array.from(arguments)));
};
} else {
return func(...args);
}
};
}
```
- 总结:柯里化可以让我们给一个函数传递较少的参数,返回一个记住来某些固定参数的新函数,这是一种对函数参数的缓存,让函数变的更灵活,让函数的粒度更小。可以把多元函数转换成一元的函数,可以组合使用函数产生强大的功能。
## 六。函数的组合
- 纯函数和柯里化很容易让我们写出洋葱代码,比如`h(f(g(x)))`
- 获取数组的最后一个元素并转化为大写字母,`_.toUpper(._first(_.revers(array)))`
- 函数的组合可以让我们把细粒度的函数,重新组合成一个新的函数
- `lodash`中的组合函数
- `lodash`中的组合函数`flow()`和`flowRight()`,都可以组合多个函数
- `flow()`是从左到右执行
- `flowRight()`是从右到左执行
- 自己实现一个`flowRight`函数:
```js
function composeRight(...args) {
return function (value) {
args.reverse().reduce(function (acc, fn) {
return fn(acc);
}, value);
};
}
//箭头函数
const compose =
(...args) =>
(value) =>
args.reverse().reduce((acc, fn) => fn(acc), value);
//如果是表达式赋值的话,不会变量提升
```
- 函数的组合要满足结合律,即 f,g,h 三个函数,无论先组合那几个,结果都是等效的,即 flowRight(_.toUpper,_.first,\_.revers)
- 函数组合如何进行调试?
```js
const log = (v) => {
console.log(v);
return v;
};
```
- `lodash`库中的 fp 模块
- `lodash`的 fp 模块提供了实用的对函数式编程友好的方法
- 提供了不可变的**auto-curried iteratee-first data-last**的方法
```js
//lodash 方法
const _ = require('lodash');
_.map(['a', 'b', 'c'], _.toUpper);
//=>['A','B','C']
_.map(['a', 'b', 'c']);
//=>['a','b','c']
//lodash/fp 模块
const fp = require('lodash/fp');
fp.map(fp.toUpper, ['a', 'b', 'c']);
fp.map(fp.toUpper)(['a', 'b', 'c']);
```
## 七.Point Free
我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。
- 不需要指明处理的数据
- 只需要合成运算过程
- 需要定义一些辅助的基本运算函数
```js
const f = fp.flowRight(fp.join('-'), fp.map(_.toLower), fp.split(''));
```
## 八。`functor`(函子)
- 为什么要了解函子
目前没有解决如何在函数式编程中,把副作用控制在可控的范围内,异常处理,异步操作等等。
- `Functor`
- 容器:包含值和值的变形关系 (这个变形关系就是函数)
- 函子:是一个特殊的容器,通过一个普通对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理 (变形关系)
```js
//Functor 函子
class Container {
//函子内部要有一个值
constructor(value) {
//这个值是传入进来的,且不对外公布
this._value = value;
}
map(fn) {
//map 方法,接受一个处理值的函数,去处理这个值。
//并且要把处理的值,传给一个新的函子,最后返回这个新的函子
return new Container(fn(this._value));
}
}
//新建一个函子
let r = new Container(5).map((x) => x + 1).map((x) => x * x);
```
`of`方法:
```js
//of 方法用来返回一个函子对象
class Container {
constructor(value) {
this._value = value;
}
static of(value) {
//传入值,返回一个新的函子对象
return new Container(value);
}
map(fn) {
return Container.of(fn(this._value));
}
}
let r = Container.of(5)
.map((x) => x + 1)
.map((x) => x * x);
console.log(r); //打印出来的是一个函子,不是值,永远不会把这个值取出来,需要改变这个值的时候,使用 map 方法传入一个函数去处理,进行链式调用。
```
- 总结
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了`map`契约的对象
- 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
- 想要处理盒子中的值,我们需要给盒子的`map`方法传递一个处理值的函数(纯函数),由这个函数对值进行处理
- 最终`map`方法返回一个包含新值的盒子(函子)
- `MayBe`函子
- 我们在编程过程中可能会遇到很多的错误,需要对这些错误进行相应的处理
- MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围之内)
```js
//MayBe 函子
class MayBe {
static of(value) {
return new MayBe(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value));
}
isNothing() {
return this._value === null || this._value === undefined;
}
}
let r = MayBe.of(null)
.map((x) => x + 1)
.map((x) => x * x);
console.log(r);
```
- 问题:如果多次调用 map,中间出现了 null 空值的情况,最后会返回包含 null 的函子。虽然 maybe 函子可以处理空值的情况,但不知道是哪一步出现了空值
- Either 函子
- Either 两者中的任意一个,类似于 if...else...的处理
- 异常会让函数变的不纯,Either 函子可以用来做异常处理
```js
//Either 函子
class Left {
static of(value) {
return new Left(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return this;
}
}
class Right {
static of(value) {
return new Right(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return Right.of(fn(this._value));
}
}
function parseJSON(str) {
try {
return Right.of(JSON.parse(str));
} catch (error) {
return Left.of({ error: error.message });
}
}
let l = parseJSON('{name:zs}'); //error
console.log(l);
let r = parseJSON('{"name":"zs"}');
console.log(r);
r.map((x) => x.name.toUpperCase());
```
- IO 函子
- IO 函子中的\_value 是一个函数,这里是把函数当作值来处理
- IO 函子可以把不纯的函数储存到\_value 中,延迟执行这个不纯的操作 (惰性执行)
- 把不纯的操作交给调用者来处理
```js
const fp = require('lodash/fp');
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
return IO.of(fp.flowRight(fn, this._value));
}
}
//调用
//因为是在 node 环境,所以直接传递 process 对象,node 的进程
let r = IO.of(process).map((p) => p.execPath);
console.log(r); //IO {_value :[Function]}
console.log(r._value()); //执行 node 进程的路径
```
- folktale
- folktale 是一个标准的函数式编程库
- 和 lodash,ramda 不同的是,他没有提供很多功能函数
- 只提供了函数式处理的操作,例如,curry,compose 等,和一些函子 Task,Either,MayBe 等
```js
//folktale 2.3.2
//Task 处理异步任务
const fs = require('fs');
const { task } = require('folktale/concurrency/task');
const { split, find } = require('lodash/fp');
function readFile(filename) {
return task((resolver) => {
fs.readFile(filename, 'utf-8', (error, data) => {
if (error) {
resolver.reject(error);
} else {
resolver.resolve(data);
}
});
});
}
//会返回一个 Task 函子
readFile('package.json')
.run()
.listen({
//监听事件的状态
onRejected: (err) => {
console.log(err);
},
onResolved: (value) => {
console.log(value);
},
});
//可以在 run 之前调用 map,去处理返回的结果
readFile('package.json')
.map(split('\n'))
.map(find((x) => x.includes('version')))
.run()
.listen({
//监听事件的状态
onRejected: (err) => {
console.log(err);
},
onResolved: (value) => {
console.log(value);
},
});
```
- Pointed 函子
- Pointed 函子是实现的静态方法 of 的函子
- of 是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文 Context 中 (把值放到容器中,使用 map 来处理值)
- Monad 函子
- Monad 函子是为来解决 IO 函子嵌套的问题
```js
const fp = require('lodash/fp');
const fs = require('fs');
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
return IO.of(fp.flowRight(fn, this._value));
}
}
let readFile = function (filename) {
return new IO(function () {
return fs.readFileSync(filename, 'utf-8');
});
};
let print = function (x) {
return new IO(function () {
console.log(x);
return x;
});
};
let cat = fp.flowRight(print, readFile);
let r = cat('package.json')._value()._value();
console.log(r);
```
- Monad 函子是一个可以变扁的 Pointed 函子,变扁就是解决函子嵌套的问题 IO(IO(x))
- 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
```js
//注意看 join 方法
const fp = require('lodash/fp');
const fs = require('fs');
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
return IO.of(fp.flowRight(fn, this._value));
}
join() {
return this._value();
}
flatMap(fn) {
//经常会用到 map 和 join 方法,所以就用 flatMap 将其变扁
return this.map(fn).join();
}
}
let print = function (x) {
return new IO(function () {
console.log(x);
return x;
});
};
let r = readFile('package.json') //这里可以用 map 去处理内容
.flatMap(print)
.join();
```
## 参考资料
- [函数式编程与异步编程](http://t.zoukankan.com/feng9exe-p-10477713.html)
- [函数式编程初探](http://www.ruanyifeng.com/blog/2012/04/functional_programming.html)
- [函数式编程入门教程](http://www.ruanyifeng.com/blog/2017/02/fp-tutorial.html)
================================================================
# https://ran.chaxus.com/cn/src/article/math/linear_algebra
================================================================
# 线性代数
## 一:向量究竟是什么
线性代数中最基础,最根源的组成部分就是向量。一般来说,有三种看待向量的观点:
1. 从物理学家的视角来看:向量是空间中的箭头,决定一个向量是它的长度和它所指的方向。其中平面的向量是二维的,处在我们生活的向量是三维的。只要长度和方向相同,我们就可以自由的移动它而向量不会发生改变。
2. 从计算机学家的角度来看:向量是有序的数字列表。比如正在做一些有关房价的分析,我们只用关注两个特征:房屋面积和价格。我们会用一对数字对房屋进行建模:
第一个是房屋面积,第二个字段是价格。注意,这里的数字顺序是不能颠倒的。我们会用二维向量对房屋进行建模
3. 数学家会试图概括这两种观点:大致地说,向量可以是任何东西。只要保证向量相加以及数字与向量相乘是有意义的即可。
总之,向量的加法和向量的数乘贯穿线性代数始终,二者起到很重要的作用。
## 二:线性变换
变换指的是输入变量,并输出结果的过程。任意一个变换可以非常复杂。其中线性变换就指的是一类特殊的变换。
在几何意义上,如果一个直线在变换后依然保持直线,不能有所弯曲,并且原点必须固定。那么这就是线性变换。
总的来说,就是保持网格平行且等距分布。部分线性变换很容易思考,比如关于原点的旋转,缩放,斜切。
接下来就是用数值去描述线性变换。提供一个向量,进行线性变换后,得到另一个向量。
实际结果是,只需要记录和 变换后的位置。其他向量都会随之而动。
## 三:矩阵和向量的相乘
线性变化由它对空间的基向量的作用完全决定,这是因为,其他任意向量,都能表示成基向量的线性组合。
在线性变化之后,网格线保持平行并等距分布这一性质有一个绝妙的推论:
只要记录基向量线性变换后的位置,就能知道任意向量变换后的坐标。就是向量 x 乘以 ,再加上向量 y 乘以
比如有一个向量就等于,然后进行了一个线性变换,使得,那么向量的变换就是
也就是把向量的乘法,转换为了向量的数乘和相加。
其中,我们把称之为,称之为
因此,可以扩展到 n 维向量和矩阵的乘法:
其中:
而矩阵与向量相乘,就是将线性变化作用于这个向量。
## 四:矩阵的乘法
很多时候,会想去描述这样一种作用:一个变换之后,在进行另一个变化。比如,想进行描述,先旋转,再斜切。总的来说,是一种复合的线性变换。
我们应该如何去描述这种复合变换呢?这就是矩阵的乘法:
需要注意的是,矩阵的乘法是从右向左的。我们可以理解为: 和 ,先进行了右边的矩阵变换,再进行了左边的矩阵变换。
和 先移动到了的位置,进行向量和矩阵的乘法。再是向量进行乘法,最后的结果就是矩阵的乘法。
我们可以扩展到 n 维的情况:
假设两个变换分别为:
矩阵乘法 的结果为:
其中:
## 五:仿射变换
线性变换从几何直观有三个要点:
- 变换前是直线的,变换后依然是直线
- 直线比例保持不变
- 变换前是原点的,变换后依然是原点
从数值上来说,线性变换是通过矩阵乘法来实现的。
仿射变换从几何直观只有两个要点:
- 变换前是直线的,变换后依然是直线
- 直线比例保持不变
少了原点保持不变这一条。比如平移,就是仿射变换而不是线性变换,因为原点移动了。
所以从数值上来说,仿射变换不仅需要矩阵的乘法,还需要加法。
对于以上公式,我们可以进行升维,从而将平移变成线性变换。
在增加一个维度后,我们可以在高维度通过线性变换来完成低纬度的仿射变换。
在几何上可以理解为,增加了一个轴,使得平移变成了在方向上的变换。从而保证了网格线保持平行并等距分布。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/bubble/
================================================================
# 冒泡排序(Bubble Sort)
冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
## 算法描述
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤 1~3,直到排序完成。
## 动画演示
## 代码演示
```ts
const bubble = (list: number[]): number[] => {
const size = list.length;
for (let i = 0; i < size - 1; i++) {
for (let j = 0; j < size - i - 1; j++) {
if (list[j] > list[j + 1]) {
list[j] = list[j] ^ list[j + 1];
list[j + 1] = list[j] ^ list[j + 1];
list[j] = list[j] ^ list[j + 1];
}
}
}
return list;
};
```
## 算法分析
冒泡排序是稳定的——严格的 `>` 比较不会交换相等的元素——并且原地排序,不需要输入数组之外的额外内存。时间复杂度在平均和最坏情况下都是 O(n²)。这份实现不管输入数组本身有多有序,每一轮都会跑完所有比较;如果加一个"某一轮没有发生交换就提前退出"的标志位,最好情况(数组已经有序)能降到 O(n),但这里没有做这个优化。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/bucket/
================================================================
# 桶排序 (Bucket Sort)
高效与否的关键在于这个分桶函数。将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
## 算法描述
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来。
## 代码演示
```ts
const count = (list: Array, max: number = 100): Array => {
const countList = new Array(max + 1);
for (let i = 0; i < list.length; i++) {
if (!countList[list[i]]) {
countList[list[i]] = 0;
}
countList[list[i]]++;
}
let startIndex = 0;
for (let i = 0; i < countList.length; i++) {
while (countList[i] > 0) {
list[startIndex++] = i;
countList[i]--;
}
}
return list;
};
const getMax = (list: Array) => {
let max = list[0];
for (let i = 0; i < list.length; i++) {
if (max < list[i]) {
max = list[i];
}
}
return max;
};
const getMin = (list: Array) => {
let min = list[0];
for (let i = 0; i < list.length; i++) {
if (min > list[i]) {
min = list[i];
}
}
return min;
};
/**
* @description: 桶排序
* @param {Array} list
* @return {Array}
*/
const bucket = (list: Array, bucketSize: number = 5, max?: number, min?: number): Array => {
if (list.length === 0) return list;
if (!max) max = getMax(list);
if (!min) min = getMin(list);
const bucketCount = Math.floor((max - min) / bucketSize) + 1;
const buckets = new Array(bucketCount + 1).fill(0).map(() => new Array(0));
for (let i = 0; i < list.length; i++) {
buckets[Math.floor((list[i] - min) / bucketSize)].push(list[i]);
}
list = [];
for (let i = 0; i < bucketCount; i++) {
list = list.concat(count(buckets[i]));
}
return list;
};
```
## 算法分析
桶排序最好情况下使用线性时间 O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为 O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/count/
================================================================
# 计数排序( Count Sort )
计数排序(counting sort)就是一种牺牲内存空间来换取低时间复杂度的排序算法,同时它也是一种不基于比较的算法。这里的不基于比较指的是数组元素之间不存在比较大小的排序算法,我们知道,用分治法来解决排序问题最快也只能使算法的时间复杂度接近 Θ(nlogn),即基于比较的时间复杂度存在下界 Ω(nlogn),而不基于比较的排序算法可以突破这一下界。
## 算法描述
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为 i 的元素出现的次数,存入数组 C 的第 i 项;
- 对所有的计数累加(从 C 中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素 i 放在新数组的第 C(i)项,每放一个元素就将 C(i)减去 1。
## 动画演示
## 代码演示
```ts
const getMax = (list: number[]) => {
let max = list[0];
for (let i = 1; i < list.length; i++) {
if (max < list[i]) {
max = list[i];
}
}
return max;
};
/**
* @description: 计数排序
* @param {Array} list
* @return {Array}
*/
const count = (list: number[]): number[] => {
if (list.length <= 1) return list;
const max = getMax(list);
const countList = new Array(max + 1).fill(0);
list.forEach((item) => {
if (!countList[item]) {
countList[item] = 1;
} else {
countList[item]++;
}
});
const result = [];
for (let i = 0; i < countList.length; i++) {
while (countList[i]) {
result.push(i);
countList[i]--;
}
}
return result;
};
```
## 算法分析
计数排序是一个稳定的排序算法。当输入的元素是 n 个 0 到 k 之间的整数时,时间复杂度是 O(n+k),空间复杂度也是 O(n+k),其排序速度快于任何比较排序算法。当 k 不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/heap/
================================================================
# 堆排序(Heap Sort)
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
## 算法描述
- 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素 R[1]与最后一个元素 R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足 R[1,2…n-1]<=R[n];
- 由于交换后新的堆顶 R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将 R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为 n-1,则整个排序过程完成。
- 升序用大根堆,降序用小根堆
## 动画演示
## 代码演示
```ts
class Heap {
value: Array;
size: number;
constructor(arr: Array = []) {
this.value = [...arr];
this.size = arr.length;
this.buildMaxHeap();
}
swap = (i: number, j: number) => {
if (this.value[i] === this.value[j]) return;
this.value[i] = this.value[i] ^ this.value[j];
this.value[j] = this.value[i] ^ this.value[j];
this.value[i] = this.value[i] ^ this.value[j];
};
heapHandler = (i: number) => {
const left = 2 * i + 1;
const right = 2 * i + 2;
let largest = i;
if (left < this.size && this.value[left] > this.value[largest]) {
largest = left;
}
if (right < this.size && this.value[right] > this.value[largest]) {
largest = right;
}
if (largest !== i) {
this.swap(i, largest);
this.heapHandler(largest);
}
};
buildMaxHeap = () => {
for (let i = this.size >> 1; i >= 0; i--) {
this.heapHandler(i);
}
for (let i = this.size - 1; i >= 0; i--) {
this.swap(0, i);
this.size--;
this.heapHandler(0);
}
};
}
/**
* @description: 堆排序
* @param {Array} list
* @return {Array}
*/
const heap = (list: Array): Array => {
const { value } = new Heap(list);
return value;
};
```
## 算法分析
堆排序不是稳定的——堆化过程中把堆顶和最后一个未排序元素交换,可能打乱相等元素的相对顺序——但它是原地排序,除了数组本身只需要 O(1) 的额外空间。和快速排序不同,它的时间复杂度在最好、平均、**以及最坏**情况下都是 O(n log n):构建堆和每次重新堆化都是每个元素 O(log n) 的代价,跟输入的初始顺序无关;和归并排序不同,它不需要任何 O(n) 的辅助空间就能拿到这个保证。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/
================================================================
# 十大经典排序
十种常见排序算法可以分为两大类:
- **比较类排序**:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。
- **非比较类排序**:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

## 算法复杂度

## 相关概念
- 稳定:如果 a 原本在 b 前面,而 a=b,排序之后 a 仍然在 b 的前面。
- 不稳定:如果 a 原本在 b 的前面,而 a=b,排序之后 a 可能会出现在 b 的后面。
- 时间复杂度:对排序数据的总的操作次数。反映当 n 变化时,操作次数呈现什么规律。
- 空间复杂度:是指算法在计算机内执行时所需存储空间的度量,它也是数据规模 n 的函数。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/insert/
================================================================
# 插入排序(Insert Sort)
表现稳定的排序算法,因为无论什么数据进去都是 O(n2) 的时间复杂度,所以用到它的时候,数据规模越小越好。优点是不占用额外的内存空间。工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
## 算法描述
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤 2~5。
## 动画演示
## 代码演示
```ts
const insert = (list: number[]): number[] => {
const size = list.length;
for (let i = 1; i < size; i++) {
const current = list[i];
let preIndex = i - 1;
while (preIndex >= 0 && list[preIndex] > current) {
list[preIndex + 1] = list[preIndex];
preIndex--;
}
list[preIndex + 1] = current;
}
return list;
};
```
## 算法分析
插入排序是稳定的——`while` 循环只会移动严格大于 `current` 的元素,相等的元素不会被换过顺序——并且是原地排序,只需要 O(1) 的额外空间。平均和最坏情况(逆序输入)下时间复杂度是 O(n²),但这份实现的 `while` 循环一旦 `list[preIndex]` 不再大于 `current` 就会立刻退出,所以输入已经有序(或接近有序)时几乎是 O(n),不需要额外加标志位——这一点跟本页冒泡排序的实现不同,冒泡排序无论输入是否有序都会跑完整个 O(n²)。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/merge/
================================================================
# 归并排序(Merge Sort)
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为 2-路归并。
## 算法描述
- 把长度为 n 的输入序列分成两个长度为 n/2 的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
## 动画演示
## 代码演示
```ts
const combine = (left: Array, right: Array) => {
const list: Array = [];
while (left.length > 0 && right.length > 0) {
if (left[0] <= right[0]) {
list.push(left.shift()!);
} else {
list.push(right.shift()!);
}
}
while (left.length) {
list.push(left.shift()!);
}
while (right.length) {
list.push(right.shift()!);
}
return list;
};
/**
* @description: 归并排序
* @param {Array} list
* @return {Array}
*/
const merge = (list: Array): Array => {
const { length } = list;
if (length <= 1) {
return list;
}
const middle = length >> 1;
const left = list.slice(0, middle);
const right = list.slice(middle);
return combine(merge(left), merge(right));
};
```
## 算法分析
归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn)的时间复杂度。代价是需要额外的内存空间。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/quick/
================================================================
# 快速排序(Quick Sort)
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
## 算法描述
快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
## 动画演示
## 代码演示
```ts
/**
* @description: 设置基准值pivot
* @param {Array} list
* @param {number} left
* @param {number} right
* @return {number} index
*/
const partition = (list: number[] = [], left: number, right: number) => {
const pivot = left;
let index = pivot + 1;
for (let i = index; i <= right; i++) {
if (list[i] < list[pivot]) {
if (list[i] !== list[index]) {
list[i] = list[i] ^ list[index];
list[index] = list[i] ^ list[index];
list[i] = list[i] ^ list[index];
}
index++;
}
}
if (list[index - 1] !== list[pivot]) {
list[index - 1] = list[index - 1] ^ list[pivot];
list[pivot] = list[index - 1] ^ list[pivot];
list[index - 1] = list[index - 1] ^ list[pivot];
}
return index - 1;
};
/**
* @description: 不断分区,设置基准值
* @param {Array} list
* @param {number} left
* @param {number} right
* @return {Array}
*/
const combine = (list: number[], left: number, right: number) => {
if (left < right) {
const partitionIndex: number = partition(list, left, right);
combine(list, partitionIndex + 1, right);
combine(list, left, partitionIndex - 1);
}
return list;
};
/**
* @description: 快速排序
* @param {Array} list
* @return {Array}
*/
const quick = (list: number[] = []): number[] => {
const size = list.length;
return combine(list, 0, size - 1);
};
```
## 算法分析
快速排序的平均时间复杂度是 O(n log n),但每次基准值都把数组切得极不均匀时,最坏情况会退化到 O(n²)。这份实现固定选最左边的元素做基准值(`partition` 里的 `pivot = left`),所以和随机选基准值的快速排序不同,一个已经有序或者逆序的输入就会直接触发这个最坏情况。快速排序不是稳定的;虽然是原地排序,但 `combine` 里的递归调用平均会用到 O(log n) 的栈空间,最坏情况下会增长到 O(n)。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/radix/
================================================================
# 基数排序(Radix Sort)
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。桶排序扩展,类似于指定桶排序按位数排序规则,同时能利用计数排序适用于小范围数的特点。
## 算法描述
- 取得数组中的最大数,并取得位数;
- arr 为原始数组,从最低位开始取每个位组成 radix 数组;
- 对 radix 进行计数排序(利用计数排序适用于小范围数的特点);
## 动画演示
## 代码演示
每个位数的桶内部用[计数排序](../count/index.md)来排序——小范围、密集的数值区间正是计数排序擅长的场景,这也是为什么基数排序的每位分桶天然要配计数排序,而不是通用的比较排序。
```ts
const getMax = (list: Array) => {
let max = list[0];
for (let i = 0; i < list.length; i++) {
if (max < list[i]) {
max = list[i];
}
}
return max;
};
/**
* @description: 计数排序——完整讲解见计数排序页面。
* @param {Array} list
* @return {Array}
*/
const count = (list: Array): Array => {
if (list.length <= 1) return list;
const max = getMax(list);
const countList = new Array(max + 1).fill(0);
list.forEach((item) => {
countList[item] = (countList[item] || 0) + 1;
});
const result: Array = [];
for (let i = 0; i < countList.length; i++) {
while (countList[i]) {
result.push(i);
countList[i]--;
}
}
return result;
};
const getDigit = (num: number) => {
let digit = 1;
while (num >= 1) {
digit++;
num = num / 10;
}
return digit;
};
/**
* @description: 基数排序
* @param {Array} list
* @return {Array}
*/
const radix = (list: Array, maxDigit?: number): Array => {
if (list.length === 0) return list;
if (!maxDigit) maxDigit = getDigit(getMax(list));
const buckets = new Array(maxDigit).fill(0).map(() => new Array(0));
for (let j = 0; j < list.length; j++) {
const digit = getDigit(list[j]);
buckets[digit - 1].push(list[j]);
}
list = [];
for (let i = 0; i < buckets.length; i++) {
list = list.concat(count(buckets[i]));
}
return list;
};
```
## 算法分析
基数排序基于分配排序,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要 O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要 O(n)的时间复杂度。假如待排数据可以分为 d 个关键字,则基数排序的时间复杂度将是 O(d\*2n) ,当然 d 要远远小于 n,因此基本上还是线性级别的。
基数排序的空间复杂度为 O(n+k),其中 k 为桶的数量。一般来说 n>>k,因此额外空间需要大概 n 个左右。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/select/
================================================================
# 选择排序(Selection Sort)
选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
## 算法描述
n 个记录的直接选择排序可经过 n-1 趟直接选择排序得到有序结果。具体算法描述如下:
- 初始状态:无序区为 R[1..n],有序区为空;
- 第 i 趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为 R[1..i-1]和 R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第 1 个记录 R 交换,使 R[1..i]和 R[i+1..n)分别变为记录个数增加 1 个的新有序区和记录个数减少 1 个的新无序区;
- n-1 趟结束,数组有序化了。
## 动画演示
## 代码实现
```js
const select = (list: number[]): number[] => {
const size = list.length;
for (let i = 0; i < size; i++) {
let minIndex = i;
for (let j = i + 1; j < size; j++) {
if (list[minIndex] >= list[j]) {
minIndex = j;
}
}
if (list[i] !== list[minIndex]) {
list[i] = list[i] ^ list[minIndex];
list[minIndex] = list[i] ^ list[minIndex];
list[i] = list[i] ^ list[minIndex];
}
}
return list;
};
```
## 算法分析
选择排序**不是稳定的**:把找到的最小值换到当前位置时,可能会跳过其他值相等的元素,改变它们的相对顺序(比如按值排序 `[3a, 3b, 1]`,`3a`/`3b` 值相等但可区分,排完后 `3b` 会跑到 `3a` 前面)。它是原地排序,只需要 O(1) 的额外空间,并且在最好、平均、**以及最坏**情况下都是 O(n²)——和插入排序不同,输入已经有序也不会变快,因为内层循环每次都要扫完剩下的整个无序区间才能找到最小值。它真正的优势是交换次数有上限:最多 n-1 次,远少于冒泡或插入排序最坏情况下的 O(n²) 次——在交换成本比比较成本高得多的场景下,这一点很重要。
================================================================
# https://ran.chaxus.com/cn/src/article/sort/shell/
================================================================
# 希尔排序(Shell Sort)
1959 年 Shell 发明,第一个突破 O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
## 算法描述
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列 t1,t2,…,tk,其中 ti>tj,tk=1;
- 按增量序列个数 k,对序列进行 k 趟排序;
- 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
## 动画演示
## 代码实现
```js
/**
* @description: 希尔排序,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
* @param {Array} list
* @return {Array}
*/
const shell = (list: number[]): number[] => {
const size = list.length;
for (let gap = size >> 1; gap > 0; gap >>= 1) {
for (let i = gap; i < size; i += gap) {
const current = list[i];
let preIndex = i - gap;
while (preIndex >= 0 && list[preIndex] > current) {
list[preIndex + gap] = list[preIndex];
preIndex -= gap;
}
list[preIndex + gap] = current;
}
}
return list;
};
```
## 算法分析
希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。动态定义间隔序列的算法是《算法(第 4 版)》的合著者 Robert Sedgewick 提出的。
================================================================
# https://ran.chaxus.com/cn/src/article/video
================================================================
---
title: '自适应码率 Web 视频加密播放'
description: '如何实现自适应码率、加密的 Web 视频播放——前后端挑战(HLS、DRM、密钥下发)与实战方案。'
---
实现自适应码率 Web 视频加密播放:前后端的挑战与解决方案
最近又遇到了`web`视频化的场景,之前也有过调研:[H5 视频化调研浅析](https://juejin.cn/post/7238739662822735933)
但这次稍微复杂一些,这次解决的是:
1. 视频播放的技术方案调研
服务端实现:
1. 视频转码
2. 生成不同码率的视频
3. 进行视频标准加密
4. 不同码率视频合并,用于动态码率播放
`web`端实现
1. `web`端播放器的设计
2. `web`端播放器的自定义扩展
3. 可拖拽进度条
4. 音量控制
5. 根据当前带宽自适应码率切换
6. 手动清晰度切换
7. 倍速播放
8. 样式自定义覆盖
9. 标准加密视频播放
10. 基于原生开发,可在所有框架运行,统一跨框架情况
11. 各浏览器控件统一
其中`web`端源码已添加`MIT`协议并完全开源,如果看完对大家有帮助的话,欢迎大家`star`,`issue`,`pr`,也希望能友好交流~
demo 地址:https://ran.chaxus.com/src/ranui/player/
源码地址:https://github.com/chaxus/ran
`demo`文档做了国际化,可切换到中文
任何一个项目,立项肯定先是技术调研,我们先看看一些大公司的视频播放方案
## 一:一些知名公司的 web 视频播放方案
### 1.B 站
我们先看看 B 站的,毕竟 B 站的主营业务就是视频弹幕网站,简直专业对口。
先找一个例子:https://www.bilibili.com/video/BV1FM411N7LJ 访问它。

打开控制台,可以看到,视频在播放的时候,会不断的请求`m4s`的视频文件。
毕竟一整个视频文件往往比较大,不可能先请求完视频文件,再进行播放。因此将一个大的视频文件分割成很多小的片段,边加载边播放,是一种更好的方式。

每次请求的`m4s`文件大概在几十`kb`到几百`kb`不等。

那为什么不采用`http`的`range`呢,可以请求一个文件的部分内容,而且粒度更细,可以设置字节范围。在`http`请求的`header`中,类似这样
```js
Range: bytes = 3171375 - 3203867;
```
我们可以检查这个链接请求`https://upos-sz-mirror08c.bilivideo.com/upgcxcode/67/92/1008149267/1008149267-1-30064.m4s`的请求头,就能发现,B 站采用的是,即分片加载,同时还用了`range`的方式。
### 2. 爱奇艺:(爱奇艺、土豆、优酷)
爱奇艺这里就不贴视频链接了,因为随便点一个视频,都要先看广告。

爱奇艺的视频主要请求的是`f4v`格式,也是分片加载。
播放一个视频时,请求多个`f4v`文件。
也采用`Range`。但和 B 站不一样的是,B 站的`Range`属性是在`m4s`请求的请求头里面,而爱奇艺的看起来是在`querystring`上,在请求`query`上带着`range`参数。
因为没发现这个请求的`header`里面有`range`参数。比如:
`https://v-6fce1712.71edge.com/videos/other/20231113/6b/bb/3f3fe83b89124248c3216156dfe2f4c3.f4v?dis_k=2ba39ee8c55c4d23781e3fb9f91fa7a46&dis_t=1701439831&dis_dz=CNC-BeiJing&dis_st=46&src=iqiyi.com&dis_hit=0&dis_tag=01010000&uuid=72713f52-6569e957-351&cross-domain=1&ssl=1&pv=0.1&cphc=arta&range=0-9000`
### 3.抖音:
抖音的方案简单粗暴,访问的链接是这个:
https://m.ixigua.com/douyin/share/video/7206914252840370721?aweme_type=107&schema_type=1&utm_source=copy&utm_campaign=client_share&utm_medium=android&app=aweme
通过查看控制台,我们可以发现,直接请求了一个视频的地址

没有进行分片,但用到了请求`range`,所以可以看到视频,是边播放边缓冲一部分。
不过我在开发的时候发现,目前租用的服务云厂商,默认会帮我们实现这项技术。
因为我把`mp4`视频上传到云服务器,通过链接进行播放的时候,就是边缓冲边播放的。
我们可以直接把这个视频地址拿出来,放到浏览器里面能直接播放,这样观察更明显。

但 B 站和爱奇艺却不能这样,因为他们采用的`m4s`和`f4v`都不是一种通用的视频格式,需要使用专门的软件或工具才能打开和编辑。
### 4.小红书:
测试用的例子链接:https://www.xiaohongshu.com/discovery/item/63b286d1000000001f00b495
小红书的方案更加简单粗暴,打开控制台,直接告诉你就是请求一个`mp4`,然后直接播放就完事了。

### 5.总结
看完了以上的各家大厂的方案,我们可以看到,基本原理都是边播放边加载,减少直接加载大视频文本的成本。并且通过分片传输,还能动态控制视频码率(清晰度)。做到根据网速,加载不同码率的分片文件,做到动态码率适应。
同时采用的视频格式,比如`f4v`,`m4s`,都不是能直接播放的媒体格式,需要一定的处理。增加盗取视频的成本,增加一定的安全性。
如果没有强要求,也可以直接采用`mp4`,或者直接用`video`播放一个视频文件地址。
## 二:常见的视频格式与协议
我们知道视频的常见格式有`mp4`,同时上面介绍了 B 站播放用的`m4s`格式,爱奇艺用的`f4v`格式
- 除了这些还有哪些视频格式?
- 为什么有这么多视频格式,有哪些不同点呢?
- 为什么这些公司会采用这种格式来播放视频呢?
### 1. B 站用的`m4s`
`M4S`格式不是一种通用的视频格式,需要使用专门的软件或工具才能打开和编辑。
`M4S` 通常会和 `MPEG-DASH` 流媒体技术一起,通过流式传输的视频的一小部分。播放器会按接收顺序播放这些片段。第一个 `M4S` 段会包含一些初始化的数据标识。
`MPEG-DASH` 是一种自适应比特率流媒体技术,通过将内容分解为一系列不同码率的`M4S`片段,然后根据当前网络带宽进行自动调整。如果想在在`web`音视频中采用`DASH`技术,可以看下
https://github.com/Dash-Industry-Forum/dash.js
### 2. 爱奇艺的`f4v`
`F4V`是一种流媒体格式,它是由`Adobe`公司推出的,继`FLV`格式之后支持`H.264`编码的流媒体格式。`F4V`格式的视频不是一种通用的视频格式,**但通常情况下**,都可以将文件后缀改为`FLV`,这样就可以使用支持`FLV`的播放器进行观看。
`FLV`格式跟常见的`MP4`格式比起来,结构更加简单,所以加载`metadata`(视频元数据,比如视频时长等信息) 会更快。具体结构我们可以在这里查到:https://en.wikipedia.org/wiki/Flash_Video#Flash_Video_Structure
比如,这是`FLV`文件的标准头,定义了从几个比特到几个比特之间,是什么含义。我们知道后,可以用`MediaSource`进行读取和转码。
| Field | Data Type | Default | Details |
| -------------- | ------------ | -------- | --------------------------------------------------- |
| Signature 签名 | byte[3] | "FLV" | 始终就是“FLV” |
| Version 版本 | uint8 | 1 | 只有 0x01 才有效 |
| Flags 标志 | uint8 位掩码 | 0x05 | 0x04 是音频,0x01 是视频(所以 0x05 是音频 + 视频) |
| Header Size | uint32_be | 9 | 用于跳过较新的扩展标头 |
而`MP4`格式会稍微复杂一些,具体标准在 [ISO/IEC 14496-12](https://www.iso.org/standard/83102.html) 大概有两百多页,这里放不下,对这方面有兴趣的可以自行查看。
然而这并不表示`MP4`更差,因为它是一种基础通用标准,所以定义上会留有很多空间,和各种情况,甚至允许在标准之内进行自行发挥和扩展。而`FLV`格式则更加固定,但优点也是更加简单。
对于`FLV`的视频播放,我们可以采用:https://github.com/bilibili/flv.js `flvjs`主要作用就是用`MediaSource`将`flv`转码成`mp4`从而喂给浏览器进行播放。
接下来是一些其他的视频格式,简单介绍一下:
### 3.`AVI`
文件名以`.avi`结尾,`AVI` 最初由 `Microsoft` 于 `1992` 年开发,是 `Windows` 的标准视频格式。`AVI` 文件使用较少的压缩来存储文件,并且比许多其他视频格式占用更多空间,这导致文件大小非常大,每分钟视频大约 `2-3 GB`。
无损文件不会随着时间的推移而降低质量,无论您打开或保存文件多少次。此外,这允许在不使用任何编解码器的情况下播放。参考资料:[Audio Video Interleave](https://en.wikipedia.org/wiki/Audio_Video_Interleave)
### 4.`MPEG`
文件名以“.mpg”或“.mpeg”结尾,MPEG 是由 ISO 和 IEC 联合成立的工作组联盟,旨在制定媒体编码标准,包括音频、视频、图形和基因组数据的压缩编码;以及各种应用程序的传输和文件格式。MPEG 格式用于各种多媒体系统。最广为人知的旧 MPEG 媒体格式通常使用 MPEG-1、MPEG-2 和 MPEG-4 AVC 媒体编码,MPEG-2 系统传输流和节目流。较新的系统通常使用 MPEG 基本媒体文件格式和动态流式处理(又名 .MPEG-DASH)。参考资料:[Moving Picture Experts Group](https://en.wikipedia.org/wiki/Moving_Picture_Experts_Group)
### 5.`MP4`
带有音频和视频的 MPEG-4 文件通常使用标准的 .mp4 扩展名。纯音频 MPEG-4 文件通常具有 .m4a 扩展名,原始 MPEG-4 可视比特流命名为 .m4v。Apple iPhone 使用 MPEG-4 音频作为其铃声,但使用.m4r 扩展名而不是.m4a 扩展名。参考资料:[MPEG-4 Part 14](https://en.wikipedia.org/wiki/MP4_file_format)
### 6.`QuickTime`
文件名以“.mov”结尾,QuickTime 能够包含媒体数据的抽象数据引用,并将媒体数据与媒体偏移和轨道编辑列表分离,这意味着 QuickTime 特别适合编辑,因为它能够就地导入和编辑(无需数据复制)。由于 QuickTime 和 MP4 容器格式都可以使用相同的 MPEG-4 格式,因此在仅限 QuickTime 的环境中,它们大多可以互换。MP4 作为国际标准,得到了更多的支持。参考资料:[QuickTime File Format](https://en.wikipedia.org/wiki/QuickTime_File_Format)
### 7.`TS`
TS 是 MPEG2-TS 的简称,是一种音视频封装格式。TS 流的后缀通常是.ts、.mpg 或者.mpeg,多数播放器直接支持这种格式的播放。TS 格式主要用于直播的码流结构,具有很好的容错能力。
## 三:浏览器对各种视频格式的兼容性
上面了解常用的视频格式,和适用范围之后,还需要看一下当前浏览器,对各种视频格式的支持程度,然后制定技术方案。
### 1. Chrome
支持的视频格式从官方文档可以查到,主要有以下这些
- MP4 (QuickTime/ MOV / ISO-BMFF / CMAF)
- Ogg
- WebM
- WAV
- HLS [Only on Android and only single-origin manifests]
官方文档如下:https://www.chromium.org/audio-video/
### 2. Safari
支持的视频格式有这些:

官方文档:https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
### 3.Firefox
支持的视频格式:

官方文档:https://support.mozilla.org/en-US/kb/html5-audio-and-video-firefox
## 四:MediaSource 和视频编码,解码,封装介绍
上面介绍了一些视频格式,和目前浏览器的一些兼容性问题。就能发现,在`web`上播放音视频其实限制还是很大的。如何解决这些限制,就会用到`MediaSource`。
视频其实是无数个图片的叠加,如果视频是一秒`60`帧,大约一秒中需要播放`60`张图片。这就导致一个几分钟的视频,就会非常大。比如上面介绍的无损格式,`avi`格式,每分钟视频大约 `2-3 GB`。这时候视频就需要进行`编码`。其实就是压缩。
编码分为视频编码和音频编码,常见的视频编码有:
- **MPEG 系列**:`MPEG-1`第二部分、`MPEG-2`第二部分(等同于`H.262`)、`MPEG-4`第二部分、`MPEG-4`第十部分(等同于`H.264`,有时候也被叫做“`MPEG-4` `AVC`”或“`H.264/AVC`”)。
- **H.26x 系列**:`H.261`、`H.262`、`H.263`、`H.264`(等同于`MPEG-4`第十部分)、`H.265/HEVC`(`ITU-T`和`ISO/IEC`联合推出)。
- **其它视频编码**:`WMV`系列、`RV`系列、`VC-1`、`DivX`、`XviD`、`X264`、`X265`、`VP8`、`VP9`、`Sorenson Video`、`AVS`。
常见的音频编码有:`AAC`,`MP3`,`AC-3`等
编码之后,还需要将音频和视频合并在一个文件里,这就是`封装`。
所以相对的,播放一个视频,就需要解封装,解码,音视频同步喂给声卡和显卡进行播放。
`MediaSource`做的就是这个工作,读取视频流,转换成浏览器能播放的格式。
以下是`flv.js`的`parseChunks`部分内容。读取`buffer`,一个字节一个字节的根据标准进行解析。然后转码。
```js
if (byteStart === 0) {
// buffer with FLV header
if (chunk.byteLength > 13) {
let probeData = FLVDemuxer.probe(chunk);
offset = probeData.dataOffset;
} else {
return 0;
}
}
if (this._firstParse) {
// handle PreviousTagSize0 before Tag1
this._firstParse = false;
if (byteStart + offset !== this._dataOffset) {
Log.w(this.TAG, 'First time parsing but chunk byteStart invalid!');
}
let v = new DataView(chunk, offset);
let prevTagSize0 = v.getUint32(0, !le);
if (prevTagSize0 !== 0) {
Log.w(this.TAG, 'PrevTagSize0 !== 0 !!!');
}
offset += 4;
}
```
### 1.MediaSource 兼容性

由此可见,基本都是绿色,但有一个特殊情况,就是`Safari on IOS`。这部分支持程度还是棕色。
## 五:HLS 播放方案
采用`HLS`技术方案,有以下几个原因:
1.兼容性
上面介绍了各种视频格式,还有浏览器的兼容性
其中 `HLS`协议是`Apple`公司实现的,在 `Apple` 的全系列产品包括 `iPhone`、`iPad`、`Safari` 等都可以原生支持播放 `HLS`。
对于其他浏览器,可以通过`MediaSource`解封装,解码,转码,进行播放。
这样也就解决了`MediaSource`的兼容性问题。
2. 业务场景需求
目前对于视频的加密有着强需求,比如需要用户付费才能观看一些视频。而`HLS`协议天然自带标准加密,同时也能基于`HLS`扩展私有加密。
3. `HLS`协议自带支持分片传输和动态码率自适应播放。
4. ~~有现成的技术方案,Hls.js~~
## 六:服务端开发
选择了采用`HLS`协议的播放方式,那么首先需要处理视频,这部分目前是在服务端进行处理。利用`ffmpeg`的能力。
~~如果以后能将`ffmpeg`搬上浏览器,且没有性能问题就好了。现在有类似的`webassembly`的`npm`包,但性能有点小问题~~
### 1.视频的转码
视频的转码的`ffmpeg`命令如下:
```sh
ffmpeg -i input.mp4 -hls_time 10 -hls_list_size 0 -c:v h264 -b:v 2M -hls_segment_filename output_%05d.ts output.m3u8 -y
```
每个参数的解释:
- `-i` 指定输入的视频
- `-hls_time` 指定分片的时间,单位是秒
- `-hls_list_size` 指定`hls`列表的数量,这里不限制
- `-c:v` 指定视频的编码格式
- `-b:v` 指定视频的码率,这里是`2M`比特率
- `-hls_segment_filename` 指定输出的`ts`文件名字,这里表示是`output_ + 五位数字`
- `output.m3u8`指定输出`m3u8`文件的名字
- `-y`有些场景,比如是否覆盖,直接选择是,避免程序卡住
为了自动化执行,这里会用到`node`的`spawn`模块,创建一个子进程,在子进程中执行`ffmpeg`的命令。
```ts
const exec = ({ params, data }: ExecOption): Promise => {
return new Promise((r, j) => {
const cp = spawn('ffmpeg', params);
cp.stderr.pipe(process.stdout);
cp.on('error', (err) => {
j(err);
});
cp.on('close', (code) => {
r({ code, data });
});
cp.on('exit', (code) => {
r({ code, data });
});
});
};
```
这时候,视频就会在指定的位置输出了,会生成一个`m3u8`和多个`ts`

`ts`是视频文件,`m3u8`更像是索引文件,用来描述`ts`,比如在什么时间,播放什么`ts`。主要内容如下:
```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.380622,
5_00000.ts
#EXTINF:10.380622,
5_00001.ts
#EXTINF:10.380622,
5_00002.ts
#EXTINF:10.380622,
5_00003.ts
#EXTINF:6.560556,
5_00004.ts
#EXTINF:1.619378,
5_00005.ts
#EXTINF:5.024189,
5_00006.ts
#EXT-X-ENDLIST
```
### 2.视频的标准加密
`HLS`协议标准加密采用的是`AES`对称加密方案。先来实现一个最标准的加密:
首先通过`node`原生模块`crypto`生成加密密钥:
```ts
import crypto from 'node:crypto';
// 生成加密密钥
const key = crypto.createHash('sha256').update(crypto.randomBytes(32)).digest('base64');
const filePathKey = path.join(__dirname, `../../public/uploads/hls/${dir}/${fileName}.key`);
const content = `${ctx.origin}/uploads/hls/${dir}/${fileName}.key\n${filePathKey}\n`;
// 密钥的文件
const fileKey = await writeFile(path.join(__dirname, `../../public/uploads/hls/${dir}/${fileName}.key`), key);
const keyInfoPath = path.join(__dirname, `../../public/uploads/hls/${dir}/${fileName}_key.bin`);
// ffmpeg 需要的 key.info
const keyInfo = await writeFile(keyInfoPath, content);
```
然后再执行`ffmpeg`命令,这里同样需要用`node`的`spawn`模块进行封装成接口:
```sh
ffmpeg -i input.mp4 -hls_time 10 -hls_list_size 0 -c:v h264 -b:v 2M -hls_key_info_file keyInfoPath -hls_segment_filename output_%05d.ts output.m3u8 -y
```
主要就增加了一个`hls_key_info_file`参数,表示加密密钥的地址。这时候,生成的`m3u8`文件就发生了变化,多了一行:
```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="http://localhost:30103/uploads/hls/5_1701577743851/5.key",IV=0x00000000000000000000000000000000
#EXTINF:10.380622,
5_00000.ts
#EXTINF:10.380622,
5_00001.ts
#EXTINF:10.380622,
5_00002.ts
#EXTINF:10.380622,
5_00003.ts
#EXTINF:6.560556,
5_00004.ts
#EXTINF:1.619378,
5_00005.ts
#EXTINF:5.024189,
5_00006.ts
#EXT-X-ENDLIST
```
多了
```
#EXT-X-KEY:METHOD=AES-128,URI="http://localhost:30103/uploads/hls/5_1701577743851/5.key",IV=0x00000000000000000000000000000000
```
- `METHOD`字段表示加密方式,这里是`AES`
- `URI`表示密钥地址,这里是`http://localhost:30103/uploads/hls/5_1701577743851/5.key`,
- `IV`是加密解密时的偏移量,现在是 0
上述加密方式,虽然视频确实加密了,但会把密钥地址写在`m3u8`里。等于把房间上锁,然后在锁上贴一个纸条,上面写了密码。
### 3.更好的安全方案
- 有加密,必然需要解密
首先我们知道,视频要在`web`端进行播放,那么无论如何,都肯定需要先解密,再播放。
- 肯定不能在`web`端放置密钥
- `web`端需要知道如何获取密钥
- 密钥用一次即失效,每次加密视频都生成新的密钥
目前更好的安全性方式主要有两种:
1. 在请求密钥的地址上进行加固:
- 校验`cookie`,既然是发起请求,那么同域名会自动携带`cookie`,只有购买过的用户才能获取密钥。(总不能让付费的用户也不能看吧)
- 生成密钥链接时,带上`ticket`,短时间失效,控制时效性
- 请求头携带`auth`,进行用户校验。比如`jwt`方案就是如此
1. 采用私有加密方式,比如`m3u8`里的`METHOD`,可能不再是`AES`这种对称加密。自定义一套加密规则,这种方式安全性会极大提高,但同时就不遵守`HLS`协议的标准了。但大多数浏览器支持`MediaSource`。可以读取文件内容,进行自定义加密和解密。根据上文的兼容性调研,`MediaSource`在`IOS`上将会有兼容性问题,所以这种方案在`IOS`上也会有兼容性问题。
### 4.自适应码率播放
这里先介绍一下码率和清晰度的关系:
码率是指:
> 码率(也称为比特率)是指视频文件在单位时间内使用的数据流量。它反映了视频文件的数据压缩程度,码率越高,压缩比就越小,画面质量就越高,但文件体积也越大。通俗来说,码率可以看作是取样率,是视频编码中画面质量控制中最重要的部分。计算公式是文件体积=时间 X 码率/8。
所以简单来说,码率越高,清晰度就越好。成正比关系。
需要根据视频的质量,和业务场景,去定义,这里给出阿里云对码率和清晰度的定义,可供参考:

为了实现自适应码率播放,我们需要将不同码率的`m3u8`合并成一个多码率的`m3u8`。
这里我没找到合适的`ffmpeg`命令,但总有办法的,最万能的方法就是,查看`HLS`协议中多码率的`m3u8`格式标准,自己写一个。
目前用`node`去写一个这样的索引文件,具体内容如下:
```
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720"
hls/5_1701577771368/5.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=50000,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=320x184,NAME="320"
hls/5_1701577744714/5.m3u8
```
- `#EXT-X-STREAM-INF`: 流媒体的描述
- `PROGRAM-ID`: 表示唯一的`ID`。
- `BANDWIDTH`: 流媒体的带宽,即每秒传输的数据量。这里带宽为`50000`,意味着每秒传输的数据量大约为`50kbps`。
- `CODECS`: 流媒体使用的编解码器。这里是使用了`mp4a.40.5`(`AAC`音频编码)和`avc1.42000d`(`AVC`视频编码)。
- `RESOLUTION`: 这个字段指示了视频的分辨率,即宽度和高度。在这个例子中,视频分辨率为`320x184`。
- `NAME`: 这个字段为流媒体提供了一个名称,本例中名称为"`320`"。我会习惯把清晰度放在`NAME`这个字段这里,方便`web`端获取
实现一个接口,传入以上的参数,动态拼接字符串,写入文件
```ts
async generateMasterPlayList(ctx: Context): Promise {
try {
const { paths, filename = Date.now() } = ctx.request.body;
let content = `#EXTM3U\n`;
paths.forEach((item: MasterPlayListOption, index: number) => {
const { id = index, bandWidth, codecs, resolution, name, url } = item;
content += `#EXT-X-STREAM-INF:PROGRAM-ID=${id},BANDWIDTH=${bandWidth},CODECS="${codecs}",RESOLUTION=${resolution},NAME="${name}"\n${url}\n`;
});
const dir = path.join(__dirname, `../../public/uploads/hls/`);
if (!existsSync(dir)) {
await createDir(dir);
}
const filePath = dir + (filename.toString().endsWith('.m3u8') ? filename : `${filename}.m3u8`)
const { success, error } = await writeFile(filePath, content);
const basename = path.basename(filePath);
return success
? ctx.successHandler({
url: `${ctx.origin}/uploads/hls/${basename}`,
})
: ctx.failHandler(error);
} catch (error) {
ctx.errorHandler(error);
}
}
```
那么`HLS`是如何自适应码率的呢?
其实是根据`BANDWIDTH`这个字段,因为我们给不同的视频设置了不同的`BANDWIDTH`。那么就可以根据当前的网速,进行动态切换。
## 七:web 端的实现
上面做了大量的工作,主要是生成了`HLS`协议的视频播放的地址。接下来就是如何在`web`端进行播放。
### 1.技术选型
我首先是看了现有的播放器`npm`,比较知名的
- 有西瓜`playler`: https://github.com/bytedance/xgplayer
- 阿里云点播方案:https://help.aliyun.com/zh/vod/developer-reference/overview-14
- 知乎的`player`:https://github.com/zhihu/griffith
其中知乎和西瓜的播放器是开源的,阿里云点播方案没有开源代码,但是有开源`demo`。

但基本上实现的功能都很丰富,同时配置项会不断的增加。
如果只是简单的初始化一个播放器,那还好。但一般这种场景,我们都会对播放器进行一定程度的定制化。比如 B 站。

就多了很多自定义的控件。进度条也是小电视的形状。
我们这边也是如此,有自己的主题色,有自己的播放按钮等等,还有一些业务功能,也要放在控制条上。
上述的开源方案,都需要花时间去研究配置项,而且使用方法都是`new Player(options)`的形式。
但尽量视图的归视图,逻辑的归逻辑会更好些。
~~更何况实现一个播放器也不是很困难。~~
期望的`player`,能满足
1. 配置够简单,最好看到就知道是怎么用的
2. 方便扩展和样式覆盖
3. 支持`hls`播放
4. 尽量适配前端的各种框架
5. 方便接入,实现价值
### 2.播放器设计
由于现在既有`react`项目,也有`vue`项目,甚至还有一些老的`jquery`项目。为了做到一次开发,任何项目都可以使用和接入。采用了`web components`技术方案。
`web components`就不做介绍了,具体可以去看这篇文章:[手写 web components 组件](https://juejin.cn/post/7170219296226803725)简单来说就是可以自定义元素,让我们像使用`div`一样使用自定义元素。
播放器我们需要考虑的点有:
1. `video`生命周期:`loadedmeta`,`canplay`, `ended`, `error`等
2. `video`状态:播放,正在播放,暂停,静音等
3. `video`属性:总时长,当前时长,音量大小,倍速等
4. `video`交互:暂停,播放,知识点,清晰度,倍速,全屏,进度控制,音量控制等
#### (1).video 的生命周期
对于 video 的生命周期,我们期望做到两件事情:
1. 我们能知道当前`video`处于什么生命周期
2. 在不同的生命周期,能挂载自定义事件。比如视频触发了`ended`。我们需要在`ended`时期跳转下一个视频
因此,我们需要监听`video`的生命周期:
```ts
listenEvent = () => {
if (!this._video) return;
this.clearListenerEvent();
this._video.addEventListener('canplay', this.onCanplay);
this._video.addEventListener('canplaythrough', this.onCanplaythrough);
this._video.addEventListener('complete', this.onComplete);
this._video.addEventListener('durationchange', this.onDurationchange);
this._video.addEventListener('emptied', this.onEmptied);
this._video.addEventListener('ended', this.onEnded);
this._video.addEventListener('error', this.onError);
this._video.addEventListener('loadeddata', this.onLoadeddata);
this._video.addEventListener('loadedmetadata', this.onLoadedmetadata);
this._video.addEventListener('loadstart', this.onLoadstart);
this._video.addEventListener('pause', this.onPause);
this._video.addEventListener('play', this.onPlay);
this._video.addEventListener('playing', this.onPlaying);
this._video.addEventListener('progress', this.onProgress);
this._video.addEventListener('ratechange', this.onRatechange);
this._video.addEventListener('seeked', this.onSeeked);
this._video.addEventListener('seeking', this.onSeeking);
this._video.addEventListener('stalled', this.onStalled);
this._video.addEventListener('suspend', this.onSuspend);
this._video.addEventListener('timeupdate', this.onTimeupdate);
this._video.addEventListener('volumechange', this.onVolumechange);
this._video.addEventListener('waiting', this.onWaiting);
};
```
在触发不同的时期时,让开发者知道。所以我们要先自定义事件,进行触发
```ts
const change = (name: string, value: unknown): void => {
const currentTime = this.getCurrentTime();
const duration = this.getTotalTime();
this.dispatchEvent(
new CustomEvent('change', {
detail: {
type: name,
data: value,
currentTime,
duration,
tag: this, // 整个 player 的实例
},
}),
);
};
const onCanplaythrough = (e: Event) => {
this.ctx.currentState = e.type;
this.change('canplaythrough', e);
};
```
这样就可以,当生命周期事件触发后,就会触发`onchange`。
在使用上,我们可以:
```jsx
const change = (e:CustomEvent) => {
const { type, data, currentTime, duration, tag } = e.detail
if(type === 'ended'){
console.log('video ended')
}
}
```
其中`type`的类型有:
| 名称 | 说明 |
| -------------- | ----------------------------------------------------------------------------------------------------------------- |
| canplay | 浏览器可以播放媒体文件了,但估计没有足够的数据来支撑播放到结束,不必停下来进一步缓冲内容。 |
| canplaythrough | 浏览器估计它可以在不停止内容缓冲的情况下播放媒体直到结束。 |
| complete | OfflineAudioContext 渲染完成。 |
| durationchange | duration 属性的值改变时触发。 |
| emptied | 媒体内容变为空;例如,当这个 media 已经加载完成(或者部分加载完成),则发送此事件,并调用 load() 方法重新加载它。 |
| ended | 视频停止播放,因为 media 已经到达结束点。 |
| loadedmetadata | 已加载元数据。 |
| progress | 在浏览器加载资源时周期性触发。 |
| ratechange | 播放速率发生变化。 |
| seeked | 跳帧(seek)操作完成。 |
| seeking | 跳帧(seek)操作开始。 |
| stalled | 用户代理(user agent)正在尝试获取媒体数据,但数据意外未出现。 |
| suspend | 媒体数据加载已暂停。 |
| loadeddata | media 中的首帧已经完成加载。 |
| timeupdate | currentTime 属性指定的时间发生变化。 |
| volumechange | 音量发生变化。 |
| waiting | 由于暂时缺少数据,播放已停止。 |
| play | 播放已开始。 |
| playing | 由于缺乏数据而暂停或延迟后,播放准备开始。 |
| pause | 播放已暂停。 |
| volume | 音量发生变化。 |
| fullscreen | 触发全屏事件 |
在不同的生命周期,能挂载事情,因此我们需要一个发布订阅类。
```ts
type Callback = (...args: unknown[]) => unknown;
type EventName = string | symbol;
type EventItem = {
name?: string | symbol;
callback: Callback;
initialCallback?: Callback;
};
const NEW_LISTENER = 'NEW_LISTENER';
export class SyncHook {
private _events: Record>;
constructor() {
this._events = {};
}
on = (eventName: EventName, eventItem: EventItem | Callback): void => {
if (this._events[eventName] && eventName !== Symbol.for(NEW_LISTENER)) {
this.emit(Symbol.for(NEW_LISTENER), eventName);
}
const callbacks = this._events[eventName] || [];
if (typeof eventItem === 'function') {
callbacks.push({
name: eventName,
callback: eventItem,
});
} else {
callbacks.push(eventItem);
}
this._events[eventName] = callbacks;
};
emit = (eventName: EventName, ...args: Array): void => {
const callbacks = this._events[eventName] || [];
callbacks.forEach((item) => {
const { callback } = item;
callback(...args);
});
};
once = (eventName: EventName, eventItem: EventItem | Callback): void => {
let one: EventItem;
if (typeof eventItem === 'function') {
one = {
name: eventName,
callback: (...args: Array) => {
eventItem(...args);
this.off(eventName, one);
},
initialCallback: eventItem,
};
} else {
const { callback } = eventItem;
one = {
name: eventName,
callback: (...args: Array) => {
callback(...args);
this.off(eventName, one);
},
initialCallback: callback,
};
}
this.on(eventName, one);
};
off = (eventName: EventName, eventItem: EventItem | Callback): void => {
const callbacks = this._events[eventName] || [];
const newCallbacks = callbacks.filter((item) => {
if (typeof eventItem === 'function') {
return item.callback !== eventItem && item.initialCallback !== eventItem;
} else {
const { callback } = eventItem;
return item.callback !== callback && item.initialCallback !== callback;
}
});
this._events[eventName] = newCallbacks;
};
}
```
因此会给`player`元素上增加一个`ctx`属性,作为全局的上下文。
```ts
this.ctx = {
currentTime: 0, // 当前时间
duration: 0, // 总时长
currentState: '', // 当前视频状态
action: new SyncHook(), // 不同时期触发的状态
};
```
我们想订阅视频的结束事件,我们可以
通过`Ref`的方式:
```
const endedEvent = () => {
console.log('video ended')
}
PlayerRef.current.ctx.action.off('ended',endedEvent)
PlayerRef.current.ctx.action.on('ended',endedEvent)
```
通过`change`方法获取的实例:
```jsx
let player
const endedEvent = () => {
console.log('video ended')
}
const change = (e:CustomEvent) => {
const { type, data, currentTime, duration, tag } = e.detail
player = tag
}
player.action.off('ended',endedEvent)
player.action.on('ended',endedEvent)
```
#### (2).video 的状态和属性
需要在全局上下文中记录下播放器的状态和属性:
```ts
this.ctx = {
currentTime: 0, // 当前时间
duration: 0, // 总时长
currentState: '', // 当前视频状态
action: new SyncHook(), // 不同时期触发的状态
volume: 0.5, // 当前音量
playbackRate: 1, // 当前倍速
clarity: '', // 当前清晰度
fullScreen: false, // 是否全屏
levels: [], // 清晰度列表
url: '', // 当前播放的地址
levelMap: new Map(), // 清晰度和名字的映射关系
};
```
#### (3).自定义 video
默认长这个样子

demo 地址:https://ran.chaxus.com/src/ranui/player/
源码地址:https://github.com/chaxus/ran
如果不喜欢控制器或者按钮,直接样式覆盖,更符合直觉,没有学习成本。
```css
.ran-player-controller {
display: none;
}
```
由于播放器本身就是一个元素,那么可以任意的在里面添加元素,添加逻辑。
```jsx
111111
```
所以,这就解决配置项,长达好几页的问题,同时看到也就知道怎么配置,怎么开发了。
## 八:总结
目前已经从前后端的角度,实现了
1. 视频的标准加密
2. 视频的动态码率播放
3. 视频的分片加载
4. 可拖拽进度条
5. 音量控制
6. 手动清晰度切换
7. 倍速播放
8. 样式自定义覆盖
9. 基于原生开发,可在所有框架运行,统一跨框架情况,各浏览器控件统一
这是`demo`和源码地址:
`demo`和文档地址:https://ran.chaxus.com/src/ranui/player/
源码地址:https://github.com/chaxus/ran
`demo`文档做了国际化,可切换到中文
================================================================
# https://ran.chaxus.com/cn/src/article/visual
================================================================
---
description: '设计 2D 可视化渲染引擎:用例、约束与边界情况,覆盖 Canvas、WebGL 与 WebGPU。'
---
# 可视化渲染引擎
## 零:系统设计
在系统设计的过程中,需要明确**使用场景**,**约束条件**,**边界情况**。描述出最主要实现的功能,将这些功能进行高层级的设计,分类,链接。
有了具体要实现的功能模块后,我们就再根据功能模块,深入细节,讨论具体的实现。
功能实现后,通过可扩展的设计原则,将这些重要的功能进行链接。
最后是一些业务场景的具体实现。
因此,对于可视化绘制引擎,需要考虑以下几个方面:
1. **组的管理:** 将多个图形元素组织成一个整体(即“组”)。这样的设计使得对组进行整体移动、缩放或变形时,组内所有元素都能响应,简化了复杂场景下的操作和管理。
2. **基础图形封装:** 构建丰富的基础图形类库,提供便捷的 `API` 来绘制常见的几何形状,如矩形、圆形、多边形、曲线等。这些基础图形应支持自定义样式和属性,以满足多样化的设计需求。
3. **层级管理:** 对于 `2D` 图形来说,必然需要层级关系的处理,定义元素之间的堆叠顺序,如确保文字总是绘制在图表的上方。层级管理将确保视觉呈现符合预期。
4. **变换矩阵:** 对一个图形组执行平移、旋转、缩放等变形操作,从而以动态和灵活的方式调整图形的展示效果。为了实现这些变形操作,变形操作类通常会采用矩阵变换的原理。通过维护一个变换矩阵,并在绘制图形组之前应用该矩阵,可以一次性完成所有变形操作的计算,提高绘制效率。
5. **事件系统:** 允许用户将事件监听器绑定到单个图形元素或整个组上。实现用户交互(如点击、拖动)。
6. **扩展设计:** 明确整个渲染过程的生命周期,并且允许开发者在对应的生命周期中插入自定义的代码,从而实现对渲染流程,事件处理,资源控制等方面的控制。
7. **应用层封装:** 实现条形图,折线图,饼图,桑基图等应用层图形,满足业务和产品的多样化需求。
首先我们实现第一个,组的管理:
## 一:组的管理:
为了进行图形组的管理,会继续实现一个容器类 `Container`,这个类代表了‘组’的概念,它提供了添加子元素,移除子元素等的方法;后续要被渲染的一些类 (如 `Graphics`,`Text`,`Sprite` 等) 都会继承于这个类;这个类本身不会被渲染 (因为它只是一个‘组’,它本身没有内容可以渲染)。
属性:
- `children`: 表示所有的子元素
- `isSort`: 添加或者删除元素后,需要表示,当前组需要更新
- `parent`: 表示当前组的父节点
方法:
- `addChild`: 添加子元素
- `removeChild`: 移除子元素
因此,实现如下:
```ts
class Container {
public readonly children: Container[] = [];
public isSort: boolean = false;
public parent: Container | undefined = undefined;
addChild = (child: Container) => {
child.parent?.removeChild(child);
this.children.push(child);
this.isSort = true;
child.parent = this;
};
removeChild = (child: Container) => {
for (let i = 0; i < this.children.length; i++) {
if (this.children[i] === child) {
this.children.splice(i, 1);
child.parent = undefined;
return;
}
}
};
}
```
有了`组`的概念后,就很自然思考:最初的`组`是什么?要怎么去创建?我们需要一个`入口`。
我们会实现`Application`类,它表示整个可视化引擎。初始化的时候,会自动创建一个根组,作为一切待渲染元素的祖先元素。
```ts
export class Application {
public readonly stage: Container; // stage 是一切待渲染元素的祖先元素。
public readonly view: HTMLCanvasElement; // canvas 元素
constructor(options: IApplicationOptions) {
const { view = document.createElement('canvas') } = options;
this.view = view;
// 创建一个根容器
this.stage = new Container();
}
}
```
有了根组后,就可以从根开始,进行遍历,继而渲染所有图形。因此我们需要实现一个`render`方法。
在这里我们需要思考下渲染的模式:
- 在传统 `web` 开发的过程中,我们需要去维护 `UI` 的状态。根据状态变量的改变,添加回调监听,从而去更新 `UI`。这就是 [Model–view–viewmodel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel)。
- 另一种渲染模式则更常见于游戏领域。`UI` 会在每一帧都进行重新渲染,不保存状态和数据,就没有 `UI diff`,双向绑定,回调更新等过程。这就是[Immediate Mode GUI ]()。
我们会按照这两种渲染模式,实现一个`start`方法和`render`方法,。
在渲染前,还需要考虑扩展性,虽然目前只用到`canvas`进行渲染,但要很显然要保留`webGL`,`webGPU`渲染能力的扩展接口。
因此设计一个`getRenderer`方法,用来抉择。根据传入的`prefer`,指定渲染方式。根据接口分离原则,每一种渲染方法都是一个独立的类。
```ts
export const getRenderer = (options: IApplicationOptions): Renderer => {
const { prefer: renderType } = options;
switch (renderType) {
case RENDERER_TYPE.CANVAS:
return new CanvasRenderer(options);
case RENDERER_TYPE.WEB_GL:
return new WebGLRenderer(options);
case RENDERER_TYPE.WEB_GPU:
return new WebGPURenderer(options);
default:
return new CanvasRenderer(options);
}
};
```
`CanvasRenderer`实现就非常简单,只有一个`render`方法,从根`container`开始,递归渲染所有的元素:
```ts
export class CanvasRenderer extends Renderer {
public ctx: CanvasRenderingContext2D;
constructor(options: IApplicationOptions) {
super(options);
console.log('正在使用 %c canvas2D ', 'color: #05aa6d; background-color: #ffffff;font-size: 20px;', '渲染');
this.ctx = this.canvasEle.getContext('2d')!;
}
public render(container: Container): void {
this.ctx.save();
this.ctx.clearRect(0, 0, this.screen.width, this.screen.height);
this.ctx.fillRect(0, 0, this.screen.width, this.screen.height);
container.renderCanvasRecursive(this);
this.ctx.restore();
}
}
```
最后是`start`方法和`render`方法的实现:
```ts
export class Application {
private readonly renderer: Renderer;
private animationFrameId: number | undefined;
public readonly stage: Container; // stage 是一切待渲染元素的祖先元素。
public readonly view: HTMLCanvasElement;
constructor(options: IApplicationOptions) {
const { view = document.createElement('canvas') } = options;
this.view = view;
// 根据参数,判断是用什么渲染模式
this.renderer = getRenderer({ ...options, view });
// 创建一个根容器
this.stage = new Container();
}
public render(): void {
this.renderer.render(this.stage);
}
// 立即渲染模式
public start(): void {
const func = () => {
this.render();
this.animationFrameId = requestAnimationFrame(func);
};
func();
}
public stop(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = undefined;
}
}
}
```
有了组的管理和`application`类,便可以在此之上进行基础图形的实现。后续的基础图形也会继承`Container`类,方便进行基础图形的组合和嵌套。
下一章实现后,就可以实例化`application`,然后在页面上`render`各种图形了。
## 二:基础图形的封装
在多数二维绘图业务场景中,复杂图形往往可以简化为基础图形的组合。核心的基础元素包括圆形、多边形以及贝塞尔曲线,它们是实现图形构建的基本单位。
此外,还有一些常用的基础图形,如矩形、圆角矩形和椭圆。我们将这些统称为“基础图形库”,通过它们的灵活组合,能够轻松构建出满足各种需求的二维场景。
首先我们定一个`Graphics`类,继承自 `Container` 类,表示绘制各种图形的容器。
```ts
class Graphics extends Container {}
```
绘制的过程中,我们需要考虑是**填充**还是**描边**图形。因此,需要定义两个属性:
`lineStyle`和`fillStyle`,用来表示 `line` 的属性,和 `fill` 的属性。
因此我们将`Graphics`类继续细分:
- 一个是:`GraphicsData`,用于保存基础图形的数据和是描边的还是填充的。
- 另一个是:`GraphicsGeometry`,表示通用的基础图形,用于一些通用的基础图形操作,比如保存图形数据
`GraphicsData`类的实现如下:
```ts
export class GraphicsData {
public shape: Shape;
public lineStyle: Line;
public fillStyle: Fill;
constructor(shape: Shape, fillStyle: Fill, lineStyle: Line) {
this.shape = shape;
this.lineStyle = lineStyle;
this.fillStyle = fillStyle;
}
}
```
其中`lineStyle`和`fillStyle`也通过实现各自的类进行实例话而成:
`line` 的属性有:`color`,`alpha`,`visible`,`width`,[cap](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineCap),[join](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin),[miterLimit](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/miterLimit)
`fill`的属性有:`color`,`alpha`,`visible`
我们可以用两个类去描述这些数据,其中的`Fill` 类是:
```ts
class Fill {
public color = '#ffffff';
public alpha = 1.0;
public visible = false;
constructor() {
this.reset();
}
public clone(): Fill {
const obj = new Fill();
obj.color = this.color;
obj.alpha = this.alpha;
obj.visible = this.visible;
return obj;
}
public reset(): void {
this.color = '#ffffff';
this.alpha = 1;
this.visible = false;
}
}
```
`Line` 继承 `Fill` 类:
```ts
class Line extends Fill {
public width = 0;
public cap = LINE_CAP.BUTT;
public join = LINE_JOIN.MITER;
public miterLimit = 10;
public clone(): Line {
const obj = new Line();
obj.color = this.color;
obj.alpha = this.alpha;
obj.visible = this.visible;
obj.width = this.width;
obj.cap = this.cap;
obj.join = this.join;
obj.miterLimit = this.miterLimit;
return obj;
}
public reset(): void {
super.reset();
this.color = '#ffffff';
this.width = 0;
this.cap = LINE_CAP.BUTT;
this.join = LINE_JOIN.MITER;
this.miterLimit = 10;
}
}
```
`GraphicsGeometry`类上会提供一个`drawShape`方法,用于添加通过`GraphicsData`实例化的图形数据。
```ts
public drawShape(shape: Shape, fillStyle: Fill, lineStyle: Line): void {
const data = new GraphicsData(shape, fillStyle, lineStyle);
this.graphicsData.push(data);
}
```
因此,`Graphics`类的属性如下:
```ts
class Graphics extends Container {
private _lineStyle = new Line();
private _fillStyle = new Fill();
constructor() {
super();
this.type = GRAPHICS;
}
}
```
还需要增加一些画线的方法和填充的方法:
```ts
class Graphics extends Container {
private _lineStyle = new Line();
private _fillStyle = new Fill();
constructor() {
super();
this.type = GRAPHICS;
}
public lineStyle(width: number, color?: string, alpha?: number): Graphics;
public lineStyle(options: ILineStyleOptions): Graphics;
public lineStyle(options: ILineStyleOptions | number, color: string = '#000000', alpha: number = 1): Graphics {
this.startPoly();
if (typeof options === 'object') {
Object.assign(this._lineStyle, options);
} else {
const opts: ILineStyleOptions = { width: options, color, alpha };
Object.assign(this._lineStyle, opts);
}
this._lineStyle.visible = true;
return this;
}
// 如果要填充图形,则需要先调用这个函数给画笔设置填充色
public beginFill(color = '#000000', alpha = 1): Graphics {
this._fillStyle.color = color;
this._fillStyle.alpha = alpha;
if (this._fillStyle.alpha > 0) {
this._fillStyle.visible = true;
}
return this;
}
/**
* 结束填充模式
*/
public endFill = (): Graphics => {
this.startPoly();
this._fillStyle.reset();
return this;
};
}
```
表示该图形是填充还是描边的。
接下来是绘制各种基础图形了,我们会将图形的绘制数据和绘制方法进行分离
1. 生成绘制图形的数据:`state`
2. 执行绘制图形的方法:`action`
`Graphics` 类作为绘制各种图形的容器,会接收`state`和`action`,最后通过`render`方法绘制到页面上。
### 1.圆
首先在 `Graphics` 类上实现绘制方法:
```ts
/**
* 画圆
* @param x 圆心 X 坐标
* @param y 圆心 Y 坐标
* @param radius 半径
*/
public drawCircle = (x: number, y: number, radius: number): Graphics => {
return this.drawShape(new Circle(x, y, radius));
};
```
实现绘制一个圆锁需要的数据,由圆的数据公式可知,绘制一个圆只需要知道圆点,半径即可:
因此实现如下:
```ts
export class Circle extends Shape {
public x: number;
public y: number;
public radius: number;
public readonly type = SHAPE_TYPE.CIRCLE;
constructor(x = 0, y = 0, radius = 0) {
super();
this.x = x;
this.y = y;
this.radius = radius;
}
}
```
最后是绘制的方法:
```ts
const circle = shape;
const { x, y, radius } = circle;
ctx.arc(x, y, radius, 0, 2 * Math.PI);
if (fillStyle.visible) {
ctx.fill();
}
if (lineStyle.visible) {
ctx.stroke();
}
```
### 2.矩形
矩形数据的实现是:
```ts
export class Rectangle extends Shape {
public x: number;
public y: number;
public width: number;
public height: number;
public type = SHAPE_TYPE.RECTANGLE;
constructor(x = 0, y = 0, width = 0, height = 0) {
super();
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
}
```
绘制方法:
```ts
const rectangle = shape;
const { x, y, width, height } = rectangle;
if (fillStyle.visible) {
ctx.fillRect(x, y, width, height);
}
if (lineStyle.visible) {
ctx.strokeRect(x, y, width, height);
}
```
在`Graphics`类上,添加数据的方法:
```ts
/**
* 画矩形
* @param x x 坐标
* @param y y 坐标
* @param width 宽度
* @param height 高度
*/
public drawRect = (x: number, y: number, width: number, height: number): Graphics => {
return this.drawShape(new Rectangle(x, y, width, height));
};
```
### 3.椭圆
如何确定一个椭圆呢,由椭圆的标准方程可知:
我们只要知道椭圆的长轴和短轴即可:
```ts
export class Ellipse extends Shape {
public x: number;
public y: number;
public radiusX: number;
public radiusY: number;
public readonly type = SHAPE_TYPE.ELLIPSE;
constructor(x = 0, y = 0, radiusX = 0, radiusY = 0) {
super();
this.x = x;
this.y = y;
this.radiusX = radiusX;
this.radiusY = radiusY;
}
}
```
最后是绘制的方法:
```ts
const ellipse = shape;
const { x, y, radiusX, radiusY } = ellipse;
ctx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2);
if (fillStyle.visible) {
ctx.fill();
}
if (lineStyle.visible) {
ctx.stroke();
}
```
### 4.多边形
多边形由多个点构成,因此,用一个 `points` 数组表示,每 `2` 个元素代表一个点的坐标
用`closeStroke`属性表示该多边形是否闭合
```ts
export class Polygon extends Shape {
public points: number[] = [];
public closeStroke = false;
public type = SHAPE_TYPE.POLYGON;
constructor(points: number[] = []) {
super();
this.points = points;
}
}
```
绘制方法如下:
```ts
const polygon = shape;
const { points, closeStroke } = polygon;
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
if (closeStroke) {
ctx.closePath();
}
if (fillStyle.visible) {
ctx.fill();
}
if (lineStyle.visible) {
ctx.stroke();
}
```
### 5.圆角矩形
圆角矩形的实现相比起矩形,需要多一个`radius`属性:
```ts
export class RoundedRectangle extends Shape {
public x: number;
public y: number;
public width: number;
public height: number;
public radius: number;
public readonly type = SHAPE_TYPE.ROUNDED_RECTANGLE;
constructor(x = 0, y = 0, width = 0, height = 0, radius = 20) {
super();
this.x = x;
this.y = y;
this.width = width;
this.height = height;
const r = Math.min(width, height) / 2;
this.radius = radius > r ? r : radius;
}
}
```
实现绘制方法时,需要在四个角绘制圆弧
```ts
const roundedRectangle = shape;
const { x, y, width, height, radius } = roundedRectangle;
ctx.moveTo(x + radius, y);
ctx.arc(x + radius, y + radius, radius, Math.PI * 1.5, Math.PI, true);
ctx.lineTo(x, y + height - radius);
ctx.arc(x + radius, y + height - radius, radius, Math.PI, Math.PI / 2, true);
ctx.lineTo(x + width - radius, y + height);
ctx.arc(x + width - radius, y + height - radius, radius, Math.PI / 2, 0, true);
ctx.lineTo(x + width, y + radius);
ctx.arc(x + width - radius, y + radius, radius, 0, Math.PI * 1.5, true);
ctx.closePath();
if (fillStyle.visible) {
ctx.fill();
}
if (lineStyle.visible) {
ctx.stroke();
}
```
## 三:层级管理
在 `canvas` 绘图环境中,先绘制的图形会被后绘制的图形所覆盖,因此,层级的管理就自然地通过绘制顺序来实现。在这种情况下,最先被绘制的图形将位于最底层,而随后绘制的图形则逐层叠加,直至最上层。
因此,我们需要考虑两个部分:
1. 如何标识当前层级
2. 如何根据层级来绘制图形
首先是第一个问题:如何标识层级
### 1.层级标识
层级属性并不只在`Container`类上实现,`Container`类表示组的概念,实际上,任何元素节点都需要层级概念,包括`Container`类。
所以,我们需要实现一个通用的节点类 `Vertex`,这个类代表了最原始的‘节点’的概念,所有可以被展示到 `canvas` 画布上的、各种类型的节点都会继承于这个类,这是一个抽象类,我们并不会直接实例化这个类。
这个类上面挂载了‘节点’的各种通用属性,比如:父元素、层级、节点是否可见等。
同时,`Container`类继承于 `Vertex` 类,‘组’也算作‘节点’。
`Vertex` 类实现如下:
```ts
class Vertex {
protected _zIndex = 0; // 节点的层级关系
public parent: Container | undefined = undefined; // 节点的父子关系
public visible = true;
}
```
同时要对 `Container` 类进行改造,增加根据 `zIndex` 的排序方法,实现如下:
```ts
class Container extends Vertex {
public readonly children: Container[] = [];
public isSort: boolean = false; // true 的时候表示需要更新排序
public parent: Container | undefined = undefined;
public addChild = (child: Container) => {
child.parent?.removeChild(child);
this.children.push(child);
this.isSort = true;
child.parent = this;
};
public removeChild = (child: Container) => {
for (let i = 0; i < this.children.length; i++) {
if (this.children[i] === child) {
this.children.splice(i, 1);
child.parent = undefined;
return;
}
}
};
public sortChildren = (): void => {
if (!this.isSort) return;
this.children.sort((a, b) => a.zIndex - b.zIndex);
this.isSort = false;
};
}
```
### 2.根据层级来绘制图形
目前,我们已经实现了基础图形,也实现了组的功能,接下来我们会按照层级和嵌套关系去渲染,比如如下代码:
```html
```
那么根据层级和嵌套关系,我们会构造出一个树结构:
```
Application
├── yellowGraphic
├── container2
| ├── greenGraphic
| └── container1
| ├── blackGraphic
| └── redGraphic
├── blueGraphic
└── grayGraphic
```
前面说了,我们的渲染策略是:子节点在父节点之上 (先绘制父节点,再绘制子节点),相同层级的兄弟节点,zIndex 越大,层级越高,相同 zIndex,则按照添加顺序来决定,后添加的节点,层级更高 (越晚绘制)。
也就是说我们期望的渲染顺序是这样的:
```sh
Application --> container2 --> container1 --> blackGraphics --> redGraphics --> greenGraphics --> yellowGraphics --> blueGraphic --> grayGraphic
```
也就是:
```
(1)Application
├── (7)yellowGraphic
├── (2)container2
| ├── (6)greenGraphic
| └── (3)container1
| ├── (4)blackGraphic
| └── (5)redGraphic
├── (8)blueGraphic
└── (9)grayGraphic
```
可以得出:我们要**先序遍历**这棵对象树,也就是说我们会先处理根节点,再递归处理子节点,直至所有节点处理完毕,退出递归。
在不断深入这棵对象树的同时,我们还要根据 `zIndex` 给每个节点的子节点进行排序。
```ts
/**
* @description: 根据 z-index 排序子元素
* @return {*}
*/
public sortChildren = (): void => {
if (!this.isSort) {
return;
}
this.children.sort((a, b) => a.zIndex - b.zIndex);
this.isSort = false;
};
/**
* 递归渲染以自身为根的整棵节点树
*/
public renderCanvasRecursive(render: CanvasRenderer) {
if (!this.visible) return
this.renderCanvas(render) // 先渲染自身
// 渲染子节点
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i]
child.renderCanvasRecursive(render)
}
}
```
既然是有嵌套结构,那么自然就会思考一个问题:循环嵌套怎么办?
这个时候,我们就能发现,根据层级和父子关系组成的渲染链,不是树结构,而是图结构。即任意两个节点之间都可能存在关系。
所以问题就可以转换成,如何解决有向图中的回环问题。
这时候就要进行有向图的拓扑排序,如果一个图能够完成拓扑排序,则图中不存在回环。否则,找到回环的节点,并进行提示,避免内存泄漏。
## 四:变换矩阵
在组的管理,层级管理,基础图形都实现了后,我们需要封装一些常用的图形变换方法。比如平移,旋转,缩放等。
虽然 `canvas` 为了开发的便捷,也提供了 `ctx.rotate`,`ctx.scale` 等方法,但 `ctx.rotate()` 和 `ctx.scale()` 方法是对当前 `CanvasRenderingContext2D` 对象的变换矩阵进行操作的。这些变换会影响之后的所有变换。
我们更希望的是,我们能对一组或者一个图形进行变换。而不是影响后续的所有操作。
我们有两种方式去解决这个问题,一种是,每次变化完成后,都进行 `ctx.resetTransform()` 重置变换矩阵,命令式编程。第二种是,构建直观的变换矩阵,通过矩阵相乘,来实现变换。
因此,我们来构建`Transform`类,添加旋转,平移,斜切 (skew) 等变换矩阵。
> 很遗憾,矩阵是什么是说不清的,你必须得自己亲眼看看 --墨菲斯
> Unfortunately, no one can be told what the Matrix is. You have to see it for yourself. --Morpheus
对于旋转和斜切,它们能保证网格平行且等距分布,原点不变。因此是线性变换,可以通过矩阵的左乘来实现。
但由于平移是仿射变换,因此,我们需要通过升维,在高纬度的线性变换来表示低维度的仿射变换。从而可以通过矩阵的乘法来实现这些变换的操作。
`Matrix` 类将会提供各种各样的与矩阵操作相关的函数 (矩阵相乘,矩阵求逆等),任何变换的叠加都将会转换成 `matrix`,方便我们调用 `canvas` 的指令。
`Transform` 类就类似 `CSS` 的 `transform`,它提供了一些更清晰、更符合人类直觉的变换,而不用直接使用矩阵变换,当然,这些变换最终会转换成矩阵变换。
所以对于二维图形的变换操作,我们需要一个 3x3 的矩阵。因此`Matrix`类的实现如下:
```ts
export class Matrix {
public a: number; // x scale
public b: number; // y skew
public c: number; // x skew
public d: number; // y scale
public tx: number; // x translation
public ty: number; // y translation
public array: Float32Array | null = null; // An array of the current matrix. Only populated when `toArray` is called
constructor(a = 1, b = 0, c = 0, d = 1, tx = 0, ty = 0) {
this.a = a;
this.b = b;
this.c = c;
this.d = d;
this.tx = tx;
this.ty = ty;
}
}
```
旋转矩阵:
如果我们有一个二维向量,则旋转后的向量 可以通过矩阵乘法得到:
代码实现矩阵的乘法如下:
```ts
public rotate = (angle: number): Matrix => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const a1 = this.a;
const c1 = this.c;
const tx1 = this.tx;
this.a = a1 * cos - this.b * sin;
this.b = a1 * sin + this.b * cos;
this.c = c1 * cos - this.d * sin;
this.d = c1 * sin + this.d * cos;
this.tx = tx1 * cos - this.ty * sin;
this.ty = tx1 * sin + this.ty * cos;
return this;
};
```
斜切矩阵:
缩放矩阵:
矩阵的乘法:
代码实现:
```ts
public scale = (x: number, y: number): Matrix => {
this.a *= x;
this.d *= y;
this.c *= x;
this.b *= y;
this.tx *= x;
this.ty *= y;
return this;
};
```
平移矩阵:
代码实现:
```ts
public translate = (x: number, y: number): Matrix => {
this.tx += x;
this.ty += y;
return this;
};
```
最后是综合的变换实现:
```ts
public setTransform = (
x: number,
y: number,
pivotX: number,
pivotY: number,
scaleX: number,
scaleY: number,
rotation: number,
skewX: number,
skewY: number,
): Matrix => {
this.a = Math.cos(rotation + skewY) * scaleX;
this.b = Math.sin(rotation + skewY) * scaleX;
this.c = -Math.sin(rotation - skewX) * scaleY;
this.d = Math.cos(rotation - skewX) * scaleY;
this.tx = x - (pivotX * this.a + pivotY * this.c);
this.ty = y - (pivotX * this.b + pivotY * this.d);
return this;
};
```
## 五:事件系统
由于我们已经实现了组和节点的概念,因此只要在节点类上继承发布订阅的类即可。
同时在 canavs 上监听各种事件,进行事件的传递:
```ts
this.canvasEle.addEventListener('pointermove', this.onPointerMove, true);
this.canvasEle.addEventListener('pointerleave', this.onPointerLeave, true);
this.canvasEle.addEventListener('pointerdown', this.onPointerDown, true);
this.canvasEle.addEventListener('pointerup', this.onPointerup, true);
```
接下来的核心是,如何判断点击到的是哪个元素。
### 1.碰撞检测
针对复杂的多边形,可以采用射线法来做碰撞检测,对于规则图形则用不到射线法,比如圆,碰撞检测的方式是:判断待检测点与圆心的距离是否小于圆的半径就行了。
#### (1) 射线法:
维护一个计数器 `count`,计数器的初始值为 `0`,然后从待检测点发出一条射线,这条射线每穿过封闭图形的边一次,就让 `count` 加 `1`,如果最后 `count` 为奇数,则判断该点在封闭图形内部,如果为偶数,则判断该点在封闭图形外部。
判断射线与曲线线段是否相交,是比较困难的,但是判断射线与直线线段相交,相对就简单许多,因此,除了一些规则的曲线图形 (完整的圆、椭圆),其他的不规则的曲线图形,都会用直边多边形来代替。
所以,射线法其实只需要处理直边多边形的情况,如下:
具体做法就是,用 `for` 循环判断这个直边多边形的每一条边,如果相交则让 `count+1`,循环结束后就能得到 `count` 了。
#### (2) 如何判断射线与线段是否相交
我们会从待检测点发出一条水平向右的无限远的射线
首先我们可以排除一些一定不相交的情况:
1. 线段在射线上方
2. 线段在射线下方
3. 线段的两个端点都在待检测点的左边
排除了以上 `3` 种一定不相交的情况后,接下来会有一种一定相交的情况,也就是线段的 `2` 个端点都在待检测点的右边:
最后,还剩下了 `1` 种情况:线段的一个端点在待检测点的左边,另一个端点在待检测点的右边,这个时候可能相交,也可能不相交:
这种情况下,计算出射线所处的直线与线段的交点的 x 坐标,然后判断这个交点的 x 坐标是否大于待检测点的 x 坐标,如果是,则说明射线与线段相交了,反之则没有相交。
还有一种极限的情况,射线和多边形的边缘重合。(不考虑,因为理论存在,但实际不存在)
判断线段与射线是否相交:
```ts
private isIntersect(
px: number,
py: number,
p1x: number,
p1y: number,
p2x: number,
p2y: number
) {
// 线段在射线上方
if (p1y > py && p2y > py) {
return false
}
// 线段在射线下方
if (p1y < py && p2y < py) {
return false
}
// 线段的两个端点都在待检测点的左边
if (p1x < px && p2x < px) {
return false
}
// 线段的2个端点都在待检测点的右边
if (p1x > px && p2x > px) {
return true
}
const p2o = p1y - p2y
const p1o = p2x - p1x
const p2q = py - p2y
const x = p2x - (p1o / p2o) * p2q
if (x > px) {
return true
} else {
return false
}
}
```
因此,把以上方法汇总起来,判断待检测点是否在一个多边形内部:
```ts
public contains(p: Point): boolean {
const len = this.points.length
let count = 0
// points 数组的每两个元素为一个顶点的坐标
for (let i = 2; i <= len - 2; i += 2) {
const p1x = this.points[i - 2]
const p1y = this.points[i - 1]
const p2x = this.points[i]
const p2y = this.points[i + 1]
if (this.isIntersect(p.x, p.y, p1x, p1y, p2x, p2y)) {
count++
}
}
// 还需要判断最后一个点和第一个点的连线是否与射线相交
const p1x = this.points[0]
const p1y = this.points[1]
const p2x = this.points[len - 2]
const p2y = this.points[len - 1]
if (this.isIntersect(p.x, p.y, p1x, p1y, p2x, p2y)) {
count++
}
if (count % 2 === 0) {
return false
} else {
return true
}
}
```
至此,多边形的碰撞检测就实现了。
#### (3) 补充所有类型的图形的碰撞检测方法
##### 圆
只要待检测点离圆心的距离小于半径,就判断待检测点在该封闭图形的内部
```ts
public contains(p: Point): boolean {
if (
(p.x - this.x) * (p.x - this.x) + (p.y - this.y) * (p.y - this.y) < this.radius * this.radius
) {
return true
} else {
return false
}
}
```
##### 矩形
```ts
public contains(p: Point): boolean {
if (
p.x > this.x &&
p.x < this.x + this.width &&
p.y > this.y &&
p.y < this.y + this.height
) {
return true
} else {
return false
}
}
```
##### 椭圆
椭圆的方程是:
因此只要,我们就判断待检测点落在椭圆的内部
```ts
public contains(p: Point): boolean {
if (
((p.x - this.x) * (p.x - this.x)) / (this.radiusX * this.radiusX) +
((p.y - this.y) * (p.y - this.y)) / (this.radiusY * this.radiusY) <
1
) {
return true
} else {
return false
}
}
```
##### 圆角矩形
```ts
public contains(p: Point): boolean {
const con1 =
p.x > this.x &&
p.x < this.x + this.width &&
p.y > this.y &&
p.y < this.y + this.height
if (!con1) {
return false
}
// 判断左上角
const c1x = this.x + this.radius
const c1y = this.y + this.radius
if (p.x < c1x && p.y < c1y) {
if (
(p.x - c1x) * (p.x - c1x) + (p.y - c1y) * (p.y - c1y) <
this.radius * this.radius
) {
return true
} else {
return false
}
}
// 判断左下角
const c2x = this.x + this.radius
const c2y = this.y + this.height - this.radius
if (p.x < c2x && p.y > c2y) {
if (
(p.x - c2x) * (p.x - c2x) + (p.y - c2y) * (p.y - c2y) <
this.radius * this.radius
) {
return true
} else {
return false
}
}
// 判断右上角
const c3x = this.x + this.width - this.radius
const c3y = this.y + this.radius
if (p.x > c3x && p.y < c3y) {
if (
(p.x - c3x) * (p.x - c3x) + (p.y - c3y) * (p.y - c3y) <
this.radius * this.radius
) {
return true
} else {
return false
}
}
// 判断右下角
const c4x = this.x + this.width - this.radius
const c4y = this.y + this.height - this.radius
if (p.x > c4x && p.y < c4y) {
if (
(p.x - c4x) * (p.x - c4x) + (p.y - c4y) * (p.y - c4y) <
this.radius * this.radius
) {
return true
} else {
return false
}
}
return true
}
```
#### (4)引入了层级关系的碰撞检测
渲染引擎在拿到这棵带有层级关系的对象树 (根节点为 `stage`) 后,会采用先序遍历的方式来渲染这棵树,这意味着,父节点会比子节点先渲染,而相同层级的兄弟节点,则按照 `zIndex` 来排序,`zIndex` 越大的兄弟节点越晚被渲染,`zIndex` 相同的兄弟节点则按照数组中的顺序来渲染,这也是层级关系的核心所在。
对于碰撞检测,同样需要遍历这棵对象树,只不过遍历的顺序不一样了。
对于碰撞检测的遍历顺序,只有一条原则:谁处于层级关系的更高层,谁先被检测 (这也是为什么我们能使用像素标记法来做碰撞检测)。这一点和渲染的顺序是反过来的。这其实也非常好理解,假设在桌子上放了一堆纸,这些纸形成了一个层级关系,然后在这堆纸所在的区域随机滴一滴墨水,这滴墨水肯定是滴在尽可能上层的那张纸上。
可以得到:我们会后序遍历这棵对象树,越晚被渲染出来的元素,越早进行碰撞检测。
```ts
let hasFoundTarget = false;
let hitTarget: Container | null = null;
const hitTestRecursive = (curTarget: Container, globalPos: Point) => {
// 如果对象不可见
if (!curTarget.visible) {
return;
}
if (hasFoundTarget) {
return;
}
// 深度优先遍历子元素
for (let i = curTarget.children.length - 1; i >= 0; i--) {
const child = curTarget.children[i];
hitTestRecursive(child, globalPos);
}
if (hasFoundTarget) {
return;
}
// 最后检测自身
const p = curTarget.worldTransform.applyInverse(globalPos);
if (curTarget.containsPoint(p)) {
hitTarget = curTarget;
hasFoundTarget = true;
}
};
const hitTest = (root: Container, globalPos: Point) => {
hasFoundTarget = false;
hitTarget = null;
hitTestRecursive(root, globalPos);
return hitTarget;
};
```
接下来还有一个问题,那就是曲线的边缘碰撞检测:
### 2.贝塞尔曲线
上面已经实现了多边形和各种图形的碰撞检测,但是曲线就没办法计算了。所以我们需要把贝塞尔曲线用多边形来进行近似。
这样就可以把曲线的碰撞检测问题,转化成多边形的碰撞检测。这样就可以用上述的方法直接解决了。
### (1)二阶贝塞尔曲线
起点(P0):这是曲线开始的位置。在绘制过程中,曲线会精确地通过这个点。
控制点(P1):这个点是用来控制曲线形状和弯曲程度的。它不一定在曲线上,但会对曲线的走向产生重要影响。通过调整控制点的位置,可以改变曲线的弯曲程度和方向。
终点(P2):这是曲线结束的位置。同样地,曲线也会精确地通过这个点。
我们首先要在贝塞尔曲线上采样一系列的点
贝塞尔曲线是一个 和 关于 的参数方程,,要在贝塞尔曲线上采样多个点,可以把这个区间分成 n 份,这样我们就得到了 n 个 t 值,然后把这些 t 值代入贝塞尔曲线的参数方程,我们就可以得到 n 个位于贝塞尔曲线上的点,然后把这些点连起来,就得到了一条近似的贝塞尔曲线。
所以我们要计算,贝塞尔曲线的长度,然后根据长度来计算需要多少个采样点。
那么如何求曲线的长度呢,用定积分。
我们首先写出贝塞尔曲线关于 和 的方程:
以起始点 ,,的贝塞尔曲线为例,它的图像是这样的:
假设我们要求 时,这条曲线的弧长 (即整条贝塞尔曲线的长度)。
我们要求的量是整条贝塞尔曲线的长度,依然是按照惯例,我们先将图像放大到很大,然后再截取一小段 (假设无限小):
这个红色的线段的长度 就是我们要求的一份, 是这条线段在 轴方向上的长度, 是这条线段在 轴方向上的长度,当 和 趋近于无穷小时,可以把红色的线段看作一条直线,所以,我们依然可以使用勾股定理,得出 。但是,到这里还没有结束,我们要对 进行积分,而不是对 和 进行积分,所以这里我们还要得出 和 对于 的表达式;由于 对 的函数和 对 的函数的形式是一样的,所以我们只需要求出 对 的表达式也就得到了 对 的表达式。
就是 对 的函数在 的变化量为 (趋近于无穷小) 时, 的变化量。如下:
是一个无穷小量,这个时候,我们可以通过导数 (即斜率) 得出 ,是 对 的函数在 处的导数,将 乘到右边,我们有:。通过同样的方法,我们可以得到:。所以我们要求的一份 ,所以,我们的积分表达式是:,接下来就是求函数 的不定积分了。
根据贝塞尔曲线的参数方程,有:
为了简化这个公式,我们把一些常数挪到一起,用另一些常数代替,令 ,,,, 所以:
所以 ,为了简化这个式子,我们再次将常量挪到一起,用另一些常量替换,令 ,,,所以:
接下来我们依然会使用换元法,来求解这个不定积分,我们会用换元法将其化解成 型不定积分,然后套公式得出结果。
首先,是两个平方量的和 ( 和 ),所以,根据一元二次方程解的个数的判断公式,我们有
接下来就是开始换元了:
令,,我们就得到了型不定积分,即 这个时候,积分变量从 变成了,因为对 的积分区间是 且 ,所以对 的积分区间为 所以我们可以得出:
接下来就是把各个常量代进去了。结果
#### 代码实现:
```ts
export const getQuadraticBezierLength = (
P0X: number,
P0Y: number,
P1X: number,
P1Y: number,
P2X: number,
P2Y: number,
) => {
const ax = 2 * (P0X - 2 * P1X + P2X);
const bx = -2 * (P0X - P1X);
const ay = 2 * (P0Y - 2 * P1Y + P2Y);
const by = -2 * (P0Y - P1Y);
const A = ax * ax + ay * ay;
const B = 2 * (ax * bx + ay * by);
const C = bx * bx + by * by;
const a = Math.sqrt((4 * A * C - B * B) / 4);
// 牛顿 - 莱布尼兹公式
const F1 =
(A / 2 + B / 4) * Math.sqrt((A + B / 2) * (A + B / 2) + a * a) +
((a * a) / 2) * Math.log(Math.abs(A + B / 2 + Math.sqrt((A + B / 2) * (A + B / 2) + a * a)));
const F0 =
(B / 4) * Math.sqrt((B * B) / 4 + a * a) + ((a * a) / 2) * Math.log(B / 2 + Math.sqrt((B * B) / 4 + a * a));
const length = (1 / (Math.sqrt(A) * A)) * (F1 - F0); // 不要忘了前面还有个(A根号A分之一)
return length;
};
```
虽然公式很长,但是代码看起来就短多了。
采样多个点,然后连成一个近似于二阶贝塞尔曲线的直边多边形
```ts
public quadraticCurveTo(cpX: number, cpY: number, toX: number, toY: number) {
const len = this.currentPath.points.length
if (len === 0) {
this.currentPath.points = [0, 0]
}
const P0X = this.currentPath.points[len - 2]
const P0Y = this.currentPath.points[len - 1]
const P1X = cpX
const P1Y = cpY
const P2X = toX
const P2Y = toY
// 求出这条二阶贝塞尔曲线的长度
const curveLength = getQuadraticBezierLength(P0X, P0Y, P1X, P1Y, P2X, P2Y)
let segmentsCount = Math.ceil(curveLength / 10) // 每10个像素采样一次
// 最大 2048 份
if (segmentsCount > 2048) {
segmentsCount = 2048
}
// 最小 8 份
if (segmentsCount < 8) {
segmentsCount = 8
}
// 计算出采样点的坐标然后放入 points 数组
for (let i = 1; i <= segmentsCount; i++) {
const t = i / segmentsCount
// 直接套用二阶贝塞尔曲线的公式
const x = (1 - t) * (1 - t) * P0X + 2 * t * (1 - t) * P1X + t * t * P2X
const y = (1 - t) * (1 - t) * P0Y + 2 * t * (1 - t) * P1Y + t * t * P2Y
this.currentPath.points.push(x, y)
}
return this
}
```
# 参考资料:
1. [如何通俗地讲解「仿射变换」?](https://www.zhihu.com/question/20666664/answer/157400568)
2. [仿射变换及其变换矩阵的理解](https://www.cnblogs.com/shine-lee/p/10950963.html)
3. [深入理解贝塞尔曲线](https://juejin.cn/post/6844903666361565191)
4. [如何理解并应用贝塞尔曲线](https://juejin.cn/post/6844903796582121485)
================================================================
# https://ran.chaxus.com/cn/src/note/centos
================================================================
---
description: '在 CentOS 8 官方镜像停止支持后,把 YUM 源切换到 vault.centos.org,以及基础的系统版本与缓存命令。'
---
# CentOS
查看当前系统的版本:
```sh
cat /etc/centos-release
```
## YUM
YUM 是 Yellowdog Updater, Modified 的缩写,虽然不是 CentOS 开发的,但已成为 CentOS 系统中常用的包管理工具。YUM 作为 RPM 的前端程序,解决了 RPM 软件包之间的依赖性问题,并提供了更加便捷的软件包管理方式。
### YUM 仓库源
CentOS 8 的官方支持已经结束,但您仍然可以使用 CentOS Stream 8 或其他第三方仓库。如果您正在使用 CentOS 8,并且希望继续使用类似的仓库,可以考虑切换到 CentOS Stream 8。
#### 1.替换镜像源
由于众所周知的原因,访问官方镜像会非常慢,所以
修改 /etc/yum.repos.d/CentOS-AppStream.repo 文件(或相应的仓库配置文件),将 baseurl 或 mirrorlist 指向一个可用的源。
我个人尝试,发现需要修改三个文件:
```sh
/etc/yum.repos.d/CentOS-Base.repo
/etc/yum.repos.d/CentOS-AppStream.repo
/etc/yum.repos.d/CentOS-Extras.repo
```
修改文件内容:
```sh
[baseos]
name=CentOS-8 - Base
baseurl=http://vault.centos.org/8.5.2111/BaseOS/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
[appstream]
name=CentOS-8 - AppStream
baseurl=http://vault.centos.org/8.5.2111/AppStream/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
[extras]
name=CentOS-8 - Extras
baseurl=http://vault.centos.org/8.5.2111/extras/$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-centosofficial
```
更新缓存并重试
1. 清理缓存:`yum clean all`
2. 生成新的缓存:`yum makecache`
3. 更新系统:`yum update`
================================================================
# https://ran.chaxus.com/cn/src/note/docker
================================================================
---
description: '实用的 Docker 命令速查:镜像与容器的生命周期管理、编写 Dockerfile、CMD,以及 daemon 起不来时的排查思路。'
---
# docker
查询 `docker` 是否在运行:
1. 输入以下命令来查找 `Docker` 守护进程的 `PID`(进程 `ID`):
```sh
ps -ef | grep docker
```
如果命令返回了包含`/usr/local/bin/dockerd`(或类似路径)的行,并且 `PID` 列有数字显示,那么 `Docker` 守护进程正在运行。
2. 使用 `docker ps` 命令
虽然 `docker ps` 命令主要用于列出正在运行的容器,但如果 `Docker` 服务没有运行,该命令会返回一个错误消息。因此,你也可以通过运行该命令并观察输出来判断 `Docker` 服务是否启动。
如果 `Docker` 服务正在运行,该命令将列出所有正在运行的容器(如果没有运行的容器,则可能只显示表头)。如果 `Docker` 服务没有运行,你将看到一个错误消息,如`“Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?”`。
3. 使用 `docker info` 命令
`docker info` 命令提供 `Docker` 系统的详细信息,包括容器数量、镜像数量、`Docker` 版本等。如果 `Docker` 服务正在运行,该命令将正常输出这些信息。
如果 `Docker` 服务正在运行,你将看到一系列关于 Docker 系统的信息。如果 Docker 服务没有运行,你将看到一个错误消息,与 docker ps 命令类似。
安装 `docker` 的网站:`https://hub.docker.com/`
## images
`Docker` 把应用程序及其依赖,打包在 `image` 文件里面。只有通过这个文件,才能生成 `Docker` 容器。`image` 文件可以看作容器的模板。`Docker` 根据 `image` 文件生成的容器实例。同一个 `image` 文件,可以生成多个同时运行的容器实例
`image` 是二进制文件。实际开发过程中,一个 `image` 文件往往通过继承另一个 `image` 文件,再加上一些个性化的设置而成。
```shell
# 搜索镜像
$ docker search [keywords]
# 列出本机所有的image文件
$docker image ls
# 删除image文件
$docker image rm [imageName]
$docker image rmi [imageID]
```
### 从仓库拉取镜像
- 将 `image` 文件从仓库拉到本地
```shell
$ docker image pull library/hello-world
# docker image pull 是抓去image文件的命令。library/hello-world是image仓库的位置
# 其中library是image文件所在的组,hello-world是image文件的名字
# 由于官方提供的image文件,都在library里面,所以它是默认组,可以省略
# 因此$ docker image pull hello-world
```
- 抓取成功后,可以在本机看到这个 `image` 文件
```shell
$ docker image ls
```
- 运行/创建这个 `image` 文件
```shell
$ docker container run hello-world
# docker container run 命令会从image文件,生成一个正在运行的容器
# 如果没有指定image文件,会从仓库自动抓取,docker image pull 不是必须
```
- 运行 `image` 文件 (重复使用容器),`docker container run` 会新建容器,每运行一次,就会新建一个容器
```shell
# 用来启动已生成,或者已经停止的容器文件
$ docker container start [containerID]
```
- 查看 `docker` 容器的输出
```shell
# 命令用来查看docker容器的输出
$ docker container logs [containerID]
# docker run 命令运行容器的时候,没有-it参数,就药用这个来输出
```
- 进入一个正在运行的 `docker` 容器
```shell
# 如果docker run 命令运行容器的时候,没有使用-it参数,就可以使用这个进入容器
$ docker container exec -it [containerID] /bin/bash
```
- 关闭容器
```shell
# 终止容器的运行,强行立即终止
$ docker container kill [containeId]
# 终止容器的运行,会自动进行收尾工作,可能不会终止
$ docker container stop [containerId]
```
## container
- `image` 文件生成的容器实例,本身也是一个文件,称为容器文件。
- 一旦容器生成,就会同时存在两个文件:`image` 文件和容器文件。关闭容器并不会删除容器文件,只是容器停止运行而已
```shell
# 列出本机正在运行的容器
$ docker container ls
# 列出本机所有的容器,包括终止运行的容器
$ docker container ls --all
```
- 终止运行的容器文件,依然会占据硬盘空间,可以删除,删除完再查看就没有了
```shell
$ docker container rm [containerID]
```
### 生成容器
- `docker container run` 命令会从 `image` 文件生成容器
```shell
$ docker container run -p 8000:3000 -it koa-demo /bin/bash
# 或者
$ docker container run -p 8000:3000 -it koa-demo:0.0.1 /bin/bash
# -p 参数:容器的3000端口映射到本机器的8000端口
# -it 参数:容器的shell映射到当前的shell,然后你在本机窗口输入命令,就会传入容器
# koa-demo:0.0.1 image文件的名字(如果有标签,还需要提供标签,默认是latest标签)
# /bin/bash: 容器启动以后,内部第一个执行的命令。这里是启动Bash,保证用户可以使用Shell。
```
- 这就表示在容器里面了,可以执行命令了
```shell
root@66d80f4aaf1e:/app# node demos/01.js
```
- 需要注意的是,node 进程运行在 Docker 容器的虚拟环境里面,进程接触到的文件系统和网络接口都是虚拟的,与本机的文件系统和网络接口是隔离的,因此需要定义容器与物理机的端口映射
- 停止容器,释放内存
```shell
# 在容器的命令行,通过Ctrl + c停止node进程,然后Ctrl + d(或者输入exit)退出容器
# 或者使用docker container kill终止容器运行
# 可能需要本机的另一个终端窗口,查看容器的ID
$ docker container ls
# 或
$ docker container ls --all
# 停止容器的运行
$ docker container kill [containerID]
# 容器停止运行后,并不会消失,需要删除容器文件
# 删除指定容器文件
$ docker container rm [containerID]
# 或
# 通过--rm参数,在容器终止运行后自动删除容器文件
$ docker container run --rm -p 8000:3000 -it koa-demo /bin/bash
```
## DockerFile
- 一个文本文件,用来配置 `image`。`Docker` 根据该文件生成二进制的 `image` 文件
- 先获取一个项目
```shell
$ git clone https://github.com/ruanyf/koa-demos.git
$ cd koa-demos
```
- 进入项目,编写 `.dockerignore` 文件。指的是下面三个路径要排除,不要打包进入 `image` 文件。
```shell
.git
node_modules
npm-debug.log
```
- 根目录下新建一个文本文件 `Dockerfile`。
```shell
FROM node:8.4
# 该image文件继承官方的node image,冒号表示标签,这是8.4版本的node
COPY . /app
# .是当前目录下的所有文件(除了.dockerignore排除的文件),都拷贝进入image文件的/app目录
WORKDIR /app
# 指定接下来的工作路径为/app
RUN npm install
# 在/app目录下,运行npm install命令安装依赖。注意,安装后所有的依赖,都将打包进入image文件
EXPOSE 3000
# 将容器的3000端口暴露出来,允许外部连接这个端口
```
- 有了 `Dockerfile` 文件以后,就可以使用 `docker image build` 命令创建 `image` 文件了。
```shell
$ docker image build -t koa-demo .
# 用-t参数来制定image文件的名字,最后的 . 代表Dockerfile文件所在的路径,因为是当前路径
# 或者
$ docker image build -t koa-demo:0.0.1 .
# 后面还可以用冒号指定标签。如果不指定,默认标签就是latest。
# 运行成功就可以用 docker image ls 来查看了
```
## CMD 命令
- 可以随着容器的启动而执行的命令,比如上个例子手动执行的 `node demos/01.js`。可以把这个命令写在 `Dockerfile` 里面
```shell
FROM node:8.4
COPY . /app
WORKDIR /app
RUN npm install --registry=https://registry.npm.taobao.org
EXPOSR 3000
# 表示容器启动后自动执行
CMD node demos/01.js
```
- CMD 命令和 RUN 命令的区别
- RUN 命令在 image 文件的构建阶段执行,执行结果都会打包进行 image 文件
- CMD 命令是容器启动后执行
- 一个 dockerfile 可以有多个 RUN 命令,但只有一个 CMD 命令
- 指定了 CMD 命令后,docker container run 命令就不能附加命令了 (比如前面的/bin/bash),否则他会覆盖 CMD 命令,现在可以使用
```shell
$ docker container run --rm - p 8000:3000 -it koa-demo:0.0.1
```
# 问题:
1. 如果遇到 docker desktop 登录不上,可以尝试 `docker login`进行登录,命令行登录会输出错误的信息,如果发现是网络问题,换一个代理即可。
2. `Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?`
```shell
# Linux:docker 服务没有启动
$ sudo service docker start
# 或者,用 systemd 的发行版:
$ sudo systemctl start docker
```
macOS 或 Windows 上用 Docker Desktop 的话,系统里根本没有 `service`/`systemctl` 这两个命令——应该去启动或重启 Docker Desktop 应用本身,守护进程是跑在它的虚拟机里的,不是宿主机上的一个服务。
## 参考资料
- [docker 入门教程](http://www.ruanyifeng.com/blog/2018/02/docker-tutorial.html)
- [从 0 开始了解 Docker](https://juejin.cn/post/6844903591375814669)
- [docker 官网](https://docs.docker.com)
- [docker 应用部署](https://juejin.cn/post/6844904167610253326#heading-18)
- [docker 里面没有 vim](https://www.cnblogs.com/river2005/p/8503238.html)
- [apt-get 配置国内源](https://www.jianshu.com/p/fb337765c2c2)
- [前端工程师 docker 教程 - 实战篇](https://juejin.cn/post/6844903956305412109)
- [docker 启动后不会停止的问题](https://www.jianshu.com/p/ca63b6c8fdf1)
- [docker 进阶系列/docker-compose/swarm/stack/secret/config](https://juejin.cn/post/6967598675820281870#heading-8)
- [docker-compose 入门指南](https://juejin.cn/post/6886018425353682951)
- [docker 删除 image](https://www.cnblogs.com/vipsoft/p/12447059.html)
================================================================
# https://ran.chaxus.com/cn/src/note/libreoffice2wasm
================================================================
---
description: '从源码构建 LibreOffice,并使用 Emscripten 与 Binaryen 将其编译为 WebAssembly 的记录,包含依赖安装与踩过的坑。'
---
# 将 LibreOffice 编译为 WebAssembly
从源码构建 LibreOffice,并使用 Emscripten 与 Binaryen 将其编译为 WebAssembly 的记录。
## 项目地址:
```sh
git clone https://git.libreoffice.org/core
```
github 地址:`https://github.com/LibreOffice/core/blob/master/static/README.wasm.md`
## 容器化环境
由于是 `c/c++` 的项目,编译过程需要很多系统级别配置。如果当前设备不支持的话,不建议强行去适配。可以进行容器化处理。
比如用一个最常见的服务器系统:
```sh
docker image pull ubuntu
```
下载完成后,就可以去构建一个服务了:
```sh
docker container run -p 30105:30105 --name=alit -itd ubuntu /bin/bash
```
构建并启动成功后,可以通过`docker container ls`去查看运行情况:
```sh
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
efd831ab0ba8 ubuntu "/bin/bash" 5 seconds ago Up 4 seconds 0.0.0.0:30105->30105/tcp alit
```
再将项目移动到容器中:
```sh
docker cp ./core alit:/home
# 总的来说,项目还是非常大的
# Successfully copied 3.59GB to alit:/home
```
进入容器,进行操作。
```sh
docker exec -it alit /bin/bash
```
这样,就可以把开发构建环境和电脑系统隔离开,防止一些系统级别不安全的操作了。
## 安装必要的依赖:
````sh
dnf install -y git cmake python3 nodejs
## 遇到问题:
1. 执行 `./autogen.sh`报错,提示`Failed to run aclocal at ./autogen.sh line 195.`
```sh
brew install automake
````
安装完成后,执行`aclocal --version`或者`automake --version`检查是否安装完成
2. 执行 `./autogen.sh`遇到报错:
```sh
checking the GNU Make version... configure: error: failed (/usr/bin/make version >= 4.0 needed)
Error running configure at ./autogen.sh line 323.
```
通过 `make --version` 检查 make 的版本,执行 `brew install make`,再执行 `brew upgrade` 进行升级。确保 `make`的版本大于 `4.0`
当使用 `brew` 安装 `GNU Make` 时,它通常会被命名为 `gmake` 而不是 `make` ,以区分于 `macOS` 系统自带的 `BSD Make`。如果你希望在使用 `make` 命令时实际上调用 `gmake` ,需要进行一些特殊处理来替换或设置别名。
在.zshrc 文件中添加如下行:
```sh
# Configure the brew installation of gmake, alias to make
alias make='gmake'
```
然后,保存文件并重新加载配置文件(通过 `source ~/.zshrc` 或重新打开终端)。
在项目中搜索并替换成:
```sh
# args.makecmd = '/usr/bin/make'
args.makecmd = '/opt/homebrew/bin/gmake'
```
在编译完成后,找到可执行的文件:
```sh
find . -name soffice
```
然后执行
```sh
/home/core/instdir/program/soffice --headless --convert-to pdf /home/office/pptx.pptx
```
将 LibreOffice 编译成 WebAssembly 并在 Web 上运行特定命令(如 soffice --headless --convert-to pdf /home/office/pptx.pptx)是一个复杂的任务。以下是一个大致的实现步骤和思路:
1. 编译 LibreOffice 为 WebAssembly
假设你已经成功编译了 LibreOffice 并生成了 .wasm 文件和相关的 JavaScript 文件。
2. 创建 HTML 和 JavaScript 文件
创建一个 HTML 文件来加载 WebAssembly 模块,并编写 JavaScript 代码来调用 soffice 命令。
HTML 文件 (index.html)
```html
LibreOffice WebAssembly
JavaScript 文件 (main.js) // Initialize the Module var Module = { onRuntimeInitialized: function() {
console.log("LibreOffice WebAssembly Module Loaded"); } }; // Function to convert file to PDF function convertToPDF() {
var fileInput = document.getElementById('fileInput'); if (fileInput.files.length == 0) { alert("Please select a file.");
return; } var file = fileInput.files[0]; var reader = new FileReader(); reader.onload = function(event) { var
arrayBuffer = event.target.result; var byteArray = new Uint8Array(arrayBuffer); // Write the file to the WASM virtual
file system var filename = '/home/office/' + file.name; Module.FS_createDataFile('/', filename, byteArray, true, true);
// Call the soffice command var command = 'soffice --headless --convert-to pdf ' + filename; Module.ccall( 'system', //
name of C function 'number', // return type ['string'], // argument types [command] // arguments ); // Read the output
PDF file var outputFilename = '/home/office/' + file.name.replace(/\.[^/.]+$/, "") + '.pdf'; var output =
Module.FS_readFile(outputFilename); // Create a blob and a link to download the PDF var blob = new Blob([output], {
type: 'application/pdf' }); var link = document.createElement('a'); link.href = window.URL.createObjectURL(blob);
link.download = file.name.replace(/\.[^/.]+$/, "") + '.pdf'; link.click(); }; reader.readAsArrayBuffer(file); }
```
3. 部署和测试
将生成的 .wasm 文件、JavaScript 文件和 HTML 文件部署到你的 Web 服务器上,打开 index.html 文件,选择一个 PPTX 文件并点击“Convert to PDF”按钮。这个过程会:
从文件输入中读取 PPTX 文件。
将文件写入 WebAssembly 模块的虚拟文件系统。
调用 soffice --headless --convert-to pdf 命令进行转换。
从虚拟文件系统中读取生成的 PDF 文件。
创建一个下载链接,允许用户下载生成的 PDF 文件。4. 注意事项
这个过程可能需要大量的内存和计算资源,可能需要优化和调整。
需要确保 Emscripten 的文件系统(MEMFS)能够正确处理大文件。
需要处理可能的错误和异常情况,并提供用户友好的错误提示。
通过这些步骤,你应该能够在 Web 上调用 soffice 命令并将 PPTX 文件转换为 PDF。如果遇到具体问题,请提供详细的错误信息,以便更好地帮助你解决问题。
减少 WebAssembly 文件的体积是一个常见的需求,尤其是在处理像 LibreOffice 这样的大型项目时。如果你只需要特定的功能(如将文件转换为 PDF),可以采取以下几种方法来减小生成的 WebAssembly 文件的体积:
1. 精简功能模块
确保只编译你需要的功能模块。你已经在 autogen.input 中指定了 --with-main-module=writer,这会限制编译的模块,但你可以进一步精简。
2. 移除不必要的组件
在 autogen.input 中,进一步禁用不必要的组件和功能。例如:
--disable-dbus
--disable-odk
--disable-postgresql-sdbc
--disable-firebird-sdbc
--disable-coinmp
--disable-cve-tests
--disable-gtk3
--disable-gstreamer-1-0
--disable-kf5
--disable-scripting-beanshell
--disable-scripting-javascript
--disable-extensions
--disable-epm
--disable-online-update
--disable-python
--disable-pdfimport
--disable-lpsolve
--disable-sdremote
--disable-scripting
--disable-avmedia
--disable-coinmp
--disable-report-builder
--disable-odk
--disable-pch
--disable-skia
--disable-scripting
1. 使用 Emscripten 优化选项
Emscripten 提供了多种优化选项,可以帮助减小生成的 WebAssembly 文件的体积:
使用 -O3 或 -Oz 进行最大化优化。
使用 --closure 1 进行 JavaScript 代码的闭包优化。
使用 --llvm-lto 3 进行链接时间优化。
在编译时,确保使用这些选项:
emconfigure ./configure ...
emmake make CXXFLAGS="-O3 --llvm-lto 3" LDFLAGS="-O3 --llvm-lto 3" 4. 移除调试信息
确保移除所有调试信息,这可以显著减小文件体积:
emconfigure ./configure ...
emmake make CXXFLAGS="-O3 --llvm-lto 3 -g0" LDFLAGS="-O3 --llvm-lto 3 -g0" 5. 使用 WebAssembly Minifier
使用 WebAssembly Minifier 工具(如 wasm-opt)进一步优化和减小生成的 WebAssembly 文件:
wasm-opt -Oz -o optimized.wasm original.wasm 6. 自定义构建脚本
如果上述方法仍然不能满足需求,可以考虑编写自定义构建脚本,只包含必要的源文件和库,进一步精简生成的 WebAssembly 文件。
7. 动态加载模块
如果可能,将一些功能模块拆分为单独的 WebAssembly 文件,并在需要时动态加载,以减少初始加载的体积。
示例 autogen.input
以下是一个示例 autogen.input 文件,结合了上述建议:
```sh
--disable-debug
--enable-sal-log
--disable-crashdump
--host=wasm32-local-emscripten
--disable-gui
--with-main-module=writer
--with-package-format=emscripten
--disable-dbus
--disable-odk
--disable-postgresql-sdbc
--disable-firebird-sdbc
--disable-coinmp
--disable-cve-tests
--disable-gtk3
--disable-gstreamer-1-0
--disable-kf5
--disable-scripting-beanshell
--disable-scripting-javascript
--disable-extensions
--disable-epm
--disable-online-update
--disable-python
--disable-pdfimport
--disable-lpsolve
--disable-sdremote
--disable-scripting
--disable-avmedia
--disable-coinmp
--disable-report-builder
--disable-odk
--disable-pch
--disable-skia
--disable-scripting
```
通过这些步骤,你应该能够显著减小生成的 WebAssembly 文件的体积,同时保留将文件转换为 PDF 的功能。如果遇到具体问题,请提供详细的错误信息,以便更好地帮助你解决问题。
wasm-opt 是 Binaryen 项目的一部分,它是一个用于优化和处理 WebAssembly 二进制文件的工具。你可以在 Ubuntu 上通过以下步骤安装和使用 wasm-opt:
1. 安装依赖项
首先,确保你的系统上已经安装了必要的依赖项:
sudo apt update
sudo apt install cmake build-essential git 2. 下载和编译 Binaryen
接下来,从源码编译 Binaryen:
# 克隆 Binaryen 仓库
git clone https://github.com/WebAssembly/binaryen.git
cd binaryen
# 创建并进入构建目录
mkdir build
cd build
# 使用 CMake 配置构建
cmake ..
# 编译 Binaryen
make
# 安装 Binaryen
sudo make install 3. 使用 wasm-opt
安装完成后,你可以使用 wasm-opt 命令来优化 WebAssembly 文件。例如:
# 假设你有一个名为 original.wasm 的 WebAssembly 文件
wasm-opt -Oz -o optimized.wasm original.wasm
在这个命令中:
-Oz 表示进行最大化的尺寸优化。
-o optimized.wasm 指定输出文件名为 optimized.wasm。
original.wasm 是输入的 WebAssembly 文件。4. 验证安装
你可以通过运行以下命令来验证 wasm-opt 是否正确安装:
wasm-opt --version
这将输出 wasm-opt 的版本信息,确认它已成功安装并可以使用。
示例
假设你有一个名为 example.wasm 的 WebAssembly 文件,你可以通过以下命令优化它:
wasm-opt -Oz -o example_optimized.wasm example.wasm
这将生成一个优化后的 WebAssembly 文件 example_optimized.wasm,其体积通常会显著减小。
通过这些步骤,你应该能够在 Ubuntu 上成功安装和使用 wasm-opt 来优化你的 WebAssembly 文件。如果在安装或使用过程中遇到任何问题,请提供详细的错误信息,以便进一步帮助你解决问题。
--disable-debug
--enable-sal-log
--disable-crashdump
--host=wasm32-local-emscripten
--disable-gui
--with-main-module=writer
--with-package-format=emscripten
--disable-dbus
--disable-odk
--disable-postgresql-sdbc
--disable-firebird-sdbc
--disable-coinmp
--disable-cve-tests
--disable-gtk3
--disable-gstreamer-1-0
--disable-kf5
--disable-scripting-beanshell
--disable-scripting-javascript
--disable-extensions
--disable-epm
--disable-online-update
--disable-python
--disable-pdfimport
--disable-lpsolve
--disable-sdremote
--disable-scripting
--disable-avmedia
--disable-coinmp
--disable-report-builder
--disable-odk
--disable-pch
--disable-skia
--disable-scripting
步骤 1:下载最新的 OpenSSL 源代码
首先,确保你在一个干净的目录中,然后下载最新版本的 OpenSSL 源代码。你可以访问 OpenSSL 官方网站 查看最新版本。假设最新版本是 openssl-3.0.9:
wget https://www.openssl.org/source/openssl-3.0.9.tar.gz
tar -xzvf openssl-3.0.9.tar.gz
cd openssl-3.0.9
步骤 2:编译和安装 OpenSSL
接下来,编译并安装 OpenSSL:
./config --prefix=/usr/local/ssl --openssldir=/usr/local/ssl shared zlib
make
make test
make install
步骤 3:确认 OpenSSL 安装路径
确保 OpenSSL 的头文件和库文件已正确安装:
ls /usr/local/ssl/include
ls /usr/local/ssl/lib
步骤 4:设置环境变量
设置正确的环境变量,以便编译时能找到 OpenSSL:
export PKG_CONFIG_PATH="/usr/local/ssl/lib/pkgconfig"
export CFLAGS="-I/usr/local/ssl/include"
export LDFLAGS="-L/usr/local/ssl/lib -lssl -lcrypto"
步骤 5:清理之前的配置
在重新运行 configure 脚本之前,清理之前的配置:
make clean
步骤 6:重新运行 configure 脚本
重新运行 configure 脚本,以确保它能找到新的 OpenSSL 版本:
emconfigure ./configure --disable-debug --enable-sal-log --disable-crashdump --host=wasm32-local-emscripten --disable-gui --with-main-module=writer --with-package-format=emscripten --disable-dbus --disable-odk --disable-postgresql-sdbc --disable-firebird-sdbc --disable-coinmp --disable-cve-tests --disable-gtk3 --disable-gstreamer-1-0 --disable-kf5 --disable-scripting-beanshell --disable-scripting-javascript --disable-extensions --disable-epm --disable-online-update --disable-python --disable-pdfimport --disable-lpsolve --disable-sdremote --disable-scripting --disable-avmedia --disable-coinmp --disable-report-builder --disable-odk --disable-pch --disable-skia --disable-scripting --srcdir=/home/core --enable-option-checking=fatal --disable-pthreads
步骤 7:检查 config.log
如果配置仍然失败,请检查 config.log 文件以获取更多详细信息:
cat config.log | grep openssl
通过确保 OpenSSL 已正确安装并设置正确的环境变量,我们应该能够解决配置过程中遇到的问题。如果问题仍然存在,请提供 config.log 文件中与 OpenSSL 相关的更多详细信息,以便进一步诊断和解决问题。
emconfigure ./configure --disable-debug --enable-sal-log --disable-crashdump --host=wasm32-local-emscripten --disable-gui --with-main-module=writer --with-package-format=emscripten --disable-dbus --disable-odk --disable-postgresql-sdbc --disable-firebird-sdbc --disable-coinmp --disable-cve-tests --disable-gtk3 --disable-gstreamer-1-0 --disable-kf5 --disable-scripting-beanshell --disable-scripting-javascript --disable-extensions --disable-epm --disable-online-update --disable-python --disable-pdfimport --disable-lpsolve --disable-sdremote --disable-scripting --disable-avmedia --disable-coinmp --disable-report-builder --disable-odk --disable-pch --disable-skia --disable-scripting --srcdir=/home/core --enable-option-checking=fatal
emmake make CXXFLAGS="-I/usr/local/ssl/include -O3 -g0 -msimd128" LDFLAGS="-L/usr/local/ssl/lib -lssl -lcrypto -O3 -g0"
emconfigure ./configure --disable-cups --disable-dbus --without-system-fontconfig --with-system-zlib --disable-dynamic-loading --disable-gui CXXFLAGS=-std=c++20 --host=wasm32-unknown-emscripten
root@8cb3480a4441:/home/core/instsetoo_native# vim CustomTarget_emscripten-install.mk
使用 file_packager.py 工具将文件预加载到虚拟文件系统中。确保 Emscripten 的环境变量已经正确设置,然后运行以下命令:
python3 /home/emsdk/upstream/emscripten/tools/file_packager.py preload.data --preload /home/core/instdir/share@/instdir/share --js-output=preload.js
================================================================
# https://ran.chaxus.com/cn/src/ranui/button/
================================================================
---
description: 'ranui Button()用于触发即时操作,支持多种类型、尺寸、加载与禁用状态,是跨框架的原生 Web Component。'
---
# Button 按钮
按钮组件用于触发一个即时操作,支持多种样式和状态。
> **适用场景**:需要一个开箱即用、自带 primary/contrast/warning/text 样式以及禁用、图标支持的可点击操作控件时——用 `` 代替手写 `